@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.
- package/CHANGELOG.md +17 -0
- package/README.md +8 -4
- package/dist/src/district-layer.d.ts +15 -0
- package/dist/src/district-layer.js +51 -0
- package/dist/src/district-style.d.ts +4 -0
- package/dist/src/district-style.js +23 -0
- package/dist/src/district-transition.d.ts +5 -0
- package/dist/src/district-transition.js +38 -0
- package/dist/src/dom.d.ts +2 -0
- package/dist/src/dom.js +14 -0
- package/dist/src/explorer-core.js +69 -179
- package/dist/src/full-data.js +2 -1
- package/dist/src/guide-map.d.ts +6 -1
- package/dist/src/guide-map.js +60 -18
- package/dist/src/guide.d.ts +2 -1
- package/dist/src/guide.js +1 -1
- package/dist/src/label-renderer.d.ts +17 -0
- package/dist/src/label-renderer.js +81 -0
- package/dist/src/map-core.js +8 -23
- package/dist/src/marker-layer.d.ts +22 -0
- package/dist/src/marker-layer.js +64 -0
- package/dist/src/static-data.js +3 -1
- package/dist/src/types.d.ts +1 -1
- package/dist/src/validation.js +5 -0
- package/docs/EXAMPLES.md +12 -12
- package/docs/api-audit.md +8 -3
- package/docs/consumer-integration.md +10 -5
- package/docs/guide-bundle-report.md +4 -4
- package/docs/migration-v3.md +9 -3
- package/package.json +3 -2
package/dist/src/full-data.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { districtMaps
|
|
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. */
|
package/dist/src/guide-map.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
package/dist/src/guide-map.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/src/guide.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/src/map-core.js
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 =
|
|
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
|
+
}
|
package/dist/src/static-data.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
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. */
|
package/dist/src/types.d.ts
CHANGED
|
@@ -41,7 +41,7 @@ export interface SFMapOptions {
|
|
|
41
41
|
districtLines?: boolean;
|
|
42
42
|
neighborhoodLines?: boolean;
|
|
43
43
|
districtFills?: boolean;
|
|
44
|
-
/**
|
|
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. */
|
package/dist/src/validation.js
CHANGED
|
@@ -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 {
|
|
20
|
+
import { staticMapData } from '@kahwee/sf-map-svg/data/static';
|
|
21
21
|
|
|
22
|
-
const svg = renderMap(
|
|
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
|
|
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 [
|
|
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 {
|
|
42
|
+
import { createGuideController } from '@kahwee/sf-map-svg/guide';
|
|
44
43
|
|
|
45
|
-
const map =
|
|
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](
|
|
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 {
|
|
74
|
+
import { createGuideController } from '@kahwee/sf-map-svg/guide';
|
|
75
75
|
|
|
76
|
-
const map =
|
|
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 `
|
|
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`,
|
|
47
|
+
`stories/Enhancements.stories.ts`, `stories/Motion.stories.ts`,
|
|
48
|
+
`stories/Controller.stories.ts`, and `scripts/smoke-package.mjs`.
|
|
44
49
|
|
|
45
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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-
|
|
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) |
|
|
8
|
-
| v3 static renderer |
|
|
9
|
-
| Guide preset |
|
|
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
|
|
package/docs/migration-v3.md
CHANGED
|
@@ -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(
|
|
8
|
-
| `/legacy` `createSFMap(options)` | `renderMap(
|
|
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
|
-
`
|
|
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.
|
|
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",
|