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:
parent
b56c8f439b
commit
e937c05323
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
21532
pnpm-lock.yaml
21532
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue