feat(worksheets): implement unique place value colors for 1-6 digit problems

Added 6 unique pastel colors (ones through hundred-thousands) with
dynamic color lookup using array indexing instead of hardcoded ternaries.

Colors:
- Ones: Light blue (rgb(227, 242, 253))
- Tens: Light green (rgb(232, 245, 233))
- Hundreds: Light yellow (rgb(255, 249, 196))
- Thousands: Light pink/rose (rgb(255, 228, 225))
- Ten-thousands: Light purple/lavender (rgb(243, 229, 245))
- Hundred-thousands: Light peach/orange (rgb(255, 239, 213))

All place value rendering (carry boxes, addends, answer boxes, ten-frames)
now uses place-colors.at(i) for dynamic lookup.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-07 22:08:43 -06:00
parent d9b54a736c
commit 65e272c570
1 changed files with 195 additions and 72 deletions

View File

@ -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})
],)
}
},
)
)
}