feat(know-your-world): add SVG path geometry helpers for future use

Add helper functions for SVG path analysis:
- parsePathToSubpaths: Parse SVG path into separate subpaths
- calculatePolygonArea: Shoelace formula for polygon area
- calculatePolygonCentroid: Shoelace formula for centroid
- getLargestSubpathCentroid: Find center of largest subpath

These will be useful for region centering improvements later.

Also adds celebration feature planning document.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-29 18:18:10 -06:00
parent 7f6b9dd558
commit ea141f04f6
2 changed files with 692 additions and 0 deletions

View File

@ -0,0 +1,424 @@
# Region Found Celebration Plan
## Problem
When a user correctly clicks on a region, the game immediately transitions to the next region with no celebration. Kids deserve positive reinforcement for correct answers!
## Current Flow
```
User clicks region → onRegionClick(id, name)
→ Provider.clickRegion()
→ Validator: isCorrect?
→ Add to regionsFound
→ Set next currentPrompt
→ State updates
→ MapRenderer re-renders with new prompt
```
The transition is instant - no pause, no celebration, no feedback.
## User Requirements
1. ✅ Gold flash effect on found region
2. ✅ Confetti burst
3. ✅ Responsive to HOW they found it:
- **Fast find** → Quick, snappy celebration (reward speed)
- **Hard-earned find** → Bigger, more satisfying celebration (acknowledge effort)
4. ✅ Sound effect (NOT speech synthesis - that's for hot/cold)
5. ✅ Block advancement until celebration completes (map gets cluttered)
6. ❌ No streaks for now
---
## Celebration Types
### 1. Lightning Find ⚡ (< 3 seconds)
Kid knew exactly where to look - reward the speed!
- **Flash**: Quick, bright gold pulse (400ms)
- **Confetti**: Fast, sparkly burst (small particles, quick fade)
- **Sound**: Quick "ding!" or sparkle
- **Duration**: ~800ms total
### 2. Standard Find ✨ (3-15 seconds, direct path)
Normal discovery - celebrate appropriately
- **Flash**: Smooth gold pulse (600ms)
- **Confetti**: Medium burst with gravity fall
- **Sound**: Pleasant chime
- **Duration**: ~1.2 seconds total
### 3. Hard-Earned Find 💪 (searched extensively)
Kid really worked for it - acknowledge the effort!
- **Flash**: Warm, satisfying glow (800ms)
- **Confetti**: Big celebration! More particles, longer duration
- **Sound**: Triumphant fanfare/chord
- **Duration**: ~1.8 seconds total
- **Extra**: Maybe show encouraging text "You found it!"
---
## Detecting Celebration Type
### Data Available from Hot/Cold System
The `useHotColdFeedback` hook already tracks:
```typescript
interface PathEntry {
x: number
y: number
timestamp: number
distance: number // Distance to target at this point
}
// In HotColdState:
history: PathEntry[] // Circular buffer of recent positions
totalDistanceChange: number // Cumulative movement toward/away from target
minDistanceSinceLastFeedback // Got close then moved away?
```
### Metrics for Effort Detection
```typescript
interface SearchMetrics {
// Time
timeToFind: number // ms from prompt start to correct click
// Distance traveled
totalCursorDistance: number // Total pixels cursor moved (from history)
straightLineDistance: number // Direct path would have been
searchEfficiency: number // straight / total (1.0 = perfect, <0.3 = searched hard)
// Direction changes
directionReversals: number // How many times changed direction toward/away
// Near misses
nearMissCount: number // Times got within CLOSE threshold then moved away
overshotCount: number // Times passed the target
// Zone transitions
zoneTransitions: number // warming→cooling→warming transitions
}
```
### Classification Logic
```typescript
function classifyCelebration(metrics: SearchMetrics): 'lightning' | 'standard' | 'hard-earned' {
// Lightning: Fast and direct
if (metrics.timeToFind < 3000 && metrics.searchEfficiency > 0.7) {
return 'lightning'
}
// Hard-earned: Any of these indicate real effort
if (
metrics.timeToFind > 20000 || // Took a while
metrics.searchEfficiency < 0.3 || // Wandered a lot
metrics.directionReversals > 10 || // Lots of back-and-forth
metrics.nearMissCount > 2 || // Got close multiple times
metrics.overshotCount > 1 // Passed it more than once
) {
return 'hard-earned'
}
return 'standard'
}
```
---
## New Flow with Celebration
```
User clicks region → onRegionClick(id, name)
→ MapRenderer intercepts (is correct?)
→ YES: Start celebration
- Calculate celebration type from search metrics
- Show flash + confetti + sound
- Block further clicks
- After animation complete:
→ Call actual clickRegion()
→ Advance to next prompt
→ NO: Normal wrong-answer handling
```
### Key Change: Delay State Update
Instead of immediately calling `clickRegion` and advancing, we:
1. Detect correct click locally in MapRenderer
2. Start celebration animation
3. Block input during celebration
4. Only AFTER celebration completes, send the click to the server/validator
This ensures the map doesn't clutter with the next prompt while celebrating.
---
## State Additions
### Provider Context (client-side only)
```typescript
// Add to Provider.tsx context
interface CelebrationState {
regionId: string
regionName: string
type: 'lightning' | 'standard' | 'hard-earned'
startTime: number
}
const [celebration, setCelebration] = useState<CelebrationState | null>(null)
```
### Expose Search Metrics from Hot/Cold Hook
```typescript
// Add to useHotColdFeedback return value
export function useHotColdFeedback(...) {
// ... existing code ...
// New: expose metrics for celebration classification
const getSearchMetrics = useCallback((): SearchMetrics => {
const state = stateRef.current
const entries = getRecentEntries(state, HISTORY_LENGTH)
.filter((e): e is PathEntry => e !== null)
// Calculate metrics from history...
return {
timeToFind: /* time since prompt started */,
totalCursorDistance: /* sum of distances between consecutive points */,
straightLineDistance: /* first point to target */,
searchEfficiency: /* straight / total */,
directionReversals: /* count sign changes in distance delta */,
nearMissCount: /* count entries with distance < CLOSE */,
overshotCount: /* from existing detection */,
zoneTransitions: /* count zone changes */,
}
}, [])
return {
checkPosition,
reset,
isSpeaking,
lastFeedbackType,
getSearchMetrics, // NEW
}
}
```
---
## Sound Effects
### Options for Sound Generation
1. **Web Audio API** - Generate tones programmatically (no files needed)
2. **Audio files** - Pre-recorded sounds (more polished, adds to bundle)
3. **Tone.js library** - Rich audio synthesis (adds dependency)
**Recommendation**: Web Audio API for MVP (no deps, small)
### Sound Design
```typescript
// useCelebrationSound.ts
const audioContext = new (window.AudioContext || window.webkitAudioContext)()
function playLightningSound() {
// Quick sparkle: high frequency, fast decay
const osc = audioContext.createOscillator()
const gain = audioContext.createGain()
osc.type = 'sine'
osc.frequency.setValueAtTime(1200, audioContext.currentTime)
osc.frequency.exponentialRampToValueAtTime(2400, audioContext.currentTime + 0.1)
gain.gain.setValueAtTime(0.3, audioContext.currentTime)
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2)
osc.connect(gain).connect(audioContext.destination)
osc.start()
osc.stop(audioContext.currentTime + 0.2)
}
function playStandardSound() {
// Pleasant chime: medium frequency, gentle decay
// Two-note chord for warmth
}
function playHardEarnedSound() {
// Triumphant: ascending notes, longer sustain
// Maybe C-E-G chord arpeggio
}
```
---
## Visual Components
### 1. Gold Flash Effect
```typescript
// In MapRenderer, for the found region path
const isFlashing = celebration?.regionId === region.id
// Animated fill using react-spring
const flashSpring = useSpring({
glow: isFlashing ? 1 : 0,
config: {
duration: celebration?.type === 'lightning' ? 400
: celebration?.type === 'hard-earned' ? 800
: 600
},
})
// SVG path style
style={{
fill: flashSpring.glow.to(g =>
g > 0 ? `rgba(251, 191, 36, ${0.5 + g * 0.5})` : normalFill
),
filter: flashSpring.glow.to(g =>
g > 0 ? `drop-shadow(0 0 ${g * 15}px rgba(251, 191, 36, 0.8))` : 'none'
),
}}
```
### 2. Confetti Component
```typescript
// components/Confetti.tsx
interface ConfettiProps {
type: 'lightning' | 'standard' | 'hard-earned'
origin: { x: number; y: number } // Screen coordinates
onComplete: () => void
}
const CONFETTI_CONFIG = {
lightning: { count: 12, duration: 600, spread: 60 },
standard: { count: 20, duration: 1000, spread: 90 },
'hard-earned': { count: 35, duration: 1500, spread: 120 },
}
function Confetti({ type, origin, onComplete }: ConfettiProps) {
const config = CONFETTI_CONFIG[type]
// Generate particles with random directions, colors, sizes
// Use CSS animations for performance
// Call onComplete when last particle finishes
}
```
### 3. Celebration Overlay
```typescript
// components/CelebrationOverlay.tsx
interface CelebrationOverlayProps {
celebration: CelebrationState
regionCenter: { x: number; y: number }
onComplete: () => void
}
function CelebrationOverlay({ celebration, regionCenter, onComplete }: CelebrationOverlayProps) {
const [phase, setPhase] = useState<'active' | 'complete'>('active')
// Track when all animations complete
const handleConfettiComplete = useCallback(() => {
setPhase('complete')
onComplete()
}, [onComplete])
return (
<div className={css({ position: 'fixed', inset: 0, pointerEvents: 'none', zIndex: 1000 })}>
<Confetti
type={celebration.type}
origin={regionCenter}
onComplete={handleConfettiComplete}
/>
{celebration.type === 'hard-earned' && (
<EncouragingText regionName={celebration.regionName} />
)}
</div>
)
}
```
---
## Files to Create/Modify
### New Files
1. **`hooks/useCelebrationSound.ts`** - Web Audio API sound effects
2. **`hooks/useSearchMetrics.ts`** - Extract metrics from hot/cold history
3. **`components/Confetti.tsx`** - CSS confetti particles
4. **`components/CelebrationOverlay.tsx`** - Orchestrates celebration
### Modified Files
1. **`Provider.tsx`** - Add celebration state to context
2. **`hooks/useHotColdFeedback.ts`** - Expose `getSearchMetrics()` method
3. **`MapRenderer.tsx`** - Intercept correct clicks, trigger celebration, delay advancement
---
## Implementation Order
### Phase 1: Infrastructure
1. Add `celebration` state to Provider context
2. Add `promptStartTime` tracking (when each region prompt begins)
3. Modify `useHotColdFeedback` to expose `getSearchMetrics()`
### Phase 2: Classification
4. Create `useSearchMetrics` hook to calculate metrics
5. Implement `classifyCelebration()` function
6. Test metric calculation with various search patterns
### Phase 3: Visuals
7. Create `Confetti` component with CSS animations
8. Add gold flash effect to MapRenderer (react-spring)
9. Create `CelebrationOverlay` to orchestrate
### Phase 4: Audio
10. Create `useCelebrationSound` hook with Web Audio API
11. Implement three sound types (lightning, standard, hard-earned)
12. Wire up sounds to celebration types
### Phase 5: Integration
13. Intercept correct clicks in MapRenderer
14. Block advancement during celebration
15. Call `clickRegion` only after celebration completes
### Phase 6: Polish
16. Add `prefers-reduced-motion` support
17. Test on mobile (performance, touch)
18. Fine-tune timing and particle counts
---
## Accessibility
1. **Reduced Motion**: `@media (prefers-reduced-motion: reduce)`
- Skip confetti entirely
- Use simple color change instead of flash
- Sound still plays (audio is separate preference)
2. **Sound Preferences**: Respect system mute / browser audio block
- Don't crash if AudioContext is blocked
- Degrade gracefully
---
## Performance Budget
- Confetti particles: Max 35 (hard-earned), CSS-animated
- Flash effect: Single react-spring animation
- Sound: Single Web Audio oscillator (or two for chords)
- No canvas, no heavy libraries
---
## Timing Summary
| Type | Flash | Confetti | Sound | Total Block |
|------|-------|----------|-------|-------------|
| Lightning ⚡ | 400ms | 600ms | 200ms | 600ms |
| Standard ✨ | 600ms | 1000ms | 400ms | 1000ms |
| Hard-earned 💪 | 800ms | 1500ms | 600ms | 1500ms |

View File

@ -1633,6 +1633,274 @@ export function shouldIncludeRegion(regionId: string, includeSizes: RegionSize[]
return includeSizes.includes(category)
}
/**
* Parse SVG path into subpaths (separated by M commands, closed by Z)
* Returns array of point arrays, one per subpath
*/
function parsePathToSubpaths(pathString: string): Array<Array<[number, number]>> {
const subpaths: Array<Array<[number, number]>> = []
let currentSubpath: Array<[number, number]> = []
let currentX = 0
let currentY = 0
let subpathStartX = 0
let subpathStartY = 0
const commandRegex = /([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g
let match
while ((match = commandRegex.exec(pathString)) !== null) {
const command = match[1]
const params =
match[2]
.trim()
.match(/-?\d+\.?\d*/g)
?.map(Number) || []
switch (command) {
case 'M':
if (currentSubpath.length > 0) subpaths.push(currentSubpath)
currentSubpath = []
for (let i = 0; i < params.length - 1; i += 2) {
currentX = params[i]
currentY = params[i + 1]
if (i === 0) {
subpathStartX = currentX
subpathStartY = currentY
}
currentSubpath.push([currentX, currentY])
}
break
case 'm':
if (currentSubpath.length > 0) subpaths.push(currentSubpath)
currentSubpath = []
for (let i = 0; i < params.length - 1; i += 2) {
currentX += params[i]
currentY += params[i + 1]
if (i === 0) {
subpathStartX = currentX
subpathStartY = currentY
}
currentSubpath.push([currentX, currentY])
}
break
case 'L':
for (let i = 0; i < params.length - 1; i += 2) {
currentX = params[i]
currentY = params[i + 1]
currentSubpath.push([currentX, currentY])
}
break
case 'l':
for (let i = 0; i < params.length - 1; i += 2) {
currentX += params[i]
currentY += params[i + 1]
currentSubpath.push([currentX, currentY])
}
break
case 'H':
for (const x of params) {
currentX = x
currentSubpath.push([currentX, currentY])
}
break
case 'h':
for (const dx of params) {
currentX += dx
currentSubpath.push([currentX, currentY])
}
break
case 'V':
for (const y of params) {
currentY = y
currentSubpath.push([currentX, currentY])
}
break
case 'v':
for (const dy of params) {
currentY += dy
currentSubpath.push([currentX, currentY])
}
break
case 'C':
for (let i = 0; i < params.length - 5; i += 6) {
currentX = params[i + 4]
currentY = params[i + 5]
currentSubpath.push([currentX, currentY])
}
break
case 'c':
for (let i = 0; i < params.length - 5; i += 6) {
currentX += params[i + 4]
currentY += params[i + 5]
currentSubpath.push([currentX, currentY])
}
break
case 'S':
for (let i = 0; i < params.length - 3; i += 4) {
currentX = params[i + 2]
currentY = params[i + 3]
currentSubpath.push([currentX, currentY])
}
break
case 's':
for (let i = 0; i < params.length - 3; i += 4) {
currentX += params[i + 2]
currentY += params[i + 3]
currentSubpath.push([currentX, currentY])
}
break
case 'Q':
for (let i = 0; i < params.length - 3; i += 4) {
currentX = params[i + 2]
currentY = params[i + 3]
currentSubpath.push([currentX, currentY])
}
break
case 'q':
for (let i = 0; i < params.length - 3; i += 4) {
currentX += params[i + 2]
currentY += params[i + 3]
currentSubpath.push([currentX, currentY])
}
break
case 'T':
for (let i = 0; i < params.length - 1; i += 2) {
currentX = params[i]
currentY = params[i + 1]
currentSubpath.push([currentX, currentY])
}
break
case 't':
for (let i = 0; i < params.length - 1; i += 2) {
currentX += params[i]
currentY += params[i + 1]
currentSubpath.push([currentX, currentY])
}
break
case 'A':
for (let i = 0; i < params.length - 6; i += 7) {
currentX = params[i + 5]
currentY = params[i + 6]
currentSubpath.push([currentX, currentY])
}
break
case 'a':
for (let i = 0; i < params.length - 6; i += 7) {
currentX += params[i + 5]
currentY += params[i + 6]
currentSubpath.push([currentX, currentY])
}
break
case 'Z':
case 'z':
currentX = subpathStartX
currentY = subpathStartY
if (currentSubpath.length > 0) {
subpaths.push(currentSubpath)
currentSubpath = []
}
break
}
}
if (currentSubpath.length > 0) subpaths.push(currentSubpath)
return subpaths
}
/**
* Calculate polygon area using shoelace formula
*/
function calculatePolygonArea(points: Array<[number, number]>): number {
if (points.length < 3) return 0
let area = 0
for (let i = 0; i < points.length; i++) {
const j = (i + 1) % points.length
area += points[i][0] * points[j][1]
area -= points[j][0] * points[i][1]
}
return Math.abs(area) / 2
}
/**
* Calculate centroid of a polygon using shoelace formula
*/
function calculatePolygonCentroid(points: Array<[number, number]>): [number, number] {
if (points.length === 0) return [0, 0]
if (points.length < 3) {
const avgX = points.reduce((s, p) => s + p[0], 0) / points.length
const avgY = points.reduce((s, p) => s + p[1], 0) / points.length
return [avgX, avgY]
}
let signedArea = 0
let cx = 0
let cy = 0
for (let i = 0; i < points.length; i++) {
const j = (i + 1) % points.length
const cross = points[i][0] * points[j][1] - points[j][0] * points[i][1]
signedArea += cross
cx += (points[i][0] + points[j][0]) * cross
cy += (points[i][1] + points[j][1]) * cross
}
signedArea /= 2
if (Math.abs(signedArea) < 0.0001) {
const avgX = points.reduce((s, p) => s + p[0], 0) / points.length
const avgY = points.reduce((s, p) => s + p[1], 0) / points.length
return [avgX, avgY]
}
return [cx / (6 * signedArea), cy / (6 * signedArea)]
}
/**
* Get the bounding box center of the largest closed subpath in an SVG path.
* Uses bounding box center (not geometric centroid) for more visually predictable results
* on elongated shapes like Norway or Chile.
* Falls back to overall path bounding box center if subpath parsing fails.
*/
export function getLargestSubpathCentroid(pathString: string): { x: number; y: number } | null {
const subpaths = parsePathToSubpaths(pathString)
// If no subpaths found, return null to signal fallback should be used
if (subpaths.length === 0) {
return null
}
// Find largest subpath by area
let largestSubpath = subpaths[0]
let largestArea = calculatePolygonArea(largestSubpath)
for (let i = 1; i < subpaths.length; i++) {
const area = calculatePolygonArea(subpaths[i])
if (area > largestArea) {
largestArea = area
largestSubpath = subpaths[i]
}
}
// If largest subpath is empty or has no area, return null
if (largestSubpath.length === 0 || largestArea === 0) {
return null
}
// Calculate bounding box center of largest subpath
let minX = largestSubpath[0][0]
let maxX = largestSubpath[0][0]
let minY = largestSubpath[0][1]
let maxY = largestSubpath[0][1]
for (const [x, y] of largestSubpath) {
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y
if (y > maxY) maxY = y
}
return { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }
}
/**
* Calculate the centroid (center of mass) of an SVG path
* Properly parses SVG path commands to extract endpoint coordinates only