@kahwee/sf-map-svg 4.3.1 → 4.3.2

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.
@@ -1,3 +1,4 @@
1
+ import { setAttributeIfChanged } from './dom.js';
1
2
  import { layoutLabels } from './explorer-layout.js';
2
3
  /** Reuse text nodes and screen-space metrics throughout a camera animation. */
3
4
  export function createLabelRenderer(layer) {
@@ -14,7 +15,14 @@ export function createLabelRenderer(layer) {
14
15
  draw(labels, view, width, obstacles) {
15
16
  const unit = view[2] / width;
16
17
  const keys = new Set();
17
- const pending = labels.map((label) => {
18
+ const pending = labels
19
+ .filter((label) => {
20
+ const y = (label.point[1] - view[1]) / unit;
21
+ // Labels are centered horizontally; an anchor outside vertically cannot fit.
22
+ const margin = label.fontSize * 1.25 + label.offset + 3;
23
+ return y >= -margin && y <= width + margin;
24
+ })
25
+ .map((label) => {
18
26
  const key = JSON.stringify([
19
27
  label.kind,
20
28
  label.name,
@@ -34,11 +42,11 @@ export function createLabelRenderer(layer) {
34
42
  entries.set(key, entry);
35
43
  }
36
44
  const { node } = entry;
37
- node.setAttribute('font-size', String(label.fontSize * unit));
38
- node.setAttribute('font-weight', String(label.fontWeight));
39
- node.setAttribute('fill', label.fill);
40
- node.setAttribute('stroke', label.halo);
41
- node.setAttribute('stroke-width', String(3 * unit));
45
+ setAttributeIfChanged(node, 'font-size', label.fontSize * unit);
46
+ setAttributeIfChanged(node, 'font-weight', label.fontWeight);
47
+ setAttributeIfChanged(node, 'fill', label.fill);
48
+ setAttributeIfChanged(node, 'stroke', label.halo);
49
+ setAttributeIfChanged(node, 'stroke-width', 3 * unit);
42
50
  // Batch writes before measuring new labels. Hidden candidates stay detached.
43
51
  if (entry.width === undefined && !node.parentNode)
44
52
  layer.append(node);
@@ -68,8 +76,8 @@ export function createLabelRenderer(layer) {
68
76
  node.remove();
69
77
  let previous = null;
70
78
  for (const item of placed) {
71
- item.node.setAttribute('x', String(view[0] + item.left * unit));
72
- item.node.setAttribute('y', String(view[1] + (item.top + item.fontSize) * unit));
79
+ setAttributeIfChanged(item.node, 'x', view[0] + item.left * unit);
80
+ setAttributeIfChanged(item.node, 'y', view[1] + (item.top + item.fontSize) * unit);
73
81
  const next = previous ? previous.nextSibling : layer.firstChild;
74
82
  if (next !== item.node)
75
83
  layer.insertBefore(item.node, next);
@@ -0,0 +1,11 @@
1
+ import type { createMarkerLayer, MarkerItem } from './marker-layer.js';
2
+ import type { MapMarker } from './types.js';
3
+ /** Own ordered records and picker reconciliation; prepare before committing DOM changes. */
4
+ export declare function createMarkerCatalog(renderer: ReturnType<typeof createMarkerLayer>, picker: HTMLSelectElement, project: (point: [number, number]) => [number, number]): {
5
+ readonly items: MarkerItem[];
6
+ prepare(markers: readonly MapMarker[], selected: string | null): {
7
+ selection: string | null;
8
+ focused: string | undefined;
9
+ commit(): void;
10
+ } | undefined;
11
+ };
@@ -0,0 +1,67 @@
1
+ import { element } from './dom.js';
2
+ import { validateMarkers } from './validation.js';
3
+ /** Own ordered records and picker reconciliation; prepare before committing DOM changes. */
4
+ export function createMarkerCatalog(renderer, picker, project) {
5
+ let items = [];
6
+ return {
7
+ get items() {
8
+ return items;
9
+ },
10
+ prepare(markers, selected) {
11
+ validateMarkers(markers);
12
+ const existing = new Map(items.map((item) => [item.marker.id, item]));
13
+ const ids = new Set();
14
+ const next = markers.map((marker) => {
15
+ ids.add(marker.id);
16
+ const previous = existing.get(marker.id);
17
+ const point = previous?.marker.lng === marker.lng && previous.marker.lat === marker.lat
18
+ ? previous.point
19
+ : project([marker.lng, marker.lat]);
20
+ return { marker: { ...marker }, point };
21
+ });
22
+ const selection = selected && ids.has(selected)
23
+ ? selected
24
+ : (markers.find((marker) => marker.selected)?.id ?? null);
25
+ const keys = ['id', 'lng', 'lat', 'label', 'selected', 'color', 'radius'];
26
+ if (picker.options.length > 0 &&
27
+ selection === selected &&
28
+ next.length === items.length &&
29
+ next.every(({ marker }, index) => keys.every((key) => marker[key] === items[index].marker[key])))
30
+ return undefined;
31
+ const focused = items.find((item) => item.visual?.node === document.activeElement)?.marker.id;
32
+ return {
33
+ selection,
34
+ focused,
35
+ commit() {
36
+ const options = new Map([...picker.options].map((option) => [option.value, option]));
37
+ for (const item of items) {
38
+ if (!ids.has(item.marker.id)) {
39
+ renderer.remove(item);
40
+ options.get(item.marker.id)?.remove();
41
+ }
42
+ }
43
+ if (!options.has('')) {
44
+ const empty = element('option', 'No marker selected');
45
+ empty.value = '';
46
+ picker.prepend(empty);
47
+ }
48
+ items = next.map(({ marker, point }, index) => {
49
+ const previous = existing.get(marker.id);
50
+ const item = previous ?? renderer.add(marker, point, index);
51
+ if (previous)
52
+ renderer.update(item, marker, point, index);
53
+ const option = options.get(marker.id) ?? element('option');
54
+ const label = marker.label ?? marker.id;
55
+ if (option.textContent !== label)
56
+ option.textContent = label;
57
+ if (option.value !== marker.id)
58
+ option.value = marker.id;
59
+ if (picker.children[index + 1] !== option)
60
+ picker.insertBefore(option, picker.children[index + 1] ?? null);
61
+ return item;
62
+ });
63
+ },
64
+ };
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,6 @@
1
+ import type { MapMarker } from './types.js';
2
+ /** A modeless choice panel also available when compact embeds hide native pickers. */
3
+ export declare function createMarkerChooser(canvas: HTMLElement, svg: SVGSVGElement, select: (id: string) => void): {
4
+ close: (restoreFocus?: boolean) => void;
5
+ open(markers: readonly MapMarker[], opener: SVGElement): void;
6
+ };
@@ -0,0 +1,59 @@
1
+ import { element } from './dom.js';
2
+ /** A modeless choice panel also available when compact embeds hide native pickers. */
3
+ export function createMarkerChooser(canvas, svg, select) {
4
+ let panel;
5
+ let events;
6
+ let origin;
7
+ function close(restoreFocus = true) {
8
+ const focused = panel?.contains(document.activeElement);
9
+ events?.abort();
10
+ panel?.remove();
11
+ panel = undefined;
12
+ if (focused && restoreFocus)
13
+ (origin?.isConnected && origin.getClientRects().length ? origin : svg).focus({
14
+ preventScroll: true,
15
+ });
16
+ }
17
+ return {
18
+ close,
19
+ open(markers, opener) {
20
+ close(false);
21
+ origin = opener;
22
+ events = new AbortController();
23
+ const signal = events.signal;
24
+ panel = element('div', '', 'sf-marker-chooser');
25
+ panel.setAttribute('role', 'dialog');
26
+ panel.setAttribute('aria-label', 'Choose a place at this location');
27
+ const title = element('strong', 'Choose a place');
28
+ const dismiss = element('button', 'Close');
29
+ dismiss.type = 'button';
30
+ dismiss.addEventListener('click', () => close(), { signal });
31
+ panel.append(title, dismiss);
32
+ for (const marker of markers) {
33
+ const button = element('button', marker.label ?? marker.id);
34
+ button.type = 'button';
35
+ button.dataset.markerChoice = marker.id;
36
+ button.addEventListener('click', () => {
37
+ close(false);
38
+ select(marker.id);
39
+ }, { signal });
40
+ panel.append(button);
41
+ }
42
+ panel.addEventListener('keydown', (event) => {
43
+ if (event.key === 'Escape') {
44
+ event.preventDefault();
45
+ event.stopPropagation();
46
+ close();
47
+ }
48
+ }, { signal });
49
+ panel.addEventListener('focusout', (event) => {
50
+ if (event.relatedTarget instanceof Node && !panel?.contains(event.relatedTarget))
51
+ close(false);
52
+ }, { signal });
53
+ canvas.append(panel);
54
+ panel
55
+ .querySelector('[data-marker-choice]')
56
+ ?.focus({ preventScroll: true });
57
+ },
58
+ };
59
+ }
@@ -1,25 +1,30 @@
1
1
  import type { normalizeFeatures } from './features.js';
2
2
  import type { MapMarker } from './types.js';
3
- export interface MarkerItem {
4
- marker: MapMarker;
5
- point: [number, number];
3
+ export interface MarkerVisual {
6
4
  node: SVGGElement;
7
5
  dot: SVGCircleElement;
8
6
  hit: SVGCircleElement;
9
7
  ring: SVGCircleElement;
10
8
  title: SVGTitleElement;
11
9
  }
10
+ export interface MarkerItem {
11
+ marker: MapMarker;
12
+ point: [number, number];
13
+ index: number;
14
+ enter: boolean;
15
+ visual: MarkerVisual | undefined;
16
+ }
12
17
  /** Own marker visuals and entrances; selection/events remain with the controller. */
13
- export declare function createMarkerLayer(layer: SVGGElement): {
18
+ export declare function createMarkerLayer(): {
14
19
  cancelEntrances: () => void;
15
- clear(): void;
16
20
  remove(item: MarkerItem): void;
17
21
  update(item: MarkerItem, marker: MapMarker, point: [number, number], index: number): void;
18
- add(marker: MapMarker, point: [number, number], index: number, { features, markerColor, selectedMarkerColor, reducedMotion, enter, }: {
22
+ add(marker: MapMarker, point: [number, number], index: number): MarkerItem;
23
+ materialize(item: MarkerItem, { features, markerColor, selectedMarkerColor, reducedMotion, selected, }: {
19
24
  features: ReturnType<typeof normalizeFeatures>;
20
25
  markerColor: string;
21
26
  selectedMarkerColor: string;
22
27
  reducedMotion: boolean;
23
- enter: boolean;
24
- }): MarkerItem;
28
+ selected: boolean;
29
+ }): MarkerVisual;
25
30
  };
@@ -1,48 +1,65 @@
1
1
  import { svgElement } from './dom.js';
2
2
  /** Own marker visuals and entrances; selection/events remain with the controller. */
3
- export function createMarkerLayer(layer) {
3
+ export function createMarkerLayer() {
4
+ const records = new Set();
4
5
  const animations = new Map();
5
6
  function cancelEntrances() {
6
7
  for (const animation of animations.values())
7
8
  animation.cancel();
8
9
  animations.clear();
10
+ for (const item of records)
11
+ item.enter = false;
9
12
  }
10
13
  return {
11
14
  cancelEntrances,
12
- clear() {
13
- cancelEntrances();
14
- layer.replaceChildren();
15
- },
16
15
  remove(item) {
17
- animations.get(item.node)?.cancel();
18
- animations.delete(item.node);
19
- item.node.remove();
16
+ const visual = item.visual;
17
+ if (visual) {
18
+ animations.get(visual.node)?.cancel();
19
+ animations.delete(visual.node);
20
+ visual.node.remove();
21
+ }
22
+ records.delete(item);
23
+ item.enter = false;
20
24
  },
21
25
  update(item, marker, point, index) {
22
- if (item.point[0] !== point[0] || item.point[1] !== point[1])
23
- item.node.setAttribute('transform', `translate(${point[0]},${point[1]})`);
24
- const label = marker.label ?? marker.id;
25
- if (item.title.textContent !== label) {
26
- item.title.textContent = label;
27
- item.node.setAttribute('aria-label', label);
26
+ const visual = item.visual;
27
+ if (visual) {
28
+ if (item.point[0] !== point[0] || item.point[1] !== point[1])
29
+ visual.node.setAttribute('transform', `translate(${point[0]},${point[1]})`);
30
+ const label = marker.label ?? marker.id;
31
+ if (visual.title.textContent !== label) {
32
+ visual.title.textContent = label;
33
+ visual.node.setAttribute('aria-label', label);
34
+ }
35
+ if (item.index !== index)
36
+ visual.node.style.setProperty('--sf-marker-index', String(index));
28
37
  }
29
- if (item.node.style.getPropertyValue('--sf-marker-index') !== String(index))
30
- item.node.style.setProperty('--sf-marker-index', String(index));
31
38
  item.marker = marker;
32
39
  item.point = point;
40
+ item.index = index;
41
+ },
42
+ add(marker, point, index) {
43
+ const item = { marker, point, index, enter: true, visual: undefined };
44
+ records.add(item);
45
+ return item;
33
46
  },
34
- add(marker, point, index, { features, markerColor, selectedMarkerColor, reducedMotion, enter, }) {
47
+ materialize(item, { features, markerColor, selectedMarkerColor, reducedMotion, selected, }) {
48
+ if (item.visual)
49
+ return item.visual;
50
+ const { marker, point, index, enter } = item;
51
+ item.enter = false;
35
52
  const node = svgElement('g', {
36
53
  transform: `translate(${point[0]},${point[1]})`,
37
54
  'data-marker-id': marker.id,
38
55
  role: 'button',
39
- tabindex: 0,
56
+ tabindex: -1,
40
57
  'aria-label': marker.label ?? marker.id,
41
- 'aria-pressed': 'false',
58
+ 'aria-pressed': String(selected),
42
59
  });
43
60
  const hit = svgElement('circle', { fill: 'transparent', 'pointer-events': 'all' });
44
61
  const dot = svgElement('circle', {
45
- fill: marker.color ?? markerColor,
62
+ fill: selected ? selectedMarkerColor : (marker.color ?? markerColor),
46
63
  stroke: '#fff9e9',
47
64
  'stroke-width': 2,
48
65
  'vector-effect': 'non-scaling-stroke',
@@ -58,7 +75,7 @@ export function createMarkerLayer(layer) {
58
75
  'stroke-width': features.selectedMarkerRing ? features.selectedMarkerRing.width : 2,
59
76
  'vector-effect': 'non-scaling-stroke',
60
77
  'pointer-events': 'none',
61
- display: 'none',
78
+ display: selected && features.selectedMarkerRing ? 'inline' : 'none',
62
79
  });
63
80
  node.style.setProperty('--sf-marker-index', String(index));
64
81
  node.append(title, hit, ring, dot);
@@ -79,8 +96,9 @@ export function createMarkerLayer(layer) {
79
96
  };
80
97
  animation.finished.then(release, release);
81
98
  }
82
- layer.append(node);
83
- return { marker, point, node, dot, hit, ring, title };
99
+ const visual = { node, dot, hit, ring, title };
100
+ item.visual = visual;
101
+ return visual;
84
102
  },
85
103
  };
86
104
  }
@@ -0,0 +1,5 @@
1
+ /** One tab stop for visible pins and clusters; bracket keys preserve arrow-key panning. */
2
+ export declare function createMarkerNavigation(svg: SVGSVGElement, signal: AbortSignal): {
3
+ sync: (next?: SVGElement[]) => void;
4
+ recover(): boolean;
5
+ };
@@ -0,0 +1,52 @@
1
+ /** One tab stop for visible pins and clusters; bracket keys preserve arrow-key panning. */
2
+ export function createMarkerNavigation(svg, signal) {
3
+ let nodes = [];
4
+ let activeKey = '';
5
+ let activeIndex = 0;
6
+ const key = (node) => node.dataset.markerId === undefined
7
+ ? `cluster:${node.dataset.clusterIds}`
8
+ : `marker:${node.dataset.markerId}`;
9
+ function sync(next = nodes) {
10
+ nodes = next.filter((node) => node.isConnected && node.style.display !== 'none');
11
+ const focusedNode = document.activeElement;
12
+ const focused = nodes.find((node) => node === focusedNode);
13
+ const active = focused ??
14
+ nodes.find((node) => key(node) === activeKey) ??
15
+ nodes[Math.min(activeIndex, nodes.length - 1)];
16
+ if (active) {
17
+ activeKey = key(active);
18
+ activeIndex = nodes.indexOf(active);
19
+ }
20
+ for (const node of next) {
21
+ const tabindex = node === active ? '0' : '-1';
22
+ if (node.getAttribute('tabindex') !== tabindex)
23
+ node.setAttribute('tabindex', tabindex);
24
+ }
25
+ }
26
+ svg.addEventListener('focusin', (event) => {
27
+ if (nodes.includes(event.target))
28
+ sync();
29
+ }, { signal });
30
+ svg.addEventListener('keydown', (event) => {
31
+ if (event.altKey ||
32
+ event.ctrlKey ||
33
+ event.metaKey ||
34
+ !nodes.includes(event.target))
35
+ return;
36
+ if (event.key !== '[' && event.key !== ']')
37
+ return;
38
+ event.preventDefault();
39
+ const index = nodes.indexOf(event.target);
40
+ nodes[(index + (event.key === ']' ? 1 : nodes.length - 1)) % nodes.length]?.focus({
41
+ preventScroll: true,
42
+ });
43
+ }, { signal });
44
+ return {
45
+ sync,
46
+ recover() {
47
+ const node = nodes.find((node) => key(node) === activeKey);
48
+ node?.focus({ preventScroll: true });
49
+ return !!node;
50
+ },
51
+ };
52
+ }
@@ -240,7 +240,7 @@ export interface NeighborhoodExplorerElement extends HTMLElement {
240
240
  fitGeometry(geometry: Geometry, padding?: number | MapPadding, options?: CameraOptions): void;
241
241
  /** Explicitly engage map touch gestures; false restores page gestures. */
242
242
  setTouchNavigation(enabled: boolean): void;
243
- /** Reconcile stable IDs; retained markers preserve nodes, focus, and entrance animations. */
243
+ /** Reconcile stable IDs; preserve existing nodes and focus. Pin visuals allocate on first visibility/selection. */
244
244
  setMarkers(markers: readonly MapMarker[]): void;
245
245
  setOverlays(overlays: readonly MapOverlay[]): void;
246
246
  selectMarker(id: string | null, options?: {
package/docs/API.md CHANGED
@@ -39,7 +39,13 @@ The controller also supports marker, neighborhood, and district selection; distr
39
39
 
40
40
  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
41
 
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.
42
+ `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.
43
+
44
+ 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.
45
+
46
+ 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.
47
+
48
+ 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
49
 
44
50
  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
51
 
@@ -96,6 +102,6 @@ Neighborhood IDs are exact source-scoped feature IDs, rather than display names
96
102
 
97
103
  ## Coordinates and units
98
104
 
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.
105
+ 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
106
 
101
107
  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/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
@@ -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-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.
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) | 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 |
10
10
 
11
11
  **500 KB target:** met.
12
12