@kahwee/sf-map-svg 0.3.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +44 -3
  2. package/LICENSE +23 -4
  3. package/README.md +267 -74
  4. package/SOURCES.md +4 -0
  5. package/{data → dist/data}/README.md +2 -1
  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 +961 -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/transit.d.ts +3 -0
  45. package/dist/src/transit.js +105 -0
  46. package/dist/src/types.d.ts +112 -0
  47. package/dist/src/types.js +1 -0
  48. package/dist/src/viewport.d.ts +5 -0
  49. package/dist/src/viewport.js +27 -0
  50. package/package.json +40 -25
  51. package/data/bart-stations.json +0 -134
  52. package/data/catalog.json +0 -4699
  53. package/data/coast.json +0 -36
  54. package/data/districts-2002.json +0 -325
  55. package/data/districts-2012.json +0 -325
  56. package/data/districts-2022.json +0 -325
  57. package/data/highways.json +0 -4138
  58. package/data/index.d.ts +0 -117
  59. package/data/landmarks.json +0 -167
  60. package/data/neighborhoods-analysis.json +0 -817
  61. package/data/neighborhoods-realtor.json +0 -1978
  62. package/data/neighborhoods.json +0 -2267
  63. package/src/geometry.d.ts +0 -8
  64. package/src/geometry.js +0 -51
  65. package/src/immutable.js +0 -8
  66. package/src/index.d.ts +0 -51
  67. package/src/index.js +0 -122
  68. package/src/layers.js +0 -54
  69. package/src/overlays.js +0 -18
  70. package/src/svg.js +0 -12
package/src/geometry.d.ts DELETED
@@ -1,8 +0,0 @@
1
- import type { Geometry, Position } from '../data/index.js';
2
- export declare function rawProject(coordinates: Position): [number, number];
3
- export declare function positions(geometry: Geometry | null | undefined): Position[];
4
- /** SVG paths for polygon/line geometry. Points must be projected and drawn as markers. */
5
- export declare function geometryPath(
6
- geometry: Geometry | null | undefined,
7
- project: (position: Position) => [number, number],
8
- ): string;
package/src/geometry.js DELETED
@@ -1,51 +0,0 @@
1
- const number = (value) => Number(value.toFixed(2));
2
-
3
- export function rawProject([longitude, latitude]) {
4
- if (
5
- !Number.isFinite(longitude) ||
6
- Math.abs(longitude) > 180 ||
7
- !Number.isFinite(latitude) ||
8
- Math.abs(latitude) >= 90
9
- )
10
- throw new RangeError(
11
- 'Coordinates must be finite [longitude, latitude], with longitude from -180 to 180 and latitude strictly between -90 and 90.',
12
- );
13
- const point = [
14
- (longitude * Math.PI) / 180,
15
- -Math.log(Math.tan(Math.PI / 4 + (latitude * Math.PI) / 360)),
16
- ];
17
- if (!point.every(Number.isFinite))
18
- throw new RangeError('Coordinates are too close to a pole for Mercator projection.');
19
- return point;
20
- }
21
- export function positions(geometry) {
22
- if (!geometry) return [];
23
- if (geometry.type === 'GeometryCollection') return geometry.geometries.flatMap(positions);
24
- const result = [];
25
- const walk = (coordinates) => {
26
- if (typeof coordinates[0] === 'number') result.push(coordinates);
27
- else coordinates.forEach(walk);
28
- };
29
- walk(geometry.coordinates);
30
- return result;
31
- }
32
- export function geometryPath(geometry, project) {
33
- if (!geometry) return '';
34
- const line = (ring) =>
35
- ring.map((p, i) => `${i ? 'L' : 'M'}${project(p).map(number).join(',')}`).join('');
36
- const polygon = (rings) => rings.map((ring) => `${line(ring)}Z`).join('');
37
- switch (geometry.type) {
38
- case 'Polygon':
39
- return polygon(geometry.coordinates);
40
- case 'MultiPolygon':
41
- return geometry.coordinates.map(polygon).join('');
42
- case 'LineString':
43
- return line(geometry.coordinates);
44
- case 'MultiLineString':
45
- return geometry.coordinates.map(line).join('');
46
- case 'GeometryCollection':
47
- return geometry.geometries.map((g) => geometryPath(g, project)).join('');
48
- default:
49
- return '';
50
- }
51
- }
package/src/immutable.js DELETED
@@ -1,8 +0,0 @@
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)) deepFreeze(child);
5
- Object.freeze(value);
6
- }
7
- return value;
8
- }
package/src/index.d.ts DELETED
@@ -1,51 +0,0 @@
1
- import type { Position } from '../data/index.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
- width?: number;
13
- height?: number;
14
- padding?: number;
15
- year?: DistrictYear;
16
- districtLines?: boolean;
17
- neighborhoodLines?: boolean;
18
- districtFills?: boolean;
19
- districtLabels?: boolean;
20
- highways?: boolean;
21
- landmarks?: boolean;
22
- bartStations?: boolean;
23
- markers?: MapMarker[];
24
- title?: string;
25
- idPrefix?: string;
26
- colors?: Partial<
27
- Record<
28
- | 'water'
29
- | 'land'
30
- | 'district'
31
- | 'neighborhood'
32
- | 'highway'
33
- | 'park'
34
- | 'landmark'
35
- | 'bart'
36
- | 'label'
37
- | 'marker'
38
- | 'selected',
39
- string
40
- >
41
- >;
42
- }
43
- export declare const districtYears: readonly DistrictYear[];
44
- export declare const neighborhoodNames: readonly string[];
45
- export declare const districtColors: readonly string[];
46
- export declare function createSFMap(options?: SFMapOptions): {
47
- svg: string;
48
- project: (coordinates: Position) => [number, number];
49
- viewBox: [number, number, number, number];
50
- };
51
- export declare function renderSFMap(options?: SFMapOptions): string;
package/src/index.js DELETED
@@ -1,122 +0,0 @@
1
- import data from './data.js';
2
- import { landmarks as landmarkData, bartStations as stationData } from './overlays.js';
3
- import { rawProject, positions, geometryPath } from './geometry.js';
4
- import { escape, stroke } from './svg.js';
5
- import * as layers from './layers.js';
6
-
7
- // The common coast is immutable: compute its Mercator bounds once per module.
8
- const coastPoints = positions(data.coast).map(rawProject);
9
- const bounds = coastPoints.reduce(
10
- (b, [x, y]) => [Math.min(b[0], x), Math.min(b[1], y), Math.max(b[2], x), Math.max(b[3], y)],
11
- [Infinity, Infinity, -Infinity, -Infinity],
12
- );
13
-
14
- export const districtYears = Object.freeze([2002, 2012, 2022]);
15
- export const neighborhoodNames = Object.freeze(data.neighborhoods.map((item) => item.name));
16
- export const districtColors = Object.freeze([
17
- '#c8dce5',
18
- '#d5e4d6',
19
- '#d1d6e8',
20
- '#d0dfd7',
21
- '#e4dcca',
22
- '#d3dced',
23
- '#d9d8e7',
24
- '#c8dfdf',
25
- '#e2d6ca',
26
- '#cbdcd1',
27
- '#dfddc9',
28
- ]);
29
- const defaults = {
30
- water: '#e7f0f3',
31
- land: '#f1f3ee',
32
- district: '#71838a',
33
- neighborhood: '#8c9195',
34
- highway: '#bd8b73',
35
- park: '#b2cfaa',
36
- landmark: '#3e6346',
37
- bart: '#24789a',
38
- label: '#304958',
39
- marker: '#245b61',
40
- selected: '#f04f32',
41
- };
42
- let sequence = 0;
43
-
44
- /** Make an offline SVG and the matching longitude/latitude projection. */
45
- export function createSFMap(options = {}) {
46
- const {
47
- width = 800,
48
- height = 800,
49
- padding = 28,
50
- year = 2022,
51
- districtLines = true,
52
- neighborhoodLines = false,
53
- districtFills = true,
54
- districtLabels = true,
55
- highways = false,
56
- landmarks = false,
57
- bartStations = false,
58
- markers = [],
59
- title = 'San Francisco map',
60
- idPrefix = `sf-map-${++sequence}`,
61
- } = options;
62
- if (
63
- ![width, height, padding].every(Number.isFinite) ||
64
- width <= 0 ||
65
- height <= 0 ||
66
- padding < 0 ||
67
- padding * 2 >= Math.min(width, height)
68
- )
69
- throw new RangeError('Use positive dimensions and padding smaller than half the map.');
70
- if (!districtYears.includes(year))
71
- throw new RangeError('District year must be 2002, 2012, or 2022.');
72
- if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(idPrefix))
73
- throw new TypeError(
74
- 'idPrefix must start with a letter and contain only letters, numbers, underscores, or hyphens.',
75
- );
76
- const colors = { ...defaults, ...options.colors };
77
- const [minX, minY, maxX, maxY] = bounds;
78
- const scale = Math.min(
79
- (width - padding * 2) / (maxX - minX),
80
- (height - padding * 2) / (maxY - minY),
81
- );
82
- const project = (coordinates) => {
83
- const [x, y] = rawProject(coordinates);
84
- const point = [
85
- (x - (minX + maxX) / 2) * scale + width / 2,
86
- (y - (minY + maxY) / 2) * scale + height / 2,
87
- ];
88
- if (!point.every(Number.isFinite))
89
- throw new RangeError('Projected coordinates must be finite; use smaller map dimensions.');
90
- return point;
91
- };
92
- const path = (geometry) => geometryPath(geometry, project);
93
- const coastPath = path(data.coast);
94
- const districts = data.districts[year];
95
- const context = { project, path, colors, idPrefix };
96
- const districtPaths =
97
- districtFills || districtLines
98
- ? districts.map((d) => ({
99
- id: d.id,
100
- path: path(d.geometry) + path(d.extras),
101
- color: districtColors[d.id - 1],
102
- }))
103
- : [];
104
- const parts = [
105
- `<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">${escape(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.' : ''}${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="${escape(colors.water)}"/><g data-layer="geography"><path data-layer="coast" d="${coastPath}" fill="${escape(colors.land)}" fill-rule="evenodd"/>`,
106
- ];
107
- // Explicit drawing order keeps optional overlays and user markers predictable.
108
- if (districtFills) parts.push(layers.districtFills(districtPaths, context));
109
- if (landmarks) parts.push(layers.landmarks(landmarkData, context));
110
- if (highways) parts.push(layers.highways(data.highways, context));
111
- if (neighborhoodLines) parts.push(layers.neighborhoods(data.neighborhoods, context));
112
- if (districtLines) parts.push(layers.districtLines(districtPaths, context));
113
- parts.push(`<path data-layer="coastline" d="${coastPath}" ${stroke(colors.district, 0.65)}/>`);
114
- if (districtLabels) parts.push(layers.districtLabels(districts, context));
115
- if (landmarks) parts.push(layers.landmarkLabels(landmarkData, context));
116
- if (bartStations) parts.push(layers.bartStations(stationData, context));
117
- parts.push(layers.markers(markers, context), '</g></svg>');
118
- return { svg: parts.join(''), project, viewBox: [0, 0, width, height] };
119
- }
120
- export function renderSFMap(options = {}) {
121
- return createSFMap(options).svg;
122
- }
package/src/layers.js DELETED
@@ -1,54 +0,0 @@
1
- import { escape, number, stroke, overlayLabel } from './svg.js';
2
-
3
- export function districtFills(districts, { idPrefix }) {
4
- return `<g data-layer="district-fills" clip-path="url(#${idPrefix}-coast)">${districts.map((d) => `<path data-district="${d.id}" d="${d.path}" fill="${d.color}" fill-rule="evenodd"/>`).join('')}</g>`;
5
- }
6
- export function districtLines(districts, { idPrefix, colors }) {
7
- 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>`;
8
- }
9
- export function landmarks(items, { idPrefix, path, colors }) {
10
- return `<g data-layer="landmarks" clip-path="url(#${idPrefix}-coast)">${items.map((item) => `<path data-landmark="${escape(item.id)}" d="${path(item.geometry)}" fill="${escape(colors.park)}" fill-rule="evenodd"><title>${escape(item.name)}</title></path>`).join('')}</g>`;
11
- }
12
- export function highways(items, { path, colors }) {
13
- return `<g data-layer="highways">${items.map((road) => `<path data-route="${escape(road.route)}" d="${path(road.geometry)}" ${stroke(colors.highway, 1.4)}/>`).join('')}</g>`;
14
- }
15
- export function neighborhoods(items, { idPrefix, path, colors }) {
16
- return `<g data-layer="neighborhood-lines" clip-path="url(#${idPrefix}-coast)">${items.map((n) => `<path data-neighborhood="${escape(n.name)}" d="${path(n.geometry)}" ${stroke(colors.neighborhood, 0.65)} stroke-dasharray="2 2"><title>${escape(n.name)}</title></path>`).join('')}</g>`;
17
- }
18
- export function districtLabels(districts, { project, colors }) {
19
- // Primary badges first, then island badges, matching the original layer order.
20
- const labels = [
21
- ...districts.map((d) => ({ id: d.id, label: d.labelPoints[0] })),
22
- ...districts.flatMap((d) => d.labelPoints.slice(1).map((label) => ({ id: d.id, label }))),
23
- ];
24
- return `<g data-layer="district-labels" font-family="system-ui,sans-serif" font-size="12" font-weight="600" text-anchor="middle" fill="${escape(colors.label)}">${labels
25
- .map((d) => {
26
- const [x, y] = project(d.label).map(number);
27
- return `<g transform="translate(${x},${y})"><circle r="10" fill="#ffffff" fill-opacity=".9"/><text dy=".35em">${d.id}</text></g>`;
28
- })
29
- .join('')}</g>`;
30
- }
31
- export function landmarkLabels(items, { project, colors }) {
32
- return `<g data-layer="landmark-labels" ${overlayLabel} fill="${escape(colors.landmark)}">${items
33
- .map((item) => {
34
- const [x, y] = project(item.label);
35
- return `<text x="${number(x + item.offset[0])}" y="${number(y + item.offset[1])}" text-anchor="${escape(item.anchor)}" dominant-baseline="middle">${escape(item.name)}</text>`;
36
- })
37
- .join('')}</g>`;
38
- }
39
- export function bartStations(items, { project, colors }) {
40
- return `<g data-layer="bart-stations">${items
41
- .map((station) => {
42
- const [x, y] = project(station.coordinates).map(number);
43
- return `<g data-bart-station="${escape(station.id)}" transform="translate(${x},${y})"><title>${escape(station.name)} BART station</title><circle r="5" fill="#fff" stroke="${escape(colors.bart)}" stroke-width="2.5"/><circle r="1.5" fill="${escape(colors.bart)}"/><text x="10" y="4" ${overlayLabel} fill="${escape(colors.bart)}">${escape(station.name)}</text></g>`;
44
- })
45
- .join('')}</g>`;
46
- }
47
- export function markers(items, { project, colors }) {
48
- return `<g data-layer="markers">${items
49
- .map((marker) => {
50
- const [x, y] = project([marker.lng, marker.lat]).map(number);
51
- return `<circle data-marker-id="${escape(marker.id)}" cx="${x}" cy="${y}" r="${marker.selected ? 8 : 5}" fill="${escape(marker.color ?? (marker.selected ? colors.selected : colors.marker))}" stroke="#fff9e9" stroke-width="2" vector-effect="non-scaling-stroke"><title>${escape(marker.label ?? marker.id)}</title></circle>`;
52
- })
53
- .join('')}</g>`;
54
- }
package/src/overlays.js DELETED
@@ -1,18 +0,0 @@
1
- import parkData from '../data/landmarks.json' with { type: 'json' };
2
- import stationData from '../data/bart-stations.json' with { type: 'json' };
3
- import { deepFreeze } from './immutable.js';
4
-
5
- export const landmarks = deepFreeze(
6
- parkData.features.map(({ id, properties, geometry }) => ({
7
- id,
8
- ...properties,
9
- geometry,
10
- })),
11
- );
12
- export const bartStations = deepFreeze(
13
- stationData.features.map(({ id, properties, geometry }) => ({
14
- id,
15
- name: properties.name,
16
- coordinates: geometry.coordinates,
17
- })),
18
- );
package/src/svg.js DELETED
@@ -1,12 +0,0 @@
1
- export const escape = (value) =>
2
- String(value)
3
- .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g, '')
4
- .replace(
5
- /[&<>"']/g,
6
- (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char],
7
- );
8
- export const number = (value) => Number(value.toFixed(2));
9
- export const stroke = (color, weight) =>
10
- `fill="none" stroke="${escape(color)}" stroke-width="${weight}" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"`;
11
- export const overlayLabel =
12
- 'font-family="system-ui,sans-serif" font-size="12" font-weight="600" stroke="#f8faf4" stroke-width="3" stroke-linejoin="round" paint-order="stroke"';