@kahwee/sf-map-svg 4.5.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +49 -2
- package/SOURCES.md +53 -0
- package/dist/data/README.md +7 -0
- package/dist/data/index.d.ts +1 -0
- package/dist/data/index.js +1 -0
- package/dist/data/precincts-2022.json +1 -0
- package/dist/data/precincts.d.ts +2 -0
- package/dist/data/precincts.js +5 -0
- package/dist/data/types.d.ts +5 -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 +436 -54
- package/llms.txt +2 -1
- package/package.json +38 -24
package/dist/data/types.d.ts
CHANGED
|
@@ -93,6 +93,11 @@ export interface DistrictProperties {
|
|
|
93
93
|
readonly labelPoints: readonly Position[];
|
|
94
94
|
readonly displayExtras: Geometry | null;
|
|
95
95
|
}
|
|
96
|
+
export interface PrecinctProperties {
|
|
97
|
+
/** Four-digit Department of Elections precinct number, such as "1101". */
|
|
98
|
+
readonly precinct: string;
|
|
99
|
+
readonly year: 2022;
|
|
100
|
+
}
|
|
96
101
|
export interface Catalog {
|
|
97
102
|
readonly schemaVersion: 1;
|
|
98
103
|
readonly description: string;
|
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.');
|
package/docs/API.md
CHANGED
|
@@ -4,6 +4,41 @@ Start with the [static and interactive examples](../README.md). Exact option
|
|
|
4
4
|
and event types are exported by [src/api.ts](../src/api.ts) and defined in
|
|
5
5
|
[src/types.ts](../src/types.ts) and [src/controller-types.ts](../src/controller-types.ts).
|
|
6
6
|
|
|
7
|
+
## Geographic queries
|
|
8
|
+
|
|
9
|
+
New in v5. See the [migration guide](migration-v5.md) for upgrade steps.
|
|
10
|
+
|
|
11
|
+
The data-free `@kahwee/sf-map-svg/geometry` entry exports `geometryBounds(geometry)`
|
|
12
|
+
and `geometryContainsPoint(geometry, [longitude, latitude])`. They work offline
|
|
13
|
+
with the same readonly GeoJSON geometries used by the renderer, without a DOM.
|
|
14
|
+
The same entry exports the `Geometry`, `Position`, and `Bounds` types, so geometry
|
|
15
|
+
consumers need no second import path for their function input and result types.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { geometryBounds, geometryContainsPoint } from '@kahwee/sf-map-svg/geometry';
|
|
19
|
+
import { neighborhoods } from '@kahwee/sf-map-svg/data/realtor';
|
|
20
|
+
|
|
21
|
+
const location = [-122.42, 37.76] as const;
|
|
22
|
+
const matches = neighborhoods.features.filter((feature) =>
|
|
23
|
+
geometryContainsPoint(feature.geometry, location),
|
|
24
|
+
);
|
|
25
|
+
const bounds = matches[0] ? geometryBounds(matches[0].geometry) : null;
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Bounds are `[west, south, east, north]` in degrees, computed from coordinates,
|
|
29
|
+
not cached feature metadata. Empty or absent geometry returns `null`.
|
|
30
|
+
Hit testing includes Polygon, MultiPolygon, and polygon members of nested
|
|
31
|
+
GeometryCollections. Hole interiors are excluded; outer and hole boundaries
|
|
32
|
+
are included, so a shared boundary may match multiple features. Ring winding
|
|
33
|
+
does not matter; open rings are closed implicitly. Other geometry types do
|
|
34
|
+
not produce polygon hits. Empty or absent geometry returns `false`.
|
|
35
|
+
|
|
36
|
+
Both helpers reject nonfinite or out-of-range longitude/latitude with
|
|
37
|
+
`RangeError`; altitude is ignored. They assume valid planar GeoJSON topology
|
|
38
|
+
and straight segments in longitude/latitude, suitable for these SF datasets.
|
|
39
|
+
They do not repair polygons, apply a boundary tolerance, or unwrap antimeridian
|
|
40
|
+
crossings. A bounds match alone does not establish polygon containment.
|
|
41
|
+
|
|
7
42
|
## Static maps
|
|
8
43
|
|
|
9
44
|
Static maps accept compact `StaticMapData` or the same source-aware `MapData` as interactive maps. With `MapData`, `source` selects a supplied neighborhood definition; SFAR realtor is preferred by default, followed by SF Find and analysis when available. A requested unavailable source throws before styling callbacks run. Compact `StaticMapData` already chooses its neighborhoods, so it rejects a `source` option. Both data shapes retain the static renderer defaults.
|
|
@@ -85,16 +120,16 @@ District style callbacks are evaluated once per district at construction and on
|
|
|
85
120
|
|
|
86
121
|
## Live configuration and inspection
|
|
87
122
|
|
|
88
|
-
`map.configure({ appearance, mode, source, year, labels, layers, features, controls })` updates an existing map. Appearance updates preserve the camera, selections, retained marker nodes, and keyboard focus. Mode/source changes through `configure
|
|
123
|
+
`map.configure({ appearance, mode, source, year, labels, layers, features, controls })` updates an existing map. Appearance updates preserve the camera, selections, retained marker nodes, and keyboard focus. Mode/source changes through `configure`, `setMode(mode)`, and `setSource(source)` preserve the camera. Use `map.camera.reset()` or `map.camera.fit(geometry)` when movement is intended. The former `{ resetView }` setter argument is removed in v5. Switching neighborhood source clears the neighborhood selection. Changing district year retains a selected district when it exists in the new year. Unsupported datasets, invalid appearance values, and invalid district-style callback results fail during preparation before commit. A reentrant styling callback can supersede the pending patch.
|
|
89
124
|
|
|
90
|
-
|
|
125
|
+
Controller patches merge nested token and feature records. Omitted and `undefined` values retain current values at every depth; `false` disables boolean switches. Use `resetConfiguration(groups?)` to remove presentation overrides explicitly. The supported groups are `features`, `layers`, `controls`, and `appearance`; omitting the argument resets all four. Resets restore built-in defaults, preserve mode/source/year/labels and camera, and do not restore constructor overrides. Unknown groups fail before changing state. Static construction options and low-level element methods retain their separately documented contracts.
|
|
91
126
|
|
|
92
127
|
```ts
|
|
93
128
|
map.configure({
|
|
94
129
|
appearance: { colors: { water: '#142d45' }, labelStyle: { fontFamily: 'Georgia,serif' } },
|
|
95
130
|
layers: { bartStations: false },
|
|
96
131
|
});
|
|
97
|
-
map.
|
|
132
|
+
map.resetConfiguration(['appearance']); // restore built-in defaults
|
|
98
133
|
|
|
99
134
|
const overrides = map.getConfiguration();
|
|
100
135
|
const effective = map.getResolvedConfiguration();
|
|
@@ -126,11 +161,14 @@ map.selectFeature({ kind: 'neighborhood', source: 'realtor', id: 'inner-mission'
|
|
|
126
161
|
map.selectFeature({ kind: 'district', year: 2022, id: 3 });
|
|
127
162
|
```
|
|
128
163
|
|
|
129
|
-
Neighborhood IDs are exact source-scoped feature IDs, rather than display names or aliases. District identity includes the boundary year. This operation returns `false` for a missing feature or a source/year other than the map's current supplied geography; it never silently switches definitions or years. Choose geography through `configure` first. An `id` of `null` clears that kind in the referenced current geography. Malformed references throw.
|
|
164
|
+
Neighborhood IDs are exact source-scoped feature IDs, rather than display names or aliases. District identity includes the boundary year. This operation returns `false` for a missing feature or a source/year other than the map's current supplied geography; it never silently switches definitions or years. Choose geography through `configure` first. An `id` of `null` clears that kind in the referenced current geography. Malformed references throw. The `selectMarker`, `selectNeighborhood`, and `selectDistrict` shortcuts select exact IDs in the current geography. Controller `selectNeighborhood` no longer resolves display names or aliases; resolve those with a source-specific data lookup first.
|
|
130
165
|
|
|
131
166
|
## Coordinates and units
|
|
132
167
|
|
|
133
|
-
|
|
168
|
+
Version 5 uses tuple inputs and exports the named `ScreenPoint` /
|
|
169
|
+
`ProjectToScreen` types. The former two-number call is removed.
|
|
170
|
+
|
|
171
|
+
GeoJSON and static projection inputs use `[longitude, latitude]` in WGS84; markers use `{ position: [longitude, latitude] }`. Static `project` returns SVG units. `projectToScreen([longitude, latitude])` accepts the same readonly GeoJSON position as static `project` and returns `ScreenPoint` (`{ x, y, visible }`) in CSS pixels relative to the mounted canvas. Altitude is ignored. Mount the map before calling; invalid Mercator coordinates throw, and calls after controller destruction throw. `visible` indicates viewport inclusion, not whether another element covers the point. Camera viewports use the fixed 800 × 800 projected map space, independent of canvas size. Pan deltas use screen pixels, converted using the mounted canvas width. Marker radius uses screen pixels in browser maps and SVG units in static output.
|
|
134
172
|
|
|
135
173
|
Explore presentation options in the [live map design playground](https://kahwee.github.io/sf-map-svg/playground.html). The page identifies its package version; released builds use compatible options for that installed version.
|
|
136
174
|
|