@kahwee/sf-map-svg 4.0.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +9 -13
  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/appearance.d.ts +5 -0
  11. package/dist/src/appearance.js +54 -0
  12. package/dist/src/configuration.js +50 -25
  13. package/dist/src/controller-types.d.ts +53 -6
  14. package/dist/src/district-morph.d.ts +17 -0
  15. package/dist/src/district-morph.js +148 -0
  16. package/dist/src/explorer-core.d.ts +14 -2
  17. package/dist/src/explorer-core.js +233 -52
  18. package/dist/src/features.d.ts +6 -0
  19. package/dist/src/features.js +11 -1
  20. package/dist/src/guide-map.js +1 -1
  21. package/dist/src/guide-static.js +3 -3
  22. package/dist/src/layer-transition.d.ts +18 -0
  23. package/dist/src/layer-transition.js +102 -0
  24. package/dist/src/layers.d.ts +9 -7
  25. package/dist/src/layers.js +28 -18
  26. package/dist/src/map-core.d.ts +14 -0
  27. package/dist/src/map-core.js +11 -5
  28. package/dist/src/map.js +125 -20
  29. package/dist/src/overlays.d.ts +3 -4
  30. package/dist/src/overlays.js +5 -1
  31. package/dist/src/static-animation.d.ts +13 -0
  32. package/dist/src/static-animation.js +36 -0
  33. package/dist/src/static.d.ts +1 -1
  34. package/dist/src/static.js +31 -2
  35. package/dist/src/transit.js +2 -1
  36. package/dist/src/types.d.ts +33 -2
  37. package/dist/src/validation.js +2 -0
  38. package/docs/API.md +85 -0
  39. package/docs/api-audit.md +3 -1
  40. package/docs/consumer-integration.md +1 -1
  41. package/docs/developer-guide.md +139 -0
  42. package/docs/guide-bundle-report.md +4 -4
  43. package/llms-full.txt +1979 -0
  44. package/llms.txt +25 -0
  45. package/package.json +12 -5
@@ -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
  }
@@ -80,6 +80,20 @@ export declare function getLayerPathsWithData(options: SFMapOptions, data: SFMap
80
80
  point: [number, number];
81
81
  }[];
82
82
  };
83
+ export declare function resolveMapColors(theme: SFMapOptions['theme'], colors?: SFMapOptions['colors']): {
84
+ water: string;
85
+ land: string;
86
+ district: string;
87
+ neighborhood: string;
88
+ highway: string;
89
+ road: string;
90
+ park: string;
91
+ landmark: string;
92
+ bart: string;
93
+ label: string;
94
+ marker: string;
95
+ selected: string;
96
+ };
83
97
  /** Make an offline SVG and the matching longitude/latitude projection. */
84
98
  export declare function createSFMapWithData(options: SFMapOptions, data: SFMapData): {
85
99
  svg: string;
@@ -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([
@@ -103,6 +104,9 @@ const transitColors = {
103
104
  marker: '#0073ae',
104
105
  selected: '#0073ae',
105
106
  };
107
+ export function resolveMapColors(theme, colors = {}) {
108
+ return { ...defaults, ...(theme === 'transit' ? transitColors : {}), ...colors };
109
+ }
106
110
  let sequence = 0;
107
111
  /** Make an offline SVG and the matching longitude/latitude projection. */
108
112
  export function createSFMapWithData(options, data) {
@@ -114,7 +118,9 @@ export function createSFMapWithData(options, data) {
114
118
  throw new TypeError('idPrefix must start with a letter and contain only letters, numbers, underscores, or hyphens.');
115
119
  if (theme !== 'districts' && theme !== 'transit')
116
120
  throw new TypeError('Theme must be districts or transit.');
117
- const colors = { ...defaults, ...(theme === 'transit' ? transitColors : {}), ...options.colors };
121
+ const colors = resolveMapColors(theme, options.colors);
122
+ const animation = normalizeStaticAnimation(options.animation);
123
+ const animated = animation !== null;
118
124
  const geometry = getLayerPathsWithData(options, data, false);
119
125
  const { project } = geometry;
120
126
  const path = (geometry) => geometryPath(geometry, project);
@@ -123,7 +129,7 @@ export function createSFMapWithData(options, data) {
123
129
  const landmarkData = data.landmarks ?? [];
124
130
  const roadData = data.keyRoads ?? [];
125
131
  const stationData = data.bartStations ?? [];
126
- const context = { project, path, colors, idPrefix, theme, labels };
132
+ const context = { project, path, colors, idPrefix, theme, labels, animated };
127
133
  const styles = prepareDistrictStyles(districtFills || districtLines ? districts : [], options.districtStyle);
128
134
  const districtPaths = districtFills || districtLines
129
135
  ? districts.map((d, index) => {
@@ -138,7 +144,7 @@ export function createSFMapWithData(options, data) {
138
144
  })
139
145
  : [];
140
146
  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"/>`,
147
+ `<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
148
  ];
143
149
  // Explicit drawing order keeps optional overlays and user markers predictable.
144
150
  if (districtFills)
@@ -153,7 +159,7 @@ export function createSFMapWithData(options, data) {
153
159
  parts.push(layers.neighborhoods(data.neighborhoods ?? [], context));
154
160
  if (districtLines)
155
161
  parts.push(layers.districtLines(districtPaths, context));
156
- parts.push(`<path data-layer="coastline" d="${coastPath}" ${stroke(colors.district, 0.65)}/>`);
162
+ parts.push(`<path data-layer="coastline" d="${coastPath}" ${stroke(colors.district, 0.65)}${animated ? ' pathLength="1"' : ''}/>`);
157
163
  if (labels && districtLabels)
158
164
  parts.push(layers.districtLabels(districts, context));
159
165
  if (labels && landmarks)
@@ -163,7 +169,7 @@ export function createSFMapWithData(options, data) {
163
169
  if (bartStations)
164
170
  parts.push(layers.bartStations(stationData, context));
165
171
  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"' : ''}/>`);
172
+ 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
173
  }
168
174
  parts.push(layers.markers(markers, context), '</g></svg>');
169
175
  return {
package/dist/src/map.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { copyAppearance } from './appearance.js';
1
2
  import { validateCameraOptions } from './camera.js';
2
3
  import { expandMapOptions, prepareConfiguration } from './configuration.js';
3
4
  import { createNeighborhoodExplorerCore } from './explorer-core.js';
@@ -6,10 +7,11 @@ import { assertOptions } from './validation.js';
6
7
  /** No geography is imported. Supply a preset or your own immutable data. */
7
8
  export function createMap(data, options = {}) {
8
9
  const expanded = expandMapOptions(options);
9
- let config = prepareConfiguration({ features: {}, layers: {}, controls: {} }, {
10
+ let config = prepareConfiguration({ features: {}, layers: {}, controls: {}, appearance: {} }, {
10
11
  features: options.features,
11
12
  layers: options.layers,
12
13
  controls: options.controls,
14
+ appearance: options.appearance,
13
15
  });
14
16
  const element = createNeighborhoodExplorerCore({
15
17
  mode: 'basemap',
@@ -18,6 +20,80 @@ export function createMap(data, options = {}) {
18
20
  onOverlayActivate: () => { },
19
21
  }, data);
20
22
  const subscriptions = new Set();
23
+ let configurationRevision = 0;
24
+ const sourceList = ['realtor', 'sf-find', 'analysis'].filter((source) => !!data.neighborhoods[source]);
25
+ const yearList = [2002, 2012, 2022].filter((year) => !!data.map.districts?.[year] && !!data.districts?.[year]);
26
+ const capabilities = () => ({
27
+ sources: [...sourceList],
28
+ years: [...yearList],
29
+ layers: Object.fromEntries(layerKeys.map((key) => [
30
+ key,
31
+ key.startsWith('district')
32
+ ? !!data.map.districts?.[element.getMapState().year]?.length &&
33
+ yearList.includes(element.getMapState().year)
34
+ : key.startsWith('neighborhood')
35
+ ? !!data.neighborhoods[element.getMapState().source]?.features.length
36
+ : key === 'roadLabels'
37
+ ? !!data.map.keyRoads?.length
38
+ : !!data.map[key]?.length,
39
+ ])),
40
+ });
41
+ const snapshot = () => ({
42
+ ...structuredClone({
43
+ features: config.features,
44
+ layers: config.layers,
45
+ controls: config.controls,
46
+ }),
47
+ appearance: copyAppearance(config.appearance),
48
+ });
49
+ const resolved = () => {
50
+ const state = element.getMapState();
51
+ const supplied = capabilities().layers;
52
+ return {
53
+ ...snapshot(),
54
+ ...state,
55
+ layers: Object.fromEntries(layerKeys.map((key) => [
56
+ key,
57
+ supplied[key] &&
58
+ (!key.endsWith('Labels') || state.labels) &&
59
+ (config.layers[key] ??
60
+ (key.startsWith('district')
61
+ ? state.mode === 'districts'
62
+ : key.startsWith('neighborhood')
63
+ ? state.mode === 'neighborhoods'
64
+ : true)),
65
+ ])),
66
+ };
67
+ };
68
+ let previousMarker = element.getSelectedMarker();
69
+ let previousNeighborhood = element.getSelection();
70
+ let previousDistrict = element.getSelectedDistrict();
71
+ const selectionListeners = [];
72
+ for (const type of ['markerchange', 'neighborhoodchange', 'districtchange']) {
73
+ const handle = (event) => {
74
+ if (event.target !== element)
75
+ return;
76
+ let detail;
77
+ if (type === 'markerchange') {
78
+ const current = element.getSelectedMarker();
79
+ detail = { kind: 'marker', current, previous: previousMarker };
80
+ previousMarker = current;
81
+ }
82
+ else if (type === 'neighborhoodchange') {
83
+ const current = element.getSelection();
84
+ detail = { kind: 'neighborhood', current, previous: previousNeighborhood };
85
+ previousNeighborhood = current;
86
+ }
87
+ else {
88
+ const current = element.getSelectedDistrict();
89
+ detail = { kind: 'district', current, previous: previousDistrict };
90
+ previousDistrict = current;
91
+ }
92
+ element.dispatchEvent(new CustomEvent('selectionchange', { detail: structuredClone(detail) }));
93
+ };
94
+ element.addEventListener(type, handle);
95
+ selectionListeners.push(() => element.removeEventListener(type, handle));
96
+ }
21
97
  let destroyed = false;
22
98
  function active() {
23
99
  if (destroyed)
@@ -41,6 +117,28 @@ export function createMap(data, options = {}) {
41
117
  element.fitGeometry(geometry, padding, motion);
42
118
  }),
43
119
  });
120
+ function configure(patch) {
121
+ const revision = ++configurationRevision;
122
+ const next = prepareConfiguration(config, patch);
123
+ const hasFeatures = 'features' in patch, hasLayers = 'layers' in patch, hasControls = 'controls' in patch;
124
+ const layers = Object.fromEntries(layerKeys.map((key) => [key, next.layers[key]]));
125
+ const controls = Object.fromEntries(controlKeys.map((key) => [key, next.controls[key]]));
126
+ const features = patch.features === undefined
127
+ ? next.features
128
+ : Object.fromEntries(Object.keys(patch.features).map((key) => [
129
+ key,
130
+ next.features[key],
131
+ ]));
132
+ element.applyPresentation({ ...patch, ...('appearance' in patch ? { appearance: next.appearance } : {}) }, () => {
133
+ config = next;
134
+ if (hasFeatures)
135
+ element.setFeatures(features);
136
+ if (hasLayers)
137
+ element.setLayers(layers);
138
+ if (hasControls)
139
+ element.setControls(controls);
140
+ }, () => !destroyed && revision === configurationRevision);
141
+ }
44
142
  return Object.freeze({
45
143
  element,
46
144
  overlayElement: element.overlayElement,
@@ -48,22 +146,13 @@ export function createMap(data, options = {}) {
48
146
  get destroyed() {
49
147
  return destroyed;
50
148
  },
51
- configure: use((patch) => {
52
- const next = prepareConfiguration(config, patch);
53
- // Full switch patches are needed so removed overrides reset engine defaults.
54
- const layers = Object.fromEntries(layerKeys.map((key) => [key, next.layers[key]]));
55
- const controls = Object.fromEntries(controlKeys.map((key) => [key, next.controls[key]]));
56
- if ('features' in patch)
57
- element.setFeatures(patch.features ?? next.features);
58
- if ('layers' in patch)
59
- element.setLayers(layers);
60
- if ('controls' in patch)
61
- element.setControls(controls);
62
- config = next;
63
- }),
64
- getConfiguration: use(() => structuredClone(config)),
149
+ configure: use(configure),
150
+ getConfiguration: use(snapshot),
151
+ getResolvedConfiguration: use(resolved),
152
+ getCapabilities: use(capabilities),
65
153
  on: use((type, listener) => {
66
154
  if (![
155
+ 'selectionchange',
67
156
  'markerchange',
68
157
  'districtchange',
69
158
  'districthover',
@@ -97,11 +186,25 @@ export function createMap(data, options = {}) {
97
186
  getSelectedNeighborhood: use(element.getSelection),
98
187
  selectDistrict: use(element.selectDistrict),
99
188
  getSelectedDistrict: use(element.getSelectedDistrict),
100
- setDistrictYear: use(element.setDistrictYear),
101
- setDistrictStyle: use(element.setDistrictStyle),
102
- setSource: use(element.setSource),
103
- setMode: use(element.setMode),
104
- setLabels: use(element.setLabels),
189
+ setDistrictYear: use((...args) => {
190
+ configurationRevision++;
191
+ element.setDistrictYear(...args);
192
+ }),
193
+ setDistrictStyle: use((style) => {
194
+ configure({ appearance: { districtStyle: style } });
195
+ }),
196
+ setSource: use((...args) => {
197
+ configurationRevision++;
198
+ element.setSource(...args);
199
+ }),
200
+ setMode: use((...args) => {
201
+ configurationRevision++;
202
+ element.setMode(...args);
203
+ }),
204
+ setLabels: use((...args) => {
205
+ configurationRevision++;
206
+ element.setLabels(...args);
207
+ }),
105
208
  setTouchNavigation: use(element.setTouchNavigation),
106
209
  projectToScreen: use(element.projectToScreen),
107
210
  destroy() {
@@ -110,6 +213,8 @@ export function createMap(data, options = {}) {
110
213
  destroyed = true;
111
214
  for (const unsubscribe of subscriptions)
112
215
  unsubscribe();
216
+ for (const unsubscribe of selectionListeners)
217
+ unsubscribe();
113
218
  element.destroy();
114
219
  },
115
220
  });
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  import { type SFMapData } from './map-core.js';
2
2
  import type { SFMapOptions } from './types.js';
3
3
  export type { DistrictRowData, SFMapData as StaticMapData } from './map-core.js';
4
- export type { DistrictStyle, DistrictYear, SFMapOptions as StaticMapOptions } from './types.js';
4
+ export type { DistrictStyle, DistrictYear, SFMapOptions as StaticMapOptions, StaticMapAppearance, StaticMapLayers, } from './types.js';
5
5
  /** Server-safe SVG rendering with explicit data; returns SVG plus projection helpers. */
6
6
  export declare function renderMap(data: SFMapData, options?: SFMapOptions): {
7
7
  svg: string;
@@ -1,9 +1,38 @@
1
+ import { prepareAppearance } from './appearance.js';
2
+ import { validateSwitchPatch } from './features.js';
1
3
  import { createSFMapWithData, getLayerPathsWithData } from './map-core.js';
4
+ import { assertOptions } from './validation.js';
2
5
  /** Server-safe SVG rendering with explicit data; returns SVG plus projection helpers. */
3
6
  export function renderMap(data, options = {}) {
4
- return createSFMapWithData(options, data);
7
+ return createSFMapWithData(expandStaticOptions(options), data);
5
8
  }
6
9
  /** Project canonical layer geometry without constructing an SVG string. */
7
10
  export function getLayerPaths(data, options = {}) {
8
- return getLayerPathsWithData(options, data);
11
+ return getLayerPathsWithData(expandStaticOptions(options), data);
12
+ }
13
+ /** Grouped keys override flat compatibility keys; omitted keys preserve renderer defaults. */
14
+ function expandStaticOptions(options) {
15
+ assertOptions(options, 'static map');
16
+ if (options.layers !== undefined)
17
+ validateSwitchPatch(options.layers, [
18
+ 'districtFills',
19
+ 'districtLines',
20
+ 'districtLabels',
21
+ 'neighborhoodLines',
22
+ 'landmarks',
23
+ 'bartStations',
24
+ 'highways',
25
+ 'keyRoads',
26
+ 'roadLabels',
27
+ ]);
28
+ if (options.appearance !== undefined) {
29
+ assertOptions(options.appearance, 'static appearance', ['theme', 'colors', 'districtStyle']);
30
+ }
31
+ const { layers, appearance, ...flat } = options;
32
+ const supplied = (group = {}) => Object.fromEntries(Object.entries(group).filter(([, value]) => value !== undefined));
33
+ return {
34
+ ...flat,
35
+ ...supplied(layers),
36
+ ...supplied(appearance === undefined ? undefined : prepareAppearance({}, appearance)),
37
+ };
9
38
  }
@@ -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');