@fusion.dev/helix-map 0.114.0 → 0.116.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 +22 -0
- package/dist/connect-map/connect-map.js +46 -2
- package/dist/leaflet/leaflet-driver.d.ts +38 -0
- package/dist/leaflet/leaflet-driver.js +81 -2
- package/dist/maplibre/maplibre-driver.d.ts +31 -2
- package/dist/maplibre/maplibre-driver.js +88 -4
- 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
|
+
};
|
|
@@ -42,6 +42,15 @@ export interface MapDriver {
|
|
|
42
42
|
* `:state(ready)`.
|
|
43
43
|
*/
|
|
44
44
|
ready?(done: () => void): () => void;
|
|
45
|
+
/**
|
|
46
|
+
* Which gestures the renderer takes from the page, as the frame's
|
|
47
|
+
* `gestures` decides. `free` is what the renderer did before it was told
|
|
48
|
+
* otherwise. `cooperative` leaves the wheel and one finger to the page and
|
|
49
|
+
* takes the wheel with Ctrl or ⌘, and two fingers. `off` takes none, while a
|
|
50
|
+
* map waiting to be pressed has not been. The frame tells the reader what
|
|
51
|
+
* passed to the page, so hide the renderer's own message where it has one.
|
|
52
|
+
*/
|
|
53
|
+
gestures?(mode: 'cooperative' | 'free' | 'off'): void;
|
|
45
54
|
/**
|
|
46
55
|
* The element the renderer made focusable, which the connector takes out of
|
|
47
56
|
* the tab order while connected so the frame's region is the one stop.
|
|
@@ -192,6 +201,19 @@ export type MapPadding = number | {
|
|
|
192
201
|
export interface MapLayerSpec {
|
|
193
202
|
/** A raster layer's credit for its tiles, which the frame shows with the map's own. */
|
|
194
203
|
attribution?: string | undefined;
|
|
204
|
+
/**
|
|
205
|
+
* An area's caption: its words, the place they stand on and which part of
|
|
206
|
+
* them stands there, `center` or `bottom` for words above the place, and
|
|
207
|
+
* the colors to draw them in, an `ink` that reads on the map and a `halo`
|
|
208
|
+
* of the land round it. Drawn above the place names, and never pressed.
|
|
209
|
+
*/
|
|
210
|
+
caption?: {
|
|
211
|
+
anchor: 'bottom' | 'center';
|
|
212
|
+
at: MapPlace;
|
|
213
|
+
halo: string;
|
|
214
|
+
ink: string;
|
|
215
|
+
text: string;
|
|
216
|
+
} | undefined;
|
|
195
217
|
/**
|
|
196
218
|
* A route's edge, drawn wider under it so it reads as laid over the roads
|
|
197
219
|
* 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),
|
|
@@ -769,8 +792,21 @@ export const connectMap = (frame, driver, options = {}) => {
|
|
|
769
792
|
queueMicrotask(flush);
|
|
770
793
|
}
|
|
771
794
|
};
|
|
795
|
+
let gestured = 'free';
|
|
796
|
+
const tellGestures = () => {
|
|
797
|
+
const mode = frame.gestureMode ?? 'free';
|
|
798
|
+
if (mode !== gestured) {
|
|
799
|
+
gestured = mode;
|
|
800
|
+
driver.gestures?.(mode);
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
tellGestures();
|
|
772
804
|
const handlePartChange = (event) => {
|
|
773
805
|
const { kind } = event.detail;
|
|
806
|
+
if (kind === 'gestures') {
|
|
807
|
+
tellGestures();
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
774
810
|
if (kind === 'menu') {
|
|
775
811
|
drawPreviews();
|
|
776
812
|
return;
|
|
@@ -799,7 +835,12 @@ export const connectMap = (frame, driver, options = {}) => {
|
|
|
799
835
|
soon({ draw: true, place: true });
|
|
800
836
|
});
|
|
801
837
|
children.observe(frame, { childList: true });
|
|
802
|
-
const stopTheming = drawn
|
|
838
|
+
const stopTheming = drawn
|
|
839
|
+
? watchTheme(frame, () => {
|
|
840
|
+
captionTheme = undefined;
|
|
841
|
+
drawn.update();
|
|
842
|
+
})
|
|
843
|
+
: undefined;
|
|
803
844
|
place();
|
|
804
845
|
const reveal = (event) => {
|
|
805
846
|
const marker = event.composedPath().find(isMarker);
|
|
@@ -877,6 +918,9 @@ export const connectMap = (frame, driver, options = {}) => {
|
|
|
877
918
|
clearTimeout(looking);
|
|
878
919
|
stopWaiting?.();
|
|
879
920
|
frame.drawn?.(false);
|
|
921
|
+
if (gestured !== 'free') {
|
|
922
|
+
driver.gestures?.('free');
|
|
923
|
+
}
|
|
880
924
|
stopTheming?.();
|
|
881
925
|
styled.disconnect();
|
|
882
926
|
children.disconnect();
|
|
@@ -21,6 +21,12 @@ export interface LeafletAttribution {
|
|
|
21
21
|
};
|
|
22
22
|
remove(): unknown;
|
|
23
23
|
}
|
|
24
|
+
/** One of Leaflet's gesture handlers, as far as the driver switches it. */
|
|
25
|
+
export interface LeafletHandler {
|
|
26
|
+
disable(): unknown;
|
|
27
|
+
enable(): unknown;
|
|
28
|
+
enabled(): boolean;
|
|
29
|
+
}
|
|
24
30
|
/**
|
|
25
31
|
* The part of a Leaflet `Map` the driver uses, so this package imports no
|
|
26
32
|
* renderer and a page brings whichever version it has.
|
|
@@ -58,6 +64,22 @@ export interface LeafletMap {
|
|
|
58
64
|
} | undefined;
|
|
59
65
|
/** How far the view is zoomed in. */
|
|
60
66
|
getZoom?(): number;
|
|
67
|
+
/** The wheel's zoom, one of the handlers a frame's `gestures` switches. */
|
|
68
|
+
scrollWheelZoom?: LeafletHandler | undefined;
|
|
69
|
+
/** Dragging the map, by a mouse or one finger. */
|
|
70
|
+
dragging?: LeafletHandler | undefined;
|
|
71
|
+
/** Two fingers zooming the map. */
|
|
72
|
+
touchZoom?: LeafletHandler | undefined;
|
|
73
|
+
/** The place under a pointer, which a zoom with the wheel is made round. */
|
|
74
|
+
mouseEventToLatLng?(event: MouseEvent): {
|
|
75
|
+
lat: number;
|
|
76
|
+
lng: number;
|
|
77
|
+
};
|
|
78
|
+
/** Zooms keeping a place where it is on screen. */
|
|
79
|
+
setZoomAround?(latlng: {
|
|
80
|
+
lat: number;
|
|
81
|
+
lng: number;
|
|
82
|
+
}, zoom: number): unknown;
|
|
61
83
|
zoomIn(delta?: number, options?: {
|
|
62
84
|
animate?: boolean;
|
|
63
85
|
}): unknown;
|
|
@@ -86,6 +108,22 @@ export interface LeafletShapes {
|
|
|
86
108
|
circleMarker?(latlng: [number, number], options: LeafletShapeOptions & {
|
|
87
109
|
radius: number;
|
|
88
110
|
}): LeafletShape;
|
|
111
|
+
/** An icon of the page's own markup, which an area's caption is drawn in. */
|
|
112
|
+
divIcon?(options: {
|
|
113
|
+
className: string;
|
|
114
|
+
html: HTMLElement;
|
|
115
|
+
iconSize: [number, number];
|
|
116
|
+
}): unknown;
|
|
117
|
+
/**
|
|
118
|
+
* A marker at a place, which an area's caption stands on. `icon` takes
|
|
119
|
+
* `never` for `addTo`'s reason: it is what `divIcon` made, which Leaflet
|
|
120
|
+
* types as its own class.
|
|
121
|
+
*/
|
|
122
|
+
marker?(latlng: [number, number], options: {
|
|
123
|
+
icon: never;
|
|
124
|
+
interactive: boolean;
|
|
125
|
+
keyboard: boolean;
|
|
126
|
+
}): LeafletShape;
|
|
89
127
|
polygon(latlngs: [number, number][], options: LeafletShapeOptions): LeafletShape;
|
|
90
128
|
polyline(latlngs: [number, number][], options: LeafletShapeOptions): LeafletShape;
|
|
91
129
|
/** A layer of map tiles from a URL, which a raster layer's frames are drawn in. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const animation = (motion) => motion ? { animate: true, duration: motion.duration / 1000 } : { animate: false };
|
|
2
|
+
const gestureState = new WeakMap();
|
|
2
3
|
export const leafletDriver = (map, shapes) => ({
|
|
3
4
|
credits(changed) {
|
|
4
5
|
const control = map.attributionControl;
|
|
@@ -38,6 +39,53 @@ export const leafletDriver = (map, shapes) => ({
|
|
|
38
39
|
});
|
|
39
40
|
},
|
|
40
41
|
focusTarget: () => map.getContainer(),
|
|
42
|
+
gestures: (mode) => {
|
|
43
|
+
const handlers = { drag: map.dragging, scroll: map.scrollWheelZoom, touch: map.touchZoom };
|
|
44
|
+
const state = gestureState.get(map) ?? {
|
|
45
|
+
had: {
|
|
46
|
+
drag: handlers.drag?.enabled() ?? false,
|
|
47
|
+
scroll: handlers.scroll?.enabled() ?? false,
|
|
48
|
+
touch: handlers.touch?.enabled() ?? false,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
gestureState.set(map, state);
|
|
52
|
+
const finger = typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches;
|
|
53
|
+
const want = mode === 'free'
|
|
54
|
+
? state.had
|
|
55
|
+
: mode === 'cooperative'
|
|
56
|
+
? { drag: !finger, scroll: false, touch: true }
|
|
57
|
+
: { drag: false, scroll: false, touch: false };
|
|
58
|
+
for (const name of ['drag', 'scroll', 'touch']) {
|
|
59
|
+
const handler = handlers[name];
|
|
60
|
+
if (handler && handler.enabled() !== want[name]) {
|
|
61
|
+
if (want[name]) {
|
|
62
|
+
handler.enable();
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
handler.disable();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const box = map.getContainer();
|
|
70
|
+
if (state.wheel) {
|
|
71
|
+
box.removeEventListener('wheel', state.wheel);
|
|
72
|
+
state.wheel = undefined;
|
|
73
|
+
}
|
|
74
|
+
if (mode === 'cooperative' && map.mouseEventToLatLng && map.setZoomAround && map.getZoom) {
|
|
75
|
+
state.wheel = (event) => {
|
|
76
|
+
if (!(event.ctrlKey || event.metaKey)) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
const at = map.mouseEventToLatLng?.(event);
|
|
81
|
+
const zoom = map.getZoom?.();
|
|
82
|
+
if (at && zoom !== undefined) {
|
|
83
|
+
map.setZoomAround?.(at, zoom - Math.sign(event.deltaY) * 0.5);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
box.addEventListener('wheel', state.wheel, { passive: false });
|
|
87
|
+
}
|
|
88
|
+
},
|
|
41
89
|
onView: (moved) => {
|
|
42
90
|
map.on('move zoom resize', moved);
|
|
43
91
|
return () => {
|
|
@@ -77,6 +125,36 @@ export const leafletDriver = (map, shapes) => ({
|
|
|
77
125
|
let drawn = [];
|
|
78
126
|
const circles = new Map();
|
|
79
127
|
let placed = new Map();
|
|
128
|
+
const captionOf = (caption) => {
|
|
129
|
+
if (!caption || !shapes.marker || !shapes.divIcon) {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
const words = document.createElement('span');
|
|
133
|
+
words.textContent = caption.text;
|
|
134
|
+
const halo = caption.halo;
|
|
135
|
+
Object.assign(words.style, {
|
|
136
|
+
color: caption.ink,
|
|
137
|
+
font: '600 14px/1 var(--helix-font-sans, sans-serif)',
|
|
138
|
+
left: '0',
|
|
139
|
+
pointerEvents: 'none',
|
|
140
|
+
position: 'absolute',
|
|
141
|
+
textShadow: `0 0 2px ${halo}, 0 0 2px ${halo}, 0 0 3px ${halo}`,
|
|
142
|
+
top: '0',
|
|
143
|
+
transform: caption.anchor === 'bottom' ? 'translate(-50%, -130%)' : 'translate(-50%, -50%)',
|
|
144
|
+
whiteSpace: 'nowrap',
|
|
145
|
+
});
|
|
146
|
+
return [
|
|
147
|
+
shapes.marker([caption.at.lat, caption.at.lng], {
|
|
148
|
+
icon: shapes.divIcon({
|
|
149
|
+
className: 'helix-map-caption',
|
|
150
|
+
html: words,
|
|
151
|
+
iconSize: [0, 0],
|
|
152
|
+
}),
|
|
153
|
+
interactive: false,
|
|
154
|
+
keyboard: false,
|
|
155
|
+
}),
|
|
156
|
+
];
|
|
157
|
+
};
|
|
80
158
|
const rasters = new Map();
|
|
81
159
|
const drawRasters = (specs) => {
|
|
82
160
|
const make = shapes.tileLayer;
|
|
@@ -203,10 +281,11 @@ export const leafletDriver = (map, shapes) => ({
|
|
|
203
281
|
interactive: false,
|
|
204
282
|
weight: 1.5,
|
|
205
283
|
});
|
|
206
|
-
|
|
284
|
+
const caption = layer.kind === 'area' ? captionOf(layer.caption) : [];
|
|
285
|
+
for (const one of [...casing, shape, ...caption]) {
|
|
207
286
|
one.addTo(map);
|
|
208
287
|
}
|
|
209
|
-
return [...casing, shape];
|
|
288
|
+
return [...casing, shape, ...caption];
|
|
210
289
|
});
|
|
211
290
|
for (const [id, had] of [...circles]) {
|
|
212
291
|
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
|
/**
|
|
@@ -90,6 +105,12 @@ interface MapLibreAnimation {
|
|
|
90
105
|
duration?: number;
|
|
91
106
|
easing?: (t: number) => number;
|
|
92
107
|
}
|
|
108
|
+
/** One of MapLibre's gesture handlers, as far as the driver switches it. */
|
|
109
|
+
export interface MapLibreHandler {
|
|
110
|
+
disable(): unknown;
|
|
111
|
+
enable(): unknown;
|
|
112
|
+
isEnabled(): boolean;
|
|
113
|
+
}
|
|
93
114
|
/**
|
|
94
115
|
* The part of a MapLibre `Map` the driver uses, so this package imports no
|
|
95
116
|
* renderer and a page brings whichever version it has.
|
|
@@ -108,6 +129,14 @@ export interface MapLibreMap {
|
|
|
108
129
|
isSourceLoaded?(id: string): boolean;
|
|
109
130
|
/** The canvas, which MapLibre makes focusable. */
|
|
110
131
|
getCanvas(): HTMLElement;
|
|
132
|
+
/** The wheel's zoom, one of the handlers a frame's `gestures` switches. */
|
|
133
|
+
scrollZoom?: MapLibreHandler;
|
|
134
|
+
/** Dragging the map, by a mouse or one finger. */
|
|
135
|
+
dragPan?: MapLibreHandler;
|
|
136
|
+
/** Two fingers zooming and turning the map. */
|
|
137
|
+
touchZoomRotate?: MapLibreHandler;
|
|
138
|
+
/** MapLibre's own cooperative gestures: Ctrl or ⌘ with the wheel, and two fingers. */
|
|
139
|
+
cooperativeGestures?: MapLibreHandler;
|
|
111
140
|
/** The middle of the view, which a map-type picture is drawn around. */
|
|
112
141
|
getCenter?(): {
|
|
113
142
|
lat: number;
|
|
@@ -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
|
});
|
|
@@ -237,6 +287,7 @@ const answerImages = (holder) => {
|
|
|
237
287
|
return () => holder.off?.('styleimagemissing', missing);
|
|
238
288
|
};
|
|
239
289
|
const PICTURE = { height: 90, width: 120 };
|
|
290
|
+
const originalGestures = new WeakMap();
|
|
240
291
|
export const mapLibreDriver = (map) => ({
|
|
241
292
|
bearing: () => map.getBearing(),
|
|
242
293
|
behind: ({ lat, lng }) => (map.transform ?? map._camera?.transform)?.isLocationOccluded?.({ lat, lng }) === true,
|
|
@@ -273,6 +324,38 @@ export const mapLibreDriver = (map) => ({
|
|
|
273
324
|
], { ...animation(motion), padding });
|
|
274
325
|
},
|
|
275
326
|
focusTarget: () => map.getCanvas(),
|
|
327
|
+
gestures: (mode) => {
|
|
328
|
+
const handlers = {
|
|
329
|
+
cooperative: map.cooperativeGestures,
|
|
330
|
+
drag: map.dragPan,
|
|
331
|
+
scroll: map.scrollZoom,
|
|
332
|
+
touch: map.touchZoomRotate,
|
|
333
|
+
};
|
|
334
|
+
const had = (originalGestures.get(map) ??
|
|
335
|
+
Object.fromEntries(Object.entries(handlers).map(([name, handler]) => [name, handler?.isEnabled() ?? false])));
|
|
336
|
+
originalGestures.set(map, had);
|
|
337
|
+
const want = mode === 'free'
|
|
338
|
+
? had
|
|
339
|
+
: mode === 'cooperative'
|
|
340
|
+
? { cooperative: true, drag: true, scroll: true, touch: true }
|
|
341
|
+
: { cooperative: false, drag: false, scroll: false, touch: false };
|
|
342
|
+
for (const [name, handler] of Object.entries(handlers)) {
|
|
343
|
+
if (handler && handler.isEnabled() !== want[name]) {
|
|
344
|
+
if (want[name]) {
|
|
345
|
+
handler.enable();
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
handler.disable();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const screen = map
|
|
353
|
+
.getContainer()
|
|
354
|
+
.querySelector('.maplibregl-cooperative-gesture-screen');
|
|
355
|
+
if (screen) {
|
|
356
|
+
screen.style.display = 'none';
|
|
357
|
+
}
|
|
358
|
+
},
|
|
276
359
|
labels: (shown) => {
|
|
277
360
|
const heard = bared.has(map);
|
|
278
361
|
bared.set(map, !shown);
|
|
@@ -798,6 +881,7 @@ export const mapLibreDriver = (map) => ({
|
|
|
798
881
|
for (const layer of LAYERS) {
|
|
799
882
|
map.addLayer(layer, labels);
|
|
800
883
|
}
|
|
884
|
+
map.addLayer(captionLayer(faceOf(map)));
|
|
801
885
|
drawExtras(specs.filter((spec) => EXTRAS.has(spec.kind)));
|
|
802
886
|
};
|
|
803
887
|
const restore = () => {
|
|
@@ -950,9 +1034,9 @@ export const mapLibreDriver = (map) => ({
|
|
|
950
1034
|
for (const id of [...drawnExtras.keys()]) {
|
|
951
1035
|
takeOffExtra(id);
|
|
952
1036
|
}
|
|
953
|
-
for (const
|
|
954
|
-
if (map.getLayer(
|
|
955
|
-
map.removeLayer(
|
|
1037
|
+
for (const id of [CAPTIONS, ...[...LAYERS].reverse().map((layer) => layer.id)]) {
|
|
1038
|
+
if (map.getLayer(id)) {
|
|
1039
|
+
map.removeLayer(id);
|
|
956
1040
|
}
|
|
957
1041
|
}
|
|
958
1042
|
if (map.getSource(SOURCE)) {
|
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.116.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.116.0",
|
|
47
|
+
"@fusion.dev/helix-icons": "0.116.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.116.0",
|
|
53
|
+
"@fusion.dev/helix-testing": "0.116.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",
|