@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.
@@ -0,0 +1,54 @@
1
+ import type { View } from './explorer-layout.js';
2
+ import type { normalizeFeatures } from './features.js';
3
+ import { type LabelItem } from './label-candidates.js';
4
+ import type { createMarkerCatalog } from './marker-catalog.js';
5
+ import type { createMarkerController } from './marker-controller.js';
6
+ import type { MarkerItem, MarkerVisual } from './marker-layer.js';
7
+ import type { createNeighborhoodLayer } from './neighborhood-layer.js';
8
+ import type { ExplorerMode, InteractiveLayers, NeighborhoodExplorerOptions } from './types.js';
9
+ interface LabelHost {
10
+ root: HTMLElement;
11
+ canvas: HTMLElement;
12
+ scale: HTMLElement;
13
+ overlayElement: HTMLElement;
14
+ secondaryRoads: NodeListOf<SVGPathElement>;
15
+ stationItems: (LabelItem & {
16
+ node: SVGCircleElement;
17
+ })[];
18
+ roadItems: LabelItem[];
19
+ parkItems: LabelItem[];
20
+ scaleUnitsPerMeter: number;
21
+ neighborhoods: ReturnType<typeof createNeighborhoodLayer>;
22
+ markerCatalog: ReturnType<typeof createMarkerCatalog>;
23
+ markers: ReturnType<typeof createMarkerController>;
24
+ labelLayer: SVGGElement;
25
+ invalidateFrame(): void;
26
+ get districtItems(): LabelItem[];
27
+ get layers(): InteractiveLayers;
28
+ get mode(): ExplorerMode;
29
+ get labelStyle(): NonNullable<NeighborhoodExplorerOptions['labelStyle']>;
30
+ get colors(): NonNullable<NeighborhoodExplorerOptions['colors']>;
31
+ get minLabelSize(): number;
32
+ get maxLabelSize(): number;
33
+ get markerRadius(): number;
34
+ get view(): View;
35
+ get destroyed(): boolean;
36
+ get labels(): boolean;
37
+ get features(): ReturnType<typeof normalizeFeatures>;
38
+ get renderedMarkers(): (MarkerItem & {
39
+ visual: MarkerVisual;
40
+ })[];
41
+ get renderedClusters(): {
42
+ point: [number, number];
43
+ }[];
44
+ enabled(layer: keyof InteractiveLayers): boolean;
45
+ drawClusters(unit: number): void;
46
+ }
47
+ /** Own candidate caching, screen-space collision layout and label metrics invalidation. */
48
+ export declare function createLabelCoordinator(host: LabelHost): {
49
+ draw: () => void;
50
+ schedule(): void;
51
+ invalidateMetrics(): void;
52
+ clear(): void;
53
+ };
54
+ export {};
@@ -0,0 +1,157 @@
1
+ import { setAttributeIfChanged } from './dom.js';
2
+ import { labelCandidates, styleLabels, } from './label-candidates.js';
3
+ import { createLabelRenderer } from './label-renderer.js';
4
+ /** Own candidate caching, screen-space collision layout and label metrics invalidation. */
5
+ export function createLabelCoordinator(host) {
6
+ const { root, canvas, scale, overlayElement, secondaryRoads, stationItems, roadItems, parkItems, scaleUnitsPerMeter, neighborhoods, markerCatalog, markers, enabled, drawClusters, } = host;
7
+ const labelRenderer = createLabelRenderer(host.labelLayer);
8
+ let secondaryRoadsVisible;
9
+ let labelInputs = [];
10
+ let preparedLabels = [];
11
+ function prepareLabels(width, zoom) {
12
+ // Only screen-space positioning changes during pan or within these zoom/width bands.
13
+ const inputs = [
14
+ neighborhoods.items,
15
+ neighborhoods.selected,
16
+ host.districtItems,
17
+ markers.revision,
18
+ host.layers,
19
+ host.mode,
20
+ host.labelStyle,
21
+ host.colors,
22
+ host.minLabelSize,
23
+ host.maxLabelSize,
24
+ host.markerRadius,
25
+ zoom >= 1.8,
26
+ width >= 550,
27
+ ];
28
+ if (inputs.every((value, index) => value === labelInputs[index]))
29
+ return preparedLabels;
30
+ labelInputs = inputs;
31
+ const selectedItem = neighborhoods.items.find((item) => item.feature === neighborhoods.selected);
32
+ const candidates = [];
33
+ const marker = markerCatalog.items.find((item) => item.marker.id === markers.selected);
34
+ if (marker)
35
+ candidates.push({
36
+ point: marker.point,
37
+ name: marker.marker.label ?? marker.marker.id,
38
+ kind: 'selected-marker',
39
+ });
40
+ if (selectedItem?.point && enabled('neighborhoodLabels'))
41
+ candidates.push({
42
+ point: selectedItem.point,
43
+ name: selectedItem.feature.properties.canonicalName,
44
+ kind: 'selected',
45
+ });
46
+ preparedLabels = styleLabels(labelCandidates({
47
+ selected: candidates,
48
+ districts: enabled('districtLabels') ? host.districtItems : [],
49
+ stations: enabled('bartStations') ? stationItems : [],
50
+ parks: enabled('landmarks') ? parkItems : [],
51
+ roads: enabled('roadLabels') ? roadItems : [],
52
+ neighborhoods: enabled('neighborhoodLabels')
53
+ ? neighborhoods.items
54
+ .filter((item) => item.point !== undefined)
55
+ .sort((a, b) => b.area - a.area || a.feature.id.localeCompare(b.feature.id))
56
+ .flatMap((item) => item.feature !== neighborhoods.selected && item.point
57
+ ? [
58
+ {
59
+ point: item.point,
60
+ name: item.feature.properties.canonicalName,
61
+ kind: 'neighborhood',
62
+ },
63
+ ]
64
+ : [])
65
+ : [],
66
+ }, width, zoom), {
67
+ colors: host.colors,
68
+ labelStyle: host.labelStyle,
69
+ labelSize: { min: host.minLabelSize, max: host.maxLabelSize },
70
+ markerRadius: marker?.marker.radius ?? host.markerRadius,
71
+ });
72
+ return preparedLabels;
73
+ }
74
+ function drawLabels() {
75
+ if (host.destroyed)
76
+ return;
77
+ const canvasBox = canvas.getBoundingClientRect();
78
+ const width = canvasBox.width;
79
+ if (!width)
80
+ return;
81
+ const unit = host.view[2] / width, zoom = 800 / host.view[2];
82
+ if (host.features.scaleBar) {
83
+ const pixelsPerMeter = scaleUnitsPerMeter / unit;
84
+ const base = 10 ** Math.floor(Math.log10(100 / pixelsPerMeter));
85
+ const meters = [5, 2, 1].map((factor) => factor * base).find((value) => value * pixelsPerMeter <= 100) ??
86
+ base;
87
+ const scaleWidth = `${meters * pixelsPerMeter}px`;
88
+ const scaleText = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`;
89
+ if (scale.style.width !== scaleWidth)
90
+ scale.style.width = scaleWidth;
91
+ if (scale.textContent !== scaleText)
92
+ scale.textContent = scaleText;
93
+ }
94
+ // Read overlay bounds before SVG writes; scale furniture above must have its current width.
95
+ const furnitureBounds = [...overlayElement.children]
96
+ .filter((node) => node.getClientRects().length > 0)
97
+ .map((node) => {
98
+ const box = node.getBoundingClientRect();
99
+ return [
100
+ box.left - canvasBox.left - 3,
101
+ box.top - canvasBox.top - 3,
102
+ box.right - canvasBox.left + 3,
103
+ box.bottom - canvasBox.top + 3,
104
+ ];
105
+ });
106
+ const showSecondaryRoads = zoom >= 1.8;
107
+ if (showSecondaryRoads !== secondaryRoadsVisible) {
108
+ for (const road of secondaryRoads)
109
+ road.style.display = showSecondaryRoads ? '' : 'none';
110
+ secondaryRoadsVisible = showSecondaryRoads;
111
+ }
112
+ for (const station of stationItems)
113
+ setAttributeIfChanged(station.node, 'r', 4.5 * unit);
114
+ drawClusters(unit);
115
+ if (!host.labels) {
116
+ labelRenderer.clear();
117
+ root.dataset.visibleLabels = '0';
118
+ return;
119
+ }
120
+ const labelCandidates = prepareLabels(width, zoom);
121
+ const stationBounds = (enabled('bartStations') ? stationItems : []).map(({ point }) => {
122
+ const x = (point[0] - host.view[0]) / unit;
123
+ const y = (point[1] - host.view[1]) / unit;
124
+ return [x - 6, y - 6, x + 6, y + 6];
125
+ });
126
+ const markerBounds = host.renderedMarkers.map(({ point, marker }) => {
127
+ const radius = marker.radius ?? host.markerRadius;
128
+ const x = (point[0] - host.view[0]) / unit, y = (point[1] - host.view[1]) / unit;
129
+ return [x - radius - 3, y - radius - 3, x + radius + 3, y + radius + 3];
130
+ });
131
+ root.dataset.visibleLabels = String(labelRenderer.draw(labelCandidates, host.view, width, [
132
+ ...stationBounds,
133
+ ...markerBounds,
134
+ ...furnitureBounds,
135
+ ...host.renderedClusters.map(({ point }) => {
136
+ const x = (point[0] - host.view[0]) / unit, y = (point[1] - host.view[1]) / unit;
137
+ return [x - 26, y - 26, x + 26, y + 26];
138
+ }),
139
+ ]));
140
+ }
141
+ return {
142
+ draw: drawLabels,
143
+ schedule() {
144
+ if (!host.destroyed)
145
+ host.invalidateFrame();
146
+ },
147
+ invalidateMetrics() {
148
+ labelRenderer.invalidateMetrics();
149
+ labelInputs = [];
150
+ },
151
+ clear() {
152
+ labelRenderer.clear();
153
+ labelInputs = [];
154
+ preparedLabels = [];
155
+ },
156
+ };
157
+ }
package/dist/src/map.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import { copyAppearance } from './appearance.js';
2
2
  import { validateCameraOptions } from './camera.js';
3
3
  import { configurationPatch, expandMapOptions, prepareConfiguration } from './configuration.js';
4
+ import { createConfigurationController } from './configuration-controller.js';
4
5
  import { createNeighborhoodExplorerCore } from './explorer-core.js';
5
6
  import { createMapShell } from './explorer-shell.js';
6
- import { controlKeys, layerKeys } from './features.js';
7
+ import { layerKeys } from './features.js';
7
8
  import { assertOptions } from './validation.js';
8
9
  /** No geography is imported. Supply a preset or your own immutable data. */
9
10
  export function createMap(data, options = {}) {
10
11
  const expanded = expandMapOptions(options);
11
- let config = prepareConfiguration({ features: {}, layers: {}, controls: {}, appearance: {} }, {
12
+ const initialConfiguration = prepareConfiguration({ features: {}, layers: {}, controls: {}, appearance: {} }, {
12
13
  features: options.features,
13
14
  layers: options.layers,
14
15
  controls: options.controls,
@@ -21,7 +22,8 @@ export function createMap(data, options = {}) {
21
22
  onOverlayActivate: () => { },
22
23
  }, data, { createShell: createMapShell });
23
24
  const subscriptions = new Set();
24
- let configurationRevision = 0;
25
+ const configuration = createConfigurationController(initialConfiguration, element, () => destroyed);
26
+ const configure = configuration.configure;
25
27
  const sourceList = ['realtor', 'sf-find', 'analysis'].filter((source) => !!data.neighborhoods[source]);
26
28
  const yearList = [2002, 2012, 2022].filter((year) => !!data.map.districts?.[year] && !!data.districts?.[year]);
27
29
  const capabilities = () => ({
@@ -42,11 +44,11 @@ export function createMap(data, options = {}) {
42
44
  const snapshot = () => ({
43
45
  ...element.getMapState(),
44
46
  ...structuredClone({
45
- features: config.features,
46
- layers: config.layers,
47
- controls: config.controls,
47
+ features: configuration.current.features,
48
+ layers: configuration.current.layers,
49
+ controls: configuration.current.controls,
48
50
  }),
49
- appearance: copyAppearance(config.appearance),
51
+ appearance: copyAppearance(configuration.current.appearance),
50
52
  });
51
53
  const resolved = () => {
52
54
  const state = element.getMapState();
@@ -58,7 +60,7 @@ export function createMap(data, options = {}) {
58
60
  key,
59
61
  supplied[key] &&
60
62
  (!key.endsWith('Labels') || state.labels) &&
61
- (config.layers[key] ??
63
+ (configuration.current.layers[key] ??
62
64
  (key.startsWith('district')
63
65
  ? state.mode === 'districts'
64
66
  : key.startsWith('neighborhood')
@@ -119,31 +121,6 @@ export function createMap(data, options = {}) {
119
121
  element.fitGeometry(geometry, padding, motion);
120
122
  }),
121
123
  });
122
- function configure(patch) {
123
- const revision = ++configurationRevision;
124
- const next = prepareConfiguration(config, patch);
125
- const hasFeatures = 'features' in patch, hasLayers = 'layers' in patch, hasControls = 'controls' in patch;
126
- const layers = Object.fromEntries(layerKeys.map((key) => [key, next.layers[key]]));
127
- const controls = Object.fromEntries(controlKeys.map((key) => [key, next.controls[key]]));
128
- const features = patch.features === undefined
129
- ? next.features
130
- : Object.fromEntries(Object.keys(patch.features).map((key) => [
131
- key,
132
- next.features[key],
133
- ]));
134
- let committed = false;
135
- element.applyPresentation({ ...patch, ...('appearance' in patch ? { appearance: next.appearance } : {}) }, () => {
136
- committed = true;
137
- config = next;
138
- if (hasFeatures)
139
- element.setFeatures(features);
140
- if (hasLayers)
141
- element.setLayers(layers);
142
- if (hasControls)
143
- element.setControls(controls);
144
- }, () => !destroyed && revision === configurationRevision);
145
- return committed && !destroyed && revision === configurationRevision;
146
- }
147
124
  return Object.freeze({
148
125
  element,
149
126
  overlayElement: element.overlayElement,
@@ -152,7 +129,7 @@ export function createMap(data, options = {}) {
152
129
  return destroyed;
153
130
  },
154
131
  configure: use((patch) => {
155
- configure(configurationPatch(patch, config));
132
+ configure(configurationPatch(patch, configuration.current));
156
133
  }),
157
134
  resetConfiguration: use((groups = ['features', 'layers', 'controls', 'appearance']) => {
158
135
  if (!Array.isArray(groups) ||
@@ -242,7 +219,7 @@ export function createMap(data, options = {}) {
242
219
  selectDistrict: use(element.selectDistrict),
243
220
  getSelectedDistrict: use(element.getSelectedDistrict),
244
221
  setDistrictYear: use((...args) => {
245
- configurationRevision++;
222
+ configuration.invalidate();
246
223
  element.setDistrictYear(...args);
247
224
  }),
248
225
  setDistrictStyle: use((style) => {
@@ -0,0 +1,55 @@
1
+ import type { Position } from '../data/types.js';
2
+ import { type View } from './explorer-layout.js';
3
+ import type { normalizeFeatures } from './features.js';
4
+ import type { createMarkerCatalog } from './marker-catalog.js';
5
+ import type { createMarkerChooser } from './marker-chooser.js';
6
+ import type { MarkerItem, MarkerVisual } from './marker-layer.js';
7
+ import type { createMarkerNavigation } from './marker-navigation.js';
8
+ import type { CameraOptions, MapMarker, NeighborhoodExplorerOptions } from './types.js';
9
+ interface MarkerHost {
10
+ root: HTMLElement;
11
+ canvas: HTMLElement;
12
+ svg: SVGSVGElement;
13
+ markerLayer: SVGGElement;
14
+ markerSelect: HTMLSelectElement;
15
+ markerLabel: HTMLElement;
16
+ markerCatalog: ReturnType<typeof createMarkerCatalog>;
17
+ markerNavigation: ReturnType<typeof createMarkerNavigation>;
18
+ get markerChooser(): ReturnType<typeof createMarkerChooser>;
19
+ get destroyed(): boolean;
20
+ get view(): View;
21
+ get features(): ReturnType<typeof normalizeFeatures>;
22
+ get markerColor(): string;
23
+ get selectedMarkerColor(): string;
24
+ get controls(): NonNullable<NeighborhoodExplorerOptions['controls']>;
25
+ strings: NonNullable<NeighborhoodExplorerOptions['strings']>;
26
+ fitPadding: NonNullable<NeighborhoodExplorerOptions['fitPadding']>;
27
+ onMarkerActivate: NeighborhoodExplorerOptions['onMarkerActivate'];
28
+ invalidateClusters(): void;
29
+ drawClusters(unit: number): void;
30
+ materializeMarker(item: MarkerItem): MarkerItem & {
31
+ visual: MarkerVisual;
32
+ };
33
+ scheduleLabels(): void;
34
+ syncFeatureControls(): void;
35
+ moveView(view: View, options?: CameraOptions): void;
36
+ }
37
+ /** Own selection revisions, activation and focus recovery independently of marker rendering. */
38
+ export declare function createMarkerController(host: MarkerHost): {
39
+ getSelectedMarker: () => {
40
+ id: string;
41
+ label?: string;
42
+ selected?: boolean;
43
+ color?: string;
44
+ radius?: number;
45
+ position: Position;
46
+ } | null;
47
+ selectMarker: (id: string | null, options?: {
48
+ fit?: boolean;
49
+ } & CameraOptions) => boolean;
50
+ setMarkers: (markers: readonly MapMarker[]) => void;
51
+ activateMarker: (id: string, node: SVGElement) => void;
52
+ readonly selected: string | null;
53
+ readonly revision: number;
54
+ };
55
+ export {};
@@ -0,0 +1,125 @@
1
+ import { validateCameraOptions } from './camera.js';
2
+ import { fitBounds } from './explorer-layout.js';
3
+ import { fitViewport } from './viewport.js';
4
+ /** Own selection revisions, activation and focus recovery independently of marker rendering. */
5
+ export function createMarkerController(host) {
6
+ const { root, canvas, svg, markerLayer, markerSelect, markerLabel, markerCatalog, markerNavigation, strings, fitPadding, onMarkerActivate, invalidateClusters, drawClusters, materializeMarker, scheduleLabels, syncFeatureControls, moveView, } = host;
7
+ let selectedMarker = null;
8
+ let markerRevision = 0;
9
+ function getSelectedMarker() {
10
+ const marker = markerCatalog.items.find((item) => item.marker.id === selectedMarker)?.marker;
11
+ return marker ? { ...marker, position: [...marker.position] } : null;
12
+ }
13
+ function selectMarker(id, options = {}) {
14
+ if (host.destroyed)
15
+ return false;
16
+ const item = markerCatalog.items.find((item) => item.marker.id === id);
17
+ if (id !== null && !item)
18
+ return false;
19
+ validateCameraOptions(options);
20
+ const width = canvas.getBoundingClientRect().width;
21
+ const target = item && options.fit !== false
22
+ ? width
23
+ ? fitViewport([...item.point, ...item.point], width, fitPadding)
24
+ : fitBounds([...item.point, ...item.point])
25
+ : null;
26
+ const revision = ++markerRevision;
27
+ const changed = selectedMarker !== id;
28
+ selectedMarker = id;
29
+ if (changed)
30
+ invalidateClusters();
31
+ markerSelect.value = id ?? '';
32
+ for (const entry of markerCatalog.items) {
33
+ const active = entry.marker.id === id;
34
+ const visual = active ? materializeMarker(entry).visual : entry.visual;
35
+ if (!visual)
36
+ continue;
37
+ const pressed = String(active);
38
+ const ring = active && host.features.selectedMarkerRing ? 'inline' : 'none';
39
+ const fill = active ? host.selectedMarkerColor : (entry.marker.color ?? host.markerColor);
40
+ if (visual.node.getAttribute('aria-pressed') !== pressed)
41
+ visual.node.setAttribute('aria-pressed', pressed);
42
+ if (visual.ring.getAttribute('display') !== ring)
43
+ visual.ring.setAttribute('display', ring);
44
+ if (visual.dot.getAttribute('fill') !== fill)
45
+ visual.dot.setAttribute('fill', fill);
46
+ if (active && markerLayer.lastChild !== visual.node)
47
+ markerLayer.append(visual.node);
48
+ }
49
+ scheduleLabels();
50
+ if (width || !host.features.clustering)
51
+ drawClusters(width ? host.view[2] / width : 1);
52
+ const marker = getSelectedMarker();
53
+ if (changed)
54
+ root.dispatchEvent(new CustomEvent('markerchange', { bubbles: true, detail: { id, marker } }));
55
+ if (!host.destroyed &&
56
+ changed &&
57
+ marker &&
58
+ markerRevision === revision &&
59
+ selectedMarker === id)
60
+ onMarkerActivate?.({ ...marker });
61
+ if (!host.destroyed && markerRevision === revision && target)
62
+ moveView(target, options);
63
+ return true;
64
+ }
65
+ function setMarkers(markers) {
66
+ if (host.destroyed)
67
+ return;
68
+ const prepared = markerCatalog.prepare(markers, selectedMarker);
69
+ if (!prepared)
70
+ return;
71
+ const { selection: nextSelection, focused: focusedMarker } = prepared;
72
+ invalidateClusters();
73
+ prepared.commit();
74
+ markerLabel.hidden = !markerCatalog.items.length || host.controls.markerPicker === false;
75
+ if (markerLabel.firstChild)
76
+ markerLabel.firstChild.textContent = `${strings.chooseMarker ?? 'Choose marker'} (${markerCatalog.items.length})`;
77
+ syncFeatureControls();
78
+ const revision = markerRevision + 1;
79
+ selectMarker(nextSelection, { fit: false });
80
+ if (!host.destroyed && markerRevision === revision && focusedMarker) {
81
+ markerNavigation.sync(markerCatalog.items.flatMap((item) => (item.visual ? [item.visual.node] : [])));
82
+ if (!markerNavigation.recover()) {
83
+ const recovery = markerCatalog.items.find((item) => item.marker.id === focusedMarker) ??
84
+ markerCatalog.items[0];
85
+ if (recovery) {
86
+ const { node } = materializeMarker(recovery).visual;
87
+ markerLayer.append(node);
88
+ node.focus({ preventScroll: true });
89
+ const width = canvas.getBoundingClientRect().width;
90
+ if (width)
91
+ drawClusters(host.view[2] / width);
92
+ }
93
+ else
94
+ svg.focus({ preventScroll: true });
95
+ }
96
+ }
97
+ }
98
+ function activateMarker(id, node) {
99
+ const item = markerCatalog.items.find((item) => item.marker.id === id);
100
+ if (!item)
101
+ return;
102
+ const coincident = markerCatalog.items.filter((other) => other.point[0] === item.point[0] && other.point[1] === item.point[1]);
103
+ if (coincident.length > 1)
104
+ host.markerChooser.open(coincident.map((other) => other.marker), node);
105
+ else {
106
+ selectMarker(id);
107
+ if (!host.destroyed && selectedMarker === id)
108
+ markerCatalog.items
109
+ .find((other) => other.marker.id === id)
110
+ ?.visual?.node.focus({ preventScroll: true });
111
+ }
112
+ }
113
+ return {
114
+ getSelectedMarker,
115
+ selectMarker,
116
+ setMarkers,
117
+ activateMarker,
118
+ get selected() {
119
+ return selectedMarker;
120
+ },
121
+ get revision() {
122
+ return markerRevision;
123
+ },
124
+ };
125
+ }
package/llms-full.txt CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  > Offline San Francisco SVG maps with explicit geographic imports, no runtime dependencies, static rendering, and a browser controller.
4
4
 
5
- @kahwee/sf-map-svg 5.1.1 · local working tree. Node >=24.0.0 for server rendering; browsers need a DOM and a JSON-capable bundler.
5
+ @kahwee/sf-map-svg 5.1.3 · local working tree. Node >=24.0.0 for server rendering; browsers need a DOM and a JSON-capable bundler.
6
6
 
7
7
  These are working-tree docs. Unreleased changelog entries may describe APIs absent from npm; verify the installed version before copying them.
8
8
 
9
9
  Use renderMap(data, options) for SVG strings; createMap(data, options) returns a controller. Mount map.element and destroy it on unmount. Choose mode explicitly: createMap defaults to basemap. Geography is separate; staticMapData and fullMapData have different shapes.
10
10
 
11
- <!-- Generated from README.md; @kahwee/sf-map-svg 5.1.1 · local working tree. -->
11
+ <!-- Generated from README.md; @kahwee/sf-map-svg 5.1.3 · local working tree. -->
12
12
 
13
13
  # SF Map SVG
14
14
 
@@ -267,7 +267,7 @@ for raw samples, output-equivalence checks, and reproducible commands.
267
267
 
268
268
  ---
269
269
 
270
- <!-- Generated from docs/developer-guide.md; @kahwee/sf-map-svg 5.1.1 · local working tree. -->
270
+ <!-- Generated from docs/developer-guide.md; @kahwee/sf-map-svg 5.1.3 · local working tree. -->
271
271
 
272
272
  # Developer guide
273
273
 
@@ -433,7 +433,7 @@ See [API options](https://github.com/kahwee/sf-map-svg/blob/main/docs/API.md), [
433
433
 
434
434
  ---
435
435
 
436
- <!-- Generated from docs/API.md; @kahwee/sf-map-svg 5.1.1 · local working tree. -->
436
+ <!-- Generated from docs/API.md; @kahwee/sf-map-svg 5.1.3 · local working tree. -->
437
437
 
438
438
  # Map options and lifecycle
439
439
 
@@ -619,7 +619,7 @@ Call `binding.setMarkers(next)` after `map.setMarkers(next)` when the catalog ch
619
619
 
620
620
  ---
621
621
 
622
- <!-- Generated from docs/EXAMPLES.md; @kahwee/sf-map-svg 5.1.1 · local working tree. -->
622
+ <!-- Generated from docs/EXAMPLES.md; @kahwee/sf-map-svg 5.1.3 · local working tree. -->
623
623
 
624
624
  # Examples
625
625
 
@@ -760,7 +760,7 @@ See [query semantics and limitations](https://github.com/kahwee/sf-map-svg/blob/
760
760
 
761
761
  ---
762
762
 
763
- <!-- Generated from docs/consumer-integration.md; @kahwee/sf-map-svg 5.1.1 · local working tree. -->
763
+ <!-- Generated from docs/consumer-integration.md; @kahwee/sf-map-svg 5.1.3 · local working tree. -->
764
764
 
765
765
  # Embedding and progressive enhancement
766
766
 
@@ -938,7 +938,7 @@ keyboard, focus, and dismissal behavior. See [coordinates and units](https://git
938
938
 
939
939
  ---
940
940
 
941
- <!-- Generated from docs/migration-v5.md; @kahwee/sf-map-svg 5.1.1 · local working tree. -->
941
+ <!-- Generated from docs/migration-v5.md; @kahwee/sf-map-svg 5.1.3 · local working tree. -->
942
942
 
943
943
  # Migrating to v5
944
944
 
@@ -1043,7 +1043,7 @@ collections. Shared edges can match more than one feature. See the
1043
1043
 
1044
1044
  ---
1045
1045
 
1046
- <!-- Generated from docs/migration-v3.md; @kahwee/sf-map-svg 5.1.1 · local working tree. -->
1046
+ <!-- Generated from docs/migration-v3.md; @kahwee/sf-map-svg 5.1.3 · local working tree. -->
1047
1047
 
1048
1048
  # Migrate to version 3
1049
1049
 
@@ -1084,7 +1084,7 @@ District styles are now prepared once per district on construction and style/yea
1084
1084
 
1085
1085
  ---
1086
1086
 
1087
- <!-- Generated from SOURCES.md; @kahwee/sf-map-svg 5.1.1 · local working tree. -->
1087
+ <!-- Generated from SOURCES.md; @kahwee/sf-map-svg 5.1.3 · local working tree. -->
1088
1088
 
1089
1089
  # Geometry and provenance
1090
1090
 
@@ -1321,7 +1321,7 @@ Report-history points preserve only actual validated source counts and their dis
1321
1321
 
1322
1322
  ---
1323
1323
 
1324
- <!-- Generated from CHANGELOG.md; @kahwee/sf-map-svg 5.1.1 · local working tree. -->
1324
+ <!-- Generated from CHANGELOG.md; @kahwee/sf-map-svg 5.1.3 · local working tree. -->
1325
1325
 
1326
1326
  # Changelog
1327
1327
 
@@ -1329,6 +1329,14 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
1329
1329
 
1330
1330
  ## Unreleased
1331
1331
 
1332
+ ## 5.1.3 — 2026-11-05
1333
+
1334
+ - Give configuration snapshots and revisions a dedicated internal owner. Split presentation preparation from a guarded, single-use commit, rejecting stale or disposed updates before mutating presentation. Preserve public configuration and reset behavior.
1335
+
1336
+ ## 5.1.2 — 2026-11-05
1337
+
1338
+ - Separate marker selection, activation and focus recovery into an internal controller; separate label candidate caching, screen-space collision layout and metrics invalidation into a label coordinator. Preserve the public API and shared animation frame scheduling.
1339
+
1332
1340
  ## 5.1.1 — 2026-11-05
1333
1341
 
1334
1342
  - Reserve precinct report controls and status space during loading to prevent phone layout shifts when multiple reports are available.
@@ -1717,7 +1725,7 @@ consistent home, and makes animation and subscription ownership predictable.
1717
1725
 
1718
1726
  # Published type contracts
1719
1727
 
1720
- @kahwee/sf-map-svg 5.1.1 · local working tree. These declarations are generated by TypeScript. Use package export paths, not the internal filenames below. Imported relative contracts are included for context, not as additional public entry points.
1728
+ @kahwee/sf-map-svg 5.1.3 · local working tree. These declarations are generated by TypeScript. Use package export paths, not the internal filenames below. Imported relative contracts are included for context, not as additional public entry points.
1721
1729
 
1722
1730
  | Public import | Declaration |
1723
1731
  | --- | --- |
package/llms.txt CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Offline San Francisco SVG maps with explicit geographic imports, no runtime dependencies, static rendering, and a browser controller.
4
4
 
5
- @kahwee/sf-map-svg 5.1.1 · local working tree. Node >=24.0.0 for server rendering; browsers need a DOM and a JSON-capable bundler.
5
+ @kahwee/sf-map-svg 5.1.3 · local working tree. Node >=24.0.0 for server rendering; browsers need a DOM and a JSON-capable bundler.
6
6
 
7
7
  These are working-tree docs. Unreleased changelog entries may describe APIs absent from npm; verify the installed version before copying them.
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kahwee/sf-map-svg",
3
- "version": "5.1.1",
3
+ "version": "5.1.3",
4
4
  "description": "Offline SVG maps of San Francisco with district boundaries, parks, landmarks, and BART stations.",
5
5
  "type": "module",
6
6
  "main": "./dist/src/api.js",