import { z } from "zod" import type { WidgetGenerator } from "@/widgets/types" import { theme } from "@/widgets/utils/theme" // Using factories to prevent Zod from creating schemas with $refs, which can cause issues with some tools. // Per-digit tokens to support digit-level highlighting and boxes const createDigitTokenSchema = () => z.discriminatedUnion("type", [ z.object({ type: z.literal("digit"), value: z.number(), color: z .string() .nullable() .transform((val) => val === "null" || val === "NULL" || val === "" ? null : val ) }), z.object({ type: z.literal("boxedDigit"), value: z.number(), color: z .string() .nullable() .transform((val) => val === "null" || val === "NULL" || val === "" ? null : val ) }), z.object({ type: z.literal("emptyBox") }), z.object({ type: z.literal("spacer") }) ]) // Note: do not export or reuse a single schema instance to avoid $ref generation type DigitToken = | { type: "digit"; value: number; color: string | null } | { type: "boxedDigit"; value: number; color: string | null } | { type: "emptyBox" } | { type: "spacer" } /** * Defines a value for the quotient row, which can either be a known numeric value * or a placeholder box indicating a missing number to be filled in. */ const createValueOrPlaceholderSchema = () => z .discriminatedUnion("type", [ z.object({ type: z.literal("value").describe("A known numeric value."), value: z .number() .describe("The numeric value to display (e.g., 10, 7)."), color: z .string() .nullable() .transform((val) => val === "null" || val === "NULL" || val === "" ? null : val ) .describe("Optional CSS color for this quotient part.") }), z.object({ type: z .literal("placeholder") .describe("An unknown value represented by an empty box.") }) ]) .describe("Represents either a known value or a placeholder box.") // (all value/placeholder typing is derived from Zod schemas) /** * Defines a single calculation step within a column of the area model, including * the numbers to be subtracted and the resulting difference. */ const createStepSchema = () => z .object({ subtrahend: z .number() .nullable() .describe( "The value being subtracted in this step (e.g., 30). Null to hide." ), difference: z .number() .nullable() .describe("The result of the subtraction (e.g., 21). Null to hide."), differenceColor: z .string() .nullable() .describe( "Optional CSS color for the difference text (e.g., 'red'). Null for default color." ), topTokens: z .array(createDigitTokenSchema()) .nullable() .describe("Optional digit tokens for top dividend row."), subtrahendTokens: z .array(createDigitTokenSchema()) .nullable() .describe("Optional digit tokens for subtrahend row."), differenceTokens: z .array(createDigitTokenSchema()) .nullable() .describe("Optional digit tokens for difference row."), rightPanel: z .object({ tokensTop: z.array(createDigitTokenSchema()).nullable(), minusTokens: z.array(createDigitTokenSchema()).nullable(), bottomTokens: z.array(createDigitTokenSchema()).nullable() }) .nullable() .describe("Optional auxiliary right-side panel with digit tokens") }) .strict() type Step = { subtrahend: number | null difference: number | null differenceColor: string | null topTokens: DigitToken[] | null subtrahendTokens: DigitToken[] | null differenceTokens: DigitToken[] | null rightPanel: { tokensTop: DigitToken[] | null minusTokens: DigitToken[] | null bottomTokens: DigitToken[] | null } | null } /** * Main Zod schema for the divisionAreaDiagram widget. */ export const DivisionAreaDiagramPropsSchema = z .object({ type: z.literal("divisionAreaDiagram"), divisor: z .number() .describe("The divisor, displayed on the left side of the model."), dividend: z .number() .describe("The initial dividend, displayed in the first column."), quotientParts: z .array(createValueOrPlaceholderSchema()) .describe("An array of quotient parts displayed across the top."), steps: z .array(createStepSchema()) .describe( "An array of calculation steps. Its length must match quotientParts." ), showFinalRemainderBox: z .boolean() .describe("If true, shows the final zero remainder with a box around it.") }) .strict() .describe( "Creates a visual area model for long division, showing partial quotients and step-by-step subtraction." ) export type DivisionAreaDiagramProps = z.infer< typeof DivisionAreaDiagramPropsSchema > /** * Generates an HTML table representing a division area model. */ export const generateDivisionAreaDiagram: WidgetGenerator< typeof DivisionAreaDiagramPropsSchema > = async (props) => { let { divisor, dividend, quotientParts, steps, showFinalRemainderBox } = props // Helper to render string content, optionally with a color. const renderContent = ( content: string | number | null, color?: string | null ): string => { if (content === null) return " " const text = typeof content === "number" ? String(content) : content // Simple HTML escaping for numbers/operators const escapedContent = text.replace(//g, ">") // Use tabular numbers to keep digits aligned uniformly const style = `${color ? `color: ${color};` : ""} font-variant-numeric: tabular-nums; letter-spacing: 0;` // Using a span to ensure styling is applied correctly. return `${escapedContent}` } const renderTokenSpan = ( token: DigitToken, opts?: { leftBorder?: boolean; rightBorder?: boolean } ): string => { const baseSpan = "display:inline-block; width: 16px; text-align:center; padding: 0; margin: 0; box-sizing: border-box;" if (token.type === "digit") { const color = token.color ? `color: ${token.color};` : "" return `${renderContent(token.value, token.color)}` } if (token.type === "boxedDigit") { const color = token.color ? `color: ${token.color};` : "" const left = opts?.leftBorder ? "border-left: 1.5px solid black;" : "border-left: 0;" const right = opts?.rightBorder ? "border-right: 1.5px solid black;" : "border-right: 0;" return `${renderContent(token.value, token.color)}` } if (token.type === "spacer") { return ` ` } // emptyBox return `` } // Inline tokens container to embed consistently in the right content cell const DIGIT_SLOT_PX = 16 const GRID_COLS = 3 const GRID_WIDTH_PX = DIGIT_SLOT_PX * GRID_COLS const DIFF_TOP_MARGIN_PX = 6 const UNDERLINE_TOP_GAP_PX = 4 const renderTokensGrid = (tokens: DigitToken[]): string => { const firstIdx = tokens.findIndex((t) => t.type === "boxedDigit") let lastIdx = -1 for (let i = tokens.length - 1; i >= 0; i--) { if (tokens[i].type === "boxedDigit") { lastIdx = i break } } return `
| ` for (const part of quotientParts) { const content = part.type === "value" ? renderContent(part.value) : renderPlaceholder() const style = "width: 80px; height: 40px; background-color: #EBF5FB; text-align:center; padding: 10px;" html += ` | ${content} | ` } html += "||||||||||||
| `
html += ` ${renderContent(divisor)} `
html += " | "
// Steps Cells
const stepsTyped: Step[] = steps
for (let index = 0; index < stepsTyped.length; index++) {
const step = stepsTyped[index]
const isFirst = index === 0
const isLast = index === steps.length - 1
const borderStyle = `border-top: 1.5px solid black; border-bottom: 1.5px solid black;${isFirst ? " border-left: 1.5px solid black;" : ""}${isLast ? " border-right: 1.5px solid black;" : ""}`
html += ``
// Inner table for this step's calculation
html += `
| "
// The difference of this step becomes the dividend of the next.
if (step.difference !== null) {
dividend = step.difference
}
}
html += "