@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.
@@ -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 ? watchTheme(frame, () => drawn.update()) : undefined;
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
- for (const one of [...casing, shape]) {
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
- 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
  });
@@ -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 layer of [...LAYERS].reverse()) {
954
- if (map.getLayer(layer.id)) {
955
- map.removeLayer(layer.id);
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)) {
@@ -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.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.114.0",
47
- "@fusion.dev/helix-icons": "0.114.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.114.0",
53
- "@fusion.dev/helix-testing": "0.114.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",