@bicharts/chart-host 0.5.76 → 0.5.78

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.
@@ -1813,6 +1813,64 @@ function planFrameGrow(inkBottomPx, elHeightPx, viewBoxH, ctmScale, slackPx = SC
1813
1813
  if (!(ctmScale > 0) || !isFinite(ctmScale)) return none("no-scale");
1814
1814
  return { grow: true, heightPx, viewBoxH: viewBoxH + delta / ctmScale, reason: "grow" };
1815
1815
  }
1816
+ var AXIS_PIN_MIN_LABELS = 2;
1817
+ var AXIS_PIN_BAND_PAD_PX = 2;
1818
+ var AXIS_PIN_MAX_BAND_FRACTION = 0.4;
1819
+ var AXIS_PIN_TRACK_REACH_PX = 12;
1820
+ function isHorizontalLabelRow(boxes) {
1821
+ if (!boxes || boxes.length < 2) return false;
1822
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
1823
+ for (const b2 of boxes) {
1824
+ const cx = (b2.left + b2.right) / 2, cy = (b2.top + b2.bottom) / 2;
1825
+ if (!isFinite(cx) || !isFinite(cy)) return false;
1826
+ if (cx < minX) minX = cx;
1827
+ if (cx > maxX) maxX = cx;
1828
+ if (cy < minY) minY = cy;
1829
+ if (cy > maxY) maxY = cy;
1830
+ }
1831
+ return maxX - minX > maxY - minY;
1832
+ }
1833
+ function labelBand(boxes, tracks = [], pad = AXIS_PIN_BAND_PAD_PX, trackReach = AXIS_PIN_TRACK_REACH_PX) {
1834
+ if (!boxes || boxes.length === 0) return null;
1835
+ let top = Infinity, bottom = -Infinity;
1836
+ for (const b2 of boxes) {
1837
+ if (!isFinite(b2.top) || !isFinite(b2.bottom)) continue;
1838
+ if (b2.top < top) top = b2.top;
1839
+ if (b2.bottom > bottom) bottom = b2.bottom;
1840
+ }
1841
+ if (!isFinite(top) || !isFinite(bottom) || bottom <= top) return null;
1842
+ for (const t of tracks || []) {
1843
+ if (!isFinite(t.top) || !isFinite(t.bottom)) continue;
1844
+ const gap = t.bottom < top ? top - t.bottom : t.top > bottom ? t.top - bottom : 0;
1845
+ if (gap > trackReach) continue;
1846
+ if (t.top < top) top = t.top;
1847
+ if (t.bottom > bottom) bottom = t.bottom;
1848
+ }
1849
+ return { top: top - pad, bottom: bottom + pad };
1850
+ }
1851
+ function planAxisPin(candidates, viewportH, minLabels = AXIS_PIN_MIN_LABELS, maxFraction = AXIS_PIN_MAX_BAND_FRACTION) {
1852
+ const none = (reason) => ({ pin: false, index: -1, band: null, reason });
1853
+ if (!isFinite(viewportH) || viewportH <= 0) return none("unmeasurable");
1854
+ let sawAxis = false, best = null;
1855
+ for (const c of candidates || []) {
1856
+ if (!c || !c.labels || c.labels.length < minLabels) continue;
1857
+ sawAxis = true;
1858
+ if (!isHorizontalLabelRow(c.labels)) continue;
1859
+ if (!best || c.labels.length > best.labels.length) best = c;
1860
+ }
1861
+ if (!best) return none(sawAxis ? "vertical-only" : "no-axis");
1862
+ const band = labelBand(best.labels, best.tracks || []);
1863
+ if (!band) return none("unmeasurable");
1864
+ if (band.bottom - band.top > viewportH * maxFraction) return none("band-too-tall");
1865
+ return { pin: true, index: best.index, band, reason: "pinned" };
1866
+ }
1867
+ function axisPinPlacement(band, scrollTop, viewportH) {
1868
+ if (!band || !isFinite(band.top) || !isFinite(band.bottom)) return null;
1869
+ if (!isFinite(scrollTop) || !isFinite(viewportH) || viewportH <= 0) return null;
1870
+ if (band.top < scrollTop) return "top";
1871
+ if (band.bottom > scrollTop + viewportH) return "bottom";
1872
+ return null;
1873
+ }
1816
1874
 
1817
1875
  // src/fitDom.ts
1818
1876
  var FIT_WALK_CAP = 8e3;
@@ -1898,6 +1956,237 @@ function fitReadingFor(lane, c, slackPx = SCROLL_SLACK_PX, inkOf) {
1898
1956
  scrollH: c.scrollHeight
1899
1957
  };
1900
1958
  }
1959
+ var noPin = (reason) => ({ pinned: false, reason, bandPx: 0, axisAt: "", labels: 0, carried: 0, edge: null });
1960
+ var CONTAINER_SLOT_AXIS_PIN = "__bicAxisPin";
1961
+ var SVG_NS2 = "http://www.w3.org/2000/svg";
1962
+ var AXIS_PIN_MAX_CARRIED = 12;
1963
+ function unpinScrolledAxis(container) {
1964
+ if (!container) return;
1965
+ const st = container[CONTAINER_SLOT_AXIS_PIN];
1966
+ if (!st) return;
1967
+ try {
1968
+ container.removeEventListener("scroll", st.onScroll);
1969
+ } catch {
1970
+ }
1971
+ try {
1972
+ if (st.overlay.parentNode) st.overlay.parentNode.removeChild(st.overlay);
1973
+ } catch {
1974
+ }
1975
+ try {
1976
+ delete container[CONTAINER_SLOT_AXIS_PIN];
1977
+ } catch {
1978
+ }
1979
+ }
1980
+ function textHidden(t, view) {
1981
+ if (t.getAttribute("display") === "none" || t.getAttribute("visibility") === "hidden") return true;
1982
+ try {
1983
+ const cs = view.getComputedStyle(t);
1984
+ if (cs && (cs.display === "none" || cs.visibility === "hidden")) return true;
1985
+ } catch {
1986
+ }
1987
+ return false;
1988
+ }
1989
+ function backdropFill(svg, container, view, svgBox) {
1990
+ try {
1991
+ const rects = svg.querySelectorAll("rect");
1992
+ for (let i2 = 0; i2 < rects.length && i2 < 6; i2++) {
1993
+ const r = rects[i2];
1994
+ if (r.closest(".d3-mark")) continue;
1995
+ const b2 = r.getBoundingClientRect();
1996
+ if (b2.width >= svgBox.width * 0.9 && b2.height >= svgBox.height * 0.9) {
1997
+ let fill = "";
1998
+ try {
1999
+ fill = view.getComputedStyle(r).fill || "";
2000
+ } catch {
2001
+ }
2002
+ if (!fill || fill === "none") fill = r.getAttribute("fill") || "";
2003
+ if (fill && fill !== "none" && fill !== "transparent") return fill;
2004
+ break;
2005
+ }
2006
+ }
2007
+ } catch {
2008
+ }
2009
+ try {
2010
+ let el = container;
2011
+ for (let hops = 0; el && hops < 12; hops++, el = el.parentElement) {
2012
+ const bg = view.getComputedStyle(el).backgroundColor;
2013
+ if (bg && bg !== "transparent" && bg !== "rgba(0, 0, 0, 0)") return bg;
2014
+ }
2015
+ } catch {
2016
+ }
2017
+ return "#ffffff";
2018
+ }
2019
+ function cloneWithChain(el, svg, root) {
2020
+ const chain = [];
2021
+ let n = el.parentElement;
2022
+ while (n && n !== svg) {
2023
+ chain.unshift(n);
2024
+ n = n.parentElement;
2025
+ }
2026
+ let parent = root;
2027
+ for (const anc of chain) {
2028
+ const c = anc.cloneNode(false);
2029
+ parent.appendChild(c);
2030
+ parent = c;
2031
+ }
2032
+ parent.appendChild(el.cloneNode(true));
2033
+ const ids = root.querySelectorAll("[id]");
2034
+ for (let i2 = 0; i2 < ids.length; i2++) ids[i2].removeAttribute("id");
2035
+ }
2036
+ function pinScrolledAxis(container, svg) {
2037
+ unpinScrolledAxis(container);
2038
+ if (!container) return noPin("no-container");
2039
+ if (!svg) return noPin("no-svg");
2040
+ try {
2041
+ const doc = svg.ownerDocument;
2042
+ const view = doc && doc.defaultView || globalThis;
2043
+ const svgBox = svg.getBoundingClientRect();
2044
+ if (!(svgBox.width > 0) || !(svgBox.height > 0)) return noPin("unmeasurable");
2045
+ const m2 = typeof svg.getScreenCTM === "function" ? svg.getScreenCTM() : null;
2046
+ if (!m2 || !(m2.a > 0) || !(m2.d > 0) || !isFinite(m2.e) || !isFinite(m2.f)) return noPin("no-ctm");
2047
+ const viewportH = container.clientHeight;
2048
+ if (!(viewportH > 0)) return noPin("unmeasurable");
2049
+ const rel = (r) => ({ left: r.left - svgBox.left, top: r.top - svgBox.top, right: r.right - svgBox.left, bottom: r.bottom - svgBox.top });
2050
+ const ticks = svg.querySelectorAll(".tick");
2051
+ const byAxis = /* @__PURE__ */ new Map();
2052
+ for (let i2 = 0; i2 < ticks.length; i2++) {
2053
+ const t = ticks[i2];
2054
+ if (t.closest("svg") !== svg) continue;
2055
+ const p2 = t.parentElement;
2056
+ if (!p2) continue;
2057
+ const list = byAxis.get(p2);
2058
+ if (list) list.push(t);
2059
+ else byAxis.set(p2, [t]);
2060
+ }
2061
+ const axes = [];
2062
+ const cands = [];
2063
+ byAxis.forEach((group, axis2) => {
2064
+ const labels = [];
2065
+ for (const tick of group) {
2066
+ const txt = tick.querySelector("text");
2067
+ if (!txt || textHidden(txt, view)) continue;
2068
+ const r = txt.getBoundingClientRect();
2069
+ if (!(r.width > 0) || !(r.height > 0)) continue;
2070
+ labels.push(rel(r));
2071
+ }
2072
+ const tracks = [];
2073
+ for (let i2 = 0; i2 < axis2.children.length; i2++) {
2074
+ const ch = axis2.children[i2];
2075
+ if (ch.tagName.toLowerCase() !== "path" || !(ch.getAttribute("class") || "").split(/\s+/).includes("domain")) continue;
2076
+ const r = ch.getBoundingClientRect();
2077
+ if (r.width > 0 && r.height <= 4) tracks.push(rel(r));
2078
+ }
2079
+ cands.push({ index: axes.length, labels, tracks });
2080
+ axes.push(axis2);
2081
+ });
2082
+ const plan = planAxisPin(cands, viewportH);
2083
+ if (!plan.pin || !plan.band) return noPin(plan.reason);
2084
+ const axis = axes[plan.index];
2085
+ const band = { top: Math.max(0, plan.band.top), bottom: Math.min(svgBox.height, plan.band.bottom) };
2086
+ const bandPx = band.bottom - band.top;
2087
+ if (!(bandPx > 0)) return noPin("unmeasurable");
2088
+ const carried = [];
2089
+ const texts = svg.querySelectorAll("text");
2090
+ for (let i2 = 0; i2 < texts.length && carried.length < AXIS_PIN_MAX_CARRIED; i2++) {
2091
+ const t = texts[i2];
2092
+ if (t.closest("svg") !== svg) continue;
2093
+ if (t.closest(".tick") || t.closest(".d3-mark") || t.closest(".d3-legend-mark")) continue;
2094
+ if (axis.contains(t) || textHidden(t, view)) continue;
2095
+ const r = rel(t.getBoundingClientRect());
2096
+ if (!(r.right > r.left) || !(r.bottom > r.top)) continue;
2097
+ if (r.top >= band.top && r.bottom <= band.bottom) carried.push(t);
2098
+ }
2099
+ const pinSvg = doc.createElementNS(SVG_NS2, "svg");
2100
+ for (const a2 of ["font-family", "font-size", "font-weight", "font", "fill", "color", "text-rendering"]) {
2101
+ const v2 = svg.getAttribute(a2);
2102
+ if (v2) pinSvg.setAttribute(a2, v2);
2103
+ }
2104
+ const styles = svg.querySelectorAll("style");
2105
+ for (let i2 = 0; i2 < styles.length; i2++) pinSvg.appendChild(styles[i2].cloneNode(true));
2106
+ const vbX = (svgBox.left - m2.e) / m2.a, vbY = (svgBox.top + band.top - m2.f) / m2.d;
2107
+ const vbW = svgBox.width / m2.a, vbH = bandPx / m2.d;
2108
+ pinSvg.setAttribute("viewBox", `${vbX} ${vbY} ${vbW} ${vbH}`);
2109
+ pinSvg.setAttribute("preserveAspectRatio", "none");
2110
+ pinSvg.setAttribute("width", String(svgBox.width));
2111
+ pinSvg.setAttribute("height", String(bandPx));
2112
+ pinSvg.setAttribute("aria-hidden", "true");
2113
+ pinSvg.style.display = "block";
2114
+ pinSvg.style.pointerEvents = "none";
2115
+ const back = doc.createElementNS(SVG_NS2, "rect");
2116
+ back.setAttribute("x", String(vbX));
2117
+ back.setAttribute("y", String(vbY));
2118
+ back.setAttribute("width", String(vbW));
2119
+ back.setAttribute("height", String(vbH));
2120
+ back.setAttribute("fill", backdropFill(svg, container, view, svgBox));
2121
+ pinSvg.appendChild(back);
2122
+ cloneWithChain(axis, svg, pinSvg);
2123
+ for (const t of carried) cloneWithChain(t, svg, pinSvg);
2124
+ const overlay = doc.createElement("div");
2125
+ overlay.setAttribute("data-bic-axis-pin", "1");
2126
+ const o2 = overlay.style;
2127
+ o2.position = "absolute";
2128
+ o2.margin = "0";
2129
+ o2.padding = "0";
2130
+ o2.border = "0";
2131
+ o2.width = `${svgBox.width}px`;
2132
+ o2.height = `${bandPx}px`;
2133
+ o2.overflow = "hidden";
2134
+ o2.pointerEvents = "none";
2135
+ o2.zIndex = "3";
2136
+ o2.display = "none";
2137
+ overlay.appendChild(pinSvg);
2138
+ try {
2139
+ const cs = view.getComputedStyle(container);
2140
+ const pos = cs && cs.position || container.style.position;
2141
+ if (!pos || pos === "static") container.style.position = "relative";
2142
+ } catch {
2143
+ container.style.position = container.style.position || "relative";
2144
+ }
2145
+ const cRect = container.getBoundingClientRect();
2146
+ const svgLeft = svgBox.left - cRect.left - container.clientLeft + container.scrollLeft;
2147
+ const svgTop = svgBox.top - cRect.top - container.clientTop + container.scrollTop;
2148
+ const contentBand = { top: svgTop + band.top, bottom: svgTop + band.bottom };
2149
+ const place = () => {
2150
+ const st = container.scrollTop, vh = container.clientHeight;
2151
+ const edge2 = axisPinPlacement(contentBand, st, vh);
2152
+ if (!edge2) {
2153
+ o2.display = "none";
2154
+ return null;
2155
+ }
2156
+ o2.display = "block";
2157
+ o2.left = `${svgLeft}px`;
2158
+ o2.top = `${edge2 === "top" ? st : st + vh - bandPx}px`;
2159
+ return edge2;
2160
+ };
2161
+ const onScroll = () => {
2162
+ try {
2163
+ if (!overlay.isConnected) {
2164
+ unpinScrolledAxis(container);
2165
+ return;
2166
+ }
2167
+ place();
2168
+ } catch {
2169
+ }
2170
+ };
2171
+ container.appendChild(overlay);
2172
+ container.addEventListener("scroll", onScroll, { passive: true });
2173
+ container[CONTAINER_SLOT_AXIS_PIN] = { overlay, onScroll };
2174
+ const edge = place();
2175
+ const mid = (band.top + band.bottom) / 2 / svgBox.height;
2176
+ return {
2177
+ pinned: true,
2178
+ reason: "pinned",
2179
+ bandPx: Math.round(bandPx),
2180
+ axisAt: mid < 1 / 3 ? "top" : mid > 2 / 3 ? "bottom" : "middle",
2181
+ labels: cands[plan.index].labels.length,
2182
+ carried: carried.length,
2183
+ edge
2184
+ };
2185
+ } catch {
2186
+ unpinScrolledAxis(container);
2187
+ return noPin("threw");
2188
+ }
2189
+ }
1901
2190
  function fitRenderedChart(container, opts = {}) {
1902
2191
  const slackPx = opts.slackPx ?? SCROLL_SLACK_PX;
1903
2192
  const result = {
@@ -1907,7 +2196,8 @@ function fitRenderedChart(container, opts = {}) {
1907
2196
  growReason: "no-container",
1908
2197
  overflowX: "hidden",
1909
2198
  overflowY: "hidden",
1910
- reading: null
2199
+ reading: null,
2200
+ axisPin: noPin("no-container")
1911
2201
  };
1912
2202
  if (!container) return result;
1913
2203
  try {
@@ -1968,6 +2258,16 @@ function fitRenderedChart(container, opts = {}) {
1968
2258
  if (opts.applyOverflow !== false) {
1969
2259
  container.style.overflowX = fit.overflowX;
1970
2260
  container.style.overflowY = fit.overflowY;
2261
+ if (fit.overflowY !== "auto") {
2262
+ unpinScrolledAxis(container);
2263
+ result.axisPin = noPin("not-scrolling");
2264
+ } else if (opts.pinAxis === false) {
2265
+ unpinScrolledAxis(container);
2266
+ result.axisPin = noPin("disabled");
2267
+ } else result.axisPin = pinScrolledAxis(container, svg);
2268
+ } else {
2269
+ unpinScrolledAxis(container);
2270
+ result.axisPin = noPin("overflow-not-applied");
1971
2271
  }
1972
2272
  } catch {
1973
2273
  }
@@ -2631,6 +2931,7 @@ function createChartHost(container, config) {
2631
2931
  container.removeEventListener(XFILTER_REFRESH_EVENT, onXf);
2632
2932
  container.removeEventListener("click", onClick);
2633
2933
  subs.clear();
2934
+ unpinScrolledAxis(container);
2634
2935
  container.innerHTML = "";
2635
2936
  }
2636
2937
  };
@@ -2703,10 +3004,20 @@ export {
2703
3004
  isPhantomBox,
2704
3005
  MAX_FRAME_GROW_FACTOR,
2705
3006
  planFrameGrow,
3007
+ AXIS_PIN_MIN_LABELS,
3008
+ AXIS_PIN_BAND_PAD_PX,
3009
+ AXIS_PIN_MAX_BAND_FRACTION,
3010
+ AXIS_PIN_TRACK_REACH_PX,
3011
+ isHorizontalLabelRow,
3012
+ labelBand,
3013
+ planAxisPin,
3014
+ axisPinPlacement,
2706
3015
  ctmScaleOf,
2707
3016
  svgInkReach,
2708
3017
  measureContainerBoxes,
2709
3018
  fitReadingFor,
3019
+ unpinScrolledAxis,
3020
+ pinScrolledAxis,
2710
3021
  fitRenderedChart,
2711
3022
  DARK_TEXT,
2712
3023
  LIGHT_TEXT,
package/dist/index.mjs CHANGED
@@ -11,6 +11,10 @@ import {
11
11
  ANIM_PLAY_SPEED_MIN,
12
12
  APPROXIMATE_POSITIONS_DEFAULT,
13
13
  AXIS_FILTER_CLASS,
14
+ AXIS_PIN_BAND_PAD_PX,
15
+ AXIS_PIN_MAX_BAND_FRACTION,
16
+ AXIS_PIN_MIN_LABELS,
17
+ AXIS_PIN_TRACK_REACH_PX,
14
18
  BACKING_MAJORITY,
15
19
  COLOR_SCALE_SELF_CLAMP_PCT_DEFAULT,
16
20
  COLOR_SCALE_SELF_CLAMP_PCT_MAX,
@@ -56,6 +60,7 @@ import {
56
60
  XFILTER_REFRESH_EVENT,
57
61
  _e,
58
62
  applyLabelContrast,
63
+ axisPinPlacement,
59
64
  backingHoldsGlyph,
60
65
  blankRenderFlag,
61
66
  buildRenderPayload,
@@ -81,14 +86,18 @@ import {
81
86
  glyphSampleGrid,
82
87
  hitBandFlag,
83
88
  isBlankRender,
89
+ isHorizontalLabelRow,
84
90
  isPhantomBox,
85
91
  isPillBackdropAlpha,
92
+ labelBand,
86
93
  loadGeo,
87
94
  measureContainerBoxes,
88
95
  needsScroll,
89
96
  noopViewStateProvider,
90
97
  periodTickSuppressesFeedback,
91
98
  pillBacksGlyph,
99
+ pinScrolledAxis,
100
+ planAxisPin,
92
101
  planFrameGrow,
93
102
  registerGeo,
94
103
  registerGeoAsset,
@@ -99,8 +108,9 @@ import {
99
108
  sessionViewStateProvider,
100
109
  stripEsmExports,
101
110
  svgInkReach,
102
- toRGBA
103
- } from "./chunk-N6XJDJRN.mjs";
111
+ toRGBA,
112
+ unpinScrolledAxis
113
+ } from "./chunk-WI2R6E6U.mjs";
104
114
  import "./chunk-A2GMXZP7.mjs";
105
115
  import "./chunk-GHODWOAL.mjs";
106
116
 
@@ -795,7 +805,9 @@ function computeQualifyFilterView(groups, term) {
795
805
  };
796
806
  }
797
807
  function qualifyFilterCountText(view) {
798
- return view.tier === "all" ? String(view.total) : `${view.matched} of ${view.total}`;
808
+ if (view.tier === "all") return String(view.total);
809
+ const below = typeof view.refusalMatched === "number" && view.refusalMatched > 0 ? ` (+${view.refusalMatched})` : "";
810
+ return `${view.matched} of ${view.total}${below}`;
799
811
  }
800
812
  function inlineFilterGate(rowCount) {
801
813
  return rowCount >= INLINE_FILTER_MIN_ROWS ? { show: true, why: "shown" } : { show: false, why: "no-overflow" };
@@ -1033,6 +1045,10 @@ export {
1033
1045
  ANIM_PLAY_SPEED_MIN,
1034
1046
  APPROXIMATE_POSITIONS_DEFAULT,
1035
1047
  AXIS_FILTER_CLASS,
1048
+ AXIS_PIN_BAND_PAD_PX,
1049
+ AXIS_PIN_MAX_BAND_FRACTION,
1050
+ AXIS_PIN_MIN_LABELS,
1051
+ AXIS_PIN_TRACK_REACH_PX,
1036
1052
  BACKING_MAJORITY,
1037
1053
  CHOOSER_MIN_HEIGHT_PX,
1038
1054
  CHOOSER_MIN_WIDTH_PX,
@@ -1082,6 +1098,7 @@ export {
1082
1098
  actionFor,
1083
1099
  applyLabelContrast,
1084
1100
  askApplyImprovements,
1101
+ axisPinPlacement,
1085
1102
  backingHoldsGlyph,
1086
1103
  bareBase64,
1087
1104
  blankRenderFlag,
@@ -1120,8 +1137,10 @@ export {
1120
1137
  hitBandFlag,
1121
1138
  inlineFilterGate,
1122
1139
  isBlankRender,
1140
+ isHorizontalLabelRow,
1123
1141
  isPhantomBox,
1124
1142
  isPillBackdropAlpha,
1143
+ labelBand,
1125
1144
  launchFavorStyle,
1126
1145
  launchGenerates,
1127
1146
  listNeedsFilter,
@@ -1136,6 +1155,8 @@ export {
1136
1155
  orderRefusalsForDisplay,
1137
1156
  periodTickSuppressesFeedback,
1138
1157
  pillBacksGlyph,
1158
+ pinScrolledAxis,
1159
+ planAxisPin,
1139
1160
  planFrameGrow,
1140
1161
  planTrivialChart,
1141
1162
  qualifyAuto,
@@ -1165,5 +1186,6 @@ export {
1165
1186
  svgInkReach,
1166
1187
  svgNaturalSize,
1167
1188
  svgToDataUrl,
1168
- toRGBA
1189
+ toRGBA,
1190
+ unpinScrolledAxis
1169
1191
  };
package/dist/react.mjs CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  createChartHost,
5
5
  geoFromCache,
6
6
  loadGeo
7
- } from "./chunk-N6XJDJRN.mjs";
7
+ } from "./chunk-WI2R6E6U.mjs";
8
8
  import "./chunk-A2GMXZP7.mjs";
9
9
  import "./chunk-GHODWOAL.mjs";
10
10
 
@@ -34,3 +34,59 @@ export interface FrameGrowPlan {
34
34
  * @param ctmScale px per user unit, from the live screen CTM
35
35
  */
36
36
  export declare function planFrameGrow(inkBottomPx: number, elHeightPx: number, viewBoxH: number, ctmScale: number, slackPx?: number, maxFactor?: number): FrameGrowPlan;
37
+ export declare const AXIS_PIN_MIN_LABELS = 2;
38
+ export declare const AXIS_PIN_BAND_PAD_PX = 2;
39
+ export declare const AXIS_PIN_MAX_BAND_FRACTION = 0.4;
40
+ export declare const AXIS_PIN_TRACK_REACH_PX = 12;
41
+ export interface LabelRowBox {
42
+ left: number;
43
+ top: number;
44
+ right: number;
45
+ bottom: number;
46
+ }
47
+ /**
48
+ * Does a row of label boxes run ACROSS the chart rather than DOWN it? A horizontal axis spreads
49
+ * its label centres over x; a y-axis spreads them over y, and pinning a y-axis would pin the
50
+ * ROWS - the exact thing the scroll exists to reveal. Ties are vertical: a single label has no
51
+ * direction and is not an axis.
52
+ */
53
+ export declare function isHorizontalLabelRow(boxes: LabelRowBox[]): boolean;
54
+ export interface AxisBand {
55
+ top: number;
56
+ bottom: number;
57
+ }
58
+ /**
59
+ * The vertical band a label row occupies, padded, widened to take in a thin track (the domain
60
+ * line) that sits within reach of the labels. Tracks further away are ignored on purpose: a
61
+ * gridline drawn as a full-height tick line is part of the plot, not of the header.
62
+ */
63
+ export declare function labelBand(boxes: LabelRowBox[], tracks?: LabelRowBox[], pad?: number, trackReach?: number): AxisBand | null;
64
+ export interface AxisPinCandidate {
65
+ /** The caller's handle for this axis - returned in the plan. */
66
+ index: number;
67
+ /** One box per VISIBLE tick label, in any one consistent coordinate space. */
68
+ labels: LabelRowBox[];
69
+ /** Thin tracks that may belong to the band (a horizontal domain line). Optional. */
70
+ tracks?: LabelRowBox[];
71
+ }
72
+ export interface AxisPinPlan {
73
+ pin: boolean;
74
+ index: number;
75
+ band: AxisBand | null;
76
+ reason: string;
77
+ }
78
+ /**
79
+ * Which axis to pin, and the band it occupies. Coordinates are whatever the caller measured in;
80
+ * the plan only compares them, and `viewportH` (same units) bounds the band. With two horizontal
81
+ * axes the one with more labels wins - it is the one carrying the scale.
82
+ */
83
+ export declare function planAxisPin(candidates: AxisPinCandidate[], viewportH: number, minLabels?: number, maxFraction?: number): AxisPinPlan;
84
+ export type AxisPinEdge = "top" | "bottom";
85
+ /**
86
+ * Where the pinned copy sits for this scroll position, or null when the original axis is in view
87
+ * and the copy must get out of the way. `band` is in CONTENT coordinates (the scroll origin), the
88
+ * viewport is [scrollTop, scrollTop + viewportH]. The copy appears as soon as the original is CUT
89
+ * by an edge, not only once it has fully left: a half-visible label row reads worse than a whole
90
+ * one drawn over it.
91
+ */
92
+ export declare function axisPinPlacement(band: AxisBand, scrollTop: number, viewportH: number): AxisPinEdge | null;
@@ -1,4 +1,4 @@
1
- import type { ContentExtent, MeasuredBox } from "./fit";
1
+ import type { ContentExtent, MeasuredBox, AxisPinEdge } from "./fit";
2
2
  export declare function ctmScaleOf(m: {
3
3
  a: number;
4
4
  b: number;
@@ -43,6 +43,13 @@ export interface FitRenderedChartOptions {
43
43
  * its own scrolling and only wants to be told.
44
44
  */
45
45
  applyOverflow?: boolean;
46
+ /**
47
+ * When the container scrolls vertically, keep the chart's horizontal axis in view by pinning
48
+ * a copy of it at the viewport edge while the original is scrolled out. Default true. The
49
+ * copy hides itself whenever the original is on screen, so there is never a second axis.
50
+ * Set false for a host that draws its own sticky header.
51
+ */
52
+ pinAxis?: boolean;
46
53
  }
47
54
  export interface FitRenderedChartResult {
48
55
  /** The frame was grown, so content that was being clipped is now painted. */
@@ -55,5 +62,28 @@ export interface FitRenderedChartResult {
55
62
  overflowY: "hidden" | "auto";
56
63
  /** The post-grow reading, or null when nothing could be measured. */
57
64
  reading: FitReading | null;
65
+ /** The axis pin: what was pinned when the container scrolls, or why nothing was. */
66
+ axisPin: AxisPinReport;
67
+ }
68
+ export interface AxisPinReport {
69
+ pinned: boolean;
70
+ /** "pinned", or the fail-open reason: not-scrolling, disabled, no-svg, no-ctm, no-axis,
71
+ * vertical-only, band-too-tall, unmeasurable, overflow-not-applied, threw. */
72
+ reason: string;
73
+ /** The pinned band's height on screen, px. */
74
+ bandPx: number;
75
+ /** Where the ORIGINAL axis sits in the chart body: top third, bottom third, or between. */
76
+ axisAt: "top" | "bottom" | "middle" | "";
77
+ /** Visible tick labels in the copy. */
78
+ labels: number;
79
+ /** Furniture texts that shared the band and came along (a "Today" caption, an axis title). */
80
+ carried: number;
81
+ /** Where the copy sat at the moment of the pass - null when the original was in view. */
82
+ edge: AxisPinEdge | null;
58
83
  }
84
+ /** Remove a previous render's pinned axis - its overlay and its scroll listener. Safe to call
85
+ * when there is none. Hosts that clear the container themselves still need this for the
86
+ * listener, which outlives the overlay. */
87
+ export declare function unpinScrolledAxis(container: HTMLElement | null | undefined): void;
88
+ export declare function pinScrolledAxis(container: HTMLElement | null | undefined, svg: SVGSVGElement | null | undefined): AxisPinReport;
59
89
  export declare function fitRenderedChart(container: HTMLElement | null | undefined, opts?: FitRenderedChartOptions): FitRenderedChartResult;
@@ -22,3 +22,5 @@ export { censusMarks, isBlankRender, blankRenderFlag, type MarkCensus, type Blan
22
22
  export { censusHitBands, hitBandFlag, MIN_HIT_BAND_PX, type HitBandCensus } from "./hitBands";
23
23
  export { SCROLL_SLACK_PX, MAX_FRAME_GROW_FACTOR, PHANTOM_FRACTION, FIT_CONTENT_SELECTOR, needsScroll, contentExtentOf, scrollFitFor, planFrameGrow, isPhantomBox, type MeasuredBox, type ContentExtent, type ScrollFit, type FrameGrowPlan, } from "./fit";
24
24
  export { fitRenderedChart, fitReadingFor, measureContainerBoxes, svgInkReach, ctmScaleOf, type FitReading, type InkReach, type FitRenderedChartOptions, type FitRenderedChartResult, } from "./fitDom";
25
+ export { AXIS_PIN_MIN_LABELS, AXIS_PIN_BAND_PAD_PX, AXIS_PIN_MAX_BAND_FRACTION, AXIS_PIN_TRACK_REACH_PX, isHorizontalLabelRow, labelBand, planAxisPin, axisPinPlacement, type LabelRowBox, type AxisBand, type AxisPinCandidate, type AxisPinPlan, type AxisPinEdge, } from "./fit";
26
+ export { pinScrolledAxis, unpinScrolledAxis, type AxisPinReport } from "./fitDom";
@@ -275,14 +275,40 @@ export interface QualifyFilterView<E> {
275
275
  * groups were built, so a host writing them in sequence cannot reorder anything by accident.
276
276
  */
277
277
  export declare function computeQualifyFilterView<E>(groups: readonly QualifyFilterGroup<E>[], term: string | null | undefined): QualifyFilterView<E>;
278
- /** The counter beside the box: "12 of 137" while filtering, the plain total otherwise.
278
+ /**
279
+ * The counter beside the box: "12 of 137" while filtering, the plain total otherwise - and
280
+ * "0 of 19 (+1)" when the term also reached the refusal block.
281
+ *
282
+ * Digits only, assembled here so both hosts show the same shape of number - the words around it
283
+ * (there are none today) would be the host's business, the arithmetic is not.
284
+ *
285
+ * THE PARENTHESISED COUNT IS THE SAME DEFECT THE NOTE WAS ALREADY FIXED FOR, one control over.
279
286
  *
280
- * Digits only, assembled here so both hosts show the same shape of number - the words around it
281
- * (there are none today) would be the host's business, the arithmetic is not. */
287
+ * Genesis (2026-09-06): a card with six rows and one measure
288
+ * bound, "box" typed. `Box plot` was on screen under "Poor fit for these fields" carrying the
289
+ * gate's own sentence, the note said where it had gone - and the counter beside the input read
290
+ * "0 of 19". That was TRUE of the fitting list, which is all this function had ever been given,
291
+ * and it is read as a claim about the DIALOG: a reader who takes the number and stops has been
292
+ * told nothing was found, three lines above the row they asked for. The same shape of error as
293
+ * the `descAndRefusalName` note (a weaker scope silencing a stronger one), and the count it
294
+ * needed was already being computed for the log and simply never reached the screen.
295
+ *
296
+ * IT FIRES ON EVERY FILTERED TERM WITH A REFUSAL MATCH, not only when the fitting list came back
297
+ * empty - and that is deliberate, because the collapsed case is the one with no other signal at
298
+ * all. When `openRefusals` is true the reader can see the row and the note explains it; when the
299
+ * fitting list answered by name the block stays shut and the note stays silent (correctly - a
300
+ * section forced open on every shared word would be noise), so this "(+1)" is the ONLY thing
301
+ * telling them the checkbox below has something of theirs behind it.
302
+ *
303
+ * NEVER FOLDED INTO `matched`. "0 of 19" and the "(+1)" count two different populations - 19 is
304
+ * the types that FIT, and the catalogue behind the checkbox is not in that denominator. Adding
305
+ * them would produce a ratio that is true of no list on screen.
306
+ */
282
307
  export declare function qualifyFilterCountText(view: {
283
308
  tier: QualifyFilterTier;
284
309
  matched: number;
285
310
  total: number;
311
+ refusalMatched?: number;
286
312
  }): string;
287
313
  /**
288
314
  * The same question for a host whose chooser is an INLINE, SCROLLING PANEL - and it has no size
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bicharts/chart-host",
3
- "version": "0.5.76",
3
+ "version": "0.5.78",
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",