@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 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.
@@ -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.lng, marker.lat);
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
- /** Patch properties can explicitly remove overrides, including with exactOptionalPropertyTypes. */
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 keys retain values. Undefined presentation groups reset; undefined mode/source/year/labels retain their current value. */
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
- /** Convenience setters retain their historic reset default; false preserves the camera. */
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(name: string | null, options?: CameraOptions & {
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, options?: MapViewUpdateOptions): void;
168
- setMode(mode: NonNullable<MapOptions['mode']>, options?: MapViewUpdateOptions): void;
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
- projectToScreen(lng: number, lat: number): {
172
- x: number;
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(lng, lat) {
144
- const [x, y] = map.project([lng, lat]);
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) => ({ ...item.marker }));
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) => [marker.lng, marker.lat]),
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)
@@ -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;
@@ -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("../data/types.js").Position) => [number, number];
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. */
@@ -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([marker.lng, marker.lat]).map(number);
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(element.selectNeighborhood),
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, options) => updateView({ source }, options)),
246
- setMode: use((mode, options) => updateView({ mode }, options)),
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: [number, number]) => [number, number]): {
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.lng === marker.lng && previous.marker.lat === marker.lat
17
+ const point = previous?.marker.position[0] === marker.position[0] &&
18
+ previous.marker.position[1] === marker.position[1]
18
19
  ? previous.point
19
- : project([marker.lng, marker.lat]);
20
- return { marker: { ...marker }, point };
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', 'lng', 'lat', 'label', 'selected', 'color', 'radius'];
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) => keys.every((key) => marker[key] === items[index].marker[key])))
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([marker.lng, marker.lat]),
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([item.lng, item.lat]);
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([marker.lng, marker.lat])), width, height)
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
  }
@@ -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("../data/types.js").Position) => [number, number];
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("../data/types.js").Position) => [number, number];
18
+ project: (coordinates: import("./geometry.js").Position) => [number, number];
19
19
  coast: string;
20
20
  districts: {
21
21
  id: number;
@@ -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
- lng: number;
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(lng: number, lat: number): {
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, MapViewUpdateOptions, ResolvedMapConfiguration, } from './controller-types.js';
274
+ export type { MapAppearance, MapCamera, MapCapabilities, MapConfiguration, MapConfigurationGroup, MapConfigurationSnapshot, MapController, MapControls, MapEvents, MapFeatureReference, MapOptions, MapSelectionChange, ResolvedMapConfiguration, } from './controller-types.js';
@@ -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 (!Number.isFinite(marker.lng) ||
163
- Math.abs(marker.lng) > 180 ||
164
- !Number.isFinite(marker.lat) ||
165
- Math.abs(marker.lat) >= 90)
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.');