@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.
- 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 +774 -223
- 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 +774 -223
- 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 +18 -10
- package/dist/types/src/components/Globe/Globe.d.ts.map +1 -1
- package/dist/types/src/components/Globe/Globe.stories.d.ts +16 -8
- package/dist/types/src/components/Globe/Globe.stories.d.ts.map +1 -1
- package/dist/types/src/components/Map/Map.d.ts +49 -13
- 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 +37 -0
- 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 +4 -4
- 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 +26 -24
- package/src/components/Globe/Globe.stories.tsx +135 -58
- package/src/components/Globe/Globe.tsx +237 -120
- package/src/components/Map/Map.stories.tsx +58 -12
- package/src/components/Map/Map.tsx +293 -91
- package/src/components/Toolbar/Controls.tsx +1 -1
- package/src/data.ts +19 -2
- package/src/hooks/context.tsx +44 -0
- 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 -1
- 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/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 -16
- 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 {
|
|
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
|
|
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.
|
|
77
|
+
* Context provider for the map. Must wrap Map.Viewport. The ref exposes a {@link MapController}.
|
|
56
78
|
*/
|
|
57
|
-
const MapRoot =
|
|
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
|
-
|
|
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
|
-
//
|
|
115
|
+
// Viewport
|
|
79
116
|
//
|
|
80
117
|
|
|
81
|
-
type
|
|
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
|
|
123
|
+
const MAP_VIEWPORT_NAME = 'Map.Viewport';
|
|
87
124
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
+
/** Default OpenStreetMap raster tile template. */
|
|
279
|
+
export const DEFAULT_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
|
155
280
|
|
|
156
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
//
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 '
|
|
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
|
-
|
|
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
|
};
|
package/src/hooks/context.tsx
CHANGED
|
@@ -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);
|
package/src/hooks/index.ts
CHANGED
|
@@ -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';
|