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 { Path2D } from "@/widgets/utils/path-builder" import { createHeightSchema, createWidthSchema } from "@/widgets/utils/schemas" import { estimateWrappedTextDimensions } from "@/widgets/utils/text" import { theme } from "@/widgets/utils/theme" const ErrInvalidBounds = errors.new("gridBounds min must be less than max") const ErrLineNotPerpendicular = errors.new("lines are not perpendicular") // Regex to enforce a 'line_' prefix for type safety const lineIdRegex = /^line_[A-Za-z0-9_]+$/ // Schema for a line in the diagram const LineSchema = z .object({ id: z .string() .regex(lineIdRegex) .describe( "Unique identifier for this line with required 'line_' prefix. Examples: 'line_r', 'line_AB', 'line_perpendicular'. Used for referencing in perpendicular indicators." ), from: z .object({ x: z.number(), y: z.number() }) .strict() .describe( "First point defining the infinite line in grid coordinates. The line extends infinitely in both directions through this point and the 'to' point." ), to: z .object({ x: z.number(), y: z.number() }) .strict() .describe( "Second point defining the infinite line in grid coordinates. Combined with 'from' point, determines the line's direction and slope." ), style: z .enum(["solid", "dotted"]) .describe( "Visual style of the line. 'solid' for standard lines, 'dotted' for auxiliary or construction lines that appear dashed." ), label: z .string() .nullable() .transform((val) => val === "null" || val === "NULL" || val === "" ? null : val ) .describe( "Optional text label for the line. Examples: 'r', 's', 'm', 'AB', 'perpendicular'. Set to null for unlabeled lines." ), labelPosition: z .enum(["start", "middle", "end"]) .describe( "Position of the label along the visible portion of the line. 'start' places near beginning, 'middle' at center, 'end' near the end of the visible line segment." ), color: z .string() .regex(CSS_COLOR_PATTERN, "invalid css color") .describe( "CSS color for both the line and its label. Examples: '#FF0000' (red), '#0066CC' (blue), '#00AA00' (green). Use distinct colors for different lines." ) }) .strict() // Schema for a perpendicular indicator const PerpendicularIndicatorSchema = z .object({ line1Id: z .string() .regex(lineIdRegex) .describe( "ID of the first line forming the perpendicular relationship. Must match an existing line's ID from the lines array." ), line2Id: z .string() .regex(lineIdRegex) .describe( "ID of the second line forming the perpendicular relationship. Must match an existing line's ID from the lines array. The two lines must be geometrically perpendicular." ), size: z .number() .positive() .describe( "Size of the right angle square marker in pixels. Typical values: 15-25px. Larger values are more visible but may overlap with other elements." ), color: z .string() .regex(CSS_COLOR_PATTERN, "invalid css color") .describe( "CSS color for the perpendicular square indicator. Examples: '#000000' (black), '#666666' (gray). Should contrast with background and lines." ) }) .strict() // Main schema for the LineDiagram widget export const LineDiagramPropsSchema = z .object({ type: z .literal("lineDiagram") .describe( "Widget type identifier for line geometry diagrams with infinite lines and perpendicular relationships." ), width: createWidthSchema(), height: createHeightSchema(), gridBounds: z .object({ minX: z.number(), maxX: z.number(), minY: z.number(), maxY: z.number() }) .strict() .describe( "Logical coordinate boundaries for the grid system. Examples: {minX: -5, maxX: 5, minY: -5, maxY: 5} creates an 11×11 grid. Lines extend to these boundaries and are clipped at the edges." ), lines: z .array(LineSchema) .describe( "Array of infinite lines to display. Each line is defined by two points but extends infinitely in both directions within the grid bounds. Lines can intersect and have perpendicular relationships." ), perpendicularIndicators: z .array(PerpendicularIndicatorSchema) .describe( "Array of right angle markers to show perpendicular relationships between lines. Each indicator draws a small square at the intersection point of two perpendicular lines." ) }) .strict() .describe( "Creates geometric line diagrams showing infinite lines on a coordinate grid. Perfect for demonstrating line relationships, intersections, and perpendicular concepts. Lines extend infinitely within the grid bounds with automatic collision-avoiding label placement. Supports right angle indicators to highlight perpendicular relationships." ) type Point = { x: number; y: number } /** * Finds the intersection point of two infinite lines. * Returns null if lines are parallel. */ const findLineIntersection = ( l1p1: Point, l1p2: Point, l2p1: Point, l2p2: Point ): Point | null => { const a1 = l1p2.y - l1p1.y const b1 = l1p1.x - l1p2.x const c1 = a1 * l1p1.x + b1 * l1p1.y const a2 = l2p2.y - l2p1.y const b2 = l2p1.x - l2p2.x const c2 = a2 * l2p1.x + b2 * l2p1.y const determinant = a1 * b2 - a2 * b1 if (Math.abs(determinant) < 1e-9) { return null // Parallel } return { x: (b2 * c1 - b1 * c2) / determinant, y: (a1 * c2 - a2 * c1) / determinant } } /** * Clips an infinite line defined by two points to a bounding box. * Returns the two intersection points with the box edges. */ const clipLineToBox = ( p1: Point, p2: Point, box: { minX: number; maxX: number; minY: number; maxY: number } ) => { const { minX, maxX, minY, maxY } = box const dx = p2.x - p1.x const dy = p2.y - p1.y const intersections: Point[] = [] if (Math.abs(dx) > 1e-9) { const m = dy / dx const c = p1.y - m * p1.x // Check left edge let y = m * minX + c if (y >= minY && y <= maxY) intersections.push({ x: minX, y }) // Check right edge y = m * maxX + c if (y >= minY && y <= maxY) intersections.push({ x: maxX, y }) } if (Math.abs(dy) > 1e-9) { const mInv = dx / dy const c = p1.x - mInv * p1.y // Check bottom edge let x = mInv * minY + c if (x >= minX && x <= maxX) intersections.push({ x, y: minY }) // Check top edge x = mInv * maxY + c if (x >= minX && x <= maxX) intersections.push({ x, y: maxY }) } return intersections.length >= 2 ? { p1: intersections[0], p2: intersections[1] } : null } /** * Generates an SVG diagram of lines on a grid, with optional perpendicular indicators. */ export const generateLineDiagram: WidgetGenerator< typeof LineDiagramPropsSchema > = async (props) => { const { width, height, gridBounds, lines, perpendicularIndicators } = props if ( gridBounds.minX >= gridBounds.maxX || gridBounds.minY >= gridBounds.maxY ) { logger.error("invalid grid bounds for line diagram", { gridBounds }) throw errors.wrap( ErrInvalidBounds, "min must be less than max for grid bounds" ) } const canvas = new CanvasImpl({ chartArea: { left: 0, top: 0, width, height }, fontPxDefault: 12, lineHeightDefault: 1.2 }) const gridWidth = gridBounds.maxX - gridBounds.minX const gridHeight = gridBounds.maxY - gridBounds.minY const chartWidth = width - 2 * PADDING const chartHeight = height - 2 * PADDING const scaleX = chartWidth / gridWidth const scaleY = chartHeight / gridHeight const toSvgX = (gridX: number) => PADDING + (gridX - gridBounds.minX) * scaleX const toSvgY = (gridY: number) => PADDING + (gridBounds.maxY - gridY) * scaleY // Y is inverted in SVG // Draw Grid for ( let y = Math.ceil(gridBounds.minY); y <= Math.floor(gridBounds.maxY); y++ ) { canvas.drawLine( toSvgX(gridBounds.minX), toSvgY(y), toSvgX(gridBounds.maxX), toSvgY(y), { stroke: theme.colors.gridMinor, strokeWidth: 1 } ) } for ( let x = Math.ceil(gridBounds.minX); x <= Math.floor(gridBounds.maxX); x++ ) { canvas.drawLine( toSvgX(x), toSvgY(gridBounds.minY), toSvgX(x), toSvgY(gridBounds.maxY), { stroke: theme.colors.gridMinor, strokeWidth: 1 } ) } // Draw Lines and Labels const lineMap = new Map(lines.map((line) => [line.id, line])) // Keep track of placed text labels to avoid text-to-text collisions const placedLabels: Array<{ x: number y: number width: number height: number padding: number }> = [] // Draw Perpendicular Indicators UNDER the lines for better aesthetics for (const indicator of perpendicularIndicators) { const line1 = lineMap.get(indicator.line1Id) const line2 = lineMap.get(indicator.line2Id) if (!line1 || !line2) continue const v1 = { x: line1.to.x - line1.from.x, y: line1.to.y - line1.from.y } const v2 = { x: line2.to.x - line2.from.x, y: line2.to.y - line2.from.y } const dotProduct = v1.x * v2.x + v1.y * v2.y if (Math.abs(dotProduct) > 1e-6) { logger.error("lines are not perpendicular", { line1Id: line1.id, line2Id: line2.id, dotProduct }) throw errors.wrap( ErrLineNotPerpendicular, `Lines '${line1.id}' and '${line2.id}' are not perpendicular.` ) } const intersection = findLineIntersection( line1.from, line1.to, line2.from, line2.to ) if (!intersection) continue const svgIntersection = { x: toSvgX(intersection.x), y: toSvgY(intersection.y) } const len1 = Math.hypot(v1.x, v1.y) const u1 = { x: v1.x / len1, y: v1.y / len1 } const len2 = Math.hypot(v2.x, v2.y) const u2 = { x: v2.x / len2, y: v2.y / len2 } const svg_u1 = { x: u1.x * scaleX, y: -u1.y * scaleY } const svg_u2 = { x: u2.x * scaleX, y: -u2.y * scaleY } // Normalize screen vectors so size is in pixels const u1LenPx = Math.hypot(svg_u1.x, svg_u1.y) || 1 const u2LenPx = Math.hypot(svg_u2.x, svg_u2.y) || 1 const n1 = { x: svg_u1.x / u1LenPx, y: svg_u1.y / u1LenPx } const n2 = { x: svg_u2.x / u2LenPx, y: svg_u2.y / u2LenPx } const markerSize = indicator.size const p0 = { x: svgIntersection.x, y: svgIntersection.y } const p1 = { x: p0.x + n1.x * markerSize, y: p0.y + n1.y * markerSize } const p2 = { x: p1.x + n2.x * markerSize, y: p1.y + n2.y * markerSize } const p3 = { x: p0.x + n2.x * markerSize, y: p0.y + n2.y * markerSize } const path = new Path2D() .moveTo(p0.x, p0.y) .lineTo(p1.x, p1.y) .lineTo(p2.x, p2.y) .lineTo(p3.x, p3.y) .closePath() canvas.drawPath(path, { fill: "none", stroke: indicator.color, strokeWidth: theme.stroke.width.thick }) } // Keep a list of screen-space segments for collision checks when placing labels const screenSegments: Array<{ a: { x: number; y: number } b: { x: number; y: number } }> = [] for (const line of lines) { const extendedPoints = clipLineToBox(line.from, line.to, gridBounds) if (!extendedPoints) continue const { p1, p2 } = extendedPoints const svgP1 = { x: toSvgX(p1.x), y: toSvgY(p1.y) } const svgP2 = { x: toSvgX(p2.x), y: toSvgY(p2.y) } const dash = line.style === "dotted" ? "5 5" : undefined // Define a per-color arrowhead so marker fill matches the line color, and make it slightly smaller const markerId = `arrow-head-${line.color.replace(/[^a-zA-Z0-9]/g, "_")}` canvas.addDef( `` ) canvas.drawLine(svgP1.x, svgP1.y, svgP2.x, svgP2.y, { stroke: line.color, strokeWidth: 2.5, markerStart: `url(#${markerId})`, markerEnd: `url(#${markerId})`, dash }) // Track for collision checks screenSegments.push({ a: svgP1, b: svgP2 }) if (line.label) { let t = 0.5 if (line.labelPosition === "start") t = 0.1 if (line.labelPosition === "end") t = 0.9 const labelPoint = { x: p1.x + t * (p2.x - p1.x), y: p1.y + t * (p2.y - p1.y) } // Compute screen-space basis for this line const sx = toSvgX(labelPoint.x) const sy = toSvgY(labelPoint.y) const sdx = svgP2.x - svgP1.x const sdy = svgP2.y - svgP1.y const slen = Math.hypot(sdx, sdy) || 1 const nx = -sdy / slen const ny = sdx / slen const tx = sdx / slen const ty = sdy / slen // Start offset from the line, then slide tangentially if the label collides const baseOffset = 20 let lx = sx + nx * baseOffset let ly = sy + ny * baseOffset const fontPx = 18 const dims = estimateWrappedTextDimensions( line.label, Number.POSITIVE_INFINITY, fontPx, 1.2 ) const halfW = dims.maxWidth / 2 const halfH = dims.height / 2 function rectIntersectsAnySegmentOrText(rect: { x: number y: number width: number height: number pad?: number }): boolean { const pad = rect.pad ?? 0 const rx = rect.x - pad const ry = rect.y - pad const rw = rect.width + 2 * pad const rh = rect.height + 2 * pad // Check for text-to-text collisions first for (const placedLabel of placedLabels) { const labelPad = placedLabel.padding const lx = placedLabel.x - labelPad const ly = placedLabel.y - labelPad const lw = placedLabel.width + 2 * labelPad const lh = placedLabel.height + 2 * labelPad // Check if rectangles overlap (AABB collision) if (rx < lx + lw && rx + rw > lx && ry < ly + lh && ry + rh > ly) { return true } } // Check for text-to-line collisions const r1 = { x: rx, y: ry } const r2 = { x: rx + rw, y: ry } const r3 = { x: rx + rw, y: ry + rh } const r4 = { x: rx, y: ry + rh } const orient = ( p: { x: number; y: number }, q: { x: number; y: number }, r: { x: number; y: number } ) => { const val = (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y) if (val > 1e-9) return 1 if (val < -1e-9) return -1 return 0 } const segmentsIntersect = ( p1: { x: number; y: number }, p2: { x: number; y: number }, p3: { x: number; y: number }, p4: { x: number; y: number } ): boolean => { const o1 = orient(p1, p2, p3) const o2 = orient(p1, p2, p4) const o3 = orient(p3, p4, p1) const o4 = orient(p3, p4, p2) return o1 !== o2 && o3 !== o4 } for (const seg of screenSegments) { if ( segmentsIntersect(seg.a, seg.b, r1, r2) || segmentsIntersect(seg.a, seg.b, r2, r3) || segmentsIntersect(seg.a, seg.b, r3, r4) || segmentsIntersect(seg.a, seg.b, r4, r1) ) { return true } } return false } function placeTangential(mult: number) { let px = lx let py = ly let it = 0 const maxIt = 80 const step = 3 while ( rectIntersectsAnySegmentOrText({ x: px - halfW, y: py - halfH, width: dims.maxWidth, height: dims.height, pad: 5 }) && it < maxIt ) { px += mult * step * tx py += mult * step * ty it++ } return { x: px, y: py, it } } const cw = placeTangential(1) const ccw = placeTangential(-1) const chosen = ccw.it < cw.it ? ccw : cw canvas.drawText({ text: line.label, x: chosen.x, y: chosen.y, fill: line.color, fontPx: fontPx, fontWeight: theme.font.weight.bold, anchor: "middle", dominantBaseline: "middle" }) // Record this label's position to prevent future text-to-text collisions // Use a larger padding to ensure better separation between labels const labelPadding = 8 placedLabels.push({ x: chosen.x - halfW, y: chosen.y - halfH, width: dims.maxWidth, height: dims.height, padding: labelPadding }) } } // (Markers already drawn under the lines above) // no global arrowhead; per-line colored markers are defined above const { svgBody, vbMinX, vbMinY, width: finalWidth, height: finalHeight } = canvas.finalize(PADDING) return `${svgBody}` }