@bicharts/chart-host 0.6.89 → 0.6.91

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/README.md CHANGED
@@ -269,9 +269,11 @@ cue, a hierarchy level) is never flattened by a fix meant for another chart.
269
269
 
270
270
  **How far the rest dims, per chart.** `options.dimOpacity` (a share of full opacity, `0.01`-`1`;
271
271
  `DIM_OPACITY_MIN` is the floor) sets the level for one host without writing CSS, and it is live
272
- through `setOptions`. The host writes it as `--lch-dim-opacity` on its own container and removes
273
- only what it wrote, so with the option absent nothing is written and the CSS above decides: the
274
- stylesheet's `0.25`, or whatever your page sets on the variable.
272
+ through `setOptions`. It takes a number or a numeric string; a value above 1 is clamped to 1 (no
273
+ dim), never read as a percentage, and anything else is ignored. The host writes it as
274
+ `--lch-dim-opacity` on its own container and, when the option goes away or the host is destroyed,
275
+ puts back the value your page had set inline there, so with the option absent nothing is written
276
+ and the CSS above decides: the stylesheet's `0.25`, or whatever your page sets on the variable.
275
277
 
276
278
  ```js
277
279
  createChartHost(el, { code, data, options: { dimOpacity: 0.15 } });
@@ -410,15 +412,41 @@ const zoomHost: ZoomMemoryHost = {
410
412
  persistZoom: json => localStorage.setItem("chart-zoom", json), // "" forgets
411
413
  log: () => {},
412
414
  };
413
- const zoomMemory = createZoomMemoryHook(zoomHost); // once per container, reused by every host on it
415
+ const zoomMemory = createZoomMemoryHook(zoomHost); // once per chart, reused by every host built for it
414
416
  const host = createChartHost(el, { code, data, d3, zoomMemory });
415
417
  ```
416
418
 
417
419
  The view survives every redraw in the session whatever the preference says, and a close and reopen
418
420
  only while `rememberAcrossSessions()` is true. It is keyed by the chart's code and the viewport size
419
421
  (to the nearest 32 px), so a regenerated chart or a real resize opens at full extent instead of
420
- somewhere arbitrary. A chart with several zoomable panels is left alone. In React, pass the hook as
421
- the `zoomMemory` prop of `<BicChart>`.
422
+ somewhere arbitrary. A chart with several zoomable panels is left alone. The `ZoomMemoryHost` is the
423
+ memory: keep one per chart for as long as the chart is on the page, and hand its hook to every host
424
+ built for that chart, on the same element or, after a remount, a new one (a destroyed host releases
425
+ the hook's watcher, so the next one can take it). In React, pass the hook as the `zoomMemory` prop of
426
+ `<BicChart>`, built once (`zoomHost` as above, kept outside the render):
427
+
428
+ ```tsx
429
+ const [zoomMemory] = useState(() => createZoomMemoryHook(zoomHost)); // once, however often this renders
430
+ return <BicChart code={code} data={data} d3={d3} zoomMemory={zoomMemory} />;
431
+ ```
432
+
433
+ A hook that arrives after mount, or goes away, rebuilds the chart's host; swapping one hook for another
434
+ on a mounted `<BicChart>` rebuilds nothing, and applies at the next rebuild (a new `code`, say) or at
435
+ once if you remount it with a `key`, so a hook built inline in the props costs no rebuild.
436
+
437
+ **A scrolled chart** keeps its place with no setup. A chart taller than its frame scrolls, and every
438
+ redraw empties the container, which used to send the reader back to the top. The host now parks the
439
+ scroll offset on the container before it clears it, and the fit puts it back once the new layout has
440
+ decided whether it still scrolls. The offset is clamped to what the new layout can scroll, and only
441
+ restored on an axis that still scrolls. That covers a resize, a filter, a restyle, and a new host
442
+ created on the same element. The memory is keyed by the chart (its `code`, or the `renderFn`'s
443
+ source text), so a different chart opens at the top, while a `renderFn` closure rebuilt on every draw
444
+ is still the same chart. It lives on the element for the session
445
+ only, and nothing is persisted. A page that manages the container's scrolling itself passes
446
+ `restoreScroll: false` (the `restoreScroll` prop of `<BicChart>`), and then nothing is remembered or
447
+ restored. A host that clears and fits its container on its own can use the same pieces:
448
+ `rememberScroll` before the clear, `takeScroll` after the render, and `fitRenderedChart`'s
449
+ `restoreScroll` option (or the pure `planScrollRestore`).
422
450
 
423
451
  ## Keeping a drawn chart readable inside its frame
424
452
 
package/dist/adapters.mjs CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  censusHitBands,
4
4
  ensureCrossfilterHitTargets,
5
5
  hitBandFlag
6
- } from "./chunk-XNS34AMM.mjs";
6
+ } from "./chunk-PDVEIW42.mjs";
7
7
  import {
8
8
  clickWasDrag,
9
9
  collectD3GlyphCenters,
@@ -12,13 +12,13 @@ import {
12
12
  markKeyOf,
13
13
  rowIdxsFromMark,
14
14
  selectionRuleOf
15
- } from "./chunk-DJ5IJV57.mjs";
15
+ } from "./chunk-VEVBXK44.mjs";
16
16
  import {
17
17
  AXIS_FILTER_CLASS,
18
18
  LEGEND_MARK_CLASS,
19
19
  MARK_CLASS,
20
20
  ROW_IDX_ATTR
21
- } from "./chunk-YDHXOIAI.mjs";
21
+ } from "./chunk-45VILLJT.mjs";
22
22
  import "./chunk-GHODWOAL.mjs";
23
23
 
24
24
  // src/adapters/types.ts
@@ -19,6 +19,7 @@ var CONTAINER_SLOT_ANIM_STOP = "__llmAnimStop";
19
19
  var CONTAINER_SLOT_XF_CLEAR = "__llmXfClear";
20
20
  var CONTAINER_SLOT_INITIAL_XF_MARK = "__llmInitialXfMark";
21
21
  var CONTAINER_SLOT_UI_STATE = "__lchUiState";
22
+ var CONTAINER_SLOT_SCROLL = "__lchScroll";
22
23
  var CONTAINER_SLOT_CLICKED_MARKS = "__lchClickedMarks";
23
24
  var VIEW_ONLY_UI_STATE_KEYS = Object.freeze(["camera", "llmZoom"]);
24
25
  function withoutViewOnlyKeys(bag) {
@@ -89,6 +90,7 @@ export {
89
90
  CONTAINER_SLOT_XF_CLEAR,
90
91
  CONTAINER_SLOT_INITIAL_XF_MARK,
91
92
  CONTAINER_SLOT_UI_STATE,
93
+ CONTAINER_SLOT_SCROLL,
92
94
  CONTAINER_SLOT_CLICKED_MARKS,
93
95
  VIEW_ONLY_UI_STATE_KEYS,
94
96
  withoutViewOnlyKeys,
@@ -1,7 +1,7 @@
1
1
  /*! @bicharts/chart-host — Apache-2.0. Bundled reference data: GeoNames (https://www.geonames.org/) CC BY 4.0; Natural Earth and US Census/TIGER (public domain). Full text: NOTICE in this package. */
2
2
  import {
3
3
  LASSO_KNOB_KEY
4
- } from "./chunk-OG6RR2AJ.mjs";
4
+ } from "./chunk-QHMLMF4L.mjs";
5
5
 
6
6
  // src/filterScope.ts
7
7
  function viewToken(view) {
@@ -1,7 +1,8 @@
1
1
  /*! @bicharts/chart-host — Apache-2.0. Bundled reference data: GeoNames (https://www.geonames.org/) CC BY 4.0; Natural Earth and US Census/TIGER (public domain). Full text: NOTICE in this package. */
2
2
  import {
3
+ CONTAINER_SLOT_SCROLL,
3
4
  SNAPSHOT_SVG_ATTR
4
- } from "./chunk-YDHXOIAI.mjs";
5
+ } from "./chunk-45VILLJT.mjs";
5
6
 
6
7
  // src/fit.ts
7
8
  var SCROLL_SLACK_PX = 8;
@@ -104,6 +105,15 @@ function axisPinPlacement(band, scrollTop, viewportH) {
104
105
  if (band.bottom > scrollTop + viewportH) return "bottom";
105
106
  return null;
106
107
  }
108
+ var nonNegative = (n) => typeof n === "number" && isFinite(n) && n > 0 ? n : 0;
109
+ function planScrollRestore(saved, key, m) {
110
+ if (!saved || key === void 0 || key === null || saved.key !== key) return { x: 0, y: 0 };
111
+ const room = (content, view) => Math.max(0, nonNegative(content) - nonNegative(view));
112
+ return {
113
+ x: m.overflowX === "auto" ? Math.min(nonNegative(saved.x), room(m.scrollWidth, m.clientWidth)) : 0,
114
+ y: m.overflowY === "auto" ? Math.min(nonNegative(saved.y), room(m.scrollHeight, m.clientHeight)) : 0
115
+ };
116
+ }
107
117
 
108
118
  // src/fitDom.ts
109
119
  var FIT_WALK_CAP = 8e3;
@@ -256,6 +266,28 @@ function unpinScrolledAxis(container) {
256
266
  } catch {
257
267
  }
258
268
  }
269
+ function rememberScroll(container, key) {
270
+ if (!container) return;
271
+ try {
272
+ const slot = container;
273
+ if (slot[CONTAINER_SLOT_SCROLL]) return;
274
+ if (container.querySelector(".llm-mm-scroll")) return;
275
+ const x = container.scrollLeft, y = container.scrollTop;
276
+ if (x > 0 || y > 0) slot[CONTAINER_SLOT_SCROLL] = { key, x, y };
277
+ } catch {
278
+ }
279
+ }
280
+ function takeScroll(container) {
281
+ if (!container) return null;
282
+ try {
283
+ const slot = container;
284
+ const m = slot[CONTAINER_SLOT_SCROLL] ?? null;
285
+ delete slot[CONTAINER_SLOT_SCROLL];
286
+ return m;
287
+ } catch {
288
+ return null;
289
+ }
290
+ }
259
291
  function textHidden(t, view) {
260
292
  if (t.getAttribute("display") === "none" || t.getAttribute("visibility") === "hidden") return true;
261
293
  try {
@@ -476,7 +508,8 @@ function fitRenderedChart(container, opts = {}) {
476
508
  overflowX: "hidden",
477
509
  overflowY: "hidden",
478
510
  reading: null,
479
- axisPin: noPin("no-container")
511
+ axisPin: noPin("no-container"),
512
+ scrolledTo: null
480
513
  };
481
514
  if (!container) return result;
482
515
  try {
@@ -537,6 +570,19 @@ function fitRenderedChart(container, opts = {}) {
537
570
  if (opts.applyOverflow !== false) {
538
571
  container.style.overflowX = fit.overflowX;
539
572
  container.style.overflowY = fit.overflowY;
573
+ if (opts.restoreScroll) {
574
+ const to = planScrollRestore(opts.restoreScroll.memory, opts.restoreScroll.key, {
575
+ scrollWidth: container.scrollWidth,
576
+ scrollHeight: container.scrollHeight,
577
+ clientWidth: container.clientWidth,
578
+ clientHeight: container.clientHeight,
579
+ overflowX: fit.overflowX,
580
+ overflowY: fit.overflowY
581
+ });
582
+ if (container.scrollLeft !== to.x) container.scrollLeft = to.x;
583
+ if (container.scrollTop !== to.y) container.scrollTop = to.y;
584
+ result.scrolledTo = to;
585
+ }
540
586
  if (fit.overflowY !== "auto") {
541
587
  unpinScrolledAxis(container);
542
588
  result.axisPin = noPin("not-scrolling");
@@ -571,12 +617,15 @@ export {
571
617
  labelBand,
572
618
  planAxisPin,
573
619
  axisPinPlacement,
620
+ planScrollRestore,
574
621
  ctmScaleOf,
575
622
  svgInkReach,
576
623
  clipWindowOf,
577
624
  measureContainerBoxes,
578
625
  fitReadingFor,
579
626
  unpinScrolledAxis,
627
+ rememberScroll,
628
+ takeScroll,
580
629
  pinScrolledAxis,
581
630
  fitRenderedChart
582
631
  };
@@ -4,7 +4,7 @@ import {
4
4
  LEGEND_MARK_CLASS,
5
5
  MARK_CLASS,
6
6
  ROW_IDX_ATTR
7
- } from "./chunk-YDHXOIAI.mjs";
7
+ } from "./chunk-45VILLJT.mjs";
8
8
 
9
9
  // src/hitTargets.ts
10
10
  var SVG_NS = "http://www.w3.org/2000/svg";
@@ -4,7 +4,7 @@ import {
4
4
  gestureWasDrag,
5
5
  isInsideControl,
6
6
  rowIdxsFromMark
7
- } from "./chunk-DJ5IJV57.mjs";
7
+ } from "./chunk-VEVBXK44.mjs";
8
8
  import {
9
9
  AXIS_FILTER_CLASS,
10
10
  CONTROL_CLASS,
@@ -12,7 +12,7 @@ import {
12
12
  MARK_CLASS,
13
13
  ROW_IDX_ATTR,
14
14
  SNAPSHOT_SVG_ATTR
15
- } from "./chunk-YDHXOIAI.mjs";
15
+ } from "./chunk-45VILLJT.mjs";
16
16
 
17
17
  // src/lasso.ts
18
18
  var LASSO_ROW_CAP = 5e3;
@@ -2,11 +2,11 @@
2
2
  import {
3
3
  LASSO_OUTLINE_CLASS,
4
4
  installLasso
5
- } from "./chunk-OG6RR2AJ.mjs";
5
+ } from "./chunk-QHMLMF4L.mjs";
6
6
  import {
7
7
  censusHitBands,
8
8
  ensureCrossfilterHitTargets
9
- } from "./chunk-XNS34AMM.mjs";
9
+ } from "./chunk-PDVEIW42.mjs";
10
10
  import {
11
11
  clearPress,
12
12
  clearSelectionGlyphs,
@@ -23,12 +23,14 @@ import {
23
23
  selectionRuleOf,
24
24
  trackPress,
25
25
  validRowSet
26
- } from "./chunk-DJ5IJV57.mjs";
26
+ } from "./chunk-VEVBXK44.mjs";
27
27
  import {
28
28
  clipWindowOf,
29
29
  fitRenderedChart,
30
+ rememberScroll,
31
+ takeScroll,
30
32
  unpinScrolledAxis
31
- } from "./chunk-QHIPRAZK.mjs";
33
+ } from "./chunk-NNLBFIWU.mjs";
32
34
  import {
33
35
  Fn,
34
36
  J3,
@@ -82,7 +84,7 @@ import {
82
84
  XFILTER_REFRESH_EVENT,
83
85
  chartOwnsTimeline,
84
86
  periodTickSuppressesFeedback
85
- } from "./chunk-YDHXOIAI.mjs";
87
+ } from "./chunk-45VILLJT.mjs";
86
88
 
87
89
  // src/labelContrast.ts
88
90
  function parseRGBA(c) {
@@ -612,10 +614,14 @@ function resolveOptions(p) {
612
614
  cultureCode: p.cultureCode,
613
615
  allowTooltips: p.allowTooltips,
614
616
  noDataText: p.noDataText,
615
- // A finite number is clamped to DIM_OPACITY_MIN-1; absent, blank or junk stays undefined so the stylesheet - and a
616
- // page's own CSS override - still decides. Never defaulted here: that would override every page's CSS.
617
+ // A finite number, or a string that is numeric once trimmed, is clamped to DIM_OPACITY_MIN-1; anything else (absent,
618
+ // blank, a boolean, an array, an object) stays undefined so the stylesheet - and a page's own CSS override - still
619
+ // decides. Not numberOr: Number() reads true as 1 and false, [] and whitespace as 0, which would dim to the floor.
620
+ // Above 1 is clamped to 1, which dims nothing; it is never reinterpreted as a percentage, so the option keeps one
621
+ // unit (a share of full opacity) for every value. Never defaulted here: that would override every page's CSS.
617
622
  dimOpacity: (() => {
618
- const n = numberOr(p.dimOpacity, NaN);
623
+ const v = p.dimOpacity;
624
+ const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN;
619
625
  return Number.isFinite(n) ? clamp(n, DIM_OPACITY_MIN, 1) : void 0;
620
626
  })(),
621
627
  // A recognised glyph or none: an unknown string must never reach the painter as a live mode, and "none" is
@@ -2029,6 +2035,7 @@ function compileRenderFn(code, win, doc, d3) {
2029
2035
  }
2030
2036
  var ASYNC_RENDER_TIMEOUT_MS = 15e3;
2031
2037
  var isThenable = (v) => !!v && (typeof v === "object" || typeof v === "function") && typeof v.then === "function";
2038
+ var dimVarPage = /* @__PURE__ */ new WeakMap();
2032
2039
  function createChartHost(container, config) {
2033
2040
  const doc = container.ownerDocument;
2034
2041
  const win = doc?.defaultView ?? globalThis;
@@ -2096,6 +2103,19 @@ function createChartHost(container, config) {
2096
2103
  });
2097
2104
  };
2098
2105
  let dimVarSet = false;
2106
+ const releaseDimVar = () => {
2107
+ if (!dimVarSet) return;
2108
+ dimVarSet = false;
2109
+ const page = dimVarPage.get(container);
2110
+ if (!page || --page.holders > 0) return;
2111
+ dimVarPage.delete(container);
2112
+ try {
2113
+ if (container.style?.getPropertyValue?.(DIM_OPACITY_VAR) !== page.wrote) return;
2114
+ if (page.was) container.style?.setProperty(DIM_OPACITY_VAR, page.was.value, page.was.priority);
2115
+ else container.style?.removeProperty(DIM_OPACITY_VAR);
2116
+ } catch {
2117
+ }
2118
+ };
2099
2119
  let current = null;
2100
2120
  let activeTickEl = null;
2101
2121
  let selectionIsPeriod = false;
@@ -2167,6 +2187,7 @@ function createChartHost(container, config) {
2167
2187
  }
2168
2188
  };
2169
2189
  const paintSelection = () => {
2190
+ if (destroyed) return;
2170
2191
  try {
2171
2192
  const sel = current;
2172
2193
  const active = !!sel && sel.length > 0;
@@ -2174,12 +2195,20 @@ function createChartHost(container, config) {
2174
2195
  container.classList?.[dim ? "add" : "remove"](SELECTION_ACTIVE_CLASS);
2175
2196
  const want = resolved.dimOpacity;
2176
2197
  if (typeof want === "number") {
2177
- container.style?.setProperty(DIM_OPACITY_VAR, String(want));
2178
- dimVarSet = true;
2179
- } else if (dimVarSet) {
2180
- container.style?.removeProperty(DIM_OPACITY_VAR);
2181
- dimVarSet = false;
2182
- }
2198
+ const level = String(want);
2199
+ let page = dimVarPage.get(container);
2200
+ if (!dimVarSet) {
2201
+ if (!page) {
2202
+ const value = container.style?.getPropertyValue?.(DIM_OPACITY_VAR) ?? "";
2203
+ page = { was: value ? { value, priority: container.style?.getPropertyPriority?.(DIM_OPACITY_VAR) ?? "" } : null, holders: 0, wrote: level };
2204
+ dimVarPage.set(container, page);
2205
+ }
2206
+ page.holders++;
2207
+ dimVarSet = true;
2208
+ }
2209
+ if (page) page.wrote = level;
2210
+ container.style?.setProperty(DIM_OPACITY_VAR, level);
2211
+ } else releaseDimVar();
2183
2212
  const lit = container.querySelectorAll(`.${ACTIVE_TICK_CLASS}`);
2184
2213
  for (const t of Array.from(lit)) t.classList?.remove(ACTIVE_TICK_CLASS);
2185
2214
  const tickStillDrawn = !!activeTickEl && (typeof container.contains === "function" ? container.contains(activeTickEl) : activeTickEl.isConnected !== false);
@@ -2416,6 +2445,7 @@ function createChartHost(container, config) {
2416
2445
  }
2417
2446
  };
2418
2447
  const notify = (rowIdxs, source, tick, fromChart = false) => {
2448
+ if (destroyed) return;
2419
2449
  current = rowIdxs;
2420
2450
  selectionFromChart = rowIdxs.length > 0 && fromChart;
2421
2451
  activeTickEl = rowIdxs.length ? tick ?? null : null;
@@ -2521,6 +2551,28 @@ function createChartHost(container, config) {
2521
2551
  }
2522
2552
  }
2523
2553
  };
2554
+ const releaseZoom = () => {
2555
+ if (zoomMemory) {
2556
+ try {
2557
+ zoomMemory.release?.(container);
2558
+ } catch {
2559
+ }
2560
+ }
2561
+ };
2562
+ const keepScroll = config.restoreScroll !== false;
2563
+ let chartSource;
2564
+ const scrollKey = () => {
2565
+ if (config.code) return config.code;
2566
+ if (chartSource === void 0 && typeof config.renderFn === "function") {
2567
+ try {
2568
+ chartSource = Function.prototype.toString.call(config.renderFn);
2569
+ } catch {
2570
+ return config.renderFn;
2571
+ }
2572
+ }
2573
+ return chartSource;
2574
+ };
2575
+ let hasRendered = false;
2524
2576
  const runPostRenderPasses = () => {
2525
2577
  if (zoomMemory && zoomCap) {
2526
2578
  try {
@@ -2608,9 +2660,11 @@ function createChartHost(container, config) {
2608
2660
  } catch {
2609
2661
  }
2610
2662
  }
2663
+ const scrollMem = takeScroll(container);
2611
2664
  if (fitOpts !== false) {
2612
2665
  try {
2613
- const r = fitRenderedChart(container, fitOpts === true ? {} : fitOpts);
2666
+ const base = fitOpts === true ? {} : fitOpts;
2667
+ const r = fitRenderedChart(container, keepScroll ? { ...base, restoreScroll: { memory: scrollMem, key: scrollKey() } } : base);
2614
2668
  config.onFit?.(r);
2615
2669
  } catch {
2616
2670
  }
@@ -2649,7 +2703,9 @@ function createChartHost(container, config) {
2649
2703
  followGlyphs(false);
2650
2704
  disposeLasso();
2651
2705
  flushZoom();
2706
+ if (hasRendered && keepScroll) rememberScroll(container, scrollKey());
2652
2707
  container.innerHTML = "";
2708
+ hasRendered = true;
2653
2709
  if (!renderFn) {
2654
2710
  if (!config.code) throw new Error("createChartHost: provide code or renderFn");
2655
2711
  renderFn = compileRenderFn(config.code, win, doc, chartD3);
@@ -2741,12 +2797,18 @@ function createChartHost(container, config) {
2741
2797
  );
2742
2798
  }));
2743
2799
  },
2800
+ // A DESTROYED HOST OWNS NOTHING ON THE CONTAINER any more. A host rebuilt on the same element has taken over
2801
+ // the chart's slots (its clear, its animation stop, its initial mark) and the clicked-marks record, so every
2802
+ // method below that reaches the container returns first: a stale reference to the old host (a closure, a late
2803
+ // callback) must not clear, stop or annotate the chart that is on screen now.
2744
2804
  setOptions(partial) {
2805
+ if (destroyed) return;
2745
2806
  raw = { ...raw, ...partial };
2746
2807
  resolved = resolveForHost(raw);
2747
2808
  host.render();
2748
2809
  },
2749
2810
  setData(next) {
2811
+ if (destroyed) return;
2750
2812
  data = next;
2751
2813
  host.render();
2752
2814
  },
@@ -2754,8 +2816,9 @@ function createChartHost(container, config) {
2754
2816
  return resolved;
2755
2817
  },
2756
2818
  setAnnotations(list) {
2819
+ if (destroyed) return;
2757
2820
  annotationLayer.set(list);
2758
- if (!destroyed) try {
2821
+ try {
2759
2822
  drawAnnotations();
2760
2823
  } catch {
2761
2824
  }
@@ -2774,6 +2837,7 @@ function createChartHost(container, config) {
2774
2837
  return () => subs.delete(cb);
2775
2838
  },
2776
2839
  clear() {
2840
+ if (destroyed) return;
2777
2841
  const s = container[CONTAINER_SLOT_XF_CLEAR];
2778
2842
  if (typeof s === "function") {
2779
2843
  try {
@@ -2786,6 +2850,7 @@ function createChartHost(container, config) {
2786
2850
  notify([], "host");
2787
2851
  },
2788
2852
  highlight(rowIdxs) {
2853
+ if (destroyed) return;
2789
2854
  const rows = Array.isArray(rowIdxs) ? rowIdxs.slice() : [];
2790
2855
  if (lasso && lasso.rows().length && !sameRowSet(rows, lasso.rows())) lasso.clear({ publish: false, source: "host" });
2791
2856
  notify(rows, "host");
@@ -2794,9 +2859,11 @@ function createChartHost(container, config) {
2794
2859
  return current;
2795
2860
  }
2796
2861
  },
2797
- animation: { stop: stopAnim },
2862
+ animation: { stop() {
2863
+ if (!destroyed) stopAnim();
2864
+ } },
2798
2865
  initialMark() {
2799
- return container[CONTAINER_SLOT_INITIAL_XF_MARK] ?? null;
2866
+ return destroyed ? null : container[CONTAINER_SLOT_INITIAL_XF_MARK] ?? null;
2800
2867
  },
2801
2868
  destroy() {
2802
2869
  if (destroyed) return;
@@ -2808,16 +2875,12 @@ function createChartHost(container, config) {
2808
2875
  clearPress(container);
2809
2876
  disposeLasso();
2810
2877
  subs.clear();
2811
- if (dimVarSet) {
2812
- try {
2813
- container.style?.removeProperty(DIM_OPACITY_VAR);
2814
- } catch {
2815
- }
2816
- dimVarSet = false;
2817
- }
2878
+ releaseDimVar();
2818
2879
  annotationLayer.destroy();
2880
+ if (hasRendered && keepScroll) rememberScroll(container, scrollKey());
2819
2881
  unpinScrolledAxis(container);
2820
2882
  flushZoom();
2883
+ releaseZoom();
2821
2884
  followGlyphs(false);
2822
2885
  container.innerHTML = "";
2823
2886
  if (glyphPositionWas !== null) {
@@ -1,7 +1,7 @@
1
1
  /*! @bicharts/chart-host — Apache-2.0. Bundled reference data: GeoNames (https://www.geonames.org/) CC BY 4.0; Natural Earth and US Census/TIGER (public domain). Full text: NOTICE in this package. */
2
2
  import {
3
3
  SNAPSHOT_SVG_ATTR
4
- } from "./chunk-YDHXOIAI.mjs";
4
+ } from "./chunk-45VILLJT.mjs";
5
5
 
6
6
  // src/snapshot.ts
7
7
  var DEFAULT_TIMEOUT_MS = 2e3;
@@ -8,7 +8,7 @@ import {
8
8
  SELECTION_RULE_ATTR,
9
9
  SELECTION_RULE_CLICKED_MARKS,
10
10
  XFILTER_REFRESH_EVENT
11
- } from "./chunk-YDHXOIAI.mjs";
11
+ } from "./chunk-45VILLJT.mjs";
12
12
 
13
13
  // src/selection.ts
14
14
  function rowIdxsFromMark(el) {
package/dist/index.mjs CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  svgNaturalSize,
16
16
  svgToDataUrl,
17
17
  tableHostOf
18
- } from "./chunk-UBB5LNH4.mjs";
18
+ } from "./chunk-VC3Z5CH7.mjs";
19
19
  import {
20
20
  ANNOTATION_CLASS,
21
21
  ANNOTATION_LAYER_CLASS,
@@ -97,7 +97,7 @@ import {
97
97
  toRGBA,
98
98
  toSourceRows,
99
99
  valuePlacementFlag
100
- } from "./chunk-562LF7XM.mjs";
100
+ } from "./chunk-RDUEXM2D.mjs";
101
101
  import {
102
102
  LASSO_AXES_SLOT,
103
103
  LASSO_COUNT_CLASS,
@@ -122,13 +122,13 @@ import {
122
122
  lassoVerdict,
123
123
  pointInShape,
124
124
  registerLassoAction
125
- } from "./chunk-OG6RR2AJ.mjs";
125
+ } from "./chunk-QHMLMF4L.mjs";
126
126
  import {
127
127
  MIN_HIT_BAND_PX,
128
128
  censusHitBands,
129
129
  ensureCrossfilterHitTargets,
130
130
  hitBandFlag
131
- } from "./chunk-XNS34AMM.mjs";
131
+ } from "./chunk-PDVEIW42.mjs";
132
132
  import {
133
133
  CONTAINER_SLOT_PRESS_AT,
134
134
  DRAG_NOT_CLICK_PX,
@@ -153,7 +153,7 @@ import {
153
153
  selectionRuleOf,
154
154
  trackPress,
155
155
  validRowSet
156
- } from "./chunk-DJ5IJV57.mjs";
156
+ } from "./chunk-VEVBXK44.mjs";
157
157
  import {
158
158
  AXIS_PIN_BAND_PAD_PX,
159
159
  AXIS_PIN_MAX_BAND_FRACTION,
@@ -176,10 +176,13 @@ import {
176
176
  pinScrolledAxis,
177
177
  planAxisPin,
178
178
  planFrameGrow,
179
+ planScrollRestore,
180
+ rememberScroll,
179
181
  scrollFitFor,
180
182
  svgInkReach,
183
+ takeScroll,
181
184
  unpinScrolledAxis
182
- } from "./chunk-QHIPRAZK.mjs";
185
+ } from "./chunk-NNLBFIWU.mjs";
183
186
  import {
184
187
  _3,
185
188
  l,
@@ -205,6 +208,7 @@ import {
205
208
  CONTAINER_SLOT_ANIM_STOP,
206
209
  CONTAINER_SLOT_CLICKED_MARKS,
207
210
  CONTAINER_SLOT_INITIAL_XF_MARK,
211
+ CONTAINER_SLOT_SCROLL,
208
212
  CONTAINER_SLOT_UI_STATE,
209
213
  CONTAINER_SLOT_XF_CLEAR,
210
214
  CONTROL_CLASS,
@@ -237,7 +241,7 @@ import {
237
241
  chartOwnsTimeline,
238
242
  periodTickSuppressesFeedback,
239
243
  withoutViewOnlyKeys
240
- } from "./chunk-YDHXOIAI.mjs";
244
+ } from "./chunk-45VILLJT.mjs";
241
245
  import "./chunk-GHODWOAL.mjs";
242
246
 
243
247
  // src/optionsVocabulary.ts
@@ -2154,6 +2158,7 @@ export {
2154
2158
  CONTAINER_SLOT_CLICKED_MARKS,
2155
2159
  CONTAINER_SLOT_INITIAL_XF_MARK,
2156
2160
  CONTAINER_SLOT_PRESS_AT,
2161
+ CONTAINER_SLOT_SCROLL,
2157
2162
  CONTAINER_SLOT_UI_STATE,
2158
2163
  CONTAINER_SLOT_XF_CLEAR,
2159
2164
  CONTROL_CLASS,
@@ -2393,6 +2398,7 @@ export {
2393
2398
  pinScrolledAxis,
2394
2399
  planAxisPin,
2395
2400
  planFrameGrow,
2401
+ planScrollRestore,
2396
2402
  planSelectionPaint,
2397
2403
  planTrivialChart,
2398
2404
  pointInShape,
@@ -2429,6 +2435,7 @@ export {
2429
2435
  registerGeoAsset,
2430
2436
  registerLassoAction,
2431
2437
  relativeLuminance,
2438
+ rememberScroll,
2432
2439
  requiredD3Plugins,
2433
2440
  resolveAutoColorScale,
2434
2441
  resolveOptions,
@@ -2455,6 +2462,7 @@ export {
2455
2462
  svgToDataUrl,
2456
2463
  syncMemberSelection,
2457
2464
  tableHostOf,
2465
+ takeScroll,
2458
2466
  toRGBA,
2459
2467
  toSourceRows,
2460
2468
  trackPress,
@@ -2,13 +2,13 @@
2
2
  import {
3
3
  chartSvgOf,
4
4
  tableHostOf
5
- } from "./chunk-UBB5LNH4.mjs";
5
+ } from "./chunk-VC3Z5CH7.mjs";
6
6
  import {
7
7
  FIT_CONTENT_SELECTOR,
8
8
  ctmScaleOf,
9
9
  isPhantomBox
10
- } from "./chunk-QHIPRAZK.mjs";
11
- import "./chunk-YDHXOIAI.mjs";
10
+ } from "./chunk-NNLBFIWU.mjs";
11
+ import "./chunk-45VILLJT.mjs";
12
12
  import "./chunk-GHODWOAL.mjs";
13
13
 
14
14
  // src/legendFit/legendReconcile.ts
package/dist/page.mjs CHANGED
@@ -14,10 +14,10 @@ import {
14
14
  readControls,
15
15
  viewFromToken,
16
16
  viewToken
17
- } from "./chunk-GOIG756B.mjs";
18
- import "./chunk-OG6RR2AJ.mjs";
19
- import "./chunk-DJ5IJV57.mjs";
20
- import "./chunk-YDHXOIAI.mjs";
17
+ } from "./chunk-CR7DSJVG.mjs";
18
+ import "./chunk-QHMLMF4L.mjs";
19
+ import "./chunk-VEVBXK44.mjs";
20
+ import "./chunk-45VILLJT.mjs";
21
21
  import "./chunk-GHODWOAL.mjs";
22
22
  export {
23
23
  LINKED_HOVER_CLASS,
package/dist/react.mjs CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  payloadRowReader,
14
14
  viewFromToken,
15
15
  viewToken
16
- } from "./chunk-GOIG756B.mjs";
16
+ } from "./chunk-CR7DSJVG.mjs";
17
17
  import {
18
18
  assembleD3,
19
19
  createChartGroup,
@@ -22,14 +22,14 @@ import {
22
22
  loadGeo,
23
23
  syncMemberSelection,
24
24
  toSourceRows
25
- } from "./chunk-562LF7XM.mjs";
26
- import "./chunk-OG6RR2AJ.mjs";
27
- import "./chunk-XNS34AMM.mjs";
28
- import "./chunk-DJ5IJV57.mjs";
29
- import "./chunk-QHIPRAZK.mjs";
25
+ } from "./chunk-RDUEXM2D.mjs";
26
+ import "./chunk-QHMLMF4L.mjs";
27
+ import "./chunk-PDVEIW42.mjs";
28
+ import "./chunk-VEVBXK44.mjs";
29
+ import "./chunk-NNLBFIWU.mjs";
30
30
  import "./chunk-KQN5C7SZ.mjs";
31
31
  import "./chunk-A2GMXZP7.mjs";
32
- import "./chunk-YDHXOIAI.mjs";
32
+ import "./chunk-45VILLJT.mjs";
33
33
  import "./chunk-GHODWOAL.mjs";
34
34
 
35
35
  // src/react.tsx
@@ -69,6 +69,7 @@ function BicChart(props) {
69
69
  viewState,
70
70
  zoomMemory,
71
71
  labelContrast,
72
+ restoreScroll,
72
73
  onLabelContrast,
73
74
  onSelectionGlyphs,
74
75
  layoutPass,
@@ -112,6 +113,11 @@ function BicChart(props) {
112
113
  };
113
114
  const userSelectedRef = useRef(false);
114
115
  const optKey = useMemo(() => JSON.stringify(options ?? {}), [options]);
116
+ const zoomOn = zoomMemory != null;
117
+ const zoomRef = useRef(zoomMemory ?? null);
118
+ useLayoutEffect(() => {
119
+ zoomRef.current = zoomMemory ?? null;
120
+ });
115
121
  const builtWithRef = useRef(null);
116
122
  const onSelectRef = useRef(onSelect);
117
123
  onSelectRef.current = onSelect;
@@ -154,9 +160,10 @@ function BicChart(props) {
154
160
  d3,
155
161
  geoKind,
156
162
  viewState,
157
- zoomMemory,
163
+ zoomMemory: zoomRef.current,
158
164
  labelContrast,
159
165
  layoutPass,
166
+ restoreScroll,
160
167
  onLabelContrast: (r) => onLabelContrastRef.current?.(r),
161
168
  onSelectionGlyphs: (r) => onSelectionGlyphsRef.current?.(r),
162
169
  // A GETTER, not a wrapper: the host decides whether to
@@ -189,7 +196,7 @@ function BicChart(props) {
189
196
  host.destroy();
190
197
  hostRef.current = null;
191
198
  };
192
- }, [code, renderFn, d3, geoKind, labelContrast, layoutPass, controls]);
199
+ }, [code, renderFn, d3, geoKind, labelContrast, layoutPass, restoreScroll, zoomOn, controls]);
193
200
  useLayoutEffect(() => {
194
201
  const host = hostRef.current;
195
202
  if (!host || !selects) return;
@@ -215,7 +222,7 @@ function BicChart(props) {
215
222
  b.detach();
216
223
  if (bindingRef.current === b) bindingRef.current = null;
217
224
  };
218
- }, [selects, code, renderFn, d3, geoKind, labelContrast, layoutPass]);
225
+ }, [selects, code, renderFn, d3, geoKind, labelContrast, layoutPass, restoreScroll, zoomOn]);
219
226
  useEffect(() => {
220
227
  if (!hostRef.current || !options) return;
221
228
  if (builtWithRef.current?.optKey === optKey) return;
package/dist/testing.mjs CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  MARK_CLASS,
5
5
  MARK_SELECTED_CLASS,
6
6
  ROW_IDX_ATTR
7
- } from "./chunk-YDHXOIAI.mjs";
7
+ } from "./chunk-45VILLJT.mjs";
8
8
  import "./chunk-GHODWOAL.mjs";
9
9
 
10
10
  // src/testing/interact.ts
@@ -35,6 +35,7 @@ export declare const CONTAINER_SLOT_ANIM_STOP = "__llmAnimStop";
35
35
  export declare const CONTAINER_SLOT_XF_CLEAR = "__llmXfClear";
36
36
  export declare const CONTAINER_SLOT_INITIAL_XF_MARK = "__llmInitialXfMark";
37
37
  export declare const CONTAINER_SLOT_UI_STATE = "__lchUiState";
38
+ export declare const CONTAINER_SLOT_SCROLL = "__lchScroll";
38
39
  export declare const CONTAINER_SLOT_CLICKED_MARKS = "__lchClickedMarks";
39
40
  export declare const VIEW_ONLY_UI_STATE_KEYS: readonly string[];
40
41
  /** The bag with every view-only key removed: what a host persists when the reader has not asked
@@ -90,3 +90,26 @@ export type AxisPinEdge = "top" | "bottom";
90
90
  * one drawn over it.
91
91
  */
92
92
  export declare function axisPinPlacement(band: AxisBand, scrollTop: number, viewportH: number): AxisPinEdge | null;
93
+ /** A reader's scroll offsets and the identity of the chart they were made on. */
94
+ export interface ScrollMemory {
95
+ key: unknown;
96
+ x: number;
97
+ y: number;
98
+ }
99
+ /** What the container reports once the overflow is decided. */
100
+ export interface ScrollMetrics {
101
+ scrollWidth: number;
102
+ scrollHeight: number;
103
+ clientWidth: number;
104
+ clientHeight: number;
105
+ overflowX: string;
106
+ overflowY: string;
107
+ }
108
+ /**
109
+ * Where the scroll goes after a redraw. The saved offsets when the SAME chart is back, clamped to what the new layout can scroll and
110
+ * applied per axis only where the container scrolls; otherwise the origin. A new chart opens at the top.
111
+ */
112
+ export declare function planScrollRestore(saved: ScrollMemory | null | undefined, key: unknown, m: ScrollMetrics): {
113
+ x: number;
114
+ y: number;
115
+ };
@@ -1,4 +1,4 @@
1
- import type { ContentExtent, MeasuredBox, AxisPinEdge } from "./fit";
1
+ import type { ContentExtent, MeasuredBox, AxisPinEdge, ScrollMemory } from "./fit";
2
2
  export declare function ctmScaleOf(m: {
3
3
  a: number;
4
4
  b: number;
@@ -59,6 +59,16 @@ export interface FitRenderedChartOptions {
59
59
  * Set false for a host that draws its own sticky header.
60
60
  */
61
61
  pinAxis?: boolean;
62
+ /**
63
+ * Put the reader's scroll back once the overflow is decided: `memory` (what `takeScroll` handed
64
+ * back) is restored only when its key is `key`, the chart now drawn, clamped to what the new
65
+ * layout can scroll and only on an axis that scrolls. Applied before the axis pin, which places
66
+ * its copy from the scroll it finds. Absent, the container is left where the render put it.
67
+ */
68
+ restoreScroll?: {
69
+ memory: ScrollMemory | null;
70
+ key: unknown;
71
+ };
62
72
  }
63
73
  export interface FitRenderedChartResult {
64
74
  /** The frame was grown, so content that was being clipped is now painted. */
@@ -73,6 +83,11 @@ export interface FitRenderedChartResult {
73
83
  reading: FitReading | null;
74
84
  /** The axis pin: what was pinned when the container scrolls, or why nothing was. */
75
85
  axisPin: AxisPinReport;
86
+ /** Where `restoreScroll` put the container ({0,0} for a different chart), or null when it was not asked or the overflow was not applied. */
87
+ scrolledTo: {
88
+ x: number;
89
+ y: number;
90
+ } | null;
76
91
  }
77
92
  export interface AxisPinReport {
78
93
  pinned: boolean;
@@ -94,5 +109,13 @@ export interface AxisPinReport {
94
109
  * when there is none. Hosts that clear the container themselves still need this for the
95
110
  * listener, which outlives the overlay. */
96
111
  export declare function unpinScrolledAxis(container: HTMLElement | null | undefined): void;
112
+ /**
113
+ * Remember where the container is scrolled, BEFORE the container is cleared. A memory already waiting is kept: a second render that
114
+ * starts before the first one restored sees an emptied (unscrolled) container, and must not overwrite what the reader really did.
115
+ * A diagram that scrolls inside itself remembers its own position, so it is left alone.
116
+ */
117
+ export declare function rememberScroll(container: HTMLElement | null | undefined, key: unknown): void;
118
+ /** The waiting memory, removed. The one place it is consumed, so a stale memory can never outlive a render. */
119
+ export declare function takeScroll(container: HTMLElement | null | undefined): ScrollMemory | null;
97
120
  export declare function pinScrolledAxis(container: HTMLElement | null | undefined, svg: SVGSVGElement | null | undefined): AxisPinReport;
98
121
  export declare function fitRenderedChart(container: HTMLElement | null | undefined, opts?: FitRenderedChartOptions): FitRenderedChartResult;
@@ -80,9 +80,10 @@ export interface ChartHostConfig {
80
80
  /**
81
81
  * REMEMBER THE READER'S ZOOM AND PAN on a chart that calls d3.zoom(), across every re-render (and across a
82
82
  * close and reopen, if the host's store says so). Build it with createZoomMemoryHook() from
83
- * "@bicharts/chart-host/zoom-memory", ONCE per container, and hand the same hook to every host created on
84
- * that container. Absent (the default), d3.zoom is never wrapped and nothing is listened for. It applies to a
85
- * chart compiled from `code` only: a precompiled `renderFn` closed over its own d3.
83
+ * "@bicharts/chart-host/zoom-memory", ONCE per chart, and hand the same hook to every host built for that chart
84
+ * (on the same element or, after a remount, a new one): a host that is destroyed releases the hook's watcher, so
85
+ * the hook can go to the next. Absent (the default), d3.zoom is never wrapped and nothing is listened for. It
86
+ * applies to a chart compiled from `code` only: a precompiled `renderFn` closed over its own d3.
86
87
  */
87
88
  zoomMemory?: ZoomMemoryHook | null;
88
89
  /** D3 v7 instance handed to the compiled code. Default: (globalThis as any).d3. */
@@ -181,6 +182,15 @@ export interface ChartHostConfig {
181
182
  * reading without touching the chart (`grow: false`, `applyOverflow: false`).
182
183
  */
183
184
  fit?: boolean | FitRenderedChartOptions;
185
+ /**
186
+ * KEEP THE READER'S SCROLL when the SAME chart is drawn again - a resize, a filter, a theme
187
+ * switch, or a new host on the same element. Default true: the offset is parked on the
188
+ * container before each clear and put back by the fit, clamped to what the new layout can
189
+ * scroll; a different chart (other code, or a renderFn with other source) opens at the top. Pass `false`
190
+ * for a page that manages the container's scrolling itself: nothing is remembered or restored,
191
+ * and a memory an earlier host left on the element is discarded at the next render.
192
+ */
193
+ restoreScroll?: boolean;
184
194
  /** The fit result after each render - what was grown, and how the chart measured. */
185
195
  onFit?: (result: FitRenderedChartResult) => void;
186
196
  /**
@@ -1,4 +1,4 @@
1
- export { HOST_CONTRACT_VERSION, GEO_POINT_PRECISIONS, MARK_CLASS, LEGEND_MARK_CLASS, AXIS_FILTER_CLASS, ROW_IDX_ATTR, LIFT_SELECTED_CLASS, CONTROL_CLASS, NO_ROW_MARKS_ATTR, SELECTION_RULE_ATTR, SELECTION_RULE_CLICKED_MARKS, MARK_KEY_ATTR, CONTAINER_SLOT_CLICKED_MARKS, SNAPSHOT_SVG_ATTR, XFILTER_REFRESH_EVENT, CONTAINER_SLOT_ANIM_STOP, CONTAINER_SLOT_XF_CLEAR, CONTAINER_SLOT_INITIAL_XF_MARK, CONTAINER_SLOT_UI_STATE, VIEW_ONLY_UI_STATE_KEYS, withoutViewOnlyKeys, HOST_CONTAINER_CLASS, SELECTION_ACTIVE_CLASS, MARK_SELECTED_CLASS, ACTIVE_TICK_CLASS, DIM_OPACITY_VAR, DIM_OPACITY_DEFAULT, DIM_OPACITY_MIN, chartOwnsTimeline, periodTickSuppressesFeedback, ANIM_PLAY_SPEED_DEFAULT, ANIM_PLAY_SPEED_MIN, ANIM_PLAY_SPEED_MAX, ANIM_LOOP_DELAY_DEFAULT, ANIM_LOOP_DELAY_MIN, ANIM_MAX_IDEAL_FRAMES_DEFAULT, ANIM_MAX_IDEAL_FRAMES_MIN, ANIM_MAX_IDEAL_FRAMES_MAX, ANIMATION_OPTION_KEYS, COLOR_SCALE_SELF_CLAMP_PCT_DEFAULT, COLOR_SCALE_SELF_CLAMP_PCT_MIN, COLOR_SCALE_SELF_CLAMP_PCT_MAX, FLIP_MODE_DEFAULT, FLIP_INTERVAL_MIN_MS, FLIP_INTERVAL_MAX_MS, FLIP_DIRECTION_DEFAULT, APPROXIMATE_POSITIONS_DEFAULT, VALUE_AXIS_BASELINE_DEFAULT, SEASONAL_MARKERS_DEFAULT, MAX_MAP_POINTS_DEFAULT, type GeoPointPrecision, type GeoMapKind, type TimelineStyle, type FlipMode, type FlipDirection, type ApproximatePositions, type ValueAxisBaseline, type SeasonalMarkers, type ColorScaleScope, type RenderOptions, type ViewStateProvider, type ChartControlChange, type XfilterRefreshDetail, } from "./contract";
1
+ export { HOST_CONTRACT_VERSION, GEO_POINT_PRECISIONS, MARK_CLASS, LEGEND_MARK_CLASS, AXIS_FILTER_CLASS, ROW_IDX_ATTR, LIFT_SELECTED_CLASS, CONTROL_CLASS, NO_ROW_MARKS_ATTR, SELECTION_RULE_ATTR, SELECTION_RULE_CLICKED_MARKS, MARK_KEY_ATTR, CONTAINER_SLOT_CLICKED_MARKS, SNAPSHOT_SVG_ATTR, XFILTER_REFRESH_EVENT, CONTAINER_SLOT_ANIM_STOP, CONTAINER_SLOT_XF_CLEAR, CONTAINER_SLOT_INITIAL_XF_MARK, CONTAINER_SLOT_UI_STATE, CONTAINER_SLOT_SCROLL, VIEW_ONLY_UI_STATE_KEYS, withoutViewOnlyKeys, HOST_CONTAINER_CLASS, SELECTION_ACTIVE_CLASS, MARK_SELECTED_CLASS, ACTIVE_TICK_CLASS, DIM_OPACITY_VAR, DIM_OPACITY_DEFAULT, DIM_OPACITY_MIN, chartOwnsTimeline, periodTickSuppressesFeedback, ANIM_PLAY_SPEED_DEFAULT, ANIM_PLAY_SPEED_MIN, ANIM_PLAY_SPEED_MAX, ANIM_LOOP_DELAY_DEFAULT, ANIM_LOOP_DELAY_MIN, ANIM_MAX_IDEAL_FRAMES_DEFAULT, ANIM_MAX_IDEAL_FRAMES_MIN, ANIM_MAX_IDEAL_FRAMES_MAX, ANIMATION_OPTION_KEYS, COLOR_SCALE_SELF_CLAMP_PCT_DEFAULT, COLOR_SCALE_SELF_CLAMP_PCT_MIN, COLOR_SCALE_SELF_CLAMP_PCT_MAX, FLIP_MODE_DEFAULT, FLIP_INTERVAL_MIN_MS, FLIP_INTERVAL_MAX_MS, FLIP_DIRECTION_DEFAULT, APPROXIMATE_POSITIONS_DEFAULT, VALUE_AXIS_BASELINE_DEFAULT, SEASONAL_MARKERS_DEFAULT, MAX_MAP_POINTS_DEFAULT, type GeoPointPrecision, type GeoMapKind, type TimelineStyle, type FlipMode, type FlipDirection, type ApproximatePositions, type ValueAxisBaseline, type SeasonalMarkers, type ColorScaleScope, type RenderOptions, type ViewStateProvider, type ChartControlChange, type XfilterRefreshDetail, } from "./contract";
2
2
  export { resolveOptions, type ResolveOptionsInput } from "./defaults";
3
3
  export { OPTIONS_VOCABULARY, LIVE_OPTION_KEYS, MAX_MAP_POINTS_MIN, MAX_MAP_POINTS_MAX, LIMIT_COUNT_MAX, wireLimitCount, codeReadsOption, type OptionVocabularyEntry, type OptionTiming, type OptionWire, type OptionKey, } from "./optionsVocabulary";
4
4
  export { loadGeo, geoFromCache, registerGeo, registerGeoAsset, geoAssetFor, clearGeoCache, type GeoAssetName, } from "./geoLazy";
@@ -42,6 +42,8 @@ export { SCROLL_SLACK_PX, MAX_FRAME_GROW_FACTOR, PHANTOM_FRACTION, FIT_CONTENT_S
42
42
  export { fitRenderedChart, fitReadingFor, measureContainerBoxes, svgInkReach, ctmScaleOf, type FitReading, type InkReach, type FitRenderedChartOptions, type FitRenderedChartResult, } from "./fitDom";
43
43
  export { AXIS_PIN_MIN_LABELS, AXIS_PIN_BAND_PAD_PX, AXIS_PIN_MAX_BAND_FRACTION, AXIS_PIN_TRACK_REACH_PX, isHorizontalLabelRow, labelBand, planAxisPin, axisPinPlacement, type LabelRowBox, type AxisBand, type AxisPinCandidate, type AxisPinPlan, type AxisPinEdge, } from "./fit";
44
44
  export { pinScrolledAxis, unpinScrolledAxis, type AxisPinReport } from "./fitDom";
45
+ export { planScrollRestore, type ScrollMemory, type ScrollMetrics } from "./fit";
46
+ export { rememberScroll, takeScroll } from "./fitDom";
45
47
  export { MIN_DELTA_E, pickDistinctColorFromSeed, buildPalette, buildPaletteFromSeed, orderMostDistinct, LEGIBLE_LIGHTNESS, type PaletteResult, type SeedForSlot, type PaletteOptions, } from "./palette";
46
48
  export { deltaEHex } from "./deltaE";
47
49
  export { resolveAutoColorScale, autoRampMinStep, autoRampCanvas, AUTO_RAMP_CLASSES, AUTO_RAMP_DRAWN_OPACITY, AUTO_RAMP_MIN_STEP_DELTA_E, type AutoColorScale, type AutoColorScaleInput } from "./autoRamp";
@@ -35,8 +35,11 @@ export interface BicChartProps {
35
35
  /**
36
36
  * REMEMBER THE READER'S ZOOM AND PAN on a chart that calls d3.zoom(), across re-renders and, if
37
37
  * the hook's store keeps it, across a reload. Build it with createZoomMemoryHook() from
38
- * "@bicharts/chart-host/zoom-memory", once per chart (module scope or useMemo) with somewhere to
39
- * keep the view. Absent, nothing is remembered. See createChartHost's `zoomMemory`.
38
+ * "@bicharts/chart-host/zoom-memory" over one ZoomMemoryHost per chart (module scope, useRef or
39
+ * useMemo) with somewhere to keep the view. Absent (null or undefined), nothing is remembered. Host config:
40
+ * a hook that arrives after mount, or goes away, rebuilds the host; a change from one hook to another does
41
+ * not, and applies at the next rebuild (a new `code`, say), or at once if the component is remounted with a
42
+ * `key`. So a hook built inline in the props is harmless. See createChartHost's `zoomMemory`.
40
43
  */
41
44
  zoomMemory?: ChartHostConfig["zoomMemory"];
42
45
  /**
@@ -46,6 +49,12 @@ export interface BicChartProps {
46
49
  * themed page is not white, and readability is measured against it. See createChartHost.
47
50
  */
48
51
  labelContrast?: ChartHostConfig["labelContrast"];
52
+ /**
53
+ * KEEP THE READER'S SCROLL when the same chart repaints (new data, new options, a rebuild).
54
+ * Default on; a different chart opens at the top. Pass `false` for a page that manages the
55
+ * chart's scrolling itself. Host config: changing it rebuilds the host. See createChartHost.
56
+ */
57
+ restoreScroll?: ChartHostConfig["restoreScroll"];
49
58
  /** What the label-contrast pass did after each render - counts, for a page that logs. */
50
59
  onLabelContrast?: ChartHostConfig["onLabelContrast"];
51
60
  /** What the selection-glyph pass did after each paint, when `options.selectionGlyph` is set - counts, for a page
@@ -1,20 +1,23 @@
1
1
  import { type ZoomState } from "./zoomMemory";
2
2
  /**
3
3
  * WHAT A HOST SUPPLIES: where the remembered view lives, and whether it may outlive the session.
4
- * One per chart container, kept for as long as the container is, so it outlives a host that is destroyed
5
- * and re-created on the same element (a redraw on every resize, say).
4
+ * One per chart, kept for as long as the chart is on the page, so it outlives a host that is destroyed
5
+ * and re-created (on the same element for a redraw on every resize, say, or on a new one after a remount).
6
6
  */
7
7
  export interface ZoomMemoryHost {
8
8
  /** The in-session tier, always on: survives every re-render, never written anywhere. */
9
9
  liveZoomState: ZoomState | null;
10
10
  /** The key the watcher stamps on what it records (the latest render's). */
11
11
  zoomWatchKey: string;
12
- /** Whether the watcher's listeners are on zoomContainer (they attach once). */
12
+ /** Whether the watcher's listeners are on zoomContainer (they attach once per element, and come off when the
13
+ * host that drew into it is destroyed). */
13
14
  zoomWatchAttached: boolean;
14
15
  /** The pending settle timer, 0 when none. */
15
16
  zoomSettleTimer: number;
16
17
  /** The element the chart draws into; it outlives each render, so listeners attach to it once. Null until
17
- * known: createZoomMemoryHook's restore adopts the render's container then. */
18
+ * known: createZoomMemoryHook's restore adopts the render's container then, moves to a new one when a
19
+ * render arrives on a different element (a remount), and gives it back when the host is destroyed. An element
20
+ * set here beforehand is the page's choice: the hook watches it and neither replaces nor gives it back. */
18
21
  zoomContainer: Element | null;
19
22
  /** Is the persisted tier on NOW? Read on every restore and every write, so a change applies to the next. */
20
23
  rememberAcrossSessions(): boolean;
@@ -68,7 +71,13 @@ export interface ZoomMemoryHook {
68
71
  flush(): void;
69
72
  /** forgetZoom: drop the session view and a pending gesture. */
70
73
  forget(): void;
74
+ /** Let go of the watcher on `container`, the element a destroyed host drew into (after flush, which records a
75
+ * gesture still settling), so the next host handed this hook attaches afresh and a hook that is replaced leaves
76
+ * nothing on the element. Without `container`, wherever the watcher is. A watcher that has since moved to another
77
+ * element is left alone. Optional: a hook written by hand without it works as before. */
78
+ release?(container?: Element): void;
71
79
  }
72
- /** Bind the zoom memory to one host object. Build it once per container and hand the same hook to every
73
- * host created on that container, so the session tier outlives each one. */
80
+ /** Bind the zoom memory to one host object. Build it once per chart and hand the same hook to every
81
+ * host created for it, so the session tier outlives each one - on the same element, or on a new one
82
+ * after a remount, where the watcher moves with it. A host that is destroyed releases the watcher. */
74
83
  export declare function createZoomMemoryHook(host: ZoomMemoryHost): ZoomMemoryHook;
@@ -192,15 +192,34 @@ function attachZoomWatcher(host, key) {
192
192
  captureCurrentZoom(host);
193
193
  }, SETTLE_MS);
194
194
  };
195
- for (const evt of ["wheel", "pointerup", "pointercancel", "click", "dblclick", "keyup"]) {
195
+ for (const evt of WATCHED_EVENTS) {
196
196
  try {
197
197
  ctr.addEventListener(evt, settle, true);
198
198
  } catch {
199
199
  }
200
200
  }
201
+ attachedWatchers.set(host, { ctr, settle });
201
202
  host.zoomWatchAttached = true;
202
203
  host.log("zoom-watcher-attached", {});
203
204
  }
205
+ var WATCHED_EVENTS = ["wheel", "pointerup", "pointercancel", "click", "dblclick", "keyup"];
206
+ var attachedWatchers = /* @__PURE__ */ new WeakMap();
207
+ var adoptedContainers = /* @__PURE__ */ new WeakMap();
208
+ function detachZoomWatcher(host) {
209
+ const w = attachedWatchers.get(host);
210
+ if (w) {
211
+ for (const evt of WATCHED_EVENTS) {
212
+ try {
213
+ w.ctr.removeEventListener(evt, w.settle, true);
214
+ } catch {
215
+ }
216
+ }
217
+ attachedWatchers.delete(host);
218
+ }
219
+ if (host.zoomSettleTimer) clearTimeout(host.zoomSettleTimer);
220
+ host.zoomSettleTimer = 0;
221
+ host.zoomWatchAttached = false;
222
+ }
204
223
  function captureCurrentZoom(host) {
205
224
  try {
206
225
  const key = host.zoomWatchKey;
@@ -242,17 +261,32 @@ function forgetZoom(host) {
242
261
  host.liveZoomState = null;
243
262
  }
244
263
  function createZoomMemoryHook(host) {
245
- return {
264
+ const hook = {
246
265
  keyFor: (code, width, height) => zoomStateKey(B(String(code ?? "")), width, height),
247
266
  capture: (d3) => captureZoomBehaviors(d3, host),
248
267
  restore: (container, capture, key, d3) => {
249
- if (!host.zoomContainer) host.zoomContainer = container;
268
+ const mine = adoptedContainers.get(host);
269
+ if (host.zoomContainer && host.zoomContainer === mine && mine !== container) {
270
+ detachZoomWatcher(host);
271
+ host.zoomContainer = null;
272
+ }
273
+ if (!host.zoomContainer) {
274
+ host.zoomContainer = container;
275
+ adoptedContainers.set(host, container);
276
+ }
250
277
  restoreZoomForRender(d3, host, capture, key);
251
278
  attachZoomWatcher(host, key);
252
279
  },
253
280
  flush: () => flushPendingZoom(host),
254
- forget: () => forgetZoom(host)
281
+ forget: () => forgetZoom(host),
282
+ release: (container) => {
283
+ if (container && attachedWatchers.get(host)?.ctr !== container) return;
284
+ detachZoomWatcher(host);
285
+ if (host.zoomContainer && host.zoomContainer === adoptedContainers.get(host)) host.zoomContainer = null;
286
+ adoptedContainers.delete(host);
287
+ }
255
288
  };
289
+ return hook;
256
290
  }
257
291
  export {
258
292
  attachZoomWatcher,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bicharts/chart-host",
3
- "version": "0.6.89",
3
+ "version": "0.6.91",
4
4
  "description": "Run a BIC-generated D3 chart in any web host: compiles the generated render() function, applies the shared option defaults, resolves mark clicks (through tooltip overlays), owns the selection affordance, and translates row indices between cross-filtered charts. The same contract the BIC Power BI visual implements, minus Power BI. React bindings at @bicharts/chart-host/react.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",