@kahwee/sf-map-svg 1.5.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +160 -6
  3. package/SOURCES.md +34 -0
  4. package/dist/data/README.md +5 -1
  5. package/dist/data/candidates/2016-11-08.json +1 -0
  6. package/dist/data/candidates/2018-11-06.json +1 -0
  7. package/dist/data/candidates/2020-11-03.json +1 -0
  8. package/dist/data/candidates/2022-11-08.json +1 -0
  9. package/dist/data/candidates/2024-11-05.json +1 -0
  10. package/dist/data/candidates/2026-06-02.json +1 -0
  11. package/dist/data/candidates/README.md +43 -0
  12. package/dist/data/candidates/catalog.json +40 -0
  13. package/dist/data/types.d.ts +4 -1
  14. package/dist/src/api.d.ts +3 -0
  15. package/dist/src/api.js +3 -0
  16. package/dist/src/camera.d.ts +16 -0
  17. package/dist/src/camera.js +55 -0
  18. package/dist/src/clusters.d.ts +7 -0
  19. package/dist/src/clusters.js +33 -0
  20. package/dist/src/configuration.d.ts +5 -0
  21. package/dist/src/configuration.js +73 -0
  22. package/dist/src/controller-types.d.ts +84 -0
  23. package/dist/src/controller-types.js +1 -0
  24. package/dist/src/explorer-core.d.ts +1 -1
  25. package/dist/src/explorer-core.js +570 -145
  26. package/dist/src/features.d.ts +24 -0
  27. package/dist/src/features.js +99 -0
  28. package/dist/src/guide-data.js +3 -2
  29. package/dist/src/guide-detailed.js +3 -2
  30. package/dist/src/guide-map.d.ts +4 -0
  31. package/dist/src/guide-map.js +35 -10
  32. package/dist/src/guide-shell.d.ts +2 -0
  33. package/dist/src/guide-shell.js +16 -0
  34. package/dist/src/guide-static.d.ts +9 -0
  35. package/dist/src/guide-static.js +26 -0
  36. package/dist/src/guide.d.ts +2 -1
  37. package/dist/src/guide.js +1 -1
  38. package/dist/src/interactive-data.d.ts +1 -1
  39. package/dist/src/interactive-data.js +2 -0
  40. package/dist/src/interactive.d.ts +1 -1
  41. package/dist/src/interactive.js +2 -0
  42. package/dist/src/layers.js +3 -1
  43. package/dist/src/map-core.js +3 -8
  44. package/dist/src/map.d.ts +7 -0
  45. package/dist/src/map.js +106 -0
  46. package/dist/src/presets.d.ts +3 -0
  47. package/dist/src/presets.js +17 -0
  48. package/dist/src/static.d.ts +10 -0
  49. package/dist/src/static.js +5 -0
  50. package/dist/src/types.d.ts +73 -9
  51. package/dist/src/validation.d.ts +7 -0
  52. package/dist/src/validation.js +226 -0
  53. package/dist/src/viewport.js +9 -2
  54. package/docs/EXAMPLES.md +11 -1
  55. package/docs/api-audit.md +80 -0
  56. package/docs/consumer-integration.md +165 -0
  57. package/docs/guide-bundle-report.md +19 -0
  58. package/docs/migration-v2.md +238 -0
  59. package/package.json +40 -11
@@ -7,6 +7,8 @@ export interface MapMarker {
7
7
  label?: string;
8
8
  selected?: boolean;
9
9
  color?: string;
10
+ /** Visible radius in screen pixels (interactive), SVG units (static). */
11
+ radius?: number;
10
12
  }
11
13
  export interface MapOverlay {
12
14
  id: string;
@@ -70,13 +72,55 @@ export interface NeighborhoodSelection {
70
72
  source: NeighborhoodSource;
71
73
  feature: NeighborhoodFeature;
72
74
  }
73
- export interface NeighborhoodExplorerOptions {
75
+ export interface MapFeatures {
76
+ /** Opt-in camera motion; reduced-motion always takes precedence. */
77
+ motion?: boolean | {
78
+ duration?: number;
79
+ };
80
+ markerEntrance?: boolean | {
81
+ duration?: number;
82
+ stagger?: number;
83
+ };
84
+ selectedMarkerRing?: boolean | {
85
+ color?: string;
86
+ width?: number;
87
+ gap?: number;
88
+ };
89
+ /** Screen-space clustering. The selected marker and full chooser remain available. */
90
+ clustering?: boolean | {
91
+ radius?: number;
92
+ };
93
+ northArrow?: boolean;
94
+ scaleBar?: boolean;
95
+ }
96
+ export interface NeighborhoodExplorerOptions extends MapFeatures {
74
97
  mode?: ExplorerMode;
75
98
  labels?: boolean;
76
99
  source?: NeighborhoodSource;
77
100
  neighborhood?: string;
78
101
  year?: DistrictYear;
79
102
  theme?: SFMapOptions['theme'];
103
+ colors?: SFMapOptions['colors'];
104
+ labelStyle?: {
105
+ fontFamily?: string;
106
+ fontWeight?: number;
107
+ haloColor?: string;
108
+ };
109
+ areaStyle?: {
110
+ selectedFill?: string;
111
+ selectedStroke?: string;
112
+ hoverFill?: string;
113
+ hoverStroke?: string;
114
+ };
115
+ legend?: {
116
+ builtins?: boolean;
117
+ hidden?: readonly ('bart' | 'park' | 'highway' | 'road')[];
118
+ items?: readonly {
119
+ label: string;
120
+ color: string;
121
+ }[];
122
+ };
123
+ attribution?: 'full' | 'compact';
80
124
  /** Explorer chrome or the reusable map with only controls and attribution. */
81
125
  interface?: 'explorer' | 'map';
82
126
  /** Independent overrides; omitted layers follow mode defaults. */
@@ -100,7 +144,7 @@ export interface NeighborhoodExplorerOptions {
100
144
  onOverlayActivate?: (overlay: MapOverlay) => void;
101
145
  /** Stable theme tokens consumed by the explorer chrome. */
102
146
  style?: Partial<Record<'ink' | 'surface' | 'accent' | 'border' | 'focus' | 'controlGap' | 'font', string>>;
103
- strings?: Partial<Record<'title' | 'mode' | 'source' | 'search' | 'chooseNeighborhood' | 'chooseMarker' | 'touchNavigation' | 'reset' | 'emptyResults' | 'gestureHelp', string>>;
147
+ strings?: Partial<Record<'title' | 'mode' | 'source' | 'search' | 'chooseNeighborhood' | 'chooseMarker' | 'touchNavigation' | 'touchNavigationLabel' | 'touchNavigationExitLabel' | 'touchNavigationDone' | 'reset' | 'emptyResults' | 'gestureHelp', string>>;
104
148
  /**
105
149
  * Independently hide chrome. `neighborhoodPicker` and `markerPicker` hide the native
106
150
  * choosers (supply your own accessible list); `help` keeps the gesture help as the map's
@@ -108,28 +152,47 @@ export interface NeighborhoodExplorerOptions {
108
152
  */
109
153
  controls?: Partial<Record<'zoom' | 'pan' | 'reset' | 'labels' | 'touch' | 'legend' | 'neighborhoodPicker' | 'markerPicker' | 'help' | 'status', boolean>>;
110
154
  }
155
+ export interface CameraOptions {
156
+ animate?: boolean;
157
+ duration?: number;
158
+ }
111
159
  export interface NeighborhoodExplorerElement extends HTMLElement {
160
+ /** Append positioned HTML children here; coordinates are relative to this layer. */
161
+ readonly overlayElement: HTMLDivElement;
162
+ projectToScreen(lng: number, lat: number): {
163
+ x: number;
164
+ y: number;
165
+ visible: boolean;
166
+ };
167
+ stopAnimation(): void;
168
+ /** Atomic patch; false disables, undefined resets a feature to its default. */
169
+ setFeatures(patch: MapFeatures): void;
170
+ getFeatures(): MapFeatures;
171
+ /** Layer overrides; undefined restores mode defaults. Preserves camera and selection. */
172
+ setLayers(patch: InteractiveLayers): void;
173
+ /** Chrome switches are independent. */
174
+ setControls(patch: NonNullable<NeighborhoodExplorerOptions['controls']>): void;
112
175
  selectNeighborhood(name: string | null, options?: {
113
176
  fit?: boolean;
114
- }): boolean;
177
+ } & CameraOptions): boolean;
115
178
  getSelection(): NeighborhoodSelection | null;
116
179
  setSource(source: NeighborhoodSource): void;
117
180
  setMode(mode: ExplorerMode): void;
118
181
  setLabels(visible: boolean): void;
119
- resetView(): void;
120
- zoomBy(factor: number): void;
121
- panBy(x: number, y: number): void;
182
+ resetView(options?: CameraOptions): void;
183
+ zoomBy(factor: number, options?: CameraOptions): void;
184
+ panBy(x: number, y: number, options?: CameraOptions): void;
122
185
  getViewport(): MapViewport;
123
- setViewport(view: MapViewport): void;
186
+ setViewport(view: MapViewport, options?: CameraOptions): void;
124
187
  /** Fit WGS84 geometry, including Point, MultiPoint, or a GeometryCollection. */
125
- fitGeometry(geometry: Geometry, padding?: number | MapPadding): void;
188
+ fitGeometry(geometry: Geometry, padding?: number | MapPadding, options?: CameraOptions): void;
126
189
  /** Explicitly engage map touch gestures; false restores page gestures. */
127
190
  setTouchNavigation(enabled: boolean): void;
128
191
  setMarkers(markers: readonly MapMarker[]): void;
129
192
  setOverlays(overlays: readonly MapOverlay[]): void;
130
193
  selectMarker(id: string | null, options?: {
131
194
  fit?: boolean;
132
- }): boolean;
195
+ } & CameraOptions): boolean;
133
196
  getSelectedMarker(): MapMarker | null;
134
197
  destroy(): void;
135
198
  }
@@ -137,3 +200,4 @@ export interface NeighborhoodExplorerElement extends HTMLElement {
137
200
  export interface TransitAnimationElement extends HTMLElement {
138
201
  destroy(): void;
139
202
  }
203
+ export type { MapAppearance, MapCamera, MapConfiguration, MapController, MapEvents, MapOptions, } from './controller-types.js';
@@ -0,0 +1,7 @@
1
+ import type { MapMarker, MapOverlay, NeighborhoodExplorerOptions } from './types.js';
2
+ /** JSON-like configuration records only; reject typo keys instead of silently defaulting. */
3
+ export declare function assertOptions(value: unknown, name: string, keys?: readonly string[]): Record<string, unknown>;
4
+ export declare function validatePadding(value: unknown): void;
5
+ export declare function validateExplorerOptions(options: NeighborhoodExplorerOptions): void;
6
+ export declare function validateMarkers(markers: readonly MapMarker[]): void;
7
+ export declare function validateOverlays(overlays: readonly MapOverlay[]): void;
@@ -0,0 +1,226 @@
1
+ import { rawProject } from './geometry.js';
2
+ /** JSON-like configuration records only; reject typo keys instead of silently defaulting. */
3
+ export function assertOptions(value, name, keys) {
4
+ if (!value ||
5
+ typeof value !== 'object' ||
6
+ Array.isArray(value) ||
7
+ ![Object.prototype, null].includes(Object.getPrototypeOf(value)))
8
+ throw new TypeError(`${name} must be a plain options object.`);
9
+ if (keys)
10
+ for (const key of Object.keys(value))
11
+ if (!keys.includes(key))
12
+ throw new TypeError(`Unknown ${name} option: ${key}`);
13
+ return value;
14
+ }
15
+ export function validatePadding(value) {
16
+ if (typeof value === 'number') {
17
+ if (Number.isFinite(value) && value >= 0)
18
+ return;
19
+ throw new RangeError('Fit requires finite nonnegative padding.');
20
+ }
21
+ const record = assertOptions(value, 'padding', ['top', 'right', 'bottom', 'left']);
22
+ for (const side of Object.values(record))
23
+ if (side !== undefined && (typeof side !== 'number' || !Number.isFinite(side) || side < 0))
24
+ throw new RangeError('Fit requires finite nonnegative padding.');
25
+ }
26
+ const optionKeys = [
27
+ 'source',
28
+ 'mode',
29
+ 'labels',
30
+ 'neighborhood',
31
+ 'year',
32
+ 'theme',
33
+ 'colors',
34
+ 'labelStyle',
35
+ 'areaStyle',
36
+ 'motion',
37
+ 'markerEntrance',
38
+ 'selectedMarkerRing',
39
+ 'clustering',
40
+ 'legend',
41
+ 'attribution',
42
+ 'northArrow',
43
+ 'scaleBar',
44
+ 'interface',
45
+ 'layers',
46
+ 'selectableNeighborhoods',
47
+ 'labelSize',
48
+ 'fitPadding',
49
+ 'markers',
50
+ 'markerRadius',
51
+ 'markerHitSize',
52
+ 'markerColor',
53
+ 'selectedMarkerColor',
54
+ 'onMarkerActivate',
55
+ 'overlays',
56
+ 'onOverlayActivate',
57
+ 'style',
58
+ 'strings',
59
+ 'controls',
60
+ ];
61
+ export function validateExplorerOptions(options) {
62
+ assertOptions(options, 'map', optionKeys);
63
+ for (const key of ['labels', 'selectableNeighborhoods'])
64
+ if (options[key] !== undefined && typeof options[key] !== 'boolean')
65
+ throw new TypeError(`${key} must be boolean.`);
66
+ for (const key of ['onMarkerActivate', 'onOverlayActivate'])
67
+ if (options[key] !== undefined && typeof options[key] !== 'function')
68
+ throw new TypeError(`${key} must be a function.`);
69
+ for (const key of ['markerColor', 'selectedMarkerColor', 'neighborhood'])
70
+ if (options[key] !== undefined && typeof options[key] !== 'string')
71
+ throw new TypeError(`${key} must be a string.`);
72
+ if (options.attribution !== undefined && !['full', 'compact'].includes(options.attribution))
73
+ throw new TypeError('Attribution must be full or compact.');
74
+ for (const [key, keys] of [
75
+ ['labelStyle', ['fontFamily', 'fontWeight', 'haloColor']],
76
+ ['areaStyle', ['selectedFill', 'selectedStroke', 'hoverFill', 'hoverStroke']],
77
+ ['labelSize', ['min', 'max']],
78
+ ['legend', ['builtins', 'hidden', 'items']],
79
+ [
80
+ 'colors',
81
+ [
82
+ 'water',
83
+ 'land',
84
+ 'district',
85
+ 'neighborhood',
86
+ 'highway',
87
+ 'road',
88
+ 'park',
89
+ 'landmark',
90
+ 'bart',
91
+ 'label',
92
+ 'marker',
93
+ 'selected',
94
+ ],
95
+ ],
96
+ ['style', ['ink', 'surface', 'accent', 'border', 'focus', 'controlGap', 'font']],
97
+ [
98
+ 'strings',
99
+ [
100
+ 'title',
101
+ 'mode',
102
+ 'source',
103
+ 'search',
104
+ 'chooseNeighborhood',
105
+ 'chooseMarker',
106
+ 'touchNavigation',
107
+ 'touchNavigationLabel',
108
+ 'touchNavigationExitLabel',
109
+ 'touchNavigationDone',
110
+ 'reset',
111
+ 'emptyResults',
112
+ 'gestureHelp',
113
+ ],
114
+ ],
115
+ ]) {
116
+ const value = options[key];
117
+ if (value === undefined)
118
+ continue;
119
+ assertOptions(value, key, keys);
120
+ if (['colors', 'style', 'strings', 'areaStyle'].includes(key))
121
+ for (const token of Object.values(value))
122
+ if (token !== undefined && typeof token !== 'string')
123
+ throw new TypeError(`${key} tokens must be strings.`);
124
+ }
125
+ if (options.labelStyle)
126
+ for (const key of ['fontFamily', 'haloColor'])
127
+ if (options.labelStyle[key] !== undefined && typeof options.labelStyle[key] !== 'string')
128
+ throw new TypeError(`${key} must be a string.`);
129
+ const legend = options.legend;
130
+ if (legend?.builtins !== undefined && typeof legend.builtins !== 'boolean')
131
+ throw new TypeError('legend.builtins must be boolean.');
132
+ if (legend?.hidden !== undefined &&
133
+ (!Array.isArray(legend.hidden) ||
134
+ Array.from(legend.hidden).some((key) => !['bart', 'park', 'road', 'highway'].includes(key))))
135
+ throw new TypeError('Unknown legend entry.');
136
+ if (legend?.items !== undefined) {
137
+ if (!Array.isArray(legend.items))
138
+ throw new TypeError('Legend items must be an array.');
139
+ for (const item of legend.items) {
140
+ assertOptions(item, 'legend item', ['label', 'color']);
141
+ if (typeof item.label !== 'string' || typeof item.color !== 'string')
142
+ throw new TypeError('Legend items require string labels and colors.');
143
+ }
144
+ }
145
+ if (options.fitPadding !== undefined)
146
+ validatePadding(options.fitPadding);
147
+ }
148
+ export function validateMarkers(markers) {
149
+ if (!Array.isArray(markers))
150
+ throw new TypeError('Markers must be an array.');
151
+ const ids = new Set();
152
+ for (const marker of markers) {
153
+ assertOptions(marker, 'marker');
154
+ if (typeof marker.id !== 'string' || !marker.id || ids.has(marker.id))
155
+ throw new RangeError('Markers require unique nonempty IDs.');
156
+ ids.add(marker.id);
157
+ if (marker.radius !== undefined && (!Number.isFinite(marker.radius) || marker.radius <= 0))
158
+ throw new RangeError('Marker radius must be positive and finite.');
159
+ if (marker.selected !== undefined && typeof marker.selected !== 'boolean')
160
+ throw new TypeError('Marker selected must be boolean.');
161
+ for (const key of ['label', 'color'])
162
+ if (marker[key] !== undefined && typeof marker[key] !== 'string')
163
+ throw new TypeError(`Marker ${key} must be a string.`);
164
+ }
165
+ }
166
+ export function validateOverlays(overlays) {
167
+ if (!Array.isArray(overlays))
168
+ throw new TypeError('Overlays must be an array.');
169
+ const ids = new Set();
170
+ for (const overlay of overlays) {
171
+ assertOptions(overlay, 'overlay');
172
+ if (typeof overlay.id !== 'string' ||
173
+ !/^[A-Za-z0-9_-]+$/.test(overlay.id) ||
174
+ ids.has(overlay.id))
175
+ throw new RangeError('Overlays require unique IDs containing only letters, numbers, underscores, or hyphens.');
176
+ ids.add(overlay.id);
177
+ if (!overlay.geometry ||
178
+ !['LineString', 'MultiLineString', 'Polygon', 'MultiPolygon'].includes(overlay.geometry.type))
179
+ throw new TypeError('Overlay geometry must be a line or polygon.');
180
+ const sequence = (value, min) => {
181
+ if (!Array.isArray(value) || value.length < min)
182
+ throw new TypeError('Overlay geometry contains an empty or incomplete coordinate sequence.');
183
+ return Array.from(value);
184
+ };
185
+ const line = (value, min) => {
186
+ for (const point of sequence(value, min)) {
187
+ const pair = sequence(point, 2);
188
+ if (pair.length !== 2)
189
+ throw new TypeError('Overlay positions require longitude and latitude.');
190
+ rawProject(pair);
191
+ }
192
+ };
193
+ const polygon = (value) => {
194
+ for (const ring of sequence(value, 1))
195
+ line(ring, 3);
196
+ };
197
+ const geometry = overlay.geometry;
198
+ switch (geometry.type) {
199
+ case 'LineString':
200
+ line(geometry.coordinates, 2);
201
+ break;
202
+ case 'MultiLineString':
203
+ for (const part of sequence(geometry.coordinates, 1))
204
+ line(part, 2);
205
+ break;
206
+ case 'Polygon':
207
+ polygon(geometry.coordinates);
208
+ break;
209
+ case 'MultiPolygon':
210
+ for (const part of sequence(geometry.coordinates, 1))
211
+ polygon(part);
212
+ break;
213
+ }
214
+ if (overlay.visible !== undefined && typeof overlay.visible !== 'boolean')
215
+ throw new TypeError('Overlay visible must be boolean.');
216
+ if (overlay.strokeWidth !== undefined &&
217
+ (!Number.isFinite(overlay.strokeWidth) || overlay.strokeWidth < 0))
218
+ throw new RangeError('Overlay strokeWidth must be a finite nonnegative number.');
219
+ if (overlay.fillOpacity !== undefined &&
220
+ (!Number.isFinite(overlay.fillOpacity) || overlay.fillOpacity < 0 || overlay.fillOpacity > 1))
221
+ throw new RangeError('Overlay fillOpacity must be a finite number from 0 to 1.');
222
+ for (const key of ['label', 'fill', 'stroke'])
223
+ if (overlay[key] !== undefined && typeof overlay[key] !== 'string')
224
+ throw new TypeError(`Overlay ${key} must be a string.`);
225
+ }
226
+ }
@@ -1,11 +1,18 @@
1
1
  import { clampView } from './explorer-layout.js';
2
+ import { validatePadding } from './validation.js';
2
3
  export function validateViewport(view) {
3
- if (view.length !== 3 || !view.every(Number.isFinite) || view[2] <= 0)
4
+ if (!Array.isArray(view) ||
5
+ view.length !== 3 ||
6
+ !Array.from(view).every(Number.isFinite) ||
7
+ view[2] <= 0)
4
8
  throw new RangeError('Viewport must contain finite x, y, and a positive size.');
5
- return clampView([...view]);
9
+ return clampView([view[0], view[1], view[2]]);
6
10
  }
7
11
  /** Fit bounds in the unobscured screen area, then constrain to the city extent. */
8
12
  export function fitViewport(bounds, width, padding = 24) {
13
+ validatePadding(padding);
14
+ if (!Array.isArray(bounds) || bounds.length !== 4 || !Array.from(bounds).every(Number.isFinite))
15
+ throw new RangeError('Fit requires four finite bounds.');
9
16
  const p = typeof padding === 'number'
10
17
  ? { top: padding, right: padding, bottom: padding, left: padding }
11
18
  : padding;
package/docs/EXAMPLES.md CHANGED
@@ -16,7 +16,7 @@ Choose by task. All snippets use the public package API; browser examples need a
16
16
 
17
17
  ```ts
18
18
  import { writeFile } from 'node:fs/promises';
19
- import { renderSFMap } from '@kahwee/sf-map-svg';
19
+ import { renderSFMap } from '@kahwee/sf-map-svg/legacy';
20
20
 
21
21
  const svg = renderSFMap({
22
22
  year: 2022,
@@ -85,3 +85,13 @@ Coordinates are WGS84 `[longitude, latitude]`. The overlay follows pan and zoom.
85
85
  [Open the interactive explorer](https://kahwee.github.io/sf-map-svg/propositions.html). It uses the public `custom-map` renderer with only the 2022 district and coast datasets and colors each district from the [certified results JSON](../data/propositions/2024-11-05.json). The JSON includes all ten statewide propositions on the November 2024 ballot, with Yes and No counts for each of San Francisco's eleven supervisorial districts. Its scope is SF votes, not statewide totals or voter demographics.
86
86
 
87
87
  The [import script](../scripts/import-2024-propositions.py) checks each district sum against the official citywide count. [Geographic and election sources](../SOURCES.md) explain the provenance.
88
+
89
+ ## Motion and compact guide embeds
90
+
91
+ The runnable `examples/generated/interactive.html` now demonstrates camera motion,
92
+ staggered marker entrances, clustering, selected marker rings, a custom legend,
93
+ compact sources, a north arrow, and a metric scale. Storybook's **Checks / Consumer
94
+ API** includes executable motion, reduced-motion, and progressive-shell checks.
95
+ See [consumer integration](consumer-integration.md) for server and browser recipes.
96
+
97
+ For the v2 controller and explicit data imports, see [the migration guide](migration-v2.md).
@@ -0,0 +1,80 @@
1
+ # Maintainer adversarial API audit
2
+
3
+ This audit covers configuration, runtime updates, rendering input, lifecycle,
4
+ selection, motion, progressive enhancement, and package boundaries. It is a
5
+ regression contract, not a claim that arbitrary host CSS, hostile JavaScript, or
6
+ unbounded geographic data can never fail.
7
+
8
+ ## Conflicts and precedence
9
+
10
+ | Combination | Contract |
11
+ | --- | --- |
12
+ | Theme and explicit colors | Explicit tokens override theme defaults; default soft palette stays stable. |
13
+ | Mode and layer overrides | Explicit layer choices win. Selecting a neighborhood does not re-enable two explicitly disabled neighborhood layers. |
14
+ | `labels:false` and individual label layers | The master switch hides text; map symbols and accessible titles remain. |
15
+ | Motion disabled and per-call animation | The default is immediate; per-call `animate:true` opts in. `animate:false` jumps. Explicit duration can override the default. Reduced motion always wins. |
16
+ | A new camera request during animation | The new request cancels the prior generation. A callback cannot revive it after cancellation/disposal. |
17
+ | Selection callback selecting another item | Newer selection wins. The old selection cannot subsequently move the camera or send a stale activation callback. |
18
+ | `selectNeighborhood(..., {fit:false})` in basemap mode | Selection can change presentation, but preserves the camera. Explicit layer overrides remain authoritative. |
19
+ | Clustering and selected/focused markers | Selected/focused pins remain individually reachable. Coincident alternatives remain available through the chooser. |
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
+ | Hiding touch control while gestures are enabled | Returns touch scrolling to the page. Other toolbar controls remain independent. |
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. |
24
+ | Layer hidden and bundle cost | Visibility never unloads imported geography. Use narrow entrypoints or data injection to save bytes. |
25
+
26
+ ## Failure boundaries and regression evidence
27
+
28
+ | Surface | Adversarial checks and behavior |
29
+ | --- | --- |
30
+ | Configuration | Interactive constructors and runtime feature/control/layer patches reject unknown keys, non-records, wrong boolean types, and invalid numeric ranges. Nested typo keys fail instead of silently defaulting. |
31
+ | Camera | Sparse arrays, NaN/infinity, malformed bounds and invalid padding are rejected before replacing pending movement. Valid extents are clamped to the supported city viewport. |
32
+ | Marker/overlay replacement | Validate and build the replacement before committing. Duplicate IDs and malformed values preserve the prior list. Overlay geometry accepts lines and polygons, with finite longitude/latitude pairs and nonempty sequences; polygon paths close rings automatically. This is shape validation, not a general polygon topology repair service. |
33
+ | Source replacement | Unavailable/malformed sources fail before replacing the chooser, selection, geometry, or source state. SFAR, SF Find and analysis identities remain separate. |
34
+ | Shared state | Overview and detailed preset data are deeply frozen. Selection/configuration reads and emitted feature snapshots are detached from renderer state. Injected collections are borrowed: callers must treat them as immutable for the map lifetime. |
35
+ | Lifecycle | Idempotent destruction cancels camera/entrance/label work, observers, global listeners, and replaceable overlay listeners. Observer initialization failure cleans up acquired resources. Application-owned listeners and callout DOM remain the application's responsibility. |
36
+ | Accessibility | Feature round trips, keyboard activation, focus recovery after removal/clustering, independent controls, touch names, and reduced motion have browser regressions. Hiding every alternative chooser is a consumer accessibility decision. |
37
+ | Output safety | SVG text and attributes are escaped. This is not a security sandbox for CSS tokens or caller-provided DOM. Keep untrusted callout content in `textContent`; the consumer controls CSS/resource policy. |
38
+ | Packaging | Clean tarball install exercises entrypoints and emitted declarations, including `LineString` overlays. No runtime dependencies or embedded geographic coordinate blobs were added. |
39
+ | Performance | Production bundle checks enforce the guide and renderer ceilings and inspect dataset inclusion. Grouping has a 2,000-marker regression; millions of markers are outside this guide-oriented design. |
40
+
41
+ Regression sources: `test/api-contract.test.js`, `test/camera.test.js`,
42
+ `test/viewport.test.js`, `stories/Robustness.stories.ts`,
43
+ `stories/Enhancements.stories.ts`, and `scripts/smoke-package.mjs`.
44
+
45
+ Callbacks run after their corresponding state is committed. Consumer callback
46
+ exceptions are not transactional validation errors and do not roll back an already
47
+ committed change. Direct mutation of the returned DOM can invalidate invariants;
48
+ use the public methods and `overlayElement` extension point.
49
+
50
+ The generic static renderer retains its existing broader option surface; strict
51
+ interactive option-key validation is not a claim that all static options share the
52
+ same schema. Host layout, delayed images, fonts, and application cards need their
53
+ own CLS tests. The scale bar is an approximate local Mercator scale, not survey
54
+ instrumentation. Clustering does not spiderfy coincident locations. Geographic
55
+ source correctness remains governed by SOURCES.md and existing topology tests.
56
+
57
+ ## Extending the contract
58
+
59
+ For every new option, define its default, disable/reset semantics, precedence,
60
+ validation boundary, cleanup owner, and effect on keyboard focus and selection.
61
+ Test invalid updates against an existing live map, not only clean construction.
62
+ Exercise enable/disable round trips and callbacks that synchronously request a
63
+ second update. Add published-type and bundle regressions when the import graph or
64
+ public types change. Update this matrix when introducing a new interaction.
65
+
66
+ ## Verification for this change
67
+
68
+ - `pnpm check`: 59 Node tests, types, lint, source catalog and bundle budgets passed.
69
+ - `pnpm test:stories:coverage`: 60 Chromium stories passed.
70
+ - `pnpm demo`, `pnpm build-storybook`, and clean-install `pnpm test:package` passed.
71
+ - Production guide: 110.2 KB gzip; data-free compatibility renderer: 21.8 KB gzip, as reported by
72
+ `pnpm report:guide` (the report script uses 1024-byte units).
73
+ - Isolated agent-browser inspection of generated static SVG examples and the
74
+ interactive example at desktop and 390px found no page overflow or browser
75
+ errors. Interactive labels remained collision-filtered at both widths. Browser
76
+ screenshots were kept outside the repository and the session was closed.
77
+
78
+ The independent pre-release review reproduced and closed stale cluster activation
79
+ and checked the v2 overlay event bridge. See the controller and robustness stories
80
+ for pointer/keyboard activation, reentrant replacement, and revoked old nodes.
@@ -0,0 +1,165 @@
1
+ # Consumer integration and testing
2
+
3
+ For new integrations, use the [v2 controller](migration-v2.md). The compatibility
4
+ recipes below remain supported for existing guide and progressive-shell consumers.
5
+
6
+ ## Choose the import graph deliberately
7
+
8
+ Use `@kahwee/sf-map-svg/guide/map` for a guide using SFAR neighborhoods. It includes
9
+ only overview coast, SFAR boundaries, selected parks and roads, and BART. The
10
+ compatibility `interactive` import includes all supported district vintages and
11
+ neighborhood collections. Hiding a layer at runtime does not remove its imported
12
+ geometry from a bundle.
13
+
14
+ Use `interactive-data` for a renderer with **no bundled geography**. Supply the
15
+ `InteractiveSFMapData` you need, optionally from a separately cached, same-origin
16
+ asset produced at build time. This can improve caching and initial loading; it
17
+ does not eliminate the cost of transmitting that geography. Serve local assets
18
+ with compression and normal long-lived versioned cache headers. Avoid root or
19
+ `data` barrel imports in client code when only one collection is needed.
20
+
21
+ Load `guide/detailed` only after a user requests higher detail. Dynamically import
22
+ the browser map when its container approaches the viewport. Keep the static
23
+ renderer in server/build code; do not import it in a client loader. The overview
24
+ SVG still contains geography, so both static and interactive representations cost
25
+ bytes. Measure HTML and JS separately, including compressed bytes and lazy chunks.
26
+ Do not inline full-detail data into every page.
27
+
28
+ `pnpm report:guide` records measured raw/gzip sizes and audits included datasets.
29
+ `pnpm check` enforces 125 KiB initial guide and 35 KiB data-free renderer budgets.
30
+ These are regression ceilings, not claims about every application's final bundle.
31
+ Current measurements are in [the bundle report](guide-bundle-report.md).
32
+
33
+ ## Progressive compact layout
34
+
35
+ Build/server code:
36
+
37
+ ```js
38
+ import { createGuideShell } from '@kahwee/sf-map-svg/guide/static';
39
+ const html = createGuideShell({ markers: places, labels: false });
40
+ // Put html into the page alongside an accessible place list.
41
+ ```
42
+
43
+ Browser code:
44
+
45
+ ```js
46
+ import { mountGuideMap } from '@kahwee/sf-map-svg/guide/map';
47
+ const map = mountGuideMap(document.querySelector('.sf-guide-shell'), {
48
+ markers: places,
49
+ labels: false,
50
+ motion: { duration: 400 },
51
+ markerEntrance: true,
52
+ clustering: true,
53
+ selectedMarkerRing: { color: '#163d61' },
54
+ strings: { touchNavigation: 'Touch pan', touchNavigationDone: 'Done' },
55
+ });
56
+ // In your place list's activation handler:
57
+ map.selectMarker(places[0].id);
58
+ // On component disposal:
59
+ // map.destroy();
60
+ ```
61
+
62
+ Pass the same palette, markers, and layer choices to both sides. `labels:false`
63
+ avoids a label-placement change during enhancement: static labels and interactive
64
+ collision placement are different algorithms. Shell placeholders are not working
65
+ controls, and the SVG remains available without JavaScript. The compact shell
66
+ reserves its own rows at all widths; it is not a general serializer for arbitrary
67
+ explorer chrome. Keep external image/card aspect ratios and list heights stable as
68
+ well: a library shell cannot prevent shifts elsewhere in your page.
69
+
70
+ ## Callouts without private DOM selectors
71
+
72
+ ```js
73
+ const callout = document.createElement('div');
74
+ callout.textContent = 'Selected place';
75
+ callout.style.position = 'absolute';
76
+ map.overlayElement.append(callout);
77
+ const placeCallout = () => {
78
+ const pin = map.getSelectedMarker();
79
+ if (!pin) { callout.hidden = true; return; }
80
+ const point = map.projectToScreen(pin.lng, pin.lat);
81
+ callout.hidden = !point.visible;
82
+ callout.style.transform = `translate(${point.x}px, ${point.y}px)`;
83
+ };
84
+ for (const event of ['markerchange', 'viewportchange', 'mapresize'])
85
+ map.addEventListener(event, placeCallout);
86
+ ```
87
+
88
+ These coordinates are relative to the canvas, not the page. Callout size,
89
+ collision avoidance, and focus belong to the application. Use `textContent` for
90
+ untrusted text. Consumer-added listeners should be removed on disposal (or use
91
+ an application-owned AbortSignal).
92
+
93
+ ## Motion and dense places
94
+
95
+ Camera motion and marker entrances are independent and optional. Keep stable pin
96
+ IDs across filtering so existing places do not repeatedly drop in. A subsequent
97
+ camera request supersedes an earlier one. Reduced motion wins over application
98
+ options; changing the preference during motion stops at the current position.
99
+
100
+ Clustering uses a deterministic screen-distance grouping in stable marker-ID order using a spatial index. It is
101
+ intended for guide-sized lists, not millions of records. Clicking or pressing Enter
102
+ on a cluster fits its extent and emits `clusteractivate`. Exactly coincident places
103
+ remain clustered even at maximum zoom. Always provide a chooser/list; selecting a
104
+ place removes it from its cluster and draws it above clusters. Avoid silently
105
+ hiding all alternate access when setting `controls.markerPicker:false`.
106
+
107
+ ## Verification expectations
108
+
109
+ Run the existing Node and browser suites plus bundle checks. The Consumer API
110
+ stories exercise transitions, supersession, teardown, reduced motion, clustered
111
+ selection, touch names, and 390px shell replacement. Also inspect the example page
112
+ at desktop/tablet/390px: keyboard focus, drag interruption, labels and marker
113
+ collisions, overflow, console errors, and sources disclosure. Test your own
114
+ container/font/CSS and delayed image loading before claiming page-wide zero CLS.
115
+
116
+ ## Runtime configuration and failure handling
117
+
118
+ Prefer `setFeatures`, `setLayers`, and `setControls` over recreating a map for each
119
+ checkbox. Patches leave omitted keys alone; explicit `false` disables and
120
+ `undefined` resets. Feature option objects replace the previous object, so
121
+ `setFeatures({ motion: { duration: 200 } })` is predictable without hidden retained
122
+ fields. `getFeatures()` is a detached normalized snapshot, safe for consumer reads.
123
+
124
+ Invalid feature/control/layer updates preserve the previous state. Invalid marker
125
+ and overlay replacements preserve the previous list. Removed overlays lose their
126
+ listeners immediately. Repeated disposal cancels all asynchronous work and returns
127
+ touch gestures to the page. Focused markers stay reachable through filtering;
128
+ when a focused marker disappears, focus returns to the map rather than the page
129
+ body. A focused cluster that dissolves also returns focus to the map.
130
+
131
+ The compact mount uses defaults for its controls, then honors caller overrides.
132
+ Enabling native pickers deliberately adds their row. The default shell replacement
133
+ has stable geometry; custom controls, fonts, and application CSS still require
134
+ consumer verification. Shells reject custom SVG dimensions/padding because the
135
+ interactive projection is fixed. Use `createGuideSVG` for custom static sizes.
136
+
137
+ Adversarial checks cover reentrant camera callbacks, invalid/atomic patches,
138
+ feature round trips, control independence, overlay replacement/disposal, detached
139
+ configuration snapshots, keyboard focus, and 2,000-marker grouping. The camera,
140
+ feature normalization, and spatial grouping modules are separate from DOM rendering
141
+ so future options can extend those contracts without rebuilding their lifecycles.
142
+
143
+ ### Adding another optional feature
144
+
145
+ Add its public type and default to the configuration boundary first. Validate and
146
+ copy the full next configuration before touching the current map. Implement enable
147
+ and disable symmetrically, and define what happens to in-flight work when it changes.
148
+ Avoid hiding unrelated controls through a shared parent. Any window/document/media
149
+ listener, observer, animation, or replaceable interactive subtree needs an explicit
150
+ owner and cleanup path. Keep camera timing and geographic grouping independently
151
+ testable rather than embedding more asynchronous state inside renderer callbacks.
152
+
153
+ For each addition, test disabled construction, enabling, disabling, repeated round
154
+ trips, invalid updates, reduced motion where relevant, and disposal. Verify that the
155
+ viewport, selection, keyboard focus, accessible descriptions, and layer legend stay
156
+ consistent. Runtime toggles are not a substitute for selective data imports or
157
+ production bundle measurement. New palette/font/layout choices still need rendered
158
+ consumer checks; passing these tests does not guarantee every arbitrary host CSS or
159
+ data combination.
160
+
161
+ The [maintainer audit matrix](api-audit.md) records option precedence, invalid-input
162
+ behavior, callback reentrancy, ownership, and explicit limits. Interactive options
163
+ reject unknown keys, including nested typos. Shared preset data is immutable;
164
+ clone it before deriving custom collections. The compact mount rejects full
165
+ attribution rather than silently overriding it.