@kahwee/sf-map-svg 4.0.1 → 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.
@@ -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)
@@ -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,6 +1,7 @@
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';
@@ -9,6 +10,7 @@ import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from '
9
10
  import { createFrameScheduler } from './frame-scheduler.js';
10
11
  import { geometryPath } from './geometry.js';
11
12
  import { createLabelRenderer } from './label-renderer.js';
13
+ import { createLayerTransitions } from './layer-transition.js';
12
14
  import { createSFMapWithData, districtColors, getLayerPathsWithData } from './map-core.js';
13
15
  import { createMarkerLayer } from './marker-layer.js';
14
16
  import { attachNavigation } from './navigation.js';
@@ -30,7 +32,7 @@ let explorerCount = 0;
30
32
  /** Create an offline, browser-only neighborhood explorer. Call destroy() before disposal. */
31
33
  export function createNeighborhoodExplorerCore(options = {}, data) {
32
34
  validateExplorerOptions(options);
33
- 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;
34
36
  if (!['neighborhoods', 'districts', 'basemap'].includes(mode))
35
37
  throw new RangeError(`Unknown map mode: ${mode}`);
36
38
  if (chrome !== 'explorer' && chrome !== 'map')
@@ -50,6 +52,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
50
52
  selectedMarkerRing,
51
53
  northArrow,
52
54
  scaleBar,
55
+ layerTransitions,
56
+ districtMorph: districtMorphOption,
53
57
  });
54
58
  if (labelStyle.fontWeight !== undefined &&
55
59
  (!Number.isFinite(labelStyle.fontWeight) ||
@@ -435,6 +439,13 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
435
439
  root.dispatchEvent(new CustomEvent('districtchange', { bubbles: true, detail: districtSelection(id) }));
436
440
  return true;
437
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
+ }
438
449
  function setDistrictStyle(style) {
439
450
  if (destroyed)
440
451
  return;
@@ -445,6 +456,12 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
445
456
  districtRevision++;
446
457
  districtStyle = style;
447
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);
448
465
  updateDistrictAppearance();
449
466
  }
450
467
  function setDistrictYear(next, options = {}) {
@@ -465,8 +482,28 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
465
482
  const geometry = getLayerPathsWithData({ year: next }, data.map, false);
466
483
  const previousYear = year;
467
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;
468
500
  for (const layer of districtLayers) {
469
- 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)
470
507
  districtTransition.fade(layer, options.duration ?? 280);
471
508
  layer.replaceChildren(...geometry.districts.map((district) => svgElement('path', {
472
509
  'data-district': district.id,
@@ -492,6 +529,11 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
492
529
  delete root.dataset.selectedDistrict;
493
530
  updateDistrictAppearance();
494
531
  updateComposition();
532
+ if (morph && lineLayer && previousOutlines)
533
+ districtMorph.start(lineLayer, previousOutlines, {
534
+ duration: morph.duration,
535
+ stroke: colors.district ?? '#71838a',
536
+ });
495
537
  scheduleLabels();
496
538
  status.textContent = `${year} supervisorial districts.`;
497
539
  root.dispatchEvent(new CustomEvent('districtyearchange', { bubbles: true, detail: { year, previousYear } }));
@@ -505,6 +547,10 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
505
547
  let items = [];
506
548
  let destroyed = false;
507
549
  let initialized = false;
550
+ const layerFades = createLayerTransitions(() => initialized && features.layerTransitions && !reducedMotion.matches
551
+ ? features.layerTransitions.duration
552
+ : 0);
553
+ const districtMorph = createDistrictMorph();
508
554
  const frames = createFrameScheduler({
509
555
  request: (callback) => requestAnimationFrame(callback),
510
556
  cancel: (id) => cancelAnimationFrame(id),
@@ -537,6 +583,12 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
537
583
  stopAnimation();
538
584
  if (Object.hasOwn(patch, 'markerEntrance'))
539
585
  cancelEntrances();
586
+ if (Object.hasOwn(patch, 'layerTransitions')) {
587
+ layerFades.cancel();
588
+ districtTransition.cancel();
589
+ }
590
+ if (Object.hasOwn(patch, 'districtMorph'))
591
+ districtMorph.stop();
540
592
  if (Object.hasOwn(patch, 'clustering'))
541
593
  invalidateClusters();
542
594
  syncFurniture();
@@ -1146,7 +1198,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1146
1198
  const revision = ++neighborhoodRevision;
1147
1199
  delete root.dataset.selectedNeighborhood;
1148
1200
  root.dataset.source = source;
1149
- areas.replaceChildren(...nextItems.map((item) => item.node));
1201
+ layerFades.crossfade(areas, () => areas.replaceChildren(...nextItems.map((item) => item.node)));
1150
1202
  neighborhoodSelect.replaceChildren(element('option', 'No neighborhood selected'));
1151
1203
  neighborhoodSelect.options[0].value = '';
1152
1204
  neighborhoodSelect.append(...nextOptions);
@@ -1189,9 +1241,9 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1189
1241
  ]) {
1190
1242
  const layer = svg.querySelector(`[data-layer="${name}"]`);
1191
1243
  if (layer)
1192
- layer.style.display = enabled(key) ? '' : 'none';
1244
+ layerFades.set(layer, enabled(key));
1193
1245
  }
1194
- stations.style.display = enabled('bartStations') ? '' : 'none';
1246
+ layerFades.set(stations, enabled('bartStations'));
1195
1247
  for (const [kind, key] of [
1196
1248
  ['bart', 'bartStations'],
1197
1249
  ['park', 'landmarks'],
@@ -1206,7 +1258,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1206
1258
  controls.legend === false ||
1207
1259
  ![...legend.children].some((node) => !node.hidden);
1208
1260
  const neighborhoodsVisible = enabled('neighborhoodLines') || (labels && enabled('neighborhoodLabels'));
1209
- areas.style.display = neighborhoodsVisible ? '' : 'none';
1261
+ layerFades.set(areas, neighborhoodsVisible);
1210
1262
  areas.style.pointerEvents = selectableNeighborhoods ? '' : 'none';
1211
1263
  neighborhoodLabel.hidden =
1212
1264
  !neighborhoodsVisible ||
@@ -1223,7 +1275,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1223
1275
  }
1224
1276
  for (const layer of districtLayers) {
1225
1277
  const key = layer.dataset.layer === 'district-fills' ? 'districtFills' : 'districtLines';
1226
- layer.style.display = enabled(key) ? '' : 'none';
1278
+ layerFades.set(layer, enabled(key));
1227
1279
  }
1228
1280
  updateDistrictAppearance();
1229
1281
  const districtsVisible = enabled('districtFills') || enabled('districtLines') || (labels && enabled('districtLabels'));
@@ -1512,12 +1564,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1512
1564
  districtRow(districtId)) {
1513
1565
  if (event.key === 'Enter' || event.key === ' ') {
1514
1566
  event.preventDefault();
1515
- selectDistrict(districtId);
1516
- root.dispatchEvent(new CustomEvent('districtactivate', {
1517
- bubbles: true,
1518
- detail: districtSelection(districtId),
1519
- }));
1520
- target.focus();
1567
+ if (activateDistrict(districtId))
1568
+ target.focus();
1521
1569
  }
1522
1570
  else if (event.key === '[' || event.key === ']') {
1523
1571
  event.preventDefault();
@@ -1568,9 +1616,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1568
1616
  ? event.target.closest('[data-layer="district-fills"] [data-district], [data-layer="district-lines"] [data-district]')
1569
1617
  : null;
1570
1618
  if (district?.dataset.district) {
1571
- const id = Number(district.dataset.district);
1572
- selectDistrict(id);
1573
- root.dispatchEvent(new CustomEvent('districtactivate', { bubbles: true, detail: districtSelection(id) }));
1619
+ activateDistrict(Number(district.dataset.district));
1574
1620
  return;
1575
1621
  }
1576
1622
  const node = event.target instanceof Element
@@ -1639,6 +1685,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1639
1685
  destroyed = true;
1640
1686
  camera.destroy();
1641
1687
  districtTransition.cancel();
1688
+ layerFades.cancel();
1689
+ districtMorph.stop();
1642
1690
  cancelEntrances();
1643
1691
  controller.abort();
1644
1692
  overlayEvents.abort();
@@ -1661,6 +1709,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1661
1709
  if (reducedMotion.matches) {
1662
1710
  stopAnimation();
1663
1711
  districtTransition.cancel();
1712
+ layerFades.cancel();
1713
+ districtMorph.stop();
1664
1714
  cancelEntrances();
1665
1715
  }
1666
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'];
@@ -4,7 +4,7 @@ function objectOption(value, name) {
4
4
  return false;
5
5
  if (value === true)
6
6
  return {};
7
- assertOptions(value, name, name === 'motion'
7
+ assertOptions(value, name, name === 'motion' || name === 'layerTransitions' || name === 'districtMorph'
8
8
  ? ['duration']
9
9
  : name === 'markerEntrance'
10
10
  ? ['duration', 'stagger']
@@ -36,6 +36,8 @@ export function normalizeFeatures(input = {}) {
36
36
  'selectedMarkerRing',
37
37
  'northArrow',
38
38
  'scaleBar',
39
+ 'layerTransitions',
40
+ 'districtMorph',
39
41
  ];
40
42
  for (const key of Object.keys(input))
41
43
  if (!keys.includes(key))
@@ -44,6 +46,8 @@ export function normalizeFeatures(input = {}) {
44
46
  const entrance = objectOption(input.markerEntrance, 'markerEntrance');
45
47
  const clustering = objectOption(input.clustering, 'clustering');
46
48
  const ring = objectOption(input.selectedMarkerRing, 'selectedMarkerRing');
49
+ const layerTransitions = objectOption(input.layerTransitions, 'layerTransitions');
50
+ const districtMorph = objectOption(input.districtMorph, 'districtMorph');
47
51
  if (ring && ring.color !== undefined && typeof ring.color !== 'string')
48
52
  throw new TypeError('Ring color must be a string.');
49
53
  return {
@@ -62,6 +66,12 @@ export function normalizeFeatures(input = {}) {
62
66
  },
63
67
  northArrow: flag(input.northArrow, 'northArrow'),
64
68
  scaleBar: flag(input.scaleBar, 'scaleBar'),
69
+ layerTransitions: layerTransitions && {
70
+ duration: numberOption(layerTransitions.duration, 360, 'Layer transition duration'),
71
+ },
72
+ districtMorph: districtMorph && {
73
+ duration: numberOption(districtMorph.duration, 1100, 'District morph duration', true),
74
+ },
65
75
  };
66
76
  }
67
77
  export const layerKeys = [
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Opacity transitions for interactive layer switches and source swaps. `duration()` is read
3
+ * on every change, so reduced motion, construction, and runtime feature patches take effect
4
+ * immediately. All animations are owned here and settle synchronously on cancel.
5
+ */
6
+ export declare function createLayerTransitions(duration: () => number): {
7
+ /** The active transition duration in milliseconds, or 0 when transitions are off. */
8
+ duration: () => number;
9
+ /** Show or hide a layer, fading when a transition duration is active. */
10
+ set(node: SVGElement | HTMLElement, visible: boolean): void;
11
+ /**
12
+ * Replace a visible layer's contents while an inert copy of the old contents fades out
13
+ * above the new ones as they fade in.
14
+ */
15
+ crossfade(node: SVGGElement, replace: () => void): void;
16
+ /** Jump every layer to its final state and remove all decorative copies. */
17
+ cancel(): void;
18
+ };
@@ -0,0 +1,102 @@
1
+ /** Remove interaction and accessibility hooks from a decorative copy of a layer. */
2
+ function makeInert(node) {
3
+ node.setAttribute('aria-hidden', 'true');
4
+ node.style.pointerEvents = 'none';
5
+ for (const item of [node, ...node.querySelectorAll('*')]) {
6
+ for (const name of ['id', 'tabindex', 'role', 'aria-label', 'aria-pressed'])
7
+ item.removeAttribute(name);
8
+ }
9
+ }
10
+ /**
11
+ * Opacity transitions for interactive layer switches and source swaps. `duration()` is read
12
+ * on every change, so reduced motion, construction, and runtime feature patches take effect
13
+ * immediately. All animations are owned here and settle synchronously on cancel.
14
+ */
15
+ export function createLayerTransitions(duration) {
16
+ const fades = new Map();
17
+ const ghosts = new Set();
18
+ function settle(node) {
19
+ const running = fades.get(node);
20
+ if (!running)
21
+ return;
22
+ fades.delete(node);
23
+ running.animation.cancel();
24
+ if (running.hiding)
25
+ node.style.display = 'none';
26
+ }
27
+ return {
28
+ /** The active transition duration in milliseconds, or 0 when transitions are off. */
29
+ duration,
30
+ /** Show or hide a layer, fading when a transition duration is active. */
31
+ set(node, visible) {
32
+ const running = fades.get(node);
33
+ const shown = node.style.display !== 'none' && !running?.hiding;
34
+ if (running) {
35
+ fades.delete(node);
36
+ running.animation.cancel();
37
+ }
38
+ const ms = duration();
39
+ if (!ms || visible === shown || typeof node.animate !== 'function') {
40
+ node.style.display = visible ? '' : 'none';
41
+ return;
42
+ }
43
+ node.style.display = '';
44
+ const animation = node.animate(visible ? [{ opacity: 0 }, { opacity: 1 }] : [{ opacity: 1 }, { opacity: 0 }], { duration: ms, easing: visible ? 'ease-out' : 'ease-in', fill: 'forwards' });
45
+ const entry = { animation, hiding: !visible };
46
+ fades.set(node, entry);
47
+ animation.finished.then(() => {
48
+ if (fades.get(node) === entry)
49
+ settle(node);
50
+ }, () => { });
51
+ },
52
+ /**
53
+ * Replace a visible layer's contents while an inert copy of the old contents fades out
54
+ * above the new ones as they fade in.
55
+ */
56
+ crossfade(node, replace) {
57
+ const ms = duration();
58
+ if (!ms || node.style.display === 'none' || typeof node.animate !== 'function') {
59
+ replace();
60
+ return;
61
+ }
62
+ const ghost = node.cloneNode(true);
63
+ makeInert(ghost);
64
+ ghost.dataset.layerTransition = '';
65
+ replace();
66
+ node.after(ghost);
67
+ const fadeOut = ghost.animate([{ opacity: 1 }, { opacity: 0 }], {
68
+ duration: ms,
69
+ easing: 'ease-out',
70
+ fill: 'forwards',
71
+ });
72
+ const entry = { animation: fadeOut, node: ghost };
73
+ ghosts.add(entry);
74
+ const remove = () => {
75
+ ghosts.delete(entry);
76
+ ghost.remove();
77
+ };
78
+ fadeOut.finished.then(remove, remove);
79
+ settle(node);
80
+ const fadeIn = node.animate([{ opacity: 0 }, { opacity: 1 }], {
81
+ duration: ms,
82
+ easing: 'ease-out',
83
+ });
84
+ const incoming = { animation: fadeIn, hiding: false };
85
+ fades.set(node, incoming);
86
+ fadeIn.finished.then(() => {
87
+ if (fades.get(node) === incoming)
88
+ fades.delete(node);
89
+ }, () => { });
90
+ },
91
+ /** Jump every layer to its final state and remove all decorative copies. */
92
+ cancel() {
93
+ for (const node of [...fades.keys()])
94
+ settle(node);
95
+ for (const { animation, node } of [...ghosts]) {
96
+ animation.cancel();
97
+ node.remove();
98
+ }
99
+ ghosts.clear();
100
+ },
101
+ };
102
+ }