@kahwee/sf-map-svg 1.5.1 → 2.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +146 -6
  3. package/dist/data/README.md +5 -1
  4. package/dist/data/types.d.ts +4 -1
  5. package/dist/src/api.d.ts +3 -0
  6. package/dist/src/api.js +3 -0
  7. package/dist/src/camera.d.ts +16 -0
  8. package/dist/src/camera.js +55 -0
  9. package/dist/src/clusters.d.ts +7 -0
  10. package/dist/src/clusters.js +33 -0
  11. package/dist/src/configuration.d.ts +5 -0
  12. package/dist/src/configuration.js +73 -0
  13. package/dist/src/controller-types.d.ts +84 -0
  14. package/dist/src/controller-types.js +1 -0
  15. package/dist/src/explorer-core.d.ts +1 -1
  16. package/dist/src/explorer-core.js +570 -145
  17. package/dist/src/features.d.ts +24 -0
  18. package/dist/src/features.js +99 -0
  19. package/dist/src/guide-data.js +3 -2
  20. package/dist/src/guide-detailed.js +3 -2
  21. package/dist/src/guide-map.d.ts +4 -0
  22. package/dist/src/guide-map.js +35 -10
  23. package/dist/src/guide-shell.d.ts +2 -0
  24. package/dist/src/guide-shell.js +16 -0
  25. package/dist/src/guide-static.d.ts +9 -0
  26. package/dist/src/guide-static.js +26 -0
  27. package/dist/src/guide.d.ts +2 -1
  28. package/dist/src/guide.js +1 -1
  29. package/dist/src/interactive-data.d.ts +1 -1
  30. package/dist/src/interactive-data.js +2 -0
  31. package/dist/src/interactive.d.ts +1 -1
  32. package/dist/src/interactive.js +2 -0
  33. package/dist/src/layers.js +3 -1
  34. package/dist/src/map-core.js +3 -8
  35. package/dist/src/map.d.ts +7 -0
  36. package/dist/src/map.js +106 -0
  37. package/dist/src/presets.d.ts +3 -0
  38. package/dist/src/presets.js +17 -0
  39. package/dist/src/static.d.ts +10 -0
  40. package/dist/src/static.js +5 -0
  41. package/dist/src/types.d.ts +73 -9
  42. package/dist/src/validation.d.ts +7 -0
  43. package/dist/src/validation.js +226 -0
  44. package/dist/src/viewport.js +9 -2
  45. package/docs/EXAMPLES.md +11 -1
  46. package/docs/api-audit.md +80 -0
  47. package/docs/consumer-integration.md +165 -0
  48. package/docs/guide-bundle-report.md +19 -0
  49. package/docs/migration-v2.md +238 -0
  50. package/package.json +40 -10
package/CHANGELOG.md CHANGED
@@ -4,6 +4,51 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 2.0.0 — 2026-09-27
8
+
9
+ Version 2 makes geography an explicit dependency and separates the application API
10
+ from its DOM element. This keeps small consumers small, gives configuration one
11
+ consistent home, and makes animation and subscription ownership predictable.
12
+
13
+ **Migration:** [Complete v1 → v2 guide, with before/after examples](https://github.com/kahwee/sf-map-svg/blob/v2.0.0/docs/migration-v2.md).
14
+ **API:** [README](https://github.com/kahwee/sf-map-svg/blob/v2.0.0/README.md).
15
+ **Consumer performance:** [Integration guide](https://github.com/kahwee/sf-map-svg/blob/v2.0.0/docs/consumer-integration.md).
16
+
17
+ ### Breaking changes
18
+
19
+ - The root export now provides data-free `createMap(data, options)` and `renderMap(data, options)`. Move old `createSFMap`, `renderSFMap`, `neighborhoodNames`, `districtColors`, `districtYears`, and legacy static type imports to `/legacy` for an incremental migration. Existing named subpaths retain their compatibility APIs.
20
+ - `createMap` returns a controller: mount `map.element`. Features belong in `features`, styling in `appearance`; flat spellings are rejected. Use `configure({ features, layers, controls })`, `camera.*`, and typed `on()` subscriptions. Appearance is construction-only.
21
+ - The controller throws on operations after disposal; `destroy()` and unsubscribe remain idempotent. Configuration rejects unknown/malformed keys and invalid ranges. Marker/overlay IDs must be unique. Shared guide geography is immutable; clone before deriving custom data.
22
+
23
+ ### Added
24
+
25
+ - Data-free `/map` and `/static` entrypoints, configuration-only `/presets`, detached configuration snapshots, and all-groups validation before configuration updates. Camera pan, zoom, reset, fit and set accept consistent animation options.
26
+ - Palette, label font/weight/halo, selected/hover neighborhood styling, custom legend entries, compact expandable attribution, and independent visible/accessibility touch labels.
27
+ - Opt-in eased camera motion and staggered marker entrances, respecting reduced motion, interruption, preference changes, and disposal.
28
+ - Per-pin radii, selected rings, deterministic screen-space clustering and accessible marker choice, HTML overlay placement, screen projection, north arrow and approximate scale bar.
29
+ - Server-safe overview `createGuideSVG` / `createGuideShell` and compact `mountGuideMap`, using matching simplified geography and reserved layout rows.
30
+ - Coastline-only basemaps without neighborhood datasets. SFAR remains the default when supplied; alternative-only data selects the available source.
31
+ - Full migration documentation, public examples, adversarial API contract matrix, production import-graph/size budgets, and automatic verification that GitHub release notes contain the complete changelog. The npm archive now includes CHANGELOG.md.
32
+
33
+ ### Fixed and hardened
34
+
35
+ - Reentrant camera and selection callbacks cannot revive obsolete animation or overwrite a newer selection. Failed source, marker, overlay, feature, layer and control updates preserve existing state.
36
+ - Runtime controls remain independent, feature toggles preserve camera/selection, and unrelated configuration updates no longer interrupt motion. Hiding touch controls returns gestures to the page.
37
+ - Revoke obsolete cluster handlers immediately when markers, selection or clustering settings change; v2 overlay activation is wired to typed events for mouse and keyboard users.
38
+ - Keyboard focus remains reachable through filtering and clustering; removed overlays lose listeners. Construction failures and repeated destruction clean up observers, listeners and asynchronous work.
39
+ - Detached selection/event snapshots prevent accidental mutation of renderer state. Malformed/sparse viewports, coordinates, padding, overlay geometry and misspelled options are rejected. Corrected public `LineString` overlay typing.
40
+ - Root static imports tree-shake away browser code and all geographic JSON. Renderer, camera, clustering, configuration and validation have separate internal boundaries; rendering remains offline with zero runtime dependencies.
41
+
42
+ ### Validation and bundle guidance
43
+
44
+ - Covered by Node regression tests, Chromium Storybook interactions/coverage, declaration tests, clean packed-consumer installation, and desktop/390px browser inspection. Required checks include demo, Storybook and Pages builds.
45
+ - Representative production gzip measurements: v2 root **22.8 KiB without geography**; static-only root import **4.5 KiB**; compatibility guide including overview geography **110.2 KiB**. Consumer output varies. Runtime switches do not remove imported code or data; use narrow entrypoints, explicit datasets and lazy detail loading.
46
+
47
+ ## 1.5.2 — 2026-09-26
48
+
49
+ - Run every Storybook story as a Chromium/Vitest browser check and publish an LCOV and JSON coverage artifact for the renderer, with baseline regression thresholds.
50
+ - Require the browser and coverage check before npm publishing or Pages deployment.
51
+
7
52
  ## 1.5.1 — 2026-09-26
8
53
 
9
54
  - Run focused Storybook 10 browser interactions and accessibility checks in CI and before npm publishing, covering overlapping markers, route overlays, and keyboard selection.
package/README.md CHANGED
@@ -14,7 +14,7 @@ Self-contained SVG maps of San Francisco, with precise coastlines, soft district
14
14
  | --- | --- | --- |
15
15
  | Explore real election data | [California propositions by SF district](https://kahwee.github.io/sf-map-svg/propositions.html) or [local measures](https://kahwee.github.io/sf-map-svg/measures.html) | `custom-map` |
16
16
  | Make a small interactive city map | [Neighborhood guide](https://kahwee.github.io/sf-map-svg/#explore-more-title) | `/guide` |
17
- | Render a static or custom SVG | [Code recipes](docs/EXAMPLES.md) | Root or `/custom-map` |
17
+ | Render a static or custom SVG | [Code recipes](docs/EXAMPLES.md) | `/static` or `/custom-map` |
18
18
  | Animate a route | [BART journey](https://kahwee.github.io/sf-map-svg/transit.html) | `/transit` or overlays |
19
19
 
20
20
  ## Install
@@ -27,7 +27,9 @@ Use Node 22.12+ for server-side rendering. Browser components need a DOM and a b
27
27
 
28
28
  | Start with | Entry point | What you get |
29
29
  | --- | --- | --- |
30
- | Static SVG | `@kahwee/sf-map-svg` | SVG markup, projection helpers, optional layers |
30
+ | v2 controller | `@kahwee/sf-map-svg` or `/map` | Explicit data, grouped options, managed events and camera |
31
+ | v2 static SVG | `@kahwee/sf-map-svg/static` | Server-safe rendering with explicit data |
32
+ | Compatibility static SVG | `@kahwee/sf-map-svg/legacy` | Original renderer with bundled geography |
31
33
  | Data-injected SVG | `@kahwee/sf-map-svg/custom-map` | Tree-shakeable renderer core with only the geographic data you provide |
32
34
  | Neighborhood explorer | `@kahwee/sf-map-svg/explorer` | Search, source selection, map controls, GeoJSON downloads |
33
35
  | Interactive map | `@kahwee/sf-map-svg/interactive` | Embeddable map and controls without the explorer sidebar |
@@ -38,11 +40,44 @@ Use Node 22.12+ for server-side rendering. Browser components need a DOM and a b
38
40
  | Metadata search | `@kahwee/sf-map-svg/data/catalog` | Search names without polygon geometry |
39
41
  | SFAR lookup | `@kahwee/sf-map-svg/data/realtor` | Default neighborhoods without alternative sources |
40
42
 
43
+ ## Version 2: explicit data and a controller
44
+
45
+ ```js
46
+ import { createMap } from '@kahwee/sf-map-svg';
47
+ import { guideMapData } from '@kahwee/sf-map-svg/guide/data';
48
+ import { guideOptions } from '@kahwee/sf-map-svg/presets';
49
+
50
+ const map = createMap(guideMapData, {
51
+ ...guideOptions,
52
+ features: { motion: true, markerEntrance: true, clustering: true },
53
+ appearance: { colors: { water: '#e6f1f5' } },
54
+ });
55
+ document.querySelector('#map').append(map.element);
56
+ map.configure({ features: { motion: false }, controls: { pan: false } });
57
+ const unsubscribe = map.on('markerchange', ({ marker }) => console.log(marker?.id));
58
+ map.camera.reset({ animate: false });
59
+ // On component disposal: map.destroy();
60
+ ```
61
+
62
+ The root and `/map` include no geography; `/static` includes no interactive runtime.
63
+ `guide/data` supplies only overview geography; `/presets` supplies configuration only.
64
+ Camera methods share `{ animate, duration }`. `configure()` accepts the same feature,
65
+ layer and control groups as construction and validates the entire patch before applying
66
+ it. Appearance is construction-only. The controller's `on()` returns an unsubscribe
67
+ function and disposal removes all its subscriptions. Operations after disposal throw;
68
+ `destroy()` itself is idempotent.
69
+
70
+ `renderMap(data, options)` from `/static` returns `{ svg, project, ... }` and needs only
71
+ `StaticMapData` (for example, `guideMapData.map`). Static options retain their SVG-unit
72
+ semantics; camera/motion/control options belong exclusively to the browser controller.
73
+ See [v2 migration and architecture](docs/migration-v2.md) for breaking changes,
74
+ configuration resets, and bundle boundaries. Existing subpaths remain compatibility APIs.
75
+
41
76
  ## Render a static map
42
77
 
43
78
  ```js
44
79
  import { writeFile } from 'node:fs/promises';
45
- import { renderSFMap } from '@kahwee/sf-map-svg';
80
+ import { renderSFMap } from '@kahwee/sf-map-svg/legacy';
46
81
 
47
82
  const svg = renderSFMap({
48
83
  landmarks: true,
@@ -61,8 +96,8 @@ That entry point does not import the package's built-in JSON collections. Its `S
61
96
  requires the coast and accepts selected district vintages plus optional neighborhood,
62
97
  road, park, and station arrays. Use the canonical JSON subpaths documented in
63
98
  [`data/README.md`](data/README.md) as source; map each feature collection to the
64
- corresponding `SFMapData` records. The root entry remains convenient and includes the
65
- built-in datasets for backward compatibility.
99
+ corresponding `SFMapData` records. The `/legacy` entry includes the built-in datasets for migration. The v2 root
100
+ imports no geographic JSON.
66
101
 
67
102
  In tree-shaking bundlers, importing a single symbol from `/data` retains only the
68
103
  modules that symbol uses.
@@ -383,10 +418,11 @@ pnpm check # formatting, data catalog, types, and tests
383
418
  pnpm demo # generated SVGs and example pages
384
419
  pnpm build-storybook # static component documentation
385
420
  pnpm test:stories # Storybook 10 browser checks in Chromium
421
+ pnpm test:stories:coverage # Chromium checks plus renderer coverage report
386
422
  pnpm test:package # install and check the packed package
387
423
  ```
388
424
 
389
- Use `pnpm format` to apply Biome formatting and safe lint fixes. Install Chromium once with `pnpm exec playwright install chromium` before local Storybook tests. Run `pnpm storybook` for interactive component examples at http://127.0.0.1:6006. CI runs package checks on Node 22, 24, and 26 plus Chromium Storybook checks on Node 24. See [CONTRIBUTING.md](CONTRIBUTING.md) for source structure and release instructions.
425
+ Use `pnpm format` to apply Biome formatting and safe lint fixes. Install Chromium once with `pnpm exec playwright install chromium` before local Storybook tests. Run `pnpm storybook` for interactive component examples at http://127.0.0.1:6006. The coverage command writes `coverage/storybook/coverage-summary.json` and `lcov.info` for `src/` TypeScript only. CI runs package and Chromium Storybook checks on Node 26 and uploads the coverage report. See [CONTRIBUTING.md](CONTRIBUTING.md) for source structure and release instructions.
390
426
 
391
427
  ### Browser verification
392
428
 
@@ -448,3 +484,107 @@ Embed the Pages demo with `<iframe src="https://kahwee.github.io/sf-map-svg/tran
448
484
  ## Ballot measures explorer
449
485
 
450
486
  [Explore local ballot measures](https://kahwee.github.io/sf-map-svg/measures.html): 44 measures from the November 2002, November 2012, November 2022, and June 2026 elections, across all three supported district map vintages. Pick an election, search its measures, inspect a district, compare two measures, and download an SVG, CSV, or the sourced JSON. The page includes touch pan/zoom, keyboard district selection, a sortable district table, and shareable year-specific views. Each election's results and district map load on demand. This is an archive of four elections, not every intervening election or a live results service. See [the election data schema](data/elections/README.md) and [SOURCES.md](SOURCES.md) for methods and source links.
487
+
488
+ ### Motion, styling, and progressive embeds
489
+
490
+ The guide now exposes the same `colors` palette as the static renderer. Existing
491
+ appearance and immediate camera movement remain the defaults. See
492
+ [consumer integration recommendations](docs/consumer-integration.md) for the
493
+ complete example, bundle choices, progressive shell, and testing contract.
494
+
495
+ ```js
496
+ import { createGuideMap } from '@kahwee/sf-map-svg/guide/map';
497
+
498
+ const map = createGuideMap({
499
+ colors: { water: '#202d38', land: '#34434a', park: '#42624d',
500
+ road: '#728080', neighborhood: '#64767e', label: '#f1f3ee' },
501
+ labelStyle: { fontFamily: 'DM Sans, system-ui, sans-serif', fontWeight: 550,
502
+ haloColor: '#34434a' },
503
+ motion: { duration: 400 },
504
+ markerEntrance: { duration: 450, stagger: 35 },
505
+ selectedMarkerRing: { color: '#f1f3ee', width: 2, gap: 3 },
506
+ clustering: { radius: 32 },
507
+ attribution: 'compact',
508
+ legend: { items: [{ label: 'Places', color: '#cf8757' }] },
509
+ northArrow: true,
510
+ scaleBar: true,
511
+ strings: { touchNavigation: 'Touch pan', touchNavigationLabel: 'Enable touch pan',
512
+ touchNavigationDone: 'Done', touchNavigationExitLabel: 'Restore page scrolling' },
513
+ });
514
+ document.querySelector('#map').append(map);
515
+ ```
516
+
517
+ | Option | Contract |
518
+ | --- | --- |
519
+ | `colors` | Static palette keys: water, land, district, neighborhood, highway, road, park, landmark, BART (`bart`), label, marker, selected |
520
+ | `labelStyle` | `fontFamily`, numeric `fontWeight`, `haloColor`; the application loads any custom font |
521
+ | `areaStyle` | `selectedFill`, `selectedStroke`, `hoverFill`, `hoverStroke`; applies to selectable neighborhoods |
522
+ | `motion` | `false` by default; `true` uses 320ms, or `{ duration }` in milliseconds |
523
+ | `markerEntrance` | `false` by default; `true` uses 420ms and 35ms stagger, or `{ duration, stagger }`; only newly introduced IDs animate, delay capped at 1s |
524
+ | `MapMarker.radius` | Per-marker visible radius; interactive units are CSS pixels, static units are SVG units |
525
+ | `selectedMarkerRing` | Optional `{ color, width, gap }` in CSS pixels; preserves the hit target |
526
+ | `clustering` | `false` by default; `true` or `{ radius }` groups nearby screen positions; selected pin remains independent |
527
+ | `legend` | `{ builtins: false, items: [{ label, color }] }` replaces built-ins; omit `builtins` to append custom entries; `hidden` hides selected built-ins (`bart`, `park`, `highway`, `road`) |
528
+ | `attribution` | `'full'` (default) or `'compact'`; compact keeps full provenance in a native disclosure |
529
+ | `northArrow`, `scaleBar` | Optional canvas furniture; scale is approximate at central SF latitude, in metric units |
530
+
531
+ `setViewport(view, { animate, duration })`, `fitGeometry(geometry, padding,
532
+ { animate, duration })`, `selectMarker(id, { fit, animate, duration })`, and
533
+ `selectNeighborhood(name, { fit, animate, duration })` accept per-call motion
534
+ controls. `animate: true` opts in even when global motion is disabled.
535
+ `stopAnimation()` freezes the camera at its current viewport. A new camera
536
+ operation replaces the previous transition; pointer gestures interrupt it.
537
+ Reduced-motion preference overrides all animation requests. `destroy()` cancels
538
+ camera frames, marker animations, listeners, and resize observation.
539
+
540
+ `map.overlayElement` is a public HTML overlay slot. `map.projectToScreen(lng, lat)`
541
+ returns `{ x, y, visible }` in CSS pixels relative to that slot. Call it after
542
+ mounting; reposition your callout on `viewportchange` and `mapresize`.
543
+ The slot ignores pointer events; interactive children can set `pointer-events:auto`.
544
+ `clusteractivate` emits `{ markers }` and fits their geographic extent. Coincident
545
+ pins cannot separate through zoom; retain the full native marker chooser or an
546
+ accessible external list. `--sf-marker-index` on each marker is a stable entrance
547
+ index hook, but the built-in animation avoids the need to style internal SVG.
548
+
549
+ For server rendering, `createGuideSVG(options)` from `@kahwee/sf-map-svg/guide/static`
550
+ uses the same simplified geography as the browser guide with one import.
551
+ `createGuideShell(options)` also reserves compact toolbar, legend, and attribution
552
+ rows; pass its `.sf-guide-shell` element to `mountGuideMap(shell, options)` from
553
+ `@kahwee/sf-map-svg/guide/map`. By default, this compact layout hides the native
554
+ pickers, pan buttons, label switch, help, and visible status; explicit control overrides are honored. Provide an accessible
555
+ external place list. The toolbar and legend scroll horizontally if needed.
556
+
557
+ ### Reconfigure without rebuilding
558
+
559
+ Construction options remain backward-compatible. Use these atomic patches for
560
+ runtime switches; they preserve the map element, viewport, markers, and selection:
561
+
562
+ ```js
563
+ map.setFeatures({ motion: false, clustering: true, selectedMarkerRing: true });
564
+ map.setLayers({ landmarks: false, bartStations: true, roadLabels: false });
565
+ map.setControls({ zoom: false, reset: true, touch: true });
566
+ ```
567
+
568
+ `setFeatures` accepts the exported `MapFeatures` interface: `motion`,
569
+ `markerEntrance`, `clustering`, `selectedMarkerRing`, `northArrow`, and `scaleBar`.
570
+ Every feature accepts `false` to disable; the first four also accept `true` for
571
+ built-in settings or an options object. Omitted patch keys retain their settings;
572
+ `undefined` resets that key to the default. Options objects **replace** the previous
573
+ object for that feature; they do not deep-merge. `getFeatures()` returns a detached,
574
+ normalized snapshot. Changing motion cancels the current transition; changing
575
+ entrance settings cancels active entrances and applies to future new marker IDs.
576
+
577
+ `setLayers` accepts `InteractiveLayers`; `undefined` restores the mode default.
578
+ Geometry, associated labels, built-in legend entries, and attribution update
579
+ together. Roads and road labels remain independent switches. Toggling does not
580
+ remove geography already imported into a client bundle.
581
+
582
+ `setControls` accepts the same keys as `controls`. Hiding zoom no longer hides
583
+ Reset, Labels, or Touch. Hiding the touch button disengages touch navigation so
584
+ page scrolling stays recoverable. Unknown switch names and invalid values throw
585
+ before modifying state. After `destroy()`, setters are no-ops and disposal can be
586
+ repeated safely. Getters return the last state; screen projection still requires
587
+ a mounted, visible canvas.
588
+
589
+ For configuration precedence, malformed inputs, callback reentrancy, and lifecycle
590
+ ownership, see the [maintainer API audit](docs/api-audit.md).
@@ -4,6 +4,10 @@ The JSON files here are the geographic source of truth, not generated copies of
4
4
 
5
5
  The renderer, `neighborhoods` convenience export, and `getNeighborhood` default to the **92 SFAR realtor areas**. Import `neighborhoods-realtor.json` directly for that same geometry. The filename `neighborhoods.json` continues to identify the separate SF Find collection.
6
6
 
7
+ Certified candidate vote snapshots are separate, optional JSON imports under
8
+ [`candidates/`](candidates/README.md). They do not change the geographic
9
+ renderer or its default bundle.
10
+
7
11
  ## Available files
8
12
 
9
13
  | File | Contents |
@@ -95,7 +99,7 @@ Lookup is exact after case/punctuation normalization, returns `undefined` for an
95
99
  ## Draw a neighborhood with the existing projection
96
100
 
97
101
  ```js
98
- import { createSFMap } from '@kahwee/sf-map-svg';
102
+ import { createSFMap } from '@kahwee/sf-map-svg/legacy';
99
103
  import { getNeighborhood } from '@kahwee/sf-map-svg/data';
100
104
  import { geometryPath } from '@kahwee/sf-map-svg/geometry';
101
105
 
@@ -4,7 +4,10 @@ export type Geometry = {
4
4
  readonly type: 'Point';
5
5
  readonly coordinates: Position;
6
6
  } | {
7
- readonly type: 'LineString' | 'MultiPoint';
7
+ readonly type: 'LineString';
8
+ readonly coordinates: readonly Position[];
9
+ } | {
10
+ readonly type: 'MultiPoint';
8
11
  readonly coordinates: readonly Position[];
9
12
  } | {
10
13
  readonly type: 'Polygon';
@@ -0,0 +1,3 @@
1
+ /** v2 root: runtime and types only. Geography is always an explicit import. */
2
+ export * from './map.js';
3
+ export * from './static.js';
@@ -0,0 +1,3 @@
1
+ /** v2 root: runtime and types only. Geography is always an explicit import. */
2
+ export * from './map.js';
3
+ export * from './static.js';
@@ -0,0 +1,16 @@
1
+ import type { CameraOptions, MapViewport } from './types.js';
2
+ export declare function validateCameraOptions(options: CameraOptions): void;
3
+ /** A cancellable camera independent of DOM rendering; generation guards cover reentrant events. */
4
+ export declare function createCamera({ read, write, duration, reduced, request, cancel, now, }: {
5
+ read: () => MapViewport;
6
+ write: (view: MapViewport) => void;
7
+ duration: () => number;
8
+ reduced: () => boolean;
9
+ request: (callback: FrameRequestCallback) => number;
10
+ cancel: (id: number) => void;
11
+ now: () => number;
12
+ }): {
13
+ stop: () => void;
14
+ destroy(): void;
15
+ move(next: MapViewport, options?: CameraOptions): void;
16
+ };
@@ -0,0 +1,55 @@
1
+ import { assertOptions } from './validation.js';
2
+ import { validateViewport } from './viewport.js';
3
+ export function validateCameraOptions(options) {
4
+ assertOptions(options, 'camera', ['animate', 'duration', 'fit']);
5
+ if ('fit' in options && options.fit !== undefined && typeof options.fit !== 'boolean')
6
+ throw new TypeError('fit must be boolean.');
7
+ if (options.animate !== undefined && typeof options.animate !== 'boolean')
8
+ throw new TypeError('animate must be a boolean.');
9
+ if (options.duration !== undefined &&
10
+ (!Number.isFinite(options.duration) || options.duration < 0))
11
+ throw new RangeError('Duration must be finite and nonnegative.');
12
+ }
13
+ /** A cancellable camera independent of DOM rendering; generation guards cover reentrant events. */
14
+ export function createCamera({ read, write, duration, reduced, request, cancel, now, }) {
15
+ let frame = 0, generation = 0, destroyed = false;
16
+ function stop() {
17
+ generation++;
18
+ cancel(frame);
19
+ frame = 0;
20
+ }
21
+ return {
22
+ stop,
23
+ destroy() {
24
+ destroyed = true;
25
+ stop();
26
+ },
27
+ move(next, options = {}) {
28
+ if (destroyed)
29
+ return;
30
+ const target = validateViewport(next);
31
+ validateCameraOptions(options);
32
+ const ms = options.duration ?? (options.animate === true ? duration() || 320 : duration());
33
+ stop();
34
+ const token = generation;
35
+ if (options.animate === false || reduced() || !ms) {
36
+ write(target);
37
+ return;
38
+ }
39
+ const from = [...read()], start = now();
40
+ if (from.every((value, i) => value === target[i]))
41
+ return;
42
+ const tick = (time) => {
43
+ if (destroyed || token !== generation)
44
+ return;
45
+ const t = Math.max(0, Math.min(1, (time - start) / ms));
46
+ const eased = 1 - (1 - t) ** 3;
47
+ write(target.map((value, i) => from[i] + (value - from[i]) * eased));
48
+ if (destroyed || token !== generation)
49
+ return;
50
+ frame = t < 1 ? request(tick) : 0;
51
+ };
52
+ frame = request(tick);
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,7 @@
1
+ /** Stable seed-distance groups with a spatial index, independent of input order and the DOM. */
2
+ export declare function clusterPoints<T extends {
3
+ marker: {
4
+ id: string;
5
+ };
6
+ point: readonly [number, number];
7
+ }>(items: readonly T[], unit: number, radius: number): T[][];
@@ -0,0 +1,33 @@
1
+ /** Stable seed-distance groups with a spatial index, independent of input order and the DOM. */
2
+ export function clusterPoints(items, unit, radius) {
3
+ const cellSize = unit * radius;
4
+ if (!Number.isFinite(cellSize) || cellSize <= 0)
5
+ throw new RangeError('Cluster scale and radius must be positive and finite.');
6
+ const cells = new Map();
7
+ const groups = [];
8
+ for (const item of [...items].sort((a, b) => a.marker.id < b.marker.id ? -1 : a.marker.id > b.marker.id ? 1 : 0)) {
9
+ const [x, y] = item.point;
10
+ const cx = Math.floor(x / cellSize), cy = Math.floor(y / cellSize);
11
+ let nearest, distance = cellSize;
12
+ for (let dx = -1; dx <= 1; dx++)
13
+ for (let dy = -1; dy <= 1; dy++) {
14
+ for (const candidate of cells.get(`${cx + dx}:${cy + dy}`) ?? []) {
15
+ const d = Math.hypot(x - candidate[0].point[0], y - candidate[0].point[1]);
16
+ if (d < distance) {
17
+ nearest = candidate;
18
+ distance = d;
19
+ }
20
+ }
21
+ }
22
+ if (nearest)
23
+ nearest.push(item);
24
+ else {
25
+ const group = [item], key = `${cx}:${cy}`;
26
+ groups.push(group);
27
+ const cell = cells.get(key) ?? [];
28
+ cell.push(group);
29
+ cells.set(key, cell);
30
+ }
31
+ }
32
+ return groups;
33
+ }
@@ -0,0 +1,5 @@
1
+ import type { MapConfiguration, MapConfigurationSnapshot, MapOptions } from './controller-types.js';
2
+ import type { NeighborhoodExplorerOptions } from './types.js';
3
+ export declare function expandMapOptions(options: MapOptions): NeighborhoodExplorerOptions;
4
+ /** Prepare the entire configuration before any DOM mutation. */
5
+ export declare function prepareConfiguration(current: MapConfigurationSnapshot, patch: MapConfiguration): MapConfigurationSnapshot;
@@ -0,0 +1,73 @@
1
+ import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from './features.js';
2
+ import { assertOptions, validateExplorerOptions } from './validation.js';
3
+ const appearanceKeys = [
4
+ 'theme',
5
+ 'colors',
6
+ 'labelStyle',
7
+ 'areaStyle',
8
+ 'labelSize',
9
+ 'style',
10
+ 'markerRadius',
11
+ 'markerHitSize',
12
+ 'markerColor',
13
+ 'selectedMarkerColor',
14
+ ];
15
+ const featureKeys = [
16
+ 'motion',
17
+ 'markerEntrance',
18
+ 'selectedMarkerRing',
19
+ 'clustering',
20
+ 'northArrow',
21
+ 'scaleBar',
22
+ ];
23
+ export function expandMapOptions(options) {
24
+ const record = assertOptions(options, 'map');
25
+ for (const key of [
26
+ ...appearanceKeys,
27
+ ...featureKeys,
28
+ 'interface',
29
+ 'onMarkerActivate',
30
+ 'onOverlayActivate',
31
+ ])
32
+ if (key in record)
33
+ throw new TypeError(`Use the v2 grouped options or map.on() instead of ${key}.`);
34
+ if (options.appearance !== undefined)
35
+ assertOptions(options.appearance, 'appearance', appearanceKeys);
36
+ if (options.features !== undefined)
37
+ normalizeFeatures(options.features);
38
+ const { features, appearance, ...rest } = options;
39
+ const expanded = { ...rest, ...appearance, ...features };
40
+ validateExplorerOptions(expanded);
41
+ return expanded;
42
+ }
43
+ /** Prepare the entire configuration before any DOM mutation. */
44
+ export function prepareConfiguration(current, patch) {
45
+ assertOptions(patch, 'configuration', ['features', 'layers', 'controls']);
46
+ const features = 'features' in patch
47
+ ? normalizeFeatures(patch.features === undefined
48
+ ? {}
49
+ : { ...current.features, ...checkedFeatures(patch.features) })
50
+ : current.features;
51
+ const switches = (key, previous, keys) => {
52
+ if (!(key in patch))
53
+ return previous;
54
+ const value = patch[key];
55
+ if (value === undefined)
56
+ return {};
57
+ validateSwitchPatch(value, keys);
58
+ const next = { ...previous, ...value };
59
+ for (const property of Object.keys(next))
60
+ if (next[property] === undefined)
61
+ delete next[property];
62
+ return next;
63
+ };
64
+ return structuredClone({
65
+ features,
66
+ layers: switches('layers', current.layers, layerKeys),
67
+ controls: switches('controls', current.controls, controlKeys),
68
+ });
69
+ }
70
+ function checkedFeatures(value) {
71
+ normalizeFeatures(value);
72
+ return value;
73
+ }
@@ -0,0 +1,84 @@
1
+ import type { Geometry, NeighborhoodSource } from '../data/types.js';
2
+ import type { CameraOptions, InteractiveLayers, MapFeatures, MapMarker, MapOverlay, MapPadding, MapViewport, NeighborhoodExplorerOptions, NeighborhoodSelection } from './types.js';
3
+ export type MapAppearance = Pick<NeighborhoodExplorerOptions, 'theme' | 'colors' | 'labelStyle' | 'areaStyle' | 'labelSize' | 'style' | 'markerRadius' | 'markerHitSize' | 'markerColor' | 'selectedMarkerColor'>;
4
+ export type MapControls = NonNullable<NeighborhoodExplorerOptions['controls']>;
5
+ /** Omitted groups/keys retain their values; undefined groups reset all keys in that group. */
6
+ export interface MapConfiguration {
7
+ features?: MapFeatures;
8
+ layers?: InteractiveLayers;
9
+ controls?: MapControls;
10
+ }
11
+ export interface MapOptions extends Omit<NeighborhoodExplorerOptions, keyof MapFeatures | keyof MapAppearance | 'interface' | 'onMarkerActivate' | 'onOverlayActivate'> {
12
+ features?: MapFeatures;
13
+ appearance?: MapAppearance;
14
+ }
15
+ export interface MapConfigurationSnapshot {
16
+ features: MapFeatures;
17
+ /** Explicit overrides; absent values continue to follow the current mode. */
18
+ layers: InteractiveLayers;
19
+ controls: MapControls;
20
+ }
21
+ export interface MapEvents {
22
+ markerchange: {
23
+ id: string | null;
24
+ marker: MapMarker | null;
25
+ };
26
+ neighborhoodchange: NeighborhoodSelection | {
27
+ id: null;
28
+ name: null;
29
+ source: NeighborhoodSource;
30
+ feature: null;
31
+ };
32
+ overlayactivate: {
33
+ overlay: MapOverlay;
34
+ };
35
+ clusteractivate: {
36
+ markers: MapMarker[];
37
+ };
38
+ viewportchange: {
39
+ viewport: MapViewport;
40
+ };
41
+ mapresize: undefined;
42
+ }
43
+ export interface MapCamera {
44
+ get(): MapViewport;
45
+ set(view: MapViewport, options?: CameraOptions): void;
46
+ fit(geometry: Geometry, options?: CameraOptions & {
47
+ padding?: number | MapPadding;
48
+ }): void;
49
+ pan(x: number, y: number, options?: CameraOptions): void;
50
+ zoom(factor: number, options?: CameraOptions): void;
51
+ reset(options?: CameraOptions): void;
52
+ stop(): void;
53
+ }
54
+ /** Owns the map lifecycle separately from its mountable DOM element. */
55
+ export interface MapController {
56
+ readonly element: HTMLElement;
57
+ readonly overlayElement: HTMLDivElement;
58
+ readonly camera: MapCamera;
59
+ readonly destroyed: boolean;
60
+ configure(patch: MapConfiguration): void;
61
+ getConfiguration(): MapConfigurationSnapshot;
62
+ on<K extends keyof MapEvents>(type: K, listener: (detail: MapEvents[K]) => void): () => void;
63
+ setMarkers(markers: readonly MapMarker[]): void;
64
+ setOverlays(overlays: readonly MapOverlay[]): void;
65
+ selectMarker(id: string | null, options?: CameraOptions & {
66
+ fit?: boolean;
67
+ }): boolean;
68
+ getSelectedMarker(): MapMarker | null;
69
+ selectNeighborhood(name: string | null, options?: CameraOptions & {
70
+ fit?: boolean;
71
+ }): boolean;
72
+ getSelectedNeighborhood(): NeighborhoodSelection | null;
73
+ setSource(source: NeighborhoodSource): void;
74
+ setMode(mode: NonNullable<MapOptions['mode']>): void;
75
+ setLabels(visible: boolean): void;
76
+ setTouchNavigation(enabled: boolean): void;
77
+ projectToScreen(lng: number, lat: number): {
78
+ x: number;
79
+ y: number;
80
+ visible: boolean;
81
+ };
82
+ /** Idempotent; stops work and managed subscriptions. Does not remove host-owned DOM. */
83
+ destroy(): void;
84
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -2,4 +2,4 @@ import type { InteractiveSFMapData } from './explorer-data.js';
2
2
  import type { NeighborhoodExplorerElement, NeighborhoodExplorerOptions } from './types.js';
3
3
  export type { ExplorerMode, NeighborhoodExplorerElement, NeighborhoodExplorerOptions, } from './types.js';
4
4
  /** Create an offline, browser-only neighborhood explorer. Call destroy() before disposal. */
5
- export declare function createNeighborhoodExplorerCore({ source, mode, labels, neighborhood, year, theme, interface: chrome, layers, selectableNeighborhoods, labelSize, fitPadding, markers: initialMarkers, markerRadius, markerHitSize, markerColor, selectedMarkerColor, onMarkerActivate, overlays: initialOverlays, onOverlayActivate, style: styleOptions, strings, controls, }: NeighborhoodExplorerOptions | undefined, data: InteractiveSFMapData): NeighborhoodExplorerElement;
5
+ export declare function createNeighborhoodExplorerCore(options: NeighborhoodExplorerOptions | undefined, data: InteractiveSFMapData): NeighborhoodExplorerElement;