@kahwee/sf-map-svg 1.5.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +145 -6
  3. package/dist/data/README.md +5 -1
  4. package/dist/data/types.d.ts +4 -1
  5. package/dist/src/api.d.ts +3 -0
  6. package/dist/src/api.js +3 -0
  7. package/dist/src/camera.d.ts +16 -0
  8. package/dist/src/camera.js +55 -0
  9. package/dist/src/clusters.d.ts +7 -0
  10. package/dist/src/clusters.js +33 -0
  11. package/dist/src/configuration.d.ts +5 -0
  12. package/dist/src/configuration.js +73 -0
  13. package/dist/src/controller-types.d.ts +84 -0
  14. package/dist/src/controller-types.js +1 -0
  15. package/dist/src/explorer-core.d.ts +1 -1
  16. package/dist/src/explorer-core.js +570 -145
  17. package/dist/src/features.d.ts +24 -0
  18. package/dist/src/features.js +99 -0
  19. package/dist/src/guide-data.js +3 -2
  20. package/dist/src/guide-detailed.js +3 -2
  21. package/dist/src/guide-map.d.ts +4 -0
  22. package/dist/src/guide-map.js +35 -10
  23. package/dist/src/guide-shell.d.ts +2 -0
  24. package/dist/src/guide-shell.js +16 -0
  25. package/dist/src/guide-static.d.ts +9 -0
  26. package/dist/src/guide-static.js +26 -0
  27. package/dist/src/guide.d.ts +2 -1
  28. package/dist/src/guide.js +1 -1
  29. package/dist/src/interactive-data.d.ts +1 -1
  30. package/dist/src/interactive-data.js +2 -0
  31. package/dist/src/interactive.d.ts +1 -1
  32. package/dist/src/interactive.js +2 -0
  33. package/dist/src/layers.js +3 -1
  34. package/dist/src/map-core.js +3 -8
  35. package/dist/src/map.d.ts +7 -0
  36. package/dist/src/map.js +106 -0
  37. package/dist/src/presets.d.ts +3 -0
  38. package/dist/src/presets.js +17 -0
  39. package/dist/src/static.d.ts +10 -0
  40. package/dist/src/static.js +5 -0
  41. package/dist/src/types.d.ts +73 -9
  42. package/dist/src/validation.d.ts +7 -0
  43. package/dist/src/validation.js +226 -0
  44. package/dist/src/viewport.js +9 -2
  45. package/docs/EXAMPLES.md +11 -1
  46. package/docs/api-audit.md +80 -0
  47. package/docs/consumer-integration.md +165 -0
  48. package/docs/guide-bundle-report.md +19 -0
  49. package/docs/migration-v2.md +238 -0
  50. package/package.json +39 -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({ source = 'realtor', mode = 'neighborhoods', labels = true, neighborhood, year = 2022, theme = 'transit', interface: chrome = 'explorer', layers = {}, selectableNeighborhoods = true, labelSize = {}, fitPadding = 24, markers: initialMarkers = [], markerRadius = 6, markerHitSize = 44, markerColor = '#245b61', selectedMarkerColor = '#f04f32', onMarkerActivate, overlays: initialOverlays = [], onOverlayActivate, style: styleOptions = {}, strings = {}, controls = {}, } = {}, data) {
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:#fff;min-height:44px;padding:9px 12px}
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:#fff;border-top:1px solid #dce5eb;color:#496578;font-size:12px}
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:#fff;color:#496578}
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
- column.append(toolbar, featureControls, canvas, legend, hint, status, attribution);
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: enabled('highways'),
263
- keyRoads: enabled('keyRoads'),
317
+ highways: true,
318
+ keyRoads: true,
264
319
  roadLabels: false,
265
- landmarks: enabled('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 = (enabled('bartStations') ? (data.map.bartStations ?? []) : []).map((feature) => {
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 = (enabled('roadLabels') ? (data.map.keyRoads ?? []) : []).map((feature) => ({
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 = (enabled('landmarks') ? (data.map.landmarks ?? []) : []).map((feature) => ({
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
- const ids = new Set();
356
- const next = overlays.map((overlay) => {
357
- if (!/^[A-Za-z0-9_-]+$/.test(overlay.id) || ids.has(overlay.id))
358
- throw new RangeError('Overlays require unique IDs containing only letters, numbers, underscores, or hyphens.');
359
- if (overlay.strokeWidth !== undefined &&
360
- (!Number.isFinite(overlay.strokeWidth) || overlay.strokeWidth < 0))
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
- onOverlayActivate(overlay);
393
- root.dispatchEvent(new CustomEvent('overlayactivate', { bubbles: true, detail: { overlay } }));
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: controller.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: controller.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, () => setView([view[0] + (dx * view[2]) / 4, view[1] + (dy * view[2]) / 4, view[2]]), panControls);
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
- setView([view[0] + (x * view[2]) / width, view[1] + (y * view[2]) / width, view[2]]);
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
- setView(fitViewport(projectedBounds(geometry, map.project), width, padding));
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
- setView([view[0] + (view[2] - size) / 2, view[1] + (view[2] - size) / 2, size]);
678
+ moveView([view[0] + (view[2] - size) / 2, view[1] + (view[2] - size) / 2, size], options);
467
679
  }
468
- function resetView() {
469
- setView([0, 0, 800]);
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 ? markerRadius + 2 : markerRadius) * unit));
491
- item.hit.setAttribute('r', String((markerHitSize / 2) * unit));
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(9, maxLabelSize)
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': item.kind.startsWith('selected') || item.kind === 'district' ? 700 : 550,
536
- fill: item.kind === 'park' ? '#426641' : item.kind === 'road' ? '#77736b' : '#163d61',
537
- stroke: '#ffffff',
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' ? markerRadius + 7 : item.kind === 'bart' ? 11 : 0,
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.map(({ point }) => {
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
- x - markerRadius - 3,
565
- y - markerRadius - 3,
566
- x + markerRadius + 3,
567
- y + markerRadius + 3,
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, [...stationBounds, ...markerBounds]);
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
- ? { id: selected.id, name: selected.properties.canonicalName, source, feature: selected }
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
- if (feature && !enabled('neighborhoodLines') && !enabled('neighborhoodLabels'))
665
- setMode('neighborhoods');
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
- item.node.setAttribute('aria-pressed', String(active));
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 ?? null,
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 hadSelection = !!selected;
714
- source = next;
715
- sourceSelect.value = source;
716
- selected = undefined;
717
- delete root.dataset.selectedNeighborhood;
718
- root.dataset.source = source;
719
- areas.replaceChildren();
720
- neighborhoodSelect.replaceChildren(element('option', 'No neighborhood selected'));
721
- neighborhoodSelect.options[0].value = '';
722
- items = (collections[source]?.features ?? []).map((feature) => {
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
- neighborhoodSelect.append(option);
1016
+ nextOptions.push(option);
740
1017
  const title = svgElement('title');
741
1018
  title.textContent = feature.properties.canonicalName;
742
1019
  node.append(title);
743
- areas.append(node);
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
- node.append(title, hit, dot);
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
- if (controls.zoom === false)
904
- zoomControls.hidden = true;
905
- if (controls.pan === false)
906
- panControls.hidden = true;
907
- if (controls.reset === false) {
908
- const reset = Array.from(toolbar.querySelectorAll('button')).find((node) => node.textContent === (strings.reset ?? 'Reset'));
909
- if (reset)
910
- reset.hidden = true;
911
- }
912
- if (controls.labels === false)
913
- labelsButton.hidden = true;
914
- if (controls.legend === false)
915
- legend.hidden = true;
916
- if (controls.help === false) {
917
- // The instructions stay available to assistive technology as the map's description.
918
- hint.hidden = true;
919
- svg.setAttribute('aria-describedby', hint.id);
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
- if (controls.status === false)
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, setView, controller.signal, () => setTouchNavigation(false));
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 ? 'Done: page scrolling' : touchLabel;
1007
- touchButton.setAttribute('aria-label', enabled ? `Exit ${touchLabel.toLowerCase()}` : `Enable ${touchLabel.toLowerCase()}`);
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
- const observer = new ResizeObserver(() => {
1450
+ listen(svg, 'pointerdown', stopAnimation);
1451
+ let observer;
1452
+ const onResize = () => {
1053
1453
  navigation.cancel();
1054
1454
  scheduleLabels();
1055
- });
1056
- observer.observe(canvas);
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: setView,
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
- observer.disconnect();
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
- setSource(source);
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();