@kahwee/sf-map-svg 4.3.1 → 4.4.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.
@@ -1,21 +1,26 @@
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';
12
13
  import { geometryPath } from './geometry.js';
14
+ import { labelCandidates, styleLabels, } from './label-candidates.js';
13
15
  import { createLabelRenderer } from './label-renderer.js';
14
16
  import { createLayerTransitions } from './layer-transition.js';
15
17
  import { createSFMapWithData, districtColors, getLayerPathsWithData, resolveMapColors, } from './map-core.js';
18
+ import { createMarkerCatalog } from './marker-catalog.js';
19
+ import { createMarkerChooser } from './marker-chooser.js';
16
20
  import { createMarkerLayer } from './marker-layer.js';
21
+ import { createMarkerNavigation } from './marker-navigation.js';
17
22
  import { attachNavigation } from './navigation.js';
18
- import { validateExplorerOptions, validateMarkers, validateOverlays } from './validation.js';
23
+ import { validateExplorerOptions, validateOverlays } from './validation.js';
19
24
  import { fitViewport, validateViewport } from './viewport.js';
20
25
  const sourceNames = {
21
26
  realtor: 'SFAR realtor · 92 areas',
@@ -160,6 +165,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
160
165
  .sf-explorer-toolbar button{min-width:44px;padding:6px 10px}
161
166
  .sf-explorer-zoom{font-size:12px;min-width:42px;text-align:center;font-variant-numeric:tabular-nums;color:#496578}
162
167
  .sf-explorer-canvas{position:relative;aspect-ratio:1;overflow:hidden;touch-action:pan-y pinch-zoom}
168
+ .sf-marker-chooser{position:absolute;z-index:2;top:12px;right:12px;display:flex;flex-direction:column;gap:6px;box-sizing:border-box;width:280px;max-width:calc(100% - 24px);max-height:calc(100% - 24px);overflow:auto;padding:12px;border:1px solid var(--sf-map-border);border-radius:8px;background:var(--sf-map-surface);color:var(--sf-map-ink);box-shadow:0 6px 20px #0003}
169
+ .sf-marker-chooser button{min-height:44px;text-align:left;overflow-wrap:anywhere}
163
170
  .sf-explorer-canvas>svg{display:block;width:100%;height:100%;max-width:none;cursor:grab;user-select:none}
164
171
  .sf-explorer-canvas>svg:active{cursor:grabbing}
165
172
  .sf-explorer-canvas path[data-neighborhood-id]{cursor:pointer}
@@ -546,6 +553,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
546
553
  }
547
554
  updateDistrictAppearance();
548
555
  let items = [];
556
+ let orderedNeighborhoods = [];
549
557
  let destroyed = false;
550
558
  let initialized = false;
551
559
  const layerFades = createLayerTransitions(() => initialized && features.layerTransitions && !reducedMotion.matches
@@ -557,7 +565,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
557
565
  cancel: (id) => cancelAnimationFrame(id),
558
566
  render: drawLabels,
559
567
  });
560
- const markerRenderer = createMarkerLayer(markerLayer);
568
+ const markerRenderer = createMarkerLayer();
561
569
  const cancelEntrances = markerRenderer.cancelEntrances;
562
570
  const camera = createCamera({
563
571
  read: getViewport,
@@ -593,11 +601,13 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
593
601
  if (Object.hasOwn(patch, 'clustering'))
594
602
  invalidateClusters();
595
603
  syncFurniture();
596
- for (const item of markerItems) {
604
+ for (const { visual, marker } of markerCatalog.items) {
605
+ if (!visual)
606
+ continue;
597
607
  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));
608
+ visual.ring.setAttribute('display', ring && marker.id === selectedMarker ? 'inline' : 'none');
609
+ visual.ring.setAttribute('stroke', ring ? (ring.color ?? selectedMarkerColor) : selectedMarkerColor);
610
+ visual.ring.setAttribute('stroke-width', String(ring ? ring.width : 2));
601
611
  }
602
612
  scheduleLabels();
603
613
  }
@@ -630,100 +640,77 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
630
640
  level: feature.level ?? 'primary',
631
641
  kind: 'road',
632
642
  }));
643
+ const secondaryRoads = svg.querySelectorAll('[data-key-road-level="secondary"]');
644
+ let secondaryRoadsVisible;
645
+ const scaleUnitsPerMeter = Math.abs(map.project([-122.44, 37.76])[0] - map.project([-122.45, 37.76])[0]) / 879;
633
646
  const parkItems = (data.map.landmarks ?? []).map((feature) => ({
634
647
  point: map.project(feature.label),
635
648
  name: feature.name,
636
649
  kind: 'park',
637
650
  }));
638
- let markerItems = [];
651
+ const markerCatalog = createMarkerCatalog(markerRenderer, markerSelect, map.project);
639
652
  let selectedMarker = null;
653
+ const markerNavigation = createMarkerNavigation(svg, controller.signal);
654
+ const markerChooser = createMarkerChooser(canvas, svg, (id) => {
655
+ if (destroyed)
656
+ return;
657
+ selectMarker(id);
658
+ if (!destroyed && selectedMarker === id)
659
+ markerCatalog.items
660
+ .find((item) => item.marker.id === id)
661
+ ?.visual?.node.focus({ preventScroll: true });
662
+ });
640
663
  let markerRevision = 0;
641
664
  let neighborhoodRevision = 0;
642
665
  const clusterLayer = svgElement('g', { 'data-layer': 'marker-clusters' });
643
666
  geography.insertBefore(clusterLayer, markerLayer);
644
- let clusterSignature = '';
645
- let clusterEvents = new AbortController();
667
+ function materializeMarker(item) {
668
+ return Object.assign(item, {
669
+ visual: markerRenderer.materialize(item, {
670
+ features,
671
+ markerColor,
672
+ selectedMarkerColor,
673
+ reducedMotion: reducedMotion.matches,
674
+ selected: item.marker.id === selectedMarker,
675
+ }),
676
+ });
677
+ }
678
+ const clusters = createClusterRenderer(svg, markerLayer, clusterLayer, markerNavigation, materializeMarker, (group, node, signal) => {
679
+ if (destroyed || signal.aborted)
680
+ return;
681
+ const markers = group.map((item) => ({ ...item.marker }));
682
+ const minimumUnit = 800 / 12 / Math.max(1, canvas.getBoundingClientRect().width);
683
+ if (clusterPoints(group, minimumUnit, features.clustering ? features.clustering.radius : 36)
684
+ .length === 1)
685
+ markerChooser.open(markers, node);
686
+ else
687
+ fitGeometry({
688
+ type: 'MultiPoint',
689
+ coordinates: markers.map((marker) => [marker.lng, marker.lat]),
690
+ });
691
+ if (!destroyed && !signal.aborted)
692
+ root.dispatchEvent(new CustomEvent('clusteractivate', { bubbles: true, detail: { markers } }));
693
+ });
694
+ let renderedMarkers = [];
695
+ let renderedClusters = [];
646
696
  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 = '';
697
+ markerChooser.close();
698
+ clusters.invalidate();
655
699
  }
656
700
  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));
701
+ const rendered = clusters.draw({
702
+ unit,
703
+ view,
704
+ items: markerCatalog.items,
705
+ revision: markerRevision,
706
+ selected: selectedMarker,
707
+ features,
708
+ markerColor,
709
+ markerRadius,
710
+ markerHitSize,
726
711
  });
712
+ renderedMarkers = rendered.markers;
713
+ renderedClusters = rendered.clusters;
727
714
  }
728
715
  let overlayEvents = new AbortController();
729
716
  function setOverlays(overlays) {
@@ -858,44 +845,31 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
858
845
  return;
859
846
  frames.invalidate();
860
847
  }
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
- }
848
+ let labelInputs = [];
849
+ let preparedLabels = [];
850
+ function prepareLabels(width, zoom) {
851
+ // Only screen-space positioning changes during pan or within these zoom/width bands.
852
+ const inputs = [
853
+ items,
854
+ selected,
855
+ districtItems,
856
+ markerRevision,
857
+ layers,
858
+ mode,
859
+ labelStyle,
860
+ colors,
861
+ minLabelSize,
862
+ maxLabelSize,
863
+ markerRadius,
864
+ zoom >= 1.8,
865
+ width >= 550,
866
+ ];
867
+ if (inputs.every((value, index) => value === labelInputs[index]))
868
+ return preparedLabels;
869
+ labelInputs = inputs;
896
870
  const selectedItem = items.find((item) => item.feature === selected);
897
871
  const candidates = [];
898
- const marker = markerItems.find((item) => item.marker.id === selectedMarker);
872
+ const marker = markerCatalog.items.find((item) => item.marker.id === selectedMarker);
899
873
  if (marker)
900
874
  candidates.push({
901
875
  point: marker.point,
@@ -908,60 +882,53 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
908
882
  name: selectedItem.feature.properties.canonicalName,
909
883
  kind: 'selected',
910
884
  });
911
- if (enabled('districtLabels'))
912
- candidates.push(...districtItems);
913
- if (enabled('bartStations') && zoom >= 1.8)
914
- candidates.push(...stationItems);
915
- candidates.push(...(enabled('landmarks') ? parkItems : []).filter((park) => zoom >= 1.8 ||
916
- park.name === 'Golden Gate Park' ||
917
- (width >= 550 && park.name === 'Presidio')));
918
- candidates.push(...(enabled('roadLabels') ? roadItems : []).filter((road) => road.level === 'primary' || zoom >= 1.8));
919
- 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) => {
929
- const fontSize = item.kind === 'road'
930
- ? Math.max(minLabelSize, Math.min(11, maxLabelSize))
931
- : Math.max(minLabelSize, Math.min(maxLabelSize, 12));
932
- return {
933
- ...item,
934
- fontSize,
935
- fontWeight: labelStyle.fontWeight ??
936
- (item.kind.startsWith('selected') || item.kind === 'district' ? 700 : 550),
937
- fill: colors.label ??
938
- (item.kind === 'park'
939
- ? (colors.landmark ?? '#426641')
940
- : item.kind === 'road'
941
- ? '#77736b'
942
- : '#163d61'),
943
- halo: labelStyle.haloColor ?? '#ffffff',
944
- offset: item.kind === 'selected-marker'
945
- ? (marker?.marker.radius ?? markerRadius) + 9
946
- : item.kind === 'bart'
947
- ? 11
948
- : 0,
949
- };
950
- });
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];
885
+ preparedLabels = styleLabels(labelCandidates({
886
+ selected: candidates,
887
+ districts: enabled('districtLabels') ? districtItems : [],
888
+ stations: enabled('bartStations') ? stationItems : [],
889
+ parks: enabled('landmarks') ? parkItems : [],
890
+ roads: enabled('roadLabels') ? roadItems : [],
891
+ neighborhoods: enabled('neighborhoodLabels')
892
+ ? orderedNeighborhoods.flatMap((item) => item.feature !== selected && item.point
893
+ ? [
894
+ {
895
+ point: item.point,
896
+ name: item.feature.properties.canonicalName,
897
+ kind: 'neighborhood',
898
+ },
899
+ ]
900
+ : [])
901
+ : [],
902
+ }, width, zoom), {
903
+ colors,
904
+ labelStyle,
905
+ labelSize: { min: minLabelSize, max: maxLabelSize },
906
+ markerRadius: marker?.marker.radius ?? markerRadius,
962
907
  });
908
+ return preparedLabels;
909
+ }
910
+ function drawLabels() {
911
+ if (destroyed)
912
+ return;
963
913
  const canvasBox = canvas.getBoundingClientRect();
964
- const furnitureBounds = [...overlayElement.children, ...clusterLayer.children]
914
+ const width = canvasBox.width;
915
+ if (!width)
916
+ return;
917
+ const unit = view[2] / width, zoom = 800 / view[2];
918
+ if (features.scaleBar) {
919
+ const pixelsPerMeter = scaleUnitsPerMeter / unit;
920
+ const base = 10 ** Math.floor(Math.log10(100 / pixelsPerMeter));
921
+ const meters = [5, 2, 1].map((factor) => factor * base).find((value) => value * pixelsPerMeter <= 100) ??
922
+ base;
923
+ const scaleWidth = `${meters * pixelsPerMeter}px`;
924
+ const scaleText = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`;
925
+ if (scale.style.width !== scaleWidth)
926
+ scale.style.width = scaleWidth;
927
+ if (scale.textContent !== scaleText)
928
+ scale.textContent = scaleText;
929
+ }
930
+ // Read overlay bounds before SVG writes; scale furniture above must have its current width.
931
+ const furnitureBounds = [...overlayElement.children]
965
932
  .filter((node) => node.getClientRects().length > 0)
966
933
  .map((node) => {
967
934
  const box = node.getBoundingClientRect();
@@ -972,10 +939,39 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
972
939
  box.bottom - canvasBox.top + 3,
973
940
  ];
974
941
  });
942
+ const showSecondaryRoads = zoom >= 1.8;
943
+ if (showSecondaryRoads !== secondaryRoadsVisible) {
944
+ for (const road of secondaryRoads)
945
+ road.style.display = showSecondaryRoads ? '' : 'none';
946
+ secondaryRoadsVisible = showSecondaryRoads;
947
+ }
948
+ for (const station of stationItems)
949
+ setAttributeIfChanged(station.node, 'r', 4.5 * unit);
950
+ drawClusters(unit);
951
+ if (!labels) {
952
+ labelRenderer.clear();
953
+ root.dataset.visibleLabels = '0';
954
+ return;
955
+ }
956
+ const labelCandidates = prepareLabels(width, zoom);
957
+ const stationBounds = (enabled('bartStations') ? stationItems : []).map(({ point }) => {
958
+ const x = (point[0] - view[0]) / unit;
959
+ const y = (point[1] - view[1]) / unit;
960
+ return [x - 6, y - 6, x + 6, y + 6];
961
+ });
962
+ const markerBounds = renderedMarkers.map(({ point, marker }) => {
963
+ const radius = marker.radius ?? markerRadius;
964
+ const x = (point[0] - view[0]) / unit, y = (point[1] - view[1]) / unit;
965
+ return [x - radius - 3, y - radius - 3, x + radius + 3, y + radius + 3];
966
+ });
975
967
  root.dataset.visibleLabels = String(labelRenderer.draw(labelCandidates, view, width, [
976
968
  ...stationBounds,
977
969
  ...markerBounds,
978
970
  ...furnitureBounds,
971
+ ...renderedClusters.map(({ point }) => {
972
+ const x = (point[0] - view[0]) / unit, y = (point[1] - view[1]) / unit;
973
+ return [x - 26, y - 26, x + 26, y + 26];
974
+ }),
979
975
  ]));
980
976
  }
981
977
  function releaseDownloads() {
@@ -1206,6 +1202,9 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1206
1202
  neighborhoodSelect.options[0].value = '';
1207
1203
  neighborhoodSelect.append(...nextOptions);
1208
1204
  items = nextItems;
1205
+ orderedNeighborhoods = nextItems
1206
+ .filter((item) => item.point !== undefined)
1207
+ .sort((a, b) => b.area - a.area || a.feature.id.localeCompare(b.feature.id));
1209
1208
  if (items[0])
1210
1209
  items[0].node.setAttribute('tabindex', '0');
1211
1210
  updateResults();
@@ -1309,18 +1308,18 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1309
1308
  const title = svg.querySelector('title');
1310
1309
  if (title)
1311
1310
  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.`);
1311
+ 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
1312
  attribution.textContent = description;
1314
1313
  scheduleLabels();
1315
1314
  }
1316
1315
  function getSelectedMarker() {
1317
- const marker = markerItems.find((item) => item.marker.id === selectedMarker)?.marker;
1316
+ const marker = markerCatalog.items.find((item) => item.marker.id === selectedMarker)?.marker;
1318
1317
  return marker ? { ...marker } : null;
1319
1318
  }
1320
1319
  function selectMarker(id, options = {}) {
1321
1320
  if (destroyed)
1322
1321
  return false;
1323
- const item = markerItems.find((item) => item.marker.id === id);
1322
+ const item = markerCatalog.items.find((item) => item.marker.id === id);
1324
1323
  if (id !== null && !item)
1325
1324
  return false;
1326
1325
  validateCameraOptions(options);
@@ -1336,21 +1335,26 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1336
1335
  if (changed)
1337
1336
  invalidateClusters();
1338
1337
  markerSelect.value = id ?? '';
1339
- for (const entry of markerItems) {
1338
+ for (const entry of markerCatalog.items) {
1340
1339
  const active = entry.marker.id === id;
1340
+ const visual = active ? materializeMarker(entry).visual : entry.visual;
1341
+ if (!visual)
1342
+ continue;
1341
1343
  const pressed = String(active);
1342
1344
  const ring = active && features.selectedMarkerRing ? 'inline' : 'none';
1343
1345
  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);
1346
+ if (visual.node.getAttribute('aria-pressed') !== pressed)
1347
+ visual.node.setAttribute('aria-pressed', pressed);
1348
+ if (visual.ring.getAttribute('display') !== ring)
1349
+ visual.ring.setAttribute('display', ring);
1350
+ if (visual.dot.getAttribute('fill') !== fill)
1351
+ visual.dot.setAttribute('fill', fill);
1352
+ if (active && markerLayer.lastChild !== visual.node)
1353
+ markerLayer.append(visual.node);
1352
1354
  }
1353
1355
  scheduleLabels();
1356
+ if (width || !features.clustering)
1357
+ drawClusters(width ? view[2] / width : 1);
1354
1358
  const marker = getSelectedMarker();
1355
1359
  if (changed)
1356
1360
  root.dispatchEvent(new CustomEvent('markerchange', { bubbles: true, detail: { id, marker } }));
@@ -1363,82 +1367,35 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1363
1367
  function setMarkers(markers) {
1364
1368
  if (destroyed)
1365
1369
  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])))
1370
+ const prepared = markerCatalog.prepare(markers, selectedMarker);
1371
+ if (!prepared)
1385
1372
  return;
1386
- const focusedMarker = markerItems.find((item) => item.node === document.activeElement)?.marker
1387
- .id;
1373
+ const { selection: nextSelection, focused: focusedMarker } = prepared;
1388
1374
  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;
1375
+ prepared.commit();
1376
+ markerLabel.hidden = !markerCatalog.items.length || controls.markerPicker === false;
1433
1377
  if (markerLabel.firstChild)
1434
- markerLabel.firstChild.textContent = `${strings.chooseMarker ?? 'Choose marker'} (${markerItems.length})`;
1378
+ markerLabel.firstChild.textContent = `${strings.chooseMarker ?? 'Choose marker'} (${markerCatalog.items.length})`;
1435
1379
  syncFeatureControls();
1436
1380
  const revision = markerRevision + 1;
1437
1381
  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
- });
1382
+ if (!destroyed && markerRevision === revision && focusedMarker) {
1383
+ markerNavigation.sync(markerCatalog.items.flatMap((item) => (item.visual ? [item.visual.node] : [])));
1384
+ if (!markerNavigation.recover()) {
1385
+ const recovery = markerCatalog.items.find((item) => item.marker.id === focusedMarker) ??
1386
+ markerCatalog.items[0];
1387
+ if (recovery) {
1388
+ const { node } = materializeMarker(recovery).visual;
1389
+ markerLayer.append(node);
1390
+ node.focus({ preventScroll: true });
1391
+ const width = canvas.getBoundingClientRect().width;
1392
+ if (width)
1393
+ drawClusters(view[2] / width);
1394
+ }
1395
+ else
1396
+ svg.focus({ preventScroll: true });
1397
+ }
1398
+ }
1442
1399
  }
1443
1400
  function setControls(patch) {
1444
1401
  if (destroyed)
@@ -1462,7 +1419,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1462
1419
  else
1463
1420
  svg.removeAttribute('aria-describedby');
1464
1421
  status.classList.toggle('sf-explorer-visually-hidden', controls.status === false);
1465
- markerLabel.hidden = !markerItems.length || controls.markerPicker === false;
1422
+ markerLabel.hidden = !markerCatalog.items.length || controls.markerPicker === false;
1466
1423
  updateComposition();
1467
1424
  }
1468
1425
  function setMode(next, reset = true) {
@@ -1606,17 +1563,32 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1606
1563
  }
1607
1564
  });
1608
1565
  listen(markerLayer, 'focusout', scheduleLabels);
1566
+ listen(markerLayer, 'focusin', scheduleLabels);
1567
+ function activateMarker(id, node) {
1568
+ const item = markerCatalog.items.find((item) => item.marker.id === id);
1569
+ if (!item)
1570
+ return;
1571
+ const coincident = markerCatalog.items.filter((other) => other.point[0] === item.point[0] && other.point[1] === item.point[1]);
1572
+ if (coincident.length > 1)
1573
+ markerChooser.open(coincident.map((other) => other.marker), node);
1574
+ else {
1575
+ selectMarker(id);
1576
+ if (!destroyed && selectedMarker === id)
1577
+ markerCatalog.items
1578
+ .find((other) => other.marker.id === id)
1579
+ ?.visual?.node.focus({ preventScroll: true });
1580
+ }
1581
+ }
1609
1582
  listen(markerLayer, 'click', (event) => {
1610
1583
  const node = event.target instanceof Element ? event.target.closest('[data-marker-id]') : null;
1611
1584
  if (node?.dataset.markerId)
1612
- selectMarker(node.dataset.markerId);
1585
+ activateMarker(node.dataset.markerId, node);
1613
1586
  });
1614
1587
  listen(markerLayer, 'keydown', (event) => {
1615
1588
  const node = event.target instanceof Element ? event.target.closest('[data-marker-id]') : null;
1616
1589
  if (node?.dataset.markerId && (event.key === 'Enter' || event.key === ' ')) {
1617
1590
  event.preventDefault();
1618
- selectMarker(node.dataset.markerId);
1619
- node.focus();
1591
+ activateMarker(node.dataset.markerId, node);
1620
1592
  }
1621
1593
  });
1622
1594
  listen(svg, 'click', (event) => {
@@ -1759,11 +1731,14 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1759
1731
  item.node.setAttribute('fill', active ? (areaStyle.selectedFill ?? '#408dbe') : 'transparent');
1760
1732
  item.node.setAttribute('stroke', active ? (areaStyle.selectedStroke ?? '#176ba2') : (colors.neighborhood ?? '#9caebc'));
1761
1733
  }
1762
- for (const item of markerItems) {
1763
- item.dot.setAttribute('fill', item.marker.id === selectedMarker
1734
+ for (const item of markerCatalog.items) {
1735
+ const visual = item.visual;
1736
+ if (!visual)
1737
+ continue;
1738
+ visual.dot.setAttribute('fill', item.marker.id === selectedMarker
1764
1739
  ? selectedMarkerColor
1765
1740
  : (item.marker.color ?? markerColor));
1766
- item.ring.setAttribute('stroke', features.selectedMarkerRing
1741
+ visual.ring.setAttribute('stroke', features.selectedMarkerRing
1767
1742
  ? (features.selectedMarkerRing.color ?? selectedMarkerColor)
1768
1743
  : selectedMarkerColor);
1769
1744
  }
@@ -1819,6 +1794,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1819
1794
  zoomBy,
1820
1795
  destroy() {
1821
1796
  destroyed = true;
1797
+ markerChooser.close();
1822
1798
  camera.destroy();
1823
1799
  districtTransition.cancel();
1824
1800
  layerFades.cancel();
@@ -1826,7 +1802,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1826
1802
  cancelEntrances();
1827
1803
  controller.abort();
1828
1804
  overlayEvents.abort();
1829
- clusterEvents.abort();
1805
+ clusters.destroy();
1830
1806
  canvas.style.touchAction = 'pan-y pinch-zoom';
1831
1807
  observer?.disconnect();
1832
1808
  frames.destroy();