@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.
- package/CHANGELOG.md +26 -0
- package/README.md +134 -1
- package/dist/src/cluster-renderer.d.ts +34 -0
- package/dist/src/cluster-renderer.js +159 -0
- package/dist/src/controller-types.d.ts +2 -2
- package/dist/src/dom.d.ts +2 -0
- package/dist/src/dom.js +6 -0
- package/dist/src/explorer-core.js +241 -265
- package/dist/src/guide-data.js +1 -0
- package/dist/src/guide-static.js +1 -0
- package/dist/src/label-candidates.d.ts +24 -0
- package/dist/src/label-candidates.js +29 -0
- package/dist/src/label-renderer.d.ts +1 -10
- package/dist/src/label-renderer.js +16 -8
- package/dist/src/map-core.d.ts +3 -1
- package/dist/src/map-core.js +7 -5
- package/dist/src/marker-catalog.d.ts +11 -0
- package/dist/src/marker-catalog.js +67 -0
- package/dist/src/marker-chooser.d.ts +6 -0
- package/dist/src/marker-chooser.js +59 -0
- package/dist/src/marker-layer.d.ts +13 -8
- package/dist/src/marker-layer.js +41 -23
- package/dist/src/marker-navigation.d.ts +5 -0
- package/dist/src/marker-navigation.js +52 -0
- package/dist/src/static-labels.d.ts +7 -0
- package/dist/src/static-labels.js +100 -0
- package/dist/src/static.js +12 -3
- package/dist/src/types.d.ts +8 -2
- package/docs/API.md +14 -4
- package/docs/EXAMPLES.md +1 -0
- package/docs/api-audit.md +5 -4
- package/docs/consumer-integration.md +43 -1
- package/docs/guide-bundle-report.md +4 -4
- package/llms-full.txt +251 -31
- package/llms.txt +1 -1
- package/package.json +14 -11
package/dist/src/guide-data.js
CHANGED
package/dist/src/guide-static.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
38
|
-
node
|
|
39
|
-
node
|
|
40
|
-
node
|
|
41
|
-
node
|
|
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
|
|
72
|
-
item.node
|
|
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);
|
package/dist/src/map-core.d.ts
CHANGED
|
@@ -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];
|
package/dist/src/map-core.js
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
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
|
-
|
|
24
|
-
}):
|
|
28
|
+
selected: boolean;
|
|
29
|
+
}): MarkerVisual;
|
|
25
30
|
};
|
package/dist/src/marker-layer.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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:
|
|
56
|
+
tabindex: -1,
|
|
40
57
|
'aria-label': marker.label ?? marker.id,
|
|
41
|
-
'aria-pressed':
|
|
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
|
-
|
|
83
|
-
|
|
99
|
+
const visual = { node, dot, hit, ring, title };
|
|
100
|
+
item.visual = visual;
|
|
101
|
+
return visual;
|
|
84
102
|
},
|
|
85
103
|
};
|
|
86
104
|
}
|
|
@@ -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;
|