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

454 lines (417 loc) 15.2 kB
import { Context, Shape, ShapeType, Circle, Rect, Line, Path, Group, Ellipse, Text, Bounds, LinearGradientPaint, RadialGradientPaint, } from "@ndbx/g"; import { ReactSVGElement, createElement } from "react"; import { Spec } from "vega"; import { vegaToShape } from "./vega-to-shape"; import { Transform } from "@ndbx/g"; // Figma blue: #0D99FF const cornerSquareSize = 1; const colorBase: ColorAttributes = { fill: "none", stroke: "#1976D2", strokeWidth: 0.5 }; const colorCenter: ColorAttributes = { fill: "none", stroke: "#1976D2", strokeWidth: 0.5 }; const colorLines: ColorAttributes = { stroke: "#1976D2", strokeWidth: 0.5, fill: "none" }; const colorPoints: ColorAttributes = { fill: "none", stroke: "#1976D2", strokeWidth: 0.5 }; interface ColorAttributes { fill: string; stroke: string; strokeWidth: number; } interface PivotShape extends Shape { "data-pivot-mode"?: string; } function applyMatrixTransform(x: number, y: number, matrix: Float32Array): { x: number; y: number } { const [a, b, c, d, e, f] = matrix; return { x: a * x + c * y + e, y: b * x + d * y + f, }; } function getPivotPoints( shape: Shape, bounds: Bounds, customPoints?: Array<{ x: number; y: number }>, key?: string | number, ): ReactSVGElement | null { const matrix = new Transform().matrix; //shape.transform.matrix; const defaultPoints = [ { x: bounds.left, y: bounds.bottom }, { x: bounds.left, y: bounds.top }, { x: bounds.right, y: bounds.top }, { x: bounds.right, y: bounds.bottom }, ]; const points = customPoints || defaultPoints; const center = [{ x: bounds.centerX, y: bounds.centerY }]; const transformedPoints = points.map((point) => ({ ...applyMatrixTransform(point.x, point.y, matrix), })); const transformedCenter = center.map((point) => ({ ...applyMatrixTransform(point.x, point.y, matrix), })); const rectWidthHeight = cornerSquareSize + colorPoints.strokeWidth + colorLines.strokeWidth; const pointElements = transformedPoints.map((point, i) => createElement("rect", { key: `point-${i}`, x: point.x - rectWidthHeight / 2, y: point.y - rectWidthHeight / 2, width: rectWidthHeight, height: rectWidthHeight, ...colorPoints, "data-tag": "point-corner", }), ); const centerElements = transformedCenter.map((point, i) => createElement("circle", { key: `cpoint-${i}`, cx: point.x, cy: point.y, r: 1, ...colorCenter, "data-tag": "point-center", }), ); const lineElements = transformedPoints .map((point, i) => { const nextPoint = transformedPoints[(i + 1) % transformedPoints.length]; const dx = nextPoint.x - point.x; const dy = nextPoint.y - point.y; const length = Math.hypot(dx, dy); if (length === 0) { return null; } const ux = dx / length; const uy = dy / length; const offsetDistance = rectWidthHeight / 2; const startX = point.x + ux * offsetDistance; const startY = point.y + uy * offsetDistance; const endX = nextPoint.x - ux * offsetDistance; const endY = nextPoint.y - uy * offsetDistance; return createElement("line", { key: `line-${i}`, x1: startX, y1: startY, x2: endX, y2: endY, ...colorLines, "data-tag": "line-corner", }); }) .filter((element) => element !== null); return createElement("g", { key }, ...lineElements, ...pointElements, ...centerElements); } function wrapShape(shape: Shape, element: ReactSVGElement, key?: string | number): ReactSVGElement { const bounds = shape.getBounds(); return createElement("g", { key }, element, getPivotPoints(shape, bounds)); } export function renderShape( shape: Shape, context?: Context, key?: string | number, showPoints: boolean = false, parentTransform: Transform = new Transform(), ): ReactSVGElement { if (shape.clipPath) { context?.clipPaths.push(shape.clipPath); } switch (shape.type) { case ShapeType.Circle: { const circle = shape as Circle; const element = createElement("circle", { key, id: circle.id, cx: circle.cx, cy: circle.cy, r: circle.radius, ...circle._getAttributes(context), }); return showPoints ? wrapShape(circle, element, key) : element; } case ShapeType.Ellipse: { const ellipse = shape as Ellipse; const element = createElement("ellipse", { key, id: ellipse.id, cx: ellipse.cx, cy: ellipse.cy, rx: ellipse.rx, ry: ellipse.ry, ...ellipse._getAttributes(context), }); return showPoints ? wrapShape(ellipse, element, key) : element; } case ShapeType.Group: { const groupShape = shape as Group; const currentTransform = parentTransform.combine(new Transform(groupShape.transform.matrix)); const element = createElement( "g", { key, id: groupShape.id, ...groupShape._getAttributes() }, groupShape.children.map((child, i) => renderShape(child, context, i, showPoints)), ); if (!showPoints) return element; const bounds = groupShape.getBounds(); let commonPoints: ReactSVGElement[] = []; if ((shape as PivotShape)["data-pivot-mode"]) { if ((shape as PivotShape)["data-pivot-mode"] === "on group") { const temp = getPivotPoints(groupShape, bounds); if (temp) commonPoints = [temp]; showPoints = false; } if ((shape as PivotShape)["data-pivot-mode"] === "canvas") { showPoints = false; } } return createElement( "g", { key }, createElement( "g", { ...groupShape._getAttributes(context) }, groupShape.children .filter((c) => c?.type) .map((child, i) => renderShape(child as PivotShape, context, i, showPoints)), ), ...commonPoints, ); } case ShapeType.Line: { const line = shape as Line; const element = createElement("line", { key, id: line.id, x1: line.x1, y1: line.y1, x2: line.x2, y2: line.y2, ...line._getAttributes(context), }); return showPoints ? wrapShape(line, element, key) : element; } case ShapeType.Path: { const path = shape as Path; const element = createElement("path", { key, id: path.id, d: path.toPathData(), ...path._getAttributes(context), }); return showPoints ? wrapShape(path, element, key) : element; } case ShapeType.Rect: { const rect = shape as Rect; const element = createElement("rect", { key, id: rect.id, x: rect.x, y: rect.y, width: rect.width, height: rect.height, ...rect._getAttributes(context), }); return showPoints ? wrapShape(rect, element, key) : element; } case ShapeType.Text: { const text = shape as Text; const element = createElement( "text", { key, id: text.id, x: text.x, y: text.y, fontFamily: text.fontFamily, fontWeight: text.fontWeight, fontSize: text.fontSize, textAnchor: text.textAnchor, ...text._getAttributes(context), }, text.text, ); if (!showPoints) return element; const bounds = text.getBounds(); // Define custom points including base points const customPoints = [ { x: bounds.left, y: bounds.base }, { x: bounds.left, y: bounds.top }, { x: bounds.right, y: bounds.top }, { x: bounds.right, y: bounds.base }, ]; return createElement("g", { key }, element, getPivotPoints(text, bounds)); } default: throw new Error(`Unknown shape type: ${shape.type}`); } } export function dimensionForRenderShape( shape: PivotShape, useMatrix: Boolean = true, cumulBounds: Bounds = { left: Infinity, right: -Infinity, top: Infinity, bottom: -Infinity, centerX: 0, centerY: 0 }, ): Bounds { switch (shape.type) { case ShapeType.Circle: { const circle = shape as Circle; const bounds = circle.getBounds(); return bounds; } case ShapeType.Ellipse: { const ellipse = shape as Ellipse; const bounds = ellipse.getBounds(); return bounds; } case ShapeType.Group: { const groupShape = shape as Group; if (groupShape.height && groupShape.width) { // height and width determined with parseSVG. const bounds = { left: Math.min(cumulBounds.left, 0), right: Math.max(cumulBounds.right, groupShape.width), top: Math.min(cumulBounds.top, 0), bottom: Math.max(cumulBounds.bottom, groupShape.height), centerX: (Math.min(cumulBounds.left, 0) + Math.max(cumulBounds.right, groupShape.width)) / 2, centerY: (Math.min(cumulBounds.top, 0) + Math.max(cumulBounds.bottom, groupShape.height)) / 2, }; return bounds; } else if ( (groupShape as PivotShape)["data-pivot-mode"] === undefined || ((groupShape as PivotShape)["data-pivot-mode"] && (groupShape as PivotShape)["data-pivot-mode"] === "per element") ) { const groupBounds = groupShape.children.reduce((accBounds: Bounds, child) => { const childBounds = dimensionForRenderShape(child as PivotShape); return { left: Math.min(accBounds.left, childBounds.left), right: Math.max(accBounds.right, childBounds.right), top: Math.min(accBounds.top, childBounds.top), bottom: Math.max(accBounds.bottom, childBounds.bottom), centerX: (Math.min(accBounds.left, childBounds.left) + Math.max(accBounds.right, childBounds.right)) / 2, centerY: (Math.min(accBounds.top, childBounds.top) + Math.max(accBounds.bottom, childBounds.bottom)) / 2, }; }, cumulBounds); cumulBounds = { left: Math.min(cumulBounds.left, groupBounds.left), right: Math.max(cumulBounds.right, groupBounds.right), top: Math.min(cumulBounds.top, groupBounds.top), bottom: Math.max(cumulBounds.bottom, groupBounds.bottom), centerX: (Math.min(cumulBounds.left, groupBounds.left) + Math.max(cumulBounds.right, groupBounds.right)) / 2, centerY: (Math.min(cumulBounds.top, groupBounds.top) + Math.max(cumulBounds.bottom, groupBounds.bottom)) / 2, }; return cumulBounds; } else if ((groupShape as PivotShape)["data-pivot-mode"] === "on group") { // get the regular bounds of each element, apply group transformation const groupBounds = groupShape.children.reduce((accBounds: Bounds, child) => { const childBounds = dimensionForRenderShape(child as PivotShape, false); return { left: Math.min(accBounds.left, childBounds.left), right: Math.max(accBounds.right, childBounds.right), top: Math.min(accBounds.top, childBounds.top), bottom: Math.max(accBounds.bottom, childBounds.bottom), centerX: (Math.min(accBounds.left, childBounds.left) + Math.max(accBounds.right, childBounds.right)) / 2, centerY: (Math.min(accBounds.top, childBounds.top) + Math.max(accBounds.bottom, childBounds.bottom)) / 2, }; }, cumulBounds); const matrix = groupShape.transform.matrix; const topLeft = Transform.applyMatrixTransform(groupBounds.left, groupBounds.top, matrix); const topRight = Transform.applyMatrixTransform(groupBounds.right, groupBounds.top, matrix); const bottomLeft = Transform.applyMatrixTransform(groupBounds.left, groupBounds.bottom, matrix); const bottomRight = Transform.applyMatrixTransform(groupBounds.right, groupBounds.bottom, matrix); // Find the min and max of the transformed corners for bounding box const left = Math.min(topLeft.x, topRight.x, bottomLeft.x, bottomRight.x); const right = Math.max(topLeft.x, topRight.x, bottomLeft.x, bottomRight.x); const top = Math.min(topLeft.y, topRight.y, bottomLeft.y, bottomRight.y); const bottom = Math.max(topLeft.y, topRight.y, bottomLeft.y, bottomRight.y); // Center points can be calculated based on the new bounding box const centerX = (left + right) / 2; const centerY = (top + bottom) / 2; cumulBounds = { left: Math.min(cumulBounds.left, left), right: Math.max(cumulBounds.right, right), top: Math.min(cumulBounds.top, top), bottom: Math.max(cumulBounds.bottom, bottom), centerX: (Math.min(cumulBounds.left, left) + Math.max(cumulBounds.right, right)) / 2, centerY: (Math.min(cumulBounds.top, top) + Math.max(cumulBounds.bottom, bottom)) / 2, }; return cumulBounds; } } case ShapeType.Line: { const line = shape as Line; const bounds = line.getBounds(); return bounds; } case ShapeType.Path: { const path = shape as Path; const bounds = path.getBounds(); return bounds; } case ShapeType.Rect: { const rect = shape as Rect; const bounds = rect.getBounds(); return bounds; } case ShapeType.Text: { const text = shape as Text; const bounds = text.getTransformedBounds(); // : text.getBounds(); return bounds; } default: // throw new Error(`Unknown shape type: ${shape.type}`); return { left: 0, right: 0, top: 0, bottom: 0, centerX: 0, centerY: 0 }; } } export function renderVegaSpec(spec: Spec): Shape { return vegaToShape(spec); } export function renderDefs(context: Context): ReactSVGElement | null { if (context.clipPaths.length === 0 && context.gradients.length === 0) return null; const clipPaths = context.clipPaths.map((clipPath) => createElement( "clipPath", { id: clipPath.id, }, renderShape(clipPath.shape), ), ); const gradients = context.gradients.map((gradient) => { const stops = gradient.stops.map((stop, index) => createElement("stop", { key: index, offset: `${stop.offset * 100}%`, stopColor: stop.color, }), ); const attrs: Record<string, string> = {}; if (gradient.type === "linearGradient") { const linear = gradient as LinearGradientPaint; attrs.x1 = `${linear.x1 * 100}%`; attrs.y1 = `${linear.y1 * 100}%`; attrs.x2 = `${linear.x2 * 100}%`; attrs.y2 = `${linear.y2 * 100}%`; } else if (gradient.type === "radialGradient") { const radial = gradient as RadialGradientPaint; attrs.cx = `${radial.cx * 100}%`; attrs.cy = `${radial.cy * 100}%`; attrs.r = `${radial.r * 100}%`; } return createElement( gradient.type, { key: gradient.id, id: gradient.id, ...attrs, }, ...stops, ); }); return createElement("defs", {}, ...clipPaths, ...gradients); }