@fusion.dev/helix-map 0.113.0 → 0.115.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/dist/connect-map/caption.d.ts +23 -0
- package/dist/connect-map/caption.js +80 -0
- package/dist/connect-map/connect-map.d.ts +20 -3
- package/dist/connect-map/connect-map.js +47 -18
- package/dist/leaflet/leaflet-driver.d.ts +16 -0
- package/dist/leaflet/leaflet-driver.js +33 -2
- package/dist/maplibre/maplibre-driver.d.ts +22 -2
- package/dist/maplibre/maplibre-driver.js +109 -20
- package/dist/style/map-style.js +2 -22
- package/dist/style/oklab.d.ts +10 -0
- package/dist/style/oklab.js +21 -0
- package/package.json +5 -5
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { MapPlace } from './connect-map.js';
|
|
2
|
+
/** Where an area's caption stands, and which part of it stands there. */
|
|
3
|
+
export interface CaptionSpot {
|
|
4
|
+
/** `center` on the spot, or `bottom`, the caption above the spot. */
|
|
5
|
+
anchor: 'bottom' | 'center';
|
|
6
|
+
at: MapPlace;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Where an area's caption stands.
|
|
10
|
+
*
|
|
11
|
+
* **Inside, at the point furthest from every edge**, where the most room is:
|
|
12
|
+
* the middle of the corners lies outside an L-shaped or a crescent area, and
|
|
13
|
+
* its caption read as the next area's. Found by laying a grid over the area,
|
|
14
|
+
* keeping the cell with the most room round it, and laying a finer grid over
|
|
15
|
+
* that, which is close enough for a caption and costs a few hundred
|
|
16
|
+
* measurements an area. Measured flat, the longitude narrowed by the
|
|
17
|
+
* latitude, so an area far from the equator is not judged wider than it is.
|
|
18
|
+
*
|
|
19
|
+
* **Outside, above its northernmost point**, clear of the shape, for an area
|
|
20
|
+
* whose caption would hide what is in it: the caption stands on the spot and
|
|
21
|
+
* reads above the edge.
|
|
22
|
+
*/
|
|
23
|
+
export declare const captionAt: (points: readonly MapPlace[], placement: 'inside' | 'outside') => CaptionSpot;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const CELLS = 16;
|
|
2
|
+
const ROUNDS = 3;
|
|
3
|
+
const inside = ({ x, y }, ring) => {
|
|
4
|
+
let crossed = false;
|
|
5
|
+
for (let at = 0, before = ring.length - 1; at < ring.length; before = at++) {
|
|
6
|
+
const a = ring[at];
|
|
7
|
+
const b = ring[before];
|
|
8
|
+
if (a.y > y !== b.y > y && x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x) {
|
|
9
|
+
crossed = !crossed;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return crossed;
|
|
13
|
+
};
|
|
14
|
+
const clearance = (point, ring) => {
|
|
15
|
+
let least = Number.POSITIVE_INFINITY;
|
|
16
|
+
for (let at = 0, before = ring.length - 1; at < ring.length; before = at++) {
|
|
17
|
+
const a = ring[before];
|
|
18
|
+
const b = ring[at];
|
|
19
|
+
const dx = b.x - a.x;
|
|
20
|
+
const dy = b.y - a.y;
|
|
21
|
+
const length = dx * dx + dy * dy;
|
|
22
|
+
const along = length === 0
|
|
23
|
+
? 0
|
|
24
|
+
: Math.min(Math.max(((point.x - a.x) * dx + (point.y - a.y) * dy) / length, 0), 1);
|
|
25
|
+
least = Math.min(least, Math.hypot(point.x - (a.x + along * dx), point.y - (a.y + along * dy)));
|
|
26
|
+
}
|
|
27
|
+
return least;
|
|
28
|
+
};
|
|
29
|
+
export const captionAt = (points, placement) => {
|
|
30
|
+
if (placement === 'outside') {
|
|
31
|
+
const north = Math.max(...points.map(({ lat }) => lat));
|
|
32
|
+
const top = points.filter(({ lat }) => north - lat < 1e-9);
|
|
33
|
+
const lng = top.reduce((sum, one) => sum + one.lng, 0) / top.length;
|
|
34
|
+
return { anchor: 'bottom', at: { lat: north, lng } };
|
|
35
|
+
}
|
|
36
|
+
const middle = points.reduce((sum, one) => sum + one.lat, 0) / points.length;
|
|
37
|
+
const narrow = Math.cos((middle * Math.PI) / 180) || 1;
|
|
38
|
+
const ring = points.map(({ lat, lng }) => ({ x: lng * narrow, y: lat }));
|
|
39
|
+
let west = Math.min(...ring.map(({ x }) => x));
|
|
40
|
+
let east = Math.max(...ring.map(({ x }) => x));
|
|
41
|
+
let south = Math.min(...ring.map(({ y }) => y));
|
|
42
|
+
let north = Math.max(...ring.map(({ y }) => y));
|
|
43
|
+
const center = { x: (west + east) / 2, y: (south + north) / 2 };
|
|
44
|
+
const offCenter = ({ x, y }) => Math.hypot(x - center.x, y - center.y);
|
|
45
|
+
const TIE = 0.02;
|
|
46
|
+
let best;
|
|
47
|
+
for (let round = 0; round < ROUNDS; round++) {
|
|
48
|
+
const width = (east - west) / CELLS;
|
|
49
|
+
const height = (north - south) / CELLS;
|
|
50
|
+
for (let column = 0; column < CELLS; column++) {
|
|
51
|
+
for (let row = 0; row < CELLS; row++) {
|
|
52
|
+
const point = { x: west + (column + 0.5) * width, y: south + (row + 0.5) * height };
|
|
53
|
+
if (!inside(point, ring)) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const room = clearance(point, ring);
|
|
57
|
+
const better = !best ||
|
|
58
|
+
room > best.room * (1 + TIE) ||
|
|
59
|
+
(room >= best.room * (1 - TIE) && offCenter(point) < offCenter(best.point));
|
|
60
|
+
if (better) {
|
|
61
|
+
best = { point, room };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!best) {
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
west = best.point.x - width;
|
|
69
|
+
east = best.point.x + width;
|
|
70
|
+
south = best.point.y - height;
|
|
71
|
+
north = best.point.y + height;
|
|
72
|
+
}
|
|
73
|
+
const at = best
|
|
74
|
+
? { lat: best.point.y, lng: best.point.x / narrow }
|
|
75
|
+
: {
|
|
76
|
+
lat: middle,
|
|
77
|
+
lng: points.reduce((sum, one) => sum + one.lng, 0) / points.length,
|
|
78
|
+
};
|
|
79
|
+
return { anchor: 'center', at };
|
|
80
|
+
};
|
|
@@ -33,9 +33,13 @@ export interface MapDriver {
|
|
|
33
33
|
stop(): void;
|
|
34
34
|
};
|
|
35
35
|
/**
|
|
36
|
-
* Calls `done` once the renderer has drawn its view with
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* Calls `done` once the renderer has drawn its view whole, with every tile
|
|
37
|
+
* it needs and the ground where there is terrain, and returns what stops the
|
|
38
|
+
* waiting. Leave the frame's own shapes out, since a route that animates is
|
|
39
|
+
* redrawn every frame and would never count as drawn. What takes a snapshot
|
|
40
|
+
* drawn on the server away and sends the frame's `helix-map-ready`; without
|
|
41
|
+
* it, the snapshot stays under the renderer's drawing and the frame is never
|
|
42
|
+
* `:state(ready)`.
|
|
39
43
|
*/
|
|
40
44
|
ready?(done: () => void): () => void;
|
|
41
45
|
/**
|
|
@@ -188,6 +192,19 @@ export type MapPadding = number | {
|
|
|
188
192
|
export interface MapLayerSpec {
|
|
189
193
|
/** A raster layer's credit for its tiles, which the frame shows with the map's own. */
|
|
190
194
|
attribution?: string | undefined;
|
|
195
|
+
/**
|
|
196
|
+
* An area's caption: its words, the place they stand on and which part of
|
|
197
|
+
* them stands there, `center` or `bottom` for words above the place, and
|
|
198
|
+
* the colors to draw them in, an `ink` that reads on the map and a `halo`
|
|
199
|
+
* of the land round it. Drawn above the place names, and never pressed.
|
|
200
|
+
*/
|
|
201
|
+
caption?: {
|
|
202
|
+
anchor: 'bottom' | 'center';
|
|
203
|
+
at: MapPlace;
|
|
204
|
+
halo: string;
|
|
205
|
+
ink: string;
|
|
206
|
+
text: string;
|
|
207
|
+
} | undefined;
|
|
191
208
|
/**
|
|
192
209
|
* A route's edge, drawn wider under it so it reads as laid over the roads
|
|
193
210
|
* rather than as one of them, where the roads are drawn in the same brand;
|
|
@@ -2,8 +2,10 @@ import { HelixActivateEvent, HelixMapClusterEvent, HelixMapPanEvent, HelixMapPar
|
|
|
2
2
|
import { creditParts } from '../credit/credit.js';
|
|
3
3
|
import { motionOf } from '../motion/motion.js';
|
|
4
4
|
import { pointsFrom } from '../points/points.js';
|
|
5
|
-
import { settledColor } from '../style/map-colors.js';
|
|
5
|
+
import { colorsNow, settledColor } from '../style/map-colors.js';
|
|
6
|
+
import { legible, parseColor } from '../style/oklab.js';
|
|
6
7
|
import { watchTheme } from '../theme/watch-theme.js';
|
|
8
|
+
import { captionAt } from './caption.js';
|
|
7
9
|
const SAME_SPOT = 2;
|
|
8
10
|
const fanOut = (count, spacing) => {
|
|
9
11
|
if (count <= 8) {
|
|
@@ -48,6 +50,7 @@ const UNPLACED_POSITION = {
|
|
|
48
50
|
'--_map-position-x': undefined,
|
|
49
51
|
'--_map-position-y': undefined,
|
|
50
52
|
};
|
|
53
|
+
const CAPTION_CONTRAST = 4.5;
|
|
51
54
|
const METERS_PER_DEGREE = 111_320;
|
|
52
55
|
const LEAST_REACH = 0.005;
|
|
53
56
|
export const reachOf = (places, least = 0) => {
|
|
@@ -389,6 +392,24 @@ export const connectMap = (frame, driver, options = {}) => {
|
|
|
389
392
|
seen = next;
|
|
390
393
|
place();
|
|
391
394
|
};
|
|
395
|
+
let captionTheme;
|
|
396
|
+
const captionOf = (layer, text, color) => {
|
|
397
|
+
if (captionTheme === undefined) {
|
|
398
|
+
const colors = colorsNow(frame);
|
|
399
|
+
const land = colors ? parseColor(colors.surface) : undefined;
|
|
400
|
+
const ink = colors ? parseColor(colors.foreground) : undefined;
|
|
401
|
+
captionTheme = colors && land && ink ? { halo: colors.surface, ink, land } : null;
|
|
402
|
+
}
|
|
403
|
+
const own = parseColor(color);
|
|
404
|
+
return {
|
|
405
|
+
...captionAt(layer.points ?? [], layer.captionPlacement ?? 'inside'),
|
|
406
|
+
halo: captionTheme?.halo ?? 'rgba(0, 0, 0, 0)',
|
|
407
|
+
ink: captionTheme && own
|
|
408
|
+
? legible(own, captionTheme.ink, captionTheme.land, CAPTION_CONTRAST)
|
|
409
|
+
: color,
|
|
410
|
+
text,
|
|
411
|
+
};
|
|
412
|
+
};
|
|
392
413
|
const readLayers = () => {
|
|
393
414
|
pointLayers.clear();
|
|
394
415
|
return readShown();
|
|
@@ -496,7 +517,9 @@ export const connectMap = (frame, driver, options = {}) => {
|
|
|
496
517
|
}
|
|
497
518
|
const edge = layer.layerKind === 'route' ? settledColor(layer, 'border-top-color') : color;
|
|
498
519
|
const dotted = layer.layerKind === 'route' && layer.variant === 'dotted';
|
|
520
|
+
const words = layer.layerKind === 'area' ? layer.caption?.trim() : undefined;
|
|
499
521
|
const spec = {
|
|
522
|
+
...(words ? { caption: captionOf(layer, words, color) } : {}),
|
|
500
523
|
...(edge && edge !== color && !dotted ? { casing: edge } : {}),
|
|
501
524
|
color,
|
|
502
525
|
id: idOf(layer),
|
|
@@ -726,23 +749,23 @@ export const connectMap = (frame, driver, options = {}) => {
|
|
|
726
749
|
snapshot.style.display = 'none';
|
|
727
750
|
};
|
|
728
751
|
const snapshots = [...frame.querySelectorAll(':scope > [data-helix-map-snapshot]')];
|
|
729
|
-
const stopWaiting =
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
snapshot
|
|
738
|
-
.animate([{ opacity: 1 }, { opacity: 0 }], {
|
|
739
|
-
duration: motion.duration,
|
|
740
|
-
easing: `linear(${Array.from({ length: 13 }, (_, step) => motion.easing(step / 12).toFixed(3)).join(', ')})`,
|
|
741
|
-
})
|
|
742
|
-
.finished.then(() => away(snapshot), () => away(snapshot));
|
|
752
|
+
const stopWaiting = driver.ready?.(() => {
|
|
753
|
+
place();
|
|
754
|
+
frame.drawn?.();
|
|
755
|
+
const motion = motionOf(frame, 'step');
|
|
756
|
+
for (const snapshot of snapshots) {
|
|
757
|
+
if (!motion || typeof snapshot.animate !== 'function') {
|
|
758
|
+
away(snapshot);
|
|
759
|
+
continue;
|
|
743
760
|
}
|
|
744
|
-
|
|
745
|
-
|
|
761
|
+
snapshot
|
|
762
|
+
.animate([{ opacity: 1 }, { opacity: 0 }], {
|
|
763
|
+
duration: motion.duration,
|
|
764
|
+
easing: `linear(${Array.from({ length: 13 }, (_, step) => motion.easing(step / 12).toFixed(3)).join(', ')})`,
|
|
765
|
+
})
|
|
766
|
+
.finished.then(() => away(snapshot), () => away(snapshot));
|
|
767
|
+
}
|
|
768
|
+
});
|
|
746
769
|
let live = true;
|
|
747
770
|
let placing = false;
|
|
748
771
|
let drawing = false;
|
|
@@ -799,7 +822,12 @@ export const connectMap = (frame, driver, options = {}) => {
|
|
|
799
822
|
soon({ draw: true, place: true });
|
|
800
823
|
});
|
|
801
824
|
children.observe(frame, { childList: true });
|
|
802
|
-
const stopTheming = drawn
|
|
825
|
+
const stopTheming = drawn
|
|
826
|
+
? watchTheme(frame, () => {
|
|
827
|
+
captionTheme = undefined;
|
|
828
|
+
drawn.update();
|
|
829
|
+
})
|
|
830
|
+
: undefined;
|
|
803
831
|
place();
|
|
804
832
|
const reveal = (event) => {
|
|
805
833
|
const marker = event.composedPath().find(isMarker);
|
|
@@ -876,6 +904,7 @@ export const connectMap = (frame, driver, options = {}) => {
|
|
|
876
904
|
stopViewing?.();
|
|
877
905
|
clearTimeout(looking);
|
|
878
906
|
stopWaiting?.();
|
|
907
|
+
frame.drawn?.(false);
|
|
879
908
|
stopTheming?.();
|
|
880
909
|
styled.disconnect();
|
|
881
910
|
children.disconnect();
|
|
@@ -86,6 +86,22 @@ export interface LeafletShapes {
|
|
|
86
86
|
circleMarker?(latlng: [number, number], options: LeafletShapeOptions & {
|
|
87
87
|
radius: number;
|
|
88
88
|
}): LeafletShape;
|
|
89
|
+
/** An icon of the page's own markup, which an area's caption is drawn in. */
|
|
90
|
+
divIcon?(options: {
|
|
91
|
+
className: string;
|
|
92
|
+
html: HTMLElement;
|
|
93
|
+
iconSize: [number, number];
|
|
94
|
+
}): unknown;
|
|
95
|
+
/**
|
|
96
|
+
* A marker at a place, which an area's caption stands on. `icon` takes
|
|
97
|
+
* `never` for `addTo`'s reason: it is what `divIcon` made, which Leaflet
|
|
98
|
+
* types as its own class.
|
|
99
|
+
*/
|
|
100
|
+
marker?(latlng: [number, number], options: {
|
|
101
|
+
icon: never;
|
|
102
|
+
interactive: boolean;
|
|
103
|
+
keyboard: boolean;
|
|
104
|
+
}): LeafletShape;
|
|
89
105
|
polygon(latlngs: [number, number][], options: LeafletShapeOptions): LeafletShape;
|
|
90
106
|
polyline(latlngs: [number, number][], options: LeafletShapeOptions): LeafletShape;
|
|
91
107
|
/** A layer of map tiles from a URL, which a raster layer's frames are drawn in. */
|
|
@@ -77,6 +77,36 @@ export const leafletDriver = (map, shapes) => ({
|
|
|
77
77
|
let drawn = [];
|
|
78
78
|
const circles = new Map();
|
|
79
79
|
let placed = new Map();
|
|
80
|
+
const captionOf = (caption) => {
|
|
81
|
+
if (!caption || !shapes.marker || !shapes.divIcon) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
const words = document.createElement('span');
|
|
85
|
+
words.textContent = caption.text;
|
|
86
|
+
const halo = caption.halo;
|
|
87
|
+
Object.assign(words.style, {
|
|
88
|
+
color: caption.ink,
|
|
89
|
+
font: '600 14px/1 var(--helix-font-sans, sans-serif)',
|
|
90
|
+
left: '0',
|
|
91
|
+
pointerEvents: 'none',
|
|
92
|
+
position: 'absolute',
|
|
93
|
+
textShadow: `0 0 2px ${halo}, 0 0 2px ${halo}, 0 0 3px ${halo}`,
|
|
94
|
+
top: '0',
|
|
95
|
+
transform: caption.anchor === 'bottom' ? 'translate(-50%, -130%)' : 'translate(-50%, -50%)',
|
|
96
|
+
whiteSpace: 'nowrap',
|
|
97
|
+
});
|
|
98
|
+
return [
|
|
99
|
+
shapes.marker([caption.at.lat, caption.at.lng], {
|
|
100
|
+
icon: shapes.divIcon({
|
|
101
|
+
className: 'helix-map-caption',
|
|
102
|
+
html: words,
|
|
103
|
+
iconSize: [0, 0],
|
|
104
|
+
}),
|
|
105
|
+
interactive: false,
|
|
106
|
+
keyboard: false,
|
|
107
|
+
}),
|
|
108
|
+
];
|
|
109
|
+
};
|
|
80
110
|
const rasters = new Map();
|
|
81
111
|
const drawRasters = (specs) => {
|
|
82
112
|
const make = shapes.tileLayer;
|
|
@@ -203,10 +233,11 @@ export const leafletDriver = (map, shapes) => ({
|
|
|
203
233
|
interactive: false,
|
|
204
234
|
weight: 1.5,
|
|
205
235
|
});
|
|
206
|
-
|
|
236
|
+
const caption = layer.kind === 'area' ? captionOf(layer.caption) : [];
|
|
237
|
+
for (const one of [...casing, shape, ...caption]) {
|
|
207
238
|
one.addTo(map);
|
|
208
239
|
}
|
|
209
|
-
return [...casing, shape];
|
|
240
|
+
return [...casing, shape, ...caption];
|
|
210
241
|
});
|
|
211
242
|
for (const [id, had] of [...circles]) {
|
|
212
243
|
if (!kept.has(id)) {
|
|
@@ -14,7 +14,7 @@ interface MapLibreEvent {
|
|
|
14
14
|
}
|
|
15
15
|
/** The frame's routes and areas, as MapLibre draws GeoJSON. */
|
|
16
16
|
interface GeoJson {
|
|
17
|
-
features: {
|
|
17
|
+
features: ({
|
|
18
18
|
geometry: {
|
|
19
19
|
coordinates: unknown;
|
|
20
20
|
type: 'LineString' | 'Polygon';
|
|
@@ -28,7 +28,20 @@ interface GeoJson {
|
|
|
28
28
|
pattern?: 'dotted';
|
|
29
29
|
};
|
|
30
30
|
type: 'Feature';
|
|
31
|
-
}
|
|
31
|
+
} | {
|
|
32
|
+
geometry: {
|
|
33
|
+
coordinates: [number, number];
|
|
34
|
+
type: 'Point';
|
|
35
|
+
};
|
|
36
|
+
properties: {
|
|
37
|
+
anchor: 'bottom' | 'center';
|
|
38
|
+
halo: string;
|
|
39
|
+
ink: string;
|
|
40
|
+
kind: 'caption';
|
|
41
|
+
text: string;
|
|
42
|
+
};
|
|
43
|
+
type: 'Feature';
|
|
44
|
+
})[];
|
|
32
45
|
type: 'FeatureCollection';
|
|
33
46
|
}
|
|
34
47
|
/**
|
|
@@ -51,6 +64,8 @@ type MapLibreLayer = {
|
|
|
51
64
|
};
|
|
52
65
|
/** Their layers, from the bottom up: areas under their own edges, routes over both. */
|
|
53
66
|
export declare const LAYERS: MapLibreLayer[];
|
|
67
|
+
/** The caption layer, in the face the style draws its names in. */
|
|
68
|
+
export declare const captionLayer: (font: readonly string[]) => Record<string, unknown>;
|
|
54
69
|
/** The routes and areas as GeoJSON, longitude first, an area's ring closed as GeoJSON requires. */
|
|
55
70
|
export declare const geoJson: (layers: MapLayerSpec[]) => GeoJson;
|
|
56
71
|
/**
|
|
@@ -101,6 +116,11 @@ export interface MapLibreMap {
|
|
|
101
116
|
loaded?(): boolean;
|
|
102
117
|
/** Whether the style has loaded: `true`, and anything else is not yet. MapLibre 6 types it loosely. */
|
|
103
118
|
isStyleLoaded?(): unknown;
|
|
119
|
+
/**
|
|
120
|
+
* Whether one source has every tile the view needs, once the view has asked
|
|
121
|
+
* for them; a source no shown layer draws from has nothing to wait for.
|
|
122
|
+
*/
|
|
123
|
+
isSourceLoaded?(id: string): boolean;
|
|
104
124
|
/** The canvas, which MapLibre makes focusable. */
|
|
105
125
|
getCanvas(): HTMLElement;
|
|
106
126
|
/** The middle of the view, which a map-type picture is drawn around. */
|
|
@@ -58,13 +58,62 @@ export const LAYERS = [
|
|
|
58
58
|
type: 'line',
|
|
59
59
|
},
|
|
60
60
|
];
|
|
61
|
+
const CAPTIONS = 'helix-area-captions';
|
|
62
|
+
export const captionLayer = (font) => ({
|
|
63
|
+
filter: ['==', ['get', 'kind'], 'caption'],
|
|
64
|
+
id: CAPTIONS,
|
|
65
|
+
layout: {
|
|
66
|
+
'text-allow-overlap': true,
|
|
67
|
+
'text-anchor': ['get', 'anchor'],
|
|
68
|
+
'text-field': ['get', 'text'],
|
|
69
|
+
'text-font': font,
|
|
70
|
+
'text-offset': [
|
|
71
|
+
'case',
|
|
72
|
+
['==', ['get', 'anchor'], 'bottom'],
|
|
73
|
+
['literal', [0, -0.3]],
|
|
74
|
+
['literal', [0, 0]],
|
|
75
|
+
],
|
|
76
|
+
'text-size': 14,
|
|
77
|
+
},
|
|
78
|
+
paint: { 'text-color': ['get', 'ink'], 'text-halo-color': ['get', 'halo'], 'text-halo-width': 2 },
|
|
79
|
+
source: SOURCE,
|
|
80
|
+
type: 'symbol',
|
|
81
|
+
});
|
|
82
|
+
const faceOf = (map) => {
|
|
83
|
+
const faces = (map.getStyle()?.layers ?? [])
|
|
84
|
+
.filter((layer) => layer.type === 'symbol')
|
|
85
|
+
.map((layer) => layer.layout?.['text-font'])
|
|
86
|
+
.filter((face) => Array.isArray(face) &&
|
|
87
|
+
face.every((one) => typeof one === 'string') &&
|
|
88
|
+
!face.some((one) => /italic/i.test(one)));
|
|
89
|
+
return (faces.find((face) => face.some((one) => /medium|semibold|bold/i.test(one))) ??
|
|
90
|
+
faces[0] ?? ['Noto Sans Regular']);
|
|
91
|
+
};
|
|
61
92
|
export const geoJson = (layers) => ({
|
|
62
93
|
features: layers.flatMap((layer) => {
|
|
63
94
|
if (layer.kind !== 'area' && layer.kind !== 'route') {
|
|
64
95
|
return [];
|
|
65
96
|
}
|
|
66
97
|
const coordinates = layer.points.map(({ lat, lng }) => [lng, lat]);
|
|
67
|
-
|
|
98
|
+
const caption = layer.caption
|
|
99
|
+
? [
|
|
100
|
+
{
|
|
101
|
+
geometry: {
|
|
102
|
+
coordinates: [layer.caption.at.lng, layer.caption.at.lat],
|
|
103
|
+
type: 'Point',
|
|
104
|
+
},
|
|
105
|
+
properties: {
|
|
106
|
+
anchor: layer.caption.anchor,
|
|
107
|
+
halo: layer.caption.halo,
|
|
108
|
+
ink: layer.caption.ink,
|
|
109
|
+
kind: 'caption',
|
|
110
|
+
text: layer.caption.text,
|
|
111
|
+
},
|
|
112
|
+
type: 'Feature',
|
|
113
|
+
},
|
|
114
|
+
]
|
|
115
|
+
: [];
|
|
116
|
+
const shape = {
|
|
68
117
|
geometry: layer.kind === 'route'
|
|
69
118
|
? { coordinates, type: 'LineString' }
|
|
70
119
|
: { coordinates: [[...coordinates, coordinates[0]]], type: 'Polygon' },
|
|
@@ -78,6 +127,7 @@ export const geoJson = (layers) => ({
|
|
|
78
127
|
},
|
|
79
128
|
type: 'Feature',
|
|
80
129
|
};
|
|
130
|
+
return [shape, ...caption];
|
|
81
131
|
}),
|
|
82
132
|
type: 'FeatureCollection',
|
|
83
133
|
});
|
|
@@ -798,6 +848,7 @@ export const mapLibreDriver = (map) => ({
|
|
|
798
848
|
for (const layer of LAYERS) {
|
|
799
849
|
map.addLayer(layer, labels);
|
|
800
850
|
}
|
|
851
|
+
map.addLayer(captionLayer(faceOf(map)));
|
|
801
852
|
drawExtras(specs.filter((spec) => EXTRAS.has(spec.kind)));
|
|
802
853
|
};
|
|
803
854
|
const restore = () => {
|
|
@@ -950,9 +1001,9 @@ export const mapLibreDriver = (map) => ({
|
|
|
950
1001
|
for (const id of [...drawnExtras.keys()]) {
|
|
951
1002
|
takeOffExtra(id);
|
|
952
1003
|
}
|
|
953
|
-
for (const
|
|
954
|
-
if (map.getLayer(
|
|
955
|
-
map.removeLayer(
|
|
1004
|
+
for (const id of [CAPTIONS, ...[...LAYERS].reverse().map((layer) => layer.id)]) {
|
|
1005
|
+
if (map.getLayer(id)) {
|
|
1006
|
+
map.removeLayer(id);
|
|
956
1007
|
}
|
|
957
1008
|
}
|
|
958
1009
|
if (map.getSource(SOURCE)) {
|
|
@@ -1056,30 +1107,68 @@ export const mapLibreDriver = (map) => ({
|
|
|
1056
1107
|
return { x, y };
|
|
1057
1108
|
},
|
|
1058
1109
|
ready: (done) => {
|
|
1059
|
-
if (map.
|
|
1060
|
-
|
|
1061
|
-
|
|
1110
|
+
if (!map.isSourceLoaded) {
|
|
1111
|
+
if (map.loaded?.() && map.areTilesLoaded?.()) {
|
|
1112
|
+
done();
|
|
1113
|
+
return () => undefined;
|
|
1114
|
+
}
|
|
1115
|
+
let finished = false;
|
|
1116
|
+
const finish = () => {
|
|
1117
|
+
if (!finished) {
|
|
1118
|
+
finished = true;
|
|
1119
|
+
map.off('load', finish);
|
|
1120
|
+
map.off('render', drawn);
|
|
1121
|
+
done();
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
const drawn = () => {
|
|
1125
|
+
if (map.isStyleLoaded?.() === true && map.areTilesLoaded?.() === true) {
|
|
1126
|
+
finish();
|
|
1127
|
+
}
|
|
1128
|
+
};
|
|
1129
|
+
map.on('load', finish);
|
|
1130
|
+
map.on('render', drawn);
|
|
1131
|
+
return () => {
|
|
1132
|
+
map.off('load', finish);
|
|
1133
|
+
map.off('render', drawn);
|
|
1134
|
+
};
|
|
1062
1135
|
}
|
|
1063
|
-
let
|
|
1064
|
-
const
|
|
1065
|
-
|
|
1066
|
-
|
|
1136
|
+
let tiled;
|
|
1137
|
+
const read = () => {
|
|
1138
|
+
const sources = map.getStyle()?.sources;
|
|
1139
|
+
tiled = sources
|
|
1140
|
+
? Object.entries(sources)
|
|
1141
|
+
.filter(([, source]) => source.type !== 'geojson')
|
|
1142
|
+
.map(([id]) => id)
|
|
1143
|
+
: undefined;
|
|
1144
|
+
};
|
|
1145
|
+
const whole = () => {
|
|
1146
|
+
if (tiled === undefined) {
|
|
1147
|
+
read();
|
|
1067
1148
|
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1149
|
+
return tiled?.every((id) => map.isSourceLoaded?.(id) === true) === true;
|
|
1150
|
+
};
|
|
1151
|
+
if (whole()) {
|
|
1070
1152
|
done();
|
|
1153
|
+
return () => undefined;
|
|
1154
|
+
}
|
|
1155
|
+
const restyled = () => {
|
|
1156
|
+
tiled = undefined;
|
|
1071
1157
|
};
|
|
1072
|
-
const
|
|
1073
|
-
if (
|
|
1074
|
-
|
|
1158
|
+
const heard = () => {
|
|
1159
|
+
if (whole()) {
|
|
1160
|
+
stop();
|
|
1161
|
+
done();
|
|
1075
1162
|
}
|
|
1076
1163
|
};
|
|
1077
1164
|
const stop = () => {
|
|
1078
|
-
map.off('
|
|
1079
|
-
map.off('render',
|
|
1165
|
+
map.off('styledata', restyled);
|
|
1166
|
+
map.off('render', heard);
|
|
1167
|
+
map.off('idle', heard);
|
|
1080
1168
|
};
|
|
1081
|
-
map.on('
|
|
1082
|
-
map.on('render',
|
|
1169
|
+
map.on('styledata', restyled);
|
|
1170
|
+
map.on('render', heard);
|
|
1171
|
+
map.on('idle', heard);
|
|
1083
1172
|
return stop;
|
|
1084
1173
|
},
|
|
1085
1174
|
rotate: ({ by, to }, motion) => {
|
package/dist/style/map-style.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { bed, firstAid, forkKnife, graduationCap, shoppingBag, star, train, tree, trophy, } from '@fusion.dev/helix-icons';
|
|
2
|
-
import {
|
|
2
|
+
import { cssColor, legible as legibleOn, mix, parseColor, rehue } from './oklab.js';
|
|
3
3
|
const OPENFREEMAP = {
|
|
4
4
|
glyphs: 'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
|
|
5
5
|
tiles: 'https://tiles.openfreemap.org/planet',
|
|
@@ -112,27 +112,7 @@ export const mapLibreStyle = (colors, options = {}) => {
|
|
|
112
112
|
const BOLD = face ? [`${face} Bold`, face] : ['Noto Sans Bold'];
|
|
113
113
|
const ITALIC = face ? [`${face} Italic`, face] : ['Noto Sans Italic'];
|
|
114
114
|
const glyphs = face ? options.glyphs : (options.glyphs ?? OPENFREEMAP.glyphs);
|
|
115
|
-
const legible = (from, amount) =>
|
|
116
|
-
const at = (step) => parseColor(cssColor(mix(from, ink, step)));
|
|
117
|
-
if (contrast(at(amount), land) >= target) {
|
|
118
|
-
return cssColor(at(amount));
|
|
119
|
-
}
|
|
120
|
-
if (contrast(ink, land) < target) {
|
|
121
|
-
return cssColor(ink);
|
|
122
|
-
}
|
|
123
|
-
let low = amount;
|
|
124
|
-
let high = 1;
|
|
125
|
-
for (let round = 0; round < 20; round++) {
|
|
126
|
-
const middle = (low + high) / 2;
|
|
127
|
-
if (contrast(at(middle), land) >= target) {
|
|
128
|
-
high = middle;
|
|
129
|
-
}
|
|
130
|
-
else {
|
|
131
|
-
low = middle;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
return cssColor(at(high));
|
|
135
|
-
};
|
|
115
|
+
const legible = (from, amount) => legibleOn(from, ink, land, target, amount);
|
|
136
116
|
const road = (id, classes, color, width, minzoom) => ({
|
|
137
117
|
filter: ['all', lines, notTunnel, ['match', ['get', 'class'], classes, true, false]],
|
|
138
118
|
id,
|
package/dist/style/oklab.d.ts
CHANGED
|
@@ -33,3 +33,13 @@ export declare const oklch: (lightness: number, chroma: number, hue: number) =>
|
|
|
33
33
|
export declare const cssColor: ({ alpha, blue, green, red }: Rgb) => string;
|
|
34
34
|
/** The WCAG contrast ratio between two colors, from 1 to 21. */
|
|
35
35
|
export declare const contrast: (one: Rgb, other: Rgb) => number;
|
|
36
|
+
/**
|
|
37
|
+
* A color that can be read on a ground: `from` mixed toward `ink` by
|
|
38
|
+
* `amount`, and further until it clears `target` against `ground`, or `ink`
|
|
39
|
+
* itself where nothing short of it does. What the style draws its labels in,
|
|
40
|
+
* and an area's caption.
|
|
41
|
+
*
|
|
42
|
+
* **Measured as it will be drawn**, rounded to eight bits a channel: the
|
|
43
|
+
* unrounded color cleared 7:1 and the one written out read 6.96.
|
|
44
|
+
*/
|
|
45
|
+
export declare const legible: (from: Rgb, ink: Rgb, ground: Rgb, target: number, amount?: number) => string;
|
package/dist/style/oklab.js
CHANGED
|
@@ -73,3 +73,24 @@ export const contrast = (one, other) => {
|
|
|
73
73
|
const [light, dark] = [luminance(one), luminance(other)].sort((a, b) => b - a);
|
|
74
74
|
return (light + 0.05) / (dark + 0.05);
|
|
75
75
|
};
|
|
76
|
+
export const legible = (from, ink, ground, target, amount = 0) => {
|
|
77
|
+
const at = (step) => parseColor(cssColor(mix(from, ink, step)));
|
|
78
|
+
if (contrast(at(amount), ground) >= target) {
|
|
79
|
+
return cssColor(at(amount));
|
|
80
|
+
}
|
|
81
|
+
if (contrast(ink, ground) < target) {
|
|
82
|
+
return cssColor(ink);
|
|
83
|
+
}
|
|
84
|
+
let low = amount;
|
|
85
|
+
let high = 1;
|
|
86
|
+
for (let round = 0; round < 20; round++) {
|
|
87
|
+
const middle = (low + high) / 2;
|
|
88
|
+
if (contrast(at(middle), ground) >= target) {
|
|
89
|
+
high = middle;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
low = middle;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return cssColor(at(high));
|
|
96
|
+
};
|
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.115.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ross Sheppard <ross.sheppard@fusion.dev>",
|
|
7
7
|
"repository": {
|
|
@@ -43,14 +43,14 @@
|
|
|
43
43
|
},
|
|
44
44
|
"bugs": "https://github.com/project-fusion/fusion/issues",
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@fusion.dev/helix-core": "0.
|
|
47
|
-
"@fusion.dev/helix-icons": "0.
|
|
46
|
+
"@fusion.dev/helix-core": "0.115.0",
|
|
47
|
+
"@fusion.dev/helix-icons": "0.115.0",
|
|
48
48
|
"@mapbox/vector-tile": "^3.0.0",
|
|
49
49
|
"pbf": "^5.1.2"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@fusion.dev/helix-headless": "0.
|
|
53
|
-
"@fusion.dev/helix-testing": "0.
|
|
52
|
+
"@fusion.dev/helix-headless": "0.115.0",
|
|
53
|
+
"@fusion.dev/helix-testing": "0.115.0",
|
|
54
54
|
"@maplibre/maplibre-gl-style-spec": "^26.4.4",
|
|
55
55
|
"@types/bun": "^1.3.0",
|
|
56
56
|
"@types/leaflet": "^1.9.20",
|