feat(blog): add subtraction and multi-digit worksheet blog posts
Add two comprehensive blog posts introducing subtraction and multi-digit worksheet features with visual examples: **Subtraction Worksheets Post:** - 7 progressive scaffolding levels (no-borrowing through cascading borrows) - Side-by-side comparisons showing impact of borrow notation - Teaching progression guide (7+ week curriculum) - 9 SVG examples demonstrating different scaffolding options - Focus on borrow notation boxes and place value colors **Multi-Digit Worksheets Post:** - Support for 1-5 digit arithmetic problems - 6-color place value system across all digit ranges - Mixed problem sizes within single worksheet - Adaptive scaffolding based on digit count - 6 SVG examples (2-digit baseline through 5-digit advanced) **Code Fixes:** - Fix typstGenerator.ts: Pass showBorrowNotation instead of showCarryBoxes to subtraction-problem-stack function (line 220) - Simplify hint text rendering in borrowBoxes.ts (remove nested text elements) **Generated Assets:** - 9 subtraction examples in public/blog/subtraction-examples/ - 6 multi-digit examples in public/blog/multi-digit-examples/ - New generation scripts: generateSubtractionExamples.ts, generateMultiDigitExamples.ts Note: Borrowing hints feature (arrows with calculations) needs additional debugging in Typst rendering layer. Current blog posts focus on working features: borrow notation boxes, place value colors, and progressive scaffolding. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
99ec5eae5e
commit
dd9587f8cd
|
|
@ -0,0 +1,289 @@
|
||||||
|
// Typst document generator for addition worksheets
|
||||||
|
|
||||||
|
import type { WorksheetProblem, WorksheetConfig } from '@/app/create/worksheets/types'
|
||||||
|
import {
|
||||||
|
generateTypstHelpers,
|
||||||
|
generateProblemStackFunction,
|
||||||
|
generateSubtractionProblemStackFunction,
|
||||||
|
generatePlaceValueColors,
|
||||||
|
} from './typstHelpers'
|
||||||
|
import { analyzeProblem, analyzeSubtractionProblem } from './problemAnalysis'
|
||||||
|
import { resolveDisplayForProblem } from './displayRules'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunk array into pages of specified size
|
||||||
|
*/
|
||||||
|
function chunkProblems(problems: WorksheetProblem[], pageSize: number): WorksheetProblem[][] {
|
||||||
|
const pages: WorksheetProblem[][] = []
|
||||||
|
for (let i = 0; i < problems.length; i += pageSize) {
|
||||||
|
pages.push(problems.slice(i, i + pageSize))
|
||||||
|
}
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate maximum number of digits in any problem on this page
|
||||||
|
* Returns max digits across all operands (handles both addition and subtraction)
|
||||||
|
*/
|
||||||
|
function calculateMaxDigits(problems: WorksheetProblem[]): number {
|
||||||
|
let maxDigits = 1
|
||||||
|
for (const problem of problems) {
|
||||||
|
if (problem.operator === 'add') {
|
||||||
|
const digitsA = problem.a.toString().length
|
||||||
|
const digitsB = problem.b.toString().length
|
||||||
|
const maxProblemDigits = Math.max(digitsA, digitsB)
|
||||||
|
maxDigits = Math.max(maxDigits, maxProblemDigits)
|
||||||
|
} else {
|
||||||
|
// Subtraction
|
||||||
|
const digitsMinuend = problem.minuend.toString().length
|
||||||
|
const digitsSubtrahend = problem.subtrahend.toString().length
|
||||||
|
const maxProblemDigits = Math.max(digitsMinuend, digitsSubtrahend)
|
||||||
|
maxDigits = Math.max(maxDigits, maxProblemDigits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxDigits
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Typst source code for a single page
|
||||||
|
*/
|
||||||
|
function generatePageTypst(
|
||||||
|
config: WorksheetConfig,
|
||||||
|
pageProblems: WorksheetProblem[],
|
||||||
|
problemOffset: number,
|
||||||
|
rowsPerPage: number
|
||||||
|
): string {
|
||||||
|
// Calculate maximum digits for proper column layout
|
||||||
|
const maxDigits = calculateMaxDigits(pageProblems)
|
||||||
|
|
||||||
|
// Enrich problems with display options based on mode
|
||||||
|
const enrichedProblems = pageProblems.map((p, index) => {
|
||||||
|
if (config.mode === 'smart' || config.mode === 'mastery') {
|
||||||
|
// Smart & Mastery modes: Per-problem conditional display based on problem complexity
|
||||||
|
// Both modes use displayRules for conditional scaffolding
|
||||||
|
const meta =
|
||||||
|
p.operator === 'add'
|
||||||
|
? analyzeProblem(p.a, p.b)
|
||||||
|
: analyzeSubtractionProblem(p.minuend, p.subtrahend)
|
||||||
|
|
||||||
|
// Choose display rules based on operator (for mastery+mixed mode)
|
||||||
|
let rulesForProblem = config.displayRules as any
|
||||||
|
|
||||||
|
if (config.mode === 'mastery') {
|
||||||
|
const masteryConfig = config as any
|
||||||
|
// If we have operator-specific rules (mastery+mixed), use them
|
||||||
|
if (p.operator === 'add' && masteryConfig.additionDisplayRules) {
|
||||||
|
rulesForProblem = masteryConfig.additionDisplayRules
|
||||||
|
console.log(
|
||||||
|
`[TYPST PROBLEM ${index}] Using additionDisplayRules for ${p.a} + ${p.b}`,
|
||||||
|
rulesForProblem
|
||||||
|
)
|
||||||
|
} else if (p.operator === 'sub' && masteryConfig.subtractionDisplayRules) {
|
||||||
|
rulesForProblem = masteryConfig.subtractionDisplayRules
|
||||||
|
console.log(
|
||||||
|
`[TYPST PROBLEM ${index}] Using subtractionDisplayRules for ${p.minuend} - ${p.subtrahend}`,
|
||||||
|
rulesForProblem
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayOptions = resolveDisplayForProblem(rulesForProblem, meta)
|
||||||
|
|
||||||
|
if (p.operator === 'sub') {
|
||||||
|
console.log(`[TYPST PROBLEM ${index}] Subtraction resolved display:`, {
|
||||||
|
problem: `${p.minuend} - ${p.subtrahend}`,
|
||||||
|
meta,
|
||||||
|
rulesUsed: rulesForProblem,
|
||||||
|
resolved: displayOptions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
...displayOptions, // Now includes showBorrowNotation and showBorrowingHints from resolved rules
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Manual mode: Per-problem conditional display using displayRules (same as Smart/Mastery)
|
||||||
|
const meta =
|
||||||
|
p.operator === 'add'
|
||||||
|
? analyzeProblem(p.a, p.b)
|
||||||
|
: analyzeSubtractionProblem(p.minuend, p.subtrahend)
|
||||||
|
|
||||||
|
const displayOptions = resolveDisplayForProblem(config.displayRules as any, meta)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
...displayOptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Generate Typst problem data with per-problem display flags
|
||||||
|
const problemsTypst = enrichedProblems
|
||||||
|
.map((p) => {
|
||||||
|
if (p.operator === 'add') {
|
||||||
|
return ` (operator: "+", a: ${p.a}, b: ${p.b}, showCarryBoxes: ${p.showCarryBoxes}, showAnswerBoxes: ${p.showAnswerBoxes}, showPlaceValueColors: ${p.showPlaceValueColors}, showTenFrames: ${p.showTenFrames}, showProblemNumbers: ${p.showProblemNumbers}, showCellBorder: ${p.showCellBorder}, showBorrowNotation: ${p.showBorrowNotation}, showBorrowingHints: ${p.showBorrowingHints}),`
|
||||||
|
} else {
|
||||||
|
return ` (operator: "−", minuend: ${p.minuend}, subtrahend: ${p.subtrahend}, showCarryBoxes: ${p.showCarryBoxes}, showAnswerBoxes: ${p.showAnswerBoxes}, showPlaceValueColors: ${p.showPlaceValueColors}, showTenFrames: ${p.showTenFrames}, showProblemNumbers: ${p.showProblemNumbers}, showCellBorder: ${p.showCellBorder}, showBorrowNotation: ${p.showBorrowNotation}, showBorrowingHints: ${p.showBorrowingHints}),`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
// DEBUG: Show Typst problem data for first problem
|
||||||
|
console.log('[TYPST DEBUG] First problem Typst data:', problemsTypst.split('\n')[0])
|
||||||
|
|
||||||
|
// Calculate actual number of rows on this page
|
||||||
|
const actualRows = Math.ceil(pageProblems.length / config.cols)
|
||||||
|
|
||||||
|
// Use smaller margins to maximize space
|
||||||
|
const margin = 0.4
|
||||||
|
const contentWidth = config.page.wIn - margin * 2
|
||||||
|
const contentHeight = config.page.hIn - margin * 2
|
||||||
|
|
||||||
|
// Calculate grid spacing based on ACTUAL rows on this page
|
||||||
|
const headerHeight = 0.35 // inches for header
|
||||||
|
const availableHeight = contentHeight - headerHeight
|
||||||
|
const problemBoxHeight = availableHeight / actualRows
|
||||||
|
const problemBoxWidth = contentWidth / config.cols
|
||||||
|
|
||||||
|
// Calculate cell size assuming MAXIMUM possible embellishments
|
||||||
|
// Check if ANY problem on this page might show ten-frames
|
||||||
|
const anyProblemMayShowTenFrames = enrichedProblems.some((p) => p.showTenFrames)
|
||||||
|
|
||||||
|
// Calculate cell size to fill the entire problem box
|
||||||
|
// Base vertical stack: carry row + addend1 + addend2 + line + answer = 5 rows
|
||||||
|
// With ten-frames: add 0.8 * cellSize row
|
||||||
|
// Total with ten-frames: ~5.8 rows
|
||||||
|
//
|
||||||
|
// Horizontal constraint: maxDigits columns + 1 for + sign
|
||||||
|
// Cell size must fit: (maxDigits + 1) * cellSize <= problemBoxWidth
|
||||||
|
const maxCellSizeForWidth = problemBoxWidth / (maxDigits + 1)
|
||||||
|
const maxCellSizeForHeight = anyProblemMayShowTenFrames
|
||||||
|
? problemBoxHeight / 6.0
|
||||||
|
: problemBoxHeight / 4.5
|
||||||
|
|
||||||
|
// Use the smaller of width/height constraints
|
||||||
|
const cellSize = Math.min(maxCellSizeForWidth, maxCellSizeForHeight)
|
||||||
|
|
||||||
|
return String.raw`
|
||||||
|
// addition-worksheet-page.typ (auto-generated)
|
||||||
|
|
||||||
|
#set page(
|
||||||
|
width: ${config.page.wIn}in,
|
||||||
|
height: ${config.page.hIn}in,
|
||||||
|
margin: ${margin}in,
|
||||||
|
fill: white
|
||||||
|
)
|
||||||
|
#set text(size: ${config.fontSize}pt, font: "New Computer Modern Math")
|
||||||
|
|
||||||
|
// Single non-breakable block to ensure one page
|
||||||
|
#block(breakable: false)[
|
||||||
|
|
||||||
|
#let heavy-stroke = 0.8pt
|
||||||
|
#let show-ten-frames-for-all = ${
|
||||||
|
config.mode === 'manual'
|
||||||
|
? config.showTenFramesForAll
|
||||||
|
? 'true'
|
||||||
|
: 'false'
|
||||||
|
: config.displayRules.tenFrames === 'always'
|
||||||
|
? 'true'
|
||||||
|
: 'false'
|
||||||
|
}
|
||||||
|
|
||||||
|
${generatePlaceValueColors()}
|
||||||
|
|
||||||
|
${generateTypstHelpers(cellSize)}
|
||||||
|
|
||||||
|
${generateProblemStackFunction(cellSize, maxDigits)}
|
||||||
|
|
||||||
|
${generateSubtractionProblemStackFunction(cellSize, maxDigits)}
|
||||||
|
|
||||||
|
#let problem-box(problem, index) = {
|
||||||
|
// Extract per-problem display flags
|
||||||
|
let grid-stroke = if problem.showCellBorder { (thickness: 1pt, dash: "dashed", paint: gray.darken(20%)) } else { none }
|
||||||
|
|
||||||
|
box(
|
||||||
|
inset: 0pt,
|
||||||
|
width: ${problemBoxWidth}in,
|
||||||
|
height: ${problemBoxHeight}in,
|
||||||
|
stroke: grid-stroke
|
||||||
|
)[
|
||||||
|
#align(center + horizon)[
|
||||||
|
#if problem.operator == "+" {
|
||||||
|
problem-stack(
|
||||||
|
problem.a, problem.b, index,
|
||||||
|
problem.showCarryBoxes,
|
||||||
|
problem.showAnswerBoxes,
|
||||||
|
problem.showPlaceValueColors,
|
||||||
|
problem.showTenFrames,
|
||||||
|
problem.showProblemNumbers
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
subtraction-problem-stack(
|
||||||
|
problem.minuend, problem.subtrahend, index,
|
||||||
|
problem.showBorrowNotation, // show-borrows (whether to show borrow boxes)
|
||||||
|
problem.showAnswerBoxes,
|
||||||
|
problem.showPlaceValueColors,
|
||||||
|
problem.showTenFrames,
|
||||||
|
problem.showProblemNumbers,
|
||||||
|
problem.showBorrowNotation, // show-borrow-notation (scratch work boxes in minuend)
|
||||||
|
problem.showBorrowingHints // show-borrowing-hints (hints with arrows)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#let problems = (
|
||||||
|
${problemsTypst}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compact header - name on left, date on right
|
||||||
|
#grid(
|
||||||
|
columns: (1fr, 1fr),
|
||||||
|
align: (left, right),
|
||||||
|
text(size: 0.75em, weight: "bold")[${config.name}],
|
||||||
|
text(size: 0.65em)[${config.date}]
|
||||||
|
)
|
||||||
|
#v(${headerHeight}in - 0.25in)
|
||||||
|
|
||||||
|
// Problem grid - exactly ${actualRows} rows × ${config.cols} columns
|
||||||
|
#grid(
|
||||||
|
columns: ${config.cols},
|
||||||
|
column-gutter: 0pt,
|
||||||
|
row-gutter: 0pt,
|
||||||
|
..for r in range(0, ${actualRows}) {
|
||||||
|
for c in range(0, ${config.cols}) {
|
||||||
|
let idx = r * ${config.cols} + c
|
||||||
|
if idx < problems.len() {
|
||||||
|
(problem-box(problems.at(idx), ${problemOffset} + idx),)
|
||||||
|
} else {
|
||||||
|
(box(width: ${problemBoxWidth}in, height: ${problemBoxHeight}in),)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
] // End of constrained block
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Typst source code for the worksheet (returns array of page sources)
|
||||||
|
*/
|
||||||
|
export function generateTypstSource(
|
||||||
|
config: WorksheetConfig,
|
||||||
|
problems: WorksheetProblem[]
|
||||||
|
): string[] {
|
||||||
|
// Use the problemsPerPage directly from config (primary state)
|
||||||
|
const problemsPerPage = config.problemsPerPage
|
||||||
|
const rowsPerPage = problemsPerPage / config.cols
|
||||||
|
|
||||||
|
// Chunk problems into discrete pages
|
||||||
|
const pages = chunkProblems(problems, problemsPerPage)
|
||||||
|
|
||||||
|
// Generate separate Typst source for each page
|
||||||
|
return pages.map((pageProblems, pageIndex) =>
|
||||||
|
generatePageTypst(config, pageProblems, pageIndex * problemsPerPage, rowsPerPage)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
// Borrow boxes row rendering for subtraction problems
|
||||||
|
// This row shows where borrows occur (FROM higher place TO lower place)
|
||||||
|
|
||||||
|
import type { CellDimensions } from '../shared/types'
|
||||||
|
import { TYPST_CONSTANTS } from '../shared/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Typst code for the borrow boxes row
|
||||||
|
*
|
||||||
|
* Borrow boxes indicate where a borrow operation occurs:
|
||||||
|
* - Source: The place value we're borrowing FROM (giving)
|
||||||
|
* - Destination: The place value we're borrowing TO (receiving)
|
||||||
|
*
|
||||||
|
* Design decision: Borrow boxes NEVER use place value colors (always stroke-only)
|
||||||
|
* to avoid arrow layering issues where arrows get covered by adjacent cell backgrounds.
|
||||||
|
*
|
||||||
|
* @param cellDimensions - Cell sizing information
|
||||||
|
* @returns Typst code for borrow boxes row
|
||||||
|
*/
|
||||||
|
export function generateBorrowBoxesRow(cellDimensions: CellDimensions): string {
|
||||||
|
const { cellSize, cellSizeIn, cellSizePt } = cellDimensions
|
||||||
|
|
||||||
|
const hintTextSize = (cellSizePt * TYPST_CONSTANTS.HINT_TEXT_SIZE_FACTOR).toFixed(1)
|
||||||
|
const arrowheadSize = (cellSizePt * TYPST_CONSTANTS.ARROWHEAD_SIZE_FACTOR).toFixed(1)
|
||||||
|
|
||||||
|
const arrowStartDx = (cellSize * TYPST_CONSTANTS.ARROW_START_DX).toFixed(2)
|
||||||
|
const arrowStartDy = (cellSize * TYPST_CONSTANTS.ARROW_START_DY).toFixed(2)
|
||||||
|
const arrowEndX = (cellSize * 0.24).toFixed(2)
|
||||||
|
const arrowEndY = (cellSize * 0.7).toFixed(2)
|
||||||
|
const arrowControlX = (cellSize * 0.11).toFixed(2)
|
||||||
|
const arrowControlY = (cellSize * -0.5).toFixed(2)
|
||||||
|
const arrowheadDx = (cellSize * TYPST_CONSTANTS.ARROWHEAD_DX).toFixed(2)
|
||||||
|
const arrowheadDy = (cellSize * TYPST_CONSTANTS.ARROWHEAD_DY).toFixed(2)
|
||||||
|
|
||||||
|
return String.raw`
|
||||||
|
// Borrow boxes row (shows borrows FROM higher place TO lower place)
|
||||||
|
[], // Empty cell for operator column
|
||||||
|
..for i in range(0, grid-digits).rev() {
|
||||||
|
// Check if we need to borrow FROM this place (to give to i-1)
|
||||||
|
// We borrow when m-digit < s-digit at position i-1
|
||||||
|
let shows-borrow = show-borrows and i > 0 and (m-digits.at(i - 1) < s-digits.at(i - 1))
|
||||||
|
|
||||||
|
if shows-borrow {
|
||||||
|
// This place borrowed FROM to give to lower place
|
||||||
|
let source-color = place-colors.at(i) // This place (giving)
|
||||||
|
let dest-color = place-colors.at(i - 1) // Lower place (receiving)
|
||||||
|
|
||||||
|
// When showing hints, determine what to display based on cascading
|
||||||
|
if show-borrowing-hints and i <= m-highest {
|
||||||
|
// Determine the actual value to show in the hint
|
||||||
|
// For cascading: if this digit is 0, it received 10 from left and gives 1 to right
|
||||||
|
// So it shows "10 - 1". Otherwise it shows "original - 1"
|
||||||
|
let original-digit = m-digits.at(i)
|
||||||
|
|
||||||
|
// Check if this is part of a cascade (is it 0 and needs to borrow?)
|
||||||
|
let is-cascade = original-digit == 0
|
||||||
|
|
||||||
|
// The display value is either the original digit or 10 (if cascading)
|
||||||
|
let display-value = if is-cascade { 10 } else { original-digit }
|
||||||
|
|
||||||
|
// Borrow boxes never use place value colors (always stroke-only)
|
||||||
|
// to avoid arrow layering issues
|
||||||
|
(box(width: ${cellSizeIn}, height: ${cellSizeIn}, stroke: ${TYPST_CONSTANTS.CELL_STROKE_WIDTH}pt)[
|
||||||
|
#place(
|
||||||
|
top + center,
|
||||||
|
dy: 2pt,
|
||||||
|
text(size: ${hintTextSize}pt, fill: gray.darken(30%), weight: "bold")[
|
||||||
|
#display-value#h(0.1em)−#h(0.1em)1
|
||||||
|
]
|
||||||
|
)
|
||||||
|
// Draw curved line using Typst bezier with control point
|
||||||
|
#place(
|
||||||
|
top + left,
|
||||||
|
dx: ${arrowStartDx}in,
|
||||||
|
dy: ${arrowStartDy}in,
|
||||||
|
path(
|
||||||
|
stroke: (paint: gray.darken(30%), thickness: ${TYPST_CONSTANTS.ARROW_STROKE_WIDTH}pt),
|
||||||
|
// Start vertex (near the "1" in borrow box)
|
||||||
|
(0pt, 0pt),
|
||||||
|
// End vertex adjusted up and left to align with arrowhead (vertex, relative-control-point)
|
||||||
|
((${arrowEndX}in, ${arrowEndY}in), (${arrowControlX}in, ${arrowControlY}in)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// Arrowhead pointing down at the top edge of borrowed 10s box
|
||||||
|
#place(
|
||||||
|
top + left,
|
||||||
|
dx: ${arrowheadDx}in,
|
||||||
|
dy: ${arrowheadDy}in,
|
||||||
|
text(size: ${arrowheadSize}pt, fill: gray.darken(30%))[▼]
|
||||||
|
)
|
||||||
|
],)
|
||||||
|
} else {
|
||||||
|
// No hints - just show stroke box
|
||||||
|
(box(width: ${cellSizeIn}, height: ${cellSizeIn}, stroke: ${TYPST_CONSTANTS.CELL_STROKE_WIDTH}pt)[],)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No borrow from this position
|
||||||
|
(box(width: ${cellSizeIn}, height: ${cellSizeIn})[
|
||||||
|
#v(${cellSizeIn})
|
||||||
|
],)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
`
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue