From 7e55953eeec79be431e3f8f643e589322946de0c Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Wed, 3 Dec 2025 09:26:13 -0600 Subject: [PATCH] feat(know-your-world): wire interaction state machine to MapRenderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add state machine integration with sync effects for incremental migration: - Call useInteractionStateMachine hook in MapRenderer - Add sync effects to drive state machine from existing boolean state: - Precision mode sync (pointerLocked, isReleasingPointerLock) - Magnifier visibility sync (showMagnifier) - Magnifier expanded sync (isMagnifierExpanded) - Add debug logging to compare machine state vs boolean state - Clean up unused imports (CompassCrosshair, FeedbackType) - Organize imports per Biome rules The state machine now runs in parallel with existing state. Next steps: 1. Test sync effects work correctly in dev console 2. Migrate handlers to dispatch events directly to state machine 3. Replace rendering conditionals with state machine checks 4. Remove old boolean state once migration is complete 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../components/MapRenderer.tsx | 1365 +++++++++-------- .../features/interaction/index.ts | 6 +- .../interaction/useInteractionStateMachine.ts | 7 +- .../features/labels/useD3ForceLabels.ts | 2 +- .../letter-confirmation/LetterDisplay.tsx | 20 +- .../letter-confirmation/letterUtils.ts | 10 +- .../useLetterConfirmation.ts | 12 +- .../features/magnifier/MagnifierControls.tsx | 12 +- .../features/magnifier/MagnifierRegions.tsx | 16 +- .../features/magnifier/ZoomLines.tsx | 25 +- .../features/magnifier/useMagnifierTouch.ts | 13 +- .../features/shared/MapRendererContext.tsx | 4 +- .../know-your-world/features/shared/types.ts | 9 +- .../features/shared/viewportUtils.ts | 16 +- 14 files changed, 764 insertions(+), 753 deletions(-) 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 e2d0708f..1b345be5 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 @@ -7,11 +7,9 @@ import { useTheme } from '@/contexts/ThemeContext' import { useVisualDebugSafe } from '@/contexts/VisualDebugContext' import type { ContinentId } from '../continents' import { usePulsingAnimation } from '../features/animations' -import { CompassCrosshair, CursorOverlay } from '../features/cursor' +import { CursorOverlay } from '../features/cursor' import { DebugAutoZoomPanel, HotColdDebugPanel, SafeZoneDebugPanel } from '../features/debug' -import { MapRendererProvider, type MapRendererContextValue } from '../features/map-renderer' -import { OtherPlayerCursors } from '../features/multiplayer' -import { RegionLayer } from '../features/regions' +import { useInteractionStateMachine } from '../features/interaction' import { getRenderedViewport, LabelLayer, useD3ForceLabels } from '../features/labels' import { applyPanDelta, @@ -30,7 +28,10 @@ import { useMagnifierState, ZoomLines, } from '../features/magnifier' +import { type MapRendererContextValue, MapRendererProvider } from '../features/map-renderer' +import { OtherPlayerCursors } from '../features/multiplayer' import { usePrecisionCalculations } from '../features/precision' +import { RegionLayer } from '../features/regions' import { useCanUsePrecisionMode, useHasAnyFinePointer, @@ -60,7 +61,6 @@ import { useKnowYourWorld } from '../Provider' import type { MapData, MapRegion } from '../types' import { type BoundingBox as DebugBoundingBox, findOptimalZoom } from '../utils/adaptiveZoomSearch' import { CELEBRATION_TIMING, classifyCelebration } from '../utils/celebration' -import type { FeedbackType } from '../utils/hotColdPhrases' import { getHeatBorderColors, getHeatCrosshairStyle } from '../utils/hotColdStyles' import { calculateMaxZoomAtThreshold, @@ -341,6 +341,10 @@ export function MapRenderer({ const canUsePrecisionMode = useCanUsePrecisionMode() // For precision mode UI/behavior const hasAnyFinePointer = useHasAnyFinePointer() // For hot/cold feedback visibility + // Interaction state machine - replaces scattered boolean flags with explicit states + // Currently running in parallel with existing state for incremental migration + const interactionMachine = useInteractionStateMachine() + // Memoize pointer lock callbacks to prevent render loop const handleLockAcquired = useCallback(() => { // Save initial cursor position @@ -470,6 +474,67 @@ export function MapRenderer({ } }, [isMagnifierExpanded, targetZoom]) + // ========================================================================== + // State Machine Sync Effects (for incremental migration) + // These effects drive the state machine from existing boolean state changes. + // Once migration is complete, handlers will dispatch directly to the machine. + // ========================================================================== + + // Sync precision mode state + useEffect(() => { + if (pointerLocked) { + interactionMachine.send({ type: 'REQUEST_PRECISION' }) + } else if (isReleasingPointerLock) { + interactionMachine.send({ type: 'PRECISION_ESCAPE_BOUNDARY' }) + } + }, [pointerLocked, isReleasingPointerLock, interactionMachine]) + + // Sync magnifier visibility + useEffect(() => { + if (showMagnifier) { + interactionMachine.send({ type: 'SHOW_MAGNIFIER' }) + } else { + interactionMachine.send({ type: 'DISMISS_MAGNIFIER' }) + } + }, [showMagnifier, interactionMachine]) + + // Sync magnifier expanded state + useEffect(() => { + if (isMagnifierExpanded) { + interactionMachine.send({ type: 'EXPAND_MAGNIFIER' }) + } else { + interactionMachine.send({ type: 'COLLAPSE_MAGNIFIER' }) + } + }, [isMagnifierExpanded, interactionMachine]) + + // Debug: Log state machine state changes and verify sync (only in dev) + useEffect(() => { + if (process.env.NODE_ENV === 'development') { + // Compare state machine derived values with existing boolean state + const machineShowMagnifier = interactionMachine.showMagnifier + const machineIsPrecision = interactionMachine.isPrecisionMode + const machineIsReleasingPrecision = interactionMachine.isReleasingPrecision + + // Log state transitions with comparison + console.log('[StateMachine]', { + state: interactionMachine.state, + machine: { showMagnifier: machineShowMagnifier, isPrecision: machineIsPrecision }, + boolean: { showMagnifier, pointerLocked, isReleasingPointerLock }, + }) + + // Warn on sync divergence (helps identify missing sync effects) + // Note: Divergence is expected during transitions due to effect timing + } + }, [ + interactionMachine.state, + interactionMachine.showMagnifier, + interactionMachine.isPrecisionMode, + interactionMachine.isReleasingPrecision, + showMagnifier, + pointerLocked, + isReleasingPointerLock, + ]) + // Give up reveal animation state const [giveUpFlashProgress, setGiveUpFlashProgress] = useState(0) // 0-1 pulsing value const [isGiveUpAnimating, setIsGiveUpAnimating] = useState(false) // Track if animation in progress @@ -2859,41 +2924,41 @@ export function MapRenderer({
- `scale(${s}) translate(${tx / s}px, ${ty / s}px)` - ), + backgroundSize: '100px 100px', }} > - {/* Render all regions (included + excluded) */} - `scale(${s}) translate(${tx / s}px, ${ty / s}px)` + ), + }} + > + {/* Render all regions (included + excluded) */} + + + {/* Debug: Render bounding boxes (only if enabled) */} + {effectiveShowDebugBoundingBoxes && + debugBoundingBoxes.map((bbox) => { + // Color based on acceptance and importance + // Green = accepted, Orange = high importance, Yellow = medium, Gray = low + const importance = bbox.importance ?? 0 + let strokeColor = '#888888' // Default gray for low importance + let fillColor = 'rgba(136, 136, 136, 0.1)' + + if (bbox.wasAccepted) { + strokeColor = '#00ff00' // Green for accepted region + fillColor = 'rgba(0, 255, 0, 0.15)' + } else if (importance > 1.5) { + strokeColor = '#ff6600' // Orange for high importance (2.0× boost + close) + fillColor = 'rgba(255, 102, 0, 0.1)' + } else if (importance > 0.5) { + strokeColor = '#ffcc00' // Yellow for medium importance + fillColor = 'rgba(255, 204, 0, 0.1)' + } + + return ( + + + + ) + })} + + {/* Arrow marker definition */} + + + + + + + + + {/* Player emoji patterns for region backgrounds */} + {Object.values(playerMetadata).map((player) => ( + + + + {player.emoji} + + + ))} + + + {/* Magnifier region indicator on main map */} + {showMagnifier && cursorPosition && svgRef.current && containerRef.current && ( + { + const containerRect = containerRef.current!.getBoundingClientRect() + const svgRect = svgRef.current!.getBoundingClientRect() + // Account for preserveAspectRatio letterboxing + const viewport = getRenderedViewport( + svgRect, + parsedViewBox.x, + parsedViewBox.y, + parsedViewBox.width, + parsedViewBox.height + ) + const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX + const cursorSvgX = + (cursorPosition.x - svgOffsetX) / viewport.scale + parsedViewBox.x + // Calculate leftover dimensions for magnifier sizing + const leftoverW = + containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right + const leftoverH = + containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom + const { width: magnifiedWidth } = getAdjustedMagnifiedDimensions( + parsedViewBox.width, + parsedViewBox.height, + zoom, + leftoverW, + leftoverH + ) + return cursorSvgX - magnifiedWidth / 2 + })} + y={zoomSpring.to((zoom: number) => { + const containerRect = containerRef.current!.getBoundingClientRect() + const svgRect = svgRef.current!.getBoundingClientRect() + // Account for preserveAspectRatio letterboxing + const viewport = getRenderedViewport( + svgRect, + parsedViewBox.x, + parsedViewBox.y, + parsedViewBox.width, + parsedViewBox.height + ) + const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY + const cursorSvgY = + (cursorPosition.y - svgOffsetY) / viewport.scale + parsedViewBox.y + // Calculate leftover dimensions for magnifier sizing + const leftoverW = + containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right + const leftoverH = + containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom + const { height: magnifiedHeight } = getAdjustedMagnifiedDimensions( + parsedViewBox.width, + parsedViewBox.height, + zoom, + leftoverW, + leftoverH + ) + return cursorSvgY - magnifiedHeight / 2 + })} + width={zoomSpring.to((zoom: number) => { + const containerRect = containerRef.current!.getBoundingClientRect() + // Calculate leftover dimensions for magnifier sizing + const leftoverW = + containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right + const leftoverH = + containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom + const { width } = getAdjustedMagnifiedDimensions( + parsedViewBox.width, + parsedViewBox.height, + zoom, + leftoverW, + leftoverH + ) + return width + })} + height={zoomSpring.to((zoom: number) => { + const containerRect = containerRef.current!.getBoundingClientRect() + // Calculate leftover dimensions for magnifier sizing + const leftoverW = + containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right + const leftoverH = + containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom + const { height } = getAdjustedMagnifiedDimensions( + parsedViewBox.width, + parsedViewBox.height, + zoom, + leftoverW, + leftoverH + ) + return height + })} + fill="none" + stroke={isDark ? '#60a5fa' : '#3b82f6'} + strokeWidth={parsedViewBox.width / 500} + vectorEffect="non-scaling-stroke" + strokeDasharray="5,5" + pointerEvents="none" + opacity={0.8} + /> + )} + + + {/* Labels for found regions - rendered via LabelLayer component */} + - {/* Debug: Render bounding boxes (only if enabled) */} + {/* Debug: Bounding box labels as HTML overlays */} {effectiveShowDebugBoundingBoxes && + containerRef.current && + svgRef.current && debugBoundingBoxes.map((bbox) => { - // Color based on acceptance and importance - // Green = accepted, Orange = high importance, Yellow = medium, Gray = low const importance = bbox.importance ?? 0 - let strokeColor = '#888888' // Default gray for low importance - let fillColor = 'rgba(136, 136, 136, 0.1)' + let strokeColor = '#888888' if (bbox.wasAccepted) { - strokeColor = '#00ff00' // Green for accepted region - fillColor = 'rgba(0, 255, 0, 0.15)' + strokeColor = '#00ff00' } else if (importance > 1.5) { - strokeColor = '#ff6600' // Orange for high importance (2.0× boost + close) - fillColor = 'rgba(255, 102, 0, 0.1)' + strokeColor = '#ff6600' } else if (importance > 0.5) { - strokeColor = '#ffcc00' // Yellow for medium importance - fillColor = 'rgba(255, 204, 0, 0.1)' + strokeColor = '#ffcc00' } + // Convert SVG coordinates to pixel coordinates (accounting for preserveAspectRatio) + const containerRect = containerRef.current!.getBoundingClientRect() + const svgRect = svgRef.current!.getBoundingClientRect() + + const viewport = getRenderedViewport( + svgRect, + parsedViewBox.x, + parsedViewBox.y, + parsedViewBox.width, + parsedViewBox.height + ) + const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX + const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY + + // Convert bbox center from SVG coords to pixels + const centerX = + (bbox.x + bbox.width / 2 - parsedViewBox.x) * viewport.scale + svgOffsetX + const centerY = + (bbox.y + bbox.height / 2 - parsedViewBox.y) * viewport.scale + svgOffsetY + return ( - - - +
+
{bbox.regionId}
+
{importance.toFixed(2)}
+
) })} - {/* Arrow marker definition */} - - - - - - - + {/* Custom cursor and heat crosshair overlays */} + - {/* Player emoji patterns for region backgrounds */} - {Object.values(playerMetadata).map((player) => ( - - - - {player.emoji} - - - ))} - - - {/* Magnifier region indicator on main map */} - {showMagnifier && cursorPosition && svgRef.current && containerRef.current && ( - { - const containerRect = containerRef.current!.getBoundingClientRect() - const svgRect = svgRef.current!.getBoundingClientRect() - // Account for preserveAspectRatio letterboxing - const viewport = getRenderedViewport( - svgRect, - parsedViewBox.x, - parsedViewBox.y, - parsedViewBox.width, - parsedViewBox.height - ) - const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX - const cursorSvgX = (cursorPosition.x - svgOffsetX) / viewport.scale + parsedViewBox.x - // Calculate leftover dimensions for magnifier sizing - const leftoverW = - containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right - const leftoverH = - containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom - const { width: magnifiedWidth } = getAdjustedMagnifiedDimensions( - parsedViewBox.width, - parsedViewBox.height, - zoom, - leftoverW, - leftoverH - ) - return cursorSvgX - magnifiedWidth / 2 - })} - y={zoomSpring.to((zoom: number) => { - const containerRect = containerRef.current!.getBoundingClientRect() - const svgRect = svgRef.current!.getBoundingClientRect() - // Account for preserveAspectRatio letterboxing - const viewport = getRenderedViewport( - svgRect, - parsedViewBox.x, - parsedViewBox.y, - parsedViewBox.width, - parsedViewBox.height - ) - const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY - const cursorSvgY = (cursorPosition.y - svgOffsetY) / viewport.scale + parsedViewBox.y - // Calculate leftover dimensions for magnifier sizing - const leftoverW = - containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right - const leftoverH = - containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom - const { height: magnifiedHeight } = getAdjustedMagnifiedDimensions( - parsedViewBox.width, - parsedViewBox.height, - zoom, - leftoverW, - leftoverH - ) - return cursorSvgY - magnifiedHeight / 2 - })} - width={zoomSpring.to((zoom: number) => { - const containerRect = containerRef.current!.getBoundingClientRect() - // Calculate leftover dimensions for magnifier sizing - const leftoverW = - containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right - const leftoverH = - containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom - const { width } = getAdjustedMagnifiedDimensions( - parsedViewBox.width, - parsedViewBox.height, - zoom, - leftoverW, - leftoverH - ) - return width - })} - height={zoomSpring.to((zoom: number) => { - const containerRect = containerRef.current!.getBoundingClientRect() - // Calculate leftover dimensions for magnifier sizing - const leftoverW = - containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right - const leftoverH = - containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom - const { height } = getAdjustedMagnifiedDimensions( - parsedViewBox.width, - parsedViewBox.height, - zoom, - leftoverW, - leftoverH - ) - return height - })} - fill="none" - stroke={isDark ? '#60a5fa' : '#3b82f6'} - strokeWidth={parsedViewBox.width / 500} - vectorEffect="non-scaling-stroke" - strokeDasharray="5,5" - pointerEvents="none" - opacity={0.8} - /> - )} - - - {/* Labels for found regions - rendered via LabelLayer component */} - - - {/* Debug: Bounding box labels as HTML overlays */} - {effectiveShowDebugBoundingBoxes && - containerRef.current && - svgRef.current && - debugBoundingBoxes.map((bbox) => { - const importance = bbox.importance ?? 0 - let strokeColor = '#888888' - - if (bbox.wasAccepted) { - strokeColor = '#00ff00' - } else if (importance > 1.5) { - strokeColor = '#ff6600' - } else if (importance > 0.5) { - strokeColor = '#ffcc00' + {/* Magnifier overlay - centers on cursor position */} + {(() => { + if (!cursorPosition || !svgRef.current || !containerRef.current) { + return null } - // Convert SVG coordinates to pixel coordinates (accounting for preserveAspectRatio) - const containerRect = containerRef.current!.getBoundingClientRect() - const svgRect = svgRef.current!.getBoundingClientRect() + // Calculate magnifier size based on leftover rectangle (area not covered by UI) + const containerRect = containerRef.current.getBoundingClientRect() + const leftoverWidth = + containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right + const leftoverHeight = + containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom + // When expanded (during/after pinch-to-zoom), use full leftover area + // Otherwise use the normal calculated dimensions + const { width: normalWidth, height: normalHeight } = getMagnifierDimensions( + leftoverWidth, + leftoverHeight + ) + const magnifierWidthPx = isMagnifierExpanded ? leftoverWidth : normalWidth + const magnifierHeightPx = isMagnifierExpanded ? leftoverHeight : normalHeight + + // Pre-compute SVG coordinate transformation for crosshair and pixel grid + const svgRect = svgRef.current.getBoundingClientRect() const viewport = getRenderedViewport( svgRect, parsedViewBox.x, @@ -3192,402 +3342,329 @@ export function MapRenderer({ ) const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY + const cursorSvgX = (cursorPosition.x - svgOffsetX) / viewport.scale + parsedViewBox.x + const cursorSvgY = (cursorPosition.y - svgOffsetY) / viewport.scale + parsedViewBox.y - // Convert bbox center from SVG coords to pixels - const centerX = (bbox.x + bbox.width / 2 - parsedViewBox.x) * viewport.scale + svgOffsetX - const centerY = (bbox.y + bbox.height / 2 - parsedViewBox.y) * viewport.scale + svgOffsetY + // Get heat-based crosshair styling + const heatStyle = getHeatCrosshairStyle( + hotColdFeedbackType, + isDark, + effectiveHotColdEnabled + ) return ( -
{ + // When hot/cold is enabled, use heat-based colors + if (effectiveHotColdEnabled && hotColdFeedbackType) { + const heatColors = getHeatBorderColors(hotColdFeedbackType, isDark) + return `${heatColors.width}px solid ${heatColors.border}` + } + // Fall back to zoom-based coloring + return zoomSpring.to( + (zoom: number) => + zoom > HIGH_ZOOM_THRESHOLD + ? `4px solid ${isDark ? '#fbbf24' : '#f59e0b'}` // gold-400/gold-500 + : `3px solid ${isDark ? '#60a5fa' : '#3b82f6'}` // blue-400/blue-600 + ) + })(), + borderRadius: '12px', + overflow: 'hidden', + // Enable touch events on mobile for panning, but keep mouse events disabled + // This allows touch-based panning while not interfering with mouse-based interactions + pointerEvents: 'auto', + touchAction: 'none', // Prevent browser handling of touch gestures + zIndex: 100, + // Box shadow with heat glow when hot/cold is enabled + boxShadow: (() => { + if (effectiveHotColdEnabled && hotColdFeedbackType) { + const heatColors = getHeatBorderColors(hotColdFeedbackType, isDark) + return `0 10px 40px rgba(0, 0, 0, 0.3), 0 0 25px ${heatColors.glow}` + } + return zoomSpring.to((zoom: number) => + zoom > HIGH_ZOOM_THRESHOLD + ? '0 10px 40px rgba(251, 191, 36, 0.4), 0 0 20px rgba(251, 191, 36, 0.2)' // Gold glow + : '0 10px 40px rgba(0, 0, 0, 0.5)' + ) + })(), + background: isDark ? '#111827' : '#f3f4f6', + opacity: magnifierSpring.opacity, }} > -
{bbox.regionId}
-
{importance.toFixed(2)}
-
- ) - })} + { + // Calculate magnified viewBox centered on cursor + const containerRect = containerRef.current!.getBoundingClientRect() + const svgRect = svgRef.current!.getBoundingClientRect() - {/* Custom cursor and heat crosshair overlays */} - - - {/* Magnifier overlay - centers on cursor position */} - {(() => { - if (!cursorPosition || !svgRef.current || !containerRef.current) { - return null - } - - // Calculate magnifier size based on leftover rectangle (area not covered by UI) - const containerRect = containerRef.current.getBoundingClientRect() - const leftoverWidth = containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right - const leftoverHeight = - containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom - - // When expanded (during/after pinch-to-zoom), use full leftover area - // Otherwise use the normal calculated dimensions - const { width: normalWidth, height: normalHeight } = getMagnifierDimensions( - leftoverWidth, - leftoverHeight - ) - const magnifierWidthPx = isMagnifierExpanded ? leftoverWidth : normalWidth - const magnifierHeightPx = isMagnifierExpanded ? leftoverHeight : normalHeight - - // Pre-compute SVG coordinate transformation for crosshair and pixel grid - const svgRect = svgRef.current.getBoundingClientRect() - const viewport = getRenderedViewport( - svgRect, - parsedViewBox.x, - parsedViewBox.y, - parsedViewBox.width, - parsedViewBox.height - ) - const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX - const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY - const cursorSvgX = (cursorPosition.x - svgOffsetX) / viewport.scale + parsedViewBox.x - const cursorSvgY = (cursorPosition.y - svgOffsetY) / viewport.scale + parsedViewBox.y - - // Get heat-based crosshair styling - const heatStyle = getHeatCrosshairStyle( - hotColdFeedbackType, - isDark, - effectiveHotColdEnabled - ) - - return ( - { - // When hot/cold is enabled, use heat-based colors - if (effectiveHotColdEnabled && hotColdFeedbackType) { - const heatColors = getHeatBorderColors(hotColdFeedbackType, isDark) - return `${heatColors.width}px solid ${heatColors.border}` - } - // Fall back to zoom-based coloring - return zoomSpring.to( - (zoom: number) => - zoom > HIGH_ZOOM_THRESHOLD - ? `4px solid ${isDark ? '#fbbf24' : '#f59e0b'}` // gold-400/gold-500 - : `3px solid ${isDark ? '#60a5fa' : '#3b82f6'}` // blue-400/blue-600 - ) - })(), - borderRadius: '12px', - overflow: 'hidden', - // Enable touch events on mobile for panning, but keep mouse events disabled - // This allows touch-based panning while not interfering with mouse-based interactions - pointerEvents: 'auto', - touchAction: 'none', // Prevent browser handling of touch gestures - zIndex: 100, - // Box shadow with heat glow when hot/cold is enabled - boxShadow: (() => { - if (effectiveHotColdEnabled && hotColdFeedbackType) { - const heatColors = getHeatBorderColors(hotColdFeedbackType, isDark) - return `0 10px 40px rgba(0, 0, 0, 0.3), 0 0 25px ${heatColors.glow}` - } - return zoomSpring.to((zoom: number) => - zoom > HIGH_ZOOM_THRESHOLD - ? '0 10px 40px rgba(251, 191, 36, 0.4), 0 0 20px rgba(251, 191, 36, 0.2)' // Gold glow - : '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() - - // Use memoized parsedViewBox for coordinate conversion - const viewport = getRenderedViewport( - svgRect, - parsedViewBox.x, - parsedViewBox.y, - parsedViewBox.width, - parsedViewBox.height - ) - - // Center position relative to SVG (uses reveal center during give-up animation) - const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX - const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY - const cursorSvgX = - (cursorPosition.x - svgOffsetX) / viewport.scale + parsedViewBox.x - const cursorSvgY = - (cursorPosition.y - svgOffsetY) / viewport.scale + parsedViewBox.y - - // Magnified view: adjust dimensions to match magnifier container aspect ratio - // This eliminates letterboxing and ensures outline matches what's visible - // Use leftover dimensions for magnifier sizing - const leftoverW = - containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right - const leftoverH = - containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom - const { width: magnifiedWidth, height: magnifiedHeight } = - getAdjustedMagnifiedDimensions( + // Use memoized parsedViewBox for coordinate conversion + const viewport = getRenderedViewport( + svgRect, + parsedViewBox.x, + parsedViewBox.y, parsedViewBox.width, - parsedViewBox.height, - zoom, - leftoverW, - leftoverH + parsedViewBox.height ) - // Center the magnified viewBox on the cursor - const magnifiedViewBoxX = cursorSvgX - magnifiedWidth / 2 - const magnifiedViewBoxY = cursorSvgY - magnifiedHeight / 2 + // Center position relative to SVG (uses reveal center during give-up animation) + const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX + const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY + const cursorSvgX = + (cursorPosition.x - svgOffsetX) / viewport.scale + parsedViewBox.x + const cursorSvgY = + (cursorPosition.y - svgOffsetY) / viewport.scale + parsedViewBox.y - return `${magnifiedViewBoxX} ${magnifiedViewBoxY} ${magnifiedWidth} ${magnifiedHeight}` - })} - style={{ - width: '100%', - height: '100%', - // Apply "disabled" visual effect when at threshold but not in precision mode - // Uses precisionCalcs.isAtThreshold from usePrecisionCalculations hook - filter: - precisionCalcs.isAtThreshold && !pointerLocked - ? 'brightness(0.6) saturate(0.5)' - : 'none', - }} - > - {/* Sea/ocean background for magnifier - solid color to match container */} - + // Magnified view: adjust dimensions to match magnifier container aspect ratio + // This eliminates letterboxing and ensures outline matches what's visible + // Use leftover dimensions for magnifier sizing + const leftoverW = + containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right + const leftoverH = + containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom + const { width: magnifiedWidth, height: magnifiedHeight } = + getAdjustedMagnifiedDimensions( + parsedViewBox.width, + parsedViewBox.height, + zoom, + leftoverW, + leftoverH + ) - {/* Render all regions in magnified view */} - + > + {/* Sea/ocean background for magnifier - solid color to match container */} + - {/* Crosshair at center position (cursor or reveal center during animation) */} - + {/* Render all regions in magnified view */} + - {/* Pixel grid overlay - shows when approaching/at/above precision mode threshold */} - {/* Uses precisionCalcs.screenPixelRatio from usePrecisionCalculations hook */} - + {/* Crosshair at center position (cursor or reveal center during animation) */} + - {/* Debug: Bounding boxes for detected regions in magnifier */} - + + {/* Debug: Bounding boxes for detected regions in magnifier */} + + + + {/* Debug: Bounding box labels as HTML overlays - positioned using animated values */} + - - {/* Debug: Bounding box labels as HTML overlays - positioned using animated values */} - - - {/* Magnifier label */} - - - {/* Scrim overlay - shows when at threshold to indicate barrier */} - {/* Uses precisionCalcs.isAtThreshold from usePrecisionCalculations hook */} - {precisionCalcs.isAtThreshold && !pointerLocked && ( -
- )} - {/* Mobile magnifier controls (Expand, Select, Full Map buttons) */} - setIsMagnifierExpanded(false)} - onExpand={() => setIsMagnifierExpanded(true)} - /> - - ) - })()} + {/* Scrim overlay - shows when at threshold to indicate barrier */} + {/* Uses precisionCalcs.isAtThreshold from usePrecisionCalculations hook */} + {precisionCalcs.isAtThreshold && !pointerLocked && ( +
+ )} - {/* Zoom lines connecting indicator to magnifier - creates "pop out" effect */} - {showMagnifier && cursorPosition && svgRef.current && containerRef.current && ( - setIsMagnifierExpanded(false)} + onExpand={() => setIsMagnifierExpanded(true)} + /> + + ) + })()} + + {/* Zoom lines connecting indicator to magnifier - creates "pop out" effect */} + {showMagnifier && cursorPosition && svgRef.current && containerRef.current && ( + + )} + + {/* Debug: Auto zoom detection visualization (dev only) */} + - )} - {/* Debug: Auto zoom detection visualization (dev only) */} - - - {/* Hot/Cold Debug Panel - shows enable conditions and current state */} - - - {/* Other players' cursors - show in multiplayer when not exclusively our turn */} - - - {/* Dev-only crop tool for getting custom viewBox coordinates */} - - - {/* Debug overlay showing safe zone rectangles */} - {/* Safe Zone Debug Panel */} - - - {/* Celebration overlay - shows confetti and sound when region is found */} - {celebration && ( - - )} + + {/* Other players' cursors - show in multiplayer when not exclusively our turn */} + + + {/* Dev-only crop tool for getting custom viewBox coordinates */} + + + {/* Debug overlay showing safe zone rectangles */} + {/* Safe Zone Debug Panel */} + + + {/* Celebration overlay - shows confetti and sound when region is found */} + {celebration && ( + + )}
) diff --git a/apps/web/src/arcade-games/know-your-world/features/interaction/index.ts b/apps/web/src/arcade-games/know-your-world/features/interaction/index.ts index c24080fd..ae6cebca 100644 --- a/apps/web/src/arcade-games/know-your-world/features/interaction/index.ts +++ b/apps/web/src/arcade-games/know-your-world/features/interaction/index.ts @@ -6,11 +6,11 @@ */ export type { - InteractionState, - InteractionEvent, InteractionContext, - TouchPoint, + InteractionEvent, + InteractionState, MachineState, + TouchPoint, UseInteractionStateMachineReturn, } from './useInteractionStateMachine' diff --git a/apps/web/src/arcade-games/know-your-world/features/interaction/useInteractionStateMachine.ts b/apps/web/src/arcade-games/know-your-world/features/interaction/useInteractionStateMachine.ts index 11bd3f92..42801515 100644 --- a/apps/web/src/arcade-games/know-your-world/features/interaction/useInteractionStateMachine.ts +++ b/apps/web/src/arcade-games/know-your-world/features/interaction/useInteractionStateMachine.ts @@ -10,7 +10,7 @@ 'use client' -import { useReducer, useCallback, useRef, useMemo } from 'react' +import { useCallback, useMemo, useReducer, useRef } from 'react' // ============================================================================ // State Types @@ -680,10 +680,7 @@ export function useInteractionStateMachine(): UseInteractionStateMachineReturn { ] ) - const showCursor = useMemo( - () => isHovering || showMagnifier, - [isHovering, showMagnifier] - ) + const showCursor = useMemo(() => isHovering || showMagnifier, [isHovering, showMagnifier]) const isAnyPanning = useMemo( () => isMagnifierPanning || isMapPanningMobile || isMapPanningDesktop, diff --git a/apps/web/src/arcade-games/know-your-world/features/labels/useD3ForceLabels.ts b/apps/web/src/arcade-games/know-your-world/features/labels/useD3ForceLabels.ts index 1a3b302e..4914da10 100644 --- a/apps/web/src/arcade-games/know-your-world/features/labels/useD3ForceLabels.ts +++ b/apps/web/src/arcade-games/know-your-world/features/labels/useD3ForceLabels.ts @@ -9,7 +9,7 @@ */ import { forceCollide, forceSimulation, forceX, forceY, type SimulationNodeDatum } from 'd3-force' -import { RefObject, useEffect, useState } from 'react' +import { type RefObject, useEffect, useState } from 'react' import type { MapData, MapRegion } from '../../types' import { getArrowStartPoint, getRenderedViewport } from './labelUtils' diff --git a/apps/web/src/arcade-games/know-your-world/features/letter-confirmation/LetterDisplay.tsx b/apps/web/src/arcade-games/know-your-world/features/letter-confirmation/LetterDisplay.tsx index d3b1a212..c1f3e0e8 100644 --- a/apps/web/src/arcade-games/know-your-world/features/letter-confirmation/LetterDisplay.tsx +++ b/apps/web/src/arcade-games/know-your-world/features/letter-confirmation/LetterDisplay.tsx @@ -90,15 +90,7 @@ export const LetterDisplay = memo(function LetterDisplay({ const isSpace = char === ' ' if (isSpace) { - return ( - - ) + return } // Get current index before incrementing @@ -113,15 +105,7 @@ export const LetterDisplay = memo(function LetterDisplay({ isComplete ) - return ( - - ) + return }) }, [regionName, confirmedCount, requiredLetters, isComplete, isDark]) diff --git a/apps/web/src/arcade-games/know-your-world/features/letter-confirmation/letterUtils.ts b/apps/web/src/arcade-games/know-your-world/features/letter-confirmation/letterUtils.ts index 97bbc9c7..4d2a4cc3 100644 --- a/apps/web/src/arcade-games/know-your-world/features/letter-confirmation/letterUtils.ts +++ b/apps/web/src/arcade-games/know-your-world/features/letter-confirmation/letterUtils.ts @@ -89,10 +89,7 @@ export function getLetterStatus( * @param isDark - Whether dark mode is active * @returns CSS properties for the letter */ -export function getLetterStyles( - status: LetterStatus, - isDark: boolean -): React.CSSProperties { +export function getLetterStyles(status: LetterStatus, isDark: boolean): React.CSSProperties { const baseStyles: React.CSSProperties = { transition: 'all 0.15s ease-out', } @@ -129,10 +126,7 @@ export function getLetterStyles( * @param requiredLetters - Number of letters required * @returns Progress value (0 = none, 1 = complete) */ -export function calculateProgress( - confirmedCount: number, - requiredLetters: number -): number { +export function calculateProgress(confirmedCount: number, requiredLetters: number): number { if (requiredLetters === 0) return 1 return Math.min(1, confirmedCount / requiredLetters) } diff --git a/apps/web/src/arcade-games/know-your-world/features/letter-confirmation/useLetterConfirmation.ts b/apps/web/src/arcade-games/know-your-world/features/letter-confirmation/useLetterConfirmation.ts index 70d78c7f..c77f152c 100644 --- a/apps/web/src/arcade-games/know-your-world/features/letter-confirmation/useLetterConfirmation.ts +++ b/apps/web/src/arcade-games/know-your-world/features/letter-confirmation/useLetterConfirmation.ts @@ -91,12 +91,7 @@ export function useLetterConfirmation({ // Get letter status for display const getLetterStatus = useCallback( (nonSpaceIndex: number): LetterStatus => { - return getLetterStatusUtil( - nonSpaceIndex, - confirmedCount, - requiredLetters, - isComplete - ) + return getLetterStatusUtil(nonSpaceIndex, confirmedCount, requiredLetters, isComplete) }, [confirmedCount, requiredLetters, isComplete] ) @@ -109,10 +104,7 @@ export function useLetterConfirmation({ const handleKeyDown = (e: KeyboardEvent) => { // Ignore if typing in an input or textarea - if ( - e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement - ) { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { return } diff --git a/apps/web/src/arcade-games/know-your-world/features/magnifier/MagnifierControls.tsx b/apps/web/src/arcade-games/know-your-world/features/magnifier/MagnifierControls.tsx index 9f89965a..329adf35 100644 --- a/apps/web/src/arcade-games/know-your-world/features/magnifier/MagnifierControls.tsx +++ b/apps/web/src/arcade-games/know-your-world/features/magnifier/MagnifierControls.tsx @@ -211,11 +211,7 @@ export const MagnifierControls = memo(function MagnifierControls({ <> {/* Expand button - top-right corner (when not expanded and not pointer locked) */} {!isExpanded && !pointerLocked && ( - + )} @@ -234,11 +230,7 @@ export const MagnifierControls = memo(function MagnifierControls({ {/* Full Map button - bottom-left corner (when expanded) */} {isExpanded && showSelectButton && ( - + Full Map )} diff --git a/apps/web/src/arcade-games/know-your-world/features/magnifier/MagnifierRegions.tsx b/apps/web/src/arcade-games/know-your-world/features/magnifier/MagnifierRegions.tsx index 7e35442f..61b2ebf6 100644 --- a/apps/web/src/arcade-games/know-your-world/features/magnifier/MagnifierRegions.tsx +++ b/apps/web/src/arcade-games/know-your-world/features/magnifier/MagnifierRegions.tsx @@ -50,7 +50,12 @@ export interface MagnifierRegionsProps { /** Get player ID who found a region */ getPlayerWhoFoundRegion: (regionId: string) => string | null /** Get region fill color */ - getRegionColor: (regionId: string, isFound: boolean, isHovered: boolean, isDark: boolean) => string + getRegionColor: ( + regionId: string, + isFound: boolean, + isHovered: boolean, + isDark: boolean + ) => string /** Get region stroke color */ getRegionStroke: (isFound: boolean, isDark: boolean) => string /** Whether to show region outline */ @@ -80,13 +85,8 @@ export const MagnifierRegions = memo(function MagnifierRegions({ getRegionStroke, showOutline, }: MagnifierRegionsProps) { - const { - regionsFound, - hoveredRegion, - celebrationRegionId, - giveUpRegionId, - isGiveUpAnimating, - } = regionState + const { regionsFound, hoveredRegion, celebrationRegionId, giveUpRegionId, isGiveUpAnimating } = + regionState const { celebrationFlash, giveUpFlash } = flashProgress return ( diff --git a/apps/web/src/arcade-games/know-your-world/features/magnifier/ZoomLines.tsx b/apps/web/src/arcade-games/know-your-world/features/magnifier/ZoomLines.tsx index 3e13ad71..46ecdc7b 100644 --- a/apps/web/src/arcade-games/know-your-world/features/magnifier/ZoomLines.tsx +++ b/apps/web/src/arcade-games/know-your-world/features/magnifier/ZoomLines.tsx @@ -16,7 +16,10 @@ import { memo, useMemo } from 'react' import { getRenderedViewport } from '../labels' -import { getAdjustedMagnifiedDimensions, getMagnifierDimensions } from '../../utils/magnifierDimensions' +import { + getAdjustedMagnifiedDimensions, + getMagnifierDimensions, +} from '../../utils/magnifierDimensions' // ============================================================================ // Types @@ -124,12 +127,7 @@ export const ZoomLines = memo(function ZoomLines({ isDark, }: ZoomLinesProps) { // Memoize all the calculated values - const { - paths, - visibleCorners, - lineColor, - glowColor, - } = useMemo(() => { + const { paths, visibleCorners, lineColor, glowColor } = useMemo(() => { // Calculate leftover rectangle dimensions (area not covered by UI elements) const leftoverWidth = containerRect.width - safeZoneMargins.left - safeZoneMargins.right const leftoverHeight = containerRect.height - safeZoneMargins.top - safeZoneMargins.bottom @@ -210,14 +208,7 @@ export const ZoomLines = memo(function ZoomLines({ magTop + magnifierHeight ) // Check if line passes through indicator - const passesThroughInd = linePassesThroughRect( - from, - to, - indTL.x, - indTL.y, - indBR.x, - indBR.y - ) + const passesThroughInd = linePassesThroughRect(from, to, indTL.x, indTL.y, indBR.x, indBR.y) return !passesThroughMag && !passesThroughInd }) @@ -233,9 +224,7 @@ export const ZoomLines = memo(function ZoomLines({ : isDark ? '#60a5fa' : '#3b82f6' // blue - const calculatedGlowColor = isHighZoom - ? 'rgba(251, 191, 36, 0.6)' - : 'rgba(96, 165, 250, 0.6)' + const calculatedGlowColor = isHighZoom ? 'rgba(251, 191, 36, 0.6)' : 'rgba(96, 165, 250, 0.6)' return { paths: calculatedPaths, diff --git a/apps/web/src/arcade-games/know-your-world/features/magnifier/useMagnifierTouch.ts b/apps/web/src/arcade-games/know-your-world/features/magnifier/useMagnifierTouch.ts index 151d0a19..cb60af10 100644 --- a/apps/web/src/arcade-games/know-your-world/features/magnifier/useMagnifierTouch.ts +++ b/apps/web/src/arcade-games/know-your-world/features/magnifier/useMagnifierTouch.ts @@ -12,7 +12,12 @@ 'use client' -import React, { useCallback, useRef, type RefObject, type TouchEvent as ReactTouchEvent } from 'react' +import React, { + useCallback, + useRef, + type RefObject, + type TouchEvent as ReactTouchEvent, +} from 'react' import type { UseMagnifierStateReturn } from './useMagnifierState' @@ -223,8 +228,10 @@ export function useMagnifierTouch(options: UseMagnifierTouchOptions): UseMagnifi totalDeltaRef.current.y += deltaY // Track if user has moved significantly - if (Math.abs(totalDeltaRef.current.x) > moveThreshold || - Math.abs(totalDeltaRef.current.y) > moveThreshold) { + if ( + Math.abs(totalDeltaRef.current.x) > moveThreshold || + Math.abs(totalDeltaRef.current.y) > moveThreshold + ) { magnifierState.didMoveRef.current = true } diff --git a/apps/web/src/arcade-games/know-your-world/features/shared/MapRendererContext.tsx b/apps/web/src/arcade-games/know-your-world/features/shared/MapRendererContext.tsx index dce28a77..2c9e63b5 100644 --- a/apps/web/src/arcade-games/know-your-world/features/shared/MapRendererContext.tsx +++ b/apps/web/src/arcade-games/know-your-world/features/shared/MapRendererContext.tsx @@ -103,9 +103,7 @@ export function MapRendererProvider({ children, value }: MapRendererProviderProp ] ) - return ( - {children} - ) + return {children} } // ============================================================================ diff --git a/apps/web/src/arcade-games/know-your-world/features/shared/types.ts b/apps/web/src/arcade-games/know-your-world/features/shared/types.ts index 3f97a2cf..1621c617 100644 --- a/apps/web/src/arcade-games/know-your-world/features/shared/types.ts +++ b/apps/web/src/arcade-games/know-your-world/features/shared/types.ts @@ -177,14 +177,7 @@ export interface FlashProgress { /** * Hot/cold feedback type for visual indicators */ -export type HotColdFeedbackType = - | 'freezing' - | 'cold' - | 'cool' - | 'warm' - | 'hot' - | 'burning' - | null +export type HotColdFeedbackType = 'freezing' | 'cold' | 'cool' | 'warm' | 'hot' | 'burning' | null /** * Heat-based styling for borders and glows diff --git a/apps/web/src/arcade-games/know-your-world/features/shared/viewportUtils.ts b/apps/web/src/arcade-games/know-your-world/features/shared/viewportUtils.ts index 46539692..e95473d0 100644 --- a/apps/web/src/arcade-games/know-your-world/features/shared/viewportUtils.ts +++ b/apps/web/src/arcade-games/know-your-world/features/shared/viewportUtils.ts @@ -96,13 +96,7 @@ export function screenToSVG( svgRect: DOMRect, viewBox: ViewBoxComponents ): SVGPosition { - const viewport = getRenderedViewport( - svgRect, - viewBox.x, - viewBox.y, - viewBox.width, - viewBox.height - ) + const viewport = getRenderedViewport(svgRect, viewBox.x, viewBox.y, viewBox.width, viewBox.height) // Calculate offset from container origin to SVG rendered content const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX @@ -130,13 +124,7 @@ export function svgToScreen( svgRect: DOMRect, viewBox: ViewBoxComponents ): CursorPosition { - const viewport = getRenderedViewport( - svgRect, - viewBox.x, - viewBox.y, - viewBox.width, - viewBox.height - ) + const viewport = getRenderedViewport(svgRect, viewBox.x, viewBox.y, viewBox.width, viewBox.height) // Calculate offset from container origin to SVG rendered content const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX