@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
@@ -5,15 +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 type { MapContainerProps } from 'react-leaflet';
12
- import { MapContainer, Marker, Popup, TileLayer, useMap } from 'react-leaflet';
13
-
14
- import { debounce } from '@dxos/async';
15
- import { ThemeProvider, type ThemedClassName, Tooltip } from '@dxos/react-ui';
16
- 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';
17
33
 
18
34
  import { type GeoMarker } from '../../types';
19
35
  import { ActionControls, type ControlProps, ZoomControls, controlPositions } from '../Toolbar';
@@ -25,13 +41,15 @@ import { ActionControls, type ControlProps, ZoomControls, controlPositions } fro
25
41
  const defaults = {
26
42
  center: { lat: 51, lng: 0 } as L.LatLngLiteral,
27
43
  zoom: 4,
28
- };
44
+ } as const;
29
45
 
30
46
  //
31
47
  // Controller
32
48
  //
33
49
 
34
50
  type MapController = {
51
+ getCenter: () => LatLngLiteral | undefined;
52
+ getZoom: () => number | undefined;
35
53
  setCenter: (center: LatLngLiteral, zoom?: number) => void;
36
54
  setZoom: (cb: (zoom: number) => number) => void;
37
55
  };
@@ -42,130 +60,245 @@ type MapController = {
42
60
 
43
61
  type MapContextValue = {
44
62
  attention?: boolean;
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;
45
66
  };
46
67
 
47
- const [MapContextProvier, useMapContext] = createContext<MapContextValue>('Map');
68
+ const [MapContextProvider, useMapContext] = createContext<MapContextValue>('Map');
48
69
 
49
70
  //
50
- // Root
71
+ // Root — headless; owns the imperative MapController (exposed via ref) and the map registry.
51
72
  //
52
73
 
53
- type MapRootProps = ThemedClassName<
54
- MapContainerProps & {
55
- onChange?: (ev: { center: LatLngLiteral; zoom: number }) => void;
56
- }
57
- >;
74
+ type MapRootProps = PropsWithChildren<Pick<MapContextValue, 'onChange'>>;
58
75
 
59
76
  /**
60
- * https://react-leaflet.js.org/docs/api-map
77
+ * Context provider for the map. Must wrap Map.Viewport. The ref exposes a {@link MapController}.
61
78
  */
62
- const MapRoot = forwardRef<MapController, MapRootProps>(
63
- (
64
- {
65
- classNames,
66
- scrollWheelZoom = true,
67
- doubleClickZoom = true,
68
- touchZoom = true,
69
- center = defaults.center,
70
- zoom = defaults.zoom,
71
- onChange,
72
- ...props
73
- },
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(
74
86
  forwardedRef,
75
- ) => {
76
- const [attention, setAttention] = useState(false);
77
- const mapRef = useRef<L.Map>(null);
78
- const map = mapRef.current;
79
-
80
- useImperativeHandle(
81
- forwardedRef,
82
- () => ({
83
- setCenter: (center: LatLngLiteral, zoom?: number) => {
84
- mapRef.current?.setView(center, zoom);
85
- },
86
- setZoom: (cb: (zoom: number) => number) => {
87
- mapRef.current?.setZoom(cb(mapRef.current?.getZoom() ?? 0));
88
- },
89
- }),
90
- [],
91
- );
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>;
119
+
120
+ /**
121
+ * https://react-leaflet.js.org/docs/api-map
122
+ */
123
+ const MAP_VIEWPORT_NAME = 'Map.Viewport';
92
124
 
93
- // Events.
94
- useEffect(() => {
95
- if (!map) {
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) {
96
173
  return;
97
174
  }
98
-
99
- const handler = debounce(() => {
100
- setAttention(true);
101
- onChange?.({
102
- center: map.getCenter(),
103
- zoom: map.getZoom(),
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
+ }
104
186
  });
105
- }, 100);
106
-
107
- map.on('move', handler);
108
- map.on('zoom', handler);
109
- map.on('focus', () => setAttention(true));
110
- map.on('blur', () => setAttention(false));
111
- return () => {
112
- map.off('move');
113
- map.off('zoom');
114
- map.off('focus');
115
- map.off('blur');
116
- };
117
- }, [map, onChange]);
118
-
119
- // Enable/disable scroll wheel zoom.
120
- // TODO(burdon): Use attention:
121
- // const {hasAttention} = useAttention(props.id);
122
- useEffect(() => {
123
- if (!map) {
124
- return;
125
187
  }
188
+ };
126
189
 
127
- if (attention) {
128
- map.scrollWheelZoom.enable();
129
- } else {
130
- map.scrollWheelZoom.disable();
131
- }
132
- }, [map, attention]);
133
-
134
- return (
135
- <MapContextProvier attention={attention}>
136
- <MapContainer
137
- {...props}
138
- ref={mapRef}
139
- className={mx('group relative grid bs-full is-full !bg-baseSurface dx-focus-ring-inset', classNames)}
140
- attributionControl={false}
141
- zoomControl={false}
142
- scrollWheelZoom={scrollWheelZoom}
143
- doubleClickZoom={doubleClickZoom}
144
- touchZoom={touchZoom}
145
- center={center}
146
- zoom={zoom}
147
- // whenReady={() => {}}
148
- />
149
- </MapContextProvier>
150
- );
151
- },
152
- );
190
+ container.addEventListener('wheel', onWheel, { passive: false });
191
+ return () => {
192
+ container.removeEventListener('wheel', onWheel);
193
+ cancelAnimationFrame(frame);
194
+ };
195
+ }, [map]);
153
196
 
154
- MapRoot.displayName = 'Map.Root';
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';
155
270
 
156
271
  //
157
272
  // Tiles
158
273
  // https://react-leaflet.js.org/docs/api-components/#tilelayer
159
274
  //
160
275
 
161
- type MapTilesProps = {};
276
+ const MAP_TILES_NAME = 'Map.Tiles';
277
+
278
+ /** Default OpenStreetMap raster tile template. */
279
+ export const DEFAULT_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
162
280
 
163
- 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) => {
164
287
  const ref = useRef<L.TileLayer>(null);
288
+ const { onChange } = useMapContext(MAP_TILES_NAME);
289
+
290
+ useMapEvents({
291
+ moveend: (ev) => {
292
+ onChange?.({
293
+ center: ev.target.getCenter(),
294
+ zoom: ev.target.getZoom(),
295
+ });
296
+ },
297
+ });
165
298
 
166
299
  // NOTE: Need to dynamically update data attribute since TileLayer doesn't update, but
167
300
  // Tailwind requires setting the property for static analysis.
168
- const { attention } = useMapContext(MapTiles.displayName);
301
+ const { attention } = useMapContext(MAP_TILES_NAME);
169
302
  useEffect(() => {
170
303
  if (ref.current) {
171
304
  ref.current.getContainer().dataset.attention = attention ? '1' : '0';
@@ -180,7 +313,7 @@ const MapTiles = (_props: MapTilesProps) => {
180
313
  data-attention={attention}
181
314
  detectRetina={true}
182
315
  className='dark:grayscale dark:invert data-[attention="0"]:!opacity-80'
183
- url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
316
+ url={url}
184
317
  keepBuffer={4}
185
318
  // opacity={attention ? 1 : 0.7}
186
319
  />
@@ -206,7 +339,7 @@ const MapTiles = (_props: MapTilesProps) => {
206
339
  );
207
340
  };
208
341
 
209
- MapTiles.displayName = 'Map.Tiles';
342
+ MapTiles.displayName = MAP_TILES_NAME;
210
343
 
211
344
  //
212
345
  // Markers
@@ -214,21 +347,32 @@ MapTiles.displayName = 'Map.Tiles';
214
347
 
215
348
  type MapMarkersProps = {
216
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[];
217
352
  selected?: string[];
353
+ /** Invoked with the marker id when a marker is clicked. */
354
+ onSelect?: (id: string) => void;
218
355
  };
219
356
 
220
- const MapMarkers = ({ selected, markers }: MapMarkersProps) => {
357
+ const MapMarkers = ({ selected, markers, lines, onSelect }: MapMarkersProps) => {
221
358
  const map = useMap();
222
359
 
223
- // 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.
224
362
  useEffect(() => {
225
- if (markers.length > 0) {
226
- const bounds = latLngBounds(markers.map((marker) => marker.location));
227
- map.fitBounds(bounds);
228
- } else {
229
- 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 });
230
374
  }
231
- }, [markers]);
375
+ }, [markers, lines, map]);
232
376
 
233
377
  return (
234
378
  <>
@@ -237,6 +381,7 @@ const MapMarkers = ({ selected, markers }: MapMarkersProps) => {
237
381
  <Marker
238
382
  key={id}
239
383
  position={{ lat, lng }}
384
+ eventHandlers={onSelect ? { click: () => onSelect(id) } : undefined}
240
385
  icon={
241
386
  // TODO(burdon): Create custom icon from bundled assets.
242
387
  // TODO(burdon): Selection state.
@@ -261,6 +406,46 @@ const MapMarkers = ({ selected, markers }: MapMarkersProps) => {
261
406
 
262
407
  MapMarkers.displayName = 'Map.Markers';
263
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
+
264
449
  //
265
450
  // Controls
266
451
  // Integrates with Leaflet custom controls.
@@ -273,29 +458,47 @@ const CustomControl = ({
273
458
  position: ControlPosition;
274
459
  }>) => {
275
460
  const map = useMap();
461
+ const rootRef = useRef<ReturnType<typeof createRoot> | undefined>(undefined);
276
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).
277
466
  useEffect(() => {
278
467
  const control = new Control({ position });
279
468
  control.onAdd = () => {
280
- const container = DomUtil.create('div', mx('!m-0', controlPositions[position]));
469
+ const container = DomUtil.create('div', mx('m-0!', controlPositions[position]));
281
470
  DomEvent.disableClickPropagation(container);
282
471
  DomEvent.disableScrollPropagation(container);
283
-
284
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.
285
476
  root.render(
286
477
  <ThemeProvider tx={defaultTx}>
287
478
  <Tooltip.Provider>{children}</Tooltip.Provider>
288
479
  </ThemeProvider>,
289
480
  );
290
-
291
481
  return container;
292
482
  };
293
483
 
294
484
  control.addTo(map);
295
485
  return () => {
296
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());
297
491
  };
298
- }, [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]);
299
502
 
300
503
  return null;
301
504
  };
@@ -320,10 +523,20 @@ const MapAction = ({ onAction, position = 'bottomright', ...props }: MapControlP
320
523
 
321
524
  export const Map = {
322
525
  Root: MapRoot,
526
+ Viewport: MapViewport,
323
527
  Tiles: MapTiles,
324
528
  Markers: MapMarkers,
529
+ Lines: MapLines,
325
530
  Zoom: MapZoom,
326
531
  Action: MapAction,
327
532
  };
328
533
 
329
- 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
+ };
@@ -5,7 +5,9 @@
5
5
  import { type ControlPosition } from 'leaflet';
6
6
  import React from 'react';
7
7
 
8
- import { IconButton, type ThemedClassName, Toolbar } from '@dxos/react-ui';
8
+ import { IconButton, type ThemedClassName, Toolbar, useTranslation } from '@dxos/react-ui';
9
+
10
+ import { translationKey } from '#translations';
9
11
 
10
12
  export type ControlAction = 'toggle' | 'start' | 'zoom-in' | 'zoom-out';
11
13
 
@@ -21,22 +23,20 @@ export const controlPositions: Record<ControlPosition, string> = {
21
23
  };
22
24
 
23
25
  export const ZoomControls = ({ classNames, onAction }: ControlProps) => {
26
+ const { t } = useTranslation(translationKey);
27
+
24
28
  return (
25
29
  <Toolbar.Root classNames={['gap-2', classNames]}>
26
30
  <IconButton
27
31
  icon='ph--plus--regular'
28
- label='zoom in'
29
32
  iconOnly
30
- size={5}
31
- classNames='px-0 aspect-square'
33
+ label={t('zoom-in-icon.button')}
32
34
  onClick={() => onAction?.('zoom-in')}
33
35
  />
34
36
  <IconButton
35
37
  icon='ph--minus--regular'
36
- label='zoom out'
37
38
  iconOnly
38
- size={5}
39
- classNames='px-0 aspect-square'
39
+ label={t('zoom-out-icon.button')}
40
40
  onClick={() => onAction?.('zoom-out')}
41
41
  />
42
42
  </Toolbar.Root>
@@ -44,22 +44,20 @@ export const ZoomControls = ({ classNames, onAction }: ControlProps) => {
44
44
  };
45
45
 
46
46
  export const ActionControls = ({ classNames, onAction }: ControlProps) => {
47
+ const { t } = useTranslation(translationKey);
48
+
47
49
  return (
48
50
  <Toolbar.Root classNames={['gap-2', classNames]}>
49
51
  <IconButton
50
- icon='ph--play--regular'
51
- label='start'
52
+ icon='ph--path--regular'
52
53
  iconOnly
53
- size={5}
54
- classNames='px-0 aspect-square'
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
- label='toggle'
60
59
  iconOnly
61
- size={5}
62
- classNames='px-0 aspect-square'
60
+ label={t('toggle-icon.button')}
63
61
  onClick={() => onAction?.('toggle')}
64
62
  />
65
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
  };