@kahwee/sf-map-svg 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,21 +1,8 @@
1
- # San Francisco SVG maps
1
+ # SF Map SVG
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@kahwee/sf-map-svg)](https://www.npmjs.com/package/@kahwee/sf-map-svg)
3
+ Offline, self-contained San Francisco SVG maps. The package has no runtime dependencies. Geography is always an explicit import; the root entry does not bundle data.
4
4
 
5
- [Live atlas](https://kahwee.github.io/sf-map-svg/) · [Example gallery](https://kahwee.github.io/sf-map-svg/examples.html) · [Code recipes](docs/EXAMPLES.md) · [Data guide](data/README.md) · [Sources](SOURCES.md) · [Contributing](CONTRIBUTING.md)
6
-
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.
8
-
9
- ![San Francisco district maps with optional neighborhood boundaries](docs/map-preview.png)
10
-
11
- ## Choose a starting point
12
-
13
- | Goal | Example | API |
14
- | --- | --- | --- |
15
- | Explore real election data | [California propositions by SF district](https://kahwee.github.io/sf-map-svg/propositions.html) or [local measures](https://kahwee.github.io/sf-map-svg/measures.html) | `custom-map` |
16
- | Make a small interactive city map | [Neighborhood guide](https://kahwee.github.io/sf-map-svg/#explore-more-title) | `/guide` |
17
- | Render a static or custom SVG | [Code recipes](docs/EXAMPLES.md) | `/static` or `/custom-map` |
18
- | Animate a route | [BART journey](https://kahwee.github.io/sf-map-svg/transit.html) | `/transit` or overlays |
5
+ [Live examples](https://kahwee.github.io/sf-map-svg/examples.html) · [Storybook source](stories/) · [Geographic sources](SOURCES.md) · [v3 migration](docs/migration-v3.md)
19
6
 
20
7
  ## Install
21
8
 
@@ -23,583 +10,65 @@ Self-contained SVG maps of San Francisco, with precise coastlines, soft district
23
10
  pnpm add @kahwee/sf-map-svg
24
11
  ```
25
12
 
26
- Use Node 22.12+ for server-side rendering. Browser components need a DOM and a bundler that supports the package’s JSON imports.
13
+ Node 22.12+ is required for server rendering. Browser maps need a DOM and a bundler that supports JSON imports.
27
14
 
28
- | Start with | Entry point | What you get |
29
- | --- | --- | --- |
30
- | v2 controller | `@kahwee/sf-map-svg` or `/map` | Explicit data, grouped options, managed events and camera |
31
- | v2 static SVG | `@kahwee/sf-map-svg/static` | Server-safe rendering with explicit data |
32
- | Compatibility static SVG | `@kahwee/sf-map-svg/legacy` | Original renderer with bundled geography |
33
- | Data-injected SVG | `@kahwee/sf-map-svg/custom-map` | Tree-shakeable renderer core with only the geographic data you provide |
34
- | Neighborhood explorer | `@kahwee/sf-map-svg/explorer` | Search, source selection, map controls, GeoJSON downloads |
35
- | Interactive map | `@kahwee/sf-map-svg/interactive` | Embeddable map and controls without the explorer sidebar |
36
- | Data-injected interactive map | `@kahwee/sf-map-svg/interactive-data` | Interactive shell without bundled geographic JSON |
37
- | Lightweight guide map | `@kahwee/sf-map-svg/guide` | Curated overview geography, with detailed data loaded explicitly |
38
- | Animated transit demo | `@kahwee/sf-map-svg/transit` | Optional, schematic BART journey with playback controls |
39
- | Geographic data | `@kahwee/sf-map-svg/data` | Source-aware lookup and canonical GeoJSON |
40
- | Metadata search | `@kahwee/sf-map-svg/data/catalog` | Search names without polygon geometry |
41
- | SFAR lookup | `@kahwee/sf-map-svg/data/realtor` | Default neighborhoods without alternative sources |
15
+ ## Static SVG
42
16
 
43
- ## Version 2: explicit data and a controller
44
-
45
- ```js
46
- import { createMap } from '@kahwee/sf-map-svg';
47
- import { guideMapData } from '@kahwee/sf-map-svg/guide/data';
48
- import { guideOptions } from '@kahwee/sf-map-svg/presets';
49
-
50
- const map = createMap(guideMapData, {
51
- ...guideOptions,
52
- features: { motion: true, markerEntrance: true, clustering: true },
53
- appearance: { colors: { water: '#e6f1f5' } },
54
- });
55
- document.querySelector('#map').append(map.element);
56
- map.configure({ features: { motion: false }, controls: { pan: false } });
57
- const unsubscribe = map.on('markerchange', ({ marker }) => console.log(marker?.id));
58
- map.camera.reset({ animate: false });
59
- // On component disposal: map.destroy();
60
- ```
61
-
62
- The root and `/map` include no geography; `/static` includes no interactive runtime.
63
- `guide/data` supplies only overview geography; `/presets` supplies configuration only.
64
- Camera methods share `{ animate, duration }`. `configure()` accepts the same feature,
65
- layer and control groups as construction and validates the entire patch before applying
66
- it. Appearance is construction-only. The controller's `on()` returns an unsubscribe
67
- function and disposal removes all its subscriptions. Operations after disposal throw;
68
- `destroy()` itself is idempotent.
69
-
70
- `renderMap(data, options)` from `/static` returns `{ svg, project, ... }` and needs only
71
- `StaticMapData` (for example, `guideMapData.map`). Static options retain their SVG-unit
72
- semantics; camera/motion/control options belong exclusively to the browser controller.
73
- See [v2 migration and architecture](docs/migration-v2.md) for breaking changes,
74
- configuration resets, and bundle boundaries. Existing subpaths remain compatibility APIs.
75
-
76
- ## Render a static map
77
-
78
- ```js
79
- import { writeFile } from 'node:fs/promises';
80
- import { renderSFMap } from '@kahwee/sf-map-svg/legacy';
17
+ ```ts
18
+ import { renderMap } from '@kahwee/sf-map-svg';
19
+ import { fullMapData } from '@kahwee/sf-map-svg/data/full';
81
20
 
82
- const svg = renderSFMap({
21
+ const { svg, project, viewBox } = renderMap(fullMapData.map, {
22
+ year: 2022,
83
23
  landmarks: true,
84
24
  bartStations: true,
85
- highways: true,
86
- neighborhoodLines: true,
87
- markers: [{ id: 'dolores', lng: -122.4269, lat: 37.7596, label: 'Dolores Park' }],
88
25
  });
89
-
90
- await writeFile('san-francisco.svg', svg);
91
26
  ```
92
27
 
93
- For smaller browser bundles, import `createSFMapWithData` from
94
- `@kahwee/sf-map-svg/custom-map` and pass only the geographic assets your map uses.
95
- That entry point does not import the package's built-in JSON collections. Its `SFMapData`
96
- requires the coast and accepts selected district vintages plus optional neighborhood,
97
- road, park, and station arrays. Use the canonical JSON subpaths documented in
98
- [`data/README.md`](data/README.md) as source; map each feature collection to the
99
- corresponding `SFMapData` records. The `/legacy` entry includes the built-in datasets for migration. The v2 root
100
- imports no geographic JSON.
28
+ `renderMap(data, options)` returns SVG markup and matching projection helpers. Import `/data/full` only when all packaged geography is needed. For static rendering without interactive lookup collections, import `staticMapData` from `/data/static`. Small maps can compose selected JSON exports from `/data/*` and pass them as `StaticMapData`. `getLayerPaths(data, options)` returns fitted geographic paths without SVG markup.
101
29
 
102
- In tree-shaking bundlers, importing a single symbol from `/data` retains only the
103
- modules that symbol uses.
104
- `searchNeighborhoods` needs catalog metadata but no polygon geometry. The synchronous
105
- source-switching `getNeighborhood` still needs all three neighborhood collections;
106
- use `/data/realtor` when only the default SFAR definitions are needed. Optional coast,
107
- district, road, park, and station collections also have independent `/data/*` entries.
108
- See [the measured bundle report](docs/module-bundle-report.md).
30
+ Static options include `theme`, `width`, `height`, `padding`, `year` (2002, 2012, 2022), `districtLines`, `districtFills`, `districtStyle`, `districtLabels`, `neighborhoodLines`, `labels`, `highways`, `keyRoads`, `roadLabels`, `landmarks`, `bartStations`, `markers`, `overlays`, `title`, `idPrefix`, and `colors`. Each optional layer is independent. User-supplied text and attributes are escaped in SVG output.
109
31
 
110
- The same split is available for browser controls with
111
- `@kahwee/sf-map-svg/interactive-data`. Pass an `InteractiveSFMapData` object containing
112
- `map` (the `SFMapData` used by the static renderer) and only the neighborhood collections
113
- you want available. This keeps alternative neighborhood sources, unused district vintages,
114
- and optional layers out of that entry's bundle. The normal `/interactive` entry retains its
115
- built-in datasets and synchronous source switching.
116
-
117
- For the curated guide map, import `@kahwee/sf-map-svg/guide`. Its overview preset contains
118
- only the simplified coastline, SFAR areas, major parks, BART points, US 101 / I-280 /
119
- Highway 1, and Market, Van Ness, Geary, Lombard, 19th Avenue, and the Embarcadero. It does
120
- not import historical districts, other neighborhood sources, or the full street network.
121
- Collision-filtered neighborhood labels are enabled by default. Road geometry and road labels
122
- have independent `layers.keyRoads` and `layers.roadLabels` controls. Lombard geometry and its
123
- label appear after 1.8× zoom; major street labels can appear at city scale in a smaller type size.
32
+ ## Interactive map
124
33
 
125
34
  ```ts
126
- import { createGuideMap } from '@kahwee/sf-map-svg/guide';
127
- import { loadGuideDetailedData } from '@kahwee/sf-map-svg/guide/detailed';
128
- import { createInteractiveSFMapWithData } from '@kahwee/sf-map-svg/interactive-data';
129
-
130
- const map = createGuideMap({ layers: { roadLabels: false } });
131
- document.querySelector('#map')!.append(map);
132
-
133
- // Load only when the user asks for closer geographic detail.
134
- const detailedData = await loadGuideDetailedData();
135
- const detailedMap = createInteractiveSFMapWithData(detailedData, {
136
- mode: 'neighborhoods',
137
- layers: { highways: true, keyRoads: true, roadLabels: true },
138
- });
139
- ```
140
-
141
- Run `pnpm data:guide` to rebuild the subpixel overview geometries from canonical files.
142
- `docs/guide-bundle-report.md` records compressed bundle sizes and verifies the included data.
143
- The detailed loader is also re-exported from `/guide` for compatibility; importing only
144
- that function no longer puts overview geography in the initial bundle.
145
-
146
- ```js
147
- import coast from '@kahwee/sf-map-svg/data/coast.json' with { type: 'json' };
148
- import realtor from '@kahwee/sf-map-svg/data/neighborhoods-realtor.json' with { type: 'json' };
149
- import { createInteractiveSFMapWithData } from '@kahwee/sf-map-svg/interactive-data';
150
-
151
- const map = createInteractiveSFMapWithData(
152
- {
153
- map: { coast: coast.features[0].geometry },
154
- neighborhoods: { realtor },
155
- },
156
- {
157
- layers: {
158
- districtFills: false,
159
- districtLines: false,
160
- districtLabels: false,
161
- landmarks: false,
162
- bartStations: false,
163
- highways: false,
164
- keyRoads: false,
165
- },
166
- },
167
- );
168
- document.querySelector('#map').append(map);
169
- ```
170
-
171
- 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.
172
-
173
- ## Static options
174
-
175
- | Option | Default | Purpose |
176
- | --- | --- | --- |
177
- | `year` | `2022` | District boundaries: `2002`, `2012`, or `2022` |
178
- | `districtLines` | `true` | Supervisorial district outlines |
179
- | `neighborhoodLines` | `false` | Dashed SFAR realtor neighborhood outlines |
180
- | `theme` | `'districts'` | Use `'transit'` for pale blue water, ivory land, green parks, and blue BART symbols; custom `colors` still take precedence |
181
- | `districtFills` | `true` | Original Site’s eleven muted district colors |
182
- | `labels` | `true` | Master switch for visible map text; symbols and accessible titles remain |
183
- | `districtLabels` | `true` | District number badges |
184
- | `highways` | `false` | Original Site’s highway geometry |
185
- | `landmarks` | `false` | Golden Gate Park, Presidio, Lincoln Park, Twin Peaks, Dolores Park, and McLaren Park |
186
- | `bartStations` | `false` | Eight San Francisco BART stations with blue rings and names |
187
- | `width`, `height` | `800`, `800` | SVG viewBox and intrinsic size |
188
- | `padding` | `28` | Space around the coast |
189
- | `markers` | `[]` | Points with `id`, `lng`, `lat`, optional `label`, `color`, `selected` |
190
- | `keyRoads` | `false` | Six selected street corridors: Market, Van Ness, Geary, Lombard, 19th Avenue, and the Embarcadero |
191
- | `roadLabels` | Same as `keyRoads` | Road labels independently of their geometry |
192
- | `colors` | Built-in palette | Override `water`, `land`, `district`, `neighborhood`, `highway`, `road`, `park`, `landmark`, `bart`, `label`, `marker`, `selected` |
193
- | `title` | `San Francisco map` | Accessible SVG title |
194
- | `idPrefix` | Unique per process | Set explicitly for deterministic output or independent server renders |
195
-
196
- For a plain outline map, set `districtFills: false`. Neighborhood areas are **August 2010 SFAR realtor areas**, not legal boundaries or a historical layer matched to the district year. See [SOURCES.md](SOURCES.md).
197
-
198
- `createSFMap(options)` returns `{ svg, project, viewBox }`. `project([longitude, latitude])` gives matching SVG coordinates for custom overlays. Named exports also include `districtYears`, `districtColors`, and `neighborhoodNames`.
199
-
200
- Use `theme: 'transit'` for pale water, ivory land, green parks, and blue BART symbols. Custom `colors` override the preset. Park and station overlays represent current source geography, independently of the district year; stations outside San Francisco, including Daly City, are excluded.
201
-
202
- `keyRoads: true` adds the six curated orientation streets using DataSF centerlines. These are orientation features, not routing guidance. The lightweight guide shows primary corridors at city scale and Lombard after zooming.
203
-
204
-
205
- ## Neighborhood explorer
206
-
207
- The browser explorer includes canonical-name and alias search, source selection, neighborhood outlines, zoom controls, and GeoJSON downloads. SFAR realtor definitions are selected by default; SF Find and analysis neighborhoods remain separate choices.
208
-
209
- ```js
210
- import { createNeighborhoodExplorer } from '@kahwee/sf-map-svg/explorer';
211
-
212
- const explorer = createNeighborhoodExplorer({ source: 'realtor' });
213
- document.querySelector('#map').append(explorer);
214
- explorer.selectNeighborhood('NoPa');
215
-
216
- // Before removing the component, release its observers and event listeners.
217
- // explorer.destroy();
218
- ```
219
-
220
- Call this browser-only factory after a DOM is available. Importing it does not mount anything. Options include `source`, an optional initial `neighborhood` name or alias, and district `year`. The returned element also exposes `setSource(source)`, `zoomBy(factor)`, and `resetView()`.
221
-
222
- Selected downloads are one-feature GeoJSON FeatureCollections retaining source attribution and boundary-processing metadata.
223
-
224
- At city scale, labels stay sparse. Zooming reveals neighborhood and BART names, with label sizing and collision checks based on the visible viewport. Station points remain visible. The static `renderSFMap` API keeps its existing labels and defaults.
225
-
226
- ### Map modes and labels
227
-
228
- The interactive explorer includes a map-mode selector and a Labels toggle. `mode: 'districts'` shows numbered supervisorial districts; `mode: 'neighborhoods'` shows names from the selected neighborhood source (SFAR realtor by default). Labels are collision-filtered and remain about 12 screen pixels through map zoom and resize; more names fit as you zoom in. Road labels use 11 pixels.
229
-
230
- ```js
231
- const explorer = createNeighborhoodExplorer({ mode: 'districts', labels: true });
232
- explorer.setLabels(false);
233
- explorer.setMode('neighborhoods');
234
- explorer.setLabels(true);
235
- ```
236
-
237
- `renderSFMap({ labels: false })` also hides all visible text while retaining station symbols and accessible titles. Standalone SVGs are static images; the interactive explorer provides the constant-size labels during map zoom.
238
-
239
- ## Reusable interactive map
240
-
241
- The `@kahwee/sf-map-svg/interactive` entry point provides a map, accessible controls, and
242
- attribution without the explorer's search sidebar or detail panel. It does not change URLs,
243
- load articles, apply editorial filters, or navigate. Importing the static entry point does not
244
- import this interactive runtime. Both paths retain zero runtime dependencies.
245
-
246
- ```js
247
- import { createInteractiveSFMap } from '@kahwee/sf-map-svg/interactive';
35
+ import { createMap } from '@kahwee/sf-map-svg';
36
+ import { fullMapData } from '@kahwee/sf-map-svg/data/full';
248
37
 
249
- const map = createInteractiveSFMap({
38
+ const map = createMap(fullMapData, {
250
39
  mode: 'neighborhoods',
251
- source: 'analysis', // 'sf-find' and 'realtor' are also available
252
- theme: 'transit',
253
- labelSize: { min: 12, max: 15 },
254
- layers: { districtFills: false, districtLines: false, districtLabels: false },
255
- });
256
- document.querySelector('#map').append(map);
257
- map.addEventListener('neighborhoodchange', ({ detail }) => {
258
- // On clear: id, name, and feature are null. source always identifies the dataset.
259
- console.log(detail.id, detail.name, detail.source);
260
- });
261
- map.selectNeighborhood('Mission', { fit: false });
262
- const selection = map.getSelection(); // { id, name, source, feature } or null
263
- map.selectNeighborhood(null);
264
- ```
265
-
266
- `createInteractiveSFMap()` defaults to `mode: 'basemap'`: no district or neighborhood layers.
267
- Parks, roads, and stations are initially enabled and can be disabled independently. The existing
268
- `createNeighborhoodExplorer()` and static APIs retain their realtor/district defaults. The
269
- source option only chooses a definition collection; it does not make that collection visible
270
- in basemap mode. Editorial groupings belong to the consumer and are never treated as geographic
271
- aliases. Import types including `InteractiveSFMapOptions`, `InteractiveSFMapElement`,
272
- `MapViewport`, `MapPadding`, and `NeighborhoodSelection` from the interactive entry point.
273
-
274
- | Option | Default | Behavior |
275
- | --- | --- | --- |
276
- | `mode` | `basemap` (interactive), `neighborhoods` (explorer) | `basemap`, `districts`, or `neighborhoods`; establishes layer defaults |
277
- | `source` | `realtor` | `realtor`, `sf-find`, or `analysis`; explicit source recommended for reusable integrations |
278
- | `theme` | `transit` | `transit` or `districts` |
279
- | `year` | `2022` | District vintage: 2002, 2012, or 2022 |
280
- | `layers` | Mode defaults | Independent booleans: `districtFills`, `districtLines`, `districtLabels`, `neighborhoodLines`, `neighborhoodLabels`, `landmarks`, `bartStations`, `highways`, `keyRoads` (geometry), `roadLabels` |
281
- | `labels` | `true` | Master visible-text switch; accessible descriptions and station symbols remain |
282
- | `labelSize` | `{ min: 11, max: 12 }` | Neighborhood and other labels use this screen-pixel range (8–32 allowed); road labels stay smaller at 9px, capped by `max`. Sizes never grow with zoom |
283
- | `selectableNeighborhoods` | `true` | Enables pointer/keyboard selection; false retains geography and labels |
284
- | `neighborhood` | Unset | Initial name, alias, or ID in the selected source |
285
- | `fitPadding` | `24` | Screen pixels, number or `{ top, right, bottom, left }`, used by selection and geometry fitting after mounting |
286
- | `markers` | `[]` | Supplied `MapMarker` records with unique, nonempty IDs; no content fetching |
287
- | `markerRadius` / `markerHitSize` | `6` / `44` | Screen-pixel visible radius and tap-target diameter, independent of zoom; selected radius grows by 2px |
288
- | `markerColor` / `selectedMarkerColor` | `#245b61` / `#f04f32` | Default marker colors; individual `marker.color` overrides the unselected color |
289
- | `onMarkerActivate` | Unset | Called when a non-null marker selection changes, including programmatic changes |
290
- | `overlays` | `[]` | GeoJSON line or polygon overlays with stable IDs and optional SVG styles |
291
- | `style` | Built-in tokens | Explorer CSS tokens: `ink`, `surface`, `accent`, `border`, `focus`, `controlGap`, `font` |
292
- | `strings` | English defaults | Replace visible map labels and gesture help for localization |
293
- | `controls` | All enabled | Independently hide `zoom`, `pan`, `reset`, `labels`, `touch`, `legend`, `neighborhoodPicker`, `markerPicker`, `help`, or `status`; source attribution remains visible |
294
-
295
- For a compact embed, hide the native choosers only when the page already lists every
296
- marker or area as an accessible control. Hidden help remains the map's accessible
297
- description, and a hidden status line remains a polite live region:
298
-
299
- ```js
300
- const map = createGuideMap({
301
- interface: 'map',
302
- controls: { labels: false, neighborhoodPicker: false, markerPicker: false, help: false, status: false },
303
- strings: { chooseMarker: 'Place on map' }, // also used after setMarkers() updates
304
- });
305
- ```
306
-
307
- Explicit layer options override mode defaults even after `setMode()`. District fills, outlines,
308
- and badges can therefore be composed with neighborhood names without requiring district
309
- labels. Descriptions identify only displayed boundary layers, their source and available
310
- vintage; analysis data is described as census-tract-based reporting areas, without inventing a
311
- boundary year. The downloadable source metadata remains available in the full explorer.
312
-
313
- Labels use measured screen-space collision boxes. Selected marker and neighborhood names
314
- come first, then district labels, stations, parks, roads, and neighborhoods in descending area
315
- order with stable ID tie-breaking. Station and marker symbols reserve space. Roads and station
316
- names appear at 1.8× zoom; city view keeps park labels sparse. Labels outside the viewport or
317
- without room are suppressed, including a selected label too wide to fit. Selection names remain
318
- available in the native chooser and through events. The label range is separate from SVG user
319
- units used by the static renderer.
320
-
321
- ### Viewport and controlled selection
322
-
323
- ```js
324
- const saved = map.getViewport(); // copied [x, y, size] in the 800×800 projected map
325
- map.zoomBy(2);
326
- map.panBy(30, 0); // move view east by 30 screen pixels
327
- map.setViewport(saved);
328
- map.resetView();
329
- map.fitGeometry({
330
- type: 'MultiPoint',
331
- coordinates: places.map(({ lng, lat }) => [lng, lat]),
332
- }, { top: 24, right: 24, bottom: 80, left: 24 });
333
- map.addEventListener('viewportchange', ({ detail }) => saveInYourState(detail.viewport));
334
- ```
335
-
336
- Call `fitGeometry` after mounting in a visible container. It accepts WGS84 GeoJSON geometry,
337
- including `Point`, `MultiPoint`, and `GeometryCollection`, and rejects empty geometry or
338
- impossible padding. Views are constrained to the city extent and 1–12× zoom. Consequently,
339
- padding is best-effort near city edges or for extents larger than the map; a single point fits
340
- at maximum zoom. This is an SF map, not a world map. Resizing keeps the projected view and
341
- recomputes screen sizes; call `fitGeometry` again if a new container size needs different fitting.
342
- Initial neighborhood fitting before mounting uses the explorer's proportional padding.
343
-
344
- `selectNeighborhood(nameOrIdOrNull, { fit: false })` and `selectMarker(idOrNull, { fit: false })`
345
- allow external state to drive selection without moving the viewport. They return false for
346
- unknown identities. `getSelection()` and `getSelectedMarker()` read current selection.
347
- `setSource(source)` clears neighborhood selection and resets the view, emitting a clear event
348
- when needed. `setMode(mode)` resets the viewport. Repeating the same viewport or selection
349
- emits no change event, avoiding state feedback loops. All events bubble. Call `destroy()`
350
- before removing the element to release listeners, observers, frames, and download URLs.
351
- `setOverlays(overlays)` replaces all consumer overlays; each overlay has a unique `id`,
352
- WGS84 `LineString`, `MultiLineString`, `Polygon`, or `MultiPolygon` geometry, optional
353
- `stroke`, `strokeWidth`, `fill`, `fillOpacity`, `visible`, and accessible `label`. Overlays
354
- track every pan, zoom, resize, and source change and render above geography but below markers.
355
- They are decorative and do not participate in label collision layout.
356
-
357
- ### Dense markers
358
-
359
- ```js
360
- const map = createInteractiveSFMap({ markers: places });
361
- document.querySelector('#map').append(map);
362
- map.addEventListener('markerchange', ({ detail }) => {
363
- // { id, marker }, both null when cleared. Render your own content panel here.
364
- renderSelection(detail.marker);
40
+ source: 'realtor',
41
+ neighborhood: 'Inner Mission',
42
+ layers: { bartStations: true },
43
+ features: { motion: true },
44
+ appearance: { theme: 'districts' },
365
45
  });
366
- map.setMarkers(updatedPlaces);
367
- map.selectMarker('place-id');
46
+ document.querySelector('#map')?.append(map.element);
47
+ map.on('neighborhoodchange', ({ name }) => console.log(name));
48
+ map.camera.zoom(1.5);
49
+ // When the view is removed:
50
+ map.destroy();
368
51
  ```
369
52
 
370
- Every supplied marker remains in the native chooser, even when positions coincide or markers
371
- are outside the current view. Pointer and keyboard activation select and fit a marker; markers
372
- also have individual keyboard stops. This is the accessible-choice alternative to clustering:
373
- no counts are estimated and no supplied markers are silently dropped. The chooser count is
374
- the supplied collection size. Replacing markers retains the selected ID when present, otherwise
375
- uses an explicitly selected marker or clears selection. Large hit targets can overlap; use the
376
- chooser to reach obscured markers. Automated clustering is not included in this release.
377
-
378
- ### Gesture policy and keyboard access
53
+ `createMap(data, options)` returns a controller. Use `map.element` for mounting, `map.configure({ features, layers, controls })` for runtime switches, `map.camera` for pan/zoom/fit/reset, and `map.on()` for typed events. Appearance is set at construction. SFAR realtor neighborhoods are the default when supplied; SF Find and analysis are explicit alternate sources. The `/data/full` preset includes all three collections and historical districts.
379
54
 
380
- - **Default touch:** one finger scrolls the page; pinch zooms the browser. Map buttons and
381
- native choosers work without engaging map gestures.
382
- - **Touch navigation:** explicitly enable the visible button (or `setTouchNavigation(true)`).
383
- One finger pans the map; two fingers pan and pinch around their midpoint. The button becomes
384
- **Done: page scrolling**. Use it or Escape to return to page gestures. The page remains
385
- scrollable outside the canvas, and Tab can leave it. Changing mode during an active gesture
386
- takes effect after fingers are lifted.
387
- - **Mouse:** drag pans; Ctrl/⌘ + wheel zooms. Ordinary wheel scrolling remains page scrolling.
388
- - **Keyboard:** focus the map, then arrows pan, +/− zoom, Home resets, and Escape exits touch
389
- navigation. Tab reaches controls, the neighborhood chooser, one neighborhood path, and markers.
390
- On a neighborhood path, `[` / `]` moves through source features and Enter/Space selects.
391
- The native chooser is also available for areas outside the current view.
392
- - Pointer cancellation, loss of capture, window blur, and resizing cancel active gestures.
393
- There is no animated camera or inertia. Button transitions are disabled with reduced motion.
55
+ Construction options include `mode` (`basemap`, `neighborhoods`, or `districts`), `source`, `neighborhood`, `year`, `labels`, `layers`, `controls`, `features`, `appearance`, `markers`, `overlays`, `legend`, `strings`, `attribution`, `fitPadding`, and touch navigation settings. `features` holds motion, marker entrances, selected marker rings, clustering, north arrow, and scale bar. `appearance` holds theme, color tokens, label and area styles, marker colors and sizes, and district styling. `layers` controls district fill/line/labels, neighborhood lines/labels, landmarks, BART, highways, key roads, and road labels. See the exported `MapOptions` type for exact values and the [Storybook examples](stories/) for live controls.
394
56
 
395
- ## Geographic data and lookup
57
+ The controller also supports marker, neighborhood, and district selection; district year and style changes; source and mode changes; labels and touch navigation; screen projection; and typed `markerchange`, `neighborhoodchange`, `districtchange`, `districthover`, `districtactivate`, `districtyearchange`, `overlayactivate`, `clusteractivate`, `viewportchange`, and `mapresize` events. `destroy()` releases browser resources; operations after destruction throw.
396
58
 
397
- All map geometry is available through stable JSON package exports. There are three district files (2002, 2012, 2022), the full 117 SF Find neighborhoods, 41 analysis neighborhoods, 92 realtor-defined areas, and separate coastline, highway, landmark, and BART files. Neighborhood records include a canonical display name, exact source name, stable ID, aliases where documented, source definition, and full polygon geometry.
59
+ For a small guide, import `guideMapData` from `/guide/data` and pass it to `createMap`. The optional `/guide` entry also provides `createGuideMap`, `mountGuideMap`, and detailed-data loading for existing guide layouts. `/transit` provides the standalone schematic transit animation. See [examples](docs/EXAMPLES.md) and [consumer integration](docs/consumer-integration.md).
398
60
 
399
- ```js
400
- import { getRealtorNeighborhood } from '@kahwee/sf-map-svg/data/realtor';
401
- import { searchNeighborhoods } from '@kahwee/sf-map-svg/data/catalog';
402
-
403
- const mission = getRealtorNeighborhood('Inner Mission');
404
- const outerMission = getRealtorNeighborhood('Outer Mission');
405
- const nopa = getRealtorNeighborhood('NoPa');
406
- const matchingDefinitions = searchNeighborhoods('mission');
407
- ```
61
+ ## Data and development
408
62
 
409
- These are 250 **source-specific definitions**, not 250 distinct neighborhoods. Canonical names are package display names, and boundaries reflect each documented source rather than a claimed universal consensus. Mission and Outer Mission remain distinct. JSON files are the source of truth used by the renderer; the default map and lookup use the 92 SFAR realtor neighborhoods. See [the data API guide](data/README.md) for all filenames, schema, lookup rules, source comparisons, and custom SVG overlays. Storybook provides downloadable JSON files beside its neighborhood examples.
410
-
411
- ## Development
412
-
413
- Requires Node 22.12+ and pnpm 12. The library uses strict TypeScript; the build emits JavaScript and declarations to `dist/`.
63
+ Canonical geography is in [`data/`](data/README.md), with provenance in [`SOURCES.md`](SOURCES.md). The public `/data` entry exposes lookup, catalog, district maps, and source-specific neighborhood collections. These are deeply frozen; clone before editing.
414
64
 
415
65
  ```sh
416
66
  pnpm install --frozen-lockfile
417
- pnpm check # formatting, data catalog, types, and tests
418
- pnpm demo # generated SVGs and example pages
419
- pnpm build-storybook # static component documentation
420
- pnpm test:stories # Storybook 10 browser checks in Chromium
421
- pnpm test:stories:coverage # Chromium checks plus renderer coverage report
422
- pnpm test:package # install and check the packed package
423
- ```
424
-
425
- Use `pnpm format` to apply Biome formatting and safe lint fixes. Install Chromium once with `pnpm exec playwright install chromium` before local Storybook tests. Run `pnpm storybook` for interactive component examples at http://127.0.0.1:6006. The coverage command writes `coverage/storybook/coverage-summary.json` and `lcov.info` for `src/` TypeScript only. CI runs package and Chromium Storybook checks on Node 26 and uploads the coverage report. See [CONTRIBUTING.md](CONTRIBUTING.md) for source structure and release instructions.
426
-
427
- ### Browser verification
428
-
429
-
430
- Run `pnpm demo` and serve the repository root. `examples/generated/index.html` covers static
431
- maps, `explorer.html` covers the full explorer, and `interactive.html` covers independently
432
- controlled neighborhood selection, 36 overlapping sample markers, and fit/save/restore hooks.
433
- Storybook **Maps / Reusable interactive map** includes both themes, selectable neighborhoods,
434
- independent layers, dense markers, and a 390px example.
435
-
436
- With that server running, the browser regression checks can be run through the installed CLI:
437
-
438
- ```sh
439
- agent-browser skills get core --full
440
- agent-browser --session sf-map-check open http://127.0.0.1:8765/examples/generated/interactive.html
441
- agent-browser --session sf-map-check eval "import('/scripts/check-interactive-browser.mjs').then(m => m.checkInteractiveBrowser())"
442
- agent-browser --session sf-map-check close
443
- ```
444
-
445
- These checks cover actual browser layout, both themes, narrow/wide containers, label collisions,
446
- zoom extremes, keyboard selection, cancellation logic, viewport state, marker reachability, and
447
- teardown. Physical iOS Safari and Android Chrome verification remains required before a release:
448
- check page scroll and browser pinch in default mode; map pan and pinch in engaged mode; lift one
449
- finger; interrupt/cancel; rotate; use Done; verify scrolling resumes. Desktop automation and
450
- synthetic pointer tests do not establish physical-device compatibility.
451
-
452
- ## GitHub Pages
453
-
454
- The [civic atlas](https://kahwee.github.io/sf-map-svg/) leads with certified June 2026 ballot measure results. Visitors can select a measure and district, switch Yes/No shading, compare the official 2002, 2012, and 2022 district maps, and play a schematic BART journey. The boundary animation morphs matched district outlines between dated SVGs. Intermediate shapes illustrate the change; each completed year uses its exact published geometry. Playback is user initiated, pauses when the page is hidden, and switches instantly when reduced motion is requested. The lightweight neighborhood guide loads on demand. The full [ballot measures explorer](https://kahwee.github.io/sf-map-svg/measures.html) spans all three district map years with four separately sourced election snapshots.
455
-
456
- The [candidate vote explorer](https://kahwee.github.io/sf-map-svg/candidates.html) maps
457
- 56 certified federal, statewide, and state legislative contests across six
458
- San Francisco elections from 2016 to 2026. It loads one election JSON on
459
- demand, marks districts only partly eligible for House or legislative races,
460
- and keeps the 2012 and 2022 supervisorial map vintages distinct. Applications
461
- can import a snapshot explicitly from npm without adding it to the default
462
- renderer:
463
-
464
- ```js
465
- import election from '@kahwee/sf-map-svg/data/candidates/2024-11-05.json' with { type: 'json' };
466
- ```
467
-
468
- See the [candidate schema](data/candidates/README.md), [source records](SOURCES.md),
469
- and [next dataset ideas](docs/data-opportunities.md).
470
-
471
- ```sh
472
- pnpm build:pages # local preview
473
- pnpm build:pages --released # use the current npm release
474
- python3 -m http.server 8765 --directory pages-dist
475
- # Open http://localhost:8765
476
- ```
477
-
478
- 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.
479
-
480
- `.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.
481
-
482
- ## License and attribution
483
-
484
- MIT-licensed software, originally extracted from KahWee’s San Francisco District Map. Geographic data retains its source terms and attribution requirements; see [LICENSE](LICENSE) and [SOURCES.md](SOURCES.md). Neighborhood definitions vary by source and are not legal boundaries or a claim of universal consensus.
485
-
486
- ### Schematic transit animation
487
-
488
- ```js
489
- import { createTransitAnimation } from '@kahwee/sf-map-svg/transit';
490
- const animation = createTransitAnimation();
491
- document.querySelector('#transit').append(animation);
492
- // On removal: animation.destroy();
493
- ```
494
-
495
- 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.
496
-
497
- 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>`.
498
-
499
- ## Ballot measures explorer
500
-
501
- [Explore local ballot measures](https://kahwee.github.io/sf-map-svg/measures.html): 44 measures from the November 2002, November 2012, November 2022, and June 2026 elections, across all three supported district map vintages. Pick an election, search its measures, inspect a district, compare two measures, and download an SVG, CSV, or the sourced JSON. The page includes touch pan/zoom, keyboard district selection, a sortable district table, and shareable year-specific views. Each election's results and district map load on demand. This is an archive of four elections, not every intervening election or a live results service. See [the election data schema](data/elections/README.md) and [SOURCES.md](SOURCES.md) for methods and source links.
502
-
503
- ### Motion, styling, and progressive embeds
504
-
505
- The guide now exposes the same `colors` palette as the static renderer. Existing
506
- appearance and immediate camera movement remain the defaults. See
507
- [consumer integration recommendations](docs/consumer-integration.md) for the
508
- complete example, bundle choices, progressive shell, and testing contract.
509
-
510
- ```js
511
- import { createGuideMap } from '@kahwee/sf-map-svg/guide/map';
512
-
513
- const map = createGuideMap({
514
- colors: { water: '#202d38', land: '#34434a', park: '#42624d',
515
- road: '#728080', neighborhood: '#64767e', label: '#f1f3ee' },
516
- labelStyle: { fontFamily: 'DM Sans, system-ui, sans-serif', fontWeight: 550,
517
- haloColor: '#34434a' },
518
- motion: { duration: 400 },
519
- markerEntrance: { duration: 450, stagger: 35 },
520
- selectedMarkerRing: { color: '#f1f3ee', width: 2, gap: 3 },
521
- clustering: { radius: 32 },
522
- attribution: 'compact',
523
- legend: { items: [{ label: 'Places', color: '#cf8757' }] },
524
- northArrow: true,
525
- scaleBar: true,
526
- strings: { touchNavigation: 'Touch pan', touchNavigationLabel: 'Enable touch pan',
527
- touchNavigationDone: 'Done', touchNavigationExitLabel: 'Restore page scrolling' },
528
- });
529
- document.querySelector('#map').append(map);
67
+ pnpm check
68
+ pnpm demo
69
+ pnpm build-storybook
70
+ pnpm test:stories:coverage
71
+ pnpm test:package
530
72
  ```
531
73
 
532
- | Option | Contract |
533
- | --- | --- |
534
- | `colors` | Static palette keys: water, land, district, neighborhood, highway, road, park, landmark, BART (`bart`), label, marker, selected |
535
- | `labelStyle` | `fontFamily`, numeric `fontWeight`, `haloColor`; the application loads any custom font |
536
- | `areaStyle` | `selectedFill`, `selectedStroke`, `hoverFill`, `hoverStroke`; applies to selectable neighborhoods |
537
- | `motion` | `false` by default; `true` uses 320ms, or `{ duration }` in milliseconds |
538
- | `markerEntrance` | `false` by default; `true` uses 420ms and 35ms stagger, or `{ duration, stagger }`; only newly introduced IDs animate, delay capped at 1s |
539
- | `MapMarker.radius` | Per-marker visible radius; interactive units are CSS pixels, static units are SVG units |
540
- | `selectedMarkerRing` | Optional `{ color, width, gap }` in CSS pixels; preserves the hit target |
541
- | `clustering` | `false` by default; `true` or `{ radius }` groups nearby screen positions; selected pin remains independent |
542
- | `legend` | `{ builtins: false, items: [{ label, color }] }` replaces built-ins; omit `builtins` to append custom entries; `hidden` hides selected built-ins (`bart`, `park`, `highway`, `road`) |
543
- | `attribution` | `'full'` (default) or `'compact'`; compact keeps full provenance in a native disclosure |
544
- | `northArrow`, `scaleBar` | Optional canvas furniture; scale is approximate at central SF latitude, in metric units |
545
-
546
- `setViewport(view, { animate, duration })`, `fitGeometry(geometry, padding,
547
- { animate, duration })`, `selectMarker(id, { fit, animate, duration })`, and
548
- `selectNeighborhood(name, { fit, animate, duration })` accept per-call motion
549
- controls. `animate: true` opts in even when global motion is disabled.
550
- `stopAnimation()` freezes the camera at its current viewport. A new camera
551
- operation replaces the previous transition; pointer gestures interrupt it.
552
- Reduced-motion preference overrides all animation requests. `destroy()` cancels
553
- camera frames, marker animations, listeners, and resize observation.
554
-
555
- `map.overlayElement` is a public HTML overlay slot. `map.projectToScreen(lng, lat)`
556
- returns `{ x, y, visible }` in CSS pixels relative to that slot. Call it after
557
- mounting; reposition your callout on `viewportchange` and `mapresize`.
558
- The slot ignores pointer events; interactive children can set `pointer-events:auto`.
559
- `clusteractivate` emits `{ markers }` and fits their geographic extent. Coincident
560
- pins cannot separate through zoom; retain the full native marker chooser or an
561
- accessible external list. `--sf-marker-index` on each marker is a stable entrance
562
- index hook, but the built-in animation avoids the need to style internal SVG.
563
-
564
- For server rendering, `createGuideSVG(options)` from `@kahwee/sf-map-svg/guide/static`
565
- uses the same simplified geography as the browser guide with one import.
566
- `createGuideShell(options)` also reserves compact toolbar, legend, and attribution
567
- rows; pass its `.sf-guide-shell` element to `mountGuideMap(shell, options)` from
568
- `@kahwee/sf-map-svg/guide/map`. By default, this compact layout hides the native
569
- pickers, pan buttons, label switch, help, and visible status; explicit control overrides are honored. Provide an accessible
570
- external place list. The toolbar and legend scroll horizontally if needed.
571
-
572
- ### Reconfigure without rebuilding
573
-
574
- Construction options remain backward-compatible. Use these atomic patches for
575
- runtime switches; they preserve the map element, viewport, markers, and selection:
576
-
577
- ```js
578
- map.setFeatures({ motion: false, clustering: true, selectedMarkerRing: true });
579
- map.setLayers({ landmarks: false, bartStations: true, roadLabels: false });
580
- map.setControls({ zoom: false, reset: true, touch: true });
581
- ```
582
-
583
- `setFeatures` accepts the exported `MapFeatures` interface: `motion`,
584
- `markerEntrance`, `clustering`, `selectedMarkerRing`, `northArrow`, and `scaleBar`.
585
- Every feature accepts `false` to disable; the first four also accept `true` for
586
- built-in settings or an options object. Omitted patch keys retain their settings;
587
- `undefined` resets that key to the default. Options objects **replace** the previous
588
- object for that feature; they do not deep-merge. `getFeatures()` returns a detached,
589
- normalized snapshot. Changing motion cancels the current transition; changing
590
- entrance settings cancels active entrances and applies to future new marker IDs.
591
-
592
- `setLayers` accepts `InteractiveLayers`; `undefined` restores the mode default.
593
- Geometry, associated labels, built-in legend entries, and attribution update
594
- together. Roads and road labels remain independent switches. Toggling does not
595
- remove geography already imported into a client bundle.
596
-
597
- `setControls` accepts the same keys as `controls`. Hiding zoom no longer hides
598
- Reset, Labels, or Touch. Hiding the touch button disengages touch navigation so
599
- page scrolling stays recoverable. Unknown switch names and invalid values throw
600
- before modifying state. After `destroy()`, setters are no-ops and disposal can be
601
- repeated safely. Getters return the last state; screen projection still requires
602
- a mounted, visible canvas.
603
-
604
- For configuration precedence, malformed inputs, callback reentrancy, and lifecycle
605
- ownership, see the [maintainer API audit](docs/api-audit.md).
74
+ The [contributing guide](CONTRIBUTING.md) explains the geographic and release checks. Version 3 removes the five old compatibility entry points; [migrate before upgrading](docs/migration-v3.md).