import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; import type { Command } from '../../core/commands/Command'; import { CompoundCommand } from '../../core/commands/CompoundCommand'; import { AddNodeCommand, ConnectCommand, DisconnectCommand, FoldEdgeCommand, MoveNodeCommand, } from '../../core/commands/graphCommands'; import { BEHAVIOR_PARAM, callProblem, canConnect, defAcceptsFrom, dropTargetPort, edgesInto, edgesOutOf, firstPortFor, type GraphEdge, type GraphNode, type BehaviorLookup, type PortSide, type PortType, portsCompatible, portTypeOf, resolveDef, RUN_BEHAVIOR, } from '../../core/graph'; import type { BehaviorAsset } from '../../core/model/Asset'; import { behaviorLookup } from '../../core/model/behaviorPrograms'; import { findAsset } from '../../core/model/Project'; import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem'; import { activeDrag, clamp, type DragPayload, drawGrid, endDrag, Menu, type MenuItem, onDragChange, type Size, Theme, textStyle, truncate, Widget, zoomFactor, } from '../../ui'; import { pressCount } from '../../ui/clicks'; import { removeNodes } from '../actions'; import { type EditorState, nodeSelection } from '../state'; import { foldedEdgeShown, foldMarkKey, foldMarkLabel } from './folding'; import { type Box, boxesInBand, frameBoxes } from './framing'; import { NodePalette } from './NodePalette'; import { type PaletteEntry, paletteEntries } from './paletteEntries'; import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView'; const GRID_STEP = 24; /** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */ const MIN_GRID_STEP = 8; const MIN_ZOOM = 0.25; const MAX_ZOOM = 2.5; /** Stand-in node height for a node with no view yet – real nodes vary with their port count. */ const NODE_HEIGHT_ESTIMATE = 80; /** Breathing room around the framed graph, so nodes do not touch the edge. */ const FIT_MARGIN = 24; /** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */ const DRAG_THRESHOLD = 3; /** 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. */ 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; } /** A mark while the edges are still adding themselves to it. */ interface PendingMark extends Omit { names: string[]; portX: number; color: number; hot: boolean; } /** * The node graph of one behavior asset. Which behavior that is comes from the outside (`show`), * so the canvas never has an opinion about the selection – the GraphEditor around it does. */ export class GraphCanvas extends Widget { private readonly bg = new Graphics(); private readonly grid = new Graphics(); private readonly clip = new Graphics(); private readonly canvas = new Container(); private readonly edges = new Graphics(); private readonly markChips = new Graphics(); /** Texts naming the far node of each folded edge; pooled, since a redraw rebuilds them all. */ private readonly markLayer = new Container(); private readonly markLabels: Text[] = []; private markLabelCount = 0; private foldMarks: FoldMark[] = []; private readonly foldPending = new Map(); /** Edges the last redraw really stroked as a curve – a hidden one is missing from it. */ private readonly strokedEdges = new Set(); private readonly nodeLayer = new Container(); private readonly tempEdge = new Graphics(); /** Drawn in screen space, so the band keeps a hairline border at any zoom. */ private readonly band = new Graphics(); private readonly hint = new Text({ text: '', style: textStyle('muted') }); private readonly views = new Map(); private assetId: string | null = null; private pan = { x: 0, y: 0 }; private zoom = 1; private panning: { startX: number; startY: number; panX: number; panY: number } | null = null; private dragging: { items: { nodeId: string; start: { x: number; y: number } }[]; pointer: { x: number; y: number }; moved: boolean; } | null = null; /** A rubber-band selection in progress, in graph coordinates. */ private marquee: { start: { x: number; y: number }; current: { x: number; y: number }; active: boolean; } | null = null; /** A behavior dragged in from the asset panel, and why it may not land. */ private incoming: { assetId: string; at: { x: number; y: number }; rejection: string } | null = null; private connecting: { from: PortSide; pointer: { x: number; y: number } } | null = null; /** A finished drag whose palette is still open – drawn like the running one, so the connection * being made stays visible while the node type is chosen. */ private pendingDrop: { from: PortSide; pointer: { x: number; y: number } } | null = null; /** Where the running drag would land, recomputed on every move so the drop holds no surprise. */ private dropTarget: PortSide | null = null; /** Whether the last redraw actually stroked the dangling edge. */ private tempEdgeShown = false; /** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */ private needsFit = true; /** Supplies live execution data while playing (null otherwise). */ traceProvider: (assetId: string) => Trace | null = () => null; timeProvider: () => number = () => 0; constructor(private readonly state: EditorState) { super(); this.canvas.addChild(this.edges, this.markChips, this.markLayer, this.nodeLayer, this.tempEdge); this.addChild(this.bg, this.grid, this.canvas, this.band, this.hint, this.clip); this.canvas.mask = this.clip; this.cursor = 'default'; this.on('pointerdown', (e: FederatedPointerEvent) => this.onCanvasPointerDown(e)); this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e)); this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e)); this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e)); this.on('wheel', (e: FederatedWheelEvent) => { const local = this.toLocal(e.global); const before = this.screenToGraph(local.x, local.y); this.zoom = clamp(this.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM); const after = this.screenToGraph(local.x, local.y); this.pan.x += (after.x - before.x) * this.zoom; this.pan.y += (after.y - before.y) * this.zoom; this.applyView(); e.preventDefault(); }); // A drag can end anywhere; without this the preview would outlive it. onDragChange(() => { if (!activeDrag()) this.setIncoming(null); }); // Only the highlight of the selected node depends on the selection. state.on('selection', () => this.redraw()); state.on('project', () => this.sync()); state.on('mode', () => this.redraw()); } get currentAssetId(): string | null { return this.assetId; } /** Switching refits the view, since the next graph is a new subject. */ show(assetId: string | null): void { if (this.assetId === assetId) return; this.assetId = assetId; this.sync(); this.needsFit = true; this.fitView(); } fit(): void { this.needsFit = true; this.fitView(); } /** Drops the node in the middle of the view, since a button press names no position. */ addNodeFrom(anchor: Widget): void { if (!this.assetId || this.state.mode !== 'edit') return; const centre = this.screenToGraph(this.w / 2, this.h / 2); new NodePalette(this.entries(), (entry) => this.addNode(entry, { x: centre.x - NODE_WIDTH / 2, y: centre.y - NODE_HEIGHT_ESTIMATE / 2 }), ).openBelow(anchor); } override measure(): Size { return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) }; } // --- model binding ---------------------------------------------------------------------- private behaviors(): BehaviorLookup { return behaviorLookup(this.state.project); } private asset(): BehaviorAsset | null { const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined; return asset && asset.type === 'behavior' ? asset : null; } /** Rebuilds node views from the model (cheap: views are reused by node id). */ private sync(): void { const asset = this.asset(); if (!asset) { // A behavior that was deleted (or undone away) must not linger as a graph. this.assetId = null; for (const view of this.views.values()) view.destroy({ children: true }); this.views.clear(); this.redraw(); return; } const seen = new Set(); const lookup = behaviorLookup(this.state.project); for (const node of asset.graph.nodes) { const def = resolveDef(node, lookup); if (!def) continue; seen.add(node.id); let view = this.views.get(node.id); if (!view || view.node !== node) { view?.destroy({ children: true }); view = new NodeView(node, def); view.on('pointerdown', (e: FederatedPointerEvent) => this.onNodePointerDown(view!, e)); this.views.set(node.id, view); this.nodeLayer.addChild(view); } view.sync(node, asset.graph, asset, this.state.project, def, callProblem(node, lookup, asset.id)); } for (const [id, view] of this.views) { if (!seen.has(id)) { view.destroy({ children: true }); this.views.delete(id); } } this.redraw(); } /** Frames the graph's nodes (or the origin) in the view. Waits for a real size. */ private fitView(): void { if (this.w === 0 || this.h === 0) return; this.needsFit = false; const asset = this.asset(); if (!asset || asset.graph.nodes.length === 0) { this.pan = { x: this.w / 2 - NODE_WIDTH / 2, y: this.h / 3 }; this.zoom = 1; this.applyView(); return; } const framing = frameBoxes(this.nodeBoxes(), { w: this.w, h: this.h }, FIT_MARGIN, { min: MIN_ZOOM, max: MAX_ZOOM, }); this.zoom = framing.zoom; this.pan = { x: framing.panX, y: framing.panY }; this.applyView(); } /** Each node as the rectangle it is actually drawn as (a view knows its own height). */ private nodeBoxes(): Box[] { return (this.asset()?.graph.nodes ?? []).map((node) => { const view = this.views.get(node.id); return { id: node.id, x: node.x, y: node.y, w: view?.w ?? NODE_WIDTH, h: view?.h ?? NODE_HEIGHT_ESTIMATE, }; }); } private applyView(): void { this.canvas.position.set(Math.round(this.pan.x), Math.round(this.pan.y)); this.canvas.scale.set(this.zoom); this.redraw(); } private screenToGraph(sx: number, sy: number): { x: number; y: number } { return { x: (sx - this.pan.x) / this.zoom, y: (sy - this.pan.y) / this.zoom }; } private graphToScreen(gx: number, gy: number): { x: number; y: number } { return { x: gx * this.zoom + this.pan.x, y: gy * this.zoom + this.pan.y }; } // --- pointer ----------------------------------------------------------------------------- private onCanvasPointerDown(e: FederatedPointerEvent): void { // The middle button pans from anywhere, a node under the pointer included – it is the one // gesture that never means "grab this". Nodes let it through for exactly that reason. if (e.button === 1) { this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y }; e.stopPropagation(); return; } if (e.target !== this) return; // Shift-drag on empty space pans as well, as it does in the scene view. if (e.button === 0 && e.shiftKey) { this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y }; return; } const local = this.toLocal(e.global); const g = this.screenToGraph(local.x, local.y); const mark = this.state.mode === 'edit' ? this.markAt(g) : null; if (e.button === 2) { if (mark) this.openMarkMenu(mark, e.global.x, e.global.y); else if (this.assetId && this.state.mode === 'edit') this.openPalette(e.global.x, e.global.y, g); return; } if (e.button === 0) { // Selecting the far nodes is also what puts the hidden curves back on screen. if (mark && this.assetId) { this.state.select(nodeSelection(this.assetId, mark.others)); return; } // Empty space: a click selects the behavior, a drag becomes a rubber band – decided on release. this.marquee = { start: g, current: g, active: false }; } } private onNodePointerDown(view: NodeView, e: FederatedPointerEvent): void { // Panning takes precedence over the node: let the middle button bubble up to the canvas. if (e.button === 1) return; if (!this.assetId || this.state.mode !== 'edit') return; e.stopPropagation(); const local = view.toLocal(e.global); const hit = view.portAt(local.x, local.y); if (e.button === 2) { if (hit) this.openPortMenu(view, hit.port.key, hit.isOutput, e.global.x, e.global.y); else this.openNodeMenu(view, e.global.x, e.global.y); return; } if (e.button !== 0) return; if (hit) { const p = this.toLocal(e.global); this.connecting = { from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, pointer: this.screenToGraph(p.x, p.y), }; this.dropTarget = null; this.redraw(); return; } if (e.ctrlKey || e.metaKey || e.shiftKey) { this.state.toggleNode(this.assetId, view.node.id); return; } if (pressCount(e, view.node.id) === 2 && this.openCalledBehavior(view.node)) return; // Clicking a selected node keeps the selection so the whole group can be dragged. if (!this.state.selectedNodeIds.includes(view.node.id)) this.state.select(nodeSelection(this.assetId, [view.node.id])); const p = this.toLocal(e.global); this.dragging = { items: this.selectedNodes().map((n) => ({ nodeId: n.id, start: { x: n.x, y: n.y } })), pointer: this.screenToGraph(p.x, p.y), moved: false, }; this.nodeLayer.addChild(view); // raise } /** Selecting the asset is what opens it: the graph tab follows the selection. */ private openCalledBehavior(node: GraphNode): boolean { if (node.type !== RUN_BEHAVIOR) return false; const id = String(node.params[BEHAVIOR_PARAM] ?? ''); const asset = id ? findAsset(this.state.project, id) : undefined; if (asset?.type !== 'behavior') return false; this.state.select({ kind: 'asset', assetId: id }); return true; } private selectedNodes(): GraphNode[] { const graph = this.asset()?.graph; if (!graph) return []; const ids = new Set(this.state.selectedNodeIds); return graph.nodes.filter((n) => ids.has(n.id)); } private onPointerMove(e: FederatedPointerEvent): void { const dragged = activeDrag(); if (dragged) { this.trackIncoming(dragged, e); return; } if (this.panning) { this.pan.x = this.panning.panX + (e.global.x - this.panning.startX); this.pan.y = this.panning.panY + (e.global.y - this.panning.startY); this.applyView(); return; } const local = this.toLocal(e.global); const g = this.screenToGraph(local.x, local.y); if (this.dragging) { const dx = g.x - this.dragging.pointer.x; const dy = g.y - this.dragging.pointer.y; if (!this.dragging.moved && Math.hypot(dx, dy) * this.zoom < DRAG_THRESHOLD) return; this.dragging.moved = true; const graph = this.asset()?.graph; if (!graph) return; for (const item of this.dragging.items) { const node = graph.nodes.find((n) => n.id === item.nodeId); if (!node) continue; node.x = Math.round(item.start.x + dx); node.y = Math.round(item.start.y + dy); } this.state.preview(); } else if (this.connecting) { this.connecting.pointer = g; this.dropTarget = this.resolveDrop(e.global.x, e.global.y); this.redraw(); } else if (this.marquee) { this.marquee.current = g; const d = Math.hypot(g.x - this.marquee.start.x, g.y - this.marquee.start.y) * this.zoom; if (!this.marquee.active && d < DRAG_THRESHOLD) return; this.marquee.active = true; this.redraw(); } } /** * Follows a behavior dragged in from the asset panel. The node is centred under the pointer, so * it lands where the ghost was rather than below and right of it. */ private trackIncoming(dragged: DragPayload, e: FederatedPointerEvent): void { const local = this.toLocal(e.global); const inside = local.x >= 0 && local.x <= this.w && local.y >= 0 && local.y <= this.h; const asset = dragged.kind === 'asset' ? findAsset(this.state.project, dragged.id) : undefined; if (!inside || !this.assetId || this.state.mode !== 'edit' || asset?.type !== 'behavior') { this.setIncoming(null); return; } const g = this.screenToGraph(local.x, local.y); this.setIncoming({ assetId: asset.id, at: { x: Math.round(g.x - NODE_WIDTH / 2), y: Math.round(g.y - TITLE_HEIGHT / 2) }, rejection: asset.id === this.assetId ? 'A behavior cannot run itself' : '', }); } private setIncoming(incoming: GraphCanvas['incoming']): void { if (!incoming && !this.incoming) return; this.incoming = incoming; this.redraw(); } private dropIncoming(drop: NonNullable): void { if (drop.rejection) { this.state.setNotice(drop.rejection); return; } const entry = this.entries().find((it) => it.id === `${RUN_BEHAVIOR}:${drop.assetId}`); if (entry) this.addNode(entry, drop.at); } private onPointerUp(e: FederatedPointerEvent): void { const incoming = this.incoming; if (incoming) { this.setIncoming(null); endDrag(); this.dropIncoming(incoming); return; } this.panning = null; const marquee = this.marquee; this.marquee = null; if (marquee && this.assetId && this.state.mode === 'edit') { if (marquee.active) this.selectInMarquee(marquee); else this.state.select({ kind: 'asset', assetId: this.assetId }); } const drag = this.dragging; this.dragging = null; if (drag?.moved && this.assetId) { const graph = this.asset()?.graph; const moves: MoveNodeCommand[] = []; for (const item of drag.items) { const node = graph?.nodes.find((n) => n.id === item.nodeId); if (!node) continue; // The preview moved the node already; put it back so the command records the start. const next = { x: node.x, y: node.y }; node.x = item.start.x; node.y = item.start.y; moves.push(new MoveNodeCommand(this.assetId, item.nodeId, next.x, next.y)); } if (moves.length === 1) this.state.execute(moves[0]!); else if (moves.length > 1) this.state.execute(new CompoundCommand('Move nodes', moves)); } const connecting = this.connecting; const target = connecting ? (this.dropTarget ?? this.resolveDrop(e.global.x, e.global.y)) : null; this.connecting = null; this.dropTarget = null; if (connecting && this.assetId) { if (target) this.connect(connecting.from, target); else if (!this.nodeUnder(e.global.x, e.global.y)) this.openPaletteForDrop(connecting, e.global.x, e.global.y); } this.redraw(); } private selectInMarquee(marquee: { start: { x: number; y: number }; current: { x: number; y: number }; }): void { if (!this.assetId) return; const ids = boxesInBand(this.nodeBoxes(), { x1: marquee.start.x, y1: marquee.start.y, x2: marquee.current.x, y2: marquee.current.y, }); // An empty band clears the nodes but keeps the behavior selected, as a plain click does. this.state.select(nodeSelection(this.assetId, ids) ?? { kind: 'asset', assetId: this.assetId }); } private connect(from: PortSide, to: PortSide): void { if (!this.assetId) return; const [out, inp] = from.isOutput ? [from, to] : [to, from]; this.state.execute( new ConnectCommand( this.assetId, { node: out.nodeId, port: out.port }, { node: inp.nodeId, port: inp.port }, ), ); } /** * Dropped into empty space: offer the node types this edge could reach, then wire the new node * up the same way a drop on its body would. */ private openPaletteForDrop( drag: { from: PortSide; pointer: { x: number; y: number } }, globalX: number, globalY: number, ): void { const graph = this.asset()?.graph; const from = drag.from; const fromType = graph && portTypeOf(graph, from, this.behaviors()); if (!graph || fromType === undefined) return; const local = this.toLocal({ x: globalX, y: globalY }); const at = this.screenToGraph(local.x, local.y); this.pendingDrop = drag; const palette = new NodePalette( this.entries(), (entry) => this.addConnectedNode(entry, from, fromType, at), (def) => defAcceptsFrom(def, fromType, from.isOutput), ); palette.onClose = () => { this.pendingDrop = null; this.redraw(); }; palette.open(globalX, globalY); } /** * Where the running drag would connect: the port under the pointer, or – dropped on a node's * body – the port that node picks for it. Null means the drop does nothing. */ private resolveDrop(globalX: number, globalY: number): PortSide | null { const graph = this.asset()?.graph; const from = this.connecting?.from; if (!graph || !from) return null; const lookup = this.behaviors(); const hit = this.portUnder(globalX, globalY); if (hit) return canConnect(graph, from, hit, lookup) ? hit : null; const node = this.nodeUnder(globalX, globalY); return node ? dropTargetPort(graph, from, node, lookup) : null; } private portUnder(globalX: number, globalY: number): PortSide | null { for (const view of this.topDownViews()) { const local = view.toLocal({ x: globalX, y: globalY }); const hit = view.portAt(local.x, local.y); if (hit) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }; } return null; } private nodeUnder(globalX: number, globalY: number): string | null { for (const view of this.topDownViews()) { const p = view.toLocal({ x: globalX, y: globalY }); if (p.x >= 0 && p.x <= view.w && p.y >= 0 && p.y <= view.h) return view.node.id; } return null; } /** Views in stacking order, topmost first – a node drawn over another one wins the pointer. */ private *topDownViews(): Iterable { for (let i = this.nodeLayer.children.length - 1; i >= 0; i--) { const child = this.nodeLayer.children[i]; if (child instanceof NodeView) yield child; } } // --- menus ------------------------------------------------------------------------------- private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void { new NodePalette(this.entries(), (entry) => this.addNode(entry, at)).open(globalX, globalY); } private entries(): PaletteEntry[] { const asset = this.asset(); return asset ? paletteEntries(this.state.project, asset) : []; } private addNode(entry: PaletteEntry, at: { x: number; y: number }): void { if (!this.assetId) return; const command = new AddNodeCommand( this.assetId, entry.def.type, Math.round(at.x), Math.round(at.y), entry.params, ); if (this.state.execute(command)) this.state.select(nodeSelection(this.assetId, [command.node.id])); } /** Adding and wiring go in as one command, so a single undo takes the whole gesture back. */ private addConnectedNode( entry: PaletteEntry, from: PortSide, fromType: PortType, at: { x: number; y: number }, ): void { if (!this.assetId) return; const add = new AddNodeCommand( this.assetId, entry.def.type, Math.round(at.x), Math.round(at.y), entry.params, ); const port = firstPortFor(entry.def, fromType, from.isOutput); const to = { nodeId: add.node.id, port: port ?? '', isOutput: !from.isOutput }; const [out, inp] = from.isOutput ? [from, to] : [to, from]; const commands: Command[] = [add]; if (port) commands.push( new ConnectCommand( this.assetId, { node: out.nodeId, port: out.port }, { node: inp.nodeId, port: inp.port }, ), ); if (this.state.execute(new CompoundCommand(add.label, commands))) this.state.select(nodeSelection(this.assetId, [add.node.id])); } private openNodeMenu(view: NodeView, globalX: number, globalY: number): void { const assetId = this.assetId; if (!assetId) return; // Right-clicking inside a multi-selection acts on all of it; outside it, on that node alone. if (!this.state.selectedNodeIds.includes(view.node.id)) this.state.select(nodeSelection(assetId, [view.node.id])); const ids = this.state.selectedNodeIds; new Menu([ { label: ids.length > 1 ? `Delete ${ids.length} nodes` : 'Delete node', danger: true, onSelect: () => removeNodes(this.state, assetId, ids), }, ]).open(globalX, globalY); } private openPortMenu( view: NodeView, port: string, isOutput: boolean, globalX: number, globalY: number, ): void { const asset = this.asset(); if (!asset) return; const edges = isOutput ? edgesOutOf(asset.graph, view.node.id, port) : edgesInto(asset.graph, view.node.id, port); if (edges.length === 0) return; const shown = edges.filter((e) => !e.folded); const hidden = edges.filter((e) => e.folded); const fold = (list: GraphEdge[], folded: boolean) => () => { const commands = list.map((e) => new FoldEdgeCommand(asset.id, e.id, folded)); this.state.execute( commands.length === 1 ? commands[0]! : new CompoundCommand(folded ? 'Hide links' : 'Show links', commands), ); }; const items: MenuItem[] = []; if (shown.length > 0) items.push({ label: shown.length === 1 ? 'Hide link' : `Hide ${shown.length} links`, onSelect: fold(shown, true), }); if (hidden.length > 0) items.push({ label: hidden.length === 1 ? 'Show link' : `Show ${hidden.length} links`, onSelect: fold(hidden, false), }); items.push({ label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`, danger: true, onSelect: () => { for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id)); }, }); new Menu(items).open(globalX, globalY); } /** The mark is the only handle a hidden edge has, so it carries the way back. */ private openMarkMenu(mark: FoldMark, globalX: number, globalY: number): void { const assetId = this.assetId; if (!assetId) return; const ids = mark.edgeIds; const n = ids.length; const all = (title: string, make: (edgeId: string) => Command) => () => { const commands = ids.map(make); this.state.execute(commands.length === 1 ? commands[0]! : new CompoundCommand(title, commands)); }; new Menu([ { label: n === 1 ? 'Show link' : `Show ${n} links`, onSelect: all('Show links', (id) => new FoldEdgeCommand(assetId, id, false)), }, { label: n === 1 ? 'Disconnect' : `Disconnect ${n} links`, danger: true, onSelect: all('Disconnect links', (id) => new DisconnectCommand(assetId, id)), }, ]).open(globalX, globalY); } // --- drawing ----------------------------------------------------------------------------- override layout(): void { this.hint.position.set(Theme.padding, this.h - Theme.padding - 16); if (this.needsFit) this.fitView(); } override redraw(): void { const c = Theme.colors; this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground); this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff); this.syncGrid(); const asset = this.asset(); this.canvas.visible = asset !== null; if (!asset) { this.hint.text = 'No behavior selected – pick one above, or add a behavior asset.'; return; } const playing = this.state.mode === 'play'; this.hint.text = asset.graph.nodes.length === 0 ? 'Right-click to add a node' : playing ? 'live' : ''; const trace = playing ? this.traceProvider(asset.id) : null; const now = this.timeProvider(); const isHot = (map: Map | undefined, id: string) => { const t = map?.get(id); return t !== undefined && now - t <= TRACE_FADE; }; const selectedNodes = new Set(this.state.selectedNodeIds); const target = this.dropTarget; for (const view of this.views.values()) { view.selected = selectedNodes.has(view.node.id); view.hot = isHot(trace?.nodes, view.node.id); view.targetPort = target && target.nodeId === view.node.id ? { port: target.port, isOutput: target.isOutput } : null; view.redraw(); } this.edges.clear(); this.markChips.clear(); this.foldMarks = []; this.markLabelCount = 0; this.strokedEdges.clear(); this.foldPending.clear(); for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id), selectedNodes); // After the curves, so no line drawn later crosses a mark that stands for a hidden one. this.drawFoldMarks(); for (let i = this.markLabelCount; i < this.markLabels.length; i++) this.markLabels[i]!.visible = false; this.tempEdge.clear(); this.tempEdgeShown = false; const drag = this.connecting ?? this.pendingDrop; if (drag) { const view = this.views.get(drag.from.nodeId); const p = view?.portPosition(drag.from.port, drag.from.isOutput); if (view && p) { const a = { x: view.x + p.x, y: view.y + p.y }; // Snapping to the resolved port makes the prediction the ring shows unmistakable. const b = this.anchorPoint(target) ?? drag.pointer; const [from, to] = drag.from.isOutput ? [a, b] : [b, a]; bezier(this.tempEdge, from.x, from.y, to.x, to.y); this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 }); this.tempEdgeShown = true; } } this.band.clear(); const m = this.marquee; if (m?.active) { const a = this.graphToScreen(m.start.x, m.start.y); const b = this.graphToScreen(m.current.x, m.current.y); this.band .rect(Math.min(a.x, b.x), Math.min(a.y, b.y), Math.abs(b.x - a.x), Math.abs(b.y - a.y)) .fill({ color: c.marquee, alpha: 0.12 }) .stroke({ width: 1, color: c.marquee }); } const drop = this.incoming; if (drop) { const a = this.graphToScreen(drop.at.x, drop.at.y); const color = drop.rejection ? c.danger : c.nodeSelected; this.band .rect(a.x, a.y, NODE_WIDTH * this.zoom, NODE_HEIGHT_ESTIMATE * this.zoom) .fill({ color, alpha: 0.15 }) .stroke({ width: 2, color }); } } /** A port's centre in graph coordinates. */ private anchorPoint(side: PortSide | null): { x: number; y: number } | null { if (!side) return null; const view = this.views.get(side.nodeId); const p = view?.portPosition(side.port, side.isOutput); return view && p ? { x: view.x + p.x, y: view.y + p.y } : null; } private drawEdge(edge: GraphEdge, hot: boolean, selected: Set): void { const from = this.views.get(edge.from.node); const to = this.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.addFoldMark(edge, start, true, to.def.label, color, hot); this.addFoldMark(edge, end, false, from.def.label, color, hot); if (!foldedEdgeShown(edge, selected, hot)) return; } bezier(this.edges, start.x, start.y, end.x, end.y); this.edges.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.strokedEdges.add(edge.id); } /** Adds one end of a hidden link to its port's mark, creating that mark on first sight. */ private addFoldMark( edge: GraphEdge, 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]; const key = foldMarkKey(side, isOutput); let mark = this.foldPending.get(key); if (!mark) { mark = { edgeIds: [], others: [], names: [], isOutput, x: port.x + (isOutput ? FOLD_STUB : -FOLD_STUB), y: port.y, portX: port.x, color, hot, }; this.foldPending.set(key, mark); } mark.edgeIds.push(edge.id); mark.names.push(otherLabel); if (!mark.others.includes(other)) mark.others.push(other); // A pulse anywhere in the bundle colours the whole mark: it is the one thing worth spotting. if (hot && !mark.hot) { mark.hot = true; mark.color = color; } } /** 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 this.foldPending.values()) { const { x, y, color, isOutput } = mark; this.edges.moveTo(mark.portX, y).lineTo(x, y).stroke({ width: 2, color, alpha: 0.9 }); const radius = mark.edgeIds.length > 1 ? FOLD_MARK_RADIUS + 2 : FOLD_MARK_RADIUS; this.edges.circle(x, y, radius).fill(color); this.edges.circle(x, y, radius).stroke({ width: 1, color: Theme.colors.node }); const label = this.markLabel(); 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.markChips .roundRect(lx - 3, y - label.height / 2 - 1, label.width + 6, label.height + 2, 3) .fill({ color: Theme.colors.node, alpha: 0.85 }); this.foldMarks.push({ edgeIds: mark.edgeIds, others: mark.others, isOutput, x, y, text: label.text }); } } private markLabel(): Text { let label = this.markLabels[this.markLabelCount]; if (!label) { label = new Text({ text: '', style: textStyle('small') }); this.markLabels.push(label); this.markLayer.addChild(label); } label.visible = true; this.markLabelCount++; return label; } private markAt(g: { x: number; y: number }): FoldMark | null { for (const m of this.foldMarks) { const radius = m.edgeIds.length > 1 ? FOLD_MARK_RADIUS + 2 : FOLD_MARK_RADIUS; if (Math.hypot(g.x - m.x, g.y - m.y) <= radius + 4) return m; } return null; } private syncGrid(): void { this.grid.clear(); const step = GRID_STEP * this.zoom; if (step < MIN_GRID_STEP) return; // `pan` is where the graph origin sits on screen, but no axis lines: it marks nothing. drawGrid(this.grid, { originX: this.pan.x, originY: this.pan.y, step, w: this.w, h: this.h, alpha: 0.04, }); } // --- test hooks ----------------------------------------------------------------------------- /** Global screen position of a port (tests drive connections with it). */ portGlobal(nodeId: string, port: string, isOutput: boolean): { x: number; y: number } | null { const view = this.views.get(nodeId); const p = view?.portPosition(port, isOutput); if (!view || !p) return null; const g = view.toGlobal({ x: p.x, y: p.y }); return { x: g.x, y: g.y }; } /** The port a running edge drag would connect to, or null when the drop would do nothing. */ pendingDropTarget(): PortSide | null { return this.dropTarget; } /** Whether the dangling edge is on screen right now (running drag, or a drop awaiting its node). */ danglingEdgeVisible(): boolean { return this.tempEdgeShown; } /** Whether the last redraw drew this edge as a curve – the claim "hidden" is about exactly this. */ edgeDrawn(edgeId: string): boolean { return this.strokedEdges.has(edgeId); } /** Every mark the last redraw drew – its length is the claim "one per port, not one per link". */ foldMarksDrawn(): { links: number; label: string; isOutput: boolean }[] { return this.foldMarks.map((m) => ({ links: m.edgeIds.length, label: m.text, isOutput: m.isOutput })); } /** Global screen position of a folded edge's mark at one end, and what that mark stands for. */ foldMarkGlobal( edgeId: string, isOutput: boolean, ): { x: number; y: number; links: number; label: string } | null { const mark = this.foldMarks.find((m) => m.edgeIds.includes(edgeId) && m.isOutput === isOutput); if (!mark) return null; const g = this.canvas.toGlobal({ x: mark.x, y: mark.y }); return { x: g.x, y: g.y, links: mark.edgeIds.length, label: mark.text }; } /** A node's drawn rectangle on screen, and the canvas' own – "fit" is a claim about both. */ nodeRect(nodeId: string): { x: number; y: number; w: number; h: number } | null { const view = this.views.get(nodeId); if (!view) return null; const topLeft = view.toGlobal({ x: 0, y: 0 }); const bottomRight = view.toGlobal({ x: view.w, y: view.h }); return { x: topLeft.x, y: topLeft.y, w: bottomRight.x - topLeft.x, h: bottomRight.y - topLeft.y, }; } canvasRect(): { x: number; y: number; w: number; h: number } { const topLeft = this.toGlobal({ x: 0, y: 0 }); const bottomRight = this.toGlobal({ x: this.w, y: this.h }); return { x: topLeft.x, y: topLeft.y, w: bottomRight.x - topLeft.x, h: bottomRight.y - topLeft.y, }; } /** * The title a node is drawn with. Worth reaching for from outside because a nested-behavior node * takes its title from the behavior it runs – "which node is this" is otherwise only a guess. */ nodeLabel(nodeId: string): string | null { return this.views.get(nodeId)?.def.label ?? null; } nodeProblem(nodeId: string): string | null { return this.views.get(nodeId)?.problemText ?? null; } /** The setting rows a node is drawn with – a setting left off the body is absent here too. */ nodeSettings(nodeId: string): string[] | null { return this.views.get(nodeId)?.settingLines ?? null; } /** Global screen position of a node's title bar centre. */ nodeGlobal(nodeId: string): { x: number; y: number } | null { const view = this.views.get(nodeId); if (!view) return null; const g = view.toGlobal({ x: view.w / 2, y: TITLE_HEIGHT / 2 }); return { x: g.x, y: g.y }; } } /** Horizontal S-curve between two points. */ 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); }