Files
feuerwerk/src/editor/graph/NodeView.ts
T
adminandClaude Opus 5.5 c087993000 Edit a Filter in its node and resize EQ and Filter nodes (#385)
A Filter sounds like one band of the Parametric EQ, but it keeps what
the EQ cannot: linked numbers, the all-pass and +-40 dB. So it stays a
node of its own and gets the EQ's editor with itself as the one band.
The editors also stop running out of their room: what does not fit is
left out instead of being cut.

- The curve of the EQ is now BandCurve with a host the EQ and the
  Filter both fill. CurvePanel holds what their panels share.
- FilterPanel shows the Filter as one band. A drag of its handle sets
  frequency and gain as one undo step. A number fed by a link is
  greyed out and the drag leaves it alone.
- A double-click on the Filter's title opens the same in a tab of its
  own. Delete there does nothing, the node stays.
- The Filter is tapped like the EQ, so its node and its tab draw the
  spectrum of what passes it. A tap is two gain nodes per voice, the
  analysers are made only while someone listens.
- EqFace and FilterFace are one CurveFace now, given the panel.
- EQ and Filter nodes have a corner to resize them, drawn like the
  floating window's. The size is saved on the node as `size`, each
  drag is one undo step, the least is 240 x 120.
- In the node the help line is gone. The row of numbers comes in only
  once the curve keeps 120 px of height. A group of the row that does
  not fit is not drawn, the one on the right first.
- A frequency or dB label that would run into the one before it is
  left out.
- The all-pass is drawn flat. The curve's maths takes every shape a
  Filter has.

Found on the way:
- The instrument presets set nodes 240 apart. A Filter with its
  editor is about 700 wide and covered the Bitcrusher, so a Filter
  now takes four columns.

Tests:
- Unit: 8 new (filter band, resize command and save, fitting rules),
  stage lists with a Filter now hold its taps
- e2e 34 (atx): ALL PASSED, 130 checks (4 new)
- Gate (atx): CHECK EXIT 0, 284 files, 4367 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:30:00 +02:00

754 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { CanvasTextMetrics, Container, Graphics, Rectangle, type Text } from 'pixi.js';
import {
asNumber,
asString,
edgesInto,
type Graph,
type GraphNode,
inlineInputDef,
inlineInputs,
inlineValue,
type NodeCategory,
type NodeDef,
nodeDef,
type PortDef,
type PortType,
type Value,
} from '../../core/graph';
import { layerIndexOf, layerMaskOf, layerName, maskLabel } from '../../core/model/collisionLayers';
import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap';
import type { Project } from '../../core/model/Project';
import { isList, type PropertyValue } from '../../core/model/Properties';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import {
formatNumber,
Theme,
textStyle,
type TextStyleToken,
toHex,
truncate,
Widget,
crispText,
} from '../../ui';
import { keyLabel, referenceLabel } from '../inspector/fieldFactory';
import { RESIZE_HANDLE } from './AnnotationView';
import { collapsedNodeWidth } from './folding';
import type { NodeFace } from './nodeFace';
import { inputTextRoom, valueSpan } from './portRow';
/** 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;
const ROW_HEIGHT = 20;
const PORT_RADIUS = 5;
/** Distance (graph units) within which a press counts as hitting a port. */
const PORT_HIT_RADIUS = 9;
const PAD = 6;
/** Room in front of the title for the collapse chevron, as wide as the hierarchy's. */
const CHEVRON_SLOT = 14;
const TITLE_X = PAD + CHEVRON_SLOT;
/** Right of a collapsed title: the side's link dot sits centred on the edge. */
const COLLAPSED_TITLE_END = PAD + PORT_RADIUS;
/** From a node's edge to the name of a port. */
const PORT_TEXT_X = PAD + PORT_RADIUS + 4;
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;
case 'state':
return c.categoryState;
case 'shader':
return c.categoryShader;
case 'particles':
return c.categoryParticles;
case 'sound':
return c.categorySound;
}
}
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 'list':
return c.portList;
case 'struct':
return c.portStruct;
case 'vec3':
return c.portVec3;
case 'picture':
return c.portPicture;
case 'audio':
return c.portAudio;
case 'fxparams':
return c.portFxParams;
case 'trigger':
return c.portTrigger;
case 'envelope':
return c.portEnvelope;
case 'any':
return c.portAny;
}
}
function rowStyle(port: PortDef): TextStyleToken {
if (port.ghost) return 'warning';
return port.type === 'exec' ? 'body' : 'muted';
}
export interface TextSpan {
text: string;
left: number;
right: number;
}
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[] = [];
/** The input and output text of each port row, among `texts`. */
private readonly rowTexts: { input: Text | null; output: Text | null }[] = [];
private paramLines: string[] = [];
/** Why this node can do nothing; see `callProblem`. */
private problem: string | null = null;
selected = false;
hot = false;
/** The state a machine begins in wears a ring, so the entry point is visible at a glance. */
isStart = false;
/**
* While something is running: `held` for a state some instance of the machine is in, `current`
* for the one the selected object's instance holds. Nothing outside a run.
*/
stateMark: 'none' | 'held' | 'current' = 'none';
/** Port a running edge drag would connect to, drawn with a ring so the drop is predictable. */
targetPort: { port: string; isOutput: boolean } | null = null;
/** Data inputs that currently have a connection (their inline value is hidden). */
private connected = new Set<string>();
private inlineTexts = new Map<string, string>();
/** Which sides carry a link – a collapsed node marks those with one dot each. */
private linked = { inputs: false, outputs: false };
private liveTexts = new Map<string, string>();
private readonly valueTexts = new Map<string, Text>();
/** Where an output's value may be drawn: right-aligned before its label, clear of the input text. */
private valueSlots = new Map<string, { left: number; right: number; top: number }>();
/**
* What the texts and the box were last made from. A graph is redrawn on every model event and on
* every frame of a run, and for nearly every node nothing has changed: a text made anew is a
* picture uploaded anew, and a box cleared is a shape built anew (#292).
*/
private textsOf = '';
private drawnOf = '';
/** How wide the sides of a face are, with the pins and their names (#380). */
private sides = { left: 0, right: 0 };
/** The corner a face that may be resized is dragged by (#385); it only shows the cursor. */
private readonly corner = new Container();
/** What the node was last drawn from, so a drag of its corner can draw it again (#385). */
private last: { graph: Graph; project: Project | null; problem: string | null } | null = null;
/** How often the texts and the box were really made, for the checks. */
readonly made = { texts: 0, box: 0 };
/**
* Not fixed at construction: a nested-behavior node's ports come from another graph, so the
* definition that applies to this node can change without the node itself changing.
*/
def: NodeDef;
constructor(
readonly node: GraphNode,
def: NodeDef,
/** What the node shows instead of its rows (#380), if anything. */
readonly face: NodeFace | null = null,
) {
super();
this.def = def;
this.cursor = 'grab';
// Pixi draws a shape of more than 200 vertices on its own, and the round pins are: a few hundred
// nodes were a few hundred draw calls. In the batch they share one.
this.box.context.batchMode = 'batch';
this.ports.context.batchMode = 'batch';
this.addChild(this.box, this.ports);
if (face) this.addChild(face.widget);
this.corner.eventMode = 'static';
this.corner.cursor = 'nwse-resize';
this.corner.hitArea = new Rectangle(0, 0, RESIZE_HANDLE, RESIZE_HANDLE);
this.addChild(this.corner);
this.sync(node, { nodes: [node], edges: [] }, null, def);
}
/** 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);
}
/**
* Whether a local point is on this node, by the same area its pointer events use. An input port
* sits at exactly x = 0, and rounding a global point back can land a hair outside it – which made
* a drop on such a port count as a drop on empty canvas and opened the palette.
*/
containsLocal(x: number, y: number): boolean {
return this.hitArea?.contains(x, y) ?? false;
}
/** `project` resolves asset references (Spawn, Is asset). */
sync(
node: GraphNode,
graph: Graph,
project: Project | null,
def: NodeDef,
problem: string | null = null,
): void {
this.def = def;
this.problem = problem;
this.last = { graph, project, problem };
this.alpha = def.dimmed?.(node.params) ? DIMMED_ALPHA : 1;
const hidden = new Set([
...(this.def.hiddenParams?.(node.params) ?? []),
...(this.def.idleParams?.(node.params, (key) => edgesInto(graph, node.id, key).length > 0) ?? []),
]);
const title = this.def.label;
const derived = title !== nodeDef(node.type)?.label;
this.paramLines = this.def.params
.filter((p) => !hidden.has(p.key))
.map((p) => ({
label: p.label,
value: formatParam(p, node.params[p.key] ?? p.default, project),
}))
// A title made from a setting states it ("Behavior: Double" under "Double"). A dangling
// reference keeps its row: "Double?" is not in the title.
.filter((p) => !derived || (p.value !== title && !title.endsWith(` ${p.value}`)))
.map((p) => `${p.label}: ${p.value}`);
this.connected = new Set(
inlineInputs(this.def)
.filter((p) => edgesInto(graph, node.id, p.key).length > 0)
.map((p) => p.key),
);
this.inlineTexts = new Map();
// Every free input shows what it carries, its default too: an untouched `And` reads "false",
// not nothing, empty text reads "(empty)" – the same value the node computes with. A free
// object or struct has nothing to type in and carries null, which it says as well.
for (const port of inlineInputs(this.def)) {
if (this.connected.has(port.key)) continue;
const text = formatField(inlineInputDef(port), inlineValue(node, port), project);
this.inlineTexts.set(port.key, text === '' ? EMPTY_TEXT : text);
}
for (const port of this.def.inputs) {
if (
(port.type === 'object' || port.type === 'struct') &&
edgesInto(graph, node.id, port.key).length === 0
)
this.inlineTexts.set(port.key, NULL_TEXT);
}
// A face shows the settings and the values itself: its sides name the ports only.
if (this.shownFace()) {
this.paramLines = [];
this.inlineTexts = new Map();
}
this.linked = {
inputs: graph.edges.some((e) => e.to.node === node.id),
outputs: graph.edges.some((e) => e.from.node === node.id),
};
const face = this.shownFace();
if (face) this.sides = this.faceSides();
const height = this.collapsed
? TITLE_HEIGHT
: face
? this.headerHeight() + this.faceSize().h + PAD
: this.rowsEnd() + PAD;
// Sized before the texts: output labels are placed against the right edge.
this.resize(
this.collapsed
? this.collapsedWidth()
: face
? this.sides.left + this.faceSize().w + this.sides.right
: NODE_WIDTH,
height,
);
if (this.face) {
this.face.widget.visible = face !== null;
if (face) this.placeFace(face);
else this.face.hide();
}
this.corner.visible = this.resizable;
this.corner.position.set(this.w - RESIZE_HANDLE, this.h - RESIZE_HANDLE);
const texts = this.textSignature();
if (texts !== this.textsOf) {
this.textsOf = texts;
this.made.texts += 1;
this.rebuildTexts();
}
this.position.set(node.x, node.y);
}
/** Everything `rebuildTexts` reads. */
private textSignature(): string {
const rows: unknown[] = [];
for (let i = 0; i < this.rowCount(); i++) {
const input = this.def.inputs[i];
const output = this.def.outputs[i];
rows.push(
input ? [this.inputText(input), rowStyle(input)] : null,
output ? [output.label, rowStyle(output), output.type !== 'exec' && !output.ghost, output.key] : null,
);
}
return JSON.stringify([this.w, this.collapsed, this.def.label, this.paramLines, this.problem, rows]);
}
/** Everything `redraw` reads. */
private drawSignature(): string {
const port = (p: PortDef) => `${p.key}:${p.paintType ?? p.type}:${p.ghost ? 1 : 0}`;
return JSON.stringify([
this.w,
this.h,
this.collapsed,
this.def.category,
this.def.transitionEdges === true,
this.isStart,
this.hot,
this.selected,
this.problem !== null,
this.stateMark,
this.linked,
this.targetPort,
this.headerHeight(),
this.resizable,
this.def.inputs.map(port),
this.def.outputs.map(port),
]);
}
private collapsedWidth(): number {
const title = CanvasTextMetrics.measureText(this.def.label, textStyle('accent')).width;
return collapsedNodeWidth(title, TITLE_X + COLLAPSED_TITLE_END, NODE_WIDTH);
}
get collapsed(): boolean {
return this.node.collapsed === true;
}
chevronAt(localX: number, localY: number): boolean {
return localY >= 0 && localY < TITLE_HEIGHT && localX >= PAD - 2 && localX < TITLE_X;
}
chevronGlobal(): { x: number; y: number } {
return this.toGlobal({ x: PAD + CHEVRON_SLOT / 2, y: TITLE_HEIGHT / 2 });
}
/** Rows of ports, which set the node's height – none on a state, whose ports are never drawn. */
private rowCount(): number {
if (this.def.transitionEdges === true) return 0;
return Math.max(this.def.inputs.length, this.def.outputs.length);
}
/** The warning line as drawn – checks read what the node really shows, not a recomputation. */
get problemText(): string | null {
return this.problem;
}
/** The setting rows as drawn, for the same reason. */
get settingLines(): string[] {
return [...this.paramLines];
}
get inputLines(): string[] {
return this.def.inputs.map((input) => this.inputText(input));
}
/** Where each port row's input and output text really landed, in node units, as cut to fit. */
get rowSpans(): { input: TextSpan | null; output: TextSpan | null }[] {
const span = (t: Text | null): TextSpan | null => t && { text: t.text, left: t.x, right: t.x + t.width };
return this.rowTexts.map((row) => ({ input: span(row.input), output: span(row.output) }));
}
get outputLines(): string[] {
return this.def.outputs.map((output) => {
const value = this.valueTexts.get(output.key)?.text;
return value === undefined ? output.label : `${value} ${output.label}`;
});
}
/** Nothing clears them when play ends: the views are rebuilt then. */
setLiveValues(values: Record<string, Value> | null, project: Project | null): void {
const next = new Map<string, string>();
for (const output of this.def.outputs) {
const value = values?.[output.key];
if (output.type === 'exec' || value === undefined) continue;
next.set(output.key, formatLive(output, value, project));
}
if (next.size === this.liveTexts.size && [...next].every(([k, v]) => this.liveTexts.get(k) === v)) return;
this.liveTexts = next;
this.placeValues();
}
private placeValues(): void {
for (const text of this.valueTexts.values()) text.destroy();
this.valueTexts.clear();
if (this.collapsed) return;
const style = textStyle('live');
for (const [key, value] of this.liveTexts) {
const slot = this.valueSlots.get(key);
if (!slot || slot.right - slot.left < 12) continue;
const text = crispText({ text: truncate(value, style, slot.right - slot.left), style });
text.position.set(slot.right - text.width, slot.top);
this.addChild(text);
this.valueTexts.set(key, text);
}
}
private inputText(input: PortDef): string {
const inline = this.inlineTexts.get(input.key);
return inline === undefined ? input.label : `${input.label} ${inline}`;
}
/** Everything above the first port row. The problem line sits in here, so ports move down with it. */
private headerHeight(): number {
const lines = this.paramLines.length + (this.problem ? 1 : 0);
return TITLE_HEIGHT + PAD + lines * ROW_HEIGHT;
}
/** Below the last port row. */
private rowsEnd(): number {
return this.headerHeight() + this.rowCount() * ROW_HEIGHT;
}
/** The node's face, while the node is open. */
private shownFace(): NodeFace | null {
return (!this.collapsed && this.face) || null;
}
/** The size the node was dragged to (#385), never below the least its face takes; else the face's own. */
private faceSize(): { w: number; h: number } {
const min = this.face?.minSize;
const size = this.node.size;
if (min && size) return { w: Math.max(min.w, size.w), h: Math.max(min.h, size.h) };
return this.face?.widget.measure() ?? { w: 0, h: 0 };
}
/** Whether the node has a corner to resize its face by: an open node whose face may be (#385). */
get resizable(): boolean {
return this.shownFace()?.minSize !== undefined;
}
/** Whether a local point is on that corner. */
cornerAt(x: number, y: number): boolean {
return (
this.resizable &&
x >= this.w - RESIZE_HANDLE &&
x <= this.w &&
y >= this.h - RESIZE_HANDLE &&
y <= this.h
);
}
cornerGlobal(): { x: number; y: number } | null {
if (!this.resizable) return null;
const at = this.toGlobal({ x: this.w - RESIZE_HANDLE / 2, y: this.h - RESIZE_HANDLE / 2 });
return { x: at.x, y: at.y };
}
/** How much of the node is not its face: the least the node itself can be dragged to follows from it. */
get faceChrome(): { w: number; h: number } {
const size = this.faceSize();
return { w: this.w - size.w, h: this.h - size.h };
}
/** A drag of the corner changed the node's size in the model: the node is drawn at it. */
refit(): void {
const last = this.last;
const face = this.shownFace();
if (!last || !face) return;
const size = this.faceSize();
if (size.w === face.widget.w && size.h === face.widget.h) return;
this.sync(this.node, last.graph, last.project, this.def, last.problem);
}
/** Each side of a face as wide as the longest name on it. */
private faceSides(): { left: number; right: number } {
const side = (ports: PortDef[]) =>
Math.ceil(
PORT_TEXT_X +
Math.max(
0,
...ports.map((p) => CanvasTextMetrics.measureText(p.label, textStyle(rowStyle(p))).width),
) +
PAD,
);
return { left: side(this.def.inputs), right: side(this.def.outputs) };
}
private placeFace(face: NodeFace): void {
const size = this.faceSize();
face.widget.position.set(this.sides.left, this.headerHeight());
face.widget.resize(size.w, size.h);
face.sync();
}
/** 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.collapsed ? TITLE_HEIGHT / 2 : this.rowY(index) };
}
/** A face has its ports in the middle of its sides. */
private rowY(index: number): number {
const top = this.shownFace() ? (this.faceSize().h - this.rowCount() * ROW_HEIGHT) / 2 : 0;
return this.headerHeight() + top + index * ROW_HEIGHT + ROW_HEIGHT / 2;
}
/**
* Every port with its local centre – none while collapsed, and none on a state, so neither can
* be drawn or grabbed. `portPosition` stays open either way: the edges still need their anchor.
*/
private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> {
if (this.collapsed || this.def.transitionEdges === true) return;
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;
this.rowTexts.length = 0;
this.valueSlots = new Map();
const add = (
text: string,
style: TextStyleToken,
x: number,
y: number,
align: 'left' | 'right' = 'left',
maxWidth = NODE_WIDTH - PAD * 2,
) => {
const node = crispText({ 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);
return node;
};
add(
this.def.label,
'accent',
TITLE_X,
(TITLE_HEIGHT - 16) / 2,
'left',
this.w - TITLE_X - (this.collapsed ? COLLAPSED_TITLE_END : PAD),
);
if (this.collapsed) {
this.placeValues();
return;
}
let y = TITLE_HEIGHT + PAD;
for (const line of this.paramLines) {
add(line, 'small', PAD + 2, y + 2);
y += ROW_HEIGHT;
}
if (this.problem) add(this.problem, 'warning', PAD + 2, y + 2);
const inputX = PORT_TEXT_X;
const face = this.shownFace() !== null;
for (let i = 0; i < this.rowCount(); i++) {
const input = this.def.inputs[i];
const output = this.def.outputs[i];
const rowTop = this.rowY(i) - ROW_HEIGHT / 2 + 2;
const outputText = output
? add(
output.label,
rowStyle(output),
this.w - PAD - PORT_RADIUS - 4,
rowTop,
'right',
face ? this.sides.right : NODE_WIDTH / 2 - 10,
)
: null;
const room = face
? this.sides.left
: inputTextRoom(inputX, NODE_WIDTH / 2 + 20, outputText ? outputText.x : null);
const inputText = input
? add(this.inputText(input), rowStyle(input), inputX, rowTop, 'left', room)
: null;
this.rowTexts.push({ input: inputText, output: outputText });
if (!face && output && outputText && output.type !== 'exec' && !output.ghost) {
const inputEnd = inputText ? inputText.x + inputText.width : PAD;
this.valueSlots.set(output.key, { ...valueSpan(inputEnd, outputText.x), top: rowTop + 1 });
}
}
this.placeValues();
}
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
this.face?.hide();
super.destroy(options);
}
override redraw(): void {
const drawn = this.drawSignature();
if (drawn === this.drawnOf) return;
this.drawnOf = drawn;
this.made.box += 1;
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));
if (!this.collapsed)
this.box
.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius)
.fill(categoryColor(this.def.category));
this.drawChevron(PAD + 4, TITLE_HEIGHT / 2);
// The ring sits where a state's ports never are: a state draws none (see `portCentres`).
if (this.isStart)
this.box
.circle(this.w - PAD - PORT_RADIUS, TITLE_HEIGHT / 2, PORT_RADIUS)
.stroke({ width: 2, color: c.text });
const border = this.hot ? c.hot : this.selected ? c.nodeSelected : this.problem ? c.danger : c.nodeBorder;
this.box
.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius)
.stroke({ width: this.selected || this.hot || this.problem ? 2 : 1, color: border });
// Inside the border, so a selected state still shows both: what is picked, and what is live.
if (this.stateMark !== 'none')
this.box
.roundRect(2.5, 2.5, this.w - 5, this.h - 5, Theme.radius)
.stroke({ width: 2, color: c.hot, alpha: this.stateMark === 'current' ? 1 : 0.35 });
if (this.resizable) {
// The floating window's corner (#371), so it reads as the same handle.
for (const at of [4, 8, 12])
this.box.moveTo(this.w - 2 - at, this.h - 2).lineTo(this.w - 2, this.h - 2 - at);
this.box.stroke({ width: 1, color: c.textMuted });
}
this.ports.clear();
const r = PORT_RADIUS;
if (this.collapsed) {
for (const [linked, x] of [
[this.linked.inputs, 0],
[this.linked.outputs, this.w],
] as const) {
if (!linked) continue;
this.ports.circle(x, TITLE_HEIGHT / 2, r - 1).fill(c.textMuted);
this.ports.circle(x, TITLE_HEIGHT / 2, r - 1).stroke({ width: 1, color: c.node });
}
}
for (const { port, isOutput, x, y } of this.portCentres()) {
if (this.targetPort?.port === port.key && this.targetPort.isOutput === isOutput) {
// A gap in the node colour first, so the ring stays visible on the white exec pin too.
this.ports.circle(x, y, r + 2).stroke({ width: 2, color: c.node });
this.ports.circle(x, y, r + 4).stroke({ width: 2, color: c.portTarget });
}
if (port.ghost) {
// Hollow: a pin is drawn where a port used to be, and nothing stands behind it any more.
this.ports.circle(x, y, r).stroke({ width: 2, color: c.danger });
} else 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 {
// `paintType`: an `any` input takes the colour of the edge feeding it, its type stays `any`.
this.ports.circle(x, y, r).fill(portColor(port.paintType ?? port.type));
this.ports.circle(x, y, r).stroke({ width: 1, color: c.node });
}
}
}
/** The hierarchy's shape, so both folds read the same. */
private drawChevron(x: number, y: number): void {
const color = Theme.colors.text;
if (this.collapsed)
this.box
.moveTo(x - 1, y - 4)
.lineTo(x + 4, y)
.lineTo(x - 1, y + 4)
.fill(color);
else
this.box
.moveTo(x - 3, y - 2)
.lineTo(x + 5, y - 2)
.lineTo(x + 1, y + 3)
.fill(color);
}
}
function formatParam(param: PropertyDef, value: PropertyValue | undefined, project: Project | null): string {
if (value === undefined || value === '') return '—';
return formatField(param, value, project);
}
/** A value in the words of the field that edits it: a key's name, a color's hex, a texture's name. */
function formatField(def: PropertyDef, value: Value, project: Project | null): string {
if (def.kind === 'key') return keyLabel(asString(value));
if (def.kind === 'color') return toHex(asNumber(value));
// Layers read as names, not as the bits they are stored in (#152, #158).
const layers = project?.collisionLayers ?? [];
if (def.kind === 'layer') return layerName(layers, layerIndexOf(value));
if (def.kind === 'layers') return maskLabel(layerMaskOf(value), layers);
if (def.kind === 'tileLayer') return asString(value) || FIRST_TILE_LAYER;
if (def.kind === 'enum') return def.options.find((o) => o.value === value)?.label ?? formatValue(value);
return referenceLabel(def, project, asString(value)) ?? formatValue(value);
}
/** How a node says "no value" and "empty value" – for inputs and live outputs alike. */
/** How a node that does nothing at its settings is drawn, a bypassed effect (#354). */
const DIMMED_ALPHA = 0.45;
const NULL_TEXT = '(null)';
const EMPTY_TEXT = '(empty)';
function formatLive(output: PortDef, value: Value, project: Project | null): string {
if (value === null) return NULL_TEXT;
if (value === '') return EMPTY_TEXT;
return output.field ? formatField(output.field, value, project) : formatValue(value);
}
function formatValue(value: Value): string {
if (typeof value === 'number') return formatNumber(value);
if (typeof value === 'boolean') return value ? 'true' : 'false';
// A list can hold hundreds of items – shown whole it would stretch the node across the graph.
if (isList(value)) {
if (value.length === 0) return EMPTY_TEXT;
const head = value.slice(0, 4).map(formatValue).join(', ');
return value.length > 4 ? `${head}, … (${value.length})` : head;
}
return asString(value);
}