Remove comments that only restate the code, keeping the section banners and the notes that carry a reason, a measured fact or an external contract. ButtonOptions.width claimed to be a fixed width but only set minWidth, so the Graph and Texture pickers grew with the name they showed. It is now what it promises, with a separate minWidth for the buttons that have two forms. Narrow buttons shrink their padding, otherwise a 24px icon button had 8px left for its glyph and truncate dropped it entirely. __gm.labelsDropped() guards that class: the overflow guard only saw text that was too wide, never a label truncated to nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
263 lines
8.6 KiB
TypeScript
263 lines
8.6 KiB
TypeScript
import { Graphics, Rectangle, Text } from 'pixi.js';
|
||
import {
|
||
asString,
|
||
edgesInto,
|
||
type Graph,
|
||
type GraphNode,
|
||
inlineInputs,
|
||
inlineValue,
|
||
type NodeCategory,
|
||
type NodeDef,
|
||
type PortDef,
|
||
type PortType,
|
||
type Value,
|
||
} from '../../core/graph';
|
||
import type { BehaviorAsset } from '../../core/model/Asset';
|
||
import type { Project } from '../../core/model/Project';
|
||
import type { PropertyValue } from '../../core/model/Properties';
|
||
import { assetRefLabel } from '../../core/properties/assetRef';
|
||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||
import { formatNumber, Theme, textStyle, type TextStyleToken, truncate, Widget } from '../../ui';
|
||
import { keyLabel } from '../inspector/fieldFactory';
|
||
|
||
/** Node geometry in graph units; exported because edge anchors must land exactly on these ports. */
|
||
export const NODE_WIDTH = 176;
|
||
export const TITLE_HEIGHT = 24;
|
||
export const ROW_HEIGHT = 20;
|
||
export const PORT_RADIUS = 5;
|
||
/** Distance (graph units) within which a press counts as hitting a port. */
|
||
export const PORT_HIT_RADIUS = 9;
|
||
const PAD = 6;
|
||
|
||
export function categoryColor(category: NodeCategory): number {
|
||
const c = Theme.colors;
|
||
switch (category) {
|
||
case 'event':
|
||
return c.categoryEvent;
|
||
case 'flow':
|
||
return c.categoryFlow;
|
||
case 'data':
|
||
return c.categoryData;
|
||
case 'variable':
|
||
return c.categoryVariable;
|
||
case 'action':
|
||
return c.categoryAction;
|
||
}
|
||
}
|
||
|
||
export function portColor(type: PortType): number {
|
||
const c = Theme.colors;
|
||
switch (type) {
|
||
case 'exec':
|
||
return c.portExec;
|
||
case 'number':
|
||
return c.portNumber;
|
||
case 'boolean':
|
||
return c.portBoolean;
|
||
case 'string':
|
||
return c.portString;
|
||
case 'object':
|
||
return c.portObject;
|
||
case 'any':
|
||
return c.portAny;
|
||
}
|
||
}
|
||
|
||
export interface PortHit {
|
||
port: PortDef;
|
||
isOutput: boolean;
|
||
}
|
||
|
||
/** Draws one graph node entirely from its NodeDef; positioned in graph coordinates by GraphCanvas. */
|
||
export class NodeView extends Widget {
|
||
private readonly box = new Graphics();
|
||
private readonly ports = new Graphics();
|
||
private readonly texts: Text[] = [];
|
||
private paramLines: string[] = [];
|
||
selected = false;
|
||
hot = false;
|
||
/** Data inputs that currently have a connection (their inline value is hidden). */
|
||
private connected = new Set<string>();
|
||
|
||
constructor(
|
||
readonly node: GraphNode,
|
||
readonly def: NodeDef,
|
||
) {
|
||
super();
|
||
this.cursor = 'grab';
|
||
this.addChild(this.box, this.ports);
|
||
this.sync(node, { nodes: [node], edges: [] }, null, null);
|
||
}
|
||
|
||
/** Ports sit on the left/right edges; widen the hit area so they can be grabbed. */
|
||
override resize(w: number, h: number): void {
|
||
super.resize(w, h);
|
||
this.hitArea = new Rectangle(-PORT_HIT_RADIUS, 0, w + PORT_HIT_RADIUS * 2, h);
|
||
}
|
||
|
||
/** `project` resolves asset references (Spawn, Is asset). */
|
||
sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null, project: Project | null): void {
|
||
this.paramLines = this.def.params.map(
|
||
(p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`,
|
||
);
|
||
this.connected = new Set(
|
||
inlineInputs(this.def)
|
||
.filter((p) => edgesInto(graph, node.id, p.key).length > 0)
|
||
.map((p) => p.key),
|
||
);
|
||
const height =
|
||
TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + this.rowCount() * ROW_HEIGHT + PAD;
|
||
this.rebuildTexts();
|
||
this.resize(NODE_WIDTH, height);
|
||
this.position.set(node.x, node.y);
|
||
}
|
||
|
||
private rowCount(): number {
|
||
return Math.max(this.def.inputs.length, this.def.outputs.length);
|
||
}
|
||
|
||
/** Local position of a port's centre. */
|
||
portPosition(key: string, isOutput: boolean): { x: number; y: number } | null {
|
||
const list = isOutput ? this.def.outputs : this.def.inputs;
|
||
const index = list.findIndex((p) => p.key === key);
|
||
if (index < 0) return null;
|
||
return { x: isOutput ? this.w : 0, y: this.rowY(index) };
|
||
}
|
||
|
||
private rowY(index: number): number {
|
||
return TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + index * ROW_HEIGHT + ROW_HEIGHT / 2;
|
||
}
|
||
|
||
/** Every port with its local centre – inputs on the left edge, outputs on the right. */
|
||
private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> {
|
||
for (const [list, isOutput] of [
|
||
[this.def.inputs, false],
|
||
[this.def.outputs, true],
|
||
] as const) {
|
||
for (let i = 0; i < list.length; i++)
|
||
yield { port: list[i]!, isOutput, x: isOutput ? this.w : 0, y: this.rowY(i) };
|
||
}
|
||
}
|
||
|
||
portAt(localX: number, localY: number): PortHit | null {
|
||
for (const { port, isOutput, x, y } of this.portCentres()) {
|
||
if (Math.hypot(localX - x, localY - y) <= PORT_HIT_RADIUS) return { port, isOutput };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private rebuildTexts(): void {
|
||
for (const t of this.texts) t.destroy();
|
||
this.texts.length = 0;
|
||
const add = (
|
||
text: string,
|
||
style: TextStyleToken,
|
||
x: number,
|
||
y: number,
|
||
align: 'left' | 'right' = 'left',
|
||
maxWidth = NODE_WIDTH - PAD * 2,
|
||
) => {
|
||
const node = new Text({ text: truncate(text, textStyle(style), maxWidth), style: textStyle(style) });
|
||
node.position.set(align === 'left' ? x : x - node.width, y);
|
||
this.addChild(node);
|
||
this.texts.push(node);
|
||
};
|
||
add(this.def.label, 'accent', PAD + 2, (TITLE_HEIGHT - 16) / 2);
|
||
let y = TITLE_HEIGHT + PAD;
|
||
for (const line of this.paramLines) {
|
||
add(line, 'small', PAD + 2, y + 2);
|
||
y += ROW_HEIGHT;
|
||
}
|
||
for (let i = 0; i < this.rowCount(); i++) {
|
||
const input = this.def.inputs[i];
|
||
const output = this.def.outputs[i];
|
||
const rowTop = y + i * ROW_HEIGHT + 2;
|
||
if (input) {
|
||
let label = input.label;
|
||
if (input.type !== 'exec' && input.type !== 'object' && !this.connected.has(input.key)) {
|
||
const value = inlineValue(this.node, input);
|
||
if (value !== null && value !== '') label += ` ${formatValue(value)}`;
|
||
}
|
||
add(
|
||
label,
|
||
input.type === 'exec' ? 'body' : 'muted',
|
||
PAD + PORT_RADIUS + 4,
|
||
rowTop,
|
||
'left',
|
||
NODE_WIDTH / 2 + 20,
|
||
);
|
||
}
|
||
if (output)
|
||
add(
|
||
output.label,
|
||
output.type === 'exec' ? 'body' : 'muted',
|
||
this.w - PAD - PORT_RADIUS - 4,
|
||
rowTop,
|
||
'right',
|
||
NODE_WIDTH / 2 - 10,
|
||
);
|
||
}
|
||
}
|
||
|
||
override redraw(): void {
|
||
const c = Theme.colors;
|
||
this.box.clear();
|
||
this.box.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 });
|
||
this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill(c.node);
|
||
this.box.roundRect(0, 0, this.w, TITLE_HEIGHT, Theme.radius).fill(categoryColor(this.def.category));
|
||
this.box
|
||
.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius)
|
||
.fill(categoryColor(this.def.category));
|
||
const border = this.hot ? c.hot : this.selected ? c.nodeSelected : c.nodeBorder;
|
||
this.box
|
||
.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius)
|
||
.stroke({ width: this.selected || this.hot ? 2 : 1, color: border });
|
||
|
||
this.ports.clear();
|
||
const r = PORT_RADIUS;
|
||
for (const { port, x, y } of this.portCentres()) {
|
||
if (port.type === 'exec') {
|
||
// A pentagon pointing right: the Blueprint-style exec pin.
|
||
this.ports
|
||
.moveTo(x - r, y - r)
|
||
.lineTo(x + 1, y - r)
|
||
.lineTo(x + r + 1, y)
|
||
.lineTo(x + 1, y + r)
|
||
.lineTo(x - r, y + r)
|
||
.closePath()
|
||
.fill(portColor('exec'));
|
||
} else {
|
||
this.ports.circle(x, y, r).fill(portColor(port.type));
|
||
this.ports.circle(x, y, r).stroke({ width: 1, color: c.node });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** The value of a setting as shown on the node; references that no longer resolve get a "?". */
|
||
function formatParam(
|
||
param: PropertyDef,
|
||
value: PropertyValue | undefined,
|
||
asset: BehaviorAsset | null,
|
||
project: Project | null,
|
||
): string {
|
||
if (value === undefined || value === '') return '—';
|
||
if (param.kind === 'key') return keyLabel(String(value));
|
||
if (param.kind === 'asset') return assetRefLabel(project, String(value));
|
||
if (
|
||
param.kind === 'enum' &&
|
||
param.key === 'variable' &&
|
||
asset &&
|
||
!asset.variables.some((v) => v.key === value)
|
||
)
|
||
return `${value}?`;
|
||
return formatValue(value);
|
||
}
|
||
|
||
function formatValue(value: Value): string {
|
||
// Whole numbers stay bare to keep nodes narrow; fractions match the field that edits them.
|
||
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : formatNumber(value);
|
||
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||
return asString(value);
|
||
}
|