chore: miscellaneous updates and documentation

- Update know-your-world implementation docs
- Update decomposition CSS styles
- Update AbacusReact component
- Update gallery template
- Update dependencies (pnpm-lock.yaml)
- Update biome config

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-08 11:40:59 -06:00
parent b56c8f439b
commit e937c05323
10 changed files with 23519 additions and 10432 deletions

View File

@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json", "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"indentStyle": "space", "indentStyle": "space",

View File

@ -3,6 +3,7 @@
## Vision ## Vision
Subtle, ambient background music that enhances the geography learning experience without being distracting. The music should: Subtle, ambient background music that enhances the geography learning experience without being distracting. The music should:
- Feel like a gentle companion, not the focus - Feel like a gentle companion, not the focus
- Subtly evoke the region being explored - Subtly evoke the region being explored
- React to game state (searching, getting warmer, finding regions) - React to game state (searching, getting warmer, finding regions)
@ -11,12 +12,14 @@ Subtle, ambient background music that enhances the geography learning experience
## Technical Approach: Strudel ## Technical Approach: Strudel
[Strudel](https://strudel.cc) is a browser-based live coding music environment that's perfect for this because: [Strudel](https://strudel.cc) is a browser-based live coding music environment that's perfect for this because:
- **Generative/algorithmic** - creates evolving, non-repetitive patterns - **Generative/algorithmic** - creates evolving, non-repetitive patterns
- **Lightweight** - runs entirely in browser via Web Audio API - **Lightweight** - runs entirely in browser via Web Audio API
- **Pattern-based** - easy to create looping ambient textures - **Pattern-based** - easy to create looping ambient textures
- **Reactive** - patterns can be modified in real-time based on game state - **Reactive** - patterns can be modified in real-time based on game state
### Key Packages ### Key Packages
``` ```
@strudel/core - Pattern engine @strudel/core - Pattern engine
@strudel/webaudio - Web Audio integration @strudel/webaudio - Web Audio integration
@ -27,13 +30,16 @@ Subtle, ambient background music that enhances the geography learning experience
## Musical Design Principles ## Musical Design Principles
### 1. Ambient, Not Melodic ### 1. Ambient, Not Melodic
- Focus on **textures and drones** rather than memorable melodies - Focus on **textures and drones** rather than memorable melodies
- Use **pads, filtered noise, gentle percussive elements** - Use **pads, filtered noise, gentle percussive elements**
- Keep it **sparse** - silence is part of the composition - Keep it **sparse** - silence is part of the composition
- Target **-18 to -24 LUFS** (quiet enough to be background) - Target **-18 to -24 LUFS** (quiet enough to be background)
### 2. Regional Flavoring (Subtle, Not Stereotypical) ### 2. Regional Flavoring (Subtle, Not Stereotypical)
The goal is **subtle evocation**, not cultural appropriation or cliches. We'll use: The goal is **subtle evocation**, not cultural appropriation or cliches. We'll use:
- **Scale/mode choices** that loosely evoke regions - **Scale/mode choices** that loosely evoke regions
- **Rhythmic characteristics** (but very subtle) - **Rhythmic characteristics** (but very subtle)
- **Timbral colors** (instrument-like synth textures) - **Timbral colors** (instrument-like synth textures)
@ -41,7 +47,9 @@ The goal is **subtle evocation**, not cultural appropriation or cliches. We'll u
**NOT**: Playing "ethnic instruments" or obvious cultural signifiers **NOT**: Playing "ethnic instruments" or obvious cultural signifiers
### 3. Non-Repetitive ### 3. Non-Repetitive
Strudel's generative nature helps here: Strudel's generative nature helps here:
- Use **probability** in patterns (`"c3 e3 g3 c4".sometimes(x => x.add(7))`) - Use **probability** in patterns (`"c3 e3 g3 c4".sometimes(x => x.add(7))`)
- **Slowly evolving parameters** (filter sweeps, volume swells) - **Slowly evolving parameters** (filter sweeps, volume swells)
- **Long cycle times** (32-64 bars before any repeat) - **Long cycle times** (32-64 bars before any repeat)
@ -69,6 +77,7 @@ Strudel's generative nature helps here:
``` ```
### Volume Balance ### Volume Balance
- **Layer 1 (Base)**: 40% of total - **Layer 1 (Base)**: 40% of total
- **Layer 2 (Continental)**: 45% of total - **Layer 2 (Continental)**: 45% of total
- **Layer 3 (Hyper-local)**: 15% of total (very subtle hint) - **Layer 3 (Hyper-local)**: 15% of total (very subtle hint)
@ -77,19 +86,21 @@ Strudel's generative nature helps here:
### World Map Regions ### World Map Regions
| Region/Continent | Musical Approach | | Region/Continent | Musical Approach |
|------------------|------------------| | -------------------- | ----------------------------------------------------------------------- |
| **Europe** | Dorian/Mixolydian modes, gentle pads, subtle church-organ-like timbres | | **Europe** | Dorian/Mixolydian modes, gentle pads, subtle church-organ-like timbres |
| **Africa** | Pentatonic scales, gentle polyrhythmic undertones, warm bass | | **Africa** | Pentatonic scales, gentle polyrhythmic undertones, warm bass |
| **Asia** | Pentatonic (different voicings), sparse, contemplative, bell-like tones | | **Asia** | Pentatonic (different voicings), sparse, contemplative, bell-like tones |
| **Middle East** | Phrygian dominant hints, desert-wind textures, sparse | | **Middle East** | Phrygian dominant hints, desert-wind textures, sparse |
| **Americas (North)** | Open fifths, spacious, subtle Americana folk modes | | **Americas (North)** | Open fifths, spacious, subtle Americana folk modes |
| **Americas (South)** | Warm, flowing, subtle Latin rhythm hints | | **Americas (South)** | Warm, flowing, subtle Latin rhythm hints |
| **Oceania** | Airy, oceanic textures, island vibes, relaxed | | **Oceania** | Airy, oceanic textures, island vibes, relaxed |
| **Default/Unknown** | Neutral ambient, no regional coloring | | **Default/Unknown** | Neutral ambient, no regional coloring |
### USA Map ### USA Map
For US states, we could do regional variations: For US states, we could do regional variations:
- **Northeast**: Urban jazz hints, cool tones - **Northeast**: Urban jazz hints, cool tones
- **South**: Warm, bluesy undertones - **South**: Warm, bluesy undertones
- **Midwest**: Open, pastoral - **Midwest**: Open, pastoral
@ -101,6 +112,7 @@ For US states, we could do regional variations:
These are **optional, very subtle** musical hints that play when searching for specific regions. Not every region needs one - only culturally distinctive ones where a tasteful hint enhances the experience. These are **optional, very subtle** musical hints that play when searching for specific regions. Not every region needs one - only culturally distinctive ones where a tasteful hint enhances the experience.
### Design Principles for Hyper-Local ### Design Principles for Hyper-Local
1. **Extremely subtle** - should barely be noticeable consciously 1. **Extremely subtle** - should barely be noticeable consciously
2. **Abstract, not literal** - evocative texture, not "playing the anthem" 2. **Abstract, not literal** - evocative texture, not "playing the anthem"
3. **Tasteful** - avoid stereotypes, focus on musical traditions 3. **Tasteful** - avoid stereotypes, focus on musical traditions
@ -109,28 +121,28 @@ These are **optional, very subtle** musical hints that play when searching for s
### Example Hyper-Local Hints (World Map) ### Example Hyper-Local Hints (World Map)
| Region | Hint Approach | | Region | Hint Approach |
|--------|---------------| | ---------------- | -------------------------------------------------- |
| **France** | Faint musette accordion texture, filtered | | **France** | Faint musette accordion texture, filtered |
| **Spain** | Subtle flamenco-style rhythmic pattern, very quiet | | **Spain** | Subtle flamenco-style rhythmic pattern, very quiet |
| **Brazil** | Gentle bossa nova rhythm hint | | **Brazil** | Gentle bossa nova rhythm hint |
| **Japan** | Sparse koto-like plucked texture | | **Japan** | Sparse koto-like plucked texture |
| **India** | Filtered sitar-like drone, tanpura texture | | **India** | Filtered sitar-like drone, tanpura texture |
| **Ireland** | Celtic harp arpeggios, extremely soft | | **Ireland** | Celtic harp arpeggios, extremely soft |
| **Egypt** | Desert wind + subtle oud-like tone | | **Egypt** | Desert wind + subtle oud-like tone |
| **Argentina** | Hint of tango rhythm, bandoneon texture | | **Argentina** | Hint of tango rhythm, bandoneon texture |
| **Jamaica** | Subtle reggae offbeat, very filtered | | **Jamaica** | Subtle reggae offbeat, very filtered |
| **Russia** | Deep balalaika-like plucks, sparse | | **Russia** | Deep balalaika-like plucks, sparse |
| **Greece** | Bouzouki-like texture, Aegean scales | | **Greece** | Bouzouki-like texture, Aegean scales |
| **Mexico** | Gentle marimba-like tones | | **Mexico** | Gentle marimba-like tones |
| **China** | Erhu-like sustained tone, pentatonic | | **China** | Erhu-like sustained tone, pentatonic |
| **Australia** | Didgeridoo-like drone (if tasteful) | | **Australia** | Didgeridoo-like drone (if tasteful) |
| **Scotland** | Bagpipe drone (heavily filtered) | | **Scotland** | Bagpipe drone (heavily filtered) |
| **USA** | Subtle country/folk guitar texture | | **USA** | Subtle country/folk guitar texture |
| **Germany** | Subtle oom-pah bass hint (very gentle) | | **Germany** | Subtle oom-pah bass hint (very gentle) |
| **Italy** | Mandolin-like arpeggios, filtered | | **Italy** | Mandolin-like arpeggios, filtered |
| **Nigeria** | Talking drum rhythm hint | | **Nigeria** | Talking drum rhythm hint |
| **South Africa** | Township jazz hint, warm | | **South Africa** | Township jazz hint, warm |
### Implementation: Region Hint Data Structure ### Implementation: Region Hint Data Structure
@ -148,13 +160,13 @@ interface RegionMusicHint {
// Example mapping // Example mapping
const regionHints: Record<string, RegionMusicHint> = { const regionHints: Record<string, RegionMusicHint> = {
'fr': { fr: {
pattern: `note("c4 e4 g4").sound("accordion").lpf(400).gain(0.1).slow(8)`, pattern: `note("c4 e4 g4").sound("accordion").lpf(400).gain(0.1).slow(8)`,
gain: 0.15, gain: 0.15,
fadeIn: 3000, fadeIn: 3000,
delayStart: 2000, // Only start hint after 2s of searching delayStart: 2000, // Only start hint after 2s of searching
}, },
'jp': { jp: {
pattern: `note("d4 f4 a4 c5").sound("pluck").lpf(600).gain(0.08).slow(12)`, pattern: `note("d4 f4 a4 c5").sound("pluck").lpf(600).gain(0.08).slow(12)`,
gain: 0.12, gain: 0.12,
fadeIn: 4000, fadeIn: 4000,
@ -165,15 +177,15 @@ const regionHints: Record<string, RegionMusicHint> = {
### USA Hyper-Local Hints ### USA Hyper-Local Hints
| State | Hint Approach | | State | Hint Approach |
|-------|---------------| | -------------- | -------------------------------------- |
| **Louisiana** | Jazz/blues hint, New Orleans feel | | **Louisiana** | Jazz/blues hint, New Orleans feel |
| **Tennessee** | Country twang, Nashville texture | | **Tennessee** | Country twang, Nashville texture |
| **New York** | Urban jazz, bebop hint | | **New York** | Urban jazz, bebop hint |
| **California** | Surf/beach vibes, relaxed | | **California** | Surf/beach vibes, relaxed |
| **Texas** | Country/western hint | | **Texas** | Country/western hint |
| **Hawaii** | Slack-key guitar, ukulele texture | | **Hawaii** | Slack-key guitar, ukulele texture |
| **Alaska** | Arctic ambient, sparse | | **Alaska** | Arctic ambient, sparse |
| **New Mexico** | Desert southwestern, Native flute hint | | **New Mexico** | Desert southwestern, Native flute hint |
### Graceful Degradation ### Graceful Degradation
@ -188,15 +200,17 @@ If music disabled → play nothing
## Game State Reactivity ## Game State Reactivity
### Temperature Feedback (Hot/Cold) ### Temperature Feedback (Hot/Cold)
| State | Musical Response |
|-------|------------------| | State | Musical Response |
| **Freezing/Cold** | Sparse, quiet, slow filter cutoff, minor color | | ----------------- | -------------------------------------------------- |
| **Neutral** | Baseline ambient | | **Freezing/Cold** | Sparse, quiet, slow filter cutoff, minor color |
| **Warmer** | Slight energy increase, filter opens | | **Neutral** | Baseline ambient |
| **Hot/On Fire** | Fuller texture, brighter, subtle excitement | | **Warmer** | Slight energy increase, filter opens |
| **Found It!** | Brief celebratory flourish, then return to ambient | | **Hot/On Fire** | Fuller texture, brighter, subtle excitement |
| **Found It!** | Brief celebratory flourish, then return to ambient |
### Implementation Approach ### Implementation Approach
- Use **Strudel's pattern modulation** to adjust parameters - Use **Strudel's pattern modulation** to adjust parameters
- Crossfade between "cold" and "hot" pattern variations - Crossfade between "cold" and "hot" pattern variations
- Keep changes **gradual** (over 2-4 seconds) to avoid jarring shifts - Keep changes **gradual** (over 2-4 seconds) to avoid jarring shifts
@ -229,6 +243,7 @@ If music disabled → play nothing
``` ```
### File Structure ### File Structure
``` ```
src/arcade-games/know-your-world/ src/arcade-games/know-your-world/
├── music/ ├── music/
@ -263,11 +278,13 @@ src/arcade-games/know-your-world/
## User Controls ## User Controls
### Minimal UI ### Minimal UI
- **Single mute/unmute button** in settings panel - **Single mute/unmute button** in settings panel
- **Volume slider** (optional, could omit for simplicity) - **Volume slider** (optional, could omit for simplicity)
- **Remember preference** in localStorage - **Remember preference** in localStorage
### Respecting User Preferences ### Respecting User Preferences
- Check `prefers-reduced-motion` - disable reactive changes - Check `prefers-reduced-motion` - disable reactive changes
- Default to **muted** on first visit (require explicit opt-in) - Default to **muted** on first visit (require explicit opt-in)
- Respect system-wide audio settings - Respect system-wide audio settings
@ -275,6 +292,7 @@ src/arcade-games/know-your-world/
## Implementation Phases ## Implementation Phases
### Phase 1: Foundation ### Phase 1: Foundation
1. Install Strudel packages (`@strudel/core`, `@strudel/webaudio`, `@strudel/mini`) 1. Install Strudel packages (`@strudel/core`, `@strudel/webaudio`, `@strudel/mini`)
2. Create `useMusicEngine` hook with basic playback 2. Create `useMusicEngine` hook with basic playback
3. Create Layer 1: Base ambient drone 3. Create Layer 1: Base ambient drone
@ -283,6 +301,7 @@ src/arcade-games/know-your-world/
6. Handle browser autoplay restrictions (require user interaction) 6. Handle browser autoplay restrictions (require user interaction)
### Phase 2: Continental Layer ### Phase 2: Continental Layer
1. Create continental preset files (Layer 2) 1. Create continental preset files (Layer 2)
2. Implement region-to-continent mapping 2. Implement region-to-continent mapping
3. Add crossfade between continental presets 3. Add crossfade between continental presets
@ -290,6 +309,7 @@ src/arcade-games/know-your-world/
5. Balance volumes between Layer 1 and Layer 2 5. Balance volumes between Layer 1 and Layer 2
### Phase 3: Hyper-Local Layer ### Phase 3: Hyper-Local Layer
1. Create data structure for region hints 1. Create data structure for region hints
2. Implement ~20 world region hints (start with most distinctive) 2. Implement ~20 world region hints (start with most distinctive)
3. Implement ~10 US state hints 3. Implement ~10 US state hints
@ -298,6 +318,7 @@ src/arcade-games/know-your-world/
6. Test layering of all three layers together 6. Test layering of all three layers together
### Phase 4: Game State Reactivity ### Phase 4: Game State Reactivity
1. Implement temperature modulation (affects all layers) 1. Implement temperature modulation (affects all layers)
2. Add celebration flourish 2. Add celebration flourish
3. Connect to existing hot/cold feedback system 3. Connect to existing hot/cold feedback system
@ -305,6 +326,7 @@ src/arcade-games/know-your-world/
5. Ensure modulation feels musical, not jarring 5. Ensure modulation feels musical, not jarring
### Phase 5: Polish ### Phase 5: Polish
1. Add volume control slider 1. Add volume control slider
2. Persist music preferences in localStorage 2. Persist music preferences in localStorage
3. Add prefers-reduced-motion support 3. Add prefers-reduced-motion support
@ -316,50 +338,56 @@ src/arcade-games/know-your-world/
## Example Strudel Patterns ## Example Strudel Patterns
### Neutral Ambient Base ### Neutral Ambient Base
```javascript ```javascript
// Slow, evolving pad // Slow, evolving pad
stack( stack(
note("c2 g2 c3").sound("sine").lpf(400).gain(0.1).slow(8), note("c2 g2 c3").sound("sine").lpf(400).gain(0.1).slow(8),
note("e3 g3").sound("triangle").lpf(800).gain(0.05).slow(16) note("e3 g3").sound("triangle").lpf(800).gain(0.05).slow(16),
).room(0.8) ).room(0.8);
``` ```
### European Variation ### European Variation
```javascript ```javascript
// Dorian coloring, organ-like // Dorian coloring, organ-like
stack( stack(
note("d2 a2 d3").sound("sine").lpf(500).gain(0.1).slow(8), note("d2 a2 d3").sound("sine").lpf(500).gain(0.1).slow(8),
note("f3 a3 c4").sound("sawtooth").lpf(600).gain(0.03).slow(12) note("f3 a3 c4").sound("sawtooth").lpf(600).gain(0.03).slow(12),
).room(0.9).delay(0.3) )
.room(0.9)
.delay(0.3);
``` ```
### Hot/Warm Modulation ### Hot/Warm Modulation
```javascript ```javascript
// Add brightness and subtle rhythm // Add brightness and subtle rhythm
basePattern basePattern
.lpf(sine.range(600, 1200).slow(4)) // Filter sweep .lpf(sine.range(600, 1200).slow(4)) // Filter sweep
.gain(sine.range(0.08, 0.12).slow(2)) // Volume pulse .gain(sine.range(0.08, 0.12).slow(2)); // Volume pulse
``` ```
### Cold Modulation ### Cold Modulation
```javascript ```javascript
// Sparse, dark, slow // Sparse, dark, slow
basePattern basePattern
.lpf(300) .lpf(300)
.gain(0.05) .gain(0.05)
.slow(2) // Half speed .slow(2) // Half speed
.sometimes(x => silence) // Random dropouts .sometimes((x) => silence); // Random dropouts
``` ```
## Risks and Mitigations ## Risks and Mitigations
| Risk | Mitigation | | Risk | Mitigation |
|------|------------| | -------------------------- | -------------------------------------------------- |
| **Annoying users** | Default to muted, easy controls, subtle design | | **Annoying users** | Default to muted, easy controls, subtle design |
| **Cultural insensitivity** | Avoid cliches, focus on abstract musical elements | | **Cultural insensitivity** | Avoid cliches, focus on abstract musical elements |
| **Performance issues** | Lazy load Strudel, use efficient patterns | | **Performance issues** | Lazy load Strudel, use efficient patterns |
| **Browser compatibility** | Web Audio API is well-supported, graceful fallback | | **Browser compatibility** | Web Audio API is well-supported, graceful fallback |
| **Bundle size** | Consider dynamic imports for music module | | **Bundle size** | Consider dynamic imports for music module |
## Open Questions ## Open Questions

View File

@ -34,21 +34,27 @@ The transition is instant - no pause, no celebration, no feedback.
## Celebration Types ## Celebration Types
### 1. Lightning Find ⚡ (< 3 seconds) ### 1. Lightning Find ⚡ (< 3 seconds)
Kid knew exactly where to look - reward the speed! Kid knew exactly where to look - reward the speed!
- **Flash**: Quick, bright gold pulse (400ms) - **Flash**: Quick, bright gold pulse (400ms)
- **Confetti**: Fast, sparkly burst (small particles, quick fade) - **Confetti**: Fast, sparkly burst (small particles, quick fade)
- **Sound**: Quick "ding!" or sparkle - **Sound**: Quick "ding!" or sparkle
- **Duration**: ~800ms total - **Duration**: ~800ms total
### 2. Standard Find ✨ (3-15 seconds, direct path) ### 2. Standard Find ✨ (3-15 seconds, direct path)
Normal discovery - celebrate appropriately Normal discovery - celebrate appropriately
- **Flash**: Smooth gold pulse (600ms) - **Flash**: Smooth gold pulse (600ms)
- **Confetti**: Medium burst with gravity fall - **Confetti**: Medium burst with gravity fall
- **Sound**: Pleasant chime - **Sound**: Pleasant chime
- **Duration**: ~1.2 seconds total - **Duration**: ~1.2 seconds total
### 3. Hard-Earned Find 💪 (searched extensively) ### 3. Hard-Earned Find 💪 (searched extensively)
Kid really worked for it - acknowledge the effort! Kid really worked for it - acknowledge the effort!
- **Flash**: Warm, satisfying glow (800ms) - **Flash**: Warm, satisfying glow (800ms)
- **Confetti**: Big celebration! More particles, longer duration - **Confetti**: Big celebration! More particles, longer duration
- **Sound**: Triumphant fanfare/chord - **Sound**: Triumphant fanfare/chord
@ -62,6 +68,7 @@ Kid really worked for it - acknowledge the effort!
### Data Available from Hot/Cold System ### Data Available from Hot/Cold System
The `useHotColdFeedback` hook already tracks: The `useHotColdFeedback` hook already tracks:
```typescript ```typescript
interface PathEntry { interface PathEntry {
x: number x: number
@ -81,46 +88,48 @@ minDistanceSinceLastFeedback // Got close then moved away?
```typescript ```typescript
interface SearchMetrics { interface SearchMetrics {
// Time // Time
timeToFind: number // ms from prompt start to correct click timeToFind: number; // ms from prompt start to correct click
// Distance traveled // Distance traveled
totalCursorDistance: number // Total pixels cursor moved (from history) totalCursorDistance: number; // Total pixels cursor moved (from history)
straightLineDistance: number // Direct path would have been straightLineDistance: number; // Direct path would have been
searchEfficiency: number // straight / total (1.0 = perfect, <0.3 = searched hard) searchEfficiency: number; // straight / total (1.0 = perfect, <0.3 = searched hard)
// Direction changes // Direction changes
directionReversals: number // How many times changed direction toward/away directionReversals: number; // How many times changed direction toward/away
// Near misses // Near misses
nearMissCount: number // Times got within CLOSE threshold then moved away nearMissCount: number; // Times got within CLOSE threshold then moved away
overshotCount: number // Times passed the target overshotCount: number; // Times passed the target
// Zone transitions // Zone transitions
zoneTransitions: number // warming→cooling→warming transitions zoneTransitions: number; // warming→cooling→warming transitions
} }
``` ```
### Classification Logic ### Classification Logic
```typescript ```typescript
function classifyCelebration(metrics: SearchMetrics): 'lightning' | 'standard' | 'hard-earned' { function classifyCelebration(
metrics: SearchMetrics,
): "lightning" | "standard" | "hard-earned" {
// Lightning: Fast and direct // Lightning: Fast and direct
if (metrics.timeToFind < 3000 && metrics.searchEfficiency > 0.7) { if (metrics.timeToFind < 3000 && metrics.searchEfficiency > 0.7) {
return 'lightning' return "lightning";
} }
// Hard-earned: Any of these indicate real effort // Hard-earned: Any of these indicate real effort
if ( if (
metrics.timeToFind > 20000 || // Took a while metrics.timeToFind > 20000 || // Took a while
metrics.searchEfficiency < 0.3 || // Wandered a lot metrics.searchEfficiency < 0.3 || // Wandered a lot
metrics.directionReversals > 10 || // Lots of back-and-forth metrics.directionReversals > 10 || // Lots of back-and-forth
metrics.nearMissCount > 2 || // Got close multiple times metrics.nearMissCount > 2 || // Got close multiple times
metrics.overshotCount > 1 // Passed it more than once metrics.overshotCount > 1 // Passed it more than once
) { ) {
return 'hard-earned' return "hard-earned";
} }
return 'standard' return "standard";
} }
``` ```
@ -144,6 +153,7 @@ User clicks region → onRegionClick(id, name)
### Key Change: Delay State Update ### Key Change: Delay State Update
Instead of immediately calling `clickRegion` and advancing, we: Instead of immediately calling `clickRegion` and advancing, we:
1. Detect correct click locally in MapRenderer 1. Detect correct click locally in MapRenderer
2. Start celebration animation 2. Start celebration animation
3. Block input during celebration 3. Block input during celebration
@ -160,13 +170,13 @@ This ensures the map doesn't clutter with the next prompt while celebrating.
```typescript ```typescript
// Add to Provider.tsx context // Add to Provider.tsx context
interface CelebrationState { interface CelebrationState {
regionId: string regionId: string;
regionName: string regionName: string;
type: 'lightning' | 'standard' | 'hard-earned' type: "lightning" | "standard" | "hard-earned";
startTime: number startTime: number;
} }
const [celebration, setCelebration] = useState<CelebrationState | null>(null) const [celebration, setCelebration] = useState<CelebrationState | null>(null);
``` ```
### Expose Search Metrics from Hot/Cold Hook ### Expose Search Metrics from Hot/Cold Hook
@ -221,23 +231,26 @@ export function useHotColdFeedback(...) {
```typescript ```typescript
// useCelebrationSound.ts // useCelebrationSound.ts
const audioContext = new (window.AudioContext || window.webkitAudioContext)() const audioContext = new (window.AudioContext || window.webkitAudioContext)();
function playLightningSound() { function playLightningSound() {
// Quick sparkle: high frequency, fast decay // Quick sparkle: high frequency, fast decay
const osc = audioContext.createOscillator() const osc = audioContext.createOscillator();
const gain = audioContext.createGain() const gain = audioContext.createGain();
osc.type = 'sine' osc.type = "sine";
osc.frequency.setValueAtTime(1200, audioContext.currentTime) osc.frequency.setValueAtTime(1200, audioContext.currentTime);
osc.frequency.exponentialRampToValueAtTime(2400, audioContext.currentTime + 0.1) osc.frequency.exponentialRampToValueAtTime(
2400,
audioContext.currentTime + 0.1,
);
gain.gain.setValueAtTime(0.3, audioContext.currentTime) gain.gain.setValueAtTime(0.3, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2) gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
osc.connect(gain).connect(audioContext.destination) osc.connect(gain).connect(audioContext.destination);
osc.start() osc.start();
osc.stop(audioContext.currentTime + 0.2) osc.stop(audioContext.currentTime + 0.2);
} }
function playStandardSound() { function playStandardSound() {
@ -287,19 +300,19 @@ style={{
```typescript ```typescript
// components/Confetti.tsx // components/Confetti.tsx
interface ConfettiProps { interface ConfettiProps {
type: 'lightning' | 'standard' | 'hard-earned' type: "lightning" | "standard" | "hard-earned";
origin: { x: number; y: number } // Screen coordinates origin: { x: number; y: number }; // Screen coordinates
onComplete: () => void onComplete: () => void;
} }
const CONFETTI_CONFIG = { const CONFETTI_CONFIG = {
lightning: { count: 12, duration: 600, spread: 60 }, lightning: { count: 12, duration: 600, spread: 60 },
standard: { count: 20, duration: 1000, spread: 90 }, standard: { count: 20, duration: 1000, spread: 90 },
'hard-earned': { count: 35, duration: 1500, spread: 120 }, "hard-earned": { count: 35, duration: 1500, spread: 120 },
} };
function Confetti({ type, origin, onComplete }: ConfettiProps) { function Confetti({ type, origin, onComplete }: ConfettiProps) {
const config = CONFETTI_CONFIG[type] const config = CONFETTI_CONFIG[type];
// Generate particles with random directions, colors, sizes // Generate particles with random directions, colors, sizes
// Use CSS animations for performance // Use CSS animations for performance
@ -347,12 +360,14 @@ function CelebrationOverlay({ celebration, regionCenter, onComplete }: Celebrati
## Files to Create/Modify ## Files to Create/Modify
### New Files ### New Files
1. **`hooks/useCelebrationSound.ts`** - Web Audio API sound effects 1. **`hooks/useCelebrationSound.ts`** - Web Audio API sound effects
2. **`hooks/useSearchMetrics.ts`** - Extract metrics from hot/cold history 2. **`hooks/useSearchMetrics.ts`** - Extract metrics from hot/cold history
3. **`components/Confetti.tsx`** - CSS confetti particles 3. **`components/Confetti.tsx`** - CSS confetti particles
4. **`components/CelebrationOverlay.tsx`** - Orchestrates celebration 4. **`components/CelebrationOverlay.tsx`** - Orchestrates celebration
### Modified Files ### Modified Files
1. **`Provider.tsx`** - Add celebration state to context 1. **`Provider.tsx`** - Add celebration state to context
2. **`hooks/useHotColdFeedback.ts`** - Expose `getSearchMetrics()` method 2. **`hooks/useHotColdFeedback.ts`** - Expose `getSearchMetrics()` method
3. **`MapRenderer.tsx`** - Intercept correct clicks, trigger celebration, delay advancement 3. **`MapRenderer.tsx`** - Intercept correct clicks, trigger celebration, delay advancement
@ -362,31 +377,37 @@ function CelebrationOverlay({ celebration, regionCenter, onComplete }: Celebrati
## Implementation Order ## Implementation Order
### Phase 1: Infrastructure ### Phase 1: Infrastructure
1. Add `celebration` state to Provider context 1. Add `celebration` state to Provider context
2. Add `promptStartTime` tracking (when each region prompt begins) 2. Add `promptStartTime` tracking (when each region prompt begins)
3. Modify `useHotColdFeedback` to expose `getSearchMetrics()` 3. Modify `useHotColdFeedback` to expose `getSearchMetrics()`
### Phase 2: Classification ### Phase 2: Classification
4. Create `useSearchMetrics` hook to calculate metrics 4. Create `useSearchMetrics` hook to calculate metrics
5. Implement `classifyCelebration()` function 5. Implement `classifyCelebration()` function
6. Test metric calculation with various search patterns 6. Test metric calculation with various search patterns
### Phase 3: Visuals ### Phase 3: Visuals
7. Create `Confetti` component with CSS animations 7. Create `Confetti` component with CSS animations
8. Add gold flash effect to MapRenderer (react-spring) 8. Add gold flash effect to MapRenderer (react-spring)
9. Create `CelebrationOverlay` to orchestrate 9. Create `CelebrationOverlay` to orchestrate
### Phase 4: Audio ### Phase 4: Audio
10. Create `useCelebrationSound` hook with Web Audio API 10. Create `useCelebrationSound` hook with Web Audio API
11. Implement three sound types (lightning, standard, hard-earned) 11. Implement three sound types (lightning, standard, hard-earned)
12. Wire up sounds to celebration types 12. Wire up sounds to celebration types
### Phase 5: Integration ### Phase 5: Integration
13. Intercept correct clicks in MapRenderer 13. Intercept correct clicks in MapRenderer
14. Block advancement during celebration 14. Block advancement during celebration
15. Call `clickRegion` only after celebration completes 15. Call `clickRegion` only after celebration completes
### Phase 6: Polish ### Phase 6: Polish
16. Add `prefers-reduced-motion` support 16. Add `prefers-reduced-motion` support
17. Test on mobile (performance, touch) 17. Test on mobile (performance, touch)
18. Fine-tune timing and particle counts 18. Fine-tune timing and particle counts
@ -417,8 +438,8 @@ function CelebrationOverlay({ celebration, regionCenter, onComplete }: Celebrati
## Timing Summary ## Timing Summary
| Type | Flash | Confetti | Sound | Total Block | | Type | Flash | Confetti | Sound | Total Block |
|------|-------|----------|-------|-------------| | -------------- | ----- | -------- | ----- | ----------- |
| Lightning ⚡ | 400ms | 600ms | 200ms | 600ms | | Lightning ⚡ | 400ms | 600ms | 200ms | 600ms |
| Standard ✨ | 600ms | 1000ms | 400ms | 1000ms | | Standard ✨ | 600ms | 1000ms | 400ms | 1000ms |
| Hard-earned 💪 | 800ms | 1500ms | 600ms | 1500ms | | Hard-earned 💪 | 800ms | 1500ms | 600ms | 1500ms |

View File

@ -9,18 +9,21 @@ The dotted outline on the main map that shows the magnified region no longer mat
- Portrait: 1/2 width × 1/3 height - Portrait: 1/2 width × 1/3 height
2. **The magnifier viewBox still uses map-based aspect ratio**: 2. **The magnifier viewBox still uses map-based aspect ratio**:
```typescript ```typescript
// Current - MapRenderer.tsx lines 3017-3018 // Current - MapRenderer.tsx lines 3017-3018
const magnifiedWidth = viewBoxWidth / zoom // e.g., 1000/10 = 100 const magnifiedWidth = viewBoxWidth / zoom; // e.g., 1000/10 = 100
const magnifiedHeight = viewBoxHeight / zoom // e.g., 500/10 = 50 (2:1 ratio) const magnifiedHeight = viewBoxHeight / zoom; // e.g., 500/10 = 50 (2:1 ratio)
``` ```
This creates a viewBox with the map's aspect ratio (e.g., 2:1 for world map). This creates a viewBox with the map's aspect ratio (e.g., 2:1 for world map).
3. **The outline uses the same calculation**: 3. **The outline uses the same calculation**:
```typescript ```typescript
// Current - MapRenderer.tsx lines 2578-2586 // Current - MapRenderer.tsx lines 2578-2586
width = viewBoxWidth / zoom width = viewBoxWidth / zoom;
height = viewBoxHeight / zoom height = viewBoxHeight / zoom;
``` ```
4. **Aspect ratio mismatch causes letterboxing**: The magnifier container might be 1/3w × 1/2h (taller), but the viewBox is 2:1 (wider). With default `preserveAspectRatio="xMidYMid meet"`, the SVG scales to fit with letterboxing. The outline shows the viewBox dimensions, but the magnifier container appears a different shape. 4. **Aspect ratio mismatch causes letterboxing**: The magnifier container might be 1/3w × 1/2h (taller), but the viewBox is 2:1 (wider). With default `preserveAspectRatio="xMidYMid meet"`, the SVG scales to fit with letterboxing. The outline shows the viewBox dimensions, but the magnifier container appears a different shape.
@ -59,6 +62,7 @@ Screen in landscape mode:
**Change the magnifier viewBox to match the container's aspect ratio.** **Change the magnifier viewBox to match the container's aspect ratio.**
Instead of letterboxing, make the magnifier show exactly what its container shape suggests. This means: Instead of letterboxing, make the magnifier show exactly what its container shape suggests. This means:
1. Calculate magnifier container aspect ratio 1. Calculate magnifier container aspect ratio
2. Adjust the magnified viewBox dimensions to match 2. Adjust the magnified viewBox dimensions to match
3. Use the same adjusted dimensions for the outline 3. Use the same adjusted dimensions for the outline
@ -66,35 +70,38 @@ Instead of letterboxing, make the magnifier show exactly what its container shap
### How It Works ### How It Works
Given: Given:
- Magnifier container: `magnifierWidth × magnifierHeight` (from `getMagnifierDimensions`) - Magnifier container: `magnifierWidth × magnifierHeight` (from `getMagnifierDimensions`)
- Base magnified region: `viewBoxWidth/zoom × viewBoxHeight/zoom` - Base magnified region: `viewBoxWidth/zoom × viewBoxHeight/zoom`
- Container aspect ratio: `CA = magnifierWidth / magnifierHeight` - Container aspect ratio: `CA = magnifierWidth / magnifierHeight`
- ViewBox aspect ratio: `VA = viewBoxWidth / viewBoxHeight` - ViewBox aspect ratio: `VA = viewBoxWidth / viewBoxHeight`
Calculate the adjusted viewBox that fills the container: Calculate the adjusted viewBox that fills the container:
```typescript ```typescript
// Start with zoom-based dimensions // Start with zoom-based dimensions
const baseWidth = viewBoxWidth / zoom const baseWidth = viewBoxWidth / zoom;
const baseHeight = viewBoxHeight / zoom const baseHeight = viewBoxHeight / zoom;
// Container aspect ratio // Container aspect ratio
const containerAspect = magnifierWidth / magnifierHeight const containerAspect = magnifierWidth / magnifierHeight;
const viewBoxAspect = baseWidth / baseHeight const viewBoxAspect = baseWidth / baseHeight;
let adjustedWidth, adjustedHeight let adjustedWidth, adjustedHeight;
if (containerAspect > viewBoxAspect) { if (containerAspect > viewBoxAspect) {
// Container is wider than viewBox - expand width to match // Container is wider than viewBox - expand width to match
adjustedHeight = baseHeight adjustedHeight = baseHeight;
adjustedWidth = baseHeight * containerAspect adjustedWidth = baseHeight * containerAspect;
} else { } else {
// Container is taller than viewBox - expand height to match // Container is taller than viewBox - expand height to match
adjustedWidth = baseWidth adjustedWidth = baseWidth;
adjustedHeight = baseWidth / containerAspect adjustedHeight = baseWidth / containerAspect;
} }
``` ```
This gives us a viewBox that: This gives us a viewBox that:
- Has the same aspect ratio as the magnifier container - Has the same aspect ratio as the magnifier container
- Is centered on the same point - Is centered on the same point
- Shows a slightly larger region (no letterboxing) - Shows a slightly larger region (no letterboxing)
@ -106,15 +113,22 @@ This gives us a viewBox that:
Move the constants and function from MapRenderer: Move the constants and function from MapRenderer:
```typescript ```typescript
export const MAGNIFIER_SIZE_SMALL = 1 / 3 export const MAGNIFIER_SIZE_SMALL = 1 / 3;
export const MAGNIFIER_SIZE_LARGE = 1 / 2 export const MAGNIFIER_SIZE_LARGE = 1 / 2;
export function getMagnifierDimensions(containerWidth: number, containerHeight: number) { export function getMagnifierDimensions(
const isLandscape = containerWidth > containerHeight containerWidth: number,
containerHeight: number,
) {
const isLandscape = containerWidth > containerHeight;
return { return {
width: containerWidth * (isLandscape ? MAGNIFIER_SIZE_SMALL : MAGNIFIER_SIZE_LARGE), width:
height: containerHeight * (isLandscape ? MAGNIFIER_SIZE_LARGE : MAGNIFIER_SIZE_SMALL), containerWidth *
} (isLandscape ? MAGNIFIER_SIZE_SMALL : MAGNIFIER_SIZE_LARGE),
height:
containerHeight *
(isLandscape ? MAGNIFIER_SIZE_LARGE : MAGNIFIER_SIZE_SMALL),
};
} }
/** /**
@ -126,28 +140,31 @@ export function getAdjustedMagnifiedDimensions(
viewBoxHeight: number, viewBoxHeight: number,
zoom: number, zoom: number,
containerWidth: number, containerWidth: number,
containerHeight: number containerHeight: number,
) { ) {
const { width: magWidth, height: magHeight } = getMagnifierDimensions(containerWidth, containerHeight) const { width: magWidth, height: magHeight } = getMagnifierDimensions(
containerWidth,
containerHeight,
);
const baseWidth = viewBoxWidth / zoom const baseWidth = viewBoxWidth / zoom;
const baseHeight = viewBoxHeight / zoom const baseHeight = viewBoxHeight / zoom;
const containerAspect = magWidth / magHeight const containerAspect = magWidth / magHeight;
const viewBoxAspect = baseWidth / baseHeight const viewBoxAspect = baseWidth / baseHeight;
if (containerAspect > viewBoxAspect) { if (containerAspect > viewBoxAspect) {
// Container is wider - expand width // Container is wider - expand width
return { return {
width: baseHeight * containerAspect, width: baseHeight * containerAspect,
height: baseHeight, height: baseHeight,
} };
} else { } else {
// Container is taller - expand height // Container is taller - expand height
return { return {
width: baseWidth, width: baseWidth,
height: baseWidth / containerAspect, height: baseWidth / containerAspect,
} };
} }
} }
``` ```
@ -155,32 +172,36 @@ export function getAdjustedMagnifiedDimensions(
### 2. Update `MapRenderer.tsx` ### 2. Update `MapRenderer.tsx`
#### Import from new utility: #### Import from new utility:
```typescript ```typescript
import { import {
getMagnifierDimensions, getMagnifierDimensions,
getAdjustedMagnifiedDimensions, getAdjustedMagnifiedDimensions,
MAGNIFIER_SIZE_SMALL, MAGNIFIER_SIZE_SMALL,
MAGNIFIER_SIZE_LARGE, MAGNIFIER_SIZE_LARGE,
} from '../utils/magnifierDimensions' } from "../utils/magnifierDimensions";
``` ```
#### Update magnifier viewBox (lines 3017-3024): #### Update magnifier viewBox (lines 3017-3024):
```typescript ```typescript
// OLD: // OLD:
const magnifiedWidth = viewBoxWidth / zoom const magnifiedWidth = viewBoxWidth / zoom;
const magnifiedHeight = viewBoxHeight / zoom const magnifiedHeight = viewBoxHeight / zoom;
// NEW: // NEW:
const { width: magnifiedWidth, height: magnifiedHeight } = getAdjustedMagnifiedDimensions( const { width: magnifiedWidth, height: magnifiedHeight } =
viewBoxWidth, getAdjustedMagnifiedDimensions(
viewBoxHeight, viewBoxWidth,
zoom, viewBoxHeight,
containerRect.width, zoom,
containerRect.height containerRect.width,
) containerRect.height,
);
``` ```
#### Update outline dimensions (lines 2578-2586): #### Update outline dimensions (lines 2578-2586):
```typescript ```typescript
// Same calculation as magnifier viewBox // Same calculation as magnifier viewBox
width={zoomSpring.to((zoom: number) => { width={zoomSpring.to((zoom: number) => {
@ -211,27 +232,28 @@ height={zoomSpring.to((zoom: number) => {
Replace hardcoded `0.5` with actual dimensions: Replace hardcoded `0.5` with actual dimensions:
```typescript ```typescript
import { getMagnifierDimensions } from '../utils/magnifierDimensions' import { getMagnifierDimensions } from "../utils/magnifierDimensions";
// Line 94, 138, 163 - replace: // Line 94, 138, 163 - replace:
// const magnifierWidth = containerRect.width * 0.5 // const magnifierWidth = containerRect.width * 0.5
// with: // with:
const { width: magnifierWidth } = getMagnifierDimensions( const { width: magnifierWidth } = getMagnifierDimensions(
containerRect.width, containerRect.width,
containerRect.height containerRect.height,
) );
``` ```
## Summary ## Summary
| Component | Current Behavior | After Fix | | Component | Current Behavior | After Fix |
|-----------|-----------------|-----------| | ------------------- | ------------------------------- | -------------------------------- |
| Magnifier container | Responsive (1/3×1/2 or 1/2×1/3) | No change | | Magnifier container | Responsive (1/3×1/2 or 1/2×1/3) | No change |
| Magnifier viewBox | Fixed 2:1 ratio → letterboxing | Matches container aspect ratio | | Magnifier viewBox | Fixed 2:1 ratio → letterboxing | Matches container aspect ratio |
| Outline | Fixed 2:1 ratio | Matches container aspect ratio | | Outline | Fixed 2:1 ratio | Matches container aspect ratio |
| Zoom calculation | Wrong (uses 0.5) | Correct (uses actual dimensions) | | Zoom calculation | Wrong (uses 0.5) | Correct (uses actual dimensions) |
### Implementation Order ### Implementation Order
1. Create `utils/magnifierDimensions.ts` with shared functions 1. Create `utils/magnifierDimensions.ts` with shared functions
2. Update `MapRenderer.tsx` imports and remove local copies of constants/function 2. Update `MapRenderer.tsx` imports and remove local copies of constants/function
3. Update magnifier viewBox calculation 3. Update magnifier viewBox calculation

View File

@ -7,6 +7,7 @@ This document outlines potential refactoring opportunities for the DrillDownMapS
**Current State:** `sizesToRange` and `rangeToSizes` are defined inline in DrillDownMapSelector.tsx. **Current State:** `sizesToRange` and `rangeToSizes` are defined inline in DrillDownMapSelector.tsx.
**Problem:** **Problem:**
- Functions can't be easily imported for testing - Functions can't be easily imported for testing
- Duplicated logic if needed elsewhere - Duplicated logic if needed elsewhere
- Component file is larger than necessary - Component file is larger than necessary
@ -15,20 +16,20 @@ This document outlines potential refactoring opportunities for the DrillDownMapS
```typescript ```typescript
// regionSizeUtils.ts // regionSizeUtils.ts
import type { RegionSize } from '../maps' import type { RegionSize } from "../maps";
import { ALL_REGION_SIZES } from '../maps' import { ALL_REGION_SIZES } from "../maps";
export function sizesToRange(sizes: RegionSize[]): [RegionSize, RegionSize] { export function sizesToRange(sizes: RegionSize[]): [RegionSize, RegionSize] {
const sorted = [...sizes].sort( const sorted = [...sizes].sort(
(a, b) => ALL_REGION_SIZES.indexOf(a) - ALL_REGION_SIZES.indexOf(b) (a, b) => ALL_REGION_SIZES.indexOf(a) - ALL_REGION_SIZES.indexOf(b),
) );
return [sorted[0], sorted[sorted.length - 1]] return [sorted[0], sorted[sorted.length - 1]];
} }
export function rangeToSizes(min: RegionSize, max: RegionSize): RegionSize[] { export function rangeToSizes(min: RegionSize, max: RegionSize): RegionSize[] {
const minIdx = ALL_REGION_SIZES.indexOf(min) const minIdx = ALL_REGION_SIZES.indexOf(min);
const maxIdx = ALL_REGION_SIZES.indexOf(max) const maxIdx = ALL_REGION_SIZES.indexOf(max);
return ALL_REGION_SIZES.slice(minIdx, maxIdx + 1) return ALL_REGION_SIZES.slice(minIdx, maxIdx + 1);
} }
``` ```
@ -41,6 +42,7 @@ export function rangeToSizes(min: RegionSize, max: RegionSize): RegionSize[] {
**Current State:** Multiple `useMemo` blocks in DrillDownMapSelector calculate excluded regions, preview regions, and region names. **Current State:** Multiple `useMemo` blocks in DrillDownMapSelector calculate excluded regions, preview regions, and region names.
**Problem:** **Problem:**
- Complex memoization dependencies - Complex memoization dependencies
- Hard to test filtering logic in isolation - Hard to test filtering logic in isolation
- Repeated patterns across different calculations - Repeated patterns across different calculations
@ -50,22 +52,24 @@ export function rangeToSizes(min: RegionSize, max: RegionSize): RegionSize[] {
```typescript ```typescript
// hooks/useRegionFiltering.ts // hooks/useRegionFiltering.ts
interface UseRegionFilteringProps { interface UseRegionFilteringProps {
mapId: 'world' | 'usa' mapId: "world" | "usa";
continentId: ContinentId | 'all' continentId: ContinentId | "all";
includeSizes: RegionSize[] includeSizes: RegionSize[];
previewSizes?: RegionSize[] | null previewSizes?: RegionSize[] | null;
} }
interface UseRegionFilteringResult { interface UseRegionFilteringResult {
includedRegions: string[] includedRegions: string[];
excludedRegions: string[] excludedRegions: string[];
previewAddRegions: string[] previewAddRegions: string[];
previewRemoveRegions: string[] previewRemoveRegions: string[];
regionNamesBySize: Record<RegionSize, string[]> regionNamesBySize: Record<RegionSize, string[]>;
selectedRegionNames: string[] selectedRegionNames: string[];
} }
export function useRegionFiltering(props: UseRegionFilteringProps): UseRegionFilteringResult { export function useRegionFiltering(
props: UseRegionFilteringProps,
): UseRegionFilteringResult {
// Consolidate all filtering logic here // Consolidate all filtering logic here
} }
``` ```
@ -79,6 +83,7 @@ export function useRegionFiltering(props: UseRegionFilteringProps): UseRegionFil
**Current State:** RangeThermometer has 14+ props, many of which are optional and interdependent. **Current State:** RangeThermometer has 14+ props, many of which are optional and interdependent.
**Problem:** **Problem:**
- Prop drilling complexity - Prop drilling complexity
- Unclear which props work together - Unclear which props work together
- Large interface to understand - Large interface to understand
@ -126,6 +131,7 @@ interface RangeThermometerProps<T> {
**Current State:** The inline region list in DrillDownMapSelector is defined inline with ~50 lines of JSX. **Current State:** The inline region list in DrillDownMapSelector is defined inline with ~50 lines of JSX.
**Problem:** **Problem:**
- DrillDownMapSelector is 1300+ lines - DrillDownMapSelector is 1300+ lines
- Region list styling is tightly coupled - Region list styling is tightly coupled
- Can't reuse list in other contexts - Can't reuse list in other contexts
@ -168,6 +174,7 @@ export function RegionListPanel({ regions, onRegionHover, maxHeight = '200px', i
**Current State:** Multiple places check for responsive behavior with `{ base: '...', md: '...' }` patterns. **Current State:** Multiple places check for responsive behavior with `{ base: '...', md: '...' }` patterns.
**Problem:** **Problem:**
- Breakpoint values scattered across components - Breakpoint values scattered across components
- Inconsistent breakpoint choices - Inconsistent breakpoint choices
- Hard to change responsive behavior globally - Hard to change responsive behavior globally
@ -177,23 +184,23 @@ export function RegionListPanel({ regions, onRegionHover, maxHeight = '200px', i
```typescript ```typescript
// utils/responsive.ts // utils/responsive.ts
export const BREAKPOINTS = { export const BREAKPOINTS = {
mobile: 'base', mobile: "base",
tablet: 'sm', tablet: "sm",
desktop: 'md', desktop: "md",
wide: 'lg', wide: "lg",
} as const } as const;
export function responsiveDisplay(showOnDesktop: boolean) { export function responsiveDisplay(showOnDesktop: boolean) {
return showOnDesktop return showOnDesktop
? { base: 'none', md: 'flex' } ? { base: "none", md: "flex" }
: { base: 'flex', md: 'none' } : { base: "flex", md: "none" };
} }
export function responsiveScale(mobileScale: number, desktopScale: number) { export function responsiveScale(mobileScale: number, desktopScale: number) {
return { return {
transform: { base: `scale(${mobileScale})`, sm: `scale(${desktopScale})` }, transform: { base: `scale(${mobileScale})`, sm: `scale(${desktopScale})` },
transformOrigin: 'top right', transformOrigin: "top right",
} };
} }
``` ```
@ -206,6 +213,7 @@ export function responsiveScale(mobileScale: number, desktopScale: number) {
**Current State:** `SelectionPath` is defined as `[] | [ContinentId] | [ContinentId, string]` **Current State:** `SelectionPath` is defined as `[] | [ContinentId] | [ContinentId, string]`
**Problem:** **Problem:**
- Hard to exhaustively check path levels - Hard to exhaustively check path levels
- Type narrowing requires manual length checks - Type narrowing requires manual length checks
- Semantics not self-documenting - Semantics not self-documenting
@ -214,19 +222,19 @@ export function responsiveScale(mobileScale: number, desktopScale: number) {
```typescript ```typescript
type SelectionPath = type SelectionPath =
| { level: 'world' } | { level: "world" }
| { level: 'continent'; continentId: ContinentId } | { level: "continent"; continentId: ContinentId }
| { level: 'submap'; continentId: ContinentId; submapId: string } | { level: "submap"; continentId: ContinentId; submapId: string };
// Usage becomes more explicit // Usage becomes more explicit
function getMapData(path: SelectionPath) { function getMapData(path: SelectionPath) {
switch (path.level) { switch (path.level) {
case 'world': case "world":
return WORLD_MAP return WORLD_MAP;
case 'continent': case "continent":
return filterByContinent(path.continentId) return filterByContinent(path.continentId);
case 'submap': case "submap":
return getSubMap(path.submapId) return getSubMap(path.submapId);
} }
} }
``` ```
@ -240,6 +248,7 @@ function getMapData(path: SelectionPath) {
**Current State:** Multiple `useMemo` hooks recalculate on every render cycle. **Current State:** Multiple `useMemo` hooks recalculate on every render cycle.
**Problem:** **Problem:**
- `getFilteredMapDataBySizesSync` called multiple times with same params - `getFilteredMapDataBySizesSync` called multiple times with same params
- No caching between component unmount/remount - No caching between component unmount/remount
- Complex dependency arrays - Complex dependency arrays
@ -248,22 +257,30 @@ function getMapData(path: SelectionPath) {
```typescript ```typescript
// Option A: Simple memoization cache // Option A: Simple memoization cache
const regionDataCache = new Map<string, MapData>() const regionDataCache = new Map<string, MapData>();
function getCachedFilteredMapData(mapId: string, continentId: string, sizes: RegionSize[]) { function getCachedFilteredMapData(
const key = `${mapId}-${continentId}-${sizes.join(',')}` mapId: string,
continentId: string,
sizes: RegionSize[],
) {
const key = `${mapId}-${continentId}-${sizes.join(",")}`;
if (!regionDataCache.has(key)) { if (!regionDataCache.has(key)) {
regionDataCache.set(key, getFilteredMapDataBySizesSync(mapId, continentId, sizes)) regionDataCache.set(
key,
getFilteredMapDataBySizesSync(mapId, continentId, sizes),
);
} }
return regionDataCache.get(key)! return regionDataCache.get(key)!;
} }
// Option B: React Query // Option B: React Query
const { data: filteredRegions } = useQuery({ const { data: filteredRegions } = useQuery({
queryKey: ['filtered-regions', mapId, continentId, includeSizes], queryKey: ["filtered-regions", mapId, continentId, includeSizes],
queryFn: () => getFilteredMapDataBySizesSync(mapId, continentId, includeSizes), queryFn: () =>
getFilteredMapDataBySizesSync(mapId, continentId, includeSizes),
staleTime: Infinity, staleTime: Infinity,
}) });
``` ```
**Impact:** Medium complexity, potential performance improvement. **Impact:** Medium complexity, potential performance improvement.
@ -290,6 +307,7 @@ const { data: filteredRegions } = useQuery({
## Testing Considerations ## Testing Considerations
After any refactoring: After any refactoring:
- Run existing tests: `npm run test:run -- src/components/Thermometer src/arcade-games/know-your-world` - Run existing tests: `npm run test:run -- src/components/Thermometer src/arcade-games/know-your-world`
- Add integration tests for extracted components - Add integration tests for extracted components
- Verify responsive behavior manually on mobile/desktop - Verify responsive behavior manually on mobile/desktop

View File

@ -7,6 +7,7 @@ Notes on creating compelling layered music tracks with Strudel.
When layering hints on continental bases, you MUST follow these rules: When layering hints on continental bases, you MUST follow these rules:
### 1. Same Key/Scale ### 1. Same Key/Scale
- **Europe base uses D dorian** → All European hints use `scale("D4:dorian")` - **Europe base uses D dorian** → All European hints use `scale("D4:dorian")`
- **Asia base uses E minor pentatonic** → All Asian hints use `scale("E4:minPent")` - **Asia base uses E minor pentatonic** → All Asian hints use `scale("E4:minPent")`
- **Africa base uses C pentatonic** → All African hints use `scale("C4:pentatonic")` - **Africa base uses C pentatonic** → All African hints use `scale("C4:pentatonic")`
@ -16,22 +17,27 @@ When layering hints on continental bases, you MUST follow these rules:
- **Oceania base uses C major** → All Oceania hints use `scale("C4:major")` - **Oceania base uses C major** → All Oceania hints use `scale("C4:major")`
### 2. Sparse Patterns (Don't Compete!) ### 2. Sparse Patterns (Don't Compete!)
**Bad:** `n("0 4 7 4")` - 4 notes per cycle, competing with base **Bad:** `n("0 4 7 4")` - 4 notes per cycle, competing with base
**Good:** `n("~ 4 ~ ~")` - 1 note per cycle, adds color only **Good:** `n("~ 4 ~ ~")` - 1 note per cycle, adds color only
### 3. Different Register ### 3. Different Register
- Base melody is octave 2-3 - Base melody is octave 2-3
- Hints should be octave 4-5 (sit above, don't clash) - Hints should be octave 4-5 (sit above, don't clash)
### 4. Soft Timbres ### 4. Soft Timbres
- Base uses sawtooth (rich harmonics) - Base uses sawtooth (rich harmonics)
- Hints should use sine/soft triangle (pure, non-competing) - Hints should use sine/soft triangle (pure, non-competing)
### 5. Slow Movement ### 5. Slow Movement
- Base moves fast with `.fast(1.2)` - Base moves fast with `.fast(1.2)`
- Hints should use `.slow(8)` or slower - evolving atmosphere - Hints should use `.slow(8)` or slower - evolving atmosphere
### 6. Low Gain ### 6. Low Gain
- Base is 0.2-0.3 gain - Base is 0.2-0.3 gain
- Hints should be 0.05-0.08 gain (subtle color) - Hints should be 0.05-0.08 gain (subtle color)
@ -41,26 +47,29 @@ Use `stack()` to layer multiple patterns that play simultaneously:
```javascript ```javascript
stack( stack(
continentalBasePattern, // Background ambient continentalBasePattern, // Background ambient
hyperLocalHintPattern // Regional character on top hyperLocalHintPattern, // Regional character on top
) );
``` ```
## Synthesis Options ## Synthesis Options
### Built-in Waveforms ### Built-in Waveforms
- `sine` - Pure, soft tone (good for drones, pads) - `sine` - Pure, soft tone (good for drones, pads)
- `triangle` - Brighter than sine, still soft (good for melodies) - `triangle` - Brighter than sine, still soft (good for melodies)
- `sawtooth` - Rich harmonics (good for string-like tones, brass) - `sawtooth` - Rich harmonics (good for string-like tones, brass)
- `square` - Hollow, reedy (good for wind instruments) - `square` - Hollow, reedy (good for wind instruments)
### FM Synthesis ### FM Synthesis
- `fm(index)` - Modulation index, defines brightness (higher = more harmonics) - `fm(index)` - Modulation index, defines brightness (higher = more harmonics)
- `fmh(ratio)` - Harmonicity ratio - `fmh(ratio)` - Harmonicity ratio
- Whole numbers: natural, harmonic sounds - Whole numbers: natural, harmonic sounds
- Decimals: metallic, inharmonic sounds - Decimals: metallic, inharmonic sounds
### Expression ### Expression
- `vib(hz)` - Vibrato frequency in Hz - `vib(hz)` - Vibrato frequency in Hz
- `vibmod(semitones)` - Vibrato depth - `vibmod(semitones)` - Vibrato depth
@ -69,21 +78,25 @@ stack(
Filter effects apply in sequence: `lpf → hpf → bpf → vowel → coarse → crush → distort → tremolo → compressor → pan → phaser → postgain` Filter effects apply in sequence: `lpf → hpf → bpf → vowel → coarse → crush → distort → tremolo → compressor → pan → phaser → postgain`
### Filters ### Filters
- `lpf(freq)` - Low-pass filter (cutoff frequency) - `lpf(freq)` - Low-pass filter (cutoff frequency)
- `hpf(freq)` - High-pass filter - `hpf(freq)` - High-pass filter
- `bpf(freq)` - Band-pass filter - `bpf(freq)` - Band-pass filter
- `vowel("a/e/i/o/u")` - Formant filter for vocal-like character - `vowel("a/e/i/o/u")` - Formant filter for vocal-like character
### Modulation ### Modulation
- `sine.range(min, max).slow(n)` - LFO for filter sweeps - `sine.range(min, max).slow(n)` - LFO for filter sweeps
- `perlin.range(min, max)` - Perlin noise for organic variation - `perlin.range(min, max)` - Perlin noise for organic variation
### Spatial Effects ### Spatial Effects
- `delay(send)` - Delay send (0-1) - `delay(send)` - Delay send (0-1)
- `room(send)` - Reverb send (0-1) - `room(send)` - Reverb send (0-1)
- `pan(position)` - Stereo position (0=left, 1=right) - `pan(position)` - Stereo position (0=left, 1=right)
### Rhythmic Techniques ### Rhythmic Techniques
- `off(time, fn)` - Create rhythmic echoes/doubling - `off(time, fn)` - Create rhythmic echoes/doubling
- Example: `.off("1/8", x => x.gain(0.3))` - echo at 1/8 note - Example: `.off("1/8", x => x.gain(0.3))` - echo at 1/8 note
- `jux(fn)` - Apply function to right channel only (stereo width) - `jux(fn)` - Apply function to right channel only (stereo width)
@ -93,24 +106,24 @@ Filter effects apply in sequence: `lpf → hpf → bpf → vowel → coarse →
Use scale function for regional character: Use scale function for regional character:
```javascript ```javascript
n("0 2 4 5").scale("C:dorian") // Jazz/modal n("0 2 4 5").scale("C:dorian"); // Jazz/modal
n("0 1 4 5").scale("E:phrygian") // Spanish/Middle Eastern n("0 1 4 5").scale("E:phrygian"); // Spanish/Middle Eastern
n("0 2 4 7").scale("D:minPent") // Asian n("0 2 4 7").scale("D:minPent"); // Asian
n("0 2 4 5").scale("G:major") // Bright/Western n("0 2 4 5").scale("G:major"); // Bright/Western
n("0 3 4 7").scale("C:blues") // Blues/Jazz n("0 3 4 7").scale("C:blues"); // Blues/Jazz
``` ```
### Key Scales for Regional Character ### Key Scales for Regional Character
| Region | Scales/Modes | Character | | Region | Scales/Modes | Character |
|--------|--------------|-----------| | -------------- | --------------------- | ------------------- |
| Western Europe | Major, Mixolydian | Bright, optimistic | | Western Europe | Major, Mixolydian | Bright, optimistic |
| Eastern Europe | Minor, Dorian | Melancholic, modal | | Eastern Europe | Minor, Dorian | Melancholic, modal |
| Balkans | Phrygian, odd meters | Exotic, dramatic | | Balkans | Phrygian, odd meters | Exotic, dramatic |
| Nordic | Dorian, Minor | Haunting, modal | | Nordic | Dorian, Minor | Haunting, modal |
| Celtic | Dorian, Mixolydian | Dance-like, modal | | Celtic | Dorian, Mixolydian | Dance-like, modal |
| Mediterranean | Phrygian | Exotic, flamenco | | Mediterranean | Phrygian | Exotic, flamenco |
| Slavic | Minor, Harmonic minor | Dramatic, emotional | | Slavic | Minor, Harmonic minor | Dramatic, emotional |
## Pattern Design Principles ## Pattern Design Principles
@ -146,18 +159,18 @@ n("0 3 4 7").scale("C:blues") // Blues/Jazz
## Chords in Mini-Notation ## Chords in Mini-Notation
```javascript ```javascript
note("[c3,e3,g3]") // C major chord note("[c3,e3,g3]"); // C major chord
note("[g3,b3,e4]") // G major, first inversion note("[g3,b3,e4]"); // G major, first inversion
note("<[c3,e3,g3] [f3,a3,c4]>") // Alternating chords note("<[c3,e3,g3] [f3,a3,c4]>"); // Alternating chords
``` ```
## Rhythmic Patterns ## Rhythmic Patterns
```javascript ```javascript
"0 4 7 4" // Straight quarter notes "0 4 7 4"; // Straight quarter notes
"0 ~ 4 5" // Rest on beat 2 "0 ~ 4 5"; // Rest on beat 2
"[0,4,7] ~ [2,5] ~" // Chords with rests (oompah) "[0,4,7] ~ [2,5] ~"; // Chords with rests (oompah)
"0 ~ [2,5] 7" // Syncopated "0 ~ [2,5] 7"; // Syncopated
``` ```
## Speed Control ## Speed Control
@ -181,6 +194,7 @@ note("<[c3,e3,g3] [f3,a3,c4]>") // Alternating chords
## Target European Countries to Add ## Target European Countries to Add
Major countries needing hints: Major countries needing hints:
- gb (UK) - British folk/rock - gb (UK) - British folk/rock
- pt (Portugal) - Fado, melancholic - pt (Portugal) - Fado, melancholic
- nl (Netherlands) - Organ/folk - nl (Netherlands) - Organ/folk
@ -213,35 +227,42 @@ Major countries needing hints:
## Musical Character by Sub-Region ## Musical Character by Sub-Region
### Western Europe ### Western Europe
- **UK/Ireland**: Celtic modes, jig rhythms, bright - **UK/Ireland**: Celtic modes, jig rhythms, bright
- **France**: Accordion, musette, chanson - **France**: Accordion, musette, chanson
- **Benelux**: Organ, carillon, cheerful - **Benelux**: Organ, carillon, cheerful
### Central Europe ### Central Europe
- **Germany/Austria**: Oompah, waltz, brass - **Germany/Austria**: Oompah, waltz, brass
- **Switzerland**: Alpine, yodel character - **Switzerland**: Alpine, yodel character
- **Poland**: Mazurka, dramatic minor - **Poland**: Mazurka, dramatic minor
### Nordic ### Nordic
- **Sweden/Norway/Denmark**: Haunting, modal, sparse - **Sweden/Norway/Denmark**: Haunting, modal, sparse
- **Finland**: Kalevala modes, melancholic - **Finland**: Kalevala modes, melancholic
- **Iceland**: Epic, atmospheric - **Iceland**: Epic, atmospheric
### Eastern Europe ### Eastern Europe
- **Ukraine/Belarus/Russia**: Dramatic minor, balalaika - **Ukraine/Belarus/Russia**: Dramatic minor, balalaika
- **Baltic states**: Kannel, choir-like - **Baltic states**: Kannel, choir-like
### Southern Europe ### Southern Europe
- **Portugal**: Fado, saudade, minor - **Portugal**: Fado, saudade, minor
- **Spain**: Flamenco, phrygian - **Spain**: Flamenco, phrygian
- **Italy**: Tarantella, mandolin - **Italy**: Tarantella, mandolin
### Balkans ### Balkans
- **Greece**: Sirtaki, phrygian - **Greece**: Sirtaki, phrygian
- **Bulgaria**: Odd meters (7/8, 11/8) - **Bulgaria**: Odd meters (7/8, 11/8)
- **Serbia/Croatia/Bosnia**: Turbo folk, emotional - **Serbia/Croatia/Bosnia**: Turbo folk, emotional
- **Romania**: Hora, violin-like - **Romania**: Hora, violin-like
### Mediterranean Islands ### Mediterranean Islands
- **Cyprus**: Greek/Turkish blend - **Cyprus**: Greek/Turkish blend
- **Malta**: Unique Mediterranean - **Malta**: Unique Mediterranean

View File

@ -1,12 +1,40 @@
/* CSS styling for DecompositionDisplay component */ /* CSS styling for DecompositionDisplay component */
.decomposition { .decomposition {
display: inline; display: block;
position: relative;
width: 100%;
font-family: "JetBrains Mono", "Fira Code", "Monaco", "Consolas", monospace; font-family: "JetBrains Mono", "Fira Code", "Monaco", "Consolas", monospace;
font-size: inherit; font-size: inherit;
line-height: inherit; line-height: inherit;
} }
/* Hidden element for measuring single-line width */
.decomposition-measure {
position: absolute;
visibility: hidden;
height: 0;
overflow: hidden;
white-space: nowrap;
pointer-events: none;
}
.decomposition-content {
display: inline;
white-space: nowrap;
}
/* Multi-line mode when content overflows */
.decomposition--multiline .decomposition-content {
display: block;
white-space: normal;
}
.decomposition-line {
display: block;
white-space: nowrap;
}
.term { .term {
display: inline-block; display: inline-block;
padding: 2px 4px; padding: 2px 4px;

View File

@ -311,6 +311,8 @@ export interface AbacusConfig {
// Legacy callbacks for backward compatibility // Legacy callbacks for backward compatibility
onClick?: (bead: BeadConfig) => void; onClick?: (bead: BeadConfig) => void;
onValueChange?: (newValue: number | bigint) => void; onValueChange?: (newValue: number | bigint) => void;
/** Called after value changes AND animations complete (useful for chaining actions) */
onValueChangeComplete?: (newValue: number | bigint) => void;
} }
export interface AbacusDimensions { export interface AbacusDimensions {
@ -1641,6 +1643,7 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
// Legacy callbacks // Legacy callbacks
onClick, onClick,
onValueChange, onValueChange,
onValueChangeComplete,
}) => { }) => {
// Try to use context config, fallback to defaults if no context // Try to use context config, fallback to defaults if no context
let contextConfig; let contextConfig;
@ -1759,7 +1762,19 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
// This is a user-initiated change, notify parent // This is a user-initiated change, notify parent
onValueChange?.(currentValue); onValueChange?.(currentValue);
}, [currentValue, onValueChange]);
// Schedule onValueChangeComplete after animations settle
// React Spring with default config (tension: 200, friction: 10) settles in ~400ms
if (onValueChangeComplete && finalConfig.animated) {
const timeoutId = setTimeout(() => {
onValueChangeComplete(currentValue);
}, 400);
return () => clearTimeout(timeoutId);
} else if (onValueChangeComplete) {
// No animation, call immediately
onValueChangeComplete(currentValue);
}
}, [currentValue, onValueChange, onValueChangeComplete, finalConfig.animated]);
// Track controlled value changes // Track controlled value changes
React.useEffect(() => { React.useEffect(() => {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff