@kahwee/sf-map-svg 4.3.2 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 4.4.0 — 2026-10-12
8
+
9
+ - Add opt-in static `labelLayout: { width }` to `renderMap`, `createGuideSVG`, and `createGuideShell`. Static and interactive maps now share overview priorities, neighborhood anchors, font sizes, and collision rules; centered labels retain their anchors during enhancement.
10
+ - Add static `neighborhoodLabels`, available flat or under `layers`, for the new layout. Guide SVGs and shells enable neighborhood names when opting in. Existing fixed-label output and interactive defaults remain unchanged; `labels: false` still hides text while preserving symbols and accessible titles.
11
+ - Keep static layout server-safe and dependency-free. The width is a CSS canvas-width hint, not the SVG projection width; portable font estimates can produce a different visible label set from browser-measured fonts. Document matching data, markers, colors, and layers, plus responsive-width and custom-style limitations.
12
+ - Keep server font estimation out of interactive-only imports and browser runtime code out of static imports. Refresh bundle reports and enforce bounded size budgets for the combined renderer and static-capable Pages tools.
13
+ - Add desktop and phone enhancement stories, collision and input-validation regressions, an SVG example, and copyable integration documentation. Existing screenshot baselines remain unchanged.
14
+
7
15
  ## 4.3.2 — 2026-10-10
8
16
 
9
17
  - Update compatible development tooling and refresh dependency security fixes.
package/README.md CHANGED
@@ -33,6 +33,23 @@ const { svg, project, viewBox } = renderMap(staticMapData, {
33
33
 
34
34
  See [map options and animations](docs/API.md#static-maps) for optional layers and static animation.
35
35
 
36
+ ## Labels before JavaScript loads
37
+
38
+ Version 4.4 adds an opt-in overview layout for server-rendered guides:
39
+
40
+ ```ts
41
+ import { createGuideShell } from '@kahwee/sf-map-svg/guide/static';
42
+
43
+ const guideMarkup = createGuideShell({
44
+ title: 'Explore San Francisco',
45
+ labelLayout: { width: 390 },
46
+ });
47
+ ```
48
+
49
+ `width` is the expected canvas width in CSS pixels. The SVG keeps its 800 × 800 projection while labels share the interactive map's priorities, anchors, sizes and collision rules. Guide shells include neighborhood names in this layout; calls without `labelLayout` keep their previous output.
50
+
51
+ Enhance the shell with `mountGuideController()` using the same data, colors, markers and layers. Static font widths are estimated, so browser fonts and resize can change which labels fit. See the [complete integration recipe and width guidance](docs/consumer-integration.md#keep-overview-labels-consistent). No migration is required from 4.3.
52
+
36
53
  ## Interactive map
37
54
 
38
55
  ```ts
@@ -165,7 +182,7 @@ pnpm benchmark:markers --counts=2000 --browsers=chromium,firefox,webkit \
165
182
 
166
183
  ### Cached label preparation and renderer components
167
184
 
168
- Neighborhood priority is sorted once per source. Styled label candidates are reused until selection, catalog, source, layers, appearance, or zoom/width eligibility changes. Overlay bounds are measured before marker and cluster SVG updates; secondary-road visibility and scale furniture skip unchanged writes. Marker catalog reconciliation and cluster rendering now have separate modules, reducing the explorer controller from 2,316 to 2,151 lines. The interactive renderer is 34.6 KiB gzip, up from 34.2 KiB, within the unchanged 35 KiB limit; static rendering remains 7.9 KiB.
185
+ Neighborhood priority is sorted once per source. Styled label candidates are reused until selection, catalog, source, layers, appearance, or zoom/width eligibility changes. Overlay bounds are measured before marker and cluster SVG updates; secondary-road visibility and scale furniture skip unchanged writes. Marker catalog reconciliation and cluster rendering now have separate modules, reducing the explorer controller from 2,316 to 2,151 lines. At the 4.3.2 marker-performance baseline, the renderer was 34.6 KiB gzip and static rendering was 7.9 KiB. See the [current bundle report](docs/guide-bundle-report.md) for measurements including the opt-in static label layout.
169
186
 
170
187
  A fresh Chromium comparison against `e774fbd` used 2,000 labeled markers, 20 operation samples, both widths, and all six frame workloads in basemap and neighborhood modes. Neighborhood-mode frame interval p95 values (milliseconds):
171
188
 
@@ -11,6 +11,7 @@ import { fitBounds, interiorAnchor, projectedBounds } from './explorer-layout.js
11
11
  import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from './features.js';
12
12
  import { createFrameScheduler } from './frame-scheduler.js';
13
13
  import { geometryPath } from './geometry.js';
14
+ import { labelCandidates, styleLabels, } from './label-candidates.js';
14
15
  import { createLabelRenderer } from './label-renderer.js';
15
16
  import { createLayerTransitions } from './layer-transition.js';
16
17
  import { createSFMapWithData, districtColors, getLayerPathsWithData, resolveMapColors, } from './map-core.js';
@@ -881,46 +882,28 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
881
882
  name: selectedItem.feature.properties.canonicalName,
882
883
  kind: 'selected',
883
884
  });
884
- if (enabled('districtLabels'))
885
- candidates.push(...districtItems);
886
- if (enabled('bartStations') && zoom >= 1.8)
887
- candidates.push(...stationItems);
888
- candidates.push(...(enabled('landmarks') ? parkItems : []).filter((park) => zoom >= 1.8 ||
889
- park.name === 'Golden Gate Park' ||
890
- (width >= 550 && park.name === 'Presidio')));
891
- candidates.push(...(enabled('roadLabels') ? roadItems : []).filter((road) => road.level === 'primary' || zoom >= 1.8));
892
- if (enabled('neighborhoodLabels'))
893
- candidates.push(...orderedNeighborhoods.flatMap((item) => item.feature !== selected && item.point
894
- ? [
895
- {
896
- point: item.point,
897
- name: item.feature.properties.canonicalName,
898
- kind: 'neighborhood',
899
- },
900
- ]
901
- : []));
902
- preparedLabels = candidates.map((item) => {
903
- const fontSize = item.kind === 'road'
904
- ? Math.max(minLabelSize, Math.min(11, maxLabelSize))
905
- : Math.max(minLabelSize, Math.min(maxLabelSize, 12));
906
- return {
907
- ...item,
908
- fontSize,
909
- fontWeight: labelStyle.fontWeight ??
910
- (item.kind.startsWith('selected') || item.kind === 'district' ? 700 : 550),
911
- fill: colors.label ??
912
- (item.kind === 'park'
913
- ? (colors.landmark ?? '#426641')
914
- : item.kind === 'road'
915
- ? '#77736b'
916
- : '#163d61'),
917
- halo: labelStyle.haloColor ?? '#ffffff',
918
- offset: item.kind === 'selected-marker'
919
- ? (marker?.marker.radius ?? markerRadius) + 9
920
- : item.kind === 'bart'
921
- ? 11
922
- : 0,
923
- };
885
+ preparedLabels = styleLabels(labelCandidates({
886
+ selected: candidates,
887
+ districts: enabled('districtLabels') ? districtItems : [],
888
+ stations: enabled('bartStations') ? stationItems : [],
889
+ parks: enabled('landmarks') ? parkItems : [],
890
+ roads: enabled('roadLabels') ? roadItems : [],
891
+ neighborhoods: enabled('neighborhoodLabels')
892
+ ? orderedNeighborhoods.flatMap((item) => item.feature !== selected && item.point
893
+ ? [
894
+ {
895
+ point: item.point,
896
+ name: item.feature.properties.canonicalName,
897
+ kind: 'neighborhood',
898
+ },
899
+ ]
900
+ : [])
901
+ : [],
902
+ }, width, zoom), {
903
+ colors,
904
+ labelStyle,
905
+ labelSize: { min: minLabelSize, max: maxLabelSize },
906
+ markerRadius: marker?.marker.radius ?? markerRadius,
924
907
  });
925
908
  return preparedLabels;
926
909
  }
@@ -15,6 +15,7 @@ export const guideMapData = deepFreeze({
15
15
  map: {
16
16
  coast: coast.features[0].geometry,
17
17
  neighborhoods: neighborhoodData.features.map((feature) => ({
18
+ id: feature.id,
18
19
  name: feature.properties.canonicalName,
19
20
  geometry: feature.geometry,
20
21
  })),
@@ -9,6 +9,7 @@ export function createGuideSVG(options = {}) {
9
9
  districtLines: false,
10
10
  districtLabels: false,
11
11
  neighborhoodLines: true,
12
+ neighborhoodLabels: options.labelLayout !== undefined,
12
13
  landmarks: true,
13
14
  highways: true,
14
15
  keyRoads: true,
@@ -0,0 +1,24 @@
1
+ import type { NeighborhoodExplorerOptions } from './types.js';
2
+ export interface LabelItem {
3
+ point: [number, number];
4
+ name: string;
5
+ kind: string;
6
+ level?: 'primary' | 'secondary';
7
+ }
8
+ export interface RenderLabel extends LabelItem {
9
+ fontSize: number;
10
+ fontWeight: number;
11
+ fill: string;
12
+ halo: string;
13
+ offset: number;
14
+ }
15
+ /** Shared priority and overview visibility for server and browser labels. */
16
+ export declare function labelCandidates(groups: {
17
+ selected: LabelItem[];
18
+ districts: LabelItem[];
19
+ stations: LabelItem[];
20
+ parks: LabelItem[];
21
+ roads: LabelItem[];
22
+ neighborhoods: LabelItem[];
23
+ }, width: number, zoom: number): LabelItem[];
24
+ export declare function styleLabels(candidates: LabelItem[], { colors, labelStyle, labelSize, markerRadius, }: Pick<NeighborhoodExplorerOptions, 'colors' | 'labelStyle' | 'labelSize' | 'markerRadius'>): RenderLabel[];
@@ -0,0 +1,29 @@
1
+ /** Shared priority and overview visibility for server and browser labels. */
2
+ export function labelCandidates(groups, width, zoom) {
3
+ return [
4
+ ...groups.selected,
5
+ ...groups.districts,
6
+ ...(zoom >= 1.8 ? groups.stations : []),
7
+ ...groups.parks.filter((park) => zoom >= 1.8 ||
8
+ park.name === 'Golden Gate Park' ||
9
+ (width >= 550 && park.name === 'Presidio')),
10
+ ...groups.roads.filter((road) => road.level === 'primary' || zoom >= 1.8),
11
+ ...groups.neighborhoods,
12
+ ];
13
+ }
14
+ export function styleLabels(candidates, { colors = {}, labelStyle = {}, labelSize = {}, markerRadius = 6, }) {
15
+ return candidates.map((item) => ({
16
+ ...item,
17
+ fontSize: Math.max(labelSize.min ?? 11, Math.min(labelSize.max ?? 12, item.kind === 'road' ? 11 : 12)),
18
+ fontWeight: labelStyle.fontWeight ??
19
+ (item.kind.startsWith('selected') || item.kind === 'district' ? 700 : 550),
20
+ fill: colors.label ??
21
+ (item.kind === 'park'
22
+ ? (colors.landmark ?? '#426641')
23
+ : item.kind === 'road'
24
+ ? '#77736b'
25
+ : '#163d61'),
26
+ halo: labelStyle.haloColor ?? '#ffffff',
27
+ offset: item.kind === 'selected-marker' ? markerRadius + 9 : item.kind === 'bart' ? 11 : 0,
28
+ }));
29
+ }
@@ -1,14 +1,5 @@
1
1
  import type { Bounds } from '../data/types.js';
2
- export interface RenderLabel {
3
- point: [number, number];
4
- name: string;
5
- kind: string;
6
- fontSize: number;
7
- fontWeight: number;
8
- fill: string;
9
- halo: string;
10
- offset: number;
11
- }
2
+ import type { RenderLabel } from './label-candidates.js';
12
3
  /** Reuse text nodes and screen-space metrics throughout a camera animation. */
13
4
  export declare function createLabelRenderer(layer: SVGGElement): {
14
5
  invalidateMetrics(): void;
@@ -1,10 +1,12 @@
1
1
  import type { Geometry, Position } from '../data/types.js';
2
2
  import type { DistrictYear, SFMapOptions } from './types.js';
3
3
  export type { DistrictYear, MapMarker, MapOverlay, SFMapOptions } from './types.js';
4
+ import { type Project } from './geometry.js';
4
5
  export interface SFMapData {
5
6
  coast: Geometry;
6
7
  districts?: Readonly<Partial<Record<DistrictYear, readonly DistrictRowData[]>>>;
7
8
  neighborhoods?: readonly {
9
+ id?: string;
8
10
  name: string;
9
11
  geometry: Geometry;
10
12
  }[];
@@ -95,7 +97,7 @@ export declare function resolveMapColors(theme: SFMapOptions['theme'], colors?:
95
97
  selected: string;
96
98
  };
97
99
  /** Make an offline SVG and the matching longitude/latitude projection. */
98
- export declare function createSFMapWithData(options: SFMapOptions, data: SFMapData): {
100
+ export declare function createSFMapWithData(options: SFMapOptions, data: SFMapData, renderLabels?: (project: Project) => string): {
99
101
  svg: string;
100
102
  project: (coordinates: Position) => [number, number];
101
103
  viewBox: [number, number, number, number];
@@ -110,7 +110,7 @@ export function resolveMapColors(theme, colors = {}) {
110
110
  }
111
111
  let sequence = 0;
112
112
  /** Make an offline SVG and the matching longitude/latitude projection. */
113
- export function createSFMapWithData(options, data) {
113
+ export function createSFMapWithData(options, data, renderLabels) {
114
114
  const { theme = 'districts', width = 800, height = 800, year = 2022, districtLines = true, neighborhoodLines = false, districtFills = true, districtLabels = true, labels = true, highways = false, keyRoads = false, landmarks = false, bartStations = false, markers = [], title = 'San Francisco map', idPrefix = `sf-map-${++sequence}`, } = options;
115
115
  validateMarkers(markers);
116
116
  validateOverlays(options.overlays ?? []);
@@ -182,17 +182,19 @@ export function createSFMapWithData(options, data) {
182
182
  if (districtLines)
183
183
  parts.push(layers.districtLines(districtPaths, context));
184
184
  parts.push(`<path data-layer="coastline" d="${coastPath}" ${stroke(colors.district, 0.65)}${animated ? ' pathLength="1"' : ''}/>`);
185
- if (labels && districtLabels)
185
+ if (labels && !renderLabels && districtLabels)
186
186
  parts.push(layers.districtLabels(districts, context));
187
- if (labels && landmarks)
187
+ if (labels && !renderLabels && landmarks)
188
188
  parts.push(layers.landmarkLabels(landmarkData, context));
189
- if (labels && roadLabels)
189
+ if (labels && !renderLabels && roadLabels)
190
190
  parts.push(layers.keyRoadLabels(roadData, context));
191
191
  if (bartStations)
192
- parts.push(layers.bartStations(stationData, context));
192
+ parts.push(layers.bartStations(stationData, { ...context, labels: labels && !renderLabels }));
193
193
  for (const overlay of options.overlays ?? []) {
194
194
  parts.push(`<path data-overlay-id="${escapeXml(overlay.id)}"${overlay.label ? ` aria-label="${escapeXml(overlay.label)}"` : ''} d="${path(overlay.geometry)}" fill="${escapeXml(overlay.fill ?? 'none')}" fill-opacity="${overlay.fillOpacity ?? 1}" stroke="${escapeXml(overlay.stroke ?? colors.road)}" stroke-width="${overlay.strokeWidth ?? 2}" vector-effect="non-scaling-stroke"${animated ? ' pathLength="1"' : ''}${overlay.visible === false ? ' display="none"' : ''}/>`);
195
195
  }
196
+ if (labels && renderLabels)
197
+ parts.push(renderLabels(project));
196
198
  parts.push(layers.markers(markers, context), '</g></svg>');
197
199
  return {
198
200
  svg: parts.join(''),
@@ -0,0 +1,7 @@
1
+ import type { Project } from './geometry.js';
2
+ import type { SFMapData } from './map-core.js';
3
+ import type { SFMapOptions } from './types.js';
4
+ /** Conservative portable metrics; the browser refines these with its actual font. */
5
+ export declare function estimateLabelWidth(text: string, fontSize: number): number;
6
+ /** Interactive overview rules, expressed entirely in screen pixels before serialization. */
7
+ export declare function staticLabels(data: SFMapData, options: SFMapOptions, project: Project): string;
@@ -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];
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.
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, 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-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-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) | 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.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
 
package/llms-full.txt CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  > Offline San Francisco SVG maps with explicit geographic imports, no runtime dependencies, static rendering, and a browser controller.
4
4
 
5
- @kahwee/sf-map-svg 4.3.2 · local working tree. Node >=24.0.0 for server rendering; browsers need a DOM and a JSON-capable bundler.
5
+ @kahwee/sf-map-svg 4.4.0 · local working tree. Node >=24.0.0 for server rendering; browsers need a DOM and a JSON-capable bundler.
6
6
 
7
7
  These are working-tree docs. Unreleased changelog entries may describe APIs absent from npm; verify the installed version before copying them.
8
8
 
9
9
  Use renderMap(data, options) for SVG strings; createMap(data, options) returns a controller. Mount map.element and destroy it on unmount. Choose mode explicitly: createMap defaults to basemap. Geography is separate; staticMapData and fullMapData have different shapes.
10
10
 
11
- <!-- Generated from README.md; @kahwee/sf-map-svg 4.3.2 · local working tree. -->
11
+ <!-- Generated from README.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
12
12
 
13
13
  # SF Map SVG
14
14
 
@@ -45,6 +45,23 @@ const { svg, project, viewBox } = renderMap(staticMapData, {
45
45
 
46
46
  See [map options and animations](https://github.com/kahwee/sf-map-svg/blob/main/docs/API.md#static-maps) for optional layers and static animation.
47
47
 
48
+ ## Labels before JavaScript loads
49
+
50
+ Version 4.4 adds an opt-in overview layout for server-rendered guides:
51
+
52
+ ```ts
53
+ import { createGuideShell } from '@kahwee/sf-map-svg/guide/static';
54
+
55
+ const guideMarkup = createGuideShell({
56
+ title: 'Explore San Francisco',
57
+ labelLayout: { width: 390 },
58
+ });
59
+ ```
60
+
61
+ `width` is the expected canvas width in CSS pixels. The SVG keeps its 800 × 800 projection while labels share the interactive map's priorities, anchors, sizes and collision rules. Guide shells include neighborhood names in this layout; calls without `labelLayout` keep their previous output.
62
+
63
+ Enhance the shell with `mountGuideController()` using the same data, colors, markers and layers. Static font widths are estimated, so browser fonts and resize can change which labels fit. See the [complete integration recipe and width guidance](https://github.com/kahwee/sf-map-svg/blob/main/docs/consumer-integration.md#keep-overview-labels-consistent). No migration is required from 4.3.
64
+
48
65
  ## Interactive map
49
66
 
50
67
  ```ts
@@ -177,7 +194,7 @@ pnpm benchmark:markers --counts=2000 --browsers=chromium,firefox,webkit \
177
194
 
178
195
  ### Cached label preparation and renderer components
179
196
 
180
- Neighborhood priority is sorted once per source. Styled label candidates are reused until selection, catalog, source, layers, appearance, or zoom/width eligibility changes. Overlay bounds are measured before marker and cluster SVG updates; secondary-road visibility and scale furniture skip unchanged writes. Marker catalog reconciliation and cluster rendering now have separate modules, reducing the explorer controller from 2,316 to 2,151 lines. The interactive renderer is 34.6 KiB gzip, up from 34.2 KiB, within the unchanged 35 KiB limit; static rendering remains 7.9 KiB.
197
+ Neighborhood priority is sorted once per source. Styled label candidates are reused until selection, catalog, source, layers, appearance, or zoom/width eligibility changes. Overlay bounds are measured before marker and cluster SVG updates; secondary-road visibility and scale furniture skip unchanged writes. Marker catalog reconciliation and cluster rendering now have separate modules, reducing the explorer controller from 2,316 to 2,151 lines. At the 4.3.2 marker-performance baseline, the renderer was 34.6 KiB gzip and static rendering was 7.9 KiB. See the [current bundle report](https://github.com/kahwee/sf-map-svg/blob/main/docs/guide-bundle-report.md) for measurements including the opt-in static label layout.
181
198
 
182
199
  A fresh Chromium comparison against `e774fbd` used 2,000 labeled markers, 20 operation samples, both widths, and all six frame workloads in basemap and neighborhood modes. Neighborhood-mode frame interval p95 values (milliseconds):
183
200
 
@@ -203,7 +220,7 @@ pnpm benchmark:markers --counts=2000 --mode=neighborhoods \
203
220
 
204
221
  ---
205
222
 
206
- <!-- Generated from docs/developer-guide.md; @kahwee/sf-map-svg 4.3.2 · local working tree. -->
223
+ <!-- Generated from docs/developer-guide.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
207
224
 
208
225
  # Developer guide
209
226
 
@@ -347,7 +364,7 @@ See [API options](https://github.com/kahwee/sf-map-svg/blob/main/docs/API.md), [
347
364
 
348
365
  ---
349
366
 
350
- <!-- Generated from docs/API.md; @kahwee/sf-map-svg 4.3.2 · local working tree. -->
367
+ <!-- Generated from docs/API.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
351
368
 
352
369
  # Map options and lifecycle
353
370
 
@@ -370,9 +387,9 @@ const { svg } = renderMap(staticMapData, presentation);
370
387
  const map = createMap(fullMapData, presentation);
371
388
  ```
372
389
 
373
- Static `layers` supports every interactive layer switch except `neighborhoodLabels`. Static `appearance` supports `theme`, `colors`, and `districtStyle`; browser typography, area interaction styles, and screen-space marker sizing remain interactive settings. Unknown keys and invalid values are rejected for both flat and grouped options. Supplied grouped keys override flat compatibility keys; omitted or `undefined` grouped keys preserve the flat value or renderer default. Undefined color tokens use the theme default instead of entering SVG attributes. The two renderers retain their existing defaults. Marker arrays accept readonly inputs.
390
+ Static `layers` supports every interactive layer switch. `neighborhoodLabels` applies to the opt-in `labelLayout` described below. Static `appearance` supports `theme`, `colors`, and `districtStyle`; browser typography, area interaction styles, and screen-space marker sizing remain interactive settings. Unknown keys and invalid values are rejected for both flat and grouped options. Supplied grouped keys override flat compatibility keys; omitted or `undefined` grouped keys preserve the flat value or renderer default. Undefined color tokens use the theme default instead of entering SVG attributes. The two renderers retain their existing defaults. Marker arrays accept readonly inputs.
374
391
 
375
- Flat compatibility options include `theme`, `width`, `height`, `padding`, `year` (2002, 2012, 2022), `districtLines`, `districtFills`, `districtStyle`, `districtLabels`, `neighborhoodLines`, `labels`, `highways`, `keyRoads`, `roadLabels`, `landmarks`, `bartStations`, `markers`, `overlays`, `title`, `idPrefix`, `colors`, and `animation`. Each optional layer is independent. User-supplied text and attributes are escaped in SVG output.
392
+ Flat compatibility options include `theme`, `width`, `height`, `padding`, `year` (2002, 2012, 2022), `districtLines`, `districtFills`, `districtStyle`, `districtLabels`, `neighborhoodLines`, `neighborhoodLabels`, `labels`, `labelLayout`, `highways`, `keyRoads`, `roadLabels`, `landmarks`, `bartStations`, `markers`, `overlays`, `title`, `idPrefix`, `colors`, and `animation`. Each optional layer is independent. User-supplied text and attributes are escaped in SVG output.
376
393
 
377
394
  Custom district IDs must be finite numbers. An empty `labelPoints` array uses the district's primary `label` position.
378
395
 
@@ -380,6 +397,10 @@ The SVG description names the selected neighborhood source for source-aware data
380
397
 
381
398
  `animation: true` (or `{ duration, delay }`) makes a static map draw itself with self-contained, scoped CSS: land fades in, the coast and lines draw, districts grow, then labels and points appear. It plays when the SVG is inserted into a page or loaded as an image, needs no JavaScript, and stays still under `prefers-reduced-motion`. Re-insert the markup to replay it. Default output is unchanged.
382
399
 
400
+ Static `labelLayout: { width: 390 }` opts into shared interactive **overview** label priorities, sizes, anchors and collision rules. `width` must be a positive finite CSS canvas width; it does not alter the SVG viewBox. Set `neighborhoodLabels: true` (flat or under `layers`) to add area names in this layout. Guide SVGs and shells enable neighborhood names by default only when opting into `labelLayout`. `labels: false` remains the master switch. Default static output is unchanged.
401
+
402
+ Static placement uses estimated system-font metrics without browser globals. Interactive placement measures actual fonts and includes live selection and HTML furniture, so exact glyph positions and the visible label set can differ. See the [integration recipe](https://github.com/kahwee/sf-map-svg/blob/main/docs/consumer-integration.md#keep-overview-labels-consistent) for width and progressive-enhancement guidance.
403
+
383
404
  ## Interactive maps
384
405
 
385
406
  `createMap` defaults to `mode: 'basemap'` and `appearance.theme: 'transit'`. Set the mode explicitly for neighborhood or district exploration. District and neighborhood layer defaults follow the mode; other supplied layers default on. The [developer guide](https://github.com/kahwee/sf-map-svg/blob/main/docs/developer-guide.md) compares static and interactive defaults and explains configuration resets, coordinates, and cleanup.
@@ -459,7 +480,7 @@ Explore presentation options in the [live map design playground](https://kahwee.
459
480
 
460
481
  ---
461
482
 
462
- <!-- Generated from docs/EXAMPLES.md; @kahwee/sf-map-svg 4.3.2 · local working tree. -->
483
+ <!-- Generated from docs/EXAMPLES.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
463
484
 
464
485
  # Examples
465
486
 
@@ -470,6 +491,7 @@ Choose by task. All snippets use the public package API; browser examples need a
470
491
  | I want to… | Start here |
471
492
  | --- | --- |
472
493
  | Render an SVG on a server | [Static map](#static-svg) |
494
+ | Show labels before JavaScript loads | [Static-to-interactive guide](https://github.com/kahwee/sf-map-svg/blob/main/docs/consumer-integration.md#keep-overview-labels-consistent) |
473
495
  | Add a small map to a browser | [Lightweight guide](#lightweight-interactive-guide) |
474
496
  | Bundle only selected geography | [Selected data](#selected-geography) |
475
497
  | Draw a route over the city | [Route overlay](#route-overlay) |
@@ -570,7 +592,7 @@ For the controller and explicit data imports, see [the v3 migration guide](https
570
592
 
571
593
  ---
572
594
 
573
- <!-- Generated from docs/consumer-integration.md; @kahwee/sf-map-svg 4.3.2 · local working tree. -->
595
+ <!-- Generated from docs/consumer-integration.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
574
596
 
575
597
  # Embedding and progressive enhancement
576
598
 
@@ -589,6 +611,46 @@ const markup = createGuideShell({ title: 'Explore San Francisco' });
589
611
 
590
612
  The shell uses an 800 × 800 projection with 28 units of padding, matching the interactive map. Use `createGuideSVG()` for custom dimensions. Rendering is offline, with zero runtime dependencies.
591
613
 
614
+ ## Keep overview labels consistent
615
+
616
+ Opt into the browser's overview label priorities and collision rules with a target **canvas width in CSS pixels**:
617
+
618
+ ```ts
619
+ const labeledMarkup = createGuideShell({
620
+ title: 'Explore San Francisco',
621
+ labelLayout: { width: 390 },
622
+ });
623
+ ```
624
+
625
+ This keeps the 800 × 800 projection. It sizes and places text for a 390 px canvas, prioritizes major parks and primary roads, includes neighborhood names, and avoids label, marker, and BART-symbol collisions. BART names and smaller parks remain deferred until interactive zoom. Use the same geography, colors, markers and layer switches when mounting. Set `layers.neighborhoodLabels: false` on both renderers to omit neighborhood names.
626
+
627
+ The width is a rendering hint, not a responsive script. Generate an appropriate variant for your known embed width; an SVG displayed at another width scales its labels with the artwork. Static layout uses conservative estimated system-font widths, while the browser measures actual fonts and recalculates on resize. Font metrics, custom browser label styles, selection, clustering and HTML overlays can therefore change the final label set or position. This reduces the transition without promising pixel-identical hydration. Existing calls without `labelLayout` keep their original fixed labels.
628
+
629
+ For a fixed-width embed, use shared inputs on the server and in the browser:
630
+
631
+ ```ts
632
+ // Shared inputs: coordinates, IDs, radii, colors and layer choices must match.
633
+ const guideMarkers = [
634
+ { id: 'ferry', label: 'Ferry Building', lng: -122.3937, lat: 37.7955, radius: 7 },
635
+ ];
636
+ const guideLayers = { neighborhoodLabels: true, roadLabels: true };
637
+ const guideColors = { label: '#183e58', landmark: '#426641' };
638
+
639
+ // Server: place this HTML in a canvas that is 390 CSS pixels wide.
640
+ const labeledPlacesMarkup = createGuideShell({
641
+ labelLayout: { width: 390 },
642
+ markers: guideMarkers,
643
+ layers: guideLayers,
644
+ appearance: { colors: guideColors },
645
+ });
646
+ ```
647
+
648
+ After inserting that markup, pass `markers: guideMarkers`, `layers: guideLayers`, and `appearance: { colors: guideColors }` to `mountGuideController()` as shown below. The static-only `labelLayout` option is not a browser option: the controller measures the actual canvas and font. The shell border is outside the canvas, so a default shell with a 390 px outer width has a 388 px canvas.
649
+
650
+ For responsive layouts whose canvas width is unknown at build time, choose a representative width and accept text scaling until enhancement, or serve independently rendered SVG variants through your application's responsive markup. The package does not detect a device on the server. Keep `labels: false` if your design requires no visible text change at enhancement.
651
+
652
+ For `renderMap()`, also enable `neighborhoodLabels` explicitly; it defaults off outside the guide preset. `labels: false` hides all text in either layout while preserving symbols and accessible titles.
653
+
592
654
  ## Enhance the existing frame
593
655
 
594
656
  In your browser entry, mount the controller into that shell:
@@ -601,7 +663,9 @@ if (!shell) throw new Error('Missing guide shell');
601
663
  const guide = mountGuideController(shell, {
602
664
  attribution: 'compact',
603
665
  features: { motion: { duration: 400 }, markerEntrance: true },
604
- appearance: { theme: 'transit' },
666
+ markers: guideMarkers,
667
+ layers: guideLayers,
668
+ appearance: { theme: 'transit', colors: guideColors },
605
669
  });
606
670
 
607
671
  // Call before your application removes the containing view.
@@ -622,7 +686,7 @@ See the [exact API contract](https://github.com/kahwee/sf-map-svg/blob/main/docs
622
686
 
623
687
  ---
624
688
 
625
- <!-- Generated from docs/migration-v3.md; @kahwee/sf-map-svg 4.3.2 · local working tree. -->
689
+ <!-- Generated from docs/migration-v3.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
626
690
 
627
691
  # Migrate to version 3
628
692
 
@@ -663,7 +727,7 @@ District styles are now prepared once per district on construction and style/yea
663
727
 
664
728
  ---
665
729
 
666
- <!-- Generated from SOURCES.md; @kahwee/sf-map-svg 4.3.2 · local working tree. -->
730
+ <!-- Generated from SOURCES.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
667
731
 
668
732
  # Geometry and provenance
669
733
 
@@ -847,7 +911,7 @@ display geometry, and no precinct polygons or raw workbook data are shipped.
847
911
 
848
912
  ---
849
913
 
850
- <!-- Generated from CHANGELOG.md; @kahwee/sf-map-svg 4.3.2 · local working tree. -->
914
+ <!-- Generated from CHANGELOG.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
851
915
 
852
916
  # Changelog
853
917
 
@@ -855,6 +919,14 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
855
919
 
856
920
  ## Unreleased
857
921
 
922
+ ## 4.4.0 — 2026-10-12
923
+
924
+ - Add opt-in static `labelLayout: { width }` to `renderMap`, `createGuideSVG`, and `createGuideShell`. Static and interactive maps now share overview priorities, neighborhood anchors, font sizes, and collision rules; centered labels retain their anchors during enhancement.
925
+ - Add static `neighborhoodLabels`, available flat or under `layers`, for the new layout. Guide SVGs and shells enable neighborhood names when opting in. Existing fixed-label output and interactive defaults remain unchanged; `labels: false` still hides text while preserving symbols and accessible titles.
926
+ - Keep static layout server-safe and dependency-free. The width is a CSS canvas-width hint, not the SVG projection width; portable font estimates can produce a different visible label set from browser-measured fonts. Document matching data, markers, colors, and layers, plus responsive-width and custom-style limitations.
927
+ - Keep server font estimation out of interactive-only imports and browser runtime code out of static imports. Refresh bundle reports and enforce bounded size budgets for the combined renderer and static-capable Pages tools.
928
+ - Add desktop and phone enhancement stories, collision and input-validation regressions, an SVG example, and copyable integration documentation. Existing screenshot baselines remain unchanged.
929
+
858
930
  ## 4.3.2 — 2026-10-10
859
931
 
860
932
  - Update compatible development tooling and refresh dependency security fixes.
@@ -1191,7 +1263,7 @@ consistent home, and makes animation and subscription ownership predictable.
1191
1263
 
1192
1264
  # Published type contracts
1193
1265
 
1194
- @kahwee/sf-map-svg 4.3.2 · local working tree. These declarations are generated by TypeScript. Use package export paths, not the internal filenames below. Imported relative contracts are included for context, not as additional public entry points.
1266
+ @kahwee/sf-map-svg 4.4.0 · local working tree. These declarations are generated by TypeScript. Use package export paths, not the internal filenames below. Imported relative contracts are included for context, not as additional public entry points.
1195
1267
 
1196
1268
  | Public import | Declaration |
1197
1269
  | --- | --- |
@@ -1608,12 +1680,18 @@ export interface SFMapOptions {
1608
1680
  source?: NeighborhoodSource;
1609
1681
  districtLines?: boolean;
1610
1682
  neighborhoodLines?: boolean;
1683
+ /** Neighborhood names in the opt-in collision layout. */
1684
+ neighborhoodLabels?: boolean;
1611
1685
  districtFills?: boolean;
1612
1686
  /** Evaluated once per district at construction/style/year updates; call setDistrictStyle again when external data changes. */
1613
1687
  districtStyle?: (district: import('./map-core.js').DistrictRowData) => DistrictStyle;
1614
1688
  districtLabels?: boolean;
1615
1689
  /** Hide all visible text labels while retaining geographic symbols and accessible titles. */
1616
1690
  labels?: boolean;
1691
+ /** Opt into interactive overview label priorities/collisions at this CSS display width. Uses estimated system-font metrics without a DOM. */
1692
+ labelLayout?: {
1693
+ width: number;
1694
+ };
1617
1695
  highways?: boolean;
1618
1696
  keyRoads?: boolean;
1619
1697
  roadLabels?: boolean;
@@ -1636,7 +1714,7 @@ export interface SFMapOptions {
1636
1714
  colors?: Partial<Record<'water' | 'land' | 'district' | 'neighborhood' | 'highway' | 'road' | 'park' | 'landmark' | 'bart' | 'label' | 'marker' | 'selected', string>>;
1637
1715
  }
1638
1716
  /** Presentation supported by both the static and interactive renderers. */
1639
- export type StaticMapLayers = Omit<InteractiveLayers, 'neighborhoodLabels'>;
1717
+ export type StaticMapLayers = InteractiveLayers;
1640
1718
  export type StaticMapAppearance = Pick<SFMapOptions, 'theme' | 'colors' | 'districtStyle'>;
1641
1719
  /** Serializable viewport in the interactive map's fixed 800 × 800 projected space. */
1642
1720
  export type MapViewport = readonly [x: number, y: number, size: number];
@@ -1839,10 +1917,12 @@ export interface InteractiveSFMapData {
1839
1917
  import type { Geometry, Position } from '../data/types.js';
1840
1918
  import type { DistrictYear, SFMapOptions } from './types.js';
1841
1919
  export type { DistrictYear, MapMarker, MapOverlay, SFMapOptions } from './types.js';
1920
+ import { type Project } from './geometry.js';
1842
1921
  export interface SFMapData {
1843
1922
  coast: Geometry;
1844
1923
  districts?: Readonly<Partial<Record<DistrictYear, readonly DistrictRowData[]>>>;
1845
1924
  neighborhoods?: readonly {
1925
+ id?: string;
1846
1926
  name: string;
1847
1927
  geometry: Geometry;
1848
1928
  }[];
@@ -1933,13 +2013,23 @@ export declare function resolveMapColors(theme: SFMapOptions['theme'], colors?:
1933
2013
  selected: string;
1934
2014
  };
1935
2015
  /** Make an offline SVG and the matching longitude/latitude projection. */
1936
- export declare function createSFMapWithData(options: SFMapOptions, data: SFMapData): {
2016
+ export declare function createSFMapWithData(options: SFMapOptions, data: SFMapData, renderLabels?: (project: Project) => string): {
1937
2017
  svg: string;
1938
2018
  project: (coordinates: Position) => [number, number];
1939
2019
  viewBox: [number, number, number, number];
1940
2020
  };
1941
2021
  ```
1942
2022
 
2023
+ ### dist/src/geometry.d.ts
2024
+
2025
+ ```ts
2026
+ import type { Geometry, Position } from '../data/types.js';
2027
+ export type Project = (position: Position) => [number, number];
2028
+ export declare function rawProject([longitude, latitude]: Position): [number, number];
2029
+ export declare function positions(geometry: Geometry | null | undefined): Position[];
2030
+ export declare function geometryPath(geometry: Geometry | null | undefined, project: Project): string;
2031
+ ```
2032
+
1943
2033
  ### dist/src/static.d.ts
1944
2034
 
1945
2035
  ```ts
@@ -2116,16 +2206,6 @@ import type { FeatureCollection, NeighborhoodProperties } from './types.js';
2116
2206
  export declare const sfFindNeighborhoods: FeatureCollection<NeighborhoodProperties>;
2117
2207
  ```
2118
2208
 
2119
- ### ./dist/src/geometry.d.ts
2120
-
2121
- ```ts
2122
- import type { Geometry, Position } from '../data/types.js';
2123
- export type Project = (position: Position) => [number, number];
2124
- export declare function rawProject([longitude, latitude]: Position): [number, number];
2125
- export declare function positions(geometry: Geometry | null | undefined): Position[];
2126
- export declare function geometryPath(geometry: Geometry | null | undefined, project: Project): string;
2127
- ```
2128
-
2129
2209
  ### ./dist/src/guide.d.ts
2130
2210
 
2131
2211
  ```ts
package/llms.txt CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Offline San Francisco SVG maps with explicit geographic imports, no runtime dependencies, static rendering, and a browser controller.
4
4
 
5
- @kahwee/sf-map-svg 4.3.2 · local working tree. Node >=24.0.0 for server rendering; browsers need a DOM and a JSON-capable bundler.
5
+ @kahwee/sf-map-svg 4.4.0 · local working tree. Node >=24.0.0 for server rendering; browsers need a DOM and a JSON-capable bundler.
6
6
 
7
7
  These are working-tree docs. Unreleased changelog entries may describe APIs absent from npm; verify the installed version before copying them.
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kahwee/sf-map-svg",
3
- "version": "4.3.2",
3
+ "version": "4.4.0",
4
4
  "description": "Offline SVG maps of San Francisco with district boundaries, parks, landmarks, and BART stations.",
5
5
  "type": "module",
6
6
  "main": "./dist/src/api.js",