import { z } from "zod"
import type { WidgetGenerator } from "@/widgets/types"
import { CanvasImpl } from "@/widgets/utils/canvas-impl"
import { PADDING } from "@/widgets/utils/constants"
import { Path2D } from "@/widgets/utils/path-builder"
import { createHeightSchema, createWidthSchema } from "@/widgets/utils/schemas"
import { theme } from "@/widgets/utils/theme"
// Defines a point with an optional label. Coordinates are calculated automatically.
const createPointDefinitionSchema = () =>
z
.object({
label: z
.string()
.nullable()
.describe(
"The text label to display next to the point (e.g., 'P', 'Q'). Null for no label."
)
})
.strict()
type Point = { x: number; y: number; label: string | null }
/**
* Creates a diagram to render fundamental geometric primitives. This widget can display
* a single point, a line segment between two points, a ray starting from a point, a line
* passing through two points, or a curved arc. It is designed for basic geometric
* illustrations where the focus is on the object and its labeled points rather than a
* full coordinate plane. The layout is determined by high-level properties like rotation
* and length, not explicit coordinates.
*/
export const GeometricPrimitiveDiagramPropsSchema = z
.object({
type: z.literal("geometricPrimitiveDiagram"),
width: createWidthSchema(),
height: createHeightSchema(),
primitive: z
.discriminatedUnion("type", [
z
.object({
type: z
.literal("point")
.describe(
"A single, isolated point, always centered in the diagram."
),
pointOne: createPointDefinitionSchema().describe(
"The point to be displayed."
)
})
.strict(),
z
.object({
type: z
.literal("segment")
.describe("A line segment with two defined endpoints."),
pointOne: createPointDefinitionSchema().describe(
"The starting point of the segment."
),
pointTwo: createPointDefinitionSchema().describe(
"The ending point of the segment."
),
rotation: z
.number()
.describe(
"The rotation of the segment in degrees. 0 is horizontal, 90 is vertical."
),
length: z
.number()
.positive()
.nullable()
.describe(
"The length of the segment in pixels. If not provided, it defaults to 60% of the canvas width."
)
})
.strict(),
z
.object({
type: z
.literal("ray")
.describe(
"A ray starting at one point and extending through another."
),
pointOne: createPointDefinitionSchema().describe(
"The starting point (endpoint) of the ray."
),
pointTwo: createPointDefinitionSchema().describe(
"A second point that defines the direction of the ray."
),
rotation: z
.number()
.describe(
"The rotation of the ray in degrees. 0 is horizontal, 90 is vertical."
),
length: z
.number()
.positive()
.nullable()
.describe(
"The distance in pixels between the start point and the direction point. Defaults to 60% of canvas width."
)
})
.strict(),
z
.object({
type: z
.literal("line")
.describe(
"An infinite line passing through two points, with arrowheads on both ends."
),
pointOne: createPointDefinitionSchema().describe(
"A first point that the line passes through."
),
pointTwo: createPointDefinitionSchema().describe(
"A second point that the line passes through."
),
rotation: z
.number()
.describe("The rotation of the line in degrees."),
length: z
.number()
.positive()
.nullable()
.describe(
"The distance in pixels between the two defined points on the line. Defaults to 60% of canvas width."
)
})
.strict(),
z
.object({
type: z.literal("arc").describe("A curved arc segment."),
pointOne: createPointDefinitionSchema().describe(
"The starting point of the arc."
),
pointTwo: createPointDefinitionSchema().describe(
"The ending point of the arc."
),
rotation: z
.number()
.describe("The rotation of the baseline of the arc in degrees."),
length: z
.number()
.positive()
.nullable()
.describe(
"The distance in pixels between the arc's endpoints. Defaults to 60% of canvas width."
),
bulge: z
.number()
.describe(
"Controls the curvature. 0 is a straight line, positive values bow 'up', negative values bow 'down' relative to the rotation."
)
})
.strict()
])
.describe("The geometric primitive to be rendered in the diagram.")
})
.strict()
export type GeometricPrimitiveDiagramProps = z.infer<
typeof GeometricPrimitiveDiagramPropsSchema
>
/**
* Helper to draw a point marker and its label.
* @param canvas The canvas implementation to draw on.
* @param point The point's data including calculated coordinates.
* @param awayFrom An optional second point to position the label away from.
*/
const drawPointAndLabel = (
canvas: CanvasImpl,
point: Point,
awayFrom?: Point
) => {
canvas.drawCircle(point.x, point.y, theme.geometry.pointRadius.base, {
fill: theme.colors.black
})
if (point.label) {
let labelX = point.x + 10
let labelY = point.y - 10
if (awayFrom) {
const dx = point.x - awayFrom.x
const dy = point.y - awayFrom.y
const dist = Math.hypot(dx, dy)
if (dist > 0) {
const offsetX = (dx / dist) * 18
const offsetY = (dy / dist) * 18
labelX = point.x + offsetX
labelY = point.y + offsetY
}
}
canvas.drawText({
x: labelX,
y: labelY,
text: point.label,
fill: theme.colors.text,
fontPx: theme.font.size.large,
fontWeight: theme.font.weight.bold,
anchor: "middle",
dominantBaseline: "middle"
})
}
}
/**
* Extends a line defined by two points to the boundaries of a given box.
*/
const extendLineToBox = (
p1: Point,
p2: Point,
box: { x: number; y: number; width: number; height: number }
): { start: Point; end: Point } | null => {
const dx = p2.x - p1.x
const dy = p2.y - p1.y
if (dx === 0 && dy === 0) return null
const intersections: Point[] = []
const checkIntersection = (
x1: number,
y1: number,
x2: number,
y2: number
) => {
const sx = x2 - x1
const sy = y2 - y1
const den = dx * sy - dy * sx
if (den === 0) return
const rx = x1 - p1.x
const ry = y1 - p1.y
const t = (rx * sy - ry * sx) / den // parameter along infinite line through p1->p2
const s = (rx * dy - ry * dx) / den // parameter along segment [0,1]
if (s >= 0 && s <= 1) {
intersections.push({ x: p1.x + t * dx, y: p1.y + t * dy, label: null })
}
}
checkIntersection(box.x, box.y, box.x + box.width, box.y) // Top
checkIntersection(box.x, box.y, box.x, box.y + box.height) // Left
checkIntersection(
box.x + box.width,
box.y,
box.x + box.width,
box.y + box.height
) // Right
checkIntersection(
box.x,
box.y + box.height,
box.x + box.width,
box.y + box.height
) // Bottom
if (intersections.length < 2) return null
// Sort intersections by distance from p1 to ensure consistent start/end
intersections.sort((a, b) => {
const distA = Math.hypot(a.x - p1.x, a.y - p1.y)
const distB = Math.hypot(b.x - p1.x, b.y - p1.y)
return distA - distB
})
const start: Point = intersections[0]
const end: Point = intersections[intersections.length - 1]
return { start, end }
}
/**
* For rays: extend from p1 toward p2 until the box boundary, ensuring directionality matches p2.
*/
const extendRayToBoxEnd = (
p1: Point,
p2: Point,
box: { x: number; y: number; width: number; height: number }
): Point | null => {
const line = extendLineToBox(p1, p2, box)
if (!line) return null
const dirX = p2.x - p1.x
const dirY = p2.y - p1.y
const toStartX = line.start.x - p1.x
const toStartY = line.start.y - p1.y
const toEndX = line.end.x - p1.x
const toEndY = line.end.y - p1.y
const dotStart = dirX * toStartX + dirY * toStartY
const dotEnd = dirX * toEndX + dirY * toEndY
// Choose the intersection that lies in the forward direction from p1 toward p2 (positive dot)
if (dotStart >= 0 && dotEnd >= 0) {
// Both forward; choose the farther one (larger projection)
return dotEnd >= dotStart ? line.end : line.start
}
if (dotStart >= 0) return line.start
if (dotEnd >= 0) return line.end
// If neither is forward (degenerate), fall back to the farther endpoint
const distStart = Math.hypot(toStartX, toStartY)
const distEnd = Math.hypot(toEndX, toEndY)
return distEnd >= distStart ? line.end : line.start
}
/**
* Generates an SVG diagram for a single geometric primitive.
*/
export const generateGeometricPrimitiveDiagram: WidgetGenerator<
typeof GeometricPrimitiveDiagramPropsSchema
> = async (props) => {
const { width, height, primitive } = props
const canvas = new CanvasImpl({
chartArea: { left: 0, top: 0, width, height },
fontPxDefault: 12,
lineHeightDefault: 1.2
})
const BBOX = {
x: PADDING,
y: PADDING,
width: width - 2 * PADDING,
height: height - 2 * PADDING
}
const ARROW_ID = "primitive-arrow"
const degToRad = (deg: number) => (deg * Math.PI) / 180
canvas.addDef(
``
)
const cx = width / 2
const cy = height / 2
switch (primitive.type) {
case "point": {
const p1: Point = { x: cx, y: cy, label: primitive.pointOne.label }
drawPointAndLabel(canvas, p1)
break
}
case "segment":
case "ray":
case "line":
case "arc": {
const length = primitive.length ?? Math.min(width, height) * 0.6
const rotationRad = degToRad(primitive.rotation)
const halfLength = length / 2
const p1: Point = {
x: cx - halfLength * Math.cos(rotationRad),
y: cy - halfLength * Math.sin(rotationRad),
label: primitive.pointOne.label
}
const p2: Point = {
x: cx + halfLength * Math.cos(rotationRad),
y: cy + halfLength * Math.sin(rotationRad),
label: primitive.pointTwo.label
}
if (primitive.type === "segment") {
canvas.drawLine(p1.x, p1.y, p2.x, p2.y, {
stroke: theme.colors.black,
strokeWidth: theme.stroke.width.thick
})
} else if (primitive.type === "ray") {
const rayEnd = extendRayToBoxEnd(p1, p2, BBOX)
if (rayEnd) {
canvas.drawLine(p1.x, p1.y, rayEnd.x, rayEnd.y, {
stroke: theme.colors.black,
strokeWidth: theme.stroke.width.thick,
markerEnd: `url(#${ARROW_ID})`
})
}
} else if (primitive.type === "line") {
const extended = extendLineToBox(p1, p2, BBOX)
if (extended) {
canvas.drawLine(
extended.start.x,
extended.start.y,
extended.end.x,
extended.end.y,
{
stroke: theme.colors.black,
strokeWidth: theme.stroke.width.thick,
markerStart: `url(#${ARROW_ID})`,
markerEnd: `url(#${ARROW_ID})`
}
)
}
} else if (primitive.type === "arc") {
const midX = (p1.x + p2.x) / 2
const midY = (p1.y + p2.y) / 2
// Perpendicular vector for bulge
const perpX = -(p2.y - p1.y)
const perpY = p2.x - p1.x
const bulgeAmount = length * primitive.bulge * 0.5
const controlX = midX + (perpX / length) * bulgeAmount
const controlY = midY + (perpY / length) * bulgeAmount
const path = new Path2D()
.moveTo(p1.x, p1.y)
.quadraticCurveTo(controlX, controlY, p2.x, p2.y)
canvas.drawPath(path, {
stroke: theme.colors.black,
strokeWidth: theme.stroke.width.thick,
fill: "none"
})
}
// Draw points and labels on top for all linear/arc types
drawPointAndLabel(canvas, p1, p2)
drawPointAndLabel(canvas, p2, p1)
break
}
}
const {
svgBody,
vbMinX,
vbMinY,
width: finalWidth,
height: finalHeight
} = canvas.finalize(PADDING)
return ``
}