@kahwee/sf-map-svg 1.1.0 → 1.3.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 +21 -0
- package/README.md +96 -6
- package/SOURCES.md +18 -1
- package/dist/data/README.md +11 -1
- package/dist/data/catalog.json +3 -3
- package/dist/data/guide/bart-stations.json +1 -0
- package/dist/data/guide/coast.json +1 -0
- package/dist/data/guide/highways-detailed.json +1 -0
- package/dist/data/guide/highways.json +1 -0
- package/dist/data/guide/key-roads.json +1 -0
- package/dist/data/guide/landmarks.json +1 -0
- package/dist/data/guide/neighborhoods-realtor.json +1 -0
- package/dist/data/key-roads.json +3837 -6741
- package/dist/data/types.d.ts +1 -0
- package/dist/src/custom-map.d.ts +3 -0
- package/dist/src/custom-map.js +1 -0
- package/dist/src/explorer-core.d.ts +5 -0
- package/dist/src/explorer-core.js +1085 -0
- package/dist/src/explorer-data.d.ts +9 -0
- package/dist/src/explorer-data.js +1 -0
- package/dist/src/explorer.d.ts +2 -2
- package/dist/src/explorer.js +28 -959
- package/dist/src/guide.d.ts +10 -0
- package/dist/src/guide.js +107 -0
- package/dist/src/index.d.ts +5 -6
- package/dist/src/index.js +12 -116
- package/dist/src/interactive-data.d.ts +6 -0
- package/dist/src/interactive-data.js +5 -0
- package/dist/src/interactive.d.ts +1 -1
- package/dist/src/layers.js +6 -1
- package/dist/src/map-core.d.ts +55 -0
- package/dist/src/map-core.js +132 -0
- package/dist/src/overlays.d.ts +1 -0
- package/dist/src/types.d.ts +22 -0
- package/package.json +16 -2
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { InteractiveSFMapData } from './explorer-data.js';
|
|
2
|
+
import type { InteractiveSFMapElement, InteractiveSFMapOptions } from './interactive-data.js';
|
|
3
|
+
/** Compact, source-aware San Francisco guide data. Imports no district or alternate-neighborhood files. */
|
|
4
|
+
export declare const guideMapData: InteractiveSFMapData;
|
|
5
|
+
/** Guide map preset: SFAR neighborhoods, major parks, BART, and curated roads. */
|
|
6
|
+
export declare function createGuideMap(options?: InteractiveSFMapOptions): InteractiveSFMapElement;
|
|
7
|
+
/** Load the detailed versions of the guide's selected geography on explicit request. */
|
|
8
|
+
export declare function loadGuideDetailedData(): Promise<InteractiveSFMapData>;
|
|
9
|
+
export type { InteractiveSFMapData } from './explorer-data.js';
|
|
10
|
+
export type { InteractiveSFMapElement, InteractiveSFMapOptions };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import bart from '../data/guide/bart-stations.json' with { type: 'json' };
|
|
2
|
+
import coast from '../data/guide/coast.json' with { type: 'json' };
|
|
3
|
+
import highways from '../data/guide/highways.json' with { type: 'json' };
|
|
4
|
+
import roads from '../data/guide/key-roads.json' with { type: 'json' };
|
|
5
|
+
import landmarks from '../data/guide/landmarks.json' with { type: 'json' };
|
|
6
|
+
import neighborhoods from '../data/guide/neighborhoods-realtor.json' with { type: 'json' };
|
|
7
|
+
import { createInteractiveSFMapWithData } from './interactive-data.js';
|
|
8
|
+
const neighborhoodData = neighborhoods;
|
|
9
|
+
const highwayData = highways;
|
|
10
|
+
const parkData = landmarks;
|
|
11
|
+
const roadData = roads;
|
|
12
|
+
const stationData = bart;
|
|
13
|
+
/** Compact, source-aware San Francisco guide data. Imports no district or alternate-neighborhood files. */
|
|
14
|
+
export const guideMapData = {
|
|
15
|
+
map: {
|
|
16
|
+
coast: coast.features[0].geometry,
|
|
17
|
+
neighborhoods: neighborhoodData.features.map((feature) => ({
|
|
18
|
+
name: feature.properties.canonicalName,
|
|
19
|
+
geometry: feature.geometry,
|
|
20
|
+
})),
|
|
21
|
+
highways: highwayData.features.map((feature) => ({
|
|
22
|
+
route: String(feature.properties.route),
|
|
23
|
+
geometry: feature.geometry,
|
|
24
|
+
})),
|
|
25
|
+
landmarks: parkData.features.map(({ id, properties, geometry }) => ({
|
|
26
|
+
id,
|
|
27
|
+
...properties,
|
|
28
|
+
geometry,
|
|
29
|
+
})),
|
|
30
|
+
keyRoads: roadData.features.map(({ id, properties, geometry }) => ({
|
|
31
|
+
id,
|
|
32
|
+
...properties,
|
|
33
|
+
sourceNames: properties.sourceNames ?? [],
|
|
34
|
+
segmentIds: properties.segmentIds ?? [],
|
|
35
|
+
geometry,
|
|
36
|
+
})),
|
|
37
|
+
bartStations: stationData.features.flatMap(({ id, properties, geometry }) => geometry.type === 'Point'
|
|
38
|
+
? [{ id, name: properties.name, coordinates: geometry.coordinates }]
|
|
39
|
+
: []),
|
|
40
|
+
},
|
|
41
|
+
neighborhoods: { realtor: neighborhoodData },
|
|
42
|
+
};
|
|
43
|
+
/** Guide map preset: SFAR neighborhoods, major parks, BART, and curated roads. */
|
|
44
|
+
export function createGuideMap(options = {}) {
|
|
45
|
+
return createInteractiveSFMapWithData(guideMapData, {
|
|
46
|
+
mode: 'neighborhoods',
|
|
47
|
+
...options,
|
|
48
|
+
layers: {
|
|
49
|
+
districtFills: false,
|
|
50
|
+
districtLines: false,
|
|
51
|
+
districtLabels: false,
|
|
52
|
+
neighborhoodLines: true,
|
|
53
|
+
neighborhoodLabels: true,
|
|
54
|
+
landmarks: true,
|
|
55
|
+
bartStations: true,
|
|
56
|
+
highways: true,
|
|
57
|
+
keyRoads: true,
|
|
58
|
+
roadLabels: true,
|
|
59
|
+
...options.layers,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** Load the detailed versions of the guide's selected geography on explicit request. */
|
|
64
|
+
export async function loadGuideDetailedData() {
|
|
65
|
+
const [coastData, neighborhoodData, parkData, highwayData, roadData, bartData] = await Promise.all([
|
|
66
|
+
import('../data/coast.json', { with: { type: 'json' } }),
|
|
67
|
+
import('../data/neighborhoods-realtor.json', { with: { type: 'json' } }),
|
|
68
|
+
import('../data/landmarks.json', { with: { type: 'json' } }),
|
|
69
|
+
import('../data/guide/highways-detailed.json', { with: { type: 'json' } }),
|
|
70
|
+
import('../data/key-roads.json', { with: { type: 'json' } }),
|
|
71
|
+
import('../data/bart-stations.json', { with: { type: 'json' } }),
|
|
72
|
+
]);
|
|
73
|
+
const detailedNeighborhoods = neighborhoodData.default;
|
|
74
|
+
const detailedRoads = roadData.default;
|
|
75
|
+
const detailedHighways = highwayData.default;
|
|
76
|
+
const detailedParks = parkData.default;
|
|
77
|
+
const detailedStations = bartData.default;
|
|
78
|
+
return {
|
|
79
|
+
map: {
|
|
80
|
+
coast: coastData.default.features[0].geometry,
|
|
81
|
+
neighborhoods: detailedNeighborhoods.features.map((feature) => ({
|
|
82
|
+
name: feature.properties.canonicalName,
|
|
83
|
+
geometry: feature.geometry,
|
|
84
|
+
})),
|
|
85
|
+
highways: detailedHighways.features
|
|
86
|
+
.filter((feature) => ['1', '101', '280'].includes(String(feature.properties.route)))
|
|
87
|
+
.map((feature) => ({
|
|
88
|
+
route: String(feature.properties.route),
|
|
89
|
+
geometry: feature.geometry,
|
|
90
|
+
})),
|
|
91
|
+
landmarks: detailedParks.features.map(({ id, properties, geometry }) => ({
|
|
92
|
+
id,
|
|
93
|
+
...properties,
|
|
94
|
+
geometry,
|
|
95
|
+
})),
|
|
96
|
+
keyRoads: detailedRoads.features.map(({ id, properties, geometry }) => ({
|
|
97
|
+
id,
|
|
98
|
+
...properties,
|
|
99
|
+
geometry,
|
|
100
|
+
})),
|
|
101
|
+
bartStations: detailedStations.features.flatMap(({ id, properties, geometry }) => geometry.type === 'Point'
|
|
102
|
+
? [{ id, name: properties.name, coordinates: geometry.coordinates }]
|
|
103
|
+
: []),
|
|
104
|
+
},
|
|
105
|
+
neighborhoods: { realtor: detailedNeighborhoods },
|
|
106
|
+
};
|
|
107
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import
|
|
2
|
-
import type {
|
|
3
|
-
export type { DistrictYear, MapMarker, SFMapOptions } from './types.js';
|
|
4
|
-
export
|
|
1
|
+
import { districtColors, districtYears } from './map-core.js';
|
|
2
|
+
import type { SFMapOptions } from './types.js';
|
|
3
|
+
export type { DistrictYear, MapMarker, MapOverlay, SFMapOptions } from './types.js';
|
|
4
|
+
export { districtColors, districtYears };
|
|
5
5
|
export declare const neighborhoodNames: readonly string[];
|
|
6
|
-
export declare const districtColors: readonly string[];
|
|
7
6
|
/** Make an offline SVG and the matching longitude/latitude projection. */
|
|
8
7
|
export declare function createSFMap(options?: SFMapOptions): {
|
|
9
8
|
svg: string;
|
|
10
|
-
project: (coordinates: Position) => [number, number];
|
|
9
|
+
project: (coordinates: import("../data/types.js").Position) => [number, number];
|
|
11
10
|
viewBox: [number, number, number, number];
|
|
12
11
|
};
|
|
13
12
|
export declare function renderSFMap(options?: SFMapOptions): string;
|
package/dist/src/index.js
CHANGED
|
@@ -1,124 +1,20 @@
|
|
|
1
1
|
import data from './data.js';
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
import { escapeXml, stroke } from './svg.js';
|
|
6
|
-
// The common coast is immutable: compute its Mercator bounds once per module.
|
|
7
|
-
const coastPoints = positions(data.coast).map(rawProject);
|
|
8
|
-
const bounds = coastPoints.reduce((b, [x, y]) => [Math.min(b[0], x), Math.min(b[1], y), Math.max(b[2], x), Math.max(b[3], y)], [Infinity, Infinity, -Infinity, -Infinity]);
|
|
9
|
-
export const districtYears = Object.freeze([2002, 2012, 2022]);
|
|
2
|
+
import { createSFMapWithData, districtColors, districtYears } from './map-core.js';
|
|
3
|
+
import { bartStations, keyRoads, landmarks } from './overlays.js';
|
|
4
|
+
export { districtColors, districtYears };
|
|
10
5
|
export const neighborhoodNames = Object.freeze(data.neighborhoods.map((item) => item.name));
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
'#c8dfdf',
|
|
20
|
-
'#e2d6ca',
|
|
21
|
-
'#cbdcd1',
|
|
22
|
-
'#dfddc9',
|
|
23
|
-
]);
|
|
24
|
-
const defaults = {
|
|
25
|
-
water: '#e7f0f3',
|
|
26
|
-
land: '#f1f3ee',
|
|
27
|
-
district: '#71838a',
|
|
28
|
-
neighborhood: '#8c9195',
|
|
29
|
-
highway: '#bd8b73',
|
|
30
|
-
road: '#bcc3c5',
|
|
31
|
-
park: '#b2cfaa',
|
|
32
|
-
landmark: '#3e6346',
|
|
33
|
-
bart: '#24789a',
|
|
34
|
-
label: '#304958',
|
|
35
|
-
marker: '#245b61',
|
|
36
|
-
selected: '#f04f32',
|
|
6
|
+
const packagedData = {
|
|
7
|
+
coast: data.coast,
|
|
8
|
+
districts: data.districts,
|
|
9
|
+
neighborhoods: data.neighborhoods,
|
|
10
|
+
highways: data.highways,
|
|
11
|
+
landmarks,
|
|
12
|
+
keyRoads,
|
|
13
|
+
bartStations,
|
|
37
14
|
};
|
|
38
|
-
const transitColors = {
|
|
39
|
-
water: '#e4f2f8',
|
|
40
|
-
land: '#fcfcf8',
|
|
41
|
-
district: '#a7b8c0',
|
|
42
|
-
neighborhood: '#b2c0c7',
|
|
43
|
-
highway: '#b9a18a',
|
|
44
|
-
park: '#c6dfbd',
|
|
45
|
-
landmark: '#426641',
|
|
46
|
-
bart: '#0073ae',
|
|
47
|
-
label: '#183e58',
|
|
48
|
-
marker: '#0073ae',
|
|
49
|
-
selected: '#0073ae',
|
|
50
|
-
};
|
|
51
|
-
let sequence = 0;
|
|
52
15
|
/** Make an offline SVG and the matching longitude/latitude projection. */
|
|
53
16
|
export function createSFMap(options = {}) {
|
|
54
|
-
|
|
55
|
-
if (![width, height, padding].every(Number.isFinite) ||
|
|
56
|
-
width <= 0 ||
|
|
57
|
-
height <= 0 ||
|
|
58
|
-
padding < 0 ||
|
|
59
|
-
padding * 2 >= Math.min(width, height))
|
|
60
|
-
throw new RangeError('Use positive dimensions and padding smaller than half the map.');
|
|
61
|
-
if (!districtYears.includes(year))
|
|
62
|
-
throw new RangeError('District year must be 2002, 2012, or 2022.');
|
|
63
|
-
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(idPrefix))
|
|
64
|
-
throw new TypeError('idPrefix must start with a letter and contain only letters, numbers, underscores, or hyphens.');
|
|
65
|
-
if (theme !== 'districts' && theme !== 'transit')
|
|
66
|
-
throw new TypeError('Theme must be districts or transit.');
|
|
67
|
-
const colors = { ...defaults, ...(theme === 'transit' ? transitColors : {}), ...options.colors };
|
|
68
|
-
const [minX, minY, maxX, maxY] = bounds;
|
|
69
|
-
const scale = Math.min((width - padding * 2) / (maxX - minX), (height - padding * 2) / (maxY - minY));
|
|
70
|
-
const project = (coordinates) => {
|
|
71
|
-
const [x, y] = rawProject(coordinates);
|
|
72
|
-
const point = [
|
|
73
|
-
(x - (minX + maxX) / 2) * scale + width / 2,
|
|
74
|
-
(y - (minY + maxY) / 2) * scale + height / 2,
|
|
75
|
-
];
|
|
76
|
-
if (!point.every(Number.isFinite))
|
|
77
|
-
throw new RangeError('Projected coordinates must be finite; use smaller map dimensions.');
|
|
78
|
-
return point;
|
|
79
|
-
};
|
|
80
|
-
const path = (geometry) => geometryPath(geometry, project);
|
|
81
|
-
const coastPath = path(data.coast);
|
|
82
|
-
const districts = data.districts[year];
|
|
83
|
-
const context = { project, path, colors, idPrefix, theme, labels };
|
|
84
|
-
const districtPaths = districtFills || districtLines
|
|
85
|
-
? districts.map((d) => ({
|
|
86
|
-
id: d.id,
|
|
87
|
-
path: path(d.geometry) + path(d.extras),
|
|
88
|
-
color: theme === 'transit' ? colors.land : districtColors[d.id - 1],
|
|
89
|
-
}))
|
|
90
|
-
: [];
|
|
91
|
-
const parts = [
|
|
92
|
-
`<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 nine selected road corridors.' : ''}${bartStations ? ' Rings mark the eight San Francisco BART stations.' : ''} Geometry from DataSF${bartStations ? ' and BART' : ''}. See package SOURCES.md.</desc><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"/>`,
|
|
93
|
-
];
|
|
94
|
-
// Explicit drawing order keeps optional overlays and user markers predictable.
|
|
95
|
-
if (districtFills)
|
|
96
|
-
parts.push(layers.districtFills(districtPaths, context));
|
|
97
|
-
if (landmarks)
|
|
98
|
-
parts.push(layers.landmarks(landmarkData, context));
|
|
99
|
-
if (keyRoads)
|
|
100
|
-
parts.push(layers.keyRoads(roadData, context));
|
|
101
|
-
if (highways)
|
|
102
|
-
parts.push(layers.highways(data.highways, context));
|
|
103
|
-
if (neighborhoodLines)
|
|
104
|
-
parts.push(layers.neighborhoods(data.neighborhoods, context));
|
|
105
|
-
if (districtLines)
|
|
106
|
-
parts.push(layers.districtLines(districtPaths, context));
|
|
107
|
-
parts.push(`<path data-layer="coastline" d="${coastPath}" ${stroke(colors.district, 0.65)}/>`);
|
|
108
|
-
if (labels && districtLabels)
|
|
109
|
-
parts.push(layers.districtLabels(districts, context));
|
|
110
|
-
if (labels && landmarks)
|
|
111
|
-
parts.push(layers.landmarkLabels(landmarkData, context));
|
|
112
|
-
if (labels && keyRoads)
|
|
113
|
-
parts.push(layers.keyRoadLabels(roadData, context));
|
|
114
|
-
if (bartStations)
|
|
115
|
-
parts.push(layers.bartStations(stationData, context));
|
|
116
|
-
parts.push(layers.markers(markers, context), '</g></svg>');
|
|
117
|
-
return {
|
|
118
|
-
svg: parts.join(''),
|
|
119
|
-
project,
|
|
120
|
-
viewBox: [0, 0, width, height],
|
|
121
|
-
};
|
|
17
|
+
return createSFMapWithData(options, packagedData);
|
|
122
18
|
}
|
|
123
19
|
export function renderSFMap(options = {}) {
|
|
124
20
|
return createSFMap(options).svg;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { InteractiveSFMapData } from './explorer-data.js';
|
|
2
|
+
import type { NeighborhoodExplorerOptions } from './types.js';
|
|
3
|
+
export type { InteractiveSFMapData } from './explorer-data.js';
|
|
4
|
+
export type { InteractiveLayers, MapPadding, MapViewport, NeighborhoodExplorerElement as InteractiveSFMapElement, NeighborhoodExplorerOptions as InteractiveSFMapOptions, NeighborhoodSelection, } from './types.js';
|
|
5
|
+
/** Browser map shell that bundles only the geography supplied by the caller. */
|
|
6
|
+
export declare function createInteractiveSFMapWithData(data: InteractiveSFMapData, options?: NeighborhoodExplorerOptions): import("./types.js").NeighborhoodExplorerElement;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { createNeighborhoodExplorerCore } from './explorer-core.js';
|
|
2
|
+
/** Browser map shell that bundles only the geography supplied by the caller. */
|
|
3
|
+
export function createInteractiveSFMapWithData(data, options = {}) {
|
|
4
|
+
return createNeighborhoodExplorerCore({ mode: 'basemap', ...options, interface: 'map' }, data);
|
|
5
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { NeighborhoodExplorerOptions } from './types.js';
|
|
2
|
-
export type { InteractiveLayers, MapPadding, MapViewport, NeighborhoodExplorerElement as InteractiveSFMapElement, NeighborhoodExplorerOptions as InteractiveSFMapOptions, NeighborhoodSelection, } from './types.js';
|
|
2
|
+
export type { InteractiveLayers, 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/layers.js
CHANGED
|
@@ -52,7 +52,12 @@ export function markers(items, { project, colors }) {
|
|
|
52
52
|
.join('')}</g>`;
|
|
53
53
|
}
|
|
54
54
|
export function keyRoads(items, { idPrefix, path, colors }) {
|
|
55
|
-
return `<g data-layer="key-roads" clip-path="url(#${idPrefix}-coast)">${items
|
|
55
|
+
return `<g data-layer="key-roads" clip-path="url(#${idPrefix}-coast)">${items
|
|
56
|
+
.map((road) => {
|
|
57
|
+
const level = road.level === 'secondary' ? 'secondary' : 'primary';
|
|
58
|
+
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>`;
|
|
59
|
+
})
|
|
60
|
+
.join('')}</g>`;
|
|
56
61
|
}
|
|
57
62
|
export function keyRoadLabels(items, { project }) {
|
|
58
63
|
return `<g data-layer="key-road-labels" ${overlayLabel} fill="#77736b">${items
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Geometry, Position } from '../data/types.js';
|
|
2
|
+
import type { DistrictYear, SFMapOptions } from './types.js';
|
|
3
|
+
export type { DistrictYear, MapMarker, MapOverlay, SFMapOptions } from './types.js';
|
|
4
|
+
export interface SFMapData {
|
|
5
|
+
coast: Geometry;
|
|
6
|
+
districts?: Readonly<Partial<Record<DistrictYear, readonly DistrictRowData[]>>>;
|
|
7
|
+
neighborhoods?: readonly {
|
|
8
|
+
name: string;
|
|
9
|
+
geometry: Geometry;
|
|
10
|
+
}[];
|
|
11
|
+
highways?: readonly {
|
|
12
|
+
route: string;
|
|
13
|
+
geometry: Geometry;
|
|
14
|
+
}[];
|
|
15
|
+
landmarks?: readonly LandmarkData[];
|
|
16
|
+
keyRoads?: readonly KeyRoadData[];
|
|
17
|
+
bartStations?: readonly BartStationData[];
|
|
18
|
+
}
|
|
19
|
+
export interface DistrictRowData {
|
|
20
|
+
id: number;
|
|
21
|
+
label: Position;
|
|
22
|
+
labelPoints: readonly Position[];
|
|
23
|
+
geometry: Geometry;
|
|
24
|
+
extras: Geometry | null;
|
|
25
|
+
}
|
|
26
|
+
export interface LandmarkData {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
label: Position;
|
|
30
|
+
offset: readonly [number, number];
|
|
31
|
+
anchor: 'middle' | 'start' | 'end';
|
|
32
|
+
geometry: Geometry;
|
|
33
|
+
}
|
|
34
|
+
export interface KeyRoadData {
|
|
35
|
+
id: string;
|
|
36
|
+
name: string;
|
|
37
|
+
level?: 'primary' | 'secondary';
|
|
38
|
+
sourceNames: readonly string[];
|
|
39
|
+
label: Position;
|
|
40
|
+
segmentIds: readonly string[];
|
|
41
|
+
geometry: Geometry;
|
|
42
|
+
}
|
|
43
|
+
export interface BartStationData {
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
coordinates: Position;
|
|
47
|
+
}
|
|
48
|
+
export declare const districtYears: readonly DistrictYear[];
|
|
49
|
+
export declare const districtColors: readonly string[];
|
|
50
|
+
/** Make an offline SVG and the matching longitude/latitude projection. */
|
|
51
|
+
export declare function createSFMapWithData(options: SFMapOptions, data: SFMapData): {
|
|
52
|
+
svg: string;
|
|
53
|
+
project: (coordinates: Position) => [number, number];
|
|
54
|
+
viewBox: [number, number, number, number];
|
|
55
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { geometryPath, positions, rawProject } from './geometry.js';
|
|
2
|
+
import * as layers from './layers.js';
|
|
3
|
+
import { escapeXml, stroke } from './svg.js';
|
|
4
|
+
export const districtYears = Object.freeze([2002, 2012, 2022]);
|
|
5
|
+
export const districtColors = Object.freeze([
|
|
6
|
+
'#c8dce5',
|
|
7
|
+
'#d5e4d6',
|
|
8
|
+
'#d1d6e8',
|
|
9
|
+
'#d0dfd7',
|
|
10
|
+
'#e4dcca',
|
|
11
|
+
'#d3dced',
|
|
12
|
+
'#d9d8e7',
|
|
13
|
+
'#c8dfdf',
|
|
14
|
+
'#e2d6ca',
|
|
15
|
+
'#cbdcd1',
|
|
16
|
+
'#dfddc9',
|
|
17
|
+
]);
|
|
18
|
+
const defaults = {
|
|
19
|
+
water: '#e7f0f3',
|
|
20
|
+
land: '#f1f3ee',
|
|
21
|
+
district: '#71838a',
|
|
22
|
+
neighborhood: '#8c9195',
|
|
23
|
+
highway: '#bd8b73',
|
|
24
|
+
road: '#bcc3c5',
|
|
25
|
+
park: '#b2cfaa',
|
|
26
|
+
landmark: '#3e6346',
|
|
27
|
+
bart: '#24789a',
|
|
28
|
+
label: '#304958',
|
|
29
|
+
marker: '#245b61',
|
|
30
|
+
selected: '#f04f32',
|
|
31
|
+
};
|
|
32
|
+
const transitColors = {
|
|
33
|
+
water: '#e4f2f8',
|
|
34
|
+
land: '#fcfcf8',
|
|
35
|
+
district: '#a7b8c0',
|
|
36
|
+
neighborhood: '#b2c0c7',
|
|
37
|
+
highway: '#b9a18a',
|
|
38
|
+
park: '#c6dfbd',
|
|
39
|
+
landmark: '#426641',
|
|
40
|
+
bart: '#0073ae',
|
|
41
|
+
label: '#183e58',
|
|
42
|
+
marker: '#0073ae',
|
|
43
|
+
selected: '#0073ae',
|
|
44
|
+
};
|
|
45
|
+
let sequence = 0;
|
|
46
|
+
/** Make an offline SVG and the matching longitude/latitude projection. */
|
|
47
|
+
export function createSFMapWithData(options, data) {
|
|
48
|
+
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;
|
|
49
|
+
const roadLabels = options.roadLabels ?? keyRoads;
|
|
50
|
+
if (![width, height, padding].every(Number.isFinite) ||
|
|
51
|
+
width <= 0 ||
|
|
52
|
+
height <= 0 ||
|
|
53
|
+
padding < 0 ||
|
|
54
|
+
padding * 2 >= Math.min(width, height))
|
|
55
|
+
throw new RangeError('Use positive dimensions and padding smaller than half the map.');
|
|
56
|
+
if (!districtYears.includes(year))
|
|
57
|
+
throw new RangeError('District year must be 2002, 2012, or 2022.');
|
|
58
|
+
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(idPrefix))
|
|
59
|
+
throw new TypeError('idPrefix must start with a letter and contain only letters, numbers, underscores, or hyphens.');
|
|
60
|
+
if (theme !== 'districts' && theme !== 'transit')
|
|
61
|
+
throw new TypeError('Theme must be districts or transit.');
|
|
62
|
+
const colors = { ...defaults, ...(theme === 'transit' ? transitColors : {}), ...options.colors };
|
|
63
|
+
const coastPoints = positions(data.coast).map(rawProject);
|
|
64
|
+
const [minX, minY, maxX, maxY] = coastPoints.reduce((b, [x, y]) => [Math.min(b[0], x), Math.min(b[1], y), Math.max(b[2], x), Math.max(b[3], y)], [Infinity, Infinity, -Infinity, -Infinity]);
|
|
65
|
+
const scale = Math.min((width - padding * 2) / (maxX - minX), (height - padding * 2) / (maxY - minY));
|
|
66
|
+
const project = (coordinates) => {
|
|
67
|
+
const [x, y] = rawProject(coordinates);
|
|
68
|
+
const point = [
|
|
69
|
+
(x - (minX + maxX) / 2) * scale + width / 2,
|
|
70
|
+
(y - (minY + maxY) / 2) * scale + height / 2,
|
|
71
|
+
];
|
|
72
|
+
if (!point.every(Number.isFinite))
|
|
73
|
+
throw new RangeError('Projected coordinates must be finite; use smaller map dimensions.');
|
|
74
|
+
return point;
|
|
75
|
+
};
|
|
76
|
+
const path = (geometry) => geometryPath(geometry, project);
|
|
77
|
+
const coastPath = path(data.coast);
|
|
78
|
+
const districts = data.districts?.[year] ?? [];
|
|
79
|
+
const landmarkData = data.landmarks ?? [];
|
|
80
|
+
const roadData = data.keyRoads ?? [];
|
|
81
|
+
const stationData = data.bartStations ?? [];
|
|
82
|
+
const context = { project, path, colors, idPrefix, theme, labels };
|
|
83
|
+
const districtPaths = districtFills || districtLines
|
|
84
|
+
? districts.map((d) => ({
|
|
85
|
+
id: d.id,
|
|
86
|
+
path: path(d.geometry) + path(d.extras),
|
|
87
|
+
color: theme === 'transit' ? colors.land : districtColors[d.id - 1],
|
|
88
|
+
}))
|
|
89
|
+
: [];
|
|
90
|
+
const parts = [
|
|
91
|
+
`<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><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"/>`,
|
|
92
|
+
];
|
|
93
|
+
// Explicit drawing order keeps optional overlays and user markers predictable.
|
|
94
|
+
if (districtFills)
|
|
95
|
+
parts.push(layers.districtFills(districtPaths, context));
|
|
96
|
+
if (landmarks)
|
|
97
|
+
parts.push(layers.landmarks(landmarkData, context));
|
|
98
|
+
if (keyRoads)
|
|
99
|
+
parts.push(layers.keyRoads(roadData, context));
|
|
100
|
+
if (highways)
|
|
101
|
+
parts.push(layers.highways(data.highways ?? [], context));
|
|
102
|
+
if (neighborhoodLines)
|
|
103
|
+
parts.push(layers.neighborhoods(data.neighborhoods ?? [], context));
|
|
104
|
+
if (districtLines)
|
|
105
|
+
parts.push(layers.districtLines(districtPaths, context));
|
|
106
|
+
parts.push(`<path data-layer="coastline" d="${coastPath}" ${stroke(colors.district, 0.65)}/>`);
|
|
107
|
+
if (labels && districtLabels)
|
|
108
|
+
parts.push(layers.districtLabels(districts, context));
|
|
109
|
+
if (labels && landmarks)
|
|
110
|
+
parts.push(layers.landmarkLabels(landmarkData, context));
|
|
111
|
+
if (labels && roadLabels)
|
|
112
|
+
parts.push(layers.keyRoadLabels(roadData, context));
|
|
113
|
+
if (bartStations)
|
|
114
|
+
parts.push(layers.bartStations(stationData, context));
|
|
115
|
+
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
|
+
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
|
+
}
|
|
126
|
+
parts.push(layers.markers(markers, context), '</g></svg>');
|
|
127
|
+
return {
|
|
128
|
+
svg: parts.join(''),
|
|
129
|
+
project,
|
|
130
|
+
viewBox: [0, 0, width, height],
|
|
131
|
+
};
|
|
132
|
+
}
|
package/dist/src/overlays.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export type Landmark = (typeof landmarks)[number];
|
|
|
16
16
|
export type BartStation = (typeof bartStations)[number];
|
|
17
17
|
export declare const keyRoads: {
|
|
18
18
|
name: string;
|
|
19
|
+
level?: 'primary' | 'secondary';
|
|
19
20
|
sourceNames: readonly string[];
|
|
20
21
|
label: Position;
|
|
21
22
|
segmentIds: readonly string[];
|
package/dist/src/types.d.ts
CHANGED
|
@@ -8,6 +8,18 @@ export interface MapMarker {
|
|
|
8
8
|
selected?: boolean;
|
|
9
9
|
color?: string;
|
|
10
10
|
}
|
|
11
|
+
export interface MapOverlay {
|
|
12
|
+
id: string;
|
|
13
|
+
geometry: Extract<Geometry, {
|
|
14
|
+
type: 'LineString' | 'MultiLineString' | 'Polygon' | 'MultiPolygon';
|
|
15
|
+
}>;
|
|
16
|
+
stroke?: string;
|
|
17
|
+
strokeWidth?: number;
|
|
18
|
+
fill?: string;
|
|
19
|
+
fillOpacity?: number;
|
|
20
|
+
visible?: boolean;
|
|
21
|
+
label?: string;
|
|
22
|
+
}
|
|
11
23
|
export interface SFMapOptions {
|
|
12
24
|
theme?: 'districts' | 'transit';
|
|
13
25
|
width?: number;
|
|
@@ -22,9 +34,11 @@ export interface SFMapOptions {
|
|
|
22
34
|
labels?: boolean;
|
|
23
35
|
highways?: boolean;
|
|
24
36
|
keyRoads?: boolean;
|
|
37
|
+
roadLabels?: boolean;
|
|
25
38
|
landmarks?: boolean;
|
|
26
39
|
bartStations?: boolean;
|
|
27
40
|
markers?: MapMarker[];
|
|
41
|
+
overlays?: readonly MapOverlay[];
|
|
28
42
|
title?: string;
|
|
29
43
|
idPrefix?: string;
|
|
30
44
|
colors?: Partial<Record<'water' | 'land' | 'district' | 'neighborhood' | 'highway' | 'road' | 'park' | 'landmark' | 'bart' | 'label' | 'marker' | 'selected', string>>;
|
|
@@ -48,6 +62,7 @@ export interface InteractiveLayers {
|
|
|
48
62
|
bartStations?: boolean;
|
|
49
63
|
highways?: boolean;
|
|
50
64
|
keyRoads?: boolean;
|
|
65
|
+
roadLabels?: boolean;
|
|
51
66
|
}
|
|
52
67
|
export interface NeighborhoodSelection {
|
|
53
68
|
id: string;
|
|
@@ -81,6 +96,12 @@ export interface NeighborhoodExplorerOptions {
|
|
|
81
96
|
markerColor?: string;
|
|
82
97
|
selectedMarkerColor?: string;
|
|
83
98
|
onMarkerActivate?: (marker: MapMarker) => void;
|
|
99
|
+
overlays?: readonly MapOverlay[];
|
|
100
|
+
onOverlayActivate?: (overlay: MapOverlay) => void;
|
|
101
|
+
/** Stable theme tokens consumed by the explorer chrome. */
|
|
102
|
+
style?: Partial<Record<'ink' | 'surface' | 'accent' | 'border' | 'focus' | 'controlGap' | 'font', string>>;
|
|
103
|
+
strings?: Partial<Record<'title' | 'mode' | 'source' | 'search' | 'chooseNeighborhood' | 'chooseMarker' | 'touchNavigation' | 'reset' | 'emptyResults' | 'gestureHelp', string>>;
|
|
104
|
+
controls?: Partial<Record<'zoom' | 'pan' | 'reset' | 'labels' | 'touch' | 'legend', boolean>>;
|
|
84
105
|
}
|
|
85
106
|
export interface NeighborhoodExplorerElement extends HTMLElement {
|
|
86
107
|
selectNeighborhood(name: string | null, options?: {
|
|
@@ -100,6 +121,7 @@ export interface NeighborhoodExplorerElement extends HTMLElement {
|
|
|
100
121
|
/** Explicitly engage map touch gestures; false restores page gestures. */
|
|
101
122
|
setTouchNavigation(enabled: boolean): void;
|
|
102
123
|
setMarkers(markers: readonly MapMarker[]): void;
|
|
124
|
+
setOverlays(overlays: readonly MapOverlay[]): void;
|
|
103
125
|
selectMarker(id: string | null, options?: {
|
|
104
126
|
fit?: boolean;
|
|
105
127
|
}): boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahwee/sf-map-svg",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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/index.js",
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
"types": "./dist/src/index.d.ts",
|
|
11
11
|
"import": "./dist/src/index.js"
|
|
12
12
|
},
|
|
13
|
+
"./custom-map": {
|
|
14
|
+
"types": "./dist/src/custom-map.d.ts",
|
|
15
|
+
"import": "./dist/src/custom-map.js"
|
|
16
|
+
},
|
|
13
17
|
"./data": {
|
|
14
18
|
"types": "./dist/data/index.d.ts",
|
|
15
19
|
"import": "./dist/data/index.js"
|
|
@@ -27,6 +31,14 @@
|
|
|
27
31
|
"types": "./dist/src/interactive.d.ts",
|
|
28
32
|
"import": "./dist/src/interactive.js"
|
|
29
33
|
},
|
|
34
|
+
"./interactive-data": {
|
|
35
|
+
"types": "./dist/src/interactive-data.d.ts",
|
|
36
|
+
"import": "./dist/src/interactive-data.js"
|
|
37
|
+
},
|
|
38
|
+
"./guide": {
|
|
39
|
+
"types": "./dist/src/guide.d.ts",
|
|
40
|
+
"import": "./dist/src/guide.js"
|
|
41
|
+
},
|
|
30
42
|
"./transit": {
|
|
31
43
|
"types": "./dist/src/transit.d.ts",
|
|
32
44
|
"import": "./dist/src/transit.js"
|
|
@@ -84,9 +96,11 @@
|
|
|
84
96
|
"data:check": "node scripts/build-data-catalog.mjs --check",
|
|
85
97
|
"typecheck": "pnpm build && tsc -p tsconfig.json",
|
|
86
98
|
"data:normalize-realtor": "node scripts/normalize-realtor.mjs",
|
|
99
|
+
"data:guide": "node scripts/build-guide-data.mjs",
|
|
100
|
+
"report:guide": "node scripts/report-guide-bundle.mjs",
|
|
87
101
|
"test:package": "node scripts/smoke-package.mjs",
|
|
88
102
|
"build": "node scripts/build.mjs",
|
|
89
103
|
"lint": "biome check .",
|
|
90
|
-
"build:pages": "pnpm
|
|
104
|
+
"build:pages": "pnpm build && node scripts/build-pages.mjs"
|
|
91
105
|
}
|
|
92
106
|
}
|