@kahwee/sf-map-svg 2.1.0 → 2.2.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 +8 -0
- package/README.md +57 -1
- package/dist/src/configuration.js +1 -0
- package/dist/src/controller-types.d.ts +23 -2
- package/dist/src/explorer-core.js +230 -4
- package/dist/src/layers.d.ts +2 -0
- package/dist/src/layers.js +2 -2
- package/dist/src/map-core.d.ts +33 -0
- package/dist/src/map-core.js +90 -28
- package/dist/src/map.d.ts +1 -1
- package/dist/src/map.js +8 -0
- package/dist/src/static.d.ts +35 -2
- package/dist/src/static.js +5 -1
- package/dist/src/types.d.ts +19 -0
- package/dist/src/validation.js +3 -0
- package/docs/EXAMPLES.md +8 -1
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 2.2.0 — 2026-09-27
|
|
8
|
+
|
|
9
|
+
- Add “One spot, three San Franciscos” to Pages and Storybook, comparing the same location across district, neighborhood, and transit views.
|
|
10
|
+
- Modernize the Storybook examples in TypeScript and gate map changes with reviewed desktop and 390 px visual snapshots on macOS and Linux.
|
|
11
|
+
- Add first-class election choropleths: static and interactive `districtStyle`, live district year/style setters, district selection and activation events, keyboard interaction, and a reduced-motion-aware year crossfade.
|
|
12
|
+
- Export `getLayerPaths` for canonical fitted layer geometry and `DistrictYear`, `DistrictRowData`, and `DistrictStyle` from the modern entrypoints.
|
|
13
|
+
- Add an official-election Storybook example covering three boundary years, vote-share coloring, mobile layout, keyboard selection, year changes, and accessibility checks.
|
|
14
|
+
|
|
7
15
|
## 2.1.0 — 2026-09-27
|
|
8
16
|
|
|
9
17
|
- Pages site: add motion and a dark theme. Cross-document View Transitions keep the header still and morph example titles into page titles; the home hero is an interactive dot map of the 2022 districts; entrances, count-ups, growing charts, and hover/focus micro-interactions respect reduced motion and print. The site adds no library.
|
package/README.md
CHANGED
|
@@ -70,6 +70,9 @@ function and disposal removes all its subscriptions. Operations after disposal t
|
|
|
70
70
|
`renderMap(data, options)` from `/static` returns `{ svg, project, ... }` and needs only
|
|
71
71
|
`StaticMapData` (for example, `guideMapData.map`). Static options retain their SVG-unit
|
|
72
72
|
semantics; camera/motion/control options belong exclusively to the browser controller.
|
|
73
|
+
`getLayerPaths(data, options)` returns the same fitted projection and canonical coast,
|
|
74
|
+
district, neighborhood, highway, landmark, road, and station paths without making SVG markup.
|
|
75
|
+
`DistrictYear`, `DistrictRowData`, and `DistrictStyle` are exported types from the root and `/static`.
|
|
73
76
|
See [v2 migration and architecture](docs/migration-v2.md) for breaking changes,
|
|
74
77
|
configuration resets, and bundle boundaries. Existing subpaths remain compatibility APIs.
|
|
75
78
|
|
|
@@ -179,6 +182,7 @@ Embed the returned SVG markup directly in a page; in Astro, use `<div set:html={
|
|
|
179
182
|
| `neighborhoodLines` | `false` | Dashed SFAR realtor neighborhood outlines |
|
|
180
183
|
| `theme` | `'districts'` | Use `'transit'` for pale blue water, ivory land, green parks, and blue BART symbols; custom `colors` still take precedence |
|
|
181
184
|
| `districtFills` | `true` | Original Site’s eleven muted district colors |
|
|
185
|
+
| `districtStyle` | — | Per-district callback returning optional `fill`, `stroke`, and `opacity` (0–1); applies to both static SVG and the interactive map |
|
|
182
186
|
| `labels` | `true` | Master switch for visible map text; symbols and accessible titles remain |
|
|
183
187
|
| `districtLabels` | `true` | District number badges |
|
|
184
188
|
| `highways` | `false` | Original Site’s highway geometry |
|
|
@@ -197,6 +201,54 @@ For a plain outline map, set `districtFills: false`. Neighborhood areas are **Au
|
|
|
197
201
|
|
|
198
202
|
`createSFMap(options)` returns `{ svg, project, viewBox }`. `project([longitude, latitude])` gives matching SVG coordinates for custom overlays. Named exports also include `districtYears`, `districtColors`, and `neighborhoodNames`.
|
|
199
203
|
|
|
204
|
+
### Election district API
|
|
205
|
+
|
|
206
|
+
Supply canonical district rows for each boundary year and return a style for each district:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
import { createMap, getLayerPaths, renderMap, type StaticMapData } from '@kahwee/sf-map-svg';
|
|
210
|
+
import { districtMaps } from '@kahwee/sf-map-svg/data/districts';
|
|
211
|
+
import coast from '@kahwee/sf-map-svg/data/coast.json' with { type: 'json' };
|
|
212
|
+
|
|
213
|
+
const districts = Object.fromEntries(
|
|
214
|
+
Object.entries(districtMaps).map(([year, collection]) => [year, collection.features.map(({ geometry, properties }) => ({
|
|
215
|
+
id: properties.district,
|
|
216
|
+
label: properties.label,
|
|
217
|
+
labelPoints: properties.labelPoints,
|
|
218
|
+
geometry,
|
|
219
|
+
extras: properties.displayExtras,
|
|
220
|
+
}))]),
|
|
221
|
+
) as StaticMapData['districts'];
|
|
222
|
+
const data: StaticMapData = { coast: coast.features[0].geometry as StaticMapData['coast'], districts };
|
|
223
|
+
const shares = new Map([[1, 0.62], [2, 0.48]]); // Replace with your vote data.
|
|
224
|
+
const districtStyle = (district: { id: number }) => ({
|
|
225
|
+
fill: (shares.get(district.id) ?? 0) >= 0.5 ? '#498c79' : '#cfdfd6',
|
|
226
|
+
stroke: '#49665f',
|
|
227
|
+
});
|
|
228
|
+
const { svg } = renderMap(data, { year: 2022, districtStyle });
|
|
229
|
+
const paths = getLayerPaths(data, { year: 2022 }); // paths.districts[0].geometry / .extras / .path
|
|
230
|
+
|
|
231
|
+
const map = createMap({ map: data, districts: districtMaps, neighborhoods: {} }, {
|
|
232
|
+
mode: 'districts', year: 2022, appearance: { districtStyle },
|
|
233
|
+
});
|
|
234
|
+
document.querySelector('#map')?.append(map.element);
|
|
235
|
+
map.on('districtchange', ({ id, year }) => console.log(id, year));
|
|
236
|
+
map.on('districtactivate', ({ id }) => console.log('activated', id));
|
|
237
|
+
map.on('districthover', ({ id }) => console.log('hovered', id));
|
|
238
|
+
map.setDistrictYear(2012, { animate: true }); // Crossfades; reduced motion disables the fade.
|
|
239
|
+
map.selectDistrict(1, { fit: true });
|
|
240
|
+
map.setDistrictStyle(districtStyle); // Recompute colors after your vote data changes.
|
|
241
|
+
// On component disposal: map.destroy();
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
District paths are keyboard buttons: Tab enters the district layer, brackets move among districts,
|
|
245
|
+
and Enter or Space selects and activates one. `getSelectedDistrict()` returns a detached
|
|
246
|
+
`{ id, year, district }` snapshot. `setDistrictYear()` preserves the camera and an existing
|
|
247
|
+
district selection, changes labels and paths in place, and emits `districtyearchange`.
|
|
248
|
+
It requires both district rows and label features for the requested year; unavailable years
|
|
249
|
+
throw without changing the map. The `animate` option crossfades boundary sets rather than
|
|
250
|
+
morphing polygons with different topology.
|
|
251
|
+
|
|
200
252
|
Use `theme: 'transit'` for pale water, ivory land, green parks, and blue BART symbols. Custom `colors` override the preset. Park and station overlays represent current source geography, independently of the district year; stations outside San Francisco, including Daly City, are excluded.
|
|
201
253
|
|
|
202
254
|
`keyRoads: true` adds the six curated orientation streets using DataSF centerlines. These are orientation features, not routing guidance. The lightweight guide shows primary corridors at city scale and Lombard after zooming.
|
|
@@ -424,13 +476,15 @@ pnpm test:package # install and check the packed package
|
|
|
424
476
|
|
|
425
477
|
Use `pnpm format` to apply Biome formatting and safe lint fixes. Install Chromium once with `pnpm exec playwright install chromium` before local Storybook tests. Run `pnpm storybook` for interactive component examples at http://127.0.0.1:6006. The coverage command writes `coverage/storybook/coverage-summary.json` and `lcov.info` for `src/` TypeScript only. CI runs package and Chromium Storybook checks on Node 26 and uploads the coverage report. See [CONTRIBUTING.md](CONTRIBUTING.md) for source structure and release instructions.
|
|
426
478
|
|
|
479
|
+
In Storybook, start with **Start here / V2 interactive map** and **Start here / V2 static SVG** for copyable public imports and live layer controls. The phone stories use a 390 px Storybook viewport; the toolbar also offers a 1280 px desktop viewport. **Maps** shows the guide and transit components, **Data** compares source definitions, **Legacy** documents compatibility entrypoints, and **Checks** contains deeper controller regressions. Accessibility violations fail Storybook browser tests by default.
|
|
480
|
+
|
|
427
481
|
### Browser verification
|
|
428
482
|
|
|
429
483
|
|
|
430
484
|
Run `pnpm demo` and serve the repository root. `examples/generated/index.html` covers static
|
|
431
485
|
maps, `explorer.html` covers the full explorer, and `interactive.html` covers independently
|
|
432
486
|
controlled neighborhood selection, 36 overlapping sample markers, and fit/save/restore hooks.
|
|
433
|
-
Storybook **
|
|
487
|
+
Storybook **Legacy / Interactive map** includes both themes, selectable neighborhoods,
|
|
434
488
|
independent layers, dense markers, and a 390px example.
|
|
435
489
|
|
|
436
490
|
With that server running, the browser regression checks can be run through the installed CLI:
|
|
@@ -451,6 +505,8 @@ synthetic pointer tests do not establish physical-device compatibility.
|
|
|
451
505
|
|
|
452
506
|
## GitHub Pages
|
|
453
507
|
|
|
508
|
+
The [one spot, three San Franciscos map](https://kahwee.github.io/sf-map-svg/spot.html) lets visitors pick a point and compare its SFAR, SF Find, and analysis neighborhood definitions, then inspect its supervisorial district on the 2002, 2012, and 2022 display maps. The same comparison appears in Storybook under **Data / One spot, three San Franciscos**. A source outline is drawn from that source's polygon; clicking the map runs point-in-polygon lookup against the canonical geographic collections. The page makes no address or legal-boundary claim.
|
|
509
|
+
|
|
454
510
|
The [civic atlas](https://kahwee.github.io/sf-map-svg/) leads with certified June 2026 ballot measure results. Visitors can select a measure and district, switch Yes/No shading, compare the official 2002, 2012, and 2022 district maps, and play a schematic BART journey. The boundary animation morphs matched district outlines between dated SVGs. Intermediate shapes illustrate the change; each completed year uses its exact published geometry. Playback is user initiated, pauses when the page is hidden, and switches instantly when reduced motion is requested. The lightweight neighborhood guide loads on demand. The full [ballot measures explorer](https://kahwee.github.io/sf-map-svg/measures.html) spans all three district map years with four separately sourced election snapshots.
|
|
455
511
|
|
|
456
512
|
The [candidate vote explorer](https://kahwee.github.io/sf-map-svg/candidates.html) maps
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Geometry, NeighborhoodSource } from '../data/types.js';
|
|
2
|
-
import type { CameraOptions, InteractiveLayers, MapFeatures, MapMarker, MapOverlay, MapPadding, MapViewport, NeighborhoodExplorerOptions, NeighborhoodSelection } from './types.js';
|
|
3
|
-
export type MapAppearance = Pick<NeighborhoodExplorerOptions, 'theme' | 'colors' | 'labelStyle' | 'areaStyle' | 'labelSize' | 'style' | 'markerRadius' | 'markerHitSize' | 'markerColor' | 'selectedMarkerColor'>;
|
|
2
|
+
import type { CameraOptions, DistrictSelection, DistrictStyle, DistrictYear, InteractiveLayers, MapFeatures, MapMarker, MapOverlay, MapPadding, MapViewport, NeighborhoodExplorerOptions, NeighborhoodSelection } from './types.js';
|
|
3
|
+
export type MapAppearance = Pick<NeighborhoodExplorerOptions, 'theme' | 'colors' | 'labelStyle' | 'areaStyle' | 'districtStyle' | 'labelSize' | 'style' | 'markerRadius' | 'markerHitSize' | 'markerColor' | 'selectedMarkerColor'>;
|
|
4
4
|
export type MapControls = NonNullable<NeighborhoodExplorerOptions['controls']>;
|
|
5
5
|
/** Omitted groups/keys retain their values; undefined groups reset all keys in that group. */
|
|
6
6
|
export interface MapConfiguration {
|
|
@@ -19,6 +19,21 @@ export interface MapConfigurationSnapshot {
|
|
|
19
19
|
controls: MapControls;
|
|
20
20
|
}
|
|
21
21
|
export interface MapEvents {
|
|
22
|
+
districtchange: DistrictSelection | {
|
|
23
|
+
id: null;
|
|
24
|
+
year: DistrictYear;
|
|
25
|
+
district: null;
|
|
26
|
+
};
|
|
27
|
+
districthover: DistrictSelection | {
|
|
28
|
+
id: null;
|
|
29
|
+
year: DistrictYear;
|
|
30
|
+
district: null;
|
|
31
|
+
};
|
|
32
|
+
districtactivate: DistrictSelection;
|
|
33
|
+
districtyearchange: {
|
|
34
|
+
year: DistrictYear;
|
|
35
|
+
previousYear: DistrictYear;
|
|
36
|
+
};
|
|
22
37
|
markerchange: {
|
|
23
38
|
id: string | null;
|
|
24
39
|
marker: MapMarker | null;
|
|
@@ -70,6 +85,12 @@ export interface MapController {
|
|
|
70
85
|
fit?: boolean;
|
|
71
86
|
}): boolean;
|
|
72
87
|
getSelectedNeighborhood(): NeighborhoodSelection | null;
|
|
88
|
+
selectDistrict(id: number | null, options?: CameraOptions & {
|
|
89
|
+
fit?: boolean;
|
|
90
|
+
}): boolean;
|
|
91
|
+
getSelectedDistrict(): DistrictSelection | null;
|
|
92
|
+
setDistrictYear(year: DistrictYear, options?: CameraOptions): void;
|
|
93
|
+
setDistrictStyle(style: ((district: DistrictSelection['district']) => DistrictStyle) | undefined): void;
|
|
73
94
|
setSource(source: NeighborhoodSource): void;
|
|
74
95
|
setMode(mode: NonNullable<MapOptions['mode']>): void;
|
|
75
96
|
setLabels(visible: boolean): void;
|
|
@@ -3,7 +3,7 @@ import { clusterPoints } from './clusters.js';
|
|
|
3
3
|
import { fitBounds, interiorAnchor, layoutLabels, projectedBounds } from './explorer-layout.js';
|
|
4
4
|
import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from './features.js';
|
|
5
5
|
import { geometryPath } from './geometry.js';
|
|
6
|
-
import { createSFMapWithData, districtColors } from './map-core.js';
|
|
6
|
+
import { createSFMapWithData, districtColors, getLayerPathsWithData } from './map-core.js';
|
|
7
7
|
import { attachNavigation } from './navigation.js';
|
|
8
8
|
import { validateExplorerOptions, validateMarkers, validateOverlays } from './validation.js';
|
|
9
9
|
import { fitViewport, validateViewport } from './viewport.js';
|
|
@@ -38,7 +38,7 @@ let explorerCount = 0;
|
|
|
38
38
|
/** Create an offline, browser-only neighborhood explorer. Call destroy() before disposal. */
|
|
39
39
|
export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
40
40
|
validateExplorerOptions(options);
|
|
41
|
-
let { source = 'realtor', mode = 'neighborhoods', labels = true, neighborhood, year = 2022, theme = 'transit', colors = {}, labelStyle = {}, areaStyle = {}, motion = false, markerEntrance = false, selectedMarkerRing, clustering = false, legend: legendOptions = {}, attribution: attributionMode = 'full', northArrow = false, scaleBar = false, interface: chrome = 'explorer', layers = {}, selectableNeighborhoods = true, labelSize = {}, fitPadding = 24, markers: initialMarkers = [], markerRadius = 6, markerHitSize = 44, markerColor = colors.marker ?? '#245b61', selectedMarkerColor = colors.selected ?? '#f04f32', onMarkerActivate, overlays: initialOverlays = [], onOverlayActivate, style: styleOptions = {}, strings = {}, controls = {}, } = options;
|
|
41
|
+
let { source = 'realtor', mode = 'neighborhoods', labels = true, neighborhood, year = 2022, theme = 'transit', colors = {}, labelStyle = {}, areaStyle = {}, districtStyle: initialDistrictStyle, motion = false, markerEntrance = false, selectedMarkerRing, clustering = false, legend: legendOptions = {}, attribution: attributionMode = 'full', northArrow = false, scaleBar = false, interface: chrome = 'explorer', layers = {}, selectableNeighborhoods = true, labelSize = {}, fitPadding = 24, markers: initialMarkers = [], markerRadius = 6, markerHitSize = 44, markerColor = colors.marker ?? '#245b61', selectedMarkerColor = colors.selected ?? '#f04f32', onMarkerActivate, overlays: initialOverlays = [], onOverlayActivate, style: styleOptions = {}, strings = {}, controls = {}, } = options;
|
|
42
42
|
if (!['neighborhoods', 'districts', 'basemap'].includes(mode))
|
|
43
43
|
throw new RangeError(`Unknown map mode: ${mode}`);
|
|
44
44
|
if (chrome !== 'explorer' && chrome !== 'map')
|
|
@@ -314,6 +314,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
314
314
|
districtLabels: false,
|
|
315
315
|
districtLines: true,
|
|
316
316
|
districtFills: true,
|
|
317
|
+
districtStyle: initialDistrictStyle,
|
|
317
318
|
highways: true,
|
|
318
319
|
keyRoads: true,
|
|
319
320
|
roadLabels: false,
|
|
@@ -365,7 +366,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
365
366
|
if (color)
|
|
366
367
|
path.setAttribute('fill', color);
|
|
367
368
|
}
|
|
368
|
-
|
|
369
|
+
let districtItems = (data.districts?.[year]?.features ?? []).flatMap((feature) => feature.properties.labelPoints.map((point) => ({
|
|
369
370
|
point: map.project(point),
|
|
370
371
|
name: String(feature.properties.district),
|
|
371
372
|
kind: 'district',
|
|
@@ -382,6 +383,171 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
382
383
|
geography.append(areas, stations, labelLayer, overlayLayer, markerLayer);
|
|
383
384
|
let view = [0, 0, 800];
|
|
384
385
|
let selected;
|
|
386
|
+
let selectedDistrict = null;
|
|
387
|
+
let hoveredDistrict = null;
|
|
388
|
+
let districtStyle = initialDistrictStyle;
|
|
389
|
+
let districtTransition;
|
|
390
|
+
root.dataset.year = String(year);
|
|
391
|
+
function districtRow(id) {
|
|
392
|
+
return data.map.districts?.[year]?.find((district) => district.id === id);
|
|
393
|
+
}
|
|
394
|
+
function districtSelection(id) {
|
|
395
|
+
const district = id === null ? undefined : districtRow(id);
|
|
396
|
+
return district
|
|
397
|
+
? { id: district.id, year, district: structuredClone(district) }
|
|
398
|
+
: { id: null, year, district: null };
|
|
399
|
+
}
|
|
400
|
+
function getSelectedDistrict() {
|
|
401
|
+
return selectedDistrict === null
|
|
402
|
+
? null
|
|
403
|
+
: districtSelection(selectedDistrict);
|
|
404
|
+
}
|
|
405
|
+
function updateDistrictAppearance() {
|
|
406
|
+
const rows = data.map.districts?.[year] ?? [];
|
|
407
|
+
for (const row of rows) {
|
|
408
|
+
const styled = districtStyle?.(row);
|
|
409
|
+
const active = selectedDistrict === row.id;
|
|
410
|
+
const hovered = hoveredDistrict === row.id;
|
|
411
|
+
const fill = svg.querySelector(`[data-layer="district-fills"] [data-district="${row.id}"]`);
|
|
412
|
+
const line = svg.querySelector(`[data-layer="district-lines"] [data-district="${row.id}"]`);
|
|
413
|
+
if (fill) {
|
|
414
|
+
fill.setAttribute('fill', styled?.fill ??
|
|
415
|
+
(theme === 'transit' ? (colors.land ?? '#fcfcf8') : districtColors[row.id - 1]));
|
|
416
|
+
if (styled?.opacity === undefined)
|
|
417
|
+
fill.removeAttribute('fill-opacity');
|
|
418
|
+
else
|
|
419
|
+
fill.setAttribute('fill-opacity', String(styled.opacity));
|
|
420
|
+
fill.setAttribute('role', 'button');
|
|
421
|
+
fill.setAttribute('aria-label', `District ${row.id}, ${year}`);
|
|
422
|
+
fill.setAttribute('aria-pressed', String(active));
|
|
423
|
+
fill.setAttribute('tabindex', row.id === 1 ? '0' : '-1');
|
|
424
|
+
fill.style.cursor = 'pointer';
|
|
425
|
+
}
|
|
426
|
+
if (line) {
|
|
427
|
+
line.setAttribute('stroke', active
|
|
428
|
+
? (colors.selected ?? '#f04f32')
|
|
429
|
+
: hovered
|
|
430
|
+
? '#163e56'
|
|
431
|
+
: (styled?.stroke ?? colors.district ?? '#a7b8c0'));
|
|
432
|
+
line.setAttribute('stroke-width', active ? '3' : hovered ? '2.2' : '1.1');
|
|
433
|
+
const lineInteractive = !enabled('districtFills') && enabled('districtLines');
|
|
434
|
+
line.style.pointerEvents = lineInteractive ? 'stroke' : 'none';
|
|
435
|
+
if (lineInteractive) {
|
|
436
|
+
line.setAttribute('role', 'button');
|
|
437
|
+
line.setAttribute('aria-label', `District ${row.id}, ${year}`);
|
|
438
|
+
line.setAttribute('aria-pressed', String(active));
|
|
439
|
+
line.setAttribute('tabindex', row.id === 1 ? '0' : '-1');
|
|
440
|
+
line.style.cursor = 'pointer';
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
line.removeAttribute('role');
|
|
444
|
+
line.removeAttribute('aria-label');
|
|
445
|
+
line.removeAttribute('aria-pressed');
|
|
446
|
+
line.removeAttribute('tabindex');
|
|
447
|
+
}
|
|
448
|
+
if (styled?.opacity === undefined)
|
|
449
|
+
line.removeAttribute('stroke-opacity');
|
|
450
|
+
else
|
|
451
|
+
line.setAttribute('stroke-opacity', String(styled.opacity));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function selectDistrict(id, options = {}) {
|
|
456
|
+
if (destroyed)
|
|
457
|
+
return false;
|
|
458
|
+
validateCameraOptions(options);
|
|
459
|
+
if (id !== null && (!Number.isInteger(id) || !districtRow(id)))
|
|
460
|
+
return false;
|
|
461
|
+
const changed = selectedDistrict !== id;
|
|
462
|
+
selectedDistrict = id;
|
|
463
|
+
if (id === null)
|
|
464
|
+
delete root.dataset.selectedDistrict;
|
|
465
|
+
else
|
|
466
|
+
root.dataset.selectedDistrict = String(id);
|
|
467
|
+
updateDistrictAppearance();
|
|
468
|
+
const selectedRow = id === null ? undefined : districtRow(id);
|
|
469
|
+
if (selectedRow && options.fit)
|
|
470
|
+
fitGeometry(selectedRow.geometry, undefined, options);
|
|
471
|
+
status.textContent =
|
|
472
|
+
id === null ? 'District selection cleared.' : `District ${id} selected, ${year} boundaries.`;
|
|
473
|
+
if (changed)
|
|
474
|
+
root.dispatchEvent(new CustomEvent('districtchange', { bubbles: true, detail: districtSelection(id) }));
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
function setDistrictStyle(style) {
|
|
478
|
+
if (destroyed)
|
|
479
|
+
return;
|
|
480
|
+
if (style !== undefined && typeof style !== 'function')
|
|
481
|
+
throw new TypeError('districtStyle must be a function.');
|
|
482
|
+
createSFMapWithData({ year, districtStyle: style, districtLabels: false }, data.map);
|
|
483
|
+
districtStyle = style;
|
|
484
|
+
updateDistrictAppearance();
|
|
485
|
+
}
|
|
486
|
+
function setDistrictYear(next, options = {}) {
|
|
487
|
+
if (destroyed)
|
|
488
|
+
return;
|
|
489
|
+
validateCameraOptions(options);
|
|
490
|
+
if (![2002, 2012, 2022].includes(next))
|
|
491
|
+
throw new RangeError('District year must be 2002, 2012, or 2022.');
|
|
492
|
+
if (!data.map.districts?.[next] || !data.districts?.[next])
|
|
493
|
+
throw new RangeError(`No ${next} district dataset was supplied.`);
|
|
494
|
+
if (next === year)
|
|
495
|
+
return;
|
|
496
|
+
const geometry = getLayerPathsWithData({ year: next }, data.map);
|
|
497
|
+
createSFMapWithData({ year: next, districtStyle, districtLabels: false }, data.map);
|
|
498
|
+
const previousYear = year;
|
|
499
|
+
districtTransition?.cancel();
|
|
500
|
+
for (const layer of districtLayers) {
|
|
501
|
+
const old = options.animate && !reducedMotion.matches ? layer.cloneNode(true) : null;
|
|
502
|
+
if (old) {
|
|
503
|
+
old.setAttribute('aria-hidden', 'true');
|
|
504
|
+
old.style.pointerEvents = 'none';
|
|
505
|
+
for (const path of old.querySelectorAll('[data-district]')) {
|
|
506
|
+
path.removeAttribute('tabindex');
|
|
507
|
+
path.removeAttribute('role');
|
|
508
|
+
path.removeAttribute('aria-label');
|
|
509
|
+
path.removeAttribute('aria-pressed');
|
|
510
|
+
}
|
|
511
|
+
layer.after(old);
|
|
512
|
+
districtTransition = old.animate([{ opacity: 1 }, { opacity: 0 }], {
|
|
513
|
+
duration: options.duration ?? 280,
|
|
514
|
+
easing: 'ease-out',
|
|
515
|
+
});
|
|
516
|
+
districtTransition.finished.then(() => old.remove()).catch(() => old.remove());
|
|
517
|
+
}
|
|
518
|
+
layer.replaceChildren(...geometry.districts.map((district) => svgElement('path', {
|
|
519
|
+
'data-district': district.id,
|
|
520
|
+
d: district.path,
|
|
521
|
+
...(layer.dataset.layer === 'district-fills'
|
|
522
|
+
? { 'fill-rule': 'evenodd' }
|
|
523
|
+
: { fill: 'none', 'stroke-width': 1.1 }),
|
|
524
|
+
})));
|
|
525
|
+
}
|
|
526
|
+
year = next;
|
|
527
|
+
hoveredDistrict = null;
|
|
528
|
+
root.dataset.year = String(year);
|
|
529
|
+
svg.dataset.year = String(year);
|
|
530
|
+
districtItems = (data.districts[next]?.features ?? []).flatMap((feature) => feature.properties.labelPoints.map((point) => ({
|
|
531
|
+
point: map.project(point),
|
|
532
|
+
name: String(feature.properties.district),
|
|
533
|
+
kind: 'district',
|
|
534
|
+
})));
|
|
535
|
+
if (selectedDistrict !== null && !districtRow(selectedDistrict))
|
|
536
|
+
selectedDistrict = null;
|
|
537
|
+
if (selectedDistrict === null)
|
|
538
|
+
delete root.dataset.selectedDistrict;
|
|
539
|
+
updateDistrictAppearance();
|
|
540
|
+
updateComposition();
|
|
541
|
+
scheduleLabels();
|
|
542
|
+
status.textContent = `${year} supervisorial districts.`;
|
|
543
|
+
root.dispatchEvent(new CustomEvent('districtyearchange', { bubbles: true, detail: { year, previousYear } }));
|
|
544
|
+
if (selectedDistrict !== null)
|
|
545
|
+
root.dispatchEvent(new CustomEvent('districtchange', {
|
|
546
|
+
bubbles: true,
|
|
547
|
+
detail: districtSelection(selectedDistrict),
|
|
548
|
+
}));
|
|
549
|
+
}
|
|
550
|
+
updateDistrictAppearance();
|
|
385
551
|
let items = [];
|
|
386
552
|
let destroyed = false;
|
|
387
553
|
let initialized = false;
|
|
@@ -1128,6 +1294,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1128
1294
|
const key = layer.dataset.layer === 'district-fills' ? 'districtFills' : 'districtLines';
|
|
1129
1295
|
layer.style.display = enabled(key) ? '' : 'none';
|
|
1130
1296
|
}
|
|
1297
|
+
updateDistrictAppearance();
|
|
1131
1298
|
const districtsVisible = enabled('districtFills') || enabled('districtLines') || (labels && enabled('districtLabels'));
|
|
1132
1299
|
const descriptions = ['San Francisco map.'];
|
|
1133
1300
|
if (districtsVisible)
|
|
@@ -1151,7 +1318,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1151
1318
|
const title = svg.querySelector('title');
|
|
1152
1319
|
if (title)
|
|
1153
1320
|
title.textContent = 'San Francisco map';
|
|
1154
|
-
svg.setAttribute('aria-label', `${description} Arrow keys pan; plus and minus zoom; Home resets.
|
|
1321
|
+
svg.setAttribute('aria-label', `${description} Arrow keys pan; plus and minus zoom; Home resets. Districts and neighborhoods: brackets move focus; Enter selects. The neighborhood and marker menus include every supplied item.`);
|
|
1155
1322
|
attribution.textContent = description;
|
|
1156
1323
|
scheduleLabels();
|
|
1157
1324
|
}
|
|
@@ -1409,6 +1576,31 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1409
1576
|
listen(markerSelect, 'change', () => selectMarker(markerSelect.value || null));
|
|
1410
1577
|
listen(svg, 'keydown', (event) => {
|
|
1411
1578
|
const target = event.target instanceof SVGElement ? event.target : null;
|
|
1579
|
+
const districtId = Number(target?.dataset.district);
|
|
1580
|
+
if (target?.closest('[data-layer="district-fills"], [data-layer="district-lines"]') &&
|
|
1581
|
+
districtRow(districtId)) {
|
|
1582
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
1583
|
+
event.preventDefault();
|
|
1584
|
+
selectDistrict(districtId);
|
|
1585
|
+
root.dispatchEvent(new CustomEvent('districtactivate', {
|
|
1586
|
+
bubbles: true,
|
|
1587
|
+
detail: districtSelection(districtId),
|
|
1588
|
+
}));
|
|
1589
|
+
target.focus();
|
|
1590
|
+
}
|
|
1591
|
+
else if (event.key === '[' || event.key === ']') {
|
|
1592
|
+
event.preventDefault();
|
|
1593
|
+
const rows = data.map.districts?.[year] ?? [];
|
|
1594
|
+
const index = rows.findIndex((row) => row.id === districtId);
|
|
1595
|
+
const next = rows[(index + (event.key === ']' ? 1 : rows.length - 1)) % rows.length];
|
|
1596
|
+
const activeLayer = enabled('districtFills') ? 'district-fills' : 'district-lines';
|
|
1597
|
+
const nextNode = svg.querySelector(`[data-layer="${activeLayer}"] [data-district="${next.id}"]`);
|
|
1598
|
+
target.setAttribute('tabindex', '-1');
|
|
1599
|
+
nextNode?.setAttribute('tabindex', '0');
|
|
1600
|
+
nextNode?.focus();
|
|
1601
|
+
}
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1412
1604
|
const id = target?.dataset.neighborhoodId;
|
|
1413
1605
|
if (!id || !selectableNeighborhoods)
|
|
1414
1606
|
return;
|
|
@@ -1441,12 +1633,42 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1441
1633
|
}
|
|
1442
1634
|
});
|
|
1443
1635
|
listen(svg, 'click', (event) => {
|
|
1636
|
+
const district = event.target instanceof Element
|
|
1637
|
+
? event.target.closest('[data-layer="district-fills"] [data-district], [data-layer="district-lines"] [data-district]')
|
|
1638
|
+
: null;
|
|
1639
|
+
if (district?.dataset.district) {
|
|
1640
|
+
const id = Number(district.dataset.district);
|
|
1641
|
+
selectDistrict(id);
|
|
1642
|
+
root.dispatchEvent(new CustomEvent('districtactivate', { bubbles: true, detail: districtSelection(id) }));
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1444
1645
|
const node = event.target instanceof Element
|
|
1445
1646
|
? event.target.closest('[data-neighborhood-id]')
|
|
1446
1647
|
: null;
|
|
1447
1648
|
if (selectableNeighborhoods && node?.dataset.neighborhoodId)
|
|
1448
1649
|
selectNeighborhood(node.dataset.neighborhoodId);
|
|
1449
1650
|
});
|
|
1651
|
+
listen(svg, 'pointerover', (event) => {
|
|
1652
|
+
const district = event.target instanceof Element
|
|
1653
|
+
? event.target.closest('[data-layer="district-fills"] [data-district], [data-layer="district-lines"] [data-district]')
|
|
1654
|
+
: null;
|
|
1655
|
+
const id = district?.dataset.district ? Number(district.dataset.district) : null;
|
|
1656
|
+
if (id === hoveredDistrict)
|
|
1657
|
+
return;
|
|
1658
|
+
hoveredDistrict = id;
|
|
1659
|
+
updateDistrictAppearance();
|
|
1660
|
+
root.dispatchEvent(new CustomEvent('districthover', { bubbles: true, detail: districtSelection(id) }));
|
|
1661
|
+
});
|
|
1662
|
+
listen(svg, 'pointerout', (event) => {
|
|
1663
|
+
if (event.relatedTarget instanceof Element &&
|
|
1664
|
+
event.relatedTarget.closest('[data-layer="district-fills"] [data-district], [data-layer="district-lines"] [data-district]'))
|
|
1665
|
+
return;
|
|
1666
|
+
if (hoveredDistrict === null)
|
|
1667
|
+
return;
|
|
1668
|
+
hoveredDistrict = null;
|
|
1669
|
+
updateDistrictAppearance();
|
|
1670
|
+
root.dispatchEvent(new CustomEvent('districthover', { bubbles: true, detail: districtSelection(null) }));
|
|
1671
|
+
});
|
|
1450
1672
|
listen(svg, 'pointerdown', stopAnimation);
|
|
1451
1673
|
let observer;
|
|
1452
1674
|
const onResize = () => {
|
|
@@ -1457,6 +1679,10 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1457
1679
|
const explorer = Object.assign(root, {
|
|
1458
1680
|
selectNeighborhood,
|
|
1459
1681
|
getSelection,
|
|
1682
|
+
selectDistrict,
|
|
1683
|
+
getSelectedDistrict,
|
|
1684
|
+
setDistrictYear,
|
|
1685
|
+
setDistrictStyle,
|
|
1460
1686
|
getViewport,
|
|
1461
1687
|
setViewport: moveView,
|
|
1462
1688
|
stopAnimation,
|
package/dist/src/layers.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ interface DistrictPath {
|
|
|
15
15
|
id: number;
|
|
16
16
|
path: string;
|
|
17
17
|
color: string;
|
|
18
|
+
stroke?: string;
|
|
19
|
+
opacity?: number;
|
|
18
20
|
}
|
|
19
21
|
export declare function districtFills(districts: readonly DistrictPath[], { idPrefix }: LayerContext): string;
|
|
20
22
|
export declare function districtLines(districts: readonly DistrictPath[], { idPrefix, colors }: LayerContext): string;
|
package/dist/src/layers.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { escapeXml, number, overlayLabel, stroke } from './svg.js';
|
|
2
2
|
export function districtFills(districts, { idPrefix }) {
|
|
3
|
-
return `<g data-layer="district-fills" clip-path="url(#${idPrefix}-coast)">${districts.map((d) => `<path data-district="${d.id}" d="${d.path}" fill="${escapeXml(d.color)}" fill-rule="evenodd"/>`).join('')}</g>`;
|
|
3
|
+
return `<g data-layer="district-fills" clip-path="url(#${idPrefix}-coast)">${districts.map((d) => `<path data-district="${d.id}" d="${d.path}" fill="${escapeXml(d.color)}"${d.opacity === undefined ? '' : ` fill-opacity="${d.opacity}"`} fill-rule="evenodd"/>`).join('')}</g>`;
|
|
4
4
|
}
|
|
5
5
|
export function districtLines(districts, { idPrefix, colors }) {
|
|
6
|
-
return `<g data-layer="district-lines" clip-path="url(#${idPrefix}-coast)">${districts.map((d) => `<path data-district="${d.id}" d="${d.path}" ${stroke(colors.district, 1.1)}/>`).join('')}</g>`;
|
|
6
|
+
return `<g data-layer="district-lines" clip-path="url(#${idPrefix}-coast)">${districts.map((d) => `<path data-district="${d.id}" d="${d.path}" ${stroke(d.stroke ?? colors.district, 1.1)}${d.opacity === undefined ? '' : ` stroke-opacity="${d.opacity}"`}/>`).join('')}</g>`;
|
|
7
7
|
}
|
|
8
8
|
export function landmarks(items, { idPrefix, path, colors }) {
|
|
9
9
|
return `<g data-layer="landmarks" clip-path="url(#${idPrefix}-coast)">${items.map((item) => `<path data-landmark="${escapeXml(item.id)}" d="${path(item.geometry)}" fill="${escapeXml(colors.park)}" fill-rule="evenodd"><title>${escapeXml(item.name)}</title></path>`).join('')}</g>`;
|
package/dist/src/map-core.d.ts
CHANGED
|
@@ -47,6 +47,39 @@ export interface BartStationData {
|
|
|
47
47
|
}
|
|
48
48
|
export declare const districtYears: readonly DistrictYear[];
|
|
49
49
|
export declare const districtColors: readonly string[];
|
|
50
|
+
/** Canonical geometry in the same fitted coordinate space used by renderMap. */
|
|
51
|
+
export declare function getLayerPathsWithData(options: SFMapOptions, data: SFMapData, complete?: boolean): {
|
|
52
|
+
year: DistrictYear;
|
|
53
|
+
viewBox: [number, number, number, number];
|
|
54
|
+
project: (coordinates: Position) => [number, number];
|
|
55
|
+
coast: string;
|
|
56
|
+
districts: {
|
|
57
|
+
id: number;
|
|
58
|
+
geometry: string;
|
|
59
|
+
extras: string;
|
|
60
|
+
path: string;
|
|
61
|
+
}[];
|
|
62
|
+
neighborhoods: {
|
|
63
|
+
name: string;
|
|
64
|
+
path: string;
|
|
65
|
+
}[];
|
|
66
|
+
highways: {
|
|
67
|
+
route: string;
|
|
68
|
+
path: string;
|
|
69
|
+
}[];
|
|
70
|
+
landmarks: {
|
|
71
|
+
id: string;
|
|
72
|
+
path: string;
|
|
73
|
+
}[];
|
|
74
|
+
keyRoads: {
|
|
75
|
+
id: string;
|
|
76
|
+
path: string;
|
|
77
|
+
}[];
|
|
78
|
+
bartStations: {
|
|
79
|
+
id: string;
|
|
80
|
+
point: [number, number];
|
|
81
|
+
}[];
|
|
82
|
+
};
|
|
50
83
|
/** Make an offline SVG and the matching longitude/latitude projection. */
|
|
51
84
|
export declare function createSFMapWithData(options: SFMapOptions, data: SFMapData): {
|
|
52
85
|
svg: string;
|
package/dist/src/map-core.js
CHANGED
|
@@ -16,6 +16,66 @@ export const districtColors = Object.freeze([
|
|
|
16
16
|
'#cbdcd1',
|
|
17
17
|
'#dfddc9',
|
|
18
18
|
]);
|
|
19
|
+
/** Canonical geometry in the same fitted coordinate space used by renderMap. */
|
|
20
|
+
export function getLayerPathsWithData(options, data, complete = true) {
|
|
21
|
+
const { width = 800, height = 800, padding = 28, year = 2022 } = options;
|
|
22
|
+
if (![width, height, padding].every(Number.isFinite) ||
|
|
23
|
+
width <= 0 ||
|
|
24
|
+
height <= 0 ||
|
|
25
|
+
padding < 0 ||
|
|
26
|
+
padding * 2 >= Math.min(width, height))
|
|
27
|
+
throw new RangeError('Use positive dimensions and padding smaller than half the map.');
|
|
28
|
+
if (!districtYears.includes(year))
|
|
29
|
+
throw new RangeError('District year must be 2002, 2012, or 2022.');
|
|
30
|
+
const coastPoints = positions(data.coast).map(rawProject);
|
|
31
|
+
const [minX, minY, maxX, maxY] = coastPoints.reduce((b, [x, y]) => [Math.min(b[0], x), Math.min(b[1], y), Math.max(b[2], x), Math.max(b[3], y)], [Infinity, Infinity, -Infinity, -Infinity]);
|
|
32
|
+
const scale = Math.min((width - padding * 2) / (maxX - minX), (height - padding * 2) / (maxY - minY));
|
|
33
|
+
const project = (coordinates) => {
|
|
34
|
+
const [x, y] = rawProject(coordinates);
|
|
35
|
+
const point = [
|
|
36
|
+
(x - (minX + maxX) / 2) * scale + width / 2,
|
|
37
|
+
(y - (minY + maxY) / 2) * scale + height / 2,
|
|
38
|
+
];
|
|
39
|
+
if (!point.every(Number.isFinite))
|
|
40
|
+
throw new RangeError('Projected coordinates must be finite; use smaller map dimensions.');
|
|
41
|
+
return point;
|
|
42
|
+
};
|
|
43
|
+
const path = (geometry) => geometryPath(geometry, project);
|
|
44
|
+
return {
|
|
45
|
+
year,
|
|
46
|
+
viewBox: [0, 0, width, height],
|
|
47
|
+
project,
|
|
48
|
+
coast: path(data.coast),
|
|
49
|
+
districts: (complete || options.districtFills !== false || options.districtLines !== false
|
|
50
|
+
? (data.districts?.[year] ?? [])
|
|
51
|
+
: []).map((district) => ({
|
|
52
|
+
id: district.id,
|
|
53
|
+
geometry: path(district.geometry),
|
|
54
|
+
extras: path(district.extras),
|
|
55
|
+
path: path(district.geometry) + path(district.extras),
|
|
56
|
+
})),
|
|
57
|
+
neighborhoods: (complete ? (data.neighborhoods ?? []) : []).map((item) => ({
|
|
58
|
+
name: item.name,
|
|
59
|
+
path: path(item.geometry),
|
|
60
|
+
})),
|
|
61
|
+
highways: (complete ? (data.highways ?? []) : []).map((item) => ({
|
|
62
|
+
route: item.route,
|
|
63
|
+
path: path(item.geometry),
|
|
64
|
+
})),
|
|
65
|
+
landmarks: (complete ? (data.landmarks ?? []) : []).map((item) => ({
|
|
66
|
+
id: item.id,
|
|
67
|
+
path: path(item.geometry),
|
|
68
|
+
})),
|
|
69
|
+
keyRoads: (complete ? (data.keyRoads ?? []) : []).map((item) => ({
|
|
70
|
+
id: item.id,
|
|
71
|
+
path: path(item.geometry),
|
|
72
|
+
})),
|
|
73
|
+
bartStations: (complete ? (data.bartStations ?? []) : []).map((item) => ({
|
|
74
|
+
id: item.id,
|
|
75
|
+
point: project(item.coordinates),
|
|
76
|
+
})),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
19
79
|
const defaults = {
|
|
20
80
|
water: '#e7f0f3',
|
|
21
81
|
land: '#f1f3ee',
|
|
@@ -46,49 +106,35 @@ const transitColors = {
|
|
|
46
106
|
let sequence = 0;
|
|
47
107
|
/** Make an offline SVG and the matching longitude/latitude projection. */
|
|
48
108
|
export function createSFMapWithData(options, data) {
|
|
49
|
-
const { theme = 'districts', width = 800, height = 800,
|
|
109
|
+
const { theme = 'districts', width = 800, height = 800, year = 2022, districtLines = true, neighborhoodLines = false, districtFills = true, districtLabels = true, labels = true, highways = false, keyRoads = false, landmarks = false, bartStations = false, markers = [], title = 'San Francisco map', idPrefix = `sf-map-${++sequence}`, } = options;
|
|
50
110
|
validateMarkers(markers);
|
|
51
111
|
validateOverlays(options.overlays ?? []);
|
|
52
112
|
const roadLabels = options.roadLabels ?? keyRoads;
|
|
53
|
-
if (![width, height, padding].every(Number.isFinite) ||
|
|
54
|
-
width <= 0 ||
|
|
55
|
-
height <= 0 ||
|
|
56
|
-
padding < 0 ||
|
|
57
|
-
padding * 2 >= Math.min(width, height))
|
|
58
|
-
throw new RangeError('Use positive dimensions and padding smaller than half the map.');
|
|
59
|
-
if (!districtYears.includes(year))
|
|
60
|
-
throw new RangeError('District year must be 2002, 2012, or 2022.');
|
|
61
113
|
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(idPrefix))
|
|
62
114
|
throw new TypeError('idPrefix must start with a letter and contain only letters, numbers, underscores, or hyphens.');
|
|
63
115
|
if (theme !== 'districts' && theme !== 'transit')
|
|
64
116
|
throw new TypeError('Theme must be districts or transit.');
|
|
65
117
|
const colors = { ...defaults, ...(theme === 'transit' ? transitColors : {}), ...options.colors };
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
const scale = Math.min((width - padding * 2) / (maxX - minX), (height - padding * 2) / (maxY - minY));
|
|
69
|
-
const project = (coordinates) => {
|
|
70
|
-
const [x, y] = rawProject(coordinates);
|
|
71
|
-
const point = [
|
|
72
|
-
(x - (minX + maxX) / 2) * scale + width / 2,
|
|
73
|
-
(y - (minY + maxY) / 2) * scale + height / 2,
|
|
74
|
-
];
|
|
75
|
-
if (!point.every(Number.isFinite))
|
|
76
|
-
throw new RangeError('Projected coordinates must be finite; use smaller map dimensions.');
|
|
77
|
-
return point;
|
|
78
|
-
};
|
|
118
|
+
const geometry = getLayerPathsWithData(options, data, false);
|
|
119
|
+
const { project } = geometry;
|
|
79
120
|
const path = (geometry) => geometryPath(geometry, project);
|
|
80
|
-
const coastPath =
|
|
121
|
+
const coastPath = geometry.coast;
|
|
81
122
|
const districts = data.districts?.[year] ?? [];
|
|
82
123
|
const landmarkData = data.landmarks ?? [];
|
|
83
124
|
const roadData = data.keyRoads ?? [];
|
|
84
125
|
const stationData = data.bartStations ?? [];
|
|
85
126
|
const context = { project, path, colors, idPrefix, theme, labels };
|
|
86
127
|
const districtPaths = districtFills || districtLines
|
|
87
|
-
? districts.map((d) =>
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
128
|
+
? districts.map((d, index) => {
|
|
129
|
+
const style = validateDistrictStyle(options.districtStyle?.(d));
|
|
130
|
+
return {
|
|
131
|
+
id: d.id,
|
|
132
|
+
path: geometry.districts[index]?.path ?? '',
|
|
133
|
+
stroke: style.stroke,
|
|
134
|
+
opacity: style.opacity,
|
|
135
|
+
color: style.fill ?? (theme === 'transit' ? colors.land : districtColors[d.id - 1]),
|
|
136
|
+
};
|
|
137
|
+
})
|
|
92
138
|
: [];
|
|
93
139
|
const parts = [
|
|
94
140
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-labelledby="${idPrefix}-title" data-sf-map="" data-year="${year}" style="max-width:100%;height:auto"><title id="${idPrefix}-title">${escapeXml(title)}</title><desc>San Francisco supervisorial district boundaries (${year}).${neighborhoodLines ? ' Dashed lines show SFAR realtor neighborhood areas, defined in August 2010.' : ''}${landmarks ? ' Highlighted areas show six parks and landmarks.' : ''}${keyRoads ? ' Thin gray lines show selected road corridors.' : ''}${bartStations ? ' Rings mark the eight San Francisco BART stations.' : ''} Geometry from DataSF${bartStations ? ' and BART' : ''}. See package SOURCES.md.</desc><defs><clipPath id="${idPrefix}-coast"><path d="${coastPath}" fill-rule="evenodd" clip-rule="evenodd"/></clipPath></defs><rect width="${width}" height="${height}" fill="${escapeXml(colors.water)}"/><g data-layer="geography"><path data-layer="coast" d="${coastPath}" fill="${escapeXml(colors.land)}" fill-rule="evenodd"/>`,
|
|
@@ -125,3 +171,19 @@ export function createSFMapWithData(options, data) {
|
|
|
125
171
|
viewBox: [0, 0, width, height],
|
|
126
172
|
};
|
|
127
173
|
}
|
|
174
|
+
function validateDistrictStyle(style) {
|
|
175
|
+
if (style === undefined)
|
|
176
|
+
return {};
|
|
177
|
+
if (!style || typeof style !== 'object' || Array.isArray(style))
|
|
178
|
+
throw new TypeError('districtStyle must return a style object.');
|
|
179
|
+
for (const key of Object.keys(style))
|
|
180
|
+
if (!['fill', 'stroke', 'opacity'].includes(key))
|
|
181
|
+
throw new TypeError(`Unknown district style: ${key}`);
|
|
182
|
+
for (const key of ['fill', 'stroke'])
|
|
183
|
+
if (style[key] !== undefined && typeof style[key] !== 'string')
|
|
184
|
+
throw new TypeError(`${key} must be a string.`);
|
|
185
|
+
if (style.opacity !== undefined &&
|
|
186
|
+
(!Number.isFinite(style.opacity) || style.opacity < 0 || style.opacity > 1))
|
|
187
|
+
throw new RangeError('District opacity must be between 0 and 1.');
|
|
188
|
+
return style;
|
|
189
|
+
}
|
package/dist/src/map.d.ts
CHANGED
|
@@ -2,6 +2,6 @@ import type { MapController, MapOptions } from './controller-types.js';
|
|
|
2
2
|
import { type InteractiveSFMapData } from './interactive-data.js';
|
|
3
3
|
export type * from './controller-types.js';
|
|
4
4
|
export type { InteractiveSFMapData as MapData } from './explorer-data.js';
|
|
5
|
-
export type { CameraOptions, InteractiveLayers, MapFeatures, MapMarker, MapOverlay, MapPadding, MapViewport, NeighborhoodSelection, } from './types.js';
|
|
5
|
+
export type { CameraOptions, DistrictSelection, DistrictStyle, DistrictYear, InteractiveLayers, MapFeatures, MapMarker, MapOverlay, MapPadding, MapViewport, NeighborhoodSelection, } from './types.js';
|
|
6
6
|
/** No geography is imported. Supply a preset or your own immutable data. */
|
|
7
7
|
export declare function createMap(data: InteractiveSFMapData, options?: MapOptions): MapController;
|
package/dist/src/map.js
CHANGED
|
@@ -63,6 +63,10 @@ export function createMap(data, options = {}) {
|
|
|
63
63
|
on: use((type, listener) => {
|
|
64
64
|
if (![
|
|
65
65
|
'markerchange',
|
|
66
|
+
'districtchange',
|
|
67
|
+
'districthover',
|
|
68
|
+
'districtactivate',
|
|
69
|
+
'districtyearchange',
|
|
66
70
|
'neighborhoodchange',
|
|
67
71
|
'overlayactivate',
|
|
68
72
|
'clusteractivate',
|
|
@@ -89,6 +93,10 @@ export function createMap(data, options = {}) {
|
|
|
89
93
|
getSelectedMarker: use(element.getSelectedMarker),
|
|
90
94
|
selectNeighborhood: use(element.selectNeighborhood),
|
|
91
95
|
getSelectedNeighborhood: use(element.getSelection),
|
|
96
|
+
selectDistrict: use(element.selectDistrict),
|
|
97
|
+
getSelectedDistrict: use(element.getSelectedDistrict),
|
|
98
|
+
setDistrictYear: use(element.setDistrictYear),
|
|
99
|
+
setDistrictStyle: use(element.setDistrictStyle),
|
|
92
100
|
setSource: use(element.setSource),
|
|
93
101
|
setMode: use(element.setMode),
|
|
94
102
|
setLabels: use(element.setLabels),
|
package/dist/src/static.d.ts
CHANGED
|
@@ -1,10 +1,43 @@
|
|
|
1
1
|
import { type SFMapData } from './map-core.js';
|
|
2
2
|
import type { SFMapOptions } from './types.js';
|
|
3
|
-
export type { SFMapData as StaticMapData } from './map-core.js';
|
|
4
|
-
export type { SFMapOptions as StaticMapOptions } from './types.js';
|
|
3
|
+
export type { DistrictRowData, SFMapData as StaticMapData } from './map-core.js';
|
|
4
|
+
export type { DistrictStyle, DistrictYear, SFMapOptions as StaticMapOptions } from './types.js';
|
|
5
5
|
/** Server-safe SVG rendering with explicit data; returns SVG plus projection helpers. */
|
|
6
6
|
export declare function renderMap(data: SFMapData, options?: SFMapOptions): {
|
|
7
7
|
svg: string;
|
|
8
8
|
project: (coordinates: import("../data/types.js").Position) => [number, number];
|
|
9
9
|
viewBox: [number, number, number, number];
|
|
10
10
|
};
|
|
11
|
+
/** Project canonical layer geometry without constructing an SVG string. */
|
|
12
|
+
export declare function getLayerPaths(data: SFMapData, options?: SFMapOptions): {
|
|
13
|
+
year: import("./types.js").DistrictYear;
|
|
14
|
+
viewBox: [number, number, number, number];
|
|
15
|
+
project: (coordinates: import("../data/types.js").Position) => [number, number];
|
|
16
|
+
coast: string;
|
|
17
|
+
districts: {
|
|
18
|
+
id: number;
|
|
19
|
+
geometry: string;
|
|
20
|
+
extras: string;
|
|
21
|
+
path: string;
|
|
22
|
+
}[];
|
|
23
|
+
neighborhoods: {
|
|
24
|
+
name: string;
|
|
25
|
+
path: string;
|
|
26
|
+
}[];
|
|
27
|
+
highways: {
|
|
28
|
+
route: string;
|
|
29
|
+
path: string;
|
|
30
|
+
}[];
|
|
31
|
+
landmarks: {
|
|
32
|
+
id: string;
|
|
33
|
+
path: string;
|
|
34
|
+
}[];
|
|
35
|
+
keyRoads: {
|
|
36
|
+
id: string;
|
|
37
|
+
path: string;
|
|
38
|
+
}[];
|
|
39
|
+
bartStations: {
|
|
40
|
+
id: string;
|
|
41
|
+
point: [number, number];
|
|
42
|
+
}[];
|
|
43
|
+
};
|
package/dist/src/static.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import { createSFMapWithData } from './map-core.js';
|
|
1
|
+
import { createSFMapWithData, getLayerPathsWithData } from './map-core.js';
|
|
2
2
|
/** Server-safe SVG rendering with explicit data; returns SVG plus projection helpers. */
|
|
3
3
|
export function renderMap(data, options = {}) {
|
|
4
4
|
return createSFMapWithData(options, data);
|
|
5
5
|
}
|
|
6
|
+
/** Project canonical layer geometry without constructing an SVG string. */
|
|
7
|
+
export function getLayerPaths(data, options = {}) {
|
|
8
|
+
return getLayerPathsWithData(options, data);
|
|
9
|
+
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import type { Geometry, NeighborhoodFeature, NeighborhoodSource } from '../data/types.js';
|
|
2
2
|
export type DistrictYear = 2002 | 2012 | 2022;
|
|
3
|
+
export interface DistrictSelection {
|
|
4
|
+
id: number;
|
|
5
|
+
year: DistrictYear;
|
|
6
|
+
district: import('./map-core.js').DistrictRowData;
|
|
7
|
+
}
|
|
8
|
+
export interface DistrictStyle {
|
|
9
|
+
fill?: string;
|
|
10
|
+
stroke?: string;
|
|
11
|
+
opacity?: number;
|
|
12
|
+
}
|
|
3
13
|
export interface MapMarker {
|
|
4
14
|
id: string;
|
|
5
15
|
lng: number;
|
|
@@ -31,6 +41,8 @@ export interface SFMapOptions {
|
|
|
31
41
|
districtLines?: boolean;
|
|
32
42
|
neighborhoodLines?: boolean;
|
|
33
43
|
districtFills?: boolean;
|
|
44
|
+
/** Style each supervisorial district without taking over SVG rendering. */
|
|
45
|
+
districtStyle?: (district: import('./map-core.js').DistrictRowData) => DistrictStyle;
|
|
34
46
|
districtLabels?: boolean;
|
|
35
47
|
/** Hide all visible text labels while retaining geographic symbols and accessible titles. */
|
|
36
48
|
labels?: boolean;
|
|
@@ -112,6 +124,7 @@ export interface NeighborhoodExplorerOptions extends MapFeatures {
|
|
|
112
124
|
hoverFill?: string;
|
|
113
125
|
hoverStroke?: string;
|
|
114
126
|
};
|
|
127
|
+
districtStyle?: SFMapOptions['districtStyle'];
|
|
115
128
|
legend?: {
|
|
116
129
|
builtins?: boolean;
|
|
117
130
|
hidden?: readonly ('bart' | 'park' | 'highway' | 'road')[];
|
|
@@ -176,6 +189,12 @@ export interface NeighborhoodExplorerElement extends HTMLElement {
|
|
|
176
189
|
fit?: boolean;
|
|
177
190
|
} & CameraOptions): boolean;
|
|
178
191
|
getSelection(): NeighborhoodSelection | null;
|
|
192
|
+
selectDistrict(id: number | null, options?: {
|
|
193
|
+
fit?: boolean;
|
|
194
|
+
} & CameraOptions): boolean;
|
|
195
|
+
getSelectedDistrict(): DistrictSelection | null;
|
|
196
|
+
setDistrictYear(year: DistrictYear, options?: CameraOptions): void;
|
|
197
|
+
setDistrictStyle(style: SFMapOptions['districtStyle']): void;
|
|
179
198
|
setSource(source: NeighborhoodSource): void;
|
|
180
199
|
setMode(mode: ExplorerMode): void;
|
|
181
200
|
setLabels(visible: boolean): void;
|
package/dist/src/validation.js
CHANGED
|
@@ -33,6 +33,7 @@ const optionKeys = [
|
|
|
33
33
|
'colors',
|
|
34
34
|
'labelStyle',
|
|
35
35
|
'areaStyle',
|
|
36
|
+
'districtStyle',
|
|
36
37
|
'motion',
|
|
37
38
|
'markerEntrance',
|
|
38
39
|
'selectedMarkerRing',
|
|
@@ -60,6 +61,8 @@ const optionKeys = [
|
|
|
60
61
|
];
|
|
61
62
|
export function validateExplorerOptions(options) {
|
|
62
63
|
assertOptions(options, 'map', optionKeys);
|
|
64
|
+
if (options.districtStyle !== undefined && typeof options.districtStyle !== 'function')
|
|
65
|
+
throw new TypeError('districtStyle must be a function.');
|
|
63
66
|
for (const key of ['labels', 'selectableNeighborhoods'])
|
|
64
67
|
if (options[key] !== undefined && typeof options[key] !== 'boolean')
|
|
65
68
|
throw new TypeError(`${key} must be boolean.`);
|
package/docs/EXAMPLES.md
CHANGED
|
@@ -27,7 +27,14 @@ const svg = renderSFMap({
|
|
|
27
27
|
await writeFile('districts.svg', svg);
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
The
|
|
30
|
+
The legacy entry includes built-in geography for convenience. The modern root and `/static` accept explicit geography. See the [full option list](../README.md#static-options).
|
|
31
|
+
|
|
32
|
+
For an election choropleth, use `renderMap(data, { year, districtStyle })` or
|
|
33
|
+
`createMap({ map: data, districts: districtMaps, neighborhoods: {} }, options)`.
|
|
34
|
+
The controller exposes `setDistrictYear`, `setDistrictStyle`, `selectDistrict`, and typed
|
|
35
|
+
district events; `getLayerPaths(data, { year })` returns fitted paths without SVG markup.
|
|
36
|
+
See the [complete election recipe](../README.md#election-district-api) and the Storybook
|
|
37
|
+
“Election choropleth” example.
|
|
31
38
|
|
|
32
39
|
## Lightweight interactive guide
|
|
33
40
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahwee/sf-map-svg",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Offline SVG maps of San Francisco with district boundaries, parks, landmarks, and BART stations.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/src/api.js",
|
|
@@ -179,15 +179,17 @@
|
|
|
179
179
|
"test": "pnpm build && node --test",
|
|
180
180
|
"test:stories": "pnpm build && vitest run --project=storybook",
|
|
181
181
|
"test:stories:coverage": "pnpm build && vitest run --project=storybook --coverage",
|
|
182
|
+
"test:visual": "playwright test --config=playwright.visual.config.mjs",
|
|
182
183
|
"demo": "pnpm build && node examples/build.mjs",
|
|
183
184
|
"format": "biome check --write .",
|
|
184
185
|
"format:check": "biome format .",
|
|
185
|
-
"check": "pnpm peers check && pnpm lint && pnpm data:check && pnpm typecheck && pnpm test && pnpm test:bundle",
|
|
186
|
+
"check": "pnpm peers check && pnpm lint && pnpm data:check && pnpm typecheck && pnpm typecheck:stories && pnpm test && pnpm test:bundle",
|
|
186
187
|
"storybook": "pnpm build && storybook dev -p 6006 --host 127.0.0.1 --no-open",
|
|
187
188
|
"build-storybook": "pnpm build && storybook build",
|
|
188
189
|
"data:catalog": "node scripts/build-data-catalog.mjs",
|
|
189
190
|
"data:check": "node scripts/build-data-catalog.mjs --check",
|
|
190
191
|
"typecheck": "pnpm build && tsc -p tsconfig.json",
|
|
192
|
+
"typecheck:stories": "tsc -p tsconfig.stories.json",
|
|
191
193
|
"data:normalize-realtor": "node scripts/normalize-realtor.mjs",
|
|
192
194
|
"data:guide": "node scripts/build-guide-data.mjs",
|
|
193
195
|
"report:guide": "node scripts/report-guide-bundle.mjs",
|