@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,339 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { createResizeObserver } from '@solid-primitives/resize-observer';
|
|
6
|
+
import {
|
|
7
|
+
type GeoProjection,
|
|
8
|
+
easeLinear,
|
|
9
|
+
easeSinOut,
|
|
10
|
+
geoMercator,
|
|
11
|
+
geoOrthographic,
|
|
12
|
+
geoPath,
|
|
13
|
+
geoTransverseMercator,
|
|
14
|
+
interpolateNumber,
|
|
15
|
+
transition,
|
|
16
|
+
} from 'd3';
|
|
17
|
+
import { type ControlPosition } from 'leaflet';
|
|
18
|
+
import { type Accessor, type JSX, type Setter, Show, createEffect, createMemo, createSignal } from 'solid-js';
|
|
19
|
+
import { type Topology } from 'topojson-specification';
|
|
20
|
+
|
|
21
|
+
import { GlobeContextProvider, type GlobeContextProviderProps, useGlobeContext } from '../../hooks';
|
|
22
|
+
import {
|
|
23
|
+
type Features,
|
|
24
|
+
type StyleSet,
|
|
25
|
+
createLayers,
|
|
26
|
+
geoToPosition,
|
|
27
|
+
positionToRotation,
|
|
28
|
+
renderLayers,
|
|
29
|
+
timer,
|
|
30
|
+
} from '../../util';
|
|
31
|
+
import { ActionControls, type ControlProps, ZoomControls, controlPositions } from '../Toolbar';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute
|
|
35
|
+
*/
|
|
36
|
+
const defaultStyles: Record<string, StyleSet> = {
|
|
37
|
+
light: {
|
|
38
|
+
background: {
|
|
39
|
+
fillStyle: '#EEE',
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
water: {
|
|
43
|
+
fillStyle: '#555',
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
land: {
|
|
47
|
+
fillStyle: '#999',
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
line: {
|
|
51
|
+
strokeStyle: 'darkred',
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
point: {
|
|
55
|
+
fillStyle: '#111111',
|
|
56
|
+
strokeStyle: '#111111',
|
|
57
|
+
strokeWidth: 1,
|
|
58
|
+
pointRadius: 0.5,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
dark: {
|
|
62
|
+
background: {
|
|
63
|
+
fillStyle: '#111111',
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
water: {
|
|
67
|
+
fillStyle: '#123E6A',
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
land: {
|
|
71
|
+
fillStyle: '#032153',
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
line: {
|
|
75
|
+
strokeStyle: '#111111',
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
point: {
|
|
79
|
+
fillStyle: '#111111',
|
|
80
|
+
strokeStyle: '#111111',
|
|
81
|
+
strokeWidth: 1,
|
|
82
|
+
pointRadius: 0.5,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type GlobeController = {
|
|
88
|
+
canvas: HTMLCanvasElement;
|
|
89
|
+
projection: GeoProjection;
|
|
90
|
+
zoom: number;
|
|
91
|
+
translation: Accessor<{ x: number; y: number } | undefined>;
|
|
92
|
+
rotation: Accessor<[number, number, number] | undefined>;
|
|
93
|
+
setZoom: (zoom: number | ((prev: number) => number)) => void;
|
|
94
|
+
setTranslation: Setter<{ x: number; y: number } | undefined>;
|
|
95
|
+
setRotation: (rotation: [number, number, number]) => void;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export type ProjectionType = 'orthographic' | 'mercator' | 'transverse-mercator';
|
|
99
|
+
|
|
100
|
+
const projectionMap: Record<ProjectionType, () => GeoProjection> = {
|
|
101
|
+
orthographic: geoOrthographic,
|
|
102
|
+
mercator: geoMercator,
|
|
103
|
+
'transverse-mercator': geoTransverseMercator,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const getProjection = (type: GlobeCanvasProps['projection'] = 'orthographic'): GeoProjection => {
|
|
107
|
+
if (typeof type === 'string') {
|
|
108
|
+
const constructor = projectionMap[type] ?? geoOrthographic;
|
|
109
|
+
return constructor();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return type ?? geoOrthographic();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
//
|
|
116
|
+
// Root
|
|
117
|
+
//
|
|
118
|
+
|
|
119
|
+
type GlobeRootProps = {
|
|
120
|
+
children: JSX.Element;
|
|
121
|
+
class?: string;
|
|
122
|
+
} & Partial<GlobeContextProviderProps> &
|
|
123
|
+
JSX.HTMLAttributes<HTMLDivElement>;
|
|
124
|
+
|
|
125
|
+
const GlobeRoot = (props: GlobeRootProps) => {
|
|
126
|
+
let containerRef: HTMLDivElement | undefined;
|
|
127
|
+
const [size, setSize] = createSignal({ width: 0, height: 0 });
|
|
128
|
+
|
|
129
|
+
// Use @solid-primitives/resize-observer
|
|
130
|
+
createResizeObserver(
|
|
131
|
+
() => containerRef,
|
|
132
|
+
({ width, height }) => {
|
|
133
|
+
setSize({ width, height });
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div ref={containerRef} class={`relative flex grow overflow-hidden ${props.class ?? ''}`} {...props}>
|
|
139
|
+
<GlobeContextProvider size={size()} {...props}>
|
|
140
|
+
{props.children}
|
|
141
|
+
</GlobeContextProvider>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
//
|
|
147
|
+
// Canvas
|
|
148
|
+
//
|
|
149
|
+
|
|
150
|
+
type GlobeCanvasProps = {
|
|
151
|
+
ref?: (controller: GlobeController) => void;
|
|
152
|
+
projection?: ProjectionType | GeoProjection;
|
|
153
|
+
topology?: Topology;
|
|
154
|
+
features?: Features;
|
|
155
|
+
styles?: StyleSet;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Basic globe renderer.
|
|
160
|
+
* https://github.com/topojson/world-atlas
|
|
161
|
+
*/
|
|
162
|
+
|
|
163
|
+
const GlobeCanvas = (props: GlobeCanvasProps) => {
|
|
164
|
+
const themeMode = 'dark'; // TODO: Get from theme context
|
|
165
|
+
const styles = createMemo(() => props.styles ?? defaultStyles[themeMode]);
|
|
166
|
+
|
|
167
|
+
// Canvas.
|
|
168
|
+
const [canvas, setCanvas] = createSignal<HTMLCanvasElement | null>(null);
|
|
169
|
+
|
|
170
|
+
// Projection.
|
|
171
|
+
const projection = createMemo(() => getProjection(props.projection));
|
|
172
|
+
|
|
173
|
+
// Layers.
|
|
174
|
+
const layers = createMemo(() => {
|
|
175
|
+
return timer(() => createLayers(props.topology as Topology, props.features, styles()));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// State.
|
|
179
|
+
const { size, center, zoom, translation, rotation, setCenter, setZoom, setTranslation, setRotation } =
|
|
180
|
+
useGlobeContext();
|
|
181
|
+
|
|
182
|
+
let zoomValue = zoom();
|
|
183
|
+
let zooming = false;
|
|
184
|
+
|
|
185
|
+
createEffect(() => {});
|
|
186
|
+
|
|
187
|
+
// Update rotation.
|
|
188
|
+
createEffect(() => {
|
|
189
|
+
const c = center();
|
|
190
|
+
if (c) {
|
|
191
|
+
setZoom(1);
|
|
192
|
+
setRotation(positionToRotation(geoToPosition(c)));
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// External controller.
|
|
197
|
+
createEffect(() => {
|
|
198
|
+
const canvasEl = canvas();
|
|
199
|
+
if (canvasEl && props.ref) {
|
|
200
|
+
const controller: GlobeController = {
|
|
201
|
+
canvas: canvasEl,
|
|
202
|
+
projection: projection(),
|
|
203
|
+
get zoom() {
|
|
204
|
+
return zoomValue;
|
|
205
|
+
},
|
|
206
|
+
translation,
|
|
207
|
+
rotation,
|
|
208
|
+
setZoom: (s) => {
|
|
209
|
+
if (typeof s === 'function') {
|
|
210
|
+
const is = interpolateNumber(zoomValue, s(zoomValue));
|
|
211
|
+
// Stop easing if already zooming.
|
|
212
|
+
transition()
|
|
213
|
+
.ease(zooming ? easeLinear : easeSinOut)
|
|
214
|
+
.duration(200)
|
|
215
|
+
.tween('scale', () => (t) => {
|
|
216
|
+
const newZoom = is(t);
|
|
217
|
+
zoomValue = newZoom;
|
|
218
|
+
setZoom(newZoom);
|
|
219
|
+
})
|
|
220
|
+
.on('end', () => {
|
|
221
|
+
zooming = false;
|
|
222
|
+
});
|
|
223
|
+
} else {
|
|
224
|
+
zoomValue = s;
|
|
225
|
+
setZoom(s);
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
setTranslation,
|
|
229
|
+
setRotation,
|
|
230
|
+
};
|
|
231
|
+
props.ref(controller);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// https://d3js.org/d3-geo/path#geoPath
|
|
236
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/getContext
|
|
237
|
+
const generator = createMemo(() => {
|
|
238
|
+
const canvasEl = canvas();
|
|
239
|
+
const proj = projection();
|
|
240
|
+
return canvasEl && proj && geoPath(proj, canvasEl.getContext('2d', { alpha: false }));
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Render on change.
|
|
244
|
+
createEffect(() => {
|
|
245
|
+
const canvasEl = canvas();
|
|
246
|
+
const proj = projection();
|
|
247
|
+
const gen = generator();
|
|
248
|
+
const z = zoom();
|
|
249
|
+
const t = translation();
|
|
250
|
+
const r = rotation();
|
|
251
|
+
const s = size();
|
|
252
|
+
|
|
253
|
+
if (canvasEl && proj && gen) {
|
|
254
|
+
timer(() => {
|
|
255
|
+
// https://d3js.org/d3-geo/projection
|
|
256
|
+
proj
|
|
257
|
+
.scale((Math.min(s.width, s.height) / 2) * z)
|
|
258
|
+
.translate([s.width / 2 + (t?.x ?? 0), s.height / 2 + (t?.y ?? 0)])
|
|
259
|
+
.rotate(r ?? [0, 0, 0]);
|
|
260
|
+
|
|
261
|
+
renderLayers(gen, layers(), z, styles());
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<Show when={size().width > 0 && size().height > 0}>
|
|
268
|
+
<canvas ref={setCanvas} width={size().width} height={size().height} />
|
|
269
|
+
</Show>
|
|
270
|
+
);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
//
|
|
274
|
+
// Debug
|
|
275
|
+
//
|
|
276
|
+
|
|
277
|
+
const GlobeDebug = (props: { position?: ControlPosition }) => {
|
|
278
|
+
const { size, zoom, translation, rotation } = useGlobeContext();
|
|
279
|
+
return (
|
|
280
|
+
<div
|
|
281
|
+
class={`z-10 absolute w-96 p-2 overflow-hidden border border-green-700 rounded ${
|
|
282
|
+
controlPositions[props.position ?? 'topleft']
|
|
283
|
+
}`}
|
|
284
|
+
>
|
|
285
|
+
<pre class='font-mono text-xs text-green-700'>
|
|
286
|
+
{JSON.stringify({ size, zoom: zoom(), translation: translation(), rotation: rotation() }, null, 2)}
|
|
287
|
+
</pre>
|
|
288
|
+
</div>
|
|
289
|
+
);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
//
|
|
293
|
+
// Panel
|
|
294
|
+
//
|
|
295
|
+
|
|
296
|
+
const GlobePanel = (props: { children: JSX.Element; position?: ControlPosition; class?: string }) => {
|
|
297
|
+
return (
|
|
298
|
+
<div class={`z-10 absolute overflow-hidden ${controlPositions[props.position ?? 'topleft']} ${props.class ?? ''}`}>
|
|
299
|
+
{props.children}
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
//
|
|
305
|
+
// Controls
|
|
306
|
+
//
|
|
307
|
+
|
|
308
|
+
const CustomControl = (props: { children: JSX.Element; position: ControlPosition }) => {
|
|
309
|
+
return <div class={`z-10 absolute overflow-hidden ${controlPositions[props.position]}`}>{props.children}</div>;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
type GlobeControlProps = { position?: ControlPosition } & Pick<ControlProps, 'onAction'>;
|
|
313
|
+
|
|
314
|
+
const GlobeZoom = (props: GlobeControlProps) => (
|
|
315
|
+
<CustomControl position={props.position ?? 'bottomleft'}>
|
|
316
|
+
<ZoomControls onAction={props.onAction} />
|
|
317
|
+
</CustomControl>
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const GlobeAction = (props: GlobeControlProps) => (
|
|
321
|
+
<CustomControl position={props.position ?? 'bottomright'}>
|
|
322
|
+
<ActionControls onAction={props.onAction} />
|
|
323
|
+
</CustomControl>
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
//
|
|
327
|
+
// Globe
|
|
328
|
+
//
|
|
329
|
+
|
|
330
|
+
export const Globe = {
|
|
331
|
+
Root: GlobeRoot,
|
|
332
|
+
Canvas: GlobeCanvas,
|
|
333
|
+
Zoom: GlobeZoom,
|
|
334
|
+
Action: GlobeAction,
|
|
335
|
+
Debug: GlobeDebug,
|
|
336
|
+
Panel: GlobePanel,
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
export type { GlobeRootProps, GlobeCanvasProps };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { createSignal } from 'solid-js';
|
|
6
|
+
import { type Meta, type StoryObj } from 'storybook-solidjs-vite';
|
|
7
|
+
|
|
8
|
+
import { useMapZoomHandler } from '../../hooks';
|
|
9
|
+
import { type GeoMarker } from '../../types';
|
|
10
|
+
|
|
11
|
+
import { Map, type MapController } from './Map';
|
|
12
|
+
|
|
13
|
+
const DefaultStory = ({ markers = [] }: { markers?: GeoMarker[] }) => {
|
|
14
|
+
const [controller, setController] = createSignal<MapController>();
|
|
15
|
+
const handleZoomAction = useMapZoomHandler(controller());
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<Map.Root ref={setController}>
|
|
19
|
+
<Map.Tiles />
|
|
20
|
+
<Map.Markers markers={() => markers} />
|
|
21
|
+
<Map.Zoom position='bottomleft' onAction={handleZoomAction} />
|
|
22
|
+
<Map.Action position='bottomright' />
|
|
23
|
+
</Map.Root>
|
|
24
|
+
);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const meta = {
|
|
28
|
+
title: 'ui/solid-ui-geo/Map',
|
|
29
|
+
component: Map.Root as any,
|
|
30
|
+
render: DefaultStory,
|
|
31
|
+
parameters: {
|
|
32
|
+
layout: 'fullscreen',
|
|
33
|
+
},
|
|
34
|
+
} satisfies Meta<typeof DefaultStory>;
|
|
35
|
+
|
|
36
|
+
export default meta;
|
|
37
|
+
|
|
38
|
+
type Story = StoryObj<typeof meta>;
|
|
39
|
+
|
|
40
|
+
export const Default: Story = {};
|
|
41
|
+
|
|
42
|
+
export const WithMarkers: Story = {
|
|
43
|
+
args: {
|
|
44
|
+
markers: [
|
|
45
|
+
{ id: 'los angeles', title: 'Los Angeles', location: { lat: 34.0522, lng: -118.2437 } },
|
|
46
|
+
{ id: 'new york', title: 'New York', location: { lat: 40.7128, lng: -74.006 } },
|
|
47
|
+
{ id: 'warsaw', title: 'Warsaw', location: { lat: 52.2297, lng: 21.0122 } },
|
|
48
|
+
{ id: 'london', title: 'London', location: { lat: 51.5074, lng: -0.1278 } },
|
|
49
|
+
{ id: 'toronto', title: 'Toronto', location: { lat: 43.6532, lng: -79.3832 } },
|
|
50
|
+
{ id: 'seattle', title: 'Seattle', location: { lat: 47.6062, lng: -122.3321 } },
|
|
51
|
+
{ id: 'barcelona', title: 'Barcelona', location: { lat: 41.3851, lng: 2.1734 } },
|
|
52
|
+
{ id: 'tokyo', title: 'Tokyo', location: { lat: 35.6762, lng: 139.6503 } },
|
|
53
|
+
{ id: 'sydney', title: 'Sydney', location: { lat: -33.8688, lng: 151.2093 } },
|
|
54
|
+
{ id: 'auckland', title: 'Auckland', location: { lat: -36.8509, lng: 174.7645 } },
|
|
55
|
+
{ id: 'new-delhi', title: 'New Delhi', location: { lat: 28.6139, lng: 77.209 } },
|
|
56
|
+
{ id: 'manila', title: 'Manila', location: { lat: 14.5995, lng: 120.9842 } },
|
|
57
|
+
{ id: 'beijing', title: 'Beijing', location: { lat: 39.9042, lng: 116.4074 } },
|
|
58
|
+
{ id: 'seoul', title: 'Seoul', location: { lat: 37.5665, lng: 126.978 } },
|
|
59
|
+
{ id: 'bangkok', title: 'Bangkok', location: { lat: 13.7563, lng: 100.5018 } },
|
|
60
|
+
{ id: 'singapore', title: 'Singapore', location: { lat: 1.3521, lng: 103.8198 } },
|
|
61
|
+
{ id: 'kuala-lumpur', title: 'Kuala Lumpur', location: { lat: 3.139, lng: 101.6869 } },
|
|
62
|
+
{ id: 'jakarta', title: 'Jakarta', location: { lat: -6.2088, lng: 106.8456 } },
|
|
63
|
+
{ id: 'hanoi', title: 'Hanoi', location: { lat: 21.0285, lng: 105.8542 } },
|
|
64
|
+
{ id: 'phnom-penh', title: 'Phnom Penh', location: { lat: 11.5564, lng: 104.9282 } },
|
|
65
|
+
{ id: 'vientiane', title: 'Vientiane', location: { lat: 17.9757, lng: 102.6331 } },
|
|
66
|
+
{ id: 'yangon', title: 'Yangon', location: { lat: 16.8661, lng: 96.1951 } },
|
|
67
|
+
] as GeoMarker[],
|
|
68
|
+
},
|
|
69
|
+
};
|