Skip to content

Commit 5d5e407

Browse files
committed
refine-scale
1 parent f16364c commit 5d5e407

7 files changed

Lines changed: 290 additions & 145 deletions

File tree

docs/data/charts/map/ProjectionMapShape.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export default function ProjectionMapShape() {
111111
{...(!autoRotation && { rotate: rotation })}
112112
{...(!autoTranslation && { translate: translation })}
113113
{...(!autoScale && { scale })}
114+
zoom
114115
height={360}
115116
>
116117
<ChartsSurface>

docs/data/charts/map/ProjectionMapShape.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ export default function ProjectionMapShape() {
123123
{...(!autoRotation && { rotate: rotation })}
124124
{...(!autoTranslation && { translate: translation })}
125125
{...(!autoScale && { scale })}
126+
zoom
126127
height={360}
127128
>
128129
<ChartsSurface>

docs/data/charts/map/ZoomMap.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export default function ZoomMap() {
3434
geoData={countries}
3535
projection="naturalEarth1"
3636
height={360}
37+
zoom
3738
series={[
3839
{
3940
type: 'mapShape',

packages/x-charts-premium/src/internals/plugins/useGeoProjection/useGeoProjection.selectors.ts

Lines changed: 70 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,35 @@ const PROJECTION_FACTORIES: Record<D3NamedProjection, (() => GeoProjection) | un
6363
const isConicProjection = (projection: GeoProjection): projection is GeoConicProjection => {
6464
return 'parallels' in projection && typeof projection.parallels === 'function';
6565
};
66+
67+
/**
68+
* Resolves a raw projection input (a d3-geo name like `'mercator'`, or a `GeoProjection` instance)
69+
* into a usable `GeoProjection`. Named projections are instantiated from their factory and, when
70+
* conic, configured with `parallels`. Returns `null` for an unknown name.
71+
*/
72+
const resolveProjection = (
73+
projectionInput: GeoProjectionInput,
74+
parallels: [number, number],
75+
): GeoProjection | null => {
76+
if (typeof projectionInput !== 'string') {
77+
return projectionInput;
78+
}
79+
const factory = PROJECTION_FACTORIES[projectionInput];
80+
if (!factory) {
81+
if (process.env.NODE_ENV !== 'production') {
82+
console.error(
83+
`MUI X Charts: Unknown projection name '${projectionInput}'. ` +
84+
`Expected one of: ${Object.keys(PROJECTION_FACTORIES).join(', ')}.`,
85+
);
86+
}
87+
return null;
88+
}
89+
const projection = factory();
90+
if (isConicProjection(projection)) {
91+
projection.parallels(parallels);
92+
}
93+
return projection;
94+
};
6695
export const selectorChartGeoProjectionState = (
6796
state: GeoChartState,
6897
): UseGeoProjectionState['geoProjection'] | undefined => state.geoProjection;
@@ -85,22 +114,16 @@ export const selectorChartZoomLevel = createSelector(
85114
selectorChartGeoProjectionZoomState,
86115
(geoProjectionZoom): number | null => geoProjectionZoom?.zoomLevel ?? 1,
87116
);
88-
89-
const selectorChartRotate = createSelectorMemoized(
90-
selectorChartGeoProjectionState,
91-
(geoProjection): [number, number] | null => geoProjection?.rotate ?? null,
92-
);
93-
94117
const selectorChartCenter = createSelectorMemoized(
95118
selectorChartGeoProjectionZoomState,
96119
(geoProjectionZoom): [number, number] | null => geoProjectionZoom?.center ?? [0, 0],
97120
);
98121

99122
const selectorChartParallels = createSelectorMemoized(
100123
selectorChartGeoProjectionState,
101-
selectorChartRotate,
102-
(geoProjection, rotate): [number, number] =>
103-
geoProjection?.parallels ?? (rotate ? [rotate[1] - 15, rotate[1] + 15] : [30, 30]),
124+
selectorChartCenter,
125+
(geoProjection, center): [number, number] =>
126+
geoProjection?.parallels ?? (center ? [-center[1] - 15, -center[1] + 15] : [30, 30]),
104127
);
105128
/**
106129
* Map a feature's `properties.name` to its index in `geoData.features`,
@@ -131,6 +154,28 @@ export const selectorChartGeoFeatureIndexesByName = createSelectorMemoized(
131154
},
132155
);
133156

157+
const selectorFitScale = createSelector(
158+
selectorChartRawGeoData,
159+
selectorChartDrawingArea,
160+
selectorChartRawProjection,
161+
selectorChartParallels,
162+
(geoData, drawingArea, projectionInput, parallels): number | null => {
163+
if (!geoData || !projectionInput) {
164+
return null;
165+
}
166+
const projection = resolveProjection(projectionInput, parallels);
167+
if (!projection) {
168+
return null;
169+
}
170+
const [[x0, y0], [x1, y1]] = geoPath(projection).bounds(geoData);
171+
const currentScale = projection.scale();
172+
return Math.min(
173+
currentScale * (drawingArea.width / (x1 - x0)),
174+
currentScale * (drawingArea.height / (y1 - y0)),
175+
);
176+
},
177+
);
178+
134179
/**
135180
* Resolves the raw `projection` input into a ready-to-use `GeoProjection` instance,
136181
* fitted to the chart's drawing area then zoomed/panned according to the current view.
@@ -147,101 +192,47 @@ export const selectorChartProjection = createSelectorMemoized(
147192
selectorChartRawProjection,
148193
selectorChartRawGeoData,
149194
selectorChartParallels,
150-
selectorChartRotate,
151195
selectorChartCenter,
152196
selectorChartZoomLevel,
153197
selectorChartDrawingArea,
198+
selectorFitScale,
154199
(
155200
projectionInput,
156201
geoData,
157202
parallels,
158-
rotate,
159203
center,
160204
zoomLevel,
161205
drawingArea,
206+
fitScale,
162207
): GeoProjection | null => {
163208
if (!projectionInput) {
164209
return null;
165210
}
166-
let projection: GeoProjection;
167-
if (typeof projectionInput === 'string') {
168-
const factory = PROJECTION_FACTORIES[projectionInput];
169-
if (!factory) {
170-
if (process.env.NODE_ENV !== 'production') {
171-
console.error(
172-
`MUI X Charts: Unknown projection name '${projectionInput}'. ` +
173-
`Expected one of: ${Object.keys(PROJECTION_FACTORIES).join(', ')}.`,
174-
);
175-
}
176-
return null;
177-
}
178-
projection = factory();
179-
if (isConicProjection(projection)) {
180-
projection.parallels(parallels);
181-
}
182-
} else {
183-
projection = projectionInput;
211+
const projection = resolveProjection(projectionInput, parallels);
212+
if (!projection) {
213+
return null;
184214
}
185215

186-
if (!geoData) {
216+
if (!geoData || fitScale == null) {
187217
return projection;
188218
}
189219

190-
if (rotate) {
191-
projection.rotate?.(rotate);
220+
if (center) {
221+
projection.rotate?.([-center[0], -center[1]]);
192222
}
193223

194-
const drawingAreaCenter = {
195-
x: drawingArea.left + drawingArea.width / 2,
196-
y: drawingArea.top + drawingArea.height / 2,
197-
};
224+
// `fitScale` is the `zoomLevel === 1` reference scale, computed independently in
225+
// `selectorFitScale` so it stays stable across pan/zoom transforms.
226+
projection.scale(zoomLevel != null && zoomLevel !== 1 ? fitScale * zoomLevel : fitScale);
198227

199-
// 1. Fit the data to the drawing area — this is the `zoomLevel === 1` reference scale.
228+
// Conic projections are positioned via `rotate`; `center` panning is not applied.
200229
if (isConicProjection(projection)) {
201-
const [[x0, y0], [x1, y1]] = geoPath(projection).bounds(geoData);
202-
const currentScale = projection.scale();
203-
const fitScale = Math.min(
204-
currentScale * (drawingArea.width / (x1 - x0)),
205-
currentScale * (drawingArea.height / (y1 - y0)),
206-
);
207-
projection.scale(fitScale);
208-
// Conic projections are positioned via `rotate`; `center` panning is not applied.
209-
if (zoomLevel != null && zoomLevel !== 1) {
210-
projection.scale(fitScale * zoomLevel);
211-
}
212230
return projection;
213231
}
214-
215-
projection.fitExtent?.(
216-
[
217-
[drawingArea.left, drawingArea.top],
218-
[drawingArea.left + drawingArea.width, drawingArea.top + drawingArea.height],
219-
],
220-
geoData,
221-
);
222-
const fitScale = projection.scale();
223-
224-
// The fitted projection centers the data: invert the drawing-area center to get the
225-
// default geographic center used when `center` is not set.
226-
const targetCenter =
227-
center ?? projection.invert?.([drawingAreaCenter.x, drawingAreaCenter.y]) ?? null;
228-
229-
// 2. Apply the zoom level relative to the fit scale.
230-
if (zoomLevel != null && zoomLevel !== 1) {
231-
projection.scale(fitScale * zoomLevel);
232-
}
233-
234-
// 3. Offset the translation so `targetCenter` lands at the drawing-area center.
235-
if (targetCenter && projection.invert) {
236-
projection.translate([0, 0]);
237-
const projected = projection(targetCenter);
238-
if (projected) {
239-
projection.translate([
240-
drawingAreaCenter.x - projected[0],
241-
drawingAreaCenter.y - projected[1],
242-
]);
243-
}
244-
}
232+
projection.translate([
233+
drawingArea.left + drawingArea.width / 2,
234+
drawingArea.top + drawingArea.height / 2,
235+
]);
245236

246237
return projection;
247238
},
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { geoMercator, geoOrthographic, type GeoProjection } from '@mui/x-charts-vendor/d3-geo';
3+
import { getRotation } from './mapZoom.utils';
4+
5+
const EXTENT: [[number, number], [number, number]] = [
6+
[0, 0],
7+
[600, 400],
8+
];
9+
10+
const sphere = { type: 'Sphere' } as const;
11+
12+
// Resolves the rotation returned by `getRotation`, applies it the same way the projection selector
13+
// does, and returns where `geoPoint` ends up on screen — which should match the target pixel.
14+
function projectAfterRotation(
15+
factory: () => GeoProjection,
16+
geoPoint: [number, number],
17+
to: [number, number],
18+
zoomFactor = 1,
19+
): [number, number] | null {
20+
const projection = factory().fitExtent(EXTENT, sphere);
21+
const center = getRotation(projection, geoPoint, to, zoomFactor);
22+
if (!center) {
23+
return null;
24+
}
25+
projection.scale(projection.scale() * zoomFactor).rotate([-center[0], -center[1]]);
26+
return projection(geoPoint) as [number, number];
27+
}
28+
29+
describe('getRotation', () => {
30+
it('keeps a point near the center under the cursor (mercator)', () => {
31+
const projected = projectAfterRotation(geoMercator, [10, 20], [320, 180]);
32+
expect(projected![0]).to.be.closeTo(320, 1e-4);
33+
expect(projected![1]).to.be.closeTo(180, 1e-4);
34+
});
35+
36+
it('keeps an off-central-meridian, high-latitude point under the cursor (mercator)', () => {
37+
// The case where the previous additive implementation drifted.
38+
const projected = projectAfterRotation(geoMercator, [-73, 60], [120, 90]);
39+
expect(projected![0]).to.be.closeTo(120, 1e-4);
40+
expect(projected![1]).to.be.closeTo(90, 1e-4);
41+
});
42+
43+
it('handles a non-trivial zoom factor', () => {
44+
const projected = projectAfterRotation(geoMercator, [40, -25], [450, 300], 2.5);
45+
expect(projected![0]).to.be.closeTo(450, 1e-4);
46+
expect(projected![1]).to.be.closeTo(300, 1e-4);
47+
});
48+
49+
it('works for an orthographic projection', () => {
50+
const projected = projectAfterRotation(geoOrthographic, [15, 35], [250, 150]);
51+
expect(projected![0]).to.be.closeTo(250, 1e-4);
52+
expect(projected![1]).to.be.closeTo(150, 1e-4);
53+
});
54+
55+
it('returns null when the projection is not invertible', () => {
56+
const projection = { invert: undefined } as unknown as GeoProjection;
57+
expect(getRotation(projection, [0, 0], [0, 0])).to.equal(null);
58+
});
59+
});

0 commit comments

Comments
 (0)