@kahwee/sf-map-svg 4.1.0 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  User-visible changes are recorded here. Unreleased entries describe changes on `main` that are not part of a tagged package release.
4
4
 
5
+ ## Unreleased
6
+
7
+ ## 4.2.0 — 2026-10-01
8
+
9
+ - Reuse one native dropdown treatment across Pages, with an inset chevron, a reserved end gutter, and preserved labels, keyboard behavior, and mobile pickers.
10
+ - Strengthen the playground with strict TypeScript, checked JavaScript/TypeScript snippets, versioned design links, persistent explicit layer choices and independent static rendering. Keep mobile controls unobstructed and picker labels legible in dark mode.
11
+ - Express explicit configuration resets for consumers using `exactOptionalPropertyTypes`, including nested appearance tokens. Make Pages API and guide references describe their actual package version without internal preview warnings.
12
+
13
+ - Share grouped `layers` and supported `appearance` options between static and interactive maps, retaining flat static options and existing defaults; accept readonly static marker arrays.
14
+ - Update interactive appearance, mode, source, district year and labels through `configure`, preserving camera and retained marker nodes. Merge appearance tokens by key and validate combined patches before commit.
15
+ - Add `getResolvedConfiguration()` and `getCapabilities()` for effective layer switches and supplied geography, plus an additive `selectionchange` event with consistent current/previous snapshots.
16
+ - Add a live Pages map design playground with six editable palettes, layers, typography, sample pins/routes, browser/static previews, undo/redo, shareable designs, copyable code and full-city SVG export. Keep released-package previews compatible with their installed API.
17
+ - Refresh the Pages site: a new SF Map SVG mark traced from the real coastline, a reframed header with a release link, card-style reference tables that no longer collapse, a live filter for the API reference, an aligned playground with one-row-per-colour inks, and 12px minimum type for labels and badges.
18
+
5
19
  ## 4.1.0 — 2026-10-01
6
20
 
7
21
  - Validate canonical and generated GeoJSON during data checks, rejecting invalid WGS84 coordinates and malformed polygon rings before release.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Offline, self-contained San Francisco SVG maps. The package has no runtime dependencies. Geography is always an explicit import; the root entry does not bundle data.
4
4
 
5
- [Live examples](https://kahwee.github.io/sf-map-svg/examples.html) · [Storybook source](stories/) · [Geographic sources](SOURCES.md) · [v3 migration](docs/migration-v3.md)
5
+ [Design playground](https://kahwee.github.io/sf-map-svg/playground.html) · [Live examples](https://kahwee.github.io/sf-map-svg/examples.html) · [Storybook source](stories/) · [Geographic sources](SOURCES.md) · [v3 migration](docs/migration-v3.md)
6
6
 
7
7
  ## Install
8
8
 
@@ -52,7 +52,7 @@ map.camera.zoom(1.5);
52
52
  map.destroy();
53
53
  ```
54
54
 
55
- `createMap(data, options)` returns a controller. Use `map.element` for mounting, `map.configure({ features, layers, controls })` for runtime switches, `map.camera` for pan/zoom/fit/reset, and `map.on()` for typed events. Appearance is set at construction. SFAR realtor neighborhoods are the default when supplied; SF Find and analysis are explicit alternate sources. The `/data/full` preset includes all three collections and historical districts.
55
+ `createMap(data, options)` returns a controller. Use `map.element` for mounting, `map.configure({ features, layers, controls, appearance, mode, source, year, labels })` for runtime switches, `map.camera` for pan/zoom/fit/reset, and `map.on()` for typed events. Appearance can change live while preserving camera, selection, and focus. `getResolvedConfiguration()` explains effective layers; `getCapabilities()` reports supplied geography. SFAR realtor neighborhoods are the default when supplied; SF Find and analysis are explicit alternate sources. The `/data/full` preset includes all three collections and historical districts.
56
56
 
57
57
  See [controller options, events, and lifecycle](docs/API.md#interactive-maps),
58
58
  [examples](docs/EXAMPLES.md), and [consumer integration](docs/consumer-integration.md).
@@ -0,0 +1,5 @@
1
+ import type { MapAppearance } from './controller-types.js';
2
+ export declare const appearanceKeys: readonly ['theme', 'colors', 'labelStyle', 'areaStyle', 'districtStyle', 'labelSize', 'style', 'markerRadius', 'markerHitSize', 'markerColor', 'selectedMarkerColor'];
3
+ /** Copy configuration records without cloning or invoking styling callbacks. */
4
+ export declare function copyAppearance(value?: MapAppearance): MapAppearance;
5
+ export declare function prepareAppearance(current: MapAppearance, patch: MapAppearance | undefined): MapAppearance;
@@ -0,0 +1,54 @@
1
+ import { assertOptions, validateExplorerOptions } from './validation.js';
2
+ export const appearanceKeys = [
3
+ 'theme',
4
+ 'colors',
5
+ 'labelStyle',
6
+ 'areaStyle',
7
+ 'districtStyle',
8
+ 'labelSize',
9
+ 'style',
10
+ 'markerRadius',
11
+ 'markerHitSize',
12
+ 'markerColor',
13
+ 'selectedMarkerColor',
14
+ ];
15
+ /** Copy configuration records without cloning or invoking styling callbacks. */
16
+ export function copyAppearance(value = {}) {
17
+ const { districtStyle, ...rest } = value;
18
+ return { ...structuredClone(rest), ...(districtStyle ? { districtStyle } : {}) };
19
+ }
20
+ export function prepareAppearance(current, patch) {
21
+ if (patch === undefined)
22
+ return {};
23
+ assertOptions(patch, 'appearance', appearanceKeys);
24
+ const next = { ...current, ...patch };
25
+ for (const key of ['colors', 'labelStyle', 'areaStyle', 'labelSize', 'style']) {
26
+ if (Object.hasOwn(patch, key) && patch[key] !== undefined) {
27
+ assertOptions(patch[key], key);
28
+ const tokens = { ...current[key], ...patch[key] };
29
+ for (const name of Object.keys(tokens))
30
+ if (tokens[name] === undefined)
31
+ delete tokens[name];
32
+ Object.assign(next, { [key]: tokens });
33
+ }
34
+ }
35
+ for (const key of appearanceKeys)
36
+ if (next[key] === undefined)
37
+ delete next[key];
38
+ validateExplorerOptions(next);
39
+ if (next.theme !== undefined && !['districts', 'transit'].includes(next.theme))
40
+ throw new TypeError('Theme must be districts or transit.');
41
+ const min = next.labelSize?.min ?? 11, max = next.labelSize?.max ?? 12;
42
+ const radius = next.markerRadius ?? 6, hit = next.markerHitSize ?? 44;
43
+ if (![min, max, radius, hit].every(Number.isFinite) ||
44
+ min < 8 ||
45
+ max < min ||
46
+ max > 32 ||
47
+ radius <= 0 ||
48
+ hit < radius * 2)
49
+ throw new RangeError('Use label sizes from 8–32px and a positive marker radius inside its hit target.');
50
+ const weight = next.labelStyle?.fontWeight;
51
+ if (weight !== undefined && (!Number.isFinite(weight) || weight < 1 || weight > 1000))
52
+ throw new RangeError('Label font weight must be finite and between 1 and 1000.');
53
+ return copyAppearance(next);
54
+ }
@@ -1,18 +1,6 @@
1
+ import { appearanceKeys, copyAppearance, prepareAppearance } from './appearance.js';
1
2
  import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from './features.js';
2
3
  import { assertOptions, validateExplorerOptions } from './validation.js';
3
- const appearanceKeys = [
4
- 'theme',
5
- 'colors',
6
- 'labelStyle',
7
- 'areaStyle',
8
- 'districtStyle',
9
- 'labelSize',
10
- 'style',
11
- 'markerRadius',
12
- 'markerHitSize',
13
- 'markerColor',
14
- 'selectedMarkerColor',
15
- ];
16
4
  const featureKeys = [
17
5
  'motion',
18
6
  'markerEntrance',
@@ -48,18 +36,34 @@ export function expandMapOptions(options) {
48
36
  ? `Use ${replacement} instead of the flat ${key} option.`
49
37
  : 'interface is internal and is not a supported createMap option.');
50
38
  }
51
- if (options.appearance !== undefined)
52
- assertOptions(options.appearance, 'appearance', appearanceKeys);
39
+ const preparedAppearance = options.appearance === undefined ? undefined : prepareAppearance({}, options.appearance);
53
40
  if (options.features !== undefined)
54
41
  normalizeFeatures(options.features);
55
- const { features, appearance, ...rest } = options;
56
- const expanded = { ...rest, ...appearance, ...features };
42
+ const { features, appearance: _appearance, ...rest } = options;
43
+ const expanded = { ...rest, ...preparedAppearance, ...features };
57
44
  validateExplorerOptions(expanded);
58
45
  return expanded;
59
46
  }
60
47
  /** Prepare the entire configuration before any DOM mutation. */
61
48
  export function prepareConfiguration(current, patch) {
62
- assertOptions(patch, 'configuration', ['features', 'layers', 'controls']);
49
+ assertOptions(patch, 'configuration', [
50
+ 'features',
51
+ 'layers',
52
+ 'controls',
53
+ 'appearance',
54
+ 'mode',
55
+ 'source',
56
+ 'year',
57
+ 'labels',
58
+ ]);
59
+ if (patch.mode !== undefined && !['basemap', 'neighborhoods', 'districts'].includes(patch.mode))
60
+ throw new RangeError('Unknown map mode.');
61
+ if (patch.source !== undefined && !['realtor', 'sf-find', 'analysis'].includes(patch.source))
62
+ throw new RangeError('Unknown neighborhood source.');
63
+ if (patch.year !== undefined && ![2002, 2012, 2022].includes(patch.year))
64
+ throw new RangeError('District year must be 2002, 2012, or 2022.');
65
+ if (patch.labels !== undefined && typeof patch.labels !== 'boolean')
66
+ throw new TypeError('Labels must be a boolean.');
63
67
  const features = 'features' in patch
64
68
  ? normalizeFeatures(patch.features === undefined
65
69
  ? {}
@@ -78,11 +82,16 @@ export function prepareConfiguration(current, patch) {
78
82
  delete next[property];
79
83
  return next;
80
84
  };
81
- return structuredClone({
82
- features,
83
- layers: switches('layers', current.layers, layerKeys),
84
- controls: switches('controls', current.controls, controlKeys),
85
- });
85
+ return {
86
+ ...structuredClone({
87
+ features,
88
+ layers: switches('layers', current.layers, layerKeys),
89
+ controls: switches('controls', current.controls, controlKeys),
90
+ }),
91
+ appearance: 'appearance' in patch
92
+ ? prepareAppearance(current.appearance ?? {}, patch.appearance)
93
+ : copyAppearance(current.appearance),
94
+ };
86
95
  }
87
96
  function checkedFeatures(value) {
88
97
  normalizeFeatures(value);
@@ -1,24 +1,66 @@
1
1
  import type { Geometry, NeighborhoodSource } from '../data/types.js';
2
2
  import type { CameraOptions, DistrictSelection, DistrictStyle, DistrictYear, InteractiveLayers, MapFeatures, MapMarker, MapOverlay, MapPadding, MapViewport, NeighborhoodExplorerOptions, NeighborhoodSelection } from './types.js';
3
- export type MapAppearance = Pick<NeighborhoodExplorerOptions, 'theme' | 'colors' | 'labelStyle' | 'areaStyle' | 'districtStyle' | 'labelSize' | 'style' | 'markerRadius' | 'markerHitSize' | 'markerColor' | 'selectedMarkerColor'>;
3
+ /** Patch properties can explicitly remove overrides, including with exactOptionalPropertyTypes. */
4
+ type Resettable<T> = {
5
+ [K in keyof T]?: T[K] | undefined;
6
+ };
7
+ type AppearanceOptions = Pick<NeighborhoodExplorerOptions, 'theme' | 'colors' | 'labelStyle' | 'areaStyle' | 'districtStyle' | 'labelSize' | 'style' | 'markerRadius' | 'markerHitSize' | 'markerColor' | 'selectedMarkerColor'>;
8
+ export type MapAppearance = {
9
+ [K in keyof AppearanceOptions]?: K extends 'colors' | 'style' | 'labelStyle' | 'labelSize' | 'areaStyle' ? Resettable<NonNullable<AppearanceOptions[K]>> | undefined : AppearanceOptions[K] | undefined;
10
+ };
4
11
  export type MapControls = NonNullable<NeighborhoodExplorerOptions['controls']>;
5
- /** Omitted groups/keys retain their values; undefined groups reset all keys in that group. */
12
+ /** Omitted keys retain values. Undefined presentation groups reset; undefined mode/source/year/labels retain their current value. */
6
13
  export interface MapConfiguration {
7
- features?: MapFeatures;
8
- layers?: InteractiveLayers;
9
- controls?: MapControls;
14
+ features?: Resettable<MapFeatures> | undefined;
15
+ layers?: Resettable<InteractiveLayers> | undefined;
16
+ controls?: Resettable<MapControls> | undefined;
17
+ appearance?: MapAppearance | undefined;
18
+ mode?: NonNullable<NeighborhoodExplorerOptions['mode']> | undefined;
19
+ source?: NeighborhoodSource | undefined;
20
+ year?: DistrictYear | undefined;
21
+ labels?: boolean | undefined;
10
22
  }
11
23
  export interface MapOptions extends Omit<NeighborhoodExplorerOptions, keyof MapFeatures | keyof MapAppearance | 'interface' | 'onMarkerActivate' | 'onOverlayActivate'> {
12
24
  features?: MapFeatures;
13
- appearance?: MapAppearance;
25
+ appearance?: MapAppearance | undefined;
14
26
  }
15
27
  export interface MapConfigurationSnapshot {
16
28
  features: MapFeatures;
17
29
  /** Explicit overrides; absent values continue to follow the current mode. */
18
30
  layers: InteractiveLayers;
19
31
  controls: MapControls;
32
+ appearance: MapAppearance;
33
+ }
34
+ export interface MapCapabilities {
35
+ sources: readonly NeighborhoodSource[];
36
+ years: readonly DistrictYear[];
37
+ /** Data availability, independent of requested layer visibility. */
38
+ layers: Readonly<Record<keyof InteractiveLayers, boolean>>;
39
+ }
40
+ export interface ResolvedMapConfiguration extends MapConfigurationSnapshot {
41
+ mode: NonNullable<MapOptions['mode']>;
42
+ source: NeighborhoodSource;
43
+ year: DistrictYear;
44
+ labels: boolean;
45
+ /** Effective switches after mode defaults and data availability; label switches honor labels. */
46
+ layers: Record<keyof InteractiveLayers, boolean>;
20
47
  }
48
+ export type MapSelectionChange = {
49
+ kind: 'marker';
50
+ current: MapMarker | null;
51
+ previous: MapMarker | null;
52
+ } | {
53
+ kind: 'neighborhood';
54
+ current: NeighborhoodSelection | null;
55
+ previous: NeighborhoodSelection | null;
56
+ } | {
57
+ kind: 'district';
58
+ current: DistrictSelection | null;
59
+ previous: DistrictSelection | null;
60
+ };
21
61
  export interface MapEvents {
62
+ /** Common selection envelope; existing change events retain their payloads. */
63
+ selectionchange: MapSelectionChange;
22
64
  districtchange: DistrictSelection | {
23
65
  id: null;
24
66
  year: DistrictYear;
@@ -56,11 +98,13 @@ export interface MapEvents {
56
98
  mapresize: undefined;
57
99
  }
58
100
  export interface MapCamera {
101
+ /** Fixed 800 × 800 projected space, not longitude/latitude. */
59
102
  get(): MapViewport;
60
103
  set(view: MapViewport, options?: CameraOptions): void;
61
104
  fit(geometry: Geometry, options?: CameraOptions & {
62
105
  padding?: number | MapPadding;
63
106
  }): void;
107
+ /** Delta in projected map units. */
64
108
  pan(x: number, y: number, options?: CameraOptions): void;
65
109
  zoom(factor: number, options?: CameraOptions): void;
66
110
  reset(options?: CameraOptions): void;
@@ -74,6 +118,8 @@ export interface MapController {
74
118
  readonly destroyed: boolean;
75
119
  configure(patch: MapConfiguration): void;
76
120
  getConfiguration(): MapConfigurationSnapshot;
121
+ getResolvedConfiguration(): ResolvedMapConfiguration;
122
+ getCapabilities(): MapCapabilities;
77
123
  on<K extends keyof MapEvents>(type: K, listener: (detail: MapEvents[K]) => void): () => void;
78
124
  /** Reconcile stable IDs; retained markers preserve nodes, focus, and entrance animations. */
79
125
  setMarkers(markers: readonly MapMarker[]): void;
@@ -104,3 +150,4 @@ export interface MapController {
104
150
  /** Idempotent; stops work and managed subscriptions. Does not remove host-owned DOM. */
105
151
  destroy(): void;
106
152
  }
153
+ export {};
@@ -1,5 +1,17 @@
1
+ import type { NeighborhoodSource } from '../data/types.js';
2
+ import type { MapConfiguration } from './controller-types.js';
1
3
  import type { InteractiveSFMapData } from './explorer-data.js';
2
- import type { NeighborhoodExplorerElement, NeighborhoodExplorerOptions } from './types.js';
4
+ import type { DistrictYear, ExplorerMode, NeighborhoodExplorerElement, NeighborhoodExplorerOptions } from './types.js';
3
5
  export type { ExplorerMode, NeighborhoodExplorerElement, NeighborhoodExplorerOptions, } from './types.js';
6
+ /** Private bridge between the renderer and its public lifecycle controller. */
7
+ type MapEngineElement = NeighborhoodExplorerElement & {
8
+ applyPresentation(patch: MapConfiguration, onCommit?: () => void, isCurrent?: () => boolean): boolean;
9
+ getMapState(): {
10
+ mode: ExplorerMode;
11
+ source: NeighborhoodSource;
12
+ year: DistrictYear;
13
+ labels: boolean;
14
+ };
15
+ };
4
16
  /** Create an offline, browser-only neighborhood explorer. Call destroy() before disposal. */
5
- export declare function createNeighborhoodExplorerCore(options: NeighborhoodExplorerOptions | undefined, data: InteractiveSFMapData): NeighborhoodExplorerElement;
17
+ export declare function createNeighborhoodExplorerCore(options: NeighborhoodExplorerOptions | undefined, data: InteractiveSFMapData): MapEngineElement;
@@ -1,3 +1,4 @@
1
+ import { copyAppearance } from './appearance.js';
1
2
  import { createCamera, validateCameraOptions } from './camera.js';
2
3
  import { clusterPoints } from './clusters.js';
3
4
  import { renderDistrictAppearance } from './district-layer.js';
@@ -11,7 +12,7 @@ import { createFrameScheduler } from './frame-scheduler.js';
11
12
  import { geometryPath } from './geometry.js';
12
13
  import { createLabelRenderer } from './label-renderer.js';
13
14
  import { createLayerTransitions } from './layer-transition.js';
14
- import { createSFMapWithData, districtColors, getLayerPathsWithData } from './map-core.js';
15
+ import { createSFMapWithData, districtColors, getLayerPathsWithData, resolveMapColors, } from './map-core.js';
15
16
  import { createMarkerLayer } from './marker-layer.js';
16
17
  import { attachNavigation } from './navigation.js';
17
18
  import { validateExplorerOptions, validateMarkers, validateOverlays } from './validation.js';
@@ -37,7 +38,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
37
38
  throw new RangeError(`Unknown map mode: ${mode}`);
38
39
  if (chrome !== 'explorer' && chrome !== 'map')
39
40
  throw new RangeError('Unknown interface.');
40
- const minLabelSize = labelSize.min ?? 11, maxLabelSize = labelSize.max ?? 12;
41
+ let minLabelSize = labelSize.min ?? 11, maxLabelSize = labelSize.max ?? 12;
41
42
  if (![minLabelSize, maxLabelSize, markerRadius, markerHitSize].every(Number.isFinite) ||
42
43
  minLabelSize < 8 ||
43
44
  maxLabelSize < minLabelSize ||
@@ -464,7 +465,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
464
465
  districtTransition.fade(fills, fade);
465
466
  updateDistrictAppearance();
466
467
  }
467
- function setDistrictYear(next, options = {}) {
468
+ function setDistrictYear(next, options = {}, preparedStyles) {
468
469
  if (destroyed)
469
470
  return;
470
471
  validateCameraOptions(options);
@@ -475,7 +476,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
475
476
  if (next === year)
476
477
  return;
477
478
  const revision = districtRevision;
478
- const prepared = prepareDistrictStyles(data.map.districts[next], districtStyle);
479
+ const prepared = preparedStyles ?? prepareDistrictStyles(data.map.districts[next], districtStyle);
479
480
  if (destroyed || revision !== districtRevision)
480
481
  return;
481
482
  const committedRevision = ++districtRevision;
@@ -1118,9 +1119,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1118
1119
  moveView(target, options);
1119
1120
  return true;
1120
1121
  }
1121
- function setSource(next) {
1122
- if (destroyed)
1123
- return;
1122
+ function prepareSource(next, reset = true) {
1124
1123
  if (!Object.hasOwn(collections, next))
1125
1124
  throw new RangeError(`Unknown neighborhood source: ${next}`);
1126
1125
  const collection = collections[next];
@@ -1191,36 +1190,42 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1191
1190
  area: (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]),
1192
1191
  };
1193
1192
  });
1194
- const hadSelection = !!selected;
1195
- source = next;
1196
- sourceSelect.value = source;
1197
- selected = undefined;
1198
- const revision = ++neighborhoodRevision;
1199
- delete root.dataset.selectedNeighborhood;
1200
- root.dataset.source = source;
1201
- layerFades.crossfade(areas, () => areas.replaceChildren(...nextItems.map((item) => item.node)));
1202
- neighborhoodSelect.replaceChildren(element('option', 'No neighborhood selected'));
1203
- neighborhoodSelect.options[0].value = '';
1204
- neighborhoodSelect.append(...nextOptions);
1205
- items = nextItems;
1206
- if (items[0])
1207
- items[0].node.setAttribute('tabindex', '0');
1208
- updateResults();
1209
- updateDetail();
1210
- updateComposition();
1211
- status.textContent =
1212
- mode === 'basemap'
1213
- ? 'San Francisco basemap.'
1214
- : mode === 'districts'
1215
- ? `${year} supervisorial districts. Numbers identify each district.`
1216
- : `${formatSourceLabel(source)}. Select a neighborhood to begin.`;
1217
- if (hadSelection)
1218
- root.dispatchEvent(new CustomEvent('neighborhoodchange', {
1219
- bubbles: true,
1220
- detail: { feature: null, source, id: null, name: null },
1221
- }));
1222
- if (!destroyed && neighborhoodRevision === revision)
1223
- resetView();
1193
+ return () => {
1194
+ const hadSelection = !!selected;
1195
+ source = next;
1196
+ sourceSelect.value = source;
1197
+ selected = undefined;
1198
+ const revision = ++neighborhoodRevision;
1199
+ delete root.dataset.selectedNeighborhood;
1200
+ root.dataset.source = source;
1201
+ layerFades.crossfade(areas, () => areas.replaceChildren(...nextItems.map((item) => item.node)));
1202
+ neighborhoodSelect.replaceChildren(element('option', 'No neighborhood selected'));
1203
+ neighborhoodSelect.options[0].value = '';
1204
+ neighborhoodSelect.append(...nextOptions);
1205
+ items = nextItems;
1206
+ if (items[0])
1207
+ items[0].node.setAttribute('tabindex', '0');
1208
+ updateResults();
1209
+ updateDetail();
1210
+ updateComposition();
1211
+ status.textContent =
1212
+ mode === 'basemap'
1213
+ ? 'San Francisco basemap.'
1214
+ : mode === 'districts'
1215
+ ? `${year} supervisorial districts. Numbers identify each district.`
1216
+ : `${formatSourceLabel(source)}. Select a neighborhood to begin.`;
1217
+ if (hadSelection)
1218
+ root.dispatchEvent(new CustomEvent('neighborhoodchange', {
1219
+ bubbles: true,
1220
+ detail: { feature: null, source, id: null, name: null },
1221
+ }));
1222
+ if (reset && !destroyed && neighborhoodRevision === revision)
1223
+ resetView();
1224
+ };
1225
+ }
1226
+ function setSource(next) {
1227
+ if (!destroyed)
1228
+ prepareSource(next)();
1224
1229
  }
1225
1230
  function syncFeatureControls() {
1226
1231
  featureControls.hidden = neighborhoodLabel.hidden && markerLabel.hidden;
@@ -1653,7 +1658,133 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1653
1658
  scheduleLabels();
1654
1659
  root.dispatchEvent(new CustomEvent('mapresize', { bubbles: true }));
1655
1660
  };
1661
+ let presentationRevision = 0;
1662
+ function applyPresentation(patch, onCommit, isCurrent = () => true) {
1663
+ const revision = ++presentationRevision;
1664
+ const nextYear = patch.year ?? year;
1665
+ const nextMode = patch.mode ?? mode;
1666
+ if ((patch.year !== undefined || nextMode === 'districts') &&
1667
+ (!data.map.districts?.[nextYear] || !data.districts?.[nextYear]))
1668
+ throw new RangeError(`No ${nextYear} district dataset was supplied.`);
1669
+ if (nextMode === 'neighborhoods' && !sources.length)
1670
+ throw new RangeError('No neighborhood dataset was supplied.');
1671
+ const commitSource = patch.source !== undefined && patch.source !== source
1672
+ ? prepareSource(patch.source, false)
1673
+ : undefined;
1674
+ const appearance = patch.appearance;
1675
+ const nextStyle = appearance ? appearance.districtStyle : districtStyle;
1676
+ const districtVersion = districtRevision;
1677
+ const prepared = appearance || nextYear !== year
1678
+ ? prepareDistrictStyles(data.map.districts?.[nextYear] ?? [], nextStyle)
1679
+ : districtStyles;
1680
+ if (destroyed ||
1681
+ !isCurrent() ||
1682
+ revision !== presentationRevision ||
1683
+ districtVersion !== districtRevision)
1684
+ return false;
1685
+ if (nextYear !== year) {
1686
+ // Validate target paths and label coordinates before any presentation group commits.
1687
+ getLayerPathsWithData({ year: nextYear }, data.map, false);
1688
+ for (const feature of data.districts?.[nextYear]?.features ?? [])
1689
+ for (const point of feature.properties.labelPoints)
1690
+ map.project(point);
1691
+ }
1692
+ onCommit?.();
1693
+ if (appearance) {
1694
+ districtRevision++;
1695
+ const next = copyAppearance(appearance);
1696
+ theme = next.theme ?? 'transit';
1697
+ colors = next.colors ?? {};
1698
+ labelStyle = next.labelStyle ?? {};
1699
+ areaStyle = next.areaStyle ?? {};
1700
+ styleOptions = next.style ?? {};
1701
+ minLabelSize = next.labelSize?.min ?? 11;
1702
+ maxLabelSize = next.labelSize?.max ?? 12;
1703
+ markerRadius = next.markerRadius ?? 6;
1704
+ markerHitSize = next.markerHitSize ?? 44;
1705
+ markerColor = next.markerColor ?? colors.marker ?? '#245b61';
1706
+ selectedMarkerColor = next.selectedMarkerColor ?? colors.selected ?? '#f04f32';
1707
+ districtTransition.cancel();
1708
+ const fills = districtLayers.find((layer) => layer.dataset.layer === 'district-fills');
1709
+ const fade = layerFades.duration();
1710
+ const stylesChanged = nextStyle !== districtStyle ||
1711
+ [...prepared].some(([id, value]) => ['fill', 'stroke', 'opacity'].some((key) => value[key] !== districtStyles.get(id)?.[key]));
1712
+ if (fills && fade && enabled('districtFills') && nextYear === year && stylesChanged)
1713
+ districtTransition.fade(fills, fade);
1714
+ districtStyle = nextStyle;
1715
+ districtStyles = prepared;
1716
+ const palette = resolveMapColors(theme, colors);
1717
+ svg.querySelector('rect')?.setAttribute('fill', palette.water);
1718
+ svg.querySelector('[data-layer="coast"]')?.setAttribute('fill', palette.land);
1719
+ svg.querySelector('[data-layer="coastline"]')?.setAttribute('stroke', palette.district);
1720
+ for (const [layer, color] of [
1721
+ ['landmarks', palette.park],
1722
+ ['highways', palette.highway],
1723
+ ['key-roads', palette.road],
1724
+ ])
1725
+ for (const node of svg.querySelectorAll(`[data-layer="${layer}"] path`)) {
1726
+ node.setAttribute(layer === 'landmarks' ? 'fill' : 'stroke', color);
1727
+ if (layer === 'highways')
1728
+ node.setAttribute('stroke-width', theme === 'transit' ? '2' : '1.4');
1729
+ }
1730
+ for (const [token, fallback, name] of [
1731
+ ['ink', '#18364f', 'ink'],
1732
+ ['surface', '#fff', 'surface'],
1733
+ ['accent', '#163d61', 'accent'],
1734
+ ['border', '#cedae3', 'border'],
1735
+ ['focus', '#1676b8', 'focus'],
1736
+ ['controlGap', '6px', 'control-gap'],
1737
+ ['font', 'system-ui,sans-serif', 'font'],
1738
+ ])
1739
+ root.style.setProperty(`--sf-map-${name}`, styleOptions[token] ?? fallback);
1740
+ labelLayer.setAttribute('font-family', labelStyle.fontFamily ?? styleOptions.font ?? 'system-ui,sans-serif');
1741
+ labelRenderer.invalidateMetrics();
1742
+ for (const station of stationItems)
1743
+ station.node.setAttribute('stroke', colors.bart ?? '#0073ae');
1744
+ for (const kind of ['bart', 'park', 'highway', 'road']) {
1745
+ const symbol = legend.querySelector(`.sf-explorer-legend-${kind}`);
1746
+ if (symbol) {
1747
+ symbol.style.removeProperty(kind === 'bart' ? 'border-color' : 'background-color');
1748
+ if (colors[kind])
1749
+ symbol.style.setProperty(kind === 'bart' ? 'border-color' : 'background-color', colors[kind]);
1750
+ }
1751
+ }
1752
+ for (const item of items) {
1753
+ const active = item.feature === selected;
1754
+ item.node.setAttribute('fill', active ? (areaStyle.selectedFill ?? '#408dbe') : 'transparent');
1755
+ item.node.setAttribute('stroke', active ? (areaStyle.selectedStroke ?? '#176ba2') : (colors.neighborhood ?? '#9caebc'));
1756
+ }
1757
+ for (const item of markerItems) {
1758
+ item.dot.setAttribute('fill', item.marker.id === selectedMarker
1759
+ ? selectedMarkerColor
1760
+ : (item.marker.color ?? markerColor));
1761
+ item.ring.setAttribute('stroke', features.selectedMarkerRing
1762
+ ? (features.selectedMarkerRing.color ?? selectedMarkerColor)
1763
+ : selectedMarkerColor);
1764
+ }
1765
+ invalidateClusters();
1766
+ updateDistrictAppearance();
1767
+ }
1768
+ if (nextYear !== year)
1769
+ setDistrictYear(nextYear, {}, prepared);
1770
+ if (destroyed || revision !== presentationRevision || !isCurrent())
1771
+ return false;
1772
+ commitSource?.();
1773
+ if (destroyed || revision !== presentationRevision || !isCurrent())
1774
+ return false;
1775
+ if (commitSource && appearance)
1776
+ for (const item of items)
1777
+ item.node.setAttribute('stroke', colors.neighborhood ?? '#9caebc');
1778
+ if (patch.mode !== undefined)
1779
+ setMode(patch.mode, false);
1780
+ if (patch.labels !== undefined)
1781
+ setLabels(patch.labels);
1782
+ scheduleLabels();
1783
+ return true;
1784
+ }
1656
1785
  const explorer = Object.assign(root, {
1786
+ applyPresentation,
1787
+ getMapState: () => ({ mode, source, year, labels }),
1657
1788
  selectNeighborhood,
1658
1789
  getSelection,
1659
1790
  selectDistrict,
@@ -6,7 +6,7 @@ import { guideOptions } from './presets.js';
6
6
  import { validateExplorerOptions } from './validation.js';
7
7
  function controllerOptions(options) {
8
8
  expandMapOptions(options);
9
- const config = prepareConfiguration({ features: {}, layers: guideOptions.layers ?? {}, controls: {} }, {
9
+ const config = prepareConfiguration({ features: {}, layers: guideOptions.layers ?? {}, controls: {}, appearance: {} }, {
10
10
  features: options.features,
11
11
  layers: options.layers === undefined ? {} : options.layers,
12
12
  controls: options.controls,
@@ -1,9 +1,9 @@
1
1
  import { guideMapData } from './guide-data.js';
2
2
  import { guideShellCSS } from './guide-shell.js';
3
- import { createSFMapWithData } from './map-core.js';
3
+ import { renderMap } from './static.js';
4
4
  /** Server-safe overview using exactly the guide browser geography. */
5
5
  export function createGuideSVG(options = {}) {
6
- return createSFMapWithData({
6
+ return renderMap(guideMapData.map, {
7
7
  theme: 'transit',
8
8
  districtFills: false,
9
9
  districtLines: false,
@@ -14,7 +14,7 @@ export function createGuideSVG(options = {}) {
14
14
  keyRoads: true,
15
15
  bartStations: true,
16
16
  ...options,
17
- }, guideMapData.map);
17
+ });
18
18
  }
19
19
  /** Stable compact frame, progressively enhanced with mountGuideMap(). No browser globals. */
20
20
  export function createGuideShell(options = {}) {
@@ -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;
@@ -104,6 +104,9 @@ const transitColors = {
104
104
  marker: '#0073ae',
105
105
  selected: '#0073ae',
106
106
  };
107
+ export function resolveMapColors(theme, colors = {}) {
108
+ return { ...defaults, ...(theme === 'transit' ? transitColors : {}), ...colors };
109
+ }
107
110
  let sequence = 0;
108
111
  /** Make an offline SVG and the matching longitude/latitude projection. */
109
112
  export function createSFMapWithData(options, data) {
@@ -115,7 +118,7 @@ export function createSFMapWithData(options, data) {
115
118
  throw new TypeError('idPrefix must start with a letter and contain only letters, numbers, underscores, or hyphens.');
116
119
  if (theme !== 'districts' && theme !== 'transit')
117
120
  throw new TypeError('Theme must be districts or transit.');
118
- const colors = { ...defaults, ...(theme === 'transit' ? transitColors : {}), ...options.colors };
121
+ const colors = resolveMapColors(theme, options.colors);
119
122
  const animation = normalizeStaticAnimation(options.animation);
120
123
  const animated = animation !== null;
121
124
  const geometry = getLayerPathsWithData(options, data, false);