@kahwee/sf-map-svg 4.4.0 → 5.0.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 (52) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +32 -0
  3. package/dist/data/README.md +7 -0
  4. package/dist/src/cards.d.ts +15 -0
  5. package/dist/src/cards.js +138 -0
  6. package/dist/src/cluster-layer.d.ts +2 -0
  7. package/dist/src/cluster-layer.js +2 -0
  8. package/dist/src/configuration.d.ts +2 -0
  9. package/dist/src/configuration.js +30 -0
  10. package/dist/src/controller-types.d.ts +11 -15
  11. package/dist/src/explorer-controls.d.ts +26 -0
  12. package/dist/src/explorer-controls.js +120 -0
  13. package/dist/src/explorer-core.d.ts +6 -1
  14. package/dist/src/explorer-core.js +156 -667
  15. package/dist/src/explorer-shell.d.ts +62 -0
  16. package/dist/src/explorer-shell.js +228 -0
  17. package/dist/src/explorer-sidebar.d.ts +20 -0
  18. package/dist/src/explorer-sidebar.js +79 -0
  19. package/dist/src/geometry.d.ts +6 -1
  20. package/dist/src/geometry.js +80 -0
  21. package/dist/src/guide-map.js +2 -1
  22. package/dist/src/guide-static.d.ts +1 -1
  23. package/dist/src/label-spread.d.ts +5 -0
  24. package/dist/src/label-spread.js +36 -0
  25. package/dist/src/layers.js +1 -1
  26. package/dist/src/map.d.ts +1 -1
  27. package/dist/src/map.js +24 -13
  28. package/dist/src/marker-catalog.d.ts +1 -1
  29. package/dist/src/marker-catalog.js +11 -5
  30. package/dist/src/neighborhood-layer.d.ts +67 -0
  31. package/dist/src/neighborhood-layer.js +256 -0
  32. package/dist/src/overlay-layer.d.ts +13 -0
  33. package/dist/src/overlay-layer.js +68 -0
  34. package/dist/src/palettes.d.ts +48 -0
  35. package/dist/src/palettes.js +40 -0
  36. package/dist/src/presets.d.ts +1 -0
  37. package/dist/src/presets.js +1 -0
  38. package/dist/src/static-labels.js +19 -4
  39. package/dist/src/static.d.ts +2 -2
  40. package/dist/src/static.js +21 -2
  41. package/dist/src/types.d.ts +21 -9
  42. package/dist/src/validation.js +7 -5
  43. package/docs/API.md +75 -5
  44. package/docs/EXAMPLES.md +29 -0
  45. package/docs/api-audit.md +22 -5
  46. package/docs/consumer-integration.md +86 -2
  47. package/docs/developer-guide.md +29 -7
  48. package/docs/guide-bundle-report.md +4 -4
  49. package/docs/migration-v5.md +100 -0
  50. package/llms-full.txt +508 -53
  51. package/llms.txt +2 -1
  52. package/package.json +17 -8
@@ -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,
@@ -346,8 +140,10 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
346
140
  arrow.hidden = !features.northArrow;
347
141
  }
348
142
  syncFurniture();
349
- function projectToScreen(lng, lat) {
350
- const [x, y] = map.project([lng, lat]);
143
+ function projectToScreen(position) {
144
+ if (!Array.isArray(position) || position.length < 2)
145
+ throw new RangeError('Expected a [longitude, latitude] position.');
146
+ const [x, y] = map.project(position);
351
147
  const width = canvas.getBoundingClientRect().width;
352
148
  if (!width)
353
149
  throw new Error('Mount the map before projecting to screen.');
@@ -386,7 +182,6 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
386
182
  const labelRenderer = createLabelRenderer(labelLayer);
387
183
  geography.append(areas, stations, labelLayer, overlayLayer, markerLayer);
388
184
  let view = [0, 0, 800];
389
- let selected;
390
185
  let selectedDistrict = null;
391
186
  let hoveredDistrict = null;
392
187
  let districtStyle = initialDistrictStyle;
@@ -552,8 +347,6 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
552
347
  }));
553
348
  }
554
349
  updateDistrictAppearance();
555
- let items = [];
556
- let orderedNeighborhoods = [];
557
350
  let destroyed = false;
558
351
  let initialized = false;
559
352
  const layerFades = createLayerTransitions(() => initialized && features.layerTransitions && !reducedMotion.matches
@@ -578,6 +371,49 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
578
371
  });
579
372
  const stopAnimation = camera.stop;
580
373
  const moveView = camera.move;
374
+ const neighborhoods = createNeighborhoodLayer({
375
+ collections,
376
+ initialSource: source,
377
+ project: map.project,
378
+ root,
379
+ areas,
380
+ canvas,
381
+ sourceSelect,
382
+ neighborhoodSelect,
383
+ status,
384
+ readPresentation: () => ({
385
+ mode,
386
+ year,
387
+ fitPadding,
388
+ areaStyle,
389
+ colors,
390
+ selectableNeighborhoods,
391
+ lines: enabled('neighborhoodLines'),
392
+ }),
393
+ isDestroyed: () => destroyed,
394
+ ensureVisible() {
395
+ if (!enabled('neighborhoodLines') &&
396
+ !enabled('neighborhoodLabels') &&
397
+ (layers.neighborhoodLines === undefined || layers.neighborhoodLabels === undefined))
398
+ setMode('neighborhoods', false);
399
+ },
400
+ refresh: {
401
+ selection() {
402
+ scheduleLabels();
403
+ updateResults();
404
+ updateDetail();
405
+ },
406
+ source() {
407
+ updateResults();
408
+ updateDetail();
409
+ updateComposition();
410
+ },
411
+ },
412
+ moveView,
413
+ resetView,
414
+ crossfade: layerFades.crossfade,
415
+ });
416
+ const { getSelection, selectNeighborhood, prepareSource, setSource } = neighborhoods;
581
417
  function getFeatures() {
582
418
  return structuredClone(features);
583
419
  }
@@ -611,7 +447,6 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
611
447
  }
612
448
  scheduleLabels();
613
449
  }
614
- const downloads = new Set();
615
450
  const stationItems = (data.map.bartStations ?? []).map((feature) => {
616
451
  return {
617
452
  point: map.project(feature.coordinates),
@@ -661,7 +496,6 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
661
496
  ?.visual?.node.focus({ preventScroll: true });
662
497
  });
663
498
  let markerRevision = 0;
664
- let neighborhoodRevision = 0;
665
499
  const clusterLayer = svgElement('g', { 'data-layer': 'marker-clusters' });
666
500
  geography.insertBefore(clusterLayer, markerLayer);
667
501
  function materializeMarker(item) {
@@ -675,10 +509,13 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
675
509
  }),
676
510
  });
677
511
  }
678
- const clusters = createClusterRenderer(svg, markerLayer, clusterLayer, markerNavigation, materializeMarker, (group, node, signal) => {
512
+ const clusters = createClusterLayer(svg, markerLayer, clusterLayer, markerNavigation, materializeMarker, (group, node, signal) => {
679
513
  if (destroyed || signal.aborted)
680
514
  return;
681
- const markers = group.map((item) => ({ ...item.marker }));
515
+ const markers = group.map((item) => ({
516
+ ...item.marker,
517
+ position: [...item.marker.position],
518
+ }));
682
519
  const minimumUnit = 800 / 12 / Math.max(1, canvas.getBoundingClientRect().width);
683
520
  if (clusterPoints(group, minimumUnit, features.clustering ? features.clustering.radius : 36)
684
521
  .length === 1)
@@ -686,7 +523,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
686
523
  else
687
524
  fitGeometry({
688
525
  type: 'MultiPoint',
689
- coordinates: markers.map((marker) => [marker.lng, marker.lat]),
526
+ coordinates: markers.map((marker) => marker.position),
690
527
  });
691
528
  if (!destroyed && !signal.aborted)
692
529
  root.dispatchEvent(new CustomEvent('clusteractivate', { bubbles: true, detail: { markers } }));
@@ -712,86 +549,39 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
712
549
  renderedMarkers = rendered.markers;
713
550
  renderedClusters = rendered.clusters;
714
551
  }
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);
552
+ const overlays = createOverlayLayer({
553
+ layer: overlayLayer,
554
+ root,
555
+ project: map.project,
556
+ onOverlayActivate,
557
+ isDestroyed: () => destroyed,
558
+ });
559
+ const setOverlays = overlays.set;
560
+ const mapControls = createExplorerControls(shell, {
561
+ svg,
562
+ signal: controller.signal,
563
+ strings,
564
+ isDestroyed: () => destroyed,
565
+ getLabels: () => labels,
566
+ getViewport,
567
+ moveView,
568
+ zoomBy,
569
+ resetView,
570
+ setLabels,
571
+ stopAnimation,
572
+ setView,
573
+ setMode,
574
+ setSource,
575
+ selectNeighborhood,
576
+ selectMarker,
577
+ updateResults,
578
+ search(query) {
579
+ const exact = getNeighborhood(query, neighborhoods.source);
580
+ const matches = searchNeighborhoods(query, neighborhoods.source);
581
+ return exact?.id ?? (matches.length === 1 ? matches[0].id : undefined);
582
+ },
583
+ });
584
+ const setTouchNavigation = mapControls.setTouchNavigation;
795
585
  function panBy(x, y, options = {}) {
796
586
  if (destroyed)
797
587
  return;
@@ -821,10 +611,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
821
611
  view = validateViewport(next);
822
612
  svg.setAttribute('viewBox', `${view[0]} ${view[1]} ${view[2]} ${view[2]}`);
823
613
  const zoom = 800 / view[2];
824
- zoomText.textContent = `${Math.round(zoom * 100)}%`;
614
+ mapControls.updateZoom(zoom);
825
615
  root.dataset.zoom = String(zoom);
826
- zoomIn.disabled = zoom >= 12;
827
- zoomOut.disabled = zoom <= 1;
828
616
  if (view.some((value, i) => value !== previous[i]))
829
617
  root.dispatchEvent(new CustomEvent('viewportchange', { bubbles: true, detail: { viewport: getViewport() } }));
830
618
  scheduleLabels();
@@ -850,8 +638,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
850
638
  function prepareLabels(width, zoom) {
851
639
  // Only screen-space positioning changes during pan or within these zoom/width bands.
852
640
  const inputs = [
853
- items,
854
- selected,
641
+ neighborhoods.items,
642
+ neighborhoods.selected,
855
643
  districtItems,
856
644
  markerRevision,
857
645
  layers,
@@ -867,7 +655,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
867
655
  if (inputs.every((value, index) => value === labelInputs[index]))
868
656
  return preparedLabels;
869
657
  labelInputs = inputs;
870
- const selectedItem = items.find((item) => item.feature === selected);
658
+ const selectedItem = neighborhoods.items.find((item) => item.feature === neighborhoods.selected);
871
659
  const candidates = [];
872
660
  const marker = markerCatalog.items.find((item) => item.marker.id === selectedMarker);
873
661
  if (marker)
@@ -889,7 +677,10 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
889
677
  parks: enabled('landmarks') ? parkItems : [],
890
678
  roads: enabled('roadLabels') ? roadItems : [],
891
679
  neighborhoods: enabled('neighborhoodLabels')
892
- ? orderedNeighborhoods.flatMap((item) => item.feature !== selected && item.point
680
+ ? neighborhoods.items
681
+ .filter((item) => item.point !== undefined)
682
+ .sort((a, b) => b.area - a.area || a.feature.id.localeCompare(b.feature.id))
683
+ .flatMap((item) => item.feature !== neighborhoods.selected && item.point
893
684
  ? [
894
685
  {
895
686
  point: item.point,
@@ -974,260 +765,22 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
974
765
  }),
975
766
  ]));
976
767
  }
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
- }
768
+ const sidebar = ui.createSidebar?.(shell, data, chrome, strings.emptyResults, searchNeighborhoods);
990
769
  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);
770
+ sidebar?.updateDetail({
771
+ mode,
772
+ year,
773
+ source: neighborhoods.source,
774
+ selected: neighborhoods.selected,
775
+ });
1027
776
  }
1028
777
  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
- };
778
+ sidebar?.updateResults({
779
+ mode,
780
+ year,
781
+ source: neighborhoods.source,
782
+ selected: neighborhoods.selected,
1191
783
  });
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
784
  }
1232
785
  function syncFeatureControls() {
1233
786
  featureControls.hidden = neighborhoodLabel.hidden && markerLabel.hidden;
@@ -1273,9 +826,13 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1273
826
  chrome === 'explorer' ||
1274
827
  controls.neighborhoodPicker === false;
1275
828
  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');
829
+ for (const item of neighborhoods.items) {
830
+ item.node.setAttribute('stroke-width', item.feature === neighborhoods.selected
831
+ ? '2.2'
832
+ : enabled('neighborhoodLines')
833
+ ? '.55'
834
+ : '0');
835
+ item.node.setAttribute('tabindex', selectableNeighborhoods && item === neighborhoods.items[0] ? '0' : '-1');
1279
836
  item.node.setAttribute('role', selectableNeighborhoods ? 'button' : 'img');
1280
837
  if (!selectableNeighborhoods)
1281
838
  item.node.removeAttribute('aria-pressed');
@@ -1290,7 +847,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1290
847
  if (districtsVisible)
1291
848
  descriptions.push(`${year} supervisorial districts, DataSF.`);
1292
849
  if (neighborhoodsVisible) {
1293
- const collection = collections[source];
850
+ const collection = collections[neighborhoods.source];
1294
851
  if (collection)
1295
852
  descriptions.push(`${collection.title}. ${collection.definition.description}`);
1296
853
  }
@@ -1314,7 +871,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1314
871
  }
1315
872
  function getSelectedMarker() {
1316
873
  const marker = markerCatalog.items.find((item) => item.marker.id === selectedMarker)?.marker;
1317
- return marker ? { ...marker } : null;
874
+ return marker ? { ...marker, position: [...marker.position] } : null;
1318
875
  }
1319
876
  function selectMarker(id, options = {}) {
1320
877
  if (destroyed)
@@ -1403,22 +960,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1403
960
  validateSwitchPatch(patch, controlKeys);
1404
961
  const next = { ...controls, ...patch };
1405
962
  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);
963
+ mapControls.update(controls);
1422
964
  markerLabel.hidden = !markerCatalog.items.length || controls.markerPicker === false;
1423
965
  updateComposition();
1424
966
  }
@@ -1446,9 +988,9 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1446
988
  ? 'San Francisco basemap.'
1447
989
  : districts
1448
990
  ? `${year} supervisorial districts. Numbers identify each district.`
1449
- : selected
1450
- ? `${selected.properties.canonicalName} selected. ${formatSourceLabel(source)}.`
1451
- : `${formatSourceLabel(source)}. Select a neighborhood to begin.`;
991
+ : neighborhoods.selected
992
+ ? `${neighborhoods.selected.properties.canonicalName} selected. ${formatSourceLabel(neighborhoods.source)}.`
993
+ : `${formatSourceLabel(neighborhoods.source)}. Select a neighborhood to begin.`;
1452
994
  if (reset)
1453
995
  resetView();
1454
996
  }
@@ -1458,7 +1000,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1458
1000
  if (typeof visible !== 'boolean')
1459
1001
  throw new TypeError('Labels must be a boolean.');
1460
1002
  labels = visible;
1461
- labelsButton.setAttribute('aria-pressed', String(labels));
1003
+ mapControls.updateLabels(labels);
1462
1004
  root.dataset.labels = String(labels);
1463
1005
  updateComposition();
1464
1006
  if (!labels) {
@@ -1467,61 +1009,6 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1467
1009
  }
1468
1010
  scheduleLabels();
1469
1011
  }
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
1012
  listen(svg, 'keydown', (event) => {
1526
1013
  const target = event.target instanceof SVGElement ? event.target : null;
1527
1014
  const districtId = Number(target?.dataset.district);
@@ -1555,9 +1042,10 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1555
1042
  }
1556
1043
  else if (event.key === '[' || event.key === ']') {
1557
1044
  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)
1045
+ const i = neighborhoods.items.findIndex((item) => item.feature.id === id);
1046
+ const next = neighborhoods.items[(i + (event.key === ']' ? 1 : neighborhoods.items.length - 1)) %
1047
+ neighborhoods.items.length];
1048
+ for (const item of neighborhoods.items)
1561
1049
  item.node.setAttribute('tabindex', item === next ? '0' : '-1');
1562
1050
  next.node.focus();
1563
1051
  }
@@ -1629,7 +1117,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1629
1117
  listen(svg, 'pointerdown', stopAnimation);
1630
1118
  let observer;
1631
1119
  const onResize = () => {
1632
- navigation.cancel();
1120
+ mapControls.cancelNavigation();
1633
1121
  scheduleLabels();
1634
1122
  root.dispatchEvent(new CustomEvent('mapresize', { bubbles: true }));
1635
1123
  };
@@ -1645,7 +1133,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1645
1133
  throw new RangeError('No neighborhood dataset was supplied.');
1646
1134
  if (patch.source !== undefined && !Object.hasOwn(collections, patch.source))
1647
1135
  throw new RangeError(`Unknown neighborhood source: ${patch.source}`);
1648
- const commitSource = patch.source !== undefined && patch.source !== source
1136
+ const commitSource = patch.source !== undefined && patch.source !== neighborhoods.source
1649
1137
  ? prepareSource(patch.source, false)
1650
1138
  : undefined;
1651
1139
  const appearance = patch.appearance;
@@ -1726,8 +1214,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1726
1214
  symbol.style.setProperty(kind === 'bart' ? 'border-color' : 'background-color', colors[kind]);
1727
1215
  }
1728
1216
  }
1729
- for (const item of items) {
1730
- const active = item.feature === selected;
1217
+ for (const item of neighborhoods.items) {
1218
+ const active = item.feature === neighborhoods.selected;
1731
1219
  item.node.setAttribute('fill', active ? (areaStyle.selectedFill ?? '#408dbe') : 'transparent');
1732
1220
  item.node.setAttribute('stroke', active ? (areaStyle.selectedStroke ?? '#176ba2') : (colors.neighborhood ?? '#9caebc'));
1733
1221
  }
@@ -1753,7 +1241,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1753
1241
  if (destroyed || revision !== presentationRevision || !isCurrent())
1754
1242
  return false;
1755
1243
  if (commitSource && appearance)
1756
- for (const item of items)
1244
+ for (const item of neighborhoods.items)
1757
1245
  item.node.setAttribute('stroke', colors.neighborhood ?? '#9caebc');
1758
1246
  if (patch.mode !== undefined)
1759
1247
  setMode(patch.mode, false);
@@ -1764,7 +1252,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1764
1252
  }
1765
1253
  const explorer = Object.assign(root, {
1766
1254
  applyPresentation,
1767
- getMapState: () => ({ mode, source, year, labels }),
1255
+ getMapState: () => ({ mode, source: neighborhoods.source, year, labels }),
1768
1256
  selectNeighborhood,
1769
1257
  getSelection,
1770
1258
  selectDistrict,
@@ -1801,13 +1289,14 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1801
1289
  districtMorph.stop();
1802
1290
  cancelEntrances();
1803
1291
  controller.abort();
1804
- overlayEvents.abort();
1292
+ overlays.destroy();
1805
1293
  clusters.destroy();
1806
1294
  canvas.style.touchAction = 'pan-y pinch-zoom';
1807
1295
  observer?.disconnect();
1808
1296
  frames.destroy();
1809
1297
  labelRenderer.clear();
1810
- releaseDownloads();
1298
+ sidebar?.destroy();
1299
+ neighborhoods.destroy();
1811
1300
  },
1812
1301
  });
1813
1302
  try {
@@ -1828,12 +1317,12 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1828
1317
  }, { signal: controller.signal });
1829
1318
  const initialMode = mode;
1830
1319
  if (sources.length)
1831
- setSource(source);
1320
+ setSource(neighborhoods.source);
1832
1321
  if (neighborhood !== undefined)
1833
1322
  selectNeighborhood(neighborhood);
1834
1323
  setMode(initialMode);
1835
- if (initialMode === 'neighborhoods' && selected) {
1836
- const selectedItem = items.find((item) => item.feature === selected);
1324
+ if (initialMode === 'neighborhoods' && neighborhoods.selected) {
1325
+ const selectedItem = neighborhoods.items.find((item) => item.feature === neighborhoods.selected);
1837
1326
  if (selectedItem)
1838
1327
  setView(fitBounds(selectedItem.bounds));
1839
1328
  }