[BUG]: geo projections lose their D3 default rotation #7949
Copy link
Copy link
Open
Labels
Description
Description
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:
// d3-geo-projection/src/quincuncial/index.js
return projection(projectQuincuncial)
.rotate([-90, -90, 45])
.clipAngle(180 - 1e-3);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:
Plotly.newPlot(gd, [{
type: 'choropleth',
locations: ['GHA', 'ARG', 'AUS', 'CAN'],
z: [1, 2, 3, 4],
showscale: false
}], {
geo: { projection: { type: 'peirce quincuncial' }, fitbounds: false },
height: 800
});- Note that
- Expected: the canonical peirce quincuncial orientation.
- Actual: rendered with rotation
[0, 0, 0].
Screenshots
Notes
- A fix needs to compose the requested rotation onto each projection's default rather than replace it, since
d3.geoProjection.rotate()replaces - This was discovered in fix: Improve handling of auto-fitting with many features and the antimeridian on geo traces #7948. It's possible that this is intentional for a good reason, but it would be worth looking at changing it regardless.
Reactions are currently unavailable


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 installedd3-geo@1.12.1/d3-geo-projection@2.9.0: 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.