@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.
@@ -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, setAttributeIfChanged, svgElement } from './dom.js';
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 { labelCandidates, styleLabels, } from './label-candidates.js';
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 === selectedMarker ? 'inline' : 'none');
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 && selectedMarker === id)
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 === selectedMarker,
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: markerRevision,
415
- selected: selectedMarker,
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
- if (destroyed)
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
- if (destroyed)
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 applyPresentation(patch, onCommit, isCurrent = () => true) {
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
- !isCurrent() ||
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
- onCommit?.();
1030
- if (appearance) {
1031
- districts.invalidate();
1032
- const next = copyAppearance(appearance);
1033
- theme = next.theme ?? 'transit';
1034
- colors = next.colors ?? {};
1035
- labelStyle = next.labelStyle ?? {};
1036
- areaStyle = next.areaStyle ?? {};
1037
- styleOptions = next.style ?? {};
1038
- minLabelSize = next.labelSize?.min ?? 11;
1039
- maxLabelSize = next.labelSize?.max ?? 12;
1040
- markerRadius = next.markerRadius ?? 6;
1041
- markerHitSize = next.markerHitSize ?? 44;
1042
- markerColor = next.markerColor ?? colors.marker ?? '#245b61';
1043
- selectedMarkerColor = next.selectedMarkerColor ?? colors.selected ?? '#f04f32';
1044
- districtTransition.cancel();
1045
- const fills = districtLayers.find((layer) => layer.dataset.layer === 'district-fills');
1046
- const fade = layerFades.duration();
1047
- const stylesChanged = nextStyle !== districts.style ||
1048
- [...prepared].some(([id, value]) => ['fill', 'stroke', 'opacity'].some((key) => value[key] !== districts.styles.get(id)?.[key]));
1049
- if (fills && fade && enabled('districtFills') && nextYear === year && stylesChanged)
1050
- districtTransition.fade(fills, fade);
1051
- districts.commitStyles(nextStyle, prepared);
1052
- const palette = resolveMapColors(theme, colors);
1053
- svg.querySelector('rect')?.setAttribute('fill', palette.water);
1054
- svg.querySelector('[data-layer="coast"]')?.setAttribute('fill', palette.land);
1055
- svg.querySelector('[data-layer="coastline"]')?.setAttribute('stroke', palette.district);
1056
- for (const [layer, color] of [
1057
- ['landmarks', palette.park],
1058
- ['highways', palette.highway],
1059
- ['key-roads', palette.road],
1060
- ])
1061
- for (const node of svg.querySelectorAll(`[data-layer="${layer}"] path`)) {
1062
- node.setAttribute(layer === 'landmarks' ? 'fill' : 'stroke', color);
1063
- if (layer === 'highways')
1064
- node.setAttribute('stroke-width', theme === 'transit' ? '2' : '1.4');
1065
- }
1066
- for (const [token, fallback, name] of [
1067
- ['ink', '#18364f', 'ink'],
1068
- ['surface', '#fff', 'surface'],
1069
- ['accent', '#163d61', 'accent'],
1070
- ['border', '#cedae3', 'border'],
1071
- ['focus', '#1676b8', 'focus'],
1072
- ['controlGap', '6px', 'control-gap'],
1073
- ['font', 'system-ui,sans-serif', 'font'],
1074
- ])
1075
- root.style.setProperty(`--sf-map-${name}`, styleOptions[token] ?? fallback);
1076
- labelLayer.setAttribute('font-family', labelStyle.fontFamily ?? styleOptions.font ?? 'system-ui,sans-serif');
1077
- labelRenderer.invalidateMetrics();
1078
- for (const station of stationItems)
1079
- station.node.setAttribute('stroke', colors.bart ?? '#0073ae');
1080
- for (const kind of ['bart', 'park', 'highway', 'road']) {
1081
- const symbol = legend.querySelector(`.sf-explorer-legend-${kind}`);
1082
- if (symbol) {
1083
- symbol.style.removeProperty(kind === 'bart' ? 'border-color' : 'background-color');
1084
- if (colors[kind])
1085
- symbol.style.setProperty(kind === 'bart' ? 'border-color' : 'background-color', colors[kind]);
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
- for (const item of neighborhoods.items) {
1089
- const active = item.feature === neighborhoods.selected;
1090
- item.node.setAttribute('fill', active ? (areaStyle.selectedFill ?? '#408dbe') : 'transparent');
1091
- item.node.setAttribute('stroke', active ? (areaStyle.selectedStroke ?? '#176ba2') : (colors.neighborhood ?? '#9caebc'));
1092
- }
1093
- for (const item of markerCatalog.items) {
1094
- const visual = item.visual;
1095
- if (!visual)
1096
- continue;
1097
- visual.dot.setAttribute('fill', item.marker.id === selectedMarker
1098
- ? selectedMarkerColor
1099
- : (item.marker.color ?? markerColor));
1100
- visual.ring.setAttribute('stroke', features.selectedMarkerRing
1101
- ? (features.selectedMarkerRing.color ?? selectedMarkerColor)
1102
- : selectedMarkerColor);
1103
- }
1104
- invalidateClusters();
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
- applyPresentation,
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
- labelRenderer.clear();
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
- labelRenderer.invalidateMetrics();
1057
+ labelCoordinator.invalidateMetrics();
1178
1058
  scheduleLabels();
1179
1059
  }, { signal: controller.signal });
1180
1060
  reducedMotion.addEventListener('change', () => {