@kahwee/sf-map-svg 4.5.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 +11 -0
- package/README.md +32 -0
- package/dist/data/README.md +7 -0
- package/dist/src/cards.js +1 -1
- 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-core.js +10 -5
- package/dist/src/geometry.d.ts +6 -1
- package/dist/src/geometry.js +80 -0
- package/dist/src/guide-static.d.ts +1 -1
- package/dist/src/layers.js +1 -1
- package/dist/src/map.d.ts +1 -1
- package/dist/src/map.js +22 -12
- package/dist/src/marker-catalog.d.ts +1 -1
- package/dist/src/marker-catalog.js +11 -5
- package/dist/src/static-labels.js +3 -3
- package/dist/src/static.d.ts +2 -2
- package/dist/src/types.d.ts +12 -9
- package/dist/src/validation.js +7 -5
- package/docs/API.md +43 -5
- package/docs/EXAMPLES.md +29 -0
- package/docs/api-audit.md +22 -5
- package/docs/consumer-integration.md +47 -1
- 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 +339 -52
- package/llms.txt +2 -1
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@ 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
|
+
|
|
7
18
|
## 4.5.0 — 2026-10-14
|
|
8
19
|
|
|
9
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.
|
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
|
package/dist/src/cards.js
CHANGED
|
@@ -44,7 +44,7 @@ export function bindMapCards(map, root, options) {
|
|
|
44
44
|
const marker = card && root.contains(card) ? markers.get(card.dataset.mapMarker ?? '') : undefined;
|
|
45
45
|
if (!marker)
|
|
46
46
|
return;
|
|
47
|
-
const point = map.projectToScreen(marker.
|
|
47
|
+
const point = map.projectToScreen(marker.position);
|
|
48
48
|
if (!point.visible)
|
|
49
49
|
return;
|
|
50
50
|
ring.style.left = `${point.x}px`;
|
|
@@ -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
|
}
|
|
@@ -140,8 +140,10 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
140
140
|
arrow.hidden = !features.northArrow;
|
|
141
141
|
}
|
|
142
142
|
syncFurniture();
|
|
143
|
-
function projectToScreen(
|
|
144
|
-
|
|
143
|
+
function projectToScreen(position) {
|
|
144
|
+
if (!Array.isArray(position) || position.length < 2)
|
|
145
|
+
throw new RangeError('Expected a [longitude, latitude] position.');
|
|
146
|
+
const [x, y] = map.project(position);
|
|
145
147
|
const width = canvas.getBoundingClientRect().width;
|
|
146
148
|
if (!width)
|
|
147
149
|
throw new Error('Mount the map before projecting to screen.');
|
|
@@ -510,7 +512,10 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
510
512
|
const clusters = createClusterLayer(svg, markerLayer, clusterLayer, markerNavigation, materializeMarker, (group, node, signal) => {
|
|
511
513
|
if (destroyed || signal.aborted)
|
|
512
514
|
return;
|
|
513
|
-
const markers = group.map((item) => ({
|
|
515
|
+
const markers = group.map((item) => ({
|
|
516
|
+
...item.marker,
|
|
517
|
+
position: [...item.marker.position],
|
|
518
|
+
}));
|
|
514
519
|
const minimumUnit = 800 / 12 / Math.max(1, canvas.getBoundingClientRect().width);
|
|
515
520
|
if (clusterPoints(group, minimumUnit, features.clustering ? features.clustering.radius : 36)
|
|
516
521
|
.length === 1)
|
|
@@ -518,7 +523,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
518
523
|
else
|
|
519
524
|
fitGeometry({
|
|
520
525
|
type: 'MultiPoint',
|
|
521
|
-
coordinates: markers.map((marker) =>
|
|
526
|
+
coordinates: markers.map((marker) => marker.position),
|
|
522
527
|
});
|
|
523
528
|
if (!destroyed && !signal.aborted)
|
|
524
529
|
root.dispatchEvent(new CustomEvent('clusteractivate', { bubbles: true, detail: { markers } }));
|
|
@@ -866,7 +871,7 @@ export function createNeighborhoodExplorerCore(options = {}, data, ui) {
|
|
|
866
871
|
}
|
|
867
872
|
function getSelectedMarker() {
|
|
868
873
|
const marker = markerCatalog.items.find((item) => item.marker.id === selectedMarker)?.marker;
|
|
869
|
-
return marker ? { ...marker } : null;
|
|
874
|
+
return marker ? { ...marker, position: [...marker.position] } : null;
|
|
870
875
|
}
|
|
871
876
|
function selectMarker(id, options = {}) {
|
|
872
877
|
if (destroyed)
|
package/dist/src/geometry.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import type { Geometry, Position } from '../data/types.js';
|
|
1
|
+
import type { Bounds, Geometry, Position } from '../data/types.js';
|
|
2
|
+
export type { Bounds, Geometry, Position } from '../data/types.js';
|
|
2
3
|
export type Project = (position: Position) => [number, number];
|
|
3
4
|
export declare function rawProject([longitude, latitude]: Position): [number, number];
|
|
4
5
|
export declare function positions(geometry: Geometry | null | undefined): Position[];
|
|
5
6
|
export declare function geometryPath(geometry: Geometry | null | undefined, project: Project): string;
|
|
7
|
+
/** Geographic [west, south, east, north], or null for empty geometry. */
|
|
8
|
+
export declare function geometryBounds(geometry: Geometry | null | undefined): Bounds | null;
|
|
9
|
+
/** Polygon hit testing in longitude/latitude; boundaries (including hole edges) count as hits. */
|
|
10
|
+
export declare function geometryContainsPoint(geometry: Geometry | null | undefined, point: Position): boolean;
|
package/dist/src/geometry.js
CHANGED
|
@@ -51,3 +51,83 @@ export function geometryPath(geometry, project) {
|
|
|
51
51
|
return '';
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
function validatePosition(point) {
|
|
55
|
+
if (!Number.isFinite(point[0]) ||
|
|
56
|
+
!Number.isFinite(point[1]) ||
|
|
57
|
+
Math.abs(point[0]) > 180 ||
|
|
58
|
+
Math.abs(point[1]) > 90)
|
|
59
|
+
throw new RangeError('Expected finite longitude/latitude within [-180, 180] and [-90, 90].');
|
|
60
|
+
}
|
|
61
|
+
/** Geographic [west, south, east, north], or null for empty geometry. */
|
|
62
|
+
export function geometryBounds(geometry) {
|
|
63
|
+
let west = Infinity;
|
|
64
|
+
let south = Infinity;
|
|
65
|
+
let east = -Infinity;
|
|
66
|
+
let north = -Infinity;
|
|
67
|
+
for (const point of positions(geometry)) {
|
|
68
|
+
validatePosition(point);
|
|
69
|
+
west = Math.min(west, point[0]);
|
|
70
|
+
south = Math.min(south, point[1]);
|
|
71
|
+
east = Math.max(east, point[0]);
|
|
72
|
+
north = Math.max(north, point[1]);
|
|
73
|
+
}
|
|
74
|
+
return west === Infinity ? null : [west, south, east, north];
|
|
75
|
+
}
|
|
76
|
+
// 0 outside, 1 inside, 2 on an edge. Rings may be explicitly or implicitly closed.
|
|
77
|
+
function ringLocation(point, ring) {
|
|
78
|
+
if (ring.length < 3)
|
|
79
|
+
return 0;
|
|
80
|
+
let inside = false;
|
|
81
|
+
const [x, y] = point;
|
|
82
|
+
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
83
|
+
const [ax, ay] = ring[j];
|
|
84
|
+
const [bx, by] = ring[i];
|
|
85
|
+
const cross = (x - ax) * (by - ay) - (y - ay) * (bx - ax);
|
|
86
|
+
if (cross === 0 &&
|
|
87
|
+
x >= Math.min(ax, bx) &&
|
|
88
|
+
x <= Math.max(ax, bx) &&
|
|
89
|
+
y >= Math.min(ay, by) &&
|
|
90
|
+
y <= Math.max(ay, by))
|
|
91
|
+
return 2;
|
|
92
|
+
if (ay > y !== by > y && x < ((bx - ax) * (y - ay)) / (by - ay) + ax)
|
|
93
|
+
inside = !inside;
|
|
94
|
+
}
|
|
95
|
+
return inside ? 1 : 0;
|
|
96
|
+
}
|
|
97
|
+
/** Polygon hit testing in longitude/latitude; boundaries (including hole edges) count as hits. */
|
|
98
|
+
export function geometryContainsPoint(geometry, point) {
|
|
99
|
+
validatePosition(point);
|
|
100
|
+
// Validate the entire input, including parts not reached by the hit test.
|
|
101
|
+
geometryBounds(geometry);
|
|
102
|
+
const polygonContains = (rings) => {
|
|
103
|
+
if (!rings.length)
|
|
104
|
+
return false;
|
|
105
|
+
const outer = ringLocation(point, rings[0]);
|
|
106
|
+
if (outer === 0)
|
|
107
|
+
return false;
|
|
108
|
+
if (outer === 2)
|
|
109
|
+
return true;
|
|
110
|
+
let inHole = false;
|
|
111
|
+
for (const ring of rings.slice(1)) {
|
|
112
|
+
const location = ringLocation(point, ring);
|
|
113
|
+
if (location === 2)
|
|
114
|
+
return true;
|
|
115
|
+
if (location === 1)
|
|
116
|
+
inHole = true;
|
|
117
|
+
}
|
|
118
|
+
return !inHole;
|
|
119
|
+
};
|
|
120
|
+
const contains = (part) => {
|
|
121
|
+
switch (part.type) {
|
|
122
|
+
case 'Polygon':
|
|
123
|
+
return polygonContains(part.coordinates);
|
|
124
|
+
case 'MultiPolygon':
|
|
125
|
+
return part.coordinates.some(polygonContains);
|
|
126
|
+
case 'GeometryCollection':
|
|
127
|
+
return part.geometries.some(contains);
|
|
128
|
+
default:
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
return geometry ? contains(geometry) : false;
|
|
133
|
+
}
|
|
@@ -2,7 +2,7 @@ import type { SFMapOptions } from './types.js';
|
|
|
2
2
|
/** Server-safe overview using exactly the guide browser geography. */
|
|
3
3
|
export declare function createGuideSVG(options?: SFMapOptions): {
|
|
4
4
|
svg: string;
|
|
5
|
-
project: (coordinates: import("
|
|
5
|
+
project: (coordinates: import("./geometry.js").Position) => [number, number];
|
|
6
6
|
viewBox: [number, number, number, number];
|
|
7
7
|
};
|
|
8
8
|
/** Stable compact frame, progressively enhanced with mountGuideMap(). No browser globals. */
|
package/dist/src/layers.js
CHANGED
|
@@ -58,7 +58,7 @@ export function markers(items, { project, colors, animated }) {
|
|
|
58
58
|
.map((marker, index) => {
|
|
59
59
|
if (marker.radius !== undefined && (!Number.isFinite(marker.radius) || marker.radius <= 0))
|
|
60
60
|
throw new RangeError('Marker radius must be positive and finite.');
|
|
61
|
-
const [x, y] = project(
|
|
61
|
+
const [x, y] = project(marker.position).map(number);
|
|
62
62
|
return `<circle data-marker-id="${escapeXml(marker.id)}" cx="${x}" cy="${y}" r="${marker.radius ?? (marker.selected ? 8 : 5)}" fill="${escapeXml(marker.color ?? (marker.selected ? colors.selected : colors.marker))}" stroke="#fff9e9" stroke-width="2" vector-effect="non-scaling-stroke"${order(animated, index)}><title>${escapeXml(marker.label ?? marker.id)}</title></circle>`;
|
|
63
63
|
})
|
|
64
64
|
.join('')}</g>`;
|
package/dist/src/map.d.ts
CHANGED
|
@@ -2,6 +2,6 @@ import type { MapController, MapOptions } from './controller-types.js';
|
|
|
2
2
|
import type { InteractiveSFMapData } from './explorer-data.js';
|
|
3
3
|
export type * from './controller-types.js';
|
|
4
4
|
export type { InteractiveSFMapData as MapData } from './explorer-data.js';
|
|
5
|
-
export type { CameraOptions, DistrictSelection, DistrictStyle, DistrictYear, InteractiveLayers, MapFeatures, MapMarker, MapOverlay, MapPadding, MapViewport, NeighborhoodSelection, } from './types.js';
|
|
5
|
+
export type { CameraOptions, DistrictSelection, DistrictStyle, DistrictYear, InteractiveLayers, MapFeatures, MapMarker, MapOverlay, MapPadding, MapViewport, NeighborhoodSelection, ProjectToScreen, ScreenPoint, } from './types.js';
|
|
6
6
|
/** No geography is imported. Supply a preset or your own immutable data. */
|
|
7
7
|
export declare function createMap(data: InteractiveSFMapData, options?: MapOptions): MapController;
|
package/dist/src/map.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { copyAppearance } from './appearance.js';
|
|
2
2
|
import { validateCameraOptions } from './camera.js';
|
|
3
|
-
import { expandMapOptions, prepareConfiguration } from './configuration.js';
|
|
3
|
+
import { configurationPatch, expandMapOptions, prepareConfiguration } from './configuration.js';
|
|
4
4
|
import { createNeighborhoodExplorerCore } from './explorer-core.js';
|
|
5
5
|
import { createMapShell } from './explorer-shell.js';
|
|
6
6
|
import { controlKeys, layerKeys } from './features.js';
|
|
@@ -144,13 +144,6 @@ export function createMap(data, options = {}) {
|
|
|
144
144
|
}, () => !destroyed && revision === configurationRevision);
|
|
145
145
|
return committed && !destroyed && revision === configurationRevision;
|
|
146
146
|
}
|
|
147
|
-
function updateView(patch, options = {}) {
|
|
148
|
-
assertOptions(options, 'view update', ['resetView']);
|
|
149
|
-
if (options.resetView !== undefined && typeof options.resetView !== 'boolean')
|
|
150
|
-
throw new TypeError('resetView must be a boolean.');
|
|
151
|
-
if (configure(patch) && options.resetView !== false)
|
|
152
|
-
element.resetView();
|
|
153
|
-
}
|
|
154
147
|
return Object.freeze({
|
|
155
148
|
element,
|
|
156
149
|
overlayElement: element.overlayElement,
|
|
@@ -159,7 +152,13 @@ export function createMap(data, options = {}) {
|
|
|
159
152
|
return destroyed;
|
|
160
153
|
},
|
|
161
154
|
configure: use((patch) => {
|
|
162
|
-
configure(patch);
|
|
155
|
+
configure(configurationPatch(patch, config));
|
|
156
|
+
}),
|
|
157
|
+
resetConfiguration: use((groups = ['features', 'layers', 'controls', 'appearance']) => {
|
|
158
|
+
if (!Array.isArray(groups) ||
|
|
159
|
+
groups.some((key) => !['features', 'layers', 'controls', 'appearance'].includes(key)))
|
|
160
|
+
throw new TypeError('Unknown configuration reset group.');
|
|
161
|
+
configure(Object.fromEntries(groups.map((key) => [key, undefined])));
|
|
163
162
|
}),
|
|
164
163
|
getConfiguration: use(snapshot),
|
|
165
164
|
getResolvedConfiguration: use(resolved),
|
|
@@ -231,7 +230,14 @@ export function createMap(data, options = {}) {
|
|
|
231
230
|
}),
|
|
232
231
|
selectMarker: use(element.selectMarker),
|
|
233
232
|
getSelectedMarker: use(element.getSelectedMarker),
|
|
234
|
-
selectNeighborhood: use(
|
|
233
|
+
selectNeighborhood: use((id, options) => {
|
|
234
|
+
const source = element.getMapState().source;
|
|
235
|
+
if (id !== null && (typeof id !== 'string' || !id))
|
|
236
|
+
throw new TypeError('Neighborhood IDs must be nonempty strings or null.');
|
|
237
|
+
if (id !== null && !data.neighborhoods[source]?.features.some((feature) => feature.id === id))
|
|
238
|
+
return false;
|
|
239
|
+
return element.selectNeighborhood(id, options);
|
|
240
|
+
}),
|
|
235
241
|
getSelectedNeighborhood: use(element.getSelection),
|
|
236
242
|
selectDistrict: use(element.selectDistrict),
|
|
237
243
|
getSelectedDistrict: use(element.getSelectedDistrict),
|
|
@@ -242,8 +248,12 @@ export function createMap(data, options = {}) {
|
|
|
242
248
|
setDistrictStyle: use((style) => {
|
|
243
249
|
configure({ appearance: { districtStyle: style } });
|
|
244
250
|
}),
|
|
245
|
-
setSource: use((source
|
|
246
|
-
|
|
251
|
+
setSource: use((source) => {
|
|
252
|
+
configure({ source });
|
|
253
|
+
}),
|
|
254
|
+
setMode: use((mode) => {
|
|
255
|
+
configure({ mode });
|
|
256
|
+
}),
|
|
247
257
|
setLabels: use((labels) => {
|
|
248
258
|
configure({ labels });
|
|
249
259
|
}),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { createMarkerLayer, MarkerItem } from './marker-layer.js';
|
|
2
2
|
import type { MapMarker } from './types.js';
|
|
3
3
|
/** Own ordered records and picker reconciliation; prepare before committing DOM changes. */
|
|
4
|
-
export declare function createMarkerCatalog(renderer: ReturnType<typeof createMarkerLayer>, picker: HTMLSelectElement, project: (point:
|
|
4
|
+
export declare function createMarkerCatalog(renderer: ReturnType<typeof createMarkerLayer>, picker: HTMLSelectElement, project: (point: import('../data/types.js').Position) => [number, number]): {
|
|
5
5
|
readonly items: MarkerItem[];
|
|
6
6
|
prepare(markers: readonly MapMarker[], selected: string | null): {
|
|
7
7
|
selection: string | null;
|
|
@@ -14,19 +14,25 @@ export function createMarkerCatalog(renderer, picker, project) {
|
|
|
14
14
|
const next = markers.map((marker) => {
|
|
15
15
|
ids.add(marker.id);
|
|
16
16
|
const previous = existing.get(marker.id);
|
|
17
|
-
const point = previous?.marker.
|
|
17
|
+
const point = previous?.marker.position[0] === marker.position[0] &&
|
|
18
|
+
previous.marker.position[1] === marker.position[1]
|
|
18
19
|
? previous.point
|
|
19
|
-
: project(
|
|
20
|
-
return {
|
|
20
|
+
: project(marker.position);
|
|
21
|
+
return {
|
|
22
|
+
marker: { ...marker, position: [...marker.position] },
|
|
23
|
+
point,
|
|
24
|
+
};
|
|
21
25
|
});
|
|
22
26
|
const selection = selected && ids.has(selected)
|
|
23
27
|
? selected
|
|
24
28
|
: (markers.find((marker) => marker.selected)?.id ?? null);
|
|
25
|
-
const keys = ['id', '
|
|
29
|
+
const keys = ['id', 'label', 'selected', 'color', 'radius'];
|
|
26
30
|
if (picker.options.length > 0 &&
|
|
27
31
|
selection === selected &&
|
|
28
32
|
next.length === items.length &&
|
|
29
|
-
next.every(({ marker }, index) =>
|
|
33
|
+
next.every(({ marker }, index) => marker.position.length === items[index].marker.position.length &&
|
|
34
|
+
marker.position.every((value, offset) => value === items[index].marker.position[offset]) &&
|
|
35
|
+
keys.every((key) => marker[key] === items[index].marker[key])))
|
|
30
36
|
return undefined;
|
|
31
37
|
const focused = items.find((item) => item.visual?.node === document.activeElement)?.marker.id;
|
|
32
38
|
return {
|
|
@@ -57,7 +57,7 @@ export function staticLabels(data, options, project) {
|
|
|
57
57
|
selected: marker
|
|
58
58
|
? [
|
|
59
59
|
{
|
|
60
|
-
point: point(
|
|
60
|
+
point: point(marker.position),
|
|
61
61
|
name: marker.label ?? marker.id,
|
|
62
62
|
kind: 'selected-marker',
|
|
63
63
|
},
|
|
@@ -96,7 +96,7 @@ export function staticLabels(data, options, project) {
|
|
|
96
96
|
})
|
|
97
97
|
: []),
|
|
98
98
|
...markers.map((item) => {
|
|
99
|
-
const [x, y] = point(
|
|
99
|
+
const [x, y] = point(item.position);
|
|
100
100
|
// Also cover the legacy SVG marker when it is larger than its interactive counterpart.
|
|
101
101
|
const radius = Math.max(item.radius ?? 6, (item.radius ?? (item.selected ? 8 : 5)) / unit) + 3;
|
|
102
102
|
return [x - radius, y - radius, x + radius, y + radius];
|
|
@@ -109,7 +109,7 @@ export function staticLabels(data, options, project) {
|
|
|
109
109
|
textWidth: estimateLabelWidth(item.name, item.fontSize),
|
|
110
110
|
textHeight: item.fontSize * 1.25,
|
|
111
111
|
})), width, height, obstacles);
|
|
112
|
-
return `<g data-layer="overview-labels" font-family="system-ui,sans-serif">${spreadLabels(placed, options.labelLayout?.maxLabels, markers.map((marker) => point(
|
|
112
|
+
return `<g data-layer="overview-labels" font-family="system-ui,sans-serif">${spreadLabels(placed, options.labelLayout?.maxLabels, markers.map((marker) => point(marker.position)), width, height)
|
|
113
113
|
.map((item) => `<text data-label-kind="${escapeXml(item.kind)}" text-anchor="middle" x="${number((item.left + item.textWidth / 2) * unit)}" y="${number((item.top + item.fontSize) * unit)}" font-size="${number(item.fontSize * unit)}" font-weight="${item.fontWeight}" fill="${escapeXml(item.fill)}" stroke="${escapeXml(item.halo)}" stroke-width="${number(3 * unit)}" stroke-linejoin="round" paint-order="stroke">${escapeXml(item.name)}</text>`)
|
|
114
114
|
.join('')}</g>`;
|
|
115
115
|
}
|
package/dist/src/static.d.ts
CHANGED
|
@@ -8,14 +8,14 @@ export type { DistrictStyle, DistrictYear, SFMapOptions as StaticMapOptions, Sta
|
|
|
8
8
|
/** Server-safe SVG rendering with explicit data; returns SVG plus projection helpers. */
|
|
9
9
|
export declare function renderMap(data: StaticMapInput, options?: SFMapOptions): {
|
|
10
10
|
svg: string;
|
|
11
|
-
project: (coordinates: import("
|
|
11
|
+
project: (coordinates: import("./geometry.js").Position) => [number, number];
|
|
12
12
|
viewBox: [number, number, number, number];
|
|
13
13
|
};
|
|
14
14
|
/** Project canonical layer geometry without constructing an SVG string. */
|
|
15
15
|
export declare function getLayerPaths(data: StaticMapInput, options?: SFMapOptions): {
|
|
16
16
|
year: import("./types.js").DistrictYear;
|
|
17
17
|
viewBox: [number, number, number, number];
|
|
18
|
-
project: (coordinates: import("
|
|
18
|
+
project: (coordinates: import("./geometry.js").Position) => [number, number];
|
|
19
19
|
coast: string;
|
|
20
20
|
districts: {
|
|
21
21
|
id: number;
|
package/dist/src/types.d.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import type { Geometry, NeighborhoodFeature, NeighborhoodSource } from '../data/types.js';
|
|
1
|
+
import type { Geometry, NeighborhoodFeature, NeighborhoodSource, Position } from '../data/types.js';
|
|
2
|
+
/** Canvas-relative CSS pixels; visibility means inside the current square map viewport. */
|
|
3
|
+
export interface ScreenPoint {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
visible: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** WGS84 longitude/latitude; altitude, if supplied in a position, is ignored. */
|
|
9
|
+
export type ProjectToScreen = (position: Position) => ScreenPoint;
|
|
2
10
|
export type DistrictYear = 2002 | 2012 | 2022;
|
|
3
11
|
export interface DistrictSelection {
|
|
4
12
|
id: number;
|
|
@@ -12,8 +20,7 @@ export interface DistrictStyle {
|
|
|
12
20
|
}
|
|
13
21
|
export interface MapMarker {
|
|
14
22
|
id: string;
|
|
15
|
-
|
|
16
|
-
lat: number;
|
|
23
|
+
position: Position;
|
|
17
24
|
label?: string;
|
|
18
25
|
selected?: boolean;
|
|
19
26
|
color?: string;
|
|
@@ -220,11 +227,7 @@ export interface CameraOptions {
|
|
|
220
227
|
export interface NeighborhoodExplorerElement extends HTMLElement {
|
|
221
228
|
/** Append positioned HTML children here; coordinates are relative to this layer. */
|
|
222
229
|
readonly overlayElement: HTMLDivElement;
|
|
223
|
-
projectToScreen
|
|
224
|
-
x: number;
|
|
225
|
-
y: number;
|
|
226
|
-
visible: boolean;
|
|
227
|
-
};
|
|
230
|
+
projectToScreen: ProjectToScreen;
|
|
228
231
|
stopAnimation(): void;
|
|
229
232
|
/** Atomic patch; false disables, undefined resets a feature to its default. */
|
|
230
233
|
setFeatures(patch: MapFeatures): void;
|
|
@@ -268,4 +271,4 @@ export interface NeighborhoodExplorerElement extends HTMLElement {
|
|
|
268
271
|
export interface TransitAnimationElement extends HTMLElement {
|
|
269
272
|
destroy(): void;
|
|
270
273
|
}
|
|
271
|
-
export type { MapAppearance, MapCamera, MapCapabilities, MapConfiguration, MapConfigurationSnapshot, MapController, MapControls, MapEvents, MapFeatureReference, MapOptions, MapSelectionChange,
|
|
274
|
+
export type { MapAppearance, MapCamera, MapCapabilities, MapConfiguration, MapConfigurationGroup, MapConfigurationSnapshot, MapController, MapControls, MapEvents, MapFeatureReference, MapOptions, MapSelectionChange, ResolvedMapConfiguration, } from './controller-types.js';
|
package/dist/src/validation.js
CHANGED
|
@@ -155,14 +155,16 @@ export function validateMarkers(markers) {
|
|
|
155
155
|
throw new TypeError('Markers must be an array.');
|
|
156
156
|
const ids = new Set();
|
|
157
157
|
for (const marker of markers) {
|
|
158
|
-
assertOptions(marker, 'marker');
|
|
158
|
+
assertOptions(marker, 'marker', ['id', 'position', 'label', 'selected', 'color', 'radius']);
|
|
159
159
|
if (typeof marker.id !== 'string' || !marker.id || ids.has(marker.id))
|
|
160
160
|
throw new RangeError('Markers require unique nonempty IDs.');
|
|
161
161
|
ids.add(marker.id);
|
|
162
|
-
if (!
|
|
163
|
-
|
|
164
|
-
!Number.isFinite(marker.
|
|
165
|
-
Math.abs(marker.
|
|
162
|
+
if (!Array.isArray(marker.position) ||
|
|
163
|
+
marker.position.length < 2 ||
|
|
164
|
+
!Number.isFinite(marker.position[0]) ||
|
|
165
|
+
Math.abs(marker.position[0]) > 180 ||
|
|
166
|
+
!Number.isFinite(marker.position[1]) ||
|
|
167
|
+
Math.abs(marker.position[1]) >= 90)
|
|
166
168
|
throw new RangeError('Marker coordinates require finite longitude from -180 to 180 and latitude strictly between -90 and 90.');
|
|
167
169
|
if (marker.radius !== undefined && (!Number.isFinite(marker.radius) || marker.radius <= 0))
|
|
168
170
|
throw new RangeError('Marker radius must be positive and finite.');
|