@@ -63,6 +63,35 @@ const PROJECTION_FACTORIES: Record<D3NamedProjection, (() => GeoProjection) | un
6363const 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+ } ;
6695export 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-
94117const selectorChartCenter = createSelectorMemoized (
95118 selectorChartGeoProjectionZoomState ,
96119 ( geoProjectionZoom ) : [ number , number ] | null => geoProjectionZoom ?. center ?? [ 0 , 0 ] ,
97120) ;
98121
99122const 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 } ,
0 commit comments