@ndbx/runtime
Version:
The `@ndbx/runtime` package provides a runtime environment to embed NodeBox visualizations directly into React applications. NodeBox is a powerful tool for creating interactive and generative visualizations, and this runtime allows you to integrate those
196 lines (174 loc) • 6.01 kB
text/typescript
import React, { ReactElement } from "react";
import ReactDOMServer from "react-dom/server";
import { Bounds, Context as GraphicsContext } from "@ndbx/g";
import { Spec } from "vega";
import { Item, Network, Color } from "./types";
import { renderDefs, renderShape, renderVegaSpec } from "./render";
function isVegaSpec(value: unknown): value is Spec {
return typeof value === "object" && value !== null && "$schema" in (value as Record<string, unknown>);
}
function colorToCss(color: Color): string {
const r = (v: number) => Math.round(v * 255);
return color.a === 1
? `rgb(${r(color.r)} ${r(color.g)} ${r(color.b)})`
: `rgb(${r(color.r)} ${r(color.g)} ${r(color.b)} / ${color.a})`;
}
function colorIsTransparent(color?: Color): boolean {
if (!color || typeof color.a !== "number") return true;
return color.a === 0;
}
export interface RenderOptions {
drawPoints?: boolean;
includeBackground?: boolean; // default: true if background is non-transparent
}
export interface PngOptions extends RenderOptions {
scale?: number; // default: 2
}
export function renderItemToSvgElement(
item: Item,
resultValue: unknown,
options: RenderOptions = {},
): ReactElement | undefined {
const includeBackground = options.includeBackground ?? true;
// Normalize the result to a drawable shape or React element
let shapeOrElement: any = resultValue;
if (isVegaSpec(resultValue)) {
shapeOrElement = renderVegaSpec(resultValue);
}
const graphicsContext = new GraphicsContext();
let shapeElement: ReactElement | null = null;
let defsElement: ReactElement | null = null;
try {
if (shapeOrElement && (shapeOrElement.type || React.isValidElement(shapeOrElement))) {
if (React.isValidElement(shapeOrElement)) {
shapeElement = shapeOrElement as ReactElement;
} else {
shapeElement = renderShape(shapeOrElement, graphicsContext, undefined, !!options.drawPoints) as any;
}
const defs = renderDefs(graphicsContext);
defsElement = defs as any;
}
} catch (e) {
console.error("renderItemToSvgElement: render error", e);
return undefined;
}
if (!shapeElement) return undefined;
// Determine SVG size and origin
let left = 0,
top = 0,
width = (item.width ?? 1000) as number,
height = (item.height ?? 1000) as number;
const maybeNetwork = item as Network;
if (typeof (shapeOrElement as any)?.getBounds === "function" && maybeNetwork.canvasSize === "auto") {
const auto: Bounds = (shapeOrElement as any).getBounds();
left = auto.left;
top = auto.top;
width = auto.right - auto.left;
height = auto.bottom - auto.top;
}
const backgroundColor = item.background;
const svg = React.createElement(
"svg",
{ width, height, xmlns: "http://www.w3.org/2000/svg" },
React.createElement(
"defs",
{},
React.createElement("clipPath", { id: "frame" }, React.createElement("rect", { x: left, y: top, width, height })),
),
React.createElement(
"g",
{
clipPath: "url(#frame)",
transform: `translate(${-left}, ${-top})`,
},
includeBackground && !colorIsTransparent(backgroundColor)
? React.createElement("rect", {
x: left,
y: top,
width,
height,
fill: colorToCss(backgroundColor),
})
: null,
shapeElement,
defsElement,
),
);
return svg;
}
export function renderItemToSvgString(
item: Item,
resultValue: unknown,
options: RenderOptions = {},
): string | undefined {
const element = renderItemToSvgElement(item, resultValue, options);
if (!element) return undefined;
return ReactDOMServer.renderToStaticMarkup(element);
}
export async function svgStringToPngBlob(
svgString: string,
width: number,
height: number,
options: PngOptions = {},
): Promise<Blob> {
const scale = options.scale ?? 2;
return new Promise<Blob>((resolve, reject) => {
try {
const img = new Image();
const svgBlob = new Blob([svgString], { type: "image/svg+xml" });
const svgUrl = URL.createObjectURL(svgBlob);
img.onload = () => {
try {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(width * scale));
canvas.height = Math.max(1, Math.round(height * scale));
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2D context not available");
ctx.scale(scale, scale);
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
URL.revokeObjectURL(svgUrl);
if (!blob) return reject(new Error("PNG blob creation failed"));
resolve(blob);
}, "image/png");
} catch (err) {
URL.revokeObjectURL(svgUrl);
reject(err);
}
};
img.onerror = (e) => {
URL.revokeObjectURL(svgUrl);
reject(new Error("Failed to load SVG into image"));
};
img.src = svgUrl;
} catch (e) {
reject(e as Error);
}
});
}
export async function renderItemToPngBlob(
item: Item,
resultValue: unknown,
options: PngOptions = {},
): Promise<Blob | undefined> {
// First, render to SVG and capture final dimensions
const maybeNetwork = item as Network;
let left = 0,
top = 0,
width = (item.width ?? 1000) as number,
height = (item.height ?? 1000) as number;
let shapeOrElement: any = resultValue;
if (isVegaSpec(resultValue)) {
shapeOrElement = renderVegaSpec(resultValue);
}
if (typeof (shapeOrElement as any)?.getBounds === "function" && maybeNetwork.canvasSize === "auto") {
const auto: Bounds = (shapeOrElement as any).getBounds();
left = auto.left;
top = auto.top;
width = auto.right - auto.left;
height = auto.bottom - auto.top;
}
const svg = renderItemToSvgString(item, resultValue, options);
if (!svg) return undefined;
return svgStringToPngBlob(svg, width, height, options);
}