@fusion.dev/helix-map 0.110.1 → 0.110.3

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.
@@ -248,6 +248,23 @@ export interface MapLayerSpec {
248
248
  */
249
249
  points: MapPlace[];
250
250
  }
251
+ /**
252
+ * The stretch of the world some places cover, and their middle.
253
+ *
254
+ * **One pass rather than `Math.max(...lats)`**, which spreads every place into
255
+ * an argument list and throws past about 65,000 of them in Safari: a points
256
+ * layer of that size made `fit()` throw.
257
+ *
258
+ * **Measured the short way round.** Longitudes are taken within 180 degrees of
259
+ * the first place, so two places either side of the antimeridian are a degree
260
+ * apart and not the whole world: framed naively, 179.5 and -179.5 zoomed out
261
+ * to the globe, and their middle landed at 0. The bounds may run past 180 as
262
+ * a result, which both renderers draw.
263
+ */
264
+ export declare const reachOf: (places: readonly MapPlace[]) => {
265
+ bounds: MapBounds;
266
+ middle: MapPlace;
267
+ };
251
268
  /**
252
269
  * How far along a line each of its points is, in meters from the first:
253
270
  * degrees with longitude narrowed by the latitude, which is a flat map's idea
@@ -28,6 +28,37 @@ const sameNames = (had, names) => (had?.length ?? 0) === names.length && names.e
28
28
  const isMarker = (node) => node?.isMapMarker === true;
29
29
  const isPosition = (node) => node?.isMapPosition === true;
30
30
  const METERS_PER_DEGREE = 111_320;
31
+ const LEAST_REACH = 0.005;
32
+ export const reachOf = (places) => {
33
+ const first = places[0]?.lng ?? 0;
34
+ let north = -90;
35
+ let south = 90;
36
+ let east = -Infinity;
37
+ let west = Infinity;
38
+ let lats = 0;
39
+ let lngs = 0;
40
+ for (const place of places) {
41
+ const lng = place.lng + 360 * Math.round((first - place.lng) / 360);
42
+ north = Math.max(north, place.lat);
43
+ south = Math.min(south, place.lat);
44
+ east = Math.max(east, lng);
45
+ west = Math.min(west, lng);
46
+ lats += place.lat;
47
+ lngs += lng;
48
+ }
49
+ const count = Math.max(places.length, 1);
50
+ const across = ((lngs / count + 540) % 360) - 180;
51
+ if (north - south < LEAST_REACH && east - west < LEAST_REACH) {
52
+ north += LEAST_REACH;
53
+ south -= LEAST_REACH;
54
+ east += LEAST_REACH;
55
+ west -= LEAST_REACH;
56
+ }
57
+ return {
58
+ bounds: { northEast: { lat: north, lng: east }, southWest: { lat: south, lng: west } },
59
+ middle: { lat: lats / count, lng: across },
60
+ };
61
+ };
31
62
  const AHEAD = 10;
32
63
  export const distancesAlong = (points) => {
33
64
  let total = 0;
@@ -63,6 +94,9 @@ const cut = (points, fraction) => {
63
94
  return [points, []];
64
95
  };
65
96
  const WALKED = 100;
97
+ const WALK_LINE = 24;
98
+ const WALK_SETTLES = 250;
99
+ const DRAGGED = 4;
66
100
  const sameWalk = (had, walk) => (had?.length ?? 0) === walk.length &&
67
101
  walk.every((one, at) => {
68
102
  const was = had?.[at];
@@ -70,6 +104,7 @@ const sameWalk = (had, walk) => (had?.length ?? 0) === walk.length &&
70
104
  Math.round(was.x) === Math.round(one.x) &&
71
105
  Math.round(was.y) === Math.round(one.y) &&
72
106
  was.label === one.label &&
107
+ was.value === one.value &&
73
108
  was.count === one.count);
74
109
  });
75
110
  const isLayer = (node) => node?.isMapLayer === true;
@@ -169,8 +204,10 @@ export const connectMap = (frame, driver, options = {}) => {
169
204
  types: ['standard', 'hybrid', 'satellite'],
170
205
  })
171
206
  .then((previews) => {
172
- ;
173
- frame.mapPreviews = previews;
207
+ if (live) {
208
+ ;
209
+ frame.mapPreviews = previews;
210
+ }
174
211
  })
175
212
  .catch(() => {
176
213
  stale = true;
@@ -180,9 +217,18 @@ export const connectMap = (frame, driver, options = {}) => {
180
217
  });
181
218
  };
182
219
  const restoreKeyboard = options.keyboard === 'renderer' ? undefined : driver.silenceKeyboard?.();
183
- const restoreZoom = frame.shadowRoot?.querySelector('[part~="zoom-in"]')
184
- ? driver.replaceZoom?.()
185
- : undefined;
220
+ let restoreZoom;
221
+ const replaceZoom = () => {
222
+ if (!restoreZoom && frame.shadowRoot?.querySelector('[part~="zoom-in"]')) {
223
+ restoreZoom = driver.replaceZoom?.();
224
+ }
225
+ };
226
+ replaceZoom();
227
+ void frame.updateComplete?.then(() => {
228
+ if (live) {
229
+ replaceZoom();
230
+ }
231
+ });
186
232
  const focusable = options.keyboard === 'renderer' ? null : (driver.focusTarget?.() ?? null);
187
233
  const tabindex = focusable?.getAttribute('tabindex') ?? null;
188
234
  focusable?.setAttribute('tabindex', '-1');
@@ -190,6 +236,19 @@ export const connectMap = (frame, driver, options = {}) => {
190
236
  let credit;
191
237
  let listening;
192
238
  let written;
239
+ let crediting = false;
240
+ const creditSoon = () => {
241
+ if (crediting) {
242
+ return;
243
+ }
244
+ crediting = true;
245
+ queueMicrotask(() => {
246
+ crediting = false;
247
+ if (live) {
248
+ writeCredit();
249
+ }
250
+ });
251
+ };
193
252
  const writeCredit = () => {
194
253
  if (!listening || !credit) {
195
254
  return;
@@ -224,7 +283,7 @@ export const connectMap = (frame, driver, options = {}) => {
224
283
  credit = document.createElement('span');
225
284
  credit.setAttribute('slot', 'attribution');
226
285
  credit.setAttribute(CREDIT, '');
227
- listening = driver.credits(writeCredit);
286
+ listening = driver.credits(creditSoon);
228
287
  writeCredit();
229
288
  }
230
289
  const markers = () => [...frame.children].filter(isMarker);
@@ -290,7 +349,8 @@ export const connectMap = (frame, driver, options = {}) => {
290
349
  }
291
350
  const { names, values } = pointsOf(layer);
292
351
  const found = drawn.inView(id).map((one) => ({ at: project(one.place), one }));
293
- found.sort((a, b) => a.at.y - b.at.y || (rtl ? b.at.x - a.at.x : a.at.x - b.at.x));
352
+ found.sort((a, b) => Math.floor(a.at.y / WALK_LINE) - Math.floor(b.at.y / WALK_LINE) ||
353
+ (rtl ? b.at.x - a.at.x : a.at.x - b.at.x));
294
354
  next.set(layer, found.slice(0, WALKED).map(({ one }) => one.cluster !== undefined
295
355
  ? { count: one.count ?? 0, key: `c:${one.cluster}`, place: one.place }
296
356
  : {
@@ -308,9 +368,14 @@ export const connectMap = (frame, driver, options = {}) => {
308
368
  seen = next;
309
369
  place();
310
370
  };
311
- const readLayers = () => [...frame.children]
371
+ const readLayers = () => {
372
+ pointLayers.clear();
373
+ return readShown();
374
+ };
375
+ const readShown = () => [...frame.children]
312
376
  .filter(isLayer)
313
377
  .filter((layer) => !layer.hidden)
378
+ .filter((layer) => !(layer.layerKind === 'raster' && layer.ground === true && !frame.mapType))
314
379
  .flatMap((layer) => {
315
380
  const color = settledColor(layer);
316
381
  if (layer.layerKind === 'contours') {
@@ -478,9 +543,10 @@ export const connectMap = (frame, driver, options = {}) => {
478
543
  }
479
544
  }
480
545
  const standing = everywhere.filter((one) => !one.behind);
546
+ const stood = new Map(standing.map((one) => [one.marker, one]));
481
547
  const gathering = frame.cluster === true;
482
548
  for (const marker of markers()) {
483
- if (!standing.some((one) => one.marker === marker) && marker.gatheredBy !== undefined) {
549
+ if (!stood.has(marker) && marker.gatheredBy !== undefined) {
484
550
  marker.gatheredBy = undefined;
485
551
  }
486
552
  }
@@ -488,8 +554,10 @@ export const connectMap = (frame, driver, options = {}) => {
488
554
  if (fanned) {
489
555
  const scale = scaleOf(fanned.place);
490
556
  const members = fanned.members;
491
- if (Math.abs(scale - fanned.scale) > fanned.scale * 0.02 ||
492
- !members.every((member) => standing.some((one) => one.marker === member))) {
557
+ const zoomed = fanned.zoom !== undefined && driver.zoom
558
+ ? Math.abs(driver.zoom() - fanned.zoom) > 0.01
559
+ : Math.abs(scale - fanned.scale) > fanned.scale * 0.02;
560
+ if (zoomed || !members.every((member) => stood.has(member))) {
493
561
  fanned = undefined;
494
562
  }
495
563
  }
@@ -516,7 +584,7 @@ export const connectMap = (frame, driver, options = {}) => {
516
584
  placing.push({
517
585
  at: { x: center.x + offset.x, y: center.y + offset.y },
518
586
  marker: member,
519
- reach: standing.find((one) => one.marker === member)?.reach,
587
+ reach: stood.get(member)?.reach,
520
588
  });
521
589
  });
522
590
  }
@@ -626,7 +694,12 @@ export const connectMap = (frame, driver, options = {}) => {
626
694
  }
627
695
  }
628
696
  };
629
- const stopViewing = driver.onView?.(place);
697
+ let looking;
698
+ const stopViewing = driver.onView?.(() => {
699
+ place();
700
+ clearTimeout(looking);
701
+ looking = setTimeout(look, WALK_SETTLES);
702
+ });
630
703
  const away = (snapshot) => {
631
704
  snapshot.hidden = true;
632
705
  snapshot.style.display = 'none';
@@ -734,22 +807,19 @@ export const connectMap = (frame, driver, options = {}) => {
734
807
  if (!members || options.clusterZoom === false) {
735
808
  return;
736
809
  }
737
- const lats = members.map((one) => one.lat);
738
- const lngs = members.map((one) => one.lng);
739
- const middle = {
740
- lat: lats.reduce((sum, lat) => sum + lat, 0) / lats.length,
741
- lng: lngs.reduce((sum, lng) => sum + lng, 0) / lngs.length,
742
- };
743
- const spread = Math.max(...members.map((one) => Math.hypot(one.lat - middle.lat, (one.lng - middle.lng) * Math.cos((middle.lat * Math.PI) / 180)) * METERS_PER_DEGREE));
810
+ const { bounds, middle } = reachOf(members.map((one) => ({ lat: one.lat, lng: one.lng })));
811
+ let spread = 0;
812
+ for (const one of members) {
813
+ const across = (((one.lng - middle.lng + 540) % 360) - 180) *
814
+ Math.cos((middle.lat * Math.PI) / 180);
815
+ spread = Math.max(spread, Math.hypot(one.lat - middle.lat, across) * METERS_PER_DEGREE);
816
+ }
744
817
  if (spread <= SAME_SPOT || !driver.fit) {
745
- fanned = { members, place: middle, scale: scaleOf(middle) };
818
+ fanned = { members, place: middle, scale: scaleOf(middle), zoom: driver.zoom?.() };
746
819
  place();
747
820
  return;
748
821
  }
749
- driver.fit({
750
- northEast: { lat: Math.max(...lats), lng: Math.max(...lngs) },
751
- southWest: { lat: Math.min(...lats), lng: Math.min(...lngs) },
752
- }, 96, motionOf(frame, 'travel'));
822
+ driver.fit(bounds, 96, motionOf(frame, 'travel'));
753
823
  };
754
824
  frame.addEventListener(HelixMapClusterEvent.eventName, handleCluster);
755
825
  const closeFan = () => {
@@ -764,18 +834,26 @@ export const connectMap = (frame, driver, options = {}) => {
764
834
  closeFan()?.focus();
765
835
  }
766
836
  };
837
+ let pressedAt;
838
+ const handleFanDown = (event) => {
839
+ pressedAt = { x: event.clientX, y: event.clientY };
840
+ };
767
841
  const handleFanPress = (event) => {
768
- if (fanned && !event.composedPath().some(isMarker)) {
842
+ const dragged = pressedAt !== undefined &&
843
+ Math.hypot(event.clientX - pressedAt.x, event.clientY - pressedAt.y) > DRAGGED;
844
+ if (fanned && !dragged && !event.composedPath().some(isMarker)) {
769
845
  closeFan();
770
846
  }
771
847
  };
772
848
  frame.addEventListener('keydown', handleFanKey);
849
+ frame.addEventListener('pointerdown', handleFanDown);
773
850
  frame.addEventListener('click', handleFanPress);
774
851
  return {
775
852
  disconnect() {
776
853
  live = false;
777
854
  drawn?.stop();
778
855
  stopViewing?.();
856
+ clearTimeout(looking);
779
857
  stopWaiting?.();
780
858
  stopTheming?.();
781
859
  styled.disconnect();
@@ -784,6 +862,7 @@ export const connectMap = (frame, driver, options = {}) => {
784
862
  frame.removeEventListener('focusin', reveal);
785
863
  frame.removeEventListener(HelixMapClusterEvent.eventName, handleCluster);
786
864
  frame.removeEventListener('keydown', handleFanKey);
865
+ frame.removeEventListener('pointerdown', handleFanDown);
787
866
  frame.removeEventListener('click', handleFanPress);
788
867
  for (const marker of markers()) {
789
868
  marker.style.removeProperty('--_map-marker-x');
@@ -797,6 +876,12 @@ export const connectMap = (frame, driver, options = {}) => {
797
876
  if (marker.fanned === true) {
798
877
  marker.fanned = false;
799
878
  }
879
+ if (marker.behind === true) {
880
+ marker.behind = false;
881
+ }
882
+ if (marker.side === 'start') {
883
+ marker.side = 'end';
884
+ }
800
885
  marker.style.removeProperty('--_map-marker-leg');
801
886
  marker.style.removeProperty('--_map-marker-leg-angle');
802
887
  }
@@ -846,12 +931,7 @@ export const connectMap = (frame, driver, options = {}) => {
846
931
  if (places.length === 0) {
847
932
  return;
848
933
  }
849
- const lats = places.map((place) => place.lat);
850
- const lngs = places.map((place) => place.lng);
851
- driver.fit({
852
- northEast: { lat: Math.max(...lats), lng: Math.max(...lngs) },
853
- southWest: { lat: Math.min(...lats), lng: Math.min(...lngs) },
854
- }, options.padding ?? 48, options.animate === false ? null : motionOf(frame, 'travel'));
934
+ driver.fit(reachOf(places).bounds, options.padding ?? 48, options.animate === false ? null : motionOf(frame, 'travel'));
855
935
  },
856
936
  };
857
937
  };
@@ -43,8 +43,8 @@ export interface LeafletMap {
43
43
  x: number;
44
44
  y: number;
45
45
  };
46
- off(type: 'layeradd layerremove' | 'move zoom resize', listener: () => void): unknown;
47
- on(type: 'layeradd layerremove' | 'move zoom resize', listener: () => void): unknown;
46
+ off(type: 'layeradd layerremove' | 'move zoom resize' | 'moveend', listener: () => void): unknown;
47
+ on(type: 'layeradd layerremove' | 'move zoom resize' | 'moveend', listener: () => void): unknown;
48
48
  fitBounds(bounds: [[number, number], [number, number]], options?: LeafletAnimation & {
49
49
  paddingBottomRight?: [number, number];
50
50
  paddingTopLeft?: [number, number];
@@ -75,6 +75,8 @@ export const leafletDriver = (map, shapes) => ({
75
75
  ? {
76
76
  layers: (read, events) => {
77
77
  let drawn = [];
78
+ const circles = new Map();
79
+ let placed = new Map();
78
80
  const rasters = new Map();
79
81
  const drawRasters = (specs) => {
80
82
  const make = shapes.tileLayer;
@@ -122,6 +124,8 @@ export const leafletDriver = (map, shapes) => ({
122
124
  shape.remove();
123
125
  }
124
126
  const specs = read();
127
+ const kept = new Set();
128
+ placed = new Map();
125
129
  drawRasters(specs.filter((spec) => spec.kind === 'raster'));
126
130
  drawn = specs.flatMap((layer) => {
127
131
  if (layer.kind === 'buildings' ||
@@ -137,7 +141,19 @@ export const leafletDriver = (map, shapes) => ({
137
141
  if (!circle) {
138
142
  return [];
139
143
  }
140
- return latlngs.map((latlng, at) => {
144
+ kept.add(layer.id);
145
+ placed.set(layer.id, layer.points);
146
+ const had = circles.get(layer.id);
147
+ if (had &&
148
+ had.points === layer.points &&
149
+ had.color === layer.color &&
150
+ had.casing === layer.casing) {
151
+ return [];
152
+ }
153
+ for (const shape of had?.shapes ?? []) {
154
+ shape.remove();
155
+ }
156
+ const made = latlngs.map((latlng, at) => {
141
157
  const point = circle(latlng, {
142
158
  color: layer.casing ?? layer.color,
143
159
  fillColor: layer.color,
@@ -150,6 +166,13 @@ export const leafletDriver = (map, shapes) => ({
150
166
  point.addTo(map);
151
167
  return point;
152
168
  });
169
+ circles.set(layer.id, {
170
+ casing: layer.casing,
171
+ color: layer.color,
172
+ points: layer.points,
173
+ shapes: made,
174
+ });
175
+ return [];
153
176
  }
154
177
  const casing = layer.kind === 'route' && layer.casing
155
178
  ? [
@@ -185,14 +208,41 @@ export const leafletDriver = (map, shapes) => ({
185
208
  }
186
209
  return [...casing, shape];
187
210
  });
211
+ for (const [id, had] of [...circles]) {
212
+ if (!kept.has(id)) {
213
+ for (const shape of had.shapes) {
214
+ shape.remove();
215
+ }
216
+ circles.delete(id);
217
+ }
218
+ }
219
+ events?.settled?.();
188
220
  };
221
+ const settled = () => events?.settled?.();
222
+ map.on('moveend', settled);
189
223
  draw();
190
224
  return {
225
+ inView: (id) => {
226
+ const box = map.getContainer();
227
+ const width = box.clientWidth;
228
+ const height = box.clientHeight;
229
+ return (placed.get(id) ?? []).flatMap((place, at) => {
230
+ const { x, y } = map.latLngToContainerPoint([place.lat, place.lng]);
231
+ return x >= 0 && y >= 0 && x <= width && y <= height ? [{ at, place }] : [];
232
+ });
233
+ },
191
234
  stop: () => {
235
+ map.off('moveend', settled);
192
236
  for (const shape of drawn) {
193
237
  shape.remove();
194
238
  }
195
239
  drawn = [];
240
+ for (const had of circles.values()) {
241
+ for (const shape of had.shapes) {
242
+ shape.remove();
243
+ }
244
+ }
245
+ circles.clear();
196
246
  drawRasters([]);
197
247
  },
198
248
  update: draw,
@@ -97,8 +97,10 @@ interface MapLibreAnimation {
97
97
  export interface MapLibreMap {
98
98
  /** Whether every tile the view needs has loaded. */
99
99
  areTilesLoaded?(): boolean;
100
- /** Whether the style has loaded. */
100
+ /** Whether the style and every tile have loaded, and nothing is waiting to be drawn. */
101
101
  loaded?(): boolean;
102
+ /** Whether the style has loaded: `true`, and anything else is not yet. MapLibre 6 types it loosely. */
103
+ isStyleLoaded?(): unknown;
102
104
  /** The canvas, which MapLibre makes focusable. */
103
105
  getCanvas(): HTMLElement;
104
106
  /** The middle of the view, which a map-type picture is drawn around. */
@@ -186,8 +188,8 @@ export interface MapLibreMap {
186
188
  enable(): void;
187
189
  isEnabled(): boolean;
188
190
  };
189
- off(type: 'click' | 'idle' | 'load' | 'mousemove' | 'move' | 'resize' | 'sourcedata' | 'moveend' | 'styledata' | 'styleimagemissing', listener: (event?: MapLibreEvent) => void): unknown;
190
- on(type: 'click' | 'idle' | 'load' | 'mousemove' | 'move' | 'resize' | 'sourcedata' | 'moveend' | 'styledata' | 'styleimagemissing', listener: (event?: MapLibreEvent) => void): unknown;
191
+ off(type: 'click' | 'idle' | 'load' | 'mousemove' | 'move' | 'resize' | 'sourcedata' | 'moveend' | 'render' | 'styledata' | 'styleimagemissing', listener: (event?: MapLibreEvent) => void): unknown;
192
+ on(type: 'click' | 'idle' | 'load' | 'mousemove' | 'move' | 'resize' | 'sourcedata' | 'moveend' | 'render' | 'styledata' | 'styleimagemissing', listener: (event?: MapLibreEvent) => void): unknown;
191
193
  removeLayer(id: string): unknown;
192
194
  removeSource(id: string): unknown;
193
195
  panBy(offset: [number, number], options?: MapLibreAnimation): unknown;
@@ -239,7 +241,7 @@ export interface MapLibreMap {
239
241
  };
240
242
  };
241
243
  /** Draws each image a style names as it is asked for. MapLibre 6. */
242
- setMissingStyleImageResolver?(resolver: ((id: string) => void) | null): unknown;
244
+ setMissingStyleImageResolver?(resolver: ((id: string) => unknown) | null): unknown;
243
245
  /** Sets one paint property of one layer, which is how a points layer takes a new color. */
244
246
  setPaintProperty(layer: string, name: string, value: unknown): unknown;
245
247
  /** Sets one layout property of one layer, which is how the buildings are shown and hidden. */
@@ -202,11 +202,22 @@ const showLabels = (map, shown) => {
202
202
  }
203
203
  }
204
204
  };
205
+ const described = new WeakMap();
206
+ const describedOf = (holder) => {
207
+ const had = described.get(holder);
208
+ if (had) {
209
+ return had;
210
+ }
211
+ const style = (holder.getStyle() ?? {});
212
+ described.set(holder, style);
213
+ queueMicrotask(() => described.delete(holder));
214
+ return style;
215
+ };
205
216
  const supplyImage = (holder, id) => {
206
217
  if (!id?.startsWith('helix-') || holder.hasImage?.(id)) {
207
218
  return;
208
219
  }
209
- const style = holder.getStyle();
220
+ const style = describedOf(holder);
210
221
  const [named = '', text = ''] = id.split('|');
211
222
  const image = style?.metadata?.['helix:images']?.[named];
212
223
  const drawn = image ? drawMapImage(image, text) : undefined;
@@ -216,8 +227,10 @@ const supplyImage = (holder, id) => {
216
227
  };
217
228
  const answerImages = (holder) => {
218
229
  if (typeof holder.setMissingStyleImageResolver === 'function') {
219
- holder.setMissingStyleImageResolver((id) => supplyImage(holder, id));
220
- return () => holder.setMissingStyleImageResolver?.(null);
230
+ const before = holder
231
+ ._missingStyleImageResolver ?? null;
232
+ holder.setMissingStyleImageResolver((id) => id.startsWith('helix-') ? supplyImage(holder, id) : before?.(id));
233
+ return () => holder.setMissingStyleImageResolver?.(before);
221
234
  }
222
235
  const missing = (event) => supplyImage(holder, event?.id);
223
236
  holder.on('styleimagemissing', missing);
@@ -265,10 +278,18 @@ export const mapLibreDriver = (map) => ({
265
278
  bared.set(map, !shown);
266
279
  showLabels(map, shown);
267
280
  if (!heard) {
281
+ let pending = false;
268
282
  map.on('styledata', () => {
269
- if (bared.get(map)) {
270
- showLabels(map, false);
283
+ if (!bared.get(map) || pending) {
284
+ return;
271
285
  }
286
+ pending = true;
287
+ queueMicrotask(() => {
288
+ pending = false;
289
+ if (bared.get(map)) {
290
+ showLabels(map, false);
291
+ }
292
+ });
272
293
  });
273
294
  }
274
295
  },
@@ -344,7 +365,8 @@ export const mapLibreDriver = (map) => ({
344
365
  const lines = new Map();
345
366
  let tracing = 0;
346
367
  const heightsOf = (url, tile, encoding) => {
347
- const had = heights.get(url);
368
+ const key = `${url}|${encoding}`;
369
+ const had = heights.get(key);
348
370
  if (had) {
349
371
  return had;
350
372
  }
@@ -366,11 +388,17 @@ export const mapLibreDriver = (map) => ({
366
388
  const pixels = context.getImageData(0, 0, bitmap.width, bitmap.height).data;
367
389
  return { ...tile, heights: heightsFrom(pixels, encoding), size: bitmap.width };
368
390
  })
369
- .catch(() => undefined);
391
+ .catch(() => undefined)
392
+ .then((tile) => {
393
+ if (tile === undefined && heights.get(key) === read) {
394
+ heights.delete(key);
395
+ }
396
+ return tile;
397
+ });
370
398
  if (heights.size >= 100) {
371
399
  heights.delete(heights.keys().next().value);
372
400
  }
373
- heights.set(url, read);
401
+ heights.set(key, read);
374
402
  return read;
375
403
  };
376
404
  const trace = async () => {
@@ -411,10 +439,13 @@ export const mapLibreDriver = (map) => ({
411
439
  const known = new Map(read.filter((tile) => tile !== undefined).map((tile) => [`${tile.x}/${tile.y}`, tile]));
412
440
  const traced = [];
413
441
  for (const tile of known.values()) {
414
- const key = `${spec.src}|${tile.z}/${tile.x}/${tile.y}|${interval}|${unit}`;
442
+ const across = 2 ** tile.z;
443
+ const beside = [-1, 0, 1]
444
+ .flatMap((dy) => [-1, 0, 1].map((dx) => known.has(`${(tile.x + dx + across) % across}/${tile.y + dy}`) ? '1' : '0'))
445
+ .join('');
446
+ const key = `${spec.src}|${spec.encoding ?? ''}|${tile.z}/${tile.x}/${tile.y}|${interval}|${unit}|${beside}`;
415
447
  let found = lines.get(key);
416
448
  if (!found) {
417
- const across = 2 ** tile.z;
418
449
  found = contoursOf(tile, interval, unit, (dx, dy) => known.get(`${(tile.x + dx + across) % across}/${tile.y + dy}`));
419
450
  if (lines.size >= 200) {
420
451
  lines.delete(lines.keys().next().value);
@@ -1029,12 +1060,27 @@ export const mapLibreDriver = (map) => ({
1029
1060
  done();
1030
1061
  return () => undefined;
1031
1062
  }
1032
- const heard = () => {
1033
- map.off('load', heard);
1063
+ let finished = false;
1064
+ const finish = () => {
1065
+ if (finished) {
1066
+ return;
1067
+ }
1068
+ finished = true;
1069
+ stop();
1034
1070
  done();
1035
1071
  };
1036
- map.on('load', heard);
1037
- return () => map.off('load', heard);
1072
+ const drawn = () => {
1073
+ if (map.isStyleLoaded?.() === true && map.areTilesLoaded?.() === true) {
1074
+ finish();
1075
+ }
1076
+ };
1077
+ const stop = () => {
1078
+ map.off('load', finish);
1079
+ map.off('render', drawn);
1080
+ };
1081
+ map.on('load', finish);
1082
+ map.on('render', drawn);
1083
+ return stop;
1038
1084
  },
1039
1085
  rotate: ({ by, to }, motion) => {
1040
1086
  const { bearing, pitch } = aimFrom(map);
@@ -57,7 +57,13 @@ const SIGNS = {
57
57
  'us-interstate': 'interstate',
58
58
  'us-state': 'state',
59
59
  };
60
- const idOf = (color) => color.replace(/[^0-9a-z]/gi, '');
60
+ const stamp = (drawn) => {
61
+ let hash = 0x811c9dc5;
62
+ for (const char of JSON.stringify(drawn)) {
63
+ hash = Math.imul(hash ^ (char.codePointAt(0) ?? 0), 0x01000193) >>> 0;
64
+ }
65
+ return hash.toString(36);
66
+ };
61
67
  const widen = (from, to) => [
62
68
  'interpolate',
63
69
  ['exponential', 1.5],
@@ -147,13 +153,14 @@ export const mapLibreStyle = (colors, options = {}) => {
147
153
  const landmarkColor = ['match', ['get', 'class']];
148
154
  for (const { classes, group, hue, icon } of LANDMARKS) {
149
155
  const color = cssColor(hued(hue));
150
- const id = `helix-landmark-${group}-${idOf(color)}`;
151
- images[id] = {
156
+ const image = {
152
157
  color,
153
158
  icon: { content: icon.content, viewBox: icon.viewBox },
154
159
  ink: halo,
155
160
  kind: 'landmark',
156
161
  };
162
+ const id = `helix-landmark-${group}-${stamp(image)}`;
163
+ images[id] = image;
157
164
  landmarkImage.push([...classes], id);
158
165
  landmarkColor.push([...classes], legible(hued(hue), 0));
159
166
  }
@@ -161,27 +168,30 @@ export const mapLibreStyle = (colors, options = {}) => {
161
168
  landmarkColor.push(legible(land, 0.6));
162
169
  const classesOf = (everyday) => LANDMARKS.filter((one) => (one.everyday === true) === everyday).flatMap(({ classes }) => classes);
163
170
  const signFont = face ? `"${face}", sans-serif` : 'sans-serif';
164
- images['helix-sign-interstate'] = {
171
+ const signs = `helix-sign-${stamp(signFont)}-`;
172
+ images[`${signs}interstate`] = {
165
173
  design: 'interstate',
166
174
  font: signFont,
167
175
  ink: '#ffffff',
168
176
  kind: 'sign',
169
177
  };
170
- images['helix-sign-us'] = { design: 'us', font: signFont, ink: '#1a1a1a', kind: 'sign' };
171
- images['helix-sign-state'] = { design: 'state', font: signFont, ink: '#1a1a1a', kind: 'sign' };
172
- const plate = { edge: toward(0.5), fill: halo };
173
- images[`helix-sign-plate-${idOf(plate.fill + plate.edge)}`] = {
174
- ...plate,
178
+ images[`${signs}us`] = { design: 'us', font: signFont, ink: '#1a1a1a', kind: 'sign' };
179
+ images[`${signs}state`] = { design: 'state', font: signFont, ink: '#1a1a1a', kind: 'sign' };
180
+ const plate = {
175
181
  design: 'plate',
182
+ edge: toward(0.5),
183
+ fill: halo,
176
184
  font: signFont,
177
185
  ink: cssColor(ink),
178
186
  kind: 'sign',
179
187
  };
188
+ const plateShape = `plate-${stamp(plate)}`;
189
+ images[`${signs}${plateShape}`] = plate;
180
190
  const signShape = ['match', ['get', 'network']];
181
191
  for (const [network, shape] of Object.entries(SIGNS)) {
182
192
  signShape.push(network, shape);
183
193
  }
184
- signShape.push(`plate-${idOf(plate.fill + plate.edge)}`);
194
+ signShape.push(plateShape);
185
195
  return {
186
196
  ...(glyphs ? { glyphs } : {}),
187
197
  layers: [
@@ -355,7 +365,7 @@ export const mapLibreStyle = (colors, options = {}) => {
355
365
  ],
356
366
  id: 'label-shield',
357
367
  layout: {
358
- 'icon-image': ['concat', 'helix-sign-', signShape, '|', ['get', 'ref']],
368
+ 'icon-image': ['concat', signs, signShape, '|', ['get', 'ref']],
359
369
  'icon-rotation-alignment': 'viewport',
360
370
  'symbol-placement': 'line',
361
371
  'symbol-spacing': 320,
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.110.1",
4
+ "version": "0.110.3",
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.110.1",
47
- "@fusion.dev/helix-icons": "0.110.1",
46
+ "@fusion.dev/helix-core": "0.110.3",
47
+ "@fusion.dev/helix-icons": "0.110.3",
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.110.1",
53
- "@fusion.dev/helix-testing": "0.110.1",
52
+ "@fusion.dev/helix-headless": "0.110.3",
53
+ "@fusion.dev/helix-testing": "0.110.3",
54
54
  "@maplibre/maplibre-gl-style-spec": "^26.4.4",
55
55
  "@types/bun": "^1.3.0",
56
56
  "@types/leaflet": "^1.9.20",