@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.
@@ -0,0 +1,62 @@
1
+ import type { NeighborhoodSource } from '../data/types.js';
2
+ import type { NeighborhoodExplorerOptions } from './types.js';
3
+ /** Construct shared map chrome; optional explorer content has its own factory below. */
4
+ export declare function createMapShell({ chrome, styleOptions, strings, legendOptions, colors, attributionMode, }: {
5
+ source: NeighborhoodSource;
6
+ sources: readonly NeighborhoodSource[];
7
+ hasDistricts: boolean;
8
+ chrome: 'map' | 'explorer';
9
+ styleOptions: NonNullable<NeighborhoodExplorerOptions['style']>;
10
+ strings: NonNullable<NeighborhoodExplorerOptions['strings']>;
11
+ legendOptions: NonNullable<NeighborhoodExplorerOptions['legend']>;
12
+ colors: NonNullable<NeighborhoodExplorerOptions['colors']>;
13
+ attributionMode: 'full' | 'compact';
14
+ }): {
15
+ root: HTMLElement;
16
+ header: HTMLElement;
17
+ panel: HTMLDivElement;
18
+ modeSelect: HTMLSelectElement;
19
+ sourceLabel: HTMLLabelElement;
20
+ sourceSelect: HTMLSelectElement;
21
+ searchLabel: HTMLLabelElement;
22
+ search: HTMLInputElement;
23
+ count: HTMLParagraphElement;
24
+ results: HTMLDivElement;
25
+ detail: HTMLDivElement;
26
+ toolbar: HTMLDivElement;
27
+ canvas: HTMLDivElement;
28
+ hint: HTMLParagraphElement;
29
+ status: HTMLParagraphElement;
30
+ legend: HTMLDivElement;
31
+ featureControls: HTMLDivElement;
32
+ neighborhoodLabel: HTMLLabelElement;
33
+ neighborhoodSelect: HTMLSelectElement;
34
+ markerLabel: HTMLLabelElement;
35
+ markerSelect: HTMLSelectElement;
36
+ attribution: HTMLParagraphElement;
37
+ };
38
+ /** Explorer-only content stays out of map/controller bundles. */
39
+ export declare function createExplorerShell(options: Parameters<typeof createMapShell>[0]): {
40
+ root: HTMLElement;
41
+ header: HTMLElement;
42
+ panel: HTMLDivElement;
43
+ modeSelect: HTMLSelectElement;
44
+ sourceLabel: HTMLLabelElement;
45
+ sourceSelect: HTMLSelectElement;
46
+ searchLabel: HTMLLabelElement;
47
+ search: HTMLInputElement;
48
+ count: HTMLParagraphElement;
49
+ results: HTMLDivElement;
50
+ detail: HTMLDivElement;
51
+ toolbar: HTMLDivElement;
52
+ canvas: HTMLDivElement;
53
+ hint: HTMLParagraphElement;
54
+ status: HTMLParagraphElement;
55
+ legend: HTMLDivElement;
56
+ featureControls: HTMLDivElement;
57
+ neighborhoodLabel: HTMLLabelElement;
58
+ neighborhoodSelect: HTMLSelectElement;
59
+ markerLabel: HTMLLabelElement;
60
+ markerSelect: HTMLSelectElement;
61
+ attribution: HTMLParagraphElement;
62
+ };
@@ -0,0 +1,228 @@
1
+ import { element } from './dom.js';
2
+ import { sourceNames } from './neighborhood-layer.js';
3
+ let explorerCount = 0;
4
+ /** Construct shared map chrome; optional explorer content has its own factory below. */
5
+ export function createMapShell({ chrome, styleOptions, strings, legendOptions, colors, attributionMode, }) {
6
+ const root = element('section', '', 'sf-explorer');
7
+ root.setAttribute('aria-label', 'San Francisco neighborhood explorer');
8
+ const style = element('style');
9
+ style.textContent = `
10
+ .sf-explorer{container:sf-neighborhood-explorer / inline-size;font:14px/1.5 var(--sf-map-font);color:var(--sf-map-ink);background:var(--sf-map-surface);border:1px solid var(--sf-map-border);border-radius:14px;overflow:hidden;max-width:1120px;margin:auto;box-shadow:0 8px 32px #18364f08}
11
+ .sf-explorer *{box-sizing:border-box}
12
+ .sf-explorer [hidden]{display:none!important}
13
+ .sf-explorer h2,.sf-explorer p{margin:0}
14
+ .sf-explorer button,.sf-explorer input,.sf-explorer select,.sf-explorer a{font:inherit}
15
+ .sf-explorer button,.sf-explorer select,.sf-explorer input{color:inherit;border:1px solid #c7d5df;border-radius:7px;background:var(--sf-map-surface);min-height:44px;padding:9px 12px}
16
+ .sf-explorer button{cursor:pointer;transition:background .12s,border-color .12s}
17
+ .sf-explorer button:hover{background:#f0f6fa;border-color:#8ba9c0}
18
+ .sf-explorer button[aria-pressed=true]{color:#123d63;background:#e6f1f9;border-color:#5689b0;font-weight:650}
19
+ .sf-explorer :focus-visible{outline:3px solid var(--sf-map-focus);outline-offset:2px}
20
+ .sf-explorer-map-column{grid-area:map;min-width:0;background:#e4f2f8}
21
+ .sf-explorer-toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:var(--sf-map-control-gap);padding:12px;background:var(--sf-map-surface);border-bottom:1px solid var(--sf-map-border)}
22
+ .sf-explorer-control-group{display:flex;align-items:center;flex-wrap:wrap;gap:6px;max-width:100%}
23
+ .sf-explorer-pan-controls{margin-left:auto}
24
+ .sf-explorer-toolbar button{min-width:44px;padding:6px 10px}
25
+ .sf-explorer-zoom{font-size:12px;min-width:42px;text-align:center;font-variant-numeric:tabular-nums;color:#496578}
26
+ .sf-explorer-canvas{position:relative;aspect-ratio:1;overflow:hidden;touch-action:pan-y pinch-zoom}
27
+ .sf-marker-chooser{position:absolute;z-index:2;top:12px;right:12px;display:flex;flex-direction:column;gap:6px;box-sizing:border-box;width:280px;max-width:calc(100% - 24px);max-height:calc(100% - 24px);overflow:auto;padding:12px;border:1px solid var(--sf-map-border);border-radius:8px;background:var(--sf-map-surface);color:var(--sf-map-ink);box-shadow:0 6px 20px #0003}
28
+ .sf-marker-chooser button{min-height:44px;text-align:left;overflow-wrap:anywhere}
29
+ .sf-explorer-canvas>svg{display:block;width:100%;height:100%;max-width:none;cursor:grab;user-select:none}
30
+ .sf-explorer-canvas>svg:active{cursor:grabbing}
31
+ .sf-explorer-canvas path[data-neighborhood-id]{cursor:pointer}
32
+ .sf-explorer-legend{display:flex;flex-wrap:wrap;gap:8px 20px;padding:14px 16px 8px;background:var(--sf-map-surface);border-top:1px solid #dce5eb;color:#496578;font-size:12px}
33
+ .sf-explorer-legend-item{display:inline-flex;align-items:center;gap:7px;white-space:nowrap}
34
+ .sf-explorer-legend-symbol{display:inline-block;flex:none;width:15px;height:11px;border-radius:2px}
35
+ .sf-explorer-legend-bart{width:10px;height:10px;margin:0 2px;border:2px solid #0073ae;border-radius:50%;background:#fff}
36
+ .sf-explorer-legend-park{background:#c6dfbd;border:1px solid #a8c69d}
37
+ .sf-explorer-legend-road{height:2px;border-radius:2px;background:#bcc3c5}
38
+ .sf-explorer-legend-highway{height:3px;border-radius:2px;background:#b9a18a}
39
+ .sf-explorer-hint{padding:6px 16px 10px;font-size:12px;color:#586f80;background:#fff}
40
+ .sf-explorer text{pointer-events:none}
41
+ .sf-explorer-status{padding:0 16px 14px;font-size:12px;background:var(--sf-map-surface);color:#496578}
42
+ .sf-explorer-visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}
43
+ .sf-explorer[data-interface=map] .sf-explorer-body{display:block}
44
+ .sf-explorer[data-interface=map] .sf-explorer-header,.sf-explorer[data-interface=map] .sf-explorer-panel,.sf-explorer[data-interface=map] .sf-explorer-detail{display:none}
45
+ .sf-explorer-feature-controls{display:flex;flex-wrap:wrap;gap:8px;padding:10px;background:#fff}
46
+ .sf-explorer-feature-controls label{display:flex;flex:1;flex-direction:column;min-width:0}
47
+ .sf-explorer-feature-controls select{width:100%;min-width:0}
48
+ .sf-explorer svg [role=button]:focus-visible{outline:none;stroke:#102f72;stroke-width:3;fill-opacity:.3}
49
+ @media(prefers-reduced-motion:reduce){.sf-explorer button{transition:none}}
50
+ .sf-explorer button:disabled{opacity:.45;cursor:default}
51
+ @container sf-neighborhood-explorer (max-width:650px){
52
+ .sf-explorer-feature-controls{flex-direction:column}
53
+ .sf-explorer-toolbar{padding:8px;gap:6px}
54
+ .sf-explorer-control-group{gap:4px}
55
+ .sf-explorer-pan-controls{flex-basis:100%;margin-left:0}
56
+ .sf-explorer-toolbar button{padding:5px 8px}
57
+ .sf-explorer-legend{gap:8px 16px;padding:12px 14px 8px}
58
+ }`;
59
+ root.dataset.interface = chrome;
60
+ root.style.setProperty('--sf-map-ink', styleOptions.ink ?? '#18364f');
61
+ root.style.setProperty('--sf-map-surface', styleOptions.surface ?? '#fff');
62
+ root.style.setProperty('--sf-map-accent', styleOptions.accent ?? '#163d61');
63
+ root.style.setProperty('--sf-map-border', styleOptions.border ?? '#cedae3');
64
+ root.style.setProperty('--sf-map-focus', styleOptions.focus ?? '#1676b8');
65
+ root.style.setProperty('--sf-map-control-gap', styleOptions.controlGap ?? '6px');
66
+ root.style.setProperty('--sf-map-font', styleOptions.font ?? 'system-ui,sans-serif');
67
+ root.append(style);
68
+ const header = element('header', '', 'sf-explorer-header');
69
+ root.append(header);
70
+ const body = element('div', '', 'sf-explorer-body');
71
+ const panel = element('div', '', 'sf-explorer-panel');
72
+ const modeSelect = element('select');
73
+ const sourceLabel = element('label');
74
+ const sourceSelect = element('select');
75
+ const searchLabel = element('label');
76
+ const search = element('input');
77
+ const count = element('p', '', 'sf-explorer-count');
78
+ const results = element('div', '', 'sf-explorer-results');
79
+ const detail = element('div', '', 'sf-explorer-detail');
80
+ const column = element('div', '', 'sf-explorer-map-column');
81
+ const toolbar = element('div', '', 'sf-explorer-toolbar');
82
+ toolbar.setAttribute('aria-label', 'Map controls');
83
+ const canvas = element('div', '', 'sf-explorer-canvas');
84
+ const hint = element('p', strings.gestureHelp ??
85
+ 'Drag to pan · Ctrl/⌘ + scroll to zoom · Focus map: arrows pan, +/− zoom, Home resets. Touch: scroll the page, or enable Touch navigation to pan and pinch the map. Escape exits.', 'sf-explorer-hint');
86
+ hint.id = `sf-explorer-help-${++explorerCount}`;
87
+ const status = element('p', '', 'sf-explorer-status');
88
+ status.setAttribute('aria-live', 'polite');
89
+ const legend = element('div', '', 'sf-explorer-legend');
90
+ legend.setAttribute('aria-label', 'Map legend');
91
+ for (const [kind, label] of [
92
+ ['bart', 'BART station'],
93
+ ['park', 'Park'],
94
+ ['highway', 'Highway'],
95
+ ['road', 'Roads'],
96
+ ]) {
97
+ if (legendOptions.builtins === false || legendOptions.hidden?.includes(kind))
98
+ continue;
99
+ const entry = element('span', '', 'sf-explorer-legend-item');
100
+ const symbol = element('span', '', `sf-explorer-legend-symbol sf-explorer-legend-${kind}`);
101
+ symbol.setAttribute('aria-hidden', 'true');
102
+ if (colors[kind]) {
103
+ if (kind === 'bart')
104
+ symbol.style.borderColor = colors[kind];
105
+ else
106
+ symbol.style.backgroundColor = colors[kind];
107
+ }
108
+ entry.append(symbol, document.createTextNode(label));
109
+ legend.append(entry);
110
+ }
111
+ for (const item of legendOptions.items ?? []) {
112
+ const entry = element('span', '', 'sf-explorer-legend-item');
113
+ const symbol = element('span', '', 'sf-explorer-legend-symbol');
114
+ symbol.style.backgroundColor = item.color;
115
+ symbol.setAttribute('aria-hidden', 'true');
116
+ entry.append(symbol, document.createTextNode(item.label));
117
+ legend.append(entry);
118
+ }
119
+ const featureControls = element('div', '', 'sf-explorer-feature-controls');
120
+ const neighborhoodLabel = element('label', strings.chooseNeighborhood ?? 'Select neighborhood');
121
+ const neighborhoodSelect = element('select');
122
+ neighborhoodLabel.append(neighborhoodSelect);
123
+ const markerLabel = element('label', strings.chooseMarker ?? 'Choose marker');
124
+ const markerSelect = element('select');
125
+ markerLabel.append(markerSelect);
126
+ featureControls.append(neighborhoodLabel, markerLabel);
127
+ const attribution = element('p', '', 'sf-explorer-hint');
128
+ const attributionDisclosure = element('details', '', 'sf-explorer-hint');
129
+ attributionDisclosure.append(element('summary', 'Map data · DataSF / BART'), attribution);
130
+ column.append(toolbar, featureControls, canvas, legend, hint, status, attributionMode === 'compact' ? attributionDisclosure : attribution);
131
+ body.append(panel, column, detail);
132
+ root.append(body);
133
+ return {
134
+ root,
135
+ header,
136
+ panel,
137
+ modeSelect,
138
+ sourceLabel,
139
+ sourceSelect,
140
+ searchLabel,
141
+ search,
142
+ count,
143
+ results,
144
+ detail,
145
+ toolbar,
146
+ canvas,
147
+ hint,
148
+ status,
149
+ legend,
150
+ featureControls,
151
+ neighborhoodLabel,
152
+ neighborhoodSelect,
153
+ markerLabel,
154
+ markerSelect,
155
+ attribution,
156
+ };
157
+ }
158
+ /** Explorer-only content stays out of map/controller bundles. */
159
+ export function createExplorerShell(options) {
160
+ const shell = createMapShell(options);
161
+ const { root, header, panel, modeSelect, sourceLabel, sourceSelect, searchLabel, search, count, results, } = shell;
162
+ const { source, sources, hasDistricts, strings } = options;
163
+ const style = element('style');
164
+ style.textContent = `.sf-explorer-header{padding:24px;border-top:4px solid var(--sf-map-accent);border-bottom:1px solid var(--sf-map-border)}
165
+ .sf-explorer-header h2{font-size:25px;line-height:1.25;font-weight:700;letter-spacing:-.7px}
166
+ .sf-explorer-header p{color:#586f80;margin-top:8px;max-width:58ch}
167
+ .sf-explorer-body{display:grid;grid-template-columns:280px minmax(0,1fr);grid-template-areas:"panel map" "detail map";grid-template-rows:auto 1fr}
168
+ .sf-explorer-panel{grid-area:panel;padding:18px;display:flex;flex-direction:column;gap:14px;border-right:1px solid #dce5eb;min-width:0}
169
+ .sf-explorer-panel label{display:flex;flex-direction:column;gap:6px;font-size:12px;font-weight:650}
170
+ .sf-explorer-panel input,.sf-explorer-panel select{width:100%;font-weight:400;font-size:14px}
171
+ .sf-explorer-panel input::placeholder{color:#6a7f8e}
172
+ .sf-explorer-results{display:flex;flex-direction:column;gap:5px;max-height:255px;overflow:auto;padding:3px;margin:-3px;overscroll-behavior:contain}
173
+ .sf-explorer-results button{text-align:left;flex-shrink:0}
174
+ .sf-explorer-count,.sf-explorer-note{font-size:12px;color:#586f80}
175
+ .sf-explorer-detail{grid-area:detail;border-top:1px solid #dce5eb;border-right:1px solid #dce5eb;padding:18px;overflow-wrap:anywhere;align-self:stretch;background:#fafcfd}
176
+ .sf-explorer-detail details{font-size:12px;margin-top:12px}
177
+ .sf-explorer-detail summary{cursor:pointer;color:#496578;min-height:32px}
178
+ .sf-explorer-detail details p{margin-top:8px}
179
+ .sf-explorer-detail h3{margin:0 0 7px;font-size:18px;line-height:1.3;letter-spacing:-.2px}
180
+ .sf-explorer-detail p{font-size:12px;margin-bottom:10px}
181
+ .sf-explorer-download{display:block;margin:8px 0;color:#12649c;text-underline-offset:3px}
182
+ @container sf-neighborhood-explorer (max-width:650px){
183
+ .sf-explorer-body{grid-template-columns:1fr;grid-template-areas:"panel" "map" "detail";grid-template-rows:auto auto auto}
184
+ .sf-explorer-header{padding:18px}
185
+ .sf-explorer-header h2{font-size:23px}
186
+ .sf-explorer-header p{font-size:13px}
187
+ .sf-explorer-panel{border-right:0;border-bottom:1px solid #dce5eb;padding:14px;gap:10px}
188
+ .sf-explorer-results{max-height:140px}
189
+ .sf-explorer-detail{padding:14px;border-right:0}
190
+ .sf-explorer-download{display:inline-block;margin:4px 14px 4px 0}
191
+ }`;
192
+ root.append(style);
193
+ header.append(element('h2', strings.title ?? 'Explore San Francisco'), element('p', 'Explore the city, from familiar names to the places in between.'));
194
+ const modeLabel = element('label', strings.mode ?? 'Map mode');
195
+ for (const [value, title] of [
196
+ ['neighborhoods', 'Neighborhoods'],
197
+ ['districts', 'Districts'],
198
+ ['basemap', 'Basemap'],
199
+ ]) {
200
+ if (value === 'districts' && !hasDistricts)
201
+ continue;
202
+ if (value === 'neighborhoods' && !sources.length)
203
+ continue;
204
+ const option = element('option', title);
205
+ option.value = value;
206
+ modeSelect.append(option);
207
+ }
208
+ modeLabel.append(modeSelect);
209
+ sourceLabel.textContent = strings.source ?? 'Neighborhood definitions';
210
+ for (const value of sources) {
211
+ const title = sourceNames[value];
212
+ const option = element('option', title);
213
+ option.value = value;
214
+ sourceSelect.append(option);
215
+ }
216
+ sourceSelect.value = source;
217
+ sourceLabel.append(sourceSelect);
218
+ searchLabel.textContent = strings.search ?? 'Find a neighborhood';
219
+ search.type = 'search';
220
+ search.placeholder = 'Try Mission, Outer Mission, or NoPa';
221
+ search.autocomplete = 'off';
222
+ searchLabel.append(search);
223
+ count.setAttribute('aria-live', 'polite');
224
+ results.setAttribute('aria-label', 'Matching neighborhoods');
225
+ results.setAttribute('role', 'group');
226
+ panel.append(modeLabel, sourceLabel, searchLabel, count, results);
227
+ return shell;
228
+ }
@@ -0,0 +1,20 @@
1
+ import type { NeighborhoodFeature, NeighborhoodSource } from '../data/types.js';
2
+ import type { InteractiveSFMapData } from './explorer-data.js';
3
+ import type { createExplorerShell } from './explorer-shell.js';
4
+ import type { DistrictYear, ExplorerMode } from './types.js';
5
+ interface SidebarState {
6
+ mode: ExplorerMode;
7
+ year: DistrictYear;
8
+ source: NeighborhoodSource;
9
+ selected: NeighborhoodFeature | undefined;
10
+ }
11
+ /** Render sidebar snapshots and own every download URL until replacement or disposal. */
12
+ export declare function createExplorerSidebar({ detail, results, search, count, }: Pick<ReturnType<typeof createExplorerShell>, 'detail' | 'results' | 'search' | 'count'>, data: InteractiveSFMapData, chrome: 'map' | 'explorer', emptyResults: string | undefined, searchNeighborhoods: (query: string, source: NeighborhoodSource) => {
13
+ id: string;
14
+ canonicalName: string;
15
+ }[]): {
16
+ updateDetail: ({ mode, year, source, selected }: SidebarState) => void;
17
+ updateResults: ({ source, selected }: SidebarState) => void;
18
+ destroy: () => void;
19
+ };
20
+ export {};
@@ -0,0 +1,79 @@
1
+ import { element } from './dom.js';
2
+ /** Render sidebar snapshots and own every download URL until replacement or disposal. */
3
+ export function createExplorerSidebar({ detail, results, search, count, }, data, chrome, emptyResults, searchNeighborhoods) {
4
+ const downloads = new Set();
5
+ function releaseDownloads() {
6
+ for (const url of downloads)
7
+ URL.revokeObjectURL(url);
8
+ downloads.clear();
9
+ }
10
+ function downloadLink(text, value, filename) {
11
+ const link = element('a', text, 'sf-explorer-download');
12
+ const url = URL.createObjectURL(new Blob([`${JSON.stringify(value, null, 2)}\n`], { type: 'application/geo+json' }));
13
+ downloads.add(url);
14
+ link.href = url;
15
+ link.download = filename;
16
+ return link;
17
+ }
18
+ function updateDetail({ mode, year, source, selected }) {
19
+ releaseDownloads();
20
+ if (chrome === 'map')
21
+ return;
22
+ detail.replaceChildren();
23
+ if (mode === 'districts') {
24
+ const collection = data.districts?.[year];
25
+ if (!collection) {
26
+ detail.append(element('h3', `${year} supervisorial districts`));
27
+ return;
28
+ }
29
+ detail.append(element('h3', `${year} supervisorial districts`), element('p', 'Eleven districts, identified by number. Switch to Neighborhoods to explore familiar place names.'), downloadLink('Download district GeoJSON', collection, `districts-${year}.geojson`), element('p', collection.definition.description));
30
+ return;
31
+ }
32
+ if (mode === 'basemap') {
33
+ detail.append(element('h3', 'San Francisco basemap'));
34
+ return;
35
+ }
36
+ const collection = data.neighborhoods[source];
37
+ if (!collection)
38
+ return;
39
+ detail.append(element('h3', selected ? selected.properties.canonicalName : 'Explore all neighborhoods'));
40
+ const description = selected
41
+ ? `Source name: ${selected.properties.sourceName}${selected.properties.aliases.length ? `. Also known as: ${selected.properties.aliases.join(', ')}` : ''}.`
42
+ : 'Choose a name or click an area to highlight its full boundary.';
43
+ const sourceSummary = {
44
+ realtor: 'SFAR realtor boundaries · August 2010 · 92 distinct areas.',
45
+ 'sf-find': 'SF Find neighborhoods · 2006 · 117 approximate areas.',
46
+ analysis: 'City analysis neighborhoods · 41 census-tract-based areas.',
47
+ };
48
+ detail.append(element('p', description), element('p', sourceSummary[source]));
49
+ if (selected)
50
+ detail.append(downloadLink('Download neighborhood GeoJSON', { ...collection, selection: selected.id, features: [selected] }, `${source}-${selected.id}.geojson`));
51
+ detail.append(downloadLink(`Download all ${collection.features.length} areas`, collection, `neighborhoods-${source}.geojson`));
52
+ const provenance = element('details');
53
+ provenance.append(element('summary', 'About these boundaries'), element('p', collection.definition.description));
54
+ detail.append(provenance);
55
+ }
56
+ function updateResults({ source, selected }) {
57
+ if (chrome === 'map')
58
+ return;
59
+ const activeElement = document.activeElement;
60
+ const focusedId = activeElement instanceof HTMLElement && results.contains(activeElement)
61
+ ? activeElement.dataset.neighborhoodResult
62
+ : undefined;
63
+ const matches = searchNeighborhoods(search.value, source).sort((a, b) => a.canonicalName.localeCompare(b.canonicalName));
64
+ count.textContent = `${matches.length} ${matches.length === 1 ? 'neighborhood' : 'neighborhoods'}${search.value ? ' found' : ' in this collection'}`;
65
+ results.replaceChildren();
66
+ for (const match of matches) {
67
+ const node = element('button', match.canonicalName);
68
+ node.type = 'button';
69
+ node.dataset.neighborhoodResult = match.id;
70
+ node.setAttribute('aria-pressed', String(selected?.id === match.id));
71
+ results.append(node);
72
+ if (focusedId === match.id)
73
+ node.focus({ preventScroll: true });
74
+ }
75
+ if (!matches.length)
76
+ results.append(element('p', emptyResults ?? 'No matches. Try another name or definition source.', 'sf-explorer-note'));
77
+ }
78
+ return { updateDetail, updateResults, destroy: releaseDownloads };
79
+ }
@@ -1,5 +1,6 @@
1
1
  import { expandMapOptions, prepareConfiguration } from './configuration.js';
2
2
  import { createNeighborhoodExplorerCore } from './explorer-core.js';
3
+ import { createMapShell } from './explorer-shell.js';
3
4
  import { guideMapData } from './guide-data.js';
4
5
  import { createMap } from './map.js';
5
6
  import { guideOptions } from './presets.js';
@@ -69,7 +70,7 @@ export function createGuideMap(options = {}) {
69
70
  ...options.layers,
70
71
  },
71
72
  interface: 'map',
72
- }, guideMapData);
73
+ }, guideMapData, { createShell: createMapShell });
73
74
  }
74
75
  /** Enhance createGuideShell() in place with a fixed compact chrome layout.
75
76
  * Keep an accessible external place list when hiding the native pickers.
@@ -0,0 +1,5 @@
1
+ /** Choose a limited, well-spaced set from labels that already passed collision checks. */
2
+ export declare function spreadLabels<T extends {
3
+ point: [number, number];
4
+ kind: string;
5
+ }>(labels: readonly T[], limit: number | undefined, markers: readonly [number, number][], width: number, height: number): readonly T[];
@@ -0,0 +1,36 @@
1
+ /** Choose a limited, well-spaced set from labels that already passed collision checks. */
2
+ export function spreadLabels(labels, limit, markers, width, height) {
3
+ if (limit === undefined || labels.length <= limit)
4
+ return labels;
5
+ if (limit === 0)
6
+ return [];
7
+ const distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
8
+ const diagonal = Math.hypot(width, height);
9
+ const remaining = [...labels];
10
+ const selected = [];
11
+ const priority = remaining.findIndex((label) => label.kind === 'selected-marker');
12
+ if (priority >= 0)
13
+ selected.push(...remaining.splice(priority, 1));
14
+ while (selected.length < limit && remaining.length) {
15
+ let best = 0;
16
+ let bestScore = -Infinity;
17
+ remaining.forEach((label, index) => {
18
+ const spacing = selected.length
19
+ ? Math.min(...selected.map((other) => distance(label.point, other.point))) / diagonal
20
+ : 0;
21
+ const proximity = markers.length
22
+ ? 1 -
23
+ markers.reduce((nearest, marker) => Math.min(nearest, distance(label.point, marker)), diagonal) /
24
+ diagonal
25
+ : 0;
26
+ // Prefer coverage, with nearby pins adding context. Original priority breaks ties.
27
+ const score = spacing * 0.75 + proximity * 0.25;
28
+ if (score > bestScore) {
29
+ bestScore = score;
30
+ best = index;
31
+ }
32
+ });
33
+ selected.push(...remaining.splice(best, 1));
34
+ }
35
+ return selected;
36
+ }
package/dist/src/map.js CHANGED
@@ -2,6 +2,7 @@ import { copyAppearance } from './appearance.js';
2
2
  import { validateCameraOptions } from './camera.js';
3
3
  import { expandMapOptions, prepareConfiguration } from './configuration.js';
4
4
  import { createNeighborhoodExplorerCore } from './explorer-core.js';
5
+ import { createMapShell } from './explorer-shell.js';
5
6
  import { controlKeys, layerKeys } from './features.js';
6
7
  import { assertOptions } from './validation.js';
7
8
  /** No geography is imported. Supply a preset or your own immutable data. */
@@ -18,7 +19,7 @@ export function createMap(data, options = {}) {
18
19
  ...expanded,
19
20
  interface: 'map',
20
21
  onOverlayActivate: () => { },
21
- }, data);
22
+ }, data, { createShell: createMapShell });
22
23
  const subscriptions = new Set();
23
24
  let configurationRevision = 0;
24
25
  const sourceList = ['realtor', 'sf-find', 'analysis'].filter((source) => !!data.neighborhoods[source]);
@@ -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 {};