@bicharts/chart-host 0.6.84 → 0.6.86
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -0
- package/dist/chunk-BQFG5HWZ.mjs +582 -0
- package/dist/{chunk-2HKO6GEK.mjs → chunk-MXL5SK7X.mjs} +11 -575
- package/dist/chunk-ZGQURBRJ.mjs +265 -0
- package/dist/index.mjs +37 -275
- package/dist/legend-fit.mjs +1645 -0
- package/dist/react.mjs +6 -3
- package/dist/types/host.d.ts +9 -0
- package/dist/types/legendFit/frameFit.d.ts +89 -0
- package/dist/types/legendFit/frameFitDom.d.ts +70 -0
- package/dist/types/legendFit/index.d.ts +5 -0
- package/dist/types/legendFit/legendReconcile.d.ts +295 -0
- package/dist/types/legendFit/legendReconcileDom.d.ts +109 -0
- package/dist/types/legendFit/pass.d.ts +29 -0
- package/dist/types/react.d.ts +7 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -351,6 +351,70 @@ const report = await assertViewStateConformance(service, {
|
|
|
351
351
|
expect(report.skipped).toEqual([]); // nothing went unchecked
|
|
352
352
|
```
|
|
353
353
|
|
|
354
|
+
## Keeping a drawn chart readable inside its frame
|
|
355
|
+
|
|
356
|
+
A generated chart cannot know how wide its own legend text will come out, where its tick labels will
|
|
357
|
+
collide, or how far a label will hang past the frame, because none of that exists until the browser
|
|
358
|
+
has laid the SVG out. `@bicharts/chart-host/legend-fit` measures the rendered chart and repairs what
|
|
359
|
+
it finds. It is opt-in, and a separate entry so a host that never calls it never loads it.
|
|
360
|
+
|
|
361
|
+
```ts
|
|
362
|
+
import { chartSvgOf } from "@bicharts/chart-host";
|
|
363
|
+
import { reconcileLegendInSvg, fitTextToFrame } from "@bicharts/chart-host/legend-fit";
|
|
364
|
+
|
|
365
|
+
// After a render, once the SVG is in the document (layout must exist):
|
|
366
|
+
const svg = chartSvgOf(container);
|
|
367
|
+
const result = svg && reconcileLegendInSvg(svg); // { applied, reason, dx, ext, fit, ... }
|
|
368
|
+
fitTextToFrame(container); // grows the viewBox for text at the edge, fits the rest
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
What `reconcileLegendInSvg` does, in this order, each phase on its own and each leaving the chart as
|
|
372
|
+
it was drawn when it has no evidence:
|
|
373
|
+
|
|
374
|
+
1. **Legend slide.** A legend that overlaps the plot or runs off the right edge is slid clear and the
|
|
375
|
+
viewBox widened just enough to hold it. The legend is found by `.d3-legend-mark`, or by its measured
|
|
376
|
+
shape (a mark-free group of same-size swatches, each with a label) when the chart omitted the class.
|
|
377
|
+
2. **Colorbar.** A gradient bar drawn over the marks, or over the x-axis, is pushed into the margin it
|
|
378
|
+
belongs in.
|
|
379
|
+
3. **Axis and label thinning.** A horizontal axis whose tick labels collide loses every k-th label
|
|
380
|
+
(the tick lines stay). A nominal axis, where each label is the only record of its bar, is never
|
|
381
|
+
thinned: each label is shrunk, then shortened with the full name in a `<title>`.
|
|
382
|
+
4. **Bottom text.** An axis title or caption sitting on top of the tick labels is pushed below them.
|
|
383
|
+
5. **Content fit.** The viewBox is extended so nothing the chart painted is cut off, unless that
|
|
384
|
+
would shrink the smallest text past a legibility floor.
|
|
385
|
+
|
|
386
|
+
`fitTextToFrame` is the sibling for text parked against the raw edge of the frame: a few units past
|
|
387
|
+
it grow the viewBox, and a label too far out for that is shrunk and then cut to the widest prefix
|
|
388
|
+
that fits, never inside a number.
|
|
389
|
+
|
|
390
|
+
Both are idempotent per render (the SVG is stamped) and take no policy: pass
|
|
391
|
+
`{ minOverlap, maxGutterFraction }` to `reconcileLegendInSvg` to make it act less often, and decide
|
|
392
|
+
yourself when to call it. They are best-effort, so call them in a `try`/`catch` and log what you like:
|
|
393
|
+
a throw must never fail a chart that already drew. The geometry behind each phase (`planLegendReconcile`, `planContentFit`,
|
|
394
|
+
`planAxisTickThin`, `planFrameFit`, ...) is exported as plain functions over boxes, for a host that
|
|
395
|
+
measures on its own terms.
|
|
396
|
+
|
|
397
|
+
**Running them after every render.** `createLegendFitPass()` is both passes in the order that works
|
|
398
|
+
(the legend first, then the frame fit, which measures what the legend pass left), with nothing to log
|
|
399
|
+
and no policy. Hand it to the host as `layoutPass`, and the host runs it after every render, re-render
|
|
400
|
+
and async settle, after the label colors are fixed and before anything measures the marks or the
|
|
401
|
+
frame:
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
import { createChartHost } from "@bicharts/chart-host";
|
|
405
|
+
import { createLegendFitPass } from "@bicharts/chart-host/legend-fit";
|
|
406
|
+
|
|
407
|
+
const layoutPass = createLegendFitPass({
|
|
408
|
+
legend: true, // or "narrow" to act only on large overlaps, or false
|
|
409
|
+
frameFit: true,
|
|
410
|
+
onReport: r => console.debug(r.legend, r.frame), // what each run did; log what you like
|
|
411
|
+
});
|
|
412
|
+
const host = createChartHost(el, { code, data, d3, layoutPass });
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
In React it is the `layoutPass` prop of `<BicChart>`. Build the pass once, at module scope or in a
|
|
416
|
+
`useMemo`: a new function on every render is a new host config, and the chart is rebuilt.
|
|
417
|
+
|
|
354
418
|
## Architecture — three layers, and why it matters if you contribute
|
|
355
419
|
|
|
356
420
|
Read this before adding code. The package has **three** concerns, and they are independent.
|
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
/*! @bicharts/chart-host — Apache-2.0. Bundled reference data: GeoNames (https://www.geonames.org/) CC BY 4.0; Natural Earth and US Census/TIGER (public domain). Full text: NOTICE in this package. */
|
|
2
|
+
import {
|
|
3
|
+
SNAPSHOT_SVG_ATTR
|
|
4
|
+
} from "./chunk-MR6TL7QZ.mjs";
|
|
5
|
+
|
|
6
|
+
// src/fit.ts
|
|
7
|
+
var SCROLL_SLACK_PX = 8;
|
|
8
|
+
function needsScroll(contentPx, viewportPx, slackPx = SCROLL_SLACK_PX) {
|
|
9
|
+
if (!isFinite(contentPx) || !isFinite(viewportPx)) return false;
|
|
10
|
+
if (contentPx <= 0 || viewportPx <= 0) return false;
|
|
11
|
+
return contentPx - viewportPx > slackPx;
|
|
12
|
+
}
|
|
13
|
+
function contentExtentOf(boxes) {
|
|
14
|
+
if (!boxes || boxes.length === 0) return { w: 0, h: 0, source: "none" };
|
|
15
|
+
const usable = boxes.filter((b) => b && isFinite(b.right) && isFinite(b.bottom) && (b.right > 0 || b.bottom > 0));
|
|
16
|
+
if (usable.length === 0) return { w: 0, h: 0, source: "none" };
|
|
17
|
+
const inFlow = usable.filter((b) => !b.floating);
|
|
18
|
+
const counted = inFlow.length > 0 ? inFlow : usable;
|
|
19
|
+
let w = 0, h = 0;
|
|
20
|
+
for (const b of counted) {
|
|
21
|
+
if (b.right > w) w = b.right;
|
|
22
|
+
if (b.bottom > h) h = b.bottom;
|
|
23
|
+
}
|
|
24
|
+
return { w, h, source: inFlow.length > 0 ? "in-flow" : "all-floating" };
|
|
25
|
+
}
|
|
26
|
+
function scrollFitFor(contentW, contentH, viewportW, viewportH, slackPx = SCROLL_SLACK_PX) {
|
|
27
|
+
return {
|
|
28
|
+
overflowX: needsScroll(contentW, viewportW, slackPx) ? "auto" : "hidden",
|
|
29
|
+
overflowY: needsScroll(contentH, viewportH, slackPx) ? "auto" : "hidden"
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
var FIT_CONTENT_SELECTOR = "text, .d3-mark, .d3-legend-mark";
|
|
33
|
+
var PHANTOM_FRACTION = 0.95;
|
|
34
|
+
function isPhantomBox(w, h, refW, refH) {
|
|
35
|
+
return w > refW * PHANTOM_FRACTION || h > refH * PHANTOM_FRACTION;
|
|
36
|
+
}
|
|
37
|
+
var MAX_FRAME_GROW_FACTOR = 20;
|
|
38
|
+
function planFrameGrow(inkBottomPx, elHeightPx, viewBoxH, ctmScale, slackPx = SCROLL_SLACK_PX, maxFactor = MAX_FRAME_GROW_FACTOR) {
|
|
39
|
+
const none = (reason) => ({ grow: false, heightPx: elHeightPx, viewBoxH, reason });
|
|
40
|
+
if (!isFinite(inkBottomPx) || !isFinite(elHeightPx) || elHeightPx <= 0) return none("unmeasurable");
|
|
41
|
+
if (!needsScroll(inkBottomPx, elHeightPx, slackPx)) return none("fits");
|
|
42
|
+
if (inkBottomPx > elHeightPx * maxFactor) return none("beyond-ceiling");
|
|
43
|
+
const heightPx = Math.ceil(inkBottomPx);
|
|
44
|
+
const delta = heightPx - elHeightPx;
|
|
45
|
+
if (!(viewBoxH > 0)) return { grow: true, heightPx, viewBoxH, reason: "grow-no-viewbox" };
|
|
46
|
+
if (!(ctmScale > 0) || !isFinite(ctmScale)) return none("no-scale");
|
|
47
|
+
return { grow: true, heightPx, viewBoxH: viewBoxH + delta / ctmScale, reason: "grow" };
|
|
48
|
+
}
|
|
49
|
+
var AXIS_PIN_MIN_LABELS = 2;
|
|
50
|
+
var AXIS_PIN_BAND_PAD_PX = 2;
|
|
51
|
+
var AXIS_PIN_MAX_BAND_FRACTION = 0.4;
|
|
52
|
+
var AXIS_PIN_TRACK_REACH_PX = 12;
|
|
53
|
+
function isHorizontalLabelRow(boxes) {
|
|
54
|
+
if (!boxes || boxes.length < 2) return false;
|
|
55
|
+
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
|
56
|
+
for (const b of boxes) {
|
|
57
|
+
const cx = (b.left + b.right) / 2, cy = (b.top + b.bottom) / 2;
|
|
58
|
+
if (!isFinite(cx) || !isFinite(cy)) return false;
|
|
59
|
+
if (cx < minX) minX = cx;
|
|
60
|
+
if (cx > maxX) maxX = cx;
|
|
61
|
+
if (cy < minY) minY = cy;
|
|
62
|
+
if (cy > maxY) maxY = cy;
|
|
63
|
+
}
|
|
64
|
+
return maxX - minX > maxY - minY;
|
|
65
|
+
}
|
|
66
|
+
function labelBand(boxes, tracks = [], pad = AXIS_PIN_BAND_PAD_PX, trackReach = AXIS_PIN_TRACK_REACH_PX) {
|
|
67
|
+
if (!boxes || boxes.length === 0) return null;
|
|
68
|
+
let top = Infinity, bottom = -Infinity;
|
|
69
|
+
for (const b of boxes) {
|
|
70
|
+
if (!isFinite(b.top) || !isFinite(b.bottom)) continue;
|
|
71
|
+
if (b.top < top) top = b.top;
|
|
72
|
+
if (b.bottom > bottom) bottom = b.bottom;
|
|
73
|
+
}
|
|
74
|
+
if (!isFinite(top) || !isFinite(bottom) || bottom <= top) return null;
|
|
75
|
+
for (const t of tracks || []) {
|
|
76
|
+
if (!isFinite(t.top) || !isFinite(t.bottom)) continue;
|
|
77
|
+
const gap = t.bottom < top ? top - t.bottom : t.top > bottom ? t.top - bottom : 0;
|
|
78
|
+
if (gap > trackReach) continue;
|
|
79
|
+
if (t.top < top) top = t.top;
|
|
80
|
+
if (t.bottom > bottom) bottom = t.bottom;
|
|
81
|
+
}
|
|
82
|
+
return { top: top - pad, bottom: bottom + pad };
|
|
83
|
+
}
|
|
84
|
+
function planAxisPin(candidates, viewportH, minLabels = AXIS_PIN_MIN_LABELS, maxFraction = AXIS_PIN_MAX_BAND_FRACTION) {
|
|
85
|
+
const none = (reason) => ({ pin: false, index: -1, band: null, reason });
|
|
86
|
+
if (!isFinite(viewportH) || viewportH <= 0) return none("unmeasurable");
|
|
87
|
+
let sawAxis = false, best = null;
|
|
88
|
+
for (const c of candidates || []) {
|
|
89
|
+
if (!c || !c.labels || c.labels.length < minLabels) continue;
|
|
90
|
+
sawAxis = true;
|
|
91
|
+
if (!isHorizontalLabelRow(c.labels)) continue;
|
|
92
|
+
if (!best || c.labels.length > best.labels.length) best = c;
|
|
93
|
+
}
|
|
94
|
+
if (!best) return none(sawAxis ? "vertical-only" : "no-axis");
|
|
95
|
+
const band = labelBand(best.labels, best.tracks || []);
|
|
96
|
+
if (!band) return none("unmeasurable");
|
|
97
|
+
if (band.bottom - band.top > viewportH * maxFraction) return none("band-too-tall");
|
|
98
|
+
return { pin: true, index: best.index, band, reason: "pinned" };
|
|
99
|
+
}
|
|
100
|
+
function axisPinPlacement(band, scrollTop, viewportH) {
|
|
101
|
+
if (!band || !isFinite(band.top) || !isFinite(band.bottom)) return null;
|
|
102
|
+
if (!isFinite(scrollTop) || !isFinite(viewportH) || viewportH <= 0) return null;
|
|
103
|
+
if (band.top < scrollTop) return "top";
|
|
104
|
+
if (band.bottom > scrollTop + viewportH) return "bottom";
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// src/fitDom.ts
|
|
109
|
+
var FIT_WALK_CAP = 8e3;
|
|
110
|
+
function ctmScaleOf(m) {
|
|
111
|
+
if (!m) return 0;
|
|
112
|
+
const s = Math.hypot(m.a, m.b);
|
|
113
|
+
return isFinite(s) && s > 0 ? s : 0;
|
|
114
|
+
}
|
|
115
|
+
function svgInkReach(svg) {
|
|
116
|
+
try {
|
|
117
|
+
const box = svg.getBoundingClientRect();
|
|
118
|
+
if (!(box.width > 0) || !(box.height > 0)) return null;
|
|
119
|
+
const els = svg.querySelectorAll(FIT_CONTENT_SELECTOR);
|
|
120
|
+
const clipOf = clipWindowReader(svg);
|
|
121
|
+
let right = -Infinity, bottom = -Infinity, seen = 0;
|
|
122
|
+
for (let i = 0; i < els.length && seen < FIT_WALK_CAP; i++) {
|
|
123
|
+
const r = els[i].getBoundingClientRect();
|
|
124
|
+
if (r.width <= 0 && r.height <= 0) continue;
|
|
125
|
+
if (isPhantomBox(r.width, r.height, box.width, box.height)) continue;
|
|
126
|
+
let rr = r.right, rb = r.bottom;
|
|
127
|
+
const clip = clipOf(els[i]);
|
|
128
|
+
if (clip) {
|
|
129
|
+
if (r.left >= clip.right || r.top >= clip.bottom) continue;
|
|
130
|
+
rr = Math.min(rr, clip.right);
|
|
131
|
+
rb = Math.min(rb, clip.bottom);
|
|
132
|
+
}
|
|
133
|
+
seen++;
|
|
134
|
+
if (rr > right) right = rr;
|
|
135
|
+
if (rb > bottom) bottom = rb;
|
|
136
|
+
}
|
|
137
|
+
if (seen === 0) return null;
|
|
138
|
+
return { right: right - box.left, bottom: bottom - box.top };
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function clipWindowOf(el) {
|
|
144
|
+
try {
|
|
145
|
+
const m = /url\(\s*['"]?#([^'")\s]+)['"]?\s*\)/.exec(el.getAttribute("clip-path") || "");
|
|
146
|
+
const cp = m ? (el.closest("svg") ?? el.ownerDocument).querySelector(`clipPath[id="${m[1].replace(/"/g, '\\"')}"]`) : null;
|
|
147
|
+
const ctm = cp ? el.getScreenCTM?.() : null;
|
|
148
|
+
if (!cp || !ctm || cp.getAttribute("clipPathUnits") === "objectBoundingBox") return null;
|
|
149
|
+
let l = Infinity, t = Infinity, r = -Infinity, b = -Infinity;
|
|
150
|
+
for (let i = 0; i < cp.children.length; i++) {
|
|
151
|
+
const bb = cp.children[i].getBBox?.();
|
|
152
|
+
if (!bb || !(bb.width > 0) || !(bb.height > 0)) continue;
|
|
153
|
+
for (const [x, y] of [[bb.x, bb.y], [bb.x + bb.width, bb.y], [bb.x, bb.y + bb.height], [bb.x + bb.width, bb.y + bb.height]]) {
|
|
154
|
+
const sx = ctm.a * x + ctm.c * y + ctm.e, sy = ctm.b * x + ctm.d * y + ctm.f;
|
|
155
|
+
l = Math.min(l, sx);
|
|
156
|
+
t = Math.min(t, sy);
|
|
157
|
+
r = Math.max(r, sx);
|
|
158
|
+
b = Math.max(b, sy);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return isFinite(r) && isFinite(b) ? { left: l, top: t, right: r, bottom: b } : null;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function clipWindowReader(svg) {
|
|
167
|
+
const own = /* @__PURE__ */ new Map();
|
|
168
|
+
const windowOf = (el) => {
|
|
169
|
+
if (!own.has(el)) own.set(el, clipWindowOf(el));
|
|
170
|
+
return own.get(el);
|
|
171
|
+
};
|
|
172
|
+
return (el) => {
|
|
173
|
+
let acc = null;
|
|
174
|
+
for (let a = el; a && a !== svg; a = a.parentElement) {
|
|
175
|
+
const w = windowOf(a);
|
|
176
|
+
if (w) acc = acc ? { right: Math.min(acc.right, w.right), bottom: Math.min(acc.bottom, w.bottom) } : w;
|
|
177
|
+
}
|
|
178
|
+
return acc;
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function measureContainerBoxes(c, inkOf) {
|
|
182
|
+
const boxes = [];
|
|
183
|
+
if (!c || !c.children) return boxes;
|
|
184
|
+
const view = c.ownerDocument && c.ownerDocument.defaultView || globalThis;
|
|
185
|
+
const cRect = c.getBoundingClientRect();
|
|
186
|
+
for (let i = 0; i < c.children.length; i++) {
|
|
187
|
+
const el = c.children[i];
|
|
188
|
+
let floating = false;
|
|
189
|
+
try {
|
|
190
|
+
const cs = view.getComputedStyle(el);
|
|
191
|
+
if (cs.display === "none") continue;
|
|
192
|
+
floating = cs.position === "absolute" || cs.position === "fixed";
|
|
193
|
+
} catch {
|
|
194
|
+
}
|
|
195
|
+
const r = el.getBoundingClientRect();
|
|
196
|
+
let right = r.right - cRect.left - c.clientLeft + c.scrollLeft;
|
|
197
|
+
let bottom = r.bottom - cRect.top - c.clientTop + c.scrollTop;
|
|
198
|
+
if (inkOf) {
|
|
199
|
+
try {
|
|
200
|
+
const ink = inkOf(el);
|
|
201
|
+
if (ink) {
|
|
202
|
+
const originX = r.left - cRect.left - c.clientLeft + c.scrollLeft;
|
|
203
|
+
const originY = r.top - cRect.top - c.clientTop + c.scrollTop;
|
|
204
|
+
if (isFinite(ink.right)) right = Math.max(right, originX + ink.right);
|
|
205
|
+
if (isFinite(ink.bottom)) bottom = Math.max(bottom, originY + ink.bottom);
|
|
206
|
+
}
|
|
207
|
+
} catch {
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
boxes.push({ right, bottom, floating });
|
|
211
|
+
}
|
|
212
|
+
return boxes;
|
|
213
|
+
}
|
|
214
|
+
function fitReadingFor(lane, c, slackPx = SCROLL_SLACK_PX, inkOf) {
|
|
215
|
+
if (!c) return null;
|
|
216
|
+
const viewW = c.clientWidth, viewH = c.clientHeight;
|
|
217
|
+
if (!(viewW > 0) || !(viewH > 0)) return null;
|
|
218
|
+
const boxes = measureContainerBoxes(c, inkOf);
|
|
219
|
+
const extent = contentExtentOf(boxes);
|
|
220
|
+
const contentW = extent.source === "none" ? c.scrollWidth : extent.w;
|
|
221
|
+
const contentH = extent.source === "none" ? c.scrollHeight : extent.h;
|
|
222
|
+
if (!isFinite(contentW) || !isFinite(contentH)) return null;
|
|
223
|
+
return {
|
|
224
|
+
lane,
|
|
225
|
+
contentW: Math.round(contentW),
|
|
226
|
+
contentH: Math.round(contentH),
|
|
227
|
+
viewW,
|
|
228
|
+
viewH,
|
|
229
|
+
overflowsX: needsScroll(contentW, viewW, slackPx),
|
|
230
|
+
overflowsY: needsScroll(contentH, viewH, slackPx),
|
|
231
|
+
extentSource: extent.source,
|
|
232
|
+
boxes: boxes.length,
|
|
233
|
+
floating: boxes.filter((b) => b.floating).length,
|
|
234
|
+
scrollW: c.scrollWidth,
|
|
235
|
+
scrollH: c.scrollHeight
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
var noPin = (reason) => ({ pinned: false, reason, bandPx: 0, axisAt: "", labels: 0, carried: 0, edge: null });
|
|
239
|
+
var CONTAINER_SLOT_AXIS_PIN = "__bicAxisPin";
|
|
240
|
+
var SVG_NS = "http://www.w3.org/2000/svg";
|
|
241
|
+
var AXIS_PIN_MAX_CARRIED = 12;
|
|
242
|
+
function unpinScrolledAxis(container) {
|
|
243
|
+
if (!container) return;
|
|
244
|
+
const st = container[CONTAINER_SLOT_AXIS_PIN];
|
|
245
|
+
if (!st) return;
|
|
246
|
+
try {
|
|
247
|
+
container.removeEventListener("scroll", st.onScroll);
|
|
248
|
+
} catch {
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
if (st.overlay.parentNode) st.overlay.parentNode.removeChild(st.overlay);
|
|
252
|
+
} catch {
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
delete container[CONTAINER_SLOT_AXIS_PIN];
|
|
256
|
+
} catch {
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function textHidden(t, view) {
|
|
260
|
+
if (t.getAttribute("display") === "none" || t.getAttribute("visibility") === "hidden") return true;
|
|
261
|
+
try {
|
|
262
|
+
const cs = view.getComputedStyle(t);
|
|
263
|
+
if (cs && (cs.display === "none" || cs.visibility === "hidden")) return true;
|
|
264
|
+
} catch {
|
|
265
|
+
}
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
function backdropFill(svg, container, view, svgBox) {
|
|
269
|
+
try {
|
|
270
|
+
const rects = svg.querySelectorAll("rect");
|
|
271
|
+
for (let i = 0; i < rects.length && i < 6; i++) {
|
|
272
|
+
const r = rects[i];
|
|
273
|
+
if (r.closest(".d3-mark")) continue;
|
|
274
|
+
const b = r.getBoundingClientRect();
|
|
275
|
+
if (b.width >= svgBox.width * 0.9 && b.height >= svgBox.height * 0.9) {
|
|
276
|
+
let fill = "";
|
|
277
|
+
try {
|
|
278
|
+
fill = view.getComputedStyle(r).fill || "";
|
|
279
|
+
} catch {
|
|
280
|
+
}
|
|
281
|
+
if (!fill || fill === "none") fill = r.getAttribute("fill") || "";
|
|
282
|
+
if (fill && fill !== "none" && fill !== "transparent") return fill;
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
let el = container;
|
|
290
|
+
for (let hops = 0; el && hops < 12; hops++, el = el.parentElement) {
|
|
291
|
+
const bg = view.getComputedStyle(el).backgroundColor;
|
|
292
|
+
if (bg && bg !== "transparent" && bg !== "rgba(0, 0, 0, 0)") return bg;
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
}
|
|
296
|
+
return "#ffffff";
|
|
297
|
+
}
|
|
298
|
+
function cloneWithChain(el, svg, root) {
|
|
299
|
+
const chain = [];
|
|
300
|
+
let n = el.parentElement;
|
|
301
|
+
while (n && n !== svg) {
|
|
302
|
+
chain.unshift(n);
|
|
303
|
+
n = n.parentElement;
|
|
304
|
+
}
|
|
305
|
+
let parent = root;
|
|
306
|
+
for (const anc of chain) {
|
|
307
|
+
const c = anc.cloneNode(false);
|
|
308
|
+
parent.appendChild(c);
|
|
309
|
+
parent = c;
|
|
310
|
+
}
|
|
311
|
+
parent.appendChild(el.cloneNode(true));
|
|
312
|
+
const ids = root.querySelectorAll("[id]");
|
|
313
|
+
for (let i = 0; i < ids.length; i++) ids[i].removeAttribute("id");
|
|
314
|
+
}
|
|
315
|
+
function pinScrolledAxis(container, svg) {
|
|
316
|
+
unpinScrolledAxis(container);
|
|
317
|
+
if (!container) return noPin("no-container");
|
|
318
|
+
if (!svg) return noPin("no-svg");
|
|
319
|
+
try {
|
|
320
|
+
const doc = svg.ownerDocument;
|
|
321
|
+
const view = doc && doc.defaultView || globalThis;
|
|
322
|
+
const svgBox = svg.getBoundingClientRect();
|
|
323
|
+
if (!(svgBox.width > 0) || !(svgBox.height > 0)) return noPin("unmeasurable");
|
|
324
|
+
const m = typeof svg.getScreenCTM === "function" ? svg.getScreenCTM() : null;
|
|
325
|
+
if (!m || !(m.a > 0) || !(m.d > 0) || !isFinite(m.e) || !isFinite(m.f)) return noPin("no-ctm");
|
|
326
|
+
const viewportH = container.clientHeight;
|
|
327
|
+
if (!(viewportH > 0)) return noPin("unmeasurable");
|
|
328
|
+
const rel = (r) => ({ left: r.left - svgBox.left, top: r.top - svgBox.top, right: r.right - svgBox.left, bottom: r.bottom - svgBox.top });
|
|
329
|
+
const ticks = svg.querySelectorAll(".tick");
|
|
330
|
+
const byAxis = /* @__PURE__ */ new Map();
|
|
331
|
+
for (let i = 0; i < ticks.length; i++) {
|
|
332
|
+
const t = ticks[i];
|
|
333
|
+
if (t.closest("svg") !== svg) continue;
|
|
334
|
+
const p = t.parentElement;
|
|
335
|
+
if (!p) continue;
|
|
336
|
+
const list = byAxis.get(p);
|
|
337
|
+
if (list) list.push(t);
|
|
338
|
+
else byAxis.set(p, [t]);
|
|
339
|
+
}
|
|
340
|
+
const axes = [];
|
|
341
|
+
const cands = [];
|
|
342
|
+
byAxis.forEach((group, axis2) => {
|
|
343
|
+
const labels = [];
|
|
344
|
+
for (const tick of group) {
|
|
345
|
+
const txt = tick.querySelector("text");
|
|
346
|
+
if (!txt || textHidden(txt, view)) continue;
|
|
347
|
+
const r = txt.getBoundingClientRect();
|
|
348
|
+
if (!(r.width > 0) || !(r.height > 0)) continue;
|
|
349
|
+
labels.push(rel(r));
|
|
350
|
+
}
|
|
351
|
+
const tracks = [];
|
|
352
|
+
for (let i = 0; i < axis2.children.length; i++) {
|
|
353
|
+
const ch = axis2.children[i];
|
|
354
|
+
if (ch.tagName.toLowerCase() !== "path" || !(ch.getAttribute("class") || "").split(/\s+/).includes("domain")) continue;
|
|
355
|
+
const r = ch.getBoundingClientRect();
|
|
356
|
+
if (r.width > 0 && r.height <= 4) tracks.push(rel(r));
|
|
357
|
+
}
|
|
358
|
+
cands.push({ index: axes.length, labels, tracks });
|
|
359
|
+
axes.push(axis2);
|
|
360
|
+
});
|
|
361
|
+
const plan = planAxisPin(cands, viewportH);
|
|
362
|
+
if (!plan.pin || !plan.band) return noPin(plan.reason);
|
|
363
|
+
const axis = axes[plan.index];
|
|
364
|
+
const band = { top: Math.max(0, plan.band.top), bottom: Math.min(svgBox.height, plan.band.bottom) };
|
|
365
|
+
const bandPx = band.bottom - band.top;
|
|
366
|
+
if (!(bandPx > 0)) return noPin("unmeasurable");
|
|
367
|
+
const carried = [];
|
|
368
|
+
const texts = svg.querySelectorAll("text");
|
|
369
|
+
for (let i = 0; i < texts.length && carried.length < AXIS_PIN_MAX_CARRIED; i++) {
|
|
370
|
+
const t = texts[i];
|
|
371
|
+
if (t.closest("svg") !== svg) continue;
|
|
372
|
+
if (t.closest(".tick") || t.closest(".d3-mark") || t.closest(".d3-legend-mark")) continue;
|
|
373
|
+
if (axis.contains(t) || textHidden(t, view)) continue;
|
|
374
|
+
const r = rel(t.getBoundingClientRect());
|
|
375
|
+
if (!(r.right > r.left) || !(r.bottom > r.top)) continue;
|
|
376
|
+
if (r.top >= band.top && r.bottom <= band.bottom) carried.push(t);
|
|
377
|
+
}
|
|
378
|
+
const pinSvg = doc.createElementNS(SVG_NS, "svg");
|
|
379
|
+
for (const a of ["font-family", "font-size", "font-weight", "font", "fill", "color", "text-rendering"]) {
|
|
380
|
+
const v = svg.getAttribute(a);
|
|
381
|
+
if (v) pinSvg.setAttribute(a, v);
|
|
382
|
+
}
|
|
383
|
+
const styles = svg.querySelectorAll("style");
|
|
384
|
+
for (let i = 0; i < styles.length; i++) pinSvg.appendChild(styles[i].cloneNode(true));
|
|
385
|
+
const vbX = (svgBox.left - m.e) / m.a, vbY = (svgBox.top + band.top - m.f) / m.d;
|
|
386
|
+
const vbW = svgBox.width / m.a, vbH = bandPx / m.d;
|
|
387
|
+
pinSvg.setAttribute("viewBox", `${vbX} ${vbY} ${vbW} ${vbH}`);
|
|
388
|
+
pinSvg.setAttribute("preserveAspectRatio", "none");
|
|
389
|
+
pinSvg.setAttribute("width", String(svgBox.width));
|
|
390
|
+
pinSvg.setAttribute("height", String(bandPx));
|
|
391
|
+
pinSvg.setAttribute("aria-hidden", "true");
|
|
392
|
+
pinSvg.style.display = "block";
|
|
393
|
+
pinSvg.style.pointerEvents = "none";
|
|
394
|
+
const back = doc.createElementNS(SVG_NS, "rect");
|
|
395
|
+
back.setAttribute("x", String(vbX));
|
|
396
|
+
back.setAttribute("y", String(vbY));
|
|
397
|
+
back.setAttribute("width", String(vbW));
|
|
398
|
+
back.setAttribute("height", String(vbH));
|
|
399
|
+
back.setAttribute("fill", backdropFill(svg, container, view, svgBox));
|
|
400
|
+
pinSvg.appendChild(back);
|
|
401
|
+
cloneWithChain(axis, svg, pinSvg);
|
|
402
|
+
for (const t of carried) cloneWithChain(t, svg, pinSvg);
|
|
403
|
+
const overlay = doc.createElement("div");
|
|
404
|
+
overlay.setAttribute("data-bic-axis-pin", "1");
|
|
405
|
+
const o = overlay.style;
|
|
406
|
+
o.position = "absolute";
|
|
407
|
+
o.margin = "0";
|
|
408
|
+
o.padding = "0";
|
|
409
|
+
o.border = "0";
|
|
410
|
+
o.width = `${svgBox.width}px`;
|
|
411
|
+
o.height = `${bandPx}px`;
|
|
412
|
+
o.overflow = "hidden";
|
|
413
|
+
o.pointerEvents = "none";
|
|
414
|
+
o.zIndex = "3";
|
|
415
|
+
o.display = "none";
|
|
416
|
+
overlay.appendChild(pinSvg);
|
|
417
|
+
try {
|
|
418
|
+
const cs = view.getComputedStyle(container);
|
|
419
|
+
const pos = cs && cs.position || container.style.position;
|
|
420
|
+
if (!pos || pos === "static") container.style.position = "relative";
|
|
421
|
+
} catch {
|
|
422
|
+
container.style.position = container.style.position || "relative";
|
|
423
|
+
}
|
|
424
|
+
const cRect = container.getBoundingClientRect();
|
|
425
|
+
const svgLeft = svgBox.left - cRect.left - container.clientLeft + container.scrollLeft;
|
|
426
|
+
const svgTop = svgBox.top - cRect.top - container.clientTop + container.scrollTop;
|
|
427
|
+
const contentBand = { top: svgTop + band.top, bottom: svgTop + band.bottom };
|
|
428
|
+
const place = () => {
|
|
429
|
+
const st = container.scrollTop, vh = container.clientHeight;
|
|
430
|
+
const edge2 = axisPinPlacement(contentBand, st, vh);
|
|
431
|
+
if (!edge2) {
|
|
432
|
+
o.display = "none";
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
o.display = "block";
|
|
436
|
+
o.left = `${svgLeft}px`;
|
|
437
|
+
o.top = `${edge2 === "top" ? st : st + vh - bandPx}px`;
|
|
438
|
+
return edge2;
|
|
439
|
+
};
|
|
440
|
+
const onScroll = () => {
|
|
441
|
+
try {
|
|
442
|
+
if (!overlay.isConnected) {
|
|
443
|
+
unpinScrolledAxis(container);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
place();
|
|
447
|
+
} catch {
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
container.appendChild(overlay);
|
|
451
|
+
container.addEventListener("scroll", onScroll, { passive: true });
|
|
452
|
+
container[CONTAINER_SLOT_AXIS_PIN] = { overlay, onScroll };
|
|
453
|
+
const edge = place();
|
|
454
|
+
const mid = (band.top + band.bottom) / 2 / svgBox.height;
|
|
455
|
+
return {
|
|
456
|
+
pinned: true,
|
|
457
|
+
reason: "pinned",
|
|
458
|
+
bandPx: Math.round(bandPx),
|
|
459
|
+
axisAt: mid < 1 / 3 ? "top" : mid > 2 / 3 ? "bottom" : "middle",
|
|
460
|
+
labels: cands[plan.index].labels.length,
|
|
461
|
+
carried: carried.length,
|
|
462
|
+
edge
|
|
463
|
+
};
|
|
464
|
+
} catch {
|
|
465
|
+
unpinScrolledAxis(container);
|
|
466
|
+
return noPin("threw");
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
function fitRenderedChart(container, opts = {}) {
|
|
470
|
+
const slackPx = opts.slackPx ?? SCROLL_SLACK_PX;
|
|
471
|
+
const result = {
|
|
472
|
+
grew: false,
|
|
473
|
+
growFrom: 0,
|
|
474
|
+
growTo: 0,
|
|
475
|
+
growReason: "no-container",
|
|
476
|
+
overflowX: "hidden",
|
|
477
|
+
overflowY: "hidden",
|
|
478
|
+
reading: null,
|
|
479
|
+
axisPin: noPin("no-container")
|
|
480
|
+
};
|
|
481
|
+
if (!container) return result;
|
|
482
|
+
try {
|
|
483
|
+
let svg = container.querySelector(`svg[${SNAPSHOT_SVG_ATTR}]`);
|
|
484
|
+
for (let i = 0; i < container.children.length && !svg; i++) {
|
|
485
|
+
const el = container.children[i];
|
|
486
|
+
if (el.tagName && el.tagName.toLowerCase() === "svg") svg = el;
|
|
487
|
+
}
|
|
488
|
+
if (!svg) svg = container.querySelector("svg");
|
|
489
|
+
let inkCache;
|
|
490
|
+
const svgInk = (el) => {
|
|
491
|
+
if (inkCache === void 0) inkCache = svgInkReach(el);
|
|
492
|
+
return inkCache;
|
|
493
|
+
};
|
|
494
|
+
const inkOf = (el) => el && el.tagName && el.tagName.toLowerCase() === "svg" ? svgInk(el) : null;
|
|
495
|
+
if (svg && opts.grow !== false) {
|
|
496
|
+
try {
|
|
497
|
+
const ink = svgInk(svg);
|
|
498
|
+
const elH = svg.getBoundingClientRect().height;
|
|
499
|
+
const vb = svg.viewBox && svg.viewBox.baseVal ? svg.viewBox.baseVal.height : 0;
|
|
500
|
+
const ctm = typeof svg.getScreenCTM === "function" ? svg.getScreenCTM() : null;
|
|
501
|
+
const plan = planFrameGrow(
|
|
502
|
+
ink ? ink.bottom : NaN,
|
|
503
|
+
elH,
|
|
504
|
+
vb,
|
|
505
|
+
ctmScaleOf(ctm),
|
|
506
|
+
slackPx
|
|
507
|
+
);
|
|
508
|
+
result.growFrom = Math.round(elH);
|
|
509
|
+
result.growTo = plan.heightPx;
|
|
510
|
+
result.growReason = plan.reason;
|
|
511
|
+
if (plan.grow) {
|
|
512
|
+
svg.setAttribute("height", String(plan.heightPx));
|
|
513
|
+
if (vb > 0) {
|
|
514
|
+
const b = svg.viewBox.baseVal;
|
|
515
|
+
svg.setAttribute("viewBox", `${b.x} ${b.y} ${b.width} ${plan.viewBoxH}`);
|
|
516
|
+
}
|
|
517
|
+
result.grew = true;
|
|
518
|
+
}
|
|
519
|
+
} catch {
|
|
520
|
+
result.growReason = "grow-threw";
|
|
521
|
+
}
|
|
522
|
+
} else if (!svg) {
|
|
523
|
+
result.growReason = "no-svg";
|
|
524
|
+
} else {
|
|
525
|
+
result.growReason = "grow-disabled";
|
|
526
|
+
}
|
|
527
|
+
const reading = fitReadingFor(opts.lane ?? "chart", container, slackPx, inkOf);
|
|
528
|
+
result.reading = reading;
|
|
529
|
+
const boxes = measureContainerBoxes(container, inkOf);
|
|
530
|
+
const extent = contentExtentOf(boxes);
|
|
531
|
+
const svgBox = svg ? svg.getBoundingClientRect() : null;
|
|
532
|
+
const contentW = extent.source === "none" ? Math.max(container.scrollWidth, svgBox ? svgBox.width : 0) : extent.w;
|
|
533
|
+
const contentH = extent.source === "none" ? Math.max(container.scrollHeight, svgBox ? svgBox.height : 0) : extent.h;
|
|
534
|
+
const fit = scrollFitFor(contentW, contentH, container.clientWidth, container.clientHeight, slackPx);
|
|
535
|
+
result.overflowX = fit.overflowX;
|
|
536
|
+
result.overflowY = fit.overflowY;
|
|
537
|
+
if (opts.applyOverflow !== false) {
|
|
538
|
+
container.style.overflowX = fit.overflowX;
|
|
539
|
+
container.style.overflowY = fit.overflowY;
|
|
540
|
+
if (fit.overflowY !== "auto") {
|
|
541
|
+
unpinScrolledAxis(container);
|
|
542
|
+
result.axisPin = noPin("not-scrolling");
|
|
543
|
+
} else if (opts.pinAxis === false) {
|
|
544
|
+
unpinScrolledAxis(container);
|
|
545
|
+
result.axisPin = noPin("disabled");
|
|
546
|
+
} else result.axisPin = pinScrolledAxis(container, svg);
|
|
547
|
+
} else {
|
|
548
|
+
unpinScrolledAxis(container);
|
|
549
|
+
result.axisPin = noPin("overflow-not-applied");
|
|
550
|
+
}
|
|
551
|
+
} catch {
|
|
552
|
+
}
|
|
553
|
+
return result;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export {
|
|
557
|
+
SCROLL_SLACK_PX,
|
|
558
|
+
needsScroll,
|
|
559
|
+
contentExtentOf,
|
|
560
|
+
scrollFitFor,
|
|
561
|
+
FIT_CONTENT_SELECTOR,
|
|
562
|
+
PHANTOM_FRACTION,
|
|
563
|
+
isPhantomBox,
|
|
564
|
+
MAX_FRAME_GROW_FACTOR,
|
|
565
|
+
planFrameGrow,
|
|
566
|
+
AXIS_PIN_MIN_LABELS,
|
|
567
|
+
AXIS_PIN_BAND_PAD_PX,
|
|
568
|
+
AXIS_PIN_MAX_BAND_FRACTION,
|
|
569
|
+
AXIS_PIN_TRACK_REACH_PX,
|
|
570
|
+
isHorizontalLabelRow,
|
|
571
|
+
labelBand,
|
|
572
|
+
planAxisPin,
|
|
573
|
+
axisPinPlacement,
|
|
574
|
+
ctmScaleOf,
|
|
575
|
+
svgInkReach,
|
|
576
|
+
clipWindowOf,
|
|
577
|
+
measureContainerBoxes,
|
|
578
|
+
fitReadingFor,
|
|
579
|
+
unpinScrolledAxis,
|
|
580
|
+
pinScrolledAxis,
|
|
581
|
+
fitRenderedChart
|
|
582
|
+
};
|