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 { theme } from "@/widgets/utils/theme"
// The main Zod schema for the new, unified widget.
export const QuantityFractionalDiagramPropsSchema = z
.object({
type: z
.literal("quantityFractionalDiagram")
.describe(
"Identifies this as a diagram for displaying fractional quantities (simple, mixed, or improper) using partitioned shapes."
),
width: createWidthSchema(),
height: createHeightSchema(),
shape: z
.discriminatedUnion("type", [
z.object({
type: z.literal("rectangle"),
rows: z
.number()
.int()
.positive()
.describe("Number of rows in the grid partition for each shape."),
columns: z
.number()
.int()
.positive()
.describe("Number of columns in the grid partition for each shape.")
}),
z.object({
type: z
.literal("circle")
.describe("A circular shape partitioned into equal sectors.")
}),
z.object({
type: z.literal("polygon"),
sides: z
.number()
.int()
.gte(3)
.describe(
"Number of sides for the regular polygon (e.g., 3 for triangle, 8 for octagon)."
),
rotation: z
.number()
.describe("Rotation angle in degrees to orient the polygon.")
})
])
.describe("The geometric shape used for all models in the diagram."),
numerator: z
.number()
.int()
.min(0)
.describe(
"The total numerator of the quantity (e.g., for 2 3/4, the total numerator is 11)."
),
denominator: z
.number()
.int()
.positive()
.describe(
"The denominator that defines the partitioning of each shape (e.g., for 2 3/4, this is 4)."
),
shadeColor: z
.string()
.regex(CSS_COLOR_PATTERN, "invalid css color")
.describe("The CSS hex color for the shaded parts."),
shapesPerRow: z
.number()
.int()
.positive()
.describe("How many shapes to display in each row before wrapping.")
})
.strict()
export type QuantityFractionalDiagramProps = z.infer<
typeof QuantityFractionalDiagramPropsSchema
>
type ShapeDefinition = z.infer<
typeof QuantityFractionalDiagramPropsSchema
>["shape"]
// Helper function to find the intersection of a ray from a center point with a polygon's edge
// Returns both the intersection point and the edge index that was intersected
function findRayPolygonIntersection(
p1: { x: number; y: number },
p2: { x: number; y: number },
polygonVertices: { x: number; y: number }[]
): { point: { x: number; y: number }; edgeIndex: number } | null {
for (let i = 0; i < polygonVertices.length; i++) {
const p3 = polygonVertices[i]
const p4 = polygonVertices[(i + 1) % polygonVertices.length]
if (!p3 || !p4) continue
const den = (p1.x - p2.x) * (p3.y - p4.y) - (p1.y - p2.y) * (p3.x - p4.x)
if (den === 0) continue
const t =
((p1.x - p3.x) * (p3.y - p4.y) - (p1.y - p3.y) * (p3.x - p4.x)) / den
const u =
-((p1.x - p2.x) * (p1.y - p3.y) - (p1.y - p2.y) * (p1.x - p3.x)) / den
if (t >= 0 && u >= 0 && u <= 1) {
return {
point: { x: p1.x + t * (p2.x - p1.x), y: p1.y + t * (p2.y - p1.y) },
edgeIndex: i
}
}
}
return null
}
/**
* Internal helper to draw a single partitioned model. This is self-contained and not exported.
*/
function drawSingleModel(
canvas: CanvasImpl,
shape: ShapeDefinition,
totalParts: number,
shadedParts: number,
shadeColor: string,
cx: number,
cy: number,
size: number
) {
const toRad = (deg: number) => (deg * Math.PI) / 180
switch (shape.type) {
case "rectangle": {
const { rows, columns } = shape
if (rows * columns !== totalParts) {
logger.error("rectangle dimension mismatch", {
rows,
columns,
totalParts,
calculatedParts: rows * columns
})
throw errors.new("rectangle rows * columns must equal totalParts")
}
const rectX = cx - size / 2
const rectY = cy - size / 2
const cellWidth = size / columns
const cellHeight = size / rows
for (let i = 0; i < shadedParts; i++) {
const row = Math.floor(i / columns)
const col = i % columns
canvas.drawRect(
rectX + col * cellWidth,
rectY + row * cellHeight,
cellWidth,
cellHeight,
{
fill: shadeColor
}
)
}
for (let r = 0; r <= rows; r++)
canvas.drawLine(
rectX,
rectY + r * cellHeight,
rectX + size,
rectY + r * cellHeight,
{
stroke: theme.colors.black,
strokeWidth:
r === 0 || r === rows
? theme.stroke.width.thick
: theme.stroke.width.thin
}
)
for (let c = 0; c <= columns; c++)
canvas.drawLine(
rectX + c * cellWidth,
rectY,
rectX + c * cellWidth,
rectY + size,
{
stroke: theme.colors.black,
strokeWidth:
c === 0 || c === columns
? theme.stroke.width.thick
: theme.stroke.width.thin
}
)
break
}
case "circle": {
const radius = size / 2
const angleStep = 360 / totalParts
for (let i = 0; i < totalParts; i++) {
const startAngle = -90 + i * angleStep
const endAngle = startAngle + angleStep
const startPoint = {
x: cx + radius * Math.cos(toRad(startAngle)),
y: cy + radius * Math.sin(toRad(startAngle))
}
const endPoint = {
x: cx + radius * Math.cos(toRad(endAngle)),
y: cy + radius * Math.sin(toRad(endAngle))
}
const path = new Path2D()
.moveTo(cx, cy)
.lineTo(startPoint.x, startPoint.y)
.arcTo(radius, radius, 0, 0, 1, endPoint.x, endPoint.y)
.closePath()
canvas.drawPath(path, {
fill: i < shadedParts ? shadeColor : "none",
stroke: theme.colors.black,
strokeWidth: theme.stroke.width.base
})
}
break
}
case "polygon": {
const { sides, rotation } = shape
const actualRotation = rotation ?? 0
const radius = size / 2
const angleOffset = toRad(-90 + actualRotation)
const vertices = Array.from({ length: sides }, (_, i) => {
const angle = (i / sides) * 2 * Math.PI + angleOffset
return {
x: cx + radius * Math.cos(angle),
y: cy + radius * Math.sin(angle)
}
})
const angleStep = 360 / totalParts
const baseAngle = -90 + actualRotation
for (let i = 0; i < totalParts; i++) {
const startAngle = baseAngle + i * angleStep
const endAngle = baseAngle + (i + 1) * angleStep
const rayStart = {
x: cx + Math.cos(toRad(startAngle)),
y: cy + Math.sin(toRad(startAngle))
}
const rayEnd = {
x: cx + Math.cos(toRad(endAngle)),
y: cy + Math.sin(toRad(endAngle))
}
const startHit = findRayPolygonIntersection(
{ x: cx, y: cy },
rayStart,
vertices
)
const endHit = findRayPolygonIntersection(
{ x: cx, y: cy },
rayEnd,
vertices
)
if (!startHit || !endHit) continue
const { point: startIntersect, edgeIndex: startEdge } = startHit
const { point: endIntersect, edgeIndex: endEdge } = endHit
const path = new Path2D()
.moveTo(cx, cy)
.lineTo(startIntersect.x, startIntersect.y)
// Walk along the polygon perimeter from the edge after startEdge up to endEdge
let edge = (startEdge + 1) % sides
while (edge !== (endEdge + 1) % sides) {
const v = vertices[edge]
if (!v) break
path.lineTo(v.x, v.y)
edge = (edge + 1) % sides
}
path.lineTo(endIntersect.x, endIntersect.y).closePath()
const isShaded = i < shadedParts
canvas.drawPath(path, {
fill: isShaded ? shadeColor : "none",
stroke: "none"
})
}
canvas.drawPolygon(vertices, {
fill: "none",
stroke: theme.colors.black,
strokeWidth: theme.stroke.width.thick
})
for (let i = 0; i < totalParts; i++) {
const angle = toRad(baseAngle + i * angleStep)
const p2 = {
x: cx + radius * 2 * Math.cos(angle),
y: cy + radius * 2 * Math.sin(angle)
}
const intersect = findRayPolygonIntersection(
{ x: cx, y: cy },
p2,
vertices
)
if (intersect) {
canvas.drawLine(cx, cy, intersect.point.x, intersect.point.y, {
stroke: theme.colors.black,
strokeWidth: theme.stroke.width.thin
})
}
}
break
}
}
}
export const generateQuantityFractionalDiagram: WidgetGenerator<
typeof QuantityFractionalDiagramPropsSchema
> = async (props) => {
const {
width,
height,
shape,
numerator,
denominator,
shadeColor,
shapesPerRow
} = props
const canvas = new CanvasImpl({
chartArea: { left: 0, top: 0, width, height },
fontPxDefault: 16,
lineHeightDefault: 1.2
})
const wholePart = Math.floor(numerator / denominator)
const fractionalNumerator = numerator % denominator
const totalShapes = wholePart + (fractionalNumerator > 0 ? 1 : 0)
if (totalShapes === 0) {
// Render a single, unshaded shape if the quantity is zero.
const shapeSize = Math.min(width, height) - PADDING * 2
drawSingleModel(
canvas,
shape,
denominator,
0,
shadeColor,
width / 2,
height / 2,
shapeSize
)
const {
svgBody,
vbMinX,
vbMinY,
width: finalWidth,
height: finalHeight
} = canvas.finalize(PADDING)
return ``
}
const numRows = Math.ceil(totalShapes / shapesPerRow)
const numCols = Math.min(totalShapes, shapesPerRow)
const gap = 15
const availableWidth = width - PADDING * 2 - (numCols - 1) * gap
const availableHeight = height - PADDING * 2 - (numRows - 1) * gap
const shapeSize = Math.min(
availableWidth / numCols,
availableHeight / numRows
)
const totalContentWidth = numCols * shapeSize + (numCols - 1) * gap
const totalContentHeight = numRows * shapeSize + (numRows - 1) * gap
const startX = (width - totalContentWidth) / 2
const startY = (height - totalContentHeight) / 2
for (let i = 0; i < totalShapes; i++) {
const row = Math.floor(i / shapesPerRow)
const col = i % shapesPerRow
const cx = startX + col * (shapeSize + gap) + shapeSize / 2
const cy = startY + row * (shapeSize + gap) + shapeSize / 2
const isWhole = i < wholePart
const shaded = isWhole ? denominator : fractionalNumerator
drawSingleModel(
canvas,
shape,
denominator,
shaded,
shadeColor,
cx,
cy,
shapeSize
)
}
const {
svgBody,
vbMinX,
vbMinY,
width: finalWidth,
height: finalHeight
} = canvas.finalize(PADDING)
return ``
}