@kahwee/sf-map-svg 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/README.md +65 -4
- package/SOURCES.md +5 -1
- package/dist/data/README.md +7 -1
- package/dist/data/catalog.json +3 -3
- package/dist/data/guide/bart-stations.json +1 -0
- package/dist/data/guide/coast.json +1 -0
- package/dist/data/guide/highways-detailed.json +1 -0
- package/dist/data/guide/highways.json +1 -0
- package/dist/data/guide/key-roads.json +1 -0
- package/dist/data/guide/landmarks.json +1 -0
- package/dist/data/guide/neighborhoods-realtor.json +1 -0
- package/dist/data/key-roads.json +3837 -6741
- package/dist/data/types.d.ts +1 -0
- package/dist/src/explorer-core.d.ts +5 -0
- package/dist/src/explorer-core.js +1085 -0
- package/dist/src/explorer-data.d.ts +9 -0
- package/dist/src/explorer-data.js +1 -0
- package/dist/src/explorer.d.ts +2 -2
- package/dist/src/explorer.js +28 -1030
- package/dist/src/guide.d.ts +10 -0
- package/dist/src/guide.js +107 -0
- package/dist/src/interactive-data.d.ts +6 -0
- package/dist/src/interactive-data.js +5 -0
- package/dist/src/layers.js +6 -1
- package/dist/src/map-core.d.ts +1 -0
- package/dist/src/map-core.js +9 -2
- package/dist/src/overlays.d.ts +1 -0
- package/dist/src/types.d.ts +2 -0
- package/package.json +11 -1
|
@@ -0,0 +1,1085 @@
|
|
|
1
|
+
import { fitBounds, interiorAnchor, layoutLabels, projectedBounds } from './explorer-layout.js';
|
|
2
|
+
import { geometryPath } from './geometry.js';
|
|
3
|
+
import { createSFMapWithData, districtColors } from './map-core.js';
|
|
4
|
+
import { attachNavigation } from './navigation.js';
|
|
5
|
+
import { fitViewport, validateViewport } from './viewport.js';
|
|
6
|
+
const svgNS = 'http://www.w3.org/2000/svg';
|
|
7
|
+
const sourceNames = {
|
|
8
|
+
realtor: 'SFAR realtor · 92 areas',
|
|
9
|
+
'sf-find': 'SF Find · 117 areas',
|
|
10
|
+
analysis: 'City analysis · 41 areas',
|
|
11
|
+
};
|
|
12
|
+
const normalizeName = (value) => value
|
|
13
|
+
.normalize('NFKD')
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.replace(/[\u0300-\u036f'’]/g, '')
|
|
16
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
17
|
+
.trim();
|
|
18
|
+
const formatSourceLabel = (source) => sourceNames[source] ?? `${source} neighborhoods`;
|
|
19
|
+
function element(tag, text, className) {
|
|
20
|
+
const node = document.createElement(tag);
|
|
21
|
+
if (text)
|
|
22
|
+
node.textContent = text;
|
|
23
|
+
if (className)
|
|
24
|
+
node.className = className;
|
|
25
|
+
return node;
|
|
26
|
+
}
|
|
27
|
+
function svgElement(tag, attributes = {}) {
|
|
28
|
+
const node = document.createElementNS(svgNS, tag);
|
|
29
|
+
for (const [key, value] of Object.entries(attributes))
|
|
30
|
+
node.setAttribute(key, String(value));
|
|
31
|
+
return node;
|
|
32
|
+
}
|
|
33
|
+
/** Create an offline, browser-only neighborhood explorer. Call destroy() before disposal. */
|
|
34
|
+
export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'neighborhoods', labels = true, neighborhood, year = 2022, theme = 'transit', interface: chrome = 'explorer', layers = {}, selectableNeighborhoods = true, labelSize = {}, fitPadding = 24, markers: initialMarkers = [], markerRadius = 6, markerHitSize = 44, markerColor = '#245b61', selectedMarkerColor = '#f04f32', onMarkerActivate, overlays: initialOverlays = [], onOverlayActivate, style: styleOptions = {}, strings = {}, controls = {}, } = {}, data) {
|
|
35
|
+
if (!['neighborhoods', 'districts', 'basemap'].includes(mode))
|
|
36
|
+
throw new RangeError(`Unknown map mode: ${mode}`);
|
|
37
|
+
if (chrome !== 'explorer' && chrome !== 'map')
|
|
38
|
+
throw new RangeError('Unknown interface.');
|
|
39
|
+
const minLabelSize = labelSize.min ?? 11, maxLabelSize = labelSize.max ?? 12;
|
|
40
|
+
if (![minLabelSize, maxLabelSize, markerRadius, markerHitSize].every(Number.isFinite) ||
|
|
41
|
+
minLabelSize < 8 ||
|
|
42
|
+
maxLabelSize < minLabelSize ||
|
|
43
|
+
maxLabelSize > 32 ||
|
|
44
|
+
markerRadius <= 0 ||
|
|
45
|
+
markerHitSize < markerRadius * 2)
|
|
46
|
+
throw new RangeError('Use label sizes from 8–32px and a positive marker radius inside its hit target.');
|
|
47
|
+
layers = { ...layers };
|
|
48
|
+
const enabled = (layer) => layers[layer] ??
|
|
49
|
+
(layer.startsWith('district')
|
|
50
|
+
? mode === 'districts'
|
|
51
|
+
: layer.startsWith('neighborhood')
|
|
52
|
+
? mode === 'neighborhoods'
|
|
53
|
+
: true);
|
|
54
|
+
if (typeof labels !== 'boolean')
|
|
55
|
+
throw new TypeError('Labels must be a boolean.');
|
|
56
|
+
const collections = data.neighborhoods;
|
|
57
|
+
const sources = ['realtor', 'sf-find', 'analysis'].filter((item) => Object.hasOwn(collections, item));
|
|
58
|
+
if (!Object.hasOwn(collections, source))
|
|
59
|
+
throw new RangeError(`Unknown neighborhood source: ${source}`);
|
|
60
|
+
if (neighborhood === '')
|
|
61
|
+
neighborhood = undefined;
|
|
62
|
+
const getNeighborhood = (name, selectedSource = source) => {
|
|
63
|
+
const term = normalizeName(name);
|
|
64
|
+
if (!term)
|
|
65
|
+
return undefined;
|
|
66
|
+
return collections[selectedSource]?.features.find((feature) => [
|
|
67
|
+
feature.id,
|
|
68
|
+
feature.properties.canonicalName,
|
|
69
|
+
feature.properties.sourceName,
|
|
70
|
+
...feature.properties.aliases,
|
|
71
|
+
].some((value) => normalizeName(value) === term));
|
|
72
|
+
};
|
|
73
|
+
const searchNeighborhoods = (query, selectedSource) => {
|
|
74
|
+
const term = normalizeName(query);
|
|
75
|
+
return (collections[selectedSource]?.features ?? [])
|
|
76
|
+
.filter((feature) => [
|
|
77
|
+
feature.id,
|
|
78
|
+
feature.properties.canonicalName,
|
|
79
|
+
feature.properties.sourceName,
|
|
80
|
+
...feature.properties.aliases,
|
|
81
|
+
].some((value) => normalizeName(value).includes(term)))
|
|
82
|
+
.map((feature) => ({ id: feature.id, canonicalName: feature.properties.canonicalName }));
|
|
83
|
+
};
|
|
84
|
+
if (neighborhood !== undefined && !getNeighborhood(neighborhood, source))
|
|
85
|
+
throw new RangeError(`Unknown neighborhood: ${neighborhood}`);
|
|
86
|
+
if (typeof document === 'undefined')
|
|
87
|
+
throw new Error('The neighborhood explorer requires a browser document.');
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
function listen(node, event, handler, options = {}) {
|
|
90
|
+
node.addEventListener(event, (incoming) => handler(incoming), { ...options, signal: controller.signal });
|
|
91
|
+
}
|
|
92
|
+
const root = element('section', '', 'sf-explorer');
|
|
93
|
+
root.setAttribute('aria-label', 'San Francisco neighborhood explorer');
|
|
94
|
+
const style = element('style');
|
|
95
|
+
style.textContent = `
|
|
96
|
+
.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}
|
|
97
|
+
.sf-explorer *{box-sizing:border-box}
|
|
98
|
+
.sf-explorer [hidden]{display:none!important}
|
|
99
|
+
.sf-explorer h2,.sf-explorer p{margin:0}
|
|
100
|
+
.sf-explorer button,.sf-explorer input,.sf-explorer select,.sf-explorer a{font:inherit}
|
|
101
|
+
.sf-explorer button,.sf-explorer select,.sf-explorer input{color:inherit;border:1px solid #c7d5df;border-radius:7px;background:#fff;min-height:44px;padding:9px 12px}
|
|
102
|
+
.sf-explorer button{cursor:pointer;transition:background .12s,border-color .12s}
|
|
103
|
+
.sf-explorer button:hover{background:#f0f6fa;border-color:#8ba9c0}
|
|
104
|
+
.sf-explorer button[aria-pressed=true]{color:#123d63;background:#e6f1f9;border-color:#5689b0;font-weight:650}
|
|
105
|
+
.sf-explorer :focus-visible{outline:3px solid var(--sf-map-focus);outline-offset:2px}
|
|
106
|
+
.sf-explorer-header{padding:24px;border-top:4px solid var(--sf-map-accent);border-bottom:1px solid var(--sf-map-border)}
|
|
107
|
+
.sf-explorer-header h2{font-size:25px;line-height:1.25;font-weight:700;letter-spacing:-.7px}
|
|
108
|
+
.sf-explorer-header p{color:#586f80;margin-top:8px;max-width:58ch}
|
|
109
|
+
.sf-explorer-body{display:grid;grid-template-columns:280px minmax(0,1fr);grid-template-areas:"panel map" "detail map";grid-template-rows:auto 1fr}
|
|
110
|
+
.sf-explorer-panel{grid-area:panel;padding:18px;display:flex;flex-direction:column;gap:14px;border-right:1px solid #dce5eb;min-width:0}
|
|
111
|
+
.sf-explorer-panel label{display:flex;flex-direction:column;gap:6px;font-size:12px;font-weight:650}
|
|
112
|
+
.sf-explorer-panel input,.sf-explorer-panel select{width:100%;font-weight:400;font-size:14px}
|
|
113
|
+
.sf-explorer-panel input::placeholder{color:#6a7f8e}
|
|
114
|
+
.sf-explorer-results{display:flex;flex-direction:column;gap:5px;max-height:255px;overflow:auto;padding:3px;margin:-3px;overscroll-behavior:contain}
|
|
115
|
+
.sf-explorer-results button{text-align:left;flex-shrink:0}
|
|
116
|
+
.sf-explorer-count,.sf-explorer-note{font-size:12px;color:#586f80}
|
|
117
|
+
.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}
|
|
118
|
+
.sf-explorer-detail details{font-size:12px;margin-top:12px}
|
|
119
|
+
.sf-explorer-detail summary{cursor:pointer;color:#496578;min-height:32px}
|
|
120
|
+
.sf-explorer-detail details p{margin-top:8px}
|
|
121
|
+
.sf-explorer-detail h3{margin:0 0 7px;font-size:18px;line-height:1.3;letter-spacing:-.2px}
|
|
122
|
+
.sf-explorer-detail p{font-size:12px;margin-bottom:10px}
|
|
123
|
+
.sf-explorer-download{display:block;margin:8px 0;color:#12649c;text-underline-offset:3px}
|
|
124
|
+
.sf-explorer-map-column{grid-area:map;min-width:0;background:#e4f2f8}
|
|
125
|
+
.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)}
|
|
126
|
+
.sf-explorer-control-group{display:flex;align-items:center;flex-wrap:wrap;gap:6px;max-width:100%}
|
|
127
|
+
.sf-explorer-pan-controls{margin-left:auto}
|
|
128
|
+
.sf-explorer-toolbar button{min-width:44px;padding:6px 10px}
|
|
129
|
+
.sf-explorer-zoom{font-size:12px;min-width:42px;text-align:center;font-variant-numeric:tabular-nums;color:#496578}
|
|
130
|
+
.sf-explorer-canvas{position:relative;aspect-ratio:1;overflow:hidden;touch-action:pan-y pinch-zoom}
|
|
131
|
+
.sf-explorer-canvas>svg{display:block;width:100%;height:100%;max-width:none;cursor:grab;user-select:none}
|
|
132
|
+
.sf-explorer-canvas>svg:active{cursor:grabbing}
|
|
133
|
+
.sf-explorer-canvas path[data-neighborhood-id]{cursor:pointer}
|
|
134
|
+
.sf-explorer-legend{display:flex;flex-wrap:wrap;gap:8px 20px;padding:14px 16px 8px;background:#fff;border-top:1px solid #dce5eb;color:#496578;font-size:12px}
|
|
135
|
+
.sf-explorer-legend-item{display:inline-flex;align-items:center;gap:7px;white-space:nowrap}
|
|
136
|
+
.sf-explorer-legend-symbol{display:inline-block;flex:none;width:15px;height:11px;border-radius:2px}
|
|
137
|
+
.sf-explorer-legend-bart{width:10px;height:10px;margin:0 2px;border:2px solid #0073ae;border-radius:50%;background:#fff}
|
|
138
|
+
.sf-explorer-legend-park{background:#c6dfbd;border:1px solid #a8c69d}
|
|
139
|
+
.sf-explorer-legend-road{height:2px;border-radius:2px;background:#bcc3c5}
|
|
140
|
+
.sf-explorer-legend-highway{height:3px;border-radius:2px;background:#b9a18a}
|
|
141
|
+
.sf-explorer-hint{padding:6px 16px 10px;font-size:12px;color:#586f80;background:#fff}
|
|
142
|
+
.sf-explorer text{pointer-events:none}
|
|
143
|
+
.sf-explorer-status{padding:0 16px 14px;font-size:12px;background:#fff;color:#496578}
|
|
144
|
+
.sf-explorer[data-interface=map] .sf-explorer-body{display:block}
|
|
145
|
+
.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}
|
|
146
|
+
.sf-explorer-feature-controls{display:flex;flex-wrap:wrap;gap:8px;padding:10px;background:#fff}
|
|
147
|
+
.sf-explorer-feature-controls label{display:flex;flex:1;flex-direction:column;min-width:0}
|
|
148
|
+
.sf-explorer-feature-controls select{width:100%;min-width:0}
|
|
149
|
+
.sf-explorer svg [role=button]:focus-visible{outline:none;stroke:#102f72;stroke-width:3;fill-opacity:.3}
|
|
150
|
+
@media(prefers-reduced-motion:reduce){.sf-explorer button{transition:none}}
|
|
151
|
+
.sf-explorer button:disabled{opacity:.45;cursor:default}
|
|
152
|
+
@container sf-neighborhood-explorer (max-width:650px){
|
|
153
|
+
.sf-explorer-body{grid-template-columns:1fr;grid-template-areas:"panel" "map" "detail";grid-template-rows:auto auto auto}
|
|
154
|
+
.sf-explorer-header{padding:18px}
|
|
155
|
+
.sf-explorer-header h2{font-size:23px}
|
|
156
|
+
.sf-explorer-header p{font-size:13px}
|
|
157
|
+
.sf-explorer-panel{border-right:0;border-bottom:1px solid #dce5eb;padding:14px;gap:10px}
|
|
158
|
+
.sf-explorer-results{max-height:140px}
|
|
159
|
+
.sf-explorer-feature-controls{flex-direction:column}
|
|
160
|
+
.sf-explorer-detail{padding:14px;border-right:0}
|
|
161
|
+
.sf-explorer-toolbar{padding:8px;gap:6px}
|
|
162
|
+
.sf-explorer-control-group{gap:4px}
|
|
163
|
+
.sf-explorer-pan-controls{flex-basis:100%;margin-left:0}
|
|
164
|
+
.sf-explorer-toolbar button{padding:5px 8px}
|
|
165
|
+
.sf-explorer-legend{gap:8px 16px;padding:12px 14px 8px}
|
|
166
|
+
.sf-explorer-download{display:inline-block;margin:4px 14px 4px 0}
|
|
167
|
+
}`;
|
|
168
|
+
root.dataset.interface = chrome;
|
|
169
|
+
root.style.setProperty('--sf-map-ink', styleOptions.ink ?? '#18364f');
|
|
170
|
+
root.style.setProperty('--sf-map-surface', styleOptions.surface ?? '#fff');
|
|
171
|
+
root.style.setProperty('--sf-map-accent', styleOptions.accent ?? '#163d61');
|
|
172
|
+
root.style.setProperty('--sf-map-border', styleOptions.border ?? '#cedae3');
|
|
173
|
+
root.style.setProperty('--sf-map-focus', styleOptions.focus ?? '#1676b8');
|
|
174
|
+
root.style.setProperty('--sf-map-control-gap', styleOptions.controlGap ?? '6px');
|
|
175
|
+
root.style.setProperty('--sf-map-font', styleOptions.font ?? 'system-ui,sans-serif');
|
|
176
|
+
root.append(style);
|
|
177
|
+
const header = element('header', '', 'sf-explorer-header');
|
|
178
|
+
header.append(element('h2', strings.title ?? 'Explore San Francisco'), element('p', 'Explore the city, from familiar names to the places in between.'));
|
|
179
|
+
root.append(header);
|
|
180
|
+
const body = element('div', '', 'sf-explorer-body');
|
|
181
|
+
const panel = element('div', '', 'sf-explorer-panel');
|
|
182
|
+
const modeLabel = element('label', strings.mode ?? 'Map mode');
|
|
183
|
+
const modeSelect = element('select');
|
|
184
|
+
for (const [value, title] of [
|
|
185
|
+
['neighborhoods', 'Neighborhoods'],
|
|
186
|
+
['districts', 'Districts'],
|
|
187
|
+
['basemap', 'Basemap'],
|
|
188
|
+
]) {
|
|
189
|
+
if (value === 'districts' && !data.districts?.[year])
|
|
190
|
+
continue;
|
|
191
|
+
const option = element('option', title);
|
|
192
|
+
option.value = value;
|
|
193
|
+
modeSelect.append(option);
|
|
194
|
+
}
|
|
195
|
+
modeLabel.append(modeSelect);
|
|
196
|
+
const sourceLabel = element('label', strings.source ?? 'Neighborhood definitions');
|
|
197
|
+
const sourceSelect = element('select');
|
|
198
|
+
for (const value of sources) {
|
|
199
|
+
const title = sourceNames[value];
|
|
200
|
+
const option = element('option', title);
|
|
201
|
+
option.value = value;
|
|
202
|
+
sourceSelect.append(option);
|
|
203
|
+
}
|
|
204
|
+
sourceSelect.value = source;
|
|
205
|
+
sourceLabel.append(sourceSelect);
|
|
206
|
+
const searchLabel = element('label', strings.search ?? 'Find a neighborhood');
|
|
207
|
+
const search = element('input');
|
|
208
|
+
search.type = 'search';
|
|
209
|
+
search.placeholder = 'Try Mission, Outer Mission, or NoPa';
|
|
210
|
+
search.autocomplete = 'off';
|
|
211
|
+
searchLabel.append(search);
|
|
212
|
+
const count = element('p', '', 'sf-explorer-count');
|
|
213
|
+
count.setAttribute('aria-live', 'polite');
|
|
214
|
+
const results = element('div', '', 'sf-explorer-results');
|
|
215
|
+
results.setAttribute('aria-label', 'Matching neighborhoods');
|
|
216
|
+
results.setAttribute('role', 'group');
|
|
217
|
+
const detail = element('div', '', 'sf-explorer-detail');
|
|
218
|
+
panel.append(modeLabel, sourceLabel, searchLabel, count, results);
|
|
219
|
+
const column = element('div', '', 'sf-explorer-map-column');
|
|
220
|
+
const toolbar = element('div', '', 'sf-explorer-toolbar');
|
|
221
|
+
toolbar.setAttribute('aria-label', 'Map controls');
|
|
222
|
+
const canvas = element('div', '', 'sf-explorer-canvas');
|
|
223
|
+
const hint = element('p', strings.gestureHelp ??
|
|
224
|
+
'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');
|
|
225
|
+
const status = element('p', '', 'sf-explorer-status');
|
|
226
|
+
status.setAttribute('aria-live', 'polite');
|
|
227
|
+
const legend = element('div', '', 'sf-explorer-legend');
|
|
228
|
+
legend.setAttribute('aria-label', 'Map legend');
|
|
229
|
+
for (const [kind, label] of [
|
|
230
|
+
['bart', 'BART station'],
|
|
231
|
+
['park', 'Park'],
|
|
232
|
+
['highway', 'Highway'],
|
|
233
|
+
['road', 'Roads'],
|
|
234
|
+
]) {
|
|
235
|
+
const entry = element('span', '', 'sf-explorer-legend-item');
|
|
236
|
+
const symbol = element('span', '', `sf-explorer-legend-symbol sf-explorer-legend-${kind}`);
|
|
237
|
+
symbol.setAttribute('aria-hidden', 'true');
|
|
238
|
+
entry.append(symbol, document.createTextNode(label));
|
|
239
|
+
legend.append(entry);
|
|
240
|
+
}
|
|
241
|
+
const featureControls = element('div', '', 'sf-explorer-feature-controls');
|
|
242
|
+
const neighborhoodLabel = element('label', strings.chooseNeighborhood ?? 'Select neighborhood');
|
|
243
|
+
const neighborhoodSelect = element('select');
|
|
244
|
+
neighborhoodLabel.append(neighborhoodSelect);
|
|
245
|
+
const markerLabel = element('label', strings.chooseMarker ?? 'Choose marker');
|
|
246
|
+
const markerSelect = element('select');
|
|
247
|
+
markerLabel.append(markerSelect);
|
|
248
|
+
featureControls.append(neighborhoodLabel, markerLabel);
|
|
249
|
+
const attribution = element('p', '', 'sf-explorer-hint');
|
|
250
|
+
column.append(toolbar, featureControls, canvas, legend, hint, status, attribution);
|
|
251
|
+
body.append(panel, column, detail);
|
|
252
|
+
root.append(body);
|
|
253
|
+
const map = createSFMapWithData({
|
|
254
|
+
year,
|
|
255
|
+
theme,
|
|
256
|
+
districtLabels: false,
|
|
257
|
+
districtLines: true,
|
|
258
|
+
districtFills: true,
|
|
259
|
+
highways: enabled('highways'),
|
|
260
|
+
keyRoads: enabled('keyRoads'),
|
|
261
|
+
roadLabels: false,
|
|
262
|
+
landmarks: enabled('landmarks'),
|
|
263
|
+
bartStations: false,
|
|
264
|
+
}, data.map);
|
|
265
|
+
canvas.innerHTML = map.svg;
|
|
266
|
+
const renderedSvg = canvas.querySelector('svg');
|
|
267
|
+
if (!renderedSvg)
|
|
268
|
+
throw new Error('The renderer did not produce an SVG.');
|
|
269
|
+
const svg = renderedSvg;
|
|
270
|
+
svg.setAttribute('role', 'group');
|
|
271
|
+
svg.setAttribute('tabindex', '0');
|
|
272
|
+
svg.setAttribute('aria-label', 'Neighborhood map. Use search results and map controls to explore.');
|
|
273
|
+
svg.removeAttribute('aria-labelledby');
|
|
274
|
+
svg.querySelector('[data-layer="landmark-labels"]')?.remove();
|
|
275
|
+
svg.querySelector('[data-layer="key-road-labels"]')?.remove();
|
|
276
|
+
const geography = svg.querySelector('[data-layer="geography"]');
|
|
277
|
+
if (!geography)
|
|
278
|
+
throw new Error('The renderer did not produce a geography layer.');
|
|
279
|
+
const districtLayers = Array.from(svg.querySelectorAll('[data-layer="district-fills"], [data-layer="district-lines"]'));
|
|
280
|
+
for (const path of svg.querySelectorAll('[data-layer="district-fills"] path[data-district]')) {
|
|
281
|
+
const color = districtColors[Number(path.dataset.district) - 1];
|
|
282
|
+
if (color)
|
|
283
|
+
path.setAttribute('fill', color);
|
|
284
|
+
}
|
|
285
|
+
const districtItems = (data.districts?.[year]?.features ?? []).flatMap((feature) => feature.properties.labelPoints.map((point) => ({
|
|
286
|
+
point: map.project(point),
|
|
287
|
+
name: String(feature.properties.district),
|
|
288
|
+
kind: 'district',
|
|
289
|
+
})));
|
|
290
|
+
const markerLayer = svgElement('g', { 'data-layer': 'interactive-markers' });
|
|
291
|
+
const overlayLayer = svgElement('g', { 'data-layer': 'user-overlays' });
|
|
292
|
+
const areas = svgElement('g', { 'data-layer': 'explorer-neighborhoods' });
|
|
293
|
+
const stations = svgElement('g', { 'data-layer': 'explorer-bart' });
|
|
294
|
+
const labelLayer = svgElement('g', {
|
|
295
|
+
'data-layer': 'explorer-labels',
|
|
296
|
+
'aria-hidden': 'true',
|
|
297
|
+
'font-family': 'system-ui,sans-serif',
|
|
298
|
+
});
|
|
299
|
+
geography.append(areas, stations, labelLayer, overlayLayer, markerLayer);
|
|
300
|
+
let view = [0, 0, 800];
|
|
301
|
+
let selected;
|
|
302
|
+
let items = [];
|
|
303
|
+
let destroyed = false;
|
|
304
|
+
let frame = 0;
|
|
305
|
+
const downloads = new Set();
|
|
306
|
+
const stationItems = (enabled('bartStations') ? (data.map.bartStations ?? []) : []).map((feature) => {
|
|
307
|
+
return {
|
|
308
|
+
point: map.project(feature.coordinates),
|
|
309
|
+
name: feature.name,
|
|
310
|
+
kind: 'bart',
|
|
311
|
+
node: svgElement('circle'),
|
|
312
|
+
};
|
|
313
|
+
});
|
|
314
|
+
for (const station of stationItems) {
|
|
315
|
+
station.node = svgElement('circle', {
|
|
316
|
+
cx: station.point[0],
|
|
317
|
+
cy: station.point[1],
|
|
318
|
+
fill: '#fff',
|
|
319
|
+
stroke: '#0073ae',
|
|
320
|
+
'stroke-width': 2,
|
|
321
|
+
'vector-effect': 'non-scaling-stroke',
|
|
322
|
+
});
|
|
323
|
+
const title = svgElement('title');
|
|
324
|
+
title.textContent = `${station.name} BART station`;
|
|
325
|
+
station.node.append(title);
|
|
326
|
+
stations.append(station.node);
|
|
327
|
+
}
|
|
328
|
+
const roadItems = (enabled('roadLabels') ? (data.map.keyRoads ?? []) : []).map((feature) => ({
|
|
329
|
+
point: map.project(feature.label),
|
|
330
|
+
name: feature.name,
|
|
331
|
+
level: feature.level ?? 'primary',
|
|
332
|
+
kind: 'road',
|
|
333
|
+
}));
|
|
334
|
+
const parkItems = (enabled('landmarks') ? (data.map.landmarks ?? []) : []).map((feature) => ({
|
|
335
|
+
point: map.project(feature.label),
|
|
336
|
+
name: feature.name,
|
|
337
|
+
kind: 'park',
|
|
338
|
+
}));
|
|
339
|
+
for (const [kind, option] of [
|
|
340
|
+
['bart', 'bartStations'],
|
|
341
|
+
['park', 'landmarks'],
|
|
342
|
+
['highway', 'highways'],
|
|
343
|
+
['road', 'keyRoads'],
|
|
344
|
+
]) {
|
|
345
|
+
if (!enabled(option))
|
|
346
|
+
legend.querySelector(`.sf-explorer-legend-${kind}`)?.parentElement?.remove();
|
|
347
|
+
}
|
|
348
|
+
legend.hidden = !legend.children.length;
|
|
349
|
+
let markerItems = [];
|
|
350
|
+
let selectedMarker = null;
|
|
351
|
+
function setOverlays(overlays) {
|
|
352
|
+
const ids = new Set();
|
|
353
|
+
const next = overlays.map((overlay) => {
|
|
354
|
+
if (!/^[A-Za-z0-9_-]+$/.test(overlay.id) || ids.has(overlay.id))
|
|
355
|
+
throw new RangeError('Overlays require unique IDs containing only letters, numbers, underscores, or hyphens.');
|
|
356
|
+
if (overlay.strokeWidth !== undefined &&
|
|
357
|
+
(!Number.isFinite(overlay.strokeWidth) || overlay.strokeWidth < 0))
|
|
358
|
+
throw new RangeError('Overlay strokeWidth must be a finite nonnegative number.');
|
|
359
|
+
if (overlay.fillOpacity !== undefined &&
|
|
360
|
+
(!Number.isFinite(overlay.fillOpacity) ||
|
|
361
|
+
overlay.fillOpacity < 0 ||
|
|
362
|
+
overlay.fillOpacity > 1))
|
|
363
|
+
throw new RangeError('Overlay fillOpacity must be a finite number from 0 to 1.');
|
|
364
|
+
ids.add(overlay.id);
|
|
365
|
+
const node = svgElement('path', {
|
|
366
|
+
'data-overlay-id': overlay.id,
|
|
367
|
+
d: geometryPath(overlay.geometry, map.project),
|
|
368
|
+
fill: overlay.fill ?? 'none',
|
|
369
|
+
'fill-opacity': overlay.fillOpacity ?? 1,
|
|
370
|
+
stroke: overlay.stroke ?? '#bd8b73',
|
|
371
|
+
'stroke-width': overlay.strokeWidth ?? 2,
|
|
372
|
+
'vector-effect': 'non-scaling-stroke',
|
|
373
|
+
display: overlay.visible === false ? 'none' : 'inline',
|
|
374
|
+
'pointer-events': 'none',
|
|
375
|
+
...(overlay.label ? { 'aria-label': overlay.label } : {}),
|
|
376
|
+
});
|
|
377
|
+
if (overlay.label) {
|
|
378
|
+
node.setAttribute('role', 'img');
|
|
379
|
+
const title = svgElement('title');
|
|
380
|
+
title.textContent = overlay.label;
|
|
381
|
+
node.append(title);
|
|
382
|
+
}
|
|
383
|
+
if (onOverlayActivate) {
|
|
384
|
+
node.setAttribute('role', 'button');
|
|
385
|
+
node.setAttribute('tabindex', overlay.visible === false ? '-1' : '0');
|
|
386
|
+
node.setAttribute('pointer-events', overlay.fill && overlay.fill !== 'none' ? 'visiblePainted' : 'stroke');
|
|
387
|
+
node.setAttribute('aria-label', overlay.label ?? overlay.id);
|
|
388
|
+
const activate = () => {
|
|
389
|
+
onOverlayActivate(overlay);
|
|
390
|
+
root.dispatchEvent(new CustomEvent('overlayactivate', { bubbles: true, detail: { overlay } }));
|
|
391
|
+
};
|
|
392
|
+
node.addEventListener('click', activate, { signal: controller.signal });
|
|
393
|
+
node.addEventListener('keydown', (event) => {
|
|
394
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
395
|
+
event.preventDefault();
|
|
396
|
+
activate();
|
|
397
|
+
}
|
|
398
|
+
}, { signal: controller.signal });
|
|
399
|
+
}
|
|
400
|
+
return node;
|
|
401
|
+
});
|
|
402
|
+
overlayLayer.replaceChildren(...next);
|
|
403
|
+
}
|
|
404
|
+
const zoomControls = element('div', '', 'sf-explorer-control-group');
|
|
405
|
+
const panControls = element('div', '', 'sf-explorer-control-group sf-explorer-pan-controls');
|
|
406
|
+
toolbar.append(zoomControls, panControls);
|
|
407
|
+
function button(text, label, action, group = zoomControls) {
|
|
408
|
+
const node = element('button', text);
|
|
409
|
+
node.type = 'button';
|
|
410
|
+
node.setAttribute('aria-label', label);
|
|
411
|
+
listen(node, 'click', action);
|
|
412
|
+
group.append(node);
|
|
413
|
+
return node;
|
|
414
|
+
}
|
|
415
|
+
const zoomOut = button('−', 'Zoom out', () => zoomBy(1 / 1.5));
|
|
416
|
+
const zoomText = element('span', '100%', 'sf-explorer-zoom');
|
|
417
|
+
zoomControls.append(zoomText);
|
|
418
|
+
const zoomIn = button('+', 'Zoom in', () => zoomBy(1.5));
|
|
419
|
+
button(strings.reset ?? 'Reset', strings.reset ?? 'Reset map to city view', resetView);
|
|
420
|
+
const labelsButton = button('Labels', 'Show map labels', () => setLabels(!labels));
|
|
421
|
+
for (const [symbol, label, dx, dy] of [
|
|
422
|
+
['←', 'Pan west', -1, 0],
|
|
423
|
+
['↑', 'Pan north', 0, -1],
|
|
424
|
+
['↓', 'Pan south', 0, 1],
|
|
425
|
+
['→', 'Pan east', 1, 0],
|
|
426
|
+
])
|
|
427
|
+
button(symbol, label, () => setView([view[0] + (dx * view[2]) / 4, view[1] + (dy * view[2]) / 4, view[2]]), panControls);
|
|
428
|
+
function panBy(x, y) {
|
|
429
|
+
if (![x, y].every(Number.isFinite))
|
|
430
|
+
throw new RangeError('Pan offsets must be finite screen pixels.');
|
|
431
|
+
const width = canvas.getBoundingClientRect().width;
|
|
432
|
+
if (width)
|
|
433
|
+
setView([view[0] + (x * view[2]) / width, view[1] + (y * view[2]) / width, view[2]]);
|
|
434
|
+
}
|
|
435
|
+
function getViewport() {
|
|
436
|
+
return [...view];
|
|
437
|
+
}
|
|
438
|
+
function fitGeometry(geometry, padding = fitPadding) {
|
|
439
|
+
const width = canvas.getBoundingClientRect().width;
|
|
440
|
+
if (!width)
|
|
441
|
+
throw new Error('Mount the map in a visible container before fitting geometry.');
|
|
442
|
+
setView(fitViewport(projectedBounds(geometry, map.project), width, padding));
|
|
443
|
+
}
|
|
444
|
+
function setView(next) {
|
|
445
|
+
if (destroyed)
|
|
446
|
+
return;
|
|
447
|
+
const previous = view;
|
|
448
|
+
view = validateViewport(next);
|
|
449
|
+
svg.setAttribute('viewBox', `${view[0]} ${view[1]} ${view[2]} ${view[2]}`);
|
|
450
|
+
const zoom = 800 / view[2];
|
|
451
|
+
zoomText.textContent = `${Math.round(zoom * 100)}%`;
|
|
452
|
+
root.dataset.zoom = String(zoom);
|
|
453
|
+
zoomIn.disabled = zoom >= 12;
|
|
454
|
+
zoomOut.disabled = zoom <= 1;
|
|
455
|
+
if (view.some((value, i) => value !== previous[i]))
|
|
456
|
+
root.dispatchEvent(new CustomEvent('viewportchange', { bubbles: true, detail: { viewport: getViewport() } }));
|
|
457
|
+
scheduleLabels();
|
|
458
|
+
}
|
|
459
|
+
function zoomBy(factor) {
|
|
460
|
+
if (!Number.isFinite(factor) || factor <= 0)
|
|
461
|
+
throw new RangeError('Zoom factor must be positive and finite.');
|
|
462
|
+
const size = Math.max(800 / 12, Math.min(800, view[2] / factor));
|
|
463
|
+
setView([view[0] + (view[2] - size) / 2, view[1] + (view[2] - size) / 2, size]);
|
|
464
|
+
}
|
|
465
|
+
function resetView() {
|
|
466
|
+
setView([0, 0, 800]);
|
|
467
|
+
}
|
|
468
|
+
function scheduleLabels() {
|
|
469
|
+
if (destroyed)
|
|
470
|
+
return;
|
|
471
|
+
cancelAnimationFrame(frame);
|
|
472
|
+
frame = requestAnimationFrame(drawLabels);
|
|
473
|
+
}
|
|
474
|
+
function drawLabels() {
|
|
475
|
+
if (destroyed)
|
|
476
|
+
return;
|
|
477
|
+
const width = canvas.getBoundingClientRect().width;
|
|
478
|
+
if (!width)
|
|
479
|
+
return;
|
|
480
|
+
labelLayer.replaceChildren();
|
|
481
|
+
const unit = view[2] / width, zoom = 800 / view[2];
|
|
482
|
+
for (const road of svg.querySelectorAll('[data-key-road-level="secondary"]'))
|
|
483
|
+
road.style.display = zoom >= 1.8 ? '' : 'none';
|
|
484
|
+
for (const station of stationItems)
|
|
485
|
+
station.node.setAttribute('r', String(4.5 * unit));
|
|
486
|
+
for (const item of markerItems) {
|
|
487
|
+
item.dot.setAttribute('r', String((item.marker.id === selectedMarker ? markerRadius + 2 : markerRadius) * unit));
|
|
488
|
+
item.hit.setAttribute('r', String((markerHitSize / 2) * unit));
|
|
489
|
+
}
|
|
490
|
+
if (!labels) {
|
|
491
|
+
root.dataset.visibleLabels = '0';
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const selectedItem = items.find((item) => item.feature === selected);
|
|
495
|
+
const candidates = [];
|
|
496
|
+
const marker = markerItems.find((item) => item.marker.id === selectedMarker);
|
|
497
|
+
if (marker)
|
|
498
|
+
candidates.push({
|
|
499
|
+
point: marker.point,
|
|
500
|
+
name: marker.marker.label ?? marker.marker.id,
|
|
501
|
+
kind: 'selected-marker',
|
|
502
|
+
});
|
|
503
|
+
if (selectedItem?.point && enabled('neighborhoodLabels'))
|
|
504
|
+
candidates.push({
|
|
505
|
+
point: selectedItem.point,
|
|
506
|
+
name: selectedItem.feature.properties.canonicalName,
|
|
507
|
+
kind: 'selected',
|
|
508
|
+
});
|
|
509
|
+
if (enabled('districtLabels'))
|
|
510
|
+
candidates.push(...districtItems);
|
|
511
|
+
if (zoom >= 1.8)
|
|
512
|
+
candidates.push(...stationItems);
|
|
513
|
+
candidates.push(...parkItems.filter((park) => zoom >= 1.8 ||
|
|
514
|
+
park.name === 'Golden Gate Park' ||
|
|
515
|
+
(width >= 550 && park.name === 'Presidio')));
|
|
516
|
+
candidates.push(...roadItems.filter((road) => road.level === 'primary' || zoom >= 1.8));
|
|
517
|
+
if (enabled('neighborhoodLabels'))
|
|
518
|
+
candidates.push(...items
|
|
519
|
+
.filter((item) => item.feature !== selected && item.point !== undefined)
|
|
520
|
+
.sort((a, b) => b.area - a.area || a.feature.id.localeCompare(b.feature.id))
|
|
521
|
+
.map((item) => ({
|
|
522
|
+
point: item.point,
|
|
523
|
+
name: item.feature.properties.canonicalName,
|
|
524
|
+
kind: 'neighborhood',
|
|
525
|
+
})));
|
|
526
|
+
const measured = candidates.map((item) => {
|
|
527
|
+
const fontSize = item.kind === 'road'
|
|
528
|
+
? Math.min(9, maxLabelSize)
|
|
529
|
+
: Math.max(minLabelSize, Math.min(maxLabelSize, 12));
|
|
530
|
+
const node = svgElement('text', {
|
|
531
|
+
'font-size': fontSize * unit,
|
|
532
|
+
'font-weight': item.kind.startsWith('selected') || item.kind === 'district' ? 700 : 550,
|
|
533
|
+
fill: item.kind === 'park' ? '#426641' : item.kind === 'road' ? '#77736b' : '#163d61',
|
|
534
|
+
stroke: '#ffffff',
|
|
535
|
+
'stroke-width': 3 * unit,
|
|
536
|
+
'stroke-linejoin': 'round',
|
|
537
|
+
'paint-order': 'stroke',
|
|
538
|
+
'data-label-kind': item.kind,
|
|
539
|
+
});
|
|
540
|
+
node.textContent = item.name;
|
|
541
|
+
labelLayer.append(node);
|
|
542
|
+
return {
|
|
543
|
+
...item,
|
|
544
|
+
node,
|
|
545
|
+
x: (item.point[0] - view[0]) / unit,
|
|
546
|
+
y: (item.point[1] - view[1]) / unit,
|
|
547
|
+
textWidth: node.getComputedTextLength() / unit,
|
|
548
|
+
fontSize,
|
|
549
|
+
textHeight: fontSize * 1.25,
|
|
550
|
+
offset: item.kind === 'selected-marker' ? markerRadius + 7 : item.kind === 'bart' ? 11 : 0,
|
|
551
|
+
};
|
|
552
|
+
});
|
|
553
|
+
const stationBounds = stationItems.map(({ point }) => {
|
|
554
|
+
const x = (point[0] - view[0]) / unit;
|
|
555
|
+
const y = (point[1] - view[1]) / unit;
|
|
556
|
+
return [x - 6, y - 6, x + 6, y + 6];
|
|
557
|
+
});
|
|
558
|
+
const markerBounds = markerItems.map(({ point }) => {
|
|
559
|
+
const x = (point[0] - view[0]) / unit, y = (point[1] - view[1]) / unit;
|
|
560
|
+
return [
|
|
561
|
+
x - markerRadius - 3,
|
|
562
|
+
y - markerRadius - 3,
|
|
563
|
+
x + markerRadius + 3,
|
|
564
|
+
y + markerRadius + 3,
|
|
565
|
+
];
|
|
566
|
+
});
|
|
567
|
+
const placed = layoutLabels(measured, width, width, [...stationBounds, ...markerBounds]);
|
|
568
|
+
for (const item of measured)
|
|
569
|
+
item.node.remove();
|
|
570
|
+
for (const item of placed) {
|
|
571
|
+
item.node.setAttribute('x', String(view[0] + item.left * unit));
|
|
572
|
+
item.node.setAttribute('y', String(view[1] + (item.top + item.fontSize) * unit));
|
|
573
|
+
labelLayer.append(item.node);
|
|
574
|
+
}
|
|
575
|
+
root.dataset.visibleLabels = String(placed.length);
|
|
576
|
+
}
|
|
577
|
+
function releaseDownloads() {
|
|
578
|
+
for (const url of downloads)
|
|
579
|
+
URL.revokeObjectURL(url);
|
|
580
|
+
downloads.clear();
|
|
581
|
+
}
|
|
582
|
+
function downloadLink(text, value, filename) {
|
|
583
|
+
const link = element('a', text, 'sf-explorer-download');
|
|
584
|
+
const url = URL.createObjectURL(new Blob([`${JSON.stringify(value, null, 2)}\n`], { type: 'application/geo+json' }));
|
|
585
|
+
downloads.add(url);
|
|
586
|
+
link.href = url;
|
|
587
|
+
link.download = filename;
|
|
588
|
+
return link;
|
|
589
|
+
}
|
|
590
|
+
function updateDetail() {
|
|
591
|
+
releaseDownloads();
|
|
592
|
+
if (chrome === 'map')
|
|
593
|
+
return;
|
|
594
|
+
detail.replaceChildren();
|
|
595
|
+
if (mode === 'districts') {
|
|
596
|
+
const collection = data.districts?.[year];
|
|
597
|
+
if (!collection) {
|
|
598
|
+
detail.append(element('h3', `${year} supervisorial districts`));
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
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));
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (mode === 'basemap') {
|
|
605
|
+
detail.append(element('h3', 'San Francisco basemap'));
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const collection = collections[source];
|
|
609
|
+
if (!collection)
|
|
610
|
+
return;
|
|
611
|
+
detail.append(element('h3', selected ? selected.properties.canonicalName : 'Explore all neighborhoods'));
|
|
612
|
+
const description = selected
|
|
613
|
+
? `Source name: ${selected.properties.sourceName}${selected.properties.aliases.length ? `. Also known as: ${selected.properties.aliases.join(', ')}` : ''}.`
|
|
614
|
+
: 'Choose a name or click an area to highlight its full boundary.';
|
|
615
|
+
const sourceSummary = {
|
|
616
|
+
realtor: 'SFAR realtor boundaries · August 2010 · 92 distinct areas.',
|
|
617
|
+
'sf-find': 'SF Find neighborhoods · 2006 · 117 approximate areas.',
|
|
618
|
+
analysis: 'City analysis neighborhoods · 41 census-tract-based areas.',
|
|
619
|
+
};
|
|
620
|
+
detail.append(element('p', description), element('p', sourceSummary[source]));
|
|
621
|
+
if (selected)
|
|
622
|
+
detail.append(downloadLink('Download neighborhood GeoJSON', { ...collection, selection: selected.id, features: [selected] }, `${source}-${selected.id}.geojson`));
|
|
623
|
+
detail.append(downloadLink(`Download all ${collection.features.length} areas`, collection, `neighborhoods-${source}.geojson`));
|
|
624
|
+
const provenance = element('details');
|
|
625
|
+
provenance.append(element('summary', 'About these boundaries'), element('p', collection.definition.description));
|
|
626
|
+
detail.append(provenance);
|
|
627
|
+
}
|
|
628
|
+
function updateResults() {
|
|
629
|
+
if (chrome === 'map')
|
|
630
|
+
return;
|
|
631
|
+
const activeElement = document.activeElement;
|
|
632
|
+
const focusedId = activeElement instanceof HTMLElement && results.contains(activeElement)
|
|
633
|
+
? activeElement.dataset.neighborhoodResult
|
|
634
|
+
: undefined;
|
|
635
|
+
const matches = searchNeighborhoods(search.value, source).sort((a, b) => a.canonicalName.localeCompare(b.canonicalName));
|
|
636
|
+
count.textContent = `${matches.length} ${matches.length === 1 ? 'neighborhood' : 'neighborhoods'}${search.value ? ' found' : ' in this collection'}`;
|
|
637
|
+
results.replaceChildren();
|
|
638
|
+
for (const match of matches) {
|
|
639
|
+
const node = element('button', match.canonicalName);
|
|
640
|
+
node.type = 'button';
|
|
641
|
+
node.dataset.neighborhoodResult = match.id;
|
|
642
|
+
node.setAttribute('aria-pressed', String(selected?.id === match.id));
|
|
643
|
+
results.append(node);
|
|
644
|
+
if (focusedId === match.id)
|
|
645
|
+
node.focus({ preventScroll: true });
|
|
646
|
+
}
|
|
647
|
+
if (!matches.length)
|
|
648
|
+
results.append(element('p', strings.emptyResults ?? 'No matches. Try another name or definition source.', 'sf-explorer-note'));
|
|
649
|
+
}
|
|
650
|
+
function getSelection() {
|
|
651
|
+
return selected
|
|
652
|
+
? { id: selected.id, name: selected.properties.canonicalName, source, feature: selected }
|
|
653
|
+
: null;
|
|
654
|
+
}
|
|
655
|
+
function selectNeighborhood(name, options = {}) {
|
|
656
|
+
if (destroyed)
|
|
657
|
+
return false;
|
|
658
|
+
const feature = name === null ? undefined : getNeighborhood(name, source);
|
|
659
|
+
if (name !== null && !feature)
|
|
660
|
+
return false;
|
|
661
|
+
if (feature && !enabled('neighborhoodLines') && !enabled('neighborhoodLabels'))
|
|
662
|
+
setMode('neighborhoods');
|
|
663
|
+
const changed = selected !== feature;
|
|
664
|
+
selected = feature;
|
|
665
|
+
for (const item of items) {
|
|
666
|
+
const active = item.feature === selected;
|
|
667
|
+
item.node.setAttribute('fill', active ? '#408dbe' : 'transparent');
|
|
668
|
+
item.node.setAttribute('fill-opacity', active ? '.16' : '1');
|
|
669
|
+
item.node.setAttribute('stroke', active ? '#176ba2' : '#9caebc');
|
|
670
|
+
item.node.setAttribute('stroke-width', active ? '2.2' : enabled('neighborhoodLines') ? '.55' : '0');
|
|
671
|
+
item.node.setAttribute('aria-pressed', String(active));
|
|
672
|
+
if (active)
|
|
673
|
+
areas.append(item.node);
|
|
674
|
+
}
|
|
675
|
+
const selectedItem = items.find((item) => item.feature === selected);
|
|
676
|
+
if (selectedItem && options.fit !== false) {
|
|
677
|
+
const width = canvas.getBoundingClientRect().width;
|
|
678
|
+
setView(width
|
|
679
|
+
? fitViewport(selectedItem.bounds, width, fitPadding)
|
|
680
|
+
: fitBounds(selectedItem.bounds));
|
|
681
|
+
}
|
|
682
|
+
if (feature)
|
|
683
|
+
root.dataset.selectedNeighborhood = feature.id;
|
|
684
|
+
else
|
|
685
|
+
delete root.dataset.selectedNeighborhood;
|
|
686
|
+
neighborhoodSelect.value = feature?.id ?? '';
|
|
687
|
+
scheduleLabels();
|
|
688
|
+
updateResults();
|
|
689
|
+
updateDetail();
|
|
690
|
+
status.textContent = feature
|
|
691
|
+
? `${feature.properties.canonicalName} selected. ${formatSourceLabel(source)}.`
|
|
692
|
+
: 'Neighborhood selection cleared.';
|
|
693
|
+
if (changed)
|
|
694
|
+
root.dispatchEvent(new CustomEvent('neighborhoodchange', {
|
|
695
|
+
bubbles: true,
|
|
696
|
+
detail: {
|
|
697
|
+
feature: feature ?? null,
|
|
698
|
+
source,
|
|
699
|
+
id: feature?.id ?? null,
|
|
700
|
+
name: feature?.properties.canonicalName ?? null,
|
|
701
|
+
},
|
|
702
|
+
}));
|
|
703
|
+
return true;
|
|
704
|
+
}
|
|
705
|
+
function setSource(next) {
|
|
706
|
+
if (destroyed)
|
|
707
|
+
return;
|
|
708
|
+
if (!Object.hasOwn(collections, next))
|
|
709
|
+
throw new RangeError(`Unknown neighborhood source: ${next}`);
|
|
710
|
+
const hadSelection = !!selected;
|
|
711
|
+
source = next;
|
|
712
|
+
sourceSelect.value = source;
|
|
713
|
+
selected = undefined;
|
|
714
|
+
delete root.dataset.selectedNeighborhood;
|
|
715
|
+
root.dataset.source = source;
|
|
716
|
+
areas.replaceChildren();
|
|
717
|
+
neighborhoodSelect.replaceChildren(element('option', 'No neighborhood selected'));
|
|
718
|
+
neighborhoodSelect.options[0].value = '';
|
|
719
|
+
items = (collections[source]?.features ?? []).map((feature) => {
|
|
720
|
+
const bounds = projectedBounds(feature.geometry, map.project);
|
|
721
|
+
const node = svgElement('path', {
|
|
722
|
+
d: geometryPath(feature.geometry, map.project),
|
|
723
|
+
'data-neighborhood-id': feature.id,
|
|
724
|
+
fill: 'transparent',
|
|
725
|
+
'fill-rule': 'evenodd',
|
|
726
|
+
stroke: '#9caebc',
|
|
727
|
+
'stroke-width': '.55',
|
|
728
|
+
'vector-effect': 'non-scaling-stroke',
|
|
729
|
+
role: 'button',
|
|
730
|
+
tabindex: -1,
|
|
731
|
+
'aria-label': feature.properties.canonicalName,
|
|
732
|
+
'aria-pressed': 'false',
|
|
733
|
+
});
|
|
734
|
+
const option = element('option', feature.properties.canonicalName);
|
|
735
|
+
option.value = feature.id;
|
|
736
|
+
neighborhoodSelect.append(option);
|
|
737
|
+
const title = svgElement('title');
|
|
738
|
+
title.textContent = feature.properties.canonicalName;
|
|
739
|
+
node.append(title);
|
|
740
|
+
areas.append(node);
|
|
741
|
+
return {
|
|
742
|
+
feature,
|
|
743
|
+
node,
|
|
744
|
+
bounds,
|
|
745
|
+
point: interiorAnchor(feature.geometry, map.project),
|
|
746
|
+
area: (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]),
|
|
747
|
+
};
|
|
748
|
+
});
|
|
749
|
+
if (items[0])
|
|
750
|
+
items[0].node.setAttribute('tabindex', '0');
|
|
751
|
+
updateResults();
|
|
752
|
+
updateDetail();
|
|
753
|
+
updateComposition();
|
|
754
|
+
resetView();
|
|
755
|
+
if (hadSelection)
|
|
756
|
+
root.dispatchEvent(new CustomEvent('neighborhoodchange', {
|
|
757
|
+
bubbles: true,
|
|
758
|
+
detail: { feature: null, source, id: null, name: null },
|
|
759
|
+
}));
|
|
760
|
+
status.textContent =
|
|
761
|
+
mode === 'basemap'
|
|
762
|
+
? 'San Francisco basemap.'
|
|
763
|
+
: mode === 'districts'
|
|
764
|
+
? `${year} supervisorial districts. Numbers identify each district.`
|
|
765
|
+
: `${formatSourceLabel(source)}. Select a neighborhood to begin.`;
|
|
766
|
+
}
|
|
767
|
+
function updateComposition() {
|
|
768
|
+
const neighborhoodsVisible = enabled('neighborhoodLines') || (labels && enabled('neighborhoodLabels'));
|
|
769
|
+
areas.style.display = neighborhoodsVisible ? '' : 'none';
|
|
770
|
+
areas.style.pointerEvents = selectableNeighborhoods ? '' : 'none';
|
|
771
|
+
neighborhoodLabel.hidden =
|
|
772
|
+
!neighborhoodsVisible || !selectableNeighborhoods || chrome === 'explorer';
|
|
773
|
+
for (const item of items) {
|
|
774
|
+
item.node.setAttribute('stroke-width', item.feature === selected ? '2.2' : enabled('neighborhoodLines') ? '.55' : '0');
|
|
775
|
+
item.node.setAttribute('tabindex', selectableNeighborhoods && item === items[0] ? '0' : '-1');
|
|
776
|
+
item.node.setAttribute('role', selectableNeighborhoods ? 'button' : 'img');
|
|
777
|
+
if (!selectableNeighborhoods)
|
|
778
|
+
item.node.removeAttribute('aria-pressed');
|
|
779
|
+
}
|
|
780
|
+
for (const layer of districtLayers) {
|
|
781
|
+
const key = layer.dataset.layer === 'district-fills' ? 'districtFills' : 'districtLines';
|
|
782
|
+
layer.style.display = enabled(key) ? '' : 'none';
|
|
783
|
+
}
|
|
784
|
+
const districtsVisible = enabled('districtFills') || enabled('districtLines') || (labels && enabled('districtLabels'));
|
|
785
|
+
const descriptions = ['San Francisco map.'];
|
|
786
|
+
if (districtsVisible)
|
|
787
|
+
descriptions.push(`${year} supervisorial districts, DataSF.`);
|
|
788
|
+
if (neighborhoodsVisible) {
|
|
789
|
+
const collection = collections[source];
|
|
790
|
+
if (collection)
|
|
791
|
+
descriptions.push(`${collection.title}. ${collection.definition.description}`);
|
|
792
|
+
}
|
|
793
|
+
if (enabled('landmarks'))
|
|
794
|
+
descriptions.push('Park property boundaries from DataSF.');
|
|
795
|
+
if (enabled('highways') || enabled('keyRoads'))
|
|
796
|
+
descriptions.push('Road geometry from DataSF.');
|
|
797
|
+
if (enabled('bartStations'))
|
|
798
|
+
descriptions.push('BART station locations from BART.');
|
|
799
|
+
descriptions.push('Display coastline from DataSF. Source downloads: September 2026.');
|
|
800
|
+
const description = descriptions.join(' ');
|
|
801
|
+
const desc = svg.querySelector('desc');
|
|
802
|
+
if (desc)
|
|
803
|
+
desc.textContent = description;
|
|
804
|
+
const title = svg.querySelector('title');
|
|
805
|
+
if (title)
|
|
806
|
+
title.textContent = 'San Francisco map';
|
|
807
|
+
svg.setAttribute('aria-label', `${description} Arrow keys pan; plus and minus zoom; Home resets. Neighborhoods: brackets move focus; Enter selects. The neighborhood and marker menus include every supplied item.`);
|
|
808
|
+
attribution.textContent = description;
|
|
809
|
+
scheduleLabels();
|
|
810
|
+
}
|
|
811
|
+
function getSelectedMarker() {
|
|
812
|
+
const marker = markerItems.find((item) => item.marker.id === selectedMarker)?.marker;
|
|
813
|
+
return marker ? { ...marker } : null;
|
|
814
|
+
}
|
|
815
|
+
function selectMarker(id, options = {}) {
|
|
816
|
+
if (destroyed)
|
|
817
|
+
return false;
|
|
818
|
+
const item = markerItems.find((item) => item.marker.id === id);
|
|
819
|
+
if (id !== null && !item)
|
|
820
|
+
return false;
|
|
821
|
+
const changed = selectedMarker !== id;
|
|
822
|
+
selectedMarker = id;
|
|
823
|
+
markerSelect.value = id ?? '';
|
|
824
|
+
for (const entry of markerItems) {
|
|
825
|
+
const active = entry.marker.id === id;
|
|
826
|
+
entry.node.setAttribute('aria-pressed', String(active));
|
|
827
|
+
entry.dot.setAttribute('fill', active ? selectedMarkerColor : (entry.marker.color ?? markerColor));
|
|
828
|
+
if (active)
|
|
829
|
+
markerLayer.append(entry.node);
|
|
830
|
+
}
|
|
831
|
+
if (item && options.fit !== false) {
|
|
832
|
+
const width = canvas.getBoundingClientRect().width;
|
|
833
|
+
const bounds = [...item.point, ...item.point];
|
|
834
|
+
setView(width ? fitViewport(bounds, width, fitPadding) : fitBounds(bounds));
|
|
835
|
+
}
|
|
836
|
+
scheduleLabels();
|
|
837
|
+
const marker = getSelectedMarker();
|
|
838
|
+
if (changed)
|
|
839
|
+
root.dispatchEvent(new CustomEvent('markerchange', { bubbles: true, detail: { id, marker } }));
|
|
840
|
+
if (changed && marker)
|
|
841
|
+
onMarkerActivate?.({ ...marker });
|
|
842
|
+
return true;
|
|
843
|
+
}
|
|
844
|
+
function setMarkers(markers) {
|
|
845
|
+
if (destroyed)
|
|
846
|
+
return;
|
|
847
|
+
const ids = new Set();
|
|
848
|
+
const next = markers.map((marker) => {
|
|
849
|
+
if (typeof marker.id !== 'string' || !marker.id || ids.has(marker.id))
|
|
850
|
+
throw new RangeError('Markers require unique nonempty IDs.');
|
|
851
|
+
ids.add(marker.id);
|
|
852
|
+
return { marker: { ...marker }, point: map.project([marker.lng, marker.lat]) };
|
|
853
|
+
});
|
|
854
|
+
const previous = selectedMarker;
|
|
855
|
+
markerLayer.replaceChildren();
|
|
856
|
+
markerSelect.replaceChildren(element('option', 'No marker selected'));
|
|
857
|
+
markerSelect.options[0].value = '';
|
|
858
|
+
markerItems = next.map(({ marker, point }) => {
|
|
859
|
+
const node = svgElement('g', {
|
|
860
|
+
transform: `translate(${point[0]},${point[1]})`,
|
|
861
|
+
'data-marker-id': marker.id,
|
|
862
|
+
role: 'button',
|
|
863
|
+
tabindex: 0,
|
|
864
|
+
'aria-label': marker.label ?? marker.id,
|
|
865
|
+
'aria-pressed': 'false',
|
|
866
|
+
});
|
|
867
|
+
const hit = svgElement('circle', { fill: 'transparent', 'pointer-events': 'all' });
|
|
868
|
+
const dot = svgElement('circle', {
|
|
869
|
+
fill: marker.color ?? markerColor,
|
|
870
|
+
stroke: '#fff9e9',
|
|
871
|
+
'stroke-width': 2,
|
|
872
|
+
'vector-effect': 'non-scaling-stroke',
|
|
873
|
+
'pointer-events': 'none',
|
|
874
|
+
});
|
|
875
|
+
const title = svgElement('title');
|
|
876
|
+
title.textContent = marker.label ?? marker.id;
|
|
877
|
+
node.append(title, hit, dot);
|
|
878
|
+
markerLayer.append(node);
|
|
879
|
+
const option = element('option', marker.label ?? marker.id);
|
|
880
|
+
option.value = marker.id;
|
|
881
|
+
markerSelect.append(option);
|
|
882
|
+
return { marker, point, node, dot, hit };
|
|
883
|
+
});
|
|
884
|
+
markerLabel.hidden = !markerItems.length;
|
|
885
|
+
if (markerLabel.firstChild)
|
|
886
|
+
markerLabel.firstChild.textContent = `Choose marker (${markerItems.length})`;
|
|
887
|
+
const nextSelection = previous && ids.has(previous)
|
|
888
|
+
? previous
|
|
889
|
+
: (markers.find((marker) => marker.selected)?.id ?? null);
|
|
890
|
+
selectMarker(nextSelection, { fit: false });
|
|
891
|
+
}
|
|
892
|
+
if (controls.zoom === false)
|
|
893
|
+
zoomControls.hidden = true;
|
|
894
|
+
if (controls.pan === false)
|
|
895
|
+
panControls.hidden = true;
|
|
896
|
+
if (controls.reset === false) {
|
|
897
|
+
const reset = Array.from(toolbar.querySelectorAll('button')).find((node) => node.textContent === (strings.reset ?? 'Reset'));
|
|
898
|
+
if (reset)
|
|
899
|
+
reset.hidden = true;
|
|
900
|
+
}
|
|
901
|
+
if (controls.labels === false)
|
|
902
|
+
labelsButton.hidden = true;
|
|
903
|
+
if (controls.legend === false)
|
|
904
|
+
legend.hidden = true;
|
|
905
|
+
function setMode(next) {
|
|
906
|
+
if (destroyed)
|
|
907
|
+
return;
|
|
908
|
+
if (!['neighborhoods', 'districts', 'basemap'].includes(next))
|
|
909
|
+
throw new RangeError(`Unknown map mode: ${next}`);
|
|
910
|
+
if (next === 'districts' && !data.districts?.[year])
|
|
911
|
+
throw new RangeError(`No ${year} district dataset was supplied.`);
|
|
912
|
+
mode = next;
|
|
913
|
+
modeSelect.value = mode;
|
|
914
|
+
root.dataset.mode = mode;
|
|
915
|
+
root.setAttribute('aria-label', 'San Francisco interactive map');
|
|
916
|
+
svg.setAttribute('aria-label', `${mode === 'districts' ? 'District' : 'Neighborhood'} map. Use map controls to explore.`);
|
|
917
|
+
const districts = mode !== 'neighborhoods';
|
|
918
|
+
for (const node of [sourceLabel, searchLabel, count, results])
|
|
919
|
+
node.hidden = districts;
|
|
920
|
+
updateComposition();
|
|
921
|
+
updateDetail();
|
|
922
|
+
resetView();
|
|
923
|
+
status.textContent =
|
|
924
|
+
mode === 'basemap'
|
|
925
|
+
? 'San Francisco basemap.'
|
|
926
|
+
: districts
|
|
927
|
+
? `${year} supervisorial districts. Numbers identify each district.`
|
|
928
|
+
: selected
|
|
929
|
+
? `${selected.properties.canonicalName} selected. ${formatSourceLabel(source)}.`
|
|
930
|
+
: `${formatSourceLabel(source)}. Select a neighborhood to begin.`;
|
|
931
|
+
}
|
|
932
|
+
function setLabels(visible) {
|
|
933
|
+
if (destroyed)
|
|
934
|
+
return;
|
|
935
|
+
if (typeof visible !== 'boolean')
|
|
936
|
+
throw new TypeError('Labels must be a boolean.');
|
|
937
|
+
labels = visible;
|
|
938
|
+
labelsButton.setAttribute('aria-pressed', String(labels));
|
|
939
|
+
root.dataset.labels = String(labels);
|
|
940
|
+
updateComposition();
|
|
941
|
+
if (!labels) {
|
|
942
|
+
labelLayer.replaceChildren();
|
|
943
|
+
root.dataset.visibleLabels = '0';
|
|
944
|
+
}
|
|
945
|
+
scheduleLabels();
|
|
946
|
+
}
|
|
947
|
+
listen(modeSelect, 'change', () => {
|
|
948
|
+
const next = modeSelect.value;
|
|
949
|
+
if (next === 'neighborhoods' || next === 'districts' || next === 'basemap')
|
|
950
|
+
setMode(next);
|
|
951
|
+
});
|
|
952
|
+
listen(search, 'input', updateResults);
|
|
953
|
+
listen(search, 'keydown', (event) => {
|
|
954
|
+
if (event.key === 'Enter') {
|
|
955
|
+
const exact = getNeighborhood(search.value, source);
|
|
956
|
+
const matches = searchNeighborhoods(search.value, source);
|
|
957
|
+
if (exact || matches.length === 1) {
|
|
958
|
+
event.preventDefault();
|
|
959
|
+
selectNeighborhood(exact?.id ?? matches[0].id);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
listen(sourceSelect, 'change', () => {
|
|
964
|
+
const next = sourceSelect.value;
|
|
965
|
+
if (next === 'realtor' || next === 'sf-find' || next === 'analysis')
|
|
966
|
+
setSource(next);
|
|
967
|
+
});
|
|
968
|
+
listen(results, 'click', (event) => {
|
|
969
|
+
const node = event.target instanceof Element
|
|
970
|
+
? event.target.closest('[data-neighborhood-result]')
|
|
971
|
+
: null;
|
|
972
|
+
if (node?.dataset.neighborhoodResult)
|
|
973
|
+
selectNeighborhood(node.dataset.neighborhoodResult);
|
|
974
|
+
});
|
|
975
|
+
const touchLabel = strings.touchNavigation ?? 'Touch navigation';
|
|
976
|
+
const touchButton = button(touchLabel, `Enable ${touchLabel.toLowerCase()}`, () => setTouchNavigation(!touchNavigation));
|
|
977
|
+
touchButton.hidden = controls.touch === false;
|
|
978
|
+
let touchNavigation = false;
|
|
979
|
+
const navigation = attachNavigation(svg, getViewport, setView, controller.signal, () => setTouchNavigation(false));
|
|
980
|
+
function setTouchNavigation(enabled) {
|
|
981
|
+
if (destroyed)
|
|
982
|
+
return;
|
|
983
|
+
if (typeof enabled !== 'boolean')
|
|
984
|
+
throw new TypeError('Touch navigation must be a boolean.');
|
|
985
|
+
touchNavigation = enabled;
|
|
986
|
+
navigation.setTouchNavigation(enabled);
|
|
987
|
+
canvas.style.touchAction = enabled ? 'none' : 'pan-y pinch-zoom';
|
|
988
|
+
touchButton.textContent = enabled ? 'Done: page scrolling' : touchLabel;
|
|
989
|
+
touchButton.setAttribute('aria-label', enabled ? `Exit ${touchLabel.toLowerCase()}` : `Enable ${touchLabel.toLowerCase()}`);
|
|
990
|
+
touchButton.setAttribute('aria-pressed', String(enabled));
|
|
991
|
+
root.dataset.touchNavigation = String(enabled);
|
|
992
|
+
}
|
|
993
|
+
listen(neighborhoodSelect, 'change', () => selectNeighborhood(neighborhoodSelect.value || null));
|
|
994
|
+
listen(markerSelect, 'change', () => selectMarker(markerSelect.value || null));
|
|
995
|
+
listen(svg, 'keydown', (event) => {
|
|
996
|
+
const target = event.target instanceof SVGElement ? event.target : null;
|
|
997
|
+
const id = target?.dataset.neighborhoodId;
|
|
998
|
+
if (!id || !selectableNeighborhoods)
|
|
999
|
+
return;
|
|
1000
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
1001
|
+
event.preventDefault();
|
|
1002
|
+
selectNeighborhood(id);
|
|
1003
|
+
target.focus();
|
|
1004
|
+
}
|
|
1005
|
+
else if (event.key === '[' || event.key === ']') {
|
|
1006
|
+
event.preventDefault();
|
|
1007
|
+
const i = items.findIndex((item) => item.feature.id === id);
|
|
1008
|
+
const next = items[(i + (event.key === ']' ? 1 : items.length - 1)) % items.length];
|
|
1009
|
+
for (const item of items)
|
|
1010
|
+
item.node.setAttribute('tabindex', item === next ? '0' : '-1');
|
|
1011
|
+
next.node.focus();
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
listen(markerLayer, 'click', (event) => {
|
|
1015
|
+
const node = event.target instanceof Element ? event.target.closest('[data-marker-id]') : null;
|
|
1016
|
+
if (node?.dataset.markerId)
|
|
1017
|
+
selectMarker(node.dataset.markerId);
|
|
1018
|
+
});
|
|
1019
|
+
listen(markerLayer, 'keydown', (event) => {
|
|
1020
|
+
const node = event.target instanceof Element ? event.target.closest('[data-marker-id]') : null;
|
|
1021
|
+
if (node?.dataset.markerId && (event.key === 'Enter' || event.key === ' ')) {
|
|
1022
|
+
event.preventDefault();
|
|
1023
|
+
selectMarker(node.dataset.markerId);
|
|
1024
|
+
node.focus();
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1027
|
+
listen(svg, 'click', (event) => {
|
|
1028
|
+
const node = event.target instanceof Element
|
|
1029
|
+
? event.target.closest('[data-neighborhood-id]')
|
|
1030
|
+
: null;
|
|
1031
|
+
if (selectableNeighborhoods && node?.dataset.neighborhoodId)
|
|
1032
|
+
selectNeighborhood(node.dataset.neighborhoodId);
|
|
1033
|
+
});
|
|
1034
|
+
const observer = new ResizeObserver(() => {
|
|
1035
|
+
navigation.cancel();
|
|
1036
|
+
scheduleLabels();
|
|
1037
|
+
});
|
|
1038
|
+
observer.observe(canvas);
|
|
1039
|
+
const explorer = Object.assign(root, {
|
|
1040
|
+
selectNeighborhood,
|
|
1041
|
+
getSelection,
|
|
1042
|
+
getViewport,
|
|
1043
|
+
setViewport: setView,
|
|
1044
|
+
fitGeometry,
|
|
1045
|
+
panBy,
|
|
1046
|
+
setTouchNavigation,
|
|
1047
|
+
setMarkers,
|
|
1048
|
+
setOverlays,
|
|
1049
|
+
selectMarker,
|
|
1050
|
+
getSelectedMarker,
|
|
1051
|
+
setSource,
|
|
1052
|
+
setMode,
|
|
1053
|
+
setLabels,
|
|
1054
|
+
resetView,
|
|
1055
|
+
zoomBy,
|
|
1056
|
+
destroy() {
|
|
1057
|
+
destroyed = true;
|
|
1058
|
+
controller.abort();
|
|
1059
|
+
observer.disconnect();
|
|
1060
|
+
cancelAnimationFrame(frame);
|
|
1061
|
+
releaseDownloads();
|
|
1062
|
+
},
|
|
1063
|
+
});
|
|
1064
|
+
try {
|
|
1065
|
+
const initialMode = mode;
|
|
1066
|
+
setSource(source);
|
|
1067
|
+
if (neighborhood !== undefined)
|
|
1068
|
+
selectNeighborhood(neighborhood);
|
|
1069
|
+
setMode(initialMode);
|
|
1070
|
+
if (initialMode === 'neighborhoods' && selected) {
|
|
1071
|
+
const selectedItem = items.find((item) => item.feature === selected);
|
|
1072
|
+
if (selectedItem)
|
|
1073
|
+
setView(fitBounds(selectedItem.bounds));
|
|
1074
|
+
}
|
|
1075
|
+
setLabels(labels);
|
|
1076
|
+
setTouchNavigation(false);
|
|
1077
|
+
setMarkers(initialMarkers);
|
|
1078
|
+
setOverlays(initialOverlays);
|
|
1079
|
+
}
|
|
1080
|
+
catch (error) {
|
|
1081
|
+
explorer.destroy();
|
|
1082
|
+
throw error;
|
|
1083
|
+
}
|
|
1084
|
+
return explorer;
|
|
1085
|
+
}
|