@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.
- package/LICENSE +102 -5
- package/data/countries-10m.ts +12 -0
- package/data/countries-110m.ts +4 -10579
- package/data/countries-50m.ts +12 -0
- package/dist/lib/browser/chunk-SC2FBYFU.mjs +17 -0
- package/dist/lib/browser/chunk-SC2FBYFU.mjs.map +7 -0
- package/dist/lib/browser/countries-10m-CWWDOKH7.mjs +6 -0
- package/dist/lib/browser/countries-10m-CWWDOKH7.mjs.map +7 -0
- package/dist/lib/browser/countries-110m-72QBAA5E.mjs +6 -0
- package/dist/lib/browser/countries-110m-72QBAA5E.mjs.map +7 -0
- package/dist/lib/browser/countries-50m-H7SL7KVF.mjs +6 -0
- package/dist/lib/browser/countries-50m-H7SL7KVF.mjs.map +7 -0
- package/dist/lib/browser/data.mjs +1 -1
- package/dist/lib/browser/index.mjs +1046 -579
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/translations.mjs +19 -0
- package/dist/lib/browser/translations.mjs.map +7 -0
- package/dist/lib/node-esm/chunk-VZENBYLJ.mjs +19 -0
- package/dist/lib/node-esm/chunk-VZENBYLJ.mjs.map +7 -0
- package/dist/lib/node-esm/countries-10m-DJZV66KG.mjs +8 -0
- package/dist/lib/node-esm/countries-10m-DJZV66KG.mjs.map +7 -0
- package/dist/lib/node-esm/countries-110m-H3WY6K4Q.mjs +8 -0
- package/dist/lib/node-esm/countries-110m-H3WY6K4Q.mjs.map +7 -0
- package/dist/lib/node-esm/countries-50m-ZY7Z3IWD.mjs +8 -0
- package/dist/lib/node-esm/countries-50m-ZY7Z3IWD.mjs.map +7 -0
- package/dist/lib/node-esm/data.mjs +1 -1
- package/dist/lib/node-esm/index.mjs +1046 -579
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/translations.mjs +21 -0
- package/dist/lib/node-esm/translations.mjs.map +7 -0
- package/dist/types/data/airports.d.ts +4 -4
- package/dist/types/data/airports.d.ts.map +1 -1
- package/dist/types/data/cities.d.ts.map +1 -1
- package/dist/types/data/countries-10m.d.ts +8 -0
- package/dist/types/data/countries-10m.d.ts.map +1 -0
- package/dist/types/data/countries-110m.d.ts +2 -30
- package/dist/types/data/countries-110m.d.ts.map +1 -1
- package/dist/types/data/countries-50m.d.ts +8 -0
- package/dist/types/data/countries-50m.d.ts.map +1 -0
- package/dist/types/data/countries-dots-3.d.ts.map +1 -1
- package/dist/types/data/countries-dots-4.d.ts.map +1 -1
- package/dist/types/src/components/Globe/Globe.d.ts +19 -9
- package/dist/types/src/components/Globe/Globe.d.ts.map +1 -1
- package/dist/types/src/components/Globe/Globe.stories.d.ts +17 -7
- package/dist/types/src/components/Globe/Globe.stories.d.ts.map +1 -1
- package/dist/types/src/components/Map/Map.d.ts +51 -9
- package/dist/types/src/components/Map/Map.d.ts.map +1 -1
- package/dist/types/src/components/Map/Map.stories.d.ts +9 -5
- package/dist/types/src/components/Map/Map.stories.d.ts.map +1 -1
- package/dist/types/src/components/Toolbar/Controls.d.ts.map +1 -1
- package/dist/types/src/data.d.ts +9 -1
- package/dist/types/src/data.d.ts.map +1 -1
- package/dist/types/src/hooks/context.d.ts +38 -3
- package/dist/types/src/hooks/context.d.ts.map +1 -1
- package/dist/types/src/hooks/index.d.ts +3 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useDrag.d.ts +22 -2
- package/dist/types/src/hooks/useDrag.d.ts.map +1 -1
- package/dist/types/src/hooks/useGlobeZoomHandler.d.ts +3 -2
- package/dist/types/src/hooks/useGlobeZoomHandler.d.ts.map +1 -1
- package/dist/types/src/hooks/useMapZoomHandler.d.ts +1 -1
- package/dist/types/src/hooks/useMapZoomHandler.d.ts.map +1 -1
- package/dist/types/src/hooks/useSimplifiedTopology.d.ts +32 -0
- package/dist/types/src/hooks/useSimplifiedTopology.d.ts.map +1 -0
- package/dist/types/src/hooks/useSpinner.d.ts +1 -1
- package/dist/types/src/hooks/useSpinner.d.ts.map +1 -1
- package/dist/types/src/hooks/useTopology.d.ts +26 -0
- package/dist/types/src/hooks/useTopology.d.ts.map +1 -0
- package/dist/types/src/hooks/useTour.d.ts +3 -2
- package/dist/types/src/hooks/useTour.d.ts.map +1 -1
- package/dist/types/src/hooks/useWheel.d.ts +24 -0
- package/dist/types/src/hooks/useWheel.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +0 -2
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +6 -6
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/src/util/animation.d.ts +16 -0
- package/dist/types/src/util/animation.d.ts.map +1 -0
- package/dist/types/src/util/debug.d.ts.map +1 -1
- package/dist/types/src/util/index.d.ts +2 -0
- package/dist/types/src/util/index.d.ts.map +1 -1
- package/dist/types/src/util/inertia.d.ts.map +1 -1
- package/dist/types/src/util/path.d.ts.map +1 -1
- package/dist/types/src/util/render.d.ts +25 -1
- package/dist/types/src/util/render.d.ts.map +1 -1
- package/dist/types/src/util/styles.d.ts +4 -0
- package/dist/types/src/util/styles.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +41 -35
- package/src/components/Globe/Globe.stories.tsx +141 -65
- package/src/components/Globe/Globe.tsx +262 -119
- package/src/components/Map/Map.stories.tsx +59 -12
- package/src/components/Map/Map.tsx +325 -82
- package/src/components/Toolbar/Controls.tsx +5 -5
- package/src/data.ts +19 -2
- package/src/hooks/context.tsx +46 -31
- package/src/hooks/index.ts +3 -0
- package/src/hooks/useDrag.ts +33 -5
- package/src/hooks/useGlobeZoomHandler.ts +2 -1
- package/src/hooks/useSimplifiedTopology.ts +81 -0
- package/src/hooks/useSpinner.ts +1 -2
- package/src/hooks/useTopology.ts +95 -0
- package/src/hooks/useTour.ts +70 -81
- package/src/hooks/useWheel.ts +83 -0
- package/src/index.ts +0 -2
- package/src/translations.ts +5 -5
- package/src/util/animation.ts +35 -0
- package/src/util/index.ts +2 -0
- package/src/util/inertia.ts +87 -4
- package/src/util/render.ts +105 -17
- package/src/util/styles.ts +62 -0
- package/dist/lib/browser/chunk-GMWLKTLN.mjs +0 -9
- package/dist/lib/browser/chunk-GMWLKTLN.mjs.map +0 -7
- package/dist/lib/browser/countries-110m-ZM3ZIEFS.mjs +0 -37859
- package/dist/lib/browser/countries-110m-ZM3ZIEFS.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-JODBF4CC.mjs +0 -11
- package/dist/lib/node-esm/chunk-JODBF4CC.mjs.map +0 -7
- package/dist/lib/node-esm/countries-110m-3SFASWVD.mjs +0 -37861
- 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, {
|
|
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 {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 [
|
|
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 =
|
|
74
|
+
type MapRootProps = PropsWithChildren<Pick<MapContextValue, 'onChange'>>;
|
|
53
75
|
|
|
54
76
|
/**
|
|
55
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
276
|
+
const MAP_TILES_NAME = 'Map.Tiles';
|
|
122
277
|
|
|
123
|
-
|
|
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(
|
|
288
|
+
const { onChange } = useMapContext(MAP_TILES_NAME);
|
|
126
289
|
|
|
127
290
|
useMapEvents({
|
|
128
|
-
|
|
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(
|
|
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=
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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('
|
|
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
|
|
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 {
|
|
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 '
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
8
|
-
|
|
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
|
};
|