@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/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@dxos/solid-ui-geo",
3
+ "version": "0.0.0",
4
+ "description": "Geo components for SolidJS.",
5
+ "homepage": "https://github.com/dxos",
6
+ "bugs": "https://github.com/dxos/issues",
7
+ "license": "MIT",
8
+ "author": "DXOS.org",
9
+ "sideEffects": true,
10
+ "type": "module",
11
+ "exports": {
12
+ "./data": {
13
+ "types": "./dist/types/src/data.d.ts",
14
+ "browser": "./dist/lib/browser/data.mjs",
15
+ "node": "./dist/lib/node-esm/data.mjs"
16
+ },
17
+ ".": {
18
+ "types": "./dist/types/src/index.d.ts",
19
+ "browser": "./dist/lib/browser/index.mjs",
20
+ "node": "./dist/lib/node-esm/index.mjs"
21
+ }
22
+ },
23
+ "types": "dist/types/src/index.d.ts",
24
+ "typesVersions": {
25
+ "*": {
26
+ "data": [
27
+ "dist/types/src/data.d.ts"
28
+ ]
29
+ }
30
+ },
31
+ "files": [
32
+ "data",
33
+ "dist",
34
+ "src"
35
+ ],
36
+ "dependencies": {
37
+ "@solid-primitives/resize-observer": "^2.0.26",
38
+ "d3": "^7.9.0",
39
+ "d3-geo-projection": "^4.0.0",
40
+ "d3-hexbin": "^0.2.2",
41
+ "geojson": "^0.5.0",
42
+ "leaflet": "^1.9.4",
43
+ "lodash.defaultsdeep": "^4.6.1",
44
+ "solid-element": "^1.9.1",
45
+ "topojson-client": "^3.1.0",
46
+ "topojson-simplify": "^3.0.3",
47
+ "versor": "^0.2.0",
48
+ "@dxos/async": "0.8.3",
49
+ "@dxos/node-std": "0.8.3",
50
+ "@dxos/log": "0.8.3",
51
+ "@dxos/debug": "0.8.3",
52
+ "@dxos/util": "0.8.3",
53
+ "@dxos/ui-theme": "0.0.0"
54
+ },
55
+ "devDependencies": {
56
+ "@solidjs/testing-library": "^0.8.10",
57
+ "@types/d3": "^7.4.3",
58
+ "@types/geojson": "^7946.0.14",
59
+ "@types/leaflet": "^1.9.16",
60
+ "@types/topojson-client": "^3.1.4",
61
+ "@types/topojson-simplify": "^3.0.3",
62
+ "@types/topojson-specification": "^1.0.5",
63
+ "JSONStream": "^1.3.5",
64
+ "geojson2h3": "^1.2.0",
65
+ "solid-js": "^1.9.9",
66
+ "vite-plugin-solid": "^2.11.10",
67
+ "@dxos/storybook-utils": "0.8.3"
68
+ },
69
+ "peerDependencies": {
70
+ "solid-js": "^1.9.9"
71
+ },
72
+ "publishConfig": {
73
+ "access": "public"
74
+ }
75
+ }
@@ -0,0 +1,397 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type FeatureCollection, type Geometry, type Position } from 'geojson';
6
+ import { createEffect, createMemo, createResource, createSignal } from 'solid-js';
7
+ import { type Meta, type StoryObj } from 'storybook-solidjs-vite';
8
+ import { type Topology } from 'topojson-specification';
9
+
10
+ import { type Vector, useDrag, useGlobeZoomHandler, useSpinner, useTour } from '../../hooks';
11
+ import { type LatLngLiteral } from '../../types';
12
+ import { type StyleSet, closestPoint } from '../../util';
13
+ import { type ControlProps } from '../Toolbar';
14
+
15
+ import { Globe, type GlobeCanvasProps, type GlobeController, type GlobeRootProps } from './Globe';
16
+
17
+ // TODO(burdon): Load from JSON at runtime?
18
+ const loadTopology = async () => (await import('../../../data/countries-110m')).default;
19
+
20
+ const defaultStyles: StyleSet = {
21
+ water: {
22
+ fillStyle: '#0a0a0a',
23
+ },
24
+
25
+ land: {
26
+ fillStyle: '#050505',
27
+ strokeStyle: 'darkgreen',
28
+ },
29
+
30
+ graticule: {
31
+ strokeStyle: '#111',
32
+ },
33
+
34
+ line: {
35
+ lineWidth: 1,
36
+ lineDash: [4, 16],
37
+ strokeStyle: 'yellow',
38
+ },
39
+
40
+ point: {
41
+ pointRadius: 2,
42
+ fillStyle: 'red',
43
+ },
44
+
45
+ cursor: {
46
+ fillStyle: 'orange',
47
+ pointRadius: 2,
48
+ },
49
+
50
+ arc: {
51
+ lineWidth: 2,
52
+ strokeStyle: 'yellow',
53
+ },
54
+ };
55
+
56
+ const dotStyles: StyleSet = {
57
+ dots: {
58
+ fillStyle: '#444',
59
+ pointRadius: 2,
60
+ },
61
+
62
+ point: {
63
+ pointRadius: 2,
64
+ fillStyle: 'red',
65
+ },
66
+
67
+ cursor: {
68
+ fillStyle: 'orange',
69
+ pointRadius: 2,
70
+ },
71
+
72
+ arc: {
73
+ lineWidth: 2,
74
+ strokeStyle: 'yellow',
75
+ },
76
+ };
77
+
78
+ const routes: Record<string, string[]> = {
79
+ LAX: ['SFO', 'SEA'],
80
+ JFK: ['LAX', 'YYZ', 'TPA', 'CXH'],
81
+ CDG: ['BHX', 'BCN', 'VIE', 'WAW', 'CPH', 'ATH', 'IST', 'TXL', 'KBP', 'TLL'],
82
+ DXB: ['IKA'],
83
+ SIN: ['HND', 'SYD', 'HKG', 'BKK'],
84
+ };
85
+
86
+ // TODO(burdon): Make hierarchical/tree.
87
+ const createTrip = (
88
+ airports: FeatureCollection<Geometry & { coordinates: Position }, { iata: string }>,
89
+ routes: Record<string, string[]>,
90
+ points: Position[] = [],
91
+ ) => {
92
+ let previousHub: LatLngLiteral;
93
+ return Object.entries(routes).reduce<{
94
+ points: LatLngLiteral[];
95
+ lines: { source: LatLngLiteral; target: LatLngLiteral }[];
96
+ }>(
97
+ (features, [hub, regional]) => {
98
+ const hubAirport = airports.features.find(({ properties }) => properties.iata === hub);
99
+ if (hubAirport) {
100
+ const [lng, lat] = closestPoint(points, hubAirport.geometry.coordinates);
101
+ const hubPoint = { lat, lng };
102
+ features.points.push(hubPoint);
103
+ if (previousHub) {
104
+ features.lines.push({ source: previousHub, target: hubPoint });
105
+ }
106
+
107
+ for (const dest of regional) {
108
+ const destAirport = airports.features.find(({ properties }) => properties.iata === dest);
109
+ if (destAirport) {
110
+ const [lng, lat] = closestPoint(points, destAirport.geometry.coordinates);
111
+ features.points.push({ lat, lng });
112
+ features.lines.push({ source: hubPoint, target: { lat, lng } });
113
+ }
114
+ }
115
+
116
+ previousHub = hubPoint;
117
+ }
118
+
119
+ return features;
120
+ },
121
+ { points: [], lines: [] },
122
+ );
123
+ };
124
+
125
+ type StoryProps = Pick<GlobeRootProps, 'zoom' | 'translation' | 'rotation'> &
126
+ Pick<GlobeCanvasProps, 'projection' | 'styles'> & {
127
+ drag?: boolean;
128
+ spin?: boolean;
129
+ tour?: boolean;
130
+ xAxis?: boolean;
131
+ };
132
+
133
+ const DefaultStory = ({
134
+ zoom: _zoom = 1,
135
+ translation,
136
+ rotation = [0, 0, 0],
137
+ projection,
138
+ styles = defaultStyles,
139
+ drag = false,
140
+ spin = false,
141
+ tour = false,
142
+ xAxis = false,
143
+ }: StoryProps) => {
144
+ const [controller, setController] = createSignal<GlobeController | null>(null);
145
+
146
+ const [dots] = createResource(async () => {
147
+ const points = (await import('../../../data/countries-dots-3')).default;
148
+ return {
149
+ type: 'Topology',
150
+ objects: { dots: points },
151
+ } as any as Topology;
152
+ });
153
+
154
+ const [topology] = createResource(loadTopology);
155
+ const [airports] = createResource(async () => (await import('../../../data/airports')).default);
156
+
157
+ const features = createMemo(() => {
158
+ return airports()
159
+ ? createTrip(airports()!, routes, (dots()?.objects.dots as any)?.geometries[0].coordinates)
160
+ : undefined;
161
+ });
162
+
163
+ // Control hooks - must be called unconditionally
164
+ let startSpinner: (() => void) | undefined;
165
+ let stopSpinner: (() => void) | undefined;
166
+ let setRunning: ((value: boolean) => void) | undefined;
167
+ let handleAction: ControlProps['onAction'] | undefined;
168
+
169
+ createEffect(() => {
170
+ const ctrl = controller();
171
+ if (ctrl) {
172
+ // Always call hooks, use disabled flag
173
+ const spinner = useSpinner(ctrl, { disabled: !spin });
174
+ startSpinner = spinner.start;
175
+ stopSpinner = spinner.stop;
176
+
177
+ const tourResult = useTour(ctrl, features()?.points, { disabled: !tour, styles });
178
+ setRunning = tourResult.setRunning;
179
+
180
+ useDrag(ctrl, {
181
+ xAxis,
182
+ disabled: !drag,
183
+ onUpdate: (event) => {
184
+ switch (event.type) {
185
+ case 'start': {
186
+ stopSpinner?.();
187
+ setRunning?.(false);
188
+ break;
189
+ }
190
+ }
191
+ },
192
+ });
193
+
194
+ handleAction = useGlobeZoomHandler(ctrl);
195
+ }
196
+ });
197
+
198
+ // TODO(burdon): Factor out handlers.
199
+ const onAction: ControlProps['onAction'] = (event) => {
200
+ switch (event) {
201
+ case 'start': {
202
+ if (spin) {
203
+ startSpinner?.();
204
+ }
205
+ if (tour) {
206
+ setRunning?.(true);
207
+ }
208
+ break;
209
+ }
210
+ default: {
211
+ handleAction?.(event);
212
+ break;
213
+ }
214
+ }
215
+ };
216
+
217
+ return (
218
+ <div style={{ position: 'fixed', inset: 0, display: 'flex' }}>
219
+ <Globe.Root zoom={_zoom} translation={translation} rotation={rotation}>
220
+ <Globe.Canvas
221
+ ref={setController}
222
+ topology={styles?.dots ? dots() : topology()}
223
+ projection={projection}
224
+ styles={styles}
225
+ features={tour ? { points: features()?.points ?? [] } : features()}
226
+ />
227
+ <Globe.Zoom onAction={onAction} />
228
+ <Globe.Action onAction={onAction} />
229
+ <Globe.Debug />
230
+ </Globe.Root>
231
+ </div>
232
+ );
233
+ };
234
+
235
+ const initialRotation: Vector = [0, -40, 0];
236
+
237
+ const meta = {
238
+ title: 'ui/solid-ui-geo/Globe',
239
+ component: Globe.Root,
240
+ render: DefaultStory,
241
+ parameters: {
242
+ layout: 'fullscreen',
243
+ },
244
+ } satisfies Meta;
245
+
246
+ export default meta;
247
+
248
+ export const Earth1 = () => {
249
+ const [topology] = createResource(loadTopology);
250
+ const [controller, setController] = createSignal<GlobeController | null>(null);
251
+
252
+ let handleAction: ((action: string) => void) | undefined;
253
+
254
+ createEffect(() => {
255
+ const ctrl = controller();
256
+ if (ctrl) {
257
+ handleAction = useGlobeZoomHandler(ctrl);
258
+ useDrag(ctrl);
259
+ }
260
+ });
261
+
262
+ return (
263
+ <Globe.Root zoom={1.2} rotation={[Math.random() * 360, 0, 0]}>
264
+ <Globe.Canvas ref={setController} topology={topology()} styles={defaultStyles} />
265
+ <Globe.Zoom onAction={(action) => handleAction?.(action)} />
266
+ </Globe.Root>
267
+ );
268
+ };
269
+
270
+ export const Earth2 = () => {
271
+ const [topology] = createResource(loadTopology);
272
+ const [controller, setController] = createSignal<GlobeController | null>(null);
273
+
274
+ let handleAction: ((action: string) => void) | undefined;
275
+
276
+ createEffect(() => {
277
+ const ctrl = controller();
278
+ if (ctrl) {
279
+ handleAction = useGlobeZoomHandler(ctrl);
280
+ useDrag(ctrl);
281
+ }
282
+ });
283
+
284
+ return (
285
+ <div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, height: '400px', display: 'flex' }}>
286
+ <Globe.Root style={{ width: '100%', height: '100%' }} zoom={2.8} translation={{ x: 0, y: 200 }}>
287
+ <Globe.Canvas ref={setController} topology={topology()} styles={defaultStyles} />
288
+ <Globe.Zoom onAction={(action) => handleAction?.(action)} />
289
+ </Globe.Root>
290
+ </div>
291
+ );
292
+ };
293
+
294
+ const monochrome: StyleSet = {
295
+ water: {
296
+ fillStyle: '#191919',
297
+ },
298
+
299
+ land: {
300
+ fillStyle: '#444',
301
+ strokeStyle: '#222',
302
+ },
303
+
304
+ border: {
305
+ strokeStyle: '#111',
306
+ },
307
+
308
+ graticule: {
309
+ strokeStyle: '#111',
310
+ },
311
+ };
312
+
313
+ export const Mercator = () => {
314
+ const [topology] = createResource(loadTopology);
315
+ const [controller, setController] = createSignal<GlobeController | null>(null);
316
+
317
+ let handleAction: ((action: string) => void) | undefined;
318
+
319
+ createEffect(() => {
320
+ const ctrl = controller();
321
+ if (ctrl) {
322
+ handleAction = useGlobeZoomHandler(ctrl);
323
+ useDrag(ctrl);
324
+ }
325
+ });
326
+
327
+ return (
328
+ <Globe.Root class='flex grow overflow-hidden' zoom={0.7} rotation={initialRotation}>
329
+ <Globe.Canvas ref={setController} topology={topology()} projection='mercator' styles={monochrome} />
330
+ <Globe.Zoom onAction={(action) => handleAction?.(action)} />
331
+ </Globe.Root>
332
+ );
333
+ };
334
+
335
+ type Story = StoryObj<typeof DefaultStory>;
336
+
337
+ export const Globe1: Story = {
338
+ args: {
339
+ drag: true,
340
+ projection: 'mercator',
341
+ zoom: 0.8,
342
+ rotation: initialRotation,
343
+ styles: defaultStyles,
344
+ },
345
+ };
346
+
347
+ export const Globe2: Story = {
348
+ args: {
349
+ drag: true,
350
+ projection: 'transverse-mercator',
351
+ zoom: 0.8,
352
+ rotation: initialRotation,
353
+ styles: defaultStyles,
354
+ },
355
+ };
356
+
357
+ export const Globe3: Story = {
358
+ args: {
359
+ drag: true,
360
+ spin: true,
361
+ zoom: 1.5,
362
+ rotation: initialRotation,
363
+ styles: defaultStyles,
364
+ },
365
+ };
366
+
367
+ export const Globe4: Story = {
368
+ args: {
369
+ drag: true,
370
+ tour: true,
371
+ zoom: 2,
372
+ rotation: initialRotation,
373
+ styles: defaultStyles,
374
+ },
375
+ };
376
+
377
+ export const Globe5: Story = {
378
+ args: {
379
+ drag: true,
380
+ tour: true,
381
+ zoom: 0.9,
382
+ rotation: initialRotation,
383
+ styles: dotStyles,
384
+ },
385
+ };
386
+
387
+ export const Globe6: Story = {
388
+ args: {
389
+ drag: true,
390
+ xAxis: true,
391
+ tour: true,
392
+ zoom: 2,
393
+ translation: { x: 0, y: 600 },
394
+ rotation: [0, -20, 0],
395
+ styles: dotStyles,
396
+ },
397
+ };