fix(globe): stop the auto-spin fighting scroll zoom - #246
fix(globe): stop the auto-spin fighting scroll zoom#246Jose-Gael-Cruz-Lopez wants to merge 2 commits into
Conversation
Zooming the globe with a wheel or trackpad was slow and glitchy, while clicking a pin and flying to it was smooth. That asymmetry is the bug. `userInteracting` was set by `mousedown` and `touchstart` only. Wheel zoom fires neither, so the auto-spin never knew the user had taken the camera. And mapbox emits `moveend` on *every* wheel tick -- its HandlerManager calls `map._stop(true)` when a handler goes active, which cancels the in-flight ease and invokes its end callback -- so each tick re-armed a fresh 1s easeTo that rotated the globe out from under the zoom. The click path was smooth because openHackathon() sets spinEnabledRef.current = false before flyTo. Measured before changing anything: 48 markers, none animated, idle frame time 16.6ms avg / 17.6ms worst. Rendering was never the problem. Fixes, in order of how much they mattered: - `wheel` now marks the camera as user-owned. It has no end event, so a WHEEL_IDLE_MS timer resumes the spin once scrolling actually stops. - The `zoom` listener only pushes to React on threshold crossings. It fired a setState per frame, and crossing 3.2 unmounted the filter bar mid-gesture. - `antialias` is now conditional on devicePixelRatio. MSAA costs the most on 2x displays and helps least there. - `unmapped` is memoised; it re-filtered the whole array on every render. The spin decision logic moves to lib/globe-spin.ts so it can be tested -- it was unreachable inside a useEffect closure. 11 tests, written first and watched fail. Times are injected and must come from a monotonic clock: a backwards wall-clock step would latch isInteracting true, and since only a timer resumes the spin the globe would stop rotating with nothing to restart it. Not verified: the gesture itself. The browser harness backgrounds the tab and throttles requestAnimationFrame, so a real scroll never reaches the page. Someone should confirm the feel by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
hackhq | a40887b | Commit Preview URL Branch Preview URL |
Aug 04 2026, 09:53 PM |
1. The wheel-idle resume could kill the spin permanently. The timer scheduled after the last wheel tick called spinGlobe, which re-checked isInteracting(performance.now()). That timer was the only thing scheduled, so if it fired even a fraction under WHEEL_IDLE_MS the check declined and left nothing behind to try again -- the globe stopped rotating for good, silently. Probed against the committed logic: a callback observing +499.99ms strands it, +500 resumes. setTimeout normally fires late, which is why nothing caught this. But performance.now() is deliberately coarsened for Spectre mitigation -- 100ms granularity under Firefox's resistFingerprinting -- and a floor-rounded pair of readings lands under the boundary easily. The timer firing *is* the end of the gesture, so it now says so: wheelEnded() clears the reservation outright instead of leaving isInteracting to re-derive it from a second clock. 2. Pinch-zoom handed the camera back mid-gesture. Mapbox re-fires the raw DOM touchend (mapbox-gl-dev.js:92198), which fires once per finger. Lifting the first finger of a two-finger pinch ran releaseCamera and started the globe rotating while the second finger was still zooming -- the same fight this branch exists to fix, on touch instead of wheel. Pre-existing, not a regression, but it belongs with this change. Release now waits for the last finger: on touchend the lifted finger is in changedTouches, so an empty `touches` is the one to act on. 3. spinDegrees returned 0 rather than null at exactly the cutoff. Zero degrees is not "no spin" to the caller -- it still ran a 1s easeTo to the same centre whose moveend re-armed the next, a permanent no-op animation loop at exactly zoom 4. The cutoff is now inclusive. eslint 0, tsc 0, 165 tests (was 162). The three new interaction tests and the tightened cutoff assertion were written first and watched fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review pass — three defects found and fixed (
|
Zooming the globe with a wheel or trackpad felt slow, unresponsive and glitchy. Clicking a pin and flying to it felt fine. That asymmetry is the bug, and it points straight at the cause.
Root cause
userInteractingwas set bymousedownandtouchstartonly (globe-map.tsx:164-165). Wheel zoom fires neither, so the auto-spin never knew the user had taken the camera.Worse, mapbox emits
moveendon every wheel tick:HandlerManagercallsmap._stop(true)when a handler goes active, which cancels the in-flight ease and invokes its end callback.map.on("moveend", spinGlobe)therefore re-armed a fresheaseTo({duration: 1000})on each tick, rotating the globe out from under the zoom the whole time.Clicking a pin was smooth because
openHackathon()setsspinEnabledRef.current = falsebeforeflyTo— the one path that disables the spin.It's also bounded:
spinGlobebails above zoom 4, so the fight only happens between 1.55 and 4 — exactly the zoom-in phase, which is why it mysteriously smooths out once you're deep in.Ruled out first
easeTocalls ~1000 ms apartChanges
wheelnow marks the camera as user-owned. It has no end event, so aWHEEL_IDLE_MStimer resumes the spin once scrolling actually stops. Ordering matters and holds:mapEventregisters first in_addDefaultHandlers,scrollZoomlast, so the gesture is recorded before the spin can re-arm.zoomlistener only pushes to React on threshold crossings. It fired asetStateper frame, and crossing 3.2 unmounted the filter bar mid-gesture.antialiasis conditional ondevicePixelRatio. MSAA costs the most on 2x displays and helps least there.unmappedis memoised — it re-filtered the whole array on every render.Testing
The spin logic moves to
lib/globe-spin.tsbecause it was untestable inside auseEffectclosure. 11 tests, written first and watched fail. The one that matters:Times are injected and must come from a monotonic clock. A backwards wall-clock step would latch
isInteractingtrue, and since only a timer resumes the spin, the globe would stop rotating with nothing to restart it — documented on the type.eslint0 ·tsc --noEmit0 · 11/11 new, full suite green ·opennextjs-cloudflare buildverified on this code (Edge middleware,wrangler deploy --dry-runaccepts the worker).Not verified
The gesture itself. The browser harness backgrounds the tab and throttles
requestAnimationFrame, so a real scroll never reaches the page. The mechanism is established from the code and the mapbox source, not from watching it stop stuttering — please confirm the feel by hand.Note
I did not change the globe resuming its spin after you stop zooming (below zoom 4). That's existing product intent, and it now waits for you to finish instead of fighting you. If it should stay still once someone deliberately zooms in, that's a one-line change but a product call.
🤖 Generated with Claude Code