@bicharts/chart-host 0.6.83 → 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.
@@ -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
+ };
@@ -44,15 +44,66 @@ export interface QualifyRefusalRow {
44
44
  reason?: string | null;
45
45
  /** True = a required channel is ABSENT. Absent/false = a threshold we are willing to waive. */
46
46
  isVeto?: boolean | null;
47
+ /**
48
+ * WHICH GATE wrote `reason`, as the server's stable code. Absent from a server that predates
49
+ * the code, which reads as "not known to be about the tile": the row keeps the grouping the
50
+ * list always had.
51
+ */
52
+ reasonCode?: string | null;
47
53
  }
48
54
  /** Which heading to write BEFORE a refused row, if any. */
49
- export type QualifyRefusalHeading = "poorFit" | "cannotDraw";
55
+ export type QualifyRefusalHeading = "poorFit" | "tooSmall" | "cannotDraw";
50
56
  /** Carried across the refusal loop. One per render. */
51
57
  export interface QualifyRefusalGroupState {
52
58
  poorFit: boolean;
59
+ tooSmall: boolean;
53
60
  cannotDraw: boolean;
54
61
  }
55
62
  export declare function newQualifyRefusalGroupState(): QualifyRefusalGroupState;
63
+ /**
64
+ * THE REFUSAL CODES THAT MEAN "THE TILE", NOT "THE FIELDS".
65
+ *
66
+ * Each refusal carries the code of the gate that wrote its sentence. These gates compare what a
67
+ * chart type needs with the size and shape of the tile and nothing else:
68
+ * - TILE_TOO_NARROW, TILE_TOO_SHORT, TILE_NOT_SQUARE, TILE_NOT_TALL: too narrow, too short, not
69
+ * square enough, not tall enough.
70
+ * - TILE_TOO_SMALL: a type that does not hold up on a small tile, turned down because the tile is
71
+ * under the size the full catalogue needs, whatever the data is.
72
+ * - EMBED_NOTHING_FITS_TILE: a headline-plus-small-drawing type whose drawing has no form that
73
+ * fits the tile; the headline alone is still drawable on a plain card.
74
+ * - EMBED_BEESWARM_TILE_TOO_SMALL: a beeswarm turned down because the tile is under the size it
75
+ * needs.
76
+ * The data can be exactly right and the type is still turned down, and the one thing that changes
77
+ * the answer is a bigger tile.
78
+ *
79
+ * Without this split those rows sat under "Can't be drawn from the fields as bound" or "Poor fit
80
+ * for these fields" - headings that send the reader to rebind data that is fine.
81
+ *
82
+ * ONLY GATES THAT LOOK AT THE TILE ALONE ARE LISTED. A gate that weighs the tile against the data
83
+ * (ROWS_CRAMPED, a row-per-category floor; TOO_MANY_PANELS, a panel budget; CATEGORIES_TOO_WIDE)
84
+ * can be answered by either, so naming it a tile problem would be the same false instruction
85
+ * turned around. A code is added here when the server starts sending one that is purely about the
86
+ * tile; an unknown code keeps the grouping it had.
87
+ */
88
+ export declare const QUALIFY_TILE_REFUSAL_CODES: readonly string[];
89
+ /**
90
+ * THE HEADING OVER THE TILE BLOCK, in the one place every host reads it from, so the wording
91
+ * cannot drift between them and a test pins it once.
92
+ *
93
+ * It says what the reader can do about it, not what category the row is in, and it says nothing
94
+ * about the fields: the rows under it are exactly the ones the fields are not the problem for.
95
+ * English source text - a host that localizes looks it up like its other headings.
96
+ */
97
+ export declare const QUALIFY_TOO_SMALL_HEADING = "Needs a bigger tile";
98
+ /**
99
+ * IS THIS REFUSAL ABOUT THE TILE'S SIZE OR SHAPE? Decided from the server's code, never from the
100
+ * sentence: a host that matched the English would go blind the day the sentences are translated,
101
+ * and would re-derive a verdict it was already handed.
102
+ *
103
+ * A row with no code (an older server) is NOT tile-bound. That is the safe direction: it keeps
104
+ * the two-heading grouping the list had before the code existed.
105
+ */
106
+ export declare function refusalIsTileBound(row: QualifyRefusalRow | null | undefined): boolean;
56
107
  /**
57
108
  * MAY THE READER PICK THIS ONE? The single place that answers it, in either host.
58
109
  *
@@ -63,15 +114,22 @@ export declare function newQualifyRefusalGroupState(): QualifyRefusalGroupState;
63
114
  */
64
115
  export declare function refusalIsSelectable(row: QualifyRefusalRow | null | undefined): boolean;
65
116
  /**
66
- * The refused rows in RENDER ORDER: everything pickable first, then the vetoes.
117
+ * The refused rows in RENDER ORDER: the pickable ones first, then the ones the tile alone turned
118
+ * down, then the vetoes the fields cause.
67
119
  *
68
120
  * A STABLE PARTITION, not a sort — within each block the server's alphabetical order survives, so
69
121
  * two answers over the same shape stay diffable. Rows without a name are dropped: a control
70
122
  * labelled with nothing cannot be chosen and a reason with no subject cannot be read.
71
123
  *
72
- * The two blocks are ordered pickable-first because the reader opened this section to DO something.
124
+ * The blocks are ordered pickable-first because the reader opened this section to DO something.
73
125
  * Putting the inert half above the actionable half makes them scroll past every chart they cannot
74
126
  * have to reach the ones they can.
127
+ *
128
+ * THE TILE BLOCK SITS BETWEEN THE TWO, and its own rows are pickable-first too. A tile refusal is
129
+ * a veto or a waivable one like any other (the reader may still pick the waivable ones), but what
130
+ * it asks of the reader is different: resize, not rebind. Every row the code marks as about the
131
+ * tile goes there whichever way its veto flag points, so none is left under a heading that names
132
+ * the fields.
75
133
  */
76
134
  export declare function orderRefusalsForDisplay<T extends QualifyRefusalRow>(rows: readonly T[] | null | undefined): T[];
77
135
  /**
@@ -3,6 +3,20 @@
3
3
  * numbers. A token that is not one names no row and is skipped; no attribute, no rows.
4
4
  */
5
5
  export declare function rowIdxsFromMark(el: Element | null): number[];
6
+ /** The most rows one published set may name: the largest window any host hands a chart. A set past it
7
+ * is cut there rather than refused, because the cap bounds the work an event can ask for and a table
8
+ * never holds a row beyond it. */
9
+ export declare const ROW_SET_MAX = 250000;
10
+ /** The rows a published set names: whole non-negative numbers only, each once, in the order given, at
11
+ * most ROW_SET_MAX. Anything else in the array is dropped, and a value that is not an array names none. */
12
+ export declare function validRowSet(rows: unknown): number[];
13
+ /**
14
+ * Publish a selection as a row set: fires the cross-filter event on `container`, carrying `rows` (valid
15
+ * ones only) and the `source` the host passes on to its subscribers. An empty set is a clear. For a
16
+ * chart that computes its own selection; a chart that selects by marks needs nothing, the host reads
17
+ * data-row-idx itself.
18
+ */
19
+ export declare function dispatchRowSet(container: Element, rows: readonly number[], source?: string): void;
6
20
  /** The keys held during a click. Cmd counts as Ctrl. */
7
21
  export interface SelectionModifiers {
8
22
  ctrl?: boolean;
@@ -89,6 +103,43 @@ export declare function nextClickedMarks(prev: ClickedMarks | null | undefined,
89
103
  * its parent. A click inside a control is neither a selection nor a click on empty canvas.
90
104
  */
91
105
  export declare function isInsideControl(target: EventTarget | Node | null | undefined, container: Element): boolean;
106
+ /** Movement between press and release, in px on either axis, at which a gesture is a drag. */
107
+ export declare const DRAG_NOT_CLICK_PX = 5;
108
+ /** A pointer position in client px. */
109
+ export interface PressPoint {
110
+ x: number;
111
+ y: number;
112
+ }
113
+ /** True when the pointer travelled far enough between press and release to be a drag. */
114
+ export declare function gestureWasDrag(down: PressPoint | null | undefined, up: PressPoint, thresholdPx?: number): boolean;
115
+ /** Where the press is parked: on the CONTAINER, so every reader (the host's click handler, a renderer
116
+ * adapter, the visual's bridge) reads the one record. */
117
+ export declare const CONTAINER_SLOT_PRESS_AT = "__lchPressAt";
118
+ /** The recorded press on `container`, or null when there is none. */
119
+ export declare function pressOf(container: Element | null | undefined): PressPoint | null;
120
+ /** Forget the recorded press: the click it led to has been read. */
121
+ export declare function clearPress(container: Element | null | undefined): void;
122
+ /**
123
+ * Record where the pointer goes down on `container`, in the CAPTURE phase so a renderer that stops
124
+ * propagation on its own handlers still leaves its origin, and whether it then travels 5px or more
125
+ * before it comes up. The travel is what makes a drag that ends where it began (a lasso closed on its
126
+ * own start, a brush returned to its origin) still a drag: the click it leaves has moved nowhere.
127
+ * Returns the function that stops all of it.
128
+ */
129
+ export declare function trackPress(container: HTMLElement): () => void;
130
+ /**
131
+ * True when `click` ended a press whose pointer travelled DRAG_NOT_CLICK_PX or more, on the way or by where
132
+ * the click landed. Reads the press, never writes it.
133
+ *
134
+ * A click a pointer made carries detail >= 1. A keyboard or scripted click carries detail 0 and no
135
+ * pointer position, so it is compared with nothing: the press on record may be one that never produced a
136
+ * click (released outside the chart), and a stale press must not turn a later keyboard click into a drag.
137
+ */
138
+ export declare function clickWasDrag(container: Element | null | undefined, click: {
139
+ clientX?: number;
140
+ clientY?: number;
141
+ detail?: number;
142
+ } | null | undefined): boolean;
92
143
  export interface MarkResolverEnv {
93
144
  root: HTMLElement;
94
145
  doc: Document;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bicharts/chart-host",
3
- "version": "0.6.83",
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",