import { edgesInto, findNode, type Graph, type PortType, portsCompatible } from './Graph'; import type { GraphLookup } from './lookup'; import { resolveDef } from './nested'; import type { NodeDef } from './NodeRegistry'; /** One end of an edge being dragged: a port on a node, seen from its own side. */ export interface PortSide { nodeId: string; port: string; isOutput: boolean; } export function portTypeOf(graph: Graph, side: PortSide, lookup: GraphLookup): PortType | undefined { const node = findNode(graph, side.nodeId); const def = node && resolveDef(node, graph, lookup); const list = side.isOutput ? def?.outputs : def?.inputs; return list?.find((p) => p.key === side.port)?.type; } /** Whether a drag from `from` may land on `to`, i.e. whether ConnectCommand would accept it. */ export function canConnect(graph: Graph, from: PortSide, to: PortSide, lookup: GraphLookup): boolean { if (from.isOutput === to.isOutput || from.nodeId === to.nodeId) return false; const fromType = portTypeOf(graph, from, lookup); const toType = portTypeOf(graph, to, lookup); if (fromType === undefined || toType === undefined) return false; const [out, inp] = from.isOutput ? [fromType, toType] : [toType, fromType]; return portsCompatible(out, inp) && !linked(graph, from, to); } /** * Which port a drag from `from` lands on when it is dropped on node `nodeId` rather than on a port: * the first free compatible one, or – when none is free – the last compatible one, whose edge is * then replaced. Returns null when the node offers nothing to connect to. */ export function dropTargetPort( graph: Graph, from: PortSide, nodeId: string, lookup: GraphLookup, ): PortSide | null { if (nodeId === from.nodeId) return null; const node = findNode(graph, nodeId); const def = node && resolveDef(node, graph, lookup); const fromType = portTypeOf(graph, from, lookup); if (!def || fromType === undefined) return null; const candidates = compatiblePorts(def, fromType, from.isOutput) .map((p) => ({ side: { nodeId, port: p.key, isOutput: !from.isOutput }, type: p.type })) .filter((c) => !linked(graph, from, c.side)); const free = candidates.find((c) => isFree(graph, c.side, c.type)); return (free ?? candidates.at(-1))?.side ?? null; } /** * Whether a node of this type has any port a drag from `fromType` could land on – the filter for * the palette that opens when an edge is dropped into empty space. */ export function defAcceptsFrom(def: NodeDef, fromType: PortType, fromIsOutput: boolean): boolean { return compatiblePorts(def, fromType, fromIsOutput).length > 0; } /** * The port a *fresh* node of this type offers such a drag. Every port of a node that is not in the * graph yet is free, so the first compatible one is what `dropTargetPort` would pick for it. */ export function firstPortFor(def: NodeDef, fromType: PortType, fromIsOutput: boolean): string | null { return compatiblePorts(def, fromType, fromIsOutput)[0]?.key ?? null; } function compatiblePorts(def: NodeDef, fromType: PortType, fromIsOutput: boolean) { const ports = fromIsOutput ? def.inputs : def.outputs; return ports.filter((p) => fromIsOutput ? portsCompatible(fromType, p.type) : portsCompatible(p.type, fromType), ); } /** Only a data input is exclusive – exec inputs and every output take any number of edges. */ function isFree(graph: Graph, side: PortSide, type: PortType): boolean { if (side.isOutput || type === 'exec') return true; return edgesInto(graph, side.nodeId, side.port).length === 0; } function linked(graph: Graph, a: PortSide, b: PortSide): boolean { const [out, inp] = a.isOutput ? [a, b] : [b, a]; return graph.edges.some( (e) => e.from.node === out.nodeId && e.from.port === out.port && e.to.node === inp.nodeId && e.to.port === inp.port, ); }