@kahwee/sf-map-svg 3.0.0 → 3.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.
@@ -1,4 +1,5 @@
1
- import { districtMaps, neighborhoodCollections } from '../data/index.js';
1
+ import { districtMaps } from '../data/districts.js';
2
+ import { neighborhoodCollections } from '../data/lookup.js';
2
3
  import { deepFreeze } from './immutable.js';
3
4
  import { staticMapData } from './static-data.js';
4
5
  /** Complete, explicit geographic preset. Import this only when all datasets are needed. */
@@ -1,5 +1,10 @@
1
+ import type { MapController, MapOptions } from './controller-types.js';
1
2
  import type { NeighborhoodExplorerElement as InteractiveSFMapElement, NeighborhoodExplorerOptions as InteractiveSFMapOptions } from './types.js';
2
- /** Guide map preset: SFAR neighborhoods, major parks, BART, and curated roads. */
3
+ /** Preferred guide API: the same grouped options, events, camera and lifecycle as createMap. */
4
+ export declare function createGuideController(options?: MapOptions): MapController;
5
+ /** Enhance a static guide shell and return its owning controller. */
6
+ export declare function mountGuideController(shell: HTMLElement, options?: MapOptions): MapController;
7
+ /** Element-based compatibility API. Prefer createGuideController for new integrations. */
3
8
  export declare function createGuideMap(options?: InteractiveSFMapOptions): InteractiveSFMapElement;
4
9
  /** Enhance createGuideShell() in place with a fixed compact chrome layout.
5
10
  * Keep an accessible external place list when hiding the native pickers.
@@ -1,8 +1,64 @@
1
+ import { expandMapOptions, prepareConfiguration } from './configuration.js';
1
2
  import { createNeighborhoodExplorerCore } from './explorer-core.js';
2
3
  import { guideMapData } from './guide-data.js';
4
+ import { createMap } from './map.js';
3
5
  import { guideOptions } from './presets.js';
4
6
  import { validateExplorerOptions } from './validation.js';
5
- /** Guide map preset: SFAR neighborhoods, major parks, BART, and curated roads. */
7
+ function controllerOptions(options) {
8
+ expandMapOptions(options);
9
+ const config = prepareConfiguration({ features: {}, layers: guideOptions.layers ?? {}, controls: {} }, {
10
+ features: options.features,
11
+ layers: options.layers === undefined ? {} : options.layers,
12
+ controls: options.controls,
13
+ });
14
+ return {
15
+ ...guideOptions,
16
+ ...options,
17
+ mode: options.mode === undefined ? guideOptions.mode : options.mode,
18
+ layers: config.layers,
19
+ };
20
+ }
21
+ /** Preferred guide API: the same grouped options, events, camera and lifecycle as createMap. */
22
+ export function createGuideController(options = {}) {
23
+ return createMap(guideMapData, controllerOptions(options));
24
+ }
25
+ /** Enhance a static guide shell and return its owning controller. */
26
+ export function mountGuideController(shell, options = {}) {
27
+ const prepared = controllerOptions(options);
28
+ const frame = shellFrame(shell, prepared.attribution);
29
+ const map = createMap(guideMapData, {
30
+ ...prepared,
31
+ attribution: 'compact',
32
+ controls: { ...shellControls, ...prepared.controls },
33
+ });
34
+ mountFrame(frame, map.element);
35
+ return map;
36
+ }
37
+ const shellControls = {
38
+ pan: false,
39
+ labels: false,
40
+ neighborhoodPicker: false,
41
+ markerPicker: false,
42
+ help: false,
43
+ status: false,
44
+ legend: true,
45
+ };
46
+ function shellFrame(shell, attribution) {
47
+ if (attribution === 'full')
48
+ throw new TypeError('The guide shell requires compact attribution; use createGuideController for full attribution.');
49
+ if (!shell.classList.contains('sf-guide-shell'))
50
+ throw new TypeError('Expected a createGuideShell container.');
51
+ const frame = shell.querySelector(':scope > .sf-guide-frame');
52
+ if (!frame || frame.classList.contains('sf-explorer'))
53
+ throw new Error('Shell is missing its static frame or already mounted.');
54
+ return frame;
55
+ }
56
+ function mountFrame(frame, map) {
57
+ map.classList.add('sf-guide-frame');
58
+ map.querySelector('.sf-explorer-map-column > details')?.classList.add('sf-guide-sources');
59
+ frame.replaceWith(map);
60
+ }
61
+ /** Element-based compatibility API. Prefer createGuideController for new integrations. */
6
62
  export function createGuideMap(options = {}) {
7
63
  validateExplorerOptions(options);
8
64
  return createNeighborhoodExplorerCore({
@@ -20,29 +76,15 @@ export function createGuideMap(options = {}) {
20
76
  */
21
77
  export function mountGuideMap(shell, options = {}) {
22
78
  validateExplorerOptions(options);
23
- if (options.attribution === 'full')
24
- throw new TypeError('The guide shell requires compact attribution; use createGuideMap for full attribution.');
25
- if (!shell.classList.contains('sf-guide-shell'))
26
- throw new TypeError('Expected a createGuideShell container.');
27
- const frame = shell.querySelector(':scope > .sf-guide-frame');
28
- if (!frame || frame.classList.contains('sf-explorer'))
29
- throw new Error('Shell is missing its static frame or already mounted.');
79
+ const frame = shellFrame(shell, options.attribution);
30
80
  const map = createGuideMap({
31
81
  ...options,
32
82
  attribution: 'compact',
33
83
  controls: {
34
- pan: false,
35
- labels: false,
36
- neighborhoodPicker: false,
37
- markerPicker: false,
38
- help: false,
39
- status: false,
40
- legend: true,
84
+ ...shellControls,
41
85
  ...options.controls,
42
86
  },
43
87
  });
44
- map.classList.add('sf-guide-frame');
45
- map.querySelector('.sf-explorer-map-column > details')?.classList.add('sf-guide-sources');
46
- frame.replaceWith(map);
88
+ mountFrame(frame, map);
47
89
  return map;
48
90
  }
@@ -1,5 +1,6 @@
1
+ export type { MapController, MapOptions } from './controller-types.js';
1
2
  export type { InteractiveSFMapData } from './explorer-data.js';
2
3
  export { guideMapData } from './guide-data.js';
3
4
  export { loadGuideDetailedData } from './guide-detailed.js';
4
- export { createGuideMap, mountGuideMap } from './guide-map.js';
5
+ export { createGuideController, createGuideMap, mountGuideController, mountGuideMap, } from './guide-map.js';
5
6
  export type { CameraOptions, MapFeatures, MapMarker, NeighborhoodExplorerElement as InteractiveSFMapElement, NeighborhoodExplorerOptions as InteractiveSFMapOptions, } from './types.js';
package/dist/src/guide.js CHANGED
@@ -1,3 +1,3 @@
1
1
  export { guideMapData } from './guide-data.js';
2
2
  export { loadGuideDetailedData } from './guide-detailed.js';
3
- export { createGuideMap, mountGuideMap } from './guide-map.js';
3
+ export { createGuideController, createGuideMap, mountGuideController, mountGuideMap, } from './guide-map.js';
@@ -0,0 +1,17 @@
1
+ import type { Bounds } from '../data/types.js';
2
+ export interface RenderLabel {
3
+ point: [number, number];
4
+ name: string;
5
+ kind: string;
6
+ fontSize: number;
7
+ fontWeight: number;
8
+ fill: string;
9
+ halo: string;
10
+ offset: number;
11
+ }
12
+ /** Reuse text nodes and screen-space metrics throughout a camera animation. */
13
+ export declare function createLabelRenderer(layer: SVGGElement): {
14
+ invalidateMetrics(): void;
15
+ clear(): void;
16
+ draw(labels: RenderLabel[], view: readonly number[], width: number, obstacles: Bounds[]): number;
17
+ };
@@ -0,0 +1,81 @@
1
+ import { layoutLabels } from './explorer-layout.js';
2
+ /** Reuse text nodes and screen-space metrics throughout a camera animation. */
3
+ export function createLabelRenderer(layer) {
4
+ const entries = new Map();
5
+ return {
6
+ invalidateMetrics() {
7
+ for (const entry of entries.values())
8
+ entry.width = undefined;
9
+ },
10
+ clear() {
11
+ entries.clear();
12
+ layer.replaceChildren();
13
+ },
14
+ draw(labels, view, width, obstacles) {
15
+ const unit = view[2] / width;
16
+ const keys = new Set();
17
+ const pending = labels.map((label) => {
18
+ const key = JSON.stringify([
19
+ label.kind,
20
+ label.name,
21
+ label.point,
22
+ label.fontSize,
23
+ label.fontWeight,
24
+ ]);
25
+ keys.add(key);
26
+ let entry = entries.get(key);
27
+ if (!entry) {
28
+ const node = document.createElementNS('http://www.w3.org/2000/svg', 'text');
29
+ node.textContent = label.name;
30
+ node.setAttribute('stroke-linejoin', 'round');
31
+ node.setAttribute('paint-order', 'stroke');
32
+ node.setAttribute('data-label-kind', label.kind);
33
+ entry = { node };
34
+ entries.set(key, entry);
35
+ }
36
+ const { node } = entry;
37
+ node.setAttribute('font-size', String(label.fontSize * unit));
38
+ node.setAttribute('font-weight', String(label.fontWeight));
39
+ node.setAttribute('fill', label.fill);
40
+ node.setAttribute('stroke', label.halo);
41
+ node.setAttribute('stroke-width', String(3 * unit));
42
+ // Batch writes before measuring new labels. Hidden candidates stay detached.
43
+ if (entry.width === undefined && !node.parentNode)
44
+ layer.append(node);
45
+ return { label, entry };
46
+ });
47
+ for (const [key, entry] of entries) {
48
+ if (!keys.has(key)) {
49
+ entry.node.remove();
50
+ entries.delete(key);
51
+ }
52
+ }
53
+ const measured = pending.map(({ label, entry }) => {
54
+ entry.width ??= entry.node.getComputedTextLength() / unit;
55
+ return {
56
+ ...label,
57
+ node: entry.node,
58
+ x: (label.point[0] - view[0]) / unit,
59
+ y: (label.point[1] - view[1]) / unit,
60
+ textWidth: entry.width,
61
+ textHeight: label.fontSize * 1.25,
62
+ };
63
+ });
64
+ const placed = layoutLabels(measured, width, width, obstacles);
65
+ const visible = new Set(placed.map(({ node }) => node));
66
+ for (const { node } of measured)
67
+ if (!visible.has(node))
68
+ node.remove();
69
+ let previous = null;
70
+ for (const item of placed) {
71
+ item.node.setAttribute('x', String(view[0] + item.left * unit));
72
+ item.node.setAttribute('y', String(view[1] + (item.top + item.fontSize) * unit));
73
+ const next = previous ? previous.nextSibling : layer.firstChild;
74
+ if (next !== item.node)
75
+ layer.insertBefore(item.node, next);
76
+ previous = item.node;
77
+ }
78
+ return placed.length;
79
+ },
80
+ };
81
+ }
@@ -1,4 +1,5 @@
1
1
  import { validateMarkers, validateOverlays } from './validation.js';
2
+ import { prepareDistrictStyles } from './district-style.js';
2
3
  import { geometryPath, positions, rawProject } from './geometry.js';
3
4
  import * as layers from './layers.js';
4
5
  import { escapeXml, stroke } from './svg.js';
@@ -48,12 +49,11 @@ export function getLayerPathsWithData(options, data, complete = true) {
48
49
  coast: path(data.coast),
49
50
  districts: (complete || options.districtFills !== false || options.districtLines !== false
50
51
  ? (data.districts?.[year] ?? [])
51
- : []).map((district) => ({
52
- id: district.id,
53
- geometry: path(district.geometry),
54
- extras: path(district.extras),
55
- path: path(district.geometry) + path(district.extras),
56
- })),
52
+ : []).map((district) => {
53
+ const geometry = path(district.geometry);
54
+ const extras = path(district.extras);
55
+ return { id: district.id, geometry, extras, path: geometry + extras };
56
+ }),
57
57
  neighborhoods: (complete ? (data.neighborhoods ?? []) : []).map((item) => ({
58
58
  name: item.name,
59
59
  path: path(item.geometry),
@@ -124,9 +124,10 @@ export function createSFMapWithData(options, data) {
124
124
  const roadData = data.keyRoads ?? [];
125
125
  const stationData = data.bartStations ?? [];
126
126
  const context = { project, path, colors, idPrefix, theme, labels };
127
+ const styles = prepareDistrictStyles(districtFills || districtLines ? districts : [], options.districtStyle);
127
128
  const districtPaths = districtFills || districtLines
128
129
  ? districts.map((d, index) => {
129
- const style = validateDistrictStyle(options.districtStyle?.(d));
130
+ const style = styles.get(d.id) ?? {};
130
131
  return {
131
132
  id: d.id,
132
133
  path: geometry.districts[index]?.path ?? '',
@@ -171,19 +172,3 @@ export function createSFMapWithData(options, data) {
171
172
  viewBox: [0, 0, width, height],
172
173
  };
173
174
  }
174
- function validateDistrictStyle(style) {
175
- if (style === undefined)
176
- return {};
177
- if (!style || typeof style !== 'object' || Array.isArray(style))
178
- throw new TypeError('districtStyle must return a style object.');
179
- for (const key of Object.keys(style))
180
- if (!['fill', 'stroke', 'opacity'].includes(key))
181
- throw new TypeError(`Unknown district style: ${key}`);
182
- for (const key of ['fill', 'stroke'])
183
- if (style[key] !== undefined && typeof style[key] !== 'string')
184
- throw new TypeError(`${key} must be a string.`);
185
- if (style.opacity !== undefined &&
186
- (!Number.isFinite(style.opacity) || style.opacity < 0 || style.opacity > 1))
187
- throw new RangeError('District opacity must be between 0 and 1.');
188
- return style;
189
- }
@@ -0,0 +1,22 @@
1
+ import type { normalizeFeatures } from './features.js';
2
+ import type { MapMarker } from './types.js';
3
+ export interface MarkerItem {
4
+ marker: MapMarker;
5
+ point: [number, number];
6
+ node: SVGGElement;
7
+ dot: SVGCircleElement;
8
+ hit: SVGCircleElement;
9
+ ring: SVGCircleElement;
10
+ }
11
+ /** Own marker visuals and entrances; selection/events remain with the controller. */
12
+ export declare function createMarkerLayer(layer: SVGGElement): {
13
+ cancelEntrances: () => void;
14
+ clear(): void;
15
+ add(marker: MapMarker, point: [number, number], index: number, { features, markerColor, selectedMarkerColor, reducedMotion, enter, }: {
16
+ features: ReturnType<typeof normalizeFeatures>;
17
+ markerColor: string;
18
+ selectedMarkerColor: string;
19
+ reducedMotion: boolean;
20
+ enter: boolean;
21
+ }): MarkerItem;
22
+ };
@@ -0,0 +1,64 @@
1
+ import { svgElement } from './dom.js';
2
+ /** Own marker visuals and entrances; selection/events remain with the controller. */
3
+ export function createMarkerLayer(layer) {
4
+ const animations = new Set();
5
+ function cancelEntrances() {
6
+ for (const animation of animations)
7
+ animation.cancel();
8
+ animations.clear();
9
+ }
10
+ return {
11
+ cancelEntrances,
12
+ clear() {
13
+ cancelEntrances();
14
+ layer.replaceChildren();
15
+ },
16
+ add(marker, point, index, { features, markerColor, selectedMarkerColor, reducedMotion, enter, }) {
17
+ const node = svgElement('g', {
18
+ transform: `translate(${point[0]},${point[1]})`,
19
+ 'data-marker-id': marker.id,
20
+ role: 'button',
21
+ tabindex: 0,
22
+ 'aria-label': marker.label ?? marker.id,
23
+ 'aria-pressed': 'false',
24
+ });
25
+ const hit = svgElement('circle', { fill: 'transparent', 'pointer-events': 'all' });
26
+ const dot = svgElement('circle', {
27
+ fill: marker.color ?? markerColor,
28
+ stroke: '#fff9e9',
29
+ 'stroke-width': 2,
30
+ 'vector-effect': 'non-scaling-stroke',
31
+ 'pointer-events': 'none',
32
+ });
33
+ const title = svgElement('title');
34
+ title.textContent = marker.label ?? marker.id;
35
+ const ring = svgElement('circle', {
36
+ fill: 'none',
37
+ stroke: features.selectedMarkerRing
38
+ ? (features.selectedMarkerRing.color ?? selectedMarkerColor)
39
+ : selectedMarkerColor,
40
+ 'stroke-width': features.selectedMarkerRing ? features.selectedMarkerRing.width : 2,
41
+ 'vector-effect': 'non-scaling-stroke',
42
+ 'pointer-events': 'none',
43
+ display: 'none',
44
+ });
45
+ node.style.setProperty('--sf-marker-index', String(index));
46
+ node.append(title, hit, ring, dot);
47
+ if (features.markerEntrance && enter && !reducedMotion && typeof dot.animate === 'function') {
48
+ const animation = dot.animate([
49
+ { opacity: 0, transform: 'translateY(-12px)' },
50
+ { opacity: 1, transform: 'translateY(0)' },
51
+ ], {
52
+ duration: features.markerEntrance.duration,
53
+ delay: Math.min(index * features.markerEntrance.stagger, 1000),
54
+ easing: 'cubic-bezier(.2,.8,.2,1)',
55
+ fill: 'backwards',
56
+ });
57
+ animations.add(animation);
58
+ animation.finished.then(() => animations.delete(animation), () => animations.delete(animation));
59
+ }
60
+ layer.append(node);
61
+ return { marker, point, node, dot, hit, ring };
62
+ },
63
+ };
64
+ }
@@ -1,4 +1,6 @@
1
- import { bartStations, keyRoads, landmarks } from '../data/index.js';
1
+ import { landmarks } from '../data/landmarks.js';
2
+ import { keyRoads } from '../data/roads.js';
3
+ import { bartStations } from '../data/stations.js';
2
4
  import data from './data.js';
3
5
  import { deepFreeze } from './immutable.js';
4
6
  /** Complete data for static rendering, without interactive lookup collections. */
@@ -41,7 +41,7 @@ export interface SFMapOptions {
41
41
  districtLines?: boolean;
42
42
  neighborhoodLines?: boolean;
43
43
  districtFills?: boolean;
44
- /** Style each supervisorial district without taking over SVG rendering. */
44
+ /** Evaluated once per district at construction/style/year updates; call setDistrictStyle again when external data changes. */
45
45
  districtStyle?: (district: import('./map-core.js').DistrictRowData) => DistrictStyle;
46
46
  districtLabels?: boolean;
47
47
  /** Hide all visible text labels while retaining geographic symbols and accessible titles. */
@@ -157,6 +157,11 @@ export function validateMarkers(markers) {
157
157
  if (typeof marker.id !== 'string' || !marker.id || ids.has(marker.id))
158
158
  throw new RangeError('Markers require unique nonempty IDs.');
159
159
  ids.add(marker.id);
160
+ if (!Number.isFinite(marker.lng) ||
161
+ Math.abs(marker.lng) > 180 ||
162
+ !Number.isFinite(marker.lat) ||
163
+ Math.abs(marker.lat) >= 90)
164
+ throw new RangeError('Marker coordinates require finite longitude from -180 to 180 and latitude strictly between -90 and 90.');
160
165
  if (marker.radius !== undefined && (!Number.isFinite(marker.radius) || marker.radius <= 0))
161
166
  throw new RangeError('Marker radius must be positive and finite.');
162
167
  if (marker.selected !== undefined && typeof marker.selected !== 'boolean')
package/docs/EXAMPLES.md CHANGED
@@ -17,9 +17,9 @@ Choose by task. All snippets use the public package API; browser examples need a
17
17
  ```ts
18
18
  import { writeFile } from 'node:fs/promises';
19
19
  import { renderMap } from '@kahwee/sf-map-svg';
20
- import { fullMapData } from '@kahwee/sf-map-svg/data/full';
20
+ import { staticMapData } from '@kahwee/sf-map-svg/data/static';
21
21
 
22
- const svg = renderMap(fullMapData.map, {
22
+ const svg = renderMap(staticMapData, {
23
23
  year: 2022,
24
24
  landmarks: true,
25
25
  bartStations: true,
@@ -28,25 +28,25 @@ const svg = renderMap(fullMapData.map, {
28
28
  await writeFile('districts.svg', svg);
29
29
  ```
30
30
 
31
- The full preset imports all packaged geography. For smaller bundles, pass selected data to the root or `/static` renderer. See the [static recipe](../README.md#static-svg).
31
+ The static preset includes the packaged map layers without interactive lookup collections. For smaller bundles, pass selected data to the root or `/static` renderer. See the [static recipe](../README.md#static-svg).
32
32
 
33
33
  For an election choropleth, use `renderMap(data, { year, districtStyle })` or
34
34
  `createMap({ map: data, districts: districtMaps, neighborhoods: {} }, options)`.
35
35
  The controller exposes `setDistrictYear`, `setDistrictStyle`, `selectDistrict`, and typed
36
36
  district events; `getLayerPaths(data, { year })` returns fitted paths without SVG markup.
37
- See the [complete election recipe](../README.md#election-district-api) and the Storybook
38
- “Election choropleth” example.
37
+ See the [Storybook election choropleth](../stories/ElectionMap.stories.ts) for a working example.
39
38
 
40
39
  ## Lightweight interactive guide
41
40
 
42
41
  ```ts
43
- import { createGuideMap } from '@kahwee/sf-map-svg/guide';
42
+ import { createGuideController } from '@kahwee/sf-map-svg/guide';
44
43
 
45
- const map = createGuideMap({ layers: { roadLabels: false } });
46
- document.querySelector('#map')?.append(map);
44
+ const map = createGuideController({ layers: { roadLabels: false } });
45
+ document.querySelector('#map')?.append(map.element);
46
+ // On unmount: map.destroy();
47
47
  ```
48
48
 
49
- The guide includes selected coast, SFAR neighborhoods, parks, roads, and stations. Detailed geography loads only when explicitly requested; see the [guide recipe](../README.md#render-a-static-map).
49
+ The guide includes selected coast, SFAR neighborhoods, parks, roads, and stations. Detailed geography loads only when explicitly requested; see the [consumer guide recipe](consumer-integration.md).
50
50
 
51
51
  ## Selected geography
52
52
 
@@ -71,10 +71,10 @@ This imports one neighborhood definition source. To omit its geometry too, impor
71
71
  ## Route overlay
72
72
 
73
73
  ```ts
74
- import { createGuideMap } from '@kahwee/sf-map-svg/guide';
74
+ import { createGuideController } from '@kahwee/sf-map-svg/guide';
75
75
 
76
- const map = createGuideMap();
77
- document.querySelector('#map')?.append(map);
76
+ const map = createGuideController();
77
+ document.querySelector('#map')?.append(map.element);
78
78
  map.setOverlays([{
79
79
  id: 'trip',
80
80
  label: 'Example route',
package/docs/api-audit.md CHANGED
@@ -20,7 +20,11 @@ unbounded geographic data can never fail.
20
20
  | Marker entrance and reduced motion | Entrances are optional, independent of camera motion, and cancel on preference changes or disposal. Stable IDs avoid repeated entrances on filtering. |
21
21
  | Hiding touch control while gestures are enabled | Returns touch scrolling to the page. Other toolbar controls remain independent. |
22
22
  | Runtime patch omitted/false/undefined | Omitted retains; false disables; undefined resets to the default. Nested feature objects replace, rather than deep-merge. |
23
- | Compact shell and full attribution | Rejected before replacement. Use `createGuideMap` for full explorer chrome. Control overrides are honored and may deliberately alter layout. |
23
+ | Compact shell and full attribution | Rejected before replacement. Use `createGuideController` for full attribution. Control overrides are honored and may deliberately alter layout. |
24
+ | District style callbacks | Prepare and copy every style before committing. Hover/selection reuse the results; call `setDistrictStyle()` to refresh changed external data. Reentrant updates or destruction supersede pending work. |
25
+ | Rapid district year changes | Both outgoing layers are tracked, inert, and removed on interruption, completion, reduced-motion changes, or destruction. Zero duration creates no transition copies. |
26
+ | Label metrics and camera movement | Reuse screen-space text measurements and nodes across frames. Font loading invalidates measurements; hidden or removed candidates do not remain in the visible layer. |
27
+ | Guide controllers and compatibility factories | `createGuideController`/`mountGuideController` share grouped options and lifecycle with `createMap`. Existing element factories retain flat options and their return types. |
24
28
  | Layer hidden and bundle cost | Visibility never unloads imported geography. Use narrow entrypoints or data injection to save bytes. |
25
29
 
26
30
  ## Failure boundaries and regression evidence
@@ -40,9 +44,10 @@ unbounded geographic data can never fail.
40
44
 
41
45
  Regression sources: `test/api-contract.test.js`, `test/camera.test.js`,
42
46
  `test/viewport.test.js`, `stories/Robustness.stories.ts`,
43
- `stories/Enhancements.stories.ts`, and `scripts/smoke-package.mjs`.
47
+ `stories/Enhancements.stories.ts`, `stories/Motion.stories.ts`,
48
+ `stories/Controller.stories.ts`, and `scripts/smoke-package.mjs`.
44
49
 
45
- Callbacks run after their corresponding state is committed. Consumer callback
50
+ Event listeners and activation callbacks run after their corresponding state is committed. District-style callbacks instead run during preparation, before commit. Consumer event callback
46
51
  exceptions are not transactional validation errors and do not roll back an already
47
52
  committed change. Direct mutation of the returned DOM can invalidate invariants;
48
53
  use the public methods and `overlayElement` extension point.
@@ -5,14 +5,19 @@ Use the version 3 root controller and explicit geographic data. The [migration g
5
5
  For a compact browser guide:
6
6
 
7
7
  ```ts
8
- import { createMap } from '@kahwee/sf-map-svg';
9
- import { guideMapData } from '@kahwee/sf-map-svg/guide/data';
10
- import { guideOptions } from '@kahwee/sf-map-svg/presets';
8
+ import { createGuideController } from '@kahwee/sf-map-svg/guide';
11
9
 
12
- const map = createMap(guideMapData, { ...guideOptions });
10
+ const map = createGuideController({
11
+ features: { motion: { duration: 400 }, markerEntrance: true },
12
+ appearance: { theme: 'transit' },
13
+ });
13
14
  document.querySelector('#map')?.append(map.element);
14
15
  // Dispose when the containing view unmounts.
15
16
  map.destroy();
16
17
  ```
17
18
 
18
- For server rendering, import `renderMap` from the root and pass `guideMapData.map` or another explicit `StaticMapData`. Import `/data/full` only if the complete packaged geography is required. All rendering stays offline and has zero runtime dependencies. The guide's detailed geography loads only when `loadGuideDetailedData()` is called.
19
+ The guide controller uses the same `configure()`, `camera`, `on()`, and `destroy()` contract as `createMap()`. Motion is opt-in and follows the user's reduced-motion preference. For explicit data composition, `createMap(guideMapData, guideOptions)` remains available using `/guide/data` and `/presets`.
20
+
21
+ To progressively enhance server-rendered `createGuideShell()` markup, use `mountGuideController(shell, options)` from `/guide`. It returns the controller and requires compact attribution. Invalid configuration leaves the static frame intact. Destroy the controller when unmounting. Existing `createGuideMap()` and `mountGuideMap()` return their original augmented elements and accept flat options for compatibility.
22
+
23
+ For server rendering, import `renderMap` from the root and pass `guideMapData.map`, `staticMapData` from `/data/static`, or another explicit `StaticMapData`. Import `/data/full` when interactive lookup collections are also required. All rendering stays offline and has zero runtime dependencies. The guide's detailed geography loads only when `loadGuideDetailedData()` is called.
@@ -1,12 +1,12 @@
1
1
  # Guide bundle size report
2
2
 
3
- Generated 2026-09-27 by `pnpm report:guide` with Vite production minification and gzip compression. Each emitted JS chunk is compressed independently. The report measures the explicit-data v3 root and the optional `@kahwee/sf-map-svg/guide` preset.
3
+ Generated 2026-09-28 by `pnpm report:guide` with Vite production minification and gzip compression. Each emitted JS chunk is compressed independently. The report measures the explicit-data v3 root and the optional `@kahwee/sf-map-svg/guide` preset.
4
4
 
5
5
  | Entry | Initial JS, raw | Initial JS, gzip | Explicit detail JS, gzip |
6
6
  | --- | ---: | ---: | ---: |
7
- | v3 root (explicit data) | 84.5 KB | 24.6 KB | — |
8
- | v3 static renderer | 14.8 KB | 4.9 KB | — |
9
- | Guide preset | 469.8 KB | 112.0 KB | 473.7 KB |
7
+ | v3 root (explicit data) | 87.4 KB | 25.5 KB | — |
8
+ | v3 static renderer | 15.3 KB | 5.0 KB | — |
9
+ | Guide preset | 477.1 KB | 114.1 KB | 473.7 KB |
10
10
 
11
11
  **500 KB target:** met.
12
12
 
@@ -4,14 +4,14 @@ Version 3 removes the deprecated compatibility entry points and their bundled-da
4
4
 
5
5
  | Version 2 import or call | Version 3 replacement |
6
6
  | --- | --- |
7
- | `/legacy` `renderSFMap(options)` | `renderMap(fullMapData.map, options).svg` |
8
- | `/legacy` `createSFMap(options)` | `renderMap(fullMapData.map, options)` |
7
+ | `/legacy` `renderSFMap(options)` | `renderMap(staticMapData, options).svg` |
8
+ | `/legacy` `createSFMap(options)` | `renderMap(staticMapData, options)` |
9
9
  | `/custom-map` `createSFMapWithData(options, data)` | `renderMap(data, options)` |
10
10
  | `/explorer` `createNeighborhoodExplorer(options)` | `createMap(fullMapData, options)`; mount `.element` |
11
11
  | `/interactive` `createInteractiveSFMap(options)` | `createMap(fullMapData, { mode: 'basemap', ...options })`; mount `.element` |
12
12
  | `/interactive-data` `createInteractiveSFMapWithData(data, options)` | `createMap(data, options)`; mount `.element` |
13
13
 
14
- `fullMapData` comes from `@kahwee/sf-map-svg/data/full`. It is an explicit import of all packaged geography. For smaller bundles, use `staticMapData` from `/data/static` for SVGs, construct data from individual `/data/*` modules, or use `guideMapData` from `/guide/data`. There is no implicit geographic data in the root import.
14
+ `staticMapData` comes from `@kahwee/sf-map-svg/data/static` and includes the complete static map. `fullMapData` comes from `/data/full` and adds the interactive lookup collections. For smaller bundles, construct data from individual `/data/*` modules or use `guideMapData` from `/guide/data`. There is no implicit geographic data in the root import.
15
15
 
16
16
  ```ts
17
17
  import { createMap, renderMap } from '@kahwee/sf-map-svg';
@@ -28,3 +28,9 @@ map.destroy();
28
28
  The controller owns subscriptions and cleanup. Replace old element calls with controller methods (`camera.get/set/pan/zoom/reset/fit/stop`, `selectNeighborhood`, `setSource`, `setMode`, `setLabels`, `setMarkers`, and `setOverlays`). Group feature flags under `features` and styling under `appearance`; `configure` updates runtime features, layers, and controls. For code that needs the guide shell, `/guide`, `/guide/data`, `/guide/static`, and `/guide/map` remain supported. `/transit` also remains supported.
29
29
 
30
30
  Review tree-shaking after migrating: `/data/full` intentionally includes every packaged layer, while the root and `/static` stay data free. Test server rendering, browser mounting, and map disposal in your application before upgrading production.
31
+
32
+ ## Updating from 3.0 to 3.1
33
+
34
+ Existing guide factories remain compatible. New integrations can use `createGuideController(options)` and `mountGuideController(shell, options)` from `/guide` or `/guide/map`. Move flat styling options into `appearance`, animation options into `features`, append `.element`, and use `.camera` and `.on()` as with `createMap`.
35
+
36
+ District styles are now prepared once per district on construction and style/year updates. Hover and selection reuse that snapshot. If a callback reads mutable external data, call `map.setDistrictStyle(callback)` after changing the data; do not rely on hovering to refresh colors. Callback errors leave the previous style intact, and callback-triggered updates or destruction take precedence over the pending update.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kahwee/sf-map-svg",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Offline SVG maps of San Francisco with district boundaries, parks, landmarks, and BART stations.",
5
5
  "type": "module",
6
6
  "main": "./dist/src/api.js",
@@ -171,7 +171,8 @@
171
171
  "demo": "pnpm build && node examples/build.mjs",
172
172
  "format": "biome check --write .",
173
173
  "format:check": "biome format .",
174
- "check": "pnpm peers check && pnpm lint && pnpm data:check && pnpm typecheck && pnpm typecheck:stories && pnpm test && pnpm test:bundle",
174
+ "check": "pnpm peers check && pnpm lint && pnpm check:docs && pnpm data:check && pnpm typecheck && pnpm typecheck:stories && pnpm test && pnpm test:bundle",
175
+ "check:docs": "node scripts/check-docs.mjs",
175
176
  "storybook": "pnpm build && storybook dev -p 6006 --host 127.0.0.1 --no-open",
176
177
  "build-storybook": "pnpm build && storybook build",
177
178
  "data:catalog": "node scripts/build-data-catalog.mjs",