UNPKG

@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

618 lines (569 loc) 16.9 kB
import { Shape, Group, Paint, SolidPaint, Path, Circle, Rect, Text, Point, Line } from "@ndbx/g"; import * as vega from "vega"; import { curveBasis, curveBasisClosed, curveBasisOpen, curveBundle, curveCardinal, curveCardinalClosed, curveCardinalOpen, curveCatmullRom, curveCatmullRomClosed, curveCatmullRomOpen, curveLinear, curveLinearClosed, curveMonotoneX, curveMonotoneY, curveNatural, curveStep, curveStepAfter, curveStepBefore, CurveFactory, CurveGenerator, } from "d3-shape"; const HalfPi = Math.PI / 2; const HalfSqrt3 = Math.sqrt(3) / 2; const Tan30 = 0.5773502691896257; type InterpolationMode = | "basis" | "basis-closed" | "basis-open" | "bundle" | "cardinal" | "cardinal-open" | "cardinal-closed" | "catmull-rom" | "catmull-rom-closed" | "catmull-rom-open" | "linear" | "linear-closed" | "monotone" | "natural" | "step" | "step-after" | "step-before"; interface VegaShape { context: (path: Path) => (item: VegaItem) => void; } interface VegaBounds { x1: number; y1: number; x2: number; y2: number; } interface VegaPadding { top: number; bottom: number; left: number; right: number; } interface VegaScene { root: VegaItem; } interface VegaItem { bounds: VegaBounds; marktype: string; items: VegaItem[]; x: number; y: number; role: string; } interface VegaVisualItem extends VegaItem { fill: string; stroke: string; strokeWidth: number; opacity: number; fillOpacity?: number; strokeOpacity?: number; } interface VegaArcItem extends VegaVisualItem { innerRadius: number; outerRadius: number; startAngle: number; endAngle: number; } interface VegaAreaItem extends VegaVisualItem { orient: "horizontal" | "vertical"; interpolate: InterpolationMode; x2: number; y2: number; } interface VegaGroupItem extends VegaItem { width: number; height: number; fill: string; clip?: boolean; } interface VegaLineItem extends VegaVisualItem { interpolate: InterpolationMode; tension?: number; orient?: "horizontal" | "vertical"; } interface VegaPathItem extends VegaVisualItem { path: string; } interface VegaRectItem extends VegaVisualItem { width: number; height: number; } interface VegaSymbolItem extends VegaVisualItem { shape: string; innerRadius: number; size: number; } interface VegaTextItem extends VegaVisualItem { angle: number; align: "left" | "center" | "right"; baseline: "top" | "middle" | "bottom" | "line-top" | "line-bottom"; dx?: number; dy?: number; font: string; fontSize: number; fontWeight: "normal" | "bold"; lineHeight?: number; limit: number; radius?: number; text: string; theta?: number; } interface VegaRuleItem extends VegaVisualItem { x2: number; y2: number; } interface VegaShapeItem extends VegaVisualItem { shape: VegaShape; } const TEXT_ALIGN_MAP = { left: "start", center: "middle", right: "end", }; // prettier-ignore const INTERPOLATION_MAP = { "basis": { curve: curveBasis }, "basis-closed": { curve: curveBasisClosed }, "basis-open": { curve: curveBasisOpen }, "bundle": { curve: curveBundle, tension: "beta", value: 0.85 }, "cardinal": { curve: curveCardinal, tension: "tension", value: 0 }, "cardinal-open": { curve: curveCardinalOpen, tension: "tension", value: 0 }, "cardinal-closed": { curve: curveCardinalClosed, tension: "tension", value: 0 }, "catmull-rom": { curve: curveCatmullRom, tension: "alpha", value: 0.5 }, "catmull-rom-closed": { curve: curveCatmullRomClosed, tension: "alpha", value: 0.5 }, "catmull-rom-open": { curve: curveCatmullRomOpen, tension: "alpha", value: 0.5 }, "linear": { curve: curveLinear }, "linear-closed": { curve: curveLinearClosed }, "monotone": { horizontal: curveMonotoneX, vertical: curveMonotoneY }, "natural": { curve: curveNatural }, "step": { curve: curveStep }, "step-after": { curve: curveStepAfter }, "step-before": { curve: curveStepBefore }, }; export function vegaToShape(spec: vega.Spec) { const flow = vega.parse(spec); const view = new vega.View(flow); view.run(); view.finalize(); const scenegraph = view.scenegraph() as unknown as VegaScene; const [dx, dy] = view.origin(); const { top, left, bottom, right } = view.padding() as VegaPadding; const root = new Group(); const bounds = scenegraph.root.bounds; const totalWidth = Math.ceil(bounds.x2 - bounds.x1 + left + right); const totalHeight = Math.ceil(bounds.y2 - bounds.y1 + top + bottom); const background = new Rect(0, 0, totalWidth, totalHeight); const viewBackground = view.background(); background.fill = viewBackground ? Paint.parse(viewBackground) : Paint.white(); root.add(background); const group = convertMarkDefinition(scenegraph.root); group.transform.translate(dx + left, dy + top); root.add(group); return root; } // From the docs: // "The levels of the tree alternate between an enclosing mark definition and contained sets of mark instances called items." // This means we have two functions: convertMarkDefinition and convertXXXInstance for specific instances. function convertMarkDefinition(def: VegaItem) { const group = new Group(); group.tags = [`mark-${def.marktype}`]; switch (def.marktype) { case "arc": for (const item of def.items) { group.add(convertArcInstance(item as VegaArcItem)); } break; case "area": { const path = convertArea(def); group.add(path); break; } case "group": if (def.role) { group.tags.push(`role-${def.role}`); } for (const item of def.items) { group.add(convertGroupInstance(item as VegaGroupItem)); } break; case "line": { const path = convertLine(def); group.add(path); break; } case "path": for (const item of def.items) { group.add(convertPathInstance(item as VegaPathItem)); } break; case "rect": for (const item of def.items) { group.add(convertRectInstance(item as VegaRectItem)); } break; case "rule": for (const item of def.items) { group.add(convertRuleInstance(item as VegaRuleItem)); } break; case "shape": for (const item of def.items) { group.add(convertShapeInstance(item as VegaShapeItem)); } break; case "symbol": for (const item of def.items) { group.add(convertSymbolInstance(item as VegaSymbolItem)); } break; case "text": for (const item of def.items) { group.add(convertTextInstance(item as VegaTextItem)); } break; default: console.warn(`Unknown marktype ${def.marktype}`); } return group; } function convertArcInstance(instance: VegaArcItem): Shape { const path = new Path(); path.complexArc( instance.x, instance.y, instance.innerRadius, instance.outerRadius, instance.startAngle, instance.endAngle, ); applyStyles(path, instance); return path; } function convertArea(def: VegaItem): Shape { if (def.items.length < 2) { return new Path(); } const firstItem = def.items[0] as VegaAreaItem; const [path, curve] = createCurve(firstItem.interpolate, firstItem.orient); const n = def.items.length; const isHorizontal = firstItem.orient === "horizontal"; curve.areaStart(); curve.lineStart(); // Draw top line for (let i = 0; i < n; i++) { const instance = def.items[i] as VegaAreaItem; curve.point(instance.x, instance.y); } curve.lineEnd(); // Draw bottom line in reverse curve.lineStart(); for (let i = n - 1; i >= 0; i--) { const instance = def.items[i] as VegaAreaItem; const baseline = isHorizontal ? instance.x2 : instance.y2; curve.point(instance.x, baseline); } curve.lineEnd(); curve.areaEnd(); applyStyles(path, firstItem); return path; } function convertGroupInstance(instance: VegaGroupItem): Shape { const group = new Group(); // Add background if (instance.fill) { const background = new Rect(0, 0, instance.width, instance.height); background.fill = Paint.parse(instance.fill); group.add(background); } let tx = instance.x || 0; let ty = instance.y || 0; if (tx || ty) { group.transform.translate(tx, ty); } if (instance.role) { group.tags = [instance.role]; } if (instance.clip) { const clipRect = new Rect(0, 0, instance.width, instance.height); group.clip(clipRect); } for (const item of instance.items) { group.add(convertMarkDefinition(item)); } return group; } function convertLine(def: VegaItem): Shape { if (def.items.length < 2) { return new Path(); } const firstItem = def.items[0] as VegaLineItem; const [path, curve] = createCurve(firstItem.interpolate, firstItem.orient); curve.lineStart(); for (let i = 0; i < def.items.length; i++) { const instance = def.items[i]; curve.point(instance.x, instance.y); } curve.lineEnd(); applyStyles(path, firstItem); return path; } function convertPathInstance(instance: VegaPathItem): Shape { if (!instance.path) return new Path(); const path = Path.fromPathData(instance.path); path.fill = Paint.none(); let { x, y } = instance; if ((x ?? 0) !== 0 || (y ?? 0) !== 0) { path.transform.translate(x, y); } applyStyles(path, instance); return path; } function convertRectInstance(instance: VegaRectItem): Shape { const rect = new Rect(instance.x, instance.y, instance.width, instance.height); return applyStyles(rect, instance); } function convertRuleInstance(instance: VegaRuleItem): Shape { let x2 = instance.x; let y2 = instance.y; if (typeof instance.x2 === "number") x2 = instance.x2; if (typeof instance.y2 === "number") y2 = instance.y2; const line = new Line(instance.x, instance.y, x2, y2); if (instance.stroke) line.stroke = Paint.parse(instance.stroke); if (instance.strokeWidth) line.strokeWidth = instance.strokeWidth; if (instance.opacity) { const stroke = line.stroke as Paint; if (stroke.type === "solid") { (stroke as SolidPaint).a = instance.opacity; } } return line; } function convertShapeInstance(instance: VegaShapeItem): Shape { const path = new Path(); instance.shape.context(path)(instance); applyStyles(path, instance); return path; } function convertSymbolInstance(instance: VegaSymbolItem): Shape { const size = instance.size; const shape = instance.shape || "circle"; switch (shape) { case "circle": { const r = Math.sqrt(size) / 2; const circle = new Circle(0, 0, r); return applyStylesAndTransform(circle, instance); } case "cross": { const r = Math.sqrt(size) / 2; const s = r / 2.5; const path = new Path(); path.moveTo(-r, -s); path.lineTo(-r, s); path.lineTo(-s, s); path.lineTo(-s, r); path.lineTo(s, r); path.lineTo(s, s); path.lineTo(r, s); path.lineTo(r, -s); path.lineTo(s, -s); path.lineTo(s, -r); path.lineTo(-s, -r); path.lineTo(-s, -s); path.close(); return applyStylesAndTransform(path, instance); } case "diamond": { const r = Math.sqrt(size) / 2; const path = new Path(); path.moveTo(-r, 0); path.lineTo(0, -r); path.lineTo(r, 0); path.lineTo(0, r); path.close(); return applyStylesAndTransform(path, instance); } case "square": { const w = Math.sqrt(size); const x = -w / 2; const rect = new Rect(x, x, w, w); return applyStylesAndTransform(rect, instance); } case "arrow": { const r = Math.sqrt(size) / 2; const s = r / 7; const t = r / 2.5; const v = r / 8; const path = new Path(); path.moveTo(-s, r); path.lineTo(s, r); path.lineTo(s, -v); path.lineTo(t, -v); path.lineTo(0, -r); path.lineTo(-t, -v); path.lineTo(-s, -v); path.close(); return applyStylesAndTransform(path, instance); } case "wedge": { const r = Math.sqrt(size) / 2; const h = HalfSqrt3 * r; const o = h - r * Tan30; const b = r / 4; const path = new Path(); path.moveTo(0, -h - o); path.lineTo(-b, h - o); path.lineTo(b, h - o); path.close(); return applyStylesAndTransform(path, instance); } case "triangle": { const r = Math.sqrt(size) / 2; const h = HalfSqrt3 * r; const o = h - r * Tan30; const path = new Path(); path.moveTo(0, -h - o); path.lineTo(-r, h - o); path.lineTo(r, h - o); path.close(); return applyStylesAndTransform(path, instance); } case "triangle-up": { const r = Math.sqrt(size) / 2; const h = HalfSqrt3 * r; const path = new Path(); path.moveTo(0, -h); path.lineTo(-r, h); path.lineTo(r, h); path.close(); return applyStylesAndTransform(path, instance); } case "triangle-down": { const r = Math.sqrt(size) / 2; const h = HalfSqrt3 * r; const path = new Path(); path.moveTo(0, h); path.lineTo(-r, -h); path.lineTo(r, -h); path.close(); return applyStylesAndTransform(path, instance); } case "triangle-right": { const r = Math.sqrt(size) / 2; const h = HalfSqrt3 * r; const path = new Path(); path.moveTo(h, 0); path.lineTo(-h, -r); path.lineTo(-h, r); path.close(); return applyStylesAndTransform(path, instance); } case "triangle-left": { const r = Math.sqrt(size) / 2; const h = HalfSqrt3 * r; const path = new Path(); path.moveTo(-h, 0); path.lineTo(h, -r); path.lineTo(h, r); path.close(); return applyStylesAndTransform(path, instance); } case "stroke": { const r = Math.sqrt(size) / 2; const path = new Path(); path.moveTo(-r, 0); path.lineTo(r, 0); return applyStylesAndTransform(path, instance); } default: const path = Path.fromPathData(shape); return applyStylesAndTransform(path, instance); } } function convertTextInstance(instance: VegaTextItem): Shape { let x = instance.x || 0; let y = (instance.y || 0) + verticalOffset(instance); if (instance.dx) x += instance.dx; if (instance.dy) y += instance.dy; const r = instance.radius || 0; if (r) { const t = (instance.theta || 0) - HalfPi; x += r * Math.cos(t); y += r * Math.sin(t); } const text = new Text(instance.text, x, y, String(instance.fontSize), instance.font || "sans-serif"); if (instance.fontWeight) text.fontWeight = instance.fontWeight; text.textAnchor = TEXT_ALIGN_MAP[instance.align] as "start" | "end" | "middle"; if (instance.angle) { text.transform.rotateDegrees(instance.angle, new Point(instance.x, instance.y)); } applyStyles(text, instance); return text; } function createCurve( interpolate: InterpolationMode, orient: "horizontal" | "vertical" | undefined, ): [Path, CurveGenerator] { const interp = INTERPOLATION_MAP[interpolate] || { curve: curveLinear }; const path = new Path(); const curveFn = "curve" in interp ? interp.curve : (interp[orient || "horizontal"] as unknown as CurveFactory); const curve = curveFn(path as unknown as Path2D); return [path, curve as CurveGenerator]; } function verticalOffset(instance: VegaTextItem): number { // This code comes from vega-scenegraph/src/util/text.js#offset const fontSize = instance.fontSize || 11; switch (instance.baseline) { case "top": return fontSize * 0.79; case "middle": return fontSize * 0.3; case "bottom": return fontSize * -0.21; case "line-top": return fontSize * 0.29 + 0.5 * (instance.lineHeight || fontSize + 2); case "line-bottom": return fontSize * 0.29 - 0.5 * (instance.lineHeight || fontSize + 2); default: return 0; } } function applyStylesAndTransform(shape: Shape, instance: VegaVisualItem): Shape { shape.transform.translate(instance.x, instance.y); return applyStyles(shape, instance); } function applyStyles(shape: Shape, instance: VegaVisualItem): Shape { // By default, shapes have no fill (this is different from SVG shapes) shape.fill = Paint.none(); if (instance.fill) shape.fill = Paint.parse(instance.fill); if (instance.stroke) shape.stroke = Paint.parse(instance.stroke); if (instance.strokeWidth) shape.strokeWidth = instance.strokeWidth; if (instance.opacity != null) shape.opacity = instance.opacity; const fill = shape.fill as SolidPaint; const stroke = shape.stroke as SolidPaint; if (fill) { if (instance.fillOpacity != null) fill.a = instance.fillOpacity; } if (stroke) { if (instance.strokeOpacity != null) stroke.a = instance.strokeOpacity; } return shape; }