@kahwee/sf-map-svg 4.4.0 → 4.5.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,16 +1,16 @@
1
1
  import { copyAppearance } from './appearance.js';
2
2
  import { createCamera, validateCameraOptions } from './camera.js';
3
- import { createClusterRenderer } from './cluster-renderer.js';
3
+ import { createClusterLayer } from './cluster-layer.js';
4
4
  import { clusterPoints } from './clusters.js';
5
5
  import { renderDistrictAppearance } from './district-layer.js';
6
6
  import { createDistrictMorph } from './district-morph.js';
7
7
  import { prepareDistrictStyles } from './district-style.js';
8
8
  import { createDistrictTransition } from './district-transition.js';
9
9
  import { element, setAttributeIfChanged, svgElement } from './dom.js';
10
- import { fitBounds, interiorAnchor, projectedBounds } from './explorer-layout.js';
10
+ import { createExplorerControls } from './explorer-controls.js';
11
+ import { fitBounds, projectedBounds } from './explorer-layout.js';
11
12
  import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from './features.js';
12
13
  import { createFrameScheduler } from './frame-scheduler.js';
13
- import { geometryPath } from './geometry.js';
14
14
  import { labelCandidates, styleLabels, } from './label-candidates.js';
15
15
  import { createLabelRenderer } from './label-renderer.js';
16
16
  import { createLayerTransitions } from './layer-transition.js';
@@ -19,24 +19,12 @@ import { createMarkerCatalog } from './marker-catalog.js';
19
19
  import { createMarkerChooser } from './marker-chooser.js';
20
20
  import { createMarkerLayer } from './marker-layer.js';
21
21
  import { createMarkerNavigation } from './marker-navigation.js';
22
- import { attachNavigation } from './navigation.js';
23
- import { validateExplorerOptions, validateOverlays } from './validation.js';
22
+ import { createNeighborhoodLayer, createNeighborhoodLookup, formatSourceLabel, } from './neighborhood-layer.js';
23
+ import { createOverlayLayer } from './overlay-layer.js';
24
+ import { validateExplorerOptions } from './validation.js';
24
25
  import { fitViewport, validateViewport } from './viewport.js';
25
- const sourceNames = {
26
- realtor: 'SFAR realtor · 92 areas',
27
- 'sf-find': 'SF Find · 117 areas',
28
- analysis: 'City analysis · 41 areas',
29
- };
30
- const normalizeName = (value) => value
31
- .normalize('NFKD')
32
- .toLowerCase()
33
- .replace(/[\u0300-\u036f'’]/g, '')
34
- .replace(/[^a-z0-9]+/g, ' ')
35
- .trim();
36
- const formatSourceLabel = (source) => sourceNames[source] ?? `${source} neighborhoods`;
37
- let explorerCount = 0;
38
26
  /** Create an offline, browser-only neighborhood explorer. Call destroy() before disposal. */
39
- export function createNeighborhoodExplorerCore(options = {}, data) {
27
+ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
40
28
  validateExplorerOptions(options);
41
29
  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;
42
30
  if (!['neighborhoods', 'districts', 'basemap'].includes(mode))
@@ -95,28 +83,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
95
83
  throw new RangeError('No neighborhood dataset was supplied.');
96
84
  if (neighborhood === '')
97
85
  neighborhood = undefined;
98
- const getNeighborhood = (name, selectedSource = source) => {
99
- const term = normalizeName(name);
100
- if (!term)
101
- return undefined;
102
- return collections[selectedSource]?.features.find((feature) => [
103
- feature.id,
104
- feature.properties.canonicalName,
105
- feature.properties.sourceName,
106
- ...feature.properties.aliases,
107
- ].some((value) => normalizeName(value) === term));
108
- };
109
- const searchNeighborhoods = (query, selectedSource) => {
110
- const term = normalizeName(query);
111
- return (collections[selectedSource]?.features ?? [])
112
- .filter((feature) => [
113
- feature.id,
114
- feature.properties.canonicalName,
115
- feature.properties.sourceName,
116
- ...feature.properties.aliases,
117
- ].some((value) => normalizeName(value).includes(term)))
118
- .map((feature) => ({ id: feature.id, canonicalName: feature.properties.canonicalName }));
119
- };
86
+ const { getNeighborhood, searchNeighborhoods } = createNeighborhoodLookup(collections);
120
87
  if (neighborhood !== undefined && !getNeighborhood(neighborhood, source))
121
88
  throw new RangeError(`Unknown neighborhood: ${neighborhood}`);
122
89
  if (typeof document === 'undefined')
@@ -126,191 +93,18 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
126
93
  function listen(node, event, handler, options = {}) {
127
94
  node.addEventListener(event, (incoming) => handler(incoming), { ...options, signal: controller.signal });
128
95
  }
129
- const root = element('section', '', 'sf-explorer');
130
- root.setAttribute('aria-label', 'San Francisco neighborhood explorer');
131
- const style = element('style');
132
- style.textContent = `
133
- .sf-explorer{container:sf-neighborhood-explorer / inline-size;font:14px/1.5 var(--sf-map-font);color:var(--sf-map-ink);background:var(--sf-map-surface);border:1px solid var(--sf-map-border);border-radius:14px;overflow:hidden;max-width:1120px;margin:auto;box-shadow:0 8px 32px #18364f08}
134
- .sf-explorer *{box-sizing:border-box}
135
- .sf-explorer [hidden]{display:none!important}
136
- .sf-explorer h2,.sf-explorer p{margin:0}
137
- .sf-explorer button,.sf-explorer input,.sf-explorer select,.sf-explorer a{font:inherit}
138
- .sf-explorer button,.sf-explorer select,.sf-explorer input{color:inherit;border:1px solid #c7d5df;border-radius:7px;background:var(--sf-map-surface);min-height:44px;padding:9px 12px}
139
- .sf-explorer button{cursor:pointer;transition:background .12s,border-color .12s}
140
- .sf-explorer button:hover{background:#f0f6fa;border-color:#8ba9c0}
141
- .sf-explorer button[aria-pressed=true]{color:#123d63;background:#e6f1f9;border-color:#5689b0;font-weight:650}
142
- .sf-explorer :focus-visible{outline:3px solid var(--sf-map-focus);outline-offset:2px}
143
- .sf-explorer-header{padding:24px;border-top:4px solid var(--sf-map-accent);border-bottom:1px solid var(--sf-map-border)}
144
- .sf-explorer-header h2{font-size:25px;line-height:1.25;font-weight:700;letter-spacing:-.7px}
145
- .sf-explorer-header p{color:#586f80;margin-top:8px;max-width:58ch}
146
- .sf-explorer-body{display:grid;grid-template-columns:280px minmax(0,1fr);grid-template-areas:"panel map" "detail map";grid-template-rows:auto 1fr}
147
- .sf-explorer-panel{grid-area:panel;padding:18px;display:flex;flex-direction:column;gap:14px;border-right:1px solid #dce5eb;min-width:0}
148
- .sf-explorer-panel label{display:flex;flex-direction:column;gap:6px;font-size:12px;font-weight:650}
149
- .sf-explorer-panel input,.sf-explorer-panel select{width:100%;font-weight:400;font-size:14px}
150
- .sf-explorer-panel input::placeholder{color:#6a7f8e}
151
- .sf-explorer-results{display:flex;flex-direction:column;gap:5px;max-height:255px;overflow:auto;padding:3px;margin:-3px;overscroll-behavior:contain}
152
- .sf-explorer-results button{text-align:left;flex-shrink:0}
153
- .sf-explorer-count,.sf-explorer-note{font-size:12px;color:#586f80}
154
- .sf-explorer-detail{grid-area:detail;border-top:1px solid #dce5eb;border-right:1px solid #dce5eb;padding:18px;overflow-wrap:anywhere;align-self:stretch;background:#fafcfd}
155
- .sf-explorer-detail details{font-size:12px;margin-top:12px}
156
- .sf-explorer-detail summary{cursor:pointer;color:#496578;min-height:32px}
157
- .sf-explorer-detail details p{margin-top:8px}
158
- .sf-explorer-detail h3{margin:0 0 7px;font-size:18px;line-height:1.3;letter-spacing:-.2px}
159
- .sf-explorer-detail p{font-size:12px;margin-bottom:10px}
160
- .sf-explorer-download{display:block;margin:8px 0;color:#12649c;text-underline-offset:3px}
161
- .sf-explorer-map-column{grid-area:map;min-width:0;background:#e4f2f8}
162
- .sf-explorer-toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:var(--sf-map-control-gap);padding:12px;background:var(--sf-map-surface);border-bottom:1px solid var(--sf-map-border)}
163
- .sf-explorer-control-group{display:flex;align-items:center;flex-wrap:wrap;gap:6px;max-width:100%}
164
- .sf-explorer-pan-controls{margin-left:auto}
165
- .sf-explorer-toolbar button{min-width:44px;padding:6px 10px}
166
- .sf-explorer-zoom{font-size:12px;min-width:42px;text-align:center;font-variant-numeric:tabular-nums;color:#496578}
167
- .sf-explorer-canvas{position:relative;aspect-ratio:1;overflow:hidden;touch-action:pan-y pinch-zoom}
168
- .sf-marker-chooser{position:absolute;z-index:2;top:12px;right:12px;display:flex;flex-direction:column;gap:6px;box-sizing:border-box;width:280px;max-width:calc(100% - 24px);max-height:calc(100% - 24px);overflow:auto;padding:12px;border:1px solid var(--sf-map-border);border-radius:8px;background:var(--sf-map-surface);color:var(--sf-map-ink);box-shadow:0 6px 20px #0003}
169
- .sf-marker-chooser button{min-height:44px;text-align:left;overflow-wrap:anywhere}
170
- .sf-explorer-canvas>svg{display:block;width:100%;height:100%;max-width:none;cursor:grab;user-select:none}
171
- .sf-explorer-canvas>svg:active{cursor:grabbing}
172
- .sf-explorer-canvas path[data-neighborhood-id]{cursor:pointer}
173
- .sf-explorer-legend{display:flex;flex-wrap:wrap;gap:8px 20px;padding:14px 16px 8px;background:var(--sf-map-surface);border-top:1px solid #dce5eb;color:#496578;font-size:12px}
174
- .sf-explorer-legend-item{display:inline-flex;align-items:center;gap:7px;white-space:nowrap}
175
- .sf-explorer-legend-symbol{display:inline-block;flex:none;width:15px;height:11px;border-radius:2px}
176
- .sf-explorer-legend-bart{width:10px;height:10px;margin:0 2px;border:2px solid #0073ae;border-radius:50%;background:#fff}
177
- .sf-explorer-legend-park{background:#c6dfbd;border:1px solid #a8c69d}
178
- .sf-explorer-legend-road{height:2px;border-radius:2px;background:#bcc3c5}
179
- .sf-explorer-legend-highway{height:3px;border-radius:2px;background:#b9a18a}
180
- .sf-explorer-hint{padding:6px 16px 10px;font-size:12px;color:#586f80;background:#fff}
181
- .sf-explorer text{pointer-events:none}
182
- .sf-explorer-status{padding:0 16px 14px;font-size:12px;background:var(--sf-map-surface);color:#496578}
183
- .sf-explorer-visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}
184
- .sf-explorer[data-interface=map] .sf-explorer-body{display:block}
185
- .sf-explorer[data-interface=map] .sf-explorer-header,.sf-explorer[data-interface=map] .sf-explorer-panel,.sf-explorer[data-interface=map] .sf-explorer-detail{display:none}
186
- .sf-explorer-feature-controls{display:flex;flex-wrap:wrap;gap:8px;padding:10px;background:#fff}
187
- .sf-explorer-feature-controls label{display:flex;flex:1;flex-direction:column;min-width:0}
188
- .sf-explorer-feature-controls select{width:100%;min-width:0}
189
- .sf-explorer svg [role=button]:focus-visible{outline:none;stroke:#102f72;stroke-width:3;fill-opacity:.3}
190
- @media(prefers-reduced-motion:reduce){.sf-explorer button{transition:none}}
191
- .sf-explorer button:disabled{opacity:.45;cursor:default}
192
- @container sf-neighborhood-explorer (max-width:650px){
193
- .sf-explorer-body{grid-template-columns:1fr;grid-template-areas:"panel" "map" "detail";grid-template-rows:auto auto auto}
194
- .sf-explorer-header{padding:18px}
195
- .sf-explorer-header h2{font-size:23px}
196
- .sf-explorer-header p{font-size:13px}
197
- .sf-explorer-panel{border-right:0;border-bottom:1px solid #dce5eb;padding:14px;gap:10px}
198
- .sf-explorer-results{max-height:140px}
199
- .sf-explorer-feature-controls{flex-direction:column}
200
- .sf-explorer-detail{padding:14px;border-right:0}
201
- .sf-explorer-toolbar{padding:8px;gap:6px}
202
- .sf-explorer-control-group{gap:4px}
203
- .sf-explorer-pan-controls{flex-basis:100%;margin-left:0}
204
- .sf-explorer-toolbar button{padding:5px 8px}
205
- .sf-explorer-legend{gap:8px 16px;padding:12px 14px 8px}
206
- .sf-explorer-download{display:inline-block;margin:4px 14px 4px 0}
207
- }`;
208
- root.dataset.interface = chrome;
209
- root.style.setProperty('--sf-map-ink', styleOptions.ink ?? '#18364f');
210
- root.style.setProperty('--sf-map-surface', styleOptions.surface ?? '#fff');
211
- root.style.setProperty('--sf-map-accent', styleOptions.accent ?? '#163d61');
212
- root.style.setProperty('--sf-map-border', styleOptions.border ?? '#cedae3');
213
- root.style.setProperty('--sf-map-focus', styleOptions.focus ?? '#1676b8');
214
- root.style.setProperty('--sf-map-control-gap', styleOptions.controlGap ?? '6px');
215
- root.style.setProperty('--sf-map-font', styleOptions.font ?? 'system-ui,sans-serif');
216
- root.append(style);
217
- const header = element('header', '', 'sf-explorer-header');
218
- header.append(element('h2', strings.title ?? 'Explore San Francisco'), element('p', 'Explore the city, from familiar names to the places in between.'));
219
- root.append(header);
220
- const body = element('div', '', 'sf-explorer-body');
221
- const panel = element('div', '', 'sf-explorer-panel');
222
- const modeLabel = element('label', strings.mode ?? 'Map mode');
223
- const modeSelect = element('select');
224
- for (const [value, title] of [
225
- ['neighborhoods', 'Neighborhoods'],
226
- ['districts', 'Districts'],
227
- ['basemap', 'Basemap'],
228
- ]) {
229
- if (value === 'districts' && !data.districts?.[year])
230
- continue;
231
- if (value === 'neighborhoods' && !sources.length)
232
- continue;
233
- const option = element('option', title);
234
- option.value = value;
235
- modeSelect.append(option);
236
- }
237
- modeLabel.append(modeSelect);
238
- const sourceLabel = element('label', strings.source ?? 'Neighborhood definitions');
239
- const sourceSelect = element('select');
240
- for (const value of sources) {
241
- const title = sourceNames[value];
242
- const option = element('option', title);
243
- option.value = value;
244
- sourceSelect.append(option);
245
- }
246
- sourceSelect.value = source;
247
- sourceLabel.append(sourceSelect);
248
- const searchLabel = element('label', strings.search ?? 'Find a neighborhood');
249
- const search = element('input');
250
- search.type = 'search';
251
- search.placeholder = 'Try Mission, Outer Mission, or NoPa';
252
- search.autocomplete = 'off';
253
- searchLabel.append(search);
254
- const count = element('p', '', 'sf-explorer-count');
255
- count.setAttribute('aria-live', 'polite');
256
- const results = element('div', '', 'sf-explorer-results');
257
- results.setAttribute('aria-label', 'Matching neighborhoods');
258
- results.setAttribute('role', 'group');
259
- const detail = element('div', '', 'sf-explorer-detail');
260
- panel.append(modeLabel, sourceLabel, searchLabel, count, results);
261
- const column = element('div', '', 'sf-explorer-map-column');
262
- const toolbar = element('div', '', 'sf-explorer-toolbar');
263
- toolbar.setAttribute('aria-label', 'Map controls');
264
- const canvas = element('div', '', 'sf-explorer-canvas');
265
- const hint = element('p', strings.gestureHelp ??
266
- 'Drag to pan · Ctrl/⌘ + scroll to zoom · Focus map: arrows pan, +/− zoom, Home resets. Touch: scroll the page, or enable Touch navigation to pan and pinch the map. Escape exits.', 'sf-explorer-hint');
267
- hint.id = `sf-explorer-help-${++explorerCount}`;
268
- const status = element('p', '', 'sf-explorer-status');
269
- status.setAttribute('aria-live', 'polite');
270
- const legend = element('div', '', 'sf-explorer-legend');
271
- legend.setAttribute('aria-label', 'Map legend');
272
- for (const [kind, label] of [
273
- ['bart', 'BART station'],
274
- ['park', 'Park'],
275
- ['highway', 'Highway'],
276
- ['road', 'Roads'],
277
- ]) {
278
- if (legendOptions.builtins === false || legendOptions.hidden?.includes(kind))
279
- continue;
280
- const entry = element('span', '', 'sf-explorer-legend-item');
281
- const symbol = element('span', '', `sf-explorer-legend-symbol sf-explorer-legend-${kind}`);
282
- symbol.setAttribute('aria-hidden', 'true');
283
- if (colors[kind]) {
284
- if (kind === 'bart')
285
- symbol.style.borderColor = colors[kind];
286
- else
287
- symbol.style.backgroundColor = colors[kind];
288
- }
289
- entry.append(symbol, document.createTextNode(label));
290
- legend.append(entry);
291
- }
292
- for (const item of legendOptions.items ?? []) {
293
- const entry = element('span', '', 'sf-explorer-legend-item');
294
- const symbol = element('span', '', 'sf-explorer-legend-symbol');
295
- symbol.style.backgroundColor = item.color;
296
- symbol.setAttribute('aria-hidden', 'true');
297
- entry.append(symbol, document.createTextNode(item.label));
298
- legend.append(entry);
299
- }
300
- const featureControls = element('div', '', 'sf-explorer-feature-controls');
301
- const neighborhoodLabel = element('label', strings.chooseNeighborhood ?? 'Select neighborhood');
302
- const neighborhoodSelect = element('select');
303
- neighborhoodLabel.append(neighborhoodSelect);
304
- const markerLabel = element('label', strings.chooseMarker ?? 'Choose marker');
305
- const markerSelect = element('select');
306
- markerLabel.append(markerSelect);
307
- featureControls.append(neighborhoodLabel, markerLabel);
308
- const attribution = element('p', '', 'sf-explorer-hint');
309
- const attributionDisclosure = element('details', '', 'sf-explorer-hint');
310
- attributionDisclosure.append(element('summary', 'Map data · DataSF / BART'), attribution);
311
- column.append(toolbar, featureControls, canvas, legend, hint, status, attributionMode === 'compact' ? attributionDisclosure : attribution);
312
- body.append(panel, column, detail);
313
- root.append(body);
96
+ const shell = ui.createShell({
97
+ source,
98
+ sources,
99
+ hasDistricts: !!data.districts?.[year],
100
+ chrome,
101
+ styleOptions,
102
+ strings,
103
+ legendOptions,
104
+ colors,
105
+ attributionMode,
106
+ });
107
+ const { root, modeSelect, sourceLabel, sourceSelect, searchLabel, count, results, canvas, status, legend, featureControls, neighborhoodLabel, neighborhoodSelect, markerLabel, markerSelect, attribution, } = shell;
314
108
  const map = createSFMapWithData({
315
109
  year,
316
110
  theme,
@@ -386,7 +180,6 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
386
180
  const labelRenderer = createLabelRenderer(labelLayer);
387
181
  geography.append(areas, stations, labelLayer, overlayLayer, markerLayer);
388
182
  let view = [0, 0, 800];
389
- let selected;
390
183
  let selectedDistrict = null;
391
184
  let hoveredDistrict = null;
392
185
  let districtStyle = initialDistrictStyle;
@@ -552,8 +345,6 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
552
345
  }));
553
346
  }
554
347
  updateDistrictAppearance();
555
- let items = [];
556
- let orderedNeighborhoods = [];
557
348
  let destroyed = false;
558
349
  let initialized = false;
559
350
  const layerFades = createLayerTransitions(() => initialized && features.layerTransitions && !reducedMotion.matches
@@ -578,6 +369,49 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
578
369
  });
579
370
  const stopAnimation = camera.stop;
580
371
  const moveView = camera.move;
372
+ const neighborhoods = createNeighborhoodLayer({
373
+ collections,
374
+ initialSource: source,
375
+ project: map.project,
376
+ root,
377
+ areas,
378
+ canvas,
379
+ sourceSelect,
380
+ neighborhoodSelect,
381
+ status,
382
+ readPresentation: () => ({
383
+ mode,
384
+ year,
385
+ fitPadding,
386
+ areaStyle,
387
+ colors,
388
+ selectableNeighborhoods,
389
+ lines: enabled('neighborhoodLines'),
390
+ }),
391
+ isDestroyed: () => destroyed,
392
+ ensureVisible() {
393
+ if (!enabled('neighborhoodLines') &&
394
+ !enabled('neighborhoodLabels') &&
395
+ (layers.neighborhoodLines === undefined || layers.neighborhoodLabels === undefined))
396
+ setMode('neighborhoods', false);
397
+ },
398
+ refresh: {
399
+ selection() {
400
+ scheduleLabels();
401
+ updateResults();
402
+ updateDetail();
403
+ },
404
+ source() {
405
+ updateResults();
406
+ updateDetail();
407
+ updateComposition();
408
+ },
409
+ },
410
+ moveView,
411
+ resetView,
412
+ crossfade: layerFades.crossfade,
413
+ });
414
+ const { getSelection, selectNeighborhood, prepareSource, setSource } = neighborhoods;
581
415
  function getFeatures() {
582
416
  return structuredClone(features);
583
417
  }
@@ -611,7 +445,6 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
611
445
  }
612
446
  scheduleLabels();
613
447
  }
614
- const downloads = new Set();
615
448
  const stationItems = (data.map.bartStations ?? []).map((feature) => {
616
449
  return {
617
450
  point: map.project(feature.coordinates),
@@ -661,7 +494,6 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
661
494
  ?.visual?.node.focus({ preventScroll: true });
662
495
  });
663
496
  let markerRevision = 0;
664
- let neighborhoodRevision = 0;
665
497
  const clusterLayer = svgElement('g', { 'data-layer': 'marker-clusters' });
666
498
  geography.insertBefore(clusterLayer, markerLayer);
667
499
  function materializeMarker(item) {
@@ -675,7 +507,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
675
507
  }),
676
508
  });
677
509
  }
678
- const clusters = createClusterRenderer(svg, markerLayer, clusterLayer, markerNavigation, materializeMarker, (group, node, signal) => {
510
+ const clusters = createClusterLayer(svg, markerLayer, clusterLayer, markerNavigation, materializeMarker, (group, node, signal) => {
679
511
  if (destroyed || signal.aborted)
680
512
  return;
681
513
  const markers = group.map((item) => ({ ...item.marker }));
@@ -712,86 +544,39 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
712
544
  renderedMarkers = rendered.markers;
713
545
  renderedClusters = rendered.clusters;
714
546
  }
715
- let overlayEvents = new AbortController();
716
- function setOverlays(overlays) {
717
- if (destroyed)
718
- return;
719
- validateOverlays(overlays);
720
- const events = new AbortController();
721
- const next = overlays.map((input) => {
722
- const overlay = structuredClone(input);
723
- const node = svgElement('path', {
724
- 'data-overlay-id': overlay.id,
725
- d: geometryPath(overlay.geometry, map.project),
726
- fill: overlay.fill ?? 'none',
727
- 'fill-opacity': overlay.fillOpacity ?? 1,
728
- stroke: overlay.stroke ?? '#bd8b73',
729
- 'stroke-width': overlay.strokeWidth ?? 2,
730
- 'vector-effect': 'non-scaling-stroke',
731
- display: overlay.visible === false ? 'none' : 'inline',
732
- 'pointer-events': 'none',
733
- ...(overlay.label ? { 'aria-label': overlay.label } : {}),
734
- });
735
- if (overlay.label) {
736
- node.setAttribute('role', 'img');
737
- const title = svgElement('title');
738
- title.textContent = overlay.label;
739
- node.append(title);
740
- }
741
- if (onOverlayActivate) {
742
- node.setAttribute('role', 'button');
743
- node.setAttribute('tabindex', overlay.visible === false ? '-1' : '0');
744
- node.setAttribute('pointer-events', overlay.fill && overlay.fill !== 'none' ? 'visiblePainted' : 'stroke');
745
- node.setAttribute('aria-label', overlay.label ?? overlay.id);
746
- const activate = () => {
747
- if (destroyed || events.signal.aborted)
748
- return;
749
- onOverlayActivate(structuredClone(overlay));
750
- if (destroyed || events.signal.aborted)
751
- return;
752
- root.dispatchEvent(new CustomEvent('overlayactivate', {
753
- bubbles: true,
754
- detail: { overlay: structuredClone(overlay) },
755
- }));
756
- };
757
- node.addEventListener('click', activate, { signal: events.signal });
758
- node.addEventListener('keydown', (event) => {
759
- if (event.key === 'Enter' || event.key === ' ') {
760
- event.preventDefault();
761
- activate();
762
- }
763
- }, { signal: events.signal });
764
- }
765
- return node;
766
- });
767
- overlayEvents.abort();
768
- overlayEvents = events;
769
- overlayLayer.replaceChildren(...next);
770
- }
771
- const zoomControls = element('div', '', 'sf-explorer-control-group');
772
- const panControls = element('div', '', 'sf-explorer-control-group sf-explorer-pan-controls');
773
- toolbar.append(zoomControls, panControls);
774
- function button(text, label, action, group = zoomControls) {
775
- const node = element('button', text);
776
- node.type = 'button';
777
- node.setAttribute('aria-label', label);
778
- listen(node, 'click', () => action());
779
- group.append(node);
780
- return node;
781
- }
782
- const zoomOut = button('−', 'Zoom out', () => zoomBy(1 / 1.5));
783
- const zoomText = element('span', '100%', 'sf-explorer-zoom');
784
- zoomControls.append(zoomText);
785
- const zoomIn = button('+', 'Zoom in', () => zoomBy(1.5));
786
- const resetButton = button(strings.reset ?? 'Reset', strings.reset ?? 'Reset map to city view', resetView);
787
- const labelsButton = button('Labels', 'Show map labels', () => setLabels(!labels));
788
- for (const [symbol, label, dx, dy] of [
789
- ['←', 'Pan west', -1, 0],
790
- ['↑', 'Pan north', 0, -1],
791
- ['↓', 'Pan south', 0, 1],
792
- ['→', 'Pan east', 1, 0],
793
- ])
794
- button(symbol, label, () => moveView([view[0] + (dx * view[2]) / 4, view[1] + (dy * view[2]) / 4, view[2]]), panControls);
547
+ const overlays = createOverlayLayer({
548
+ layer: overlayLayer,
549
+ root,
550
+ project: map.project,
551
+ onOverlayActivate,
552
+ isDestroyed: () => destroyed,
553
+ });
554
+ const setOverlays = overlays.set;
555
+ const mapControls = createExplorerControls(shell, {
556
+ svg,
557
+ signal: controller.signal,
558
+ strings,
559
+ isDestroyed: () => destroyed,
560
+ getLabels: () => labels,
561
+ getViewport,
562
+ moveView,
563
+ zoomBy,
564
+ resetView,
565
+ setLabels,
566
+ stopAnimation,
567
+ setView,
568
+ setMode,
569
+ setSource,
570
+ selectNeighborhood,
571
+ selectMarker,
572
+ updateResults,
573
+ search(query) {
574
+ const exact = getNeighborhood(query, neighborhoods.source);
575
+ const matches = searchNeighborhoods(query, neighborhoods.source);
576
+ return exact?.id ?? (matches.length === 1 ? matches[0].id : undefined);
577
+ },
578
+ });
579
+ const setTouchNavigation = mapControls.setTouchNavigation;
795
580
  function panBy(x, y, options = {}) {
796
581
  if (destroyed)
797
582
  return;
@@ -821,10 +606,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
821
606
  view = validateViewport(next);
822
607
  svg.setAttribute('viewBox', `${view[0]} ${view[1]} ${view[2]} ${view[2]}`);
823
608
  const zoom = 800 / view[2];
824
- zoomText.textContent = `${Math.round(zoom * 100)}%`;
609
+ mapControls.updateZoom(zoom);
825
610
  root.dataset.zoom = String(zoom);
826
- zoomIn.disabled = zoom >= 12;
827
- zoomOut.disabled = zoom <= 1;
828
611
  if (view.some((value, i) => value !== previous[i]))
829
612
  root.dispatchEvent(new CustomEvent('viewportchange', { bubbles: true, detail: { viewport: getViewport() } }));
830
613
  scheduleLabels();
@@ -850,8 +633,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
850
633
  function prepareLabels(width, zoom) {
851
634
  // Only screen-space positioning changes during pan or within these zoom/width bands.
852
635
  const inputs = [
853
- items,
854
- selected,
636
+ neighborhoods.items,
637
+ neighborhoods.selected,
855
638
  districtItems,
856
639
  markerRevision,
857
640
  layers,
@@ -867,7 +650,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
867
650
  if (inputs.every((value, index) => value === labelInputs[index]))
868
651
  return preparedLabels;
869
652
  labelInputs = inputs;
870
- const selectedItem = items.find((item) => item.feature === selected);
653
+ const selectedItem = neighborhoods.items.find((item) => item.feature === neighborhoods.selected);
871
654
  const candidates = [];
872
655
  const marker = markerCatalog.items.find((item) => item.marker.id === selectedMarker);
873
656
  if (marker)
@@ -889,7 +672,10 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
889
672
  parks: enabled('landmarks') ? parkItems : [],
890
673
  roads: enabled('roadLabels') ? roadItems : [],
891
674
  neighborhoods: enabled('neighborhoodLabels')
892
- ? orderedNeighborhoods.flatMap((item) => item.feature !== selected && item.point
675
+ ? neighborhoods.items
676
+ .filter((item) => item.point !== undefined)
677
+ .sort((a, b) => b.area - a.area || a.feature.id.localeCompare(b.feature.id))
678
+ .flatMap((item) => item.feature !== neighborhoods.selected && item.point
893
679
  ? [
894
680
  {
895
681
  point: item.point,
@@ -974,260 +760,22 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
974
760
  }),
975
761
  ]));
976
762
  }
977
- function releaseDownloads() {
978
- for (const url of downloads)
979
- URL.revokeObjectURL(url);
980
- downloads.clear();
981
- }
982
- function downloadLink(text, value, filename) {
983
- const link = element('a', text, 'sf-explorer-download');
984
- const url = URL.createObjectURL(new Blob([`${JSON.stringify(value, null, 2)}\n`], { type: 'application/geo+json' }));
985
- downloads.add(url);
986
- link.href = url;
987
- link.download = filename;
988
- return link;
989
- }
763
+ const sidebar = ui.createSidebar?.(shell, data, chrome, strings.emptyResults, searchNeighborhoods);
990
764
  function updateDetail() {
991
- releaseDownloads();
992
- if (chrome === 'map')
993
- return;
994
- detail.replaceChildren();
995
- if (mode === 'districts') {
996
- const collection = data.districts?.[year];
997
- if (!collection) {
998
- detail.append(element('h3', `${year} supervisorial districts`));
999
- return;
1000
- }
1001
- detail.append(element('h3', `${year} supervisorial districts`), element('p', 'Eleven districts, identified by number. Switch to Neighborhoods to explore familiar place names.'), downloadLink('Download district GeoJSON', collection, `districts-${year}.geojson`), element('p', collection.definition.description));
1002
- return;
1003
- }
1004
- if (mode === 'basemap') {
1005
- detail.append(element('h3', 'San Francisco basemap'));
1006
- return;
1007
- }
1008
- const collection = collections[source];
1009
- if (!collection)
1010
- return;
1011
- detail.append(element('h3', selected ? selected.properties.canonicalName : 'Explore all neighborhoods'));
1012
- const description = selected
1013
- ? `Source name: ${selected.properties.sourceName}${selected.properties.aliases.length ? `. Also known as: ${selected.properties.aliases.join(', ')}` : ''}.`
1014
- : 'Choose a name or click an area to highlight its full boundary.';
1015
- const sourceSummary = {
1016
- realtor: 'SFAR realtor boundaries · August 2010 · 92 distinct areas.',
1017
- 'sf-find': 'SF Find neighborhoods · 2006 · 117 approximate areas.',
1018
- analysis: 'City analysis neighborhoods · 41 census-tract-based areas.',
1019
- };
1020
- detail.append(element('p', description), element('p', sourceSummary[source]));
1021
- if (selected)
1022
- detail.append(downloadLink('Download neighborhood GeoJSON', { ...collection, selection: selected.id, features: [selected] }, `${source}-${selected.id}.geojson`));
1023
- detail.append(downloadLink(`Download all ${collection.features.length} areas`, collection, `neighborhoods-${source}.geojson`));
1024
- const provenance = element('details');
1025
- provenance.append(element('summary', 'About these boundaries'), element('p', collection.definition.description));
1026
- detail.append(provenance);
765
+ sidebar?.updateDetail({
766
+ mode,
767
+ year,
768
+ source: neighborhoods.source,
769
+ selected: neighborhoods.selected,
770
+ });
1027
771
  }
1028
772
  function updateResults() {
1029
- if (chrome === 'map')
1030
- return;
1031
- const activeElement = document.activeElement;
1032
- const focusedId = activeElement instanceof HTMLElement && results.contains(activeElement)
1033
- ? activeElement.dataset.neighborhoodResult
1034
- : undefined;
1035
- const matches = searchNeighborhoods(search.value, source).sort((a, b) => a.canonicalName.localeCompare(b.canonicalName));
1036
- count.textContent = `${matches.length} ${matches.length === 1 ? 'neighborhood' : 'neighborhoods'}${search.value ? ' found' : ' in this collection'}`;
1037
- results.replaceChildren();
1038
- for (const match of matches) {
1039
- const node = element('button', match.canonicalName);
1040
- node.type = 'button';
1041
- node.dataset.neighborhoodResult = match.id;
1042
- node.setAttribute('aria-pressed', String(selected?.id === match.id));
1043
- results.append(node);
1044
- if (focusedId === match.id)
1045
- node.focus({ preventScroll: true });
1046
- }
1047
- if (!matches.length)
1048
- results.append(element('p', strings.emptyResults ?? 'No matches. Try another name or definition source.', 'sf-explorer-note'));
1049
- }
1050
- function getSelection() {
1051
- return selected
1052
- ? {
1053
- id: selected.id,
1054
- name: selected.properties.canonicalName,
1055
- source,
1056
- feature: structuredClone(selected),
1057
- }
1058
- : null;
1059
- }
1060
- function selectNeighborhood(name, options = {}) {
1061
- if (destroyed)
1062
- return false;
1063
- const feature = name === null
1064
- ? undefined
1065
- : (collections[source]?.features.find((entry) => entry.id === name) ??
1066
- getNeighborhood(name, source));
1067
- if (name !== null && !feature)
1068
- return false;
1069
- validateCameraOptions(options);
1070
- const selectedItem = items.find((item) => item.feature === feature);
1071
- const width = canvas.getBoundingClientRect().width;
1072
- const target = selectedItem && options.fit !== false
1073
- ? width
1074
- ? fitViewport(selectedItem.bounds, width, fitPadding)
1075
- : fitBounds(selectedItem.bounds)
1076
- : null;
1077
- if (feature &&
1078
- !enabled('neighborhoodLines') &&
1079
- !enabled('neighborhoodLabels') &&
1080
- (layers.neighborhoodLines === undefined || layers.neighborhoodLabels === undefined))
1081
- setMode('neighborhoods', false);
1082
- const revision = ++neighborhoodRevision;
1083
- const changed = selected !== feature;
1084
- selected = feature;
1085
- for (const item of items) {
1086
- const active = item.feature === selected;
1087
- item.node.setAttribute('fill', active ? (areaStyle.selectedFill ?? '#408dbe') : 'transparent');
1088
- item.node.setAttribute('fill-opacity', active ? '.16' : '1');
1089
- item.node.setAttribute('stroke', active ? (areaStyle.selectedStroke ?? '#176ba2') : (colors.neighborhood ?? '#9caebc'));
1090
- item.node.setAttribute('stroke-width', active ? '2.2' : enabled('neighborhoodLines') ? '.55' : '0');
1091
- if (selectableNeighborhoods)
1092
- item.node.setAttribute('aria-pressed', String(active));
1093
- if (active)
1094
- areas.append(item.node);
1095
- }
1096
- if (feature)
1097
- root.dataset.selectedNeighborhood = feature.id;
1098
- else
1099
- delete root.dataset.selectedNeighborhood;
1100
- neighborhoodSelect.value = feature?.id ?? '';
1101
- scheduleLabels();
1102
- updateResults();
1103
- updateDetail();
1104
- status.textContent = feature
1105
- ? `${feature.properties.canonicalName} selected. ${formatSourceLabel(source)}.`
1106
- : 'Neighborhood selection cleared.';
1107
- if (changed)
1108
- root.dispatchEvent(new CustomEvent('neighborhoodchange', {
1109
- bubbles: true,
1110
- detail: {
1111
- feature: feature ? structuredClone(feature) : null,
1112
- source,
1113
- id: feature?.id ?? null,
1114
- name: feature?.properties.canonicalName ?? null,
1115
- },
1116
- }));
1117
- if (!destroyed && neighborhoodRevision === revision && target)
1118
- moveView(target, options);
1119
- return true;
1120
- }
1121
- function prepareSource(next, reset = true) {
1122
- if (!Object.hasOwn(collections, next))
1123
- throw new RangeError(`Unknown neighborhood source: ${next}`);
1124
- const collection = collections[next];
1125
- if (!collection ||
1126
- !Array.isArray(collection.features) ||
1127
- typeof collection.title !== 'string' ||
1128
- typeof collection.definition?.description !== 'string')
1129
- throw new TypeError('Neighborhood collections require features, title and definition description.');
1130
- const ids = new Set();
1131
- const nextOptions = [];
1132
- const nextItems = collection.features.map((feature) => {
1133
- if (!feature ||
1134
- typeof feature.id !== 'string' ||
1135
- !feature.id ||
1136
- ids.has(feature.id) ||
1137
- !feature.properties ||
1138
- typeof feature.properties.canonicalName !== 'string' ||
1139
- typeof feature.properties.sourceName !== 'string' ||
1140
- !Array.isArray(feature.properties.aliases) ||
1141
- Array.from(feature.properties.aliases).some((alias) => typeof alias !== 'string') ||
1142
- !['Polygon', 'MultiPolygon'].includes(feature.geometry?.type))
1143
- throw new TypeError('Neighborhood features require unique IDs, names, aliases and polygon geometry.');
1144
- ids.add(feature.id);
1145
- const bounds = projectedBounds(feature.geometry, map.project);
1146
- if (!bounds.every(Number.isFinite))
1147
- throw new RangeError('Neighborhood geometry must be nonempty and finite.');
1148
- const node = svgElement('path', {
1149
- d: geometryPath(feature.geometry, map.project),
1150
- 'data-neighborhood-id': feature.id,
1151
- fill: 'transparent',
1152
- 'fill-rule': 'evenodd',
1153
- stroke: colors.neighborhood ?? '#9caebc',
1154
- 'stroke-width': '.55',
1155
- 'vector-effect': 'non-scaling-stroke',
1156
- role: 'button',
1157
- tabindex: -1,
1158
- 'aria-label': feature.properties.canonicalName,
1159
- 'aria-pressed': 'false',
1160
- });
1161
- const option = element('option', feature.properties.canonicalName);
1162
- option.value = feature.id;
1163
- nextOptions.push(option);
1164
- const title = svgElement('title');
1165
- title.textContent = feature.properties.canonicalName;
1166
- node.append(title);
1167
- node.addEventListener('pointerenter', () => {
1168
- if (destroyed ||
1169
- !selectableNeighborhoods ||
1170
- selected?.id === feature.id ||
1171
- (!areaStyle.hoverFill && !areaStyle.hoverStroke))
1172
- return;
1173
- node.setAttribute('fill', areaStyle.hoverFill ?? '#408dbe');
1174
- node.setAttribute('fill-opacity', '.12');
1175
- node.setAttribute('stroke', areaStyle.hoverStroke ?? colors.neighborhood ?? '#176ba2');
1176
- });
1177
- node.addEventListener('pointerleave', () => {
1178
- if (destroyed || selected?.id === feature.id)
1179
- return;
1180
- node.setAttribute('fill', 'transparent');
1181
- node.setAttribute('fill-opacity', '1');
1182
- node.setAttribute('stroke', colors.neighborhood ?? '#9caebc');
1183
- });
1184
- return {
1185
- feature,
1186
- node,
1187
- bounds,
1188
- point: interiorAnchor(feature.geometry, map.project),
1189
- area: (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]),
1190
- };
773
+ sidebar?.updateResults({
774
+ mode,
775
+ year,
776
+ source: neighborhoods.source,
777
+ selected: neighborhoods.selected,
1191
778
  });
1192
- return () => {
1193
- const hadSelection = !!selected;
1194
- source = next;
1195
- sourceSelect.value = source;
1196
- selected = undefined;
1197
- const revision = ++neighborhoodRevision;
1198
- delete root.dataset.selectedNeighborhood;
1199
- root.dataset.source = source;
1200
- layerFades.crossfade(areas, () => areas.replaceChildren(...nextItems.map((item) => item.node)));
1201
- neighborhoodSelect.replaceChildren(element('option', 'No neighborhood selected'));
1202
- neighborhoodSelect.options[0].value = '';
1203
- neighborhoodSelect.append(...nextOptions);
1204
- items = nextItems;
1205
- orderedNeighborhoods = nextItems
1206
- .filter((item) => item.point !== undefined)
1207
- .sort((a, b) => b.area - a.area || a.feature.id.localeCompare(b.feature.id));
1208
- if (items[0])
1209
- items[0].node.setAttribute('tabindex', '0');
1210
- updateResults();
1211
- updateDetail();
1212
- updateComposition();
1213
- status.textContent =
1214
- mode === 'basemap'
1215
- ? 'San Francisco basemap.'
1216
- : mode === 'districts'
1217
- ? `${year} supervisorial districts. Numbers identify each district.`
1218
- : `${formatSourceLabel(source)}. Select a neighborhood to begin.`;
1219
- if (hadSelection)
1220
- root.dispatchEvent(new CustomEvent('neighborhoodchange', {
1221
- bubbles: true,
1222
- detail: { feature: null, source, id: null, name: null },
1223
- }));
1224
- if (reset && !destroyed && neighborhoodRevision === revision)
1225
- resetView();
1226
- };
1227
- }
1228
- function setSource(next) {
1229
- if (!destroyed)
1230
- prepareSource(next)();
1231
779
  }
1232
780
  function syncFeatureControls() {
1233
781
  featureControls.hidden = neighborhoodLabel.hidden && markerLabel.hidden;
@@ -1273,9 +821,13 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1273
821
  chrome === 'explorer' ||
1274
822
  controls.neighborhoodPicker === false;
1275
823
  syncFeatureControls();
1276
- for (const item of items) {
1277
- item.node.setAttribute('stroke-width', item.feature === selected ? '2.2' : enabled('neighborhoodLines') ? '.55' : '0');
1278
- item.node.setAttribute('tabindex', selectableNeighborhoods && item === items[0] ? '0' : '-1');
824
+ for (const item of neighborhoods.items) {
825
+ item.node.setAttribute('stroke-width', item.feature === neighborhoods.selected
826
+ ? '2.2'
827
+ : enabled('neighborhoodLines')
828
+ ? '.55'
829
+ : '0');
830
+ item.node.setAttribute('tabindex', selectableNeighborhoods && item === neighborhoods.items[0] ? '0' : '-1');
1279
831
  item.node.setAttribute('role', selectableNeighborhoods ? 'button' : 'img');
1280
832
  if (!selectableNeighborhoods)
1281
833
  item.node.removeAttribute('aria-pressed');
@@ -1290,7 +842,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1290
842
  if (districtsVisible)
1291
843
  descriptions.push(`${year} supervisorial districts, DataSF.`);
1292
844
  if (neighborhoodsVisible) {
1293
- const collection = collections[source];
845
+ const collection = collections[neighborhoods.source];
1294
846
  if (collection)
1295
847
  descriptions.push(`${collection.title}. ${collection.definition.description}`);
1296
848
  }
@@ -1403,22 +955,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1403
955
  validateSwitchPatch(patch, controlKeys);
1404
956
  const next = { ...controls, ...patch };
1405
957
  controls = next;
1406
- for (const node of [zoomIn, zoomOut, zoomText])
1407
- node.hidden = controls.zoom === false;
1408
- panControls.hidden = controls.pan === false;
1409
- resetButton.hidden = controls.reset === false;
1410
- labelsButton.hidden = controls.labels === false;
1411
- touchButton.hidden = controls.touch === false;
1412
- if (touchButton.hidden && touchNavigation)
1413
- setTouchNavigation(false);
1414
- zoomControls.hidden = [...zoomControls.children].every((node) => node.hidden);
1415
- toolbar.hidden = zoomControls.hidden && panControls.hidden;
1416
- hint.hidden = controls.help === false;
1417
- if (hint.hidden)
1418
- svg.setAttribute('aria-describedby', hint.id);
1419
- else
1420
- svg.removeAttribute('aria-describedby');
1421
- status.classList.toggle('sf-explorer-visually-hidden', controls.status === false);
958
+ mapControls.update(controls);
1422
959
  markerLabel.hidden = !markerCatalog.items.length || controls.markerPicker === false;
1423
960
  updateComposition();
1424
961
  }
@@ -1446,9 +983,9 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1446
983
  ? 'San Francisco basemap.'
1447
984
  : districts
1448
985
  ? `${year} supervisorial districts. Numbers identify each district.`
1449
- : selected
1450
- ? `${selected.properties.canonicalName} selected. ${formatSourceLabel(source)}.`
1451
- : `${formatSourceLabel(source)}. Select a neighborhood to begin.`;
986
+ : neighborhoods.selected
987
+ ? `${neighborhoods.selected.properties.canonicalName} selected. ${formatSourceLabel(neighborhoods.source)}.`
988
+ : `${formatSourceLabel(neighborhoods.source)}. Select a neighborhood to begin.`;
1452
989
  if (reset)
1453
990
  resetView();
1454
991
  }
@@ -1458,7 +995,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1458
995
  if (typeof visible !== 'boolean')
1459
996
  throw new TypeError('Labels must be a boolean.');
1460
997
  labels = visible;
1461
- labelsButton.setAttribute('aria-pressed', String(labels));
998
+ mapControls.updateLabels(labels);
1462
999
  root.dataset.labels = String(labels);
1463
1000
  updateComposition();
1464
1001
  if (!labels) {
@@ -1467,61 +1004,6 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1467
1004
  }
1468
1005
  scheduleLabels();
1469
1006
  }
1470
- listen(modeSelect, 'change', () => {
1471
- const next = modeSelect.value;
1472
- if (next === 'neighborhoods' || next === 'districts' || next === 'basemap')
1473
- setMode(next);
1474
- });
1475
- listen(search, 'input', updateResults);
1476
- listen(search, 'keydown', (event) => {
1477
- if (event.key === 'Enter') {
1478
- const exact = getNeighborhood(search.value, source);
1479
- const matches = searchNeighborhoods(search.value, source);
1480
- if (exact || matches.length === 1) {
1481
- event.preventDefault();
1482
- selectNeighborhood(exact?.id ?? matches[0].id);
1483
- }
1484
- }
1485
- });
1486
- listen(sourceSelect, 'change', () => {
1487
- const next = sourceSelect.value;
1488
- if (next === 'realtor' || next === 'sf-find' || next === 'analysis')
1489
- setSource(next);
1490
- });
1491
- listen(results, 'click', (event) => {
1492
- const node = event.target instanceof Element
1493
- ? event.target.closest('[data-neighborhood-result]')
1494
- : null;
1495
- if (node?.dataset.neighborhoodResult)
1496
- selectNeighborhood(node.dataset.neighborhoodResult);
1497
- });
1498
- const touchLabel = strings.touchNavigation ?? 'Touch navigation';
1499
- const touchButton = button(touchLabel, strings.touchNavigationLabel ?? `Enable ${touchLabel.toLowerCase()}`, () => setTouchNavigation(!touchNavigation));
1500
- touchButton.hidden = controls.touch === false;
1501
- let touchNavigation = false;
1502
- const navigation = attachNavigation(svg, getViewport, (next) => {
1503
- stopAnimation();
1504
- setView(next);
1505
- }, controller.signal, () => setTouchNavigation(false));
1506
- function setTouchNavigation(enabled) {
1507
- if (destroyed)
1508
- return;
1509
- if (typeof enabled !== 'boolean')
1510
- throw new TypeError('Touch navigation must be a boolean.');
1511
- touchNavigation = enabled;
1512
- navigation.setTouchNavigation(enabled);
1513
- canvas.style.touchAction = enabled ? 'none' : 'pan-y pinch-zoom';
1514
- touchButton.textContent = enabled
1515
- ? (strings.touchNavigationDone ?? 'Done: page scrolling')
1516
- : touchLabel;
1517
- touchButton.setAttribute('aria-label', enabled
1518
- ? (strings.touchNavigationExitLabel ?? `Exit ${touchLabel.toLowerCase()}`)
1519
- : (strings.touchNavigationLabel ?? `Enable ${touchLabel.toLowerCase()}`));
1520
- touchButton.setAttribute('aria-pressed', String(enabled));
1521
- root.dataset.touchNavigation = String(enabled);
1522
- }
1523
- listen(neighborhoodSelect, 'change', () => selectNeighborhood(neighborhoodSelect.value || null));
1524
- listen(markerSelect, 'change', () => selectMarker(markerSelect.value || null));
1525
1007
  listen(svg, 'keydown', (event) => {
1526
1008
  const target = event.target instanceof SVGElement ? event.target : null;
1527
1009
  const districtId = Number(target?.dataset.district);
@@ -1555,9 +1037,10 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1555
1037
  }
1556
1038
  else if (event.key === '[' || event.key === ']') {
1557
1039
  event.preventDefault();
1558
- const i = items.findIndex((item) => item.feature.id === id);
1559
- const next = items[(i + (event.key === ']' ? 1 : items.length - 1)) % items.length];
1560
- for (const item of items)
1040
+ const i = neighborhoods.items.findIndex((item) => item.feature.id === id);
1041
+ const next = neighborhoods.items[(i + (event.key === ']' ? 1 : neighborhoods.items.length - 1)) %
1042
+ neighborhoods.items.length];
1043
+ for (const item of neighborhoods.items)
1561
1044
  item.node.setAttribute('tabindex', item === next ? '0' : '-1');
1562
1045
  next.node.focus();
1563
1046
  }
@@ -1629,7 +1112,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1629
1112
  listen(svg, 'pointerdown', stopAnimation);
1630
1113
  let observer;
1631
1114
  const onResize = () => {
1632
- navigation.cancel();
1115
+ mapControls.cancelNavigation();
1633
1116
  scheduleLabels();
1634
1117
  root.dispatchEvent(new CustomEvent('mapresize', { bubbles: true }));
1635
1118
  };
@@ -1645,7 +1128,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1645
1128
  throw new RangeError('No neighborhood dataset was supplied.');
1646
1129
  if (patch.source !== undefined && !Object.hasOwn(collections, patch.source))
1647
1130
  throw new RangeError(`Unknown neighborhood source: ${patch.source}`);
1648
- const commitSource = patch.source !== undefined && patch.source !== source
1131
+ const commitSource = patch.source !== undefined && patch.source !== neighborhoods.source
1649
1132
  ? prepareSource(patch.source, false)
1650
1133
  : undefined;
1651
1134
  const appearance = patch.appearance;
@@ -1726,8 +1209,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1726
1209
  symbol.style.setProperty(kind === 'bart' ? 'border-color' : 'background-color', colors[kind]);
1727
1210
  }
1728
1211
  }
1729
- for (const item of items) {
1730
- const active = item.feature === selected;
1212
+ for (const item of neighborhoods.items) {
1213
+ const active = item.feature === neighborhoods.selected;
1731
1214
  item.node.setAttribute('fill', active ? (areaStyle.selectedFill ?? '#408dbe') : 'transparent');
1732
1215
  item.node.setAttribute('stroke', active ? (areaStyle.selectedStroke ?? '#176ba2') : (colors.neighborhood ?? '#9caebc'));
1733
1216
  }
@@ -1753,7 +1236,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1753
1236
  if (destroyed || revision !== presentationRevision || !isCurrent())
1754
1237
  return false;
1755
1238
  if (commitSource && appearance)
1756
- for (const item of items)
1239
+ for (const item of neighborhoods.items)
1757
1240
  item.node.setAttribute('stroke', colors.neighborhood ?? '#9caebc');
1758
1241
  if (patch.mode !== undefined)
1759
1242
  setMode(patch.mode, false);
@@ -1764,7 +1247,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1764
1247
  }
1765
1248
  const explorer = Object.assign(root, {
1766
1249
  applyPresentation,
1767
- getMapState: () => ({ mode, source, year, labels }),
1250
+ getMapState: () => ({ mode, source: neighborhoods.source, year, labels }),
1768
1251
  selectNeighborhood,
1769
1252
  getSelection,
1770
1253
  selectDistrict,
@@ -1801,13 +1284,14 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1801
1284
  districtMorph.stop();
1802
1285
  cancelEntrances();
1803
1286
  controller.abort();
1804
- overlayEvents.abort();
1287
+ overlays.destroy();
1805
1288
  clusters.destroy();
1806
1289
  canvas.style.touchAction = 'pan-y pinch-zoom';
1807
1290
  observer?.disconnect();
1808
1291
  frames.destroy();
1809
1292
  labelRenderer.clear();
1810
- releaseDownloads();
1293
+ sidebar?.destroy();
1294
+ neighborhoods.destroy();
1811
1295
  },
1812
1296
  });
1813
1297
  try {
@@ -1828,12 +1312,12 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1828
1312
  }, { signal: controller.signal });
1829
1313
  const initialMode = mode;
1830
1314
  if (sources.length)
1831
- setSource(source);
1315
+ setSource(neighborhoods.source);
1832
1316
  if (neighborhood !== undefined)
1833
1317
  selectNeighborhood(neighborhood);
1834
1318
  setMode(initialMode);
1835
- if (initialMode === 'neighborhoods' && selected) {
1836
- const selectedItem = items.find((item) => item.feature === selected);
1319
+ if (initialMode === 'neighborhoods' && neighborhoods.selected) {
1320
+ const selectedItem = neighborhoods.items.find((item) => item.feature === neighborhoods.selected);
1837
1321
  if (selectedItem)
1838
1322
  setView(fitBounds(selectedItem.bounds));
1839
1323
  }