@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
@@ -5,13 +5,31 @@
5
5
  import 'leaflet/dist/leaflet.css';
6
6
 
7
7
  import { createContext } from '@radix-ui/react-context';
8
- import L, { Control, type ControlPosition, DomEvent, DomUtil, type LatLngLiteral, latLngBounds } from 'leaflet';
9
- import React, { type PropsWithChildren, forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
8
+ import L, { Control, type ControlPosition, DomEvent, DomUtil, type LatLngLiteral, point, latLngBounds } from 'leaflet';
9
+ import React, {
10
+ type PropsWithChildren,
11
+ forwardRef,
12
+ useCallback,
13
+ useEffect,
14
+ useImperativeHandle,
15
+ useRef,
16
+ useState,
17
+ } from 'react';
10
18
  import { createRoot } from 'react-dom/client';
11
- import { MapContainer, type MapContainerProps, Marker, Popup, TileLayer, useMap, useMapEvents } from 'react-leaflet';
19
+ import {
20
+ MapContainer,
21
+ type MapContainerProps,
22
+ Marker,
23
+ Polyline,
24
+ Popup,
25
+ TileLayer,
26
+ useMap,
27
+ useMapEvents,
28
+ } from 'react-leaflet';
12
29
 
13
30
  import { type ThemedClassName, ThemeProvider, Tooltip } from '@dxos/react-ui';
14
- import { composable, composableProps, defaultTx, mx } from '@dxos/ui-theme';
31
+ import { composable, composableProps, defaultTx } from '@dxos/react-ui';
32
+ import { mx } from '@dxos/ui-theme';
15
33
 
16
34
  import { type GeoMarker } from '../../types';
17
35
  import { ActionControls, type ControlProps, ZoomControls, controlPositions } from '../Toolbar';
@@ -30,6 +48,8 @@ const defaults = {
30
48
  //
31
49
 
32
50
  type MapController = {
51
+ getCenter: () => LatLngLiteral | undefined;
52
+ getZoom: () => number | undefined;
33
53
  setCenter: (center: LatLngLiteral, zoom?: number) => void;
34
54
  setZoom: (cb: (zoom: number) => number) => void;
35
55
  };
@@ -41,33 +61,50 @@ type MapController = {
41
61
  type MapContextValue = {
42
62
  attention?: boolean;
43
63
  onChange?: (ev: { center: LatLngLiteral; zoom: number }) => void;
64
+ /** Called by Map.Viewport to register/unregister the leaflet map with the controller owned by Map.Root. */
65
+ registerMap: (map: L.Map | null) => void;
44
66
  };
45
67
 
46
68
  const [MapContextProvider, useMapContext] = createContext<MapContextValue>('Map');
47
69
 
48
70
  //
49
- // Root
71
+ // Root — headless; owns the imperative MapController (exposed via ref) and the map registry.
50
72
  //
51
73
 
52
- type MapRootProps = Pick<MapContextValue, 'onChange'>;
74
+ type MapRootProps = PropsWithChildren<Pick<MapContextValue, 'onChange'>>;
53
75
 
54
76
  /**
55
- * Context provider for the map. Must wrap Map.Content.
77
+ * Context provider for the map. Must wrap Map.Viewport. The ref exposes a {@link MapController}.
56
78
  */
57
- const MapRoot = composable<HTMLDivElement, MapRootProps>(({ children, onChange, ...props }, forwardedRef) => {
79
+ const MapRoot = forwardRef<MapController, MapRootProps>(({ children, onChange }, forwardedRef) => {
80
+ const mapRef = useRef<L.Map | null>(null);
81
+ const registerMap = useCallback((map: L.Map | null) => {
82
+ mapRef.current = map;
83
+ }, []);
84
+
85
+ useImperativeHandle(
86
+ forwardedRef,
87
+ () => ({
88
+ getCenter: () => {
89
+ const center = mapRef.current?.getCenter();
90
+ return center ? { lat: center.lat, lng: center.lng } : undefined;
91
+ },
92
+ getZoom: () => mapRef.current?.getZoom(),
93
+ setCenter: (center: LatLngLiteral, zoom?: number) => {
94
+ mapRef.current?.setView(center, zoom);
95
+ },
96
+ setZoom: (cb: (zoom: number) => number) => {
97
+ mapRef.current?.setZoom(cb(mapRef.current?.getZoom() ?? 0));
98
+ },
99
+ }),
100
+ [],
101
+ );
102
+
58
103
  // TODO(burdon): Use attention: const [attention, setAttention] = useState(false);
59
104
  const attention = false;
60
105
  return (
61
- <MapContextProvider attention={attention} onChange={onChange}>
62
- <div
63
- {...composableProps(props, {
64
- role: 'none',
65
- classNames: 'dx-container grid dx-focus-ring-inset',
66
- })}
67
- ref={forwardedRef}
68
- >
69
- {children}
70
- </div>
106
+ <MapContextProvider attention={attention} onChange={onChange} registerMap={registerMap}>
107
+ {children}
71
108
  </MapContextProvider>
72
109
  );
73
110
  });
@@ -75,74 +112,161 @@ const MapRoot = composable<HTMLDivElement, MapRootProps>(({ children, onChange,
75
112
  MapRoot.displayName = 'Map.Root';
76
113
 
77
114
  //
78
- // Content
115
+ // Viewport
79
116
  //
80
117
 
81
- type MapContentProps = ThemedClassName<Omit<MapContainerProps, 'children'> & PropsWithChildren>;
118
+ type MapViewportProps = ThemedClassName<Omit<MapContainerProps, 'children'> & PropsWithChildren>;
82
119
 
83
120
  /**
84
121
  * https://react-leaflet.js.org/docs/api-map
85
122
  */
86
- const MAP_CONTENT_NAME = 'Map.Content';
123
+ const MAP_VIEWPORT_NAME = 'Map.Viewport';
87
124
 
88
- const MapContent = forwardRef<MapController, MapContentProps>(
89
- (
90
- { classNames, scrollWheelZoom = true, doubleClickZoom = true, touchZoom = true, center, zoom, children, ...props },
91
- forwardedRef,
92
- ) => {
93
- const { attention } = useMapContext(MAP_CONTENT_NAME);
94
- const mapRef = useRef<L.Map>(null);
95
- const map = mapRef.current;
96
-
97
- useImperativeHandle(
98
- forwardedRef,
99
- () => ({
100
- setCenter: (center: LatLngLiteral, zoom?: number) => {
101
- mapRef.current?.setView(center, zoom);
102
- },
103
- setZoom: (cb: (zoom: number) => number) => {
104
- mapRef.current?.setZoom(cb(mapRef.current?.getZoom() ?? 0));
105
- },
106
- }),
107
- [],
108
- );
125
+ /**
126
+ * Recalculates the leaflet map size when its container resizes (e.g. a companion
127
+ * panel opening/closing). Without this, leaflet keeps its stale size and renders
128
+ * blank/gray tiles in the newly-exposed area until the next pan/zoom. Coalesced
129
+ * via rAF to avoid ResizeObserver feedback loops.
130
+ */
131
+ const MapResize = () => {
132
+ const map = useMap();
133
+ useEffect(() => {
134
+ const container = map.getContainer();
135
+ let frame = 0;
136
+ const observer = new ResizeObserver(() => {
137
+ cancelAnimationFrame(frame);
138
+ frame = requestAnimationFrame(() => map.invalidateSize());
139
+ });
140
+ observer.observe(container);
141
+ return () => {
142
+ cancelAnimationFrame(frame);
143
+ observer.disconnect();
144
+ };
145
+ }, [map]);
146
+
147
+ return null;
148
+ };
149
+
150
+ /**
151
+ * Enables pinch-to-zoom on trackpads / ctrl+wheel. Browsers deliver a trackpad pinch as a `wheel`
152
+ * event with `ctrlKey` set; Leaflet only zooms those via `scrollWheelZoom`, which is intentionally
153
+ * off here (so plain scrolling doesn't hijack the page). This handler zooms on the pinch gesture
154
+ * only, leaving normal wheel scrolling untouched. (Touchscreen pinch is handled by Leaflet's
155
+ * `touchZoom`.)
156
+ */
157
+ // Zoom levels per pixel of pinch (ctrl+wheel) delta.
158
+ const PINCH_ZOOM_SENSITIVITY = 0.03;
109
159
 
110
- // Enable/disable scroll wheel zoom.
111
- // TODO(burdon): Use attention:
112
- // const {hasAttention} = useAttention(props.id);
113
- useEffect(() => {
114
- if (!map) {
160
+ const MapPinchZoom = () => {
161
+ const map = useMap();
162
+ useEffect(() => {
163
+ const container = map.getContainer();
164
+ let frame = 0;
165
+ let point: ReturnType<typeof L.point> | undefined;
166
+ // Accumulate the target against the last requested value (not the live, mid-zoom `getZoom()`)
167
+ // and apply once per animation frame without zoom animation — overlapping animated zooms are
168
+ // what made this jittery. Reset between frames so the next batch re-reads the settled zoom.
169
+ let target: number | undefined;
170
+
171
+ const onWheel = (event: WheelEvent) => {
172
+ if (!event.ctrlKey) {
115
173
  return;
116
174
  }
117
-
118
- if (attention) {
119
- map.scrollWheelZoom.enable();
120
- } else {
121
- map.scrollWheelZoom.disable();
175
+ event.preventDefault();
176
+ const rect = container.getBoundingClientRect();
177
+ point = L.point(event.clientX - rect.left, event.clientY - rect.top);
178
+ target = (target ?? map.getZoom()) - event.deltaY * PINCH_ZOOM_SENSITIVITY;
179
+ if (!frame) {
180
+ frame = requestAnimationFrame(() => {
181
+ frame = 0;
182
+ if (target !== undefined && point) {
183
+ map.setZoomAround(point, target, { animate: false });
184
+ target = undefined;
185
+ }
186
+ });
122
187
  }
123
- }, [map, attention]);
124
-
125
- return (
126
- <MapContainer
127
- {...props}
128
- className={mx('group relative grid bg-base-surface!', classNames)}
129
- attributionControl={false}
130
- zoomControl={false}
131
- scrollWheelZoom={scrollWheelZoom}
132
- doubleClickZoom={doubleClickZoom}
133
- touchZoom={touchZoom}
134
- center={center ?? defaults.center}
135
- zoom={zoom ?? defaults.zoom}
136
- whenReady={() => {}}
137
- ref={mapRef}
138
- >
139
- {children}
140
- </MapContainer>
141
- );
142
- },
143
- );
188
+ };
189
+
190
+ container.addEventListener('wheel', onWheel, { passive: false });
191
+ return () => {
192
+ container.removeEventListener('wheel', onWheel);
193
+ cancelAnimationFrame(frame);
194
+ };
195
+ }, [map]);
196
+
197
+ return null;
198
+ };
199
+
200
+ /**
201
+ * Map.Viewport is the focusable Leaflet frame. It can be the target of a parent `<Panel.Content asChild>`
202
+ * (Slot), so it reconciles an injected `className` via `composableProps`. Leaflet owns the underlying
203
+ * container element, so the forwarded DOM ref can't be attached and is intentionally unused.
204
+ */
205
+ const MapViewport = composable<HTMLDivElement, MapViewportProps>((props, _forwardedRef) => {
206
+ const {
207
+ scrollWheelZoom = true,
208
+ doubleClickZoom = true,
209
+ touchZoom = true,
210
+ center,
211
+ zoom,
212
+ whenReady,
213
+ children,
214
+ ...rest
215
+ } = props;
216
+ const { attention, registerMap } = useMapContext(MAP_VIEWPORT_NAME);
217
+ // Local copy of the leaflet map for this component's own effects; also registered with Map.Root.
218
+ const [map, setMap] = useState<L.Map | null>(null);
219
+
220
+ // Register/unregister the map with the controller owned by Map.Root.
221
+ const setMapRef = useCallback(
222
+ (next: L.Map | null) => {
223
+ setMap(next);
224
+ registerMap(next);
225
+ },
226
+ [registerMap],
227
+ );
144
228
 
145
- MapContent.displayName = 'Map.Content';
229
+ // Enable/disable scroll wheel zoom.
230
+ // TODO(burdon): Use attention:
231
+ // const {hasAttention} = useAttention(props.id);
232
+ useEffect(() => {
233
+ if (!map) {
234
+ return;
235
+ }
236
+
237
+ if (attention) {
238
+ map.scrollWheelZoom.enable();
239
+ } else {
240
+ map.scrollWheelZoom.disable();
241
+ }
242
+ }, [map, attention]);
243
+
244
+ return (
245
+ <MapContainer
246
+ {...composableProps(rest, {
247
+ // Frame classes (formerly on Map.Root): focusable grid container.
248
+ classNames: 'dx-container group relative grid dx-focus-ring-inset bg-base-surface!',
249
+ })}
250
+ attributionControl={false}
251
+ zoomControl={false}
252
+ scrollWheelZoom={scrollWheelZoom}
253
+ doubleClickZoom={doubleClickZoom}
254
+ touchZoom={touchZoom}
255
+ // Allow fractional zoom so trackpad pinch (small ctrl+wheel deltas) isn't rounded away.
256
+ zoomSnap={0}
257
+ center={center ?? defaults.center}
258
+ zoom={zoom ?? defaults.zoom}
259
+ whenReady={whenReady}
260
+ ref={setMapRef}
261
+ >
262
+ <MapResize />
263
+ <MapPinchZoom />
264
+ {children}
265
+ </MapContainer>
266
+ );
267
+ });
268
+
269
+ MapViewport.displayName = 'Map.Viewport';
146
270
 
147
271
  //
148
272
  // Tiles
@@ -151,14 +275,20 @@ MapContent.displayName = 'Map.Content';
151
275
 
152
276
  const MAP_TILES_NAME = 'Map.Tiles';
153
277
 
154
- type MapTilesProps = {};
278
+ /** Default OpenStreetMap raster tile template. */
279
+ export const DEFAULT_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
155
280
 
156
- const MapTiles = (_props: MapTilesProps) => {
281
+ type MapTilesProps = {
282
+ /** Leaflet tile URL template (e.g. a MapTiler style endpoint with an API key). Defaults to OpenStreetMap. */
283
+ url?: string;
284
+ };
285
+
286
+ const MapTiles = ({ url = DEFAULT_TILE_URL }: MapTilesProps) => {
157
287
  const ref = useRef<L.TileLayer>(null);
158
288
  const { onChange } = useMapContext(MAP_TILES_NAME);
159
289
 
160
290
  useMapEvents({
161
- zoomstart: (ev) => {
291
+ moveend: (ev) => {
162
292
  onChange?.({
163
293
  center: ev.target.getCenter(),
164
294
  zoom: ev.target.getZoom(),
@@ -183,7 +313,7 @@ const MapTiles = (_props: MapTilesProps) => {
183
313
  data-attention={attention}
184
314
  detectRetina={true}
185
315
  className='dark:grayscale dark:invert data-[attention="0"]:!opacity-80'
186
- url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
316
+ url={url}
187
317
  keepBuffer={4}
188
318
  // opacity={attention ? 1 : 0.7}
189
319
  />
@@ -217,21 +347,32 @@ MapTiles.displayName = MAP_TILES_NAME;
217
347
 
218
348
  type MapMarkersProps = {
219
349
  markers?: GeoMarker[];
350
+ /** Connecting lines (e.g. a route). Used here only to extend the viewport fit; drawn by `Map.Lines`. */
351
+ lines?: MapLine[];
220
352
  selected?: string[];
353
+ /** Invoked with the marker id when a marker is clicked. */
354
+ onSelect?: (id: string) => void;
221
355
  };
222
356
 
223
- const MapMarkers = ({ selected, markers }: MapMarkersProps) => {
357
+ const MapMarkers = ({ selected, markers, lines, onSelect }: MapMarkersProps) => {
224
358
  const map = useMap();
225
359
 
226
- // Set the viewport around the markers, or show the whole world map if `markers` is empty.
360
+ // Fit the viewport around the markers and any connecting lines. When there is nothing to frame,
361
+ // leave the current view alone so caller-provided center/zoom (or prior interaction) is preserved.
227
362
  useEffect(() => {
228
- if (markers.length > 0) {
229
- const bounds = latLngBounds(markers.map((marker) => marker.location));
230
- map.fitBounds(bounds);
231
- } else {
232
- map.setView(defaults.center, defaults.zoom);
363
+ const points: LatLngLiteral[] = [
364
+ ...(markers?.map((marker) => marker.location) ?? []),
365
+ ...(lines?.flatMap((line) => [line.source, line.target]) ?? []),
366
+ ];
367
+ if (points.length > 0) {
368
+ const bounds = latLngBounds(points);
369
+ const size = map.getSize();
370
+ const padding = Math.max(48, Math.min(size.x, size.y) / 6);
371
+ // `animate: false`: a deferred zoom animation can outlive the map (e.g. on unmount) and throw
372
+ // a Leaflet `_leaflet_pos` error against a removed layer; fitting instantly avoids the race.
373
+ map.fitBounds(bounds, { padding: point(padding, padding), animate: false });
233
374
  }
234
- }, [markers]);
375
+ }, [markers, lines, map]);
235
376
 
236
377
  return (
237
378
  <>
@@ -240,6 +381,7 @@ const MapMarkers = ({ selected, markers }: MapMarkersProps) => {
240
381
  <Marker
241
382
  key={id}
242
383
  position={{ lat, lng }}
384
+ eventHandlers={onSelect ? { click: () => onSelect(id) } : undefined}
243
385
  icon={
244
386
  // TODO(burdon): Create custom icon from bundled assets.
245
387
  // TODO(burdon): Selection state.
@@ -264,6 +406,46 @@ const MapMarkers = ({ selected, markers }: MapMarkersProps) => {
264
406
 
265
407
  MapMarkers.displayName = 'Map.Markers';
266
408
 
409
+ //
410
+ // Lines
411
+ //
412
+
413
+ /** A connecting line between two points (e.g. a route leg). `color` is any CSS/Leaflet stroke color. */
414
+ export type MapLine = { source: LatLngLiteral; target: LatLngLiteral; color?: string };
415
+
416
+ type MapLinesProps = {
417
+ lines?: MapLine[];
418
+ };
419
+
420
+ const MapLines = ({ lines }: MapLinesProps) => {
421
+ if (!lines || lines.length === 0) {
422
+ return null;
423
+ }
424
+
425
+ // Merge consecutive connected segments with the same color into a single Polyline so
426
+ // Leaflet renders one continuous smooth path rather than N disconnected stub segments.
427
+ const polylines: Array<{ positions: LatLngLiteral[]; color?: string }> = [];
428
+ for (const { source, target, color } of lines) {
429
+ const last = polylines[polylines.length - 1];
430
+ const lastPos = last?.positions[last.positions.length - 1];
431
+ if (last && last.color === color && lastPos?.lat === source.lat && lastPos?.lng === source.lng) {
432
+ last.positions.push(target);
433
+ } else {
434
+ polylines.push({ positions: [source, target], color });
435
+ }
436
+ }
437
+
438
+ return (
439
+ <>
440
+ {polylines.map(({ positions, color }, index) => (
441
+ <Polyline key={index} positions={positions} pathOptions={{ color, weight: 4, opacity: 0.8 }} />
442
+ ))}
443
+ </>
444
+ );
445
+ };
446
+
447
+ MapLines.displayName = 'Map.Lines';
448
+
267
449
  //
268
450
  // Controls
269
451
  // Integrates with Leaflet custom controls.
@@ -276,29 +458,47 @@ const CustomControl = ({
276
458
  position: ControlPosition;
277
459
  }>) => {
278
460
  const map = useMap();
461
+ const rootRef = useRef<ReturnType<typeof createRoot> | undefined>(undefined);
279
462
 
463
+ // Mount the leaflet control (and its React root) once per map/position. Children are
464
+ // rendered into the persistent root by the effect below, so updating them does NOT
465
+ // tear down and re-add the control (which would flicker on every parent re-render).
280
466
  useEffect(() => {
281
467
  const control = new Control({ position });
282
468
  control.onAdd = () => {
283
469
  const container = DomUtil.create('div', mx('m-0!', controlPositions[position]));
284
470
  DomEvent.disableClickPropagation(container);
285
471
  DomEvent.disableScrollPropagation(container);
286
-
287
472
  const root = createRoot(container);
473
+ rootRef.current = root;
474
+ // Initial render — covers mount and any map/position remount; the effect below
475
+ // handles subsequent children-only updates.
288
476
  root.render(
289
477
  <ThemeProvider tx={defaultTx}>
290
478
  <Tooltip.Provider>{children}</Tooltip.Provider>
291
479
  </ThemeProvider>,
292
480
  );
293
-
294
481
  return container;
295
482
  };
296
483
 
297
484
  control.addTo(map);
298
485
  return () => {
299
486
  control.remove();
487
+ const root = rootRef.current;
488
+ rootRef.current = undefined;
489
+ // Defer unmount so it doesn't run synchronously during a React render/commit.
490
+ queueMicrotask(() => root?.unmount());
300
491
  };
301
- }, [map, position, children]);
492
+ }, [map, position]);
493
+
494
+ // Re-render children into the persistent root whenever they change.
495
+ useEffect(() => {
496
+ rootRef.current?.render(
497
+ <ThemeProvider tx={defaultTx}>
498
+ <Tooltip.Provider>{children}</Tooltip.Provider>
499
+ </ThemeProvider>,
500
+ );
501
+ }, [children]);
302
502
 
303
503
  return null;
304
504
  };
@@ -323,9 +523,10 @@ const MapAction = ({ onAction, position = 'bottomright', ...props }: MapControlP
323
523
 
324
524
  export const Map = {
325
525
  Root: MapRoot,
326
- Content: MapContent,
526
+ Viewport: MapViewport,
327
527
  Tiles: MapTiles,
328
528
  Markers: MapMarkers,
529
+ Lines: MapLines,
329
530
  Zoom: MapZoom,
330
531
  Action: MapAction,
331
532
  };
@@ -333,8 +534,9 @@ export const Map = {
333
534
  export {
334
535
  type MapController,
335
536
  type MapRootProps,
336
- type MapContentProps,
537
+ type MapViewportProps,
337
538
  type MapTilesProps,
338
539
  type MapMarkersProps,
540
+ type MapLinesProps,
339
541
  type MapControlProps,
340
542
  };
@@ -7,7 +7,7 @@ import React from 'react';
7
7
 
8
8
  import { IconButton, type ThemedClassName, Toolbar, useTranslation } from '@dxos/react-ui';
9
9
 
10
- import { translationKey } from '../../translations';
10
+ import { translationKey } from '#translations';
11
11
 
12
12
  export type ControlAction = 'toggle' | 'start' | 'zoom-in' | 'zoom-out';
13
13
 
package/src/data.ts CHANGED
@@ -4,6 +4,23 @@
4
4
 
5
5
  import { type Topology } from 'topojson-specification';
6
6
 
7
- export const loadTopology = async (): Promise<Topology> => {
8
- return (await import('../data/countries-110m.ts')).default;
7
+ /**
8
+ * World-atlas Natural Earth resolutions. Higher numbers = lower detail.
9
+ * - `110m` (~110 KB): default; suitable for low-zoom globes.
10
+ * - `50m` (~750 KB): mid-zoom.
11
+ * - `10m` (~3.6 MB): high zoom; pair with `useSimplifiedTopology` to keep
12
+ * per-frame render cost bounded.
13
+ */
14
+ export type CountriesResolution = '110m' | '50m' | '10m';
15
+
16
+ export const loadTopology = async (resolution: CountriesResolution = '110m'): Promise<Topology> => {
17
+ switch (resolution) {
18
+ case '10m':
19
+ return (await import('../data/countries-10m.ts')).default;
20
+ case '50m':
21
+ return (await import('../data/countries-50m.ts')).default;
22
+ case '110m':
23
+ default:
24
+ return (await import('../data/countries-110m.ts')).default;
25
+ }
9
26
  };
@@ -2,6 +2,7 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
+ import { type GeoProjection } from 'd3';
5
6
  import { type Dispatch, type SetStateAction, createContext, useContext } from 'react';
6
7
 
7
8
  import { raise } from '@dxos/debug';
@@ -21,12 +22,55 @@ export type GlobeContextType = {
21
22
  zoom: number;
22
23
  translation: Point;
23
24
  rotation: Vector;
25
+ setSize: Dispatch<SetStateAction<Size>>;
24
26
  setCenter: Dispatch<SetStateAction<LatLngLiteral>>;
25
27
  setZoom: Dispatch<SetStateAction<number>>;
26
28
  setTranslation: Dispatch<SetStateAction<Point>>;
27
29
  setRotation: Dispatch<SetStateAction<Vector>>;
30
+ /** Registers (or clears) the controller built by Globe.Canvas so Globe.Root can expose it via its ref. */
31
+ registerController: (controller: GlobeController | null) => void;
28
32
  };
29
33
 
34
+ //
35
+ // Controller
36
+ //
37
+
38
+ /**
39
+ * Imperative options accepted by GlobeController.flyTo.
40
+ */
41
+ export type FlyToOptions = {
42
+ /** Base duration in ms (scales with great-circle distance). */
43
+ duration?: number;
44
+ /** Optional pitch offset applied along the latitude axis of the target. */
45
+ tilt?: number;
46
+ /**
47
+ * Optional per-frame callback fired before the rotation tween advances.
48
+ * Useful for layered animations (e.g. cursor / arc trails in tours).
49
+ * `t` runs 0→1 across the eased duration.
50
+ */
51
+ onTick?: (t: number) => void;
52
+ };
53
+
54
+ export type FlyToTarget = LatLngLiteral & {
55
+ /** Optional zoom factor; interpolated alongside rotation when set. */
56
+ zoom?: number;
57
+ };
58
+
59
+ export type GlobeController = {
60
+ canvas: HTMLCanvasElement;
61
+ projection: GeoProjection;
62
+ /**
63
+ * Animates the globe to the given lat/lng (and optional zoom) along a
64
+ * great-circle arc. Returns a Promise that resolves on completion and
65
+ * rejects if interrupted (e.g. by another flyTo on the same globe).
66
+ */
67
+ flyTo: (target: FlyToTarget, options?: FlyToOptions) => Promise<void>;
68
+ /**
69
+ * Interrupts any in-flight `flyTo` (used by tours when stopped mid-segment).
70
+ */
71
+ cancelFlyTo: () => void;
72
+ } & Pick<GlobeContextType, 'zoom' | 'translation' | 'rotation' | 'setZoom' | 'setTranslation' | 'setRotation'>;
73
+
30
74
  /** @internal */
31
75
  // TODO(burdon): Replace with radix.
32
76
  export const GlobeContext = createContext<GlobeContextType>(undefined);
@@ -6,5 +6,8 @@ export * from './context';
6
6
  export * from './useDrag';
7
7
  export * from './useGlobeZoomHandler';
8
8
  export * from './useMapZoomHandler';
9
+ export * from './useSimplifiedTopology';
9
10
  export * from './useSpinner';
11
+ export * from './useTopology';
10
12
  export * from './useTour';
13
+ export * from './useWheel';