import { Container, Graphics, Text } from 'pixi.js'; import { type GraphEdge, portsCompatible } from '../../core/graph'; import { Theme, textStyle, truncate } from '../../ui'; import { dashedLine } from '../../viewport/symbols'; import { arrowCorners, arrowShaft, type Point, transitionEnds, transitionOffsets } from './transitions'; import { buildFoldMarks, foldedEdgeShown, type FoldMarkEnd, foldMarkAt, foldMarkKey, foldMarkLabel, foldMarkRadius, } from './folding'; import { curvePoint, curveReach } from './beads'; 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; const TRANSITION_LABEL_WIDTH = 110; const BEAD_RADIUS = 4.5; /** Dash pattern and sampling of an edge hanging on a port that is gone. */ const GHOST_DASH = 7; const GHOST_GAP = 5; const DASH_STEPS = 80; /** 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 beads = 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(); private beadsDrawn: { edgeId: string; progress: number; x: number; y: number }[] = []; /** Sideways shift per transition, so two between the same states do not lie on top of each other. */ private offsets = new Map(); private transitionLabels: { edgeId: string; text: string; x: number; y: number }[] = []; constructor() { super(); this.addChild(this.curves, this.beads, this.chips, this.labelLayer); } draw( edges: readonly GraphEdge[], views: ReadonlyMap, isHot: (edgeId: string) => boolean, selected: Set, beads: (edgeId: string) => readonly number[] = () => [], ): void { this.curves.clear(); this.beads.clear(); this.beadsDrawn = []; this.chips.clear(); this.marks = []; this.ends = []; this.labelCount = 0; this.stroked.clear(); this.transitionLabels = []; this.offsets = transitionOffsets( edges .filter((e) => isTransition(e, views)) .map((e) => ({ id: e.id, from: e.from.node, to: e.to.node })), ); for (const edge of edges) this.drawEdge(edge, views, isHot(edge.id), selected, beads(edge.id)); // 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); } drawnBeads(): readonly { edgeId: string; progress: number; x: number; y: number }[] { return this.beadsDrawn; } private drawEdge( edge: GraphEdge, views: ReadonlyMap, hot: boolean, selected: Set, beads: readonly number[], ): 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 out = from.def.outputs.find((p) => p.key === edge.from.port); const inp = to.def.inputs.find((p) => p.key === edge.to.port); const type = out?.type ?? 'any'; const inType = inp?.type ?? 'any'; // One end on a port that is gone: the edge is drawn, but never as a working link. const ghost = out?.ghost === true || inp?.ghost === true; const valid = !ghost && portsCompatible(type, inType); const color = hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger; const pins = { start: { x: from.x + a.x, y: from.y + a.y }, end: { x: to.x + b.x, y: to.y + b.y }, }; const transition = isTransition(edge, views) ? transitionEnds(from, to, this.offsets.get(edge.id) ?? 0) : null; const { start, end } = transition ?? pins; if (edge.folded) { // The fold mark belongs on the port, whatever the line between the nodes does. this.addFoldEnd(edge, views, pins.start, true, to.def.label, color, hot); this.addFoldEnd(edge, views, pins.end, false, from.def.label, color, hot); if (!foldedEdgeShown(edge, selected, hot)) return; } const shaft = transition ? arrowShaft(start, end) : end; if (ghost && transition) dashedLine(this.curves, start.x, start.y, shaft.x, shaft.y, GHOST_DASH, GHOST_GAP); else if (ghost) dashedBezier(this.curves, start.x, start.y, end.x, end.y, GHOST_DASH, GHOST_GAP); else if (transition) this.curves.moveTo(start.x, start.y).lineTo(shaft.x, shaft.y); else 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), }); if (transition) arrowHead(this.curves, start, end, color); if (transition && edge.label) this.drawTransitionLabel(edge, start, end); this.stroked.add(edge.id); for (const progress of beads) { const { x, y } = curvePoint(start.x, start.y, end.x, end.y, progress); // Light with a dark rim: a bead in the highlight colour would vanish on the edge it lights up. this.beads .circle(x, y, BEAD_RADIUS) .fill(Theme.colors.text) .stroke({ width: 1.5, color: Theme.colors.graphBackground }); this.beadsDrawn.push({ edgeId: edge.id, progress, x, y }); } } /** The transition's name at the middle of its arrow, on a plate so the line does not run through it. */ private drawTransitionLabel(edge: GraphEdge, start: Point, end: Point): void { const label = this.nextLabel(); label.text = truncate(edge.label ?? '', textStyle('small'), TRANSITION_LABEL_WIDTH); const x = (start.x + end.x) / 2 - label.width / 2; const y = (start.y + end.y) / 2 - label.height / 2; label.position.set(x, y); this.chips .roundRect(x - 3, y - 1, label.width + 6, label.height + 2, 3) .fill({ color: Theme.colors.node, alpha: 0.85 }); this.transitionLabels.push({ edgeId: edge.id, text: label.text, x, y }); } drawnTransitionLabels(): readonly { edgeId: string; text: string; x: number; y: number }[] { return this.transitionLabels; } 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; } } function isTransition(edge: GraphEdge, views: ReadonlyMap): boolean { return ( views.get(edge.from.node)?.def.transitionEdges === true && views.get(edge.to.node)?.def.transitionEdges === true ); } /** The filled triangle at the target end of a transition. */ export function arrowHead(g: Graphics, start: Point, end: Point, color: number): void { const corners = arrowCorners(start, end); if (!corners) return; const [tip, left, right] = corners; g.moveTo(tip.x, tip.y).lineTo(left.x, left.y).lineTo(right.x, right.y).closePath().fill(color); } /** Horizontal S-curve between two points. */ export function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void { const dx = curveReach(x1, x2); g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2); } /** * The same curve as dashes. Pixi has no dash style, and unlike a straight line a bezier cannot be * stepped by length, so it is sampled and the walked distance decides which steps are drawn. */ export function dashedBezier( g: Graphics, x1: number, y1: number, x2: number, y2: number, dash: number, gap: number, ): void { const dx = curveReach(x1, x2); const at = (t: number) => { const u = 1 - t; return { x: u * u * u * x1 + 3 * u * u * t * (x1 + dx) + 3 * u * t * t * (x2 - dx) + t * t * t * x2, y: u * u * u * y1 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y2, }; }; let prev = at(0); let walked = 0; for (let i = 1; i <= DASH_STEPS; i++) { const point = at(i / DASH_STEPS); if (walked % (dash + gap) < dash) g.moveTo(prev.x, prev.y).lineTo(point.x, point.y); walked += Math.hypot(point.x - prev.x, point.y - prev.y); prev = point; } }