import { Container, Graphics, Text } from 'pixi.js'; import { type GraphEdge, portsCompatible } from '../../core/graph'; import { Theme, textStyle, truncate } from '../../ui'; import { buildFoldMarks, foldedEdgeShown, type FoldMarkEnd, foldMarkAt, foldMarkKey, foldMarkLabel, foldMarkRadius, } from './folding'; import { type NodeView, portColor } from './NodeView'; /** How far a folded edge's stub reaches out of its port. */ const FOLD_STUB = 16; const FOLD_MARK_RADIUS = 5; const FOLD_LABEL_WIDTH = 96; /** One port's hidden links as drawn: what they stand in for, and where they can be clicked. */ export interface FoldMark { edgeIds: string[]; /** The nodes at the other ends, deduped – clicking selects them all. */ others: string[]; isOutput: boolean; x: number; y: number; text: string; } /** The curves between nodes and the marks of the hidden ones, in graph coordinates. */ export class EdgeLayer extends Container { private readonly curves = new Graphics(); private readonly chips = new Graphics(); /** Texts naming the far node of each folded edge; pooled, since a redraw rebuilds them all. */ private readonly labelLayer = new Container(); private readonly labels: Text[] = []; private labelCount = 0; private ends: FoldMarkEnd[] = []; private marks: FoldMark[] = []; /** Edges the last draw really stroked as a curve – a hidden one is missing from it. */ private readonly stroked = new Set(); constructor() { super(); this.addChild(this.curves, this.chips, this.labelLayer); } draw( edges: readonly GraphEdge[], views: ReadonlyMap, isHot: (edgeId: string) => boolean, selected: Set, ): void { this.curves.clear(); this.chips.clear(); this.marks = []; this.ends = []; this.labelCount = 0; this.stroked.clear(); for (const edge of edges) this.drawEdge(edge, views, isHot(edge.id), selected); // After the curves, so no line drawn later crosses a mark that stands for a hidden one. this.drawFoldMarks(); for (let i = this.labelCount; i < this.labels.length; i++) this.labels[i]!.visible = false; } markAt(g: { x: number; y: number }): FoldMark | null { return foldMarkAt(this.marks, g, FOLD_MARK_RADIUS); } drawnMarks(): readonly FoldMark[] { return this.marks; } wasStroked(edgeId: string): boolean { return this.stroked.has(edgeId); } private drawEdge( edge: GraphEdge, views: ReadonlyMap, hot: boolean, selected: Set, ): void { const from = views.get(edge.from.node); const to = views.get(edge.to.node); const a = from?.portPosition(edge.from.port, true); const b = to?.portPosition(edge.to.port, false); if (!from || !to || !a || !b) return; const type = from.def.outputs.find((p) => p.key === edge.from.port)?.type ?? 'any'; const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any'; const valid = portsCompatible(type, inType); const color = hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger; const start = { x: from.x + a.x, y: from.y + a.y }; const end = { x: to.x + b.x, y: to.y + b.y }; if (edge.folded) { this.addFoldEnd(edge, views, start, true, to.def.label, color, hot); this.addFoldEnd(edge, views, end, false, from.def.label, color, hot); if (!foldedEdgeShown(edge, selected, hot)) return; } bezier(this.curves, start.x, start.y, end.x, end.y); this.curves.stroke({ width: hot ? 4 : type === 'exec' ? 2.5 : 2, color, // Dimmed while folded, so a curve that is only on loan is not mistaken for a plain one. alpha: (type === 'exec' || hot ? 1 : 0.85) * (edge.folded ? 0.6 : 1), }); this.stroked.add(edge.id); } private addFoldEnd( edge: GraphEdge, views: ReadonlyMap, port: { x: number; y: number }, isOutput: boolean, otherLabel: string, color: number, hot: boolean, ): void { const [side, other] = isOutput ? [edge.from, edge.to.node] : [edge.to, edge.from.node]; this.ends.push({ edgeId: edge.id, key: foldMarkKey(side, isOutput, views.get(side.node)?.collapsed), other, otherLabel, isOutput, portX: port.x, portY: port.y, color, hot, }); } /** A stub out of the port, a dot, and what waits at the other end – one per port, not per link. */ private drawFoldMarks(): void { for (const mark of buildFoldMarks(this.ends, FOLD_STUB)) { const { x, y, color, isOutput } = mark; this.curves.moveTo(mark.portX, y).lineTo(x, y).stroke({ width: 2, color, alpha: 0.9 }); const radius = foldMarkRadius(mark.edgeIds.length, FOLD_MARK_RADIUS); this.curves.circle(x, y, radius).fill(color); this.curves.circle(x, y, radius).stroke({ width: 1, color: Theme.colors.node }); const label = this.nextLabel(); label.text = truncate(foldMarkLabel(mark.names), textStyle('small'), FOLD_LABEL_WIDTH); const gap = radius + 4; const lx = isOutput ? x + gap : x - gap - label.width; label.position.set(lx, y - label.height / 2); // Without a plate behind it the name blurs into whatever curves pass behind the mark. this.chips .roundRect(lx - 3, y - label.height / 2 - 1, label.width + 6, label.height + 2, 3) .fill({ color: Theme.colors.node, alpha: 0.85 }); this.marks.push({ edgeIds: mark.edgeIds, others: mark.others, isOutput, x, y, text: label.text }); } } private nextLabel(): Text { let label = this.labels[this.labelCount]; if (!label) { label = new Text({ text: '', style: textStyle('small') }); this.labels.push(label); this.labelLayer.addChild(label); } label.visible = true; this.labelCount++; return label; } } /** Horizontal S-curve between two points. */ export function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void { const dx = Math.max(40, Math.abs(x2 - x1) * 0.5); g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2); }