@kahwee/sf-map-svg 4.0.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,11 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 4.0.1 — 2026-09-28
8
+
9
+ - Reconcile marker updates by ID, preserving retained SVG nodes, picker options, keyboard focus, and entrance animations. Identical ordered updates no longer mutate the DOM; only new IDs animate in and removed markers cancel their entrances.
10
+ - Advance animated cameras and dependent label, marker, and cluster layout in one shared browser frame, with regression coverage for coalescing, cancellation, reentrant updates, and destruction.
11
+
7
12
  ## 4.0.0 — 2026-09-28
8
13
 
9
14
  - Breaking: require Node 24 or newer for server rendering and development. Validate the minimum supported major alongside Node 26 in CI; browser runtime requirements are unchanged.
package/README.md CHANGED
@@ -58,6 +58,8 @@ Construction options include `mode` (`basemap`, `neighborhoods`, or `districts`)
58
58
 
59
59
  The controller also supports marker, neighborhood, and district selection; district year and style changes; source and mode changes; labels and touch navigation; screen projection; and typed `markerchange`, `neighborhoodchange`, `districtchange`, `districthover`, `districtactivate`, `districtyearchange`, `overlayactivate`, `clusteractivate`, `viewportchange`, and `mapresize` events. `destroy()` releases browser resources; operations after destruction throw.
60
60
 
61
+ `setMarkers()` reconciles by stable marker `id`: retained markers keep their DOM nodes, focus, and in-progress entrance animations; only new IDs animate in. Identical ordered marker updates are a visual no-op. Camera state and `viewportchange` events remain synchronous, while animated camera steps and their dependent label/marker layout commit in the same browser frame.
62
+
61
63
  For a small guide, import `guideMapData` from `/guide/data` and pass it to `createMap`. The optional `/guide` entry also provides `createGuideMap`, `mountGuideMap`, and detailed-data loading for existing guide layouts. `/transit` provides the standalone schematic transit animation. See [examples](docs/EXAMPLES.md) and [consumer integration](docs/consumer-integration.md).
62
64
 
63
65
  For the guide preset with the same controller API, use `createGuideController(options)` from `/guide` (or `/guide/map`). It accepts grouped `MapOptions` and returns `MapController`; `mountGuideController(shell, options)` enhances a `createGuideShell()` container. Both use the lightweight guide geography. Existing `createGuideMap` and `mountGuideMap` calls retain their element-based API.
@@ -75,6 +75,7 @@ export interface MapController {
75
75
  configure(patch: MapConfiguration): void;
76
76
  getConfiguration(): MapConfigurationSnapshot;
77
77
  on<K extends keyof MapEvents>(type: K, listener: (detail: MapEvents[K]) => void): () => void;
78
+ /** Reconcile stable IDs; retained markers preserve nodes, focus, and entrance animations. */
78
79
  setMarkers(markers: readonly MapMarker[]): void;
79
80
  setOverlays(overlays: readonly MapOverlay[]): void;
80
81
  selectMarker(id: string | null, options?: CameraOptions & {
@@ -6,6 +6,7 @@ import { createDistrictTransition } from './district-transition.js';
6
6
  import { element, svgElement } from './dom.js';
7
7
  import { fitBounds, interiorAnchor, projectedBounds } from './explorer-layout.js';
8
8
  import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from './features.js';
9
+ import { createFrameScheduler } from './frame-scheduler.js';
9
10
  import { geometryPath } from './geometry.js';
10
11
  import { createLabelRenderer } from './label-renderer.js';
11
12
  import { createSFMapWithData, districtColors, getLayerPathsWithData } from './map-core.js';
@@ -504,7 +505,11 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
504
505
  let items = [];
505
506
  let destroyed = false;
506
507
  let initialized = false;
507
- let frame = 0;
508
+ const frames = createFrameScheduler({
509
+ request: (callback) => requestAnimationFrame(callback),
510
+ cancel: (id) => cancelAnimationFrame(id),
511
+ render: drawLabels,
512
+ });
508
513
  const markerRenderer = createMarkerLayer(markerLayer);
509
514
  const cancelEntrances = markerRenderer.cancelEntrances;
510
515
  const camera = createCamera({
@@ -512,8 +517,8 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
512
517
  write: setView,
513
518
  duration: () => (initialized && features.motion ? features.motion.duration : 0),
514
519
  reduced: () => reducedMotion.matches,
515
- request: (callback) => requestAnimationFrame(callback),
516
- cancel: (id) => cancelAnimationFrame(id),
520
+ request: frames.request,
521
+ cancel: frames.cancel,
517
522
  now: () => performance.now(),
518
523
  });
519
524
  const stopAnimation = camera.stop;
@@ -798,8 +803,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
798
803
  function scheduleLabels() {
799
804
  if (destroyed)
800
805
  return;
801
- cancelAnimationFrame(frame);
802
- frame = requestAnimationFrame(drawLabels);
806
+ frames.invalidate();
803
807
  }
804
808
  function drawLabels() {
805
809
  if (destroyed)
@@ -1274,10 +1278,16 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1274
1278
  markerSelect.value = id ?? '';
1275
1279
  for (const entry of markerItems) {
1276
1280
  const active = entry.marker.id === id;
1277
- entry.node.setAttribute('aria-pressed', String(active));
1278
- entry.ring.setAttribute('display', active && features.selectedMarkerRing ? 'inline' : 'none');
1279
- entry.dot.setAttribute('fill', active ? selectedMarkerColor : (entry.marker.color ?? markerColor));
1280
- if (active)
1281
+ const pressed = String(active);
1282
+ const ring = active && features.selectedMarkerRing ? 'inline' : 'none';
1283
+ const fill = active ? selectedMarkerColor : (entry.marker.color ?? markerColor);
1284
+ if (entry.node.getAttribute('aria-pressed') !== pressed)
1285
+ entry.node.setAttribute('aria-pressed', pressed);
1286
+ if (entry.ring.getAttribute('display') !== ring)
1287
+ entry.ring.setAttribute('display', ring);
1288
+ if (entry.dot.getAttribute('fill') !== fill)
1289
+ entry.dot.setAttribute('fill', fill);
1290
+ if (active && markerLayer.lastChild !== entry.node)
1281
1291
  markerLayer.append(entry.node);
1282
1292
  }
1283
1293
  scheduleLabels();
@@ -1294,41 +1304,78 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1294
1304
  if (destroyed)
1295
1305
  return;
1296
1306
  validateMarkers(markers);
1307
+ const existing = new Map(markerItems.map((item) => [item.marker.id, item]));
1297
1308
  const ids = new Set();
1298
1309
  const next = markers.map((marker) => {
1299
1310
  ids.add(marker.id);
1300
- return { marker: { ...marker }, point: map.project([marker.lng, marker.lat]) };
1311
+ const previous = existing.get(marker.id);
1312
+ const point = previous?.marker.lng === marker.lng && previous.marker.lat === marker.lat
1313
+ ? previous.point
1314
+ : map.project([marker.lng, marker.lat]);
1315
+ return { marker: { ...marker }, point };
1301
1316
  });
1317
+ const nextSelection = selectedMarker && ids.has(selectedMarker)
1318
+ ? selectedMarker
1319
+ : (markers.find((marker) => marker.selected)?.id ?? null);
1320
+ const keys = ['id', 'lng', 'lat', 'label', 'selected', 'color', 'radius'];
1321
+ if (markerSelect.options.length > 0 &&
1322
+ nextSelection === selectedMarker &&
1323
+ next.length === markerItems.length &&
1324
+ next.every(({ marker }, index) => keys.every((key) => marker[key] === markerItems[index].marker[key])))
1325
+ return;
1302
1326
  const focusedMarker = markerItems.find((item) => item.node === document.activeElement)?.marker
1303
1327
  .id;
1304
1328
  invalidateClusters();
1305
- const previousIds = new Set(markerItems.map((item) => item.marker.id));
1306
- markerRenderer.clear();
1307
- const previous = selectedMarker;
1308
- markerSelect.replaceChildren(element('option', 'No marker selected'));
1309
- markerSelect.options[0].value = '';
1329
+ const options = new Map([...markerSelect.options].map((option) => [option.value, option]));
1330
+ for (const item of markerItems) {
1331
+ if (!ids.has(item.marker.id)) {
1332
+ markerRenderer.remove(item);
1333
+ options.get(item.marker.id)?.remove();
1334
+ }
1335
+ }
1336
+ if (!options.has('')) {
1337
+ const empty = element('option', 'No marker selected');
1338
+ empty.value = '';
1339
+ markerSelect.prepend(empty);
1340
+ }
1310
1341
  markerItems = next.map(({ marker, point }, index) => {
1311
- const item = markerRenderer.add(marker, point, index, {
1312
- features,
1313
- markerColor,
1314
- selectedMarkerColor,
1315
- reducedMotion: reducedMotion.matches,
1316
- enter: !previousIds.has(marker.id),
1317
- });
1318
- const option = element('option', marker.label ?? marker.id);
1319
- option.value = marker.id;
1320
- markerSelect.append(option);
1342
+ const previous = existing.get(marker.id);
1343
+ const item = previous ??
1344
+ markerRenderer.add(marker, point, index, {
1345
+ features,
1346
+ markerColor,
1347
+ selectedMarkerColor,
1348
+ reducedMotion: reducedMotion.matches,
1349
+ enter: true,
1350
+ });
1351
+ if (previous)
1352
+ markerRenderer.update(item, marker, point, index);
1353
+ const option = options.get(marker.id) ?? element('option');
1354
+ const label = marker.label ?? marker.id;
1355
+ if (option.textContent !== label)
1356
+ option.textContent = label;
1357
+ if (option.value !== marker.id)
1358
+ option.value = marker.id;
1359
+ if (markerSelect.children[index + 1] !== option)
1360
+ markerSelect.insertBefore(option, markerSelect.children[index + 1] ?? null);
1321
1361
  return item;
1322
1362
  });
1363
+ // Match input order without detaching unchanged nodes; selected markers remain on top.
1364
+ const ordered = markerItems.filter((item) => item.marker.id !== nextSelection);
1365
+ const selectedItem = markerItems.find((item) => item.marker.id === nextSelection);
1366
+ if (selectedItem)
1367
+ ordered.push(selectedItem);
1368
+ ordered.forEach((item, index) => {
1369
+ if (markerLayer.children[index] !== item.node)
1370
+ markerLayer.insertBefore(item.node, markerLayer.children[index] ?? null);
1371
+ });
1323
1372
  markerLabel.hidden = !markerItems.length || controls.markerPicker === false;
1324
1373
  if (markerLabel.firstChild)
1325
1374
  markerLabel.firstChild.textContent = `${strings.chooseMarker ?? 'Choose marker'} (${markerItems.length})`;
1326
1375
  syncFeatureControls();
1327
- const nextSelection = previous && ids.has(previous)
1328
- ? previous
1329
- : (markers.find((marker) => marker.selected)?.id ?? null);
1376
+ const revision = markerRevision + 1;
1330
1377
  selectMarker(nextSelection, { fit: false });
1331
- if (focusedMarker)
1378
+ if (!destroyed && markerRevision === revision && focusedMarker)
1332
1379
  (markerItems.find((item) => item.marker.id === focusedMarker)?.node ?? svg).focus({
1333
1380
  preventScroll: true,
1334
1381
  });
@@ -1598,7 +1645,7 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
1598
1645
  clusterEvents.abort();
1599
1646
  canvas.style.touchAction = 'pan-y pinch-zoom';
1600
1647
  observer?.disconnect();
1601
- cancelAnimationFrame(frame);
1648
+ frames.destroy();
1602
1649
  labelRenderer.clear();
1603
1650
  releaseDownloads();
1604
1651
  },
@@ -0,0 +1,11 @@
1
+ /** One browser frame for camera advancement and the dependent screen-space rendering. */
2
+ export declare function createFrameScheduler({ request, cancel, render, }: {
3
+ request: (callback: FrameRequestCallback) => number;
4
+ cancel: (id: number) => void;
5
+ render: () => void;
6
+ }): {
7
+ request(callback: FrameRequestCallback): number;
8
+ cancel(id: number): void;
9
+ invalidate(): void;
10
+ destroy(): void;
11
+ };
@@ -0,0 +1,64 @@
1
+ /** One browser frame for camera advancement and the dependent screen-space rendering. */
2
+ export function createFrameScheduler({ request, cancel, render, }) {
3
+ let frame;
4
+ let pending;
5
+ let sequence = 0;
6
+ let dirty = false;
7
+ let flushing = false;
8
+ let destroyed = false;
9
+ function schedule() {
10
+ if (!destroyed && !flushing && frame === undefined && (pending || dirty))
11
+ frame = request(flush);
12
+ }
13
+ function flush(time) {
14
+ frame = undefined;
15
+ if (destroyed)
16
+ return;
17
+ flushing = true;
18
+ const next = pending;
19
+ pending = undefined;
20
+ try {
21
+ next?.callback(time);
22
+ if (!destroyed && dirty) {
23
+ dirty = false;
24
+ render();
25
+ }
26
+ }
27
+ finally {
28
+ flushing = false;
29
+ schedule();
30
+ }
31
+ }
32
+ return {
33
+ request(callback) {
34
+ const id = ++sequence;
35
+ if (!destroyed) {
36
+ pending = { id, callback };
37
+ schedule();
38
+ }
39
+ return id;
40
+ },
41
+ cancel(id) {
42
+ if (pending?.id === id)
43
+ pending = undefined;
44
+ if (!pending && !dirty && frame !== undefined) {
45
+ cancel(frame);
46
+ frame = undefined;
47
+ }
48
+ },
49
+ invalidate() {
50
+ if (destroyed)
51
+ return;
52
+ dirty = true;
53
+ schedule();
54
+ },
55
+ destroy() {
56
+ destroyed = true;
57
+ if (frame !== undefined)
58
+ cancel(frame);
59
+ frame = undefined;
60
+ pending = undefined;
61
+ dirty = false;
62
+ },
63
+ };
64
+ }
@@ -7,11 +7,14 @@ export interface MarkerItem {
7
7
  dot: SVGCircleElement;
8
8
  hit: SVGCircleElement;
9
9
  ring: SVGCircleElement;
10
+ title: SVGTitleElement;
10
11
  }
11
12
  /** Own marker visuals and entrances; selection/events remain with the controller. */
12
13
  export declare function createMarkerLayer(layer: SVGGElement): {
13
14
  cancelEntrances: () => void;
14
15
  clear(): void;
16
+ remove(item: MarkerItem): void;
17
+ update(item: MarkerItem, marker: MapMarker, point: [number, number], index: number): void;
15
18
  add(marker: MapMarker, point: [number, number], index: number, { features, markerColor, selectedMarkerColor, reducedMotion, enter, }: {
16
19
  features: ReturnType<typeof normalizeFeatures>;
17
20
  markerColor: string;
@@ -1,9 +1,9 @@
1
1
  import { svgElement } from './dom.js';
2
2
  /** Own marker visuals and entrances; selection/events remain with the controller. */
3
3
  export function createMarkerLayer(layer) {
4
- const animations = new Set();
4
+ const animations = new Map();
5
5
  function cancelEntrances() {
6
- for (const animation of animations)
6
+ for (const animation of animations.values())
7
7
  animation.cancel();
8
8
  animations.clear();
9
9
  }
@@ -13,6 +13,24 @@ export function createMarkerLayer(layer) {
13
13
  cancelEntrances();
14
14
  layer.replaceChildren();
15
15
  },
16
+ remove(item) {
17
+ animations.get(item.node)?.cancel();
18
+ animations.delete(item.node);
19
+ item.node.remove();
20
+ },
21
+ update(item, marker, point, index) {
22
+ if (item.point[0] !== point[0] || item.point[1] !== point[1])
23
+ item.node.setAttribute('transform', `translate(${point[0]},${point[1]})`);
24
+ const label = marker.label ?? marker.id;
25
+ if (item.title.textContent !== label) {
26
+ item.title.textContent = label;
27
+ item.node.setAttribute('aria-label', label);
28
+ }
29
+ if (item.node.style.getPropertyValue('--sf-marker-index') !== String(index))
30
+ item.node.style.setProperty('--sf-marker-index', String(index));
31
+ item.marker = marker;
32
+ item.point = point;
33
+ },
16
34
  add(marker, point, index, { features, markerColor, selectedMarkerColor, reducedMotion, enter, }) {
17
35
  const node = svgElement('g', {
18
36
  transform: `translate(${point[0]},${point[1]})`,
@@ -54,11 +72,15 @@ export function createMarkerLayer(layer) {
54
72
  easing: 'cubic-bezier(.2,.8,.2,1)',
55
73
  fill: 'backwards',
56
74
  });
57
- animations.add(animation);
58
- animation.finished.then(() => animations.delete(animation), () => animations.delete(animation));
75
+ animations.set(node, animation);
76
+ const release = () => {
77
+ if (animations.get(node) === animation)
78
+ animations.delete(node);
79
+ };
80
+ animation.finished.then(release, release);
59
81
  }
60
82
  layer.append(node);
61
- return { marker, point, node, dot, hit, ring };
83
+ return { marker, point, node, dot, hit, ring, title };
62
84
  },
63
85
  };
64
86
  }
@@ -207,6 +207,7 @@ export interface NeighborhoodExplorerElement extends HTMLElement {
207
207
  fitGeometry(geometry: Geometry, padding?: number | MapPadding, options?: CameraOptions): void;
208
208
  /** Explicitly engage map touch gestures; false restores page gestures. */
209
209
  setTouchNavigation(enabled: boolean): void;
210
+ /** Reconcile stable IDs; retained markers preserve nodes, focus, and entrance animations. */
210
211
  setMarkers(markers: readonly MapMarker[]): void;
211
212
  setOverlays(overlays: readonly MapOverlay[]): void;
212
213
  selectMarker(id: string | null, options?: {
package/docs/api-audit.md CHANGED
@@ -24,6 +24,8 @@ unbounded geographic data can never fail.
24
24
  | District style callbacks | Prepare and copy every style before committing. Hover/selection reuse the results; call `setDistrictStyle()` to refresh changed external data. Reentrant updates or destruction supersede pending work. |
25
25
  | Rapid district year changes | Both outgoing layers are tracked, inert, and removed on interruption, completion, reduced-motion changes, or destruction. Zero duration creates no transition copies. |
26
26
  | Label metrics and camera movement | Reuse screen-space text measurements and nodes across frames. Font loading invalidates measurements; hidden or removed candidates do not remain in the visible layer. |
27
+ | Marker replacement | Reconcile stable IDs, preserve retained nodes/picker options and entrance animations, cancel removed entrances, and restore focus after reordering. Identical ordered values do not mutate the DOM or restart work. |
28
+ | Camera and label scheduling | A shared frame advances the camera before rendering dependent labels, marker sizes, and clusters. State/events remain synchronous; cancellation preserves pending layout, and destruction cancels both kinds of work. |
27
29
  | Guide controllers and compatibility factories | `createGuideController`/`mountGuideController` share grouped options and lifecycle with `createMap`. Existing element factories retain flat options and their return types. |
28
30
  | Layer hidden and bundle cost | Visibility never unloads imported geography. Use narrow entrypoints or data injection to save bytes. |
29
31
 
@@ -4,9 +4,9 @@ Generated 2026-09-28 by `pnpm report:guide` with Vite production minification an
4
4
 
5
5
  | Entry | Initial JS, raw | Initial JS, gzip | Explicit detail JS, gzip |
6
6
  | --- | ---: | ---: | ---: |
7
- | v3 root (explicit data) | 87.4 KB | 25.5 KB | — |
7
+ | v3 root (explicit data) | 89.7 KB | 26.3 KB | — |
8
8
  | v3 static renderer | 15.3 KB | 5.0 KB | — |
9
- | Guide preset | 477.1 KB | 114.1 KB | 473.7 KB |
9
+ | Guide preset | 479.5 KB | 114.9 KB | 473.7 KB |
10
10
 
11
11
  **500 KB target:** met.
12
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kahwee/sf-map-svg",
3
- "version": "4.0.0",
3
+ "version": "4.0.1",
4
4
  "description": "Offline SVG maps of San Francisco with district boundaries, parks, landmarks, and BART stations.",
5
5
  "type": "module",
6
6
  "main": "./dist/src/api.js",