@kahwee/sf-map-svg 4.1.0 → 4.3.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,3 +1,4 @@
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';
@@ -11,7 +12,7 @@ import { createFrameScheduler } from './frame-scheduler.js';
11
12
  import { geometryPath } from './geometry.js';
12
13
  import { createLabelRenderer } from './label-renderer.js';
13
14
  import { createLayerTransitions } from './layer-transition.js';
14
- import { createSFMapWithData, districtColors, getLayerPathsWithData } from './map-core.js';
15
+ import { createSFMapWithData, districtColors, getLayerPathsWithData, resolveMapColors, } from './map-core.js';
15
16
  import { createMarkerLayer } from './marker-layer.js';
16
17
  import { attachNavigation } from './navigation.js';
17
18
  import { validateExplorerOptions, validateMarkers, validateOverlays } from './validation.js';
@@ -37,7 +38,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
37
38
  throw new RangeError(`Unknown map mode: ${mode}`);
38
39
  if (chrome !== 'explorer' && chrome !== 'map')
39
40
  throw new RangeError('Unknown interface.');
40
- const minLabelSize = labelSize.min ?? 11, maxLabelSize = labelSize.max ?? 12;
41
+ let minLabelSize = labelSize.min ?? 11, maxLabelSize = labelSize.max ?? 12;
41
42
  if (![minLabelSize, maxLabelSize, markerRadius, markerHitSize].every(Number.isFinite) ||
42
43
  minLabelSize < 8 ||
43
44
  maxLabelSize < minLabelSize ||
@@ -464,7 +465,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
464
465
  districtTransition.fade(fills, fade);
465
466
  updateDistrictAppearance();
466
467
  }
467
- function setDistrictYear(next, options = {}) {
468
+ function setDistrictYear(next, options = {}, preparedStyles) {
468
469
  if (destroyed)
469
470
  return;
470
471
  validateCameraOptions(options);
@@ -475,7 +476,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
475
476
  if (next === year)
476
477
  return;
477
478
  const revision = districtRevision;
478
- const prepared = prepareDistrictStyles(data.map.districts[next], districtStyle);
479
+ const prepared = preparedStyles ?? prepareDistrictStyles(data.map.districts[next], districtStyle);
479
480
  if (destroyed || revision !== districtRevision)
480
481
  return;
481
482
  const committedRevision = ++districtRevision;
@@ -1063,7 +1064,10 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1063
1064
  function selectNeighborhood(name, options = {}) {
1064
1065
  if (destroyed)
1065
1066
  return false;
1066
- const feature = name === null ? undefined : getNeighborhood(name, source);
1067
+ const feature = name === null
1068
+ ? undefined
1069
+ : (collections[source]?.features.find((entry) => entry.id === name) ??
1070
+ getNeighborhood(name, source));
1067
1071
  if (name !== null && !feature)
1068
1072
  return false;
1069
1073
  validateCameraOptions(options);
@@ -1118,9 +1122,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1118
1122
  moveView(target, options);
1119
1123
  return true;
1120
1124
  }
1121
- function setSource(next) {
1122
- if (destroyed)
1123
- return;
1125
+ function prepareSource(next, reset = true) {
1124
1126
  if (!Object.hasOwn(collections, next))
1125
1127
  throw new RangeError(`Unknown neighborhood source: ${next}`);
1126
1128
  const collection = collections[next];
@@ -1191,36 +1193,42 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1191
1193
  area: (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]),
1192
1194
  };
1193
1195
  });
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 (!destroyed && neighborhoodRevision === revision)
1223
- resetView();
1196
+ return () => {
1197
+ const hadSelection = !!selected;
1198
+ source = next;
1199
+ sourceSelect.value = source;
1200
+ selected = undefined;
1201
+ const revision = ++neighborhoodRevision;
1202
+ delete root.dataset.selectedNeighborhood;
1203
+ root.dataset.source = source;
1204
+ layerFades.crossfade(areas, () => areas.replaceChildren(...nextItems.map((item) => item.node)));
1205
+ neighborhoodSelect.replaceChildren(element('option', 'No neighborhood selected'));
1206
+ neighborhoodSelect.options[0].value = '';
1207
+ neighborhoodSelect.append(...nextOptions);
1208
+ items = nextItems;
1209
+ if (items[0])
1210
+ items[0].node.setAttribute('tabindex', '0');
1211
+ updateResults();
1212
+ updateDetail();
1213
+ updateComposition();
1214
+ status.textContent =
1215
+ mode === 'basemap'
1216
+ ? 'San Francisco basemap.'
1217
+ : mode === 'districts'
1218
+ ? `${year} supervisorial districts. Numbers identify each district.`
1219
+ : `${formatSourceLabel(source)}. Select a neighborhood to begin.`;
1220
+ if (hadSelection)
1221
+ root.dispatchEvent(new CustomEvent('neighborhoodchange', {
1222
+ bubbles: true,
1223
+ detail: { feature: null, source, id: null, name: null },
1224
+ }));
1225
+ if (reset && !destroyed && neighborhoodRevision === revision)
1226
+ resetView();
1227
+ };
1228
+ }
1229
+ function setSource(next) {
1230
+ if (!destroyed)
1231
+ prepareSource(next)();
1224
1232
  }
1225
1233
  function syncFeatureControls() {
1226
1234
  featureControls.hidden = neighborhoodLabel.hidden && markerLabel.hidden;
@@ -1653,7 +1661,135 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1653
1661
  scheduleLabels();
1654
1662
  root.dispatchEvent(new CustomEvent('mapresize', { bubbles: true }));
1655
1663
  };
1664
+ let presentationRevision = 0;
1665
+ function applyPresentation(patch, onCommit, isCurrent = () => true) {
1666
+ const revision = ++presentationRevision;
1667
+ const nextYear = patch.year ?? year;
1668
+ const nextMode = patch.mode ?? mode;
1669
+ if ((patch.year !== undefined || nextMode === 'districts') &&
1670
+ (!data.map.districts?.[nextYear] || !data.districts?.[nextYear]))
1671
+ throw new RangeError(`No ${nextYear} district dataset was supplied.`);
1672
+ if (nextMode === 'neighborhoods' && !sources.length)
1673
+ throw new RangeError('No neighborhood dataset was supplied.');
1674
+ if (patch.source !== undefined && !Object.hasOwn(collections, patch.source))
1675
+ throw new RangeError(`Unknown neighborhood source: ${patch.source}`);
1676
+ const commitSource = patch.source !== undefined && patch.source !== source
1677
+ ? prepareSource(patch.source, false)
1678
+ : undefined;
1679
+ const appearance = patch.appearance;
1680
+ const nextStyle = appearance ? appearance.districtStyle : districtStyle;
1681
+ const districtVersion = districtRevision;
1682
+ const prepared = appearance || nextYear !== year
1683
+ ? prepareDistrictStyles(data.map.districts?.[nextYear] ?? [], nextStyle)
1684
+ : districtStyles;
1685
+ if (destroyed ||
1686
+ !isCurrent() ||
1687
+ revision !== presentationRevision ||
1688
+ districtVersion !== districtRevision)
1689
+ return false;
1690
+ if (nextYear !== year) {
1691
+ // Validate target paths and label coordinates before any presentation group commits.
1692
+ getLayerPathsWithData({ year: nextYear }, data.map, false);
1693
+ for (const feature of data.districts?.[nextYear]?.features ?? [])
1694
+ for (const point of feature.properties.labelPoints)
1695
+ map.project(point);
1696
+ }
1697
+ onCommit?.();
1698
+ if (appearance) {
1699
+ districtRevision++;
1700
+ const next = copyAppearance(appearance);
1701
+ theme = next.theme ?? 'transit';
1702
+ colors = next.colors ?? {};
1703
+ labelStyle = next.labelStyle ?? {};
1704
+ areaStyle = next.areaStyle ?? {};
1705
+ styleOptions = next.style ?? {};
1706
+ minLabelSize = next.labelSize?.min ?? 11;
1707
+ maxLabelSize = next.labelSize?.max ?? 12;
1708
+ markerRadius = next.markerRadius ?? 6;
1709
+ markerHitSize = next.markerHitSize ?? 44;
1710
+ markerColor = next.markerColor ?? colors.marker ?? '#245b61';
1711
+ selectedMarkerColor = next.selectedMarkerColor ?? colors.selected ?? '#f04f32';
1712
+ districtTransition.cancel();
1713
+ const fills = districtLayers.find((layer) => layer.dataset.layer === 'district-fills');
1714
+ const fade = layerFades.duration();
1715
+ const stylesChanged = nextStyle !== districtStyle ||
1716
+ [...prepared].some(([id, value]) => ['fill', 'stroke', 'opacity'].some((key) => value[key] !== districtStyles.get(id)?.[key]));
1717
+ if (fills && fade && enabled('districtFills') && nextYear === year && stylesChanged)
1718
+ districtTransition.fade(fills, fade);
1719
+ districtStyle = nextStyle;
1720
+ districtStyles = prepared;
1721
+ const palette = resolveMapColors(theme, colors);
1722
+ svg.querySelector('rect')?.setAttribute('fill', palette.water);
1723
+ svg.querySelector('[data-layer="coast"]')?.setAttribute('fill', palette.land);
1724
+ svg.querySelector('[data-layer="coastline"]')?.setAttribute('stroke', palette.district);
1725
+ for (const [layer, color] of [
1726
+ ['landmarks', palette.park],
1727
+ ['highways', palette.highway],
1728
+ ['key-roads', palette.road],
1729
+ ])
1730
+ for (const node of svg.querySelectorAll(`[data-layer="${layer}"] path`)) {
1731
+ node.setAttribute(layer === 'landmarks' ? 'fill' : 'stroke', color);
1732
+ if (layer === 'highways')
1733
+ node.setAttribute('stroke-width', theme === 'transit' ? '2' : '1.4');
1734
+ }
1735
+ for (const [token, fallback, name] of [
1736
+ ['ink', '#18364f', 'ink'],
1737
+ ['surface', '#fff', 'surface'],
1738
+ ['accent', '#163d61', 'accent'],
1739
+ ['border', '#cedae3', 'border'],
1740
+ ['focus', '#1676b8', 'focus'],
1741
+ ['controlGap', '6px', 'control-gap'],
1742
+ ['font', 'system-ui,sans-serif', 'font'],
1743
+ ])
1744
+ root.style.setProperty(`--sf-map-${name}`, styleOptions[token] ?? fallback);
1745
+ labelLayer.setAttribute('font-family', labelStyle.fontFamily ?? styleOptions.font ?? 'system-ui,sans-serif');
1746
+ labelRenderer.invalidateMetrics();
1747
+ for (const station of stationItems)
1748
+ station.node.setAttribute('stroke', colors.bart ?? '#0073ae');
1749
+ for (const kind of ['bart', 'park', 'highway', 'road']) {
1750
+ const symbol = legend.querySelector(`.sf-explorer-legend-${kind}`);
1751
+ if (symbol) {
1752
+ symbol.style.removeProperty(kind === 'bart' ? 'border-color' : 'background-color');
1753
+ if (colors[kind])
1754
+ symbol.style.setProperty(kind === 'bart' ? 'border-color' : 'background-color', colors[kind]);
1755
+ }
1756
+ }
1757
+ for (const item of items) {
1758
+ const active = item.feature === selected;
1759
+ item.node.setAttribute('fill', active ? (areaStyle.selectedFill ?? '#408dbe') : 'transparent');
1760
+ item.node.setAttribute('stroke', active ? (areaStyle.selectedStroke ?? '#176ba2') : (colors.neighborhood ?? '#9caebc'));
1761
+ }
1762
+ for (const item of markerItems) {
1763
+ item.dot.setAttribute('fill', item.marker.id === selectedMarker
1764
+ ? selectedMarkerColor
1765
+ : (item.marker.color ?? markerColor));
1766
+ item.ring.setAttribute('stroke', features.selectedMarkerRing
1767
+ ? (features.selectedMarkerRing.color ?? selectedMarkerColor)
1768
+ : selectedMarkerColor);
1769
+ }
1770
+ invalidateClusters();
1771
+ updateDistrictAppearance();
1772
+ }
1773
+ if (nextYear !== year)
1774
+ setDistrictYear(nextYear, {}, prepared);
1775
+ if (destroyed || revision !== presentationRevision || !isCurrent())
1776
+ return false;
1777
+ commitSource?.();
1778
+ if (destroyed || revision !== presentationRevision || !isCurrent())
1779
+ return false;
1780
+ if (commitSource && appearance)
1781
+ for (const item of items)
1782
+ item.node.setAttribute('stroke', colors.neighborhood ?? '#9caebc');
1783
+ if (patch.mode !== undefined)
1784
+ setMode(patch.mode, false);
1785
+ if (patch.labels !== undefined)
1786
+ setLabels(patch.labels);
1787
+ scheduleLabels();
1788
+ return true;
1789
+ }
1656
1790
  const explorer = Object.assign(root, {
1791
+ applyPresentation,
1792
+ getMapState: () => ({ mode, source, year, labels }),
1657
1793
  selectNeighborhood,
1658
1794
  getSelection,
1659
1795
  selectDistrict,
@@ -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 = {}) {
@@ -80,6 +80,20 @@ export declare function getLayerPathsWithData(options: SFMapOptions, data: SFMap
80
80
  point: [number, number];
81
81
  }[];
82
82
  };
83
+ export declare function resolveMapColors(theme: SFMapOptions['theme'], colors?: SFMapOptions['colors']): {
84
+ water: string;
85
+ land: string;
86
+ district: string;
87
+ neighborhood: string;
88
+ highway: string;
89
+ road: string;
90
+ park: string;
91
+ landmark: string;
92
+ bart: string;
93
+ label: string;
94
+ marker: string;
95
+ selected: string;
96
+ };
83
97
  /** Make an offline SVG and the matching longitude/latitude projection. */
84
98
  export declare function createSFMapWithData(options: SFMapOptions, data: SFMapData): {
85
99
  svg: string;
@@ -104,6 +104,9 @@ const transitColors = {
104
104
  marker: '#0073ae',
105
105
  selected: '#0073ae',
106
106
  };
107
+ export function resolveMapColors(theme, colors = {}) {
108
+ return { ...defaults, ...(theme === 'transit' ? transitColors : {}), ...colors };
109
+ }
107
110
  let sequence = 0;
108
111
  /** Make an offline SVG and the matching longitude/latitude projection. */
109
112
  export function createSFMapWithData(options, data) {
@@ -115,7 +118,7 @@ export function createSFMapWithData(options, data) {
115
118
  throw new TypeError('idPrefix must start with a letter and contain only letters, numbers, underscores, or hyphens.');
116
119
  if (theme !== 'districts' && theme !== 'transit')
117
120
  throw new TypeError('Theme must be districts or transit.');
118
- const colors = { ...defaults, ...(theme === 'transit' ? transitColors : {}), ...options.colors };
121
+ const colors = resolveMapColors(theme, options.colors);
119
122
  const animation = normalizeStaticAnimation(options.animation);
120
123
  const animated = animation !== null;
121
124
  const geometry = getLayerPathsWithData(options, data, false);
package/dist/src/map.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { copyAppearance } from './appearance.js';
1
2
  import { validateCameraOptions } from './camera.js';
2
3
  import { expandMapOptions, prepareConfiguration } from './configuration.js';
3
4
  import { createNeighborhoodExplorerCore } from './explorer-core.js';
@@ -6,10 +7,11 @@ import { assertOptions } from './validation.js';
6
7
  /** No geography is imported. Supply a preset or your own immutable data. */
7
8
  export function createMap(data, options = {}) {
8
9
  const expanded = expandMapOptions(options);
9
- let config = prepareConfiguration({ features: {}, layers: {}, controls: {} }, {
10
+ let config = prepareConfiguration({ features: {}, layers: {}, controls: {}, appearance: {} }, {
10
11
  features: options.features,
11
12
  layers: options.layers,
12
13
  controls: options.controls,
14
+ appearance: options.appearance,
13
15
  });
14
16
  const element = createNeighborhoodExplorerCore({
15
17
  mode: 'basemap',
@@ -18,6 +20,81 @@ export function createMap(data, options = {}) {
18
20
  onOverlayActivate: () => { },
19
21
  }, data);
20
22
  const subscriptions = new Set();
23
+ let configurationRevision = 0;
24
+ const sourceList = ['realtor', 'sf-find', 'analysis'].filter((source) => !!data.neighborhoods[source]);
25
+ const yearList = [2002, 2012, 2022].filter((year) => !!data.map.districts?.[year] && !!data.districts?.[year]);
26
+ const capabilities = () => ({
27
+ sources: [...sourceList],
28
+ years: [...yearList],
29
+ layers: Object.fromEntries(layerKeys.map((key) => [
30
+ key,
31
+ key.startsWith('district')
32
+ ? !!data.map.districts?.[element.getMapState().year]?.length &&
33
+ yearList.includes(element.getMapState().year)
34
+ : key.startsWith('neighborhood')
35
+ ? !!data.neighborhoods[element.getMapState().source]?.features.length
36
+ : key === 'roadLabels'
37
+ ? !!data.map.keyRoads?.length
38
+ : !!data.map[key]?.length,
39
+ ])),
40
+ });
41
+ const snapshot = () => ({
42
+ ...element.getMapState(),
43
+ ...structuredClone({
44
+ features: config.features,
45
+ layers: config.layers,
46
+ controls: config.controls,
47
+ }),
48
+ appearance: copyAppearance(config.appearance),
49
+ });
50
+ const resolved = () => {
51
+ const state = element.getMapState();
52
+ const supplied = capabilities().layers;
53
+ return {
54
+ ...snapshot(),
55
+ ...state,
56
+ layers: Object.fromEntries(layerKeys.map((key) => [
57
+ key,
58
+ supplied[key] &&
59
+ (!key.endsWith('Labels') || state.labels) &&
60
+ (config.layers[key] ??
61
+ (key.startsWith('district')
62
+ ? state.mode === 'districts'
63
+ : key.startsWith('neighborhood')
64
+ ? state.mode === 'neighborhoods'
65
+ : true)),
66
+ ])),
67
+ };
68
+ };
69
+ let previousMarker = element.getSelectedMarker();
70
+ let previousNeighborhood = element.getSelection();
71
+ let previousDistrict = element.getSelectedDistrict();
72
+ const selectionListeners = [];
73
+ for (const type of ['markerchange', 'neighborhoodchange', 'districtchange']) {
74
+ const handle = (event) => {
75
+ if (event.target !== element)
76
+ return;
77
+ let detail;
78
+ if (type === 'markerchange') {
79
+ const current = element.getSelectedMarker();
80
+ detail = { kind: 'marker', current, previous: previousMarker };
81
+ previousMarker = current;
82
+ }
83
+ else if (type === 'neighborhoodchange') {
84
+ const current = element.getSelection();
85
+ detail = { kind: 'neighborhood', current, previous: previousNeighborhood };
86
+ previousNeighborhood = current;
87
+ }
88
+ else {
89
+ const current = element.getSelectedDistrict();
90
+ detail = { kind: 'district', current, previous: previousDistrict };
91
+ previousDistrict = current;
92
+ }
93
+ element.dispatchEvent(new CustomEvent('selectionchange', { detail: structuredClone(detail) }));
94
+ };
95
+ element.addEventListener(type, handle);
96
+ selectionListeners.push(() => element.removeEventListener(type, handle));
97
+ }
21
98
  let destroyed = false;
22
99
  function active() {
23
100
  if (destroyed)
@@ -41,6 +118,38 @@ export function createMap(data, options = {}) {
41
118
  element.fitGeometry(geometry, padding, motion);
42
119
  }),
43
120
  });
121
+ function configure(patch) {
122
+ const revision = ++configurationRevision;
123
+ const next = prepareConfiguration(config, patch);
124
+ const hasFeatures = 'features' in patch, hasLayers = 'layers' in patch, hasControls = 'controls' in patch;
125
+ const layers = Object.fromEntries(layerKeys.map((key) => [key, next.layers[key]]));
126
+ const controls = Object.fromEntries(controlKeys.map((key) => [key, next.controls[key]]));
127
+ const features = patch.features === undefined
128
+ ? next.features
129
+ : Object.fromEntries(Object.keys(patch.features).map((key) => [
130
+ key,
131
+ next.features[key],
132
+ ]));
133
+ let committed = false;
134
+ element.applyPresentation({ ...patch, ...('appearance' in patch ? { appearance: next.appearance } : {}) }, () => {
135
+ committed = true;
136
+ config = next;
137
+ if (hasFeatures)
138
+ element.setFeatures(features);
139
+ if (hasLayers)
140
+ element.setLayers(layers);
141
+ if (hasControls)
142
+ element.setControls(controls);
143
+ }, () => !destroyed && revision === configurationRevision);
144
+ return committed && !destroyed && revision === configurationRevision;
145
+ }
146
+ function updateView(patch, options = {}) {
147
+ assertOptions(options, 'view update', ['resetView']);
148
+ if (options.resetView !== undefined && typeof options.resetView !== 'boolean')
149
+ throw new TypeError('resetView must be a boolean.');
150
+ if (configure(patch) && options.resetView !== false)
151
+ element.resetView();
152
+ }
44
153
  return Object.freeze({
45
154
  element,
46
155
  overlayElement: element.overlayElement,
@@ -49,21 +158,14 @@ export function createMap(data, options = {}) {
49
158
  return destroyed;
50
159
  },
51
160
  configure: use((patch) => {
52
- const next = prepareConfiguration(config, patch);
53
- // Full switch patches are needed so removed overrides reset engine defaults.
54
- const layers = Object.fromEntries(layerKeys.map((key) => [key, next.layers[key]]));
55
- const controls = Object.fromEntries(controlKeys.map((key) => [key, next.controls[key]]));
56
- if ('features' in patch)
57
- element.setFeatures(patch.features ?? next.features);
58
- if ('layers' in patch)
59
- element.setLayers(layers);
60
- if ('controls' in patch)
61
- element.setControls(controls);
62
- config = next;
161
+ configure(patch);
63
162
  }),
64
- getConfiguration: use(() => structuredClone(config)),
163
+ getConfiguration: use(snapshot),
164
+ getResolvedConfiguration: use(resolved),
165
+ getCapabilities: use(capabilities),
65
166
  on: use((type, listener) => {
66
167
  if (![
168
+ 'selectionchange',
67
169
  'markerchange',
68
170
  'districtchange',
69
171
  'districthover',
@@ -91,17 +193,59 @@ export function createMap(data, options = {}) {
91
193
  }),
92
194
  setMarkers: use(element.setMarkers),
93
195
  setOverlays: use(element.setOverlays),
196
+ selectFeature: use((reference, options) => {
197
+ assertOptions(reference, 'feature reference');
198
+ if (!['marker', 'neighborhood', 'district'].includes(reference.kind))
199
+ throw new TypeError('Unknown feature kind.');
200
+ assertOptions(reference, 'feature reference', reference.kind === 'marker'
201
+ ? ['kind', 'id']
202
+ : reference.kind === 'neighborhood'
203
+ ? ['kind', 'id', 'source']
204
+ : ['kind', 'id', 'year']);
205
+ if (reference.kind === 'district') {
206
+ if (reference.id !== null &&
207
+ (typeof reference.id !== 'number' || !Number.isFinite(reference.id)))
208
+ throw new TypeError('District IDs must be finite numbers or null.');
209
+ if (![2002, 2012, 2022].includes(reference.year))
210
+ throw new RangeError('Unknown district year.');
211
+ if (reference.year !== element.getMapState().year || !yearList.includes(reference.year))
212
+ return false;
213
+ return element.selectDistrict(reference.id, options);
214
+ }
215
+ if (reference.id !== null && (typeof reference.id !== 'string' || !reference.id))
216
+ throw new TypeError('Feature IDs must be nonempty strings or null.');
217
+ if (reference.kind === 'marker')
218
+ return element.selectMarker(reference.id, options);
219
+ if (!['realtor', 'sf-find', 'analysis'].includes(reference.source))
220
+ throw new RangeError('Unknown neighborhood source.');
221
+ if (reference.source !== element.getMapState().source ||
222
+ !sourceList.includes(reference.source))
223
+ return false;
224
+ const feature = reference.id === null
225
+ ? null
226
+ : data.neighborhoods[reference.source]?.features.find((entry) => entry.id === reference.id);
227
+ if (reference.id !== null && !feature)
228
+ return false;
229
+ return element.selectNeighborhood(feature?.id ?? null, options);
230
+ }),
94
231
  selectMarker: use(element.selectMarker),
95
232
  getSelectedMarker: use(element.getSelectedMarker),
96
233
  selectNeighborhood: use(element.selectNeighborhood),
97
234
  getSelectedNeighborhood: use(element.getSelection),
98
235
  selectDistrict: use(element.selectDistrict),
99
236
  getSelectedDistrict: use(element.getSelectedDistrict),
100
- setDistrictYear: use(element.setDistrictYear),
101
- setDistrictStyle: use(element.setDistrictStyle),
102
- setSource: use(element.setSource),
103
- setMode: use(element.setMode),
104
- setLabels: use(element.setLabels),
237
+ setDistrictYear: use((...args) => {
238
+ configurationRevision++;
239
+ element.setDistrictYear(...args);
240
+ }),
241
+ setDistrictStyle: use((style) => {
242
+ configure({ appearance: { districtStyle: style } });
243
+ }),
244
+ setSource: use((source, options) => updateView({ source }, options)),
245
+ setMode: use((mode, options) => updateView({ mode }, options)),
246
+ setLabels: use((labels) => {
247
+ configure({ labels });
248
+ }),
105
249
  setTouchNavigation: use(element.setTouchNavigation),
106
250
  projectToScreen: use(element.projectToScreen),
107
251
  destroy() {
@@ -110,6 +254,8 @@ export function createMap(data, options = {}) {
110
254
  destroyed = true;
111
255
  for (const unsubscribe of subscriptions)
112
256
  unsubscribe();
257
+ for (const unsubscribe of selectionListeners)
258
+ unsubscribe();
113
259
  element.destroy();
114
260
  },
115
261
  });
@@ -1,15 +1,18 @@
1
+ import type { InteractiveSFMapData } from './explorer-data.js';
1
2
  import { type SFMapData } from './map-core.js';
2
3
  import type { SFMapOptions } from './types.js';
4
+ /** Either compact static geography or the same source-aware geography used by createMap. */
5
+ export type StaticMapInput = SFMapData | InteractiveSFMapData;
3
6
  export type { DistrictRowData, SFMapData as StaticMapData } from './map-core.js';
4
- export type { DistrictStyle, DistrictYear, SFMapOptions as StaticMapOptions } from './types.js';
7
+ export type { DistrictStyle, DistrictYear, SFMapOptions as StaticMapOptions, StaticMapAppearance, StaticMapLayers, } from './types.js';
5
8
  /** Server-safe SVG rendering with explicit data; returns SVG plus projection helpers. */
6
- export declare function renderMap(data: SFMapData, options?: SFMapOptions): {
9
+ export declare function renderMap(data: StaticMapInput, options?: SFMapOptions): {
7
10
  svg: string;
8
11
  project: (coordinates: import("../data/types.js").Position) => [number, number];
9
12
  viewBox: [number, number, number, number];
10
13
  };
11
14
  /** Project canonical layer geometry without constructing an SVG string. */
12
- export declare function getLayerPaths(data: SFMapData, options?: SFMapOptions): {
15
+ export declare function getLayerPaths(data: StaticMapInput, options?: SFMapOptions): {
13
16
  year: import("./types.js").DistrictYear;
14
17
  viewBox: [number, number, number, number];
15
18
  project: (coordinates: import("../data/types.js").Position) => [number, number];