@kahwee/sf-map-svg 4.0.1 → 4.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 +36 -0
- package/README.md +9 -13
- package/dist/data/guide/coast.json +1 -1
- package/dist/data/guide/landmarks.json +1 -1
- package/dist/data/guide/neighborhoods-realtor.json +1 -1
- package/dist/data/stations.d.ts +2 -2
- package/dist/data/types.d.ts +8 -1
- package/dist/src/api.d.ts +1 -1
- package/dist/src/api.js +1 -1
- package/dist/src/appearance.d.ts +5 -0
- package/dist/src/appearance.js +54 -0
- package/dist/src/configuration.js +50 -25
- package/dist/src/controller-types.d.ts +53 -6
- package/dist/src/district-morph.d.ts +17 -0
- package/dist/src/district-morph.js +148 -0
- package/dist/src/explorer-core.d.ts +14 -2
- package/dist/src/explorer-core.js +233 -52
- package/dist/src/features.d.ts +6 -0
- package/dist/src/features.js +11 -1
- package/dist/src/guide-map.js +1 -1
- package/dist/src/guide-static.js +3 -3
- package/dist/src/layer-transition.d.ts +18 -0
- package/dist/src/layer-transition.js +102 -0
- package/dist/src/layers.d.ts +9 -7
- package/dist/src/layers.js +28 -18
- package/dist/src/map-core.d.ts +14 -0
- package/dist/src/map-core.js +11 -5
- package/dist/src/map.js +125 -20
- package/dist/src/overlays.d.ts +3 -4
- package/dist/src/overlays.js +5 -1
- package/dist/src/static-animation.d.ts +13 -0
- package/dist/src/static-animation.js +36 -0
- package/dist/src/static.d.ts +1 -1
- package/dist/src/static.js +31 -2
- package/dist/src/transit.js +2 -1
- package/dist/src/types.d.ts +33 -2
- package/dist/src/validation.js +2 -0
- package/docs/API.md +85 -0
- package/docs/api-audit.md +3 -1
- package/docs/consumer-integration.md +1 -1
- package/docs/developer-guide.md +139 -0
- package/docs/guide-bundle-report.md +4 -4
- package/llms-full.txt +1979 -0
- package/llms.txt +25 -0
- package/package.json +12 -5
|
@@ -1,6 +1,8 @@
|
|
|
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';
|
|
5
|
+
import { createDistrictMorph } from './district-morph.js';
|
|
4
6
|
import { prepareDistrictStyles } from './district-style.js';
|
|
5
7
|
import { createDistrictTransition } from './district-transition.js';
|
|
6
8
|
import { element, svgElement } from './dom.js';
|
|
@@ -9,7 +11,8 @@ import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from '
|
|
|
9
11
|
import { createFrameScheduler } from './frame-scheduler.js';
|
|
10
12
|
import { geometryPath } from './geometry.js';
|
|
11
13
|
import { createLabelRenderer } from './label-renderer.js';
|
|
12
|
-
import {
|
|
14
|
+
import { createLayerTransitions } from './layer-transition.js';
|
|
15
|
+
import { createSFMapWithData, districtColors, getLayerPathsWithData, resolveMapColors, } from './map-core.js';
|
|
13
16
|
import { createMarkerLayer } from './marker-layer.js';
|
|
14
17
|
import { attachNavigation } from './navigation.js';
|
|
15
18
|
import { validateExplorerOptions, validateMarkers, validateOverlays } from './validation.js';
|
|
@@ -30,12 +33,12 @@ let explorerCount = 0;
|
|
|
30
33
|
/** Create an offline, browser-only neighborhood explorer. Call destroy() before disposal. */
|
|
31
34
|
export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
32
35
|
validateExplorerOptions(options);
|
|
33
|
-
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;
|
|
36
|
+
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, layerTransitions = false, districtMorph: districtMorphOption = 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;
|
|
34
37
|
if (!['neighborhoods', 'districts', 'basemap'].includes(mode))
|
|
35
38
|
throw new RangeError(`Unknown map mode: ${mode}`);
|
|
36
39
|
if (chrome !== 'explorer' && chrome !== 'map')
|
|
37
40
|
throw new RangeError('Unknown interface.');
|
|
38
|
-
|
|
41
|
+
let minLabelSize = labelSize.min ?? 11, maxLabelSize = labelSize.max ?? 12;
|
|
39
42
|
if (![minLabelSize, maxLabelSize, markerRadius, markerHitSize].every(Number.isFinite) ||
|
|
40
43
|
minLabelSize < 8 ||
|
|
41
44
|
maxLabelSize < minLabelSize ||
|
|
@@ -50,6 +53,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
50
53
|
selectedMarkerRing,
|
|
51
54
|
northArrow,
|
|
52
55
|
scaleBar,
|
|
56
|
+
layerTransitions,
|
|
57
|
+
districtMorph: districtMorphOption,
|
|
53
58
|
});
|
|
54
59
|
if (labelStyle.fontWeight !== undefined &&
|
|
55
60
|
(!Number.isFinite(labelStyle.fontWeight) ||
|
|
@@ -435,6 +440,13 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
435
440
|
root.dispatchEvent(new CustomEvent('districtchange', { bubbles: true, detail: districtSelection(id) }));
|
|
436
441
|
return true;
|
|
437
442
|
}
|
|
443
|
+
function activateDistrict(id) {
|
|
444
|
+
const revision = districtRevision + 1;
|
|
445
|
+
if (!selectDistrict(id) || destroyed || revision !== districtRevision)
|
|
446
|
+
return false;
|
|
447
|
+
root.dispatchEvent(new CustomEvent('districtactivate', { bubbles: true, detail: districtSelection(id) }));
|
|
448
|
+
return !destroyed && revision === districtRevision;
|
|
449
|
+
}
|
|
438
450
|
function setDistrictStyle(style) {
|
|
439
451
|
if (destroyed)
|
|
440
452
|
return;
|
|
@@ -445,9 +457,15 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
445
457
|
districtRevision++;
|
|
446
458
|
districtStyle = style;
|
|
447
459
|
districtStyles = prepared;
|
|
460
|
+
const fills = districtLayers.find((layer) => layer.dataset.layer === 'district-fills');
|
|
461
|
+
const fade = layerFades.duration();
|
|
462
|
+
// A new style settles any fill crossfade still running from an earlier change.
|
|
463
|
+
districtTransition.cancel();
|
|
464
|
+
if (fills && fade && enabled('districtFills'))
|
|
465
|
+
districtTransition.fade(fills, fade);
|
|
448
466
|
updateDistrictAppearance();
|
|
449
467
|
}
|
|
450
|
-
function setDistrictYear(next, options = {}) {
|
|
468
|
+
function setDistrictYear(next, options = {}, preparedStyles) {
|
|
451
469
|
if (destroyed)
|
|
452
470
|
return;
|
|
453
471
|
validateCameraOptions(options);
|
|
@@ -458,15 +476,35 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
458
476
|
if (next === year)
|
|
459
477
|
return;
|
|
460
478
|
const revision = districtRevision;
|
|
461
|
-
const prepared = prepareDistrictStyles(data.map.districts[next], districtStyle);
|
|
479
|
+
const prepared = preparedStyles ?? prepareDistrictStyles(data.map.districts[next], districtStyle);
|
|
462
480
|
if (destroyed || revision !== districtRevision)
|
|
463
481
|
return;
|
|
464
482
|
const committedRevision = ++districtRevision;
|
|
465
483
|
const geometry = getLayerPathsWithData({ year: next }, data.map, false);
|
|
466
484
|
const previousYear = year;
|
|
467
485
|
districtTransition.cancel();
|
|
486
|
+
districtMorph.stop();
|
|
487
|
+
const lineLayer = districtLayers.find((layer) => layer.dataset.layer === 'district-lines');
|
|
488
|
+
const morph = features.districtMorph &&
|
|
489
|
+
options.animate !== false &&
|
|
490
|
+
initialized &&
|
|
491
|
+
!reducedMotion.matches &&
|
|
492
|
+
(enabled('districtLines') || enabled('districtFills'))
|
|
493
|
+
? features.districtMorph
|
|
494
|
+
: false;
|
|
495
|
+
const previousOutlines = morph && lineLayer
|
|
496
|
+
? new Map([...lineLayer.querySelectorAll('path[data-district]')].map((path) => [
|
|
497
|
+
path.dataset.district ?? '',
|
|
498
|
+
path.getAttribute('d') ?? '',
|
|
499
|
+
]))
|
|
500
|
+
: undefined;
|
|
468
501
|
for (const layer of districtLayers) {
|
|
469
|
-
if (
|
|
502
|
+
if (morph) {
|
|
503
|
+
// The morph replaces the line crossfade; fills fade across the whole morph.
|
|
504
|
+
if (layer !== lineLayer)
|
|
505
|
+
districtTransition.fade(layer, morph.duration);
|
|
506
|
+
}
|
|
507
|
+
else if (options.animate && !reducedMotion.matches)
|
|
470
508
|
districtTransition.fade(layer, options.duration ?? 280);
|
|
471
509
|
layer.replaceChildren(...geometry.districts.map((district) => svgElement('path', {
|
|
472
510
|
'data-district': district.id,
|
|
@@ -492,6 +530,11 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
492
530
|
delete root.dataset.selectedDistrict;
|
|
493
531
|
updateDistrictAppearance();
|
|
494
532
|
updateComposition();
|
|
533
|
+
if (morph && lineLayer && previousOutlines)
|
|
534
|
+
districtMorph.start(lineLayer, previousOutlines, {
|
|
535
|
+
duration: morph.duration,
|
|
536
|
+
stroke: colors.district ?? '#71838a',
|
|
537
|
+
});
|
|
495
538
|
scheduleLabels();
|
|
496
539
|
status.textContent = `${year} supervisorial districts.`;
|
|
497
540
|
root.dispatchEvent(new CustomEvent('districtyearchange', { bubbles: true, detail: { year, previousYear } }));
|
|
@@ -505,6 +548,10 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
505
548
|
let items = [];
|
|
506
549
|
let destroyed = false;
|
|
507
550
|
let initialized = false;
|
|
551
|
+
const layerFades = createLayerTransitions(() => initialized && features.layerTransitions && !reducedMotion.matches
|
|
552
|
+
? features.layerTransitions.duration
|
|
553
|
+
: 0);
|
|
554
|
+
const districtMorph = createDistrictMorph();
|
|
508
555
|
const frames = createFrameScheduler({
|
|
509
556
|
request: (callback) => requestAnimationFrame(callback),
|
|
510
557
|
cancel: (id) => cancelAnimationFrame(id),
|
|
@@ -537,6 +584,12 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
537
584
|
stopAnimation();
|
|
538
585
|
if (Object.hasOwn(patch, 'markerEntrance'))
|
|
539
586
|
cancelEntrances();
|
|
587
|
+
if (Object.hasOwn(patch, 'layerTransitions')) {
|
|
588
|
+
layerFades.cancel();
|
|
589
|
+
districtTransition.cancel();
|
|
590
|
+
}
|
|
591
|
+
if (Object.hasOwn(patch, 'districtMorph'))
|
|
592
|
+
districtMorph.stop();
|
|
540
593
|
if (Object.hasOwn(patch, 'clustering'))
|
|
541
594
|
invalidateClusters();
|
|
542
595
|
syncFurniture();
|
|
@@ -1066,9 +1119,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1066
1119
|
moveView(target, options);
|
|
1067
1120
|
return true;
|
|
1068
1121
|
}
|
|
1069
|
-
function
|
|
1070
|
-
if (destroyed)
|
|
1071
|
-
return;
|
|
1122
|
+
function prepareSource(next, reset = true) {
|
|
1072
1123
|
if (!Object.hasOwn(collections, next))
|
|
1073
1124
|
throw new RangeError(`Unknown neighborhood source: ${next}`);
|
|
1074
1125
|
const collection = collections[next];
|
|
@@ -1139,36 +1190,42 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1139
1190
|
area: (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]),
|
|
1140
1191
|
};
|
|
1141
1192
|
});
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
items[0]
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1193
|
+
return () => {
|
|
1194
|
+
const hadSelection = !!selected;
|
|
1195
|
+
source = next;
|
|
1196
|
+
sourceSelect.value = source;
|
|
1197
|
+
selected = undefined;
|
|
1198
|
+
const revision = ++neighborhoodRevision;
|
|
1199
|
+
delete root.dataset.selectedNeighborhood;
|
|
1200
|
+
root.dataset.source = source;
|
|
1201
|
+
layerFades.crossfade(areas, () => areas.replaceChildren(...nextItems.map((item) => item.node)));
|
|
1202
|
+
neighborhoodSelect.replaceChildren(element('option', 'No neighborhood selected'));
|
|
1203
|
+
neighborhoodSelect.options[0].value = '';
|
|
1204
|
+
neighborhoodSelect.append(...nextOptions);
|
|
1205
|
+
items = nextItems;
|
|
1206
|
+
if (items[0])
|
|
1207
|
+
items[0].node.setAttribute('tabindex', '0');
|
|
1208
|
+
updateResults();
|
|
1209
|
+
updateDetail();
|
|
1210
|
+
updateComposition();
|
|
1211
|
+
status.textContent =
|
|
1212
|
+
mode === 'basemap'
|
|
1213
|
+
? 'San Francisco basemap.'
|
|
1214
|
+
: mode === 'districts'
|
|
1215
|
+
? `${year} supervisorial districts. Numbers identify each district.`
|
|
1216
|
+
: `${formatSourceLabel(source)}. Select a neighborhood to begin.`;
|
|
1217
|
+
if (hadSelection)
|
|
1218
|
+
root.dispatchEvent(new CustomEvent('neighborhoodchange', {
|
|
1219
|
+
bubbles: true,
|
|
1220
|
+
detail: { feature: null, source, id: null, name: null },
|
|
1221
|
+
}));
|
|
1222
|
+
if (reset && !destroyed && neighborhoodRevision === revision)
|
|
1223
|
+
resetView();
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
function setSource(next) {
|
|
1227
|
+
if (!destroyed)
|
|
1228
|
+
prepareSource(next)();
|
|
1172
1229
|
}
|
|
1173
1230
|
function syncFeatureControls() {
|
|
1174
1231
|
featureControls.hidden = neighborhoodLabel.hidden && markerLabel.hidden;
|
|
@@ -1189,9 +1246,9 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1189
1246
|
]) {
|
|
1190
1247
|
const layer = svg.querySelector(`[data-layer="${name}"]`);
|
|
1191
1248
|
if (layer)
|
|
1192
|
-
layer
|
|
1249
|
+
layerFades.set(layer, enabled(key));
|
|
1193
1250
|
}
|
|
1194
|
-
stations
|
|
1251
|
+
layerFades.set(stations, enabled('bartStations'));
|
|
1195
1252
|
for (const [kind, key] of [
|
|
1196
1253
|
['bart', 'bartStations'],
|
|
1197
1254
|
['park', 'landmarks'],
|
|
@@ -1206,7 +1263,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1206
1263
|
controls.legend === false ||
|
|
1207
1264
|
![...legend.children].some((node) => !node.hidden);
|
|
1208
1265
|
const neighborhoodsVisible = enabled('neighborhoodLines') || (labels && enabled('neighborhoodLabels'));
|
|
1209
|
-
areas
|
|
1266
|
+
layerFades.set(areas, neighborhoodsVisible);
|
|
1210
1267
|
areas.style.pointerEvents = selectableNeighborhoods ? '' : 'none';
|
|
1211
1268
|
neighborhoodLabel.hidden =
|
|
1212
1269
|
!neighborhoodsVisible ||
|
|
@@ -1223,7 +1280,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1223
1280
|
}
|
|
1224
1281
|
for (const layer of districtLayers) {
|
|
1225
1282
|
const key = layer.dataset.layer === 'district-fills' ? 'districtFills' : 'districtLines';
|
|
1226
|
-
layer
|
|
1283
|
+
layerFades.set(layer, enabled(key));
|
|
1227
1284
|
}
|
|
1228
1285
|
updateDistrictAppearance();
|
|
1229
1286
|
const districtsVisible = enabled('districtFills') || enabled('districtLines') || (labels && enabled('districtLabels'));
|
|
@@ -1512,12 +1569,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1512
1569
|
districtRow(districtId)) {
|
|
1513
1570
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
1514
1571
|
event.preventDefault();
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
bubbles: true,
|
|
1518
|
-
detail: districtSelection(districtId),
|
|
1519
|
-
}));
|
|
1520
|
-
target.focus();
|
|
1572
|
+
if (activateDistrict(districtId))
|
|
1573
|
+
target.focus();
|
|
1521
1574
|
}
|
|
1522
1575
|
else if (event.key === '[' || event.key === ']') {
|
|
1523
1576
|
event.preventDefault();
|
|
@@ -1568,9 +1621,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1568
1621
|
? event.target.closest('[data-layer="district-fills"] [data-district], [data-layer="district-lines"] [data-district]')
|
|
1569
1622
|
: null;
|
|
1570
1623
|
if (district?.dataset.district) {
|
|
1571
|
-
|
|
1572
|
-
selectDistrict(id);
|
|
1573
|
-
root.dispatchEvent(new CustomEvent('districtactivate', { bubbles: true, detail: districtSelection(id) }));
|
|
1624
|
+
activateDistrict(Number(district.dataset.district));
|
|
1574
1625
|
return;
|
|
1575
1626
|
}
|
|
1576
1627
|
const node = event.target instanceof Element
|
|
@@ -1607,7 +1658,133 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1607
1658
|
scheduleLabels();
|
|
1608
1659
|
root.dispatchEvent(new CustomEvent('mapresize', { bubbles: true }));
|
|
1609
1660
|
};
|
|
1661
|
+
let presentationRevision = 0;
|
|
1662
|
+
function applyPresentation(patch, onCommit, isCurrent = () => true) {
|
|
1663
|
+
const revision = ++presentationRevision;
|
|
1664
|
+
const nextYear = patch.year ?? year;
|
|
1665
|
+
const nextMode = patch.mode ?? mode;
|
|
1666
|
+
if ((patch.year !== undefined || nextMode === 'districts') &&
|
|
1667
|
+
(!data.map.districts?.[nextYear] || !data.districts?.[nextYear]))
|
|
1668
|
+
throw new RangeError(`No ${nextYear} district dataset was supplied.`);
|
|
1669
|
+
if (nextMode === 'neighborhoods' && !sources.length)
|
|
1670
|
+
throw new RangeError('No neighborhood dataset was supplied.');
|
|
1671
|
+
const commitSource = patch.source !== undefined && patch.source !== source
|
|
1672
|
+
? prepareSource(patch.source, false)
|
|
1673
|
+
: undefined;
|
|
1674
|
+
const appearance = patch.appearance;
|
|
1675
|
+
const nextStyle = appearance ? appearance.districtStyle : districtStyle;
|
|
1676
|
+
const districtVersion = districtRevision;
|
|
1677
|
+
const prepared = appearance || nextYear !== year
|
|
1678
|
+
? prepareDistrictStyles(data.map.districts?.[nextYear] ?? [], nextStyle)
|
|
1679
|
+
: districtStyles;
|
|
1680
|
+
if (destroyed ||
|
|
1681
|
+
!isCurrent() ||
|
|
1682
|
+
revision !== presentationRevision ||
|
|
1683
|
+
districtVersion !== districtRevision)
|
|
1684
|
+
return false;
|
|
1685
|
+
if (nextYear !== year) {
|
|
1686
|
+
// Validate target paths and label coordinates before any presentation group commits.
|
|
1687
|
+
getLayerPathsWithData({ year: nextYear }, data.map, false);
|
|
1688
|
+
for (const feature of data.districts?.[nextYear]?.features ?? [])
|
|
1689
|
+
for (const point of feature.properties.labelPoints)
|
|
1690
|
+
map.project(point);
|
|
1691
|
+
}
|
|
1692
|
+
onCommit?.();
|
|
1693
|
+
if (appearance) {
|
|
1694
|
+
districtRevision++;
|
|
1695
|
+
const next = copyAppearance(appearance);
|
|
1696
|
+
theme = next.theme ?? 'transit';
|
|
1697
|
+
colors = next.colors ?? {};
|
|
1698
|
+
labelStyle = next.labelStyle ?? {};
|
|
1699
|
+
areaStyle = next.areaStyle ?? {};
|
|
1700
|
+
styleOptions = next.style ?? {};
|
|
1701
|
+
minLabelSize = next.labelSize?.min ?? 11;
|
|
1702
|
+
maxLabelSize = next.labelSize?.max ?? 12;
|
|
1703
|
+
markerRadius = next.markerRadius ?? 6;
|
|
1704
|
+
markerHitSize = next.markerHitSize ?? 44;
|
|
1705
|
+
markerColor = next.markerColor ?? colors.marker ?? '#245b61';
|
|
1706
|
+
selectedMarkerColor = next.selectedMarkerColor ?? colors.selected ?? '#f04f32';
|
|
1707
|
+
districtTransition.cancel();
|
|
1708
|
+
const fills = districtLayers.find((layer) => layer.dataset.layer === 'district-fills');
|
|
1709
|
+
const fade = layerFades.duration();
|
|
1710
|
+
const stylesChanged = nextStyle !== districtStyle ||
|
|
1711
|
+
[...prepared].some(([id, value]) => ['fill', 'stroke', 'opacity'].some((key) => value[key] !== districtStyles.get(id)?.[key]));
|
|
1712
|
+
if (fills && fade && enabled('districtFills') && nextYear === year && stylesChanged)
|
|
1713
|
+
districtTransition.fade(fills, fade);
|
|
1714
|
+
districtStyle = nextStyle;
|
|
1715
|
+
districtStyles = prepared;
|
|
1716
|
+
const palette = resolveMapColors(theme, colors);
|
|
1717
|
+
svg.querySelector('rect')?.setAttribute('fill', palette.water);
|
|
1718
|
+
svg.querySelector('[data-layer="coast"]')?.setAttribute('fill', palette.land);
|
|
1719
|
+
svg.querySelector('[data-layer="coastline"]')?.setAttribute('stroke', palette.district);
|
|
1720
|
+
for (const [layer, color] of [
|
|
1721
|
+
['landmarks', palette.park],
|
|
1722
|
+
['highways', palette.highway],
|
|
1723
|
+
['key-roads', palette.road],
|
|
1724
|
+
])
|
|
1725
|
+
for (const node of svg.querySelectorAll(`[data-layer="${layer}"] path`)) {
|
|
1726
|
+
node.setAttribute(layer === 'landmarks' ? 'fill' : 'stroke', color);
|
|
1727
|
+
if (layer === 'highways')
|
|
1728
|
+
node.setAttribute('stroke-width', theme === 'transit' ? '2' : '1.4');
|
|
1729
|
+
}
|
|
1730
|
+
for (const [token, fallback, name] of [
|
|
1731
|
+
['ink', '#18364f', 'ink'],
|
|
1732
|
+
['surface', '#fff', 'surface'],
|
|
1733
|
+
['accent', '#163d61', 'accent'],
|
|
1734
|
+
['border', '#cedae3', 'border'],
|
|
1735
|
+
['focus', '#1676b8', 'focus'],
|
|
1736
|
+
['controlGap', '6px', 'control-gap'],
|
|
1737
|
+
['font', 'system-ui,sans-serif', 'font'],
|
|
1738
|
+
])
|
|
1739
|
+
root.style.setProperty(`--sf-map-${name}`, styleOptions[token] ?? fallback);
|
|
1740
|
+
labelLayer.setAttribute('font-family', labelStyle.fontFamily ?? styleOptions.font ?? 'system-ui,sans-serif');
|
|
1741
|
+
labelRenderer.invalidateMetrics();
|
|
1742
|
+
for (const station of stationItems)
|
|
1743
|
+
station.node.setAttribute('stroke', colors.bart ?? '#0073ae');
|
|
1744
|
+
for (const kind of ['bart', 'park', 'highway', 'road']) {
|
|
1745
|
+
const symbol = legend.querySelector(`.sf-explorer-legend-${kind}`);
|
|
1746
|
+
if (symbol) {
|
|
1747
|
+
symbol.style.removeProperty(kind === 'bart' ? 'border-color' : 'background-color');
|
|
1748
|
+
if (colors[kind])
|
|
1749
|
+
symbol.style.setProperty(kind === 'bart' ? 'border-color' : 'background-color', colors[kind]);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
for (const item of items) {
|
|
1753
|
+
const active = item.feature === selected;
|
|
1754
|
+
item.node.setAttribute('fill', active ? (areaStyle.selectedFill ?? '#408dbe') : 'transparent');
|
|
1755
|
+
item.node.setAttribute('stroke', active ? (areaStyle.selectedStroke ?? '#176ba2') : (colors.neighborhood ?? '#9caebc'));
|
|
1756
|
+
}
|
|
1757
|
+
for (const item of markerItems) {
|
|
1758
|
+
item.dot.setAttribute('fill', item.marker.id === selectedMarker
|
|
1759
|
+
? selectedMarkerColor
|
|
1760
|
+
: (item.marker.color ?? markerColor));
|
|
1761
|
+
item.ring.setAttribute('stroke', features.selectedMarkerRing
|
|
1762
|
+
? (features.selectedMarkerRing.color ?? selectedMarkerColor)
|
|
1763
|
+
: selectedMarkerColor);
|
|
1764
|
+
}
|
|
1765
|
+
invalidateClusters();
|
|
1766
|
+
updateDistrictAppearance();
|
|
1767
|
+
}
|
|
1768
|
+
if (nextYear !== year)
|
|
1769
|
+
setDistrictYear(nextYear, {}, prepared);
|
|
1770
|
+
if (destroyed || revision !== presentationRevision || !isCurrent())
|
|
1771
|
+
return false;
|
|
1772
|
+
commitSource?.();
|
|
1773
|
+
if (destroyed || revision !== presentationRevision || !isCurrent())
|
|
1774
|
+
return false;
|
|
1775
|
+
if (commitSource && appearance)
|
|
1776
|
+
for (const item of items)
|
|
1777
|
+
item.node.setAttribute('stroke', colors.neighborhood ?? '#9caebc');
|
|
1778
|
+
if (patch.mode !== undefined)
|
|
1779
|
+
setMode(patch.mode, false);
|
|
1780
|
+
if (patch.labels !== undefined)
|
|
1781
|
+
setLabels(patch.labels);
|
|
1782
|
+
scheduleLabels();
|
|
1783
|
+
return true;
|
|
1784
|
+
}
|
|
1610
1785
|
const explorer = Object.assign(root, {
|
|
1786
|
+
applyPresentation,
|
|
1787
|
+
getMapState: () => ({ mode, source, year, labels }),
|
|
1611
1788
|
selectNeighborhood,
|
|
1612
1789
|
getSelection,
|
|
1613
1790
|
selectDistrict,
|
|
@@ -1639,6 +1816,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1639
1816
|
destroyed = true;
|
|
1640
1817
|
camera.destroy();
|
|
1641
1818
|
districtTransition.cancel();
|
|
1819
|
+
layerFades.cancel();
|
|
1820
|
+
districtMorph.stop();
|
|
1642
1821
|
cancelEntrances();
|
|
1643
1822
|
controller.abort();
|
|
1644
1823
|
overlayEvents.abort();
|
|
@@ -1661,6 +1840,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1661
1840
|
if (reducedMotion.matches) {
|
|
1662
1841
|
stopAnimation();
|
|
1663
1842
|
districtTransition.cancel();
|
|
1843
|
+
layerFades.cancel();
|
|
1844
|
+
districtMorph.stop();
|
|
1664
1845
|
cancelEntrances();
|
|
1665
1846
|
}
|
|
1666
1847
|
}, { signal: controller.signal });
|
package/dist/src/features.d.ts
CHANGED
|
@@ -18,6 +18,12 @@ export declare function normalizeFeatures(input?: MapFeatures): {
|
|
|
18
18
|
};
|
|
19
19
|
northArrow: boolean;
|
|
20
20
|
scaleBar: boolean;
|
|
21
|
+
layerTransitions: false | {
|
|
22
|
+
duration: number;
|
|
23
|
+
};
|
|
24
|
+
districtMorph: false | {
|
|
25
|
+
duration: number;
|
|
26
|
+
};
|
|
21
27
|
};
|
|
22
28
|
export declare const layerKeys: readonly ['districtFills', 'districtLines', 'districtLabels', 'neighborhoodLines', 'neighborhoodLabels', 'landmarks', 'bartStations', 'highways', 'keyRoads', 'roadLabels'];
|
|
23
29
|
export declare const controlKeys: readonly ['zoom', 'pan', 'reset', 'labels', 'touch', 'legend', 'neighborhoodPicker', 'markerPicker', 'help', 'status'];
|
package/dist/src/features.js
CHANGED
|
@@ -4,7 +4,7 @@ function objectOption(value, name) {
|
|
|
4
4
|
return false;
|
|
5
5
|
if (value === true)
|
|
6
6
|
return {};
|
|
7
|
-
assertOptions(value, name, name === 'motion'
|
|
7
|
+
assertOptions(value, name, name === 'motion' || name === 'layerTransitions' || name === 'districtMorph'
|
|
8
8
|
? ['duration']
|
|
9
9
|
: name === 'markerEntrance'
|
|
10
10
|
? ['duration', 'stagger']
|
|
@@ -36,6 +36,8 @@ export function normalizeFeatures(input = {}) {
|
|
|
36
36
|
'selectedMarkerRing',
|
|
37
37
|
'northArrow',
|
|
38
38
|
'scaleBar',
|
|
39
|
+
'layerTransitions',
|
|
40
|
+
'districtMorph',
|
|
39
41
|
];
|
|
40
42
|
for (const key of Object.keys(input))
|
|
41
43
|
if (!keys.includes(key))
|
|
@@ -44,6 +46,8 @@ export function normalizeFeatures(input = {}) {
|
|
|
44
46
|
const entrance = objectOption(input.markerEntrance, 'markerEntrance');
|
|
45
47
|
const clustering = objectOption(input.clustering, 'clustering');
|
|
46
48
|
const ring = objectOption(input.selectedMarkerRing, 'selectedMarkerRing');
|
|
49
|
+
const layerTransitions = objectOption(input.layerTransitions, 'layerTransitions');
|
|
50
|
+
const districtMorph = objectOption(input.districtMorph, 'districtMorph');
|
|
47
51
|
if (ring && ring.color !== undefined && typeof ring.color !== 'string')
|
|
48
52
|
throw new TypeError('Ring color must be a string.');
|
|
49
53
|
return {
|
|
@@ -62,6 +66,12 @@ export function normalizeFeatures(input = {}) {
|
|
|
62
66
|
},
|
|
63
67
|
northArrow: flag(input.northArrow, 'northArrow'),
|
|
64
68
|
scaleBar: flag(input.scaleBar, 'scaleBar'),
|
|
69
|
+
layerTransitions: layerTransitions && {
|
|
70
|
+
duration: numberOption(layerTransitions.duration, 360, 'Layer transition duration'),
|
|
71
|
+
},
|
|
72
|
+
districtMorph: districtMorph && {
|
|
73
|
+
duration: numberOption(districtMorph.duration, 1100, 'District morph duration', true),
|
|
74
|
+
},
|
|
65
75
|
};
|
|
66
76
|
}
|
|
67
77
|
export const layerKeys = [
|
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 = {}) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opacity transitions for interactive layer switches and source swaps. `duration()` is read
|
|
3
|
+
* on every change, so reduced motion, construction, and runtime feature patches take effect
|
|
4
|
+
* immediately. All animations are owned here and settle synchronously on cancel.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createLayerTransitions(duration: () => number): {
|
|
7
|
+
/** The active transition duration in milliseconds, or 0 when transitions are off. */
|
|
8
|
+
duration: () => number;
|
|
9
|
+
/** Show or hide a layer, fading when a transition duration is active. */
|
|
10
|
+
set(node: SVGElement | HTMLElement, visible: boolean): void;
|
|
11
|
+
/**
|
|
12
|
+
* Replace a visible layer's contents while an inert copy of the old contents fades out
|
|
13
|
+
* above the new ones as they fade in.
|
|
14
|
+
*/
|
|
15
|
+
crossfade(node: SVGGElement, replace: () => void): void;
|
|
16
|
+
/** Jump every layer to its final state and remove all decorative copies. */
|
|
17
|
+
cancel(): void;
|
|
18
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** Remove interaction and accessibility hooks from a decorative copy of a layer. */
|
|
2
|
+
function makeInert(node) {
|
|
3
|
+
node.setAttribute('aria-hidden', 'true');
|
|
4
|
+
node.style.pointerEvents = 'none';
|
|
5
|
+
for (const item of [node, ...node.querySelectorAll('*')]) {
|
|
6
|
+
for (const name of ['id', 'tabindex', 'role', 'aria-label', 'aria-pressed'])
|
|
7
|
+
item.removeAttribute(name);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Opacity transitions for interactive layer switches and source swaps. `duration()` is read
|
|
12
|
+
* on every change, so reduced motion, construction, and runtime feature patches take effect
|
|
13
|
+
* immediately. All animations are owned here and settle synchronously on cancel.
|
|
14
|
+
*/
|
|
15
|
+
export function createLayerTransitions(duration) {
|
|
16
|
+
const fades = new Map();
|
|
17
|
+
const ghosts = new Set();
|
|
18
|
+
function settle(node) {
|
|
19
|
+
const running = fades.get(node);
|
|
20
|
+
if (!running)
|
|
21
|
+
return;
|
|
22
|
+
fades.delete(node);
|
|
23
|
+
running.animation.cancel();
|
|
24
|
+
if (running.hiding)
|
|
25
|
+
node.style.display = 'none';
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
/** The active transition duration in milliseconds, or 0 when transitions are off. */
|
|
29
|
+
duration,
|
|
30
|
+
/** Show or hide a layer, fading when a transition duration is active. */
|
|
31
|
+
set(node, visible) {
|
|
32
|
+
const running = fades.get(node);
|
|
33
|
+
const shown = node.style.display !== 'none' && !running?.hiding;
|
|
34
|
+
if (running) {
|
|
35
|
+
fades.delete(node);
|
|
36
|
+
running.animation.cancel();
|
|
37
|
+
}
|
|
38
|
+
const ms = duration();
|
|
39
|
+
if (!ms || visible === shown || typeof node.animate !== 'function') {
|
|
40
|
+
node.style.display = visible ? '' : 'none';
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
node.style.display = '';
|
|
44
|
+
const animation = node.animate(visible ? [{ opacity: 0 }, { opacity: 1 }] : [{ opacity: 1 }, { opacity: 0 }], { duration: ms, easing: visible ? 'ease-out' : 'ease-in', fill: 'forwards' });
|
|
45
|
+
const entry = { animation, hiding: !visible };
|
|
46
|
+
fades.set(node, entry);
|
|
47
|
+
animation.finished.then(() => {
|
|
48
|
+
if (fades.get(node) === entry)
|
|
49
|
+
settle(node);
|
|
50
|
+
}, () => { });
|
|
51
|
+
},
|
|
52
|
+
/**
|
|
53
|
+
* Replace a visible layer's contents while an inert copy of the old contents fades out
|
|
54
|
+
* above the new ones as they fade in.
|
|
55
|
+
*/
|
|
56
|
+
crossfade(node, replace) {
|
|
57
|
+
const ms = duration();
|
|
58
|
+
if (!ms || node.style.display === 'none' || typeof node.animate !== 'function') {
|
|
59
|
+
replace();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const ghost = node.cloneNode(true);
|
|
63
|
+
makeInert(ghost);
|
|
64
|
+
ghost.dataset.layerTransition = '';
|
|
65
|
+
replace();
|
|
66
|
+
node.after(ghost);
|
|
67
|
+
const fadeOut = ghost.animate([{ opacity: 1 }, { opacity: 0 }], {
|
|
68
|
+
duration: ms,
|
|
69
|
+
easing: 'ease-out',
|
|
70
|
+
fill: 'forwards',
|
|
71
|
+
});
|
|
72
|
+
const entry = { animation: fadeOut, node: ghost };
|
|
73
|
+
ghosts.add(entry);
|
|
74
|
+
const remove = () => {
|
|
75
|
+
ghosts.delete(entry);
|
|
76
|
+
ghost.remove();
|
|
77
|
+
};
|
|
78
|
+
fadeOut.finished.then(remove, remove);
|
|
79
|
+
settle(node);
|
|
80
|
+
const fadeIn = node.animate([{ opacity: 0 }, { opacity: 1 }], {
|
|
81
|
+
duration: ms,
|
|
82
|
+
easing: 'ease-out',
|
|
83
|
+
});
|
|
84
|
+
const incoming = { animation: fadeIn, hiding: false };
|
|
85
|
+
fades.set(node, incoming);
|
|
86
|
+
fadeIn.finished.then(() => {
|
|
87
|
+
if (fades.get(node) === incoming)
|
|
88
|
+
fades.delete(node);
|
|
89
|
+
}, () => { });
|
|
90
|
+
},
|
|
91
|
+
/** Jump every layer to its final state and remove all decorative copies. */
|
|
92
|
+
cancel() {
|
|
93
|
+
for (const node of [...fades.keys()])
|
|
94
|
+
settle(node);
|
|
95
|
+
for (const { animation, node } of [...ghosts]) {
|
|
96
|
+
animation.cancel();
|
|
97
|
+
node.remove();
|
|
98
|
+
}
|
|
99
|
+
ghosts.clear();
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|