feat: add adaptive zoom magnifier for Know Your World map

Add intelligent magnifying glass feature with smooth animations:

Magnifier Features:
- Appears when 7+ regions overlap in 50×50px box OR region <8px
- Adaptive zoom (8×-24×) based on region density and size
- Smooth zoom/opacity animations using react-spring
- Dynamic positioning to avoid covering cursor
- Visual indicator box on main map shows magnified area
- Crosshair shows exact cursor position in magnified view

Implementation Details:
- Uses react-spring for smooth zoom and fade transitions
- Position calculated per quadrant (opposite corner from cursor)
- Zoom formula: base 8× + density factor + size factor
- Animated SVG viewBox for seamless zooming
- Dashed blue indicator rectangle tracks magnified region

UI/UX Improvements:
- Remove duplicate turn indicator (use player avatar dock)
- Hide arrow labels feature behind flag (disabled by default)
- Add Storybook stories for map renderer with tuning controls

This makes clicking tiny island nations and crowded regions much easier!

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-18 20:04:32 -06:00
parent e900e4465b
commit 1e8846cdb1
3 changed files with 1074 additions and 147 deletions

View File

@ -0,0 +1,169 @@
import type { Meta, StoryObj } from '@storybook/react'
import { MapRenderer } from './MapRenderer'
import { getFilteredMapData } from '../maps'
import type { ContinentId } from '../continents'
const meta = {
title: 'Arcade/KnowYourWorld/MapRenderer',
component: MapRenderer,
parameters: {
layout: 'fullscreen',
},
argTypes: {
continent: {
control: 'select',
options: [
'all',
'africa',
'asia',
'europe',
'north-america',
'south-america',
'oceania',
'antarctica',
],
description: 'Select a continent to filter the map',
},
difficulty: {
control: 'select',
options: ['easy', 'hard'],
description: 'Game difficulty',
},
showArrows: {
control: 'boolean',
description: 'Show arrow labels for small regions (experimental feature)',
},
centeringStrength: {
control: { type: 'range', min: 0.1, max: 10, step: 0.1 },
description: 'Force pulling labels back to regions (higher = stronger)',
},
collisionPadding: {
control: { type: 'range', min: 0, max: 50, step: 1 },
description: 'Extra padding around labels for collision detection',
},
simulationIterations: {
control: { type: 'range', min: 0, max: 500, step: 10 },
description: 'Number of simulation iterations (more = more settled)',
},
useObstacles: {
control: 'boolean',
description: 'Use region obstacles to push labels away from map',
},
obstaclePadding: {
control: { type: 'range', min: 0, max: 50, step: 1 },
description: 'Extra padding around region obstacles',
},
},
} satisfies Meta<typeof MapRenderer>
export default meta
type Story = StoryObj<typeof meta>
// Mock data
const mockPlayerMetadata = {
'player-1': {
id: 'player-1',
name: 'Player 1',
emoji: '😊',
color: '#3b82f6',
},
'player-2': {
id: 'player-2',
name: 'Player 2',
emoji: '🎮',
color: '#ef4444',
},
}
// Story template
const Template = (args: any) => {
const mapData = getFilteredMapData('world', args.continent as ContinentId | 'all')
// Simulate some found regions (first 5 regions)
const regionsFound = mapData.regions.slice(0, 5).map((r) => r.id)
// Mock guess history
const guessHistory = regionsFound.map((regionId, index) => ({
playerId: index % 2 === 0 ? 'player-1' : 'player-2',
regionId,
correct: true,
}))
return (
<div style={{ padding: '20px', minHeight: '100vh', background: '#111827' }}>
<MapRenderer
mapData={mapData}
regionsFound={regionsFound}
currentPrompt={mapData.regions[5]?.id || null}
difficulty={args.difficulty}
onRegionClick={(id, name) => console.log('Clicked:', id, name)}
guessHistory={guessHistory}
playerMetadata={mockPlayerMetadata}
forceTuning={{
showArrows: args.showArrows,
centeringStrength: args.centeringStrength,
collisionPadding: args.collisionPadding,
simulationIterations: args.simulationIterations,
useObstacles: args.useObstacles,
obstaclePadding: args.obstaclePadding,
}}
/>
</div>
)
}
export const Oceania: Story = {
render: Template,
args: {
continent: 'oceania',
difficulty: 'easy',
showArrows: false,
centeringStrength: 2.0,
collisionPadding: 5,
simulationIterations: 200,
useObstacles: true,
obstaclePadding: 10,
},
}
export const Europe: Story = {
render: Template,
args: {
continent: 'europe',
difficulty: 'easy',
showArrows: false,
centeringStrength: 2.0,
collisionPadding: 5,
simulationIterations: 200,
useObstacles: true,
obstaclePadding: 10,
},
}
export const Africa: Story = {
render: Template,
args: {
continent: 'africa',
difficulty: 'easy',
showArrows: false,
centeringStrength: 2.0,
collisionPadding: 5,
simulationIterations: 200,
useObstacles: true,
obstaclePadding: 10,
},
}
export const AllWorld: Story = {
render: Template,
args: {
continent: 'all',
difficulty: 'easy',
showArrows: false,
centeringStrength: 2.0,
collisionPadding: 5,
simulationIterations: 200,
useObstacles: true,
obstaclePadding: 10,
},
}

View File

@ -158,6 +158,8 @@ export function PlayingPhase() {
currentPrompt={state.currentPrompt}
difficulty={state.difficulty}
onRegionClick={clickRegion}
guessHistory={state.guessHistory}
playerMetadata={state.playerMetadata}
/>
{/* Game Mode Info */}
@ -198,26 +200,6 @@ export function PlayingPhase() {
</div>
</div>
</div>
{/* Turn Indicator (for turn-based mode) */}
{state.gameMode === 'turn-based' && (
<div
data-section="turn-indicator"
className={css({
textAlign: 'center',
padding: '3',
bg: isDark ? 'purple.900' : 'purple.50',
rounded: 'lg',
border: '2px solid',
borderColor: 'purple.500',
fontSize: 'lg',
fontWeight: 'semibold',
color: isDark ? 'purple.100' : 'purple.900',
})}
>
Current Turn: {state.playerMetadata[state.currentPlayer]?.name || state.currentPlayer}
</div>
)}
</div>
)
}