@kahwee/sf-map-svg 0.3.0 → 1.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +23 -4
  3. package/README.md +213 -2
  4. package/SOURCES.md +4 -0
  5. package/{data → dist/data}/README.md +1 -0
  6. package/dist/data/bart-stations.json +134 -0
  7. package/dist/data/catalog.json +4718 -0
  8. package/dist/data/coast.json +36 -0
  9. package/dist/data/districts-2002.json +325 -0
  10. package/dist/data/districts-2012.json +325 -0
  11. package/dist/data/districts-2022.json +325 -0
  12. package/dist/data/highways.json +4138 -0
  13. package/dist/data/index.d.ts +26 -0
  14. package/{data → dist/data}/index.js +37 -39
  15. package/dist/data/key-roads.json +12662 -0
  16. package/dist/data/landmarks.json +167 -0
  17. package/dist/data/neighborhoods-analysis.json +817 -0
  18. package/dist/data/neighborhoods-realtor.json +1978 -0
  19. package/dist/data/neighborhoods.json +2267 -0
  20. package/dist/data/types.d.ts +117 -0
  21. package/dist/data/types.js +1 -0
  22. package/dist/src/data.d.ts +25 -0
  23. package/{src → dist/src}/data.js +17 -20
  24. package/dist/src/explorer-layout.d.ts +23 -0
  25. package/dist/src/explorer-layout.js +72 -0
  26. package/dist/src/explorer.d.ts +4 -0
  27. package/dist/src/explorer.js +955 -0
  28. package/dist/src/geometry.d.ts +5 -0
  29. package/dist/src/geometry.js +53 -0
  30. package/dist/src/immutable.d.ts +2 -0
  31. package/dist/src/immutable.js +9 -0
  32. package/dist/src/index.d.ts +13 -0
  33. package/dist/src/index.js +125 -0
  34. package/dist/src/interactive.d.ts +4 -0
  35. package/dist/src/interactive.js +5 -0
  36. package/dist/src/layers.d.ts +36 -0
  37. package/dist/src/layers.js +64 -0
  38. package/dist/src/navigation.d.ts +6 -0
  39. package/dist/src/navigation.js +145 -0
  40. package/dist/src/overlays.d.ts +25 -0
  41. package/dist/src/overlays.js +15 -0
  42. package/dist/src/svg.d.ts +4 -0
  43. package/dist/src/svg.js +7 -0
  44. package/dist/src/types.d.ts +108 -0
  45. package/dist/src/types.js +1 -0
  46. package/dist/src/viewport.d.ts +5 -0
  47. package/dist/src/viewport.js +27 -0
  48. package/package.json +34 -24
  49. package/data/bart-stations.json +0 -134
  50. package/data/catalog.json +0 -4699
  51. package/data/coast.json +0 -36
  52. package/data/districts-2002.json +0 -325
  53. package/data/districts-2012.json +0 -325
  54. package/data/districts-2022.json +0 -325
  55. package/data/highways.json +0 -4138
  56. package/data/index.d.ts +0 -117
  57. package/data/landmarks.json +0 -167
  58. package/data/neighborhoods-analysis.json +0 -817
  59. package/data/neighborhoods-realtor.json +0 -1978
  60. package/data/neighborhoods.json +0 -2267
  61. package/src/geometry.d.ts +0 -8
  62. package/src/geometry.js +0 -51
  63. package/src/immutable.js +0 -8
  64. package/src/index.d.ts +0 -51
  65. package/src/index.js +0 -122
  66. package/src/layers.js +0 -54
  67. package/src/overlays.js +0 -18
  68. package/src/svg.js +0 -12
@@ -0,0 +1,5 @@
1
+ import type { Geometry, Position } from '../data/types.js';
2
+ export type Project = (position: Position) => [number, number];
3
+ export declare function rawProject([longitude, latitude]: Position): [number, number];
4
+ export declare function positions(geometry: Geometry | null | undefined): Position[];
5
+ export declare function geometryPath(geometry: Geometry | null | undefined, project: Project): string;
@@ -0,0 +1,53 @@
1
+ const number = (value) => Number(value.toFixed(2));
2
+ export function rawProject([longitude, latitude]) {
3
+ if (!Number.isFinite(longitude) ||
4
+ Math.abs(longitude) > 180 ||
5
+ !Number.isFinite(latitude) ||
6
+ Math.abs(latitude) >= 90)
7
+ throw new RangeError('Coordinates must be finite [longitude, latitude], with longitude from -180 to 180 and latitude strictly between -90 and 90.');
8
+ const point = [
9
+ (longitude * Math.PI) / 180,
10
+ -Math.log(Math.tan(Math.PI / 4 + (latitude * Math.PI) / 360)),
11
+ ];
12
+ if (!point.every(Number.isFinite))
13
+ throw new RangeError('Coordinates are too close to a pole for Mercator projection.');
14
+ return point;
15
+ }
16
+ export function positions(geometry) {
17
+ if (!geometry)
18
+ return [];
19
+ switch (geometry.type) {
20
+ case 'Point':
21
+ return [geometry.coordinates];
22
+ case 'LineString':
23
+ case 'MultiPoint':
24
+ return [...geometry.coordinates];
25
+ case 'Polygon':
26
+ case 'MultiLineString':
27
+ return geometry.coordinates.flat();
28
+ case 'MultiPolygon':
29
+ return geometry.coordinates.flat(2);
30
+ case 'GeometryCollection':
31
+ return geometry.geometries.flatMap(positions);
32
+ }
33
+ }
34
+ export function geometryPath(geometry, project) {
35
+ if (!geometry)
36
+ return '';
37
+ const line = (ring) => ring.map((p, i) => `${i ? 'L' : 'M'}${project(p).map(number).join(',')}`).join('');
38
+ const polygon = (rings) => rings.map((ring) => `${line(ring)}Z`).join('');
39
+ switch (geometry.type) {
40
+ case 'Polygon':
41
+ return polygon(geometry.coordinates);
42
+ case 'MultiPolygon':
43
+ return geometry.coordinates.map(polygon).join('');
44
+ case 'LineString':
45
+ return line(geometry.coordinates);
46
+ case 'MultiLineString':
47
+ return geometry.coordinates.map(line).join('');
48
+ case 'GeometryCollection':
49
+ return geometry.geometries.map((g) => geometryPath(g, project)).join('');
50
+ default:
51
+ return '';
52
+ }
53
+ }
@@ -0,0 +1,2 @@
1
+ /** Freeze shared geographic objects so a consumer cannot change later renders. */
2
+ export declare function deepFreeze<T>(value: T): T;
@@ -0,0 +1,9 @@
1
+ /** Freeze shared geographic objects so a consumer cannot change later renders. */
2
+ export function deepFreeze(value) {
3
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
4
+ for (const child of Object.values(value))
5
+ deepFreeze(child);
6
+ Object.freeze(value);
7
+ }
8
+ return value;
9
+ }
@@ -0,0 +1,13 @@
1
+ import type { Position } from '../data/types.js';
2
+ import type { DistrictYear, SFMapOptions } from './types.js';
3
+ export type { DistrictYear, MapMarker, SFMapOptions } from './types.js';
4
+ export declare const districtYears: readonly DistrictYear[];
5
+ export declare const neighborhoodNames: readonly string[];
6
+ export declare const districtColors: readonly string[];
7
+ /** Make an offline SVG and the matching longitude/latitude projection. */
8
+ export declare function createSFMap(options?: SFMapOptions): {
9
+ svg: string;
10
+ project: (coordinates: Position) => [number, number];
11
+ viewBox: [number, number, number, number];
12
+ };
13
+ export declare function renderSFMap(options?: SFMapOptions): string;
@@ -0,0 +1,125 @@
1
+ import data from './data.js';
2
+ import { geometryPath, positions, rawProject } from './geometry.js';
3
+ import * as layers from './layers.js';
4
+ import { landmarks as landmarkData, keyRoads as roadData, bartStations as stationData, } from './overlays.js';
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]);
10
+ export const neighborhoodNames = Object.freeze(data.neighborhoods.map((item) => item.name));
11
+ export const districtColors = Object.freeze([
12
+ '#c8dce5',
13
+ '#d5e4d6',
14
+ '#d1d6e8',
15
+ '#d0dfd7',
16
+ '#e4dcca',
17
+ '#d3dced',
18
+ '#d9d8e7',
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',
37
+ };
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
+ /** Make an offline SVG and the matching longitude/latitude projection. */
53
+ export function createSFMap(options = {}) {
54
+ 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;
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
+ };
122
+ }
123
+ export function renderSFMap(options = {}) {
124
+ return createSFMap(options).svg;
125
+ }
@@ -0,0 +1,4 @@
1
+ import type { NeighborhoodExplorerOptions } from './types.js';
2
+ export type { InteractiveLayers, MapPadding, MapViewport, NeighborhoodExplorerElement as InteractiveSFMapElement, NeighborhoodExplorerOptions as InteractiveSFMapOptions, NeighborhoodSelection, } from './types.js';
3
+ /** Reusable browser map; plain basemap by default, no editorial UI or URL changes. */
4
+ export declare function createInteractiveSFMap(options?: NeighborhoodExplorerOptions): import("./types.js").NeighborhoodExplorerElement;
@@ -0,0 +1,5 @@
1
+ import { createNeighborhoodExplorer } from './explorer.js';
2
+ /** Reusable browser map; plain basemap by default, no editorial UI or URL changes. */
3
+ export function createInteractiveSFMap(options = {}) {
4
+ return createNeighborhoodExplorer({ mode: 'basemap', ...options, interface: 'map' });
5
+ }
@@ -0,0 +1,36 @@
1
+ import type { Geometry } from '../data/types.js';
2
+ import type { DistrictRow } from './data.js';
3
+ import type { Project } from './geometry.js';
4
+ import type { BartStation, KeyRoad, Landmark } from './overlays.js';
5
+ import type { MapMarker, SFMapOptions } from './types.js';
6
+ interface LayerContext {
7
+ labels: boolean;
8
+ theme: NonNullable<SFMapOptions['theme']>;
9
+ idPrefix: string;
10
+ colors: Required<NonNullable<SFMapOptions['colors']>>;
11
+ project: Project;
12
+ path: (geometry: Geometry | null | undefined) => string;
13
+ }
14
+ interface DistrictPath {
15
+ id: number;
16
+ path: string;
17
+ color: string;
18
+ }
19
+ export declare function districtFills(districts: readonly DistrictPath[], { idPrefix }: LayerContext): string;
20
+ export declare function districtLines(districts: readonly DistrictPath[], { idPrefix, colors }: LayerContext): string;
21
+ export declare function landmarks(items: readonly Landmark[], { idPrefix, path, colors }: LayerContext): string;
22
+ export declare function highways(items: readonly {
23
+ route: string;
24
+ geometry: Geometry;
25
+ }[], { path, colors, theme }: LayerContext): string;
26
+ export declare function neighborhoods(items: readonly {
27
+ name: string;
28
+ geometry: Geometry;
29
+ }[], { idPrefix, path, colors }: LayerContext): string;
30
+ export declare function districtLabels(districts: readonly DistrictRow[], { project, colors }: LayerContext): string;
31
+ export declare function landmarkLabels(items: readonly Landmark[], { project, colors }: LayerContext): string;
32
+ export declare function bartStations(items: readonly BartStation[], { project, colors, labels }: LayerContext): string;
33
+ export declare function markers(items: readonly MapMarker[], { project, colors }: LayerContext): string;
34
+ export declare function keyRoads(items: readonly KeyRoad[], { idPrefix, path, colors }: LayerContext): string;
35
+ export declare function keyRoadLabels(items: readonly KeyRoad[], { project }: LayerContext): string;
36
+ export {};
@@ -0,0 +1,64 @@
1
+ import { escapeXml, number, overlayLabel, stroke } from './svg.js';
2
+ export function districtFills(districts, { idPrefix }) {
3
+ return `<g data-layer="district-fills" clip-path="url(#${idPrefix}-coast)">${districts.map((d) => `<path data-district="${d.id}" d="${d.path}" fill="${escapeXml(d.color)}" fill-rule="evenodd"/>`).join('')}</g>`;
4
+ }
5
+ export function districtLines(districts, { idPrefix, colors }) {
6
+ return `<g data-layer="district-lines" clip-path="url(#${idPrefix}-coast)">${districts.map((d) => `<path data-district="${d.id}" d="${d.path}" ${stroke(colors.district, 1.1)}/>`).join('')}</g>`;
7
+ }
8
+ export function landmarks(items, { idPrefix, path, colors }) {
9
+ 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
+ }
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>`;
13
+ }
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>`;
16
+ }
17
+ export function districtLabels(districts, { project, colors }) {
18
+ // Primary badges first, then island badges, matching the original layer order.
19
+ const labels = [
20
+ ...districts.map((d) => ({ id: d.id, label: d.labelPoints[0] })),
21
+ ...districts.flatMap((d) => d.labelPoints.slice(1).map((label) => ({ id: d.id, label }))),
22
+ ];
23
+ 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
+ .map((d) => {
25
+ 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>`;
27
+ })
28
+ .join('')}</g>`;
29
+ }
30
+ export function landmarkLabels(items, { project, colors }) {
31
+ return `<g data-layer="landmark-labels" ${overlayLabel} fill="${escapeXml(colors.landmark)}">${items
32
+ .map((item) => {
33
+ const [x, y] = project(item.label);
34
+ return `<text x="${number(x + item.offset[0])}" y="${number(y + item.offset[1])}" text-anchor="${escapeXml(item.anchor)}" dominant-baseline="middle">${escapeXml(item.name)}</text>`;
35
+ })
36
+ .join('')}</g>`;
37
+ }
38
+ export function bartStations(items, { project, colors, labels }) {
39
+ return `<g data-layer="bart-stations">${items
40
+ .map((station) => {
41
+ 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>`;
43
+ })
44
+ .join('')}</g>`;
45
+ }
46
+ export function markers(items, { project, colors }) {
47
+ return `<g data-layer="markers">${items
48
+ .map((marker) => {
49
+ 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>`;
51
+ })
52
+ .join('')}</g>`;
53
+ }
54
+ export function keyRoads(items, { idPrefix, path, colors }) {
55
+ return `<g data-layer="key-roads" clip-path="url(#${idPrefix}-coast)">${items.map((road) => `<path data-key-road="${escapeXml(road.id)}" d="${path(road.geometry)}" ${stroke(colors.road, 1)}><title>${escapeXml(road.name)}</title></path>`).join('')}</g>`;
56
+ }
57
+ export function keyRoadLabels(items, { project }) {
58
+ return `<g data-layer="key-road-labels" ${overlayLabel} fill="#77736b">${items
59
+ .map((road) => {
60
+ const [x, y] = project(road.label);
61
+ return `<text x="${number(x)}" y="${number(y - 5)}" text-anchor="middle">${escapeXml(road.name)}</text>`;
62
+ })
63
+ .join('')}</g>`;
64
+ }
@@ -0,0 +1,6 @@
1
+ import type { MapViewport } from './types.js';
2
+ /** DOM navigation owns no content, selection, history, or application controls. */
3
+ export declare function attachNavigation(svg: SVGSVGElement, getView: () => MapViewport, setView: (view: MapViewport) => void, signal: AbortSignal, onExit: () => void): {
4
+ cancel: () => void;
5
+ setTouchNavigation(value: boolean): void;
6
+ };
@@ -0,0 +1,145 @@
1
+ /** DOM navigation owns no content, selection, history, or application controls. */
2
+ export function attachNavigation(svg, getView, setView, signal, onExit) {
3
+ let engaged = false;
4
+ let moved = false;
5
+ let skipClick = false;
6
+ const pointers = new Map();
7
+ let start;
8
+ const listen = (target, type, handler, options = {}) => target.addEventListener(type, handler, { ...options, signal });
9
+ function rebase() {
10
+ start = pointers.size ? { points: [...pointers.values()], view: getView() } : undefined;
11
+ }
12
+ function cancel() {
13
+ for (const id of pointers.keys()) {
14
+ if (svg.hasPointerCapture(id))
15
+ svg.releasePointerCapture(id);
16
+ }
17
+ pointers.clear();
18
+ start = undefined;
19
+ if (moved)
20
+ skipClick = true;
21
+ moved = false;
22
+ }
23
+ function zoom(factor) {
24
+ const [x, y, size] = getView();
25
+ const next = Math.max(800 / 12, Math.min(800, size / factor));
26
+ setView([x + (size - next) / 2, y + (size - next) / 2, next]);
27
+ }
28
+ listen(svg, 'pointerdown', (event) => {
29
+ if (event.button !== 0 || (event.pointerType === 'touch' && !engaged))
30
+ return;
31
+ if (!pointers.size) {
32
+ moved = false;
33
+ skipClick = false;
34
+ }
35
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
36
+ rebase();
37
+ });
38
+ listen(window, 'pointermove', (event) => {
39
+ if (!pointers.has(event.pointerId) || !start)
40
+ return;
41
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
42
+ const points = [...pointers.values()];
43
+ const midpoint = (p) => p.length > 1 ? { x: (p[0].x + p[1].x) / 2, y: (p[0].y + p[1].y) / 2 } : p[0];
44
+ const a = midpoint(start.points), b = midpoint(points);
45
+ const distance = (p) => Math.hypot(p[1].x - p[0].x, p[1].y - p[0].y);
46
+ const ratio = points.length > 1 && start.points.length > 1
47
+ ? distance(points) / Math.max(1, distance(start.points))
48
+ : 1;
49
+ if (Math.hypot(b.x - a.x, b.y - a.y) > 4 || Math.abs(ratio - 1) > 0.02)
50
+ moved = true;
51
+ if (!moved)
52
+ return;
53
+ if (!svg.hasPointerCapture(event.pointerId))
54
+ svg.setPointerCapture(event.pointerId);
55
+ const box = svg.getBoundingClientRect();
56
+ if (!box.width)
57
+ return;
58
+ const [x, y, size] = start.view;
59
+ const next = Math.max(800 / 12, Math.min(800, size / Math.max(0.01, ratio)));
60
+ setView([
61
+ x + ((a.x - box.left) * size) / box.width - ((b.x - box.left) * next) / box.width,
62
+ y + ((a.y - box.top) * size) / box.width - ((b.y - box.top) * next) / box.width,
63
+ next,
64
+ ]);
65
+ });
66
+ const release = (event) => {
67
+ if (!pointers.has(event.pointerId))
68
+ return;
69
+ pointers.delete(event.pointerId);
70
+ if (moved)
71
+ skipClick = true;
72
+ if (svg.hasPointerCapture(event.pointerId))
73
+ svg.releasePointerCapture(event.pointerId);
74
+ rebase();
75
+ };
76
+ listen(window, 'pointerup', release);
77
+ listen(window, 'pointercancel', (event) => {
78
+ if (pointers.has(event.pointerId))
79
+ cancel();
80
+ });
81
+ listen(svg, 'lostpointercapture', (event) => {
82
+ // Touch starts with implicit capture on the hit path. Its release during
83
+ // transfer to the SVG must not cancel the newly captured gesture.
84
+ if (event.target === svg)
85
+ release(event);
86
+ });
87
+ listen(window, 'blur', cancel);
88
+ listen(svg, 'click', (event) => {
89
+ if (skipClick && event.detail !== 0) {
90
+ skipClick = false;
91
+ event.preventDefault();
92
+ event.stopImmediatePropagation();
93
+ }
94
+ }, { capture: true });
95
+ listen(svg, 'keydown', (event) => {
96
+ if (event.altKey || event.ctrlKey || event.metaKey)
97
+ return;
98
+ const [x, y, size] = getView();
99
+ const step = size / 8;
100
+ switch (event.key) {
101
+ case 'ArrowLeft':
102
+ setView([x - step, y, size]);
103
+ break;
104
+ case 'ArrowRight':
105
+ setView([x + step, y, size]);
106
+ break;
107
+ case 'ArrowUp':
108
+ setView([x, y - step, size]);
109
+ break;
110
+ case 'ArrowDown':
111
+ setView([x, y + step, size]);
112
+ break;
113
+ case '+':
114
+ case '=':
115
+ zoom(1.5);
116
+ break;
117
+ case '-':
118
+ zoom(1 / 1.5);
119
+ break;
120
+ case 'Home':
121
+ setView([0, 0, 800]);
122
+ break;
123
+ case 'Escape':
124
+ onExit();
125
+ break;
126
+ default:
127
+ return;
128
+ }
129
+ event.preventDefault();
130
+ });
131
+ listen(svg, 'wheel', (event) => {
132
+ if (!event.ctrlKey && !event.metaKey)
133
+ return;
134
+ event.preventDefault();
135
+ zoom(Math.exp(-Math.max(-100, Math.min(100, event.deltaY)) / 300));
136
+ }, { passive: false });
137
+ signal.addEventListener('abort', cancel, { once: true });
138
+ return {
139
+ cancel,
140
+ setTouchNavigation(value) {
141
+ cancel();
142
+ engaged = value;
143
+ },
144
+ };
145
+ }
@@ -0,0 +1,25 @@
1
+ import type { Position } from '../data/types.js';
2
+ export declare const landmarks: {
3
+ name: string;
4
+ label: Position;
5
+ offset: readonly [number, number];
6
+ anchor: 'middle' | 'start' | 'end';
7
+ id: string;
8
+ geometry: import("../data/types.js").Geometry;
9
+ }[];
10
+ export declare const bartStations: {
11
+ id: string;
12
+ name: string;
13
+ coordinates: Position;
14
+ }[];
15
+ export type Landmark = (typeof landmarks)[number];
16
+ export type BartStation = (typeof bartStations)[number];
17
+ export declare const keyRoads: {
18
+ name: string;
19
+ sourceNames: readonly string[];
20
+ label: Position;
21
+ segmentIds: readonly string[];
22
+ id: string;
23
+ geometry: import("../data/types.js").Geometry;
24
+ }[];
25
+ export type KeyRoad = (typeof keyRoads)[number];
@@ -0,0 +1,15 @@
1
+ import stationData from '../data/bart-stations.json' with { type: 'json' };
2
+ import roadData from '../data/key-roads.json' with { type: 'json' };
3
+ import parkData from '../data/landmarks.json' with { type: 'json' };
4
+ import { deepFreeze } from './immutable.js';
5
+ export const landmarks = deepFreeze(parkData.features.map(({ id, properties, geometry }) => ({
6
+ id,
7
+ ...properties,
8
+ geometry,
9
+ })));
10
+ export const bartStations = deepFreeze(stationData.features.map(({ id, properties, geometry }) => ({
11
+ id,
12
+ name: properties.name,
13
+ coordinates: geometry.coordinates,
14
+ })));
15
+ export const keyRoads = deepFreeze(roadData.features.map(({ id, properties, geometry }) => ({ id, ...properties, geometry })));
@@ -0,0 +1,4 @@
1
+ export declare const escapeXml: (value: unknown) => string;
2
+ export declare const number: (value: number) => number;
3
+ export declare const stroke: (color: string, weight: number) => string;
4
+ export declare const overlayLabel = "font-family=\"system-ui,sans-serif\" font-size=\"12\" font-weight=\"600\" stroke=\"#f8faf4\" stroke-width=\"3\" stroke-linejoin=\"round\" paint-order=\"stroke\"";
@@ -0,0 +1,7 @@
1
+ export const escapeXml = (value) => String(value)
2
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: XML 1.0 forbids these control characters.
3
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g, '')
4
+ .replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char] ?? char);
5
+ export const number = (value) => Number(value.toFixed(2));
6
+ export const stroke = (color, weight) => `fill="none" stroke="${escapeXml(color)}" stroke-width="${weight}" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"`;
7
+ export const overlayLabel = 'font-family="system-ui,sans-serif" font-size="12" font-weight="600" stroke="#f8faf4" stroke-width="3" stroke-linejoin="round" paint-order="stroke"';
@@ -0,0 +1,108 @@
1
+ import type { Geometry, NeighborhoodFeature, NeighborhoodSource } from '../data/types.js';
2
+ export type DistrictYear = 2002 | 2012 | 2022;
3
+ export interface MapMarker {
4
+ id: string;
5
+ lng: number;
6
+ lat: number;
7
+ label?: string;
8
+ selected?: boolean;
9
+ color?: string;
10
+ }
11
+ export interface SFMapOptions {
12
+ theme?: 'districts' | 'transit';
13
+ width?: number;
14
+ height?: number;
15
+ padding?: number;
16
+ year?: DistrictYear;
17
+ districtLines?: boolean;
18
+ neighborhoodLines?: boolean;
19
+ districtFills?: boolean;
20
+ districtLabels?: boolean;
21
+ /** Hide all visible text labels while retaining geographic symbols and accessible titles. */
22
+ labels?: boolean;
23
+ highways?: boolean;
24
+ keyRoads?: boolean;
25
+ landmarks?: boolean;
26
+ bartStations?: boolean;
27
+ markers?: MapMarker[];
28
+ title?: string;
29
+ idPrefix?: string;
30
+ colors?: Partial<Record<'water' | 'land' | 'district' | 'neighborhood' | 'highway' | 'road' | 'park' | 'landmark' | 'bart' | 'label' | 'marker' | 'selected', string>>;
31
+ }
32
+ /** Serializable viewport in the interactive map's fixed 800 × 800 projected space. */
33
+ export type MapViewport = readonly [x: number, y: number, size: number];
34
+ export type ExplorerMode = 'neighborhoods' | 'districts' | 'basemap';
35
+ export interface MapPadding {
36
+ top?: number;
37
+ right?: number;
38
+ bottom?: number;
39
+ left?: number;
40
+ }
41
+ export interface InteractiveLayers {
42
+ districtFills?: boolean;
43
+ districtLines?: boolean;
44
+ districtLabels?: boolean;
45
+ neighborhoodLines?: boolean;
46
+ neighborhoodLabels?: boolean;
47
+ landmarks?: boolean;
48
+ bartStations?: boolean;
49
+ highways?: boolean;
50
+ keyRoads?: boolean;
51
+ }
52
+ export interface NeighborhoodSelection {
53
+ id: string;
54
+ name: string;
55
+ source: NeighborhoodSource;
56
+ feature: NeighborhoodFeature;
57
+ }
58
+ export interface NeighborhoodExplorerOptions {
59
+ mode?: ExplorerMode;
60
+ labels?: boolean;
61
+ source?: NeighborhoodSource;
62
+ neighborhood?: string;
63
+ year?: DistrictYear;
64
+ theme?: SFMapOptions['theme'];
65
+ /** Explorer chrome or the reusable map with only controls and attribution. */
66
+ interface?: 'explorer' | 'map';
67
+ /** Independent overrides; omitted layers follow mode defaults. */
68
+ layers?: InteractiveLayers;
69
+ selectableNeighborhoods?: boolean;
70
+ /** Screen pixels; defaults preserve 11px roads / 12px other labels. */
71
+ labelSize?: {
72
+ min?: number;
73
+ max?: number;
74
+ };
75
+ /** Screen-pixel space reserved when fitting geometry. */
76
+ fitPadding?: number | MapPadding;
77
+ markers?: readonly MapMarker[];
78
+ /** Screen pixels, independent of zoom. */
79
+ markerRadius?: number;
80
+ markerHitSize?: number;
81
+ markerColor?: string;
82
+ selectedMarkerColor?: string;
83
+ onMarkerActivate?: (marker: MapMarker) => void;
84
+ }
85
+ export interface NeighborhoodExplorerElement extends HTMLElement {
86
+ selectNeighborhood(name: string | null, options?: {
87
+ fit?: boolean;
88
+ }): boolean;
89
+ getSelection(): NeighborhoodSelection | null;
90
+ setSource(source: NeighborhoodSource): void;
91
+ setMode(mode: ExplorerMode): void;
92
+ setLabels(visible: boolean): void;
93
+ resetView(): void;
94
+ zoomBy(factor: number): void;
95
+ panBy(x: number, y: number): void;
96
+ getViewport(): MapViewport;
97
+ setViewport(view: MapViewport): void;
98
+ /** Fit WGS84 geometry, including Point, MultiPoint, or a GeometryCollection. */
99
+ fitGeometry(geometry: Geometry, padding?: number | MapPadding): void;
100
+ /** Explicitly engage map touch gestures; false restores page gestures. */
101
+ setTouchNavigation(enabled: boolean): void;
102
+ setMarkers(markers: readonly MapMarker[]): void;
103
+ selectMarker(id: string | null, options?: {
104
+ fit?: boolean;
105
+ }): boolean;
106
+ getSelectedMarker(): MapMarker | null;
107
+ destroy(): void;
108
+ }
@@ -0,0 +1 @@
1
+ export {};