@kahwee/sf-map-svg 5.1.0 → 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.
@@ -2,21 +2,19 @@ import { copyAppearance } from './appearance.js';
2
2
  import { createCamera, validateCameraOptions } from './camera.js';
3
3
  import { createClusterLayer } from './cluster-layer.js';
4
4
  import { clusterPoints } from './clusters.js';
5
- import { renderDistrictAppearance } from './district-layer.js';
6
- import { createDistrictMorph } from './district-morph.js';
5
+ import { createDistrictController } from './district-controller.js';
7
6
  import { prepareDistrictStyles } from './district-style.js';
8
- import { createDistrictTransition } from './district-transition.js';
9
- import { element, setAttributeIfChanged, svgElement } from './dom.js';
7
+ import { element, svgElement } from './dom.js';
10
8
  import { createExplorerControls } from './explorer-controls.js';
11
9
  import { fitBounds, projectedBounds } from './explorer-layout.js';
12
10
  import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from './features.js';
13
11
  import { createFrameScheduler } from './frame-scheduler.js';
14
- import { labelCandidates, styleLabels, } from './label-candidates.js';
15
- import { createLabelRenderer } from './label-renderer.js';
12
+ import { createLabelCoordinator } from './label-coordinator.js';
16
13
  import { createLayerTransitions } from './layer-transition.js';
17
14
  import { createSFMapWithData, districtColors, getLayerPathsWithData, resolveMapColors, } from './map-core.js';
18
15
  import { createMarkerCatalog } from './marker-catalog.js';
19
16
  import { createMarkerChooser } from './marker-chooser.js';
17
+ import { createMarkerController } from './marker-controller.js';
20
18
  import { createMarkerLayer } from './marker-layer.js';
21
19
  import { createMarkerNavigation } from './marker-navigation.js';
22
20
  import { createNeighborhoodLayer, createNeighborhoodLookup, formatSourceLabel, } from './neighborhood-layer.js';
@@ -179,180 +177,53 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
179
177
  'aria-hidden': 'true',
180
178
  'font-family': labelStyle.fontFamily ?? styleOptions.font ?? 'system-ui,sans-serif',
181
179
  });
182
- const labelRenderer = createLabelRenderer(labelLayer);
183
180
  geography.append(areas, stations, labelLayer, overlayLayer, markerLayer);
184
181
  let view = [0, 0, 800];
185
- let selectedDistrict = null;
186
- let hoveredDistrict = null;
187
- let districtStyle = initialDistrictStyle;
188
- let districtStyles = prepareDistrictStyles(data.map.districts?.[year] ?? [], districtStyle);
189
- const districtTransition = createDistrictTransition();
190
- let districtRevision = 0;
191
- root.dataset.year = String(year);
192
- function districtRow(id) {
193
- return data.map.districts?.[year]?.find((district) => district.id === id);
194
- }
195
- function districtSelection(id) {
196
- const district = id === null ? undefined : districtRow(id);
197
- return district
198
- ? { id: district.id, year, district: structuredClone(district) }
199
- : { id: null, year, district: null };
200
- }
201
- function getSelectedDistrict() {
202
- return selectedDistrict === null
203
- ? null
204
- : districtSelection(selectedDistrict);
205
- }
206
- function updateDistrictAppearance() {
207
- renderDistrictAppearance({
208
- svg,
209
- rows: data.map.districts?.[year] ?? [],
210
- styles: districtStyles,
211
- selectedDistrict,
212
- hoveredDistrict,
213
- year,
214
- theme,
215
- colors,
216
- fills: enabled('districtFills'),
217
- lines: enabled('districtLines'),
218
- });
219
- }
220
- function selectDistrict(id, options = {}) {
221
- if (destroyed)
222
- return false;
223
- validateCameraOptions(options);
224
- if (id !== null && (!Number.isInteger(id) || !districtRow(id)))
225
- return false;
226
- const changed = selectedDistrict !== id;
227
- const revision = ++districtRevision;
228
- selectedDistrict = id;
229
- if (id === null)
230
- delete root.dataset.selectedDistrict;
231
- else
232
- root.dataset.selectedDistrict = String(id);
233
- updateDistrictAppearance();
234
- const selectedRow = id === null ? undefined : districtRow(id);
235
- if (selectedRow && options.fit)
236
- fitGeometry(selectedRow.geometry, undefined, options);
237
- if (destroyed || revision !== districtRevision)
238
- return true;
239
- status.textContent =
240
- id === null ? 'District selection cleared.' : `District ${id} selected, ${year} boundaries.`;
241
- if (changed)
242
- root.dispatchEvent(new CustomEvent('districtchange', { bubbles: true, detail: districtSelection(id) }));
243
- return true;
244
- }
245
- function activateDistrict(id) {
246
- const revision = districtRevision + 1;
247
- if (!selectDistrict(id) || destroyed || revision !== districtRevision)
248
- return false;
249
- root.dispatchEvent(new CustomEvent('districtactivate', { bubbles: true, detail: districtSelection(id) }));
250
- return !destroyed && revision === districtRevision;
251
- }
252
- function setDistrictStyle(style) {
253
- if (destroyed)
254
- return;
255
- const revision = districtRevision;
256
- const prepared = prepareDistrictStyles(data.map.districts?.[year] ?? [], style);
257
- if (destroyed || revision !== districtRevision)
258
- return;
259
- districtRevision++;
260
- districtStyle = style;
261
- districtStyles = prepared;
262
- const fills = districtLayers.find((layer) => layer.dataset.layer === 'district-fills');
263
- const fade = layerFades.duration();
264
- // A new style settles any fill crossfade still running from an earlier change.
265
- districtTransition.cancel();
266
- if (fills && fade && enabled('districtFills'))
267
- districtTransition.fade(fills, fade);
268
- updateDistrictAppearance();
269
- }
270
- function setDistrictYear(next, options = {}, preparedStyles) {
271
- if (destroyed)
272
- return;
273
- validateCameraOptions(options);
274
- if (![2002, 2012, 2022].includes(next))
275
- throw new RangeError('District year must be 2002, 2012, or 2022.');
276
- if (!data.map.districts?.[next] || !data.districts?.[next])
277
- throw new RangeError(`No ${next} district dataset was supplied.`);
278
- if (next === year)
279
- return;
280
- const revision = districtRevision;
281
- const prepared = preparedStyles ?? prepareDistrictStyles(data.map.districts[next], districtStyle);
282
- if (destroyed || revision !== districtRevision)
283
- return;
284
- const committedRevision = ++districtRevision;
285
- const geometry = getLayerPathsWithData({ year: next }, data.map, false);
286
- const previousYear = year;
287
- districtTransition.cancel();
288
- districtMorph.stop();
289
- const lineLayer = districtLayers.find((layer) => layer.dataset.layer === 'district-lines');
290
- const morph = features.districtMorph &&
291
- options.animate !== false &&
292
- initialized &&
293
- !reducedMotion.matches &&
294
- (enabled('districtLines') || enabled('districtFills'))
295
- ? features.districtMorph
296
- : false;
297
- const previousOutlines = morph && lineLayer
298
- ? new Map([...lineLayer.querySelectorAll('path[data-district]')].map((path) => [
299
- path.dataset.district ?? '',
300
- path.getAttribute('d') ?? '',
301
- ]))
302
- : undefined;
303
- for (const layer of districtLayers) {
304
- if (morph) {
305
- // The morph replaces the line crossfade; fills fade across the whole morph.
306
- if (layer !== lineLayer)
307
- districtTransition.fade(layer, morph.duration);
308
- }
309
- else if (options.animate && !reducedMotion.matches)
310
- districtTransition.fade(layer, options.duration ?? 280);
311
- layer.replaceChildren(...geometry.districts.map((district) => svgElement('path', {
312
- 'data-district': district.id,
313
- d: district.path,
314
- ...(layer.dataset.layer === 'district-fills'
315
- ? { 'fill-rule': 'evenodd' }
316
- : { fill: 'none', 'stroke-width': 1.1 }),
317
- })));
318
- }
319
- year = next;
320
- districtStyles = prepared;
321
- hoveredDistrict = null;
322
- root.dataset.year = String(year);
323
- svg.dataset.year = String(year);
324
- districtItems = (data.districts[next]?.features ?? []).flatMap((feature) => feature.properties.labelPoints.map((point) => ({
325
- point: map.project(point),
326
- name: String(feature.properties.district),
327
- kind: 'district',
328
- })));
329
- if (selectedDistrict !== null && !districtRow(selectedDistrict))
330
- selectedDistrict = null;
331
- if (selectedDistrict === null)
332
- delete root.dataset.selectedDistrict;
333
- updateDistrictAppearance();
334
- updateComposition();
335
- if (morph && lineLayer && previousOutlines)
336
- districtMorph.start(lineLayer, previousOutlines, {
337
- duration: morph.duration,
338
- stroke: colors.district ?? '#71838a',
339
- });
340
- scheduleLabels();
341
- status.textContent = `${year} supervisorial districts.`;
342
- root.dispatchEvent(new CustomEvent('districtyearchange', { bubbles: true, detail: { year, previousYear } }));
343
- if (!destroyed && committedRevision === districtRevision && selectedDistrict !== null)
344
- root.dispatchEvent(new CustomEvent('districtchange', {
345
- bubbles: true,
346
- detail: districtSelection(selectedDistrict),
347
- }));
348
- }
182
+ const districts = createDistrictController({
183
+ root,
184
+ svg,
185
+ status,
186
+ data,
187
+ districtLayers,
188
+ reducedMotion,
189
+ enabled,
190
+ fitGeometry,
191
+ updateComposition,
192
+ scheduleLabels,
193
+ get year() {
194
+ return year;
195
+ },
196
+ set year(value) {
197
+ year = value;
198
+ },
199
+ get theme() {
200
+ return theme;
201
+ },
202
+ get colors() {
203
+ return colors;
204
+ },
205
+ get features() {
206
+ return features;
207
+ },
208
+ get destroyed() {
209
+ return destroyed;
210
+ },
211
+ get initialized() {
212
+ return initialized;
213
+ },
214
+ fadeDuration: () => layerFades.duration(),
215
+ project: map.project,
216
+ setLabels: (items) => {
217
+ districtItems = items;
218
+ },
219
+ }, initialDistrictStyle);
220
+ const { districtRow, districtSelection, getSelectedDistrict, updateDistrictAppearance, selectDistrict, activateDistrict, setDistrictStyle, setDistrictYear, transition: districtTransition, morph: districtMorph, } = districts;
349
221
  updateDistrictAppearance();
350
222
  let destroyed = false;
351
223
  let initialized = false;
352
224
  const layerFades = createLayerTransitions(() => initialized && features.layerTransitions && !reducedMotion.matches
353
225
  ? features.layerTransitions.duration
354
226
  : 0);
355
- const districtMorph = createDistrictMorph();
356
227
  const frames = createFrameScheduler({
357
228
  request: (callback) => requestAnimationFrame(callback),
358
229
  cancel: (id) => cancelAnimationFrame(id),
@@ -441,7 +312,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
441
312
  if (!visual)
442
313
  continue;
443
314
  const ring = features.selectedMarkerRing;
444
- visual.ring.setAttribute('display', ring && marker.id === selectedMarker ? 'inline' : 'none');
315
+ visual.ring.setAttribute('display', ring && marker.id === markers.selected ? 'inline' : 'none');
445
316
  visual.ring.setAttribute('stroke', ring ? (ring.color ?? selectedMarkerColor) : selectedMarkerColor);
446
317
  visual.ring.setAttribute('stroke-width', String(ring ? ring.width : 2));
447
318
  }
@@ -476,7 +347,6 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
476
347
  kind: 'road',
477
348
  }));
478
349
  const secondaryRoads = svg.querySelectorAll('[data-key-road-level="secondary"]');
479
- let secondaryRoadsVisible;
480
350
  const scaleUnitsPerMeter = Math.abs(map.project([-122.44, 37.76])[0] - map.project([-122.45, 37.76])[0]) / 879;
481
351
  const parkItems = (data.map.landmarks ?? []).map((feature) => ({
482
352
  point: map.project(feature.label),
@@ -484,18 +354,57 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
484
354
  kind: 'park',
485
355
  }));
486
356
  const markerCatalog = createMarkerCatalog(markerRenderer, markerSelect, map.project);
487
- let selectedMarker = null;
488
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;
489
399
  const markerChooser = createMarkerChooser(canvas, svg, (id) => {
490
400
  if (destroyed)
491
401
  return;
492
402
  selectMarker(id);
493
- if (!destroyed && selectedMarker === id)
403
+ if (!destroyed && markers.selected === id)
494
404
  markerCatalog.items
495
405
  .find((item) => item.marker.id === id)
496
406
  ?.visual?.node.focus({ preventScroll: true });
497
407
  });
498
- let markerRevision = 0;
499
408
  const clusterLayer = svgElement('g', { 'data-layer': 'marker-clusters' });
500
409
  geography.insertBefore(clusterLayer, markerLayer);
501
410
  function materializeMarker(item) {
@@ -505,7 +414,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
505
414
  markerColor,
506
415
  selectedMarkerColor,
507
416
  reducedMotion: reducedMotion.matches,
508
- selected: item.marker.id === selectedMarker,
417
+ selected: item.marker.id === markers.selected,
509
418
  }),
510
419
  });
511
420
  }
@@ -539,8 +448,8 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
539
448
  unit,
540
449
  view,
541
450
  items: markerCatalog.items,
542
- revision: markerRevision,
543
- selected: selectedMarker,
451
+ revision: markers.revision,
452
+ selected: markers.selected,
544
453
  features,
545
454
  markerColor,
546
455
  markerRadius,
@@ -629,141 +538,70 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
629
538
  moveView([0, 0, 800], options);
630
539
  }
631
540
  function scheduleLabels() {
632
- if (destroyed)
633
- return;
634
- frames.invalidate();
635
- }
636
- let labelInputs = [];
637
- let preparedLabels = [];
638
- function prepareLabels(width, zoom) {
639
- // Only screen-space positioning changes during pan or within these zoom/width bands.
640
- const inputs = [
641
- neighborhoods.items,
642
- neighborhoods.selected,
643
- districtItems,
644
- markerRevision,
645
- layers,
646
- mode,
647
- labelStyle,
648
- colors,
649
- minLabelSize,
650
- maxLabelSize,
651
- markerRadius,
652
- zoom >= 1.8,
653
- width >= 550,
654
- ];
655
- if (inputs.every((value, index) => value === labelInputs[index]))
656
- return preparedLabels;
657
- labelInputs = inputs;
658
- const selectedItem = neighborhoods.items.find((item) => item.feature === neighborhoods.selected);
659
- const candidates = [];
660
- const marker = markerCatalog.items.find((item) => item.marker.id === selectedMarker);
661
- if (marker)
662
- candidates.push({
663
- point: marker.point,
664
- name: marker.marker.label ?? marker.marker.id,
665
- kind: 'selected-marker',
666
- });
667
- if (selectedItem?.point && enabled('neighborhoodLabels'))
668
- candidates.push({
669
- point: selectedItem.point,
670
- name: selectedItem.feature.properties.canonicalName,
671
- kind: 'selected',
672
- });
673
- preparedLabels = styleLabels(labelCandidates({
674
- selected: candidates,
675
- districts: enabled('districtLabels') ? districtItems : [],
676
- stations: enabled('bartStations') ? stationItems : [],
677
- parks: enabled('landmarks') ? parkItems : [],
678
- roads: enabled('roadLabels') ? roadItems : [],
679
- neighborhoods: enabled('neighborhoodLabels')
680
- ? neighborhoods.items
681
- .filter((item) => item.point !== undefined)
682
- .sort((a, b) => b.area - a.area || a.feature.id.localeCompare(b.feature.id))
683
- .flatMap((item) => item.feature !== neighborhoods.selected && item.point
684
- ? [
685
- {
686
- point: item.point,
687
- name: item.feature.properties.canonicalName,
688
- kind: 'neighborhood',
689
- },
690
- ]
691
- : [])
692
- : [],
693
- }, width, zoom), {
694
- colors,
695
- labelStyle,
696
- labelSize: { min: minLabelSize, max: maxLabelSize },
697
- markerRadius: marker?.marker.radius ?? markerRadius,
698
- });
699
- return preparedLabels;
541
+ labelCoordinator.schedule();
700
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
+ });
701
603
  function drawLabels() {
702
- if (destroyed)
703
- return;
704
- const canvasBox = canvas.getBoundingClientRect();
705
- const width = canvasBox.width;
706
- if (!width)
707
- return;
708
- const unit = view[2] / width, zoom = 800 / view[2];
709
- if (features.scaleBar) {
710
- const pixelsPerMeter = scaleUnitsPerMeter / unit;
711
- const base = 10 ** Math.floor(Math.log10(100 / pixelsPerMeter));
712
- const meters = [5, 2, 1].map((factor) => factor * base).find((value) => value * pixelsPerMeter <= 100) ??
713
- base;
714
- const scaleWidth = `${meters * pixelsPerMeter}px`;
715
- const scaleText = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`;
716
- if (scale.style.width !== scaleWidth)
717
- scale.style.width = scaleWidth;
718
- if (scale.textContent !== scaleText)
719
- scale.textContent = scaleText;
720
- }
721
- // Read overlay bounds before SVG writes; scale furniture above must have its current width.
722
- const furnitureBounds = [...overlayElement.children]
723
- .filter((node) => node.getClientRects().length > 0)
724
- .map((node) => {
725
- const box = node.getBoundingClientRect();
726
- return [
727
- box.left - canvasBox.left - 3,
728
- box.top - canvasBox.top - 3,
729
- box.right - canvasBox.left + 3,
730
- box.bottom - canvasBox.top + 3,
731
- ];
732
- });
733
- const showSecondaryRoads = zoom >= 1.8;
734
- if (showSecondaryRoads !== secondaryRoadsVisible) {
735
- for (const road of secondaryRoads)
736
- road.style.display = showSecondaryRoads ? '' : 'none';
737
- secondaryRoadsVisible = showSecondaryRoads;
738
- }
739
- for (const station of stationItems)
740
- setAttributeIfChanged(station.node, 'r', 4.5 * unit);
741
- drawClusters(unit);
742
- if (!labels) {
743
- labelRenderer.clear();
744
- root.dataset.visibleLabels = '0';
745
- return;
746
- }
747
- const labelCandidates = prepareLabels(width, zoom);
748
- const stationBounds = (enabled('bartStations') ? stationItems : []).map(({ point }) => {
749
- const x = (point[0] - view[0]) / unit;
750
- const y = (point[1] - view[1]) / unit;
751
- return [x - 6, y - 6, x + 6, y + 6];
752
- });
753
- const markerBounds = renderedMarkers.map(({ point, marker }) => {
754
- const radius = marker.radius ?? markerRadius;
755
- const x = (point[0] - view[0]) / unit, y = (point[1] - view[1]) / unit;
756
- return [x - radius - 3, y - radius - 3, x + radius + 3, y + radius + 3];
757
- });
758
- root.dataset.visibleLabels = String(labelRenderer.draw(labelCandidates, view, width, [
759
- ...stationBounds,
760
- ...markerBounds,
761
- ...furnitureBounds,
762
- ...renderedClusters.map(({ point }) => {
763
- const x = (point[0] - view[0]) / unit, y = (point[1] - view[1]) / unit;
764
- return [x - 26, y - 26, x + 26, y + 26];
765
- }),
766
- ]));
604
+ labelCoordinator.draw();
767
605
  }
768
606
  const sidebar = ui.createSidebar?.(shell, data, chrome, strings.emptyResults, searchNeighborhoods);
769
607
  function updateDetail() {
@@ -869,91 +707,6 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
869
707
  attribution.textContent = description;
870
708
  scheduleLabels();
871
709
  }
872
- function getSelectedMarker() {
873
- const marker = markerCatalog.items.find((item) => item.marker.id === selectedMarker)?.marker;
874
- return marker ? { ...marker, position: [...marker.position] } : null;
875
- }
876
- function selectMarker(id, options = {}) {
877
- if (destroyed)
878
- return false;
879
- const item = markerCatalog.items.find((item) => item.marker.id === id);
880
- if (id !== null && !item)
881
- return false;
882
- validateCameraOptions(options);
883
- const width = canvas.getBoundingClientRect().width;
884
- const target = item && options.fit !== false
885
- ? width
886
- ? fitViewport([...item.point, ...item.point], width, fitPadding)
887
- : fitBounds([...item.point, ...item.point])
888
- : null;
889
- const revision = ++markerRevision;
890
- const changed = selectedMarker !== id;
891
- selectedMarker = id;
892
- if (changed)
893
- invalidateClusters();
894
- markerSelect.value = id ?? '';
895
- for (const entry of markerCatalog.items) {
896
- const active = entry.marker.id === id;
897
- const visual = active ? materializeMarker(entry).visual : entry.visual;
898
- if (!visual)
899
- continue;
900
- const pressed = String(active);
901
- const ring = active && features.selectedMarkerRing ? 'inline' : 'none';
902
- const fill = active ? selectedMarkerColor : (entry.marker.color ?? markerColor);
903
- if (visual.node.getAttribute('aria-pressed') !== pressed)
904
- visual.node.setAttribute('aria-pressed', pressed);
905
- if (visual.ring.getAttribute('display') !== ring)
906
- visual.ring.setAttribute('display', ring);
907
- if (visual.dot.getAttribute('fill') !== fill)
908
- visual.dot.setAttribute('fill', fill);
909
- if (active && markerLayer.lastChild !== visual.node)
910
- markerLayer.append(visual.node);
911
- }
912
- scheduleLabels();
913
- if (width || !features.clustering)
914
- drawClusters(width ? view[2] / width : 1);
915
- const marker = getSelectedMarker();
916
- if (changed)
917
- root.dispatchEvent(new CustomEvent('markerchange', { bubbles: true, detail: { id, marker } }));
918
- if (!destroyed && changed && marker && markerRevision === revision && selectedMarker === id)
919
- onMarkerActivate?.({ ...marker });
920
- if (!destroyed && markerRevision === revision && target)
921
- moveView(target, options);
922
- return true;
923
- }
924
- function setMarkers(markers) {
925
- if (destroyed)
926
- return;
927
- const prepared = markerCatalog.prepare(markers, selectedMarker);
928
- if (!prepared)
929
- return;
930
- const { selection: nextSelection, focused: focusedMarker } = prepared;
931
- invalidateClusters();
932
- prepared.commit();
933
- markerLabel.hidden = !markerCatalog.items.length || controls.markerPicker === false;
934
- if (markerLabel.firstChild)
935
- markerLabel.firstChild.textContent = `${strings.chooseMarker ?? 'Choose marker'} (${markerCatalog.items.length})`;
936
- syncFeatureControls();
937
- const revision = markerRevision + 1;
938
- selectMarker(nextSelection, { fit: false });
939
- if (!destroyed && markerRevision === revision && focusedMarker) {
940
- markerNavigation.sync(markerCatalog.items.flatMap((item) => (item.visual ? [item.visual.node] : [])));
941
- if (!markerNavigation.recover()) {
942
- const recovery = markerCatalog.items.find((item) => item.marker.id === focusedMarker) ??
943
- markerCatalog.items[0];
944
- if (recovery) {
945
- const { node } = materializeMarker(recovery).visual;
946
- markerLayer.append(node);
947
- node.focus({ preventScroll: true });
948
- const width = canvas.getBoundingClientRect().width;
949
- if (width)
950
- drawClusters(view[2] / width);
951
- }
952
- else
953
- svg.focus({ preventScroll: true });
954
- }
955
- }
956
- }
957
710
  function setControls(patch) {
958
711
  if (destroyed)
959
712
  return;
@@ -1052,21 +805,6 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
1052
805
  });
1053
806
  listen(markerLayer, 'focusout', scheduleLabels);
1054
807
  listen(markerLayer, 'focusin', scheduleLabels);
1055
- function activateMarker(id, node) {
1056
- const item = markerCatalog.items.find((item) => item.marker.id === id);
1057
- if (!item)
1058
- return;
1059
- const coincident = markerCatalog.items.filter((other) => other.point[0] === item.point[0] && other.point[1] === item.point[1]);
1060
- if (coincident.length > 1)
1061
- markerChooser.open(coincident.map((other) => other.marker), node);
1062
- else {
1063
- selectMarker(id);
1064
- if (!destroyed && selectedMarker === id)
1065
- markerCatalog.items
1066
- .find((other) => other.marker.id === id)
1067
- ?.visual?.node.focus({ preventScroll: true });
1068
- }
1069
- }
1070
808
  listen(markerLayer, 'click', (event) => {
1071
809
  const node = event.target instanceof Element ? event.target.closest('[data-marker-id]') : null;
1072
810
  if (node?.dataset.markerId)
@@ -1098,9 +836,9 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
1098
836
  ? event.target.closest('[data-layer="district-fills"] [data-district], [data-layer="district-lines"] [data-district]')
1099
837
  : null;
1100
838
  const id = district?.dataset.district ? Number(district.dataset.district) : null;
1101
- if (id === hoveredDistrict)
839
+ if (id === districts.hovered)
1102
840
  return;
1103
- hoveredDistrict = id;
841
+ districts.hovered = id;
1104
842
  updateDistrictAppearance();
1105
843
  root.dispatchEvent(new CustomEvent('districthover', { bubbles: true, detail: districtSelection(id) }));
1106
844
  });
@@ -1108,9 +846,9 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
1108
846
  if (event.relatedTarget instanceof Element &&
1109
847
  event.relatedTarget.closest('[data-layer="district-fills"] [data-district], [data-layer="district-lines"] [data-district]'))
1110
848
  return;
1111
- if (hoveredDistrict === null)
849
+ if (districts.hovered === null)
1112
850
  return;
1113
- hoveredDistrict = null;
851
+ districts.hovered = null;
1114
852
  updateDistrictAppearance();
1115
853
  root.dispatchEvent(new CustomEvent('districthover', { bubbles: true, detail: districtSelection(null) }));
1116
854
  });
@@ -1137,15 +875,15 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
1137
875
  ? prepareSource(patch.source, false)
1138
876
  : undefined;
1139
877
  const appearance = patch.appearance;
1140
- const nextStyle = appearance ? appearance.districtStyle : districtStyle;
1141
- const districtVersion = districtRevision;
878
+ const nextStyle = appearance ? appearance.districtStyle : districts.style;
879
+ const districtVersion = districts.revision;
1142
880
  const prepared = appearance || nextYear !== year
1143
881
  ? prepareDistrictStyles(data.map.districts?.[nextYear] ?? [], nextStyle)
1144
- : districtStyles;
882
+ : districts.styles;
1145
883
  if (destroyed ||
1146
884
  !isCurrent() ||
1147
885
  revision !== presentationRevision ||
1148
- districtVersion !== districtRevision)
886
+ districtVersion !== districts.revision)
1149
887
  return false;
1150
888
  if (nextYear !== year) {
1151
889
  // Validate target paths and label coordinates before any presentation group commits.
@@ -1156,7 +894,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
1156
894
  }
1157
895
  onCommit?.();
1158
896
  if (appearance) {
1159
- districtRevision++;
897
+ districts.invalidate();
1160
898
  const next = copyAppearance(appearance);
1161
899
  theme = next.theme ?? 'transit';
1162
900
  colors = next.colors ?? {};
@@ -1172,12 +910,11 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
1172
910
  districtTransition.cancel();
1173
911
  const fills = districtLayers.find((layer) => layer.dataset.layer === 'district-fills');
1174
912
  const fade = layerFades.duration();
1175
- const stylesChanged = nextStyle !== districtStyle ||
1176
- [...prepared].some(([id, value]) => ['fill', 'stroke', 'opacity'].some((key) => value[key] !== districtStyles.get(id)?.[key]));
913
+ const stylesChanged = nextStyle !== districts.style ||
914
+ [...prepared].some(([id, value]) => ['fill', 'stroke', 'opacity'].some((key) => value[key] !== districts.styles.get(id)?.[key]));
1177
915
  if (fills && fade && enabled('districtFills') && nextYear === year && stylesChanged)
1178
916
  districtTransition.fade(fills, fade);
1179
- districtStyle = nextStyle;
1180
- districtStyles = prepared;
917
+ districts.commitStyles(nextStyle, prepared);
1181
918
  const palette = resolveMapColors(theme, colors);
1182
919
  svg.querySelector('rect')?.setAttribute('fill', palette.water);
1183
920
  svg.querySelector('[data-layer="coast"]')?.setAttribute('fill', palette.land);
@@ -1203,7 +940,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
1203
940
  ])
1204
941
  root.style.setProperty(`--sf-map-${name}`, styleOptions[token] ?? fallback);
1205
942
  labelLayer.setAttribute('font-family', labelStyle.fontFamily ?? styleOptions.font ?? 'system-ui,sans-serif');
1206
- labelRenderer.invalidateMetrics();
943
+ labelCoordinator.invalidateMetrics();
1207
944
  for (const station of stationItems)
1208
945
  station.node.setAttribute('stroke', colors.bart ?? '#0073ae');
1209
946
  for (const kind of ['bart', 'park', 'highway', 'road']) {
@@ -1223,7 +960,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
1223
960
  const visual = item.visual;
1224
961
  if (!visual)
1225
962
  continue;
1226
- visual.dot.setAttribute('fill', item.marker.id === selectedMarker
963
+ visual.dot.setAttribute('fill', item.marker.id === markers.selected
1227
964
  ? selectedMarkerColor
1228
965
  : (item.marker.color ?? markerColor));
1229
966
  visual.ring.setAttribute('stroke', features.selectedMarkerRing
@@ -1294,7 +1031,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
1294
1031
  canvas.style.touchAction = 'pan-y pinch-zoom';
1295
1032
  observer?.disconnect();
1296
1033
  frames.destroy();
1297
- labelRenderer.clear();
1034
+ labelCoordinator.clear();
1298
1035
  sidebar?.destroy();
1299
1036
  neighborhoods.destroy();
1300
1037
  },
@@ -1303,7 +1040,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
1303
1040
  observer = new ResizeObserver(onResize);
1304
1041
  observer.observe(canvas);
1305
1042
  document.fonts?.addEventListener('loadingdone', () => {
1306
- labelRenderer.invalidateMetrics();
1043
+ labelCoordinator.invalidateMetrics();
1307
1044
  scheduleLabels();
1308
1045
  }, { signal: controller.signal });
1309
1046
  reducedMotion.addEventListener('change', () => {