@kahwee/sf-map-svg 5.1.0 → 5.1.1

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 CHANGED
@@ -4,6 +4,12 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 5.1.1 — 2026-11-05
8
+
9
+ - Reserve precinct report controls and status space during loading to prevent phone layout shifts when multiple reports are available.
10
+
11
+ - Isolate district selection, style revisions and cancellable motion in an internal controller while preserving public map behavior. Document interactive state ownership and verify district lifecycle callbacks.
12
+
7
13
  ## 5.1.0 — 2026-11-05
8
14
 
9
15
  - Add official November 2026 precinct aggregates (`data/elections/precincts-<report time>.json` with a time-ordered `precincts-index.json`) and the 2022 precinct boundaries (`data/precincts-2022.json`), reconciled with the department's summary report. Only aggregates are committed, never the ballot-level export. Export the boundaries as `precinctMaps` from `@kahwee/sf-map-svg/data` and `@kahwee/sf-map-svg/data/precincts`; results snapshots stay Pages data.
@@ -0,0 +1,64 @@
1
+ import type { Geometry, Position } from '../data/types.js';
2
+ import { prepareDistrictStyles } from './district-style.js';
3
+ import type { InteractiveSFMapData } from './explorer-data.js';
4
+ import type { normalizeFeatures } from './features.js';
5
+ import type { LabelItem } from './label-candidates.js';
6
+ import type { CameraOptions, DistrictSelection, DistrictYear, NeighborhoodExplorerOptions } from './types.js';
7
+ type Host = {
8
+ root: HTMLElement;
9
+ svg: SVGSVGElement;
10
+ status: HTMLElement;
11
+ data: InteractiveSFMapData;
12
+ districtLayers: SVGGElement[];
13
+ get year(): DistrictYear;
14
+ set year(value: DistrictYear);
15
+ get theme(): NeighborhoodExplorerOptions['theme'];
16
+ get colors(): NonNullable<NeighborhoodExplorerOptions['colors']>;
17
+ get features(): ReturnType<typeof normalizeFeatures>;
18
+ get destroyed(): boolean;
19
+ get initialized(): boolean;
20
+ reducedMotion: MediaQueryList;
21
+ enabled(layer: 'districtFills' | 'districtLines'): boolean;
22
+ fadeDuration(): number;
23
+ fitGeometry(geometry: Geometry, padding: undefined, options: CameraOptions): void;
24
+ project(point: Position): [number, number];
25
+ setLabels(items: LabelItem[]): void;
26
+ updateComposition(): void;
27
+ scheduleLabels(): void;
28
+ };
29
+ /** Owns district selection, styles, revisions and cancellable motion. */
30
+ export declare function createDistrictController(host: Host, initialDistrictStyle: NeighborhoodExplorerOptions['districtStyle']): {
31
+ districtRow: (id: number) => import("./map-core.js").DistrictRowData | undefined;
32
+ districtSelection: (id: number | null) => DistrictSelection | {
33
+ id: null;
34
+ year: DistrictYear;
35
+ district: null;
36
+ };
37
+ getSelectedDistrict: () => DistrictSelection | null;
38
+ updateDistrictAppearance: () => void;
39
+ selectDistrict: (id: number | null, options?: {
40
+ fit?: boolean;
41
+ } & CameraOptions) => boolean;
42
+ activateDistrict: (id: number) => boolean;
43
+ setDistrictStyle: (style: ((district: import('./map-core.js').DistrictRowData) => import("./types.js").DistrictStyle) | undefined) => void;
44
+ setDistrictYear: (next: DistrictYear, options?: CameraOptions, preparedStyles?: ReturnType<typeof prepareDistrictStyles>) => void;
45
+ transition: {
46
+ cancel(): void;
47
+ fade(layer: SVGGElement, duration: number): void;
48
+ };
49
+ morph: {
50
+ stop: () => void;
51
+ start(layer: SVGGElement, previous: ReadonlyMap<string, string>, { duration, stroke }: {
52
+ duration: number;
53
+ stroke: string;
54
+ }): void;
55
+ readonly active: boolean;
56
+ };
57
+ readonly revision: number;
58
+ invalidate(): void;
59
+ readonly style: ((district: import('./map-core.js').DistrictRowData) => import("./types.js").DistrictStyle) | undefined;
60
+ readonly styles: ReadonlyMap<number, import("./types.js").DistrictStyle>;
61
+ commitStyles(style: ((district: import('./map-core.js').DistrictRowData) => import("./types.js").DistrictStyle) | undefined, styles: ReadonlyMap<number, import("./types.js").DistrictStyle>): void;
62
+ hovered: number | null;
63
+ };
64
+ export {};
@@ -0,0 +1,215 @@
1
+ import { validateCameraOptions } from './camera.js';
2
+ import { renderDistrictAppearance } from './district-layer.js';
3
+ import { createDistrictMorph } from './district-morph.js';
4
+ import { prepareDistrictStyles } from './district-style.js';
5
+ import { createDistrictTransition } from './district-transition.js';
6
+ import { svgElement } from './dom.js';
7
+ import { getLayerPathsWithData } from './map-core.js';
8
+ /** Owns district selection, styles, revisions and cancellable motion. */
9
+ export function createDistrictController(host, initialDistrictStyle) {
10
+ const { root, svg, status, data, districtLayers, reducedMotion, enabled, fitGeometry, updateComposition, scheduleLabels, } = host;
11
+ const districtMorph = createDistrictMorph();
12
+ let selectedDistrict = null;
13
+ let hoveredDistrict = null;
14
+ let districtStyle = initialDistrictStyle;
15
+ let districtStyles = prepareDistrictStyles(data.map.districts?.[host.year] ?? [], districtStyle);
16
+ const districtTransition = createDistrictTransition();
17
+ let districtRevision = 0;
18
+ root.dataset.year = String(host.year);
19
+ function districtRow(id) {
20
+ return data.map.districts?.[host.year]?.find((district) => district.id === id);
21
+ }
22
+ function districtSelection(id) {
23
+ const district = id === null ? undefined : districtRow(id);
24
+ return district
25
+ ? { id: district.id, year: host.year, district: structuredClone(district) }
26
+ : { id: null, year: host.year, district: null };
27
+ }
28
+ function getSelectedDistrict() {
29
+ return selectedDistrict === null
30
+ ? null
31
+ : districtSelection(selectedDistrict);
32
+ }
33
+ function updateDistrictAppearance() {
34
+ renderDistrictAppearance({
35
+ svg,
36
+ rows: data.map.districts?.[host.year] ?? [],
37
+ styles: districtStyles,
38
+ selectedDistrict,
39
+ hoveredDistrict,
40
+ year: host.year,
41
+ theme: host.theme,
42
+ colors: host.colors,
43
+ fills: enabled('districtFills'),
44
+ lines: enabled('districtLines'),
45
+ });
46
+ }
47
+ function selectDistrict(id, options = {}) {
48
+ if (host.destroyed)
49
+ return false;
50
+ validateCameraOptions(options);
51
+ if (id !== null && (!Number.isInteger(id) || !districtRow(id)))
52
+ return false;
53
+ const changed = selectedDistrict !== id;
54
+ const revision = ++districtRevision;
55
+ selectedDistrict = id;
56
+ if (id === null)
57
+ delete root.dataset.selectedDistrict;
58
+ else
59
+ root.dataset.selectedDistrict = String(id);
60
+ updateDistrictAppearance();
61
+ const selectedRow = id === null ? undefined : districtRow(id);
62
+ if (selectedRow && options.fit)
63
+ fitGeometry(selectedRow.geometry, undefined, options);
64
+ if (host.destroyed || revision !== districtRevision)
65
+ return true;
66
+ status.textContent =
67
+ id === null
68
+ ? 'District selection cleared.'
69
+ : `District ${id} selected, ${host.year} boundaries.`;
70
+ if (changed)
71
+ root.dispatchEvent(new CustomEvent('districtchange', { bubbles: true, detail: districtSelection(id) }));
72
+ return true;
73
+ }
74
+ function activateDistrict(id) {
75
+ const revision = districtRevision + 1;
76
+ if (!selectDistrict(id) || host.destroyed || revision !== districtRevision)
77
+ return false;
78
+ root.dispatchEvent(new CustomEvent('districtactivate', { bubbles: true, detail: districtSelection(id) }));
79
+ return !host.destroyed && revision === districtRevision;
80
+ }
81
+ function setDistrictStyle(style) {
82
+ if (host.destroyed)
83
+ return;
84
+ const revision = districtRevision;
85
+ const prepared = prepareDistrictStyles(data.map.districts?.[host.year] ?? [], style);
86
+ if (host.destroyed || revision !== districtRevision)
87
+ return;
88
+ districtRevision++;
89
+ districtStyle = style;
90
+ districtStyles = prepared;
91
+ const fills = districtLayers.find((layer) => layer.dataset.layer === 'district-fills');
92
+ const fade = host.fadeDuration();
93
+ // A new style settles any fill crossfade still running from an earlier change.
94
+ districtTransition.cancel();
95
+ if (fills && fade && enabled('districtFills'))
96
+ districtTransition.fade(fills, fade);
97
+ updateDistrictAppearance();
98
+ }
99
+ function setDistrictYear(next, options = {}, preparedStyles) {
100
+ if (host.destroyed)
101
+ return;
102
+ validateCameraOptions(options);
103
+ if (![2002, 2012, 2022].includes(next))
104
+ throw new RangeError('District year must be 2002, 2012, or 2022.');
105
+ if (!data.map.districts?.[next] || !data.districts?.[next])
106
+ throw new RangeError(`No ${next} district dataset was supplied.`);
107
+ if (next === host.year)
108
+ return;
109
+ const revision = districtRevision;
110
+ const prepared = preparedStyles ?? prepareDistrictStyles(data.map.districts[next], districtStyle);
111
+ if (host.destroyed || revision !== districtRevision)
112
+ return;
113
+ const committedRevision = ++districtRevision;
114
+ const geometry = getLayerPathsWithData({ year: next }, data.map, false);
115
+ const previousYear = host.year;
116
+ districtTransition.cancel();
117
+ districtMorph.stop();
118
+ const lineLayer = districtLayers.find((layer) => layer.dataset.layer === 'district-lines');
119
+ const morph = host.features.districtMorph &&
120
+ options.animate !== false &&
121
+ host.initialized &&
122
+ !reducedMotion.matches &&
123
+ (enabled('districtLines') || enabled('districtFills'))
124
+ ? host.features.districtMorph
125
+ : false;
126
+ const previousOutlines = morph && lineLayer
127
+ ? new Map([...lineLayer.querySelectorAll('path[data-district]')].map((path) => [
128
+ path.dataset.district ?? '',
129
+ path.getAttribute('d') ?? '',
130
+ ]))
131
+ : undefined;
132
+ for (const layer of districtLayers) {
133
+ if (morph) {
134
+ // The morph replaces the line crossfade; fills fade across the whole morph.
135
+ if (layer !== lineLayer)
136
+ districtTransition.fade(layer, morph.duration);
137
+ }
138
+ else if (options.animate && !reducedMotion.matches)
139
+ districtTransition.fade(layer, options.duration ?? 280);
140
+ layer.replaceChildren(...geometry.districts.map((district) => svgElement('path', {
141
+ 'data-district': district.id,
142
+ d: district.path,
143
+ ...(layer.dataset.layer === 'district-fills'
144
+ ? { 'fill-rule': 'evenodd' }
145
+ : { fill: 'none', 'stroke-width': 1.1 }),
146
+ })));
147
+ }
148
+ host.year = next;
149
+ districtStyles = prepared;
150
+ hoveredDistrict = null;
151
+ root.dataset.year = String(host.year);
152
+ svg.dataset.year = String(host.year);
153
+ host.setLabels((data.districts[next]?.features ?? []).flatMap((feature) => feature.properties.labelPoints.map((point) => ({
154
+ point: host.project(point),
155
+ name: String(feature.properties.district),
156
+ kind: 'district',
157
+ }))));
158
+ if (selectedDistrict !== null && !districtRow(selectedDistrict))
159
+ selectedDistrict = null;
160
+ if (selectedDistrict === null)
161
+ delete root.dataset.selectedDistrict;
162
+ updateDistrictAppearance();
163
+ updateComposition();
164
+ if (morph && lineLayer && previousOutlines)
165
+ districtMorph.start(lineLayer, previousOutlines, {
166
+ duration: morph.duration,
167
+ stroke: host.colors.district ?? '#71838a',
168
+ });
169
+ scheduleLabels();
170
+ status.textContent = `${host.year} supervisorial districts.`;
171
+ root.dispatchEvent(new CustomEvent('districtyearchange', {
172
+ bubbles: true,
173
+ detail: { year: host.year, previousYear },
174
+ }));
175
+ if (!host.destroyed && committedRevision === districtRevision && selectedDistrict !== null)
176
+ root.dispatchEvent(new CustomEvent('districtchange', {
177
+ bubbles: true,
178
+ detail: districtSelection(selectedDistrict),
179
+ }));
180
+ }
181
+ return {
182
+ districtRow,
183
+ districtSelection,
184
+ getSelectedDistrict,
185
+ updateDistrictAppearance,
186
+ selectDistrict,
187
+ activateDistrict,
188
+ setDistrictStyle,
189
+ setDistrictYear,
190
+ transition: districtTransition,
191
+ morph: districtMorph,
192
+ get revision() {
193
+ return districtRevision;
194
+ },
195
+ invalidate() {
196
+ districtRevision++;
197
+ },
198
+ get style() {
199
+ return districtStyle;
200
+ },
201
+ get styles() {
202
+ return districtStyles;
203
+ },
204
+ commitStyles(style, styles) {
205
+ districtStyle = style;
206
+ districtStyles = styles;
207
+ },
208
+ get hovered() {
209
+ return hoveredDistrict;
210
+ },
211
+ set hovered(id) {
212
+ hoveredDistrict = id;
213
+ },
214
+ };
215
+ }
@@ -2,10 +2,8 @@ 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
7
  import { element, setAttributeIfChanged, svgElement } from './dom.js';
10
8
  import { createExplorerControls } from './explorer-controls.js';
11
9
  import { fitBounds, projectedBounds } from './explorer-layout.js';
@@ -182,177 +180,51 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
182
180
  const labelRenderer = createLabelRenderer(labelLayer);
183
181
  geography.append(areas, stations, labelLayer, overlayLayer, markerLayer);
184
182
  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
- }
183
+ const districts = createDistrictController({
184
+ root,
185
+ svg,
186
+ status,
187
+ data,
188
+ districtLayers,
189
+ reducedMotion,
190
+ enabled,
191
+ fitGeometry,
192
+ updateComposition,
193
+ scheduleLabels,
194
+ get year() {
195
+ return year;
196
+ },
197
+ set year(value) {
198
+ year = value;
199
+ },
200
+ get theme() {
201
+ return theme;
202
+ },
203
+ get colors() {
204
+ return colors;
205
+ },
206
+ get features() {
207
+ return features;
208
+ },
209
+ get destroyed() {
210
+ return destroyed;
211
+ },
212
+ get initialized() {
213
+ return initialized;
214
+ },
215
+ fadeDuration: () => layerFades.duration(),
216
+ project: map.project,
217
+ setLabels: (items) => {
218
+ districtItems = items;
219
+ },
220
+ }, initialDistrictStyle);
221
+ const { districtRow, districtSelection, getSelectedDistrict, updateDistrictAppearance, selectDistrict, activateDistrict, setDistrictStyle, setDistrictYear, transition: districtTransition, morph: districtMorph, } = districts;
349
222
  updateDistrictAppearance();
350
223
  let destroyed = false;
351
224
  let initialized = false;
352
225
  const layerFades = createLayerTransitions(() => initialized && features.layerTransitions && !reducedMotion.matches
353
226
  ? features.layerTransitions.duration
354
227
  : 0);
355
- const districtMorph = createDistrictMorph();
356
228
  const frames = createFrameScheduler({
357
229
  request: (callback) => requestAnimationFrame(callback),
358
230
  cancel: (id) => cancelAnimationFrame(id),
@@ -1098,9 +970,9 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
1098
970
  ? event.target.closest('[data-layer="district-fills"] [data-district], [data-layer="district-lines"] [data-district]')
1099
971
  : null;
1100
972
  const id = district?.dataset.district ? Number(district.dataset.district) : null;
1101
- if (id === hoveredDistrict)
973
+ if (id === districts.hovered)
1102
974
  return;
1103
- hoveredDistrict = id;
975
+ districts.hovered = id;
1104
976
  updateDistrictAppearance();
1105
977
  root.dispatchEvent(new CustomEvent('districthover', { bubbles: true, detail: districtSelection(id) }));
1106
978
  });
@@ -1108,9 +980,9 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
1108
980
  if (event.relatedTarget instanceof Element &&
1109
981
  event.relatedTarget.closest('[data-layer="district-fills"] [data-district], [data-layer="district-lines"] [data-district]'))
1110
982
  return;
1111
- if (hoveredDistrict === null)
983
+ if (districts.hovered === null)
1112
984
  return;
1113
- hoveredDistrict = null;
985
+ districts.hovered = null;
1114
986
  updateDistrictAppearance();
1115
987
  root.dispatchEvent(new CustomEvent('districthover', { bubbles: true, detail: districtSelection(null) }));
1116
988
  });
@@ -1137,15 +1009,15 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
1137
1009
  ? prepareSource(patch.source, false)
1138
1010
  : undefined;
1139
1011
  const appearance = patch.appearance;
1140
- const nextStyle = appearance ? appearance.districtStyle : districtStyle;
1141
- const districtVersion = districtRevision;
1012
+ const nextStyle = appearance ? appearance.districtStyle : districts.style;
1013
+ const districtVersion = districts.revision;
1142
1014
  const prepared = appearance || nextYear !== year
1143
1015
  ? prepareDistrictStyles(data.map.districts?.[nextYear] ?? [], nextStyle)
1144
- : districtStyles;
1016
+ : districts.styles;
1145
1017
  if (destroyed ||
1146
1018
  !isCurrent() ||
1147
1019
  revision !== presentationRevision ||
1148
- districtVersion !== districtRevision)
1020
+ districtVersion !== districts.revision)
1149
1021
  return false;
1150
1022
  if (nextYear !== year) {
1151
1023
  // Validate target paths and label coordinates before any presentation group commits.
@@ -1156,7 +1028,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
1156
1028
  }
1157
1029
  onCommit?.();
1158
1030
  if (appearance) {
1159
- districtRevision++;
1031
+ districts.invalidate();
1160
1032
  const next = copyAppearance(appearance);
1161
1033
  theme = next.theme ?? 'transit';
1162
1034
  colors = next.colors ?? {};
@@ -1172,12 +1044,11 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
1172
1044
  districtTransition.cancel();
1173
1045
  const fills = districtLayers.find((layer) => layer.dataset.layer === 'district-fills');
1174
1046
  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]));
1047
+ const stylesChanged = nextStyle !== districts.style ||
1048
+ [...prepared].some(([id, value]) => ['fill', 'stroke', 'opacity'].some((key) => value[key] !== districts.styles.get(id)?.[key]));
1177
1049
  if (fills && fade && enabled('districtFills') && nextYear === year && stylesChanged)
1178
1050
  districtTransition.fade(fills, fade);
1179
- districtStyle = nextStyle;
1180
- districtStyles = prepared;
1051
+ districts.commitStyles(nextStyle, prepared);
1181
1052
  const palette = resolveMapColors(theme, colors);
1182
1053
  svg.querySelector('rect')?.setAttribute('fill', palette.water);
1183
1054
  svg.querySelector('[data-layer="coast"]')?.setAttribute('fill', palette.land);
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.0 · 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.1 · 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.0 · local working tree. -->
11
+ <!-- Generated from README.md; @kahwee/sf-map-svg 5.1.1 · 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.0 · local working tree. -->
270
+ <!-- Generated from docs/developer-guide.md; @kahwee/sf-map-svg 5.1.1 · 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.0 · local working tree. -->
436
+ <!-- Generated from docs/API.md; @kahwee/sf-map-svg 5.1.1 · 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.0 · local working tree. -->
622
+ <!-- Generated from docs/EXAMPLES.md; @kahwee/sf-map-svg 5.1.1 · 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.0 · local working tree. -->
763
+ <!-- Generated from docs/consumer-integration.md; @kahwee/sf-map-svg 5.1.1 · 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.0 · local working tree. -->
941
+ <!-- Generated from docs/migration-v5.md; @kahwee/sf-map-svg 5.1.1 · 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.0 · local working tree. -->
1046
+ <!-- Generated from docs/migration-v3.md; @kahwee/sf-map-svg 5.1.1 · 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.0 · local working tree. -->
1087
+ <!-- Generated from SOURCES.md; @kahwee/sf-map-svg 5.1.1 · 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.0 · local working tree. -->
1324
+ <!-- Generated from CHANGELOG.md; @kahwee/sf-map-svg 5.1.1 · local working tree. -->
1325
1325
 
1326
1326
  # Changelog
1327
1327
 
@@ -1329,6 +1329,12 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
1329
1329
 
1330
1330
  ## Unreleased
1331
1331
 
1332
+ ## 5.1.1 — 2026-11-05
1333
+
1334
+ - Reserve precinct report controls and status space during loading to prevent phone layout shifts when multiple reports are available.
1335
+
1336
+ - Isolate district selection, style revisions and cancellable motion in an internal controller while preserving public map behavior. Document interactive state ownership and verify district lifecycle callbacks.
1337
+
1332
1338
  ## 5.1.0 — 2026-11-05
1333
1339
 
1334
1340
  - Add official November 2026 precinct aggregates (`data/elections/precincts-<report time>.json` with a time-ordered `precincts-index.json`) and the 2022 precinct boundaries (`data/precincts-2022.json`), reconciled with the department's summary report. Only aggregates are committed, never the ballot-level export. Export the boundaries as `precinctMaps` from `@kahwee/sf-map-svg/data` and `@kahwee/sf-map-svg/data/precincts`; results snapshots stay Pages data.
@@ -1711,7 +1717,7 @@ consistent home, and makes animation and subscription ownership predictable.
1711
1717
 
1712
1718
  # Published type contracts
1713
1719
 
1714
- @kahwee/sf-map-svg 5.1.0 · 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.
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.
1715
1721
 
1716
1722
  | Public import | Declaration |
1717
1723
  | --- | --- |
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.0 · 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.1 · 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.0",
3
+ "version": "5.1.1",
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",