@kahwee/sf-map-svg 1.0.0 → 1.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.
@@ -25,7 +25,7 @@ function svgElement(tag, attributes = {}) {
25
25
  return node;
26
26
  }
27
27
  /** Create an offline, browser-only neighborhood explorer. Call destroy() before disposal. */
28
- export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighborhoods', labels = true, neighborhood, year = 2022, theme = 'transit', interface: chrome = 'explorer', layers = {}, selectableNeighborhoods = true, labelSize = {}, fitPadding = 24, markers: initialMarkers = [], markerRadius = 6, markerHitSize = 44, markerColor = '#245b61', selectedMarkerColor = '#f04f32', onMarkerActivate, } = {}) {
28
+ export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighborhoods', labels = true, neighborhood, year = 2022, theme = 'transit', interface: chrome = 'explorer', layers = {}, selectableNeighborhoods = true, labelSize = {}, fitPadding = 24, markers: initialMarkers = [], markerRadius = 6, markerHitSize = 44, markerColor = '#245b61', selectedMarkerColor = '#f04f32', onMarkerActivate, overlays: initialOverlays = [], onOverlayActivate, style: styleOptions = {}, strings = {}, controls = {}, } = {}) {
29
29
  if (!['neighborhoods', 'districts', 'basemap'].includes(mode))
30
30
  throw new RangeError(`Unknown map mode: ${mode}`);
31
31
  if (chrome !== 'explorer' && chrome !== 'map')
@@ -63,7 +63,7 @@ export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighbo
63
63
  root.setAttribute('aria-label', 'San Francisco neighborhood explorer');
64
64
  const style = element('style');
65
65
  style.textContent = `
66
- .sf-explorer{container:sf-neighborhood-explorer / inline-size;font:14px/1.5 system-ui,sans-serif;color:#18364f;background:#fff;border:1px solid #cedae3;border-radius:14px;overflow:hidden;max-width:1120px;margin:auto;box-shadow:0 8px 32px #18364f08}
66
+ .sf-explorer{container:sf-neighborhood-explorer / inline-size;font:14px/1.5 var(--sf-map-font);color:var(--sf-map-ink);background:var(--sf-map-surface);border:1px solid var(--sf-map-border);border-radius:14px;overflow:hidden;max-width:1120px;margin:auto;box-shadow:0 8px 32px #18364f08}
67
67
  .sf-explorer *{box-sizing:border-box}
68
68
  .sf-explorer [hidden]{display:none!important}
69
69
  .sf-explorer h2,.sf-explorer p{margin:0}
@@ -72,8 +72,8 @@ export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighbo
72
72
  .sf-explorer button{cursor:pointer;transition:background .12s,border-color .12s}
73
73
  .sf-explorer button:hover{background:#f0f6fa;border-color:#8ba9c0}
74
74
  .sf-explorer button[aria-pressed=true]{color:#123d63;background:#e6f1f9;border-color:#5689b0;font-weight:650}
75
- .sf-explorer :focus-visible{outline:3px solid #1676b8;outline-offset:2px}
76
- .sf-explorer-header{padding:24px;border-top:4px solid #163d61;border-bottom:1px solid #dce5eb}
75
+ .sf-explorer :focus-visible{outline:3px solid var(--sf-map-focus);outline-offset:2px}
76
+ .sf-explorer-header{padding:24px;border-top:4px solid var(--sf-map-accent);border-bottom:1px solid var(--sf-map-border)}
77
77
  .sf-explorer-header h2{font-size:25px;line-height:1.25;font-weight:700;letter-spacing:-.7px}
78
78
  .sf-explorer-header p{color:#586f80;margin-top:8px;max-width:58ch}
79
79
  .sf-explorer-body{display:grid;grid-template-columns:280px minmax(0,1fr);grid-template-areas:"panel map" "detail map";grid-template-rows:auto 1fr}
@@ -92,7 +92,7 @@ export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighbo
92
92
  .sf-explorer-detail p{font-size:12px;margin-bottom:10px}
93
93
  .sf-explorer-download{display:block;margin:8px 0;color:#12649c;text-underline-offset:3px}
94
94
  .sf-explorer-map-column{grid-area:map;min-width:0;background:#e4f2f8}
95
- .sf-explorer-toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:6px;padding:12px;background:#fff;border-bottom:1px solid #dce5eb}
95
+ .sf-explorer-toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:var(--sf-map-control-gap);padding:12px;background:var(--sf-map-surface);border-bottom:1px solid var(--sf-map-border)}
96
96
  .sf-explorer-control-group{display:flex;align-items:center;flex-wrap:wrap;gap:6px;max-width:100%}
97
97
  .sf-explorer-pan-controls{margin-left:auto}
98
98
  .sf-explorer-toolbar button{min-width:44px;padding:6px 10px}
@@ -136,13 +136,20 @@ export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighbo
136
136
  .sf-explorer-download{display:inline-block;margin:4px 14px 4px 0}
137
137
  }`;
138
138
  root.dataset.interface = chrome;
139
+ root.style.setProperty('--sf-map-ink', styleOptions.ink ?? '#18364f');
140
+ root.style.setProperty('--sf-map-surface', styleOptions.surface ?? '#fff');
141
+ root.style.setProperty('--sf-map-accent', styleOptions.accent ?? '#163d61');
142
+ root.style.setProperty('--sf-map-border', styleOptions.border ?? '#cedae3');
143
+ root.style.setProperty('--sf-map-focus', styleOptions.focus ?? '#1676b8');
144
+ root.style.setProperty('--sf-map-control-gap', styleOptions.controlGap ?? '6px');
145
+ root.style.setProperty('--sf-map-font', styleOptions.font ?? 'system-ui,sans-serif');
139
146
  root.append(style);
140
147
  const header = element('header', '', 'sf-explorer-header');
141
- header.append(element('h2', 'Explore San Francisco'), element('p', 'Explore the city, from familiar names to the places in between.'));
148
+ header.append(element('h2', strings.title ?? 'Explore San Francisco'), element('p', 'Explore the city, from familiar names to the places in between.'));
142
149
  root.append(header);
143
150
  const body = element('div', '', 'sf-explorer-body');
144
151
  const panel = element('div', '', 'sf-explorer-panel');
145
- const modeLabel = element('label', 'Map mode');
152
+ const modeLabel = element('label', strings.mode ?? 'Map mode');
146
153
  const modeSelect = element('select');
147
154
  for (const [value, title] of [
148
155
  ['neighborhoods', 'Neighborhoods'],
@@ -154,7 +161,7 @@ export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighbo
154
161
  modeSelect.append(option);
155
162
  }
156
163
  modeLabel.append(modeSelect);
157
- const sourceLabel = element('label', 'Neighborhood definitions');
164
+ const sourceLabel = element('label', strings.source ?? 'Neighborhood definitions');
158
165
  const sourceSelect = element('select');
159
166
  for (const [value, title] of Object.entries(sourceNames)) {
160
167
  const option = element('option', title);
@@ -163,7 +170,7 @@ export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighbo
163
170
  }
164
171
  sourceSelect.value = source;
165
172
  sourceLabel.append(sourceSelect);
166
- const searchLabel = element('label', 'Find a neighborhood');
173
+ const searchLabel = element('label', strings.search ?? 'Find a neighborhood');
167
174
  const search = element('input');
168
175
  search.type = 'search';
169
176
  search.placeholder = 'Try Mission, Outer Mission, or NoPa';
@@ -180,7 +187,8 @@ export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighbo
180
187
  const toolbar = element('div', '', 'sf-explorer-toolbar');
181
188
  toolbar.setAttribute('aria-label', 'Map controls');
182
189
  const canvas = element('div', '', 'sf-explorer-canvas');
183
- const hint = element('p', 'Drag to pan · Ctrl/⌘ + scroll to zoom · Focus map: arrows pan, +/− zoom, Home resets. Touch: scroll the page, or enable Touch navigation to pan and pinch the map. Escape exits.', 'sf-explorer-hint');
190
+ const hint = element('p', strings.gestureHelp ??
191
+ 'Drag to pan · Ctrl/⌘ + scroll to zoom · Focus map: arrows pan, +/− zoom, Home resets. Touch: scroll the page, or enable Touch navigation to pan and pinch the map. Escape exits.', 'sf-explorer-hint');
184
192
  const status = element('p', '', 'sf-explorer-status');
185
193
  status.setAttribute('aria-live', 'polite');
186
194
  const legend = element('div', '', 'sf-explorer-legend');
@@ -198,10 +206,10 @@ export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighbo
198
206
  legend.append(entry);
199
207
  }
200
208
  const featureControls = element('div', '', 'sf-explorer-feature-controls');
201
- const neighborhoodLabel = element('label', 'Select neighborhood');
209
+ const neighborhoodLabel = element('label', strings.chooseNeighborhood ?? 'Select neighborhood');
202
210
  const neighborhoodSelect = element('select');
203
211
  neighborhoodLabel.append(neighborhoodSelect);
204
- const markerLabel = element('label', 'Choose marker');
212
+ const markerLabel = element('label', strings.chooseMarker ?? 'Choose marker');
205
213
  const markerSelect = element('select');
206
214
  markerLabel.append(markerSelect);
207
215
  featureControls.append(neighborhoodLabel, markerLabel);
@@ -246,6 +254,7 @@ export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighbo
246
254
  kind: 'district',
247
255
  })));
248
256
  const markerLayer = svgElement('g', { 'data-layer': 'interactive-markers' });
257
+ const overlayLayer = svgElement('g', { 'data-layer': 'user-overlays' });
249
258
  const areas = svgElement('g', { 'data-layer': 'explorer-neighborhoods' });
250
259
  const stations = svgElement('g', { 'data-layer': 'explorer-bart' });
251
260
  const labelLayer = svgElement('g', {
@@ -253,7 +262,7 @@ export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighbo
253
262
  'aria-hidden': 'true',
254
263
  'font-family': 'system-ui,sans-serif',
255
264
  });
256
- geography.append(areas, stations, labelLayer, markerLayer);
265
+ geography.append(areas, stations, labelLayer, overlayLayer, markerLayer);
257
266
  let view = [0, 0, 800];
258
267
  let selected;
259
268
  let items = [];
@@ -306,6 +315,51 @@ export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighbo
306
315
  legend.hidden = !legend.children.length;
307
316
  let markerItems = [];
308
317
  let selectedMarker = null;
318
+ function setOverlays(overlays) {
319
+ const ids = new Set();
320
+ const next = overlays.map((overlay) => {
321
+ if (!overlay.id || ids.has(overlay.id))
322
+ throw new RangeError('Overlays require unique nonempty IDs.');
323
+ ids.add(overlay.id);
324
+ const node = svgElement('path', {
325
+ 'data-overlay-id': overlay.id,
326
+ d: geometryPath(overlay.geometry, map.project),
327
+ fill: overlay.fill ?? 'none',
328
+ 'fill-opacity': overlay.fillOpacity ?? 1,
329
+ stroke: overlay.stroke ?? '#bd8b73',
330
+ 'stroke-width': overlay.strokeWidth ?? 2,
331
+ 'vector-effect': 'non-scaling-stroke',
332
+ display: overlay.visible === false ? 'none' : 'inline',
333
+ 'pointer-events': 'none',
334
+ ...(overlay.label ? { 'aria-label': overlay.label } : {}),
335
+ });
336
+ if (overlay.label) {
337
+ node.setAttribute('role', 'img');
338
+ const title = svgElement('title');
339
+ title.textContent = overlay.label;
340
+ node.append(title);
341
+ }
342
+ if (onOverlayActivate) {
343
+ node.setAttribute('role', 'button');
344
+ node.setAttribute('tabindex', '0');
345
+ node.setAttribute('pointer-events', 'stroke');
346
+ node.setAttribute('aria-label', overlay.label ?? overlay.id);
347
+ const activate = () => {
348
+ onOverlayActivate(overlay);
349
+ root.dispatchEvent(new CustomEvent('overlayactivate', { bubbles: true, detail: { overlay } }));
350
+ };
351
+ node.addEventListener('click', activate, { signal: controller.signal });
352
+ node.addEventListener('keydown', (event) => {
353
+ if (event.key === 'Enter' || event.key === ' ') {
354
+ event.preventDefault();
355
+ activate();
356
+ }
357
+ }, { signal: controller.signal });
358
+ }
359
+ return node;
360
+ });
361
+ overlayLayer.replaceChildren(...next);
362
+ }
309
363
  const zoomControls = element('div', '', 'sf-explorer-control-group');
310
364
  const panControls = element('div', '', 'sf-explorer-control-group sf-explorer-pan-controls');
311
365
  toolbar.append(zoomControls, panControls);
@@ -321,7 +375,7 @@ export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighbo
321
375
  const zoomText = element('span', '100%', 'sf-explorer-zoom');
322
376
  zoomControls.append(zoomText);
323
377
  const zoomIn = button('+', 'Zoom in', () => zoomBy(1.5));
324
- button('Reset', 'Reset map to city view', resetView);
378
+ button(strings.reset ?? 'Reset', strings.reset ?? 'Reset map to city view', resetView);
325
379
  const labelsButton = button('Labels', 'Show map labels', () => setLabels(!labels));
326
380
  for (const [symbol, label, dx, dy] of [
327
381
  ['←', 'Pan west', -1, 0],
@@ -541,7 +595,7 @@ export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighbo
541
595
  node.focus({ preventScroll: true });
542
596
  }
543
597
  if (!matches.length)
544
- results.append(element('p', 'No matches. Try another name or definition source.', 'sf-explorer-note'));
598
+ results.append(element('p', strings.emptyResults ?? 'No matches. Try another name or definition source.', 'sf-explorer-note'));
545
599
  }
546
600
  function getSelection() {
547
601
  return selected
@@ -784,6 +838,19 @@ export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighbo
784
838
  : (markers.find((marker) => marker.selected)?.id ?? null);
785
839
  selectMarker(nextSelection, { fit: false });
786
840
  }
841
+ if (controls.zoom === false)
842
+ zoomControls.hidden = true;
843
+ if (controls.pan === false)
844
+ panControls.hidden = true;
845
+ if (controls.reset === false) {
846
+ const reset = Array.from(toolbar.querySelectorAll('button')).find((node) => node.textContent === (strings.reset ?? 'Reset'));
847
+ if (reset)
848
+ reset.hidden = true;
849
+ }
850
+ if (controls.labels === false)
851
+ labelsButton.hidden = true;
852
+ if (controls.legend === false)
853
+ legend.hidden = true;
787
854
  function setMode(next) {
788
855
  if (destroyed)
789
856
  return;
@@ -852,7 +919,9 @@ export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighbo
852
919
  if (node?.dataset.neighborhoodResult)
853
920
  selectNeighborhood(node.dataset.neighborhoodResult);
854
921
  });
855
- const touchButton = button('Touch navigation', 'Enable map touch navigation', () => setTouchNavigation(!touchNavigation));
922
+ const touchLabel = strings.touchNavigation ?? 'Touch navigation';
923
+ const touchButton = button(touchLabel, `Enable ${touchLabel.toLowerCase()}`, () => setTouchNavigation(!touchNavigation));
924
+ touchButton.hidden = controls.touch === false;
856
925
  let touchNavigation = false;
857
926
  const navigation = attachNavigation(svg, getViewport, setView, controller.signal, () => setTouchNavigation(false));
858
927
  function setTouchNavigation(enabled) {
@@ -863,8 +932,8 @@ export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighbo
863
932
  touchNavigation = enabled;
864
933
  navigation.setTouchNavigation(enabled);
865
934
  canvas.style.touchAction = enabled ? 'none' : 'pan-y pinch-zoom';
866
- touchButton.textContent = enabled ? 'Done: page scrolling' : 'Touch navigation';
867
- touchButton.setAttribute('aria-label', enabled ? 'Exit map touch navigation' : 'Enable map touch navigation');
935
+ touchButton.textContent = enabled ? 'Done: page scrolling' : touchLabel;
936
+ touchButton.setAttribute('aria-label', enabled ? `Exit ${touchLabel.toLowerCase()}` : `Enable ${touchLabel.toLowerCase()}`);
868
937
  touchButton.setAttribute('aria-pressed', String(enabled));
869
938
  root.dataset.touchNavigation = String(enabled);
870
939
  }
@@ -923,6 +992,7 @@ export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighbo
923
992
  panBy,
924
993
  setTouchNavigation,
925
994
  setMarkers,
995
+ setOverlays,
926
996
  selectMarker,
927
997
  getSelectedMarker,
928
998
  setSource,
@@ -938,18 +1008,25 @@ export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighbo
938
1008
  releaseDownloads();
939
1009
  },
940
1010
  });
941
- const initialMode = mode;
942
- setSource(source);
943
- if (neighborhood !== undefined)
944
- selectNeighborhood(neighborhood);
945
- setMode(initialMode);
946
- if (initialMode === 'neighborhoods' && selected) {
947
- const selectedItem = items.find((item) => item.feature === selected);
948
- if (selectedItem)
949
- setView(fitBounds(selectedItem.bounds));
1011
+ try {
1012
+ const initialMode = mode;
1013
+ setSource(source);
1014
+ if (neighborhood !== undefined)
1015
+ selectNeighborhood(neighborhood);
1016
+ setMode(initialMode);
1017
+ if (initialMode === 'neighborhoods' && selected) {
1018
+ const selectedItem = items.find((item) => item.feature === selected);
1019
+ if (selectedItem)
1020
+ setView(fitBounds(selectedItem.bounds));
1021
+ }
1022
+ setLabels(labels);
1023
+ setTouchNavigation(false);
1024
+ setMarkers(initialMarkers);
1025
+ setOverlays(initialOverlays);
1026
+ }
1027
+ catch (error) {
1028
+ explorer.destroy();
1029
+ throw error;
950
1030
  }
951
- setLabels(labels);
952
- setTouchNavigation(false);
953
- setMarkers(initialMarkers);
954
1031
  return explorer;
955
1032
  }
@@ -1,13 +1,12 @@
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[];
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 { 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]);
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
- 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',
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
- 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
- };
17
+ return createSFMapWithData(options, packagedData);
122
18
  }
123
19
  export function renderSFMap(options = {}) {
124
20
  return createSFMap(options).svg;
@@ -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;
@@ -0,0 +1,54 @@
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
+ sourceNames: readonly string[];
38
+ label: Position;
39
+ segmentIds: readonly string[];
40
+ geometry: Geometry;
41
+ }
42
+ export interface BartStationData {
43
+ id: string;
44
+ name: string;
45
+ coordinates: Position;
46
+ }
47
+ export declare const districtYears: readonly DistrictYear[];
48
+ export declare const districtColors: readonly string[];
49
+ /** Make an offline SVG and the matching longitude/latitude projection. */
50
+ export declare function createSFMapWithData(options: SFMapOptions, data: SFMapData): {
51
+ svg: string;
52
+ project: (coordinates: Position) => [number, number];
53
+ viewBox: [number, number, number, number];
54
+ };
@@ -0,0 +1,125 @@
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
+ if (![width, height, padding].every(Number.isFinite) ||
50
+ width <= 0 ||
51
+ height <= 0 ||
52
+ padding < 0 ||
53
+ padding * 2 >= Math.min(width, height))
54
+ throw new RangeError('Use positive dimensions and padding smaller than half the map.');
55
+ if (!districtYears.includes(year))
56
+ throw new RangeError('District year must be 2002, 2012, or 2022.');
57
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(idPrefix))
58
+ throw new TypeError('idPrefix must start with a letter and contain only letters, numbers, underscores, or hyphens.');
59
+ if (theme !== 'districts' && theme !== 'transit')
60
+ throw new TypeError('Theme must be districts or transit.');
61
+ const colors = { ...defaults, ...(theme === 'transit' ? transitColors : {}), ...options.colors };
62
+ const coastPoints = positions(data.coast).map(rawProject);
63
+ 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]);
64
+ const scale = Math.min((width - padding * 2) / (maxX - minX), (height - padding * 2) / (maxY - minY));
65
+ const project = (coordinates) => {
66
+ const [x, y] = rawProject(coordinates);
67
+ const point = [
68
+ (x - (minX + maxX) / 2) * scale + width / 2,
69
+ (y - (minY + maxY) / 2) * scale + height / 2,
70
+ ];
71
+ if (!point.every(Number.isFinite))
72
+ throw new RangeError('Projected coordinates must be finite; use smaller map dimensions.');
73
+ return point;
74
+ };
75
+ const path = (geometry) => geometryPath(geometry, project);
76
+ const coastPath = path(data.coast);
77
+ const districts = data.districts?.[year] ?? [];
78
+ const landmarkData = data.landmarks ?? [];
79
+ const roadData = data.keyRoads ?? [];
80
+ const stationData = data.bartStations ?? [];
81
+ const context = { project, path, colors, idPrefix, theme, labels };
82
+ const districtPaths = districtFills || districtLines
83
+ ? districts.map((d) => ({
84
+ id: d.id,
85
+ path: path(d.geometry) + path(d.extras),
86
+ color: theme === 'transit' ? colors.land : districtColors[d.id - 1],
87
+ }))
88
+ : [];
89
+ const parts = [
90
+ `<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"/>`,
91
+ ];
92
+ // Explicit drawing order keeps optional overlays and user markers predictable.
93
+ if (districtFills)
94
+ parts.push(layers.districtFills(districtPaths, context));
95
+ if (landmarks)
96
+ parts.push(layers.landmarks(landmarkData, context));
97
+ if (keyRoads)
98
+ parts.push(layers.keyRoads(roadData, context));
99
+ if (highways)
100
+ parts.push(layers.highways(data.highways ?? [], context));
101
+ if (neighborhoodLines)
102
+ parts.push(layers.neighborhoods(data.neighborhoods ?? [], context));
103
+ if (districtLines)
104
+ parts.push(layers.districtLines(districtPaths, context));
105
+ parts.push(`<path data-layer="coastline" d="${coastPath}" ${stroke(colors.district, 0.65)}/>`);
106
+ if (labels && districtLabels)
107
+ parts.push(layers.districtLabels(districts, context));
108
+ if (labels && landmarks)
109
+ parts.push(layers.landmarkLabels(landmarkData, context));
110
+ if (labels && keyRoads)
111
+ parts.push(layers.keyRoadLabels(roadData, context));
112
+ if (bartStations)
113
+ parts.push(layers.bartStations(stationData, context));
114
+ for (const overlay of options.overlays ?? []) {
115
+ if (!overlay.id || !/^[A-Za-z0-9_-]+$/.test(overlay.id))
116
+ throw new TypeError('Overlay IDs must contain only letters, numbers, underscores, or hyphens.');
117
+ 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"' : ''}/>`);
118
+ }
119
+ parts.push(layers.markers(markers, context), '</g></svg>');
120
+ return {
121
+ svg: parts.join(''),
122
+ project,
123
+ viewBox: [0, 0, width, height],
124
+ };
125
+ }
@@ -0,0 +1,3 @@
1
+ import type { TransitAnimationElement } from './types.js';
2
+ /** A deliberately schematic, offline station-to-station animation. Starts paused. */
3
+ export declare function createTransitAnimation(): TransitAnimationElement;