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