Add Vue Flow graph visualizer
This commit is contained in:
188
graphfx-ui/src/lib/graphfxToVueFlow.ts
Normal file
188
graphfx-ui/src/lib/graphfxToVueFlow.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import type { Edge, Node } from '@vue-flow/core'
|
||||
|
||||
export type GraphFxIoValue = {
|
||||
label?: string | null
|
||||
value?: unknown
|
||||
output?: string | null
|
||||
}
|
||||
|
||||
export type GraphFxSerializedNode = {
|
||||
node: {
|
||||
id: string
|
||||
name: string
|
||||
options?: {
|
||||
in?: Record<string, GraphFxIoValue>
|
||||
out?: Record<string, GraphFxIoValue>
|
||||
[key: string]: unknown
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
x?: number
|
||||
y?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type GraphFxSerializedGraph = GraphFxSerializedNode[]
|
||||
|
||||
export type GraphFxPort = {
|
||||
name: string
|
||||
label: string
|
||||
type?: string
|
||||
output?: string | null
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
export type GraphFxNodeData = {
|
||||
id: string
|
||||
name: string
|
||||
inputs: GraphFxPort[]
|
||||
outputs: GraphFxPort[]
|
||||
hasSavedPosition: boolean
|
||||
raw: GraphFxSerializedNode
|
||||
}
|
||||
|
||||
export type GraphFxFlowWarning = {
|
||||
type: 'missing-output' | 'duplicate-output' | 'invalid-node'
|
||||
message: string
|
||||
nodeId?: string
|
||||
ioName?: string
|
||||
outputId?: string
|
||||
}
|
||||
|
||||
export type GraphFxFlowModel = {
|
||||
nodes: Node<GraphFxNodeData>[]
|
||||
edges: Edge[]
|
||||
warnings: GraphFxFlowWarning[]
|
||||
}
|
||||
|
||||
export function getInputHandleId(nodeId: string, inputName: string) {
|
||||
return `${nodeId}:in:${inputName}`
|
||||
}
|
||||
|
||||
export function getOutputHandleId(nodeId: string, outputName: string) {
|
||||
return `${nodeId}:out:${outputName}`
|
||||
}
|
||||
|
||||
function labelFor(name: string, io?: GraphFxIoValue) {
|
||||
return io?.label || name
|
||||
}
|
||||
|
||||
function toPorts(record: Record<string, GraphFxIoValue> | undefined, direction: 'in' | 'out') {
|
||||
return Object.entries(record || {}).map(([name, io]) => ({
|
||||
name,
|
||||
label: labelFor(name, io),
|
||||
output: direction === 'in' ? io?.output || null : null,
|
||||
value: direction === 'in' ? io?.value : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function savedPosition(item: GraphFxSerializedNode, index: number) {
|
||||
const hasX = typeof item.x === 'number' && Number.isFinite(item.x)
|
||||
const hasY = typeof item.y === 'number' && Number.isFinite(item.y)
|
||||
|
||||
return {
|
||||
hasSavedPosition: hasX && hasY,
|
||||
position: hasX && hasY ? { x: item.x as number, y: item.y as number } : { x: index * 280, y: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
export function graphFxToVueFlow(graph: GraphFxSerializedGraph): GraphFxFlowModel {
|
||||
const warnings: GraphFxFlowWarning[] = []
|
||||
const outputBySerializedId = new Map<string, { nodeId: string; outputName: string }>()
|
||||
const nodeIds = new Set<string>()
|
||||
|
||||
for (const [index, item] of graph.entries()) {
|
||||
if (!item?.node?.id || !item.node.name) {
|
||||
warnings.push({
|
||||
type: 'invalid-node',
|
||||
message: `Graph entry #${index + 1} does not look like a serialized GraphFX node.`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
nodeIds.add(item.node.id)
|
||||
|
||||
for (const outputName of Object.keys(item.node.options?.out || {})) {
|
||||
const serializedOutputId = `${item.node.id}-${outputName}`
|
||||
if (outputBySerializedId.has(serializedOutputId)) {
|
||||
warnings.push({
|
||||
type: 'duplicate-output',
|
||||
message: `Output id ${serializedOutputId} appears more than once.`,
|
||||
nodeId: item.node.id,
|
||||
ioName: outputName,
|
||||
outputId: serializedOutputId,
|
||||
})
|
||||
}
|
||||
outputBySerializedId.set(serializedOutputId, { nodeId: item.node.id, outputName })
|
||||
}
|
||||
}
|
||||
|
||||
const nodes: Node<GraphFxNodeData>[] = graph
|
||||
.filter((item) => item?.node?.id && item.node.name)
|
||||
.map((item, index) => {
|
||||
const { hasSavedPosition, position } = savedPosition(item, index)
|
||||
const inputs = toPorts(item.node.options?.in, 'in')
|
||||
const outputs = toPorts(item.node.options?.out, 'out')
|
||||
|
||||
return {
|
||||
id: item.node.id,
|
||||
type: 'graphfx',
|
||||
position,
|
||||
data: {
|
||||
id: item.node.id,
|
||||
name: item.node.name,
|
||||
inputs,
|
||||
outputs,
|
||||
hasSavedPosition,
|
||||
raw: item,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const edges: Edge[] = []
|
||||
|
||||
for (const item of graph) {
|
||||
if (!item?.node?.id) continue
|
||||
|
||||
for (const [inputName, input] of Object.entries(item.node.options?.in || {})) {
|
||||
if (!input.output) continue
|
||||
|
||||
const source = outputBySerializedId.get(input.output)
|
||||
if (!source) {
|
||||
warnings.push({
|
||||
type: 'missing-output',
|
||||
message: `${item.node.name}.${inputName} references missing output ${input.output}.`,
|
||||
nodeId: item.node.id,
|
||||
ioName: inputName,
|
||||
outputId: input.output,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
edges.push({
|
||||
id: `${source.nodeId}:${source.outputName}->${item.node.id}:${inputName}`,
|
||||
source: source.nodeId,
|
||||
target: item.node.id,
|
||||
sourceHandle: getOutputHandleId(source.nodeId, source.outputName),
|
||||
targetHandle: getInputHandleId(item.node.id, inputName),
|
||||
label: inputName,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges, warnings }
|
||||
}
|
||||
|
||||
export function parseGraphFxJson(json: string): GraphFxSerializedGraph {
|
||||
const parsed = JSON.parse(json)
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('GraphFX serialized graphs are arrays returned by graph.serialize().')
|
||||
}
|
||||
return parsed as GraphFxSerializedGraph
|
||||
}
|
||||
|
||||
export function countSavedPositions(graph: GraphFxSerializedGraph) {
|
||||
return graph.filter((item) => typeof item.x === 'number' && typeof item.y === 'number').length
|
||||
}
|
||||
Reference in New Issue
Block a user