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 { CSS_COLOR_PATTERN } from "@/widgets/utils/css-color"
import { createHeightSchema, createWidthSchema } from "@/widgets/utils/schemas"
import { theme } from "@/widgets/utils/theme"
export const ErrInvalidRange = errors.new("min must be less than max")
function toOpaqueHex(color: string): string {
const hex = color.toLowerCase()
// #RRGGBBAA -> #RRGGBB (strip alpha)
if (hex.length === 9) {
return hex.slice(0, 7)
}
// #RGB -> #RRGGBB (expand)
if (hex.length === 4) {
const r = hex[1]
const g = hex[2]
const b = hex[3]
return `#${r}${r}${g}${g}${b}${b}`
}
// #RRGGBB stays as-is
return hex
}
function createBoundarySchema() {
return z
.object({
value: z
.number()
.describe(
"The numerical value where this boundary occurs on the number line (e.g., 3, -2, 5.5, 0). Must be within min/max range."
),
type: z
.enum(["open", "closed"])
.describe(
"Boundary type. 'open' (exclusive) shows hollow circle for < or >, 'closed' (inclusive) shows filled circle for ≤ or ≥."
)
})
.strict()
}
function createStartSchema() {
return z.discriminatedUnion("type", [
z
.object({
type: z
.literal("bounded")
.describe("The range has a defined starting point."),
at: createBoundarySchema().describe(
"The starting boundary with its value and open/closed type."
)
})
.strict(),
z
.object({
type: z
.literal("unbounded")
.describe(
"The range extends infinitely to the left (negative infinity)."
)
})
.strict()
])
}
function createEndSchema() {
return z.discriminatedUnion("type", [
z
.object({
type: z
.literal("bounded")
.describe("The range has a defined ending point."),
at: createBoundarySchema().describe(
"The ending boundary with its value and open/closed type."
)
})
.strict(),
z
.object({
type: z
.literal("unbounded")
.describe(
"The range extends infinitely to the right (positive infinity)."
)
})
.strict()
])
}
function createRangeSchema() {
return z
.object({
start: createStartSchema().describe(
"The left boundary of the shaded region. Can be bounded (specific value) or unbounded (extends to -∞)."
),
end: createEndSchema().describe(
"The right boundary of the shaded region. Can be bounded (specific value) or unbounded (extends to +∞)."
),
color: z
.string()
.regex(
CSS_COLOR_PATTERN,
"invalid css color; use hex (#RGB, #RRGGBB, #RRGGBBAA)"
)
.describe(
"Hex-only color for the shaded region (e.g., '#4287F54D' for 30% alpha, '#FFE5B4'). Use translucency via 8-digit hex for overlapping ranges."
)
})
.strict()
}
export const InequalityNumberLinePropsSchema = z
.object({
type: z
.literal("inequalityNumberLine")
.describe(
"Identifies this as an inequality number line for visualizing solution sets."
),
width: createWidthSchema(),
height: createHeightSchema(),
min: z
.number()
.describe(
"Minimum value shown on the number line (e.g., -10, -5, 0). Should be less than smallest relevant boundary."
),
max: z
.number()
.describe(
"Maximum value shown on the number line (e.g., 10, 20, 15). Should be greater than largest relevant boundary."
),
tickInterval: z
.number()
.positive()
.describe(
"Spacing between tick marks (e.g., 1, 2, 0.5). Should evenly divide the range for clean appearance."
),
ranges: z
.array(createRangeSchema())
.describe(
"Solution ranges to shade on the number line. Can overlap for compound inequalities. Empty array shows blank number line. Order doesn't affect display."
)
})
.strict()
.describe(
"Creates number lines showing solution sets for inequalities with shaded regions, open/closed endpoints, and arrows for unbounded intervals. Essential for teaching inequality notation (x < 5, x ≥ -2), compound inequalities (3 < x ≤ 7), and solution set visualization. Supports multiple overlapping ranges."
)
export type InequalityNumberLineProps = z.infer<
typeof InequalityNumberLinePropsSchema
>
/**
* Generates an SVG number line to graph the solution set of single or compound inequalities,
* using open/closed circles and shaded regions to represent the solution.
*/
export const generateInequalityNumberLine: WidgetGenerator<
typeof InequalityNumberLinePropsSchema
> = async (data) => {
const { width, height, min, max, tickInterval, ranges } = data
const chartWidth = width - 2 * PADDING
const yPos = height / 2
if (min >= max) {
logger.error("inequality number line invalid range", { min, max })
throw errors.wrap(
ErrInvalidRange,
`min (${min}) must be less than max (${max})`
)
}
const scale = chartWidth / (max - min)
const toSvgX = (val: number) => PADDING + (val - min) * scale
const canvas = new CanvasImpl({
chartArea: { left: 0, top: 0, width, height },
fontPxDefault: 12,
lineHeightDefault: 1.2
})
// Define markers for arrows
canvas.addDef(
``
)
// Add colored arrow markers for ranges (use opaque colors to avoid showing axis beneath)
const uniqueColors = new Set(ranges.map((r) => toOpaqueHex(r.color)))
for (const color of uniqueColors) {
const colorId = color.replace(/[^a-zA-Z0-9]/g, "")
canvas.addDef(
``
)
}
// Axis and Ticks
canvas.drawLine(PADDING, yPos, width - PADDING, yPos, {
stroke: theme.colors.axis,
strokeWidth: theme.stroke.width.base,
markerStart: "url(#arrow)",
markerEnd: "url(#arrow)"
})
for (let t = min; t <= max; t += tickInterval) {
const x = toSvgX(t)
// Match tick sizing with standard number line widget for visual consistency
canvas.drawLine(x, yPos - 8, x, yPos + 8, {
stroke: theme.colors.axis,
strokeWidth: theme.stroke.width.base
})
canvas.drawText({
x: x,
y: yPos + 25,
text: String(t),
fill: theme.colors.axisLabel,
anchor: "middle"
})
}
for (const r of ranges) {
const lineColor = toOpaqueHex(r.color)
const startPos =
r.start.type === "bounded" ? toSvgX(r.start.at.value) : PADDING
const endPos =
r.end.type === "bounded" ? toSvgX(r.end.at.value) : width - PADDING
const colorId = lineColor.replace(/[^a-zA-Z0-9]/g, "")
// Add markers for unbounded cases
let markerStart: string | undefined
let markerEnd: string | undefined
if (r.start.type === "unbounded") {
markerStart = `url(#arrow-${colorId})`
}
if (r.end.type === "unbounded") {
markerEnd = `url(#arrow-${colorId})`
}
canvas.drawLine(startPos, yPos, endPos, yPos, {
stroke: lineColor,
strokeWidth: theme.stroke.width.xxxthick,
strokeLinecap: "butt",
markerStart: markerStart,
markerEnd: markerEnd
})
// Boundary circles
if (r.start.type === "bounded") {
const fill =
r.start.at.type === "closed" ? lineColor : theme.colors.background
canvas.drawCircle(startPos, yPos, 5, {
fill: fill,
stroke: lineColor,
strokeWidth: theme.stroke.width.base
})
}
if (r.end.type === "bounded") {
const fill = r.end.at.type === "closed" ? lineColor : theme.colors.white
canvas.drawCircle(endPos, yPos, 5, {
fill: fill,
stroke: lineColor,
strokeWidth: theme.stroke.width.base
})
}
}
// NEW: Finalize the canvas and construct the root SVG element
const {
svgBody,
vbMinX,
vbMinY,
width: finalWidth,
height: finalHeight
} = canvas.finalize(PADDING)
return ``
}