@kahwee/sf-map-svg 1.5.1 → 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 +45 -0
- package/README.md +146 -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 +40 -10
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { MapFeatures } from './types.js';
|
|
2
|
+
/** One normalization boundary shared by construction and atomic runtime patches. */
|
|
3
|
+
export declare function normalizeFeatures(input?: MapFeatures): {
|
|
4
|
+
motion: false | {
|
|
5
|
+
duration: number;
|
|
6
|
+
};
|
|
7
|
+
markerEntrance: false | {
|
|
8
|
+
duration: number;
|
|
9
|
+
stagger: number;
|
|
10
|
+
};
|
|
11
|
+
clustering: false | {
|
|
12
|
+
radius: number;
|
|
13
|
+
};
|
|
14
|
+
selectedMarkerRing: false | {
|
|
15
|
+
color: string | undefined;
|
|
16
|
+
width: number;
|
|
17
|
+
gap: number;
|
|
18
|
+
};
|
|
19
|
+
northArrow: boolean;
|
|
20
|
+
scaleBar: boolean;
|
|
21
|
+
};
|
|
22
|
+
export declare const layerKeys: readonly ['districtFills', 'districtLines', 'districtLabels', 'neighborhoodLines', 'neighborhoodLabels', 'landmarks', 'bartStations', 'highways', 'keyRoads', 'roadLabels'];
|
|
23
|
+
export declare const controlKeys: readonly ['zoom', 'pan', 'reset', 'labels', 'touch', 'legend', 'neighborhoodPicker', 'markerPicker', 'help', 'status'];
|
|
24
|
+
export declare function validateSwitchPatch(patch: unknown, keys: readonly string[]): void;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { assertOptions } from './validation.js';
|
|
2
|
+
function objectOption(value, name) {
|
|
3
|
+
if (value === undefined || value === false)
|
|
4
|
+
return false;
|
|
5
|
+
if (value === true)
|
|
6
|
+
return {};
|
|
7
|
+
assertOptions(value, name, name === 'motion'
|
|
8
|
+
? ['duration']
|
|
9
|
+
: name === 'markerEntrance'
|
|
10
|
+
? ['duration', 'stagger']
|
|
11
|
+
: name === 'clustering'
|
|
12
|
+
? ['radius']
|
|
13
|
+
: ['color', 'width', 'gap']);
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
function numberOption(value, fallback, name, positive = false) {
|
|
17
|
+
const result = value === undefined ? fallback : value;
|
|
18
|
+
if (typeof result !== 'number' ||
|
|
19
|
+
!Number.isFinite(result) ||
|
|
20
|
+
(positive ? result <= 0 : result < 0))
|
|
21
|
+
throw new RangeError(`${name} must be finite and ${positive ? 'positive' : 'nonnegative'}.`);
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
function flag(value, name) {
|
|
25
|
+
if (value !== undefined && typeof value !== 'boolean')
|
|
26
|
+
throw new TypeError(`${name} must be a boolean.`);
|
|
27
|
+
return value ?? false;
|
|
28
|
+
}
|
|
29
|
+
/** One normalization boundary shared by construction and atomic runtime patches. */
|
|
30
|
+
export function normalizeFeatures(input = {}) {
|
|
31
|
+
assertOptions(input, 'features');
|
|
32
|
+
const keys = [
|
|
33
|
+
'motion',
|
|
34
|
+
'markerEntrance',
|
|
35
|
+
'clustering',
|
|
36
|
+
'selectedMarkerRing',
|
|
37
|
+
'northArrow',
|
|
38
|
+
'scaleBar',
|
|
39
|
+
];
|
|
40
|
+
for (const key of Object.keys(input))
|
|
41
|
+
if (!keys.includes(key))
|
|
42
|
+
throw new TypeError(`Unknown feature: ${key}`);
|
|
43
|
+
const motion = objectOption(input.motion, 'motion');
|
|
44
|
+
const entrance = objectOption(input.markerEntrance, 'markerEntrance');
|
|
45
|
+
const clustering = objectOption(input.clustering, 'clustering');
|
|
46
|
+
const ring = objectOption(input.selectedMarkerRing, 'selectedMarkerRing');
|
|
47
|
+
if (ring && ring.color !== undefined && typeof ring.color !== 'string')
|
|
48
|
+
throw new TypeError('Ring color must be a string.');
|
|
49
|
+
return {
|
|
50
|
+
motion: motion && { duration: numberOption(motion.duration, 320, 'Motion duration') },
|
|
51
|
+
markerEntrance: entrance && {
|
|
52
|
+
duration: numberOption(entrance.duration, 420, 'Entrance duration'),
|
|
53
|
+
stagger: numberOption(entrance.stagger, 35, 'Entrance stagger'),
|
|
54
|
+
},
|
|
55
|
+
clustering: clustering && {
|
|
56
|
+
radius: numberOption(clustering.radius, 32, 'Cluster radius', true),
|
|
57
|
+
},
|
|
58
|
+
selectedMarkerRing: ring && {
|
|
59
|
+
color: ring.color,
|
|
60
|
+
width: numberOption(ring.width, 2, 'Ring width'),
|
|
61
|
+
gap: numberOption(ring.gap, 3, 'Ring gap'),
|
|
62
|
+
},
|
|
63
|
+
northArrow: flag(input.northArrow, 'northArrow'),
|
|
64
|
+
scaleBar: flag(input.scaleBar, 'scaleBar'),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export const layerKeys = [
|
|
68
|
+
'districtFills',
|
|
69
|
+
'districtLines',
|
|
70
|
+
'districtLabels',
|
|
71
|
+
'neighborhoodLines',
|
|
72
|
+
'neighborhoodLabels',
|
|
73
|
+
'landmarks',
|
|
74
|
+
'bartStations',
|
|
75
|
+
'highways',
|
|
76
|
+
'keyRoads',
|
|
77
|
+
'roadLabels',
|
|
78
|
+
];
|
|
79
|
+
export const controlKeys = [
|
|
80
|
+
'zoom',
|
|
81
|
+
'pan',
|
|
82
|
+
'reset',
|
|
83
|
+
'labels',
|
|
84
|
+
'touch',
|
|
85
|
+
'legend',
|
|
86
|
+
'neighborhoodPicker',
|
|
87
|
+
'markerPicker',
|
|
88
|
+
'help',
|
|
89
|
+
'status',
|
|
90
|
+
];
|
|
91
|
+
export function validateSwitchPatch(patch, keys) {
|
|
92
|
+
const record = assertOptions(patch, 'switches');
|
|
93
|
+
for (const [key, value] of Object.entries(record)) {
|
|
94
|
+
if (!keys.includes(key))
|
|
95
|
+
throw new TypeError(`Unknown switch: ${key}`);
|
|
96
|
+
if (value !== undefined && typeof value !== 'boolean')
|
|
97
|
+
throw new TypeError(`${key} must be boolean.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
package/dist/src/guide-data.js
CHANGED
|
@@ -4,13 +4,14 @@ import highways from '../data/guide/highways.json' with { type: 'json' };
|
|
|
4
4
|
import roads from '../data/guide/key-roads.json' with { type: 'json' };
|
|
5
5
|
import landmarks from '../data/guide/landmarks.json' with { type: 'json' };
|
|
6
6
|
import neighborhoods from '../data/guide/neighborhoods-realtor.json' with { type: 'json' };
|
|
7
|
+
import { deepFreeze } from './immutable.js';
|
|
7
8
|
const neighborhoodData = neighborhoods;
|
|
8
9
|
const highwayData = highways;
|
|
9
10
|
const parkData = landmarks;
|
|
10
11
|
const roadData = roads;
|
|
11
12
|
const stationData = bart;
|
|
12
13
|
/** Compact, source-aware geography with no district or alternate-neighborhood files. */
|
|
13
|
-
export const guideMapData = {
|
|
14
|
+
export const guideMapData = deepFreeze({
|
|
14
15
|
map: {
|
|
15
16
|
coast: coast.features[0].geometry,
|
|
16
17
|
neighborhoods: neighborhoodData.features.map((feature) => ({
|
|
@@ -38,4 +39,4 @@ export const guideMapData = {
|
|
|
38
39
|
: []),
|
|
39
40
|
},
|
|
40
41
|
neighborhoods: { realtor: neighborhoodData },
|
|
41
|
-
};
|
|
42
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deepFreeze } from './immutable.js';
|
|
1
2
|
/** Load detailed selected geography only after the caller requests it. */
|
|
2
3
|
export async function loadGuideDetailedData() {
|
|
3
4
|
const [coastData, neighborhoodData, parkData, highwayData, roadData, bartData] = await Promise.all([
|
|
@@ -13,7 +14,7 @@ export async function loadGuideDetailedData() {
|
|
|
13
14
|
const detailedHighways = highwayData.default;
|
|
14
15
|
const detailedParks = parkData.default;
|
|
15
16
|
const detailedStations = bartData.default;
|
|
16
|
-
return {
|
|
17
|
+
return deepFreeze({
|
|
17
18
|
map: {
|
|
18
19
|
coast: coastData.default.features[0].geometry,
|
|
19
20
|
neighborhoods: detailedNeighborhoods.features.map((feature) => ({
|
|
@@ -41,5 +42,5 @@ export async function loadGuideDetailedData() {
|
|
|
41
42
|
: []),
|
|
42
43
|
},
|
|
43
44
|
neighborhoods: { realtor: detailedNeighborhoods },
|
|
44
|
-
};
|
|
45
|
+
});
|
|
45
46
|
}
|
package/dist/src/guide-map.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
import type { InteractiveSFMapElement, InteractiveSFMapOptions } from './interactive-data.js';
|
|
2
2
|
/** Guide map preset: SFAR neighborhoods, major parks, BART, and curated roads. */
|
|
3
3
|
export declare function createGuideMap(options?: InteractiveSFMapOptions): InteractiveSFMapElement;
|
|
4
|
+
/** Enhance createGuideShell() in place with a fixed compact chrome layout.
|
|
5
|
+
* Keep an accessible external place list when hiding the native pickers.
|
|
6
|
+
*/
|
|
7
|
+
export declare function mountGuideMap(shell: HTMLElement, options?: InteractiveSFMapOptions): InteractiveSFMapElement;
|
package/dist/src/guide-map.js
CHANGED
|
@@ -1,22 +1,47 @@
|
|
|
1
1
|
import { guideMapData } from './guide-data.js';
|
|
2
2
|
import { createInteractiveSFMapWithData } from './interactive-data.js';
|
|
3
|
+
import { guideOptions } from './presets.js';
|
|
4
|
+
import { validateExplorerOptions } from './validation.js';
|
|
3
5
|
/** Guide map preset: SFAR neighborhoods, major parks, BART, and curated roads. */
|
|
4
6
|
export function createGuideMap(options = {}) {
|
|
7
|
+
validateExplorerOptions(options);
|
|
5
8
|
return createInteractiveSFMapWithData(guideMapData, {
|
|
6
9
|
mode: 'neighborhoods',
|
|
7
10
|
...options,
|
|
8
11
|
layers: {
|
|
9
|
-
|
|
10
|
-
districtLines: false,
|
|
11
|
-
districtLabels: false,
|
|
12
|
-
neighborhoodLines: true,
|
|
13
|
-
neighborhoodLabels: true,
|
|
14
|
-
landmarks: true,
|
|
15
|
-
bartStations: true,
|
|
16
|
-
highways: true,
|
|
17
|
-
keyRoads: true,
|
|
18
|
-
roadLabels: true,
|
|
12
|
+
...guideOptions.layers,
|
|
19
13
|
...options.layers,
|
|
20
14
|
},
|
|
21
15
|
});
|
|
22
16
|
}
|
|
17
|
+
/** Enhance createGuideShell() in place with a fixed compact chrome layout.
|
|
18
|
+
* Keep an accessible external place list when hiding the native pickers.
|
|
19
|
+
*/
|
|
20
|
+
export function mountGuideMap(shell, options = {}) {
|
|
21
|
+
validateExplorerOptions(options);
|
|
22
|
+
if (options.attribution === 'full')
|
|
23
|
+
throw new TypeError('The guide shell requires compact attribution; use createGuideMap for full attribution.');
|
|
24
|
+
if (!shell.classList.contains('sf-guide-shell'))
|
|
25
|
+
throw new TypeError('Expected a createGuideShell container.');
|
|
26
|
+
const frame = shell.querySelector(':scope > .sf-guide-frame');
|
|
27
|
+
if (!frame || frame.classList.contains('sf-explorer'))
|
|
28
|
+
throw new Error('Shell is missing its static frame or already mounted.');
|
|
29
|
+
const map = createGuideMap({
|
|
30
|
+
...options,
|
|
31
|
+
attribution: 'compact',
|
|
32
|
+
controls: {
|
|
33
|
+
pan: false,
|
|
34
|
+
labels: false,
|
|
35
|
+
neighborhoodPicker: false,
|
|
36
|
+
markerPicker: false,
|
|
37
|
+
help: false,
|
|
38
|
+
status: false,
|
|
39
|
+
legend: true,
|
|
40
|
+
...options.controls,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
map.classList.add('sf-guide-frame');
|
|
44
|
+
map.querySelector('.sf-explorer-map-column > details')?.classList.add('sf-guide-sources');
|
|
45
|
+
frame.replaceWith(map);
|
|
46
|
+
return map;
|
|
47
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/** Shared compact-embed geometry; keep server and mounted frames identical. */
|
|
2
|
+
export declare const guideShellCSS = "\n.sf-guide-shell{max-width:1120px;margin:auto}\n.sf-guide-shell .sf-guide-frame{display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:64px auto auto 44px minmax(40px,auto);border:1px solid #cedae3;border-radius:14px;overflow:hidden;margin:0;max-width:none;font:14px/1.5 system-ui,sans-serif}\n.sf-guide-shell .sf-guide-frame[data-interface] .sf-explorer-body,.sf-guide-shell .sf-guide-frame .sf-explorer-map-column{display:contents}\n.sf-guide-shell .sf-explorer-toolbar{grid-row:1;flex-wrap:nowrap;overflow-x:auto;padding:8px;margin:0}\n.sf-guide-shell .sf-explorer-control-group{flex-wrap:nowrap;max-width:none;flex-shrink:0}\n.sf-guide-shell .sf-explorer-toolbar button{white-space:nowrap}\n.sf-guide-shell .sf-explorer-feature-controls{grid-row:2}\n.sf-guide-shell .sf-guide-placeholder:nth-child(3){grid-row:4}\n.sf-guide-shell .sf-explorer-canvas{grid-row:3;aspect-ratio:1;min-width:0}\n.sf-guide-shell .sf-explorer-canvas>svg{display:block;width:100%;height:100%}\n.sf-guide-shell .sf-explorer-legend{grid-row:4;flex-wrap:nowrap;overflow-x:auto;padding:10px 14px;align-items:center;margin:0}\n.sf-guide-shell .sf-guide-sources{grid-row:5;box-sizing:border-box;min-height:40px;padding:8px 14px;margin:0;font:12px/24px system-ui,sans-serif;background:#fff;color:#496578}\n.sf-guide-shell .sf-guide-placeholder{box-sizing:border-box;padding:12px 14px;background:#fff;color:#496578}\n";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Shared compact-embed geometry; keep server and mounted frames identical. */
|
|
2
|
+
export const guideShellCSS = `
|
|
3
|
+
.sf-guide-shell{max-width:1120px;margin:auto}
|
|
4
|
+
.sf-guide-shell .sf-guide-frame{display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:64px auto auto 44px minmax(40px,auto);border:1px solid #cedae3;border-radius:14px;overflow:hidden;margin:0;max-width:none;font:14px/1.5 system-ui,sans-serif}
|
|
5
|
+
.sf-guide-shell .sf-guide-frame[data-interface] .sf-explorer-body,.sf-guide-shell .sf-guide-frame .sf-explorer-map-column{display:contents}
|
|
6
|
+
.sf-guide-shell .sf-explorer-toolbar{grid-row:1;flex-wrap:nowrap;overflow-x:auto;padding:8px;margin:0}
|
|
7
|
+
.sf-guide-shell .sf-explorer-control-group{flex-wrap:nowrap;max-width:none;flex-shrink:0}
|
|
8
|
+
.sf-guide-shell .sf-explorer-toolbar button{white-space:nowrap}
|
|
9
|
+
.sf-guide-shell .sf-explorer-feature-controls{grid-row:2}
|
|
10
|
+
.sf-guide-shell .sf-guide-placeholder:nth-child(3){grid-row:4}
|
|
11
|
+
.sf-guide-shell .sf-explorer-canvas{grid-row:3;aspect-ratio:1;min-width:0}
|
|
12
|
+
.sf-guide-shell .sf-explorer-canvas>svg{display:block;width:100%;height:100%}
|
|
13
|
+
.sf-guide-shell .sf-explorer-legend{grid-row:4;flex-wrap:nowrap;overflow-x:auto;padding:10px 14px;align-items:center;margin:0}
|
|
14
|
+
.sf-guide-shell .sf-guide-sources{grid-row:5;box-sizing:border-box;min-height:40px;padding:8px 14px;margin:0;font:12px/24px system-ui,sans-serif;background:#fff;color:#496578}
|
|
15
|
+
.sf-guide-shell .sf-guide-placeholder{box-sizing:border-box;padding:12px 14px;background:#fff;color:#496578}
|
|
16
|
+
`;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SFMapOptions } from './types.js';
|
|
2
|
+
/** Server-safe overview using exactly the guide browser geography. */
|
|
3
|
+
export declare function createGuideSVG(options?: SFMapOptions): {
|
|
4
|
+
svg: string;
|
|
5
|
+
project: (coordinates: import("../data/types.js").Position) => [number, number];
|
|
6
|
+
viewBox: [number, number, number, number];
|
|
7
|
+
};
|
|
8
|
+
/** Stable compact frame, progressively enhanced with mountGuideMap(). No browser globals. */
|
|
9
|
+
export declare function createGuideShell(options?: SFMapOptions): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { guideMapData } from './guide-data.js';
|
|
2
|
+
import { guideShellCSS } from './guide-shell.js';
|
|
3
|
+
import { createSFMapWithData } from './map-core.js';
|
|
4
|
+
/** Server-safe overview using exactly the guide browser geography. */
|
|
5
|
+
export function createGuideSVG(options = {}) {
|
|
6
|
+
return createSFMapWithData({
|
|
7
|
+
theme: 'transit',
|
|
8
|
+
districtFills: false,
|
|
9
|
+
districtLines: false,
|
|
10
|
+
districtLabels: false,
|
|
11
|
+
neighborhoodLines: true,
|
|
12
|
+
landmarks: true,
|
|
13
|
+
highways: true,
|
|
14
|
+
keyRoads: true,
|
|
15
|
+
bartStations: true,
|
|
16
|
+
...options,
|
|
17
|
+
}, guideMapData.map);
|
|
18
|
+
}
|
|
19
|
+
/** Stable compact frame, progressively enhanced with mountGuideMap(). No browser globals. */
|
|
20
|
+
export function createGuideShell(options = {}) {
|
|
21
|
+
if ((options.width !== undefined && options.width !== 800) ||
|
|
22
|
+
(options.height !== undefined && options.height !== 800) ||
|
|
23
|
+
(options.padding !== undefined && options.padding !== 28))
|
|
24
|
+
throw new RangeError('Guide shells use the interactive 800 by 800 projection with 28 units of padding. Use createGuideSVG for custom dimensions.');
|
|
25
|
+
return `<div class="sf-guide-shell"><style>${guideShellCSS}</style><div class="sf-guide-frame"><div class="sf-guide-placeholder" aria-hidden="true">Map controls</div><div class="sf-explorer-canvas">${createGuideSVG(options).svg}</div><div class="sf-guide-placeholder" aria-hidden="true">Map legend</div><div class="sf-guide-sources">Map data · DataSF / BART</div></div></div>`;
|
|
26
|
+
}
|
package/dist/src/guide.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type { InteractiveSFMapData } from './explorer-data.js';
|
|
2
2
|
export { guideMapData } from './guide-data.js';
|
|
3
3
|
export { loadGuideDetailedData } from './guide-detailed.js';
|
|
4
|
-
export { createGuideMap } from './guide-map.js';
|
|
4
|
+
export { createGuideMap, mountGuideMap } from './guide-map.js';
|
|
5
5
|
export type { InteractiveSFMapElement, InteractiveSFMapOptions } from './interactive-data.js';
|
|
6
|
+
export type { CameraOptions, MapFeatures, MapMarker } from './types.js';
|
package/dist/src/guide.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { InteractiveSFMapData } from './explorer-data.js';
|
|
2
2
|
import type { NeighborhoodExplorerOptions } from './types.js';
|
|
3
3
|
export type { InteractiveSFMapData } from './explorer-data.js';
|
|
4
|
-
export type { InteractiveLayers, MapPadding, MapViewport, NeighborhoodExplorerElement as InteractiveSFMapElement, NeighborhoodExplorerOptions as InteractiveSFMapOptions, NeighborhoodSelection, } from './types.js';
|
|
4
|
+
export type { CameraOptions, InteractiveLayers, MapFeatures, MapMarker, MapPadding, MapViewport, NeighborhoodExplorerElement as InteractiveSFMapElement, NeighborhoodExplorerOptions as InteractiveSFMapOptions, NeighborhoodSelection, } from './types.js';
|
|
5
5
|
/** Browser map shell that bundles only the geography supplied by the caller. */
|
|
6
6
|
export declare function createInteractiveSFMapWithData(data: InteractiveSFMapData, options?: NeighborhoodExplorerOptions): import("./types.js").NeighborhoodExplorerElement;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createNeighborhoodExplorerCore } from './explorer-core.js';
|
|
2
|
+
import { validateExplorerOptions } from './validation.js';
|
|
2
3
|
/** Browser map shell that bundles only the geography supplied by the caller. */
|
|
3
4
|
export function createInteractiveSFMapWithData(data, options = {}) {
|
|
5
|
+
validateExplorerOptions(options);
|
|
4
6
|
return createNeighborhoodExplorerCore({ mode: 'basemap', ...options, interface: 'map' }, data);
|
|
5
7
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { NeighborhoodExplorerOptions } from './types.js';
|
|
2
|
-
export type { InteractiveLayers, MapOverlay, MapPadding, MapViewport, NeighborhoodExplorerElement as InteractiveSFMapElement, NeighborhoodExplorerOptions as InteractiveSFMapOptions, NeighborhoodSelection, } from './types.js';
|
|
2
|
+
export type { CameraOptions, InteractiveLayers, MapFeatures, MapMarker, MapOverlay, MapPadding, MapViewport, NeighborhoodExplorerElement as InteractiveSFMapElement, NeighborhoodExplorerOptions as InteractiveSFMapOptions, NeighborhoodSelection, } from './types.js';
|
|
3
3
|
/** Reusable browser map; plain basemap by default, no editorial UI or URL changes. */
|
|
4
4
|
export declare function createInteractiveSFMap(options?: NeighborhoodExplorerOptions): import("./types.js").NeighborhoodExplorerElement;
|
package/dist/src/interactive.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createNeighborhoodExplorer } from './explorer.js';
|
|
2
|
+
import { validateExplorerOptions } from './validation.js';
|
|
2
3
|
/** Reusable browser map; plain basemap by default, no editorial UI or URL changes. */
|
|
3
4
|
export function createInteractiveSFMap(options = {}) {
|
|
5
|
+
validateExplorerOptions(options);
|
|
4
6
|
return createNeighborhoodExplorer({ mode: 'basemap', ...options, interface: 'map' });
|
|
5
7
|
}
|
package/dist/src/layers.js
CHANGED
|
@@ -46,8 +46,10 @@ export function bartStations(items, { project, colors, labels }) {
|
|
|
46
46
|
export function markers(items, { project, colors }) {
|
|
47
47
|
return `<g data-layer="markers">${items
|
|
48
48
|
.map((marker) => {
|
|
49
|
+
if (marker.radius !== undefined && (!Number.isFinite(marker.radius) || marker.radius <= 0))
|
|
50
|
+
throw new RangeError('Marker radius must be positive and finite.');
|
|
49
51
|
const [x, y] = project([marker.lng, marker.lat]).map(number);
|
|
50
|
-
return `<circle data-marker-id="${escapeXml(marker.id)}" cx="${x}" cy="${y}" r="${marker.selected ? 8 : 5}" fill="${escapeXml(marker.color ?? (marker.selected ? colors.selected : colors.marker))}" stroke="#fff9e9" stroke-width="2" vector-effect="non-scaling-stroke"><title>${escapeXml(marker.label ?? marker.id)}</title></circle>`;
|
|
52
|
+
return `<circle data-marker-id="${escapeXml(marker.id)}" cx="${x}" cy="${y}" r="${marker.radius ?? (marker.selected ? 8 : 5)}" fill="${escapeXml(marker.color ?? (marker.selected ? colors.selected : colors.marker))}" stroke="#fff9e9" stroke-width="2" vector-effect="non-scaling-stroke"><title>${escapeXml(marker.label ?? marker.id)}</title></circle>`;
|
|
51
53
|
})
|
|
52
54
|
.join('')}</g>`;
|
|
53
55
|
}
|
package/dist/src/map-core.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateMarkers, validateOverlays } from './validation.js';
|
|
1
2
|
import { geometryPath, positions, rawProject } from './geometry.js';
|
|
2
3
|
import * as layers from './layers.js';
|
|
3
4
|
import { escapeXml, stroke } from './svg.js';
|
|
@@ -46,6 +47,8 @@ let sequence = 0;
|
|
|
46
47
|
/** Make an offline SVG and the matching longitude/latitude projection. */
|
|
47
48
|
export function createSFMapWithData(options, data) {
|
|
48
49
|
const { theme = 'districts', width = 800, height = 800, padding = 28, year = 2022, districtLines = true, neighborhoodLines = false, districtFills = true, districtLabels = true, labels = true, highways = false, keyRoads = false, landmarks = false, bartStations = false, markers = [], title = 'San Francisco map', idPrefix = `sf-map-${++sequence}`, } = options;
|
|
50
|
+
validateMarkers(markers);
|
|
51
|
+
validateOverlays(options.overlays ?? []);
|
|
49
52
|
const roadLabels = options.roadLabels ?? keyRoads;
|
|
50
53
|
if (![width, height, padding].every(Number.isFinite) ||
|
|
51
54
|
width <= 0 ||
|
|
@@ -113,14 +116,6 @@ export function createSFMapWithData(options, data) {
|
|
|
113
116
|
if (bartStations)
|
|
114
117
|
parts.push(layers.bartStations(stationData, context));
|
|
115
118
|
for (const overlay of options.overlays ?? []) {
|
|
116
|
-
if (!overlay.id || !/^[A-Za-z0-9_-]+$/.test(overlay.id))
|
|
117
|
-
throw new TypeError('Overlay IDs must contain only letters, numbers, underscores, or hyphens.');
|
|
118
|
-
if (overlay.strokeWidth !== undefined &&
|
|
119
|
-
(!Number.isFinite(overlay.strokeWidth) || overlay.strokeWidth < 0))
|
|
120
|
-
throw new RangeError('Overlay strokeWidth must be a finite nonnegative number.');
|
|
121
|
-
if (overlay.fillOpacity !== undefined &&
|
|
122
|
-
(!Number.isFinite(overlay.fillOpacity) || overlay.fillOpacity < 0 || overlay.fillOpacity > 1))
|
|
123
|
-
throw new RangeError('Overlay fillOpacity must be a finite number from 0 to 1.');
|
|
124
119
|
parts.push(`<path data-overlay-id="${escapeXml(overlay.id)}"${overlay.label ? ` aria-label="${escapeXml(overlay.label)}"` : ''} d="${path(overlay.geometry)}" fill="${escapeXml(overlay.fill ?? 'none')}" fill-opacity="${overlay.fillOpacity ?? 1}" stroke="${escapeXml(overlay.stroke ?? colors.road)}" stroke-width="${overlay.strokeWidth ?? 2}" vector-effect="non-scaling-stroke"${overlay.visible === false ? ' display="none"' : ''}/>`);
|
|
125
120
|
}
|
|
126
121
|
parts.push(layers.markers(markers, context), '</g></svg>');
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { MapController, MapOptions } from './controller-types.js';
|
|
2
|
+
import { type InteractiveSFMapData } from './interactive-data.js';
|
|
3
|
+
export type * from './controller-types.js';
|
|
4
|
+
export type { InteractiveSFMapData as MapData } from './explorer-data.js';
|
|
5
|
+
export type { CameraOptions, InteractiveLayers, MapFeatures, MapMarker, MapOverlay, MapPadding, MapViewport, NeighborhoodSelection, } from './types.js';
|
|
6
|
+
/** No geography is imported. Supply a preset or your own immutable data. */
|
|
7
|
+
export declare function createMap(data: InteractiveSFMapData, options?: MapOptions): MapController;
|
package/dist/src/map.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { validateCameraOptions } from './camera.js';
|
|
2
|
+
import { expandMapOptions, prepareConfiguration } from './configuration.js';
|
|
3
|
+
import { controlKeys, layerKeys } from './features.js';
|
|
4
|
+
import { createInteractiveSFMapWithData } from './interactive-data.js';
|
|
5
|
+
import { assertOptions } from './validation.js';
|
|
6
|
+
/** No geography is imported. Supply a preset or your own immutable data. */
|
|
7
|
+
export function createMap(data, options = {}) {
|
|
8
|
+
const expanded = expandMapOptions(options);
|
|
9
|
+
let config = prepareConfiguration({ features: {}, layers: {}, controls: {} }, {
|
|
10
|
+
features: options.features,
|
|
11
|
+
layers: options.layers,
|
|
12
|
+
controls: options.controls,
|
|
13
|
+
});
|
|
14
|
+
const element = createInteractiveSFMapWithData(data, {
|
|
15
|
+
...expanded,
|
|
16
|
+
onOverlayActivate: () => { },
|
|
17
|
+
});
|
|
18
|
+
const subscriptions = new Set();
|
|
19
|
+
let destroyed = false;
|
|
20
|
+
function active() {
|
|
21
|
+
if (destroyed)
|
|
22
|
+
throw new Error('Map controller has been destroyed.');
|
|
23
|
+
}
|
|
24
|
+
const use = (fn) => (...args) => {
|
|
25
|
+
active();
|
|
26
|
+
return fn(...args);
|
|
27
|
+
};
|
|
28
|
+
const camera = Object.freeze({
|
|
29
|
+
get: use(element.getViewport),
|
|
30
|
+
set: use(element.setViewport),
|
|
31
|
+
stop: use(element.stopAnimation),
|
|
32
|
+
pan: use(element.panBy),
|
|
33
|
+
zoom: use(element.zoomBy),
|
|
34
|
+
reset: use(element.resetView),
|
|
35
|
+
fit: use((geometry, fitOptions = {}) => {
|
|
36
|
+
assertOptions(fitOptions, 'fit', ['padding', 'animate', 'duration']);
|
|
37
|
+
const { padding, ...motion } = fitOptions;
|
|
38
|
+
validateCameraOptions(motion);
|
|
39
|
+
element.fitGeometry(geometry, padding, motion);
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
return Object.freeze({
|
|
43
|
+
element,
|
|
44
|
+
overlayElement: element.overlayElement,
|
|
45
|
+
camera,
|
|
46
|
+
get destroyed() {
|
|
47
|
+
return destroyed;
|
|
48
|
+
},
|
|
49
|
+
configure: use((patch) => {
|
|
50
|
+
const next = prepareConfiguration(config, patch);
|
|
51
|
+
// Full switch patches are needed so removed overrides reset engine defaults.
|
|
52
|
+
const layers = Object.fromEntries(layerKeys.map((key) => [key, next.layers[key]]));
|
|
53
|
+
const controls = Object.fromEntries(controlKeys.map((key) => [key, next.controls[key]]));
|
|
54
|
+
if ('features' in patch)
|
|
55
|
+
element.setFeatures(patch.features ?? next.features);
|
|
56
|
+
if ('layers' in patch)
|
|
57
|
+
element.setLayers(layers);
|
|
58
|
+
if ('controls' in patch)
|
|
59
|
+
element.setControls(controls);
|
|
60
|
+
config = next;
|
|
61
|
+
}),
|
|
62
|
+
getConfiguration: use(() => structuredClone(config)),
|
|
63
|
+
on: use((type, listener) => {
|
|
64
|
+
if (![
|
|
65
|
+
'markerchange',
|
|
66
|
+
'neighborhoodchange',
|
|
67
|
+
'overlayactivate',
|
|
68
|
+
'clusteractivate',
|
|
69
|
+
'viewportchange',
|
|
70
|
+
'mapresize',
|
|
71
|
+
].includes(type) ||
|
|
72
|
+
typeof listener !== 'function')
|
|
73
|
+
throw new TypeError('Expected a supported map event and listener.');
|
|
74
|
+
const handle = (event) => {
|
|
75
|
+
if (event.target === element)
|
|
76
|
+
listener(structuredClone(event.detail ?? undefined));
|
|
77
|
+
};
|
|
78
|
+
element.addEventListener(type, handle);
|
|
79
|
+
const unsubscribe = () => {
|
|
80
|
+
element.removeEventListener(type, handle);
|
|
81
|
+
subscriptions.delete(unsubscribe);
|
|
82
|
+
};
|
|
83
|
+
subscriptions.add(unsubscribe);
|
|
84
|
+
return unsubscribe;
|
|
85
|
+
}),
|
|
86
|
+
setMarkers: use(element.setMarkers),
|
|
87
|
+
setOverlays: use(element.setOverlays),
|
|
88
|
+
selectMarker: use(element.selectMarker),
|
|
89
|
+
getSelectedMarker: use(element.getSelectedMarker),
|
|
90
|
+
selectNeighborhood: use(element.selectNeighborhood),
|
|
91
|
+
getSelectedNeighborhood: use(element.getSelection),
|
|
92
|
+
setSource: use(element.setSource),
|
|
93
|
+
setMode: use(element.setMode),
|
|
94
|
+
setLabels: use(element.setLabels),
|
|
95
|
+
setTouchNavigation: use(element.setTouchNavigation),
|
|
96
|
+
projectToScreen: use(element.projectToScreen),
|
|
97
|
+
destroy() {
|
|
98
|
+
if (destroyed)
|
|
99
|
+
return;
|
|
100
|
+
destroyed = true;
|
|
101
|
+
for (const unsubscribe of subscriptions)
|
|
102
|
+
unsubscribe();
|
|
103
|
+
element.destroy();
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { deepFreeze } from './immutable.js';
|
|
2
|
+
/** Configuration only: pair with guide/data or a compatible caller-supplied dataset. */
|
|
3
|
+
export const guideOptions = deepFreeze({
|
|
4
|
+
mode: 'neighborhoods',
|
|
5
|
+
layers: {
|
|
6
|
+
districtFills: false,
|
|
7
|
+
districtLines: false,
|
|
8
|
+
districtLabels: false,
|
|
9
|
+
neighborhoodLines: true,
|
|
10
|
+
neighborhoodLabels: true,
|
|
11
|
+
landmarks: true,
|
|
12
|
+
bartStations: true,
|
|
13
|
+
highways: true,
|
|
14
|
+
keyRoads: true,
|
|
15
|
+
roadLabels: true,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type SFMapData } from './map-core.js';
|
|
2
|
+
import type { SFMapOptions } from './types.js';
|
|
3
|
+
export type { SFMapData as StaticMapData } from './map-core.js';
|
|
4
|
+
export type { SFMapOptions as StaticMapOptions } from './types.js';
|
|
5
|
+
/** Server-safe SVG rendering with explicit data; returns SVG plus projection helpers. */
|
|
6
|
+
export declare function renderMap(data: SFMapData, options?: SFMapOptions): {
|
|
7
|
+
svg: string;
|
|
8
|
+
project: (coordinates: import("../data/types.js").Position) => [number, number];
|
|
9
|
+
viewBox: [number, number, number, number];
|
|
10
|
+
};
|