@fusion.dev/helix-map 0.205.2 → 0.207.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/README.md CHANGED
@@ -14,8 +14,14 @@ for the first paint, the keyboard, and writing a driver. The elements themselves
14
14
  [Map reference](https://fusion.dev/helix/components/map).
15
15
 
16
16
  ```js
17
+ import '@fusion.dev/helix/map'
18
+ import 'maplibre-gl/dist/maplibre-gl.css'
17
19
  import { colorsReady, connectMap } from '@fusion.dev/helix-map'
18
20
  import { followTheme, mapLibreDriver, mapLibreStyle } from '@fusion.dev/helix-map/maplibre'
21
+ import * as maplibregl from 'maplibre-gl'
22
+
23
+ // MapLibre 6 runs its worker from a file of its own. Point it there once.
24
+ maplibregl.setWorkerUrl('/maplibre/maplibre-gl-worker.mjs')
19
25
 
20
26
  const frame = document.querySelector('helix-map')
21
27
  const style = (colors) => mapLibreStyle(colors)
@@ -26,19 +32,31 @@ const map = new maplibregl.Map({
26
32
  })
27
33
 
28
34
  const connection = connectMap(frame, mapLibreDriver(map))
35
+ connection.fit({ animate: false })
29
36
  followTheme(map, frame, style)
30
37
 
31
38
  // Later, when the map goes away:
32
39
  connection.disconnect()
33
40
  ```
34
41
 
42
+ MapLibre 6 runs its worker from two files, `maplibre-gl-worker.mjs` and
43
+ `maplibre-gl-shared.mjs`. Serve both from one folder, and pass the worker's
44
+ address to `setWorkerUrl` before you create a map, as the guide shows.
45
+
46
+ **The map's tiles come from OpenFreeMap by default**, and so do its glyphs
47
+ where you pass no `font`. OpenFreeMap needs no key. The requests go to its
48
+ servers: from your reader's browser for the map, and from your server for
49
+ `mapSnapshot`. Pass `tiles` and `glyphs` to `mapLibreStyle`, and `tiles` to
50
+ `mapSnapshot`, to use your own servers.
51
+
35
52
  ## What it exports
36
53
 
37
54
  | Import | What |
38
55
  |---|---|
39
- | `@fusion.dev/helix-map` | `connectMap`, `colorsReady`, `mapColorsFrom`, `watchTheme`, `motionOf`, `distancesAlong`, `pointsFrom`, and the `MapDriver` type |
56
+ | `@fusion.dev/helix-map` | `connectMap`, `colorsReady`, `mapColorsFrom`, `mapFontFrom`, `fontReady`, `watchTheme`, `motionOf`, `distancesAlong`, `pointsFrom`, and the `MapDriver` type |
40
57
  | `@fusion.dev/helix-map/maplibre` | `mapLibreDriver(map)`, `mapLibreStyle(colors, options)`, `followTheme(map, element, style)` |
41
58
  | `@fusion.dev/helix-map/leaflet` | `leafletDriver(map, L)` |
59
+ | `@fusion.dev/helix-map/snapshot` | `mapSnapshot(view, options)`, an SVG of the map drawn on your server to paint before the renderer loads, with the credit its tiles name |
42
60
 
43
61
  **Neither renderer is a dependency.** A driver types the map it takes by the
44
62
  calls it makes on it, so a page brings whichever version it has. A spec holds
@@ -312,7 +312,9 @@ export declare const distancesAlong: (points: readonly MapPlace[]) => number[];
312
312
  export interface ConnectOptions {
313
313
  /**
314
314
  * Whether the renderer's credit fills the frame's `attribution` slot. On by
315
- * default, and left alone either way where the page slotted its own.
315
+ * default, and left alone either way where the page slotted its own. A
316
+ * snapshot's credit stays until the renderer's is written, and stays for
317
+ * good with `false`.
316
318
  */
317
319
  credit?: boolean | undefined;
318
320
  /**
@@ -361,7 +363,8 @@ export interface MapConnection {
361
363
  * the frame draws its own; the frame's markers stood on their places as the
362
364
  * view moves, and brought into view as the keyboard reaches them; and the
363
365
  * renderer's credit written into the frame's
364
- * `attribution` slot as text and links, kept current as its sources change.
366
+ * `attribution` slot as text and links, kept current as its sources change,
367
+ * in place of a snapshot's.
365
368
  *
366
369
  * The frame is `helix-map` or `helix-headless-map`, and the driver is
367
370
  * `mapLibreDriver`, `leafletDriver` or one of the page's own.
@@ -1,5 +1,5 @@
1
1
  import { HelixActivateEvent, HelixMapClusterEvent, HelixMapPanEvent, HelixMapPartChangeEvent, HelixMapRotateEvent, HelixMapTiltEvent, HelixMapZoomEvent, isRtl, watchTheme, } from '@fusion.dev/helix-core';
2
- import { creditParts } from '../credit/credit.js';
2
+ import { CREDIT, creditParts } from '../credit/credit.js';
3
3
  import { motionOf } from '../motion/motion.js';
4
4
  import { pointsFrom } from '../points/points.js';
5
5
  import { colorsNow, settledColor } from '../style/map-colors.js';
@@ -142,7 +142,6 @@ const idOf = (element) => {
142
142
  }
143
143
  return id;
144
144
  };
145
- const CREDIT = 'data-helix-map-credit';
146
145
  const viewportOf = (frame) => {
147
146
  const box = (frame.shadowRoot?.querySelector('[part="viewport"]') ?? frame).getBoundingClientRect();
148
147
  return { height: box.height, width: box.width };
@@ -258,6 +257,18 @@ export const connectMap = (frame, driver, options = {}) => {
258
257
  const tabindex = focusable?.getAttribute('tabindex') ?? null;
259
258
  focusable?.setAttribute('tabindex', '-1');
260
259
  const owned = [...frame.children].some((child) => child.getAttribute('slot') === 'attribution' && !child.hasAttribute(CREDIT));
260
+ const served = [...frame.children].filter((child) => child instanceof HTMLElement &&
261
+ child.getAttribute('slot') === 'attribution' &&
262
+ child.hasAttribute(CREDIT));
263
+ const stepAside = () => {
264
+ for (const one of served) {
265
+ one.hidden = true;
266
+ one.style.display = 'none';
267
+ }
268
+ };
269
+ if (owned) {
270
+ stepAside();
271
+ }
261
272
  let credit;
262
273
  let listening;
263
274
  let written;
@@ -300,8 +311,11 @@ export const connectMap = (frame, driver, options = {}) => {
300
311
  if (nodes.length === 0) {
301
312
  credit.remove();
302
313
  }
303
- else if (credit.parentNode !== frame) {
304
- frame.append(credit);
314
+ else {
315
+ stepAside();
316
+ if (credit.parentNode !== frame) {
317
+ frame.append(credit);
318
+ }
305
319
  }
306
320
  };
307
321
  if (options.credit !== false && !owned && driver.credits) {
@@ -5,6 +5,18 @@ export interface CreditPart {
5
5
  /** The words, as they are read. */
6
6
  text: string;
7
7
  }
8
+ /**
9
+ * What marks a credit Helix wrote, the connector's or a snapshot's, so the
10
+ * connector can tell one from the page's own: it replaces a credit with this,
11
+ * and leaves one without it alone.
12
+ */
13
+ export declare const CREDIT = "data-helix-map-credit";
14
+ /**
15
+ * A credit as the markup the connector writes into the frame: a span in the
16
+ * `attribution` slot, marked as Helix's, of words and links that open in a new
17
+ * tab in the text's own color. Nothing where the credit says nothing.
18
+ */
19
+ export declare const creditMarkup: (parts: readonly CreditPart[]) => string;
8
20
  /**
9
21
  * A renderer's credits as runs of text and links, and nothing else.
10
22
  *
@@ -14,6 +26,8 @@ export interface CreditPart {
14
26
  * in the page this sits in. So every element but a link is taken for its
15
27
  * words, a link only where it leads to `http` or `https`, and the result is
16
28
  * written with `textContent`. Two credits that read the same are one, which
17
- * is what a style that names OpenStreetMap on every source needs.
29
+ * is what a style that names OpenStreetMap on every source needs. A server,
30
+ * which has no `DOMParser`, reads the same runs from the markup, so the credit
31
+ * `mapSnapshot` writes reads as the one the renderer gives later.
18
32
  */
19
33
  export declare const creditParts: (credits: readonly string[]) => CreditPart[];
@@ -1,4 +1,5 @@
1
1
  const BETWEEN = ' | ';
2
+ export const CREDIT = 'data-helix-map-credit';
2
3
  const safe = (href) => {
3
4
  if (!href) {
4
5
  return false;
@@ -28,6 +29,70 @@ const walk = (node, into) => {
28
29
  }
29
30
  }
30
31
  };
32
+ const ENTITIES = {
33
+ amp: '&',
34
+ apos: "'",
35
+ copy: '©',
36
+ gt: '>',
37
+ lt: '<',
38
+ nbsp: '\u00a0',
39
+ quot: '"',
40
+ reg: '®',
41
+ };
42
+ const decoded = (words) => words.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (written, name) => {
43
+ if (name.startsWith('#')) {
44
+ const code = /x/i.test(name) ? Number.parseInt(name.slice(2), 16) : Number(name.slice(1));
45
+ return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : written;
46
+ }
47
+ return ENTITIES[name.toLowerCase()] ?? written;
48
+ });
49
+ const TAG = /<!--[\s\S]*?-->|<(\/?)([a-z][^\s/>]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/gi;
50
+ const HREF = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i;
51
+ const read = (markup, into) => {
52
+ let link;
53
+ let from = 0;
54
+ const words = (written) => {
55
+ if (written === '') {
56
+ return;
57
+ }
58
+ if (link) {
59
+ link.text += decoded(written);
60
+ }
61
+ else {
62
+ into.push({ text: decoded(written) });
63
+ }
64
+ };
65
+ for (const tag of markup.matchAll(TAG)) {
66
+ words(markup.slice(from, tag.index));
67
+ from = tag.index + tag[0].length;
68
+ if (tag[2]?.toLowerCase() !== 'a') {
69
+ continue;
70
+ }
71
+ if (tag[1] === '/') {
72
+ if (link) {
73
+ into.push(link);
74
+ }
75
+ link = undefined;
76
+ }
77
+ else if (!link) {
78
+ const found = HREF.exec(tag[3] ?? '');
79
+ const href = found ? decoded(found[1] ?? found[2] ?? found[3] ?? '') : null;
80
+ link = safe(href) ? { href, text: '' } : undefined;
81
+ }
82
+ }
83
+ words(markup.slice(from));
84
+ if (link) {
85
+ into.push(link);
86
+ }
87
+ };
88
+ const escaped = (words) => words.replace(/[&<>"]/g, (character) => `&#${character.charCodeAt(0)};`);
89
+ export const creditMarkup = (parts) => parts.length === 0
90
+ ? ''
91
+ : `<span ${CREDIT} slot="attribution">${parts
92
+ .map((part) => part.href === undefined
93
+ ? escaped(part.text)
94
+ : `<a data-link="quiet" href="${escaped(part.href)}" rel="noopener noreferrer" target="_blank">${escaped(part.text)}</a>`)
95
+ .join('')}</span>`;
31
96
  export const creditParts = (credits) => {
32
97
  const seen = new Set();
33
98
  const parts = [];
@@ -37,7 +102,7 @@ export const creditParts = (credits) => {
37
102
  walk(new DOMParser().parseFromString(credit, 'text/html').body, runs);
38
103
  }
39
104
  else {
40
- runs.push({ text: credit.replace(/<[^>]*>/g, '') });
105
+ read(credit, runs);
41
106
  }
42
107
  const clean = runs
43
108
  .map((run) => ({ ...run, text: run.text.replace(/\s+/g, ' ') }))
@@ -1,4 +1,5 @@
1
+ export type { CreditPart } from '../credit/credit.js';
1
2
  export type { MapCamera, MapView, ScreenPoint } from './camera.js';
2
3
  export { cameraOf, worldOf } from './camera.js';
3
- export type { SnapshotLayer, SnapshotOptions, SnapshotToward } from './snapshot.js';
4
+ export type { MapSnapshot, SnapshotLayer, SnapshotOptions, SnapshotToward } from './snapshot.js';
4
5
  export { mapSnapshot, SNAPSHOT_LAYERS, widthAt } from './snapshot.js';
@@ -1,3 +1,4 @@
1
+ import { type CreditPart } from '../credit/credit.js';
1
2
  import { type MapView } from './camera.js';
2
3
  /** The token a snapshot layer's color is the land moved toward. */
3
4
  export type SnapshotToward = 'foreground' | 'info' | 'primary' | 'success';
@@ -52,6 +53,24 @@ export interface SnapshotOptions {
52
53
  /** A TileJSON URL for vector tiles in the OpenMapTiles schema: the one given to `mapLibreStyle`. OpenFreeMap's by default. */
53
54
  tiles?: string | undefined;
54
55
  }
56
+ /** A map drawn on the server, and the credit its tiles ask for. */
57
+ export interface MapSnapshot {
58
+ /**
59
+ * The tiles' credit, read from their TileJSON's `attribution`, as runs of
60
+ * words and links: for a framework that writes `picture` in an element of
61
+ * its own, and the credit in a `<span slot="attribution">` with
62
+ * `data-helix-map-credit`. Empty where the tiles name none.
63
+ */
64
+ credit: CreditPart[];
65
+ /**
66
+ * The picture, then the credit in the frame's `attribution` slot, as markup
67
+ * to write in the frame after the element the renderer draws in. An empty
68
+ * string where the snapshot could not be drawn.
69
+ */
70
+ markup: string;
71
+ /** The picture alone, as markup. An empty string where it could not be drawn. */
72
+ picture: string;
73
+ }
55
74
  /**
56
75
  * **A map drawn on the server, for the page's first paint**: an SVG of the
57
76
  * view a renderer is about to draw, from the same vector tiles and in the
@@ -70,11 +89,19 @@ export interface SnapshotOptions {
70
89
  * toward a token in OKLab, so it is drawn in whichever theme the page is in,
71
90
  * light or dark, and changes with it.
72
91
  *
73
- * **Whole or nothing.** Resolves to an empty string where any tile the view
74
- * needs cannot be read in time, or the view is not numbers, which a page
75
- * renders as nothing and the renderer then draws. A snapshot with a tile
76
- * missing looks finished and is not, and one that waited on a slow server
77
- * held the page: each request is given `timeout` milliseconds. A tile the
78
- * server says it does not have, a 404, is drawn empty, as the sea is.
92
+ * **Credited from the first paint.** Most tile licenses require the credit
93
+ * wherever the map is shown, and a frame showing the snapshot while the
94
+ * renderer loads, or a page that never loads one, has no renderer's credit.
95
+ * So the snapshot carries the credit the tiles' TileJSON names, and `markup`
96
+ * writes it in the frame's `attribution` slot, marked as Helix's:
97
+ * `connectMap` hides it once the renderer's credit is written, or at once
98
+ * where the page slotted its own.
99
+ *
100
+ * **Whole or nothing.** Holds empty strings where any tile the view needs
101
+ * cannot be read in time, or the view is not numbers, which a page renders
102
+ * as nothing and the renderer then draws. A snapshot with a tile missing
103
+ * looks finished and is not, and one that waited on a slow server held the
104
+ * page: each request is given `timeout` milliseconds. A tile the server says
105
+ * it does not have, a 404, is drawn empty, as the sea is.
79
106
  */
80
- export declare const mapSnapshot: (asked: MapView, options?: SnapshotOptions) => Promise<string>;
107
+ export declare const mapSnapshot: (asked: MapView, options?: SnapshotOptions) => Promise<MapSnapshot>;
@@ -1,5 +1,6 @@
1
1
  import { VectorTile } from '@mapbox/vector-tile';
2
2
  import { PbfReader } from 'pbf';
3
+ import { creditMarkup, creditParts } from '../credit/credit.js';
3
4
  import { cameraOf } from './camera.js';
4
5
  export const SNAPSHOT_LAYERS = [
5
6
  {
@@ -149,6 +150,7 @@ const simplified = (points) => {
149
150
  }
150
151
  return points.filter((_, index) => kept[index] === 1);
151
152
  };
153
+ const nothing = () => ({ credit: [], markup: '', picture: '' });
152
154
  const OPENFREEMAP = 'https://tiles.openfreemap.org/planet';
153
155
  const MOST_TILES = 40;
154
156
  const MARGIN = 16;
@@ -163,7 +165,7 @@ export const mapSnapshot = async (asked, options = {}) => {
163
165
  };
164
166
  if (![view.bearing, view.center.lat, view.center.lng, view.pitch, view.zoom].every(Number.isFinite) ||
165
167
  !(view.width > 0 && view.height > 0)) {
166
- return '';
168
+ return nothing();
167
169
  }
168
170
  const wait = options.timeout ?? 3000;
169
171
  const get = (url) => {
@@ -184,8 +186,10 @@ export const mapSnapshot = async (asked, options = {}) => {
184
186
  .catch(() => undefined);
185
187
  const template = tiles?.tiles?.[0];
186
188
  if (!template) {
187
- return '';
189
+ return nothing();
188
190
  }
191
+ const attribution = tiles.attribution;
192
+ const credit = typeof attribution === 'string' ? creditParts([attribution]) : [];
189
193
  const deepest = tiles.maxzoom ?? 14;
190
194
  const camera = cameraOf(view);
191
195
  const corners = [
@@ -308,7 +312,7 @@ export const mapSnapshot = async (asked, options = {}) => {
308
312
  }
309
313
  }
310
314
  if (!whole) {
311
- return '';
315
+ return nothing();
312
316
  }
313
317
  const body = shown
314
318
  .map((layer) => {
@@ -326,7 +330,8 @@ export const mapSnapshot = async (asked, options = {}) => {
326
330
  ], view.zoom).toFixed(2)}px;stroke-linecap:round;stroke-linejoin:round" d="${paths.join('')}"/>`;
327
331
  })
328
332
  .join('');
329
- return (`<div aria-hidden="true" data-helix-map-snapshot style="inset:0;overflow:hidden;pointer-events:none;position:absolute">` +
333
+ const picture = `<div aria-hidden="true" data-helix-map-snapshot style="inset:0;overflow:hidden;pointer-events:none;position:absolute">` +
330
334
  `<svg height="${view.height}" viewBox="0 0 ${view.width} ${view.height}" width="${view.width}" xmlns="http://www.w3.org/2000/svg" style="background:var(${TOKENS.surface});left:50%;position:absolute;top:50%;translate:-50% -50%">` +
331
- `${body}</svg></div>`);
335
+ `${body}</svg></div>`;
336
+ return { credit, markup: picture + creditMarkup(credit), picture };
332
337
  };
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.205.2",
4
+ "version": "0.207.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -25,6 +25,7 @@
25
25
  "types": "./dist/maplibre/index.d.ts",
26
26
  "default": "./dist/maplibre/index.js"
27
27
  },
28
+ "./package.json": "./package.json",
28
29
  "./snapshot": {
29
30
  "types": "./dist/snapshot/index.d.ts",
30
31
  "default": "./dist/snapshot/index.js"
@@ -43,14 +44,14 @@
43
44
  },
44
45
  "bugs": "https://github.com/project-fusion/fusion/issues",
45
46
  "dependencies": {
46
- "@fusion.dev/helix-core": "0.205.2",
47
- "@fusion.dev/helix-icons": "0.205.2",
47
+ "@fusion.dev/helix-core": "0.207.0",
48
+ "@fusion.dev/helix-icons": "0.207.0",
48
49
  "@mapbox/vector-tile": "^3.0.0",
49
50
  "pbf": "^5.1.2"
50
51
  },
51
52
  "devDependencies": {
52
- "@fusion.dev/helix-headless": "0.205.2",
53
- "@fusion.dev/helix-testing": "0.205.2",
53
+ "@fusion.dev/helix-headless": "0.207.0",
54
+ "@fusion.dev/helix-testing": "0.207.0",
54
55
  "@maplibre/maplibre-gl-style-spec": "^26.4.4",
55
56
  "@types/bun": "^1.4.2",
56
57
  "@types/leaflet": "^1.9.20",