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" function createRectSchema() { return z .object({ width: z .number() .positive() .describe( "Width of the rectangle in relative units (e.g., 4, 6, 2.5). Not pixels - scaled to fit display area." ), height: z .number() .positive() .describe( "Height of the rectangle in relative units (e.g., 3, 4, 1.5). Proportions matter more than absolute values." ) }) .strict() } function createGroupSchema() { return z .object({ label: z .string() .nullable() .transform((val) => val === "null" || val === "NULL" || val === "" ? null : val ) .describe( "Label for this shape transformation (e.g., 'Shape A', 'Rectangle 1', 'Original', null). Displayed as a header for the shape pair. Null for no label." ), before: createRectSchema().describe( "Dimensions of the original rectangle before transformation." ), after: createRectSchema().describe( "Dimensions of the rectangle after transformation. Compare proportions to 'before' to show scaling type." ), color: z .string() .regex( CSS_COLOR_PATTERN, "invalid css color; use hex (#RGB, #RRGGBB, #RRGGBBAA), rgb/rgba(), hsl/hsla(), or a common named color" ) .describe( "CSS fill color for both rectangles in this group (e.g., '#4472C4' for blue, 'orange', 'rgba(255,0,0,0.7)'). Distinguishes the two shape groups." ) }) .strict() } export const ScaleCopiesSliderPropsSchema = z .object({ type: z .literal("scaleCopiesSlider") .describe( "Identifies this as a scale copies comparison widget showing proportional vs non-proportional scaling." ), width: createWidthSchema(), height: createHeightSchema(), shapeA: createGroupSchema().describe( "First shape transformation, typically showing proportional scaling where width and height scale by same factor." ), shapeB: createGroupSchema().describe( "Second shape transformation, typically showing non-proportional scaling where dimensions scale differently." ) }) .strict() .describe( "Compares two rectangle transformations side-by-side to illustrate proportional (similar shapes) vs non-proportional scaling. Each group shows before/after rectangles. Essential for teaching similarity, scale factors, and distinguishing between scaling that preserves shape vs distortion." ) export type ScaleCopiesSliderProps = z.infer< typeof ScaleCopiesSliderPropsSchema > /** * Generates an SVG diagram to visually compare a proportional scaling * transformation against a non-proportional one. */ export const generateScaleCopiesSlider: WidgetGenerator< typeof ScaleCopiesSliderPropsSchema > = async (props) => { const { width, height, shapeA, shapeB } = props const padding = { top: PADDING * 1.5, right: PADDING, bottom: PADDING * 1.5, left: PADDING } const rowGap = 20 const colGap = 30 // Gap for the arrow // The available drawing area for each of the two main rows (Shape A and Shape B) const rowHeight = (height - padding.top - padding.bottom - rowGap) / 2 // The available drawing area for each shape (Before/After) within a row const shapeWidth = (width - padding.left - padding.right - colGap) / 2 // Find the maximum width and height across all four shapes to determine a universal scale factor const allShapes = [shapeA.before, shapeA.after, shapeB.before, shapeB.after] const maxWidth = Math.max(...allShapes.map((s) => s.width)) const maxHeight = Math.max(...allShapes.map((s) => s.height)) // Calculate scale to fit the largest shape within the allocated areas const scale = Math.min(shapeWidth / maxWidth, rowHeight / maxHeight) const canvas = new CanvasImpl({ chartArea: { left: 0, top: 0, width, height }, fontPxDefault: 12, lineHeightDefault: 1.2 }) canvas.addStyle( ".label { font-size: 14px; font-weight: bold; } .sub-label { font-size: 12px; fill: #555; }" ) /** * Helper function to draw a single row (e.g., for Shape A) containing * the "Before" shape, an arrow, and the "After" shape. */ const drawShapeGroup = ( shape: ScaleCopiesSliderProps["shapeA"], yOffset: number ): void => { // --- Before Shape --- const beforeW = shape.before.width * scale const beforeH = shape.before.height * scale const beforeX = padding.left + (shapeWidth - beforeW) / 2 // Center within its column const beforeY = yOffset + (rowHeight - beforeH) / 2 // Center within its row // Canvas automatically tracks extents canvas.drawRect(beforeX, beforeY, beforeW, beforeH, { fill: shape.color, stroke: theme.colors.axis, strokeWidth: theme.stroke.width.thin }) const beforeLabelX = padding.left + shapeWidth / 2 canvas.drawText({ x: beforeLabelX, y: yOffset + rowHeight + 15, text: "Before", anchor: "middle" }) // --- Arrow --- const arrowXStart = padding.left + shapeWidth + 5 const arrowXEnd = arrowXStart + colGap - 10 const arrowY = yOffset + rowHeight / 2 // add arrowhead marker to defs and draw a line using marker-end canvas.addDef( `` ) canvas.drawLine(arrowXStart, arrowY, arrowXEnd, arrowY, { stroke: theme.colors.axis, strokeWidth: theme.stroke.width.base, markerEnd: "url(#arrowhead)" }) // --- After Shape --- const afterW = shape.after.width * scale const afterH = shape.after.height * scale const afterX = padding.left + shapeWidth + colGap + (shapeWidth - afterW) / 2 // Center const afterY = yOffset + (rowHeight - afterH) / 2 // Center // Canvas automatically tracks extents canvas.drawRect(afterX, afterY, afterW, afterH, { fill: shape.color, stroke: theme.colors.axis, strokeWidth: theme.stroke.width.thin }) const afterLabelX = padding.left + shapeWidth + colGap + shapeWidth / 2 canvas.drawText({ x: afterLabelX, y: yOffset + rowHeight + 15, text: "After", anchor: "middle" }) // --- Main Row Label (only if label exists) --- if (shape.label !== null) { const labelX = width / 2 canvas.drawText({ x: labelX, y: yOffset - 8, text: shape.label, anchor: "middle" }) } } // Draw Shape A group in the top half drawShapeGroup(shapeA, padding.top) // Draw Shape B group in the bottom half const shapeB_Y_Offset = padding.top + rowHeight + rowGap drawShapeGroup(shapeB, shapeB_Y_Offset) // NEW: Finalize the canvas and construct the root SVG element const { svgBody, vbMinX, vbMinY, width: finalWidth, height: finalHeight } = canvas.finalize(PADDING) return `${svgBody}` }