SIGN IN SIGN UP

Globe: pan with a versor so dragging stays consistent near the poles (#8154)

* feat(globe): opt-in quaternion (versor) panning via DragPanOptions.fixedBearing

Dragging the globe near or across the poles with the default bearing-preserving
mapping inverts and stalls (#8003). Add an opt-in versor mode: enable dragPan
with {fixedBearing: false} to rotate the globe with a single quaternion that
couples center and bearing, staying smooth at every latitude at the cost of a
changing bearing. Default behavior is unchanged.

Core of #5330 by @jcolot, reduced to the drag path (no inertia rework), rebased
onto main. Fixes #5296.

Assisted-By: Claude Fable 5 (claude-fable-5)

* rename versorSetLocationAtPoint to quaternionSetLocationAtPoint

Avoids a cspell dictionary entry for 'versor' and uses the more common term.
No public API change (DragPanOptions.fixedBearing is unchanged).

* make quaternion the default globe drag; drop the fixedBearing flag

Per review (#8017), fix the near-pole panning inversion (#5296) in the default
globe drag instead of behind an opt-in DragPanOptions.fixedBearing option:
vertical-perspective panning now always rotates the globe with a quaternion.
Removes the option and its plumbing, and the flag-demo example.

* Address review: drop duplicate comment and the unreachable NaN guard

The quaternionSetLocationAtPoint tsdoc already documents the behaviour, so
remove the duplicate inline comment in handleMapControlsPan. The NaN guard was
unreachable (lngLatBearingFromOrientation uses asin(clamp(...)) and atan2 on
finite quaternion components, so it never yields NaN); remove it.

* Globe pan: use quaternion rotation only near the poles, keep bearing elsewhere

Per review (jcolot): making the quaternion rotation the default meant every
globe drag changed bearing. Restrict it to the polar region (|lat| > 75, where
the bearing-preserving mapping inverts and stalls, #5296) and keep the
bearing-preserving drag everywhere else, so ordinary dragging no longer drifts
north-up. Adds a test covering both branches.

* feat(globe): keep bearing fixed while panning, opt out with fixedBearing

Quaternion panning currently rotates the map's bearing on every globe drag,
with no way back to the previous behaviour, since the bearing-preserving
path was removed. Users who never touch bearing find their map rotated
after a plain pan.

lngLatBearingFromOrientation already splits the orientation into a swing
(center) and a twist (bearing), so the twist can be dropped while keeping
the same quaternion: the globe still moves smoothly across the poles, and
the bearing stays put. That keeps the #5296 fix while leaving pan
behaviour unchanged for everyone who does not opt in.

Near the pole the swing longitude is ill-conditioned, so it is derived
instead from the tangential sweep of the pan delta around the pole, as
cross(r, panDelta) / |r|^2, and blended with a smoothstep over the last 12
degrees so there is no seam. Two numerical details this depends on: the
sweep must come from the real pixel delta, as crossing two near-equal
reconstructed positions loses the sign to cancellation at the pole; and the
blend ramp must be anchored on MAX_VALID_LATITUDE, where the center
actually locks, or it saturates and leaves the discarded swing term a vote
exactly where it is numerical noise.

* docs(examples): add globe example toggling DragPanOptions.fixedBearing

Demonstrates the quaternion-based panning mode added for DragPanOptions.fixedBearing,
letting users compare fixed-bearing vs. free-bearing dragging near the poles.

* docs(examples): describe fixedBearing in the globe panning example

Both settings rotate the globe with a quaternion, so the example should say
that fixedBearing selects what happens to the bearing rather than which
panning implementation is used.

* docs(globe): tighten the pan comments and say cursor, not finger

Move the description of the azimuthal "dial" into the tsdoc, where the rest
of the method's contract already lives, and keep inline comments for the two
numerical hazards that are easy to reintroduce: the ramp anchored on
MAX_VALID_LATITUDE, and deriving the sweep from the real pixel delta. Drop a
comment that only restated the line below it.

Say cursor rather than finger, since the same path serves mouse and touch.

* Address review: extract the near-pole handling, clean comments, fold the example into the globe demo

Assisted-By: Claude <noreply@anthropic.com>

* Remove the fixedBearing option: bearing-preserving pan is the only globe drag mode

Decided in review: ship the default behavior only, add the option back if
someone asks for the free-bearing mode. quaternionSetLocationAtPoint keeps
both modes as the internal primitive.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(globe): keep panning as the cursor reaches and leaves the globe

Panning currently stops dead when the cursor leaves the globe, and is
frantic just before it does. Both come from the same place: a ray at angle
`a` off the view axis meets the globe at `asin(D sin a) - a`, whose slope
runs away to infinity as the ray goes tangent. At the silhouette a pixel is
worth degrees of arc, and past it there is no intersection at all.

`screenPointToLocation` copes by snapping missing pixels onto the horizon
circle, but that collapses the radial dimension, so cursor motion along the
horizon becomes a twist about the view axis and the bearing spins away. The
guard avoided the spin by ignoring the drag, which also removed something
that worked before this feature: the previous delta-based pan never asked
where the cursor was, so it kept panning anywhere on screen.

Leave the exact curve shortly before tangency instead, and continue with a
hyperbola that matches it in both value and slope there, then decelerates and
saturates short of the far side. This follows Bell's virtual trackball, which
likewise blends the sphere into a hyperbolic sheet before reaching the rim.
The band is much narrower here, a tenth of the angle to the horizon, so
dragging on the globe stays exact almost everywhere; what it gives up is the
outermost sliver, where a pixel was already worth degrees of arc and the
tracking was not usable anyway.

Because the two curves meet in slope, there is no jump in speed at the
handover, so the falloff rate is derived from the geometry rather than
chosen. Only the width of the band is a matter of feel.

Both ends of the rotation also have to come from the same mapping. The
caller derives the source with `screenPointToLocation`, which snaps to the
horizon off the globe, so pairing it with the new mapping rotated between
two unrelated points and spun the globe. The source is re-derived from the
previous cursor position instead.

The ray's angle off the view axis is taken with atan2 rather than asin, so a
ray pointing away from the globe is just a wide angle that the falloff
saturates. That needs a ray more than 90 degrees off the axis, which the
pitch limit rules out today, but expressing it costs nothing and means this
never has to report that a point could not be mapped.

* fix(globe): stop the drag freezing next to a centred pole

The near-pole dial ignored the cursor's sweep within 20px of the pole, since
the angle a drag subtends grows without bound as the cursor closes on it.
But with the pole centred the dial supplies all of the longitude change, and
the center latitude is already clamped, so inside that spot the drag stopped
moving at all. At its edge the sweep reappeared at full size, stepping from
no rotation to about 20 degrees in a pixel.

Soften the radius instead of ignoring the sweep below it. The numerator
vanishes with the radius while the denominator levels off, so the dial eases
to nothing at the pole rather than switching off, and the value is unchanged
further out.

* docs(globe): trim the pan comments and move them out of the methods

Address review feedback: no in-method comments where the method already
carries a tsdoc, and the tsdoc itself was too long. What survives is what a
reader would otherwise get wrong, the frame swizzle, the numerical hazards
near the pole, and the reference for the falloff. The narration of how the
code came to look this way is left to the history.

* refactor(globe): name the helper versorSetLocationAtPoint again

A versor is a unit quaternion, which is exactly what this composes, so the
name says more than the general term does. It is also the word this
technique goes by in the mapping literature, from Jason Davies' versor
dragging onwards.

* refactor(globe): default versorSetLocationAtPoint to a fixed bearing

Dragging the globe is the only caller and always holds the bearing, so that
is what the default should be. The parameter stays because the exact
cursor tracking it enables is what the round-trip test checks, which is the
closest thing there is to a test of the quaternion itself, but it now sits
after the pan delta so the drag can leave it out entirely.

Setting the bearing to the value it already has was a no-op, so it now only
happens on the path that actually changes it.

* docs: add a changelog entry for the globe pan fix

Also teach cspell "versor" and the TVCG abbreviation used in the reference
for the falloff curve.

* test: pin the bundle sizes for the globe pan changes

* docs(globe): shorten the falloff comments and correct the attribution

The curve here is not Bell's, it borrows his idea of easing the sphere into
a hyperbola before the rim and then fits the hyperbola differently, so say
that rather than claiming to follow him. The reference now clearly belongs
to his construction and not to this one.

Also drop what the code already says: the arithmetic of where the handover
sits, and the exact radius Bell's geometry forces him to.

* test(globe): inline the setup helper into the pan test

It had one caller left once the bearing-changing case went away with the
fixedBearing option, so the indirection only hid what the test sets up.

---------

Co-authored-by: Clement IGONET <clement@igonet.fr>
Co-authored-by: Claude <noreply@anthropic.com>
J
Julien Colot committed
523fa440a39672628e2d0263e36795dc5e2965ca
Parent: e06d0c7
Committed by GitHub <noreply@github.com> on 8/12/2026, 8:18:00 AM