@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 +6 -0
- package/dist/src/district-controller.d.ts +64 -0
- package/dist/src/district-controller.js +215 -0
- package/dist/src/explorer-core.js +52 -181
- package/llms-full.txt +17 -11
- package/llms.txt +1 -1
- package/package.json +1 -1
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 {
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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 ===
|
|
973
|
+
if (id === districts.hovered)
|
|
1102
974
|
return;
|
|
1103
|
-
|
|
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 (
|
|
983
|
+
if (districts.hovered === null)
|
|
1112
984
|
return;
|
|
1113
|
-
|
|
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 :
|
|
1141
|
-
const districtVersion =
|
|
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
|
-
:
|
|
1016
|
+
: districts.styles;
|
|
1145
1017
|
if (destroyed ||
|
|
1146
1018
|
!isCurrent() ||
|
|
1147
1019
|
revision !== presentationRevision ||
|
|
1148
|
-
districtVersion !==
|
|
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
|
-
|
|
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 !==
|
|
1176
|
-
[...prepared].some(([id, value]) => ['fill', 'stroke', 'opacity'].some((key) => value[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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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