@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/README.md +7 -9
  3. package/dist/data/guide/coast.json +1 -1
  4. package/dist/data/guide/landmarks.json +1 -1
  5. package/dist/data/guide/neighborhoods-realtor.json +1 -1
  6. package/dist/data/stations.d.ts +2 -2
  7. package/dist/data/types.d.ts +8 -1
  8. package/dist/src/api.d.ts +1 -1
  9. package/dist/src/api.js +1 -1
  10. package/dist/src/configuration.js +18 -2
  11. package/dist/src/controller-types.d.ts +1 -0
  12. package/dist/src/district-morph.d.ts +17 -0
  13. package/dist/src/district-morph.js +148 -0
  14. package/dist/src/explorer-core.js +143 -46
  15. package/dist/src/features.d.ts +6 -0
  16. package/dist/src/features.js +11 -1
  17. package/dist/src/frame-scheduler.d.ts +11 -0
  18. package/dist/src/frame-scheduler.js +64 -0
  19. package/dist/src/layer-transition.d.ts +18 -0
  20. package/dist/src/layer-transition.js +102 -0
  21. package/dist/src/layers.d.ts +9 -7
  22. package/dist/src/layers.js +28 -18
  23. package/dist/src/map-core.js +7 -4
  24. package/dist/src/marker-layer.d.ts +3 -0
  25. package/dist/src/marker-layer.js +27 -5
  26. package/dist/src/overlays.d.ts +3 -4
  27. package/dist/src/overlays.js +5 -1
  28. package/dist/src/static-animation.d.ts +13 -0
  29. package/dist/src/static-animation.js +36 -0
  30. package/dist/src/transit.js +2 -1
  31. package/dist/src/types.d.ts +26 -0
  32. package/dist/src/validation.js +2 -0
  33. package/docs/API.md +33 -0
  34. package/docs/api-audit.md +2 -0
  35. package/docs/consumer-integration.md +1 -1
  36. package/docs/developer-guide.md +137 -0
  37. package/docs/guide-bundle-report.md +4 -4
  38. package/llms-full.txt +1844 -0
  39. package/llms.txt +25 -0
  40. package/package.json +10 -4
@@ -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
+ }
@@ -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 {};
@@ -1,29 +1,39 @@
1
1
  import { escapeXml, number, overlayLabel, stroke } from './svg.js';
2
- export function districtFills(districts, { idPrefix }) {
3
- return `<g data-layer="district-fills" clip-path="url(#${idPrefix}-coast)">${districts.map((d) => `<path data-district="${d.id}" d="${d.path}" fill="${escapeXml(d.color)}"${d.opacity === undefined ? '' : ` fill-opacity="${d.opacity}"`} fill-rule="evenodd"/>`).join('')}</g>`;
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
- export function districtLines(districts, { idPrefix, colors }) {
6
- return `<g data-layer="district-lines" clip-path="url(#${idPrefix}-coast)">${districts.map((d) => `<path data-district="${d.id}" d="${d.path}" ${stroke(d.stroke ?? colors.district, 1.1)}${d.opacity === undefined ? '' : ` stroke-opacity="${d.opacity}"`}/>`).join('')}</g>`;
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
  }
@@ -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><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"/>`,
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;
@@ -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 Set();
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.add(animation);
58
- animation.finished.then(() => animations.delete(animation), () => animations.delete(animation));
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
  }
@@ -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;
@@ -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 }) => ({ 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
+ }
@@ -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 = stations.features.map((station) => map.project(station.geometry.coordinates));
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');