@dxos/react-ui-geo 0.8.4-main.fffef41 → 0.8.4-staging.60fe92afc8

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 (121) 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 +1046 -579
  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 +1046 -579
  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 +19 -9
  45. package/dist/types/src/components/Globe/Globe.d.ts.map +1 -1
  46. package/dist/types/src/components/Globe/Globe.stories.d.ts +17 -7
  47. package/dist/types/src/components/Globe/Globe.stories.d.ts.map +1 -1
  48. package/dist/types/src/components/Map/Map.d.ts +51 -9
  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 +38 -3
  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 +6 -6
  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 +41 -35
  92. package/src/components/Globe/Globe.stories.tsx +141 -65
  93. package/src/components/Globe/Globe.tsx +262 -119
  94. package/src/components/Map/Map.stories.tsx +59 -12
  95. package/src/components/Map/Map.tsx +325 -82
  96. package/src/components/Toolbar/Controls.tsx +5 -5
  97. package/src/data.ts +19 -2
  98. package/src/hooks/context.tsx +46 -31
  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 -2
  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/translations.ts +5 -5
  109. package/src/util/animation.ts +35 -0
  110. package/src/util/index.ts +2 -0
  111. package/src/util/inertia.ts +87 -4
  112. package/src/util/render.ts +105 -17
  113. package/src/util/styles.ts +62 -0
  114. package/dist/lib/browser/chunk-GMWLKTLN.mjs +0 -9
  115. package/dist/lib/browser/chunk-GMWLKTLN.mjs.map +0 -7
  116. package/dist/lib/browser/countries-110m-ZM3ZIEFS.mjs +0 -37859
  117. package/dist/lib/browser/countries-110m-ZM3ZIEFS.mjs.map +0 -7
  118. package/dist/lib/node-esm/chunk-JODBF4CC.mjs +0 -11
  119. package/dist/lib/node-esm/chunk-JODBF4CC.mjs.map +0 -7
  120. package/dist/lib/node-esm/countries-110m-3SFASWVD.mjs +0 -37861
  121. 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,
@@ -26,19 +29,32 @@ import React, {
26
29
  import { useResizeDetector } from 'react-resize-detector';
27
30
  import { type Topology } from 'topojson-specification';
28
31
 
29
- import { type ThemeMode, type ThemedClassName, useDynamicRef, useThemeContext } from '@dxos/react-ui';
30
- import { mx } from '@dxos/react-ui-theme';
32
+ import {
33
+ type ThemeMode,
34
+ type ThemedClassName,
35
+ useComposedRefs,
36
+ useControlledState,
37
+ useDynamicRef,
38
+ useThemeContext,
39
+ } from '@dxos/react-ui';
40
+ import { composable, composableProps } from '@dxos/react-ui';
41
+ import { mx } from '@dxos/ui-theme';
31
42
 
32
43
  import {
33
- GlobeContextProvider,
34
- type GlobeContextProviderProps,
44
+ GlobeContext,
35
45
  type GlobeContextType,
46
+ type GlobeController,
47
+ type Point,
48
+ type Size,
49
+ type Vector,
36
50
  useGlobeContext,
37
51
  } from '../../hooks';
38
52
  import {
39
53
  type Features,
40
54
  type StyleSet,
41
55
  createLayers,
56
+ createRotationTween,
57
+ flyDuration,
42
58
  geoToPosition,
43
59
  positionToRotation,
44
60
  renderLayers,
@@ -54,19 +70,15 @@ const defaultStyles: Record<ThemeMode, StyleSet> = {
54
70
  background: {
55
71
  fillStyle: '#EEE',
56
72
  },
57
-
58
73
  water: {
59
74
  fillStyle: '#555',
60
75
  },
61
-
62
76
  land: {
63
77
  fillStyle: '#999',
64
78
  },
65
-
66
79
  line: {
67
80
  strokeStyle: 'darkred',
68
81
  },
69
-
70
82
  point: {
71
83
  fillStyle: '#111111',
72
84
  strokeStyle: '#111111',
@@ -78,19 +90,15 @@ const defaultStyles: Record<ThemeMode, StyleSet> = {
78
90
  background: {
79
91
  fillStyle: '#111111',
80
92
  },
81
-
82
93
  water: {
83
94
  fillStyle: '#123E6A',
84
95
  },
85
-
86
96
  land: {
87
97
  fillStyle: '#032153',
88
98
  },
89
-
90
99
  line: {
91
100
  strokeStyle: '#111111',
92
101
  },
93
-
94
102
  point: {
95
103
  fillStyle: '#111111',
96
104
  strokeStyle: '#111111',
@@ -100,11 +108,6 @@ const defaultStyles: Record<ThemeMode, StyleSet> = {
100
108
  },
101
109
  };
102
110
 
103
- export type GlobeController = {
104
- canvas: HTMLCanvasElement;
105
- projection: GeoProjection;
106
- } & Pick<GlobeContextType, 'zoom' | 'translation' | 'rotation' | 'setZoom' | 'setTranslation' | 'setRotation'>;
107
-
108
111
  export type ProjectionType = 'orthographic' | 'mercator' | 'transverse-mercator';
109
112
 
110
113
  const projectionMap: Record<ProjectionType, () => GeoProjection> = {
@@ -126,19 +129,96 @@ const getProjection = (type: GlobeCanvasProps['projection'] = 'orthographic'): G
126
129
  // Root
127
130
  //
128
131
 
129
- type GlobeRootProps = PropsWithChildren<ThemedClassName<GlobeContextProviderProps>>;
132
+ const DEFAULT_ZOOM = 1.5;
130
133
 
131
- const GlobeRoot = ({ classNames, children, ...props }: GlobeRootProps) => {
132
- const { ref, width, height } = useResizeDetector<HTMLDivElement>();
134
+ type GlobeRootProps = Partial<Pick<GlobeContextType, 'center' | 'zoom' | 'translation' | 'rotation'>> &
135
+ PropsWithChildren;
133
136
 
134
- return (
135
- <div ref={ref} className={mx('relative flex grow overflow-hidden', classNames)}>
136
- <GlobeContextProvider size={{ width, height }} {...props}>
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>(
143
+ (
144
+ {
145
+ children,
146
+ center: centerProp,
147
+ zoom: zoomProp = DEFAULT_ZOOM,
148
+ translation: translationProp,
149
+ rotation: rotationProp,
150
+ },
151
+ forwardedRef,
152
+ ) => {
153
+ const [size, setSize] = useState<Size>({ width: 0, height: 0 });
154
+ const [center, setCenter] = useControlledState(centerProp);
155
+ const [zoom, setZoom] = useControlledState(zoomProp);
156
+ const [translation, setTranslation] = useControlledState<Point>(translationProp);
157
+ const [rotation, setRotation] = useControlledState<Vector>(rotationProp);
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
+
169
+ return (
170
+ <GlobeContext.Provider
171
+ value={{
172
+ size,
173
+ center,
174
+ zoom,
175
+ translation,
176
+ rotation,
177
+ setSize,
178
+ setCenter,
179
+ setZoom,
180
+ setTranslation,
181
+ setRotation,
182
+ registerController,
183
+ }}
184
+ >
137
185
  {children}
138
- </GlobeContextProvider>
186
+ </GlobeContext.Provider>
187
+ );
188
+ },
189
+ );
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}
139
217
  </div>
140
218
  );
141
- };
219
+ });
220
+
221
+ GlobeViewport.displayName = 'Globe.Viewport';
142
222
 
143
223
  //
144
224
  // Canvas
@@ -152,103 +232,165 @@ type GlobeCanvasProps = {
152
232
  };
153
233
 
154
234
  /**
155
- * 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.
156
238
  * https://github.com/topojson/world-atlas
157
239
  */
158
- // TODO(burdon): Move controller to root.
159
- const GlobeCanvas = forwardRef<GlobeController, GlobeCanvasProps>(
160
- ({ projection: projectionParam, topology, features, styles: stylesParam }, forwardRef) => {
161
- const { themeMode } = useThemeContext();
162
- const styles = useMemo(() => stylesParam ?? defaultStyles[themeMode], [stylesParam, themeMode]);
163
-
164
- // Canvas.
165
- const [canvas, setCanvas] = useState<HTMLCanvasElement>(null);
166
- const canvasRef = (canvas: HTMLCanvasElement) => setCanvas(canvas);
167
-
168
- // Projection.
169
- const projection = useMemo(() => getProjection(projectionParam), [projectionParam]);
170
-
171
- // Layers.
172
- // TODO(burdon): Generate on the fly based on what is visible.
173
- const layers = useMemo(() => {
174
- return timer(() => createLayers(topology as Topology, features, styles));
175
- }, [topology, features, styles]);
176
-
177
- // State.
178
- const { size, center, zoom, translation, rotation, setCenter, setZoom, setTranslation, setRotation } =
179
- useGlobeContext();
180
- const zoomRef = useDynamicRef(zoom);
181
-
182
- // Update rotation.
183
- useEffect(() => {
184
- if (center) {
185
- setZoom(1);
186
- setRotation(positionToRotation(geoToPosition(center)));
187
- }
188
- }, [center]);
189
-
190
- // External controller.
191
- const zooming = useRef(false);
192
- useImperativeHandle<GlobeController, GlobeController>(forwardRef, () => {
193
- return {
194
- canvas,
195
- projection,
196
- center,
197
- get zoom() {
198
- return zoomRef.current;
199
- },
200
- translation,
201
- rotation,
202
- setCenter,
203
- setZoom: (s) => {
204
- if (typeof s === 'function') {
205
- const is = interpolateNumber(zoomRef.current, s(zoomRef.current));
206
- // Stop easing if already zooming.
207
- transition()
208
- .ease(zooming.current ? easeLinear : easeSinOut)
209
- .duration(200)
210
- .tween('scale', () => (t) => setZoom(is(t)))
211
- .on('end', () => {
212
- zooming.current = false;
213
- });
214
- } else {
215
- setZoom(s);
216
- }
217
- },
218
- setTranslation,
219
- setRotation,
220
- };
221
- }, [canvas]);
222
-
223
- // https://d3js.org/d3-geo/path#geoPath
224
- // https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/getContext
225
- const generator = useMemo(
226
- () => canvas && projection && geoPath(projection, canvas.getContext('2d', { alpha: false })),
227
- [canvas, projection],
228
- );
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
+ );
229
281
 
230
- // Render on change.
231
- useEffect(() => {
232
- if (canvas && projection) {
233
- timer(() => {
234
- // https://d3js.org/d3-geo/projection
235
- projection
236
- .scale((Math.min(size.width, size.height) / 2) * zoom)
237
- .translate([size.width / 2 + (translation?.x ?? 0), size.height / 2 + (translation?.y ?? 0)])
238
- .rotate(rotation ?? [0, 0, 0]);
239
-
240
- renderLayers(generator, layers, zoom, styles);
241
- });
242
- }
243
- }, [generator, size, zoom, translation, rotation, layers]);
244
-
245
- if (!size.width || !size.height) {
246
- 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
+ });
247
383
  }
384
+ }, [generator, size, zoom, translation, rotation, layers, projectionProp]);
248
385
 
249
- return <canvas ref={canvasRef} width={size.width} height={size.height} />;
250
- },
251
- );
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';
252
394
 
253
395
  //
254
396
  // Debug
@@ -259,7 +401,7 @@ const GlobeDebug = ({ position = 'topleft' }: { position?: ControlPosition }) =>
259
401
  return (
260
402
  <div
261
403
  className={mx(
262
- 'z-10 absolute is-96 p-2 overflow-hidden border border-green-700 rounded',
404
+ 'z-10 absolute w-96 p-2 overflow-hidden border border-green-700 rounded-sm',
263
405
  controlPositions[position],
264
406
  )}
265
407
  >
@@ -310,6 +452,7 @@ const GlobeAction = ({ onAction, position = 'bottomright', ...props }: GlobeCont
310
452
 
311
453
  export const Globe = {
312
454
  Root: GlobeRoot,
455
+ Viewport: GlobeViewport,
313
456
  Canvas: GlobeCanvas,
314
457
  Zoom: GlobeZoom,
315
458
  Action: GlobeAction,
@@ -317,4 +460,4 @@ export const Globe = {
317
460
  Panel: GlobePanel,
318
461
  };
319
462
 
320
- export type { GlobeRootProps, GlobeCanvasProps };
463
+ export type { GlobeRootProps, GlobeViewportProps, GlobeCanvasProps };
@@ -3,26 +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 { withTheme } from '@dxos/react-ui/testing';
8
+ import { Input, Panel, Toolbar } from '@dxos/react-ui';
9
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
9
10
 
10
11
  import { useMapZoomHandler } from '../../hooks';
11
12
  import { type GeoMarker } from '../../types';
13
+ import { Map, MapMarkersProps, MapTilesProps, type MapController } from './Map';
12
14
 
13
- import { Map, type MapController } from './Map';
15
+ type DefaultStoryProps = Pick<MapTilesProps, 'url'> & Pick<MapMarkersProps, 'markers'>;
14
16
 
15
- const DefaultStory = ({ markers = [] }: { markers?: GeoMarker[] }) => {
17
+ const DefaultStory = ({ url: urlProp, markers = [] }: DefaultStoryProps) => {
16
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
+
17
23
  const handleZoomAction = useMapZoomHandler(controller);
18
24
 
19
25
  return (
20
- <Map.Root ref={setController}>
21
- <Map.Tiles />
22
- <Map.Markers markers={markers} />
23
- <Map.Zoom position='bottomleft' onAction={handleZoomAction} />
24
- <Map.Action position='bottomright' />
25
- </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>
26
54
  );
27
55
  };
28
56
 
@@ -30,7 +58,7 @@ const meta = {
30
58
  title: 'ui/react-ui-geo/Map',
31
59
  component: Map.Root as any,
32
60
  render: DefaultStory,
33
- decorators: [withTheme],
61
+ decorators: [withTheme(), withLayout({ layout: 'fullscreen' })],
34
62
  parameters: {
35
63
  layout: 'fullscreen',
36
64
  },
@@ -42,7 +70,17 @@ type Story = StoryObj<typeof meta>;
42
70
 
43
71
  export const Default: Story = {};
44
72
 
45
- 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 = {
46
84
  args: {
47
85
  markers: [
48
86
  { id: 'los angeles', title: 'Los Angeles', location: { lat: 34.0522, lng: -118.2437 } },
@@ -70,3 +108,12 @@ export const WithMarkers: Story = {
70
108
  ] as GeoMarker[],
71
109
  },
72
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
+ };