@kahwee/sf-map-svg 4.0.0 → 4.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/README.md +7 -9
  3. package/dist/data/guide/coast.json +1 -1
  4. package/dist/data/guide/landmarks.json +1 -1
  5. package/dist/data/guide/neighborhoods-realtor.json +1 -1
  6. package/dist/data/stations.d.ts +2 -2
  7. package/dist/data/types.d.ts +8 -1
  8. package/dist/src/api.d.ts +1 -1
  9. package/dist/src/api.js +1 -1
  10. package/dist/src/configuration.js +18 -2
  11. package/dist/src/controller-types.d.ts +1 -0
  12. package/dist/src/district-morph.d.ts +17 -0
  13. package/dist/src/district-morph.js +148 -0
  14. package/dist/src/explorer-core.js +143 -46
  15. package/dist/src/features.d.ts +6 -0
  16. package/dist/src/features.js +11 -1
  17. package/dist/src/frame-scheduler.d.ts +11 -0
  18. package/dist/src/frame-scheduler.js +64 -0
  19. package/dist/src/layer-transition.d.ts +18 -0
  20. package/dist/src/layer-transition.js +102 -0
  21. package/dist/src/layers.d.ts +9 -7
  22. package/dist/src/layers.js +28 -18
  23. package/dist/src/map-core.js +7 -4
  24. package/dist/src/marker-layer.d.ts +3 -0
  25. package/dist/src/marker-layer.js +27 -5
  26. package/dist/src/overlays.d.ts +3 -4
  27. package/dist/src/overlays.js +5 -1
  28. package/dist/src/static-animation.d.ts +13 -0
  29. package/dist/src/static-animation.js +36 -0
  30. package/dist/src/transit.js +2 -1
  31. package/dist/src/types.d.ts +26 -0
  32. package/dist/src/validation.js +2 -0
  33. package/docs/API.md +33 -0
  34. package/docs/api-audit.md +2 -0
  35. package/docs/consumer-integration.md +1 -1
  36. package/docs/developer-guide.md +137 -0
  37. package/docs/guide-bundle-report.md +4 -4
  38. package/llms-full.txt +1844 -0
  39. package/llms.txt +25 -0
  40. package/package.json +10 -4
@@ -1,4 +1,4 @@
1
- import type { FeatureCollection } from './types.js';
2
- export declare const bartStations: FeatureCollection<{
1
+ import type { PointFeatureCollection } from './types.js';
2
+ export declare const bartStations: PointFeatureCollection<{
3
3
  readonly name: string;
4
4
  }>;
@@ -60,6 +60,13 @@ export interface FeatureCollection<P = Readonly<Record<string, unknown>>> {
60
60
  };
61
61
  readonly features: readonly Feature<P>[];
62
62
  }
63
+ export type PointFeatureCollection<P = Readonly<Record<string, unknown>>> = Omit<FeatureCollection<P>, 'features'> & {
64
+ readonly features: readonly (Feature<P> & {
65
+ readonly geometry: Extract<Geometry, {
66
+ readonly type: 'Point';
67
+ }>;
68
+ })[];
69
+ };
63
70
  export type NeighborhoodSource = 'sf-find' | 'analysis' | 'realtor';
64
71
  export interface NeighborhoodProperties {
65
72
  readonly name: string;
@@ -69,7 +76,7 @@ export interface NeighborhoodProperties {
69
76
  readonly definitionSource: NeighborhoodSource;
70
77
  readonly nameSources: readonly string[];
71
78
  readonly note?: string;
72
- readonly sourceCode?: string;
79
+ readonly sourceCode?: string | null;
73
80
  readonly realtorDistrict?: string;
74
81
  }
75
82
  export interface NeighborhoodEntry extends NeighborhoodProperties {
package/dist/src/api.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- /** v2 root: runtime and types only. Geography is always an explicit import. */
1
+ /** Runtime and types only. Geography is always an explicit import. */
2
2
  export * from './map.js';
3
3
  export * from './static.js';
package/dist/src/api.js CHANGED
@@ -1,3 +1,3 @@
1
- /** v2 root: runtime and types only. Geography is always an explicit import. */
1
+ /** Runtime and types only. Geography is always an explicit import. */
2
2
  export * from './map.js';
3
3
  export * from './static.js';
@@ -20,6 +20,8 @@ const featureKeys = [
20
20
  'clustering',
21
21
  'northArrow',
22
22
  'scaleBar',
23
+ 'layerTransitions',
24
+ 'districtMorph',
23
25
  ];
24
26
  export function expandMapOptions(options) {
25
27
  const record = assertOptions(options, 'map');
@@ -30,8 +32,22 @@ export function expandMapOptions(options) {
30
32
  'onMarkerActivate',
31
33
  'onOverlayActivate',
32
34
  ])
33
- if (key in record)
34
- throw new TypeError(`Use the v2 grouped options or map.on() instead of ${key}.`);
35
+ if (key in record) {
36
+ const replacement = appearanceKeys.includes(key)
37
+ ? `appearance.${key}`
38
+ : featureKeys.includes(key)
39
+ ? `features.${key}`
40
+ : key === 'onMarkerActivate'
41
+ ? "map.on('markerchange', listener)"
42
+ : key === 'onOverlayActivate'
43
+ ? "map.on('overlayactivate', listener)"
44
+ : null;
45
+ throw new TypeError(key === 'onMarkerActivate'
46
+ ? `Marker activation callbacks are not supported; use ${replacement} for selection changes.`
47
+ : replacement
48
+ ? `Use ${replacement} instead of the flat ${key} option.`
49
+ : 'interface is internal and is not a supported createMap option.');
50
+ }
35
51
  if (options.appearance !== undefined)
36
52
  assertOptions(options.appearance, 'appearance', appearanceKeys);
37
53
  if (options.features !== undefined)
@@ -75,6 +75,7 @@ export interface MapController {
75
75
  configure(patch: MapConfiguration): void;
76
76
  getConfiguration(): MapConfigurationSnapshot;
77
77
  on<K extends keyof MapEvents>(type: K, listener: (detail: MapEvents[K]) => void): () => void;
78
+ /** Reconcile stable IDs; retained markers preserve nodes, focus, and entrance animations. */
78
79
  setMarkers(markers: readonly MapMarker[]): void;
79
80
  setOverlays(overlays: readonly MapOverlay[]): void;
80
81
  selectMarker(id: string | null, options?: CameraOptions & {
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Morph district outlines between two map years. Matching rings are resampled and aligned;
3
+ * a ring without a partner grows from, or shrinks into, its partner's center. The real
4
+ * district layers stay the exact year snapshots; this only draws a decorative overlay.
5
+ */
6
+ export declare function createDistrictMorph(): {
7
+ stop: () => void;
8
+ /**
9
+ * `previous` maps district id to its old outline path; the new outlines are read from
10
+ * `layer`, which stays hidden until the morph settles into it.
11
+ */
12
+ start(layer: SVGGElement, previous: ReadonlyMap<string, string>, { duration, stroke }: {
13
+ duration: number;
14
+ stroke: string;
15
+ }): void;
16
+ readonly active: boolean;
17
+ };
@@ -0,0 +1,148 @@
1
+ const svgNamespace = 'http://www.w3.org/2000/svg';
2
+ function rings(d) {
3
+ return (d.match(/M[^M]*/g) ?? [])
4
+ .map((piece) => {
5
+ const node = document.createElementNS(svgNamespace, 'path');
6
+ node.setAttribute('d', piece);
7
+ const length = node.getTotalLength();
8
+ const middle = node.getPointAtLength(length / 2);
9
+ return { length, center: [middle.x, middle.y], node };
10
+ })
11
+ .filter((ring) => ring.length > 0)
12
+ .sort((a, b) => b.length - a.length);
13
+ }
14
+ function sample(ring, count) {
15
+ return Array.from({ length: count }, (_, index) => {
16
+ const point = ring.node.getPointAtLength((ring.length * index) / count);
17
+ return [point.x, point.y];
18
+ });
19
+ }
20
+ /** Rotate and orient `to` so each point travels the shortest total distance. */
21
+ function align(from, to) {
22
+ const count = from.length;
23
+ let best = Number.POSITIVE_INFINITY;
24
+ let bestOffset = 0;
25
+ let bestDirection = 1;
26
+ for (const direction of [1, -1]) {
27
+ for (let offset = 0; offset < count; offset++) {
28
+ let score = 0;
29
+ for (let index = 0; index < count; index += 4) {
30
+ const a = from[index];
31
+ const b = to[(offset + direction * index + count * 2) % count];
32
+ score += (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2;
33
+ }
34
+ if (score < best) {
35
+ best = score;
36
+ bestOffset = offset;
37
+ bestDirection = direction;
38
+ }
39
+ }
40
+ }
41
+ return from.map((_, index) => to[(bestOffset + bestDirection * index + count * 2) % count]);
42
+ }
43
+ const ease = (t) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);
44
+ /**
45
+ * Morph district outlines between two map years. Matching rings are resampled and aligned;
46
+ * a ring without a partner grows from, or shrinks into, its partner's center. The real
47
+ * district layers stay the exact year snapshots; this only draws a decorative overlay.
48
+ */
49
+ export function createDistrictMorph() {
50
+ let frame = 0;
51
+ let overlay;
52
+ let lines;
53
+ function stop() {
54
+ cancelAnimationFrame(frame);
55
+ frame = 0;
56
+ overlay?.remove();
57
+ overlay = undefined;
58
+ lines?.style.removeProperty('opacity');
59
+ lines = undefined;
60
+ }
61
+ return {
62
+ stop,
63
+ /**
64
+ * `previous` maps district id to its old outline path; the new outlines are read from
65
+ * `layer`, which stays hidden until the morph settles into it.
66
+ */
67
+ start(layer, previous, { duration, stroke }) {
68
+ stop();
69
+ const next = new Map([...layer.querySelectorAll('path[data-district]')].map((path) => [
70
+ path.dataset.district ?? '',
71
+ path.getAttribute('d') ?? '',
72
+ ]));
73
+ const group = document.createElementNS(svgNamespace, 'g');
74
+ group.dataset.layer = 'district-morph';
75
+ group.setAttribute('aria-hidden', 'true');
76
+ group.style.pointerEvents = 'none';
77
+ const clip = layer.getAttribute('clip-path');
78
+ if (clip)
79
+ group.setAttribute('clip-path', clip);
80
+ const tracks = [];
81
+ for (const id of new Set([...previous.keys(), ...next.keys()])) {
82
+ const before = rings(previous.get(id) ?? '');
83
+ const after = rings(next.get(id) ?? '');
84
+ for (let index = 0; index < Math.max(before.length, after.length); index++) {
85
+ const start = before[index];
86
+ const end = after[index];
87
+ if (!start && !end)
88
+ continue;
89
+ const count = Math.min(360, Math.max(48, Math.ceil(Math.max(start?.length ?? 0, end?.length ?? 0) / 3)));
90
+ const from = start
91
+ ? sample(start, count)
92
+ : Array.from({ length: count }, () => end.center);
93
+ const target = end
94
+ ? sample(end, count)
95
+ : Array.from({ length: count }, () => start.center);
96
+ const node = document.createElementNS(svgNamespace, 'path');
97
+ for (const [name, value] of Object.entries({
98
+ fill: 'none',
99
+ stroke,
100
+ 'stroke-width': '1.6',
101
+ 'stroke-linejoin': 'round',
102
+ 'vector-effect': 'non-scaling-stroke',
103
+ }))
104
+ node.setAttribute(name, value);
105
+ group.append(node);
106
+ tracks.push({ node, from, to: start && end ? align(from, target) : target });
107
+ }
108
+ }
109
+ if (!tracks.length)
110
+ return;
111
+ overlay = group;
112
+ lines = layer;
113
+ layer.after(group);
114
+ layer.style.opacity = '0';
115
+ let begin;
116
+ const draw = (progress) => {
117
+ const t = ease(progress);
118
+ for (const track of tracks) {
119
+ let d = '';
120
+ for (let index = 0; index < track.from.length; index++) {
121
+ const [ax, ay] = track.from[index];
122
+ const [bx, by] = track.to[index];
123
+ d += `${index ? 'L' : 'M'}${(ax + (bx - ax) * t).toFixed(2)},${(ay + (by - ay) * t).toFixed(2)}`;
124
+ }
125
+ track.node.setAttribute('d', `${d}Z`);
126
+ }
127
+ // The morph hands over to the exact outlines in its final quarter.
128
+ const settle = Math.max(0, (progress - 0.75) / 0.25);
129
+ group.style.opacity = String(1 - settle);
130
+ layer.style.opacity = String(settle);
131
+ };
132
+ draw(0);
133
+ const tick = (time) => {
134
+ begin ??= time;
135
+ const progress = Math.min(1, (time - begin) / duration);
136
+ draw(progress);
137
+ if (progress < 1)
138
+ frame = requestAnimationFrame(tick);
139
+ else
140
+ stop();
141
+ };
142
+ frame = requestAnimationFrame(tick);
143
+ },
144
+ get active() {
145
+ return overlay !== undefined;
146
+ },
147
+ };
148
+ }
@@ -1,13 +1,16 @@
1
1
  import { createCamera, validateCameraOptions } from './camera.js';
2
2
  import { clusterPoints } from './clusters.js';
3
3
  import { renderDistrictAppearance } from './district-layer.js';
4
+ import { createDistrictMorph } from './district-morph.js';
4
5
  import { prepareDistrictStyles } from './district-style.js';
5
6
  import { createDistrictTransition } from './district-transition.js';
6
7
  import { element, svgElement } from './dom.js';
7
8
  import { fitBounds, interiorAnchor, projectedBounds } from './explorer-layout.js';
8
9
  import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from './features.js';
10
+ import { createFrameScheduler } from './frame-scheduler.js';
9
11
  import { geometryPath } from './geometry.js';
10
12
  import { createLabelRenderer } from './label-renderer.js';
13
+ import { createLayerTransitions } from './layer-transition.js';
11
14
  import { createSFMapWithData, districtColors, getLayerPathsWithData } from './map-core.js';
12
15
  import { createMarkerLayer } from './marker-layer.js';
13
16
  import { attachNavigation } from './navigation.js';
@@ -29,7 +32,7 @@ let explorerCount = 0;
29
32
  /** Create an offline, browser-only neighborhood explorer. Call destroy() before disposal. */
30
33
  export function createNeighborhoodExplorerCore(options = {}, data) {
31
34
  validateExplorerOptions(options);
32
- let { source = 'realtor', mode = 'neighborhoods', labels = true, neighborhood, year = 2022, theme = 'transit', colors = {}, labelStyle = {}, areaStyle = {}, districtStyle: initialDistrictStyle, motion = false, markerEntrance = false, selectedMarkerRing, clustering = false, legend: legendOptions = {}, attribution: attributionMode = 'full', northArrow = false, scaleBar = false, interface: chrome = 'explorer', layers = {}, selectableNeighborhoods = true, labelSize = {}, fitPadding = 24, markers: initialMarkers = [], markerRadius = 6, markerHitSize = 44, markerColor = colors.marker ?? '#245b61', selectedMarkerColor = colors.selected ?? '#f04f32', onMarkerActivate, overlays: initialOverlays = [], onOverlayActivate, style: styleOptions = {}, strings = {}, controls = {}, } = options;
35
+ let { source = 'realtor', mode = 'neighborhoods', labels = true, neighborhood, year = 2022, theme = 'transit', colors = {}, labelStyle = {}, areaStyle = {}, districtStyle: initialDistrictStyle, motion = false, markerEntrance = false, selectedMarkerRing, clustering = false, legend: legendOptions = {}, attribution: attributionMode = 'full', northArrow = false, scaleBar = false, layerTransitions = false, districtMorph: districtMorphOption = false, interface: chrome = 'explorer', layers = {}, selectableNeighborhoods = true, labelSize = {}, fitPadding = 24, markers: initialMarkers = [], markerRadius = 6, markerHitSize = 44, markerColor = colors.marker ?? '#245b61', selectedMarkerColor = colors.selected ?? '#f04f32', onMarkerActivate, overlays: initialOverlays = [], onOverlayActivate, style: styleOptions = {}, strings = {}, controls = {}, } = options;
33
36
  if (!['neighborhoods', 'districts', 'basemap'].includes(mode))
34
37
  throw new RangeError(`Unknown map mode: ${mode}`);
35
38
  if (chrome !== 'explorer' && chrome !== 'map')
@@ -49,6 +52,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
49
52
  selectedMarkerRing,
50
53
  northArrow,
51
54
  scaleBar,
55
+ layerTransitions,
56
+ districtMorph: districtMorphOption,
52
57
  });
53
58
  if (labelStyle.fontWeight !== undefined &&
54
59
  (!Number.isFinite(labelStyle.fontWeight) ||
@@ -434,6 +439,13 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
434
439
  root.dispatchEvent(new CustomEvent('districtchange', { bubbles: true, detail: districtSelection(id) }));
435
440
  return true;
436
441
  }
442
+ function activateDistrict(id) {
443
+ const revision = districtRevision + 1;
444
+ if (!selectDistrict(id) || destroyed || revision !== districtRevision)
445
+ return false;
446
+ root.dispatchEvent(new CustomEvent('districtactivate', { bubbles: true, detail: districtSelection(id) }));
447
+ return !destroyed && revision === districtRevision;
448
+ }
437
449
  function setDistrictStyle(style) {
438
450
  if (destroyed)
439
451
  return;
@@ -444,6 +456,12 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
444
456
  districtRevision++;
445
457
  districtStyle = style;
446
458
  districtStyles = prepared;
459
+ const fills = districtLayers.find((layer) => layer.dataset.layer === 'district-fills');
460
+ const fade = layerFades.duration();
461
+ // A new style settles any fill crossfade still running from an earlier change.
462
+ districtTransition.cancel();
463
+ if (fills && fade && enabled('districtFills'))
464
+ districtTransition.fade(fills, fade);
447
465
  updateDistrictAppearance();
448
466
  }
449
467
  function setDistrictYear(next, options = {}) {
@@ -464,8 +482,28 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
464
482
  const geometry = getLayerPathsWithData({ year: next }, data.map, false);
465
483
  const previousYear = year;
466
484
  districtTransition.cancel();
485
+ districtMorph.stop();
486
+ const lineLayer = districtLayers.find((layer) => layer.dataset.layer === 'district-lines');
487
+ const morph = features.districtMorph &&
488
+ options.animate !== false &&
489
+ initialized &&
490
+ !reducedMotion.matches &&
491
+ (enabled('districtLines') || enabled('districtFills'))
492
+ ? features.districtMorph
493
+ : false;
494
+ const previousOutlines = morph && lineLayer
495
+ ? new Map([...lineLayer.querySelectorAll('path[data-district]')].map((path) => [
496
+ path.dataset.district ?? '',
497
+ path.getAttribute('d') ?? '',
498
+ ]))
499
+ : undefined;
467
500
  for (const layer of districtLayers) {
468
- if (options.animate && !reducedMotion.matches)
501
+ if (morph) {
502
+ // The morph replaces the line crossfade; fills fade across the whole morph.
503
+ if (layer !== lineLayer)
504
+ districtTransition.fade(layer, morph.duration);
505
+ }
506
+ else if (options.animate && !reducedMotion.matches)
469
507
  districtTransition.fade(layer, options.duration ?? 280);
470
508
  layer.replaceChildren(...geometry.districts.map((district) => svgElement('path', {
471
509
  'data-district': district.id,
@@ -491,6 +529,11 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
491
529
  delete root.dataset.selectedDistrict;
492
530
  updateDistrictAppearance();
493
531
  updateComposition();
532
+ if (morph && lineLayer && previousOutlines)
533
+ districtMorph.start(lineLayer, previousOutlines, {
534
+ duration: morph.duration,
535
+ stroke: colors.district ?? '#71838a',
536
+ });
494
537
  scheduleLabels();
495
538
  status.textContent = `${year} supervisorial districts.`;
496
539
  root.dispatchEvent(new CustomEvent('districtyearchange', { bubbles: true, detail: { year, previousYear } }));
@@ -504,7 +547,15 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
504
547
  let items = [];
505
548
  let destroyed = false;
506
549
  let initialized = false;
507
- let frame = 0;
550
+ const layerFades = createLayerTransitions(() => initialized && features.layerTransitions && !reducedMotion.matches
551
+ ? features.layerTransitions.duration
552
+ : 0);
553
+ const districtMorph = createDistrictMorph();
554
+ const frames = createFrameScheduler({
555
+ request: (callback) => requestAnimationFrame(callback),
556
+ cancel: (id) => cancelAnimationFrame(id),
557
+ render: drawLabels,
558
+ });
508
559
  const markerRenderer = createMarkerLayer(markerLayer);
509
560
  const cancelEntrances = markerRenderer.cancelEntrances;
510
561
  const camera = createCamera({
@@ -512,8 +563,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
512
563
  write: setView,
513
564
  duration: () => (initialized && features.motion ? features.motion.duration : 0),
514
565
  reduced: () => reducedMotion.matches,
515
- request: (callback) => requestAnimationFrame(callback),
516
- cancel: (id) => cancelAnimationFrame(id),
566
+ request: frames.request,
567
+ cancel: frames.cancel,
517
568
  now: () => performance.now(),
518
569
  });
519
570
  const stopAnimation = camera.stop;
@@ -532,6 +583,12 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
532
583
  stopAnimation();
533
584
  if (Object.hasOwn(patch, 'markerEntrance'))
534
585
  cancelEntrances();
586
+ if (Object.hasOwn(patch, 'layerTransitions')) {
587
+ layerFades.cancel();
588
+ districtTransition.cancel();
589
+ }
590
+ if (Object.hasOwn(patch, 'districtMorph'))
591
+ districtMorph.stop();
535
592
  if (Object.hasOwn(patch, 'clustering'))
536
593
  invalidateClusters();
537
594
  syncFurniture();
@@ -798,8 +855,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
798
855
  function scheduleLabels() {
799
856
  if (destroyed)
800
857
  return;
801
- cancelAnimationFrame(frame);
802
- frame = requestAnimationFrame(drawLabels);
858
+ frames.invalidate();
803
859
  }
804
860
  function drawLabels() {
805
861
  if (destroyed)
@@ -1142,7 +1198,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1142
1198
  const revision = ++neighborhoodRevision;
1143
1199
  delete root.dataset.selectedNeighborhood;
1144
1200
  root.dataset.source = source;
1145
- areas.replaceChildren(...nextItems.map((item) => item.node));
1201
+ layerFades.crossfade(areas, () => areas.replaceChildren(...nextItems.map((item) => item.node)));
1146
1202
  neighborhoodSelect.replaceChildren(element('option', 'No neighborhood selected'));
1147
1203
  neighborhoodSelect.options[0].value = '';
1148
1204
  neighborhoodSelect.append(...nextOptions);
@@ -1185,9 +1241,9 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1185
1241
  ]) {
1186
1242
  const layer = svg.querySelector(`[data-layer="${name}"]`);
1187
1243
  if (layer)
1188
- layer.style.display = enabled(key) ? '' : 'none';
1244
+ layerFades.set(layer, enabled(key));
1189
1245
  }
1190
- stations.style.display = enabled('bartStations') ? '' : 'none';
1246
+ layerFades.set(stations, enabled('bartStations'));
1191
1247
  for (const [kind, key] of [
1192
1248
  ['bart', 'bartStations'],
1193
1249
  ['park', 'landmarks'],
@@ -1202,7 +1258,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1202
1258
  controls.legend === false ||
1203
1259
  ![...legend.children].some((node) => !node.hidden);
1204
1260
  const neighborhoodsVisible = enabled('neighborhoodLines') || (labels && enabled('neighborhoodLabels'));
1205
- areas.style.display = neighborhoodsVisible ? '' : 'none';
1261
+ layerFades.set(areas, neighborhoodsVisible);
1206
1262
  areas.style.pointerEvents = selectableNeighborhoods ? '' : 'none';
1207
1263
  neighborhoodLabel.hidden =
1208
1264
  !neighborhoodsVisible ||
@@ -1219,7 +1275,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1219
1275
  }
1220
1276
  for (const layer of districtLayers) {
1221
1277
  const key = layer.dataset.layer === 'district-fills' ? 'districtFills' : 'districtLines';
1222
- layer.style.display = enabled(key) ? '' : 'none';
1278
+ layerFades.set(layer, enabled(key));
1223
1279
  }
1224
1280
  updateDistrictAppearance();
1225
1281
  const districtsVisible = enabled('districtFills') || enabled('districtLines') || (labels && enabled('districtLabels'));
@@ -1274,10 +1330,16 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1274
1330
  markerSelect.value = id ?? '';
1275
1331
  for (const entry of markerItems) {
1276
1332
  const active = entry.marker.id === id;
1277
- entry.node.setAttribute('aria-pressed', String(active));
1278
- entry.ring.setAttribute('display', active && features.selectedMarkerRing ? 'inline' : 'none');
1279
- entry.dot.setAttribute('fill', active ? selectedMarkerColor : (entry.marker.color ?? markerColor));
1280
- if (active)
1333
+ const pressed = String(active);
1334
+ const ring = active && features.selectedMarkerRing ? 'inline' : 'none';
1335
+ const fill = active ? selectedMarkerColor : (entry.marker.color ?? markerColor);
1336
+ if (entry.node.getAttribute('aria-pressed') !== pressed)
1337
+ entry.node.setAttribute('aria-pressed', pressed);
1338
+ if (entry.ring.getAttribute('display') !== ring)
1339
+ entry.ring.setAttribute('display', ring);
1340
+ if (entry.dot.getAttribute('fill') !== fill)
1341
+ entry.dot.setAttribute('fill', fill);
1342
+ if (active && markerLayer.lastChild !== entry.node)
1281
1343
  markerLayer.append(entry.node);
1282
1344
  }
1283
1345
  scheduleLabels();
@@ -1294,41 +1356,78 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1294
1356
  if (destroyed)
1295
1357
  return;
1296
1358
  validateMarkers(markers);
1359
+ const existing = new Map(markerItems.map((item) => [item.marker.id, item]));
1297
1360
  const ids = new Set();
1298
1361
  const next = markers.map((marker) => {
1299
1362
  ids.add(marker.id);
1300
- return { marker: { ...marker }, point: map.project([marker.lng, marker.lat]) };
1363
+ const previous = existing.get(marker.id);
1364
+ const point = previous?.marker.lng === marker.lng && previous.marker.lat === marker.lat
1365
+ ? previous.point
1366
+ : map.project([marker.lng, marker.lat]);
1367
+ return { marker: { ...marker }, point };
1301
1368
  });
1369
+ const nextSelection = selectedMarker && ids.has(selectedMarker)
1370
+ ? selectedMarker
1371
+ : (markers.find((marker) => marker.selected)?.id ?? null);
1372
+ const keys = ['id', 'lng', 'lat', 'label', 'selected', 'color', 'radius'];
1373
+ if (markerSelect.options.length > 0 &&
1374
+ nextSelection === selectedMarker &&
1375
+ next.length === markerItems.length &&
1376
+ next.every(({ marker }, index) => keys.every((key) => marker[key] === markerItems[index].marker[key])))
1377
+ return;
1302
1378
  const focusedMarker = markerItems.find((item) => item.node === document.activeElement)?.marker
1303
1379
  .id;
1304
1380
  invalidateClusters();
1305
- const previousIds = new Set(markerItems.map((item) => item.marker.id));
1306
- markerRenderer.clear();
1307
- const previous = selectedMarker;
1308
- markerSelect.replaceChildren(element('option', 'No marker selected'));
1309
- markerSelect.options[0].value = '';
1381
+ const options = new Map([...markerSelect.options].map((option) => [option.value, option]));
1382
+ for (const item of markerItems) {
1383
+ if (!ids.has(item.marker.id)) {
1384
+ markerRenderer.remove(item);
1385
+ options.get(item.marker.id)?.remove();
1386
+ }
1387
+ }
1388
+ if (!options.has('')) {
1389
+ const empty = element('option', 'No marker selected');
1390
+ empty.value = '';
1391
+ markerSelect.prepend(empty);
1392
+ }
1310
1393
  markerItems = next.map(({ marker, point }, index) => {
1311
- const item = markerRenderer.add(marker, point, index, {
1312
- features,
1313
- markerColor,
1314
- selectedMarkerColor,
1315
- reducedMotion: reducedMotion.matches,
1316
- enter: !previousIds.has(marker.id),
1317
- });
1318
- const option = element('option', marker.label ?? marker.id);
1319
- option.value = marker.id;
1320
- markerSelect.append(option);
1394
+ const previous = existing.get(marker.id);
1395
+ const item = previous ??
1396
+ markerRenderer.add(marker, point, index, {
1397
+ features,
1398
+ markerColor,
1399
+ selectedMarkerColor,
1400
+ reducedMotion: reducedMotion.matches,
1401
+ enter: true,
1402
+ });
1403
+ if (previous)
1404
+ markerRenderer.update(item, marker, point, index);
1405
+ const option = options.get(marker.id) ?? element('option');
1406
+ const label = marker.label ?? marker.id;
1407
+ if (option.textContent !== label)
1408
+ option.textContent = label;
1409
+ if (option.value !== marker.id)
1410
+ option.value = marker.id;
1411
+ if (markerSelect.children[index + 1] !== option)
1412
+ markerSelect.insertBefore(option, markerSelect.children[index + 1] ?? null);
1321
1413
  return item;
1322
1414
  });
1415
+ // Match input order without detaching unchanged nodes; selected markers remain on top.
1416
+ const ordered = markerItems.filter((item) => item.marker.id !== nextSelection);
1417
+ const selectedItem = markerItems.find((item) => item.marker.id === nextSelection);
1418
+ if (selectedItem)
1419
+ ordered.push(selectedItem);
1420
+ ordered.forEach((item, index) => {
1421
+ if (markerLayer.children[index] !== item.node)
1422
+ markerLayer.insertBefore(item.node, markerLayer.children[index] ?? null);
1423
+ });
1323
1424
  markerLabel.hidden = !markerItems.length || controls.markerPicker === false;
1324
1425
  if (markerLabel.firstChild)
1325
1426
  markerLabel.firstChild.textContent = `${strings.chooseMarker ?? 'Choose marker'} (${markerItems.length})`;
1326
1427
  syncFeatureControls();
1327
- const nextSelection = previous && ids.has(previous)
1328
- ? previous
1329
- : (markers.find((marker) => marker.selected)?.id ?? null);
1428
+ const revision = markerRevision + 1;
1330
1429
  selectMarker(nextSelection, { fit: false });
1331
- if (focusedMarker)
1430
+ if (!destroyed && markerRevision === revision && focusedMarker)
1332
1431
  (markerItems.find((item) => item.marker.id === focusedMarker)?.node ?? svg).focus({
1333
1432
  preventScroll: true,
1334
1433
  });
@@ -1465,12 +1564,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1465
1564
  districtRow(districtId)) {
1466
1565
  if (event.key === 'Enter' || event.key === ' ') {
1467
1566
  event.preventDefault();
1468
- selectDistrict(districtId);
1469
- root.dispatchEvent(new CustomEvent('districtactivate', {
1470
- bubbles: true,
1471
- detail: districtSelection(districtId),
1472
- }));
1473
- target.focus();
1567
+ if (activateDistrict(districtId))
1568
+ target.focus();
1474
1569
  }
1475
1570
  else if (event.key === '[' || event.key === ']') {
1476
1571
  event.preventDefault();
@@ -1521,9 +1616,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1521
1616
  ? event.target.closest('[data-layer="district-fills"] [data-district], [data-layer="district-lines"] [data-district]')
1522
1617
  : null;
1523
1618
  if (district?.dataset.district) {
1524
- const id = Number(district.dataset.district);
1525
- selectDistrict(id);
1526
- root.dispatchEvent(new CustomEvent('districtactivate', { bubbles: true, detail: districtSelection(id) }));
1619
+ activateDistrict(Number(district.dataset.district));
1527
1620
  return;
1528
1621
  }
1529
1622
  const node = event.target instanceof Element
@@ -1592,13 +1685,15 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1592
1685
  destroyed = true;
1593
1686
  camera.destroy();
1594
1687
  districtTransition.cancel();
1688
+ layerFades.cancel();
1689
+ districtMorph.stop();
1595
1690
  cancelEntrances();
1596
1691
  controller.abort();
1597
1692
  overlayEvents.abort();
1598
1693
  clusterEvents.abort();
1599
1694
  canvas.style.touchAction = 'pan-y pinch-zoom';
1600
1695
  observer?.disconnect();
1601
- cancelAnimationFrame(frame);
1696
+ frames.destroy();
1602
1697
  labelRenderer.clear();
1603
1698
  releaseDownloads();
1604
1699
  },
@@ -1614,6 +1709,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1614
1709
  if (reducedMotion.matches) {
1615
1710
  stopAnimation();
1616
1711
  districtTransition.cancel();
1712
+ layerFades.cancel();
1713
+ districtMorph.stop();
1617
1714
  cancelEntrances();
1618
1715
  }
1619
1716
  }, { signal: controller.signal });
@@ -18,6 +18,12 @@ export declare function normalizeFeatures(input?: MapFeatures): {
18
18
  };
19
19
  northArrow: boolean;
20
20
  scaleBar: boolean;
21
+ layerTransitions: false | {
22
+ duration: number;
23
+ };
24
+ districtMorph: false | {
25
+ duration: number;
26
+ };
21
27
  };
22
28
  export declare const layerKeys: readonly ['districtFills', 'districtLines', 'districtLabels', 'neighborhoodLines', 'neighborhoodLabels', 'landmarks', 'bartStations', 'highways', 'keyRoads', 'roadLabels'];
23
29
  export declare const controlKeys: readonly ['zoom', 'pan', 'reset', 'labels', 'touch', 'legend', 'neighborhoodPicker', 'markerPicker', 'help', 'status'];