@fusion.dev/helix-map 0.106.0 → 0.108.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/README.md +2 -2
- package/dist/connect-map/connect-map.d.ts +6 -0
- package/dist/connect-map/connect-map.js +23 -0
- package/dist/leaflet/leaflet-driver.d.ts +4 -0
- package/dist/leaflet/leaflet-driver.js +27 -0
- package/dist/maplibre/maplibre-driver.d.ts +4 -0
- package/dist/maplibre/maplibre-driver.js +12 -0
- package/dist/snapshot/camera.d.ts +43 -0
- package/dist/snapshot/camera.js +41 -0
- package/dist/snapshot/index.d.ts +4 -0
- package/dist/snapshot/index.js +2 -0
- package/dist/snapshot/snapshot.d.ts +74 -0
- package/dist/snapshot/snapshot.js +289 -0
- package/package.json +11 -5
package/README.md
CHANGED
|
@@ -9,8 +9,8 @@ bun add @fusion.dev/helix @fusion.dev/helix-map maplibre-gl
|
|
|
9
9
|
|
|
10
10
|
**The guide is [Maps](https://fusion.dev/helix/docs/maps)**: connecting a
|
|
11
11
|
renderer, the style, markers, clusters, routes, a live position, points, layers
|
|
12
|
-
such as 3D buildings, terrain, and weather radar,
|
|
13
|
-
driver. The elements themselves are on the
|
|
12
|
+
such as 3D buildings, terrain, and weather radar, a snapshot drawn on the server
|
|
13
|
+
for the first paint, the keyboard, and writing a driver. The elements themselves are on the
|
|
14
14
|
[Map reference](https://fusion.dev/helix/components/map).
|
|
15
15
|
|
|
16
16
|
```js
|
|
@@ -32,6 +32,12 @@ export interface MapDriver {
|
|
|
32
32
|
read(): string[];
|
|
33
33
|
stop(): void;
|
|
34
34
|
};
|
|
35
|
+
/**
|
|
36
|
+
* Calls `done` once the renderer has drawn its view with its tiles, and
|
|
37
|
+
* returns what stops the waiting. What takes a snapshot drawn on the server
|
|
38
|
+
* away; without it, the snapshot stays under the renderer's drawing.
|
|
39
|
+
*/
|
|
40
|
+
ready?(done: () => void): () => void;
|
|
35
41
|
/**
|
|
36
42
|
* The element the renderer made focusable, which the connector takes out of
|
|
37
43
|
* the tab order while connected so the frame's region is the one stop.
|
|
@@ -617,6 +617,28 @@ export const connectMap = (frame, driver, options = {}) => {
|
|
|
617
617
|
}
|
|
618
618
|
};
|
|
619
619
|
const stopViewing = driver.onView?.(place);
|
|
620
|
+
const away = (snapshot) => {
|
|
621
|
+
snapshot.hidden = true;
|
|
622
|
+
snapshot.style.display = 'none';
|
|
623
|
+
};
|
|
624
|
+
const snapshots = [...frame.querySelectorAll(':scope > [data-helix-map-snapshot]')];
|
|
625
|
+
const stopWaiting = snapshots.length > 0
|
|
626
|
+
? driver.ready?.(() => {
|
|
627
|
+
const motion = motionOf(frame, 'step');
|
|
628
|
+
for (const snapshot of snapshots) {
|
|
629
|
+
if (!motion || typeof snapshot.animate !== 'function') {
|
|
630
|
+
away(snapshot);
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
snapshot
|
|
634
|
+
.animate([{ opacity: 1 }, { opacity: 0 }], {
|
|
635
|
+
duration: motion.duration,
|
|
636
|
+
easing: `linear(${Array.from({ length: 13 }, (_, step) => motion.easing(step / 12).toFixed(3)).join(', ')})`,
|
|
637
|
+
})
|
|
638
|
+
.finished.then(() => away(snapshot), () => away(snapshot));
|
|
639
|
+
}
|
|
640
|
+
})
|
|
641
|
+
: undefined;
|
|
620
642
|
let live = true;
|
|
621
643
|
let placing = false;
|
|
622
644
|
let drawing = false;
|
|
@@ -744,6 +766,7 @@ export const connectMap = (frame, driver, options = {}) => {
|
|
|
744
766
|
live = false;
|
|
745
767
|
drawn?.stop();
|
|
746
768
|
stopViewing?.();
|
|
769
|
+
stopWaiting?.();
|
|
747
770
|
stopTheming?.();
|
|
748
771
|
styled.disconnect();
|
|
749
772
|
children.disconnect();
|
|
@@ -8,6 +8,10 @@ interface LeafletAnimation {
|
|
|
8
8
|
export interface LeafletLayer {
|
|
9
9
|
/** The layer's credit, where it has one. */
|
|
10
10
|
getAttribution?: (() => string | null | undefined) | undefined;
|
|
11
|
+
/** Whether a tile layer still has tiles on the way. */
|
|
12
|
+
isLoading?: (() => boolean) | undefined;
|
|
13
|
+
off?: ((type: 'load', listener: () => void) => unknown) | undefined;
|
|
14
|
+
once?: ((type: 'load', listener: () => void) => unknown) | undefined;
|
|
11
15
|
}
|
|
12
16
|
/** The credit control Leaflet adds unless told not to. */
|
|
13
17
|
export interface LeafletAttribution {
|
|
@@ -44,6 +44,33 @@ export const leafletDriver = (map, shapes) => ({
|
|
|
44
44
|
map.off('move zoom resize', moved);
|
|
45
45
|
};
|
|
46
46
|
},
|
|
47
|
+
ready: (done) => {
|
|
48
|
+
const loading = [];
|
|
49
|
+
map.eachLayer((layer) => {
|
|
50
|
+
if (layer.isLoading?.()) {
|
|
51
|
+
loading.push(layer);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
if (loading.length === 0) {
|
|
55
|
+
done();
|
|
56
|
+
return () => undefined;
|
|
57
|
+
}
|
|
58
|
+
let left = loading.length;
|
|
59
|
+
const heard = () => {
|
|
60
|
+
left -= 1;
|
|
61
|
+
if (left === 0) {
|
|
62
|
+
done();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
for (const layer of loading) {
|
|
66
|
+
layer.once?.('load', heard);
|
|
67
|
+
}
|
|
68
|
+
return () => {
|
|
69
|
+
for (const layer of loading) {
|
|
70
|
+
layer.off?.('load', heard);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
},
|
|
47
74
|
...(shapes
|
|
48
75
|
? {
|
|
49
76
|
layers: (read, events) => {
|
|
@@ -95,6 +95,10 @@ interface MapLibreAnimation {
|
|
|
95
95
|
* renderer and a page brings whichever version it has.
|
|
96
96
|
*/
|
|
97
97
|
export interface MapLibreMap {
|
|
98
|
+
/** Whether every tile the view needs has loaded. */
|
|
99
|
+
areTilesLoaded?(): boolean;
|
|
100
|
+
/** Whether the style has loaded. */
|
|
101
|
+
loaded?(): boolean;
|
|
98
102
|
/** The canvas, which MapLibre makes focusable. */
|
|
99
103
|
getCanvas(): HTMLElement;
|
|
100
104
|
/** The middle of the view, which a map-type picture is drawn around. */
|
|
@@ -1016,6 +1016,18 @@ export const mapLibreDriver = (map) => ({
|
|
|
1016
1016
|
const { x, y } = map.project([lng, lat]);
|
|
1017
1017
|
return { x, y };
|
|
1018
1018
|
},
|
|
1019
|
+
ready: (done) => {
|
|
1020
|
+
if (map.loaded?.() && map.areTilesLoaded?.()) {
|
|
1021
|
+
done();
|
|
1022
|
+
return () => undefined;
|
|
1023
|
+
}
|
|
1024
|
+
const heard = () => {
|
|
1025
|
+
map.off('load', heard);
|
|
1026
|
+
done();
|
|
1027
|
+
};
|
|
1028
|
+
map.on('load', heard);
|
|
1029
|
+
return () => map.off('load', heard);
|
|
1030
|
+
},
|
|
1019
1031
|
rotate: ({ by, to }, motion) => {
|
|
1020
1032
|
const { bearing, pitch } = aimFrom(map);
|
|
1021
1033
|
turn(map, to ?? bearing + (by ?? 0), pitch, motion);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { MapPlace } from '../connect-map/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Where a map is looking and how big it is drawn: what MapLibre is given as
|
|
4
|
+
* `center`, `zoom`, `pitch` and `bearing`, and the box it draws in.
|
|
5
|
+
*/
|
|
6
|
+
export interface MapView {
|
|
7
|
+
/** Degrees clockwise from north the map is turned. 0 by default. */
|
|
8
|
+
bearing?: number | undefined;
|
|
9
|
+
center: MapPlace;
|
|
10
|
+
/** The box's height in CSS pixels. */
|
|
11
|
+
height: number;
|
|
12
|
+
/** Degrees the map is tilted back from looking straight down. 0 by default. */
|
|
13
|
+
pitch?: number | undefined;
|
|
14
|
+
/** The box's width in CSS pixels. */
|
|
15
|
+
width: number;
|
|
16
|
+
zoom: number;
|
|
17
|
+
}
|
|
18
|
+
/** A point on the screen, in CSS pixels from the box's top left. */
|
|
19
|
+
export type ScreenPoint = [number, number];
|
|
20
|
+
/** Where a place falls on the world at a width of `size` pixels, in Web Mercator. */
|
|
21
|
+
export declare const worldOf: (place: MapPlace, size: number) => ScreenPoint;
|
|
22
|
+
/** A camera for a view: the world to the screen, the screen back to the ground, and the world's width. */
|
|
23
|
+
export interface MapCamera {
|
|
24
|
+
/** A point on the world at `size` pixels wide, on the screen, or `undefined` where it is behind the camera. */
|
|
25
|
+
toScreen(world: ScreenPoint): ScreenPoint | undefined;
|
|
26
|
+
/** A point on the screen, where it meets the ground, on the world at `size` pixels wide. */
|
|
27
|
+
toWorld(screen: ScreenPoint): ScreenPoint;
|
|
28
|
+
/** How wide the world is at the view's zoom, in pixels. */
|
|
29
|
+
size: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* **MapLibre's camera, for drawing what it would draw.** The world is turned
|
|
33
|
+
* by the bearing about the view's center and tilted back by the pitch, then
|
|
34
|
+
* seen in perspective from where MapLibre stands its camera: half the box's
|
|
35
|
+
* height over the tangent of half the field of view. A tilted map is taller
|
|
36
|
+
* at its foot than at its head, and parallel streets lean toward the
|
|
37
|
+
* horizon, as they do in the renderer, so what is drawn from this lines up
|
|
38
|
+
* with what the renderer draws once it has loaded.
|
|
39
|
+
*
|
|
40
|
+
* The camera is centered, so a page that gives MapLibre `padding` moves the
|
|
41
|
+
* center it gives here to match.
|
|
42
|
+
*/
|
|
43
|
+
export declare const cameraOf: (view: MapView) => MapCamera;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const WORLD = 512;
|
|
2
|
+
const FIELD_OF_VIEW = 0.6435011087932844;
|
|
3
|
+
export const worldOf = (place, size) => {
|
|
4
|
+
const sine = Math.sin((place.lat * Math.PI) / 180);
|
|
5
|
+
return [
|
|
6
|
+
((place.lng + 180) / 360) * size,
|
|
7
|
+
(0.5 - Math.log((1 + sine) / (1 - sine)) / (4 * Math.PI)) * size,
|
|
8
|
+
];
|
|
9
|
+
};
|
|
10
|
+
export const cameraOf = (view) => {
|
|
11
|
+
const size = WORLD * 2 ** view.zoom;
|
|
12
|
+
const [cx, cy] = worldOf(view.center, size);
|
|
13
|
+
const turn = ((view.bearing ?? 0) * Math.PI) / 180;
|
|
14
|
+
const tilt = ((view.pitch ?? 0) * Math.PI) / 180;
|
|
15
|
+
const distance = (0.5 * view.height) / Math.tan(FIELD_OF_VIEW / 2);
|
|
16
|
+
return {
|
|
17
|
+
size,
|
|
18
|
+
toScreen: ([wx, wy]) => {
|
|
19
|
+
const across = (wx - cx) * Math.cos(turn) + (wy - cy) * Math.sin(turn);
|
|
20
|
+
const toward = -(wx - cx) * Math.sin(turn) + (wy - cy) * Math.cos(turn);
|
|
21
|
+
const depth = distance - toward * Math.sin(tilt);
|
|
22
|
+
if (depth < distance * 0.05) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
return [
|
|
26
|
+
view.width / 2 + (distance * across) / depth,
|
|
27
|
+
view.height / 2 + (distance * toward * Math.cos(tilt)) / depth,
|
|
28
|
+
];
|
|
29
|
+
},
|
|
30
|
+
toWorld: ([sx, sy]) => {
|
|
31
|
+
const x = sx - view.width / 2;
|
|
32
|
+
const y = sy - view.height / 2;
|
|
33
|
+
const toward = (y * distance) / (distance * Math.cos(tilt) + y * Math.sin(tilt));
|
|
34
|
+
const across = (x * (distance - toward * Math.sin(tilt))) / distance;
|
|
35
|
+
return [
|
|
36
|
+
cx + across * Math.cos(turn) - toward * Math.sin(turn),
|
|
37
|
+
cy + across * Math.sin(turn) + toward * Math.cos(turn),
|
|
38
|
+
];
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type { MapCamera, MapView, ScreenPoint } from './camera.js';
|
|
2
|
+
export { cameraOf, worldOf } from './camera.js';
|
|
3
|
+
export type { SnapshotLayer, SnapshotOptions, SnapshotToward } from './snapshot.js';
|
|
4
|
+
export { mapSnapshot, SNAPSHOT_LAYERS, widthAt } from './snapshot.js';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { type MapView } from './camera.js';
|
|
2
|
+
/** The token a snapshot layer's color is the land moved toward. */
|
|
3
|
+
export type SnapshotToward = 'foreground' | 'info' | 'primary' | 'success';
|
|
4
|
+
/**
|
|
5
|
+
* One layer of a snapshot: which of the tiles' layers it draws, which of its
|
|
6
|
+
* features, and in what. **The same as `mapLibreStyle`'s layer of the same
|
|
7
|
+
* `id`**, its color the land moved toward a token by the same amount and its
|
|
8
|
+
* width the same at every zoom, which the snapshot's spec holds it to, so the
|
|
9
|
+
* picture a page paints first is the map the renderer then draws.
|
|
10
|
+
*/
|
|
11
|
+
export interface SnapshotLayer {
|
|
12
|
+
/** Only features of these classes. */
|
|
13
|
+
classes?: readonly string[];
|
|
14
|
+
/** How far the land is moved toward `toward`, from 0 to 1. */
|
|
15
|
+
amount: number;
|
|
16
|
+
/** The feature classes left out. */
|
|
17
|
+
except?: readonly string[];
|
|
18
|
+
id: string;
|
|
19
|
+
kind: 'fill' | 'line';
|
|
20
|
+
/** The zoom it starts at, as the style's layer does. */
|
|
21
|
+
minzoom?: number;
|
|
22
|
+
/** Whether a feature in a tunnel is left out, as the style leaves it. */
|
|
23
|
+
overground?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* The smallest a shape is drawn, as the area of the box around it on the
|
|
26
|
+
* screen, in square pixels. A city's buildings are most of the weight of a
|
|
27
|
+
* street map and the least of what a first paint needs, and the renderer
|
|
28
|
+
* draws the small ones over it a moment later.
|
|
29
|
+
*/
|
|
30
|
+
smallest?: number;
|
|
31
|
+
/** The tiles' layer it reads, in the OpenMapTiles schema. */
|
|
32
|
+
source: string;
|
|
33
|
+
toward: SnapshotToward;
|
|
34
|
+
/** A line's width at two zooms, in pixels, widened between them as the style widens it. */
|
|
35
|
+
width?: readonly [readonly [number, number], readonly [number, number]];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* What a snapshot draws, bottom to top: the ground, the water, the buildings
|
|
39
|
+
* and the roads. The labels, the landmarks and the signs are left to the
|
|
40
|
+
* renderer, which places them where they do not collide; a picture that drew
|
|
41
|
+
* them would draw them where the renderer then would not.
|
|
42
|
+
*/
|
|
43
|
+
export declare const SNAPSHOT_LAYERS: readonly SnapshotLayer[];
|
|
44
|
+
/** A width between two zooms as MapLibre interpolates it: exponentially, by 1.5 a zoom. */
|
|
45
|
+
export declare const widthAt: ([[z0, w0], [z1, w1]]: readonly [readonly [number, number], readonly [number, number]], zoom: number) => number;
|
|
46
|
+
/** What a snapshot is drawn from. */
|
|
47
|
+
export interface SnapshotOptions {
|
|
48
|
+
/** The fetch the tiles are asked for with, where a server has its own, such as one that caches. */
|
|
49
|
+
fetch?: typeof fetch | undefined;
|
|
50
|
+
/** A TileJSON URL for vector tiles in the OpenMapTiles schema: the one given to `mapLibreStyle`. OpenFreeMap's by default. */
|
|
51
|
+
tiles?: string | undefined;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* **A map drawn on the server, for the page's first paint**: an SVG of the
|
|
55
|
+
* view a renderer is about to draw, from the same vector tiles and in the
|
|
56
|
+
* same colors, so the map is there before the renderer has loaded. Put it in
|
|
57
|
+
* the frame after the element the renderer draws in, since the renderer
|
|
58
|
+
* paints its land before its tiles arrive; `connectMap` fades it out once
|
|
59
|
+
* the renderer has drawn them. The frame's markers stand on it meanwhile.
|
|
60
|
+
*
|
|
61
|
+
* **Seen through the renderer's camera**, tilted and turned as the view
|
|
62
|
+
* says, so the streets it draws lie under the streets the renderer draws.
|
|
63
|
+
* It is drawn as a flat map, which is what a globe is at the zooms a page
|
|
64
|
+
* opens a street map at: a view zoomed out past about 6 is a globe in the
|
|
65
|
+
* renderer, and the picture would not match it.
|
|
66
|
+
*
|
|
67
|
+
* **Colored by the page's tokens**, each color written as the land moved
|
|
68
|
+
* toward a token in OKLab, so it is drawn in whichever theme the page is in,
|
|
69
|
+
* light or dark, and changes with it.
|
|
70
|
+
*
|
|
71
|
+
* Resolves to an empty string where the tiles cannot be read, which a page
|
|
72
|
+
* renders as nothing.
|
|
73
|
+
*/
|
|
74
|
+
export declare const mapSnapshot: (view: MapView, options?: SnapshotOptions) => Promise<string>;
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { VectorTile } from '@mapbox/vector-tile';
|
|
2
|
+
import Pbf from 'pbf';
|
|
3
|
+
import { cameraOf } from './camera.js';
|
|
4
|
+
export const SNAPSHOT_LAYERS = [
|
|
5
|
+
{
|
|
6
|
+
amount: 0.096,
|
|
7
|
+
classes: ['wood', 'grass', 'wetland'],
|
|
8
|
+
id: 'landcover',
|
|
9
|
+
kind: 'fill',
|
|
10
|
+
source: 'landcover',
|
|
11
|
+
toward: 'success',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
amount: 0.16,
|
|
15
|
+
except: ['historic'],
|
|
16
|
+
id: 'park',
|
|
17
|
+
kind: 'fill',
|
|
18
|
+
source: 'park',
|
|
19
|
+
toward: 'success',
|
|
20
|
+
},
|
|
21
|
+
{ amount: 0.3, id: 'water', kind: 'fill', overground: true, source: 'water', toward: 'info' },
|
|
22
|
+
{
|
|
23
|
+
amount: 0.3,
|
|
24
|
+
id: 'waterway',
|
|
25
|
+
kind: 'line',
|
|
26
|
+
overground: true,
|
|
27
|
+
source: 'waterway',
|
|
28
|
+
toward: 'info',
|
|
29
|
+
width: [
|
|
30
|
+
[8, 0.5],
|
|
31
|
+
[18, 6],
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
amount: 0.07,
|
|
36
|
+
id: 'building',
|
|
37
|
+
kind: 'fill',
|
|
38
|
+
minzoom: 13,
|
|
39
|
+
smallest: 400,
|
|
40
|
+
source: 'building',
|
|
41
|
+
toward: 'foreground',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
amount: 0.1,
|
|
45
|
+
classes: ['minor', 'service'],
|
|
46
|
+
id: 'road-minor',
|
|
47
|
+
kind: 'line',
|
|
48
|
+
minzoom: 12,
|
|
49
|
+
overground: true,
|
|
50
|
+
source: 'transportation',
|
|
51
|
+
toward: 'foreground',
|
|
52
|
+
width: [
|
|
53
|
+
[12, 0.5],
|
|
54
|
+
[18, 10],
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
amount: 0.16,
|
|
59
|
+
classes: ['secondary', 'tertiary'],
|
|
60
|
+
id: 'road-secondary',
|
|
61
|
+
kind: 'line',
|
|
62
|
+
minzoom: 10,
|
|
63
|
+
overground: true,
|
|
64
|
+
source: 'transportation',
|
|
65
|
+
toward: 'foreground',
|
|
66
|
+
width: [
|
|
67
|
+
[10, 0.5],
|
|
68
|
+
[18, 14],
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
amount: 0.4,
|
|
73
|
+
classes: ['primary', 'trunk'],
|
|
74
|
+
id: 'road-major',
|
|
75
|
+
kind: 'line',
|
|
76
|
+
minzoom: 8,
|
|
77
|
+
overground: true,
|
|
78
|
+
source: 'transportation',
|
|
79
|
+
toward: 'primary',
|
|
80
|
+
width: [
|
|
81
|
+
[8, 0.5],
|
|
82
|
+
[18, 16],
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
amount: 0.6,
|
|
87
|
+
classes: ['motorway'],
|
|
88
|
+
id: 'road-motorway',
|
|
89
|
+
kind: 'line',
|
|
90
|
+
minzoom: 5,
|
|
91
|
+
overground: true,
|
|
92
|
+
source: 'transportation',
|
|
93
|
+
toward: 'primary',
|
|
94
|
+
width: [
|
|
95
|
+
[5, 0.5],
|
|
96
|
+
[18, 18],
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
const TOKENS = {
|
|
101
|
+
foreground: '--helix-color-foreground',
|
|
102
|
+
info: '--helix-color-info',
|
|
103
|
+
primary: '--helix-color-primary',
|
|
104
|
+
success: '--helix-color-success',
|
|
105
|
+
surface: '--helix-color-surface',
|
|
106
|
+
};
|
|
107
|
+
const colorOf = (layer) => `color-mix(in oklab, var(${TOKENS.surface}), var(${TOKENS[layer.toward]}) ${+(layer.amount * 100).toFixed(2)}%)`;
|
|
108
|
+
export const widthAt = ([[z0, w0], [z1, w1]], zoom) => {
|
|
109
|
+
if (zoom <= z0) {
|
|
110
|
+
return w0;
|
|
111
|
+
}
|
|
112
|
+
if (zoom >= z1) {
|
|
113
|
+
return w1;
|
|
114
|
+
}
|
|
115
|
+
const t = (1.5 ** (zoom - z0) - 1) / (1.5 ** (z1 - z0) - 1);
|
|
116
|
+
return w0 + t * (w1 - w0);
|
|
117
|
+
};
|
|
118
|
+
const TOLERANCE = 0.8;
|
|
119
|
+
const SHORTEST = 12;
|
|
120
|
+
const simplified = (points) => {
|
|
121
|
+
if (points.length < 3) {
|
|
122
|
+
return points;
|
|
123
|
+
}
|
|
124
|
+
const kept = new Uint8Array(points.length);
|
|
125
|
+
kept[0] = 1;
|
|
126
|
+
kept[points.length - 1] = 1;
|
|
127
|
+
const stack = [[0, points.length - 1]];
|
|
128
|
+
while (stack.length > 0) {
|
|
129
|
+
const [from, to] = stack.pop();
|
|
130
|
+
const [ax, ay] = points[from];
|
|
131
|
+
const [bx, by] = points[to];
|
|
132
|
+
const length = Math.hypot(bx - ax, by - ay);
|
|
133
|
+
let farthest = -1;
|
|
134
|
+
let at = -1;
|
|
135
|
+
for (let index = from + 1; index < to; index++) {
|
|
136
|
+
const [px, py] = points[index];
|
|
137
|
+
const away = length === 0
|
|
138
|
+
? Math.hypot(px - ax, py - ay)
|
|
139
|
+
: Math.abs((bx - ax) * (ay - py) - (ax - px) * (by - ay)) / length;
|
|
140
|
+
if (away > farthest) {
|
|
141
|
+
farthest = away;
|
|
142
|
+
at = index;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (farthest > TOLERANCE) {
|
|
146
|
+
kept[at] = 1;
|
|
147
|
+
stack.push([from, at], [at, to]);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return points.filter((_, index) => kept[index] === 1);
|
|
151
|
+
};
|
|
152
|
+
const OPENFREEMAP = 'https://tiles.openfreemap.org/planet';
|
|
153
|
+
const MOST_TILES = 40;
|
|
154
|
+
const MARGIN = 16;
|
|
155
|
+
export const mapSnapshot = async (view, options = {}) => {
|
|
156
|
+
const get = options.fetch ?? fetch;
|
|
157
|
+
const tiles = await get(options.tiles ?? OPENFREEMAP)
|
|
158
|
+
.then((response) => (response.ok ? response.json() : undefined))
|
|
159
|
+
.catch(() => undefined);
|
|
160
|
+
const template = tiles?.tiles?.[0];
|
|
161
|
+
if (!template) {
|
|
162
|
+
return '';
|
|
163
|
+
}
|
|
164
|
+
const deepest = tiles.maxzoom ?? 14;
|
|
165
|
+
const camera = cameraOf(view);
|
|
166
|
+
const corners = [
|
|
167
|
+
[0, 0],
|
|
168
|
+
[view.width, 0],
|
|
169
|
+
[0, view.height],
|
|
170
|
+
[view.width, view.height],
|
|
171
|
+
];
|
|
172
|
+
const ground = corners.map((corner) => camera.toWorld(corner));
|
|
173
|
+
let depth = Math.max(0, Math.min(deepest, Math.floor(view.zoom)));
|
|
174
|
+
const covering = (at) => {
|
|
175
|
+
const span = camera.size / 2 ** at;
|
|
176
|
+
const xs = ground.map(([x]) => Math.floor(x / span));
|
|
177
|
+
const ys = ground.map(([, y]) => Math.floor(y / span));
|
|
178
|
+
const count = 2 ** at;
|
|
179
|
+
const wanted = [];
|
|
180
|
+
for (let x = Math.min(...xs); x <= Math.max(...xs); x++) {
|
|
181
|
+
for (let y = Math.max(0, Math.min(...ys)); y <= Math.min(count - 1, Math.max(...ys)); y++) {
|
|
182
|
+
wanted.push([((x % count) + count) % count, y]);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return wanted;
|
|
186
|
+
};
|
|
187
|
+
let wanted = covering(depth);
|
|
188
|
+
while (wanted.length > MOST_TILES && depth > 0) {
|
|
189
|
+
depth -= 1;
|
|
190
|
+
wanted = covering(depth);
|
|
191
|
+
}
|
|
192
|
+
const span = camera.size / 2 ** depth;
|
|
193
|
+
const drawn = new Map(SNAPSHOT_LAYERS.map((layer) => [layer.id, []]));
|
|
194
|
+
const shown = SNAPSHOT_LAYERS.filter((layer) => view.zoom >= (layer.minzoom ?? 0));
|
|
195
|
+
const pathOf = (rings, [tx, ty], extent, closed, smallest) => {
|
|
196
|
+
let path = '';
|
|
197
|
+
for (const ring of rings) {
|
|
198
|
+
const points = [];
|
|
199
|
+
for (const point of ring) {
|
|
200
|
+
const on = camera.toScreen([(tx + point.x / extent) * span, (ty + point.y / extent) * span]);
|
|
201
|
+
if (on) {
|
|
202
|
+
points.push(on);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (points.length < 2) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const xs = points.map(([x]) => x);
|
|
209
|
+
const ys = points.map(([, y]) => y);
|
|
210
|
+
if (Math.max(...xs) < -MARGIN ||
|
|
211
|
+
Math.min(...xs) > view.width + MARGIN ||
|
|
212
|
+
Math.max(...ys) < -MARGIN ||
|
|
213
|
+
Math.min(...ys) > view.height + MARGIN ||
|
|
214
|
+
(Math.max(...xs) - Math.min(...xs)) * (Math.max(...ys) - Math.min(...ys)) < smallest ||
|
|
215
|
+
(!closed &&
|
|
216
|
+
Math.hypot(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys)) <
|
|
217
|
+
SHORTEST)) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
let [lastX, lastY] = [0, 0];
|
|
221
|
+
simplified(points).forEach(([x, y], index) => {
|
|
222
|
+
const [px, py] = [Math.round(x), Math.round(y)];
|
|
223
|
+
if (index === 0) {
|
|
224
|
+
path += `M${px} ${py}`;
|
|
225
|
+
}
|
|
226
|
+
else if (px !== lastX || py !== lastY) {
|
|
227
|
+
path += `l${px - lastX} ${py - lastY}`;
|
|
228
|
+
}
|
|
229
|
+
;
|
|
230
|
+
[lastX, lastY] = [px, py];
|
|
231
|
+
});
|
|
232
|
+
if (closed) {
|
|
233
|
+
path += 'z';
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return path;
|
|
237
|
+
};
|
|
238
|
+
await Promise.all(wanted.map(async (at) => {
|
|
239
|
+
const url = template
|
|
240
|
+
.replace('{z}', String(depth))
|
|
241
|
+
.replace('{x}', String(at[0]))
|
|
242
|
+
.replace('{y}', String(at[1]));
|
|
243
|
+
const bytes = await get(url)
|
|
244
|
+
.then((response) => (response.ok ? response.arrayBuffer() : undefined))
|
|
245
|
+
.catch(() => undefined);
|
|
246
|
+
if (!bytes) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const tile = new VectorTile(new Pbf(new Uint8Array(bytes)));
|
|
250
|
+
for (const layer of shown) {
|
|
251
|
+
const source = tile.layers[layer.source];
|
|
252
|
+
if (!source) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
for (let index = 0; index < source.length; index++) {
|
|
256
|
+
const feature = source.feature(index);
|
|
257
|
+
const kind = String(feature.properties.class ?? '');
|
|
258
|
+
if (feature.type !== (layer.kind === 'fill' ? 3 : 2) ||
|
|
259
|
+
(layer.classes && !layer.classes.includes(kind)) ||
|
|
260
|
+
layer.except?.includes(kind) ||
|
|
261
|
+
(layer.overground && feature.properties.brunnel === 'tunnel')) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const path = pathOf(feature.loadGeometry(), at, source.extent, layer.kind === 'fill', layer.smallest ?? 0);
|
|
265
|
+
if (path) {
|
|
266
|
+
drawn.get(layer.id)?.push(path);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}));
|
|
271
|
+
const body = shown
|
|
272
|
+
.map((layer) => {
|
|
273
|
+
const paths = drawn.get(layer.id) ?? [];
|
|
274
|
+
if (paths.length === 0) {
|
|
275
|
+
return '';
|
|
276
|
+
}
|
|
277
|
+
const color = colorOf(layer);
|
|
278
|
+
return layer.kind === 'fill'
|
|
279
|
+
? `<path fill-rule="evenodd" style="fill:${color}" d="${paths.join('')}"/>`
|
|
280
|
+
: `<path style="fill:none;stroke:${color};stroke-width:${+widthAt(layer.width ?? [
|
|
281
|
+
[0, 1],
|
|
282
|
+
[1, 1],
|
|
283
|
+
], view.zoom).toFixed(2)}px;stroke-linecap:round;stroke-linejoin:round" d="${paths.join('')}"/>`;
|
|
284
|
+
})
|
|
285
|
+
.join('');
|
|
286
|
+
return (`<div aria-hidden="true" data-helix-map-snapshot style="inset:0;overflow:hidden;pointer-events:none;position:absolute">` +
|
|
287
|
+
`<svg height="${view.height}" viewBox="0 0 ${view.width} ${view.height}" width="${view.width}" xmlns="http://www.w3.org/2000/svg" style="background:var(${TOKENS.surface});left:50%;position:absolute;top:50%;translate:-50% -50%">` +
|
|
288
|
+
`${body}</svg></div>`);
|
|
289
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fusion.dev/helix-map",
|
|
3
3
|
"description": "Connects the Helix Map frame to a map renderer: MapLibre, Leaflet, or a driver of your own.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.108.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ross Sheppard <ross.sheppard@fusion.dev>",
|
|
7
7
|
"repository": {
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
"./maplibre": {
|
|
25
25
|
"types": "./dist/maplibre/index.d.ts",
|
|
26
26
|
"default": "./dist/maplibre/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./snapshot": {
|
|
29
|
+
"types": "./dist/snapshot/index.d.ts",
|
|
30
|
+
"default": "./dist/snapshot/index.js"
|
|
27
31
|
}
|
|
28
32
|
},
|
|
29
33
|
"files": [
|
|
@@ -39,12 +43,14 @@
|
|
|
39
43
|
},
|
|
40
44
|
"bugs": "https://github.com/project-fusion/fusion/issues",
|
|
41
45
|
"dependencies": {
|
|
42
|
-
"@fusion.dev/helix-core": "0.
|
|
43
|
-
"@fusion.dev/helix-icons": "0.
|
|
46
|
+
"@fusion.dev/helix-core": "0.108.0",
|
|
47
|
+
"@fusion.dev/helix-icons": "0.108.0",
|
|
48
|
+
"@mapbox/vector-tile": "^2.0.4",
|
|
49
|
+
"pbf": "^4.0.1"
|
|
44
50
|
},
|
|
45
51
|
"devDependencies": {
|
|
46
|
-
"@fusion.dev/helix-headless": "0.
|
|
47
|
-
"@fusion.dev/helix-testing": "0.
|
|
52
|
+
"@fusion.dev/helix-headless": "0.108.0",
|
|
53
|
+
"@fusion.dev/helix-testing": "0.108.0",
|
|
48
54
|
"@maplibre/maplibre-gl-style-spec": "^24.10.0",
|
|
49
55
|
"@types/bun": "^1.3.0",
|
|
50
56
|
"@types/leaflet": "^1.9.20",
|