@kahwee/sf-map-svg 4.3.0 → 4.3.2

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.
@@ -1,11 +1,12 @@
1
1
  import { copyAppearance } from './appearance.js';
2
2
  import { createCamera, validateCameraOptions } from './camera.js';
3
+ import { createClusterRenderer } from './cluster-renderer.js';
3
4
  import { clusterPoints } from './clusters.js';
4
5
  import { renderDistrictAppearance } from './district-layer.js';
5
6
  import { createDistrictMorph } from './district-morph.js';
6
7
  import { prepareDistrictStyles } from './district-style.js';
7
8
  import { createDistrictTransition } from './district-transition.js';
8
- import { element, svgElement } from './dom.js';
9
+ import { element, setAttributeIfChanged, svgElement } from './dom.js';
9
10
  import { fitBounds, interiorAnchor, projectedBounds } from './explorer-layout.js';
10
11
  import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from './features.js';
11
12
  import { createFrameScheduler } from './frame-scheduler.js';
@@ -13,9 +14,12 @@ import { geometryPath } from './geometry.js';
13
14
  import { createLabelRenderer } from './label-renderer.js';
14
15
  import { createLayerTransitions } from './layer-transition.js';
15
16
  import { createSFMapWithData, districtColors, getLayerPathsWithData, resolveMapColors, } from './map-core.js';
17
+ import { createMarkerCatalog } from './marker-catalog.js';
18
+ import { createMarkerChooser } from './marker-chooser.js';
16
19
  import { createMarkerLayer } from './marker-layer.js';
20
+ import { createMarkerNavigation } from './marker-navigation.js';
17
21
  import { attachNavigation } from './navigation.js';
18
- import { validateExplorerOptions, validateMarkers, validateOverlays } from './validation.js';
22
+ import { validateExplorerOptions, validateOverlays } from './validation.js';
19
23
  import { fitViewport, validateViewport } from './viewport.js';
20
24
  const sourceNames = {
21
25
  realtor: 'SFAR realtor · 92 areas',
@@ -160,6 +164,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
160
164
  .sf-explorer-toolbar button{min-width:44px;padding:6px 10px}
161
165
  .sf-explorer-zoom{font-size:12px;min-width:42px;text-align:center;font-variant-numeric:tabular-nums;color:#496578}
162
166
  .sf-explorer-canvas{position:relative;aspect-ratio:1;overflow:hidden;touch-action:pan-y pinch-zoom}
167
+ .sf-marker-chooser{position:absolute;z-index:2;top:12px;right:12px;display:flex;flex-direction:column;gap:6px;box-sizing:border-box;width:280px;max-width:calc(100% - 24px);max-height:calc(100% - 24px);overflow:auto;padding:12px;border:1px solid var(--sf-map-border);border-radius:8px;background:var(--sf-map-surface);color:var(--sf-map-ink);box-shadow:0 6px 20px #0003}
168
+ .sf-marker-chooser button{min-height:44px;text-align:left;overflow-wrap:anywhere}
163
169
  .sf-explorer-canvas>svg{display:block;width:100%;height:100%;max-width:none;cursor:grab;user-select:none}
164
170
  .sf-explorer-canvas>svg:active{cursor:grabbing}
165
171
  .sf-explorer-canvas path[data-neighborhood-id]{cursor:pointer}
@@ -546,6 +552,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
546
552
  }
547
553
  updateDistrictAppearance();
548
554
  let items = [];
555
+ let orderedNeighborhoods = [];
549
556
  let destroyed = false;
550
557
  let initialized = false;
551
558
  const layerFades = createLayerTransitions(() => initialized && features.layerTransitions && !reducedMotion.matches
@@ -557,7 +564,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
557
564
  cancel: (id) => cancelAnimationFrame(id),
558
565
  render: drawLabels,
559
566
  });
560
- const markerRenderer = createMarkerLayer(markerLayer);
567
+ const markerRenderer = createMarkerLayer();
561
568
  const cancelEntrances = markerRenderer.cancelEntrances;
562
569
  const camera = createCamera({
563
570
  read: getViewport,
@@ -593,11 +600,13 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
593
600
  if (Object.hasOwn(patch, 'clustering'))
594
601
  invalidateClusters();
595
602
  syncFurniture();
596
- for (const item of markerItems) {
603
+ for (const { visual, marker } of markerCatalog.items) {
604
+ if (!visual)
605
+ continue;
597
606
  const ring = features.selectedMarkerRing;
598
- item.ring.setAttribute('display', ring && item.marker.id === selectedMarker ? 'inline' : 'none');
599
- item.ring.setAttribute('stroke', ring ? (ring.color ?? selectedMarkerColor) : selectedMarkerColor);
600
- item.ring.setAttribute('stroke-width', String(ring ? ring.width : 2));
607
+ visual.ring.setAttribute('display', ring && marker.id === selectedMarker ? 'inline' : 'none');
608
+ visual.ring.setAttribute('stroke', ring ? (ring.color ?? selectedMarkerColor) : selectedMarkerColor);
609
+ visual.ring.setAttribute('stroke-width', String(ring ? ring.width : 2));
601
610
  }
602
611
  scheduleLabels();
603
612
  }
@@ -630,100 +639,77 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
630
639
  level: feature.level ?? 'primary',
631
640
  kind: 'road',
632
641
  }));
642
+ const secondaryRoads = svg.querySelectorAll('[data-key-road-level="secondary"]');
643
+ let secondaryRoadsVisible;
644
+ const scaleUnitsPerMeter = Math.abs(map.project([-122.44, 37.76])[0] - map.project([-122.45, 37.76])[0]) / 879;
633
645
  const parkItems = (data.map.landmarks ?? []).map((feature) => ({
634
646
  point: map.project(feature.label),
635
647
  name: feature.name,
636
648
  kind: 'park',
637
649
  }));
638
- let markerItems = [];
650
+ const markerCatalog = createMarkerCatalog(markerRenderer, markerSelect, map.project);
639
651
  let selectedMarker = null;
652
+ const markerNavigation = createMarkerNavigation(svg, controller.signal);
653
+ const markerChooser = createMarkerChooser(canvas, svg, (id) => {
654
+ if (destroyed)
655
+ return;
656
+ selectMarker(id);
657
+ if (!destroyed && selectedMarker === id)
658
+ markerCatalog.items
659
+ .find((item) => item.marker.id === id)
660
+ ?.visual?.node.focus({ preventScroll: true });
661
+ });
640
662
  let markerRevision = 0;
641
663
  let neighborhoodRevision = 0;
642
664
  const clusterLayer = svgElement('g', { 'data-layer': 'marker-clusters' });
643
665
  geography.insertBefore(clusterLayer, markerLayer);
644
- let clusterSignature = '';
645
- let clusterEvents = new AbortController();
666
+ function materializeMarker(item) {
667
+ return Object.assign(item, {
668
+ visual: markerRenderer.materialize(item, {
669
+ features,
670
+ markerColor,
671
+ selectedMarkerColor,
672
+ reducedMotion: reducedMotion.matches,
673
+ selected: item.marker.id === selectedMarker,
674
+ }),
675
+ });
676
+ }
677
+ const clusters = createClusterRenderer(svg, markerLayer, clusterLayer, markerNavigation, materializeMarker, (group, node, signal) => {
678
+ if (destroyed || signal.aborted)
679
+ return;
680
+ const markers = group.map((item) => ({ ...item.marker }));
681
+ const minimumUnit = 800 / 12 / Math.max(1, canvas.getBoundingClientRect().width);
682
+ if (clusterPoints(group, minimumUnit, features.clustering ? features.clustering.radius : 36)
683
+ .length === 1)
684
+ markerChooser.open(markers, node);
685
+ else
686
+ fitGeometry({
687
+ type: 'MultiPoint',
688
+ coordinates: markers.map((marker) => [marker.lng, marker.lat]),
689
+ });
690
+ if (!destroyed && !signal.aborted)
691
+ root.dispatchEvent(new CustomEvent('clusteractivate', { bubbles: true, detail: { markers } }));
692
+ });
693
+ let renderedMarkers = [];
694
+ let renderedClusters = [];
646
695
  function invalidateClusters() {
647
- clusterEvents.abort();
648
- clusterEvents = new AbortController();
649
- clusterSignature = '';
650
- if (clusterLayer.contains(document.activeElement))
651
- svg.focus({ preventScroll: true });
652
- clusterLayer.replaceChildren();
653
- for (const item of markerItems)
654
- item.node.style.display = '';
696
+ markerChooser.close();
697
+ clusters.invalidate();
655
698
  }
656
699
  function drawClusters(unit) {
657
- for (const item of markerItems)
658
- item.node.style.display = '';
659
- const eligible = markerItems.filter((item) => item.marker.id !== selectedMarker && document.activeElement !== item.node);
660
- const groups = features.clustering
661
- ? clusterPoints(eligible, unit, features.clustering.radius)
662
- : [];
663
- const clusters = groups.filter((group) => group.length > 1);
664
- for (const group of clusters)
665
- for (const item of group)
666
- item.node.style.display = 'none';
667
- const signature = JSON.stringify(clusters.map((group) => group.map((item) => item.marker.id)));
668
- if (signature !== clusterSignature) {
669
- // Revoke old closures before replacing their interactive subtree.
670
- clusterEvents.abort();
671
- clusterEvents = new AbortController();
672
- const signal = clusterEvents.signal;
673
- clusterSignature = signature;
674
- if (clusterLayer.contains(document.activeElement))
675
- svg.focus({ preventScroll: true });
676
- clusterLayer.replaceChildren();
677
- for (const group of clusters) {
678
- const node = svgElement('g', {
679
- role: 'button',
680
- tabindex: 0,
681
- 'aria-label': `${group.length} places. Zoom to explore; all places are available in the marker chooser.`,
682
- });
683
- node.style.cursor = 'pointer';
684
- node.append(svgElement('circle', {
685
- fill: markerColor,
686
- stroke: '#fff',
687
- 'stroke-width': 2,
688
- 'vector-effect': 'non-scaling-stroke',
689
- }));
690
- const text = svgElement('text', {
691
- fill: '#fff',
692
- 'text-anchor': 'middle',
693
- 'dominant-baseline': 'central',
694
- });
695
- text.textContent = String(group.length);
696
- node.append(text);
697
- const activate = () => {
698
- if (destroyed || signal.aborted)
699
- return;
700
- const markers = group.map((item) => ({ ...item.marker }));
701
- fitGeometry({
702
- type: 'MultiPoint',
703
- coordinates: markers.map((marker) => [marker.lng, marker.lat]),
704
- });
705
- if (destroyed || signal.aborted)
706
- return;
707
- root.dispatchEvent(new CustomEvent('clusteractivate', { bubbles: true, detail: { markers } }));
708
- };
709
- node.addEventListener('click', activate, { signal });
710
- node.addEventListener('keydown', (event) => {
711
- if (event.key === 'Enter' || event.key === ' ') {
712
- event.preventDefault();
713
- activate();
714
- }
715
- }, { signal });
716
- clusterLayer.append(node);
717
- }
718
- }
719
- clusters.forEach((group, index) => {
720
- const x = group.reduce((sum, item) => sum + item.point[0], 0) / group.length;
721
- const y = group.reduce((sum, item) => sum + item.point[1], 0) / group.length;
722
- const node = clusterLayer.children[index];
723
- node.setAttribute('transform', `translate(${x},${y})`);
724
- node.querySelector('circle')?.setAttribute('r', String(22 * unit));
725
- node.querySelector('text')?.setAttribute('font-size', String(12 * unit));
700
+ const rendered = clusters.draw({
701
+ unit,
702
+ view,
703
+ items: markerCatalog.items,
704
+ revision: markerRevision,
705
+ selected: selectedMarker,
706
+ features,
707
+ markerColor,
708
+ markerRadius,
709
+ markerHitSize,
726
710
  });
711
+ renderedMarkers = rendered.markers;
712
+ renderedClusters = rendered.clusters;
727
713
  }
728
714
  let overlayEvents = new AbortController();
729
715
  function setOverlays(overlays) {
@@ -858,44 +844,31 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
858
844
  return;
859
845
  frames.invalidate();
860
846
  }
861
- function drawLabels() {
862
- if (destroyed)
863
- return;
864
- const width = canvas.getBoundingClientRect().width;
865
- if (!width)
866
- return;
867
- const unit = view[2] / width, zoom = 800 / view[2];
868
- for (const road of svg.querySelectorAll('[data-key-road-level="secondary"]'))
869
- road.style.display = zoom >= 1.8 ? '' : 'none';
870
- for (const station of stationItems)
871
- station.node.setAttribute('r', String(4.5 * unit));
872
- for (const item of markerItems) {
873
- item.dot.setAttribute('r', String(((item.marker.radius ?? markerRadius) + (item.marker.id === selectedMarker ? 2 : 0)) *
874
- unit));
875
- item.hit.setAttribute('r', String(Math.max(markerHitSize / 2, (item.marker.radius ?? markerRadius) + 4) * unit));
876
- item.ring.setAttribute('r', String(((item.marker.radius ?? markerRadius) +
877
- 2 +
878
- (features.selectedMarkerRing ? features.selectedMarkerRing.gap : 3)) *
879
- unit));
880
- }
881
- drawClusters(unit);
882
- if (features.scaleBar) {
883
- const a = map.project([-122.45, 37.76]), b = map.project([-122.44, 37.76]);
884
- const pixelsPerMeter = Math.abs(b[0] - a[0]) / unit / 879;
885
- const base = 10 ** Math.floor(Math.log10(100 / pixelsPerMeter));
886
- const meters = [5, 2, 1].map((factor) => factor * base).find((value) => value * pixelsPerMeter <= 100) ??
887
- base;
888
- scale.style.width = `${meters * pixelsPerMeter}px`;
889
- scale.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`;
890
- }
891
- if (!labels) {
892
- labelRenderer.clear();
893
- root.dataset.visibleLabels = '0';
894
- return;
895
- }
847
+ let labelInputs = [];
848
+ let preparedLabels = [];
849
+ function prepareLabels(width, zoom) {
850
+ // Only screen-space positioning changes during pan or within these zoom/width bands.
851
+ const inputs = [
852
+ items,
853
+ selected,
854
+ districtItems,
855
+ markerRevision,
856
+ layers,
857
+ mode,
858
+ labelStyle,
859
+ colors,
860
+ minLabelSize,
861
+ maxLabelSize,
862
+ markerRadius,
863
+ zoom >= 1.8,
864
+ width >= 550,
865
+ ];
866
+ if (inputs.every((value, index) => value === labelInputs[index]))
867
+ return preparedLabels;
868
+ labelInputs = inputs;
896
869
  const selectedItem = items.find((item) => item.feature === selected);
897
870
  const candidates = [];
898
- const marker = markerItems.find((item) => item.marker.id === selectedMarker);
871
+ const marker = markerCatalog.items.find((item) => item.marker.id === selectedMarker);
899
872
  if (marker)
900
873
  candidates.push({
901
874
  point: marker.point,
@@ -917,15 +890,16 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
917
890
  (width >= 550 && park.name === 'Presidio')));
918
891
  candidates.push(...(enabled('roadLabels') ? roadItems : []).filter((road) => road.level === 'primary' || zoom >= 1.8));
919
892
  if (enabled('neighborhoodLabels'))
920
- candidates.push(...items
921
- .filter((item) => item.feature !== selected && item.point !== undefined)
922
- .sort((a, b) => b.area - a.area || a.feature.id.localeCompare(b.feature.id))
923
- .map((item) => ({
924
- point: item.point,
925
- name: item.feature.properties.canonicalName,
926
- kind: 'neighborhood',
927
- })));
928
- const labelCandidates = candidates.map((item) => {
893
+ candidates.push(...orderedNeighborhoods.flatMap((item) => item.feature !== selected && item.point
894
+ ? [
895
+ {
896
+ point: item.point,
897
+ name: item.feature.properties.canonicalName,
898
+ kind: 'neighborhood',
899
+ },
900
+ ]
901
+ : []));
902
+ preparedLabels = candidates.map((item) => {
929
903
  const fontSize = item.kind === 'road'
930
904
  ? Math.max(minLabelSize, Math.min(11, maxLabelSize))
931
905
  : Math.max(minLabelSize, Math.min(maxLabelSize, 12));
@@ -948,20 +922,30 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
948
922
  : 0,
949
923
  };
950
924
  });
951
- const stationBounds = (enabled('bartStations') ? stationItems : []).map(({ point }) => {
952
- const x = (point[0] - view[0]) / unit;
953
- const y = (point[1] - view[1]) / unit;
954
- return [x - 6, y - 6, x + 6, y + 6];
955
- });
956
- const markerBounds = markerItems
957
- .filter((item) => item.node.style.display !== 'none')
958
- .map(({ point, marker }) => {
959
- const radius = marker.radius ?? markerRadius;
960
- const x = (point[0] - view[0]) / unit, y = (point[1] - view[1]) / unit;
961
- return [x - radius - 3, y - radius - 3, x + radius + 3, y + radius + 3];
962
- });
925
+ return preparedLabels;
926
+ }
927
+ function drawLabels() {
928
+ if (destroyed)
929
+ return;
963
930
  const canvasBox = canvas.getBoundingClientRect();
964
- const furnitureBounds = [...overlayElement.children, ...clusterLayer.children]
931
+ const width = canvasBox.width;
932
+ if (!width)
933
+ return;
934
+ const unit = view[2] / width, zoom = 800 / view[2];
935
+ if (features.scaleBar) {
936
+ const pixelsPerMeter = scaleUnitsPerMeter / unit;
937
+ const base = 10 ** Math.floor(Math.log10(100 / pixelsPerMeter));
938
+ const meters = [5, 2, 1].map((factor) => factor * base).find((value) => value * pixelsPerMeter <= 100) ??
939
+ base;
940
+ const scaleWidth = `${meters * pixelsPerMeter}px`;
941
+ const scaleText = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`;
942
+ if (scale.style.width !== scaleWidth)
943
+ scale.style.width = scaleWidth;
944
+ if (scale.textContent !== scaleText)
945
+ scale.textContent = scaleText;
946
+ }
947
+ // Read overlay bounds before SVG writes; scale furniture above must have its current width.
948
+ const furnitureBounds = [...overlayElement.children]
965
949
  .filter((node) => node.getClientRects().length > 0)
966
950
  .map((node) => {
967
951
  const box = node.getBoundingClientRect();
@@ -972,10 +956,39 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
972
956
  box.bottom - canvasBox.top + 3,
973
957
  ];
974
958
  });
959
+ const showSecondaryRoads = zoom >= 1.8;
960
+ if (showSecondaryRoads !== secondaryRoadsVisible) {
961
+ for (const road of secondaryRoads)
962
+ road.style.display = showSecondaryRoads ? '' : 'none';
963
+ secondaryRoadsVisible = showSecondaryRoads;
964
+ }
965
+ for (const station of stationItems)
966
+ setAttributeIfChanged(station.node, 'r', 4.5 * unit);
967
+ drawClusters(unit);
968
+ if (!labels) {
969
+ labelRenderer.clear();
970
+ root.dataset.visibleLabels = '0';
971
+ return;
972
+ }
973
+ const labelCandidates = prepareLabels(width, zoom);
974
+ const stationBounds = (enabled('bartStations') ? stationItems : []).map(({ point }) => {
975
+ const x = (point[0] - view[0]) / unit;
976
+ const y = (point[1] - view[1]) / unit;
977
+ return [x - 6, y - 6, x + 6, y + 6];
978
+ });
979
+ const markerBounds = renderedMarkers.map(({ point, marker }) => {
980
+ const radius = marker.radius ?? markerRadius;
981
+ const x = (point[0] - view[0]) / unit, y = (point[1] - view[1]) / unit;
982
+ return [x - radius - 3, y - radius - 3, x + radius + 3, y + radius + 3];
983
+ });
975
984
  root.dataset.visibleLabels = String(labelRenderer.draw(labelCandidates, view, width, [
976
985
  ...stationBounds,
977
986
  ...markerBounds,
978
987
  ...furnitureBounds,
988
+ ...renderedClusters.map(({ point }) => {
989
+ const x = (point[0] - view[0]) / unit, y = (point[1] - view[1]) / unit;
990
+ return [x - 26, y - 26, x + 26, y + 26];
991
+ }),
979
992
  ]));
980
993
  }
981
994
  function releaseDownloads() {
@@ -1206,6 +1219,9 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1206
1219
  neighborhoodSelect.options[0].value = '';
1207
1220
  neighborhoodSelect.append(...nextOptions);
1208
1221
  items = nextItems;
1222
+ orderedNeighborhoods = nextItems
1223
+ .filter((item) => item.point !== undefined)
1224
+ .sort((a, b) => b.area - a.area || a.feature.id.localeCompare(b.feature.id));
1209
1225
  if (items[0])
1210
1226
  items[0].node.setAttribute('tabindex', '0');
1211
1227
  updateResults();
@@ -1309,18 +1325,18 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1309
1325
  const title = svg.querySelector('title');
1310
1326
  if (title)
1311
1327
  title.textContent = 'San Francisco map';
1312
- svg.setAttribute('aria-label', `${description} Arrow keys pan; plus and minus zoom; Home resets. Districts and neighborhoods: brackets move focus; Enter selects. The neighborhood and marker menus include every supplied item.`);
1328
+ svg.setAttribute('aria-label', `${description} Arrow keys pan; plus and minus zoom; Home resets. Districts, neighborhoods, markers and clusters: brackets move focus; Enter selects. Coincident pins open a place chooser. The neighborhood and marker menus include every supplied item.`);
1313
1329
  attribution.textContent = description;
1314
1330
  scheduleLabels();
1315
1331
  }
1316
1332
  function getSelectedMarker() {
1317
- const marker = markerItems.find((item) => item.marker.id === selectedMarker)?.marker;
1333
+ const marker = markerCatalog.items.find((item) => item.marker.id === selectedMarker)?.marker;
1318
1334
  return marker ? { ...marker } : null;
1319
1335
  }
1320
1336
  function selectMarker(id, options = {}) {
1321
1337
  if (destroyed)
1322
1338
  return false;
1323
- const item = markerItems.find((item) => item.marker.id === id);
1339
+ const item = markerCatalog.items.find((item) => item.marker.id === id);
1324
1340
  if (id !== null && !item)
1325
1341
  return false;
1326
1342
  validateCameraOptions(options);
@@ -1336,21 +1352,26 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1336
1352
  if (changed)
1337
1353
  invalidateClusters();
1338
1354
  markerSelect.value = id ?? '';
1339
- for (const entry of markerItems) {
1355
+ for (const entry of markerCatalog.items) {
1340
1356
  const active = entry.marker.id === id;
1357
+ const visual = active ? materializeMarker(entry).visual : entry.visual;
1358
+ if (!visual)
1359
+ continue;
1341
1360
  const pressed = String(active);
1342
1361
  const ring = active && features.selectedMarkerRing ? 'inline' : 'none';
1343
1362
  const fill = active ? selectedMarkerColor : (entry.marker.color ?? markerColor);
1344
- if (entry.node.getAttribute('aria-pressed') !== pressed)
1345
- entry.node.setAttribute('aria-pressed', pressed);
1346
- if (entry.ring.getAttribute('display') !== ring)
1347
- entry.ring.setAttribute('display', ring);
1348
- if (entry.dot.getAttribute('fill') !== fill)
1349
- entry.dot.setAttribute('fill', fill);
1350
- if (active && markerLayer.lastChild !== entry.node)
1351
- markerLayer.append(entry.node);
1363
+ if (visual.node.getAttribute('aria-pressed') !== pressed)
1364
+ visual.node.setAttribute('aria-pressed', pressed);
1365
+ if (visual.ring.getAttribute('display') !== ring)
1366
+ visual.ring.setAttribute('display', ring);
1367
+ if (visual.dot.getAttribute('fill') !== fill)
1368
+ visual.dot.setAttribute('fill', fill);
1369
+ if (active && markerLayer.lastChild !== visual.node)
1370
+ markerLayer.append(visual.node);
1352
1371
  }
1353
1372
  scheduleLabels();
1373
+ if (width || !features.clustering)
1374
+ drawClusters(width ? view[2] / width : 1);
1354
1375
  const marker = getSelectedMarker();
1355
1376
  if (changed)
1356
1377
  root.dispatchEvent(new CustomEvent('markerchange', { bubbles: true, detail: { id, marker } }));
@@ -1363,82 +1384,35 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1363
1384
  function setMarkers(markers) {
1364
1385
  if (destroyed)
1365
1386
  return;
1366
- validateMarkers(markers);
1367
- const existing = new Map(markerItems.map((item) => [item.marker.id, item]));
1368
- const ids = new Set();
1369
- const next = markers.map((marker) => {
1370
- ids.add(marker.id);
1371
- const previous = existing.get(marker.id);
1372
- const point = previous?.marker.lng === marker.lng && previous.marker.lat === marker.lat
1373
- ? previous.point
1374
- : map.project([marker.lng, marker.lat]);
1375
- return { marker: { ...marker }, point };
1376
- });
1377
- const nextSelection = selectedMarker && ids.has(selectedMarker)
1378
- ? selectedMarker
1379
- : (markers.find((marker) => marker.selected)?.id ?? null);
1380
- const keys = ['id', 'lng', 'lat', 'label', 'selected', 'color', 'radius'];
1381
- if (markerSelect.options.length > 0 &&
1382
- nextSelection === selectedMarker &&
1383
- next.length === markerItems.length &&
1384
- next.every(({ marker }, index) => keys.every((key) => marker[key] === markerItems[index].marker[key])))
1387
+ const prepared = markerCatalog.prepare(markers, selectedMarker);
1388
+ if (!prepared)
1385
1389
  return;
1386
- const focusedMarker = markerItems.find((item) => item.node === document.activeElement)?.marker
1387
- .id;
1390
+ const { selection: nextSelection, focused: focusedMarker } = prepared;
1388
1391
  invalidateClusters();
1389
- const options = new Map([...markerSelect.options].map((option) => [option.value, option]));
1390
- for (const item of markerItems) {
1391
- if (!ids.has(item.marker.id)) {
1392
- markerRenderer.remove(item);
1393
- options.get(item.marker.id)?.remove();
1394
- }
1395
- }
1396
- if (!options.has('')) {
1397
- const empty = element('option', 'No marker selected');
1398
- empty.value = '';
1399
- markerSelect.prepend(empty);
1400
- }
1401
- markerItems = next.map(({ marker, point }, index) => {
1402
- const previous = existing.get(marker.id);
1403
- const item = previous ??
1404
- markerRenderer.add(marker, point, index, {
1405
- features,
1406
- markerColor,
1407
- selectedMarkerColor,
1408
- reducedMotion: reducedMotion.matches,
1409
- enter: true,
1410
- });
1411
- if (previous)
1412
- markerRenderer.update(item, marker, point, index);
1413
- const option = options.get(marker.id) ?? element('option');
1414
- const label = marker.label ?? marker.id;
1415
- if (option.textContent !== label)
1416
- option.textContent = label;
1417
- if (option.value !== marker.id)
1418
- option.value = marker.id;
1419
- if (markerSelect.children[index + 1] !== option)
1420
- markerSelect.insertBefore(option, markerSelect.children[index + 1] ?? null);
1421
- return item;
1422
- });
1423
- // Match input order without detaching unchanged nodes; selected markers remain on top.
1424
- const ordered = markerItems.filter((item) => item.marker.id !== nextSelection);
1425
- const selectedItem = markerItems.find((item) => item.marker.id === nextSelection);
1426
- if (selectedItem)
1427
- ordered.push(selectedItem);
1428
- ordered.forEach((item, index) => {
1429
- if (markerLayer.children[index] !== item.node)
1430
- markerLayer.insertBefore(item.node, markerLayer.children[index] ?? null);
1431
- });
1432
- markerLabel.hidden = !markerItems.length || controls.markerPicker === false;
1392
+ prepared.commit();
1393
+ markerLabel.hidden = !markerCatalog.items.length || controls.markerPicker === false;
1433
1394
  if (markerLabel.firstChild)
1434
- markerLabel.firstChild.textContent = `${strings.chooseMarker ?? 'Choose marker'} (${markerItems.length})`;
1395
+ markerLabel.firstChild.textContent = `${strings.chooseMarker ?? 'Choose marker'} (${markerCatalog.items.length})`;
1435
1396
  syncFeatureControls();
1436
1397
  const revision = markerRevision + 1;
1437
1398
  selectMarker(nextSelection, { fit: false });
1438
- if (!destroyed && markerRevision === revision && focusedMarker)
1439
- (markerItems.find((item) => item.marker.id === focusedMarker)?.node ?? svg).focus({
1440
- preventScroll: true,
1441
- });
1399
+ if (!destroyed && markerRevision === revision && focusedMarker) {
1400
+ markerNavigation.sync(markerCatalog.items.flatMap((item) => (item.visual ? [item.visual.node] : [])));
1401
+ if (!markerNavigation.recover()) {
1402
+ const recovery = markerCatalog.items.find((item) => item.marker.id === focusedMarker) ??
1403
+ markerCatalog.items[0];
1404
+ if (recovery) {
1405
+ const { node } = materializeMarker(recovery).visual;
1406
+ markerLayer.append(node);
1407
+ node.focus({ preventScroll: true });
1408
+ const width = canvas.getBoundingClientRect().width;
1409
+ if (width)
1410
+ drawClusters(view[2] / width);
1411
+ }
1412
+ else
1413
+ svg.focus({ preventScroll: true });
1414
+ }
1415
+ }
1442
1416
  }
1443
1417
  function setControls(patch) {
1444
1418
  if (destroyed)
@@ -1462,7 +1436,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1462
1436
  else
1463
1437
  svg.removeAttribute('aria-describedby');
1464
1438
  status.classList.toggle('sf-explorer-visually-hidden', controls.status === false);
1465
- markerLabel.hidden = !markerItems.length || controls.markerPicker === false;
1439
+ markerLabel.hidden = !markerCatalog.items.length || controls.markerPicker === false;
1466
1440
  updateComposition();
1467
1441
  }
1468
1442
  function setMode(next, reset = true) {
@@ -1606,17 +1580,32 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1606
1580
  }
1607
1581
  });
1608
1582
  listen(markerLayer, 'focusout', scheduleLabels);
1583
+ listen(markerLayer, 'focusin', scheduleLabels);
1584
+ function activateMarker(id, node) {
1585
+ const item = markerCatalog.items.find((item) => item.marker.id === id);
1586
+ if (!item)
1587
+ return;
1588
+ const coincident = markerCatalog.items.filter((other) => other.point[0] === item.point[0] && other.point[1] === item.point[1]);
1589
+ if (coincident.length > 1)
1590
+ markerChooser.open(coincident.map((other) => other.marker), node);
1591
+ else {
1592
+ selectMarker(id);
1593
+ if (!destroyed && selectedMarker === id)
1594
+ markerCatalog.items
1595
+ .find((other) => other.marker.id === id)
1596
+ ?.visual?.node.focus({ preventScroll: true });
1597
+ }
1598
+ }
1609
1599
  listen(markerLayer, 'click', (event) => {
1610
1600
  const node = event.target instanceof Element ? event.target.closest('[data-marker-id]') : null;
1611
1601
  if (node?.dataset.markerId)
1612
- selectMarker(node.dataset.markerId);
1602
+ activateMarker(node.dataset.markerId, node);
1613
1603
  });
1614
1604
  listen(markerLayer, 'keydown', (event) => {
1615
1605
  const node = event.target instanceof Element ? event.target.closest('[data-marker-id]') : null;
1616
1606
  if (node?.dataset.markerId && (event.key === 'Enter' || event.key === ' ')) {
1617
1607
  event.preventDefault();
1618
- selectMarker(node.dataset.markerId);
1619
- node.focus();
1608
+ activateMarker(node.dataset.markerId, node);
1620
1609
  }
1621
1610
  });
1622
1611
  listen(svg, 'click', (event) => {
@@ -1759,11 +1748,14 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1759
1748
  item.node.setAttribute('fill', active ? (areaStyle.selectedFill ?? '#408dbe') : 'transparent');
1760
1749
  item.node.setAttribute('stroke', active ? (areaStyle.selectedStroke ?? '#176ba2') : (colors.neighborhood ?? '#9caebc'));
1761
1750
  }
1762
- for (const item of markerItems) {
1763
- item.dot.setAttribute('fill', item.marker.id === selectedMarker
1751
+ for (const item of markerCatalog.items) {
1752
+ const visual = item.visual;
1753
+ if (!visual)
1754
+ continue;
1755
+ visual.dot.setAttribute('fill', item.marker.id === selectedMarker
1764
1756
  ? selectedMarkerColor
1765
1757
  : (item.marker.color ?? markerColor));
1766
- item.ring.setAttribute('stroke', features.selectedMarkerRing
1758
+ visual.ring.setAttribute('stroke', features.selectedMarkerRing
1767
1759
  ? (features.selectedMarkerRing.color ?? selectedMarkerColor)
1768
1760
  : selectedMarkerColor);
1769
1761
  }
@@ -1819,6 +1811,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1819
1811
  zoomBy,
1820
1812
  destroy() {
1821
1813
  destroyed = true;
1814
+ markerChooser.close();
1822
1815
  camera.destroy();
1823
1816
  districtTransition.cancel();
1824
1817
  layerFades.cancel();
@@ -1826,7 +1819,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1826
1819
  cancelEntrances();
1827
1820
  controller.abort();
1828
1821
  overlayEvents.abort();
1829
- clusterEvents.abort();
1822
+ clusters.destroy();
1830
1823
  canvas.style.touchAction = 'pan-y pinch-zoom';
1831
1824
  observer?.disconnect();
1832
1825
  frames.destroy();