@bicharts/chart-host 0.6.84 → 0.6.85

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/dist/react.mjs CHANGED
@@ -22,11 +22,12 @@ import {
22
22
  loadGeo,
23
23
  syncMemberSelection,
24
24
  toSourceRows
25
- } from "./chunk-2HKO6GEK.mjs";
25
+ } from "./chunk-CDWNZHIN.mjs";
26
26
  import "./chunk-QIRLWJBI.mjs";
27
27
  import "./chunk-2ZUUIZWL.mjs";
28
28
  import "./chunk-7HKVV5T5.mjs";
29
29
  import "./chunk-IURROJT7.mjs";
30
+ import "./chunk-BQFG5HWZ.mjs";
30
31
  import "./chunk-A2GMXZP7.mjs";
31
32
  import "./chunk-MR6TL7QZ.mjs";
32
33
  import "./chunk-GHODWOAL.mjs";
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Frame fit — pure geometry.
3
+ *
4
+ * The third measured sibling of the label-contrast and legend-reconcile passes.
5
+ * Generated code routinely parks a footer/source caption against the
6
+ * RAW svg edge — `svg.append('text').attr('y', height - 2)` — instead of reserving
7
+ * a band for it inside `margin.bottom`. That looks right in the code and is wrong
8
+ * on screen: a baseline is not an ink box. A glyph descends roughly 0.27 * fontSize
9
+ * BELOW its baseline, so a 2px gap at font-size 7.5 puts the tails of "g", "p", "y"
10
+ * exactly on (or past) the frame, and `.lch-d3ctr` is `overflow:hidden` — the host
11
+ * silently slices them. Measured live on a Marimekko (690x340): the
12
+ * caption's ink box ran 331->340 in a 340-tall frame, i.e. flush, tails gone.
13
+ *
14
+ * Corpus: 41 D3 generations across ~20 chart types park text within 5 user units of
15
+ * the frame, so this is a RENDERER-level habit, not one chart's bug.
16
+ *
17
+ * WHY EXTEND THE VIEWBOX RATHER THAN MOVE THE TEXT: nudging a glyph back inside
18
+ * can shove it into whatever sits above it — in that Marimekko the axis title's ink
19
+ * ends 12 units above the caption, so a blind 6-unit lift would collide the two
20
+ * and trade a clipped line for an overlapping one. Growing the viewBox instead
21
+ * makes the SVG scale to fit its container: nothing moves relative to anything
22
+ * else, nothing is clipped, and a 2-unit grow on a 340-unit frame is a 0.6%
23
+ * rescale nobody can see. This is exactly the remedy the legend-reconcile pass already
24
+ * uses for a legend that runs off the right edge.
25
+ *
26
+ * Coordinates: EVERYTHING here is in the SVG's USER space (== viewBox units). The
27
+ * caller maps measured client-px boxes through `svg.getScreenCTM().inverse()`
28
+ * first, which captures viewBox, preserveAspectRatio, container scaling AND host
29
+ * page zoom in one matrix — so this module needs no px<->unit factor. Same
30
+ * contract as legendReconcile; the box and viewBox types are shared with it.
31
+ *
32
+ * DOM-free so it can be unit-tested on plain numbers; the DOM I/O lives in
33
+ * frameFitDom.fitTextToFrame.
34
+ */
35
+ import type { Rect, ViewBox } from "./legendReconcile";
36
+ export type { Rect, ViewBox };
37
+ export interface FrameFitPlan {
38
+ action: "none" | "extend" | "defer";
39
+ top: number;
40
+ right: number;
41
+ bottom: number;
42
+ left: number;
43
+ newViewBox: ViewBox;
44
+ considered: number;
45
+ rescued: number;
46
+ deferred: number;
47
+ deferredIdx: number[];
48
+ reason: string;
49
+ }
50
+ export declare const FRAME_PAD = 1;
51
+ export declare const MAX_EXTEND_FRACTION = 0.08;
52
+ /**
53
+ * Work out how much the viewBox must grow so every measured ink box sits inside
54
+ * the frame with FRAME_PAD to spare.
55
+ *
56
+ * @param boxes ink boxes in USER space (see module note on the CTM mapping)
57
+ * @param vb the SVG's current viewBox in user units
58
+ * @param maxFraction per-axis extension ceiling, as a fraction of the frame
59
+ */
60
+ export declare function planFrameFit(boxes: Rect[], vb: ViewBox, maxFraction?: number): FrameFitPlan;
61
+ /** Serialize a viewBox the way the SVG attribute wants it. */
62
+ export declare function viewBoxAttr(vb: ViewBox): string;
63
+ export type TextAnchor = "start" | "middle" | "end";
64
+ /** The minimum readable type, px. */
65
+ export declare const FIT_TYPE_FLOOR = 10;
66
+ /** A shrink never goes below this fraction of the chart's own size: past it, cut instead. */
67
+ export declare const FIT_SHRINK_FLOOR = 0.8;
68
+ /**
69
+ * The width, in user units, a label may keep so that its box sits inside `frame` (with
70
+ * FRAME_PAD), given the edge its anchor holds - or null when no shortening can fit it
71
+ * (it overflows vertically, or its held edge is itself outside the frame).
72
+ */
73
+ export declare function labelRoom(box: Rect, frame: ViewBox, anchor: TextAnchor): number | null;
74
+ export interface LabelFitDecision {
75
+ action: "none" | "shrink" | "cut";
76
+ fontSize: number;
77
+ }
78
+ /**
79
+ * Shrink or cut: a label `width` units wide at `fontSize` px that must fit `room` units.
80
+ * Width scales with the font size, so the first guess is exact to within hinting; the caller
81
+ * re-measures after a shrink and falls through to the cut if hinting disagreed.
82
+ */
83
+ export declare function labelFitDecision(width: number, room: number, fontSize: number): LabelFitDecision;
84
+ /**
85
+ * The widest prefix of `full` (plus the ellipsis) that `fits`, never cut inside a number:
86
+ * a cut between two digits moves back to before the number ("largest 12..." of
87
+ * "largest 128 of 400" states a different count). Empty when nothing fits.
88
+ */
89
+ export declare function cutToFit(full: string, fits: (s: string) => boolean, ell?: string): string;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Frame fit - the labels the viewBox could not rescue.
3
+ *
4
+ * fitTextToFrame grows the viewBox for text that pokes a few units past the frame and
5
+ * DEFERS text that is further out, because swallowing it would shrink the whole chart. A
6
+ * deferred label used to stay exactly where the chart put it, sliced by the frame, and
7
+ * a log line only counted it: a Bullet of 21 KPIs at 1230x626 reported
8
+ * `{"rescued":8,"deferred":6,"grew":"left+68.06"}` while six KPI names still read
9
+ * "...f Service Effort - Reduce effort".
10
+ *
11
+ * A label that overflows on the side its anchor does NOT hold is fitted in place: the type
12
+ * shrinks first (see labelFitDecision - never below the 10px floor or 80% of the chart's size),
13
+ * then the string is cut to the widest prefix that fits, never inside a number, and the full
14
+ * text rides a <title>. A label that overflows vertically, or on the side its anchor holds, is
15
+ * counted as unfixable and left alone. The geometry is in frameFit.ts; this is the DOM I/O,
16
+ * with the measurement handed in so the caller's CTM mapping (viewBox, zoom) is the only one.
17
+ */
18
+ import { Rect, ViewBox, FrameFitPlan } from "./frameFit";
19
+ export interface DeferredLabelFitReport {
20
+ /** Deferred text elements looked at. */
21
+ deferred: number;
22
+ /** Fitted by a smaller font size alone. */
23
+ shrunk: number;
24
+ /** Fitted by cutting the string (after any shrink). */
25
+ cut: number;
26
+ /** Could not be fitted by shortening: vertical overflow, the held edge outside, rotated, tspans. */
27
+ unfixable: number;
28
+ /** The first few full labels that were fitted, for the log line. */
29
+ labels: string[];
30
+ }
31
+ /**
32
+ * Fit ONE label, `width` wide, into `room` (both in whatever units `widthNow` measures): shrink the
33
+ * type first (labelFitDecision - never below the 10px floor or 80% of the label's size), then cut
34
+ * the string to the widest prefix that fits, never inside a number, with the full text riding a
35
+ * <title>. `minChars` keeps at least that many characters before the ellipsis even when they do not
36
+ * fit - a caller that must never REMOVE a label (the nominal axis) passes 1. Shared by the
37
+ * frame fit and the nominal-axis cut.
38
+ */
39
+ export declare function fitLabelToRoom(el: Element, width: number, room: number, widthNow: () => number, minChars?: number): "none" | "shrunk" | "cut";
40
+ /**
41
+ * Fit each deferred label to `frame`. `measure` returns the element's box in the SAME user
42
+ * space as `frame` (null when it cannot). Never throws; a label it cannot judge is unfixable.
43
+ */
44
+ export declare function fitDeferredLabels(els: Element[], frame: ViewBox, measure: (el: Element) => Rect | null): DeferredLabelFitReport;
45
+ /** Perf ceiling: every text costs a getBoundingClientRect, and a Tabular can paint thousands of
46
+ * cells; above this the sweep costs more than the defect it fixes (and such charts scroll their
47
+ * own body anyway, so their overflow is intentional). */
48
+ export declare const FRAME_FIT_TEXT_CAP = 600;
49
+ export interface FrameFitOutcome {
50
+ /** Set when the pass declined: too many texts to measure. */
51
+ skipped?: {
52
+ texts: number;
53
+ cap: number;
54
+ };
55
+ /** The viewBox plan (planFrameFit). */
56
+ plan?: FrameFitPlan;
57
+ /** What happened to the labels the plan deferred, when there were any. */
58
+ labels?: DeferredLabelFitReport;
59
+ }
60
+ /**
61
+ * THE WHOLE PASS, host-free: grow the viewBox for text a few units past the frame (planFrameFit),
62
+ * then fit the labels it deferred (fitDeferredLabels). Null when there is nothing to measure (no
63
+ * svg, no text, no CTM, an unreadable viewBox). A host adds its own logging around this call; it
64
+ * is kept DOM-only so a headless replay can run exactly what a host runs.
65
+ */
66
+ export declare function fitTextToFrame(host: HTMLElement): FrameFitOutcome | null;
67
+ /** True when the element sits under a clip-path/mask, inside <defs>, or inside a scrollable
68
+ * subtree - all cases where being outside the frame is the chart's intent rather than a
69
+ * text-metric miss. */
70
+ export declare function isClippedOrScrolled(el: Element, stopAt: Element): boolean;
@@ -0,0 +1,4 @@
1
+ export { MIN_OVERLAP, MAX_GUTTER_FRACTION, COLORBAR_ON_PLOT_COVER, MAX_FIT_FRACTION, FIT_PAD, LEGIBILITY_GRACE_PX, MIN_LEGIBLE_PX, BOTTOM_TEXT_MIN_TICK_HITS, SWATCH_MAX_PX, SWATCH_LABEL_GAP_PX, SIGNATURE_MIN_PAIRS, AXIS_THIN_MIN_TICKS, AXIS_THIN_GAP, LABEL_ROW_MIN_TEXTS, LABEL_ROW_Y_TOLERANCE, LABEL_ROW_TRACK_COVER, LABEL_ROW_TRACK_REACH, rightmostOverlappingPlotEdge, bottommostOverlappingPlotEdge, planLegendReconcile, colorbarProbePoints, colorbarOnPlot, colorbarSitsOnMarks, colorbarReconcileSide, planContentFit, planBottomTextPush, swatchPairs, isLegendSignature, planAxisTickThin, isNumericLabelText, isDateLikeLabelText, isOrderedAxisLabelText, axisLabelsAreNominal, nominalLabelRooms, groupLabelRows, labelRowHasTrack, type Rect, type ViewBox, type ReconcileSide, type ReconcilePlan, type ReconcileOptions, type ContentFitPlan, type ContentFitOptions, type SwatchPairs, type AxisThinPlan, } from "./legendReconcile";
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
+ export { reconcileLegendInSvg, findSignatureLegendGroups, findColorbarFurniture, smallestRenderedTextPx, styleDeclaresScroll, type LegendReconcileResult, type AxisThinReport, } from "./legendReconcileDom";
4
+ export { FRAME_FIT_TEXT_CAP, fitTextToFrame, fitDeferredLabels, fitLabelToRoom, isClippedOrScrolled, type FrameFitOutcome, type DeferredLabelFitReport, } from "./frameFitDom";
@@ -0,0 +1,295 @@
1
+ /**
2
+ * D3 legend / plot reconciliation — pure geometry.
3
+ *
4
+ * The measured sibling of the label-contrast pass. A generated D3 chart
5
+ * can draw its legend ON TOP of plot content, or with its text running off the
6
+ * right edge, because legend WIDTH is data-dependent — the generator can't reserve
7
+ * the right gutter at author time without measuring rendered text, which only
8
+ * exists after render. So we measure post-render and fix it.
9
+ *
10
+ * Coordinates: EVERYTHING here is in the SVG's USER space (== viewBox units).
11
+ * The caller maps the measured client-px bounding boxes into user space via
12
+ * svg.getScreenCTM().inverse() before calling — that one matrix captures the
13
+ * viewBox, preserveAspectRatio and any container scaling/letterboxing exactly,
14
+ * so this module needs no px↔unit scale factor.
15
+ *
16
+ * The fix is TWO independent quantities (the bug in v1 was conflating them):
17
+ * - SLIDE (legendDx): move the legend right just enough that its LEFT edge
18
+ * clears the rightmost plot content it overlaps, + pad.
19
+ * - EXTEND (ext): widen the viewBox just enough that the slid legend's RIGHT
20
+ * edge sits inside it, + pad. Widening the viewBox makes the SVG scale to
21
+ * fit its container, so nothing is clipped and the plot isn't reflowed.
22
+ * The first version set EXTEND = SLIDE, which left the legend's right edge exactly
23
+ * as clipped as before and made the gutter overlap-sized rather than need-sized
24
+ * (a Sankey's legend text stayed cut off at the viewport edge, with more gutter
25
+ * than necessary).
26
+ *
27
+ * DOM-free: the caller gathers user-space boxes + the viewBox and applies the
28
+ * plan (set newViewBox, translate the legend group by legendDx). Tests feed
29
+ * plain numbers and assert the plan.
30
+ */
31
+ export interface Rect {
32
+ left: number;
33
+ top: number;
34
+ right: number;
35
+ bottom: number;
36
+ }
37
+ export interface ViewBox {
38
+ x: number;
39
+ y: number;
40
+ w: number;
41
+ h: number;
42
+ }
43
+ export type ReconcileSide = "right" | "bottom";
44
+ export interface ReconcilePlan {
45
+ action: "none" | "reserve";
46
+ legendDx: number;
47
+ legendDy: number;
48
+ ext: number;
49
+ newViewBox: ViewBox;
50
+ reason: string;
51
+ }
52
+ export declare const MIN_OVERLAP = 2;
53
+ export declare const MAX_GUTTER_FRACTION = 0.6;
54
+ export interface ReconcileOptions {
55
+ /** Ignore overlaps at or below this many user units (default MIN_OVERLAP). */
56
+ minOverlap?: number;
57
+ /** Defer if the viewBox extension would exceed this fraction of its width
58
+ * (default MAX_GUTTER_FRACTION). */
59
+ maxGutterFraction?: number;
60
+ /** Gap (user units) left between the legend's LEFT and the plot (default 8). */
61
+ pad?: number;
62
+ /** Gap (user units) left between the legend's RIGHT and the viewBox edge
63
+ * (default 4). Smaller than `pad` so the legend hugs the right edge — this
64
+ * is the lever for "too much gutter to the right of the legend". */
65
+ edgePad?: number;
66
+ }
67
+ /**
68
+ * The rightmost right-edge among plot elements that vertically overlap the
69
+ * legend and do NOT horizontally span it. Per-element (not a union) so a title
70
+ * or axis label that isn't actually beside the legend doesn't count, and a
71
+ * full-width plot background (which spans the legend) is ignored — that's not a
72
+ * collision a right gutter fixes. Returns -Infinity when nothing sits beside it.
73
+ */
74
+ export declare function rightmostOverlappingPlotEdge(legendBox: Rect, plotRects: Rect[]): number;
75
+ /**
76
+ * The bottom-most bottom-edge among plot elements whose box INTERSECTS the legend
77
+ * (horizontal AND vertical overlap) without fully spanning it. The 90°-rotated
78
+ * analogue of rightmostOverlappingPlotEdge — for a horizontal bottom
79
+ * legend/colorbar overprinted by the x-axis ticks/title. Box-intersection (not a
80
+ * "starts above" test) so a colorbar whose own TITLE sits above its gradient —
81
+ * making the union box taller than the colliders — still detects the x-axis
82
+ * labels that overlap the gradient. Content that clears the legend entirely
83
+ * (a cell row above, a footer below) doesn't intersect and is ignored. Returns
84
+ * -Infinity when nothing intersects.
85
+ */
86
+ export declare function bottommostOverlappingPlotEdge(legendBox: Rect, plotRects: Rect[]): number;
87
+ /**
88
+ * Decide whether/how to reconcile an overlapping/clipped D3 legend.
89
+ *
90
+ * @param legendBox legend group bbox in USER space
91
+ * @param plotRects per-element plot-content bboxes (marks + non-legend text) in USER space
92
+ * @param viewBox the SVG's current viewBox
93
+ * @param opts strictness thresholds (mode-dependent)
94
+ */
95
+ export declare function planLegendReconcile(legendBox: Rect | null, plotRects: Rect[] | null, viewBox: ViewBox | null, opts?: ReconcileOptions, side?: ReconcileSide): ReconcilePlan;
96
+ /**
97
+ * WHICH MARGIN A STRUCTURALLY-FOUND COLORBAR BELONGS IN.
98
+ *
99
+ * The colorbar pass was first written for ONE case — a horizontal bar in the bottom margin
100
+ * overprinting the x-axis — so it asked `planLegendReconcile` for side "bottom" and nothing
101
+ * else. A VERTICAL colorbar inset into the plot was therefore detected, measured, refused
102
+ * ("not-horizontal-legend") and shipped covering the marks. A treemap with `margin.right = 4`,
103
+ * a colorbar at `plotW - barW - 52` and a `rgba(255,255,255,0.88)` plate under it is that case
104
+ * exactly — the generated code's own comment reads *"Background plate so legend is readable
105
+ * over treemap tiles"*, so the occlusion was deliberate, and two cells and their labels went
106
+ * behind it.
107
+ *
108
+ * The right-side remedy already existed for a legend that carries `.d3-legend-mark`; a colorbar
109
+ * carries no contract class, which is the only reason it was out of reach. So this picks the
110
+ * side from the bar's own geometry and the rest of the machinery is unchanged.
111
+ *
112
+ * THE TESTS HERE MIRROR THE PLANNER'S OWN GATES, deliberately — a strip wider than 2× its
113
+ * height can only go down, anything else can only go right, and each must already be anchored
114
+ * in the margin it is being pushed into. Returning null where the planner would refuse keeps
115
+ * the two from disagreeing: a caller that guesses gets a "not-…" reason instead of a move, and
116
+ * a decorative gradient panel in the middle of the canvas is nobody's furniture.
117
+ */
118
+ /**
119
+ * IS THE COLORBAR DRAWN ON TOP OF THE CHART? Measured against the GRADIENT BAR itself,
120
+ * not the group box - and by plain intersection, not by the edge test the right slide uses.
121
+ *
122
+ * BOTH of those choices were forced by a corpus of rendered charts, and each was wrong the
123
+ * first time:
124
+ *
125
+ * - THE GROUP BOX IS AMBIGUOUS. It also holds the ticks, the title and a backing plate, so
126
+ * "marks reach past the group's left edge" reads the same for a colorbar sitting on the
127
+ * plot and for one parked correctly in the gutter with a label hanging left of its bar.
128
+ * Over 401 rendered charts that ambiguity was the whole problem: gating on the group box
129
+ * moved 89 charts, only 37 of which had a mark near the bar at all. The BAR's own box has
130
+ * no such ambiguity - a mark under it means the colorbar was drawn over the chart, full
131
+ * stop. That reading finds 15.
132
+ * - `rightmostOverlappingPlotEdge` SKIPS A MARK THAT SPANS THE BOX, which is right for a
133
+ * full-width backdrop and exactly wrong here: a treemap tile drawn behind the colorbar
134
+ * spans it, and that is the case where the occlusion is TOTAL. It answered "nothing
135
+ * overlaps" on a bar sitting entirely on a tile.
136
+ *
137
+ * A MAJORITY, because a colorbar beside the marks still gets its edge touched. The measured
138
+ * spread of `cover` over the 15 is
139
+ * 1.00 1.00 1.00 0.97 | 0.57 0.49 0.41 | 0.25 0.25 0.13 0.06 0.05 0.03 0.01 0.01
140
+ * - the top four are bars drawn wholly on a mark, and the tail from 0.25 down is the last
141
+ * bar of a chart grazing a bar parked in the gutter, which is the content-fit pass's business
142
+ * and not a reason to re-lay-out a cached chart. 0.5 is the default, not a discovery: it is
143
+ * the one number here that a reader should feel free to move.
144
+ */
145
+ export declare const COLORBAR_ON_PLOT_COVER = 0.5;
146
+ /**
147
+ * WHERE TO ASK "IS ANYTHING PAINTED HERE?" - an evenly spaced grid strictly inside the
148
+ * bar, inset half a cell so no probe lands on its own edge.
149
+ *
150
+ * A BOX IS NOT INK, and this pass learned it the expensive way. Gating on box
151
+ * intersection alone fired on a density-contour plot whose OUTERMOST contour
152
+ * is a thin diagonal band from the top-left to the bottom-right: its bounding box is
153
+ * 705x664 of a 776x779 canvas and therefore covers the colorbar completely, while its
154
+ * ink comes nowhere near it. A box-only test shipped at 50% precision on the corpus.
155
+ * The remedy is the browser's own answer - isPointInFill / isPointInStroke at these
156
+ * points.
157
+ */
158
+ export declare function colorbarProbePoints(bar: Rect | null, cols?: number, rows?: number): {
159
+ x: number;
160
+ y: number;
161
+ }[];
162
+ /** A majority of the bar's own area has a mark painted under it. */
163
+ export declare function colorbarOnPlot(inkHits: number, probes: number, minCover?: number): boolean;
164
+ export declare function colorbarSitsOnMarks(bar: Rect | null, marks: Rect[] | null, minCover?: number): boolean;
165
+ export declare function colorbarReconcileSide(bar: Rect | null, viewBox: ViewBox | null): ReconcileSide | null;
166
+ /**
167
+ * CONTENT FIT — the universal "nothing gets cut off" pass (a Sankey used
168
+ * margin.left=20 with text-anchor:end node labels at x0-6, so left labels hung ~80
169
+ * units into NEGATIVE x and were clipped — while the legend mechanism, which only
170
+ * ever measures the legend column and extends the RIGHT edge, applied cleanly.
171
+ * Whack-a-mole by design: every targeted fix watches one edge).
172
+ *
173
+ * This is edge- and chart-agnostic: take the union bbox of ALL rendered
174
+ * content and EXTEND the viewBox on whichever sides content hangs past, + pad.
175
+ * Extend-only — never shrink (intentional margins/whitespace are not ours to
176
+ * reclaim). Runs after any legend slide (it re-measures, so it covers the slid
177
+ * legend's final position too) and also when there is NO legend at all.
178
+ *
179
+ * Outlier defense: a generation may park junk far off-canvas (work elements at
180
+ * x=-9999). A rect is considered only if it intersects the viewBox expanded by
181
+ * maxFitFraction per side; the final extension is also clamped to that
182
+ * fraction. A real hanging label sits just past the edge and is unaffected;
183
+ * parked junk is ignored rather than blowing the canvas up.
184
+ */
185
+ export interface ContentFitPlan {
186
+ action: "none" | "fit";
187
+ newViewBox: ViewBox;
188
+ reason: string;
189
+ /** user units added per side (0 when that side didn't move) */
190
+ cut: {
191
+ left: number;
192
+ top: number;
193
+ right: number;
194
+ bottom: number;
195
+ };
196
+ }
197
+ export declare const MAX_FIT_FRACTION = 0.35;
198
+ export declare const FIT_PAD = 4;
199
+ export declare const LEGIBILITY_GRACE_PX = 1;
200
+ export interface ContentFitOptions {
201
+ /** Ignore per-side overhangs at or below this many user units (default MIN_OVERLAP). */
202
+ minOverhang?: number;
203
+ /** Per-side extension cap as a fraction of the viewBox dimension (default MAX_FIT_FRACTION). */
204
+ maxFitFraction?: number;
205
+ /** Padding (user units) between content and the new viewBox edge (default FIT_PAD). */
206
+ pad?: number;
207
+ /**
208
+ * The smallest text the chart currently paints, in SCREEN px — declared user units
209
+ * already multiplied by the live CTM scale, because that is the number a reader's eye
210
+ * meets. Omitted (or non-finite) disables the legibility test entirely, which is the
211
+ * honest degrade: a chart whose text could not be measured must keep today's behaviour
212
+ * rather than be refused on a guess.
213
+ */
214
+ smallestTextPx?: number;
215
+ /** The readable floor in px (default MIN_LEGIBLE_PX). */
216
+ minLegiblePx?: number;
217
+ /** How much of the smallest text the fit may spend (default LEGIBILITY_GRACE_PX). */
218
+ legibilityGracePx?: number;
219
+ }
220
+ export declare const MIN_LEGIBLE_PX = 10;
221
+ export declare function planContentFit(contentRects: Rect[] | null, viewBox: ViewBox | null, opts?: ContentFitOptions): ContentFitPlan;
222
+ export declare const BOTTOM_TEXT_MIN_TICK_HITS = 2;
223
+ export declare function planBottomTextPush(candidate: Rect, tickBoxes: Rect[], gap?: number, transform?: string): {
224
+ move: boolean;
225
+ dy: number;
226
+ };
227
+ export declare const SWATCH_MAX_PX = 28;
228
+ export declare const SWATCH_LABEL_GAP_PX = 30;
229
+ export declare const SIGNATURE_MIN_PAIRS = 2;
230
+ export interface SwatchPairs {
231
+ pairs: number;
232
+ sameSize: boolean;
233
+ }
234
+ export declare function swatchPairs(shapes: Rect[], texts: Rect[]): SwatchPairs;
235
+ export declare function isLegendSignature(p: SwatchPairs): boolean;
236
+ export declare const AXIS_THIN_MIN_TICKS = 4;
237
+ export declare const AXIS_THIN_GAP = 2;
238
+ export interface AxisThinPlan {
239
+ stride: number;
240
+ hide: number[];
241
+ reason: string;
242
+ }
243
+ /**
244
+ * Boxes are the tick labels of ONE axis, in any order (they are sorted here by
245
+ * left edge, and `hide` comes back indexed against the INPUT). Returns the
246
+ * smallest stride that clears every adjacent collision.
247
+ *
248
+ * Fail-open in both directions: too few ticks to be worth thinning, and an axis
249
+ * still colliding when only its two ends survive, both hide NOTHING. An ugly
250
+ * axis is recoverable by resizing the tile; an axis stripped to one label is not.
251
+ */
252
+ export declare function planAxisTickThin(boxes: Rect[], gap?: number, minTicks?: number): AxisThinPlan;
253
+ export declare const LABEL_ROW_MIN_TEXTS = 3;
254
+ export declare const LABEL_ROW_Y_TOLERANCE = 2;
255
+ /**
256
+ * A tick-like label: at least one digit, and nothing but digits and the furniture a number
257
+ * format adds - separators, sign, decimal point, currency, percent, a K/M/B suffix, a
258
+ * time colon. "Jan 2026" and "1 region unmatched" are not numeric; "$1.2M" and "-3.5%" are.
259
+ */
260
+ export declare function isNumericLabelText(s: string): boolean;
261
+ /**
262
+ * A date-like axis caption: a year (2026, FY2026, "Jan 2026"), a month or weekday name, a quarter,
263
+ * half or week ("Q3", "H1", "W12"), a slashed date ("1/15", "15.01.2026") or a clock time ("6 PM").
264
+ * Deliberately generous: a label read as ordered is thinned as before, so the cost of a wrong
265
+ * "date" is today's behaviour, never worse.
266
+ */
267
+ export declare function isDateLikeLabelText(s: string): boolean;
268
+ /** Ordered = a number (isNumericLabelText) or a date-like caption (isDateLikeLabelText). */
269
+ export declare function isOrderedAxisLabelText(s: string): boolean;
270
+ /** A NOMINAL axis: at least one non-empty label that is neither a number nor date-like. One name
271
+ * among dates counts - that name has no neighbour to imply it. Empty labels say nothing. */
272
+ export declare function axisLabelsAreNominal(labels: string[]): boolean;
273
+ /**
274
+ * The room each label of a nominal axis may take, in input order: the step to its NARROWER
275
+ * neighbour less `gap`. `anchors` are the labels' anchor x positions (centre for middle, left
276
+ * edge for start, right edge for end - one anchor kind per axis), so the step is the band pitch
277
+ * whatever the labels' widths. Two labels each within that room cannot collide, whichever anchor
278
+ * they share. A lone anchor has no band and no limit.
279
+ */
280
+ export declare function nominalLabelRooms(anchors: number[], gap?: number): number[];
281
+ /**
282
+ * Group label boxes into horizontal ROWS - same vertical centre within `tol` px. Returns
283
+ * index lists into the input, each sorted by left edge; rows of one are dropped because a
284
+ * lone label has nothing to collide with.
285
+ */
286
+ export declare function groupLabelRows(boxes: Rect[], tol?: number): number[][];
287
+ export declare const LABEL_ROW_TRACK_COVER = 0.8;
288
+ export declare const LABEL_ROW_TRACK_REACH = 3;
289
+ /**
290
+ * Does a horizontal track run under (or over) this row of labels? `row` is the row's label
291
+ * boxes; `candidates` are the non-mark rect / line / path boxes of the row's own group. The
292
+ * candidates within reach are unioned - a five-swatch bar is five rects - and the union has
293
+ * to cover `cover` of the row's span. Pure, so the geometry is testable without a DOM.
294
+ */
295
+ export declare function labelRowHasTrack(row: Rect[], candidates: Rect[], cover?: number, reach?: number): boolean;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * D3 legend reconcile — DOM adapter.
3
+ *
4
+ * The PURE geometry lives in legendReconcile.ts (unit-tested). This is the
5
+ * browser-side glue that the geometry can't be: find the legend group in a
6
+ * rendered SVG, measure it + the plot content in the SVG's USER space (via
7
+ * getScreenCTM, which captures viewBox + scaling exactly), run the plan, and
8
+ * apply it (widen the viewBox, slide the legend). It needs browser layout
9
+ * (getScreenCTM / DOMPoint), so its tests run on a jsdom layout stand-in — same
10
+ * split as the label-contrast pass's applier and its pure decision. Idempotent within
11
+ * a render via a data attribute; the caller wraps it in try/catch and owns logging.
12
+ */
13
+ import { ReconcileOptions } from "./legendReconcile";
14
+ export interface LegendReconcileResult {
15
+ applied: boolean;
16
+ reason: string;
17
+ dx?: number;
18
+ ext?: number;
19
+ newW?: number;
20
+ furniture?: number;
21
+ fit?: string;
22
+ cb?: string;
23
+ bt?: string;
24
+ fitDeclined?: string;
25
+ smallestTextPx?: number;
26
+ at?: string;
27
+ axisThin?: AxisThinReport;
28
+ lt?: string;
29
+ labelThin?: {
30
+ rows: number;
31
+ hidden: number;
32
+ stride: number;
33
+ };
34
+ dy?: number;
35
+ source?: "class" | "signature";
36
+ }
37
+ export declare function smallestRenderedTextPx(svg: SVGSVGElement, ctmScale: number): number | null;
38
+ /**
39
+ * SELF-MANAGED SCROLL LAYOUT. A chart that renders its
40
+ * content as HTML inside a <foreignObject> with its OWN inner scroll body — a
41
+ * table-with-embedded scorecard (30 users × 32px rows scrolling inside a ~525px body) —
42
+ * manages its own frame. Its below-the-fold rows are SCROLLED, not clipped. The
43
+ * content-fit pass, which exists to rescue clipped SVG content, reads those off-canvas
44
+ * row <text>/marks as clipped and grows the viewBox (+239 on the bottom in that case);
45
+ * preserveAspectRatio then draws the taller viewBox to fit, letterboxing the chart into
46
+ * huge left/right gutters with everything shrunk (~72%) and the text illegibly small.
47
+ * NONE of the reconcile passes (legend slide / colorbar push / bottom-text / content-fit)
48
+ * apply to a self-scrolling HTML table, so skip the whole thing. Detected STRUCTURALLY (a
49
+ * <foreignObject> containing an element that scrolls its own content), never by chart-type
50
+ * name — any future self-managed layout is covered automatically.
51
+ */
52
+ export declare function styleDeclaresScroll(inlineStyle: string | null | undefined, overflow?: string, overflowY?: string): boolean;
53
+ /**
54
+ * AXIS TICK THINNING. A horizontal axis whose own tick labels overlap each
55
+ * other - eleven "Jan 2026" into a 312px plot, or "50K | 60K | 70K" along a
56
+ * numeric axis. The decision (which stride clears it) is planAxisTickThin; this
57
+ * is the DOM half: find each axis, establish it IS horizontal, measure the real
58
+ * text, and hide the labels the plan drops.
59
+ *
60
+ * MEASURED, NOT ESTIMATED, and that is the whole reason this lives client-side.
61
+ * The server exec-gate breadcrumb has to approximate text width as
62
+ * chars * fontSize * 0.6 because jsdom has no getComputedTextLength - good
63
+ * enough to triage on and not good enough to spend a regeneration on. Here the
64
+ * box is the browser's own.
65
+ *
66
+ * Hides the text only. The tick LINE and the gridline stay, so the reader keeps
67
+ * every position they align against and loses only a redundant caption.
68
+ * Conservative and fail-open throughout: fewer than four ticks, any rotated
69
+ * label (rotation is the generation's own answer to density and pairs badly
70
+ * with thinning), a vertical axis, or an axis still colliding when only its two
71
+ * ends survive all leave the axis exactly as it was drawn.
72
+ */
73
+ /** What the axis pass did, for the caller's log line. cut / shrunk / nominal appear only when a
74
+ * NOMINAL axis was fitted instead of thinned; colliding counts labels still touching a
75
+ * neighbour after the cut kept their first character (a band too narrow for even that). */
76
+ export interface AxisThinReport {
77
+ axes: number;
78
+ hidden: number;
79
+ stride: number;
80
+ cut?: number;
81
+ shrunk?: number;
82
+ nominal?: boolean;
83
+ colliding?: number;
84
+ }
85
+ export declare function reconcileLegendInSvg(svg: SVGSVGElement, opts?: ReconcileOptions): LegendReconcileResult;
86
+ /**
87
+ * LEGENDS THAT NEVER ANNOUNCED THEMSELVES. The
88
+ * maximal mark-free <g> subtrees of the chart, judged by the legend signature in
89
+ * legendReconcile.ts. Walk top-down and stop descending once a group qualifies as
90
+ * mark-free, so an axis is one block and a legend nested inside the plot group is
91
+ * found on the way down. Axes are skipped STRUCTURALLY - d3's axis generator always
92
+ * emits g.tick / path.domain - which is the one exclusion a signature is allowed.
93
+ */
94
+ export declare function findSignatureLegendGroups(svg: SVGSVGElement): Element[];
95
+ /**
96
+ * THE COLORBAR'S OWN FURNITURE, FOUND STRUCTURALLY. The class-based legend finder
97
+ * above can't see a colorbar — the generator leaves the gradient rect UNCLASSED (and
98
+ * reuses .d3-legend-mark for the clickable AXIS labels, which are COLLIDERS here,
99
+ * not furniture). So: a <rect> filled url(#<gradient>) that isn't a plot mark; its
100
+ * parent <g> is the colorbar group, which is what carries the tick labels, the
101
+ * title and — the case that made this matter — a backing plate.
102
+ *
103
+ * Exported so the corpus replay measures with the SHIPPED detector instead of a
104
+ * copy of it; a replay that re-implements the predicate tests the translation.
105
+ */
106
+ export declare function findColorbarFurniture(svg: SVGSVGElement): {
107
+ groups: Element[];
108
+ bars: SVGGraphicsElement[];
109
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bicharts/chart-host",
3
- "version": "0.6.84",
3
+ "version": "0.6.85",
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",
@@ -59,6 +59,11 @@
59
59
  "import": "./dist/adapters.mjs",
60
60
  "default": "./dist/adapters.mjs"
61
61
  },
62
+ "./legend-fit": {
63
+ "types": "./dist/types/legendFit/index.d.ts",
64
+ "import": "./dist/legend-fit.mjs",
65
+ "default": "./dist/legend-fit.mjs"
66
+ },
62
67
  "./dax": {
63
68
  "types": "./dist/types/dax.d.ts",
64
69
  "import": "./dist/dax.mjs",