@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.
@@ -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,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);
@@ -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,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
+ }
@@ -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;