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": {
"enabled": true,
"indentStyle": "space",

View File

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

View File

@ -34,21 +34,27 @@ The transition is instant - no pause, no celebration, no feedback.
## Celebration Types
### 1. Lightning Find ⚡ (< 3 seconds)
Kid knew exactly where to look - reward the speed!
- **Flash**: Quick, bright gold pulse (400ms)
- **Confetti**: Fast, sparkly burst (small particles, quick fade)
- **Sound**: Quick "ding!" or sparkle
- **Duration**: ~800ms total
### 2. Standard Find ✨ (3-15 seconds, direct path)
Normal discovery - celebrate appropriately
- **Flash**: Smooth gold pulse (600ms)
- **Confetti**: Medium burst with gravity fall
- **Sound**: Pleasant chime
- **Duration**: ~1.2 seconds total
### 3. Hard-Earned Find 💪 (searched extensively)
Kid really worked for it - acknowledge the effort!
- **Flash**: Warm, satisfying glow (800ms)
- **Confetti**: Big celebration! More particles, longer duration
- **Sound**: Triumphant fanfare/chord
@ -62,6 +68,7 @@ Kid really worked for it - acknowledge the effort!
### Data Available from Hot/Cold System
The `useHotColdFeedback` hook already tracks:
```typescript
interface PathEntry {
x: number
@ -81,46 +88,48 @@ minDistanceSinceLastFeedback // Got close then moved away?
```typescript
interface SearchMetrics {
// Time
timeToFind: number // ms from prompt start to correct click
timeToFind: number; // ms from prompt start to correct click
// Distance traveled
totalCursorDistance: number // Total pixels cursor moved (from history)
straightLineDistance: number // Direct path would have been
searchEfficiency: number // straight / total (1.0 = perfect, <0.3 = searched hard)
totalCursorDistance: number; // Total pixels cursor moved (from history)
straightLineDistance: number; // Direct path would have been
searchEfficiency: number; // straight / total (1.0 = perfect, <0.3 = searched hard)
// Direction changes
directionReversals: number // How many times changed direction toward/away
directionReversals: number; // How many times changed direction toward/away
// Near misses
nearMissCount: number // Times got within CLOSE threshold then moved away
overshotCount: number // Times passed the target
nearMissCount: number; // Times got within CLOSE threshold then moved away
overshotCount: number; // Times passed the target
// Zone transitions
zoneTransitions: number // warming→cooling→warming transitions
zoneTransitions: number; // warming→cooling→warming transitions
}
```
### Classification Logic
```typescript
function classifyCelebration(metrics: SearchMetrics): 'lightning' | 'standard' | 'hard-earned' {
function classifyCelebration(
metrics: SearchMetrics,
): "lightning" | "standard" | "hard-earned" {
// Lightning: Fast and direct
if (metrics.timeToFind < 3000 && metrics.searchEfficiency > 0.7) {
return 'lightning'
return "lightning";
}
// Hard-earned: Any of these indicate real effort
if (
metrics.timeToFind > 20000 || // Took a while
metrics.searchEfficiency < 0.3 || // Wandered a lot
metrics.directionReversals > 10 || // Lots of back-and-forth
metrics.nearMissCount > 2 || // Got close multiple times
metrics.overshotCount > 1 // Passed it more than once
metrics.timeToFind > 20000 || // Took a while
metrics.searchEfficiency < 0.3 || // Wandered a lot
metrics.directionReversals > 10 || // Lots of back-and-forth
metrics.nearMissCount > 2 || // Got close multiple times
metrics.overshotCount > 1 // Passed it more than once
) {
return 'hard-earned'
return "hard-earned";
}
return 'standard'
return "standard";
}
```
@ -144,6 +153,7 @@ User clicks region → onRegionClick(id, name)
### Key Change: Delay State Update
Instead of immediately calling `clickRegion` and advancing, we:
1. Detect correct click locally in MapRenderer
2. Start celebration animation
3. Block input during celebration
@ -160,13 +170,13 @@ This ensures the map doesn't clutter with the next prompt while celebrating.
```typescript
// Add to Provider.tsx context
interface CelebrationState {
regionId: string
regionName: string
type: 'lightning' | 'standard' | 'hard-earned'
startTime: number
regionId: string;
regionName: string;
type: "lightning" | "standard" | "hard-earned";
startTime: number;
}
const [celebration, setCelebration] = useState<CelebrationState | null>(null)
const [celebration, setCelebration] = useState<CelebrationState | null>(null);
```
### Expose Search Metrics from Hot/Cold Hook
@ -221,23 +231,26 @@ export function useHotColdFeedback(...) {
```typescript
// useCelebrationSound.ts
const audioContext = new (window.AudioContext || window.webkitAudioContext)()
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
function playLightningSound() {
// Quick sparkle: high frequency, fast decay
const osc = audioContext.createOscillator()
const gain = audioContext.createGain()
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.type = 'sine'
osc.frequency.setValueAtTime(1200, audioContext.currentTime)
osc.frequency.exponentialRampToValueAtTime(2400, audioContext.currentTime + 0.1)
osc.type = "sine";
osc.frequency.setValueAtTime(1200, audioContext.currentTime);
osc.frequency.exponentialRampToValueAtTime(
2400,
audioContext.currentTime + 0.1,
);
gain.gain.setValueAtTime(0.3, audioContext.currentTime)
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2)
gain.gain.setValueAtTime(0.3, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
osc.connect(gain).connect(audioContext.destination)
osc.start()
osc.stop(audioContext.currentTime + 0.2)
osc.connect(gain).connect(audioContext.destination);
osc.start();
osc.stop(audioContext.currentTime + 0.2);
}
function playStandardSound() {
@ -287,19 +300,19 @@ style={{
```typescript
// components/Confetti.tsx
interface ConfettiProps {
type: 'lightning' | 'standard' | 'hard-earned'
origin: { x: number; y: number } // Screen coordinates
onComplete: () => void
type: "lightning" | "standard" | "hard-earned";
origin: { x: number; y: number }; // Screen coordinates
onComplete: () => void;
}
const CONFETTI_CONFIG = {
lightning: { count: 12, duration: 600, spread: 60 },
standard: { count: 20, duration: 1000, spread: 90 },
'hard-earned': { count: 35, duration: 1500, spread: 120 },
}
"hard-earned": { count: 35, duration: 1500, spread: 120 },
};
function Confetti({ type, origin, onComplete }: ConfettiProps) {
const config = CONFETTI_CONFIG[type]
const config = CONFETTI_CONFIG[type];
// Generate particles with random directions, colors, sizes
// Use CSS animations for performance
@ -347,12 +360,14 @@ function CelebrationOverlay({ celebration, regionCenter, onComplete }: Celebrati
## Files to Create/Modify
### New Files
1. **`hooks/useCelebrationSound.ts`** - Web Audio API sound effects
2. **`hooks/useSearchMetrics.ts`** - Extract metrics from hot/cold history
3. **`components/Confetti.tsx`** - CSS confetti particles
4. **`components/CelebrationOverlay.tsx`** - Orchestrates celebration
### Modified Files
1. **`Provider.tsx`** - Add celebration state to context
2. **`hooks/useHotColdFeedback.ts`** - Expose `getSearchMetrics()` method
3. **`MapRenderer.tsx`** - Intercept correct clicks, trigger celebration, delay advancement
@ -362,31 +377,37 @@ function CelebrationOverlay({ celebration, regionCenter, onComplete }: Celebrati
## Implementation Order
### Phase 1: Infrastructure
1. Add `celebration` state to Provider context
2. Add `promptStartTime` tracking (when each region prompt begins)
3. Modify `useHotColdFeedback` to expose `getSearchMetrics()`
### Phase 2: Classification
4. Create `useSearchMetrics` hook to calculate metrics
5. Implement `classifyCelebration()` function
6. Test metric calculation with various search patterns
### Phase 3: Visuals
7. Create `Confetti` component with CSS animations
8. Add gold flash effect to MapRenderer (react-spring)
9. Create `CelebrationOverlay` to orchestrate
### Phase 4: Audio
10. Create `useCelebrationSound` hook with Web Audio API
11. Implement three sound types (lightning, standard, hard-earned)
12. Wire up sounds to celebration types
### Phase 5: Integration
13. Intercept correct clicks in MapRenderer
14. Block advancement during celebration
15. Call `clickRegion` only after celebration completes
### Phase 6: Polish
16. Add `prefers-reduced-motion` support
17. Test on mobile (performance, touch)
18. Fine-tune timing and particle counts
@ -417,8 +438,8 @@ function CelebrationOverlay({ celebration, regionCenter, onComplete }: Celebrati
## Timing Summary
| Type | Flash | Confetti | Sound | Total Block |
|------|-------|----------|-------|-------------|
| Lightning ⚡ | 400ms | 600ms | 200ms | 600ms |
| Standard ✨ | 600ms | 1000ms | 400ms | 1000ms |
| Hard-earned 💪 | 800ms | 1500ms | 600ms | 1500ms |
| Type | Flash | Confetti | Sound | Total Block |
| -------------- | ----- | -------- | ----- | ----------- |
| Lightning ⚡ | 400ms | 600ms | 200ms | 600ms |
| Standard ✨ | 600ms | 1000ms | 400ms | 1000ms |
| 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
2. **The magnifier viewBox still uses map-based aspect ratio**:
```typescript
// Current - MapRenderer.tsx lines 3017-3018
const magnifiedWidth = viewBoxWidth / zoom // e.g., 1000/10 = 100
const magnifiedHeight = viewBoxHeight / zoom // e.g., 500/10 = 50 (2:1 ratio)
const magnifiedWidth = viewBoxWidth / zoom; // e.g., 1000/10 = 100
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).
3. **The outline uses the same calculation**:
```typescript
// Current - MapRenderer.tsx lines 2578-2586
width = viewBoxWidth / zoom
height = viewBoxHeight / zoom
width = viewBoxWidth / 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.
@ -59,6 +62,7 @@ Screen in landscape mode:
**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:
1. Calculate magnifier container aspect ratio
2. Adjust the magnified viewBox dimensions to match
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
Given:
- Magnifier container: `magnifierWidth × magnifierHeight` (from `getMagnifierDimensions`)
- Base magnified region: `viewBoxWidth/zoom × viewBoxHeight/zoom`
- Container aspect ratio: `CA = magnifierWidth / magnifierHeight`
- ViewBox aspect ratio: `VA = viewBoxWidth / viewBoxHeight`
Calculate the adjusted viewBox that fills the container:
```typescript
// Start with zoom-based dimensions
const baseWidth = viewBoxWidth / zoom
const baseHeight = viewBoxHeight / zoom
const baseWidth = viewBoxWidth / zoom;
const baseHeight = viewBoxHeight / zoom;
// Container aspect ratio
const containerAspect = magnifierWidth / magnifierHeight
const viewBoxAspect = baseWidth / baseHeight
const containerAspect = magnifierWidth / magnifierHeight;
const viewBoxAspect = baseWidth / baseHeight;
let adjustedWidth, adjustedHeight
let adjustedWidth, adjustedHeight;
if (containerAspect > viewBoxAspect) {
// Container is wider than viewBox - expand width to match
adjustedHeight = baseHeight
adjustedWidth = baseHeight * containerAspect
adjustedHeight = baseHeight;
adjustedWidth = baseHeight * containerAspect;
} else {
// Container is taller than viewBox - expand height to match
adjustedWidth = baseWidth
adjustedHeight = baseWidth / containerAspect
adjustedWidth = baseWidth;
adjustedHeight = baseWidth / containerAspect;
}
```
This gives us a viewBox that:
- Has the same aspect ratio as the magnifier container
- Is centered on the same point
- Shows a slightly larger region (no letterboxing)
@ -106,15 +113,22 @@ This gives us a viewBox that:
Move the constants and function from MapRenderer:
```typescript
export const MAGNIFIER_SIZE_SMALL = 1 / 3
export const MAGNIFIER_SIZE_LARGE = 1 / 2
export const MAGNIFIER_SIZE_SMALL = 1 / 3;
export const MAGNIFIER_SIZE_LARGE = 1 / 2;
export function getMagnifierDimensions(containerWidth: number, containerHeight: number) {
const isLandscape = containerWidth > containerHeight
export function getMagnifierDimensions(
containerWidth: number,
containerHeight: number,
) {
const isLandscape = containerWidth > containerHeight;
return {
width: containerWidth * (isLandscape ? MAGNIFIER_SIZE_SMALL : MAGNIFIER_SIZE_LARGE),
height: containerHeight * (isLandscape ? MAGNIFIER_SIZE_LARGE : MAGNIFIER_SIZE_SMALL),
}
width:
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,
zoom: 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 baseHeight = viewBoxHeight / zoom
const baseWidth = viewBoxWidth / zoom;
const baseHeight = viewBoxHeight / zoom;
const containerAspect = magWidth / magHeight
const viewBoxAspect = baseWidth / baseHeight
const containerAspect = magWidth / magHeight;
const viewBoxAspect = baseWidth / baseHeight;
if (containerAspect > viewBoxAspect) {
// Container is wider - expand width
return {
width: baseHeight * containerAspect,
height: baseHeight,
}
};
} else {
// Container is taller - expand height
return {
width: baseWidth,
height: baseWidth / containerAspect,
}
};
}
}
```
@ -155,32 +172,36 @@ export function getAdjustedMagnifiedDimensions(
### 2. Update `MapRenderer.tsx`
#### Import from new utility:
```typescript
import {
getMagnifierDimensions,
getAdjustedMagnifiedDimensions,
MAGNIFIER_SIZE_SMALL,
MAGNIFIER_SIZE_LARGE,
} from '../utils/magnifierDimensions'
} from "../utils/magnifierDimensions";
```
#### Update magnifier viewBox (lines 3017-3024):
```typescript
// OLD:
const magnifiedWidth = viewBoxWidth / zoom
const magnifiedHeight = viewBoxHeight / zoom
const magnifiedWidth = viewBoxWidth / zoom;
const magnifiedHeight = viewBoxHeight / zoom;
// NEW:
const { width: magnifiedWidth, height: magnifiedHeight } = getAdjustedMagnifiedDimensions(
viewBoxWidth,
viewBoxHeight,
zoom,
containerRect.width,
containerRect.height
)
const { width: magnifiedWidth, height: magnifiedHeight } =
getAdjustedMagnifiedDimensions(
viewBoxWidth,
viewBoxHeight,
zoom,
containerRect.width,
containerRect.height,
);
```
#### Update outline dimensions (lines 2578-2586):
```typescript
// Same calculation as magnifier viewBox
width={zoomSpring.to((zoom: number) => {
@ -211,27 +232,28 @@ height={zoomSpring.to((zoom: number) => {
Replace hardcoded `0.5` with actual dimensions:
```typescript
import { getMagnifierDimensions } from '../utils/magnifierDimensions'
import { getMagnifierDimensions } from "../utils/magnifierDimensions";
// Line 94, 138, 163 - replace:
// const magnifierWidth = containerRect.width * 0.5
// with:
const { width: magnifierWidth } = getMagnifierDimensions(
containerRect.width,
containerRect.height
)
containerRect.height,
);
```
## Summary
| Component | Current Behavior | After Fix |
|-----------|-----------------|-----------|
| 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 |
| Outline | Fixed 2:1 ratio | Matches container aspect ratio |
| Zoom calculation | Wrong (uses 0.5) | Correct (uses actual dimensions) |
| Component | Current Behavior | After Fix |
| ------------------- | ------------------------------- | -------------------------------- |
| 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 |
| Outline | Fixed 2:1 ratio | Matches container aspect ratio |
| Zoom calculation | Wrong (uses 0.5) | Correct (uses actual dimensions) |
### Implementation Order
1. Create `utils/magnifierDimensions.ts` with shared functions
2. Update `MapRenderer.tsx` imports and remove local copies of constants/function
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.
**Problem:**
- Functions can't be easily imported for testing
- Duplicated logic if needed elsewhere
- Component file is larger than necessary
@ -15,20 +16,20 @@ This document outlines potential refactoring opportunities for the DrillDownMapS
```typescript
// regionSizeUtils.ts
import type { RegionSize } from '../maps'
import { ALL_REGION_SIZES } from '../maps'
import type { RegionSize } from "../maps";
import { ALL_REGION_SIZES } from "../maps";
export function sizesToRange(sizes: RegionSize[]): [RegionSize, RegionSize] {
const sorted = [...sizes].sort(
(a, b) => ALL_REGION_SIZES.indexOf(a) - ALL_REGION_SIZES.indexOf(b)
)
return [sorted[0], sorted[sorted.length - 1]]
(a, b) => ALL_REGION_SIZES.indexOf(a) - ALL_REGION_SIZES.indexOf(b),
);
return [sorted[0], sorted[sorted.length - 1]];
}
export function rangeToSizes(min: RegionSize, max: RegionSize): RegionSize[] {
const minIdx = ALL_REGION_SIZES.indexOf(min)
const maxIdx = ALL_REGION_SIZES.indexOf(max)
return ALL_REGION_SIZES.slice(minIdx, maxIdx + 1)
const minIdx = ALL_REGION_SIZES.indexOf(min);
const maxIdx = ALL_REGION_SIZES.indexOf(max);
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.
**Problem:**
- Complex memoization dependencies
- Hard to test filtering logic in isolation
- Repeated patterns across different calculations
@ -50,22 +52,24 @@ export function rangeToSizes(min: RegionSize, max: RegionSize): RegionSize[] {
```typescript
// hooks/useRegionFiltering.ts
interface UseRegionFilteringProps {
mapId: 'world' | 'usa'
continentId: ContinentId | 'all'
includeSizes: RegionSize[]
previewSizes?: RegionSize[] | null
mapId: "world" | "usa";
continentId: ContinentId | "all";
includeSizes: RegionSize[];
previewSizes?: RegionSize[] | null;
}
interface UseRegionFilteringResult {
includedRegions: string[]
excludedRegions: string[]
previewAddRegions: string[]
previewRemoveRegions: string[]
regionNamesBySize: Record<RegionSize, string[]>
selectedRegionNames: string[]
includedRegions: string[];
excludedRegions: string[];
previewAddRegions: string[];
previewRemoveRegions: string[];
regionNamesBySize: Record<RegionSize, string[]>;
selectedRegionNames: string[];
}
export function useRegionFiltering(props: UseRegionFilteringProps): UseRegionFilteringResult {
export function useRegionFiltering(
props: UseRegionFilteringProps,
): UseRegionFilteringResult {
// 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.
**Problem:**
- Prop drilling complexity
- Unclear which props work together
- 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.
**Problem:**
- DrillDownMapSelector is 1300+ lines
- Region list styling is tightly coupled
- 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.
**Problem:**
- Breakpoint values scattered across components
- Inconsistent breakpoint choices
- Hard to change responsive behavior globally
@ -177,23 +184,23 @@ export function RegionListPanel({ regions, onRegionHover, maxHeight = '200px', i
```typescript
// utils/responsive.ts
export const BREAKPOINTS = {
mobile: 'base',
tablet: 'sm',
desktop: 'md',
wide: 'lg',
} as const
mobile: "base",
tablet: "sm",
desktop: "md",
wide: "lg",
} as const;
export function responsiveDisplay(showOnDesktop: boolean) {
return showOnDesktop
? { base: 'none', md: 'flex' }
: { base: 'flex', md: 'none' }
? { base: "none", md: "flex" }
: { base: "flex", md: "none" };
}
export function responsiveScale(mobileScale: number, desktopScale: number) {
return {
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]`
**Problem:**
- Hard to exhaustively check path levels
- Type narrowing requires manual length checks
- Semantics not self-documenting
@ -214,19 +222,19 @@ export function responsiveScale(mobileScale: number, desktopScale: number) {
```typescript
type SelectionPath =
| { level: 'world' }
| { level: 'continent'; continentId: ContinentId }
| { level: 'submap'; continentId: ContinentId; submapId: string }
| { level: "world" }
| { level: "continent"; continentId: ContinentId }
| { level: "submap"; continentId: ContinentId; submapId: string };
// Usage becomes more explicit
function getMapData(path: SelectionPath) {
switch (path.level) {
case 'world':
return WORLD_MAP
case 'continent':
return filterByContinent(path.continentId)
case 'submap':
return getSubMap(path.submapId)
case "world":
return WORLD_MAP;
case "continent":
return filterByContinent(path.continentId);
case "submap":
return getSubMap(path.submapId);
}
}
```
@ -240,6 +248,7 @@ function getMapData(path: SelectionPath) {
**Current State:** Multiple `useMemo` hooks recalculate on every render cycle.
**Problem:**
- `getFilteredMapDataBySizesSync` called multiple times with same params
- No caching between component unmount/remount
- Complex dependency arrays
@ -248,22 +257,30 @@ function getMapData(path: SelectionPath) {
```typescript
// 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[]) {
const key = `${mapId}-${continentId}-${sizes.join(',')}`
function getCachedFilteredMapData(
mapId: string,
continentId: string,
sizes: RegionSize[],
) {
const key = `${mapId}-${continentId}-${sizes.join(",")}`;
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
const { data: filteredRegions } = useQuery({
queryKey: ['filtered-regions', mapId, continentId, includeSizes],
queryFn: () => getFilteredMapDataBySizesSync(mapId, continentId, includeSizes),
queryKey: ["filtered-regions", mapId, continentId, includeSizes],
queryFn: () =>
getFilteredMapDataBySizesSync(mapId, continentId, includeSizes),
staleTime: Infinity,
})
});
```
**Impact:** Medium complexity, potential performance improvement.
@ -290,6 +307,7 @@ const { data: filteredRegions } = useQuery({
## Testing Considerations
After any refactoring:
- Run existing tests: `npm run test:run -- src/components/Thermometer src/arcade-games/know-your-world`
- Add integration tests for extracted components
- 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:
### 1. Same Key/Scale
- **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")`
- **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")`
### 2. Sparse Patterns (Don't Compete!)
**Bad:** `n("0 4 7 4")` - 4 notes per cycle, competing with base
**Good:** `n("~ 4 ~ ~")` - 1 note per cycle, adds color only
### 3. Different Register
- Base melody is octave 2-3
- Hints should be octave 4-5 (sit above, don't clash)
### 4. Soft Timbres
- Base uses sawtooth (rich harmonics)
- Hints should use sine/soft triangle (pure, non-competing)
### 5. Slow Movement
- Base moves fast with `.fast(1.2)`
- Hints should use `.slow(8)` or slower - evolving atmosphere
### 6. Low Gain
- Base is 0.2-0.3 gain
- Hints should be 0.05-0.08 gain (subtle color)
@ -41,26 +47,29 @@ Use `stack()` to layer multiple patterns that play simultaneously:
```javascript
stack(
continentalBasePattern, // Background ambient
hyperLocalHintPattern // Regional character on top
)
continentalBasePattern, // Background ambient
hyperLocalHintPattern, // Regional character on top
);
```
## Synthesis Options
### Built-in Waveforms
- `sine` - Pure, soft tone (good for drones, pads)
- `triangle` - Brighter than sine, still soft (good for melodies)
- `sawtooth` - Rich harmonics (good for string-like tones, brass)
- `square` - Hollow, reedy (good for wind instruments)
### FM Synthesis
- `fm(index)` - Modulation index, defines brightness (higher = more harmonics)
- `fmh(ratio)` - Harmonicity ratio
- Whole numbers: natural, harmonic sounds
- Decimals: metallic, inharmonic sounds
### Expression
- `vib(hz)` - Vibrato frequency in Hz
- `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`
### Filters
- `lpf(freq)` - Low-pass filter (cutoff frequency)
- `hpf(freq)` - High-pass filter
- `bpf(freq)` - Band-pass filter
- `vowel("a/e/i/o/u")` - Formant filter for vocal-like character
### Modulation
- `sine.range(min, max).slow(n)` - LFO for filter sweeps
- `perlin.range(min, max)` - Perlin noise for organic variation
### Spatial Effects
- `delay(send)` - Delay send (0-1)
- `room(send)` - Reverb send (0-1)
- `pan(position)` - Stereo position (0=left, 1=right)
### Rhythmic Techniques
- `off(time, fn)` - Create rhythmic echoes/doubling
- Example: `.off("1/8", x => x.gain(0.3))` - echo at 1/8 note
- `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:
```javascript
n("0 2 4 5").scale("C:dorian") // Jazz/modal
n("0 1 4 5").scale("E:phrygian") // Spanish/Middle Eastern
n("0 2 4 7").scale("D:minPent") // Asian
n("0 2 4 5").scale("G:major") // Bright/Western
n("0 3 4 7").scale("C:blues") // Blues/Jazz
n("0 2 4 5").scale("C:dorian"); // Jazz/modal
n("0 1 4 5").scale("E:phrygian"); // Spanish/Middle Eastern
n("0 2 4 7").scale("D:minPent"); // Asian
n("0 2 4 5").scale("G:major"); // Bright/Western
n("0 3 4 7").scale("C:blues"); // Blues/Jazz
```
### Key Scales for Regional Character
| Region | Scales/Modes | Character |
|--------|--------------|-----------|
| Western Europe | Major, Mixolydian | Bright, optimistic |
| Eastern Europe | Minor, Dorian | Melancholic, modal |
| Balkans | Phrygian, odd meters | Exotic, dramatic |
| Nordic | Dorian, Minor | Haunting, modal |
| Celtic | Dorian, Mixolydian | Dance-like, modal |
| Mediterranean | Phrygian | Exotic, flamenco |
| Slavic | Minor, Harmonic minor | Dramatic, emotional |
| Region | Scales/Modes | Character |
| -------------- | --------------------- | ------------------- |
| Western Europe | Major, Mixolydian | Bright, optimistic |
| Eastern Europe | Minor, Dorian | Melancholic, modal |
| Balkans | Phrygian, odd meters | Exotic, dramatic |
| Nordic | Dorian, Minor | Haunting, modal |
| Celtic | Dorian, Mixolydian | Dance-like, modal |
| Mediterranean | Phrygian | Exotic, flamenco |
| Slavic | Minor, Harmonic minor | Dramatic, emotional |
## Pattern Design Principles
@ -146,18 +159,18 @@ n("0 3 4 7").scale("C:blues") // Blues/Jazz
## Chords in Mini-Notation
```javascript
note("[c3,e3,g3]") // C major chord
note("[g3,b3,e4]") // G major, first inversion
note("<[c3,e3,g3] [f3,a3,c4]>") // Alternating chords
note("[c3,e3,g3]"); // C major chord
note("[g3,b3,e4]"); // G major, first inversion
note("<[c3,e3,g3] [f3,a3,c4]>"); // Alternating chords
```
## Rhythmic Patterns
```javascript
"0 4 7 4" // Straight quarter notes
"0 ~ 4 5" // Rest on beat 2
"[0,4,7] ~ [2,5] ~" // Chords with rests (oompah)
"0 ~ [2,5] 7" // Syncopated
"0 4 7 4"; // Straight quarter notes
"0 ~ 4 5"; // Rest on beat 2
"[0,4,7] ~ [2,5] ~"; // Chords with rests (oompah)
"0 ~ [2,5] 7"; // Syncopated
```
## Speed Control
@ -181,6 +194,7 @@ note("<[c3,e3,g3] [f3,a3,c4]>") // Alternating chords
## Target European Countries to Add
Major countries needing hints:
- gb (UK) - British folk/rock
- pt (Portugal) - Fado, melancholic
- nl (Netherlands) - Organ/folk
@ -213,35 +227,42 @@ Major countries needing hints:
## Musical Character by Sub-Region
### Western Europe
- **UK/Ireland**: Celtic modes, jig rhythms, bright
- **France**: Accordion, musette, chanson
- **Benelux**: Organ, carillon, cheerful
### Central Europe
- **Germany/Austria**: Oompah, waltz, brass
- **Switzerland**: Alpine, yodel character
- **Poland**: Mazurka, dramatic minor
### Nordic
- **Sweden/Norway/Denmark**: Haunting, modal, sparse
- **Finland**: Kalevala modes, melancholic
- **Iceland**: Epic, atmospheric
### Eastern Europe
- **Ukraine/Belarus/Russia**: Dramatic minor, balalaika
- **Baltic states**: Kannel, choir-like
### Southern Europe
- **Portugal**: Fado, saudade, minor
- **Spain**: Flamenco, phrygian
- **Italy**: Tarantella, mandolin
### Balkans
- **Greece**: Sirtaki, phrygian
- **Bulgaria**: Odd meters (7/8, 11/8)
- **Serbia/Croatia/Bosnia**: Turbo folk, emotional
- **Romania**: Hora, violin-like
### Mediterranean Islands
- **Cyprus**: Greek/Turkish blend
- **Malta**: Unique Mediterranean

View File

@ -1,12 +1,40 @@
/* CSS styling for DecompositionDisplay component */
.decomposition {
display: inline;
display: block;
position: relative;
width: 100%;
font-family: "JetBrains Mono", "Fira Code", "Monaco", "Consolas", monospace;
font-size: 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 {
display: inline-block;
padding: 2px 4px;

View File

@ -311,6 +311,8 @@ export interface AbacusConfig {
// Legacy callbacks for backward compatibility
onClick?: (bead: BeadConfig) => 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 {
@ -1641,6 +1643,7 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
// Legacy callbacks
onClick,
onValueChange,
onValueChangeComplete,
}) => {
// Try to use context config, fallback to defaults if no context
let contextConfig;
@ -1759,7 +1762,19 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
// This is a user-initiated change, notify parent
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
React.useEffect(() => {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff