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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user