@kahwee/sf-map-svg 4.5.0 → 5.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/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.5.0 · local working tree. Node >=24.0.0 for server rendering; browsers need a DOM and a JSON-capable bundler.
5
+ @kahwee/sf-map-svg 5.0.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.5.0 · local working tree. -->
11
+ <!-- Generated from README.md; @kahwee/sf-map-svg 5.0.0 · local working tree. -->
12
12
 
13
13
  # SF Map SVG
14
14
 
@@ -18,6 +18,11 @@ Offline, self-contained San Francisco SVG maps. The package has no runtime depen
18
18
 
19
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
20
 
21
+ ## Upgrading to v5
22
+
23
+ Version 5.0.0 standardizes coordinate tuples and controller reset behavior.
24
+ Read the [migration guide](https://github.com/kahwee/sf-map-svg/blob/main/docs/migration-v5.md) before upgrading from v4.
25
+
21
26
  ## Install
22
27
 
23
28
  ```sh
@@ -94,6 +99,33 @@ See [controller options, events, and lifecycle](https://github.com/kahwee/sf-map
94
99
  [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).
95
100
  Call `destroy()` when removing an interactive map.
96
101
 
102
+ ## Geographic queries and screen coordinates
103
+
104
+ New in v5. See the [v5 migration guide](https://github.com/kahwee/sf-map-svg/blob/main/docs/migration-v5.md) for breaking changes.
105
+
106
+ Use the data-free `/geometry` entry to calculate geographic bounds or test which
107
+ polygons contain a location, without constructing a map or requiring a browser:
108
+
109
+ ```ts
110
+ import { geometryBounds, geometryContainsPoint } from '@kahwee/sf-map-svg/geometry';
111
+ import { neighborhoods } from '@kahwee/sf-map-svg/data/realtor';
112
+
113
+ const location = [-122.42, 37.76] as const;
114
+ const matches = neighborhoods.features.filter(({ geometry }) =>
115
+ geometryContainsPoint(geometry, location),
116
+ );
117
+ const bounds = matches[0] ? geometryBounds(matches[0].geometry) : null;
118
+ ```
119
+
120
+ Polygon holes are respected; shared boundaries can match multiple areas.
121
+ Bounds are `[west, south, east, north]` in degrees, not camera coordinates.
122
+ The [query contract](https://github.com/kahwee/sf-map-svg/blob/main/docs/API.md#geographic-queries) covers empty geometry,
123
+ validation, and geographic limitations.
124
+
125
+ For mounted browser maps, `map.projectToScreen(location)` returns canvas-relative
126
+ CSS pixels and viewport visibility. All coordinate inputs now use GeoJSON tuples. See the [overlay recipe](https://github.com/kahwee/sf-map-svg/blob/main/docs/consumer-integration.md#position-html-overlays)
127
+ and [coordinate units](https://github.com/kahwee/sf-map-svg/blob/main/docs/API.md#coordinates-and-units).
128
+
97
129
  ## Documentation for coding assistants
98
130
 
99
131
  [Developer guide](https://github.com/kahwee/sf-map-svg/blob/main/docs/developer-guide.md) · [llms.txt index](https://github.com/kahwee/sf-map-svg/blob/main/llms.txt) · [Complete text documentation](https://github.com/kahwee/sf-map-svg/blob/main/llms-full.txt). Generated from project docs and TypeScript declarations; run `pnpm docs:llms` after changing those inputs.
@@ -220,7 +252,7 @@ pnpm benchmark:markers --counts=2000 --mode=neighborhoods \
220
252
 
221
253
  ---
222
254
 
223
- <!-- Generated from docs/developer-guide.md; @kahwee/sf-map-svg 4.5.0 · local working tree. -->
255
+ <!-- Generated from docs/developer-guide.md; @kahwee/sf-map-svg 5.0.0 · local working tree. -->
224
256
 
225
257
  # Developer guide
226
258
 
@@ -234,6 +266,7 @@ Choose the renderer and geography separately. `renderMap` produces a string with
234
266
  | Explore districts or several neighborhood definitions | `/map`: `createMap` | `/data/full`: `fullMapData` |
235
267
  | Embed a smaller neighborhood guide | `/guide/map`: `createGuideController` | Included lightweight guide geography |
236
268
  | Use the guide data with the general controller | `/map`: `createMap` | `/guide/data`: `guideMapData` |
269
+ | Query bounds or polygon containment without a DOM | `/geometry` | Caller-supplied geometry or explicit dataset imports |
237
270
  | Look up names without mounting a map | `/data/lookup` | Source-aware lookup helpers |
238
271
 
239
272
  Imports starting with `/` in this table are package suffixes, such as `@kahwee/sf-map-svg/static`. Node 24+ is required for server rendering. Browser examples need a DOM and a bundler that supports JSON modules. This is an ESM package.
@@ -320,22 +353,43 @@ SFAR realtor neighborhoods are the default when supplied. If only an alternative
320
353
  | Camera | `map.camera.set/pan/zoom/fit/reset(...)` |
321
354
  | Palette, typography, control styling | `map.configure({ appearance })`; retains camera, selection and focus |
322
355
 
323
- Appearance token objects merge by key; `undefined` removes an override and `appearance: undefined` resets the group. Static maps accept grouped `layers` and the shared `appearance` keys (`theme`, `colors`, `districtStyle`) too. See [the API contract](https://github.com/kahwee/sf-map-svg/blob/main/docs/API.md#live-configuration-and-inspection) and [the design playground](https://kahwee.github.io/sf-map-svg/playground.html).
324
-
325
- `configure` merges supplied keys; omitted keys keep their existing values. Set a layer or control key to `undefined` to remove its override. Set a whole group to `undefined` to reset that group. Set a feature to `false` to disable it. Explicit layer overrides keep winning after mode changes.
356
+ Controller patches merge nested token and feature records. Omitted and `undefined`
357
+ values retain existing values. Use `false` to disable a boolean switch and
358
+ `resetConfiguration()` to clear presentation overrides explicitly:
326
359
 
327
360
  ```ts
328
361
  map.configure({ layers: { neighborhoodLabels: false } });
329
- map.configure({ layers: { neighborhoodLabels: undefined } }); // follow mode again
330
362
  map.configure({ features: { motion: false } });
331
- map.configure({ layers: undefined }); // clear all explicit layer overrides
363
+ map.resetConfiguration(['layers']); // follow mode-derived defaults again
364
+ map.resetConfiguration(['appearance']); // built-in palette and typography
332
365
  ```
333
366
 
334
- `getConfiguration()` returns current mode/source/year/labels and a detached snapshot of the presentation groups, including explicit layer and appearance overrides. Camera and selection remain separate runtime state. Use `getResolvedConfiguration()` for current mode/source/year/labels and effective layer switches, and `getCapabilities()` for supplied geography. Invalid configuration patches leave the current map unchanged. Convenience methods use the same atomic update path: pass `map.setMode(mode, { resetView: false })` or `map.setSource(source, { resetView: false })` to keep the camera. Their default still resets the view.
367
+ Reset groups are `features`, `layers`, `controls`, and `appearance`; omitting
368
+ the argument resets all four. Resets preserve geographic choices and camera,
369
+ and do not restore constructor overrides. Static maps accept grouped layers and
370
+ the shared appearance keys (`theme`, `colors`, `districtStyle`) at construction.
371
+ See [the API contract](https://github.com/kahwee/sf-map-svg/blob/main/docs/API.md#live-configuration-and-inspection).
372
+
373
+ `getConfiguration()` returns a detached snapshot of geographic choices and
374
+ presentation overrides. Use `getResolvedConfiguration()` for effective layers
375
+ and `getCapabilities()` for supplied geography. Invalid patches preserve state.
376
+ `setMode`, `setSource`, and `configure` all preserve the camera; request movement
377
+ with `camera.reset()` or `camera.fit(geometry)`. See the
378
+ [v5 migration guide](https://github.com/kahwee/sf-map-svg/blob/main/docs/migration-v5.md) for changed call forms.
379
+
380
+ ## Geographic queries
381
+
382
+ The v5 `/geometry` helpers `geometryBounds(geometry)` and
383
+ `geometryContainsPoint(geometry, position)` work without a DOM or bundled
384
+ geography. Import `Bounds`, `Geometry`, and `Position` types from the same entry.
385
+ Use containment for area lookup and bounds for geographic extents; pass the
386
+ original geometry to `camera.fit`, rather than geographic bounds to `camera.set`.
387
+ See the [worked example](https://github.com/kahwee/sf-map-svg/blob/main/docs/EXAMPLES.md#find-areas-containing-a-location) and
388
+ [full query semantics](https://github.com/kahwee/sf-map-svg/blob/main/docs/API.md#geographic-queries).
335
389
 
336
390
  ## Markers, routes and camera coordinates
337
391
 
338
- Marker positions are `{ lng, lat }`. GeoJSON coordinates and the static projection use `[longitude, latitude]`, in WGS84. Camera viewports use `[x, y, size]` in the fixed 800 × 800 projected map space; they are not geographic bounds. `camera.pan` uses projected units. `projectToScreen(lng, lat)` returns pixels relative to the map canvas, plus visibility.
392
+ Markers use `position: [longitude, latitude]`. GeoJSON coordinates and the static projection use `[longitude, latitude]`, in WGS84. Camera viewports use `[x, y, size]` in the fixed 800 × 800 projected map space; they are not geographic bounds. `camera.pan` takes deltas in CSS pixels and converts them using the mounted canvas width. `projectToScreen([lng, lat])` returns CSS pixels relative to the map canvas, plus viewport visibility. The former two-number call is removed in v5.
339
393
 
340
394
  Marker and overlay IDs must be unique. Keep marker IDs stable across updates so retained markers preserve DOM nodes, focus and entrance animations. Use `null` to clear a selection; selection methods return a boolean. Selection events can contain null values. `map.on()` returns an unsubscribe function.
341
395
 
@@ -364,7 +418,7 @@ See [API options](https://github.com/kahwee/sf-map-svg/blob/main/docs/API.md), [
364
418
 
365
419
  ---
366
420
 
367
- <!-- Generated from docs/API.md; @kahwee/sf-map-svg 4.5.0 · local working tree. -->
421
+ <!-- Generated from docs/API.md; @kahwee/sf-map-svg 5.0.0 · local working tree. -->
368
422
 
369
423
  # Map options and lifecycle
370
424
 
@@ -372,6 +426,41 @@ Start with the [static and interactive examples](https://github.com/kahwee/sf-ma
372
426
  and event types are exported by [src/api.ts](https://github.com/kahwee/sf-map-svg/blob/main/src/api.ts) and defined in
373
427
  [src/types.ts](https://github.com/kahwee/sf-map-svg/blob/main/src/types.ts) and [src/controller-types.ts](https://github.com/kahwee/sf-map-svg/blob/main/src/controller-types.ts).
374
428
 
429
+ ## Geographic queries
430
+
431
+ New in v5. See the [migration guide](https://github.com/kahwee/sf-map-svg/blob/main/docs/migration-v5.md) for upgrade steps.
432
+
433
+ The data-free `@kahwee/sf-map-svg/geometry` entry exports `geometryBounds(geometry)`
434
+ and `geometryContainsPoint(geometry, [longitude, latitude])`. They work offline
435
+ with the same readonly GeoJSON geometries used by the renderer, without a DOM.
436
+ The same entry exports the `Geometry`, `Position`, and `Bounds` types, so geometry
437
+ consumers need no second import path for their function input and result types.
438
+
439
+ ```ts
440
+ import { geometryBounds, geometryContainsPoint } from '@kahwee/sf-map-svg/geometry';
441
+ import { neighborhoods } from '@kahwee/sf-map-svg/data/realtor';
442
+
443
+ const location = [-122.42, 37.76] as const;
444
+ const matches = neighborhoods.features.filter((feature) =>
445
+ geometryContainsPoint(feature.geometry, location),
446
+ );
447
+ const bounds = matches[0] ? geometryBounds(matches[0].geometry) : null;
448
+ ```
449
+
450
+ Bounds are `[west, south, east, north]` in degrees, computed from coordinates,
451
+ not cached feature metadata. Empty or absent geometry returns `null`.
452
+ Hit testing includes Polygon, MultiPolygon, and polygon members of nested
453
+ GeometryCollections. Hole interiors are excluded; outer and hole boundaries
454
+ are included, so a shared boundary may match multiple features. Ring winding
455
+ does not matter; open rings are closed implicitly. Other geometry types do
456
+ not produce polygon hits. Empty or absent geometry returns `false`.
457
+
458
+ Both helpers reject nonfinite or out-of-range longitude/latitude with
459
+ `RangeError`; altitude is ignored. They assume valid planar GeoJSON topology
460
+ and straight segments in longitude/latitude, suitable for these SF datasets.
461
+ They do not repair polygons, apply a boundary tolerance, or unwrap antimeridian
462
+ crossings. A bounds match alone does not establish polygon containment.
463
+
375
464
  ## Static maps
376
465
 
377
466
  Static maps accept compact `StaticMapData` or the same source-aware `MapData` as interactive maps. With `MapData`, `source` selects a supplied neighborhood definition; SFAR realtor is preferred by default, followed by SF Find and analysis when available. A requested unavailable source throws before styling callbacks run. Compact `StaticMapData` already chooses its neighborhoods, so it rejects a `source` option. Both data shapes retain the static renderer defaults.
@@ -453,16 +542,16 @@ District style callbacks are evaluated once per district at construction and on
453
542
 
454
543
  ## Live configuration and inspection
455
544
 
456
- `map.configure({ appearance, mode, source, year, labels, layers, features, controls })` updates an existing map. Appearance updates preserve the camera, selections, retained marker nodes, and keyboard focus. Mode/source changes through `configure` preserve the camera; the `setMode(mode, { resetView: false })` and `setSource(source, { resetView: false })` convenience methods use the same atomic update path and preserve the camera. Omit the second argument to retain their existing reset behavior. Invalid view-update options fail before changing configuration. Switching neighborhood source clears the neighborhood selection. Changing district year retains a selected district when it exists in the new year. Unsupported datasets, invalid appearance values, and invalid district-style callback results fail during preparation before commit. A reentrant styling callback can supersede the pending patch.
545
+ `map.configure({ appearance, mode, source, year, labels, layers, features, controls })` updates an existing map. Appearance updates preserve the camera, selections, retained marker nodes, and keyboard focus. Mode/source changes through `configure`, `setMode(mode)`, and `setSource(source)` preserve the camera. Use `map.camera.reset()` or `map.camera.fit(geometry)` when movement is intended. The former `{ resetView }` setter argument is removed in v5. Switching neighborhood source clears the neighborhood selection. Changing district year retains a selected district when it exists in the new year. Unsupported datasets, invalid appearance values, and invalid district-style callback results fail during preparation before commit. A reentrant styling callback can supersede the pending patch.
457
546
 
458
- Appearance token objects (`colors`, `style`, `labelStyle`, `labelSize`, `areaStyle`) merge supplied keys; other appearance properties replace. Omitted keys retain their value, an explicit `undefined` property removes that override, and `appearance: undefined` resets the whole appearance group. Feature objects retain their existing replacement semantics. Mode/source/year/labels ignore `undefined`; reset them with an explicit value. Patch types explicitly permit these resets with TypeScript’s `exactOptionalPropertyTypes` enabled.
547
+ Controller patches merge nested token and feature records. Omitted and `undefined` values retain current values at every depth; `false` disables boolean switches. Use `resetConfiguration(groups?)` to remove presentation overrides explicitly. The supported groups are `features`, `layers`, `controls`, and `appearance`; omitting the argument resets all four. Resets restore built-in defaults, preserve mode/source/year/labels and camera, and do not restore constructor overrides. Unknown groups fail before changing state. Static construction options and low-level element methods retain their separately documented contracts.
459
548
 
460
549
  ```ts
461
550
  map.configure({
462
551
  appearance: { colors: { water: '#142d45' }, labelStyle: { fontFamily: 'Georgia,serif' } },
463
552
  layers: { bartStations: false },
464
553
  });
465
- map.configure({ appearance: undefined }); // restore construction defaults, not initial overrides
554
+ map.resetConfiguration(['appearance']); // restore built-in defaults
466
555
 
467
556
  const overrides = map.getConfiguration();
468
557
  const effective = map.getResolvedConfiguration();
@@ -494,11 +583,14 @@ map.selectFeature({ kind: 'neighborhood', source: 'realtor', id: 'inner-mission'
494
583
  map.selectFeature({ kind: 'district', year: 2022, id: 3 });
495
584
  ```
496
585
 
497
- Neighborhood IDs are exact source-scoped feature IDs, rather than display names or aliases. District identity includes the boundary year. This operation returns `false` for a missing feature or a source/year other than the map's current supplied geography; it never silently switches definitions or years. Choose geography through `configure` first. An `id` of `null` clears that kind in the referenced current geography. Malformed references throw. Existing `selectMarker`, `selectNeighborhood`, and `selectDistrict` shortcuts remain supported.
586
+ Neighborhood IDs are exact source-scoped feature IDs, rather than display names or aliases. District identity includes the boundary year. This operation returns `false` for a missing feature or a source/year other than the map's current supplied geography; it never silently switches definitions or years. Choose geography through `configure` first. An `id` of `null` clears that kind in the referenced current geography. Malformed references throw. The `selectMarker`, `selectNeighborhood`, and `selectDistrict` shortcuts select exact IDs in the current geography. Controller `selectNeighborhood` no longer resolves display names or aliases; resolve those with a source-specific data lookup first.
498
587
 
499
588
  ## Coordinates and units
500
589
 
501
- 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.
590
+ Version 5 uses tuple inputs and exports the named `ScreenPoint` /
591
+ `ProjectToScreen` types. The former two-number call is removed.
592
+
593
+ GeoJSON and static projection inputs use `[longitude, latitude]` in WGS84; markers use `{ position: [longitude, latitude] }`. Static `project` returns SVG units. `projectToScreen([longitude, latitude])` accepts the same readonly GeoJSON position as static `project` and returns `ScreenPoint` (`{ x, y, visible }`) in CSS pixels relative to the mounted canvas. Altitude is ignored. Mount the map before calling; invalid Mercator coordinates throw, and calls after controller destruction throw. `visible` indicates viewport inclusion, not whether another element covers the point. 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.
502
594
 
503
595
  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.
504
596
 
@@ -512,7 +604,7 @@ Call `binding.setMarkers(next)` after `map.setMarkers(next)` when the catalog ch
512
604
 
513
605
  ---
514
606
 
515
- <!-- Generated from docs/EXAMPLES.md; @kahwee/sf-map-svg 4.5.0 · local working tree. -->
607
+ <!-- Generated from docs/EXAMPLES.md; @kahwee/sf-map-svg 5.0.0 · local working tree. -->
516
608
 
517
609
  # Examples
518
610
 
@@ -622,9 +714,38 @@ See [consumer integration](https://github.com/kahwee/sf-map-svg/blob/main/docs/c
622
714
 
623
715
  For the controller and explicit data imports, see [the v3 migration guide](https://github.com/kahwee/sf-map-svg/blob/main/docs/migration-v3.md).
624
716
 
717
+ ## Find areas containing a location
718
+
719
+ New in v5. This example runs in
720
+ Node or a browser without mounting a map and imports only the SFAR definition:
721
+
722
+ ```ts
723
+ import { geometryBounds, geometryContainsPoint } from '@kahwee/sf-map-svg/geometry';
724
+ import { neighborhoods } from '@kahwee/sf-map-svg/data/realtor';
725
+
726
+ const location = [-122.42, 37.76] as const;
727
+ const matches = neighborhoods.features.filter((feature) =>
728
+ geometryContainsPoint(feature.geometry, location),
729
+ );
730
+ for (const feature of matches) {
731
+ console.log(feature.id, feature.properties.canonicalName, geometryBounds(feature.geometry));
732
+ }
733
+ ```
734
+
735
+ Keep all matches when the location lies on a shared boundary. An empty result
736
+ means no supplied polygon contains the location; it does not trigger a switch
737
+ to another neighborhood definition. For an existing map using the same realtor
738
+ source, pass a chosen match to
739
+ `map.selectFeature({ kind: 'neighborhood', source: 'realtor', id: feature.id })`.
740
+ To fit it, use `map.camera.fit(feature.geometry)`. Geographic bounds cannot be
741
+ passed directly to `camera.set`, which takes projected `[x, y, size]`.
742
+
743
+ See [query semantics and limitations](https://github.com/kahwee/sf-map-svg/blob/main/docs/API.md#geographic-queries) and the
744
+ [HTML overlay recipe](https://github.com/kahwee/sf-map-svg/blob/main/docs/consumer-integration.md#position-html-overlays).
745
+
625
746
  ---
626
747
 
627
- <!-- Generated from docs/consumer-integration.md; @kahwee/sf-map-svg 4.5.0 · local working tree. -->
748
+ <!-- Generated from docs/consumer-integration.md; @kahwee/sf-map-svg 5.0.0 · local working tree. -->
628
749
 
629
750
  # Embedding and progressive enhancement
630
751
 
@@ -663,7 +784,7 @@ For a fixed-width embed, use shared inputs on the server and in the browser:
663
784
  ```ts
664
785
  // Shared inputs: coordinates, IDs, radii, colors and layer choices must match.
665
786
  const guideMarkers = [
666
- { id: 'ferry', label: 'Ferry Building', lng: -122.3937, lat: 37.7955, radius: 7 },
787
+ { id: 'ferry', label: 'Ferry Building', position: [-122.3937, 37.7955] as const, radius: 7 },
667
788
  ];
668
789
  const guideLayers = { neighborhoodLabels: true, roadLabels: true };
669
790
  const guideColors = { label: '#183e58', landmark: '#426641' };
@@ -754,9 +875,160 @@ Style `[data-map-selected]` with an outline or background change that remains le
754
875
 
755
876
  Run `pnpm demo` and open `examples/generated/linked-cards.html` for the complete pattern. Storybook **Maps / Linked place cards** checks hover, keyboard selection, card reveal, both palettes, and disposal.
756
877
 
878
+ ## Position HTML overlays
879
+
880
+ In v5, pass the same readonly `[longitude, latitude]` position used by static
881
+ projection. The former two-number call is removed; see the
882
+ [migration guide](https://github.com/kahwee/sf-map-svg/blob/main/docs/migration-v5.md).
883
+
884
+ Call this helper after mounting the map in a container with nonzero width:
885
+
886
+ ```ts
887
+ import type { MapController } from '@kahwee/sf-map-svg';
888
+ import type { Position } from '@kahwee/sf-map-svg/geometry';
889
+
890
+ function attachCallout(map: MapController, location: Position, label: string) {
891
+ const callout = document.createElement('span');
892
+ callout.textContent = label;
893
+ callout.style.cssText = 'position:absolute;pointer-events:none;transform:translate(-50%,-100%)';
894
+ const update = () => {
895
+ const point = map.projectToScreen(location);
896
+ callout.hidden = !point.visible;
897
+ callout.style.left = `${point.x}px`;
898
+ callout.style.top = `${point.y}px`;
899
+ };
900
+ update();
901
+ map.overlayElement.append(callout);
902
+ const stopViewport = map.on('viewportchange', update);
903
+ const stopResize = map.on('mapresize', update);
904
+ return () => {
905
+ stopViewport();
906
+ stopResize();
907
+ callout.remove();
908
+ };
909
+ }
910
+ ```
911
+
912
+ Use `const detach = attachCallout(map, [-122.3937, 37.7955], 'Ferry Building')`
913
+ with your mounted controller. Call `detach()` before `map.destroy()` on unmount.
914
+ Keep the container visible while projecting; an unmounted or zero-width canvas
915
+ throws. Controller calls after destruction also throw. Camera moves and resize
916
+ both require repositioning; a one-time projection becomes stale.
917
+
918
+ `x` and `y` are CSS pixels relative to `overlayElement`, not page coordinates or
919
+ SVG units. `visible` tests inclusion in the map viewport; it does not account
920
+ for occlusion by other content. This noninteractive label uses `textContent`
921
+ and leaves pointer input to the map. Interactive callouts need their own
922
+ keyboard, focus, and dismissal behavior. See [coordinates and units](https://github.com/kahwee/sf-map-svg/blob/main/docs/API.md#coordinates-and-units).
923
+
757
924
  ---
758
925
 
759
- <!-- Generated from docs/migration-v3.md; @kahwee/sf-map-svg 4.5.0 · local working tree. -->
926
+ <!-- Generated from docs/migration-v5.md; @kahwee/sf-map-svg 5.0.0 · local working tree. -->
927
+
928
+ # Migrating to v5
929
+
930
+ Version 5.0.0 makes coordinate inputs, controller updates, and reset behavior
931
+ consistent. It requires Node 24+ and retains offline rendering, explicit data
932
+ imports, zero runtime dependencies, and the existing default map appearance.
933
+ The version bump prepares the next release; it does not publish the package.
934
+
935
+ ## Coordinates use GeoJSON positions
936
+
937
+ Markers now require `position: [longitude, latitude]`. The old `lng` and `lat`
938
+ properties are rejected. `projectToScreen` takes one readonly position; its
939
+ old two-number form is removed. Altitude is accepted and ignored by projection.
940
+
941
+ ```ts
942
+ // v4
943
+ const oldMarker = { id: 'ferry', lng: -122.3937, lat: 37.7955 };
944
+ // v5
945
+ const marker = { id: 'ferry', position: [-122.3937, 37.7955] as const };
946
+ map.setMarkers([marker]);
947
+ const point = map.projectToScreen(marker.position);
948
+ ```
949
+
950
+ Use `Position` from `/geometry` for reusable positions and `ScreenPoint` or
951
+ `ProjectToScreen` from the root or `/map` for screen-projection contracts.
952
+ `x` and `y` remain canvas-relative CSS pixels; `visible` means inside the
953
+ viewport, not unobscured. Mount the map in a nonzero-width container first.
954
+ Camera viewports still use projected `[x, y, size]`; camera pan deltas are CSS
955
+ pixels. Geographic bounds are not camera viewports.
956
+
957
+ ## Controller updates preserve the camera
958
+
959
+ `configure`, `setMode`, and `setSource` preserve the camera. The second
960
+ `{ resetView }` argument to the controller convenience setters is removed.
961
+ To reproduce an old automatic reset, request it explicitly:
962
+
963
+ ```ts
964
+ map.setMode('neighborhoods');
965
+ map.camera.reset({ animate: false });
966
+ ```
967
+
968
+ Selection still fits by default. Use `{ fit: false }` to change selection
969
+ without moving the camera, or `camera.fit(geometry)` for an explicit fit.
970
+
971
+ ## Reset presentation explicitly
972
+
973
+ In controller `configure` patches, omitted and `undefined` values are no-ops at
974
+ every record depth. Nested token and feature records merge; an explicit boolean
975
+ replaces a feature record and `false` disables a boolean switch.
976
+
977
+ ```ts
978
+ map.configure({ appearance: { colors: { water: '#142d45' } } });
979
+ map.configure({ appearance: undefined }); // keeps the current appearance
980
+ map.resetConfiguration(['appearance']); // restores built-in appearance defaults
981
+ map.resetConfiguration(['layers', 'controls']); // removes explicit overrides
982
+ map.resetConfiguration(); // resets all four presentation groups
983
+ ```
984
+
985
+ The reset groups are `features`, `layers`, `controls`, and `appearance`.
986
+ Resetting a group does not restore initial constructor overrides, change the
987
+ camera, or change mode/source/year/labels. It restores built-in defaults and
988
+ mode-derived layer choices. Supply geographic choices explicitly through
989
+ `configure`. To remove a single token while retaining other overrides, build
990
+ the desired group, reset that group, then configure the retained values.
991
+ Each call commits atomically; the two calls are separate updates.
992
+
993
+ These rules describe `MapController` from `createMap`, `createGuideController`,
994
+ and `mountGuideController`. Low-level element factories retain their separate
995
+ `setFeatures` / `setLayers` APIs; use controllers for the grouped update contract.
996
+
997
+ ## Select neighborhoods by exact identity
998
+
999
+ Controller `selectNeighborhood(id)` accepts an exact ID in the current source,
1000
+ rather than a display name or alias. Unknown IDs return `false` without changing
1001
+ selection. Resolve a human-entered name using the source-specific lookup first.
1002
+ For integrations, prefer the source-qualified identity:
1003
+
1004
+ ```ts
1005
+ import { getRealtorNeighborhood } from '@kahwee/sf-map-svg/data/realtor';
1006
+
1007
+ const feature = getRealtorNeighborhood('Inner Mission');
1008
+ if (feature) {
1009
+ map.selectFeature({ kind: 'neighborhood', source: 'realtor', id: feature.id }, { fit: false });
1010
+ }
1011
+ ```
1012
+
1013
+ `selectFeature` requires the current supplied source/year and never switches
1014
+ geography implicitly. Marker IDs and numeric district IDs remain unchanged.
1015
+ The construction-time `neighborhood` option and low-level element lookup APIs
1016
+ continue to resolve names; the stricter ID contract applies to controller
1017
+ selection methods. Existing selection event names remain available alongside
1018
+ `selectionchange`.
1019
+
1020
+ ## Offline geometry queries
1021
+
1022
+ `geometryBounds(geometry)` and `geometryContainsPoint(geometry, position)` are
1023
+ new in v5. They accept readonly GeoJSON without a DOM or geographic imports.
1024
+ Bounds are `[west, south, east, north]` or `null` for empty geometry. Containment
1025
+ respects holes, includes polygon boundaries, and supports islands and nested
1026
+ collections. Shared edges can match more than one feature. See the
1027
+ [query contract](https://github.com/kahwee/sf-map-svg/blob/main/docs/API.md#geographic-queries) for validation and planar limitations.
1028
+
1029
+ ---
1030
+
1031
+ <!-- Generated from docs/migration-v3.md; @kahwee/sf-map-svg 5.0.0 · local working tree. -->
760
1032
 
761
1033
  # Migrate to version 3
762
1034
 
@@ -797,7 +1069,7 @@ District styles are now prepared once per district on construction and style/yea
797
1069
 
798
1070
  ---
799
1071
 
800
- <!-- Generated from SOURCES.md; @kahwee/sf-map-svg 4.5.0 · local working tree. -->
1072
+ <!-- Generated from SOURCES.md; @kahwee/sf-map-svg 5.0.0 · local working tree. -->
801
1073
 
802
1074
  # Geometry and provenance
803
1075
 
@@ -981,7 +1253,7 @@ display geometry, and no precinct polygons or raw workbook data are shipped.
981
1253
 
982
1254
  ---
983
1255
 
984
- <!-- Generated from CHANGELOG.md; @kahwee/sf-map-svg 4.5.0 · local working tree. -->
1256
+ <!-- Generated from CHANGELOG.md; @kahwee/sf-map-svg 5.0.0 · local working tree. -->
985
1257
 
986
1258
  # Changelog
987
1259
 
@@ -989,6 +1261,17 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
989
1261
 
990
1262
  ## Unreleased
991
1263
 
1264
+ ## 5.0.0 — 2026-10-19
1265
+
1266
+ - Breaking: controller `setMode` and `setSource` preserve the camera; remove their `{ resetView }` argument. Call `camera.reset()` or `camera.fit()` explicitly.
1267
+ - Breaking: controller `configure` treats all omitted and undefined values as no-ops and merges nested feature records. Add `resetConfiguration(groups?)` to clear presentation overrides explicitly.
1268
+ - Breaking: controller `selectNeighborhood` selects exact IDs in the current source; resolve names through source-specific data lookup or use source/year-qualified `selectFeature`.
1269
+ - Include a [v5 migration guide](https://github.com/kahwee/sf-map-svg/blob/main/docs/migration-v5.md), migrated consumer examples, and regression checks. Retain Node 24+, offline rendering, zero runtime dependencies, geographic data, and default appearance.
1270
+
1271
+ - Add offline `geometryBounds()` and `geometryContainsPoint()` helpers to `/geometry`, with readonly inputs, polygon holes and islands, nested collections, explicit boundary semantics, and coordinate validation. Export `Bounds`, `Geometry`, and `Position` from the same entry.
1272
+ - Breaking: markers now require `position: [longitude, latitude]`; remove the numeric `projectToScreen(lng, lat)` form in favor of one readonly GeoJSON position. Export shared `ScreenPoint` and `ProjectToScreen` types and cover camera changes, resizing, invalid coordinates, and controller disposal in browser regressions.
1273
+ - Document geographic queries, screen-coordinate integration, and API consistency follow-ups. Correct the developer guide: camera pan deltas are CSS pixels, not projected map units.
1274
+
992
1275
  ## 4.5.0 — 2026-10-14
993
1276
 
994
1277
  - Add the optional `/cards` entry with `bindMapCards()`: hover/focus previews a pin without changing selection or camera, native buttons select places, map selection reveals matching cards without stealing focus, and explicit cleanup restores owned DOM attributes.
@@ -1347,7 +1630,7 @@ consistent home, and makes animation and subscription ownership predictable.
1347
1630
 
1348
1631
  # Published type contracts
1349
1632
 
1350
- @kahwee/sf-map-svg 4.5.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.
1633
+ @kahwee/sf-map-svg 5.0.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.
1351
1634
 
1352
1635
  | Public import | Declaration |
1353
1636
  | --- | --- |
@@ -1393,7 +1676,7 @@ import type { MapController, MapOptions } from './controller-types.js';
1393
1676
  import type { InteractiveSFMapData } from './explorer-data.js';
1394
1677
  export type * from './controller-types.js';
1395
1678
  export type { InteractiveSFMapData as MapData } from './explorer-data.js';
1396
- export type { CameraOptions, DistrictSelection, DistrictStyle, DistrictYear, InteractiveLayers, MapFeatures, MapMarker, MapOverlay, MapPadding, MapViewport, NeighborhoodSelection, } from './types.js';
1679
+ export type { CameraOptions, DistrictSelection, DistrictStyle, DistrictYear, InteractiveLayers, MapFeatures, MapMarker, MapOverlay, MapPadding, MapViewport, NeighborhoodSelection, ProjectToScreen, ScreenPoint, } from './types.js';
1397
1680
  /** No geography is imported. Supply a preset or your own immutable data. */
1398
1681
  export declare function createMap(data: InteractiveSFMapData, options?: MapOptions): MapController;
1399
1682
  ```
@@ -1402,8 +1685,8 @@ export declare function createMap(data: InteractiveSFMapData, options?: MapOptio
1402
1685
 
1403
1686
  ```ts
1404
1687
  import type { Geometry, NeighborhoodSource } from '../data/types.js';
1405
- import type { CameraOptions, DistrictSelection, DistrictStyle, DistrictYear, InteractiveLayers, MapFeatures, MapMarker, MapOverlay, MapPadding, MapViewport, NeighborhoodExplorerOptions, NeighborhoodSelection } from './types.js';
1406
- /** Patch properties can explicitly remove overrides, including with exactOptionalPropertyTypes. */
1688
+ import type { CameraOptions, DistrictSelection, DistrictStyle, DistrictYear, InteractiveLayers, MapFeatures, MapMarker, MapOverlay, MapPadding, MapViewport, NeighborhoodExplorerOptions, NeighborhoodSelection, ProjectToScreen } from './types.js';
1689
+ /** Explicit undefined is accepted as a no-op, including with exactOptionalPropertyTypes. */
1407
1690
  type Resettable<T> = {
1408
1691
  [K in keyof T]?: T[K] | undefined;
1409
1692
  };
@@ -1412,7 +1695,7 @@ export type MapAppearance = {
1412
1695
  [K in keyof AppearanceOptions]?: K extends 'colors' | 'style' | 'labelStyle' | 'labelSize' | 'areaStyle' ? Resettable<NonNullable<AppearanceOptions[K]>> | undefined : AppearanceOptions[K] | undefined;
1413
1696
  };
1414
1697
  export type MapControls = NonNullable<NeighborhoodExplorerOptions['controls']>;
1415
- /** Omitted keys retain values. Undefined presentation groups reset; undefined mode/source/year/labels retain their current value. */
1698
+ /** Omitted and undefined values retain current values. Use resetConfiguration to clear overrides. */
1416
1699
  export interface MapConfiguration {
1417
1700
  features?: Resettable<MapFeatures> | undefined;
1418
1701
  layers?: Resettable<InteractiveLayers> | undefined;
@@ -1518,10 +1801,7 @@ export interface MapEvents {
1518
1801
  };
1519
1802
  mapresize: undefined;
1520
1803
  }
1521
- /** Convenience setters retain their historic reset default; false preserves the camera. */
1522
- export interface MapViewUpdateOptions {
1523
- resetView?: boolean;
1524
- }
1804
+ export type MapConfigurationGroup = 'features' | 'layers' | 'controls' | 'appearance';
1525
1805
  export interface MapCamera {
1526
1806
  /** Fixed 800 × 800 projected space, not longitude/latitude. */
1527
1807
  get(): MapViewport;
@@ -1542,6 +1822,8 @@ export interface MapController {
1542
1822
  readonly camera: MapCamera;
1543
1823
  readonly destroyed: boolean;
1544
1824
  configure(patch: MapConfiguration): void;
1825
+ /** Clear presentation overrides to built-in defaults, preserving camera and geographic choices. */
1826
+ resetConfiguration(groups?: readonly MapConfigurationGroup[]): void;
1545
1827
  getConfiguration(): MapConfigurationSnapshot;
1546
1828
  getResolvedConfiguration(): ResolvedMapConfiguration;
1547
1829
  getCapabilities(): MapCapabilities;
@@ -1557,7 +1839,7 @@ export interface MapController {
1557
1839
  fit?: boolean;
1558
1840
  }): boolean;
1559
1841
  getSelectedMarker(): MapMarker | null;
1560
- selectNeighborhood(name: string | null, options?: CameraOptions & {
1842
+ selectNeighborhood(id: string | null, options?: CameraOptions & {
1561
1843
  fit?: boolean;
1562
1844
  }): boolean;
1563
1845
  getSelectedNeighborhood(): NeighborhoodSelection | null;
@@ -1567,15 +1849,12 @@ export interface MapController {
1567
1849
  getSelectedDistrict(): DistrictSelection | null;
1568
1850
  setDistrictYear(year: DistrictYear, options?: CameraOptions): void;
1569
1851
  setDistrictStyle(style: ((district: DistrictSelection['district']) => DistrictStyle) | undefined): void;
1570
- setSource(source: NeighborhoodSource, options?: MapViewUpdateOptions): void;
1571
- setMode(mode: NonNullable<MapOptions['mode']>, options?: MapViewUpdateOptions): void;
1852
+ setSource(source: NeighborhoodSource): void;
1853
+ setMode(mode: NonNullable<MapOptions['mode']>): void;
1572
1854
  setLabels(visible: boolean): void;
1573
1855
  setTouchNavigation(enabled: boolean): void;
1574
- projectToScreen(lng: number, lat: number): {
1575
- x: number;
1576
- y: number;
1577
- visible: boolean;
1578
- };
1856
+ /** Accepts a GeoJSON position; returns canvas-relative CSS pixels. */
1857
+ projectToScreen: ProjectToScreen;
1579
1858
  /** Idempotent; stops work and managed subscriptions. Does not remove host-owned DOM. */
1580
1859
  destroy(): void;
1581
1860
  }
@@ -1718,7 +1997,15 @@ export interface KeyRoadProperties {
1718
1997
  ### dist/src/types.d.ts
1719
1998
 
1720
1999
  ```ts
1721
- import type { Geometry, NeighborhoodFeature, NeighborhoodSource } from '../data/types.js';
2000
+ import type { Geometry, NeighborhoodFeature, NeighborhoodSource, Position } from '../data/types.js';
2001
+ /** Canvas-relative CSS pixels; visibility means inside the current square map viewport. */
2002
+ export interface ScreenPoint {
2003
+ x: number;
2004
+ y: number;
2005
+ visible: boolean;
2006
+ }
2007
+ /** WGS84 longitude/latitude; altitude, if supplied in a position, is ignored. */
2008
+ export type ProjectToScreen = (position: Position) => ScreenPoint;
1722
2009
  export type DistrictYear = 2002 | 2012 | 2022;
1723
2010
  export interface DistrictSelection {
1724
2011
  id: number;
@@ -1732,8 +2019,7 @@ export interface DistrictStyle {
1732
2019
  }
1733
2020
  export interface MapMarker {
1734
2021
  id: string;
1735
- lng: number;
1736
- lat: number;
2022
+ position: Position;
1737
2023
  label?: string;
1738
2024
  selected?: boolean;
1739
2025
  color?: string;
@@ -1940,11 +2226,7 @@ export interface CameraOptions {
1940
2226
  export interface NeighborhoodExplorerElement extends HTMLElement {
1941
2227
  /** Append positioned HTML children here; coordinates are relative to this layer. */
1942
2228
  readonly overlayElement: HTMLDivElement;
1943
- projectToScreen(lng: number, lat: number): {
1944
- x: number;
1945
- y: number;
1946
- visible: boolean;
1947
- };
2229
+ projectToScreen: ProjectToScreen;
1948
2230
  stopAnimation(): void;
1949
2231
  /** Atomic patch; false disables, undefined resets a feature to its default. */
1950
2232
  setFeatures(patch: MapFeatures): void;
@@ -1988,7 +2270,7 @@ export interface NeighborhoodExplorerElement extends HTMLElement {
1988
2270
  export interface TransitAnimationElement extends HTMLElement {
1989
2271
  destroy(): void;
1990
2272
  }
1991
- export type { MapAppearance, MapCamera, MapCapabilities, MapConfiguration, MapConfigurationSnapshot, MapController, MapControls, MapEvents, MapFeatureReference, MapOptions, MapSelectionChange, MapViewUpdateOptions, ResolvedMapConfiguration, } from './controller-types.js';
2273
+ export type { MapAppearance, MapCamera, MapCapabilities, MapConfiguration, MapConfigurationGroup, MapConfigurationSnapshot, MapController, MapControls, MapEvents, MapFeatureReference, MapOptions, MapSelectionChange, ResolvedMapConfiguration, } from './controller-types.js';
1992
2274
  ```
1993
2275
 
1994
2276
  ### dist/src/explorer-data.d.ts
@@ -2117,11 +2399,16 @@ export declare function createSFMapWithData(options: SFMapOptions, data: SFMapDa
2117
2399
  ### dist/src/geometry.d.ts
2118
2400
 
2119
2401
  ```ts
2120
- import type { Geometry, Position } from '../data/types.js';
2402
+ import type { Bounds, Geometry, Position } from '../data/types.js';
2403
+ export type { Bounds, Geometry, Position } from '../data/types.js';
2121
2404
  export type Project = (position: Position) => [number, number];
2122
2405
  export declare function rawProject([longitude, latitude]: Position): [number, number];
2123
2406
  export declare function positions(geometry: Geometry | null | undefined): Position[];
2124
2407
  export declare function geometryPath(geometry: Geometry | null | undefined, project: Project): string;
2408
+ /** Geographic [west, south, east, north], or null for empty geometry. */
2409
+ export declare function geometryBounds(geometry: Geometry | null | undefined): Bounds | null;
2410
+ /** Polygon hit testing in longitude/latitude; boundaries (including hole edges) count as hits. */
2411
+ export declare function geometryContainsPoint(geometry: Geometry | null | undefined, point: Position): boolean;
2125
2412
  ```
2126
2413
 
2127
2414
  ### dist/src/static.d.ts
@@ -2137,14 +2424,14 @@ export type { DistrictStyle, DistrictYear, SFMapOptions as StaticMapOptions, Sta
2137
2424
  /** Server-safe SVG rendering with explicit data; returns SVG plus projection helpers. */
2138
2425
  export declare function renderMap(data: StaticMapInput, options?: SFMapOptions): {
2139
2426
  svg: string;
2140
- project: (coordinates: import("../data/types.js").Position) => [number, number];
2427
+ project: (coordinates: import("./geometry.js").Position) => [number, number];
2141
2428
  viewBox: [number, number, number, number];
2142
2429
  };
2143
2430
  /** Project canonical layer geometry without constructing an SVG string. */
2144
2431
  export declare function getLayerPaths(data: StaticMapInput, options?: SFMapOptions): {
2145
2432
  year: import("./types.js").DistrictYear;
2146
2433
  viewBox: [number, number, number, number];
2147
- project: (coordinates: import("../data/types.js").Position) => [number, number];
2434
+ project: (coordinates: import("./geometry.js").Position) => [number, number];
2148
2435
  coast: string;
2149
2436
  districts: {
2150
2437
  id: number;
@@ -2359,7 +2646,7 @@ import type { SFMapOptions } from './types.js';
2359
2646
  /** Server-safe overview using exactly the guide browser geography. */
2360
2647
  export declare function createGuideSVG(options?: SFMapOptions): {
2361
2648
  svg: string;
2362
- project: (coordinates: import("../data/types.js").Position) => [number, number];
2649
+ project: (coordinates: import("./geometry.js").Position) => [number, number];
2363
2650
  viewBox: [number, number, number, number];
2364
2651
  };
2365
2652
  /** Stable compact frame, progressively enhanced with mountGuideMap(). No browser globals. */