@kahwee/sf-map-svg 4.3.1 → 4.4.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/llms-full.txt CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  > Offline San Francisco SVG maps with explicit geographic imports, no runtime dependencies, static rendering, and a browser controller.
4
4
 
5
- @kahwee/sf-map-svg 4.3.1 · local working tree. Node >=24.0.0 for server rendering; browsers need a DOM and a JSON-capable bundler.
5
+ @kahwee/sf-map-svg 4.4.0 · local working tree. Node >=24.0.0 for server rendering; browsers need a DOM and a JSON-capable bundler.
6
6
 
7
7
  These are working-tree docs. Unreleased changelog entries may describe APIs absent from npm; verify the installed version before copying them.
8
8
 
9
9
  Use renderMap(data, options) for SVG strings; createMap(data, options) returns a controller. Mount map.element and destroy it on unmount. Choose mode explicitly: createMap defaults to basemap. Geography is separate; staticMapData and fullMapData have different shapes.
10
10
 
11
- <!-- Generated from README.md; @kahwee/sf-map-svg 4.3.1 · local working tree. -->
11
+ <!-- Generated from README.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
12
12
 
13
13
  # SF Map SVG
14
14
 
@@ -16,6 +16,8 @@ Offline, self-contained San Francisco SVG maps. The package has no runtime depen
16
16
 
17
17
  [Getting started](https://github.com/kahwee/sf-map-svg/blob/main/docs/developer-guide.md) · [API reference](https://github.com/kahwee/sf-map-svg/blob/main/docs/API.md) · [Design playground](https://kahwee.github.io/sf-map-svg/playground.html) · [Live examples](https://kahwee.github.io/sf-map-svg/examples.html) · [Storybook source](https://github.com/kahwee/sf-map-svg/blob/main/stories/) · [Geographic sources](https://github.com/kahwee/sf-map-svg/blob/main/SOURCES.md) · [v3 migration](https://github.com/kahwee/sf-map-svg/blob/main/docs/migration-v3.md)
18
18
 
19
+ [Map preview](https://github.com/kahwee/sf-map-svg/blob/main/docs/map-preview.png) · 2,000-marker screencasts: [desktop](https://github.com/kahwee/sf-map-svg/blob/main/docs/media/markers-2000-desktop.webm), [390 px phone](https://github.com/kahwee/sf-map-svg/blob/main/docs/media/markers-2000-phone.webm). [Screenshots and capture instructions](https://github.com/kahwee/sf-map-svg/blob/main/docs/media/README.md) show zoom, viewport culling, clustering, label updates, and selection from the complete catalog. These recordings illustrate behavior; frame timings are reported below.
20
+
19
21
  ## Install
20
22
 
21
23
  ```sh
@@ -43,6 +45,23 @@ const { svg, project, viewBox } = renderMap(staticMapData, {
43
45
 
44
46
  See [map options and animations](https://github.com/kahwee/sf-map-svg/blob/main/docs/API.md#static-maps) for optional layers and static animation.
45
47
 
48
+ ## Labels before JavaScript loads
49
+
50
+ Version 4.4 adds an opt-in overview layout for server-rendered guides:
51
+
52
+ ```ts
53
+ import { createGuideShell } from '@kahwee/sf-map-svg/guide/static';
54
+
55
+ const guideMarkup = createGuideShell({
56
+ title: 'Explore San Francisco',
57
+ labelLayout: { width: 390 },
58
+ });
59
+ ```
60
+
61
+ `width` is the expected canvas width in CSS pixels. The SVG keeps its 800 × 800 projection while labels share the interactive map's priorities, anchors, sizes and collision rules. Guide shells include neighborhood names in this layout; calls without `labelLayout` keep their previous output.
62
+
63
+ Enhance the shell with `mountGuideController()` using the same data, colors, markers and layers. Static font widths are estimated, so browser fonts and resize can change which labels fit. See the [complete integration recipe and width guidance](https://github.com/kahwee/sf-map-svg/blob/main/docs/consumer-integration.md#keep-overview-labels-consistent). No migration is required from 4.3.
64
+
46
65
  ## Interactive map
47
66
 
48
67
  ```ts
@@ -83,11 +102,125 @@ Call `destroy()` when removing an interactive map.
83
102
 
84
103
  Canonical geography is in [`data/`](https://github.com/kahwee/sf-map-svg/blob/main/data/README.md), with provenance in [`SOURCES.md`](https://github.com/kahwee/sf-map-svg/blob/main/SOURCES.md). The public `/data` entry exposes lookup, catalog, district maps, and source-specific neighborhood collections. These are deeply frozen; clone before editing.
85
104
 
86
- Install with `pnpm install --frozen-lockfile`. See the [contributing guide](https://github.com/kahwee/sf-map-svg/blob/main/CONTRIBUTING.md) for architecture and the [validation and release runbook](https://github.com/kahwee/sf-map-svg/blob/main/docs/maintenance.md) for checks, browser inspection, and publishing. If you are upgrading from v2, follow the [v3 migration guide](https://github.com/kahwee/sf-map-svg/blob/main/docs/migration-v3.md).
105
+ Install with `pnpm install --frozen-lockfile`. Bun 1.4.3 is pinned in `.bun-version`; `pnpm test:bun` checks compiled-package imports and static rendering. Strict TypeScript 7.0.2 declarations and packed consumers remain part of the Node/pnpm checks. See the [contributing guide](https://github.com/kahwee/sf-map-svg/blob/main/CONTRIBUTING.md) for architecture and the [validation and release runbook](https://github.com/kahwee/sf-map-svg/blob/main/docs/maintenance.md) for checks, browser inspection, and publishing. If you are upgrading from v2, follow the [v3 migration guide](https://github.com/kahwee/sf-map-svg/blob/main/docs/migration-v3.md).
106
+
107
+ ## Browser checks and marker performance
108
+
109
+ The focused interaction suite checks a production bundle in Chromium, Firefox, and WebKit at 1440 px and 390 px. It covers 2,000-marker viewport culling and reattachment, overlapping-place selection, marker keyboard navigation and focus recovery, camera input, resizing, touch-mode scroll policy, reduced motion, and disposal. Phone widths check responsive layout in these engines; they do not replace physical-device gesture testing. The full Storybook and screenshot suites continue to use Chromium.
110
+
111
+ ```sh
112
+ pnpm exec playwright install --with-deps chromium firefox webkit
113
+ pnpm test:browsers
114
+ pnpm benchmark:markers --browsers=chromium,firefox,webkit
115
+ ```
116
+
117
+ The benchmark uses headless engines and a deterministic city grid with 500 and 2,000 labeled markers at 800 px and 390 px viewport widths. It loads the production bundle and fonts before timing, disables motion, and starts each operation from a consistent camera and clustering state. Each operation has three warmups and 20 measured samples. Results include mount, pan, zoom, clustering changes, retained-ID updates, replacement, and identical updates. Run benchmarks alone on an otherwise idle machine. Use `--labels=false` to compare label cost and `--output=test-results/another-run.json` to keep separate results.
118
+
119
+ `test-results/marker-benchmark.json` records browser versions, machine details, and p50/p95/max timings. Synchronous times include the API call and forced layout; settled times include two animation frames and are not GPU-paint measurements. Six continuous workloads record 60 frame intervals each: pan, rapid zoom, and resize with clustering on and off. Four initial frame callbacks precede the measurements. An untimed allocation probe also counts newly created pin groups, including detached nodes. These measurements describe the test machine, rather than a universal performance limit or a mobile-device guarantee. The report also records mounted pin/cluster counts at overview and two zoom levels, with clustering on and off; the picker retains all records. CI saves a shorter 2,000-marker Chromium benchmark for comparison without timing thresholds.
120
+
121
+ Measured October 10–11, 2026 against renderer commit `1d523d9`, in a Linux x64 container on an Intel Xeon Platinum 8573C with a four-CPU quota and 16 GiB memory limit, using Node 26.11.1 and Playwright 1.63.0. All 36 focused browser checks passed. The 2,000-marker results below use clustering and labels. Mount, update (retained IDs), and replacement (all new IDs) show median synchronous milliseconds; pan shows the 95th-percentile frame interval in milliseconds.
122
+
123
+ | Engine | Viewport | Mount | Update | Replace | Pan frame p95 |
124
+ | --- | ---: | ---: | ---: | ---: | ---: |
125
+ | Chromium 153 | 800 px | 147.2 | 28.3 | 92.0 | 166.7 |
126
+ | Chromium 153 | 390 px | 180.6 | 24.4 | 104.4 | 183.3 |
127
+ | Firefox 155 | 800 px | 351.0 | 55.0 | 222.0 | 200.3 |
128
+ | Firefox 155 | 390 px | 386.0 | 211.0 | 161.0 | 116.8 |
129
+ | WebKit 26.6 | 800 px | 468.0 | 105.0 | 896.0 | 302.0 |
130
+ | WebKit 26.6 | 390 px | 235.0 | 107.0 | 889.0 | 118.0 |
131
+
132
+ The baseline retained all supplied pin nodes in the SVG, including clustered pins. These frame intervals exceeded the 16.7 ms budget for 60 Hz interaction. Prefer smaller marker lists and stable IDs, and avoid repeatedly replacing every marker during interaction. Identical 2,000-marker updates took 0.7–2.0 ms at the median across these runs; full replacement was especially costly in WebKit. Keep camera-call timings separate from frame timings: a quick synchronous call does not imply a smooth rendered frame.
133
+
134
+ In that baseline, a separate 20-sample Chromium run with 2,000 markers at 800 px and `labels: false` reduced the pan frame p95 from 166.7 ms to 50.0 ms and median mount time from 147.2 ms to 115.6 ms. Disabling labels helps this workload but still does not establish 60 Hz performance. Reproduce the comparison with `pnpm benchmark:markers --counts=2000 --widths=800 --labels=false --output=test-results/marker-benchmark-no-labels.json`.
135
+
136
+ ### Viewport renderer results
137
+
138
+ The viewport renderer detaches offscreen pins and clustered members, reuses their nodes when they return, caches cluster groups during pan, and skips unchanged SVG writes. Selected and focused pins stay mounted; every supplied record remains selectable. Cluster membership uses the full catalog, so panning does not change counts at the edges.
139
+
140
+ The same 20-sample, 2,000-marker benchmark was rerun October 11, 2026 on the same host, browser versions, and label settings. Columns use the same units as the baseline above.
141
+
142
+ | Engine | Viewport | Mount | Update | Replace | Pan frame p95 |
143
+ | --- | ---: | ---: | ---: | ---: | ---: |
144
+ | Chromium 153 | 800 px | 92.3 | 17.0 | 52.8 | 16.8 |
145
+ | Chromium 153 | 390 px | 97.6 | 12.9 | 63.6 | 16.7 |
146
+ | Firefox 155 | 800 px | 106.0 | 21.0 | 54.0 | 49.4 |
147
+ | Firefox 155 | 390 px | 123.0 | 45.0 | 78.0 | 17.1 |
148
+ | WebKit 26.6 | 800 px | 160.0 | 32.0 | 805.0 | 49.0 |
149
+ | WebKit 26.6 | 390 px | 164.0 | 20.0 | 781.0 | 36.0 |
150
+
151
+ Desktop pan frame p95 improved from 166.7 → 16.8 ms in Chromium, 200.3 → 49.4 ms in Firefox, and 302.0 → 49.0 ms in WebKit. Firefox and WebKit still exceed a 60 Hz frame budget in this run. Prefer stable IDs, clustering, and fewer enabled label layers for dense interactive views; full replacement still creates all new records and picker options. These headless measurements do not establish smoothness on a physical phone.
152
+
153
+ With clustering off at 800 px, the mounted pin count fell from 2,000 in city view to 228 at the sampled 4× zoom; all 2,000 records stayed in the picker. **Checks / 2000 markers / Desktop** and **Phone** in Storybook show live counts and test zoom, pan, retained-ID updates, offscreen selection, and reset. The [plan and storyboard](https://github.com/kahwee/sf-map-svg/blob/main/docs/marker-performance-plan.md) describe the regression contract. [Saved baseline and updated JSON](https://github.com/kahwee/sf-map-svg/blob/main/docs/benchmarks/markers-2026-10-11.json) include all timing percentiles and viewport counts.
154
+
155
+ ```sh
156
+ pnpm benchmark:markers --counts=2000 --browsers=chromium,firefox,webkit \
157
+ --output=test-results/marker-benchmark-viewport.json
158
+ ```
159
+
160
+ ### Lazy pin creation and expanded workloads
161
+
162
+ Individual SVG pins now allocate on first visibility or selection, then retain their nodes for reuse. With 2,000 clustered records, an untimed probe counted zero pin groups at mount, zero after replacing every ID, and one after selecting a pin. The previous renderer created 2,000 groups at mount and another 2,000 on replacement. The complete picker, selection, keyboard focus, and deferred entrance animations remain available.
163
+
164
+ October 11, 2026 results with the same engines, labeled grid, and 20 operation samples (median synchronous milliseconds):
165
+
166
+ | Engine | Viewport | Mount | Replace all IDs |
167
+ | --- | ---: | ---: | ---: |
168
+ | chromium | 800 px | 78.1 | 39.3 |
169
+ | chromium | 390 px | 87.9 | 47.9 |
170
+ | firefox | 800 px | 88.0 | 24.0 |
171
+ | firefox | 390 px | 95.0 | 46.0 |
172
+ | webkit | 800 px | 128.0 | 717.0 |
173
+ | webkit | 390 px | 132.0 | 710.0 |
174
+
175
+ A separate run of the same expanded harness against `e41aa2b` measured Chromium mount medians of 115.1/123.6 ms and replacement medians of 57.1/89.8 ms at 800/390 px, versus 78.1/87.9 ms and 39.3/47.9 ms after lazy allocation. This paired control covers Chromium only. WebKit replacement remains expensive at 717/710 ms; stable IDs remain preferable.
176
+
177
+ The expanded stress cases reveal work that culling alone does not remove. Unclustered frame interval p95 values, in milliseconds:
178
+
179
+ | Engine | Viewport | Pan | Rapid zoom | Resize |
180
+ | --- | ---: | ---: | ---: | ---: |
181
+ | chromium | 800 px | 16.7 | 133.4 | 83.3 |
182
+ | chromium | 390 px | 33.3 | 33.4 | 50.0 |
183
+ | firefox | 800 px | 33.8 | 34.2 | 100.2 |
184
+ | firefox | 390 px | 34.2 | 49.4 | 50.7 |
185
+ | webkit | 800 px | 60.0 | 106.0 | 342.0 |
186
+ | webkit | 390 px | 114.0 | 124.0 | 192.0 |
187
+
188
+ These runs do not demonstrate a consistent frame-rate gain: desktop Chromium clustered pan p95 was 50.1 ms in the control and 66.8 ms after lazy allocation, compared with 16.8 ms in the earlier run. Rapid zoom alternates reciprocal 1.2× factors; resize alternates host widths by 80 px. Each workload runs with clustering both on and off, samples counts outside the timed loop, and retains 60 measured intervals. Headless frame timings vary and do not establish physical-device smoothness. [Saved lazy and control results](https://github.com/kahwee/sf-map-svg/blob/main/docs/benchmarks/markers-lazy-2026-10-11.json) preserve every scenario and allocation count.
189
+
190
+ ```sh
191
+ pnpm benchmark:markers --counts=2000 --browsers=chromium,firefox,webkit \
192
+ --output=test-results/marker-benchmark-lazy.json
193
+ ```
194
+
195
+ ### Cached label preparation and renderer components
196
+
197
+ Neighborhood priority is sorted once per source. Styled label candidates are reused until selection, catalog, source, layers, appearance, or zoom/width eligibility changes. Overlay bounds are measured before marker and cluster SVG updates; secondary-road visibility and scale furniture skip unchanged writes. Marker catalog reconciliation and cluster rendering now have separate modules, reducing the explorer controller from 2,316 to 2,151 lines. At the 4.3.2 marker-performance baseline, the renderer was 34.6 KiB gzip and static rendering was 7.9 KiB. See the [current bundle report](https://github.com/kahwee/sf-map-svg/blob/main/docs/guide-bundle-report.md) for measurements including the opt-in static label layout.
198
+
199
+ A fresh Chromium comparison against `e774fbd` used 2,000 labeled markers, 20 operation samples, both widths, and all six frame workloads in basemap and neighborhood modes. Neighborhood-mode frame interval p95 values (milliseconds):
200
+
201
+ | Workload | 800 px before | 800 px after | 390 px before | 390 px after |
202
+ | --- | ---: | ---: | ---: | ---: |
203
+ | clustered pan | 66.7 | 50.0 | 16.7 | 16.8 |
204
+ | clustered rapid zoom | 66.7 | 83.4 | 33.4 | 16.8 |
205
+ | clustered resize | 16.7 | 66.7 | 66.6 | 16.8 |
206
+ | unclustered pan | 16.7 | 49.9 | 33.3 | 66.6 |
207
+ | unclustered rapid zoom | 116.6 | 33.4 | 66.7 | 83.4 |
208
+ | unclustered resize | 66.7 | 33.3 | 50.0 | 50.1 |
209
+
210
+ Results are mixed rather than a consistent frame-rate gain. Basemap desktop clustered pan p95 changed from 50.0 to 16.8 ms, while phone unclustered pan changed from 16.8 to 50.0 ms. Basemap mount medians were 72.1 → 88.8 ms at 800 px and 88.3 → 89.1 ms at 390 px; neighborhood-mode mount medians were 81.5 → 73.3 ms and 93.9 → 89.0 ms. Viewport counts and allocation probes match before and after. [Saved comparisons](https://github.com/kahwee/sf-map-svg/blob/main/docs/benchmarks/markers-refactor-2026-10-11.json) include every operation and scenario; these headless runs do not establish physical-device smoothness.
211
+
212
+ ```sh
213
+ pnpm benchmark:markers --counts=2000 --mode=neighborhoods \
214
+ --output=test-results/marker-benchmark-neighborhoods.json
215
+ ```
216
+
217
+ ## CI maintenance
218
+
219
+ [GitHub Actions maintenance](https://github.com/kahwee/sf-map-svg/blob/main/.github/ACTIONS.md) covers workflows, parallel checks, action versions, and weekly updates.
87
220
 
88
221
  ---
89
222
 
90
- <!-- Generated from docs/developer-guide.md; @kahwee/sf-map-svg 4.3.1 · local working tree. -->
223
+ <!-- Generated from docs/developer-guide.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
91
224
 
92
225
  # Developer guide
93
226
 
@@ -231,7 +364,7 @@ See [API options](https://github.com/kahwee/sf-map-svg/blob/main/docs/API.md), [
231
364
 
232
365
  ---
233
366
 
234
- <!-- Generated from docs/API.md; @kahwee/sf-map-svg 4.3.1 · local working tree. -->
367
+ <!-- Generated from docs/API.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
235
368
 
236
369
  # Map options and lifecycle
237
370
 
@@ -254,9 +387,9 @@ const { svg } = renderMap(staticMapData, presentation);
254
387
  const map = createMap(fullMapData, presentation);
255
388
  ```
256
389
 
257
- Static `layers` supports every interactive layer switch except `neighborhoodLabels`. Static `appearance` supports `theme`, `colors`, and `districtStyle`; browser typography, area interaction styles, and screen-space marker sizing remain interactive settings. Unknown keys and invalid values are rejected for both flat and grouped options. Supplied grouped keys override flat compatibility keys; omitted or `undefined` grouped keys preserve the flat value or renderer default. Undefined color tokens use the theme default instead of entering SVG attributes. The two renderers retain their existing defaults. Marker arrays accept readonly inputs.
390
+ Static `layers` supports every interactive layer switch. `neighborhoodLabels` applies to the opt-in `labelLayout` described below. Static `appearance` supports `theme`, `colors`, and `districtStyle`; browser typography, area interaction styles, and screen-space marker sizing remain interactive settings. Unknown keys and invalid values are rejected for both flat and grouped options. Supplied grouped keys override flat compatibility keys; omitted or `undefined` grouped keys preserve the flat value or renderer default. Undefined color tokens use the theme default instead of entering SVG attributes. The two renderers retain their existing defaults. Marker arrays accept readonly inputs.
258
391
 
259
- Flat compatibility 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`, `colors`, and `animation`. Each optional layer is independent. User-supplied text and attributes are escaped in SVG output.
392
+ Flat compatibility options include `theme`, `width`, `height`, `padding`, `year` (2002, 2012, 2022), `districtLines`, `districtFills`, `districtStyle`, `districtLabels`, `neighborhoodLines`, `neighborhoodLabels`, `labels`, `labelLayout`, `highways`, `keyRoads`, `roadLabels`, `landmarks`, `bartStations`, `markers`, `overlays`, `title`, `idPrefix`, `colors`, and `animation`. Each optional layer is independent. User-supplied text and attributes are escaped in SVG output.
260
393
 
261
394
  Custom district IDs must be finite numbers. An empty `labelPoints` array uses the district's primary `label` position.
262
395
 
@@ -264,6 +397,10 @@ The SVG description names the selected neighborhood source for source-aware data
264
397
 
265
398
  `animation: true` (or `{ duration, delay }`) makes a static map draw itself with self-contained, scoped CSS: land fades in, the coast and lines draw, districts grow, then labels and points appear. It plays when the SVG is inserted into a page or loaded as an image, needs no JavaScript, and stays still under `prefers-reduced-motion`. Re-insert the markup to replay it. Default output is unchanged.
266
399
 
400
+ Static `labelLayout: { width: 390 }` opts into shared interactive **overview** label priorities, sizes, anchors and collision rules. `width` must be a positive finite CSS canvas width; it does not alter the SVG viewBox. Set `neighborhoodLabels: true` (flat or under `layers`) to add area names in this layout. Guide SVGs and shells enable neighborhood names by default only when opting into `labelLayout`. `labels: false` remains the master switch. Default static output is unchanged.
401
+
402
+ Static placement uses estimated system-font metrics without browser globals. Interactive placement measures actual fonts and includes live selection and HTML furniture, so exact glyph positions and the visible label set can differ. See the [integration recipe](https://github.com/kahwee/sf-map-svg/blob/main/docs/consumer-integration.md#keep-overview-labels-consistent) for width and progressive-enhancement guidance.
403
+
267
404
  ## Interactive maps
268
405
 
269
406
  `createMap` defaults to `mode: 'basemap'` and `appearance.theme: 'transit'`. Set the mode explicitly for neighborhood or district exploration. District and neighborhood layer defaults follow the mode; other supplied layers default on. The [developer guide](https://github.com/kahwee/sf-map-svg/blob/main/docs/developer-guide.md) compares static and interactive defaults and explains configuration resets, coordinates, and cleanup.
@@ -274,7 +411,13 @@ The controller also supports marker, neighborhood, and district selection; distr
274
411
 
275
412
  If a district callback destroys the map or selects a newer district, the interrupted activation stops without moving keyboard focus or sending a stale event.
276
413
 
277
- `setMarkers()` reconciles by stable marker `id`: retained markers keep their DOM nodes, focus, and in-progress entrance animations; only new IDs animate in. Identical ordered marker updates are a visual no-op. Camera state and `viewportchange` events remain synchronous, while animated camera steps and their dependent label/marker layout commit in the same browser frame.
414
+ `setMarkers()` reconciles by stable marker `id`: retained markers keep any previously created nodes, focus, and in-progress entrance animations. New pin entrances start when the pin first becomes visible; a cached pin does not animate again merely because it returns to the viewport. Disabling entrances, reduced-motion changes, and destruction cancel pending and active entrances. Identical ordered marker updates are a visual no-op. Camera state and `viewportchange` events remain synchronous, while animated camera steps and their dependent label/marker layout commit in the same browser frame.
415
+
416
+ Interactive maps create individual pin nodes only on first visibility or selection, then cache them for reuse. Record and style updates before first visibility apply to the eventual node. They mount only pins and cluster symbols intersecting a padded viewport; clustered member pins detach. All records remain in the native picker and selectable through the API. Selected and keyboard-focused pins stay mounted, even outside the viewport, and retained nodes return when panning back. Cluster membership uses the complete catalog and stays stable during pan at the same zoom. Static SVG exports are unaffected.
417
+
418
+ Visible markers and clusters share one tab stop. Use `[` and `]` to move focus, Enter or Space to activate, and Tab to leave the group. Arrow keys retain map panning. Marker replacement preserves the focused ID when available and moves focus to a surviving marker when it is removed.
419
+
420
+ Activating coincident pins opens a place chooser, including when compact embeds hide the native marker picker. Clusters zoom when their members can separate at maximum zoom; otherwise they open the chooser. Escape or Close returns focus to the originating pin or cluster when it remains visible. Choosing a place selects its marker; replacing markers or destroying the map closes the chooser. `clusteractivate` still reports the activated cluster's markers.
278
421
 
279
422
  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](https://github.com/kahwee/sf-map-svg/blob/main/docs/EXAMPLES.md) and [consumer integration](https://github.com/kahwee/sf-map-svg/blob/main/docs/consumer-integration.md).
280
423
 
@@ -331,13 +474,13 @@ Neighborhood IDs are exact source-scoped feature IDs, rather than display names
331
474
 
332
475
  ## Coordinates and units
333
476
 
334
- GeoJSON and static projection inputs use `[longitude, latitude]` in WGS84; markers use `{ lng, lat }`. Static `project` returns SVG units. `projectToScreen(lng, lat)` returns pixels relative to the mounted canvas. Camera viewports and pan deltas use the fixed 800 × 800 projected map space, independent of canvas size. Marker radius uses screen pixels in browser maps and SVG units in static output.
477
+ GeoJSON and static projection inputs use `[longitude, latitude]` in WGS84; markers use `{ lng, lat }`. Static `project` returns SVG units. `projectToScreen(lng, lat)` returns pixels relative to the mounted canvas. Camera viewports use the fixed 800 × 800 projected map space, independent of canvas size. Pan deltas use screen pixels, converted using the mounted canvas width. Marker radius uses screen pixels in browser maps and SVG units in static output.
335
478
 
336
479
  Explore presentation options in the [live map design playground](https://kahwee.github.io/sf-map-svg/playground.html). The page identifies its package version; released builds use compatible options for that installed version.
337
480
 
338
481
  ---
339
482
 
340
- <!-- Generated from docs/EXAMPLES.md; @kahwee/sf-map-svg 4.3.1 · local working tree. -->
483
+ <!-- Generated from docs/EXAMPLES.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
341
484
 
342
485
  # Examples
343
486
 
@@ -348,6 +491,7 @@ Choose by task. All snippets use the public package API; browser examples need a
348
491
  | I want to… | Start here |
349
492
  | --- | --- |
350
493
  | Render an SVG on a server | [Static map](#static-svg) |
494
+ | Show labels before JavaScript loads | [Static-to-interactive guide](https://github.com/kahwee/sf-map-svg/blob/main/docs/consumer-integration.md#keep-overview-labels-consistent) |
351
495
  | Add a small map to a browser | [Lightweight guide](#lightweight-interactive-guide) |
352
496
  | Bundle only selected geography | [Selected data](#selected-geography) |
353
497
  | Draw a route over the city | [Route overlay](#route-overlay) |
@@ -448,7 +592,7 @@ For the controller and explicit data imports, see [the v3 migration guide](https
448
592
 
449
593
  ---
450
594
 
451
- <!-- Generated from docs/consumer-integration.md; @kahwee/sf-map-svg 4.3.1 · local working tree. -->
595
+ <!-- Generated from docs/consumer-integration.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
452
596
 
453
597
  # Embedding and progressive enhancement
454
598
 
@@ -467,6 +611,46 @@ const markup = createGuideShell({ title: 'Explore San Francisco' });
467
611
 
468
612
  The shell uses an 800 × 800 projection with 28 units of padding, matching the interactive map. Use `createGuideSVG()` for custom dimensions. Rendering is offline, with zero runtime dependencies.
469
613
 
614
+ ## Keep overview labels consistent
615
+
616
+ Opt into the browser's overview label priorities and collision rules with a target **canvas width in CSS pixels**:
617
+
618
+ ```ts
619
+ const labeledMarkup = createGuideShell({
620
+ title: 'Explore San Francisco',
621
+ labelLayout: { width: 390 },
622
+ });
623
+ ```
624
+
625
+ This keeps the 800 × 800 projection. It sizes and places text for a 390 px canvas, prioritizes major parks and primary roads, includes neighborhood names, and avoids label, marker, and BART-symbol collisions. BART names and smaller parks remain deferred until interactive zoom. Use the same geography, colors, markers and layer switches when mounting. Set `layers.neighborhoodLabels: false` on both renderers to omit neighborhood names.
626
+
627
+ The width is a rendering hint, not a responsive script. Generate an appropriate variant for your known embed width; an SVG displayed at another width scales its labels with the artwork. Static layout uses conservative estimated system-font widths, while the browser measures actual fonts and recalculates on resize. Font metrics, custom browser label styles, selection, clustering and HTML overlays can therefore change the final label set or position. This reduces the transition without promising pixel-identical hydration. Existing calls without `labelLayout` keep their original fixed labels.
628
+
629
+ For a fixed-width embed, use shared inputs on the server and in the browser:
630
+
631
+ ```ts
632
+ // Shared inputs: coordinates, IDs, radii, colors and layer choices must match.
633
+ const guideMarkers = [
634
+ { id: 'ferry', label: 'Ferry Building', lng: -122.3937, lat: 37.7955, radius: 7 },
635
+ ];
636
+ const guideLayers = { neighborhoodLabels: true, roadLabels: true };
637
+ const guideColors = { label: '#183e58', landmark: '#426641' };
638
+
639
+ // Server: place this HTML in a canvas that is 390 CSS pixels wide.
640
+ const labeledPlacesMarkup = createGuideShell({
641
+ labelLayout: { width: 390 },
642
+ markers: guideMarkers,
643
+ layers: guideLayers,
644
+ appearance: { colors: guideColors },
645
+ });
646
+ ```
647
+
648
+ After inserting that markup, pass `markers: guideMarkers`, `layers: guideLayers`, and `appearance: { colors: guideColors }` to `mountGuideController()` as shown below. The static-only `labelLayout` option is not a browser option: the controller measures the actual canvas and font. The shell border is outside the canvas, so a default shell with a 390 px outer width has a 388 px canvas.
649
+
650
+ For responsive layouts whose canvas width is unknown at build time, choose a representative width and accept text scaling until enhancement, or serve independently rendered SVG variants through your application's responsive markup. The package does not detect a device on the server. Keep `labels: false` if your design requires no visible text change at enhancement.
651
+
652
+ For `renderMap()`, also enable `neighborhoodLabels` explicitly; it defaults off outside the guide preset. `labels: false` hides all text in either layout while preserving symbols and accessible titles.
653
+
470
654
  ## Enhance the existing frame
471
655
 
472
656
  In your browser entry, mount the controller into that shell:
@@ -479,7 +663,9 @@ if (!shell) throw new Error('Missing guide shell');
479
663
  const guide = mountGuideController(shell, {
480
664
  attribution: 'compact',
481
665
  features: { motion: { duration: 400 }, markerEntrance: true },
482
- appearance: { theme: 'transit' },
666
+ markers: guideMarkers,
667
+ layers: guideLayers,
668
+ appearance: { theme: 'transit', colors: guideColors },
483
669
  });
484
670
 
485
671
  // Call before your application removes the containing view.
@@ -500,7 +686,7 @@ See the [exact API contract](https://github.com/kahwee/sf-map-svg/blob/main/docs
500
686
 
501
687
  ---
502
688
 
503
- <!-- Generated from docs/migration-v3.md; @kahwee/sf-map-svg 4.3.1 · local working tree. -->
689
+ <!-- Generated from docs/migration-v3.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
504
690
 
505
691
  # Migrate to version 3
506
692
 
@@ -541,7 +727,7 @@ District styles are now prepared once per district on construction and style/yea
541
727
 
542
728
  ---
543
729
 
544
- <!-- Generated from SOURCES.md; @kahwee/sf-map-svg 4.3.1 · local working tree. -->
730
+ <!-- Generated from SOURCES.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
545
731
 
546
732
  # Geometry and provenance
547
733
 
@@ -725,7 +911,7 @@ display geometry, and no precinct polygons or raw workbook data are shipped.
725
911
 
726
912
  ---
727
913
 
728
- <!-- Generated from CHANGELOG.md; @kahwee/sf-map-svg 4.3.1 · local working tree. -->
914
+ <!-- Generated from CHANGELOG.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
729
915
 
730
916
  # Changelog
731
917
 
@@ -733,6 +919,32 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
733
919
 
734
920
  ## Unreleased
735
921
 
922
+ ## 4.4.0 — 2026-10-12
923
+
924
+ - Add opt-in static `labelLayout: { width }` to `renderMap`, `createGuideSVG`, and `createGuideShell`. Static and interactive maps now share overview priorities, neighborhood anchors, font sizes, and collision rules; centered labels retain their anchors during enhancement.
925
+ - Add static `neighborhoodLabels`, available flat or under `layers`, for the new layout. Guide SVGs and shells enable neighborhood names when opting in. Existing fixed-label output and interactive defaults remain unchanged; `labels: false` still hides text while preserving symbols and accessible titles.
926
+ - Keep static layout server-safe and dependency-free. The width is a CSS canvas-width hint, not the SVG projection width; portable font estimates can produce a different visible label set from browser-measured fonts. Document matching data, markers, colors, and layers, plus responsive-width and custom-style limitations.
927
+ - Keep server font estimation out of interactive-only imports and browser runtime code out of static imports. Refresh bundle reports and enforce bounded size budgets for the combined renderer and static-capable Pages tools.
928
+ - Add desktop and phone enhancement stories, collision and input-validation regressions, an SVG example, and copyable integration documentation. Existing screenshot baselines remain unchanged.
929
+
930
+ ## 4.3.2 — 2026-10-10
931
+
932
+ - Update compatible development tooling and refresh dependency security fixes.
933
+
934
+ - Cache label candidates across camera frames, prepare neighborhood priority once per source, and measure overlays before SVG updates.
935
+ - Extract marker catalog reconciliation and cluster rendering with explicit cache and listener ownership; add label refresh checks across catalog, appearance, layers, and source changes.
936
+
937
+ - Create individual marker visuals only on first visibility or selection, keeping the full catalog and retained nodes. Defer new pin entrances and cancel pending entrances with active animations.
938
+ - Benchmark clustered and unclustered continuous pan, rapid zoom, and resize, with separate allocation probes.
939
+
940
+ - Cull offscreen pins and cluster symbols while preserving the full catalog, cached nodes, selection, and keyboard focus. Cache cluster groups during pan and skip unchanged SVG attributes.
941
+ - Add interactive 2,000-marker desktop and phone stories, viewport count benchmarks, and a Bun 1.4.3 compatibility gate.
942
+
943
+ - Add production-bundle interaction checks in Chromium, Firefox, and WebKit, plus reproducible marker benchmarks and CI performance artifacts.
944
+ - Add optimized desktop and phone marker screencasts to the Pages examples, with captions and playback size checks.
945
+ - Add a place chooser for coincident pins and clusters that cannot separate at maximum zoom, including compact embeds with hidden native pickers.
946
+ - Give visible markers and clusters one tab stop with bracket-key navigation, preserving focus across marker reordering and recovering focus when a marker is removed.
947
+
736
948
  ## 4.3.1 — 2026-10-04
737
949
 
738
950
  - Reject unknown flat static options and invalid values before rendering or invoking district styling callbacks, matching grouped-option validation.
@@ -1051,7 +1263,7 @@ consistent home, and makes animation and subscription ownership predictable.
1051
1263
 
1052
1264
  # Published type contracts
1053
1265
 
1054
- @kahwee/sf-map-svg 4.3.1 · local working tree. These declarations are generated by TypeScript. Use package export paths, not the internal filenames below. Imported relative contracts are included for context, not as additional public entry points.
1266
+ @kahwee/sf-map-svg 4.4.0 · local working tree. These declarations are generated by TypeScript. Use package export paths, not the internal filenames below. Imported relative contracts are included for context, not as additional public entry points.
1055
1267
 
1056
1268
  | Public import | Declaration |
1057
1269
  | --- | --- |
@@ -1232,7 +1444,7 @@ export interface MapCamera {
1232
1444
  fit(geometry: Geometry, options?: CameraOptions & {
1233
1445
  padding?: number | MapPadding;
1234
1446
  }): void;
1235
- /** Delta in projected map units. */
1447
+ /** Delta in screen pixels, converted using the mounted canvas width. */
1236
1448
  pan(x: number, y: number, options?: CameraOptions): void;
1237
1449
  zoom(factor: number, options?: CameraOptions): void;
1238
1450
  reset(options?: CameraOptions): void;
@@ -1249,7 +1461,7 @@ export interface MapController {
1249
1461
  getResolvedConfiguration(): ResolvedMapConfiguration;
1250
1462
  getCapabilities(): MapCapabilities;
1251
1463
  on<K extends keyof MapEvents>(type: K, listener: (detail: MapEvents[K]) => void): () => void;
1252
- /** Reconcile stable IDs; retained markers preserve nodes, focus, and entrance animations. */
1464
+ /** Reconcile stable IDs; preserve existing nodes and focus. Pin visuals allocate on first visibility/selection. */
1253
1465
  setMarkers(markers: readonly MapMarker[]): void;
1254
1466
  setOverlays(overlays: readonly MapOverlay[]): void;
1255
1467
  /** False for missing IDs or a source/year other than the current geography. */
@@ -1468,12 +1680,18 @@ export interface SFMapOptions {
1468
1680
  source?: NeighborhoodSource;
1469
1681
  districtLines?: boolean;
1470
1682
  neighborhoodLines?: boolean;
1683
+ /** Neighborhood names in the opt-in collision layout. */
1684
+ neighborhoodLabels?: boolean;
1471
1685
  districtFills?: boolean;
1472
1686
  /** Evaluated once per district at construction/style/year updates; call setDistrictStyle again when external data changes. */
1473
1687
  districtStyle?: (district: import('./map-core.js').DistrictRowData) => DistrictStyle;
1474
1688
  districtLabels?: boolean;
1475
1689
  /** Hide all visible text labels while retaining geographic symbols and accessible titles. */
1476
1690
  labels?: boolean;
1691
+ /** Opt into interactive overview label priorities/collisions at this CSS display width. Uses estimated system-font metrics without a DOM. */
1692
+ labelLayout?: {
1693
+ width: number;
1694
+ };
1477
1695
  highways?: boolean;
1478
1696
  keyRoads?: boolean;
1479
1697
  roadLabels?: boolean;
@@ -1496,7 +1714,7 @@ export interface SFMapOptions {
1496
1714
  colors?: Partial<Record<'water' | 'land' | 'district' | 'neighborhood' | 'highway' | 'road' | 'park' | 'landmark' | 'bart' | 'label' | 'marker' | 'selected', string>>;
1497
1715
  }
1498
1716
  /** Presentation supported by both the static and interactive renderers. */
1499
- export type StaticMapLayers = Omit<InteractiveLayers, 'neighborhoodLabels'>;
1717
+ export type StaticMapLayers = InteractiveLayers;
1500
1718
  export type StaticMapAppearance = Pick<SFMapOptions, 'theme' | 'colors' | 'districtStyle'>;
1501
1719
  /** Serializable viewport in the interactive map's fixed 800 × 800 projected space. */
1502
1720
  export type MapViewport = readonly [x: number, y: number, size: number];
@@ -1663,7 +1881,7 @@ export interface NeighborhoodExplorerElement extends HTMLElement {
1663
1881
  fitGeometry(geometry: Geometry, padding?: number | MapPadding, options?: CameraOptions): void;
1664
1882
  /** Explicitly engage map touch gestures; false restores page gestures. */
1665
1883
  setTouchNavigation(enabled: boolean): void;
1666
- /** Reconcile stable IDs; retained markers preserve nodes, focus, and entrance animations. */
1884
+ /** Reconcile stable IDs; preserve existing nodes and focus. Pin visuals allocate on first visibility/selection. */
1667
1885
  setMarkers(markers: readonly MapMarker[]): void;
1668
1886
  setOverlays(overlays: readonly MapOverlay[]): void;
1669
1887
  selectMarker(id: string | null, options?: {
@@ -1699,10 +1917,12 @@ export interface InteractiveSFMapData {
1699
1917
  import type { Geometry, Position } from '../data/types.js';
1700
1918
  import type { DistrictYear, SFMapOptions } from './types.js';
1701
1919
  export type { DistrictYear, MapMarker, MapOverlay, SFMapOptions } from './types.js';
1920
+ import { type Project } from './geometry.js';
1702
1921
  export interface SFMapData {
1703
1922
  coast: Geometry;
1704
1923
  districts?: Readonly<Partial<Record<DistrictYear, readonly DistrictRowData[]>>>;
1705
1924
  neighborhoods?: readonly {
1925
+ id?: string;
1706
1926
  name: string;
1707
1927
  geometry: Geometry;
1708
1928
  }[];
@@ -1793,13 +2013,23 @@ export declare function resolveMapColors(theme: SFMapOptions['theme'], colors?:
1793
2013
  selected: string;
1794
2014
  };
1795
2015
  /** Make an offline SVG and the matching longitude/latitude projection. */
1796
- export declare function createSFMapWithData(options: SFMapOptions, data: SFMapData): {
2016
+ export declare function createSFMapWithData(options: SFMapOptions, data: SFMapData, renderLabels?: (project: Project) => string): {
1797
2017
  svg: string;
1798
2018
  project: (coordinates: Position) => [number, number];
1799
2019
  viewBox: [number, number, number, number];
1800
2020
  };
1801
2021
  ```
1802
2022
 
2023
+ ### dist/src/geometry.d.ts
2024
+
2025
+ ```ts
2026
+ import type { Geometry, Position } from '../data/types.js';
2027
+ export type Project = (position: Position) => [number, number];
2028
+ export declare function rawProject([longitude, latitude]: Position): [number, number];
2029
+ export declare function positions(geometry: Geometry | null | undefined): Position[];
2030
+ export declare function geometryPath(geometry: Geometry | null | undefined, project: Project): string;
2031
+ ```
2032
+
1803
2033
  ### dist/src/static.d.ts
1804
2034
 
1805
2035
  ```ts
@@ -1976,16 +2206,6 @@ import type { FeatureCollection, NeighborhoodProperties } from './types.js';
1976
2206
  export declare const sfFindNeighborhoods: FeatureCollection<NeighborhoodProperties>;
1977
2207
  ```
1978
2208
 
1979
- ### ./dist/src/geometry.d.ts
1980
-
1981
- ```ts
1982
- import type { Geometry, Position } from '../data/types.js';
1983
- export type Project = (position: Position) => [number, number];
1984
- export declare function rawProject([longitude, latitude]: Position): [number, number];
1985
- export declare function positions(geometry: Geometry | null | undefined): Position[];
1986
- export declare function geometryPath(geometry: Geometry | null | undefined, project: Project): string;
1987
- ```
1988
-
1989
2209
  ### ./dist/src/guide.d.ts
1990
2210
 
1991
2211
  ```ts
package/llms.txt CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Offline San Francisco SVG maps with explicit geographic imports, no runtime dependencies, static rendering, and a browser controller.
4
4
 
5
- @kahwee/sf-map-svg 4.3.1 · local working tree. Node >=24.0.0 for server rendering; browsers need a DOM and a JSON-capable bundler.
5
+ @kahwee/sf-map-svg 4.4.0 · local working tree. Node >=24.0.0 for server rendering; browsers need a DOM and a JSON-capable bundler.
6
6
 
7
7
  These are working-tree docs. Unreleased changelog entries may describe APIs absent from npm; verify the installed version before copying them.
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kahwee/sf-map-svg",
3
- "version": "4.3.1",
3
+ "version": "4.4.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/api.js",
@@ -135,22 +135,22 @@
135
135
  "node": ">=24.0.0"
136
136
  },
137
137
  "devDependencies": {
138
- "@biomejs/biome": "2.5.14",
139
- "@storybook/addon-a11y": "10.6.0",
140
- "@storybook/addon-docs": "^10.6.0",
141
- "@storybook/addon-vitest": "10.6.0",
142
- "@storybook/html-vite": "^10.6.0",
143
- "@vitest/browser-playwright": "^5.0.2",
144
- "@vitest/coverage-v8": "5.0.2",
138
+ "@biomejs/biome": "2.5.15",
139
+ "@storybook/addon-a11y": "10.6.1",
140
+ "@storybook/addon-docs": "^10.6.1",
141
+ "@storybook/addon-vitest": "10.6.1",
142
+ "@storybook/html-vite": "^10.6.1",
143
+ "@vitest/browser-playwright": "^5.0.3",
144
+ "@vitest/coverage-v8": "5.0.3",
145
145
  "@xmldom/xmldom": "^0.9.12",
146
146
  "playwright": "^1.63.0",
147
147
  "polygon-clipping": "^0.15.7",
148
148
  "react": "^19.3.0",
149
149
  "react-dom": "^19.3.0",
150
- "storybook": "^10.6.0",
150
+ "storybook": "^10.6.1",
151
151
  "typescript": "^7.0.2",
152
- "vite": "^8.3.1",
153
- "vitest": "^5.0.2"
152
+ "vite": "^8.3.3",
153
+ "vitest": "^5.0.3"
154
154
  },
155
155
  "homepage": "https://kahwee.github.io/sf-map-svg/",
156
156
  "bugs": {
@@ -172,6 +172,9 @@
172
172
  "test:stories": "pnpm build && vitest run --project=storybook",
173
173
  "test:stories:coverage": "pnpm build && vitest run --project=storybook --coverage",
174
174
  "test:visual": "playwright test --config=playwright.visual.config.mjs",
175
+ "test:browsers": "playwright test --config=playwright.browser.config.mjs",
176
+ "test:bun": "pnpm build && bun scripts/smoke-bun.mjs",
177
+ "benchmark:markers": "node scripts/benchmark-markers.mjs",
175
178
  "demo": "pnpm build && node examples/build.mjs",
176
179
  "format": "biome check --write .",
177
180
  "format:check": "biome format .",