@kahwee/sf-map-svg 4.3.2 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +18 -1
  3. package/dist/src/cards.d.ts +15 -0
  4. package/dist/src/cards.js +138 -0
  5. package/dist/src/cluster-layer.d.ts +2 -0
  6. package/dist/src/cluster-layer.js +2 -0
  7. package/dist/src/explorer-controls.d.ts +26 -0
  8. package/dist/src/explorer-controls.js +120 -0
  9. package/dist/src/explorer-core.d.ts +6 -1
  10. package/dist/src/explorer-core.js +168 -701
  11. package/dist/src/explorer-shell.d.ts +62 -0
  12. package/dist/src/explorer-shell.js +228 -0
  13. package/dist/src/explorer-sidebar.d.ts +20 -0
  14. package/dist/src/explorer-sidebar.js +79 -0
  15. package/dist/src/guide-data.js +1 -0
  16. package/dist/src/guide-map.js +2 -1
  17. package/dist/src/guide-static.js +1 -0
  18. package/dist/src/label-candidates.d.ts +24 -0
  19. package/dist/src/label-candidates.js +29 -0
  20. package/dist/src/label-renderer.d.ts +1 -10
  21. package/dist/src/label-spread.d.ts +5 -0
  22. package/dist/src/label-spread.js +36 -0
  23. package/dist/src/map-core.d.ts +3 -1
  24. package/dist/src/map-core.js +7 -5
  25. package/dist/src/map.js +2 -1
  26. package/dist/src/neighborhood-layer.d.ts +67 -0
  27. package/dist/src/neighborhood-layer.js +256 -0
  28. package/dist/src/overlay-layer.d.ts +13 -0
  29. package/dist/src/overlay-layer.js +68 -0
  30. package/dist/src/palettes.d.ts +48 -0
  31. package/dist/src/palettes.js +40 -0
  32. package/dist/src/presets.d.ts +1 -0
  33. package/dist/src/presets.js +1 -0
  34. package/dist/src/static-labels.d.ts +7 -0
  35. package/dist/src/static-labels.js +115 -0
  36. package/dist/src/static.js +31 -3
  37. package/dist/src/types.d.ts +16 -1
  38. package/docs/API.md +38 -2
  39. package/docs/EXAMPLES.md +1 -0
  40. package/docs/consumer-integration.md +81 -1
  41. package/docs/guide-bundle-report.md +4 -4
  42. package/llms-full.txt +274 -26
  43. package/llms.txt +1 -1
  44. package/package.json +16 -8
@@ -1,16 +1,17 @@
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
+ import { labelCandidates, styleLabels, } from './label-candidates.js';
14
15
  import { createLabelRenderer } from './label-renderer.js';
15
16
  import { createLayerTransitions } from './layer-transition.js';
16
17
  import { createSFMapWithData, districtColors, getLayerPathsWithData, resolveMapColors, } from './map-core.js';
@@ -18,24 +19,12 @@ import { createMarkerCatalog } from './marker-catalog.js';
18
19
  import { createMarkerChooser } from './marker-chooser.js';
19
20
  import { createMarkerLayer } from './marker-layer.js';
20
21
  import { createMarkerNavigation } from './marker-navigation.js';
21
- import { attachNavigation } from './navigation.js';
22
- 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';
23
25
  import { fitViewport, validateViewport } from './viewport.js';
24
- const sourceNames = {
25
- realtor: 'SFAR realtor · 92 areas',
26
- 'sf-find': 'SF Find · 117 areas',
27
- analysis: 'City analysis · 41 areas',
28
- };
29
- const normalizeName = (value) => value
30
- .normalize('NFKD')
31
- .toLowerCase()
32
- .replace(/[\u0300-\u036f'’]/g, '')
33
- .replace(/[^a-z0-9]+/g, ' ')
34
- .trim();
35
- const formatSourceLabel = (source) => sourceNames[source] ?? `${source} neighborhoods`;
36
- let explorerCount = 0;
37
26
  /** Create an offline, browser-only neighborhood explorer. Call destroy() before disposal. */
38
- export function createNeighborhoodExplorerCore(options = {}, data) {
27
+ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
39
28
  validateExplorerOptions(options);
40
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;
41
30
  if (!['neighborhoods', 'districts', 'basemap'].includes(mode))
@@ -94,28 +83,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
94
83
  throw new RangeError('No neighborhood dataset was supplied.');
95
84
  if (neighborhood === '')
96
85
  neighborhood = undefined;
97
- const getNeighborhood = (name, selectedSource = source) => {
98
- const term = normalizeName(name);
99
- if (!term)
100
- return undefined;
101
- return collections[selectedSource]?.features.find((feature) => [
102
- feature.id,
103
- feature.properties.canonicalName,
104
- feature.properties.sourceName,
105
- ...feature.properties.aliases,
106
- ].some((value) => normalizeName(value) === term));
107
- };
108
- const searchNeighborhoods = (query, selectedSource) => {
109
- const term = normalizeName(query);
110
- return (collections[selectedSource]?.features ?? [])
111
- .filter((feature) => [
112
- feature.id,
113
- feature.properties.canonicalName,
114
- feature.properties.sourceName,
115
- ...feature.properties.aliases,
116
- ].some((value) => normalizeName(value).includes(term)))
117
- .map((feature) => ({ id: feature.id, canonicalName: feature.properties.canonicalName }));
118
- };
86
+ const { getNeighborhood, searchNeighborhoods } = createNeighborhoodLookup(collections);
119
87
  if (neighborhood !== undefined && !getNeighborhood(neighborhood, source))
120
88
  throw new RangeError(`Unknown neighborhood: ${neighborhood}`);
121
89
  if (typeof document === 'undefined')
@@ -125,191 +93,18 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
125
93
  function listen(node, event, handler, options = {}) {
126
94
  node.addEventListener(event, (incoming) => handler(incoming), { ...options, signal: controller.signal });
127
95
  }
128
- const root = element('section', '', 'sf-explorer');
129
- root.setAttribute('aria-label', 'San Francisco neighborhood explorer');
130
- const style = element('style');
131
- style.textContent = `
132
- .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}
133
- .sf-explorer *{box-sizing:border-box}
134
- .sf-explorer [hidden]{display:none!important}
135
- .sf-explorer h2,.sf-explorer p{margin:0}
136
- .sf-explorer button,.sf-explorer input,.sf-explorer select,.sf-explorer a{font:inherit}
137
- .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}
138
- .sf-explorer button{cursor:pointer;transition:background .12s,border-color .12s}
139
- .sf-explorer button:hover{background:#f0f6fa;border-color:#8ba9c0}
140
- .sf-explorer button[aria-pressed=true]{color:#123d63;background:#e6f1f9;border-color:#5689b0;font-weight:650}
141
- .sf-explorer :focus-visible{outline:3px solid var(--sf-map-focus);outline-offset:2px}
142
- .sf-explorer-header{padding:24px;border-top:4px solid var(--sf-map-accent);border-bottom:1px solid var(--sf-map-border)}
143
- .sf-explorer-header h2{font-size:25px;line-height:1.25;font-weight:700;letter-spacing:-.7px}
144
- .sf-explorer-header p{color:#586f80;margin-top:8px;max-width:58ch}
145
- .sf-explorer-body{display:grid;grid-template-columns:280px minmax(0,1fr);grid-template-areas:"panel map" "detail map";grid-template-rows:auto 1fr}
146
- .sf-explorer-panel{grid-area:panel;padding:18px;display:flex;flex-direction:column;gap:14px;border-right:1px solid #dce5eb;min-width:0}
147
- .sf-explorer-panel label{display:flex;flex-direction:column;gap:6px;font-size:12px;font-weight:650}
148
- .sf-explorer-panel input,.sf-explorer-panel select{width:100%;font-weight:400;font-size:14px}
149
- .sf-explorer-panel input::placeholder{color:#6a7f8e}
150
- .sf-explorer-results{display:flex;flex-direction:column;gap:5px;max-height:255px;overflow:auto;padding:3px;margin:-3px;overscroll-behavior:contain}
151
- .sf-explorer-results button{text-align:left;flex-shrink:0}
152
- .sf-explorer-count,.sf-explorer-note{font-size:12px;color:#586f80}
153
- .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}
154
- .sf-explorer-detail details{font-size:12px;margin-top:12px}
155
- .sf-explorer-detail summary{cursor:pointer;color:#496578;min-height:32px}
156
- .sf-explorer-detail details p{margin-top:8px}
157
- .sf-explorer-detail h3{margin:0 0 7px;font-size:18px;line-height:1.3;letter-spacing:-.2px}
158
- .sf-explorer-detail p{font-size:12px;margin-bottom:10px}
159
- .sf-explorer-download{display:block;margin:8px 0;color:#12649c;text-underline-offset:3px}
160
- .sf-explorer-map-column{grid-area:map;min-width:0;background:#e4f2f8}
161
- .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)}
162
- .sf-explorer-control-group{display:flex;align-items:center;flex-wrap:wrap;gap:6px;max-width:100%}
163
- .sf-explorer-pan-controls{margin-left:auto}
164
- .sf-explorer-toolbar button{min-width:44px;padding:6px 10px}
165
- .sf-explorer-zoom{font-size:12px;min-width:42px;text-align:center;font-variant-numeric:tabular-nums;color:#496578}
166
- .sf-explorer-canvas{position:relative;aspect-ratio:1;overflow:hidden;touch-action:pan-y pinch-zoom}
167
- .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}
168
- .sf-marker-chooser button{min-height:44px;text-align:left;overflow-wrap:anywhere}
169
- .sf-explorer-canvas>svg{display:block;width:100%;height:100%;max-width:none;cursor:grab;user-select:none}
170
- .sf-explorer-canvas>svg:active{cursor:grabbing}
171
- .sf-explorer-canvas path[data-neighborhood-id]{cursor:pointer}
172
- .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}
173
- .sf-explorer-legend-item{display:inline-flex;align-items:center;gap:7px;white-space:nowrap}
174
- .sf-explorer-legend-symbol{display:inline-block;flex:none;width:15px;height:11px;border-radius:2px}
175
- .sf-explorer-legend-bart{width:10px;height:10px;margin:0 2px;border:2px solid #0073ae;border-radius:50%;background:#fff}
176
- .sf-explorer-legend-park{background:#c6dfbd;border:1px solid #a8c69d}
177
- .sf-explorer-legend-road{height:2px;border-radius:2px;background:#bcc3c5}
178
- .sf-explorer-legend-highway{height:3px;border-radius:2px;background:#b9a18a}
179
- .sf-explorer-hint{padding:6px 16px 10px;font-size:12px;color:#586f80;background:#fff}
180
- .sf-explorer text{pointer-events:none}
181
- .sf-explorer-status{padding:0 16px 14px;font-size:12px;background:var(--sf-map-surface);color:#496578}
182
- .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}
183
- .sf-explorer[data-interface=map] .sf-explorer-body{display:block}
184
- .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}
185
- .sf-explorer-feature-controls{display:flex;flex-wrap:wrap;gap:8px;padding:10px;background:#fff}
186
- .sf-explorer-feature-controls label{display:flex;flex:1;flex-direction:column;min-width:0}
187
- .sf-explorer-feature-controls select{width:100%;min-width:0}
188
- .sf-explorer svg [role=button]:focus-visible{outline:none;stroke:#102f72;stroke-width:3;fill-opacity:.3}
189
- @media(prefers-reduced-motion:reduce){.sf-explorer button{transition:none}}
190
- .sf-explorer button:disabled{opacity:.45;cursor:default}
191
- @container sf-neighborhood-explorer (max-width:650px){
192
- .sf-explorer-body{grid-template-columns:1fr;grid-template-areas:"panel" "map" "detail";grid-template-rows:auto auto auto}
193
- .sf-explorer-header{padding:18px}
194
- .sf-explorer-header h2{font-size:23px}
195
- .sf-explorer-header p{font-size:13px}
196
- .sf-explorer-panel{border-right:0;border-bottom:1px solid #dce5eb;padding:14px;gap:10px}
197
- .sf-explorer-results{max-height:140px}
198
- .sf-explorer-feature-controls{flex-direction:column}
199
- .sf-explorer-detail{padding:14px;border-right:0}
200
- .sf-explorer-toolbar{padding:8px;gap:6px}
201
- .sf-explorer-control-group{gap:4px}
202
- .sf-explorer-pan-controls{flex-basis:100%;margin-left:0}
203
- .sf-explorer-toolbar button{padding:5px 8px}
204
- .sf-explorer-legend{gap:8px 16px;padding:12px 14px 8px}
205
- .sf-explorer-download{display:inline-block;margin:4px 14px 4px 0}
206
- }`;
207
- root.dataset.interface = chrome;
208
- root.style.setProperty('--sf-map-ink', styleOptions.ink ?? '#18364f');
209
- root.style.setProperty('--sf-map-surface', styleOptions.surface ?? '#fff');
210
- root.style.setProperty('--sf-map-accent', styleOptions.accent ?? '#163d61');
211
- root.style.setProperty('--sf-map-border', styleOptions.border ?? '#cedae3');
212
- root.style.setProperty('--sf-map-focus', styleOptions.focus ?? '#1676b8');
213
- root.style.setProperty('--sf-map-control-gap', styleOptions.controlGap ?? '6px');
214
- root.style.setProperty('--sf-map-font', styleOptions.font ?? 'system-ui,sans-serif');
215
- root.append(style);
216
- const header = element('header', '', 'sf-explorer-header');
217
- header.append(element('h2', strings.title ?? 'Explore San Francisco'), element('p', 'Explore the city, from familiar names to the places in between.'));
218
- root.append(header);
219
- const body = element('div', '', 'sf-explorer-body');
220
- const panel = element('div', '', 'sf-explorer-panel');
221
- const modeLabel = element('label', strings.mode ?? 'Map mode');
222
- const modeSelect = element('select');
223
- for (const [value, title] of [
224
- ['neighborhoods', 'Neighborhoods'],
225
- ['districts', 'Districts'],
226
- ['basemap', 'Basemap'],
227
- ]) {
228
- if (value === 'districts' && !data.districts?.[year])
229
- continue;
230
- if (value === 'neighborhoods' && !sources.length)
231
- continue;
232
- const option = element('option', title);
233
- option.value = value;
234
- modeSelect.append(option);
235
- }
236
- modeLabel.append(modeSelect);
237
- const sourceLabel = element('label', strings.source ?? 'Neighborhood definitions');
238
- const sourceSelect = element('select');
239
- for (const value of sources) {
240
- const title = sourceNames[value];
241
- const option = element('option', title);
242
- option.value = value;
243
- sourceSelect.append(option);
244
- }
245
- sourceSelect.value = source;
246
- sourceLabel.append(sourceSelect);
247
- const searchLabel = element('label', strings.search ?? 'Find a neighborhood');
248
- const search = element('input');
249
- search.type = 'search';
250
- search.placeholder = 'Try Mission, Outer Mission, or NoPa';
251
- search.autocomplete = 'off';
252
- searchLabel.append(search);
253
- const count = element('p', '', 'sf-explorer-count');
254
- count.setAttribute('aria-live', 'polite');
255
- const results = element('div', '', 'sf-explorer-results');
256
- results.setAttribute('aria-label', 'Matching neighborhoods');
257
- results.setAttribute('role', 'group');
258
- const detail = element('div', '', 'sf-explorer-detail');
259
- panel.append(modeLabel, sourceLabel, searchLabel, count, results);
260
- const column = element('div', '', 'sf-explorer-map-column');
261
- const toolbar = element('div', '', 'sf-explorer-toolbar');
262
- toolbar.setAttribute('aria-label', 'Map controls');
263
- const canvas = element('div', '', 'sf-explorer-canvas');
264
- const hint = element('p', strings.gestureHelp ??
265
- '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');
266
- hint.id = `sf-explorer-help-${++explorerCount}`;
267
- const status = element('p', '', 'sf-explorer-status');
268
- status.setAttribute('aria-live', 'polite');
269
- const legend = element('div', '', 'sf-explorer-legend');
270
- legend.setAttribute('aria-label', 'Map legend');
271
- for (const [kind, label] of [
272
- ['bart', 'BART station'],
273
- ['park', 'Park'],
274
- ['highway', 'Highway'],
275
- ['road', 'Roads'],
276
- ]) {
277
- if (legendOptions.builtins === false || legendOptions.hidden?.includes(kind))
278
- continue;
279
- const entry = element('span', '', 'sf-explorer-legend-item');
280
- const symbol = element('span', '', `sf-explorer-legend-symbol sf-explorer-legend-${kind}`);
281
- symbol.setAttribute('aria-hidden', 'true');
282
- if (colors[kind]) {
283
- if (kind === 'bart')
284
- symbol.style.borderColor = colors[kind];
285
- else
286
- symbol.style.backgroundColor = colors[kind];
287
- }
288
- entry.append(symbol, document.createTextNode(label));
289
- legend.append(entry);
290
- }
291
- for (const item of legendOptions.items ?? []) {
292
- const entry = element('span', '', 'sf-explorer-legend-item');
293
- const symbol = element('span', '', 'sf-explorer-legend-symbol');
294
- symbol.style.backgroundColor = item.color;
295
- symbol.setAttribute('aria-hidden', 'true');
296
- entry.append(symbol, document.createTextNode(item.label));
297
- legend.append(entry);
298
- }
299
- const featureControls = element('div', '', 'sf-explorer-feature-controls');
300
- const neighborhoodLabel = element('label', strings.chooseNeighborhood ?? 'Select neighborhood');
301
- const neighborhoodSelect = element('select');
302
- neighborhoodLabel.append(neighborhoodSelect);
303
- const markerLabel = element('label', strings.chooseMarker ?? 'Choose marker');
304
- const markerSelect = element('select');
305
- markerLabel.append(markerSelect);
306
- featureControls.append(neighborhoodLabel, markerLabel);
307
- const attribution = element('p', '', 'sf-explorer-hint');
308
- const attributionDisclosure = element('details', '', 'sf-explorer-hint');
309
- attributionDisclosure.append(element('summary', 'Map data · DataSF / BART'), attribution);
310
- column.append(toolbar, featureControls, canvas, legend, hint, status, attributionMode === 'compact' ? attributionDisclosure : attribution);
311
- body.append(panel, column, detail);
312
- 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;
313
108
  const map = createSFMapWithData({
314
109
  year,
315
110
  theme,
@@ -385,7 +180,6 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
385
180
  const labelRenderer = createLabelRenderer(labelLayer);
386
181
  geography.append(areas, stations, labelLayer, overlayLayer, markerLayer);
387
182
  let view = [0, 0, 800];
388
- let selected;
389
183
  let selectedDistrict = null;
390
184
  let hoveredDistrict = null;
391
185
  let districtStyle = initialDistrictStyle;
@@ -551,8 +345,6 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
551
345
  }));
552
346
  }
553
347
  updateDistrictAppearance();
554
- let items = [];
555
- let orderedNeighborhoods = [];
556
348
  let destroyed = false;
557
349
  let initialized = false;
558
350
  const layerFades = createLayerTransitions(() => initialized && features.layerTransitions && !reducedMotion.matches
@@ -577,6 +369,49 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
577
369
  });
578
370
  const stopAnimation = camera.stop;
579
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;
580
415
  function getFeatures() {
581
416
  return structuredClone(features);
582
417
  }
@@ -610,7 +445,6 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
610
445
  }
611
446
  scheduleLabels();
612
447
  }
613
- const downloads = new Set();
614
448
  const stationItems = (data.map.bartStations ?? []).map((feature) => {
615
449
  return {
616
450
  point: map.project(feature.coordinates),
@@ -660,7 +494,6 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
660
494
  ?.visual?.node.focus({ preventScroll: true });
661
495
  });
662
496
  let markerRevision = 0;
663
- let neighborhoodRevision = 0;
664
497
  const clusterLayer = svgElement('g', { 'data-layer': 'marker-clusters' });
665
498
  geography.insertBefore(clusterLayer, markerLayer);
666
499
  function materializeMarker(item) {
@@ -674,7 +507,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
674
507
  }),
675
508
  });
676
509
  }
677
- const clusters = createClusterRenderer(svg, markerLayer, clusterLayer, markerNavigation, materializeMarker, (group, node, signal) => {
510
+ const clusters = createClusterLayer(svg, markerLayer, clusterLayer, markerNavigation, materializeMarker, (group, node, signal) => {
678
511
  if (destroyed || signal.aborted)
679
512
  return;
680
513
  const markers = group.map((item) => ({ ...item.marker }));
@@ -711,86 +544,39 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
711
544
  renderedMarkers = rendered.markers;
712
545
  renderedClusters = rendered.clusters;
713
546
  }
714
- let overlayEvents = new AbortController();
715
- function setOverlays(overlays) {
716
- if (destroyed)
717
- return;
718
- validateOverlays(overlays);
719
- const events = new AbortController();
720
- const next = overlays.map((input) => {
721
- const overlay = structuredClone(input);
722
- const node = svgElement('path', {
723
- 'data-overlay-id': overlay.id,
724
- d: geometryPath(overlay.geometry, map.project),
725
- fill: overlay.fill ?? 'none',
726
- 'fill-opacity': overlay.fillOpacity ?? 1,
727
- stroke: overlay.stroke ?? '#bd8b73',
728
- 'stroke-width': overlay.strokeWidth ?? 2,
729
- 'vector-effect': 'non-scaling-stroke',
730
- display: overlay.visible === false ? 'none' : 'inline',
731
- 'pointer-events': 'none',
732
- ...(overlay.label ? { 'aria-label': overlay.label } : {}),
733
- });
734
- if (overlay.label) {
735
- node.setAttribute('role', 'img');
736
- const title = svgElement('title');
737
- title.textContent = overlay.label;
738
- node.append(title);
739
- }
740
- if (onOverlayActivate) {
741
- node.setAttribute('role', 'button');
742
- node.setAttribute('tabindex', overlay.visible === false ? '-1' : '0');
743
- node.setAttribute('pointer-events', overlay.fill && overlay.fill !== 'none' ? 'visiblePainted' : 'stroke');
744
- node.setAttribute('aria-label', overlay.label ?? overlay.id);
745
- const activate = () => {
746
- if (destroyed || events.signal.aborted)
747
- return;
748
- onOverlayActivate(structuredClone(overlay));
749
- if (destroyed || events.signal.aborted)
750
- return;
751
- root.dispatchEvent(new CustomEvent('overlayactivate', {
752
- bubbles: true,
753
- detail: { overlay: structuredClone(overlay) },
754
- }));
755
- };
756
- node.addEventListener('click', activate, { signal: events.signal });
757
- node.addEventListener('keydown', (event) => {
758
- if (event.key === 'Enter' || event.key === ' ') {
759
- event.preventDefault();
760
- activate();
761
- }
762
- }, { signal: events.signal });
763
- }
764
- return node;
765
- });
766
- overlayEvents.abort();
767
- overlayEvents = events;
768
- overlayLayer.replaceChildren(...next);
769
- }
770
- const zoomControls = element('div', '', 'sf-explorer-control-group');
771
- const panControls = element('div', '', 'sf-explorer-control-group sf-explorer-pan-controls');
772
- toolbar.append(zoomControls, panControls);
773
- function button(text, label, action, group = zoomControls) {
774
- const node = element('button', text);
775
- node.type = 'button';
776
- node.setAttribute('aria-label', label);
777
- listen(node, 'click', () => action());
778
- group.append(node);
779
- return node;
780
- }
781
- const zoomOut = button('−', 'Zoom out', () => zoomBy(1 / 1.5));
782
- const zoomText = element('span', '100%', 'sf-explorer-zoom');
783
- zoomControls.append(zoomText);
784
- const zoomIn = button('+', 'Zoom in', () => zoomBy(1.5));
785
- const resetButton = button(strings.reset ?? 'Reset', strings.reset ?? 'Reset map to city view', resetView);
786
- const labelsButton = button('Labels', 'Show map labels', () => setLabels(!labels));
787
- for (const [symbol, label, dx, dy] of [
788
- ['←', 'Pan west', -1, 0],
789
- ['↑', 'Pan north', 0, -1],
790
- ['↓', 'Pan south', 0, 1],
791
- ['→', 'Pan east', 1, 0],
792
- ])
793
- 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;
794
580
  function panBy(x, y, options = {}) {
795
581
  if (destroyed)
796
582
  return;
@@ -820,10 +606,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
820
606
  view = validateViewport(next);
821
607
  svg.setAttribute('viewBox', `${view[0]} ${view[1]} ${view[2]} ${view[2]}`);
822
608
  const zoom = 800 / view[2];
823
- zoomText.textContent = `${Math.round(zoom * 100)}%`;
609
+ mapControls.updateZoom(zoom);
824
610
  root.dataset.zoom = String(zoom);
825
- zoomIn.disabled = zoom >= 12;
826
- zoomOut.disabled = zoom <= 1;
827
611
  if (view.some((value, i) => value !== previous[i]))
828
612
  root.dispatchEvent(new CustomEvent('viewportchange', { bubbles: true, detail: { viewport: getViewport() } }));
829
613
  scheduleLabels();
@@ -849,8 +633,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
849
633
  function prepareLabels(width, zoom) {
850
634
  // Only screen-space positioning changes during pan or within these zoom/width bands.
851
635
  const inputs = [
852
- items,
853
- selected,
636
+ neighborhoods.items,
637
+ neighborhoods.selected,
854
638
  districtItems,
855
639
  markerRevision,
856
640
  layers,
@@ -866,7 +650,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
866
650
  if (inputs.every((value, index) => value === labelInputs[index]))
867
651
  return preparedLabels;
868
652
  labelInputs = inputs;
869
- const selectedItem = items.find((item) => item.feature === selected);
653
+ const selectedItem = neighborhoods.items.find((item) => item.feature === neighborhoods.selected);
870
654
  const candidates = [];
871
655
  const marker = markerCatalog.items.find((item) => item.marker.id === selectedMarker);
872
656
  if (marker)
@@ -881,46 +665,31 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
881
665
  name: selectedItem.feature.properties.canonicalName,
882
666
  kind: 'selected',
883
667
  });
884
- if (enabled('districtLabels'))
885
- candidates.push(...districtItems);
886
- if (enabled('bartStations') && zoom >= 1.8)
887
- candidates.push(...stationItems);
888
- candidates.push(...(enabled('landmarks') ? parkItems : []).filter((park) => zoom >= 1.8 ||
889
- park.name === 'Golden Gate Park' ||
890
- (width >= 550 && park.name === 'Presidio')));
891
- candidates.push(...(enabled('roadLabels') ? roadItems : []).filter((road) => road.level === 'primary' || zoom >= 1.8));
892
- if (enabled('neighborhoodLabels'))
893
- candidates.push(...orderedNeighborhoods.flatMap((item) => item.feature !== selected && item.point
894
- ? [
895
- {
896
- point: item.point,
897
- name: item.feature.properties.canonicalName,
898
- kind: 'neighborhood',
899
- },
900
- ]
901
- : []));
902
- preparedLabels = candidates.map((item) => {
903
- const fontSize = item.kind === 'road'
904
- ? Math.max(minLabelSize, Math.min(11, maxLabelSize))
905
- : Math.max(minLabelSize, Math.min(maxLabelSize, 12));
906
- return {
907
- ...item,
908
- fontSize,
909
- fontWeight: labelStyle.fontWeight ??
910
- (item.kind.startsWith('selected') || item.kind === 'district' ? 700 : 550),
911
- fill: colors.label ??
912
- (item.kind === 'park'
913
- ? (colors.landmark ?? '#426641')
914
- : item.kind === 'road'
915
- ? '#77736b'
916
- : '#163d61'),
917
- halo: labelStyle.haloColor ?? '#ffffff',
918
- offset: item.kind === 'selected-marker'
919
- ? (marker?.marker.radius ?? markerRadius) + 9
920
- : item.kind === 'bart'
921
- ? 11
922
- : 0,
923
- };
668
+ preparedLabels = styleLabels(labelCandidates({
669
+ selected: candidates,
670
+ districts: enabled('districtLabels') ? districtItems : [],
671
+ stations: enabled('bartStations') ? stationItems : [],
672
+ parks: enabled('landmarks') ? parkItems : [],
673
+ roads: enabled('roadLabels') ? roadItems : [],
674
+ neighborhoods: enabled('neighborhoodLabels')
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
679
+ ? [
680
+ {
681
+ point: item.point,
682
+ name: item.feature.properties.canonicalName,
683
+ kind: 'neighborhood',
684
+ },
685
+ ]
686
+ : [])
687
+ : [],
688
+ }, width, zoom), {
689
+ colors,
690
+ labelStyle,
691
+ labelSize: { min: minLabelSize, max: maxLabelSize },
692
+ markerRadius: marker?.marker.radius ?? markerRadius,
924
693
  });
925
694
  return preparedLabels;
926
695
  }
@@ -991,260 +760,22 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
991
760
  }),
992
761
  ]));
993
762
  }
994
- function releaseDownloads() {
995
- for (const url of downloads)
996
- URL.revokeObjectURL(url);
997
- downloads.clear();
998
- }
999
- function downloadLink(text, value, filename) {
1000
- const link = element('a', text, 'sf-explorer-download');
1001
- const url = URL.createObjectURL(new Blob([`${JSON.stringify(value, null, 2)}\n`], { type: 'application/geo+json' }));
1002
- downloads.add(url);
1003
- link.href = url;
1004
- link.download = filename;
1005
- return link;
1006
- }
763
+ const sidebar = ui.createSidebar?.(shell, data, chrome, strings.emptyResults, searchNeighborhoods);
1007
764
  function updateDetail() {
1008
- releaseDownloads();
1009
- if (chrome === 'map')
1010
- return;
1011
- detail.replaceChildren();
1012
- if (mode === 'districts') {
1013
- const collection = data.districts?.[year];
1014
- if (!collection) {
1015
- detail.append(element('h3', `${year} supervisorial districts`));
1016
- return;
1017
- }
1018
- 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));
1019
- return;
1020
- }
1021
- if (mode === 'basemap') {
1022
- detail.append(element('h3', 'San Francisco basemap'));
1023
- return;
1024
- }
1025
- const collection = collections[source];
1026
- if (!collection)
1027
- return;
1028
- detail.append(element('h3', selected ? selected.properties.canonicalName : 'Explore all neighborhoods'));
1029
- const description = selected
1030
- ? `Source name: ${selected.properties.sourceName}${selected.properties.aliases.length ? `. Also known as: ${selected.properties.aliases.join(', ')}` : ''}.`
1031
- : 'Choose a name or click an area to highlight its full boundary.';
1032
- const sourceSummary = {
1033
- realtor: 'SFAR realtor boundaries · August 2010 · 92 distinct areas.',
1034
- 'sf-find': 'SF Find neighborhoods · 2006 · 117 approximate areas.',
1035
- analysis: 'City analysis neighborhoods · 41 census-tract-based areas.',
1036
- };
1037
- detail.append(element('p', description), element('p', sourceSummary[source]));
1038
- if (selected)
1039
- detail.append(downloadLink('Download neighborhood GeoJSON', { ...collection, selection: selected.id, features: [selected] }, `${source}-${selected.id}.geojson`));
1040
- detail.append(downloadLink(`Download all ${collection.features.length} areas`, collection, `neighborhoods-${source}.geojson`));
1041
- const provenance = element('details');
1042
- provenance.append(element('summary', 'About these boundaries'), element('p', collection.definition.description));
1043
- detail.append(provenance);
765
+ sidebar?.updateDetail({
766
+ mode,
767
+ year,
768
+ source: neighborhoods.source,
769
+ selected: neighborhoods.selected,
770
+ });
1044
771
  }
1045
772
  function updateResults() {
1046
- if (chrome === 'map')
1047
- return;
1048
- const activeElement = document.activeElement;
1049
- const focusedId = activeElement instanceof HTMLElement && results.contains(activeElement)
1050
- ? activeElement.dataset.neighborhoodResult
1051
- : undefined;
1052
- const matches = searchNeighborhoods(search.value, source).sort((a, b) => a.canonicalName.localeCompare(b.canonicalName));
1053
- count.textContent = `${matches.length} ${matches.length === 1 ? 'neighborhood' : 'neighborhoods'}${search.value ? ' found' : ' in this collection'}`;
1054
- results.replaceChildren();
1055
- for (const match of matches) {
1056
- const node = element('button', match.canonicalName);
1057
- node.type = 'button';
1058
- node.dataset.neighborhoodResult = match.id;
1059
- node.setAttribute('aria-pressed', String(selected?.id === match.id));
1060
- results.append(node);
1061
- if (focusedId === match.id)
1062
- node.focus({ preventScroll: true });
1063
- }
1064
- if (!matches.length)
1065
- results.append(element('p', strings.emptyResults ?? 'No matches. Try another name or definition source.', 'sf-explorer-note'));
1066
- }
1067
- function getSelection() {
1068
- return selected
1069
- ? {
1070
- id: selected.id,
1071
- name: selected.properties.canonicalName,
1072
- source,
1073
- feature: structuredClone(selected),
1074
- }
1075
- : null;
1076
- }
1077
- function selectNeighborhood(name, options = {}) {
1078
- if (destroyed)
1079
- return false;
1080
- const feature = name === null
1081
- ? undefined
1082
- : (collections[source]?.features.find((entry) => entry.id === name) ??
1083
- getNeighborhood(name, source));
1084
- if (name !== null && !feature)
1085
- return false;
1086
- validateCameraOptions(options);
1087
- const selectedItem = items.find((item) => item.feature === feature);
1088
- const width = canvas.getBoundingClientRect().width;
1089
- const target = selectedItem && options.fit !== false
1090
- ? width
1091
- ? fitViewport(selectedItem.bounds, width, fitPadding)
1092
- : fitBounds(selectedItem.bounds)
1093
- : null;
1094
- if (feature &&
1095
- !enabled('neighborhoodLines') &&
1096
- !enabled('neighborhoodLabels') &&
1097
- (layers.neighborhoodLines === undefined || layers.neighborhoodLabels === undefined))
1098
- setMode('neighborhoods', false);
1099
- const revision = ++neighborhoodRevision;
1100
- const changed = selected !== feature;
1101
- selected = feature;
1102
- for (const item of items) {
1103
- const active = item.feature === selected;
1104
- item.node.setAttribute('fill', active ? (areaStyle.selectedFill ?? '#408dbe') : 'transparent');
1105
- item.node.setAttribute('fill-opacity', active ? '.16' : '1');
1106
- item.node.setAttribute('stroke', active ? (areaStyle.selectedStroke ?? '#176ba2') : (colors.neighborhood ?? '#9caebc'));
1107
- item.node.setAttribute('stroke-width', active ? '2.2' : enabled('neighborhoodLines') ? '.55' : '0');
1108
- if (selectableNeighborhoods)
1109
- item.node.setAttribute('aria-pressed', String(active));
1110
- if (active)
1111
- areas.append(item.node);
1112
- }
1113
- if (feature)
1114
- root.dataset.selectedNeighborhood = feature.id;
1115
- else
1116
- delete root.dataset.selectedNeighborhood;
1117
- neighborhoodSelect.value = feature?.id ?? '';
1118
- scheduleLabels();
1119
- updateResults();
1120
- updateDetail();
1121
- status.textContent = feature
1122
- ? `${feature.properties.canonicalName} selected. ${formatSourceLabel(source)}.`
1123
- : 'Neighborhood selection cleared.';
1124
- if (changed)
1125
- root.dispatchEvent(new CustomEvent('neighborhoodchange', {
1126
- bubbles: true,
1127
- detail: {
1128
- feature: feature ? structuredClone(feature) : null,
1129
- source,
1130
- id: feature?.id ?? null,
1131
- name: feature?.properties.canonicalName ?? null,
1132
- },
1133
- }));
1134
- if (!destroyed && neighborhoodRevision === revision && target)
1135
- moveView(target, options);
1136
- return true;
1137
- }
1138
- function prepareSource(next, reset = true) {
1139
- if (!Object.hasOwn(collections, next))
1140
- throw new RangeError(`Unknown neighborhood source: ${next}`);
1141
- const collection = collections[next];
1142
- if (!collection ||
1143
- !Array.isArray(collection.features) ||
1144
- typeof collection.title !== 'string' ||
1145
- typeof collection.definition?.description !== 'string')
1146
- throw new TypeError('Neighborhood collections require features, title and definition description.');
1147
- const ids = new Set();
1148
- const nextOptions = [];
1149
- const nextItems = collection.features.map((feature) => {
1150
- if (!feature ||
1151
- typeof feature.id !== 'string' ||
1152
- !feature.id ||
1153
- ids.has(feature.id) ||
1154
- !feature.properties ||
1155
- typeof feature.properties.canonicalName !== 'string' ||
1156
- typeof feature.properties.sourceName !== 'string' ||
1157
- !Array.isArray(feature.properties.aliases) ||
1158
- Array.from(feature.properties.aliases).some((alias) => typeof alias !== 'string') ||
1159
- !['Polygon', 'MultiPolygon'].includes(feature.geometry?.type))
1160
- throw new TypeError('Neighborhood features require unique IDs, names, aliases and polygon geometry.');
1161
- ids.add(feature.id);
1162
- const bounds = projectedBounds(feature.geometry, map.project);
1163
- if (!bounds.every(Number.isFinite))
1164
- throw new RangeError('Neighborhood geometry must be nonempty and finite.');
1165
- const node = svgElement('path', {
1166
- d: geometryPath(feature.geometry, map.project),
1167
- 'data-neighborhood-id': feature.id,
1168
- fill: 'transparent',
1169
- 'fill-rule': 'evenodd',
1170
- stroke: colors.neighborhood ?? '#9caebc',
1171
- 'stroke-width': '.55',
1172
- 'vector-effect': 'non-scaling-stroke',
1173
- role: 'button',
1174
- tabindex: -1,
1175
- 'aria-label': feature.properties.canonicalName,
1176
- 'aria-pressed': 'false',
1177
- });
1178
- const option = element('option', feature.properties.canonicalName);
1179
- option.value = feature.id;
1180
- nextOptions.push(option);
1181
- const title = svgElement('title');
1182
- title.textContent = feature.properties.canonicalName;
1183
- node.append(title);
1184
- node.addEventListener('pointerenter', () => {
1185
- if (destroyed ||
1186
- !selectableNeighborhoods ||
1187
- selected?.id === feature.id ||
1188
- (!areaStyle.hoverFill && !areaStyle.hoverStroke))
1189
- return;
1190
- node.setAttribute('fill', areaStyle.hoverFill ?? '#408dbe');
1191
- node.setAttribute('fill-opacity', '.12');
1192
- node.setAttribute('stroke', areaStyle.hoverStroke ?? colors.neighborhood ?? '#176ba2');
1193
- });
1194
- node.addEventListener('pointerleave', () => {
1195
- if (destroyed || selected?.id === feature.id)
1196
- return;
1197
- node.setAttribute('fill', 'transparent');
1198
- node.setAttribute('fill-opacity', '1');
1199
- node.setAttribute('stroke', colors.neighborhood ?? '#9caebc');
1200
- });
1201
- return {
1202
- feature,
1203
- node,
1204
- bounds,
1205
- point: interiorAnchor(feature.geometry, map.project),
1206
- area: (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]),
1207
- };
773
+ sidebar?.updateResults({
774
+ mode,
775
+ year,
776
+ source: neighborhoods.source,
777
+ selected: neighborhoods.selected,
1208
778
  });
1209
- return () => {
1210
- const hadSelection = !!selected;
1211
- source = next;
1212
- sourceSelect.value = source;
1213
- selected = undefined;
1214
- const revision = ++neighborhoodRevision;
1215
- delete root.dataset.selectedNeighborhood;
1216
- root.dataset.source = source;
1217
- layerFades.crossfade(areas, () => areas.replaceChildren(...nextItems.map((item) => item.node)));
1218
- neighborhoodSelect.replaceChildren(element('option', 'No neighborhood selected'));
1219
- neighborhoodSelect.options[0].value = '';
1220
- neighborhoodSelect.append(...nextOptions);
1221
- items = nextItems;
1222
- orderedNeighborhoods = nextItems
1223
- .filter((item) => item.point !== undefined)
1224
- .sort((a, b) => b.area - a.area || a.feature.id.localeCompare(b.feature.id));
1225
- if (items[0])
1226
- items[0].node.setAttribute('tabindex', '0');
1227
- updateResults();
1228
- updateDetail();
1229
- updateComposition();
1230
- status.textContent =
1231
- mode === 'basemap'
1232
- ? 'San Francisco basemap.'
1233
- : mode === 'districts'
1234
- ? `${year} supervisorial districts. Numbers identify each district.`
1235
- : `${formatSourceLabel(source)}. Select a neighborhood to begin.`;
1236
- if (hadSelection)
1237
- root.dispatchEvent(new CustomEvent('neighborhoodchange', {
1238
- bubbles: true,
1239
- detail: { feature: null, source, id: null, name: null },
1240
- }));
1241
- if (reset && !destroyed && neighborhoodRevision === revision)
1242
- resetView();
1243
- };
1244
- }
1245
- function setSource(next) {
1246
- if (!destroyed)
1247
- prepareSource(next)();
1248
779
  }
1249
780
  function syncFeatureControls() {
1250
781
  featureControls.hidden = neighborhoodLabel.hidden && markerLabel.hidden;
@@ -1290,9 +821,13 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1290
821
  chrome === 'explorer' ||
1291
822
  controls.neighborhoodPicker === false;
1292
823
  syncFeatureControls();
1293
- for (const item of items) {
1294
- item.node.setAttribute('stroke-width', item.feature === selected ? '2.2' : enabled('neighborhoodLines') ? '.55' : '0');
1295
- 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');
1296
831
  item.node.setAttribute('role', selectableNeighborhoods ? 'button' : 'img');
1297
832
  if (!selectableNeighborhoods)
1298
833
  item.node.removeAttribute('aria-pressed');
@@ -1307,7 +842,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1307
842
  if (districtsVisible)
1308
843
  descriptions.push(`${year} supervisorial districts, DataSF.`);
1309
844
  if (neighborhoodsVisible) {
1310
- const collection = collections[source];
845
+ const collection = collections[neighborhoods.source];
1311
846
  if (collection)
1312
847
  descriptions.push(`${collection.title}. ${collection.definition.description}`);
1313
848
  }
@@ -1420,22 +955,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1420
955
  validateSwitchPatch(patch, controlKeys);
1421
956
  const next = { ...controls, ...patch };
1422
957
  controls = next;
1423
- for (const node of [zoomIn, zoomOut, zoomText])
1424
- node.hidden = controls.zoom === false;
1425
- panControls.hidden = controls.pan === false;
1426
- resetButton.hidden = controls.reset === false;
1427
- labelsButton.hidden = controls.labels === false;
1428
- touchButton.hidden = controls.touch === false;
1429
- if (touchButton.hidden && touchNavigation)
1430
- setTouchNavigation(false);
1431
- zoomControls.hidden = [...zoomControls.children].every((node) => node.hidden);
1432
- toolbar.hidden = zoomControls.hidden && panControls.hidden;
1433
- hint.hidden = controls.help === false;
1434
- if (hint.hidden)
1435
- svg.setAttribute('aria-describedby', hint.id);
1436
- else
1437
- svg.removeAttribute('aria-describedby');
1438
- status.classList.toggle('sf-explorer-visually-hidden', controls.status === false);
958
+ mapControls.update(controls);
1439
959
  markerLabel.hidden = !markerCatalog.items.length || controls.markerPicker === false;
1440
960
  updateComposition();
1441
961
  }
@@ -1463,9 +983,9 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1463
983
  ? 'San Francisco basemap.'
1464
984
  : districts
1465
985
  ? `${year} supervisorial districts. Numbers identify each district.`
1466
- : selected
1467
- ? `${selected.properties.canonicalName} selected. ${formatSourceLabel(source)}.`
1468
- : `${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.`;
1469
989
  if (reset)
1470
990
  resetView();
1471
991
  }
@@ -1475,7 +995,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1475
995
  if (typeof visible !== 'boolean')
1476
996
  throw new TypeError('Labels must be a boolean.');
1477
997
  labels = visible;
1478
- labelsButton.setAttribute('aria-pressed', String(labels));
998
+ mapControls.updateLabels(labels);
1479
999
  root.dataset.labels = String(labels);
1480
1000
  updateComposition();
1481
1001
  if (!labels) {
@@ -1484,61 +1004,6 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1484
1004
  }
1485
1005
  scheduleLabels();
1486
1006
  }
1487
- listen(modeSelect, 'change', () => {
1488
- const next = modeSelect.value;
1489
- if (next === 'neighborhoods' || next === 'districts' || next === 'basemap')
1490
- setMode(next);
1491
- });
1492
- listen(search, 'input', updateResults);
1493
- listen(search, 'keydown', (event) => {
1494
- if (event.key === 'Enter') {
1495
- const exact = getNeighborhood(search.value, source);
1496
- const matches = searchNeighborhoods(search.value, source);
1497
- if (exact || matches.length === 1) {
1498
- event.preventDefault();
1499
- selectNeighborhood(exact?.id ?? matches[0].id);
1500
- }
1501
- }
1502
- });
1503
- listen(sourceSelect, 'change', () => {
1504
- const next = sourceSelect.value;
1505
- if (next === 'realtor' || next === 'sf-find' || next === 'analysis')
1506
- setSource(next);
1507
- });
1508
- listen(results, 'click', (event) => {
1509
- const node = event.target instanceof Element
1510
- ? event.target.closest('[data-neighborhood-result]')
1511
- : null;
1512
- if (node?.dataset.neighborhoodResult)
1513
- selectNeighborhood(node.dataset.neighborhoodResult);
1514
- });
1515
- const touchLabel = strings.touchNavigation ?? 'Touch navigation';
1516
- const touchButton = button(touchLabel, strings.touchNavigationLabel ?? `Enable ${touchLabel.toLowerCase()}`, () => setTouchNavigation(!touchNavigation));
1517
- touchButton.hidden = controls.touch === false;
1518
- let touchNavigation = false;
1519
- const navigation = attachNavigation(svg, getViewport, (next) => {
1520
- stopAnimation();
1521
- setView(next);
1522
- }, controller.signal, () => setTouchNavigation(false));
1523
- function setTouchNavigation(enabled) {
1524
- if (destroyed)
1525
- return;
1526
- if (typeof enabled !== 'boolean')
1527
- throw new TypeError('Touch navigation must be a boolean.');
1528
- touchNavigation = enabled;
1529
- navigation.setTouchNavigation(enabled);
1530
- canvas.style.touchAction = enabled ? 'none' : 'pan-y pinch-zoom';
1531
- touchButton.textContent = enabled
1532
- ? (strings.touchNavigationDone ?? 'Done: page scrolling')
1533
- : touchLabel;
1534
- touchButton.setAttribute('aria-label', enabled
1535
- ? (strings.touchNavigationExitLabel ?? `Exit ${touchLabel.toLowerCase()}`)
1536
- : (strings.touchNavigationLabel ?? `Enable ${touchLabel.toLowerCase()}`));
1537
- touchButton.setAttribute('aria-pressed', String(enabled));
1538
- root.dataset.touchNavigation = String(enabled);
1539
- }
1540
- listen(neighborhoodSelect, 'change', () => selectNeighborhood(neighborhoodSelect.value || null));
1541
- listen(markerSelect, 'change', () => selectMarker(markerSelect.value || null));
1542
1007
  listen(svg, 'keydown', (event) => {
1543
1008
  const target = event.target instanceof SVGElement ? event.target : null;
1544
1009
  const districtId = Number(target?.dataset.district);
@@ -1572,9 +1037,10 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1572
1037
  }
1573
1038
  else if (event.key === '[' || event.key === ']') {
1574
1039
  event.preventDefault();
1575
- const i = items.findIndex((item) => item.feature.id === id);
1576
- const next = items[(i + (event.key === ']' ? 1 : items.length - 1)) % items.length];
1577
- 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)
1578
1044
  item.node.setAttribute('tabindex', item === next ? '0' : '-1');
1579
1045
  next.node.focus();
1580
1046
  }
@@ -1646,7 +1112,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1646
1112
  listen(svg, 'pointerdown', stopAnimation);
1647
1113
  let observer;
1648
1114
  const onResize = () => {
1649
- navigation.cancel();
1115
+ mapControls.cancelNavigation();
1650
1116
  scheduleLabels();
1651
1117
  root.dispatchEvent(new CustomEvent('mapresize', { bubbles: true }));
1652
1118
  };
@@ -1662,7 +1128,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1662
1128
  throw new RangeError('No neighborhood dataset was supplied.');
1663
1129
  if (patch.source !== undefined && !Object.hasOwn(collections, patch.source))
1664
1130
  throw new RangeError(`Unknown neighborhood source: ${patch.source}`);
1665
- const commitSource = patch.source !== undefined && patch.source !== source
1131
+ const commitSource = patch.source !== undefined && patch.source !== neighborhoods.source
1666
1132
  ? prepareSource(patch.source, false)
1667
1133
  : undefined;
1668
1134
  const appearance = patch.appearance;
@@ -1743,8 +1209,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1743
1209
  symbol.style.setProperty(kind === 'bart' ? 'border-color' : 'background-color', colors[kind]);
1744
1210
  }
1745
1211
  }
1746
- for (const item of items) {
1747
- const active = item.feature === selected;
1212
+ for (const item of neighborhoods.items) {
1213
+ const active = item.feature === neighborhoods.selected;
1748
1214
  item.node.setAttribute('fill', active ? (areaStyle.selectedFill ?? '#408dbe') : 'transparent');
1749
1215
  item.node.setAttribute('stroke', active ? (areaStyle.selectedStroke ?? '#176ba2') : (colors.neighborhood ?? '#9caebc'));
1750
1216
  }
@@ -1770,7 +1236,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1770
1236
  if (destroyed || revision !== presentationRevision || !isCurrent())
1771
1237
  return false;
1772
1238
  if (commitSource && appearance)
1773
- for (const item of items)
1239
+ for (const item of neighborhoods.items)
1774
1240
  item.node.setAttribute('stroke', colors.neighborhood ?? '#9caebc');
1775
1241
  if (patch.mode !== undefined)
1776
1242
  setMode(patch.mode, false);
@@ -1781,7 +1247,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1781
1247
  }
1782
1248
  const explorer = Object.assign(root, {
1783
1249
  applyPresentation,
1784
- getMapState: () => ({ mode, source, year, labels }),
1250
+ getMapState: () => ({ mode, source: neighborhoods.source, year, labels }),
1785
1251
  selectNeighborhood,
1786
1252
  getSelection,
1787
1253
  selectDistrict,
@@ -1818,13 +1284,14 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1818
1284
  districtMorph.stop();
1819
1285
  cancelEntrances();
1820
1286
  controller.abort();
1821
- overlayEvents.abort();
1287
+ overlays.destroy();
1822
1288
  clusters.destroy();
1823
1289
  canvas.style.touchAction = 'pan-y pinch-zoom';
1824
1290
  observer?.disconnect();
1825
1291
  frames.destroy();
1826
1292
  labelRenderer.clear();
1827
- releaseDownloads();
1293
+ sidebar?.destroy();
1294
+ neighborhoods.destroy();
1828
1295
  },
1829
1296
  });
1830
1297
  try {
@@ -1845,12 +1312,12 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1845
1312
  }, { signal: controller.signal });
1846
1313
  const initialMode = mode;
1847
1314
  if (sources.length)
1848
- setSource(source);
1315
+ setSource(neighborhoods.source);
1849
1316
  if (neighborhood !== undefined)
1850
1317
  selectNeighborhood(neighborhood);
1851
1318
  setMode(initialMode);
1852
- if (initialMode === 'neighborhoods' && selected) {
1853
- 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);
1854
1321
  if (selectedItem)
1855
1322
  setView(fitBounds(selectedItem.bounds));
1856
1323
  }