feat(know-your-world): fix magnifier outline aspect ratio and add visual debug toggle

Magnifier outline fix:
- Create shared magnifierDimensions.ts utility for responsive magnifier sizing
- Adjust magnifier viewBox to match container aspect ratio (eliminates letterboxing)
- Fix dotted outline dimensions to match magnifier's actual visible region
- Fix zoom lines to connect to correct corners matching the adjusted aspect ratio
- Fix useMagnifierZoom.ts to use actual magnifier dimensions instead of hardcoded 0.5

Visual debug toggle:
- Add VisualDebugContext for global debug flag control (dev mode only)
- Add Developer section to hamburger menu with visual debug toggle
- Wire up all debug visualizations (bounding boxes, safe zones, magnifier info)
- Persist preference to localStorage

🤖 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-29 17:13:23 -06:00
parent ebe07e358f
commit ac915f2065
7 changed files with 325 additions and 52 deletions

View File

@@ -136,10 +136,10 @@ export function GameInfoPanel({
// During give-up animation, show the given-up region's name instead of the next region
const displayRegionName = isGiveUpAnimating
? state.giveUpReveal?.regionName ?? currentRegionName
? (state.giveUpReveal?.regionName ?? currentRegionName)
: currentRegionName
const displayRegionId = isGiveUpAnimating
? state.giveUpReveal?.regionId ?? currentRegionId
? (state.giveUpReveal?.regionId ?? currentRegionId)
: currentRegionId
// Get flag emoji for the displayed region (not necessarily the current prompt)

View File

@@ -5,6 +5,7 @@ import { css } from '@styled/css'
import { forceCollide, forceSimulation, forceX, forceY, type SimulationNodeDatum } from 'd3-force'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import { useVisualDebugSafe } from '@/contexts/VisualDebugContext'
import type { ContinentId } from '../continents'
import { useHotColdFeedback } from '../hooks/useHotColdFeedback'
import { useMagnifierZoom } from '../hooks/useMagnifierZoom'
@@ -34,6 +35,12 @@ import { useKnowYourWorld } from '../Provider'
import type { MapData, MapRegion } from '../types'
import { type BoundingBox as DebugBoundingBox, findOptimalZoom } from '../utils/adaptiveZoomSearch'
import type { FeedbackType } from '../utils/hotColdPhrases'
import {
getAdjustedMagnifiedDimensions,
getMagnifierDimensions,
MAGNIFIER_SIZE_LARGE,
MAGNIFIER_SIZE_SMALL,
} from '../utils/magnifierDimensions'
import {
calculateMaxZoomAtThreshold,
calculateScreenPixelRatio,
@@ -57,10 +64,6 @@ const PRECISION_MODE_THRESHOLD = 20
const LABEL_FADE_RADIUS = 150 // pixels - labels within this radius fade
const LABEL_MIN_OPACITY = 0.08 // minimum opacity for faded labels
// Magnifier size ratios - responsive to container aspect ratio
const MAGNIFIER_SIZE_SMALL = 1 / 3 // Used for the constrained dimension
const MAGNIFIER_SIZE_LARGE = 1 / 2 // Used for the unconstrained dimension
// Game nav height offset - buttons should appear below the nav when in full-viewport mode
const NAV_HEIGHT_OFFSET = 150
@@ -74,19 +77,6 @@ const SAFE_ZONE_MARGINS: SafeZoneMargins = {
left: 0, // Progress at top-left is small, doesn't need full-height margin
}
/**
* Calculate magnifier dimensions based on container aspect ratio.
* - Landscape (wider): 1/3 width, 1/2 height (more vertical space available)
* - Portrait (taller): 1/2 width, 1/3 height (more horizontal space available)
*/
function getMagnifierDimensions(containerWidth: number, containerHeight: number) {
const isLandscape = containerWidth > containerHeight
return {
width: containerWidth * (isLandscape ? MAGNIFIER_SIZE_SMALL : MAGNIFIER_SIZE_LARGE),
height: containerHeight * (isLandscape ? MAGNIFIER_SIZE_LARGE : MAGNIFIER_SIZE_SMALL),
}
}
/**
* Get emoji for hot/cold feedback type
* Returns emoji that matches the temperature/status of the last feedback
@@ -378,6 +368,15 @@ export function MapRenderer({
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Visual debug mode from global context (only enabled in dev AND when user toggles it on)
const { isVisualDebugEnabled } = useVisualDebugSafe()
// Effective debug flags - combine prop with context
// Props allow component-level override, context allows global toggle
const effectiveShowDebugBoundingBoxes = showDebugBoundingBoxes && isVisualDebugEnabled
const effectiveShowMagnifierDebugInfo = SHOW_MAGNIFIER_DEBUG_INFO && isVisualDebugEnabled
const effectiveShowSafeZoneDebug = SHOW_SAFE_ZONE_DEBUG && isVisualDebugEnabled
// Calculate excluded regions (regions filtered out by size/continent)
const excludedRegions = useMemo(() => {
// Get full unfiltered map data
@@ -2442,7 +2441,7 @@ export function MapRenderer({
})}
{/* Debug: Render bounding boxes (only if enabled) */}
{showDebugBoundingBoxes &&
{effectiveShowDebugBoundingBoxes &&
debugBoundingBoxes.map((bbox) => {
// Color based on acceptance and importance
// Green = accepted, Orange = high importance, Yellow = medium, Gray = low
@@ -2541,7 +2540,13 @@ export function MapRenderer({
)
const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX
const cursorSvgX = (cursorPosition.x - svgOffsetX) / viewport.scale + viewBoxX
const magnifiedWidth = viewBoxWidth / zoom
const { width: magnifiedWidth } = getAdjustedMagnifiedDimensions(
viewBoxWidth,
viewBoxHeight,
zoom,
containerRect.width,
containerRect.height
)
return cursorSvgX - magnifiedWidth / 2
})}
y={zoomSpring.to((zoom: number) => {
@@ -2562,18 +2567,42 @@ export function MapRenderer({
)
const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY
const cursorSvgY = (cursorPosition.y - svgOffsetY) / viewport.scale + viewBoxY
const magnifiedHeight = viewBoxHeight / zoom
const { height: magnifiedHeight } = getAdjustedMagnifiedDimensions(
viewBoxWidth,
viewBoxHeight,
zoom,
containerRect.width,
containerRect.height
)
return cursorSvgY - magnifiedHeight / 2
})}
width={zoomSpring.to((zoom: number) => {
const containerRect = containerRef.current!.getBoundingClientRect()
const viewBoxParts = displayViewBox.split(' ').map(Number)
const viewBoxWidth = viewBoxParts[2] || 1000
return viewBoxWidth / zoom
const viewBoxHeight = viewBoxParts[3] || 1000
const { width } = getAdjustedMagnifiedDimensions(
viewBoxWidth,
viewBoxHeight,
zoom,
containerRect.width,
containerRect.height
)
return width
})}
height={zoomSpring.to((zoom: number) => {
const containerRect = containerRef.current!.getBoundingClientRect()
const viewBoxParts = displayViewBox.split(' ').map(Number)
const viewBoxWidth = viewBoxParts[2] || 1000
const viewBoxHeight = viewBoxParts[3] || 1000
return viewBoxHeight / zoom
const { height } = getAdjustedMagnifiedDimensions(
viewBoxWidth,
viewBoxHeight,
zoom,
containerRect.width,
containerRect.height
)
return height
})}
fill="none"
stroke={isDark ? '#60a5fa' : '#3b82f6'}
@@ -2758,7 +2787,7 @@ export function MapRenderer({
})}
{/* Debug: Bounding box labels as HTML overlays */}
{showDebugBoundingBoxes &&
{effectiveShowDebugBoundingBoxes &&
containerRef.current &&
svgRef.current &&
debugBoundingBoxes.map((bbox) => {
@@ -3003,9 +3032,16 @@ export function MapRenderer({
const cursorSvgX = (cursorPosition.x - svgOffsetX) / viewport.scale + viewBoxX
const cursorSvgY = (cursorPosition.y - svgOffsetY) / viewport.scale + viewBoxY
// Magnified view: adaptive zoom (using animated value)
const magnifiedWidth = viewBoxWidth / zoom
const magnifiedHeight = viewBoxHeight / zoom
// Magnified view: adjust dimensions to match magnifier container aspect ratio
// This eliminates letterboxing and ensures outline matches what's visible
const { width: magnifiedWidth, height: magnifiedHeight } =
getAdjustedMagnifiedDimensions(
viewBoxWidth,
viewBoxHeight,
zoom,
containerRect.width,
containerRect.height
)
// Center the magnified viewBox on the cursor
const magnifiedViewBoxX = cursorSvgX - magnifiedWidth / 2
@@ -3301,7 +3337,7 @@ export function MapRenderer({
})()}
{/* Debug: Bounding boxes for detected regions in magnifier */}
{SHOW_DEBUG_BOUNDING_BOXES &&
{effectiveShowDebugBoundingBoxes &&
debugBoundingBoxes.map((bbox) => {
const importance = bbox.importance ?? 0
@@ -3333,7 +3369,7 @@ export function MapRenderer({
</animated.svg>
{/* Debug: Bounding box labels as HTML overlays - positioned using animated values */}
{SHOW_DEBUG_BOUNDING_BOXES &&
{effectiveShowDebugBoundingBoxes &&
debugBoundingBoxes.map((bbox) => {
const importance = bbox.importance ?? 0
let strokeColor = '#888888'
@@ -3511,7 +3547,7 @@ export function MapRenderer({
}
// Below threshold - show debug info in dev, simple zoom in prod
if (SHOW_MAGNIFIER_DEBUG_INFO) {
if (effectiveShowMagnifierDebugInfo) {
return `${z.toFixed(1)}× | ${screenPixelRatio.toFixed(1)} px/px`
}
@@ -3589,8 +3625,14 @@ export function MapRenderer({
const viewBoxHeight = viewBoxParts[3] || 1000
const currentZoom = getCurrentZoom()
const indicatorWidth = viewBoxWidth / currentZoom
const indicatorHeight = viewBoxHeight / currentZoom
// Use adjusted dimensions to match magnifier aspect ratio
const { width: indicatorWidth, height: indicatorHeight } = getAdjustedMagnifiedDimensions(
viewBoxWidth,
viewBoxHeight,
currentZoom,
containerRect.width,
containerRect.height
)
// Convert cursor to SVG coordinates (accounting for preserveAspectRatio)
const viewport = getRenderedViewport(
@@ -3813,7 +3855,7 @@ export function MapRenderer({
})()}
{/* Debug: Auto zoom detection visualization (dev only) */}
{SHOW_MAGNIFIER_DEBUG_INFO && cursorPosition && containerRef.current && (
{effectiveShowMagnifierDebugInfo && cursorPosition && containerRef.current && (
<>
{/* Detection box - 50px box around cursor */}
<div
@@ -4100,7 +4142,7 @@ export function MapRenderer({
/>
{/* Debug overlay showing safe zone rectangles */}
{SHOW_SAFE_ZONE_DEBUG &&
{effectiveShowSafeZoneDebug &&
fillContainer &&
(() => {
// Calculate the leftover rectangle (viewport minus margins)

View File

@@ -6,11 +6,12 @@
* coordinating with pointer lock state.
*/
import { useState, useEffect, useRef, type RefObject } from 'react'
import { useSpring, useSpringRef } from '@react-spring/web'
import { type RefObject, useEffect, useRef, useState } from 'react'
import { getMagnifierDimensions } from '../utils/magnifierDimensions'
import {
calculateScreenPixelRatio,
calculateMaxZoomAtThreshold,
calculateScreenPixelRatio,
isAboveThreshold,
} from '../utils/screenPixelRatio'
@@ -91,7 +92,10 @@ export function useMagnifierZoom(options: UseMagnifierZoomOptions): UseMagnifier
const containerRect = containerElement.getBoundingClientRect()
const svgRect = svgElement.getBoundingClientRect()
const magnifierWidth = containerRect.width * 0.5
const { width: magnifierWidth } = getMagnifierDimensions(
containerRect.width,
containerRect.height
)
const viewBoxParts = viewBox.split(' ').map(Number)
const viewBoxWidth = viewBoxParts[2]
@@ -135,7 +139,10 @@ export function useMagnifierZoom(options: UseMagnifierZoomOptions): UseMagnifier
(() => {
const containerRect = containerRef.current.getBoundingClientRect()
const svgRect = svgRef.current.getBoundingClientRect()
const magnifierWidth = containerRect.width * 0.5
const { width: magnifierWidth } = getMagnifierDimensions(
containerRect.width,
containerRect.height
)
const viewBoxParts = viewBox.split(' ').map(Number)
const viewBoxWidth = viewBoxParts[2]
@@ -160,7 +167,10 @@ export function useMagnifierZoom(options: UseMagnifierZoomOptions): UseMagnifier
(() => {
const containerRect = containerRef.current.getBoundingClientRect()
const svgRect = svgRef.current.getBoundingClientRect()
const magnifierWidth = containerRect.width * 0.5
const { width: magnifierWidth } = getMagnifierDimensions(
containerRect.width,
containerRect.height
)
const viewBoxParts = viewBox.split(' ').map(Number)
const viewBoxWidth = viewBoxParts[2]

View File

@@ -0,0 +1,76 @@
/**
* Magnifier Dimension Utilities
*
* Shared functions for calculating magnifier dimensions that respond to
* container aspect ratio. Used by MapRenderer and useMagnifierZoom.
*/
// Magnifier size ratios - responsive to container aspect ratio
export const MAGNIFIER_SIZE_SMALL = 1 / 3 // Used for the constrained dimension
export const MAGNIFIER_SIZE_LARGE = 1 / 2 // Used for the unconstrained dimension
/**
* Calculate magnifier dimensions based on container aspect ratio.
* - Landscape (wider): 1/3 width, 1/2 height (more vertical space available)
* - Portrait (taller): 1/2 width, 1/3 height (more horizontal space available)
*/
export function getMagnifierDimensions(containerWidth: number, containerHeight: number) {
const isLandscape = containerWidth > containerHeight
return {
width: containerWidth * (isLandscape ? MAGNIFIER_SIZE_SMALL : MAGNIFIER_SIZE_LARGE),
height: containerHeight * (isLandscape ? MAGNIFIER_SIZE_LARGE : MAGNIFIER_SIZE_SMALL),
}
}
/**
* Calculate the magnified viewBox dimensions that match the magnifier container's aspect ratio.
*
* The magnifier container has a responsive aspect ratio (varies with screen orientation),
* but the map viewBox has a fixed aspect ratio (e.g., 2:1 for world map). Without adjustment,
* this causes letterboxing in the magnifier SVG.
*
* This function expands the viewBox dimensions to match the container's aspect ratio,
* eliminating letterboxing and ensuring the outline on the main map matches exactly
* what's visible in the magnifier.
*
* @param viewBoxWidth - The map's viewBox width
* @param viewBoxHeight - The map's viewBox height
* @param zoom - Current zoom level
* @param containerWidth - The game container's width in pixels
* @param containerHeight - The game container's height in pixels
* @returns Adjusted width and height for the magnified viewBox
*/
export function getAdjustedMagnifiedDimensions(
viewBoxWidth: number,
viewBoxHeight: number,
zoom: number,
containerWidth: number,
containerHeight: number
) {
const { width: magWidth, height: magHeight } = getMagnifierDimensions(
containerWidth,
containerHeight
)
// Base dimensions from zoom (what we'd show without aspect ratio adjustment)
const baseWidth = viewBoxWidth / zoom
const baseHeight = viewBoxHeight / zoom
// Compare aspect ratios
const containerAspect = magWidth / magHeight
const viewBoxAspect = baseWidth / baseHeight
if (containerAspect > viewBoxAspect) {
// Container is wider than viewBox aspect ratio - expand width to fill
return {
width: baseHeight * containerAspect,
height: baseHeight,
}
} else {
// Container is taller than viewBox aspect ratio - expand height to fill
return {
width: baseWidth,
height: baseWidth / containerAspect,
}
}
}

View File

@@ -9,17 +9,17 @@ import { createPortal } from 'react-dom'
import { css } from '../../styled-system/css'
import { container, hstack } from '../../styled-system/patterns'
import { Z_INDEX } from '../constants/zIndex'
import { useDeploymentInfo } from '../contexts/DeploymentInfoContext'
import { useFullscreen } from '../contexts/FullscreenContext'
import { useTheme } from '../contexts/ThemeContext'
import { useDeploymentInfo } from '../contexts/DeploymentInfoContext'
import { useVisualDebug } from '../contexts/VisualDebugContext'
// Import HomeHeroContext for optional usage
import type { Subtitle } from '../data/abaciOneSubtitles'
import { getRandomSubtitle } from '../data/abaciOneSubtitles'
import { AbacusDisplayDropdown } from './AbacusDisplayDropdown'
import { LanguageSelector } from './LanguageSelector'
import { ThemeToggle } from './ThemeToggle'
// Import HomeHeroContext for optional usage
import type { Subtitle } from '../data/abaciOneSubtitles'
type HomeHeroContextValue = {
subtitle: Subtitle
isHeroVisible: boolean
@@ -73,6 +73,7 @@ function MenuContent({
}) {
const isDark = resolvedTheme === 'dark'
const { open: openDeploymentInfo } = useDeploymentInfo()
const { isVisualDebugEnabled, toggleVisualDebug, isDevelopment } = useVisualDebug()
const linkStyle = {
display: 'flex',
@@ -315,6 +316,38 @@ function MenuContent({
<div style={{ padding: '0 6px' }}>
<ThemeToggle />
</div>
{/* Developer Section - only in development */}
{isDevelopment && (
<>
<div style={separatorStyle} />
<div style={sectionHeaderStyle}>Developer</div>
<div
data-setting="visual-debug"
onClick={() => {
toggleVisualDebug()
}}
style={controlButtonStyle}
onMouseEnter={(e) => {
e.currentTarget.style.background = isDark
? 'rgba(234, 179, 8, 0.2)'
: 'rgba(234, 179, 8, 0.1)'
e.currentTarget.style.color = isDark
? 'rgba(253, 224, 71, 1)'
: 'rgba(161, 98, 7, 1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = isDark
? 'rgba(209, 213, 219, 1)'
: 'rgba(55, 65, 81, 1)'
}}
>
<span style={{ fontSize: '18px' }}>{isVisualDebugEnabled ? '🔍' : '🐞'}</span>
<span>Visual Debug {isVisualDebugEnabled ? 'ON' : 'OFF'}</span>
</div>
</>
)}
</div>
</div>
) : (
@@ -407,6 +440,36 @@ function MenuContent({
<DropdownMenu.Item onSelect={(e) => e.preventDefault()} style={{ padding: '0 6px' }}>
<ThemeToggle />
</DropdownMenu.Item>
{/* Developer Section - only in development */}
{isDevelopment && (
<>
<DropdownMenu.Separator style={separatorStyle} />
<div style={sectionHeaderStyle}>Developer</div>
<DropdownMenu.Item
data-setting="visual-debug"
onSelect={toggleVisualDebug}
style={controlButtonStyle}
onMouseEnter={(e) => {
e.currentTarget.style.background = isDark
? 'rgba(234, 179, 8, 0.2)'
: 'rgba(234, 179, 8, 0.1)'
e.currentTarget.style.color = isDark
? 'rgba(253, 224, 71, 1)'
: 'rgba(161, 98, 7, 1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = isDark
? 'rgba(209, 213, 219, 1)'
: 'rgba(55, 65, 81, 1)'
}}
>
<span style={{ fontSize: '16px' }}>{isVisualDebugEnabled ? '🔍' : '🐞'}</span>
<span>Visual Debug {isVisualDebugEnabled ? 'ON' : 'OFF'}</span>
</DropdownMenu.Item>
</>
)}
</>
)}
</div>

View File

@@ -5,19 +5,20 @@ import { QueryClientProvider } from '@tanstack/react-query'
import { NextIntlClientProvider } from 'next-intl'
import { type ReactNode, useState } from 'react'
import { ToastProvider } from '@/components/common/ToastContext'
import { DeploymentInfoProvider } from '@/contexts/DeploymentInfoContext'
import { FullscreenProvider } from '@/contexts/FullscreenContext'
import { GameModeProvider } from '@/contexts/GameModeContext'
import { UserProfileProvider } from '@/contexts/UserProfileContext'
import { HomeHeroProvider } from '@/contexts/HomeHeroContext'
import { LocaleProvider, useLocaleContext } from '@/contexts/LocaleContext'
import { MyAbacusProvider } from '@/contexts/MyAbacusContext'
import { ThemeProvider } from '@/contexts/ThemeContext'
import { createQueryClient } from '@/lib/queryClient'
import { UserProfileProvider } from '@/contexts/UserProfileContext'
import { VisualDebugProvider } from '@/contexts/VisualDebugContext'
import type { Locale } from '@/i18n/messages'
import { createQueryClient } from '@/lib/queryClient'
import { AbacusSettingsSync } from './AbacusSettingsSync'
import { DeploymentInfo } from './DeploymentInfo'
import { DeploymentInfoProvider } from '@/contexts/DeploymentInfoContext'
import { MyAbacusProvider } from '@/contexts/MyAbacusContext'
import { MyAbacus } from './MyAbacus'
import { HomeHeroProvider } from '@/contexts/HomeHeroContext'
interface ClientProvidersProps {
children: ReactNode
@@ -65,9 +66,11 @@ export function ClientProviders({
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<LocaleProvider initialLocale={initialLocale} initialMessages={initialMessages}>
<InnerProviders>{children}</InnerProviders>
</LocaleProvider>
<VisualDebugProvider>
<LocaleProvider initialLocale={initialLocale} initialMessages={initialMessages}>
<InnerProviders>{children}</InnerProviders>
</LocaleProvider>
</VisualDebugProvider>
</ThemeProvider>
</QueryClientProvider>
)

View File

@@ -0,0 +1,79 @@
'use client'
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from 'react'
const STORAGE_KEY = 'visual-debug-enabled'
interface VisualDebugContextType {
/** Whether visual debug elements are enabled (only functional in development) */
isVisualDebugEnabled: boolean
/** Toggle visual debug mode on/off */
toggleVisualDebug: () => void
/** Whether we're in development mode (visual debug toggle only shows in dev) */
isDevelopment: boolean
}
const VisualDebugContext = createContext<VisualDebugContextType | null>(null)
export function VisualDebugProvider({ children }: { children: ReactNode }) {
const [isEnabled, setIsEnabled] = useState(false)
const isDevelopment = process.env.NODE_ENV === 'development'
// Load from localStorage on mount
useEffect(() => {
if (typeof window === 'undefined') return
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'true') {
setIsEnabled(true)
}
}, [])
// Persist to localStorage
useEffect(() => {
if (typeof window === 'undefined') return
localStorage.setItem(STORAGE_KEY, String(isEnabled))
}, [isEnabled])
const toggleVisualDebug = useCallback(() => {
setIsEnabled((prev) => !prev)
}, [])
// Only enable visual debug in development mode
const isVisualDebugEnabled = isDevelopment && isEnabled
return (
<VisualDebugContext.Provider
value={{
isVisualDebugEnabled,
toggleVisualDebug,
isDevelopment,
}}
>
{children}
</VisualDebugContext.Provider>
)
}
export function useVisualDebug(): VisualDebugContextType {
const context = useContext(VisualDebugContext)
if (!context) {
throw new Error('useVisualDebug must be used within a VisualDebugProvider')
}
return context
}
/**
* Hook for components that may be rendered outside the provider.
* Returns safe defaults (debug disabled) if no provider is found.
*/
export function useVisualDebugSafe(): VisualDebugContextType {
const context = useContext(VisualDebugContext)
if (!context) {
return {
isVisualDebugEnabled: false,
toggleVisualDebug: () => {},
isDevelopment: process.env.NODE_ENV === 'development',
}
}
return context
}