diff --git a/apps/web/src/app/create/worksheets/addition/typstHelpers.ts b/apps/web/src/app/create/worksheets/addition/typstHelpers.ts index 34948de4..99b4220b 100644 --- a/apps/web/src/app/create/worksheets/addition/typstHelpers.ts +++ b/apps/web/src/app/create/worksheets/addition/typstHelpers.ts @@ -18,10 +18,13 @@ export interface DisplayOptions { */ export function generateTypstHelpers(cellSize: number): string { return String.raw` -// Place value colors (light pastels) -#let color-ones = rgb(227, 242, 253) // Light blue -#let color-tens = rgb(232, 245, 233) // Light green -#let color-hundreds = rgb(255, 249, 196) // Light yellow +// Place value colors (light pastels) - unique color per place value +#let color-ones = rgb(227, 242, 253) // Light blue (ones) +#let color-tens = rgb(232, 245, 233) // Light green (tens) +#let color-hundreds = rgb(255, 249, 196) // Light yellow (hundreds) +#let color-thousands = rgb(255, 228, 225) // Light pink/rose (thousands) +#let color-ten-thousands = rgb(243, 229, 245) // Light purple/lavender (ten-thousands) +#let color-hundred-thousands = rgb(255, 239, 213) // Light peach/orange (hundred-thousands) #let color-none = white // No color // Ten-frame helper - stacked 2 frames vertically, sized to fit cell width @@ -93,92 +96,201 @@ export function generateTypstHelpers(cellSize: number): string { * Generate Typst function for rendering problem stack/grid * This is the SINGLE SOURCE OF TRUTH for problem rendering layout * Used by both full worksheets and preview examples + * + * @param cellSize Size of each digit cell in inches + * @param maxDigits Maximum number of digits in any problem on this page (1-6) */ -export function generateProblemStackFunction(cellSize: number): string { +export function generateProblemStackFunction(cellSize: number, maxDigits: number = 3): string { const cellSizeIn = `${cellSize}in` const cellSizePt = cellSize * 72 + // Generate place value color assignments (unique color per place value) + // Index 0 = ones, 1 = tens, 2 = hundreds, 3 = thousands, 4 = ten-thousands, 5 = hundred-thousands + const placeColors = [ + 'color-ones', // 0: ones (light blue) + 'color-tens', // 1: tens (light green) + 'color-hundreds', // 2: hundreds (light yellow) + 'color-thousands', // 3: thousands (light pink/rose) + 'color-ten-thousands', // 4: ten-thousands (light purple/lavender) + 'color-hundred-thousands', // 5: hundred-thousands (light peach/orange) + ] + return String.raw` -// Problem rendering function for addition worksheets -// Returns the stack/grid structure for rendering a single 2-digit addition problem +// Problem rendering function for addition worksheets (supports 1-${maxDigits} digit problems) +// Returns the stack/grid structure for rendering a single addition problem // Per-problem display flags: show-carries, show-answers, show-colors, show-ten-frames, show-numbers -#let problem-stack(a, b, aT, aO, bT, bO, index-or-none, show-carries, show-answers, show-colors, show-ten-frames, show-numbers) = { +#let problem-stack(a, b, index-or-none, show-carries, show-answers, show-colors, show-ten-frames, show-numbers) = { + // Place value colors array for dynamic lookup (index 0 = ones, 1 = tens, ...) + let place-colors = (${placeColors.join(', ')}) + + // Extract digits dynamically based on problem size + let max-digits = ${maxDigits} + // Allow one extra digit for potential overflow (e.g., 99999 + 99999 = 199998) + let max-extraction = max-digits + 1 + let a-digits = () + let b-digits = () + let temp-a = a + let temp-b = b + + // Extract digits from right to left (ones, tens, hundreds, ...) + for i in range(0, max-extraction) { + a-digits.push(calc.rem(temp-a, 10)) + b-digits.push(calc.rem(temp-b, 10)) + temp-a = calc.floor(temp-a / 10) + temp-b = calc.floor(temp-b / 10) + } + + // Find highest non-zero digit position for each number (for leading zero detection) + let a-highest = 0 + let b-highest = 0 + for i in range(0, max-extraction) { + if a-digits.at(i) > 0 { a-highest = i } + if b-digits.at(i) > 0 { b-highest = i } + } + + // Actual number of digits in this specific problem (not the page max) + // Must consider the sum, which may have one more digit than the addends (e.g., 99 + 1 = 100) + let sum = a + b + let sum-highest = 0 + let temp-sum = sum + for i in range(0, max-extraction) { + if calc.rem(temp-sum, 10) > 0 { sum-highest = i } + temp-sum = calc.floor(temp-sum / 10) + } + let actual-digits = calc.max(calc.max(a-highest, b-highest), sum-highest) + 1 + + // Generate column list dynamically based on actual digits in this problem + // Column list: [+sign column (0.5em), ...actual-digits × cellSizeIn] + let column-list = (0.5em,) + for i in range(0, actual-digits) { + column-list.push(${cellSizeIn}) + } + + // Show problem number (only if problem numbers are enabled) + let problem-number-display = if show-numbers and index-or-none != none { + align(top + left)[ + #box(inset: (left: 0.08in, top: 0.05in))[ + #text(size: ${(cellSizePt * 0.6).toFixed(1)}pt, weight: "bold", font: "New Computer Modern Math")[\##(index-or-none + 1).] + ] + ] + } + stack( dir: ttb, spacing: 0pt, - if show-numbers and index-or-none != none { - align(top + left)[ - #box(inset: (left: 0.08in, top: 0.05in))[ - #text(size: ${(cellSizePt * 0.6).toFixed(1)}pt, weight: "bold", font: "New Computer Modern Math")[\##(index-or-none + 1).] - ] - ] - }, + problem-number-display, grid( - columns: (0.5em, ${cellSizeIn}, ${cellSizeIn}, ${cellSizeIn}), + columns: column-list, gutter: 0pt, - [], - // Hundreds carry box: shows carry FROM tens (green) TO hundreds (yellow) - if show-carries { - if show-colors { - diagonal-split-box(${cellSizeIn}, color-tens, color-hundreds) - } else { - box(width: ${cellSizeIn}, height: ${cellSizeIn}, stroke: 0.5pt)[] - } - } else { v(${cellSizeIn}) }, - // Tens carry box: shows carry FROM ones (blue) TO tens (green) - if show-carries { - if show-colors { - diagonal-split-box(${cellSizeIn}, color-ones, color-tens) - } else { - box(width: ${cellSizeIn}, height: ${cellSizeIn}, stroke: 0.5pt)[] - } - } else { v(${cellSizeIn}) }, - [], + // Carry boxes row (one per place value, right to left) + [], // Empty cell for + sign column + ..for i in range(0, actual-digits).rev() { + // DEBUG: Show which place values get carry boxes and why + let show-carry = show-carries and i > 0 - [], - [], - box(width: ${cellSizeIn}, height: ${cellSizeIn}, fill: if show-colors { color-tens } else { color-none })[#align(center + horizon)[#text(size: ${(cellSizePt * 0.8).toFixed(1)}pt)[#str(aT)]]], - box(width: ${cellSizeIn}, height: ${cellSizeIn}, fill: if show-colors { color-ones } else { color-none })[#align(center + horizon)[#text(size: ${(cellSizePt * 0.8).toFixed(1)}pt)[#str(aO)]]], + if show-carry { + // Carry box: shows carry FROM position i-1 TO position i + let source-color = place-colors.at(i - 1) // Color of source place value + let dest-color = place-colors.at(i) // Color of destination place value + if show-colors { + (box(width: ${cellSizeIn}, height: ${cellSizeIn})[ + #diagonal-split-box(${cellSizeIn}, source-color, dest-color) + ],) + } else { + (box(width: ${cellSizeIn}, height: ${cellSizeIn}, stroke: 0.5pt)[],) + } + } else { + // No carry box for this position (i == 0 or show-carries is false) + (box(width: ${cellSizeIn}, height: ${cellSizeIn})[ + #v(${cellSizeIn}) + ],) + } + }, + + // First addend row (right to left: ones, tens, hundreds, ...) + [], // Empty cell for + sign column + ..for i in range(0, actual-digits).rev() { + let digit = a-digits.at(i) + let place-color = place-colors.at(i) // Dynamic color lookup by place value + let fill-color = if show-colors { place-color } else { color-none } + + // Hide leading zeros (zeros higher than the highest non-zero digit) + (box(width: ${cellSizeIn}, height: ${cellSizeIn}, fill: fill-color)[ + #align(center + horizon)[ + #if i <= a-highest [ + #text(size: ${(cellSizePt * 0.8).toFixed(1)}pt)[#str(digit)] + ] else [ + #h(0pt) + ] + ] + ],) + }, + + // Second addend row with + sign (right to left) box(width: ${cellSizeIn}, height: ${cellSizeIn})[#align(center + horizon)[#text(size: ${(cellSizePt * 0.8).toFixed(1)}pt)[+]]], - [], - box(width: ${cellSizeIn}, height: ${cellSizeIn}, fill: if show-colors { color-tens } else { color-none })[#align(center + horizon)[#text(size: ${(cellSizePt * 0.8).toFixed(1)}pt)[#str(bT)]]], - box(width: ${cellSizeIn}, height: ${cellSizeIn}, fill: if show-colors { color-ones } else { color-none })[#align(center + horizon)[#text(size: ${(cellSizePt * 0.8).toFixed(1)}pt)[#str(bO)]]], + ..for i in range(0, actual-digits).rev() { + let digit = b-digits.at(i) + let place-color = place-colors.at(i) // Dynamic color lookup by place value + let fill-color = if show-colors { place-color } else { color-none } + + // Hide leading zeros (zeros higher than the highest non-zero digit) + (box(width: ${cellSizeIn}, height: ${cellSizeIn}, fill: fill-color)[ + #align(center + horizon)[ + #if i <= b-highest [ + #text(size: ${(cellSizePt * 0.8).toFixed(1)}pt)[#str(digit)] + ] else [ + #h(0pt) + ] + ] + ],) + }, // Line row - [], - line(length: ${cellSizeIn}, stroke: heavy-stroke), - line(length: ${cellSizeIn}, stroke: heavy-stroke), - line(length: ${cellSizeIn}, stroke: heavy-stroke), + [], // Empty cell for + sign column + ..for i in range(0, actual-digits) { + (line(length: ${cellSizeIn}, stroke: heavy-stroke),) + }, // Ten-frames row with overlaid line on top + // Show ten-frames for any place value that has regrouping (or all if show-ten-frames-for-all) ..if show-ten-frames { - let carry = if (aO + bO) >= 10 { 1 } else { 0 } - let tens-regroup = (aT + bT + carry) >= 10 - let ones-regroup = (aO + bO) >= 10 - let needs-ten-frames = show-ten-frames-for-all or tens-regroup or ones-regroup + // Check which place values need ten-frames (only for actual digits in this problem) + let carry = 0 + let regrouping-places = () + for i in range(0, actual-digits) { + let digit-sum = a-digits.at(i) + b-digits.at(i) + carry + if digit-sum >= 10 { + regrouping-places.push(i) + carry = 1 + } else { + carry = 0 + } + } + + let needs-ten-frames = show-ten-frames-for-all or regrouping-places.len() > 0 if needs-ten-frames { ( - [], - [], // Empty cell for hundreds column - if show-ten-frames-for-all or tens-regroup { - box(width: ${cellSizeIn}, height: ${cellSizeIn} * 0.8)[ - #align(center + top)[#ten-frames-stacked(${cellSizeIn} * 0.90, if show-colors { color-hundreds } else { color-none }, if show-colors { color-tens } else { color-none })] - #place(top, line(length: ${cellSizeIn} * 0.90, stroke: heavy-stroke)) - ] - h(2.5pt) - } else { - v(${cellSizeIn} * 0.8) - }, - if show-ten-frames-for-all or ones-regroup { - box(width: ${cellSizeIn}, height: ${cellSizeIn} * 0.8)[ - #align(center + top)[#ten-frames-stacked(${cellSizeIn} * 0.90, if show-colors { color-tens } else { color-none }, if show-colors { color-ones } else { color-none })] - #place(top, line(length: ${cellSizeIn} * 0.90, stroke: heavy-stroke)) - ] - } else { - v(${cellSizeIn} * 0.8) + [], // Empty cell for + sign column + // Show ten-frames for any place value that needs regrouping + ..for i in range(0, actual-digits).rev() { + let shows-frame = show-ten-frames-for-all or (i in regrouping-places) + if shows-frame { + // Show ten-frame for this place value + // Top frame: carry destination (next higher place value) + // Bottom frame: current place value overflow + let top-color = if i + 1 < actual-digits { place-colors.at(i + 1) } else { color-none } + let bottom-color = place-colors.at(i) + + (box(width: ${cellSizeIn}, height: ${cellSizeIn} * 0.8)[ + #align(center + top)[#ten-frames-stacked(${cellSizeIn} * 0.90, if show-colors { top-color } else { color-none }, if show-colors { bottom-color } else { color-none })] + #place(top, line(length: ${cellSizeIn} * 0.90, stroke: heavy-stroke)) + ],) + } else { + (v(${cellSizeIn} * 0.8),) + } }, ) } else { @@ -188,11 +300,22 @@ export function generateProblemStackFunction(cellSize: number): string { () }, - // Answer boxes - [], - if show-answers { box(width: ${cellSizeIn}, height: ${cellSizeIn}, stroke: 0.5pt, fill: if show-colors { color-hundreds } else { color-none })[] } else { v(${cellSizeIn}) }, - if show-answers { box(width: ${cellSizeIn}, height: ${cellSizeIn}, stroke: 0.5pt, fill: if show-colors { color-tens } else { color-none })[] } else { v(${cellSizeIn}) }, - if show-answers { box(width: ${cellSizeIn}, height: ${cellSizeIn}, stroke: 0.5pt, fill: if show-colors { color-ones } else { color-none })[] } else { v(${cellSizeIn}) }, + // Answer boxes (one per digit column, only for actual digits including sum) + [], // Empty cell for + sign column + ..for i in range(0, actual-digits).rev() { + let place-color = place-colors.at(i) // Dynamic color lookup by place value + let fill-color = if show-colors { place-color } else { color-none } + let show-answer = show-answers + + if show-answer { + (box(width: ${cellSizeIn}, height: ${cellSizeIn}, stroke: 0.5pt, fill: fill-color)[],) + } else { + // No answer box (show-answers is false) + (box(width: ${cellSizeIn}, height: ${cellSizeIn})[ + #v(${cellSizeIn}) + ],) + } + }, ) ) }