import * as errors from "@superbuilders/errors"
import * as logger from "@superbuilders/slog"
import { z } from "zod"
import type { WidgetGenerator } from "@/widgets/types"
import { CanvasImpl } from "@/widgets/utils/canvas-impl"
import { PADDING } from "@/widgets/utils/constants"
import { selectAxisLabels } from "@/widgets/utils/layout"
import { createHeightSchema, createWidthSchema } from "@/widgets/utils/schemas"
import { type Theme, theme } from "@/widgets/utils/theme"
export const NumberLineWithActionPropsSchema = z
.object({
type: z
.literal("numberLineWithAction")
.describe(
"Identifies this as a number line with action arrows showing addition/subtraction operations."
),
width: createWidthSchema(),
height: createHeightSchema(),
orientation: z
.enum(["horizontal", "vertical"])
.describe(
"Direction of the number line. 'horizontal' is left-to-right, 'vertical' is bottom-to-top. Affects layout and arrow directions."
),
min: z
.number()
.describe(
"Minimum value shown on the number line (e.g., -10, 0, -5). Should be less than startValue - sum of negative deltas."
),
max: z
.number()
.describe(
"Maximum value shown on the number line (e.g., 20, 10, 15). Should be greater than startValue + sum of positive deltas."
),
tickInterval: z
.discriminatedUnion("type", [
z
.object({
type: z.literal("whole"),
interval: z
.number()
.positive()
.describe(
"Whole number interval between major ticks (e.g., 1, 2, 5, 10)"
)
})
.strict(),
z
.object({
type: z.literal("fraction"),
numerator: z.literal(1).describe("Always 1 for unit fractions"),
denominator: z
.number()
.int()
.positive()
.describe(
"Denominator for unit fractions (e.g., 5 for fifths, 7 for sevenths)"
)
})
.strict()
])
.describe(
"Major tick interval specification with full-length ticks and labels"
),
secondaryTickInterval: z
.discriminatedUnion("type", [
z
.object({
type: z.literal("whole"),
interval: z
.number()
.positive()
.describe(
"Whole number interval between minor ticks (e.g., 0.5, 1, 2.5)"
)
})
.strict(),
z
.object({
type: z.literal("fraction"),
numerator: z.literal(1).describe("Always 1 for unit fractions"),
denominator: z
.number()
.int()
.positive()
.describe(
"Denominator for unit fractions (e.g., 10 for tenths, 20 for twentieths)"
)
})
.strict()
])
.nullable()
.describe(
"Optional minor tick interval with half-length ticks and no labels"
),
startValue: z
.number()
.describe(
"The initial position before any actions (e.g., 5, 0, -2). Marked with a distinct point. Actions begin from here."
),
actions: z
.array(
z
.object({
delta: z
.discriminatedUnion("type", [
z
.object({
type: z.literal("whole").describe("Whole number change"),
value: z
.number()
.int()
.describe("Integer value for the change, e.g. -3, 0, 5"),
sign: z
.enum(["+", "-"])
.describe("Sign of the whole number change")
})
.strict(),
z
.object({
type: z
.literal("fraction")
.describe("Proper or improper fraction change"),
numerator: z
.number()
.int()
.min(0)
.describe("Numerator (non-negative)"),
denominator: z
.number()
.int()
.positive()
.describe("Denominator (positive)"),
sign: z
.enum(["+", "-"])
.describe("Sign of the fraction change")
})
.strict(),
z
.object({
type: z
.literal("mixed")
.describe("Mixed number change like 1 1/4"),
whole: z
.number()
.int()
.min(0)
.describe("Whole part (non-negative)"),
numerator: z
.number()
.int()
.min(0)
.describe("Numerator of the fractional part"),
denominator: z
.number()
.int()
.positive()
.describe(
"Denominator of the fractional part (positive)"
),
sign: z
.enum(["+", "-"])
.describe("Sign of the mixed number change")
})
.strict()
])
.describe(
"The change amount for this action. Supports numeric, fractional, or mixed number values with explicit signs. Positive for addition/forward, negative for subtraction/backward."
)
})
.strict()
)
.describe(
"Sequence of operations shown as curved arrows. Applied in order from startValue. Multiple actions create multi-step problems (e.g., 5 + 3 - 2)."
)
})
.strict()
.describe(
"Creates an interactive number line showing arithmetic operations as curved 'jump' arrows. Perfect for teaching addition/subtraction concepts, multi-step problems, and integer operations. Supports multiple sequential actions to show complex calculations step-by-step."
)
export type NumberLineWithActionProps = z.infer<
typeof NumberLineWithActionPropsSchema
>
type ActionDelta = z.infer<
typeof NumberLineWithActionPropsSchema
>["actions"][number]["delta"]
/**
* Calculates the numerical value of an action's delta.
*/
const toNumericDelta = (delta: ActionDelta): number => {
if (delta.type === "whole") {
return delta.sign === "-" ? -delta.value : delta.value
}
if (delta.type === "fraction") {
const magnitude =
delta.denominator === 0 ? 0 : delta.numerator / delta.denominator
return delta.sign === "-" ? -magnitude : magnitude
}
// mixed
const total =
delta.whole +
(delta.denominator === 0 ? 0 : delta.numerator / delta.denominator)
return delta.sign === "-" ? -total : total
}
/**
* Generates the display label for an action's delta.
*/
const toDeltaLabel = (delta: ActionDelta): string => {
if (delta.type === "whole") {
const sign = delta.sign === "-" ? "-" : "+"
return `${sign}${delta.value}`
}
if (delta.type === "fraction") {
const sign = delta.sign === "-" ? "-" : "+"
return `${sign}${delta.numerator}/${delta.denominator}`
}
// mixed
const sign = delta.sign === "-" ? "-" : "+"
return `${sign}${delta.whole} ${delta.numerator}/${delta.denominator}`
}
/**
* Formats a numeric value as a fraction when appropriate.
*/
const formatPointLabel = (value: number, min: number, max: number): string => {
if (Number.isInteger(value)) {
return value.toString()
}
// For unit intervals [0,1], try to represent as simple fractions
if (min === 0 && max === 1) {
// Try common denominators
const denominators = [2, 3, 4, 5, 6, 7, 8, 9, 10, 12]
for (const den of denominators) {
const num = Math.round(value * den)
if (Math.abs(value - num / den) < 1e-10) {
if (num === 0) return "0"
if (num === den) return "1"
// Simplify the fraction
const gcd = (a: number, b: number): number =>
b === 0 ? a : gcd(b, a % b)
const g = gcd(num, den)
return `${num / g}/${den / g}`
}
}
}
// Fallback to decimal with limited precision
return value.toFixed(2).replace(/\.?0+$/, "")
}
/**
* Creates an interactive number line showing arithmetic operations as curved 'jump' arrows.
* Perfect for teaching addition/subtraction concepts, multi-step problems, and integer operations.
* Supports multiple sequential actions to show complex calculations step-by-step.
*/
// Helper function to draw a dotted line with clipping around label areas
function drawClippedDottedLine(
canvas: CanvasImpl,
x1: number,
y1: number,
x2: number,
y2: number,
labelBounds: Array<{ x: number; y: number; width: number; height: number }>,
theme: Theme
) {
// Check if the line intersects with any label bounds
const lineIntersections: Array<{ start: number; end: number }> = []
// For vertical lines (x1 === x2)
if (Math.abs(x1 - x2) < 0.1) {
const lineX = x1
const minY = Math.min(y1, y2)
const maxY = Math.max(y1, y2)
for (const bound of labelBounds) {
// Check if vertical line intersects with label rectangle
if (lineX >= bound.x && lineX <= bound.x + bound.width) {
const intersectionStart = Math.max(minY, bound.y)
const intersectionEnd = Math.min(maxY, bound.y + bound.height)
if (intersectionStart < intersectionEnd) {
lineIntersections.push({
start: intersectionStart,
end: intersectionEnd
})
}
}
}
}
// For horizontal lines (y1 === y2)
else if (Math.abs(y1 - y2) < 0.1) {
const lineY = y1
const minX = Math.min(x1, x2)
const maxX = Math.max(x1, x2)
for (const bound of labelBounds) {
// Check if horizontal line intersects with label rectangle
if (lineY >= bound.y && lineY <= bound.y + bound.height) {
const intersectionStart = Math.max(minX, bound.x)
const intersectionEnd = Math.min(maxX, bound.x + bound.width)
if (intersectionStart < intersectionEnd) {
lineIntersections.push({
start: intersectionStart,
end: intersectionEnd
})
}
}
}
}
// If no intersections, draw the full line
if (lineIntersections.length === 0) {
canvas.drawLine(x1, y1, x2, y2, {
stroke: theme.colors.actionPrimary,
strokeWidth: theme.stroke.width.thin,
dash: "2,2",
opacity: 0.6
})
return
}
// Sort intersections and draw line segments between them
lineIntersections.sort((a, b) => a.start - b.start)
// For vertical lines
if (Math.abs(x1 - x2) < 0.1) {
const lineX = x1
const minY = Math.min(y1, y2)
const maxY = Math.max(y1, y2)
let currentY = minY
for (const intersection of lineIntersections) {
// Draw segment before intersection
if (currentY < intersection.start) {
canvas.drawLine(lineX, currentY, lineX, intersection.start, {
stroke: theme.colors.actionPrimary,
strokeWidth: theme.stroke.width.thin,
dash: "2,2",
opacity: 0.6
})
}
currentY = Math.max(currentY, intersection.end)
}
// Draw final segment after last intersection
if (currentY < maxY) {
canvas.drawLine(lineX, currentY, lineX, maxY, {
stroke: theme.colors.actionPrimary,
strokeWidth: theme.stroke.width.thin,
dash: "2,2",
opacity: 0.6
})
}
}
// For horizontal lines
else {
const lineY = y1
const minX = Math.min(x1, x2)
const maxX = Math.max(x1, x2)
let currentX = minX
for (const intersection of lineIntersections) {
// Draw segment before intersection
if (currentX < intersection.start) {
canvas.drawLine(currentX, lineY, intersection.start, lineY, {
stroke: theme.colors.actionPrimary,
strokeWidth: theme.stroke.width.thin,
dash: "2,2",
opacity: 0.6
})
}
currentX = Math.max(currentX, intersection.end)
}
// Draw final segment after last intersection
if (currentX < maxX) {
canvas.drawLine(currentX, lineY, maxX, lineY, {
stroke: theme.colors.actionPrimary,
strokeWidth: theme.stroke.width.thin,
dash: "2,2",
opacity: 0.6
})
}
}
}
export const generateNumberLineWithAction: WidgetGenerator<
typeof NumberLineWithActionPropsSchema
> = async (data) => {
const {
width,
height,
orientation,
min,
max,
tickInterval,
secondaryTickInterval,
startValue,
actions
} = data
const isHorizontal = orientation === "horizontal"
const lineLength = (isHorizontal ? width : height) - 2 * PADDING
if (min >= max) return ``
// Validate that start value and all action points stay within bounds
if (startValue < min || startValue > max) {
logger.error("start value outside bounds", {
startValue,
min,
max
})
throw errors.new("start value outside bounds")
}
let currentValue = startValue
for (let i = 0; i < actions.length; i++) {
const action = actions[i]
if (!action) continue
const numericDelta = toNumericDelta(action.delta)
currentValue += numericDelta
if (currentValue < min || currentValue > max) {
logger.error("action results in value outside bounds", {
actionIndex: i + 1,
delta: numericDelta,
resultValue: currentValue,
min,
max
})
throw errors.new("action results in value outside bounds")
}
}
const scale = lineLength / (max - min)
// Helper function to generate tick values and labels
const generateTicks = (
tickSpec: typeof tickInterval
): { values: number[]; labels: string[] } => {
const values: number[] = []
const labels: string[] = []
if (tickSpec.type === "whole") {
// Generate ticks at whole number intervals
const interval = tickSpec.interval
const startTick = Math.ceil(min / interval) * interval
for (let value = startTick; value <= max; value += interval) {
if (value >= min) {
values.push(value)
labels.push(
Number.isInteger(value) ? value.toString() : value.toFixed(2)
)
}
}
} else if (tickSpec.type === "fraction") {
// Generate ticks at fractional intervals (1/denominator)
const denominator = tickSpec.denominator
const interval = 1 / denominator
const numTicks = Math.floor((max - min) / interval) + 1
for (let i = 0; i <= numTicks; i++) {
const value = min + i * interval
if (value <= max + 1e-10) {
// Add small epsilon for floating point precision
values.push(value)
// Generate proper fractional labels
if (Number.isInteger(value)) {
labels.push(value.toString())
} else {
// For unit intervals [0,1], show as simple fractions
if (min === 0 && max === 1) {
labels.push(`${i}/${denominator}`)
} else {
// For other ranges, show fractional representation
const wholePart = Math.floor(value)
const fracPart = value - wholePart
const fracNumerator = Math.round(fracPart * denominator)
if (fracNumerator === 0) {
labels.push(wholePart.toString())
} else if (wholePart === 0) {
labels.push(`${fracNumerator}/${denominator}`)
} else {
labels.push(`${wholePart} ${fracNumerator}/${denominator}`)
}
}
}
}
}
}
return { values, labels }
}
// Generate major ticks
const { values: tickValues, labels: tickLabels } = generateTicks(tickInterval)
// Generate secondary ticks if specified
const secondaryTickValues: number[] = secondaryTickInterval
? generateTicks(secondaryTickInterval).values
: []
const canvas = new CanvasImpl({
chartArea: { left: 0, top: 0, width, height },
fontPxDefault: 12,
lineHeightDefault: 1.2
})
canvas.addDef(
``
)
if (isHorizontal) {
const yPos = height / 2
const toSvgX = (val: number) => PADDING + (val - min) * scale
// Axis and Ticks
canvas.drawLine(PADDING, yPos, width - PADDING, yPos, {
stroke: theme.colors.axis,
strokeWidth: theme.stroke.width.thick
})
const values = tickValues
const labels = tickLabels
const tickPositions = values.map(toSvgX)
// Check if start value coincides with any tick and force it to have a label
const startValueAtTick = tickValues.some(
(tick) => Math.abs(tick - startValue) < 1e-10
)
// Smart label selection to prevent overlaps, but force start value to be labeled
const selectedLabels = selectAxisLabels({
labels: labels,
positions: tickPositions,
axisLengthPx: lineLength,
orientation: "horizontal",
fontPx: theme.font.size.small,
minGapPx: 8
})
// If start value is at a tick position, force it to be selected
if (startValueAtTick) {
const startTickIndex = tickValues.findIndex(
(tick) => Math.abs(tick - startValue) < 1e-10
)
if (startTickIndex >= 0) {
selectedLabels.add(startTickIndex)
}
}
// Draw major ticks with full length and labels
values.forEach((t, i) => {
const x = toSvgX(t)
canvas.drawLine(x, yPos - 5, x, yPos + 5, {
stroke: theme.colors.axis,
strokeWidth: theme.stroke.width.base
})
if (selectedLabels.has(i)) {
canvas.drawText({
x: x,
y: yPos + 20,
text: labels[i] ?? "",
fill: theme.colors.axis,
anchor: "middle",
fontPx: theme.font.size.small
})
}
})
// Draw secondary ticks with half length and no labels, BUT promote to primary if it's the start value
for (const t of secondaryTickValues) {
// Skip secondary ticks that overlap with major ticks
const isOverlapping = tickValues.some(
(majorTick) => Math.abs(majorTick - t) < 1e-10
)
if (!isOverlapping) {
const x = toSvgX(t)
const isStartValue = Math.abs(t - startValue) < 1e-10
if (isStartValue) {
// Keep secondary tick size, but add label
canvas.drawLine(x, yPos - 2.5, x, yPos + 2.5, {
stroke: theme.colors.axis,
strokeWidth: theme.stroke.width.thin
})
const startLabel = formatPointLabel(startValue, min, max)
canvas.drawText({
x: x,
y: yPos + 20,
text: startLabel,
fill: theme.colors.axis,
anchor: "middle",
fontPx: theme.font.size.small
})
} else {
// Regular secondary tick
canvas.drawLine(x, yPos - 2.5, x, yPos + 2.5, {
stroke: theme.colors.axis,
strokeWidth: theme.stroke.width.thin
})
}
}
}
// Start value marker (no separate label - uses tick label instead)
const startX = toSvgX(startValue)
canvas.drawCircle(startX, yPos, theme.geometry.pointRadius.small, {
fill: theme.colors.actionPrimary,
stroke: theme.colors.actionPrimary,
strokeWidth: theme.stroke.width.thin
})
// Action arrows - sequential with stacked labels
let currentValue = startValue
const labelBounds: Array<{
x: number
y: number
width: number
height: number
}> = []
// First pass: collect label bounds
for (let i = 0; i < actions.length; i++) {
const action = actions[i]
if (!action) continue
const numericDelta = toNumericDelta(action.delta)
const actionLabel = toDeltaLabel(action.delta)
const actionStartX = toSvgX(currentValue)
const actionEndX = toSvgX(currentValue + numericDelta)
const midX = (actionStartX + actionEndX) / 2
const baseOffset = 30
const arrowSpacing = 25
const arrowY = yPos - baseOffset - i * arrowSpacing
// Estimate label dimensions with tight bounds (server-safe approximation)
const labelWidth = actionLabel.length * theme.font.size.small * 0.6
const labelHeight = theme.font.size.small
const padding = 2 // Minimal padding for tighter clipping
labelBounds.push({
x: midX - labelWidth / 2 - padding,
y: arrowY - 8 - labelHeight / 2 - padding,
width: labelWidth + 2 * padding,
height: labelHeight + 2 * padding
})
currentValue += numericDelta
}
// Second pass: draw arrows and clipped dotted lines
currentValue = startValue
for (let i = 0; i < actions.length; i++) {
const action = actions[i]
if (!action) continue
const numericDelta = toNumericDelta(action.delta)
const actionStartX = toSvgX(currentValue)
const actionEndX = toSvgX(currentValue + numericDelta)
const midX = (actionStartX + actionEndX) / 2
// Stack arrows vertically with better spacing
const baseOffset = 30
const arrowSpacing = 25
const arrowY = yPos - baseOffset - i * arrowSpacing
// Draw completely horizontal arrow
canvas.drawLine(actionStartX, arrowY, actionEndX, arrowY, {
stroke: theme.colors.actionPrimary,
strokeWidth: theme.stroke.width.base,
markerEnd: "url(#action-arrow)"
})
// Draw dotted lines with clipping around labels
drawClippedDottedLine(
canvas,
actionStartX,
arrowY,
actionStartX,
yPos,
labelBounds,
theme
)
drawClippedDottedLine(
canvas,
actionEndX,
arrowY,
actionEndX,
yPos,
labelBounds,
theme
)
// Arrow label with better spacing
const actionLabel = toDeltaLabel(action.delta)
canvas.drawText({
x: midX,
y: arrowY - 8,
text: actionLabel,
fill: theme.colors.actionPrimary,
anchor: "middle",
fontPx: theme.font.size.small,
fontWeight: theme.font.weight.bold
})
currentValue += numericDelta
}
// Final value marker
const finalX = toSvgX(currentValue)
canvas.drawCircle(finalX, yPos, theme.geometry.pointRadius.small, {
fill: theme.colors.actionSecondary,
stroke: theme.colors.actionSecondary,
strokeWidth: theme.stroke.width.thin
})
} else {
// Vertical orientation
const xPos = width / 2
const toSvgY = (val: number) => height - PADDING - (val - min) * scale
// Axis and Ticks
canvas.drawLine(xPos, PADDING, xPos, height - PADDING, {
stroke: theme.colors.axis,
strokeWidth: theme.stroke.width.thick
})
const values = tickValues
const labels = tickLabels
const tickPositions = values.map(toSvgY)
// Check if start value coincides with any tick and force it to have a label
const startValueAtTick = tickValues.some(
(tick) => Math.abs(tick - startValue) < 1e-10
)
// Smart label selection to prevent overlaps, but force start value to be labeled
const selectedLabels = selectAxisLabels({
labels: labels,
positions: tickPositions,
axisLengthPx: lineLength,
orientation: "vertical",
fontPx: theme.font.size.small,
minGapPx: 12
})
// If start value is at a tick position, force it to be selected
if (startValueAtTick) {
const startTickIndex = tickValues.findIndex(
(tick) => Math.abs(tick - startValue) < 1e-10
)
if (startTickIndex >= 0) {
selectedLabels.add(startTickIndex)
}
}
// Draw major ticks with full length and labels
values.forEach((t, i) => {
const y = toSvgY(t)
canvas.drawLine(xPos - 5, y, xPos + 5, y, {
stroke: theme.colors.axis,
strokeWidth: theme.stroke.width.base
})
const labelX = xPos - 10
if (selectedLabels.has(i)) {
canvas.drawText({
x: labelX,
y: y + 4,
text: labels[i] ?? "",
fill: theme.colors.axis,
anchor: "end",
fontPx: theme.font.size.small
})
}
})
// Draw secondary ticks with half length and no labels, BUT promote to primary if it's the start value
for (const t of secondaryTickValues) {
// Skip secondary ticks that overlap with major ticks
const isOverlapping = tickValues.some(
(majorTick) => Math.abs(majorTick - t) < 1e-10
)
if (!isOverlapping) {
const y = toSvgY(t)
const isStartValue = Math.abs(t - startValue) < 1e-10
if (isStartValue) {
// Keep secondary tick size, but add label
canvas.drawLine(xPos - 2.5, y, xPos + 2.5, y, {
stroke: theme.colors.axis,
strokeWidth: theme.stroke.width.thin
})
const startLabel = formatPointLabel(startValue, min, max)
const labelX = xPos - 10
canvas.drawText({
x: labelX,
y: y + 4,
text: startLabel,
fill: theme.colors.axis,
anchor: "end",
fontPx: theme.font.size.small
})
} else {
// Regular secondary tick
canvas.drawLine(xPos - 2.5, y, xPos + 2.5, y, {
stroke: theme.colors.axis,
strokeWidth: theme.stroke.width.thin
})
}
}
}
// Start value marker (no separate label - uses tick label instead)
const startY = toSvgY(startValue)
canvas.drawCircle(xPos, startY, theme.geometry.pointRadius.small, {
fill: theme.colors.actionPrimary,
stroke: theme.colors.actionPrimary,
strokeWidth: theme.stroke.width.thin
})
// Action arrows - sequential with stacked labels
let currentValue = startValue
const labelBounds: Array<{
x: number
y: number
width: number
height: number
}> = []
// First pass: collect label bounds for vertical layout
for (let i = 0; i < actions.length; i++) {
const action = actions[i]
if (!action) continue
const numericDelta = toNumericDelta(action.delta)
const actionLabel = toDeltaLabel(action.delta)
const actionStartY = toSvgY(currentValue)
const actionEndY = toSvgY(currentValue + numericDelta)
const midY = (actionStartY + actionEndY) / 2
const baseOffset = 30
const arrowSpacing = 32 // Reduced from 40 to 32 for tighter spacing
const arrowX = xPos + baseOffset + i * arrowSpacing
const labelX = arrowX + 18 // Reduced from 25 to 18 for better balance
// For rotated text (-90 degrees), calculate accurate bounding box
const textWidth = actionLabel.length * theme.font.size.small * 0.6 // original text width
const textHeight = theme.font.size.small // original text height
const padding = 3 // Adequate padding for proper clipping
// For -90 degree rotation around (labelX, midY), use heavily asymmetric clipping
// Text visually extends more to the left, so we need much more padding on the left
const leftPadding = padding + 4 // Much more padding on the left where text appears
const rightPadding = 0 // No padding on the right - preserve maximum dotted line
const topPadding = padding
const bottomPadding = padding
labelBounds.push({
x: labelX - textHeight / 2 - leftPadding, // more space on left
y: midY - textWidth / 2 - topPadding, // top edge
width: textHeight + leftPadding + rightPadding, // asymmetric width
height: textWidth + topPadding + bottomPadding // symmetric height
})
currentValue += numericDelta
}
// Second pass: draw arrows and clipped dotted lines
currentValue = startValue
for (let i = 0; i < actions.length; i++) {
const action = actions[i]
if (!action) continue
const numericDelta = toNumericDelta(action.delta)
const actionStartY = toSvgY(currentValue)
const actionEndY = toSvgY(currentValue + numericDelta)
const midY = (actionStartY + actionEndY) / 2
// Stack arrows horizontally with better spacing
const baseOffset = 30
const arrowSpacing = 32 // Reduced from 40 to 32 for tighter spacing
const arrowX = xPos + baseOffset + i * arrowSpacing
// Draw completely vertical arrow
canvas.drawLine(arrowX, actionStartY, arrowX, actionEndY, {
stroke: theme.colors.actionPrimary,
strokeWidth: theme.stroke.width.base,
markerEnd: "url(#action-arrow)"
})
// Draw dotted lines with clipping around labels
drawClippedDottedLine(
canvas,
arrowX,
actionStartY,
xPos,
actionStartY,
labelBounds,
theme
)
drawClippedDottedLine(
canvas,
arrowX,
actionEndY,
xPos,
actionEndY,
labelBounds,
theme
)
// Arrow label (rotated for vertical layout) with better spacing
const actionLabel = toDeltaLabel(action.delta)
const labelX = arrowX + 18 // Reduced from 25 to 18 for better balance
canvas.drawText({
x: labelX,
y: midY,
text: actionLabel,
fill: theme.colors.actionPrimary,
anchor: "middle",
fontPx: theme.font.size.small,
fontWeight: theme.font.weight.bold,
rotate: { angle: -90, cx: labelX, cy: midY }
})
currentValue += numericDelta
}
// Final value marker
const finalY = toSvgY(currentValue)
canvas.drawCircle(xPos, finalY, theme.geometry.pointRadius.small, {
fill: theme.colors.actionSecondary,
stroke: theme.colors.actionSecondary,
strokeWidth: theme.stroke.width.thin
})
}
// NEW: Finalize the canvas and construct the root SVG element
const {
svgBody,
vbMinX,
vbMinY,
width: finalWidth,
height: finalHeight
} = canvas.finalize(PADDING)
return ``
}