@kahwee/sf-map-svg 4.3.0 → 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.
- package/CHANGELOG.md +24 -0
- package/README.md +117 -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 +228 -235
- package/dist/src/label-renderer.js +16 -8
- package/dist/src/map-core.js +24 -2
- 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.js +59 -23
- package/dist/src/types.d.ts +1 -1
- package/docs/API.md +11 -3
- package/docs/api-audit.md +5 -4
- package/docs/guide-bundle-report.md +4 -4
- package/llms-full.txt +165 -17
- package/llms.txt +1 -1
- package/package.json +14 -11
|
@@ -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.js
CHANGED
|
@@ -105,7 +105,8 @@ const transitColors = {
|
|
|
105
105
|
selected: '#0073ae',
|
|
106
106
|
};
|
|
107
107
|
export function resolveMapColors(theme, colors = {}) {
|
|
108
|
-
|
|
108
|
+
const supplied = Object.fromEntries(Object.entries(colors).filter(([, value]) => value !== undefined));
|
|
109
|
+
return { ...defaults, ...(theme === 'transit' ? transitColors : {}), ...supplied };
|
|
109
110
|
}
|
|
110
111
|
let sequence = 0;
|
|
111
112
|
/** Make an offline SVG and the matching longitude/latitude projection. */
|
|
@@ -143,8 +144,29 @@ export function createSFMapWithData(options, data) {
|
|
|
143
144
|
};
|
|
144
145
|
})
|
|
145
146
|
: [];
|
|
147
|
+
const neighborhoodDescription = options.source === 'realtor'
|
|
148
|
+
? 'SFAR realtor neighborhood areas, defined in August 2010'
|
|
149
|
+
: options.source === 'sf-find'
|
|
150
|
+
? 'SF Find neighborhood areas'
|
|
151
|
+
: options.source === 'analysis'
|
|
152
|
+
? 'DataSF analysis neighborhood areas'
|
|
153
|
+
: 'the supplied neighborhood areas';
|
|
154
|
+
const description = [
|
|
155
|
+
districts.length && (districtFills || districtLines || (labels && districtLabels))
|
|
156
|
+
? `San Francisco district boundaries (${year}).`
|
|
157
|
+
: 'San Francisco map.',
|
|
158
|
+
neighborhoodLines && data.neighborhoods?.length
|
|
159
|
+
? `Dashed lines show ${neighborhoodDescription}.`
|
|
160
|
+
: '',
|
|
161
|
+
landmarks && landmarkData.length ? `Park and landmark features: ${landmarkData.length}.` : '',
|
|
162
|
+
keyRoads && roadData.length ? 'Selected road corridors.' : '',
|
|
163
|
+
bartStations && stationData.length ? `BART stations: ${stationData.length}.` : '',
|
|
164
|
+
'See SOURCES.md for geographic sources.',
|
|
165
|
+
]
|
|
166
|
+
.filter(Boolean)
|
|
167
|
+
.join(' ');
|
|
146
168
|
const parts = [
|
|
147
|
-
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-labelledby="${idPrefix}-title" data-sf-map="" data-year="${year}"${animated ? ` data-sf-animate="${idPrefix}"` : ''} style="max-width:100%;height:auto"><title id="${idPrefix}-title">${escapeXml(title)}</title><desc
|
|
169
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-labelledby="${idPrefix}-title" data-sf-map="" data-year="${year}"${animated ? ` data-sf-animate="${idPrefix}"` : ''} style="max-width:100%;height:auto"><title id="${idPrefix}-title">${escapeXml(title)}</title><desc>${escapeXml(description)}</desc>${animation ? staticAnimationStyle(idPrefix, animation) : ''}<defs><clipPath id="${idPrefix}-coast"><path d="${coastPath}" fill-rule="evenodd" clip-rule="evenodd"/></clipPath></defs><rect width="${width}" height="${height}" fill="${escapeXml(colors.water)}"/><g data-layer="geography"><path data-layer="coast" d="${coastPath}" fill="${escapeXml(colors.land)}" fill-rule="evenodd"/>`,
|
|
148
170
|
];
|
|
149
171
|
// Explicit drawing order keeps optional overlays and user markers predictable.
|
|
150
172
|
if (districtFills)
|
|
@@ -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
|
+
}
|
package/dist/src/static.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { prepareAppearance } from './appearance.js';
|
|
2
2
|
import { validateSwitchPatch } from './features.js';
|
|
3
3
|
import { createSFMapWithData, getLayerPathsWithData } from './map-core.js';
|
|
4
|
-
import {
|
|
4
|
+
import { normalizeStaticAnimation } from './static-animation.js';
|
|
5
|
+
import { assertOptions, validateMarkers, validateOverlays } from './validation.js';
|
|
5
6
|
/** Server-safe SVG rendering with explicit data; returns SVG plus projection helpers. */
|
|
6
7
|
export function renderMap(data, options = {}) {
|
|
7
8
|
const prepared = expandStaticOptions(options);
|
|
8
|
-
|
|
9
|
+
const { map, source } = staticInput(data, prepared);
|
|
10
|
+
return createSFMapWithData({ ...prepared, source }, map);
|
|
9
11
|
}
|
|
10
12
|
/** Project canonical layer geometry without constructing an SVG string. */
|
|
11
13
|
export function getLayerPaths(data, options = {}) {
|
|
12
14
|
const prepared = expandStaticOptions(options);
|
|
13
|
-
return getLayerPathsWithData(prepared, staticInput(data, prepared));
|
|
15
|
+
return getLayerPathsWithData(prepared, staticInput(data, prepared).map);
|
|
14
16
|
}
|
|
15
17
|
/** Borrow immutable geometry, deriving only the renderer's lightweight neighborhood rows. */
|
|
16
18
|
function staticInput(data, options) {
|
|
@@ -19,45 +21,79 @@ function staticInput(data, options) {
|
|
|
19
21
|
if (!('map' in data)) {
|
|
20
22
|
if (options.source !== undefined)
|
|
21
23
|
throw new TypeError('source requires source-aware MapData; compact StaticMapData already chooses its geography.');
|
|
22
|
-
return data;
|
|
24
|
+
return { map: data, source: undefined };
|
|
23
25
|
}
|
|
24
26
|
const source = options.source ??
|
|
25
27
|
['realtor', 'sf-find', 'analysis'].find((candidate) => !!data.neighborhoods[candidate]);
|
|
26
28
|
if (source === undefined)
|
|
27
|
-
return data.map;
|
|
29
|
+
return { map: data.map, source: undefined };
|
|
28
30
|
const collection = data.neighborhoods[source];
|
|
29
31
|
if (!collection)
|
|
30
32
|
throw new RangeError(`No ${source} neighborhood dataset was supplied.`);
|
|
31
33
|
return {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
geometry,
|
|
36
|
-
|
|
34
|
+
source,
|
|
35
|
+
map: {
|
|
36
|
+
...data.map,
|
|
37
|
+
neighborhoods: collection.features.map(({ geometry, properties }) => ({
|
|
38
|
+
name: properties.sourceName,
|
|
39
|
+
geometry,
|
|
40
|
+
})),
|
|
41
|
+
},
|
|
37
42
|
};
|
|
38
43
|
}
|
|
44
|
+
const staticLayerKeys = [
|
|
45
|
+
'districtFills',
|
|
46
|
+
'districtLines',
|
|
47
|
+
'districtLabels',
|
|
48
|
+
'neighborhoodLines',
|
|
49
|
+
'landmarks',
|
|
50
|
+
'bartStations',
|
|
51
|
+
'highways',
|
|
52
|
+
'keyRoads',
|
|
53
|
+
'roadLabels',
|
|
54
|
+
];
|
|
39
55
|
/** Grouped keys override flat compatibility keys; omitted keys preserve renderer defaults. */
|
|
40
56
|
function expandStaticOptions(options) {
|
|
41
|
-
assertOptions(options, 'static map'
|
|
57
|
+
assertOptions(options, 'static map', [
|
|
58
|
+
...staticLayerKeys,
|
|
59
|
+
'layers',
|
|
60
|
+
'appearance',
|
|
61
|
+
'theme',
|
|
62
|
+
'colors',
|
|
63
|
+
'districtStyle',
|
|
64
|
+
'width',
|
|
65
|
+
'height',
|
|
66
|
+
'padding',
|
|
67
|
+
'year',
|
|
68
|
+
'source',
|
|
69
|
+
'labels',
|
|
70
|
+
'markers',
|
|
71
|
+
'overlays',
|
|
72
|
+
'title',
|
|
73
|
+
'idPrefix',
|
|
74
|
+
'animation',
|
|
75
|
+
]);
|
|
76
|
+
for (const key of [...staticLayerKeys, 'labels'])
|
|
77
|
+
if (options[key] !== undefined && typeof options[key] !== 'boolean')
|
|
78
|
+
throw new TypeError(`${key} must be boolean.`);
|
|
79
|
+
for (const key of ['title', 'idPrefix'])
|
|
80
|
+
if (options[key] !== undefined && typeof options[key] !== 'string')
|
|
81
|
+
throw new TypeError(`${key} must be a string.`);
|
|
82
|
+
if (options.markers !== undefined)
|
|
83
|
+
validateMarkers(options.markers);
|
|
84
|
+
if (options.overlays !== undefined)
|
|
85
|
+
validateOverlays(options.overlays);
|
|
86
|
+
normalizeStaticAnimation(options.animation);
|
|
42
87
|
if (options.layers !== undefined)
|
|
43
|
-
validateSwitchPatch(options.layers,
|
|
44
|
-
'districtFills',
|
|
45
|
-
'districtLines',
|
|
46
|
-
'districtLabels',
|
|
47
|
-
'neighborhoodLines',
|
|
48
|
-
'landmarks',
|
|
49
|
-
'bartStations',
|
|
50
|
-
'highways',
|
|
51
|
-
'keyRoads',
|
|
52
|
-
'roadLabels',
|
|
53
|
-
]);
|
|
88
|
+
validateSwitchPatch(options.layers, staticLayerKeys);
|
|
54
89
|
if (options.appearance !== undefined) {
|
|
55
90
|
assertOptions(options.appearance, 'static appearance', ['theme', 'colors', 'districtStyle']);
|
|
56
91
|
}
|
|
57
|
-
const { layers, appearance, ...flat } = options;
|
|
92
|
+
const { layers, appearance, theme, colors, districtStyle, ...flat } = options;
|
|
58
93
|
const supplied = (group = {}) => Object.fromEntries(Object.entries(group).filter(([, value]) => value !== undefined));
|
|
59
94
|
return {
|
|
60
95
|
...flat,
|
|
96
|
+
...prepareAppearance({}, { theme, colors, districtStyle }),
|
|
61
97
|
...supplied(layers),
|
|
62
98
|
...supplied(appearance === undefined ? undefined : prepareAppearance({}, appearance)),
|
|
63
99
|
};
|
package/dist/src/types.d.ts
CHANGED
|
@@ -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;
|
|
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
|
@@ -19,12 +19,14 @@ 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
|
|
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.
|
|
23
23
|
|
|
24
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.
|
|
25
25
|
|
|
26
26
|
Custom district IDs must be finite numbers. An empty `labelPoints` array uses the district's primary `label` position.
|
|
27
27
|
|
|
28
|
+
The SVG description names the selected neighborhood source for source-aware data and uses a generic description for compact inputs. It describes enabled layers only when their data is supplied, using actual park and station counts.
|
|
29
|
+
|
|
28
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.
|
|
29
31
|
|
|
30
32
|
## Interactive maps
|
|
@@ -37,7 +39,13 @@ The controller also supports marker, neighborhood, and district selection; distr
|
|
|
37
39
|
|
|
38
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.
|
|
39
41
|
|
|
40
|
-
`setMarkers()` reconciles by stable marker `id`: retained markers keep
|
|
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.
|
|
41
49
|
|
|
42
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).
|
|
43
51
|
|
|
@@ -94,6 +102,6 @@ Neighborhood IDs are exact source-scoped feature IDs, rather than display names
|
|
|
94
102
|
|
|
95
103
|
## Coordinates and units
|
|
96
104
|
|
|
97
|
-
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
|
|
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.
|
|
98
106
|
|
|
99
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.
|