@dxos/react-ui-geo 0.8.4-main.fd6878d → 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 +1054 -578
  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 +1054 -578
  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 +37 -11
  47. package/dist/types/src/components/Globe/Globe.stories.d.ts.map +1 -1
  48. package/dist/types/src/components/Map/Map.d.ts +55 -16
  49. package/dist/types/src/components/Map/Map.d.ts.map +1 -1
  50. package/dist/types/src/components/Map/Map.stories.d.ts +19 -9
  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 -1
  76. package/dist/types/src/index.d.ts.map +1 -1
  77. package/dist/types/src/translations.d.ts +12 -0
  78. package/dist/types/src/translations.d.ts.map +1 -0
  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 +43 -37
  92. package/src/components/Globe/Globe.stories.tsx +201 -82
  93. package/src/components/Globe/Globe.tsx +263 -119
  94. package/src/components/Map/Map.stories.tsx +66 -17
  95. package/src/components/Map/Map.tsx +333 -120
  96. package/src/components/Toolbar/Controls.tsx +12 -14
  97. package/src/data.ts +19 -2
  98. package/src/hooks/context.tsx +49 -28
  99. package/src/hooks/index.ts +3 -0
  100. package/src/hooks/useDrag.ts +33 -5
  101. package/src/hooks/useGlobeZoomHandler.ts +10 -3
  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 +71 -81
  106. package/src/hooks/useWheel.ts +83 -0
  107. package/src/index.ts +0 -1
  108. package/src/translations.ts +20 -0
  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,18 +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>();
133
- return (
134
- <div ref={ref} className={mx('relative flex grow overflow-hidden', classNames)}>
135
- <GlobeContextProvider size={{ width, height }} {...props}>
134
+ type GlobeRootProps = Partial<Pick<GlobeContextType, 'center' | 'zoom' | 'translation' | 'rotation'>> &
135
+ PropsWithChildren;
136
+
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
+ >
136
185
  {children}
137
- </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}
138
217
  </div>
139
218
  );
140
- };
219
+ });
220
+
221
+ GlobeViewport.displayName = 'Globe.Viewport';
141
222
 
142
223
  //
143
224
  // Canvas
@@ -151,103 +232,165 @@ type GlobeCanvasProps = {
151
232
  };
152
233
 
153
234
  /**
154
- * 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.
155
238
  * https://github.com/topojson/world-atlas
156
239
  */
157
- // TODO(burdon): Move controller to root.
158
- const GlobeCanvas = forwardRef<GlobeController, GlobeCanvasProps>(
159
- ({ projection: _projection, topology, features, styles: _styles }, forwardRef) => {
160
- const { themeMode } = useThemeContext();
161
- const styles = useMemo(() => _styles ?? defaultStyles[themeMode], [_styles, themeMode]);
162
-
163
- // Canvas.
164
- const [canvas, setCanvas] = useState<HTMLCanvasElement>(null);
165
- const canvasRef = (canvas: HTMLCanvasElement) => setCanvas(canvas);
166
-
167
- // Projection.
168
- const projection = useMemo(() => getProjection(_projection), [_projection]);
169
-
170
- // Layers.
171
- // TODO(burdon): Generate on the fly based on what is visible.
172
- const layers = useMemo(() => {
173
- return timer(() => createLayers(topology as Topology, features, styles));
174
- }, [topology, features, styles]);
175
-
176
- // State.
177
- const { size, center, zoom, translation, rotation, setCenter, setZoom, setTranslation, setRotation } =
178
- useGlobeContext();
179
- const zoomRef = useDynamicRef(zoom);
180
-
181
- // Update rotation.
182
- useEffect(() => {
183
- if (center) {
184
- setZoom(1);
185
- setRotation(positionToRotation(geoToPosition(center)));
186
- }
187
- }, [center]);
188
-
189
- // External controller.
190
- const zooming = useRef(false);
191
- useImperativeHandle<GlobeController, GlobeController>(forwardRef, () => {
192
- return {
193
- canvas,
194
- projection,
195
- center,
196
- get zoom() {
197
- return zoomRef.current;
198
- },
199
- translation,
200
- rotation,
201
- setCenter,
202
- setZoom: (s) => {
203
- if (typeof s === 'function') {
204
- const is = interpolateNumber(zoomRef.current, s(zoomRef.current));
205
- // Stop easing if already zooming.
206
- transition()
207
- .ease(zooming.current ? easeLinear : easeSinOut)
208
- .duration(200)
209
- .tween('scale', () => (t) => setZoom(is(t)))
210
- .on('end', () => {
211
- zooming.current = false;
212
- });
213
- } else {
214
- setZoom(s);
215
- }
216
- },
217
- setTranslation,
218
- setRotation,
219
- };
220
- }, [canvas]);
221
-
222
- // https://d3js.org/d3-geo/path#geoPath
223
- // https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/getContext
224
- const generator = useMemo(
225
- () => canvas && projection && geoPath(projection, canvas.getContext('2d', { alpha: false })),
226
- [canvas, projection],
227
- );
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
+ );
228
281
 
229
- // Render on change.
230
- useEffect(() => {
231
- if (canvas && projection) {
232
- timer(() => {
233
- // https://d3js.org/d3-geo/projection
234
- projection
235
- .scale((Math.min(size.width, size.height) / 2) * zoom)
236
- .translate([size.width / 2 + (translation?.x ?? 0), size.height / 2 + (translation?.y ?? 0)])
237
- .rotate(rotation ?? [0, 0, 0]);
238
-
239
- renderLayers(generator, layers, zoom, styles);
240
- });
241
- }
242
- }, [generator, size, zoom, translation, rotation, layers]);
243
-
244
- if (!size.width || !size.height) {
245
- 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
+ });
246
383
  }
384
+ }, [generator, size, zoom, translation, rotation, layers, projectionProp]);
247
385
 
248
- return <canvas ref={canvasRef} width={size.width} height={size.height} />;
249
- },
250
- );
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';
251
394
 
252
395
  //
253
396
  // Debug
@@ -258,7 +401,7 @@ const GlobeDebug = ({ position = 'topleft' }: { position?: ControlPosition }) =>
258
401
  return (
259
402
  <div
260
403
  className={mx(
261
- 'z-10 absolute w-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',
262
405
  controlPositions[position],
263
406
  )}
264
407
  >
@@ -309,6 +452,7 @@ const GlobeAction = ({ onAction, position = 'bottomright', ...props }: GlobeCont
309
452
 
310
453
  export const Globe = {
311
454
  Root: GlobeRoot,
455
+ Viewport: GlobeViewport,
312
456
  Canvas: GlobeCanvas,
313
457
  Zoom: GlobeZoom,
314
458
  Action: GlobeAction,
@@ -316,4 +460,4 @@ export const Globe = {
316
460
  Panel: GlobePanel,
317
461
  };
318
462
 
319
- export type { GlobeRootProps, GlobeCanvasProps };
463
+ export type { GlobeRootProps, GlobeViewportProps, GlobeCanvasProps };
@@ -2,45 +2,85 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import '@dxos-theme';
6
-
7
5
  import { type Meta, type StoryObj } from '@storybook/react-vite';
8
- import React, { useState } from 'react';
6
+ import React, { useMemo, useState } from 'react';
9
7
 
10
- import { withLayout, withTheme } from '@dxos/storybook-utils';
8
+ import { Input, Panel, Toolbar } from '@dxos/react-ui';
9
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
11
10
 
12
11
  import { useMapZoomHandler } from '../../hooks';
13
12
  import { type GeoMarker } from '../../types';
13
+ import { Map, MapMarkersProps, MapTilesProps, type MapController } from './Map';
14
14
 
15
- import { Map, type MapController } from './Map';
15
+ type DefaultStoryProps = Pick<MapTilesProps, 'url'> & Pick<MapMarkersProps, 'markers'>;
16
16
 
17
- const DefaultStory = ({ markers = [] }: { markers?: GeoMarker[] }) => {
17
+ const DefaultStory = ({ url: urlProp, markers = [] }: DefaultStoryProps) => {
18
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
+
19
23
  const handleZoomAction = useMapZoomHandler(controller);
20
24
 
21
25
  return (
22
- <Map.Root ref={setController}>
23
- <Map.Tiles />
24
- <Map.Markers markers={markers} />
25
- <Map.Zoom position='bottomleft' onAction={handleZoomAction} />
26
- <Map.Action position='bottomright' />
27
- </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>
28
54
  );
29
55
  };
30
56
 
31
- const meta: Meta<typeof DefaultStory> = {
57
+ const meta = {
32
58
  title: 'ui/react-ui-geo/Map',
59
+ component: Map.Root as any,
33
60
  render: DefaultStory,
34
- decorators: [withTheme, withLayout({ fullscreen: true })],
35
- };
61
+ decorators: [withTheme(), withLayout({ layout: 'fullscreen' })],
62
+ parameters: {
63
+ layout: 'fullscreen',
64
+ },
65
+ } satisfies Meta<typeof DefaultStory>;
36
66
 
37
67
  export default meta;
38
68
 
39
- type Story = StoryObj<typeof DefaultStory>;
69
+ type Story = StoryObj<typeof meta>;
40
70
 
41
71
  export const Default: Story = {};
42
72
 
43
- 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 = {
44
84
  args: {
45
85
  markers: [
46
86
  { id: 'los angeles', title: 'Los Angeles', location: { lat: 34.0522, lng: -118.2437 } },
@@ -68,3 +108,12 @@ export const WithMarkers: Story = {
68
108
  ] as GeoMarker[],
69
109
  },
70
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
+ };