@bicharts/chart-host 0.5.62 → 0.5.64

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.
@@ -1308,6 +1308,210 @@ function hitBandFlag(c) {
1308
1308
  return c.uncovered > 0 ? "hitband:d3:thin" : "hitband:d3:ok";
1309
1309
  }
1310
1310
 
1311
+ // src/fit.ts
1312
+ var SCROLL_SLACK_PX = 8;
1313
+ function needsScroll(contentPx, viewportPx, slackPx = SCROLL_SLACK_PX) {
1314
+ if (!isFinite(contentPx) || !isFinite(viewportPx)) return false;
1315
+ if (contentPx <= 0 || viewportPx <= 0) return false;
1316
+ return contentPx - viewportPx > slackPx;
1317
+ }
1318
+ function contentExtentOf(boxes) {
1319
+ if (!boxes || boxes.length === 0) return { w: 0, h: 0, source: "none" };
1320
+ const usable = boxes.filter((b2) => b2 && isFinite(b2.right) && isFinite(b2.bottom) && (b2.right > 0 || b2.bottom > 0));
1321
+ if (usable.length === 0) return { w: 0, h: 0, source: "none" };
1322
+ const inFlow = usable.filter((b2) => !b2.floating);
1323
+ const counted = inFlow.length > 0 ? inFlow : usable;
1324
+ let w = 0, h3 = 0;
1325
+ for (const b2 of counted) {
1326
+ if (b2.right > w) w = b2.right;
1327
+ if (b2.bottom > h3) h3 = b2.bottom;
1328
+ }
1329
+ return { w, h: h3, source: inFlow.length > 0 ? "in-flow" : "all-floating" };
1330
+ }
1331
+ function scrollFitFor(contentW, contentH, viewportW, viewportH, slackPx = SCROLL_SLACK_PX) {
1332
+ return {
1333
+ overflowX: needsScroll(contentW, viewportW, slackPx) ? "auto" : "hidden",
1334
+ overflowY: needsScroll(contentH, viewportH, slackPx) ? "auto" : "hidden"
1335
+ };
1336
+ }
1337
+ var FIT_CONTENT_SELECTOR = "text, .d3-mark, .d3-legend-mark";
1338
+ var PHANTOM_FRACTION = 0.95;
1339
+ function isPhantomBox(w, h3, refW, refH) {
1340
+ return w > refW * PHANTOM_FRACTION || h3 > refH * PHANTOM_FRACTION;
1341
+ }
1342
+ var MAX_FRAME_GROW_FACTOR = 20;
1343
+ function planFrameGrow(inkBottomPx, elHeightPx, viewBoxH, ctmScale, slackPx = SCROLL_SLACK_PX, maxFactor = MAX_FRAME_GROW_FACTOR) {
1344
+ const none = (reason) => ({ grow: false, heightPx: elHeightPx, viewBoxH, reason });
1345
+ if (!isFinite(inkBottomPx) || !isFinite(elHeightPx) || elHeightPx <= 0) return none("unmeasurable");
1346
+ if (!needsScroll(inkBottomPx, elHeightPx, slackPx)) return none("fits");
1347
+ if (inkBottomPx > elHeightPx * maxFactor) return none("beyond-ceiling");
1348
+ const heightPx = Math.ceil(inkBottomPx);
1349
+ const delta = heightPx - elHeightPx;
1350
+ if (!(viewBoxH > 0)) return { grow: true, heightPx, viewBoxH, reason: "grow-no-viewbox" };
1351
+ if (!(ctmScale > 0) || !isFinite(ctmScale)) return none("no-scale");
1352
+ return { grow: true, heightPx, viewBoxH: viewBoxH + delta / ctmScale, reason: "grow" };
1353
+ }
1354
+
1355
+ // src/fitDom.ts
1356
+ var FIT_WALK_CAP = 8e3;
1357
+ function ctmScaleOf(m2) {
1358
+ if (!m2) return 0;
1359
+ const s = Math.hypot(m2.a, m2.b);
1360
+ return isFinite(s) && s > 0 ? s : 0;
1361
+ }
1362
+ function svgInkReach(svg) {
1363
+ try {
1364
+ const box = svg.getBoundingClientRect();
1365
+ if (!(box.width > 0) || !(box.height > 0)) return null;
1366
+ const els = svg.querySelectorAll(FIT_CONTENT_SELECTOR);
1367
+ let right = -Infinity, bottom = -Infinity, seen = 0;
1368
+ for (let i = 0; i < els.length && seen < FIT_WALK_CAP; i++) {
1369
+ const r = els[i].getBoundingClientRect();
1370
+ if (r.width <= 0 && r.height <= 0) continue;
1371
+ if (isPhantomBox(r.width, r.height, box.width, box.height)) continue;
1372
+ seen++;
1373
+ if (r.right > right) right = r.right;
1374
+ if (r.bottom > bottom) bottom = r.bottom;
1375
+ }
1376
+ if (seen === 0) return null;
1377
+ return { right: right - box.left, bottom: bottom - box.top };
1378
+ } catch {
1379
+ return null;
1380
+ }
1381
+ }
1382
+ function measureContainerBoxes(c, inkOf) {
1383
+ const boxes = [];
1384
+ if (!c || !c.children) return boxes;
1385
+ const view = c.ownerDocument && c.ownerDocument.defaultView || globalThis;
1386
+ const cRect = c.getBoundingClientRect();
1387
+ for (let i = 0; i < c.children.length; i++) {
1388
+ const el = c.children[i];
1389
+ let floating = false;
1390
+ try {
1391
+ const cs = view.getComputedStyle(el);
1392
+ if (cs.display === "none") continue;
1393
+ floating = cs.position === "absolute" || cs.position === "fixed";
1394
+ } catch {
1395
+ }
1396
+ const r = el.getBoundingClientRect();
1397
+ let right = r.right - cRect.left - c.clientLeft + c.scrollLeft;
1398
+ let bottom = r.bottom - cRect.top - c.clientTop + c.scrollTop;
1399
+ if (inkOf) {
1400
+ try {
1401
+ const ink = inkOf(el);
1402
+ if (ink) {
1403
+ const originX = r.left - cRect.left - c.clientLeft + c.scrollLeft;
1404
+ const originY = r.top - cRect.top - c.clientTop + c.scrollTop;
1405
+ if (isFinite(ink.right)) right = Math.max(right, originX + ink.right);
1406
+ if (isFinite(ink.bottom)) bottom = Math.max(bottom, originY + ink.bottom);
1407
+ }
1408
+ } catch {
1409
+ }
1410
+ }
1411
+ boxes.push({ right, bottom, floating });
1412
+ }
1413
+ return boxes;
1414
+ }
1415
+ function fitReadingFor(lane, c, slackPx = SCROLL_SLACK_PX, inkOf) {
1416
+ if (!c) return null;
1417
+ const viewW = c.clientWidth, viewH = c.clientHeight;
1418
+ if (!(viewW > 0) || !(viewH > 0)) return null;
1419
+ const boxes = measureContainerBoxes(c, inkOf);
1420
+ const extent = contentExtentOf(boxes);
1421
+ const contentW = extent.source === "none" ? c.scrollWidth : extent.w;
1422
+ const contentH = extent.source === "none" ? c.scrollHeight : extent.h;
1423
+ if (!isFinite(contentW) || !isFinite(contentH)) return null;
1424
+ return {
1425
+ lane,
1426
+ contentW: Math.round(contentW),
1427
+ contentH: Math.round(contentH),
1428
+ viewW,
1429
+ viewH,
1430
+ overflowsX: needsScroll(contentW, viewW, slackPx),
1431
+ overflowsY: needsScroll(contentH, viewH, slackPx),
1432
+ extentSource: extent.source,
1433
+ boxes: boxes.length,
1434
+ floating: boxes.filter((b2) => b2.floating).length,
1435
+ scrollW: c.scrollWidth,
1436
+ scrollH: c.scrollHeight
1437
+ };
1438
+ }
1439
+ function fitRenderedChart(container, opts = {}) {
1440
+ const slackPx = opts.slackPx ?? SCROLL_SLACK_PX;
1441
+ const result = {
1442
+ grew: false,
1443
+ growFrom: 0,
1444
+ growTo: 0,
1445
+ growReason: "no-container",
1446
+ overflowX: "hidden",
1447
+ overflowY: "hidden",
1448
+ reading: null
1449
+ };
1450
+ if (!container) return result;
1451
+ try {
1452
+ let svg = null;
1453
+ for (let i = 0; i < container.children.length && !svg; i++) {
1454
+ const el = container.children[i];
1455
+ if (el.tagName && el.tagName.toLowerCase() === "svg") svg = el;
1456
+ }
1457
+ if (!svg) svg = container.querySelector("svg");
1458
+ let inkCache;
1459
+ const svgInk = (el) => {
1460
+ if (inkCache === void 0) inkCache = svgInkReach(el);
1461
+ return inkCache;
1462
+ };
1463
+ const inkOf = (el) => el && el.tagName && el.tagName.toLowerCase() === "svg" ? svgInk(el) : null;
1464
+ if (svg && opts.grow !== false) {
1465
+ try {
1466
+ const ink = svgInk(svg);
1467
+ const elH = svg.getBoundingClientRect().height;
1468
+ const vb = svg.viewBox && svg.viewBox.baseVal ? svg.viewBox.baseVal.height : 0;
1469
+ const ctm = typeof svg.getScreenCTM === "function" ? svg.getScreenCTM() : null;
1470
+ const plan = planFrameGrow(
1471
+ ink ? ink.bottom : NaN,
1472
+ elH,
1473
+ vb,
1474
+ ctmScaleOf(ctm),
1475
+ slackPx
1476
+ );
1477
+ result.growFrom = Math.round(elH);
1478
+ result.growTo = plan.heightPx;
1479
+ result.growReason = plan.reason;
1480
+ if (plan.grow) {
1481
+ svg.setAttribute("height", String(plan.heightPx));
1482
+ if (vb > 0) {
1483
+ const b2 = svg.viewBox.baseVal;
1484
+ svg.setAttribute("viewBox", `${b2.x} ${b2.y} ${b2.width} ${plan.viewBoxH}`);
1485
+ }
1486
+ result.grew = true;
1487
+ }
1488
+ } catch {
1489
+ result.growReason = "grow-threw";
1490
+ }
1491
+ } else if (!svg) {
1492
+ result.growReason = "no-svg";
1493
+ } else {
1494
+ result.growReason = "grow-disabled";
1495
+ }
1496
+ const reading = fitReadingFor(opts.lane ?? "chart", container, slackPx, inkOf);
1497
+ result.reading = reading;
1498
+ const boxes = measureContainerBoxes(container, inkOf);
1499
+ const extent = contentExtentOf(boxes);
1500
+ const svgBox = svg ? svg.getBoundingClientRect() : null;
1501
+ const contentW = extent.source === "none" ? Math.max(container.scrollWidth, svgBox ? svgBox.width : 0) : extent.w;
1502
+ const contentH = extent.source === "none" ? Math.max(container.scrollHeight, svgBox ? svgBox.height : 0) : extent.h;
1503
+ const fit = scrollFitFor(contentW, contentH, container.clientWidth, container.clientHeight, slackPx);
1504
+ result.overflowX = fit.overflowX;
1505
+ result.overflowY = fit.overflowY;
1506
+ if (opts.applyOverflow !== false) {
1507
+ container.style.overflowX = fit.overflowX;
1508
+ container.style.overflowY = fit.overflowY;
1509
+ }
1510
+ } catch {
1511
+ }
1512
+ return result;
1513
+ }
1514
+
1311
1515
  // src/host.ts
1312
1516
  function sessionViewStateProvider(container) {
1313
1517
  const holder = container;
@@ -1569,6 +1773,7 @@ function createChartHost(container, config) {
1569
1773
  container.addEventListener("click", onClick);
1570
1774
  container.classList?.add(HOST_CONTAINER_CLASS);
1571
1775
  ensureAffordanceStyles();
1776
+ const fitOpts = config.fit ?? true;
1572
1777
  const stopAnim = () => {
1573
1778
  const s = container[CONTAINER_SLOT_ANIM_STOP];
1574
1779
  if (typeof s === "function") {
@@ -1637,6 +1842,13 @@ function createChartHost(container, config) {
1637
1842
  } catch {
1638
1843
  }
1639
1844
  }
1845
+ if (fitOpts !== false) {
1846
+ try {
1847
+ const r = fitRenderedChart(container, fitOpts === true ? {} : fitOpts);
1848
+ config.onFit?.(r);
1849
+ } catch {
1850
+ }
1851
+ }
1640
1852
  },
1641
1853
  setOptions(partial) {
1642
1854
  raw = { ...raw, ...partial };
@@ -1745,6 +1957,20 @@ export {
1745
1957
  MIN_HIT_BAND_PX,
1746
1958
  censusHitBands,
1747
1959
  hitBandFlag,
1960
+ SCROLL_SLACK_PX,
1961
+ needsScroll,
1962
+ contentExtentOf,
1963
+ scrollFitFor,
1964
+ FIT_CONTENT_SELECTOR,
1965
+ PHANTOM_FRACTION,
1966
+ isPhantomBox,
1967
+ MAX_FRAME_GROW_FACTOR,
1968
+ planFrameGrow,
1969
+ ctmScaleOf,
1970
+ svgInkReach,
1971
+ measureContainerBoxes,
1972
+ fitReadingFor,
1973
+ fitRenderedChart,
1748
1974
  sessionViewStateProvider,
1749
1975
  noopViewStateProvider,
1750
1976
  requiredD3Plugins,
package/dist/index.mjs CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  D,
22
22
  DIM_OPACITY_DEFAULT,
23
23
  DIM_OPACITY_VAR,
24
+ FIT_CONTENT_SELECTOR,
24
25
  FLIP_MODE_DEFAULT,
25
26
  G,
26
27
  GEO_POINT_PRECISIONS,
@@ -31,8 +32,11 @@ import {
31
32
  LIFT_SELECTED_CLASS,
32
33
  MARK_CLASS,
33
34
  MARK_SELECTED_CLASS,
35
+ MAX_FRAME_GROW_FACTOR,
34
36
  MIN_HIT_BAND_PX,
37
+ PHANTOM_FRACTION,
35
38
  ROW_IDX_ATTR,
39
+ SCROLL_SLACK_PX,
36
40
  SELECTION_ACTIVE_CLASS,
37
41
  VALUE_AXIS_BASELINE_DEFAULT,
38
42
  XFILTER_REFRESH_EVENT,
@@ -43,26 +47,36 @@ import {
43
47
  chartOwnsTimeline,
44
48
  clearGeoCache,
45
49
  compileRenderFn,
50
+ contentExtentOf,
46
51
  createChartHost,
47
52
  createMarkResolver,
53
+ ctmScaleOf,
48
54
  ensureCrossfilterHitTargets,
49
55
  explainRenderFailure,
56
+ fitReadingFor,
57
+ fitRenderedChart,
50
58
  geoAssetFor,
51
59
  geoFromCache,
52
60
  hitBandFlag,
53
61
  isBlankRender,
62
+ isPhantomBox,
54
63
  loadGeo,
64
+ measureContainerBoxes,
55
65
  ne,
66
+ needsScroll,
56
67
  noopViewStateProvider,
57
68
  periodTickSuppressesFeedback,
69
+ planFrameGrow,
58
70
  registerGeo,
59
71
  registerGeoAsset,
60
72
  requiredD3Plugins,
61
73
  resolveOptions,
74
+ scrollFitFor,
62
75
  sessionViewStateProvider,
63
76
  stripEsmExports,
77
+ svgInkReach,
64
78
  te
65
- } from "./chunk-WG4NTR5H.mjs";
79
+ } from "./chunk-YDFJF2UG.mjs";
66
80
  import "./chunk-A2GMXZP7.mjs";
67
81
 
68
82
  // src/trivial.ts
@@ -1011,6 +1025,7 @@ export {
1011
1025
  FILTER_MIN_TERM_CHARS,
1012
1026
  FILTER_MIN_WIDTH_PX,
1013
1027
  FILTER_ROW_PX,
1028
+ FIT_CONTENT_SELECTOR,
1014
1029
  FLIP_MODE_DEFAULT,
1015
1030
  GEO_POINT_PRECISIONS,
1016
1031
  HOST_CONTAINER_CLASS,
@@ -1020,8 +1035,11 @@ export {
1020
1035
  LIFT_SELECTED_CLASS,
1021
1036
  MARK_CLASS,
1022
1037
  MARK_SELECTED_CLASS,
1038
+ MAX_FRAME_GROW_FACTOR,
1023
1039
  MIN_HIT_BAND_PX,
1040
+ PHANTOM_FRACTION,
1024
1041
  ROW_IDX_ATTR,
1042
+ SCROLL_SLACK_PX,
1025
1043
  SELECTION_ACTIVE_CLASS,
1026
1044
  VALUE_AXIS_BASELINE_DEFAULT,
1027
1045
  XFILTER_REFRESH_EVENT,
@@ -1043,22 +1061,29 @@ export {
1043
1061
  computeQualifyFilterView,
1044
1062
  computeSelectionCard,
1045
1063
  confirmLaunch,
1064
+ contentExtentOf,
1046
1065
  createChartHost,
1047
1066
  createMarkResolver,
1067
+ ctmScaleOf,
1048
1068
  ensureCrossfilterHitTargets,
1049
1069
  explainRenderFailure,
1050
1070
  filterFitsChooser,
1051
1071
  filterQualifyRows,
1072
+ fitReadingFor,
1073
+ fitRenderedChart,
1052
1074
  geoAssetFor,
1053
1075
  geoFromCache,
1054
1076
  hasRefusalsToShow,
1055
1077
  hitBandFlag,
1056
1078
  inlineFilterGate,
1057
1079
  isBlankRender,
1080
+ isPhantomBox,
1058
1081
  launchFavorStyle,
1059
1082
  launchGenerates,
1060
1083
  listNeedsFilter,
1061
1084
  loadGeo,
1085
+ measureContainerBoxes,
1086
+ needsScroll,
1062
1087
  newQualifyGroupState,
1063
1088
  newQualifyRefusalGroupState,
1064
1089
  noopViewStateProvider,
@@ -1066,6 +1091,7 @@ export {
1066
1091
  normalizeFilterTerm,
1067
1092
  orderRefusalsForDisplay,
1068
1093
  periodTickSuppressesFeedback,
1094
+ planFrameGrow,
1069
1095
  planTrivialChart,
1070
1096
  qualifyAuto,
1071
1097
  qualifyCancel,
@@ -1084,11 +1110,13 @@ export {
1084
1110
  registerGeoAsset,
1085
1111
  requiredD3Plugins,
1086
1112
  resolveOptions,
1113
+ scrollFitFor,
1087
1114
  sessionViewStateProvider,
1088
1115
  shouldOpenChooserOnGenerate,
1089
1116
  shouldOpenInlineChooserOnGenerate,
1090
1117
  shouldReview,
1091
1118
  stripEsmExports,
1119
+ svgInkReach,
1092
1120
  svgNaturalSize,
1093
1121
  svgToDataUrl
1094
1122
  };
package/dist/react.mjs CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  createChartHost,
5
5
  geoFromCache,
6
6
  loadGeo
7
- } from "./chunk-WG4NTR5H.mjs";
7
+ } from "./chunk-YDFJF2UG.mjs";
8
8
  import "./chunk-A2GMXZP7.mjs";
9
9
 
10
10
  // src/react.tsx
@@ -0,0 +1,36 @@
1
+ export declare const SCROLL_SLACK_PX = 8;
2
+ export declare function needsScroll(contentPx: number, viewportPx: number, slackPx?: number): boolean;
3
+ export interface MeasuredBox {
4
+ right: number;
5
+ bottom: number;
6
+ floating: boolean;
7
+ }
8
+ export interface ContentExtent {
9
+ w: number;
10
+ h: number;
11
+ source: "in-flow" | "all-floating" | "none";
12
+ }
13
+ export declare function contentExtentOf(boxes: MeasuredBox[]): ContentExtent;
14
+ export interface ScrollFit {
15
+ overflowX: "hidden" | "auto";
16
+ overflowY: "hidden" | "auto";
17
+ }
18
+ export declare function scrollFitFor(contentW: number, contentH: number, viewportW: number, viewportH: number, slackPx?: number): ScrollFit;
19
+ export declare const FIT_CONTENT_SELECTOR = "text, .d3-mark, .d3-legend-mark";
20
+ export declare const PHANTOM_FRACTION = 0.95;
21
+ export declare function isPhantomBox(w: number, h: number, refW: number, refH: number): boolean;
22
+ export declare const MAX_FRAME_GROW_FACTOR = 20;
23
+ export interface FrameGrowPlan {
24
+ grow: boolean;
25
+ heightPx: number;
26
+ viewBoxH: number;
27
+ reason: string;
28
+ }
29
+ /**
30
+ * @param inkBottomPx how far the chart's ink reaches below the element's top, in px
31
+ * @param elHeightPx the element's current rendered height, in px
32
+ * @param viewBoxH the current viewBox height in user units, or 0 when there is no viewBox
33
+ * (then user units ARE px and only the element moves)
34
+ * @param ctmScale px per user unit, from the live screen CTM
35
+ */
36
+ export declare function planFrameGrow(inkBottomPx: number, elHeightPx: number, viewBoxH: number, ctmScale: number, slackPx?: number, maxFactor?: number): FrameGrowPlan;
@@ -0,0 +1,59 @@
1
+ import type { ContentExtent, MeasuredBox } from "./fit";
2
+ export declare function ctmScaleOf(m: {
3
+ a: number;
4
+ b: number;
5
+ } | null | undefined): number;
6
+ export declare function svgInkReach(svg: SVGSVGElement): {
7
+ right: number;
8
+ bottom: number;
9
+ } | null;
10
+ export type InkReach = (el: Element) => {
11
+ right: number;
12
+ bottom: number;
13
+ } | null;
14
+ export declare function measureContainerBoxes(c: HTMLElement, inkOf?: InkReach): MeasuredBox[];
15
+ export interface FitReading {
16
+ lane: string;
17
+ contentW: number;
18
+ contentH: number;
19
+ viewW: number;
20
+ viewH: number;
21
+ overflowsX: boolean;
22
+ overflowsY: boolean;
23
+ extentSource: ContentExtent["source"];
24
+ boxes: number;
25
+ floating: number;
26
+ scrollW: number;
27
+ scrollH: number;
28
+ }
29
+ export declare function fitReadingFor(lane: string, c: HTMLElement | null | undefined, slackPx?: number, inkOf?: InkReach): FitReading | null;
30
+ export interface FitRenderedChartOptions {
31
+ /** Name for this container in the returned reading. Purely descriptive. */
32
+ lane?: string;
33
+ /** How far content must exceed the viewport before it counts. Defaults to SCROLL_SLACK_PX. */
34
+ slackPx?: number;
35
+ /**
36
+ * Grow the chart's <svg> to whatever it actually drew, so the container has something to
37
+ * scroll TO. Default true - it is the half of this pass that recovers content that is
38
+ * otherwise unreachable. Set false to take the READING without touching the chart.
39
+ */
40
+ grow?: boolean;
41
+ /**
42
+ * Set `overflowX` / `overflowY` on the container. Default true. Set false when the host owns
43
+ * its own scrolling and only wants to be told.
44
+ */
45
+ applyOverflow?: boolean;
46
+ }
47
+ export interface FitRenderedChartResult {
48
+ /** The frame was grown, so content that was being clipped is now painted. */
49
+ grew: boolean;
50
+ growFrom: number;
51
+ growTo: number;
52
+ growReason: string;
53
+ /** What the container's overflow was set to (or would have been). */
54
+ overflowX: "hidden" | "auto";
55
+ overflowY: "hidden" | "auto";
56
+ /** The post-grow reading, or null when nothing could be measured. */
57
+ reading: FitReading | null;
58
+ }
59
+ export declare function fitRenderedChart(container: HTMLElement | null | undefined, opts?: FitRenderedChartOptions): FitRenderedChartResult;
@@ -2,6 +2,7 @@ import { type RenderOptions, type ViewStateProvider } from "./contract";
2
2
  import { type ResolveOptionsInput } from "./defaults";
3
3
  import { type MarkCensus } from "./blankRender";
4
4
  import { type HitBandCensus } from "./hitBands";
5
+ import { type FitRenderedChartOptions, type FitRenderedChartResult } from "./fitDom";
5
6
  export type RenderFn = (container: HTMLElement, data: any, options: RenderOptions) => void;
6
7
  /**
7
8
  * THE SESSION PROVIDER — what every host got hard-coded before contract 1.6.0, now named.
@@ -96,6 +97,24 @@ export interface ChartHostConfig {
96
97
  /** The chart's marks are not tagged with the shared contract (a Vega lane names its own
97
98
  * marks), so a mark count proves nothing and the blank verdict is suppressed. */
98
99
  contractUntagged?: boolean;
100
+ /**
101
+ * DOES THE RENDERED CHART FIT ITS FRAME (2026-09-03)? Runs after every render.
102
+ *
103
+ * The outermost <svg> clips at its own viewport in every browser, so a chart that sets
104
+ * `svg height = options.height` and draws a taller body loses the overflow OUTRIGHT - the
105
+ * rows past the fold are never painted, and the container's scrollHeight agrees that
106
+ * everything fits, so nothing anywhere offers a scrollbar. This grows the frame to the ink
107
+ * the chart actually drew and THEN sets the container's overflow, in that order, because a
108
+ * scrollbar on an un-grown frame scrolls to nothing.
109
+ *
110
+ * DEFAULT ON, because the alternative is silent data loss and the pass only acts when ink is
111
+ * measurably outside the frame (8px of slack, and a 20x ceiling against a mark parked in the
112
+ * weeds). Pass `false` for a host that owns its own layout, or an options object to take the
113
+ * reading without touching the chart (`grow: false`, `applyOverflow: false`).
114
+ */
115
+ fit?: boolean | FitRenderedChartOptions;
116
+ /** The fit result after each render - what was grown, and how the chart measured. */
117
+ onFit?: (result: FitRenderedChartResult) => void;
99
118
  }
100
119
  export interface ChartHost {
101
120
  render(): void;
@@ -18,3 +18,5 @@ export { computeSelectionCard, normaliseAggregation, type SelectionCardModel, ty
18
18
  export { ensureCrossfilterHitTargets, type HitTargetReport } from "./hitTargets";
19
19
  export { censusMarks, isBlankRender, blankRenderFlag, type MarkCensus, type BlankVerdictInput } from "./blankRender";
20
20
  export { censusHitBands, hitBandFlag, MIN_HIT_BAND_PX, type HitBandCensus } from "./hitBands";
21
+ export { SCROLL_SLACK_PX, MAX_FRAME_GROW_FACTOR, PHANTOM_FRACTION, FIT_CONTENT_SELECTOR, needsScroll, contentExtentOf, scrollFitFor, planFrameGrow, isPhantomBox, type MeasuredBox, type ContentExtent, type ScrollFit, type FrameGrowPlan, } from "./fit";
22
+ export { fitRenderedChart, fitReadingFor, measureContainerBoxes, svgInkReach, ctmScaleOf, type FitReading, type InkReach, type FitRenderedChartOptions, type FitRenderedChartResult, } from "./fitDom";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bicharts/chart-host",
3
- "version": "0.5.62",
3
+ "version": "0.5.64",
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",