@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.
@@ -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 ? watchTheme(frame, () => drawn.update()) : undefined;
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
- for (const one of [...casing, shape]) {
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
- return {
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 layer of [...LAYERS].reverse()) {
954
- if (map.getLayer(layer.id)) {
955
- map.removeLayer(layer.id);
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)) {
@@ -1,5 +1,5 @@
1
1
  import { bed, firstAid, forkKnife, graduationCap, shoppingBag, star, train, tree, trophy, } from '@fusion.dev/helix-icons';
2
- import { contrast, cssColor, mix, parseColor, rehue } from './oklab.js';
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,
@@ -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;
@@ -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.114.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.114.0",
47
- "@fusion.dev/helix-icons": "0.114.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.114.0",
53
- "@fusion.dev/helix-testing": "0.114.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",