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
|
showTenFrames: boolean
|
||||||
showTenFramesForAll: boolean
|
showTenFramesForAll: boolean
|
||||||
showBorrowNotation: boolean
|
showBorrowNotation: boolean
|
||||||
operator: 'addition' | 'subtraction' | 'mixed'
|
operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||||
addend1?: number
|
addend1?: number
|
||||||
addend2?: number
|
addend2?: number
|
||||||
minuend?: number
|
minuend?: number
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ interface OrientationPanelProps {
|
||||||
// Config for problem space validation
|
// Config for problem space validation
|
||||||
digitRange?: { min: number; max: number }
|
digitRange?: { min: number; max: number }
|
||||||
pAnyStart?: number
|
pAnyStart?: number
|
||||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||||
mode?: 'custom' | 'mastery'
|
mode?: 'custom' | 'mastery'
|
||||||
// Layout options
|
// Layout options
|
||||||
problemNumbers?: 'always' | 'never'
|
problemNumbers?: 'always' | 'never'
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import type { WorksheetFormState } from '@/app/create/worksheets/types'
|
||||||
export interface WorksheetConfigContextValue {
|
export interface WorksheetConfigContextValue {
|
||||||
formState: WorksheetFormState
|
formState: WorksheetFormState
|
||||||
onChange: (updates: Partial<WorksheetFormState>) => void
|
onChange: (updates: Partial<WorksheetFormState>) => void
|
||||||
operator: 'addition' | 'subtraction' | 'mixed'
|
operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||||
isReadOnly?: boolean
|
isReadOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { css } from '@styled/css'
|
import { css } from '@styled/css'
|
||||||
|
|
||||||
export interface OperatorIconProps {
|
export interface OperatorIconProps {
|
||||||
operator: 'addition' | 'subtraction' | 'mixed'
|
operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||||
isDark?: boolean
|
isDark?: boolean
|
||||||
color?: 'gray' | 'green'
|
color?: 'gray' | 'green'
|
||||||
|
|
@ -15,8 +15,9 @@ const sizeMap = {
|
||||||
xl: 'xl',
|
xl: 'xl',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
function getOperatorSymbol(operator: 'addition' | 'subtraction' | 'mixed'): string {
|
function getOperatorSymbol(operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'): string {
|
||||||
if (operator === 'mixed') return '±'
|
if (operator === 'mixed') return '±'
|
||||||
|
if (operator === 'fractions') return '¾'
|
||||||
if (operator === 'subtraction') return '−'
|
if (operator === 'subtraction') return '−'
|
||||||
return '+'
|
return '+'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -11,8 +11,13 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
|
||||||
// Derive checkbox states from operator value
|
// Derive checkbox states from operator value
|
||||||
const additionChecked = operator === 'addition' || operator === 'mixed' || !operator
|
const additionChecked = operator === 'addition' || operator === 'mixed' || !operator
|
||||||
const subtractionChecked = operator === 'subtraction' || operator === 'mixed'
|
const subtractionChecked = operator === 'subtraction' || operator === 'mixed'
|
||||||
|
const fractionsChecked = operator === 'fractions'
|
||||||
|
|
||||||
const handleAdditionChange = (checked: boolean) => {
|
const handleAdditionChange = (checked: boolean) => {
|
||||||
|
if (fractionsChecked) {
|
||||||
|
onChange(checked ? 'addition' : 'subtraction')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!checked && !subtractionChecked) {
|
if (!checked && !subtractionChecked) {
|
||||||
// Can't uncheck if it's the only one checked
|
// Can't uncheck if it's the only one checked
|
||||||
return
|
return
|
||||||
|
|
@ -27,6 +32,10 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubtractionChange = (checked: boolean) => {
|
const handleSubtractionChange = (checked: boolean) => {
|
||||||
|
if (fractionsChecked) {
|
||||||
|
onChange(checked ? 'subtraction' : 'addition')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!checked && !additionChecked) {
|
if (!checked && !additionChecked) {
|
||||||
// Can't uncheck if it's the only one checked
|
// Can't uncheck if it's the only one checked
|
||||||
return
|
return
|
||||||
|
|
@ -187,6 +196,69 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
|
|
@ -196,11 +268,13 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
|
||||||
lineHeight: '1.5',
|
lineHeight: '1.5',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{additionChecked && subtractionChecked
|
{fractionsChecked
|
||||||
? 'Problems will randomly use addition or subtraction'
|
? 'All problems will be mixed-denominator fraction addition'
|
||||||
: 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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ export function RuleThermometer({
|
||||||
const isAutoResolved = !isMixedMode && isAutoSelected && resolvedValue === option.value
|
const isAutoResolved = !isMixedMode && isAutoSelected && resolvedValue === option.value
|
||||||
|
|
||||||
// Determine which operator to show
|
// Determine which operator to show
|
||||||
let operatorToShow: 'addition' | 'subtraction' | 'mixed' | null = null
|
let operatorToShow: 'addition' | 'subtraction' | 'mixed' | 'fractions' | null = null
|
||||||
if (isAutoResolvedMixed) {
|
if (isAutoResolvedMixed) {
|
||||||
if (additionDefersHere && subtractionDefersHere) {
|
if (additionDefersHere && subtractionDefersHere) {
|
||||||
operatorToShow = 'mixed' // Both defer here
|
operatorToShow = 'mixed' // Both defer here
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { css } from '@styled/css'
|
||||||
*/
|
*/
|
||||||
export function getScaffoldingSummary(
|
export function getScaffoldingSummary(
|
||||||
displayRules: any,
|
displayRules: any,
|
||||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||||
): React.ReactNode {
|
): React.ReactNode {
|
||||||
console.log('[getScaffoldingSummary] displayRules:', displayRules, 'operator:', operator)
|
console.log('[getScaffoldingSummary] displayRules:', displayRules, 'operator:', operator)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,7 @@ 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 +20,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,6 +30,7 @@ export const TABS: Tab[] = [
|
||||||
label: 'Operator',
|
label: 'Operator',
|
||||||
icon: (operator) => {
|
icon: (operator) => {
|
||||||
if (operator === 'mixed') return '±'
|
if (operator === 'mixed') return '±'
|
||||||
|
if (operator === 'fractions') return '¾'
|
||||||
if (operator === 'subtraction') return '−'
|
if (operator === 'subtraction') return '−'
|
||||||
return '+'
|
return '+'
|
||||||
},
|
},
|
||||||
|
|
@ -115,7 +116,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
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { execSync } from 'child_process'
|
import { execSync } from 'child_process'
|
||||||
import type { WorksheetFormState } from '@/app/create/worksheets/types'
|
import type { WorksheetFormState } from '@/app/create/worksheets/types'
|
||||||
import {
|
import {
|
||||||
|
generateFractionProblems,
|
||||||
generateMasteryMixedProblems,
|
generateMasteryMixedProblems,
|
||||||
generateMixedProblems,
|
generateMixedProblems,
|
||||||
generateProblems,
|
generateProblems,
|
||||||
|
|
@ -127,33 +128,36 @@ export async function generateWorksheetPreview(
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Standard problem generation
|
// Standard problem generation
|
||||||
problems =
|
if (operator === 'addition') {
|
||||||
operator === 'addition'
|
problems = generateProblems(
|
||||||
? generateProblems(
|
validatedConfig.total,
|
||||||
validatedConfig.total,
|
validatedConfig.pAnyStart,
|
||||||
validatedConfig.pAnyStart,
|
validatedConfig.pAllStart,
|
||||||
validatedConfig.pAllStart,
|
validatedConfig.interpolate,
|
||||||
validatedConfig.interpolate,
|
validatedConfig.seed,
|
||||||
validatedConfig.seed,
|
validatedConfig.digitRange
|
||||||
validatedConfig.digitRange
|
)
|
||||||
)
|
} else if (operator === 'subtraction') {
|
||||||
: operator === 'subtraction'
|
problems = generateSubtractionProblems(
|
||||||
? generateSubtractionProblems(
|
validatedConfig.total,
|
||||||
validatedConfig.total,
|
validatedConfig.digitRange,
|
||||||
validatedConfig.digitRange,
|
validatedConfig.pAnyStart,
|
||||||
validatedConfig.pAnyStart,
|
validatedConfig.pAllStart,
|
||||||
validatedConfig.pAllStart,
|
validatedConfig.interpolate,
|
||||||
validatedConfig.interpolate,
|
validatedConfig.seed
|
||||||
validatedConfig.seed
|
)
|
||||||
)
|
} else if (operator === 'fractions') {
|
||||||
: generateMixedProblems(
|
problems = generateFractionProblems(validatedConfig.total, validatedConfig.seed)
|
||||||
validatedConfig.total,
|
} else {
|
||||||
validatedConfig.digitRange,
|
problems = generateMixedProblems(
|
||||||
validatedConfig.pAnyStart,
|
validatedConfig.total,
|
||||||
validatedConfig.pAllStart,
|
validatedConfig.digitRange,
|
||||||
validatedConfig.interpolate,
|
validatedConfig.pAnyStart,
|
||||||
validatedConfig.seed
|
validatedConfig.pAllStart,
|
||||||
)
|
validatedConfig.interpolate,
|
||||||
|
validatedConfig.seed
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[PREVIEW] Step 2: ✓ Generated ${problems.length} problems`)
|
console.log(`[PREVIEW] Step 2: ✓ Generated ${problems.length} problems`)
|
||||||
|
|
@ -328,6 +332,8 @@ export async function generateSinglePage(
|
||||||
validatedConfig.interpolate,
|
validatedConfig.interpolate,
|
||||||
validatedConfig.seed
|
validatedConfig.seed
|
||||||
)
|
)
|
||||||
|
} else if (operator === 'fractions') {
|
||||||
|
problems = generateFractionProblems(validatedConfig.total, validatedConfig.seed)
|
||||||
} else if (operator === 'subtraction') {
|
} else if (operator === 'subtraction') {
|
||||||
problems = generateSubtractionProblems(
|
problems = generateSubtractionProblems(
|
||||||
validatedConfig.total,
|
validatedConfig.total,
|
||||||
|
|
|
||||||
|
|
@ -1269,3 +1269,45 @@ export function generateMixedProblems(
|
||||||
|
|
||||||
return problems
|
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
|
* 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,19 @@ export interface SubtractionProblem {
|
||||||
operator: 'sub'
|
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)
|
* 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'
|
||||||
|
|
@ -116,6 +116,21 @@ function chunkProblems(problems: WorksheetProblem[], pageSize: number): Workshee
|
||||||
return pages
|
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
|
* 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)
|
||||||
|
|
@ -448,9 +463,11 @@ ${(() => {
|
||||||
function calculateAnswer(problem: WorksheetProblem): number {
|
function calculateAnswer(problem: WorksheetProblem): number {
|
||||||
if (problem.operator === 'add') {
|
if (problem.operator === 'add') {
|
||||||
return problem.a + problem.b
|
return problem.a + problem.b
|
||||||
} else {
|
} else if (problem.operator === 'sub') {
|
||||||
return problem.minuend - problem.subtrahend
|
return problem.minuend - problem.subtrahend
|
||||||
}
|
}
|
||||||
|
const fractionAnswer = addFractions(problem as FractionProblem)
|
||||||
|
return fractionAnswer.numerator / fractionAnswer.denominator
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -462,14 +479,164 @@ function formatProblemWithAnswer(
|
||||||
index: number,
|
index: number,
|
||||||
showNumber: boolean
|
showNumber: boolean
|
||||||
): string {
|
): string {
|
||||||
const answer = calculateAnswer(problem)
|
const prefix = showNumber ? `*${index + 1}.* ` : ''
|
||||||
if (problem.operator === 'add') {
|
if (problem.operator === 'add') {
|
||||||
const prefix = showNumber ? `*${index + 1}.* ` : ''
|
return `${prefix}${problem.a} + ${problem.b} = *${calculateAnswer(problem)}*`
|
||||||
return `${prefix}${problem.a} + ${problem.b} = *${answer}*`
|
|
||||||
} else {
|
|
||||||
const prefix = showNumber ? `*${index + 1}.* ` : ''
|
|
||||||
return `${prefix}${problem.minuend} − ${problem.subtrahend} = *${answer}*`
|
|
||||||
}
|
}
|
||||||
|
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
|
// Chunk problems into discrete pages
|
||||||
const pages = chunkProblems(problems, problemsPerPage)
|
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
|
// Generate separate Typst source for each worksheet page
|
||||||
const worksheetPages = pages.map((pageProblems, pageIndex) =>
|
const worksheetPages = pages.map((pageProblems, pageIndex) =>
|
||||||
generatePageTypst(
|
generatePageTypst(
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export const SETTING_ICONS = {
|
||||||
multiplication: '×',
|
multiplication: '×',
|
||||||
division: '÷',
|
division: '÷',
|
||||||
mixed: '±',
|
mixed: '±',
|
||||||
|
fractions: '¾',
|
||||||
},
|
},
|
||||||
difficulty: {
|
difficulty: {
|
||||||
smart: '🎯',
|
smart: '🎯',
|
||||||
|
|
|
||||||
|
|
@ -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,6 +123,9 @@ 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 a very large combinatorial space when denominators are unconstrained
|
||||||
|
estimatedSpace = 1000000
|
||||||
} else {
|
} else {
|
||||||
estimatedSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, operator)
|
estimatedSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, operator)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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", "mixed", or "fractions"')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate seed (must be positive integer)
|
// Validate seed (must be positive integer)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue