@kahwee/sf-map-svg 5.1.1 → 5.1.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 +4 -0
- package/dist/src/explorer-core.js +115 -249
- package/dist/src/label-coordinator.d.ts +54 -0
- package/dist/src/label-coordinator.js +157 -0
- package/dist/src/marker-controller.d.ts +55 -0
- package/dist/src/marker-controller.js +125 -0
- package/llms-full.txt +15 -11
- package/llms.txt +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,10 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 5.1.2 — 2026-11-05
|
|
8
|
+
|
|
9
|
+
- Separate marker selection, activation and focus recovery into an internal controller; separate label candidate caching, screen-space collision layout and metrics invalidation into a label coordinator. Preserve the public API and shared animation frame scheduling.
|
|
10
|
+
|
|
7
11
|
## 5.1.1 — 2026-11-05
|
|
8
12
|
|
|
9
13
|
- Reserve precinct report controls and status space during loading to prevent phone layout shifts when multiple reports are available.
|
|
@@ -4,17 +4,17 @@ import { createClusterLayer } from './cluster-layer.js';
|
|
|
4
4
|
import { clusterPoints } from './clusters.js';
|
|
5
5
|
import { createDistrictController } from './district-controller.js';
|
|
6
6
|
import { prepareDistrictStyles } from './district-style.js';
|
|
7
|
-
import { element,
|
|
7
|
+
import { element, svgElement } from './dom.js';
|
|
8
8
|
import { createExplorerControls } from './explorer-controls.js';
|
|
9
9
|
import { fitBounds, projectedBounds } from './explorer-layout.js';
|
|
10
10
|
import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from './features.js';
|
|
11
11
|
import { createFrameScheduler } from './frame-scheduler.js';
|
|
12
|
-
import {
|
|
13
|
-
import { createLabelRenderer } from './label-renderer.js';
|
|
12
|
+
import { createLabelCoordinator } from './label-coordinator.js';
|
|
14
13
|
import { createLayerTransitions } from './layer-transition.js';
|
|
15
14
|
import { createSFMapWithData, districtColors, getLayerPathsWithData, resolveMapColors, } from './map-core.js';
|
|
16
15
|
import { createMarkerCatalog } from './marker-catalog.js';
|
|
17
16
|
import { createMarkerChooser } from './marker-chooser.js';
|
|
17
|
+
import { createMarkerController } from './marker-controller.js';
|
|
18
18
|
import { createMarkerLayer } from './marker-layer.js';
|
|
19
19
|
import { createMarkerNavigation } from './marker-navigation.js';
|
|
20
20
|
import { createNeighborhoodLayer, createNeighborhoodLookup, formatSourceLabel, } from './neighborhood-layer.js';
|
|
@@ -177,7 +177,6 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
177
177
|
'aria-hidden': 'true',
|
|
178
178
|
'font-family': labelStyle.fontFamily ?? styleOptions.font ?? 'system-ui,sans-serif',
|
|
179
179
|
});
|
|
180
|
-
const labelRenderer = createLabelRenderer(labelLayer);
|
|
181
180
|
geography.append(areas, stations, labelLayer, overlayLayer, markerLayer);
|
|
182
181
|
let view = [0, 0, 800];
|
|
183
182
|
const districts = createDistrictController({
|
|
@@ -313,7 +312,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
313
312
|
if (!visual)
|
|
314
313
|
continue;
|
|
315
314
|
const ring = features.selectedMarkerRing;
|
|
316
|
-
visual.ring.setAttribute('display', ring && marker.id ===
|
|
315
|
+
visual.ring.setAttribute('display', ring && marker.id === markers.selected ? 'inline' : 'none');
|
|
317
316
|
visual.ring.setAttribute('stroke', ring ? (ring.color ?? selectedMarkerColor) : selectedMarkerColor);
|
|
318
317
|
visual.ring.setAttribute('stroke-width', String(ring ? ring.width : 2));
|
|
319
318
|
}
|
|
@@ -348,7 +347,6 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
348
347
|
kind: 'road',
|
|
349
348
|
}));
|
|
350
349
|
const secondaryRoads = svg.querySelectorAll('[data-key-road-level="secondary"]');
|
|
351
|
-
let secondaryRoadsVisible;
|
|
352
350
|
const scaleUnitsPerMeter = Math.abs(map.project([-122.44, 37.76])[0] - map.project([-122.45, 37.76])[0]) / 879;
|
|
353
351
|
const parkItems = (data.map.landmarks ?? []).map((feature) => ({
|
|
354
352
|
point: map.project(feature.label),
|
|
@@ -356,18 +354,57 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
356
354
|
kind: 'park',
|
|
357
355
|
}));
|
|
358
356
|
const markerCatalog = createMarkerCatalog(markerRenderer, markerSelect, map.project);
|
|
359
|
-
let selectedMarker = null;
|
|
360
357
|
const markerNavigation = createMarkerNavigation(svg, controller.signal);
|
|
358
|
+
const markers = createMarkerController({
|
|
359
|
+
root,
|
|
360
|
+
canvas,
|
|
361
|
+
svg,
|
|
362
|
+
markerLayer,
|
|
363
|
+
markerSelect,
|
|
364
|
+
markerLabel,
|
|
365
|
+
markerCatalog,
|
|
366
|
+
markerNavigation,
|
|
367
|
+
strings,
|
|
368
|
+
fitPadding,
|
|
369
|
+
onMarkerActivate,
|
|
370
|
+
invalidateClusters,
|
|
371
|
+
drawClusters,
|
|
372
|
+
materializeMarker,
|
|
373
|
+
scheduleLabels,
|
|
374
|
+
syncFeatureControls,
|
|
375
|
+
moveView,
|
|
376
|
+
get markerChooser() {
|
|
377
|
+
return markerChooser;
|
|
378
|
+
},
|
|
379
|
+
get destroyed() {
|
|
380
|
+
return destroyed;
|
|
381
|
+
},
|
|
382
|
+
get view() {
|
|
383
|
+
return view;
|
|
384
|
+
},
|
|
385
|
+
get features() {
|
|
386
|
+
return features;
|
|
387
|
+
},
|
|
388
|
+
get markerColor() {
|
|
389
|
+
return markerColor;
|
|
390
|
+
},
|
|
391
|
+
get selectedMarkerColor() {
|
|
392
|
+
return selectedMarkerColor;
|
|
393
|
+
},
|
|
394
|
+
get controls() {
|
|
395
|
+
return controls;
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
const { getSelectedMarker, selectMarker, setMarkers, activateMarker } = markers;
|
|
361
399
|
const markerChooser = createMarkerChooser(canvas, svg, (id) => {
|
|
362
400
|
if (destroyed)
|
|
363
401
|
return;
|
|
364
402
|
selectMarker(id);
|
|
365
|
-
if (!destroyed &&
|
|
403
|
+
if (!destroyed && markers.selected === id)
|
|
366
404
|
markerCatalog.items
|
|
367
405
|
.find((item) => item.marker.id === id)
|
|
368
406
|
?.visual?.node.focus({ preventScroll: true });
|
|
369
407
|
});
|
|
370
|
-
let markerRevision = 0;
|
|
371
408
|
const clusterLayer = svgElement('g', { 'data-layer': 'marker-clusters' });
|
|
372
409
|
geography.insertBefore(clusterLayer, markerLayer);
|
|
373
410
|
function materializeMarker(item) {
|
|
@@ -377,7 +414,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
377
414
|
markerColor,
|
|
378
415
|
selectedMarkerColor,
|
|
379
416
|
reducedMotion: reducedMotion.matches,
|
|
380
|
-
selected: item.marker.id ===
|
|
417
|
+
selected: item.marker.id === markers.selected,
|
|
381
418
|
}),
|
|
382
419
|
});
|
|
383
420
|
}
|
|
@@ -411,8 +448,8 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
411
448
|
unit,
|
|
412
449
|
view,
|
|
413
450
|
items: markerCatalog.items,
|
|
414
|
-
revision:
|
|
415
|
-
selected:
|
|
451
|
+
revision: markers.revision,
|
|
452
|
+
selected: markers.selected,
|
|
416
453
|
features,
|
|
417
454
|
markerColor,
|
|
418
455
|
markerRadius,
|
|
@@ -501,141 +538,70 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
501
538
|
moveView([0, 0, 800], options);
|
|
502
539
|
}
|
|
503
540
|
function scheduleLabels() {
|
|
504
|
-
|
|
505
|
-
return;
|
|
506
|
-
frames.invalidate();
|
|
507
|
-
}
|
|
508
|
-
let labelInputs = [];
|
|
509
|
-
let preparedLabels = [];
|
|
510
|
-
function prepareLabels(width, zoom) {
|
|
511
|
-
// Only screen-space positioning changes during pan or within these zoom/width bands.
|
|
512
|
-
const inputs = [
|
|
513
|
-
neighborhoods.items,
|
|
514
|
-
neighborhoods.selected,
|
|
515
|
-
districtItems,
|
|
516
|
-
markerRevision,
|
|
517
|
-
layers,
|
|
518
|
-
mode,
|
|
519
|
-
labelStyle,
|
|
520
|
-
colors,
|
|
521
|
-
minLabelSize,
|
|
522
|
-
maxLabelSize,
|
|
523
|
-
markerRadius,
|
|
524
|
-
zoom >= 1.8,
|
|
525
|
-
width >= 550,
|
|
526
|
-
];
|
|
527
|
-
if (inputs.every((value, index) => value === labelInputs[index]))
|
|
528
|
-
return preparedLabels;
|
|
529
|
-
labelInputs = inputs;
|
|
530
|
-
const selectedItem = neighborhoods.items.find((item) => item.feature === neighborhoods.selected);
|
|
531
|
-
const candidates = [];
|
|
532
|
-
const marker = markerCatalog.items.find((item) => item.marker.id === selectedMarker);
|
|
533
|
-
if (marker)
|
|
534
|
-
candidates.push({
|
|
535
|
-
point: marker.point,
|
|
536
|
-
name: marker.marker.label ?? marker.marker.id,
|
|
537
|
-
kind: 'selected-marker',
|
|
538
|
-
});
|
|
539
|
-
if (selectedItem?.point && enabled('neighborhoodLabels'))
|
|
540
|
-
candidates.push({
|
|
541
|
-
point: selectedItem.point,
|
|
542
|
-
name: selectedItem.feature.properties.canonicalName,
|
|
543
|
-
kind: 'selected',
|
|
544
|
-
});
|
|
545
|
-
preparedLabels = styleLabels(labelCandidates({
|
|
546
|
-
selected: candidates,
|
|
547
|
-
districts: enabled('districtLabels') ? districtItems : [],
|
|
548
|
-
stations: enabled('bartStations') ? stationItems : [],
|
|
549
|
-
parks: enabled('landmarks') ? parkItems : [],
|
|
550
|
-
roads: enabled('roadLabels') ? roadItems : [],
|
|
551
|
-
neighborhoods: enabled('neighborhoodLabels')
|
|
552
|
-
? neighborhoods.items
|
|
553
|
-
.filter((item) => item.point !== undefined)
|
|
554
|
-
.sort((a, b) => b.area - a.area || a.feature.id.localeCompare(b.feature.id))
|
|
555
|
-
.flatMap((item) => item.feature !== neighborhoods.selected && item.point
|
|
556
|
-
? [
|
|
557
|
-
{
|
|
558
|
-
point: item.point,
|
|
559
|
-
name: item.feature.properties.canonicalName,
|
|
560
|
-
kind: 'neighborhood',
|
|
561
|
-
},
|
|
562
|
-
]
|
|
563
|
-
: [])
|
|
564
|
-
: [],
|
|
565
|
-
}, width, zoom), {
|
|
566
|
-
colors,
|
|
567
|
-
labelStyle,
|
|
568
|
-
labelSize: { min: minLabelSize, max: maxLabelSize },
|
|
569
|
-
markerRadius: marker?.marker.radius ?? markerRadius,
|
|
570
|
-
});
|
|
571
|
-
return preparedLabels;
|
|
541
|
+
labelCoordinator.schedule();
|
|
572
542
|
}
|
|
543
|
+
const labelCoordinator = createLabelCoordinator({
|
|
544
|
+
root,
|
|
545
|
+
canvas,
|
|
546
|
+
scale,
|
|
547
|
+
overlayElement,
|
|
548
|
+
secondaryRoads,
|
|
549
|
+
stationItems,
|
|
550
|
+
roadItems,
|
|
551
|
+
parkItems,
|
|
552
|
+
scaleUnitsPerMeter,
|
|
553
|
+
neighborhoods,
|
|
554
|
+
markerCatalog,
|
|
555
|
+
markers,
|
|
556
|
+
enabled,
|
|
557
|
+
drawClusters,
|
|
558
|
+
labelLayer,
|
|
559
|
+
invalidateFrame: frames.invalidate,
|
|
560
|
+
get districtItems() {
|
|
561
|
+
return districtItems;
|
|
562
|
+
},
|
|
563
|
+
get layers() {
|
|
564
|
+
return layers;
|
|
565
|
+
},
|
|
566
|
+
get mode() {
|
|
567
|
+
return mode;
|
|
568
|
+
},
|
|
569
|
+
get labelStyle() {
|
|
570
|
+
return labelStyle;
|
|
571
|
+
},
|
|
572
|
+
get colors() {
|
|
573
|
+
return colors;
|
|
574
|
+
},
|
|
575
|
+
get minLabelSize() {
|
|
576
|
+
return minLabelSize;
|
|
577
|
+
},
|
|
578
|
+
get maxLabelSize() {
|
|
579
|
+
return maxLabelSize;
|
|
580
|
+
},
|
|
581
|
+
get markerRadius() {
|
|
582
|
+
return markerRadius;
|
|
583
|
+
},
|
|
584
|
+
get view() {
|
|
585
|
+
return view;
|
|
586
|
+
},
|
|
587
|
+
get destroyed() {
|
|
588
|
+
return destroyed;
|
|
589
|
+
},
|
|
590
|
+
get features() {
|
|
591
|
+
return features;
|
|
592
|
+
},
|
|
593
|
+
get labels() {
|
|
594
|
+
return labels;
|
|
595
|
+
},
|
|
596
|
+
get renderedMarkers() {
|
|
597
|
+
return renderedMarkers;
|
|
598
|
+
},
|
|
599
|
+
get renderedClusters() {
|
|
600
|
+
return renderedClusters;
|
|
601
|
+
},
|
|
602
|
+
});
|
|
573
603
|
function drawLabels() {
|
|
574
|
-
|
|
575
|
-
return;
|
|
576
|
-
const canvasBox = canvas.getBoundingClientRect();
|
|
577
|
-
const width = canvasBox.width;
|
|
578
|
-
if (!width)
|
|
579
|
-
return;
|
|
580
|
-
const unit = view[2] / width, zoom = 800 / view[2];
|
|
581
|
-
if (features.scaleBar) {
|
|
582
|
-
const pixelsPerMeter = scaleUnitsPerMeter / unit;
|
|
583
|
-
const base = 10 ** Math.floor(Math.log10(100 / pixelsPerMeter));
|
|
584
|
-
const meters = [5, 2, 1].map((factor) => factor * base).find((value) => value * pixelsPerMeter <= 100) ??
|
|
585
|
-
base;
|
|
586
|
-
const scaleWidth = `${meters * pixelsPerMeter}px`;
|
|
587
|
-
const scaleText = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`;
|
|
588
|
-
if (scale.style.width !== scaleWidth)
|
|
589
|
-
scale.style.width = scaleWidth;
|
|
590
|
-
if (scale.textContent !== scaleText)
|
|
591
|
-
scale.textContent = scaleText;
|
|
592
|
-
}
|
|
593
|
-
// Read overlay bounds before SVG writes; scale furniture above must have its current width.
|
|
594
|
-
const furnitureBounds = [...overlayElement.children]
|
|
595
|
-
.filter((node) => node.getClientRects().length > 0)
|
|
596
|
-
.map((node) => {
|
|
597
|
-
const box = node.getBoundingClientRect();
|
|
598
|
-
return [
|
|
599
|
-
box.left - canvasBox.left - 3,
|
|
600
|
-
box.top - canvasBox.top - 3,
|
|
601
|
-
box.right - canvasBox.left + 3,
|
|
602
|
-
box.bottom - canvasBox.top + 3,
|
|
603
|
-
];
|
|
604
|
-
});
|
|
605
|
-
const showSecondaryRoads = zoom >= 1.8;
|
|
606
|
-
if (showSecondaryRoads !== secondaryRoadsVisible) {
|
|
607
|
-
for (const road of secondaryRoads)
|
|
608
|
-
road.style.display = showSecondaryRoads ? '' : 'none';
|
|
609
|
-
secondaryRoadsVisible = showSecondaryRoads;
|
|
610
|
-
}
|
|
611
|
-
for (const station of stationItems)
|
|
612
|
-
setAttributeIfChanged(station.node, 'r', 4.5 * unit);
|
|
613
|
-
drawClusters(unit);
|
|
614
|
-
if (!labels) {
|
|
615
|
-
labelRenderer.clear();
|
|
616
|
-
root.dataset.visibleLabels = '0';
|
|
617
|
-
return;
|
|
618
|
-
}
|
|
619
|
-
const labelCandidates = prepareLabels(width, zoom);
|
|
620
|
-
const stationBounds = (enabled('bartStations') ? stationItems : []).map(({ point }) => {
|
|
621
|
-
const x = (point[0] - view[0]) / unit;
|
|
622
|
-
const y = (point[1] - view[1]) / unit;
|
|
623
|
-
return [x - 6, y - 6, x + 6, y + 6];
|
|
624
|
-
});
|
|
625
|
-
const markerBounds = renderedMarkers.map(({ point, marker }) => {
|
|
626
|
-
const radius = marker.radius ?? markerRadius;
|
|
627
|
-
const x = (point[0] - view[0]) / unit, y = (point[1] - view[1]) / unit;
|
|
628
|
-
return [x - radius - 3, y - radius - 3, x + radius + 3, y + radius + 3];
|
|
629
|
-
});
|
|
630
|
-
root.dataset.visibleLabels = String(labelRenderer.draw(labelCandidates, view, width, [
|
|
631
|
-
...stationBounds,
|
|
632
|
-
...markerBounds,
|
|
633
|
-
...furnitureBounds,
|
|
634
|
-
...renderedClusters.map(({ point }) => {
|
|
635
|
-
const x = (point[0] - view[0]) / unit, y = (point[1] - view[1]) / unit;
|
|
636
|
-
return [x - 26, y - 26, x + 26, y + 26];
|
|
637
|
-
}),
|
|
638
|
-
]));
|
|
604
|
+
labelCoordinator.draw();
|
|
639
605
|
}
|
|
640
606
|
const sidebar = ui.createSidebar?.(shell, data, chrome, strings.emptyResults, searchNeighborhoods);
|
|
641
607
|
function updateDetail() {
|
|
@@ -741,91 +707,6 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
741
707
|
attribution.textContent = description;
|
|
742
708
|
scheduleLabels();
|
|
743
709
|
}
|
|
744
|
-
function getSelectedMarker() {
|
|
745
|
-
const marker = markerCatalog.items.find((item) => item.marker.id === selectedMarker)?.marker;
|
|
746
|
-
return marker ? { ...marker, position: [...marker.position] } : null;
|
|
747
|
-
}
|
|
748
|
-
function selectMarker(id, options = {}) {
|
|
749
|
-
if (destroyed)
|
|
750
|
-
return false;
|
|
751
|
-
const item = markerCatalog.items.find((item) => item.marker.id === id);
|
|
752
|
-
if (id !== null && !item)
|
|
753
|
-
return false;
|
|
754
|
-
validateCameraOptions(options);
|
|
755
|
-
const width = canvas.getBoundingClientRect().width;
|
|
756
|
-
const target = item && options.fit !== false
|
|
757
|
-
? width
|
|
758
|
-
? fitViewport([...item.point, ...item.point], width, fitPadding)
|
|
759
|
-
: fitBounds([...item.point, ...item.point])
|
|
760
|
-
: null;
|
|
761
|
-
const revision = ++markerRevision;
|
|
762
|
-
const changed = selectedMarker !== id;
|
|
763
|
-
selectedMarker = id;
|
|
764
|
-
if (changed)
|
|
765
|
-
invalidateClusters();
|
|
766
|
-
markerSelect.value = id ?? '';
|
|
767
|
-
for (const entry of markerCatalog.items) {
|
|
768
|
-
const active = entry.marker.id === id;
|
|
769
|
-
const visual = active ? materializeMarker(entry).visual : entry.visual;
|
|
770
|
-
if (!visual)
|
|
771
|
-
continue;
|
|
772
|
-
const pressed = String(active);
|
|
773
|
-
const ring = active && features.selectedMarkerRing ? 'inline' : 'none';
|
|
774
|
-
const fill = active ? selectedMarkerColor : (entry.marker.color ?? markerColor);
|
|
775
|
-
if (visual.node.getAttribute('aria-pressed') !== pressed)
|
|
776
|
-
visual.node.setAttribute('aria-pressed', pressed);
|
|
777
|
-
if (visual.ring.getAttribute('display') !== ring)
|
|
778
|
-
visual.ring.setAttribute('display', ring);
|
|
779
|
-
if (visual.dot.getAttribute('fill') !== fill)
|
|
780
|
-
visual.dot.setAttribute('fill', fill);
|
|
781
|
-
if (active && markerLayer.lastChild !== visual.node)
|
|
782
|
-
markerLayer.append(visual.node);
|
|
783
|
-
}
|
|
784
|
-
scheduleLabels();
|
|
785
|
-
if (width || !features.clustering)
|
|
786
|
-
drawClusters(width ? view[2] / width : 1);
|
|
787
|
-
const marker = getSelectedMarker();
|
|
788
|
-
if (changed)
|
|
789
|
-
root.dispatchEvent(new CustomEvent('markerchange', { bubbles: true, detail: { id, marker } }));
|
|
790
|
-
if (!destroyed && changed && marker && markerRevision === revision && selectedMarker === id)
|
|
791
|
-
onMarkerActivate?.({ ...marker });
|
|
792
|
-
if (!destroyed && markerRevision === revision && target)
|
|
793
|
-
moveView(target, options);
|
|
794
|
-
return true;
|
|
795
|
-
}
|
|
796
|
-
function setMarkers(markers) {
|
|
797
|
-
if (destroyed)
|
|
798
|
-
return;
|
|
799
|
-
const prepared = markerCatalog.prepare(markers, selectedMarker);
|
|
800
|
-
if (!prepared)
|
|
801
|
-
return;
|
|
802
|
-
const { selection: nextSelection, focused: focusedMarker } = prepared;
|
|
803
|
-
invalidateClusters();
|
|
804
|
-
prepared.commit();
|
|
805
|
-
markerLabel.hidden = !markerCatalog.items.length || controls.markerPicker === false;
|
|
806
|
-
if (markerLabel.firstChild)
|
|
807
|
-
markerLabel.firstChild.textContent = `${strings.chooseMarker ?? 'Choose marker'} (${markerCatalog.items.length})`;
|
|
808
|
-
syncFeatureControls();
|
|
809
|
-
const revision = markerRevision + 1;
|
|
810
|
-
selectMarker(nextSelection, { fit: false });
|
|
811
|
-
if (!destroyed && markerRevision === revision && focusedMarker) {
|
|
812
|
-
markerNavigation.sync(markerCatalog.items.flatMap((item) => (item.visual ? [item.visual.node] : [])));
|
|
813
|
-
if (!markerNavigation.recover()) {
|
|
814
|
-
const recovery = markerCatalog.items.find((item) => item.marker.id === focusedMarker) ??
|
|
815
|
-
markerCatalog.items[0];
|
|
816
|
-
if (recovery) {
|
|
817
|
-
const { node } = materializeMarker(recovery).visual;
|
|
818
|
-
markerLayer.append(node);
|
|
819
|
-
node.focus({ preventScroll: true });
|
|
820
|
-
const width = canvas.getBoundingClientRect().width;
|
|
821
|
-
if (width)
|
|
822
|
-
drawClusters(view[2] / width);
|
|
823
|
-
}
|
|
824
|
-
else
|
|
825
|
-
svg.focus({ preventScroll: true });
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
710
|
function setControls(patch) {
|
|
830
711
|
if (destroyed)
|
|
831
712
|
return;
|
|
@@ -924,21 +805,6 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
924
805
|
});
|
|
925
806
|
listen(markerLayer, 'focusout', scheduleLabels);
|
|
926
807
|
listen(markerLayer, 'focusin', scheduleLabels);
|
|
927
|
-
function activateMarker(id, node) {
|
|
928
|
-
const item = markerCatalog.items.find((item) => item.marker.id === id);
|
|
929
|
-
if (!item)
|
|
930
|
-
return;
|
|
931
|
-
const coincident = markerCatalog.items.filter((other) => other.point[0] === item.point[0] && other.point[1] === item.point[1]);
|
|
932
|
-
if (coincident.length > 1)
|
|
933
|
-
markerChooser.open(coincident.map((other) => other.marker), node);
|
|
934
|
-
else {
|
|
935
|
-
selectMarker(id);
|
|
936
|
-
if (!destroyed && selectedMarker === id)
|
|
937
|
-
markerCatalog.items
|
|
938
|
-
.find((other) => other.marker.id === id)
|
|
939
|
-
?.visual?.node.focus({ preventScroll: true });
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
808
|
listen(markerLayer, 'click', (event) => {
|
|
943
809
|
const node = event.target instanceof Element ? event.target.closest('[data-marker-id]') : null;
|
|
944
810
|
if (node?.dataset.markerId)
|
|
@@ -1074,7 +940,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
1074
940
|
])
|
|
1075
941
|
root.style.setProperty(`--sf-map-${name}`, styleOptions[token] ?? fallback);
|
|
1076
942
|
labelLayer.setAttribute('font-family', labelStyle.fontFamily ?? styleOptions.font ?? 'system-ui,sans-serif');
|
|
1077
|
-
|
|
943
|
+
labelCoordinator.invalidateMetrics();
|
|
1078
944
|
for (const station of stationItems)
|
|
1079
945
|
station.node.setAttribute('stroke', colors.bart ?? '#0073ae');
|
|
1080
946
|
for (const kind of ['bart', 'park', 'highway', 'road']) {
|
|
@@ -1094,7 +960,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
1094
960
|
const visual = item.visual;
|
|
1095
961
|
if (!visual)
|
|
1096
962
|
continue;
|
|
1097
|
-
visual.dot.setAttribute('fill', item.marker.id ===
|
|
963
|
+
visual.dot.setAttribute('fill', item.marker.id === markers.selected
|
|
1098
964
|
? selectedMarkerColor
|
|
1099
965
|
: (item.marker.color ?? markerColor));
|
|
1100
966
|
visual.ring.setAttribute('stroke', features.selectedMarkerRing
|
|
@@ -1165,7 +1031,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
1165
1031
|
canvas.style.touchAction = 'pan-y pinch-zoom';
|
|
1166
1032
|
observer?.disconnect();
|
|
1167
1033
|
frames.destroy();
|
|
1168
|
-
|
|
1034
|
+
labelCoordinator.clear();
|
|
1169
1035
|
sidebar?.destroy();
|
|
1170
1036
|
neighborhoods.destroy();
|
|
1171
1037
|
},
|
|
@@ -1174,7 +1040,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
1174
1040
|
observer = new ResizeObserver(onResize);
|
|
1175
1041
|
observer.observe(canvas);
|
|
1176
1042
|
document.fonts?.addEventListener('loadingdone', () => {
|
|
1177
|
-
|
|
1043
|
+
labelCoordinator.invalidateMetrics();
|
|
1178
1044
|
scheduleLabels();
|
|
1179
1045
|
}, { signal: controller.signal });
|
|
1180
1046
|
reducedMotion.addEventListener('change', () => {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { View } from './explorer-layout.js';
|
|
2
|
+
import type { normalizeFeatures } from './features.js';
|
|
3
|
+
import { type LabelItem } from './label-candidates.js';
|
|
4
|
+
import type { createMarkerCatalog } from './marker-catalog.js';
|
|
5
|
+
import type { createMarkerController } from './marker-controller.js';
|
|
6
|
+
import type { MarkerItem, MarkerVisual } from './marker-layer.js';
|
|
7
|
+
import type { createNeighborhoodLayer } from './neighborhood-layer.js';
|
|
8
|
+
import type { ExplorerMode, InteractiveLayers, NeighborhoodExplorerOptions } from './types.js';
|
|
9
|
+
interface LabelHost {
|
|
10
|
+
root: HTMLElement;
|
|
11
|
+
canvas: HTMLElement;
|
|
12
|
+
scale: HTMLElement;
|
|
13
|
+
overlayElement: HTMLElement;
|
|
14
|
+
secondaryRoads: NodeListOf<SVGPathElement>;
|
|
15
|
+
stationItems: (LabelItem & {
|
|
16
|
+
node: SVGCircleElement;
|
|
17
|
+
})[];
|
|
18
|
+
roadItems: LabelItem[];
|
|
19
|
+
parkItems: LabelItem[];
|
|
20
|
+
scaleUnitsPerMeter: number;
|
|
21
|
+
neighborhoods: ReturnType<typeof createNeighborhoodLayer>;
|
|
22
|
+
markerCatalog: ReturnType<typeof createMarkerCatalog>;
|
|
23
|
+
markers: ReturnType<typeof createMarkerController>;
|
|
24
|
+
labelLayer: SVGGElement;
|
|
25
|
+
invalidateFrame(): void;
|
|
26
|
+
get districtItems(): LabelItem[];
|
|
27
|
+
get layers(): InteractiveLayers;
|
|
28
|
+
get mode(): ExplorerMode;
|
|
29
|
+
get labelStyle(): NonNullable<NeighborhoodExplorerOptions['labelStyle']>;
|
|
30
|
+
get colors(): NonNullable<NeighborhoodExplorerOptions['colors']>;
|
|
31
|
+
get minLabelSize(): number;
|
|
32
|
+
get maxLabelSize(): number;
|
|
33
|
+
get markerRadius(): number;
|
|
34
|
+
get view(): View;
|
|
35
|
+
get destroyed(): boolean;
|
|
36
|
+
get labels(): boolean;
|
|
37
|
+
get features(): ReturnType<typeof normalizeFeatures>;
|
|
38
|
+
get renderedMarkers(): (MarkerItem & {
|
|
39
|
+
visual: MarkerVisual;
|
|
40
|
+
})[];
|
|
41
|
+
get renderedClusters(): {
|
|
42
|
+
point: [number, number];
|
|
43
|
+
}[];
|
|
44
|
+
enabled(layer: keyof InteractiveLayers): boolean;
|
|
45
|
+
drawClusters(unit: number): void;
|
|
46
|
+
}
|
|
47
|
+
/** Own candidate caching, screen-space collision layout and label metrics invalidation. */
|
|
48
|
+
export declare function createLabelCoordinator(host: LabelHost): {
|
|
49
|
+
draw: () => void;
|
|
50
|
+
schedule(): void;
|
|
51
|
+
invalidateMetrics(): void;
|
|
52
|
+
clear(): void;
|
|
53
|
+
};
|
|
54
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { setAttributeIfChanged } from './dom.js';
|
|
2
|
+
import { labelCandidates, styleLabels, } from './label-candidates.js';
|
|
3
|
+
import { createLabelRenderer } from './label-renderer.js';
|
|
4
|
+
/** Own candidate caching, screen-space collision layout and label metrics invalidation. */
|
|
5
|
+
export function createLabelCoordinator(host) {
|
|
6
|
+
const { root, canvas, scale, overlayElement, secondaryRoads, stationItems, roadItems, parkItems, scaleUnitsPerMeter, neighborhoods, markerCatalog, markers, enabled, drawClusters, } = host;
|
|
7
|
+
const labelRenderer = createLabelRenderer(host.labelLayer);
|
|
8
|
+
let secondaryRoadsVisible;
|
|
9
|
+
let labelInputs = [];
|
|
10
|
+
let preparedLabels = [];
|
|
11
|
+
function prepareLabels(width, zoom) {
|
|
12
|
+
// Only screen-space positioning changes during pan or within these zoom/width bands.
|
|
13
|
+
const inputs = [
|
|
14
|
+
neighborhoods.items,
|
|
15
|
+
neighborhoods.selected,
|
|
16
|
+
host.districtItems,
|
|
17
|
+
markers.revision,
|
|
18
|
+
host.layers,
|
|
19
|
+
host.mode,
|
|
20
|
+
host.labelStyle,
|
|
21
|
+
host.colors,
|
|
22
|
+
host.minLabelSize,
|
|
23
|
+
host.maxLabelSize,
|
|
24
|
+
host.markerRadius,
|
|
25
|
+
zoom >= 1.8,
|
|
26
|
+
width >= 550,
|
|
27
|
+
];
|
|
28
|
+
if (inputs.every((value, index) => value === labelInputs[index]))
|
|
29
|
+
return preparedLabels;
|
|
30
|
+
labelInputs = inputs;
|
|
31
|
+
const selectedItem = neighborhoods.items.find((item) => item.feature === neighborhoods.selected);
|
|
32
|
+
const candidates = [];
|
|
33
|
+
const marker = markerCatalog.items.find((item) => item.marker.id === markers.selected);
|
|
34
|
+
if (marker)
|
|
35
|
+
candidates.push({
|
|
36
|
+
point: marker.point,
|
|
37
|
+
name: marker.marker.label ?? marker.marker.id,
|
|
38
|
+
kind: 'selected-marker',
|
|
39
|
+
});
|
|
40
|
+
if (selectedItem?.point && enabled('neighborhoodLabels'))
|
|
41
|
+
candidates.push({
|
|
42
|
+
point: selectedItem.point,
|
|
43
|
+
name: selectedItem.feature.properties.canonicalName,
|
|
44
|
+
kind: 'selected',
|
|
45
|
+
});
|
|
46
|
+
preparedLabels = styleLabels(labelCandidates({
|
|
47
|
+
selected: candidates,
|
|
48
|
+
districts: enabled('districtLabels') ? host.districtItems : [],
|
|
49
|
+
stations: enabled('bartStations') ? stationItems : [],
|
|
50
|
+
parks: enabled('landmarks') ? parkItems : [],
|
|
51
|
+
roads: enabled('roadLabels') ? roadItems : [],
|
|
52
|
+
neighborhoods: enabled('neighborhoodLabels')
|
|
53
|
+
? neighborhoods.items
|
|
54
|
+
.filter((item) => item.point !== undefined)
|
|
55
|
+
.sort((a, b) => b.area - a.area || a.feature.id.localeCompare(b.feature.id))
|
|
56
|
+
.flatMap((item) => item.feature !== neighborhoods.selected && item.point
|
|
57
|
+
? [
|
|
58
|
+
{
|
|
59
|
+
point: item.point,
|
|
60
|
+
name: item.feature.properties.canonicalName,
|
|
61
|
+
kind: 'neighborhood',
|
|
62
|
+
},
|
|
63
|
+
]
|
|
64
|
+
: [])
|
|
65
|
+
: [],
|
|
66
|
+
}, width, zoom), {
|
|
67
|
+
colors: host.colors,
|
|
68
|
+
labelStyle: host.labelStyle,
|
|
69
|
+
labelSize: { min: host.minLabelSize, max: host.maxLabelSize },
|
|
70
|
+
markerRadius: marker?.marker.radius ?? host.markerRadius,
|
|
71
|
+
});
|
|
72
|
+
return preparedLabels;
|
|
73
|
+
}
|
|
74
|
+
function drawLabels() {
|
|
75
|
+
if (host.destroyed)
|
|
76
|
+
return;
|
|
77
|
+
const canvasBox = canvas.getBoundingClientRect();
|
|
78
|
+
const width = canvasBox.width;
|
|
79
|
+
if (!width)
|
|
80
|
+
return;
|
|
81
|
+
const unit = host.view[2] / width, zoom = 800 / host.view[2];
|
|
82
|
+
if (host.features.scaleBar) {
|
|
83
|
+
const pixelsPerMeter = scaleUnitsPerMeter / unit;
|
|
84
|
+
const base = 10 ** Math.floor(Math.log10(100 / pixelsPerMeter));
|
|
85
|
+
const meters = [5, 2, 1].map((factor) => factor * base).find((value) => value * pixelsPerMeter <= 100) ??
|
|
86
|
+
base;
|
|
87
|
+
const scaleWidth = `${meters * pixelsPerMeter}px`;
|
|
88
|
+
const scaleText = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`;
|
|
89
|
+
if (scale.style.width !== scaleWidth)
|
|
90
|
+
scale.style.width = scaleWidth;
|
|
91
|
+
if (scale.textContent !== scaleText)
|
|
92
|
+
scale.textContent = scaleText;
|
|
93
|
+
}
|
|
94
|
+
// Read overlay bounds before SVG writes; scale furniture above must have its current width.
|
|
95
|
+
const furnitureBounds = [...overlayElement.children]
|
|
96
|
+
.filter((node) => node.getClientRects().length > 0)
|
|
97
|
+
.map((node) => {
|
|
98
|
+
const box = node.getBoundingClientRect();
|
|
99
|
+
return [
|
|
100
|
+
box.left - canvasBox.left - 3,
|
|
101
|
+
box.top - canvasBox.top - 3,
|
|
102
|
+
box.right - canvasBox.left + 3,
|
|
103
|
+
box.bottom - canvasBox.top + 3,
|
|
104
|
+
];
|
|
105
|
+
});
|
|
106
|
+
const showSecondaryRoads = zoom >= 1.8;
|
|
107
|
+
if (showSecondaryRoads !== secondaryRoadsVisible) {
|
|
108
|
+
for (const road of secondaryRoads)
|
|
109
|
+
road.style.display = showSecondaryRoads ? '' : 'none';
|
|
110
|
+
secondaryRoadsVisible = showSecondaryRoads;
|
|
111
|
+
}
|
|
112
|
+
for (const station of stationItems)
|
|
113
|
+
setAttributeIfChanged(station.node, 'r', 4.5 * unit);
|
|
114
|
+
drawClusters(unit);
|
|
115
|
+
if (!host.labels) {
|
|
116
|
+
labelRenderer.clear();
|
|
117
|
+
root.dataset.visibleLabels = '0';
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const labelCandidates = prepareLabels(width, zoom);
|
|
121
|
+
const stationBounds = (enabled('bartStations') ? stationItems : []).map(({ point }) => {
|
|
122
|
+
const x = (point[0] - host.view[0]) / unit;
|
|
123
|
+
const y = (point[1] - host.view[1]) / unit;
|
|
124
|
+
return [x - 6, y - 6, x + 6, y + 6];
|
|
125
|
+
});
|
|
126
|
+
const markerBounds = host.renderedMarkers.map(({ point, marker }) => {
|
|
127
|
+
const radius = marker.radius ?? host.markerRadius;
|
|
128
|
+
const x = (point[0] - host.view[0]) / unit, y = (point[1] - host.view[1]) / unit;
|
|
129
|
+
return [x - radius - 3, y - radius - 3, x + radius + 3, y + radius + 3];
|
|
130
|
+
});
|
|
131
|
+
root.dataset.visibleLabels = String(labelRenderer.draw(labelCandidates, host.view, width, [
|
|
132
|
+
...stationBounds,
|
|
133
|
+
...markerBounds,
|
|
134
|
+
...furnitureBounds,
|
|
135
|
+
...host.renderedClusters.map(({ point }) => {
|
|
136
|
+
const x = (point[0] - host.view[0]) / unit, y = (point[1] - host.view[1]) / unit;
|
|
137
|
+
return [x - 26, y - 26, x + 26, y + 26];
|
|
138
|
+
}),
|
|
139
|
+
]));
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
draw: drawLabels,
|
|
143
|
+
schedule() {
|
|
144
|
+
if (!host.destroyed)
|
|
145
|
+
host.invalidateFrame();
|
|
146
|
+
},
|
|
147
|
+
invalidateMetrics() {
|
|
148
|
+
labelRenderer.invalidateMetrics();
|
|
149
|
+
labelInputs = [];
|
|
150
|
+
},
|
|
151
|
+
clear() {
|
|
152
|
+
labelRenderer.clear();
|
|
153
|
+
labelInputs = [];
|
|
154
|
+
preparedLabels = [];
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Position } from '../data/types.js';
|
|
2
|
+
import { type View } from './explorer-layout.js';
|
|
3
|
+
import type { normalizeFeatures } from './features.js';
|
|
4
|
+
import type { createMarkerCatalog } from './marker-catalog.js';
|
|
5
|
+
import type { createMarkerChooser } from './marker-chooser.js';
|
|
6
|
+
import type { MarkerItem, MarkerVisual } from './marker-layer.js';
|
|
7
|
+
import type { createMarkerNavigation } from './marker-navigation.js';
|
|
8
|
+
import type { CameraOptions, MapMarker, NeighborhoodExplorerOptions } from './types.js';
|
|
9
|
+
interface MarkerHost {
|
|
10
|
+
root: HTMLElement;
|
|
11
|
+
canvas: HTMLElement;
|
|
12
|
+
svg: SVGSVGElement;
|
|
13
|
+
markerLayer: SVGGElement;
|
|
14
|
+
markerSelect: HTMLSelectElement;
|
|
15
|
+
markerLabel: HTMLElement;
|
|
16
|
+
markerCatalog: ReturnType<typeof createMarkerCatalog>;
|
|
17
|
+
markerNavigation: ReturnType<typeof createMarkerNavigation>;
|
|
18
|
+
get markerChooser(): ReturnType<typeof createMarkerChooser>;
|
|
19
|
+
get destroyed(): boolean;
|
|
20
|
+
get view(): View;
|
|
21
|
+
get features(): ReturnType<typeof normalizeFeatures>;
|
|
22
|
+
get markerColor(): string;
|
|
23
|
+
get selectedMarkerColor(): string;
|
|
24
|
+
get controls(): NonNullable<NeighborhoodExplorerOptions['controls']>;
|
|
25
|
+
strings: NonNullable<NeighborhoodExplorerOptions['strings']>;
|
|
26
|
+
fitPadding: NonNullable<NeighborhoodExplorerOptions['fitPadding']>;
|
|
27
|
+
onMarkerActivate: NeighborhoodExplorerOptions['onMarkerActivate'];
|
|
28
|
+
invalidateClusters(): void;
|
|
29
|
+
drawClusters(unit: number): void;
|
|
30
|
+
materializeMarker(item: MarkerItem): MarkerItem & {
|
|
31
|
+
visual: MarkerVisual;
|
|
32
|
+
};
|
|
33
|
+
scheduleLabels(): void;
|
|
34
|
+
syncFeatureControls(): void;
|
|
35
|
+
moveView(view: View, options?: CameraOptions): void;
|
|
36
|
+
}
|
|
37
|
+
/** Own selection revisions, activation and focus recovery independently of marker rendering. */
|
|
38
|
+
export declare function createMarkerController(host: MarkerHost): {
|
|
39
|
+
getSelectedMarker: () => {
|
|
40
|
+
id: string;
|
|
41
|
+
label?: string;
|
|
42
|
+
selected?: boolean;
|
|
43
|
+
color?: string;
|
|
44
|
+
radius?: number;
|
|
45
|
+
position: Position;
|
|
46
|
+
} | null;
|
|
47
|
+
selectMarker: (id: string | null, options?: {
|
|
48
|
+
fit?: boolean;
|
|
49
|
+
} & CameraOptions) => boolean;
|
|
50
|
+
setMarkers: (markers: readonly MapMarker[]) => void;
|
|
51
|
+
activateMarker: (id: string, node: SVGElement) => void;
|
|
52
|
+
readonly selected: string | null;
|
|
53
|
+
readonly revision: number;
|
|
54
|
+
};
|
|
55
|
+
export {};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { validateCameraOptions } from './camera.js';
|
|
2
|
+
import { fitBounds } from './explorer-layout.js';
|
|
3
|
+
import { fitViewport } from './viewport.js';
|
|
4
|
+
/** Own selection revisions, activation and focus recovery independently of marker rendering. */
|
|
5
|
+
export function createMarkerController(host) {
|
|
6
|
+
const { root, canvas, svg, markerLayer, markerSelect, markerLabel, markerCatalog, markerNavigation, strings, fitPadding, onMarkerActivate, invalidateClusters, drawClusters, materializeMarker, scheduleLabels, syncFeatureControls, moveView, } = host;
|
|
7
|
+
let selectedMarker = null;
|
|
8
|
+
let markerRevision = 0;
|
|
9
|
+
function getSelectedMarker() {
|
|
10
|
+
const marker = markerCatalog.items.find((item) => item.marker.id === selectedMarker)?.marker;
|
|
11
|
+
return marker ? { ...marker, position: [...marker.position] } : null;
|
|
12
|
+
}
|
|
13
|
+
function selectMarker(id, options = {}) {
|
|
14
|
+
if (host.destroyed)
|
|
15
|
+
return false;
|
|
16
|
+
const item = markerCatalog.items.find((item) => item.marker.id === id);
|
|
17
|
+
if (id !== null && !item)
|
|
18
|
+
return false;
|
|
19
|
+
validateCameraOptions(options);
|
|
20
|
+
const width = canvas.getBoundingClientRect().width;
|
|
21
|
+
const target = item && options.fit !== false
|
|
22
|
+
? width
|
|
23
|
+
? fitViewport([...item.point, ...item.point], width, fitPadding)
|
|
24
|
+
: fitBounds([...item.point, ...item.point])
|
|
25
|
+
: null;
|
|
26
|
+
const revision = ++markerRevision;
|
|
27
|
+
const changed = selectedMarker !== id;
|
|
28
|
+
selectedMarker = id;
|
|
29
|
+
if (changed)
|
|
30
|
+
invalidateClusters();
|
|
31
|
+
markerSelect.value = id ?? '';
|
|
32
|
+
for (const entry of markerCatalog.items) {
|
|
33
|
+
const active = entry.marker.id === id;
|
|
34
|
+
const visual = active ? materializeMarker(entry).visual : entry.visual;
|
|
35
|
+
if (!visual)
|
|
36
|
+
continue;
|
|
37
|
+
const pressed = String(active);
|
|
38
|
+
const ring = active && host.features.selectedMarkerRing ? 'inline' : 'none';
|
|
39
|
+
const fill = active ? host.selectedMarkerColor : (entry.marker.color ?? host.markerColor);
|
|
40
|
+
if (visual.node.getAttribute('aria-pressed') !== pressed)
|
|
41
|
+
visual.node.setAttribute('aria-pressed', pressed);
|
|
42
|
+
if (visual.ring.getAttribute('display') !== ring)
|
|
43
|
+
visual.ring.setAttribute('display', ring);
|
|
44
|
+
if (visual.dot.getAttribute('fill') !== fill)
|
|
45
|
+
visual.dot.setAttribute('fill', fill);
|
|
46
|
+
if (active && markerLayer.lastChild !== visual.node)
|
|
47
|
+
markerLayer.append(visual.node);
|
|
48
|
+
}
|
|
49
|
+
scheduleLabels();
|
|
50
|
+
if (width || !host.features.clustering)
|
|
51
|
+
drawClusters(width ? host.view[2] / width : 1);
|
|
52
|
+
const marker = getSelectedMarker();
|
|
53
|
+
if (changed)
|
|
54
|
+
root.dispatchEvent(new CustomEvent('markerchange', { bubbles: true, detail: { id, marker } }));
|
|
55
|
+
if (!host.destroyed &&
|
|
56
|
+
changed &&
|
|
57
|
+
marker &&
|
|
58
|
+
markerRevision === revision &&
|
|
59
|
+
selectedMarker === id)
|
|
60
|
+
onMarkerActivate?.({ ...marker });
|
|
61
|
+
if (!host.destroyed && markerRevision === revision && target)
|
|
62
|
+
moveView(target, options);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
function setMarkers(markers) {
|
|
66
|
+
if (host.destroyed)
|
|
67
|
+
return;
|
|
68
|
+
const prepared = markerCatalog.prepare(markers, selectedMarker);
|
|
69
|
+
if (!prepared)
|
|
70
|
+
return;
|
|
71
|
+
const { selection: nextSelection, focused: focusedMarker } = prepared;
|
|
72
|
+
invalidateClusters();
|
|
73
|
+
prepared.commit();
|
|
74
|
+
markerLabel.hidden = !markerCatalog.items.length || host.controls.markerPicker === false;
|
|
75
|
+
if (markerLabel.firstChild)
|
|
76
|
+
markerLabel.firstChild.textContent = `${strings.chooseMarker ?? 'Choose marker'} (${markerCatalog.items.length})`;
|
|
77
|
+
syncFeatureControls();
|
|
78
|
+
const revision = markerRevision + 1;
|
|
79
|
+
selectMarker(nextSelection, { fit: false });
|
|
80
|
+
if (!host.destroyed && markerRevision === revision && focusedMarker) {
|
|
81
|
+
markerNavigation.sync(markerCatalog.items.flatMap((item) => (item.visual ? [item.visual.node] : [])));
|
|
82
|
+
if (!markerNavigation.recover()) {
|
|
83
|
+
const recovery = markerCatalog.items.find((item) => item.marker.id === focusedMarker) ??
|
|
84
|
+
markerCatalog.items[0];
|
|
85
|
+
if (recovery) {
|
|
86
|
+
const { node } = materializeMarker(recovery).visual;
|
|
87
|
+
markerLayer.append(node);
|
|
88
|
+
node.focus({ preventScroll: true });
|
|
89
|
+
const width = canvas.getBoundingClientRect().width;
|
|
90
|
+
if (width)
|
|
91
|
+
drawClusters(host.view[2] / width);
|
|
92
|
+
}
|
|
93
|
+
else
|
|
94
|
+
svg.focus({ preventScroll: true });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function activateMarker(id, node) {
|
|
99
|
+
const item = markerCatalog.items.find((item) => item.marker.id === id);
|
|
100
|
+
if (!item)
|
|
101
|
+
return;
|
|
102
|
+
const coincident = markerCatalog.items.filter((other) => other.point[0] === item.point[0] && other.point[1] === item.point[1]);
|
|
103
|
+
if (coincident.length > 1)
|
|
104
|
+
host.markerChooser.open(coincident.map((other) => other.marker), node);
|
|
105
|
+
else {
|
|
106
|
+
selectMarker(id);
|
|
107
|
+
if (!host.destroyed && selectedMarker === id)
|
|
108
|
+
markerCatalog.items
|
|
109
|
+
.find((other) => other.marker.id === id)
|
|
110
|
+
?.visual?.node.focus({ preventScroll: true });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
getSelectedMarker,
|
|
115
|
+
selectMarker,
|
|
116
|
+
setMarkers,
|
|
117
|
+
activateMarker,
|
|
118
|
+
get selected() {
|
|
119
|
+
return selectedMarker;
|
|
120
|
+
},
|
|
121
|
+
get revision() {
|
|
122
|
+
return markerRevision;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
package/llms-full.txt
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
> Offline San Francisco SVG maps with explicit geographic imports, no runtime dependencies, static rendering, and a browser controller.
|
|
4
4
|
|
|
5
|
-
@kahwee/sf-map-svg 5.1.
|
|
5
|
+
@kahwee/sf-map-svg 5.1.2 · local working tree. Node >=24.0.0 for server rendering; browsers need a DOM and a JSON-capable bundler.
|
|
6
6
|
|
|
7
7
|
These are working-tree docs. Unreleased changelog entries may describe APIs absent from npm; verify the installed version before copying them.
|
|
8
8
|
|
|
9
9
|
Use renderMap(data, options) for SVG strings; createMap(data, options) returns a controller. Mount map.element and destroy it on unmount. Choose mode explicitly: createMap defaults to basemap. Geography is separate; staticMapData and fullMapData have different shapes.
|
|
10
10
|
|
|
11
|
-
<!-- Generated from README.md; @kahwee/sf-map-svg 5.1.
|
|
11
|
+
<!-- Generated from README.md; @kahwee/sf-map-svg 5.1.2 · local working tree. -->
|
|
12
12
|
|
|
13
13
|
# SF Map SVG
|
|
14
14
|
|
|
@@ -267,7 +267,7 @@ for raw samples, output-equivalence checks, and reproducible commands.
|
|
|
267
267
|
|
|
268
268
|
---
|
|
269
269
|
|
|
270
|
-
<!-- Generated from docs/developer-guide.md; @kahwee/sf-map-svg 5.1.
|
|
270
|
+
<!-- Generated from docs/developer-guide.md; @kahwee/sf-map-svg 5.1.2 · local working tree. -->
|
|
271
271
|
|
|
272
272
|
# Developer guide
|
|
273
273
|
|
|
@@ -433,7 +433,7 @@ See [API options](https://github.com/kahwee/sf-map-svg/blob/main/docs/API.md), [
|
|
|
433
433
|
|
|
434
434
|
---
|
|
435
435
|
|
|
436
|
-
<!-- Generated from docs/API.md; @kahwee/sf-map-svg 5.1.
|
|
436
|
+
<!-- Generated from docs/API.md; @kahwee/sf-map-svg 5.1.2 · local working tree. -->
|
|
437
437
|
|
|
438
438
|
# Map options and lifecycle
|
|
439
439
|
|
|
@@ -619,7 +619,7 @@ Call `binding.setMarkers(next)` after `map.setMarkers(next)` when the catalog ch
|
|
|
619
619
|
|
|
620
620
|
---
|
|
621
621
|
|
|
622
|
-
<!-- Generated from docs/EXAMPLES.md; @kahwee/sf-map-svg 5.1.
|
|
622
|
+
<!-- Generated from docs/EXAMPLES.md; @kahwee/sf-map-svg 5.1.2 · local working tree. -->
|
|
623
623
|
|
|
624
624
|
# Examples
|
|
625
625
|
|
|
@@ -760,7 +760,7 @@ See [query semantics and limitations](https://github.com/kahwee/sf-map-svg/blob/
|
|
|
760
760
|
|
|
761
761
|
---
|
|
762
762
|
|
|
763
|
-
<!-- Generated from docs/consumer-integration.md; @kahwee/sf-map-svg 5.1.
|
|
763
|
+
<!-- Generated from docs/consumer-integration.md; @kahwee/sf-map-svg 5.1.2 · local working tree. -->
|
|
764
764
|
|
|
765
765
|
# Embedding and progressive enhancement
|
|
766
766
|
|
|
@@ -938,7 +938,7 @@ keyboard, focus, and dismissal behavior. See [coordinates and units](https://git
|
|
|
938
938
|
|
|
939
939
|
---
|
|
940
940
|
|
|
941
|
-
<!-- Generated from docs/migration-v5.md; @kahwee/sf-map-svg 5.1.
|
|
941
|
+
<!-- Generated from docs/migration-v5.md; @kahwee/sf-map-svg 5.1.2 · local working tree. -->
|
|
942
942
|
|
|
943
943
|
# Migrating to v5
|
|
944
944
|
|
|
@@ -1043,7 +1043,7 @@ collections. Shared edges can match more than one feature. See the
|
|
|
1043
1043
|
|
|
1044
1044
|
---
|
|
1045
1045
|
|
|
1046
|
-
<!-- Generated from docs/migration-v3.md; @kahwee/sf-map-svg 5.1.
|
|
1046
|
+
<!-- Generated from docs/migration-v3.md; @kahwee/sf-map-svg 5.1.2 · local working tree. -->
|
|
1047
1047
|
|
|
1048
1048
|
# Migrate to version 3
|
|
1049
1049
|
|
|
@@ -1084,7 +1084,7 @@ District styles are now prepared once per district on construction and style/yea
|
|
|
1084
1084
|
|
|
1085
1085
|
---
|
|
1086
1086
|
|
|
1087
|
-
<!-- Generated from SOURCES.md; @kahwee/sf-map-svg 5.1.
|
|
1087
|
+
<!-- Generated from SOURCES.md; @kahwee/sf-map-svg 5.1.2 · local working tree. -->
|
|
1088
1088
|
|
|
1089
1089
|
# Geometry and provenance
|
|
1090
1090
|
|
|
@@ -1321,7 +1321,7 @@ Report-history points preserve only actual validated source counts and their dis
|
|
|
1321
1321
|
|
|
1322
1322
|
---
|
|
1323
1323
|
|
|
1324
|
-
<!-- Generated from CHANGELOG.md; @kahwee/sf-map-svg 5.1.
|
|
1324
|
+
<!-- Generated from CHANGELOG.md; @kahwee/sf-map-svg 5.1.2 · local working tree. -->
|
|
1325
1325
|
|
|
1326
1326
|
# Changelog
|
|
1327
1327
|
|
|
@@ -1329,6 +1329,10 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
|
|
|
1329
1329
|
|
|
1330
1330
|
## Unreleased
|
|
1331
1331
|
|
|
1332
|
+
## 5.1.2 — 2026-11-05
|
|
1333
|
+
|
|
1334
|
+
- Separate marker selection, activation and focus recovery into an internal controller; separate label candidate caching, screen-space collision layout and metrics invalidation into a label coordinator. Preserve the public API and shared animation frame scheduling.
|
|
1335
|
+
|
|
1332
1336
|
## 5.1.1 — 2026-11-05
|
|
1333
1337
|
|
|
1334
1338
|
- Reserve precinct report controls and status space during loading to prevent phone layout shifts when multiple reports are available.
|
|
@@ -1717,7 +1721,7 @@ consistent home, and makes animation and subscription ownership predictable.
|
|
|
1717
1721
|
|
|
1718
1722
|
# Published type contracts
|
|
1719
1723
|
|
|
1720
|
-
@kahwee/sf-map-svg 5.1.
|
|
1724
|
+
@kahwee/sf-map-svg 5.1.2 · local working tree. These declarations are generated by TypeScript. Use package export paths, not the internal filenames below. Imported relative contracts are included for context, not as additional public entry points.
|
|
1721
1725
|
|
|
1722
1726
|
| Public import | Declaration |
|
|
1723
1727
|
| --- | --- |
|
package/llms.txt
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Offline San Francisco SVG maps with explicit geographic imports, no runtime dependencies, static rendering, and a browser controller.
|
|
4
4
|
|
|
5
|
-
@kahwee/sf-map-svg 5.1.
|
|
5
|
+
@kahwee/sf-map-svg 5.1.2 · local working tree. Node >=24.0.0 for server rendering; browsers need a DOM and a JSON-capable bundler.
|
|
6
6
|
|
|
7
7
|
These are working-tree docs. Unreleased changelog entries may describe APIs absent from npm; verify the installed version before copying them.
|
|
8
8
|
|
package/package.json
CHANGED