Add a decimals option, fix dropdown overflow and the texture filter
- NumberField takes a decimals option (default 3); frame rects and the slice dialog use 0, matching their integer model. - Button truncates its label to the drawn width; Dropdown reserves the chevron slot. NodeView reuses the shared truncate(). - Setting scaleMode leaves the style's shared resource id in place, so the old GPU sampler stayed in use until a reload. Call style.update(). - Graph gets a header bar like the Texture tab: GraphEditor is now the frame, the former editor is GraphCanvas. A selection the tab does not concern no longer unloads the behavior. - New e2e step 14 guards text overflow across all widgets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
35a1a23af8
commit
f16801aef3
22 files changed
+874
-493
No files matched your search
+85
-443
@@ -1,474 +1,116 @@
|
||||
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||||
import {
|
||||
AddNodeCommand,
|
||||
ConnectCommand,
|
||||
DisconnectCommand,
|
||||
MoveNodeCommand,
|
||||
RemoveNodeCommand,
|
||||
} from '../../core/commands/graphCommands';
|
||||
import {
|
||||
edgesInto,
|
||||
edgesOutOf,
|
||||
type GraphEdge,
|
||||
type NodeDef,
|
||||
nodeDef,
|
||||
portsCompatible,
|
||||
} from '../../core/graph';
|
||||
import { Graphics } from 'pixi.js';
|
||||
import type { BehaviorAsset } from '../../core/model/Asset';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
|
||||
import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui';
|
||||
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui';
|
||||
import type { EditorState } from '../state';
|
||||
import { NodePalette } from './NodePalette';
|
||||
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView';
|
||||
import { GraphCanvas } from './GraphCanvas';
|
||||
|
||||
const GRID_STEP = 24;
|
||||
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
|
||||
const MIN_GRID_STEP = 8;
|
||||
const MIN_ZOOM = 0.25;
|
||||
const MAX_ZOOM = 2.5;
|
||||
/** Stand-in node height for centring the view – real nodes vary with their port count. */
|
||||
const NODE_HEIGHT_ESTIMATE = 80;
|
||||
|
||||
interface PortAnchor {
|
||||
nodeId: string;
|
||||
port: string;
|
||||
isOutput: boolean;
|
||||
}
|
||||
/** Room kept for the "N nodes · M links" readout, wide enough for a three-digit graph. */
|
||||
const INFO_WIDTH = 150;
|
||||
|
||||
/**
|
||||
* The node graph editor for one behavior asset. Shows the graph of whatever behavior the
|
||||
* selection refers to (asset, placed object or node). When nothing or a non-behavior is
|
||||
* selected the editor is empty – the graph strictly mirrors the selection.
|
||||
* The Graph tab: a picker for which behavior is being worked on, the actions that act on the
|
||||
* whole graph, and the canvas the nodes are drawn on.
|
||||
*
|
||||
* Interactions: drag nodes, drag from port to port to connect, right-click the canvas for
|
||||
* the node palette, right-click a node or port for delete/disconnect, wheel to zoom, drag
|
||||
* the empty canvas to pan. All edits go through commands (undoable).
|
||||
* It follows the selection – selecting a behavior asset, a placed behavior or one of its nodes
|
||||
* shows that graph – but it keeps showing the last behavior when the selection moves elsewhere.
|
||||
* Otherwise clicking a sprite to check its position would empty the graph you are editing.
|
||||
*/
|
||||
export class GraphEditor extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly grid = new Graphics();
|
||||
private readonly clip = new Graphics();
|
||||
private readonly canvas = new Container();
|
||||
private readonly edges = new Graphics();
|
||||
private readonly nodeLayer = new Container();
|
||||
private readonly tempEdge = new Graphics();
|
||||
private readonly hint = new Text({ text: '', style: textStyle('muted') });
|
||||
private readonly views = new Map<string, NodeView>();
|
||||
private assetId: string | null = null;
|
||||
private pan = { x: 0, y: 0 };
|
||||
private zoom = 1;
|
||||
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
|
||||
private dragging: {
|
||||
nodeId: string;
|
||||
start: { x: number; y: number };
|
||||
pointer: { x: number; y: number };
|
||||
moved: boolean;
|
||||
} | null = null;
|
||||
private connecting: { from: PortAnchor; pointer: { x: number; y: number } } | null = null;
|
||||
/** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */
|
||||
private needsFit = true;
|
||||
/** Supplies live execution data while playing (null otherwise). */
|
||||
traceProvider: (assetId: string) => Trace | null = () => null;
|
||||
timeProvider: () => number = () => 0;
|
||||
export class GraphEditor extends VStack {
|
||||
/** The canvas itself: the live redraw while playing and the check scripts reach for it. */
|
||||
readonly canvas: GraphCanvas;
|
||||
private readonly picker: Dropdown;
|
||||
private readonly info: Label;
|
||||
private readonly addButton: Button;
|
||||
private readonly headerBg = new Graphics();
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super();
|
||||
this.canvas.addChild(this.edges, this.nodeLayer, this.tempEdge);
|
||||
this.addChild(this.bg, this.grid, this.canvas, this.hint, this.clip);
|
||||
this.canvas.mask = this.clip;
|
||||
this.cursor = 'default';
|
||||
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||||
header.minHeight = Theme.controlHeight + Theme.padding * 2;
|
||||
header.addChildAt(this.headerBg, 0);
|
||||
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onCanvasPointerDown(e));
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||||
this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e));
|
||||
this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e));
|
||||
this.on('wheel', (e: FederatedWheelEvent) => {
|
||||
const local = this.toLocal(e.global);
|
||||
const before = this.screenToGraph(local.x, local.y);
|
||||
this.zoom = clamp(this.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM);
|
||||
const after = this.screenToGraph(local.x, local.y);
|
||||
this.pan.x += (after.x - before.x) * this.zoom;
|
||||
this.pan.y += (after.y - before.y) * this.zoom;
|
||||
this.applyView();
|
||||
e.preventDefault();
|
||||
});
|
||||
header.add(new Label('Behavior', { style: 'muted' }));
|
||||
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
|
||||
header.add(this.picker);
|
||||
this.addButton = new Button('Add node…', { onClick: () => this.canvas.addNodeFrom(this.addButton) });
|
||||
header.add(this.addButton);
|
||||
header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
|
||||
header.add(new Spacer(), 1);
|
||||
this.info = new Label('', { style: 'small', align: 'right' });
|
||||
// Reserved rather than measured: the label is built empty, and a stack sizes it once from
|
||||
// the text it had at the time – so without a floor the readout is clipped from then on.
|
||||
this.info.minWidth = INFO_WIDTH;
|
||||
header.add(this.info);
|
||||
|
||||
this.canvas = new GraphCanvas(state);
|
||||
this.add(header);
|
||||
this.add(this.canvas, 1);
|
||||
|
||||
state.on('selection', () => this.followSelection());
|
||||
state.on('project', () => this.refresh());
|
||||
// Adding nodes is an edit, and edits made while playing would be thrown away with the run.
|
||||
state.on('mode', () => this.refresh());
|
||||
state.on('load', () => {
|
||||
this.assetId = null;
|
||||
this.canvas.show(null);
|
||||
this.followSelection();
|
||||
});
|
||||
state.on('project', () => this.sync());
|
||||
state.on('mode', () => this.redraw());
|
||||
this.followSelection();
|
||||
}
|
||||
|
||||
/** The behavior whose graph is shown, if any. */
|
||||
/** The behavior currently shown – the check scripts read this. */
|
||||
get currentAssetId(): string | null {
|
||||
return this.assetId;
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||||
}
|
||||
|
||||
// --- model binding ----------------------------------------------------------------------
|
||||
|
||||
private asset(): BehaviorAsset | null {
|
||||
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
||||
return asset && asset.type === 'behavior' ? asset : null;
|
||||
return this.canvas.currentAssetId;
|
||||
}
|
||||
|
||||
private followSelection(): void {
|
||||
const wanted = this.state.selectedBehaviorId;
|
||||
if (wanted && wanted !== this.assetId) {
|
||||
this.assetId = wanted;
|
||||
this.sync();
|
||||
this.needsFit = true;
|
||||
this.fitView();
|
||||
} else if (!wanted || !this.asset()) {
|
||||
this.assetId = null;
|
||||
this.sync();
|
||||
} else {
|
||||
this.redraw();
|
||||
}
|
||||
const id = this.state.selectedBehaviorId;
|
||||
// Null merely means "the selection is about something else"; the tab keeps its subject.
|
||||
if (id) this.canvas.show(id);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/** Rebuilds node views from the model (cheap: views are reused by node id). */
|
||||
private sync(): void {
|
||||
private asset(): BehaviorAsset | null {
|
||||
const id = this.canvas.currentAssetId;
|
||||
const asset = id ? findAsset(this.state.project, id) : undefined;
|
||||
return asset && asset.type === 'behavior' ? asset : null;
|
||||
}
|
||||
|
||||
private behaviors(): BehaviorAsset[] {
|
||||
return this.state.project.assets
|
||||
.filter((a): a is BehaviorAsset => a.type === 'behavior')
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
private openPicker(): void {
|
||||
const current = this.canvas.currentAssetId;
|
||||
const items: MenuItem[] = this.behaviors().map((asset) => ({
|
||||
label: asset.id === current ? `● ${asset.name}` : asset.name,
|
||||
onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }),
|
||||
}));
|
||||
if (items.length === 0) items.push({ label: 'No behaviors yet', onSelect: () => undefined });
|
||||
new Menu(items, { searchable: true }).openBelow(this.picker);
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
// A deleted behavior leaves the canvas empty; rather than showing nothing at all the tab
|
||||
// falls back to whatever behavior is left, the way it starts out on the first one.
|
||||
if (!this.asset()) this.canvas.show(this.behaviors()[0]?.id ?? null);
|
||||
const asset = this.asset();
|
||||
if (!asset) {
|
||||
this.assetId = null;
|
||||
for (const view of this.views.values()) view.destroy({ children: true });
|
||||
this.views.clear();
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const node of asset.graph.nodes) {
|
||||
const def = nodeDef(node.type);
|
||||
if (!def) continue;
|
||||
seen.add(node.id);
|
||||
let view = this.views.get(node.id);
|
||||
if (!view || view.node !== node) {
|
||||
view?.destroy({ children: true });
|
||||
view = new NodeView(node, def);
|
||||
view.on('pointerdown', (e: FederatedPointerEvent) => this.onNodePointerDown(view!, e));
|
||||
this.views.set(node.id, view);
|
||||
this.nodeLayer.addChild(view);
|
||||
}
|
||||
view.sync(node, asset.graph, asset, this.state.project);
|
||||
}
|
||||
for (const [id, view] of this.views) {
|
||||
if (!seen.has(id)) {
|
||||
view.destroy({ children: true });
|
||||
this.views.delete(id);
|
||||
}
|
||||
}
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** Centres the graph's nodes (or the origin) in the view. Waits for a real size. */
|
||||
private fitView(): void {
|
||||
if (this.w === 0 || this.h === 0) return;
|
||||
this.needsFit = false;
|
||||
const asset = this.asset();
|
||||
if (!asset || asset.graph.nodes.length === 0) {
|
||||
this.pan = { x: this.w / 2 - NODE_WIDTH / 2, y: this.h / 3 };
|
||||
this.zoom = 1;
|
||||
} else {
|
||||
const xs = asset.graph.nodes.map((n) => n.x);
|
||||
const ys = asset.graph.nodes.map((n) => n.y);
|
||||
const cx = (Math.min(...xs) + Math.max(...xs) + NODE_WIDTH) / 2;
|
||||
const cy = (Math.min(...ys) + Math.max(...ys) + NODE_HEIGHT_ESTIMATE) / 2;
|
||||
this.zoom = 1;
|
||||
this.pan = { x: this.w / 2 - cx, y: this.h / 2 - cy };
|
||||
}
|
||||
this.applyView();
|
||||
}
|
||||
|
||||
private applyView(): void {
|
||||
this.canvas.position.set(Math.round(this.pan.x), Math.round(this.pan.y));
|
||||
this.canvas.scale.set(this.zoom);
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
private screenToGraph(sx: number, sy: number): { x: number; y: number } {
|
||||
return { x: (sx - this.pan.x) / this.zoom, y: (sy - this.pan.y) / this.zoom };
|
||||
}
|
||||
|
||||
// --- pointer -----------------------------------------------------------------------------
|
||||
|
||||
private onCanvasPointerDown(e: FederatedPointerEvent): void {
|
||||
if (e.target !== this) return;
|
||||
const local = this.toLocal(e.global);
|
||||
if (e.button === 2) {
|
||||
if (this.assetId && this.state.mode === 'edit')
|
||||
this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
|
||||
return;
|
||||
}
|
||||
if (e.button === 0 || e.button === 1) {
|
||||
if (e.button === 0 && this.assetId && this.state.mode === 'edit')
|
||||
this.state.select({ kind: 'asset', assetId: this.assetId });
|
||||
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
|
||||
}
|
||||
}
|
||||
|
||||
private onNodePointerDown(view: NodeView, e: FederatedPointerEvent): void {
|
||||
if (!this.assetId || this.state.mode !== 'edit') return;
|
||||
e.stopPropagation();
|
||||
const local = view.toLocal(e.global);
|
||||
const hit = view.portAt(local.x, local.y);
|
||||
if (e.button === 2) {
|
||||
if (hit) this.openPortMenu(view, hit.port.key, hit.isOutput, e.global.x, e.global.y);
|
||||
else this.openNodeMenu(view, e.global.x, e.global.y);
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
if (hit) {
|
||||
const p = this.toLocal(e.global);
|
||||
this.connecting = {
|
||||
from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput },
|
||||
pointer: this.screenToGraph(p.x, p.y),
|
||||
};
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: view.node.id });
|
||||
const p = this.toLocal(e.global);
|
||||
this.dragging = {
|
||||
nodeId: view.node.id,
|
||||
start: { x: view.node.x, y: view.node.y },
|
||||
pointer: this.screenToGraph(p.x, p.y),
|
||||
moved: false,
|
||||
};
|
||||
this.nodeLayer.addChild(view); // raise
|
||||
}
|
||||
|
||||
private onPointerMove(e: FederatedPointerEvent): void {
|
||||
if (this.panning) {
|
||||
this.pan.x = this.panning.panX + (e.global.x - this.panning.startX);
|
||||
this.pan.y = this.panning.panY + (e.global.y - this.panning.startY);
|
||||
this.applyView();
|
||||
return;
|
||||
}
|
||||
const local = this.toLocal(e.global);
|
||||
const g = this.screenToGraph(local.x, local.y);
|
||||
if (this.dragging) {
|
||||
const dx = g.x - this.dragging.pointer.x;
|
||||
const dy = g.y - this.dragging.pointer.y;
|
||||
if (!this.dragging.moved && Math.hypot(dx, dy) * this.zoom < 3) return;
|
||||
this.dragging.moved = true;
|
||||
const node = this.asset()?.graph.nodes.find((n) => n.id === this.dragging?.nodeId);
|
||||
if (!node) return;
|
||||
node.x = Math.round(this.dragging.start.x + dx);
|
||||
node.y = Math.round(this.dragging.start.y + dy);
|
||||
this.state.preview();
|
||||
} else if (this.connecting) {
|
||||
this.connecting.pointer = g;
|
||||
this.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
private onPointerUp(e: FederatedPointerEvent): void {
|
||||
this.panning = null;
|
||||
const drag = this.dragging;
|
||||
this.dragging = null;
|
||||
if (drag && this.assetId) {
|
||||
const node = this.asset()?.graph.nodes.find((n) => n.id === drag.nodeId);
|
||||
if (node && drag.moved) {
|
||||
// The preview moved the node already; put it back so the command records the start.
|
||||
const next = { x: node.x, y: node.y };
|
||||
node.x = drag.start.x;
|
||||
node.y = drag.start.y;
|
||||
this.state.execute(new MoveNodeCommand(this.assetId, drag.nodeId, next.x, next.y));
|
||||
}
|
||||
}
|
||||
const connecting = this.connecting;
|
||||
this.connecting = null;
|
||||
if (connecting && this.assetId) {
|
||||
const target = this.portUnder(e.global.x, e.global.y);
|
||||
if (target && target.isOutput !== connecting.from.isOutput) {
|
||||
const out = connecting.from.isOutput ? connecting.from : target;
|
||||
const inp = connecting.from.isOutput ? target : connecting.from;
|
||||
this.state.execute(
|
||||
new ConnectCommand(
|
||||
this.assetId,
|
||||
{ node: out.nodeId, port: out.port },
|
||||
{ node: inp.nodeId, port: inp.port },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
private portUnder(globalX: number, globalY: number): PortAnchor | null {
|
||||
for (const view of this.views.values()) {
|
||||
const local = view.toLocal({ x: globalX, y: globalY });
|
||||
const hit = view.portAt(local.x, local.y);
|
||||
if (hit) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- menus -------------------------------------------------------------------------------
|
||||
|
||||
private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void {
|
||||
new NodePalette((def: NodeDef) => {
|
||||
if (!this.assetId) return;
|
||||
const command = new AddNodeCommand(this.assetId, def.type, at.x, at.y);
|
||||
if (this.state.execute(command))
|
||||
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
|
||||
}).open(globalX, globalY);
|
||||
}
|
||||
|
||||
private openNodeMenu(view: NodeView, globalX: number, globalY: number): void {
|
||||
const assetId = this.assetId;
|
||||
if (!assetId) return;
|
||||
this.state.select({ kind: 'node', assetId, nodeId: view.node.id });
|
||||
new Menu([
|
||||
{
|
||||
label: 'Delete node',
|
||||
danger: true,
|
||||
onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)),
|
||||
},
|
||||
]).open(globalX, globalY);
|
||||
}
|
||||
|
||||
private openPortMenu(
|
||||
view: NodeView,
|
||||
port: string,
|
||||
isOutput: boolean,
|
||||
globalX: number,
|
||||
globalY: number,
|
||||
): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) return;
|
||||
const edges = isOutput
|
||||
? edgesOutOf(asset.graph, view.node.id, port)
|
||||
: edgesInto(asset.graph, view.node.id, port);
|
||||
if (edges.length === 0) return;
|
||||
new Menu([
|
||||
{
|
||||
label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`,
|
||||
danger: true,
|
||||
onSelect: () => {
|
||||
for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id));
|
||||
},
|
||||
},
|
||||
]).open(globalX, globalY);
|
||||
}
|
||||
|
||||
// --- drawing -----------------------------------------------------------------------------
|
||||
|
||||
override layout(): void {
|
||||
this.hint.position.set(Theme.padding, this.h - Theme.padding - 16);
|
||||
if (this.needsFit) this.fitView();
|
||||
this.picker.setText(asset ? asset.name : 'No behavior');
|
||||
const nodes = asset?.graph.nodes.length ?? 0;
|
||||
const links = asset?.graph.edges.length ?? 0;
|
||||
this.info.setText(
|
||||
asset ? `${nodes} ${nodes === 1 ? 'node' : 'nodes'} · ${links} ${links === 1 ? 'link' : 'links'}` : '',
|
||||
);
|
||||
this.addButton.setEnabled(Boolean(asset) && this.state.mode === 'edit');
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const c = Theme.colors;
|
||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground);
|
||||
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
||||
this.syncGrid();
|
||||
const asset = this.asset();
|
||||
this.canvas.visible = asset !== null;
|
||||
if (!asset) {
|
||||
this.hint.text = 'Select a behavior asset (or a placed behavior) to edit its graph.';
|
||||
return;
|
||||
}
|
||||
const playing = this.state.mode === 'play';
|
||||
this.hint.text =
|
||||
asset.graph.nodes.length === 0
|
||||
? `${asset.name} · right-click to add a node`
|
||||
: `${asset.name}${playing ? ' · live' : ''}`;
|
||||
|
||||
const trace = playing ? this.traceProvider(asset.id) : null;
|
||||
const now = this.timeProvider();
|
||||
const isHot = (map: Map<string, number> | undefined, id: string) => {
|
||||
const t = map?.get(id);
|
||||
return t !== undefined && now - t <= TRACE_FADE;
|
||||
};
|
||||
const selectedNode = this.state.selection?.kind === 'node' ? this.state.selection.nodeId : null;
|
||||
for (const view of this.views.values()) {
|
||||
view.selected = view.node.id === selectedNode;
|
||||
view.hot = isHot(trace?.nodes, view.node.id);
|
||||
view.redraw();
|
||||
}
|
||||
|
||||
this.edges.clear();
|
||||
for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id));
|
||||
|
||||
this.tempEdge.clear();
|
||||
if (this.connecting) {
|
||||
const view = this.views.get(this.connecting.from.nodeId);
|
||||
const p = view?.portPosition(this.connecting.from.port, this.connecting.from.isOutput);
|
||||
if (view && p) {
|
||||
const a = { x: view.x + p.x, y: view.y + p.y };
|
||||
const b = this.connecting.pointer;
|
||||
const [from, to] = this.connecting.from.isOutput ? [a, b] : [b, a];
|
||||
bezier(this.tempEdge, from.x, from.y, to.x, to.y);
|
||||
this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawEdge(edge: GraphEdge, hot: boolean): void {
|
||||
const from = this.views.get(edge.from.node);
|
||||
const to = this.views.get(edge.to.node);
|
||||
const a = from?.portPosition(edge.from.port, true);
|
||||
const b = to?.portPosition(edge.to.port, false);
|
||||
if (!from || !to || !a || !b) return;
|
||||
const type = from.def.outputs.find((p) => p.key === edge.from.port)?.type ?? 'any';
|
||||
const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any';
|
||||
const valid = portsCompatible(type, inType);
|
||||
bezier(this.edges, from.x + a.x, from.y + a.y, to.x + b.x, to.y + b.y);
|
||||
this.edges.stroke({
|
||||
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
|
||||
color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger,
|
||||
alpha: type === 'exec' || hot ? 1 : 0.85,
|
||||
});
|
||||
}
|
||||
|
||||
private syncGrid(): void {
|
||||
this.grid.clear();
|
||||
const step = GRID_STEP * this.zoom;
|
||||
if (step < MIN_GRID_STEP) return;
|
||||
// `pan` is where the graph origin sits on screen – but the graph has no origin worth
|
||||
// marking, so no axis lines.
|
||||
drawGrid(this.grid, {
|
||||
originX: this.pan.x,
|
||||
originY: this.pan.y,
|
||||
step,
|
||||
w: this.w,
|
||||
h: this.h,
|
||||
alpha: 0.04,
|
||||
});
|
||||
}
|
||||
|
||||
// --- test hooks -----------------------------------------------------------------------------
|
||||
|
||||
/** Global screen position of a port (tests drive connections with it). */
|
||||
portGlobal(nodeId: string, port: string, isOutput: boolean): { x: number; y: number } | null {
|
||||
const view = this.views.get(nodeId);
|
||||
const p = view?.portPosition(port, isOutput);
|
||||
if (!view || !p) return null;
|
||||
const g = view.toGlobal({ x: p.x, y: p.y });
|
||||
return { x: g.x, y: g.y };
|
||||
}
|
||||
|
||||
/** Global screen position of a node's title bar centre. */
|
||||
nodeGlobal(nodeId: string): { x: number; y: number } | null {
|
||||
const view = this.views.get(nodeId);
|
||||
if (!view) return null;
|
||||
const g = view.toGlobal({ x: view.w / 2, y: TITLE_HEIGHT / 2 });
|
||||
return { x: g.x, y: g.y };
|
||||
super.redraw();
|
||||
this.headerBg.clear();
|
||||
const h = Theme.controlHeight + Theme.padding * 2;
|
||||
this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader);
|
||||
this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border);
|
||||
}
|
||||
}
|
||||
|
||||
/** Horizontal S-curve between two points, as used by every node editor. */
|
||||
function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void {
|
||||
const dx = Math.max(40, Math.abs(x2 - x1) * 0.5);
|
||||
g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
|
||||
}
|
||||
Reference in new issue
Block a user