@bicharts/chart-host 0.5.27 → 0.5.28

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/index.mjs CHANGED
@@ -317,6 +317,135 @@ function histogramSource(idx, name) {
317
317
  }`;
318
318
  }
319
319
 
320
+ // src/snapshot.ts
321
+ var DEFAULT_TIMEOUT_MS = 2e3;
322
+ function svgToDataUrl(svg) {
323
+ const cloned = svg.cloneNode(true);
324
+ const w = svg.clientWidth || svg.getBoundingClientRect && svg.getBoundingClientRect().width || 600;
325
+ const h = svg.clientHeight || svg.getBoundingClientRect && svg.getBoundingClientRect().height || 400;
326
+ cloned.setAttribute("width", String(Math.round(w)));
327
+ cloned.setAttribute("height", String(Math.round(h)));
328
+ if (!cloned.getAttribute("xmlns")) cloned.setAttribute("xmlns", "http://www.w3.org/2000/svg");
329
+ const svgStr = new XMLSerializer().serializeToString(cloned);
330
+ const bytes = new TextEncoder().encode(svgStr);
331
+ let binary = "";
332
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
333
+ return "data:image/svg+xml;base64," + btoa(binary);
334
+ }
335
+ function svgNaturalSize(svg) {
336
+ const w = svg.clientWidth || svg.getBoundingClientRect && svg.getBoundingClientRect().width || 600;
337
+ const h = svg.clientHeight || svg.getBoundingClientRect && svg.getBoundingClientRect().height || 400;
338
+ return { width: Math.round(w), height: Math.round(h) };
339
+ }
340
+ function rasterizeSvgToPngDataUrl(svgDataUrl, width, height, opts = {}) {
341
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
342
+ return new Promise((resolve) => {
343
+ let img;
344
+ try {
345
+ img = new Image();
346
+ } catch (e) {
347
+ opts.onWarn?.("snapshot-no-image", e instanceof Error ? e.message : String(e));
348
+ resolve(null);
349
+ return;
350
+ }
351
+ const timer = setTimeout(() => {
352
+ opts.onWarn?.("snapshot-raster-timeout", { width, height, timeoutMs });
353
+ resolve(null);
354
+ }, timeoutMs);
355
+ img.onload = () => {
356
+ clearTimeout(timer);
357
+ try {
358
+ const cap = opts.maxSide && opts.maxSide > 0 ? opts.maxSide : Math.max(width, height);
359
+ const scale = Math.min(1, cap / Math.max(width, height));
360
+ const cw = Math.max(1, Math.round(width * scale));
361
+ const ch = Math.max(1, Math.round(height * scale));
362
+ const canvas = document.createElement("canvas");
363
+ canvas.width = cw;
364
+ canvas.height = ch;
365
+ const c2d = canvas.getContext("2d");
366
+ if (!c2d) {
367
+ resolve(null);
368
+ return;
369
+ }
370
+ c2d.fillStyle = "white";
371
+ c2d.fillRect(0, 0, cw, ch);
372
+ c2d.drawImage(img, 0, 0, cw, ch);
373
+ resolve(canvas.toDataURL("image/png"));
374
+ } catch (e) {
375
+ opts.onWarn?.("snapshot-raster-draw", e instanceof Error ? e.message : String(e));
376
+ resolve(null);
377
+ }
378
+ };
379
+ img.onerror = () => {
380
+ clearTimeout(timer);
381
+ opts.onWarn?.("snapshot-raster-decode", { width, height });
382
+ resolve(null);
383
+ };
384
+ img.src = svgDataUrl;
385
+ });
386
+ }
387
+ async function captureSvgSnapshot(root, opts = {}) {
388
+ if (!root) return null;
389
+ const svg = root instanceof SVGSVGElement ? root : root.querySelector("svg");
390
+ if (!svg) return null;
391
+ let svgUrl;
392
+ try {
393
+ svgUrl = svgToDataUrl(svg);
394
+ } catch (e) {
395
+ opts.onWarn?.("snapshot-serialize", e instanceof Error ? e.message : String(e));
396
+ return null;
397
+ }
398
+ const { width, height } = svgNaturalSize(svg);
399
+ try {
400
+ const png = await rasterizeSvgToPngDataUrl(svgUrl, width, height, opts);
401
+ return png || svgUrl;
402
+ } catch (e) {
403
+ opts.onWarn?.("snapshot-raster-unexpected", e instanceof Error ? e.message : String(e));
404
+ return svgUrl;
405
+ }
406
+ }
407
+
408
+ // src/review.ts
409
+ function shouldReview(g) {
410
+ if (!g.enabled) return false;
411
+ if (!g.modeLicensed) return false;
412
+ if (!g.firstRenderAfterGenerate) return false;
413
+ if (!g.codeVersion || g.codeVersion <= 0) return false;
414
+ if (g.inFlight) return false;
415
+ if (g.reviewedVersions.has(g.codeVersion)) return false;
416
+ return true;
417
+ }
418
+ function bareBase64(dataUrlOrB64) {
419
+ const m = /^data:([^;,]+);base64,(.*)$/s.exec(dataUrlOrB64 || "");
420
+ if (m) return { b64: m[2], mediaType: m[1] };
421
+ return { b64: dataUrlOrB64 || "", mediaType: "image/png" };
422
+ }
423
+ function buildReviewWire(input) {
424
+ const { b64, mediaType } = bareBase64(input.capture);
425
+ return {
426
+ request: input.request,
427
+ imageBase64: b64,
428
+ imageMediaType: mediaType,
429
+ version: input.version,
430
+ originalAsk: (input.originalAsk ?? "").toString(),
431
+ codeSummary: (input.codeSummary ?? "").toString(),
432
+ chartType: (input.chartType ?? "").toString(),
433
+ correlationId: (input.correlationId ?? "").toString()
434
+ };
435
+ }
436
+ function actionFor(v) {
437
+ if (!v) return { kind: "keep", reason: "no verdict returned", userVisible: false };
438
+ if (v.status === "PROPOSED") {
439
+ const instr = (v.instruction ?? "").toString().trim();
440
+ if (instr === "") return { kind: "keep", reason: "reviewer proposed no actionable change", userVisible: false };
441
+ return { kind: "propose", instruction: instr, reason: (v.verdictReason ?? "").toString().trim() };
442
+ }
443
+ if (v.status === "OK" && v.fix) return { kind: "apply-fix", fix: v.fix, reason: v.verdictReason ?? "" };
444
+ if (v.status === "NOCHANGE") return { kind: "keep", reason: v.verdictReason ?? "the chart renders as asked", userVisible: false };
445
+ if (v.status === "REFUSED") return { kind: "keep", reason: v.errorMessage ?? "review not available", userVisible: false };
446
+ return { kind: "keep", reason: v.errorMessage ?? v.verdictReason ?? "review failed", userVisible: false };
447
+ }
448
+
320
449
  // src/index.ts
321
450
  function registerCityTable(packed) {
322
451
  L3(packed);
@@ -349,7 +478,11 @@ export {
349
478
  ROW_IDX_ATTR,
350
479
  SELECTION_ACTIVE_CLASS,
351
480
  XFILTER_REFRESH_EVENT,
481
+ actionFor,
482
+ bareBase64,
352
483
  buildRenderPayload,
484
+ buildReviewWire,
485
+ captureSvgSnapshot,
353
486
  clearGeoCache,
354
487
  compileRenderFn,
355
488
  compileTrivialSource,
@@ -360,10 +493,14 @@ export {
360
493
  geoFromCache,
361
494
  loadGeo,
362
495
  planTrivialChart,
496
+ rasterizeSvgToPngDataUrl,
363
497
  registerCityTable,
364
498
  registerGeo,
365
499
  registerGeoAsset,
366
500
  requiredD3Plugins,
367
501
  resolveOptions,
368
- stripEsmExports
502
+ shouldReview,
503
+ stripEsmExports,
504
+ svgNaturalSize,
505
+ svgToDataUrl
369
506
  };
@@ -8,3 +8,5 @@ export { buildRenderPayload, type RenderPayload, type GeoPointBinding } from "./
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
10
  export { planTrivialChart, compileTrivialSource, type TrivialPlan, type TrivialShapeKind } from "./trivial";
11
+ export { captureSvgSnapshot, svgToDataUrl, svgNaturalSize, rasterizeSvgToPngDataUrl, type SnapshotOptions } from "./snapshot";
12
+ export { shouldReview, buildReviewWire, bareBase64, actionFor, type ReviewGate, type ReviewWire, type ReviewVerdict, type ReviewAction } from "./review";
@@ -0,0 +1,89 @@
1
+ /** The facts a "should we review this render?" decision needs, named so policy reads in one place. */
2
+ export interface ReviewGate {
3
+ /** The user's settings toggle. Reviews are strictly opt-in. */
4
+ enabled: boolean;
5
+ /** Licensed accounts only — the host checks its own credential shape and says so here. */
6
+ modeLicensed: boolean;
7
+ /**
8
+ * True when THIS render is the first paint after a generate this session. A cached
9
+ * restore, a resize, a cross-filter repaint — none of these are "the first render from a
10
+ * generate", and reviewing them would judge the same pixels twice.
11
+ */
12
+ firstRenderAfterGenerate: boolean;
13
+ /** The version on screen. 0/undefined = nothing to review. */
14
+ codeVersion: number | null | undefined;
15
+ /**
16
+ * Versions this session already submitted. Guards re-entrancy: an applied fix's own first
17
+ * render must not trigger a second review of the fix — a fix of a fix of a fix is a
18
+ * billing loop.
19
+ */
20
+ reviewedVersions: ReadonlySet<number>;
21
+ /** A review already in flight for this instance. */
22
+ inFlight: boolean;
23
+ }
24
+ /** Should a review fire for this render? Every clause is a reason NOT to, because the free
25
+ * direction is "no review". */
26
+ export declare function shouldReview(g: ReviewGate): boolean;
27
+ /** The review endpoint's wire body. `request` is the SAME generate request the host would send
28
+ * for a modify of the reviewed version — the service writes the fix intent into it, so an
29
+ * applied fix is provably an ordinary modify of exactly this request. */
30
+ export interface ReviewWire {
31
+ request: unknown;
32
+ imageBase64: string;
33
+ imageMediaType: string;
34
+ version: number;
35
+ originalAsk: string;
36
+ codeSummary: string;
37
+ chartType: string;
38
+ correlationId: string;
39
+ /**
40
+ * THE SECOND HALF of the propose/accept round-trip. Absent on the judging call; set to the
41
+ * proposal's instruction — verbatim — when the user accepted. Its presence is the whole
42
+ * difference between a free judgement and a billed modify.
43
+ */
44
+ applyInstruction?: string;
45
+ }
46
+ /** Strip a data-URL prefix if the capture produced one; the wire carries bare base64. */
47
+ export declare function bareBase64(dataUrlOrB64: string): {
48
+ b64: string;
49
+ mediaType: string;
50
+ };
51
+ export declare function buildReviewWire(input: {
52
+ request: unknown;
53
+ capture: string;
54
+ version: number;
55
+ originalAsk: string | null | undefined;
56
+ codeSummary: string | null | undefined;
57
+ chartType: string | null | undefined;
58
+ correlationId: string | null | undefined;
59
+ }): ReviewWire;
60
+ /** What the service answers, camelCase on the wire. */
61
+ export interface ReviewVerdict {
62
+ status: "PROPOSED" | "OK" | "NOCHANGE" | "REFUSED" | "ERROR" | string;
63
+ verdictReason?: string | null;
64
+ /** Set only on PROPOSED: the judge's instruction, sent back verbatim if the user accepts. */
65
+ instruction?: string | null;
66
+ /** A full generate result when status === "OK": the fix IS a modify. */
67
+ fix?: unknown;
68
+ errorMessage?: string | null;
69
+ }
70
+ /** How the host should react to a verdict:
71
+ * - propose → ASK THE USER; nothing has been charged and declining stays free;
72
+ * - apply-fix → the accepted round-trip answered with a finished result — apply and re-render;
73
+ * - keep → chart untouched; log the reason. A REFUSED never surfaces as a user error:
74
+ * the toggle was on for an account that cannot use the feature, and the chart
75
+ * is fine. */
76
+ export type ReviewAction = {
77
+ kind: "propose";
78
+ instruction: string;
79
+ reason: string;
80
+ } | {
81
+ kind: "apply-fix";
82
+ fix: unknown;
83
+ reason: string;
84
+ } | {
85
+ kind: "keep";
86
+ reason: string;
87
+ userVisible: boolean;
88
+ };
89
+ export declare function actionFor(v: ReviewVerdict | null | undefined): ReviewAction;
@@ -0,0 +1,32 @@
1
+ export interface SnapshotOptions {
2
+ /**
3
+ * Cap the raster's longest side, preserving aspect. Keeps a giant viewport from producing
4
+ * a multi-megabyte PNG. 0 = no cap (raster at the SVG's natural size).
5
+ */
6
+ maxSide?: number;
7
+ /** Deadline for the SVG decode, in ms. Past it the raster resolves null. */
8
+ timeoutMs?: number;
9
+ /** Diagnostic sink for the silent-failure paths. */
10
+ onWarn?: (event: string, detail?: unknown) => void;
11
+ }
12
+ /**
13
+ * Serialize an SVG element to a `data:image/svg+xml;base64,` URL, with intrinsic
14
+ * width/height inlined on a clone so a later Image() load knows its size.
15
+ */
16
+ export declare function svgToDataUrl(svg: SVGSVGElement): string;
17
+ /** The measured drawing size of an SVG, with the same fallbacks svgToDataUrl inlines. */
18
+ export declare function svgNaturalSize(svg: SVGSVGElement): {
19
+ width: number;
20
+ height: number;
21
+ };
22
+ /**
23
+ * Rasterize an SVG data URL to a PNG data URL. Resolves null on any failure — decode error,
24
+ * deadline, tainted canvas — and never rejects.
25
+ */
26
+ export declare function rasterizeSvgToPngDataUrl(svgDataUrl: string, width: number, height: number, opts?: SnapshotOptions): Promise<string | null>;
27
+ /**
28
+ * Capture the first <svg> under `root` (or the element itself) as a PNG data URL, falling
29
+ * back to the SVG data URL when rasterizing is unavailable, and to null only when there is
30
+ * genuinely nothing on the canvas to capture.
31
+ */
32
+ export declare function captureSvgSnapshot(root: Element | null | undefined, opts?: SnapshotOptions): Promise<string | null>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bicharts/chart-host",
3
- "version": "0.5.27",
3
+ "version": "0.5.28",
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",