import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; import { textOf } from '../../core/model/Properties'; import type { Command } from '../../core/commands/Command'; import { oneStep } from '../../core/commands/CompoundCommand'; import { AddNodeCommand, CollapseNodesCommand, ConnectCommand, MoveNodeCommand, } from '../../core/commands/graphCommands'; import { BEHAVIOR_PARAM, callProblem, canConnect, defAcceptsFrom, dropTargetPort, edgesInto, edgesOutOf, firstPortFor, type Graph, type GraphNode, type GraphLookup, type PortSide, type PortType, portTypeOf, resolveDef, RUN_BEHAVIOR, } from '../../core/graph'; import type { GraphAsset } from '../../core/model/Asset'; import { graphLookup } from '../../core/model/graphLookup'; import { findAsset, findGraphAsset } from '../../core/model/Project'; import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem'; import { beadsInFlight, launchBead } from './beads'; import { activeDrag, clamp, type DragPayload, drawBand, drawGrid, endDrag, onDragChange, pressCount, type Size, Theme, textStyle, Widget, zoomFactor, } from '../../ui'; import { type EditorState, nodeSelection } from '../state'; import { type Box, boxesInBand, dragOffset, frameBoxes } from '../framing'; import { bezier, EdgeLayer } from './EdgeLayer'; import { openLinkMenu, openNodeMenu } from './graphMenus'; import { NodePalette } from './NodePalette'; import { type PaletteEntry, paletteEntries } from './paletteEntries'; import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView'; /** Spacing of the background grid – and, with snapping on, of the positions a drag can leave. */ 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; /** Live values change every frame; ten updates a second are still readable and cost far less text layout. */ const LIVE_VALUE_INTERVAL_MS = 100; /** * The node graph of one behavior or state machine asset. Which one that is comes from the outside * (`show`), so the canvas never has an opinion about the selection – the tab 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 edgeLayer = new EdgeLayer(); 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 = ''; 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; private snapping = false; /** 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; private valuesShownAt = 0; /** In game time, so a paused game holds its beads. */ private readonly beadTimes = new Map(); private readonly beadFired = new Map(); private beadAsset = ''; /** Supplies live execution data while playing (null otherwise). */ traceProvider: (assetId: string) => Trace | null = () => null; timeProvider: () => number = () => 0; /** A double-clicked call node; the tab opens the behavior and records the step. */ onOpenBehavior: (assetId: string) => void = (assetId) => this.state.select({ kind: 'asset', assetId }); constructor( private readonly state: EditorState, /** Names the asset kind in the empty state: "No behavior selected …". */ private readonly noun = 'behavior', ) { super(); this.canvas.addChild(this.edgeLayer, 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 { return this.assetId; } /** Switching refits the view, since the next graph is a new subject. */ show(assetId: string): void { if (this.assetId === assetId) return; this.assetId = assetId; this.sync(); this.needsFit = true; this.fitView(); } fit(): void { this.needsFit = true; this.fitView(); } /** Takes effect on the next drag only – turning it on must not shuffle the graph. */ setSnapping(on: boolean): void { this.snapping = on; } get snapEnabled(): boolean { return this.snapping; } /** Screen pixels per graph unit; the fit picks it from the canvas size, so a check cannot assume 1. */ get zoomLevel(): number { return this.zoom; } override measure(): Size { return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) }; } private behaviors(): GraphLookup { return graphLookup(this.state.project); } private asset(): GraphAsset | null { return (this.assetId && findGraphAsset(this.state.project, this.assetId)) || null; } /** Rebuilds node views from the model (cheap: views are reused by node id). */ private sync(): void { const asset = this.asset(); if (!asset) { // An asset that was deleted (or undone away) must not linger as a graph. this.assetId = ''; for (const view of this.views.values()) view.destroy({ children: true }); this.views.clear(); this.redraw(); return; } const seen = new Set(); const lookup = graphLookup(this.state.project); for (const node of asset.graph.nodes) { const def = resolveDef(node, asset.graph, 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, 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 }; } 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. const startPan = () => { this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y }; }; if (e.button === 1) { startPan(); 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) { startPan(); return; } const local = this.toLocal(e.global); const g = this.screenToGraph(local.x, local.y); const mark = this.edgeLayer.markAt(g); if (e.button === 2) { if (mark) this.openLinks((g) => g.edges.filter((it) => mark.edgeIds.includes(it.id)), e.global); else if (this.assetId) 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) return; e.stopPropagation(); const local = view.toLocal(e.global); const hit = view.portAt(local.x, local.y); if (e.button === 2) { const { id } = view.node; if (hit) { const key = hit.port.key; this.openLinks((g) => (hit.isOutput ? edgesOutOf(g, id, key) : edgesInto(g, id, key)), e.global); } else { // Right-clicking inside a multi-selection acts on all of it; outside it, on that node alone. if (!this.state.selectedNodeIds.includes(id)) this.state.select(nodeSelection(this.assetId, [id])); openNodeMenu(this.state, this.assetId, this.selectedNodes(), 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; } // Like the hierarchy's chevron: it folds, and leaves the selection alone. if (view.chevronAt(local.x, local.y)) { this.state.execute(new CollapseNodesCommand(this.assetId, [view.node.id], !view.collapsed)); 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 } private openCalledBehavior(node: GraphNode): boolean { if (node.type !== RUN_BEHAVIOR) return false; const id = textOf(node.params[BEHAVIOR_PARAM] ?? ''); const asset = id ? findAsset(this.state.project, id) : undefined; if (asset?.type !== 'behavior') return false; this.onOpenBehavior(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; const step = this.snapping ? GRID_STEP : 0; for (const item of this.dragging.items) { const node = graph.nodes.find((n) => n.id === item.nodeId); if (!node) continue; const offset = dragOffset(item.start, dx, dy, step); node.x = item.start.x + offset.x; node.y = item.start.y + offset.y; } 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; // Only a behavior graph runs other behaviors; a state machine has nothing to drop in. if (!inside || this.asset()?.type !== 'behavior' || 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) { 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 > 0) this.state.execute(oneStep('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) this.state.execute(connectSides(this.assetId, from, to)); } /** * 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); if (!node || this.views.get(node)?.collapsed) return null; return dropTargetPort(graph, from, node, lookup); } 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; } } 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 newNode(entry: PaletteEntry, at: { x: number; y: number }): AddNodeCommand { return new AddNodeCommand(this.assetId, entry.def.type, Math.round(at.x), Math.round(at.y), entry.params); } private addNode(entry: PaletteEntry, at: { x: number; y: number }): void { if (!this.assetId) return; const command = this.newNode(entry, at); 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 = this.newNode(entry, at); const port = firstPortFor(entry.def, fromType, from.isOutput); const commands: Command[] = [add]; if (port) commands.push( connectSides(this.assetId, from, { nodeId: add.node.id, port, isOutput: !from.isOutput }), ); if (this.state.execute(oneStep(add.label, commands))) this.state.select(nodeSelection(this.assetId, [add.node.id])); } private openLinks(pick: (graph: Graph) => Graph['edges'], at: { x: number; y: number }): void { const graph = this.asset()?.graph; if (graph) openLinkMenu(this.state, this.assetId, pick(graph), at.x, at.y); } 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 ${this.noun} selected – pick one above, or add a ${this.noun} 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(); this.launchBeads(asset.id, trace); 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; const wall = performance.now(); const values = trace && wall - this.valuesShownAt >= LIVE_VALUE_INTERVAL_MS ? trace.values : null; if (values) this.valuesShownAt = wall; for (const view of this.views.values()) { view.selected = selectedNodes.has(view.node.id); view.hot = isHot(trace?.nodes, view.node.id); if (values) view.setLiveValues(values.get(view.node.id) ?? null, this.state.project); view.targetPort = target && target.nodeId === view.node.id ? { port: target.port, isOutput: target.isOutput } : null; view.redraw(); } this.edgeLayer.draw( asset.graph.edges, this.views, (id) => isHot(trace?.edges, id), selectedNodes, (id) => beadsInFlight(this.beadTimes.get(id) ?? [], now), ); this.tempEdge.clear(); this.tempEdgeShown = false; const drag = this.connecting ?? this.pendingDrop; const a = drag && this.anchorPoint(drag.from); if (drag && a) { // 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) { drawBand( this.band, this.graphToScreen(m.start.x, m.start.y), this.graphToScreen(m.current.x, m.current.y), ); } 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 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, }); } /** 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 stroked this edge as a curve. */ edgeDrawn(edgeId: string): boolean { return this.edgeLayer.wasStroked(edgeId); } foldMarksDrawn(): { links: number; label: string; isOutput: boolean }[] { return this.edgeLayer .drawnMarks() .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.edgeLayer .drawnMarks() .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 }; } nodeRect(nodeId: string): Rect | null { const view = this.views.get(nodeId); return view ? globalRect(view, view.w, view.h) : null; } canvasRect(): Rect { return globalRect(this, this.w, this.h); } /** * A drawn point no node covers, with room below and right for a node created there and for the * palette that creates it. Searched from the bottom up, so no edge between nodes crosses it. */ emptySpotGlobal(): { x: number; y: number } | null { const rect = this.canvasRect(); const room = { below: NODE_HEIGHT_ESTIMATE * this.zoom + 40, right: NODE_WIDTH * this.zoom + 40 }; const taken = [...this.views.keys()].map((id) => this.nodeRect(id)).filter((r): r is Rect => r !== null); const clear = (x: number, y: number) => !taken.some( (r) => x >= r.x - 16 && x <= r.x + r.w + 16 && y >= r.y - 16 && y <= r.y + r.h + room.below + 16, ); for (let y = rect.y + rect.h - room.below; y > rect.y + 40; y -= 20) { // Right to left: a node created here must stay inside the view a later marquee spans. for (let x = rect.x + rect.w - room.right; x > rect.x + 40; x -= 20) if (clear(x, y)) return { x, y }; } return null; } /** A nested-behavior node takes its title from the behavior it runs. */ 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; } nodeInputs(nodeId: string): string[] | null { return this.views.get(nodeId)?.inputLines ?? null; } private launchBeads(assetId: string, trace: Trace | null): void { if (!trace || assetId !== this.beadAsset) { this.beadTimes.clear(); this.beadFired.clear(); this.beadAsset = assetId; } for (const [id, time] of trace?.edges ?? []) { if (this.beadFired.get(id) === time) continue; this.beadFired.set(id, time); const times = this.beadTimes.get(id) ?? []; this.beadTimes.set(id, times); launchBead(times, time); } } beadsDrawn(): readonly { edgeId: string; progress: number; x: number; y: number }[] { return this.edgeLayer.drawnBeads(); } nodeOutputs(nodeId: string): string[] | null { return this.views.get(nodeId)?.outputLines ?? 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 }; } chevronGlobal(nodeId: string): { x: number; y: number } | null { const g = this.views.get(nodeId)?.chevronGlobal(); return g ? { x: g.x, y: g.y } : null; } } type Rect = { x: number; y: number; w: number; h: number }; function globalRect(target: Container, w: number, h: number): Rect { const topLeft = target.toGlobal({ x: 0, y: 0 }); const bottomRight = target.toGlobal({ x: w, y: h }); return { x: topLeft.x, y: topLeft.y, w: bottomRight.x - topLeft.x, h: bottomRight.y - topLeft.y }; } /** A drag may start at either end; the command wants the output first. */ function connectSides(assetId: string, a: PortSide, b: PortSide): ConnectCommand { const [out, inp] = a.isOutput ? [a, b] : [b, a]; return new ConnectCommand( assetId, { node: out.nodeId, port: out.port }, { node: inp.nodeId, port: inp.port }, ); }