@kahwee/sf-map-svg 4.4.0 → 5.0.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 +25 -0
- package/README.md +32 -0
- package/dist/data/README.md +7 -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/configuration.d.ts +2 -0
- package/dist/src/configuration.js +30 -0
- package/dist/src/controller-types.d.ts +11 -15
- 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 +156 -667
- 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/geometry.d.ts +6 -1
- package/dist/src/geometry.js +80 -0
- package/dist/src/guide-map.js +2 -1
- package/dist/src/guide-static.d.ts +1 -1
- package/dist/src/label-spread.d.ts +5 -0
- package/dist/src/label-spread.js +36 -0
- package/dist/src/layers.js +1 -1
- package/dist/src/map.d.ts +1 -1
- package/dist/src/map.js +24 -13
- package/dist/src/marker-catalog.d.ts +1 -1
- package/dist/src/marker-catalog.js +11 -5
- 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 +19 -4
- package/dist/src/static.d.ts +2 -2
- package/dist/src/static.js +21 -2
- package/dist/src/types.d.ts +21 -9
- package/dist/src/validation.js +7 -5
- package/docs/API.md +75 -5
- package/docs/EXAMPLES.md +29 -0
- package/docs/api-audit.md +22 -5
- package/docs/consumer-integration.md +86 -2
- package/docs/developer-guide.md +29 -7
- package/docs/guide-bundle-report.md +4 -4
- package/docs/migration-v5.md +100 -0
- package/llms-full.txt +508 -53
- package/llms.txt +2 -1
- package/package.json +17 -8
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { createMarkerLayer, MarkerItem } from './marker-layer.js';
|
|
2
2
|
import type { MapMarker } from './types.js';
|
|
3
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:
|
|
4
|
+
export declare function createMarkerCatalog(renderer: ReturnType<typeof createMarkerLayer>, picker: HTMLSelectElement, project: (point: import('../data/types.js').Position) => [number, number]): {
|
|
5
5
|
readonly items: MarkerItem[];
|
|
6
6
|
prepare(markers: readonly MapMarker[], selected: string | null): {
|
|
7
7
|
selection: string | null;
|
|
@@ -14,19 +14,25 @@ export function createMarkerCatalog(renderer, picker, project) {
|
|
|
14
14
|
const next = markers.map((marker) => {
|
|
15
15
|
ids.add(marker.id);
|
|
16
16
|
const previous = existing.get(marker.id);
|
|
17
|
-
const point = previous?.marker.
|
|
17
|
+
const point = previous?.marker.position[0] === marker.position[0] &&
|
|
18
|
+
previous.marker.position[1] === marker.position[1]
|
|
18
19
|
? previous.point
|
|
19
|
-
: project(
|
|
20
|
-
return {
|
|
20
|
+
: project(marker.position);
|
|
21
|
+
return {
|
|
22
|
+
marker: { ...marker, position: [...marker.position] },
|
|
23
|
+
point,
|
|
24
|
+
};
|
|
21
25
|
});
|
|
22
26
|
const selection = selected && ids.has(selected)
|
|
23
27
|
? selected
|
|
24
28
|
: (markers.find((marker) => marker.selected)?.id ?? null);
|
|
25
|
-
const keys = ['id', '
|
|
29
|
+
const keys = ['id', 'label', 'selected', 'color', 'radius'];
|
|
26
30
|
if (picker.options.length > 0 &&
|
|
27
31
|
selection === selected &&
|
|
28
32
|
next.length === items.length &&
|
|
29
|
-
next.every(({ marker }, index) =>
|
|
33
|
+
next.every(({ marker }, index) => marker.position.length === items[index].marker.position.length &&
|
|
34
|
+
marker.position.every((value, offset) => value === items[index].marker.position[offset]) &&
|
|
35
|
+
keys.every((key) => marker[key] === items[index].marker[key])))
|
|
30
36
|
return undefined;
|
|
31
37
|
const focused = items.find((item) => item.visual?.node === document.activeElement)?.marker.id;
|
|
32
38
|
return {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Bounds, NeighborhoodFeature, NeighborhoodSource } from '../data/types.js';
|
|
2
|
+
import type { InteractiveSFMapData } from './explorer-data.js';
|
|
3
|
+
import { type Project } from './geometry.js';
|
|
4
|
+
import type { CameraOptions, DistrictYear, ExplorerMode, MapPadding, MapViewport, NeighborhoodExplorerOptions, NeighborhoodSelection } from './types.js';
|
|
5
|
+
export interface NeighborhoodItem {
|
|
6
|
+
feature: NeighborhoodFeature;
|
|
7
|
+
node: SVGPathElement;
|
|
8
|
+
bounds: Bounds;
|
|
9
|
+
point: [number, number] | undefined;
|
|
10
|
+
area: number;
|
|
11
|
+
}
|
|
12
|
+
export declare const sourceNames: Record<NeighborhoodSource, string>;
|
|
13
|
+
export declare const formatSourceLabel: (source: NeighborhoodSource) => string;
|
|
14
|
+
/** Source-scoped names and aliases; same-name polygons never cross source boundaries. */
|
|
15
|
+
export declare function createNeighborhoodLookup(collections: InteractiveSFMapData['neighborhoods']): {
|
|
16
|
+
getNeighborhood: (name: string, selectedSource: NeighborhoodSource) => import("../data/types.js").Feature<import("../data/types.js").NeighborhoodProperties> | undefined;
|
|
17
|
+
searchNeighborhoods: (query: string, selectedSource: NeighborhoodSource) => {
|
|
18
|
+
id: string;
|
|
19
|
+
canonicalName: string;
|
|
20
|
+
}[];
|
|
21
|
+
};
|
|
22
|
+
interface NeighborhoodPresentation {
|
|
23
|
+
mode: ExplorerMode;
|
|
24
|
+
year: DistrictYear;
|
|
25
|
+
fitPadding: number | MapPadding;
|
|
26
|
+
areaStyle: NonNullable<NeighborhoodExplorerOptions['areaStyle']>;
|
|
27
|
+
colors: NonNullable<NeighborhoodExplorerOptions['colors']>;
|
|
28
|
+
selectableNeighborhoods: boolean;
|
|
29
|
+
lines: boolean;
|
|
30
|
+
}
|
|
31
|
+
interface NeighborhoodLayerOptions {
|
|
32
|
+
collections: InteractiveSFMapData['neighborhoods'];
|
|
33
|
+
initialSource: NeighborhoodSource;
|
|
34
|
+
project: Project;
|
|
35
|
+
root: HTMLElement;
|
|
36
|
+
areas: SVGGElement;
|
|
37
|
+
canvas: HTMLElement;
|
|
38
|
+
sourceSelect: HTMLSelectElement;
|
|
39
|
+
neighborhoodSelect: HTMLSelectElement;
|
|
40
|
+
status: HTMLElement;
|
|
41
|
+
readPresentation(): NeighborhoodPresentation;
|
|
42
|
+
isDestroyed(): boolean;
|
|
43
|
+
ensureVisible(): void;
|
|
44
|
+
refresh: {
|
|
45
|
+
selection(): void;
|
|
46
|
+
source(): void;
|
|
47
|
+
};
|
|
48
|
+
moveView(view: MapViewport, options: CameraOptions): void;
|
|
49
|
+
resetView(): void;
|
|
50
|
+
crossfade(layer: SVGGElement, commit: () => void): void;
|
|
51
|
+
}
|
|
52
|
+
/** Own neighborhood identity, prepared geometry, selection revisions and hover listeners.
|
|
53
|
+
* Preparation is detached: only the returned commit can replace the active source.
|
|
54
|
+
*/
|
|
55
|
+
export declare function createNeighborhoodLayer({ collections, initialSource, project, root, areas, canvas, sourceSelect, neighborhoodSelect, status, readPresentation, isDestroyed, ensureVisible, refresh, moveView, resetView, crossfade, }: NeighborhoodLayerOptions): {
|
|
56
|
+
readonly source: NeighborhoodSource;
|
|
57
|
+
readonly selected: NeighborhoodFeature | undefined;
|
|
58
|
+
readonly items: readonly NeighborhoodItem[];
|
|
59
|
+
getSelection: () => NeighborhoodSelection | null;
|
|
60
|
+
selectNeighborhood: (name: string | null, options?: {
|
|
61
|
+
fit?: boolean;
|
|
62
|
+
} & CameraOptions) => boolean;
|
|
63
|
+
prepareSource: (next: NeighborhoodSource, reset?: boolean) => () => void;
|
|
64
|
+
setSource: (next: NeighborhoodSource) => void;
|
|
65
|
+
destroy(): void;
|
|
66
|
+
};
|
|
67
|
+
export {};
|
|
@@ -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;
|
|
@@ -48,7 +57,7 @@ export function staticLabels(data, options, project) {
|
|
|
48
57
|
selected: marker
|
|
49
58
|
? [
|
|
50
59
|
{
|
|
51
|
-
point: point(
|
|
60
|
+
point: point(marker.position),
|
|
52
61
|
name: marker.label ?? marker.id,
|
|
53
62
|
kind: 'selected-marker',
|
|
54
63
|
},
|
|
@@ -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) => {
|
|
@@ -83,7 +96,7 @@ export function staticLabels(data, options, project) {
|
|
|
83
96
|
})
|
|
84
97
|
: []),
|
|
85
98
|
...markers.map((item) => {
|
|
86
|
-
const [x, y] = point(
|
|
99
|
+
const [x, y] = point(item.position);
|
|
87
100
|
// Also cover the legacy SVG marker when it is larger than its interactive counterpart.
|
|
88
101
|
const radius = Math.max(item.radius ?? 6, (item.radius ?? (item.selected ? 8 : 5)) / unit) + 3;
|
|
89
102
|
return [x - radius, y - radius, x + radius, y + radius];
|
|
@@ -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.position)), 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.d.ts
CHANGED
|
@@ -8,14 +8,14 @@ export type { DistrictStyle, DistrictYear, SFMapOptions as StaticMapOptions, Sta
|
|
|
8
8
|
/** Server-safe SVG rendering with explicit data; returns SVG plus projection helpers. */
|
|
9
9
|
export declare function renderMap(data: StaticMapInput, options?: SFMapOptions): {
|
|
10
10
|
svg: string;
|
|
11
|
-
project: (coordinates: import("
|
|
11
|
+
project: (coordinates: import("./geometry.js").Position) => [number, number];
|
|
12
12
|
viewBox: [number, number, number, number];
|
|
13
13
|
};
|
|
14
14
|
/** Project canonical layer geometry without constructing an SVG string. */
|
|
15
15
|
export declare function getLayerPaths(data: StaticMapInput, options?: SFMapOptions): {
|
|
16
16
|
year: import("./types.js").DistrictYear;
|
|
17
17
|
viewBox: [number, number, number, number];
|
|
18
|
-
project: (coordinates: import("
|
|
18
|
+
project: (coordinates: import("./geometry.js").Position) => [number, number];
|
|
19
19
|
coast: string;
|
|
20
20
|
districts: {
|
|
21
21
|
id: number;
|
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);
|