@bicharts/chart-host 0.5.27 → 0.5.29

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,232 @@ 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
+
449
+ // src/reviewDialog.ts
450
+ var DEFAULT_TEXT = {
451
+ title: "Apply improvements?",
452
+ fallbackReason: "The AI review suggests a change to this chart.",
453
+ cost: "Applying creates a new chart version and uses a generation. Declining costs nothing.",
454
+ decline: "No thanks",
455
+ accept: "Apply"
456
+ };
457
+ function askApplyImprovements(container, reason, opts = {}) {
458
+ return new Promise((resolve) => {
459
+ try {
460
+ const minW = opts.minWidth ?? 260;
461
+ const minH = opts.minHeight ?? 170;
462
+ const w = container.clientWidth || container.offsetWidth || 0;
463
+ const h = container.clientHeight || container.offsetHeight || 0;
464
+ if (w < minW || h < minH) {
465
+ resolve(false);
466
+ return;
467
+ }
468
+ const text = { ...DEFAULT_TEXT, ...opts.text ?? {} };
469
+ const doc = container.ownerDocument;
470
+ const ov = doc.createElement("div");
471
+ ov.id = opts.id ?? "bic-review-ask-overlay";
472
+ if (opts.overlayClass) ov.className = opts.overlayClass;
473
+ else ov.style.cssText = "position:absolute;inset:0;background:rgba(0,0,0,0.55);z-index:110;";
474
+ ov.style.display = "flex";
475
+ ov.style.alignItems = "center";
476
+ ov.style.justifyContent = "center";
477
+ const card = doc.createElement("div");
478
+ if (opts.cardClass) card.className = opts.cardClass;
479
+ else card.style.cssText = "background:#ffffff;color:#1f2937;border-radius:10px;box-shadow:0 6px 24px rgba(0,0,0,0.22);";
480
+ card.style.width = "auto";
481
+ card.style.height = "auto";
482
+ card.style.maxWidth = "min(420px, 90%)";
483
+ card.style.maxHeight = "90%";
484
+ card.style.overflow = "auto";
485
+ card.style.padding = "16px 18px";
486
+ card.style.display = "flex";
487
+ card.style.flexDirection = "column";
488
+ card.style.gap = "10px";
489
+ card.style.boxSizing = "border-box";
490
+ const head = doc.createElement("div");
491
+ head.style.cssText = "font:600 13px 'Segoe UI',system-ui,sans-serif;";
492
+ head.textContent = text.title;
493
+ const why = doc.createElement("div");
494
+ why.style.cssText = "font:400 12px/1.45 'Segoe UI',system-ui,sans-serif;white-space:normal;";
495
+ why.textContent = reason || text.fallbackReason;
496
+ const cost = doc.createElement("div");
497
+ cost.style.cssText = "font:400 11px/1.4 'Segoe UI',system-ui,sans-serif;opacity:0.75;white-space:normal;";
498
+ cost.textContent = text.cost;
499
+ const row = doc.createElement("div");
500
+ row.style.cssText = "display:flex;gap:8px;justify-content:flex-end;margin-top:4px;";
501
+ const no = doc.createElement("button");
502
+ no.type = "button";
503
+ no.textContent = text.decline;
504
+ const yes = doc.createElement("button");
505
+ yes.type = "button";
506
+ yes.textContent = text.accept;
507
+ yes.style.cssText = "font-weight:600;";
508
+ let done = false;
509
+ const finish = (v) => {
510
+ if (done) return;
511
+ done = true;
512
+ try {
513
+ if (ov.parentElement) ov.parentElement.removeChild(ov);
514
+ } catch {
515
+ }
516
+ doc.removeEventListener("keydown", onKey, true);
517
+ resolve(v);
518
+ };
519
+ const onKey = (e) => {
520
+ if (e.key === "Escape") {
521
+ e.stopPropagation();
522
+ finish(false);
523
+ }
524
+ };
525
+ no.addEventListener("click", () => finish(false));
526
+ yes.addEventListener("click", () => finish(true));
527
+ ov.addEventListener("click", (e) => {
528
+ if (e.target === ov) finish(false);
529
+ });
530
+ card.addEventListener("click", (e) => e.stopPropagation());
531
+ doc.addEventListener("keydown", onKey, true);
532
+ row.append(no, yes);
533
+ card.append(head, why, cost, row);
534
+ ov.appendChild(card);
535
+ container.appendChild(ov);
536
+ try {
537
+ yes.focus();
538
+ } catch {
539
+ }
540
+ } catch {
541
+ resolve(false);
542
+ }
543
+ });
544
+ }
545
+
320
546
  // src/index.ts
321
547
  function registerCityTable(packed) {
322
548
  L3(packed);
@@ -349,7 +575,12 @@ export {
349
575
  ROW_IDX_ATTR,
350
576
  SELECTION_ACTIVE_CLASS,
351
577
  XFILTER_REFRESH_EVENT,
578
+ actionFor,
579
+ askApplyImprovements,
580
+ bareBase64,
352
581
  buildRenderPayload,
582
+ buildReviewWire,
583
+ captureSvgSnapshot,
353
584
  clearGeoCache,
354
585
  compileRenderFn,
355
586
  compileTrivialSource,
@@ -360,10 +591,14 @@ export {
360
591
  geoFromCache,
361
592
  loadGeo,
362
593
  planTrivialChart,
594
+ rasterizeSvgToPngDataUrl,
363
595
  registerCityTable,
364
596
  registerGeo,
365
597
  registerGeoAsset,
366
598
  requiredD3Plugins,
367
599
  resolveOptions,
368
- stripEsmExports
600
+ shouldReview,
601
+ stripEsmExports,
602
+ svgNaturalSize,
603
+ svgToDataUrl
369
604
  };
@@ -8,3 +8,6 @@ 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";
13
+ export { askApplyImprovements, type ReviewDialogOptions, type ReviewDialogText } from "./reviewDialog";
@@ -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,23 @@
1
+ export interface ReviewDialogText {
2
+ title?: string;
3
+ /** Shown when the judge supplied no reason. The judge's reason always wins when present. */
4
+ fallbackReason?: string;
5
+ cost?: string;
6
+ decline?: string;
7
+ accept?: string;
8
+ }
9
+ export interface ReviewDialogOptions {
10
+ /** Overlay element id. Stable so tests and log-readers can find the same dialog anywhere. */
11
+ id?: string;
12
+ /** Host chrome. When given they are ADDED to the inline baseline, so a host theme (e.g. a
13
+ * dark-mode card background) can win where it should while the layout stays put. */
14
+ overlayClass?: string;
15
+ cardClass?: string;
16
+ /** The too-small-to-ask floor. Below it the dialog is a clipped smear over the chart, and
17
+ * asking illegibly for permission to spend money is worse than not asking. */
18
+ minWidth?: number;
19
+ minHeight?: number;
20
+ text?: ReviewDialogText;
21
+ }
22
+ /** Resolves true ONLY on a deliberate click of Apply. Never rejects. */
23
+ export declare function askApplyImprovements(container: HTMLElement, reason: string, opts?: ReviewDialogOptions): Promise<boolean>;
@@ -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.29",
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",