@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.
@@ -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,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './Map';
@@ -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
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './Controls';
@@ -0,0 +1,7 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './Globe';
6
+ export * from './Map';
7
+ export * from './Toolbar';
package/src/data.ts ADDED
@@ -0,0 +1,9 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Topology } from 'topojson-specification';
6
+
7
+ export const loadTopology = async (): Promise<Topology> => {
8
+ return (await import('../data/countries-110m')).default;
9
+ };
@@ -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,10 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './context';
6
+ export * from './useDrag';
7
+ export * from './useGlobeZoomHandler';
8
+ export * from './useMapZoomHandler';
9
+ export * from './useSpinner';
10
+ export * from './useTour';
@@ -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
+ };