@kahwee/sf-map-svg 4.3.1 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,100 @@
1
+ import { interiorAnchor, layoutLabels, projectedBounds } from './explorer-layout.js';
2
+ import { labelCandidates, styleLabels } from './label-candidates.js';
3
+ import { escapeXml, number } from './svg.js';
4
+ /** Conservative portable metrics; the browser refines these with its actual font. */
5
+ export function estimateLabelWidth(text, fontSize) {
6
+ return Array.from(text).reduce((width, char) => width +
7
+ (/[\u0300-\u036f]/u.test(char)
8
+ ? 0
9
+ : /[\s.,:;'!|ilI]/u.test(char)
10
+ ? 0.32
11
+ : /[MW@%]/u.test(char)
12
+ ? 0.95
13
+ : (char.codePointAt(0) ?? 0) > 0x024f
14
+ ? 1
15
+ : /[A-Z]/u.test(char)
16
+ ? 0.72
17
+ : 0.6) *
18
+ fontSize, 0);
19
+ }
20
+ /** Interactive overview rules, expressed entirely in screen pixels before serialization. */
21
+ export function staticLabels(data, options, project) {
22
+ const width = options.labelLayout?.width ?? options.width ?? 800;
23
+ const unit = (options.width ?? 800) / width;
24
+ const height = (options.height ?? 800) / unit;
25
+ const point = (coordinates) => {
26
+ const [x, y] = project(coordinates);
27
+ return [x / unit, y / unit];
28
+ };
29
+ const markers = options.markers ?? [];
30
+ const marker = markers.find((item) => item.selected);
31
+ const neighborhoods = options.neighborhoodLabels
32
+ ? (data.neighborhoods ?? [])
33
+ .map((item) => {
34
+ const bounds = projectedBounds(item.geometry, point);
35
+ return {
36
+ ...item,
37
+ point: interiorAnchor(item.geometry, point),
38
+ area: (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]),
39
+ };
40
+ })
41
+ .sort((a, b) => b.area - a.area || (a.id ?? a.name).localeCompare(b.id ?? b.name))
42
+ : [];
43
+ const districts = options.districtLabels !== false ? (data.districts?.[options.year ?? 2022] ?? []) : [];
44
+ for (const district of districts)
45
+ if (!Number.isFinite(district.id))
46
+ throw new TypeError('District IDs must be finite numbers.');
47
+ const candidates = styleLabels(labelCandidates({
48
+ selected: marker
49
+ ? [
50
+ {
51
+ point: point([marker.lng, marker.lat]),
52
+ name: marker.label ?? marker.id,
53
+ kind: 'selected-marker',
54
+ },
55
+ ]
56
+ : [],
57
+ districts: options.districtLabels !== false
58
+ ? districts.flatMap((district) => (district.labelPoints.length ? district.labelPoints : [district.label]).map((label) => ({ point: point(label), name: String(district.id), kind: 'district' })))
59
+ : [],
60
+ stations: [], // Station names appear only after zooming; symbols remain at overview.
61
+ parks: options.landmarks
62
+ ? (data.landmarks ?? []).map((item) => ({
63
+ point: point(item.label),
64
+ name: item.name,
65
+ kind: 'park',
66
+ }))
67
+ : [],
68
+ roads: (options.roadLabels ?? options.keyRoads)
69
+ ? (data.keyRoads ?? []).map((item) => ({
70
+ point: point(item.label),
71
+ name: item.name,
72
+ level: item.level ?? 'primary',
73
+ kind: 'road',
74
+ }))
75
+ : [],
76
+ neighborhoods: neighborhoods.flatMap((item) => item.point ? [{ point: item.point, name: item.name, kind: 'neighborhood' }] : []),
77
+ }, width, 1), { colors: options.colors, markerRadius: marker?.radius ?? 6 });
78
+ const obstacles = [
79
+ ...(options.bartStations
80
+ ? (data.bartStations ?? []).map((item) => {
81
+ const [x, y] = point(item.coordinates);
82
+ return [x - 6, y - 6, x + 6, y + 6];
83
+ })
84
+ : []),
85
+ ...markers.map((item) => {
86
+ const [x, y] = point([item.lng, item.lat]);
87
+ // Also cover the legacy SVG marker when it is larger than its interactive counterpart.
88
+ const radius = Math.max(item.radius ?? 6, (item.radius ?? (item.selected ? 8 : 5)) / unit) + 3;
89
+ return [x - radius, y - radius, x + radius, y + radius];
90
+ }),
91
+ ];
92
+ const placed = layoutLabels(candidates.map((item) => ({
93
+ ...item,
94
+ x: item.point[0],
95
+ y: item.point[1],
96
+ textWidth: estimateLabelWidth(item.name, item.fontSize),
97
+ textHeight: item.fontSize * 1.25,
98
+ })), width, height, obstacles);
99
+ return `<g data-layer="overview-labels" font-family="system-ui,sans-serif">${placed.map((item) => `<text data-label-kind="${escapeXml(item.kind)}" text-anchor="middle" x="${number((item.left + item.textWidth / 2) * unit)}" y="${number((item.top + item.fontSize) * unit)}" font-size="${number(item.fontSize * unit)}" font-weight="${item.fontWeight}" fill="${escapeXml(item.fill)}" stroke="${escapeXml(item.halo)}" stroke-width="${number(3 * unit)}" stroke-linejoin="round" paint-order="stroke">${escapeXml(item.name)}</text>`).join('')}</g>`;
100
+ }
@@ -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,11 @@ 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', ['width']);
88
+ if (!Number.isFinite(options.labelLayout.width) || options.labelLayout.width <= 0)
89
+ throw new RangeError('Label layout width must be positive and finite.');
90
+ }
82
91
  if (options.markers !== undefined)
83
92
  validateMarkers(options.markers);
84
93
  if (options.overlays !== undefined)
@@ -45,12 +45,18 @@ 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
+ };
54
60
  highways?: boolean;
55
61
  keyRoads?: boolean;
56
62
  roadLabels?: boolean;
@@ -73,7 +79,7 @@ export interface SFMapOptions {
73
79
  colors?: Partial<Record<'water' | 'land' | 'district' | 'neighborhood' | 'highway' | 'road' | 'park' | 'landmark' | 'bart' | 'label' | 'marker' | 'selected', string>>;
74
80
  }
75
81
  /** Presentation supported by both the static and interactive renderers. */
76
- export type StaticMapLayers = Omit<InteractiveLayers, 'neighborhoodLabels'>;
82
+ export type StaticMapLayers = InteractiveLayers;
77
83
  export type StaticMapAppearance = Pick<SFMapOptions, 'theme' | 'colors' | 'districtStyle'>;
78
84
  /** Serializable viewport in the interactive map's fixed 800 × 800 projected space. */
79
85
  export type MapViewport = readonly [x: number, y: number, size: number];
@@ -240,7 +246,7 @@ export interface NeighborhoodExplorerElement extends HTMLElement {
240
246
  fitGeometry(geometry: Geometry, padding?: number | MapPadding, options?: CameraOptions): void;
241
247
  /** Explicitly engage map touch gestures; false restores page gestures. */
242
248
  setTouchNavigation(enabled: boolean): void;
243
- /** Reconcile stable IDs; retained markers preserve nodes, focus, and entrance animations. */
249
+ /** Reconcile stable IDs; preserve existing nodes and focus. Pin visuals allocate on first visibility/selection. */
244
250
  setMarkers(markers: readonly MapMarker[]): void;
245
251
  setOverlays(overlays: readonly MapOverlay[]): void;
246
252
  selectMarker(id: string | null, options?: {
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,10 @@ 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
+ 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
+
32
36
  ## Interactive maps
33
37
 
34
38
  `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.
@@ -39,7 +43,13 @@ The controller also supports marker, neighborhood, and district selection; distr
39
43
 
40
44
  If a district callback destroys the map or selects a newer district, the interrupted activation stops without moving keyboard focus or sending a stale event.
41
45
 
42
- `setMarkers()` reconciles by stable marker `id`: retained markers keep their DOM nodes, focus, and in-progress entrance animations; only new IDs animate in. Identical ordered marker updates are a visual no-op. Camera state and `viewportchange` events remain synchronous, while animated camera steps and their dependent label/marker layout commit in the same browser frame.
46
+ `setMarkers()` reconciles by stable marker `id`: retained markers keep any previously created nodes, focus, and in-progress entrance animations. New pin entrances start when the pin first becomes visible; a cached pin does not animate again merely because it returns to the viewport. Disabling entrances, reduced-motion changes, and destruction cancel pending and active entrances. Identical ordered marker updates are a visual no-op. Camera state and `viewportchange` events remain synchronous, while animated camera steps and their dependent label/marker layout commit in the same browser frame.
47
+
48
+ Interactive maps create individual pin nodes only on first visibility or selection, then cache them for reuse. Record and style updates before first visibility apply to the eventual node. They mount only pins and cluster symbols intersecting a padded viewport; clustered member pins detach. All records remain in the native picker and selectable through the API. Selected and keyboard-focused pins stay mounted, even outside the viewport, and retained nodes return when panning back. Cluster membership uses the complete catalog and stays stable during pan at the same zoom. Static SVG exports are unaffected.
49
+
50
+ Visible markers and clusters share one tab stop. Use `[` and `]` to move focus, Enter or Space to activate, and Tab to leave the group. Arrow keys retain map panning. Marker replacement preserves the focused ID when available and moves focus to a surviving marker when it is removed.
51
+
52
+ Activating coincident pins opens a place chooser, including when compact embeds hide the native marker picker. Clusters zoom when their members can separate at maximum zoom; otherwise they open the chooser. Escape or Close returns focus to the originating pin or cluster when it remains visible. Choosing a place selects its marker; replacing markers or destroying the map closes the chooser. `clusteractivate` still reports the activated cluster's markers.
43
53
 
44
54
  For a small guide, import `guideMapData` from `/guide/data` and pass it to `createMap`. The optional `/guide` entry also provides `createGuideMap`, `mountGuideMap`, and detailed-data loading for existing guide layouts. `/transit` provides the standalone schematic transit animation. See [examples](EXAMPLES.md) and [consumer integration](consumer-integration.md).
45
55
 
@@ -96,6 +106,6 @@ Neighborhood IDs are exact source-scoped feature IDs, rather than display names
96
106
 
97
107
  ## Coordinates and units
98
108
 
99
- GeoJSON and static projection inputs use `[longitude, latitude]` in WGS84; markers use `{ lng, lat }`. Static `project` returns SVG units. `projectToScreen(lng, lat)` returns pixels relative to the mounted canvas. Camera viewports and pan deltas use the fixed 800 × 800 projected map space, independent of canvas size. Marker radius uses screen pixels in browser maps and SVG units in static output.
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.
100
110
 
101
111
  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.
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) |
package/docs/api-audit.md CHANGED
@@ -16,8 +16,9 @@ unbounded geographic data can never fail.
16
16
  | A new camera request during animation | The new request cancels the prior generation. A callback cannot revive it after cancellation/disposal. |
17
17
  | Selection callback selecting another item | Newer selection wins. The old selection cannot subsequently move the camera or send a stale activation callback. |
18
18
  | `selectNeighborhood(..., {fit:false})` in basemap mode | Selection can change presentation, but preserves the camera. Explicit layer overrides remain authoritative. |
19
- | Clustering and selected/focused markers | Selected/focused pins remain individually reachable. Coincident alternatives remain available through the chooser. |
20
- | Marker entrance and reduced motion | Entrances are optional, independent of camera motion, and cancel on preference changes or disposal. Stable IDs avoid repeated entrances on filtering. |
19
+ | Clustering and selected/focused markers | Selected/focused pins remain individually reachable. Coincident pins and clusters that cannot separate at maximum zoom open a place chooser, including compact shells with hidden native pickers. Visible pins and clusters share one tab stop with bracket-key navigation. |
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
+ | 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. |
21
22
  | Hiding touch control while gestures are enabled | Returns touch scrolling to the page. Other toolbar controls remain independent. |
22
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. |
23
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. |
@@ -45,7 +46,7 @@ Live appearance patches prepare values, district styles and source replacement b
45
46
  | Accessibility | Feature round trips, keyboard activation, focus recovery after removal/clustering, independent controls, touch names, and reduced motion have browser regressions. Hiding every alternative chooser is a consumer accessibility decision. |
46
47
  | Output safety | SVG text and attributes are escaped. This is not a security sandbox for CSS tokens or caller-provided DOM. Keep untrusted callout content in `textContent`; the consumer controls CSS/resource policy. |
47
48
  | Packaging | Clean tarball install exercises entrypoints and emitted declarations, including `LineString` overlays. No runtime dependencies or embedded geographic coordinate blobs were added. |
48
- | Performance | Production bundle checks enforce the guide and renderer ceilings and inspect dataset inclusion. Grouping has a 2,000-marker regression; millions of markers are outside this guide-oriented design. |
49
+ | Performance | Production bundle checks enforce the guide and renderer ceilings and inspect dataset inclusion. Viewport culling, focus, reattachment, and cluster reuse have 2,000-marker regressions across three engines and two widths; millions of markers are outside this guide-oriented design. |
49
50
 
50
51
  Regression sources: `test/api-contract.test.js`, `test/camera.test.js`,
51
52
  `test/viewport.test.js`, `stories/Robustness.stories.ts`,
@@ -61,7 +62,7 @@ Static grouped layers and appearance reject unknown keys, while flat compatibili
61
62
  interactive option-key validation is not a claim that all static options share the
62
63
  same schema. Host layout, delayed images, fonts, and application cards need their
63
64
  own CLS tests. The scale bar is an approximate local Mercator scale, not survey
64
- instrumentation. Clustering does not spiderfy coincident locations. Geographic
65
+ instrumentation. Coincident locations use a place chooser rather than spiderfying. Geographic
65
66
  source correctness remains governed by SOURCES.md and existing topology tests.
66
67
 
67
68
  ## Extending the contract
@@ -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, 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.
@@ -1,12 +1,12 @@
1
1
  # Guide bundle size report
2
2
 
3
- Generated 2026-10-01 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-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.
4
4
 
5
5
  | Entry | Initial JS, raw | Initial JS, gzip | Explicit detail JS, gzip |
6
6
  | --- | ---: | ---: | ---: |
7
- | Renderer root (explicit data) | 99.3 KB | 29.1 KB | — |
8
- | Static renderer | 18.2 KB | 5.8 KB | — |
9
- | Guide preset | 489.1 KB | 116.8 KB | 467.0 KB |
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 |
10
10
 
11
11
  **500 KB target:** met.
12
12