@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.
- package/CHANGELOG.md +24 -0
- package/README.md +117 -1
- package/dist/src/cluster-renderer.d.ts +34 -0
- package/dist/src/cluster-renderer.js +159 -0
- package/dist/src/controller-types.d.ts +2 -2
- package/dist/src/dom.d.ts +2 -0
- package/dist/src/dom.js +6 -0
- package/dist/src/explorer-core.js +228 -235
- package/dist/src/label-renderer.js +16 -8
- package/dist/src/map-core.js +24 -2
- package/dist/src/marker-catalog.d.ts +11 -0
- package/dist/src/marker-catalog.js +67 -0
- package/dist/src/marker-chooser.d.ts +6 -0
- package/dist/src/marker-chooser.js +59 -0
- package/dist/src/marker-layer.d.ts +13 -8
- package/dist/src/marker-layer.js +41 -23
- package/dist/src/marker-navigation.d.ts +5 -0
- package/dist/src/marker-navigation.js +52 -0
- package/dist/src/static.js +59 -23
- package/dist/src/types.d.ts +1 -1
- package/docs/API.md +11 -3
- package/docs/api-audit.md +5 -4
- package/docs/guide-bundle-report.md +4 -4
- package/llms-full.txt +165 -17
- package/llms.txt +1 -1
- package/package.json +14 -11
|
@@ -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,
|
|
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(
|
|
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
|
|
603
|
+
for (const { visual, marker } of markerCatalog.items) {
|
|
604
|
+
if (!visual)
|
|
605
|
+
continue;
|
|
597
606
|
const ring = features.selectedMarkerRing;
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
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
|
-
|
|
645
|
-
|
|
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
|
-
|
|
648
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
:
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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 =
|
|
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(...
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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 (
|
|
1345
|
-
|
|
1346
|
-
if (
|
|
1347
|
-
|
|
1348
|
-
if (
|
|
1349
|
-
|
|
1350
|
-
if (active && markerLayer.lastChild !==
|
|
1351
|
-
markerLayer.append(
|
|
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
|
-
|
|
1367
|
-
|
|
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
|
|
1387
|
-
.id;
|
|
1390
|
+
const { selection: nextSelection, focused: focusedMarker } = prepared;
|
|
1388
1391
|
invalidateClusters();
|
|
1389
|
-
|
|
1390
|
-
|
|
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'} (${
|
|
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
|
-
(
|
|
1440
|
-
|
|
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 = !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1763
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1822
|
+
clusters.destroy();
|
|
1830
1823
|
canvas.style.touchAction = 'pan-y pinch-zoom';
|
|
1831
1824
|
observer?.disconnect();
|
|
1832
1825
|
frames.destroy();
|