@kahwee/sf-map-svg 3.0.0 → 3.1.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/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 3.1.0 — 2026-09-28
8
+
9
+ - Add `createGuideController` and `mountGuideController` with the grouped options, camera, subscriptions, and lifecycle of `createMap`; preserve existing element-based guide factories.
10
+ - Reject invalid marker coordinates before static rendering invokes district-style callbacks.
11
+ - Prepare district styles once per update before changing the map, and reuse projected district paths.
12
+ - Track both district crossfades so interrupted transitions, reduced motion, and teardown remove all outgoing layers.
13
+ - Reuse label nodes and screen-space text measurements during camera movement, refreshing metrics when fonts load; separate label rendering and district appearance from the interactive engine.
14
+ - Give marker visuals and entrance animations their own lifecycle, and prevent reentrant district callbacks from overwriting newer state or restarting work after destruction.
15
+ - Document guide-controller integration and district-style refresh semantics; add browser regressions for callback atomicity, interrupted fades, camera motion, marker replacement, label reuse, and progressive shell mounting.
16
+
17
+ ## 3.0.1 — 2026-09-27
18
+
19
+ - Narrow the complete-data presets to their direct geographic modules without changing rendered SVGs or public API behavior.
20
+ - Correct stale contributing and example documentation links, add a local Markdown link check to `pnpm check`, and show the v3 controller API on the Pages homepage.
21
+ - Skip the early push-triggered Pages build while a new package version is still processing on npm; the successful release workflow deploys that version from the registry.
22
+ - Serve local visual snapshots with the same streaming Node HTTP approach as the Pages browser check, avoiding intermittent resets while loading large optional map data.
23
+
7
24
  ## 3.0.0 — 2026-09-27
8
25
 
9
26
  Version 3 removes the deprecated compatibility APIs. Applications must migrate before upgrading. The data-free root API introduced in version 2 remains the supported API.
package/README.md CHANGED
@@ -16,16 +16,16 @@ Node 22.12+ is required for server rendering. Browser maps need a DOM and a bund
16
16
 
17
17
  ```ts
18
18
  import { renderMap } from '@kahwee/sf-map-svg';
19
- import { fullMapData } from '@kahwee/sf-map-svg/data/full';
19
+ import { staticMapData } from '@kahwee/sf-map-svg/data/static';
20
20
 
21
- const { svg, project, viewBox } = renderMap(fullMapData.map, {
21
+ const { svg, project, viewBox } = renderMap(staticMapData, {
22
22
  year: 2022,
23
23
  landmarks: true,
24
24
  bartStations: true,
25
25
  });
26
26
  ```
27
27
 
28
- `renderMap(data, options)` returns SVG markup and matching projection helpers. Import `/data/full` only when all packaged geography is needed. For static rendering without interactive lookup collections, import `staticMapData` from `/data/static`. Small maps can compose selected JSON exports from `/data/*` and pass them as `StaticMapData`. `getLayerPaths(data, options)` returns fitted geographic paths without SVG markup.
28
+ `renderMap(data, options)` returns SVG markup and matching projection helpers. `/data/static` includes the complete static map without interactive lookup collections. Small maps can compose selected JSON exports from `/data/*` and pass them as `StaticMapData`. `getLayerPaths(data, options)` returns fitted geographic paths without SVG markup.
29
29
 
30
30
  Static options include `theme`, `width`, `height`, `padding`, `year` (2002, 2012, 2022), `districtLines`, `districtFills`, `districtStyle`, `districtLabels`, `neighborhoodLines`, `labels`, `highways`, `keyRoads`, `roadLabels`, `landmarks`, `bartStations`, `markers`, `overlays`, `title`, `idPrefix`, and `colors`. Each optional layer is independent. User-supplied text and attributes are escaped in SVG output.
31
31
 
@@ -52,12 +52,16 @@ map.destroy();
52
52
 
53
53
  `createMap(data, options)` returns a controller. Use `map.element` for mounting, `map.configure({ features, layers, controls })` for runtime switches, `map.camera` for pan/zoom/fit/reset, and `map.on()` for typed events. Appearance is set at construction. SFAR realtor neighborhoods are the default when supplied; SF Find and analysis are explicit alternate sources. The `/data/full` preset includes all three collections and historical districts.
54
54
 
55
- Construction options include `mode` (`basemap`, `neighborhoods`, or `districts`), `source`, `neighborhood`, `year`, `labels`, `layers`, `controls`, `features`, `appearance`, `markers`, `overlays`, `legend`, `strings`, `attribution`, `fitPadding`, and touch navigation settings. `features` holds motion, marker entrances, selected marker rings, clustering, north arrow, and scale bar. `appearance` holds theme, color tokens, label and area styles, marker colors and sizes, and district styling. `layers` controls district fill/line/labels, neighborhood lines/labels, landmarks, BART, highways, key roads, and road labels. See the exported `MapOptions` type for exact values and the [Storybook examples](stories/) for live controls.
55
+ Construction options include `mode` (`basemap`, `neighborhoods`, or `districts`), `source`, `neighborhood`, `year`, `labels`, `layers`, `controls`, `features`, `appearance`, `markers`, `overlays`, `legend`, `strings`, `attribution`, and `fitPadding`. Enable map touch gestures with the touch control or `map.setTouchNavigation(true)`. `features` holds motion, marker entrances, selected marker rings, clustering, north arrow, and scale bar. `appearance` holds theme, color tokens, label and area styles, marker colors and sizes, and district styling. `layers` controls district fill/line/labels, neighborhood lines/labels, landmarks, BART, highways, key roads, and road labels. See the exported `MapOptions` type for exact values and the [Storybook examples](stories/) for live controls.
56
56
 
57
57
  The controller also supports marker, neighborhood, and district selection; district year and style changes; source and mode changes; labels and touch navigation; screen projection; and typed `markerchange`, `neighborhoodchange`, `districtchange`, `districthover`, `districtactivate`, `districtyearchange`, `overlayactivate`, `clusteractivate`, `viewportchange`, and `mapresize` events. `destroy()` releases browser resources; operations after destruction throw.
58
58
 
59
59
  For a small guide, import `guideMapData` from `/guide/data` and pass it to `createMap`. The optional `/guide` entry also provides `createGuideMap`, `mountGuideMap`, and detailed-data loading for existing guide layouts. `/transit` provides the standalone schematic transit animation. See [examples](docs/EXAMPLES.md) and [consumer integration](docs/consumer-integration.md).
60
60
 
61
+ For the guide preset with the same controller API, use `createGuideController(options)` from `/guide` (or `/guide/map`). It accepts grouped `MapOptions` and returns `MapController`; `mountGuideController(shell, options)` enhances a `createGuideShell()` container. Both use the lightweight guide geography. Existing `createGuideMap` and `mountGuideMap` calls retain their element-based API.
62
+
63
+ District style callbacks are evaluated once per district at construction and on style or year updates. Hover, selection, and layer toggles reuse the prepared styles; call `setDistrictStyle()` again when external styling data changes. Invalid styles leave the current map unchanged. District fades respect reduced motion and are removed on interruption or destruction.
64
+
61
65
  ## Data and development
62
66
 
63
67
  Canonical geography is in [`data/`](data/README.md), with provenance in [`SOURCES.md`](SOURCES.md). The public `/data` entry exposes lookup, catalog, district maps, and source-specific neighborhood collections. These are deeply frozen; clone before editing.
@@ -0,0 +1,15 @@
1
+ import type { DistrictRowData } from './map-core.js';
2
+ import type { DistrictStyle, DistrictYear, SFMapOptions } from './types.js';
3
+ /** Apply prepared styles and interaction state without invoking consumer callbacks. */
4
+ export declare function renderDistrictAppearance({ svg, rows, styles, selectedDistrict, hoveredDistrict, year, theme, colors, fills, lines, }: {
5
+ svg: SVGSVGElement;
6
+ rows: readonly DistrictRowData[];
7
+ styles: ReadonlyMap<number, DistrictStyle>;
8
+ selectedDistrict: number | null;
9
+ hoveredDistrict: number | null;
10
+ year: DistrictYear;
11
+ theme: SFMapOptions['theme'];
12
+ colors: NonNullable<SFMapOptions['colors']>;
13
+ fills: boolean;
14
+ lines: boolean;
15
+ }): void;
@@ -0,0 +1,51 @@
1
+ import { districtColors } from './map-core.js';
2
+ /** Apply prepared styles and interaction state without invoking consumer callbacks. */
3
+ export function renderDistrictAppearance({ svg, rows, styles, selectedDistrict, hoveredDistrict, year, theme, colors, fills, lines, }) {
4
+ for (const row of rows) {
5
+ const styled = styles.get(row.id);
6
+ const active = selectedDistrict === row.id;
7
+ const hovered = hoveredDistrict === row.id;
8
+ const fill = svg.querySelector(`[data-layer="district-fills"] [data-district="${row.id}"]`);
9
+ const line = svg.querySelector(`[data-layer="district-lines"] [data-district="${row.id}"]`);
10
+ if (fill) {
11
+ fill.setAttribute('fill', styled?.fill ??
12
+ (theme === 'transit' ? (colors.land ?? '#fcfcf8') : districtColors[row.id - 1]));
13
+ if (styled?.opacity === undefined)
14
+ fill.removeAttribute('fill-opacity');
15
+ else
16
+ fill.setAttribute('fill-opacity', String(styled.opacity));
17
+ fill.setAttribute('role', 'button');
18
+ fill.setAttribute('aria-label', `District ${row.id}, ${year}`);
19
+ fill.setAttribute('aria-pressed', String(active));
20
+ fill.setAttribute('tabindex', row.id === 1 ? '0' : '-1');
21
+ fill.style.cursor = 'pointer';
22
+ }
23
+ if (line) {
24
+ line.setAttribute('stroke', active
25
+ ? (colors.selected ?? '#f04f32')
26
+ : hovered
27
+ ? '#163e56'
28
+ : (styled?.stroke ?? colors.district ?? '#a7b8c0'));
29
+ line.setAttribute('stroke-width', active ? '3' : hovered ? '2.2' : '1.1');
30
+ const lineInteractive = !fills && lines;
31
+ line.style.pointerEvents = lineInteractive ? 'stroke' : 'none';
32
+ if (lineInteractive) {
33
+ line.setAttribute('role', 'button');
34
+ line.setAttribute('aria-label', `District ${row.id}, ${year}`);
35
+ line.setAttribute('aria-pressed', String(active));
36
+ line.setAttribute('tabindex', row.id === 1 ? '0' : '-1');
37
+ line.style.cursor = 'pointer';
38
+ }
39
+ else {
40
+ line.removeAttribute('role');
41
+ line.removeAttribute('aria-label');
42
+ line.removeAttribute('aria-pressed');
43
+ line.removeAttribute('tabindex');
44
+ }
45
+ if (styled?.opacity === undefined)
46
+ line.removeAttribute('stroke-opacity');
47
+ else
48
+ line.setAttribute('stroke-opacity', String(styled.opacity));
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,4 @@
1
+ import type { DistrictRowData } from './map-core.js';
2
+ import type { DistrictStyle, SFMapOptions } from './types.js';
3
+ /** Evaluate user code once and copy its result before changing the map. */
4
+ export declare function prepareDistrictStyles(rows: readonly DistrictRowData[], callback: SFMapOptions['districtStyle']): ReadonlyMap<number, DistrictStyle>;
@@ -0,0 +1,23 @@
1
+ /** Evaluate user code once and copy its result before changing the map. */
2
+ export function prepareDistrictStyles(rows, callback) {
3
+ if (callback !== undefined && typeof callback !== 'function')
4
+ throw new TypeError('districtStyle must be a function.');
5
+ return new Map(rows.map((row) => [row.id, validateDistrictStyle(callback?.(row))]));
6
+ }
7
+ function validateDistrictStyle(style) {
8
+ if (style === undefined)
9
+ return {};
10
+ if (!style || typeof style !== 'object' || Array.isArray(style))
11
+ throw new TypeError('districtStyle must return a style object.');
12
+ for (const key of Object.keys(style))
13
+ if (!['fill', 'stroke', 'opacity'].includes(key))
14
+ throw new TypeError(`Unknown district style: ${key}`);
15
+ const copy = { ...style };
16
+ for (const key of ['fill', 'stroke'])
17
+ if (copy[key] !== undefined && typeof copy[key] !== 'string')
18
+ throw new TypeError(`${key} must be a string.`);
19
+ if (copy.opacity !== undefined &&
20
+ (!Number.isFinite(copy.opacity) || copy.opacity < 0 || copy.opacity > 1))
21
+ throw new RangeError('District opacity must be between 0 and 1.');
22
+ return copy;
23
+ }
@@ -0,0 +1,5 @@
1
+ /** Own both district-layer fades and remove their inert copies synchronously on cancellation. */
2
+ export declare function createDistrictTransition(): {
3
+ cancel(): void;
4
+ fade(layer: SVGGElement, duration: number): void;
5
+ };
@@ -0,0 +1,38 @@
1
+ /** Own both district-layer fades and remove their inert copies synchronously on cancellation. */
2
+ export function createDistrictTransition() {
3
+ const active = new Map();
4
+ const remove = (animation, node) => {
5
+ active.delete(animation);
6
+ node.remove();
7
+ };
8
+ return {
9
+ cancel() {
10
+ for (const [animation, node] of active) {
11
+ animation.cancel();
12
+ node.remove();
13
+ }
14
+ active.clear();
15
+ },
16
+ fade(layer, duration) {
17
+ if (!duration)
18
+ return;
19
+ const old = layer.cloneNode(true);
20
+ old.dataset.districtTransition = '';
21
+ old.setAttribute('aria-hidden', 'true');
22
+ old.style.pointerEvents = 'none';
23
+ for (const path of old.querySelectorAll('[data-district]')) {
24
+ path.removeAttribute('tabindex');
25
+ path.removeAttribute('role');
26
+ path.removeAttribute('aria-label');
27
+ path.removeAttribute('aria-pressed');
28
+ }
29
+ layer.after(old);
30
+ const animation = old.animate([{ opacity: 1 }, { opacity: 0 }], {
31
+ duration,
32
+ easing: 'ease-out',
33
+ });
34
+ active.set(animation, old);
35
+ animation.finished.then(() => remove(animation, old), () => remove(animation, old));
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,2 @@
1
+ export declare function element<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, className?: string): HTMLElementTagNameMap[K];
2
+ export declare function svgElement<K extends keyof SVGElementTagNameMap>(tag: K, attributes?: Record<string, string | number>): SVGElementTagNameMap[K];
@@ -0,0 +1,14 @@
1
+ export function element(tag, text, className) {
2
+ const node = document.createElement(tag);
3
+ if (text)
4
+ node.textContent = text;
5
+ if (className)
6
+ node.className = className;
7
+ return node;
8
+ }
9
+ export function svgElement(tag, attributes = {}) {
10
+ const node = document.createElementNS('http://www.w3.org/2000/svg', tag);
11
+ for (const [key, value] of Object.entries(attributes))
12
+ node.setAttribute(key, String(value));
13
+ return node;
14
+ }
@@ -1,13 +1,18 @@
1
1
  import { createCamera, validateCameraOptions } from './camera.js';
2
2
  import { clusterPoints } from './clusters.js';
3
- import { fitBounds, interiorAnchor, layoutLabels, projectedBounds } from './explorer-layout.js';
3
+ import { renderDistrictAppearance } from './district-layer.js';
4
+ import { prepareDistrictStyles } from './district-style.js';
5
+ import { createDistrictTransition } from './district-transition.js';
6
+ import { element, svgElement } from './dom.js';
7
+ import { fitBounds, interiorAnchor, projectedBounds } from './explorer-layout.js';
4
8
  import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from './features.js';
5
9
  import { geometryPath } from './geometry.js';
10
+ import { createLabelRenderer } from './label-renderer.js';
6
11
  import { createSFMapWithData, districtColors, getLayerPathsWithData } from './map-core.js';
12
+ import { createMarkerLayer } from './marker-layer.js';
7
13
  import { attachNavigation } from './navigation.js';
8
14
  import { validateExplorerOptions, validateMarkers, validateOverlays } from './validation.js';
9
15
  import { fitViewport, validateViewport } from './viewport.js';
10
- const svgNS = 'http://www.w3.org/2000/svg';
11
16
  const sourceNames = {
12
17
  realtor: 'SFAR realtor · 92 areas',
13
18
  'sf-find': 'SF Find · 117 areas',
@@ -20,20 +25,6 @@ const normalizeName = (value) => value
20
25
  .replace(/[^a-z0-9]+/g, ' ')
21
26
  .trim();
22
27
  const formatSourceLabel = (source) => sourceNames[source] ?? `${source} neighborhoods`;
23
- function element(tag, text, className) {
24
- const node = document.createElement(tag);
25
- if (text)
26
- node.textContent = text;
27
- if (className)
28
- node.className = className;
29
- return node;
30
- }
31
- function svgElement(tag, attributes = {}) {
32
- const node = document.createElementNS(svgNS, tag);
33
- for (const [key, value] of Object.entries(attributes))
34
- node.setAttribute(key, String(value));
35
- return node;
36
- }
37
28
  let explorerCount = 0;
38
29
  /** Create an offline, browser-only neighborhood explorer. Call destroy() before disposal. */
39
30
  export function createNeighborhoodExplorerCore(options = {}, data) {
@@ -314,7 +305,6 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
314
305
  districtLabels: false,
315
306
  districtLines: true,
316
307
  districtFills: true,
317
- districtStyle: initialDistrictStyle,
318
308
  highways: true,
319
309
  keyRoads: true,
320
310
  roadLabels: false,
@@ -380,13 +370,16 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
380
370
  'aria-hidden': 'true',
381
371
  'font-family': labelStyle.fontFamily ?? styleOptions.font ?? 'system-ui,sans-serif',
382
372
  });
373
+ const labelRenderer = createLabelRenderer(labelLayer);
383
374
  geography.append(areas, stations, labelLayer, overlayLayer, markerLayer);
384
375
  let view = [0, 0, 800];
385
376
  let selected;
386
377
  let selectedDistrict = null;
387
378
  let hoveredDistrict = null;
388
379
  let districtStyle = initialDistrictStyle;
389
- let districtTransition;
380
+ let districtStyles = prepareDistrictStyles(data.map.districts?.[year] ?? [], districtStyle);
381
+ const districtTransition = createDistrictTransition();
382
+ let districtRevision = 0;
390
383
  root.dataset.year = String(year);
391
384
  function districtRow(id) {
392
385
  return data.map.districts?.[year]?.find((district) => district.id === id);
@@ -403,54 +396,18 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
403
396
  : districtSelection(selectedDistrict);
404
397
  }
405
398
  function updateDistrictAppearance() {
406
- const rows = data.map.districts?.[year] ?? [];
407
- for (const row of rows) {
408
- const styled = districtStyle?.(row);
409
- const active = selectedDistrict === row.id;
410
- const hovered = hoveredDistrict === row.id;
411
- const fill = svg.querySelector(`[data-layer="district-fills"] [data-district="${row.id}"]`);
412
- const line = svg.querySelector(`[data-layer="district-lines"] [data-district="${row.id}"]`);
413
- if (fill) {
414
- fill.setAttribute('fill', styled?.fill ??
415
- (theme === 'transit' ? (colors.land ?? '#fcfcf8') : districtColors[row.id - 1]));
416
- if (styled?.opacity === undefined)
417
- fill.removeAttribute('fill-opacity');
418
- else
419
- fill.setAttribute('fill-opacity', String(styled.opacity));
420
- fill.setAttribute('role', 'button');
421
- fill.setAttribute('aria-label', `District ${row.id}, ${year}`);
422
- fill.setAttribute('aria-pressed', String(active));
423
- fill.setAttribute('tabindex', row.id === 1 ? '0' : '-1');
424
- fill.style.cursor = 'pointer';
425
- }
426
- if (line) {
427
- line.setAttribute('stroke', active
428
- ? (colors.selected ?? '#f04f32')
429
- : hovered
430
- ? '#163e56'
431
- : (styled?.stroke ?? colors.district ?? '#a7b8c0'));
432
- line.setAttribute('stroke-width', active ? '3' : hovered ? '2.2' : '1.1');
433
- const lineInteractive = !enabled('districtFills') && enabled('districtLines');
434
- line.style.pointerEvents = lineInteractive ? 'stroke' : 'none';
435
- if (lineInteractive) {
436
- line.setAttribute('role', 'button');
437
- line.setAttribute('aria-label', `District ${row.id}, ${year}`);
438
- line.setAttribute('aria-pressed', String(active));
439
- line.setAttribute('tabindex', row.id === 1 ? '0' : '-1');
440
- line.style.cursor = 'pointer';
441
- }
442
- else {
443
- line.removeAttribute('role');
444
- line.removeAttribute('aria-label');
445
- line.removeAttribute('aria-pressed');
446
- line.removeAttribute('tabindex');
447
- }
448
- if (styled?.opacity === undefined)
449
- line.removeAttribute('stroke-opacity');
450
- else
451
- line.setAttribute('stroke-opacity', String(styled.opacity));
452
- }
453
- }
399
+ renderDistrictAppearance({
400
+ svg,
401
+ rows: data.map.districts?.[year] ?? [],
402
+ styles: districtStyles,
403
+ selectedDistrict,
404
+ hoveredDistrict,
405
+ year,
406
+ theme,
407
+ colors,
408
+ fills: enabled('districtFills'),
409
+ lines: enabled('districtLines'),
410
+ });
454
411
  }
455
412
  function selectDistrict(id, options = {}) {
456
413
  if (destroyed)
@@ -459,6 +416,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
459
416
  if (id !== null && (!Number.isInteger(id) || !districtRow(id)))
460
417
  return false;
461
418
  const changed = selectedDistrict !== id;
419
+ const revision = ++districtRevision;
462
420
  selectedDistrict = id;
463
421
  if (id === null)
464
422
  delete root.dataset.selectedDistrict;
@@ -468,6 +426,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
468
426
  const selectedRow = id === null ? undefined : districtRow(id);
469
427
  if (selectedRow && options.fit)
470
428
  fitGeometry(selectedRow.geometry, undefined, options);
429
+ if (destroyed || revision !== districtRevision)
430
+ return true;
471
431
  status.textContent =
472
432
  id === null ? 'District selection cleared.' : `District ${id} selected, ${year} boundaries.`;
473
433
  if (changed)
@@ -477,10 +437,13 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
477
437
  function setDistrictStyle(style) {
478
438
  if (destroyed)
479
439
  return;
480
- if (style !== undefined && typeof style !== 'function')
481
- throw new TypeError('districtStyle must be a function.');
482
- createSFMapWithData({ year, districtStyle: style, districtLabels: false }, data.map);
440
+ const revision = districtRevision;
441
+ const prepared = prepareDistrictStyles(data.map.districts?.[year] ?? [], style);
442
+ if (destroyed || revision !== districtRevision)
443
+ return;
444
+ districtRevision++;
483
445
  districtStyle = style;
446
+ districtStyles = prepared;
484
447
  updateDistrictAppearance();
485
448
  }
486
449
  function setDistrictYear(next, options = {}) {
@@ -493,28 +456,17 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
493
456
  throw new RangeError(`No ${next} district dataset was supplied.`);
494
457
  if (next === year)
495
458
  return;
496
- const geometry = getLayerPathsWithData({ year: next }, data.map);
497
- createSFMapWithData({ year: next, districtStyle, districtLabels: false }, data.map);
459
+ const revision = districtRevision;
460
+ const prepared = prepareDistrictStyles(data.map.districts[next], districtStyle);
461
+ if (destroyed || revision !== districtRevision)
462
+ return;
463
+ const committedRevision = ++districtRevision;
464
+ const geometry = getLayerPathsWithData({ year: next }, data.map, false);
498
465
  const previousYear = year;
499
- districtTransition?.cancel();
466
+ districtTransition.cancel();
500
467
  for (const layer of districtLayers) {
501
- const old = options.animate && !reducedMotion.matches ? layer.cloneNode(true) : null;
502
- if (old) {
503
- old.setAttribute('aria-hidden', 'true');
504
- old.style.pointerEvents = 'none';
505
- for (const path of old.querySelectorAll('[data-district]')) {
506
- path.removeAttribute('tabindex');
507
- path.removeAttribute('role');
508
- path.removeAttribute('aria-label');
509
- path.removeAttribute('aria-pressed');
510
- }
511
- layer.after(old);
512
- districtTransition = old.animate([{ opacity: 1 }, { opacity: 0 }], {
513
- duration: options.duration ?? 280,
514
- easing: 'ease-out',
515
- });
516
- districtTransition.finished.then(() => old.remove()).catch(() => old.remove());
517
- }
468
+ if (options.animate && !reducedMotion.matches)
469
+ districtTransition.fade(layer, options.duration ?? 280);
518
470
  layer.replaceChildren(...geometry.districts.map((district) => svgElement('path', {
519
471
  'data-district': district.id,
520
472
  d: district.path,
@@ -524,6 +476,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
524
476
  })));
525
477
  }
526
478
  year = next;
479
+ districtStyles = prepared;
527
480
  hoveredDistrict = null;
528
481
  root.dataset.year = String(year);
529
482
  svg.dataset.year = String(year);
@@ -541,7 +494,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
541
494
  scheduleLabels();
542
495
  status.textContent = `${year} supervisorial districts.`;
543
496
  root.dispatchEvent(new CustomEvent('districtyearchange', { bubbles: true, detail: { year, previousYear } }));
544
- if (selectedDistrict !== null)
497
+ if (!destroyed && committedRevision === districtRevision && selectedDistrict !== null)
545
498
  root.dispatchEvent(new CustomEvent('districtchange', {
546
499
  bubbles: true,
547
500
  detail: districtSelection(selectedDistrict),
@@ -552,12 +505,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
552
505
  let destroyed = false;
553
506
  let initialized = false;
554
507
  let frame = 0;
555
- const entranceAnimations = new Set();
556
- function cancelEntrances() {
557
- for (const animation of entranceAnimations)
558
- animation.cancel();
559
- entranceAnimations.clear();
560
- }
508
+ const markerRenderer = createMarkerLayer(markerLayer);
509
+ const cancelEntrances = markerRenderer.cancelEntrances;
561
510
  const camera = createCamera({
562
511
  read: getViewport,
563
512
  write: setView,
@@ -858,7 +807,6 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
858
807
  const width = canvas.getBoundingClientRect().width;
859
808
  if (!width)
860
809
  return;
861
- labelLayer.replaceChildren();
862
810
  const unit = view[2] / width, zoom = 800 / view[2];
863
811
  for (const road of svg.querySelectorAll('[data-key-road-level="secondary"]'))
864
812
  road.style.display = zoom >= 1.8 ? '' : 'none';
@@ -884,6 +832,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
884
832
  scale.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`;
885
833
  }
886
834
  if (!labels) {
835
+ labelRenderer.clear();
887
836
  root.dataset.visibleLabels = '0';
888
837
  return;
889
838
  }
@@ -919,13 +868,14 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
919
868
  name: item.feature.properties.canonicalName,
920
869
  kind: 'neighborhood',
921
870
  })));
922
- const measured = candidates.map((item) => {
871
+ const labelCandidates = candidates.map((item) => {
923
872
  const fontSize = item.kind === 'road'
924
873
  ? Math.max(minLabelSize, Math.min(11, maxLabelSize))
925
874
  : Math.max(minLabelSize, Math.min(maxLabelSize, 12));
926
- const node = svgElement('text', {
927
- 'font-size': fontSize * unit,
928
- 'font-weight': labelStyle.fontWeight ??
875
+ return {
876
+ ...item,
877
+ fontSize,
878
+ fontWeight: labelStyle.fontWeight ??
929
879
  (item.kind.startsWith('selected') || item.kind === 'district' ? 700 : 550),
930
880
  fill: colors.label ??
931
881
  (item.kind === 'park'
@@ -933,22 +883,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
933
883
  : item.kind === 'road'
934
884
  ? '#77736b'
935
885
  : '#163d61'),
936
- stroke: labelStyle.haloColor ?? '#ffffff',
937
- 'stroke-width': 3 * unit,
938
- 'stroke-linejoin': 'round',
939
- 'paint-order': 'stroke',
940
- 'data-label-kind': item.kind,
941
- });
942
- node.textContent = item.name;
943
- labelLayer.append(node);
944
- return {
945
- ...item,
946
- node,
947
- x: (item.point[0] - view[0]) / unit,
948
- y: (item.point[1] - view[1]) / unit,
949
- textWidth: node.getComputedTextLength() / unit,
950
- fontSize,
951
- textHeight: fontSize * 1.25,
886
+ halo: labelStyle.haloColor ?? '#ffffff',
952
887
  offset: item.kind === 'selected-marker'
953
888
  ? (marker?.marker.radius ?? markerRadius) + 9
954
889
  : item.kind === 'bart'
@@ -980,19 +915,11 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
980
915
  box.bottom - canvasBox.top + 3,
981
916
  ];
982
917
  });
983
- const placed = layoutLabels(measured, width, width, [
918
+ root.dataset.visibleLabels = String(labelRenderer.draw(labelCandidates, view, width, [
984
919
  ...stationBounds,
985
920
  ...markerBounds,
986
921
  ...furnitureBounds,
987
- ]);
988
- for (const item of measured)
989
- item.node.remove();
990
- for (const item of placed) {
991
- item.node.setAttribute('x', String(view[0] + item.left * unit));
992
- item.node.setAttribute('y', String(view[1] + (item.top + item.fontSize) * unit));
993
- labelLayer.append(item.node);
994
- }
995
- root.dataset.visibleLabels = String(placed.length);
922
+ ]));
996
923
  }
997
924
  function releaseDownloads() {
998
925
  for (const url of downloads)
@@ -1376,65 +1303,22 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1376
1303
  .id;
1377
1304
  invalidateClusters();
1378
1305
  const previousIds = new Set(markerItems.map((item) => item.marker.id));
1379
- for (const animation of entranceAnimations)
1380
- animation.cancel();
1381
- entranceAnimations.clear();
1306
+ markerRenderer.clear();
1382
1307
  const previous = selectedMarker;
1383
- markerLayer.replaceChildren();
1384
1308
  markerSelect.replaceChildren(element('option', 'No marker selected'));
1385
1309
  markerSelect.options[0].value = '';
1386
1310
  markerItems = next.map(({ marker, point }, index) => {
1387
- const node = svgElement('g', {
1388
- transform: `translate(${point[0]},${point[1]})`,
1389
- 'data-marker-id': marker.id,
1390
- role: 'button',
1391
- tabindex: 0,
1392
- 'aria-label': marker.label ?? marker.id,
1393
- 'aria-pressed': 'false',
1394
- });
1395
- const hit = svgElement('circle', { fill: 'transparent', 'pointer-events': 'all' });
1396
- const dot = svgElement('circle', {
1397
- fill: marker.color ?? markerColor,
1398
- stroke: '#fff9e9',
1399
- 'stroke-width': 2,
1400
- 'vector-effect': 'non-scaling-stroke',
1401
- 'pointer-events': 'none',
1402
- });
1403
- const title = svgElement('title');
1404
- title.textContent = marker.label ?? marker.id;
1405
- const ring = svgElement('circle', {
1406
- fill: 'none',
1407
- stroke: features.selectedMarkerRing
1408
- ? (features.selectedMarkerRing.color ?? selectedMarkerColor)
1409
- : selectedMarkerColor,
1410
- 'stroke-width': features.selectedMarkerRing ? features.selectedMarkerRing.width : 2,
1411
- 'vector-effect': 'non-scaling-stroke',
1412
- 'pointer-events': 'none',
1413
- display: 'none',
1311
+ const item = markerRenderer.add(marker, point, index, {
1312
+ features,
1313
+ markerColor,
1314
+ selectedMarkerColor,
1315
+ reducedMotion: reducedMotion.matches,
1316
+ enter: !previousIds.has(marker.id),
1414
1317
  });
1415
- node.style.setProperty('--sf-marker-index', String(index));
1416
- node.append(title, hit, ring, dot);
1417
- if (features.markerEntrance &&
1418
- !previousIds.has(marker.id) &&
1419
- !reducedMotion.matches &&
1420
- typeof dot.animate === 'function') {
1421
- const animation = dot.animate([
1422
- { opacity: 0, transform: 'translateY(-12px)' },
1423
- { opacity: 1, transform: 'translateY(0)' },
1424
- ], {
1425
- duration: features.markerEntrance.duration,
1426
- delay: Math.min(index * features.markerEntrance.stagger, 1000),
1427
- easing: 'cubic-bezier(.2,.8,.2,1)',
1428
- fill: 'backwards',
1429
- });
1430
- entranceAnimations.add(animation);
1431
- animation.onfinish = () => entranceAnimations.delete(animation);
1432
- }
1433
- markerLayer.append(node);
1434
1318
  const option = element('option', marker.label ?? marker.id);
1435
1319
  option.value = marker.id;
1436
1320
  markerSelect.append(option);
1437
- return { marker, point, node, dot, hit, ring };
1321
+ return item;
1438
1322
  });
1439
1323
  markerLabel.hidden = !markerItems.length || controls.markerPicker === false;
1440
1324
  if (markerLabel.firstChild)
@@ -1707,6 +1591,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1707
1591
  destroy() {
1708
1592
  destroyed = true;
1709
1593
  camera.destroy();
1594
+ districtTransition.cancel();
1710
1595
  cancelEntrances();
1711
1596
  controller.abort();
1712
1597
  overlayEvents.abort();
@@ -1714,16 +1599,21 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1714
1599
  canvas.style.touchAction = 'pan-y pinch-zoom';
1715
1600
  observer?.disconnect();
1716
1601
  cancelAnimationFrame(frame);
1602
+ labelRenderer.clear();
1717
1603
  releaseDownloads();
1718
1604
  },
1719
1605
  });
1720
1606
  try {
1721
1607
  observer = new ResizeObserver(onResize);
1722
1608
  observer.observe(canvas);
1723
- document.fonts?.addEventListener('loadingdone', scheduleLabels, { signal: controller.signal });
1609
+ document.fonts?.addEventListener('loadingdone', () => {
1610
+ labelRenderer.invalidateMetrics();
1611
+ scheduleLabels();
1612
+ }, { signal: controller.signal });
1724
1613
  reducedMotion.addEventListener('change', () => {
1725
1614
  if (reducedMotion.matches) {
1726
1615
  stopAnimation();
1616
+ districtTransition.cancel();
1727
1617
  cancelEntrances();
1728
1618
  }
1729
1619
  }, { signal: controller.signal });