@bicharts/chart-host 0.5.44 → 0.5.45

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.
@@ -1086,6 +1086,42 @@ function ensureCrossfilterHitTargets(container, doc) {
1086
1086
  return report;
1087
1087
  }
1088
1088
 
1089
+ // src/blankRender.ts
1090
+ var EMPTY = { markCount: 0, legendMarkCount: 0, containerKind: "empty" };
1091
+ function censusMarks(container) {
1092
+ if (!container || typeof container.querySelectorAll !== "function") return EMPTY;
1093
+ try {
1094
+ const marks = /* @__PURE__ */ new Set();
1095
+ for (const el of Array.from(container.querySelectorAll(`.${MARK_CLASS}`))) marks.add(el);
1096
+ for (const el of Array.from(container.querySelectorAll(`[${ROW_IDX_ATTR}]`))) marks.add(el);
1097
+ const legend = /* @__PURE__ */ new Set();
1098
+ for (const el of Array.from(container.querySelectorAll(`.${LEGEND_MARK_CLASS}`))) {
1099
+ legend.add(el);
1100
+ marks.delete(el);
1101
+ }
1102
+ const hasSvg = !!container.querySelector("svg");
1103
+ const hasAnything = hasSvg || (container.childElementCount ?? 0) > 0;
1104
+ return {
1105
+ markCount: marks.size,
1106
+ legendMarkCount: legend.size,
1107
+ containerKind: hasSvg ? "svg" : hasAnything ? "html" : "empty"
1108
+ };
1109
+ } catch {
1110
+ return EMPTY;
1111
+ }
1112
+ }
1113
+ function isBlankRender(i) {
1114
+ if (i.contractUntagged) return false;
1115
+ if (i.animated) return false;
1116
+ if (i.authoredNoDataText) return false;
1117
+ if (!(i.rows > 0)) return false;
1118
+ return i.markCount === 0;
1119
+ }
1120
+ function blankRenderFlag(lang, cause) {
1121
+ const clean = (s) => String(s || "unknown").toLowerCase().replace(/[^a-z0-9-]+/g, "-");
1122
+ return `blankrender:${clean(lang)}:${clean(cause)}`;
1123
+ }
1124
+
1089
1125
  // src/host.ts
1090
1126
  var parseRowIdxs = (s) => (s || "").split(",").map((x2) => parseInt(x2, 10)).filter((n) => Number.isFinite(n));
1091
1127
  var D3_PLUGIN_PACKAGES = {
@@ -1186,6 +1222,7 @@ function createChartHost(container, config) {
1186
1222
  let renderFn = config.renderFn ?? null;
1187
1223
  let destroyed = false;
1188
1224
  let warnedGeo = false;
1225
+ let warnedBlank = false;
1189
1226
  let current = null;
1190
1227
  let activeTickEl = null;
1191
1228
  let selectionIsPeriod = false;
@@ -1353,6 +1390,27 @@ function createChartHost(container, config) {
1353
1390
  }
1354
1391
  ensureCrossfilterHitTargets(container, doc);
1355
1392
  paintSelection();
1393
+ try {
1394
+ const census = censusMarks(container);
1395
+ if (isBlankRender({
1396
+ markCount: census.markCount,
1397
+ rows: data.rows ? data.rows.length : 0,
1398
+ animated: config.animated,
1399
+ contractUntagged: config.contractUntagged
1400
+ })) {
1401
+ if (!warnedBlank) {
1402
+ warnedBlank = true;
1403
+ try {
1404
+ (win?.console ?? console)?.warn(
1405
+ `[@bicharts/chart-host] this chart rendered without error but painted no data marks, against ${data.rows.length} row(s) \u2014 so the container is empty. The usual cause is that the generated code binds a column BY NAME that is no longer in the data, sending it down its own "no data" branch. Check that the columns in your table still match the ones the chart was generated for.`
1406
+ );
1407
+ } catch {
1408
+ }
1409
+ }
1410
+ config.onBlankRender?.({ census, rows: data.rows ? data.rows.length : 0 });
1411
+ }
1412
+ } catch {
1413
+ }
1356
1414
  },
1357
1415
  setOptions(partial) {
1358
1416
  raw = { ...raw, ...partial };
@@ -1448,6 +1506,9 @@ export {
1448
1506
  buildRenderPayload,
1449
1507
  createMarkResolver,
1450
1508
  ensureCrossfilterHitTargets,
1509
+ censusMarks,
1510
+ isBlankRender,
1511
+ blankRenderFlag,
1451
1512
  requiredD3Plugins,
1452
1513
  explainRenderFailure,
1453
1514
  stripEsmExports,
package/dist/index.mjs CHANGED
@@ -30,7 +30,9 @@ import {
30
30
  ROW_IDX_ATTR,
31
31
  SELECTION_ACTIVE_CLASS,
32
32
  XFILTER_REFRESH_EVENT,
33
+ blankRenderFlag,
33
34
  buildRenderPayload,
35
+ censusMarks,
34
36
  chartOwnsTimeline,
35
37
  clearGeoCache,
36
38
  compileRenderFn,
@@ -40,6 +42,7 @@ import {
40
42
  explainRenderFailure,
41
43
  geoAssetFor,
42
44
  geoFromCache,
45
+ isBlankRender,
43
46
  loadGeo,
44
47
  periodTickSuppressesFeedback,
45
48
  registerGeo,
@@ -47,7 +50,7 @@ import {
47
50
  requiredD3Plugins,
48
51
  resolveOptions,
49
52
  stripEsmExports
50
- } from "./chunk-BMYEYUEN.mjs";
53
+ } from "./chunk-DZ5FNFID.mjs";
51
54
  import "./chunk-A2GMXZP7.mjs";
52
55
 
53
56
  // src/trivial.ts
@@ -811,10 +814,12 @@ export {
811
814
  actionFor,
812
815
  askApplyImprovements,
813
816
  bareBase64,
817
+ blankRenderFlag,
814
818
  buildRenderPayload,
815
819
  buildReviewWire,
816
820
  canConfirmLaunch,
817
821
  captureSvgSnapshot,
822
+ censusMarks,
818
823
  chartOwnsTimeline,
819
824
  chooserFitsViewport,
820
825
  clearGeoCache,
@@ -828,6 +833,7 @@ export {
828
833
  explainRenderFailure,
829
834
  geoAssetFor,
830
835
  geoFromCache,
836
+ isBlankRender,
831
837
  launchFavorStyle,
832
838
  launchGenerates,
833
839
  loadGeo,
package/dist/react.mjs CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  createChartHost,
5
5
  geoFromCache,
6
6
  loadGeo
7
- } from "./chunk-BMYEYUEN.mjs";
7
+ } from "./chunk-DZ5FNFID.mjs";
8
8
  import "./chunk-A2GMXZP7.mjs";
9
9
 
10
10
  // src/react.tsx
@@ -0,0 +1,60 @@
1
+ export interface MarkCensus {
2
+ /** Data marks found anywhere in the container. */
3
+ markCount: number;
4
+ /** Legend swatches / axis furniture — clickable, but NOT evidence the data drew. */
5
+ legendMarkCount: number;
6
+ /** What the chart built. `html` is a first-class answer, not a failure — see below. */
7
+ containerKind: "svg" | "html" | "empty";
8
+ }
9
+ /**
10
+ * Count what the render actually left behind.
11
+ *
12
+ * NOT SCOPED TO `<svg>`, and that is what makes this work at all. Plenty of chart types are pure
13
+ * HTML - a table with embedded bars, a KPI card - and never create an SVG, so a census that
14
+ * starts from `container.querySelector("svg")` finds nothing for exactly the charts it most needs
15
+ * to measure. Those charts still honour the contract: their working path tags each row
16
+ * `class="d3-mark"` + `data-row-idx`, and their no-data branch appends a bare element with
17
+ * neither. The class and the attribute ARE the signal, in both DOM worlds; the element name never
18
+ * was.
19
+ *
20
+ * Counts by contract, never by tag: a chart marking `<rect>`, `<text>`, `<path>`, `<circle>` or
21
+ * `<div>` is equally visible here. Never throws — telemetry must not be why a delivered render
22
+ * fails.
23
+ */
24
+ export declare function censusMarks(container: Element | null | undefined): MarkCensus;
25
+ export interface BlankVerdictInput {
26
+ /** From censusMarks. */
27
+ markCount: number;
28
+ /** Rows the chart was HANDED. Zero rows is a different, already-handled story. */
29
+ rows: number;
30
+ /**
31
+ * This chart declares time keyframes. Its first frame is legitimately allowed to be empty
32
+ * (an animated map before any country enters the series), so a verdict on frame one would be
33
+ * a lie about the commonest animated opening.
34
+ */
35
+ animated?: boolean;
36
+ /**
37
+ * The author configured their own "No Data Text". They asked for this rectangle to say
38
+ * something when there is nothing to draw, and overruling them would be telling someone their
39
+ * deliberate empty state is a defect.
40
+ */
41
+ authoredNoDataText?: boolean;
42
+ /**
43
+ * The chart's marks are not tagged by our contract at all — a Vega lane, whose marks the
44
+ * generated spec names itself. Absence of `.d3-mark` there proves nothing, so the census is
45
+ * evidence-free and must not return a verdict.
46
+ */
47
+ contractUntagged?: boolean;
48
+ }
49
+ /**
50
+ * Did this render paint nothing, against data that had something to paint?
51
+ *
52
+ * EVERY CLAUSE IS A FALSE POSITIVE WE WOULD OTHERWISE SHIP. The value of this verdict is that a
53
+ * human believes it; one wrong accusation on a chart that is fine costs more than the case it
54
+ * catches, which is the same argument `columnsGoneFromDiff` makes about type narrowings.
55
+ */
56
+ export declare function isBlankRender(i: BlankVerdictInput): boolean;
57
+ /** A short, stable token a host can attach to its own per-render telemetry. Deliberately
58
+ * formatted for an always-on channel rather than a debug log: a blank render is most
59
+ * interesting in aggregate, and the charts worth counting are the ones nobody is watching. */
60
+ export declare function blankRenderFlag(lang: string, cause: string): string;
@@ -1,5 +1,6 @@
1
1
  import { type RenderOptions } from "./contract";
2
2
  import { type ResolveOptionsInput } from "./defaults";
3
+ import { type MarkCensus } from "./blankRender";
3
4
  export type RenderFn = (container: HTMLElement, data: any, options: RenderOptions) => void;
4
5
  export interface ChartHostConfig {
5
6
  data: {
@@ -36,6 +37,26 @@ export interface ChartHostConfig {
36
37
  * already holds its assets (e.g. `geoForKind` from "@bicharts/chart-host/geo", or the
37
38
  * data.geo.json the MCP wrote next to the chart). */
38
39
  geoProvider?: (geoKind: string) => any | undefined;
40
+ /**
41
+ * Called when a render completes cleanly having painted NO data marks against a non-empty
42
+ * table (2026-09-01). The chart did not throw, so nothing else will tell you — this is
43
+ * the only notification that the rectangle your reader is looking at is empty.
44
+ *
45
+ * The commonest cause by far is schema drift: generated code binds columns BY NAME, so a
46
+ * renamed or removed field sends its own `findIndex(...) === -1` guard down the no-data
47
+ * branch. A host that knows the binding can say WHICH column; one that does not should still
48
+ * surface it, because "empty and silent" is the state this exists to abolish.
49
+ */
50
+ onBlankRender?: (info: {
51
+ census: MarkCensus;
52
+ rows: number;
53
+ }) => void;
54
+ /** This chart declares time keyframes: frame one is allowed to be empty, so no blank verdict
55
+ * is issued for it. */
56
+ animated?: boolean;
57
+ /** The chart's marks are not tagged with the shared contract (a Vega lane names its own
58
+ * marks), so a mark count proves nothing and the blank verdict is suppressed. */
59
+ contractUntagged?: boolean;
39
60
  }
40
61
  export interface ChartHost {
41
62
  render(): void;
@@ -15,3 +15,4 @@ export { qualifyGroupHeadingFor, newQualifyGroupState, type QualifyGroupRow, typ
15
15
  export { qualifyPick, qualifyAuto, qualifyCancel, launchGenerates, launchFavorStyle, chooserFitsViewport, shouldOpenChooserOnGenerate, shouldOpenInlineChooserOnGenerate, canConfirmLaunch, confirmLaunch, qualifyFailureFallsOpen, CHOOSER_MIN_WIDTH_PX, CHOOSER_MIN_HEIGHT_PX, type QualifyLaunchOutcome, type ChooserGateInput, } from "./qualifyLaunch";
16
16
  export { computeSelectionCard, normaliseAggregation, type SelectionCardModel, type SelectionCardLine, type SelectionCardOptions, } from "./selectionCard";
17
17
  export { ensureCrossfilterHitTargets, type HitTargetReport } from "./hitTargets";
18
+ export { censusMarks, isBlankRender, blankRenderFlag, type MarkCensus, type BlankVerdictInput } from "./blankRender";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bicharts/chart-host",
3
- "version": "0.5.44",
3
+ "version": "0.5.45",
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",