@kahwee/sf-map-svg 4.1.0 → 4.3.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 +23 -0
- package/README.md +11 -15
- package/dist/src/appearance.d.ts +5 -0
- package/dist/src/appearance.js +54 -0
- package/dist/src/configuration.d.ts +3 -1
- package/dist/src/configuration.js +32 -23
- package/dist/src/controller-types.d.ts +81 -8
- package/dist/src/explorer-core.d.ts +14 -2
- package/dist/src/explorer-core.js +174 -38
- package/dist/src/guide-map.js +1 -1
- package/dist/src/guide-static.js +3 -3
- package/dist/src/map-core.d.ts +14 -0
- package/dist/src/map-core.js +4 -1
- package/dist/src/map.js +164 -18
- package/dist/src/static.d.ts +6 -3
- package/dist/src/static.js +57 -2
- package/dist/src/types.d.ts +10 -2
- package/docs/API.md +67 -1
- package/docs/api-audit.md +7 -7
- package/docs/consumer-integration.md +35 -11
- package/docs/developer-guide.md +6 -4
- package/llms-full.txt +263 -54
- package/llms.txt +1 -1
- package/package.json +3 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { copyAppearance } from './appearance.js';
|
|
1
2
|
import { createCamera, validateCameraOptions } from './camera.js';
|
|
2
3
|
import { clusterPoints } from './clusters.js';
|
|
3
4
|
import { renderDistrictAppearance } from './district-layer.js';
|
|
@@ -11,7 +12,7 @@ import { createFrameScheduler } from './frame-scheduler.js';
|
|
|
11
12
|
import { geometryPath } from './geometry.js';
|
|
12
13
|
import { createLabelRenderer } from './label-renderer.js';
|
|
13
14
|
import { createLayerTransitions } from './layer-transition.js';
|
|
14
|
-
import { createSFMapWithData, districtColors, getLayerPathsWithData } from './map-core.js';
|
|
15
|
+
import { createSFMapWithData, districtColors, getLayerPathsWithData, resolveMapColors, } from './map-core.js';
|
|
15
16
|
import { createMarkerLayer } from './marker-layer.js';
|
|
16
17
|
import { attachNavigation } from './navigation.js';
|
|
17
18
|
import { validateExplorerOptions, validateMarkers, validateOverlays } from './validation.js';
|
|
@@ -37,7 +38,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
37
38
|
throw new RangeError(`Unknown map mode: ${mode}`);
|
|
38
39
|
if (chrome !== 'explorer' && chrome !== 'map')
|
|
39
40
|
throw new RangeError('Unknown interface.');
|
|
40
|
-
|
|
41
|
+
let minLabelSize = labelSize.min ?? 11, maxLabelSize = labelSize.max ?? 12;
|
|
41
42
|
if (![minLabelSize, maxLabelSize, markerRadius, markerHitSize].every(Number.isFinite) ||
|
|
42
43
|
minLabelSize < 8 ||
|
|
43
44
|
maxLabelSize < minLabelSize ||
|
|
@@ -464,7 +465,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
464
465
|
districtTransition.fade(fills, fade);
|
|
465
466
|
updateDistrictAppearance();
|
|
466
467
|
}
|
|
467
|
-
function setDistrictYear(next, options = {}) {
|
|
468
|
+
function setDistrictYear(next, options = {}, preparedStyles) {
|
|
468
469
|
if (destroyed)
|
|
469
470
|
return;
|
|
470
471
|
validateCameraOptions(options);
|
|
@@ -475,7 +476,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
475
476
|
if (next === year)
|
|
476
477
|
return;
|
|
477
478
|
const revision = districtRevision;
|
|
478
|
-
const prepared = prepareDistrictStyles(data.map.districts[next], districtStyle);
|
|
479
|
+
const prepared = preparedStyles ?? prepareDistrictStyles(data.map.districts[next], districtStyle);
|
|
479
480
|
if (destroyed || revision !== districtRevision)
|
|
480
481
|
return;
|
|
481
482
|
const committedRevision = ++districtRevision;
|
|
@@ -1063,7 +1064,10 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1063
1064
|
function selectNeighborhood(name, options = {}) {
|
|
1064
1065
|
if (destroyed)
|
|
1065
1066
|
return false;
|
|
1066
|
-
const feature = name === null
|
|
1067
|
+
const feature = name === null
|
|
1068
|
+
? undefined
|
|
1069
|
+
: (collections[source]?.features.find((entry) => entry.id === name) ??
|
|
1070
|
+
getNeighborhood(name, source));
|
|
1067
1071
|
if (name !== null && !feature)
|
|
1068
1072
|
return false;
|
|
1069
1073
|
validateCameraOptions(options);
|
|
@@ -1118,9 +1122,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1118
1122
|
moveView(target, options);
|
|
1119
1123
|
return true;
|
|
1120
1124
|
}
|
|
1121
|
-
function
|
|
1122
|
-
if (destroyed)
|
|
1123
|
-
return;
|
|
1125
|
+
function prepareSource(next, reset = true) {
|
|
1124
1126
|
if (!Object.hasOwn(collections, next))
|
|
1125
1127
|
throw new RangeError(`Unknown neighborhood source: ${next}`);
|
|
1126
1128
|
const collection = collections[next];
|
|
@@ -1191,36 +1193,42 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1191
1193
|
area: (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]),
|
|
1192
1194
|
};
|
|
1193
1195
|
});
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
items[0]
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1196
|
+
return () => {
|
|
1197
|
+
const hadSelection = !!selected;
|
|
1198
|
+
source = next;
|
|
1199
|
+
sourceSelect.value = source;
|
|
1200
|
+
selected = undefined;
|
|
1201
|
+
const revision = ++neighborhoodRevision;
|
|
1202
|
+
delete root.dataset.selectedNeighborhood;
|
|
1203
|
+
root.dataset.source = source;
|
|
1204
|
+
layerFades.crossfade(areas, () => areas.replaceChildren(...nextItems.map((item) => item.node)));
|
|
1205
|
+
neighborhoodSelect.replaceChildren(element('option', 'No neighborhood selected'));
|
|
1206
|
+
neighborhoodSelect.options[0].value = '';
|
|
1207
|
+
neighborhoodSelect.append(...nextOptions);
|
|
1208
|
+
items = nextItems;
|
|
1209
|
+
if (items[0])
|
|
1210
|
+
items[0].node.setAttribute('tabindex', '0');
|
|
1211
|
+
updateResults();
|
|
1212
|
+
updateDetail();
|
|
1213
|
+
updateComposition();
|
|
1214
|
+
status.textContent =
|
|
1215
|
+
mode === 'basemap'
|
|
1216
|
+
? 'San Francisco basemap.'
|
|
1217
|
+
: mode === 'districts'
|
|
1218
|
+
? `${year} supervisorial districts. Numbers identify each district.`
|
|
1219
|
+
: `${formatSourceLabel(source)}. Select a neighborhood to begin.`;
|
|
1220
|
+
if (hadSelection)
|
|
1221
|
+
root.dispatchEvent(new CustomEvent('neighborhoodchange', {
|
|
1222
|
+
bubbles: true,
|
|
1223
|
+
detail: { feature: null, source, id: null, name: null },
|
|
1224
|
+
}));
|
|
1225
|
+
if (reset && !destroyed && neighborhoodRevision === revision)
|
|
1226
|
+
resetView();
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
function setSource(next) {
|
|
1230
|
+
if (!destroyed)
|
|
1231
|
+
prepareSource(next)();
|
|
1224
1232
|
}
|
|
1225
1233
|
function syncFeatureControls() {
|
|
1226
1234
|
featureControls.hidden = neighborhoodLabel.hidden && markerLabel.hidden;
|
|
@@ -1653,7 +1661,135 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1653
1661
|
scheduleLabels();
|
|
1654
1662
|
root.dispatchEvent(new CustomEvent('mapresize', { bubbles: true }));
|
|
1655
1663
|
};
|
|
1664
|
+
let presentationRevision = 0;
|
|
1665
|
+
function applyPresentation(patch, onCommit, isCurrent = () => true) {
|
|
1666
|
+
const revision = ++presentationRevision;
|
|
1667
|
+
const nextYear = patch.year ?? year;
|
|
1668
|
+
const nextMode = patch.mode ?? mode;
|
|
1669
|
+
if ((patch.year !== undefined || nextMode === 'districts') &&
|
|
1670
|
+
(!data.map.districts?.[nextYear] || !data.districts?.[nextYear]))
|
|
1671
|
+
throw new RangeError(`No ${nextYear} district dataset was supplied.`);
|
|
1672
|
+
if (nextMode === 'neighborhoods' && !sources.length)
|
|
1673
|
+
throw new RangeError('No neighborhood dataset was supplied.');
|
|
1674
|
+
if (patch.source !== undefined && !Object.hasOwn(collections, patch.source))
|
|
1675
|
+
throw new RangeError(`Unknown neighborhood source: ${patch.source}`);
|
|
1676
|
+
const commitSource = patch.source !== undefined && patch.source !== source
|
|
1677
|
+
? prepareSource(patch.source, false)
|
|
1678
|
+
: undefined;
|
|
1679
|
+
const appearance = patch.appearance;
|
|
1680
|
+
const nextStyle = appearance ? appearance.districtStyle : districtStyle;
|
|
1681
|
+
const districtVersion = districtRevision;
|
|
1682
|
+
const prepared = appearance || nextYear !== year
|
|
1683
|
+
? prepareDistrictStyles(data.map.districts?.[nextYear] ?? [], nextStyle)
|
|
1684
|
+
: districtStyles;
|
|
1685
|
+
if (destroyed ||
|
|
1686
|
+
!isCurrent() ||
|
|
1687
|
+
revision !== presentationRevision ||
|
|
1688
|
+
districtVersion !== districtRevision)
|
|
1689
|
+
return false;
|
|
1690
|
+
if (nextYear !== year) {
|
|
1691
|
+
// Validate target paths and label coordinates before any presentation group commits.
|
|
1692
|
+
getLayerPathsWithData({ year: nextYear }, data.map, false);
|
|
1693
|
+
for (const feature of data.districts?.[nextYear]?.features ?? [])
|
|
1694
|
+
for (const point of feature.properties.labelPoints)
|
|
1695
|
+
map.project(point);
|
|
1696
|
+
}
|
|
1697
|
+
onCommit?.();
|
|
1698
|
+
if (appearance) {
|
|
1699
|
+
districtRevision++;
|
|
1700
|
+
const next = copyAppearance(appearance);
|
|
1701
|
+
theme = next.theme ?? 'transit';
|
|
1702
|
+
colors = next.colors ?? {};
|
|
1703
|
+
labelStyle = next.labelStyle ?? {};
|
|
1704
|
+
areaStyle = next.areaStyle ?? {};
|
|
1705
|
+
styleOptions = next.style ?? {};
|
|
1706
|
+
minLabelSize = next.labelSize?.min ?? 11;
|
|
1707
|
+
maxLabelSize = next.labelSize?.max ?? 12;
|
|
1708
|
+
markerRadius = next.markerRadius ?? 6;
|
|
1709
|
+
markerHitSize = next.markerHitSize ?? 44;
|
|
1710
|
+
markerColor = next.markerColor ?? colors.marker ?? '#245b61';
|
|
1711
|
+
selectedMarkerColor = next.selectedMarkerColor ?? colors.selected ?? '#f04f32';
|
|
1712
|
+
districtTransition.cancel();
|
|
1713
|
+
const fills = districtLayers.find((layer) => layer.dataset.layer === 'district-fills');
|
|
1714
|
+
const fade = layerFades.duration();
|
|
1715
|
+
const stylesChanged = nextStyle !== districtStyle ||
|
|
1716
|
+
[...prepared].some(([id, value]) => ['fill', 'stroke', 'opacity'].some((key) => value[key] !== districtStyles.get(id)?.[key]));
|
|
1717
|
+
if (fills && fade && enabled('districtFills') && nextYear === year && stylesChanged)
|
|
1718
|
+
districtTransition.fade(fills, fade);
|
|
1719
|
+
districtStyle = nextStyle;
|
|
1720
|
+
districtStyles = prepared;
|
|
1721
|
+
const palette = resolveMapColors(theme, colors);
|
|
1722
|
+
svg.querySelector('rect')?.setAttribute('fill', palette.water);
|
|
1723
|
+
svg.querySelector('[data-layer="coast"]')?.setAttribute('fill', palette.land);
|
|
1724
|
+
svg.querySelector('[data-layer="coastline"]')?.setAttribute('stroke', palette.district);
|
|
1725
|
+
for (const [layer, color] of [
|
|
1726
|
+
['landmarks', palette.park],
|
|
1727
|
+
['highways', palette.highway],
|
|
1728
|
+
['key-roads', palette.road],
|
|
1729
|
+
])
|
|
1730
|
+
for (const node of svg.querySelectorAll(`[data-layer="${layer}"] path`)) {
|
|
1731
|
+
node.setAttribute(layer === 'landmarks' ? 'fill' : 'stroke', color);
|
|
1732
|
+
if (layer === 'highways')
|
|
1733
|
+
node.setAttribute('stroke-width', theme === 'transit' ? '2' : '1.4');
|
|
1734
|
+
}
|
|
1735
|
+
for (const [token, fallback, name] of [
|
|
1736
|
+
['ink', '#18364f', 'ink'],
|
|
1737
|
+
['surface', '#fff', 'surface'],
|
|
1738
|
+
['accent', '#163d61', 'accent'],
|
|
1739
|
+
['border', '#cedae3', 'border'],
|
|
1740
|
+
['focus', '#1676b8', 'focus'],
|
|
1741
|
+
['controlGap', '6px', 'control-gap'],
|
|
1742
|
+
['font', 'system-ui,sans-serif', 'font'],
|
|
1743
|
+
])
|
|
1744
|
+
root.style.setProperty(`--sf-map-${name}`, styleOptions[token] ?? fallback);
|
|
1745
|
+
labelLayer.setAttribute('font-family', labelStyle.fontFamily ?? styleOptions.font ?? 'system-ui,sans-serif');
|
|
1746
|
+
labelRenderer.invalidateMetrics();
|
|
1747
|
+
for (const station of stationItems)
|
|
1748
|
+
station.node.setAttribute('stroke', colors.bart ?? '#0073ae');
|
|
1749
|
+
for (const kind of ['bart', 'park', 'highway', 'road']) {
|
|
1750
|
+
const symbol = legend.querySelector(`.sf-explorer-legend-${kind}`);
|
|
1751
|
+
if (symbol) {
|
|
1752
|
+
symbol.style.removeProperty(kind === 'bart' ? 'border-color' : 'background-color');
|
|
1753
|
+
if (colors[kind])
|
|
1754
|
+
symbol.style.setProperty(kind === 'bart' ? 'border-color' : 'background-color', colors[kind]);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
for (const item of items) {
|
|
1758
|
+
const active = item.feature === selected;
|
|
1759
|
+
item.node.setAttribute('fill', active ? (areaStyle.selectedFill ?? '#408dbe') : 'transparent');
|
|
1760
|
+
item.node.setAttribute('stroke', active ? (areaStyle.selectedStroke ?? '#176ba2') : (colors.neighborhood ?? '#9caebc'));
|
|
1761
|
+
}
|
|
1762
|
+
for (const item of markerItems) {
|
|
1763
|
+
item.dot.setAttribute('fill', item.marker.id === selectedMarker
|
|
1764
|
+
? selectedMarkerColor
|
|
1765
|
+
: (item.marker.color ?? markerColor));
|
|
1766
|
+
item.ring.setAttribute('stroke', features.selectedMarkerRing
|
|
1767
|
+
? (features.selectedMarkerRing.color ?? selectedMarkerColor)
|
|
1768
|
+
: selectedMarkerColor);
|
|
1769
|
+
}
|
|
1770
|
+
invalidateClusters();
|
|
1771
|
+
updateDistrictAppearance();
|
|
1772
|
+
}
|
|
1773
|
+
if (nextYear !== year)
|
|
1774
|
+
setDistrictYear(nextYear, {}, prepared);
|
|
1775
|
+
if (destroyed || revision !== presentationRevision || !isCurrent())
|
|
1776
|
+
return false;
|
|
1777
|
+
commitSource?.();
|
|
1778
|
+
if (destroyed || revision !== presentationRevision || !isCurrent())
|
|
1779
|
+
return false;
|
|
1780
|
+
if (commitSource && appearance)
|
|
1781
|
+
for (const item of items)
|
|
1782
|
+
item.node.setAttribute('stroke', colors.neighborhood ?? '#9caebc');
|
|
1783
|
+
if (patch.mode !== undefined)
|
|
1784
|
+
setMode(patch.mode, false);
|
|
1785
|
+
if (patch.labels !== undefined)
|
|
1786
|
+
setLabels(patch.labels);
|
|
1787
|
+
scheduleLabels();
|
|
1788
|
+
return true;
|
|
1789
|
+
}
|
|
1656
1790
|
const explorer = Object.assign(root, {
|
|
1791
|
+
applyPresentation,
|
|
1792
|
+
getMapState: () => ({ mode, source, year, labels }),
|
|
1657
1793
|
selectNeighborhood,
|
|
1658
1794
|
getSelection,
|
|
1659
1795
|
selectDistrict,
|
package/dist/src/guide-map.js
CHANGED
|
@@ -6,7 +6,7 @@ import { guideOptions } from './presets.js';
|
|
|
6
6
|
import { validateExplorerOptions } from './validation.js';
|
|
7
7
|
function controllerOptions(options) {
|
|
8
8
|
expandMapOptions(options);
|
|
9
|
-
const config = prepareConfiguration({ features: {}, layers: guideOptions.layers ?? {}, controls: {} }, {
|
|
9
|
+
const config = prepareConfiguration({ features: {}, layers: guideOptions.layers ?? {}, controls: {}, appearance: {} }, {
|
|
10
10
|
features: options.features,
|
|
11
11
|
layers: options.layers === undefined ? {} : options.layers,
|
|
12
12
|
controls: options.controls,
|
package/dist/src/guide-static.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { guideMapData } from './guide-data.js';
|
|
2
2
|
import { guideShellCSS } from './guide-shell.js';
|
|
3
|
-
import {
|
|
3
|
+
import { renderMap } from './static.js';
|
|
4
4
|
/** Server-safe overview using exactly the guide browser geography. */
|
|
5
5
|
export function createGuideSVG(options = {}) {
|
|
6
|
-
return
|
|
6
|
+
return renderMap(guideMapData.map, {
|
|
7
7
|
theme: 'transit',
|
|
8
8
|
districtFills: false,
|
|
9
9
|
districtLines: false,
|
|
@@ -14,7 +14,7 @@ export function createGuideSVG(options = {}) {
|
|
|
14
14
|
keyRoads: true,
|
|
15
15
|
bartStations: true,
|
|
16
16
|
...options,
|
|
17
|
-
}
|
|
17
|
+
});
|
|
18
18
|
}
|
|
19
19
|
/** Stable compact frame, progressively enhanced with mountGuideMap(). No browser globals. */
|
|
20
20
|
export function createGuideShell(options = {}) {
|
package/dist/src/map-core.d.ts
CHANGED
|
@@ -80,6 +80,20 @@ export declare function getLayerPathsWithData(options: SFMapOptions, data: SFMap
|
|
|
80
80
|
point: [number, number];
|
|
81
81
|
}[];
|
|
82
82
|
};
|
|
83
|
+
export declare function resolveMapColors(theme: SFMapOptions['theme'], colors?: SFMapOptions['colors']): {
|
|
84
|
+
water: string;
|
|
85
|
+
land: string;
|
|
86
|
+
district: string;
|
|
87
|
+
neighborhood: string;
|
|
88
|
+
highway: string;
|
|
89
|
+
road: string;
|
|
90
|
+
park: string;
|
|
91
|
+
landmark: string;
|
|
92
|
+
bart: string;
|
|
93
|
+
label: string;
|
|
94
|
+
marker: string;
|
|
95
|
+
selected: string;
|
|
96
|
+
};
|
|
83
97
|
/** Make an offline SVG and the matching longitude/latitude projection. */
|
|
84
98
|
export declare function createSFMapWithData(options: SFMapOptions, data: SFMapData): {
|
|
85
99
|
svg: string;
|
package/dist/src/map-core.js
CHANGED
|
@@ -104,6 +104,9 @@ const transitColors = {
|
|
|
104
104
|
marker: '#0073ae',
|
|
105
105
|
selected: '#0073ae',
|
|
106
106
|
};
|
|
107
|
+
export function resolveMapColors(theme, colors = {}) {
|
|
108
|
+
return { ...defaults, ...(theme === 'transit' ? transitColors : {}), ...colors };
|
|
109
|
+
}
|
|
107
110
|
let sequence = 0;
|
|
108
111
|
/** Make an offline SVG and the matching longitude/latitude projection. */
|
|
109
112
|
export function createSFMapWithData(options, data) {
|
|
@@ -115,7 +118,7 @@ export function createSFMapWithData(options, data) {
|
|
|
115
118
|
throw new TypeError('idPrefix must start with a letter and contain only letters, numbers, underscores, or hyphens.');
|
|
116
119
|
if (theme !== 'districts' && theme !== 'transit')
|
|
117
120
|
throw new TypeError('Theme must be districts or transit.');
|
|
118
|
-
const colors =
|
|
121
|
+
const colors = resolveMapColors(theme, options.colors);
|
|
119
122
|
const animation = normalizeStaticAnimation(options.animation);
|
|
120
123
|
const animated = animation !== null;
|
|
121
124
|
const geometry = getLayerPathsWithData(options, data, false);
|
package/dist/src/map.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { copyAppearance } from './appearance.js';
|
|
1
2
|
import { validateCameraOptions } from './camera.js';
|
|
2
3
|
import { expandMapOptions, prepareConfiguration } from './configuration.js';
|
|
3
4
|
import { createNeighborhoodExplorerCore } from './explorer-core.js';
|
|
@@ -6,10 +7,11 @@ import { assertOptions } from './validation.js';
|
|
|
6
7
|
/** No geography is imported. Supply a preset or your own immutable data. */
|
|
7
8
|
export function createMap(data, options = {}) {
|
|
8
9
|
const expanded = expandMapOptions(options);
|
|
9
|
-
let config = prepareConfiguration({ features: {}, layers: {}, controls: {} }, {
|
|
10
|
+
let config = prepareConfiguration({ features: {}, layers: {}, controls: {}, appearance: {} }, {
|
|
10
11
|
features: options.features,
|
|
11
12
|
layers: options.layers,
|
|
12
13
|
controls: options.controls,
|
|
14
|
+
appearance: options.appearance,
|
|
13
15
|
});
|
|
14
16
|
const element = createNeighborhoodExplorerCore({
|
|
15
17
|
mode: 'basemap',
|
|
@@ -18,6 +20,81 @@ export function createMap(data, options = {}) {
|
|
|
18
20
|
onOverlayActivate: () => { },
|
|
19
21
|
}, data);
|
|
20
22
|
const subscriptions = new Set();
|
|
23
|
+
let configurationRevision = 0;
|
|
24
|
+
const sourceList = ['realtor', 'sf-find', 'analysis'].filter((source) => !!data.neighborhoods[source]);
|
|
25
|
+
const yearList = [2002, 2012, 2022].filter((year) => !!data.map.districts?.[year] && !!data.districts?.[year]);
|
|
26
|
+
const capabilities = () => ({
|
|
27
|
+
sources: [...sourceList],
|
|
28
|
+
years: [...yearList],
|
|
29
|
+
layers: Object.fromEntries(layerKeys.map((key) => [
|
|
30
|
+
key,
|
|
31
|
+
key.startsWith('district')
|
|
32
|
+
? !!data.map.districts?.[element.getMapState().year]?.length &&
|
|
33
|
+
yearList.includes(element.getMapState().year)
|
|
34
|
+
: key.startsWith('neighborhood')
|
|
35
|
+
? !!data.neighborhoods[element.getMapState().source]?.features.length
|
|
36
|
+
: key === 'roadLabels'
|
|
37
|
+
? !!data.map.keyRoads?.length
|
|
38
|
+
: !!data.map[key]?.length,
|
|
39
|
+
])),
|
|
40
|
+
});
|
|
41
|
+
const snapshot = () => ({
|
|
42
|
+
...element.getMapState(),
|
|
43
|
+
...structuredClone({
|
|
44
|
+
features: config.features,
|
|
45
|
+
layers: config.layers,
|
|
46
|
+
controls: config.controls,
|
|
47
|
+
}),
|
|
48
|
+
appearance: copyAppearance(config.appearance),
|
|
49
|
+
});
|
|
50
|
+
const resolved = () => {
|
|
51
|
+
const state = element.getMapState();
|
|
52
|
+
const supplied = capabilities().layers;
|
|
53
|
+
return {
|
|
54
|
+
...snapshot(),
|
|
55
|
+
...state,
|
|
56
|
+
layers: Object.fromEntries(layerKeys.map((key) => [
|
|
57
|
+
key,
|
|
58
|
+
supplied[key] &&
|
|
59
|
+
(!key.endsWith('Labels') || state.labels) &&
|
|
60
|
+
(config.layers[key] ??
|
|
61
|
+
(key.startsWith('district')
|
|
62
|
+
? state.mode === 'districts'
|
|
63
|
+
: key.startsWith('neighborhood')
|
|
64
|
+
? state.mode === 'neighborhoods'
|
|
65
|
+
: true)),
|
|
66
|
+
])),
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
let previousMarker = element.getSelectedMarker();
|
|
70
|
+
let previousNeighborhood = element.getSelection();
|
|
71
|
+
let previousDistrict = element.getSelectedDistrict();
|
|
72
|
+
const selectionListeners = [];
|
|
73
|
+
for (const type of ['markerchange', 'neighborhoodchange', 'districtchange']) {
|
|
74
|
+
const handle = (event) => {
|
|
75
|
+
if (event.target !== element)
|
|
76
|
+
return;
|
|
77
|
+
let detail;
|
|
78
|
+
if (type === 'markerchange') {
|
|
79
|
+
const current = element.getSelectedMarker();
|
|
80
|
+
detail = { kind: 'marker', current, previous: previousMarker };
|
|
81
|
+
previousMarker = current;
|
|
82
|
+
}
|
|
83
|
+
else if (type === 'neighborhoodchange') {
|
|
84
|
+
const current = element.getSelection();
|
|
85
|
+
detail = { kind: 'neighborhood', current, previous: previousNeighborhood };
|
|
86
|
+
previousNeighborhood = current;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const current = element.getSelectedDistrict();
|
|
90
|
+
detail = { kind: 'district', current, previous: previousDistrict };
|
|
91
|
+
previousDistrict = current;
|
|
92
|
+
}
|
|
93
|
+
element.dispatchEvent(new CustomEvent('selectionchange', { detail: structuredClone(detail) }));
|
|
94
|
+
};
|
|
95
|
+
element.addEventListener(type, handle);
|
|
96
|
+
selectionListeners.push(() => element.removeEventListener(type, handle));
|
|
97
|
+
}
|
|
21
98
|
let destroyed = false;
|
|
22
99
|
function active() {
|
|
23
100
|
if (destroyed)
|
|
@@ -41,6 +118,38 @@ export function createMap(data, options = {}) {
|
|
|
41
118
|
element.fitGeometry(geometry, padding, motion);
|
|
42
119
|
}),
|
|
43
120
|
});
|
|
121
|
+
function configure(patch) {
|
|
122
|
+
const revision = ++configurationRevision;
|
|
123
|
+
const next = prepareConfiguration(config, patch);
|
|
124
|
+
const hasFeatures = 'features' in patch, hasLayers = 'layers' in patch, hasControls = 'controls' in patch;
|
|
125
|
+
const layers = Object.fromEntries(layerKeys.map((key) => [key, next.layers[key]]));
|
|
126
|
+
const controls = Object.fromEntries(controlKeys.map((key) => [key, next.controls[key]]));
|
|
127
|
+
const features = patch.features === undefined
|
|
128
|
+
? next.features
|
|
129
|
+
: Object.fromEntries(Object.keys(patch.features).map((key) => [
|
|
130
|
+
key,
|
|
131
|
+
next.features[key],
|
|
132
|
+
]));
|
|
133
|
+
let committed = false;
|
|
134
|
+
element.applyPresentation({ ...patch, ...('appearance' in patch ? { appearance: next.appearance } : {}) }, () => {
|
|
135
|
+
committed = true;
|
|
136
|
+
config = next;
|
|
137
|
+
if (hasFeatures)
|
|
138
|
+
element.setFeatures(features);
|
|
139
|
+
if (hasLayers)
|
|
140
|
+
element.setLayers(layers);
|
|
141
|
+
if (hasControls)
|
|
142
|
+
element.setControls(controls);
|
|
143
|
+
}, () => !destroyed && revision === configurationRevision);
|
|
144
|
+
return committed && !destroyed && revision === configurationRevision;
|
|
145
|
+
}
|
|
146
|
+
function updateView(patch, options = {}) {
|
|
147
|
+
assertOptions(options, 'view update', ['resetView']);
|
|
148
|
+
if (options.resetView !== undefined && typeof options.resetView !== 'boolean')
|
|
149
|
+
throw new TypeError('resetView must be a boolean.');
|
|
150
|
+
if (configure(patch) && options.resetView !== false)
|
|
151
|
+
element.resetView();
|
|
152
|
+
}
|
|
44
153
|
return Object.freeze({
|
|
45
154
|
element,
|
|
46
155
|
overlayElement: element.overlayElement,
|
|
@@ -49,21 +158,14 @@ export function createMap(data, options = {}) {
|
|
|
49
158
|
return destroyed;
|
|
50
159
|
},
|
|
51
160
|
configure: use((patch) => {
|
|
52
|
-
|
|
53
|
-
// Full switch patches are needed so removed overrides reset engine defaults.
|
|
54
|
-
const layers = Object.fromEntries(layerKeys.map((key) => [key, next.layers[key]]));
|
|
55
|
-
const controls = Object.fromEntries(controlKeys.map((key) => [key, next.controls[key]]));
|
|
56
|
-
if ('features' in patch)
|
|
57
|
-
element.setFeatures(patch.features ?? next.features);
|
|
58
|
-
if ('layers' in patch)
|
|
59
|
-
element.setLayers(layers);
|
|
60
|
-
if ('controls' in patch)
|
|
61
|
-
element.setControls(controls);
|
|
62
|
-
config = next;
|
|
161
|
+
configure(patch);
|
|
63
162
|
}),
|
|
64
|
-
getConfiguration: use(
|
|
163
|
+
getConfiguration: use(snapshot),
|
|
164
|
+
getResolvedConfiguration: use(resolved),
|
|
165
|
+
getCapabilities: use(capabilities),
|
|
65
166
|
on: use((type, listener) => {
|
|
66
167
|
if (![
|
|
168
|
+
'selectionchange',
|
|
67
169
|
'markerchange',
|
|
68
170
|
'districtchange',
|
|
69
171
|
'districthover',
|
|
@@ -91,17 +193,59 @@ export function createMap(data, options = {}) {
|
|
|
91
193
|
}),
|
|
92
194
|
setMarkers: use(element.setMarkers),
|
|
93
195
|
setOverlays: use(element.setOverlays),
|
|
196
|
+
selectFeature: use((reference, options) => {
|
|
197
|
+
assertOptions(reference, 'feature reference');
|
|
198
|
+
if (!['marker', 'neighborhood', 'district'].includes(reference.kind))
|
|
199
|
+
throw new TypeError('Unknown feature kind.');
|
|
200
|
+
assertOptions(reference, 'feature reference', reference.kind === 'marker'
|
|
201
|
+
? ['kind', 'id']
|
|
202
|
+
: reference.kind === 'neighborhood'
|
|
203
|
+
? ['kind', 'id', 'source']
|
|
204
|
+
: ['kind', 'id', 'year']);
|
|
205
|
+
if (reference.kind === 'district') {
|
|
206
|
+
if (reference.id !== null &&
|
|
207
|
+
(typeof reference.id !== 'number' || !Number.isFinite(reference.id)))
|
|
208
|
+
throw new TypeError('District IDs must be finite numbers or null.');
|
|
209
|
+
if (![2002, 2012, 2022].includes(reference.year))
|
|
210
|
+
throw new RangeError('Unknown district year.');
|
|
211
|
+
if (reference.year !== element.getMapState().year || !yearList.includes(reference.year))
|
|
212
|
+
return false;
|
|
213
|
+
return element.selectDistrict(reference.id, options);
|
|
214
|
+
}
|
|
215
|
+
if (reference.id !== null && (typeof reference.id !== 'string' || !reference.id))
|
|
216
|
+
throw new TypeError('Feature IDs must be nonempty strings or null.');
|
|
217
|
+
if (reference.kind === 'marker')
|
|
218
|
+
return element.selectMarker(reference.id, options);
|
|
219
|
+
if (!['realtor', 'sf-find', 'analysis'].includes(reference.source))
|
|
220
|
+
throw new RangeError('Unknown neighborhood source.');
|
|
221
|
+
if (reference.source !== element.getMapState().source ||
|
|
222
|
+
!sourceList.includes(reference.source))
|
|
223
|
+
return false;
|
|
224
|
+
const feature = reference.id === null
|
|
225
|
+
? null
|
|
226
|
+
: data.neighborhoods[reference.source]?.features.find((entry) => entry.id === reference.id);
|
|
227
|
+
if (reference.id !== null && !feature)
|
|
228
|
+
return false;
|
|
229
|
+
return element.selectNeighborhood(feature?.id ?? null, options);
|
|
230
|
+
}),
|
|
94
231
|
selectMarker: use(element.selectMarker),
|
|
95
232
|
getSelectedMarker: use(element.getSelectedMarker),
|
|
96
233
|
selectNeighborhood: use(element.selectNeighborhood),
|
|
97
234
|
getSelectedNeighborhood: use(element.getSelection),
|
|
98
235
|
selectDistrict: use(element.selectDistrict),
|
|
99
236
|
getSelectedDistrict: use(element.getSelectedDistrict),
|
|
100
|
-
setDistrictYear: use(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
237
|
+
setDistrictYear: use((...args) => {
|
|
238
|
+
configurationRevision++;
|
|
239
|
+
element.setDistrictYear(...args);
|
|
240
|
+
}),
|
|
241
|
+
setDistrictStyle: use((style) => {
|
|
242
|
+
configure({ appearance: { districtStyle: style } });
|
|
243
|
+
}),
|
|
244
|
+
setSource: use((source, options) => updateView({ source }, options)),
|
|
245
|
+
setMode: use((mode, options) => updateView({ mode }, options)),
|
|
246
|
+
setLabels: use((labels) => {
|
|
247
|
+
configure({ labels });
|
|
248
|
+
}),
|
|
105
249
|
setTouchNavigation: use(element.setTouchNavigation),
|
|
106
250
|
projectToScreen: use(element.projectToScreen),
|
|
107
251
|
destroy() {
|
|
@@ -110,6 +254,8 @@ export function createMap(data, options = {}) {
|
|
|
110
254
|
destroyed = true;
|
|
111
255
|
for (const unsubscribe of subscriptions)
|
|
112
256
|
unsubscribe();
|
|
257
|
+
for (const unsubscribe of selectionListeners)
|
|
258
|
+
unsubscribe();
|
|
113
259
|
element.destroy();
|
|
114
260
|
},
|
|
115
261
|
});
|
package/dist/src/static.d.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
|
+
import type { InteractiveSFMapData } from './explorer-data.js';
|
|
1
2
|
import { type SFMapData } from './map-core.js';
|
|
2
3
|
import type { SFMapOptions } from './types.js';
|
|
4
|
+
/** Either compact static geography or the same source-aware geography used by createMap. */
|
|
5
|
+
export type StaticMapInput = SFMapData | InteractiveSFMapData;
|
|
3
6
|
export type { DistrictRowData, SFMapData as StaticMapData } from './map-core.js';
|
|
4
|
-
export type { DistrictStyle, DistrictYear, SFMapOptions as StaticMapOptions } from './types.js';
|
|
7
|
+
export type { DistrictStyle, DistrictYear, SFMapOptions as StaticMapOptions, StaticMapAppearance, StaticMapLayers, } from './types.js';
|
|
5
8
|
/** Server-safe SVG rendering with explicit data; returns SVG plus projection helpers. */
|
|
6
|
-
export declare function renderMap(data:
|
|
9
|
+
export declare function renderMap(data: StaticMapInput, options?: SFMapOptions): {
|
|
7
10
|
svg: string;
|
|
8
11
|
project: (coordinates: import("../data/types.js").Position) => [number, number];
|
|
9
12
|
viewBox: [number, number, number, number];
|
|
10
13
|
};
|
|
11
14
|
/** Project canonical layer geometry without constructing an SVG string. */
|
|
12
|
-
export declare function getLayerPaths(data:
|
|
15
|
+
export declare function getLayerPaths(data: StaticMapInput, options?: SFMapOptions): {
|
|
13
16
|
year: import("./types.js").DistrictYear;
|
|
14
17
|
viewBox: [number, number, number, number];
|
|
15
18
|
project: (coordinates: import("../data/types.js").Position) => [number, number];
|