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