Compare commits
1 Commits
main
...
codex/add-
| Author | SHA1 | Date |
|---|---|---|
|
|
52f33ee276 |
|
|
@ -2,8 +2,8 @@ import { css } from '@styled/css'
|
||||||
import { OperatorIcon } from './OperatorIcon'
|
import { OperatorIcon } from './OperatorIcon'
|
||||||
|
|
||||||
export interface OperatorSectionProps {
|
export interface OperatorSectionProps {
|
||||||
operator: 'addition' | 'subtraction' | 'mixed' | undefined
|
operator: 'addition' | 'subtraction' | 'mixed' | 'fractions' | undefined
|
||||||
onChange: (operator: 'addition' | 'subtraction' | 'mixed') => void
|
onChange: (operator: 'addition' | 'subtraction' | 'mixed' | 'fractions') => void
|
||||||
isDark?: boolean
|
isDark?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,6 +187,67 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{/* Fractions Toggle */}
|
||||||
|
<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:
|
||||||
|
operator === 'fractions'
|
||||||
|
? isDark
|
||||||
|
? 'brand.900'
|
||||||
|
: 'brand.50'
|
||||||
|
: isDark
|
||||||
|
? 'gray.700'
|
||||||
|
: 'white',
|
||||||
|
borderColor: operator === 'fractions' ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
|
||||||
|
_hover: {
|
||||||
|
borderColor: 'brand.400',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={operator === 'fractions'}
|
||||||
|
onChange={() => onChange('fractions')}
|
||||||
|
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="addition" isDark={isDark} />
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
fontSize: 'sm',
|
||||||
|
fontWeight: 'semibold',
|
||||||
|
color: isDark ? 'gray.200' : 'gray.700',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Mixed Denominator Fractions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
|
|
@ -196,11 +257,13 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
|
||||||
lineHeight: '1.5',
|
lineHeight: '1.5',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{additionChecked && subtractionChecked
|
{operator === 'fractions'
|
||||||
? 'Problems will randomly use addition or subtraction'
|
? 'All problems will add fractions with different denominators'
|
||||||
: subtractionChecked
|
: additionChecked && subtractionChecked
|
||||||
? 'All problems will be subtraction'
|
? 'Problems will randomly use addition or subtraction'
|
||||||
: 'All problems will be addition'}
|
: subtractionChecked
|
||||||
|
? 'All problems will be subtraction'
|
||||||
|
: 'All problems will be addition'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import type { DisplayRules } from '../../displayRules'
|
||||||
interface ProblemPreviewProps {
|
interface ProblemPreviewProps {
|
||||||
displayRules: DisplayRules
|
displayRules: DisplayRules
|
||||||
resolvedDisplayRules?: DisplayRules
|
resolvedDisplayRules?: DisplayRules
|
||||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||||
digitRange?: { min: number; max: number }
|
digitRange?: { min: number; max: number }
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,10 @@ import { ProblemPreview } from './ProblemPreview'
|
||||||
export interface Tab {
|
export interface Tab {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
icon: string | ((operator?: 'addition' | 'subtraction' | 'mixed') => string) | 'preview'
|
icon:
|
||||||
|
| string
|
||||||
|
| ((operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions') => string)
|
||||||
|
| 'preview'
|
||||||
subtitle?: (props: {
|
subtitle?: (props: {
|
||||||
mode?: 'custom' | 'manual' | 'mastery'
|
mode?: 'custom' | 'manual' | 'mastery'
|
||||||
difficultyProfile?: string
|
difficultyProfile?: string
|
||||||
|
|
@ -20,7 +23,7 @@ export interface Tab {
|
||||||
pages?: number
|
pages?: number
|
||||||
displayRules?: DisplayRules
|
displayRules?: DisplayRules
|
||||||
resolvedDisplayRules?: DisplayRules
|
resolvedDisplayRules?: DisplayRules
|
||||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||||
}) => string | null
|
}) => string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,6 +34,7 @@ export const TABS: Tab[] = [
|
||||||
icon: (operator) => {
|
icon: (operator) => {
|
||||||
if (operator === 'mixed') return '±'
|
if (operator === 'mixed') return '±'
|
||||||
if (operator === 'subtraction') return '−'
|
if (operator === 'subtraction') return '−'
|
||||||
|
if (operator === 'fractions') return '⅟'
|
||||||
return '+'
|
return '+'
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -115,7 +119,7 @@ export const TABS: Tab[] = [
|
||||||
interface TabNavigationProps {
|
interface TabNavigationProps {
|
||||||
activeTab: string
|
activeTab: string
|
||||||
onChange: (tabId: string) => void
|
onChange: (tabId: string) => void
|
||||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||||
mode?: 'custom' | 'manual' | 'mastery'
|
mode?: 'custom' | 'manual' | 'mastery'
|
||||||
difficultyProfile?: string
|
difficultyProfile?: string
|
||||||
interpolate?: boolean
|
interpolate?: boolean
|
||||||
|
|
|
||||||
|
|
@ -343,7 +343,7 @@ const additionConfigV4BaseSchema = z.object({
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// V4: Operator selection (addition, subtraction, or mixed)
|
// 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)
|
// Regrouping probabilities (shared between modes)
|
||||||
pAnyStart: z.number().min(0).max(1),
|
pAnyStart: z.number().min(0).max(1),
|
||||||
|
|
|
||||||
|
|
@ -243,7 +243,7 @@ function describeScaffoldingChange(
|
||||||
fromRules: DisplayRules,
|
fromRules: DisplayRules,
|
||||||
toRules: DisplayRules,
|
toRules: DisplayRules,
|
||||||
direction: 'added' | 'reduced',
|
direction: 'added' | 'reduced',
|
||||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||||
): string {
|
): string {
|
||||||
const changes: string[] = []
|
const changes: string[] = []
|
||||||
|
|
||||||
|
|
@ -786,7 +786,7 @@ export function makeHarder(
|
||||||
displayRules: DisplayRules
|
displayRules: DisplayRules
|
||||||
},
|
},
|
||||||
mode: DifficultyMode = 'both',
|
mode: DifficultyMode = 'both',
|
||||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||||
): {
|
): {
|
||||||
pAnyStart: number
|
pAnyStart: number
|
||||||
pAllStart: number
|
pAllStart: number
|
||||||
|
|
@ -989,7 +989,7 @@ export function makeEasier(
|
||||||
displayRules: DisplayRules
|
displayRules: DisplayRules
|
||||||
},
|
},
|
||||||
mode: DifficultyMode = 'both',
|
mode: DifficultyMode = 'both',
|
||||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||||
): {
|
): {
|
||||||
pAnyStart: number
|
pAnyStart: number
|
||||||
pAllStart: number
|
pAllStart: number
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type { WorksheetFormState } from '@/app/create/worksheets/types'
|
||||||
import {
|
import {
|
||||||
generateMasteryMixedProblems,
|
generateMasteryMixedProblems,
|
||||||
generateMixedProblems,
|
generateMixedProblems,
|
||||||
|
generateFractionProblems,
|
||||||
generateProblems,
|
generateProblems,
|
||||||
generateSubtractionProblems,
|
generateSubtractionProblems,
|
||||||
} from './problemGenerator'
|
} from './problemGenerator'
|
||||||
|
|
@ -86,7 +87,9 @@ export async function generateWorksheetPreview(
|
||||||
let problems
|
let problems
|
||||||
|
|
||||||
// Special handling for mastery + mixed mode
|
// Special handling for mastery + mixed mode
|
||||||
if (mode === 'mastery' && operator === 'mixed') {
|
if (operator === 'fractions') {
|
||||||
|
problems = generateFractionProblems(validatedConfig.total, validatedConfig.seed ?? Date.now())
|
||||||
|
} else if (mode === 'mastery' && operator === 'mixed') {
|
||||||
// Query both skill configs
|
// Query both skill configs
|
||||||
const addSkillId = config.currentAdditionSkillId
|
const addSkillId = config.currentAdditionSkillId
|
||||||
const subSkillId = config.currentSubtractionSkillId
|
const subSkillId = config.currentSubtractionSkillId
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AdditionProblem,
|
AdditionProblem,
|
||||||
|
FractionProblem,
|
||||||
ProblemCategory,
|
ProblemCategory,
|
||||||
SubtractionProblem,
|
SubtractionProblem,
|
||||||
WorksheetProblem,
|
WorksheetProblem,
|
||||||
|
|
@ -1269,3 +1270,49 @@ export function generateMixedProblems(
|
||||||
|
|
||||||
return problems
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function generateFractionProblems(
|
||||||
|
total: number,
|
||||||
|
seed: number,
|
||||||
|
options: {
|
||||||
|
minNumerator?: number
|
||||||
|
maxNumerator?: number
|
||||||
|
minDenominator?: number
|
||||||
|
maxDenominator?: number
|
||||||
|
} = {}
|
||||||
|
): FractionProblem[] {
|
||||||
|
const {
|
||||||
|
minNumerator = 1,
|
||||||
|
maxNumerator = 9,
|
||||||
|
minDenominator = 2,
|
||||||
|
maxDenominator = 12,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const rand = createPRNG(seed)
|
||||||
|
const problems: FractionProblem[] = []
|
||||||
|
|
||||||
|
const randomFraction = () => {
|
||||||
|
const denominator = randint(minDenominator, maxDenominator, rand)
|
||||||
|
const numerator = randint(minNumerator, Math.max(minNumerator, denominator - 1), rand)
|
||||||
|
return { numerator, denominator }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
let a = randomFraction()
|
||||||
|
let b = randomFraction()
|
||||||
|
|
||||||
|
while (a.denominator === b.denominator) {
|
||||||
|
b = randomFraction()
|
||||||
|
}
|
||||||
|
|
||||||
|
problems.push({
|
||||||
|
aNumerator: a.numerator,
|
||||||
|
aDenominator: a.denominator,
|
||||||
|
bNumerator: b.numerator,
|
||||||
|
bDenominator: b.denominator,
|
||||||
|
operator: 'fractions',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ export type WorksheetFormState = Partial<Omit<AdditionConfigV4Custom, 'version'>
|
||||||
/**
|
/**
|
||||||
* Worksheet operator type
|
* Worksheet operator type
|
||||||
*/
|
*/
|
||||||
export type WorksheetOperator = 'addition' | 'subtraction' | 'mixed'
|
export type WorksheetOperator = 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single addition problem
|
* A single addition problem
|
||||||
|
|
@ -136,10 +136,21 @@ export interface SubtractionProblem {
|
||||||
operator: 'sub'
|
operator: 'sub'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fraction addition problem (mixed denominators)
|
||||||
|
*/
|
||||||
|
export interface FractionProblem {
|
||||||
|
aNumerator: number
|
||||||
|
aDenominator: number
|
||||||
|
bNumerator: number
|
||||||
|
bDenominator: number
|
||||||
|
operator: 'fractions'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified problem type (addition or subtraction)
|
* Unified problem type (addition or subtraction)
|
||||||
*/
|
*/
|
||||||
export type WorksheetProblem = AdditionProblem | SubtractionProblem
|
export type WorksheetProblem = AdditionProblem | SubtractionProblem | FractionProblem
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validation result
|
* Validation result
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Typst document generator for addition worksheets
|
// 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 { resolveDisplayForProblem } from './displayRules'
|
||||||
import { analyzeProblem, analyzeSubtractionProblem } from './problemAnalysis'
|
import { analyzeProblem, analyzeSubtractionProblem } from './problemAnalysis'
|
||||||
import { generateQRCodeSVG } from './qrCodeGenerator'
|
import { generateQRCodeSVG } from './qrCodeGenerator'
|
||||||
|
|
@ -24,6 +24,13 @@ function generateWorksheetDescription(config: WorksheetConfig): {
|
||||||
title: string
|
title: string
|
||||||
scaffolding: string
|
scaffolding: string
|
||||||
} {
|
} {
|
||||||
|
if (config.operator === 'fractions') {
|
||||||
|
return {
|
||||||
|
title: 'Fraction addition (mixed denominators)',
|
||||||
|
scaffolding: 'No scaffolding elements required',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Line 1: Digit range + operator + regrouping percentage
|
// Line 1: Digit range + operator + regrouping percentage
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
|
||||||
|
|
@ -116,6 +123,26 @@ function chunkProblems(problems: WorksheetProblem[], pageSize: number): Workshee
|
||||||
return pages
|
return pages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gcd(a: number, b: number): number {
|
||||||
|
return b === 0 ? Math.abs(a) : gcd(b, a % b)
|
||||||
|
}
|
||||||
|
|
||||||
|
function lcm(a: number, b: number): number {
|
||||||
|
return Math.abs((a * b) / gcd(a, b))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAndSimplifyFractions(problem: FractionProblem) {
|
||||||
|
const commonDenominator = lcm(problem.aDenominator, problem.bDenominator)
|
||||||
|
const numerator =
|
||||||
|
problem.aNumerator * (commonDenominator / problem.aDenominator) +
|
||||||
|
problem.bNumerator * (commonDenominator / problem.bDenominator)
|
||||||
|
const divisor = gcd(numerator, commonDenominator)
|
||||||
|
return {
|
||||||
|
numerator: numerator / divisor,
|
||||||
|
denominator: commonDenominator / divisor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate maximum number of digits in any problem on this page
|
* Calculate maximum number of digits in any problem on this page
|
||||||
* Returns max digits across all operands (handles both addition and subtraction)
|
* Returns max digits across all operands (handles both addition and subtraction)
|
||||||
|
|
@ -128,12 +155,21 @@ function calculateMaxDigits(problems: WorksheetProblem[]): number {
|
||||||
const digitsB = problem.b.toString().length
|
const digitsB = problem.b.toString().length
|
||||||
const maxProblemDigits = Math.max(digitsA, digitsB)
|
const maxProblemDigits = Math.max(digitsA, digitsB)
|
||||||
maxDigits = Math.max(maxDigits, maxProblemDigits)
|
maxDigits = Math.max(maxDigits, maxProblemDigits)
|
||||||
} else {
|
} else if (problem.operator === 'sub') {
|
||||||
// Subtraction
|
// Subtraction
|
||||||
const digitsMinuend = problem.minuend.toString().length
|
const digitsMinuend = problem.minuend.toString().length
|
||||||
const digitsSubtrahend = problem.subtrahend.toString().length
|
const digitsSubtrahend = problem.subtrahend.toString().length
|
||||||
const maxProblemDigits = Math.max(digitsMinuend, digitsSubtrahend)
|
const maxProblemDigits = Math.max(digitsMinuend, digitsSubtrahend)
|
||||||
maxDigits = Math.max(maxDigits, maxProblemDigits)
|
maxDigits = Math.max(maxDigits, maxProblemDigits)
|
||||||
|
} else {
|
||||||
|
const fractionProblem = problem as FractionProblem
|
||||||
|
const maxProblemDigits = Math.max(
|
||||||
|
fractionProblem.aNumerator.toString().length,
|
||||||
|
fractionProblem.aDenominator.toString().length,
|
||||||
|
fractionProblem.bNumerator.toString().length,
|
||||||
|
fractionProblem.bDenominator.toString().length
|
||||||
|
)
|
||||||
|
maxDigits = Math.max(maxDigits, maxProblemDigits)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return maxDigits
|
return maxDigits
|
||||||
|
|
@ -596,6 +632,12 @@ export async function generateTypstSource(
|
||||||
shareUrl?: string,
|
shareUrl?: string,
|
||||||
domain?: string
|
domain?: string
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
|
const hasFractionProblems = problems.some((p) => (p as WorksheetProblem).operator === 'fractions')
|
||||||
|
|
||||||
|
if (hasFractionProblems) {
|
||||||
|
return await generateFractionTypstSource(config, problems as FractionProblem[], shareUrl, domain)
|
||||||
|
}
|
||||||
|
|
||||||
// Use the problemsPerPage directly from config (primary state)
|
// Use the problemsPerPage directly from config (primary state)
|
||||||
const problemsPerPage = config.problemsPerPage
|
const problemsPerPage = config.problemsPerPage
|
||||||
const rowsPerPage = problemsPerPage / config.cols
|
const rowsPerPage = problemsPerPage / config.cols
|
||||||
|
|
@ -651,3 +693,133 @@ export async function generateTypstSource(
|
||||||
|
|
||||||
return worksheetPages
|
return worksheetPages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateFractionPageTypst(
|
||||||
|
config: WorksheetConfig,
|
||||||
|
pageProblems: FractionProblem[],
|
||||||
|
problemOffset: number,
|
||||||
|
qrCodeSvg?: string,
|
||||||
|
shareCode?: string,
|
||||||
|
domain?: string
|
||||||
|
): string {
|
||||||
|
const margin = 0.4
|
||||||
|
const problemData = pageProblems
|
||||||
|
.map((p, idx) => {
|
||||||
|
const answer = addAndSimplifyFractions(p)
|
||||||
|
return ` (number: ${problemOffset + idx + 1}, aNum: ${p.aNumerator}, aDen: ${p.aDenominator}, bNum: ${p.bNumerator}, bDen: ${p.bDenominator}, ansNum: ${answer.numerator}, ansDen: ${answer.denominator}),`
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
return `#let fraction-problems = (
|
||||||
|
${problemData}
|
||||||
|
)
|
||||||
|
|
||||||
|
#page(
|
||||||
|
paper: (${config.page.wIn}in, ${config.page.hIn}in),
|
||||||
|
margin: (${margin}in, ${margin}in),
|
||||||
|
header: align(center)[
|
||||||
|
#text(size: 14pt, weight: 'bold')[${config.name}]
|
||||||
|
#v(6pt)
|
||||||
|
#text(size: 10pt)[Fraction Addition (Mixed Denominators)]
|
||||||
|
],
|
||||||
|
footer: align(center)[
|
||||||
|
#text(size: 8pt)[${domain || 'abaci.one'}]
|
||||||
|
],
|
||||||
|
)[
|
||||||
|
#grid(columns: ${config.cols}, gutter: 0.3in)[
|
||||||
|
..for p in fraction-problems {
|
||||||
|
box(stroke: 0.5pt, inset: 10pt)[
|
||||||
|
#stack(dir: ttb, spacing: 6pt)[
|
||||||
|
#align(center)[
|
||||||
|
#text(size: 13pt)[#frac(p.aNum, p.aDen) + #frac(p.bNum, p.bDen) = \h(30pt)]
|
||||||
|
]
|
||||||
|
#align(right)[#text(size: 8pt)[#p.number]]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
${
|
||||||
|
qrCodeSvg
|
||||||
|
? `#place(bottom + left, dx: 0.1in, dy: -0.05in)[#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'}]])]`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateFractionAnswerKey(
|
||||||
|
config: WorksheetConfig,
|
||||||
|
problems: FractionProblem[],
|
||||||
|
qrCodeSvg?: string,
|
||||||
|
shareCode?: string
|
||||||
|
): string[] {
|
||||||
|
const margin = 0.4
|
||||||
|
const answerLines = problems
|
||||||
|
.map((p, idx) => {
|
||||||
|
const answer = addAndSimplifyFractions(p)
|
||||||
|
return ` #text(size: 11pt)[${idx + 1}. #frac(${p.aNumerator}, ${p.aDenominator}) + #frac(${p.bNumerator}, ${p.bDenominator}) = #frac(${answer.numerator}, ${answer.denominator})]`
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const qrBlock =
|
||||||
|
qrCodeSvg && shareCode
|
||||||
|
? `#place(bottom + left, dx: 0.1in, dy: -0.05in)[#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}])])]`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return [
|
||||||
|
`#page(
|
||||||
|
paper: (${config.page.wIn}in, ${config.page.hIn}in),
|
||||||
|
margin: (${margin}in, ${margin}in),
|
||||||
|
header: align(center)[#text(size: 14pt, weight: 'bold')[Answer Key - Fractions]],
|
||||||
|
)[
|
||||||
|
#stack(dir: ttb, spacing: 6pt)[
|
||||||
|
${answerLines}
|
||||||
|
]
|
||||||
|
${qrBlock}
|
||||||
|
]
|
||||||
|
`,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateFractionTypstSource(
|
||||||
|
config: WorksheetConfig,
|
||||||
|
problems: FractionProblem[],
|
||||||
|
shareUrl?: string,
|
||||||
|
domain?: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
const problemsPerPage = config.problemsPerPage
|
||||||
|
const pages = chunkProblems(problems, problemsPerPage)
|
||||||
|
|
||||||
|
let qrCodeSvg: string | undefined
|
||||||
|
let shareCode: string | undefined
|
||||||
|
if (config.includeQRCode && shareUrl) {
|
||||||
|
qrCodeSvg = await generateQRCodeSVG(shareUrl, 200)
|
||||||
|
shareCode = extractShareCode(shareUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
let brandDomain = domain
|
||||||
|
if (!brandDomain && shareUrl) {
|
||||||
|
try {
|
||||||
|
brandDomain = new URL(shareUrl).hostname
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid URL and fall back to default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fractionPages = pages.map((page, pageIndex) =>
|
||||||
|
generateFractionPageTypst(
|
||||||
|
config,
|
||||||
|
page,
|
||||||
|
pageIndex * problemsPerPage,
|
||||||
|
qrCodeSvg,
|
||||||
|
shareCode,
|
||||||
|
brandDomain
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (config.includeAnswerKey) {
|
||||||
|
return [...fractionPages, ...generateFractionAnswerKey(config, problems, qrCodeSvg, shareCode)]
|
||||||
|
}
|
||||||
|
|
||||||
|
return fractionPages
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ export function validateProblemSpace(
|
||||||
pages: number,
|
pages: number,
|
||||||
digitRange: { min: number; max: number },
|
digitRange: { min: number; max: number },
|
||||||
pAnyStart: number,
|
pAnyStart: number,
|
||||||
operator: 'addition' | 'subtraction' | 'mixed'
|
operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||||
): ProblemSpaceValidation {
|
): ProblemSpaceValidation {
|
||||||
const requestedProblems = problemsPerPage * pages
|
const requestedProblems = problemsPerPage * pages
|
||||||
const warnings: string[] = []
|
const warnings: string[] = []
|
||||||
|
|
@ -123,12 +123,15 @@ export function validateProblemSpace(
|
||||||
const addSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, 'addition')
|
const addSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, 'addition')
|
||||||
const subSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, 'subtraction')
|
const subSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, 'subtraction')
|
||||||
estimatedSpace = addSpace + subSpace
|
estimatedSpace = addSpace + subSpace
|
||||||
|
} else if (operator === 'fractions') {
|
||||||
|
// Fractions have extremely large combinatorial space; treat as effectively infinite
|
||||||
|
estimatedSpace = Number.POSITIVE_INFINITY
|
||||||
} else {
|
} else {
|
||||||
estimatedSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, operator)
|
estimatedSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, operator)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate duplicate risk
|
// Calculate duplicate risk
|
||||||
const ratio = requestedProblems / estimatedSpace
|
const ratio = estimatedSpace === Number.POSITIVE_INFINITY ? 0 : requestedProblems / estimatedSpace
|
||||||
let duplicateRisk: 'none' | 'low' | 'medium' | 'high' | 'extreme'
|
let duplicateRisk: 'none' | 'low' | 'medium' | 'high' | 'extreme'
|
||||||
|
|
||||||
if (ratio < 0.3) {
|
if (ratio < 0.3) {
|
||||||
|
|
|
||||||
|
|
@ -167,8 +167,8 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
|
||||||
|
|
||||||
// V4: Validate operator (addition, subtraction, or mixed)
|
// V4: Validate operator (addition, subtraction, or mixed)
|
||||||
const operator = formState.operator ?? 'addition'
|
const operator = formState.operator ?? 'addition'
|
||||||
if (!['addition', 'subtraction', 'mixed'].includes(operator)) {
|
if (!['addition', 'subtraction', 'mixed', 'fractions'].includes(operator)) {
|
||||||
errors.push('Operator must be "addition", "subtraction", or "mixed"')
|
errors.push('Operator must be "addition", "subtraction", "fractions", or "mixed"')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate seed (must be positive integer)
|
// Validate seed (must be positive integer)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue