@bicharts/chart-host 0.6.85 → 0.6.87

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
@@ -351,6 +351,30 @@ const report = await assertViewStateConformance(service, {
351
351
  expect(report.skipped).toEqual([]); // nothing went unchecked
352
352
  ```
353
353
 
354
+ **A zoomed or panned view** is not in the bag: d3-zoom keeps it on the DOM node, and every redraw
355
+ (a resize, a cross-filter, a restyle) opens the chart at full extent again. `@bicharts/chart-host/zoom-memory`
356
+ remembers it. You supply where the view lives; the hook does the rest on every render:
357
+
358
+ ```ts
359
+ import { createZoomMemoryHook, type ZoomMemoryHost } from "@bicharts/chart-host/zoom-memory";
360
+
361
+ const zoomHost: ZoomMemoryHost = {
362
+ liveZoomState: null, zoomWatchKey: "", zoomWatchAttached: false, zoomSettleTimer: 0, zoomContainer: el,
363
+ rememberAcrossSessions: () => prefs.rememberZoom, // the persisted tier is on now?
364
+ readPersistedZoom: () => localStorage.getItem("chart-zoom"),
365
+ persistZoom: json => localStorage.setItem("chart-zoom", json), // "" forgets
366
+ log: () => {},
367
+ };
368
+ const zoomMemory = createZoomMemoryHook(zoomHost); // once per container, reused by every host on it
369
+ const host = createChartHost(el, { code, data, d3, zoomMemory });
370
+ ```
371
+
372
+ The view survives every redraw in the session whatever the preference says, and a close and reopen
373
+ only while `rememberAcrossSessions()` is true. It is keyed by the chart's code and the viewport size
374
+ (to the nearest 32 px), so a regenerated chart or a real resize opens at full extent instead of
375
+ somewhere arbitrary. A chart with several zoomable panels is left alone. In React, pass the hook as
376
+ the `zoomMemory` prop of `<BicChart>`.
377
+
354
378
  ## Keeping a drawn chart readable inside its frame
355
379
 
356
380
  A generated chart cannot know how wide its own legend text will come out, where its tick labels will
@@ -394,6 +418,27 @@ a throw must never fail a chart that already drew. The geometry behind each phas
394
418
  `planAxisTickThin`, `planFrameFit`, ...) is exported as plain functions over boxes, for a host that
395
419
  measures on its own terms.
396
420
 
421
+ **Running them after every render.** `createLegendFitPass()` is both passes in the order that works
422
+ (the legend first, then the frame fit, which measures what the legend pass left), with nothing to log
423
+ and no policy. Hand it to the host as `layoutPass`, and the host runs it after every render, re-render
424
+ and async settle, after the label colors are fixed and before anything measures the marks or the
425
+ frame:
426
+
427
+ ```ts
428
+ import { createChartHost } from "@bicharts/chart-host";
429
+ import { createLegendFitPass } from "@bicharts/chart-host/legend-fit";
430
+
431
+ const layoutPass = createLegendFitPass({
432
+ legend: true, // or "narrow" to act only on large overlaps, or false
433
+ frameFit: true,
434
+ onReport: r => console.debug(r.legend, r.frame), // what each run did; log what you like
435
+ });
436
+ const host = createChartHost(el, { code, data, d3, layoutPass });
437
+ ```
438
+
439
+ In React it is the `layoutPass` prop of `<BicChart>`. Build the pass once, at module scope or in a
440
+ `useMemo`: a new function on every render is a new host config, and the chart is rebuilt.
441
+
397
442
  ## Architecture — three layers, and why it matters if you contribute
398
443
 
399
444
  Read this before adding code. The package has **three** concerns, and they are independent.
package/dist/card.mjs CHANGED
@@ -1,8 +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
3
  computeSelectionCard
4
- } from "./chunk-GG4U66E4.mjs";
5
- import "./chunk-2ZUUIZWL.mjs";
4
+ } from "./chunk-Z6OZDX4Z.mjs";
5
+ import "./chunk-KQN5C7SZ.mjs";
6
6
  import "./chunk-GHODWOAL.mjs";
7
7
 
8
8
  // src/selectionCardChrome.ts
@@ -3,13 +3,6 @@ import {
3
3
  LASSO_OUTLINE_CLASS,
4
4
  installLasso
5
5
  } from "./chunk-QIRLWJBI.mjs";
6
- import {
7
- Fn,
8
- J3,
9
- X,
10
- Xn,
11
- Yn
12
- } from "./chunk-2ZUUIZWL.mjs";
13
6
  import {
14
7
  censusHitBands,
15
8
  ensureCrossfilterHitTargets
@@ -33,6 +26,13 @@ import {
33
26
  fitRenderedChart,
34
27
  unpinScrolledAxis
35
28
  } from "./chunk-BQFG5HWZ.mjs";
29
+ import {
30
+ Fn,
31
+ J3,
32
+ X,
33
+ Xn,
34
+ Yn
35
+ } from "./chunk-KQN5C7SZ.mjs";
36
36
  import {
37
37
  NA_ISO3,
38
38
  NA_LAT_MAX,
@@ -2015,6 +2015,7 @@ function createChartHost(container, config) {
2015
2015
  const doc = container.ownerDocument;
2016
2016
  const win = doc?.defaultView ?? globalThis;
2017
2017
  const d3 = config.d3 ?? globalThis.d3 ?? win?.d3;
2018
+ const chartD3 = writableD3(d3);
2018
2019
  let data = config.data;
2019
2020
  {
2020
2021
  const stamped = config.data?.meta?.hostContract;
@@ -2285,7 +2286,24 @@ function createChartHost(container, config) {
2285
2286
  }
2286
2287
  }
2287
2288
  };
2289
+ const zoomMemory = config.zoomMemory ?? null;
2290
+ let zoomCap = null;
2291
+ let zoomKey = "";
2292
+ const flushZoom = () => {
2293
+ if (zoomMemory) {
2294
+ try {
2295
+ zoomMemory.flush();
2296
+ } catch {
2297
+ }
2298
+ }
2299
+ };
2288
2300
  const runPostRenderPasses = () => {
2301
+ if (zoomMemory && zoomCap) {
2302
+ try {
2303
+ zoomMemory.restore(container, zoomCap, zoomKey, chartD3);
2304
+ } catch {
2305
+ }
2306
+ }
2289
2307
  try {
2290
2308
  const r = scopeChartIds(container, idScope);
2291
2309
  if (r.collisions.length && !warnedIds) {
@@ -2312,6 +2330,12 @@ function createChartHost(container, config) {
2312
2330
  } catch {
2313
2331
  }
2314
2332
  }
2333
+ if (config.layoutPass) {
2334
+ try {
2335
+ config.layoutPass(container);
2336
+ } catch {
2337
+ }
2338
+ }
2315
2339
  ensureCrossfilterHitTargets(container, doc);
2316
2340
  paintSelection();
2317
2341
  try {
@@ -2398,10 +2422,11 @@ function createChartHost(container, config) {
2398
2422
  const seq2 = ++renderSeq;
2399
2423
  stopAnim();
2400
2424
  disposeLasso();
2425
+ flushZoom();
2401
2426
  container.innerHTML = "";
2402
2427
  if (!renderFn) {
2403
2428
  if (!config.code) throw new Error("createChartHost: provide code or renderFn");
2404
- renderFn = compileRenderFn(config.code, win, doc, d3);
2429
+ renderFn = compileRenderFn(config.code, win, doc, chartD3);
2405
2430
  }
2406
2431
  if (resolved.geo === void 0 && config.geoKind) {
2407
2432
  const g = config.geoProvider?.(config.geoKind) ?? geoFromCache(config.geoKind);
@@ -2421,8 +2446,28 @@ function createChartHost(container, config) {
2421
2446
  if (resolved.geoPointDest === void 0 && data.geoPointDest) resolved = { ...resolved, geoPointDest: data.geoPointDest };
2422
2447
  if (resolved.dateCellsAreUtcDays === void 0 && data.dateCellsAreUtcDays) resolved = { ...resolved, dateCellsAreUtcDays: true };
2423
2448
  let outcome;
2449
+ zoomCap = null;
2450
+ if (zoomMemory && config.code && !config.renderFn) {
2451
+ try {
2452
+ zoomKey = zoomMemory.keyFor(
2453
+ config.code,
2454
+ resolved.width ?? container.clientWidth,
2455
+ resolved.height ?? container.clientHeight
2456
+ );
2457
+ zoomCap = zoomMemory.capture(chartD3);
2458
+ } catch {
2459
+ zoomCap = null;
2460
+ }
2461
+ }
2424
2462
  try {
2425
- outcome = renderFn(container, { columns: data.columns, rows: data.rows }, resolved);
2463
+ try {
2464
+ outcome = renderFn(container, { columns: data.columns, rows: data.rows }, resolved);
2465
+ } finally {
2466
+ try {
2467
+ zoomCap?.release();
2468
+ } catch {
2469
+ }
2470
+ }
2426
2471
  } catch (err) {
2427
2472
  const fatal = renderFailure(err);
2428
2473
  if (fatal) {
@@ -2537,6 +2582,7 @@ function createChartHost(container, config) {
2537
2582
  subs.clear();
2538
2583
  annotationLayer.destroy();
2539
2584
  unpinScrolledAxis(container);
2585
+ flushZoom();
2540
2586
  container.innerHTML = "";
2541
2587
  }
2542
2588
  };
@@ -841,6 +841,12 @@ var N = /[^\p{L}\p{M}\p{N}\x00-\x7F]/gu;
841
841
  function f(n) {
842
842
  return n ? String(n).normalize("NFC").replace(F2, "$1 $2").replace(S, " ").replace(N, " ").toLowerCase().split(/\s+/).filter((e) => e.length > 0) : [];
843
843
  }
844
+ function B2(n) {
845
+ if (!n) return 0;
846
+ let e = 0xcbf29ce484222325n, t = 0x100000001b3n, r2 = e;
847
+ for (let o2 = 0; o2 < n.length; o2++) r2 ^= BigInt(n.charCodeAt(o2)), r2 = r2 * t & 0xFFFFFFFFFFFFFFFFn;
848
+ return Number(r2 & 0x1FFFFFFFFFFFFFn);
849
+ }
844
850
  function Z(n) {
845
851
  let e = Date.parse(n);
846
852
  if (isNaN(e)) return null;
@@ -1275,6 +1281,7 @@ var Vr2 = Object.freeze(["open", "generate", "version", "rebind", "data", "sampl
1275
1281
  var Ae = 2 ** 31 - 1;
1276
1282
 
1277
1283
  export {
1284
+ B2 as B,
1278
1285
  Z,
1279
1286
  g2 as g,
1280
1287
  X2 as X,
@@ -6,7 +6,7 @@ import {
6
6
  ii,
7
7
  ni,
8
8
  xt
9
- } from "./chunk-2ZUUIZWL.mjs";
9
+ } from "./chunk-KQN5C7SZ.mjs";
10
10
 
11
11
  // src/sourceDateFormat.ts
12
12
  function formatSourceDate(raw, opts = {}) {
package/dist/index.mjs CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  formatSourceDateFor,
6
6
  isExcelDateFormat,
7
7
  normaliseAggregation
8
- } from "./chunk-GG4U66E4.mjs";
8
+ } from "./chunk-Z6OZDX4Z.mjs";
9
9
  import {
10
10
  captureSvgSnapshot,
11
11
  chartSvgOf,
@@ -97,7 +97,7 @@ import {
97
97
  toRGBA,
98
98
  toSourceRows,
99
99
  valuePlacementFlag
100
- } from "./chunk-CDWNZHIN.mjs";
100
+ } from "./chunk-HZNMMRQ3.mjs";
101
101
  import {
102
102
  LASSO_AXES_SLOT,
103
103
  LASSO_COUNT_CLASS,
@@ -123,11 +123,6 @@ import {
123
123
  pointInShape,
124
124
  registerLassoAction
125
125
  } from "./chunk-QIRLWJBI.mjs";
126
- import {
127
- _3,
128
- l,
129
- o
130
- } from "./chunk-2ZUUIZWL.mjs";
131
126
  import {
132
127
  MIN_HIT_BAND_PX,
133
128
  censusHitBands,
@@ -185,6 +180,11 @@ import {
185
180
  svgInkReach,
186
181
  unpinScrolledAxis
187
182
  } from "./chunk-BQFG5HWZ.mjs";
183
+ import {
184
+ _3,
185
+ l,
186
+ o
187
+ } from "./chunk-KQN5C7SZ.mjs";
188
188
  import "./chunk-A2GMXZP7.mjs";
189
189
  import {
190
190
  ACTIVE_TICK_CLASS,
@@ -1,6 +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
- chartSvgOf
3
+ chartSvgOf,
4
+ tableHostOf
4
5
  } from "./chunk-ZGQURBRJ.mjs";
5
6
  import {
6
7
  FIT_CONTENT_SELECTOR,
@@ -1554,6 +1555,36 @@ function reconcileBottomTextPhase(svg) {
1554
1555
  }
1555
1556
  return { applied: true, bt: `moved${moved} dy${Math.round(maxDy)}` };
1556
1557
  }
1558
+
1559
+ // src/legendFit/pass.ts
1560
+ var NARROW = { minOverlap: 16, maxGutterFraction: 0.2 };
1561
+ var errorText = (e) => String(e?.message ?? e).slice(0, 120);
1562
+ function createLegendFitPass(opts = {}) {
1563
+ const legendOn = opts.legend ?? true;
1564
+ const frameOn = opts.frameFit ?? true;
1565
+ return (container) => {
1566
+ const report = { legend: { skipped: "off" }, frame: { skipped: "off" } };
1567
+ if (legendOn !== false) {
1568
+ try {
1569
+ const svg = chartSvgOf(container);
1570
+ report.legend = svg ? reconcileLegendInSvg(svg, legendOn === "narrow" ? NARROW : {}) : { skipped: "no-svg" };
1571
+ } catch (e) {
1572
+ report.legend = { error: errorText(e) };
1573
+ }
1574
+ }
1575
+ if (frameOn) {
1576
+ try {
1577
+ report.frame = tableHostOf(container) ? { skipped: "table-lane" } : fitTextToFrame(container) ?? { skipped: "nothing-to-measure" };
1578
+ } catch (e) {
1579
+ report.frame = { error: errorText(e) };
1580
+ }
1581
+ }
1582
+ try {
1583
+ opts.onReport?.(report);
1584
+ } catch {
1585
+ }
1586
+ };
1587
+ }
1557
1588
  export {
1558
1589
  AXIS_THIN_GAP,
1559
1590
  AXIS_THIN_MIN_TICKS,
@@ -1583,6 +1614,7 @@ export {
1583
1614
  colorbarProbePoints,
1584
1615
  colorbarReconcileSide,
1585
1616
  colorbarSitsOnMarks,
1617
+ createLegendFitPass,
1586
1618
  cutToFit,
1587
1619
  findColorbarFurniture,
1588
1620
  findSignatureLegendGroups,
package/dist/react.mjs CHANGED
@@ -22,12 +22,12 @@ import {
22
22
  loadGeo,
23
23
  syncMemberSelection,
24
24
  toSourceRows
25
- } from "./chunk-CDWNZHIN.mjs";
25
+ } from "./chunk-HZNMMRQ3.mjs";
26
26
  import "./chunk-QIRLWJBI.mjs";
27
- import "./chunk-2ZUUIZWL.mjs";
28
27
  import "./chunk-7HKVV5T5.mjs";
29
28
  import "./chunk-IURROJT7.mjs";
30
29
  import "./chunk-BQFG5HWZ.mjs";
30
+ import "./chunk-KQN5C7SZ.mjs";
31
31
  import "./chunk-A2GMXZP7.mjs";
32
32
  import "./chunk-MR6TL7QZ.mjs";
33
33
  import "./chunk-GHODWOAL.mjs";
@@ -67,8 +67,10 @@ function BicChart(props) {
67
67
  d3,
68
68
  geoKind,
69
69
  viewState,
70
+ zoomMemory,
70
71
  labelContrast,
71
72
  onLabelContrast,
73
+ layoutPass,
72
74
  onInvalidSentinel,
73
75
  id,
74
76
  filteredBy,
@@ -149,7 +151,9 @@ function BicChart(props) {
149
151
  d3,
150
152
  geoKind,
151
153
  viewState,
154
+ zoomMemory,
152
155
  labelContrast,
156
+ layoutPass,
153
157
  onLabelContrast: (r) => onLabelContrastRef.current?.(r),
154
158
  // A GETTER, not a wrapper: the host decides whether to
155
159
  // swallow the sentinel throw by whether this is SET, so
@@ -181,7 +185,7 @@ function BicChart(props) {
181
185
  host.destroy();
182
186
  hostRef.current = null;
183
187
  };
184
- }, [code, renderFn, d3, geoKind, labelContrast, controls]);
188
+ }, [code, renderFn, d3, geoKind, labelContrast, layoutPass, controls]);
185
189
  useLayoutEffect(() => {
186
190
  const host = hostRef.current;
187
191
  if (!host || !selects) return;
@@ -207,7 +211,7 @@ function BicChart(props) {
207
211
  b.detach();
208
212
  if (bindingRef.current === b) bindingRef.current = null;
209
213
  };
210
- }, [selects, code, renderFn, d3, geoKind, labelContrast]);
214
+ }, [selects, code, renderFn, d3, geoKind, labelContrast, layoutPass]);
211
215
  useEffect(() => {
212
216
  if (!hostRef.current || !options) return;
213
217
  if (builtWithRef.current?.optKey === optKey) return;
@@ -18,6 +18,7 @@ import { type FitRenderedChartOptions, type FitRenderedChartResult } from "./fit
18
18
  import { type LabelContrastOptions, type LabelContrastReport } from "./labelContrastDom";
19
19
  import { type IdScopeReport } from "./idScope";
20
20
  import { type MarkAnnotation, type AnnotationReport } from "./annotations";
21
+ import type { ZoomMemoryHook } from "./zoomMemory/zoomMemoryDom";
21
22
  export type RenderFn = (container: HTMLElement, data: any, options: RenderOptions) => void;
22
23
  /**
23
24
  * THE SESSION PROVIDER — what every host got hard-coded before contract 1.6.0, now named.
@@ -73,6 +74,14 @@ export interface ChartHostConfig {
73
74
  * session store parked on the container — the behaviour every host had before 1.6.0.
74
75
  * A raw `options.setUiState` still wins over this, so an existing caller is untouched. */
75
76
  viewState?: ViewStateProvider;
77
+ /**
78
+ * REMEMBER THE READER'S ZOOM AND PAN on a chart that calls d3.zoom(), across every re-render (and across a
79
+ * close and reopen, if the host's store says so). Build it with createZoomMemoryHook() from
80
+ * "@bicharts/chart-host/zoom-memory", ONCE per container, and hand the same hook to every host created on
81
+ * that container. Absent (the default), d3.zoom is never wrapped and nothing is listened for. It applies to a
82
+ * chart compiled from `code` only: a precompiled `renderFn` closed over its own d3.
83
+ */
84
+ zoomMemory?: ZoomMemoryHook | null;
76
85
  /** D3 v7 instance handed to the compiled code. Default: (globalThis as any).d3. */
77
86
  d3?: any;
78
87
  /** Attach geometry for this geoKind as options.geo (choropleths / basemaps). Resolved
@@ -190,6 +199,15 @@ export interface ChartHostConfig {
190
199
  labelContrast?: boolean | LabelContrastOptions;
191
200
  /** What the label-contrast pass did after each render - counts, so a host can log them. */
192
201
  onLabelContrast?: (report: LabelContrastReport) => void;
202
+ /**
203
+ * A LAYOUT PASS the host brings, run after every render (both the synchronous and the promise lane, and every
204
+ * setOptions / setData re-render) AFTER the label-contrast pass and BEFORE the hit-target heal, the selection
205
+ * repaint, the censuses and the fit - the order a pass that moves the legend or grows the viewBox needs, because
206
+ * the fit must measure the final layout. Absent by default: the main entry carries no such pass. Build one with
207
+ * createLegendFitPass() from "@bicharts/chart-host/legend-fit". It must not throw (a throw is swallowed and the
208
+ * render stands); it reports through its own callback, never through this host.
209
+ */
210
+ layoutPass?: (container: HTMLElement) => void;
193
211
  /**
194
212
  * What the id-scoping pass did after each render: how many of the chart's ids got this host's suffix, and which
195
213
  * of them also existed elsewhere in the page (a clash it prevented - two same-size charts naming one clip path).
@@ -2,3 +2,4 @@ export { MIN_OVERLAP, MAX_GUTTER_FRACTION, COLORBAR_ON_PLOT_COVER, MAX_FIT_FRACT
2
2
  export { FRAME_PAD, MAX_EXTEND_FRACTION, FIT_TYPE_FLOOR, FIT_SHRINK_FLOOR, planFrameFit, viewBoxAttr, labelRoom, labelFitDecision, cutToFit, type FrameFitPlan, type TextAnchor, type LabelFitDecision, } from "./frameFit";
3
3
  export { reconcileLegendInSvg, findSignatureLegendGroups, findColorbarFurniture, smallestRenderedTextPx, styleDeclaresScroll, type LegendReconcileResult, type AxisThinReport, } from "./legendReconcileDom";
4
4
  export { FRAME_FIT_TEXT_CAP, fitTextToFrame, fitDeferredLabels, fitLabelToRoom, isClippedOrScrolled, type FrameFitOutcome, type DeferredLabelFitReport, } from "./frameFitDom";
5
+ export { createLegendFitPass, type LegendFitPassOptions, type LegendFitReport, type LegendFitStrictness } from "./pass";
@@ -0,0 +1,29 @@
1
+ import { type LegendReconcileResult } from "./legendReconcileDom";
2
+ import { type FrameFitOutcome } from "./frameFitDom";
3
+ /** "smart" acts on smaller overlaps with larger moves; "narrow" only on large, unambiguous ones. */
4
+ export type LegendFitStrictness = "smart" | "narrow";
5
+ export interface LegendFitPassOptions {
6
+ /** Run the legend reconcile (legend slide, colorbar, axis and label-row thinning, bottom-text push, content fit). Default true. */
7
+ legend?: boolean | LegendFitStrictness;
8
+ /** Run the frame fit (viewBox growth for text just past the frame, then shrink-and-cut of the labels it deferred). Default true. */
9
+ frameFit?: boolean;
10
+ /** What each run did. Counts and reasons only; the host decides what to log. */
11
+ onReport?: (report: LegendFitReport) => void;
12
+ }
13
+ export interface LegendFitReport {
14
+ legend: LegendReconcileResult | {
15
+ skipped: "off" | "no-svg";
16
+ } | {
17
+ error: string;
18
+ };
19
+ frame: FrameFitOutcome | {
20
+ skipped: "off" | "table-lane" | "nothing-to-measure";
21
+ } | {
22
+ error: string;
23
+ };
24
+ }
25
+ /**
26
+ * Build the pass once (module scope, or a memo) and hand it to createChartHost as `layoutPass`: a new function
27
+ * on every render is a new host config. Each run never throws; a pass that fails reports `{ error }`.
28
+ */
29
+ export declare function createLegendFitPass(opts?: LegendFitPassOptions): (container: HTMLElement) => void;
@@ -32,6 +32,13 @@ export interface BicChartProps {
32
32
  * default could pick between them, which is why this is a prop rather than a flag.
33
33
  */
34
34
  viewState?: ChartHostConfig["viewState"];
35
+ /**
36
+ * REMEMBER THE READER'S ZOOM AND PAN on a chart that calls d3.zoom(), across re-renders and, if
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`.
40
+ */
41
+ zoomMemory?: ChartHostConfig["zoomMemory"];
35
42
  /**
36
43
  * CAN THE LABELS ON THE MARKS BE READ? Default on: after every render a label sitting on a
37
44
  * mark it cannot be read against is recoloured black or white. Pass `false` for a page that
@@ -41,6 +48,13 @@ export interface BicChartProps {
41
48
  labelContrast?: ChartHostConfig["labelContrast"];
42
49
  /** What the label-contrast pass did after each render - counts, for a page that logs. */
43
50
  onLabelContrast?: ChartHostConfig["onLabelContrast"];
51
+ /**
52
+ * A LAYOUT PASS run after every render, after label contrast and before the fit - e.g.
53
+ * `createLegendFitPass()` from "@bicharts/chart-host/legend-fit", which slides a legend clear of
54
+ * the plot and fits text to the frame. Absent by default. Build it ONCE (module scope or useMemo):
55
+ * a new function each render rebuilds the host. See createChartHost's `layoutPass`.
56
+ */
57
+ layoutPass?: ChartHostConfig["layoutPass"];
44
58
  /**
45
59
  * THE CHART STOPPED BECAUSE THE DATA LACKS A COLUMN IT IS BUILT ON. Supplied: the chart's
46
60
  * container is left empty and this is called with the chart's reason, instead of the throw
@@ -0,0 +1,2 @@
1
+ export { zoomStateKey, viewportBucket, isUsableTransform, isIdentity, transformsDiffer, serializeZoomState, parseZoomState, isZoomStateUsable, readNodeTransform, findZoomedNodes, soleZoomedNode, type ZoomTransform, type ZoomState, } from "./zoomMemory";
2
+ export { captureZoomBehaviors, restoreZoomForRender, attachZoomWatcher, captureCurrentZoom, flushPendingZoom, forgetZoom, createZoomMemoryHook, type ZoomMemoryHost, type ZoomCapture, type ZoomMemoryHook, } from "./zoomMemoryDom";
@@ -0,0 +1,59 @@
1
+ /** A d3-zoom transform, reduced to the three numbers that define it. */
2
+ export interface ZoomTransform {
3
+ k: number;
4
+ x: number;
5
+ y: number;
6
+ }
7
+ /** What gets persisted: the transform plus what it was captured against. */
8
+ export interface ZoomState extends ZoomTransform {
9
+ /** Identity of the chart + viewport this transform is meaningful for. */
10
+ key: string;
11
+ }
12
+ /** Identity is not identity across a resize (see viewportBucket) or a regeneration. */
13
+ export declare function zoomStateKey(codeHash: string | number, width: number, height: number): string;
14
+ /** Round a dimension so ordinary 1-2px reflow jitter does not discard a good transform,
15
+ * while a real resize (which changes the fitted frame the transform was chosen against)
16
+ * does. 32px is a bucket the reader cannot hit by accident and cannot miss on purpose. */
17
+ export declare function viewportBucket(px: number): number;
18
+ /** A transform we are willing to APPLY.
19
+ *
20
+ * A PERSISTED TRANSFORM IS UNTRUSTED: it round-trips through a saved file that a person can hand-edit,
21
+ * and it was captured against a geometry that may no longer exist. The floor here is only "these are
22
+ * three real numbers and the scale is not nonsense".
23
+ *
24
+ * NOTE ON WHO CLAMPS. `zoom.transform` does NOT constrain - d3-zoom applies `constrain()` in its
25
+ * wheel/drag/touch handlers and then assigns the result, but the programmatic `zoom.transform` path sets
26
+ * `__zoom` to exactly what it is given. So the host must clamp before applying, and it does it with the
27
+ * BEHAVIOR'S OWN accessors (scaleExtent(), extent(), translateExtent(), constrain()) rather than
28
+ * re-deriving the limits here - see constrainToBehavior in zoomMemoryDom.ts. That keeps one source of
29
+ * truth: the chart that built the zoom owns what a legal view is, and the host only asks it. */
30
+ export declare function isUsableTransform(t: unknown): t is ZoomTransform;
31
+ /** k=1 with no offset is the identity - the state a chart is already in after a fresh
32
+ * render. Storing or restoring it is pure noise, and persisting it would write to the
33
+ * saved file every time somebody zoomed out again. */
34
+ export declare function isIdentity(t: ZoomTransform): boolean;
35
+ /** Sub-pixel differences are re-render noise, not the reader's intent. Without this, restoring a
36
+ * transform would itself look like a change and write straight back - a persist loop. */
37
+ export declare function transformsDiffer(a: ZoomTransform | null, b: ZoomTransform | null): boolean;
38
+ export declare function serializeZoomState(t: ZoomTransform, key: string): string;
39
+ /** Never throws. A blob that will not parse is treated as absent, which is the same
40
+ * outcome as never having saved one. */
41
+ export declare function parseZoomState(raw: string | null | undefined): ZoomState | null;
42
+ /** A remembered transform is only meaningful for the chart + viewport that produced it.
43
+ * A regeneration changes the code hash; a real resize changes the viewport bucket. Either
44
+ * way the old transform describes a frame that no longer exists, and the honest thing is
45
+ * to open at identity rather than somewhere arbitrary. */
46
+ export declare function isZoomStateUsable(state: ZoomState | null, currentKey: string): boolean;
47
+ /** Read d3-zoom's stored transform off a node, defensively.
48
+ * `node.__zoom` is a d3 ZoomTransform instance; only its three numbers are wanted. */
49
+ export declare function readNodeTransform(node: any): ZoomTransform | null;
50
+ /** Every element under `root` (inclusive) that d3-zoom has attached to.
51
+ *
52
+ * Nearly always exactly one - the map's pan-catcher rect or the SVG itself. More than one
53
+ * means a composite chart with independently zoomable panels; those are deliberately NOT
54
+ * remembered, because pairing several transforms to several panels across a re-render
55
+ * needs an identity there is none of, and restoring them to the WRONG panels would be worse
56
+ * than not restoring at all. */
57
+ export declare function findZoomedNodes(root: Element | null): Element[];
58
+ /** The single zoomed node worth remembering, or null. See findZoomedNodes on why >1 opts out. */
59
+ export declare function soleZoomedNode(root: Element | null): Element | null;
@@ -0,0 +1,74 @@
1
+ import { type ZoomState } from "./zoomMemory";
2
+ /**
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).
6
+ */
7
+ export interface ZoomMemoryHost {
8
+ /** The in-session tier, always on: survives every re-render, never written anywhere. */
9
+ liveZoomState: ZoomState | null;
10
+ /** The key the watcher stamps on what it records (the latest render's). */
11
+ zoomWatchKey: string;
12
+ /** Whether the watcher's listeners are on zoomContainer (they attach once). */
13
+ zoomWatchAttached: boolean;
14
+ /** The pending settle timer, 0 when none. */
15
+ zoomSettleTimer: number;
16
+ /** 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
+ zoomContainer: Element | null;
19
+ /** Is the persisted tier on NOW? Read on every restore and every write, so a change applies to the next. */
20
+ rememberAcrossSessions(): boolean;
21
+ /** The persisted blob, or null. Read only while rememberAcrossSessions() is true; `key` is the current
22
+ * render's, for a host that wants to say why a saved view did not apply. */
23
+ readPersistedZoom(key: string): string | null;
24
+ /** Write the persisted blob; "" forgets it. Called only while rememberAcrossSessions() is true. */
25
+ persistZoom(json: string): void;
26
+ /** Diagnostics: a tag and a small payload. Never transform data beyond the three numbers and the key. */
27
+ log(tag: string, data?: Record<string, unknown>): void;
28
+ }
29
+ /** What captureZoomBehaviors hands back: the behaviors the chart created, and the unwrap. */
30
+ export interface ZoomCapture {
31
+ behaviors: any[];
32
+ release: () => void;
33
+ }
34
+ /** Wrap `d3.zoom` so every behavior the generated code creates is recorded.
35
+ *
36
+ * This works BECAUSE d3's chainable setters mutate and return the same object:
37
+ * `d3.zoom().scaleExtent([1,8]).translateExtent(...)` hands back the very object captured, so the
38
+ * reference stays live no matter how the chart configures it. (Wrapping the returned object instead
39
+ * would NOT work - the first chained call would unwrap it.) */
40
+ export declare function captureZoomBehaviors(d3: any, host: ZoomMemoryHost): ZoomCapture;
41
+ /** Put the remembered view back. Called synchronously after render() returned and before the browser
42
+ * paints - which is what makes this a restore rather than a visible jump - and before any pass that
43
+ * measures the chart, so they measure the view the reader will see. */
44
+ export declare function restoreZoomForRender(d3: any, host: ZoomMemoryHost, capture: ZoomCapture, key: string): void;
45
+ /** Record the transform after interaction settles.
46
+ *
47
+ * Listeners are attached ONCE to the container (which outlives individual renders) and read the key
48
+ * from the host object, so a re-render does not stack another set. Capture phase, because the chart's
49
+ * own handlers may stop propagation. */
50
+ export declare function attachZoomWatcher(host: ZoomMemoryHost, key: string): void;
51
+ /** Read the live transform off the DOM and remember it. */
52
+ export declare function captureCurrentZoom(host: ZoomMemoryHost): void;
53
+ /** A gesture that has not settled yet is recorded NOW. Call it before the chart's DOM goes away (a
54
+ * redraw, a teardown): the pending timer would otherwise fire over an empty container and lose it. */
55
+ export declare function flushPendingZoom(host: ZoomMemoryHost): void;
56
+ /** Drop the session view and any pending gesture, and persist nothing - for a host that swaps in a
57
+ * different chart, where a timer armed on the old one must not write after the swap. */
58
+ export declare function forgetZoom(host: ZoomMemoryHost): void;
59
+ /** WHAT createChartHost TAKES as `zoomMemory`: the functions above bound to one ZoomMemoryHost. */
60
+ export interface ZoomMemoryHook {
61
+ /** The key a render's view is remembered under: the code's hash and the viewport's 32px bucket. */
62
+ keyFor(code: string, width: number, height: number): string;
63
+ /** Wrap d3.zoom on the d3 the chart is handed, for the duration of its run. */
64
+ capture(d3: any): ZoomCapture;
65
+ /** Put the remembered view back, then attach the watcher (once) under this render's key. */
66
+ restore(container: Element, capture: ZoomCapture, key: string, d3: any): void;
67
+ /** flushPendingZoom: record a gesture still settling. */
68
+ flush(): void;
69
+ /** forgetZoom: drop the session view and a pending gesture. */
70
+ forget(): void;
71
+ }
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. */
74
+ export declare function createZoomMemoryHook(host: ZoomMemoryHost): ZoomMemoryHook;
@@ -0,0 +1,276 @@
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
+ import {
3
+ B
4
+ } from "./chunk-KQN5C7SZ.mjs";
5
+ import "./chunk-GHODWOAL.mjs";
6
+
7
+ // src/zoomMemory/zoomMemory.ts
8
+ function zoomStateKey(codeHash, width, height) {
9
+ return `${codeHash}:${viewportBucket(width)}x${viewportBucket(height)}`;
10
+ }
11
+ function viewportBucket(px) {
12
+ if (!isFiniteNumber(px) || px <= 0) return 0;
13
+ return Math.round(px / 32);
14
+ }
15
+ function isFiniteNumber(v) {
16
+ return typeof v === "number" && isFinite(v);
17
+ }
18
+ function isUsableTransform(t) {
19
+ if (!t || typeof t !== "object") return false;
20
+ const c = t;
21
+ if (!isFiniteNumber(c.k) || !isFiniteNumber(c.x) || !isFiniteNumber(c.y)) return false;
22
+ if (c.k <= 0 || c.k > 1e6) return false;
23
+ if (Math.abs(c.x) > 1e7 || Math.abs(c.y) > 1e7) return false;
24
+ return true;
25
+ }
26
+ function isIdentity(t) {
27
+ return t.k === 1 && t.x === 0 && t.y === 0;
28
+ }
29
+ function transformsDiffer(a, b) {
30
+ if (!a || !b) return a !== b;
31
+ return Math.abs(a.k - b.k) > 1e-6 || Math.abs(a.x - b.x) > 0.5 || Math.abs(a.y - b.y) > 0.5;
32
+ }
33
+ function serializeZoomState(t, key) {
34
+ return JSON.stringify({
35
+ k: Math.round(t.k * 1e4) / 1e4,
36
+ x: Math.round(t.x * 100) / 100,
37
+ y: Math.round(t.y * 100) / 100,
38
+ key
39
+ });
40
+ }
41
+ function parseZoomState(raw) {
42
+ if (!raw || typeof raw !== "string") return null;
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(raw);
46
+ } catch {
47
+ return null;
48
+ }
49
+ if (!isUsableTransform(parsed)) return null;
50
+ const key = typeof parsed.key === "string" ? parsed.key : "";
51
+ if (!key) return null;
52
+ return { k: parsed.k, x: parsed.x, y: parsed.y, key };
53
+ }
54
+ function isZoomStateUsable(state, currentKey) {
55
+ return !!state && !!currentKey && state.key === currentKey && isUsableTransform(state);
56
+ }
57
+ function readNodeTransform(node) {
58
+ if (!node) return null;
59
+ const z = node.__zoom;
60
+ if (!z) return null;
61
+ const t = { k: z.k, x: z.x, y: z.y };
62
+ return isUsableTransform(t) ? t : null;
63
+ }
64
+ function findZoomedNodes(root) {
65
+ if (!root) return [];
66
+ const out = [];
67
+ if (root.__zoom) out.push(root);
68
+ let all;
69
+ try {
70
+ all = root.querySelectorAll("*");
71
+ } catch {
72
+ return out;
73
+ }
74
+ for (let i = 0; i < all.length; i++) {
75
+ if (all[i].__zoom) out.push(all[i]);
76
+ }
77
+ return out;
78
+ }
79
+ function soleZoomedNode(root) {
80
+ const nodes = findZoomedNodes(root);
81
+ return nodes.length === 1 ? nodes[0] : null;
82
+ }
83
+
84
+ // src/zoomMemory/zoomMemoryDom.ts
85
+ var SETTLE_MS = 350;
86
+ function captureZoomBehaviors(d3, host) {
87
+ const noop = { behaviors: [], release: () => void 0 };
88
+ if (!d3 || typeof d3.zoom !== "function") return noop;
89
+ const behaviors = [];
90
+ const original = d3.zoom;
91
+ try {
92
+ const wrapped = function(...args) {
93
+ const b = original.apply(this, args);
94
+ try {
95
+ if (b) behaviors.push(b);
96
+ } catch {
97
+ }
98
+ return b;
99
+ };
100
+ try {
101
+ for (const k of Object.keys(original)) wrapped[k] = original[k];
102
+ } catch {
103
+ }
104
+ d3.zoom = wrapped;
105
+ } catch (e) {
106
+ host.log("zoom-capture-wrap-failed", { message: e instanceof Error ? e.message : String(e) });
107
+ return noop;
108
+ }
109
+ return {
110
+ behaviors,
111
+ release: () => {
112
+ try {
113
+ d3.zoom = original;
114
+ } catch {
115
+ }
116
+ }
117
+ };
118
+ }
119
+ function constrainToBehavior(d3, behavior, node, t) {
120
+ let out = d3.zoomIdentity.translate(t.x, t.y).scale(t.k);
121
+ try {
122
+ const scaleExtent = typeof behavior.scaleExtent === "function" ? behavior.scaleExtent() : null;
123
+ if (scaleExtent && scaleExtent.length === 2) {
124
+ const k = Math.max(scaleExtent[0], Math.min(scaleExtent[1], t.k));
125
+ out = d3.zoomIdentity.translate(t.x, t.y).scale(k);
126
+ }
127
+ } catch {
128
+ }
129
+ try {
130
+ const constrain = typeof behavior.constrain === "function" ? behavior.constrain() : null;
131
+ const extentFn = typeof behavior.extent === "function" ? behavior.extent() : null;
132
+ const translateExtent = typeof behavior.translateExtent === "function" ? behavior.translateExtent() : null;
133
+ if (typeof constrain === "function" && translateExtent) {
134
+ const extent = typeof extentFn === "function" ? extentFn.apply(node, [node && node.__data__]) : extentFn;
135
+ if (extent) out = constrain(out, extent, translateExtent);
136
+ }
137
+ } catch {
138
+ }
139
+ return out;
140
+ }
141
+ function rememberedFor(host, key) {
142
+ if (isZoomStateUsable(host.liveZoomState, key)) return host.liveZoomState;
143
+ if (!rememberAcrossSessions(host)) return null;
144
+ let raw = null;
145
+ try {
146
+ raw = host.readPersistedZoom(key);
147
+ } catch {
148
+ raw = null;
149
+ }
150
+ const persisted = parseZoomState(raw);
151
+ return isZoomStateUsable(persisted, key) ? persisted : null;
152
+ }
153
+ function rememberAcrossSessions(host) {
154
+ try {
155
+ return !!host.rememberAcrossSessions();
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
160
+ function restoreZoomForRender(d3, host, capture, key) {
161
+ try {
162
+ const state = rememberedFor(host, key);
163
+ if (!state) return;
164
+ const node = soleZoomedNode(host.zoomContainer);
165
+ if (!node) return;
166
+ const current = readNodeTransform(node);
167
+ if (current && !transformsDiffer(current, state)) return;
168
+ const behavior = capture.behaviors.length === 1 ? capture.behaviors[0] : null;
169
+ if (!behavior || typeof behavior.transform !== "function") {
170
+ host.log("zoom-restore-no-behavior", { behaviors: capture.behaviors.length });
171
+ return;
172
+ }
173
+ if (!d3 || !d3.zoomIdentity || typeof d3.select !== "function") return;
174
+ const t = constrainToBehavior(d3, behavior, node, state);
175
+ d3.select(node).call(behavior.transform, t);
176
+ host.log("zoom-restored", { k: round(t.k), x: round(t.x), y: round(t.y), key });
177
+ } catch (e) {
178
+ host.log("zoom-restore-ex", { message: e instanceof Error ? e.message : String(e) });
179
+ }
180
+ }
181
+ function round(n) {
182
+ return Math.round(n * 100) / 100;
183
+ }
184
+ function attachZoomWatcher(host, key) {
185
+ host.zoomWatchKey = key;
186
+ const ctr = host.zoomContainer;
187
+ if (host.zoomWatchAttached || !ctr) return;
188
+ const settle = () => {
189
+ if (host.zoomSettleTimer) clearTimeout(host.zoomSettleTimer);
190
+ host.zoomSettleTimer = setTimeout(() => {
191
+ host.zoomSettleTimer = 0;
192
+ captureCurrentZoom(host);
193
+ }, SETTLE_MS);
194
+ };
195
+ for (const evt of ["wheel", "pointerup", "pointercancel", "click", "dblclick", "keyup"]) {
196
+ try {
197
+ ctr.addEventListener(evt, settle, true);
198
+ } catch {
199
+ }
200
+ }
201
+ host.zoomWatchAttached = true;
202
+ host.log("zoom-watcher-attached", {});
203
+ }
204
+ function captureCurrentZoom(host) {
205
+ try {
206
+ const key = host.zoomWatchKey;
207
+ if (!key) return;
208
+ const node = soleZoomedNode(host.zoomContainer);
209
+ if (!node) return;
210
+ const t = readNodeTransform(node);
211
+ if (!t) return;
212
+ if (isIdentity(t)) {
213
+ if (host.liveZoomState) {
214
+ host.liveZoomState = null;
215
+ persist(host, "");
216
+ host.log("zoom-forgotten", { key });
217
+ }
218
+ return;
219
+ }
220
+ const previous = host.liveZoomState;
221
+ if (previous && previous.key === key && !transformsDiffer(previous, t)) return;
222
+ host.liveZoomState = { k: t.k, x: t.x, y: t.y, key };
223
+ persist(host, serializeZoomState(t, key));
224
+ host.log("zoom-remembered", { k: round(t.k), x: round(t.x), y: round(t.y), key });
225
+ } catch (e) {
226
+ host.log("zoom-capture-ex", { message: e instanceof Error ? e.message : String(e) });
227
+ }
228
+ }
229
+ function persist(host, json) {
230
+ if (!rememberAcrossSessions(host)) return;
231
+ host.persistZoom(json);
232
+ }
233
+ function flushPendingZoom(host) {
234
+ if (!host.zoomSettleTimer) return;
235
+ clearTimeout(host.zoomSettleTimer);
236
+ host.zoomSettleTimer = 0;
237
+ captureCurrentZoom(host);
238
+ }
239
+ function forgetZoom(host) {
240
+ if (host.zoomSettleTimer) clearTimeout(host.zoomSettleTimer);
241
+ host.zoomSettleTimer = 0;
242
+ host.liveZoomState = null;
243
+ }
244
+ function createZoomMemoryHook(host) {
245
+ return {
246
+ keyFor: (code, width, height) => zoomStateKey(B(String(code ?? "")), width, height),
247
+ capture: (d3) => captureZoomBehaviors(d3, host),
248
+ restore: (container, capture, key, d3) => {
249
+ if (!host.zoomContainer) host.zoomContainer = container;
250
+ restoreZoomForRender(d3, host, capture, key);
251
+ attachZoomWatcher(host, key);
252
+ },
253
+ flush: () => flushPendingZoom(host),
254
+ forget: () => forgetZoom(host)
255
+ };
256
+ }
257
+ export {
258
+ attachZoomWatcher,
259
+ captureCurrentZoom,
260
+ captureZoomBehaviors,
261
+ createZoomMemoryHook,
262
+ findZoomedNodes,
263
+ flushPendingZoom,
264
+ forgetZoom,
265
+ isIdentity,
266
+ isUsableTransform,
267
+ isZoomStateUsable,
268
+ parseZoomState,
269
+ readNodeTransform,
270
+ restoreZoomForRender,
271
+ serializeZoomState,
272
+ soleZoomedNode,
273
+ transformsDiffer,
274
+ viewportBucket,
275
+ zoomStateKey
276
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bicharts/chart-host",
3
- "version": "0.6.85",
3
+ "version": "0.6.87",
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",
@@ -64,6 +64,11 @@
64
64
  "import": "./dist/legend-fit.mjs",
65
65
  "default": "./dist/legend-fit.mjs"
66
66
  },
67
+ "./zoom-memory": {
68
+ "types": "./dist/types/zoomMemory/index.d.ts",
69
+ "import": "./dist/zoom-memory.mjs",
70
+ "default": "./dist/zoom-memory.mjs"
71
+ },
67
72
  "./dax": {
68
73
  "types": "./dist/types/dax.d.ts",
69
74
  "import": "./dist/dax.mjs",