@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.
- 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 +1054 -578
- 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 +1054 -578
- 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 +37 -11
- package/dist/types/src/components/Globe/Globe.stories.d.ts.map +1 -1
- package/dist/types/src/components/Map/Map.d.ts +55 -16
- package/dist/types/src/components/Map/Map.d.ts.map +1 -1
- package/dist/types/src/components/Map/Map.stories.d.ts +19 -9
- 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 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +12 -0
- package/dist/types/src/translations.d.ts.map +1 -0
- 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 +43 -37
- package/src/components/Globe/Globe.stories.tsx +201 -82
- package/src/components/Globe/Globe.tsx +263 -119
- package/src/components/Map/Map.stories.tsx +66 -17
- package/src/components/Map/Map.tsx +333 -120
- package/src/components/Toolbar/Controls.tsx +12 -14
- package/src/data.ts +19 -2
- package/src/hooks/context.tsx +49 -28
- package/src/hooks/index.ts +3 -0
- package/src/hooks/useDrag.ts +33 -5
- package/src/hooks/useGlobeZoomHandler.ts +10 -3
- 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 +71 -81
- package/src/hooks/useWheel.ts +83 -0
- package/src/index.ts +0 -1
- package/src/translations.ts +20 -0
- 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,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, {
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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 [
|
|
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 =
|
|
54
|
-
MapContainerProps & {
|
|
55
|
-
onChange?: (ev: { center: LatLngLiteral; zoom: number }) => void;
|
|
56
|
-
}
|
|
57
|
-
>;
|
|
74
|
+
type MapRootProps = PropsWithChildren<Pick<MapContextValue, 'onChange'>>;
|
|
58
75
|
|
|
59
76
|
/**
|
|
60
|
-
*
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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=
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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('
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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--
|
|
51
|
-
label='start'
|
|
52
|
+
icon='ph--path--regular'
|
|
52
53
|
iconOnly
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|