@kahwee/sf-map-svg 1.5.2 → 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.
- package/CHANGELOG.md +40 -0
- package/README.md +145 -6
- package/dist/data/README.md +5 -1
- package/dist/data/types.d.ts +4 -1
- package/dist/src/api.d.ts +3 -0
- package/dist/src/api.js +3 -0
- package/dist/src/camera.d.ts +16 -0
- package/dist/src/camera.js +55 -0
- package/dist/src/clusters.d.ts +7 -0
- package/dist/src/clusters.js +33 -0
- package/dist/src/configuration.d.ts +5 -0
- package/dist/src/configuration.js +73 -0
- package/dist/src/controller-types.d.ts +84 -0
- package/dist/src/controller-types.js +1 -0
- package/dist/src/explorer-core.d.ts +1 -1
- package/dist/src/explorer-core.js +570 -145
- package/dist/src/features.d.ts +24 -0
- package/dist/src/features.js +99 -0
- package/dist/src/guide-data.js +3 -2
- package/dist/src/guide-detailed.js +3 -2
- package/dist/src/guide-map.d.ts +4 -0
- package/dist/src/guide-map.js +35 -10
- package/dist/src/guide-shell.d.ts +2 -0
- package/dist/src/guide-shell.js +16 -0
- package/dist/src/guide-static.d.ts +9 -0
- package/dist/src/guide-static.js +26 -0
- package/dist/src/guide.d.ts +2 -1
- package/dist/src/guide.js +1 -1
- package/dist/src/interactive-data.d.ts +1 -1
- package/dist/src/interactive-data.js +2 -0
- package/dist/src/interactive.d.ts +1 -1
- package/dist/src/interactive.js +2 -0
- package/dist/src/layers.js +3 -1
- package/dist/src/map-core.js +3 -8
- package/dist/src/map.d.ts +7 -0
- package/dist/src/map.js +106 -0
- package/dist/src/presets.d.ts +3 -0
- package/dist/src/presets.js +17 -0
- package/dist/src/static.d.ts +10 -0
- package/dist/src/static.js +5 -0
- package/dist/src/types.d.ts +73 -9
- package/dist/src/validation.d.ts +7 -0
- package/dist/src/validation.js +226 -0
- package/dist/src/viewport.js +9 -2
- package/docs/EXAMPLES.md +11 -1
- package/docs/api-audit.md +80 -0
- package/docs/consumer-integration.md +165 -0
- package/docs/guide-bundle-report.md +19 -0
- package/docs/migration-v2.md +238 -0
- package/package.json +39 -11
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,46 @@ 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
|
+
|
|
7
47
|
## 1.5.2 — 2026-09-26
|
|
8
48
|
|
|
9
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.
|
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) |
|
|
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
|
-
|
|
|
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
|
|
65
|
-
|
|
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.
|
|
@@ -387,7 +422,7 @@ pnpm test:stories:coverage # Chromium checks plus renderer coverage report
|
|
|
387
422
|
pnpm test:package # install and check the packed package
|
|
388
423
|
```
|
|
389
424
|
|
|
390
|
-
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
|
|
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.
|
|
391
426
|
|
|
392
427
|
### Browser verification
|
|
393
428
|
|
|
@@ -449,3 +484,107 @@ Embed the Pages demo with `<iframe src="https://kahwee.github.io/sf-map-svg/tran
|
|
|
449
484
|
## Ballot measures explorer
|
|
450
485
|
|
|
451
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).
|
package/dist/data/README.md
CHANGED
|
@@ -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
|
|
package/dist/data/types.d.ts
CHANGED
|
@@ -4,7 +4,10 @@ export type Geometry = {
|
|
|
4
4
|
readonly type: 'Point';
|
|
5
5
|
readonly coordinates: Position;
|
|
6
6
|
} | {
|
|
7
|
-
readonly type: 'LineString'
|
|
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';
|
package/dist/src/api.js
ADDED
|
@@ -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(
|
|
5
|
+
export declare function createNeighborhoodExplorerCore(options: NeighborhoodExplorerOptions | undefined, data: InteractiveSFMapData): NeighborhoodExplorerElement;
|