@kahwee/sf-map-svg 4.3.2 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +18 -1
  3. package/dist/src/cards.d.ts +15 -0
  4. package/dist/src/cards.js +138 -0
  5. package/dist/src/cluster-layer.d.ts +2 -0
  6. package/dist/src/cluster-layer.js +2 -0
  7. package/dist/src/explorer-controls.d.ts +26 -0
  8. package/dist/src/explorer-controls.js +120 -0
  9. package/dist/src/explorer-core.d.ts +6 -1
  10. package/dist/src/explorer-core.js +168 -701
  11. package/dist/src/explorer-shell.d.ts +62 -0
  12. package/dist/src/explorer-shell.js +228 -0
  13. package/dist/src/explorer-sidebar.d.ts +20 -0
  14. package/dist/src/explorer-sidebar.js +79 -0
  15. package/dist/src/guide-data.js +1 -0
  16. package/dist/src/guide-map.js +2 -1
  17. package/dist/src/guide-static.js +1 -0
  18. package/dist/src/label-candidates.d.ts +24 -0
  19. package/dist/src/label-candidates.js +29 -0
  20. package/dist/src/label-renderer.d.ts +1 -10
  21. package/dist/src/label-spread.d.ts +5 -0
  22. package/dist/src/label-spread.js +36 -0
  23. package/dist/src/map-core.d.ts +3 -1
  24. package/dist/src/map-core.js +7 -5
  25. package/dist/src/map.js +2 -1
  26. package/dist/src/neighborhood-layer.d.ts +67 -0
  27. package/dist/src/neighborhood-layer.js +256 -0
  28. package/dist/src/overlay-layer.d.ts +13 -0
  29. package/dist/src/overlay-layer.js +68 -0
  30. package/dist/src/palettes.d.ts +48 -0
  31. package/dist/src/palettes.js +40 -0
  32. package/dist/src/presets.d.ts +1 -0
  33. package/dist/src/presets.js +1 -0
  34. package/dist/src/static-labels.d.ts +7 -0
  35. package/dist/src/static-labels.js +115 -0
  36. package/dist/src/static.js +31 -3
  37. package/dist/src/types.d.ts +16 -1
  38. package/docs/API.md +38 -2
  39. package/docs/EXAMPLES.md +1 -0
  40. package/docs/consumer-integration.md +81 -1
  41. package/docs/guide-bundle-report.md +4 -4
  42. package/llms-full.txt +274 -26
  43. package/llms.txt +1 -1
  44. package/package.json +16 -8
@@ -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
+ });
@@ -1,3 +1,4 @@
1
1
  import type { MapOptions } from './controller-types.js';
2
2
  /** Configuration only: pair with guide/data or a compatible caller-supplied dataset. */
3
3
  export declare const guideOptions: Readonly<MapOptions>;
4
+ export { type MapPalette, mapPalettes } from './palettes.js';
@@ -15,3 +15,4 @@ export const guideOptions = deepFreeze({
15
15
  roadLabels: true,
16
16
  },
17
17
  });
18
+ export { mapPalettes } from './palettes.js';
@@ -0,0 +1,7 @@
1
+ import type { Project } from './geometry.js';
2
+ import type { SFMapData } from './map-core.js';
3
+ import type { SFMapOptions } from './types.js';
4
+ /** Conservative portable metrics; the browser refines these with its actual font. */
5
+ export declare function estimateLabelWidth(text: string, fontSize: number): number;
6
+ /** Interactive overview rules, expressed entirely in screen pixels before serialization. */
7
+ export declare function staticLabels(data: SFMapData, options: SFMapOptions, project: Project): string;
@@ -0,0 +1,115 @@
1
+ import { interiorAnchor, layoutLabels, projectedBounds } from './explorer-layout.js';
2
+ import { labelCandidates, styleLabels } from './label-candidates.js';
3
+ import { spreadLabels } from './label-spread.js';
4
+ import { escapeXml, number } from './svg.js';
5
+ /** Conservative portable metrics; the browser refines these with its actual font. */
6
+ export function estimateLabelWidth(text, fontSize) {
7
+ return Array.from(text).reduce((width, char) => width +
8
+ (/[\u0300-\u036f]/u.test(char)
9
+ ? 0
10
+ : /[\s.,:;'!|ilI]/u.test(char)
11
+ ? 0.32
12
+ : /[MW@%]/u.test(char)
13
+ ? 0.95
14
+ : (char.codePointAt(0) ?? 0) > 0x024f
15
+ ? 1
16
+ : /[A-Z]/u.test(char)
17
+ ? 0.72
18
+ : 0.6) *
19
+ fontSize, 0);
20
+ }
21
+ /** Interactive overview rules, expressed entirely in screen pixels before serialization. */
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
+ }
31
+ const width = options.labelLayout?.width ?? options.width ?? 800;
32
+ const unit = (options.width ?? 800) / width;
33
+ const height = (options.height ?? 800) / unit;
34
+ const point = (coordinates) => {
35
+ const [x, y] = project(coordinates);
36
+ return [x / unit, y / unit];
37
+ };
38
+ const markers = options.markers ?? [];
39
+ const marker = markers.find((item) => item.selected);
40
+ const neighborhoods = options.neighborhoodLabels
41
+ ? (data.neighborhoods ?? [])
42
+ .map((item) => {
43
+ const bounds = projectedBounds(item.geometry, point);
44
+ return {
45
+ ...item,
46
+ point: interiorAnchor(item.geometry, point),
47
+ area: (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]),
48
+ };
49
+ })
50
+ .sort((a, b) => b.area - a.area || (a.id ?? a.name).localeCompare(b.id ?? b.name))
51
+ : [];
52
+ const districts = options.districtLabels !== false ? (data.districts?.[options.year ?? 2022] ?? []) : [];
53
+ for (const district of districts)
54
+ if (!Number.isFinite(district.id))
55
+ throw new TypeError('District IDs must be finite numbers.');
56
+ const candidates = styleLabels(labelCandidates({
57
+ selected: marker
58
+ ? [
59
+ {
60
+ point: point([marker.lng, marker.lat]),
61
+ name: marker.label ?? marker.id,
62
+ kind: 'selected-marker',
63
+ },
64
+ ]
65
+ : [],
66
+ districts: options.districtLabels !== false
67
+ ? districts.flatMap((district) => (district.labelPoints.length ? district.labelPoints : [district.label]).map((label) => ({ point: point(label), name: String(district.id), kind: 'district' })))
68
+ : [],
69
+ stations: [], // Station names appear only after zooming; symbols remain at overview.
70
+ parks: options.landmarks
71
+ ? (data.landmarks ?? []).map((item) => ({
72
+ point: point(item.label),
73
+ name: item.name,
74
+ kind: 'park',
75
+ }))
76
+ : [],
77
+ roads: (options.roadLabels ?? options.keyRoads)
78
+ ? (data.keyRoads ?? []).map((item) => ({
79
+ point: point(item.label),
80
+ name: item.name,
81
+ level: item.level ?? 'primary',
82
+ kind: 'road',
83
+ }))
84
+ : [],
85
+ neighborhoods: neighborhoods.flatMap((item) => item.point ? [{ point: item.point, name: item.name, kind: 'neighborhood' }] : []),
86
+ }, width, 1), {
87
+ colors: options.colors,
88
+ markerRadius: marker?.radius ?? 6,
89
+ labelStyle: { haloColor: options.labelLayout?.haloColor },
90
+ });
91
+ const obstacles = [
92
+ ...(options.bartStations
93
+ ? (data.bartStations ?? []).map((item) => {
94
+ const [x, y] = point(item.coordinates);
95
+ return [x - 6, y - 6, x + 6, y + 6];
96
+ })
97
+ : []),
98
+ ...markers.map((item) => {
99
+ const [x, y] = point([item.lng, item.lat]);
100
+ // Also cover the legacy SVG marker when it is larger than its interactive counterpart.
101
+ const radius = Math.max(item.radius ?? 6, (item.radius ?? (item.selected ? 8 : 5)) / unit) + 3;
102
+ return [x - radius, y - radius, x + radius, y + radius];
103
+ }),
104
+ ];
105
+ const placed = layoutLabels(candidates.map((item) => ({
106
+ ...item,
107
+ x: item.point[0],
108
+ y: item.point[1],
109
+ textWidth: estimateLabelWidth(item.name, item.fontSize),
110
+ textHeight: item.fontSize * 1.25,
111
+ })), width, height, obstacles);
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>`;
115
+ }