@kahwee/sf-map-svg 1.5.2 → 2.0.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 +40 -0
- package/README.md +145 -6
- package/dist/data/README.md +5 -1
- package/dist/data/types.d.ts +4 -1
- package/dist/src/api.d.ts +3 -0
- package/dist/src/api.js +3 -0
- package/dist/src/camera.d.ts +16 -0
- package/dist/src/camera.js +55 -0
- package/dist/src/clusters.d.ts +7 -0
- package/dist/src/clusters.js +33 -0
- package/dist/src/configuration.d.ts +5 -0
- package/dist/src/configuration.js +73 -0
- package/dist/src/controller-types.d.ts +84 -0
- package/dist/src/controller-types.js +1 -0
- package/dist/src/explorer-core.d.ts +1 -1
- package/dist/src/explorer-core.js +570 -145
- package/dist/src/features.d.ts +24 -0
- package/dist/src/features.js +99 -0
- package/dist/src/guide-data.js +3 -2
- package/dist/src/guide-detailed.js +3 -2
- package/dist/src/guide-map.d.ts +4 -0
- package/dist/src/guide-map.js +35 -10
- package/dist/src/guide-shell.d.ts +2 -0
- package/dist/src/guide-shell.js +16 -0
- package/dist/src/guide-static.d.ts +9 -0
- package/dist/src/guide-static.js +26 -0
- package/dist/src/guide.d.ts +2 -1
- package/dist/src/guide.js +1 -1
- package/dist/src/interactive-data.d.ts +1 -1
- package/dist/src/interactive-data.js +2 -0
- package/dist/src/interactive.d.ts +1 -1
- package/dist/src/interactive.js +2 -0
- package/dist/src/layers.js +3 -1
- package/dist/src/map-core.js +3 -8
- package/dist/src/map.d.ts +7 -0
- package/dist/src/map.js +106 -0
- package/dist/src/presets.d.ts +3 -0
- package/dist/src/presets.js +17 -0
- package/dist/src/static.d.ts +10 -0
- package/dist/src/static.js +5 -0
- package/dist/src/types.d.ts +73 -9
- package/dist/src/validation.d.ts +7 -0
- package/dist/src/validation.js +226 -0
- package/dist/src/viewport.js +9 -2
- package/docs/EXAMPLES.md +11 -1
- package/docs/api-audit.md +80 -0
- package/docs/consumer-integration.md +165 -0
- package/docs/guide-bundle-report.md +19 -0
- package/docs/migration-v2.md +238 -0
- package/package.json +39 -11
package/dist/src/types.d.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/src/viewport.js
CHANGED
|
@@ -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 (
|
|
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([
|
|
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.
|