@kahwee/sf-map-svg 4.3.1 → 4.4.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 +26 -0
- package/README.md +134 -1
- package/dist/src/cluster-renderer.d.ts +34 -0
- package/dist/src/cluster-renderer.js +159 -0
- package/dist/src/controller-types.d.ts +2 -2
- package/dist/src/dom.d.ts +2 -0
- package/dist/src/dom.js +6 -0
- package/dist/src/explorer-core.js +241 -265
- package/dist/src/guide-data.js +1 -0
- package/dist/src/guide-static.js +1 -0
- package/dist/src/label-candidates.d.ts +24 -0
- package/dist/src/label-candidates.js +29 -0
- package/dist/src/label-renderer.d.ts +1 -10
- package/dist/src/label-renderer.js +16 -8
- package/dist/src/map-core.d.ts +3 -1
- package/dist/src/map-core.js +7 -5
- package/dist/src/marker-catalog.d.ts +11 -0
- package/dist/src/marker-catalog.js +67 -0
- package/dist/src/marker-chooser.d.ts +6 -0
- package/dist/src/marker-chooser.js +59 -0
- package/dist/src/marker-layer.d.ts +13 -8
- package/dist/src/marker-layer.js +41 -23
- package/dist/src/marker-navigation.d.ts +5 -0
- package/dist/src/marker-navigation.js +52 -0
- package/dist/src/static-labels.d.ts +7 -0
- package/dist/src/static-labels.js +100 -0
- package/dist/src/static.js +12 -3
- package/dist/src/types.d.ts +8 -2
- package/docs/API.md +14 -4
- package/docs/EXAMPLES.md +1 -0
- package/docs/api-audit.md +5 -4
- package/docs/consumer-integration.md +43 -1
- package/docs/guide-bundle-report.md +4 -4
- package/llms-full.txt +251 -31
- package/llms.txt +1 -1
- package/package.json +14 -11
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,32 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 4.4.0 — 2026-10-12
|
|
8
|
+
|
|
9
|
+
- Add opt-in static `labelLayout: { width }` to `renderMap`, `createGuideSVG`, and `createGuideShell`. Static and interactive maps now share overview priorities, neighborhood anchors, font sizes, and collision rules; centered labels retain their anchors during enhancement.
|
|
10
|
+
- Add static `neighborhoodLabels`, available flat or under `layers`, for the new layout. Guide SVGs and shells enable neighborhood names when opting in. Existing fixed-label output and interactive defaults remain unchanged; `labels: false` still hides text while preserving symbols and accessible titles.
|
|
11
|
+
- Keep static layout server-safe and dependency-free. The width is a CSS canvas-width hint, not the SVG projection width; portable font estimates can produce a different visible label set from browser-measured fonts. Document matching data, markers, colors, and layers, plus responsive-width and custom-style limitations.
|
|
12
|
+
- Keep server font estimation out of interactive-only imports and browser runtime code out of static imports. Refresh bundle reports and enforce bounded size budgets for the combined renderer and static-capable Pages tools.
|
|
13
|
+
- Add desktop and phone enhancement stories, collision and input-validation regressions, an SVG example, and copyable integration documentation. Existing screenshot baselines remain unchanged.
|
|
14
|
+
|
|
15
|
+
## 4.3.2 — 2026-10-10
|
|
16
|
+
|
|
17
|
+
- Update compatible development tooling and refresh dependency security fixes.
|
|
18
|
+
|
|
19
|
+
- Cache label candidates across camera frames, prepare neighborhood priority once per source, and measure overlays before SVG updates.
|
|
20
|
+
- Extract marker catalog reconciliation and cluster rendering with explicit cache and listener ownership; add label refresh checks across catalog, appearance, layers, and source changes.
|
|
21
|
+
|
|
22
|
+
- Create individual marker visuals only on first visibility or selection, keeping the full catalog and retained nodes. Defer new pin entrances and cancel pending entrances with active animations.
|
|
23
|
+
- Benchmark clustered and unclustered continuous pan, rapid zoom, and resize, with separate allocation probes.
|
|
24
|
+
|
|
25
|
+
- Cull offscreen pins and cluster symbols while preserving the full catalog, cached nodes, selection, and keyboard focus. Cache cluster groups during pan and skip unchanged SVG attributes.
|
|
26
|
+
- Add interactive 2,000-marker desktop and phone stories, viewport count benchmarks, and a Bun 1.4.3 compatibility gate.
|
|
27
|
+
|
|
28
|
+
- Add production-bundle interaction checks in Chromium, Firefox, and WebKit, plus reproducible marker benchmarks and CI performance artifacts.
|
|
29
|
+
- Add optimized desktop and phone marker screencasts to the Pages examples, with captions and playback size checks.
|
|
30
|
+
- Add a place chooser for coincident pins and clusters that cannot separate at maximum zoom, including compact embeds with hidden native pickers.
|
|
31
|
+
- Give visible markers and clusters one tab stop with bracket-key navigation, preserving focus across marker reordering and recovering focus when a marker is removed.
|
|
32
|
+
|
|
7
33
|
## 4.3.1 — 2026-10-04
|
|
8
34
|
|
|
9
35
|
- Reject unknown flat static options and invalid values before rendering or invoking district styling callbacks, matching grouped-option validation.
|
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@ Offline, self-contained San Francisco SVG maps. The package has no runtime depen
|
|
|
4
4
|
|
|
5
5
|
[Getting started](docs/developer-guide.md) · [API reference](docs/API.md) · [Design playground](https://kahwee.github.io/sf-map-svg/playground.html) · [Live examples](https://kahwee.github.io/sf-map-svg/examples.html) · [Storybook source](stories/) · [Geographic sources](SOURCES.md) · [v3 migration](docs/migration-v3.md)
|
|
6
6
|
|
|
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
|
+
|
|
7
9
|
## Install
|
|
8
10
|
|
|
9
11
|
```sh
|
|
@@ -31,6 +33,23 @@ const { svg, project, viewBox } = renderMap(staticMapData, {
|
|
|
31
33
|
|
|
32
34
|
See [map options and animations](docs/API.md#static-maps) for optional layers and static animation.
|
|
33
35
|
|
|
36
|
+
## Labels before JavaScript loads
|
|
37
|
+
|
|
38
|
+
Version 4.4 adds an opt-in overview layout for server-rendered guides:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { createGuideShell } from '@kahwee/sf-map-svg/guide/static';
|
|
42
|
+
|
|
43
|
+
const guideMarkup = createGuideShell({
|
|
44
|
+
title: 'Explore San Francisco',
|
|
45
|
+
labelLayout: { width: 390 },
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`width` is the expected canvas width in CSS pixels. The SVG keeps its 800 × 800 projection while labels share the interactive map's priorities, anchors, sizes and collision rules. Guide shells include neighborhood names in this layout; calls without `labelLayout` keep their previous output.
|
|
50
|
+
|
|
51
|
+
Enhance the shell with `mountGuideController()` using the same data, colors, markers and layers. Static font widths are estimated, so browser fonts and resize can change which labels fit. See the [complete integration recipe and width guidance](docs/consumer-integration.md#keep-overview-labels-consistent). No migration is required from 4.3.
|
|
52
|
+
|
|
34
53
|
## Interactive map
|
|
35
54
|
|
|
36
55
|
```ts
|
|
@@ -71,4 +90,118 @@ Call `destroy()` when removing an interactive map.
|
|
|
71
90
|
|
|
72
91
|
Canonical geography is in [`data/`](data/README.md), with provenance in [`SOURCES.md`](SOURCES.md). The public `/data` entry exposes lookup, catalog, district maps, and source-specific neighborhood collections. These are deeply frozen; clone before editing.
|
|
73
92
|
|
|
74
|
-
Install with `pnpm install --frozen-lockfile`. See the [contributing guide](CONTRIBUTING.md) for architecture and the [validation and release runbook](https://github.com/kahwee/sf-map-svg/blob/main/docs/maintenance.md) for checks, browser inspection, and publishing. If you are upgrading from v2, follow the [v3 migration guide](docs/migration-v3.md).
|
|
93
|
+
Install with `pnpm install --frozen-lockfile`. Bun 1.4.3 is pinned in `.bun-version`; `pnpm test:bun` checks compiled-package imports and static rendering. Strict TypeScript 7.0.2 declarations and packed consumers remain part of the Node/pnpm checks. See the [contributing guide](CONTRIBUTING.md) for architecture and the [validation and release runbook](https://github.com/kahwee/sf-map-svg/blob/main/docs/maintenance.md) for checks, browser inspection, and publishing. If you are upgrading from v2, follow the [v3 migration guide](docs/migration-v3.md).
|
|
94
|
+
|
|
95
|
+
## Browser checks and marker performance
|
|
96
|
+
|
|
97
|
+
The focused interaction suite checks a production bundle in Chromium, Firefox, and WebKit at 1440 px and 390 px. It covers 2,000-marker viewport culling and reattachment, overlapping-place selection, marker keyboard navigation and focus recovery, camera input, resizing, touch-mode scroll policy, reduced motion, and disposal. Phone widths check responsive layout in these engines; they do not replace physical-device gesture testing. The full Storybook and screenshot suites continue to use Chromium.
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
pnpm exec playwright install --with-deps chromium firefox webkit
|
|
101
|
+
pnpm test:browsers
|
|
102
|
+
pnpm benchmark:markers --browsers=chromium,firefox,webkit
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The benchmark uses headless engines and a deterministic city grid with 500 and 2,000 labeled markers at 800 px and 390 px viewport widths. It loads the production bundle and fonts before timing, disables motion, and starts each operation from a consistent camera and clustering state. Each operation has three warmups and 20 measured samples. Results include mount, pan, zoom, clustering changes, retained-ID updates, replacement, and identical updates. Run benchmarks alone on an otherwise idle machine. Use `--labels=false` to compare label cost and `--output=test-results/another-run.json` to keep separate results.
|
|
106
|
+
|
|
107
|
+
`test-results/marker-benchmark.json` records browser versions, machine details, and p50/p95/max timings. Synchronous times include the API call and forced layout; settled times include two animation frames and are not GPU-paint measurements. Six continuous workloads record 60 frame intervals each: pan, rapid zoom, and resize with clustering on and off. Four initial frame callbacks precede the measurements. An untimed allocation probe also counts newly created pin groups, including detached nodes. These measurements describe the test machine, rather than a universal performance limit or a mobile-device guarantee. The report also records mounted pin/cluster counts at overview and two zoom levels, with clustering on and off; the picker retains all records. CI saves a shorter 2,000-marker Chromium benchmark for comparison without timing thresholds.
|
|
108
|
+
|
|
109
|
+
Measured October 10–11, 2026 against renderer commit `1d523d9`, in a Linux x64 container on an Intel Xeon Platinum 8573C with a four-CPU quota and 16 GiB memory limit, using Node 26.11.1 and Playwright 1.63.0. All 36 focused browser checks passed. The 2,000-marker results below use clustering and labels. Mount, update (retained IDs), and replacement (all new IDs) show median synchronous milliseconds; pan shows the 95th-percentile frame interval in milliseconds.
|
|
110
|
+
|
|
111
|
+
| Engine | Viewport | Mount | Update | Replace | Pan frame p95 |
|
|
112
|
+
| --- | ---: | ---: | ---: | ---: | ---: |
|
|
113
|
+
| Chromium 153 | 800 px | 147.2 | 28.3 | 92.0 | 166.7 |
|
|
114
|
+
| Chromium 153 | 390 px | 180.6 | 24.4 | 104.4 | 183.3 |
|
|
115
|
+
| Firefox 155 | 800 px | 351.0 | 55.0 | 222.0 | 200.3 |
|
|
116
|
+
| Firefox 155 | 390 px | 386.0 | 211.0 | 161.0 | 116.8 |
|
|
117
|
+
| WebKit 26.6 | 800 px | 468.0 | 105.0 | 896.0 | 302.0 |
|
|
118
|
+
| WebKit 26.6 | 390 px | 235.0 | 107.0 | 889.0 | 118.0 |
|
|
119
|
+
|
|
120
|
+
The baseline retained all supplied pin nodes in the SVG, including clustered pins. These frame intervals exceeded the 16.7 ms budget for 60 Hz interaction. Prefer smaller marker lists and stable IDs, and avoid repeatedly replacing every marker during interaction. Identical 2,000-marker updates took 0.7–2.0 ms at the median across these runs; full replacement was especially costly in WebKit. Keep camera-call timings separate from frame timings: a quick synchronous call does not imply a smooth rendered frame.
|
|
121
|
+
|
|
122
|
+
In that baseline, a separate 20-sample Chromium run with 2,000 markers at 800 px and `labels: false` reduced the pan frame p95 from 166.7 ms to 50.0 ms and median mount time from 147.2 ms to 115.6 ms. Disabling labels helps this workload but still does not establish 60 Hz performance. Reproduce the comparison with `pnpm benchmark:markers --counts=2000 --widths=800 --labels=false --output=test-results/marker-benchmark-no-labels.json`.
|
|
123
|
+
|
|
124
|
+
### Viewport renderer results
|
|
125
|
+
|
|
126
|
+
The viewport renderer detaches offscreen pins and clustered members, reuses their nodes when they return, caches cluster groups during pan, and skips unchanged SVG writes. Selected and focused pins stay mounted; every supplied record remains selectable. Cluster membership uses the full catalog, so panning does not change counts at the edges.
|
|
127
|
+
|
|
128
|
+
The same 20-sample, 2,000-marker benchmark was rerun October 11, 2026 on the same host, browser versions, and label settings. Columns use the same units as the baseline above.
|
|
129
|
+
|
|
130
|
+
| Engine | Viewport | Mount | Update | Replace | Pan frame p95 |
|
|
131
|
+
| --- | ---: | ---: | ---: | ---: | ---: |
|
|
132
|
+
| Chromium 153 | 800 px | 92.3 | 17.0 | 52.8 | 16.8 |
|
|
133
|
+
| Chromium 153 | 390 px | 97.6 | 12.9 | 63.6 | 16.7 |
|
|
134
|
+
| Firefox 155 | 800 px | 106.0 | 21.0 | 54.0 | 49.4 |
|
|
135
|
+
| Firefox 155 | 390 px | 123.0 | 45.0 | 78.0 | 17.1 |
|
|
136
|
+
| WebKit 26.6 | 800 px | 160.0 | 32.0 | 805.0 | 49.0 |
|
|
137
|
+
| WebKit 26.6 | 390 px | 164.0 | 20.0 | 781.0 | 36.0 |
|
|
138
|
+
|
|
139
|
+
Desktop pan frame p95 improved from 166.7 → 16.8 ms in Chromium, 200.3 → 49.4 ms in Firefox, and 302.0 → 49.0 ms in WebKit. Firefox and WebKit still exceed a 60 Hz frame budget in this run. Prefer stable IDs, clustering, and fewer enabled label layers for dense interactive views; full replacement still creates all new records and picker options. These headless measurements do not establish smoothness on a physical phone.
|
|
140
|
+
|
|
141
|
+
With clustering off at 800 px, the mounted pin count fell from 2,000 in city view to 228 at the sampled 4× zoom; all 2,000 records stayed in the picker. **Checks / 2000 markers / Desktop** and **Phone** in Storybook show live counts and test zoom, pan, retained-ID updates, offscreen selection, and reset. The [plan and storyboard](https://github.com/kahwee/sf-map-svg/blob/main/docs/marker-performance-plan.md) describe the regression contract. [Saved baseline and updated JSON](https://github.com/kahwee/sf-map-svg/blob/main/docs/benchmarks/markers-2026-10-11.json) include all timing percentiles and viewport counts.
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
pnpm benchmark:markers --counts=2000 --browsers=chromium,firefox,webkit \
|
|
145
|
+
--output=test-results/marker-benchmark-viewport.json
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Lazy pin creation and expanded workloads
|
|
149
|
+
|
|
150
|
+
Individual SVG pins now allocate on first visibility or selection, then retain their nodes for reuse. With 2,000 clustered records, an untimed probe counted zero pin groups at mount, zero after replacing every ID, and one after selecting a pin. The previous renderer created 2,000 groups at mount and another 2,000 on replacement. The complete picker, selection, keyboard focus, and deferred entrance animations remain available.
|
|
151
|
+
|
|
152
|
+
October 11, 2026 results with the same engines, labeled grid, and 20 operation samples (median synchronous milliseconds):
|
|
153
|
+
|
|
154
|
+
| Engine | Viewport | Mount | Replace all IDs |
|
|
155
|
+
| --- | ---: | ---: | ---: |
|
|
156
|
+
| chromium | 800 px | 78.1 | 39.3 |
|
|
157
|
+
| chromium | 390 px | 87.9 | 47.9 |
|
|
158
|
+
| firefox | 800 px | 88.0 | 24.0 |
|
|
159
|
+
| firefox | 390 px | 95.0 | 46.0 |
|
|
160
|
+
| webkit | 800 px | 128.0 | 717.0 |
|
|
161
|
+
| webkit | 390 px | 132.0 | 710.0 |
|
|
162
|
+
|
|
163
|
+
A separate run of the same expanded harness against `e41aa2b` measured Chromium mount medians of 115.1/123.6 ms and replacement medians of 57.1/89.8 ms at 800/390 px, versus 78.1/87.9 ms and 39.3/47.9 ms after lazy allocation. This paired control covers Chromium only. WebKit replacement remains expensive at 717/710 ms; stable IDs remain preferable.
|
|
164
|
+
|
|
165
|
+
The expanded stress cases reveal work that culling alone does not remove. Unclustered frame interval p95 values, in milliseconds:
|
|
166
|
+
|
|
167
|
+
| Engine | Viewport | Pan | Rapid zoom | Resize |
|
|
168
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
169
|
+
| chromium | 800 px | 16.7 | 133.4 | 83.3 |
|
|
170
|
+
| chromium | 390 px | 33.3 | 33.4 | 50.0 |
|
|
171
|
+
| firefox | 800 px | 33.8 | 34.2 | 100.2 |
|
|
172
|
+
| firefox | 390 px | 34.2 | 49.4 | 50.7 |
|
|
173
|
+
| webkit | 800 px | 60.0 | 106.0 | 342.0 |
|
|
174
|
+
| webkit | 390 px | 114.0 | 124.0 | 192.0 |
|
|
175
|
+
|
|
176
|
+
These runs do not demonstrate a consistent frame-rate gain: desktop Chromium clustered pan p95 was 50.1 ms in the control and 66.8 ms after lazy allocation, compared with 16.8 ms in the earlier run. Rapid zoom alternates reciprocal 1.2× factors; resize alternates host widths by 80 px. Each workload runs with clustering both on and off, samples counts outside the timed loop, and retains 60 measured intervals. Headless frame timings vary and do not establish physical-device smoothness. [Saved lazy and control results](https://github.com/kahwee/sf-map-svg/blob/main/docs/benchmarks/markers-lazy-2026-10-11.json) preserve every scenario and allocation count.
|
|
177
|
+
|
|
178
|
+
```sh
|
|
179
|
+
pnpm benchmark:markers --counts=2000 --browsers=chromium,firefox,webkit \
|
|
180
|
+
--output=test-results/marker-benchmark-lazy.json
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Cached label preparation and renderer components
|
|
184
|
+
|
|
185
|
+
Neighborhood priority is sorted once per source. Styled label candidates are reused until selection, catalog, source, layers, appearance, or zoom/width eligibility changes. Overlay bounds are measured before marker and cluster SVG updates; secondary-road visibility and scale furniture skip unchanged writes. Marker catalog reconciliation and cluster rendering now have separate modules, reducing the explorer controller from 2,316 to 2,151 lines. At the 4.3.2 marker-performance baseline, the renderer was 34.6 KiB gzip and static rendering was 7.9 KiB. See the [current bundle report](docs/guide-bundle-report.md) for measurements including the opt-in static label layout.
|
|
186
|
+
|
|
187
|
+
A fresh Chromium comparison against `e774fbd` used 2,000 labeled markers, 20 operation samples, both widths, and all six frame workloads in basemap and neighborhood modes. Neighborhood-mode frame interval p95 values (milliseconds):
|
|
188
|
+
|
|
189
|
+
| Workload | 800 px before | 800 px after | 390 px before | 390 px after |
|
|
190
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
191
|
+
| clustered pan | 66.7 | 50.0 | 16.7 | 16.8 |
|
|
192
|
+
| clustered rapid zoom | 66.7 | 83.4 | 33.4 | 16.8 |
|
|
193
|
+
| clustered resize | 16.7 | 66.7 | 66.6 | 16.8 |
|
|
194
|
+
| unclustered pan | 16.7 | 49.9 | 33.3 | 66.6 |
|
|
195
|
+
| unclustered rapid zoom | 116.6 | 33.4 | 66.7 | 83.4 |
|
|
196
|
+
| unclustered resize | 66.7 | 33.3 | 50.0 | 50.1 |
|
|
197
|
+
|
|
198
|
+
Results are mixed rather than a consistent frame-rate gain. Basemap desktop clustered pan p95 changed from 50.0 to 16.8 ms, while phone unclustered pan changed from 16.8 to 50.0 ms. Basemap mount medians were 72.1 → 88.8 ms at 800 px and 88.3 → 89.1 ms at 390 px; neighborhood-mode mount medians were 81.5 → 73.3 ms and 93.9 → 89.0 ms. Viewport counts and allocation probes match before and after. [Saved comparisons](https://github.com/kahwee/sf-map-svg/blob/main/docs/benchmarks/markers-refactor-2026-10-11.json) include every operation and scenario; these headless runs do not establish physical-device smoothness.
|
|
199
|
+
|
|
200
|
+
```sh
|
|
201
|
+
pnpm benchmark:markers --counts=2000 --mode=neighborhoods \
|
|
202
|
+
--output=test-results/marker-benchmark-neighborhoods.json
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## CI maintenance
|
|
206
|
+
|
|
207
|
+
[GitHub Actions maintenance](.github/ACTIONS.md) covers workflows, parallel checks, action versions, and weekly updates.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { normalizeFeatures } from './features.js';
|
|
2
|
+
import type { MarkerItem, MarkerVisual } from './marker-layer.js';
|
|
3
|
+
import type { createMarkerNavigation } from './marker-navigation.js';
|
|
4
|
+
import type { MapViewport } from './types.js';
|
|
5
|
+
interface ClusterState {
|
|
6
|
+
unit: number;
|
|
7
|
+
view: MapViewport;
|
|
8
|
+
items: MarkerItem[];
|
|
9
|
+
revision: number;
|
|
10
|
+
selected: string | null;
|
|
11
|
+
features: ReturnType<typeof normalizeFeatures>;
|
|
12
|
+
markerColor: string;
|
|
13
|
+
markerRadius: number;
|
|
14
|
+
markerHitSize: number;
|
|
15
|
+
}
|
|
16
|
+
interface MarkerCluster {
|
|
17
|
+
items: MarkerItem[];
|
|
18
|
+
point: [number, number];
|
|
19
|
+
node: SVGGElement | undefined;
|
|
20
|
+
}
|
|
21
|
+
/** Own cluster caching, mounted marker nodes, and cancellable cluster listeners. */
|
|
22
|
+
export declare function createClusterRenderer(svg: SVGSVGElement, markerLayer: SVGGElement, clusterLayer: SVGGElement, markerNavigation: ReturnType<typeof createMarkerNavigation>, materializeMarker: (item: MarkerItem) => MarkerItem & {
|
|
23
|
+
visual: MarkerVisual;
|
|
24
|
+
}, onActivate: (items: MarkerItem[], node: SVGGElement, signal: AbortSignal) => void): {
|
|
25
|
+
draw: (state: ClusterState) => {
|
|
26
|
+
markers: (MarkerItem & {
|
|
27
|
+
visual: MarkerVisual;
|
|
28
|
+
})[];
|
|
29
|
+
clusters: MarkerCluster[];
|
|
30
|
+
};
|
|
31
|
+
invalidate: () => void;
|
|
32
|
+
destroy(): void;
|
|
33
|
+
};
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { clusterPoints } from './clusters.js';
|
|
2
|
+
import { setAttributeIfChanged, svgElement } from './dom.js';
|
|
3
|
+
/** Own cluster caching, mounted marker nodes, and cancellable cluster listeners. */
|
|
4
|
+
export function createClusterRenderer(svg, markerLayer, clusterLayer, markerNavigation, materializeMarker, onActivate) {
|
|
5
|
+
let clusterSignature = '';
|
|
6
|
+
let clusterEvents = new AbortController();
|
|
7
|
+
let clusterCache;
|
|
8
|
+
let renderedMarkers = [];
|
|
9
|
+
let renderedClusters = [];
|
|
10
|
+
function invalidate() {
|
|
11
|
+
clusterEvents.abort();
|
|
12
|
+
clusterEvents = new AbortController();
|
|
13
|
+
clusterSignature = '';
|
|
14
|
+
clusterCache = undefined;
|
|
15
|
+
if (clusterLayer.contains(document.activeElement))
|
|
16
|
+
svg.focus({ preventScroll: true });
|
|
17
|
+
clusterLayer.replaceChildren();
|
|
18
|
+
}
|
|
19
|
+
function draw(state) {
|
|
20
|
+
const { unit, view, items: markerItems, revision: markerRevision, selected: selectedMarker, features, markerColor, markerRadius, markerHitSize, } = state;
|
|
21
|
+
const active = document.activeElement;
|
|
22
|
+
const hadClusterFocus = clusterLayer.contains(active);
|
|
23
|
+
const focused = active instanceof SVGElement ? active.dataset.markerId : undefined;
|
|
24
|
+
if (!clusterCache ||
|
|
25
|
+
clusterCache.unit !== unit ||
|
|
26
|
+
clusterCache.revision !== markerRevision ||
|
|
27
|
+
clusterCache.focused !== focused) {
|
|
28
|
+
const groups = features.clustering
|
|
29
|
+
? clusterPoints(markerItems.filter((item) => item.marker.id !== selectedMarker && item.visual?.node !== active), unit, features.clustering.radius).filter((group) => group.length > 1)
|
|
30
|
+
: [];
|
|
31
|
+
const signature = JSON.stringify(groups.map((group) => group.map((item) => item.marker.id)));
|
|
32
|
+
const previous = clusterCache?.clusters;
|
|
33
|
+
const unchanged = signature === clusterSignature;
|
|
34
|
+
if (!unchanged) {
|
|
35
|
+
clusterEvents.abort();
|
|
36
|
+
clusterEvents = new AbortController();
|
|
37
|
+
if (hadClusterFocus)
|
|
38
|
+
svg.focus({ preventScroll: true });
|
|
39
|
+
clusterLayer.replaceChildren();
|
|
40
|
+
clusterSignature = signature;
|
|
41
|
+
}
|
|
42
|
+
const grouped = new Set(groups.flat());
|
|
43
|
+
clusterCache = {
|
|
44
|
+
unit,
|
|
45
|
+
revision: markerRevision,
|
|
46
|
+
focused,
|
|
47
|
+
singles: markerItems.filter((item) => !grouped.has(item)),
|
|
48
|
+
clusters: groups.map((items, index) => ({
|
|
49
|
+
items,
|
|
50
|
+
point: [
|
|
51
|
+
items.reduce((sum, item) => sum + item.point[0], 0) / items.length,
|
|
52
|
+
items.reduce((sum, item) => sum + item.point[1], 0) / items.length,
|
|
53
|
+
],
|
|
54
|
+
node: unchanged ? previous?.[index]?.node : undefined,
|
|
55
|
+
})),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const intersects = (point, pixels) => {
|
|
59
|
+
const padding = pixels * unit;
|
|
60
|
+
return (point[0] >= view[0] - padding &&
|
|
61
|
+
point[0] <= view[0] + view[2] + padding &&
|
|
62
|
+
point[1] >= view[1] - padding &&
|
|
63
|
+
point[1] <= view[1] + view[2] + padding);
|
|
64
|
+
};
|
|
65
|
+
renderedMarkers = clusterCache.singles
|
|
66
|
+
.filter((item) => item.visual?.node === active ||
|
|
67
|
+
item.marker.id === selectedMarker ||
|
|
68
|
+
intersects(item.point, Math.max(markerHitSize / 2, (item.marker.radius ?? markerRadius) + 6)))
|
|
69
|
+
.map(materializeMarker);
|
|
70
|
+
const mounted = new Set(renderedMarkers.map((item) => item.visual.node));
|
|
71
|
+
for (const node of Array.from(markerLayer.children))
|
|
72
|
+
if (!mounted.has(node))
|
|
73
|
+
node.remove();
|
|
74
|
+
// Retain input order and stable nodes, with the selected pin above its peers.
|
|
75
|
+
renderedMarkers.sort((a, b) => Number(a.marker.id === selectedMarker) - Number(b.marker.id === selectedMarker));
|
|
76
|
+
renderedMarkers.forEach((item, index) => {
|
|
77
|
+
setAttributeIfChanged(item.visual.dot, 'r', ((item.marker.radius ?? markerRadius) + (item.marker.id === selectedMarker ? 2 : 0)) * unit);
|
|
78
|
+
setAttributeIfChanged(item.visual.hit, 'r', Math.max(markerHitSize / 2, (item.marker.radius ?? markerRadius) + 4) * unit);
|
|
79
|
+
setAttributeIfChanged(item.visual.ring, 'r', ((item.marker.radius ?? markerRadius) +
|
|
80
|
+
2 +
|
|
81
|
+
(features.selectedMarkerRing ? features.selectedMarkerRing.gap : 3)) *
|
|
82
|
+
unit);
|
|
83
|
+
if (markerLayer.children[index] !== item.visual.node)
|
|
84
|
+
markerLayer.insertBefore(item.visual.node, markerLayer.children[index] ?? null);
|
|
85
|
+
});
|
|
86
|
+
if (active instanceof SVGGElement && mounted.has(active) && document.activeElement !== active)
|
|
87
|
+
active.focus({ preventScroll: true });
|
|
88
|
+
renderedClusters = clusterCache.clusters.filter((cluster) => cluster.node === active || intersects(cluster.point, 26));
|
|
89
|
+
const visibleClusters = new Set(renderedClusters);
|
|
90
|
+
for (const cluster of clusterCache.clusters)
|
|
91
|
+
if (!visibleClusters.has(cluster))
|
|
92
|
+
cluster.node?.remove();
|
|
93
|
+
for (const cluster of renderedClusters) {
|
|
94
|
+
const group = cluster.items;
|
|
95
|
+
if (!cluster.node) {
|
|
96
|
+
const signal = clusterEvents.signal;
|
|
97
|
+
const node = svgElement('g', {
|
|
98
|
+
role: 'button',
|
|
99
|
+
tabindex: -1,
|
|
100
|
+
'data-cluster-ids': JSON.stringify(group.map((item) => item.marker.id)),
|
|
101
|
+
'aria-label': `${group.length} places. Activate to explore or choose a place.`,
|
|
102
|
+
});
|
|
103
|
+
node.style.cursor = 'pointer';
|
|
104
|
+
node.append(svgElement('circle', {
|
|
105
|
+
fill: markerColor,
|
|
106
|
+
stroke: '#fff',
|
|
107
|
+
'stroke-width': 2,
|
|
108
|
+
'vector-effect': 'non-scaling-stroke',
|
|
109
|
+
}));
|
|
110
|
+
const text = svgElement('text', {
|
|
111
|
+
fill: '#fff',
|
|
112
|
+
'text-anchor': 'middle',
|
|
113
|
+
'dominant-baseline': 'central',
|
|
114
|
+
});
|
|
115
|
+
text.textContent = String(group.length);
|
|
116
|
+
node.append(text);
|
|
117
|
+
const activate = () => {
|
|
118
|
+
if (!signal.aborted)
|
|
119
|
+
onActivate(group, node, signal);
|
|
120
|
+
};
|
|
121
|
+
node.addEventListener('click', activate, { signal });
|
|
122
|
+
node.addEventListener('keydown', (event) => {
|
|
123
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
activate();
|
|
126
|
+
}
|
|
127
|
+
}, { signal });
|
|
128
|
+
cluster.node = node;
|
|
129
|
+
}
|
|
130
|
+
const node = cluster.node;
|
|
131
|
+
if (node.parentNode !== clusterLayer)
|
|
132
|
+
clusterLayer.append(node);
|
|
133
|
+
setAttributeIfChanged(node, 'transform', `translate(${cluster.point[0]},${cluster.point[1]})`);
|
|
134
|
+
const circle = node.querySelector('circle');
|
|
135
|
+
const text = node.querySelector('text');
|
|
136
|
+
if (circle)
|
|
137
|
+
setAttributeIfChanged(circle, 'r', 22 * unit);
|
|
138
|
+
if (text)
|
|
139
|
+
setAttributeIfChanged(text, 'font-size', 12 * unit);
|
|
140
|
+
}
|
|
141
|
+
markerNavigation.sync([
|
|
142
|
+
...renderedMarkers.map((item) => item.visual.node),
|
|
143
|
+
...Array.from(clusterLayer.children),
|
|
144
|
+
]);
|
|
145
|
+
if (hadClusterFocus && document.activeElement === svg)
|
|
146
|
+
markerNavigation.recover();
|
|
147
|
+
return { markers: renderedMarkers, clusters: renderedClusters };
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
draw,
|
|
151
|
+
invalidate,
|
|
152
|
+
destroy() {
|
|
153
|
+
clusterEvents.abort();
|
|
154
|
+
clusterCache = undefined;
|
|
155
|
+
renderedMarkers = [];
|
|
156
|
+
renderedClusters = [];
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -126,7 +126,7 @@ export interface MapCamera {
|
|
|
126
126
|
fit(geometry: Geometry, options?: CameraOptions & {
|
|
127
127
|
padding?: number | MapPadding;
|
|
128
128
|
}): void;
|
|
129
|
-
/** Delta in
|
|
129
|
+
/** Delta in screen pixels, converted using the mounted canvas width. */
|
|
130
130
|
pan(x: number, y: number, options?: CameraOptions): void;
|
|
131
131
|
zoom(factor: number, options?: CameraOptions): void;
|
|
132
132
|
reset(options?: CameraOptions): void;
|
|
@@ -143,7 +143,7 @@ export interface MapController {
|
|
|
143
143
|
getResolvedConfiguration(): ResolvedMapConfiguration;
|
|
144
144
|
getCapabilities(): MapCapabilities;
|
|
145
145
|
on<K extends keyof MapEvents>(type: K, listener: (detail: MapEvents[K]) => void): () => void;
|
|
146
|
-
/** Reconcile stable IDs;
|
|
146
|
+
/** Reconcile stable IDs; preserve existing nodes and focus. Pin visuals allocate on first visibility/selection. */
|
|
147
147
|
setMarkers(markers: readonly MapMarker[]): void;
|
|
148
148
|
setOverlays(overlays: readonly MapOverlay[]): void;
|
|
149
149
|
/** False for missing IDs or a source/year other than the current geography. */
|
package/dist/src/dom.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export declare function element<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, className?: string): HTMLElementTagNameMap[K];
|
|
2
2
|
export declare function svgElement<K extends keyof SVGElementTagNameMap>(tag: K, attributes?: Record<string, string | number>): SVGElementTagNameMap[K];
|
|
3
|
+
/** Avoid invalidating SVG layout for attributes whose values have not changed. */
|
|
4
|
+
export declare function setAttributeIfChanged(node: Element, name: string, value: string | number): void;
|
package/dist/src/dom.js
CHANGED
|
@@ -12,3 +12,9 @@ export function svgElement(tag, attributes = {}) {
|
|
|
12
12
|
node.setAttribute(key, String(value));
|
|
13
13
|
return node;
|
|
14
14
|
}
|
|
15
|
+
/** Avoid invalidating SVG layout for attributes whose values have not changed. */
|
|
16
|
+
export function setAttributeIfChanged(node, name, value) {
|
|
17
|
+
const text = String(value);
|
|
18
|
+
if (node.getAttribute(name) !== text)
|
|
19
|
+
node.setAttribute(name, text);
|
|
20
|
+
}
|