@dxos/solid-ui-geo 0.0.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 +8 -0
- package/README.md +3 -0
- package/data/airports.ts +52524 -0
- package/data/countries-110m.ts +10587 -0
- package/data/countries-dots-3.ts +42989 -0
- package/data/raw/airports.json +39386 -0
- package/data/raw/countries-10m.json +1 -0
- package/data/raw/countries-110m.json +1 -0
- package/data/raw/countries-50m.json +1 -0
- package/data/raw/countries.json +1 -0
- package/package.json +75 -0
- package/src/components/Globe/Globe.solid-stories.tsx +397 -0
- package/src/components/Globe/Globe.tsx +339 -0
- package/src/components/Globe/index.ts +5 -0
- package/src/components/Map/Map.solid-stories.tsx +69 -0
- package/src/components/Map/Map.tsx +333 -0
- package/src/components/Map/index.ts +5 -0
- package/src/components/Toolbar/Controls.tsx +66 -0
- package/src/components/Toolbar/index.ts +5 -0
- package/src/components/index.ts +7 -0
- package/src/data.ts +9 -0
- package/src/hooks/context.tsx +79 -0
- package/src/hooks/index.ts +10 -0
- package/src/hooks/useDrag.ts +54 -0
- package/src/hooks/useGlobeZoomHandler.ts +30 -0
- package/src/hooks/useMapZoomHandler.ts +24 -0
- package/src/hooks/useSpinner.ts +71 -0
- package/src/hooks/useTour.ts +124 -0
- package/src/index.ts +10 -0
- package/src/translations.ts +18 -0
- package/src/types.ts +13 -0
- package/src/util/debug.ts +17 -0
- package/src/util/index.ts +8 -0
- package/src/util/inertia.ts +196 -0
- package/src/util/path.ts +52 -0
- package/src/util/render.ts +164 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import 'leaflet/dist/leaflet.css';
|
|
6
|
+
|
|
7
|
+
import L, { type ControlPosition, type LatLngLiteral, latLngBounds } from 'leaflet';
|
|
8
|
+
import {
|
|
9
|
+
type Accessor,
|
|
10
|
+
type JSX,
|
|
11
|
+
type Setter,
|
|
12
|
+
Show,
|
|
13
|
+
createContext,
|
|
14
|
+
createEffect,
|
|
15
|
+
createSignal,
|
|
16
|
+
onCleanup,
|
|
17
|
+
onMount,
|
|
18
|
+
useContext,
|
|
19
|
+
} from 'solid-js';
|
|
20
|
+
|
|
21
|
+
import { type GeoMarker } from '../../types';
|
|
22
|
+
import { ActionControls, type ControlProps, ZoomControls, controlPositions } from '../Toolbar';
|
|
23
|
+
|
|
24
|
+
// TODO(burdon): Guess initial location.
|
|
25
|
+
|
|
26
|
+
const defaults = {
|
|
27
|
+
center: { lat: 51, lng: 0 } as L.LatLngLiteral,
|
|
28
|
+
zoom: 4,
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
//
|
|
32
|
+
// Controller
|
|
33
|
+
//
|
|
34
|
+
|
|
35
|
+
export type MapController = {
|
|
36
|
+
setCenter: (center: LatLngLiteral, zoom?: number) => void;
|
|
37
|
+
setZoom: (cb: (zoom: number) => number) => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
//
|
|
41
|
+
// Context
|
|
42
|
+
//
|
|
43
|
+
|
|
44
|
+
type MapContextValue = {
|
|
45
|
+
map: Accessor<L.Map | null>;
|
|
46
|
+
attention: Accessor<boolean>;
|
|
47
|
+
setAttention: Setter<boolean>;
|
|
48
|
+
onChange?: (ev: { center: LatLngLiteral; zoom: number }) => void;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const MapContext = createContext<MapContextValue>();
|
|
52
|
+
|
|
53
|
+
const useMapContext = (displayName: string) => {
|
|
54
|
+
const context = useContext(MapContext);
|
|
55
|
+
if (!context) {
|
|
56
|
+
throw new Error(`${displayName} must be used within Map.Root`);
|
|
57
|
+
}
|
|
58
|
+
return context;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
//
|
|
62
|
+
// Root
|
|
63
|
+
//
|
|
64
|
+
|
|
65
|
+
type MapRootProps = {
|
|
66
|
+
children: JSX.Element;
|
|
67
|
+
ref?: (controller: MapController) => void;
|
|
68
|
+
class?: string;
|
|
69
|
+
scrollWheelZoom?: boolean;
|
|
70
|
+
doubleClickZoom?: boolean;
|
|
71
|
+
touchZoom?: boolean;
|
|
72
|
+
center?: LatLngLiteral;
|
|
73
|
+
zoom?: number;
|
|
74
|
+
onChange?: (ev: { center: LatLngLiteral; zoom: number }) => void;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* https://leafletjs.com/reference.html#map
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
const MapRoot = (props: MapRootProps) => {
|
|
82
|
+
let mapContainer: HTMLDivElement | undefined;
|
|
83
|
+
const [map, setMap] = createSignal<L.Map | null>(null);
|
|
84
|
+
const [attention, setAttention] = createSignal(false);
|
|
85
|
+
|
|
86
|
+
onMount(() => {
|
|
87
|
+
if (!mapContainer) return;
|
|
88
|
+
|
|
89
|
+
const leafletMap = L.map(mapContainer, {
|
|
90
|
+
center: props.center ?? defaults.center,
|
|
91
|
+
zoom: props.zoom ?? defaults.zoom,
|
|
92
|
+
attributionControl: false,
|
|
93
|
+
zoomControl: false,
|
|
94
|
+
scrollWheelZoom: props.scrollWheelZoom ?? true,
|
|
95
|
+
doubleClickZoom: props.doubleClickZoom ?? true,
|
|
96
|
+
touchZoom: props.touchZoom ?? true,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
setMap(leafletMap);
|
|
100
|
+
|
|
101
|
+
// Set up controller
|
|
102
|
+
if (props.ref) {
|
|
103
|
+
props.ref({
|
|
104
|
+
setCenter: (center: LatLngLiteral, zoom?: number) => {
|
|
105
|
+
leafletMap.setView(center, zoom);
|
|
106
|
+
},
|
|
107
|
+
setZoom: (cb: (zoom: number) => number) => {
|
|
108
|
+
leafletMap.setZoom(cb(leafletMap.getZoom()));
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
onCleanup(() => {
|
|
114
|
+
leafletMap.remove();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Enable/disable scroll wheel zoom based on attention
|
|
119
|
+
createEffect(() => {
|
|
120
|
+
const leafletMap = map();
|
|
121
|
+
if (!leafletMap) return;
|
|
122
|
+
|
|
123
|
+
if (attention()) {
|
|
124
|
+
leafletMap.scrollWheelZoom.enable();
|
|
125
|
+
} else {
|
|
126
|
+
leafletMap.scrollWheelZoom.disable();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<MapContext.Provider value={{ map, attention, setAttention, onChange: props.onChange }}>
|
|
132
|
+
<div
|
|
133
|
+
ref={mapContainer}
|
|
134
|
+
class={`group relative grid h-full w-full bg-gray-100 dark:bg-gray-900 ${props.class ?? ''}`}
|
|
135
|
+
style={{ 'z-index': '0' }}
|
|
136
|
+
/>
|
|
137
|
+
<Show when={map()}>{props.children}</Show>
|
|
138
|
+
</MapContext.Provider>
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
//
|
|
143
|
+
// Tiles
|
|
144
|
+
//
|
|
145
|
+
|
|
146
|
+
const MapTiles = () => {
|
|
147
|
+
const { map, onChange, attention } = useMapContext(MapTiles.name);
|
|
148
|
+
let tileLayer: L.TileLayer | null = null;
|
|
149
|
+
|
|
150
|
+
createEffect(() => {
|
|
151
|
+
const leafletMap = map();
|
|
152
|
+
if (!leafletMap) return;
|
|
153
|
+
|
|
154
|
+
const att = attention();
|
|
155
|
+
tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
156
|
+
detectRetina: true,
|
|
157
|
+
keepBuffer: 4,
|
|
158
|
+
className: `dark:grayscale dark:invert ${att ? '' : 'opacity-80'}`,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
tileLayer.addTo(leafletMap);
|
|
162
|
+
|
|
163
|
+
// Set up event listeners
|
|
164
|
+
leafletMap.on('zoomstart', (ev) => {
|
|
165
|
+
onChange?.({
|
|
166
|
+
center: ev.target.getCenter(),
|
|
167
|
+
zoom: ev.target.getZoom(),
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
onCleanup(() => {
|
|
172
|
+
if (tileLayer) {
|
|
173
|
+
tileLayer.remove();
|
|
174
|
+
tileLayer = null;
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Update tile layer class when attention changes
|
|
180
|
+
createEffect(() => {
|
|
181
|
+
const att = attention();
|
|
182
|
+
if (tileLayer) {
|
|
183
|
+
const container = tileLayer.getContainer();
|
|
184
|
+
if (container) {
|
|
185
|
+
container.className = `dark:grayscale dark:invert ${att ? '' : 'opacity-80'}`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return null;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
//
|
|
194
|
+
// Markers
|
|
195
|
+
//
|
|
196
|
+
|
|
197
|
+
type MapMarkersProps = {
|
|
198
|
+
markers?: Accessor<GeoMarker[]>;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const MapMarkers = (props: MapMarkersProps) => {
|
|
202
|
+
const { map } = useMapContext(MapMarkers.name);
|
|
203
|
+
const leafletMarkers: L.Marker[] = [];
|
|
204
|
+
let lastMarkerIds = new Set<string>();
|
|
205
|
+
|
|
206
|
+
// Clean up all markers when component unmounts
|
|
207
|
+
onCleanup(() => {
|
|
208
|
+
leafletMarkers.forEach((marker) => marker.remove());
|
|
209
|
+
leafletMarkers.length = 0;
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
createEffect(() => {
|
|
213
|
+
const leafletMap = map();
|
|
214
|
+
if (!leafletMap) return;
|
|
215
|
+
|
|
216
|
+
const markerList = props.markers?.() ?? [];
|
|
217
|
+
const currentIds = new Set(markerList.map((m) => m.id));
|
|
218
|
+
|
|
219
|
+
// Skip if the markers haven't actually changed
|
|
220
|
+
if (
|
|
221
|
+
currentIds.size === lastMarkerIds.size &&
|
|
222
|
+
[...currentIds].every((id) => lastMarkerIds.has(id)) &&
|
|
223
|
+
leafletMarkers.length > 0
|
|
224
|
+
) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Clear existing markers
|
|
229
|
+
leafletMarkers.forEach((marker) => marker.remove());
|
|
230
|
+
leafletMarkers.length = 0;
|
|
231
|
+
lastMarkerIds = currentIds;
|
|
232
|
+
|
|
233
|
+
if (markerList.length > 0) {
|
|
234
|
+
const bounds = latLngBounds(markerList.map((marker) => marker.location));
|
|
235
|
+
leafletMap.fitBounds(bounds);
|
|
236
|
+
|
|
237
|
+
// Add new markers
|
|
238
|
+
markerList.forEach(({ title, location }) => {
|
|
239
|
+
const marker = L.marker(location, {
|
|
240
|
+
icon: new L.Icon({
|
|
241
|
+
iconUrl: 'https://dxos.network/marker-icon.png',
|
|
242
|
+
iconRetinaUrl: 'https://dxos.network/marker-icon-2x.png',
|
|
243
|
+
shadowUrl: 'https://dxos.network/marker-shadow.png',
|
|
244
|
+
iconSize: [25, 41],
|
|
245
|
+
iconAnchor: [12, 41],
|
|
246
|
+
popupAnchor: [1, -34],
|
|
247
|
+
shadowSize: [41, 41],
|
|
248
|
+
}),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
if (title) {
|
|
252
|
+
marker.bindPopup(title);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
marker.addTo(leafletMap);
|
|
256
|
+
leafletMarkers.push(marker);
|
|
257
|
+
});
|
|
258
|
+
} else {
|
|
259
|
+
leafletMap.setView(defaults.center, defaults.zoom);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
return null;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
//
|
|
267
|
+
// Controls
|
|
268
|
+
//
|
|
269
|
+
|
|
270
|
+
const CustomControl = (props: { children: JSX.Element; position: ControlPosition }) => {
|
|
271
|
+
const { map: mapAccessor } = useMapContext(CustomControl.name);
|
|
272
|
+
let controlContainer: HTMLDivElement | undefined;
|
|
273
|
+
|
|
274
|
+
createEffect(() => {
|
|
275
|
+
const map = mapAccessor();
|
|
276
|
+
if (!map || !controlContainer) return;
|
|
277
|
+
|
|
278
|
+
const Control = L.Control.extend({
|
|
279
|
+
onAdd: () => {
|
|
280
|
+
const container = L.DomUtil.create('div', `${controlPositions[props.position]} !m-0`);
|
|
281
|
+
L.DomEvent.disableClickPropagation(container);
|
|
282
|
+
L.DomEvent.disableScrollPropagation(container);
|
|
283
|
+
|
|
284
|
+
if (controlContainer) {
|
|
285
|
+
container.appendChild(controlContainer);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return container;
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const control = new Control({ position: props.position });
|
|
293
|
+
control.addTo(map);
|
|
294
|
+
|
|
295
|
+
onCleanup(() => {
|
|
296
|
+
control.remove();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<div ref={controlContainer} style={{ display: 'contents' }}>
|
|
302
|
+
{props.children}
|
|
303
|
+
</div>
|
|
304
|
+
);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
type MapControlProps = { position?: ControlPosition } & Pick<ControlProps, 'onAction'>;
|
|
308
|
+
|
|
309
|
+
const MapZoom = (props: MapControlProps) => (
|
|
310
|
+
<CustomControl position={props.position ?? 'bottomleft'}>
|
|
311
|
+
<ZoomControls onAction={props.onAction} />
|
|
312
|
+
</CustomControl>
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const MapAction = (props: MapControlProps) => (
|
|
316
|
+
<CustomControl position={props.position ?? 'bottomright'}>
|
|
317
|
+
<ActionControls onAction={props.onAction} />
|
|
318
|
+
</CustomControl>
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
//
|
|
322
|
+
// Map
|
|
323
|
+
//
|
|
324
|
+
|
|
325
|
+
export const Map = {
|
|
326
|
+
Root: MapRoot,
|
|
327
|
+
Tiles: MapTiles,
|
|
328
|
+
Markers: MapMarkers,
|
|
329
|
+
Zoom: MapZoom,
|
|
330
|
+
Action: MapAction,
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
export { type MapRootProps, type MapMarkersProps, type MapControlProps };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type ControlPosition } from 'leaflet';
|
|
6
|
+
import { type JSX } from 'solid-js';
|
|
7
|
+
|
|
8
|
+
export type ControlAction = 'toggle' | 'start' | 'zoom-in' | 'zoom-out';
|
|
9
|
+
|
|
10
|
+
export type ControlProps = {
|
|
11
|
+
class?: string;
|
|
12
|
+
onAction?: (action: ControlAction) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const controlPositions: Record<ControlPosition, string> = {
|
|
16
|
+
topleft: 'top-2 left-2',
|
|
17
|
+
topright: 'top-2 right-2',
|
|
18
|
+
bottomleft: 'bottom-2 left-2',
|
|
19
|
+
bottomright: 'bottom-2 right-2',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const ZoomControls = (props: ControlProps): JSX.Element => {
|
|
23
|
+
return (
|
|
24
|
+
<div class={`flex flex-row gap-2 ${props.class ?? ''}`}>
|
|
25
|
+
<button
|
|
26
|
+
type='button'
|
|
27
|
+
class='dx-button dx-focus-ring w-10 h-10 min-bs-[2.5rem] pli-3 rounded-sm flex items-center justify-center'
|
|
28
|
+
onClick={() => props.onAction?.('zoom-in')}
|
|
29
|
+
title='Zoom in'
|
|
30
|
+
>
|
|
31
|
+
<span class='text-xl'>+</span>
|
|
32
|
+
</button>
|
|
33
|
+
<button
|
|
34
|
+
type='button'
|
|
35
|
+
class='dx-button dx-focus-ring w-10 h-10 min-bs-[2.5rem] pli-3 rounded-sm flex items-center justify-center'
|
|
36
|
+
onClick={() => props.onAction?.('zoom-out')}
|
|
37
|
+
title='Zoom out'
|
|
38
|
+
>
|
|
39
|
+
<span class='text-xl'>−</span>
|
|
40
|
+
</button>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const ActionControls = (props: ControlProps): JSX.Element => {
|
|
46
|
+
return (
|
|
47
|
+
<div class={`flex flex-row gap-2 ${props.class ?? ''}`}>
|
|
48
|
+
<button
|
|
49
|
+
type='button'
|
|
50
|
+
class='dx-button dx-focus-ring w-10 h-10 min-bs-[2.5rem] pli-3 rounded-sm flex items-center justify-center'
|
|
51
|
+
onClick={() => props.onAction?.('start')}
|
|
52
|
+
title='Start'
|
|
53
|
+
>
|
|
54
|
+
<span class='text-xl'>▶</span>
|
|
55
|
+
</button>
|
|
56
|
+
<button
|
|
57
|
+
type='button'
|
|
58
|
+
class='dx-button dx-focus-ring w-10 h-10 min-bs-[2.5rem] pli-3 rounded-sm flex items-center justify-center'
|
|
59
|
+
onClick={() => props.onAction?.('toggle')}
|
|
60
|
+
title='Toggle'
|
|
61
|
+
>
|
|
62
|
+
<span class='text-xl'>🌍</span>
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
};
|
package/src/data.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Accessor, type JSX, type Setter, createContext, createEffect, createSignal, useContext } from 'solid-js';
|
|
6
|
+
|
|
7
|
+
import { raise } from '@dxos/debug';
|
|
8
|
+
|
|
9
|
+
import { type LatLngLiteral } from '../types';
|
|
10
|
+
|
|
11
|
+
// TODO(burdon): Factor out common geometry types.
|
|
12
|
+
export type Size = { width: number; height: number };
|
|
13
|
+
export type Point = { x: number; y: number };
|
|
14
|
+
export type Vector = [number, number, number];
|
|
15
|
+
|
|
16
|
+
export type GlobeContextType = {
|
|
17
|
+
size: Accessor<Size>;
|
|
18
|
+
center: Accessor<LatLngLiteral | undefined>;
|
|
19
|
+
zoom: Accessor<number>;
|
|
20
|
+
translation: Accessor<Point | undefined>;
|
|
21
|
+
rotation: Accessor<Vector | undefined>;
|
|
22
|
+
setCenter: Setter<LatLngLiteral | undefined>;
|
|
23
|
+
setZoom: Setter<number>;
|
|
24
|
+
setTranslation: Setter<Point | undefined>;
|
|
25
|
+
setRotation: Setter<Vector | undefined>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const defaults = {
|
|
29
|
+
center: { lat: 51, lng: 0 } as LatLngLiteral,
|
|
30
|
+
zoom: 4,
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
const GlobeContext = createContext<GlobeContextType>();
|
|
34
|
+
|
|
35
|
+
export type GlobeContextProviderProps = {
|
|
36
|
+
children: JSX.Element;
|
|
37
|
+
size?: Size;
|
|
38
|
+
center?: LatLngLiteral;
|
|
39
|
+
zoom?: number;
|
|
40
|
+
translation?: Point;
|
|
41
|
+
rotation?: Vector;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const GlobeContextProvider = (props: GlobeContextProviderProps) => {
|
|
45
|
+
const [size, setSize] = createSignal<Size>(props.size ?? { width: 0, height: 0 });
|
|
46
|
+
const [center, setCenter] = createSignal<LatLngLiteral | undefined>(props.center ?? defaults.center);
|
|
47
|
+
const [zoom, setZoom] = createSignal(props.zoom ?? defaults.zoom);
|
|
48
|
+
const [translation, setTranslation] = createSignal<Point | undefined>(props.translation);
|
|
49
|
+
const [rotation, setRotation] = createSignal<Vector | undefined>(props.rotation);
|
|
50
|
+
|
|
51
|
+
// Update size when prop changes
|
|
52
|
+
createEffect(() => {
|
|
53
|
+
if (props.size) {
|
|
54
|
+
setSize(props.size);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<GlobeContext.Provider
|
|
60
|
+
value={{
|
|
61
|
+
size,
|
|
62
|
+
center,
|
|
63
|
+
zoom,
|
|
64
|
+
translation,
|
|
65
|
+
rotation,
|
|
66
|
+
setCenter,
|
|
67
|
+
setZoom,
|
|
68
|
+
setTranslation,
|
|
69
|
+
setRotation,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{props.children}
|
|
73
|
+
</GlobeContext.Provider>
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const useGlobeContext = () => {
|
|
78
|
+
return useContext(GlobeContext) ?? raise(new Error('Missing GlobeContext'));
|
|
79
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { select } from 'd3';
|
|
6
|
+
import { createEffect, onCleanup } from 'solid-js';
|
|
7
|
+
|
|
8
|
+
import { type GlobeController } from '../components';
|
|
9
|
+
import { geoInertiaDrag } from '../util';
|
|
10
|
+
|
|
11
|
+
export type GlobeDragEvent = {
|
|
12
|
+
type: 'start' | 'move' | 'end';
|
|
13
|
+
controller: GlobeController;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type DragOptions = {
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
duration?: number;
|
|
19
|
+
xAxis?: boolean; // TODO(burdon): Generalize.
|
|
20
|
+
onUpdate?: (event: GlobeDragEvent) => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Allows user to drag globe.
|
|
25
|
+
*/
|
|
26
|
+
export const useDrag = (controller?: GlobeController | null, options: DragOptions = {}) => {
|
|
27
|
+
createEffect(() => {
|
|
28
|
+
const canvas = controller?.canvas;
|
|
29
|
+
if (!canvas || options.disabled) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
geoInertiaDrag(
|
|
34
|
+
select(canvas),
|
|
35
|
+
() => {
|
|
36
|
+
controller.setRotation(controller.projection.rotate());
|
|
37
|
+
options.onUpdate?.({ type: 'move', controller });
|
|
38
|
+
},
|
|
39
|
+
controller.projection,
|
|
40
|
+
{
|
|
41
|
+
xAxis: options.xAxis,
|
|
42
|
+
time: 3_000,
|
|
43
|
+
start: () => options.onUpdate?.({ type: 'start', controller }),
|
|
44
|
+
finish: () => options.onUpdate?.({ type: 'end', controller }),
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
onCleanup(() => {
|
|
49
|
+
cancelDrag(select(canvas));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const cancelDrag = (node) => node.on('.drag', null);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type ControlProps, type GlobeController } from '../components';
|
|
6
|
+
|
|
7
|
+
const ZOOM_FACTOR = 0.1;
|
|
8
|
+
|
|
9
|
+
export const useGlobeZoomHandler = (controller: GlobeController | null | undefined): ControlProps['onAction'] => {
|
|
10
|
+
return (event) => {
|
|
11
|
+
if (!controller) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
switch (event) {
|
|
16
|
+
case 'zoom-in': {
|
|
17
|
+
controller.setZoom((zoom) => {
|
|
18
|
+
return zoom * (1 + ZOOM_FACTOR);
|
|
19
|
+
});
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
case 'zoom-out': {
|
|
23
|
+
controller.setZoom((zoom) => {
|
|
24
|
+
return zoom * (1 - ZOOM_FACTOR);
|
|
25
|
+
});
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type ControlProps, type MapController } from '../components';
|
|
6
|
+
|
|
7
|
+
export const useMapZoomHandler = (controller: MapController | null | undefined): ControlProps['onAction'] => {
|
|
8
|
+
return (event) => {
|
|
9
|
+
if (!controller) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
switch (event) {
|
|
14
|
+
case 'zoom-in': {
|
|
15
|
+
controller.setZoom((scale) => scale + 1);
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
case 'zoom-out': {
|
|
19
|
+
controller.setZoom((scale) => scale - 1);
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { timer as d3Timer } from 'd3';
|
|
6
|
+
import { type Timer } from 'd3';
|
|
7
|
+
import { createEffect, createSignal, onCleanup } from 'solid-js';
|
|
8
|
+
|
|
9
|
+
import { type GlobeController } from '../components';
|
|
10
|
+
|
|
11
|
+
import { type Vector } from './context';
|
|
12
|
+
|
|
13
|
+
export type SpinnerOptions = {
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
delta?: Vector;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Rotates globe.
|
|
20
|
+
*/
|
|
21
|
+
export const useSpinner = (controller?: GlobeController | null, options: SpinnerOptions = {}) => {
|
|
22
|
+
const [running, setRunning] = createSignal(false);
|
|
23
|
+
|
|
24
|
+
createEffect(() => {
|
|
25
|
+
let timer: Timer | undefined;
|
|
26
|
+
|
|
27
|
+
const start = () => {
|
|
28
|
+
const delta: Vector = options.delta ?? [0.001, 0, 0];
|
|
29
|
+
|
|
30
|
+
let t = 0;
|
|
31
|
+
let lastRotation = controller!.projection.rotate();
|
|
32
|
+
timer = d3Timer((elapsed) => {
|
|
33
|
+
const dt = elapsed - t;
|
|
34
|
+
t = elapsed;
|
|
35
|
+
|
|
36
|
+
const rotation: Vector = [
|
|
37
|
+
lastRotation[0] + delta[0] * dt,
|
|
38
|
+
lastRotation[1] + delta[1] * dt,
|
|
39
|
+
lastRotation[2] + delta[2] * dt,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
lastRotation = rotation;
|
|
43
|
+
controller!.setRotation(rotation);
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const stop = () => {
|
|
48
|
+
if (timer) {
|
|
49
|
+
timer.stop();
|
|
50
|
+
timer = undefined;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
if (controller && running()) {
|
|
55
|
+
start();
|
|
56
|
+
} else {
|
|
57
|
+
stop();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
onCleanup(() => stop());
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
start: () => {
|
|
65
|
+
if (!options.disabled) {
|
|
66
|
+
setRunning(true);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
stop: () => setRunning(false),
|
|
70
|
+
};
|
|
71
|
+
};
|