feat: add precision mode system with pixel grid visualization
Add precision mode threshold system for know-your-world magnifier: **Precision Mode Threshold System:** - Constant PRECISION_MODE_THRESHOLD = 20 px/px - Caps zoom when not in pointer lock to prevent exceeding threshold - Shows clickable notice when threshold reached - Activates pointer lock for precision control **Pixel Grid Visualization:** - Shows gold grid overlay aligned with crosshair - Each grid cell = 1 screen pixel of mouse movement on main map - Fades in from 70% to 100% of threshold (14-20 px/px) - Fades out from 100% to 130% of threshold (20-26 px/px) - Visible in both normal and precision modes **Visual "Disabled" State:** - Magnifier dims (60% brightness, 50% saturation) when at threshold - Indicates zoom is capped until precision mode activated - Returns to normal appearance in precision mode **User Experience:** - Below 14 px/px: Normal magnifier - 14-20 px/px: Grid fades in as warning - At 20 px/px: Full grid, dimmed magnifier, "Click here (not map) for precision mode" - Click magnifier label (not map) to activate pointer lock - In precision mode: Grid fades out (20-26 px/px), magnifier returns to normal - e.stopPropagation() prevents accidental region clicks **Debug Mode:** - SHOW_MAGNIFIER_DEBUG_INFO flag (dev only) - Shows technical info: zoom level and px/px ratio 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
404d5bb353
commit
53e90414a3
|
|
@ -61,7 +61,22 @@
|
|||
"Bash(node server.js:*)",
|
||||
"Bash(git fetch:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(npm run test:run:*)"
|
||||
"Bash(npm run test:run:*)",
|
||||
"Bash(for:*)",
|
||||
"Bash(do sleep 30)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(done)",
|
||||
"Bash(do sleep 120)",
|
||||
"Bash(node --version)",
|
||||
"Bash(docker run:*)",
|
||||
"Bash(docker pull:*)",
|
||||
"Bash(docker inspect:*)",
|
||||
"Bash(docker system prune:*)",
|
||||
"Bash(docker stop:*)",
|
||||
"Bash(docker rm:*)",
|
||||
"Bash(docker logs:*)",
|
||||
"Bash(docker exec:*)",
|
||||
"Bash(node --input-type=module -e:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ test.describe('Know Your World - Full-Screen Layout', () => {
|
|||
// Should contain emojis (game mode: 🤝/🏁/↔️, difficulty: 😊/🤔)
|
||||
const hasEmojis = await gameInfo.evaluate((el) => {
|
||||
const text = el.textContent || ''
|
||||
return /[🤝🏁↔️😊🤔]/.test(text)
|
||||
return /[🤝🏁↔️😊🤔]/u.test(text)
|
||||
})
|
||||
expect(hasEmojis).toBe(true)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ import { forceSimulation, forceCollide, forceX, forceY, type SimulationNodeDatum
|
|||
import { WORLD_MAP, USA_MAP, filterRegionsByContinent } from '../maps'
|
||||
import type { ContinentId } from '../continents'
|
||||
|
||||
// Debug flag: show technical info in magnifier (dev only)
|
||||
const SHOW_MAGNIFIER_DEBUG_INFO = process.env.NODE_ENV === 'development'
|
||||
|
||||
// Precision mode threshold: screen pixel ratio that triggers pointer lock recommendation
|
||||
const PRECISION_MODE_THRESHOLD = 20
|
||||
|
||||
interface BoundingBox {
|
||||
minX: number
|
||||
maxX: number
|
||||
|
|
@ -231,7 +237,10 @@ export function MapRenderer({
|
|||
// When acquiring pointer lock, save the initial cursor position
|
||||
if (isLocked && cursorPositionRef.current) {
|
||||
initialCapturePositionRef.current = { ...cursorPositionRef.current }
|
||||
console.log('[Pointer Lock] 📍 Saved initial capture position:', initialCapturePositionRef.current)
|
||||
console.log(
|
||||
'[Pointer Lock] 📍 Saved initial capture position:',
|
||||
initialCapturePositionRef.current
|
||||
)
|
||||
}
|
||||
|
||||
// Reset cursor squish when lock state changes
|
||||
|
|
@ -835,13 +844,21 @@ export function MapRenderer({
|
|||
const dampenedDistRight = svgOffsetX + svgRect.width - cursorX
|
||||
const dampenedDistTop = cursorY - svgOffsetY
|
||||
const dampenedDistBottom = svgOffsetY + svgRect.height - cursorY
|
||||
const dampenedMinDist = Math.min(dampenedDistLeft, dampenedDistRight, dampenedDistTop, dampenedDistBottom)
|
||||
const dampenedMinDist = Math.min(
|
||||
dampenedDistLeft,
|
||||
dampenedDistRight,
|
||||
dampenedDistTop,
|
||||
dampenedDistBottom
|
||||
)
|
||||
|
||||
// Debug logging for boundary proximity
|
||||
if (dampenedMinDist < squishZone) {
|
||||
console.log('[Squish Debug]', {
|
||||
cursorPos: { x: cursorX.toFixed(1), y: cursorY.toFixed(1) },
|
||||
containerSize: { width: containerRect.width.toFixed(1), height: containerRect.height.toFixed(1) },
|
||||
containerSize: {
|
||||
width: containerRect.width.toFixed(1),
|
||||
height: containerRect.height.toFixed(1),
|
||||
},
|
||||
svgSize: { width: svgRect.width.toFixed(1), height: svgRect.height.toFixed(1) },
|
||||
svgOffset: { x: svgOffsetX.toFixed(1), y: svgOffsetY.toFixed(1) },
|
||||
distances: {
|
||||
|
|
@ -890,7 +907,7 @@ export function MapRenderer({
|
|||
const progress = Math.min(elapsed / duration, 1)
|
||||
|
||||
// Ease out cubic for smooth deceleration
|
||||
const eased = 1 - Math.pow(1 - progress, 3)
|
||||
const eased = 1 - (1 - progress) ** 3
|
||||
|
||||
const interpolatedX = startPos.x + (endPos.x - startPos.x) * eased
|
||||
const interpolatedY = startPos.y + (endPos.y - startPos.y) * eased
|
||||
|
|
@ -1350,6 +1367,34 @@ export function MapRenderer({
|
|||
{ top: newTop, left: newLeft }
|
||||
)
|
||||
}
|
||||
|
||||
// Cap zoom if not in pointer lock mode to prevent excessive screen pixel ratios
|
||||
if (!pointerLocked && containerRef.current && svgRef.current) {
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const svgRect = svgRef.current.getBoundingClientRect()
|
||||
const magnifierWidth = containerRect.width * 0.5
|
||||
const viewBoxParts = mapData.viewBox.split(' ').map(Number)
|
||||
const viewBoxWidth = viewBoxParts[2]
|
||||
|
||||
if (viewBoxWidth && !isNaN(viewBoxWidth)) {
|
||||
// Calculate what the screen pixel ratio would be at this zoom
|
||||
const magnifiedViewBoxWidth = viewBoxWidth / adaptiveZoom
|
||||
const magnifierScreenPixelsPerSvgUnit = magnifierWidth / magnifiedViewBoxWidth
|
||||
const mainMapSvgUnitsPerScreenPixel = viewBoxWidth / svgRect.width
|
||||
const screenPixelRatio = mainMapSvgUnitsPerScreenPixel * magnifierScreenPixelsPerSvgUnit
|
||||
|
||||
// If it exceeds threshold, cap the zoom to stay at threshold
|
||||
if (screenPixelRatio > PRECISION_MODE_THRESHOLD) {
|
||||
// Solve for max zoom: ratio = zoom * (magnifierWidth / mainMapWidth)
|
||||
const maxZoom = PRECISION_MODE_THRESHOLD / (magnifierWidth / svgRect.width)
|
||||
adaptiveZoom = Math.min(adaptiveZoom, maxZoom)
|
||||
console.log(
|
||||
`[Magnifier] Capping zoom at ${adaptiveZoom.toFixed(1)}× (threshold: ${PRECISION_MODE_THRESHOLD} px/px, would have been ${screenPixelRatio.toFixed(1)} px/px)`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTargetZoom(adaptiveZoom)
|
||||
setShowMagnifier(true)
|
||||
setTargetOpacity(1)
|
||||
|
|
@ -1814,6 +1859,35 @@ export function MapRenderer({
|
|||
}}
|
||||
>
|
||||
<animated.svg
|
||||
style={{
|
||||
filter: (() => {
|
||||
// Apply "disabled" visual effect when at threshold but not in precision mode
|
||||
if (pointerLocked) return 'none'
|
||||
|
||||
const containerRect = containerRef.current?.getBoundingClientRect()
|
||||
const svgRect = svgRef.current?.getBoundingClientRect()
|
||||
if (!containerRect || !svgRect) return 'none'
|
||||
|
||||
const magnifierWidth = containerRect.width * 0.5
|
||||
const viewBoxParts = mapData.viewBox.split(' ').map(Number)
|
||||
const viewBoxWidth = viewBoxParts[2]
|
||||
if (!viewBoxWidth || isNaN(viewBoxWidth)) return 'none'
|
||||
|
||||
const currentZoom = magnifierSpring.zoom.get()
|
||||
const magnifiedViewBoxWidth = viewBoxWidth / currentZoom
|
||||
const magnifierScreenPixelsPerSvgUnit = magnifierWidth / magnifiedViewBoxWidth
|
||||
const mainMapSvgUnitsPerScreenPixel = viewBoxWidth / svgRect.width
|
||||
const screenPixelRatio =
|
||||
mainMapSvgUnitsPerScreenPixel * magnifierScreenPixelsPerSvgUnit
|
||||
|
||||
// When at or above threshold (but not in precision mode), add disabled effect
|
||||
if (screenPixelRatio >= PRECISION_MODE_THRESHOLD) {
|
||||
return 'brightness(0.6) saturate(0.5)'
|
||||
}
|
||||
|
||||
return 'none'
|
||||
})(),
|
||||
}}
|
||||
viewBox={magnifierSpring.zoom.to((zoom) => {
|
||||
// Calculate magnified viewBox centered on cursor
|
||||
const containerRect = containerRef.current!.getBoundingClientRect()
|
||||
|
|
@ -1925,6 +1999,121 @@ export function MapRenderer({
|
|||
)
|
||||
})()}
|
||||
</g>
|
||||
|
||||
{/* Pixel grid overlay - shows when approaching/at/above precision mode threshold */}
|
||||
{(() => {
|
||||
const containerRect = containerRef.current?.getBoundingClientRect()
|
||||
const svgRect = svgRef.current?.getBoundingClientRect()
|
||||
if (!containerRect || !svgRect) return null
|
||||
|
||||
const magnifierWidth = containerRect.width * 0.5
|
||||
const viewBoxParts = mapData.viewBox.split(' ').map(Number)
|
||||
const viewBoxWidth = viewBoxParts[2]
|
||||
const viewBoxHeight = viewBoxParts[3]
|
||||
const viewBoxX = viewBoxParts[0] || 0
|
||||
const viewBoxY = viewBoxParts[1] || 0
|
||||
|
||||
if (!viewBoxWidth || isNaN(viewBoxWidth)) return null
|
||||
|
||||
const currentZoom = magnifierSpring.zoom.get()
|
||||
const magnifiedViewBoxWidth = viewBoxWidth / currentZoom
|
||||
const magnifierScreenPixelsPerSvgUnit = magnifierWidth / magnifiedViewBoxWidth
|
||||
const mainMapSvgUnitsPerScreenPixel = viewBoxWidth / svgRect.width
|
||||
const screenPixelRatio =
|
||||
mainMapSvgUnitsPerScreenPixel * magnifierScreenPixelsPerSvgUnit
|
||||
|
||||
// Fade grid in/out within 30% range on both sides of threshold
|
||||
// Visible from 70% to 130% of threshold (14 to 26 px/px at threshold=20)
|
||||
const fadeStartRatio = PRECISION_MODE_THRESHOLD * 0.7
|
||||
const fadeEndRatio = PRECISION_MODE_THRESHOLD * 1.3
|
||||
|
||||
if (screenPixelRatio < fadeStartRatio || screenPixelRatio > fadeEndRatio) return null
|
||||
|
||||
// Calculate opacity: 0 at edges (70% and 130%), 1 at threshold (100%)
|
||||
let gridOpacity: number
|
||||
if (screenPixelRatio <= PRECISION_MODE_THRESHOLD) {
|
||||
// Fading in: 0 at 70%, 1 at 100%
|
||||
gridOpacity =
|
||||
(screenPixelRatio - fadeStartRatio) / (PRECISION_MODE_THRESHOLD - fadeStartRatio)
|
||||
} else {
|
||||
// Fading out: 1 at 100%, 0 at 130%
|
||||
gridOpacity =
|
||||
(fadeEndRatio - screenPixelRatio) / (fadeEndRatio - PRECISION_MODE_THRESHOLD)
|
||||
}
|
||||
|
||||
// Calculate grid spacing in SVG units
|
||||
// Each grid cell represents one screen pixel of mouse movement on the main map
|
||||
const gridSpacingSvgUnits = mainMapSvgUnitsPerScreenPixel
|
||||
|
||||
// Get cursor position in SVG coordinates
|
||||
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
|
||||
|
||||
// Calculate grid bounds (magnifier viewport)
|
||||
const magnifiedHeight = viewBoxHeight / currentZoom
|
||||
const gridLeft = cursorSvgX - magnifiedViewBoxWidth / 2
|
||||
const gridRight = cursorSvgX + magnifiedViewBoxWidth / 2
|
||||
const gridTop = cursorSvgY - magnifiedHeight / 2
|
||||
const gridBottom = cursorSvgY + magnifiedHeight / 2
|
||||
|
||||
// Calculate grid line positions aligned with cursor (crosshair center)
|
||||
const lines: Array<{ type: 'h' | 'v'; pos: number }> = []
|
||||
|
||||
// Vertical lines (aligned with cursor X)
|
||||
const firstVerticalLine =
|
||||
Math.floor((gridLeft - cursorSvgX) / gridSpacingSvgUnits) * gridSpacingSvgUnits +
|
||||
cursorSvgX
|
||||
for (let x = firstVerticalLine; x <= gridRight; x += gridSpacingSvgUnits) {
|
||||
lines.push({ type: 'v', pos: x })
|
||||
}
|
||||
|
||||
// Horizontal lines (aligned with cursor Y)
|
||||
const firstHorizontalLine =
|
||||
Math.floor((gridTop - cursorSvgY) / gridSpacingSvgUnits) * gridSpacingSvgUnits +
|
||||
cursorSvgY
|
||||
for (let y = firstHorizontalLine; y <= gridBottom; y += gridSpacingSvgUnits) {
|
||||
lines.push({ type: 'h', pos: y })
|
||||
}
|
||||
|
||||
// Apply opacity to grid color
|
||||
const baseOpacity = isDark ? 0.5 : 0.6
|
||||
const finalOpacity = baseOpacity * gridOpacity
|
||||
const gridColor = `rgba(251, 191, 36, ${finalOpacity})`
|
||||
|
||||
return (
|
||||
<g data-element="pixel-grid-overlay">
|
||||
{lines.map((line, i) =>
|
||||
line.type === 'v' ? (
|
||||
<line
|
||||
key={`vgrid-${i}`}
|
||||
x1={line.pos}
|
||||
y1={gridTop}
|
||||
x2={line.pos}
|
||||
y2={gridBottom}
|
||||
stroke={gridColor}
|
||||
strokeWidth={viewBoxWidth / 2000}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
) : (
|
||||
<line
|
||||
key={`hgrid-${i}`}
|
||||
x1={gridLeft}
|
||||
y1={line.pos}
|
||||
x2={gridRight}
|
||||
y2={line.pos}
|
||||
stroke={gridColor}
|
||||
strokeWidth={viewBoxWidth / 2000}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})()}
|
||||
</animated.svg>
|
||||
|
||||
{/* Magnifier label */}
|
||||
|
|
@ -1939,10 +2128,67 @@ export function MapRenderer({
|
|||
fontSize: '11px',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? '#60a5fa' : '#3b82f6',
|
||||
pointerEvents: 'none',
|
||||
pointerEvents: pointerLocked ? 'none' : 'auto',
|
||||
cursor: pointerLocked ? 'default' : 'pointer',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Request pointer lock when user clicks on notice
|
||||
if (!pointerLocked && containerRef.current) {
|
||||
e.stopPropagation() // Prevent click from bubbling to map
|
||||
containerRef.current.requestPointerLock()
|
||||
}
|
||||
}}
|
||||
data-element="magnifier-label"
|
||||
>
|
||||
{magnifierSpring.zoom.to((z) => `${z.toFixed(1)}× Zoom`)}
|
||||
{magnifierSpring.zoom.to((z) => {
|
||||
const multiplier = magnifierSpring.movementMultiplier.get()
|
||||
|
||||
// When in pointer lock mode, show "Precision mode active" notice
|
||||
if (pointerLocked) {
|
||||
return 'Precision mode active'
|
||||
}
|
||||
|
||||
// When NOT in pointer lock, calculate screen pixel ratio
|
||||
const containerRect = containerRef.current?.getBoundingClientRect()
|
||||
const svgRect = svgRef.current?.getBoundingClientRect()
|
||||
if (!containerRect || !svgRect) {
|
||||
return `${z.toFixed(1)}×`
|
||||
}
|
||||
|
||||
const magnifierWidth = containerRect.width * 0.5
|
||||
const viewBoxParts = mapData.viewBox.split(' ').map(Number)
|
||||
const viewBoxWidth = viewBoxParts[2]
|
||||
|
||||
if (!viewBoxWidth || isNaN(viewBoxWidth)) {
|
||||
return `${z.toFixed(1)}×`
|
||||
}
|
||||
|
||||
// SVG units visible in magnifier
|
||||
const magnifiedViewBoxWidth = viewBoxWidth / z
|
||||
|
||||
// Screen pixels per SVG unit in magnifier window
|
||||
const magnifierScreenPixelsPerSvgUnit = magnifierWidth / magnifiedViewBoxWidth
|
||||
|
||||
// SVG units per screen pixel on main map
|
||||
const mainMapSvgUnitsPerScreenPixel = viewBoxWidth / svgRect.width
|
||||
|
||||
// Screen pixel movement in magnifier =
|
||||
// (SVG units moved on main map) × (screen pixels per SVG unit in magnifier)
|
||||
const screenPixelRatio =
|
||||
mainMapSvgUnitsPerScreenPixel * magnifierScreenPixelsPerSvgUnit
|
||||
|
||||
// If at or above threshold, show clickable notice to activate precision controls
|
||||
if (screenPixelRatio >= PRECISION_MODE_THRESHOLD) {
|
||||
return 'Click here (not map) for precision mode'
|
||||
}
|
||||
|
||||
// Below threshold - show debug info in dev, simple zoom in prod
|
||||
if (SHOW_MAGNIFIER_DEBUG_INFO) {
|
||||
return `${z.toFixed(1)}× | ${screenPixelRatio.toFixed(1)} px/px`
|
||||
}
|
||||
|
||||
return `${z.toFixed(1)}×`
|
||||
})}
|
||||
</animated.div>
|
||||
</animated.div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export function PlayingPhase() {
|
|||
|
||||
// Get the display name for the current prompt
|
||||
const currentRegionName = state.currentPrompt
|
||||
? mapData.regions.find((r) => r.id === state.currentPrompt)?.name ?? null
|
||||
? (mapData.regions.find((r) => r.id === state.currentPrompt)?.name ?? null)
|
||||
: null
|
||||
|
||||
// Debug logging
|
||||
|
|
|
|||
Loading…
Reference in New Issue