@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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 5.1.2 — 2026-11-05
8
+
9
+ - 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.
10
+
11
+ ## 5.1.1 — 2026-11-05
12
+
13
+ - Reserve precinct report controls and status space during loading to prevent phone layout shifts when multiple reports are available.
14
+
15
+ - 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.
16
+
7
17
  ## 5.1.0 — 2026-11-05
8
18
 
9
19
  - 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
+ }