Fix fraction answer key QR placement syntax
This commit is contained in:
parent
9159608dcd
commit
aca08b1aec
|
|
@ -140,7 +140,7 @@ async function fetchExample(options: {
|
|||
showTenFrames: boolean
|
||||
showTenFramesForAll: boolean
|
||||
showBorrowNotation: boolean
|
||||
operator: 'addition' | 'subtraction' | 'mixed'
|
||||
operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
addend1?: number
|
||||
addend2?: number
|
||||
minuend?: number
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ interface OrientationPanelProps {
|
|||
// Config for problem space validation
|
||||
digitRange?: { min: number; max: number }
|
||||
pAnyStart?: number
|
||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
||||
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
mode?: 'custom' | 'mastery'
|
||||
// Layout options
|
||||
problemNumbers?: 'always' | 'never'
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import type { WorksheetFormState } from '@/app/create/worksheets/types'
|
|||
export interface WorksheetConfigContextValue {
|
||||
formState: WorksheetFormState
|
||||
onChange: (updates: Partial<WorksheetFormState>) => void
|
||||
operator: 'addition' | 'subtraction' | 'mixed'
|
||||
operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
isReadOnly?: boolean
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { css } from '@styled/css'
|
||||
|
||||
export interface OperatorIconProps {
|
||||
operator: 'addition' | 'subtraction' | 'mixed'
|
||||
operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
isDark?: boolean
|
||||
color?: 'gray' | 'green'
|
||||
|
|
@ -15,8 +15,9 @@ const sizeMap = {
|
|||
xl: 'xl',
|
||||
} as const
|
||||
|
||||
function getOperatorSymbol(operator: 'addition' | 'subtraction' | 'mixed'): string {
|
||||
function getOperatorSymbol(operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'): string {
|
||||
if (operator === 'mixed') return '±'
|
||||
if (operator === 'fractions') return '¾'
|
||||
if (operator === 'subtraction') return '−'
|
||||
return '+'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { css } from '@styled/css'
|
|||
import { OperatorIcon } from './OperatorIcon'
|
||||
|
||||
export interface OperatorSectionProps {
|
||||
operator: 'addition' | 'subtraction' | 'mixed' | undefined
|
||||
onChange: (operator: 'addition' | 'subtraction' | 'mixed') => void
|
||||
operator: 'addition' | 'subtraction' | 'mixed' | 'fractions' | undefined
|
||||
onChange: (operator: 'addition' | 'subtraction' | 'mixed' | 'fractions') => void
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -11,8 +11,13 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
|
|||
// Derive checkbox states from operator value
|
||||
const additionChecked = operator === 'addition' || operator === 'mixed' || !operator
|
||||
const subtractionChecked = operator === 'subtraction' || operator === 'mixed'
|
||||
const fractionsChecked = operator === 'fractions'
|
||||
|
||||
const handleAdditionChange = (checked: boolean) => {
|
||||
if (fractionsChecked) {
|
||||
onChange(checked ? 'addition' : 'subtraction')
|
||||
return
|
||||
}
|
||||
if (!checked && !subtractionChecked) {
|
||||
// Can't uncheck if it's the only one checked
|
||||
return
|
||||
|
|
@ -27,6 +32,10 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
|
|||
}
|
||||
|
||||
const handleSubtractionChange = (checked: boolean) => {
|
||||
if (fractionsChecked) {
|
||||
onChange(checked ? 'subtraction' : 'addition')
|
||||
return
|
||||
}
|
||||
if (!checked && !additionChecked) {
|
||||
// Can't uncheck if it's the only one checked
|
||||
return
|
||||
|
|
@ -187,6 +196,69 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
|
|||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Fractions Selector */}
|
||||
<label
|
||||
data-action="toggle-fractions"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '3',
|
||||
cursor: 'pointer',
|
||||
px: '3',
|
||||
py: '2.5',
|
||||
rounded: 'lg',
|
||||
border: '2px solid',
|
||||
transition: 'all 0.2s',
|
||||
bg: fractionsChecked
|
||||
? isDark
|
||||
? 'brand.900'
|
||||
: 'brand.50'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'white',
|
||||
borderColor: fractionsChecked ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
|
||||
_hover: {
|
||||
borderColor: 'brand.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
checked={fractionsChecked}
|
||||
onChange={(e) => onChange(e.target.checked ? 'fractions' : 'addition')}
|
||||
className={css({
|
||||
width: '5',
|
||||
height: '5',
|
||||
cursor: 'pointer',
|
||||
accentColor: 'brand.600',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
})}
|
||||
>
|
||||
<OperatorIcon operator="fractions" isDark={isDark} />
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
'@media (max-width: 200px)': {
|
||||
fontSize: 'xs',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Mixed denominator fractions
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p
|
||||
|
|
@ -196,11 +268,13 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
|
|||
lineHeight: '1.5',
|
||||
})}
|
||||
>
|
||||
{additionChecked && subtractionChecked
|
||||
? 'Problems will randomly use addition or subtraction'
|
||||
: subtractionChecked
|
||||
? 'All problems will be subtraction'
|
||||
: 'All problems will be addition'}
|
||||
{fractionsChecked
|
||||
? 'All problems will be mixed-denominator fraction addition'
|
||||
: additionChecked && subtractionChecked
|
||||
? 'Problems will randomly use addition or subtraction'
|
||||
: subtractionChecked
|
||||
? 'All problems will be subtraction'
|
||||
: 'All problems will be addition'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export function RuleThermometer({
|
|||
const isAutoResolved = !isMixedMode && isAutoSelected && resolvedValue === option.value
|
||||
|
||||
// Determine which operator to show
|
||||
let operatorToShow: 'addition' | 'subtraction' | 'mixed' | null = null
|
||||
let operatorToShow: 'addition' | 'subtraction' | 'mixed' | 'fractions' | null = null
|
||||
if (isAutoResolvedMixed) {
|
||||
if (additionDefersHere && subtractionDefersHere) {
|
||||
operatorToShow = 'mixed' // Both defer here
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { css } from '@styled/css'
|
|||
*/
|
||||
export function getScaffoldingSummary(
|
||||
displayRules: any,
|
||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
||||
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
): React.ReactNode {
|
||||
console.log('[getScaffoldingSummary] displayRules:', displayRules, 'operator:', operator)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type { DisplayRules } from '../../displayRules'
|
|||
interface ProblemPreviewProps {
|
||||
displayRules: DisplayRules
|
||||
resolvedDisplayRules?: DisplayRules
|
||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
||||
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
digitRange?: { min: number; max: number }
|
||||
className?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { ProblemPreview } from './ProblemPreview'
|
|||
export interface Tab {
|
||||
id: string
|
||||
label: string
|
||||
icon: string | ((operator?: 'addition' | 'subtraction' | 'mixed') => string) | 'preview'
|
||||
icon: string | ((operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions') => string) | 'preview'
|
||||
subtitle?: (props: {
|
||||
mode?: 'custom' | 'manual' | 'mastery'
|
||||
difficultyProfile?: string
|
||||
|
|
@ -20,7 +20,7 @@ export interface Tab {
|
|||
pages?: number
|
||||
displayRules?: DisplayRules
|
||||
resolvedDisplayRules?: DisplayRules
|
||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
||||
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
}) => string | null
|
||||
}
|
||||
|
||||
|
|
@ -30,6 +30,7 @@ export const TABS: Tab[] = [
|
|||
label: 'Operator',
|
||||
icon: (operator) => {
|
||||
if (operator === 'mixed') return '±'
|
||||
if (operator === 'fractions') return '¾'
|
||||
if (operator === 'subtraction') return '−'
|
||||
return '+'
|
||||
},
|
||||
|
|
@ -115,7 +116,7 @@ export const TABS: Tab[] = [
|
|||
interface TabNavigationProps {
|
||||
activeTab: string
|
||||
onChange: (tabId: string) => void
|
||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
||||
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
mode?: 'custom' | 'manual' | 'mastery'
|
||||
difficultyProfile?: string
|
||||
interpolate?: boolean
|
||||
|
|
|
|||
|
|
@ -343,7 +343,7 @@ const additionConfigV4BaseSchema = z.object({
|
|||
}),
|
||||
|
||||
// V4: Operator selection (addition, subtraction, or mixed)
|
||||
operator: z.enum(['addition', 'subtraction', 'mixed']).default('addition'),
|
||||
operator: z.enum(['addition', 'subtraction', 'mixed', 'fractions']).default('addition'),
|
||||
|
||||
// Regrouping probabilities (shared between modes)
|
||||
pAnyStart: z.number().min(0).max(1),
|
||||
|
|
|
|||
|
|
@ -243,7 +243,7 @@ function describeScaffoldingChange(
|
|||
fromRules: DisplayRules,
|
||||
toRules: DisplayRules,
|
||||
direction: 'added' | 'reduced',
|
||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
||||
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
): string {
|
||||
const changes: string[] = []
|
||||
|
||||
|
|
@ -786,7 +786,7 @@ export function makeHarder(
|
|||
displayRules: DisplayRules
|
||||
},
|
||||
mode: DifficultyMode = 'both',
|
||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
||||
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
): {
|
||||
pAnyStart: number
|
||||
pAllStart: number
|
||||
|
|
@ -989,7 +989,7 @@ export function makeEasier(
|
|||
displayRules: DisplayRules
|
||||
},
|
||||
mode: DifficultyMode = 'both',
|
||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
||||
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
): {
|
||||
pAnyStart: number
|
||||
pAllStart: number
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { execSync } from 'child_process'
|
||||
import type { WorksheetFormState } from '@/app/create/worksheets/types'
|
||||
import {
|
||||
generateFractionProblems,
|
||||
generateMasteryMixedProblems,
|
||||
generateMixedProblems,
|
||||
generateProblems,
|
||||
|
|
@ -127,33 +128,36 @@ export async function generateWorksheetPreview(
|
|||
)
|
||||
} else {
|
||||
// Standard problem generation
|
||||
problems =
|
||||
operator === 'addition'
|
||||
? generateProblems(
|
||||
validatedConfig.total,
|
||||
validatedConfig.pAnyStart,
|
||||
validatedConfig.pAllStart,
|
||||
validatedConfig.interpolate,
|
||||
validatedConfig.seed,
|
||||
validatedConfig.digitRange
|
||||
)
|
||||
: operator === 'subtraction'
|
||||
? generateSubtractionProblems(
|
||||
validatedConfig.total,
|
||||
validatedConfig.digitRange,
|
||||
validatedConfig.pAnyStart,
|
||||
validatedConfig.pAllStart,
|
||||
validatedConfig.interpolate,
|
||||
validatedConfig.seed
|
||||
)
|
||||
: generateMixedProblems(
|
||||
validatedConfig.total,
|
||||
validatedConfig.digitRange,
|
||||
validatedConfig.pAnyStart,
|
||||
validatedConfig.pAllStart,
|
||||
validatedConfig.interpolate,
|
||||
validatedConfig.seed
|
||||
)
|
||||
if (operator === 'addition') {
|
||||
problems = generateProblems(
|
||||
validatedConfig.total,
|
||||
validatedConfig.pAnyStart,
|
||||
validatedConfig.pAllStart,
|
||||
validatedConfig.interpolate,
|
||||
validatedConfig.seed,
|
||||
validatedConfig.digitRange
|
||||
)
|
||||
} else if (operator === 'subtraction') {
|
||||
problems = generateSubtractionProblems(
|
||||
validatedConfig.total,
|
||||
validatedConfig.digitRange,
|
||||
validatedConfig.pAnyStart,
|
||||
validatedConfig.pAllStart,
|
||||
validatedConfig.interpolate,
|
||||
validatedConfig.seed
|
||||
)
|
||||
} else if (operator === 'fractions') {
|
||||
problems = generateFractionProblems(validatedConfig.total, validatedConfig.seed)
|
||||
} else {
|
||||
problems = generateMixedProblems(
|
||||
validatedConfig.total,
|
||||
validatedConfig.digitRange,
|
||||
validatedConfig.pAnyStart,
|
||||
validatedConfig.pAllStart,
|
||||
validatedConfig.interpolate,
|
||||
validatedConfig.seed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[PREVIEW] Step 2: ✓ Generated ${problems.length} problems`)
|
||||
|
|
@ -328,6 +332,8 @@ export async function generateSinglePage(
|
|||
validatedConfig.interpolate,
|
||||
validatedConfig.seed
|
||||
)
|
||||
} else if (operator === 'fractions') {
|
||||
problems = generateFractionProblems(validatedConfig.total, validatedConfig.seed)
|
||||
} else if (operator === 'subtraction') {
|
||||
problems = generateSubtractionProblems(
|
||||
validatedConfig.total,
|
||||
|
|
|
|||
|
|
@ -1269,3 +1269,45 @@ export function generateMixedProblems(
|
|||
|
||||
return problems
|
||||
}
|
||||
|
||||
function gcd(a: number, b: number): number {
|
||||
return b === 0 ? Math.abs(a) : gcd(b, a % b)
|
||||
}
|
||||
|
||||
function simplifyFraction(numerator: number, denominator: number): { numerator: number; denominator: number } {
|
||||
const divisor = gcd(numerator, denominator)
|
||||
return { numerator: numerator / divisor, denominator: denominator / divisor }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate addition problems with mixed denominators (fractions)
|
||||
*/
|
||||
export function generateFractionProblems(total: number, seed: number): FractionProblem[] {
|
||||
const rand = createPRNG(seed)
|
||||
const problems: FractionProblem[] = []
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
let leftDen = randint(2, 12, rand)
|
||||
let rightDen = randint(2, 12, rand)
|
||||
|
||||
// Ensure mixed denominators
|
||||
if (rightDen === leftDen) {
|
||||
rightDen = ((rightDen % 12) + 1) + 1 // rotate to a different denominator between 2-13
|
||||
if (rightDen > 12) rightDen = 2
|
||||
}
|
||||
|
||||
const leftNum = randint(1, leftDen - 1, rand)
|
||||
const rightNum = randint(1, rightDen - 1, rand)
|
||||
|
||||
const left = simplifyFraction(leftNum, leftDen)
|
||||
const right = simplifyFraction(rightNum, rightDen)
|
||||
|
||||
problems.push({
|
||||
left,
|
||||
right,
|
||||
operator: 'fraction',
|
||||
})
|
||||
}
|
||||
|
||||
return problems
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ export type WorksheetFormState = Partial<Omit<AdditionConfigV4Custom, 'version'>
|
|||
/**
|
||||
* Worksheet operator type
|
||||
*/
|
||||
export type WorksheetOperator = 'addition' | 'subtraction' | 'mixed'
|
||||
export type WorksheetOperator = 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
|
||||
/**
|
||||
* A single addition problem
|
||||
|
|
@ -136,10 +136,19 @@ export interface SubtractionProblem {
|
|||
operator: 'sub'
|
||||
}
|
||||
|
||||
/**
|
||||
* A single fraction addition problem with mixed denominators
|
||||
*/
|
||||
export interface FractionProblem {
|
||||
left: { numerator: number; denominator: number }
|
||||
right: { numerator: number; denominator: number }
|
||||
operator: 'fraction'
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified problem type (addition or subtraction)
|
||||
*/
|
||||
export type WorksheetProblem = AdditionProblem | SubtractionProblem
|
||||
export type WorksheetProblem = AdditionProblem | SubtractionProblem | FractionProblem
|
||||
|
||||
/**
|
||||
* Validation result
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Typst document generator for addition worksheets
|
||||
|
||||
import type { WorksheetConfig, WorksheetProblem } from '@/app/create/worksheets/types'
|
||||
import type { FractionProblem, WorksheetConfig, WorksheetProblem } from '@/app/create/worksheets/types'
|
||||
import { resolveDisplayForProblem } from './displayRules'
|
||||
import { analyzeProblem, analyzeSubtractionProblem } from './problemAnalysis'
|
||||
import { generateQRCodeSVG } from './qrCodeGenerator'
|
||||
|
|
@ -116,6 +116,21 @@ function chunkProblems(problems: WorksheetProblem[], pageSize: number): Workshee
|
|||
return pages
|
||||
}
|
||||
|
||||
function gcdInt(a: number, b: number): number {
|
||||
return b === 0 ? Math.abs(a) : gcdInt(b, a % b)
|
||||
}
|
||||
|
||||
function addFractions(problem: FractionProblem): { numerator: number; denominator: number } {
|
||||
const lcmDenominator =
|
||||
(problem.left.denominator * problem.right.denominator) /
|
||||
gcdInt(problem.left.denominator, problem.right.denominator)
|
||||
const numerator =
|
||||
problem.left.numerator * (lcmDenominator / problem.left.denominator) +
|
||||
problem.right.numerator * (lcmDenominator / problem.right.denominator)
|
||||
const divisor = gcdInt(numerator, lcmDenominator)
|
||||
return { numerator: numerator / divisor, denominator: lcmDenominator / divisor }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate maximum number of digits in any problem on this page
|
||||
* Returns max digits across all operands (handles both addition and subtraction)
|
||||
|
|
@ -448,9 +463,11 @@ ${(() => {
|
|||
function calculateAnswer(problem: WorksheetProblem): number {
|
||||
if (problem.operator === 'add') {
|
||||
return problem.a + problem.b
|
||||
} else {
|
||||
} else if (problem.operator === 'sub') {
|
||||
return problem.minuend - problem.subtrahend
|
||||
}
|
||||
const fractionAnswer = addFractions(problem as FractionProblem)
|
||||
return fractionAnswer.numerator / fractionAnswer.denominator
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -462,14 +479,164 @@ function formatProblemWithAnswer(
|
|||
index: number,
|
||||
showNumber: boolean
|
||||
): string {
|
||||
const answer = calculateAnswer(problem)
|
||||
const prefix = showNumber ? `*${index + 1}.* ` : ''
|
||||
if (problem.operator === 'add') {
|
||||
const prefix = showNumber ? `*${index + 1}.* ` : ''
|
||||
return `${prefix}${problem.a} + ${problem.b} = *${answer}*`
|
||||
} else {
|
||||
const prefix = showNumber ? `*${index + 1}.* ` : ''
|
||||
return `${prefix}${problem.minuend} − ${problem.subtrahend} = *${answer}*`
|
||||
return `${prefix}${problem.a} + ${problem.b} = *${calculateAnswer(problem)}*`
|
||||
}
|
||||
if (problem.operator === 'sub') {
|
||||
return `${prefix}${problem.minuend} − ${problem.subtrahend} = *${calculateAnswer(problem)}*`
|
||||
}
|
||||
const answer = addFractions(problem as FractionProblem)
|
||||
const left = (problem as FractionProblem).left
|
||||
const right = (problem as FractionProblem).right
|
||||
return `${prefix}${left.numerator}/${left.denominator} + ${right.numerator}/${right.denominator} = *${answer.numerator}/${answer.denominator}*`
|
||||
}
|
||||
|
||||
function generateFractionPageTypst(
|
||||
config: WorksheetConfig,
|
||||
pageProblems: WorksheetProblem[],
|
||||
problemOffset: number,
|
||||
rowsPerPage: number,
|
||||
qrCodeSvg?: string,
|
||||
shareCode?: string,
|
||||
domain?: string
|
||||
): string {
|
||||
const actualRows = Math.ceil(pageProblems.length / config.cols)
|
||||
const margin = 0.4
|
||||
const contentWidth = config.page.wIn - margin * 2
|
||||
const contentHeight = config.page.hIn - margin * 2
|
||||
const headerHeight = 0.35
|
||||
const availableHeight = contentHeight - headerHeight
|
||||
const problemBoxHeight = availableHeight / actualRows
|
||||
const problemBoxWidth = contentWidth / config.cols
|
||||
|
||||
const problemsTypst = pageProblems
|
||||
.map((p) => {
|
||||
const fraction = p as FractionProblem
|
||||
return ` (left_num: ${fraction.left.numerator}, left_den: ${fraction.left.denominator}, right_num: ${fraction.right.numerator}, right_den: ${fraction.right.denominator}),`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const description = generateWorksheetDescription(config)
|
||||
const brandDomain = domain || 'abaci.one'
|
||||
const breadcrumb = 'Create › Worksheets'
|
||||
|
||||
return String.raw`
|
||||
// fraction-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")
|
||||
|
||||
#let problems = (
|
||||
${problemsTypst}
|
||||
)
|
||||
|
||||
#block(breakable: false)[
|
||||
#box(
|
||||
width: 100%,
|
||||
stroke: (bottom: 1pt + gray),
|
||||
inset: (bottom: 2pt),
|
||||
)[
|
||||
#grid(
|
||||
columns: (1fr, auto),
|
||||
column-gutter: 0.1in,
|
||||
align: (left + top, right + top),
|
||||
[
|
||||
#text(size: 0.85em, weight: "bold")[${description.title}] \\
|
||||
#text(size: 0.6em, fill: gray.darken(20%))[${description.scaffolding}]
|
||||
],
|
||||
[
|
||||
#stack(dir: ttb, spacing: 1pt, align(right)[
|
||||
#text(size: 0.6em)[*Date:* ${config.date}]
|
||||
], align(right)[
|
||||
#text(size: 0.5em, fill: gray.darken(10%), weight: "medium")[${brandDomain}]
|
||||
], align(right)[
|
||||
#text(size: 0.4em, fill: gray)[${breadcrumb}]
|
||||
])
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
#v(-0.25in)
|
||||
|
||||
#let fraction-box(problem, index) = {
|
||||
box(
|
||||
inset: (top: 0pt, bottom: -${(problemBoxHeight / 3).toFixed(3)}in, left: 0pt, right: 0pt),
|
||||
width: ${problemBoxWidth}in,
|
||||
height: ${problemBoxHeight}in,
|
||||
stroke: (thickness: 1pt, dash: "dashed", paint: gray.darken(20%))
|
||||
)[
|
||||
#if index != none {
|
||||
place(top + left, dx: 0.02in, dy: 0.02in)[
|
||||
#text(size: ${(problemBoxHeight * 72 * 0.35).toFixed(1)}pt, weight: "bold", font: "New Computer Modern Math")[\##(index + 1).]
|
||||
]
|
||||
}
|
||||
#align(center + horizon)[
|
||||
#text(size: ${config.fontSize}pt)[#frac(problem.left_num, problem.left_den) + #frac(problem.right_num, problem.right_den) = ]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
#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() {
|
||||
(fraction-box(problems.at(idx), ${problemOffset} + idx),)
|
||||
} else {
|
||||
(box(width: ${problemBoxWidth}in, height: ${problemBoxHeight}in),)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
`
|
||||
}
|
||||
|
||||
function generateFractionAnswerKeyTypst(
|
||||
config: WorksheetConfig,
|
||||
problems: WorksheetProblem[],
|
||||
showProblemNumbers: boolean,
|
||||
qrCodeSvg?: string,
|
||||
shareCode?: string
|
||||
): string[] {
|
||||
const problemsPerPage = config.problemsPerPage
|
||||
const worksheetPageCount = Math.ceil(problems.length / problemsPerPage)
|
||||
const pages: string[] = []
|
||||
|
||||
for (let i = 0; i < worksheetPageCount; i++) {
|
||||
const start = i * problemsPerPage
|
||||
const pageProblems = problems.slice(start, start + problemsPerPage)
|
||||
const answers = pageProblems
|
||||
.map((problem, idx) => formatProblemWithAnswer(problem, start + idx, showProblemNumbers))
|
||||
.join(' \\\n')
|
||||
|
||||
pages.push(String.raw`
|
||||
#set page(width: ${config.page.wIn}in, height: ${config.page.hIn}in, margin: 0.6in)
|
||||
#set text(size: ${config.fontSize}pt, font: "New Computer Modern Math")
|
||||
|
||||
#block(breakable: false)[
|
||||
#text(size: 12pt, weight: "bold")[Answer Key - Page ${i + 1}] \\
|
||||
${answers}
|
||||
${
|
||||
qrCodeSvg
|
||||
? `\\
|
||||
#place(bottom + left, dx: 0.1in, dy: -0.1in)[#stack(dir: ttb, spacing: 2pt, align(center)[#image(bytes("${qrCodeSvg.replace(/"/g, '\\"').replace(/\n/g, '')}"), format: "svg", width: 0.63in, height: 0.63in)], align(center)[#text(size: 7pt, font: "Courier New")[${shareCode || 'PREVIEW'}]])]`
|
||||
: ''
|
||||
}
|
||||
]
|
||||
`)
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -622,6 +789,35 @@ export async function generateTypstSource(
|
|||
// Chunk problems into discrete pages
|
||||
const pages = chunkProblems(problems, problemsPerPage)
|
||||
|
||||
const hasFractionProblems = problems.some((p) => p.operator === 'fraction')
|
||||
if (hasFractionProblems) {
|
||||
const worksheetPages = pages.map((pageProblems, pageIndex) =>
|
||||
generateFractionPageTypst(
|
||||
config,
|
||||
pageProblems,
|
||||
pageIndex * problemsPerPage,
|
||||
rowsPerPage,
|
||||
qrCodeSvg,
|
||||
shareCode,
|
||||
brandDomain
|
||||
)
|
||||
)
|
||||
|
||||
if (config.includeAnswerKey) {
|
||||
const showProblemNumbers = true
|
||||
const answerPages = generateFractionAnswerKeyTypst(
|
||||
config,
|
||||
problems,
|
||||
showProblemNumbers,
|
||||
qrCodeSvg,
|
||||
shareCode
|
||||
)
|
||||
return [...worksheetPages, ...answerPages]
|
||||
}
|
||||
|
||||
return worksheetPages
|
||||
}
|
||||
|
||||
// Generate separate Typst source for each worksheet page
|
||||
const worksheetPages = pages.map((pageProblems, pageIndex) =>
|
||||
generatePageTypst(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export const SETTING_ICONS = {
|
|||
multiplication: '×',
|
||||
division: '÷',
|
||||
mixed: '±',
|
||||
fractions: '¾',
|
||||
},
|
||||
difficulty: {
|
||||
smart: '🎯',
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export function validateProblemSpace(
|
|||
pages: number,
|
||||
digitRange: { min: number; max: number },
|
||||
pAnyStart: number,
|
||||
operator: 'addition' | 'subtraction' | 'mixed'
|
||||
operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
): ProblemSpaceValidation {
|
||||
const requestedProblems = problemsPerPage * pages
|
||||
const warnings: string[] = []
|
||||
|
|
@ -123,6 +123,9 @@ export function validateProblemSpace(
|
|||
const addSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, 'addition')
|
||||
const subSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, 'subtraction')
|
||||
estimatedSpace = addSpace + subSpace
|
||||
} else if (operator === 'fractions') {
|
||||
// Fractions have a very large combinatorial space when denominators are unconstrained
|
||||
estimatedSpace = 1000000
|
||||
} else {
|
||||
estimatedSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, operator)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,8 +167,8 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
|
|||
|
||||
// V4: Validate operator (addition, subtraction, or mixed)
|
||||
const operator = formState.operator ?? 'addition'
|
||||
if (!['addition', 'subtraction', 'mixed'].includes(operator)) {
|
||||
errors.push('Operator must be "addition", "subtraction", or "mixed"')
|
||||
if (!['addition', 'subtraction', 'mixed', 'fractions'].includes(operator)) {
|
||||
errors.push('Operator must be "addition", "subtraction", "mixed", or "fractions"')
|
||||
}
|
||||
|
||||
// Validate seed (must be positive integer)
|
||||
|
|
|
|||
Loading…
Reference in New Issue