@kahwee/sf-map-svg 4.0.0 → 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 +28 -1
- package/README.md +7 -9
- 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/controller-types.d.ts +1 -0
- package/dist/src/district-morph.d.ts +17 -0
- package/dist/src/district-morph.js +148 -0
- package/dist/src/explorer-core.js +143 -46
- package/dist/src/features.d.ts +6 -0
- package/dist/src/features.js +11 -1
- package/dist/src/frame-scheduler.d.ts +11 -0
- package/dist/src/frame-scheduler.js +64 -0
- 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/marker-layer.d.ts +3 -0
- package/dist/src/marker-layer.js +27 -5
- 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 +26 -0
- package/dist/src/validation.js +2 -0
- package/docs/API.md +33 -0
- package/docs/api-audit.md +2 -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)
|
|
@@ -75,6 +75,7 @@ export interface MapController {
|
|
|
75
75
|
configure(patch: MapConfiguration): void;
|
|
76
76
|
getConfiguration(): MapConfigurationSnapshot;
|
|
77
77
|
on<K extends keyof MapEvents>(type: K, listener: (detail: MapEvents[K]) => void): () => void;
|
|
78
|
+
/** Reconcile stable IDs; retained markers preserve nodes, focus, and entrance animations. */
|
|
78
79
|
setMarkers(markers: readonly MapMarker[]): void;
|
|
79
80
|
setOverlays(overlays: readonly MapOverlay[]): void;
|
|
80
81
|
selectMarker(id: string | null, options?: CameraOptions & {
|
|
@@ -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,13 +1,16 @@
|
|
|
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';
|
|
7
8
|
import { fitBounds, interiorAnchor, projectedBounds } from './explorer-layout.js';
|
|
8
9
|
import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from './features.js';
|
|
10
|
+
import { createFrameScheduler } from './frame-scheduler.js';
|
|
9
11
|
import { geometryPath } from './geometry.js';
|
|
10
12
|
import { createLabelRenderer } from './label-renderer.js';
|
|
13
|
+
import { createLayerTransitions } from './layer-transition.js';
|
|
11
14
|
import { createSFMapWithData, districtColors, getLayerPathsWithData } from './map-core.js';
|
|
12
15
|
import { createMarkerLayer } from './marker-layer.js';
|
|
13
16
|
import { attachNavigation } from './navigation.js';
|
|
@@ -29,7 +32,7 @@ let explorerCount = 0;
|
|
|
29
32
|
/** Create an offline, browser-only neighborhood explorer. Call destroy() before disposal. */
|
|
30
33
|
export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
31
34
|
validateExplorerOptions(options);
|
|
32
|
-
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;
|
|
33
36
|
if (!['neighborhoods', 'districts', 'basemap'].includes(mode))
|
|
34
37
|
throw new RangeError(`Unknown map mode: ${mode}`);
|
|
35
38
|
if (chrome !== 'explorer' && chrome !== 'map')
|
|
@@ -49,6 +52,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
49
52
|
selectedMarkerRing,
|
|
50
53
|
northArrow,
|
|
51
54
|
scaleBar,
|
|
55
|
+
layerTransitions,
|
|
56
|
+
districtMorph: districtMorphOption,
|
|
52
57
|
});
|
|
53
58
|
if (labelStyle.fontWeight !== undefined &&
|
|
54
59
|
(!Number.isFinite(labelStyle.fontWeight) ||
|
|
@@ -434,6 +439,13 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
434
439
|
root.dispatchEvent(new CustomEvent('districtchange', { bubbles: true, detail: districtSelection(id) }));
|
|
435
440
|
return true;
|
|
436
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
|
+
}
|
|
437
449
|
function setDistrictStyle(style) {
|
|
438
450
|
if (destroyed)
|
|
439
451
|
return;
|
|
@@ -444,6 +456,12 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
444
456
|
districtRevision++;
|
|
445
457
|
districtStyle = style;
|
|
446
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);
|
|
447
465
|
updateDistrictAppearance();
|
|
448
466
|
}
|
|
449
467
|
function setDistrictYear(next, options = {}) {
|
|
@@ -464,8 +482,28 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
464
482
|
const geometry = getLayerPathsWithData({ year: next }, data.map, false);
|
|
465
483
|
const previousYear = year;
|
|
466
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;
|
|
467
500
|
for (const layer of districtLayers) {
|
|
468
|
-
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)
|
|
469
507
|
districtTransition.fade(layer, options.duration ?? 280);
|
|
470
508
|
layer.replaceChildren(...geometry.districts.map((district) => svgElement('path', {
|
|
471
509
|
'data-district': district.id,
|
|
@@ -491,6 +529,11 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
491
529
|
delete root.dataset.selectedDistrict;
|
|
492
530
|
updateDistrictAppearance();
|
|
493
531
|
updateComposition();
|
|
532
|
+
if (morph && lineLayer && previousOutlines)
|
|
533
|
+
districtMorph.start(lineLayer, previousOutlines, {
|
|
534
|
+
duration: morph.duration,
|
|
535
|
+
stroke: colors.district ?? '#71838a',
|
|
536
|
+
});
|
|
494
537
|
scheduleLabels();
|
|
495
538
|
status.textContent = `${year} supervisorial districts.`;
|
|
496
539
|
root.dispatchEvent(new CustomEvent('districtyearchange', { bubbles: true, detail: { year, previousYear } }));
|
|
@@ -504,7 +547,15 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
504
547
|
let items = [];
|
|
505
548
|
let destroyed = false;
|
|
506
549
|
let initialized = false;
|
|
507
|
-
|
|
550
|
+
const layerFades = createLayerTransitions(() => initialized && features.layerTransitions && !reducedMotion.matches
|
|
551
|
+
? features.layerTransitions.duration
|
|
552
|
+
: 0);
|
|
553
|
+
const districtMorph = createDistrictMorph();
|
|
554
|
+
const frames = createFrameScheduler({
|
|
555
|
+
request: (callback) => requestAnimationFrame(callback),
|
|
556
|
+
cancel: (id) => cancelAnimationFrame(id),
|
|
557
|
+
render: drawLabels,
|
|
558
|
+
});
|
|
508
559
|
const markerRenderer = createMarkerLayer(markerLayer);
|
|
509
560
|
const cancelEntrances = markerRenderer.cancelEntrances;
|
|
510
561
|
const camera = createCamera({
|
|
@@ -512,8 +563,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
512
563
|
write: setView,
|
|
513
564
|
duration: () => (initialized && features.motion ? features.motion.duration : 0),
|
|
514
565
|
reduced: () => reducedMotion.matches,
|
|
515
|
-
request:
|
|
516
|
-
cancel:
|
|
566
|
+
request: frames.request,
|
|
567
|
+
cancel: frames.cancel,
|
|
517
568
|
now: () => performance.now(),
|
|
518
569
|
});
|
|
519
570
|
const stopAnimation = camera.stop;
|
|
@@ -532,6 +583,12 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
532
583
|
stopAnimation();
|
|
533
584
|
if (Object.hasOwn(patch, 'markerEntrance'))
|
|
534
585
|
cancelEntrances();
|
|
586
|
+
if (Object.hasOwn(patch, 'layerTransitions')) {
|
|
587
|
+
layerFades.cancel();
|
|
588
|
+
districtTransition.cancel();
|
|
589
|
+
}
|
|
590
|
+
if (Object.hasOwn(patch, 'districtMorph'))
|
|
591
|
+
districtMorph.stop();
|
|
535
592
|
if (Object.hasOwn(patch, 'clustering'))
|
|
536
593
|
invalidateClusters();
|
|
537
594
|
syncFurniture();
|
|
@@ -798,8 +855,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
798
855
|
function scheduleLabels() {
|
|
799
856
|
if (destroyed)
|
|
800
857
|
return;
|
|
801
|
-
|
|
802
|
-
frame = requestAnimationFrame(drawLabels);
|
|
858
|
+
frames.invalidate();
|
|
803
859
|
}
|
|
804
860
|
function drawLabels() {
|
|
805
861
|
if (destroyed)
|
|
@@ -1142,7 +1198,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1142
1198
|
const revision = ++neighborhoodRevision;
|
|
1143
1199
|
delete root.dataset.selectedNeighborhood;
|
|
1144
1200
|
root.dataset.source = source;
|
|
1145
|
-
areas.replaceChildren(...nextItems.map((item) => item.node));
|
|
1201
|
+
layerFades.crossfade(areas, () => areas.replaceChildren(...nextItems.map((item) => item.node)));
|
|
1146
1202
|
neighborhoodSelect.replaceChildren(element('option', 'No neighborhood selected'));
|
|
1147
1203
|
neighborhoodSelect.options[0].value = '';
|
|
1148
1204
|
neighborhoodSelect.append(...nextOptions);
|
|
@@ -1185,9 +1241,9 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1185
1241
|
]) {
|
|
1186
1242
|
const layer = svg.querySelector(`[data-layer="${name}"]`);
|
|
1187
1243
|
if (layer)
|
|
1188
|
-
layer
|
|
1244
|
+
layerFades.set(layer, enabled(key));
|
|
1189
1245
|
}
|
|
1190
|
-
stations
|
|
1246
|
+
layerFades.set(stations, enabled('bartStations'));
|
|
1191
1247
|
for (const [kind, key] of [
|
|
1192
1248
|
['bart', 'bartStations'],
|
|
1193
1249
|
['park', 'landmarks'],
|
|
@@ -1202,7 +1258,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1202
1258
|
controls.legend === false ||
|
|
1203
1259
|
![...legend.children].some((node) => !node.hidden);
|
|
1204
1260
|
const neighborhoodsVisible = enabled('neighborhoodLines') || (labels && enabled('neighborhoodLabels'));
|
|
1205
|
-
areas
|
|
1261
|
+
layerFades.set(areas, neighborhoodsVisible);
|
|
1206
1262
|
areas.style.pointerEvents = selectableNeighborhoods ? '' : 'none';
|
|
1207
1263
|
neighborhoodLabel.hidden =
|
|
1208
1264
|
!neighborhoodsVisible ||
|
|
@@ -1219,7 +1275,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1219
1275
|
}
|
|
1220
1276
|
for (const layer of districtLayers) {
|
|
1221
1277
|
const key = layer.dataset.layer === 'district-fills' ? 'districtFills' : 'districtLines';
|
|
1222
|
-
layer
|
|
1278
|
+
layerFades.set(layer, enabled(key));
|
|
1223
1279
|
}
|
|
1224
1280
|
updateDistrictAppearance();
|
|
1225
1281
|
const districtsVisible = enabled('districtFills') || enabled('districtLines') || (labels && enabled('districtLabels'));
|
|
@@ -1274,10 +1330,16 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1274
1330
|
markerSelect.value = id ?? '';
|
|
1275
1331
|
for (const entry of markerItems) {
|
|
1276
1332
|
const active = entry.marker.id === id;
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
if (
|
|
1333
|
+
const pressed = String(active);
|
|
1334
|
+
const ring = active && features.selectedMarkerRing ? 'inline' : 'none';
|
|
1335
|
+
const fill = active ? selectedMarkerColor : (entry.marker.color ?? markerColor);
|
|
1336
|
+
if (entry.node.getAttribute('aria-pressed') !== pressed)
|
|
1337
|
+
entry.node.setAttribute('aria-pressed', pressed);
|
|
1338
|
+
if (entry.ring.getAttribute('display') !== ring)
|
|
1339
|
+
entry.ring.setAttribute('display', ring);
|
|
1340
|
+
if (entry.dot.getAttribute('fill') !== fill)
|
|
1341
|
+
entry.dot.setAttribute('fill', fill);
|
|
1342
|
+
if (active && markerLayer.lastChild !== entry.node)
|
|
1281
1343
|
markerLayer.append(entry.node);
|
|
1282
1344
|
}
|
|
1283
1345
|
scheduleLabels();
|
|
@@ -1294,41 +1356,78 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1294
1356
|
if (destroyed)
|
|
1295
1357
|
return;
|
|
1296
1358
|
validateMarkers(markers);
|
|
1359
|
+
const existing = new Map(markerItems.map((item) => [item.marker.id, item]));
|
|
1297
1360
|
const ids = new Set();
|
|
1298
1361
|
const next = markers.map((marker) => {
|
|
1299
1362
|
ids.add(marker.id);
|
|
1300
|
-
|
|
1363
|
+
const previous = existing.get(marker.id);
|
|
1364
|
+
const point = previous?.marker.lng === marker.lng && previous.marker.lat === marker.lat
|
|
1365
|
+
? previous.point
|
|
1366
|
+
: map.project([marker.lng, marker.lat]);
|
|
1367
|
+
return { marker: { ...marker }, point };
|
|
1301
1368
|
});
|
|
1369
|
+
const nextSelection = selectedMarker && ids.has(selectedMarker)
|
|
1370
|
+
? selectedMarker
|
|
1371
|
+
: (markers.find((marker) => marker.selected)?.id ?? null);
|
|
1372
|
+
const keys = ['id', 'lng', 'lat', 'label', 'selected', 'color', 'radius'];
|
|
1373
|
+
if (markerSelect.options.length > 0 &&
|
|
1374
|
+
nextSelection === selectedMarker &&
|
|
1375
|
+
next.length === markerItems.length &&
|
|
1376
|
+
next.every(({ marker }, index) => keys.every((key) => marker[key] === markerItems[index].marker[key])))
|
|
1377
|
+
return;
|
|
1302
1378
|
const focusedMarker = markerItems.find((item) => item.node === document.activeElement)?.marker
|
|
1303
1379
|
.id;
|
|
1304
1380
|
invalidateClusters();
|
|
1305
|
-
const
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1381
|
+
const options = new Map([...markerSelect.options].map((option) => [option.value, option]));
|
|
1382
|
+
for (const item of markerItems) {
|
|
1383
|
+
if (!ids.has(item.marker.id)) {
|
|
1384
|
+
markerRenderer.remove(item);
|
|
1385
|
+
options.get(item.marker.id)?.remove();
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
if (!options.has('')) {
|
|
1389
|
+
const empty = element('option', 'No marker selected');
|
|
1390
|
+
empty.value = '';
|
|
1391
|
+
markerSelect.prepend(empty);
|
|
1392
|
+
}
|
|
1310
1393
|
markerItems = next.map(({ marker, point }, index) => {
|
|
1311
|
-
const
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1394
|
+
const previous = existing.get(marker.id);
|
|
1395
|
+
const item = previous ??
|
|
1396
|
+
markerRenderer.add(marker, point, index, {
|
|
1397
|
+
features,
|
|
1398
|
+
markerColor,
|
|
1399
|
+
selectedMarkerColor,
|
|
1400
|
+
reducedMotion: reducedMotion.matches,
|
|
1401
|
+
enter: true,
|
|
1402
|
+
});
|
|
1403
|
+
if (previous)
|
|
1404
|
+
markerRenderer.update(item, marker, point, index);
|
|
1405
|
+
const option = options.get(marker.id) ?? element('option');
|
|
1406
|
+
const label = marker.label ?? marker.id;
|
|
1407
|
+
if (option.textContent !== label)
|
|
1408
|
+
option.textContent = label;
|
|
1409
|
+
if (option.value !== marker.id)
|
|
1410
|
+
option.value = marker.id;
|
|
1411
|
+
if (markerSelect.children[index + 1] !== option)
|
|
1412
|
+
markerSelect.insertBefore(option, markerSelect.children[index + 1] ?? null);
|
|
1321
1413
|
return item;
|
|
1322
1414
|
});
|
|
1415
|
+
// Match input order without detaching unchanged nodes; selected markers remain on top.
|
|
1416
|
+
const ordered = markerItems.filter((item) => item.marker.id !== nextSelection);
|
|
1417
|
+
const selectedItem = markerItems.find((item) => item.marker.id === nextSelection);
|
|
1418
|
+
if (selectedItem)
|
|
1419
|
+
ordered.push(selectedItem);
|
|
1420
|
+
ordered.forEach((item, index) => {
|
|
1421
|
+
if (markerLayer.children[index] !== item.node)
|
|
1422
|
+
markerLayer.insertBefore(item.node, markerLayer.children[index] ?? null);
|
|
1423
|
+
});
|
|
1323
1424
|
markerLabel.hidden = !markerItems.length || controls.markerPicker === false;
|
|
1324
1425
|
if (markerLabel.firstChild)
|
|
1325
1426
|
markerLabel.firstChild.textContent = `${strings.chooseMarker ?? 'Choose marker'} (${markerItems.length})`;
|
|
1326
1427
|
syncFeatureControls();
|
|
1327
|
-
const
|
|
1328
|
-
? previous
|
|
1329
|
-
: (markers.find((marker) => marker.selected)?.id ?? null);
|
|
1428
|
+
const revision = markerRevision + 1;
|
|
1330
1429
|
selectMarker(nextSelection, { fit: false });
|
|
1331
|
-
if (focusedMarker)
|
|
1430
|
+
if (!destroyed && markerRevision === revision && focusedMarker)
|
|
1332
1431
|
(markerItems.find((item) => item.marker.id === focusedMarker)?.node ?? svg).focus({
|
|
1333
1432
|
preventScroll: true,
|
|
1334
1433
|
});
|
|
@@ -1465,12 +1564,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1465
1564
|
districtRow(districtId)) {
|
|
1466
1565
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
1467
1566
|
event.preventDefault();
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
bubbles: true,
|
|
1471
|
-
detail: districtSelection(districtId),
|
|
1472
|
-
}));
|
|
1473
|
-
target.focus();
|
|
1567
|
+
if (activateDistrict(districtId))
|
|
1568
|
+
target.focus();
|
|
1474
1569
|
}
|
|
1475
1570
|
else if (event.key === '[' || event.key === ']') {
|
|
1476
1571
|
event.preventDefault();
|
|
@@ -1521,9 +1616,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1521
1616
|
? event.target.closest('[data-layer="district-fills"] [data-district], [data-layer="district-lines"] [data-district]')
|
|
1522
1617
|
: null;
|
|
1523
1618
|
if (district?.dataset.district) {
|
|
1524
|
-
|
|
1525
|
-
selectDistrict(id);
|
|
1526
|
-
root.dispatchEvent(new CustomEvent('districtactivate', { bubbles: true, detail: districtSelection(id) }));
|
|
1619
|
+
activateDistrict(Number(district.dataset.district));
|
|
1527
1620
|
return;
|
|
1528
1621
|
}
|
|
1529
1622
|
const node = event.target instanceof Element
|
|
@@ -1592,13 +1685,15 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1592
1685
|
destroyed = true;
|
|
1593
1686
|
camera.destroy();
|
|
1594
1687
|
districtTransition.cancel();
|
|
1688
|
+
layerFades.cancel();
|
|
1689
|
+
districtMorph.stop();
|
|
1595
1690
|
cancelEntrances();
|
|
1596
1691
|
controller.abort();
|
|
1597
1692
|
overlayEvents.abort();
|
|
1598
1693
|
clusterEvents.abort();
|
|
1599
1694
|
canvas.style.touchAction = 'pan-y pinch-zoom';
|
|
1600
1695
|
observer?.disconnect();
|
|
1601
|
-
|
|
1696
|
+
frames.destroy();
|
|
1602
1697
|
labelRenderer.clear();
|
|
1603
1698
|
releaseDownloads();
|
|
1604
1699
|
},
|
|
@@ -1614,6 +1709,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1614
1709
|
if (reducedMotion.matches) {
|
|
1615
1710
|
stopAnimation();
|
|
1616
1711
|
districtTransition.cancel();
|
|
1712
|
+
layerFades.cancel();
|
|
1713
|
+
districtMorph.stop();
|
|
1617
1714
|
cancelEntrances();
|
|
1618
1715
|
}
|
|
1619
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'];
|