Skip to content

fix(globe): stop the auto-spin fighting scroll zoom - #246

Open
Jose-Gael-Cruz-Lopez wants to merge 2 commits into
mainfrom
fix/globe-zoom-fights-scroll
Open

fix(globe): stop the auto-spin fighting scroll zoom#246
Jose-Gael-Cruz-Lopez wants to merge 2 commits into
mainfrom
fix/globe-zoom-fights-scroll

Conversation

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Collaborator

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

userInteracting was set by mousedown and touchstart only (globe-map.tsx:164-165). Wheel zoom fires neither, so the auto-spin never knew the user had taken the camera.

Worse, mapbox emits moveend on every wheel tick: HandlerManager calls map._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 fresh easeTo({duration: 1000}) on each tick, rotating the globe out from under the zoom the whole time.

Clicking a pin was smooth because openHackathon() sets spinEnabledRef.current = false before flyTo — the one path that disables the spin.

It's also bounded: spinGlobe bails 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

Measured Result
48 markers, 0 with the pulse animation not marker count or CSS
Idle frame time 16.6 ms avg / 17.6 ms worst rendering was never the problem
5 consecutive spin easeTo calls ~1000 ms apart spin chain confirmed live

Changes

  • 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. Ordering matters and holds: mapEvent registers first in _addDefaultHandlers, scrollZoom last, so the gesture is recorded before the spin can re-arm.
  • 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 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.

Testing

The spin logic moves to lib/globe-spin.ts because it was untestable inside a useEffect closure. 11 tests, written first and watched fail. The one that matters:

it("treats wheel zoom as interaction")

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 — documented on the type.

eslint 0 · tsc --noEmit 0 · 11/11 new, full suite green · opennextjs-cloudflare build verified on this code (Edge middleware, wrangler deploy --dry-run accepts 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

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>
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hackhq Ready Ready Preview Aug 4, 2026 9:51pm

Request Review

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 4, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Collaborator Author

Review pass — three defects found and fixed (a40887b)

I reviewed this branch for functionality errors and found three, one of them introduced by the first commit.

1. 🔴 The wheel-idle resume could kill the spin permanently (new in this PR)

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:

timer fires at +499.99 -> isInteracting=true   *** spin dead ***
timer fires at +500    -> isInteracting=false  spin resumes OK

setTimeout normally fires late, which is why CI and local runs never hit it. But performance.now() is deliberately coarsened for Spectre mitigation — 100 ms granularity under Firefox's resistFingerprinting — and a floor-rounded pair of readings lands under the boundary easily.

Fix: 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 here. 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 instead of null at exactly the cutoff

Zero degrees isn't "no spin" to the caller — it still ran a 1 s easeTo to the same centre, whose moveend re-armed the next one. A permanent no-op animation loop at exactly zoom 4. The cutoff is now inclusive.


eslint 0 · tsc --noEmit 0 · 165 tests (was 162). The three new interaction tests and the tightened cutoff assertion were written first and watched fail.

Still unverified, same as before: the gesture itself. The browser harness throttles requestAnimationFrame in a background tab, so a real scroll never reaches the page. Please confirm the feel by hand before merging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant