@bicharts/chart-host 0.5.76 → 0.5.77

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
 
@@ -1033,6 +1043,10 @@ export {
1033
1043
  ANIM_PLAY_SPEED_MIN,
1034
1044
  APPROXIMATE_POSITIONS_DEFAULT,
1035
1045
  AXIS_FILTER_CLASS,
1046
+ AXIS_PIN_BAND_PAD_PX,
1047
+ AXIS_PIN_MAX_BAND_FRACTION,
1048
+ AXIS_PIN_MIN_LABELS,
1049
+ AXIS_PIN_TRACK_REACH_PX,
1036
1050
  BACKING_MAJORITY,
1037
1051
  CHOOSER_MIN_HEIGHT_PX,
1038
1052
  CHOOSER_MIN_WIDTH_PX,
@@ -1082,6 +1096,7 @@ export {
1082
1096
  actionFor,
1083
1097
  applyLabelContrast,
1084
1098
  askApplyImprovements,
1099
+ axisPinPlacement,
1085
1100
  backingHoldsGlyph,
1086
1101
  bareBase64,
1087
1102
  blankRenderFlag,
@@ -1120,8 +1135,10 @@ export {
1120
1135
  hitBandFlag,
1121
1136
  inlineFilterGate,
1122
1137
  isBlankRender,
1138
+ isHorizontalLabelRow,
1123
1139
  isPhantomBox,
1124
1140
  isPillBackdropAlpha,
1141
+ labelBand,
1125
1142
  launchFavorStyle,
1126
1143
  launchGenerates,
1127
1144
  listNeedsFilter,
@@ -1136,6 +1153,8 @@ export {
1136
1153
  orderRefusalsForDisplay,
1137
1154
  periodTickSuppressesFeedback,
1138
1155
  pillBacksGlyph,
1156
+ pinScrolledAxis,
1157
+ planAxisPin,
1139
1158
  planFrameGrow,
1140
1159
  planTrivialChart,
1141
1160
  qualifyAuto,
@@ -1165,5 +1184,6 @@ export {
1165
1184
  svgInkReach,
1166
1185
  svgNaturalSize,
1167
1186
  svgToDataUrl,
1168
- toRGBA
1187
+ toRGBA,
1188
+ unpinScrolledAxis
1169
1189
  };
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";
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.77",
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",