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": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -78,7 +87,7 @@ 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 |
|
||||
|
|
@ -89,7 +98,9 @@ Strudel's generative nature helps here:
|
|||
| **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
|
||||
|
|
@ -110,7 +122,7 @@ 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 |
|
||||
|
|
@ -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,
|
||||
|
|
@ -166,7 +178,7 @@ 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 |
|
||||
|
|
@ -188,8 +200,9 @@ 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 |
|
||||
|
|
@ -197,6 +210,7 @@ If music disabled → play nothing
|
|||
| **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,45 +338,51 @@ 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
|
||||
.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
|
||||
.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 |
|
||||
|
|
|
|||
|
|
@ -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,32 +88,34 @@ 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
|
||||
|
|
@ -117,10 +126,10 @@ function classifyCelebration(metrics: SearchMetrics): 'lightning' | 'standard' |
|
|||
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
|
||||
|
|
@ -418,7 +439,7 @@ 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 |
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
const { width: magnifiedWidth, height: magnifiedHeight } =
|
||||
getAdjustedMagnifiedDimensions(
|
||||
viewBoxWidth,
|
||||
viewBoxHeight,
|
||||
zoom,
|
||||
containerRect.width,
|
||||
containerRect.height
|
||||
)
|
||||
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) |
|
||||
|
||||
### 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
@ -42,25 +48,28 @@ Use `stack()` to layer multiple patterns that play simultaneously:
|
|||
```javascript
|
||||
stack(
|
||||
continentalBasePattern, // Background ambient
|
||||
hyperLocalHintPattern // Regional character on top
|
||||
)
|
||||
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,17 +106,17 @@ 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 |
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
21310
pnpm-lock.yaml
21310
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue