From 1e8846cdb1de8e557f2ef53eff6014995911f3ce Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Tue, 18 Nov 2025 20:04:32 -0600 Subject: [PATCH] feat: add adaptive zoom magnifier for Know Your World map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/MapRenderer.stories.tsx | 169 +++ .../components/MapRenderer.tsx | 1030 +++++++++++++++-- .../components/PlayingPhase.tsx | 22 +- 3 files changed, 1074 insertions(+), 147 deletions(-) create mode 100644 apps/web/src/arcade-games/know-your-world/components/MapRenderer.stories.tsx diff --git a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.stories.tsx b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.stories.tsx new file mode 100644 index 00000000..567ecbe6 --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.stories.tsx @@ -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 + +export default meta +type Story = StoryObj + +// 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 ( +
+ 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, + }} + /> +
+ ) +} + +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, + }, +} diff --git a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx index 97f09abb..e522bc65 100644 --- a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx @@ -1,6 +1,7 @@ 'use client' -import { useState, useMemo } from 'react' +import { useState, useMemo, useRef, useEffect } from 'react' +import { useSpring, animated } from '@react-spring/web' import { css } from '@styled/css' import { useTheme } from '@/contexts/ThemeContext' import type { MapData, MapRegion } from '../types' @@ -11,6 +12,7 @@ import { getLabelTextColor, getLabelTextShadow, } from '../mapColors' +import { forceSimulation, forceCollide, forceX, forceY, type SimulationNodeDatum } from 'd3-force' interface BoundingBox { minX: number @@ -22,20 +24,35 @@ interface BoundingBox { area: number } -interface SmallRegionLabel { - regionId: string - regionName: string - regionCenter: [number, number] - labelPosition: [number, number] - isFound: boolean -} - interface MapRendererProps { mapData: MapData regionsFound: string[] currentPrompt: string | null difficulty: 'easy' | 'hard' onRegionClick: (regionId: string, regionName: string) => void + guessHistory: Array<{ + playerId: string + regionId: string + correct: boolean + }> + playerMetadata: Record< + string, + { + id: string + name: string + emoji: string + color: string + } + > + // Force simulation tuning parameters + forceTuning?: { + showArrows?: boolean + centeringStrength?: number + collisionPadding?: number + simulationIterations?: number + useObstacles?: boolean + obstaclePadding?: number + } } /** @@ -69,21 +86,12 @@ function calculateBoundingBox(pathString: string): BoundingBox { return { minX, maxX, minY, maxY, width, height, area } } -/** - * Determine if a region is too small to click easily - */ -function isSmallRegion(bbox: BoundingBox, viewBox: string): boolean { - // Parse viewBox to get map dimensions - const viewBoxParts = viewBox.split(' ').map(Number) - const mapWidth = viewBoxParts[2] || 1000 - const mapHeight = viewBoxParts[3] || 1000 - - // Thresholds (relative to map size) - const minWidth = mapWidth * 0.025 // 2.5% of map width - const minHeight = mapHeight * 0.025 // 2.5% of map height - const minArea = mapWidth * mapHeight * 0.001 // 0.1% of total map area - - return bbox.width < minWidth || bbox.height < minHeight || bbox.area < minArea +interface RegionLabelPosition { + regionId: string + regionName: string + x: number // pixel position + y: number // pixel position + players: string[] } export function MapRenderer({ @@ -92,38 +100,385 @@ export function MapRenderer({ currentPrompt, difficulty, onRegionClick, + guessHistory, + playerMetadata, + forceTuning = {}, }: MapRendererProps) { + // Extract force tuning parameters with defaults + const { + showArrows = false, + centeringStrength = 2.0, + collisionPadding = 5, + simulationIterations = 200, + useObstacles = true, + obstaclePadding = 10, + } = forceTuning const { resolvedTheme } = useTheme() const isDark = resolvedTheme === 'dark' const [hoveredRegion, setHoveredRegion] = useState(null) + const svgRef = useRef(null) + const containerRef = useRef(null) + const [svgDimensions, setSvgDimensions] = useState({ width: 1000, height: 500 }) + const [cursorPosition, setCursorPosition] = useState<{ x: number; y: number } | null>(null) + const [showMagnifier, setShowMagnifier] = useState(false) + const [targetZoom, setTargetZoom] = useState(10) + const [targetOpacity, setTargetOpacity] = useState(0) - // Calculate small regions that need labels with arrows - const smallRegionLabels = useMemo(() => { - const labels: SmallRegionLabel[] = [] - const viewBoxParts = mapData.viewBox.split(' ').map(Number) - const mapWidth = viewBoxParts[2] || 1000 - const mapHeight = viewBoxParts[3] || 1000 + // Animated spring values for smooth transitions + const magnifierSpring = useSpring({ + zoom: targetZoom, + opacity: targetOpacity, + config: { + tension: 200, + friction: 25, + }, + }) - mapData.regions.forEach((region) => { - const bbox = calculateBoundingBox(region.path) - if (isSmallRegion(bbox, mapData.viewBox)) { - // Position label to the right and slightly down from region - // This is a simple strategy - could be improved with collision detection - const offsetX = mapWidth * 0.08 - const offsetY = mapHeight * 0.03 + const [labelPositions, setLabelPositions] = useState([]) + const [smallRegionLabelPositions, setSmallRegionLabelPositions] = useState< + Array<{ + regionId: string + regionName: string + isFound: boolean + labelX: number // pixel position for label + labelY: number // pixel position for label + lineStartX: number // pixel position for line start + lineStartY: number // pixel position for line start + lineEndX: number // pixel position for line end (region center) + lineEndY: number // pixel position for line end + }> + >([]) - labels.push({ - regionId: region.id, - regionName: region.name, - regionCenter: region.center, - labelPosition: [region.center[0] + offsetX, region.center[1] + offsetY], - isFound: regionsFound.includes(region.id), + // Measure SVG element to get actual pixel dimensions + useEffect(() => { + if (!svgRef.current) return + + const updateDimensions = () => { + const rect = svgRef.current?.getBoundingClientRect() + if (rect) { + setSvgDimensions({ width: rect.width, height: rect.height }) + } + } + + // Initial measurement + updateDimensions() + + // Update on window resize + window.addEventListener('resize', updateDimensions) + return () => window.removeEventListener('resize', updateDimensions) + }, [mapData.viewBox]) // Re-measure when viewBox changes (continent/map selection) + + // Calculate label positions using ghost elements + useEffect(() => { + if (!svgRef.current || !containerRef.current) return + + const updateLabelPositions = () => { + const containerRect = containerRef.current?.getBoundingClientRect() + if (!containerRect) return + + const positions: RegionLabelPosition[] = [] + const smallPositions: typeof smallRegionLabelPositions = [] + + // Parse viewBox for scale calculations + const viewBoxParts = mapData.viewBox.split(' ').map(Number) + const viewBoxX = viewBoxParts[0] || 0 + const viewBoxY = viewBoxParts[1] || 0 + const viewBoxWidth = viewBoxParts[2] || 1000 + const viewBoxHeight = viewBoxParts[3] || 1000 + + const svgRect = svgRef.current?.getBoundingClientRect() + if (!svgRect) return + + const scaleX = svgRect.width / viewBoxWidth + const scaleY = svgRect.height / viewBoxHeight + + // Calculate SVG offset within container (accounts for padding) + const svgOffsetX = svgRect.left - containerRect.left + const svgOffsetY = svgRect.top - containerRect.top + + // Collect all regions with their info for force simulation + interface LabelNode extends SimulationNodeDatum { + id: string + name: string + x: number + y: number + targetX: number + targetY: number + width: number + height: number + isFound: boolean + isSmall: boolean + players?: string[] + } + + const allLabelNodes: LabelNode[] = [] + + mapData.regions.forEach((region) => { + // Calculate centroid pixel position directly from SVG coordinates + // Account for SVG offset within container (padding, etc.) + const centroidPixelX = (region.center[0] - viewBoxX) * scaleX + svgOffsetX + const centroidPixelY = (region.center[1] - viewBoxY) * scaleY + svgOffsetY + + const pixelX = centroidPixelX + const pixelY = centroidPixelY + + // Get the actual region path element to measure its TRUE screen dimensions + const regionPath = svgRef.current?.querySelector(`path[data-region-id="${region.id}"]`) + if (!regionPath) return + + const pathRect = regionPath.getBoundingClientRect() + const pixelWidth = pathRect.width + const pixelHeight = pathRect.height + const pixelArea = pathRect.width * pathRect.height + + // Check if this is a small region using ACTUAL screen pixels + const isSmall = pixelWidth < 10 || pixelHeight < 10 || pixelArea < 100 + + // Debug logging for ALL regions to diagnose threshold issues + console.log( + `[MapRenderer] ${isSmall ? '🔍 SMALL' : '📍 Regular'}: ${region.name} - ` + + `W:${pixelWidth.toFixed(1)}px H:${pixelHeight.toFixed(1)}px ` + + `Area:${pixelArea.toFixed(0)}px² (threshold: 10px)` + ) + + // Collect label nodes for regions that need labels + // Only show arrow labels for small regions if showArrows flag is enabled + const shouldShowLabel = regionsFound.includes(region.id) || (isSmall && showArrows) + + if (shouldShowLabel) { + const players = regionsFound.includes(region.id) + ? guessHistory + .filter((guess) => guess.regionId === region.id && guess.correct) + .map((guess) => guess.playerId) + .filter((playerId, index, self) => self.indexOf(playerId) === index) + : undefined + + const labelWidth = region.name.length * 7 + 15 + const labelHeight = isSmall ? 25 : 30 + + allLabelNodes.push({ + id: region.id, + name: region.name, + x: pixelX, // Start directly on region - will spread out to avoid collisions + y: pixelY, + targetX: pixelX, // Anchor point to pull back toward + targetY: pixelY, + width: labelWidth, + height: labelHeight, + isFound: regionsFound.includes(region.id), + isSmall, + players, + }) + } + }) + + // Add region obstacles to repel labels away from the map itself + interface ObstacleNode extends SimulationNodeDatum { + id: string + x: number + y: number + isObstacle: true + radius: number + } + + const obstacleNodes: ObstacleNode[] = [] + + // Add all regions (including unlabeled ones) as obstacles (if enabled) + if (useObstacles) { + mapData.regions.forEach((region) => { + const ghostElement = svgRef.current?.querySelector(`[data-ghost-region="${region.id}"]`) + if (!ghostElement) return + + const ghostRect = ghostElement.getBoundingClientRect() + const pixelX = ghostRect.left - containerRect.left + ghostRect.width / 2 + const pixelY = ghostRect.top - containerRect.top + ghostRect.height / 2 + + const regionPath = svgRef.current?.querySelector(`path[data-region-id="${region.id}"]`) + if (!regionPath) return + + const pathRect = regionPath.getBoundingClientRect() + const regionRadius = Math.max(pathRect.width, pathRect.height) / 2 + + obstacleNodes.push({ + id: `obstacle-${region.id}`, + isObstacle: true, + x: pixelX, + y: pixelY, + radius: regionRadius + obstaclePadding, + }) }) } - }) - return labels - }, [mapData, regionsFound]) + // Combine labels and obstacles for simulation + const allNodes = [...allLabelNodes, ...obstacleNodes] + + // Run force simulation to position labels without overlaps + if (allLabelNodes.length > 0) { + const simulation = forceSimulation(allNodes) + .force( + 'collide', + forceCollide().radius((d) => { + if ('isObstacle' in d && d.isObstacle) { + return (d as ObstacleNode).radius + } + const label = d as LabelNode + return Math.max(label.width, label.height) / 2 + collisionPadding + }) + ) + .force( + 'x', + forceX((d) => { + if ('isObstacle' in d && d.isObstacle) return d.x + return (d as LabelNode).targetX + }).strength(centeringStrength) + ) + .force( + 'y', + forceY((d) => { + if ('isObstacle' in d && d.isObstacle) return d.y + return (d as LabelNode).targetY + }).strength(centeringStrength) + ) + .stop() + + // Run simulation - labels start on regions and only move as needed + for (let i = 0; i < simulationIterations; i++) { + simulation.tick() + } + + // Helper: Calculate arrow start point on label edge closest to region + const getArrowStartPoint = ( + labelX: number, + labelY: number, + labelWidth: number, + labelHeight: number, + targetX: number, + targetY: number + ): { x: number; y: number } => { + // Direction from label to region + const dx = targetX - labelX + const dy = targetY - labelY + + // Label edges + const halfWidth = labelWidth / 2 + const halfHeight = labelHeight / 2 + + // Calculate intersection with label box + // Use parametric line equation: point = (labelX, labelY) + t * (dx, dy) + // Find t where line intersects rectangle edges + + let bestT = 0 + const epsilon = 1e-10 + + // Check each edge + if (Math.abs(dx) > epsilon) { + // Right edge: x = labelX + halfWidth + const tRight = halfWidth / dx + if (tRight > 0 && tRight <= 1) { + const y = labelY + tRight * dy + if (Math.abs(y - labelY) <= halfHeight) { + bestT = tRight + } + } + // Left edge: x = labelX - halfWidth + const tLeft = -halfWidth / dx + if (tLeft > 0 && tLeft <= 1) { + const y = labelY + tLeft * dy + if (Math.abs(y - labelY) <= halfHeight) { + if (bestT === 0 || tLeft < bestT) bestT = tLeft + } + } + } + + if (Math.abs(dy) > epsilon) { + // Bottom edge: y = labelY + halfHeight + const tBottom = halfHeight / dy + if (tBottom > 0 && tBottom <= 1) { + const x = labelX + tBottom * dx + if (Math.abs(x - labelX) <= halfWidth) { + if (bestT === 0 || tBottom < bestT) bestT = tBottom + } + } + // Top edge: y = labelY - halfHeight + const tTop = -halfHeight / dy + if (tTop > 0 && tTop <= 1) { + const x = labelX + tTop * dx + if (Math.abs(x - labelX) <= halfWidth) { + if (bestT === 0 || tTop < bestT) bestT = tTop + } + } + } + + return { + x: labelX + bestT * dx, + y: labelY + bestT * dy, + } + } + + // Extract positions from simulation results + for (const node of allLabelNodes) { + if (node.isSmall) { + const arrowStart = getArrowStartPoint( + node.x!, + node.y!, + node.width, + node.height, + node.targetX, + node.targetY + ) + + smallPositions.push({ + regionId: node.id, + regionName: node.name, + isFound: node.isFound, + labelX: node.x!, + labelY: node.y!, + lineStartX: arrowStart.x, + lineStartY: arrowStart.y, + lineEndX: node.targetX, + lineEndY: node.targetY, + }) + } else { + positions.push({ + regionId: node.id, + regionName: node.name, + x: node.x!, + y: node.y!, + players: node.players || [], + }) + } + } + } + + setLabelPositions(positions) + setSmallRegionLabelPositions(smallPositions) + + // Debug: Log summary + console.log('[MapRenderer] Label positions updated:', { + mapId: mapData.id, + totalRegions: mapData.regions.length, + regularLabels: positions.length, + smallRegionLabels: smallPositions.length, + viewBox: mapData.viewBox, + svgDimensions, + }) + } + + // Small delay to ensure ghost elements are rendered + const timeoutId = setTimeout(updateLabelPositions, 0) + + // Update on resize + window.addEventListener('resize', updateLabelPositions) + return () => { + clearTimeout(timeoutId) + window.removeEventListener('resize', updateLabelPositions) + } + }, [mapData, regionsFound, guessHistory, svgDimensions]) + + // Calculate viewBox dimensions for label offset calculations + const viewBoxParts = mapData.viewBox.split(' ').map(Number) + const viewBoxWidth = viewBoxParts[2] || 1000 + const viewBoxHeight = viewBoxParts[3] || 1000 const showOutline = (region: MapRegion): boolean => { // Easy mode: always show outlines @@ -133,10 +488,132 @@ export function MapRenderer({ return hoveredRegion === region.id || regionsFound.includes(region.id) } + // Handle mouse movement to track cursor and show magnifier when needed + const handleMouseMove = (e: React.MouseEvent) => { + if (!svgRef.current || !containerRef.current) return + + const containerRect = containerRef.current.getBoundingClientRect() + const svgRect = svgRef.current.getBoundingClientRect() + + // Get cursor position relative to container + const cursorX = e.clientX - containerRect.left + const cursorY = e.clientY - containerRect.top + + // Check if cursor is over the SVG + const isOverSvg = + e.clientX >= svgRect.left && + e.clientX <= svgRect.right && + e.clientY >= svgRect.top && + e.clientY <= svgRect.bottom + + if (!isOverSvg) { + setShowMagnifier(false) + setTargetOpacity(0) + setCursorPosition(null) + return + } + + setCursorPosition({ x: cursorX, y: cursorY }) + + // Define 50px × 50px detection box around cursor + const detectionBoxSize = 50 + const halfBox = detectionBoxSize / 2 + + // Count regions in the detection box and track their sizes + let regionsInBox = 0 + let hasSmallRegion = false + let totalRegionArea = 0 + let smallestRegionSize = Infinity + + mapData.regions.forEach((region) => { + const regionPath = svgRef.current?.querySelector(`path[data-region-id="${region.id}"]`) + if (!regionPath) return + + const pathRect = regionPath.getBoundingClientRect() + + // Check if region overlaps with detection box + const boxLeft = e.clientX - halfBox + const boxRight = e.clientX + halfBox + const boxTop = e.clientY - halfBox + const boxBottom = e.clientY + halfBox + + const regionLeft = pathRect.left + const regionRight = pathRect.right + const regionTop = pathRect.top + const regionBottom = pathRect.bottom + + const overlaps = + regionLeft < boxRight && + regionRight > boxLeft && + regionTop < boxBottom && + regionBottom > boxTop + + if (overlaps) { + regionsInBox++ + + // Check if this region is very small (stricter threshold) + const pixelWidth = pathRect.width + const pixelHeight = pathRect.height + const pixelArea = pathRect.width * pathRect.height + const isVerySmall = pixelWidth < 8 || pixelHeight < 8 || pixelArea < 64 + + if (isVerySmall) { + hasSmallRegion = true + } + + // Track region sizes for adaptive zoom + totalRegionArea += pixelArea + const regionSize = Math.min(pixelWidth, pixelHeight) + smallestRegionSize = Math.min(smallestRegionSize, regionSize) + } + }) + + // Calculate adaptive zoom level based on region density and size + // Base zoom: 8x + // More regions = more zoom (up to +8x for 10+ regions) + // Smaller regions = more zoom (up to +8x for very tiny regions) + // Stricter threshold: 7+ regions OR very small regions (< 8px) + const shouldShow = regionsInBox >= 7 || hasSmallRegion + + if (shouldShow) { + let adaptiveZoom = 8 // Base zoom + + // Add zoom based on region count (crowded areas need more zoom) + const countFactor = Math.min(regionsInBox / 10, 1) // 0 to 1 + adaptiveZoom += countFactor * 8 + + // Add zoom based on smallest region size (tiny regions need more zoom) + if (smallestRegionSize !== Infinity) { + const sizeFactor = Math.max(0, 1 - smallestRegionSize / 20) // 0 to 1 (1 = very small) + adaptiveZoom += sizeFactor * 8 + } + + // Clamp zoom between 8x and 24x + adaptiveZoom = Math.max(8, Math.min(24, adaptiveZoom)) + + setTargetZoom(adaptiveZoom) + setShowMagnifier(true) + setTargetOpacity(1) + } else { + setShowMagnifier(false) + setTargetOpacity(0) + } + } + + const handleMouseLeave = () => { + setShowMagnifier(false) + setTargetOpacity(0) + setCursorPosition(null) + } + return (
{/* Region path */} setHoveredRegion(region.id)} onMouseLeave={() => setHoveredRegion(null)} @@ -184,85 +661,15 @@ export function MapRenderer({ }} /> - {/* Region label (show if found) */} - {regionsFound.includes(region.id) && ( - - {region.name} - - )} - - ))} - - {/* Small region labels with arrows */} - {smallRegionLabels.map((label) => ( - - {/* Arrow line from label to region center */} - - - {/* Label background */} - onRegionClick(label.regionId, label.regionName)} - onMouseEnter={() => setHoveredRegion(label.regionId)} - onMouseLeave={() => setHoveredRegion(null)} - /> - - {/* Label text */} - onRegionClick(label.regionId, label.regionName)} - onMouseEnter={() => setHoveredRegion(label.regionId)} - onMouseLeave={() => setHoveredRegion(null)} - > - {label.regionName} - ))} @@ -271,8 +678,377 @@ export function MapRenderer({ + + + + + {/* Magnifier region indicator on main map */} + {showMagnifier && cursorPosition && svgRef.current && containerRef.current && ( + { + const containerRect = containerRef.current!.getBoundingClientRect() + const svgRect = svgRef.current!.getBoundingClientRect() + const viewBoxParts = mapData.viewBox.split(' ').map(Number) + const viewBoxX = viewBoxParts[0] || 0 + const viewBoxY = viewBoxParts[1] || 0 + const viewBoxWidth = viewBoxParts[2] || 1000 + const viewBoxHeight = viewBoxParts[3] || 1000 + const scaleX = viewBoxWidth / svgRect.width + const scaleY = viewBoxHeight / svgRect.height + const svgOffsetX = svgRect.left - containerRect.left + const svgOffsetY = svgRect.top - containerRect.top + const cursorSvgX = (cursorPosition.x - svgOffsetX) * scaleX + viewBoxX + const magnifiedWidth = viewBoxWidth / zoom + return cursorSvgX - magnifiedWidth / 2 + })} + y={magnifierSpring.zoom.to((zoom) => { + const containerRect = containerRef.current!.getBoundingClientRect() + const svgRect = svgRef.current!.getBoundingClientRect() + const viewBoxParts = mapData.viewBox.split(' ').map(Number) + const viewBoxX = viewBoxParts[0] || 0 + const viewBoxY = viewBoxParts[1] || 0 + const viewBoxWidth = viewBoxParts[2] || 1000 + const viewBoxHeight = viewBoxParts[3] || 1000 + const scaleX = viewBoxWidth / svgRect.width + const scaleY = viewBoxHeight / svgRect.height + const svgOffsetX = svgRect.left - containerRect.left + const svgOffsetY = svgRect.top - containerRect.top + const cursorSvgY = (cursorPosition.y - svgOffsetY) * scaleY + viewBoxY + const magnifiedHeight = viewBoxHeight / zoom + return cursorSvgY - magnifiedHeight / 2 + })} + width={magnifierSpring.zoom.to((zoom) => { + const viewBoxParts = mapData.viewBox.split(' ').map(Number) + const viewBoxWidth = viewBoxParts[2] || 1000 + return viewBoxWidth / zoom + })} + height={magnifierSpring.zoom.to((zoom) => { + const viewBoxParts = mapData.viewBox.split(' ').map(Number) + const viewBoxHeight = viewBoxParts[3] || 1000 + return viewBoxHeight / zoom + })} + fill="none" + stroke={isDark ? '#60a5fa' : '#3b82f6'} + strokeWidth={viewBoxWidth / 500} + vectorEffect="non-scaling-stroke" + strokeDasharray="5,5" + pointerEvents="none" + opacity={0.8} + /> + )} + + {/* HTML labels positioned absolutely over the SVG */} + {labelPositions.map((label) => ( +
+ {/* Region name */} +
+ {label.regionName} +
+ + {/* Player avatars */} + {label.players.length > 0 && ( +
+ {label.players.map((playerId) => { + const player = playerMetadata[playerId] + if (!player) return null + + return ( +
+ {player.emoji} +
+ ) + })} +
+ )} +
+ ))} + + {/* Small region labels with arrows positioned absolutely over the SVG */} + {smallRegionLabelPositions.map((label) => ( +
+ {/* Arrow line - use SVG positioned absolutely */} + + + {/* Debug: Show arrow endpoint (region centroid) */} + + + + {/* Label box and text */} +
onRegionClick(label.regionId, label.regionName)} + onMouseEnter={() => setHoveredRegion(label.regionId)} + onMouseLeave={() => setHoveredRegion(null)} + > + {/* Background box */} +
+ {label.regionName} +
+
+
+ ))} + + {/* Magnifier Window */} + {showMagnifier && cursorPosition && svgRef.current && containerRef.current && ( + { + const containerRect = containerRef.current!.getBoundingClientRect() + const magnifierWidth = containerRect.width * 0.5 + const magnifierHeight = magnifierWidth / 2 + + // Determine which quadrant cursor is in + const isLeftHalf = cursorPosition.x < containerRect.width / 2 + const isTopHalf = cursorPosition.y < containerRect.height / 2 + + // Position in opposite corner from cursor + return { + top: isTopHalf ? 'auto' : '20px', + bottom: isTopHalf ? '20px' : 'auto', + left: isLeftHalf ? 'auto' : '20px', + right: isLeftHalf ? '20px' : 'auto', + } + })(), + width: '50%', + aspectRatio: '2/1', + border: `3px solid ${isDark ? '#60a5fa' : '#3b82f6'}`, + borderRadius: '12px', + overflow: 'hidden', + pointerEvents: 'none', + zIndex: 100, + boxShadow: '0 10px 40px rgba(0, 0, 0, 0.5)', + background: isDark ? '#111827' : '#f3f4f6', + opacity: magnifierSpring.opacity, + }} + > + { + // Calculate magnified viewBox centered on cursor + const containerRect = containerRef.current!.getBoundingClientRect() + const svgRect = svgRef.current!.getBoundingClientRect() + + // Convert cursor position to SVG coordinates + const viewBoxParts = mapData.viewBox.split(' ').map(Number) + const viewBoxX = viewBoxParts[0] || 0 + const viewBoxY = viewBoxParts[1] || 0 + const viewBoxWidth = viewBoxParts[2] || 1000 + const viewBoxHeight = viewBoxParts[3] || 1000 + + const scaleX = viewBoxWidth / svgRect.width + const scaleY = viewBoxHeight / svgRect.height + + // Cursor position relative to SVG + const svgOffsetX = svgRect.left - containerRect.left + const svgOffsetY = svgRect.top - containerRect.top + const cursorSvgX = (cursorPosition.x - svgOffsetX) * scaleX + viewBoxX + const cursorSvgY = (cursorPosition.y - svgOffsetY) * scaleY + viewBoxY + + // Magnified view: adaptive zoom (using animated value) + const magnifiedWidth = viewBoxWidth / zoom + const magnifiedHeight = viewBoxHeight / zoom + + // Center the magnified viewBox on the cursor + const magnifiedViewBoxX = cursorSvgX - magnifiedWidth / 2 + const magnifiedViewBoxY = cursorSvgY - magnifiedHeight / 2 + + return `${magnifiedViewBoxX} ${magnifiedViewBoxY} ${magnifiedWidth} ${magnifiedHeight}` + })} + style={{ + width: '100%', + height: '100%', + }} + > + {/* Background */} + + + {/* Render all regions in magnified view */} + {mapData.regions.map((region) => ( + + ))} + + {/* Crosshair at cursor position */} + + {(() => { + const containerRect = containerRef.current!.getBoundingClientRect() + const svgRect = svgRef.current!.getBoundingClientRect() + const viewBoxParts = mapData.viewBox.split(' ').map(Number) + const viewBoxX = viewBoxParts[0] || 0 + const viewBoxY = viewBoxParts[1] || 0 + const viewBoxWidth = viewBoxParts[2] || 1000 + const viewBoxHeight = viewBoxParts[3] || 1000 + const scaleX = viewBoxWidth / svgRect.width + const scaleY = viewBoxHeight / svgRect.height + const svgOffsetX = svgRect.left - containerRect.left + const svgOffsetY = svgRect.top - containerRect.top + const cursorSvgX = (cursorPosition.x - svgOffsetX) * scaleX + viewBoxX + const cursorSvgY = (cursorPosition.y - svgOffsetY) * scaleY + viewBoxY + + return ( + <> + + + + + ) + })()} + + + + {/* Magnifier label */} + + {magnifierSpring.zoom.to((z) => `${z.toFixed(1)}× Zoom`)} + + + )}
) } diff --git a/apps/web/src/arcade-games/know-your-world/components/PlayingPhase.tsx b/apps/web/src/arcade-games/know-your-world/components/PlayingPhase.tsx index 7297621b..caebdd74 100644 --- a/apps/web/src/arcade-games/know-your-world/components/PlayingPhase.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/PlayingPhase.tsx @@ -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() { - - {/* Turn Indicator (for turn-based mode) */} - {state.gameMode === 'turn-based' && ( -
- Current Turn: {state.playerMetadata[state.currentPlayer]?.name || state.currentPlayer} -
- )} ) }