@kahwee/sf-map-svg 4.0.1 → 4.2.0
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 +36 -0
- package/README.md +9 -13
- package/dist/data/guide/coast.json +1 -1
- package/dist/data/guide/landmarks.json +1 -1
- package/dist/data/guide/neighborhoods-realtor.json +1 -1
- package/dist/data/stations.d.ts +2 -2
- package/dist/data/types.d.ts +8 -1
- package/dist/src/api.d.ts +1 -1
- package/dist/src/api.js +1 -1
- package/dist/src/appearance.d.ts +5 -0
- package/dist/src/appearance.js +54 -0
- package/dist/src/configuration.js +50 -25
- package/dist/src/controller-types.d.ts +53 -6
- package/dist/src/district-morph.d.ts +17 -0
- package/dist/src/district-morph.js +148 -0
- package/dist/src/explorer-core.d.ts +14 -2
- package/dist/src/explorer-core.js +233 -52
- package/dist/src/features.d.ts +6 -0
- package/dist/src/features.js +11 -1
- package/dist/src/guide-map.js +1 -1
- package/dist/src/guide-static.js +3 -3
- package/dist/src/layer-transition.d.ts +18 -0
- package/dist/src/layer-transition.js +102 -0
- package/dist/src/layers.d.ts +9 -7
- package/dist/src/layers.js +28 -18
- package/dist/src/map-core.d.ts +14 -0
- package/dist/src/map-core.js +11 -5
- package/dist/src/map.js +125 -20
- package/dist/src/overlays.d.ts +3 -4
- package/dist/src/overlays.js +5 -1
- package/dist/src/static-animation.d.ts +13 -0
- package/dist/src/static-animation.js +36 -0
- package/dist/src/static.d.ts +1 -1
- package/dist/src/static.js +31 -2
- package/dist/src/transit.js +2 -1
- package/dist/src/types.d.ts +33 -2
- package/dist/src/validation.js +2 -0
- package/docs/API.md +85 -0
- package/docs/api-audit.md +3 -1
- package/docs/consumer-integration.md +1 -1
- package/docs/developer-guide.md +139 -0
- package/docs/guide-bundle-report.md +4 -4
- package/llms-full.txt +1979 -0
- package/llms.txt +25 -0
- package/package.json +12 -5
package/dist/data/stations.d.ts
CHANGED
package/dist/data/types.d.ts
CHANGED
|
@@ -60,6 +60,13 @@ export interface FeatureCollection<P = Readonly<Record<string, unknown>>> {
|
|
|
60
60
|
};
|
|
61
61
|
readonly features: readonly Feature<P>[];
|
|
62
62
|
}
|
|
63
|
+
export type PointFeatureCollection<P = Readonly<Record<string, unknown>>> = Omit<FeatureCollection<P>, 'features'> & {
|
|
64
|
+
readonly features: readonly (Feature<P> & {
|
|
65
|
+
readonly geometry: Extract<Geometry, {
|
|
66
|
+
readonly type: 'Point';
|
|
67
|
+
}>;
|
|
68
|
+
})[];
|
|
69
|
+
};
|
|
63
70
|
export type NeighborhoodSource = 'sf-find' | 'analysis' | 'realtor';
|
|
64
71
|
export interface NeighborhoodProperties {
|
|
65
72
|
readonly name: string;
|
|
@@ -69,7 +76,7 @@ export interface NeighborhoodProperties {
|
|
|
69
76
|
readonly definitionSource: NeighborhoodSource;
|
|
70
77
|
readonly nameSources: readonly string[];
|
|
71
78
|
readonly note?: string;
|
|
72
|
-
readonly sourceCode?: string;
|
|
79
|
+
readonly sourceCode?: string | null;
|
|
73
80
|
readonly realtorDistrict?: string;
|
|
74
81
|
}
|
|
75
82
|
export interface NeighborhoodEntry extends NeighborhoodProperties {
|
package/dist/src/api.d.ts
CHANGED
package/dist/src/api.js
CHANGED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { MapAppearance } from './controller-types.js';
|
|
2
|
+
export declare const appearanceKeys: readonly ['theme', 'colors', 'labelStyle', 'areaStyle', 'districtStyle', 'labelSize', 'style', 'markerRadius', 'markerHitSize', 'markerColor', 'selectedMarkerColor'];
|
|
3
|
+
/** Copy configuration records without cloning or invoking styling callbacks. */
|
|
4
|
+
export declare function copyAppearance(value?: MapAppearance): MapAppearance;
|
|
5
|
+
export declare function prepareAppearance(current: MapAppearance, patch: MapAppearance | undefined): MapAppearance;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { assertOptions, validateExplorerOptions } from './validation.js';
|
|
2
|
+
export const appearanceKeys = [
|
|
3
|
+
'theme',
|
|
4
|
+
'colors',
|
|
5
|
+
'labelStyle',
|
|
6
|
+
'areaStyle',
|
|
7
|
+
'districtStyle',
|
|
8
|
+
'labelSize',
|
|
9
|
+
'style',
|
|
10
|
+
'markerRadius',
|
|
11
|
+
'markerHitSize',
|
|
12
|
+
'markerColor',
|
|
13
|
+
'selectedMarkerColor',
|
|
14
|
+
];
|
|
15
|
+
/** Copy configuration records without cloning or invoking styling callbacks. */
|
|
16
|
+
export function copyAppearance(value = {}) {
|
|
17
|
+
const { districtStyle, ...rest } = value;
|
|
18
|
+
return { ...structuredClone(rest), ...(districtStyle ? { districtStyle } : {}) };
|
|
19
|
+
}
|
|
20
|
+
export function prepareAppearance(current, patch) {
|
|
21
|
+
if (patch === undefined)
|
|
22
|
+
return {};
|
|
23
|
+
assertOptions(patch, 'appearance', appearanceKeys);
|
|
24
|
+
const next = { ...current, ...patch };
|
|
25
|
+
for (const key of ['colors', 'labelStyle', 'areaStyle', 'labelSize', 'style']) {
|
|
26
|
+
if (Object.hasOwn(patch, key) && patch[key] !== undefined) {
|
|
27
|
+
assertOptions(patch[key], key);
|
|
28
|
+
const tokens = { ...current[key], ...patch[key] };
|
|
29
|
+
for (const name of Object.keys(tokens))
|
|
30
|
+
if (tokens[name] === undefined)
|
|
31
|
+
delete tokens[name];
|
|
32
|
+
Object.assign(next, { [key]: tokens });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
for (const key of appearanceKeys)
|
|
36
|
+
if (next[key] === undefined)
|
|
37
|
+
delete next[key];
|
|
38
|
+
validateExplorerOptions(next);
|
|
39
|
+
if (next.theme !== undefined && !['districts', 'transit'].includes(next.theme))
|
|
40
|
+
throw new TypeError('Theme must be districts or transit.');
|
|
41
|
+
const min = next.labelSize?.min ?? 11, max = next.labelSize?.max ?? 12;
|
|
42
|
+
const radius = next.markerRadius ?? 6, hit = next.markerHitSize ?? 44;
|
|
43
|
+
if (![min, max, radius, hit].every(Number.isFinite) ||
|
|
44
|
+
min < 8 ||
|
|
45
|
+
max < min ||
|
|
46
|
+
max > 32 ||
|
|
47
|
+
radius <= 0 ||
|
|
48
|
+
hit < radius * 2)
|
|
49
|
+
throw new RangeError('Use label sizes from 8–32px and a positive marker radius inside its hit target.');
|
|
50
|
+
const weight = next.labelStyle?.fontWeight;
|
|
51
|
+
if (weight !== undefined && (!Number.isFinite(weight) || weight < 1 || weight > 1000))
|
|
52
|
+
throw new RangeError('Label font weight must be finite and between 1 and 1000.');
|
|
53
|
+
return copyAppearance(next);
|
|
54
|
+
}
|
|
@@ -1,18 +1,6 @@
|
|
|
1
|
+
import { appearanceKeys, copyAppearance, prepareAppearance } from './appearance.js';
|
|
1
2
|
import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from './features.js';
|
|
2
3
|
import { assertOptions, validateExplorerOptions } from './validation.js';
|
|
3
|
-
const appearanceKeys = [
|
|
4
|
-
'theme',
|
|
5
|
-
'colors',
|
|
6
|
-
'labelStyle',
|
|
7
|
-
'areaStyle',
|
|
8
|
-
'districtStyle',
|
|
9
|
-
'labelSize',
|
|
10
|
-
'style',
|
|
11
|
-
'markerRadius',
|
|
12
|
-
'markerHitSize',
|
|
13
|
-
'markerColor',
|
|
14
|
-
'selectedMarkerColor',
|
|
15
|
-
];
|
|
16
4
|
const featureKeys = [
|
|
17
5
|
'motion',
|
|
18
6
|
'markerEntrance',
|
|
@@ -20,6 +8,8 @@ const featureKeys = [
|
|
|
20
8
|
'clustering',
|
|
21
9
|
'northArrow',
|
|
22
10
|
'scaleBar',
|
|
11
|
+
'layerTransitions',
|
|
12
|
+
'districtMorph',
|
|
23
13
|
];
|
|
24
14
|
export function expandMapOptions(options) {
|
|
25
15
|
const record = assertOptions(options, 'map');
|
|
@@ -30,20 +20,50 @@ export function expandMapOptions(options) {
|
|
|
30
20
|
'onMarkerActivate',
|
|
31
21
|
'onOverlayActivate',
|
|
32
22
|
])
|
|
33
|
-
if (key in record)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
23
|
+
if (key in record) {
|
|
24
|
+
const replacement = appearanceKeys.includes(key)
|
|
25
|
+
? `appearance.${key}`
|
|
26
|
+
: featureKeys.includes(key)
|
|
27
|
+
? `features.${key}`
|
|
28
|
+
: key === 'onMarkerActivate'
|
|
29
|
+
? "map.on('markerchange', listener)"
|
|
30
|
+
: key === 'onOverlayActivate'
|
|
31
|
+
? "map.on('overlayactivate', listener)"
|
|
32
|
+
: null;
|
|
33
|
+
throw new TypeError(key === 'onMarkerActivate'
|
|
34
|
+
? `Marker activation callbacks are not supported; use ${replacement} for selection changes.`
|
|
35
|
+
: replacement
|
|
36
|
+
? `Use ${replacement} instead of the flat ${key} option.`
|
|
37
|
+
: 'interface is internal and is not a supported createMap option.');
|
|
38
|
+
}
|
|
39
|
+
const preparedAppearance = options.appearance === undefined ? undefined : prepareAppearance({}, options.appearance);
|
|
37
40
|
if (options.features !== undefined)
|
|
38
41
|
normalizeFeatures(options.features);
|
|
39
|
-
const { features, appearance, ...rest } = options;
|
|
40
|
-
const expanded = { ...rest, ...
|
|
42
|
+
const { features, appearance: _appearance, ...rest } = options;
|
|
43
|
+
const expanded = { ...rest, ...preparedAppearance, ...features };
|
|
41
44
|
validateExplorerOptions(expanded);
|
|
42
45
|
return expanded;
|
|
43
46
|
}
|
|
44
47
|
/** Prepare the entire configuration before any DOM mutation. */
|
|
45
48
|
export function prepareConfiguration(current, patch) {
|
|
46
|
-
assertOptions(patch, 'configuration', [
|
|
49
|
+
assertOptions(patch, 'configuration', [
|
|
50
|
+
'features',
|
|
51
|
+
'layers',
|
|
52
|
+
'controls',
|
|
53
|
+
'appearance',
|
|
54
|
+
'mode',
|
|
55
|
+
'source',
|
|
56
|
+
'year',
|
|
57
|
+
'labels',
|
|
58
|
+
]);
|
|
59
|
+
if (patch.mode !== undefined && !['basemap', 'neighborhoods', 'districts'].includes(patch.mode))
|
|
60
|
+
throw new RangeError('Unknown map mode.');
|
|
61
|
+
if (patch.source !== undefined && !['realtor', 'sf-find', 'analysis'].includes(patch.source))
|
|
62
|
+
throw new RangeError('Unknown neighborhood source.');
|
|
63
|
+
if (patch.year !== undefined && ![2002, 2012, 2022].includes(patch.year))
|
|
64
|
+
throw new RangeError('District year must be 2002, 2012, or 2022.');
|
|
65
|
+
if (patch.labels !== undefined && typeof patch.labels !== 'boolean')
|
|
66
|
+
throw new TypeError('Labels must be a boolean.');
|
|
47
67
|
const features = 'features' in patch
|
|
48
68
|
? normalizeFeatures(patch.features === undefined
|
|
49
69
|
? {}
|
|
@@ -62,11 +82,16 @@ export function prepareConfiguration(current, patch) {
|
|
|
62
82
|
delete next[property];
|
|
63
83
|
return next;
|
|
64
84
|
};
|
|
65
|
-
return
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
85
|
+
return {
|
|
86
|
+
...structuredClone({
|
|
87
|
+
features,
|
|
88
|
+
layers: switches('layers', current.layers, layerKeys),
|
|
89
|
+
controls: switches('controls', current.controls, controlKeys),
|
|
90
|
+
}),
|
|
91
|
+
appearance: 'appearance' in patch
|
|
92
|
+
? prepareAppearance(current.appearance ?? {}, patch.appearance)
|
|
93
|
+
: copyAppearance(current.appearance),
|
|
94
|
+
};
|
|
70
95
|
}
|
|
71
96
|
function checkedFeatures(value) {
|
|
72
97
|
normalizeFeatures(value);
|
|
@@ -1,24 +1,66 @@
|
|
|
1
1
|
import type { Geometry, NeighborhoodSource } from '../data/types.js';
|
|
2
2
|
import type { CameraOptions, DistrictSelection, DistrictStyle, DistrictYear, InteractiveLayers, MapFeatures, MapMarker, MapOverlay, MapPadding, MapViewport, NeighborhoodExplorerOptions, NeighborhoodSelection } from './types.js';
|
|
3
|
-
|
|
3
|
+
/** Patch properties can explicitly remove overrides, including with exactOptionalPropertyTypes. */
|
|
4
|
+
type Resettable<T> = {
|
|
5
|
+
[K in keyof T]?: T[K] | undefined;
|
|
6
|
+
};
|
|
7
|
+
type AppearanceOptions = Pick<NeighborhoodExplorerOptions, 'theme' | 'colors' | 'labelStyle' | 'areaStyle' | 'districtStyle' | 'labelSize' | 'style' | 'markerRadius' | 'markerHitSize' | 'markerColor' | 'selectedMarkerColor'>;
|
|
8
|
+
export type MapAppearance = {
|
|
9
|
+
[K in keyof AppearanceOptions]?: K extends 'colors' | 'style' | 'labelStyle' | 'labelSize' | 'areaStyle' ? Resettable<NonNullable<AppearanceOptions[K]>> | undefined : AppearanceOptions[K] | undefined;
|
|
10
|
+
};
|
|
4
11
|
export type MapControls = NonNullable<NeighborhoodExplorerOptions['controls']>;
|
|
5
|
-
/** Omitted
|
|
12
|
+
/** Omitted keys retain values. Undefined presentation groups reset; undefined mode/source/year/labels retain their current value. */
|
|
6
13
|
export interface MapConfiguration {
|
|
7
|
-
features?: MapFeatures;
|
|
8
|
-
layers?: InteractiveLayers;
|
|
9
|
-
controls?: MapControls;
|
|
14
|
+
features?: Resettable<MapFeatures> | undefined;
|
|
15
|
+
layers?: Resettable<InteractiveLayers> | undefined;
|
|
16
|
+
controls?: Resettable<MapControls> | undefined;
|
|
17
|
+
appearance?: MapAppearance | undefined;
|
|
18
|
+
mode?: NonNullable<NeighborhoodExplorerOptions['mode']> | undefined;
|
|
19
|
+
source?: NeighborhoodSource | undefined;
|
|
20
|
+
year?: DistrictYear | undefined;
|
|
21
|
+
labels?: boolean | undefined;
|
|
10
22
|
}
|
|
11
23
|
export interface MapOptions extends Omit<NeighborhoodExplorerOptions, keyof MapFeatures | keyof MapAppearance | 'interface' | 'onMarkerActivate' | 'onOverlayActivate'> {
|
|
12
24
|
features?: MapFeatures;
|
|
13
|
-
appearance?: MapAppearance;
|
|
25
|
+
appearance?: MapAppearance | undefined;
|
|
14
26
|
}
|
|
15
27
|
export interface MapConfigurationSnapshot {
|
|
16
28
|
features: MapFeatures;
|
|
17
29
|
/** Explicit overrides; absent values continue to follow the current mode. */
|
|
18
30
|
layers: InteractiveLayers;
|
|
19
31
|
controls: MapControls;
|
|
32
|
+
appearance: MapAppearance;
|
|
33
|
+
}
|
|
34
|
+
export interface MapCapabilities {
|
|
35
|
+
sources: readonly NeighborhoodSource[];
|
|
36
|
+
years: readonly DistrictYear[];
|
|
37
|
+
/** Data availability, independent of requested layer visibility. */
|
|
38
|
+
layers: Readonly<Record<keyof InteractiveLayers, boolean>>;
|
|
39
|
+
}
|
|
40
|
+
export interface ResolvedMapConfiguration extends MapConfigurationSnapshot {
|
|
41
|
+
mode: NonNullable<MapOptions['mode']>;
|
|
42
|
+
source: NeighborhoodSource;
|
|
43
|
+
year: DistrictYear;
|
|
44
|
+
labels: boolean;
|
|
45
|
+
/** Effective switches after mode defaults and data availability; label switches honor labels. */
|
|
46
|
+
layers: Record<keyof InteractiveLayers, boolean>;
|
|
20
47
|
}
|
|
48
|
+
export type MapSelectionChange = {
|
|
49
|
+
kind: 'marker';
|
|
50
|
+
current: MapMarker | null;
|
|
51
|
+
previous: MapMarker | null;
|
|
52
|
+
} | {
|
|
53
|
+
kind: 'neighborhood';
|
|
54
|
+
current: NeighborhoodSelection | null;
|
|
55
|
+
previous: NeighborhoodSelection | null;
|
|
56
|
+
} | {
|
|
57
|
+
kind: 'district';
|
|
58
|
+
current: DistrictSelection | null;
|
|
59
|
+
previous: DistrictSelection | null;
|
|
60
|
+
};
|
|
21
61
|
export interface MapEvents {
|
|
62
|
+
/** Common selection envelope; existing change events retain their payloads. */
|
|
63
|
+
selectionchange: MapSelectionChange;
|
|
22
64
|
districtchange: DistrictSelection | {
|
|
23
65
|
id: null;
|
|
24
66
|
year: DistrictYear;
|
|
@@ -56,11 +98,13 @@ export interface MapEvents {
|
|
|
56
98
|
mapresize: undefined;
|
|
57
99
|
}
|
|
58
100
|
export interface MapCamera {
|
|
101
|
+
/** Fixed 800 × 800 projected space, not longitude/latitude. */
|
|
59
102
|
get(): MapViewport;
|
|
60
103
|
set(view: MapViewport, options?: CameraOptions): void;
|
|
61
104
|
fit(geometry: Geometry, options?: CameraOptions & {
|
|
62
105
|
padding?: number | MapPadding;
|
|
63
106
|
}): void;
|
|
107
|
+
/** Delta in projected map units. */
|
|
64
108
|
pan(x: number, y: number, options?: CameraOptions): void;
|
|
65
109
|
zoom(factor: number, options?: CameraOptions): void;
|
|
66
110
|
reset(options?: CameraOptions): void;
|
|
@@ -74,6 +118,8 @@ export interface MapController {
|
|
|
74
118
|
readonly destroyed: boolean;
|
|
75
119
|
configure(patch: MapConfiguration): void;
|
|
76
120
|
getConfiguration(): MapConfigurationSnapshot;
|
|
121
|
+
getResolvedConfiguration(): ResolvedMapConfiguration;
|
|
122
|
+
getCapabilities(): MapCapabilities;
|
|
77
123
|
on<K extends keyof MapEvents>(type: K, listener: (detail: MapEvents[K]) => void): () => void;
|
|
78
124
|
/** Reconcile stable IDs; retained markers preserve nodes, focus, and entrance animations. */
|
|
79
125
|
setMarkers(markers: readonly MapMarker[]): void;
|
|
@@ -104,3 +150,4 @@ export interface MapController {
|
|
|
104
150
|
/** Idempotent; stops work and managed subscriptions. Does not remove host-owned DOM. */
|
|
105
151
|
destroy(): void;
|
|
106
152
|
}
|
|
153
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Morph district outlines between two map years. Matching rings are resampled and aligned;
|
|
3
|
+
* a ring without a partner grows from, or shrinks into, its partner's center. The real
|
|
4
|
+
* district layers stay the exact year snapshots; this only draws a decorative overlay.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createDistrictMorph(): {
|
|
7
|
+
stop: () => void;
|
|
8
|
+
/**
|
|
9
|
+
* `previous` maps district id to its old outline path; the new outlines are read from
|
|
10
|
+
* `layer`, which stays hidden until the morph settles into it.
|
|
11
|
+
*/
|
|
12
|
+
start(layer: SVGGElement, previous: ReadonlyMap<string, string>, { duration, stroke }: {
|
|
13
|
+
duration: number;
|
|
14
|
+
stroke: string;
|
|
15
|
+
}): void;
|
|
16
|
+
readonly active: boolean;
|
|
17
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const svgNamespace = 'http://www.w3.org/2000/svg';
|
|
2
|
+
function rings(d) {
|
|
3
|
+
return (d.match(/M[^M]*/g) ?? [])
|
|
4
|
+
.map((piece) => {
|
|
5
|
+
const node = document.createElementNS(svgNamespace, 'path');
|
|
6
|
+
node.setAttribute('d', piece);
|
|
7
|
+
const length = node.getTotalLength();
|
|
8
|
+
const middle = node.getPointAtLength(length / 2);
|
|
9
|
+
return { length, center: [middle.x, middle.y], node };
|
|
10
|
+
})
|
|
11
|
+
.filter((ring) => ring.length > 0)
|
|
12
|
+
.sort((a, b) => b.length - a.length);
|
|
13
|
+
}
|
|
14
|
+
function sample(ring, count) {
|
|
15
|
+
return Array.from({ length: count }, (_, index) => {
|
|
16
|
+
const point = ring.node.getPointAtLength((ring.length * index) / count);
|
|
17
|
+
return [point.x, point.y];
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/** Rotate and orient `to` so each point travels the shortest total distance. */
|
|
21
|
+
function align(from, to) {
|
|
22
|
+
const count = from.length;
|
|
23
|
+
let best = Number.POSITIVE_INFINITY;
|
|
24
|
+
let bestOffset = 0;
|
|
25
|
+
let bestDirection = 1;
|
|
26
|
+
for (const direction of [1, -1]) {
|
|
27
|
+
for (let offset = 0; offset < count; offset++) {
|
|
28
|
+
let score = 0;
|
|
29
|
+
for (let index = 0; index < count; index += 4) {
|
|
30
|
+
const a = from[index];
|
|
31
|
+
const b = to[(offset + direction * index + count * 2) % count];
|
|
32
|
+
score += (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2;
|
|
33
|
+
}
|
|
34
|
+
if (score < best) {
|
|
35
|
+
best = score;
|
|
36
|
+
bestOffset = offset;
|
|
37
|
+
bestDirection = direction;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return from.map((_, index) => to[(bestOffset + bestDirection * index + count * 2) % count]);
|
|
42
|
+
}
|
|
43
|
+
const ease = (t) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);
|
|
44
|
+
/**
|
|
45
|
+
* Morph district outlines between two map years. Matching rings are resampled and aligned;
|
|
46
|
+
* a ring without a partner grows from, or shrinks into, its partner's center. The real
|
|
47
|
+
* district layers stay the exact year snapshots; this only draws a decorative overlay.
|
|
48
|
+
*/
|
|
49
|
+
export function createDistrictMorph() {
|
|
50
|
+
let frame = 0;
|
|
51
|
+
let overlay;
|
|
52
|
+
let lines;
|
|
53
|
+
function stop() {
|
|
54
|
+
cancelAnimationFrame(frame);
|
|
55
|
+
frame = 0;
|
|
56
|
+
overlay?.remove();
|
|
57
|
+
overlay = undefined;
|
|
58
|
+
lines?.style.removeProperty('opacity');
|
|
59
|
+
lines = undefined;
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
stop,
|
|
63
|
+
/**
|
|
64
|
+
* `previous` maps district id to its old outline path; the new outlines are read from
|
|
65
|
+
* `layer`, which stays hidden until the morph settles into it.
|
|
66
|
+
*/
|
|
67
|
+
start(layer, previous, { duration, stroke }) {
|
|
68
|
+
stop();
|
|
69
|
+
const next = new Map([...layer.querySelectorAll('path[data-district]')].map((path) => [
|
|
70
|
+
path.dataset.district ?? '',
|
|
71
|
+
path.getAttribute('d') ?? '',
|
|
72
|
+
]));
|
|
73
|
+
const group = document.createElementNS(svgNamespace, 'g');
|
|
74
|
+
group.dataset.layer = 'district-morph';
|
|
75
|
+
group.setAttribute('aria-hidden', 'true');
|
|
76
|
+
group.style.pointerEvents = 'none';
|
|
77
|
+
const clip = layer.getAttribute('clip-path');
|
|
78
|
+
if (clip)
|
|
79
|
+
group.setAttribute('clip-path', clip);
|
|
80
|
+
const tracks = [];
|
|
81
|
+
for (const id of new Set([...previous.keys(), ...next.keys()])) {
|
|
82
|
+
const before = rings(previous.get(id) ?? '');
|
|
83
|
+
const after = rings(next.get(id) ?? '');
|
|
84
|
+
for (let index = 0; index < Math.max(before.length, after.length); index++) {
|
|
85
|
+
const start = before[index];
|
|
86
|
+
const end = after[index];
|
|
87
|
+
if (!start && !end)
|
|
88
|
+
continue;
|
|
89
|
+
const count = Math.min(360, Math.max(48, Math.ceil(Math.max(start?.length ?? 0, end?.length ?? 0) / 3)));
|
|
90
|
+
const from = start
|
|
91
|
+
? sample(start, count)
|
|
92
|
+
: Array.from({ length: count }, () => end.center);
|
|
93
|
+
const target = end
|
|
94
|
+
? sample(end, count)
|
|
95
|
+
: Array.from({ length: count }, () => start.center);
|
|
96
|
+
const node = document.createElementNS(svgNamespace, 'path');
|
|
97
|
+
for (const [name, value] of Object.entries({
|
|
98
|
+
fill: 'none',
|
|
99
|
+
stroke,
|
|
100
|
+
'stroke-width': '1.6',
|
|
101
|
+
'stroke-linejoin': 'round',
|
|
102
|
+
'vector-effect': 'non-scaling-stroke',
|
|
103
|
+
}))
|
|
104
|
+
node.setAttribute(name, value);
|
|
105
|
+
group.append(node);
|
|
106
|
+
tracks.push({ node, from, to: start && end ? align(from, target) : target });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!tracks.length)
|
|
110
|
+
return;
|
|
111
|
+
overlay = group;
|
|
112
|
+
lines = layer;
|
|
113
|
+
layer.after(group);
|
|
114
|
+
layer.style.opacity = '0';
|
|
115
|
+
let begin;
|
|
116
|
+
const draw = (progress) => {
|
|
117
|
+
const t = ease(progress);
|
|
118
|
+
for (const track of tracks) {
|
|
119
|
+
let d = '';
|
|
120
|
+
for (let index = 0; index < track.from.length; index++) {
|
|
121
|
+
const [ax, ay] = track.from[index];
|
|
122
|
+
const [bx, by] = track.to[index];
|
|
123
|
+
d += `${index ? 'L' : 'M'}${(ax + (bx - ax) * t).toFixed(2)},${(ay + (by - ay) * t).toFixed(2)}`;
|
|
124
|
+
}
|
|
125
|
+
track.node.setAttribute('d', `${d}Z`);
|
|
126
|
+
}
|
|
127
|
+
// The morph hands over to the exact outlines in its final quarter.
|
|
128
|
+
const settle = Math.max(0, (progress - 0.75) / 0.25);
|
|
129
|
+
group.style.opacity = String(1 - settle);
|
|
130
|
+
layer.style.opacity = String(settle);
|
|
131
|
+
};
|
|
132
|
+
draw(0);
|
|
133
|
+
const tick = (time) => {
|
|
134
|
+
begin ??= time;
|
|
135
|
+
const progress = Math.min(1, (time - begin) / duration);
|
|
136
|
+
draw(progress);
|
|
137
|
+
if (progress < 1)
|
|
138
|
+
frame = requestAnimationFrame(tick);
|
|
139
|
+
else
|
|
140
|
+
stop();
|
|
141
|
+
};
|
|
142
|
+
frame = requestAnimationFrame(tick);
|
|
143
|
+
},
|
|
144
|
+
get active() {
|
|
145
|
+
return overlay !== undefined;
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -1,5 +1,17 @@
|
|
|
1
|
+
import type { NeighborhoodSource } from '../data/types.js';
|
|
2
|
+
import type { MapConfiguration } from './controller-types.js';
|
|
1
3
|
import type { InteractiveSFMapData } from './explorer-data.js';
|
|
2
|
-
import type { NeighborhoodExplorerElement, NeighborhoodExplorerOptions } from './types.js';
|
|
4
|
+
import type { DistrictYear, ExplorerMode, NeighborhoodExplorerElement, NeighborhoodExplorerOptions } from './types.js';
|
|
3
5
|
export type { ExplorerMode, NeighborhoodExplorerElement, NeighborhoodExplorerOptions, } from './types.js';
|
|
6
|
+
/** Private bridge between the renderer and its public lifecycle controller. */
|
|
7
|
+
type MapEngineElement = NeighborhoodExplorerElement & {
|
|
8
|
+
applyPresentation(patch: MapConfiguration, onCommit?: () => void, isCurrent?: () => boolean): boolean;
|
|
9
|
+
getMapState(): {
|
|
10
|
+
mode: ExplorerMode;
|
|
11
|
+
source: NeighborhoodSource;
|
|
12
|
+
year: DistrictYear;
|
|
13
|
+
labels: boolean;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
4
16
|
/** Create an offline, browser-only neighborhood explorer. Call destroy() before disposal. */
|
|
5
|
-
export declare function createNeighborhoodExplorerCore(options: NeighborhoodExplorerOptions | undefined, data: InteractiveSFMapData):
|
|
17
|
+
export declare function createNeighborhoodExplorerCore(options: NeighborhoodExplorerOptions | undefined, data: InteractiveSFMapData): MapEngineElement;
|