@kahwee/sf-map-svg 4.0.1 → 4.2.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 (45) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +9 -13
  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/appearance.d.ts +5 -0
  11. package/dist/src/appearance.js +54 -0
  12. package/dist/src/configuration.js +50 -25
  13. package/dist/src/controller-types.d.ts +53 -6
  14. package/dist/src/district-morph.d.ts +17 -0
  15. package/dist/src/district-morph.js +148 -0
  16. package/dist/src/explorer-core.d.ts +14 -2
  17. package/dist/src/explorer-core.js +233 -52
  18. package/dist/src/features.d.ts +6 -0
  19. package/dist/src/features.js +11 -1
  20. package/dist/src/guide-map.js +1 -1
  21. package/dist/src/guide-static.js +3 -3
  22. package/dist/src/layer-transition.d.ts +18 -0
  23. package/dist/src/layer-transition.js +102 -0
  24. package/dist/src/layers.d.ts +9 -7
  25. package/dist/src/layers.js +28 -18
  26. package/dist/src/map-core.d.ts +14 -0
  27. package/dist/src/map-core.js +11 -5
  28. package/dist/src/map.js +125 -20
  29. package/dist/src/overlays.d.ts +3 -4
  30. package/dist/src/overlays.js +5 -1
  31. package/dist/src/static-animation.d.ts +13 -0
  32. package/dist/src/static-animation.js +36 -0
  33. package/dist/src/static.d.ts +1 -1
  34. package/dist/src/static.js +31 -2
  35. package/dist/src/transit.js +2 -1
  36. package/dist/src/types.d.ts +33 -2
  37. package/dist/src/validation.js +2 -0
  38. package/docs/API.md +85 -0
  39. package/docs/api-audit.md +3 -1
  40. package/docs/consumer-integration.md +1 -1
  41. package/docs/developer-guide.md +139 -0
  42. package/docs/guide-bundle-report.md +4 -4
  43. package/llms-full.txt +1979 -0
  44. package/llms.txt +25 -0
  45. package/package.json +12 -5
@@ -1,6 +1,8 @@
1
+ import { copyAppearance } from './appearance.js';
1
2
  import { createCamera, validateCameraOptions } from './camera.js';
2
3
  import { clusterPoints } from './clusters.js';
3
4
  import { renderDistrictAppearance } from './district-layer.js';
5
+ import { createDistrictMorph } from './district-morph.js';
4
6
  import { prepareDistrictStyles } from './district-style.js';
5
7
  import { createDistrictTransition } from './district-transition.js';
6
8
  import { element, svgElement } from './dom.js';
@@ -9,7 +11,8 @@ import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from '
9
11
  import { createFrameScheduler } from './frame-scheduler.js';
10
12
  import { geometryPath } from './geometry.js';
11
13
  import { createLabelRenderer } from './label-renderer.js';
12
- import { createSFMapWithData, districtColors, getLayerPathsWithData } from './map-core.js';
14
+ import { createLayerTransitions } from './layer-transition.js';
15
+ import { createSFMapWithData, districtColors, getLayerPathsWithData, resolveMapColors, } from './map-core.js';
13
16
  import { createMarkerLayer } from './marker-layer.js';
14
17
  import { attachNavigation } from './navigation.js';
15
18
  import { validateExplorerOptions, validateMarkers, validateOverlays } from './validation.js';
@@ -30,12 +33,12 @@ let explorerCount = 0;
30
33
  /** Create an offline, browser-only neighborhood explorer. Call destroy() before disposal. */
31
34
  export function createNeighborhoodExplorerCore(options = {}, data) {
32
35
  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;
36
+ 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
37
  if (!['neighborhoods', 'districts', 'basemap'].includes(mode))
35
38
  throw new RangeError(`Unknown map mode: ${mode}`);
36
39
  if (chrome !== 'explorer' && chrome !== 'map')
37
40
  throw new RangeError('Unknown interface.');
38
- const minLabelSize = labelSize.min ?? 11, maxLabelSize = labelSize.max ?? 12;
41
+ let minLabelSize = labelSize.min ?? 11, maxLabelSize = labelSize.max ?? 12;
39
42
  if (![minLabelSize, maxLabelSize, markerRadius, markerHitSize].every(Number.isFinite) ||
40
43
  minLabelSize < 8 ||
41
44
  maxLabelSize < minLabelSize ||
@@ -50,6 +53,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
50
53
  selectedMarkerRing,
51
54
  northArrow,
52
55
  scaleBar,
56
+ layerTransitions,
57
+ districtMorph: districtMorphOption,
53
58
  });
54
59
  if (labelStyle.fontWeight !== undefined &&
55
60
  (!Number.isFinite(labelStyle.fontWeight) ||
@@ -435,6 +440,13 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
435
440
  root.dispatchEvent(new CustomEvent('districtchange', { bubbles: true, detail: districtSelection(id) }));
436
441
  return true;
437
442
  }
443
+ function activateDistrict(id) {
444
+ const revision = districtRevision + 1;
445
+ if (!selectDistrict(id) || destroyed || revision !== districtRevision)
446
+ return false;
447
+ root.dispatchEvent(new CustomEvent('districtactivate', { bubbles: true, detail: districtSelection(id) }));
448
+ return !destroyed && revision === districtRevision;
449
+ }
438
450
  function setDistrictStyle(style) {
439
451
  if (destroyed)
440
452
  return;
@@ -445,9 +457,15 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
445
457
  districtRevision++;
446
458
  districtStyle = style;
447
459
  districtStyles = prepared;
460
+ const fills = districtLayers.find((layer) => layer.dataset.layer === 'district-fills');
461
+ const fade = layerFades.duration();
462
+ // A new style settles any fill crossfade still running from an earlier change.
463
+ districtTransition.cancel();
464
+ if (fills && fade && enabled('districtFills'))
465
+ districtTransition.fade(fills, fade);
448
466
  updateDistrictAppearance();
449
467
  }
450
- function setDistrictYear(next, options = {}) {
468
+ function setDistrictYear(next, options = {}, preparedStyles) {
451
469
  if (destroyed)
452
470
  return;
453
471
  validateCameraOptions(options);
@@ -458,15 +476,35 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
458
476
  if (next === year)
459
477
  return;
460
478
  const revision = districtRevision;
461
- const prepared = prepareDistrictStyles(data.map.districts[next], districtStyle);
479
+ const prepared = preparedStyles ?? prepareDistrictStyles(data.map.districts[next], districtStyle);
462
480
  if (destroyed || revision !== districtRevision)
463
481
  return;
464
482
  const committedRevision = ++districtRevision;
465
483
  const geometry = getLayerPathsWithData({ year: next }, data.map, false);
466
484
  const previousYear = year;
467
485
  districtTransition.cancel();
486
+ districtMorph.stop();
487
+ const lineLayer = districtLayers.find((layer) => layer.dataset.layer === 'district-lines');
488
+ const morph = features.districtMorph &&
489
+ options.animate !== false &&
490
+ initialized &&
491
+ !reducedMotion.matches &&
492
+ (enabled('districtLines') || enabled('districtFills'))
493
+ ? features.districtMorph
494
+ : false;
495
+ const previousOutlines = morph && lineLayer
496
+ ? new Map([...lineLayer.querySelectorAll('path[data-district]')].map((path) => [
497
+ path.dataset.district ?? '',
498
+ path.getAttribute('d') ?? '',
499
+ ]))
500
+ : undefined;
468
501
  for (const layer of districtLayers) {
469
- if (options.animate && !reducedMotion.matches)
502
+ if (morph) {
503
+ // The morph replaces the line crossfade; fills fade across the whole morph.
504
+ if (layer !== lineLayer)
505
+ districtTransition.fade(layer, morph.duration);
506
+ }
507
+ else if (options.animate && !reducedMotion.matches)
470
508
  districtTransition.fade(layer, options.duration ?? 280);
471
509
  layer.replaceChildren(...geometry.districts.map((district) => svgElement('path', {
472
510
  'data-district': district.id,
@@ -492,6 +530,11 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
492
530
  delete root.dataset.selectedDistrict;
493
531
  updateDistrictAppearance();
494
532
  updateComposition();
533
+ if (morph && lineLayer && previousOutlines)
534
+ districtMorph.start(lineLayer, previousOutlines, {
535
+ duration: morph.duration,
536
+ stroke: colors.district ?? '#71838a',
537
+ });
495
538
  scheduleLabels();
496
539
  status.textContent = `${year} supervisorial districts.`;
497
540
  root.dispatchEvent(new CustomEvent('districtyearchange', { bubbles: true, detail: { year, previousYear } }));
@@ -505,6 +548,10 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
505
548
  let items = [];
506
549
  let destroyed = false;
507
550
  let initialized = false;
551
+ const layerFades = createLayerTransitions(() => initialized && features.layerTransitions && !reducedMotion.matches
552
+ ? features.layerTransitions.duration
553
+ : 0);
554
+ const districtMorph = createDistrictMorph();
508
555
  const frames = createFrameScheduler({
509
556
  request: (callback) => requestAnimationFrame(callback),
510
557
  cancel: (id) => cancelAnimationFrame(id),
@@ -537,6 +584,12 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
537
584
  stopAnimation();
538
585
  if (Object.hasOwn(patch, 'markerEntrance'))
539
586
  cancelEntrances();
587
+ if (Object.hasOwn(patch, 'layerTransitions')) {
588
+ layerFades.cancel();
589
+ districtTransition.cancel();
590
+ }
591
+ if (Object.hasOwn(patch, 'districtMorph'))
592
+ districtMorph.stop();
540
593
  if (Object.hasOwn(patch, 'clustering'))
541
594
  invalidateClusters();
542
595
  syncFurniture();
@@ -1066,9 +1119,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1066
1119
  moveView(target, options);
1067
1120
  return true;
1068
1121
  }
1069
- function setSource(next) {
1070
- if (destroyed)
1071
- return;
1122
+ function prepareSource(next, reset = true) {
1072
1123
  if (!Object.hasOwn(collections, next))
1073
1124
  throw new RangeError(`Unknown neighborhood source: ${next}`);
1074
1125
  const collection = collections[next];
@@ -1139,36 +1190,42 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1139
1190
  area: (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]),
1140
1191
  };
1141
1192
  });
1142
- const hadSelection = !!selected;
1143
- source = next;
1144
- sourceSelect.value = source;
1145
- selected = undefined;
1146
- const revision = ++neighborhoodRevision;
1147
- delete root.dataset.selectedNeighborhood;
1148
- root.dataset.source = source;
1149
- areas.replaceChildren(...nextItems.map((item) => item.node));
1150
- neighborhoodSelect.replaceChildren(element('option', 'No neighborhood selected'));
1151
- neighborhoodSelect.options[0].value = '';
1152
- neighborhoodSelect.append(...nextOptions);
1153
- items = nextItems;
1154
- if (items[0])
1155
- items[0].node.setAttribute('tabindex', '0');
1156
- updateResults();
1157
- updateDetail();
1158
- updateComposition();
1159
- status.textContent =
1160
- mode === 'basemap'
1161
- ? 'San Francisco basemap.'
1162
- : mode === 'districts'
1163
- ? `${year} supervisorial districts. Numbers identify each district.`
1164
- : `${formatSourceLabel(source)}. Select a neighborhood to begin.`;
1165
- if (hadSelection)
1166
- root.dispatchEvent(new CustomEvent('neighborhoodchange', {
1167
- bubbles: true,
1168
- detail: { feature: null, source, id: null, name: null },
1169
- }));
1170
- if (!destroyed && neighborhoodRevision === revision)
1171
- resetView();
1193
+ return () => {
1194
+ const hadSelection = !!selected;
1195
+ source = next;
1196
+ sourceSelect.value = source;
1197
+ selected = undefined;
1198
+ const revision = ++neighborhoodRevision;
1199
+ delete root.dataset.selectedNeighborhood;
1200
+ root.dataset.source = source;
1201
+ layerFades.crossfade(areas, () => areas.replaceChildren(...nextItems.map((item) => item.node)));
1202
+ neighborhoodSelect.replaceChildren(element('option', 'No neighborhood selected'));
1203
+ neighborhoodSelect.options[0].value = '';
1204
+ neighborhoodSelect.append(...nextOptions);
1205
+ items = nextItems;
1206
+ if (items[0])
1207
+ items[0].node.setAttribute('tabindex', '0');
1208
+ updateResults();
1209
+ updateDetail();
1210
+ updateComposition();
1211
+ status.textContent =
1212
+ mode === 'basemap'
1213
+ ? 'San Francisco basemap.'
1214
+ : mode === 'districts'
1215
+ ? `${year} supervisorial districts. Numbers identify each district.`
1216
+ : `${formatSourceLabel(source)}. Select a neighborhood to begin.`;
1217
+ if (hadSelection)
1218
+ root.dispatchEvent(new CustomEvent('neighborhoodchange', {
1219
+ bubbles: true,
1220
+ detail: { feature: null, source, id: null, name: null },
1221
+ }));
1222
+ if (reset && !destroyed && neighborhoodRevision === revision)
1223
+ resetView();
1224
+ };
1225
+ }
1226
+ function setSource(next) {
1227
+ if (!destroyed)
1228
+ prepareSource(next)();
1172
1229
  }
1173
1230
  function syncFeatureControls() {
1174
1231
  featureControls.hidden = neighborhoodLabel.hidden && markerLabel.hidden;
@@ -1189,9 +1246,9 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1189
1246
  ]) {
1190
1247
  const layer = svg.querySelector(`[data-layer="${name}"]`);
1191
1248
  if (layer)
1192
- layer.style.display = enabled(key) ? '' : 'none';
1249
+ layerFades.set(layer, enabled(key));
1193
1250
  }
1194
- stations.style.display = enabled('bartStations') ? '' : 'none';
1251
+ layerFades.set(stations, enabled('bartStations'));
1195
1252
  for (const [kind, key] of [
1196
1253
  ['bart', 'bartStations'],
1197
1254
  ['park', 'landmarks'],
@@ -1206,7 +1263,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1206
1263
  controls.legend === false ||
1207
1264
  ![...legend.children].some((node) => !node.hidden);
1208
1265
  const neighborhoodsVisible = enabled('neighborhoodLines') || (labels && enabled('neighborhoodLabels'));
1209
- areas.style.display = neighborhoodsVisible ? '' : 'none';
1266
+ layerFades.set(areas, neighborhoodsVisible);
1210
1267
  areas.style.pointerEvents = selectableNeighborhoods ? '' : 'none';
1211
1268
  neighborhoodLabel.hidden =
1212
1269
  !neighborhoodsVisible ||
@@ -1223,7 +1280,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1223
1280
  }
1224
1281
  for (const layer of districtLayers) {
1225
1282
  const key = layer.dataset.layer === 'district-fills' ? 'districtFills' : 'districtLines';
1226
- layer.style.display = enabled(key) ? '' : 'none';
1283
+ layerFades.set(layer, enabled(key));
1227
1284
  }
1228
1285
  updateDistrictAppearance();
1229
1286
  const districtsVisible = enabled('districtFills') || enabled('districtLines') || (labels && enabled('districtLabels'));
@@ -1512,12 +1569,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1512
1569
  districtRow(districtId)) {
1513
1570
  if (event.key === 'Enter' || event.key === ' ') {
1514
1571
  event.preventDefault();
1515
- selectDistrict(districtId);
1516
- root.dispatchEvent(new CustomEvent('districtactivate', {
1517
- bubbles: true,
1518
- detail: districtSelection(districtId),
1519
- }));
1520
- target.focus();
1572
+ if (activateDistrict(districtId))
1573
+ target.focus();
1521
1574
  }
1522
1575
  else if (event.key === '[' || event.key === ']') {
1523
1576
  event.preventDefault();
@@ -1568,9 +1621,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1568
1621
  ? event.target.closest('[data-layer="district-fills"] [data-district], [data-layer="district-lines"] [data-district]')
1569
1622
  : null;
1570
1623
  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) }));
1624
+ activateDistrict(Number(district.dataset.district));
1574
1625
  return;
1575
1626
  }
1576
1627
  const node = event.target instanceof Element
@@ -1607,7 +1658,133 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1607
1658
  scheduleLabels();
1608
1659
  root.dispatchEvent(new CustomEvent('mapresize', { bubbles: true }));
1609
1660
  };
1661
+ let presentationRevision = 0;
1662
+ function applyPresentation(patch, onCommit, isCurrent = () => true) {
1663
+ const revision = ++presentationRevision;
1664
+ const nextYear = patch.year ?? year;
1665
+ const nextMode = patch.mode ?? mode;
1666
+ if ((patch.year !== undefined || nextMode === 'districts') &&
1667
+ (!data.map.districts?.[nextYear] || !data.districts?.[nextYear]))
1668
+ throw new RangeError(`No ${nextYear} district dataset was supplied.`);
1669
+ if (nextMode === 'neighborhoods' && !sources.length)
1670
+ throw new RangeError('No neighborhood dataset was supplied.');
1671
+ const commitSource = patch.source !== undefined && patch.source !== source
1672
+ ? prepareSource(patch.source, false)
1673
+ : undefined;
1674
+ const appearance = patch.appearance;
1675
+ const nextStyle = appearance ? appearance.districtStyle : districtStyle;
1676
+ const districtVersion = districtRevision;
1677
+ const prepared = appearance || nextYear !== year
1678
+ ? prepareDistrictStyles(data.map.districts?.[nextYear] ?? [], nextStyle)
1679
+ : districtStyles;
1680
+ if (destroyed ||
1681
+ !isCurrent() ||
1682
+ revision !== presentationRevision ||
1683
+ districtVersion !== districtRevision)
1684
+ return false;
1685
+ if (nextYear !== year) {
1686
+ // Validate target paths and label coordinates before any presentation group commits.
1687
+ getLayerPathsWithData({ year: nextYear }, data.map, false);
1688
+ for (const feature of data.districts?.[nextYear]?.features ?? [])
1689
+ for (const point of feature.properties.labelPoints)
1690
+ map.project(point);
1691
+ }
1692
+ onCommit?.();
1693
+ if (appearance) {
1694
+ districtRevision++;
1695
+ const next = copyAppearance(appearance);
1696
+ theme = next.theme ?? 'transit';
1697
+ colors = next.colors ?? {};
1698
+ labelStyle = next.labelStyle ?? {};
1699
+ areaStyle = next.areaStyle ?? {};
1700
+ styleOptions = next.style ?? {};
1701
+ minLabelSize = next.labelSize?.min ?? 11;
1702
+ maxLabelSize = next.labelSize?.max ?? 12;
1703
+ markerRadius = next.markerRadius ?? 6;
1704
+ markerHitSize = next.markerHitSize ?? 44;
1705
+ markerColor = next.markerColor ?? colors.marker ?? '#245b61';
1706
+ selectedMarkerColor = next.selectedMarkerColor ?? colors.selected ?? '#f04f32';
1707
+ districtTransition.cancel();
1708
+ const fills = districtLayers.find((layer) => layer.dataset.layer === 'district-fills');
1709
+ const fade = layerFades.duration();
1710
+ const stylesChanged = nextStyle !== districtStyle ||
1711
+ [...prepared].some(([id, value]) => ['fill', 'stroke', 'opacity'].some((key) => value[key] !== districtStyles.get(id)?.[key]));
1712
+ if (fills && fade && enabled('districtFills') && nextYear === year && stylesChanged)
1713
+ districtTransition.fade(fills, fade);
1714
+ districtStyle = nextStyle;
1715
+ districtStyles = prepared;
1716
+ const palette = resolveMapColors(theme, colors);
1717
+ svg.querySelector('rect')?.setAttribute('fill', palette.water);
1718
+ svg.querySelector('[data-layer="coast"]')?.setAttribute('fill', palette.land);
1719
+ svg.querySelector('[data-layer="coastline"]')?.setAttribute('stroke', palette.district);
1720
+ for (const [layer, color] of [
1721
+ ['landmarks', palette.park],
1722
+ ['highways', palette.highway],
1723
+ ['key-roads', palette.road],
1724
+ ])
1725
+ for (const node of svg.querySelectorAll(`[data-layer="${layer}"] path`)) {
1726
+ node.setAttribute(layer === 'landmarks' ? 'fill' : 'stroke', color);
1727
+ if (layer === 'highways')
1728
+ node.setAttribute('stroke-width', theme === 'transit' ? '2' : '1.4');
1729
+ }
1730
+ for (const [token, fallback, name] of [
1731
+ ['ink', '#18364f', 'ink'],
1732
+ ['surface', '#fff', 'surface'],
1733
+ ['accent', '#163d61', 'accent'],
1734
+ ['border', '#cedae3', 'border'],
1735
+ ['focus', '#1676b8', 'focus'],
1736
+ ['controlGap', '6px', 'control-gap'],
1737
+ ['font', 'system-ui,sans-serif', 'font'],
1738
+ ])
1739
+ root.style.setProperty(`--sf-map-${name}`, styleOptions[token] ?? fallback);
1740
+ labelLayer.setAttribute('font-family', labelStyle.fontFamily ?? styleOptions.font ?? 'system-ui,sans-serif');
1741
+ labelRenderer.invalidateMetrics();
1742
+ for (const station of stationItems)
1743
+ station.node.setAttribute('stroke', colors.bart ?? '#0073ae');
1744
+ for (const kind of ['bart', 'park', 'highway', 'road']) {
1745
+ const symbol = legend.querySelector(`.sf-explorer-legend-${kind}`);
1746
+ if (symbol) {
1747
+ symbol.style.removeProperty(kind === 'bart' ? 'border-color' : 'background-color');
1748
+ if (colors[kind])
1749
+ symbol.style.setProperty(kind === 'bart' ? 'border-color' : 'background-color', colors[kind]);
1750
+ }
1751
+ }
1752
+ for (const item of items) {
1753
+ const active = item.feature === selected;
1754
+ item.node.setAttribute('fill', active ? (areaStyle.selectedFill ?? '#408dbe') : 'transparent');
1755
+ item.node.setAttribute('stroke', active ? (areaStyle.selectedStroke ?? '#176ba2') : (colors.neighborhood ?? '#9caebc'));
1756
+ }
1757
+ for (const item of markerItems) {
1758
+ item.dot.setAttribute('fill', item.marker.id === selectedMarker
1759
+ ? selectedMarkerColor
1760
+ : (item.marker.color ?? markerColor));
1761
+ item.ring.setAttribute('stroke', features.selectedMarkerRing
1762
+ ? (features.selectedMarkerRing.color ?? selectedMarkerColor)
1763
+ : selectedMarkerColor);
1764
+ }
1765
+ invalidateClusters();
1766
+ updateDistrictAppearance();
1767
+ }
1768
+ if (nextYear !== year)
1769
+ setDistrictYear(nextYear, {}, prepared);
1770
+ if (destroyed || revision !== presentationRevision || !isCurrent())
1771
+ return false;
1772
+ commitSource?.();
1773
+ if (destroyed || revision !== presentationRevision || !isCurrent())
1774
+ return false;
1775
+ if (commitSource && appearance)
1776
+ for (const item of items)
1777
+ item.node.setAttribute('stroke', colors.neighborhood ?? '#9caebc');
1778
+ if (patch.mode !== undefined)
1779
+ setMode(patch.mode, false);
1780
+ if (patch.labels !== undefined)
1781
+ setLabels(patch.labels);
1782
+ scheduleLabels();
1783
+ return true;
1784
+ }
1610
1785
  const explorer = Object.assign(root, {
1786
+ applyPresentation,
1787
+ getMapState: () => ({ mode, source, year, labels }),
1611
1788
  selectNeighborhood,
1612
1789
  getSelection,
1613
1790
  selectDistrict,
@@ -1639,6 +1816,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1639
1816
  destroyed = true;
1640
1817
  camera.destroy();
1641
1818
  districtTransition.cancel();
1819
+ layerFades.cancel();
1820
+ districtMorph.stop();
1642
1821
  cancelEntrances();
1643
1822
  controller.abort();
1644
1823
  overlayEvents.abort();
@@ -1661,6 +1840,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1661
1840
  if (reducedMotion.matches) {
1662
1841
  stopAnimation();
1663
1842
  districtTransition.cancel();
1843
+ layerFades.cancel();
1844
+ districtMorph.stop();
1664
1845
  cancelEntrances();
1665
1846
  }
1666
1847
  }, { 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 = [
@@ -6,7 +6,7 @@ import { guideOptions } from './presets.js';
6
6
  import { validateExplorerOptions } from './validation.js';
7
7
  function controllerOptions(options) {
8
8
  expandMapOptions(options);
9
- const config = prepareConfiguration({ features: {}, layers: guideOptions.layers ?? {}, controls: {} }, {
9
+ const config = prepareConfiguration({ features: {}, layers: guideOptions.layers ?? {}, controls: {}, appearance: {} }, {
10
10
  features: options.features,
11
11
  layers: options.layers === undefined ? {} : options.layers,
12
12
  controls: options.controls,
@@ -1,9 +1,9 @@
1
1
  import { guideMapData } from './guide-data.js';
2
2
  import { guideShellCSS } from './guide-shell.js';
3
- import { createSFMapWithData } from './map-core.js';
3
+ import { renderMap } from './static.js';
4
4
  /** Server-safe overview using exactly the guide browser geography. */
5
5
  export function createGuideSVG(options = {}) {
6
- return createSFMapWithData({
6
+ return renderMap(guideMapData.map, {
7
7
  theme: 'transit',
8
8
  districtFills: false,
9
9
  districtLines: false,
@@ -14,7 +14,7 @@ export function createGuideSVG(options = {}) {
14
14
  keyRoads: true,
15
15
  bartStations: true,
16
16
  ...options,
17
- }, guideMapData.map);
17
+ });
18
18
  }
19
19
  /** Stable compact frame, progressively enhanced with mountGuideMap(). No browser globals. */
20
20
  export function createGuideShell(options = {}) {
@@ -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
+ }