From 8e9c0548c831230ebc01ccf5daa5887a7ea5d6ab Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Wed, 19 Nov 2025 18:45:23 -0600 Subject: [PATCH] feat: use big.js for arbitrary precision in cursor and zoom math MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: - JavaScript's 64-bit floats lose precision with ultra-small movements - Vatican City (~0.05px) requires 0.03x dampened cursor (0.001px precision) - At 1000x zoom with tiny deltas, floating-point rounding causes "one pixel works" - Coordinate transformations compound precision loss Solution: - Install big.js for arbitrary-precision decimal arithmetic - Use Big for cursor position tracking (cursorPositionRef stores Big values) - Use Big for movement delta calculations (e.movementX * 0.03) - Use Big for container → SVG coordinate transformations - Use Big for zoom viewport calculations at extreme zoom levels (1000x) - Convert to numbers only when interfacing with DOM/display Technical details: - cursorPositionRef: { x: Big; y: Big } instead of { x: number; y: number } - Cursor deltas: new Big(e.movementX).times(currentMultiplier) - SVG coordinates: cursorXBig.minus(offset).times(scale).plus(viewBoxX) - Zoom viewport: new Big(viewBoxWidth).div(testZoom) - Maintains full precision through entire calculation chain This should allow Vatican City to be clickable from multiple cursor positions instead of requiring exact pixel-perfect positioning. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/web/package.json | 2 + .../components/MapRenderer.tsx | 93 ++++++++++++++----- 2 files changed, 70 insertions(+), 25 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 1264c7b2..1a6bbf10 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -61,6 +61,7 @@ "@use-gesture/react": "^10.3.1", "bcryptjs": "^2.4.3", "better-sqlite3": "^12.4.1", + "big.js": "^7.0.1", "d3-force": "^3.0.0", "drizzle-orm": "^0.44.6", "embla-carousel-autoplay": "^8.6.0", @@ -108,6 +109,7 @@ "@testing-library/react": "^16.3.0", "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", + "@types/big.js": "^6.2.2", "@types/d3-force": "^3.0.10", "@types/js-yaml": "^4.0.9", "@types/node": "^20.0.0", 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 44b0c7e4..b43123af 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 @@ -15,6 +15,7 @@ import { import { forceSimulation, forceCollide, forceX, forceY, type SimulationNodeDatum } from 'd3-force' import { getMapData, getFilteredMapData, filterRegionsByContinent } from '../maps' import type { ContinentId } from '../continents' +import Big from 'big.js' interface BoundingBox { minX: number @@ -182,7 +183,9 @@ export function MapRenderer({ const [showLockPrompt, setShowLockPrompt] = useState(true) // Cursor position tracking (container-relative coordinates) - const cursorPositionRef = useRef<{ x: number; y: number } | null>(null) + // Using Big.js for arbitrary precision to handle ultra-precise cursor movements + // when dampened to 0.03x for sub-pixel regions like Vatican City + const cursorPositionRef = useRef<{ x: Big; y: Big } | null>(null) const [smallestRegionSize, setSmallestRegionSize] = useState(Infinity) // Debug: Track bounding boxes for visualization @@ -763,29 +766,46 @@ export function MapRenderer({ const svgRect = svgRef.current.getBoundingClientRect() // Get cursor position relative to container - let cursorX: number - let cursorY: number + // Using Big.js for arbitrary precision in cursor tracking + let cursorXBig: Big + let cursorYBig: Big if (pointerLocked) { // When pointer is locked, use movement deltas with precision multiplier - const lastX = cursorPositionRef.current?.x ?? containerRect.width / 2 - const lastY = cursorPositionRef.current?.y ?? containerRect.height / 2 + const lastX = cursorPositionRef.current?.x ?? new Big(containerRect.width / 2) + const lastY = cursorPositionRef.current?.y ?? new Big(containerRect.height / 2) // Apply smoothly animated movement multiplier for gradual cursor dampening transitions // This prevents jarring changes when moving between regions of different sizes const currentMultiplier = magnifierSpring.movementMultiplier.get() - cursorX = lastX + e.movementX * currentMultiplier - cursorY = lastY + e.movementY * currentMultiplier - // Clamp to container bounds - cursorX = Math.max(0, Math.min(containerRect.width, cursorX)) - cursorY = Math.max(0, Math.min(containerRect.height, cursorY)) + // Use Big.js for precise delta calculation + // This maintains precision even with 0.03x multiplier for sub-pixel regions + const deltaX = new Big(e.movementX).times(currentMultiplier) + const deltaY = new Big(e.movementY).times(currentMultiplier) + + cursorXBig = lastX.plus(deltaX) + cursorYBig = lastY.plus(deltaY) + + // Clamp to container bounds (convert to Big for comparison) + const minBound = new Big(0) + const maxXBound = new Big(containerRect.width) + const maxYBound = new Big(containerRect.height) + + if (cursorXBig.lt(minBound)) cursorXBig = minBound + if (cursorXBig.gt(maxXBound)) cursorXBig = maxXBound + if (cursorYBig.lt(minBound)) cursorYBig = minBound + if (cursorYBig.gt(maxYBound)) cursorYBig = maxYBound } else { // Normal mode: use absolute position - cursorX = e.clientX - containerRect.left - cursorY = e.clientY - containerRect.top + cursorXBig = new Big(e.clientX - containerRect.left) + cursorYBig = new Big(e.clientY - containerRect.top) } + // Convert to regular numbers for display and comparisons + const cursorX = cursorXBig.toNumber() + const cursorY = cursorYBig.toNumber() + // Check if cursor is over the SVG const isOverSvg = cursorX >= svgRect.left - containerRect.left && @@ -803,8 +823,8 @@ export function MapRenderer({ // No velocity tracking needed - zoom adapts immediately to region size - // Update cursor position ref for next frame - cursorPositionRef.current = { x: cursorX, y: cursorY } + // Update cursor position ref for next frame (store Big values for precision) + cursorPositionRef.current = { x: cursorXBig, y: cursorYBig } setCursorPosition({ x: cursorX, y: cursorY }) // Define 50px × 50px detection box around cursor @@ -999,26 +1019,49 @@ export function MapRenderer({ const MIN_ZOOM = 1 const ZOOM_STEP = 0.9 // Reduce by 10% each iteration - // Convert cursor position to SVG coordinates - const scaleX = viewBoxWidth / svgRect.width - const scaleY = viewBoxHeight / svgRect.height + // Convert cursor position to SVG coordinates using Big.js for precision + const scaleXBig = new Big(viewBoxWidth).div(svgRect.width) + const scaleYBig = new Big(viewBoxHeight).div(svgRect.height) const viewBoxX = viewBoxParts[0] || 0 const viewBoxY = viewBoxParts[1] || 0 - const cursorSvgX = (cursorX - (svgRect.left - containerRect.left)) * scaleX + viewBoxX - const cursorSvgY = (cursorY - (svgRect.top - containerRect.top)) * scaleY + viewBoxY + + // Use Big.js for precise coordinate transformation + const cursorSvgXBig = cursorXBig + .minus(svgRect.left - containerRect.left) + .times(scaleXBig) + .plus(viewBoxX) + const cursorSvgYBig = cursorYBig + .minus(svgRect.top - containerRect.top) + .times(scaleYBig) + .plus(viewBoxY) + + // Convert to numbers for use in calculations + const cursorSvgX = cursorSvgXBig.toNumber() + const cursorSvgY = cursorSvgYBig.toNumber() // Zoom search logging disabled for performance for (let testZoom = MAX_ZOOM; testZoom >= MIN_ZOOM; testZoom *= ZOOM_STEP) { // Calculate the SVG viewport that will be shown in the magnifier at this zoom - const magnifiedViewBoxWidth = viewBoxWidth / testZoom - const magnifiedViewBoxHeight = viewBoxHeight / testZoom + // Use Big.js for precise viewport calculations at extreme zoom levels + const testZoomBig = new Big(testZoom) + const magnifiedViewBoxWidthBig = new Big(viewBoxWidth).div(testZoomBig) + const magnifiedViewBoxHeightBig = new Big(viewBoxHeight).div(testZoomBig) // The viewport is centered on cursor position, but clamped to map bounds - let viewportLeft = cursorSvgX - magnifiedViewBoxWidth / 2 - let viewportRight = cursorSvgX + magnifiedViewBoxWidth / 2 - let viewportTop = cursorSvgY - magnifiedViewBoxHeight / 2 - let viewportBottom = cursorSvgY + magnifiedViewBoxHeight / 2 + const halfWidthBig = magnifiedViewBoxWidthBig.div(2) + const halfHeightBig = magnifiedViewBoxHeightBig.div(2) + + let viewportLeftBig = cursorSvgXBig.minus(halfWidthBig) + let viewportRightBig = cursorSvgXBig.plus(halfWidthBig) + let viewportTopBig = cursorSvgYBig.minus(halfHeightBig) + let viewportBottomBig = cursorSvgYBig.plus(halfHeightBig) + + // Convert to regular numbers for comparison and display + let viewportLeft = viewportLeftBig.toNumber() + let viewportRight = viewportRightBig.toNumber() + let viewportTop = viewportTopBig.toNumber() + let viewportBottom = viewportBottomBig.toNumber() // Clamp viewport to stay within map bounds const mapLeft = viewBoxX