@kahwee/sf-map-svg 3.0.0 → 3.1.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 +17 -0
- package/README.md +8 -4
- package/dist/src/district-layer.d.ts +15 -0
- package/dist/src/district-layer.js +51 -0
- package/dist/src/district-style.d.ts +4 -0
- package/dist/src/district-style.js +23 -0
- package/dist/src/district-transition.d.ts +5 -0
- package/dist/src/district-transition.js +38 -0
- package/dist/src/dom.d.ts +2 -0
- package/dist/src/dom.js +14 -0
- package/dist/src/explorer-core.js +69 -179
- package/dist/src/full-data.js +2 -1
- package/dist/src/guide-map.d.ts +6 -1
- package/dist/src/guide-map.js +60 -18
- package/dist/src/guide.d.ts +2 -1
- package/dist/src/guide.js +1 -1
- package/dist/src/label-renderer.d.ts +17 -0
- package/dist/src/label-renderer.js +81 -0
- package/dist/src/map-core.js +8 -23
- package/dist/src/marker-layer.d.ts +22 -0
- package/dist/src/marker-layer.js +64 -0
- package/dist/src/static-data.js +3 -1
- package/dist/src/types.d.ts +1 -1
- package/dist/src/validation.js +5 -0
- package/docs/EXAMPLES.md +12 -12
- package/docs/api-audit.md +8 -3
- package/docs/consumer-integration.md +10 -5
- package/docs/guide-bundle-report.md +4 -4
- package/docs/migration-v3.md +9 -3
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 3.1.0 — 2026-09-28
|
|
8
|
+
|
|
9
|
+
- Add `createGuideController` and `mountGuideController` with the grouped options, camera, subscriptions, and lifecycle of `createMap`; preserve existing element-based guide factories.
|
|
10
|
+
- Reject invalid marker coordinates before static rendering invokes district-style callbacks.
|
|
11
|
+
- Prepare district styles once per update before changing the map, and reuse projected district paths.
|
|
12
|
+
- Track both district crossfades so interrupted transitions, reduced motion, and teardown remove all outgoing layers.
|
|
13
|
+
- Reuse label nodes and screen-space text measurements during camera movement, refreshing metrics when fonts load; separate label rendering and district appearance from the interactive engine.
|
|
14
|
+
- Give marker visuals and entrance animations their own lifecycle, and prevent reentrant district callbacks from overwriting newer state or restarting work after destruction.
|
|
15
|
+
- Document guide-controller integration and district-style refresh semantics; add browser regressions for callback atomicity, interrupted fades, camera motion, marker replacement, label reuse, and progressive shell mounting.
|
|
16
|
+
|
|
17
|
+
## 3.0.1 — 2026-09-27
|
|
18
|
+
|
|
19
|
+
- Narrow the complete-data presets to their direct geographic modules without changing rendered SVGs or public API behavior.
|
|
20
|
+
- Correct stale contributing and example documentation links, add a local Markdown link check to `pnpm check`, and show the v3 controller API on the Pages homepage.
|
|
21
|
+
- Skip the early push-triggered Pages build while a new package version is still processing on npm; the successful release workflow deploys that version from the registry.
|
|
22
|
+
- Serve local visual snapshots with the same streaming Node HTTP approach as the Pages browser check, avoiding intermittent resets while loading large optional map data.
|
|
23
|
+
|
|
7
24
|
## 3.0.0 — 2026-09-27
|
|
8
25
|
|
|
9
26
|
Version 3 removes the deprecated compatibility APIs. Applications must migrate before upgrading. The data-free root API introduced in version 2 remains the supported API.
|
package/README.md
CHANGED
|
@@ -16,16 +16,16 @@ Node 22.12+ is required for server rendering. Browser maps need a DOM and a bund
|
|
|
16
16
|
|
|
17
17
|
```ts
|
|
18
18
|
import { renderMap } from '@kahwee/sf-map-svg';
|
|
19
|
-
import {
|
|
19
|
+
import { staticMapData } from '@kahwee/sf-map-svg/data/static';
|
|
20
20
|
|
|
21
|
-
const { svg, project, viewBox } = renderMap(
|
|
21
|
+
const { svg, project, viewBox } = renderMap(staticMapData, {
|
|
22
22
|
year: 2022,
|
|
23
23
|
landmarks: true,
|
|
24
24
|
bartStations: true,
|
|
25
25
|
});
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
`renderMap(data, options)` returns SVG markup and matching projection helpers.
|
|
28
|
+
`renderMap(data, options)` returns SVG markup and matching projection helpers. `/data/static` includes the complete static map without interactive lookup collections. Small maps can compose selected JSON exports from `/data/*` and pass them as `StaticMapData`. `getLayerPaths(data, options)` returns fitted geographic paths without SVG markup.
|
|
29
29
|
|
|
30
30
|
Static options include `theme`, `width`, `height`, `padding`, `year` (2002, 2012, 2022), `districtLines`, `districtFills`, `districtStyle`, `districtLabels`, `neighborhoodLines`, `labels`, `highways`, `keyRoads`, `roadLabels`, `landmarks`, `bartStations`, `markers`, `overlays`, `title`, `idPrefix`, and `colors`. Each optional layer is independent. User-supplied text and attributes are escaped in SVG output.
|
|
31
31
|
|
|
@@ -52,12 +52,16 @@ map.destroy();
|
|
|
52
52
|
|
|
53
53
|
`createMap(data, options)` returns a controller. Use `map.element` for mounting, `map.configure({ features, layers, controls })` for runtime switches, `map.camera` for pan/zoom/fit/reset, and `map.on()` for typed events. Appearance is set at construction. SFAR realtor neighborhoods are the default when supplied; SF Find and analysis are explicit alternate sources. The `/data/full` preset includes all three collections and historical districts.
|
|
54
54
|
|
|
55
|
-
Construction options include `mode` (`basemap`, `neighborhoods`, or `districts`), `source`, `neighborhood`, `year`, `labels`, `layers`, `controls`, `features`, `appearance`, `markers`, `overlays`, `legend`, `strings`, `attribution`, `fitPadding
|
|
55
|
+
Construction options include `mode` (`basemap`, `neighborhoods`, or `districts`), `source`, `neighborhood`, `year`, `labels`, `layers`, `controls`, `features`, `appearance`, `markers`, `overlays`, `legend`, `strings`, `attribution`, and `fitPadding`. Enable map touch gestures with the touch control or `map.setTouchNavigation(true)`. `features` holds motion, marker entrances, selected marker rings, clustering, north arrow, and scale bar. `appearance` holds theme, color tokens, label and area styles, marker colors and sizes, and district styling. `layers` controls district fill/line/labels, neighborhood lines/labels, landmarks, BART, highways, key roads, and road labels. See the exported `MapOptions` type for exact values and the [Storybook examples](stories/) for live controls.
|
|
56
56
|
|
|
57
57
|
The controller also supports marker, neighborhood, and district selection; district year and style changes; source and mode changes; labels and touch navigation; screen projection; and typed `markerchange`, `neighborhoodchange`, `districtchange`, `districthover`, `districtactivate`, `districtyearchange`, `overlayactivate`, `clusteractivate`, `viewportchange`, and `mapresize` events. `destroy()` releases browser resources; operations after destruction throw.
|
|
58
58
|
|
|
59
59
|
For a small guide, import `guideMapData` from `/guide/data` and pass it to `createMap`. The optional `/guide` entry also provides `createGuideMap`, `mountGuideMap`, and detailed-data loading for existing guide layouts. `/transit` provides the standalone schematic transit animation. See [examples](docs/EXAMPLES.md) and [consumer integration](docs/consumer-integration.md).
|
|
60
60
|
|
|
61
|
+
For the guide preset with the same controller API, use `createGuideController(options)` from `/guide` (or `/guide/map`). It accepts grouped `MapOptions` and returns `MapController`; `mountGuideController(shell, options)` enhances a `createGuideShell()` container. Both use the lightweight guide geography. Existing `createGuideMap` and `mountGuideMap` calls retain their element-based API.
|
|
62
|
+
|
|
63
|
+
District style callbacks are evaluated once per district at construction and on style or year updates. Hover, selection, and layer toggles reuse the prepared styles; call `setDistrictStyle()` again when external styling data changes. Invalid styles leave the current map unchanged. District fades respect reduced motion and are removed on interruption or destruction.
|
|
64
|
+
|
|
61
65
|
## Data and development
|
|
62
66
|
|
|
63
67
|
Canonical geography is in [`data/`](data/README.md), with provenance in [`SOURCES.md`](SOURCES.md). The public `/data` entry exposes lookup, catalog, district maps, and source-specific neighborhood collections. These are deeply frozen; clone before editing.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { DistrictRowData } from './map-core.js';
|
|
2
|
+
import type { DistrictStyle, DistrictYear, SFMapOptions } from './types.js';
|
|
3
|
+
/** Apply prepared styles and interaction state without invoking consumer callbacks. */
|
|
4
|
+
export declare function renderDistrictAppearance({ svg, rows, styles, selectedDistrict, hoveredDistrict, year, theme, colors, fills, lines, }: {
|
|
5
|
+
svg: SVGSVGElement;
|
|
6
|
+
rows: readonly DistrictRowData[];
|
|
7
|
+
styles: ReadonlyMap<number, DistrictStyle>;
|
|
8
|
+
selectedDistrict: number | null;
|
|
9
|
+
hoveredDistrict: number | null;
|
|
10
|
+
year: DistrictYear;
|
|
11
|
+
theme: SFMapOptions['theme'];
|
|
12
|
+
colors: NonNullable<SFMapOptions['colors']>;
|
|
13
|
+
fills: boolean;
|
|
14
|
+
lines: boolean;
|
|
15
|
+
}): void;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { districtColors } from './map-core.js';
|
|
2
|
+
/** Apply prepared styles and interaction state without invoking consumer callbacks. */
|
|
3
|
+
export function renderDistrictAppearance({ svg, rows, styles, selectedDistrict, hoveredDistrict, year, theme, colors, fills, lines, }) {
|
|
4
|
+
for (const row of rows) {
|
|
5
|
+
const styled = styles.get(row.id);
|
|
6
|
+
const active = selectedDistrict === row.id;
|
|
7
|
+
const hovered = hoveredDistrict === row.id;
|
|
8
|
+
const fill = svg.querySelector(`[data-layer="district-fills"] [data-district="${row.id}"]`);
|
|
9
|
+
const line = svg.querySelector(`[data-layer="district-lines"] [data-district="${row.id}"]`);
|
|
10
|
+
if (fill) {
|
|
11
|
+
fill.setAttribute('fill', styled?.fill ??
|
|
12
|
+
(theme === 'transit' ? (colors.land ?? '#fcfcf8') : districtColors[row.id - 1]));
|
|
13
|
+
if (styled?.opacity === undefined)
|
|
14
|
+
fill.removeAttribute('fill-opacity');
|
|
15
|
+
else
|
|
16
|
+
fill.setAttribute('fill-opacity', String(styled.opacity));
|
|
17
|
+
fill.setAttribute('role', 'button');
|
|
18
|
+
fill.setAttribute('aria-label', `District ${row.id}, ${year}`);
|
|
19
|
+
fill.setAttribute('aria-pressed', String(active));
|
|
20
|
+
fill.setAttribute('tabindex', row.id === 1 ? '0' : '-1');
|
|
21
|
+
fill.style.cursor = 'pointer';
|
|
22
|
+
}
|
|
23
|
+
if (line) {
|
|
24
|
+
line.setAttribute('stroke', active
|
|
25
|
+
? (colors.selected ?? '#f04f32')
|
|
26
|
+
: hovered
|
|
27
|
+
? '#163e56'
|
|
28
|
+
: (styled?.stroke ?? colors.district ?? '#a7b8c0'));
|
|
29
|
+
line.setAttribute('stroke-width', active ? '3' : hovered ? '2.2' : '1.1');
|
|
30
|
+
const lineInteractive = !fills && lines;
|
|
31
|
+
line.style.pointerEvents = lineInteractive ? 'stroke' : 'none';
|
|
32
|
+
if (lineInteractive) {
|
|
33
|
+
line.setAttribute('role', 'button');
|
|
34
|
+
line.setAttribute('aria-label', `District ${row.id}, ${year}`);
|
|
35
|
+
line.setAttribute('aria-pressed', String(active));
|
|
36
|
+
line.setAttribute('tabindex', row.id === 1 ? '0' : '-1');
|
|
37
|
+
line.style.cursor = 'pointer';
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
line.removeAttribute('role');
|
|
41
|
+
line.removeAttribute('aria-label');
|
|
42
|
+
line.removeAttribute('aria-pressed');
|
|
43
|
+
line.removeAttribute('tabindex');
|
|
44
|
+
}
|
|
45
|
+
if (styled?.opacity === undefined)
|
|
46
|
+
line.removeAttribute('stroke-opacity');
|
|
47
|
+
else
|
|
48
|
+
line.setAttribute('stroke-opacity', String(styled.opacity));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { DistrictRowData } from './map-core.js';
|
|
2
|
+
import type { DistrictStyle, SFMapOptions } from './types.js';
|
|
3
|
+
/** Evaluate user code once and copy its result before changing the map. */
|
|
4
|
+
export declare function prepareDistrictStyles(rows: readonly DistrictRowData[], callback: SFMapOptions['districtStyle']): ReadonlyMap<number, DistrictStyle>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Evaluate user code once and copy its result before changing the map. */
|
|
2
|
+
export function prepareDistrictStyles(rows, callback) {
|
|
3
|
+
if (callback !== undefined && typeof callback !== 'function')
|
|
4
|
+
throw new TypeError('districtStyle must be a function.');
|
|
5
|
+
return new Map(rows.map((row) => [row.id, validateDistrictStyle(callback?.(row))]));
|
|
6
|
+
}
|
|
7
|
+
function validateDistrictStyle(style) {
|
|
8
|
+
if (style === undefined)
|
|
9
|
+
return {};
|
|
10
|
+
if (!style || typeof style !== 'object' || Array.isArray(style))
|
|
11
|
+
throw new TypeError('districtStyle must return a style object.');
|
|
12
|
+
for (const key of Object.keys(style))
|
|
13
|
+
if (!['fill', 'stroke', 'opacity'].includes(key))
|
|
14
|
+
throw new TypeError(`Unknown district style: ${key}`);
|
|
15
|
+
const copy = { ...style };
|
|
16
|
+
for (const key of ['fill', 'stroke'])
|
|
17
|
+
if (copy[key] !== undefined && typeof copy[key] !== 'string')
|
|
18
|
+
throw new TypeError(`${key} must be a string.`);
|
|
19
|
+
if (copy.opacity !== undefined &&
|
|
20
|
+
(!Number.isFinite(copy.opacity) || copy.opacity < 0 || copy.opacity > 1))
|
|
21
|
+
throw new RangeError('District opacity must be between 0 and 1.');
|
|
22
|
+
return copy;
|
|
23
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Own both district-layer fades and remove their inert copies synchronously on cancellation. */
|
|
2
|
+
export function createDistrictTransition() {
|
|
3
|
+
const active = new Map();
|
|
4
|
+
const remove = (animation, node) => {
|
|
5
|
+
active.delete(animation);
|
|
6
|
+
node.remove();
|
|
7
|
+
};
|
|
8
|
+
return {
|
|
9
|
+
cancel() {
|
|
10
|
+
for (const [animation, node] of active) {
|
|
11
|
+
animation.cancel();
|
|
12
|
+
node.remove();
|
|
13
|
+
}
|
|
14
|
+
active.clear();
|
|
15
|
+
},
|
|
16
|
+
fade(layer, duration) {
|
|
17
|
+
if (!duration)
|
|
18
|
+
return;
|
|
19
|
+
const old = layer.cloneNode(true);
|
|
20
|
+
old.dataset.districtTransition = '';
|
|
21
|
+
old.setAttribute('aria-hidden', 'true');
|
|
22
|
+
old.style.pointerEvents = 'none';
|
|
23
|
+
for (const path of old.querySelectorAll('[data-district]')) {
|
|
24
|
+
path.removeAttribute('tabindex');
|
|
25
|
+
path.removeAttribute('role');
|
|
26
|
+
path.removeAttribute('aria-label');
|
|
27
|
+
path.removeAttribute('aria-pressed');
|
|
28
|
+
}
|
|
29
|
+
layer.after(old);
|
|
30
|
+
const animation = old.animate([{ opacity: 1 }, { opacity: 0 }], {
|
|
31
|
+
duration,
|
|
32
|
+
easing: 'ease-out',
|
|
33
|
+
});
|
|
34
|
+
active.set(animation, old);
|
|
35
|
+
animation.finished.then(() => remove(animation, old), () => remove(animation, old));
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export declare function element<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, className?: string): HTMLElementTagNameMap[K];
|
|
2
|
+
export declare function svgElement<K extends keyof SVGElementTagNameMap>(tag: K, attributes?: Record<string, string | number>): SVGElementTagNameMap[K];
|
package/dist/src/dom.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function element(tag, text, className) {
|
|
2
|
+
const node = document.createElement(tag);
|
|
3
|
+
if (text)
|
|
4
|
+
node.textContent = text;
|
|
5
|
+
if (className)
|
|
6
|
+
node.className = className;
|
|
7
|
+
return node;
|
|
8
|
+
}
|
|
9
|
+
export function svgElement(tag, attributes = {}) {
|
|
10
|
+
const node = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
|
11
|
+
for (const [key, value] of Object.entries(attributes))
|
|
12
|
+
node.setAttribute(key, String(value));
|
|
13
|
+
return node;
|
|
14
|
+
}
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { createCamera, validateCameraOptions } from './camera.js';
|
|
2
2
|
import { clusterPoints } from './clusters.js';
|
|
3
|
-
import {
|
|
3
|
+
import { renderDistrictAppearance } from './district-layer.js';
|
|
4
|
+
import { prepareDistrictStyles } from './district-style.js';
|
|
5
|
+
import { createDistrictTransition } from './district-transition.js';
|
|
6
|
+
import { element, svgElement } from './dom.js';
|
|
7
|
+
import { fitBounds, interiorAnchor, projectedBounds } from './explorer-layout.js';
|
|
4
8
|
import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from './features.js';
|
|
5
9
|
import { geometryPath } from './geometry.js';
|
|
10
|
+
import { createLabelRenderer } from './label-renderer.js';
|
|
6
11
|
import { createSFMapWithData, districtColors, getLayerPathsWithData } from './map-core.js';
|
|
12
|
+
import { createMarkerLayer } from './marker-layer.js';
|
|
7
13
|
import { attachNavigation } from './navigation.js';
|
|
8
14
|
import { validateExplorerOptions, validateMarkers, validateOverlays } from './validation.js';
|
|
9
15
|
import { fitViewport, validateViewport } from './viewport.js';
|
|
10
|
-
const svgNS = 'http://www.w3.org/2000/svg';
|
|
11
16
|
const sourceNames = {
|
|
12
17
|
realtor: 'SFAR realtor · 92 areas',
|
|
13
18
|
'sf-find': 'SF Find · 117 areas',
|
|
@@ -20,20 +25,6 @@ const normalizeName = (value) => value
|
|
|
20
25
|
.replace(/[^a-z0-9]+/g, ' ')
|
|
21
26
|
.trim();
|
|
22
27
|
const formatSourceLabel = (source) => sourceNames[source] ?? `${source} neighborhoods`;
|
|
23
|
-
function element(tag, text, className) {
|
|
24
|
-
const node = document.createElement(tag);
|
|
25
|
-
if (text)
|
|
26
|
-
node.textContent = text;
|
|
27
|
-
if (className)
|
|
28
|
-
node.className = className;
|
|
29
|
-
return node;
|
|
30
|
-
}
|
|
31
|
-
function svgElement(tag, attributes = {}) {
|
|
32
|
-
const node = document.createElementNS(svgNS, tag);
|
|
33
|
-
for (const [key, value] of Object.entries(attributes))
|
|
34
|
-
node.setAttribute(key, String(value));
|
|
35
|
-
return node;
|
|
36
|
-
}
|
|
37
28
|
let explorerCount = 0;
|
|
38
29
|
/** Create an offline, browser-only neighborhood explorer. Call destroy() before disposal. */
|
|
39
30
|
export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
@@ -314,7 +305,6 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
314
305
|
districtLabels: false,
|
|
315
306
|
districtLines: true,
|
|
316
307
|
districtFills: true,
|
|
317
|
-
districtStyle: initialDistrictStyle,
|
|
318
308
|
highways: true,
|
|
319
309
|
keyRoads: true,
|
|
320
310
|
roadLabels: false,
|
|
@@ -380,13 +370,16 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
380
370
|
'aria-hidden': 'true',
|
|
381
371
|
'font-family': labelStyle.fontFamily ?? styleOptions.font ?? 'system-ui,sans-serif',
|
|
382
372
|
});
|
|
373
|
+
const labelRenderer = createLabelRenderer(labelLayer);
|
|
383
374
|
geography.append(areas, stations, labelLayer, overlayLayer, markerLayer);
|
|
384
375
|
let view = [0, 0, 800];
|
|
385
376
|
let selected;
|
|
386
377
|
let selectedDistrict = null;
|
|
387
378
|
let hoveredDistrict = null;
|
|
388
379
|
let districtStyle = initialDistrictStyle;
|
|
389
|
-
let
|
|
380
|
+
let districtStyles = prepareDistrictStyles(data.map.districts?.[year] ?? [], districtStyle);
|
|
381
|
+
const districtTransition = createDistrictTransition();
|
|
382
|
+
let districtRevision = 0;
|
|
390
383
|
root.dataset.year = String(year);
|
|
391
384
|
function districtRow(id) {
|
|
392
385
|
return data.map.districts?.[year]?.find((district) => district.id === id);
|
|
@@ -403,54 +396,18 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
403
396
|
: districtSelection(selectedDistrict);
|
|
404
397
|
}
|
|
405
398
|
function updateDistrictAppearance() {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
else
|
|
419
|
-
fill.setAttribute('fill-opacity', String(styled.opacity));
|
|
420
|
-
fill.setAttribute('role', 'button');
|
|
421
|
-
fill.setAttribute('aria-label', `District ${row.id}, ${year}`);
|
|
422
|
-
fill.setAttribute('aria-pressed', String(active));
|
|
423
|
-
fill.setAttribute('tabindex', row.id === 1 ? '0' : '-1');
|
|
424
|
-
fill.style.cursor = 'pointer';
|
|
425
|
-
}
|
|
426
|
-
if (line) {
|
|
427
|
-
line.setAttribute('stroke', active
|
|
428
|
-
? (colors.selected ?? '#f04f32')
|
|
429
|
-
: hovered
|
|
430
|
-
? '#163e56'
|
|
431
|
-
: (styled?.stroke ?? colors.district ?? '#a7b8c0'));
|
|
432
|
-
line.setAttribute('stroke-width', active ? '3' : hovered ? '2.2' : '1.1');
|
|
433
|
-
const lineInteractive = !enabled('districtFills') && enabled('districtLines');
|
|
434
|
-
line.style.pointerEvents = lineInteractive ? 'stroke' : 'none';
|
|
435
|
-
if (lineInteractive) {
|
|
436
|
-
line.setAttribute('role', 'button');
|
|
437
|
-
line.setAttribute('aria-label', `District ${row.id}, ${year}`);
|
|
438
|
-
line.setAttribute('aria-pressed', String(active));
|
|
439
|
-
line.setAttribute('tabindex', row.id === 1 ? '0' : '-1');
|
|
440
|
-
line.style.cursor = 'pointer';
|
|
441
|
-
}
|
|
442
|
-
else {
|
|
443
|
-
line.removeAttribute('role');
|
|
444
|
-
line.removeAttribute('aria-label');
|
|
445
|
-
line.removeAttribute('aria-pressed');
|
|
446
|
-
line.removeAttribute('tabindex');
|
|
447
|
-
}
|
|
448
|
-
if (styled?.opacity === undefined)
|
|
449
|
-
line.removeAttribute('stroke-opacity');
|
|
450
|
-
else
|
|
451
|
-
line.setAttribute('stroke-opacity', String(styled.opacity));
|
|
452
|
-
}
|
|
453
|
-
}
|
|
399
|
+
renderDistrictAppearance({
|
|
400
|
+
svg,
|
|
401
|
+
rows: data.map.districts?.[year] ?? [],
|
|
402
|
+
styles: districtStyles,
|
|
403
|
+
selectedDistrict,
|
|
404
|
+
hoveredDistrict,
|
|
405
|
+
year,
|
|
406
|
+
theme,
|
|
407
|
+
colors,
|
|
408
|
+
fills: enabled('districtFills'),
|
|
409
|
+
lines: enabled('districtLines'),
|
|
410
|
+
});
|
|
454
411
|
}
|
|
455
412
|
function selectDistrict(id, options = {}) {
|
|
456
413
|
if (destroyed)
|
|
@@ -459,6 +416,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
459
416
|
if (id !== null && (!Number.isInteger(id) || !districtRow(id)))
|
|
460
417
|
return false;
|
|
461
418
|
const changed = selectedDistrict !== id;
|
|
419
|
+
const revision = ++districtRevision;
|
|
462
420
|
selectedDistrict = id;
|
|
463
421
|
if (id === null)
|
|
464
422
|
delete root.dataset.selectedDistrict;
|
|
@@ -468,6 +426,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
468
426
|
const selectedRow = id === null ? undefined : districtRow(id);
|
|
469
427
|
if (selectedRow && options.fit)
|
|
470
428
|
fitGeometry(selectedRow.geometry, undefined, options);
|
|
429
|
+
if (destroyed || revision !== districtRevision)
|
|
430
|
+
return true;
|
|
471
431
|
status.textContent =
|
|
472
432
|
id === null ? 'District selection cleared.' : `District ${id} selected, ${year} boundaries.`;
|
|
473
433
|
if (changed)
|
|
@@ -477,10 +437,13 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
477
437
|
function setDistrictStyle(style) {
|
|
478
438
|
if (destroyed)
|
|
479
439
|
return;
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
440
|
+
const revision = districtRevision;
|
|
441
|
+
const prepared = prepareDistrictStyles(data.map.districts?.[year] ?? [], style);
|
|
442
|
+
if (destroyed || revision !== districtRevision)
|
|
443
|
+
return;
|
|
444
|
+
districtRevision++;
|
|
483
445
|
districtStyle = style;
|
|
446
|
+
districtStyles = prepared;
|
|
484
447
|
updateDistrictAppearance();
|
|
485
448
|
}
|
|
486
449
|
function setDistrictYear(next, options = {}) {
|
|
@@ -493,28 +456,17 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
493
456
|
throw new RangeError(`No ${next} district dataset was supplied.`);
|
|
494
457
|
if (next === year)
|
|
495
458
|
return;
|
|
496
|
-
const
|
|
497
|
-
|
|
459
|
+
const revision = districtRevision;
|
|
460
|
+
const prepared = prepareDistrictStyles(data.map.districts[next], districtStyle);
|
|
461
|
+
if (destroyed || revision !== districtRevision)
|
|
462
|
+
return;
|
|
463
|
+
const committedRevision = ++districtRevision;
|
|
464
|
+
const geometry = getLayerPathsWithData({ year: next }, data.map, false);
|
|
498
465
|
const previousYear = year;
|
|
499
|
-
districtTransition
|
|
466
|
+
districtTransition.cancel();
|
|
500
467
|
for (const layer of districtLayers) {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
old.setAttribute('aria-hidden', 'true');
|
|
504
|
-
old.style.pointerEvents = 'none';
|
|
505
|
-
for (const path of old.querySelectorAll('[data-district]')) {
|
|
506
|
-
path.removeAttribute('tabindex');
|
|
507
|
-
path.removeAttribute('role');
|
|
508
|
-
path.removeAttribute('aria-label');
|
|
509
|
-
path.removeAttribute('aria-pressed');
|
|
510
|
-
}
|
|
511
|
-
layer.after(old);
|
|
512
|
-
districtTransition = old.animate([{ opacity: 1 }, { opacity: 0 }], {
|
|
513
|
-
duration: options.duration ?? 280,
|
|
514
|
-
easing: 'ease-out',
|
|
515
|
-
});
|
|
516
|
-
districtTransition.finished.then(() => old.remove()).catch(() => old.remove());
|
|
517
|
-
}
|
|
468
|
+
if (options.animate && !reducedMotion.matches)
|
|
469
|
+
districtTransition.fade(layer, options.duration ?? 280);
|
|
518
470
|
layer.replaceChildren(...geometry.districts.map((district) => svgElement('path', {
|
|
519
471
|
'data-district': district.id,
|
|
520
472
|
d: district.path,
|
|
@@ -524,6 +476,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
524
476
|
})));
|
|
525
477
|
}
|
|
526
478
|
year = next;
|
|
479
|
+
districtStyles = prepared;
|
|
527
480
|
hoveredDistrict = null;
|
|
528
481
|
root.dataset.year = String(year);
|
|
529
482
|
svg.dataset.year = String(year);
|
|
@@ -541,7 +494,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
541
494
|
scheduleLabels();
|
|
542
495
|
status.textContent = `${year} supervisorial districts.`;
|
|
543
496
|
root.dispatchEvent(new CustomEvent('districtyearchange', { bubbles: true, detail: { year, previousYear } }));
|
|
544
|
-
if (selectedDistrict !== null)
|
|
497
|
+
if (!destroyed && committedRevision === districtRevision && selectedDistrict !== null)
|
|
545
498
|
root.dispatchEvent(new CustomEvent('districtchange', {
|
|
546
499
|
bubbles: true,
|
|
547
500
|
detail: districtSelection(selectedDistrict),
|
|
@@ -552,12 +505,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
552
505
|
let destroyed = false;
|
|
553
506
|
let initialized = false;
|
|
554
507
|
let frame = 0;
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
for (const animation of entranceAnimations)
|
|
558
|
-
animation.cancel();
|
|
559
|
-
entranceAnimations.clear();
|
|
560
|
-
}
|
|
508
|
+
const markerRenderer = createMarkerLayer(markerLayer);
|
|
509
|
+
const cancelEntrances = markerRenderer.cancelEntrances;
|
|
561
510
|
const camera = createCamera({
|
|
562
511
|
read: getViewport,
|
|
563
512
|
write: setView,
|
|
@@ -858,7 +807,6 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
858
807
|
const width = canvas.getBoundingClientRect().width;
|
|
859
808
|
if (!width)
|
|
860
809
|
return;
|
|
861
|
-
labelLayer.replaceChildren();
|
|
862
810
|
const unit = view[2] / width, zoom = 800 / view[2];
|
|
863
811
|
for (const road of svg.querySelectorAll('[data-key-road-level="secondary"]'))
|
|
864
812
|
road.style.display = zoom >= 1.8 ? '' : 'none';
|
|
@@ -884,6 +832,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
884
832
|
scale.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`;
|
|
885
833
|
}
|
|
886
834
|
if (!labels) {
|
|
835
|
+
labelRenderer.clear();
|
|
887
836
|
root.dataset.visibleLabels = '0';
|
|
888
837
|
return;
|
|
889
838
|
}
|
|
@@ -919,13 +868,14 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
919
868
|
name: item.feature.properties.canonicalName,
|
|
920
869
|
kind: 'neighborhood',
|
|
921
870
|
})));
|
|
922
|
-
const
|
|
871
|
+
const labelCandidates = candidates.map((item) => {
|
|
923
872
|
const fontSize = item.kind === 'road'
|
|
924
873
|
? Math.max(minLabelSize, Math.min(11, maxLabelSize))
|
|
925
874
|
: Math.max(minLabelSize, Math.min(maxLabelSize, 12));
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
875
|
+
return {
|
|
876
|
+
...item,
|
|
877
|
+
fontSize,
|
|
878
|
+
fontWeight: labelStyle.fontWeight ??
|
|
929
879
|
(item.kind.startsWith('selected') || item.kind === 'district' ? 700 : 550),
|
|
930
880
|
fill: colors.label ??
|
|
931
881
|
(item.kind === 'park'
|
|
@@ -933,22 +883,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
933
883
|
: item.kind === 'road'
|
|
934
884
|
? '#77736b'
|
|
935
885
|
: '#163d61'),
|
|
936
|
-
|
|
937
|
-
'stroke-width': 3 * unit,
|
|
938
|
-
'stroke-linejoin': 'round',
|
|
939
|
-
'paint-order': 'stroke',
|
|
940
|
-
'data-label-kind': item.kind,
|
|
941
|
-
});
|
|
942
|
-
node.textContent = item.name;
|
|
943
|
-
labelLayer.append(node);
|
|
944
|
-
return {
|
|
945
|
-
...item,
|
|
946
|
-
node,
|
|
947
|
-
x: (item.point[0] - view[0]) / unit,
|
|
948
|
-
y: (item.point[1] - view[1]) / unit,
|
|
949
|
-
textWidth: node.getComputedTextLength() / unit,
|
|
950
|
-
fontSize,
|
|
951
|
-
textHeight: fontSize * 1.25,
|
|
886
|
+
halo: labelStyle.haloColor ?? '#ffffff',
|
|
952
887
|
offset: item.kind === 'selected-marker'
|
|
953
888
|
? (marker?.marker.radius ?? markerRadius) + 9
|
|
954
889
|
: item.kind === 'bart'
|
|
@@ -980,19 +915,11 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
980
915
|
box.bottom - canvasBox.top + 3,
|
|
981
916
|
];
|
|
982
917
|
});
|
|
983
|
-
|
|
918
|
+
root.dataset.visibleLabels = String(labelRenderer.draw(labelCandidates, view, width, [
|
|
984
919
|
...stationBounds,
|
|
985
920
|
...markerBounds,
|
|
986
921
|
...furnitureBounds,
|
|
987
|
-
]);
|
|
988
|
-
for (const item of measured)
|
|
989
|
-
item.node.remove();
|
|
990
|
-
for (const item of placed) {
|
|
991
|
-
item.node.setAttribute('x', String(view[0] + item.left * unit));
|
|
992
|
-
item.node.setAttribute('y', String(view[1] + (item.top + item.fontSize) * unit));
|
|
993
|
-
labelLayer.append(item.node);
|
|
994
|
-
}
|
|
995
|
-
root.dataset.visibleLabels = String(placed.length);
|
|
922
|
+
]));
|
|
996
923
|
}
|
|
997
924
|
function releaseDownloads() {
|
|
998
925
|
for (const url of downloads)
|
|
@@ -1376,65 +1303,22 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1376
1303
|
.id;
|
|
1377
1304
|
invalidateClusters();
|
|
1378
1305
|
const previousIds = new Set(markerItems.map((item) => item.marker.id));
|
|
1379
|
-
|
|
1380
|
-
animation.cancel();
|
|
1381
|
-
entranceAnimations.clear();
|
|
1306
|
+
markerRenderer.clear();
|
|
1382
1307
|
const previous = selectedMarker;
|
|
1383
|
-
markerLayer.replaceChildren();
|
|
1384
1308
|
markerSelect.replaceChildren(element('option', 'No marker selected'));
|
|
1385
1309
|
markerSelect.options[0].value = '';
|
|
1386
1310
|
markerItems = next.map(({ marker, point }, index) => {
|
|
1387
|
-
const
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
'aria-pressed': 'false',
|
|
1394
|
-
});
|
|
1395
|
-
const hit = svgElement('circle', { fill: 'transparent', 'pointer-events': 'all' });
|
|
1396
|
-
const dot = svgElement('circle', {
|
|
1397
|
-
fill: marker.color ?? markerColor,
|
|
1398
|
-
stroke: '#fff9e9',
|
|
1399
|
-
'stroke-width': 2,
|
|
1400
|
-
'vector-effect': 'non-scaling-stroke',
|
|
1401
|
-
'pointer-events': 'none',
|
|
1402
|
-
});
|
|
1403
|
-
const title = svgElement('title');
|
|
1404
|
-
title.textContent = marker.label ?? marker.id;
|
|
1405
|
-
const ring = svgElement('circle', {
|
|
1406
|
-
fill: 'none',
|
|
1407
|
-
stroke: features.selectedMarkerRing
|
|
1408
|
-
? (features.selectedMarkerRing.color ?? selectedMarkerColor)
|
|
1409
|
-
: selectedMarkerColor,
|
|
1410
|
-
'stroke-width': features.selectedMarkerRing ? features.selectedMarkerRing.width : 2,
|
|
1411
|
-
'vector-effect': 'non-scaling-stroke',
|
|
1412
|
-
'pointer-events': 'none',
|
|
1413
|
-
display: 'none',
|
|
1311
|
+
const item = markerRenderer.add(marker, point, index, {
|
|
1312
|
+
features,
|
|
1313
|
+
markerColor,
|
|
1314
|
+
selectedMarkerColor,
|
|
1315
|
+
reducedMotion: reducedMotion.matches,
|
|
1316
|
+
enter: !previousIds.has(marker.id),
|
|
1414
1317
|
});
|
|
1415
|
-
node.style.setProperty('--sf-marker-index', String(index));
|
|
1416
|
-
node.append(title, hit, ring, dot);
|
|
1417
|
-
if (features.markerEntrance &&
|
|
1418
|
-
!previousIds.has(marker.id) &&
|
|
1419
|
-
!reducedMotion.matches &&
|
|
1420
|
-
typeof dot.animate === 'function') {
|
|
1421
|
-
const animation = dot.animate([
|
|
1422
|
-
{ opacity: 0, transform: 'translateY(-12px)' },
|
|
1423
|
-
{ opacity: 1, transform: 'translateY(0)' },
|
|
1424
|
-
], {
|
|
1425
|
-
duration: features.markerEntrance.duration,
|
|
1426
|
-
delay: Math.min(index * features.markerEntrance.stagger, 1000),
|
|
1427
|
-
easing: 'cubic-bezier(.2,.8,.2,1)',
|
|
1428
|
-
fill: 'backwards',
|
|
1429
|
-
});
|
|
1430
|
-
entranceAnimations.add(animation);
|
|
1431
|
-
animation.onfinish = () => entranceAnimations.delete(animation);
|
|
1432
|
-
}
|
|
1433
|
-
markerLayer.append(node);
|
|
1434
1318
|
const option = element('option', marker.label ?? marker.id);
|
|
1435
1319
|
option.value = marker.id;
|
|
1436
1320
|
markerSelect.append(option);
|
|
1437
|
-
return
|
|
1321
|
+
return item;
|
|
1438
1322
|
});
|
|
1439
1323
|
markerLabel.hidden = !markerItems.length || controls.markerPicker === false;
|
|
1440
1324
|
if (markerLabel.firstChild)
|
|
@@ -1707,6 +1591,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1707
1591
|
destroy() {
|
|
1708
1592
|
destroyed = true;
|
|
1709
1593
|
camera.destroy();
|
|
1594
|
+
districtTransition.cancel();
|
|
1710
1595
|
cancelEntrances();
|
|
1711
1596
|
controller.abort();
|
|
1712
1597
|
overlayEvents.abort();
|
|
@@ -1714,16 +1599,21 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1714
1599
|
canvas.style.touchAction = 'pan-y pinch-zoom';
|
|
1715
1600
|
observer?.disconnect();
|
|
1716
1601
|
cancelAnimationFrame(frame);
|
|
1602
|
+
labelRenderer.clear();
|
|
1717
1603
|
releaseDownloads();
|
|
1718
1604
|
},
|
|
1719
1605
|
});
|
|
1720
1606
|
try {
|
|
1721
1607
|
observer = new ResizeObserver(onResize);
|
|
1722
1608
|
observer.observe(canvas);
|
|
1723
|
-
document.fonts?.addEventListener('loadingdone',
|
|
1609
|
+
document.fonts?.addEventListener('loadingdone', () => {
|
|
1610
|
+
labelRenderer.invalidateMetrics();
|
|
1611
|
+
scheduleLabels();
|
|
1612
|
+
}, { signal: controller.signal });
|
|
1724
1613
|
reducedMotion.addEventListener('change', () => {
|
|
1725
1614
|
if (reducedMotion.matches) {
|
|
1726
1615
|
stopAnimation();
|
|
1616
|
+
districtTransition.cancel();
|
|
1727
1617
|
cancelEntrances();
|
|
1728
1618
|
}
|
|
1729
1619
|
}, { signal: controller.signal });
|