@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.
- package/CHANGELOG.md +26 -0
- package/README.md +134 -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 +241 -265
- package/dist/src/guide-data.js +1 -0
- package/dist/src/guide-static.js +1 -0
- package/dist/src/label-candidates.d.ts +24 -0
- package/dist/src/label-candidates.js +29 -0
- package/dist/src/label-renderer.d.ts +1 -10
- package/dist/src/label-renderer.js +16 -8
- package/dist/src/map-core.d.ts +3 -1
- package/dist/src/map-core.js +7 -5
- 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-labels.d.ts +7 -0
- package/dist/src/static-labels.js +100 -0
- package/dist/src/static.js +12 -3
- package/dist/src/types.d.ts +8 -2
- package/docs/API.md +14 -4
- package/docs/EXAMPLES.md +1 -0
- package/docs/api-audit.md +5 -4
- package/docs/consumer-integration.md +43 -1
- package/docs/guide-bundle-report.md +4 -4
- package/llms-full.txt +251 -31
- package/llms.txt +1 -1
- package/package.json +14 -11
|
@@ -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,
|
|
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(
|
|
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
|
|
604
|
+
for (const { visual, marker } of markerCatalog.items) {
|
|
605
|
+
if (!visual)
|
|
606
|
+
continue;
|
|
597
607
|
const ring = features.selectedMarkerRing;
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
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
|
-
|
|
645
|
-
|
|
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
|
-
|
|
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 = '';
|
|
697
|
+
markerChooser.close();
|
|
698
|
+
clusters.invalidate();
|
|
655
699
|
}
|
|
656
700
|
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));
|
|
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
|
-
|
|
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
|
-
}
|
|
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 =
|
|
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
|
-
|
|
912
|
-
candidates
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
(
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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 (
|
|
1345
|
-
|
|
1346
|
-
if (
|
|
1347
|
-
|
|
1348
|
-
if (
|
|
1349
|
-
|
|
1350
|
-
if (active && markerLayer.lastChild !==
|
|
1351
|
-
markerLayer.append(
|
|
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
|
-
|
|
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])))
|
|
1370
|
+
const prepared = markerCatalog.prepare(markers, selectedMarker);
|
|
1371
|
+
if (!prepared)
|
|
1385
1372
|
return;
|
|
1386
|
-
const
|
|
1387
|
-
.id;
|
|
1373
|
+
const { selection: nextSelection, focused: focusedMarker } = prepared;
|
|
1388
1374
|
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;
|
|
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'} (${
|
|
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
|
-
(
|
|
1440
|
-
|
|
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 = !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1763
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1805
|
+
clusters.destroy();
|
|
1830
1806
|
canvas.style.touchAction = 'pan-y pinch-zoom';
|
|
1831
1807
|
observer?.disconnect();
|
|
1832
1808
|
frames.destroy();
|