feat(worksheets): add draggable dice easter egg with physics
Add a fun easter egg where the dice in the worksheet action menu can be dragged and thrown. The dice: - Tracks pointer movement and calculates throw velocity - Uses physics simulation with gravity pulling back to origin - Rolls continuously based on movement direction and speed - Uses direct DOM manipulation for smooth 60fps animation - Triggers shuffle when thrown and returns home Also includes worksheet improvements: - Conditional name field display (hide when empty/default) - Date positioned top-right next to QR code - Reduced problem number size - Tightened header-to-grid spacing - Problem numbers aligned to cell corners 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -96,7 +96,9 @@
|
||||
"Bash(apps/web/src/arcade-games/know-your-world/features/magnifier/useMagnifierStyle.ts )",
|
||||
"Bash(apps/web/src/arcade-games/know-your-world/features/cursor/ )",
|
||||
"Bash(apps/web/src/arcade-games/know-your-world/features/interaction/ )",
|
||||
"Bash(apps/web/src/arcade-games/know-your-world/utils/heatStyles.ts)"
|
||||
"Bash(apps/web/src/arcade-games/know-your-world/utils/heatStyles.ts)",
|
||||
"Bash(ping:*)",
|
||||
"WebFetch(domain:typst.app)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Answer Key Feature Implementation Plan
|
||||
|
||||
## Design Decisions
|
||||
1. **Format**: Compact list (e.g., `1. 45 + 27 = 72`)
|
||||
2. **Placement**: End of PDF (after all worksheet pages)
|
||||
3. **Problem numbers**: Match worksheet config - show if `displayRules.problemNumbers !== 'never'`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Add config option
|
||||
- **File**: `types.ts`
|
||||
- Add `includeAnswerKey?: boolean` to `WorksheetFormState`
|
||||
- Default: `false`
|
||||
|
||||
### 2. Update validation
|
||||
- **File**: `validation.ts`
|
||||
- Pass through `includeAnswerKey` in validated config
|
||||
|
||||
### 3. Create answer key generator
|
||||
- **File**: `typstGenerator.ts` (new function)
|
||||
- Function: `generateAnswerKeyTypst(config, problems, showProblemNumbers)`
|
||||
- Output: Typst source for answer key page(s)
|
||||
- Format: Compact multi-column list
|
||||
```
|
||||
Answer Key
|
||||
|
||||
1. 45 + 27 = 72 11. 33 + 18 = 51
|
||||
2. 89 + 34 = 123 12. 56 + 77 = 133
|
||||
...
|
||||
```
|
||||
|
||||
### 4. Integrate into page generation
|
||||
- **File**: `typstGenerator.ts`
|
||||
- After worksheet pages, if `includeAnswerKey`:
|
||||
```typescript
|
||||
if (config.includeAnswerKey) {
|
||||
const answerKeyPages = generateAnswerKeyTypst(config, problems, showProblemNumbers)
|
||||
return [...worksheetPages, ...answerKeyPages]
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Add UI toggle
|
||||
- **File**: Find worksheet config form component
|
||||
- Add checkbox: "Include Answer Key"
|
||||
|
||||
### 6. Update preview (optional)
|
||||
- Show answer key pages in preview carousel
|
||||
|
||||
## Answer Key Typst Template
|
||||
|
||||
```typst
|
||||
#set page(paper: "us-letter", margin: 0.5in)
|
||||
#set text(font: "New Computer Modern Math", size: 12pt)
|
||||
|
||||
#align(center)[
|
||||
#text(size: 18pt, weight: "bold")[Answer Key]
|
||||
#v(0.5em)
|
||||
#text(size: 12pt, fill: gray)[{worksheet name}]
|
||||
]
|
||||
|
||||
#v(1em)
|
||||
|
||||
#columns(3, gutter: 1em)[
|
||||
// Problem 1
|
||||
#text[*1.* 45 + 27 = *72*]
|
||||
|
||||
// Problem 2
|
||||
#text[*2.* 89 − 34 = *55*]
|
||||
|
||||
// etc...
|
||||
]
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
1. `types.ts` - Add `includeAnswerKey` field
|
||||
2. `validation.ts` - Pass through new field
|
||||
3. `typstGenerator.ts` - Add answer key generation
|
||||
4. Worksheet form component - Add UI toggle
|
||||
5. Preview component (optional) - Show answer key pages
|
||||
@@ -5,6 +5,7 @@ import { animated, useSpring } from '@react-spring/web'
|
||||
import { css } from '@styled/css'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { WorksheetFormState } from '@/app/create/worksheets/types'
|
||||
import { UploadWorksheetModal } from '@/components/worksheets/UploadWorksheetModal'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
@@ -146,6 +147,7 @@ function DiceIcon({
|
||||
}}
|
||||
>
|
||||
<animated.div
|
||||
data-dice-cube
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
@@ -222,6 +224,104 @@ export function PreviewCenter({
|
||||
// Track which face we're showing (for ensuring consecutive rolls differ)
|
||||
const [currentFace, setCurrentFace] = useState(() => ((formState.seed ?? 0) % 5) + 2)
|
||||
|
||||
// Draggable dice state with physics simulation
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [diceOrigin, setDiceOrigin] = useState({ x: 0, y: 0 })
|
||||
const diceButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const diceContainerRef = useRef<HTMLDivElement>(null)
|
||||
const dragStartPos = useRef({ x: 0, y: 0 })
|
||||
|
||||
// Physics state for thrown dice
|
||||
const [isFlying, setIsFlying] = useState(false)
|
||||
const dicePhysics = useRef({
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
rotationX: 0,
|
||||
rotationY: 0,
|
||||
rotationZ: 0,
|
||||
})
|
||||
const lastPointerPos = useRef({ x: 0, y: 0, time: 0 })
|
||||
const animationFrameRef = useRef<number>()
|
||||
|
||||
// Ref to the portal dice element for direct DOM manipulation during drag/flying (avoids re-renders)
|
||||
const portalDiceRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Physics simulation for thrown dice - uses direct DOM manipulation for performance
|
||||
useEffect(() => {
|
||||
if (!isFlying) return
|
||||
|
||||
const GRAVITY_STRENGTH = 1.2 // Pull toward origin (spring-like)
|
||||
const BASE_FRICTION = 0.92 // Base velocity dampening
|
||||
const ROTATION_FACTOR = 0.5 // How much velocity affects rotation
|
||||
const STOP_THRESHOLD = 2 // Distance threshold to snap home
|
||||
const VELOCITY_THRESHOLD = 0.5 // Velocity threshold to snap home
|
||||
const CLOSE_RANGE = 30 // Distance at which extra damping kicks in
|
||||
|
||||
const animate = () => {
|
||||
const p = dicePhysics.current
|
||||
const el = portalDiceRef.current
|
||||
if (!el) {
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate distance to origin
|
||||
const dist = Math.sqrt(p.x * p.x + p.y * p.y)
|
||||
|
||||
// Apply spring force toward origin (proportional to distance)
|
||||
if (dist > 0) {
|
||||
const springForce = GRAVITY_STRENGTH
|
||||
p.vx += (-p.x / dist) * springForce * (dist / 50) // Stronger when further
|
||||
p.vy += (-p.y / dist) * springForce * (dist / 50)
|
||||
}
|
||||
|
||||
// Apply friction - extra damping when close to prevent oscillation
|
||||
const friction = dist < CLOSE_RANGE ? 0.85 : BASE_FRICTION
|
||||
p.vx *= friction
|
||||
p.vy *= friction
|
||||
|
||||
// Update position
|
||||
p.x += p.vx
|
||||
p.y += p.vy
|
||||
|
||||
// Update rotation based on velocity (dice rolls as it moves)
|
||||
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy)
|
||||
p.rotationX += p.vy * ROTATION_FACTOR * 10
|
||||
p.rotationY -= p.vx * ROTATION_FACTOR * 10
|
||||
p.rotationZ += speed * ROTATION_FACTOR * 2
|
||||
|
||||
// Update DOM directly - no React re-renders
|
||||
el.style.transform = `translate(${p.x}px, ${p.y}px)`
|
||||
|
||||
// Update dice rotation via CSS custom properties (the DiceIcon reads these)
|
||||
const diceEl = el.querySelector('[data-dice-cube]') as HTMLElement | null
|
||||
if (diceEl) {
|
||||
diceEl.style.transform = `rotateX(${p.rotationX}deg) rotateY(${p.rotationY}deg) rotateZ(${p.rotationZ}deg)`
|
||||
}
|
||||
|
||||
// Check if we should stop - snap to home when close and slow
|
||||
const totalVelocity = Math.sqrt(p.vx * p.vx + p.vy * p.vy)
|
||||
if (dist < STOP_THRESHOLD && totalVelocity < VELOCITY_THRESHOLD) {
|
||||
// Dice has returned home
|
||||
setIsFlying(false)
|
||||
dicePhysics.current = { x: 0, y: 0, vx: 0, vy: 0, rotationX: 0, rotationY: 0, rotationZ: 0 }
|
||||
return
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
}
|
||||
}, [isFlying])
|
||||
|
||||
// Compute target rotation: add dramatic spins, then land on the face rotation
|
||||
const targetFaceRotation = DICE_FACE_ROTATIONS[currentFace] || { rotateX: 0, rotateY: 0 }
|
||||
const diceRotation = {
|
||||
@@ -256,6 +356,105 @@ export function PreviewCenter({
|
||||
setCurrentFace(targetFace)
|
||||
}, [onChange, currentFace])
|
||||
|
||||
// Dice drag handlers for the easter egg - drag dice off and release to roll
|
||||
const handleDicePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (isGenerating) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Cancel any ongoing physics animation
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
setIsFlying(false)
|
||||
|
||||
// Capture the pointer
|
||||
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
|
||||
|
||||
// Get the dice container's position for portal rendering
|
||||
if (diceContainerRef.current) {
|
||||
const rect = diceContainerRef.current.getBoundingClientRect()
|
||||
setDiceOrigin({ x: rect.left, y: rect.top })
|
||||
}
|
||||
|
||||
dragStartPos.current = { x: e.clientX, y: e.clientY }
|
||||
lastPointerPos.current = { x: e.clientX, y: e.clientY, time: performance.now() }
|
||||
dicePhysics.current = { x: 0, y: 0, vx: 0, vy: 0, rotationX: 0, rotationY: 0, rotationZ: 0 }
|
||||
setIsDragging(true)
|
||||
},
|
||||
[isGenerating]
|
||||
)
|
||||
|
||||
const handleDicePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isDragging) return
|
||||
e.preventDefault()
|
||||
|
||||
const dx = e.clientX - dragStartPos.current.x
|
||||
const dy = e.clientY - dragStartPos.current.y
|
||||
|
||||
// Update DOM directly to avoid React re-renders during drag
|
||||
if (portalDiceRef.current) {
|
||||
portalDiceRef.current.style.transform = `translate(${dx}px, ${dy}px)`
|
||||
}
|
||||
|
||||
// Store position in ref for use when releasing
|
||||
dicePhysics.current.x = dx
|
||||
dicePhysics.current.y = dy
|
||||
|
||||
// Track velocity for throw calculation
|
||||
lastPointerPos.current = { x: e.clientX, y: e.clientY, time: performance.now() }
|
||||
},
|
||||
[isDragging]
|
||||
)
|
||||
|
||||
const handleDicePointerUp = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isDragging) return
|
||||
e.preventDefault()
|
||||
|
||||
// Release the pointer
|
||||
;(e.target as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
|
||||
// Calculate throw velocity from recent movement
|
||||
const now = performance.now()
|
||||
const dt = Math.max(now - lastPointerPos.current.time, 16) // At least 1 frame
|
||||
const vx = ((e.clientX - lastPointerPos.current.x) / dt) * 16 // Normalize to ~60fps
|
||||
const vy = ((e.clientY - lastPointerPos.current.y) / dt) * 16
|
||||
|
||||
// Get position from physics ref (set during drag)
|
||||
const posX = dicePhysics.current.x
|
||||
const posY = dicePhysics.current.y
|
||||
|
||||
// Calculate distance dragged
|
||||
const distance = Math.sqrt(posX ** 2 + posY ** 2)
|
||||
|
||||
// If dragged more than 20px, trigger throw physics
|
||||
if (distance > 20) {
|
||||
// Initialize physics with current position and throw velocity
|
||||
dicePhysics.current = {
|
||||
x: posX,
|
||||
y: posY,
|
||||
vx: vx * 1.5, // Amplify throw velocity
|
||||
vy: vy * 1.5,
|
||||
rotationX: 0,
|
||||
rotationY: 0,
|
||||
rotationZ: 0,
|
||||
}
|
||||
setIsFlying(true)
|
||||
// Trigger shuffle when thrown
|
||||
handleShuffle()
|
||||
} else {
|
||||
// Not thrown far enough, snap back
|
||||
dicePhysics.current = { x: 0, y: 0, vx: 0, vy: 0, rotationX: 0, rotationY: 0, rotationZ: 0 }
|
||||
}
|
||||
|
||||
setIsDragging(false)
|
||||
},
|
||||
[isDragging, handleShuffle]
|
||||
)
|
||||
|
||||
// Detect scrolling in the scroll container
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
@@ -406,26 +605,34 @@ export function PreviewCenter({
|
||||
</button>
|
||||
|
||||
{/* Shuffle Button - only in edit mode (1/3 split secondary action) */}
|
||||
{/* Easter egg: drag the dice off and release to roll it back! */}
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
ref={diceButtonRef}
|
||||
type="button"
|
||||
data-action="shuffle-problems"
|
||||
onClick={handleShuffle}
|
||||
onPointerDown={handleDicePointerDown}
|
||||
onPointerMove={handleDicePointerMove}
|
||||
onPointerUp={handleDicePointerUp}
|
||||
onPointerCancel={handleDicePointerUp}
|
||||
disabled={isGenerating}
|
||||
title="Shuffle problems (generate new set)"
|
||||
title="Shuffle problems (drag or click to roll)"
|
||||
className={css({
|
||||
px: '3',
|
||||
py: '2.5',
|
||||
bg: 'brand.600',
|
||||
color: 'white',
|
||||
cursor: isGenerating ? 'not-allowed' : 'pointer',
|
||||
cursor: isDragging ? 'grabbing' : isGenerating ? 'not-allowed' : 'grab',
|
||||
opacity: isGenerating ? '0.7' : '1',
|
||||
borderLeft: '1px solid',
|
||||
borderColor: 'brand.700',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s',
|
||||
transition: 'background 0.2s',
|
||||
touchAction: 'none', // Prevent scroll on touch devices
|
||||
userSelect: 'none',
|
||||
_hover: isGenerating
|
||||
? {}
|
||||
: {
|
||||
@@ -433,15 +640,53 @@ export function PreviewCenter({
|
||||
},
|
||||
})}
|
||||
>
|
||||
<DiceIcon
|
||||
rotateX={diceRotation.rotateX}
|
||||
rotateY={diceRotation.rotateY}
|
||||
rotateZ={diceRotation.rotateZ}
|
||||
isDark={isDark}
|
||||
/>
|
||||
{/* Dice container - hidden when dragging/flying since we show portal version */}
|
||||
<div
|
||||
ref={diceContainerRef}
|
||||
style={{
|
||||
opacity: isDragging || isFlying ? 0 : 1,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<DiceIcon
|
||||
rotateX={diceRotation.rotateX}
|
||||
rotateY={diceRotation.rotateY}
|
||||
rotateZ={diceRotation.rotateZ}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Portal-rendered dice when dragging/flying - renders outside button to avoid clipping */}
|
||||
{/* All transforms are applied directly via ref for performance - no React re-renders */}
|
||||
{(isDragging || isFlying) &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={portalDiceRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: diceOrigin.x,
|
||||
top: diceOrigin.y,
|
||||
// Transform is updated directly via ref during drag/flying
|
||||
transform: 'translate(0px, 0px)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 100000,
|
||||
cursor: isDragging ? 'grabbing' : 'default',
|
||||
willChange: 'transform', // Hint to browser for GPU acceleration
|
||||
}}
|
||||
>
|
||||
{/* During flying, rotation is updated directly on data-dice-cube element */}
|
||||
<DiceIcon
|
||||
rotateX={diceRotation.rotateX}
|
||||
rotateY={diceRotation.rotateY}
|
||||
rotateZ={diceRotation.rotateZ}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Dropdown Trigger */}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
|
||||
@@ -8,25 +8,82 @@
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
/**
|
||||
* Generate a QR code as an SVG string
|
||||
* Generate a ± (plus-minus) operator icon SVG for the QR code center
|
||||
* This matches the OperatorIcon React component's visual style
|
||||
*
|
||||
* @param size - Size of the icon in pixels
|
||||
* @returns SVG string of the ± symbol
|
||||
*/
|
||||
function generateOperatorIcon(size: number): string {
|
||||
const s = size
|
||||
// Use a bold, clean ± symbol centered in the icon
|
||||
// The symbol is drawn with thick strokes for good visibility
|
||||
const strokeWidth = s * 0.12
|
||||
const centerX = s / 2
|
||||
const centerY = s / 2
|
||||
|
||||
// Position the + part higher and the - part lower
|
||||
const plusCenterY = centerY - s * 0.15
|
||||
const minusCenterY = centerY + s * 0.25
|
||||
const barLength = s * 0.5
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 ${s} ${s}">
|
||||
<rect x="0" y="0" width="${s}" height="${s}" fill="white"/>
|
||||
<!-- Plus sign (horizontal bar) -->
|
||||
<line x1="${centerX - barLength / 2}" y1="${plusCenterY}" x2="${centerX + barLength / 2}" y2="${plusCenterY}" stroke="#4F46E5" stroke-width="${strokeWidth}" stroke-linecap="round"/>
|
||||
<!-- Plus sign (vertical bar) -->
|
||||
<line x1="${centerX}" y1="${plusCenterY - barLength / 2}" x2="${centerX}" y2="${plusCenterY + barLength / 2}" stroke="#4F46E5" stroke-width="${strokeWidth}" stroke-linecap="round"/>
|
||||
<!-- Minus sign -->
|
||||
<line x1="${centerX - barLength / 2}" y1="${minusCenterY}" x2="${centerX + barLength / 2}" y2="${minusCenterY}" stroke="#4F46E5" stroke-width="${strokeWidth}" stroke-linecap="round"/>
|
||||
</svg>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code as an SVG string with an optional center logo
|
||||
*
|
||||
* @param url - The URL to encode in the QR code
|
||||
* @param size - The size of the QR code in pixels (default 100)
|
||||
* @param withLogo - Whether to add an abacus logo in the center (default true)
|
||||
* @returns SVG string representing the QR code
|
||||
*/
|
||||
export async function generateQRCodeSVG(url: string, size = 100): Promise<string> {
|
||||
export async function generateQRCodeSVG(url: string, size = 100, withLogo = true): Promise<string> {
|
||||
// Use high error correction when adding a logo (can recover ~30% of data)
|
||||
const errorCorrectionLevel = withLogo ? 'H' : 'M'
|
||||
|
||||
const svg = await QRCode.toString(url, {
|
||||
type: 'svg',
|
||||
width: size,
|
||||
margin: 1,
|
||||
errorCorrectionLevel: 'M', // Medium error correction - good balance
|
||||
errorCorrectionLevel,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
|
||||
return svg
|
||||
if (!withLogo) {
|
||||
return svg
|
||||
}
|
||||
|
||||
// Add ± operator logo to the center of the QR code
|
||||
// Logo should cover ~15-20% of the QR code area for best scannability
|
||||
const logoSize = Math.round(size * 0.22)
|
||||
const logoOffset = Math.round((size - logoSize) / 2)
|
||||
|
||||
// Parse the SVG and insert the logo
|
||||
// The QR code SVG has a white background, so we add the logo on top
|
||||
const logoSvg = generateOperatorIcon(logoSize)
|
||||
|
||||
// Extract just the content of the logo SVG (without the outer svg tags)
|
||||
const logoContent = logoSvg.replace(/<svg[^>]*>/, '').replace(/<\/svg>/, '')
|
||||
|
||||
// Insert logo into QR code SVG (before closing </svg> tag)
|
||||
const svgWithLogo = svg.replace(
|
||||
'</svg>',
|
||||
`<g transform="translate(${logoOffset}, ${logoOffset})">${logoContent}</g></svg>`
|
||||
)
|
||||
|
||||
return svgWithLogo
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,100 @@ import {
|
||||
generateTypstHelpers,
|
||||
} from './typstHelpers'
|
||||
|
||||
/**
|
||||
* Generate a human-readable description of the worksheet settings
|
||||
* Format matches the difficulty preset dropdown collapsed view:
|
||||
* - Line 1: Digit range + operator + regrouping percentage
|
||||
* - Line 2: Scaffolding summary (Always: X, Y / When needed: Z)
|
||||
*
|
||||
* @param config - The worksheet configuration
|
||||
* @returns Object with title (for prominent display) and scaffolding (for detail line)
|
||||
*/
|
||||
function generateWorksheetDescription(config: WorksheetConfig): {
|
||||
title: string
|
||||
scaffolding: string
|
||||
} {
|
||||
// Line 1: Digit range + operator + regrouping percentage
|
||||
const parts: string[] = []
|
||||
|
||||
// Digit range (e.g., "2-digit" or "1–3 digit")
|
||||
const minDigits = config.digitRange?.min ?? 1
|
||||
const maxDigits = config.digitRange?.max ?? 2
|
||||
if (minDigits === maxDigits) {
|
||||
parts.push(`${minDigits}-digit`)
|
||||
} else {
|
||||
parts.push(`${minDigits}–${maxDigits} digit`)
|
||||
}
|
||||
|
||||
// Operator
|
||||
if (config.operator === 'addition') {
|
||||
parts.push('addition')
|
||||
} else if (config.operator === 'subtraction') {
|
||||
parts.push('subtraction')
|
||||
} else {
|
||||
parts.push('mixed operations')
|
||||
}
|
||||
|
||||
// Regrouping percentage (pAnyStart)
|
||||
const pAnyStart = (config as any).pAnyStart ?? 0.25
|
||||
const regroupingPercent = Math.round(pAnyStart * 100)
|
||||
parts.push(`• ${regroupingPercent}% regrouping`)
|
||||
|
||||
const title = parts.join(' ')
|
||||
|
||||
// Line 2: Scaffolding summary (matches getScaffoldingSummary format)
|
||||
const alwaysItems: string[] = []
|
||||
const conditionalItems: string[] = []
|
||||
|
||||
if (config.displayRules) {
|
||||
const rules = config.displayRules
|
||||
const operator = config.operator
|
||||
|
||||
// Addition-specific scaffolds (skip for subtraction-only)
|
||||
if (operator !== 'subtraction') {
|
||||
if (rules.carryBoxes === 'always') alwaysItems.push('carry boxes')
|
||||
else if (rules.carryBoxes && rules.carryBoxes !== 'never')
|
||||
conditionalItems.push('carry boxes')
|
||||
|
||||
if (rules.tenFrames === 'always') alwaysItems.push('ten-frames')
|
||||
else if (rules.tenFrames && rules.tenFrames !== 'never') conditionalItems.push('ten-frames')
|
||||
}
|
||||
|
||||
// Universal scaffolds
|
||||
if (rules.answerBoxes === 'always') alwaysItems.push('answer boxes')
|
||||
else if (rules.answerBoxes && rules.answerBoxes !== 'never')
|
||||
conditionalItems.push('answer boxes')
|
||||
|
||||
if (rules.placeValueColors === 'always') alwaysItems.push('place value colors')
|
||||
else if (rules.placeValueColors && rules.placeValueColors !== 'never')
|
||||
conditionalItems.push('place value colors')
|
||||
|
||||
// Subtraction-specific scaffolds (skip for addition-only)
|
||||
if (operator !== 'addition') {
|
||||
if (rules.borrowNotation === 'always') alwaysItems.push('borrow notation')
|
||||
else if (rules.borrowNotation && rules.borrowNotation !== 'never')
|
||||
conditionalItems.push('borrow notation')
|
||||
|
||||
if (rules.borrowingHints === 'always') alwaysItems.push('borrowing hints')
|
||||
else if (rules.borrowingHints && rules.borrowingHints !== 'never')
|
||||
conditionalItems.push('borrowing hints')
|
||||
}
|
||||
}
|
||||
|
||||
// Build scaffolding summary string
|
||||
const scaffoldingParts: string[] = []
|
||||
if (alwaysItems.length > 0) {
|
||||
scaffoldingParts.push(`Always: ${alwaysItems.join(', ')}`)
|
||||
}
|
||||
if (conditionalItems.length > 0) {
|
||||
scaffoldingParts.push(`When needed: ${conditionalItems.join(', ')}`)
|
||||
}
|
||||
|
||||
const scaffolding = scaffoldingParts.length > 0 ? scaffoldingParts.join(' • ') : 'No scaffolding'
|
||||
|
||||
return { title, scaffolding }
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk array into pages of specified size
|
||||
*/
|
||||
@@ -49,6 +143,7 @@ function calculateMaxDigits(problems: WorksheetProblem[]): number {
|
||||
* Generate Typst source code for a single page
|
||||
* @param qrCodeSvg - Optional raw SVG string for QR code to embed
|
||||
* @param shareCode - Optional share code to display under QR code (e.g., "k7mP2qR")
|
||||
* @param domain - Optional domain name for branding (e.g., "abaci.one")
|
||||
*/
|
||||
function generatePageTypst(
|
||||
config: WorksheetConfig,
|
||||
@@ -56,7 +151,8 @@ function generatePageTypst(
|
||||
problemOffset: number,
|
||||
rowsPerPage: number,
|
||||
qrCodeSvg?: string,
|
||||
shareCode?: string
|
||||
shareCode?: string,
|
||||
domain?: string
|
||||
): string {
|
||||
// Calculate maximum digits for proper column layout
|
||||
const maxDigits = calculateMaxDigits(pageProblems)
|
||||
@@ -205,11 +301,17 @@ ${generateSubtractionProblemStackFunction(cellSize, maxDigits)}
|
||||
let grid-stroke = if problem.showCellBorder { (thickness: 1pt, dash: "dashed", paint: gray.darken(20%)) } else { none }
|
||||
|
||||
box(
|
||||
inset: 0pt,
|
||||
inset: (top: 0pt, bottom: -${(cellSize / 3).toFixed(3)}in, left: 0pt, right: 0pt),
|
||||
width: ${problemBoxWidth}in,
|
||||
height: ${problemBoxHeight}in,
|
||||
stroke: grid-stroke
|
||||
)[
|
||||
// Problem number in top-left corner of the cell
|
||||
#if problem.showProblemNumbers and index != none {
|
||||
place(top + left, dx: 0.02in, dy: 0.02in)[
|
||||
#text(size: ${(cellSize * 72 * 0.4).toFixed(1)}pt, weight: "bold", font: "New Computer Modern Math")[\##(index + 1).]
|
||||
]
|
||||
}
|
||||
#align(center + horizon)[
|
||||
#if problem.operator == "+" {
|
||||
problem-stack(
|
||||
@@ -218,7 +320,7 @@ ${generateSubtractionProblemStackFunction(cellSize, maxDigits)}
|
||||
problem.showAnswerBoxes,
|
||||
problem.showPlaceValueColors,
|
||||
problem.showTenFrames,
|
||||
problem.showProblemNumbers
|
||||
false // Don't show problem numbers here - shown at cell level
|
||||
)
|
||||
} else {
|
||||
subtraction-problem-stack(
|
||||
@@ -227,7 +329,7 @@ ${generateSubtractionProblemStackFunction(cellSize, maxDigits)}
|
||||
problem.showAnswerBoxes,
|
||||
problem.showPlaceValueColors,
|
||||
problem.showTenFrames,
|
||||
problem.showProblemNumbers,
|
||||
false, // Don't show problem numbers here - shown at cell level
|
||||
problem.showBorrowNotation, // show-borrow-notation (scratch work boxes in minuend)
|
||||
problem.showBorrowingHints // show-borrowing-hints (hints with arrows)
|
||||
)
|
||||
@@ -240,20 +342,84 @@ ${generateSubtractionProblemStackFunction(cellSize, maxDigits)}
|
||||
${problemsTypst}
|
||||
)
|
||||
|
||||
// Compact header - name on left, date and QR code with share code on right
|
||||
#grid(
|
||||
columns: (1fr, auto, auto),
|
||||
column-gutter: 0.15in,
|
||||
align: (left, right, right),
|
||||
text(size: 0.75em, weight: "bold")[${config.name}],
|
||||
text(size: 0.65em)[${config.date}],
|
||||
${
|
||||
qrCodeSvg
|
||||
? `stack(dir: ttb, spacing: 2pt, align(center)[#image.decode("${qrCodeSvg.replace(/"/g, '\\"').replace(/\n/g, '')}", width: 0.35in, height: 0.35in)], align(center)[#text(size: 6pt, font: "Courier New")[${shareCode || 'PREVIEW'}]])`
|
||||
: `[]`
|
||||
}
|
||||
)
|
||||
#v(${headerHeight}in - 0.25in)
|
||||
// Letterhead with worksheet description, student info, and QR code
|
||||
${(() => {
|
||||
const description = generateWorksheetDescription(config)
|
||||
// Check if user specified a real name (not the default 'Student' placeholder)
|
||||
// Validation defaults empty names to 'Student', so we treat that as "no name specified"
|
||||
const hasName = config.name && config.name.trim().length > 0 && config.name.trim() !== 'Student'
|
||||
const brandDomain = domain || 'abaci.one'
|
||||
const breadcrumb = 'Create › Worksheets'
|
||||
|
||||
// When name is empty, description gets more prominence (larger font)
|
||||
const titleSize = hasName ? '0.7em' : '0.85em'
|
||||
const scaffoldSize = hasName ? '0.5em' : '0.6em'
|
||||
|
||||
return `#box(
|
||||
width: 100%,
|
||||
stroke: (bottom: 1pt + gray),
|
||||
inset: (bottom: 2pt),
|
||||
)[
|
||||
#grid(
|
||||
columns: (1fr, auto),
|
||||
column-gutter: 0.1in,
|
||||
align: (left + top, right + top),
|
||||
// Left side: Description, Name (if specified)
|
||||
[
|
||||
// Title line: digit range, operator, regrouping %
|
||||
#text(size: ${titleSize}, weight: "bold")[${description.title}] \\
|
||||
// Scaffolding summary
|
||||
#text(size: ${scaffoldSize}, fill: gray.darken(20%))[${description.scaffolding}]
|
||||
${
|
||||
hasName
|
||||
? ` \\
|
||||
// Name row (date moved to right side)
|
||||
#grid(
|
||||
columns: (auto, 1fr),
|
||||
column-gutter: 4pt,
|
||||
align: (left + horizon, left + horizon),
|
||||
text(size: 0.65em)[*Name:*],
|
||||
box(stroke: (bottom: 0.5pt + black))[#h(0.25em)${config.name}#h(1fr)]
|
||||
)`
|
||||
: ''
|
||||
}
|
||||
],
|
||||
// Right side: Date + QR code, share code, domain, breadcrumb
|
||||
[
|
||||
${
|
||||
qrCodeSvg
|
||||
? `#align(right)[
|
||||
#stack(dir: ttb, spacing: 0pt, align(right)[
|
||||
// Date and QR code on same row, top-aligned
|
||||
#grid(
|
||||
columns: (auto, auto),
|
||||
column-gutter: 0.1in,
|
||||
align: (right + top, right + top),
|
||||
text(size: 0.6em)[*Date:* ${config.date}],
|
||||
image(bytes("${qrCodeSvg.replace(/"/g, '\\"').replace(/\n/g, '')}"), format: "svg", width: 0.5in, height: 0.5in)
|
||||
)
|
||||
], align(right)[
|
||||
// Share code and domain/breadcrumb tightly packed
|
||||
#set par(leading: 0pt)
|
||||
#text(size: 5pt, font: "Courier New", fill: gray.darken(20%))[${shareCode || 'PREVIEW'}] \\
|
||||
#text(size: 0.4em, fill: gray.darken(10%), weight: "medium")[${brandDomain}] #text(size: 0.35em, fill: gray)[${breadcrumb}]
|
||||
])
|
||||
]`
|
||||
: `#align(right)[
|
||||
#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)
|
||||
|
||||
// Problem grid - exactly ${actualRows} rows × ${config.cols} columns
|
||||
#grid(
|
||||
@@ -398,7 +564,7 @@ ${
|
||||
qrCodeSvg
|
||||
? `// QR code linking to shared worksheet with share code below
|
||||
#place(bottom + left, dx: 0.1in, dy: -0.1in)[
|
||||
#stack(dir: ttb, spacing: 2pt, align(center)[#image.decode("${qrCodeSvg.replace(/"/g, '\\"').replace(/\n/g, '')}", width: 0.5in, height: 0.5in)], align(center)[#text(size: 7pt, font: "Courier New")[${shareCode || 'PREVIEW'}]])
|
||||
#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'}]])
|
||||
]`
|
||||
: ''
|
||||
}
|
||||
@@ -422,11 +588,13 @@ function extractShareCode(shareUrl?: string): string | undefined {
|
||||
/**
|
||||
* Generate Typst source code for the worksheet (returns array of page sources)
|
||||
* @param shareUrl - Optional share URL for QR code embedding (required if config.includeQRCode is true)
|
||||
* @param domain - Optional domain name for branding (defaults to extracting from shareUrl or "abaci.one")
|
||||
*/
|
||||
export async function generateTypstSource(
|
||||
config: WorksheetConfig,
|
||||
problems: WorksheetProblem[],
|
||||
shareUrl?: string
|
||||
shareUrl?: string,
|
||||
domain?: string
|
||||
): Promise<string[]> {
|
||||
// Use the problemsPerPage directly from config (primary state)
|
||||
const problemsPerPage = config.problemsPerPage
|
||||
@@ -440,6 +608,17 @@ export async function generateTypstSource(
|
||||
shareCode = extractShareCode(shareUrl)
|
||||
}
|
||||
|
||||
// Extract domain from shareUrl if not provided explicitly
|
||||
let brandDomain = domain
|
||||
if (!brandDomain && shareUrl) {
|
||||
try {
|
||||
const url = new URL(shareUrl)
|
||||
brandDomain = url.hostname
|
||||
} catch {
|
||||
// Invalid URL, use default
|
||||
}
|
||||
}
|
||||
|
||||
// Chunk problems into discrete pages
|
||||
const pages = chunkProblems(problems, problemsPerPage)
|
||||
|
||||
@@ -451,7 +630,8 @@ export async function generateTypstSource(
|
||||
pageIndex * problemsPerPage,
|
||||
rowsPerPage,
|
||||
qrCodeSvg,
|
||||
shareCode
|
||||
shareCode,
|
||||
brandDomain
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -90,23 +90,11 @@ export function generateProblemStackFunction(cellSize: number, maxDigits: number
|
||||
column-list.push(${cellSizeIn})
|
||||
}
|
||||
|
||||
// Show problem number (only if problem numbers are enabled)
|
||||
let problem-number-display = if show-numbers and index-or-none != none {
|
||||
align(top + left)[
|
||||
#box(inset: (left: 0.08in, top: 0.05in))[
|
||||
#text(size: ${(cellSizePt * 0.6).toFixed(1)}pt, weight: "bold", font: "New Computer Modern Math")[\##(index-or-none + 1).]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
stack(
|
||||
dir: ttb,
|
||||
spacing: 0pt,
|
||||
problem-number-display,
|
||||
// Wrap grid in a box to enable place() overlay for operator
|
||||
box[
|
||||
#grid(
|
||||
columns: column-list,
|
||||
// Wrap grid in a box to enable place() overlay for operator
|
||||
// Note: Problem numbers are now rendered at the problem-box level, not here
|
||||
box[
|
||||
#grid(
|
||||
columns: column-list,
|
||||
gutter: 0pt,
|
||||
|
||||
// Carry boxes row (one per place value, right to left)
|
||||
@@ -256,7 +244,6 @@ export function generateProblemStackFunction(cellSize: number, maxDigits: number
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
@@ -103,22 +103,10 @@ export function generateSubtractionProblemStackFunction(
|
||||
column-list.push(${cellSizeIn})
|
||||
}
|
||||
|
||||
// Show problem number (only if problem numbers are enabled)
|
||||
let problem-number-display = if show-numbers and index-or-none != none {
|
||||
align(top + left)[
|
||||
#box(inset: (left: 0.08in, top: 0.05in))[
|
||||
#text(size: ${(cellSizePt * 0.6).toFixed(1)}pt, weight: "bold", font: "New Computer Modern Math")[\##(index-or-none + 1).]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
stack(
|
||||
dir: ttb,
|
||||
spacing: 0pt,
|
||||
problem-number-display,
|
||||
// Wrap grid in a box to enable place() overlay for operator
|
||||
box[
|
||||
#grid(
|
||||
// Wrap grid in a box to enable place() overlay for operator
|
||||
// Note: Problem numbers are now rendered at the problem-box level, not here
|
||||
box[
|
||||
#grid(
|
||||
columns: column-list,
|
||||
gutter: 0pt,
|
||||
|
||||
@@ -136,7 +124,6 @@ ${generateAnswerBoxesRow(cellDimensions)}
|
||||
)
|
||||
${generateOperatorOverlay(cellDimensions)}
|
||||
]
|
||||
)
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user