Add Vue Flow graph visualizer

This commit is contained in:
2026-06-18 23:45:33 +02:00
parent 2083546311
commit 7b814b440f
19 changed files with 2708 additions and 0 deletions

View 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
}