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

224 lines (213 loc) 8.12 kB
import { Item, Network, Node, ConnectionType, FunctionItem, ParameterValue, LiteralValue, ContextGlobals, } from "./types"; import Context from "./context"; import RuntimeNode, { createExpressionContext } from "./runtime-node"; import { findInputConnection, findOutletConnection } from "./queries"; import { loadItem } from "./loaders"; export async function renderItem(cx: Context, item: Item, values?: Record<string, LiteralValue>): Promise<any> { const fqName = `self/self/${item.name}`; await evaluateItem(cx, fqName, item, values); if (item.type === "NETWORK") { const network = item as Network; if (!network.outputPorts || network.outputPorts.length === 0) { const renderedNode = network.children.find((node) => node.id === network.renderedNode)! as Node; if (!renderedNode) { const errorMessage = `No rendered node found in network ${network.name}.`; throw new Error(errorMessage); } const renderedNodeFn = cx.lookupItemByName(renderedNode.fn); if (renderedNodeFn) { const outputs = renderedNodeFn.outputPorts.map((port) => cx.portValues.get(`${renderedNode.id}/${port.name}`)); if (outputs.length > 0) return outputs[0]; } } else { const outputs = network.outputPorts.map((port) => cx.portValues.get(`${network.id}/${port.name}`)); if (outputs.length > 0) return outputs[0]; } } } export async function evaluateItem( cx: Context, fqName: string, item: Item, values?: Record<string, LiteralValue>, ): Promise<RuntimeNode | undefined | null> { if (item.type === "NETWORK") { return await evaluateNetwork(cx, item as Network, values); } else if (item.type === "FUNCTION") { return await evaluateFunction(cx, fqName, values); } else { throw new Error(`Invalid item: ${item}`); } } export async function evaluateNetwork( cx: Context, network: Network, values?: Record<string, LiteralValue>, ): Promise<RuntimeNode | null> { let toVisit = []; const renderedNode = network.children.find((node) => node.id === network.renderedNode); if (renderedNode) { toVisit.push(renderedNode as Node); console.assert(renderedNode.type === "NODE"); } if (Array.isArray(network.outputPorts)) { for (const outPort of network.outputPorts) { const conn = findOutletConnection(network, outPort.name); if (conn) { const outNode = network.children.find((n) => n.id === conn.outNode) as Node; if (outNode) { toVisit.push(outNode); } } } } if (toVisit.length === 0) { return null; } const visitedNodes = new Set<string>(); const visitNode = async (node: Node): Promise<any> => { const nodeFn = cx.lookupItemByName(node.fn); if (!nodeFn) return; if (visitedNodes.has(node.id)) { return; } visitedNodes.add(node.id); for (const port of nodeFn.inputPorts) { const conn = findInputConnection(network, node, port.name); if (!conn) { continue; } if (conn.type === ConnectionType.InletToNode) { const inlet = network.inputPorts.find((p) => p.name === conn.inlet); if (!inlet) { throw new Error(`Inlet not found: ${conn.inlet}`); } // FIXME: What do we do here? Do we get the value from the calling network? throw new Error(`Unimplemented: Inlet value`); } else { const outNode = network.children.find((n) => n.id === conn.outNode) as Node; if (!outNode) { throw new Error(`Output node not found: ${conn.outNode}`); } await visitNode(outNode); const portValue = cx.portValues.get(`${conn.outNode}/${conn.outPort}`); if (portValue) { cx.portValues.set(`${node.id}/${port.name}`, portValue); } } } await evaluateNode(cx, node, createExpressionContext(cx, { network, values })); }; for (const node of toVisit) { await visitNode(node); } if (Array.isArray(network.outputPorts)) { for (const outPort of network?.outputPorts) { const conn = findOutletConnection(network, outPort.name); if (!conn) { continue; } const portValue = cx.portValues.get(`${conn.outNode}/${conn.outPort}`); if (portValue) { cx.portValues.set(`${network.id}/${outPort.name}`, portValue); } } } if (renderedNode) { return cx.runtimeNodes.get(renderedNode.id)!; } else { return null; } } async function evaluateNode(cx: Context, node: Node, globals: ContextGlobals) { // The given node is the simple JSON-serialized node that just contains the node ID and values. // In order to run this node, we need an "initialized" node that contains the onRender function. // Let's check if we have that node by looking in the `cx.runtimeNodes` map. let runtimeNode = cx.runtimeNodes.get(node.id); if (!runtimeNode) { runtimeNode = await createRuntimeNodeForItem(cx, node.id, node.fn); if (runtimeNode !== undefined) cx.runtimeNodes.set(node.id, runtimeNode); } if (runtimeNode && runtimeNode.dirty) { runtimeNode.values = structuredClone(node.values || {}); runtimeNode.globals = globals; await runtimeNode.onRender(cx); runtimeNode.dirty = runtimeNode.timeDependent ? true : false; runtimeNode.outputPorts.forEach((port) => { cx.portValues.set(`${node.id}/${port.name}`, port._value); }); } } /** * Given a fully qualified item name (`userId/projectId/itemName`), create a runtime node for that item. * @param {Context cx The context object. * @param {string} nodeId The id of the node. Used for looking up port values in the context. * @param {string} fqName The fully qualified item name. * @returns {RuntimeNode} The runtime node for the given item. */ export async function createRuntimeNodeForItem( cx: Context, nodeId: string, fqName: string, ): Promise<RuntimeNode | undefined> { // We do this by looking up the function item in the project and then loading the module. const nodeFn = cx.lookupItemByName(fqName); if (!nodeFn) return undefined; if (nodeFn.type === "FUNCTION") { await loadItem(cx, nodeFn, fqName); const initializerFn = cx.initializers.get(fqName); if (!initializerFn) { throw new Error(`Function not found: ${fqName}`); } // Create a runtime node object. const runtimeNode = new RuntimeNode(cx, nodeId, nodeFn); // Run the initializer function on the node object to initialize the ports and parameters. initializerFn(runtimeNode); return runtimeNode; } else { const runtimeNode = new RuntimeNode(cx, nodeId, nodeFn); return runtimeNode; } } export async function evaluateFunction( cx: Context, fqName: string, values?: Record<string, LiteralValue>, ): Promise<RuntimeNode | undefined> { let runtimeNode = cx.runtimeNodes.get(fqName); if (!runtimeNode) { // We use the fully qualified name here twice, because we don't have a node ID. // This means that when we're executing this function in isolation, and looking up the value of an input port, // we will not find anything in the context, because the port value is stored as `nodeId/portName`. runtimeNode = await createRuntimeNodeForItem(cx, fqName, fqName); if (!runtimeNode) return undefined; cx.runtimeNodes.set(fqName, runtimeNode); } if (values) { runtimeNode.values = Object.fromEntries(Object.entries(values).map(([k, v]) => [k, { type: "VALUE", value: v }])); } await runtimeNode.onRender(cx); return runtimeNode; } export async function evaluateRuntimeNode(runtimeNode: RuntimeNode, values?: Record<string, ParameterValue>) { if (values) { runtimeNode.values = structuredClone(values); } await runtimeNode.onRender(runtimeNode.cx); } export async function sendChangeEvent(cx: Context, nodeId: string, parameterName: string) { // FIXME: This needs to be changed once we're merging subnetworks, because we can't use // global node IDs anymore. const runtimeNode = cx.runtimeNodes.get(nodeId); if (!runtimeNode) return; runtimeNode.onChange(cx, parameterName); }