@kahwee/sf-map-svg 1.1.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.
- package/CHANGELOG.md +16 -0
- package/README.md +31 -2
- package/SOURCES.md +13 -0
- package/dist/data/README.md +4 -0
- package/dist/src/custom-map.d.ts +3 -0
- package/dist/src/custom-map.js +1 -0
- package/dist/src/explorer.d.ts +1 -1
- package/dist/src/explorer.js +89 -18
- package/dist/src/index.d.ts +5 -6
- package/dist/src/index.js +12 -116
- package/dist/src/interactive.d.ts +1 -1
- package/dist/src/map-core.d.ts +54 -0
- package/dist/src/map-core.js +125 -0
- package/dist/src/types.d.ts +20 -0
- package/package.json +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
- Make the measures atlas a full-screen map with floating controls, expandable mobile results, touch pan/zoom, overlay tables and sources, and a distraction-free focus view.
|
|
8
|
+
|
|
9
|
+
- Redesign the measures explorer around visible district maps, side-by-side comparisons, Yes/No and district-color modes, keyboard zoom/pan, district fitting, stable labels, and SVG/CSV exports.
|
|
10
|
+
|
|
11
|
+
- Add a Pages ballot-measures explorer for certified June 2026 local results, with district comparisons, shareable selections, and official data downloads.
|
|
12
|
+
|
|
13
|
+
- Build public Pages demos and SVG downloads from the published npm version, with visible release metadata, installation copying, release links, and social previews.
|
|
14
|
+
- Refresh Pages after successful publishing and improve mobile layout and release documentation.
|
|
15
|
+
|
|
16
|
+
## 1.2.0
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Add `@kahwee/sf-map-svg/custom-map`, a data-injected renderer entry point that lets consumers bundle only the geographic datasets they provide.
|
|
21
|
+
- Add public styled geographic overlays, explorer theme tokens, configurable labels, and optional controls.
|
|
22
|
+
|
|
7
23
|
## 1.1.0
|
|
8
24
|
|
|
9
25
|
### Added
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# San Francisco SVG maps
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@kahwee/sf-map-svg)
|
|
4
|
+
|
|
3
5
|
[Explore the live map](https://kahwee.github.io/sf-map-svg/) · [Data guide](data/README.md) · [Geographic sources](SOURCES.md) · [Contributing](CONTRIBUTING.md)
|
|
4
6
|
|
|
5
7
|
Self-contained SVG maps of San Francisco, with precise coastlines, soft district colors, parks, roads, BART stations, and searchable neighborhoods. Render static SVGs in Node or add an interactive map to a browser. All geometry is bundled; there are no runtime dependencies, map tiles, API keys, or external data requests.
|
|
@@ -17,8 +19,10 @@ Use Node 22.12+ for server-side rendering. Browser components need a DOM and a b
|
|
|
17
19
|
| Start with | Entry point | What you get |
|
|
18
20
|
| --- | --- | --- |
|
|
19
21
|
| Static SVG | `@kahwee/sf-map-svg` | SVG markup, projection helpers, optional layers |
|
|
22
|
+
| Data-injected SVG | `@kahwee/sf-map-svg/custom-map` | Tree-shakeable renderer core with only the geographic data you provide |
|
|
20
23
|
| Neighborhood explorer | `@kahwee/sf-map-svg/explorer` | Search, source selection, map controls, GeoJSON downloads |
|
|
21
24
|
| Interactive map | `@kahwee/sf-map-svg/interactive` | Embeddable map and controls without the explorer sidebar |
|
|
25
|
+
| Animated transit demo | `@kahwee/sf-map-svg/transit` | Optional, schematic BART journey with playback controls |
|
|
22
26
|
| Geographic data | `@kahwee/sf-map-svg/data` | Source-aware lookup and canonical GeoJSON |
|
|
23
27
|
|
|
24
28
|
## Render a static map
|
|
@@ -38,6 +42,15 @@ const svg = renderSFMap({
|
|
|
38
42
|
await writeFile('san-francisco.svg', svg);
|
|
39
43
|
```
|
|
40
44
|
|
|
45
|
+
For smaller browser bundles, import `createSFMapWithData` from
|
|
46
|
+
`@kahwee/sf-map-svg/custom-map` and pass only the geographic assets your map uses.
|
|
47
|
+
That entry point does not import the package's built-in JSON collections. Its `SFMapData`
|
|
48
|
+
requires the coast and accepts selected district vintages plus optional neighborhood,
|
|
49
|
+
road, park, and station arrays. Use the canonical JSON subpaths documented in
|
|
50
|
+
[`data/README.md`](data/README.md) as source; map each feature collection to the
|
|
51
|
+
corresponding `SFMapData` records. The root entry remains convenient and includes the
|
|
52
|
+
built-in datasets for backward compatibility.
|
|
53
|
+
|
|
41
54
|
Embed the returned SVG markup directly in a page; in Astro, use `<div set:html={svg} />`. User-supplied text and attributes are XML escaped. Use a distinct `idPrefix` for each map when combining independently rendered SVGs.
|
|
42
55
|
|
|
43
56
|
## Static options
|
|
@@ -156,6 +169,10 @@ aliases. Import types including `InteractiveSFMapOptions`, `InteractiveSFMapElem
|
|
|
156
169
|
| `markerRadius` / `markerHitSize` | `6` / `44` | Screen-pixel visible radius and tap-target diameter, independent of zoom; selected radius grows by 2px |
|
|
157
170
|
| `markerColor` / `selectedMarkerColor` | `#245b61` / `#f04f32` | Default marker colors; individual `marker.color` overrides the unselected color |
|
|
158
171
|
| `onMarkerActivate` | Unset | Called when a non-null marker selection changes, including programmatic changes |
|
|
172
|
+
| `overlays` | `[]` | GeoJSON line or polygon overlays with stable IDs and optional SVG styles |
|
|
173
|
+
| `style` | Built-in tokens | Explorer CSS tokens: `ink`, `surface`, `accent`, `border`, `focus`, `controlGap`, `font` |
|
|
174
|
+
| `strings` | English defaults | Replace visible map labels and gesture help for localization |
|
|
175
|
+
| `controls` | All enabled | Independently hide `zoom`, `pan`, `reset`, `labels`, `touch`, or `legend`; source attribution remains visible |
|
|
159
176
|
|
|
160
177
|
Explicit layer options override mode defaults even after `setMode()`. District fills, outlines,
|
|
161
178
|
and badges can therefore be composed with neighborhood names without requiring district
|
|
@@ -201,6 +218,11 @@ unknown identities. `getSelection()` and `getSelectedMarker()` read current sele
|
|
|
201
218
|
when needed. `setMode(mode)` resets the viewport. Repeating the same viewport or selection
|
|
202
219
|
emits no change event, avoiding state feedback loops. All events bubble. Call `destroy()`
|
|
203
220
|
before removing the element to release listeners, observers, frames, and download URLs.
|
|
221
|
+
`setOverlays(overlays)` replaces all consumer overlays; each overlay has a unique `id`,
|
|
222
|
+
WGS84 `LineString`, `MultiLineString`, `Polygon`, or `MultiPolygon` geometry, optional
|
|
223
|
+
`stroke`, `strokeWidth`, `fill`, `fillOpacity`, `visible`, and accessible `label`. Overlays
|
|
224
|
+
track every pan, zoom, resize, and source change and render above geography but below markers.
|
|
225
|
+
They are decorative and do not participate in label collision layout.
|
|
204
226
|
|
|
205
227
|
### Dense markers
|
|
206
228
|
|
|
@@ -301,12 +323,15 @@ synthetic pointer tests do not establish physical-device compatibility.
|
|
|
301
323
|
The [live explorer](https://kahwee.github.io/sf-map-svg/) provides neighborhood search, 2022 district views, SVG examples, and GeoJSON downloads. Its source is in `website/` and uses the public explorer API.
|
|
302
324
|
|
|
303
325
|
```sh
|
|
304
|
-
pnpm build:pages
|
|
326
|
+
pnpm build:pages # local preview
|
|
327
|
+
pnpm build:pages --released # use the current npm release
|
|
305
328
|
python3 -m http.server 8765 --directory pages-dist
|
|
306
329
|
# Open http://localhost:8765
|
|
307
330
|
```
|
|
308
331
|
|
|
309
|
-
The build bundles
|
|
332
|
+
The build bundles assets into `pages-dist/` with relative URLs for GitHub’s project path. Local builds use the working package; deployed builds use npm’s latest stable package for both browser components and SVG downloads. The page shows that version and links to its release notes. `pages-dist/release.json` records the build’s version and source.
|
|
333
|
+
|
|
334
|
+
`.github/workflows/pages.yml` validates and deploys on pushes to `main` and after successful npm publishing. Release-triggered builds wait for registry processing before using the newly published version. Pages deployment does not publish npm packages or releases.
|
|
310
335
|
|
|
311
336
|
## License and attribution
|
|
312
337
|
|
|
@@ -324,3 +349,7 @@ document.querySelector('#transit').append(animation);
|
|
|
324
349
|
This optional browser component starts paused, with Play/Pause and a keyboard-accessible journey slider. A loop lasts 28 seconds; timing is illustrative. It connects the bundled official BART station centroids with straight segments, not actual tracks or live service. It pauses when the page is hidden. No autoplay means reduced-motion users can inspect the static map or scrub manually. Existing map defaults are unchanged.
|
|
325
350
|
|
|
326
351
|
Embed the Pages demo with `<iframe src="https://kahwee.github.io/sf-map-svg/transit.html" title="Schematic BART journey" loading="lazy" style="width:100%;height:clamp(650px, calc(100vw + 240px), 930px);border:0"></iframe>`.
|
|
352
|
+
|
|
353
|
+
## Ballot measures explorer
|
|
354
|
+
|
|
355
|
+
[Explore June 2026 ballot measures](https://kahwee.github.io/sf-map-svg/measures.html): certified local Measures A–D, citywide outcomes, full-screen district maps with floating controls, expandable mobile results, touch pan/zoom, a focus view, side-by-side measure comparisons, Yes/No or original district colors, zoom and district fitting, a sortable comparison table, shareable views, and SVG/CSV/JSON downloads. The page uses the released map renderer and separately bundled official election results. It is an archive, not a live results service or voting guide. Methodology and source links are available on the page and in [SOURCES.md](SOURCES.md).
|
package/SOURCES.md
CHANGED
|
@@ -79,3 +79,16 @@ The cleanup retains all 92 identities, names, and source codes. It uses no round
|
|
|
79
79
|
## Key road landmarks
|
|
80
80
|
|
|
81
81
|
Downloaded September 26, 2026 (UTC; September 25 in San Francisco) from [DataSF Streets – Active and Retired](https://data.sf.gov/resource/3psu-pn9h.geojson), filtering `active = true` and exact source street names. `data/key-roads.json` groups 724 source segments into nine named corridors, preserving every source coordinate and CNN segment ID. Geary St and Geary Blvd are grouped under the Geary Blvd display label. These are geographic orientation features, not a complete network or vehicle-access guidance. Label anchors select existing source vertices near editorial targets. Regenerate with `node scripts/import-key-roads.mjs` then `pnpm data:catalog`. The full query and retrieval date are embedded in the JSON. DataSF terms apply.
|
|
82
|
+
|
|
83
|
+
## June 2026 ballot measures explorer
|
|
84
|
+
|
|
85
|
+
Downloaded September 26, 2026 from the San Francisco Department of Elections:
|
|
86
|
+
|
|
87
|
+
- Final district workbook: https://sfelections.org/results/20260602/data/20260625/dsov.xlsx
|
|
88
|
+
- Citywide summary, official measure titles, ballot questions, and thresholds: https://sfelections.org/results/20260602/index.html
|
|
89
|
+
- Certification dated June 25, 2026: https://sfelections.org/results/20260602/data/20260625/CertificationLetterJun22026.pdf
|
|
90
|
+
- Final report index: https://sfelections.org/results/20260602w/detail.html
|
|
91
|
+
|
|
92
|
+
`data/elections/2026-06-02.json` contains Measures A–D, their citywide counts, and the 11 `SUP DIST n - Total` rows from workbook sheets 20–23. Each row retains its worksheet row number; metadata retains the workbook SHA-256. `scripts/import-election-results.py` extracts these with openpyxl (ingestion only), checks all district sums including under/overvotes, and cross-checks Yes/No citywide counts against the official HTML summary. Rerun with the downloaded workbook and summary paths. No original Site ballot overlays are reused.
|
|
93
|
+
|
|
94
|
+
The Pages-only explorer calculates Yes / (Yes + No), excluding under/overvotes. Measure A uses the two-thirds threshold; B–D use a strict majority, as stated in the official summary. These are citywide outcomes, not district-level passage decisions. District counts are reported directly by Elections, not spatially assigned to neighborhoods. The existing 2022 district display map is reused unchanged. Election JSON is a separate website dataset, not a new npm package API; no live service or forthcoming-election coverage is claimed.
|
package/dist/data/README.md
CHANGED
|
@@ -99,3 +99,7 @@ Edit the canonical JSON only. Keep coordinate precision and source labels; recor
|
|
|
99
99
|
The 92 realtor neighborhoods have disjoint interiors. Shared borders and corner points are allowed. The source contained tiny overlapping boundary slivers; the normalized JSON assigns each such area once using stable-ID order, preserving the combined footprint without rounding or buffering. `topology` records this processing. This guarantee applies within the realtor collection; alternative neighborhood sources and district/park layers describe different concepts and must not be treated as additional mutually exclusive neighborhoods.
|
|
100
100
|
|
|
101
101
|
Run `pnpm data:normalize-realtor` when updating realtor geometry, then `pnpm data:catalog` and `pnpm check`. Cleanup aborts if it would erase a neighborhood, leave overlapping interiors, or change the combined footprint. Tests reject any nonempty polygon intersection; they do not excuse small slivers with an area threshold.
|
|
102
|
+
|
|
103
|
+
## Election result snapshots (Pages only)
|
|
104
|
+
|
|
105
|
+
`elections/2026-06-02.json` holds certified local-measure results for the June 2026 Pages explorer. It contains four measures, citywide counts, and eleven supervisorial district totals per measure, plus source URLs, workbook checksum, and source row references. It is not a GeoJSON collection or a public npm export. It does not change the default SFAR neighborhood dataset. See `SOURCES.md` and `scripts/import-election-results.py` for extraction and validation.
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export type { BartStationData, DistrictRowData, KeyRoadData, LandmarkData, SFMapData, } from './map-core.js';
|
|
2
|
+
export { createSFMapWithData, districtColors, districtYears, } from './map-core.js';
|
|
3
|
+
export type { DistrictYear, MapMarker, MapOverlay, SFMapOptions } from './types.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createSFMapWithData, districtColors, districtYears, } from './map-core.js';
|
package/dist/src/explorer.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { NeighborhoodExplorerElement, NeighborhoodExplorerOptions } from './types.js';
|
|
2
2
|
export type { ExplorerMode, NeighborhoodExplorerElement, NeighborhoodExplorerOptions, } from './types.js';
|
|
3
3
|
/** Create an offline, browser-only neighborhood explorer. Call destroy() before disposal. */
|
|
4
|
-
export declare function createNeighborhoodExplorer({ source, mode, labels, neighborhood, year, theme, interface: chrome, layers, selectableNeighborhoods, labelSize, fitPadding, markers: initialMarkers, markerRadius, markerHitSize, markerColor, selectedMarkerColor, onMarkerActivate, }?: NeighborhoodExplorerOptions): NeighborhoodExplorerElement;
|
|
4
|
+
export declare function createNeighborhoodExplorer({ source, mode, labels, neighborhood, year, theme, interface: chrome, layers, selectableNeighborhoods, labelSize, fitPadding, markers: initialMarkers, markerRadius, markerHitSize, markerColor, selectedMarkerColor, onMarkerActivate, overlays: initialOverlays, onOverlayActivate, style: styleOptions, strings, controls, }?: NeighborhoodExplorerOptions): NeighborhoodExplorerElement;
|
package/dist/src/explorer.js
CHANGED
|
@@ -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
|
|
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
|
|
76
|
-
.sf-explorer-header{padding:24px;border-top:4px solid
|
|
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:
|
|
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',
|
|
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
|
|
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' :
|
|
867
|
-
touchButton.setAttribute('aria-label', enabled ?
|
|
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,
|
|
@@ -952,6 +1022,7 @@ export function createNeighborhoodExplorer({ source = 'realtor', mode = 'neighbo
|
|
|
952
1022
|
setLabels(labels);
|
|
953
1023
|
setTouchNavigation(false);
|
|
954
1024
|
setMarkers(initialMarkers);
|
|
1025
|
+
setOverlays(initialOverlays);
|
|
955
1026
|
}
|
|
956
1027
|
catch (error) {
|
|
957
1028
|
explorer.destroy();
|
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;
|
|
@@ -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
|
+
}
|
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;
|
|
@@ -25,6 +37,7 @@ export interface SFMapOptions {
|
|
|
25
37
|
landmarks?: boolean;
|
|
26
38
|
bartStations?: boolean;
|
|
27
39
|
markers?: MapMarker[];
|
|
40
|
+
overlays?: readonly MapOverlay[];
|
|
28
41
|
title?: string;
|
|
29
42
|
idPrefix?: string;
|
|
30
43
|
colors?: Partial<Record<'water' | 'land' | 'district' | 'neighborhood' | 'highway' | 'road' | 'park' | 'landmark' | 'bart' | 'label' | 'marker' | 'selected', string>>;
|
|
@@ -81,6 +94,12 @@ export interface NeighborhoodExplorerOptions {
|
|
|
81
94
|
markerColor?: string;
|
|
82
95
|
selectedMarkerColor?: string;
|
|
83
96
|
onMarkerActivate?: (marker: MapMarker) => void;
|
|
97
|
+
overlays?: readonly MapOverlay[];
|
|
98
|
+
onOverlayActivate?: (overlay: MapOverlay) => void;
|
|
99
|
+
/** Stable theme tokens consumed by the explorer chrome. */
|
|
100
|
+
style?: Partial<Record<'ink' | 'surface' | 'accent' | 'border' | 'focus' | 'controlGap' | 'font', string>>;
|
|
101
|
+
strings?: Partial<Record<'title' | 'mode' | 'source' | 'search' | 'chooseNeighborhood' | 'chooseMarker' | 'touchNavigation' | 'reset' | 'emptyResults' | 'gestureHelp', string>>;
|
|
102
|
+
controls?: Partial<Record<'zoom' | 'pan' | 'reset' | 'labels' | 'touch' | 'legend', boolean>>;
|
|
84
103
|
}
|
|
85
104
|
export interface NeighborhoodExplorerElement extends HTMLElement {
|
|
86
105
|
selectNeighborhood(name: string | null, options?: {
|
|
@@ -100,6 +119,7 @@ export interface NeighborhoodExplorerElement extends HTMLElement {
|
|
|
100
119
|
/** Explicitly engage map touch gestures; false restores page gestures. */
|
|
101
120
|
setTouchNavigation(enabled: boolean): void;
|
|
102
121
|
setMarkers(markers: readonly MapMarker[]): void;
|
|
122
|
+
setOverlays(overlays: readonly MapOverlay[]): void;
|
|
103
123
|
selectMarker(id: string | null, options?: {
|
|
104
124
|
fit?: boolean;
|
|
105
125
|
}): boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahwee/sf-map-svg",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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"
|
|
@@ -87,6 +91,6 @@
|
|
|
87
91
|
"test:package": "node scripts/smoke-package.mjs",
|
|
88
92
|
"build": "node scripts/build.mjs",
|
|
89
93
|
"lint": "biome check .",
|
|
90
|
-
"build:pages": "pnpm
|
|
94
|
+
"build:pages": "pnpm build && node scripts/build-pages.mjs"
|
|
91
95
|
}
|
|
92
96
|
}
|