@kahwee/sf-map-svg 4.3.2 → 4.5.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 (44) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +18 -1
  3. package/dist/src/cards.d.ts +15 -0
  4. package/dist/src/cards.js +138 -0
  5. package/dist/src/cluster-layer.d.ts +2 -0
  6. package/dist/src/cluster-layer.js +2 -0
  7. package/dist/src/explorer-controls.d.ts +26 -0
  8. package/dist/src/explorer-controls.js +120 -0
  9. package/dist/src/explorer-core.d.ts +6 -1
  10. package/dist/src/explorer-core.js +168 -701
  11. package/dist/src/explorer-shell.d.ts +62 -0
  12. package/dist/src/explorer-shell.js +228 -0
  13. package/dist/src/explorer-sidebar.d.ts +20 -0
  14. package/dist/src/explorer-sidebar.js +79 -0
  15. package/dist/src/guide-data.js +1 -0
  16. package/dist/src/guide-map.js +2 -1
  17. package/dist/src/guide-static.js +1 -0
  18. package/dist/src/label-candidates.d.ts +24 -0
  19. package/dist/src/label-candidates.js +29 -0
  20. package/dist/src/label-renderer.d.ts +1 -10
  21. package/dist/src/label-spread.d.ts +5 -0
  22. package/dist/src/label-spread.js +36 -0
  23. package/dist/src/map-core.d.ts +3 -1
  24. package/dist/src/map-core.js +7 -5
  25. package/dist/src/map.js +2 -1
  26. package/dist/src/neighborhood-layer.d.ts +67 -0
  27. package/dist/src/neighborhood-layer.js +256 -0
  28. package/dist/src/overlay-layer.d.ts +13 -0
  29. package/dist/src/overlay-layer.js +68 -0
  30. package/dist/src/palettes.d.ts +48 -0
  31. package/dist/src/palettes.js +40 -0
  32. package/dist/src/presets.d.ts +1 -0
  33. package/dist/src/presets.js +1 -0
  34. package/dist/src/static-labels.d.ts +7 -0
  35. package/dist/src/static-labels.js +115 -0
  36. package/dist/src/static.js +31 -3
  37. package/dist/src/types.d.ts +16 -1
  38. package/docs/API.md +38 -2
  39. package/docs/EXAMPLES.md +1 -0
  40. package/docs/consumer-integration.md +81 -1
  41. package/docs/guide-bundle-report.md +4 -4
  42. package/llms-full.txt +274 -26
  43. package/llms.txt +1 -1
  44. package/package.json +16 -8
@@ -2,12 +2,13 @@ import { prepareAppearance } from './appearance.js';
2
2
  import { validateSwitchPatch } from './features.js';
3
3
  import { createSFMapWithData, getLayerPathsWithData } from './map-core.js';
4
4
  import { normalizeStaticAnimation } from './static-animation.js';
5
+ import { staticLabels } from './static-labels.js';
5
6
  import { assertOptions, validateMarkers, validateOverlays } from './validation.js';
6
7
  /** Server-safe SVG rendering with explicit data; returns SVG plus projection helpers. */
7
8
  export function renderMap(data, options = {}) {
8
9
  const prepared = expandStaticOptions(options);
9
10
  const { map, source } = staticInput(data, prepared);
10
- return createSFMapWithData({ ...prepared, source }, map);
11
+ return createSFMapWithData({ ...prepared, source }, map, prepared.labelLayout ? (project) => staticLabels(map, prepared, project) : undefined);
11
12
  }
12
13
  /** Project canonical layer geometry without constructing an SVG string. */
13
14
  export function getLayerPaths(data, options = {}) {
@@ -34,8 +35,9 @@ function staticInput(data, options) {
34
35
  source,
35
36
  map: {
36
37
  ...data.map,
37
- neighborhoods: collection.features.map(({ geometry, properties }) => ({
38
- name: properties.sourceName,
38
+ neighborhoods: collection.features.map(({ id, geometry, properties }) => ({
39
+ id,
40
+ name: options.labelLayout ? properties.canonicalName : properties.sourceName,
39
41
  geometry,
40
42
  })),
41
43
  },
@@ -46,6 +48,7 @@ const staticLayerKeys = [
46
48
  'districtLines',
47
49
  'districtLabels',
48
50
  'neighborhoodLines',
51
+ 'neighborhoodLabels',
49
52
  'landmarks',
50
53
  'bartStations',
51
54
  'highways',
@@ -67,6 +70,7 @@ function expandStaticOptions(options) {
67
70
  'year',
68
71
  'source',
69
72
  'labels',
73
+ 'labelLayout',
70
74
  'markers',
71
75
  'overlays',
72
76
  'title',
@@ -79,6 +83,30 @@ function expandStaticOptions(options) {
79
83
  for (const key of ['title', 'idPrefix'])
80
84
  if (options[key] !== undefined && typeof options[key] !== 'string')
81
85
  throw new TypeError(`${key} must be a string.`);
86
+ if (options.labelLayout !== undefined) {
87
+ assertOptions(options.labelLayout, 'label layout', [
88
+ 'width',
89
+ 'maxLabels',
90
+ 'haloColor',
91
+ 'compact',
92
+ ]);
93
+ if (!Number.isFinite(options.labelLayout.width) || options.labelLayout.width <= 0)
94
+ throw new RangeError('Invalid label layout width.');
95
+ const { maxLabels, haloColor, compact } = options.labelLayout;
96
+ if (maxLabels !== undefined && (!Number.isSafeInteger(maxLabels) || maxLabels < 0))
97
+ throw new RangeError('Invalid maxLabels.');
98
+ if (haloColor !== undefined && typeof haloColor !== 'string')
99
+ throw new TypeError('haloColor must be a string.');
100
+ if (compact !== undefined) {
101
+ assertOptions(compact, 'compact label layout', ['width', 'maxLabels']);
102
+ if (!Number.isFinite(compact.width) ||
103
+ compact.width <= 0 ||
104
+ compact.width >= options.labelLayout.width)
105
+ throw new RangeError('Invalid compact width.');
106
+ if (!Number.isSafeInteger(compact.maxLabels) || compact.maxLabels < 0)
107
+ throw new RangeError('Invalid maxLabels.');
108
+ }
109
+ }
82
110
  if (options.markers !== undefined)
83
111
  validateMarkers(options.markers);
84
112
  if (options.overlays !== undefined)
@@ -45,12 +45,27 @@ export interface SFMapOptions {
45
45
  source?: NeighborhoodSource;
46
46
  districtLines?: boolean;
47
47
  neighborhoodLines?: boolean;
48
+ /** Neighborhood names in the opt-in collision layout. */
49
+ neighborhoodLabels?: boolean;
48
50
  districtFills?: boolean;
49
51
  /** Evaluated once per district at construction/style/year updates; call setDistrictStyle again when external data changes. */
50
52
  districtStyle?: (district: import('./map-core.js').DistrictRowData) => DistrictStyle;
51
53
  districtLabels?: boolean;
52
54
  /** Hide all visible text labels while retaining geographic symbols and accessible titles. */
53
55
  labels?: boolean;
56
+ /** Opt into interactive overview label priorities/collisions at this CSS display width. Uses estimated system-font metrics without a DOM. */
57
+ labelLayout?: {
58
+ width: number;
59
+ /** Maximum placed labels, spread across the map with nearby-pin context; zero hides text only. */
60
+ maxLabels?: number;
61
+ /** Label outline color, for example the land color on a dark map. */
62
+ haloColor?: string;
63
+ /** Alternate layout below this SVG viewport width, without browser JavaScript. */
64
+ compact?: {
65
+ width: number;
66
+ maxLabels: number;
67
+ };
68
+ };
54
69
  highways?: boolean;
55
70
  keyRoads?: boolean;
56
71
  roadLabels?: boolean;
@@ -73,7 +88,7 @@ export interface SFMapOptions {
73
88
  colors?: Partial<Record<'water' | 'land' | 'district' | 'neighborhood' | 'highway' | 'road' | 'park' | 'landmark' | 'bart' | 'label' | 'marker' | 'selected', string>>;
74
89
  }
75
90
  /** Presentation supported by both the static and interactive renderers. */
76
- export type StaticMapLayers = Omit<InteractiveLayers, 'neighborhoodLabels'>;
91
+ export type StaticMapLayers = InteractiveLayers;
77
92
  export type StaticMapAppearance = Pick<SFMapOptions, 'theme' | 'colors' | 'districtStyle'>;
78
93
  /** Serializable viewport in the interactive map's fixed 800 × 800 projected space. */
79
94
  export type MapViewport = readonly [x: number, y: number, size: number];
package/docs/API.md CHANGED
@@ -19,9 +19,9 @@ const { svg } = renderMap(staticMapData, presentation);
19
19
  const map = createMap(fullMapData, presentation);
20
20
  ```
21
21
 
22
- Static `layers` supports every interactive layer switch except `neighborhoodLabels`. Static `appearance` supports `theme`, `colors`, and `districtStyle`; browser typography, area interaction styles, and screen-space marker sizing remain interactive settings. Unknown keys and invalid values are rejected for both flat and grouped options. Supplied grouped keys override flat compatibility keys; omitted or `undefined` grouped keys preserve the flat value or renderer default. Undefined color tokens use the theme default instead of entering SVG attributes. The two renderers retain their existing defaults. Marker arrays accept readonly inputs.
22
+ Static `layers` supports every interactive layer switch. `neighborhoodLabels` applies to the opt-in `labelLayout` described below. Static `appearance` supports `theme`, `colors`, and `districtStyle`; browser typography, area interaction styles, and screen-space marker sizing remain interactive settings. Unknown keys and invalid values are rejected for both flat and grouped options. Supplied grouped keys override flat compatibility keys; omitted or `undefined` grouped keys preserve the flat value or renderer default. Undefined color tokens use the theme default instead of entering SVG attributes. The two renderers retain their existing defaults. Marker arrays accept readonly inputs.
23
23
 
24
- Flat compatibility options include `theme`, `width`, `height`, `padding`, `year` (2002, 2012, 2022), `districtLines`, `districtFills`, `districtStyle`, `districtLabels`, `neighborhoodLines`, `labels`, `highways`, `keyRoads`, `roadLabels`, `landmarks`, `bartStations`, `markers`, `overlays`, `title`, `idPrefix`, `colors`, and `animation`. Each optional layer is independent. User-supplied text and attributes are escaped in SVG output.
24
+ Flat compatibility options include `theme`, `width`, `height`, `padding`, `year` (2002, 2012, 2022), `districtLines`, `districtFills`, `districtStyle`, `districtLabels`, `neighborhoodLines`, `neighborhoodLabels`, `labels`, `labelLayout`, `highways`, `keyRoads`, `roadLabels`, `landmarks`, `bartStations`, `markers`, `overlays`, `title`, `idPrefix`, `colors`, and `animation`. Each optional layer is independent. User-supplied text and attributes are escaped in SVG output.
25
25
 
26
26
  Custom district IDs must be finite numbers. An empty `labelPoints` array uses the district's primary `label` position.
27
27
 
@@ -29,6 +29,34 @@ The SVG description names the selected neighborhood source for source-aware data
29
29
 
30
30
  `animation: true` (or `{ duration, delay }`) makes a static map draw itself with self-contained, scoped CSS: land fades in, the coast and lines draw, districts grow, then labels and points appear. It plays when the SVG is inserted into a page or loaded as an image, needs no JavaScript, and stays still under `prefers-reduced-motion`. Re-insert the markup to replay it. Default output is unchanged.
31
31
 
32
+ 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
+
34
+ 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.
35
+
36
+ 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.
37
+
38
+ ```js
39
+ createGuideSVG({
40
+ labelLayout: {
41
+ width: 800,
42
+ haloColor: '#1b2c28',
43
+ compact: { width: 480, maxLabels: 4 },
44
+ },
45
+ colors: { water: '#0a1412', land: '#1b2c28', label: '#d5e3e8' },
46
+ markers: places,
47
+ });
48
+ ```
49
+
50
+ See the [field-guide preview recipe](consumer-integration.md#editorial-map-previews) for a complete build-time example.
51
+
52
+ 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.
53
+
54
+ ## Editorial palettes
55
+
56
+ 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.
57
+
58
+ 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.
59
+
32
60
  ## Interactive maps
33
61
 
34
62
  `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.
@@ -105,3 +133,11 @@ Neighborhood IDs are exact source-scoped feature IDs, rather than display names
105
133
  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.
106
134
 
107
135
  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.
136
+
137
+ ## Linked place cards
138
+
139
+ `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.
140
+
141
+ 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.
142
+
143
+ 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
@@ -7,6 +7,7 @@ Choose by task. All snippets use the public package API; browser examples need a
7
7
  | I want to… | Start here |
8
8
  | --- | --- |
9
9
  | Render an SVG on a server | [Static map](#static-svg) |
10
+ | Show labels before JavaScript loads | [Static-to-interactive guide](consumer-integration.md#keep-overview-labels-consistent) |
10
11
  | Add a small map to a browser | [Lightweight guide](#lightweight-interactive-guide) |
11
12
  | Bundle only selected geography | [Selected data](#selected-geography) |
12
13
  | Draw a route over the city | [Route overlay](#route-overlay) |
@@ -15,6 +15,46 @@ const markup = createGuideShell({ title: 'Explore San Francisco' });
15
15
 
16
16
  The shell uses an 800 × 800 projection with 28 units of padding, matching the interactive map. Use `createGuideSVG()` for custom dimensions. Rendering is offline, with zero runtime dependencies.
17
17
 
18
+ ## Keep overview labels consistent
19
+
20
+ Opt into the browser's overview label priorities and collision rules with a target **canvas width in CSS pixels**:
21
+
22
+ ```ts
23
+ const labeledMarkup = createGuideShell({
24
+ title: 'Explore San Francisco',
25
+ labelLayout: { width: 390 },
26
+ });
27
+ ```
28
+
29
+ This keeps the 800 × 800 projection. It sizes and places text for a 390 px canvas, prioritizes major parks and primary roads, includes neighborhood names, and avoids label, marker, and BART-symbol collisions. BART names and smaller parks remain deferred until interactive zoom. Use the same geography, colors, markers and layer switches when mounting. Set `layers.neighborhoodLabels: false` on both renderers to omit neighborhood names.
30
+
31
+ The width is a rendering hint, not a responsive script. Generate an appropriate variant for your known embed width; an SVG displayed at another width scales its labels with the artwork. Static layout uses conservative estimated system-font widths, while the browser measures actual fonts and recalculates on resize. Font metrics, custom browser label styles, selection, clustering and HTML overlays can therefore change the final label set or position. This reduces the transition without promising pixel-identical hydration. Existing calls without `labelLayout` keep their original fixed labels.
32
+
33
+ For a fixed-width embed, use shared inputs on the server and in the browser:
34
+
35
+ ```ts
36
+ // Shared inputs: coordinates, IDs, radii, colors and layer choices must match.
37
+ const guideMarkers = [
38
+ { id: 'ferry', label: 'Ferry Building', lng: -122.3937, lat: 37.7955, radius: 7 },
39
+ ];
40
+ const guideLayers = { neighborhoodLabels: true, roadLabels: true };
41
+ const guideColors = { label: '#183e58', landmark: '#426641' };
42
+
43
+ // Server: place this HTML in a canvas that is 390 CSS pixels wide.
44
+ const labeledPlacesMarkup = createGuideShell({
45
+ labelLayout: { width: 390 },
46
+ markers: guideMarkers,
47
+ layers: guideLayers,
48
+ appearance: { colors: guideColors },
49
+ });
50
+ ```
51
+
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
+
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
+
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
+
18
58
  ## Enhance the existing frame
19
59
 
20
60
  In your browser entry, mount the controller into that shell:
@@ -27,7 +67,9 @@ if (!shell) throw new Error('Missing guide shell');
27
67
  const guide = mountGuideController(shell, {
28
68
  attribution: 'compact',
29
69
  features: { motion: { duration: 400 }, markerEntrance: true },
30
- appearance: { theme: 'transit' },
70
+ markers: guideMarkers,
71
+ layers: guideLayers,
72
+ appearance: { theme: 'transit', colors: guideColors },
31
73
  });
32
74
 
33
75
  // Call before your application removes the containing view.
@@ -45,3 +87,41 @@ For a new browser-only guide, use `createGuideController()` and mount its `.elem
45
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.
46
88
 
47
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.
@@ -1,12 +1,12 @@
1
1
  # Guide bundle size report
2
2
 
3
- Generated 2026-10-11 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-14 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) | 118.8 KB | 34.6 KB | — |
8
- | Static renderer | 25.8 KB | 7.9 KB | — |
9
- | Guide preset | 507.8 KB | 122.0 KB | 467.0 KB |
7
+ | Renderer root (explicit data) | 122.8 KB | 36.3 KB | — |
8
+ | Static renderer | 33.2 KB | 10.2 KB | — |
9
+ | Guide preset | 507.0 KB | 122.1 KB | 467.0 KB |
10
10
 
11
11
  **500 KB target:** met.
12
12