@bicharts/chart-host 0.5.58 → 0.5.59

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.
@@ -1227,6 +1227,87 @@ function blankRenderFlag(lang, cause) {
1227
1227
  return `blankrender:${clean(lang)}:${clean(cause)}`;
1228
1228
  }
1229
1229
 
1230
+ // src/hitBands.ts
1231
+ var MIN_HIT_BAND_PX = 8;
1232
+ var ELEMENT_CAP2 = 5e3;
1233
+ var EMPTY2 = {
1234
+ openStrokes: 0,
1235
+ interactive: 0,
1236
+ hairline: 0,
1237
+ uncovered: 0,
1238
+ widestPx: 0
1239
+ };
1240
+ var OPEN_TAGS = /* @__PURE__ */ new Set(["path", "line", "polyline"]);
1241
+ function styleOf(el, doc) {
1242
+ try {
1243
+ const view = doc?.defaultView;
1244
+ return view?.getComputedStyle ? view.getComputedStyle(el) : null;
1245
+ } catch {
1246
+ return null;
1247
+ }
1248
+ }
1249
+ function attrOrComputed(el, name, cs) {
1250
+ const a2 = el.getAttribute?.(name);
1251
+ if (a2 !== null && a2 !== void 0 && String(a2).trim() !== "") return String(a2).trim();
1252
+ const c = cs?.[name === "stroke-width" ? "strokeWidth" : name];
1253
+ return c === null || c === void 0 ? "" : String(c).trim();
1254
+ }
1255
+ function isUnfilled(fill) {
1256
+ const f = fill.toLowerCase();
1257
+ return f === "none" || f === "transparent" || f === "rgba(0, 0, 0, 0)" || f === "rgba(0,0,0,0)";
1258
+ }
1259
+ function widthPx(raw) {
1260
+ const m2 = /^(-?\d+(?:\.\d+)?)\s*(px)?$/i.exec(raw);
1261
+ if (!m2) return NaN;
1262
+ const n = parseFloat(m2[1]);
1263
+ return isFinite(n) ? n : NaN;
1264
+ }
1265
+ function censusHitBands(container, doc) {
1266
+ if (!container || typeof container.querySelectorAll !== "function") return EMPTY2;
1267
+ try {
1268
+ const els = Array.from(
1269
+ container.querySelectorAll(`.${MARK_CLASS}, [${ROW_IDX_ATTR}]`)
1270
+ );
1271
+ if (els.length === 0 || els.length > ELEMENT_CAP2) return EMPTY2;
1272
+ const ownerDoc = doc ?? container.ownerDocument;
1273
+ const widestByGeom = /* @__PURE__ */ new Map();
1274
+ const rows = [];
1275
+ for (const el of els) {
1276
+ const tag = String(el.tagName || "").toLowerCase();
1277
+ if (!OPEN_TAGS.has(tag)) continue;
1278
+ const cs = styleOf(el, ownerDoc);
1279
+ const open = isUnfilled(attrOrComputed(el, "fill", cs));
1280
+ const w = widthPx(attrOrComputed(el, "stroke-width", cs) || "1");
1281
+ const geom = String(el.getAttribute?.("d") ?? "") || `${tag}#${String(el.getAttribute?.(ROW_IDX_ATTR) ?? "")}`;
1282
+ const inert = String(cs?.pointerEvents ?? el.style?.pointerEvents ?? "") === "none";
1283
+ rows.push({ el, geom, w, open, inert });
1284
+ if (isFinite(w)) {
1285
+ const prev = widestByGeom.get(geom);
1286
+ if (prev === void 0 || w > prev) widestByGeom.set(geom, w);
1287
+ }
1288
+ }
1289
+ const out = { ...EMPTY2 };
1290
+ for (const r of rows) {
1291
+ if (!r.open) continue;
1292
+ out.openStrokes++;
1293
+ if (r.inert) continue;
1294
+ out.interactive++;
1295
+ if (isFinite(r.w) && r.w > out.widestPx) out.widestPx = Math.round(r.w);
1296
+ if (!(r.w < MIN_HIT_BAND_PX)) continue;
1297
+ out.hairline++;
1298
+ const best = widestByGeom.get(r.geom);
1299
+ if (best === void 0 || best < MIN_HIT_BAND_PX) out.uncovered++;
1300
+ }
1301
+ return out;
1302
+ } catch {
1303
+ return EMPTY2;
1304
+ }
1305
+ }
1306
+ function hitBandFlag(c) {
1307
+ if (c.interactive === 0) return "";
1308
+ return c.uncovered > 0 ? "hitband:d3:thin" : "hitband:d3:ok";
1309
+ }
1310
+
1230
1311
  // src/host.ts
1231
1312
  function sessionViewStateProvider(container) {
1232
1313
  const holder = container;
@@ -1550,6 +1631,12 @@ function createChartHost(container, config) {
1550
1631
  }
1551
1632
  } catch {
1552
1633
  }
1634
+ if (config.onHitBandCensus) {
1635
+ try {
1636
+ config.onHitBandCensus(censusHitBands(container, doc));
1637
+ } catch {
1638
+ }
1639
+ }
1553
1640
  },
1554
1641
  setOptions(partial) {
1555
1642
  raw = { ...raw, ...partial };
@@ -1655,6 +1742,9 @@ export {
1655
1742
  censusMarks,
1656
1743
  isBlankRender,
1657
1744
  blankRenderFlag,
1745
+ MIN_HIT_BAND_PX,
1746
+ censusHitBands,
1747
+ hitBandFlag,
1658
1748
  sessionViewStateProvider,
1659
1749
  noopViewStateProvider,
1660
1750
  requiredD3Plugins,
package/dist/index.mjs CHANGED
@@ -31,12 +31,14 @@ import {
31
31
  LIFT_SELECTED_CLASS,
32
32
  MARK_CLASS,
33
33
  MARK_SELECTED_CLASS,
34
+ MIN_HIT_BAND_PX,
34
35
  ROW_IDX_ATTR,
35
36
  SELECTION_ACTIVE_CLASS,
36
37
  VALUE_AXIS_BASELINE_DEFAULT,
37
38
  XFILTER_REFRESH_EVENT,
38
39
  blankRenderFlag,
39
40
  buildRenderPayload,
41
+ censusHitBands,
40
42
  censusMarks,
41
43
  chartOwnsTimeline,
42
44
  clearGeoCache,
@@ -47,6 +49,7 @@ import {
47
49
  explainRenderFailure,
48
50
  geoAssetFor,
49
51
  geoFromCache,
52
+ hitBandFlag,
50
53
  isBlankRender,
51
54
  loadGeo,
52
55
  ne,
@@ -59,7 +62,7 @@ import {
59
62
  sessionViewStateProvider,
60
63
  stripEsmExports,
61
64
  te
62
- } from "./chunk-UUH6S5Q5.mjs";
65
+ } from "./chunk-WG4NTR5H.mjs";
63
66
  import "./chunk-A2GMXZP7.mjs";
64
67
 
65
68
  // src/trivial.ts
@@ -898,6 +901,7 @@ export {
898
901
  LIFT_SELECTED_CLASS,
899
902
  MARK_CLASS,
900
903
  MARK_SELECTED_CLASS,
904
+ MIN_HIT_BAND_PX,
901
905
  ROW_IDX_ATTR,
902
906
  SELECTION_ACTIVE_CLASS,
903
907
  VALUE_AXIS_BASELINE_DEFAULT,
@@ -910,6 +914,7 @@ export {
910
914
  buildReviewWire,
911
915
  canConfirmLaunch,
912
916
  captureSvgSnapshot,
917
+ censusHitBands,
913
918
  censusMarks,
914
919
  chartOwnsTimeline,
915
920
  chooserFitsViewport,
@@ -925,6 +930,7 @@ export {
925
930
  geoAssetFor,
926
931
  geoFromCache,
927
932
  hasRefusalsToShow,
933
+ hitBandFlag,
928
934
  isBlankRender,
929
935
  launchFavorStyle,
930
936
  launchGenerates,
package/dist/react.mjs CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  createChartHost,
5
5
  geoFromCache,
6
6
  loadGeo
7
- } from "./chunk-UUH6S5Q5.mjs";
7
+ } from "./chunk-WG4NTR5H.mjs";
8
8
  import "./chunk-A2GMXZP7.mjs";
9
9
 
10
10
  // src/react.tsx
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Narrowest stroke, in CSS pixels, that counts as a real click target.
3
+ *
4
+ * The generated-code recipe asks for 10-14px. Eight is the floor rather than the target: it is
5
+ * generous enough that a chart which made a deliberate effort is not counted as a defect, and it
6
+ * is the same number the server-side rule uses, so the two halves cannot drift into disagreeing
7
+ * about which strokes are thin.
8
+ */
9
+ export declare const MIN_HIT_BAND_PX = 8;
10
+ export interface HitBandCensus {
11
+ /** Tagged marks that are OPEN strokes (`fill` none/transparent with a painted stroke). */
12
+ openStrokes: number;
13
+ /** Of those, the ones the chart has not opted out of with `pointer-events:none`. */
14
+ interactive: number;
15
+ /** Of the interactive ones, those whose own band is under `MIN_HIT_BAND_PX`. */
16
+ hairline: number;
17
+ /** Of the hairlines, those with no wider companion sharing their geometry. THE DEFECT. */
18
+ uncovered: number;
19
+ /** Widest band, rounded, seen on any interactive open stroke — for tuning the floor. */
20
+ widestPx: number;
21
+ }
22
+ /**
23
+ * Count the declared marks that are open strokes, and how many of them are too thin to hit.
24
+ *
25
+ * A companion is any OTHER tagged element sharing this one's geometry (the same `d`, or the same
26
+ * row index when there is no `d`) whose band clears the floor. That is exactly the recipe's
27
+ * shape, and it means a chart which drew its hit band correctly reports `uncovered: 0` however it
28
+ * spelled the transparency — the width is what makes a band a band.
29
+ *
30
+ * A mark whose own visible stroke is already wide is its OWN hit band and is never counted as a
31
+ * hairline. A Sankey link is the case that matters: its thickness is its value, routinely 10-40px,
32
+ * and asking it for a companion would be asking for a second hit area on top of a good one.
33
+ *
34
+ * Never throws — telemetry must not be the reason a delivered render fails. An unreadable width
35
+ * counts as WIDE (`NaN` fails the `< MIN_HIT_BAND_PX` test), because a census that guesses
36
+ * "defect" when it cannot measure would report the environment rather than the chart.
37
+ */
38
+ export declare function censusHitBands(container: any, doc?: any): HitBandCensus;
39
+ /**
40
+ * The always-on behaviour tag for this census, or "" when there is nothing to say.
41
+ *
42
+ * One tag per render in the `hitband:` namespace, so the rate is a `LIKE` away and the three
43
+ * states sum to every render that drew an open stroke — a rate needs a denominator, and
44
+ * `hitband:d3:ok` is it. Charts with no open-stroke marks at all emit nothing rather than a
45
+ * third value nobody will remember to exclude.
46
+ */
47
+ export declare function hitBandFlag(c: HitBandCensus): string;
@@ -1,6 +1,7 @@
1
1
  import { type RenderOptions, type ViewStateProvider } from "./contract";
2
2
  import { type ResolveOptionsInput } from "./defaults";
3
3
  import { type MarkCensus } from "./blankRender";
4
+ import { type HitBandCensus } from "./hitBands";
4
5
  export type RenderFn = (container: HTMLElement, data: any, options: RenderOptions) => void;
5
6
  /**
6
7
  * THE SESSION PROVIDER — what every host got hard-coded before contract 1.6.0, now named.
@@ -77,6 +78,18 @@ export interface ChartHostConfig {
77
78
  census: MarkCensus;
78
79
  rows: number;
79
80
  }) => void;
81
+ /**
82
+ * Called after every render with the hit-band census — how many of this chart's marks are
83
+ * thin open strokes, and how many of those have no wider companion to catch a click
84
+ * (2026-09-03). Counts only; the host decides what, if anything, to record. It fires even
85
+ * when everything is fine, because a rate needs a denominator and `interactive` is it: a
86
+ * chart with no open-stroke marks reports zeroes and should be excluded, not counted as good.
87
+ *
88
+ * This is the half a server-side code check structurally cannot do. A regex reads
89
+ * `.attr('stroke-width', 2)` and nothing else; `.attr('stroke-width', d => scale(d.v))` is a
90
+ * number only the browser knows, and this reads the COMPUTED width off the rendered element.
91
+ */
92
+ onHitBandCensus?: (census: HitBandCensus) => void;
80
93
  /** This chart declares time keyframes: frame one is allowed to be empty, so no blank verdict
81
94
  * is issued for it. */
82
95
  animated?: boolean;
@@ -16,3 +16,4 @@ export { qualifyPick, qualifyAuto, qualifyCancel, launchGenerates, launchFavorSt
16
16
  export { computeSelectionCard, normaliseAggregation, type SelectionCardModel, type SelectionCardLine, type SelectionCardOptions, } from "./selectionCard";
17
17
  export { ensureCrossfilterHitTargets, type HitTargetReport } from "./hitTargets";
18
18
  export { censusMarks, isBlankRender, blankRenderFlag, type MarkCensus, type BlankVerdictInput } from "./blankRender";
19
+ export { censusHitBands, hitBandFlag, MIN_HIT_BAND_PX, type HitBandCensus } from "./hitBands";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bicharts/chart-host",
3
- "version": "0.5.58",
3
+ "version": "0.5.59",
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",