feat(know-your-world): add SVG path geometry helpers for future use
Add helper functions for SVG path analysis: - parsePathToSubpaths: Parse SVG path into separate subpaths - calculatePolygonArea: Shoelace formula for polygon area - calculatePolygonCentroid: Shoelace formula for centroid - getLargestSubpathCentroid: Find center of largest subpath These will be useful for region centering improvements later. Also adds celebration feature planning document. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
7f6b9dd558
commit
ea141f04f6
|
|
@ -0,0 +1,424 @@
|
||||||
|
# Region Found Celebration Plan
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When a user correctly clicks on a region, the game immediately transitions to the next region with no celebration. Kids deserve positive reinforcement for correct answers!
|
||||||
|
|
||||||
|
## Current Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User clicks region → onRegionClick(id, name)
|
||||||
|
→ Provider.clickRegion()
|
||||||
|
→ Validator: isCorrect?
|
||||||
|
→ Add to regionsFound
|
||||||
|
→ Set next currentPrompt
|
||||||
|
→ State updates
|
||||||
|
→ MapRenderer re-renders with new prompt
|
||||||
|
```
|
||||||
|
|
||||||
|
The transition is instant - no pause, no celebration, no feedback.
|
||||||
|
|
||||||
|
## User Requirements
|
||||||
|
|
||||||
|
1. ✅ Gold flash effect on found region
|
||||||
|
2. ✅ Confetti burst
|
||||||
|
3. ✅ Responsive to HOW they found it:
|
||||||
|
- **Fast find** → Quick, snappy celebration (reward speed)
|
||||||
|
- **Hard-earned find** → Bigger, more satisfying celebration (acknowledge effort)
|
||||||
|
4. ✅ Sound effect (NOT speech synthesis - that's for hot/cold)
|
||||||
|
5. ✅ Block advancement until celebration completes (map gets cluttered)
|
||||||
|
6. ❌ No streaks for now
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Celebration Types
|
||||||
|
|
||||||
|
### 1. Lightning Find ⚡ (< 3 seconds)
|
||||||
|
Kid knew exactly where to look - reward the speed!
|
||||||
|
- **Flash**: Quick, bright gold pulse (400ms)
|
||||||
|
- **Confetti**: Fast, sparkly burst (small particles, quick fade)
|
||||||
|
- **Sound**: Quick "ding!" or sparkle
|
||||||
|
- **Duration**: ~800ms total
|
||||||
|
|
||||||
|
### 2. Standard Find ✨ (3-15 seconds, direct path)
|
||||||
|
Normal discovery - celebrate appropriately
|
||||||
|
- **Flash**: Smooth gold pulse (600ms)
|
||||||
|
- **Confetti**: Medium burst with gravity fall
|
||||||
|
- **Sound**: Pleasant chime
|
||||||
|
- **Duration**: ~1.2 seconds total
|
||||||
|
|
||||||
|
### 3. Hard-Earned Find 💪 (searched extensively)
|
||||||
|
Kid really worked for it - acknowledge the effort!
|
||||||
|
- **Flash**: Warm, satisfying glow (800ms)
|
||||||
|
- **Confetti**: Big celebration! More particles, longer duration
|
||||||
|
- **Sound**: Triumphant fanfare/chord
|
||||||
|
- **Duration**: ~1.8 seconds total
|
||||||
|
- **Extra**: Maybe show encouraging text "You found it!"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detecting Celebration Type
|
||||||
|
|
||||||
|
### Data Available from Hot/Cold System
|
||||||
|
|
||||||
|
The `useHotColdFeedback` hook already tracks:
|
||||||
|
```typescript
|
||||||
|
interface PathEntry {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
timestamp: number
|
||||||
|
distance: number // Distance to target at this point
|
||||||
|
}
|
||||||
|
|
||||||
|
// In HotColdState:
|
||||||
|
history: PathEntry[] // Circular buffer of recent positions
|
||||||
|
totalDistanceChange: number // Cumulative movement toward/away from target
|
||||||
|
minDistanceSinceLastFeedback // Got close then moved away?
|
||||||
|
```
|
||||||
|
|
||||||
|
### Metrics for Effort Detection
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SearchMetrics {
|
||||||
|
// Time
|
||||||
|
timeToFind: number // ms from prompt start to correct click
|
||||||
|
|
||||||
|
// Distance traveled
|
||||||
|
totalCursorDistance: number // Total pixels cursor moved (from history)
|
||||||
|
straightLineDistance: number // Direct path would have been
|
||||||
|
searchEfficiency: number // straight / total (1.0 = perfect, <0.3 = searched hard)
|
||||||
|
|
||||||
|
// Direction changes
|
||||||
|
directionReversals: number // How many times changed direction toward/away
|
||||||
|
|
||||||
|
// Near misses
|
||||||
|
nearMissCount: number // Times got within CLOSE threshold then moved away
|
||||||
|
overshotCount: number // Times passed the target
|
||||||
|
|
||||||
|
// Zone transitions
|
||||||
|
zoneTransitions: number // warming→cooling→warming transitions
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Classification Logic
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function classifyCelebration(metrics: SearchMetrics): 'lightning' | 'standard' | 'hard-earned' {
|
||||||
|
// Lightning: Fast and direct
|
||||||
|
if (metrics.timeToFind < 3000 && metrics.searchEfficiency > 0.7) {
|
||||||
|
return 'lightning'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard-earned: Any of these indicate real effort
|
||||||
|
if (
|
||||||
|
metrics.timeToFind > 20000 || // Took a while
|
||||||
|
metrics.searchEfficiency < 0.3 || // Wandered a lot
|
||||||
|
metrics.directionReversals > 10 || // Lots of back-and-forth
|
||||||
|
metrics.nearMissCount > 2 || // Got close multiple times
|
||||||
|
metrics.overshotCount > 1 // Passed it more than once
|
||||||
|
) {
|
||||||
|
return 'hard-earned'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'standard'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Flow with Celebration
|
||||||
|
|
||||||
|
```
|
||||||
|
User clicks region → onRegionClick(id, name)
|
||||||
|
→ MapRenderer intercepts (is correct?)
|
||||||
|
→ YES: Start celebration
|
||||||
|
- Calculate celebration type from search metrics
|
||||||
|
- Show flash + confetti + sound
|
||||||
|
- Block further clicks
|
||||||
|
- After animation complete:
|
||||||
|
→ Call actual clickRegion()
|
||||||
|
→ Advance to next prompt
|
||||||
|
→ NO: Normal wrong-answer handling
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Change: Delay State Update
|
||||||
|
|
||||||
|
Instead of immediately calling `clickRegion` and advancing, we:
|
||||||
|
1. Detect correct click locally in MapRenderer
|
||||||
|
2. Start celebration animation
|
||||||
|
3. Block input during celebration
|
||||||
|
4. Only AFTER celebration completes, send the click to the server/validator
|
||||||
|
|
||||||
|
This ensures the map doesn't clutter with the next prompt while celebrating.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State Additions
|
||||||
|
|
||||||
|
### Provider Context (client-side only)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Add to Provider.tsx context
|
||||||
|
interface CelebrationState {
|
||||||
|
regionId: string
|
||||||
|
regionName: string
|
||||||
|
type: 'lightning' | 'standard' | 'hard-earned'
|
||||||
|
startTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const [celebration, setCelebration] = useState<CelebrationState | null>(null)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expose Search Metrics from Hot/Cold Hook
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Add to useHotColdFeedback return value
|
||||||
|
export function useHotColdFeedback(...) {
|
||||||
|
// ... existing code ...
|
||||||
|
|
||||||
|
// New: expose metrics for celebration classification
|
||||||
|
const getSearchMetrics = useCallback((): SearchMetrics => {
|
||||||
|
const state = stateRef.current
|
||||||
|
const entries = getRecentEntries(state, HISTORY_LENGTH)
|
||||||
|
.filter((e): e is PathEntry => e !== null)
|
||||||
|
|
||||||
|
// Calculate metrics from history...
|
||||||
|
return {
|
||||||
|
timeToFind: /* time since prompt started */,
|
||||||
|
totalCursorDistance: /* sum of distances between consecutive points */,
|
||||||
|
straightLineDistance: /* first point to target */,
|
||||||
|
searchEfficiency: /* straight / total */,
|
||||||
|
directionReversals: /* count sign changes in distance delta */,
|
||||||
|
nearMissCount: /* count entries with distance < CLOSE */,
|
||||||
|
overshotCount: /* from existing detection */,
|
||||||
|
zoneTransitions: /* count zone changes */,
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
checkPosition,
|
||||||
|
reset,
|
||||||
|
isSpeaking,
|
||||||
|
lastFeedbackType,
|
||||||
|
getSearchMetrics, // NEW
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sound Effects
|
||||||
|
|
||||||
|
### Options for Sound Generation
|
||||||
|
|
||||||
|
1. **Web Audio API** - Generate tones programmatically (no files needed)
|
||||||
|
2. **Audio files** - Pre-recorded sounds (more polished, adds to bundle)
|
||||||
|
3. **Tone.js library** - Rich audio synthesis (adds dependency)
|
||||||
|
|
||||||
|
**Recommendation**: Web Audio API for MVP (no deps, small)
|
||||||
|
|
||||||
|
### Sound Design
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// useCelebrationSound.ts
|
||||||
|
const audioContext = new (window.AudioContext || window.webkitAudioContext)()
|
||||||
|
|
||||||
|
function playLightningSound() {
|
||||||
|
// Quick sparkle: high frequency, fast decay
|
||||||
|
const osc = audioContext.createOscillator()
|
||||||
|
const gain = audioContext.createGain()
|
||||||
|
|
||||||
|
osc.type = 'sine'
|
||||||
|
osc.frequency.setValueAtTime(1200, audioContext.currentTime)
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(2400, audioContext.currentTime + 0.1)
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.3, audioContext.currentTime)
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2)
|
||||||
|
|
||||||
|
osc.connect(gain).connect(audioContext.destination)
|
||||||
|
osc.start()
|
||||||
|
osc.stop(audioContext.currentTime + 0.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function playStandardSound() {
|
||||||
|
// Pleasant chime: medium frequency, gentle decay
|
||||||
|
// Two-note chord for warmth
|
||||||
|
}
|
||||||
|
|
||||||
|
function playHardEarnedSound() {
|
||||||
|
// Triumphant: ascending notes, longer sustain
|
||||||
|
// Maybe C-E-G chord arpeggio
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visual Components
|
||||||
|
|
||||||
|
### 1. Gold Flash Effect
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In MapRenderer, for the found region path
|
||||||
|
const isFlashing = celebration?.regionId === region.id
|
||||||
|
|
||||||
|
// Animated fill using react-spring
|
||||||
|
const flashSpring = useSpring({
|
||||||
|
glow: isFlashing ? 1 : 0,
|
||||||
|
config: {
|
||||||
|
duration: celebration?.type === 'lightning' ? 400
|
||||||
|
: celebration?.type === 'hard-earned' ? 800
|
||||||
|
: 600
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// SVG path style
|
||||||
|
style={{
|
||||||
|
fill: flashSpring.glow.to(g =>
|
||||||
|
g > 0 ? `rgba(251, 191, 36, ${0.5 + g * 0.5})` : normalFill
|
||||||
|
),
|
||||||
|
filter: flashSpring.glow.to(g =>
|
||||||
|
g > 0 ? `drop-shadow(0 0 ${g * 15}px rgba(251, 191, 36, 0.8))` : 'none'
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Confetti Component
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// components/Confetti.tsx
|
||||||
|
interface ConfettiProps {
|
||||||
|
type: 'lightning' | 'standard' | 'hard-earned'
|
||||||
|
origin: { x: number; y: number } // Screen coordinates
|
||||||
|
onComplete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFETTI_CONFIG = {
|
||||||
|
lightning: { count: 12, duration: 600, spread: 60 },
|
||||||
|
standard: { count: 20, duration: 1000, spread: 90 },
|
||||||
|
'hard-earned': { count: 35, duration: 1500, spread: 120 },
|
||||||
|
}
|
||||||
|
|
||||||
|
function Confetti({ type, origin, onComplete }: ConfettiProps) {
|
||||||
|
const config = CONFETTI_CONFIG[type]
|
||||||
|
|
||||||
|
// Generate particles with random directions, colors, sizes
|
||||||
|
// Use CSS animations for performance
|
||||||
|
// Call onComplete when last particle finishes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Celebration Overlay
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// components/CelebrationOverlay.tsx
|
||||||
|
interface CelebrationOverlayProps {
|
||||||
|
celebration: CelebrationState
|
||||||
|
regionCenter: { x: number; y: number }
|
||||||
|
onComplete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function CelebrationOverlay({ celebration, regionCenter, onComplete }: CelebrationOverlayProps) {
|
||||||
|
const [phase, setPhase] = useState<'active' | 'complete'>('active')
|
||||||
|
|
||||||
|
// Track when all animations complete
|
||||||
|
const handleConfettiComplete = useCallback(() => {
|
||||||
|
setPhase('complete')
|
||||||
|
onComplete()
|
||||||
|
}, [onComplete])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css({ position: 'fixed', inset: 0, pointerEvents: 'none', zIndex: 1000 })}>
|
||||||
|
<Confetti
|
||||||
|
type={celebration.type}
|
||||||
|
origin={regionCenter}
|
||||||
|
onComplete={handleConfettiComplete}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{celebration.type === 'hard-earned' && (
|
||||||
|
<EncouragingText regionName={celebration.regionName} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Create/Modify
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
1. **`hooks/useCelebrationSound.ts`** - Web Audio API sound effects
|
||||||
|
2. **`hooks/useSearchMetrics.ts`** - Extract metrics from hot/cold history
|
||||||
|
3. **`components/Confetti.tsx`** - CSS confetti particles
|
||||||
|
4. **`components/CelebrationOverlay.tsx`** - Orchestrates celebration
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
1. **`Provider.tsx`** - Add celebration state to context
|
||||||
|
2. **`hooks/useHotColdFeedback.ts`** - Expose `getSearchMetrics()` method
|
||||||
|
3. **`MapRenderer.tsx`** - Intercept correct clicks, trigger celebration, delay advancement
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
### Phase 1: Infrastructure
|
||||||
|
1. Add `celebration` state to Provider context
|
||||||
|
2. Add `promptStartTime` tracking (when each region prompt begins)
|
||||||
|
3. Modify `useHotColdFeedback` to expose `getSearchMetrics()`
|
||||||
|
|
||||||
|
### Phase 2: Classification
|
||||||
|
4. Create `useSearchMetrics` hook to calculate metrics
|
||||||
|
5. Implement `classifyCelebration()` function
|
||||||
|
6. Test metric calculation with various search patterns
|
||||||
|
|
||||||
|
### Phase 3: Visuals
|
||||||
|
7. Create `Confetti` component with CSS animations
|
||||||
|
8. Add gold flash effect to MapRenderer (react-spring)
|
||||||
|
9. Create `CelebrationOverlay` to orchestrate
|
||||||
|
|
||||||
|
### Phase 4: Audio
|
||||||
|
10. Create `useCelebrationSound` hook with Web Audio API
|
||||||
|
11. Implement three sound types (lightning, standard, hard-earned)
|
||||||
|
12. Wire up sounds to celebration types
|
||||||
|
|
||||||
|
### Phase 5: Integration
|
||||||
|
13. Intercept correct clicks in MapRenderer
|
||||||
|
14. Block advancement during celebration
|
||||||
|
15. Call `clickRegion` only after celebration completes
|
||||||
|
|
||||||
|
### Phase 6: Polish
|
||||||
|
16. Add `prefers-reduced-motion` support
|
||||||
|
17. Test on mobile (performance, touch)
|
||||||
|
18. Fine-tune timing and particle counts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
1. **Reduced Motion**: `@media (prefers-reduced-motion: reduce)`
|
||||||
|
- Skip confetti entirely
|
||||||
|
- Use simple color change instead of flash
|
||||||
|
- Sound still plays (audio is separate preference)
|
||||||
|
|
||||||
|
2. **Sound Preferences**: Respect system mute / browser audio block
|
||||||
|
- Don't crash if AudioContext is blocked
|
||||||
|
- Degrade gracefully
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Budget
|
||||||
|
|
||||||
|
- Confetti particles: Max 35 (hard-earned), CSS-animated
|
||||||
|
- Flash effect: Single react-spring animation
|
||||||
|
- Sound: Single Web Audio oscillator (or two for chords)
|
||||||
|
- No canvas, no heavy libraries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timing Summary
|
||||||
|
|
||||||
|
| Type | Flash | Confetti | Sound | Total Block |
|
||||||
|
|------|-------|----------|-------|-------------|
|
||||||
|
| Lightning ⚡ | 400ms | 600ms | 200ms | 600ms |
|
||||||
|
| Standard ✨ | 600ms | 1000ms | 400ms | 1000ms |
|
||||||
|
| Hard-earned 💪 | 800ms | 1500ms | 600ms | 1500ms |
|
||||||
|
|
@ -1633,6 +1633,274 @@ export function shouldIncludeRegion(regionId: string, includeSizes: RegionSize[]
|
||||||
return includeSizes.includes(category)
|
return includeSizes.includes(category)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse SVG path into subpaths (separated by M commands, closed by Z)
|
||||||
|
* Returns array of point arrays, one per subpath
|
||||||
|
*/
|
||||||
|
function parsePathToSubpaths(pathString: string): Array<Array<[number, number]>> {
|
||||||
|
const subpaths: Array<Array<[number, number]>> = []
|
||||||
|
let currentSubpath: Array<[number, number]> = []
|
||||||
|
let currentX = 0
|
||||||
|
let currentY = 0
|
||||||
|
let subpathStartX = 0
|
||||||
|
let subpathStartY = 0
|
||||||
|
|
||||||
|
const commandRegex = /([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g
|
||||||
|
let match
|
||||||
|
|
||||||
|
while ((match = commandRegex.exec(pathString)) !== null) {
|
||||||
|
const command = match[1]
|
||||||
|
const params =
|
||||||
|
match[2]
|
||||||
|
.trim()
|
||||||
|
.match(/-?\d+\.?\d*/g)
|
||||||
|
?.map(Number) || []
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case 'M':
|
||||||
|
if (currentSubpath.length > 0) subpaths.push(currentSubpath)
|
||||||
|
currentSubpath = []
|
||||||
|
for (let i = 0; i < params.length - 1; i += 2) {
|
||||||
|
currentX = params[i]
|
||||||
|
currentY = params[i + 1]
|
||||||
|
if (i === 0) {
|
||||||
|
subpathStartX = currentX
|
||||||
|
subpathStartY = currentY
|
||||||
|
}
|
||||||
|
currentSubpath.push([currentX, currentY])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'm':
|
||||||
|
if (currentSubpath.length > 0) subpaths.push(currentSubpath)
|
||||||
|
currentSubpath = []
|
||||||
|
for (let i = 0; i < params.length - 1; i += 2) {
|
||||||
|
currentX += params[i]
|
||||||
|
currentY += params[i + 1]
|
||||||
|
if (i === 0) {
|
||||||
|
subpathStartX = currentX
|
||||||
|
subpathStartY = currentY
|
||||||
|
}
|
||||||
|
currentSubpath.push([currentX, currentY])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'L':
|
||||||
|
for (let i = 0; i < params.length - 1; i += 2) {
|
||||||
|
currentX = params[i]
|
||||||
|
currentY = params[i + 1]
|
||||||
|
currentSubpath.push([currentX, currentY])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'l':
|
||||||
|
for (let i = 0; i < params.length - 1; i += 2) {
|
||||||
|
currentX += params[i]
|
||||||
|
currentY += params[i + 1]
|
||||||
|
currentSubpath.push([currentX, currentY])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'H':
|
||||||
|
for (const x of params) {
|
||||||
|
currentX = x
|
||||||
|
currentSubpath.push([currentX, currentY])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'h':
|
||||||
|
for (const dx of params) {
|
||||||
|
currentX += dx
|
||||||
|
currentSubpath.push([currentX, currentY])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'V':
|
||||||
|
for (const y of params) {
|
||||||
|
currentY = y
|
||||||
|
currentSubpath.push([currentX, currentY])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'v':
|
||||||
|
for (const dy of params) {
|
||||||
|
currentY += dy
|
||||||
|
currentSubpath.push([currentX, currentY])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'C':
|
||||||
|
for (let i = 0; i < params.length - 5; i += 6) {
|
||||||
|
currentX = params[i + 4]
|
||||||
|
currentY = params[i + 5]
|
||||||
|
currentSubpath.push([currentX, currentY])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'c':
|
||||||
|
for (let i = 0; i < params.length - 5; i += 6) {
|
||||||
|
currentX += params[i + 4]
|
||||||
|
currentY += params[i + 5]
|
||||||
|
currentSubpath.push([currentX, currentY])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'S':
|
||||||
|
for (let i = 0; i < params.length - 3; i += 4) {
|
||||||
|
currentX = params[i + 2]
|
||||||
|
currentY = params[i + 3]
|
||||||
|
currentSubpath.push([currentX, currentY])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 's':
|
||||||
|
for (let i = 0; i < params.length - 3; i += 4) {
|
||||||
|
currentX += params[i + 2]
|
||||||
|
currentY += params[i + 3]
|
||||||
|
currentSubpath.push([currentX, currentY])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'Q':
|
||||||
|
for (let i = 0; i < params.length - 3; i += 4) {
|
||||||
|
currentX = params[i + 2]
|
||||||
|
currentY = params[i + 3]
|
||||||
|
currentSubpath.push([currentX, currentY])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'q':
|
||||||
|
for (let i = 0; i < params.length - 3; i += 4) {
|
||||||
|
currentX += params[i + 2]
|
||||||
|
currentY += params[i + 3]
|
||||||
|
currentSubpath.push([currentX, currentY])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'T':
|
||||||
|
for (let i = 0; i < params.length - 1; i += 2) {
|
||||||
|
currentX = params[i]
|
||||||
|
currentY = params[i + 1]
|
||||||
|
currentSubpath.push([currentX, currentY])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 't':
|
||||||
|
for (let i = 0; i < params.length - 1; i += 2) {
|
||||||
|
currentX += params[i]
|
||||||
|
currentY += params[i + 1]
|
||||||
|
currentSubpath.push([currentX, currentY])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'A':
|
||||||
|
for (let i = 0; i < params.length - 6; i += 7) {
|
||||||
|
currentX = params[i + 5]
|
||||||
|
currentY = params[i + 6]
|
||||||
|
currentSubpath.push([currentX, currentY])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'a':
|
||||||
|
for (let i = 0; i < params.length - 6; i += 7) {
|
||||||
|
currentX += params[i + 5]
|
||||||
|
currentY += params[i + 6]
|
||||||
|
currentSubpath.push([currentX, currentY])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'Z':
|
||||||
|
case 'z':
|
||||||
|
currentX = subpathStartX
|
||||||
|
currentY = subpathStartY
|
||||||
|
if (currentSubpath.length > 0) {
|
||||||
|
subpaths.push(currentSubpath)
|
||||||
|
currentSubpath = []
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSubpath.length > 0) subpaths.push(currentSubpath)
|
||||||
|
return subpaths
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate polygon area using shoelace formula
|
||||||
|
*/
|
||||||
|
function calculatePolygonArea(points: Array<[number, number]>): number {
|
||||||
|
if (points.length < 3) return 0
|
||||||
|
let area = 0
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const j = (i + 1) % points.length
|
||||||
|
area += points[i][0] * points[j][1]
|
||||||
|
area -= points[j][0] * points[i][1]
|
||||||
|
}
|
||||||
|
return Math.abs(area) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate centroid of a polygon using shoelace formula
|
||||||
|
*/
|
||||||
|
function calculatePolygonCentroid(points: Array<[number, number]>): [number, number] {
|
||||||
|
if (points.length === 0) return [0, 0]
|
||||||
|
if (points.length < 3) {
|
||||||
|
const avgX = points.reduce((s, p) => s + p[0], 0) / points.length
|
||||||
|
const avgY = points.reduce((s, p) => s + p[1], 0) / points.length
|
||||||
|
return [avgX, avgY]
|
||||||
|
}
|
||||||
|
|
||||||
|
let signedArea = 0
|
||||||
|
let cx = 0
|
||||||
|
let cy = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const j = (i + 1) % points.length
|
||||||
|
const cross = points[i][0] * points[j][1] - points[j][0] * points[i][1]
|
||||||
|
signedArea += cross
|
||||||
|
cx += (points[i][0] + points[j][0]) * cross
|
||||||
|
cy += (points[i][1] + points[j][1]) * cross
|
||||||
|
}
|
||||||
|
|
||||||
|
signedArea /= 2
|
||||||
|
if (Math.abs(signedArea) < 0.0001) {
|
||||||
|
const avgX = points.reduce((s, p) => s + p[0], 0) / points.length
|
||||||
|
const avgY = points.reduce((s, p) => s + p[1], 0) / points.length
|
||||||
|
return [avgX, avgY]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [cx / (6 * signedArea), cy / (6 * signedArea)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the bounding box center of the largest closed subpath in an SVG path.
|
||||||
|
* Uses bounding box center (not geometric centroid) for more visually predictable results
|
||||||
|
* on elongated shapes like Norway or Chile.
|
||||||
|
* Falls back to overall path bounding box center if subpath parsing fails.
|
||||||
|
*/
|
||||||
|
export function getLargestSubpathCentroid(pathString: string): { x: number; y: number } | null {
|
||||||
|
const subpaths = parsePathToSubpaths(pathString)
|
||||||
|
|
||||||
|
// If no subpaths found, return null to signal fallback should be used
|
||||||
|
if (subpaths.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find largest subpath by area
|
||||||
|
let largestSubpath = subpaths[0]
|
||||||
|
let largestArea = calculatePolygonArea(largestSubpath)
|
||||||
|
|
||||||
|
for (let i = 1; i < subpaths.length; i++) {
|
||||||
|
const area = calculatePolygonArea(subpaths[i])
|
||||||
|
if (area > largestArea) {
|
||||||
|
largestArea = area
|
||||||
|
largestSubpath = subpaths[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If largest subpath is empty or has no area, return null
|
||||||
|
if (largestSubpath.length === 0 || largestArea === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate bounding box center of largest subpath
|
||||||
|
let minX = largestSubpath[0][0]
|
||||||
|
let maxX = largestSubpath[0][0]
|
||||||
|
let minY = largestSubpath[0][1]
|
||||||
|
let maxY = largestSubpath[0][1]
|
||||||
|
|
||||||
|
for (const [x, y] of largestSubpath) {
|
||||||
|
if (x < minX) minX = x
|
||||||
|
if (x > maxX) maxX = x
|
||||||
|
if (y < minY) minY = y
|
||||||
|
if (y > maxY) maxY = y
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate the centroid (center of mass) of an SVG path
|
* Calculate the centroid (center of mass) of an SVG path
|
||||||
* Properly parses SVG path commands to extract endpoint coordinates only
|
* Properly parses SVG path commands to extract endpoint coordinates only
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue