@bicharts/chart-host 0.5.14 → 0.5.16
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/dist/{chunk-CHXK4WAI.mjs → chunk-EIKZXZPN.mjs} +196 -193
- package/dist/index.mjs +275 -1
- package/dist/react.mjs +1 -1
- package/dist/types/contract.d.ts +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/trivial.d.ts +32 -0
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -39,9 +39,281 @@ import {
|
|
|
39
39
|
requiredD3Plugins,
|
|
40
40
|
resolveOptions,
|
|
41
41
|
stripEsmExports
|
|
42
|
-
} from "./chunk-
|
|
42
|
+
} from "./chunk-EIKZXZPN.mjs";
|
|
43
43
|
import "./chunk-A2GMXZP7.mjs";
|
|
44
44
|
|
|
45
|
+
// src/trivial.ts
|
|
46
|
+
var META_COLUMNS = /* @__PURE__ */ new Set(["__rowIdx__", "__geoIso__", "__geoLat__", "__geoLon__"]);
|
|
47
|
+
var MAX_FREQUENCY_CATEGORIES = 30;
|
|
48
|
+
var MIN_HISTOGRAM_ROWS = 8;
|
|
49
|
+
function columnName(c) {
|
|
50
|
+
return typeof c === "string" ? c : String(c?.name ?? "");
|
|
51
|
+
}
|
|
52
|
+
function isNumericColumn(rows, idx) {
|
|
53
|
+
let seen = 0;
|
|
54
|
+
for (let r = 0; r < rows.length && seen < 50; r++) {
|
|
55
|
+
const v = rows[r]?.[idx];
|
|
56
|
+
if (v === null || v === void 0 || v === "") continue;
|
|
57
|
+
seen++;
|
|
58
|
+
if (typeof v === "number") {
|
|
59
|
+
if (!isFinite(v)) return false;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (String(v).trim() === "" || !isFinite(Number(v))) return false;
|
|
63
|
+
}
|
|
64
|
+
return seen > 0;
|
|
65
|
+
}
|
|
66
|
+
function lit(s) {
|
|
67
|
+
return JSON.stringify(String(s));
|
|
68
|
+
}
|
|
69
|
+
function planTrivialChart(data) {
|
|
70
|
+
const allCols = Array.isArray(data?.columns) ? data.columns : [];
|
|
71
|
+
const rows = Array.isArray(data?.rows) ? data.rows : [];
|
|
72
|
+
if (!allCols.length || !rows.length) return null;
|
|
73
|
+
const dataCols = [];
|
|
74
|
+
for (let i = 0; i < allCols.length; i++) {
|
|
75
|
+
if (!META_COLUMNS.has(columnName(allCols[i]))) dataCols.push(i);
|
|
76
|
+
}
|
|
77
|
+
if (dataCols.length !== 1) return null;
|
|
78
|
+
const idx = dataCols[0];
|
|
79
|
+
const name = columnName(allCols[idx]) || "Value";
|
|
80
|
+
const finish = (kind, reason, source) => ({
|
|
81
|
+
kind,
|
|
82
|
+
reason,
|
|
83
|
+
columnIndex: idx,
|
|
84
|
+
source,
|
|
85
|
+
render: compileTrivialSource(source)
|
|
86
|
+
});
|
|
87
|
+
if (rows.length === 1) {
|
|
88
|
+
return finish(
|
|
89
|
+
"card",
|
|
90
|
+
"One row of one column holds a single value, so this is a card. No generation was needed and none was used.",
|
|
91
|
+
cardSource(idx, name)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (isNumericColumn(rows, idx)) {
|
|
95
|
+
if (rows.length < MIN_HISTOGRAM_ROWS) return null;
|
|
96
|
+
return finish(
|
|
97
|
+
"histogram",
|
|
98
|
+
"A single numeric column has one defensible chart: the distribution of its values. No generation was needed and none was used.",
|
|
99
|
+
histogramSource(idx, name)
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const distinct = /* @__PURE__ */ new Set();
|
|
103
|
+
for (let r = 0; r < rows.length; r++) {
|
|
104
|
+
const raw = rows[r]?.[idx];
|
|
105
|
+
distinct.add(raw === null || raw === void 0 || raw === "" ? "(blank)" : String(raw));
|
|
106
|
+
}
|
|
107
|
+
if (distinct.size < 2 || distinct.size >= rows.length) return null;
|
|
108
|
+
if (distinct.size > MAX_FREQUENCY_CATEGORIES) return null;
|
|
109
|
+
return finish(
|
|
110
|
+
"frequency-bar",
|
|
111
|
+
"A single categorical column has one defensible chart: how often each value occurs. No generation was needed and none was used.",
|
|
112
|
+
frequencySource(idx, name)
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
function compileTrivialSource(source) {
|
|
116
|
+
const factory = new Function(
|
|
117
|
+
"container",
|
|
118
|
+
"data",
|
|
119
|
+
"options",
|
|
120
|
+
source + "\n; return typeof render === 'function' ? render : null;"
|
|
121
|
+
);
|
|
122
|
+
const fn = factory(null, null, null);
|
|
123
|
+
if (typeof fn !== "function") throw new Error("trivial template did not define render()");
|
|
124
|
+
return fn;
|
|
125
|
+
}
|
|
126
|
+
var PRELUDE = `
|
|
127
|
+
var W = options.width, H = options.height;
|
|
128
|
+
var FG = options.themeFg || '#333333';
|
|
129
|
+
var BG = options.backgroundColor || 'transparent';
|
|
130
|
+
var ACCENT = (options.palette && options.palette.length ? options.palette[0] : '#3182bd');
|
|
131
|
+
// Chrome font from the viewport, matching the archetypes' CF convention so a
|
|
132
|
+
// deterministic chart does not look foreign beside a generated one.
|
|
133
|
+
var CF = Math.max(9, Math.min(14, Math.round(Math.min(W, H) / 55)));
|
|
134
|
+
var rows = (data && data.rows) || [];
|
|
135
|
+
function fmt(v) {
|
|
136
|
+
try { return new Intl.NumberFormat(options.cultureCode || undefined).format(v); }
|
|
137
|
+
catch (e) { return String(v); }
|
|
138
|
+
}
|
|
139
|
+
function compact(v) {
|
|
140
|
+
var a = Math.abs(v);
|
|
141
|
+
if (a >= 1e9) return (v / 1e9).toFixed(a >= 1e10 ? 0 : 1) + 'B';
|
|
142
|
+
if (a >= 1e6) return (v / 1e6).toFixed(a >= 1e7 ? 0 : 1) + 'M';
|
|
143
|
+
if (a >= 1e3) return (v / 1e3).toFixed(a >= 1e4 ? 0 : 1) + 'K';
|
|
144
|
+
return fmt(Math.round(v * 100) / 100);
|
|
145
|
+
}
|
|
146
|
+
function svgEl(tag, attrs) {
|
|
147
|
+
var n = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
|
148
|
+
for (var k in attrs) if (Object.prototype.hasOwnProperty.call(attrs, k)) n.setAttribute(k, String(attrs[k]));
|
|
149
|
+
return n;
|
|
150
|
+
}
|
|
151
|
+
container.replaceChildren();
|
|
152
|
+
`;
|
|
153
|
+
function cardSource(idx, name) {
|
|
154
|
+
return `function render(container, data, options) {${PRELUDE}
|
|
155
|
+
var raw = rows[0] ? rows[0][${idx}] : null;
|
|
156
|
+
var blank = (raw === null || raw === undefined || raw === '');
|
|
157
|
+
var isNum = !blank && (typeof raw === 'number' || (String(raw).trim() !== '' && isFinite(Number(raw))));
|
|
158
|
+
var shown = blank ? '(blank)' : (isNum ? fmt(Number(raw)) : String(raw));
|
|
159
|
+
|
|
160
|
+
var root = document.createElement('div');
|
|
161
|
+
root.style.width = W + 'px'; root.style.height = H + 'px';
|
|
162
|
+
root.style.display = 'flex'; root.style.flexDirection = 'column';
|
|
163
|
+
root.style.alignItems = 'center'; root.style.justifyContent = 'center';
|
|
164
|
+
root.style.boxSizing = 'border-box'; root.style.padding = '8px';
|
|
165
|
+
root.style.overflow = 'hidden'; root.style.fontFamily = 'sans-serif';
|
|
166
|
+
root.style.color = FG; root.style.background = BG;
|
|
167
|
+
|
|
168
|
+
// The value sizes to BOTH the space and the string, so a long text value shrinks to fit
|
|
169
|
+
// instead of overflowing a card measured for a short number.
|
|
170
|
+
var budget = Math.max(1, W - 24);
|
|
171
|
+
var byWidth = budget / Math.max(1, shown.length * 0.62);
|
|
172
|
+
var size = Math.max(14, Math.min(H * 0.42, byWidth, 96));
|
|
173
|
+
|
|
174
|
+
var value = document.createElement('div');
|
|
175
|
+
value.style.fontSize = Math.round(size) + 'px'; value.style.fontWeight = '700';
|
|
176
|
+
value.style.lineHeight = '1.1'; value.style.maxWidth = '100%';
|
|
177
|
+
value.style.textAlign = 'center'; value.style.wordBreak = 'break-word';
|
|
178
|
+
value.textContent = shown;
|
|
179
|
+
|
|
180
|
+
var label = document.createElement('div');
|
|
181
|
+
label.style.fontSize = CF + 'px'; label.style.opacity = '0.75';
|
|
182
|
+
label.style.marginTop = '6px'; label.style.textAlign = 'center';
|
|
183
|
+
label.style.maxWidth = '100%'; label.style.overflow = 'hidden';
|
|
184
|
+
label.style.textOverflow = 'ellipsis'; label.style.whiteSpace = 'nowrap';
|
|
185
|
+
label.textContent = ${lit(name)};
|
|
186
|
+
|
|
187
|
+
root.appendChild(value); root.appendChild(label);
|
|
188
|
+
container.appendChild(root);
|
|
189
|
+
}`;
|
|
190
|
+
}
|
|
191
|
+
function frequencySource(idx, name) {
|
|
192
|
+
return `function render(container, data, options) {${PRELUDE}
|
|
193
|
+
var counts = new Map();
|
|
194
|
+
for (var r = 0; r < rows.length; r++) {
|
|
195
|
+
var raw = rows[r] ? rows[r][${idx}] : null;
|
|
196
|
+
var key = (raw === null || raw === undefined || raw === '') ? '(blank)' : String(raw);
|
|
197
|
+
var b = counts.get(key);
|
|
198
|
+
if (b) b.push(r); else counts.set(key, [r]);
|
|
199
|
+
}
|
|
200
|
+
var entries = Array.from(counts.entries()).sort(function (a, b) { return b[1].length - a[1].length; });
|
|
201
|
+
var max = entries.length ? entries[0][1].length : 1;
|
|
202
|
+
|
|
203
|
+
var svg = svgEl('svg', { width: W, height: H });
|
|
204
|
+
var padL = Math.min(Math.max(70, W * 0.22), W * 0.4);
|
|
205
|
+
var padR = 52, padT = CF + 12, padB = 8;
|
|
206
|
+
var plotW = Math.max(10, W - padL - padR);
|
|
207
|
+
var bandH = Math.max(8, (H - padT - padB) / entries.length);
|
|
208
|
+
var barH = Math.min(bandH - 3, 26);
|
|
209
|
+
|
|
210
|
+
var title = svgEl('text', { x: 8, y: CF + 2, 'font-size': CF, 'font-weight': '700', fill: FG });
|
|
211
|
+
title.textContent = ${lit(name)} + ' \\u2014 count of ' + fmt(rows.length) + ' rows';
|
|
212
|
+
svg.appendChild(title);
|
|
213
|
+
|
|
214
|
+
entries.forEach(function (e, i) {
|
|
215
|
+
var key = e[0], idxs = e[1];
|
|
216
|
+
var y = padT + i * bandH;
|
|
217
|
+
var w = Math.max(1, (idxs.length / max) * plotW);
|
|
218
|
+
|
|
219
|
+
// A PAINTED, full-band hit target: the row is clickable across its whole width, not
|
|
220
|
+
// only where the bar reaches. Same grammar as generated charts, so cross-filter
|
|
221
|
+
// behaves identically whether or not a model was involved.
|
|
222
|
+
var hit = svgEl('rect', { x: 0, y: y, width: W, height: bandH, fill: 'transparent' });
|
|
223
|
+
hit.setAttribute('class', ${lit(MARK_CLASS)});
|
|
224
|
+
hit.setAttribute(${lit(ROW_IDX_ATTR)}, idxs.join(','));
|
|
225
|
+
hit.style.cursor = 'pointer';
|
|
226
|
+
svg.appendChild(hit);
|
|
227
|
+
|
|
228
|
+
var label = svgEl('text', { x: padL - 6, y: y + barH / 2 + CF * 0.36,
|
|
229
|
+
'text-anchor': 'end', 'font-size': CF - 1, fill: FG });
|
|
230
|
+
label.setAttribute('pointer-events', 'none');
|
|
231
|
+
var budget = Math.max(4, Math.floor((padL - 10) / (CF * 0.58)));
|
|
232
|
+
label.textContent = key.length > budget ? key.slice(0, budget - 1) + '\\u2026' : key;
|
|
233
|
+
svg.appendChild(label);
|
|
234
|
+
|
|
235
|
+
var bar = svgEl('rect', { x: padL, y: y, width: w, height: barH,
|
|
236
|
+
fill: ACCENT, 'fill-opacity': 0.85 });
|
|
237
|
+
bar.setAttribute('pointer-events', 'none');
|
|
238
|
+
svg.appendChild(bar);
|
|
239
|
+
|
|
240
|
+
var val = svgEl('text', { x: padL + w + 6, y: y + barH / 2 + CF * 0.36,
|
|
241
|
+
'font-size': CF - 1, fill: FG });
|
|
242
|
+
val.setAttribute('pointer-events', 'none');
|
|
243
|
+
val.textContent = fmt(idxs.length);
|
|
244
|
+
svg.appendChild(val);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
container.appendChild(svg);
|
|
248
|
+
}`;
|
|
249
|
+
}
|
|
250
|
+
function histogramSource(idx, name) {
|
|
251
|
+
return `function render(container, data, options) {${PRELUDE}
|
|
252
|
+
var vals = [];
|
|
253
|
+
for (var r = 0; r < rows.length; r++) {
|
|
254
|
+
var raw = rows[r] ? rows[r][${idx}] : null;
|
|
255
|
+
if (raw === null || raw === undefined || raw === '') continue;
|
|
256
|
+
var n = Number(raw);
|
|
257
|
+
if (isFinite(n)) vals.push({ v: n, r: r });
|
|
258
|
+
}
|
|
259
|
+
var svg = svgEl('svg', { width: W, height: H });
|
|
260
|
+
if (!vals.length) { container.appendChild(svg); return; }
|
|
261
|
+
|
|
262
|
+
var lo = vals[0].v, hi = vals[0].v;
|
|
263
|
+
for (var i = 0; i < vals.length; i++) { if (vals[i].v < lo) lo = vals[i].v; if (vals[i].v > hi) hi = vals[i].v; }
|
|
264
|
+
// A constant column has no distribution to show; one full bar is the honest picture.
|
|
265
|
+
var span = hi - lo;
|
|
266
|
+
var binCount = span === 0 ? 1 : Math.max(4, Math.min(24, Math.round(Math.sqrt(vals.length))));
|
|
267
|
+
var binW = span === 0 ? 1 : span / binCount;
|
|
268
|
+
|
|
269
|
+
var bins = [];
|
|
270
|
+
for (var b = 0; b < binCount; b++) bins.push([]);
|
|
271
|
+
for (var j = 0; j < vals.length; j++) {
|
|
272
|
+
var k = span === 0 ? 0 : Math.floor((vals[j].v - lo) / binW);
|
|
273
|
+
if (k >= binCount) k = binCount - 1; // the maximum lands in the last bin
|
|
274
|
+
if (k < 0) k = 0;
|
|
275
|
+
bins[k].push(vals[j].r);
|
|
276
|
+
}
|
|
277
|
+
var maxCount = 1;
|
|
278
|
+
for (var m = 0; m < bins.length; m++) if (bins[m].length > maxCount) maxCount = bins[m].length;
|
|
279
|
+
|
|
280
|
+
var padL = 8, padR = 8, padT = CF + 12, padB = CF + 14;
|
|
281
|
+
var plotW = Math.max(10, W - padL - padR);
|
|
282
|
+
var plotH = Math.max(10, H - padT - padB);
|
|
283
|
+
var bw = plotW / binCount;
|
|
284
|
+
|
|
285
|
+
var title = svgEl('text', { x: padL, y: CF + 2, 'font-size': CF, 'font-weight': '700', fill: FG });
|
|
286
|
+
title.textContent = ${lit(name)} + ' \\u2014 distribution of ' + fmt(vals.length) + ' values';
|
|
287
|
+
svg.appendChild(title);
|
|
288
|
+
|
|
289
|
+
bins.forEach(function (idxs, i) {
|
|
290
|
+
var h = (idxs.length / maxCount) * plotH;
|
|
291
|
+
var x = padL + i * bw;
|
|
292
|
+
var hit = svgEl('rect', { x: x, y: padT, width: Math.max(1, bw), height: plotH, fill: 'transparent' });
|
|
293
|
+
hit.setAttribute('class', ${lit(MARK_CLASS)});
|
|
294
|
+
hit.setAttribute(${lit(ROW_IDX_ATTR)}, idxs.join(','));
|
|
295
|
+
hit.style.cursor = 'pointer';
|
|
296
|
+
svg.appendChild(hit);
|
|
297
|
+
if (idxs.length) {
|
|
298
|
+
var bar = svgEl('rect', { x: x + 1, y: padT + plotH - h,
|
|
299
|
+
width: Math.max(1, bw - 2), height: h, fill: ACCENT, 'fill-opacity': 0.85 });
|
|
300
|
+
bar.setAttribute('pointer-events', 'none');
|
|
301
|
+
svg.appendChild(bar);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Endpoints only: a deterministic chart should not pretend to a tick strategy it has not
|
|
306
|
+
// earned, and two honest numbers beat a crowded axis.
|
|
307
|
+
var loT = svgEl('text', { x: padL, y: H - 4, 'font-size': CF - 1, fill: FG });
|
|
308
|
+
loT.textContent = compact(lo);
|
|
309
|
+
var hiT = svgEl('text', { x: W - padR, y: H - 4, 'text-anchor': 'end', 'font-size': CF - 1, fill: FG });
|
|
310
|
+
hiT.textContent = compact(hi);
|
|
311
|
+
svg.appendChild(loT); svg.appendChild(hiT);
|
|
312
|
+
|
|
313
|
+
container.appendChild(svg);
|
|
314
|
+
}`;
|
|
315
|
+
}
|
|
316
|
+
|
|
45
317
|
// src/index.ts
|
|
46
318
|
function registerCityTable(packed) {
|
|
47
319
|
U3(packed);
|
|
@@ -74,12 +346,14 @@ export {
|
|
|
74
346
|
buildRenderPayload,
|
|
75
347
|
clearGeoCache,
|
|
76
348
|
compileRenderFn,
|
|
349
|
+
compileTrivialSource,
|
|
77
350
|
createChartHost,
|
|
78
351
|
createMarkResolver,
|
|
79
352
|
explainRenderFailure,
|
|
80
353
|
geoAssetFor,
|
|
81
354
|
geoFromCache,
|
|
82
355
|
loadGeo,
|
|
356
|
+
planTrivialChart,
|
|
83
357
|
registerCityTable,
|
|
84
358
|
registerGeo,
|
|
85
359
|
registerGeoAsset,
|
package/dist/react.mjs
CHANGED
package/dist/types/contract.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const HOST_CONTRACT_VERSION = "1.
|
|
1
|
+
export declare const HOST_CONTRACT_VERSION = "1.3.0";
|
|
2
2
|
export type GeoPointPrecision = "latlon" | "city" | "zip3" | "state" | "country";
|
|
3
3
|
/** Every valid tier, ordered most precise → coarsest. Runtime form of GeoPointPrecision. */
|
|
4
4
|
export declare const GEO_POINT_PRECISIONS: readonly GeoPointPrecision[];
|
package/dist/types/index.d.ts
CHANGED
|
@@ -7,3 +7,4 @@ export declare function registerCityTable(packed: string): void;
|
|
|
7
7
|
export { buildRenderPayload, type RenderPayload, type GeoPointBinding } from "./payload";
|
|
8
8
|
export { createChartHost, compileRenderFn, stripEsmExports, requiredD3Plugins, explainRenderFailure, type ChartHost, type ChartHostConfig, type RenderFn } from "./host";
|
|
9
9
|
export { createMarkResolver, type MarkResolver, type MarkResolverEnv } from "./selection";
|
|
10
|
+
export { planTrivialChart, compileTrivialSource, type TrivialPlan, type TrivialShapeKind } from "./trivial";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { RenderFn } from "./host";
|
|
2
|
+
export type TrivialShapeKind = "card" | "frequency-bar" | "histogram";
|
|
3
|
+
export interface TrivialPlan {
|
|
4
|
+
kind: TrivialShapeKind;
|
|
5
|
+
/** Plain-language reason, written to be shown to the user verbatim. A deterministic
|
|
6
|
+
* render must never be silent about being deterministic — the user pressed a button
|
|
7
|
+
* expecting a generation, and "no credit used" is good news they should receive. */
|
|
8
|
+
reason: string;
|
|
9
|
+
/** Index of the single data column this plan reads. */
|
|
10
|
+
columnIndex: number;
|
|
11
|
+
/** Self-contained `function render(container, data, options)` source. Feed it through
|
|
12
|
+
* the same path as generated code: persist it, compile it, share it. */
|
|
13
|
+
source: string;
|
|
14
|
+
/** The same source, compiled. Convenience for a host that wants to draw immediately. */
|
|
15
|
+
render: RenderFn;
|
|
16
|
+
}
|
|
17
|
+
interface DataLike {
|
|
18
|
+
columns?: Array<{
|
|
19
|
+
name?: string;
|
|
20
|
+
isMeasure?: boolean;
|
|
21
|
+
dataType?: string;
|
|
22
|
+
} | string>;
|
|
23
|
+
rows?: any[][];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Decide whether a dataset has exactly one defensible chart, and return its source when it
|
|
27
|
+
* does. Returns null whenever there is a real choice to make.
|
|
28
|
+
*/
|
|
29
|
+
export declare function planTrivialChart(data: DataLike | null | undefined): TrivialPlan | null;
|
|
30
|
+
/** Compile emitted source the same way a host compiles generated code. */
|
|
31
|
+
export declare function compileTrivialSource(source: string): RenderFn;
|
|
32
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bicharts/chart-host",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.16",
|
|
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",
|