A held key fired once and then nothing, so sliding a Tetris piece sideways or walking a menu meant building a timer next to every key node. Two settings on the node do it now: Repeat after and Repeat interval, both in milliseconds so the usual values stay whole numbers (300, 50) instead of 0.3 and 0.05. A delay of 0 keeps the node exactly as it was, an interval of 0 means every frame. The browser's own auto-repeat is deliberately not used - Input.press has always dropped it - because its delay and rate are an operating-system setting of the player's, not a game's. The repeat is built from the measured hold time instead, per node, so two nodes on one key keep their own clocks. The rule itself is a pure function in keyRepeat.ts and is checked without a world. Both rows stay off the node body while the delay is 0: two lines of "0" on every key node in every graph would be noise, and the screenshot said so. NodeDef gains hiddenParams for that; the inspector still shows them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1064 lines
39 KiB
TypeScript
1064 lines
39 KiB
TypeScript
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||
import type { Command } from '../../core/commands/Command';
|
||
import { CompoundCommand } from '../../core/commands/CompoundCommand';
|
||
import {
|
||
AddNodeCommand,
|
||
ConnectCommand,
|
||
DisconnectCommand,
|
||
FoldEdgeCommand,
|
||
MoveNodeCommand,
|
||
} from '../../core/commands/graphCommands';
|
||
import {
|
||
BEHAVIOR_PARAM,
|
||
callProblem,
|
||
canConnect,
|
||
defAcceptsFrom,
|
||
dropTargetPort,
|
||
edgesInto,
|
||
edgesOutOf,
|
||
firstPortFor,
|
||
type GraphEdge,
|
||
type GraphNode,
|
||
type BehaviorLookup,
|
||
type PortSide,
|
||
type PortType,
|
||
portsCompatible,
|
||
portTypeOf,
|
||
resolveDef,
|
||
RUN_BEHAVIOR,
|
||
} from '../../core/graph';
|
||
import type { BehaviorAsset } from '../../core/model/Asset';
|
||
import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
||
import { findAsset } from '../../core/model/Project';
|
||
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
|
||
import {
|
||
activeDrag,
|
||
clamp,
|
||
type DragPayload,
|
||
drawGrid,
|
||
endDrag,
|
||
Menu,
|
||
type MenuItem,
|
||
onDragChange,
|
||
type Size,
|
||
Theme,
|
||
textStyle,
|
||
truncate,
|
||
Widget,
|
||
zoomFactor,
|
||
} from '../../ui';
|
||
import { pressCount } from '../../ui/clicks';
|
||
import { removeNodes } from '../actions';
|
||
import { type EditorState, nodeSelection } from '../state';
|
||
import { foldedEdgeShown, foldMarkKey, foldMarkLabel } from './folding';
|
||
import { type Box, boxesInBand, frameBoxes } from './framing';
|
||
import { NodePalette } from './NodePalette';
|
||
import { type PaletteEntry, paletteEntries } from './paletteEntries';
|
||
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView';
|
||
|
||
const GRID_STEP = 24;
|
||
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
|
||
const MIN_GRID_STEP = 8;
|
||
const MIN_ZOOM = 0.25;
|
||
const MAX_ZOOM = 2.5;
|
||
/** Stand-in node height for a node with no view yet – real nodes vary with their port count. */
|
||
const NODE_HEIGHT_ESTIMATE = 80;
|
||
/** Breathing room around the framed graph, so nodes do not touch the edge. */
|
||
const FIT_MARGIN = 24;
|
||
/** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */
|
||
const DRAG_THRESHOLD = 3;
|
||
/** How far a folded edge's stub reaches out of its port. */
|
||
const FOLD_STUB = 16;
|
||
const FOLD_MARK_RADIUS = 5;
|
||
const FOLD_LABEL_WIDTH = 96;
|
||
|
||
/** One port's hidden links as drawn: what they stand in for, and where they can be clicked. */
|
||
interface FoldMark {
|
||
edgeIds: string[];
|
||
/** The nodes at the other ends, deduped – clicking selects them all. */
|
||
others: string[];
|
||
isOutput: boolean;
|
||
x: number;
|
||
y: number;
|
||
text: string;
|
||
}
|
||
|
||
/** A mark while the edges are still adding themselves to it. */
|
||
interface PendingMark extends Omit<FoldMark, 'text'> {
|
||
names: string[];
|
||
portX: number;
|
||
color: number;
|
||
hot: boolean;
|
||
}
|
||
|
||
/**
|
||
* The node graph of one behavior asset. Which behavior that is comes from the outside (`show`),
|
||
* so the canvas never has an opinion about the selection – the GraphEditor around it does.
|
||
*/
|
||
export class GraphCanvas extends Widget {
|
||
private readonly bg = new Graphics();
|
||
private readonly grid = new Graphics();
|
||
private readonly clip = new Graphics();
|
||
private readonly canvas = new Container();
|
||
private readonly edges = new Graphics();
|
||
private readonly markChips = new Graphics();
|
||
/** Texts naming the far node of each folded edge; pooled, since a redraw rebuilds them all. */
|
||
private readonly markLayer = new Container();
|
||
private readonly markLabels: Text[] = [];
|
||
private markLabelCount = 0;
|
||
private foldMarks: FoldMark[] = [];
|
||
private readonly foldPending = new Map<string, PendingMark>();
|
||
/** Edges the last redraw really stroked as a curve – a hidden one is missing from it. */
|
||
private readonly strokedEdges = new Set<string>();
|
||
private readonly nodeLayer = new Container();
|
||
private readonly tempEdge = new Graphics();
|
||
/** Drawn in screen space, so the band keeps a hairline border at any zoom. */
|
||
private readonly band = new Graphics();
|
||
private readonly hint = new Text({ text: '', style: textStyle('muted') });
|
||
private readonly views = new Map<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: {
|
||
items: { nodeId: string; start: { x: number; y: number } }[];
|
||
pointer: { x: number; y: number };
|
||
moved: boolean;
|
||
} | null = null;
|
||
/** A rubber-band selection in progress, in graph coordinates. */
|
||
private marquee: {
|
||
start: { x: number; y: number };
|
||
current: { x: number; y: number };
|
||
active: boolean;
|
||
} | null = null;
|
||
/** A behavior dragged in from the asset panel, and why it may not land. */
|
||
private incoming: { assetId: string; at: { x: number; y: number }; rejection: string } | null = null;
|
||
private connecting: { from: PortSide; pointer: { x: number; y: number } } | null = null;
|
||
/** A finished drag whose palette is still open – drawn like the running one, so the connection
|
||
* being made stays visible while the node type is chosen. */
|
||
private pendingDrop: { from: PortSide; pointer: { x: number; y: number } } | null = null;
|
||
/** Where the running drag would land, recomputed on every move so the drop holds no surprise. */
|
||
private dropTarget: PortSide | null = null;
|
||
/** Whether the last redraw actually stroked the dangling edge. */
|
||
private tempEdgeShown = false;
|
||
/** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */
|
||
private needsFit = true;
|
||
/** Supplies live execution data while playing (null otherwise). */
|
||
traceProvider: (assetId: string) => Trace | null = () => null;
|
||
timeProvider: () => number = () => 0;
|
||
|
||
constructor(private readonly state: EditorState) {
|
||
super();
|
||
this.canvas.addChild(this.edges, this.markChips, this.markLayer, this.nodeLayer, this.tempEdge);
|
||
this.addChild(this.bg, this.grid, this.canvas, this.band, this.hint, this.clip);
|
||
this.canvas.mask = this.clip;
|
||
this.cursor = 'default';
|
||
|
||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onCanvasPointerDown(e));
|
||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||
this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e));
|
||
this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e));
|
||
this.on('wheel', (e: FederatedWheelEvent) => {
|
||
const local = this.toLocal(e.global);
|
||
const before = this.screenToGraph(local.x, local.y);
|
||
this.zoom = clamp(this.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM);
|
||
const after = this.screenToGraph(local.x, local.y);
|
||
this.pan.x += (after.x - before.x) * this.zoom;
|
||
this.pan.y += (after.y - before.y) * this.zoom;
|
||
this.applyView();
|
||
e.preventDefault();
|
||
});
|
||
|
||
// A drag can end anywhere; without this the preview would outlive it.
|
||
onDragChange(() => {
|
||
if (!activeDrag()) this.setIncoming(null);
|
||
});
|
||
|
||
// Only the highlight of the selected node depends on the selection.
|
||
state.on('selection', () => this.redraw());
|
||
state.on('project', () => this.sync());
|
||
state.on('mode', () => this.redraw());
|
||
}
|
||
|
||
get currentAssetId(): string | null {
|
||
return this.assetId;
|
||
}
|
||
|
||
/** Switching refits the view, since the next graph is a new subject. */
|
||
show(assetId: string | null): void {
|
||
if (this.assetId === assetId) return;
|
||
this.assetId = assetId;
|
||
this.sync();
|
||
this.needsFit = true;
|
||
this.fitView();
|
||
}
|
||
|
||
fit(): void {
|
||
this.needsFit = true;
|
||
this.fitView();
|
||
}
|
||
|
||
/** Drops the node in the middle of the view, since a button press names no position. */
|
||
addNodeFrom(anchor: Widget): void {
|
||
if (!this.assetId || this.state.mode !== 'edit') return;
|
||
const centre = this.screenToGraph(this.w / 2, this.h / 2);
|
||
new NodePalette(this.entries(), (entry) =>
|
||
this.addNode(entry, { x: centre.x - NODE_WIDTH / 2, y: centre.y - NODE_HEIGHT_ESTIMATE / 2 }),
|
||
).openBelow(anchor);
|
||
}
|
||
|
||
override measure(): Size {
|
||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||
}
|
||
|
||
// --- model binding ----------------------------------------------------------------------
|
||
|
||
private behaviors(): BehaviorLookup {
|
||
return behaviorLookup(this.state.project);
|
||
}
|
||
|
||
private asset(): BehaviorAsset | null {
|
||
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
||
return asset && asset.type === 'behavior' ? asset : null;
|
||
}
|
||
|
||
/** Rebuilds node views from the model (cheap: views are reused by node id). */
|
||
private sync(): void {
|
||
const asset = this.asset();
|
||
if (!asset) {
|
||
// A behavior that was deleted (or undone away) must not linger as a graph.
|
||
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>();
|
||
const lookup = behaviorLookup(this.state.project);
|
||
for (const node of asset.graph.nodes) {
|
||
const def = resolveDef(node, lookup);
|
||
if (!def) continue;
|
||
seen.add(node.id);
|
||
let view = this.views.get(node.id);
|
||
if (!view || view.node !== node) {
|
||
view?.destroy({ children: true });
|
||
view = new NodeView(node, def);
|
||
view.on('pointerdown', (e: FederatedPointerEvent) => this.onNodePointerDown(view!, e));
|
||
this.views.set(node.id, view);
|
||
this.nodeLayer.addChild(view);
|
||
}
|
||
view.sync(node, asset.graph, asset, this.state.project, def, callProblem(node, lookup, asset.id));
|
||
}
|
||
for (const [id, view] of this.views) {
|
||
if (!seen.has(id)) {
|
||
view.destroy({ children: true });
|
||
this.views.delete(id);
|
||
}
|
||
}
|
||
this.redraw();
|
||
}
|
||
|
||
/** Frames the graph's nodes (or the origin) in the view. Waits for a real size. */
|
||
private fitView(): void {
|
||
if (this.w === 0 || this.h === 0) return;
|
||
this.needsFit = false;
|
||
const asset = this.asset();
|
||
if (!asset || asset.graph.nodes.length === 0) {
|
||
this.pan = { x: this.w / 2 - NODE_WIDTH / 2, y: this.h / 3 };
|
||
this.zoom = 1;
|
||
this.applyView();
|
||
return;
|
||
}
|
||
const framing = frameBoxes(this.nodeBoxes(), { w: this.w, h: this.h }, FIT_MARGIN, {
|
||
min: MIN_ZOOM,
|
||
max: MAX_ZOOM,
|
||
});
|
||
this.zoom = framing.zoom;
|
||
this.pan = { x: framing.panX, y: framing.panY };
|
||
this.applyView();
|
||
}
|
||
|
||
/** Each node as the rectangle it is actually drawn as (a view knows its own height). */
|
||
private nodeBoxes(): Box[] {
|
||
return (this.asset()?.graph.nodes ?? []).map((node) => {
|
||
const view = this.views.get(node.id);
|
||
return {
|
||
id: node.id,
|
||
x: node.x,
|
||
y: node.y,
|
||
w: view?.w ?? NODE_WIDTH,
|
||
h: view?.h ?? NODE_HEIGHT_ESTIMATE,
|
||
};
|
||
});
|
||
}
|
||
|
||
private applyView(): void {
|
||
this.canvas.position.set(Math.round(this.pan.x), Math.round(this.pan.y));
|
||
this.canvas.scale.set(this.zoom);
|
||
this.redraw();
|
||
}
|
||
|
||
private screenToGraph(sx: number, sy: number): { x: number; y: number } {
|
||
return { x: (sx - this.pan.x) / this.zoom, y: (sy - this.pan.y) / this.zoom };
|
||
}
|
||
|
||
private graphToScreen(gx: number, gy: number): { x: number; y: number } {
|
||
return { x: gx * this.zoom + this.pan.x, y: gy * this.zoom + this.pan.y };
|
||
}
|
||
|
||
// --- pointer -----------------------------------------------------------------------------
|
||
|
||
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.
|
||
if (e.button === 1) {
|
||
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
if (e.target !== this) return;
|
||
// Shift-drag on empty space pans as well, as it does in the scene view.
|
||
if (e.button === 0 && e.shiftKey) {
|
||
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
|
||
return;
|
||
}
|
||
const local = this.toLocal(e.global);
|
||
const g = this.screenToGraph(local.x, local.y);
|
||
const mark = this.state.mode === 'edit' ? this.markAt(g) : null;
|
||
if (e.button === 2) {
|
||
if (mark) this.openMarkMenu(mark, e.global.x, e.global.y);
|
||
else if (this.assetId && this.state.mode === 'edit') this.openPalette(e.global.x, e.global.y, g);
|
||
return;
|
||
}
|
||
if (e.button === 0) {
|
||
// Selecting the far nodes is also what puts the hidden curves back on screen.
|
||
if (mark && this.assetId) {
|
||
this.state.select(nodeSelection(this.assetId, mark.others));
|
||
return;
|
||
}
|
||
// Empty space: a click selects the behavior, a drag becomes a rubber band – decided on release.
|
||
this.marquee = { start: g, current: g, active: false };
|
||
}
|
||
}
|
||
|
||
private onNodePointerDown(view: NodeView, e: FederatedPointerEvent): void {
|
||
// Panning takes precedence over the node: let the middle button bubble up to the canvas.
|
||
if (e.button === 1) return;
|
||
if (!this.assetId || 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.dropTarget = null;
|
||
this.redraw();
|
||
return;
|
||
}
|
||
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
||
this.state.toggleNode(this.assetId, view.node.id);
|
||
return;
|
||
}
|
||
if (pressCount(e, view.node.id) === 2 && this.openCalledBehavior(view.node)) return;
|
||
// Clicking a selected node keeps the selection so the whole group can be dragged.
|
||
if (!this.state.selectedNodeIds.includes(view.node.id))
|
||
this.state.select(nodeSelection(this.assetId, [view.node.id]));
|
||
const p = this.toLocal(e.global);
|
||
this.dragging = {
|
||
items: this.selectedNodes().map((n) => ({ nodeId: n.id, start: { x: n.x, y: n.y } })),
|
||
pointer: this.screenToGraph(p.x, p.y),
|
||
moved: false,
|
||
};
|
||
this.nodeLayer.addChild(view); // raise
|
||
}
|
||
|
||
/** Selecting the asset is what opens it: the graph tab follows the selection. */
|
||
private openCalledBehavior(node: GraphNode): boolean {
|
||
if (node.type !== RUN_BEHAVIOR) return false;
|
||
const id = String(node.params[BEHAVIOR_PARAM] ?? '');
|
||
const asset = id ? findAsset(this.state.project, id) : undefined;
|
||
if (asset?.type !== 'behavior') return false;
|
||
this.state.select({ kind: 'asset', assetId: id });
|
||
return true;
|
||
}
|
||
|
||
private selectedNodes(): GraphNode[] {
|
||
const graph = this.asset()?.graph;
|
||
if (!graph) return [];
|
||
const ids = new Set(this.state.selectedNodeIds);
|
||
return graph.nodes.filter((n) => ids.has(n.id));
|
||
}
|
||
|
||
private onPointerMove(e: FederatedPointerEvent): void {
|
||
const dragged = activeDrag();
|
||
if (dragged) {
|
||
this.trackIncoming(dragged, e);
|
||
return;
|
||
}
|
||
if (this.panning) {
|
||
this.pan.x = this.panning.panX + (e.global.x - this.panning.startX);
|
||
this.pan.y = this.panning.panY + (e.global.y - this.panning.startY);
|
||
this.applyView();
|
||
return;
|
||
}
|
||
const local = this.toLocal(e.global);
|
||
const g = this.screenToGraph(local.x, local.y);
|
||
if (this.dragging) {
|
||
const dx = g.x - this.dragging.pointer.x;
|
||
const dy = g.y - this.dragging.pointer.y;
|
||
if (!this.dragging.moved && Math.hypot(dx, dy) * this.zoom < DRAG_THRESHOLD) return;
|
||
this.dragging.moved = true;
|
||
const graph = this.asset()?.graph;
|
||
if (!graph) return;
|
||
for (const item of this.dragging.items) {
|
||
const node = graph.nodes.find((n) => n.id === item.nodeId);
|
||
if (!node) continue;
|
||
node.x = Math.round(item.start.x + dx);
|
||
node.y = Math.round(item.start.y + dy);
|
||
}
|
||
this.state.preview();
|
||
} else if (this.connecting) {
|
||
this.connecting.pointer = g;
|
||
this.dropTarget = this.resolveDrop(e.global.x, e.global.y);
|
||
this.redraw();
|
||
} else if (this.marquee) {
|
||
this.marquee.current = g;
|
||
const d = Math.hypot(g.x - this.marquee.start.x, g.y - this.marquee.start.y) * this.zoom;
|
||
if (!this.marquee.active && d < DRAG_THRESHOLD) return;
|
||
this.marquee.active = true;
|
||
this.redraw();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Follows a behavior dragged in from the asset panel. The node is centred under the pointer, so
|
||
* it lands where the ghost was rather than below and right of it.
|
||
*/
|
||
private trackIncoming(dragged: DragPayload, e: FederatedPointerEvent): void {
|
||
const local = this.toLocal(e.global);
|
||
const inside = local.x >= 0 && local.x <= this.w && local.y >= 0 && local.y <= this.h;
|
||
const asset = dragged.kind === 'asset' ? findAsset(this.state.project, dragged.id) : undefined;
|
||
if (!inside || !this.assetId || this.state.mode !== 'edit' || asset?.type !== 'behavior') {
|
||
this.setIncoming(null);
|
||
return;
|
||
}
|
||
const g = this.screenToGraph(local.x, local.y);
|
||
this.setIncoming({
|
||
assetId: asset.id,
|
||
at: { x: Math.round(g.x - NODE_WIDTH / 2), y: Math.round(g.y - TITLE_HEIGHT / 2) },
|
||
rejection: asset.id === this.assetId ? 'A behavior cannot run itself' : '',
|
||
});
|
||
}
|
||
|
||
private setIncoming(incoming: GraphCanvas['incoming']): void {
|
||
if (!incoming && !this.incoming) return;
|
||
this.incoming = incoming;
|
||
this.redraw();
|
||
}
|
||
|
||
private dropIncoming(drop: NonNullable<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;
|
||
const marquee = this.marquee;
|
||
this.marquee = null;
|
||
if (marquee && this.assetId && this.state.mode === 'edit') {
|
||
if (marquee.active) this.selectInMarquee(marquee);
|
||
else this.state.select({ kind: 'asset', assetId: this.assetId });
|
||
}
|
||
const drag = this.dragging;
|
||
this.dragging = null;
|
||
if (drag?.moved && this.assetId) {
|
||
const graph = this.asset()?.graph;
|
||
const moves: MoveNodeCommand[] = [];
|
||
for (const item of drag.items) {
|
||
const node = graph?.nodes.find((n) => n.id === item.nodeId);
|
||
if (!node) continue;
|
||
// The preview moved the node already; put it back so the command records the start.
|
||
const next = { x: node.x, y: node.y };
|
||
node.x = item.start.x;
|
||
node.y = item.start.y;
|
||
moves.push(new MoveNodeCommand(this.assetId, item.nodeId, next.x, next.y));
|
||
}
|
||
if (moves.length === 1) this.state.execute(moves[0]!);
|
||
else if (moves.length > 1) this.state.execute(new CompoundCommand('Move nodes', moves));
|
||
}
|
||
const connecting = this.connecting;
|
||
const target = connecting ? (this.dropTarget ?? this.resolveDrop(e.global.x, e.global.y)) : null;
|
||
this.connecting = null;
|
||
this.dropTarget = null;
|
||
if (connecting && this.assetId) {
|
||
if (target) this.connect(connecting.from, target);
|
||
else if (!this.nodeUnder(e.global.x, e.global.y))
|
||
this.openPaletteForDrop(connecting, e.global.x, e.global.y);
|
||
}
|
||
this.redraw();
|
||
}
|
||
|
||
private selectInMarquee(marquee: {
|
||
start: { x: number; y: number };
|
||
current: { x: number; y: number };
|
||
}): void {
|
||
if (!this.assetId) return;
|
||
const ids = boxesInBand(this.nodeBoxes(), {
|
||
x1: marquee.start.x,
|
||
y1: marquee.start.y,
|
||
x2: marquee.current.x,
|
||
y2: marquee.current.y,
|
||
});
|
||
// An empty band clears the nodes but keeps the behavior selected, as a plain click does.
|
||
this.state.select(nodeSelection(this.assetId, ids) ?? { kind: 'asset', assetId: this.assetId });
|
||
}
|
||
|
||
private connect(from: PortSide, to: PortSide): void {
|
||
if (!this.assetId) return;
|
||
const [out, inp] = from.isOutput ? [from, to] : [to, from];
|
||
this.state.execute(
|
||
new ConnectCommand(
|
||
this.assetId,
|
||
{ node: out.nodeId, port: out.port },
|
||
{ node: inp.nodeId, port: inp.port },
|
||
),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
return node ? dropTargetPort(graph, from, node, lookup) : null;
|
||
}
|
||
|
||
private portUnder(globalX: number, globalY: number): PortSide | null {
|
||
for (const view of this.topDownViews()) {
|
||
const local = view.toLocal({ x: globalX, y: globalY });
|
||
const hit = view.portAt(local.x, local.y);
|
||
if (hit) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private nodeUnder(globalX: number, globalY: number): string | null {
|
||
for (const view of this.topDownViews()) {
|
||
const p = view.toLocal({ x: globalX, y: globalY });
|
||
if (p.x >= 0 && p.x <= view.w && p.y >= 0 && p.y <= view.h) return view.node.id;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Views in stacking order, topmost first – a node drawn over another one wins the pointer. */
|
||
private *topDownViews(): Iterable<NodeView> {
|
||
for (let i = this.nodeLayer.children.length - 1; i >= 0; i--) {
|
||
const child = this.nodeLayer.children[i];
|
||
if (child instanceof NodeView) yield child;
|
||
}
|
||
}
|
||
|
||
// --- menus -------------------------------------------------------------------------------
|
||
|
||
private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void {
|
||
new NodePalette(this.entries(), (entry) => this.addNode(entry, at)).open(globalX, globalY);
|
||
}
|
||
|
||
private entries(): PaletteEntry[] {
|
||
const asset = this.asset();
|
||
return asset ? paletteEntries(this.state.project, asset) : [];
|
||
}
|
||
|
||
private addNode(entry: PaletteEntry, at: { x: number; y: number }): void {
|
||
if (!this.assetId) return;
|
||
const command = new AddNodeCommand(
|
||
this.assetId,
|
||
entry.def.type,
|
||
Math.round(at.x),
|
||
Math.round(at.y),
|
||
entry.params,
|
||
);
|
||
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 = new AddNodeCommand(
|
||
this.assetId,
|
||
entry.def.type,
|
||
Math.round(at.x),
|
||
Math.round(at.y),
|
||
entry.params,
|
||
);
|
||
const port = firstPortFor(entry.def, fromType, from.isOutput);
|
||
const to = { nodeId: add.node.id, port: port ?? '', isOutput: !from.isOutput };
|
||
const [out, inp] = from.isOutput ? [from, to] : [to, from];
|
||
const commands: Command[] = [add];
|
||
if (port)
|
||
commands.push(
|
||
new ConnectCommand(
|
||
this.assetId,
|
||
{ node: out.nodeId, port: out.port },
|
||
{ node: inp.nodeId, port: inp.port },
|
||
),
|
||
);
|
||
if (this.state.execute(new CompoundCommand(add.label, commands)))
|
||
this.state.select(nodeSelection(this.assetId, [add.node.id]));
|
||
}
|
||
|
||
private openNodeMenu(view: NodeView, globalX: number, globalY: number): void {
|
||
const assetId = this.assetId;
|
||
if (!assetId) return;
|
||
// Right-clicking inside a multi-selection acts on all of it; outside it, on that node alone.
|
||
if (!this.state.selectedNodeIds.includes(view.node.id))
|
||
this.state.select(nodeSelection(assetId, [view.node.id]));
|
||
const ids = this.state.selectedNodeIds;
|
||
new Menu([
|
||
{
|
||
label: ids.length > 1 ? `Delete ${ids.length} nodes` : 'Delete node',
|
||
danger: true,
|
||
onSelect: () => removeNodes(this.state, assetId, ids),
|
||
},
|
||
]).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;
|
||
const shown = edges.filter((e) => !e.folded);
|
||
const hidden = edges.filter((e) => e.folded);
|
||
const fold = (list: GraphEdge[], folded: boolean) => () => {
|
||
const commands = list.map((e) => new FoldEdgeCommand(asset.id, e.id, folded));
|
||
this.state.execute(
|
||
commands.length === 1
|
||
? commands[0]!
|
||
: new CompoundCommand(folded ? 'Hide links' : 'Show links', commands),
|
||
);
|
||
};
|
||
const items: MenuItem[] = [];
|
||
if (shown.length > 0)
|
||
items.push({
|
||
label: shown.length === 1 ? 'Hide link' : `Hide ${shown.length} links`,
|
||
onSelect: fold(shown, true),
|
||
});
|
||
if (hidden.length > 0)
|
||
items.push({
|
||
label: hidden.length === 1 ? 'Show link' : `Show ${hidden.length} links`,
|
||
onSelect: fold(hidden, false),
|
||
});
|
||
items.push({
|
||
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));
|
||
},
|
||
});
|
||
new Menu(items).open(globalX, globalY);
|
||
}
|
||
|
||
/** The mark is the only handle a hidden edge has, so it carries the way back. */
|
||
private openMarkMenu(mark: FoldMark, globalX: number, globalY: number): void {
|
||
const assetId = this.assetId;
|
||
if (!assetId) return;
|
||
const ids = mark.edgeIds;
|
||
const n = ids.length;
|
||
const all = (title: string, make: (edgeId: string) => Command) => () => {
|
||
const commands = ids.map(make);
|
||
this.state.execute(commands.length === 1 ? commands[0]! : new CompoundCommand(title, commands));
|
||
};
|
||
new Menu([
|
||
{
|
||
label: n === 1 ? 'Show link' : `Show ${n} links`,
|
||
onSelect: all('Show links', (id) => new FoldEdgeCommand(assetId, id, false)),
|
||
},
|
||
{
|
||
label: n === 1 ? 'Disconnect' : `Disconnect ${n} links`,
|
||
danger: true,
|
||
onSelect: all('Disconnect links', (id) => new DisconnectCommand(assetId, id)),
|
||
},
|
||
]).open(globalX, globalY);
|
||
}
|
||
|
||
// --- drawing -----------------------------------------------------------------------------
|
||
|
||
override layout(): void {
|
||
this.hint.position.set(Theme.padding, this.h - Theme.padding - 16);
|
||
if (this.needsFit) this.fitView();
|
||
}
|
||
|
||
override redraw(): void {
|
||
const c = Theme.colors;
|
||
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground);
|
||
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
||
this.syncGrid();
|
||
const asset = this.asset();
|
||
this.canvas.visible = asset !== null;
|
||
if (!asset) {
|
||
this.hint.text = 'No behavior selected – pick one above, or add a behavior asset.';
|
||
return;
|
||
}
|
||
const playing = this.state.mode === 'play';
|
||
this.hint.text = asset.graph.nodes.length === 0 ? 'Right-click to add a node' : playing ? 'live' : '';
|
||
|
||
const trace = playing ? this.traceProvider(asset.id) : null;
|
||
const now = this.timeProvider();
|
||
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 target = this.dropTarget;
|
||
for (const view of this.views.values()) {
|
||
view.selected = selectedNodes.has(view.node.id);
|
||
view.hot = isHot(trace?.nodes, view.node.id);
|
||
view.targetPort =
|
||
target && target.nodeId === view.node.id ? { port: target.port, isOutput: target.isOutput } : null;
|
||
view.redraw();
|
||
}
|
||
|
||
this.edges.clear();
|
||
this.markChips.clear();
|
||
this.foldMarks = [];
|
||
this.markLabelCount = 0;
|
||
this.strokedEdges.clear();
|
||
this.foldPending.clear();
|
||
for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id), selectedNodes);
|
||
// After the curves, so no line drawn later crosses a mark that stands for a hidden one.
|
||
this.drawFoldMarks();
|
||
for (let i = this.markLabelCount; i < this.markLabels.length; i++) this.markLabels[i]!.visible = false;
|
||
|
||
this.tempEdge.clear();
|
||
this.tempEdgeShown = false;
|
||
const drag = this.connecting ?? this.pendingDrop;
|
||
if (drag) {
|
||
const view = this.views.get(drag.from.nodeId);
|
||
const p = view?.portPosition(drag.from.port, drag.from.isOutput);
|
||
if (view && p) {
|
||
const a = { x: view.x + p.x, y: view.y + p.y };
|
||
// Snapping to the resolved port makes the prediction the ring shows unmistakable.
|
||
const b = this.anchorPoint(target) ?? drag.pointer;
|
||
const [from, to] = drag.from.isOutput ? [a, b] : [b, a];
|
||
bezier(this.tempEdge, from.x, from.y, to.x, to.y);
|
||
this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
|
||
this.tempEdgeShown = true;
|
||
}
|
||
}
|
||
|
||
this.band.clear();
|
||
const m = this.marquee;
|
||
if (m?.active) {
|
||
const a = this.graphToScreen(m.start.x, m.start.y);
|
||
const b = this.graphToScreen(m.current.x, m.current.y);
|
||
this.band
|
||
.rect(Math.min(a.x, b.x), Math.min(a.y, b.y), Math.abs(b.x - a.x), Math.abs(b.y - a.y))
|
||
.fill({ color: c.marquee, alpha: 0.12 })
|
||
.stroke({ width: 1, color: c.marquee });
|
||
}
|
||
const drop = this.incoming;
|
||
if (drop) {
|
||
const a = this.graphToScreen(drop.at.x, drop.at.y);
|
||
const color = drop.rejection ? c.danger : c.nodeSelected;
|
||
this.band
|
||
.rect(a.x, a.y, NODE_WIDTH * this.zoom, NODE_HEIGHT_ESTIMATE * this.zoom)
|
||
.fill({ color, alpha: 0.15 })
|
||
.stroke({ width: 2, color });
|
||
}
|
||
}
|
||
|
||
/** A port's centre in graph coordinates. */
|
||
private anchorPoint(side: PortSide | null): { x: number; y: number } | null {
|
||
if (!side) return null;
|
||
const view = this.views.get(side.nodeId);
|
||
const p = view?.portPosition(side.port, side.isOutput);
|
||
return view && p ? { x: view.x + p.x, y: view.y + p.y } : null;
|
||
}
|
||
|
||
private drawEdge(edge: GraphEdge, hot: boolean, selected: Set<string>): 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);
|
||
const color = hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger;
|
||
const start = { x: from.x + a.x, y: from.y + a.y };
|
||
const end = { x: to.x + b.x, y: to.y + b.y };
|
||
if (edge.folded) {
|
||
this.addFoldMark(edge, start, true, to.def.label, color, hot);
|
||
this.addFoldMark(edge, end, false, from.def.label, color, hot);
|
||
if (!foldedEdgeShown(edge, selected, hot)) return;
|
||
}
|
||
bezier(this.edges, start.x, start.y, end.x, end.y);
|
||
this.edges.stroke({
|
||
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
|
||
color,
|
||
// Dimmed while folded, so a curve that is only on loan is not mistaken for a plain one.
|
||
alpha: (type === 'exec' || hot ? 1 : 0.85) * (edge.folded ? 0.6 : 1),
|
||
});
|
||
this.strokedEdges.add(edge.id);
|
||
}
|
||
|
||
/** Adds one end of a hidden link to its port's mark, creating that mark on first sight. */
|
||
private addFoldMark(
|
||
edge: GraphEdge,
|
||
port: { x: number; y: number },
|
||
isOutput: boolean,
|
||
otherLabel: string,
|
||
color: number,
|
||
hot: boolean,
|
||
): void {
|
||
const [side, other] = isOutput ? [edge.from, edge.to.node] : [edge.to, edge.from.node];
|
||
const key = foldMarkKey(side, isOutput);
|
||
let mark = this.foldPending.get(key);
|
||
if (!mark) {
|
||
mark = {
|
||
edgeIds: [],
|
||
others: [],
|
||
names: [],
|
||
isOutput,
|
||
x: port.x + (isOutput ? FOLD_STUB : -FOLD_STUB),
|
||
y: port.y,
|
||
portX: port.x,
|
||
color,
|
||
hot,
|
||
};
|
||
this.foldPending.set(key, mark);
|
||
}
|
||
mark.edgeIds.push(edge.id);
|
||
mark.names.push(otherLabel);
|
||
if (!mark.others.includes(other)) mark.others.push(other);
|
||
// A pulse anywhere in the bundle colours the whole mark: it is the one thing worth spotting.
|
||
if (hot && !mark.hot) {
|
||
mark.hot = true;
|
||
mark.color = color;
|
||
}
|
||
}
|
||
|
||
/** A stub out of the port, a dot, and what waits at the other end – one per port, not per link. */
|
||
private drawFoldMarks(): void {
|
||
for (const mark of this.foldPending.values()) {
|
||
const { x, y, color, isOutput } = mark;
|
||
this.edges.moveTo(mark.portX, y).lineTo(x, y).stroke({ width: 2, color, alpha: 0.9 });
|
||
const radius = mark.edgeIds.length > 1 ? FOLD_MARK_RADIUS + 2 : FOLD_MARK_RADIUS;
|
||
this.edges.circle(x, y, radius).fill(color);
|
||
this.edges.circle(x, y, radius).stroke({ width: 1, color: Theme.colors.node });
|
||
const label = this.markLabel();
|
||
label.text = truncate(foldMarkLabel(mark.names), textStyle('small'), FOLD_LABEL_WIDTH);
|
||
const gap = radius + 4;
|
||
const lx = isOutput ? x + gap : x - gap - label.width;
|
||
label.position.set(lx, y - label.height / 2);
|
||
// Without a plate behind it the name blurs into whatever curves pass behind the mark.
|
||
this.markChips
|
||
.roundRect(lx - 3, y - label.height / 2 - 1, label.width + 6, label.height + 2, 3)
|
||
.fill({ color: Theme.colors.node, alpha: 0.85 });
|
||
this.foldMarks.push({ edgeIds: mark.edgeIds, others: mark.others, isOutput, x, y, text: label.text });
|
||
}
|
||
}
|
||
|
||
private markLabel(): Text {
|
||
let label = this.markLabels[this.markLabelCount];
|
||
if (!label) {
|
||
label = new Text({ text: '', style: textStyle('small') });
|
||
this.markLabels.push(label);
|
||
this.markLayer.addChild(label);
|
||
}
|
||
label.visible = true;
|
||
this.markLabelCount++;
|
||
return label;
|
||
}
|
||
|
||
private markAt(g: { x: number; y: number }): FoldMark | null {
|
||
for (const m of this.foldMarks) {
|
||
const radius = m.edgeIds.length > 1 ? FOLD_MARK_RADIUS + 2 : FOLD_MARK_RADIUS;
|
||
if (Math.hypot(g.x - m.x, g.y - m.y) <= radius + 4) return m;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private syncGrid(): void {
|
||
this.grid.clear();
|
||
const step = GRID_STEP * this.zoom;
|
||
if (step < MIN_GRID_STEP) return;
|
||
// `pan` is where the graph origin sits on screen, but no axis lines: it marks nothing.
|
||
drawGrid(this.grid, {
|
||
originX: this.pan.x,
|
||
originY: this.pan.y,
|
||
step,
|
||
w: this.w,
|
||
h: this.h,
|
||
alpha: 0.04,
|
||
});
|
||
}
|
||
|
||
// --- 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 };
|
||
}
|
||
|
||
/** The port a running edge drag would connect to, or null when the drop would do nothing. */
|
||
pendingDropTarget(): PortSide | null {
|
||
return this.dropTarget;
|
||
}
|
||
|
||
/** Whether the dangling edge is on screen right now (running drag, or a drop awaiting its node). */
|
||
danglingEdgeVisible(): boolean {
|
||
return this.tempEdgeShown;
|
||
}
|
||
|
||
/** Whether the last redraw drew this edge as a curve – the claim "hidden" is about exactly this. */
|
||
edgeDrawn(edgeId: string): boolean {
|
||
return this.strokedEdges.has(edgeId);
|
||
}
|
||
|
||
/** Every mark the last redraw drew – its length is the claim "one per port, not one per link". */
|
||
foldMarksDrawn(): { links: number; label: string; isOutput: boolean }[] {
|
||
return this.foldMarks.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.foldMarks.find((m) => m.edgeIds.includes(edgeId) && m.isOutput === isOutput);
|
||
if (!mark) return null;
|
||
const g = this.canvas.toGlobal({ x: mark.x, y: mark.y });
|
||
return { x: g.x, y: g.y, links: mark.edgeIds.length, label: mark.text };
|
||
}
|
||
|
||
/** A node's drawn rectangle on screen, and the canvas' own – "fit" is a claim about both. */
|
||
nodeRect(nodeId: string): { x: number; y: number; w: number; h: number } | null {
|
||
const view = this.views.get(nodeId);
|
||
if (!view) return null;
|
||
const topLeft = view.toGlobal({ x: 0, y: 0 });
|
||
const bottomRight = view.toGlobal({ x: view.w, y: view.h });
|
||
return {
|
||
x: topLeft.x,
|
||
y: topLeft.y,
|
||
w: bottomRight.x - topLeft.x,
|
||
h: bottomRight.y - topLeft.y,
|
||
};
|
||
}
|
||
|
||
canvasRect(): { x: number; y: number; w: number; h: number } {
|
||
const topLeft = this.toGlobal({ x: 0, y: 0 });
|
||
const bottomRight = this.toGlobal({ x: this.w, y: this.h });
|
||
return {
|
||
x: topLeft.x,
|
||
y: topLeft.y,
|
||
w: bottomRight.x - topLeft.x,
|
||
h: bottomRight.y - topLeft.y,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The title a node is drawn with. Worth reaching for from outside because a nested-behavior node
|
||
* takes its title from the behavior it runs – "which node is this" is otherwise only a guess.
|
||
*/
|
||
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;
|
||
}
|
||
|
||
/** 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 };
|
||
}
|
||
}
|
||
|
||
/** Horizontal S-curve between two points. */
|
||
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);
|
||
}
|