feat: use big.js for arbitrary precision in cursor and zoom math

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-19 18:45:23 -06:00
parent d85450a4cd
commit 8e9c0548c8
2 changed files with 70 additions and 25 deletions

View File

@@ -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",

View File

@@ -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<number>(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