@bicharts/chart-host 0.6.88 → 0.6.90

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
@@ -277,6 +277,41 @@ stylesheet's `0.25`, or whatever your page sets on the variable.
277
277
  createChartHost(el, { code, data, options: { dimOpacity: 0.15 } });
278
278
  ```
279
279
 
280
+ **A glyph on each selected mark.** `options.selectionGlyph` (`"dot"` or `"check"`; absent or
281
+ `"none"` draws nothing, the default) puts a small glyph on every selected mark, in a layer the host
282
+ owns inside the container (`.bic-selection-glyphs`, each glyph `.lch-sel-glyph`, never hit-tested).
283
+ It is painted after every selection change and, on a redraw, after the layout pass and the fit, so
284
+ nothing measures it as part of the chart. Only a mark the reader can see gets one: a mark hidden by
285
+ `visibility` or `opacity` (a legend toggle) and a mark outside its svg's frame or its plot's
286
+ `clip-path` (a point panned into the axis margin) get none. While a selection is lit, a mark that
287
+ moves or hides without a redraw (a pan or zoom, a transition, an animation frame, a legend toggle)
288
+ takes its glyph with it on the next frame; a write that holds no mark (a hover dot, a tooltip)
289
+ repaints nothing, and that repaint is not reported again. Where the browser has a `ResizeObserver`,
290
+ a container resized without a redraw (a responsive chart, a hidden tab shown) is repainted too, and
291
+ reported when it drew a different count. The layer clips at the container's scroll area, so a
292
+ glyph never widens what the container scrolls, and it sits under the note badges, under a chart's
293
+ own tooltip or control that sets a `z-index` above 1, and under a pinned axis, so a glyph on a row
294
+ scrolled beneath the axis is covered with its row. When the page left the container
295
+ `position: static`, the host makes it `relative` so the layer scrolls and clips with the marks, and
296
+ `destroy()` puts back what the page had. A period picked on a chart's own timeline dims nothing and
297
+ marks nothing.
298
+
299
+ Some charts never take a glyph: on a flow or relationship chart a selected series would get one on
300
+ every point. The generate answer says so (`suppressSelectionGlyph`, read by shape-core's
301
+ `parseGenerateResponse`); pass it as `options.suppressSelectionGlyph`, or leave it in the payload's
302
+ `meta` (`data.meta.suppressSelectionGlyph`) and the host promotes it, the way it promotes
303
+ `geoPoint`. Keep it with the chart's code, so a redraw from a cache honors it too.
304
+ `onSelectionGlyphs` reports what each paint did (`{ mode, drawn, total, suppressed }`), and is
305
+ called only when a glyph mode is set:
306
+
307
+ ```js
308
+ createChartHost(el, {
309
+ code, data,
310
+ options: { selectionGlyph: "dot", suppressSelectionGlyph: answer.suppressSelectionGlyph },
311
+ onSelectionGlyphs: r => console.debug("glyphs", r),
312
+ });
313
+ ```
314
+
280
315
  **Which marks count as selected.** By default a mark is selected when *any* of the rows in its
281
316
  `data-row-idx` list is selected, which is what lights a legend swatch together with its series. A
282
317
  chart whose marks share rows declares the other rule by stamping
@@ -385,6 +420,20 @@ only while `rememberAcrossSessions()` is true. It is keyed by the chart's code a
385
420
  somewhere arbitrary. A chart with several zoomable panels is left alone. In React, pass the hook as
386
421
  the `zoomMemory` prop of `<BicChart>`.
387
422
 
423
+ **A scrolled chart** keeps its place with no setup. A chart taller than its frame scrolls, and every
424
+ redraw empties the container, which used to send the reader back to the top. The host now parks the
425
+ scroll offset on the container before it clears it, and the fit puts it back once the new layout has
426
+ decided whether it still scrolls. The offset is clamped to what the new layout can scroll, and only
427
+ restored on an axis that still scrolls. That covers a resize, a filter, a restyle, and a new host
428
+ created on the same element. The memory is keyed by the chart (its `code`, or the `renderFn`'s
429
+ source text), so a different chart opens at the top, while a `renderFn` closure rebuilt on every draw
430
+ is still the same chart. It lives on the element for the session
431
+ only, and nothing is persisted. A page that manages the container's scrolling itself passes
432
+ `restoreScroll: false` (the `restoreScroll` prop of `<BicChart>`), and then nothing is remembered or
433
+ restored. A host that clears and fits its container on its own can use the same pieces:
434
+ `rememberScroll` before the clear, `takeScroll` after the render, and `fitRenderedChart`'s
435
+ `restoreScroll` option (or the pure `planScrollRestore`).
436
+
388
437
  ## Keeping a drawn chart readable inside its frame
389
438
 
390
439
  A generated chart cannot know how wide its own legend text will come out, where its tick labels will
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-IUXNCX7D.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-IZXVV74H.mjs";
4
+ } from "./chunk-QHMLMF4L.mjs";
5
5
 
6
6
  // src/filterScope.ts
7
7
  function viewToken(view) {
@@ -2,30 +2,35 @@
2
2
  import {
3
3
  LASSO_OUTLINE_CLASS,
4
4
  installLasso
5
- } from "./chunk-IZXVV74H.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
+ clearSelectionGlyphs,
12
13
  clickWasDrag,
14
+ collectGlyphCentersWhere,
13
15
  createMarkResolver,
14
16
  isInsideControl,
15
17
  markKeyOf,
16
18
  nextClickedMarks,
17
19
  nextSelection,
20
+ paintSelectionGlyphs,
18
21
  planSelectionPaint,
19
22
  sameRowSet,
20
23
  selectionRuleOf,
21
24
  trackPress,
22
25
  validRowSet
23
- } from "./chunk-IUXNCX7D.mjs";
26
+ } from "./chunk-VEVBXK44.mjs";
24
27
  import {
25
28
  clipWindowOf,
26
29
  fitRenderedChart,
30
+ rememberScroll,
31
+ takeScroll,
27
32
  unpinScrolledAxis
28
- } from "./chunk-QHIPRAZK.mjs";
33
+ } from "./chunk-NNLBFIWU.mjs";
29
34
  import {
30
35
  Fn,
31
36
  J3,
@@ -79,7 +84,7 @@ import {
79
84
  XFILTER_REFRESH_EVENT,
80
85
  chartOwnsTimeline,
81
86
  periodTickSuppressesFeedback
82
- } from "./chunk-YDHXOIAI.mjs";
87
+ } from "./chunk-45VILLJT.mjs";
83
88
 
84
89
  // src/labelContrast.ts
85
90
  function parseRGBA(c) {
@@ -615,6 +620,14 @@ function resolveOptions(p) {
615
620
  const n = numberOr(p.dimOpacity, NaN);
616
621
  return Number.isFinite(n) ? clamp(n, DIM_OPACITY_MIN, 1) : void 0;
617
622
  })(),
623
+ // A recognised glyph or none: an unknown string must never reach the painter as a live mode, and "none" is
624
+ // undefined rather than a value, so a host that passes nothing and one that passes "none" resolve alike.
625
+ selectionGlyph: (() => {
626
+ const v = (p.selectionGlyph == null ? "" : String(p.selectionGlyph)).toLowerCase();
627
+ return v === "dot" || v === "check" ? v : void 0;
628
+ })(),
629
+ // A host FACT from the generate answer, true only for a literal true: silence never suppresses anything.
630
+ suppressSelectionGlyph: p.suppressSelectionGlyph === true ? true : void 0,
618
631
  onSelect: p.onSelect,
619
632
  // Control gestures (1.10.0). Passed straight through like onSelect: the host owns what a gesture MEANS,
620
633
  // and a host that supplies nothing leaves every control behaving exactly as before.
@@ -2208,12 +2221,209 @@ function createChartHost(container, config) {
2208
2221
  } catch {
2209
2222
  }
2210
2223
  };
2224
+ const GLYPH_LAYER_CLASS = "bic-selection-glyphs";
2225
+ let glyphPositionWas = null;
2226
+ let glyphObserver = null;
2227
+ let glyphResize = null;
2228
+ let glyphSize = "";
2229
+ let glyphQueued = false;
2230
+ let glyphQueuedReport = false;
2231
+ let glyphReported = "";
2232
+ const sizeOfContainer = () => `${container.offsetWidth ?? 0}x${container.offsetHeight ?? 0}`;
2233
+ const glyphBlind = `.${GLYPH_LAYER_CLASS}, .${LASSO_OUTLINE_CLASS}`;
2234
+ const movesAMark = (r) => {
2235
+ const t = r.target;
2236
+ if (!t || t.closest?.(glyphBlind)) return false;
2237
+ return !!(t.classList?.contains(MARK_CLASS) || t.querySelector?.(`.${MARK_CLASS}`));
2238
+ };
2239
+ const queueGlyphs = (reportIfChanged) => {
2240
+ if (reportIfChanged) glyphQueuedReport = true;
2241
+ if (glyphQueued) return;
2242
+ glyphQueued = true;
2243
+ const run = () => {
2244
+ const r = glyphQueuedReport;
2245
+ glyphQueued = false;
2246
+ glyphQueuedReport = false;
2247
+ if (glyphObserver || glyphResize) paintGlyphLayer(r ? "if-changed" : false);
2248
+ };
2249
+ if (win?.requestAnimationFrame) win.requestAnimationFrame(run);
2250
+ else setTimeout(run, 16);
2251
+ };
2252
+ const followGlyphs = (on) => {
2253
+ if (!on) {
2254
+ if (glyphObserver) {
2255
+ glyphObserver.disconnect();
2256
+ glyphObserver = null;
2257
+ }
2258
+ if (glyphResize) {
2259
+ glyphResize.disconnect();
2260
+ glyphResize = null;
2261
+ }
2262
+ return;
2263
+ }
2264
+ const MO = win?.MutationObserver;
2265
+ if (!glyphObserver && MO) {
2266
+ glyphObserver = new MO((records) => {
2267
+ if (!glyphQueued && records.some(movesAMark)) queueGlyphs(false);
2268
+ });
2269
+ glyphObserver.observe(container, {
2270
+ subtree: true,
2271
+ attributes: true,
2272
+ attributeFilter: [
2273
+ "transform",
2274
+ "d",
2275
+ "x",
2276
+ "y",
2277
+ "cx",
2278
+ "cy",
2279
+ "points",
2280
+ "width",
2281
+ "height",
2282
+ "visibility",
2283
+ "opacity",
2284
+ "style",
2285
+ "class"
2286
+ ]
2287
+ });
2288
+ }
2289
+ const RO = win?.ResizeObserver;
2290
+ if (!glyphResize && RO) {
2291
+ glyphResize = new RO(() => {
2292
+ if (sizeOfContainer() !== glyphSize) queueGlyphs(true);
2293
+ });
2294
+ glyphResize.observe(container);
2295
+ }
2296
+ };
2297
+ const markShown = (m) => {
2298
+ try {
2299
+ const cv = m.checkVisibility;
2300
+ if (typeof cv === "function") return cv.call(m, { visibilityProperty: true, opacityProperty: true }) !== false;
2301
+ const cs = win?.getComputedStyle;
2302
+ if (typeof cs !== "function") return true;
2303
+ const own = cs.call(win, m);
2304
+ if (own.visibility === "hidden" || own.visibility === "collapse") return false;
2305
+ for (let el = m; el && el !== container; el = el.parentElement) {
2306
+ const s = el === m ? own : cs.call(win, el);
2307
+ if (s.display === "none" || s.opacity !== "" && parseFloat(s.opacity) === 0) return false;
2308
+ }
2309
+ } catch {
2310
+ }
2311
+ return true;
2312
+ };
2313
+ const glyphWindows = () => {
2314
+ const windowOf = /* @__PURE__ */ new Map();
2315
+ const read = (el) => {
2316
+ if (!windowOf.has(el)) {
2317
+ let w = null;
2318
+ try {
2319
+ if ((el.tagName || "").toLowerCase() === "svg") {
2320
+ const r = el.getBoundingClientRect();
2321
+ if (r.width > 0 && r.height > 0) w = r;
2322
+ } else if (el.hasAttribute?.("clip-path")) w = clipWindowOf(el);
2323
+ } catch {
2324
+ w = null;
2325
+ }
2326
+ windowOf.set(el, w);
2327
+ }
2328
+ return windowOf.get(el) ?? null;
2329
+ };
2330
+ const EPS2 = 0.5;
2331
+ return (m, x, y) => {
2332
+ for (let el = m; el && el !== container; el = el.parentElement) {
2333
+ const w = read(el);
2334
+ if (w && (x < w.left - EPS2 || x > w.right + EPS2 || y < w.top - EPS2 || y > w.bottom + EPS2)) return false;
2335
+ }
2336
+ return true;
2337
+ };
2338
+ };
2339
+ const paintGlyphLayer = (report = true) => {
2340
+ if (destroyed) return;
2341
+ let follow = false;
2342
+ try {
2343
+ let layer = container.querySelector(`:scope > .${GLYPH_LAYER_CLASS}`);
2344
+ clearSelectionGlyphs(layer);
2345
+ const mode = resolved.selectionGlyph;
2346
+ if (mode !== "dot" && mode !== "check") {
2347
+ glyphReported = "";
2348
+ return;
2349
+ }
2350
+ const active = !!current && current.length > 0 && !selectionIsPeriod;
2351
+ if (!active) {
2352
+ glyphReported = "";
2353
+ return;
2354
+ }
2355
+ if (resolved.suppressSelectionGlyph) {
2356
+ if (report === true) try {
2357
+ config.onSelectionGlyphs?.({ mode, drawn: 0, total: 0, suppressed: true });
2358
+ } catch {
2359
+ }
2360
+ glyphReported = "";
2361
+ return;
2362
+ }
2363
+ follow = true;
2364
+ if (!layer) {
2365
+ layer = doc.createElement("div");
2366
+ layer.className = GLYPH_LAYER_CLASS;
2367
+ layer.setAttribute("aria-hidden", "true");
2368
+ Object.assign(layer.style, {
2369
+ position: "absolute",
2370
+ left: "0",
2371
+ top: "0",
2372
+ width: "0",
2373
+ height: "0",
2374
+ overflow: "hidden",
2375
+ pointerEvents: "none",
2376
+ zIndex: "1"
2377
+ });
2378
+ try {
2379
+ const pos = win?.getComputedStyle?.(container)?.position || container.style.position;
2380
+ if (!pos || pos === "static") {
2381
+ const was = container.style.position;
2382
+ container.style.position = "relative";
2383
+ if (glyphPositionWas === null) glyphPositionWas = was;
2384
+ }
2385
+ } catch {
2386
+ }
2387
+ container.appendChild(layer);
2388
+ }
2389
+ layer.style.width = "0";
2390
+ layer.style.height = "0";
2391
+ const sw = container.scrollWidth || 0, sh = container.scrollHeight || 0;
2392
+ layer.style.width = `${sw}px`;
2393
+ layer.style.height = `${sh}px`;
2394
+ glyphSize = sizeOfContainer();
2395
+ const centers = [];
2396
+ collectGlyphCentersWhere(container, layer.getBoundingClientRect(), centers, { shown: markShown, at: glyphWindows() });
2397
+ const hc = !!resolved.isHighContrast;
2398
+ const drawn = centers.length ? paintSelectionGlyphs(layer, centers, {
2399
+ symbol: mode === "check" ? "\u2713" : "\u25CF",
2400
+ color: hc ? resolved.themeFg || "#111" : "#111",
2401
+ halo: hc ? resolved.themeBg || "#fff" : "#fff"
2402
+ }) : 0;
2403
+ const said = `${mode}:${drawn}:${centers.length}`;
2404
+ if (report === true || report === "if-changed" && said !== glyphReported) {
2405
+ glyphReported = said;
2406
+ try {
2407
+ config.onSelectionGlyphs?.({ mode, drawn, total: centers.length, suppressed: false });
2408
+ } catch {
2409
+ }
2410
+ }
2411
+ } catch {
2412
+ } finally {
2413
+ try {
2414
+ followGlyphs(follow);
2415
+ glyphObserver?.takeRecords();
2416
+ } catch {
2417
+ }
2418
+ }
2419
+ };
2211
2420
  const notify = (rowIdxs, source, tick, fromChart = false) => {
2212
2421
  current = rowIdxs;
2213
2422
  selectionFromChart = rowIdxs.length > 0 && fromChart;
2214
2423
  activeTickEl = rowIdxs.length ? tick ?? null : null;
2215
2424
  selectionIsPeriod = rowIdxs.length > 0 && periodTickSuppressesFeedback(!!tick, chartOwnsTimeline(container));
2216
2425
  paintSelection();
2426
+ paintGlyphLayer();
2217
2427
  for (const cb of subs) {
2218
2428
  try {
2219
2429
  cb(rowIdxs, source);
@@ -2313,6 +2523,20 @@ function createChartHost(container, config) {
2313
2523
  }
2314
2524
  }
2315
2525
  };
2526
+ const keepScroll = config.restoreScroll !== false;
2527
+ let chartSource;
2528
+ const scrollKey = () => {
2529
+ if (config.code) return config.code;
2530
+ if (chartSource === void 0 && typeof config.renderFn === "function") {
2531
+ try {
2532
+ chartSource = Function.prototype.toString.call(config.renderFn);
2533
+ } catch {
2534
+ return config.renderFn;
2535
+ }
2536
+ }
2537
+ return chartSource;
2538
+ };
2539
+ let hasRendered = false;
2316
2540
  const runPostRenderPasses = () => {
2317
2541
  if (zoomMemory && zoomCap) {
2318
2542
  try {
@@ -2400,9 +2624,11 @@ function createChartHost(container, config) {
2400
2624
  } catch {
2401
2625
  }
2402
2626
  }
2627
+ const scrollMem = takeScroll(container);
2403
2628
  if (fitOpts !== false) {
2404
2629
  try {
2405
- const r = fitRenderedChart(container, fitOpts === true ? {} : fitOpts);
2630
+ const base = fitOpts === true ? {} : fitOpts;
2631
+ const r = fitRenderedChart(container, keepScroll ? { ...base, restoreScroll: { memory: scrollMem, key: scrollKey() } } : base);
2406
2632
  config.onFit?.(r);
2407
2633
  } catch {
2408
2634
  }
@@ -2411,6 +2637,7 @@ function createChartHost(container, config) {
2411
2637
  drawAnnotations();
2412
2638
  } catch {
2413
2639
  }
2640
+ paintGlyphLayer();
2414
2641
  try {
2415
2642
  lasso = installLasso({
2416
2643
  container,
@@ -2437,9 +2664,12 @@ function createChartHost(container, config) {
2437
2664
  if (destroyed) return;
2438
2665
  const seq2 = ++renderSeq;
2439
2666
  stopAnim();
2667
+ followGlyphs(false);
2440
2668
  disposeLasso();
2441
2669
  flushZoom();
2670
+ if (hasRendered && keepScroll) rememberScroll(container, scrollKey());
2442
2671
  container.innerHTML = "";
2672
+ hasRendered = true;
2443
2673
  if (!renderFn) {
2444
2674
  if (!config.code) throw new Error("createChartHost: provide code or renderFn");
2445
2675
  renderFn = compileRenderFn(config.code, win, doc, chartD3);
@@ -2461,6 +2691,8 @@ function createChartHost(container, config) {
2461
2691
  if (resolved.geoPoint === void 0 && data.geoPoint) resolved = { ...resolved, geoPoint: data.geoPoint };
2462
2692
  if (resolved.geoPointDest === void 0 && data.geoPointDest) resolved = { ...resolved, geoPointDest: data.geoPointDest };
2463
2693
  if (resolved.dateCellsAreUtcDays === void 0 && data.dateCellsAreUtcDays) resolved = { ...resolved, dateCellsAreUtcDays: true };
2694
+ if (resolved.suppressSelectionGlyph === void 0 && data.meta?.suppressSelectionGlyph === true)
2695
+ resolved = { ...resolved, suppressSelectionGlyph: true };
2464
2696
  let outcome;
2465
2697
  zoomCap = null;
2466
2698
  if (zoomMemory && config.code && !config.renderFn) {
@@ -2604,9 +2836,18 @@ function createChartHost(container, config) {
2604
2836
  dimVarSet = false;
2605
2837
  }
2606
2838
  annotationLayer.destroy();
2839
+ if (hasRendered && keepScroll) rememberScroll(container, scrollKey());
2607
2840
  unpinScrolledAxis(container);
2608
2841
  flushZoom();
2842
+ followGlyphs(false);
2609
2843
  container.innerHTML = "";
2844
+ if (glyphPositionWas !== null) {
2845
+ try {
2846
+ if (container.style.position === "relative") container.style.position = glyphPositionWas;
2847
+ } catch {
2848
+ }
2849
+ glyphPositionWas = null;
2850
+ }
2610
2851
  }
2611
2852
  };
2612
2853
  return host;
@@ -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-IUXNCX7D.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;
@@ -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) {
@@ -304,8 +304,12 @@ function createMarkResolver(env) {
304
304
  return { isInvisibleStrokePath, distToPathCenterline, refineHitCorridor, findMark, rowIdxsFromMark, penetrateOverlayAt, resolveByGeometry };
305
305
  }
306
306
  function collectD3GlyphCenters(container, baseRect, out) {
307
+ collectGlyphCentersWhere(container, baseRect, out);
308
+ }
309
+ function collectGlyphCentersWhere(container, baseRect, out, filter) {
307
310
  const Point = container.ownerDocument?.defaultView?.DOMPoint ?? globalThis.DOMPoint;
308
- const marks = container.querySelectorAll(`.${MARK_CLASS}.${MARK_SELECTED_CLASS}`);
311
+ let marks = container.querySelectorAll(`.${MARK_CLASS}.${MARK_SELECTED_CLASS}`);
312
+ if (filter?.shown) marks = Array.from(marks).filter((m) => filter.shown(m));
309
313
  const rects = [];
310
314
  for (let i = 0; i < marks.length; i++) {
311
315
  const r = marks[i].getBoundingClientRect();
@@ -385,6 +389,7 @@ function collectD3GlyphCenters(container, baseRect, out) {
385
389
  sx = isRect ? r.left + r.width * 0.8 : r.left + r.width / 2;
386
390
  sy = isRect ? r.top + r.height * 0.2 : r.top + r.height / 2;
387
391
  }
392
+ if (filter?.at && !filter.at(el, sx, sy)) continue;
388
393
  out.push({ x: sx - baseRect.left, y: sy - baseRect.top });
389
394
  }
390
395
  }
@@ -439,6 +444,7 @@ export {
439
444
  clickWasDrag,
440
445
  createMarkResolver,
441
446
  collectD3GlyphCenters,
447
+ collectGlyphCentersWhere,
442
448
  SELECTION_GLYPH_CLASS,
443
449
  clearSelectionGlyphs,
444
450
  paintSelectionGlyphs
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-HXH6NIRV.mjs";
100
+ } from "./chunk-FHHT7DBM.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-IZXVV74H.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-IUXNCX7D.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-56IZWXW2.mjs";
18
- import "./chunk-IZXVV74H.mjs";
19
- import "./chunk-IUXNCX7D.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-56IZWXW2.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-HXH6NIRV.mjs";
26
- import "./chunk-IZXVV74H.mjs";
27
- import "./chunk-XNS34AMM.mjs";
28
- import "./chunk-IUXNCX7D.mjs";
29
- import "./chunk-QHIPRAZK.mjs";
25
+ } from "./chunk-FHHT7DBM.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,7 +69,9 @@ function BicChart(props) {
69
69
  viewState,
70
70
  zoomMemory,
71
71
  labelContrast,
72
+ restoreScroll,
72
73
  onLabelContrast,
74
+ onSelectionGlyphs,
73
75
  layoutPass,
74
76
  onInvalidSentinel,
75
77
  id,
@@ -116,6 +118,8 @@ function BicChart(props) {
116
118
  onSelectRef.current = onSelect;
117
119
  const onLabelContrastRef = useRef(onLabelContrast);
118
120
  onLabelContrastRef.current = onLabelContrast;
121
+ const onSelectionGlyphsRef = useRef(onSelectionGlyphs);
122
+ onSelectionGlyphsRef.current = onSelectionGlyphs;
119
123
  const onInvalidSentinelRef = useRef(onInvalidSentinel);
120
124
  onInvalidSentinelRef.current = onInvalidSentinel;
121
125
  const onAnnotationClickRef = useRef(onAnnotationClick);
@@ -154,7 +158,9 @@ function BicChart(props) {
154
158
  zoomMemory,
155
159
  labelContrast,
156
160
  layoutPass,
161
+ restoreScroll,
157
162
  onLabelContrast: (r) => onLabelContrastRef.current?.(r),
163
+ onSelectionGlyphs: (r) => onSelectionGlyphsRef.current?.(r),
158
164
  // A GETTER, not a wrapper: the host decides whether to
159
165
  // swallow the sentinel throw by whether this is SET, so
160
166
  // an always-present wrapper would hide the throw from a
@@ -185,7 +191,7 @@ function BicChart(props) {
185
191
  host.destroy();
186
192
  hostRef.current = null;
187
193
  };
188
- }, [code, renderFn, d3, geoKind, labelContrast, layoutPass, controls]);
194
+ }, [code, renderFn, d3, geoKind, labelContrast, layoutPass, restoreScroll, controls]);
189
195
  useLayoutEffect(() => {
190
196
  const host = hostRef.current;
191
197
  if (!host || !selects) return;
@@ -211,7 +217,7 @@ function BicChart(props) {
211
217
  b.detach();
212
218
  if (bindingRef.current === b) bindingRef.current = null;
213
219
  };
214
- }, [selects, code, renderFn, d3, geoKind, labelContrast, layoutPass]);
220
+ }, [selects, code, renderFn, d3, geoKind, labelContrast, layoutPass, restoreScroll]);
215
221
  useEffect(() => {
216
222
  if (!hostRef.current || !options) return;
217
223
  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
@@ -163,6 +164,10 @@ export interface RenderOptions {
163
164
  /** How far a selection dims the marks outside it, as a share of full opacity (DIM_OPACITY_MIN-1). Absent: the
164
165
  * stylesheet decides - DIM_OPACITY_DEFAULT, or whatever the page sets on --lch-dim-opacity. */
165
166
  dimOpacity?: number;
167
+ /** A small glyph on each selected mark: "dot" or "check". Absent or "none": no glyph (the default). */
168
+ selectionGlyph?: "none" | "dot" | "check";
169
+ /** A host FACT from the generate answer: this chart's family never takes a selection glyph. */
170
+ suppressSelectionGlyph?: boolean;
166
171
  animAutoPlay?: boolean;
167
172
  animPlaySpeedMs?: number;
168
173
  animLoopDelaySec?: number;
@@ -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;
@@ -62,6 +62,9 @@ export interface ChartHostConfig {
62
62
  meta?: {
63
63
  hostContract?: string;
64
64
  chart?: string;
65
+ /** The generate answer's fact that this chart's family never takes a selection glyph. Promoted into
66
+ * options.suppressSelectionGlyph, so a host that hands over the whole payload honors it unasked. */
67
+ suppressSelectionGlyph?: boolean;
65
68
  };
66
69
  };
67
70
  /** Generated render() source — compiled exactly like the visual/preview/exec-gate. */
@@ -178,6 +181,15 @@ export interface ChartHostConfig {
178
181
  * reading without touching the chart (`grow: false`, `applyOverflow: false`).
179
182
  */
180
183
  fit?: boolean | FitRenderedChartOptions;
184
+ /**
185
+ * KEEP THE READER'S SCROLL when the SAME chart is drawn again - a resize, a filter, a theme
186
+ * switch, or a new host on the same element. Default true: the offset is parked on the
187
+ * container before each clear and put back by the fit, clamped to what the new layout can
188
+ * scroll; a different chart (other code, or a renderFn with other source) opens at the top. Pass `false`
189
+ * for a page that manages the container's scrolling itself: nothing is remembered or restored,
190
+ * and a memory an earlier host left on the element is discarded at the next render.
191
+ */
192
+ restoreScroll?: boolean;
181
193
  /** The fit result after each render - what was grown, and how the chart measured. */
182
194
  onFit?: (result: FitRenderedChartResult) => void;
183
195
  /**
@@ -199,6 +211,18 @@ export interface ChartHostConfig {
199
211
  labelContrast?: boolean | LabelContrastOptions;
200
212
  /** What the label-contrast pass did after each render - counts, so a host can log them. */
201
213
  onLabelContrast?: (report: LabelContrastReport) => void;
214
+ /**
215
+ * What the selection-glyph pass did after each paint of an active selection - counts, so a host can log them.
216
+ * Called only when options.selectionGlyph is "dot" or "check"; `suppressed` is true when the chart's family never
217
+ * takes a glyph (options.suppressSelectionGlyph, or the same fact in data.meta). A repaint that only follows marks
218
+ * moving without a render (a pan, a transition) is not reported again.
219
+ */
220
+ onSelectionGlyphs?: (report: {
221
+ mode: "dot" | "check";
222
+ drawn: number;
223
+ total: number;
224
+ suppressed: boolean;
225
+ }) => void;
202
226
  /**
203
227
  * A LAYOUT PASS the host brings, run after every render (both the synchronous and the promise lane, and every
204
228
  * setOptions / setData re-render) AFTER the label-contrast pass and BEFORE the hit-target heal, the selection
@@ -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";
@@ -46,8 +46,17 @@ export interface BicChartProps {
46
46
  * themed page is not white, and readability is measured against it. See createChartHost.
47
47
  */
48
48
  labelContrast?: ChartHostConfig["labelContrast"];
49
+ /**
50
+ * KEEP THE READER'S SCROLL when the same chart repaints (new data, new options, a rebuild).
51
+ * Default on; a different chart opens at the top. Pass `false` for a page that manages the
52
+ * chart's scrolling itself. Host config: changing it rebuilds the host. See createChartHost.
53
+ */
54
+ restoreScroll?: ChartHostConfig["restoreScroll"];
49
55
  /** What the label-contrast pass did after each render - counts, for a page that logs. */
50
56
  onLabelContrast?: ChartHostConfig["onLabelContrast"];
57
+ /** What the selection-glyph pass did after each paint, when `options.selectionGlyph` is set - counts, for a page
58
+ * that logs. The glyph itself, the dim level and the suppression fact ride the `options` prop. */
59
+ onSelectionGlyphs?: ChartHostConfig["onSelectionGlyphs"];
51
60
  /**
52
61
  * A LAYOUT PASS run after every render, after label contrast and before the fit - e.g.
53
62
  * `createLegendFitPass()` from "@bicharts/chart-host/legend-fit", which slides a legend clear of
@@ -169,6 +169,18 @@ export declare function collectD3GlyphCenters(container: Element, baseRect: {
169
169
  left: number;
170
170
  top: number;
171
171
  }, out: GlyphCenter[]): void;
172
+ /** Which selected marks get a glyph, for a host that draws its own layer: `shown` drops a mark before anything is
173
+ * measured against it (a mark the reader can't see), `at` drops one by where its glyph would go, in client
174
+ * coordinates (a mark panned or zoomed out of its plot). Either absent keeps every mark. */
175
+ export interface GlyphCenterFilter {
176
+ shown?(mark: Element): boolean;
177
+ at?(mark: Element, clientX: number, clientY: number): boolean;
178
+ }
179
+ /** collectD3GlyphCenters with a filter. Package-internal: the exported function keeps every mark, as it always has. */
180
+ export declare function collectGlyphCentersWhere(container: Element, baseRect: {
181
+ left: number;
182
+ top: number;
183
+ }, out: GlyphCenter[], filter?: GlyphCenterFilter): void;
172
184
  /** The class every painted selection glyph carries. */
173
185
  export declare const SELECTION_GLYPH_CLASS = "lch-sel-glyph";
174
186
  /** Removes every glyph (every child) from the overlay. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bicharts/chart-host",
3
- "version": "0.6.88",
3
+ "version": "0.6.90",
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",