@kahwee/sf-map-svg 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +38 -569
- package/dist/data/README.md +3 -2
- 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/full-data.d.ts +3 -0
- package/dist/src/full-data.js +9 -0
- package/dist/src/guide-map.d.ts +1 -1
- package/dist/src/guide-map.js +4 -3
- package/dist/src/guide.d.ts +1 -2
- 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 +2 -2
- package/dist/src/map.js +13 -3
- package/dist/src/static-data.d.ts +3 -0
- package/dist/src/static-data.js +23 -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 +18 -9
- package/docs/api-audit.md +5 -14
- package/docs/consumer-integration.md +12 -159
- package/docs/guide-bundle-report.md +5 -7
- package/docs/migration-v3.md +30 -0
- package/package.json +13 -23
- package/dist/src/custom-map.d.ts +0 -3
- package/dist/src/custom-map.js +0 -1
- package/dist/src/explorer.d.ts +0 -4
- package/dist/src/explorer.js +0 -30
- package/dist/src/index.d.ts +0 -12
- package/dist/src/index.js +0 -21
- package/dist/src/interactive-data.d.ts +0 -6
- package/dist/src/interactive-data.js +0 -7
- package/dist/src/interactive.d.ts +0 -4
- package/dist/src/interactive.js +0 -7
- package/docs/migration-v2.md +0 -238
package/dist/data/README.md
CHANGED
|
@@ -99,11 +99,12 @@ Lookup is exact after case/punctuation normalization, returns `undefined` for an
|
|
|
99
99
|
## Draw a neighborhood with the existing projection
|
|
100
100
|
|
|
101
101
|
```js
|
|
102
|
-
import {
|
|
102
|
+
import { renderMap } from '@kahwee/sf-map-svg';
|
|
103
|
+
import { fullMapData } from '@kahwee/sf-map-svg/data/full';
|
|
103
104
|
import { getNeighborhood } from '@kahwee/sf-map-svg/data';
|
|
104
105
|
import { geometryPath } from '@kahwee/sf-map-svg/geometry';
|
|
105
106
|
|
|
106
|
-
const map =
|
|
107
|
+
const map = renderMap(fullMapData.map, { districtFills: false });
|
|
107
108
|
const mission = getNeighborhood('Inner Mission');
|
|
108
109
|
const pathData = geometryPath(mission.geometry, map.project);
|
|
109
110
|
// Use pathData as an SVG <path d="..."> over map.svg.
|
|
@@ -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,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { districtMaps, neighborhoodCollections } from '../data/index.js';
|
|
2
|
+
import { deepFreeze } from './immutable.js';
|
|
3
|
+
import { staticMapData } from './static-data.js';
|
|
4
|
+
/** Complete, explicit geographic preset. Import this only when all datasets are needed. */
|
|
5
|
+
export const fullMapData = deepFreeze({
|
|
6
|
+
map: staticMapData,
|
|
7
|
+
neighborhoods: neighborhoodCollections,
|
|
8
|
+
districts: districtMaps,
|
|
9
|
+
});
|
package/dist/src/guide-map.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { InteractiveSFMapElement, InteractiveSFMapOptions } from './
|
|
1
|
+
import type { NeighborhoodExplorerElement as InteractiveSFMapElement, NeighborhoodExplorerOptions as InteractiveSFMapOptions } from './types.js';
|
|
2
2
|
/** Guide map preset: SFAR neighborhoods, major parks, BART, and curated roads. */
|
|
3
3
|
export declare function createGuideMap(options?: InteractiveSFMapOptions): InteractiveSFMapElement;
|
|
4
4
|
/** Enhance createGuideShell() in place with a fixed compact chrome layout.
|
package/dist/src/guide-map.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
|
+
import { createNeighborhoodExplorerCore } from './explorer-core.js';
|
|
1
2
|
import { guideMapData } from './guide-data.js';
|
|
2
|
-
import { createInteractiveSFMapWithData } from './interactive-data.js';
|
|
3
3
|
import { guideOptions } from './presets.js';
|
|
4
4
|
import { validateExplorerOptions } from './validation.js';
|
|
5
5
|
/** Guide map preset: SFAR neighborhoods, major parks, BART, and curated roads. */
|
|
6
6
|
export function createGuideMap(options = {}) {
|
|
7
7
|
validateExplorerOptions(options);
|
|
8
|
-
return
|
|
8
|
+
return createNeighborhoodExplorerCore({
|
|
9
9
|
mode: 'neighborhoods',
|
|
10
10
|
...options,
|
|
11
11
|
layers: {
|
|
12
12
|
...guideOptions.layers,
|
|
13
13
|
...options.layers,
|
|
14
14
|
},
|
|
15
|
-
|
|
15
|
+
interface: 'map',
|
|
16
|
+
}, guideMapData);
|
|
16
17
|
}
|
|
17
18
|
/** Enhance createGuideShell() in place with a fixed compact chrome layout.
|
|
18
19
|
* Keep an accessible external place list when hiding the native pickers.
|
package/dist/src/guide.d.ts
CHANGED
|
@@ -2,5 +2,4 @@ export type { InteractiveSFMapData } from './explorer-data.js';
|
|
|
2
2
|
export { guideMapData } from './guide-data.js';
|
|
3
3
|
export { loadGuideDetailedData } from './guide-detailed.js';
|
|
4
4
|
export { createGuideMap, mountGuideMap } from './guide-map.js';
|
|
5
|
-
export type { InteractiveSFMapElement, InteractiveSFMapOptions } from './
|
|
6
|
-
export type { CameraOptions, MapFeatures, MapMarker } from './types.js';
|
|
5
|
+
export type { CameraOptions, MapFeatures, MapMarker, NeighborhoodExplorerElement as InteractiveSFMapElement, NeighborhoodExplorerOptions as InteractiveSFMapOptions, } from './types.js';
|
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
|
+
}
|