@kahwee/sf-map-svg 5.1.1 → 5.1.3
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 +8 -0
- package/dist/src/configuration-controller.d.ts +21 -0
- package/dist/src/configuration-controller.js +43 -0
- package/dist/src/explorer-core.d.ts +2 -1
- package/dist/src/explorer-core.js +227 -347
- package/dist/src/label-coordinator.d.ts +54 -0
- package/dist/src/label-coordinator.js +157 -0
- package/dist/src/map.js +12 -35
- package/dist/src/marker-controller.d.ts +55 -0
- package/dist/src/marker-controller.js +125 -0
- package/llms-full.txt +19 -11
- package/llms.txt +1 -1
- package/package.json +1 -1
|
@@ -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)
|
|
@@ -994,7 +860,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
994
860
|
root.dispatchEvent(new CustomEvent('mapresize', { bubbles: true }));
|
|
995
861
|
};
|
|
996
862
|
let presentationRevision = 0;
|
|
997
|
-
function
|
|
863
|
+
function preparePresentation(patch) {
|
|
998
864
|
const revision = ++presentationRevision;
|
|
999
865
|
const nextYear = patch.year ?? year;
|
|
1000
866
|
const nextMode = patch.mode ?? mode;
|
|
@@ -1014,11 +880,8 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
1014
880
|
const prepared = appearance || nextYear !== year
|
|
1015
881
|
? prepareDistrictStyles(data.map.districts?.[nextYear] ?? [], nextStyle)
|
|
1016
882
|
: districts.styles;
|
|
1017
|
-
if (destroyed ||
|
|
1018
|
-
|
|
1019
|
-
revision !== presentationRevision ||
|
|
1020
|
-
districtVersion !== districts.revision)
|
|
1021
|
-
return false;
|
|
883
|
+
if (destroyed || revision !== presentationRevision || districtVersion !== districts.revision)
|
|
884
|
+
return undefined;
|
|
1022
885
|
if (nextYear !== year) {
|
|
1023
886
|
// Validate target paths and label coordinates before any presentation group commits.
|
|
1024
887
|
getLayerPathsWithData({ year: nextYear }, data.map, false);
|
|
@@ -1026,103 +889,120 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
1026
889
|
for (const point of feature.properties.labelPoints)
|
|
1027
890
|
map.project(point);
|
|
1028
891
|
}
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
892
|
+
let consumed = false;
|
|
893
|
+
return {
|
|
894
|
+
commit(onCommit, isCurrent) {
|
|
895
|
+
if (consumed ||
|
|
896
|
+
destroyed ||
|
|
897
|
+
!isCurrent() ||
|
|
898
|
+
revision !== presentationRevision ||
|
|
899
|
+
districtVersion !== districts.revision)
|
|
900
|
+
return false;
|
|
901
|
+
consumed = true;
|
|
902
|
+
onCommit();
|
|
903
|
+
if (destroyed ||
|
|
904
|
+
!isCurrent() ||
|
|
905
|
+
revision !== presentationRevision ||
|
|
906
|
+
districtVersion !== districts.revision)
|
|
907
|
+
return false;
|
|
908
|
+
if (appearance) {
|
|
909
|
+
districts.invalidate();
|
|
910
|
+
const next = copyAppearance(appearance);
|
|
911
|
+
theme = next.theme ?? 'transit';
|
|
912
|
+
colors = next.colors ?? {};
|
|
913
|
+
labelStyle = next.labelStyle ?? {};
|
|
914
|
+
areaStyle = next.areaStyle ?? {};
|
|
915
|
+
styleOptions = next.style ?? {};
|
|
916
|
+
minLabelSize = next.labelSize?.min ?? 11;
|
|
917
|
+
maxLabelSize = next.labelSize?.max ?? 12;
|
|
918
|
+
markerRadius = next.markerRadius ?? 6;
|
|
919
|
+
markerHitSize = next.markerHitSize ?? 44;
|
|
920
|
+
markerColor = next.markerColor ?? colors.marker ?? '#245b61';
|
|
921
|
+
selectedMarkerColor = next.selectedMarkerColor ?? colors.selected ?? '#f04f32';
|
|
922
|
+
districtTransition.cancel();
|
|
923
|
+
const fills = districtLayers.find((layer) => layer.dataset.layer === 'district-fills');
|
|
924
|
+
const fade = layerFades.duration();
|
|
925
|
+
const stylesChanged = nextStyle !== districts.style ||
|
|
926
|
+
[...prepared].some(([id, value]) => ['fill', 'stroke', 'opacity'].some((key) => value[key] !== districts.styles.get(id)?.[key]));
|
|
927
|
+
if (fills && fade && enabled('districtFills') && nextYear === year && stylesChanged)
|
|
928
|
+
districtTransition.fade(fills, fade);
|
|
929
|
+
districts.commitStyles(nextStyle, prepared);
|
|
930
|
+
const palette = resolveMapColors(theme, colors);
|
|
931
|
+
svg.querySelector('rect')?.setAttribute('fill', palette.water);
|
|
932
|
+
svg.querySelector('[data-layer="coast"]')?.setAttribute('fill', palette.land);
|
|
933
|
+
svg.querySelector('[data-layer="coastline"]')?.setAttribute('stroke', palette.district);
|
|
934
|
+
for (const [layer, color] of [
|
|
935
|
+
['landmarks', palette.park],
|
|
936
|
+
['highways', palette.highway],
|
|
937
|
+
['key-roads', palette.road],
|
|
938
|
+
])
|
|
939
|
+
for (const node of svg.querySelectorAll(`[data-layer="${layer}"] path`)) {
|
|
940
|
+
node.setAttribute(layer === 'landmarks' ? 'fill' : 'stroke', color);
|
|
941
|
+
if (layer === 'highways')
|
|
942
|
+
node.setAttribute('stroke-width', theme === 'transit' ? '2' : '1.4');
|
|
943
|
+
}
|
|
944
|
+
for (const [token, fallback, name] of [
|
|
945
|
+
['ink', '#18364f', 'ink'],
|
|
946
|
+
['surface', '#fff', 'surface'],
|
|
947
|
+
['accent', '#163d61', 'accent'],
|
|
948
|
+
['border', '#cedae3', 'border'],
|
|
949
|
+
['focus', '#1676b8', 'focus'],
|
|
950
|
+
['controlGap', '6px', 'control-gap'],
|
|
951
|
+
['font', 'system-ui,sans-serif', 'font'],
|
|
952
|
+
])
|
|
953
|
+
root.style.setProperty(`--sf-map-${name}`, styleOptions[token] ?? fallback);
|
|
954
|
+
labelLayer.setAttribute('font-family', labelStyle.fontFamily ?? styleOptions.font ?? 'system-ui,sans-serif');
|
|
955
|
+
labelCoordinator.invalidateMetrics();
|
|
956
|
+
for (const station of stationItems)
|
|
957
|
+
station.node.setAttribute('stroke', colors.bart ?? '#0073ae');
|
|
958
|
+
for (const kind of ['bart', 'park', 'highway', 'road']) {
|
|
959
|
+
const symbol = legend.querySelector(`.sf-explorer-legend-${kind}`);
|
|
960
|
+
if (symbol) {
|
|
961
|
+
symbol.style.removeProperty(kind === 'bart' ? 'border-color' : 'background-color');
|
|
962
|
+
if (colors[kind])
|
|
963
|
+
symbol.style.setProperty(kind === 'bart' ? 'border-color' : 'background-color', colors[kind]);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
for (const item of neighborhoods.items) {
|
|
967
|
+
const active = item.feature === neighborhoods.selected;
|
|
968
|
+
item.node.setAttribute('fill', active ? (areaStyle.selectedFill ?? '#408dbe') : 'transparent');
|
|
969
|
+
item.node.setAttribute('stroke', active ? (areaStyle.selectedStroke ?? '#176ba2') : (colors.neighborhood ?? '#9caebc'));
|
|
970
|
+
}
|
|
971
|
+
for (const item of markerCatalog.items) {
|
|
972
|
+
const visual = item.visual;
|
|
973
|
+
if (!visual)
|
|
974
|
+
continue;
|
|
975
|
+
visual.dot.setAttribute('fill', item.marker.id === markers.selected
|
|
976
|
+
? selectedMarkerColor
|
|
977
|
+
: (item.marker.color ?? markerColor));
|
|
978
|
+
visual.ring.setAttribute('stroke', features.selectedMarkerRing
|
|
979
|
+
? (features.selectedMarkerRing.color ?? selectedMarkerColor)
|
|
980
|
+
: selectedMarkerColor);
|
|
981
|
+
}
|
|
982
|
+
invalidateClusters();
|
|
983
|
+
updateDistrictAppearance();
|
|
1086
984
|
}
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
updateDistrictAppearance();
|
|
1106
|
-
}
|
|
1107
|
-
if (nextYear !== year)
|
|
1108
|
-
setDistrictYear(nextYear, {}, prepared);
|
|
1109
|
-
if (destroyed || revision !== presentationRevision || !isCurrent())
|
|
1110
|
-
return false;
|
|
1111
|
-
commitSource?.();
|
|
1112
|
-
if (destroyed || revision !== presentationRevision || !isCurrent())
|
|
1113
|
-
return false;
|
|
1114
|
-
if (commitSource && appearance)
|
|
1115
|
-
for (const item of neighborhoods.items)
|
|
1116
|
-
item.node.setAttribute('stroke', colors.neighborhood ?? '#9caebc');
|
|
1117
|
-
if (patch.mode !== undefined)
|
|
1118
|
-
setMode(patch.mode, false);
|
|
1119
|
-
if (patch.labels !== undefined)
|
|
1120
|
-
setLabels(patch.labels);
|
|
1121
|
-
scheduleLabels();
|
|
1122
|
-
return true;
|
|
985
|
+
if (nextYear !== year)
|
|
986
|
+
setDistrictYear(nextYear, {}, prepared);
|
|
987
|
+
if (destroyed || revision !== presentationRevision || !isCurrent())
|
|
988
|
+
return false;
|
|
989
|
+
commitSource?.();
|
|
990
|
+
if (destroyed || revision !== presentationRevision || !isCurrent())
|
|
991
|
+
return false;
|
|
992
|
+
if (commitSource && appearance)
|
|
993
|
+
for (const item of neighborhoods.items)
|
|
994
|
+
item.node.setAttribute('stroke', colors.neighborhood ?? '#9caebc');
|
|
995
|
+
if (patch.mode !== undefined)
|
|
996
|
+
setMode(patch.mode, false);
|
|
997
|
+
if (patch.labels !== undefined)
|
|
998
|
+
setLabels(patch.labels);
|
|
999
|
+
scheduleLabels();
|
|
1000
|
+
return true;
|
|
1001
|
+
},
|
|
1002
|
+
};
|
|
1123
1003
|
}
|
|
1124
1004
|
const explorer = Object.assign(root, {
|
|
1125
|
-
|
|
1005
|
+
preparePresentation,
|
|
1126
1006
|
getMapState: () => ({ mode, source: neighborhoods.source, year, labels }),
|
|
1127
1007
|
selectNeighborhood,
|
|
1128
1008
|
getSelection,
|
|
@@ -1165,7 +1045,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
1165
1045
|
canvas.style.touchAction = 'pan-y pinch-zoom';
|
|
1166
1046
|
observer?.disconnect();
|
|
1167
1047
|
frames.destroy();
|
|
1168
|
-
|
|
1048
|
+
labelCoordinator.clear();
|
|
1169
1049
|
sidebar?.destroy();
|
|
1170
1050
|
neighborhoods.destroy();
|
|
1171
1051
|
},
|
|
@@ -1174,7 +1054,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
1174
1054
|
observer = new ResizeObserver(onResize);
|
|
1175
1055
|
observer.observe(canvas);
|
|
1176
1056
|
document.fonts?.addEventListener('loadingdone', () => {
|
|
1177
|
-
|
|
1057
|
+
labelCoordinator.invalidateMetrics();
|
|
1178
1058
|
scheduleLabels();
|
|
1179
1059
|
}, { signal: controller.signal });
|
|
1180
1060
|
reducedMotion.addEventListener('change', () => {
|