@kahwee/sf-map-svg 4.4.0 → 4.5.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 +14 -0
- package/dist/src/cards.d.ts +15 -0
- package/dist/src/cards.js +138 -0
- package/dist/src/cluster-layer.d.ts +2 -0
- package/dist/src/cluster-layer.js +2 -0
- package/dist/src/explorer-controls.d.ts +26 -0
- package/dist/src/explorer-controls.js +120 -0
- package/dist/src/explorer-core.d.ts +6 -1
- package/dist/src/explorer-core.js +146 -662
- package/dist/src/explorer-shell.d.ts +62 -0
- package/dist/src/explorer-shell.js +228 -0
- package/dist/src/explorer-sidebar.d.ts +20 -0
- package/dist/src/explorer-sidebar.js +79 -0
- package/dist/src/guide-map.js +2 -1
- package/dist/src/label-spread.d.ts +5 -0
- package/dist/src/label-spread.js +36 -0
- package/dist/src/map.js +2 -1
- package/dist/src/neighborhood-layer.d.ts +67 -0
- package/dist/src/neighborhood-layer.js +256 -0
- package/dist/src/overlay-layer.d.ts +13 -0
- package/dist/src/overlay-layer.js +68 -0
- package/dist/src/palettes.d.ts +48 -0
- package/dist/src/palettes.js +40 -0
- package/dist/src/presets.d.ts +1 -0
- package/dist/src/presets.js +1 -0
- package/dist/src/static-labels.js +17 -2
- package/dist/src/static.js +21 -2
- package/dist/src/types.d.ts +9 -0
- package/docs/API.md +32 -0
- package/docs/consumer-integration.md +39 -1
- package/docs/guide-bundle-report.md +4 -4
- package/llms-full.txt +179 -11
- package/llms.txt +1 -1
- package/package.json +16 -8
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { validateCameraOptions } from './camera.js';
|
|
2
|
+
import { element, svgElement } from './dom.js';
|
|
3
|
+
import { fitBounds, interiorAnchor, projectedBounds } from './explorer-layout.js';
|
|
4
|
+
import { geometryPath } from './geometry.js';
|
|
5
|
+
import { fitViewport } from './viewport.js';
|
|
6
|
+
export const sourceNames = {
|
|
7
|
+
realtor: 'SFAR realtor · 92 areas',
|
|
8
|
+
'sf-find': 'SF Find · 117 areas',
|
|
9
|
+
analysis: 'City analysis · 41 areas',
|
|
10
|
+
};
|
|
11
|
+
const normalizeName = (value) => value
|
|
12
|
+
.normalize('NFKD')
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/[\u0300-\u036f'’]/g, '')
|
|
15
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
16
|
+
.trim();
|
|
17
|
+
export const formatSourceLabel = (source) => sourceNames[source] ?? `${source} neighborhoods`;
|
|
18
|
+
/** Source-scoped names and aliases; same-name polygons never cross source boundaries. */
|
|
19
|
+
export function createNeighborhoodLookup(collections) {
|
|
20
|
+
const getNeighborhood = (name, selectedSource) => {
|
|
21
|
+
const term = normalizeName(name);
|
|
22
|
+
if (!term)
|
|
23
|
+
return undefined;
|
|
24
|
+
return collections[selectedSource]?.features.find((feature) => [
|
|
25
|
+
feature.id,
|
|
26
|
+
feature.properties.canonicalName,
|
|
27
|
+
feature.properties.sourceName,
|
|
28
|
+
...feature.properties.aliases,
|
|
29
|
+
].some((value) => normalizeName(value) === term));
|
|
30
|
+
};
|
|
31
|
+
const searchNeighborhoods = (query, selectedSource) => {
|
|
32
|
+
const term = normalizeName(query);
|
|
33
|
+
return (collections[selectedSource]?.features ?? [])
|
|
34
|
+
.filter((feature) => [
|
|
35
|
+
feature.id,
|
|
36
|
+
feature.properties.canonicalName,
|
|
37
|
+
feature.properties.sourceName,
|
|
38
|
+
...feature.properties.aliases,
|
|
39
|
+
].some((value) => normalizeName(value).includes(term)))
|
|
40
|
+
.map((feature) => ({ id: feature.id, canonicalName: feature.properties.canonicalName }));
|
|
41
|
+
};
|
|
42
|
+
return { getNeighborhood, searchNeighborhoods };
|
|
43
|
+
}
|
|
44
|
+
/** Own neighborhood identity, prepared geometry, selection revisions and hover listeners.
|
|
45
|
+
* Preparation is detached: only the returned commit can replace the active source.
|
|
46
|
+
*/
|
|
47
|
+
export function createNeighborhoodLayer({ collections, initialSource, project, root, areas, canvas, sourceSelect, neighborhoodSelect, status, readPresentation, isDestroyed, ensureVisible, refresh, moveView, resetView, crossfade, }) {
|
|
48
|
+
let source = initialSource;
|
|
49
|
+
let selected;
|
|
50
|
+
let items = [];
|
|
51
|
+
let neighborhoodRevision = 0;
|
|
52
|
+
let sourceEvents = new AbortController();
|
|
53
|
+
const { getNeighborhood } = createNeighborhoodLookup(collections);
|
|
54
|
+
function getSelection() {
|
|
55
|
+
return selected
|
|
56
|
+
? {
|
|
57
|
+
id: selected.id,
|
|
58
|
+
name: selected.properties.canonicalName,
|
|
59
|
+
source,
|
|
60
|
+
feature: structuredClone(selected),
|
|
61
|
+
}
|
|
62
|
+
: null;
|
|
63
|
+
}
|
|
64
|
+
function selectNeighborhood(name, options = {}) {
|
|
65
|
+
if (isDestroyed())
|
|
66
|
+
return false;
|
|
67
|
+
const feature = name === null
|
|
68
|
+
? undefined
|
|
69
|
+
: (collections[source]?.features.find((entry) => entry.id === name) ??
|
|
70
|
+
getNeighborhood(name, source));
|
|
71
|
+
if (name !== null && !feature)
|
|
72
|
+
return false;
|
|
73
|
+
validateCameraOptions(options);
|
|
74
|
+
const { fitPadding } = readPresentation();
|
|
75
|
+
const selectedItem = items.find((item) => item.feature === feature);
|
|
76
|
+
const width = canvas.getBoundingClientRect().width;
|
|
77
|
+
const target = selectedItem && options.fit !== false
|
|
78
|
+
? width
|
|
79
|
+
? fitViewport(selectedItem.bounds, width, fitPadding)
|
|
80
|
+
: fitBounds(selectedItem.bounds)
|
|
81
|
+
: null;
|
|
82
|
+
if (feature)
|
|
83
|
+
ensureVisible();
|
|
84
|
+
const { areaStyle, colors, selectableNeighborhoods, lines } = readPresentation();
|
|
85
|
+
const revision = ++neighborhoodRevision;
|
|
86
|
+
const changed = selected !== feature;
|
|
87
|
+
selected = feature;
|
|
88
|
+
for (const item of items) {
|
|
89
|
+
const active = item.feature === selected;
|
|
90
|
+
item.node.setAttribute('fill', active ? (areaStyle.selectedFill ?? '#408dbe') : 'transparent');
|
|
91
|
+
item.node.setAttribute('fill-opacity', active ? '.16' : '1');
|
|
92
|
+
item.node.setAttribute('stroke', active ? (areaStyle.selectedStroke ?? '#176ba2') : (colors.neighborhood ?? '#9caebc'));
|
|
93
|
+
item.node.setAttribute('stroke-width', active ? '2.2' : lines ? '.55' : '0');
|
|
94
|
+
if (selectableNeighborhoods)
|
|
95
|
+
item.node.setAttribute('aria-pressed', String(active));
|
|
96
|
+
if (active)
|
|
97
|
+
areas.append(item.node);
|
|
98
|
+
}
|
|
99
|
+
if (feature)
|
|
100
|
+
root.dataset.selectedNeighborhood = feature.id;
|
|
101
|
+
else
|
|
102
|
+
delete root.dataset.selectedNeighborhood;
|
|
103
|
+
neighborhoodSelect.value = feature?.id ?? '';
|
|
104
|
+
refresh.selection();
|
|
105
|
+
status.textContent = feature
|
|
106
|
+
? `${feature.properties.canonicalName} selected. ${formatSourceLabel(source)}.`
|
|
107
|
+
: 'Neighborhood selection cleared.';
|
|
108
|
+
if (changed)
|
|
109
|
+
root.dispatchEvent(new CustomEvent('neighborhoodchange', {
|
|
110
|
+
bubbles: true,
|
|
111
|
+
detail: {
|
|
112
|
+
feature: feature ? structuredClone(feature) : null,
|
|
113
|
+
source,
|
|
114
|
+
id: feature?.id ?? null,
|
|
115
|
+
name: feature?.properties.canonicalName ?? null,
|
|
116
|
+
},
|
|
117
|
+
}));
|
|
118
|
+
if (!isDestroyed() && neighborhoodRevision === revision && target)
|
|
119
|
+
moveView(target, options);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
function prepareSource(next, reset = true) {
|
|
123
|
+
const { colors } = readPresentation();
|
|
124
|
+
const events = new AbortController();
|
|
125
|
+
if (!Object.hasOwn(collections, next))
|
|
126
|
+
throw new RangeError(`Unknown neighborhood source: ${next}`);
|
|
127
|
+
const collection = collections[next];
|
|
128
|
+
if (!collection ||
|
|
129
|
+
!Array.isArray(collection.features) ||
|
|
130
|
+
typeof collection.title !== 'string' ||
|
|
131
|
+
typeof collection.definition?.description !== 'string')
|
|
132
|
+
throw new TypeError('Neighborhood collections require features, title and definition description.');
|
|
133
|
+
const ids = new Set();
|
|
134
|
+
const nextOptions = [];
|
|
135
|
+
const nextItems = collection.features.map((feature) => {
|
|
136
|
+
if (!feature ||
|
|
137
|
+
typeof feature.id !== 'string' ||
|
|
138
|
+
!feature.id ||
|
|
139
|
+
ids.has(feature.id) ||
|
|
140
|
+
!feature.properties ||
|
|
141
|
+
typeof feature.properties.canonicalName !== 'string' ||
|
|
142
|
+
typeof feature.properties.sourceName !== 'string' ||
|
|
143
|
+
!Array.isArray(feature.properties.aliases) ||
|
|
144
|
+
Array.from(feature.properties.aliases).some((alias) => typeof alias !== 'string') ||
|
|
145
|
+
!['Polygon', 'MultiPolygon'].includes(feature.geometry?.type))
|
|
146
|
+
throw new TypeError('Neighborhood features require unique IDs, names, aliases and polygon geometry.');
|
|
147
|
+
ids.add(feature.id);
|
|
148
|
+
const bounds = projectedBounds(feature.geometry, project);
|
|
149
|
+
if (!bounds.every(Number.isFinite))
|
|
150
|
+
throw new RangeError('Neighborhood geometry must be nonempty and finite.');
|
|
151
|
+
const node = svgElement('path', {
|
|
152
|
+
d: geometryPath(feature.geometry, project),
|
|
153
|
+
'data-neighborhood-id': feature.id,
|
|
154
|
+
fill: 'transparent',
|
|
155
|
+
'fill-rule': 'evenodd',
|
|
156
|
+
stroke: colors.neighborhood ?? '#9caebc',
|
|
157
|
+
'stroke-width': '.55',
|
|
158
|
+
'vector-effect': 'non-scaling-stroke',
|
|
159
|
+
role: 'button',
|
|
160
|
+
tabindex: -1,
|
|
161
|
+
'aria-label': feature.properties.canonicalName,
|
|
162
|
+
'aria-pressed': 'false',
|
|
163
|
+
});
|
|
164
|
+
const option = element('option', feature.properties.canonicalName);
|
|
165
|
+
option.value = feature.id;
|
|
166
|
+
nextOptions.push(option);
|
|
167
|
+
const title = svgElement('title');
|
|
168
|
+
title.textContent = feature.properties.canonicalName;
|
|
169
|
+
node.append(title);
|
|
170
|
+
node.addEventListener('pointerenter', () => {
|
|
171
|
+
const { selectableNeighborhoods, areaStyle, colors } = readPresentation();
|
|
172
|
+
if (isDestroyed() ||
|
|
173
|
+
!selectableNeighborhoods ||
|
|
174
|
+
selected?.id === feature.id ||
|
|
175
|
+
(!areaStyle.hoverFill && !areaStyle.hoverStroke))
|
|
176
|
+
return;
|
|
177
|
+
node.setAttribute('fill', areaStyle.hoverFill ?? '#408dbe');
|
|
178
|
+
node.setAttribute('fill-opacity', '.12');
|
|
179
|
+
node.setAttribute('stroke', areaStyle.hoverStroke ?? colors.neighborhood ?? '#176ba2');
|
|
180
|
+
}, { signal: events.signal });
|
|
181
|
+
node.addEventListener('pointerleave', () => {
|
|
182
|
+
if (isDestroyed() || selected?.id === feature.id)
|
|
183
|
+
return;
|
|
184
|
+
node.setAttribute('fill', 'transparent');
|
|
185
|
+
node.setAttribute('fill-opacity', '1');
|
|
186
|
+
node.setAttribute('stroke', readPresentation().colors.neighborhood ?? '#9caebc');
|
|
187
|
+
}, { signal: events.signal });
|
|
188
|
+
return {
|
|
189
|
+
feature,
|
|
190
|
+
node,
|
|
191
|
+
bounds,
|
|
192
|
+
point: interiorAnchor(feature.geometry, project),
|
|
193
|
+
area: (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]),
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
return () => {
|
|
197
|
+
if (isDestroyed()) {
|
|
198
|
+
events.abort();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
sourceEvents.abort();
|
|
202
|
+
sourceEvents = events;
|
|
203
|
+
const { mode, year } = readPresentation();
|
|
204
|
+
const hadSelection = !!selected;
|
|
205
|
+
source = next;
|
|
206
|
+
sourceSelect.value = source;
|
|
207
|
+
selected = undefined;
|
|
208
|
+
const revision = ++neighborhoodRevision;
|
|
209
|
+
delete root.dataset.selectedNeighborhood;
|
|
210
|
+
root.dataset.source = source;
|
|
211
|
+
crossfade(areas, () => areas.replaceChildren(...nextItems.map((item) => item.node)));
|
|
212
|
+
neighborhoodSelect.replaceChildren(element('option', 'No neighborhood selected'));
|
|
213
|
+
neighborhoodSelect.options[0].value = '';
|
|
214
|
+
neighborhoodSelect.append(...nextOptions);
|
|
215
|
+
items = nextItems;
|
|
216
|
+
if (items[0])
|
|
217
|
+
items[0].node.setAttribute('tabindex', '0');
|
|
218
|
+
refresh.source();
|
|
219
|
+
status.textContent =
|
|
220
|
+
mode === 'basemap'
|
|
221
|
+
? 'San Francisco basemap.'
|
|
222
|
+
: mode === 'districts'
|
|
223
|
+
? `${year} supervisorial districts. Numbers identify each district.`
|
|
224
|
+
: `${formatSourceLabel(source)}. Select a neighborhood to begin.`;
|
|
225
|
+
if (hadSelection)
|
|
226
|
+
root.dispatchEvent(new CustomEvent('neighborhoodchange', {
|
|
227
|
+
bubbles: true,
|
|
228
|
+
detail: { feature: null, source, id: null, name: null },
|
|
229
|
+
}));
|
|
230
|
+
if (reset && !isDestroyed() && neighborhoodRevision === revision)
|
|
231
|
+
resetView();
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function setSource(next) {
|
|
235
|
+
if (!isDestroyed())
|
|
236
|
+
prepareSource(next)();
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
get source() {
|
|
240
|
+
return source;
|
|
241
|
+
},
|
|
242
|
+
get selected() {
|
|
243
|
+
return selected;
|
|
244
|
+
},
|
|
245
|
+
get items() {
|
|
246
|
+
return items;
|
|
247
|
+
},
|
|
248
|
+
getSelection,
|
|
249
|
+
selectNeighborhood,
|
|
250
|
+
prepareSource,
|
|
251
|
+
setSource,
|
|
252
|
+
destroy() {
|
|
253
|
+
sourceEvents.abort();
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Project } from './geometry.js';
|
|
2
|
+
import type { MapOverlay } from './types.js';
|
|
3
|
+
/** Prepare overlays before replacing live nodes; each replacement owns an abortable event scope. */
|
|
4
|
+
export declare function createOverlayLayer({ layer: overlayLayer, root, project, onOverlayActivate, isDestroyed, }: {
|
|
5
|
+
layer: SVGGElement;
|
|
6
|
+
root: HTMLElement;
|
|
7
|
+
project: Project;
|
|
8
|
+
onOverlayActivate: ((overlay: MapOverlay) => void) | undefined;
|
|
9
|
+
isDestroyed(): boolean;
|
|
10
|
+
}): {
|
|
11
|
+
set: (overlays: readonly MapOverlay[]) => void;
|
|
12
|
+
destroy(): void;
|
|
13
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { svgElement } from './dom.js';
|
|
2
|
+
import { geometryPath } from './geometry.js';
|
|
3
|
+
import { validateOverlays } from './validation.js';
|
|
4
|
+
/** Prepare overlays before replacing live nodes; each replacement owns an abortable event scope. */
|
|
5
|
+
export function createOverlayLayer({ layer: overlayLayer, root, project, onOverlayActivate, isDestroyed, }) {
|
|
6
|
+
let overlayEvents = new AbortController();
|
|
7
|
+
function setOverlays(overlays) {
|
|
8
|
+
if (isDestroyed())
|
|
9
|
+
return;
|
|
10
|
+
validateOverlays(overlays);
|
|
11
|
+
const events = new AbortController();
|
|
12
|
+
const next = overlays.map((input) => {
|
|
13
|
+
const overlay = structuredClone(input);
|
|
14
|
+
const node = svgElement('path', {
|
|
15
|
+
'data-overlay-id': overlay.id,
|
|
16
|
+
d: geometryPath(overlay.geometry, project),
|
|
17
|
+
fill: overlay.fill ?? 'none',
|
|
18
|
+
'fill-opacity': overlay.fillOpacity ?? 1,
|
|
19
|
+
stroke: overlay.stroke ?? '#bd8b73',
|
|
20
|
+
'stroke-width': overlay.strokeWidth ?? 2,
|
|
21
|
+
'vector-effect': 'non-scaling-stroke',
|
|
22
|
+
display: overlay.visible === false ? 'none' : 'inline',
|
|
23
|
+
'pointer-events': 'none',
|
|
24
|
+
...(overlay.label ? { 'aria-label': overlay.label } : {}),
|
|
25
|
+
});
|
|
26
|
+
if (overlay.label) {
|
|
27
|
+
node.setAttribute('role', 'img');
|
|
28
|
+
const title = svgElement('title');
|
|
29
|
+
title.textContent = overlay.label;
|
|
30
|
+
node.append(title);
|
|
31
|
+
}
|
|
32
|
+
if (onOverlayActivate) {
|
|
33
|
+
node.setAttribute('role', 'button');
|
|
34
|
+
node.setAttribute('tabindex', overlay.visible === false ? '-1' : '0');
|
|
35
|
+
node.setAttribute('pointer-events', overlay.fill && overlay.fill !== 'none' ? 'visiblePainted' : 'stroke');
|
|
36
|
+
node.setAttribute('aria-label', overlay.label ?? overlay.id);
|
|
37
|
+
const activate = () => {
|
|
38
|
+
if (isDestroyed() || events.signal.aborted)
|
|
39
|
+
return;
|
|
40
|
+
onOverlayActivate(structuredClone(overlay));
|
|
41
|
+
if (isDestroyed() || events.signal.aborted)
|
|
42
|
+
return;
|
|
43
|
+
root.dispatchEvent(new CustomEvent('overlayactivate', {
|
|
44
|
+
bubbles: true,
|
|
45
|
+
detail: { overlay: structuredClone(overlay) },
|
|
46
|
+
}));
|
|
47
|
+
};
|
|
48
|
+
node.addEventListener('click', activate, { signal: events.signal });
|
|
49
|
+
node.addEventListener('keydown', (event) => {
|
|
50
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
activate();
|
|
53
|
+
}
|
|
54
|
+
}, { signal: events.signal });
|
|
55
|
+
}
|
|
56
|
+
return node;
|
|
57
|
+
});
|
|
58
|
+
overlayEvents.abort();
|
|
59
|
+
overlayEvents = events;
|
|
60
|
+
overlayLayer.replaceChildren(...next);
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
set: setOverlays,
|
|
64
|
+
destroy() {
|
|
65
|
+
overlayEvents.abort();
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { SFMapOptions } from './types.js';
|
|
2
|
+
/** Shared tokens for static SVGs, interactive maps, and consumer-owned place cards. */
|
|
3
|
+
export interface MapPalette {
|
|
4
|
+
readonly colors: NonNullable<SFMapOptions['colors']>;
|
|
5
|
+
readonly haloColor: string;
|
|
6
|
+
readonly markerColor: string;
|
|
7
|
+
readonly selectedMarkerColor: string;
|
|
8
|
+
readonly selectedMarkerRingColor: string;
|
|
9
|
+
}
|
|
10
|
+
/** Opt-in editorial palettes inspired by YorkSF. Existing renderer themes stay unchanged. */
|
|
11
|
+
export declare const mapPalettes: {
|
|
12
|
+
nightForest: {
|
|
13
|
+
colors: {
|
|
14
|
+
water: string;
|
|
15
|
+
land: string;
|
|
16
|
+
park: string;
|
|
17
|
+
neighborhood: string;
|
|
18
|
+
district: string;
|
|
19
|
+
road: string;
|
|
20
|
+
highway: string;
|
|
21
|
+
landmark: string;
|
|
22
|
+
bart: string;
|
|
23
|
+
label: string;
|
|
24
|
+
};
|
|
25
|
+
haloColor: string;
|
|
26
|
+
markerColor: string;
|
|
27
|
+
selectedMarkerColor: string;
|
|
28
|
+
selectedMarkerRingColor: string;
|
|
29
|
+
};
|
|
30
|
+
warmPaper: {
|
|
31
|
+
colors: {
|
|
32
|
+
water: string;
|
|
33
|
+
land: string;
|
|
34
|
+
park: string;
|
|
35
|
+
neighborhood: string;
|
|
36
|
+
district: string;
|
|
37
|
+
road: string;
|
|
38
|
+
highway: string;
|
|
39
|
+
landmark: string;
|
|
40
|
+
bart: string;
|
|
41
|
+
label: string;
|
|
42
|
+
};
|
|
43
|
+
haloColor: string;
|
|
44
|
+
markerColor: string;
|
|
45
|
+
selectedMarkerColor: string;
|
|
46
|
+
selectedMarkerRingColor: string;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { deepFreeze } from './immutable.js';
|
|
2
|
+
/** Opt-in editorial palettes inspired by YorkSF. Existing renderer themes stay unchanged. */
|
|
3
|
+
export const mapPalettes = /* @__PURE__ */ deepFreeze({
|
|
4
|
+
nightForest: {
|
|
5
|
+
colors: {
|
|
6
|
+
water: '#0a1412',
|
|
7
|
+
land: '#1b2c28',
|
|
8
|
+
park: '#1f3d33',
|
|
9
|
+
neighborhood: '#3b554e',
|
|
10
|
+
district: '#3b554e',
|
|
11
|
+
road: '#2f4943',
|
|
12
|
+
highway: '#5a4d40',
|
|
13
|
+
landmark: '#8fcbb4',
|
|
14
|
+
bart: '#8ea4ae',
|
|
15
|
+
label: '#d5e3e8',
|
|
16
|
+
},
|
|
17
|
+
haloColor: '#1b2c28',
|
|
18
|
+
markerColor: '#f2845f',
|
|
19
|
+
selectedMarkerColor: '#fff9e9',
|
|
20
|
+
selectedMarkerRingColor: '#ff957d',
|
|
21
|
+
},
|
|
22
|
+
warmPaper: {
|
|
23
|
+
colors: {
|
|
24
|
+
water: '#dce8ea',
|
|
25
|
+
land: '#f4efe4',
|
|
26
|
+
park: '#dcebe2',
|
|
27
|
+
neighborhood: '#88998e',
|
|
28
|
+
district: '#566b60',
|
|
29
|
+
road: '#c6bca9',
|
|
30
|
+
highway: '#987859',
|
|
31
|
+
landmark: '#315d48',
|
|
32
|
+
bart: '#365b68',
|
|
33
|
+
label: '#243d32',
|
|
34
|
+
},
|
|
35
|
+
haloColor: '#f4efe4',
|
|
36
|
+
markerColor: '#b23b10',
|
|
37
|
+
selectedMarkerColor: '#183a2f',
|
|
38
|
+
selectedMarkerRingColor: '#b23b10',
|
|
39
|
+
},
|
|
40
|
+
});
|
package/dist/src/presets.d.ts
CHANGED
package/dist/src/presets.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { interiorAnchor, layoutLabels, projectedBounds } from './explorer-layout.js';
|
|
2
2
|
import { labelCandidates, styleLabels } from './label-candidates.js';
|
|
3
|
+
import { spreadLabels } from './label-spread.js';
|
|
3
4
|
import { escapeXml, number } from './svg.js';
|
|
4
5
|
/** Conservative portable metrics; the browser refines these with its actual font. */
|
|
5
6
|
export function estimateLabelWidth(text, fontSize) {
|
|
@@ -19,6 +20,14 @@ export function estimateLabelWidth(text, fontSize) {
|
|
|
19
20
|
}
|
|
20
21
|
/** Interactive overview rules, expressed entirely in screen pixels before serialization. */
|
|
21
22
|
export function staticLabels(data, options, project) {
|
|
23
|
+
if (options.labelLayout?.compact) {
|
|
24
|
+
const { compact, ...layout } = options.labelLayout;
|
|
25
|
+
const wide = staticLabels(data, { ...options, labelLayout: layout }, project);
|
|
26
|
+
const narrow = staticLabels(data, { ...options, labelLayout: { ...layout, ...compact } }, project);
|
|
27
|
+
// Width-specific selectors prevent different breakpoints in inline maps interfering.
|
|
28
|
+
const key = compact.width;
|
|
29
|
+
return `<style>[data-label-layout="compact-${key}"]{display:none}@media(max-width:${key}px){[data-label-layout="wide-${key}"]{display:none}[data-label-layout="compact-${key}"]{display:inline}}</style><g data-label-layout="wide-${key}">${wide}</g><g data-label-layout="compact-${key}">${narrow}</g>`;
|
|
30
|
+
}
|
|
22
31
|
const width = options.labelLayout?.width ?? options.width ?? 800;
|
|
23
32
|
const unit = (options.width ?? 800) / width;
|
|
24
33
|
const height = (options.height ?? 800) / unit;
|
|
@@ -74,7 +83,11 @@ export function staticLabels(data, options, project) {
|
|
|
74
83
|
}))
|
|
75
84
|
: [],
|
|
76
85
|
neighborhoods: neighborhoods.flatMap((item) => item.point ? [{ point: item.point, name: item.name, kind: 'neighborhood' }] : []),
|
|
77
|
-
}, width, 1), {
|
|
86
|
+
}, width, 1), {
|
|
87
|
+
colors: options.colors,
|
|
88
|
+
markerRadius: marker?.radius ?? 6,
|
|
89
|
+
labelStyle: { haloColor: options.labelLayout?.haloColor },
|
|
90
|
+
});
|
|
78
91
|
const obstacles = [
|
|
79
92
|
...(options.bartStations
|
|
80
93
|
? (data.bartStations ?? []).map((item) => {
|
|
@@ -96,5 +109,7 @@ export function staticLabels(data, options, project) {
|
|
|
96
109
|
textWidth: estimateLabelWidth(item.name, item.fontSize),
|
|
97
110
|
textHeight: item.fontSize * 1.25,
|
|
98
111
|
})), width, height, obstacles);
|
|
99
|
-
return `<g data-layer="overview-labels" font-family="system-ui,sans-serif">${placed
|
|
112
|
+
return `<g data-layer="overview-labels" font-family="system-ui,sans-serif">${spreadLabels(placed, options.labelLayout?.maxLabels, markers.map((marker) => point([marker.lng, marker.lat])), width, height)
|
|
113
|
+
.map((item) => `<text data-label-kind="${escapeXml(item.kind)}" text-anchor="middle" x="${number((item.left + item.textWidth / 2) * unit)}" y="${number((item.top + item.fontSize) * unit)}" font-size="${number(item.fontSize * unit)}" font-weight="${item.fontWeight}" fill="${escapeXml(item.fill)}" stroke="${escapeXml(item.halo)}" stroke-width="${number(3 * unit)}" stroke-linejoin="round" paint-order="stroke">${escapeXml(item.name)}</text>`)
|
|
114
|
+
.join('')}</g>`;
|
|
100
115
|
}
|
package/dist/src/static.js
CHANGED
|
@@ -84,9 +84,28 @@ function expandStaticOptions(options) {
|
|
|
84
84
|
if (options[key] !== undefined && typeof options[key] !== 'string')
|
|
85
85
|
throw new TypeError(`${key} must be a string.`);
|
|
86
86
|
if (options.labelLayout !== undefined) {
|
|
87
|
-
assertOptions(options.labelLayout, 'label layout', [
|
|
87
|
+
assertOptions(options.labelLayout, 'label layout', [
|
|
88
|
+
'width',
|
|
89
|
+
'maxLabels',
|
|
90
|
+
'haloColor',
|
|
91
|
+
'compact',
|
|
92
|
+
]);
|
|
88
93
|
if (!Number.isFinite(options.labelLayout.width) || options.labelLayout.width <= 0)
|
|
89
|
-
throw new RangeError('
|
|
94
|
+
throw new RangeError('Invalid label layout width.');
|
|
95
|
+
const { maxLabels, haloColor, compact } = options.labelLayout;
|
|
96
|
+
if (maxLabels !== undefined && (!Number.isSafeInteger(maxLabels) || maxLabels < 0))
|
|
97
|
+
throw new RangeError('Invalid maxLabels.');
|
|
98
|
+
if (haloColor !== undefined && typeof haloColor !== 'string')
|
|
99
|
+
throw new TypeError('haloColor must be a string.');
|
|
100
|
+
if (compact !== undefined) {
|
|
101
|
+
assertOptions(compact, 'compact label layout', ['width', 'maxLabels']);
|
|
102
|
+
if (!Number.isFinite(compact.width) ||
|
|
103
|
+
compact.width <= 0 ||
|
|
104
|
+
compact.width >= options.labelLayout.width)
|
|
105
|
+
throw new RangeError('Invalid compact width.');
|
|
106
|
+
if (!Number.isSafeInteger(compact.maxLabels) || compact.maxLabels < 0)
|
|
107
|
+
throw new RangeError('Invalid maxLabels.');
|
|
108
|
+
}
|
|
90
109
|
}
|
|
91
110
|
if (options.markers !== undefined)
|
|
92
111
|
validateMarkers(options.markers);
|
package/dist/src/types.d.ts
CHANGED
|
@@ -56,6 +56,15 @@ export interface SFMapOptions {
|
|
|
56
56
|
/** Opt into interactive overview label priorities/collisions at this CSS display width. Uses estimated system-font metrics without a DOM. */
|
|
57
57
|
labelLayout?: {
|
|
58
58
|
width: number;
|
|
59
|
+
/** Maximum placed labels, spread across the map with nearby-pin context; zero hides text only. */
|
|
60
|
+
maxLabels?: number;
|
|
61
|
+
/** Label outline color, for example the land color on a dark map. */
|
|
62
|
+
haloColor?: string;
|
|
63
|
+
/** Alternate layout below this SVG viewport width, without browser JavaScript. */
|
|
64
|
+
compact?: {
|
|
65
|
+
width: number;
|
|
66
|
+
maxLabels: number;
|
|
67
|
+
};
|
|
59
68
|
};
|
|
60
69
|
highways?: boolean;
|
|
61
70
|
keyRoads?: boolean;
|
package/docs/API.md
CHANGED
|
@@ -31,8 +31,32 @@ The SVG description names the selected neighborhood source for source-aware data
|
|
|
31
31
|
|
|
32
32
|
Static `labelLayout: { width: 390 }` opts into shared interactive **overview** label priorities, sizes, anchors and collision rules. `width` must be a positive finite CSS canvas width; it does not alter the SVG viewBox. Set `neighborhoodLabels: true` (flat or under `layers`) to add area names in this layout. Guide SVGs and shells enable neighborhood names by default only when opting into `labelLayout`. `labels: false` remains the master switch. Default static output is unchanged.
|
|
33
33
|
|
|
34
|
+
For editorial previews, `labelLayout` also accepts `maxLabels` (a nonnegative safe integer), `haloColor` (an SVG color string), and `compact: { width, maxLabels }`. The compact width must be positive, finite, and smaller than the main layout width. Each layout places labels around pins independently, then chooses up to its budget for spatial coverage and nearby-pin context (selected marker first when it fits). Without a budget the existing overview order is unchanged. Equal spatial scores retain the original priority order. A zero budget hides text, never pins. The default halo remains white; use the land color for a quieter dark map.
|
|
35
|
+
|
|
36
|
+
Compact mode embeds two label groups and a media query in the same SVG. At or below the compact width it shows the compact group; above it shows the main group. In an `<img>` or standalone SVG, the breakpoint follows the SVG viewport. In inline SVG it follows the document viewport, so use `<img>` for cards whose width differs from the page. No JavaScript, duplicated geometry, external fonts, or extra image requests are needed. The compact width is also its font-layout hint; very narrow images still scale the text. The interactive renderer continues to use its live collision layout after enhancement.
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
createGuideSVG({
|
|
40
|
+
labelLayout: {
|
|
41
|
+
width: 800,
|
|
42
|
+
haloColor: '#1b2c28',
|
|
43
|
+
compact: { width: 480, maxLabels: 4 },
|
|
44
|
+
},
|
|
45
|
+
colors: { water: '#0a1412', land: '#1b2c28', label: '#d5e3e8' },
|
|
46
|
+
markers: places,
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
See the [field-guide preview recipe](consumer-integration.md#editorial-map-previews) for a complete build-time example.
|
|
51
|
+
|
|
34
52
|
Static placement uses estimated system-font metrics without browser globals. Interactive placement measures actual fonts and includes live selection and HTML furniture, so exact glyph positions and the visible label set can differ. See the [integration recipe](consumer-integration.md#keep-overview-labels-consistent) for width and progressive-enhancement guidance.
|
|
35
53
|
|
|
54
|
+
## Editorial palettes
|
|
55
|
+
|
|
56
|
+
Import `mapPalettes` from `@kahwee/sf-map-svg/presets`. `nightForest` and `warmPaper` are deeply frozen, opt-in token sets. Each supplies `colors`, `haloColor`, `markerColor`, `selectedMarkerColor`, and `selectedMarkerRingColor`. They work with any supplied geography and do not change the existing themes. Text clears 4.5:1 and pins/rings clear 3:1 against the palette's land, park, and water surfaces; consumers must check their own overrides and surrounding UI.
|
|
57
|
+
|
|
58
|
+
For static maps pass `colors: palette.colors`, `labelLayout: { width: 800, haloColor: palette.haloColor }`, and the marker color on each marker. For interactive maps pass `appearance: { colors: palette.colors, labelStyle: { haloColor: palette.haloColor }, markerColor: palette.markerColor, selectedMarkerColor: palette.selectedMarkerColor }` and `features: { selectedMarkerRing: { color: palette.selectedMarkerRingColor } }`. Choose the palette in the consuming application; these tokens do not read system preferences or install global CSS.
|
|
59
|
+
|
|
36
60
|
## Interactive maps
|
|
37
61
|
|
|
38
62
|
`createMap` defaults to `mode: 'basemap'` and `appearance.theme: 'transit'`. Set the mode explicitly for neighborhood or district exploration. District and neighborhood layer defaults follow the mode; other supplied layers default on. The [developer guide](developer-guide.md) compares static and interactive defaults and explains configuration resets, coordinates, and cleanup.
|
|
@@ -109,3 +133,11 @@ Neighborhood IDs are exact source-scoped feature IDs, rather than display names
|
|
|
109
133
|
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.
|
|
110
134
|
|
|
111
135
|
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.
|
|
136
|
+
|
|
137
|
+
## Linked place cards
|
|
138
|
+
|
|
139
|
+
`bindMapCards(map, root, options)` from `@kahwee/sf-map-svg/cards` connects consumer-owned cards to a `MapController`. Each card uses `data-map-marker="stable-marker-id"`; native `<button type="button" data-map-select>` controls select that place without panning. Article links keep their ordinary navigation. `options.markers` is the same catalog supplied to the map; `highlightColor` optionally changes the preview ring and `scrollOnSelect: false` disables automatic card reveal.
|
|
140
|
+
|
|
141
|
+
Pointer hover and keyboard focus draw a temporary ring through `projectToScreen()`. They do not call `selectMarker`, change the camera, or emit selection events. Focused cards take precedence over hovered cards. Offscreen points do not draw a ring. Touch users use the selection button. Selected cards receive `data-map-selected` and their buttons receive `aria-pressed`; style the card attribute in your own CSS. Selecting a pin reveals its card with immediate native scrolling, without moving focus. Filtering/reordering/removing cards is safe; IDs are compared directly, not inserted into CSS selectors.
|
|
142
|
+
|
|
143
|
+
Call `binding.setMarkers(next)` after `map.setMarkers(next)` when the catalog changes. Call `binding.destroy()` before `map.destroy()` on unmount; cleanup is idempotent and restores attributes that the binding owned. The module does not create cards, fetch data, change URLs, install global CSS, or import geography. See the [linked-card recipe](consumer-integration.md#link-place-cards-to-pins).
|
|
@@ -51,7 +51,7 @@ const labeledPlacesMarkup = createGuideShell({
|
|
|
51
51
|
|
|
52
52
|
After inserting that markup, pass `markers: guideMarkers`, `layers: guideLayers`, and `appearance: { colors: guideColors }` to `mountGuideController()` as shown below. The static-only `labelLayout` option is not a browser option: the controller measures the actual canvas and font. The shell border is outside the canvas, so a default shell with a 390 px outer width has a 388 px canvas.
|
|
53
53
|
|
|
54
|
-
For responsive layouts whose canvas width is unknown at build time, choose a representative width and accept text scaling until enhancement, or serve independently rendered SVG variants through your application's responsive markup. The package does not detect a device on the server. Keep `labels: false` if your design requires no visible text change at enhancement.
|
|
54
|
+
For responsive layouts whose canvas width is unknown at build time, use `labelLayout.compact` for an embedded image with two static label layouts (see [editorial previews](#editorial-map-previews)), choose a representative width and accept text scaling until enhancement, or serve independently rendered SVG variants through your application's responsive markup. The package does not detect a device on the server. Keep `labels: false` if your design requires no visible text change at enhancement.
|
|
55
55
|
|
|
56
56
|
For `renderMap()`, also enable `neighborhoodLabels` explicitly; it defaults off outside the guide preset. `labels: false` hides all text in either layout while preserving symbols and accessible titles.
|
|
57
57
|
|
|
@@ -87,3 +87,41 @@ For a new browser-only guide, use `createGuideController()` and mount its `.elem
|
|
|
87
87
|
`createMap(guideMapData, guideOptions)` remains available through `/guide/data` and `/presets`. For server rendering, pass `guideMapData.map` or `/data/static` to `renderMap()`. Use `/data/full` when interactive lookup collections are needed. The guide's detailed geography loads only on an explicit `loadGuideDetailedData()` call; enabling a layer does not download it.
|
|
88
88
|
|
|
89
89
|
See the [exact API contract](API.md), [worked examples](EXAMPLES.md), and [bundle measurements](guide-bundle-report.md).
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
## Editorial map previews
|
|
93
|
+
|
|
94
|
+
YorkSF uses one city overview to connect its field notes and compact locator maps to orient individual articles. These patterns work for any local guide: keep the place catalog in the consuming application, generate SVG images at build time, and link each preview to the application's map or article route.
|
|
95
|
+
|
|
96
|
+
Run `pnpm demo` and open `examples/generated/field-guide.html` for a self-contained light/dark example. Its overview uses `labelLayout.compact` to limit mobile text without removing pins, and `haloColor` to match the night palette. The locator cards share those colors but omit labels at small sizes. Images reserve their aspect ratio and load lazily; the links work without JavaScript. The example derives its sample park coordinates from the package's existing landmark data rather than maintaining another geographic dataset.
|
|
97
|
+
|
|
98
|
+
Use stable marker IDs for deep links. Keep addresses, visit dates, historical-status caveats, and editorial descriptions in your own content model. The package supplies map graphics; it does not imply that a place is currently open or that an author visited it. Keep a visible list beside the overview so the image is not the only way to find a place. SVG alt text should describe its purpose; link text should name the destination.
|
|
99
|
+
|
|
100
|
+
For an image card, `compact.width` follows the image's rendered viewport. For inline SVG it follows the outer document viewport; do not assume it behaves like a container query. Avoid embedding scripts in exported images. Interactive maps remain a separate progressive enhancement.
|
|
101
|
+
|
|
102
|
+
## Link place cards to pins
|
|
103
|
+
|
|
104
|
+
Keep ordinary article links and add a separate map-selection button. Native buttons give keyboard and touch users the same action as pointer users:
|
|
105
|
+
|
|
106
|
+
```html
|
|
107
|
+
<article data-map-marker="park">
|
|
108
|
+
<h2><a href="/places/park/">A place in the park</a></h2>
|
|
109
|
+
<button type="button" data-map-select>Show on map</button>
|
|
110
|
+
</article>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
import { bindMapCards } from '@kahwee/sf-map-svg/cards';
|
|
115
|
+
import { mapPalettes } from '@kahwee/sf-map-svg/presets';
|
|
116
|
+
|
|
117
|
+
const binding = bindMapCards(map, document.querySelector('#place-cards'), {
|
|
118
|
+
markers: places,
|
|
119
|
+
highlightColor: mapPalettes.nightForest.selectedMarkerRingColor,
|
|
120
|
+
});
|
|
121
|
+
// On catalog change: map.setMarkers(next); binding.setMarkers(next);
|
|
122
|
+
// On unmount: binding.destroy(); map.destroy();
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Style `[data-map-selected]` with an outline or background change that remains legible in both themes. Use a visible focus outline on card buttons and links. Hover/focus only previews the location: it preserves selection and camera. Map selection reveals the card without shifting keyboard focus; set `scrollOnSelect: false` if your application owns scrolling. Match cards and markers by stable IDs, and keep hidden/filtered places out of both catalogs when that is your intended filter behavior.
|
|
126
|
+
|
|
127
|
+
Run `pnpm demo` and open `examples/generated/linked-cards.html` for the complete pattern. Storybook **Maps / Linked place cards** checks hover, keyboard selection, card reveal, both palettes, and disposal.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Guide bundle size report
|
|
2
2
|
|
|
3
|
-
Generated 2026-10-
|
|
3
|
+
Generated 2026-10-14 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) | 122.
|
|
8
|
-
| Static renderer |
|
|
9
|
-
| Guide preset |
|
|
7
|
+
| Renderer root (explicit data) | 122.8 KB | 36.3 KB | — |
|
|
8
|
+
| Static renderer | 33.2 KB | 10.2 KB | — |
|
|
9
|
+
| Guide preset | 507.0 KB | 122.1 KB | 467.0 KB |
|
|
10
10
|
|
|
11
11
|
**500 KB target:** met.
|
|
12
12
|
|