@kahwee/sf-map-svg 4.0.1 → 4.1.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 +23 -1
- package/README.md +7 -11
- 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/configuration.js +18 -2
- package/dist/src/district-morph.d.ts +17 -0
- package/dist/src/district-morph.js +148 -0
- package/dist/src/explorer-core.js +66 -16
- package/dist/src/features.d.ts +6 -0
- package/dist/src/features.js +11 -1
- 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.js +7 -4
- 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/transit.js +2 -1
- package/dist/src/types.d.ts +25 -0
- package/dist/src/validation.js +2 -0
- package/docs/API.md +33 -0
- package/docs/consumer-integration.md +1 -1
- package/docs/developer-guide.md +137 -0
- package/docs/guide-bundle-report.md +4 -4
- package/llms-full.txt +1844 -0
- package/llms.txt +25 -0
- package/package.json +10 -4
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
|
@@ -20,6 +20,8 @@ const featureKeys = [
|
|
|
20
20
|
'clustering',
|
|
21
21
|
'northArrow',
|
|
22
22
|
'scaleBar',
|
|
23
|
+
'layerTransitions',
|
|
24
|
+
'districtMorph',
|
|
23
25
|
];
|
|
24
26
|
export function expandMapOptions(options) {
|
|
25
27
|
const record = assertOptions(options, 'map');
|
|
@@ -30,8 +32,22 @@ export function expandMapOptions(options) {
|
|
|
30
32
|
'onMarkerActivate',
|
|
31
33
|
'onOverlayActivate',
|
|
32
34
|
])
|
|
33
|
-
if (key in record)
|
|
34
|
-
|
|
35
|
+
if (key in record) {
|
|
36
|
+
const replacement = appearanceKeys.includes(key)
|
|
37
|
+
? `appearance.${key}`
|
|
38
|
+
: featureKeys.includes(key)
|
|
39
|
+
? `features.${key}`
|
|
40
|
+
: key === 'onMarkerActivate'
|
|
41
|
+
? "map.on('markerchange', listener)"
|
|
42
|
+
: key === 'onOverlayActivate'
|
|
43
|
+
? "map.on('overlayactivate', listener)"
|
|
44
|
+
: null;
|
|
45
|
+
throw new TypeError(key === 'onMarkerActivate'
|
|
46
|
+
? `Marker activation callbacks are not supported; use ${replacement} for selection changes.`
|
|
47
|
+
: replacement
|
|
48
|
+
? `Use ${replacement} instead of the flat ${key} option.`
|
|
49
|
+
: 'interface is internal and is not a supported createMap option.');
|
|
50
|
+
}
|
|
35
51
|
if (options.appearance !== undefined)
|
|
36
52
|
assertOptions(options.appearance, 'appearance', appearanceKeys);
|
|
37
53
|
if (options.features !== undefined)
|
|
@@ -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,6 +1,7 @@
|
|
|
1
1
|
import { createCamera, validateCameraOptions } from './camera.js';
|
|
2
2
|
import { clusterPoints } from './clusters.js';
|
|
3
3
|
import { renderDistrictAppearance } from './district-layer.js';
|
|
4
|
+
import { createDistrictMorph } from './district-morph.js';
|
|
4
5
|
import { prepareDistrictStyles } from './district-style.js';
|
|
5
6
|
import { createDistrictTransition } from './district-transition.js';
|
|
6
7
|
import { element, svgElement } from './dom.js';
|
|
@@ -9,6 +10,7 @@ import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from '
|
|
|
9
10
|
import { createFrameScheduler } from './frame-scheduler.js';
|
|
10
11
|
import { geometryPath } from './geometry.js';
|
|
11
12
|
import { createLabelRenderer } from './label-renderer.js';
|
|
13
|
+
import { createLayerTransitions } from './layer-transition.js';
|
|
12
14
|
import { createSFMapWithData, districtColors, getLayerPathsWithData } from './map-core.js';
|
|
13
15
|
import { createMarkerLayer } from './marker-layer.js';
|
|
14
16
|
import { attachNavigation } from './navigation.js';
|
|
@@ -30,7 +32,7 @@ let explorerCount = 0;
|
|
|
30
32
|
/** Create an offline, browser-only neighborhood explorer. Call destroy() before disposal. */
|
|
31
33
|
export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
32
34
|
validateExplorerOptions(options);
|
|
33
|
-
let { source = 'realtor', mode = 'neighborhoods', labels = true, neighborhood, year = 2022, theme = 'transit', colors = {}, labelStyle = {}, areaStyle = {}, districtStyle: initialDistrictStyle, motion = false, markerEntrance = false, selectedMarkerRing, clustering = false, legend: legendOptions = {}, attribution: attributionMode = 'full', northArrow = false, scaleBar = false, interface: chrome = 'explorer', layers = {}, selectableNeighborhoods = true, labelSize = {}, fitPadding = 24, markers: initialMarkers = [], markerRadius = 6, markerHitSize = 44, markerColor = colors.marker ?? '#245b61', selectedMarkerColor = colors.selected ?? '#f04f32', onMarkerActivate, overlays: initialOverlays = [], onOverlayActivate, style: styleOptions = {}, strings = {}, controls = {}, } = options;
|
|
35
|
+
let { source = 'realtor', mode = 'neighborhoods', labels = true, neighborhood, year = 2022, theme = 'transit', colors = {}, labelStyle = {}, areaStyle = {}, districtStyle: initialDistrictStyle, motion = false, markerEntrance = false, selectedMarkerRing, clustering = false, legend: legendOptions = {}, attribution: attributionMode = 'full', northArrow = false, scaleBar = false, layerTransitions = false, districtMorph: districtMorphOption = false, interface: chrome = 'explorer', layers = {}, selectableNeighborhoods = true, labelSize = {}, fitPadding = 24, markers: initialMarkers = [], markerRadius = 6, markerHitSize = 44, markerColor = colors.marker ?? '#245b61', selectedMarkerColor = colors.selected ?? '#f04f32', onMarkerActivate, overlays: initialOverlays = [], onOverlayActivate, style: styleOptions = {}, strings = {}, controls = {}, } = options;
|
|
34
36
|
if (!['neighborhoods', 'districts', 'basemap'].includes(mode))
|
|
35
37
|
throw new RangeError(`Unknown map mode: ${mode}`);
|
|
36
38
|
if (chrome !== 'explorer' && chrome !== 'map')
|
|
@@ -50,6 +52,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
50
52
|
selectedMarkerRing,
|
|
51
53
|
northArrow,
|
|
52
54
|
scaleBar,
|
|
55
|
+
layerTransitions,
|
|
56
|
+
districtMorph: districtMorphOption,
|
|
53
57
|
});
|
|
54
58
|
if (labelStyle.fontWeight !== undefined &&
|
|
55
59
|
(!Number.isFinite(labelStyle.fontWeight) ||
|
|
@@ -435,6 +439,13 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
435
439
|
root.dispatchEvent(new CustomEvent('districtchange', { bubbles: true, detail: districtSelection(id) }));
|
|
436
440
|
return true;
|
|
437
441
|
}
|
|
442
|
+
function activateDistrict(id) {
|
|
443
|
+
const revision = districtRevision + 1;
|
|
444
|
+
if (!selectDistrict(id) || destroyed || revision !== districtRevision)
|
|
445
|
+
return false;
|
|
446
|
+
root.dispatchEvent(new CustomEvent('districtactivate', { bubbles: true, detail: districtSelection(id) }));
|
|
447
|
+
return !destroyed && revision === districtRevision;
|
|
448
|
+
}
|
|
438
449
|
function setDistrictStyle(style) {
|
|
439
450
|
if (destroyed)
|
|
440
451
|
return;
|
|
@@ -445,6 +456,12 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
445
456
|
districtRevision++;
|
|
446
457
|
districtStyle = style;
|
|
447
458
|
districtStyles = prepared;
|
|
459
|
+
const fills = districtLayers.find((layer) => layer.dataset.layer === 'district-fills');
|
|
460
|
+
const fade = layerFades.duration();
|
|
461
|
+
// A new style settles any fill crossfade still running from an earlier change.
|
|
462
|
+
districtTransition.cancel();
|
|
463
|
+
if (fills && fade && enabled('districtFills'))
|
|
464
|
+
districtTransition.fade(fills, fade);
|
|
448
465
|
updateDistrictAppearance();
|
|
449
466
|
}
|
|
450
467
|
function setDistrictYear(next, options = {}) {
|
|
@@ -465,8 +482,28 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
465
482
|
const geometry = getLayerPathsWithData({ year: next }, data.map, false);
|
|
466
483
|
const previousYear = year;
|
|
467
484
|
districtTransition.cancel();
|
|
485
|
+
districtMorph.stop();
|
|
486
|
+
const lineLayer = districtLayers.find((layer) => layer.dataset.layer === 'district-lines');
|
|
487
|
+
const morph = features.districtMorph &&
|
|
488
|
+
options.animate !== false &&
|
|
489
|
+
initialized &&
|
|
490
|
+
!reducedMotion.matches &&
|
|
491
|
+
(enabled('districtLines') || enabled('districtFills'))
|
|
492
|
+
? features.districtMorph
|
|
493
|
+
: false;
|
|
494
|
+
const previousOutlines = morph && lineLayer
|
|
495
|
+
? new Map([...lineLayer.querySelectorAll('path[data-district]')].map((path) => [
|
|
496
|
+
path.dataset.district ?? '',
|
|
497
|
+
path.getAttribute('d') ?? '',
|
|
498
|
+
]))
|
|
499
|
+
: undefined;
|
|
468
500
|
for (const layer of districtLayers) {
|
|
469
|
-
if (
|
|
501
|
+
if (morph) {
|
|
502
|
+
// The morph replaces the line crossfade; fills fade across the whole morph.
|
|
503
|
+
if (layer !== lineLayer)
|
|
504
|
+
districtTransition.fade(layer, morph.duration);
|
|
505
|
+
}
|
|
506
|
+
else if (options.animate && !reducedMotion.matches)
|
|
470
507
|
districtTransition.fade(layer, options.duration ?? 280);
|
|
471
508
|
layer.replaceChildren(...geometry.districts.map((district) => svgElement('path', {
|
|
472
509
|
'data-district': district.id,
|
|
@@ -492,6 +529,11 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
492
529
|
delete root.dataset.selectedDistrict;
|
|
493
530
|
updateDistrictAppearance();
|
|
494
531
|
updateComposition();
|
|
532
|
+
if (morph && lineLayer && previousOutlines)
|
|
533
|
+
districtMorph.start(lineLayer, previousOutlines, {
|
|
534
|
+
duration: morph.duration,
|
|
535
|
+
stroke: colors.district ?? '#71838a',
|
|
536
|
+
});
|
|
495
537
|
scheduleLabels();
|
|
496
538
|
status.textContent = `${year} supervisorial districts.`;
|
|
497
539
|
root.dispatchEvent(new CustomEvent('districtyearchange', { bubbles: true, detail: { year, previousYear } }));
|
|
@@ -505,6 +547,10 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
505
547
|
let items = [];
|
|
506
548
|
let destroyed = false;
|
|
507
549
|
let initialized = false;
|
|
550
|
+
const layerFades = createLayerTransitions(() => initialized && features.layerTransitions && !reducedMotion.matches
|
|
551
|
+
? features.layerTransitions.duration
|
|
552
|
+
: 0);
|
|
553
|
+
const districtMorph = createDistrictMorph();
|
|
508
554
|
const frames = createFrameScheduler({
|
|
509
555
|
request: (callback) => requestAnimationFrame(callback),
|
|
510
556
|
cancel: (id) => cancelAnimationFrame(id),
|
|
@@ -537,6 +583,12 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
537
583
|
stopAnimation();
|
|
538
584
|
if (Object.hasOwn(patch, 'markerEntrance'))
|
|
539
585
|
cancelEntrances();
|
|
586
|
+
if (Object.hasOwn(patch, 'layerTransitions')) {
|
|
587
|
+
layerFades.cancel();
|
|
588
|
+
districtTransition.cancel();
|
|
589
|
+
}
|
|
590
|
+
if (Object.hasOwn(patch, 'districtMorph'))
|
|
591
|
+
districtMorph.stop();
|
|
540
592
|
if (Object.hasOwn(patch, 'clustering'))
|
|
541
593
|
invalidateClusters();
|
|
542
594
|
syncFurniture();
|
|
@@ -1146,7 +1198,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1146
1198
|
const revision = ++neighborhoodRevision;
|
|
1147
1199
|
delete root.dataset.selectedNeighborhood;
|
|
1148
1200
|
root.dataset.source = source;
|
|
1149
|
-
areas.replaceChildren(...nextItems.map((item) => item.node));
|
|
1201
|
+
layerFades.crossfade(areas, () => areas.replaceChildren(...nextItems.map((item) => item.node)));
|
|
1150
1202
|
neighborhoodSelect.replaceChildren(element('option', 'No neighborhood selected'));
|
|
1151
1203
|
neighborhoodSelect.options[0].value = '';
|
|
1152
1204
|
neighborhoodSelect.append(...nextOptions);
|
|
@@ -1189,9 +1241,9 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1189
1241
|
]) {
|
|
1190
1242
|
const layer = svg.querySelector(`[data-layer="${name}"]`);
|
|
1191
1243
|
if (layer)
|
|
1192
|
-
layer
|
|
1244
|
+
layerFades.set(layer, enabled(key));
|
|
1193
1245
|
}
|
|
1194
|
-
stations
|
|
1246
|
+
layerFades.set(stations, enabled('bartStations'));
|
|
1195
1247
|
for (const [kind, key] of [
|
|
1196
1248
|
['bart', 'bartStations'],
|
|
1197
1249
|
['park', 'landmarks'],
|
|
@@ -1206,7 +1258,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1206
1258
|
controls.legend === false ||
|
|
1207
1259
|
![...legend.children].some((node) => !node.hidden);
|
|
1208
1260
|
const neighborhoodsVisible = enabled('neighborhoodLines') || (labels && enabled('neighborhoodLabels'));
|
|
1209
|
-
areas
|
|
1261
|
+
layerFades.set(areas, neighborhoodsVisible);
|
|
1210
1262
|
areas.style.pointerEvents = selectableNeighborhoods ? '' : 'none';
|
|
1211
1263
|
neighborhoodLabel.hidden =
|
|
1212
1264
|
!neighborhoodsVisible ||
|
|
@@ -1223,7 +1275,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1223
1275
|
}
|
|
1224
1276
|
for (const layer of districtLayers) {
|
|
1225
1277
|
const key = layer.dataset.layer === 'district-fills' ? 'districtFills' : 'districtLines';
|
|
1226
|
-
layer
|
|
1278
|
+
layerFades.set(layer, enabled(key));
|
|
1227
1279
|
}
|
|
1228
1280
|
updateDistrictAppearance();
|
|
1229
1281
|
const districtsVisible = enabled('districtFills') || enabled('districtLines') || (labels && enabled('districtLabels'));
|
|
@@ -1512,12 +1564,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1512
1564
|
districtRow(districtId)) {
|
|
1513
1565
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
1514
1566
|
event.preventDefault();
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
bubbles: true,
|
|
1518
|
-
detail: districtSelection(districtId),
|
|
1519
|
-
}));
|
|
1520
|
-
target.focus();
|
|
1567
|
+
if (activateDistrict(districtId))
|
|
1568
|
+
target.focus();
|
|
1521
1569
|
}
|
|
1522
1570
|
else if (event.key === '[' || event.key === ']') {
|
|
1523
1571
|
event.preventDefault();
|
|
@@ -1568,9 +1616,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1568
1616
|
? event.target.closest('[data-layer="district-fills"] [data-district], [data-layer="district-lines"] [data-district]')
|
|
1569
1617
|
: null;
|
|
1570
1618
|
if (district?.dataset.district) {
|
|
1571
|
-
|
|
1572
|
-
selectDistrict(id);
|
|
1573
|
-
root.dispatchEvent(new CustomEvent('districtactivate', { bubbles: true, detail: districtSelection(id) }));
|
|
1619
|
+
activateDistrict(Number(district.dataset.district));
|
|
1574
1620
|
return;
|
|
1575
1621
|
}
|
|
1576
1622
|
const node = event.target instanceof Element
|
|
@@ -1639,6 +1685,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1639
1685
|
destroyed = true;
|
|
1640
1686
|
camera.destroy();
|
|
1641
1687
|
districtTransition.cancel();
|
|
1688
|
+
layerFades.cancel();
|
|
1689
|
+
districtMorph.stop();
|
|
1642
1690
|
cancelEntrances();
|
|
1643
1691
|
controller.abort();
|
|
1644
1692
|
overlayEvents.abort();
|
|
@@ -1661,6 +1709,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1661
1709
|
if (reducedMotion.matches) {
|
|
1662
1710
|
stopAnimation();
|
|
1663
1711
|
districtTransition.cancel();
|
|
1712
|
+
layerFades.cancel();
|
|
1713
|
+
districtMorph.stop();
|
|
1664
1714
|
cancelEntrances();
|
|
1665
1715
|
}
|
|
1666
1716
|
}, { signal: controller.signal });
|
package/dist/src/features.d.ts
CHANGED
|
@@ -18,6 +18,12 @@ export declare function normalizeFeatures(input?: MapFeatures): {
|
|
|
18
18
|
};
|
|
19
19
|
northArrow: boolean;
|
|
20
20
|
scaleBar: boolean;
|
|
21
|
+
layerTransitions: false | {
|
|
22
|
+
duration: number;
|
|
23
|
+
};
|
|
24
|
+
districtMorph: false | {
|
|
25
|
+
duration: number;
|
|
26
|
+
};
|
|
21
27
|
};
|
|
22
28
|
export declare const layerKeys: readonly ['districtFills', 'districtLines', 'districtLabels', 'neighborhoodLines', 'neighborhoodLabels', 'landmarks', 'bartStations', 'highways', 'keyRoads', 'roadLabels'];
|
|
23
29
|
export declare const controlKeys: readonly ['zoom', 'pan', 'reset', 'labels', 'touch', 'legend', 'neighborhoodPicker', 'markerPicker', 'help', 'status'];
|
package/dist/src/features.js
CHANGED
|
@@ -4,7 +4,7 @@ function objectOption(value, name) {
|
|
|
4
4
|
return false;
|
|
5
5
|
if (value === true)
|
|
6
6
|
return {};
|
|
7
|
-
assertOptions(value, name, name === 'motion'
|
|
7
|
+
assertOptions(value, name, name === 'motion' || name === 'layerTransitions' || name === 'districtMorph'
|
|
8
8
|
? ['duration']
|
|
9
9
|
: name === 'markerEntrance'
|
|
10
10
|
? ['duration', 'stagger']
|
|
@@ -36,6 +36,8 @@ export function normalizeFeatures(input = {}) {
|
|
|
36
36
|
'selectedMarkerRing',
|
|
37
37
|
'northArrow',
|
|
38
38
|
'scaleBar',
|
|
39
|
+
'layerTransitions',
|
|
40
|
+
'districtMorph',
|
|
39
41
|
];
|
|
40
42
|
for (const key of Object.keys(input))
|
|
41
43
|
if (!keys.includes(key))
|
|
@@ -44,6 +46,8 @@ export function normalizeFeatures(input = {}) {
|
|
|
44
46
|
const entrance = objectOption(input.markerEntrance, 'markerEntrance');
|
|
45
47
|
const clustering = objectOption(input.clustering, 'clustering');
|
|
46
48
|
const ring = objectOption(input.selectedMarkerRing, 'selectedMarkerRing');
|
|
49
|
+
const layerTransitions = objectOption(input.layerTransitions, 'layerTransitions');
|
|
50
|
+
const districtMorph = objectOption(input.districtMorph, 'districtMorph');
|
|
47
51
|
if (ring && ring.color !== undefined && typeof ring.color !== 'string')
|
|
48
52
|
throw new TypeError('Ring color must be a string.');
|
|
49
53
|
return {
|
|
@@ -62,6 +66,12 @@ export function normalizeFeatures(input = {}) {
|
|
|
62
66
|
},
|
|
63
67
|
northArrow: flag(input.northArrow, 'northArrow'),
|
|
64
68
|
scaleBar: flag(input.scaleBar, 'scaleBar'),
|
|
69
|
+
layerTransitions: layerTransitions && {
|
|
70
|
+
duration: numberOption(layerTransitions.duration, 360, 'Layer transition duration'),
|
|
71
|
+
},
|
|
72
|
+
districtMorph: districtMorph && {
|
|
73
|
+
duration: numberOption(districtMorph.duration, 1100, 'District morph duration', true),
|
|
74
|
+
},
|
|
65
75
|
};
|
|
66
76
|
}
|
|
67
77
|
export const layerKeys = [
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opacity transitions for interactive layer switches and source swaps. `duration()` is read
|
|
3
|
+
* on every change, so reduced motion, construction, and runtime feature patches take effect
|
|
4
|
+
* immediately. All animations are owned here and settle synchronously on cancel.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createLayerTransitions(duration: () => number): {
|
|
7
|
+
/** The active transition duration in milliseconds, or 0 when transitions are off. */
|
|
8
|
+
duration: () => number;
|
|
9
|
+
/** Show or hide a layer, fading when a transition duration is active. */
|
|
10
|
+
set(node: SVGElement | HTMLElement, visible: boolean): void;
|
|
11
|
+
/**
|
|
12
|
+
* Replace a visible layer's contents while an inert copy of the old contents fades out
|
|
13
|
+
* above the new ones as they fade in.
|
|
14
|
+
*/
|
|
15
|
+
crossfade(node: SVGGElement, replace: () => void): void;
|
|
16
|
+
/** Jump every layer to its final state and remove all decorative copies. */
|
|
17
|
+
cancel(): void;
|
|
18
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** Remove interaction and accessibility hooks from a decorative copy of a layer. */
|
|
2
|
+
function makeInert(node) {
|
|
3
|
+
node.setAttribute('aria-hidden', 'true');
|
|
4
|
+
node.style.pointerEvents = 'none';
|
|
5
|
+
for (const item of [node, ...node.querySelectorAll('*')]) {
|
|
6
|
+
for (const name of ['id', 'tabindex', 'role', 'aria-label', 'aria-pressed'])
|
|
7
|
+
item.removeAttribute(name);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Opacity transitions for interactive layer switches and source swaps. `duration()` is read
|
|
12
|
+
* on every change, so reduced motion, construction, and runtime feature patches take effect
|
|
13
|
+
* immediately. All animations are owned here and settle synchronously on cancel.
|
|
14
|
+
*/
|
|
15
|
+
export function createLayerTransitions(duration) {
|
|
16
|
+
const fades = new Map();
|
|
17
|
+
const ghosts = new Set();
|
|
18
|
+
function settle(node) {
|
|
19
|
+
const running = fades.get(node);
|
|
20
|
+
if (!running)
|
|
21
|
+
return;
|
|
22
|
+
fades.delete(node);
|
|
23
|
+
running.animation.cancel();
|
|
24
|
+
if (running.hiding)
|
|
25
|
+
node.style.display = 'none';
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
/** The active transition duration in milliseconds, or 0 when transitions are off. */
|
|
29
|
+
duration,
|
|
30
|
+
/** Show or hide a layer, fading when a transition duration is active. */
|
|
31
|
+
set(node, visible) {
|
|
32
|
+
const running = fades.get(node);
|
|
33
|
+
const shown = node.style.display !== 'none' && !running?.hiding;
|
|
34
|
+
if (running) {
|
|
35
|
+
fades.delete(node);
|
|
36
|
+
running.animation.cancel();
|
|
37
|
+
}
|
|
38
|
+
const ms = duration();
|
|
39
|
+
if (!ms || visible === shown || typeof node.animate !== 'function') {
|
|
40
|
+
node.style.display = visible ? '' : 'none';
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
node.style.display = '';
|
|
44
|
+
const animation = node.animate(visible ? [{ opacity: 0 }, { opacity: 1 }] : [{ opacity: 1 }, { opacity: 0 }], { duration: ms, easing: visible ? 'ease-out' : 'ease-in', fill: 'forwards' });
|
|
45
|
+
const entry = { animation, hiding: !visible };
|
|
46
|
+
fades.set(node, entry);
|
|
47
|
+
animation.finished.then(() => {
|
|
48
|
+
if (fades.get(node) === entry)
|
|
49
|
+
settle(node);
|
|
50
|
+
}, () => { });
|
|
51
|
+
},
|
|
52
|
+
/**
|
|
53
|
+
* Replace a visible layer's contents while an inert copy of the old contents fades out
|
|
54
|
+
* above the new ones as they fade in.
|
|
55
|
+
*/
|
|
56
|
+
crossfade(node, replace) {
|
|
57
|
+
const ms = duration();
|
|
58
|
+
if (!ms || node.style.display === 'none' || typeof node.animate !== 'function') {
|
|
59
|
+
replace();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const ghost = node.cloneNode(true);
|
|
63
|
+
makeInert(ghost);
|
|
64
|
+
ghost.dataset.layerTransition = '';
|
|
65
|
+
replace();
|
|
66
|
+
node.after(ghost);
|
|
67
|
+
const fadeOut = ghost.animate([{ opacity: 1 }, { opacity: 0 }], {
|
|
68
|
+
duration: ms,
|
|
69
|
+
easing: 'ease-out',
|
|
70
|
+
fill: 'forwards',
|
|
71
|
+
});
|
|
72
|
+
const entry = { animation: fadeOut, node: ghost };
|
|
73
|
+
ghosts.add(entry);
|
|
74
|
+
const remove = () => {
|
|
75
|
+
ghosts.delete(entry);
|
|
76
|
+
ghost.remove();
|
|
77
|
+
};
|
|
78
|
+
fadeOut.finished.then(remove, remove);
|
|
79
|
+
settle(node);
|
|
80
|
+
const fadeIn = node.animate([{ opacity: 0 }, { opacity: 1 }], {
|
|
81
|
+
duration: ms,
|
|
82
|
+
easing: 'ease-out',
|
|
83
|
+
});
|
|
84
|
+
const incoming = { animation: fadeIn, hiding: false };
|
|
85
|
+
fades.set(node, incoming);
|
|
86
|
+
fadeIn.finished.then(() => {
|
|
87
|
+
if (fades.get(node) === incoming)
|
|
88
|
+
fades.delete(node);
|
|
89
|
+
}, () => { });
|
|
90
|
+
},
|
|
91
|
+
/** Jump every layer to its final state and remove all decorative copies. */
|
|
92
|
+
cancel() {
|
|
93
|
+
for (const node of [...fades.keys()])
|
|
94
|
+
settle(node);
|
|
95
|
+
for (const { animation, node } of [...ghosts]) {
|
|
96
|
+
animation.cancel();
|
|
97
|
+
node.remove();
|
|
98
|
+
}
|
|
99
|
+
ghosts.clear();
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|