@kahwee/sf-map-svg 4.4.0 → 5.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 +25 -0
- package/README.md +32 -0
- package/dist/data/README.md +7 -0
- package/dist/src/cards.d.ts +15 -0
- package/dist/src/cards.js +138 -0
- package/dist/src/cluster-layer.d.ts +2 -0
- package/dist/src/cluster-layer.js +2 -0
- package/dist/src/configuration.d.ts +2 -0
- package/dist/src/configuration.js +30 -0
- package/dist/src/controller-types.d.ts +11 -15
- package/dist/src/explorer-controls.d.ts +26 -0
- package/dist/src/explorer-controls.js +120 -0
- package/dist/src/explorer-core.d.ts +6 -1
- package/dist/src/explorer-core.js +156 -667
- package/dist/src/explorer-shell.d.ts +62 -0
- package/dist/src/explorer-shell.js +228 -0
- package/dist/src/explorer-sidebar.d.ts +20 -0
- package/dist/src/explorer-sidebar.js +79 -0
- package/dist/src/geometry.d.ts +6 -1
- package/dist/src/geometry.js +80 -0
- package/dist/src/guide-map.js +2 -1
- package/dist/src/guide-static.d.ts +1 -1
- package/dist/src/label-spread.d.ts +5 -0
- package/dist/src/label-spread.js +36 -0
- package/dist/src/layers.js +1 -1
- package/dist/src/map.d.ts +1 -1
- package/dist/src/map.js +24 -13
- package/dist/src/marker-catalog.d.ts +1 -1
- package/dist/src/marker-catalog.js +11 -5
- package/dist/src/neighborhood-layer.d.ts +67 -0
- package/dist/src/neighborhood-layer.js +256 -0
- package/dist/src/overlay-layer.d.ts +13 -0
- package/dist/src/overlay-layer.js +68 -0
- package/dist/src/palettes.d.ts +48 -0
- package/dist/src/palettes.js +40 -0
- package/dist/src/presets.d.ts +1 -0
- package/dist/src/presets.js +1 -0
- package/dist/src/static-labels.js +19 -4
- package/dist/src/static.d.ts +2 -2
- package/dist/src/static.js +21 -2
- package/dist/src/types.d.ts +21 -9
- package/dist/src/validation.js +7 -5
- package/docs/API.md +75 -5
- package/docs/EXAMPLES.md +29 -0
- package/docs/api-audit.md +22 -5
- package/docs/consumer-integration.md +86 -2
- package/docs/developer-guide.md +29 -7
- package/docs/guide-bundle-report.md +4 -4
- package/docs/migration-v5.md +100 -0
- package/llms-full.txt +508 -53
- package/llms.txt +2 -1
- package/package.json +17 -8
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,31 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 5.0.0 — 2026-10-19
|
|
8
|
+
|
|
9
|
+
- Breaking: controller `setMode` and `setSource` preserve the camera; remove their `{ resetView }` argument. Call `camera.reset()` or `camera.fit()` explicitly.
|
|
10
|
+
- Breaking: controller `configure` treats all omitted and undefined values as no-ops and merges nested feature records. Add `resetConfiguration(groups?)` to clear presentation overrides explicitly.
|
|
11
|
+
- Breaking: controller `selectNeighborhood` selects exact IDs in the current source; resolve names through source-specific data lookup or use source/year-qualified `selectFeature`.
|
|
12
|
+
- Include a [v5 migration guide](docs/migration-v5.md), migrated consumer examples, and regression checks. Retain Node 24+, offline rendering, zero runtime dependencies, geographic data, and default appearance.
|
|
13
|
+
|
|
14
|
+
- Add offline `geometryBounds()` and `geometryContainsPoint()` helpers to `/geometry`, with readonly inputs, polygon holes and islands, nested collections, explicit boundary semantics, and coordinate validation. Export `Bounds`, `Geometry`, and `Position` from the same entry.
|
|
15
|
+
- Breaking: markers now require `position: [longitude, latitude]`; remove the numeric `projectToScreen(lng, lat)` form in favor of one readonly GeoJSON position. Export shared `ScreenPoint` and `ProjectToScreen` types and cover camera changes, resizing, invalid coordinates, and controller disposal in browser regressions.
|
|
16
|
+
- Document geographic queries, screen-coordinate integration, and API consistency follow-ups. Correct the developer guide: camera pan deltas are CSS pixels, not projected map units.
|
|
17
|
+
|
|
18
|
+
## 4.5.0 — 2026-10-14
|
|
19
|
+
|
|
20
|
+
- Add the optional `/cards` entry with `bindMapCards()`: hover/focus previews a pin without changing selection or camera, native buttons select places, map selection reveals matching cards without stealing focus, and explicit cleanup restores owned DOM attributes.
|
|
21
|
+
- Spread budgeted static labels across the map while favoring nearby-pin context and retaining a selected-place label when it fits. Keep collision avoidance, all pins, unbudgeted labels, and default output unchanged.
|
|
22
|
+
|
|
23
|
+
- Add opt-in `mapPalettes.nightForest` and `mapPalettes.warmPaper` tokens for consistent static/interactive map colors, label halos, markers, and selection rings, with contrast checks.
|
|
24
|
+
|
|
25
|
+
- Port YorkSF’s editorial map-preview pattern into static rendering: opt-in compact SVG label layouts, spatial label budgets, and configurable halos for dark palettes. Add a build-time field-guide example and light/dark Storybook preview without runtime dependencies or changes to default output.
|
|
26
|
+
|
|
27
|
+
- Separate interactive neighborhood state, controls, sidebar downloads and overlays into lifecycle-owned modules; revoke neighborhood hover listeners when replacing sources and keep optional explorer sidebar code out of normal map bundles.
|
|
28
|
+
- Build the package once during `pnpm check`, while keeping standalone test and typecheck commands self-contained.
|
|
29
|
+
|
|
30
|
+
- Include compatible development-tool updates to Vite 8.3.4 and Playwright 1.64.0. Keep Storybook/addons 10.6.1 and aligned Vitest/browser/coverage 5.0.3; their newer 10.6.2 and 5.0.4 releases remain inside pnpm's release-age window. Make the consumer examples' custom touch-button accessible names include their visible text. Remove the obsolete Storybook 10.6.0 peer overrides; retain Node 24+ and zero runtime dependencies. These tooling changes were previously prepared as 4.4.1 but not published separately.
|
|
31
|
+
|
|
7
32
|
## 4.4.0 — 2026-10-12
|
|
8
33
|
|
|
9
34
|
- Add opt-in static `labelLayout: { width }` to `renderMap`, `createGuideSVG`, and `createGuideShell`. Static and interactive maps now share overview priorities, neighborhood anchors, font sizes, and collision rules; centered labels retain their anchors during enhancement.
|
package/README.md
CHANGED
|
@@ -6,6 +6,11 @@ Offline, self-contained San Francisco SVG maps. The package has no runtime depen
|
|
|
6
6
|
|
|
7
7
|
[Map preview](https://github.com/kahwee/sf-map-svg/blob/main/docs/map-preview.png) · 2,000-marker screencasts: [desktop](https://github.com/kahwee/sf-map-svg/blob/main/docs/media/markers-2000-desktop.webm), [390 px phone](https://github.com/kahwee/sf-map-svg/blob/main/docs/media/markers-2000-phone.webm). [Screenshots and capture instructions](https://github.com/kahwee/sf-map-svg/blob/main/docs/media/README.md) show zoom, viewport culling, clustering, label updates, and selection from the complete catalog. These recordings illustrate behavior; frame timings are reported below.
|
|
8
8
|
|
|
9
|
+
## Upgrading to v5
|
|
10
|
+
|
|
11
|
+
Version 5.0.0 standardizes coordinate tuples and controller reset behavior.
|
|
12
|
+
Read the [migration guide](docs/migration-v5.md) before upgrading from v4.
|
|
13
|
+
|
|
9
14
|
## Install
|
|
10
15
|
|
|
11
16
|
```sh
|
|
@@ -82,6 +87,33 @@ See [controller options, events, and lifecycle](docs/API.md#interactive-maps),
|
|
|
82
87
|
[examples](docs/EXAMPLES.md), and [consumer integration](docs/consumer-integration.md).
|
|
83
88
|
Call `destroy()` when removing an interactive map.
|
|
84
89
|
|
|
90
|
+
## Geographic queries and screen coordinates
|
|
91
|
+
|
|
92
|
+
New in v5. See the [v5 migration guide](docs/migration-v5.md) for breaking changes.
|
|
93
|
+
|
|
94
|
+
Use the data-free `/geometry` entry to calculate geographic bounds or test which
|
|
95
|
+
polygons contain a location, without constructing a map or requiring a browser:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { geometryBounds, geometryContainsPoint } from '@kahwee/sf-map-svg/geometry';
|
|
99
|
+
import { neighborhoods } from '@kahwee/sf-map-svg/data/realtor';
|
|
100
|
+
|
|
101
|
+
const location = [-122.42, 37.76] as const;
|
|
102
|
+
const matches = neighborhoods.features.filter(({ geometry }) =>
|
|
103
|
+
geometryContainsPoint(geometry, location),
|
|
104
|
+
);
|
|
105
|
+
const bounds = matches[0] ? geometryBounds(matches[0].geometry) : null;
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Polygon holes are respected; shared boundaries can match multiple areas.
|
|
109
|
+
Bounds are `[west, south, east, north]` in degrees, not camera coordinates.
|
|
110
|
+
The [query contract](docs/API.md#geographic-queries) covers empty geometry,
|
|
111
|
+
validation, and geographic limitations.
|
|
112
|
+
|
|
113
|
+
For mounted browser maps, `map.projectToScreen(location)` returns canvas-relative
|
|
114
|
+
CSS pixels and viewport visibility. All coordinate inputs now use GeoJSON tuples. See the [overlay recipe](docs/consumer-integration.md#position-html-overlays)
|
|
115
|
+
and [coordinate units](docs/API.md#coordinates-and-units).
|
|
116
|
+
|
|
85
117
|
## Documentation for coding assistants
|
|
86
118
|
|
|
87
119
|
[Developer guide](docs/developer-guide.md) · [llms.txt index](llms.txt) · [Complete text documentation](llms-full.txt). Generated from project docs and TypeScript declarations; run `pnpm docs:llms` after changing those inputs.
|
package/dist/data/README.md
CHANGED
|
@@ -110,6 +110,13 @@ const pathData = geometryPath(mission.geometry, map.project);
|
|
|
110
110
|
// Use pathData as an SVG <path d="..."> over map.svg.
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
+
The v5 `/geometry` helpers `geometryBounds(geometry)` and
|
|
114
|
+
`geometryContainsPoint(geometry, position)` also work with these readonly dataset
|
|
115
|
+
features. They import no geography themselves. Containment respects holes and
|
|
116
|
+
includes shared boundaries, so keep all matches when querying a source. See the
|
|
117
|
+
[area-lookup example](../docs/EXAMPLES.md#find-areas-containing-a-location) and
|
|
118
|
+
[query contract](../docs/API.md#geographic-queries). These additions are available in v5.
|
|
119
|
+
|
|
113
120
|
`geometryPath` handles Polygon, MultiPolygon, LineString, MultiLineString, and GeometryCollection; points use `map.project` and a marker element. Storybook's **Data / Neighborhood explorer** demonstrates the complete overlay and offers JSON downloads.
|
|
114
121
|
|
|
115
122
|
## District display metadata
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MapController } from './controller-types.js';
|
|
2
|
+
import type { MapMarker } from './types.js';
|
|
3
|
+
export interface MapCardsOptions {
|
|
4
|
+
markers: readonly MapMarker[];
|
|
5
|
+
highlightColor?: string;
|
|
6
|
+
/** Reveal selected cards with native scrolling, without moving keyboard focus. Default true. */
|
|
7
|
+
scrollOnSelect?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** Connect consumer-owned [data-map-marker] cards and [data-map-select] buttons to a map. */
|
|
10
|
+
export declare function bindMapCards(map: MapController, root: HTMLElement, options: MapCardsOptions): {
|
|
11
|
+
/** Call after map.setMarkers() when your place catalog changes. */
|
|
12
|
+
setMarkers(next: readonly MapMarker[]): void;
|
|
13
|
+
/** Remove owned listeners/decoration and restore card attributes. Call before map.destroy(). */
|
|
14
|
+
destroy(): void;
|
|
15
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { validateMarkers } from './validation.js';
|
|
2
|
+
/** Connect consumer-owned [data-map-marker] cards and [data-map-select] buttons to a map. */
|
|
3
|
+
export function bindMapCards(map, root, options) {
|
|
4
|
+
if (map.destroyed)
|
|
5
|
+
throw new Error('Cannot bind cards to a destroyed map.');
|
|
6
|
+
validateMarkers(options.markers);
|
|
7
|
+
let markers = new Map(options.markers.map((marker) => [marker.id, { ...marker }]));
|
|
8
|
+
let disposed = false;
|
|
9
|
+
let hovered = null;
|
|
10
|
+
let focused = null;
|
|
11
|
+
let selecting = false;
|
|
12
|
+
const document = root.ownerDocument;
|
|
13
|
+
const ring = document.createElement('span');
|
|
14
|
+
ring.dataset.mapCardHighlight = '';
|
|
15
|
+
ring.setAttribute('aria-hidden', 'true');
|
|
16
|
+
ring.style.cssText =
|
|
17
|
+
'position:absolute;width:26px;height:26px;box-sizing:border-box;border:3px solid;border-radius:50%;transform:translate(-50%,-50%);pointer-events:none';
|
|
18
|
+
ring.style.borderColor = options.highlightColor ?? '#e4531b';
|
|
19
|
+
ring.hidden = true;
|
|
20
|
+
map.overlayElement.append(ring);
|
|
21
|
+
const originals = new Map();
|
|
22
|
+
function attribute(node, key, value) {
|
|
23
|
+
let saved = originals.get(node);
|
|
24
|
+
if (!saved) {
|
|
25
|
+
saved = new Map();
|
|
26
|
+
originals.set(node, saved);
|
|
27
|
+
}
|
|
28
|
+
if (!saved.has(key))
|
|
29
|
+
saved.set(key, node.getAttribute(key));
|
|
30
|
+
if (value === null)
|
|
31
|
+
node.removeAttribute(key);
|
|
32
|
+
else
|
|
33
|
+
node.setAttribute(key, value);
|
|
34
|
+
}
|
|
35
|
+
const cardAt = (target) => {
|
|
36
|
+
const card = target instanceof Element ? target.closest('[data-map-marker]') : null;
|
|
37
|
+
return card && root.contains(card) ? card : null;
|
|
38
|
+
};
|
|
39
|
+
function highlight() {
|
|
40
|
+
ring.hidden = true;
|
|
41
|
+
if (disposed || map.destroyed || !map.overlayElement.getBoundingClientRect().width)
|
|
42
|
+
return;
|
|
43
|
+
const card = focused ?? hovered;
|
|
44
|
+
const marker = card && root.contains(card) ? markers.get(card.dataset.mapMarker ?? '') : undefined;
|
|
45
|
+
if (!marker)
|
|
46
|
+
return;
|
|
47
|
+
const point = map.projectToScreen(marker.position);
|
|
48
|
+
if (!point.visible)
|
|
49
|
+
return;
|
|
50
|
+
ring.style.left = `${point.x}px`;
|
|
51
|
+
ring.style.top = `${point.y}px`;
|
|
52
|
+
ring.hidden = false;
|
|
53
|
+
}
|
|
54
|
+
function selection(id, reveal) {
|
|
55
|
+
for (const card of root.querySelectorAll('[data-map-marker]')) {
|
|
56
|
+
const selected = id !== null && card.dataset.mapMarker === id;
|
|
57
|
+
attribute(card, 'data-map-selected', selected ? '' : null);
|
|
58
|
+
for (const button of card.querySelectorAll('button[data-map-select]'))
|
|
59
|
+
attribute(button, 'aria-pressed', String(selected));
|
|
60
|
+
if (selected && reveal && !selecting && options.scrollOnSelect !== false)
|
|
61
|
+
card.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const handlers = {
|
|
65
|
+
pointerover: (event) => {
|
|
66
|
+
if (event.pointerType !== 'touch') {
|
|
67
|
+
hovered = cardAt(event.target);
|
|
68
|
+
highlight();
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
pointerout: (event) => {
|
|
72
|
+
hovered = cardAt(event.relatedTarget);
|
|
73
|
+
highlight();
|
|
74
|
+
},
|
|
75
|
+
focusin: (event) => {
|
|
76
|
+
focused = cardAt(event.target);
|
|
77
|
+
highlight();
|
|
78
|
+
},
|
|
79
|
+
focusout: (event) => {
|
|
80
|
+
focused = cardAt(event.relatedTarget);
|
|
81
|
+
highlight();
|
|
82
|
+
},
|
|
83
|
+
click: (event) => {
|
|
84
|
+
if (disposed || map.destroyed)
|
|
85
|
+
return;
|
|
86
|
+
const target = event.target instanceof Element ? event.target.closest('button[data-map-select]') : null;
|
|
87
|
+
const card = cardAt(target);
|
|
88
|
+
const id = card?.dataset.mapMarker;
|
|
89
|
+
if (!target || !card || !id || !markers.has(id) || target.disabled)
|
|
90
|
+
return;
|
|
91
|
+
selecting = true;
|
|
92
|
+
try {
|
|
93
|
+
map.selectMarker(id, { fit: false });
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
selecting = false;
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
for (const [type, listener] of Object.entries(handlers))
|
|
101
|
+
root.addEventListener(type, listener);
|
|
102
|
+
const unsubscribers = [
|
|
103
|
+
map.on('markerchange', ({ id }) => selection(id, true)),
|
|
104
|
+
map.on('viewportchange', highlight),
|
|
105
|
+
map.on('mapresize', highlight),
|
|
106
|
+
];
|
|
107
|
+
selection(map.getSelectedMarker()?.id ?? null, false);
|
|
108
|
+
return {
|
|
109
|
+
/** Call after map.setMarkers() when your place catalog changes. */
|
|
110
|
+
setMarkers(next) {
|
|
111
|
+
if (disposed)
|
|
112
|
+
return;
|
|
113
|
+
validateMarkers(next);
|
|
114
|
+
markers = new Map(next.map((marker) => [marker.id, { ...marker }]));
|
|
115
|
+
if (!map.destroyed)
|
|
116
|
+
selection(map.getSelectedMarker()?.id ?? null, false);
|
|
117
|
+
highlight();
|
|
118
|
+
},
|
|
119
|
+
/** Remove owned listeners/decoration and restore card attributes. Call before map.destroy(). */
|
|
120
|
+
destroy() {
|
|
121
|
+
if (disposed)
|
|
122
|
+
return;
|
|
123
|
+
disposed = true;
|
|
124
|
+
for (const [type, listener] of Object.entries(handlers))
|
|
125
|
+
root.removeEventListener(type, listener);
|
|
126
|
+
for (const unsubscribe of unsubscribers)
|
|
127
|
+
unsubscribe();
|
|
128
|
+
ring.remove();
|
|
129
|
+
for (const [node, saved] of originals)
|
|
130
|
+
for (const [key, value] of saved)
|
|
131
|
+
if (value === null)
|
|
132
|
+
node.removeAttribute(key);
|
|
133
|
+
else
|
|
134
|
+
node.setAttribute(key, value);
|
|
135
|
+
originals.clear();
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -2,6 +2,8 @@ import type { MapConfiguration, MapConfigurationSnapshot, MapOptions } from './c
|
|
|
2
2
|
import type { NeighborhoodExplorerOptions } from './types.js';
|
|
3
3
|
export declare function expandMapOptions(options: MapOptions): NeighborhoodExplorerOptions;
|
|
4
4
|
type PresentationSnapshot = Pick<MapConfigurationSnapshot, 'features' | 'layers' | 'controls' | 'appearance'>;
|
|
5
|
+
/** Public v5 patches consistently ignore undefined at every record depth. */
|
|
6
|
+
export declare function configurationPatch(patch: MapConfiguration, current?: Partial<MapConfiguration>): MapConfiguration;
|
|
5
7
|
/** Prepare the entire configuration before any DOM mutation. */
|
|
6
8
|
export declare function prepareConfiguration(current: PresentationSnapshot, patch: MapConfiguration): PresentationSnapshot;
|
|
7
9
|
export {};
|
|
@@ -44,6 +44,36 @@ export function expandMapOptions(options) {
|
|
|
44
44
|
validateExplorerOptions(expanded);
|
|
45
45
|
return expanded;
|
|
46
46
|
}
|
|
47
|
+
/** Public v5 patches consistently ignore undefined at every record depth. */
|
|
48
|
+
export function configurationPatch(patch, current = {}) {
|
|
49
|
+
assertOptions(patch, 'configuration', [
|
|
50
|
+
'features',
|
|
51
|
+
'layers',
|
|
52
|
+
'controls',
|
|
53
|
+
'appearance',
|
|
54
|
+
'mode',
|
|
55
|
+
'source',
|
|
56
|
+
'year',
|
|
57
|
+
'labels',
|
|
58
|
+
]);
|
|
59
|
+
const clean = (value, previous, depth) => {
|
|
60
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
61
|
+
return value;
|
|
62
|
+
const prior = previous && typeof previous === 'object' && !Array.isArray(previous)
|
|
63
|
+
? previous
|
|
64
|
+
: {};
|
|
65
|
+
return {
|
|
66
|
+
...(depth >= 2 ? prior : {}),
|
|
67
|
+
...Object.fromEntries(Object.entries(value)
|
|
68
|
+
.filter(([, entry]) => entry !== undefined || depth > 0)
|
|
69
|
+
.map(([key, entry]) => [
|
|
70
|
+
key,
|
|
71
|
+
clean(entry === undefined ? prior[key] : entry, prior[key], depth + 1),
|
|
72
|
+
])),
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
return clean(patch, current, 0);
|
|
76
|
+
}
|
|
47
77
|
/** Prepare the entire configuration before any DOM mutation. */
|
|
48
78
|
export function prepareConfiguration(current, patch) {
|
|
49
79
|
assertOptions(patch, 'configuration', [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Geometry, NeighborhoodSource } from '../data/types.js';
|
|
2
|
-
import type { CameraOptions, DistrictSelection, DistrictStyle, DistrictYear, InteractiveLayers, MapFeatures, MapMarker, MapOverlay, MapPadding, MapViewport, NeighborhoodExplorerOptions, NeighborhoodSelection } from './types.js';
|
|
3
|
-
/**
|
|
2
|
+
import type { CameraOptions, DistrictSelection, DistrictStyle, DistrictYear, InteractiveLayers, MapFeatures, MapMarker, MapOverlay, MapPadding, MapViewport, NeighborhoodExplorerOptions, NeighborhoodSelection, ProjectToScreen } from './types.js';
|
|
3
|
+
/** Explicit undefined is accepted as a no-op, including with exactOptionalPropertyTypes. */
|
|
4
4
|
type Resettable<T> = {
|
|
5
5
|
[K in keyof T]?: T[K] | undefined;
|
|
6
6
|
};
|
|
@@ -9,7 +9,7 @@ export type MapAppearance = {
|
|
|
9
9
|
[K in keyof AppearanceOptions]?: K extends 'colors' | 'style' | 'labelStyle' | 'labelSize' | 'areaStyle' ? Resettable<NonNullable<AppearanceOptions[K]>> | undefined : AppearanceOptions[K] | undefined;
|
|
10
10
|
};
|
|
11
11
|
export type MapControls = NonNullable<NeighborhoodExplorerOptions['controls']>;
|
|
12
|
-
/** Omitted
|
|
12
|
+
/** Omitted and undefined values retain current values. Use resetConfiguration to clear overrides. */
|
|
13
13
|
export interface MapConfiguration {
|
|
14
14
|
features?: Resettable<MapFeatures> | undefined;
|
|
15
15
|
layers?: Resettable<InteractiveLayers> | undefined;
|
|
@@ -115,10 +115,7 @@ export interface MapEvents {
|
|
|
115
115
|
};
|
|
116
116
|
mapresize: undefined;
|
|
117
117
|
}
|
|
118
|
-
|
|
119
|
-
export interface MapViewUpdateOptions {
|
|
120
|
-
resetView?: boolean;
|
|
121
|
-
}
|
|
118
|
+
export type MapConfigurationGroup = 'features' | 'layers' | 'controls' | 'appearance';
|
|
122
119
|
export interface MapCamera {
|
|
123
120
|
/** Fixed 800 × 800 projected space, not longitude/latitude. */
|
|
124
121
|
get(): MapViewport;
|
|
@@ -139,6 +136,8 @@ export interface MapController {
|
|
|
139
136
|
readonly camera: MapCamera;
|
|
140
137
|
readonly destroyed: boolean;
|
|
141
138
|
configure(patch: MapConfiguration): void;
|
|
139
|
+
/** Clear presentation overrides to built-in defaults, preserving camera and geographic choices. */
|
|
140
|
+
resetConfiguration(groups?: readonly MapConfigurationGroup[]): void;
|
|
142
141
|
getConfiguration(): MapConfigurationSnapshot;
|
|
143
142
|
getResolvedConfiguration(): ResolvedMapConfiguration;
|
|
144
143
|
getCapabilities(): MapCapabilities;
|
|
@@ -154,7 +153,7 @@ export interface MapController {
|
|
|
154
153
|
fit?: boolean;
|
|
155
154
|
}): boolean;
|
|
156
155
|
getSelectedMarker(): MapMarker | null;
|
|
157
|
-
selectNeighborhood(
|
|
156
|
+
selectNeighborhood(id: string | null, options?: CameraOptions & {
|
|
158
157
|
fit?: boolean;
|
|
159
158
|
}): boolean;
|
|
160
159
|
getSelectedNeighborhood(): NeighborhoodSelection | null;
|
|
@@ -164,15 +163,12 @@ export interface MapController {
|
|
|
164
163
|
getSelectedDistrict(): DistrictSelection | null;
|
|
165
164
|
setDistrictYear(year: DistrictYear, options?: CameraOptions): void;
|
|
166
165
|
setDistrictStyle(style: ((district: DistrictSelection['district']) => DistrictStyle) | undefined): void;
|
|
167
|
-
setSource(source: NeighborhoodSource
|
|
168
|
-
setMode(mode: NonNullable<MapOptions['mode']
|
|
166
|
+
setSource(source: NeighborhoodSource): void;
|
|
167
|
+
setMode(mode: NonNullable<MapOptions['mode']>): void;
|
|
169
168
|
setLabels(visible: boolean): void;
|
|
170
169
|
setTouchNavigation(enabled: boolean): void;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
y: number;
|
|
174
|
-
visible: boolean;
|
|
175
|
-
};
|
|
170
|
+
/** Accepts a GeoJSON position; returns canvas-relative CSS pixels. */
|
|
171
|
+
projectToScreen: ProjectToScreen;
|
|
176
172
|
/** Idempotent; stops work and managed subscriptions. Does not remove host-owned DOM. */
|
|
177
173
|
destroy(): void;
|
|
178
174
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { createExplorerShell } from './explorer-shell.js';
|
|
2
|
+
import type { MapViewport, NeighborhoodExplorerElement, NeighborhoodExplorerOptions } from './types.js';
|
|
3
|
+
type Controls = NonNullable<NeighborhoodExplorerOptions['controls']>;
|
|
4
|
+
type Shell = Pick<ReturnType<typeof createExplorerShell>, 'root' | 'toolbar' | 'canvas' | 'hint' | 'status' | 'modeSelect' | 'sourceSelect' | 'search' | 'results' | 'neighborhoodSelect' | 'markerSelect'>;
|
|
5
|
+
type Commands = Pick<NeighborhoodExplorerElement, 'getViewport' | 'zoomBy' | 'resetView' | 'setLabels' | 'stopAnimation' | 'setMode' | 'setSource' | 'selectNeighborhood' | 'selectMarker'> & {
|
|
6
|
+
svg: SVGSVGElement;
|
|
7
|
+
signal: AbortSignal;
|
|
8
|
+
strings: NonNullable<NeighborhoodExplorerOptions['strings']>;
|
|
9
|
+
isDestroyed(): boolean;
|
|
10
|
+
getLabels(): boolean;
|
|
11
|
+
moveView(view: MapViewport): void;
|
|
12
|
+
setView(view: MapViewport): void;
|
|
13
|
+
updateResults(): void;
|
|
14
|
+
search(query: string): string | undefined;
|
|
15
|
+
};
|
|
16
|
+
/** Own toolbar and form events, touch navigation and control visibility.
|
|
17
|
+
* Commands read current map state; all listeners share the map's disposal signal.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createExplorerControls({ root, toolbar, canvas, hint, status, modeSelect, sourceSelect, search, results, neighborhoodSelect, markerSelect, }: Shell, { svg, signal, strings, isDestroyed, getLabels, getViewport, moveView, zoomBy, resetView, setLabels, stopAnimation, setView, setMode, setSource, selectNeighborhood, selectMarker, updateResults, search: findNeighborhood, }: Commands): {
|
|
20
|
+
setTouchNavigation: (enabled: boolean) => void;
|
|
21
|
+
cancelNavigation: () => void;
|
|
22
|
+
update(controls: Controls): void;
|
|
23
|
+
updateLabels(labels: boolean): void;
|
|
24
|
+
updateZoom(zoom: number): void;
|
|
25
|
+
};
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { element } from './dom.js';
|
|
2
|
+
import { attachNavigation } from './navigation.js';
|
|
3
|
+
/** Own toolbar and form events, touch navigation and control visibility.
|
|
4
|
+
* Commands read current map state; all listeners share the map's disposal signal.
|
|
5
|
+
*/
|
|
6
|
+
export function createExplorerControls({ root, toolbar, canvas, hint, status, modeSelect, sourceSelect, search, results, neighborhoodSelect, markerSelect, }, { svg, signal, strings, isDestroyed, getLabels, getViewport, moveView, zoomBy, resetView, setLabels, stopAnimation, setView, setMode, setSource, selectNeighborhood, selectMarker, updateResults, search: findNeighborhood, }) {
|
|
7
|
+
function listen(node, event, handler) {
|
|
8
|
+
node.addEventListener(event, (incoming) => handler(incoming), { signal });
|
|
9
|
+
}
|
|
10
|
+
const zoomControls = element('div', '', 'sf-explorer-control-group');
|
|
11
|
+
const panControls = element('div', '', 'sf-explorer-control-group sf-explorer-pan-controls');
|
|
12
|
+
toolbar.append(zoomControls, panControls);
|
|
13
|
+
function button(text, label, action, group = zoomControls) {
|
|
14
|
+
const node = element('button', text);
|
|
15
|
+
node.type = 'button';
|
|
16
|
+
node.setAttribute('aria-label', label);
|
|
17
|
+
node.addEventListener('click', () => action(), { signal });
|
|
18
|
+
group.append(node);
|
|
19
|
+
return node;
|
|
20
|
+
}
|
|
21
|
+
const zoomOut = button('−', 'Zoom out', () => zoomBy(1 / 1.5));
|
|
22
|
+
const zoomText = element('span', '100%', 'sf-explorer-zoom');
|
|
23
|
+
zoomControls.append(zoomText);
|
|
24
|
+
const zoomIn = button('+', 'Zoom in', () => zoomBy(1.5));
|
|
25
|
+
const resetButton = button(strings.reset ?? 'Reset', strings.reset ?? 'Reset map to city view', resetView);
|
|
26
|
+
const labelsButton = button('Labels', 'Show map labels', () => setLabels(!getLabels()));
|
|
27
|
+
for (const [symbol, label, dx, dy] of [
|
|
28
|
+
['←', 'Pan west', -1, 0],
|
|
29
|
+
['↑', 'Pan north', 0, -1],
|
|
30
|
+
['↓', 'Pan south', 0, 1],
|
|
31
|
+
['→', 'Pan east', 1, 0],
|
|
32
|
+
])
|
|
33
|
+
button(symbol, label, () => {
|
|
34
|
+
const view = getViewport();
|
|
35
|
+
moveView([view[0] + (dx * view[2]) / 4, view[1] + (dy * view[2]) / 4, view[2]]);
|
|
36
|
+
}, panControls);
|
|
37
|
+
const touchLabel = strings.touchNavigation ?? 'Touch navigation';
|
|
38
|
+
const touchButton = button(touchLabel, strings.touchNavigationLabel ?? `Enable ${touchLabel.toLowerCase()}`, () => setTouchNavigation(!touchNavigation));
|
|
39
|
+
let touchNavigation = false;
|
|
40
|
+
const navigation = attachNavigation(svg, getViewport, (next) => {
|
|
41
|
+
stopAnimation();
|
|
42
|
+
setView(next);
|
|
43
|
+
}, signal, () => setTouchNavigation(false));
|
|
44
|
+
function setTouchNavigation(enabled) {
|
|
45
|
+
if (isDestroyed())
|
|
46
|
+
return;
|
|
47
|
+
if (typeof enabled !== 'boolean')
|
|
48
|
+
throw new TypeError('Touch navigation must be a boolean.');
|
|
49
|
+
touchNavigation = enabled;
|
|
50
|
+
navigation.setTouchNavigation(enabled);
|
|
51
|
+
canvas.style.touchAction = enabled ? 'none' : 'pan-y pinch-zoom';
|
|
52
|
+
touchButton.textContent = enabled
|
|
53
|
+
? (strings.touchNavigationDone ?? 'Done: page scrolling')
|
|
54
|
+
: touchLabel;
|
|
55
|
+
touchButton.setAttribute('aria-label', enabled
|
|
56
|
+
? (strings.touchNavigationExitLabel ?? `Exit ${touchLabel.toLowerCase()}`)
|
|
57
|
+
: (strings.touchNavigationLabel ?? `Enable ${touchLabel.toLowerCase()}`));
|
|
58
|
+
touchButton.setAttribute('aria-pressed', String(enabled));
|
|
59
|
+
root.dataset.touchNavigation = String(enabled);
|
|
60
|
+
}
|
|
61
|
+
listen(modeSelect, 'change', () => {
|
|
62
|
+
const next = modeSelect.value;
|
|
63
|
+
if (next === 'neighborhoods' || next === 'districts' || next === 'basemap')
|
|
64
|
+
setMode(next);
|
|
65
|
+
});
|
|
66
|
+
listen(search, 'input', updateResults);
|
|
67
|
+
listen(search, 'keydown', (event) => {
|
|
68
|
+
if (event.key === 'Enter') {
|
|
69
|
+
const match = findNeighborhood(search.value);
|
|
70
|
+
if (match) {
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
selectNeighborhood(match);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
listen(sourceSelect, 'change', () => {
|
|
77
|
+
const next = sourceSelect.value;
|
|
78
|
+
if (next === 'realtor' || next === 'sf-find' || next === 'analysis')
|
|
79
|
+
setSource(next);
|
|
80
|
+
});
|
|
81
|
+
listen(results, 'click', (event) => {
|
|
82
|
+
const node = event.target instanceof Element
|
|
83
|
+
? event.target.closest('[data-neighborhood-result]')
|
|
84
|
+
: null;
|
|
85
|
+
if (node?.dataset.neighborhoodResult)
|
|
86
|
+
selectNeighborhood(node.dataset.neighborhoodResult);
|
|
87
|
+
});
|
|
88
|
+
listen(neighborhoodSelect, 'change', () => selectNeighborhood(neighborhoodSelect.value || null));
|
|
89
|
+
listen(markerSelect, 'change', () => selectMarker(markerSelect.value || null));
|
|
90
|
+
return {
|
|
91
|
+
setTouchNavigation,
|
|
92
|
+
cancelNavigation: navigation.cancel,
|
|
93
|
+
update(controls) {
|
|
94
|
+
for (const node of [zoomIn, zoomOut, zoomText])
|
|
95
|
+
node.hidden = controls.zoom === false;
|
|
96
|
+
panControls.hidden = controls.pan === false;
|
|
97
|
+
resetButton.hidden = controls.reset === false;
|
|
98
|
+
labelsButton.hidden = controls.labels === false;
|
|
99
|
+
touchButton.hidden = controls.touch === false;
|
|
100
|
+
if (touchButton.hidden && touchNavigation)
|
|
101
|
+
setTouchNavigation(false);
|
|
102
|
+
zoomControls.hidden = [...zoomControls.children].every((node) => node.hidden);
|
|
103
|
+
toolbar.hidden = zoomControls.hidden && panControls.hidden;
|
|
104
|
+
hint.hidden = controls.help === false;
|
|
105
|
+
if (hint.hidden)
|
|
106
|
+
svg.setAttribute('aria-describedby', hint.id);
|
|
107
|
+
else
|
|
108
|
+
svg.removeAttribute('aria-describedby');
|
|
109
|
+
status.classList.toggle('sf-explorer-visually-hidden', controls.status === false);
|
|
110
|
+
},
|
|
111
|
+
updateLabels(labels) {
|
|
112
|
+
labelsButton.setAttribute('aria-pressed', String(labels));
|
|
113
|
+
},
|
|
114
|
+
updateZoom(zoom) {
|
|
115
|
+
zoomText.textContent = `${Math.round(zoom * 100)}%`;
|
|
116
|
+
zoomIn.disabled = zoom >= 12;
|
|
117
|
+
zoomOut.disabled = zoom <= 1;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { NeighborhoodSource } from '../data/types.js';
|
|
2
2
|
import type { MapConfiguration } from './controller-types.js';
|
|
3
3
|
import type { InteractiveSFMapData } from './explorer-data.js';
|
|
4
|
+
import type { createMapShell } from './explorer-shell.js';
|
|
5
|
+
import type { createExplorerSidebar } from './explorer-sidebar.js';
|
|
4
6
|
import type { DistrictYear, ExplorerMode, NeighborhoodExplorerElement, NeighborhoodExplorerOptions } from './types.js';
|
|
5
7
|
export type { ExplorerMode, NeighborhoodExplorerElement, NeighborhoodExplorerOptions, } from './types.js';
|
|
6
8
|
/** Private bridge between the renderer and its public lifecycle controller. */
|
|
@@ -14,4 +16,7 @@ type MapEngineElement = NeighborhoodExplorerElement & {
|
|
|
14
16
|
};
|
|
15
17
|
};
|
|
16
18
|
/** Create an offline, browser-only neighborhood explorer. Call destroy() before disposal. */
|
|
17
|
-
export declare function createNeighborhoodExplorerCore(options: NeighborhoodExplorerOptions | undefined, data: InteractiveSFMapData
|
|
19
|
+
export declare function createNeighborhoodExplorerCore(options: NeighborhoodExplorerOptions | undefined, data: InteractiveSFMapData, ui: {
|
|
20
|
+
createShell: typeof createMapShell;
|
|
21
|
+
createSidebar?: typeof createExplorerSidebar;
|
|
22
|
+
}): MapEngineElement;
|