@kahwee/sf-map-svg 4.0.1 → 4.2.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 +36 -0
- package/README.md +9 -13
- package/dist/data/guide/coast.json +1 -1
- package/dist/data/guide/landmarks.json +1 -1
- package/dist/data/guide/neighborhoods-realtor.json +1 -1
- package/dist/data/stations.d.ts +2 -2
- package/dist/data/types.d.ts +8 -1
- package/dist/src/api.d.ts +1 -1
- package/dist/src/api.js +1 -1
- package/dist/src/appearance.d.ts +5 -0
- package/dist/src/appearance.js +54 -0
- package/dist/src/configuration.js +50 -25
- package/dist/src/controller-types.d.ts +53 -6
- package/dist/src/district-morph.d.ts +17 -0
- package/dist/src/district-morph.js +148 -0
- package/dist/src/explorer-core.d.ts +14 -2
- package/dist/src/explorer-core.js +233 -52
- package/dist/src/features.d.ts +6 -0
- package/dist/src/features.js +11 -1
- package/dist/src/guide-map.js +1 -1
- package/dist/src/guide-static.js +3 -3
- package/dist/src/layer-transition.d.ts +18 -0
- package/dist/src/layer-transition.js +102 -0
- package/dist/src/layers.d.ts +9 -7
- package/dist/src/layers.js +28 -18
- package/dist/src/map-core.d.ts +14 -0
- package/dist/src/map-core.js +11 -5
- package/dist/src/map.js +125 -20
- package/dist/src/overlays.d.ts +3 -4
- package/dist/src/overlays.js +5 -1
- package/dist/src/static-animation.d.ts +13 -0
- package/dist/src/static-animation.js +36 -0
- package/dist/src/static.d.ts +1 -1
- package/dist/src/static.js +31 -2
- package/dist/src/transit.js +2 -1
- package/dist/src/types.d.ts +33 -2
- package/dist/src/validation.js +2 -0
- package/docs/API.md +85 -0
- package/docs/api-audit.md +3 -1
- package/docs/consumer-integration.md +1 -1
- package/docs/developer-guide.md +139 -0
- package/docs/guide-bundle-report.md +4 -4
- package/llms-full.txt +1979 -0
- package/llms.txt +25 -0
- package/package.json +12 -5
package/dist/src/layers.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { BartStation, KeyRoad, Landmark } from './overlays.js';
|
|
|
5
5
|
import type { MapMarker, SFMapOptions } from './types.js';
|
|
6
6
|
interface LayerContext {
|
|
7
7
|
labels: boolean;
|
|
8
|
+
/** Animated static maps index items for staggering and measure lines for drawing. */
|
|
9
|
+
animated?: boolean;
|
|
8
10
|
theme: NonNullable<SFMapOptions['theme']>;
|
|
9
11
|
idPrefix: string;
|
|
10
12
|
colors: Required<NonNullable<SFMapOptions['colors']>>;
|
|
@@ -18,21 +20,21 @@ interface DistrictPath {
|
|
|
18
20
|
stroke?: string;
|
|
19
21
|
opacity?: number;
|
|
20
22
|
}
|
|
21
|
-
export declare function districtFills(districts: readonly DistrictPath[], { idPrefix }: LayerContext): string;
|
|
22
|
-
export declare function districtLines(districts: readonly DistrictPath[], { idPrefix, colors }: LayerContext): string;
|
|
23
|
+
export declare function districtFills(districts: readonly DistrictPath[], { idPrefix, animated }: LayerContext): string;
|
|
24
|
+
export declare function districtLines(districts: readonly DistrictPath[], { idPrefix, colors, animated }: LayerContext): string;
|
|
23
25
|
export declare function landmarks(items: readonly Landmark[], { idPrefix, path, colors }: LayerContext): string;
|
|
24
26
|
export declare function highways(items: readonly {
|
|
25
27
|
route: string;
|
|
26
28
|
geometry: Geometry;
|
|
27
|
-
}[], { path, colors, theme }: LayerContext): string;
|
|
29
|
+
}[], { path, colors, theme, animated }: LayerContext): string;
|
|
28
30
|
export declare function neighborhoods(items: readonly {
|
|
29
31
|
name: string;
|
|
30
32
|
geometry: Geometry;
|
|
31
|
-
}[], { idPrefix, path, colors }: LayerContext): string;
|
|
33
|
+
}[], { idPrefix, path, colors, animated }: LayerContext): string;
|
|
32
34
|
export declare function districtLabels(districts: readonly DistrictRow[], { project, colors }: LayerContext): string;
|
|
33
35
|
export declare function landmarkLabels(items: readonly Landmark[], { project, colors }: LayerContext): string;
|
|
34
|
-
export declare function bartStations(items: readonly BartStation[], { project, colors, labels }: LayerContext): string;
|
|
35
|
-
export declare function markers(items: readonly MapMarker[], { project, colors }: LayerContext): string;
|
|
36
|
-
export declare function keyRoads(items: readonly KeyRoad[], { idPrefix, path, colors }: LayerContext): string;
|
|
36
|
+
export declare function bartStations(items: readonly BartStation[], { project, colors, labels, animated }: LayerContext): string;
|
|
37
|
+
export declare function markers(items: readonly MapMarker[], { project, colors, animated }: LayerContext): string;
|
|
38
|
+
export declare function keyRoads(items: readonly KeyRoad[], { idPrefix, path, colors, animated }: LayerContext): string;
|
|
37
39
|
export declare function keyRoadLabels(items: readonly KeyRoad[], { project }: LayerContext): string;
|
|
38
40
|
export {};
|
package/dist/src/layers.js
CHANGED
|
@@ -1,29 +1,39 @@
|
|
|
1
1
|
import { escapeXml, number, overlayLabel, stroke } from './svg.js';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
/** Custom JavaScript/JSON rows must not introduce markup or CSS through numeric IDs. */
|
|
3
|
+
function districtId(id) {
|
|
4
|
+
if (!Number.isFinite(id))
|
|
5
|
+
throw new TypeError('District IDs must be finite numbers.');
|
|
6
|
+
return id;
|
|
4
7
|
}
|
|
5
|
-
|
|
6
|
-
|
|
8
|
+
/** Stagger index for animated static maps. */
|
|
9
|
+
const order = (animated, index) => animated ? ` style="--i:${index}"` : '';
|
|
10
|
+
/** Normalized length so one dash can draw a line on. */
|
|
11
|
+
const drawable = (animated) => (animated ? ' pathLength="1"' : '');
|
|
12
|
+
export function districtFills(districts, { idPrefix, animated }) {
|
|
13
|
+
return `<g data-layer="district-fills" clip-path="url(#${idPrefix}-coast)">${districts.map((d) => `<path data-district="${districtId(d.id)}" d="${d.path}" fill="${escapeXml(d.color)}"${d.opacity === undefined ? '' : ` fill-opacity="${d.opacity}"`} fill-rule="evenodd"${order(animated, districtId(d.id))}/>`).join('')}</g>`;
|
|
14
|
+
}
|
|
15
|
+
export function districtLines(districts, { idPrefix, colors, animated }) {
|
|
16
|
+
return `<g data-layer="district-lines" clip-path="url(#${idPrefix}-coast)">${districts.map((d) => `<path data-district="${districtId(d.id)}" d="${d.path}" ${stroke(d.stroke ?? colors.district, 1.1)}${d.opacity === undefined ? '' : ` stroke-opacity="${d.opacity}"`}${drawable(animated)}${order(animated, districtId(d.id))}/>`).join('')}</g>`;
|
|
7
17
|
}
|
|
8
18
|
export function landmarks(items, { idPrefix, path, colors }) {
|
|
9
19
|
return `<g data-layer="landmarks" clip-path="url(#${idPrefix}-coast)">${items.map((item) => `<path data-landmark="${escapeXml(item.id)}" d="${path(item.geometry)}" fill="${escapeXml(colors.park)}" fill-rule="evenodd"><title>${escapeXml(item.name)}</title></path>`).join('')}</g>`;
|
|
10
20
|
}
|
|
11
|
-
export function highways(items, { path, colors, theme }) {
|
|
12
|
-
return `<g data-layer="highways">${items.map((road) => `<path data-route="${escapeXml(road.route)}" d="${path(road.geometry)}" ${stroke(colors.highway, theme === 'transit' ? 2 : 1.4)}/>`).join('')}</g>`;
|
|
21
|
+
export function highways(items, { path, colors, theme, animated }) {
|
|
22
|
+
return `<g data-layer="highways">${items.map((road) => `<path data-route="${escapeXml(road.route)}" d="${path(road.geometry)}" ${stroke(colors.highway, theme === 'transit' ? 2 : 1.4)}${drawable(animated)}/>`).join('')}</g>`;
|
|
13
23
|
}
|
|
14
|
-
export function neighborhoods(items, { idPrefix, path, colors }) {
|
|
15
|
-
return `<g data-layer="neighborhood-lines" clip-path="url(#${idPrefix}-coast)">${items.map((n) => `<path data-neighborhood="${escapeXml(n.name)}" d="${path(n.geometry)}" ${stroke(colors.neighborhood, 0.65)} stroke-dasharray="2 2"><title>${escapeXml(n.name)}</title></path>`).join('')}</g>`;
|
|
24
|
+
export function neighborhoods(items, { idPrefix, path, colors, animated }) {
|
|
25
|
+
return `<g data-layer="neighborhood-lines" clip-path="url(#${idPrefix}-coast)">${items.map((n, index) => `<path data-neighborhood="${escapeXml(n.name)}" d="${path(n.geometry)}" ${stroke(colors.neighborhood, 0.65)} stroke-dasharray="2 2"${order(animated, index)}><title>${escapeXml(n.name)}</title></path>`).join('')}</g>`;
|
|
16
26
|
}
|
|
17
27
|
export function districtLabels(districts, { project, colors }) {
|
|
18
28
|
// Primary badges first, then island badges, matching the original layer order.
|
|
19
29
|
const labels = [
|
|
20
|
-
...districts.map((d) => ({ id: d.id, label: d.labelPoints[0] })),
|
|
30
|
+
...districts.map((d) => ({ id: d.id, label: d.labelPoints[0] ?? d.label })),
|
|
21
31
|
...districts.flatMap((d) => d.labelPoints.slice(1).map((label) => ({ id: d.id, label }))),
|
|
22
32
|
];
|
|
23
33
|
return `<g data-layer="district-labels" font-family="system-ui,sans-serif" font-size="12" font-weight="600" text-anchor="middle" fill="${escapeXml(colors.label)}">${labels
|
|
24
34
|
.map((d) => {
|
|
25
35
|
const [x, y] = project(d.label).map(number);
|
|
26
|
-
return `<g transform="translate(${x},${y})"><circle r="10" fill="#ffffff" fill-opacity=".9"/><text dy=".35em">${d.id}</text></g>`;
|
|
36
|
+
return `<g transform="translate(${x},${y})"><circle r="10" fill="#ffffff" fill-opacity=".9"/><text dy=".35em">${districtId(d.id)}</text></g>`;
|
|
27
37
|
})
|
|
28
38
|
.join('')}</g>`;
|
|
29
39
|
}
|
|
@@ -35,29 +45,29 @@ export function landmarkLabels(items, { project, colors }) {
|
|
|
35
45
|
})
|
|
36
46
|
.join('')}</g>`;
|
|
37
47
|
}
|
|
38
|
-
export function bartStations(items, { project, colors, labels }) {
|
|
48
|
+
export function bartStations(items, { project, colors, labels, animated }) {
|
|
39
49
|
return `<g data-layer="bart-stations">${items
|
|
40
|
-
.map((station) => {
|
|
50
|
+
.map((station, index) => {
|
|
41
51
|
const [x, y] = project(station.coordinates).map(number);
|
|
42
|
-
return `<g data-bart-station="${escapeXml(station.id)}" transform="translate(${x},${y})"><title>${escapeXml(station.name)} BART station</title><circle r="5" fill="#fff" stroke="${escapeXml(colors.bart)}" stroke-width="2.5"/><circle r="1.5" fill="${escapeXml(colors.bart)}"/>${labels ? `<text x="10" y="4" ${overlayLabel} fill="${escapeXml(colors.bart)}">${escapeXml(station.name)}</text>` : ''}</g>`;
|
|
52
|
+
return `<g data-bart-station="${escapeXml(station.id)}" transform="translate(${x},${y})"${order(animated, index)}><title>${escapeXml(station.name)} BART station</title><circle r="5" fill="#fff" stroke="${escapeXml(colors.bart)}" stroke-width="2.5"/><circle r="1.5" fill="${escapeXml(colors.bart)}"/>${labels ? `<text x="10" y="4" ${overlayLabel} fill="${escapeXml(colors.bart)}">${escapeXml(station.name)}</text>` : ''}</g>`;
|
|
43
53
|
})
|
|
44
54
|
.join('')}</g>`;
|
|
45
55
|
}
|
|
46
|
-
export function markers(items, { project, colors }) {
|
|
56
|
+
export function markers(items, { project, colors, animated }) {
|
|
47
57
|
return `<g data-layer="markers">${items
|
|
48
|
-
.map((marker) => {
|
|
58
|
+
.map((marker, index) => {
|
|
49
59
|
if (marker.radius !== undefined && (!Number.isFinite(marker.radius) || marker.radius <= 0))
|
|
50
60
|
throw new RangeError('Marker radius must be positive and finite.');
|
|
51
61
|
const [x, y] = project([marker.lng, marker.lat]).map(number);
|
|
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>`;
|
|
62
|
+
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"${order(animated, index)}><title>${escapeXml(marker.label ?? marker.id)}</title></circle>`;
|
|
53
63
|
})
|
|
54
64
|
.join('')}</g>`;
|
|
55
65
|
}
|
|
56
|
-
export function keyRoads(items, { idPrefix, path, colors }) {
|
|
66
|
+
export function keyRoads(items, { idPrefix, path, colors, animated }) {
|
|
57
67
|
return `<g data-layer="key-roads" clip-path="url(#${idPrefix}-coast)">${items
|
|
58
68
|
.map((road) => {
|
|
59
69
|
const level = road.level === 'secondary' ? 'secondary' : 'primary';
|
|
60
|
-
return `<path data-key-road="${escapeXml(road.id)}" data-key-road-level="${level}" d="${path(road.geometry)}" ${stroke(colors.road, 1)}><title>${escapeXml(road.name)}</title></path>`;
|
|
70
|
+
return `<path data-key-road="${escapeXml(road.id)}" data-key-road-level="${level}" d="${path(road.geometry)}" ${stroke(colors.road, 1)}${drawable(animated)}><title>${escapeXml(road.name)}</title></path>`;
|
|
61
71
|
})
|
|
62
72
|
.join('')}</g>`;
|
|
63
73
|
}
|
package/dist/src/map-core.d.ts
CHANGED
|
@@ -80,6 +80,20 @@ export declare function getLayerPathsWithData(options: SFMapOptions, data: SFMap
|
|
|
80
80
|
point: [number, number];
|
|
81
81
|
}[];
|
|
82
82
|
};
|
|
83
|
+
export declare function resolveMapColors(theme: SFMapOptions['theme'], colors?: SFMapOptions['colors']): {
|
|
84
|
+
water: string;
|
|
85
|
+
land: string;
|
|
86
|
+
district: string;
|
|
87
|
+
neighborhood: string;
|
|
88
|
+
highway: string;
|
|
89
|
+
road: string;
|
|
90
|
+
park: string;
|
|
91
|
+
landmark: string;
|
|
92
|
+
bart: string;
|
|
93
|
+
label: string;
|
|
94
|
+
marker: string;
|
|
95
|
+
selected: string;
|
|
96
|
+
};
|
|
83
97
|
/** Make an offline SVG and the matching longitude/latitude projection. */
|
|
84
98
|
export declare function createSFMapWithData(options: SFMapOptions, data: SFMapData): {
|
|
85
99
|
svg: string;
|
package/dist/src/map-core.js
CHANGED
|
@@ -2,6 +2,7 @@ import { validateMarkers, validateOverlays } from './validation.js';
|
|
|
2
2
|
import { prepareDistrictStyles } from './district-style.js';
|
|
3
3
|
import { geometryPath, positions, rawProject } from './geometry.js';
|
|
4
4
|
import * as layers from './layers.js';
|
|
5
|
+
import { normalizeStaticAnimation, staticAnimationStyle } from './static-animation.js';
|
|
5
6
|
import { escapeXml, stroke } from './svg.js';
|
|
6
7
|
export const districtYears = Object.freeze([2002, 2012, 2022]);
|
|
7
8
|
export const districtColors = Object.freeze([
|
|
@@ -103,6 +104,9 @@ const transitColors = {
|
|
|
103
104
|
marker: '#0073ae',
|
|
104
105
|
selected: '#0073ae',
|
|
105
106
|
};
|
|
107
|
+
export function resolveMapColors(theme, colors = {}) {
|
|
108
|
+
return { ...defaults, ...(theme === 'transit' ? transitColors : {}), ...colors };
|
|
109
|
+
}
|
|
106
110
|
let sequence = 0;
|
|
107
111
|
/** Make an offline SVG and the matching longitude/latitude projection. */
|
|
108
112
|
export function createSFMapWithData(options, data) {
|
|
@@ -114,7 +118,9 @@ export function createSFMapWithData(options, data) {
|
|
|
114
118
|
throw new TypeError('idPrefix must start with a letter and contain only letters, numbers, underscores, or hyphens.');
|
|
115
119
|
if (theme !== 'districts' && theme !== 'transit')
|
|
116
120
|
throw new TypeError('Theme must be districts or transit.');
|
|
117
|
-
const colors =
|
|
121
|
+
const colors = resolveMapColors(theme, options.colors);
|
|
122
|
+
const animation = normalizeStaticAnimation(options.animation);
|
|
123
|
+
const animated = animation !== null;
|
|
118
124
|
const geometry = getLayerPathsWithData(options, data, false);
|
|
119
125
|
const { project } = geometry;
|
|
120
126
|
const path = (geometry) => geometryPath(geometry, project);
|
|
@@ -123,7 +129,7 @@ export function createSFMapWithData(options, data) {
|
|
|
123
129
|
const landmarkData = data.landmarks ?? [];
|
|
124
130
|
const roadData = data.keyRoads ?? [];
|
|
125
131
|
const stationData = data.bartStations ?? [];
|
|
126
|
-
const context = { project, path, colors, idPrefix, theme, labels };
|
|
132
|
+
const context = { project, path, colors, idPrefix, theme, labels, animated };
|
|
127
133
|
const styles = prepareDistrictStyles(districtFills || districtLines ? districts : [], options.districtStyle);
|
|
128
134
|
const districtPaths = districtFills || districtLines
|
|
129
135
|
? districts.map((d, index) => {
|
|
@@ -138,7 +144,7 @@ export function createSFMapWithData(options, data) {
|
|
|
138
144
|
})
|
|
139
145
|
: [];
|
|
140
146
|
const parts = [
|
|
141
|
-
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-labelledby="${idPrefix}-title" data-sf-map="" data-year="${year}" style="max-width:100%;height:auto"><title id="${idPrefix}-title">${escapeXml(title)}</title><desc>San Francisco supervisorial district boundaries (${year}).${neighborhoodLines ? ' Dashed lines show SFAR realtor neighborhood areas, defined in August 2010.' : ''}${landmarks ? ' Highlighted areas show six parks and landmarks.' : ''}${keyRoads ? ' Thin gray lines show selected road corridors.' : ''}${bartStations ? ' Rings mark the eight San Francisco BART stations.' : ''} Geometry from DataSF${bartStations ? ' and BART' : ''}. See package SOURCES.md.</desc
|
|
147
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-labelledby="${idPrefix}-title" data-sf-map="" data-year="${year}"${animated ? ` data-sf-animate="${idPrefix}"` : ''} style="max-width:100%;height:auto"><title id="${idPrefix}-title">${escapeXml(title)}</title><desc>San Francisco supervisorial district boundaries (${year}).${neighborhoodLines ? ' Dashed lines show SFAR realtor neighborhood areas, defined in August 2010.' : ''}${landmarks ? ' Highlighted areas show six parks and landmarks.' : ''}${keyRoads ? ' Thin gray lines show selected road corridors.' : ''}${bartStations ? ' Rings mark the eight San Francisco BART stations.' : ''} Geometry from DataSF${bartStations ? ' and BART' : ''}. See package SOURCES.md.</desc>${animation ? staticAnimationStyle(idPrefix, animation) : ''}<defs><clipPath id="${idPrefix}-coast"><path d="${coastPath}" fill-rule="evenodd" clip-rule="evenodd"/></clipPath></defs><rect width="${width}" height="${height}" fill="${escapeXml(colors.water)}"/><g data-layer="geography"><path data-layer="coast" d="${coastPath}" fill="${escapeXml(colors.land)}" fill-rule="evenodd"/>`,
|
|
142
148
|
];
|
|
143
149
|
// Explicit drawing order keeps optional overlays and user markers predictable.
|
|
144
150
|
if (districtFills)
|
|
@@ -153,7 +159,7 @@ export function createSFMapWithData(options, data) {
|
|
|
153
159
|
parts.push(layers.neighborhoods(data.neighborhoods ?? [], context));
|
|
154
160
|
if (districtLines)
|
|
155
161
|
parts.push(layers.districtLines(districtPaths, context));
|
|
156
|
-
parts.push(`<path data-layer="coastline" d="${coastPath}" ${stroke(colors.district, 0.65)}/>`);
|
|
162
|
+
parts.push(`<path data-layer="coastline" d="${coastPath}" ${stroke(colors.district, 0.65)}${animated ? ' pathLength="1"' : ''}/>`);
|
|
157
163
|
if (labels && districtLabels)
|
|
158
164
|
parts.push(layers.districtLabels(districts, context));
|
|
159
165
|
if (labels && landmarks)
|
|
@@ -163,7 +169,7 @@ export function createSFMapWithData(options, data) {
|
|
|
163
169
|
if (bartStations)
|
|
164
170
|
parts.push(layers.bartStations(stationData, context));
|
|
165
171
|
for (const overlay of options.overlays ?? []) {
|
|
166
|
-
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"' : ''}/>`);
|
|
172
|
+
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"${animated ? ' pathLength="1"' : ''}${overlay.visible === false ? ' display="none"' : ''}/>`);
|
|
167
173
|
}
|
|
168
174
|
parts.push(layers.markers(markers, context), '</g></svg>');
|
|
169
175
|
return {
|
package/dist/src/map.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { copyAppearance } from './appearance.js';
|
|
1
2
|
import { validateCameraOptions } from './camera.js';
|
|
2
3
|
import { expandMapOptions, prepareConfiguration } from './configuration.js';
|
|
3
4
|
import { createNeighborhoodExplorerCore } from './explorer-core.js';
|
|
@@ -6,10 +7,11 @@ import { assertOptions } from './validation.js';
|
|
|
6
7
|
/** No geography is imported. Supply a preset or your own immutable data. */
|
|
7
8
|
export function createMap(data, options = {}) {
|
|
8
9
|
const expanded = expandMapOptions(options);
|
|
9
|
-
let config = prepareConfiguration({ features: {}, layers: {}, controls: {} }, {
|
|
10
|
+
let config = prepareConfiguration({ features: {}, layers: {}, controls: {}, appearance: {} }, {
|
|
10
11
|
features: options.features,
|
|
11
12
|
layers: options.layers,
|
|
12
13
|
controls: options.controls,
|
|
14
|
+
appearance: options.appearance,
|
|
13
15
|
});
|
|
14
16
|
const element = createNeighborhoodExplorerCore({
|
|
15
17
|
mode: 'basemap',
|
|
@@ -18,6 +20,80 @@ export function createMap(data, options = {}) {
|
|
|
18
20
|
onOverlayActivate: () => { },
|
|
19
21
|
}, data);
|
|
20
22
|
const subscriptions = new Set();
|
|
23
|
+
let configurationRevision = 0;
|
|
24
|
+
const sourceList = ['realtor', 'sf-find', 'analysis'].filter((source) => !!data.neighborhoods[source]);
|
|
25
|
+
const yearList = [2002, 2012, 2022].filter((year) => !!data.map.districts?.[year] && !!data.districts?.[year]);
|
|
26
|
+
const capabilities = () => ({
|
|
27
|
+
sources: [...sourceList],
|
|
28
|
+
years: [...yearList],
|
|
29
|
+
layers: Object.fromEntries(layerKeys.map((key) => [
|
|
30
|
+
key,
|
|
31
|
+
key.startsWith('district')
|
|
32
|
+
? !!data.map.districts?.[element.getMapState().year]?.length &&
|
|
33
|
+
yearList.includes(element.getMapState().year)
|
|
34
|
+
: key.startsWith('neighborhood')
|
|
35
|
+
? !!data.neighborhoods[element.getMapState().source]?.features.length
|
|
36
|
+
: key === 'roadLabels'
|
|
37
|
+
? !!data.map.keyRoads?.length
|
|
38
|
+
: !!data.map[key]?.length,
|
|
39
|
+
])),
|
|
40
|
+
});
|
|
41
|
+
const snapshot = () => ({
|
|
42
|
+
...structuredClone({
|
|
43
|
+
features: config.features,
|
|
44
|
+
layers: config.layers,
|
|
45
|
+
controls: config.controls,
|
|
46
|
+
}),
|
|
47
|
+
appearance: copyAppearance(config.appearance),
|
|
48
|
+
});
|
|
49
|
+
const resolved = () => {
|
|
50
|
+
const state = element.getMapState();
|
|
51
|
+
const supplied = capabilities().layers;
|
|
52
|
+
return {
|
|
53
|
+
...snapshot(),
|
|
54
|
+
...state,
|
|
55
|
+
layers: Object.fromEntries(layerKeys.map((key) => [
|
|
56
|
+
key,
|
|
57
|
+
supplied[key] &&
|
|
58
|
+
(!key.endsWith('Labels') || state.labels) &&
|
|
59
|
+
(config.layers[key] ??
|
|
60
|
+
(key.startsWith('district')
|
|
61
|
+
? state.mode === 'districts'
|
|
62
|
+
: key.startsWith('neighborhood')
|
|
63
|
+
? state.mode === 'neighborhoods'
|
|
64
|
+
: true)),
|
|
65
|
+
])),
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
let previousMarker = element.getSelectedMarker();
|
|
69
|
+
let previousNeighborhood = element.getSelection();
|
|
70
|
+
let previousDistrict = element.getSelectedDistrict();
|
|
71
|
+
const selectionListeners = [];
|
|
72
|
+
for (const type of ['markerchange', 'neighborhoodchange', 'districtchange']) {
|
|
73
|
+
const handle = (event) => {
|
|
74
|
+
if (event.target !== element)
|
|
75
|
+
return;
|
|
76
|
+
let detail;
|
|
77
|
+
if (type === 'markerchange') {
|
|
78
|
+
const current = element.getSelectedMarker();
|
|
79
|
+
detail = { kind: 'marker', current, previous: previousMarker };
|
|
80
|
+
previousMarker = current;
|
|
81
|
+
}
|
|
82
|
+
else if (type === 'neighborhoodchange') {
|
|
83
|
+
const current = element.getSelection();
|
|
84
|
+
detail = { kind: 'neighborhood', current, previous: previousNeighborhood };
|
|
85
|
+
previousNeighborhood = current;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
const current = element.getSelectedDistrict();
|
|
89
|
+
detail = { kind: 'district', current, previous: previousDistrict };
|
|
90
|
+
previousDistrict = current;
|
|
91
|
+
}
|
|
92
|
+
element.dispatchEvent(new CustomEvent('selectionchange', { detail: structuredClone(detail) }));
|
|
93
|
+
};
|
|
94
|
+
element.addEventListener(type, handle);
|
|
95
|
+
selectionListeners.push(() => element.removeEventListener(type, handle));
|
|
96
|
+
}
|
|
21
97
|
let destroyed = false;
|
|
22
98
|
function active() {
|
|
23
99
|
if (destroyed)
|
|
@@ -41,6 +117,28 @@ export function createMap(data, options = {}) {
|
|
|
41
117
|
element.fitGeometry(geometry, padding, motion);
|
|
42
118
|
}),
|
|
43
119
|
});
|
|
120
|
+
function configure(patch) {
|
|
121
|
+
const revision = ++configurationRevision;
|
|
122
|
+
const next = prepareConfiguration(config, patch);
|
|
123
|
+
const hasFeatures = 'features' in patch, hasLayers = 'layers' in patch, hasControls = 'controls' in patch;
|
|
124
|
+
const layers = Object.fromEntries(layerKeys.map((key) => [key, next.layers[key]]));
|
|
125
|
+
const controls = Object.fromEntries(controlKeys.map((key) => [key, next.controls[key]]));
|
|
126
|
+
const features = patch.features === undefined
|
|
127
|
+
? next.features
|
|
128
|
+
: Object.fromEntries(Object.keys(patch.features).map((key) => [
|
|
129
|
+
key,
|
|
130
|
+
next.features[key],
|
|
131
|
+
]));
|
|
132
|
+
element.applyPresentation({ ...patch, ...('appearance' in patch ? { appearance: next.appearance } : {}) }, () => {
|
|
133
|
+
config = next;
|
|
134
|
+
if (hasFeatures)
|
|
135
|
+
element.setFeatures(features);
|
|
136
|
+
if (hasLayers)
|
|
137
|
+
element.setLayers(layers);
|
|
138
|
+
if (hasControls)
|
|
139
|
+
element.setControls(controls);
|
|
140
|
+
}, () => !destroyed && revision === configurationRevision);
|
|
141
|
+
}
|
|
44
142
|
return Object.freeze({
|
|
45
143
|
element,
|
|
46
144
|
overlayElement: element.overlayElement,
|
|
@@ -48,22 +146,13 @@ export function createMap(data, options = {}) {
|
|
|
48
146
|
get destroyed() {
|
|
49
147
|
return destroyed;
|
|
50
148
|
},
|
|
51
|
-
configure: use(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const controls = Object.fromEntries(controlKeys.map((key) => [key, next.controls[key]]));
|
|
56
|
-
if ('features' in patch)
|
|
57
|
-
element.setFeatures(patch.features ?? next.features);
|
|
58
|
-
if ('layers' in patch)
|
|
59
|
-
element.setLayers(layers);
|
|
60
|
-
if ('controls' in patch)
|
|
61
|
-
element.setControls(controls);
|
|
62
|
-
config = next;
|
|
63
|
-
}),
|
|
64
|
-
getConfiguration: use(() => structuredClone(config)),
|
|
149
|
+
configure: use(configure),
|
|
150
|
+
getConfiguration: use(snapshot),
|
|
151
|
+
getResolvedConfiguration: use(resolved),
|
|
152
|
+
getCapabilities: use(capabilities),
|
|
65
153
|
on: use((type, listener) => {
|
|
66
154
|
if (![
|
|
155
|
+
'selectionchange',
|
|
67
156
|
'markerchange',
|
|
68
157
|
'districtchange',
|
|
69
158
|
'districthover',
|
|
@@ -97,11 +186,25 @@ export function createMap(data, options = {}) {
|
|
|
97
186
|
getSelectedNeighborhood: use(element.getSelection),
|
|
98
187
|
selectDistrict: use(element.selectDistrict),
|
|
99
188
|
getSelectedDistrict: use(element.getSelectedDistrict),
|
|
100
|
-
setDistrictYear: use(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
189
|
+
setDistrictYear: use((...args) => {
|
|
190
|
+
configurationRevision++;
|
|
191
|
+
element.setDistrictYear(...args);
|
|
192
|
+
}),
|
|
193
|
+
setDistrictStyle: use((style) => {
|
|
194
|
+
configure({ appearance: { districtStyle: style } });
|
|
195
|
+
}),
|
|
196
|
+
setSource: use((...args) => {
|
|
197
|
+
configurationRevision++;
|
|
198
|
+
element.setSource(...args);
|
|
199
|
+
}),
|
|
200
|
+
setMode: use((...args) => {
|
|
201
|
+
configurationRevision++;
|
|
202
|
+
element.setMode(...args);
|
|
203
|
+
}),
|
|
204
|
+
setLabels: use((...args) => {
|
|
205
|
+
configurationRevision++;
|
|
206
|
+
element.setLabels(...args);
|
|
207
|
+
}),
|
|
105
208
|
setTouchNavigation: use(element.setTouchNavigation),
|
|
106
209
|
projectToScreen: use(element.projectToScreen),
|
|
107
210
|
destroy() {
|
|
@@ -110,6 +213,8 @@ export function createMap(data, options = {}) {
|
|
|
110
213
|
destroyed = true;
|
|
111
214
|
for (const unsubscribe of subscriptions)
|
|
112
215
|
unsubscribe();
|
|
216
|
+
for (const unsubscribe of selectionListeners)
|
|
217
|
+
unsubscribe();
|
|
113
218
|
element.destroy();
|
|
114
219
|
},
|
|
115
220
|
});
|
package/dist/src/overlays.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import type { Position } from '../data/types.js';
|
|
2
1
|
export declare const landmarks: {
|
|
3
2
|
name: string;
|
|
4
|
-
label: Position;
|
|
3
|
+
label: import("../data/types.js").Position;
|
|
5
4
|
offset: readonly [number, number];
|
|
6
5
|
anchor: 'middle' | 'start' | 'end';
|
|
7
6
|
id: string;
|
|
@@ -10,7 +9,7 @@ export declare const landmarks: {
|
|
|
10
9
|
export declare const bartStations: {
|
|
11
10
|
id: string;
|
|
12
11
|
name: string;
|
|
13
|
-
coordinates: Position;
|
|
12
|
+
coordinates: import("../data/types.js").Position;
|
|
14
13
|
}[];
|
|
15
14
|
export type Landmark = (typeof landmarks)[number];
|
|
16
15
|
export type BartStation = (typeof bartStations)[number];
|
|
@@ -18,7 +17,7 @@ export declare const keyRoads: {
|
|
|
18
17
|
name: string;
|
|
19
18
|
level?: 'primary' | 'secondary';
|
|
20
19
|
sourceNames: readonly string[];
|
|
21
|
-
label: Position;
|
|
20
|
+
label: import("../data/types.js").Position;
|
|
22
21
|
segmentIds: readonly string[];
|
|
23
22
|
id: string;
|
|
24
23
|
geometry: import("../data/types.js").Geometry;
|
package/dist/src/overlays.js
CHANGED
|
@@ -12,4 +12,8 @@ export const bartStations = deepFreeze(stationData.features.map(({ id, propertie
|
|
|
12
12
|
name: properties.name,
|
|
13
13
|
coordinates: geometry.coordinates,
|
|
14
14
|
})));
|
|
15
|
-
export const keyRoads = deepFreeze(roadData.features.map(({ id, properties, geometry }) => ({
|
|
15
|
+
export const keyRoads = deepFreeze(roadData.features.map(({ id, properties, geometry }) => ({
|
|
16
|
+
id,
|
|
17
|
+
...properties,
|
|
18
|
+
geometry,
|
|
19
|
+
})));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SFMapOptions } from './types.js';
|
|
2
|
+
export interface StaticAnimation {
|
|
3
|
+
duration: number;
|
|
4
|
+
delay: number;
|
|
5
|
+
}
|
|
6
|
+
/** Validate `animation` and return timings, or null when animation is off. */
|
|
7
|
+
export declare function normalizeStaticAnimation(value: SFMapOptions['animation']): StaticAnimation | null;
|
|
8
|
+
/**
|
|
9
|
+
* Scoped, self-contained CSS for an animated static map. It runs when the SVG is
|
|
10
|
+
* inserted into a page or loaded as an image, and stays still under reduced motion.
|
|
11
|
+
* Line layers carry `pathLength="1"` so a single dash can draw them on.
|
|
12
|
+
*/
|
|
13
|
+
export declare function staticAnimationStyle(idPrefix: string, { duration, delay }: StaticAnimation): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { assertOptions } from './validation.js';
|
|
2
|
+
/** Reference choreography length; `duration` rescales every step proportionally. */
|
|
3
|
+
const REFERENCE = 2400;
|
|
4
|
+
/** Validate `animation` and return timings, or null when animation is off. */
|
|
5
|
+
export function normalizeStaticAnimation(value) {
|
|
6
|
+
if (value === undefined || value === false)
|
|
7
|
+
return null;
|
|
8
|
+
if (value === true)
|
|
9
|
+
return { duration: REFERENCE, delay: 0 };
|
|
10
|
+
assertOptions(value, 'animation', ['duration', 'delay']);
|
|
11
|
+
const { duration = REFERENCE, delay = 0 } = value;
|
|
12
|
+
if (typeof duration !== 'number' || !Number.isFinite(duration) || duration <= 0)
|
|
13
|
+
throw new RangeError('Animation duration must be a positive finite number.');
|
|
14
|
+
if (typeof delay !== 'number' || !Number.isFinite(delay) || delay < 0)
|
|
15
|
+
throw new RangeError('Animation delay must be a nonnegative finite number.');
|
|
16
|
+
return { duration, delay };
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Scoped, self-contained CSS for an animated static map. It runs when the SVG is
|
|
20
|
+
* inserted into a page or loaded as an image, and stays still under reduced motion.
|
|
21
|
+
* Line layers carry `pathLength="1"` so a single dash can draw them on.
|
|
22
|
+
*/
|
|
23
|
+
export function staticAnimationStyle(idPrefix, { duration, delay }) {
|
|
24
|
+
const scale = duration / REFERENCE;
|
|
25
|
+
const time = (ms) => `${Math.round(ms * scale)}ms`;
|
|
26
|
+
const at = (ms, step) => step === undefined
|
|
27
|
+
? `${Math.round(delay + ms * scale)}ms`
|
|
28
|
+
: `calc(${Math.round(delay + ms * scale)}ms + var(--i,0) * ${Math.round(step * scale)}ms)`;
|
|
29
|
+
const scope = `[data-sf-animate="${idPrefix}"]`;
|
|
30
|
+
const out = 'cubic-bezier(.16,1,.3,1)';
|
|
31
|
+
const inOut = 'cubic-bezier(.65,0,.35,1)';
|
|
32
|
+
const fade = `${idPrefix}-fade`;
|
|
33
|
+
const draw = `${idPrefix}-draw`;
|
|
34
|
+
const grow = `${idPrefix}-grow`;
|
|
35
|
+
return `<style>@keyframes ${fade}{from{opacity:0}}@keyframes ${draw}{from{stroke-dashoffset:1}}@keyframes ${grow}{from{opacity:0;transform:scale(.86)}}${scope} [data-layer="coast"]{animation:${fade} ${time(700)} ${out} ${at(0)} both}${scope} [data-layer="coastline"],${scope} [data-layer="district-lines"] path,${scope} [data-layer="highways"] path,${scope} [data-layer="key-roads"] path,${scope} [data-overlay-id]{stroke-dasharray:1}${scope} [data-layer="coastline"]{animation:${draw} ${time(1500)} ${inOut} ${at(0)} both}${scope} [data-layer="district-fills"] path{transform-box:fill-box;transform-origin:center;animation:${grow} ${time(800)} ${out} ${at(300, 60)} both}${scope} [data-layer="landmarks"]{animation:${fade} ${time(700)} ${out} ${at(900)} both}${scope} [data-layer="key-roads"] path,${scope} [data-layer="highways"] path{animation:${draw} ${time(1100)} ${inOut} ${at(1000)} both}${scope} [data-layer="neighborhood-lines"] path{animation:${fade} ${time(500)} ${out} ${at(1100, 7)} both}${scope} [data-layer="district-lines"] path{animation:${draw} ${time(1000)} ${inOut} ${at(1150, 45)} both}${scope} [data-overlay-id]{animation:${draw} ${time(1000)} ${inOut} ${at(1500)} both}${scope} [data-layer$="-labels"]{animation:${fade} ${time(600)} ${out} ${at(1850)} both}${scope} [data-bart-station]>circle,${scope} [data-marker-id]{transform-box:fill-box;transform-origin:center;animation:${grow} ${time(500)} ${out} ${at(1900, 70)} both}${scope} [data-bart-station]>text{animation:${fade} ${time(500)} ${out} ${at(2000, 70)} both}@media (prefers-reduced-motion:reduce){${scope} *{animation:none!important}}</style>`;
|
|
36
|
+
}
|
package/dist/src/static.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type SFMapData } from './map-core.js';
|
|
2
2
|
import type { SFMapOptions } from './types.js';
|
|
3
3
|
export type { DistrictRowData, SFMapData as StaticMapData } from './map-core.js';
|
|
4
|
-
export type { DistrictStyle, DistrictYear, SFMapOptions as StaticMapOptions } from './types.js';
|
|
4
|
+
export type { DistrictStyle, DistrictYear, SFMapOptions as StaticMapOptions, StaticMapAppearance, StaticMapLayers, } from './types.js';
|
|
5
5
|
/** Server-safe SVG rendering with explicit data; returns SVG plus projection helpers. */
|
|
6
6
|
export declare function renderMap(data: SFMapData, options?: SFMapOptions): {
|
|
7
7
|
svg: string;
|
package/dist/src/static.js
CHANGED
|
@@ -1,9 +1,38 @@
|
|
|
1
|
+
import { prepareAppearance } from './appearance.js';
|
|
2
|
+
import { validateSwitchPatch } from './features.js';
|
|
1
3
|
import { createSFMapWithData, getLayerPathsWithData } from './map-core.js';
|
|
4
|
+
import { assertOptions } from './validation.js';
|
|
2
5
|
/** Server-safe SVG rendering with explicit data; returns SVG plus projection helpers. */
|
|
3
6
|
export function renderMap(data, options = {}) {
|
|
4
|
-
return createSFMapWithData(options, data);
|
|
7
|
+
return createSFMapWithData(expandStaticOptions(options), data);
|
|
5
8
|
}
|
|
6
9
|
/** Project canonical layer geometry without constructing an SVG string. */
|
|
7
10
|
export function getLayerPaths(data, options = {}) {
|
|
8
|
-
return getLayerPathsWithData(options, data);
|
|
11
|
+
return getLayerPathsWithData(expandStaticOptions(options), data);
|
|
12
|
+
}
|
|
13
|
+
/** Grouped keys override flat compatibility keys; omitted keys preserve renderer defaults. */
|
|
14
|
+
function expandStaticOptions(options) {
|
|
15
|
+
assertOptions(options, 'static map');
|
|
16
|
+
if (options.layers !== undefined)
|
|
17
|
+
validateSwitchPatch(options.layers, [
|
|
18
|
+
'districtFills',
|
|
19
|
+
'districtLines',
|
|
20
|
+
'districtLabels',
|
|
21
|
+
'neighborhoodLines',
|
|
22
|
+
'landmarks',
|
|
23
|
+
'bartStations',
|
|
24
|
+
'highways',
|
|
25
|
+
'keyRoads',
|
|
26
|
+
'roadLabels',
|
|
27
|
+
]);
|
|
28
|
+
if (options.appearance !== undefined) {
|
|
29
|
+
assertOptions(options.appearance, 'static appearance', ['theme', 'colors', 'districtStyle']);
|
|
30
|
+
}
|
|
31
|
+
const { layers, appearance, ...flat } = options;
|
|
32
|
+
const supplied = (group = {}) => Object.fromEntries(Object.entries(group).filter(([, value]) => value !== undefined));
|
|
33
|
+
return {
|
|
34
|
+
...flat,
|
|
35
|
+
...supplied(layers),
|
|
36
|
+
...supplied(appearance === undefined ? undefined : prepareAppearance({}, appearance)),
|
|
37
|
+
};
|
|
9
38
|
}
|
package/dist/src/transit.js
CHANGED
|
@@ -5,6 +5,7 @@ import parks from '../data/landmarks.json' with { type: 'json' };
|
|
|
5
5
|
import { createSFMapWithData } from './map-core.js';
|
|
6
6
|
const districtData = districts;
|
|
7
7
|
const parkData = parks;
|
|
8
|
+
const stationData = stations;
|
|
8
9
|
const transitMapData = {
|
|
9
10
|
coast: coast.features[0].geometry,
|
|
10
11
|
districts: {
|
|
@@ -38,7 +39,7 @@ export function createTransitAnimation() {
|
|
|
38
39
|
</style>${map.svg}<div class="controls"><button type="button">Play</button><label>Journey <input aria-label="Journey progress" type="range" min="0" max="1000" value="0"></label></div><output aria-live="off"></output><p>Schematic BART journey. Straight connections between official station locations; not track geometry, a timetable, or live trains. One loop takes 28 seconds.</p>`;
|
|
39
40
|
const svg = root.querySelector('svg');
|
|
40
41
|
const ns = 'http://www.w3.org/2000/svg';
|
|
41
|
-
const points =
|
|
42
|
+
const points = stationData.features.map((station) => map.project(station.geometry.coordinates));
|
|
42
43
|
const line = document.createElementNS(ns, 'polyline');
|
|
43
44
|
line.setAttribute('points', points.map((point) => point.join(',')).join(' '));
|
|
44
45
|
line.setAttribute('fill', 'none');
|