@fusion.dev/helix-map 0.114.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 +13 -0
- package/dist/connect-map/connect-map.js +30 -2
- package/dist/leaflet/leaflet-driver.d.ts +16 -0
- package/dist/leaflet/leaflet-driver.js +33 -2
- package/dist/maplibre/maplibre-driver.d.ts +17 -2
- package/dist/maplibre/maplibre-driver.js +55 -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
|
+
};
|
|
@@ -192,6 +192,19 @@ export type MapPadding = number | {
|
|
|
192
192
|
export interface MapLayerSpec {
|
|
193
193
|
/** A raster layer's credit for its tiles, which the frame shows with the map's own. */
|
|
194
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;
|
|
195
208
|
/**
|
|
196
209
|
* A route's edge, drawn wider under it so it reads as laid over the roads
|
|
197
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),
|
|
@@ -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);
|
|
@@ -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
|
/**
|
|
@@ -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)) {
|
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",
|