@kahwee/sf-map-svg 1.5.2 → 2.1.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 +46 -0
- package/README.md +160 -6
- package/SOURCES.md +34 -0
- package/dist/data/README.md +5 -1
- package/dist/data/candidates/2016-11-08.json +1 -0
- package/dist/data/candidates/2018-11-06.json +1 -0
- package/dist/data/candidates/2020-11-03.json +1 -0
- package/dist/data/candidates/2022-11-08.json +1 -0
- package/dist/data/candidates/2024-11-05.json +1 -0
- package/dist/data/candidates/2026-06-02.json +1 -0
- package/dist/data/candidates/README.md +43 -0
- package/dist/data/candidates/catalog.json +40 -0
- package/dist/data/types.d.ts +4 -1
- package/dist/src/api.d.ts +3 -0
- package/dist/src/api.js +3 -0
- package/dist/src/camera.d.ts +16 -0
- package/dist/src/camera.js +55 -0
- package/dist/src/clusters.d.ts +7 -0
- package/dist/src/clusters.js +33 -0
- package/dist/src/configuration.d.ts +5 -0
- package/dist/src/configuration.js +73 -0
- package/dist/src/controller-types.d.ts +84 -0
- package/dist/src/controller-types.js +1 -0
- package/dist/src/explorer-core.d.ts +1 -1
- package/dist/src/explorer-core.js +570 -145
- package/dist/src/features.d.ts +24 -0
- package/dist/src/features.js +99 -0
- package/dist/src/guide-data.js +3 -2
- package/dist/src/guide-detailed.js +3 -2
- package/dist/src/guide-map.d.ts +4 -0
- package/dist/src/guide-map.js +35 -10
- package/dist/src/guide-shell.d.ts +2 -0
- package/dist/src/guide-shell.js +16 -0
- package/dist/src/guide-static.d.ts +9 -0
- package/dist/src/guide-static.js +26 -0
- package/dist/src/guide.d.ts +2 -1
- package/dist/src/guide.js +1 -1
- package/dist/src/interactive-data.d.ts +1 -1
- package/dist/src/interactive-data.js +2 -0
- package/dist/src/interactive.d.ts +1 -1
- package/dist/src/interactive.js +2 -0
- package/dist/src/layers.js +3 -1
- package/dist/src/map-core.js +3 -8
- package/dist/src/map.d.ts +7 -0
- package/dist/src/map.js +106 -0
- package/dist/src/presets.d.ts +3 -0
- package/dist/src/presets.js +17 -0
- package/dist/src/static.d.ts +10 -0
- package/dist/src/static.js +5 -0
- package/dist/src/types.d.ts +73 -9
- package/dist/src/validation.d.ts +7 -0
- package/dist/src/validation.js +226 -0
- package/dist/src/viewport.js +9 -2
- package/docs/EXAMPLES.md +11 -1
- package/docs/api-audit.md +80 -0
- package/docs/consumer-integration.md +165 -0
- package/docs/guide-bundle-report.md +19 -0
- package/docs/migration-v2.md +238 -0
- package/package.json +40 -11
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import { createCamera, validateCameraOptions } from './camera.js';
|
|
2
|
+
import { clusterPoints } from './clusters.js';
|
|
1
3
|
import { fitBounds, interiorAnchor, layoutLabels, projectedBounds } from './explorer-layout.js';
|
|
4
|
+
import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from './features.js';
|
|
2
5
|
import { geometryPath } from './geometry.js';
|
|
3
6
|
import { createSFMapWithData, districtColors } from './map-core.js';
|
|
4
7
|
import { attachNavigation } from './navigation.js';
|
|
8
|
+
import { validateExplorerOptions, validateMarkers, validateOverlays } from './validation.js';
|
|
5
9
|
import { fitViewport, validateViewport } from './viewport.js';
|
|
6
10
|
const svgNS = 'http://www.w3.org/2000/svg';
|
|
7
11
|
const sourceNames = {
|
|
@@ -32,7 +36,9 @@ function svgElement(tag, attributes = {}) {
|
|
|
32
36
|
}
|
|
33
37
|
let explorerCount = 0;
|
|
34
38
|
/** Create an offline, browser-only neighborhood explorer. Call destroy() before disposal. */
|
|
35
|
-
export function createNeighborhoodExplorerCore(
|
|
39
|
+
export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
40
|
+
validateExplorerOptions(options);
|
|
41
|
+
let { source = 'realtor', mode = 'neighborhoods', labels = true, neighborhood, year = 2022, theme = 'transit', colors = {}, labelStyle = {}, areaStyle = {}, motion = false, markerEntrance = false, selectedMarkerRing, clustering = false, legend: legendOptions = {}, attribution: attributionMode = 'full', northArrow = false, scaleBar = 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;
|
|
36
42
|
if (!['neighborhoods', 'districts', 'basemap'].includes(mode))
|
|
37
43
|
throw new RangeError(`Unknown map mode: ${mode}`);
|
|
38
44
|
if (chrome !== 'explorer' && chrome !== 'map')
|
|
@@ -45,6 +51,29 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
45
51
|
markerRadius <= 0 ||
|
|
46
52
|
markerHitSize < markerRadius * 2)
|
|
47
53
|
throw new RangeError('Use label sizes from 8–32px and a positive marker radius inside its hit target.');
|
|
54
|
+
let features = normalizeFeatures({
|
|
55
|
+
motion,
|
|
56
|
+
markerEntrance,
|
|
57
|
+
clustering,
|
|
58
|
+
selectedMarkerRing,
|
|
59
|
+
northArrow,
|
|
60
|
+
scaleBar,
|
|
61
|
+
});
|
|
62
|
+
if (labelStyle.fontWeight !== undefined &&
|
|
63
|
+
(!Number.isFinite(labelStyle.fontWeight) ||
|
|
64
|
+
labelStyle.fontWeight < 1 ||
|
|
65
|
+
labelStyle.fontWeight > 1000))
|
|
66
|
+
throw new RangeError('Label font weight must be finite and between 1 and 1000.');
|
|
67
|
+
validateSwitchPatch(controls, controlKeys);
|
|
68
|
+
colors = { ...colors };
|
|
69
|
+
labelStyle = { ...labelStyle };
|
|
70
|
+
areaStyle = { ...areaStyle };
|
|
71
|
+
strings = { ...strings };
|
|
72
|
+
controls = { ...controls };
|
|
73
|
+
if (typeof fitPadding === 'object')
|
|
74
|
+
fitPadding = { ...fitPadding };
|
|
75
|
+
validateSwitchPatch(layers, layerKeys);
|
|
76
|
+
validateSwitchPatch(controls, controlKeys);
|
|
48
77
|
layers = { ...layers };
|
|
49
78
|
const enabled = (layer) => layers[layer] ??
|
|
50
79
|
(layer.startsWith('district')
|
|
@@ -56,8 +85,12 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
56
85
|
throw new TypeError('Labels must be a boolean.');
|
|
57
86
|
const collections = data.neighborhoods;
|
|
58
87
|
const sources = ['realtor', 'sf-find', 'analysis'].filter((item) => Object.hasOwn(collections, item));
|
|
59
|
-
if (!Object.hasOwn(collections, source))
|
|
88
|
+
if (options.source === undefined && sources.length && !Object.hasOwn(collections, source))
|
|
89
|
+
source = sources[0];
|
|
90
|
+
if (!Object.hasOwn(collections, source) && (sources.length || options.source !== undefined))
|
|
60
91
|
throw new RangeError(`Unknown neighborhood source: ${source}`);
|
|
92
|
+
if (!sources.length && mode === 'neighborhoods')
|
|
93
|
+
throw new RangeError('No neighborhood dataset was supplied.');
|
|
61
94
|
if (neighborhood === '')
|
|
62
95
|
neighborhood = undefined;
|
|
63
96
|
const getNeighborhood = (name, selectedSource = source) => {
|
|
@@ -86,6 +119,7 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
86
119
|
throw new RangeError(`Unknown neighborhood: ${neighborhood}`);
|
|
87
120
|
if (typeof document === 'undefined')
|
|
88
121
|
throw new Error('The neighborhood explorer requires a browser document.');
|
|
122
|
+
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
89
123
|
const controller = new AbortController();
|
|
90
124
|
function listen(node, event, handler, options = {}) {
|
|
91
125
|
node.addEventListener(event, (incoming) => handler(incoming), { ...options, signal: controller.signal });
|
|
@@ -99,7 +133,7 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
99
133
|
.sf-explorer [hidden]{display:none!important}
|
|
100
134
|
.sf-explorer h2,.sf-explorer p{margin:0}
|
|
101
135
|
.sf-explorer button,.sf-explorer input,.sf-explorer select,.sf-explorer a{font:inherit}
|
|
102
|
-
.sf-explorer button,.sf-explorer select,.sf-explorer input{color:inherit;border:1px solid #c7d5df;border-radius:7px;background
|
|
136
|
+
.sf-explorer button,.sf-explorer select,.sf-explorer input{color:inherit;border:1px solid #c7d5df;border-radius:7px;background:var(--sf-map-surface);min-height:44px;padding:9px 12px}
|
|
103
137
|
.sf-explorer button{cursor:pointer;transition:background .12s,border-color .12s}
|
|
104
138
|
.sf-explorer button:hover{background:#f0f6fa;border-color:#8ba9c0}
|
|
105
139
|
.sf-explorer button[aria-pressed=true]{color:#123d63;background:#e6f1f9;border-color:#5689b0;font-weight:650}
|
|
@@ -132,7 +166,7 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
132
166
|
.sf-explorer-canvas>svg{display:block;width:100%;height:100%;max-width:none;cursor:grab;user-select:none}
|
|
133
167
|
.sf-explorer-canvas>svg:active{cursor:grabbing}
|
|
134
168
|
.sf-explorer-canvas path[data-neighborhood-id]{cursor:pointer}
|
|
135
|
-
.sf-explorer-legend{display:flex;flex-wrap:wrap;gap:8px 20px;padding:14px 16px 8px;background
|
|
169
|
+
.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}
|
|
136
170
|
.sf-explorer-legend-item{display:inline-flex;align-items:center;gap:7px;white-space:nowrap}
|
|
137
171
|
.sf-explorer-legend-symbol{display:inline-block;flex:none;width:15px;height:11px;border-radius:2px}
|
|
138
172
|
.sf-explorer-legend-bart{width:10px;height:10px;margin:0 2px;border:2px solid #0073ae;border-radius:50%;background:#fff}
|
|
@@ -141,7 +175,7 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
141
175
|
.sf-explorer-legend-highway{height:3px;border-radius:2px;background:#b9a18a}
|
|
142
176
|
.sf-explorer-hint{padding:6px 16px 10px;font-size:12px;color:#586f80;background:#fff}
|
|
143
177
|
.sf-explorer text{pointer-events:none}
|
|
144
|
-
.sf-explorer-status{padding:0 16px 14px;font-size:12px;background
|
|
178
|
+
.sf-explorer-status{padding:0 16px 14px;font-size:12px;background:var(--sf-map-surface);color:#496578}
|
|
145
179
|
.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}
|
|
146
180
|
.sf-explorer[data-interface=map] .sf-explorer-body{display:block}
|
|
147
181
|
.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}
|
|
@@ -190,6 +224,8 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
190
224
|
]) {
|
|
191
225
|
if (value === 'districts' && !data.districts?.[year])
|
|
192
226
|
continue;
|
|
227
|
+
if (value === 'neighborhoods' && !sources.length)
|
|
228
|
+
continue;
|
|
193
229
|
const option = element('option', title);
|
|
194
230
|
option.value = value;
|
|
195
231
|
modeSelect.append(option);
|
|
@@ -235,12 +271,28 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
235
271
|
['highway', 'Highway'],
|
|
236
272
|
['road', 'Roads'],
|
|
237
273
|
]) {
|
|
274
|
+
if (legendOptions.builtins === false || legendOptions.hidden?.includes(kind))
|
|
275
|
+
continue;
|
|
238
276
|
const entry = element('span', '', 'sf-explorer-legend-item');
|
|
239
277
|
const symbol = element('span', '', `sf-explorer-legend-symbol sf-explorer-legend-${kind}`);
|
|
240
278
|
symbol.setAttribute('aria-hidden', 'true');
|
|
279
|
+
if (colors[kind]) {
|
|
280
|
+
if (kind === 'bart')
|
|
281
|
+
symbol.style.borderColor = colors[kind];
|
|
282
|
+
else
|
|
283
|
+
symbol.style.backgroundColor = colors[kind];
|
|
284
|
+
}
|
|
241
285
|
entry.append(symbol, document.createTextNode(label));
|
|
242
286
|
legend.append(entry);
|
|
243
287
|
}
|
|
288
|
+
for (const item of legendOptions.items ?? []) {
|
|
289
|
+
const entry = element('span', '', 'sf-explorer-legend-item');
|
|
290
|
+
const symbol = element('span', '', 'sf-explorer-legend-symbol');
|
|
291
|
+
symbol.style.backgroundColor = item.color;
|
|
292
|
+
symbol.setAttribute('aria-hidden', 'true');
|
|
293
|
+
entry.append(symbol, document.createTextNode(item.label));
|
|
294
|
+
legend.append(entry);
|
|
295
|
+
}
|
|
244
296
|
const featureControls = element('div', '', 'sf-explorer-feature-controls');
|
|
245
297
|
const neighborhoodLabel = element('label', strings.chooseNeighborhood ?? 'Select neighborhood');
|
|
246
298
|
const neighborhoodSelect = element('select');
|
|
@@ -250,19 +302,22 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
250
302
|
markerLabel.append(markerSelect);
|
|
251
303
|
featureControls.append(neighborhoodLabel, markerLabel);
|
|
252
304
|
const attribution = element('p', '', 'sf-explorer-hint');
|
|
253
|
-
|
|
305
|
+
const attributionDisclosure = element('details', '', 'sf-explorer-hint');
|
|
306
|
+
attributionDisclosure.append(element('summary', 'Map data · DataSF / BART'), attribution);
|
|
307
|
+
column.append(toolbar, featureControls, canvas, legend, hint, status, attributionMode === 'compact' ? attributionDisclosure : attribution);
|
|
254
308
|
body.append(panel, column, detail);
|
|
255
309
|
root.append(body);
|
|
256
310
|
const map = createSFMapWithData({
|
|
257
311
|
year,
|
|
258
312
|
theme,
|
|
313
|
+
colors,
|
|
259
314
|
districtLabels: false,
|
|
260
315
|
districtLines: true,
|
|
261
316
|
districtFills: true,
|
|
262
|
-
highways:
|
|
263
|
-
keyRoads:
|
|
317
|
+
highways: true,
|
|
318
|
+
keyRoads: true,
|
|
264
319
|
roadLabels: false,
|
|
265
|
-
landmarks:
|
|
320
|
+
landmarks: true,
|
|
266
321
|
bartStations: false,
|
|
267
322
|
}, data.map);
|
|
268
323
|
canvas.innerHTML = map.svg;
|
|
@@ -270,6 +325,31 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
270
325
|
if (!renderedSvg)
|
|
271
326
|
throw new Error('The renderer did not produce an SVG.');
|
|
272
327
|
const svg = renderedSvg;
|
|
328
|
+
const overlayElement = element('div', '', 'sf-map-overlay');
|
|
329
|
+
overlayElement.style.cssText = 'position:absolute;inset:0;pointer-events:none';
|
|
330
|
+
canvas.append(overlayElement);
|
|
331
|
+
const scale = element('span', '', 'sf-map-scale');
|
|
332
|
+
scale.style.cssText =
|
|
333
|
+
'position:absolute;bottom:12px;left:12px;border-bottom:2px solid currentColor;background:var(--sf-map-surface);text-align:center;white-space:nowrap;font:12px/1.5 system-ui,sans-serif';
|
|
334
|
+
overlayElement.append(scale);
|
|
335
|
+
const arrow = element('span', '↑ N', 'sf-map-north');
|
|
336
|
+
arrow.setAttribute('aria-label', 'North');
|
|
337
|
+
arrow.style.cssText =
|
|
338
|
+
'position:absolute;top:12px;right:12px;padding:4px;background:var(--sf-map-surface)';
|
|
339
|
+
overlayElement.append(arrow);
|
|
340
|
+
function syncFurniture() {
|
|
341
|
+
scale.hidden = !features.scaleBar;
|
|
342
|
+
arrow.hidden = !features.northArrow;
|
|
343
|
+
}
|
|
344
|
+
syncFurniture();
|
|
345
|
+
function projectToScreen(lng, lat) {
|
|
346
|
+
const [x, y] = map.project([lng, lat]);
|
|
347
|
+
const width = canvas.getBoundingClientRect().width;
|
|
348
|
+
if (!width)
|
|
349
|
+
throw new Error('Mount the map before projecting to screen.');
|
|
350
|
+
const px = ((x - view[0]) * width) / view[2], py = ((y - view[1]) * width) / view[2];
|
|
351
|
+
return { x: px, y: py, visible: px >= 0 && py >= 0 && px <= width && py <= width };
|
|
352
|
+
}
|
|
273
353
|
svg.setAttribute('role', 'group');
|
|
274
354
|
svg.setAttribute('tabindex', '0');
|
|
275
355
|
svg.setAttribute('aria-label', 'Neighborhood map. Use search results and map controls to explore.');
|
|
@@ -297,16 +377,59 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
297
377
|
const labelLayer = svgElement('g', {
|
|
298
378
|
'data-layer': 'explorer-labels',
|
|
299
379
|
'aria-hidden': 'true',
|
|
300
|
-
'font-family': 'system-ui,sans-serif',
|
|
380
|
+
'font-family': labelStyle.fontFamily ?? styleOptions.font ?? 'system-ui,sans-serif',
|
|
301
381
|
});
|
|
302
382
|
geography.append(areas, stations, labelLayer, overlayLayer, markerLayer);
|
|
303
383
|
let view = [0, 0, 800];
|
|
304
384
|
let selected;
|
|
305
385
|
let items = [];
|
|
306
386
|
let destroyed = false;
|
|
387
|
+
let initialized = false;
|
|
307
388
|
let frame = 0;
|
|
389
|
+
const entranceAnimations = new Set();
|
|
390
|
+
function cancelEntrances() {
|
|
391
|
+
for (const animation of entranceAnimations)
|
|
392
|
+
animation.cancel();
|
|
393
|
+
entranceAnimations.clear();
|
|
394
|
+
}
|
|
395
|
+
const camera = createCamera({
|
|
396
|
+
read: getViewport,
|
|
397
|
+
write: setView,
|
|
398
|
+
duration: () => (initialized && features.motion ? features.motion.duration : 0),
|
|
399
|
+
reduced: () => reducedMotion.matches,
|
|
400
|
+
request: (callback) => requestAnimationFrame(callback),
|
|
401
|
+
cancel: (id) => cancelAnimationFrame(id),
|
|
402
|
+
now: () => performance.now(),
|
|
403
|
+
});
|
|
404
|
+
const stopAnimation = camera.stop;
|
|
405
|
+
const moveView = camera.move;
|
|
406
|
+
function getFeatures() {
|
|
407
|
+
return structuredClone(features);
|
|
408
|
+
}
|
|
409
|
+
function setFeatures(patch) {
|
|
410
|
+
if (destroyed)
|
|
411
|
+
return;
|
|
412
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch))
|
|
413
|
+
throw new TypeError('Features must be an options object.');
|
|
414
|
+
const next = normalizeFeatures({ ...features, ...patch });
|
|
415
|
+
features = next;
|
|
416
|
+
if (Object.hasOwn(patch, 'motion'))
|
|
417
|
+
stopAnimation();
|
|
418
|
+
if (Object.hasOwn(patch, 'markerEntrance'))
|
|
419
|
+
cancelEntrances();
|
|
420
|
+
if (Object.hasOwn(patch, 'clustering'))
|
|
421
|
+
invalidateClusters();
|
|
422
|
+
syncFurniture();
|
|
423
|
+
for (const item of markerItems) {
|
|
424
|
+
const ring = features.selectedMarkerRing;
|
|
425
|
+
item.ring.setAttribute('display', ring && item.marker.id === selectedMarker ? 'inline' : 'none');
|
|
426
|
+
item.ring.setAttribute('stroke', ring ? (ring.color ?? selectedMarkerColor) : selectedMarkerColor);
|
|
427
|
+
item.ring.setAttribute('stroke-width', String(ring ? ring.width : 2));
|
|
428
|
+
}
|
|
429
|
+
scheduleLabels();
|
|
430
|
+
}
|
|
308
431
|
const downloads = new Set();
|
|
309
|
-
const stationItems = (
|
|
432
|
+
const stationItems = (data.map.bartStations ?? []).map((feature) => {
|
|
310
433
|
return {
|
|
311
434
|
point: map.project(feature.coordinates),
|
|
312
435
|
name: feature.name,
|
|
@@ -319,7 +442,7 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
319
442
|
cx: station.point[0],
|
|
320
443
|
cy: station.point[1],
|
|
321
444
|
fill: '#fff',
|
|
322
|
-
stroke: '#0073ae',
|
|
445
|
+
stroke: colors.bart ?? '#0073ae',
|
|
323
446
|
'stroke-width': 2,
|
|
324
447
|
'vector-effect': 'non-scaling-stroke',
|
|
325
448
|
});
|
|
@@ -328,43 +451,115 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
328
451
|
station.node.append(title);
|
|
329
452
|
stations.append(station.node);
|
|
330
453
|
}
|
|
331
|
-
const roadItems = (
|
|
454
|
+
const roadItems = (data.map.keyRoads ?? []).map((feature) => ({
|
|
332
455
|
point: map.project(feature.label),
|
|
333
456
|
name: feature.name,
|
|
334
457
|
level: feature.level ?? 'primary',
|
|
335
458
|
kind: 'road',
|
|
336
459
|
}));
|
|
337
|
-
const parkItems = (
|
|
460
|
+
const parkItems = (data.map.landmarks ?? []).map((feature) => ({
|
|
338
461
|
point: map.project(feature.label),
|
|
339
462
|
name: feature.name,
|
|
340
463
|
kind: 'park',
|
|
341
464
|
}));
|
|
342
|
-
for (const [kind, option] of [
|
|
343
|
-
['bart', 'bartStations'],
|
|
344
|
-
['park', 'landmarks'],
|
|
345
|
-
['highway', 'highways'],
|
|
346
|
-
['road', 'keyRoads'],
|
|
347
|
-
]) {
|
|
348
|
-
if (!enabled(option))
|
|
349
|
-
legend.querySelector(`.sf-explorer-legend-${kind}`)?.parentElement?.remove();
|
|
350
|
-
}
|
|
351
|
-
legend.hidden = !legend.children.length;
|
|
352
465
|
let markerItems = [];
|
|
353
466
|
let selectedMarker = null;
|
|
467
|
+
let markerRevision = 0;
|
|
468
|
+
let neighborhoodRevision = 0;
|
|
469
|
+
const clusterLayer = svgElement('g', { 'data-layer': 'marker-clusters' });
|
|
470
|
+
geography.insertBefore(clusterLayer, markerLayer);
|
|
471
|
+
let clusterSignature = '';
|
|
472
|
+
let clusterEvents = new AbortController();
|
|
473
|
+
function invalidateClusters() {
|
|
474
|
+
clusterEvents.abort();
|
|
475
|
+
clusterEvents = new AbortController();
|
|
476
|
+
clusterSignature = '';
|
|
477
|
+
if (clusterLayer.contains(document.activeElement))
|
|
478
|
+
svg.focus({ preventScroll: true });
|
|
479
|
+
clusterLayer.replaceChildren();
|
|
480
|
+
for (const item of markerItems)
|
|
481
|
+
item.node.style.display = '';
|
|
482
|
+
}
|
|
483
|
+
function drawClusters(unit) {
|
|
484
|
+
for (const item of markerItems)
|
|
485
|
+
item.node.style.display = '';
|
|
486
|
+
const eligible = markerItems.filter((item) => item.marker.id !== selectedMarker && document.activeElement !== item.node);
|
|
487
|
+
const groups = features.clustering
|
|
488
|
+
? clusterPoints(eligible, unit, features.clustering.radius)
|
|
489
|
+
: [];
|
|
490
|
+
const clusters = groups.filter((group) => group.length > 1);
|
|
491
|
+
for (const group of clusters)
|
|
492
|
+
for (const item of group)
|
|
493
|
+
item.node.style.display = 'none';
|
|
494
|
+
const signature = JSON.stringify(clusters.map((group) => group.map((item) => item.marker.id)));
|
|
495
|
+
if (signature !== clusterSignature) {
|
|
496
|
+
// Revoke old closures before replacing their interactive subtree.
|
|
497
|
+
clusterEvents.abort();
|
|
498
|
+
clusterEvents = new AbortController();
|
|
499
|
+
const signal = clusterEvents.signal;
|
|
500
|
+
clusterSignature = signature;
|
|
501
|
+
if (clusterLayer.contains(document.activeElement))
|
|
502
|
+
svg.focus({ preventScroll: true });
|
|
503
|
+
clusterLayer.replaceChildren();
|
|
504
|
+
for (const group of clusters) {
|
|
505
|
+
const node = svgElement('g', {
|
|
506
|
+
role: 'button',
|
|
507
|
+
tabindex: 0,
|
|
508
|
+
'aria-label': `${group.length} places. Zoom to explore; all places are available in the marker chooser.`,
|
|
509
|
+
});
|
|
510
|
+
node.style.cursor = 'pointer';
|
|
511
|
+
node.append(svgElement('circle', {
|
|
512
|
+
fill: markerColor,
|
|
513
|
+
stroke: '#fff',
|
|
514
|
+
'stroke-width': 2,
|
|
515
|
+
'vector-effect': 'non-scaling-stroke',
|
|
516
|
+
}));
|
|
517
|
+
const text = svgElement('text', {
|
|
518
|
+
fill: '#fff',
|
|
519
|
+
'text-anchor': 'middle',
|
|
520
|
+
'dominant-baseline': 'central',
|
|
521
|
+
});
|
|
522
|
+
text.textContent = String(group.length);
|
|
523
|
+
node.append(text);
|
|
524
|
+
const activate = () => {
|
|
525
|
+
if (destroyed || signal.aborted)
|
|
526
|
+
return;
|
|
527
|
+
const markers = group.map((item) => ({ ...item.marker }));
|
|
528
|
+
fitGeometry({
|
|
529
|
+
type: 'MultiPoint',
|
|
530
|
+
coordinates: markers.map((marker) => [marker.lng, marker.lat]),
|
|
531
|
+
});
|
|
532
|
+
if (destroyed || signal.aborted)
|
|
533
|
+
return;
|
|
534
|
+
root.dispatchEvent(new CustomEvent('clusteractivate', { bubbles: true, detail: { markers } }));
|
|
535
|
+
};
|
|
536
|
+
node.addEventListener('click', activate, { signal });
|
|
537
|
+
node.addEventListener('keydown', (event) => {
|
|
538
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
539
|
+
event.preventDefault();
|
|
540
|
+
activate();
|
|
541
|
+
}
|
|
542
|
+
}, { signal });
|
|
543
|
+
clusterLayer.append(node);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
clusters.forEach((group, index) => {
|
|
547
|
+
const x = group.reduce((sum, item) => sum + item.point[0], 0) / group.length;
|
|
548
|
+
const y = group.reduce((sum, item) => sum + item.point[1], 0) / group.length;
|
|
549
|
+
const node = clusterLayer.children[index];
|
|
550
|
+
node.setAttribute('transform', `translate(${x},${y})`);
|
|
551
|
+
node.querySelector('circle')?.setAttribute('r', String(22 * unit));
|
|
552
|
+
node.querySelector('text')?.setAttribute('font-size', String(12 * unit));
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
let overlayEvents = new AbortController();
|
|
354
556
|
function setOverlays(overlays) {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
throw new RangeError('Overlay strokeWidth must be a finite nonnegative number.');
|
|
362
|
-
if (overlay.fillOpacity !== undefined &&
|
|
363
|
-
(!Number.isFinite(overlay.fillOpacity) ||
|
|
364
|
-
overlay.fillOpacity < 0 ||
|
|
365
|
-
overlay.fillOpacity > 1))
|
|
366
|
-
throw new RangeError('Overlay fillOpacity must be a finite number from 0 to 1.');
|
|
367
|
-
ids.add(overlay.id);
|
|
557
|
+
if (destroyed)
|
|
558
|
+
return;
|
|
559
|
+
validateOverlays(overlays);
|
|
560
|
+
const events = new AbortController();
|
|
561
|
+
const next = overlays.map((input) => {
|
|
562
|
+
const overlay = structuredClone(input);
|
|
368
563
|
const node = svgElement('path', {
|
|
369
564
|
'data-overlay-id': overlay.id,
|
|
370
565
|
d: geometryPath(overlay.geometry, map.project),
|
|
@@ -389,19 +584,28 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
389
584
|
node.setAttribute('pointer-events', overlay.fill && overlay.fill !== 'none' ? 'visiblePainted' : 'stroke');
|
|
390
585
|
node.setAttribute('aria-label', overlay.label ?? overlay.id);
|
|
391
586
|
const activate = () => {
|
|
392
|
-
|
|
393
|
-
|
|
587
|
+
if (destroyed || events.signal.aborted)
|
|
588
|
+
return;
|
|
589
|
+
onOverlayActivate(structuredClone(overlay));
|
|
590
|
+
if (destroyed || events.signal.aborted)
|
|
591
|
+
return;
|
|
592
|
+
root.dispatchEvent(new CustomEvent('overlayactivate', {
|
|
593
|
+
bubbles: true,
|
|
594
|
+
detail: { overlay: structuredClone(overlay) },
|
|
595
|
+
}));
|
|
394
596
|
};
|
|
395
|
-
node.addEventListener('click', activate, { signal:
|
|
597
|
+
node.addEventListener('click', activate, { signal: events.signal });
|
|
396
598
|
node.addEventListener('keydown', (event) => {
|
|
397
599
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
398
600
|
event.preventDefault();
|
|
399
601
|
activate();
|
|
400
602
|
}
|
|
401
|
-
}, { signal:
|
|
603
|
+
}, { signal: events.signal });
|
|
402
604
|
}
|
|
403
605
|
return node;
|
|
404
606
|
});
|
|
607
|
+
overlayEvents.abort();
|
|
608
|
+
overlayEvents = events;
|
|
405
609
|
overlayLayer.replaceChildren(...next);
|
|
406
610
|
}
|
|
407
611
|
const zoomControls = element('div', '', 'sf-explorer-control-group');
|
|
@@ -411,7 +615,7 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
411
615
|
const node = element('button', text);
|
|
412
616
|
node.type = 'button';
|
|
413
617
|
node.setAttribute('aria-label', label);
|
|
414
|
-
listen(node, 'click', action);
|
|
618
|
+
listen(node, 'click', () => action());
|
|
415
619
|
group.append(node);
|
|
416
620
|
return node;
|
|
417
621
|
}
|
|
@@ -419,7 +623,7 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
419
623
|
const zoomText = element('span', '100%', 'sf-explorer-zoom');
|
|
420
624
|
zoomControls.append(zoomText);
|
|
421
625
|
const zoomIn = button('+', 'Zoom in', () => zoomBy(1.5));
|
|
422
|
-
button(strings.reset ?? 'Reset', strings.reset ?? 'Reset map to city view', resetView);
|
|
626
|
+
const resetButton = button(strings.reset ?? 'Reset', strings.reset ?? 'Reset map to city view', resetView);
|
|
423
627
|
const labelsButton = button('Labels', 'Show map labels', () => setLabels(!labels));
|
|
424
628
|
for (const [symbol, label, dx, dy] of [
|
|
425
629
|
['←', 'Pan west', -1, 0],
|
|
@@ -427,22 +631,28 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
427
631
|
['↓', 'Pan south', 0, 1],
|
|
428
632
|
['→', 'Pan east', 1, 0],
|
|
429
633
|
])
|
|
430
|
-
button(symbol, label, () =>
|
|
431
|
-
function panBy(x, y) {
|
|
634
|
+
button(symbol, label, () => moveView([view[0] + (dx * view[2]) / 4, view[1] + (dy * view[2]) / 4, view[2]]), panControls);
|
|
635
|
+
function panBy(x, y, options = {}) {
|
|
636
|
+
if (destroyed)
|
|
637
|
+
return;
|
|
432
638
|
if (![x, y].every(Number.isFinite))
|
|
433
639
|
throw new RangeError('Pan offsets must be finite screen pixels.');
|
|
640
|
+
validateCameraOptions(options);
|
|
434
641
|
const width = canvas.getBoundingClientRect().width;
|
|
435
|
-
if (width)
|
|
436
|
-
|
|
642
|
+
if (!width)
|
|
643
|
+
throw new Error('Mount the map in a visible container before panning.');
|
|
644
|
+
moveView([view[0] + (x * view[2]) / width, view[1] + (y * view[2]) / width, view[2]], options);
|
|
437
645
|
}
|
|
438
646
|
function getViewport() {
|
|
439
647
|
return [...view];
|
|
440
648
|
}
|
|
441
|
-
function fitGeometry(geometry, padding = fitPadding) {
|
|
649
|
+
function fitGeometry(geometry, padding = fitPadding, options = {}) {
|
|
650
|
+
if (destroyed)
|
|
651
|
+
return;
|
|
442
652
|
const width = canvas.getBoundingClientRect().width;
|
|
443
653
|
if (!width)
|
|
444
654
|
throw new Error('Mount the map in a visible container before fitting geometry.');
|
|
445
|
-
|
|
655
|
+
moveView(fitViewport(projectedBounds(geometry, map.project), width, padding), options);
|
|
446
656
|
}
|
|
447
657
|
function setView(next) {
|
|
448
658
|
if (destroyed)
|
|
@@ -459,14 +669,16 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
459
669
|
root.dispatchEvent(new CustomEvent('viewportchange', { bubbles: true, detail: { viewport: getViewport() } }));
|
|
460
670
|
scheduleLabels();
|
|
461
671
|
}
|
|
462
|
-
function zoomBy(factor) {
|
|
672
|
+
function zoomBy(factor, options = {}) {
|
|
673
|
+
if (destroyed)
|
|
674
|
+
return;
|
|
463
675
|
if (!Number.isFinite(factor) || factor <= 0)
|
|
464
676
|
throw new RangeError('Zoom factor must be positive and finite.');
|
|
465
677
|
const size = Math.max(800 / 12, Math.min(800, view[2] / factor));
|
|
466
|
-
|
|
678
|
+
moveView([view[0] + (view[2] - size) / 2, view[1] + (view[2] - size) / 2, size], options);
|
|
467
679
|
}
|
|
468
|
-
function resetView() {
|
|
469
|
-
|
|
680
|
+
function resetView(options = {}) {
|
|
681
|
+
moveView([0, 0, 800], options);
|
|
470
682
|
}
|
|
471
683
|
function scheduleLabels() {
|
|
472
684
|
if (destroyed)
|
|
@@ -487,8 +699,23 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
487
699
|
for (const station of stationItems)
|
|
488
700
|
station.node.setAttribute('r', String(4.5 * unit));
|
|
489
701
|
for (const item of markerItems) {
|
|
490
|
-
item.dot.setAttribute('r', String((item.marker.id === selectedMarker ?
|
|
491
|
-
|
|
702
|
+
item.dot.setAttribute('r', String(((item.marker.radius ?? markerRadius) + (item.marker.id === selectedMarker ? 2 : 0)) *
|
|
703
|
+
unit));
|
|
704
|
+
item.hit.setAttribute('r', String(Math.max(markerHitSize / 2, (item.marker.radius ?? markerRadius) + 4) * unit));
|
|
705
|
+
item.ring.setAttribute('r', String(((item.marker.radius ?? markerRadius) +
|
|
706
|
+
2 +
|
|
707
|
+
(features.selectedMarkerRing ? features.selectedMarkerRing.gap : 3)) *
|
|
708
|
+
unit));
|
|
709
|
+
}
|
|
710
|
+
drawClusters(unit);
|
|
711
|
+
if (features.scaleBar) {
|
|
712
|
+
const a = map.project([-122.45, 37.76]), b = map.project([-122.44, 37.76]);
|
|
713
|
+
const pixelsPerMeter = Math.abs(b[0] - a[0]) / unit / 879;
|
|
714
|
+
const base = 10 ** Math.floor(Math.log10(100 / pixelsPerMeter));
|
|
715
|
+
const meters = [5, 2, 1].map((factor) => factor * base).find((value) => value * pixelsPerMeter <= 100) ??
|
|
716
|
+
base;
|
|
717
|
+
scale.style.width = `${meters * pixelsPerMeter}px`;
|
|
718
|
+
scale.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`;
|
|
492
719
|
}
|
|
493
720
|
if (!labels) {
|
|
494
721
|
root.dataset.visibleLabels = '0';
|
|
@@ -511,12 +738,12 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
511
738
|
});
|
|
512
739
|
if (enabled('districtLabels'))
|
|
513
740
|
candidates.push(...districtItems);
|
|
514
|
-
if (zoom >= 1.8)
|
|
741
|
+
if (enabled('bartStations') && zoom >= 1.8)
|
|
515
742
|
candidates.push(...stationItems);
|
|
516
|
-
candidates.push(...parkItems.filter((park) => zoom >= 1.8 ||
|
|
743
|
+
candidates.push(...(enabled('landmarks') ? parkItems : []).filter((park) => zoom >= 1.8 ||
|
|
517
744
|
park.name === 'Golden Gate Park' ||
|
|
518
745
|
(width >= 550 && park.name === 'Presidio')));
|
|
519
|
-
candidates.push(...roadItems.filter((road) => road.level === 'primary' || zoom >= 1.8));
|
|
746
|
+
candidates.push(...(enabled('roadLabels') ? roadItems : []).filter((road) => road.level === 'primary' || zoom >= 1.8));
|
|
520
747
|
if (enabled('neighborhoodLabels'))
|
|
521
748
|
candidates.push(...items
|
|
522
749
|
.filter((item) => item.feature !== selected && item.point !== undefined)
|
|
@@ -528,13 +755,19 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
528
755
|
})));
|
|
529
756
|
const measured = candidates.map((item) => {
|
|
530
757
|
const fontSize = item.kind === 'road'
|
|
531
|
-
? Math.min(
|
|
758
|
+
? Math.max(minLabelSize, Math.min(11, maxLabelSize))
|
|
532
759
|
: Math.max(minLabelSize, Math.min(maxLabelSize, 12));
|
|
533
760
|
const node = svgElement('text', {
|
|
534
761
|
'font-size': fontSize * unit,
|
|
535
|
-
'font-weight':
|
|
536
|
-
|
|
537
|
-
|
|
762
|
+
'font-weight': labelStyle.fontWeight ??
|
|
763
|
+
(item.kind.startsWith('selected') || item.kind === 'district' ? 700 : 550),
|
|
764
|
+
fill: colors.label ??
|
|
765
|
+
(item.kind === 'park'
|
|
766
|
+
? (colors.landmark ?? '#426641')
|
|
767
|
+
: item.kind === 'road'
|
|
768
|
+
? '#77736b'
|
|
769
|
+
: '#163d61'),
|
|
770
|
+
stroke: labelStyle.haloColor ?? '#ffffff',
|
|
538
771
|
'stroke-width': 3 * unit,
|
|
539
772
|
'stroke-linejoin': 'round',
|
|
540
773
|
'paint-order': 'stroke',
|
|
@@ -550,24 +783,42 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
550
783
|
textWidth: node.getComputedTextLength() / unit,
|
|
551
784
|
fontSize,
|
|
552
785
|
textHeight: fontSize * 1.25,
|
|
553
|
-
offset: item.kind === 'selected-marker'
|
|
786
|
+
offset: item.kind === 'selected-marker'
|
|
787
|
+
? (marker?.marker.radius ?? markerRadius) + 9
|
|
788
|
+
: item.kind === 'bart'
|
|
789
|
+
? 11
|
|
790
|
+
: 0,
|
|
554
791
|
};
|
|
555
792
|
});
|
|
556
|
-
const stationBounds = stationItems.map(({ point }) => {
|
|
793
|
+
const stationBounds = (enabled('bartStations') ? stationItems : []).map(({ point }) => {
|
|
557
794
|
const x = (point[0] - view[0]) / unit;
|
|
558
795
|
const y = (point[1] - view[1]) / unit;
|
|
559
796
|
return [x - 6, y - 6, x + 6, y + 6];
|
|
560
797
|
});
|
|
561
|
-
const markerBounds = markerItems
|
|
798
|
+
const markerBounds = markerItems
|
|
799
|
+
.filter((item) => item.node.style.display !== 'none')
|
|
800
|
+
.map(({ point, marker }) => {
|
|
801
|
+
const radius = marker.radius ?? markerRadius;
|
|
562
802
|
const x = (point[0] - view[0]) / unit, y = (point[1] - view[1]) / unit;
|
|
803
|
+
return [x - radius - 3, y - radius - 3, x + radius + 3, y + radius + 3];
|
|
804
|
+
});
|
|
805
|
+
const canvasBox = canvas.getBoundingClientRect();
|
|
806
|
+
const furnitureBounds = [...overlayElement.children, ...clusterLayer.children]
|
|
807
|
+
.filter((node) => node.getClientRects().length > 0)
|
|
808
|
+
.map((node) => {
|
|
809
|
+
const box = node.getBoundingClientRect();
|
|
563
810
|
return [
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
811
|
+
box.left - canvasBox.left - 3,
|
|
812
|
+
box.top - canvasBox.top - 3,
|
|
813
|
+
box.right - canvasBox.left + 3,
|
|
814
|
+
box.bottom - canvasBox.top + 3,
|
|
568
815
|
];
|
|
569
816
|
});
|
|
570
|
-
const placed = layoutLabels(measured, width, width, [
|
|
817
|
+
const placed = layoutLabels(measured, width, width, [
|
|
818
|
+
...stationBounds,
|
|
819
|
+
...markerBounds,
|
|
820
|
+
...furnitureBounds,
|
|
821
|
+
]);
|
|
571
822
|
for (const item of measured)
|
|
572
823
|
item.node.remove();
|
|
573
824
|
for (const item of placed) {
|
|
@@ -652,7 +903,12 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
652
903
|
}
|
|
653
904
|
function getSelection() {
|
|
654
905
|
return selected
|
|
655
|
-
? {
|
|
906
|
+
? {
|
|
907
|
+
id: selected.id,
|
|
908
|
+
name: selected.properties.canonicalName,
|
|
909
|
+
source,
|
|
910
|
+
feature: structuredClone(selected),
|
|
911
|
+
}
|
|
656
912
|
: null;
|
|
657
913
|
}
|
|
658
914
|
function selectNeighborhood(name, options = {}) {
|
|
@@ -661,27 +917,33 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
661
917
|
const feature = name === null ? undefined : getNeighborhood(name, source);
|
|
662
918
|
if (name !== null && !feature)
|
|
663
919
|
return false;
|
|
664
|
-
|
|
665
|
-
|
|
920
|
+
validateCameraOptions(options);
|
|
921
|
+
const selectedItem = items.find((item) => item.feature === feature);
|
|
922
|
+
const width = canvas.getBoundingClientRect().width;
|
|
923
|
+
const target = selectedItem && options.fit !== false
|
|
924
|
+
? width
|
|
925
|
+
? fitViewport(selectedItem.bounds, width, fitPadding)
|
|
926
|
+
: fitBounds(selectedItem.bounds)
|
|
927
|
+
: null;
|
|
928
|
+
if (feature &&
|
|
929
|
+
!enabled('neighborhoodLines') &&
|
|
930
|
+
!enabled('neighborhoodLabels') &&
|
|
931
|
+
(layers.neighborhoodLines === undefined || layers.neighborhoodLabels === undefined))
|
|
932
|
+
setMode('neighborhoods', false);
|
|
933
|
+
const revision = ++neighborhoodRevision;
|
|
666
934
|
const changed = selected !== feature;
|
|
667
935
|
selected = feature;
|
|
668
936
|
for (const item of items) {
|
|
669
937
|
const active = item.feature === selected;
|
|
670
|
-
item.node.setAttribute('fill', active ? '#408dbe' : 'transparent');
|
|
938
|
+
item.node.setAttribute('fill', active ? (areaStyle.selectedFill ?? '#408dbe') : 'transparent');
|
|
671
939
|
item.node.setAttribute('fill-opacity', active ? '.16' : '1');
|
|
672
|
-
item.node.setAttribute('stroke', active ? '#176ba2' : '#9caebc');
|
|
940
|
+
item.node.setAttribute('stroke', active ? (areaStyle.selectedStroke ?? '#176ba2') : (colors.neighborhood ?? '#9caebc'));
|
|
673
941
|
item.node.setAttribute('stroke-width', active ? '2.2' : enabled('neighborhoodLines') ? '.55' : '0');
|
|
674
|
-
|
|
942
|
+
if (selectableNeighborhoods)
|
|
943
|
+
item.node.setAttribute('aria-pressed', String(active));
|
|
675
944
|
if (active)
|
|
676
945
|
areas.append(item.node);
|
|
677
946
|
}
|
|
678
|
-
const selectedItem = items.find((item) => item.feature === selected);
|
|
679
|
-
if (selectedItem && options.fit !== false) {
|
|
680
|
-
const width = canvas.getBoundingClientRect().width;
|
|
681
|
-
setView(width
|
|
682
|
-
? fitViewport(selectedItem.bounds, width, fitPadding)
|
|
683
|
-
: fitBounds(selectedItem.bounds));
|
|
684
|
-
}
|
|
685
947
|
if (feature)
|
|
686
948
|
root.dataset.selectedNeighborhood = feature.id;
|
|
687
949
|
else
|
|
@@ -697,12 +959,14 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
697
959
|
root.dispatchEvent(new CustomEvent('neighborhoodchange', {
|
|
698
960
|
bubbles: true,
|
|
699
961
|
detail: {
|
|
700
|
-
feature: feature
|
|
962
|
+
feature: feature ? structuredClone(feature) : null,
|
|
701
963
|
source,
|
|
702
964
|
id: feature?.id ?? null,
|
|
703
965
|
name: feature?.properties.canonicalName ?? null,
|
|
704
966
|
},
|
|
705
967
|
}));
|
|
968
|
+
if (!destroyed && neighborhoodRevision === revision && target)
|
|
969
|
+
moveView(target, options);
|
|
706
970
|
return true;
|
|
707
971
|
}
|
|
708
972
|
function setSource(next) {
|
|
@@ -710,23 +974,36 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
710
974
|
return;
|
|
711
975
|
if (!Object.hasOwn(collections, next))
|
|
712
976
|
throw new RangeError(`Unknown neighborhood source: ${next}`);
|
|
713
|
-
const
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
977
|
+
const collection = collections[next];
|
|
978
|
+
if (!collection ||
|
|
979
|
+
!Array.isArray(collection.features) ||
|
|
980
|
+
typeof collection.title !== 'string' ||
|
|
981
|
+
typeof collection.definition?.description !== 'string')
|
|
982
|
+
throw new TypeError('Neighborhood collections require features, title and definition description.');
|
|
983
|
+
const ids = new Set();
|
|
984
|
+
const nextOptions = [];
|
|
985
|
+
const nextItems = collection.features.map((feature) => {
|
|
986
|
+
if (!feature ||
|
|
987
|
+
typeof feature.id !== 'string' ||
|
|
988
|
+
!feature.id ||
|
|
989
|
+
ids.has(feature.id) ||
|
|
990
|
+
!feature.properties ||
|
|
991
|
+
typeof feature.properties.canonicalName !== 'string' ||
|
|
992
|
+
typeof feature.properties.sourceName !== 'string' ||
|
|
993
|
+
!Array.isArray(feature.properties.aliases) ||
|
|
994
|
+
Array.from(feature.properties.aliases).some((alias) => typeof alias !== 'string') ||
|
|
995
|
+
!['Polygon', 'MultiPolygon'].includes(feature.geometry?.type))
|
|
996
|
+
throw new TypeError('Neighborhood features require unique IDs, names, aliases and polygon geometry.');
|
|
997
|
+
ids.add(feature.id);
|
|
723
998
|
const bounds = projectedBounds(feature.geometry, map.project);
|
|
999
|
+
if (!bounds.every(Number.isFinite))
|
|
1000
|
+
throw new RangeError('Neighborhood geometry must be nonempty and finite.');
|
|
724
1001
|
const node = svgElement('path', {
|
|
725
1002
|
d: geometryPath(feature.geometry, map.project),
|
|
726
1003
|
'data-neighborhood-id': feature.id,
|
|
727
1004
|
fill: 'transparent',
|
|
728
1005
|
'fill-rule': 'evenodd',
|
|
729
|
-
stroke: '#9caebc',
|
|
1006
|
+
stroke: colors.neighborhood ?? '#9caebc',
|
|
730
1007
|
'stroke-width': '.55',
|
|
731
1008
|
'vector-effect': 'non-scaling-stroke',
|
|
732
1009
|
role: 'button',
|
|
@@ -736,11 +1013,27 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
736
1013
|
});
|
|
737
1014
|
const option = element('option', feature.properties.canonicalName);
|
|
738
1015
|
option.value = feature.id;
|
|
739
|
-
|
|
1016
|
+
nextOptions.push(option);
|
|
740
1017
|
const title = svgElement('title');
|
|
741
1018
|
title.textContent = feature.properties.canonicalName;
|
|
742
1019
|
node.append(title);
|
|
743
|
-
|
|
1020
|
+
node.addEventListener('pointerenter', () => {
|
|
1021
|
+
if (destroyed ||
|
|
1022
|
+
!selectableNeighborhoods ||
|
|
1023
|
+
selected?.id === feature.id ||
|
|
1024
|
+
(!areaStyle.hoverFill && !areaStyle.hoverStroke))
|
|
1025
|
+
return;
|
|
1026
|
+
node.setAttribute('fill', areaStyle.hoverFill ?? '#408dbe');
|
|
1027
|
+
node.setAttribute('fill-opacity', '.12');
|
|
1028
|
+
node.setAttribute('stroke', areaStyle.hoverStroke ?? colors.neighborhood ?? '#176ba2');
|
|
1029
|
+
});
|
|
1030
|
+
node.addEventListener('pointerleave', () => {
|
|
1031
|
+
if (destroyed || selected?.id === feature.id)
|
|
1032
|
+
return;
|
|
1033
|
+
node.setAttribute('fill', 'transparent');
|
|
1034
|
+
node.setAttribute('fill-opacity', '1');
|
|
1035
|
+
node.setAttribute('stroke', colors.neighborhood ?? '#9caebc');
|
|
1036
|
+
});
|
|
744
1037
|
return {
|
|
745
1038
|
feature,
|
|
746
1039
|
node,
|
|
@@ -749,28 +1042,72 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
749
1042
|
area: (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]),
|
|
750
1043
|
};
|
|
751
1044
|
});
|
|
1045
|
+
const hadSelection = !!selected;
|
|
1046
|
+
source = next;
|
|
1047
|
+
sourceSelect.value = source;
|
|
1048
|
+
selected = undefined;
|
|
1049
|
+
const revision = ++neighborhoodRevision;
|
|
1050
|
+
delete root.dataset.selectedNeighborhood;
|
|
1051
|
+
root.dataset.source = source;
|
|
1052
|
+
areas.replaceChildren(...nextItems.map((item) => item.node));
|
|
1053
|
+
neighborhoodSelect.replaceChildren(element('option', 'No neighborhood selected'));
|
|
1054
|
+
neighborhoodSelect.options[0].value = '';
|
|
1055
|
+
neighborhoodSelect.append(...nextOptions);
|
|
1056
|
+
items = nextItems;
|
|
752
1057
|
if (items[0])
|
|
753
1058
|
items[0].node.setAttribute('tabindex', '0');
|
|
754
1059
|
updateResults();
|
|
755
1060
|
updateDetail();
|
|
756
1061
|
updateComposition();
|
|
757
|
-
resetView();
|
|
758
|
-
if (hadSelection)
|
|
759
|
-
root.dispatchEvent(new CustomEvent('neighborhoodchange', {
|
|
760
|
-
bubbles: true,
|
|
761
|
-
detail: { feature: null, source, id: null, name: null },
|
|
762
|
-
}));
|
|
763
1062
|
status.textContent =
|
|
764
1063
|
mode === 'basemap'
|
|
765
1064
|
? 'San Francisco basemap.'
|
|
766
1065
|
: mode === 'districts'
|
|
767
1066
|
? `${year} supervisorial districts. Numbers identify each district.`
|
|
768
1067
|
: `${formatSourceLabel(source)}. Select a neighborhood to begin.`;
|
|
1068
|
+
if (hadSelection)
|
|
1069
|
+
root.dispatchEvent(new CustomEvent('neighborhoodchange', {
|
|
1070
|
+
bubbles: true,
|
|
1071
|
+
detail: { feature: null, source, id: null, name: null },
|
|
1072
|
+
}));
|
|
1073
|
+
if (!destroyed && neighborhoodRevision === revision)
|
|
1074
|
+
resetView();
|
|
769
1075
|
}
|
|
770
1076
|
function syncFeatureControls() {
|
|
771
1077
|
featureControls.hidden = neighborhoodLabel.hidden && markerLabel.hidden;
|
|
772
1078
|
}
|
|
1079
|
+
function setLayers(patch) {
|
|
1080
|
+
if (destroyed)
|
|
1081
|
+
return;
|
|
1082
|
+
validateSwitchPatch(patch, layerKeys);
|
|
1083
|
+
const next = { ...layers, ...patch };
|
|
1084
|
+
layers = next;
|
|
1085
|
+
updateComposition();
|
|
1086
|
+
}
|
|
773
1087
|
function updateComposition() {
|
|
1088
|
+
for (const [name, key] of [
|
|
1089
|
+
['landmarks', 'landmarks'],
|
|
1090
|
+
['highways', 'highways'],
|
|
1091
|
+
['key-roads', 'keyRoads'],
|
|
1092
|
+
]) {
|
|
1093
|
+
const layer = svg.querySelector(`[data-layer="${name}"]`);
|
|
1094
|
+
if (layer)
|
|
1095
|
+
layer.style.display = enabled(key) ? '' : 'none';
|
|
1096
|
+
}
|
|
1097
|
+
stations.style.display = enabled('bartStations') ? '' : 'none';
|
|
1098
|
+
for (const [kind, key] of [
|
|
1099
|
+
['bart', 'bartStations'],
|
|
1100
|
+
['park', 'landmarks'],
|
|
1101
|
+
['road', 'keyRoads'],
|
|
1102
|
+
['highway', 'highways'],
|
|
1103
|
+
]) {
|
|
1104
|
+
const entry = legend.querySelector(`.sf-explorer-legend-${kind}`)?.parentElement;
|
|
1105
|
+
if (entry)
|
|
1106
|
+
entry.hidden = !enabled(key);
|
|
1107
|
+
}
|
|
1108
|
+
legend.hidden =
|
|
1109
|
+
controls.legend === false ||
|
|
1110
|
+
![...legend.children].some((node) => !node.hidden);
|
|
774
1111
|
const neighborhoodsVisible = enabled('neighborhoodLines') || (labels && enabled('neighborhoodLabels'));
|
|
775
1112
|
areas.style.display = neighborhoodsVisible ? '' : 'none';
|
|
776
1113
|
areas.style.pointerEvents = selectableNeighborhoods ? '' : 'none';
|
|
@@ -828,44 +1165,58 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
828
1165
|
const item = markerItems.find((item) => item.marker.id === id);
|
|
829
1166
|
if (id !== null && !item)
|
|
830
1167
|
return false;
|
|
1168
|
+
validateCameraOptions(options);
|
|
1169
|
+
const width = canvas.getBoundingClientRect().width;
|
|
1170
|
+
const target = item && options.fit !== false
|
|
1171
|
+
? width
|
|
1172
|
+
? fitViewport([...item.point, ...item.point], width, fitPadding)
|
|
1173
|
+
: fitBounds([...item.point, ...item.point])
|
|
1174
|
+
: null;
|
|
1175
|
+
const revision = ++markerRevision;
|
|
831
1176
|
const changed = selectedMarker !== id;
|
|
832
1177
|
selectedMarker = id;
|
|
1178
|
+
if (changed)
|
|
1179
|
+
invalidateClusters();
|
|
833
1180
|
markerSelect.value = id ?? '';
|
|
834
1181
|
for (const entry of markerItems) {
|
|
835
1182
|
const active = entry.marker.id === id;
|
|
836
1183
|
entry.node.setAttribute('aria-pressed', String(active));
|
|
1184
|
+
entry.ring.setAttribute('display', active && features.selectedMarkerRing ? 'inline' : 'none');
|
|
837
1185
|
entry.dot.setAttribute('fill', active ? selectedMarkerColor : (entry.marker.color ?? markerColor));
|
|
838
1186
|
if (active)
|
|
839
1187
|
markerLayer.append(entry.node);
|
|
840
1188
|
}
|
|
841
|
-
if (item && options.fit !== false) {
|
|
842
|
-
const width = canvas.getBoundingClientRect().width;
|
|
843
|
-
const bounds = [...item.point, ...item.point];
|
|
844
|
-
setView(width ? fitViewport(bounds, width, fitPadding) : fitBounds(bounds));
|
|
845
|
-
}
|
|
846
1189
|
scheduleLabels();
|
|
847
1190
|
const marker = getSelectedMarker();
|
|
848
1191
|
if (changed)
|
|
849
1192
|
root.dispatchEvent(new CustomEvent('markerchange', { bubbles: true, detail: { id, marker } }));
|
|
850
|
-
if (changed && marker)
|
|
1193
|
+
if (!destroyed && changed && marker && markerRevision === revision && selectedMarker === id)
|
|
851
1194
|
onMarkerActivate?.({ ...marker });
|
|
1195
|
+
if (!destroyed && markerRevision === revision && target)
|
|
1196
|
+
moveView(target, options);
|
|
852
1197
|
return true;
|
|
853
1198
|
}
|
|
854
1199
|
function setMarkers(markers) {
|
|
855
1200
|
if (destroyed)
|
|
856
1201
|
return;
|
|
1202
|
+
validateMarkers(markers);
|
|
857
1203
|
const ids = new Set();
|
|
858
1204
|
const next = markers.map((marker) => {
|
|
859
|
-
if (typeof marker.id !== 'string' || !marker.id || ids.has(marker.id))
|
|
860
|
-
throw new RangeError('Markers require unique nonempty IDs.');
|
|
861
1205
|
ids.add(marker.id);
|
|
862
1206
|
return { marker: { ...marker }, point: map.project([marker.lng, marker.lat]) };
|
|
863
1207
|
});
|
|
1208
|
+
const focusedMarker = markerItems.find((item) => item.node === document.activeElement)?.marker
|
|
1209
|
+
.id;
|
|
1210
|
+
invalidateClusters();
|
|
1211
|
+
const previousIds = new Set(markerItems.map((item) => item.marker.id));
|
|
1212
|
+
for (const animation of entranceAnimations)
|
|
1213
|
+
animation.cancel();
|
|
1214
|
+
entranceAnimations.clear();
|
|
864
1215
|
const previous = selectedMarker;
|
|
865
1216
|
markerLayer.replaceChildren();
|
|
866
1217
|
markerSelect.replaceChildren(element('option', 'No marker selected'));
|
|
867
1218
|
markerSelect.options[0].value = '';
|
|
868
|
-
markerItems = next.map(({ marker, point }) => {
|
|
1219
|
+
markerItems = next.map(({ marker, point }, index) => {
|
|
869
1220
|
const node = svgElement('g', {
|
|
870
1221
|
transform: `translate(${point[0]},${point[1]})`,
|
|
871
1222
|
'data-marker-id': marker.id,
|
|
@@ -884,12 +1235,39 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
884
1235
|
});
|
|
885
1236
|
const title = svgElement('title');
|
|
886
1237
|
title.textContent = marker.label ?? marker.id;
|
|
887
|
-
|
|
1238
|
+
const ring = svgElement('circle', {
|
|
1239
|
+
fill: 'none',
|
|
1240
|
+
stroke: features.selectedMarkerRing
|
|
1241
|
+
? (features.selectedMarkerRing.color ?? selectedMarkerColor)
|
|
1242
|
+
: selectedMarkerColor,
|
|
1243
|
+
'stroke-width': features.selectedMarkerRing ? features.selectedMarkerRing.width : 2,
|
|
1244
|
+
'vector-effect': 'non-scaling-stroke',
|
|
1245
|
+
'pointer-events': 'none',
|
|
1246
|
+
display: 'none',
|
|
1247
|
+
});
|
|
1248
|
+
node.style.setProperty('--sf-marker-index', String(index));
|
|
1249
|
+
node.append(title, hit, ring, dot);
|
|
1250
|
+
if (features.markerEntrance &&
|
|
1251
|
+
!previousIds.has(marker.id) &&
|
|
1252
|
+
!reducedMotion.matches &&
|
|
1253
|
+
typeof dot.animate === 'function') {
|
|
1254
|
+
const animation = dot.animate([
|
|
1255
|
+
{ opacity: 0, transform: 'translateY(-12px)' },
|
|
1256
|
+
{ opacity: 1, transform: 'translateY(0)' },
|
|
1257
|
+
], {
|
|
1258
|
+
duration: features.markerEntrance.duration,
|
|
1259
|
+
delay: Math.min(index * features.markerEntrance.stagger, 1000),
|
|
1260
|
+
easing: 'cubic-bezier(.2,.8,.2,1)',
|
|
1261
|
+
fill: 'backwards',
|
|
1262
|
+
});
|
|
1263
|
+
entranceAnimations.add(animation);
|
|
1264
|
+
animation.onfinish = () => entranceAnimations.delete(animation);
|
|
1265
|
+
}
|
|
888
1266
|
markerLayer.append(node);
|
|
889
1267
|
const option = element('option', marker.label ?? marker.id);
|
|
890
1268
|
option.value = marker.id;
|
|
891
1269
|
markerSelect.append(option);
|
|
892
|
-
return { marker, point, node, dot, hit };
|
|
1270
|
+
return { marker, point, node, dot, hit, ring };
|
|
893
1271
|
});
|
|
894
1272
|
markerLabel.hidden = !markerItems.length || controls.markerPicker === false;
|
|
895
1273
|
if (markerLabel.firstChild)
|
|
@@ -899,32 +1277,43 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
899
1277
|
? previous
|
|
900
1278
|
: (markers.find((marker) => marker.selected)?.id ?? null);
|
|
901
1279
|
selectMarker(nextSelection, { fit: false });
|
|
1280
|
+
if (focusedMarker)
|
|
1281
|
+
(markerItems.find((item) => item.marker.id === focusedMarker)?.node ?? svg).focus({
|
|
1282
|
+
preventScroll: true,
|
|
1283
|
+
});
|
|
902
1284
|
}
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
labelsButton.hidden =
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
1285
|
+
function setControls(patch) {
|
|
1286
|
+
if (destroyed)
|
|
1287
|
+
return;
|
|
1288
|
+
validateSwitchPatch(patch, controlKeys);
|
|
1289
|
+
const next = { ...controls, ...patch };
|
|
1290
|
+
controls = next;
|
|
1291
|
+
for (const node of [zoomIn, zoomOut, zoomText])
|
|
1292
|
+
node.hidden = controls.zoom === false;
|
|
1293
|
+
panControls.hidden = controls.pan === false;
|
|
1294
|
+
resetButton.hidden = controls.reset === false;
|
|
1295
|
+
labelsButton.hidden = controls.labels === false;
|
|
1296
|
+
touchButton.hidden = controls.touch === false;
|
|
1297
|
+
if (touchButton.hidden && touchNavigation)
|
|
1298
|
+
setTouchNavigation(false);
|
|
1299
|
+
zoomControls.hidden = [...zoomControls.children].every((node) => node.hidden);
|
|
1300
|
+
toolbar.hidden = zoomControls.hidden && panControls.hidden;
|
|
1301
|
+
hint.hidden = controls.help === false;
|
|
1302
|
+
if (hint.hidden)
|
|
1303
|
+
svg.setAttribute('aria-describedby', hint.id);
|
|
1304
|
+
else
|
|
1305
|
+
svg.removeAttribute('aria-describedby');
|
|
1306
|
+
status.classList.toggle('sf-explorer-visually-hidden', controls.status === false);
|
|
1307
|
+
markerLabel.hidden = !markerItems.length || controls.markerPicker === false;
|
|
1308
|
+
updateComposition();
|
|
920
1309
|
}
|
|
921
|
-
|
|
922
|
-
status.classList.add('sf-explorer-visually-hidden');
|
|
923
|
-
function setMode(next) {
|
|
1310
|
+
function setMode(next, reset = true) {
|
|
924
1311
|
if (destroyed)
|
|
925
1312
|
return;
|
|
926
1313
|
if (!['neighborhoods', 'districts', 'basemap'].includes(next))
|
|
927
1314
|
throw new RangeError(`Unknown map mode: ${next}`);
|
|
1315
|
+
if (next === 'neighborhoods' && !sources.length)
|
|
1316
|
+
throw new RangeError('No neighborhood dataset was supplied.');
|
|
928
1317
|
if (next === 'districts' && !data.districts?.[year])
|
|
929
1318
|
throw new RangeError(`No ${year} district dataset was supplied.`);
|
|
930
1319
|
mode = next;
|
|
@@ -937,7 +1326,6 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
937
1326
|
node.hidden = districts;
|
|
938
1327
|
updateComposition();
|
|
939
1328
|
updateDetail();
|
|
940
|
-
resetView();
|
|
941
1329
|
status.textContent =
|
|
942
1330
|
mode === 'basemap'
|
|
943
1331
|
? 'San Francisco basemap.'
|
|
@@ -946,6 +1334,8 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
946
1334
|
: selected
|
|
947
1335
|
? `${selected.properties.canonicalName} selected. ${formatSourceLabel(source)}.`
|
|
948
1336
|
: `${formatSourceLabel(source)}. Select a neighborhood to begin.`;
|
|
1337
|
+
if (reset)
|
|
1338
|
+
resetView();
|
|
949
1339
|
}
|
|
950
1340
|
function setLabels(visible) {
|
|
951
1341
|
if (destroyed)
|
|
@@ -991,10 +1381,13 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
991
1381
|
selectNeighborhood(node.dataset.neighborhoodResult);
|
|
992
1382
|
});
|
|
993
1383
|
const touchLabel = strings.touchNavigation ?? 'Touch navigation';
|
|
994
|
-
const touchButton = button(touchLabel, `Enable ${touchLabel.toLowerCase()}`, () => setTouchNavigation(!touchNavigation));
|
|
1384
|
+
const touchButton = button(touchLabel, strings.touchNavigationLabel ?? `Enable ${touchLabel.toLowerCase()}`, () => setTouchNavigation(!touchNavigation));
|
|
995
1385
|
touchButton.hidden = controls.touch === false;
|
|
996
1386
|
let touchNavigation = false;
|
|
997
|
-
const navigation = attachNavigation(svg, getViewport,
|
|
1387
|
+
const navigation = attachNavigation(svg, getViewport, (next) => {
|
|
1388
|
+
stopAnimation();
|
|
1389
|
+
setView(next);
|
|
1390
|
+
}, controller.signal, () => setTouchNavigation(false));
|
|
998
1391
|
function setTouchNavigation(enabled) {
|
|
999
1392
|
if (destroyed)
|
|
1000
1393
|
return;
|
|
@@ -1003,8 +1396,12 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
1003
1396
|
touchNavigation = enabled;
|
|
1004
1397
|
navigation.setTouchNavigation(enabled);
|
|
1005
1398
|
canvas.style.touchAction = enabled ? 'none' : 'pan-y pinch-zoom';
|
|
1006
|
-
touchButton.textContent = enabled
|
|
1007
|
-
|
|
1399
|
+
touchButton.textContent = enabled
|
|
1400
|
+
? (strings.touchNavigationDone ?? 'Done: page scrolling')
|
|
1401
|
+
: touchLabel;
|
|
1402
|
+
touchButton.setAttribute('aria-label', enabled
|
|
1403
|
+
? (strings.touchNavigationExitLabel ?? `Exit ${touchLabel.toLowerCase()}`)
|
|
1404
|
+
: (strings.touchNavigationLabel ?? `Enable ${touchLabel.toLowerCase()}`));
|
|
1008
1405
|
touchButton.setAttribute('aria-pressed', String(enabled));
|
|
1009
1406
|
root.dataset.touchNavigation = String(enabled);
|
|
1010
1407
|
}
|
|
@@ -1029,6 +1426,7 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
1029
1426
|
next.node.focus();
|
|
1030
1427
|
}
|
|
1031
1428
|
});
|
|
1429
|
+
listen(markerLayer, 'focusout', scheduleLabels);
|
|
1032
1430
|
listen(markerLayer, 'click', (event) => {
|
|
1033
1431
|
const node = event.target instanceof Element ? event.target.closest('[data-marker-id]') : null;
|
|
1034
1432
|
if (node?.dataset.markerId)
|
|
@@ -1049,16 +1447,25 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
1049
1447
|
if (selectableNeighborhoods && node?.dataset.neighborhoodId)
|
|
1050
1448
|
selectNeighborhood(node.dataset.neighborhoodId);
|
|
1051
1449
|
});
|
|
1052
|
-
|
|
1450
|
+
listen(svg, 'pointerdown', stopAnimation);
|
|
1451
|
+
let observer;
|
|
1452
|
+
const onResize = () => {
|
|
1053
1453
|
navigation.cancel();
|
|
1054
1454
|
scheduleLabels();
|
|
1055
|
-
|
|
1056
|
-
|
|
1455
|
+
root.dispatchEvent(new CustomEvent('mapresize', { bubbles: true }));
|
|
1456
|
+
};
|
|
1057
1457
|
const explorer = Object.assign(root, {
|
|
1058
1458
|
selectNeighborhood,
|
|
1059
1459
|
getSelection,
|
|
1060
1460
|
getViewport,
|
|
1061
|
-
setViewport:
|
|
1461
|
+
setViewport: moveView,
|
|
1462
|
+
stopAnimation,
|
|
1463
|
+
setFeatures,
|
|
1464
|
+
getFeatures,
|
|
1465
|
+
setLayers,
|
|
1466
|
+
setControls,
|
|
1467
|
+
overlayElement,
|
|
1468
|
+
projectToScreen,
|
|
1062
1469
|
fitGeometry,
|
|
1063
1470
|
panBy,
|
|
1064
1471
|
setTouchNavigation,
|
|
@@ -1073,15 +1480,30 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
1073
1480
|
zoomBy,
|
|
1074
1481
|
destroy() {
|
|
1075
1482
|
destroyed = true;
|
|
1483
|
+
camera.destroy();
|
|
1484
|
+
cancelEntrances();
|
|
1076
1485
|
controller.abort();
|
|
1077
|
-
|
|
1486
|
+
overlayEvents.abort();
|
|
1487
|
+
clusterEvents.abort();
|
|
1488
|
+
canvas.style.touchAction = 'pan-y pinch-zoom';
|
|
1489
|
+
observer?.disconnect();
|
|
1078
1490
|
cancelAnimationFrame(frame);
|
|
1079
1491
|
releaseDownloads();
|
|
1080
1492
|
},
|
|
1081
1493
|
});
|
|
1082
1494
|
try {
|
|
1495
|
+
observer = new ResizeObserver(onResize);
|
|
1496
|
+
observer.observe(canvas);
|
|
1497
|
+
document.fonts?.addEventListener('loadingdone', scheduleLabels, { signal: controller.signal });
|
|
1498
|
+
reducedMotion.addEventListener('change', () => {
|
|
1499
|
+
if (reducedMotion.matches) {
|
|
1500
|
+
stopAnimation();
|
|
1501
|
+
cancelEntrances();
|
|
1502
|
+
}
|
|
1503
|
+
}, { signal: controller.signal });
|
|
1083
1504
|
const initialMode = mode;
|
|
1084
|
-
|
|
1505
|
+
if (sources.length)
|
|
1506
|
+
setSource(source);
|
|
1085
1507
|
if (neighborhood !== undefined)
|
|
1086
1508
|
selectNeighborhood(neighborhood);
|
|
1087
1509
|
setMode(initialMode);
|
|
@@ -1092,8 +1514,11 @@ export function createNeighborhoodExplorerCore({ source = 'realtor', mode = 'nei
|
|
|
1092
1514
|
}
|
|
1093
1515
|
setLabels(labels);
|
|
1094
1516
|
setTouchNavigation(false);
|
|
1517
|
+
setLayers(layers);
|
|
1518
|
+
setControls(controls);
|
|
1095
1519
|
setMarkers(initialMarkers);
|
|
1096
1520
|
setOverlays(initialOverlays);
|
|
1521
|
+
initialized = true;
|
|
1097
1522
|
}
|
|
1098
1523
|
catch (error) {
|
|
1099
1524
|
explorer.destroy();
|