@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
@@ -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, useState } 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';
12
-
13
- import { ThemeProvider, type ThemedClassName, Tooltip } from '@dxos/react-ui';
14
- import { defaultTx, mx } from '@dxos/react-ui-theme';
19
+ import {
20
+ MapContainer,
21
+ type MapContainerProps,
22
+ Marker,
23
+ Polyline,
24
+ Popup,
25
+ TileLayer,
26
+ useMap,
27
+ useMapEvents,
28
+ } from 'react-leaflet';
29
+
30
+ import { type ThemedClassName, ThemeProvider, Tooltip } from '@dxos/react-ui';
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,91 +61,234 @@ 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
- const [MapContextProvier, useMapContext] = createContext<MapContextValue>('Map');
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 = ThemedClassName<MapContainerProps & Pick<MapContextValue, 'onChange'>>;
74
+ type MapRootProps = PropsWithChildren<Pick<MapContextValue, 'onChange'>>;
53
75
 
54
76
  /**
55
- * https://react-leaflet.js.org/docs/api-map
77
+ * Context provider for the map. Must wrap Map.Viewport. The ref exposes a {@link MapController}.
56
78
  */
57
- const MapRoot = forwardRef<MapController, MapRootProps>(
58
- (
59
- { classNames, scrollWheelZoom = true, doubleClickZoom = true, touchZoom = true, center, zoom, onChange, ...props },
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(
60
86
  forwardedRef,
61
- ) => {
62
- const [attention, setAttention] = useState(false);
63
- const mapRef = useRef<L.Map>(null);
64
- const map = mapRef.current;
65
-
66
- useImperativeHandle(
67
- forwardedRef,
68
- () => ({
69
- setCenter: (center: LatLngLiteral, zoom?: number) => {
70
- mapRef.current?.setView(center, zoom);
71
- },
72
- setZoom: (cb: (zoom: number) => number) => {
73
- mapRef.current?.setZoom(cb(mapRef.current?.getZoom() ?? 0));
74
- },
75
- }),
76
- [],
77
- );
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
+
103
+ // TODO(burdon): Use attention: const [attention, setAttention] = useState(false);
104
+ const attention = false;
105
+ return (
106
+ <MapContextProvider attention={attention} onChange={onChange} registerMap={registerMap}>
107
+ {children}
108
+ </MapContextProvider>
109
+ );
110
+ });
111
+
112
+ MapRoot.displayName = 'Map.Root';
113
+
114
+ //
115
+ // Viewport
116
+ //
117
+
118
+ type MapViewportProps = ThemedClassName<Omit<MapContainerProps, 'children'> & PropsWithChildren>;
78
119
 
79
- // Enable/disable scroll wheel zoom.
80
- // TODO(burdon): Use attention:
81
- // const {hasAttention} = useAttention(props.id);
82
- useEffect(() => {
83
- if (!map) {
120
+ /**
121
+ * https://react-leaflet.js.org/docs/api-map
122
+ */
123
+ const MAP_VIEWPORT_NAME = 'Map.Viewport';
124
+
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;
159
+
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) {
84
173
  return;
85
174
  }
86
-
87
- if (attention) {
88
- map.scrollWheelZoom.enable();
89
- } else {
90
- 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
+ });
91
187
  }
92
- }, [map, attention]);
93
-
94
- return (
95
- <MapContextProvier attention={attention} onChange={onChange}>
96
- <MapContainer
97
- {...props}
98
- ref={mapRef}
99
- className={mx('group relative grid bs-full is-full !bg-baseSurface dx-focus-ring-inset', classNames)}
100
- attributionControl={false}
101
- zoomControl={false}
102
- scrollWheelZoom={scrollWheelZoom}
103
- doubleClickZoom={doubleClickZoom}
104
- touchZoom={touchZoom}
105
- center={center ?? defaults.center}
106
- zoom={zoom ?? defaults.zoom}
107
- // whenReady={() => {}}
108
- />
109
- </MapContextProvier>
110
- );
111
- },
112
- );
188
+ };
113
189
 
114
- MapRoot.displayName = 'Map.Root';
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
+ );
228
+
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';
115
270
 
116
271
  //
117
272
  // Tiles
118
273
  // https://react-leaflet.js.org/docs/api-components/#tilelayer
119
274
  //
120
275
 
121
- type MapTilesProps = {};
276
+ const MAP_TILES_NAME = 'Map.Tiles';
122
277
 
123
- const MapTiles = (_props: MapTilesProps) => {
278
+ /** Default OpenStreetMap raster tile template. */
279
+ export const DEFAULT_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
280
+
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) => {
124
287
  const ref = useRef<L.TileLayer>(null);
125
- const { onChange } = useMapContext(MapTiles.displayName);
288
+ const { onChange } = useMapContext(MAP_TILES_NAME);
126
289
 
127
290
  useMapEvents({
128
- zoomstart: (ev) => {
291
+ moveend: (ev) => {
129
292
  onChange?.({
130
293
  center: ev.target.getCenter(),
131
294
  zoom: ev.target.getZoom(),
@@ -135,7 +298,7 @@ const MapTiles = (_props: MapTilesProps) => {
135
298
 
136
299
  // NOTE: Need to dynamically update data attribute since TileLayer doesn't update, but
137
300
  // Tailwind requires setting the property for static analysis.
138
- const { attention } = useMapContext(MapTiles.displayName);
301
+ const { attention } = useMapContext(MAP_TILES_NAME);
139
302
  useEffect(() => {
140
303
  if (ref.current) {
141
304
  ref.current.getContainer().dataset.attention = attention ? '1' : '0';
@@ -150,7 +313,7 @@ const MapTiles = (_props: MapTilesProps) => {
150
313
  data-attention={attention}
151
314
  detectRetina={true}
152
315
  className='dark:grayscale dark:invert data-[attention="0"]:!opacity-80'
153
- url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
316
+ url={url}
154
317
  keepBuffer={4}
155
318
  // opacity={attention ? 1 : 0.7}
156
319
  />
@@ -176,7 +339,7 @@ const MapTiles = (_props: MapTilesProps) => {
176
339
  );
177
340
  };
178
341
 
179
- MapTiles.displayName = 'Map.Tiles';
342
+ MapTiles.displayName = MAP_TILES_NAME;
180
343
 
181
344
  //
182
345
  // Markers
@@ -184,21 +347,32 @@ MapTiles.displayName = 'Map.Tiles';
184
347
 
185
348
  type MapMarkersProps = {
186
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[];
187
352
  selected?: string[];
353
+ /** Invoked with the marker id when a marker is clicked. */
354
+ onSelect?: (id: string) => void;
188
355
  };
189
356
 
190
- const MapMarkers = ({ selected, markers }: MapMarkersProps) => {
357
+ const MapMarkers = ({ selected, markers, lines, onSelect }: MapMarkersProps) => {
191
358
  const map = useMap();
192
359
 
193
- // 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.
194
362
  useEffect(() => {
195
- if (markers.length > 0) {
196
- const bounds = latLngBounds(markers.map((marker) => marker.location));
197
- map.fitBounds(bounds);
198
- } else {
199
- 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 });
200
374
  }
201
- }, [markers]);
375
+ }, [markers, lines, map]);
202
376
 
203
377
  return (
204
378
  <>
@@ -207,6 +381,7 @@ const MapMarkers = ({ selected, markers }: MapMarkersProps) => {
207
381
  <Marker
208
382
  key={id}
209
383
  position={{ lat, lng }}
384
+ eventHandlers={onSelect ? { click: () => onSelect(id) } : undefined}
210
385
  icon={
211
386
  // TODO(burdon): Create custom icon from bundled assets.
212
387
  // TODO(burdon): Selection state.
@@ -231,6 +406,46 @@ const MapMarkers = ({ selected, markers }: MapMarkersProps) => {
231
406
 
232
407
  MapMarkers.displayName = 'Map.Markers';
233
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
+
234
449
  //
235
450
  // Controls
236
451
  // Integrates with Leaflet custom controls.
@@ -243,29 +458,47 @@ const CustomControl = ({
243
458
  position: ControlPosition;
244
459
  }>) => {
245
460
  const map = useMap();
461
+ const rootRef = useRef<ReturnType<typeof createRoot> | undefined>(undefined);
246
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).
247
466
  useEffect(() => {
248
467
  const control = new Control({ position });
249
468
  control.onAdd = () => {
250
- const container = DomUtil.create('div', mx('!m-0', controlPositions[position]));
469
+ const container = DomUtil.create('div', mx('m-0!', controlPositions[position]));
251
470
  DomEvent.disableClickPropagation(container);
252
471
  DomEvent.disableScrollPropagation(container);
253
-
254
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.
255
476
  root.render(
256
477
  <ThemeProvider tx={defaultTx}>
257
478
  <Tooltip.Provider>{children}</Tooltip.Provider>
258
479
  </ThemeProvider>,
259
480
  );
260
-
261
481
  return container;
262
482
  };
263
483
 
264
484
  control.addTo(map);
265
485
  return () => {
266
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());
267
491
  };
268
- }, [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]);
269
502
 
270
503
  return null;
271
504
  };
@@ -290,10 +523,20 @@ const MapAction = ({ onAction, position = 'bottomright', ...props }: MapControlP
290
523
 
291
524
  export const Map = {
292
525
  Root: MapRoot,
526
+ Viewport: MapViewport,
293
527
  Tiles: MapTiles,
294
528
  Markers: MapMarkers,
529
+ Lines: MapLines,
295
530
  Zoom: MapZoom,
296
531
  Action: MapAction,
297
532
  };
298
533
 
299
- export { type MapController, type MapRootProps, type MapTilesProps, type MapMarkersProps, type MapControlProps };
534
+ export {
535
+ type MapController,
536
+ type MapRootProps,
537
+ type MapViewportProps,
538
+ type MapTilesProps,
539
+ type MapMarkersProps,
540
+ type MapLinesProps,
541
+ type MapControlProps,
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
 
@@ -30,13 +30,13 @@ export const ZoomControls = ({ classNames, onAction }: ControlProps) => {
30
30
  <IconButton
31
31
  icon='ph--plus--regular'
32
32
  iconOnly
33
- label={t('zoom in icon button')}
33
+ label={t('zoom-in-icon.button')}
34
34
  onClick={() => onAction?.('zoom-in')}
35
35
  />
36
36
  <IconButton
37
37
  icon='ph--minus--regular'
38
38
  iconOnly
39
- label={t('zoom out icon button')}
39
+ label={t('zoom-out-icon.button')}
40
40
  onClick={() => onAction?.('zoom-out')}
41
41
  />
42
42
  </Toolbar.Root>
@@ -51,13 +51,13 @@ export const ActionControls = ({ classNames, onAction }: ControlProps) => {
51
51
  <IconButton
52
52
  icon='ph--path--regular'
53
53
  iconOnly
54
- label={t('start icon button')}
54
+ label={t('start-icon.button')}
55
55
  onClick={() => onAction?.('start')}
56
56
  />
57
57
  <IconButton
58
58
  icon='ph--globe-hemisphere-west--regular'
59
59
  iconOnly
60
- label={t('toggle icon button')}
60
+ label={t('toggle-icon.button')}
61
61
  onClick={() => onAction?.('toggle')}
62
62
  />
63
63
  </Toolbar.Root>
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
  };