@bicharts/chart-host 0.6.85 → 0.6.86

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
@@ -394,6 +394,27 @@ a throw must never fail a chart that already drew. The geometry behind each phas
394
394
  `planAxisTickThin`, `planFrameFit`, ...) is exported as plain functions over boxes, for a host that
395
395
  measures on its own terms.
396
396
 
397
+ **Running them after every render.** `createLegendFitPass()` is both passes in the order that works
398
+ (the legend first, then the frame fit, which measures what the legend pass left), with nothing to log
399
+ and no policy. Hand it to the host as `layoutPass`, and the host runs it after every render, re-render
400
+ and async settle, after the label colors are fixed and before anything measures the marks or the
401
+ frame:
402
+
403
+ ```ts
404
+ import { createChartHost } from "@bicharts/chart-host";
405
+ import { createLegendFitPass } from "@bicharts/chart-host/legend-fit";
406
+
407
+ const layoutPass = createLegendFitPass({
408
+ legend: true, // or "narrow" to act only on large overlaps, or false
409
+ frameFit: true,
410
+ onReport: r => console.debug(r.legend, r.frame), // what each run did; log what you like
411
+ });
412
+ const host = createChartHost(el, { code, data, d3, layoutPass });
413
+ ```
414
+
415
+ In React it is the `layoutPass` prop of `<BicChart>`. Build the pass once, at module scope or in a
416
+ `useMemo`: a new function on every render is a new host config, and the chart is rebuilt.
417
+
397
418
  ## Architecture — three layers, and why it matters if you contribute
398
419
 
399
420
  Read this before adding code. The package has **three** concerns, and they are independent.
@@ -2312,6 +2312,12 @@ function createChartHost(container, config) {
2312
2312
  } catch {
2313
2313
  }
2314
2314
  }
2315
+ if (config.layoutPass) {
2316
+ try {
2317
+ config.layoutPass(container);
2318
+ } catch {
2319
+ }
2320
+ }
2315
2321
  ensureCrossfilterHitTargets(container, doc);
2316
2322
  paintSelection();
2317
2323
  try {
package/dist/index.mjs CHANGED
@@ -97,7 +97,7 @@ import {
97
97
  toRGBA,
98
98
  toSourceRows,
99
99
  valuePlacementFlag
100
- } from "./chunk-CDWNZHIN.mjs";
100
+ } from "./chunk-MXL5SK7X.mjs";
101
101
  import {
102
102
  LASSO_AXES_SLOT,
103
103
  LASSO_COUNT_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,7 +22,7 @@ import {
22
22
  loadGeo,
23
23
  syncMemberSelection,
24
24
  toSourceRows
25
- } from "./chunk-CDWNZHIN.mjs";
25
+ } from "./chunk-MXL5SK7X.mjs";
26
26
  import "./chunk-QIRLWJBI.mjs";
27
27
  import "./chunk-2ZUUIZWL.mjs";
28
28
  import "./chunk-7HKVV5T5.mjs";
@@ -69,6 +69,7 @@ function BicChart(props) {
69
69
  viewState,
70
70
  labelContrast,
71
71
  onLabelContrast,
72
+ layoutPass,
72
73
  onInvalidSentinel,
73
74
  id,
74
75
  filteredBy,
@@ -150,6 +151,7 @@ function BicChart(props) {
150
151
  geoKind,
151
152
  viewState,
152
153
  labelContrast,
154
+ layoutPass,
153
155
  onLabelContrast: (r) => onLabelContrastRef.current?.(r),
154
156
  // A GETTER, not a wrapper: the host decides whether to
155
157
  // swallow the sentinel throw by whether this is SET, so
@@ -181,7 +183,7 @@ function BicChart(props) {
181
183
  host.destroy();
182
184
  hostRef.current = null;
183
185
  };
184
- }, [code, renderFn, d3, geoKind, labelContrast, controls]);
186
+ }, [code, renderFn, d3, geoKind, labelContrast, layoutPass, controls]);
185
187
  useLayoutEffect(() => {
186
188
  const host = hostRef.current;
187
189
  if (!host || !selects) return;
@@ -207,7 +209,7 @@ function BicChart(props) {
207
209
  b.detach();
208
210
  if (bindingRef.current === b) bindingRef.current = null;
209
211
  };
210
- }, [selects, code, renderFn, d3, geoKind, labelContrast]);
212
+ }, [selects, code, renderFn, d3, geoKind, labelContrast, layoutPass]);
211
213
  useEffect(() => {
212
214
  if (!hostRef.current || !options) return;
213
215
  if (builtWithRef.current?.optKey === optKey) return;
@@ -190,6 +190,15 @@ export interface ChartHostConfig {
190
190
  labelContrast?: boolean | LabelContrastOptions;
191
191
  /** What the label-contrast pass did after each render - counts, so a host can log them. */
192
192
  onLabelContrast?: (report: LabelContrastReport) => void;
193
+ /**
194
+ * A LAYOUT PASS the host brings, run after every render (both the synchronous and the promise lane, and every
195
+ * setOptions / setData re-render) AFTER the label-contrast pass and BEFORE the hit-target heal, the selection
196
+ * repaint, the censuses and the fit - the order a pass that moves the legend or grows the viewBox needs, because
197
+ * the fit must measure the final layout. Absent by default: the main entry carries no such pass. Build one with
198
+ * createLegendFitPass() from "@bicharts/chart-host/legend-fit". It must not throw (a throw is swallowed and the
199
+ * render stands); it reports through its own callback, never through this host.
200
+ */
201
+ layoutPass?: (container: HTMLElement) => void;
193
202
  /**
194
203
  * What the id-scoping pass did after each render: how many of the chart's ids got this host's suffix, and which
195
204
  * 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;
@@ -41,6 +41,13 @@ export interface BicChartProps {
41
41
  labelContrast?: ChartHostConfig["labelContrast"];
42
42
  /** What the label-contrast pass did after each render - counts, for a page that logs. */
43
43
  onLabelContrast?: ChartHostConfig["onLabelContrast"];
44
+ /**
45
+ * A LAYOUT PASS run after every render, after label contrast and before the fit - e.g.
46
+ * `createLegendFitPass()` from "@bicharts/chart-host/legend-fit", which slides a legend clear of
47
+ * the plot and fits text to the frame. Absent by default. Build it ONCE (module scope or useMemo):
48
+ * a new function each render rebuilds the host. See createChartHost's `layoutPass`.
49
+ */
50
+ layoutPass?: ChartHostConfig["layoutPass"];
44
51
  /**
45
52
  * THE CHART STOPPED BECAUSE THE DATA LACKS A COLUMN IT IS BUILT ON. Supplied: the chart's
46
53
  * container is left empty and this is called with the chart's reason, instead of the throw
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.86",
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",