@dxos/react-ui-geo 0.8.4-staging.ac66bdf99f → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSE +102 -5
  2. package/data/countries-10m.ts +12 -0
  3. package/data/countries-110m.ts +4 -10579
  4. package/data/countries-50m.ts +12 -0
  5. package/dist/lib/browser/chunk-SC2FBYFU.mjs +17 -0
  6. package/dist/lib/browser/chunk-SC2FBYFU.mjs.map +7 -0
  7. package/dist/lib/browser/countries-10m-CWWDOKH7.mjs +6 -0
  8. package/dist/lib/browser/countries-10m-CWWDOKH7.mjs.map +7 -0
  9. package/dist/lib/browser/countries-110m-72QBAA5E.mjs +6 -0
  10. package/dist/lib/browser/countries-110m-72QBAA5E.mjs.map +7 -0
  11. package/dist/lib/browser/countries-50m-H7SL7KVF.mjs +6 -0
  12. package/dist/lib/browser/countries-50m-H7SL7KVF.mjs.map +7 -0
  13. package/dist/lib/browser/data.mjs +1 -1
  14. package/dist/lib/browser/index.mjs +774 -223
  15. package/dist/lib/browser/index.mjs.map +4 -4
  16. package/dist/lib/browser/meta.json +1 -1
  17. package/dist/lib/browser/translations.mjs +19 -0
  18. package/dist/lib/browser/translations.mjs.map +7 -0
  19. package/dist/lib/node-esm/chunk-VZENBYLJ.mjs +19 -0
  20. package/dist/lib/node-esm/chunk-VZENBYLJ.mjs.map +7 -0
  21. package/dist/lib/node-esm/countries-10m-DJZV66KG.mjs +8 -0
  22. package/dist/lib/node-esm/countries-10m-DJZV66KG.mjs.map +7 -0
  23. package/dist/lib/node-esm/countries-110m-H3WY6K4Q.mjs +8 -0
  24. package/dist/lib/node-esm/countries-110m-H3WY6K4Q.mjs.map +7 -0
  25. package/dist/lib/node-esm/countries-50m-ZY7Z3IWD.mjs +8 -0
  26. package/dist/lib/node-esm/countries-50m-ZY7Z3IWD.mjs.map +7 -0
  27. package/dist/lib/node-esm/data.mjs +1 -1
  28. package/dist/lib/node-esm/index.mjs +774 -223
  29. package/dist/lib/node-esm/index.mjs.map +4 -4
  30. package/dist/lib/node-esm/meta.json +1 -1
  31. package/dist/lib/node-esm/translations.mjs +21 -0
  32. package/dist/lib/node-esm/translations.mjs.map +7 -0
  33. package/dist/types/data/airports.d.ts +4 -4
  34. package/dist/types/data/airports.d.ts.map +1 -1
  35. package/dist/types/data/cities.d.ts.map +1 -1
  36. package/dist/types/data/countries-10m.d.ts +8 -0
  37. package/dist/types/data/countries-10m.d.ts.map +1 -0
  38. package/dist/types/data/countries-110m.d.ts +2 -30
  39. package/dist/types/data/countries-110m.d.ts.map +1 -1
  40. package/dist/types/data/countries-50m.d.ts +8 -0
  41. package/dist/types/data/countries-50m.d.ts.map +1 -0
  42. package/dist/types/data/countries-dots-3.d.ts.map +1 -1
  43. package/dist/types/data/countries-dots-4.d.ts.map +1 -1
  44. package/dist/types/src/components/Globe/Globe.d.ts +18 -10
  45. package/dist/types/src/components/Globe/Globe.d.ts.map +1 -1
  46. package/dist/types/src/components/Globe/Globe.stories.d.ts +16 -8
  47. package/dist/types/src/components/Globe/Globe.stories.d.ts.map +1 -1
  48. package/dist/types/src/components/Map/Map.d.ts +49 -13
  49. package/dist/types/src/components/Map/Map.d.ts.map +1 -1
  50. package/dist/types/src/components/Map/Map.stories.d.ts +9 -5
  51. package/dist/types/src/components/Map/Map.stories.d.ts.map +1 -1
  52. package/dist/types/src/components/Toolbar/Controls.d.ts.map +1 -1
  53. package/dist/types/src/data.d.ts +9 -1
  54. package/dist/types/src/data.d.ts.map +1 -1
  55. package/dist/types/src/hooks/context.d.ts +37 -0
  56. package/dist/types/src/hooks/context.d.ts.map +1 -1
  57. package/dist/types/src/hooks/index.d.ts +3 -0
  58. package/dist/types/src/hooks/index.d.ts.map +1 -1
  59. package/dist/types/src/hooks/useDrag.d.ts +22 -2
  60. package/dist/types/src/hooks/useDrag.d.ts.map +1 -1
  61. package/dist/types/src/hooks/useGlobeZoomHandler.d.ts +3 -2
  62. package/dist/types/src/hooks/useGlobeZoomHandler.d.ts.map +1 -1
  63. package/dist/types/src/hooks/useMapZoomHandler.d.ts +1 -1
  64. package/dist/types/src/hooks/useMapZoomHandler.d.ts.map +1 -1
  65. package/dist/types/src/hooks/useSimplifiedTopology.d.ts +32 -0
  66. package/dist/types/src/hooks/useSimplifiedTopology.d.ts.map +1 -0
  67. package/dist/types/src/hooks/useSpinner.d.ts +1 -1
  68. package/dist/types/src/hooks/useSpinner.d.ts.map +1 -1
  69. package/dist/types/src/hooks/useTopology.d.ts +26 -0
  70. package/dist/types/src/hooks/useTopology.d.ts.map +1 -0
  71. package/dist/types/src/hooks/useTour.d.ts +3 -2
  72. package/dist/types/src/hooks/useTour.d.ts.map +1 -1
  73. package/dist/types/src/hooks/useWheel.d.ts +24 -0
  74. package/dist/types/src/hooks/useWheel.d.ts.map +1 -0
  75. package/dist/types/src/index.d.ts +0 -2
  76. package/dist/types/src/index.d.ts.map +1 -1
  77. package/dist/types/src/translations.d.ts +4 -4
  78. package/dist/types/src/translations.d.ts.map +1 -1
  79. package/dist/types/src/util/animation.d.ts +16 -0
  80. package/dist/types/src/util/animation.d.ts.map +1 -0
  81. package/dist/types/src/util/debug.d.ts.map +1 -1
  82. package/dist/types/src/util/index.d.ts +2 -0
  83. package/dist/types/src/util/index.d.ts.map +1 -1
  84. package/dist/types/src/util/inertia.d.ts.map +1 -1
  85. package/dist/types/src/util/path.d.ts.map +1 -1
  86. package/dist/types/src/util/render.d.ts +25 -1
  87. package/dist/types/src/util/render.d.ts.map +1 -1
  88. package/dist/types/src/util/styles.d.ts +4 -0
  89. package/dist/types/src/util/styles.d.ts.map +1 -0
  90. package/dist/types/tsconfig.tsbuildinfo +1 -1
  91. package/package.json +26 -24
  92. package/src/components/Globe/Globe.stories.tsx +135 -58
  93. package/src/components/Globe/Globe.tsx +237 -120
  94. package/src/components/Map/Map.stories.tsx +58 -12
  95. package/src/components/Map/Map.tsx +293 -91
  96. package/src/components/Toolbar/Controls.tsx +1 -1
  97. package/src/data.ts +19 -2
  98. package/src/hooks/context.tsx +44 -0
  99. package/src/hooks/index.ts +3 -0
  100. package/src/hooks/useDrag.ts +33 -5
  101. package/src/hooks/useGlobeZoomHandler.ts +2 -1
  102. package/src/hooks/useSimplifiedTopology.ts +81 -0
  103. package/src/hooks/useSpinner.ts +1 -1
  104. package/src/hooks/useTopology.ts +95 -0
  105. package/src/hooks/useTour.ts +70 -81
  106. package/src/hooks/useWheel.ts +83 -0
  107. package/src/index.ts +0 -2
  108. package/src/util/animation.ts +35 -0
  109. package/src/util/index.ts +2 -0
  110. package/src/util/inertia.ts +87 -4
  111. package/src/util/render.ts +105 -16
  112. package/src/util/styles.ts +62 -0
  113. package/dist/lib/browser/chunk-GMWLKTLN.mjs +0 -9
  114. package/dist/lib/browser/chunk-GMWLKTLN.mjs.map +0 -7
  115. package/dist/lib/browser/countries-110m-ZM3ZIEFS.mjs +0 -37859
  116. package/dist/lib/browser/countries-110m-ZM3ZIEFS.mjs.map +0 -7
  117. package/dist/lib/node-esm/chunk-JODBF4CC.mjs +0 -11
  118. package/dist/lib/node-esm/chunk-JODBF4CC.mjs.map +0 -7
  119. package/dist/lib/node-esm/countries-110m-3SFASWVD.mjs +0 -37861
  120. package/dist/lib/node-esm/countries-110m-3SFASWVD.mjs.map +0 -7
@@ -4,6 +4,7 @@
4
4
 
5
5
  import {
6
6
  type GeoProjection,
7
+ selection as d3Selection,
7
8
  easeLinear,
8
9
  easeSinOut,
9
10
  geoMercator,
@@ -17,7 +18,9 @@ import { type ControlPosition } from 'leaflet';
17
18
  import React, {
18
19
  type PropsWithChildren,
19
20
  forwardRef,
21
+ useCallback,
20
22
  useEffect,
23
+ useId,
21
24
  useImperativeHandle,
22
25
  useMemo,
23
26
  useRef,
@@ -34,13 +37,24 @@ import {
34
37
  useDynamicRef,
35
38
  useThemeContext,
36
39
  } from '@dxos/react-ui';
37
- import { composable, composableProps, mx } from '@dxos/ui-theme';
40
+ import { composable, composableProps } from '@dxos/react-ui';
41
+ import { mx } from '@dxos/ui-theme';
38
42
 
39
- import { GlobeContext, type GlobeContextType, type Point, type Vector, useGlobeContext } from '../../hooks';
43
+ import {
44
+ GlobeContext,
45
+ type GlobeContextType,
46
+ type GlobeController,
47
+ type Point,
48
+ type Size,
49
+ type Vector,
50
+ useGlobeContext,
51
+ } from '../../hooks';
40
52
  import {
41
53
  type Features,
42
54
  type StyleSet,
43
55
  createLayers,
56
+ createRotationTween,
57
+ flyDuration,
44
58
  geoToPosition,
45
59
  positionToRotation,
46
60
  renderLayers,
@@ -56,19 +70,15 @@ const defaultStyles: Record<ThemeMode, StyleSet> = {
56
70
  background: {
57
71
  fillStyle: '#EEE',
58
72
  },
59
-
60
73
  water: {
61
74
  fillStyle: '#555',
62
75
  },
63
-
64
76
  land: {
65
77
  fillStyle: '#999',
66
78
  },
67
-
68
79
  line: {
69
80
  strokeStyle: 'darkred',
70
81
  },
71
-
72
82
  point: {
73
83
  fillStyle: '#111111',
74
84
  strokeStyle: '#111111',
@@ -80,19 +90,15 @@ const defaultStyles: Record<ThemeMode, StyleSet> = {
80
90
  background: {
81
91
  fillStyle: '#111111',
82
92
  },
83
-
84
93
  water: {
85
94
  fillStyle: '#123E6A',
86
95
  },
87
-
88
96
  land: {
89
97
  fillStyle: '#032153',
90
98
  },
91
-
92
99
  line: {
93
100
  strokeStyle: '#111111',
94
101
  },
95
-
96
102
  point: {
97
103
  fillStyle: '#111111',
98
104
  strokeStyle: '#111111',
@@ -102,11 +108,6 @@ const defaultStyles: Record<ThemeMode, StyleSet> = {
102
108
  },
103
109
  };
104
110
 
105
- export type GlobeController = {
106
- canvas: HTMLCanvasElement;
107
- projection: GeoProjection;
108
- } & Pick<GlobeContextType, 'zoom' | 'translation' | 'rotation' | 'setZoom' | 'setTranslation' | 'setRotation'>;
109
-
110
111
  export type ProjectionType = 'orthographic' | 'mercator' | 'transverse-mercator';
111
112
 
112
113
  const projectionMap: Record<ProjectionType, () => GeoProjection> = {
@@ -128,44 +129,97 @@ const getProjection = (type: GlobeCanvasProps['projection'] = 'orthographic'): G
128
129
  // Root
129
130
  //
130
131
 
131
- type GlobeRootProps = Partial<Pick<GlobeContextType, 'center' | 'zoom' | 'translation' | 'rotation'>>;
132
+ const DEFAULT_ZOOM = 1.5;
133
+
134
+ type GlobeRootProps = Partial<Pick<GlobeContextType, 'center' | 'zoom' | 'translation' | 'rotation'>> &
135
+ PropsWithChildren;
132
136
 
133
- const GlobeRoot = composable<HTMLDivElement, GlobeRootProps>(
137
+ /**
138
+ * Headless context/state provider for the globe. Renders no DOM; wrap a `Globe.Viewport` to mount
139
+ * the measured container. The current `GlobeController` (built by `Globe.Canvas`) is exposed via
140
+ * this component's `ref`.
141
+ */
142
+ const GlobeRoot = forwardRef<GlobeController | null, GlobeRootProps>(
134
143
  (
135
- { children, center: centerProp, zoom: zoomProp, translation: translationProp, rotation: rotationProp, ...props },
144
+ {
145
+ children,
146
+ center: centerProp,
147
+ zoom: zoomProp = DEFAULT_ZOOM,
148
+ translation: translationProp,
149
+ rotation: rotationProp,
150
+ },
136
151
  forwardedRef,
137
152
  ) => {
138
- const localRef = useRef<HTMLDivElement>(null);
139
- const composedRef = useComposedRefs<HTMLDivElement>(localRef, forwardedRef);
140
- const { width, height } = useResizeDetector<HTMLDivElement>({ targetRef: localRef });
141
-
153
+ const [size, setSize] = useState<Size>({ width: 0, height: 0 });
142
154
  const [center, setCenter] = useControlledState(centerProp);
143
- const [zoom, setZoom] = useControlledState(zoomProp ?? 4);
155
+ const [zoom, setZoom] = useControlledState(zoomProp);
144
156
  const [translation, setTranslation] = useControlledState<Point>(translationProp);
145
157
  const [rotation, setRotation] = useControlledState<Vector>(rotationProp);
146
158
 
159
+ // The controller is built by Globe.Canvas and registered here; Globe.Root re-exposes it via its
160
+ // ref. Held in state (not a ref) so that when Globe.Canvas registers a new controller, Root
161
+ // re-renders and the imperative handle below updates — re-running consumer effects keyed on the
162
+ // controller (e.g. useDrag/useWheel) once the canvas mounts.
163
+ const [controller, setController] = useState<GlobeController | null>(null);
164
+ const registerController = useCallback((next: GlobeController | null) => setController(next), []);
165
+
166
+ // Expose the live controller (or null until Globe.Canvas mounts) via Root's ref.
167
+ useImperativeHandle(forwardedRef, () => controller, [controller]);
168
+
147
169
  return (
148
170
  <GlobeContext.Provider
149
171
  value={{
150
- size: { width, height },
172
+ size,
151
173
  center,
152
174
  zoom,
153
175
  translation,
154
176
  rotation,
177
+ setSize,
155
178
  setCenter,
156
179
  setZoom,
157
180
  setTranslation,
158
181
  setRotation,
182
+ registerController,
159
183
  }}
160
184
  >
161
- <div {...composableProps(props, { classNames: 'relative dx-container' })} ref={composedRef}>
162
- {children}
163
- </div>
185
+ {children}
164
186
  </GlobeContext.Provider>
165
187
  );
166
188
  },
167
189
  );
168
190
 
191
+ GlobeRoot.displayName = 'Globe.Root';
192
+
193
+ //
194
+ // Viewport
195
+ //
196
+
197
+ /** Consumer-facing props for `Globe.Viewport` (classNames + children). */
198
+ type GlobeViewportProps = ThemedClassName<PropsWithChildren>;
199
+
200
+ /**
201
+ * Measured container for the globe. Renders the `relative dx-container` div, observes its size, and
202
+ * publishes measurements to the context so `Globe.Canvas` can size the canvas.
203
+ */
204
+ const GlobeViewport = composable<HTMLDivElement>(({ children, ...props }, forwardedRef) => {
205
+ const { setSize } = useGlobeContext();
206
+ const localRef = useRef<HTMLDivElement>(null);
207
+ const composedRef = useComposedRefs<HTMLDivElement>(localRef, forwardedRef);
208
+ const { width, height } = useResizeDetector<HTMLDivElement>({ targetRef: localRef });
209
+
210
+ useEffect(() => {
211
+ setSize({ width: width ?? 0, height: height ?? 0 });
212
+ }, [width, height, setSize]);
213
+
214
+ return (
215
+ <div {...composableProps(props, { classNames: 'relative dx-container' })} ref={composedRef}>
216
+ {children}
217
+ </div>
218
+ );
219
+ });
220
+
221
+ GlobeViewport.displayName = 'Globe.Viewport';
222
+
169
223
  //
170
224
  // Canvas
171
225
  //
@@ -178,103 +232,165 @@ type GlobeCanvasProps = {
178
232
  };
179
233
 
180
234
  /**
181
- * Basic globe renderer.
235
+ * Basic globe renderer. Builds the imperative `GlobeController` and registers it with `Globe.Root`
236
+ * (via `registerController` from context) so consumers reading the controller from Root's ref get
237
+ * the live instance.
182
238
  * https://github.com/topojson/world-atlas
183
239
  */
184
- // TODO(burdon): Move controller to root.
185
- const GlobeCanvas = forwardRef<GlobeController, GlobeCanvasProps>(
186
- ({ projection: projectionProp, topology, features, styles: stylesProp }, forwardRef) => {
187
- const { themeMode } = useThemeContext();
188
- const styles = useMemo(() => stylesProp ?? defaultStyles[themeMode], [stylesProp, themeMode]);
189
-
190
- // Canvas.
191
- const [canvas, setCanvas] = useState<HTMLCanvasElement>(null);
192
- const canvasRef = (canvas: HTMLCanvasElement) => setCanvas(canvas);
193
-
194
- // Projection.
195
- const projection = useMemo(() => getProjection(projectionProp), [projectionProp]);
196
-
197
- // Layers.
198
- // TODO(burdon): Generate on the fly based on what is visible.
199
- const layers = useMemo(() => {
200
- return timer(() => createLayers(topology as Topology, features, styles));
201
- }, [topology, features, styles]);
202
-
203
- // State.
204
- const { size, center, zoom, translation, rotation, setCenter, setZoom, setTranslation, setRotation } =
205
- useGlobeContext();
206
- const zoomRef = useDynamicRef(zoom);
207
-
208
- // Update rotation.
209
- useEffect(() => {
210
- if (center) {
211
- setZoom(1);
212
- setRotation(positionToRotation(geoToPosition(center)));
213
- }
214
- }, [center]);
215
-
216
- // External controller.
217
- const zooming = useRef(false);
218
- useImperativeHandle<GlobeController, GlobeController>(forwardRef, () => {
219
- return {
220
- canvas,
221
- projection,
222
- center,
223
- get zoom() {
224
- return zoomRef.current;
225
- },
226
- translation,
227
- rotation,
228
- setCenter,
229
- setZoom: (state) => {
230
- if (typeof state === 'function') {
231
- const is = interpolateNumber(zoomRef.current, state(zoomRef.current));
232
- // Stop easing if already zooming.
233
- transition()
234
- .ease(zooming.current ? easeLinear : easeSinOut)
235
- .duration(200)
236
- .tween('scale', () => (t) => setZoom(is(t)))
237
- .on('end', () => {
238
- zooming.current = false;
239
- });
240
- } else {
241
- setZoom(state);
242
- }
243
- },
244
- setTranslation,
245
- setRotation,
246
- };
247
- }, [canvas]);
248
-
249
- // https://d3js.org/d3-geo/path#geoPath
250
- // https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/getContext
251
- const generator = useMemo(
252
- () => canvas && projection && geoPath(projection, canvas.getContext('2d', { alpha: false })),
253
- [canvas, projection],
254
- );
240
+ const GlobeCanvas = ({ projection: projectionProp, topology, features, styles: stylesProp }: GlobeCanvasProps) => {
241
+ const { themeMode } = useThemeContext();
242
+ const styles = useMemo(() => stylesProp ?? defaultStyles[themeMode], [stylesProp, themeMode]);
243
+ const { size, center, zoom, translation, rotation, setZoom, setTranslation, setRotation, registerController } =
244
+ useGlobeContext();
245
+
246
+ const zoomRef = useDynamicRef(zoom);
247
+
248
+ // Canvas.
249
+ const [canvas, setCanvas] = useState<HTMLCanvasElement>(null);
250
+ const canvasRef = (canvas: HTMLCanvasElement) => setCanvas(canvas);
251
+
252
+ // Projection.
253
+ const projection = useMemo(() => getProjection(projectionProp), [projectionProp]);
254
+
255
+ // Layers.
256
+ // TODO(burdon): Generate on-the-fly based on what is visible.
257
+ const layers = useMemo(() => {
258
+ return timer(() => createLayers(topology as Topology, features, styles));
259
+ }, [topology, features, styles]);
260
+
261
+ // Update rotation when the center changes. Preserve current zoom — callers can set zoom
262
+ // independently via the `zoom` prop or `setZoom` on the controller.
263
+ useEffect(() => {
264
+ if (center) {
265
+ setRotation(positionToRotation(geoToPosition(center)));
266
+ }
267
+ }, [center]);
268
+
269
+ // Per-instance flyTo plumbing. d3 named transitions are scoped per DOM
270
+ // element and `d3Selection()` returns the documentElement root, so a
271
+ // shared name would let one globe's flyTo interrupt another's. The
272
+ // useId-scoped name keeps each Globe.Canvas's transition isolated.
273
+ const flyToSelection = useMemo(() => d3Selection(), []);
274
+ const flyToTransitionName = `globe-fly-to-${useId()}`;
275
+ useEffect(
276
+ () => () => {
277
+ flyToSelection.interrupt(flyToTransitionName);
278
+ },
279
+ [flyToSelection, flyToTransitionName],
280
+ );
255
281
 
256
- // Render on change.
257
- useEffect(() => {
258
- if (canvas && projection) {
259
- timer(() => {
260
- // https://d3js.org/d3-geo/projection
261
- projection
262
- .scale((Math.min(size.width, size.height) / 2) * zoom)
263
- .translate([size.width / 2 + (translation?.x ?? 0), size.height / 2 + (translation?.y ?? 0)])
264
- .rotate(rotation ?? [0, 0, 0]);
265
-
266
- renderLayers(generator, layers, zoom, styles);
267
- });
268
- }
269
- }, [generator, size, zoom, translation, rotation, layers]);
270
-
271
- if (!size.width || !size.height) {
272
- return null;
282
+ // External controller.
283
+ const zooming = useRef(false);
284
+ const controller = useMemo<GlobeController>(() => {
285
+ return {
286
+ canvas,
287
+ projection,
288
+ get zoom() {
289
+ return zoomRef.current;
290
+ },
291
+ translation,
292
+ rotation,
293
+ setZoom: (state) => {
294
+ if (typeof state === 'function') {
295
+ const is = interpolateNumber(zoomRef.current, state(zoomRef.current));
296
+ // Stop easing if already zooming.
297
+ transition()
298
+ .ease(zooming.current ? easeLinear : easeSinOut)
299
+ .duration(200)
300
+ .tween('scale', () => (t) => setZoom(is(t)))
301
+ .on('end', () => {
302
+ zooming.current = false;
303
+ });
304
+ } else {
305
+ setZoom(state);
306
+ }
307
+ },
308
+ setTranslation,
309
+ setRotation,
310
+ flyTo: (target, options = {}) => {
311
+ const { duration = 1_200, tilt = 0, onTick } = options;
312
+ const p2 = geoToPosition(target);
313
+ const r1 = projection.rotate() as Vector;
314
+ const r2 = positionToRotation(p2, tilt);
315
+
316
+ // Approximate current centre from the inverse of the rotation.
317
+ const p1: [number, number] = [-r1[0], -r1[1]];
318
+ const rotationTween = createRotationTween(projection, setRotation, r1, r2);
319
+ const iz = target.zoom !== undefined ? interpolateNumber(zoomRef.current, target.zoom) : undefined;
320
+
321
+ flyToSelection.interrupt(flyToTransitionName);
322
+ const tx = flyToSelection.transition(flyToTransitionName).duration(flyDuration(p1, p2, duration, 1_500));
323
+ if (onTick) {
324
+ tx.tween('flyToOnTick', () => onTick);
325
+ }
326
+ tx.tween('flyToRotation', () => rotationTween);
327
+ if (iz) {
328
+ tx.tween('flyToZoom', () => (t: number) => setZoom(iz(t)));
329
+ }
330
+ return tx.end();
331
+ },
332
+ cancelFlyTo: () => {
333
+ flyToSelection.interrupt(flyToTransitionName);
334
+ },
335
+ };
336
+ // Keep the controller IDENTITY stable: only rebuild on these mount-stable deps. Deliberately
337
+ // exclude `center`/`translation`/`rotation` — they are read here as closure snapshots, and
338
+ // callers pass them as inline literals (e.g. `rotation={[0, 0, 0]}`), so a fresh reference every
339
+ // render would rebuild the controller, re-fire `registerController`, re-render Root, hand a new
340
+ // controller back through the consumer's `ref={setController}`, and loop forever. `projection`
341
+ // must stay in deps: switching between stories that pass different projection types creates a new
342
+ // instance via useMemo, and any consumer reading controller.projection (e.g. useDrag) would
343
+ // otherwise mutate a dead instance while the canvas renders the new one. The `useControlledState`
344
+ // setters and `zoomRef` are stable, so they need not be listed.
345
+ }, [canvas, projection, flyToSelection, flyToTransitionName]);
346
+
347
+ // Register the controller with Globe.Root and clear it on unmount.
348
+ useEffect(() => {
349
+ registerController(controller);
350
+ return () => registerController(null);
351
+ }, [registerController, controller]);
352
+
353
+ // https://d3js.org/d3-geo/path#geoPath
354
+ // https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/getContext
355
+ // Keep the context alpha-enabled: when a style set omits `background`, `renderLayers`
356
+ // clears to transparent so the canvas's themed CSS background (below) shows through the
357
+ // area outside the globe — correct in both light and dark mode.
358
+ const generator = useMemo(
359
+ () => canvas && projection && geoPath(projection, canvas.getContext('2d')),
360
+ [canvas, projection],
361
+ );
362
+
363
+ // Render on change.
364
+ useEffect(() => {
365
+ if (canvas && projection) {
366
+ timer(() => {
367
+ // https://d3js.org/d3-geo/projection
368
+ projection
369
+ .scale((Math.min(size.width, size.height) / 2) * zoom)
370
+ .translate([size.width / 2 + (translation?.x ?? 0), size.height / 2 + (translation?.y ?? 0)])
371
+ .rotate(rotation ?? [0, 0, 0]);
372
+
373
+ // Provide a view-center for per-frame culling — only meaningful for
374
+ // projections that present a single visible hemisphere (e.g.
375
+ // orthographic). For Mercator/transverse-mercator the whole sphere
376
+ // is always visible, so we skip culling.
377
+ const isOrthographic = !projectionProp || projectionProp === 'orthographic';
378
+ const [lambda, phi] = (rotation ?? [0, 0, 0]) as Vector;
379
+ const viewCenter: [number, number] | undefined = isOrthographic ? [-lambda, -phi] : undefined;
380
+
381
+ renderLayers(generator, layers, zoom, styles, viewCenter);
382
+ });
273
383
  }
384
+ }, [generator, size, zoom, translation, rotation, layers, projectionProp]);
274
385
 
275
- return <canvas ref={canvasRef} width={size.width} height={size.height} />;
276
- },
277
- );
386
+ if (!size.width || !size.height) {
387
+ return null;
388
+ }
389
+
390
+ return <canvas ref={canvasRef} className='bg-base-surface' width={size.width} height={size.height} />;
391
+ };
392
+
393
+ GlobeCanvas.displayName = 'Globe.Canvas';
278
394
 
279
395
  //
280
396
  // Debug
@@ -336,6 +452,7 @@ const GlobeAction = ({ onAction, position = 'bottomright', ...props }: GlobeCont
336
452
 
337
453
  export const Globe = {
338
454
  Root: GlobeRoot,
455
+ Viewport: GlobeViewport,
339
456
  Canvas: GlobeCanvas,
340
457
  Zoom: GlobeZoom,
341
458
  Action: GlobeAction,
@@ -343,4 +460,4 @@ export const Globe = {
343
460
  Panel: GlobePanel,
344
461
  };
345
462
 
346
- export type { GlobeRootProps, GlobeCanvasProps };
463
+ export type { GlobeRootProps, GlobeViewportProps, GlobeCanvasProps };
@@ -3,27 +3,54 @@
3
3
  //
4
4
 
5
5
  import { type Meta, type StoryObj } from '@storybook/react-vite';
6
- import React, { useState } from 'react';
6
+ import React, { useMemo, useState } from 'react';
7
7
 
8
+ import { Input, Panel, Toolbar } from '@dxos/react-ui';
8
9
  import { withLayout, withTheme } from '@dxos/react-ui/testing';
9
10
 
10
11
  import { useMapZoomHandler } from '../../hooks';
11
12
  import { type GeoMarker } from '../../types';
12
- import { Map, type MapController } from './Map';
13
+ import { Map, MapMarkersProps, MapTilesProps, type MapController } from './Map';
13
14
 
14
- const DefaultStory = ({ markers = [] }: { markers?: GeoMarker[] }) => {
15
+ type DefaultStoryProps = Pick<MapTilesProps, 'url'> & Pick<MapMarkersProps, 'markers'>;
16
+
17
+ const DefaultStory = ({ url: urlProp, markers = [] }: DefaultStoryProps) => {
15
18
  const [controller, setController] = useState<MapController>();
19
+ const [key, setKey] = useState('');
20
+ // Substitute the `${key}` placeholder in a keyed tile URL (e.g. MapTiler); undefined → default OSM tiles.
21
+ const url = useMemo(() => urlProp?.replace('${key}', key), [urlProp, key]);
22
+
16
23
  const handleZoomAction = useMapZoomHandler(controller);
17
24
 
18
25
  return (
19
- <Map.Root>
20
- <Map.Content ref={setController}>
21
- <Map.Tiles />
22
- <Map.Markers markers={markers} />
23
- <Map.Zoom position='bottomleft' onAction={handleZoomAction} />
24
- <Map.Action position='bottomright' />
25
- </Map.Content>
26
- </Map.Root>
26
+ <Panel.Root>
27
+ {urlProp && (
28
+ <Panel.Toolbar asChild>
29
+ <Toolbar.Root>
30
+ <Input.Root>
31
+ <Input.TextInput
32
+ spellCheck={false}
33
+ placeholder='API KEY'
34
+ value={key}
35
+ onChange={(ev) => setKey(ev.target.value)}
36
+ />
37
+ </Input.Root>
38
+ </Toolbar.Root>
39
+ </Panel.Toolbar>
40
+ )}
41
+ {/* Map.Root is headless (context only), so it sits outside Panel.Content; Panel.Content asChild
42
+ then targets the Leaflet frame (Map.Viewport) directly — no extra wrapper element. */}
43
+ <Map.Root ref={setController}>
44
+ <Panel.Content asChild>
45
+ <Map.Viewport>
46
+ <Map.Tiles url={url} />
47
+ <Map.Markers markers={markers} />
48
+ <Map.Zoom position='bottomleft' onAction={handleZoomAction} />
49
+ <Map.Action position='bottomright' />
50
+ </Map.Viewport>
51
+ </Panel.Content>
52
+ </Map.Root>
53
+ </Panel.Root>
27
54
  );
28
55
  };
29
56
 
@@ -43,7 +70,17 @@ type Story = StoryObj<typeof meta>;
43
70
 
44
71
  export const Default: Story = {};
45
72
 
46
- export const WithMarkers: Story = {
73
+ export const Bounds: Story = {
74
+ args: {
75
+ markers: [
76
+ { id: 'london', title: 'London', location: { lat: 51.5074, lng: -0.1278 } },
77
+ { id: 'barcelona', title: 'Barcelona', location: { lat: 41.3851, lng: 2.1734 } },
78
+ { id: 'warsaw', title: 'Warsaw', location: { lat: 52.2297, lng: 21.0122 } },
79
+ ] as GeoMarker[],
80
+ },
81
+ };
82
+
83
+ export const Markers: Story = {
47
84
  args: {
48
85
  markers: [
49
86
  { id: 'los angeles', title: 'Los Angeles', location: { lat: 34.0522, lng: -118.2437 } },
@@ -71,3 +108,12 @@ export const WithMarkers: Story = {
71
108
  ] as GeoMarker[],
72
109
  },
73
110
  };
111
+
112
+ /**
113
+ * https://docs.maptiler.com/leaflet
114
+ */
115
+ export const CustomTiles: Story = {
116
+ args: {
117
+ url: 'https://api.maptiler.com/maps/streets-v4/{z}/{x}/{y}.png?&key=${key}',
118
+ },
119
+ };