@kahwee/sf-map-svg 4.4.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +32 -0
  3. package/dist/data/README.md +7 -0
  4. package/dist/src/cards.d.ts +15 -0
  5. package/dist/src/cards.js +138 -0
  6. package/dist/src/cluster-layer.d.ts +2 -0
  7. package/dist/src/cluster-layer.js +2 -0
  8. package/dist/src/configuration.d.ts +2 -0
  9. package/dist/src/configuration.js +30 -0
  10. package/dist/src/controller-types.d.ts +11 -15
  11. package/dist/src/explorer-controls.d.ts +26 -0
  12. package/dist/src/explorer-controls.js +120 -0
  13. package/dist/src/explorer-core.d.ts +6 -1
  14. package/dist/src/explorer-core.js +156 -667
  15. package/dist/src/explorer-shell.d.ts +62 -0
  16. package/dist/src/explorer-shell.js +228 -0
  17. package/dist/src/explorer-sidebar.d.ts +20 -0
  18. package/dist/src/explorer-sidebar.js +79 -0
  19. package/dist/src/geometry.d.ts +6 -1
  20. package/dist/src/geometry.js +80 -0
  21. package/dist/src/guide-map.js +2 -1
  22. package/dist/src/guide-static.d.ts +1 -1
  23. package/dist/src/label-spread.d.ts +5 -0
  24. package/dist/src/label-spread.js +36 -0
  25. package/dist/src/layers.js +1 -1
  26. package/dist/src/map.d.ts +1 -1
  27. package/dist/src/map.js +24 -13
  28. package/dist/src/marker-catalog.d.ts +1 -1
  29. package/dist/src/marker-catalog.js +11 -5
  30. package/dist/src/neighborhood-layer.d.ts +67 -0
  31. package/dist/src/neighborhood-layer.js +256 -0
  32. package/dist/src/overlay-layer.d.ts +13 -0
  33. package/dist/src/overlay-layer.js +68 -0
  34. package/dist/src/palettes.d.ts +48 -0
  35. package/dist/src/palettes.js +40 -0
  36. package/dist/src/presets.d.ts +1 -0
  37. package/dist/src/presets.js +1 -0
  38. package/dist/src/static-labels.js +19 -4
  39. package/dist/src/static.d.ts +2 -2
  40. package/dist/src/static.js +21 -2
  41. package/dist/src/types.d.ts +21 -9
  42. package/dist/src/validation.js +7 -5
  43. package/docs/API.md +75 -5
  44. package/docs/EXAMPLES.md +29 -0
  45. package/docs/api-audit.md +22 -5
  46. package/docs/consumer-integration.md +86 -2
  47. package/docs/developer-guide.md +29 -7
  48. package/docs/guide-bundle-report.md +4 -4
  49. package/docs/migration-v5.md +100 -0
  50. package/llms-full.txt +508 -53
  51. package/llms.txt +2 -1
  52. package/package.json +17 -8
@@ -1,4 +1,12 @@
1
- import type { Geometry, NeighborhoodFeature, NeighborhoodSource } from '../data/types.js';
1
+ import type { Geometry, NeighborhoodFeature, NeighborhoodSource, Position } from '../data/types.js';
2
+ /** Canvas-relative CSS pixels; visibility means inside the current square map viewport. */
3
+ export interface ScreenPoint {
4
+ x: number;
5
+ y: number;
6
+ visible: boolean;
7
+ }
8
+ /** WGS84 longitude/latitude; altitude, if supplied in a position, is ignored. */
9
+ export type ProjectToScreen = (position: Position) => ScreenPoint;
2
10
  export type DistrictYear = 2002 | 2012 | 2022;
3
11
  export interface DistrictSelection {
4
12
  id: number;
@@ -12,8 +20,7 @@ export interface DistrictStyle {
12
20
  }
13
21
  export interface MapMarker {
14
22
  id: string;
15
- lng: number;
16
- lat: number;
23
+ position: Position;
17
24
  label?: string;
18
25
  selected?: boolean;
19
26
  color?: string;
@@ -56,6 +63,15 @@ export interface SFMapOptions {
56
63
  /** Opt into interactive overview label priorities/collisions at this CSS display width. Uses estimated system-font metrics without a DOM. */
57
64
  labelLayout?: {
58
65
  width: number;
66
+ /** Maximum placed labels, spread across the map with nearby-pin context; zero hides text only. */
67
+ maxLabels?: number;
68
+ /** Label outline color, for example the land color on a dark map. */
69
+ haloColor?: string;
70
+ /** Alternate layout below this SVG viewport width, without browser JavaScript. */
71
+ compact?: {
72
+ width: number;
73
+ maxLabels: number;
74
+ };
59
75
  };
60
76
  highways?: boolean;
61
77
  keyRoads?: boolean;
@@ -211,11 +227,7 @@ export interface CameraOptions {
211
227
  export interface NeighborhoodExplorerElement extends HTMLElement {
212
228
  /** Append positioned HTML children here; coordinates are relative to this layer. */
213
229
  readonly overlayElement: HTMLDivElement;
214
- projectToScreen(lng: number, lat: number): {
215
- x: number;
216
- y: number;
217
- visible: boolean;
218
- };
230
+ projectToScreen: ProjectToScreen;
219
231
  stopAnimation(): void;
220
232
  /** Atomic patch; false disables, undefined resets a feature to its default. */
221
233
  setFeatures(patch: MapFeatures): void;
@@ -259,4 +271,4 @@ export interface NeighborhoodExplorerElement extends HTMLElement {
259
271
  export interface TransitAnimationElement extends HTMLElement {
260
272
  destroy(): void;
261
273
  }
262
- export type { MapAppearance, MapCamera, MapCapabilities, MapConfiguration, MapConfigurationSnapshot, MapController, MapControls, MapEvents, MapFeatureReference, MapOptions, MapSelectionChange, MapViewUpdateOptions, ResolvedMapConfiguration, } from './controller-types.js';
274
+ export type { MapAppearance, MapCamera, MapCapabilities, MapConfiguration, MapConfigurationGroup, MapConfigurationSnapshot, MapController, MapControls, MapEvents, MapFeatureReference, MapOptions, MapSelectionChange, ResolvedMapConfiguration, } from './controller-types.js';
@@ -155,14 +155,16 @@ export function validateMarkers(markers) {
155
155
  throw new TypeError('Markers must be an array.');
156
156
  const ids = new Set();
157
157
  for (const marker of markers) {
158
- assertOptions(marker, 'marker');
158
+ assertOptions(marker, 'marker', ['id', 'position', 'label', 'selected', 'color', 'radius']);
159
159
  if (typeof marker.id !== 'string' || !marker.id || ids.has(marker.id))
160
160
  throw new RangeError('Markers require unique nonempty IDs.');
161
161
  ids.add(marker.id);
162
- if (!Number.isFinite(marker.lng) ||
163
- Math.abs(marker.lng) > 180 ||
164
- !Number.isFinite(marker.lat) ||
165
- Math.abs(marker.lat) >= 90)
162
+ if (!Array.isArray(marker.position) ||
163
+ marker.position.length < 2 ||
164
+ !Number.isFinite(marker.position[0]) ||
165
+ Math.abs(marker.position[0]) > 180 ||
166
+ !Number.isFinite(marker.position[1]) ||
167
+ Math.abs(marker.position[1]) >= 90)
166
168
  throw new RangeError('Marker coordinates require finite longitude from -180 to 180 and latitude strictly between -90 and 90.');
167
169
  if (marker.radius !== undefined && (!Number.isFinite(marker.radius) || marker.radius <= 0))
168
170
  throw new RangeError('Marker radius must be positive and finite.');
package/docs/API.md CHANGED
@@ -4,6 +4,41 @@ Start with the [static and interactive examples](../README.md). Exact option
4
4
  and event types are exported by [src/api.ts](../src/api.ts) and defined in
5
5
  [src/types.ts](../src/types.ts) and [src/controller-types.ts](../src/controller-types.ts).
6
6
 
7
+ ## Geographic queries
8
+
9
+ New in v5. See the [migration guide](migration-v5.md) for upgrade steps.
10
+
11
+ The data-free `@kahwee/sf-map-svg/geometry` entry exports `geometryBounds(geometry)`
12
+ and `geometryContainsPoint(geometry, [longitude, latitude])`. They work offline
13
+ with the same readonly GeoJSON geometries used by the renderer, without a DOM.
14
+ The same entry exports the `Geometry`, `Position`, and `Bounds` types, so geometry
15
+ consumers need no second import path for their function input and result types.
16
+
17
+ ```ts
18
+ import { geometryBounds, geometryContainsPoint } from '@kahwee/sf-map-svg/geometry';
19
+ import { neighborhoods } from '@kahwee/sf-map-svg/data/realtor';
20
+
21
+ const location = [-122.42, 37.76] as const;
22
+ const matches = neighborhoods.features.filter((feature) =>
23
+ geometryContainsPoint(feature.geometry, location),
24
+ );
25
+ const bounds = matches[0] ? geometryBounds(matches[0].geometry) : null;
26
+ ```
27
+
28
+ Bounds are `[west, south, east, north]` in degrees, computed from coordinates,
29
+ not cached feature metadata. Empty or absent geometry returns `null`.
30
+ Hit testing includes Polygon, MultiPolygon, and polygon members of nested
31
+ GeometryCollections. Hole interiors are excluded; outer and hole boundaries
32
+ are included, so a shared boundary may match multiple features. Ring winding
33
+ does not matter; open rings are closed implicitly. Other geometry types do
34
+ not produce polygon hits. Empty or absent geometry returns `false`.
35
+
36
+ Both helpers reject nonfinite or out-of-range longitude/latitude with
37
+ `RangeError`; altitude is ignored. They assume valid planar GeoJSON topology
38
+ and straight segments in longitude/latitude, suitable for these SF datasets.
39
+ They do not repair polygons, apply a boundary tolerance, or unwrap antimeridian
40
+ crossings. A bounds match alone does not establish polygon containment.
41
+
7
42
  ## Static maps
8
43
 
9
44
  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.
@@ -31,8 +66,32 @@ The SVG description names the selected neighborhood source for source-aware data
31
66
 
32
67
  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.
33
68
 
69
+ For editorial previews, `labelLayout` also accepts `maxLabels` (a nonnegative safe integer), `haloColor` (an SVG color string), and `compact: { width, maxLabels }`. The compact width must be positive, finite, and smaller than the main layout width. Each layout places labels around pins independently, then chooses up to its budget for spatial coverage and nearby-pin context (selected marker first when it fits). Without a budget the existing overview order is unchanged. Equal spatial scores retain the original priority order. A zero budget hides text, never pins. The default halo remains white; use the land color for a quieter dark map.
70
+
71
+ Compact mode embeds two label groups and a media query in the same SVG. At or below the compact width it shows the compact group; above it shows the main group. In an `<img>` or standalone SVG, the breakpoint follows the SVG viewport. In inline SVG it follows the document viewport, so use `<img>` for cards whose width differs from the page. No JavaScript, duplicated geometry, external fonts, or extra image requests are needed. The compact width is also its font-layout hint; very narrow images still scale the text. The interactive renderer continues to use its live collision layout after enhancement.
72
+
73
+ ```js
74
+ createGuideSVG({
75
+ labelLayout: {
76
+ width: 800,
77
+ haloColor: '#1b2c28',
78
+ compact: { width: 480, maxLabels: 4 },
79
+ },
80
+ colors: { water: '#0a1412', land: '#1b2c28', label: '#d5e3e8' },
81
+ markers: places,
82
+ });
83
+ ```
84
+
85
+ See the [field-guide preview recipe](consumer-integration.md#editorial-map-previews) for a complete build-time example.
86
+
34
87
  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](consumer-integration.md#keep-overview-labels-consistent) for width and progressive-enhancement guidance.
35
88
 
89
+ ## Editorial palettes
90
+
91
+ Import `mapPalettes` from `@kahwee/sf-map-svg/presets`. `nightForest` and `warmPaper` are deeply frozen, opt-in token sets. Each supplies `colors`, `haloColor`, `markerColor`, `selectedMarkerColor`, and `selectedMarkerRingColor`. They work with any supplied geography and do not change the existing themes. Text clears 4.5:1 and pins/rings clear 3:1 against the palette's land, park, and water surfaces; consumers must check their own overrides and surrounding UI.
92
+
93
+ For static maps pass `colors: palette.colors`, `labelLayout: { width: 800, haloColor: palette.haloColor }`, and the marker color on each marker. For interactive maps pass `appearance: { colors: palette.colors, labelStyle: { haloColor: palette.haloColor }, markerColor: palette.markerColor, selectedMarkerColor: palette.selectedMarkerColor }` and `features: { selectedMarkerRing: { color: palette.selectedMarkerRingColor } }`. Choose the palette in the consuming application; these tokens do not read system preferences or install global CSS.
94
+
36
95
  ## Interactive maps
37
96
 
38
97
  `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](developer-guide.md) compares static and interactive defaults and explains configuration resets, coordinates, and cleanup.
@@ -61,16 +120,16 @@ District style callbacks are evaluated once per district at construction and on
61
120
 
62
121
  ## Live configuration and inspection
63
122
 
64
- `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.
123
+ `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.
65
124
 
66
- 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.
125
+ 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.
67
126
 
68
127
  ```ts
69
128
  map.configure({
70
129
  appearance: { colors: { water: '#142d45' }, labelStyle: { fontFamily: 'Georgia,serif' } },
71
130
  layers: { bartStations: false },
72
131
  });
73
- map.configure({ appearance: undefined }); // restore construction defaults, not initial overrides
132
+ map.resetConfiguration(['appearance']); // restore built-in defaults
74
133
 
75
134
  const overrides = map.getConfiguration();
76
135
  const effective = map.getResolvedConfiguration();
@@ -102,10 +161,21 @@ map.selectFeature({ kind: 'neighborhood', source: 'realtor', id: 'inner-mission'
102
161
  map.selectFeature({ kind: 'district', year: 2022, id: 3 });
103
162
  ```
104
163
 
105
- 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.
164
+ 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.
106
165
 
107
166
  ## Coordinates and units
108
167
 
109
- 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.
168
+ Version 5 uses tuple inputs and exports the named `ScreenPoint` /
169
+ `ProjectToScreen` types. The former two-number call is removed.
170
+
171
+ 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.
110
172
 
111
173
  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.
174
+
175
+ ## Linked place cards
176
+
177
+ `bindMapCards(map, root, options)` from `@kahwee/sf-map-svg/cards` connects consumer-owned cards to a `MapController`. Each card uses `data-map-marker="stable-marker-id"`; native `<button type="button" data-map-select>` controls select that place without panning. Article links keep their ordinary navigation. `options.markers` is the same catalog supplied to the map; `highlightColor` optionally changes the preview ring and `scrollOnSelect: false` disables automatic card reveal.
178
+
179
+ Pointer hover and keyboard focus draw a temporary ring through `projectToScreen()`. They do not call `selectMarker`, change the camera, or emit selection events. Focused cards take precedence over hovered cards. Offscreen points do not draw a ring. Touch users use the selection button. Selected cards receive `data-map-selected` and their buttons receive `aria-pressed`; style the card attribute in your own CSS. Selecting a pin reveals its card with immediate native scrolling, without moving focus. Filtering/reordering/removing cards is safe; IDs are compared directly, not inserted into CSS selectors.
180
+
181
+ Call `binding.setMarkers(next)` after `map.setMarkers(next)` when the catalog changes. Call `binding.destroy()` before `map.destroy()` on unmount; cleanup is idempotent and restores attributes that the binding owned. The module does not create cards, fetch data, change URLs, install global CSS, or import geography. See the [linked-card recipe](consumer-integration.md#link-place-cards-to-pins).
package/docs/EXAMPLES.md CHANGED
@@ -105,3 +105,32 @@ API** includes executable motion, reduced-motion, and progressive-shell checks.
105
105
  See [consumer integration](consumer-integration.md) for server and browser recipes.
106
106
 
107
107
  For the controller and explicit data imports, see [the v3 migration guide](migration-v3.md).
108
+
109
+ ## Find areas containing a location
110
+
111
+ New in v5. This example runs in
112
+ Node or a browser without mounting a map and imports only the SFAR definition:
113
+
114
+ ```ts
115
+ import { geometryBounds, geometryContainsPoint } from '@kahwee/sf-map-svg/geometry';
116
+ import { neighborhoods } from '@kahwee/sf-map-svg/data/realtor';
117
+
118
+ const location = [-122.42, 37.76] as const;
119
+ const matches = neighborhoods.features.filter((feature) =>
120
+ geometryContainsPoint(feature.geometry, location),
121
+ );
122
+ for (const feature of matches) {
123
+ console.log(feature.id, feature.properties.canonicalName, geometryBounds(feature.geometry));
124
+ }
125
+ ```
126
+
127
+ Keep all matches when the location lies on a shared boundary. An empty result
128
+ means no supplied polygon contains the location; it does not trigger a switch
129
+ to another neighborhood definition. For an existing map using the same realtor
130
+ source, pass a chosen match to
131
+ `map.selectFeature({ kind: 'neighborhood', source: 'realtor', id: feature.id })`.
132
+ To fit it, use `map.camera.fit(feature.geometry)`. Geographic bounds cannot be
133
+ passed directly to `camera.set`, which takes projected `[x, y, size]`.
134
+
135
+ See [query semantics and limitations](API.md#geographic-queries) and the
136
+ [HTML overlay recipe](consumer-integration.md#position-html-overlays).
package/docs/api-audit.md CHANGED
@@ -20,8 +20,8 @@ unbounded geographic data can never fail.
20
20
  | Viewport marker rendering | Individual pin visuals allocate on first visibility or selection; offscreen pins and cluster symbols detach with screen-space overscan. Uncreated records receive current labels/styles on first materialization. Full catalog/picker and cached node identities remain available; selected/focused pins stay mounted. Pan reuses global cluster membership and nodes until zoom, data, selection, focus, or clustering changes. |
21
21
  | Marker entrance and reduced motion | Entrances begin on first visibility, are independent of camera motion, and cancel on preference changes or disposal, including pending entrances. Cached pins do not restart entrances on reentry. Stable IDs avoid repeated entrances on filtering. |
22
22
  | Hiding touch control while gestures are enabled | Returns touch scrolling to the page. Other toolbar controls remain independent. |
23
- | Convenience mode/source setters | Share the atomic configuration path. `{ resetView: false }` preserves the camera; omitted options retain the historic reset. Invalid options and unavailable geography leave state unchanged. |
24
- | Runtime patch omitted/false/undefined | Omitted retains; false disables boolean switches; undefined removes group/key overrides. Top-level mode/source/year/labels set to undefined retain their current values. Nested feature objects replace, rather than deep-merge. |
23
+ | Convenience mode/source setters | Share the atomic configuration path. Setters preserve the camera; explicit camera methods move it. Unavailable geography leaves state unchanged. |
24
+ | Runtime patch omitted/false/undefined | Controller patches ignore omitted/undefined values at every record depth; false disables boolean switches. Nested feature records merge. Explicit resetConfiguration clears presentation groups. Low-level element setters retain their separate contract. |
25
25
  | Compact shell and full attribution | Rejected before replacement. Use `createGuideController` for full attribution. Control overrides are honored and may deliberately alter layout. |
26
26
  | District style callbacks | Prepare and copy every style before committing. Hover/selection reuse the results; call `setDistrictStyle()` to refresh changed external data. Reentrant updates or destruction supersede pending work. |
27
27
  | Rapid district year changes | Both outgoing layers are tracked, inert, and removed on interruption, completion, reduced-motion changes, or destruction. Zero duration creates no transition copies. |
@@ -58,15 +58,32 @@ exceptions are not transactional validation errors and do not roll back an alrea
58
58
  committed change. Direct mutation of the returned DOM can invalidate invariants;
59
59
  use the public methods and `overlayElement` extension point.
60
60
 
61
- Static grouped layers and appearance reject unknown keys, while flat compatibility options retain their existing surface; strict
62
- interactive option-key validation is not a claim that all static options share the
63
- same schema. Host layout, delayed images, fonts, and application cards need their
61
+ Static flat and grouped options reject unknown keys and invalid values before
62
+ rendering. Grouped options take precedence over flat compatibility options;
63
+ static and interactive schemas retain their documented differences. Host layout, delayed images, fonts, and application cards need their
64
64
  own CLS tests. The scale bar is an approximate local Mercator scale, not survey
65
65
  instrumentation. Coincident locations use a place chooser rather than spiderfying. Geographic
66
66
  source correctness remains governed by SOURCES.md and existing topology tests.
67
67
 
68
68
  ## Extending the contract
69
69
 
70
+ ### V5 consistency contract
71
+
72
+ - Geometry helpers export their input/result types alongside functions and use
73
+ geometry-first arguments with readonly GeoJSON positions.
74
+ - Marker positions, static projection, and screen projection all use
75
+ `[longitude, latitude]`. Screen projection returns CSS pixels; camera viewports
76
+ remain projected map units and pan deltas remain CSS pixels.
77
+ - Controller `configure`, `setMode`, and `setSource` preserve the camera. Camera
78
+ movement is explicit through camera methods or selection's `fit` option.
79
+ - Controller patches ignore omitted/undefined values at every depth and merge
80
+ nested records. `resetConfiguration` clears named presentation groups to
81
+ built-in defaults without changing geographic choices or camera.
82
+ - Controller neighborhood selection requires exact IDs. Prefer source/year-qualified
83
+ `selectFeature`; name lookup belongs in source-specific dataset helpers.
84
+ - Construction-time neighborhood lookup and low-level element APIs retain their
85
+ separately documented contracts. See [v5 migration](migration-v5.md).
86
+
70
87
  For every new option, define its default, disable/reset semantics, precedence,
71
88
  validation boundary, cleanup owner, and effect on keyboard focus and selection.
72
89
  Test invalid updates against an existing live map, not only clean construction.
@@ -35,7 +35,7 @@ For a fixed-width embed, use shared inputs on the server and in the browser:
35
35
  ```ts
36
36
  // Shared inputs: coordinates, IDs, radii, colors and layer choices must match.
37
37
  const guideMarkers = [
38
- { id: 'ferry', label: 'Ferry Building', lng: -122.3937, lat: 37.7955, radius: 7 },
38
+ { id: 'ferry', label: 'Ferry Building', position: [-122.3937, 37.7955] as const, radius: 7 },
39
39
  ];
40
40
  const guideLayers = { neighborhoodLabels: true, roadLabels: true };
41
41
  const guideColors = { label: '#183e58', landmark: '#426641' };
@@ -51,7 +51,7 @@ const labeledPlacesMarkup = createGuideShell({
51
51
 
52
52
  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.
53
53
 
54
- 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.
54
+ For responsive layouts whose canvas width is unknown at build time, use `labelLayout.compact` for an embedded image with two static label layouts (see [editorial previews](#editorial-map-previews)), 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.
55
55
 
56
56
  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.
57
57
 
@@ -87,3 +87,87 @@ For a new browser-only guide, use `createGuideController()` and mount its `.elem
87
87
  `createMap(guideMapData, guideOptions)` remains available through `/guide/data` and `/presets`. For server rendering, pass `guideMapData.map` or `/data/static` to `renderMap()`. Use `/data/full` when interactive lookup collections are needed. The guide's detailed geography loads only on an explicit `loadGuideDetailedData()` call; enabling a layer does not download it.
88
88
 
89
89
  See the [exact API contract](API.md), [worked examples](EXAMPLES.md), and [bundle measurements](guide-bundle-report.md).
90
+
91
+
92
+ ## Editorial map previews
93
+
94
+ YorkSF uses one city overview to connect its field notes and compact locator maps to orient individual articles. These patterns work for any local guide: keep the place catalog in the consuming application, generate SVG images at build time, and link each preview to the application's map or article route.
95
+
96
+ Run `pnpm demo` and open `examples/generated/field-guide.html` for a self-contained light/dark example. Its overview uses `labelLayout.compact` to limit mobile text without removing pins, and `haloColor` to match the night palette. The locator cards share those colors but omit labels at small sizes. Images reserve their aspect ratio and load lazily; the links work without JavaScript. The example derives its sample park coordinates from the package's existing landmark data rather than maintaining another geographic dataset.
97
+
98
+ Use stable marker IDs for deep links. Keep addresses, visit dates, historical-status caveats, and editorial descriptions in your own content model. The package supplies map graphics; it does not imply that a place is currently open or that an author visited it. Keep a visible list beside the overview so the image is not the only way to find a place. SVG alt text should describe its purpose; link text should name the destination.
99
+
100
+ For an image card, `compact.width` follows the image's rendered viewport. For inline SVG it follows the outer document viewport; do not assume it behaves like a container query. Avoid embedding scripts in exported images. Interactive maps remain a separate progressive enhancement.
101
+
102
+ ## Link place cards to pins
103
+
104
+ Keep ordinary article links and add a separate map-selection button. Native buttons give keyboard and touch users the same action as pointer users:
105
+
106
+ ```html
107
+ <article data-map-marker="park">
108
+ <h2><a href="/places/park/">A place in the park</a></h2>
109
+ <button type="button" data-map-select>Show on map</button>
110
+ </article>
111
+ ```
112
+
113
+ ```js
114
+ import { bindMapCards } from '@kahwee/sf-map-svg/cards';
115
+ import { mapPalettes } from '@kahwee/sf-map-svg/presets';
116
+
117
+ const binding = bindMapCards(map, document.querySelector('#place-cards'), {
118
+ markers: places,
119
+ highlightColor: mapPalettes.nightForest.selectedMarkerRingColor,
120
+ });
121
+ // On catalog change: map.setMarkers(next); binding.setMarkers(next);
122
+ // On unmount: binding.destroy(); map.destroy();
123
+ ```
124
+
125
+ Style `[data-map-selected]` with an outline or background change that remains legible in both themes. Use a visible focus outline on card buttons and links. Hover/focus only previews the location: it preserves selection and camera. Map selection reveals the card without shifting keyboard focus; set `scrollOnSelect: false` if your application owns scrolling. Match cards and markers by stable IDs, and keep hidden/filtered places out of both catalogs when that is your intended filter behavior.
126
+
127
+ 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.
128
+
129
+ ## Position HTML overlays
130
+
131
+ In v5, pass the same readonly `[longitude, latitude]` position used by static
132
+ projection. The former two-number call is removed; see the
133
+ [migration guide](migration-v5.md).
134
+
135
+ Call this helper after mounting the map in a container with nonzero width:
136
+
137
+ ```ts
138
+ import type { MapController } from '@kahwee/sf-map-svg';
139
+ import type { Position } from '@kahwee/sf-map-svg/geometry';
140
+
141
+ function attachCallout(map: MapController, location: Position, label: string) {
142
+ const callout = document.createElement('span');
143
+ callout.textContent = label;
144
+ callout.style.cssText = 'position:absolute;pointer-events:none;transform:translate(-50%,-100%)';
145
+ const update = () => {
146
+ const point = map.projectToScreen(location);
147
+ callout.hidden = !point.visible;
148
+ callout.style.left = `${point.x}px`;
149
+ callout.style.top = `${point.y}px`;
150
+ };
151
+ update();
152
+ map.overlayElement.append(callout);
153
+ const stopViewport = map.on('viewportchange', update);
154
+ const stopResize = map.on('mapresize', update);
155
+ return () => {
156
+ stopViewport();
157
+ stopResize();
158
+ callout.remove();
159
+ };
160
+ }
161
+ ```
162
+
163
+ Use `const detach = attachCallout(map, [-122.3937, 37.7955], 'Ferry Building')`
164
+ with your mounted controller. Call `detach()` before `map.destroy()` on unmount.
165
+ Keep the container visible while projecting; an unmounted or zero-width canvas
166
+ throws. Controller calls after destruction also throw. Camera moves and resize
167
+ both require repositioning; a one-time projection becomes stale.
168
+
169
+ `x` and `y` are CSS pixels relative to `overlayElement`, not page coordinates or
170
+ SVG units. `visible` tests inclusion in the map viewport; it does not account
171
+ for occlusion by other content. This noninteractive label uses `textContent`
172
+ and leaves pointer input to the map. Interactive callouts need their own
173
+ keyboard, focus, and dismissal behavior. See [coordinates and units](API.md#coordinates-and-units).
@@ -10,6 +10,7 @@ Choose the renderer and geography separately. `renderMap` produces a string with
10
10
  | Explore districts or several neighborhood definitions | `/map`: `createMap` | `/data/full`: `fullMapData` |
11
11
  | Embed a smaller neighborhood guide | `/guide/map`: `createGuideController` | Included lightweight guide geography |
12
12
  | Use the guide data with the general controller | `/map`: `createMap` | `/guide/data`: `guideMapData` |
13
+ | Query bounds or polygon containment without a DOM | `/geometry` | Caller-supplied geometry or explicit dataset imports |
13
14
  | Look up names without mounting a map | `/data/lookup` | Source-aware lookup helpers |
14
15
 
15
16
  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.
@@ -96,22 +97,43 @@ SFAR realtor neighborhoods are the default when supplied. If only an alternative
96
97
  | Camera | `map.camera.set/pan/zoom/fit/reset(...)` |
97
98
  | Palette, typography, control styling | `map.configure({ appearance })`; retains camera, selection and focus |
98
99
 
99
- 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](API.md#live-configuration-and-inspection) and [the design playground](https://kahwee.github.io/sf-map-svg/playground.html).
100
-
101
- `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.
100
+ Controller patches merge nested token and feature records. Omitted and `undefined`
101
+ values retain existing values. Use `false` to disable a boolean switch and
102
+ `resetConfiguration()` to clear presentation overrides explicitly:
102
103
 
103
104
  ```ts
104
105
  map.configure({ layers: { neighborhoodLabels: false } });
105
- map.configure({ layers: { neighborhoodLabels: undefined } }); // follow mode again
106
106
  map.configure({ features: { motion: false } });
107
- map.configure({ layers: undefined }); // clear all explicit layer overrides
107
+ map.resetConfiguration(['layers']); // follow mode-derived defaults again
108
+ map.resetConfiguration(['appearance']); // built-in palette and typography
108
109
  ```
109
110
 
110
- `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.
111
+ Reset groups are `features`, `layers`, `controls`, and `appearance`; omitting
112
+ the argument resets all four. Resets preserve geographic choices and camera,
113
+ and do not restore constructor overrides. Static maps accept grouped layers and
114
+ the shared appearance keys (`theme`, `colors`, `districtStyle`) at construction.
115
+ See [the API contract](API.md#live-configuration-and-inspection).
116
+
117
+ `getConfiguration()` returns a detached snapshot of geographic choices and
118
+ presentation overrides. Use `getResolvedConfiguration()` for effective layers
119
+ and `getCapabilities()` for supplied geography. Invalid patches preserve state.
120
+ `setMode`, `setSource`, and `configure` all preserve the camera; request movement
121
+ with `camera.reset()` or `camera.fit(geometry)`. See the
122
+ [v5 migration guide](migration-v5.md) for changed call forms.
123
+
124
+ ## Geographic queries
125
+
126
+ The v5 `/geometry` helpers `geometryBounds(geometry)` and
127
+ `geometryContainsPoint(geometry, position)` work without a DOM or bundled
128
+ geography. Import `Bounds`, `Geometry`, and `Position` types from the same entry.
129
+ Use containment for area lookup and bounds for geographic extents; pass the
130
+ original geometry to `camera.fit`, rather than geographic bounds to `camera.set`.
131
+ See the [worked example](EXAMPLES.md#find-areas-containing-a-location) and
132
+ [full query semantics](API.md#geographic-queries).
111
133
 
112
134
  ## Markers, routes and camera coordinates
113
135
 
114
- 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.
136
+ 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.
115
137
 
116
138
  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.
117
139
 
@@ -1,12 +1,12 @@
1
1
  # Guide bundle size report
2
2
 
3
- Generated 2026-10-13 by `pnpm report:guide` with Vite production minification and gzip compression. Each emitted JS chunk is compressed independently. The report measures the explicit-data root and the optional `@kahwee/sf-map-svg/guide` preset.
3
+ Generated 2026-10-20 by `pnpm report:guide` with Vite production minification and gzip compression. Each emitted JS chunk is compressed independently. The report measures the explicit-data root and the optional `@kahwee/sf-map-svg/guide` preset.
4
4
 
5
5
  | Entry | Initial JS, raw | Initial JS, gzip | Explicit detail JS, gzip |
6
6
  | --- | ---: | ---: | ---: |
7
- | Renderer root (explicit data) | 122.2 KB | 35.7 KB | — |
8
- | Static renderer | 31.4 KB | 9.7 KB | — |
9
- | Guide preset | 508.3 KB | 122.1 KB | 467.0 KB |
7
+ | Renderer root (explicit data) | 124.2 KB | 36.5 KB | — |
8
+ | Static renderer | 33.4 KB | 10.3 KB | — |
9
+ | Guide preset | 508.4 KB | 122.3 KB | 467.0 KB |
10
10
 
11
11
  **500 KB target:** met.
12
12
 
@@ -0,0 +1,100 @@
1
+ # Migrating to v5
2
+
3
+ Version 5.0.0 makes coordinate inputs, controller updates, and reset behavior
4
+ consistent. It requires Node 24+ and retains offline rendering, explicit data
5
+ imports, zero runtime dependencies, and the existing default map appearance.
6
+ The version bump prepares the next release; it does not publish the package.
7
+
8
+ ## Coordinates use GeoJSON positions
9
+
10
+ Markers now require `position: [longitude, latitude]`. The old `lng` and `lat`
11
+ properties are rejected. `projectToScreen` takes one readonly position; its
12
+ old two-number form is removed. Altitude is accepted and ignored by projection.
13
+
14
+ ```ts
15
+ // v4
16
+ const oldMarker = { id: 'ferry', lng: -122.3937, lat: 37.7955 };
17
+ // v5
18
+ const marker = { id: 'ferry', position: [-122.3937, 37.7955] as const };
19
+ map.setMarkers([marker]);
20
+ const point = map.projectToScreen(marker.position);
21
+ ```
22
+
23
+ Use `Position` from `/geometry` for reusable positions and `ScreenPoint` or
24
+ `ProjectToScreen` from the root or `/map` for screen-projection contracts.
25
+ `x` and `y` remain canvas-relative CSS pixels; `visible` means inside the
26
+ viewport, not unobscured. Mount the map in a nonzero-width container first.
27
+ Camera viewports still use projected `[x, y, size]`; camera pan deltas are CSS
28
+ pixels. Geographic bounds are not camera viewports.
29
+
30
+ ## Controller updates preserve the camera
31
+
32
+ `configure`, `setMode`, and `setSource` preserve the camera. The second
33
+ `{ resetView }` argument to the controller convenience setters is removed.
34
+ To reproduce an old automatic reset, request it explicitly:
35
+
36
+ ```ts
37
+ map.setMode('neighborhoods');
38
+ map.camera.reset({ animate: false });
39
+ ```
40
+
41
+ Selection still fits by default. Use `{ fit: false }` to change selection
42
+ without moving the camera, or `camera.fit(geometry)` for an explicit fit.
43
+
44
+ ## Reset presentation explicitly
45
+
46
+ In controller `configure` patches, omitted and `undefined` values are no-ops at
47
+ every record depth. Nested token and feature records merge; an explicit boolean
48
+ replaces a feature record and `false` disables a boolean switch.
49
+
50
+ ```ts
51
+ map.configure({ appearance: { colors: { water: '#142d45' } } });
52
+ map.configure({ appearance: undefined }); // keeps the current appearance
53
+ map.resetConfiguration(['appearance']); // restores built-in appearance defaults
54
+ map.resetConfiguration(['layers', 'controls']); // removes explicit overrides
55
+ map.resetConfiguration(); // resets all four presentation groups
56
+ ```
57
+
58
+ The reset groups are `features`, `layers`, `controls`, and `appearance`.
59
+ Resetting a group does not restore initial constructor overrides, change the
60
+ camera, or change mode/source/year/labels. It restores built-in defaults and
61
+ mode-derived layer choices. Supply geographic choices explicitly through
62
+ `configure`. To remove a single token while retaining other overrides, build
63
+ the desired group, reset that group, then configure the retained values.
64
+ Each call commits atomically; the two calls are separate updates.
65
+
66
+ These rules describe `MapController` from `createMap`, `createGuideController`,
67
+ and `mountGuideController`. Low-level element factories retain their separate
68
+ `setFeatures` / `setLayers` APIs; use controllers for the grouped update contract.
69
+
70
+ ## Select neighborhoods by exact identity
71
+
72
+ Controller `selectNeighborhood(id)` accepts an exact ID in the current source,
73
+ rather than a display name or alias. Unknown IDs return `false` without changing
74
+ selection. Resolve a human-entered name using the source-specific lookup first.
75
+ For integrations, prefer the source-qualified identity:
76
+
77
+ ```ts
78
+ import { getRealtorNeighborhood } from '@kahwee/sf-map-svg/data/realtor';
79
+
80
+ const feature = getRealtorNeighborhood('Inner Mission');
81
+ if (feature) {
82
+ map.selectFeature({ kind: 'neighborhood', source: 'realtor', id: feature.id }, { fit: false });
83
+ }
84
+ ```
85
+
86
+ `selectFeature` requires the current supplied source/year and never switches
87
+ geography implicitly. Marker IDs and numeric district IDs remain unchanged.
88
+ The construction-time `neighborhood` option and low-level element lookup APIs
89
+ continue to resolve names; the stricter ID contract applies to controller
90
+ selection methods. Existing selection event names remain available alongside
91
+ `selectionchange`.
92
+
93
+ ## Offline geometry queries
94
+
95
+ `geometryBounds(geometry)` and `geometryContainsPoint(geometry, position)` are
96
+ new in v5. They accept readonly GeoJSON without a DOM or geographic imports.
97
+ Bounds are `[west, south, east, north]` or `null` for empty geometry. Containment
98
+ respects holes, includes polygon boundaries, and supports islands and nested
99
+ collections. Shared edges can match more than one feature. See the
100
+ [query contract](API.md#geographic-queries) for validation and planar limitations.