import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js'; import { textOf } from '../../core/model/Properties'; import { AddNodeCommand, CollapseNodesCommand } from '../../core/commands/graphCommands'; import { SetMachineStartCommand } from '../../core/commands/assetCommands'; import { AddAnnotationCommand, type AnnotationBox } from '../../core/commands/annotationCommands'; import { type AnnotationKind, BEHAVIOR_PARAM, blockProblem, callProblem, drawnDef, edgesInto, edgesOutOf, ghostProblem, type Graph, type GraphNode, type PortDef, type PortSide, resolveDef, namesBehavior, RUN_BEHAVIOR, startStateOf, } from '../../core/graph'; import type { GraphAsset } from '../../core/model/Asset'; import { graphLookup } from '../../core/model/graphLookup'; import { particleLibrary } from '../../viewport/ParticleLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { findAsset, findGraphAsset } from '../../core/model/Project'; import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem'; import { BeadClock } from './beads'; import { activeDrag, type DragPayload, drawBand, endDrag, onDragChange, pressCount, requestRender, type MenuItem, type Size, Theme, textStyle, toHex, truncate, Widget, zoomFactor, crispText, } from '../../ui'; import { annotationSelection, edgeSelection, type EditorState, graphSelection, nodeSelection, } from '../state'; import { canPasteGraph, copySelection, pasteGraph } from '../clipboard'; import { type Box, boundsOf, boxesInBand, boxesWithinBand, frameBoxes } from '../framing'; import { annotationBoxes, boxOf } from './annotationDrag'; import { AnnotationLayer } from './annotationLayer'; import { ANNOTATION_TITLE_HEIGHT, type AnnotationView, RESIZE_HANDLE } from './AnnotationView'; import { type BlockFrame, blockFrames, drawBlockFrames } from './blockFrames'; import { EdgeLayer } from './EdgeLayer'; import { DRAG_THRESHOLD, GraphDrags } from './graphDrags'; import type { GraphCanvasHost } from './graphHost'; import { openAnnotationMenu, openEdgeMenu, openLinkMenu, openNodeMenu } from './graphMenus'; import { NodePalette, type PaletteAction } from './NodePalette'; import { splitEntries } from './splitMenu'; import { type PaletteEntry, paletteEntries } from './paletteEntries'; import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView'; import { drawGraphGrid, FIT_MARGIN, inView, ZOOM_LIMITS, zoomedAbout } from './graphView'; import { GraphWiring } from './wiring'; /** One running instance's answer for a machine: which state it holds, and whose instance it is. */ export interface LiveMachineState { state: string; /** Object id of the behavior itself, and of the object it hangs under (empty when it has none). */ objectId: string; ownerId: string; } /** Stand-in node height for a node with no view yet – real nodes vary with their port count. */ const NODE_HEIGHT_ESTIMATE = 80; /** Live values change every frame; ten updates a second are still readable and cost far less text layout. */ const LIVE_VALUE_INTERVAL_MS = 100; /** A group or comment created out of nothing – large enough to drop a few nodes into. */ const NEW_GROUP_SIZE = { w: 320, h: 200 }; const NEW_NOTE_SIZE = { w: 220, h: 96 }; /** Air a group keeps around the nodes it was drawn around; the top also holds the title bar. */ const GROUP_MARGIN = 20; /** How far from a link, in screen pixels, a press still means that link. */ const EDGE_TOLERANCE = 6; /** * 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 clipped = new Container(); private readonly canvas = new Container(); private readonly annotationLayer = new AnnotationLayer((view, e) => this.onAnnotationPointerDown(view, e)); private readonly blockLayer = new Graphics(); private drawnBlocks: BlockFrame[] = []; 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 = crispText({ 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 snapping = false; private readonly wiring: GraphWiring; private readonly drags: GraphDrags; /** 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; /** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */ private needsFit = true; private valuesShownAt = 0; private readonly beads = new BeadClock(); /** Last place the pointer was seen, in global coordinates – where a paste lands. */ private pointerGlobal: { x: number; y: number } | null = null; /** Supplies live execution data while playing (null otherwise). */ traceProvider: (assetId: string) => Trace | null = () => null; /** Which state each running instance of this machine holds; empty when nothing runs. */ stateProvider: (assetId: string) => readonly LiveMachineState[] = () => []; 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(); const host: GraphCanvasHost = { state: this.state, assetId: () => this.assetId, asset: () => this.asset(), views: () => this.views, topDownViews: () => this.topDownViews(), nodeUnder: (global) => this.nodeUnder(global.x, global.y), nodeBoxes: () => this.nodeBoxes(), toGraph: (global) => { const local = this.toLocal(global); return this.screenToGraph(local.x, local.y); }, zoom: () => this.zoom, snapping: () => this.snapping, entries: () => this.entries(), newNode: (entry, at) => this.newNode(entry, at), redraw: () => this.redraw(), moved: () => this.moved(), }; this.wiring = new GraphWiring(host); this.drags = new GraphDrags(host); this.canvas.addChild( this.annotationLayer, this.blockLayer, this.edgeLayer, this.nodeLayer, this.tempEdge, ); this.clipped.addChild(this.canvas); this.addChild(this.bg, this.grid, this.clipped, this.band, this.hint, this.clip); this.clipped.mask = this.clip; // A group of its own: panning and zooming move it as one, and a node that enters or leaves the // picture rebuilds the drawing of the graph only, not of the whole editor. The mask sits one // level above it: on the group itself it would be panned and zoomed along with the graph. this.canvas.isRenderGroup = true; 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) => { ({ pan: this.pan, zoom: this.zoom } = zoomedAbout( { pan: this.pan, zoom: this.zoom }, this.toLocal(e.global), zoomFactor(e.deltaY), )); 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; // Its source node is in the graph being left, so the click that finished it would land nowhere. this.wiring.cancel(); this.assetId = assetId; this.sync(); this.needsFit = true; this.fitView(); } fit(): void { this.needsFit = true; this.fitView(); } saveView(): GraphViewState { return { pan: { ...this.pan }, zoom: this.zoom, needsFit: this.needsFit }; } restoreView(view: unknown): void { const saved = view as GraphViewState; if (saved.needsFit) return; this.pan = { ...saved.pan }; this.zoom = saved.zoom; this.needsFit = false; this.applyView(); } /** 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 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 = ''; this.wiring.cancel(); for (const view of this.views.values()) view.destroy({ children: true }); this.views.clear(); this.annotationLayer.clearViews(); this.redraw(); return; } this.annotationLayer.sync(asset.graph); const seen = new Set(); const lookup = graphLookup(this.state.project); // In a compiled graph a node's problem is the compiler's, not a call's. const shaderBuild = asset.type === 'shader' ? shaderLibrary.buildFor(asset) : null; const compilerProblems = shaderBuild ? { ...shaderBuild.hints, ...shaderBuild.problems } : asset.type === 'particles' ? particleLibrary.buildFor(asset).problems : null; for (const node of asset.graph.nodes) { const resolved = resolveDef(node, asset.graph, lookup); if (!resolved) continue; const def = drawnDef(node, asset.graph, resolved, lookup); 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); } const problem = (compilerProblems ? (compilerProblems[node.id] ?? null) : callProblem(node, lookup, asset.id)) ?? ghostProblem(node, asset.graph, resolved) ?? blockProblem(asset.graph, node); view.sync(node, asset.graph, this.state.project, def, problem); } for (const [id, view] of this.views) { if (!seen.has(id)) { view.destroy({ children: true }); this.views.delete(id); } } this.redraw(); } /** * A drag moved nodes or boxes in the model. Nothing a node shows depends on where it stands, so * the views are put there and the links drawn again – without asking every node what it is. */ private moved(): void { const asset = this.asset(); if (!asset) return; this.annotationLayer.sync(asset.graph); for (const node of asset.graph.nodes) this.views.get(node.id)?.position.set(node.x, node.y); this.redraw(); } private onAnnotationPointerDown(view: AnnotationView, e: FederatedPointerEvent): void { if (e.button === 1 || !this.assetId) return; e.stopPropagation(); // An armed transition eats the click, exactly as it does over a node. if (this.wiring.isArmed && (e.button === 0 || e.button === 2)) { this.wiring.finish(e.global, false); return; } // A link runs over the box it crosses, so it is the nearer thing under the pointer. if (this.pressEdge(e)) return; const id = view.annotation.id; // Grabbing a box that is already part of a wider selection keeps that selection, the way a node // does: otherwise dragging one of several would silently drop the rest first. if (!this.state.selectedAnnotationIds.includes(id)) this.state.select(annotationSelection(this.assetId, [id])); if (e.button === 2) { openAnnotationMenu(this.state, this.assetId, view.kind, id, e.global.x, e.global.y); return; } if (e.button === 0) this.drags.grabBox(view, e.global); } /** Adds a group or comment and selects it, so its title can be typed straight away. */ private addAnnotation(kind: AnnotationKind, box: AnnotationBox, text: string): void { if (!this.assetId) return; const command = new AddAnnotationCommand(this.assetId, kind, box, text); if (this.state.execute(command)) this.state.select(annotationSelection(this.assetId, [command.annotation.id])); } /** A group drawn around the selected nodes, with room for the title bar above them. */ groupSelectedNodes(): void { const ids = new Set(this.state.selectedNodeIds); const bounds = boundsOf(this.nodeBoxes().filter((b) => ids.has(b.id))); if (!bounds) return; this.addAnnotation( 'group', { x: bounds.minX - GROUP_MARGIN, y: bounds.minY - GROUP_MARGIN - ANNOTATION_TITLE_HEIGHT, w: bounds.maxX - bounds.minX + GROUP_MARGIN * 2, h: bounds.maxY - bounds.minY + GROUP_MARGIN * 2 + ANNOTATION_TITLE_HEIGHT, }, 'Group', ); } /** * The one entry that marks a state as the machine's start, or takes the mark off it again – the * same place, so it reads as the switch it is. Only a state machine has one to give. */ private startItems(nodeId: string): MenuItem[] { const asset = this.asset(); if (asset?.type !== 'statemachine') return []; const isStart = startStateOf(asset.graph, asset.start) === nodeId; return [ { label: isStart ? 'Remove start state' : 'Set as start state', onSelect: () => this.state.execute(new SetMachineStartCommand(asset.id, isStart ? '' : nodeId)), }, ]; } /** * Which states to light up while something runs: every state an instance holds, and strongly the * one held by the selected object's instance. Without a selection nothing is strong – that keeps * "many instances, many states" readable instead of picking one of them at random. */ private stateMarks(asset: GraphAsset): Map { const marks = new Map(); if (asset.type !== 'statemachine') return marks; const selection = this.state.selection; const picked = selection?.kind === 'object' ? selection.objectId : ''; for (const live of this.stateProvider(asset.id)) { const mine = picked !== '' && (live.objectId === picked || live.ownerId === picked); if (mine || marks.get(live.state) !== 'current') marks.set(live.state, mine ? 'current' : 'held'); } return marks; } /** What the last redraw marked, for the checks: the drawn state, not a recomputation of it. */ stateMarksDrawn(): Record { const marks: Record = {}; for (const [id, view] of this.views) if (view.stateMark !== 'none') marks[id] = view.stateMark; return marks; } /** Whether the start ring is drawn on this node – the same rule. */ startRingDrawn(nodeId: string): boolean { return this.views.get(nodeId)?.isStart ?? false; } /** 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(); // Groups and comments are part of the picture: a fit that framed only the nodes would cut the // box drawn around them, and a graph holding nothing but a comment would frame the origin. const boxes = asset ? [...this.nodeBoxes(), ...annotationBoxes(asset.graph)] : []; if (boxes.length === 0) { this.pan = { x: this.w / 2 - NODE_WIDTH / 2, y: this.h / 3 }; this.zoom = 1; this.applyView(); return; } const framing = frameBoxes(boxes, { w: this.w, h: this.h }, FIT_MARGIN, ZOOM_LIMITS); 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, }; }); } /** * Moves the picture, and draws anew only what is laid out on the screen rather than in the graph: * the grid and the band. Nodes and links keep their shapes – a pan that rebuilt them on every * pointer move cost more than a frame in a graph of a few hundred nodes (#292). */ private applyView(): void { this.canvas.position.set(Math.round(this.pan.x), Math.round(this.pan.y)); this.canvas.scale.set(this.zoom); this.syncGrid(); this.cull(); this.drawBand(); requestRender(); } /** Takes the nodes outside the picture out of the drawing – see `inView`. */ private cull(): void { const view = { pan: this.pan, zoom: this.zoom }; for (const node of this.views.values()) node.visible = inView(node, view, this.w, this.h); } /** How many nodes the last redraw left in the picture. */ nodesDrawn(): number { let drawn = 0; for (const node of this.views.values()) if (node.visible) drawn += 1; return drawn; } /** How often the texts and boxes of the nodes were really made, and the links drawn – a pan must add nothing. */ partsMade(): { texts: number; box: number; links: number } { const made = { texts: 0, box: 0, links: this.edgeLayer.drawCount }; for (const node of this.views.values()) { made.texts += node.made.texts; made.box += node.made.box; } return made; } 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; // Away from every state, so there is nothing to transition to: the click drops the attempt. if (this.wiring.isArmed && (e.button === 0 || e.button === 2)) { this.wiring.finish(e.global, false); return; } const local = this.toLocal(e.global); const g = this.screenToGraph(local.x, local.y); const mark = this.edgeLayer.markAt(g); if (!mark && this.pressEdge(e)) return; // Shift-drag on empty space pans as well, as it does in the scene view. if (e.button === 0 && e.shiftKey) { startPan(); return; } 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 }; } } /** * A press on a link (#289): it selects the link, Ctrl or Shift adds or takes it, and the right * button opens its menu. False when no link is under the pointer. */ private pressEdge(e: FederatedPointerEvent): boolean { if (!this.assetId || (e.button !== 0 && e.button !== 2)) return false; const local = this.toLocal(e.global); const id = this.edgeLayer.edgeAt(this.screenToGraph(local.x, local.y), EDGE_TOLERANCE / this.zoom); if (!id) return false; if (e.button === 0 && (e.ctrlKey || e.metaKey || e.shiftKey)) { this.state.toggleEdge(this.assetId, id); return true; } // Pressing a link of a wider selection keeps that selection, so its menu speaks about all of it. if (!this.state.selectedEdgeIds.includes(id)) this.state.select(edgeSelection(this.assetId, [id])); if (e.button === 2) openEdgeMenu(this.state, this.assetId, e.global.x, e.global.y); return true; } 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(); // An armed transition eats the next click whole: on another state it lands, anywhere else it // is dropped. Selecting or dragging the node underneath would be a second meaning for one click. if (this.wiring.isArmed && (e.button === 0 || e.button === 2)) { this.wiring.finish(e.global, e.button === 0); return; } 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; const extras = hit.isOutput ? splitEntries(this.state, this.assetId, view.node, hit.port) : []; this.openLinks( (g) => (hit.isOutput ? edgesOutOf(g, id, key) : edgesInto(g, id, key)), e.global, extras, ); } 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])); const at = this.toLocal(e.global); const graphPoint = this.screenToGraph(at.x, at.y); const picked = this.state.selectedNodeIds.length; const extras: MenuItem[] = [ ...(view.def.transitionEdges === true ? [ { label: 'Make transition', onSelect: () => this.wiring.arm(id, graphPoint) }, ...this.startItems(id), ] : []), { label: picked > 1 ? `Group these ${picked} nodes` : 'Group this node', onSelect: () => this.groupSelectedNodes(), }, { label: picked > 1 ? `Copy ${picked} nodes` : 'Copy', onSelect: () => copySelection(this.state) }, ]; openNodeMenu(this.state, this.assetId, this.selectedNodes(), e.global.x, e.global.y, extras); } return; } if (e.button !== 0) return; // A ghost pin is a handle for the right-click menu only: nothing to drag from, and dragging the // node instead would make a press on the very edge of it move the whole node. if (hit?.port.ghost) return; if (hit) { this.wiring.startDrag({ nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, e.global); 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])); if (this.drags.grabNodes(e.global)) this.nodeLayer.addChild(view); // raise } private openCalledBehavior(node: GraphNode): boolean { if (!namesBehavior(node.type)) 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)); } /** Ctrl+V: under the pointer while it is over this canvas, else a grid step beside the original. */ paste(): boolean { return this.assetId ? pasteGraph(this.state, this.assetId, this.pointerGraph(), this.snapping) : false; } /** The pointer in graph units, or null when it is not over this canvas. */ private pointerGraph(): { x: number; y: number } | null { const global = this.pointerGlobal; if (!global) return null; const local = this.toLocal(global); if (local.x < 0 || local.y < 0 || local.x > this.w || local.y > this.h) return null; return this.screenToGraph(local.x, local.y); } private onPointerMove(e: FederatedPointerEvent): void { this.pointerGlobal = { x: e.global.x, y: e.global.y }; 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; } if (this.drags.move(e.global) || this.wiring.move(e.global)) return; const local = this.toLocal(e.global); const g = this.screenToGraph(local.x, local.y); 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.drawBand(); } } /** * 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.drawBand(); } 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; if (this.drags.releaseBox()) { this.redraw(); return; } 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 }); } this.drags.releaseNodes(); this.wiring.release(e.global); this.redraw(); } private selectInMarquee(marquee: { start: { x: number; y: number }; current: { x: number; y: number }; }): void { if (!this.assetId) return; const band = { x1: marquee.start.x, y1: marquee.start.y, x2: marquee.current.x, y2: marquee.current.y, }; const ids = boxesInBand(this.nodeBoxes(), band); // A box counts only when the band holds all of it – see `boxesWithinBand`. const graph = this.asset()?.graph; const boxIds = graph ? boxesWithinBand(annotationBoxes(graph), band) : []; // An empty band clears the selection but keeps the behavior, as a plain click does. this.state.select( graphSelection(this.assetId, ids, boxIds, this.edgeLayer.edgesIn(band)) ?? { kind: 'asset', assetId: this.assetId, }, ); } private nodeUnder(globalX: number, globalY: number): string | null { for (const view of this.topDownViews()) { const p = view.toLocal({ x: globalX, y: globalY }); if (view.containsLocal(p.x, p.y)) 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), undefined, this.annotationActions(at), ).open(globalX, globalY); } /** What the palette offers that is not a node. All of it lands where it was opened. */ private annotationActions(at: { x: number; y: number }): PaletteAction[] { return [ ...(canPasteGraph(this.state) ? [ { id: 'paste', label: 'Paste', hint: 'clipboard', match: 'paste clipboard', onSelect: () => void pasteGraph(this.state, this.assetId, at, this.snapping), }, ] : []), { id: 'add-group', label: 'Add group', hint: 'box', match: 'group box frame', onSelect: () => this.addAnnotation('group', { ...at, ...NEW_GROUP_SIZE }, 'Group'), }, { id: 'add-comment', label: 'Add comment', hint: 'text', match: 'comment note text', onSelect: () => this.addAnnotation('note', { ...at, ...NEW_NOTE_SIZE }, 'Comment'), }, ]; } 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])); } private openLinks( pick: (graph: Graph) => Graph['edges'], at: { x: number; y: number }, extras: readonly MenuItem[] = [], ): void { const graph = this.asset()?.graph; if (graph) openLinkMenu(this.state, this.assetId, pick(graph), at.x, at.y, extras); } /** Shortened to the canvas: since the Particles tab put a preview beside it, a canvas can be narrow. */ private setHint(text: string): void { this.hint.text = truncate(text, this.hint.style, this.w - Theme.padding * 2); } 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.setHint(`No ${this.noun} selected – pick one above, or add one.`); return; } const playing = this.state.mode === 'play'; this.setHint( this.wiring.isArmed ? 'Click the state to transition to, this one to re-enter it – anywhere else, or Escape, cancels' : 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.beads.launch(asset.id, trace?.edges ?? null); 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 selectedAnnotations = new Set(this.state.selectedAnnotationIds); this.annotationLayer.redrawViews(selectedAnnotations); const target = this.wiring.target; const wall = performance.now(); const values = trace && wall - this.valuesShownAt >= LIVE_VALUE_INTERVAL_MS ? trace.values : null; if (values) this.valuesShownAt = wall; const marks = this.stateMarks(asset); const start = asset.type === 'statemachine' ? startStateOf(asset.graph, asset.start) : ''; for (const view of this.views.values()) { view.selected = selectedNodes.has(view.node.id); view.hot = isHot(trace?.nodes, view.node.id); view.isStart = view.node.id === start; view.stateMark = marks.get(view.node.id) ?? 'none'; 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.cull(); this.drawnBlocks = blockFrames(asset.graph, (id) => { const view = this.views.get(id); return view ? { id, x: view.x, y: view.y, w: view.w, h: view.h } : null; }); drawBlockFrames(this.blockLayer, this.drawnBlocks); this.edgeLayer.draw( asset.graph.edges, this.views, (id) => isHot(trace?.edges, id), selectedNodes, (id) => this.beads.inFlight(id, now), new Set(this.state.selectedEdgeIds), ); this.wiring.draw(this.tempEdge, asset.graph); this.drawBand(); } /** The rubber band and the place a dragged behavior would land, both in screen space. */ private drawBand(): void { const c = Theme.colors; this.band.clear(); if (!this.asset()) return; 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 }); } } private syncGrid(): void { drawGraphGrid(this.grid, { pan: this.pan, zoom: this.zoom }, this.w, this.h); } /** 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); return view && p ? globalPoint(view, p) : null; } /** The port a running edge drag would connect to, or null when the drop would do nothing. */ pendingDropTarget(): PortSide | null { return this.wiring.target; } /** Whether the dangling edge is on screen right now (running drag, or a drop awaiting its node). */ danglingEdgeVisible(): boolean { return this.wiring.edgeShown; } /** The frames the last redraw put behind the `Find` … `Match` blocks, in graph units. */ blockFrames(): BlockFrame[] { return this.drawnBlocks; } /** Whether the last redraw stroked this edge as a curve. */ edgeDrawn(edgeId: string): boolean { return this.edgeLayer.wasStroked(edgeId); } /** As a hex string, so a test can name the colour it expects: `danger` marks an edge gone invalid. */ edgeColor(edgeId: string): string | null { const color = this.edgeLayer.strokeColor(edgeId); return color === null ? null : `#${color.toString(16).padStart(6, '0')}`; } /** * The port types the node is drawn with – what a shared group (#207) makes of its inputs. `paint` * is what an input's pin is *coloured* as, which can differ: the type stays `any`, the pin follows * the edge feeding it. */ portTypes(nodeId: string): { inputs: Record; outputs: Record; paint: Record; } | null { const view = this.views.get(nodeId); if (!view) return null; const side = (ports: readonly PortDef[]) => Object.fromEntries(ports.map((p) => [p.key, p.type])); return { inputs: side(view.def.inputs), outputs: side(view.def.outputs), paint: Object.fromEntries(view.def.inputs.map((p) => [p.key, p.paintType ?? p.type])), }; } /** Halfway along a link as drawn, on screen – where a check clicks it. Null for one without a line. */ edgeGlobal(edgeId: string): { x: number; y: number } | null { const middle = this.edgeLayer.edgeMiddle(edgeId); return middle ? globalPoint(this.edgeLayer, middle) : null; } /** The links the last redraw marked as picked. */ pickedEdgesDrawn(): string[] { return this.edgeLayer.pickedDrawn(); } /** The name drawn on a transition, or null when nothing is drawn on it. */ transitionLabelDrawn(edgeId: string): string | null { return this.edgeLayer.drawnTransitionLabels().find((l) => l.edgeId === edgeId)?.text ?? null; } /** Where that name's plate is drawn on screen, top-left corner and size – null when nothing is drawn. */ transitionLabelAt(edgeId: string): { x: number; y: number; w: number; h: number } | null { const label = this.edgeLayer.drawnTransitionLabels().find((l) => l.edgeId === edgeId); if (!label) return null; const corner = globalPoint(this.edgeLayer, label); const far = globalPoint(this.edgeLayer, { x: label.x + label.w, y: label.y + label.h }); return { ...corner, w: far.x - corner.x, h: far.y - corner.y }; } /** Whether a pin can be grabbed on this node at all – false for a state, whose ports are hidden. */ portGrabbable(nodeId: string, port: string, isOutput: boolean): boolean { const view = this.views.get(nodeId); const p = view?.portPosition(port, isOutput); return view !== undefined && p !== null && p !== undefined && view.portAt(p.x, p.y) !== null; } /** The line at the bottom of the canvas – what tells the user an armed transition is waiting. */ shownHint(): string { return this.hint.text; } 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; return { ...globalPoint(this.canvas, mark), 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; } 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); return view ? globalPoint(view, { x: view.w / 2, y: TITLE_HEIGHT / 2 }) : null; } /** Every group and comment as it is drawn: the box, the text on it, and both its colours. */ annotationsDrawn(): { id: string; kind: AnnotationKind; text: string; color: string; textColor: string; selected: boolean; x: number; y: number; w: number; h: number; }[] { return [...this.annotationLayer.views.values()].map((view) => ({ id: view.annotation.id, kind: view.kind, text: view.drawnText(), color: toHex(view.color), textColor: toHex(view.textColor), selected: view.selected, ...boxOf(view.annotation), })); } /** Where a group or comment is grabbed: its title bar, or – with `corner` – its resize handle. */ annotationGlobal(id: string, corner = false): { x: number; y: number } | null { const view = this.annotationLayer.views.get(id); if (!view) return null; const local = corner ? { x: view.w - RESIZE_HANDLE / 2, y: view.h - RESIZE_HANDLE / 2 } : { x: view.w / 2, y: ANNOTATION_TITLE_HEIGHT / 2 }; return globalPoint(view, local); } /** The rectangle a box is drawn as, on screen – what a fit has to bring into view. */ annotationRect(id: string): Rect | null { const view = this.annotationLayer.views.get(id); return view ? globalRect(view, view.w, view.h) : null; } 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 }; /** Zoom and scroll of one graph, kept while another document is on screen (#171). */ interface GraphViewState { pan: { x: number; y: number }; zoom: number; needsFit: boolean; } function globalPoint(target: Container, local: { x: number; y: number }): { x: number; y: number } { const g = target.toGlobal({ x: local.x, y: local.y }); return { x: g.x, y: g.y }; } 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 }; }