@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,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,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './Globe';
@@ -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
+ };