A shader ran only inside the rectangle of what wears it: the quad has the size of the sprite, and outside of it no fragment runs. An outline or a glow built with Sample texture was cut off at the border. The issue still spoke of Filter.padding, but since #77 the shader is no filter any more, the limit is the geometry. - The Output node has a number Padding next to Edge: so many pixels of the picture per side the wearer draws beyond it. 0 is the default and draws as before, 256 is the most. - The quad grows and its UVs grow with it, so the picture keeps its place and size. In the margin the UVs run on below 0 and above 1. - Transparent is a fourth Edge mode: outside of the picture there is nothing. It is cut in the fragment (texelAt), so it needs no copy of the picture with an address mode of its own. - Edge holds everywhere alike, also for Texture color, Texture alpha and unconnected inputs. - Padding above 0 with Edge on Clamp puts the hint "Padding with Clamp smears the border" on the Output. The shader still compiles. A chosen Clamp and the default cannot be told apart. - All four wearers draw the margin. A sprite counts in pixels of its frame, a text in its units, a particle source grows every particle about its centre. - A nine-slice sprite gets a ring around its nine fields, as wide as so many border pixels. The fields themselves stay. - Without a picture a pixel is one unit of the wearer's own size, or of the particle size. - Particles now take the Edge mode of their shader. It had no effect on them before. - An object wearing a padded shader is never culled, or its margin would vanish at the edge of the screen. - The preview in the shader tab shows the margin and leaves room for it. - Picking, the selection frame, the size and the collider stay with the rectangle without the margin. Tests: - Gate on melektaus-atx right before this commit: 216 files, 3336 tests (3319 + 17), exit 0. - e2e 28,30,31,37,41 on melektaus-atx: 178 checks, all pass. e2e 1-37,47,48,58 ran before the last change (no culling with padding): 1281 checks, all pass. - 17 new unit tests: the compiler (padding, Transparent, the hint) and the geometry (quad, nine-slice ring). - Seven new e2e checks: four at the sprite in module 28, one each for text, particles and nine-slice. - 17 unit counter-probes all fail their tests. Three browser counter-probes fail their checks (5, 3 and 1 of them). - Three screenshots looked at: the sprite with Clamp and margin in the scene, the preview in the shader tab, the text. - The e2e sum of 1687 is counted (1680 + 7), the full suite and the smokes did not run. - Not seen: the hint at the node in a picture, particles and nine-slice in a picture, the game view while playing, sprites without a picture, vector and pixel art pictures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1189 lines
45 KiB
TypeScript
1189 lines
45 KiB
TypeScript
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<string, NodeView>();
|
||
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<string>();
|
||
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<string, 'held' | 'current'> {
|
||
const marks = new Map<string, 'held' | 'current'>();
|
||
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<string, string> {
|
||
const marks: Record<string, string> = {};
|
||
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<GraphCanvas['incoming']>): 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<NodeView> {
|
||
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<string, number> | 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<string, string>;
|
||
outputs: Record<string, string>;
|
||
paint: Record<string, string>;
|
||
} | 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 };
|
||
}
|