@kahwee/sf-map-svg 5.0.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 +19 -0
- package/README.md +17 -2
- package/SOURCES.md +53 -0
- package/dist/data/index.d.ts +1 -0
- package/dist/data/index.js +1 -0
- package/dist/data/precincts-2022.json +1 -0
- package/dist/data/precincts.d.ts +2 -0
- package/dist/data/precincts.js +5 -0
- package/dist/data/types.d.ts +5 -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 +114 -13
- package/llms.txt +1 -1
- package/package.json +37 -24
package/dist/data/types.d.ts
CHANGED
|
@@ -93,6 +93,11 @@ export interface DistrictProperties {
|
|
|
93
93
|
readonly labelPoints: readonly Position[];
|
|
94
94
|
readonly displayExtras: Geometry | null;
|
|
95
95
|
}
|
|
96
|
+
export interface PrecinctProperties {
|
|
97
|
+
/** Four-digit Department of Elections precinct number, such as "1101". */
|
|
98
|
+
readonly precinct: string;
|
|
99
|
+
readonly year: 2022;
|
|
100
|
+
}
|
|
96
101
|
export interface Catalog {
|
|
97
102
|
readonly schemaVersion: 1;
|
|
98
103
|
readonly description: string;
|
|
@@ -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);
|