@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/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,11 @@
|
|
|
1
|
+
/** One browser frame for camera advancement and the dependent screen-space rendering. */
|
|
2
|
+
export declare function createFrameScheduler({ request, cancel, render, }: {
|
|
3
|
+
request: (callback: FrameRequestCallback) => number;
|
|
4
|
+
cancel: (id: number) => void;
|
|
5
|
+
render: () => void;
|
|
6
|
+
}): {
|
|
7
|
+
request(callback: FrameRequestCallback): number;
|
|
8
|
+
cancel(id: number): void;
|
|
9
|
+
invalidate(): void;
|
|
10
|
+
destroy(): void;
|
|
11
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** One browser frame for camera advancement and the dependent screen-space rendering. */
|
|
2
|
+
export function createFrameScheduler({ request, cancel, render, }) {
|
|
3
|
+
let frame;
|
|
4
|
+
let pending;
|
|
5
|
+
let sequence = 0;
|
|
6
|
+
let dirty = false;
|
|
7
|
+
let flushing = false;
|
|
8
|
+
let destroyed = false;
|
|
9
|
+
function schedule() {
|
|
10
|
+
if (!destroyed && !flushing && frame === undefined && (pending || dirty))
|
|
11
|
+
frame = request(flush);
|
|
12
|
+
}
|
|
13
|
+
function flush(time) {
|
|
14
|
+
frame = undefined;
|
|
15
|
+
if (destroyed)
|
|
16
|
+
return;
|
|
17
|
+
flushing = true;
|
|
18
|
+
const next = pending;
|
|
19
|
+
pending = undefined;
|
|
20
|
+
try {
|
|
21
|
+
next?.callback(time);
|
|
22
|
+
if (!destroyed && dirty) {
|
|
23
|
+
dirty = false;
|
|
24
|
+
render();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
flushing = false;
|
|
29
|
+
schedule();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
request(callback) {
|
|
34
|
+
const id = ++sequence;
|
|
35
|
+
if (!destroyed) {
|
|
36
|
+
pending = { id, callback };
|
|
37
|
+
schedule();
|
|
38
|
+
}
|
|
39
|
+
return id;
|
|
40
|
+
},
|
|
41
|
+
cancel(id) {
|
|
42
|
+
if (pending?.id === id)
|
|
43
|
+
pending = undefined;
|
|
44
|
+
if (!pending && !dirty && frame !== undefined) {
|
|
45
|
+
cancel(frame);
|
|
46
|
+
frame = undefined;
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
invalidate() {
|
|
50
|
+
if (destroyed)
|
|
51
|
+
return;
|
|
52
|
+
dirty = true;
|
|
53
|
+
schedule();
|
|
54
|
+
},
|
|
55
|
+
destroy() {
|
|
56
|
+
destroyed = true;
|
|
57
|
+
if (frame !== undefined)
|
|
58
|
+
cancel(frame);
|
|
59
|
+
frame = undefined;
|
|
60
|
+
pending = undefined;
|
|
61
|
+
dirty = false;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/src/layers.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { BartStation, KeyRoad, Landmark } from './overlays.js';
|
|
|
5
5
|
import type { MapMarker, SFMapOptions } from './types.js';
|
|
6
6
|
interface LayerContext {
|
|
7
7
|
labels: boolean;
|
|
8
|
+
/** Animated static maps index items for staggering and measure lines for drawing. */
|
|
9
|
+
animated?: boolean;
|
|
8
10
|
theme: NonNullable<SFMapOptions['theme']>;
|
|
9
11
|
idPrefix: string;
|
|
10
12
|
colors: Required<NonNullable<SFMapOptions['colors']>>;
|
|
@@ -18,21 +20,21 @@ interface DistrictPath {
|
|
|
18
20
|
stroke?: string;
|
|
19
21
|
opacity?: number;
|
|
20
22
|
}
|
|
21
|
-
export declare function districtFills(districts: readonly DistrictPath[], { idPrefix }: LayerContext): string;
|
|
22
|
-
export declare function districtLines(districts: readonly DistrictPath[], { idPrefix, colors }: LayerContext): string;
|
|
23
|
+
export declare function districtFills(districts: readonly DistrictPath[], { idPrefix, animated }: LayerContext): string;
|
|
24
|
+
export declare function districtLines(districts: readonly DistrictPath[], { idPrefix, colors, animated }: LayerContext): string;
|
|
23
25
|
export declare function landmarks(items: readonly Landmark[], { idPrefix, path, colors }: LayerContext): string;
|
|
24
26
|
export declare function highways(items: readonly {
|
|
25
27
|
route: string;
|
|
26
28
|
geometry: Geometry;
|
|
27
|
-
}[], { path, colors, theme }: LayerContext): string;
|
|
29
|
+
}[], { path, colors, theme, animated }: LayerContext): string;
|
|
28
30
|
export declare function neighborhoods(items: readonly {
|
|
29
31
|
name: string;
|
|
30
32
|
geometry: Geometry;
|
|
31
|
-
}[], { idPrefix, path, colors }: LayerContext): string;
|
|
33
|
+
}[], { idPrefix, path, colors, animated }: LayerContext): string;
|
|
32
34
|
export declare function districtLabels(districts: readonly DistrictRow[], { project, colors }: LayerContext): string;
|
|
33
35
|
export declare function landmarkLabels(items: readonly Landmark[], { project, colors }: LayerContext): string;
|
|
34
|
-
export declare function bartStations(items: readonly BartStation[], { project, colors, labels }: LayerContext): string;
|
|
35
|
-
export declare function markers(items: readonly MapMarker[], { project, colors }: LayerContext): string;
|
|
36
|
-
export declare function keyRoads(items: readonly KeyRoad[], { idPrefix, path, colors }: LayerContext): string;
|
|
36
|
+
export declare function bartStations(items: readonly BartStation[], { project, colors, labels, animated }: LayerContext): string;
|
|
37
|
+
export declare function markers(items: readonly MapMarker[], { project, colors, animated }: LayerContext): string;
|
|
38
|
+
export declare function keyRoads(items: readonly KeyRoad[], { idPrefix, path, colors, animated }: LayerContext): string;
|
|
37
39
|
export declare function keyRoadLabels(items: readonly KeyRoad[], { project }: LayerContext): string;
|
|
38
40
|
export {};
|
package/dist/src/layers.js
CHANGED
|
@@ -1,29 +1,39 @@
|
|
|
1
1
|
import { escapeXml, number, overlayLabel, stroke } from './svg.js';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
/** Custom JavaScript/JSON rows must not introduce markup or CSS through numeric IDs. */
|
|
3
|
+
function districtId(id) {
|
|
4
|
+
if (!Number.isFinite(id))
|
|
5
|
+
throw new TypeError('District IDs must be finite numbers.');
|
|
6
|
+
return id;
|
|
4
7
|
}
|
|
5
|
-
|
|
6
|
-
|
|
8
|
+
/** Stagger index for animated static maps. */
|
|
9
|
+
const order = (animated, index) => animated ? ` style="--i:${index}"` : '';
|
|
10
|
+
/** Normalized length so one dash can draw a line on. */
|
|
11
|
+
const drawable = (animated) => (animated ? ' pathLength="1"' : '');
|
|
12
|
+
export function districtFills(districts, { idPrefix, animated }) {
|
|
13
|
+
return `<g data-layer="district-fills" clip-path="url(#${idPrefix}-coast)">${districts.map((d) => `<path data-district="${districtId(d.id)}" d="${d.path}" fill="${escapeXml(d.color)}"${d.opacity === undefined ? '' : ` fill-opacity="${d.opacity}"`} fill-rule="evenodd"${order(animated, districtId(d.id))}/>`).join('')}</g>`;
|
|
14
|
+
}
|
|
15
|
+
export function districtLines(districts, { idPrefix, colors, animated }) {
|
|
16
|
+
return `<g data-layer="district-lines" clip-path="url(#${idPrefix}-coast)">${districts.map((d) => `<path data-district="${districtId(d.id)}" d="${d.path}" ${stroke(d.stroke ?? colors.district, 1.1)}${d.opacity === undefined ? '' : ` stroke-opacity="${d.opacity}"`}${drawable(animated)}${order(animated, districtId(d.id))}/>`).join('')}</g>`;
|
|
7
17
|
}
|
|
8
18
|
export function landmarks(items, { idPrefix, path, colors }) {
|
|
9
19
|
return `<g data-layer="landmarks" clip-path="url(#${idPrefix}-coast)">${items.map((item) => `<path data-landmark="${escapeXml(item.id)}" d="${path(item.geometry)}" fill="${escapeXml(colors.park)}" fill-rule="evenodd"><title>${escapeXml(item.name)}</title></path>`).join('')}</g>`;
|
|
10
20
|
}
|
|
11
|
-
export function highways(items, { path, colors, theme }) {
|
|
12
|
-
return `<g data-layer="highways">${items.map((road) => `<path data-route="${escapeXml(road.route)}" d="${path(road.geometry)}" ${stroke(colors.highway, theme === 'transit' ? 2 : 1.4)}/>`).join('')}</g>`;
|
|
21
|
+
export function highways(items, { path, colors, theme, animated }) {
|
|
22
|
+
return `<g data-layer="highways">${items.map((road) => `<path data-route="${escapeXml(road.route)}" d="${path(road.geometry)}" ${stroke(colors.highway, theme === 'transit' ? 2 : 1.4)}${drawable(animated)}/>`).join('')}</g>`;
|
|
13
23
|
}
|
|
14
|
-
export function neighborhoods(items, { idPrefix, path, colors }) {
|
|
15
|
-
return `<g data-layer="neighborhood-lines" clip-path="url(#${idPrefix}-coast)">${items.map((n) => `<path data-neighborhood="${escapeXml(n.name)}" d="${path(n.geometry)}" ${stroke(colors.neighborhood, 0.65)} stroke-dasharray="2 2"><title>${escapeXml(n.name)}</title></path>`).join('')}</g>`;
|
|
24
|
+
export function neighborhoods(items, { idPrefix, path, colors, animated }) {
|
|
25
|
+
return `<g data-layer="neighborhood-lines" clip-path="url(#${idPrefix}-coast)">${items.map((n, index) => `<path data-neighborhood="${escapeXml(n.name)}" d="${path(n.geometry)}" ${stroke(colors.neighborhood, 0.65)} stroke-dasharray="2 2"${order(animated, index)}><title>${escapeXml(n.name)}</title></path>`).join('')}</g>`;
|
|
16
26
|
}
|
|
17
27
|
export function districtLabels(districts, { project, colors }) {
|
|
18
28
|
// Primary badges first, then island badges, matching the original layer order.
|
|
19
29
|
const labels = [
|
|
20
|
-
...districts.map((d) => ({ id: d.id, label: d.labelPoints[0] })),
|
|
30
|
+
...districts.map((d) => ({ id: d.id, label: d.labelPoints[0] ?? d.label })),
|
|
21
31
|
...districts.flatMap((d) => d.labelPoints.slice(1).map((label) => ({ id: d.id, label }))),
|
|
22
32
|
];
|
|
23
33
|
return `<g data-layer="district-labels" font-family="system-ui,sans-serif" font-size="12" font-weight="600" text-anchor="middle" fill="${escapeXml(colors.label)}">${labels
|
|
24
34
|
.map((d) => {
|
|
25
35
|
const [x, y] = project(d.label).map(number);
|
|
26
|
-
return `<g transform="translate(${x},${y})"><circle r="10" fill="#ffffff" fill-opacity=".9"/><text dy=".35em">${d.id}</text></g>`;
|
|
36
|
+
return `<g transform="translate(${x},${y})"><circle r="10" fill="#ffffff" fill-opacity=".9"/><text dy=".35em">${districtId(d.id)}</text></g>`;
|
|
27
37
|
})
|
|
28
38
|
.join('')}</g>`;
|
|
29
39
|
}
|
|
@@ -35,29 +45,29 @@ export function landmarkLabels(items, { project, colors }) {
|
|
|
35
45
|
})
|
|
36
46
|
.join('')}</g>`;
|
|
37
47
|
}
|
|
38
|
-
export function bartStations(items, { project, colors, labels }) {
|
|
48
|
+
export function bartStations(items, { project, colors, labels, animated }) {
|
|
39
49
|
return `<g data-layer="bart-stations">${items
|
|
40
|
-
.map((station) => {
|
|
50
|
+
.map((station, index) => {
|
|
41
51
|
const [x, y] = project(station.coordinates).map(number);
|
|
42
|
-
return `<g data-bart-station="${escapeXml(station.id)}" transform="translate(${x},${y})"><title>${escapeXml(station.name)} BART station</title><circle r="5" fill="#fff" stroke="${escapeXml(colors.bart)}" stroke-width="2.5"/><circle r="1.5" fill="${escapeXml(colors.bart)}"/>${labels ? `<text x="10" y="4" ${overlayLabel} fill="${escapeXml(colors.bart)}">${escapeXml(station.name)}</text>` : ''}</g>`;
|
|
52
|
+
return `<g data-bart-station="${escapeXml(station.id)}" transform="translate(${x},${y})"${order(animated, index)}><title>${escapeXml(station.name)} BART station</title><circle r="5" fill="#fff" stroke="${escapeXml(colors.bart)}" stroke-width="2.5"/><circle r="1.5" fill="${escapeXml(colors.bart)}"/>${labels ? `<text x="10" y="4" ${overlayLabel} fill="${escapeXml(colors.bart)}">${escapeXml(station.name)}</text>` : ''}</g>`;
|
|
43
53
|
})
|
|
44
54
|
.join('')}</g>`;
|
|
45
55
|
}
|
|
46
|
-
export function markers(items, { project, colors }) {
|
|
56
|
+
export function markers(items, { project, colors, animated }) {
|
|
47
57
|
return `<g data-layer="markers">${items
|
|
48
|
-
.map((marker) => {
|
|
58
|
+
.map((marker, index) => {
|
|
49
59
|
if (marker.radius !== undefined && (!Number.isFinite(marker.radius) || marker.radius <= 0))
|
|
50
60
|
throw new RangeError('Marker radius must be positive and finite.');
|
|
51
61
|
const [x, y] = project([marker.lng, marker.lat]).map(number);
|
|
52
|
-
return `<circle data-marker-id="${escapeXml(marker.id)}" cx="${x}" cy="${y}" r="${marker.radius ?? (marker.selected ? 8 : 5)}" fill="${escapeXml(marker.color ?? (marker.selected ? colors.selected : colors.marker))}" stroke="#fff9e9" stroke-width="2" vector-effect="non-scaling-stroke"><title>${escapeXml(marker.label ?? marker.id)}</title></circle>`;
|
|
62
|
+
return `<circle data-marker-id="${escapeXml(marker.id)}" cx="${x}" cy="${y}" r="${marker.radius ?? (marker.selected ? 8 : 5)}" fill="${escapeXml(marker.color ?? (marker.selected ? colors.selected : colors.marker))}" stroke="#fff9e9" stroke-width="2" vector-effect="non-scaling-stroke"${order(animated, index)}><title>${escapeXml(marker.label ?? marker.id)}</title></circle>`;
|
|
53
63
|
})
|
|
54
64
|
.join('')}</g>`;
|
|
55
65
|
}
|
|
56
|
-
export function keyRoads(items, { idPrefix, path, colors }) {
|
|
66
|
+
export function keyRoads(items, { idPrefix, path, colors, animated }) {
|
|
57
67
|
return `<g data-layer="key-roads" clip-path="url(#${idPrefix}-coast)">${items
|
|
58
68
|
.map((road) => {
|
|
59
69
|
const level = road.level === 'secondary' ? 'secondary' : 'primary';
|
|
60
|
-
return `<path data-key-road="${escapeXml(road.id)}" data-key-road-level="${level}" d="${path(road.geometry)}" ${stroke(colors.road, 1)}><title>${escapeXml(road.name)}</title></path>`;
|
|
70
|
+
return `<path data-key-road="${escapeXml(road.id)}" data-key-road-level="${level}" d="${path(road.geometry)}" ${stroke(colors.road, 1)}${drawable(animated)}><title>${escapeXml(road.name)}</title></path>`;
|
|
61
71
|
})
|
|
62
72
|
.join('')}</g>`;
|
|
63
73
|
}
|
package/dist/src/map-core.js
CHANGED
|
@@ -2,6 +2,7 @@ import { validateMarkers, validateOverlays } from './validation.js';
|
|
|
2
2
|
import { prepareDistrictStyles } from './district-style.js';
|
|
3
3
|
import { geometryPath, positions, rawProject } from './geometry.js';
|
|
4
4
|
import * as layers from './layers.js';
|
|
5
|
+
import { normalizeStaticAnimation, staticAnimationStyle } from './static-animation.js';
|
|
5
6
|
import { escapeXml, stroke } from './svg.js';
|
|
6
7
|
export const districtYears = Object.freeze([2002, 2012, 2022]);
|
|
7
8
|
export const districtColors = Object.freeze([
|
|
@@ -115,6 +116,8 @@ export function createSFMapWithData(options, data) {
|
|
|
115
116
|
if (theme !== 'districts' && theme !== 'transit')
|
|
116
117
|
throw new TypeError('Theme must be districts or transit.');
|
|
117
118
|
const colors = { ...defaults, ...(theme === 'transit' ? transitColors : {}), ...options.colors };
|
|
119
|
+
const animation = normalizeStaticAnimation(options.animation);
|
|
120
|
+
const animated = animation !== null;
|
|
118
121
|
const geometry = getLayerPathsWithData(options, data, false);
|
|
119
122
|
const { project } = geometry;
|
|
120
123
|
const path = (geometry) => geometryPath(geometry, project);
|
|
@@ -123,7 +126,7 @@ export function createSFMapWithData(options, data) {
|
|
|
123
126
|
const landmarkData = data.landmarks ?? [];
|
|
124
127
|
const roadData = data.keyRoads ?? [];
|
|
125
128
|
const stationData = data.bartStations ?? [];
|
|
126
|
-
const context = { project, path, colors, idPrefix, theme, labels };
|
|
129
|
+
const context = { project, path, colors, idPrefix, theme, labels, animated };
|
|
127
130
|
const styles = prepareDistrictStyles(districtFills || districtLines ? districts : [], options.districtStyle);
|
|
128
131
|
const districtPaths = districtFills || districtLines
|
|
129
132
|
? districts.map((d, index) => {
|
|
@@ -138,7 +141,7 @@ export function createSFMapWithData(options, data) {
|
|
|
138
141
|
})
|
|
139
142
|
: [];
|
|
140
143
|
const parts = [
|
|
141
|
-
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-labelledby="${idPrefix}-title" data-sf-map="" data-year="${year}" style="max-width:100%;height:auto"><title id="${idPrefix}-title">${escapeXml(title)}</title><desc>San Francisco supervisorial district boundaries (${year}).${neighborhoodLines ? ' Dashed lines show SFAR realtor neighborhood areas, defined in August 2010.' : ''}${landmarks ? ' Highlighted areas show six parks and landmarks.' : ''}${keyRoads ? ' Thin gray lines show selected road corridors.' : ''}${bartStations ? ' Rings mark the eight San Francisco BART stations.' : ''} Geometry from DataSF${bartStations ? ' and BART' : ''}. See package SOURCES.md.</desc
|
|
144
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-labelledby="${idPrefix}-title" data-sf-map="" data-year="${year}"${animated ? ` data-sf-animate="${idPrefix}"` : ''} style="max-width:100%;height:auto"><title id="${idPrefix}-title">${escapeXml(title)}</title><desc>San Francisco supervisorial district boundaries (${year}).${neighborhoodLines ? ' Dashed lines show SFAR realtor neighborhood areas, defined in August 2010.' : ''}${landmarks ? ' Highlighted areas show six parks and landmarks.' : ''}${keyRoads ? ' Thin gray lines show selected road corridors.' : ''}${bartStations ? ' Rings mark the eight San Francisco BART stations.' : ''} Geometry from DataSF${bartStations ? ' and BART' : ''}. See package SOURCES.md.</desc>${animation ? staticAnimationStyle(idPrefix, animation) : ''}<defs><clipPath id="${idPrefix}-coast"><path d="${coastPath}" fill-rule="evenodd" clip-rule="evenodd"/></clipPath></defs><rect width="${width}" height="${height}" fill="${escapeXml(colors.water)}"/><g data-layer="geography"><path data-layer="coast" d="${coastPath}" fill="${escapeXml(colors.land)}" fill-rule="evenodd"/>`,
|
|
142
145
|
];
|
|
143
146
|
// Explicit drawing order keeps optional overlays and user markers predictable.
|
|
144
147
|
if (districtFills)
|
|
@@ -153,7 +156,7 @@ export function createSFMapWithData(options, data) {
|
|
|
153
156
|
parts.push(layers.neighborhoods(data.neighborhoods ?? [], context));
|
|
154
157
|
if (districtLines)
|
|
155
158
|
parts.push(layers.districtLines(districtPaths, context));
|
|
156
|
-
parts.push(`<path data-layer="coastline" d="${coastPath}" ${stroke(colors.district, 0.65)}/>`);
|
|
159
|
+
parts.push(`<path data-layer="coastline" d="${coastPath}" ${stroke(colors.district, 0.65)}${animated ? ' pathLength="1"' : ''}/>`);
|
|
157
160
|
if (labels && districtLabels)
|
|
158
161
|
parts.push(layers.districtLabels(districts, context));
|
|
159
162
|
if (labels && landmarks)
|
|
@@ -163,7 +166,7 @@ export function createSFMapWithData(options, data) {
|
|
|
163
166
|
if (bartStations)
|
|
164
167
|
parts.push(layers.bartStations(stationData, context));
|
|
165
168
|
for (const overlay of options.overlays ?? []) {
|
|
166
|
-
parts.push(`<path data-overlay-id="${escapeXml(overlay.id)}"${overlay.label ? ` aria-label="${escapeXml(overlay.label)}"` : ''} d="${path(overlay.geometry)}" fill="${escapeXml(overlay.fill ?? 'none')}" fill-opacity="${overlay.fillOpacity ?? 1}" stroke="${escapeXml(overlay.stroke ?? colors.road)}" stroke-width="${overlay.strokeWidth ?? 2}" vector-effect="non-scaling-stroke"${overlay.visible === false ? ' display="none"' : ''}/>`);
|
|
169
|
+
parts.push(`<path data-overlay-id="${escapeXml(overlay.id)}"${overlay.label ? ` aria-label="${escapeXml(overlay.label)}"` : ''} d="${path(overlay.geometry)}" fill="${escapeXml(overlay.fill ?? 'none')}" fill-opacity="${overlay.fillOpacity ?? 1}" stroke="${escapeXml(overlay.stroke ?? colors.road)}" stroke-width="${overlay.strokeWidth ?? 2}" vector-effect="non-scaling-stroke"${animated ? ' pathLength="1"' : ''}${overlay.visible === false ? ' display="none"' : ''}/>`);
|
|
167
170
|
}
|
|
168
171
|
parts.push(layers.markers(markers, context), '</g></svg>');
|
|
169
172
|
return {
|
|
@@ -7,11 +7,14 @@ export interface MarkerItem {
|
|
|
7
7
|
dot: SVGCircleElement;
|
|
8
8
|
hit: SVGCircleElement;
|
|
9
9
|
ring: SVGCircleElement;
|
|
10
|
+
title: SVGTitleElement;
|
|
10
11
|
}
|
|
11
12
|
/** Own marker visuals and entrances; selection/events remain with the controller. */
|
|
12
13
|
export declare function createMarkerLayer(layer: SVGGElement): {
|
|
13
14
|
cancelEntrances: () => void;
|
|
14
15
|
clear(): void;
|
|
16
|
+
remove(item: MarkerItem): void;
|
|
17
|
+
update(item: MarkerItem, marker: MapMarker, point: [number, number], index: number): void;
|
|
15
18
|
add(marker: MapMarker, point: [number, number], index: number, { features, markerColor, selectedMarkerColor, reducedMotion, enter, }: {
|
|
16
19
|
features: ReturnType<typeof normalizeFeatures>;
|
|
17
20
|
markerColor: string;
|
package/dist/src/marker-layer.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { svgElement } from './dom.js';
|
|
2
2
|
/** Own marker visuals and entrances; selection/events remain with the controller. */
|
|
3
3
|
export function createMarkerLayer(layer) {
|
|
4
|
-
const animations = new
|
|
4
|
+
const animations = new Map();
|
|
5
5
|
function cancelEntrances() {
|
|
6
|
-
for (const animation of animations)
|
|
6
|
+
for (const animation of animations.values())
|
|
7
7
|
animation.cancel();
|
|
8
8
|
animations.clear();
|
|
9
9
|
}
|
|
@@ -13,6 +13,24 @@ export function createMarkerLayer(layer) {
|
|
|
13
13
|
cancelEntrances();
|
|
14
14
|
layer.replaceChildren();
|
|
15
15
|
},
|
|
16
|
+
remove(item) {
|
|
17
|
+
animations.get(item.node)?.cancel();
|
|
18
|
+
animations.delete(item.node);
|
|
19
|
+
item.node.remove();
|
|
20
|
+
},
|
|
21
|
+
update(item, marker, point, index) {
|
|
22
|
+
if (item.point[0] !== point[0] || item.point[1] !== point[1])
|
|
23
|
+
item.node.setAttribute('transform', `translate(${point[0]},${point[1]})`);
|
|
24
|
+
const label = marker.label ?? marker.id;
|
|
25
|
+
if (item.title.textContent !== label) {
|
|
26
|
+
item.title.textContent = label;
|
|
27
|
+
item.node.setAttribute('aria-label', label);
|
|
28
|
+
}
|
|
29
|
+
if (item.node.style.getPropertyValue('--sf-marker-index') !== String(index))
|
|
30
|
+
item.node.style.setProperty('--sf-marker-index', String(index));
|
|
31
|
+
item.marker = marker;
|
|
32
|
+
item.point = point;
|
|
33
|
+
},
|
|
16
34
|
add(marker, point, index, { features, markerColor, selectedMarkerColor, reducedMotion, enter, }) {
|
|
17
35
|
const node = svgElement('g', {
|
|
18
36
|
transform: `translate(${point[0]},${point[1]})`,
|
|
@@ -54,11 +72,15 @@ export function createMarkerLayer(layer) {
|
|
|
54
72
|
easing: 'cubic-bezier(.2,.8,.2,1)',
|
|
55
73
|
fill: 'backwards',
|
|
56
74
|
});
|
|
57
|
-
animations.
|
|
58
|
-
|
|
75
|
+
animations.set(node, animation);
|
|
76
|
+
const release = () => {
|
|
77
|
+
if (animations.get(node) === animation)
|
|
78
|
+
animations.delete(node);
|
|
79
|
+
};
|
|
80
|
+
animation.finished.then(release, release);
|
|
59
81
|
}
|
|
60
82
|
layer.append(node);
|
|
61
|
-
return { marker, point, node, dot, hit, ring };
|
|
83
|
+
return { marker, point, node, dot, hit, ring, title };
|
|
62
84
|
},
|
|
63
85
|
};
|
|
64
86
|
}
|
package/dist/src/overlays.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import type { Position } from '../data/types.js';
|
|
2
1
|
export declare const landmarks: {
|
|
3
2
|
name: string;
|
|
4
|
-
label: Position;
|
|
3
|
+
label: import("../data/types.js").Position;
|
|
5
4
|
offset: readonly [number, number];
|
|
6
5
|
anchor: 'middle' | 'start' | 'end';
|
|
7
6
|
id: string;
|
|
@@ -10,7 +9,7 @@ export declare const landmarks: {
|
|
|
10
9
|
export declare const bartStations: {
|
|
11
10
|
id: string;
|
|
12
11
|
name: string;
|
|
13
|
-
coordinates: Position;
|
|
12
|
+
coordinates: import("../data/types.js").Position;
|
|
14
13
|
}[];
|
|
15
14
|
export type Landmark = (typeof landmarks)[number];
|
|
16
15
|
export type BartStation = (typeof bartStations)[number];
|
|
@@ -18,7 +17,7 @@ export declare const keyRoads: {
|
|
|
18
17
|
name: string;
|
|
19
18
|
level?: 'primary' | 'secondary';
|
|
20
19
|
sourceNames: readonly string[];
|
|
21
|
-
label: Position;
|
|
20
|
+
label: import("../data/types.js").Position;
|
|
22
21
|
segmentIds: readonly string[];
|
|
23
22
|
id: string;
|
|
24
23
|
geometry: import("../data/types.js").Geometry;
|
package/dist/src/overlays.js
CHANGED
|
@@ -12,4 +12,8 @@ export const bartStations = deepFreeze(stationData.features.map(({ id, propertie
|
|
|
12
12
|
name: properties.name,
|
|
13
13
|
coordinates: geometry.coordinates,
|
|
14
14
|
})));
|
|
15
|
-
export const keyRoads = deepFreeze(roadData.features.map(({ id, properties, geometry }) => ({
|
|
15
|
+
export const keyRoads = deepFreeze(roadData.features.map(({ id, properties, geometry }) => ({
|
|
16
|
+
id,
|
|
17
|
+
...properties,
|
|
18
|
+
geometry,
|
|
19
|
+
})));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SFMapOptions } from './types.js';
|
|
2
|
+
export interface StaticAnimation {
|
|
3
|
+
duration: number;
|
|
4
|
+
delay: number;
|
|
5
|
+
}
|
|
6
|
+
/** Validate `animation` and return timings, or null when animation is off. */
|
|
7
|
+
export declare function normalizeStaticAnimation(value: SFMapOptions['animation']): StaticAnimation | null;
|
|
8
|
+
/**
|
|
9
|
+
* Scoped, self-contained CSS for an animated static map. It runs when the SVG is
|
|
10
|
+
* inserted into a page or loaded as an image, and stays still under reduced motion.
|
|
11
|
+
* Line layers carry `pathLength="1"` so a single dash can draw them on.
|
|
12
|
+
*/
|
|
13
|
+
export declare function staticAnimationStyle(idPrefix: string, { duration, delay }: StaticAnimation): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { assertOptions } from './validation.js';
|
|
2
|
+
/** Reference choreography length; `duration` rescales every step proportionally. */
|
|
3
|
+
const REFERENCE = 2400;
|
|
4
|
+
/** Validate `animation` and return timings, or null when animation is off. */
|
|
5
|
+
export function normalizeStaticAnimation(value) {
|
|
6
|
+
if (value === undefined || value === false)
|
|
7
|
+
return null;
|
|
8
|
+
if (value === true)
|
|
9
|
+
return { duration: REFERENCE, delay: 0 };
|
|
10
|
+
assertOptions(value, 'animation', ['duration', 'delay']);
|
|
11
|
+
const { duration = REFERENCE, delay = 0 } = value;
|
|
12
|
+
if (typeof duration !== 'number' || !Number.isFinite(duration) || duration <= 0)
|
|
13
|
+
throw new RangeError('Animation duration must be a positive finite number.');
|
|
14
|
+
if (typeof delay !== 'number' || !Number.isFinite(delay) || delay < 0)
|
|
15
|
+
throw new RangeError('Animation delay must be a nonnegative finite number.');
|
|
16
|
+
return { duration, delay };
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Scoped, self-contained CSS for an animated static map. It runs when the SVG is
|
|
20
|
+
* inserted into a page or loaded as an image, and stays still under reduced motion.
|
|
21
|
+
* Line layers carry `pathLength="1"` so a single dash can draw them on.
|
|
22
|
+
*/
|
|
23
|
+
export function staticAnimationStyle(idPrefix, { duration, delay }) {
|
|
24
|
+
const scale = duration / REFERENCE;
|
|
25
|
+
const time = (ms) => `${Math.round(ms * scale)}ms`;
|
|
26
|
+
const at = (ms, step) => step === undefined
|
|
27
|
+
? `${Math.round(delay + ms * scale)}ms`
|
|
28
|
+
: `calc(${Math.round(delay + ms * scale)}ms + var(--i,0) * ${Math.round(step * scale)}ms)`;
|
|
29
|
+
const scope = `[data-sf-animate="${idPrefix}"]`;
|
|
30
|
+
const out = 'cubic-bezier(.16,1,.3,1)';
|
|
31
|
+
const inOut = 'cubic-bezier(.65,0,.35,1)';
|
|
32
|
+
const fade = `${idPrefix}-fade`;
|
|
33
|
+
const draw = `${idPrefix}-draw`;
|
|
34
|
+
const grow = `${idPrefix}-grow`;
|
|
35
|
+
return `<style>@keyframes ${fade}{from{opacity:0}}@keyframes ${draw}{from{stroke-dashoffset:1}}@keyframes ${grow}{from{opacity:0;transform:scale(.86)}}${scope} [data-layer="coast"]{animation:${fade} ${time(700)} ${out} ${at(0)} both}${scope} [data-layer="coastline"],${scope} [data-layer="district-lines"] path,${scope} [data-layer="highways"] path,${scope} [data-layer="key-roads"] path,${scope} [data-overlay-id]{stroke-dasharray:1}${scope} [data-layer="coastline"]{animation:${draw} ${time(1500)} ${inOut} ${at(0)} both}${scope} [data-layer="district-fills"] path{transform-box:fill-box;transform-origin:center;animation:${grow} ${time(800)} ${out} ${at(300, 60)} both}${scope} [data-layer="landmarks"]{animation:${fade} ${time(700)} ${out} ${at(900)} both}${scope} [data-layer="key-roads"] path,${scope} [data-layer="highways"] path{animation:${draw} ${time(1100)} ${inOut} ${at(1000)} both}${scope} [data-layer="neighborhood-lines"] path{animation:${fade} ${time(500)} ${out} ${at(1100, 7)} both}${scope} [data-layer="district-lines"] path{animation:${draw} ${time(1000)} ${inOut} ${at(1150, 45)} both}${scope} [data-overlay-id]{animation:${draw} ${time(1000)} ${inOut} ${at(1500)} both}${scope} [data-layer$="-labels"]{animation:${fade} ${time(600)} ${out} ${at(1850)} both}${scope} [data-bart-station]>circle,${scope} [data-marker-id]{transform-box:fill-box;transform-origin:center;animation:${grow} ${time(500)} ${out} ${at(1900, 70)} both}${scope} [data-bart-station]>text{animation:${fade} ${time(500)} ${out} ${at(2000, 70)} both}@media (prefers-reduced-motion:reduce){${scope} *{animation:none!important}}</style>`;
|
|
36
|
+
}
|
package/dist/src/transit.js
CHANGED
|
@@ -5,6 +5,7 @@ import parks from '../data/landmarks.json' with { type: 'json' };
|
|
|
5
5
|
import { createSFMapWithData } from './map-core.js';
|
|
6
6
|
const districtData = districts;
|
|
7
7
|
const parkData = parks;
|
|
8
|
+
const stationData = stations;
|
|
8
9
|
const transitMapData = {
|
|
9
10
|
coast: coast.features[0].geometry,
|
|
10
11
|
districts: {
|
|
@@ -38,7 +39,7 @@ export function createTransitAnimation() {
|
|
|
38
39
|
</style>${map.svg}<div class="controls"><button type="button">Play</button><label>Journey <input aria-label="Journey progress" type="range" min="0" max="1000" value="0"></label></div><output aria-live="off"></output><p>Schematic BART journey. Straight connections between official station locations; not track geometry, a timetable, or live trains. One loop takes 28 seconds.</p>`;
|
|
39
40
|
const svg = root.querySelector('svg');
|
|
40
41
|
const ns = 'http://www.w3.org/2000/svg';
|
|
41
|
-
const points =
|
|
42
|
+
const points = stationData.features.map((station) => map.project(station.geometry.coordinates));
|
|
42
43
|
const line = document.createElementNS(ns, 'polyline');
|
|
43
44
|
line.setAttribute('points', points.map((point) => point.join(',')).join(' '));
|
|
44
45
|
line.setAttribute('fill', 'none');
|