@kahwee/sf-map-svg 3.0.1 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -1
- package/README.md +8 -2
- package/dist/data/README.md +1 -1
- 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/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/types.d.ts +1 -1
- package/dist/src/validation.js +5 -0
- package/docs/EXAMPLES.md +7 -6
- package/docs/api-audit.md +8 -3
- package/docs/consumer-integration.md +9 -4
- package/docs/guide-bundle-report.md +4 -4
- package/docs/migration-v3.md +6 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,21 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 4.0.0 — 2026-09-28
|
|
8
|
+
|
|
9
|
+
- Breaking: require Node 24 or newer for server rendering and development. Validate the minimum supported major alongside Node 26 in CI; browser runtime requirements are unchanged.
|
|
10
|
+
- No map API, geographic data, rendering, or animation changes. Existing v3 integrations only need a supported Node runtime to upgrade.
|
|
11
|
+
|
|
12
|
+
## 3.1.0 — 2026-09-28
|
|
13
|
+
|
|
14
|
+
- Add `createGuideController` and `mountGuideController` with the grouped options, camera, subscriptions, and lifecycle of `createMap`; preserve existing element-based guide factories.
|
|
15
|
+
- Reject invalid marker coordinates before static rendering invokes district-style callbacks.
|
|
16
|
+
- Prepare district styles once per update before changing the map, and reuse projected district paths.
|
|
17
|
+
- Track both district crossfades so interrupted transitions, reduced motion, and teardown remove all outgoing layers.
|
|
18
|
+
- 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.
|
|
19
|
+
- Give marker visuals and entrance animations their own lifecycle, and prevent reentrant district callbacks from overwriting newer state or restarting work after destruction.
|
|
20
|
+
- 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.
|
|
21
|
+
|
|
7
22
|
## 3.0.1 — 2026-09-27
|
|
8
23
|
|
|
9
24
|
- Narrow the complete-data presets to their direct geographic modules without changing rendered SVGs or public API behavior.
|
|
@@ -226,7 +241,7 @@ consistent home, and makes animation and subscription ownership predictable.
|
|
|
226
241
|
- Add overlay color options, TypeScript declarations, source records, and SVG checks.
|
|
227
242
|
- Add Storybook 10.6 with eight interactive examples and API controls.
|
|
228
243
|
- Add usage examples, contributor instructions, and AGENTS.md.
|
|
229
|
-
- Update GitHub Actions and validate Storybook and package builds
|
|
244
|
+
- Update GitHub Actions and validate Storybook and package builds across supported Node versions.
|
|
230
245
|
- Configure weekly Dependabot updates for development dependencies and Actions.
|
|
231
246
|
- Keep existing layer defaults unchanged, runtime dependencies at zero, and distribution private.
|
|
232
247
|
|
package/README.md
CHANGED
|
@@ -10,7 +10,9 @@ Offline, self-contained San Francisco SVG maps. The package has no runtime depen
|
|
|
10
10
|
pnpm add @kahwee/sf-map-svg
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Node
|
|
13
|
+
Node 24+ is required for server rendering. Browser maps need a DOM and a bundler that supports JSON imports.
|
|
14
|
+
|
|
15
|
+
Upgrading from v3 to v4: update your Node runtime to 24 or newer. The map API, browser requirements, rendering, and animations are unchanged.
|
|
14
16
|
|
|
15
17
|
## Static SVG
|
|
16
18
|
|
|
@@ -52,12 +54,16 @@ map.destroy();
|
|
|
52
54
|
|
|
53
55
|
`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
56
|
|
|
55
|
-
Construction options include `mode` (`basemap`, `neighborhoods`, or `districts`), `source`, `neighborhood`, `year`, `labels`, `layers`, `controls`, `features`, `appearance`, `markers`, `overlays`, `legend`, `strings`, `attribution`, `fitPadding
|
|
57
|
+
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
58
|
|
|
57
59
|
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
60
|
|
|
59
61
|
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
62
|
|
|
63
|
+
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.
|
|
64
|
+
|
|
65
|
+
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.
|
|
66
|
+
|
|
61
67
|
## Data and development
|
|
62
68
|
|
|
63
69
|
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.
|
package/dist/data/README.md
CHANGED
|
@@ -37,7 +37,7 @@ const mission = neighborhoods.features.find((f) => f.id === 'inner-mission');
|
|
|
37
37
|
console.log(mission.properties.canonicalName, mission.geometry);
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
Node
|
|
40
|
+
Node 24+ supports this syntax. Bundlers may also support JSON imports without the import attribute. Files are included in the package archive; non-JavaScript consumers can parse the same JSON files directly. These are package entry points, not a hosted API. The public repository and GitHub Pages explorer also provide access to the source data. Consumers who need only one dataset can import its JSON subpath or a typed, frozen leaf module.
|
|
41
41
|
|
|
42
42
|
## Import only the data you need
|
|
43
43
|
|
|
@@ -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 });
|
package/dist/src/guide-map.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import type { MapController, MapOptions } from './controller-types.js';
|
|
1
2
|
import type { NeighborhoodExplorerElement as InteractiveSFMapElement, NeighborhoodExplorerOptions as InteractiveSFMapOptions } from './types.js';
|
|
2
|
-
/**
|
|
3
|
+
/** Preferred guide API: the same grouped options, events, camera and lifecycle as createMap. */
|
|
4
|
+
export declare function createGuideController(options?: MapOptions): MapController;
|
|
5
|
+
/** Enhance a static guide shell and return its owning controller. */
|
|
6
|
+
export declare function mountGuideController(shell: HTMLElement, options?: MapOptions): MapController;
|
|
7
|
+
/** Element-based compatibility API. Prefer createGuideController for new integrations. */
|
|
3
8
|
export declare function createGuideMap(options?: InteractiveSFMapOptions): InteractiveSFMapElement;
|
|
4
9
|
/** Enhance createGuideShell() in place with a fixed compact chrome layout.
|
|
5
10
|
* Keep an accessible external place list when hiding the native pickers.
|
package/dist/src/guide-map.js
CHANGED
|
@@ -1,8 +1,64 @@
|
|
|
1
|
+
import { expandMapOptions, prepareConfiguration } from './configuration.js';
|
|
1
2
|
import { createNeighborhoodExplorerCore } from './explorer-core.js';
|
|
2
3
|
import { guideMapData } from './guide-data.js';
|
|
4
|
+
import { createMap } from './map.js';
|
|
3
5
|
import { guideOptions } from './presets.js';
|
|
4
6
|
import { validateExplorerOptions } from './validation.js';
|
|
5
|
-
|
|
7
|
+
function controllerOptions(options) {
|
|
8
|
+
expandMapOptions(options);
|
|
9
|
+
const config = prepareConfiguration({ features: {}, layers: guideOptions.layers ?? {}, controls: {} }, {
|
|
10
|
+
features: options.features,
|
|
11
|
+
layers: options.layers === undefined ? {} : options.layers,
|
|
12
|
+
controls: options.controls,
|
|
13
|
+
});
|
|
14
|
+
return {
|
|
15
|
+
...guideOptions,
|
|
16
|
+
...options,
|
|
17
|
+
mode: options.mode === undefined ? guideOptions.mode : options.mode,
|
|
18
|
+
layers: config.layers,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** Preferred guide API: the same grouped options, events, camera and lifecycle as createMap. */
|
|
22
|
+
export function createGuideController(options = {}) {
|
|
23
|
+
return createMap(guideMapData, controllerOptions(options));
|
|
24
|
+
}
|
|
25
|
+
/** Enhance a static guide shell and return its owning controller. */
|
|
26
|
+
export function mountGuideController(shell, options = {}) {
|
|
27
|
+
const prepared = controllerOptions(options);
|
|
28
|
+
const frame = shellFrame(shell, prepared.attribution);
|
|
29
|
+
const map = createMap(guideMapData, {
|
|
30
|
+
...prepared,
|
|
31
|
+
attribution: 'compact',
|
|
32
|
+
controls: { ...shellControls, ...prepared.controls },
|
|
33
|
+
});
|
|
34
|
+
mountFrame(frame, map.element);
|
|
35
|
+
return map;
|
|
36
|
+
}
|
|
37
|
+
const shellControls = {
|
|
38
|
+
pan: false,
|
|
39
|
+
labels: false,
|
|
40
|
+
neighborhoodPicker: false,
|
|
41
|
+
markerPicker: false,
|
|
42
|
+
help: false,
|
|
43
|
+
status: false,
|
|
44
|
+
legend: true,
|
|
45
|
+
};
|
|
46
|
+
function shellFrame(shell, attribution) {
|
|
47
|
+
if (attribution === 'full')
|
|
48
|
+
throw new TypeError('The guide shell requires compact attribution; use createGuideController for full attribution.');
|
|
49
|
+
if (!shell.classList.contains('sf-guide-shell'))
|
|
50
|
+
throw new TypeError('Expected a createGuideShell container.');
|
|
51
|
+
const frame = shell.querySelector(':scope > .sf-guide-frame');
|
|
52
|
+
if (!frame || frame.classList.contains('sf-explorer'))
|
|
53
|
+
throw new Error('Shell is missing its static frame or already mounted.');
|
|
54
|
+
return frame;
|
|
55
|
+
}
|
|
56
|
+
function mountFrame(frame, map) {
|
|
57
|
+
map.classList.add('sf-guide-frame');
|
|
58
|
+
map.querySelector('.sf-explorer-map-column > details')?.classList.add('sf-guide-sources');
|
|
59
|
+
frame.replaceWith(map);
|
|
60
|
+
}
|
|
61
|
+
/** Element-based compatibility API. Prefer createGuideController for new integrations. */
|
|
6
62
|
export function createGuideMap(options = {}) {
|
|
7
63
|
validateExplorerOptions(options);
|
|
8
64
|
return createNeighborhoodExplorerCore({
|
|
@@ -20,29 +76,15 @@ export function createGuideMap(options = {}) {
|
|
|
20
76
|
*/
|
|
21
77
|
export function mountGuideMap(shell, options = {}) {
|
|
22
78
|
validateExplorerOptions(options);
|
|
23
|
-
|
|
24
|
-
throw new TypeError('The guide shell requires compact attribution; use createGuideMap for full attribution.');
|
|
25
|
-
if (!shell.classList.contains('sf-guide-shell'))
|
|
26
|
-
throw new TypeError('Expected a createGuideShell container.');
|
|
27
|
-
const frame = shell.querySelector(':scope > .sf-guide-frame');
|
|
28
|
-
if (!frame || frame.classList.contains('sf-explorer'))
|
|
29
|
-
throw new Error('Shell is missing its static frame or already mounted.');
|
|
79
|
+
const frame = shellFrame(shell, options.attribution);
|
|
30
80
|
const map = createGuideMap({
|
|
31
81
|
...options,
|
|
32
82
|
attribution: 'compact',
|
|
33
83
|
controls: {
|
|
34
|
-
|
|
35
|
-
labels: false,
|
|
36
|
-
neighborhoodPicker: false,
|
|
37
|
-
markerPicker: false,
|
|
38
|
-
help: false,
|
|
39
|
-
status: false,
|
|
40
|
-
legend: true,
|
|
84
|
+
...shellControls,
|
|
41
85
|
...options.controls,
|
|
42
86
|
},
|
|
43
87
|
});
|
|
44
|
-
|
|
45
|
-
map.querySelector('.sf-explorer-map-column > details')?.classList.add('sf-guide-sources');
|
|
46
|
-
frame.replaceWith(map);
|
|
88
|
+
mountFrame(frame, map);
|
|
47
89
|
return map;
|
|
48
90
|
}
|
package/dist/src/guide.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
export type { MapController, MapOptions } from './controller-types.js';
|
|
1
2
|
export type { InteractiveSFMapData } from './explorer-data.js';
|
|
2
3
|
export { guideMapData } from './guide-data.js';
|
|
3
4
|
export { loadGuideDetailedData } from './guide-detailed.js';
|
|
4
|
-
export { createGuideMap, mountGuideMap } from './guide-map.js';
|
|
5
|
+
export { createGuideController, createGuideMap, mountGuideController, mountGuideMap, } from './guide-map.js';
|
|
5
6
|
export type { CameraOptions, MapFeatures, MapMarker, NeighborhoodExplorerElement as InteractiveSFMapElement, NeighborhoodExplorerOptions as InteractiveSFMapOptions, } from './types.js';
|
package/dist/src/guide.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { guideMapData } from './guide-data.js';
|
|
2
2
|
export { loadGuideDetailedData } from './guide-detailed.js';
|
|
3
|
-
export { createGuideMap, mountGuideMap } from './guide-map.js';
|
|
3
|
+
export { createGuideController, createGuideMap, mountGuideController, mountGuideMap, } from './guide-map.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Bounds } from '../data/types.js';
|
|
2
|
+
export interface RenderLabel {
|
|
3
|
+
point: [number, number];
|
|
4
|
+
name: string;
|
|
5
|
+
kind: string;
|
|
6
|
+
fontSize: number;
|
|
7
|
+
fontWeight: number;
|
|
8
|
+
fill: string;
|
|
9
|
+
halo: string;
|
|
10
|
+
offset: number;
|
|
11
|
+
}
|
|
12
|
+
/** Reuse text nodes and screen-space metrics throughout a camera animation. */
|
|
13
|
+
export declare function createLabelRenderer(layer: SVGGElement): {
|
|
14
|
+
invalidateMetrics(): void;
|
|
15
|
+
clear(): void;
|
|
16
|
+
draw(labels: RenderLabel[], view: readonly number[], width: number, obstacles: Bounds[]): number;
|
|
17
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { layoutLabels } from './explorer-layout.js';
|
|
2
|
+
/** Reuse text nodes and screen-space metrics throughout a camera animation. */
|
|
3
|
+
export function createLabelRenderer(layer) {
|
|
4
|
+
const entries = new Map();
|
|
5
|
+
return {
|
|
6
|
+
invalidateMetrics() {
|
|
7
|
+
for (const entry of entries.values())
|
|
8
|
+
entry.width = undefined;
|
|
9
|
+
},
|
|
10
|
+
clear() {
|
|
11
|
+
entries.clear();
|
|
12
|
+
layer.replaceChildren();
|
|
13
|
+
},
|
|
14
|
+
draw(labels, view, width, obstacles) {
|
|
15
|
+
const unit = view[2] / width;
|
|
16
|
+
const keys = new Set();
|
|
17
|
+
const pending = labels.map((label) => {
|
|
18
|
+
const key = JSON.stringify([
|
|
19
|
+
label.kind,
|
|
20
|
+
label.name,
|
|
21
|
+
label.point,
|
|
22
|
+
label.fontSize,
|
|
23
|
+
label.fontWeight,
|
|
24
|
+
]);
|
|
25
|
+
keys.add(key);
|
|
26
|
+
let entry = entries.get(key);
|
|
27
|
+
if (!entry) {
|
|
28
|
+
const node = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
29
|
+
node.textContent = label.name;
|
|
30
|
+
node.setAttribute('stroke-linejoin', 'round');
|
|
31
|
+
node.setAttribute('paint-order', 'stroke');
|
|
32
|
+
node.setAttribute('data-label-kind', label.kind);
|
|
33
|
+
entry = { node };
|
|
34
|
+
entries.set(key, entry);
|
|
35
|
+
}
|
|
36
|
+
const { node } = entry;
|
|
37
|
+
node.setAttribute('font-size', String(label.fontSize * unit));
|
|
38
|
+
node.setAttribute('font-weight', String(label.fontWeight));
|
|
39
|
+
node.setAttribute('fill', label.fill);
|
|
40
|
+
node.setAttribute('stroke', label.halo);
|
|
41
|
+
node.setAttribute('stroke-width', String(3 * unit));
|
|
42
|
+
// Batch writes before measuring new labels. Hidden candidates stay detached.
|
|
43
|
+
if (entry.width === undefined && !node.parentNode)
|
|
44
|
+
layer.append(node);
|
|
45
|
+
return { label, entry };
|
|
46
|
+
});
|
|
47
|
+
for (const [key, entry] of entries) {
|
|
48
|
+
if (!keys.has(key)) {
|
|
49
|
+
entry.node.remove();
|
|
50
|
+
entries.delete(key);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const measured = pending.map(({ label, entry }) => {
|
|
54
|
+
entry.width ??= entry.node.getComputedTextLength() / unit;
|
|
55
|
+
return {
|
|
56
|
+
...label,
|
|
57
|
+
node: entry.node,
|
|
58
|
+
x: (label.point[0] - view[0]) / unit,
|
|
59
|
+
y: (label.point[1] - view[1]) / unit,
|
|
60
|
+
textWidth: entry.width,
|
|
61
|
+
textHeight: label.fontSize * 1.25,
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
const placed = layoutLabels(measured, width, width, obstacles);
|
|
65
|
+
const visible = new Set(placed.map(({ node }) => node));
|
|
66
|
+
for (const { node } of measured)
|
|
67
|
+
if (!visible.has(node))
|
|
68
|
+
node.remove();
|
|
69
|
+
let previous = null;
|
|
70
|
+
for (const item of placed) {
|
|
71
|
+
item.node.setAttribute('x', String(view[0] + item.left * unit));
|
|
72
|
+
item.node.setAttribute('y', String(view[1] + (item.top + item.fontSize) * unit));
|
|
73
|
+
const next = previous ? previous.nextSibling : layer.firstChild;
|
|
74
|
+
if (next !== item.node)
|
|
75
|
+
layer.insertBefore(item.node, next);
|
|
76
|
+
previous = item.node;
|
|
77
|
+
}
|
|
78
|
+
return placed.length;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
package/dist/src/map-core.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { validateMarkers, validateOverlays } from './validation.js';
|
|
2
|
+
import { prepareDistrictStyles } from './district-style.js';
|
|
2
3
|
import { geometryPath, positions, rawProject } from './geometry.js';
|
|
3
4
|
import * as layers from './layers.js';
|
|
4
5
|
import { escapeXml, stroke } from './svg.js';
|
|
@@ -48,12 +49,11 @@ export function getLayerPathsWithData(options, data, complete = true) {
|
|
|
48
49
|
coast: path(data.coast),
|
|
49
50
|
districts: (complete || options.districtFills !== false || options.districtLines !== false
|
|
50
51
|
? (data.districts?.[year] ?? [])
|
|
51
|
-
: []).map((district) =>
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
})),
|
|
52
|
+
: []).map((district) => {
|
|
53
|
+
const geometry = path(district.geometry);
|
|
54
|
+
const extras = path(district.extras);
|
|
55
|
+
return { id: district.id, geometry, extras, path: geometry + extras };
|
|
56
|
+
}),
|
|
57
57
|
neighborhoods: (complete ? (data.neighborhoods ?? []) : []).map((item) => ({
|
|
58
58
|
name: item.name,
|
|
59
59
|
path: path(item.geometry),
|
|
@@ -124,9 +124,10 @@ export function createSFMapWithData(options, data) {
|
|
|
124
124
|
const roadData = data.keyRoads ?? [];
|
|
125
125
|
const stationData = data.bartStations ?? [];
|
|
126
126
|
const context = { project, path, colors, idPrefix, theme, labels };
|
|
127
|
+
const styles = prepareDistrictStyles(districtFills || districtLines ? districts : [], options.districtStyle);
|
|
127
128
|
const districtPaths = districtFills || districtLines
|
|
128
129
|
? districts.map((d, index) => {
|
|
129
|
-
const style =
|
|
130
|
+
const style = styles.get(d.id) ?? {};
|
|
130
131
|
return {
|
|
131
132
|
id: d.id,
|
|
132
133
|
path: geometry.districts[index]?.path ?? '',
|
|
@@ -171,19 +172,3 @@ export function createSFMapWithData(options, data) {
|
|
|
171
172
|
viewBox: [0, 0, width, height],
|
|
172
173
|
};
|
|
173
174
|
}
|
|
174
|
-
function validateDistrictStyle(style) {
|
|
175
|
-
if (style === undefined)
|
|
176
|
-
return {};
|
|
177
|
-
if (!style || typeof style !== 'object' || Array.isArray(style))
|
|
178
|
-
throw new TypeError('districtStyle must return a style object.');
|
|
179
|
-
for (const key of Object.keys(style))
|
|
180
|
-
if (!['fill', 'stroke', 'opacity'].includes(key))
|
|
181
|
-
throw new TypeError(`Unknown district style: ${key}`);
|
|
182
|
-
for (const key of ['fill', 'stroke'])
|
|
183
|
-
if (style[key] !== undefined && typeof style[key] !== 'string')
|
|
184
|
-
throw new TypeError(`${key} must be a string.`);
|
|
185
|
-
if (style.opacity !== undefined &&
|
|
186
|
-
(!Number.isFinite(style.opacity) || style.opacity < 0 || style.opacity > 1))
|
|
187
|
-
throw new RangeError('District opacity must be between 0 and 1.');
|
|
188
|
-
return style;
|
|
189
|
-
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { normalizeFeatures } from './features.js';
|
|
2
|
+
import type { MapMarker } from './types.js';
|
|
3
|
+
export interface MarkerItem {
|
|
4
|
+
marker: MapMarker;
|
|
5
|
+
point: [number, number];
|
|
6
|
+
node: SVGGElement;
|
|
7
|
+
dot: SVGCircleElement;
|
|
8
|
+
hit: SVGCircleElement;
|
|
9
|
+
ring: SVGCircleElement;
|
|
10
|
+
}
|
|
11
|
+
/** Own marker visuals and entrances; selection/events remain with the controller. */
|
|
12
|
+
export declare function createMarkerLayer(layer: SVGGElement): {
|
|
13
|
+
cancelEntrances: () => void;
|
|
14
|
+
clear(): void;
|
|
15
|
+
add(marker: MapMarker, point: [number, number], index: number, { features, markerColor, selectedMarkerColor, reducedMotion, enter, }: {
|
|
16
|
+
features: ReturnType<typeof normalizeFeatures>;
|
|
17
|
+
markerColor: string;
|
|
18
|
+
selectedMarkerColor: string;
|
|
19
|
+
reducedMotion: boolean;
|
|
20
|
+
enter: boolean;
|
|
21
|
+
}): MarkerItem;
|
|
22
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { svgElement } from './dom.js';
|
|
2
|
+
/** Own marker visuals and entrances; selection/events remain with the controller. */
|
|
3
|
+
export function createMarkerLayer(layer) {
|
|
4
|
+
const animations = new Set();
|
|
5
|
+
function cancelEntrances() {
|
|
6
|
+
for (const animation of animations)
|
|
7
|
+
animation.cancel();
|
|
8
|
+
animations.clear();
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
cancelEntrances,
|
|
12
|
+
clear() {
|
|
13
|
+
cancelEntrances();
|
|
14
|
+
layer.replaceChildren();
|
|
15
|
+
},
|
|
16
|
+
add(marker, point, index, { features, markerColor, selectedMarkerColor, reducedMotion, enter, }) {
|
|
17
|
+
const node = svgElement('g', {
|
|
18
|
+
transform: `translate(${point[0]},${point[1]})`,
|
|
19
|
+
'data-marker-id': marker.id,
|
|
20
|
+
role: 'button',
|
|
21
|
+
tabindex: 0,
|
|
22
|
+
'aria-label': marker.label ?? marker.id,
|
|
23
|
+
'aria-pressed': 'false',
|
|
24
|
+
});
|
|
25
|
+
const hit = svgElement('circle', { fill: 'transparent', 'pointer-events': 'all' });
|
|
26
|
+
const dot = svgElement('circle', {
|
|
27
|
+
fill: marker.color ?? markerColor,
|
|
28
|
+
stroke: '#fff9e9',
|
|
29
|
+
'stroke-width': 2,
|
|
30
|
+
'vector-effect': 'non-scaling-stroke',
|
|
31
|
+
'pointer-events': 'none',
|
|
32
|
+
});
|
|
33
|
+
const title = svgElement('title');
|
|
34
|
+
title.textContent = marker.label ?? marker.id;
|
|
35
|
+
const ring = svgElement('circle', {
|
|
36
|
+
fill: 'none',
|
|
37
|
+
stroke: features.selectedMarkerRing
|
|
38
|
+
? (features.selectedMarkerRing.color ?? selectedMarkerColor)
|
|
39
|
+
: selectedMarkerColor,
|
|
40
|
+
'stroke-width': features.selectedMarkerRing ? features.selectedMarkerRing.width : 2,
|
|
41
|
+
'vector-effect': 'non-scaling-stroke',
|
|
42
|
+
'pointer-events': 'none',
|
|
43
|
+
display: 'none',
|
|
44
|
+
});
|
|
45
|
+
node.style.setProperty('--sf-marker-index', String(index));
|
|
46
|
+
node.append(title, hit, ring, dot);
|
|
47
|
+
if (features.markerEntrance && enter && !reducedMotion && typeof dot.animate === 'function') {
|
|
48
|
+
const animation = dot.animate([
|
|
49
|
+
{ opacity: 0, transform: 'translateY(-12px)' },
|
|
50
|
+
{ opacity: 1, transform: 'translateY(0)' },
|
|
51
|
+
], {
|
|
52
|
+
duration: features.markerEntrance.duration,
|
|
53
|
+
delay: Math.min(index * features.markerEntrance.stagger, 1000),
|
|
54
|
+
easing: 'cubic-bezier(.2,.8,.2,1)',
|
|
55
|
+
fill: 'backwards',
|
|
56
|
+
});
|
|
57
|
+
animations.add(animation);
|
|
58
|
+
animation.finished.then(() => animations.delete(animation), () => animations.delete(animation));
|
|
59
|
+
}
|
|
60
|
+
layer.append(node);
|
|
61
|
+
return { marker, point, node, dot, hit, ring };
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -41,7 +41,7 @@ export interface SFMapOptions {
|
|
|
41
41
|
districtLines?: boolean;
|
|
42
42
|
neighborhoodLines?: boolean;
|
|
43
43
|
districtFills?: boolean;
|
|
44
|
-
/**
|
|
44
|
+
/** Evaluated once per district at construction/style/year updates; call setDistrictStyle again when external data changes. */
|
|
45
45
|
districtStyle?: (district: import('./map-core.js').DistrictRowData) => DistrictStyle;
|
|
46
46
|
districtLabels?: boolean;
|
|
47
47
|
/** Hide all visible text labels while retaining geographic symbols and accessible titles. */
|
package/dist/src/validation.js
CHANGED
|
@@ -157,6 +157,11 @@ export function validateMarkers(markers) {
|
|
|
157
157
|
if (typeof marker.id !== 'string' || !marker.id || ids.has(marker.id))
|
|
158
158
|
throw new RangeError('Markers require unique nonempty IDs.');
|
|
159
159
|
ids.add(marker.id);
|
|
160
|
+
if (!Number.isFinite(marker.lng) ||
|
|
161
|
+
Math.abs(marker.lng) > 180 ||
|
|
162
|
+
!Number.isFinite(marker.lat) ||
|
|
163
|
+
Math.abs(marker.lat) >= 90)
|
|
164
|
+
throw new RangeError('Marker coordinates require finite longitude from -180 to 180 and latitude strictly between -90 and 90.');
|
|
160
165
|
if (marker.radius !== undefined && (!Number.isFinite(marker.radius) || marker.radius <= 0))
|
|
161
166
|
throw new RangeError('Marker radius must be positive and finite.');
|
|
162
167
|
if (marker.selected !== undefined && typeof marker.selected !== 'boolean')
|
package/docs/EXAMPLES.md
CHANGED
|
@@ -39,10 +39,11 @@ See the [Storybook election choropleth](../stories/ElectionMap.stories.ts) for a
|
|
|
39
39
|
## Lightweight interactive guide
|
|
40
40
|
|
|
41
41
|
```ts
|
|
42
|
-
import {
|
|
42
|
+
import { createGuideController } from '@kahwee/sf-map-svg/guide';
|
|
43
43
|
|
|
44
|
-
const map =
|
|
45
|
-
document.querySelector('#map')?.append(map);
|
|
44
|
+
const map = createGuideController({ layers: { roadLabels: false } });
|
|
45
|
+
document.querySelector('#map')?.append(map.element);
|
|
46
|
+
// On unmount: map.destroy();
|
|
46
47
|
```
|
|
47
48
|
|
|
48
49
|
The guide includes selected coast, SFAR neighborhoods, parks, roads, and stations. Detailed geography loads only when explicitly requested; see the [consumer guide recipe](consumer-integration.md).
|
|
@@ -70,10 +71,10 @@ This imports one neighborhood definition source. To omit its geometry too, impor
|
|
|
70
71
|
## Route overlay
|
|
71
72
|
|
|
72
73
|
```ts
|
|
73
|
-
import {
|
|
74
|
+
import { createGuideController } from '@kahwee/sf-map-svg/guide';
|
|
74
75
|
|
|
75
|
-
const map =
|
|
76
|
-
document.querySelector('#map')?.append(map);
|
|
76
|
+
const map = createGuideController();
|
|
77
|
+
document.querySelector('#map')?.append(map.element);
|
|
77
78
|
map.setOverlays([{
|
|
78
79
|
id: 'trip',
|
|
79
80
|
label: 'Example route',
|
package/docs/api-audit.md
CHANGED
|
@@ -20,7 +20,11 @@ unbounded geographic data can never fail.
|
|
|
20
20
|
| Marker entrance and reduced motion | Entrances are optional, independent of camera motion, and cancel on preference changes or disposal. Stable IDs avoid repeated entrances on filtering. |
|
|
21
21
|
| Hiding touch control while gestures are enabled | Returns touch scrolling to the page. Other toolbar controls remain independent. |
|
|
22
22
|
| Runtime patch omitted/false/undefined | Omitted retains; false disables; undefined resets to the default. Nested feature objects replace, rather than deep-merge. |
|
|
23
|
-
| Compact shell and full attribution | Rejected before replacement. Use `
|
|
23
|
+
| Compact shell and full attribution | Rejected before replacement. Use `createGuideController` for full attribution. Control overrides are honored and may deliberately alter layout. |
|
|
24
|
+
| District style callbacks | Prepare and copy every style before committing. Hover/selection reuse the results; call `setDistrictStyle()` to refresh changed external data. Reentrant updates or destruction supersede pending work. |
|
|
25
|
+
| Rapid district year changes | Both outgoing layers are tracked, inert, and removed on interruption, completion, reduced-motion changes, or destruction. Zero duration creates no transition copies. |
|
|
26
|
+
| Label metrics and camera movement | Reuse screen-space text measurements and nodes across frames. Font loading invalidates measurements; hidden or removed candidates do not remain in the visible layer. |
|
|
27
|
+
| Guide controllers and compatibility factories | `createGuideController`/`mountGuideController` share grouped options and lifecycle with `createMap`. Existing element factories retain flat options and their return types. |
|
|
24
28
|
| Layer hidden and bundle cost | Visibility never unloads imported geography. Use narrow entrypoints or data injection to save bytes. |
|
|
25
29
|
|
|
26
30
|
## Failure boundaries and regression evidence
|
|
@@ -40,9 +44,10 @@ unbounded geographic data can never fail.
|
|
|
40
44
|
|
|
41
45
|
Regression sources: `test/api-contract.test.js`, `test/camera.test.js`,
|
|
42
46
|
`test/viewport.test.js`, `stories/Robustness.stories.ts`,
|
|
43
|
-
`stories/Enhancements.stories.ts`,
|
|
47
|
+
`stories/Enhancements.stories.ts`, `stories/Motion.stories.ts`,
|
|
48
|
+
`stories/Controller.stories.ts`, and `scripts/smoke-package.mjs`.
|
|
44
49
|
|
|
45
|
-
|
|
50
|
+
Event listeners and activation callbacks run after their corresponding state is committed. District-style callbacks instead run during preparation, before commit. Consumer event callback
|
|
46
51
|
exceptions are not transactional validation errors and do not roll back an already
|
|
47
52
|
committed change. Direct mutation of the returned DOM can invalidate invariants;
|
|
48
53
|
use the public methods and `overlayElement` extension point.
|
|
@@ -5,14 +5,19 @@ Use the version 3 root controller and explicit geographic data. The [migration g
|
|
|
5
5
|
For a compact browser guide:
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
|
-
import {
|
|
9
|
-
import { guideMapData } from '@kahwee/sf-map-svg/guide/data';
|
|
10
|
-
import { guideOptions } from '@kahwee/sf-map-svg/presets';
|
|
8
|
+
import { createGuideController } from '@kahwee/sf-map-svg/guide';
|
|
11
9
|
|
|
12
|
-
const map =
|
|
10
|
+
const map = createGuideController({
|
|
11
|
+
features: { motion: { duration: 400 }, markerEntrance: true },
|
|
12
|
+
appearance: { theme: 'transit' },
|
|
13
|
+
});
|
|
13
14
|
document.querySelector('#map')?.append(map.element);
|
|
14
15
|
// Dispose when the containing view unmounts.
|
|
15
16
|
map.destroy();
|
|
16
17
|
```
|
|
17
18
|
|
|
19
|
+
The guide controller uses the same `configure()`, `camera`, `on()`, and `destroy()` contract as `createMap()`. Motion is opt-in and follows the user's reduced-motion preference. For explicit data composition, `createMap(guideMapData, guideOptions)` remains available using `/guide/data` and `/presets`.
|
|
20
|
+
|
|
21
|
+
To progressively enhance server-rendered `createGuideShell()` markup, use `mountGuideController(shell, options)` from `/guide`. It returns the controller and requires compact attribution. Invalid configuration leaves the static frame intact. Destroy the controller when unmounting. Existing `createGuideMap()` and `mountGuideMap()` return their original augmented elements and accept flat options for compatibility.
|
|
22
|
+
|
|
18
23
|
For server rendering, import `renderMap` from the root and pass `guideMapData.map`, `staticMapData` from `/data/static`, or another explicit `StaticMapData`. Import `/data/full` when interactive lookup collections are also required. All rendering stays offline and has zero runtime dependencies. The guide's detailed geography loads only when `loadGuideDetailedData()` is called.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Guide bundle size report
|
|
2
2
|
|
|
3
|
-
Generated 2026-09-
|
|
3
|
+
Generated 2026-09-28 by `pnpm report:guide` with Vite production minification and gzip compression. Each emitted JS chunk is compressed independently. The report measures the explicit-data v3 root and the optional `@kahwee/sf-map-svg/guide` preset.
|
|
4
4
|
|
|
5
5
|
| Entry | Initial JS, raw | Initial JS, gzip | Explicit detail JS, gzip |
|
|
6
6
|
| --- | ---: | ---: | ---: |
|
|
7
|
-
| v3 root (explicit data) |
|
|
8
|
-
| v3 static renderer |
|
|
9
|
-
| Guide preset |
|
|
7
|
+
| v3 root (explicit data) | 87.4 KB | 25.5 KB | — |
|
|
8
|
+
| v3 static renderer | 15.3 KB | 5.0 KB | — |
|
|
9
|
+
| Guide preset | 477.1 KB | 114.1 KB | 473.7 KB |
|
|
10
10
|
|
|
11
11
|
**500 KB target:** met.
|
|
12
12
|
|
package/docs/migration-v3.md
CHANGED
|
@@ -28,3 +28,9 @@ map.destroy();
|
|
|
28
28
|
The controller owns subscriptions and cleanup. Replace old element calls with controller methods (`camera.get/set/pan/zoom/reset/fit/stop`, `selectNeighborhood`, `setSource`, `setMode`, `setLabels`, `setMarkers`, and `setOverlays`). Group feature flags under `features` and styling under `appearance`; `configure` updates runtime features, layers, and controls. For code that needs the guide shell, `/guide`, `/guide/data`, `/guide/static`, and `/guide/map` remain supported. `/transit` also remains supported.
|
|
29
29
|
|
|
30
30
|
Review tree-shaking after migrating: `/data/full` intentionally includes every packaged layer, while the root and `/static` stay data free. Test server rendering, browser mounting, and map disposal in your application before upgrading production.
|
|
31
|
+
|
|
32
|
+
## Updating from 3.0 to 3.1
|
|
33
|
+
|
|
34
|
+
Existing guide factories remain compatible. New integrations can use `createGuideController(options)` and `mountGuideController(shell, options)` from `/guide` or `/guide/map`. Move flat styling options into `appearance`, animation options into `features`, append `.element`, and use `.camera` and `.on()` as with `createMap`.
|
|
35
|
+
|
|
36
|
+
District styles are now prepared once per district on construction and style/year updates. Hover and selection reuse that snapshot. If a callback reads mutable external data, call `map.setDistrictStyle(callback)` after changing the data; do not rely on hovering to refresh colors. Callback errors leave the previous style intact, and callback-triggered updates or destruction take precedence over the pending update.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahwee/sf-map-svg",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "Offline SVG maps of San Francisco with district boundaries, parks, landmarks, and BART stations.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/src/api.js",
|
|
@@ -128,7 +128,7 @@
|
|
|
128
128
|
},
|
|
129
129
|
"license": "MIT",
|
|
130
130
|
"engines": {
|
|
131
|
-
"node": ">=
|
|
131
|
+
"node": ">=24.0.0"
|
|
132
132
|
},
|
|
133
133
|
"devDependencies": {
|
|
134
134
|
"@biomejs/biome": "2.5.14",
|