@kahwee/sf-map-svg 5.1.0 → 5.1.2
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 +10 -0
- package/dist/src/district-controller.d.ts +64 -0
- package/dist/src/district-controller.js +215 -0
- package/dist/src/explorer-core.js +167 -430
- package/dist/src/label-coordinator.d.ts +54 -0
- package/dist/src/label-coordinator.js +157 -0
- package/dist/src/marker-controller.d.ts +55 -0
- package/dist/src/marker-controller.js +125 -0
- package/llms-full.txt +21 -11
- package/llms.txt +1 -1
- package/package.json +1 -1
|
@@ -2,21 +2,19 @@ import { copyAppearance } from './appearance.js';
|
|
|
2
2
|
import { createCamera, validateCameraOptions } from './camera.js';
|
|
3
3
|
import { createClusterLayer } from './cluster-layer.js';
|
|
4
4
|
import { clusterPoints } from './clusters.js';
|
|
5
|
-
import {
|
|
6
|
-
import { createDistrictMorph } from './district-morph.js';
|
|
5
|
+
import { createDistrictController } from './district-controller.js';
|
|
7
6
|
import { prepareDistrictStyles } from './district-style.js';
|
|
8
|
-
import {
|
|
9
|
-
import { element, setAttributeIfChanged, svgElement } from './dom.js';
|
|
7
|
+
import { element, svgElement } from './dom.js';
|
|
10
8
|
import { createExplorerControls } from './explorer-controls.js';
|
|
11
9
|
import { fitBounds, projectedBounds } from './explorer-layout.js';
|
|
12
10
|
import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from './features.js';
|
|
13
11
|
import { createFrameScheduler } from './frame-scheduler.js';
|
|
14
|
-
import {
|
|
15
|
-
import { createLabelRenderer } from './label-renderer.js';
|
|
12
|
+
import { createLabelCoordinator } from './label-coordinator.js';
|
|
16
13
|
import { createLayerTransitions } from './layer-transition.js';
|
|
17
14
|
import { createSFMapWithData, districtColors, getLayerPathsWithData, resolveMapColors, } from './map-core.js';
|
|
18
15
|
import { createMarkerCatalog } from './marker-catalog.js';
|
|
19
16
|
import { createMarkerChooser } from './marker-chooser.js';
|
|
17
|
+
import { createMarkerController } from './marker-controller.js';
|
|
20
18
|
import { createMarkerLayer } from './marker-layer.js';
|
|
21
19
|
import { createMarkerNavigation } from './marker-navigation.js';
|
|
22
20
|
import { createNeighborhoodLayer, createNeighborhoodLookup, formatSourceLabel, } from './neighborhood-layer.js';
|
|
@@ -179,180 +177,53 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
179
177
|
'aria-hidden': 'true',
|
|
180
178
|
'font-family': labelStyle.fontFamily ?? styleOptions.font ?? 'system-ui,sans-serif',
|
|
181
179
|
});
|
|
182
|
-
const labelRenderer = createLabelRenderer(labelLayer);
|
|
183
180
|
geography.append(areas, stations, labelLayer, overlayLayer, markerLayer);
|
|
184
181
|
let view = [0, 0, 800];
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
if (id !== null && (!Number.isInteger(id) || !districtRow(id)))
|
|
225
|
-
return false;
|
|
226
|
-
const changed = selectedDistrict !== id;
|
|
227
|
-
const revision = ++districtRevision;
|
|
228
|
-
selectedDistrict = id;
|
|
229
|
-
if (id === null)
|
|
230
|
-
delete root.dataset.selectedDistrict;
|
|
231
|
-
else
|
|
232
|
-
root.dataset.selectedDistrict = String(id);
|
|
233
|
-
updateDistrictAppearance();
|
|
234
|
-
const selectedRow = id === null ? undefined : districtRow(id);
|
|
235
|
-
if (selectedRow && options.fit)
|
|
236
|
-
fitGeometry(selectedRow.geometry, undefined, options);
|
|
237
|
-
if (destroyed || revision !== districtRevision)
|
|
238
|
-
return true;
|
|
239
|
-
status.textContent =
|
|
240
|
-
id === null ? 'District selection cleared.' : `District ${id} selected, ${year} boundaries.`;
|
|
241
|
-
if (changed)
|
|
242
|
-
root.dispatchEvent(new CustomEvent('districtchange', { bubbles: true, detail: districtSelection(id) }));
|
|
243
|
-
return true;
|
|
244
|
-
}
|
|
245
|
-
function activateDistrict(id) {
|
|
246
|
-
const revision = districtRevision + 1;
|
|
247
|
-
if (!selectDistrict(id) || destroyed || revision !== districtRevision)
|
|
248
|
-
return false;
|
|
249
|
-
root.dispatchEvent(new CustomEvent('districtactivate', { bubbles: true, detail: districtSelection(id) }));
|
|
250
|
-
return !destroyed && revision === districtRevision;
|
|
251
|
-
}
|
|
252
|
-
function setDistrictStyle(style) {
|
|
253
|
-
if (destroyed)
|
|
254
|
-
return;
|
|
255
|
-
const revision = districtRevision;
|
|
256
|
-
const prepared = prepareDistrictStyles(data.map.districts?.[year] ?? [], style);
|
|
257
|
-
if (destroyed || revision !== districtRevision)
|
|
258
|
-
return;
|
|
259
|
-
districtRevision++;
|
|
260
|
-
districtStyle = style;
|
|
261
|
-
districtStyles = prepared;
|
|
262
|
-
const fills = districtLayers.find((layer) => layer.dataset.layer === 'district-fills');
|
|
263
|
-
const fade = layerFades.duration();
|
|
264
|
-
// A new style settles any fill crossfade still running from an earlier change.
|
|
265
|
-
districtTransition.cancel();
|
|
266
|
-
if (fills && fade && enabled('districtFills'))
|
|
267
|
-
districtTransition.fade(fills, fade);
|
|
268
|
-
updateDistrictAppearance();
|
|
269
|
-
}
|
|
270
|
-
function setDistrictYear(next, options = {}, preparedStyles) {
|
|
271
|
-
if (destroyed)
|
|
272
|
-
return;
|
|
273
|
-
validateCameraOptions(options);
|
|
274
|
-
if (![2002, 2012, 2022].includes(next))
|
|
275
|
-
throw new RangeError('District year must be 2002, 2012, or 2022.');
|
|
276
|
-
if (!data.map.districts?.[next] || !data.districts?.[next])
|
|
277
|
-
throw new RangeError(`No ${next} district dataset was supplied.`);
|
|
278
|
-
if (next === year)
|
|
279
|
-
return;
|
|
280
|
-
const revision = districtRevision;
|
|
281
|
-
const prepared = preparedStyles ?? prepareDistrictStyles(data.map.districts[next], districtStyle);
|
|
282
|
-
if (destroyed || revision !== districtRevision)
|
|
283
|
-
return;
|
|
284
|
-
const committedRevision = ++districtRevision;
|
|
285
|
-
const geometry = getLayerPathsWithData({ year: next }, data.map, false);
|
|
286
|
-
const previousYear = year;
|
|
287
|
-
districtTransition.cancel();
|
|
288
|
-
districtMorph.stop();
|
|
289
|
-
const lineLayer = districtLayers.find((layer) => layer.dataset.layer === 'district-lines');
|
|
290
|
-
const morph = features.districtMorph &&
|
|
291
|
-
options.animate !== false &&
|
|
292
|
-
initialized &&
|
|
293
|
-
!reducedMotion.matches &&
|
|
294
|
-
(enabled('districtLines') || enabled('districtFills'))
|
|
295
|
-
? features.districtMorph
|
|
296
|
-
: false;
|
|
297
|
-
const previousOutlines = morph && lineLayer
|
|
298
|
-
? new Map([...lineLayer.querySelectorAll('path[data-district]')].map((path) => [
|
|
299
|
-
path.dataset.district ?? '',
|
|
300
|
-
path.getAttribute('d') ?? '',
|
|
301
|
-
]))
|
|
302
|
-
: undefined;
|
|
303
|
-
for (const layer of districtLayers) {
|
|
304
|
-
if (morph) {
|
|
305
|
-
// The morph replaces the line crossfade; fills fade across the whole morph.
|
|
306
|
-
if (layer !== lineLayer)
|
|
307
|
-
districtTransition.fade(layer, morph.duration);
|
|
308
|
-
}
|
|
309
|
-
else if (options.animate && !reducedMotion.matches)
|
|
310
|
-
districtTransition.fade(layer, options.duration ?? 280);
|
|
311
|
-
layer.replaceChildren(...geometry.districts.map((district) => svgElement('path', {
|
|
312
|
-
'data-district': district.id,
|
|
313
|
-
d: district.path,
|
|
314
|
-
...(layer.dataset.layer === 'district-fills'
|
|
315
|
-
? { 'fill-rule': 'evenodd' }
|
|
316
|
-
: { fill: 'none', 'stroke-width': 1.1 }),
|
|
317
|
-
})));
|
|
318
|
-
}
|
|
319
|
-
year = next;
|
|
320
|
-
districtStyles = prepared;
|
|
321
|
-
hoveredDistrict = null;
|
|
322
|
-
root.dataset.year = String(year);
|
|
323
|
-
svg.dataset.year = String(year);
|
|
324
|
-
districtItems = (data.districts[next]?.features ?? []).flatMap((feature) => feature.properties.labelPoints.map((point) => ({
|
|
325
|
-
point: map.project(point),
|
|
326
|
-
name: String(feature.properties.district),
|
|
327
|
-
kind: 'district',
|
|
328
|
-
})));
|
|
329
|
-
if (selectedDistrict !== null && !districtRow(selectedDistrict))
|
|
330
|
-
selectedDistrict = null;
|
|
331
|
-
if (selectedDistrict === null)
|
|
332
|
-
delete root.dataset.selectedDistrict;
|
|
333
|
-
updateDistrictAppearance();
|
|
334
|
-
updateComposition();
|
|
335
|
-
if (morph && lineLayer && previousOutlines)
|
|
336
|
-
districtMorph.start(lineLayer, previousOutlines, {
|
|
337
|
-
duration: morph.duration,
|
|
338
|
-
stroke: colors.district ?? '#71838a',
|
|
339
|
-
});
|
|
340
|
-
scheduleLabels();
|
|
341
|
-
status.textContent = `${year} supervisorial districts.`;
|
|
342
|
-
root.dispatchEvent(new CustomEvent('districtyearchange', { bubbles: true, detail: { year, previousYear } }));
|
|
343
|
-
if (!destroyed && committedRevision === districtRevision && selectedDistrict !== null)
|
|
344
|
-
root.dispatchEvent(new CustomEvent('districtchange', {
|
|
345
|
-
bubbles: true,
|
|
346
|
-
detail: districtSelection(selectedDistrict),
|
|
347
|
-
}));
|
|
348
|
-
}
|
|
182
|
+
const districts = createDistrictController({
|
|
183
|
+
root,
|
|
184
|
+
svg,
|
|
185
|
+
status,
|
|
186
|
+
data,
|
|
187
|
+
districtLayers,
|
|
188
|
+
reducedMotion,
|
|
189
|
+
enabled,
|
|
190
|
+
fitGeometry,
|
|
191
|
+
updateComposition,
|
|
192
|
+
scheduleLabels,
|
|
193
|
+
get year() {
|
|
194
|
+
return year;
|
|
195
|
+
},
|
|
196
|
+
set year(value) {
|
|
197
|
+
year = value;
|
|
198
|
+
},
|
|
199
|
+
get theme() {
|
|
200
|
+
return theme;
|
|
201
|
+
},
|
|
202
|
+
get colors() {
|
|
203
|
+
return colors;
|
|
204
|
+
},
|
|
205
|
+
get features() {
|
|
206
|
+
return features;
|
|
207
|
+
},
|
|
208
|
+
get destroyed() {
|
|
209
|
+
return destroyed;
|
|
210
|
+
},
|
|
211
|
+
get initialized() {
|
|
212
|
+
return initialized;
|
|
213
|
+
},
|
|
214
|
+
fadeDuration: () => layerFades.duration(),
|
|
215
|
+
project: map.project,
|
|
216
|
+
setLabels: (items) => {
|
|
217
|
+
districtItems = items;
|
|
218
|
+
},
|
|
219
|
+
}, initialDistrictStyle);
|
|
220
|
+
const { districtRow, districtSelection, getSelectedDistrict, updateDistrictAppearance, selectDistrict, activateDistrict, setDistrictStyle, setDistrictYear, transition: districtTransition, morph: districtMorph, } = districts;
|
|
349
221
|
updateDistrictAppearance();
|
|
350
222
|
let destroyed = false;
|
|
351
223
|
let initialized = false;
|
|
352
224
|
const layerFades = createLayerTransitions(() => initialized && features.layerTransitions && !reducedMotion.matches
|
|
353
225
|
? features.layerTransitions.duration
|
|
354
226
|
: 0);
|
|
355
|
-
const districtMorph = createDistrictMorph();
|
|
356
227
|
const frames = createFrameScheduler({
|
|
357
228
|
request: (callback) => requestAnimationFrame(callback),
|
|
358
229
|
cancel: (id) => cancelAnimationFrame(id),
|
|
@@ -441,7 +312,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
441
312
|
if (!visual)
|
|
442
313
|
continue;
|
|
443
314
|
const ring = features.selectedMarkerRing;
|
|
444
|
-
visual.ring.setAttribute('display', ring && marker.id ===
|
|
315
|
+
visual.ring.setAttribute('display', ring && marker.id === markers.selected ? 'inline' : 'none');
|
|
445
316
|
visual.ring.setAttribute('stroke', ring ? (ring.color ?? selectedMarkerColor) : selectedMarkerColor);
|
|
446
317
|
visual.ring.setAttribute('stroke-width', String(ring ? ring.width : 2));
|
|
447
318
|
}
|
|
@@ -476,7 +347,6 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
476
347
|
kind: 'road',
|
|
477
348
|
}));
|
|
478
349
|
const secondaryRoads = svg.querySelectorAll('[data-key-road-level="secondary"]');
|
|
479
|
-
let secondaryRoadsVisible;
|
|
480
350
|
const scaleUnitsPerMeter = Math.abs(map.project([-122.44, 37.76])[0] - map.project([-122.45, 37.76])[0]) / 879;
|
|
481
351
|
const parkItems = (data.map.landmarks ?? []).map((feature) => ({
|
|
482
352
|
point: map.project(feature.label),
|
|
@@ -484,18 +354,57 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
484
354
|
kind: 'park',
|
|
485
355
|
}));
|
|
486
356
|
const markerCatalog = createMarkerCatalog(markerRenderer, markerSelect, map.project);
|
|
487
|
-
let selectedMarker = null;
|
|
488
357
|
const markerNavigation = createMarkerNavigation(svg, controller.signal);
|
|
358
|
+
const markers = createMarkerController({
|
|
359
|
+
root,
|
|
360
|
+
canvas,
|
|
361
|
+
svg,
|
|
362
|
+
markerLayer,
|
|
363
|
+
markerSelect,
|
|
364
|
+
markerLabel,
|
|
365
|
+
markerCatalog,
|
|
366
|
+
markerNavigation,
|
|
367
|
+
strings,
|
|
368
|
+
fitPadding,
|
|
369
|
+
onMarkerActivate,
|
|
370
|
+
invalidateClusters,
|
|
371
|
+
drawClusters,
|
|
372
|
+
materializeMarker,
|
|
373
|
+
scheduleLabels,
|
|
374
|
+
syncFeatureControls,
|
|
375
|
+
moveView,
|
|
376
|
+
get markerChooser() {
|
|
377
|
+
return markerChooser;
|
|
378
|
+
},
|
|
379
|
+
get destroyed() {
|
|
380
|
+
return destroyed;
|
|
381
|
+
},
|
|
382
|
+
get view() {
|
|
383
|
+
return view;
|
|
384
|
+
},
|
|
385
|
+
get features() {
|
|
386
|
+
return features;
|
|
387
|
+
},
|
|
388
|
+
get markerColor() {
|
|
389
|
+
return markerColor;
|
|
390
|
+
},
|
|
391
|
+
get selectedMarkerColor() {
|
|
392
|
+
return selectedMarkerColor;
|
|
393
|
+
},
|
|
394
|
+
get controls() {
|
|
395
|
+
return controls;
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
const { getSelectedMarker, selectMarker, setMarkers, activateMarker } = markers;
|
|
489
399
|
const markerChooser = createMarkerChooser(canvas, svg, (id) => {
|
|
490
400
|
if (destroyed)
|
|
491
401
|
return;
|
|
492
402
|
selectMarker(id);
|
|
493
|
-
if (!destroyed &&
|
|
403
|
+
if (!destroyed && markers.selected === id)
|
|
494
404
|
markerCatalog.items
|
|
495
405
|
.find((item) => item.marker.id === id)
|
|
496
406
|
?.visual?.node.focus({ preventScroll: true });
|
|
497
407
|
});
|
|
498
|
-
let markerRevision = 0;
|
|
499
408
|
const clusterLayer = svgElement('g', { 'data-layer': 'marker-clusters' });
|
|
500
409
|
geography.insertBefore(clusterLayer, markerLayer);
|
|
501
410
|
function materializeMarker(item) {
|
|
@@ -505,7 +414,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
505
414
|
markerColor,
|
|
506
415
|
selectedMarkerColor,
|
|
507
416
|
reducedMotion: reducedMotion.matches,
|
|
508
|
-
selected: item.marker.id ===
|
|
417
|
+
selected: item.marker.id === markers.selected,
|
|
509
418
|
}),
|
|
510
419
|
});
|
|
511
420
|
}
|
|
@@ -539,8 +448,8 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
539
448
|
unit,
|
|
540
449
|
view,
|
|
541
450
|
items: markerCatalog.items,
|
|
542
|
-
revision:
|
|
543
|
-
selected:
|
|
451
|
+
revision: markers.revision,
|
|
452
|
+
selected: markers.selected,
|
|
544
453
|
features,
|
|
545
454
|
markerColor,
|
|
546
455
|
markerRadius,
|
|
@@ -629,141 +538,70 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
629
538
|
moveView([0, 0, 800], options);
|
|
630
539
|
}
|
|
631
540
|
function scheduleLabels() {
|
|
632
|
-
|
|
633
|
-
return;
|
|
634
|
-
frames.invalidate();
|
|
635
|
-
}
|
|
636
|
-
let labelInputs = [];
|
|
637
|
-
let preparedLabels = [];
|
|
638
|
-
function prepareLabels(width, zoom) {
|
|
639
|
-
// Only screen-space positioning changes during pan or within these zoom/width bands.
|
|
640
|
-
const inputs = [
|
|
641
|
-
neighborhoods.items,
|
|
642
|
-
neighborhoods.selected,
|
|
643
|
-
districtItems,
|
|
644
|
-
markerRevision,
|
|
645
|
-
layers,
|
|
646
|
-
mode,
|
|
647
|
-
labelStyle,
|
|
648
|
-
colors,
|
|
649
|
-
minLabelSize,
|
|
650
|
-
maxLabelSize,
|
|
651
|
-
markerRadius,
|
|
652
|
-
zoom >= 1.8,
|
|
653
|
-
width >= 550,
|
|
654
|
-
];
|
|
655
|
-
if (inputs.every((value, index) => value === labelInputs[index]))
|
|
656
|
-
return preparedLabels;
|
|
657
|
-
labelInputs = inputs;
|
|
658
|
-
const selectedItem = neighborhoods.items.find((item) => item.feature === neighborhoods.selected);
|
|
659
|
-
const candidates = [];
|
|
660
|
-
const marker = markerCatalog.items.find((item) => item.marker.id === selectedMarker);
|
|
661
|
-
if (marker)
|
|
662
|
-
candidates.push({
|
|
663
|
-
point: marker.point,
|
|
664
|
-
name: marker.marker.label ?? marker.marker.id,
|
|
665
|
-
kind: 'selected-marker',
|
|
666
|
-
});
|
|
667
|
-
if (selectedItem?.point && enabled('neighborhoodLabels'))
|
|
668
|
-
candidates.push({
|
|
669
|
-
point: selectedItem.point,
|
|
670
|
-
name: selectedItem.feature.properties.canonicalName,
|
|
671
|
-
kind: 'selected',
|
|
672
|
-
});
|
|
673
|
-
preparedLabels = styleLabels(labelCandidates({
|
|
674
|
-
selected: candidates,
|
|
675
|
-
districts: enabled('districtLabels') ? districtItems : [],
|
|
676
|
-
stations: enabled('bartStations') ? stationItems : [],
|
|
677
|
-
parks: enabled('landmarks') ? parkItems : [],
|
|
678
|
-
roads: enabled('roadLabels') ? roadItems : [],
|
|
679
|
-
neighborhoods: enabled('neighborhoodLabels')
|
|
680
|
-
? neighborhoods.items
|
|
681
|
-
.filter((item) => item.point !== undefined)
|
|
682
|
-
.sort((a, b) => b.area - a.area || a.feature.id.localeCompare(b.feature.id))
|
|
683
|
-
.flatMap((item) => item.feature !== neighborhoods.selected && item.point
|
|
684
|
-
? [
|
|
685
|
-
{
|
|
686
|
-
point: item.point,
|
|
687
|
-
name: item.feature.properties.canonicalName,
|
|
688
|
-
kind: 'neighborhood',
|
|
689
|
-
},
|
|
690
|
-
]
|
|
691
|
-
: [])
|
|
692
|
-
: [],
|
|
693
|
-
}, width, zoom), {
|
|
694
|
-
colors,
|
|
695
|
-
labelStyle,
|
|
696
|
-
labelSize: { min: minLabelSize, max: maxLabelSize },
|
|
697
|
-
markerRadius: marker?.marker.radius ?? markerRadius,
|
|
698
|
-
});
|
|
699
|
-
return preparedLabels;
|
|
541
|
+
labelCoordinator.schedule();
|
|
700
542
|
}
|
|
543
|
+
const labelCoordinator = createLabelCoordinator({
|
|
544
|
+
root,
|
|
545
|
+
canvas,
|
|
546
|
+
scale,
|
|
547
|
+
overlayElement,
|
|
548
|
+
secondaryRoads,
|
|
549
|
+
stationItems,
|
|
550
|
+
roadItems,
|
|
551
|
+
parkItems,
|
|
552
|
+
scaleUnitsPerMeter,
|
|
553
|
+
neighborhoods,
|
|
554
|
+
markerCatalog,
|
|
555
|
+
markers,
|
|
556
|
+
enabled,
|
|
557
|
+
drawClusters,
|
|
558
|
+
labelLayer,
|
|
559
|
+
invalidateFrame: frames.invalidate,
|
|
560
|
+
get districtItems() {
|
|
561
|
+
return districtItems;
|
|
562
|
+
},
|
|
563
|
+
get layers() {
|
|
564
|
+
return layers;
|
|
565
|
+
},
|
|
566
|
+
get mode() {
|
|
567
|
+
return mode;
|
|
568
|
+
},
|
|
569
|
+
get labelStyle() {
|
|
570
|
+
return labelStyle;
|
|
571
|
+
},
|
|
572
|
+
get colors() {
|
|
573
|
+
return colors;
|
|
574
|
+
},
|
|
575
|
+
get minLabelSize() {
|
|
576
|
+
return minLabelSize;
|
|
577
|
+
},
|
|
578
|
+
get maxLabelSize() {
|
|
579
|
+
return maxLabelSize;
|
|
580
|
+
},
|
|
581
|
+
get markerRadius() {
|
|
582
|
+
return markerRadius;
|
|
583
|
+
},
|
|
584
|
+
get view() {
|
|
585
|
+
return view;
|
|
586
|
+
},
|
|
587
|
+
get destroyed() {
|
|
588
|
+
return destroyed;
|
|
589
|
+
},
|
|
590
|
+
get features() {
|
|
591
|
+
return features;
|
|
592
|
+
},
|
|
593
|
+
get labels() {
|
|
594
|
+
return labels;
|
|
595
|
+
},
|
|
596
|
+
get renderedMarkers() {
|
|
597
|
+
return renderedMarkers;
|
|
598
|
+
},
|
|
599
|
+
get renderedClusters() {
|
|
600
|
+
return renderedClusters;
|
|
601
|
+
},
|
|
602
|
+
});
|
|
701
603
|
function drawLabels() {
|
|
702
|
-
|
|
703
|
-
return;
|
|
704
|
-
const canvasBox = canvas.getBoundingClientRect();
|
|
705
|
-
const width = canvasBox.width;
|
|
706
|
-
if (!width)
|
|
707
|
-
return;
|
|
708
|
-
const unit = view[2] / width, zoom = 800 / view[2];
|
|
709
|
-
if (features.scaleBar) {
|
|
710
|
-
const pixelsPerMeter = scaleUnitsPerMeter / unit;
|
|
711
|
-
const base = 10 ** Math.floor(Math.log10(100 / pixelsPerMeter));
|
|
712
|
-
const meters = [5, 2, 1].map((factor) => factor * base).find((value) => value * pixelsPerMeter <= 100) ??
|
|
713
|
-
base;
|
|
714
|
-
const scaleWidth = `${meters * pixelsPerMeter}px`;
|
|
715
|
-
const scaleText = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`;
|
|
716
|
-
if (scale.style.width !== scaleWidth)
|
|
717
|
-
scale.style.width = scaleWidth;
|
|
718
|
-
if (scale.textContent !== scaleText)
|
|
719
|
-
scale.textContent = scaleText;
|
|
720
|
-
}
|
|
721
|
-
// Read overlay bounds before SVG writes; scale furniture above must have its current width.
|
|
722
|
-
const furnitureBounds = [...overlayElement.children]
|
|
723
|
-
.filter((node) => node.getClientRects().length > 0)
|
|
724
|
-
.map((node) => {
|
|
725
|
-
const box = node.getBoundingClientRect();
|
|
726
|
-
return [
|
|
727
|
-
box.left - canvasBox.left - 3,
|
|
728
|
-
box.top - canvasBox.top - 3,
|
|
729
|
-
box.right - canvasBox.left + 3,
|
|
730
|
-
box.bottom - canvasBox.top + 3,
|
|
731
|
-
];
|
|
732
|
-
});
|
|
733
|
-
const showSecondaryRoads = zoom >= 1.8;
|
|
734
|
-
if (showSecondaryRoads !== secondaryRoadsVisible) {
|
|
735
|
-
for (const road of secondaryRoads)
|
|
736
|
-
road.style.display = showSecondaryRoads ? '' : 'none';
|
|
737
|
-
secondaryRoadsVisible = showSecondaryRoads;
|
|
738
|
-
}
|
|
739
|
-
for (const station of stationItems)
|
|
740
|
-
setAttributeIfChanged(station.node, 'r', 4.5 * unit);
|
|
741
|
-
drawClusters(unit);
|
|
742
|
-
if (!labels) {
|
|
743
|
-
labelRenderer.clear();
|
|
744
|
-
root.dataset.visibleLabels = '0';
|
|
745
|
-
return;
|
|
746
|
-
}
|
|
747
|
-
const labelCandidates = prepareLabels(width, zoom);
|
|
748
|
-
const stationBounds = (enabled('bartStations') ? stationItems : []).map(({ point }) => {
|
|
749
|
-
const x = (point[0] - view[0]) / unit;
|
|
750
|
-
const y = (point[1] - view[1]) / unit;
|
|
751
|
-
return [x - 6, y - 6, x + 6, y + 6];
|
|
752
|
-
});
|
|
753
|
-
const markerBounds = renderedMarkers.map(({ point, marker }) => {
|
|
754
|
-
const radius = marker.radius ?? markerRadius;
|
|
755
|
-
const x = (point[0] - view[0]) / unit, y = (point[1] - view[1]) / unit;
|
|
756
|
-
return [x - radius - 3, y - radius - 3, x + radius + 3, y + radius + 3];
|
|
757
|
-
});
|
|
758
|
-
root.dataset.visibleLabels = String(labelRenderer.draw(labelCandidates, view, width, [
|
|
759
|
-
...stationBounds,
|
|
760
|
-
...markerBounds,
|
|
761
|
-
...furnitureBounds,
|
|
762
|
-
...renderedClusters.map(({ point }) => {
|
|
763
|
-
const x = (point[0] - view[0]) / unit, y = (point[1] - view[1]) / unit;
|
|
764
|
-
return [x - 26, y - 26, x + 26, y + 26];
|
|
765
|
-
}),
|
|
766
|
-
]));
|
|
604
|
+
labelCoordinator.draw();
|
|
767
605
|
}
|
|
768
606
|
const sidebar = ui.createSidebar?.(shell, data, chrome, strings.emptyResults, searchNeighborhoods);
|
|
769
607
|
function updateDetail() {
|
|
@@ -869,91 +707,6 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
869
707
|
attribution.textContent = description;
|
|
870
708
|
scheduleLabels();
|
|
871
709
|
}
|
|
872
|
-
function getSelectedMarker() {
|
|
873
|
-
const marker = markerCatalog.items.find((item) => item.marker.id === selectedMarker)?.marker;
|
|
874
|
-
return marker ? { ...marker, position: [...marker.position] } : null;
|
|
875
|
-
}
|
|
876
|
-
function selectMarker(id, options = {}) {
|
|
877
|
-
if (destroyed)
|
|
878
|
-
return false;
|
|
879
|
-
const item = markerCatalog.items.find((item) => item.marker.id === id);
|
|
880
|
-
if (id !== null && !item)
|
|
881
|
-
return false;
|
|
882
|
-
validateCameraOptions(options);
|
|
883
|
-
const width = canvas.getBoundingClientRect().width;
|
|
884
|
-
const target = item && options.fit !== false
|
|
885
|
-
? width
|
|
886
|
-
? fitViewport([...item.point, ...item.point], width, fitPadding)
|
|
887
|
-
: fitBounds([...item.point, ...item.point])
|
|
888
|
-
: null;
|
|
889
|
-
const revision = ++markerRevision;
|
|
890
|
-
const changed = selectedMarker !== id;
|
|
891
|
-
selectedMarker = id;
|
|
892
|
-
if (changed)
|
|
893
|
-
invalidateClusters();
|
|
894
|
-
markerSelect.value = id ?? '';
|
|
895
|
-
for (const entry of markerCatalog.items) {
|
|
896
|
-
const active = entry.marker.id === id;
|
|
897
|
-
const visual = active ? materializeMarker(entry).visual : entry.visual;
|
|
898
|
-
if (!visual)
|
|
899
|
-
continue;
|
|
900
|
-
const pressed = String(active);
|
|
901
|
-
const ring = active && features.selectedMarkerRing ? 'inline' : 'none';
|
|
902
|
-
const fill = active ? selectedMarkerColor : (entry.marker.color ?? markerColor);
|
|
903
|
-
if (visual.node.getAttribute('aria-pressed') !== pressed)
|
|
904
|
-
visual.node.setAttribute('aria-pressed', pressed);
|
|
905
|
-
if (visual.ring.getAttribute('display') !== ring)
|
|
906
|
-
visual.ring.setAttribute('display', ring);
|
|
907
|
-
if (visual.dot.getAttribute('fill') !== fill)
|
|
908
|
-
visual.dot.setAttribute('fill', fill);
|
|
909
|
-
if (active && markerLayer.lastChild !== visual.node)
|
|
910
|
-
markerLayer.append(visual.node);
|
|
911
|
-
}
|
|
912
|
-
scheduleLabels();
|
|
913
|
-
if (width || !features.clustering)
|
|
914
|
-
drawClusters(width ? view[2] / width : 1);
|
|
915
|
-
const marker = getSelectedMarker();
|
|
916
|
-
if (changed)
|
|
917
|
-
root.dispatchEvent(new CustomEvent('markerchange', { bubbles: true, detail: { id, marker } }));
|
|
918
|
-
if (!destroyed && changed && marker && markerRevision === revision && selectedMarker === id)
|
|
919
|
-
onMarkerActivate?.({ ...marker });
|
|
920
|
-
if (!destroyed && markerRevision === revision && target)
|
|
921
|
-
moveView(target, options);
|
|
922
|
-
return true;
|
|
923
|
-
}
|
|
924
|
-
function setMarkers(markers) {
|
|
925
|
-
if (destroyed)
|
|
926
|
-
return;
|
|
927
|
-
const prepared = markerCatalog.prepare(markers, selectedMarker);
|
|
928
|
-
if (!prepared)
|
|
929
|
-
return;
|
|
930
|
-
const { selection: nextSelection, focused: focusedMarker } = prepared;
|
|
931
|
-
invalidateClusters();
|
|
932
|
-
prepared.commit();
|
|
933
|
-
markerLabel.hidden = !markerCatalog.items.length || controls.markerPicker === false;
|
|
934
|
-
if (markerLabel.firstChild)
|
|
935
|
-
markerLabel.firstChild.textContent = `${strings.chooseMarker ?? 'Choose marker'} (${markerCatalog.items.length})`;
|
|
936
|
-
syncFeatureControls();
|
|
937
|
-
const revision = markerRevision + 1;
|
|
938
|
-
selectMarker(nextSelection, { fit: false });
|
|
939
|
-
if (!destroyed && markerRevision === revision && focusedMarker) {
|
|
940
|
-
markerNavigation.sync(markerCatalog.items.flatMap((item) => (item.visual ? [item.visual.node] : [])));
|
|
941
|
-
if (!markerNavigation.recover()) {
|
|
942
|
-
const recovery = markerCatalog.items.find((item) => item.marker.id === focusedMarker) ??
|
|
943
|
-
markerCatalog.items[0];
|
|
944
|
-
if (recovery) {
|
|
945
|
-
const { node } = materializeMarker(recovery).visual;
|
|
946
|
-
markerLayer.append(node);
|
|
947
|
-
node.focus({ preventScroll: true });
|
|
948
|
-
const width = canvas.getBoundingClientRect().width;
|
|
949
|
-
if (width)
|
|
950
|
-
drawClusters(view[2] / width);
|
|
951
|
-
}
|
|
952
|
-
else
|
|
953
|
-
svg.focus({ preventScroll: true });
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
710
|
function setControls(patch) {
|
|
958
711
|
if (destroyed)
|
|
959
712
|
return;
|
|
@@ -1052,21 +805,6 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
1052
805
|
});
|
|
1053
806
|
listen(markerLayer, 'focusout', scheduleLabels);
|
|
1054
807
|
listen(markerLayer, 'focusin', scheduleLabels);
|
|
1055
|
-
function activateMarker(id, node) {
|
|
1056
|
-
const item = markerCatalog.items.find((item) => item.marker.id === id);
|
|
1057
|
-
if (!item)
|
|
1058
|
-
return;
|
|
1059
|
-
const coincident = markerCatalog.items.filter((other) => other.point[0] === item.point[0] && other.point[1] === item.point[1]);
|
|
1060
|
-
if (coincident.length > 1)
|
|
1061
|
-
markerChooser.open(coincident.map((other) => other.marker), node);
|
|
1062
|
-
else {
|
|
1063
|
-
selectMarker(id);
|
|
1064
|
-
if (!destroyed && selectedMarker === id)
|
|
1065
|
-
markerCatalog.items
|
|
1066
|
-
.find((other) => other.marker.id === id)
|
|
1067
|
-
?.visual?.node.focus({ preventScroll: true });
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
808
|
listen(markerLayer, 'click', (event) => {
|
|
1071
809
|
const node = event.target instanceof Element ? event.target.closest('[data-marker-id]') : null;
|
|
1072
810
|
if (node?.dataset.markerId)
|
|
@@ -1098,9 +836,9 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
1098
836
|
? event.target.closest('[data-layer="district-fills"] [data-district], [data-layer="district-lines"] [data-district]')
|
|
1099
837
|
: null;
|
|
1100
838
|
const id = district?.dataset.district ? Number(district.dataset.district) : null;
|
|
1101
|
-
if (id ===
|
|
839
|
+
if (id === districts.hovered)
|
|
1102
840
|
return;
|
|
1103
|
-
|
|
841
|
+
districts.hovered = id;
|
|
1104
842
|
updateDistrictAppearance();
|
|
1105
843
|
root.dispatchEvent(new CustomEvent('districthover', { bubbles: true, detail: districtSelection(id) }));
|
|
1106
844
|
});
|
|
@@ -1108,9 +846,9 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
1108
846
|
if (event.relatedTarget instanceof Element &&
|
|
1109
847
|
event.relatedTarget.closest('[data-layer="district-fills"] [data-district], [data-layer="district-lines"] [data-district]'))
|
|
1110
848
|
return;
|
|
1111
|
-
if (
|
|
849
|
+
if (districts.hovered === null)
|
|
1112
850
|
return;
|
|
1113
|
-
|
|
851
|
+
districts.hovered = null;
|
|
1114
852
|
updateDistrictAppearance();
|
|
1115
853
|
root.dispatchEvent(new CustomEvent('districthover', { bubbles: true, detail: districtSelection(null) }));
|
|
1116
854
|
});
|
|
@@ -1137,15 +875,15 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
1137
875
|
? prepareSource(patch.source, false)
|
|
1138
876
|
: undefined;
|
|
1139
877
|
const appearance = patch.appearance;
|
|
1140
|
-
const nextStyle = appearance ? appearance.districtStyle :
|
|
1141
|
-
const districtVersion =
|
|
878
|
+
const nextStyle = appearance ? appearance.districtStyle : districts.style;
|
|
879
|
+
const districtVersion = districts.revision;
|
|
1142
880
|
const prepared = appearance || nextYear !== year
|
|
1143
881
|
? prepareDistrictStyles(data.map.districts?.[nextYear] ?? [], nextStyle)
|
|
1144
|
-
:
|
|
882
|
+
: districts.styles;
|
|
1145
883
|
if (destroyed ||
|
|
1146
884
|
!isCurrent() ||
|
|
1147
885
|
revision !== presentationRevision ||
|
|
1148
|
-
districtVersion !==
|
|
886
|
+
districtVersion !== districts.revision)
|
|
1149
887
|
return false;
|
|
1150
888
|
if (nextYear !== year) {
|
|
1151
889
|
// Validate target paths and label coordinates before any presentation group commits.
|
|
@@ -1156,7 +894,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
1156
894
|
}
|
|
1157
895
|
onCommit?.();
|
|
1158
896
|
if (appearance) {
|
|
1159
|
-
|
|
897
|
+
districts.invalidate();
|
|
1160
898
|
const next = copyAppearance(appearance);
|
|
1161
899
|
theme = next.theme ?? 'transit';
|
|
1162
900
|
colors = next.colors ?? {};
|
|
@@ -1172,12 +910,11 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
1172
910
|
districtTransition.cancel();
|
|
1173
911
|
const fills = districtLayers.find((layer) => layer.dataset.layer === 'district-fills');
|
|
1174
912
|
const fade = layerFades.duration();
|
|
1175
|
-
const stylesChanged = nextStyle !==
|
|
1176
|
-
[...prepared].some(([id, value]) => ['fill', 'stroke', 'opacity'].some((key) => value[key] !==
|
|
913
|
+
const stylesChanged = nextStyle !== districts.style ||
|
|
914
|
+
[...prepared].some(([id, value]) => ['fill', 'stroke', 'opacity'].some((key) => value[key] !== districts.styles.get(id)?.[key]));
|
|
1177
915
|
if (fills && fade && enabled('districtFills') && nextYear === year && stylesChanged)
|
|
1178
916
|
districtTransition.fade(fills, fade);
|
|
1179
|
-
|
|
1180
|
-
districtStyles = prepared;
|
|
917
|
+
districts.commitStyles(nextStyle, prepared);
|
|
1181
918
|
const palette = resolveMapColors(theme, colors);
|
|
1182
919
|
svg.querySelector('rect')?.setAttribute('fill', palette.water);
|
|
1183
920
|
svg.querySelector('[data-layer="coast"]')?.setAttribute('fill', palette.land);
|
|
@@ -1203,7 +940,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
1203
940
|
])
|
|
1204
941
|
root.style.setProperty(`--sf-map-${name}`, styleOptions[token] ?? fallback);
|
|
1205
942
|
labelLayer.setAttribute('font-family', labelStyle.fontFamily ?? styleOptions.font ?? 'system-ui,sans-serif');
|
|
1206
|
-
|
|
943
|
+
labelCoordinator.invalidateMetrics();
|
|
1207
944
|
for (const station of stationItems)
|
|
1208
945
|
station.node.setAttribute('stroke', colors.bart ?? '#0073ae');
|
|
1209
946
|
for (const kind of ['bart', 'park', 'highway', 'road']) {
|
|
@@ -1223,7 +960,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
1223
960
|
const visual = item.visual;
|
|
1224
961
|
if (!visual)
|
|
1225
962
|
continue;
|
|
1226
|
-
visual.dot.setAttribute('fill', item.marker.id ===
|
|
963
|
+
visual.dot.setAttribute('fill', item.marker.id === markers.selected
|
|
1227
964
|
? selectedMarkerColor
|
|
1228
965
|
: (item.marker.color ?? markerColor));
|
|
1229
966
|
visual.ring.setAttribute('stroke', features.selectedMarkerRing
|
|
@@ -1294,7 +1031,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
1294
1031
|
canvas.style.touchAction = 'pan-y pinch-zoom';
|
|
1295
1032
|
observer?.disconnect();
|
|
1296
1033
|
frames.destroy();
|
|
1297
|
-
|
|
1034
|
+
labelCoordinator.clear();
|
|
1298
1035
|
sidebar?.destroy();
|
|
1299
1036
|
neighborhoods.destroy();
|
|
1300
1037
|
},
|
|
@@ -1303,7 +1040,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
1303
1040
|
observer = new ResizeObserver(onResize);
|
|
1304
1041
|
observer.observe(canvas);
|
|
1305
1042
|
document.fonts?.addEventListener('loadingdone', () => {
|
|
1306
|
-
|
|
1043
|
+
labelCoordinator.invalidateMetrics();
|
|
1307
1044
|
scheduleLabels();
|
|
1308
1045
|
}, { signal: controller.signal });
|
|
1309
1046
|
reducedMotion.addEventListener('change', () => {
|