Split wiring, drags and the frame layer out of GraphCanvas (#215)

GraphCanvas had grown to 1406 lines. Wiring, dragging and the frame
views each carry their own state, so they now live in their own
classes behind a narrow host interface. Behaviour is unchanged.

- GraphCanvasHost is what the new classes see of the canvas.
- Wiring owns dragging a pin, the palette on a drop into empty space
  and "Make transition".
- GraphDrags owns moving nodes and frames, including snapping.
- AnnotationLayer holds the frame views. The test hooks stay on the
  canvas and read the views through the layer.
- BeadClock moves into beads.ts and can now be tested without a
  browser.
- The canvas builds host, wiring and drags in its constructor because
  state is a parameter property and not set during field init.
- GraphCanvas is down to 1019 lines.

Tests:
- ./check.sh: 145 files, 2192 tests, CHECK EXIT 0 (+2 for BeadClock)
- e2e 1-28,31,36: ALL PASSED, 932 checks
- New e2e check in 36k: a frame caught by the rubber band is also
  drawn as selected. No check covered that before. Against a bent
  build it fails with selected: false.
- Counter-checks for drop target, snapping and beads fail exactly
  the expected checks.
- Editor check sum is now 1314 (CI measured 1313 at 487b3aa).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-23 10:20:05 +02:00
1 parent e31db3df1d
commit b830f9c747
9 files changed
+631 -454

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1313 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1314 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+66 -453
View File
@@ -1,42 +1,22 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { textOf } from '../../core/model/Properties'; import { textOf } from '../../core/model/Properties';
import type { Command } from '../../core/commands/Command'; import { AddNodeCommand, CollapseNodesCommand } from '../../core/commands/graphCommands';
import { oneStep } from '../../core/commands/CompoundCommand';
import { AddNodeCommand, CollapseNodesCommand, ConnectCommand } from '../../core/commands/graphCommands';
import { SetMachineStartCommand } from '../../core/commands/assetCommands'; import { SetMachineStartCommand } from '../../core/commands/assetCommands';
import { import { AddAnnotationCommand, type AnnotationBox } from '../../core/commands/annotationCommands';
AddAnnotationCommand,
type AnnotationBox,
annotationNoun,
normalizeBox,
} from '../../core/commands/annotationCommands';
import { import {
type AnnotationKind, type AnnotationKind,
ANNOTATION_KINDS,
annotationsOf,
BEHAVIOR_PARAM, BEHAVIOR_PARAM,
callProblem, callProblem,
canConnect,
defAcceptsFrom,
drawnDef, drawnDef,
dropTargetPort,
edgesInto, edgesInto,
edgesOutOf, edgesOutOf,
findAnnotation,
firstPortFor,
ghostProblem, ghostProblem,
type Graph, type Graph,
type GraphNode, type GraphNode,
type GraphLookup,
MIN_ANNOTATION_SIZE,
type PortDef, type PortDef,
type PortSide, type PortSide,
type PortType,
portTypeOf,
resolveDef, resolveDef,
RUN_BEHAVIOR, RUN_BEHAVIOR,
STATE_IN,
STATE_OUT,
startStateOf, startStateOf,
} from '../../core/graph'; } from '../../core/graph';
import type { GraphAsset } from '../../core/model/Asset'; import type { GraphAsset } from '../../core/model/Asset';
@@ -45,7 +25,7 @@ import { particleLibrary } from '../../viewport/ParticleLibrary';
import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { findAsset, findGraphAsset } from '../../core/model/Project'; import { findAsset, findGraphAsset } from '../../core/model/Project';
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem'; import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { beadsInFlight, launchBead } from './beads'; import { BeadClock } from './beads';
import { import {
activeDrag, activeDrag,
type DragPayload, type DragPayload,
@@ -59,38 +39,23 @@ import {
textStyle, textStyle,
toHex, toHex,
truncate, truncate,
ui,
Widget, Widget,
zoomFactor, zoomFactor,
} from '../../ui'; } from '../../ui';
import { annotationSelection, type EditorState, graphSelection, nodeSelection } from '../state'; import { annotationSelection, type EditorState, graphSelection, nodeSelection } from '../state';
import { import { type Box, boundsOf, boxesInBand, boxesWithinBand, frameBoxes } from '../framing';
type Box, import { annotationBoxes, boxOf } from './annotationDrag';
boundsOf, import { AnnotationLayer } from './annotationLayer';
boxesInBand, import { ANNOTATION_TITLE_HEIGHT, type AnnotationView, RESIZE_HANDLE } from './AnnotationView';
boxesWithinBand, import { EdgeLayer } from './EdgeLayer';
dragOffset, import { DRAG_THRESHOLD, GraphDrags } from './graphDrags';
frameBoxes, import type { GraphCanvasHost } from './graphHost';
resizeSize,
} from '../framing';
import {
annotationBoxes,
boxOf,
type BoxSeed,
boxSeeds,
placeTravellers,
recordTravellers,
travellers,
type Travellers,
} from './annotationDrag';
import { ANNOTATION_TITLE_HEIGHT, AnnotationView, RESIZE_HANDLE } from './AnnotationView';
import { arrowHead, bezier, EdgeLayer, loopPath } from './EdgeLayer';
import { arrowShaft, type Point, selfLoop, transitionPreview } from './transitions';
import { openAnnotationMenu, openLinkMenu, openNodeMenu } from './graphMenus'; import { openAnnotationMenu, openLinkMenu, openNodeMenu } from './graphMenus';
import { NodePalette, type PaletteAction } from './NodePalette'; import { NodePalette, type PaletteAction } from './NodePalette';
import { type PaletteEntry, paletteEntries } from './paletteEntries'; import { type PaletteEntry, paletteEntries } from './paletteEntries';
import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView'; import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView';
import { drawGraphGrid, FIT_MARGIN, GRID_STEP, ZOOM_LIMITS, zoomedAbout } from './graphView'; import { drawGraphGrid, FIT_MARGIN, 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. */ /** One running instance's answer for a machine: which state it holds, and whose instance it is. */
export interface LiveMachineState { export interface LiveMachineState {
@@ -102,8 +67,6 @@ export interface LiveMachineState {
/** Stand-in node height for a node with no view yet – real nodes vary with their port count. */ /** Stand-in node height for a node with no view yet – real nodes vary with their port count. */
const NODE_HEIGHT_ESTIMATE = 80; const NODE_HEIGHT_ESTIMATE = 80;
/** 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. */ /** Live values change every frame; ten updates a second are still readable and cost far less text layout. */
const LIVE_VALUE_INTERVAL_MS = 100; const LIVE_VALUE_INTERVAL_MS = 100;
/** A group or comment created out of nothing – large enough to drop a few nodes into. */ /** A group or comment created out of nothing – large enough to drop a few nodes into. */
@@ -121,8 +84,7 @@ export class GraphCanvas extends Widget {
private readonly grid = new Graphics(); private readonly grid = new Graphics();
private readonly clip = new Graphics(); private readonly clip = new Graphics();
private readonly canvas = new Container(); private readonly canvas = new Container();
/** Behind the edges: a group is a backdrop, and a comment must never cover a node. */ private readonly annotationLayer = new AnnotationLayer((view, e) => this.onAnnotationPointerDown(view, e));
private readonly annotationLayer = new Container();
private readonly edgeLayer = new EdgeLayer(); private readonly edgeLayer = new EdgeLayer();
private readonly nodeLayer = new Container(); private readonly nodeLayer = new Container();
private readonly tempEdge = new Graphics(); private readonly tempEdge = new Graphics();
@@ -130,26 +92,13 @@ export class GraphCanvas extends Widget {
private readonly band = new Graphics(); private readonly band = new Graphics();
private readonly hint = new Text({ text: '', style: textStyle('muted') }); private readonly hint = new Text({ text: '', style: textStyle('muted') });
private readonly views = new Map<string, NodeView>(); private readonly views = new Map<string, NodeView>();
private readonly annotations = new Map<string, AnnotationView>();
private assetId = ''; private assetId = '';
private pan = { x: 0, y: 0 }; private pan = { x: 0, y: 0 };
private zoom = 1; private zoom = 1;
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null; private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
/** Nodes being dragged, with the boxes a band caught alongside them – they travel together. */
private dragging: { along: Travellers; pointer: { x: number; y: number }; moved: boolean } | null = null;
/**
* A group or comment being moved or resized. `along` is fixed when the drag begins: which nodes a
* group carries is a question about where they were when it was grabbed, not a running one –
* otherwise a node would join the group halfway through the gesture that passes over it.
*/
private annotationDrag: {
grabbed: BoxSeed;
mode: 'move' | 'resize';
along: Travellers;
pointer: { x: number; y: number };
moved: boolean;
} | null = null;
private snapping = false; private snapping = false;
private readonly wiring: GraphWiring;
private readonly drags: GraphDrags;
/** A rubber-band selection in progress, in graph coordinates. */ /** A rubber-band selection in progress, in graph coordinates. */
private marquee: { private marquee: {
start: { x: number; y: number }; start: { x: number; y: number };
@@ -158,25 +107,10 @@ export class GraphCanvas extends Widget {
} | null = null; } | null = null;
/** A behavior dragged in from the asset panel, and why it may not land. */ /** 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 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;
/** A transition begun from a state's menu, waiting for the click that names its target. It takes
* the shape of a drag because it is one in everything but the held button – that way the running
* preview draws it without a second code path. `release` takes the Escape handler back off. */
private armed: { from: PortSide; pointer: { x: number; y: number }; release: () => void } | 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). */ /** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */
private needsFit = true; private needsFit = true;
private valuesShownAt = 0; private valuesShownAt = 0;
/** In game time, so a paused game holds its beads. */ private readonly beads = new BeadClock();
private readonly beadTimes = new Map<string, number[]>();
private readonly beadFired = new Map<string, number>();
private beadAsset = '';
/** Supplies live execution data while playing (null otherwise). */ /** Supplies live execution data while playing (null otherwise). */
traceProvider: (assetId: string) => Trace | null = () => null; traceProvider: (assetId: string) => Trace | null = () => null;
/** Which state each running instance of this machine holds; empty when nothing runs. */ /** Which state each running instance of this machine holds; empty when nothing runs. */
@@ -191,6 +125,26 @@ export class GraphCanvas extends Widget {
private readonly noun = 'behavior', private readonly noun = 'behavior',
) { ) {
super(); 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(),
};
this.wiring = new GraphWiring(host);
this.drags = new GraphDrags(host);
this.canvas.addChild(this.annotationLayer, this.edgeLayer, this.nodeLayer, this.tempEdge); this.canvas.addChild(this.annotationLayer, this.edgeLayer, this.nodeLayer, this.tempEdge);
this.addChild(this.bg, this.grid, this.canvas, this.band, this.hint, this.clip); this.addChild(this.bg, this.grid, this.canvas, this.band, this.hint, this.clip);
this.canvas.mask = this.clip; this.canvas.mask = this.clip;
@@ -229,7 +183,7 @@ export class GraphCanvas extends Widget {
show(assetId: string): void { show(assetId: string): void {
if (this.assetId === assetId) return; if (this.assetId === assetId) return;
// Its source node is in the graph being left, so the click that finished it would land nowhere. // Its source node is in the graph being left, so the click that finished it would land nowhere.
this.cancelTransition(); this.wiring.cancel();
this.assetId = assetId; this.assetId = assetId;
this.sync(); this.sync();
this.needsFit = true; this.needsFit = true;
@@ -272,10 +226,6 @@ export class GraphCanvas extends Widget {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) }; 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 { private asset(): GraphAsset | null {
return (this.assetId && findGraphAsset(this.state.project, this.assetId)) || null; return (this.assetId && findGraphAsset(this.state.project, this.assetId)) || null;
} }
@@ -286,14 +236,14 @@ export class GraphCanvas extends Widget {
if (!asset) { if (!asset) {
// An asset that was deleted (or undone away) must not linger as a graph. // An asset that was deleted (or undone away) must not linger as a graph.
this.assetId = ''; this.assetId = '';
this.cancelTransition(); this.wiring.cancel();
for (const view of this.views.values()) view.destroy({ children: true }); for (const view of this.views.values()) view.destroy({ children: true });
this.views.clear(); this.views.clear();
this.clearAnnotations(); this.annotationLayer.clearViews();
this.redraw(); this.redraw();
return; return;
} }
this.syncAnnotations(asset.graph); this.annotationLayer.sync(asset.graph);
const seen = new Set<string>(); const seen = new Set<string>();
const lookup = graphLookup(this.state.project); const lookup = graphLookup(this.state.project);
// In a compiled graph a node's problem is the compiler's, not a call's. // In a compiled graph a node's problem is the compiler's, not a call's.
@@ -330,42 +280,12 @@ export class GraphCanvas extends Widget {
this.redraw(); this.redraw();
} }
private clearAnnotations(): void {
for (const view of this.annotations.values()) view.destroy({ children: true });
this.annotations.clear();
}
/** Same shape as `sync`: views are reused by id, and a box that is gone takes its view with it. */
private syncAnnotations(graph: Graph): void {
const seen = new Set<string>();
for (const kind of ANNOTATION_KINDS) {
for (const annotation of annotationsOf(graph, kind)) {
seen.add(annotation.id);
let view = this.annotations.get(annotation.id);
if (!view || view.kind !== kind) {
view?.destroy({ children: true });
view = new AnnotationView(annotation, kind);
view.on('pointerdown', (e: FederatedPointerEvent) => this.onAnnotationPointerDown(view!, e));
this.annotations.set(annotation.id, view);
this.annotationLayer.addChild(view);
}
view.sync(annotation);
}
}
for (const [id, view] of this.annotations) {
if (!seen.has(id)) {
view.destroy({ children: true });
this.annotations.delete(id);
}
}
}
private onAnnotationPointerDown(view: AnnotationView, e: FederatedPointerEvent): void { private onAnnotationPointerDown(view: AnnotationView, e: FederatedPointerEvent): void {
if (e.button === 1 || !this.assetId) return; if (e.button === 1 || !this.assetId) return;
e.stopPropagation(); e.stopPropagation();
// An armed transition eats the click, exactly as it does over a node. // An armed transition eats the click, exactly as it does over a node.
if (this.armed && (e.button === 0 || e.button === 2)) { if (this.wiring.isArmed && (e.button === 0 || e.button === 2)) {
this.finishTransition(e.global.x, e.global.y, false); this.wiring.finish(e.global, false);
return; return;
} }
const id = view.annotation.id; const id = view.annotation.id;
@@ -377,61 +297,7 @@ export class GraphCanvas extends Widget {
openAnnotationMenu(this.state, this.assetId, view.kind, id, e.global.x, e.global.y); openAnnotationMenu(this.state, this.assetId, view.kind, id, e.global.x, e.global.y);
return; return;
} }
if (e.button !== 0) return; if (e.button === 0) this.drags.grabBox(view, e.global);
const graph = this.asset()?.graph;
if (!graph) return;
const local = view.toLocal(e.global);
const p = this.toLocal(e.global);
const grabbed: BoxSeed = { id, kind: view.kind, start: boxOf(view.annotation) };
const resizing = view.handleAt(local.x, local.y);
// A resize carries nothing: membership is re-derived from where the box ends up, so a node the
// corner sweeps over simply belongs to it after.
const seeds = [grabbed, ...boxSeeds(graph, this.state.selectedAnnotationIds, id)];
const along = resizing
? { boxes: [grabbed], nodes: [] }
: travellers(graph, this.nodeBoxes(), seeds, this.state.selectedNodeIds);
this.annotationDrag = {
grabbed,
mode: resizing ? 'resize' : 'move',
along,
pointer: this.screenToGraph(p.x, p.y),
moved: false,
};
}
/** Moves or resizes the dragged box in the model, for the preview; committed on release. */
private previewAnnotationDrag(g: { x: number; y: number }): void {
const drag = this.annotationDrag;
const graph = this.asset()?.graph;
if (!drag || !graph) return;
const dx = g.x - drag.pointer.x;
const dy = g.y - drag.pointer.y;
if (!drag.moved && Math.hypot(dx, dy) * this.zoom < DRAG_THRESHOLD) return;
drag.moved = true;
const step = this.snapping ? GRID_STEP : 0;
if (drag.mode === 'resize') {
const self = findAnnotation(graph, drag.grabbed.id)?.annotation;
if (!self) return;
const size = resizeSize(drag.grabbed.start, dx, dy, step, MIN_ANNOTATION_SIZE);
Object.assign(self, normalizeBox({ ...drag.grabbed.start, ...size }));
} else {
// One offset for all of it, the grabbed box's: what travels with a group keeps its distance.
const offset = dragOffset(drag.grabbed.start, dx, dy, step);
placeTravellers(graph, drag.along, () => offset);
}
this.state.preview();
}
/** Puts the previewed boxes back and records the whole gesture as one undo step. */
private commitAnnotationDrag(): void {
const drag = this.annotationDrag;
this.annotationDrag = null;
const graph = this.asset()?.graph;
if (!drag?.moved || !graph || !this.assetId) return;
const noun = annotationNoun(drag.grabbed.kind);
const label = drag.mode === 'resize' ? `Resize ${noun}` : undefined;
const commands = recordTravellers(graph, this.assetId, drag.along, label);
this.state.execute(oneStep(`${drag.mode === 'resize' ? 'Resize' : 'Move'} ${noun}`, commands));
} }
/** Adds a group or comment and selects it, so its title can be typed straight away. */ /** Adds a group or comment and selects it, so its title can be typed straight away. */
@@ -565,8 +431,8 @@ export class GraphCanvas extends Widget {
} }
if (e.target !== this) return; if (e.target !== this) return;
// Away from every state, so there is nothing to transition to: the click drops the attempt. // Away from every state, so there is nothing to transition to: the click drops the attempt.
if (this.armed && (e.button === 0 || e.button === 2)) { if (this.wiring.isArmed && (e.button === 0 || e.button === 2)) {
this.finishTransition(e.global.x, e.global.y, false); this.wiring.finish(e.global, false);
return; return;
} }
// Shift-drag on empty space pans as well, as it does in the scene view. // Shift-drag on empty space pans as well, as it does in the scene view.
@@ -600,8 +466,8 @@ export class GraphCanvas extends Widget {
e.stopPropagation(); e.stopPropagation();
// An armed transition eats the next click whole: on another state it lands, anywhere else it // 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. // is dropped. Selecting or dragging the node underneath would be a second meaning for one click.
if (this.armed && (e.button === 0 || e.button === 2)) { if (this.wiring.isArmed && (e.button === 0 || e.button === 2)) {
this.finishTransition(e.global.x, e.global.y, e.button === 0); this.wiring.finish(e.global, e.button === 0);
return; return;
} }
const local = view.toLocal(e.global); const local = view.toLocal(e.global);
@@ -620,7 +486,7 @@ export class GraphCanvas extends Widget {
const extras: MenuItem[] = [ const extras: MenuItem[] = [
...(view.def.transitionEdges === true ...(view.def.transitionEdges === true
? [ ? [
{ label: 'Make transition', onSelect: () => this.armTransition(id, graphPoint) }, { label: 'Make transition', onSelect: () => this.wiring.arm(id, graphPoint) },
...this.startItems(id), ...this.startItems(id),
] ]
: []), : []),
@@ -638,13 +504,7 @@ export class GraphCanvas extends Widget {
// node instead would make a press on the very edge of it move the whole node. // node instead would make a press on the very edge of it move the whole node.
if (hit?.port.ghost) return; if (hit?.port.ghost) return;
if (hit) { if (hit) {
const p = this.toLocal(e.global); this.wiring.startDrag({ nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, 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; return;
} }
// Like the hierarchy's chevron: it folds, and leaves the selection alone. // Like the hierarchy's chevron: it folds, and leaves the selection alone.
@@ -660,16 +520,7 @@ export class GraphCanvas extends Widget {
// Clicking a selected node keeps the selection so the whole group can be dragged. // Clicking a selected node keeps the selection so the whole group can be dragged.
if (!this.state.selectedNodeIds.includes(view.node.id)) if (!this.state.selectedNodeIds.includes(view.node.id))
this.state.select(nodeSelection(this.assetId, [view.node.id])); this.state.select(nodeSelection(this.assetId, [view.node.id]));
const graph = this.asset()?.graph; if (this.drags.grabNodes(e.global)) this.nodeLayer.addChild(view); // raise
if (!graph) return;
const p = this.toLocal(e.global);
const seeds = boxSeeds(graph, this.state.selectedAnnotationIds);
this.dragging = {
along: travellers(graph, this.nodeBoxes(), seeds, this.state.selectedNodeIds),
pointer: this.screenToGraph(p.x, p.y),
moved: false,
};
this.nodeLayer.addChild(view); // raise
} }
private openCalledBehavior(node: GraphNode): boolean { private openCalledBehavior(node: GraphNode): boolean {
@@ -700,30 +551,10 @@ export class GraphCanvas extends Widget {
this.applyView(); this.applyView();
return; return;
} }
if (this.drags.move(e.global) || this.wiring.move(e.global)) return;
const local = this.toLocal(e.global); const local = this.toLocal(e.global);
const g = this.screenToGraph(local.x, local.y); const g = this.screenToGraph(local.x, local.y);
if (this.annotationDrag) { if (this.marquee) {
this.previewAnnotationDrag(g);
} else 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;
// Each thing snaps for itself, node or box, so what lands on the grid is what was grabbed.
placeTravellers(graph, this.dragging.along, (start) => dragOffset(start, dx, dy, step));
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.armed) {
this.armed.pointer = g;
this.dropTarget = this.armedTarget(e.global.x, e.global.y);
this.redraw();
} else if (this.marquee) {
this.marquee.current = g; this.marquee.current = g;
const d = Math.hypot(g.x - this.marquee.start.x, g.y - this.marquee.start.y) * this.zoom; 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; if (!this.marquee.active && d < DRAG_THRESHOLD) return;
@@ -777,8 +608,7 @@ export class GraphCanvas extends Widget {
return; return;
} }
this.panning = null; this.panning = null;
if (this.annotationDrag) { if (this.drags.releaseBox()) {
this.commitAnnotationDrag();
this.redraw(); this.redraw();
return; return;
} }
@@ -788,23 +618,8 @@ export class GraphCanvas extends Widget {
if (marquee.active) this.selectInMarquee(marquee); if (marquee.active) this.selectInMarquee(marquee);
else this.state.select({ kind: 'asset', assetId: this.assetId }); else this.state.select({ kind: 'asset', assetId: this.assetId });
} }
const drag = this.dragging; this.drags.releaseNodes();
this.dragging = null; this.wiring.release(e.global);
const graph = this.asset()?.graph;
if (drag?.moved && graph && this.assetId) {
// The boxes that came along belong in the same step: one gesture, one undo.
const moves = recordTravellers(graph, this.assetId, drag.along);
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(); this.redraw();
} }
@@ -827,76 +642,6 @@ export class GraphCanvas extends Widget {
this.state.select(graphSelection(this.assetId, ids, boxIds) ?? { kind: 'asset', assetId: this.assetId }); this.state.select(graphSelection(this.assetId, ids, boxIds) ?? { 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);
// A ghost is no target, and it must not shadow the node body underneath it either.
if (hit && !hit.port.ghost) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput };
}
return null;
}
/** Null unless the drag started on a state: only those draw as a straight arrow. */
private transitionPreviewOf(
from: PortSide,
target: PortSide | null,
pointer: Point,
): { start: Point; end: Point } | null {
const source = this.views.get(from.nodeId);
if (source?.def.transitionEdges !== true) return null;
const to = target ? this.views.get(target.nodeId) : undefined;
return transitionPreview(source, to?.def.transitionEdges === true ? to : null, pointer);
}
private nodeUnder(globalX: number, globalY: number): string | null { private nodeUnder(globalX: number, globalY: number): string | null {
for (const view of this.topDownViews()) { for (const view of this.topDownViews()) {
const p = view.toLocal({ x: globalX, y: globalY }); const p = view.toLocal({ x: globalX, y: globalY });
@@ -957,79 +702,11 @@ export class GraphCanvas extends Widget {
if (this.state.execute(command)) this.state.select(nodeSelection(this.assetId, [command.node.id])); 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 { private openLinks(pick: (graph: Graph) => Graph['edges'], at: { x: number; y: number }): void {
const graph = this.asset()?.graph; const graph = this.asset()?.graph;
if (graph) openLinkMenu(this.state, this.assetId, pick(graph), at.x, at.y); if (graph) openLinkMenu(this.state, this.assetId, pick(graph), at.x, at.y);
} }
/** Begin a transition out of this state; the next click on another one completes it. */
private armTransition(nodeId: string, pointer: { x: number; y: number }): void {
this.cancelTransition();
const handlers = ui().focus.globalHandlers;
const onKey = (event: KeyboardEvent): boolean => {
if (event.key !== 'Escape' || !this.armed) return false;
this.cancelTransition();
this.redraw();
return true;
};
handlers.push(onKey);
this.armed = {
from: { nodeId, port: STATE_OUT, isOutput: true },
pointer,
release: () => {
const at = handlers.indexOf(onKey);
if (at >= 0) handlers.splice(at, 1);
},
};
this.dropTarget = null;
this.redraw();
}
private cancelTransition(): void {
this.armed?.release();
this.armed = null;
}
/** Where an armed transition would land: the state under the pointer, its own source included. */
private armedTarget(globalX: number, globalY: number): PortSide | null {
if (!this.armed) return null;
const node = this.nodeUnder(globalX, globalY);
if (!node) return null;
const view = this.views.get(node);
return view?.def.transitionEdges === true ? { nodeId: node, port: STATE_IN, isOutput: false } : null;
}
/** Finish or abandon an armed transition. True when the click belonged to it. */
private finishTransition(globalX: number, globalY: number, connect: boolean): boolean {
const armed = this.armed;
if (!armed) return false;
const target = connect ? this.armedTarget(globalX, globalY) : null;
this.cancelTransition();
this.dropTarget = null;
if (target) this.connect(armed.from, target);
this.redraw();
return true;
}
/** Shortened to the canvas: since the Particles tab put a preview beside it, a canvas can be narrow. */ /** Shortened to the canvas: since the Particles tab put a preview beside it, a canvas can be narrow. */
private setHint(text: string): void { private setHint(text: string): void {
this.hint.text = truncate(text, this.hint.style, this.w - Theme.padding * 2); this.hint.text = truncate(text, this.hint.style, this.w - Theme.padding * 2);
@@ -1053,7 +730,7 @@ export class GraphCanvas extends Widget {
} }
const playing = this.state.mode === 'play'; const playing = this.state.mode === 'play';
this.setHint( this.setHint(
this.armed this.wiring.isArmed
? 'Click the state to transition to, this one to re-enter it – anywhere else, or Escape, cancels' ? 'Click the state to transition to, this one to re-enter it – anywhere else, or Escape, cancels'
: asset.graph.nodes.length === 0 : asset.graph.nodes.length === 0
? 'Right-click to add a node' ? 'Right-click to add a node'
@@ -1064,18 +741,15 @@ export class GraphCanvas extends Widget {
const trace = playing ? this.traceProvider(asset.id) : null; const trace = playing ? this.traceProvider(asset.id) : null;
const now = this.timeProvider(); const now = this.timeProvider();
this.launchBeads(asset.id, trace); this.beads.launch(asset.id, trace?.edges ?? null);
const isHot = (map: Map<string, number> | undefined, id: string) => { const isHot = (map: Map<string, number> | undefined, id: string) => {
const t = map?.get(id); const t = map?.get(id);
return t !== undefined && now - t <= TRACE_FADE; return t !== undefined && now - t <= TRACE_FADE;
}; };
const selectedNodes = new Set(this.state.selectedNodeIds); const selectedNodes = new Set(this.state.selectedNodeIds);
const selectedAnnotations = new Set(this.state.selectedAnnotationIds); const selectedAnnotations = new Set(this.state.selectedAnnotationIds);
for (const view of this.annotations.values()) { this.annotationLayer.redrawViews(selectedAnnotations);
view.selected = selectedAnnotations.has(view.annotation.id); const target = this.wiring.target;
view.redraw();
}
const target = this.dropTarget;
const wall = performance.now(); const wall = performance.now();
const values = trace && wall - this.valuesShownAt >= LIVE_VALUE_INTERVAL_MS ? trace.values : null; const values = trace && wall - this.valuesShownAt >= LIVE_VALUE_INTERVAL_MS ? trace.values : null;
if (values) this.valuesShownAt = wall; if (values) this.valuesShownAt = wall;
@@ -1097,38 +771,10 @@ export class GraphCanvas extends Widget {
this.views, this.views,
(id) => isHot(trace?.edges, id), (id) => isHot(trace?.edges, id),
selectedNodes, selectedNodes,
(id) => beadsInFlight(this.beadTimes.get(id) ?? [], now), (id) => this.beads.inFlight(id, now),
); );
this.tempEdge.clear(); this.wiring.draw(this.tempEdge, asset.graph);
this.tempEdgeShown = false;
const drag = this.connecting ?? this.pendingDrop ?? this.armed;
const a = drag && this.anchorPoint(drag.from);
const home = drag && target?.nodeId === drag.from.nodeId ? this.views.get(target.nodeId) : undefined;
if (home?.def.transitionEdges === true) {
const ring = asset.graph.edges.filter(
(e) => e.from.node === home.node.id && e.to.node === home.node.id,
).length;
const loop = selfLoop(home, ring);
loopPath(this.tempEdge, loop);
this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
arrowHead(this.tempEdge, loop.c2, loop.end, c.textMuted);
this.tempEdgeShown = true;
} else if (drag && a) {
// Snapping to the resolved port makes the prediction the ring shows unmistakable.
const b = this.anchorPoint(target) ?? drag.pointer;
// Dragged off a state, the line already looks like the transition it is about to become.
const preview = this.transitionPreviewOf(drag.from, target, drag.pointer);
const ends = preview ? [preview.start, preview.end] : [a, b];
const [from, to] = (drag.from.isOutput ? ends : [ends[1], ends[0]]) as [Point, Point];
if (preview) {
const shaft = arrowShaft(from, to);
this.tempEdge.moveTo(from.x, from.y).lineTo(shaft.x, shaft.y);
} else bezier(this.tempEdge, from.x, from.y, to.x, to.y);
this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
if (preview) arrowHead(this.tempEdge, from, to, c.textMuted);
this.tempEdgeShown = true;
}
this.band.clear(); this.band.clear();
const m = this.marquee; const m = this.marquee;
@@ -1150,29 +796,6 @@ export class GraphCanvas extends Widget {
} }
} }
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);
}
}
/** 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 { private syncGrid(): void {
drawGraphGrid(this.grid, { pan: this.pan, zoom: this.zoom }, this.w, this.h); drawGraphGrid(this.grid, { pan: this.pan, zoom: this.zoom }, this.w, this.h);
} }
@@ -1186,12 +809,12 @@ export class GraphCanvas extends Widget {
/** The port a running edge drag would connect to, or null when the drop would do nothing. */ /** The port a running edge drag would connect to, or null when the drop would do nothing. */
pendingDropTarget(): PortSide | null { pendingDropTarget(): PortSide | null {
return this.dropTarget; return this.wiring.target;
} }
/** Whether the dangling edge is on screen right now (running drag, or a drop awaiting its node). */ /** Whether the dangling edge is on screen right now (running drag, or a drop awaiting its node). */
danglingEdgeVisible(): boolean { danglingEdgeVisible(): boolean {
return this.tempEdgeShown; return this.wiring.edgeShown;
} }
/** Whether the last redraw stroked this edge as a curve. */ /** Whether the last redraw stroked this edge as a curve. */
@@ -1342,7 +965,7 @@ export class GraphCanvas extends Widget {
w: number; w: number;
h: number; h: number;
}[] { }[] {
return [...this.annotations.values()].map((view) => ({ return [...this.annotationLayer.views.values()].map((view) => ({
id: view.annotation.id, id: view.annotation.id,
kind: view.kind, kind: view.kind,
text: view.drawnText(), text: view.drawnText(),
@@ -1355,7 +978,7 @@ export class GraphCanvas extends Widget {
/** Where a group or comment is grabbed: its title bar, or – with `corner` – its resize handle. */ /** 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 { annotationGlobal(id: string, corner = false): { x: number; y: number } | null {
const view = this.annotations.get(id); const view = this.annotationLayer.views.get(id);
if (!view) return null; if (!view) return null;
const local = corner const local = corner
? { x: view.w - RESIZE_HANDLE / 2, y: view.h - RESIZE_HANDLE / 2 } ? { x: view.w - RESIZE_HANDLE / 2, y: view.h - RESIZE_HANDLE / 2 }
@@ -1365,7 +988,7 @@ export class GraphCanvas extends Widget {
/** The rectangle a box is drawn as, on screen – what a fit has to bring into view. */ /** The rectangle a box is drawn as, on screen – what a fit has to bring into view. */
annotationRect(id: string): Rect | null { annotationRect(id: string): Rect | null {
const view = this.annotations.get(id); const view = this.annotationLayer.views.get(id);
return view ? globalRect(view, view.w, view.h) : null; return view ? globalRect(view, view.w, view.h) : null;
} }
@@ -1394,13 +1017,3 @@ function globalRect(target: Container, w: number, h: number): Rect {
const bottomRight = target.toGlobal({ x: w, y: h }); 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 }; 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 },
);
}
+54
View File
@@ -0,0 +1,54 @@
import { Container, type FederatedPointerEvent } from 'pixi.js';
import { ANNOTATION_KINDS, annotationsOf, type Graph } from '../../core/graph';
import { AnnotationView } from './AnnotationView';
/** The groups and comments of a graph, behind its edges: a group is a backdrop, and a comment must never cover a node. */
export class AnnotationLayer extends Container {
private readonly byId = new Map<string, AnnotationView>();
constructor(private readonly onPress: (view: AnnotationView, e: FederatedPointerEvent) => void) {
super();
}
get views(): ReadonlyMap<string, AnnotationView> {
return this.byId;
}
clearViews(): void {
for (const view of this.byId.values()) view.destroy({ children: true });
this.byId.clear();
}
/** Views are reused by id, and a box that is gone takes its view with it. */
sync(graph: Graph): void {
const seen = new Set<string>();
for (const kind of ANNOTATION_KINDS) {
for (const annotation of annotationsOf(graph, kind)) {
seen.add(annotation.id);
let view = this.byId.get(annotation.id);
if (!view || view.kind !== kind) {
view?.destroy({ children: true });
view = new AnnotationView(annotation, kind);
const pressed = view;
view.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(pressed, e));
this.byId.set(annotation.id, view);
this.addChild(view);
}
view.sync(annotation);
}
}
for (const [id, view] of this.byId) {
if (!seen.has(id)) {
view.destroy({ children: true });
this.byId.delete(id);
}
}
}
redrawViews(selected: ReadonlySet<string>): void {
for (const view of this.byId.values()) {
view.selected = selected.has(view.annotation.id);
view.redraw();
}
}
}
+27
View File
@@ -17,6 +17,33 @@ export function beadsInFlight(times: readonly number[], now: number): number[] {
return times.map((t) => (now - t) / BEAD_TRAVEL).filter((p) => p >= 0 && p <= 1); return times.map((t) => (now - t) / BEAD_TRAVEL).filter((p) => p >= 0 && p <= 1);
} }
/** The beads of one graph's edges, in game time so a paused game holds them. */
export class BeadClock {
private readonly times = new Map<string, number[]>();
private readonly fired = new Map<string, number>();
private assetId = '';
/** Launches a bead for every edge that fired since the last call; without a trace all are dropped. */
launch(assetId: string, fired: ReadonlyMap<string, number> | null): void {
if (!fired || assetId !== this.assetId) {
this.times.clear();
this.fired.clear();
this.assetId = assetId;
}
for (const [id, time] of fired ?? []) {
if (this.fired.get(id) === time) continue;
this.fired.set(id, time);
const times = this.times.get(id) ?? [];
this.times.set(id, times);
launchBead(times, time);
}
}
inFlight(edgeId: string, now: number): number[] {
return beadsInFlight(this.times.get(edgeId) ?? [], now);
}
}
/** How far an edge's curve reaches out of its ports before it bends towards the other end. */ /** How far an edge's curve reaches out of its ports before it bends towards the other end. */
export function curveReach(x1: number, x2: number): number { export function curveReach(x1: number, x2: number): number {
return Math.max(40, Math.abs(x2 - x1) * 0.5); return Math.max(40, Math.abs(x2 - x1) * 0.5);
+149
View File
@@ -0,0 +1,149 @@
import { annotationNoun, normalizeBox } from '../../core/commands/annotationCommands';
import { oneStep } from '../../core/commands/CompoundCommand';
import { findAnnotation, MIN_ANNOTATION_SIZE } from '../../core/graph';
import { dragOffset, resizeSize } from '../framing';
import {
boxOf,
type BoxSeed,
boxSeeds,
placeTravellers,
recordTravellers,
travellers,
type Travellers,
} from './annotationDrag';
import type { AnnotationView } from './AnnotationView';
import type { GraphCanvasHost } from './graphHost';
import { GRID_STEP } from './graphView';
import type { Point } from './transitions';
/** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */
export const DRAG_THRESHOLD = 3;
/** Nodes, groups and comments dragged about the canvas, each gesture one undo step. */
export class GraphDrags {
/** Nodes being dragged, with the boxes a band caught alongside them – they travel together. */
private nodes: { along: Travellers; pointer: Point; moved: boolean } | null = null;
/**
* A group or comment being moved or resized. `along` is fixed when the drag begins: which nodes a
* group carries is a question about where they were when it was grabbed, not a running one –
* otherwise a node would join the group halfway through the gesture that passes over it.
*/
private box: {
grabbed: BoxSeed;
mode: 'move' | 'resize';
along: Travellers;
pointer: Point;
moved: boolean;
} | null = null;
constructor(private readonly host: GraphCanvasHost) {}
/** A press on a node: the selection travels with it. False when there is no graph to move in. */
grabNodes(global: Point): boolean {
const graph = this.host.asset()?.graph;
if (!graph) return false;
const state = this.host.state;
const seeds = boxSeeds(graph, state.selectedAnnotationIds);
this.nodes = {
along: travellers(graph, this.host.nodeBoxes(), seeds, state.selectedNodeIds),
pointer: this.host.toGraph(global),
moved: false,
};
return true;
}
/** A press on a group or comment: its handle resizes it, anywhere else moves it and what it holds. */
grabBox(view: AnnotationView, global: Point): void {
const graph = this.host.asset()?.graph;
if (!graph) return;
const state = this.host.state;
const id = view.annotation.id;
const local = view.toLocal(global);
const grabbed: BoxSeed = { id, kind: view.kind, start: boxOf(view.annotation) };
const resizing = view.handleAt(local.x, local.y);
// A resize carries nothing: membership is re-derived from where the box ends up, so a node the
// corner sweeps over simply belongs to it after.
const seeds = [grabbed, ...boxSeeds(graph, state.selectedAnnotationIds, id)];
const along = resizing
? { boxes: [grabbed], nodes: [] }
: travellers(graph, this.host.nodeBoxes(), seeds, state.selectedNodeIds);
this.box = {
grabbed,
mode: resizing ? 'resize' : 'move',
along,
pointer: this.host.toGraph(global),
moved: false,
};
}
/** Moves what is dragged in the model, for the preview; false when nothing is. */
move(global: Point): boolean {
if (this.box) this.previewBox(this.host.toGraph(global));
else if (this.nodes) this.previewNodes(this.host.toGraph(global));
else return false;
return true;
}
private previewNodes(g: Point): void {
const drag = this.nodes!;
const dx = g.x - drag.pointer.x;
const dy = g.y - drag.pointer.y;
if (!drag.moved && Math.hypot(dx, dy) * this.host.zoom() < DRAG_THRESHOLD) return;
drag.moved = true;
const graph = this.host.asset()?.graph;
if (!graph) return;
const step = this.host.snapping() ? GRID_STEP : 0;
// Each thing snaps for itself, node or box, so what lands on the grid is what was grabbed.
placeTravellers(graph, drag.along, (start) => dragOffset(start, dx, dy, step));
this.host.state.preview();
}
/** Moves or resizes the dragged box in the model, for the preview; committed on release. */
private previewBox(g: Point): void {
const drag = this.box!;
const graph = this.host.asset()?.graph;
if (!graph) return;
const dx = g.x - drag.pointer.x;
const dy = g.y - drag.pointer.y;
if (!drag.moved && Math.hypot(dx, dy) * this.host.zoom() < DRAG_THRESHOLD) return;
drag.moved = true;
const step = this.host.snapping() ? GRID_STEP : 0;
if (drag.mode === 'resize') {
const self = findAnnotation(graph, drag.grabbed.id)?.annotation;
if (!self) return;
const size = resizeSize(drag.grabbed.start, dx, dy, step, MIN_ANNOTATION_SIZE);
Object.assign(self, normalizeBox({ ...drag.grabbed.start, ...size }));
} else {
// One offset for all of it, the grabbed box's: what travels with a group keeps its distance.
const offset = dragOffset(drag.grabbed.start, dx, dy, step);
placeTravellers(graph, drag.along, () => offset);
}
this.host.state.preview();
}
/** Puts the previewed boxes back and records the whole gesture as one undo step. False when no box was dragged. */
releaseBox(): boolean {
const drag = this.box;
if (!drag) return false;
this.box = null;
const graph = this.host.asset()?.graph;
const assetId = this.host.assetId();
if (!drag.moved || !graph || !assetId) return true;
const noun = annotationNoun(drag.grabbed.kind);
const label = drag.mode === 'resize' ? `Resize ${noun}` : undefined;
const commands = recordTravellers(graph, assetId, drag.along, label);
this.host.state.execute(oneStep(`${drag.mode === 'resize' ? 'Resize' : 'Move'} ${noun}`, commands));
return true;
}
/** The boxes that came along belong in the same step as the nodes: one gesture, one undo. */
releaseNodes(): void {
const drag = this.nodes;
this.nodes = null;
const graph = this.host.asset()?.graph;
const assetId = this.host.assetId();
if (!drag?.moved || !graph || !assetId) return;
const moves = recordTravellers(graph, assetId, drag.along);
if (moves.length > 0) this.host.state.execute(oneStep('Move nodes', moves));
}
}
+31
View File
@@ -0,0 +1,31 @@
import type { AddNodeCommand } from '../../core/commands/graphCommands';
import type { GraphAsset } from '../../core/model/Asset';
import type { Box } from '../framing';
import type { EditorState } from '../state';
import type { NodeView } from './NodeView';
import type { PaletteEntry } from './paletteEntries';
import type { Point } from './transitions';
/** What the graph canvas lends the gestures that work inside it. */
export interface GraphCanvasHost {
readonly state: EditorState;
/** The asset on the canvas; empty when there is none. */
assetId(): string;
asset(): GraphAsset | null;
views(): ReadonlyMap<string, NodeView>;
/** Views in stacking order, topmost first – a node drawn over another one wins the pointer. */
topDownViews(): Iterable<NodeView>;
nodeUnder(global: Point): string | null;
/** Each node as the rectangle it is actually drawn as. */
nodeBoxes(): Box[];
/** The graph point under a global pointer position. */
toGraph(global: Point): Point;
/** Screen pixels per graph unit. */
zoom(): number;
snapping(): boolean;
/** What the palette offers for this graph. */
entries(): PaletteEntry[];
/** The command that adds a node from the palette at a graph point. */
newNode(entry: PaletteEntry, at: Point): AddNodeCommand;
redraw(): void;
}
+275
View File
@@ -0,0 +1,275 @@
import type { Graphics } from 'pixi.js';
import type { Command } from '../../core/commands/Command';
import { oneStep } from '../../core/commands/CompoundCommand';
import { ConnectCommand } from '../../core/commands/graphCommands';
import {
canConnect,
defAcceptsFrom,
dropTargetPort,
firstPortFor,
type Graph,
type PortSide,
type PortType,
portTypeOf,
STATE_IN,
STATE_OUT,
} from '../../core/graph';
import { graphLookup } from '../../core/model/graphLookup';
import { Theme, ui } from '../../ui';
import { nodeSelection } from '../state';
import { arrowHead, bezier, loopPath } from './EdgeLayer';
import type { GraphCanvasHost } from './graphHost';
import { NodePalette } from './NodePalette';
import type { PaletteEntry } from './paletteEntries';
import { arrowShaft, type Point, selfLoop, transitionPreview } from './transitions';
/**
* Making edges: a drag from a pin, the palette a drag into empty space opens, and a transition
* armed from a state's menu that waits for the click naming its target.
*/
export class GraphWiring {
private connecting: { from: PortSide; pointer: Point } | 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: Point } | null = null;
/** A transition begun from a state's menu, waiting for the click that names its target. It takes
* the shape of a drag because it is one in everything but the held button – that way the running
* preview draws it without a second code path. `release` takes the Escape handler back off. */
private armed: { from: PortSide; pointer: Point; release: () => void } | 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;
constructor(private readonly host: GraphCanvasHost) {}
/** A transition is waiting for its target. */
get isArmed(): boolean {
return this.armed !== null;
}
/** The port a running edge drag would connect to, or null when the drop would do nothing. */
get target(): PortSide | null {
return this.dropTarget;
}
/** Whether the dangling edge is on screen right now (running drag, or a drop awaiting its node). */
get edgeShown(): boolean {
return this.tempEdgeShown;
}
/** A press on a pin: the edge follows the pointer until the button comes up. */
startDrag(from: PortSide, global: Point): void {
this.connecting = { from, pointer: this.host.toGraph(global) };
this.dropTarget = null;
this.host.redraw();
}
/** Follows the pointer with a drag or an armed transition; false when there is neither. */
move(global: Point): boolean {
const g = this.host.toGraph(global);
if (this.connecting) {
this.connecting.pointer = g;
this.dropTarget = this.resolveDrop(global);
} else if (this.armed) {
this.armed.pointer = g;
this.dropTarget = this.armedTarget(global);
} else return false;
this.host.redraw();
return true;
}
/** Ends a drag: onto a port it connects, into empty space it offers the palette. */
release(global: Point): void {
const connecting = this.connecting;
const target = connecting ? (this.dropTarget ?? this.resolveDrop(global)) : null;
this.connecting = null;
this.dropTarget = null;
if (!connecting || !this.host.assetId()) return;
if (target) this.connect(connecting.from, target);
else if (!this.host.nodeUnder(global)) this.openPaletteForDrop(connecting, global);
}
private connect(from: PortSide, to: PortSide): void {
const assetId = this.host.assetId();
if (assetId) this.host.state.execute(connectSides(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: Point }, global: Point): void {
const graph = this.host.asset()?.graph;
const from = drag.from;
const fromType = graph && portTypeOf(graph, from, graphLookup(this.host.state.project));
if (!graph || fromType === undefined) return;
const at = this.host.toGraph(global);
this.pendingDrop = drag;
const palette = new NodePalette(
this.host.entries(),
(entry) => this.addConnectedNode(entry, from, fromType, at),
(def) => defAcceptsFrom(def, fromType, from.isOutput),
);
palette.onClose = () => {
this.pendingDrop = null;
this.host.redraw();
};
palette.open(global.x, global.y);
}
/** 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: Point): void {
const assetId = this.host.assetId();
if (!assetId) return;
const add = this.host.newNode(entry, at);
const port = firstPortFor(entry.def, fromType, from.isOutput);
const commands: Command[] = [add];
if (port)
commands.push(connectSides(assetId, from, { nodeId: add.node.id, port, isOutput: !from.isOutput }));
if (this.host.state.execute(oneStep(add.label, commands)))
this.host.state.select(nodeSelection(assetId, [add.node.id]));
}
/**
* 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(global: Point): PortSide | null {
const graph = this.host.asset()?.graph;
const from = this.connecting?.from;
if (!graph || !from) return null;
const lookup = graphLookup(this.host.state.project);
const hit = this.portUnder(global);
if (hit) return canConnect(graph, from, hit, lookup) ? hit : null;
const node = this.host.nodeUnder(global);
if (!node || this.host.views().get(node)?.collapsed) return null;
return dropTargetPort(graph, from, node, lookup);
}
private portUnder(global: Point): PortSide | null {
for (const view of this.host.topDownViews()) {
const local = view.toLocal(global);
const hit = view.portAt(local.x, local.y);
// A ghost is no target, and it must not shadow the node body underneath it either.
if (hit && !hit.port.ghost) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput };
}
return null;
}
/** Begin a transition out of this state; the next click on another one completes it. */
arm(nodeId: string, pointer: Point): void {
this.cancel();
const handlers = ui().focus.globalHandlers;
const onKey = (event: KeyboardEvent): boolean => {
if (event.key !== 'Escape' || !this.armed) return false;
this.cancel();
this.host.redraw();
return true;
};
handlers.push(onKey);
this.armed = {
from: { nodeId, port: STATE_OUT, isOutput: true },
pointer,
release: () => {
const at = handlers.indexOf(onKey);
if (at >= 0) handlers.splice(at, 1);
},
};
this.dropTarget = null;
this.host.redraw();
}
cancel(): void {
this.armed?.release();
this.armed = null;
}
/** Where an armed transition would land: the state under the pointer, its own source included. */
private armedTarget(global: Point): PortSide | null {
if (!this.armed) return null;
const node = this.host.nodeUnder(global);
if (!node) return null;
const view = this.host.views().get(node);
return view?.def.transitionEdges === true ? { nodeId: node, port: STATE_IN, isOutput: false } : null;
}
/** Finish or abandon an armed transition. True when the click belonged to it. */
finish(global: Point, connect: boolean): boolean {
const armed = this.armed;
if (!armed) return false;
const target = connect ? this.armedTarget(global) : null;
this.cancel();
this.dropTarget = null;
if (target) this.connect(armed.from, target);
this.host.redraw();
return true;
}
/** The edge being made, from its pin to the pointer – or to the port it would land on. */
draw(g: Graphics, graph: Graph): void {
const c = Theme.colors;
const views = this.host.views();
const target = this.dropTarget;
g.clear();
this.tempEdgeShown = false;
const drag = this.connecting ?? this.pendingDrop ?? this.armed;
const a = drag && this.anchorPoint(drag.from);
const home = drag && target?.nodeId === drag.from.nodeId ? views.get(target.nodeId) : undefined;
if (home?.def.transitionEdges === true) {
const ring = graph.edges.filter(
(e) => e.from.node === home.node.id && e.to.node === home.node.id,
).length;
const loop = selfLoop(home, ring);
loopPath(g, loop);
g.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
arrowHead(g, loop.c2, loop.end, c.textMuted);
this.tempEdgeShown = true;
} else if (drag && a) {
// Snapping to the resolved port makes the prediction the ring shows unmistakable.
const b = this.anchorPoint(target) ?? drag.pointer;
// Dragged off a state, the line already looks like the transition it is about to become.
const preview = this.transitionPreviewOf(drag.from, target, drag.pointer);
const ends = preview ? [preview.start, preview.end] : [a, b];
const [from, to] = (drag.from.isOutput ? ends : [ends[1], ends[0]]) as [Point, Point];
if (preview) {
const shaft = arrowShaft(from, to);
g.moveTo(from.x, from.y).lineTo(shaft.x, shaft.y);
} else bezier(g, from.x, from.y, to.x, to.y);
g.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
if (preview) arrowHead(g, from, to, c.textMuted);
this.tempEdgeShown = true;
}
}
/** Null unless the drag started on a state: only those draw as a straight arrow. */
private transitionPreviewOf(
from: PortSide,
target: PortSide | null,
pointer: Point,
): { start: Point; end: Point } | null {
const views = this.host.views();
const source = views.get(from.nodeId);
if (source?.def.transitionEdges !== true) return null;
const to = target ? views.get(target.nodeId) : undefined;
return transitionPreview(source, to?.def.transitionEdges === true ? to : null, pointer);
}
/** A port's centre in graph coordinates. */
private anchorPoint(side: PortSide | null): Point | null {
if (!side) return null;
const view = this.host.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;
}
}
/** 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 },
);
}
+22
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { import {
BEAD_SPACING, BEAD_SPACING,
BEAD_TRAVEL, BEAD_TRAVEL,
BeadClock,
beadsInFlight, beadsInFlight,
curvePoint, curvePoint,
launchBead, launchBead,
@@ -41,6 +42,27 @@ describe('beads on a firing edge', () => {
}); });
}); });
describe('the beads of one graph', () => {
it('launches once per fire, even when the same trace is read on every frame', () => {
const clock = new BeadClock();
const fired = new Map([['e1', 1]]);
clock.launch('g', fired);
clock.launch('g', fired);
expect(clock.inFlight('e1', 1)).toEqual([0]);
expect(clock.inFlight('e2', 1)).toEqual([]);
});
it('drops every bead when the trace goes away or another graph is shown', () => {
const clock = new BeadClock();
clock.launch('g', new Map([['e1', 1]]));
clock.launch('g', null);
expect(clock.inFlight('e1', 1)).toEqual([]);
clock.launch('g', new Map([['e1', 2]]));
clock.launch('other', new Map());
expect(clock.inFlight('e1', 2)).toEqual([]);
});
});
describe('the curve a bead follows', () => { describe('the curve a bead follows', () => {
it('starts in the source port and ends in the target port', () => { it('starts in the source port and ends in the target port', () => {
expect(curvePoint(10, 20, 300, 80, 0)).toEqual({ x: 10, y: 20 }); expect(curvePoint(10, 20, 300, 80, 0)).toEqual({ x: 10, y: 20 });
+6
View File
@@ -270,6 +270,12 @@ export async function graphGroups(t) {
caught.nodeIds.includes('gg-start'), caught.nodeIds.includes('gg-start'),
JSON.stringify(caught), JSON.stringify(caught),
); );
const drawnCaught = (await boxes()).find((box) => box.id === away.id);
check(
'the comment the band caught is drawn as selected',
drawnCaught?.selected === true,
JSON.stringify(drawnCaught),
);
// 36l. But a band that merely crosses a box leaves it alone – the rule that keeps a group, which // 36l. But a band that merely crosses a box leaves it alone – the rule that keeps a group, which
// wraps the nodes it holds, from joining every band drawn over any of them. It starts to the // wraps the nodes it holds, from joining every band drawn over any of them. It starts to the