You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[BUG]: geo projections lose their D3 default rotation #7949
Geo.updateProjection calls projection.rotate([-rotation.lon, -rotation.lat, rotation.roll]) unconditionally. For a world-scope map projection.rotation defaults to [0, 0, 0], so any rotation the D3 projection ships with is discarded.
Six of the projections in plotly.js carry a non-identity default rotation:
projection
D3 default rotation
albers
[96, 0, 0]
bertin1953
[-16.5, -42, 0]
gringorten quincuncial
[-90, -90, 45]
peirce quincuncial
[-90, -90, 45]
sinu mollweide
[-20, -55, 0]
wiechel
[0, -90, 45]
For the quincuncials and wiechel that rotation is geometric: it places the sphere in the polar aspect and tilts the square 45°, and is applied inside the projection's own constructor:
Replacing it renders a different orientation from the one D3 defines. (For albers, D3's [96, 0, 0] preconfigures it for the USA, so discarding it is probably intended.)
Screenshots/Video
For 'peirce quincuncial':
D3 projection
Plotly projection
Steps to reproduce
Be on master
Open Plotly devtools
Enter the following snippet into the browser console:
Independently verified at head ea028a66f8 (full agent, local clone + node REPL).
Enumeration confirms the report exactly. I instantiated all 84 projections listed in constants.projNames (src/plots/geo/constants.js:4) against the installed [email protected] / [email protected]: precisely six carry non-identity constructor rotations, matching this issue's table — albers[96,0,0], bertin1953[-16.5,-42,0], gringorten quincuncial/peirce quincuncial[-90,-90,45], sinu mollweide[-20,-55,0], wiechel[0,-90,45]; the other 78 default to [0,0,0]. (albers usa has no .rotate at all — getProjection stubs missing methods at src/plots/geo/geo.js:724-732.) Replacement semantics reproduced:
constp=require('d3-geo-projection').geoPeirceQuincuncial();p.rotate();// [-90, -90, 45] (constructor)p.rotate([-0,-0,0]);// what updateProjection does for world scopep.rotate();// [0, 0, 0] — canonical polar aspect discarded
The unconditional replace is src/plots/geo/geo.js:307, fed by coercion defaults that are [0,0,0]-based for world scope (src/plots/geo/layout_defaults.js:118-122, scopeDefaults.world.projRotate). None of the six affected projections is clipped per lonaxisSpan (src/plots/geo/constants.js:126), so all of them flow through this exact path.
One caution on the compose-onto-default fix direction: additive composition quietly flips the meaning of projection.rotation.* from absolute d3 angles to offsets relative to the constructor rotation, and several subsystems persist/read those values assuming they're absolute:
zoom.js reads projection.rotate() off the live projection and emits/stores 'geo.projection.rotation.lon': -rotate[0] during pan (both the plotly_relayouting payload and syncCb);
saveViewInitial (geo.js:629-659) snapshots rotation.lon for uirevision/reset;
the fitbounds branch deletes projection.rotation.lon(/lat) and lets it be re-derived (layout_defaults.js:213-221).
Under an offset scheme each convention needs revisiting, and any previously persisted relayout/uirevision values would be misinterpreted after upgrade.
A lower-blast-radius alternative: keep .replace() semantics and instead make the coerced defaults correct per projection — a small dfltRotation table consulted in handleGeoDefaults when coercing projection.rotation.lat/.roll (world scope). rotation.lon can stay center-of-lonaxis.range-derived, so the existing "lon follows the view" behavior is untouched; with nothing set by the user, the layout then already equals the D3 constructor rotation and geo.js:307 keeps writing absolute angles. Zoom/uirevision/fitbounds invariants survive unchanged. Either way the fix is a visible orientation change for the same six projections, so image baselines need updating — but this variant doesn't change what a stored rotation value means.
Happy to prototype either variant with jasmine coverage over the six types if maintainers have a preference on semantics.
Activity
fix(geo): preserve D3 projection default rotation when user does not …
CAOShurong commentedon Aug 24, 2026
Independently verified at head
ea028a66f8(full agent, local clone + node REPL).Enumeration confirms the report exactly. I instantiated all 84 projections listed in
constants.projNames(src/plots/geo/constants.js:4) against the installed[email protected]/[email protected]: precisely six carry non-identity constructor rotations, matching this issue's table —albers[96,0,0],bertin1953[-16.5,-42,0],gringorten quincuncial/peirce quincuncial[-90,-90,45],sinu mollweide[-20,-55,0],wiechel[0,-90,45]; the other 78 default to[0,0,0]. (albers usahas no.rotateat all —getProjectionstubs missing methods at src/plots/geo/geo.js:724-732.) Replacement semantics reproduced:The unconditional replace is src/plots/geo/geo.js:307, fed by coercion defaults that are
[0,0,0]-based for world scope (src/plots/geo/layout_defaults.js:118-122,scopeDefaults.world.projRotate). None of the six affected projections is clipped perlonaxisSpan(src/plots/geo/constants.js:126), so all of them flow through this exact path.One caution on the compose-onto-default fix direction: additive composition quietly flips the meaning of
projection.rotation.*from absolute d3 angles to offsets relative to the constructor rotation, and several subsystems persist/read those values assuming they're absolute:zoom.jsreadsprojection.rotate()off the live projection and emits/stores'geo.projection.rotation.lon': -rotate[0]during pan (both theplotly_relayoutingpayload andsyncCb);saveViewInitial(geo.js:629-659) snapshotsrotation.lonfor uirevision/reset;fitboundsbranch deletesprojection.rotation.lon(/lat)and lets it be re-derived (layout_defaults.js:213-221).Under an offset scheme each convention needs revisiting, and any previously persisted relayout/uirevision values would be misinterpreted after upgrade.
A lower-blast-radius alternative: keep
.replace()semantics and instead make the coerced defaults correct per projection — a smalldfltRotationtable consulted inhandleGeoDefaultswhen coercingprojection.rotation.lat/.roll(world scope).rotation.loncan stay center-of-lonaxis.range-derived, so the existing "lon follows the view" behavior is untouched; with nothing set by the user, the layout then already equals the D3 constructor rotation and geo.js:307 keeps writing absolute angles. Zoom/uirevision/fitbounds invariants survive unchanged. Either way the fix is a visible orientation change for the same six projections, so image baselines need updating — but this variant doesn't change what a stored rotation value means.Happy to prototype either variant with jasmine coverage over the six types if maintainers have a preference on semantics.