@kahwee/sf-map-svg 4.1.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +2 -2
- package/dist/src/appearance.d.ts +5 -0
- package/dist/src/appearance.js +54 -0
- package/dist/src/configuration.js +32 -23
- package/dist/src/controller-types.d.ts +53 -6
- package/dist/src/explorer-core.d.ts +14 -2
- package/dist/src/explorer-core.js +168 -37
- package/dist/src/guide-map.js +1 -1
- package/dist/src/guide-static.js +3 -3
- package/dist/src/map-core.d.ts +14 -0
- package/dist/src/map-core.js +4 -1
- package/dist/src/map.js +125 -20
- package/dist/src/static.d.ts +1 -1
- package/dist/src/static.js +31 -2
- package/dist/src/types.d.ts +8 -2
- package/docs/API.md +53 -1
- package/docs/api-audit.md +3 -1
- package/docs/developer-guide.md +5 -3
- package/llms-full.txt +160 -25
- package/llms.txt +1 -1
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
User-visible changes are recorded here. Unreleased entries describe changes on `main` that are not part of a tagged package release.
|
|
4
4
|
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
## 4.2.0 — 2026-10-01
|
|
8
|
+
|
|
9
|
+
- Reuse one native dropdown treatment across Pages, with an inset chevron, a reserved end gutter, and preserved labels, keyboard behavior, and mobile pickers.
|
|
10
|
+
- Strengthen the playground with strict TypeScript, checked JavaScript/TypeScript snippets, versioned design links, persistent explicit layer choices and independent static rendering. Keep mobile controls unobstructed and picker labels legible in dark mode.
|
|
11
|
+
- Express explicit configuration resets for consumers using `exactOptionalPropertyTypes`, including nested appearance tokens. Make Pages API and guide references describe their actual package version without internal preview warnings.
|
|
12
|
+
|
|
13
|
+
- Share grouped `layers` and supported `appearance` options between static and interactive maps, retaining flat static options and existing defaults; accept readonly static marker arrays.
|
|
14
|
+
- Update interactive appearance, mode, source, district year and labels through `configure`, preserving camera and retained marker nodes. Merge appearance tokens by key and validate combined patches before commit.
|
|
15
|
+
- Add `getResolvedConfiguration()` and `getCapabilities()` for effective layer switches and supplied geography, plus an additive `selectionchange` event with consistent current/previous snapshots.
|
|
16
|
+
- Add a live Pages map design playground with six editable palettes, layers, typography, sample pins/routes, browser/static previews, undo/redo, shareable designs, copyable code and full-city SVG export. Keep released-package previews compatible with their installed API.
|
|
17
|
+
- Refresh the Pages site: a new SF Map SVG mark traced from the real coastline, a reframed header with a release link, card-style reference tables that no longer collapse, a live filter for the API reference, an aligned playground with one-row-per-colour inks, and 12px minimum type for labels and badges.
|
|
18
|
+
|
|
5
19
|
## 4.1.0 — 2026-10-01
|
|
6
20
|
|
|
7
21
|
- Validate canonical and generated GeoJSON during data checks, rejecting invalid WGS84 coordinates and malformed polygon rings before release.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Offline, self-contained San Francisco SVG maps. The package has no runtime dependencies. Geography is always an explicit import; the root entry does not bundle data.
|
|
4
4
|
|
|
5
|
-
[Live examples](https://kahwee.github.io/sf-map-svg/examples.html) · [Storybook source](stories/) · [Geographic sources](SOURCES.md) · [v3 migration](docs/migration-v3.md)
|
|
5
|
+
[Design playground](https://kahwee.github.io/sf-map-svg/playground.html) · [Live examples](https://kahwee.github.io/sf-map-svg/examples.html) · [Storybook source](stories/) · [Geographic sources](SOURCES.md) · [v3 migration](docs/migration-v3.md)
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -52,7 +52,7 @@ map.camera.zoom(1.5);
|
|
|
52
52
|
map.destroy();
|
|
53
53
|
```
|
|
54
54
|
|
|
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
|
|
55
|
+
`createMap(data, options)` returns a controller. Use `map.element` for mounting, `map.configure({ features, layers, controls, appearance, mode, source, year, labels })` for runtime switches, `map.camera` for pan/zoom/fit/reset, and `map.on()` for typed events. Appearance can change live while preserving camera, selection, and focus. `getResolvedConfiguration()` explains effective layers; `getCapabilities()` reports supplied geography. 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.
|
|
56
56
|
|
|
57
57
|
See [controller options, events, and lifecycle](docs/API.md#interactive-maps),
|
|
58
58
|
[examples](docs/EXAMPLES.md), and [consumer integration](docs/consumer-integration.md).
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { MapAppearance } from './controller-types.js';
|
|
2
|
+
export declare const appearanceKeys: readonly ['theme', 'colors', 'labelStyle', 'areaStyle', 'districtStyle', 'labelSize', 'style', 'markerRadius', 'markerHitSize', 'markerColor', 'selectedMarkerColor'];
|
|
3
|
+
/** Copy configuration records without cloning or invoking styling callbacks. */
|
|
4
|
+
export declare function copyAppearance(value?: MapAppearance): MapAppearance;
|
|
5
|
+
export declare function prepareAppearance(current: MapAppearance, patch: MapAppearance | undefined): MapAppearance;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { assertOptions, validateExplorerOptions } from './validation.js';
|
|
2
|
+
export const appearanceKeys = [
|
|
3
|
+
'theme',
|
|
4
|
+
'colors',
|
|
5
|
+
'labelStyle',
|
|
6
|
+
'areaStyle',
|
|
7
|
+
'districtStyle',
|
|
8
|
+
'labelSize',
|
|
9
|
+
'style',
|
|
10
|
+
'markerRadius',
|
|
11
|
+
'markerHitSize',
|
|
12
|
+
'markerColor',
|
|
13
|
+
'selectedMarkerColor',
|
|
14
|
+
];
|
|
15
|
+
/** Copy configuration records without cloning or invoking styling callbacks. */
|
|
16
|
+
export function copyAppearance(value = {}) {
|
|
17
|
+
const { districtStyle, ...rest } = value;
|
|
18
|
+
return { ...structuredClone(rest), ...(districtStyle ? { districtStyle } : {}) };
|
|
19
|
+
}
|
|
20
|
+
export function prepareAppearance(current, patch) {
|
|
21
|
+
if (patch === undefined)
|
|
22
|
+
return {};
|
|
23
|
+
assertOptions(patch, 'appearance', appearanceKeys);
|
|
24
|
+
const next = { ...current, ...patch };
|
|
25
|
+
for (const key of ['colors', 'labelStyle', 'areaStyle', 'labelSize', 'style']) {
|
|
26
|
+
if (Object.hasOwn(patch, key) && patch[key] !== undefined) {
|
|
27
|
+
assertOptions(patch[key], key);
|
|
28
|
+
const tokens = { ...current[key], ...patch[key] };
|
|
29
|
+
for (const name of Object.keys(tokens))
|
|
30
|
+
if (tokens[name] === undefined)
|
|
31
|
+
delete tokens[name];
|
|
32
|
+
Object.assign(next, { [key]: tokens });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
for (const key of appearanceKeys)
|
|
36
|
+
if (next[key] === undefined)
|
|
37
|
+
delete next[key];
|
|
38
|
+
validateExplorerOptions(next);
|
|
39
|
+
if (next.theme !== undefined && !['districts', 'transit'].includes(next.theme))
|
|
40
|
+
throw new TypeError('Theme must be districts or transit.');
|
|
41
|
+
const min = next.labelSize?.min ?? 11, max = next.labelSize?.max ?? 12;
|
|
42
|
+
const radius = next.markerRadius ?? 6, hit = next.markerHitSize ?? 44;
|
|
43
|
+
if (![min, max, radius, hit].every(Number.isFinite) ||
|
|
44
|
+
min < 8 ||
|
|
45
|
+
max < min ||
|
|
46
|
+
max > 32 ||
|
|
47
|
+
radius <= 0 ||
|
|
48
|
+
hit < radius * 2)
|
|
49
|
+
throw new RangeError('Use label sizes from 8–32px and a positive marker radius inside its hit target.');
|
|
50
|
+
const weight = next.labelStyle?.fontWeight;
|
|
51
|
+
if (weight !== undefined && (!Number.isFinite(weight) || weight < 1 || weight > 1000))
|
|
52
|
+
throw new RangeError('Label font weight must be finite and between 1 and 1000.');
|
|
53
|
+
return copyAppearance(next);
|
|
54
|
+
}
|
|
@@ -1,18 +1,6 @@
|
|
|
1
|
+
import { appearanceKeys, copyAppearance, prepareAppearance } from './appearance.js';
|
|
1
2
|
import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from './features.js';
|
|
2
3
|
import { assertOptions, validateExplorerOptions } from './validation.js';
|
|
3
|
-
const appearanceKeys = [
|
|
4
|
-
'theme',
|
|
5
|
-
'colors',
|
|
6
|
-
'labelStyle',
|
|
7
|
-
'areaStyle',
|
|
8
|
-
'districtStyle',
|
|
9
|
-
'labelSize',
|
|
10
|
-
'style',
|
|
11
|
-
'markerRadius',
|
|
12
|
-
'markerHitSize',
|
|
13
|
-
'markerColor',
|
|
14
|
-
'selectedMarkerColor',
|
|
15
|
-
];
|
|
16
4
|
const featureKeys = [
|
|
17
5
|
'motion',
|
|
18
6
|
'markerEntrance',
|
|
@@ -48,18 +36,34 @@ export function expandMapOptions(options) {
|
|
|
48
36
|
? `Use ${replacement} instead of the flat ${key} option.`
|
|
49
37
|
: 'interface is internal and is not a supported createMap option.');
|
|
50
38
|
}
|
|
51
|
-
|
|
52
|
-
assertOptions(options.appearance, 'appearance', appearanceKeys);
|
|
39
|
+
const preparedAppearance = options.appearance === undefined ? undefined : prepareAppearance({}, options.appearance);
|
|
53
40
|
if (options.features !== undefined)
|
|
54
41
|
normalizeFeatures(options.features);
|
|
55
|
-
const { features, appearance, ...rest } = options;
|
|
56
|
-
const expanded = { ...rest, ...
|
|
42
|
+
const { features, appearance: _appearance, ...rest } = options;
|
|
43
|
+
const expanded = { ...rest, ...preparedAppearance, ...features };
|
|
57
44
|
validateExplorerOptions(expanded);
|
|
58
45
|
return expanded;
|
|
59
46
|
}
|
|
60
47
|
/** Prepare the entire configuration before any DOM mutation. */
|
|
61
48
|
export function prepareConfiguration(current, patch) {
|
|
62
|
-
assertOptions(patch, 'configuration', [
|
|
49
|
+
assertOptions(patch, 'configuration', [
|
|
50
|
+
'features',
|
|
51
|
+
'layers',
|
|
52
|
+
'controls',
|
|
53
|
+
'appearance',
|
|
54
|
+
'mode',
|
|
55
|
+
'source',
|
|
56
|
+
'year',
|
|
57
|
+
'labels',
|
|
58
|
+
]);
|
|
59
|
+
if (patch.mode !== undefined && !['basemap', 'neighborhoods', 'districts'].includes(patch.mode))
|
|
60
|
+
throw new RangeError('Unknown map mode.');
|
|
61
|
+
if (patch.source !== undefined && !['realtor', 'sf-find', 'analysis'].includes(patch.source))
|
|
62
|
+
throw new RangeError('Unknown neighborhood source.');
|
|
63
|
+
if (patch.year !== undefined && ![2002, 2012, 2022].includes(patch.year))
|
|
64
|
+
throw new RangeError('District year must be 2002, 2012, or 2022.');
|
|
65
|
+
if (patch.labels !== undefined && typeof patch.labels !== 'boolean')
|
|
66
|
+
throw new TypeError('Labels must be a boolean.');
|
|
63
67
|
const features = 'features' in patch
|
|
64
68
|
? normalizeFeatures(patch.features === undefined
|
|
65
69
|
? {}
|
|
@@ -78,11 +82,16 @@ export function prepareConfiguration(current, patch) {
|
|
|
78
82
|
delete next[property];
|
|
79
83
|
return next;
|
|
80
84
|
};
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
return {
|
|
86
|
+
...structuredClone({
|
|
87
|
+
features,
|
|
88
|
+
layers: switches('layers', current.layers, layerKeys),
|
|
89
|
+
controls: switches('controls', current.controls, controlKeys),
|
|
90
|
+
}),
|
|
91
|
+
appearance: 'appearance' in patch
|
|
92
|
+
? prepareAppearance(current.appearance ?? {}, patch.appearance)
|
|
93
|
+
: copyAppearance(current.appearance),
|
|
94
|
+
};
|
|
86
95
|
}
|
|
87
96
|
function checkedFeatures(value) {
|
|
88
97
|
normalizeFeatures(value);
|
|
@@ -1,24 +1,66 @@
|
|
|
1
1
|
import type { Geometry, NeighborhoodSource } from '../data/types.js';
|
|
2
2
|
import type { CameraOptions, DistrictSelection, DistrictStyle, DistrictYear, InteractiveLayers, MapFeatures, MapMarker, MapOverlay, MapPadding, MapViewport, NeighborhoodExplorerOptions, NeighborhoodSelection } from './types.js';
|
|
3
|
-
|
|
3
|
+
/** Patch properties can explicitly remove overrides, including with exactOptionalPropertyTypes. */
|
|
4
|
+
type Resettable<T> = {
|
|
5
|
+
[K in keyof T]?: T[K] | undefined;
|
|
6
|
+
};
|
|
7
|
+
type AppearanceOptions = Pick<NeighborhoodExplorerOptions, 'theme' | 'colors' | 'labelStyle' | 'areaStyle' | 'districtStyle' | 'labelSize' | 'style' | 'markerRadius' | 'markerHitSize' | 'markerColor' | 'selectedMarkerColor'>;
|
|
8
|
+
export type MapAppearance = {
|
|
9
|
+
[K in keyof AppearanceOptions]?: K extends 'colors' | 'style' | 'labelStyle' | 'labelSize' | 'areaStyle' ? Resettable<NonNullable<AppearanceOptions[K]>> | undefined : AppearanceOptions[K] | undefined;
|
|
10
|
+
};
|
|
4
11
|
export type MapControls = NonNullable<NeighborhoodExplorerOptions['controls']>;
|
|
5
|
-
/** Omitted
|
|
12
|
+
/** Omitted keys retain values. Undefined presentation groups reset; undefined mode/source/year/labels retain their current value. */
|
|
6
13
|
export interface MapConfiguration {
|
|
7
|
-
features?: MapFeatures;
|
|
8
|
-
layers?: InteractiveLayers;
|
|
9
|
-
controls?: MapControls;
|
|
14
|
+
features?: Resettable<MapFeatures> | undefined;
|
|
15
|
+
layers?: Resettable<InteractiveLayers> | undefined;
|
|
16
|
+
controls?: Resettable<MapControls> | undefined;
|
|
17
|
+
appearance?: MapAppearance | undefined;
|
|
18
|
+
mode?: NonNullable<NeighborhoodExplorerOptions['mode']> | undefined;
|
|
19
|
+
source?: NeighborhoodSource | undefined;
|
|
20
|
+
year?: DistrictYear | undefined;
|
|
21
|
+
labels?: boolean | undefined;
|
|
10
22
|
}
|
|
11
23
|
export interface MapOptions extends Omit<NeighborhoodExplorerOptions, keyof MapFeatures | keyof MapAppearance | 'interface' | 'onMarkerActivate' | 'onOverlayActivate'> {
|
|
12
24
|
features?: MapFeatures;
|
|
13
|
-
appearance?: MapAppearance;
|
|
25
|
+
appearance?: MapAppearance | undefined;
|
|
14
26
|
}
|
|
15
27
|
export interface MapConfigurationSnapshot {
|
|
16
28
|
features: MapFeatures;
|
|
17
29
|
/** Explicit overrides; absent values continue to follow the current mode. */
|
|
18
30
|
layers: InteractiveLayers;
|
|
19
31
|
controls: MapControls;
|
|
32
|
+
appearance: MapAppearance;
|
|
33
|
+
}
|
|
34
|
+
export interface MapCapabilities {
|
|
35
|
+
sources: readonly NeighborhoodSource[];
|
|
36
|
+
years: readonly DistrictYear[];
|
|
37
|
+
/** Data availability, independent of requested layer visibility. */
|
|
38
|
+
layers: Readonly<Record<keyof InteractiveLayers, boolean>>;
|
|
39
|
+
}
|
|
40
|
+
export interface ResolvedMapConfiguration extends MapConfigurationSnapshot {
|
|
41
|
+
mode: NonNullable<MapOptions['mode']>;
|
|
42
|
+
source: NeighborhoodSource;
|
|
43
|
+
year: DistrictYear;
|
|
44
|
+
labels: boolean;
|
|
45
|
+
/** Effective switches after mode defaults and data availability; label switches honor labels. */
|
|
46
|
+
layers: Record<keyof InteractiveLayers, boolean>;
|
|
20
47
|
}
|
|
48
|
+
export type MapSelectionChange = {
|
|
49
|
+
kind: 'marker';
|
|
50
|
+
current: MapMarker | null;
|
|
51
|
+
previous: MapMarker | null;
|
|
52
|
+
} | {
|
|
53
|
+
kind: 'neighborhood';
|
|
54
|
+
current: NeighborhoodSelection | null;
|
|
55
|
+
previous: NeighborhoodSelection | null;
|
|
56
|
+
} | {
|
|
57
|
+
kind: 'district';
|
|
58
|
+
current: DistrictSelection | null;
|
|
59
|
+
previous: DistrictSelection | null;
|
|
60
|
+
};
|
|
21
61
|
export interface MapEvents {
|
|
62
|
+
/** Common selection envelope; existing change events retain their payloads. */
|
|
63
|
+
selectionchange: MapSelectionChange;
|
|
22
64
|
districtchange: DistrictSelection | {
|
|
23
65
|
id: null;
|
|
24
66
|
year: DistrictYear;
|
|
@@ -56,11 +98,13 @@ export interface MapEvents {
|
|
|
56
98
|
mapresize: undefined;
|
|
57
99
|
}
|
|
58
100
|
export interface MapCamera {
|
|
101
|
+
/** Fixed 800 × 800 projected space, not longitude/latitude. */
|
|
59
102
|
get(): MapViewport;
|
|
60
103
|
set(view: MapViewport, options?: CameraOptions): void;
|
|
61
104
|
fit(geometry: Geometry, options?: CameraOptions & {
|
|
62
105
|
padding?: number | MapPadding;
|
|
63
106
|
}): void;
|
|
107
|
+
/** Delta in projected map units. */
|
|
64
108
|
pan(x: number, y: number, options?: CameraOptions): void;
|
|
65
109
|
zoom(factor: number, options?: CameraOptions): void;
|
|
66
110
|
reset(options?: CameraOptions): void;
|
|
@@ -74,6 +118,8 @@ export interface MapController {
|
|
|
74
118
|
readonly destroyed: boolean;
|
|
75
119
|
configure(patch: MapConfiguration): void;
|
|
76
120
|
getConfiguration(): MapConfigurationSnapshot;
|
|
121
|
+
getResolvedConfiguration(): ResolvedMapConfiguration;
|
|
122
|
+
getCapabilities(): MapCapabilities;
|
|
77
123
|
on<K extends keyof MapEvents>(type: K, listener: (detail: MapEvents[K]) => void): () => void;
|
|
78
124
|
/** Reconcile stable IDs; retained markers preserve nodes, focus, and entrance animations. */
|
|
79
125
|
setMarkers(markers: readonly MapMarker[]): void;
|
|
@@ -104,3 +150,4 @@ export interface MapController {
|
|
|
104
150
|
/** Idempotent; stops work and managed subscriptions. Does not remove host-owned DOM. */
|
|
105
151
|
destroy(): void;
|
|
106
152
|
}
|
|
153
|
+
export {};
|
|
@@ -1,5 +1,17 @@
|
|
|
1
|
+
import type { NeighborhoodSource } from '../data/types.js';
|
|
2
|
+
import type { MapConfiguration } from './controller-types.js';
|
|
1
3
|
import type { InteractiveSFMapData } from './explorer-data.js';
|
|
2
|
-
import type { NeighborhoodExplorerElement, NeighborhoodExplorerOptions } from './types.js';
|
|
4
|
+
import type { DistrictYear, ExplorerMode, NeighborhoodExplorerElement, NeighborhoodExplorerOptions } from './types.js';
|
|
3
5
|
export type { ExplorerMode, NeighborhoodExplorerElement, NeighborhoodExplorerOptions, } from './types.js';
|
|
6
|
+
/** Private bridge between the renderer and its public lifecycle controller. */
|
|
7
|
+
type MapEngineElement = NeighborhoodExplorerElement & {
|
|
8
|
+
applyPresentation(patch: MapConfiguration, onCommit?: () => void, isCurrent?: () => boolean): boolean;
|
|
9
|
+
getMapState(): {
|
|
10
|
+
mode: ExplorerMode;
|
|
11
|
+
source: NeighborhoodSource;
|
|
12
|
+
year: DistrictYear;
|
|
13
|
+
labels: boolean;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
4
16
|
/** Create an offline, browser-only neighborhood explorer. Call destroy() before disposal. */
|
|
5
|
-
export declare function createNeighborhoodExplorerCore(options: NeighborhoodExplorerOptions | undefined, data: InteractiveSFMapData):
|
|
17
|
+
export declare function createNeighborhoodExplorerCore(options: NeighborhoodExplorerOptions | undefined, data: InteractiveSFMapData): MapEngineElement;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { copyAppearance } from './appearance.js';
|
|
1
2
|
import { createCamera, validateCameraOptions } from './camera.js';
|
|
2
3
|
import { clusterPoints } from './clusters.js';
|
|
3
4
|
import { renderDistrictAppearance } from './district-layer.js';
|
|
@@ -11,7 +12,7 @@ import { createFrameScheduler } from './frame-scheduler.js';
|
|
|
11
12
|
import { geometryPath } from './geometry.js';
|
|
12
13
|
import { createLabelRenderer } from './label-renderer.js';
|
|
13
14
|
import { createLayerTransitions } from './layer-transition.js';
|
|
14
|
-
import { createSFMapWithData, districtColors, getLayerPathsWithData } from './map-core.js';
|
|
15
|
+
import { createSFMapWithData, districtColors, getLayerPathsWithData, resolveMapColors, } from './map-core.js';
|
|
15
16
|
import { createMarkerLayer } from './marker-layer.js';
|
|
16
17
|
import { attachNavigation } from './navigation.js';
|
|
17
18
|
import { validateExplorerOptions, validateMarkers, validateOverlays } from './validation.js';
|
|
@@ -37,7 +38,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
37
38
|
throw new RangeError(`Unknown map mode: ${mode}`);
|
|
38
39
|
if (chrome !== 'explorer' && chrome !== 'map')
|
|
39
40
|
throw new RangeError('Unknown interface.');
|
|
40
|
-
|
|
41
|
+
let minLabelSize = labelSize.min ?? 11, maxLabelSize = labelSize.max ?? 12;
|
|
41
42
|
if (![minLabelSize, maxLabelSize, markerRadius, markerHitSize].every(Number.isFinite) ||
|
|
42
43
|
minLabelSize < 8 ||
|
|
43
44
|
maxLabelSize < minLabelSize ||
|
|
@@ -464,7 +465,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
464
465
|
districtTransition.fade(fills, fade);
|
|
465
466
|
updateDistrictAppearance();
|
|
466
467
|
}
|
|
467
|
-
function setDistrictYear(next, options = {}) {
|
|
468
|
+
function setDistrictYear(next, options = {}, preparedStyles) {
|
|
468
469
|
if (destroyed)
|
|
469
470
|
return;
|
|
470
471
|
validateCameraOptions(options);
|
|
@@ -475,7 +476,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
475
476
|
if (next === year)
|
|
476
477
|
return;
|
|
477
478
|
const revision = districtRevision;
|
|
478
|
-
const prepared = prepareDistrictStyles(data.map.districts[next], districtStyle);
|
|
479
|
+
const prepared = preparedStyles ?? prepareDistrictStyles(data.map.districts[next], districtStyle);
|
|
479
480
|
if (destroyed || revision !== districtRevision)
|
|
480
481
|
return;
|
|
481
482
|
const committedRevision = ++districtRevision;
|
|
@@ -1118,9 +1119,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1118
1119
|
moveView(target, options);
|
|
1119
1120
|
return true;
|
|
1120
1121
|
}
|
|
1121
|
-
function
|
|
1122
|
-
if (destroyed)
|
|
1123
|
-
return;
|
|
1122
|
+
function prepareSource(next, reset = true) {
|
|
1124
1123
|
if (!Object.hasOwn(collections, next))
|
|
1125
1124
|
throw new RangeError(`Unknown neighborhood source: ${next}`);
|
|
1126
1125
|
const collection = collections[next];
|
|
@@ -1191,36 +1190,42 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1191
1190
|
area: (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]),
|
|
1192
1191
|
};
|
|
1193
1192
|
});
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
items[0]
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1193
|
+
return () => {
|
|
1194
|
+
const hadSelection = !!selected;
|
|
1195
|
+
source = next;
|
|
1196
|
+
sourceSelect.value = source;
|
|
1197
|
+
selected = undefined;
|
|
1198
|
+
const revision = ++neighborhoodRevision;
|
|
1199
|
+
delete root.dataset.selectedNeighborhood;
|
|
1200
|
+
root.dataset.source = source;
|
|
1201
|
+
layerFades.crossfade(areas, () => areas.replaceChildren(...nextItems.map((item) => item.node)));
|
|
1202
|
+
neighborhoodSelect.replaceChildren(element('option', 'No neighborhood selected'));
|
|
1203
|
+
neighborhoodSelect.options[0].value = '';
|
|
1204
|
+
neighborhoodSelect.append(...nextOptions);
|
|
1205
|
+
items = nextItems;
|
|
1206
|
+
if (items[0])
|
|
1207
|
+
items[0].node.setAttribute('tabindex', '0');
|
|
1208
|
+
updateResults();
|
|
1209
|
+
updateDetail();
|
|
1210
|
+
updateComposition();
|
|
1211
|
+
status.textContent =
|
|
1212
|
+
mode === 'basemap'
|
|
1213
|
+
? 'San Francisco basemap.'
|
|
1214
|
+
: mode === 'districts'
|
|
1215
|
+
? `${year} supervisorial districts. Numbers identify each district.`
|
|
1216
|
+
: `${formatSourceLabel(source)}. Select a neighborhood to begin.`;
|
|
1217
|
+
if (hadSelection)
|
|
1218
|
+
root.dispatchEvent(new CustomEvent('neighborhoodchange', {
|
|
1219
|
+
bubbles: true,
|
|
1220
|
+
detail: { feature: null, source, id: null, name: null },
|
|
1221
|
+
}));
|
|
1222
|
+
if (reset && !destroyed && neighborhoodRevision === revision)
|
|
1223
|
+
resetView();
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
function setSource(next) {
|
|
1227
|
+
if (!destroyed)
|
|
1228
|
+
prepareSource(next)();
|
|
1224
1229
|
}
|
|
1225
1230
|
function syncFeatureControls() {
|
|
1226
1231
|
featureControls.hidden = neighborhoodLabel.hidden && markerLabel.hidden;
|
|
@@ -1653,7 +1658,133 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
1653
1658
|
scheduleLabels();
|
|
1654
1659
|
root.dispatchEvent(new CustomEvent('mapresize', { bubbles: true }));
|
|
1655
1660
|
};
|
|
1661
|
+
let presentationRevision = 0;
|
|
1662
|
+
function applyPresentation(patch, onCommit, isCurrent = () => true) {
|
|
1663
|
+
const revision = ++presentationRevision;
|
|
1664
|
+
const nextYear = patch.year ?? year;
|
|
1665
|
+
const nextMode = patch.mode ?? mode;
|
|
1666
|
+
if ((patch.year !== undefined || nextMode === 'districts') &&
|
|
1667
|
+
(!data.map.districts?.[nextYear] || !data.districts?.[nextYear]))
|
|
1668
|
+
throw new RangeError(`No ${nextYear} district dataset was supplied.`);
|
|
1669
|
+
if (nextMode === 'neighborhoods' && !sources.length)
|
|
1670
|
+
throw new RangeError('No neighborhood dataset was supplied.');
|
|
1671
|
+
const commitSource = patch.source !== undefined && patch.source !== source
|
|
1672
|
+
? prepareSource(patch.source, false)
|
|
1673
|
+
: undefined;
|
|
1674
|
+
const appearance = patch.appearance;
|
|
1675
|
+
const nextStyle = appearance ? appearance.districtStyle : districtStyle;
|
|
1676
|
+
const districtVersion = districtRevision;
|
|
1677
|
+
const prepared = appearance || nextYear !== year
|
|
1678
|
+
? prepareDistrictStyles(data.map.districts?.[nextYear] ?? [], nextStyle)
|
|
1679
|
+
: districtStyles;
|
|
1680
|
+
if (destroyed ||
|
|
1681
|
+
!isCurrent() ||
|
|
1682
|
+
revision !== presentationRevision ||
|
|
1683
|
+
districtVersion !== districtRevision)
|
|
1684
|
+
return false;
|
|
1685
|
+
if (nextYear !== year) {
|
|
1686
|
+
// Validate target paths and label coordinates before any presentation group commits.
|
|
1687
|
+
getLayerPathsWithData({ year: nextYear }, data.map, false);
|
|
1688
|
+
for (const feature of data.districts?.[nextYear]?.features ?? [])
|
|
1689
|
+
for (const point of feature.properties.labelPoints)
|
|
1690
|
+
map.project(point);
|
|
1691
|
+
}
|
|
1692
|
+
onCommit?.();
|
|
1693
|
+
if (appearance) {
|
|
1694
|
+
districtRevision++;
|
|
1695
|
+
const next = copyAppearance(appearance);
|
|
1696
|
+
theme = next.theme ?? 'transit';
|
|
1697
|
+
colors = next.colors ?? {};
|
|
1698
|
+
labelStyle = next.labelStyle ?? {};
|
|
1699
|
+
areaStyle = next.areaStyle ?? {};
|
|
1700
|
+
styleOptions = next.style ?? {};
|
|
1701
|
+
minLabelSize = next.labelSize?.min ?? 11;
|
|
1702
|
+
maxLabelSize = next.labelSize?.max ?? 12;
|
|
1703
|
+
markerRadius = next.markerRadius ?? 6;
|
|
1704
|
+
markerHitSize = next.markerHitSize ?? 44;
|
|
1705
|
+
markerColor = next.markerColor ?? colors.marker ?? '#245b61';
|
|
1706
|
+
selectedMarkerColor = next.selectedMarkerColor ?? colors.selected ?? '#f04f32';
|
|
1707
|
+
districtTransition.cancel();
|
|
1708
|
+
const fills = districtLayers.find((layer) => layer.dataset.layer === 'district-fills');
|
|
1709
|
+
const fade = layerFades.duration();
|
|
1710
|
+
const stylesChanged = nextStyle !== districtStyle ||
|
|
1711
|
+
[...prepared].some(([id, value]) => ['fill', 'stroke', 'opacity'].some((key) => value[key] !== districtStyles.get(id)?.[key]));
|
|
1712
|
+
if (fills && fade && enabled('districtFills') && nextYear === year && stylesChanged)
|
|
1713
|
+
districtTransition.fade(fills, fade);
|
|
1714
|
+
districtStyle = nextStyle;
|
|
1715
|
+
districtStyles = prepared;
|
|
1716
|
+
const palette = resolveMapColors(theme, colors);
|
|
1717
|
+
svg.querySelector('rect')?.setAttribute('fill', palette.water);
|
|
1718
|
+
svg.querySelector('[data-layer="coast"]')?.setAttribute('fill', palette.land);
|
|
1719
|
+
svg.querySelector('[data-layer="coastline"]')?.setAttribute('stroke', palette.district);
|
|
1720
|
+
for (const [layer, color] of [
|
|
1721
|
+
['landmarks', palette.park],
|
|
1722
|
+
['highways', palette.highway],
|
|
1723
|
+
['key-roads', palette.road],
|
|
1724
|
+
])
|
|
1725
|
+
for (const node of svg.querySelectorAll(`[data-layer="${layer}"] path`)) {
|
|
1726
|
+
node.setAttribute(layer === 'landmarks' ? 'fill' : 'stroke', color);
|
|
1727
|
+
if (layer === 'highways')
|
|
1728
|
+
node.setAttribute('stroke-width', theme === 'transit' ? '2' : '1.4');
|
|
1729
|
+
}
|
|
1730
|
+
for (const [token, fallback, name] of [
|
|
1731
|
+
['ink', '#18364f', 'ink'],
|
|
1732
|
+
['surface', '#fff', 'surface'],
|
|
1733
|
+
['accent', '#163d61', 'accent'],
|
|
1734
|
+
['border', '#cedae3', 'border'],
|
|
1735
|
+
['focus', '#1676b8', 'focus'],
|
|
1736
|
+
['controlGap', '6px', 'control-gap'],
|
|
1737
|
+
['font', 'system-ui,sans-serif', 'font'],
|
|
1738
|
+
])
|
|
1739
|
+
root.style.setProperty(`--sf-map-${name}`, styleOptions[token] ?? fallback);
|
|
1740
|
+
labelLayer.setAttribute('font-family', labelStyle.fontFamily ?? styleOptions.font ?? 'system-ui,sans-serif');
|
|
1741
|
+
labelRenderer.invalidateMetrics();
|
|
1742
|
+
for (const station of stationItems)
|
|
1743
|
+
station.node.setAttribute('stroke', colors.bart ?? '#0073ae');
|
|
1744
|
+
for (const kind of ['bart', 'park', 'highway', 'road']) {
|
|
1745
|
+
const symbol = legend.querySelector(`.sf-explorer-legend-${kind}`);
|
|
1746
|
+
if (symbol) {
|
|
1747
|
+
symbol.style.removeProperty(kind === 'bart' ? 'border-color' : 'background-color');
|
|
1748
|
+
if (colors[kind])
|
|
1749
|
+
symbol.style.setProperty(kind === 'bart' ? 'border-color' : 'background-color', colors[kind]);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
for (const item of items) {
|
|
1753
|
+
const active = item.feature === selected;
|
|
1754
|
+
item.node.setAttribute('fill', active ? (areaStyle.selectedFill ?? '#408dbe') : 'transparent');
|
|
1755
|
+
item.node.setAttribute('stroke', active ? (areaStyle.selectedStroke ?? '#176ba2') : (colors.neighborhood ?? '#9caebc'));
|
|
1756
|
+
}
|
|
1757
|
+
for (const item of markerItems) {
|
|
1758
|
+
item.dot.setAttribute('fill', item.marker.id === selectedMarker
|
|
1759
|
+
? selectedMarkerColor
|
|
1760
|
+
: (item.marker.color ?? markerColor));
|
|
1761
|
+
item.ring.setAttribute('stroke', features.selectedMarkerRing
|
|
1762
|
+
? (features.selectedMarkerRing.color ?? selectedMarkerColor)
|
|
1763
|
+
: selectedMarkerColor);
|
|
1764
|
+
}
|
|
1765
|
+
invalidateClusters();
|
|
1766
|
+
updateDistrictAppearance();
|
|
1767
|
+
}
|
|
1768
|
+
if (nextYear !== year)
|
|
1769
|
+
setDistrictYear(nextYear, {}, prepared);
|
|
1770
|
+
if (destroyed || revision !== presentationRevision || !isCurrent())
|
|
1771
|
+
return false;
|
|
1772
|
+
commitSource?.();
|
|
1773
|
+
if (destroyed || revision !== presentationRevision || !isCurrent())
|
|
1774
|
+
return false;
|
|
1775
|
+
if (commitSource && appearance)
|
|
1776
|
+
for (const item of items)
|
|
1777
|
+
item.node.setAttribute('stroke', colors.neighborhood ?? '#9caebc');
|
|
1778
|
+
if (patch.mode !== undefined)
|
|
1779
|
+
setMode(patch.mode, false);
|
|
1780
|
+
if (patch.labels !== undefined)
|
|
1781
|
+
setLabels(patch.labels);
|
|
1782
|
+
scheduleLabels();
|
|
1783
|
+
return true;
|
|
1784
|
+
}
|
|
1656
1785
|
const explorer = Object.assign(root, {
|
|
1786
|
+
applyPresentation,
|
|
1787
|
+
getMapState: () => ({ mode, source, year, labels }),
|
|
1657
1788
|
selectNeighborhood,
|
|
1658
1789
|
getSelection,
|
|
1659
1790
|
selectDistrict,
|
package/dist/src/guide-map.js
CHANGED
|
@@ -6,7 +6,7 @@ import { guideOptions } from './presets.js';
|
|
|
6
6
|
import { validateExplorerOptions } from './validation.js';
|
|
7
7
|
function controllerOptions(options) {
|
|
8
8
|
expandMapOptions(options);
|
|
9
|
-
const config = prepareConfiguration({ features: {}, layers: guideOptions.layers ?? {}, controls: {} }, {
|
|
9
|
+
const config = prepareConfiguration({ features: {}, layers: guideOptions.layers ?? {}, controls: {}, appearance: {} }, {
|
|
10
10
|
features: options.features,
|
|
11
11
|
layers: options.layers === undefined ? {} : options.layers,
|
|
12
12
|
controls: options.controls,
|
package/dist/src/guide-static.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { guideMapData } from './guide-data.js';
|
|
2
2
|
import { guideShellCSS } from './guide-shell.js';
|
|
3
|
-
import {
|
|
3
|
+
import { renderMap } from './static.js';
|
|
4
4
|
/** Server-safe overview using exactly the guide browser geography. */
|
|
5
5
|
export function createGuideSVG(options = {}) {
|
|
6
|
-
return
|
|
6
|
+
return renderMap(guideMapData.map, {
|
|
7
7
|
theme: 'transit',
|
|
8
8
|
districtFills: false,
|
|
9
9
|
districtLines: false,
|
|
@@ -14,7 +14,7 @@ export function createGuideSVG(options = {}) {
|
|
|
14
14
|
keyRoads: true,
|
|
15
15
|
bartStations: true,
|
|
16
16
|
...options,
|
|
17
|
-
}
|
|
17
|
+
});
|
|
18
18
|
}
|
|
19
19
|
/** Stable compact frame, progressively enhanced with mountGuideMap(). No browser globals. */
|
|
20
20
|
export function createGuideShell(options = {}) {
|
package/dist/src/map-core.d.ts
CHANGED
|
@@ -80,6 +80,20 @@ export declare function getLayerPathsWithData(options: SFMapOptions, data: SFMap
|
|
|
80
80
|
point: [number, number];
|
|
81
81
|
}[];
|
|
82
82
|
};
|
|
83
|
+
export declare function resolveMapColors(theme: SFMapOptions['theme'], colors?: SFMapOptions['colors']): {
|
|
84
|
+
water: string;
|
|
85
|
+
land: string;
|
|
86
|
+
district: string;
|
|
87
|
+
neighborhood: string;
|
|
88
|
+
highway: string;
|
|
89
|
+
road: string;
|
|
90
|
+
park: string;
|
|
91
|
+
landmark: string;
|
|
92
|
+
bart: string;
|
|
93
|
+
label: string;
|
|
94
|
+
marker: string;
|
|
95
|
+
selected: string;
|
|
96
|
+
};
|
|
83
97
|
/** Make an offline SVG and the matching longitude/latitude projection. */
|
|
84
98
|
export declare function createSFMapWithData(options: SFMapOptions, data: SFMapData): {
|
|
85
99
|
svg: string;
|
package/dist/src/map-core.js
CHANGED
|
@@ -104,6 +104,9 @@ const transitColors = {
|
|
|
104
104
|
marker: '#0073ae',
|
|
105
105
|
selected: '#0073ae',
|
|
106
106
|
};
|
|
107
|
+
export function resolveMapColors(theme, colors = {}) {
|
|
108
|
+
return { ...defaults, ...(theme === 'transit' ? transitColors : {}), ...colors };
|
|
109
|
+
}
|
|
107
110
|
let sequence = 0;
|
|
108
111
|
/** Make an offline SVG and the matching longitude/latitude projection. */
|
|
109
112
|
export function createSFMapWithData(options, data) {
|
|
@@ -115,7 +118,7 @@ export function createSFMapWithData(options, data) {
|
|
|
115
118
|
throw new TypeError('idPrefix must start with a letter and contain only letters, numbers, underscores, or hyphens.');
|
|
116
119
|
if (theme !== 'districts' && theme !== 'transit')
|
|
117
120
|
throw new TypeError('Theme must be districts or transit.');
|
|
118
|
-
const colors =
|
|
121
|
+
const colors = resolveMapColors(theme, options.colors);
|
|
119
122
|
const animation = normalizeStaticAnimation(options.animation);
|
|
120
123
|
const animated = animation !== null;
|
|
121
124
|
const geometry = getLayerPathsWithData(options, data, false);
|