Consolidate duplicated editor code, trim comments, refresh README

Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus;
the port and marker menus were the same list and are now one function.
Value specs of the inspector and the list tab share valueSpecs.ts, and
the remaining copies (compound commands, type glyphs, typed asset
lookup, name rule, play/editor world setup, marquee) are merged.

Four copies had already drifted apart; merging them fixes:
- "Disconnect N links" in the port menu is one undo step, not N
- "+ Add collider" is hidden on a placed container instance, as in
  the hierarchy menu, instead of always failing
- a bool property coerced from the text "false" becomes false
- a port command on an unknown node type reports instead of crashing

Comment-only edits leave the bundle byte-identical. README corrected in
35 places (step limit, jump-run clock, suite duration, 409 body, ...).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 11:00:30 +02:00
1 parent 9212e61c4c
commit 8f92a06c84
96 files changed
+999 -1261

No files matched your search

+172
View File
@@ -0,0 +1,172 @@
import { Container, Graphics, Text } from 'pixi.js';
import { type GraphEdge, portsCompatible } from '../../core/graph';
import { Theme, textStyle, truncate } from '../../ui';
import {
buildFoldMarks,
foldedEdgeShown,
type FoldMarkEnd,
foldMarkAt,
foldMarkKey,
foldMarkLabel,
foldMarkRadius,
} from './folding';
import { type NodeView, portColor } from './NodeView';
/** 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. */
export 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;
}
/** The curves between nodes and the marks of the hidden ones, in graph coordinates. */
export class EdgeLayer extends Container {
private readonly curves = new Graphics();
private readonly chips = new Graphics();
/** Texts naming the far node of each folded edge; pooled, since a redraw rebuilds them all. */
private readonly labelLayer = new Container();
private readonly labels: Text[] = [];
private labelCount = 0;
private ends: FoldMarkEnd[] = [];
private marks: FoldMark[] = [];
/** Edges the last draw really stroked as a curve – a hidden one is missing from it. */
private readonly stroked = new Set<string>();
constructor() {
super();
this.addChild(this.curves, this.chips, this.labelLayer);
}
draw(
edges: readonly GraphEdge[],
views: ReadonlyMap<string, NodeView>,
isHot: (edgeId: string) => boolean,
selected: Set<string>,
): void {
this.curves.clear();
this.chips.clear();
this.marks = [];
this.ends = [];
this.labelCount = 0;
this.stroked.clear();
for (const edge of edges) this.drawEdge(edge, views, isHot(edge.id), selected);
// After the curves, so no line drawn later crosses a mark that stands for a hidden one.
this.drawFoldMarks();
for (let i = this.labelCount; i < this.labels.length; i++) this.labels[i]!.visible = false;
}
markAt(g: { x: number; y: number }): FoldMark | null {
return foldMarkAt(this.marks, g, FOLD_MARK_RADIUS);
}
drawnMarks(): readonly FoldMark[] {
return this.marks;
}
wasStroked(edgeId: string): boolean {
return this.stroked.has(edgeId);
}
private drawEdge(
edge: GraphEdge,
views: ReadonlyMap<string, NodeView>,
hot: boolean,
selected: Set<string>,
): void {
const from = views.get(edge.from.node);
const to = 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.addFoldEnd(edge, views, start, true, to.def.label, color, hot);
this.addFoldEnd(edge, views, end, false, from.def.label, color, hot);
if (!foldedEdgeShown(edge, selected, hot)) return;
}
bezier(this.curves, start.x, start.y, end.x, end.y);
this.curves.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.stroked.add(edge.id);
}
private addFoldEnd(
edge: GraphEdge,
views: ReadonlyMap<string, NodeView>,
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];
this.ends.push({
edgeId: edge.id,
key: foldMarkKey(side, isOutput, views.get(side.node)?.collapsed),
other,
otherLabel,
isOutput,
portX: port.x,
portY: port.y,
color,
hot,
});
}
/** 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 buildFoldMarks(this.ends, FOLD_STUB)) {
const { x, y, color, isOutput } = mark;
this.curves.moveTo(mark.portX, y).lineTo(x, y).stroke({ width: 2, color, alpha: 0.9 });
const radius = foldMarkRadius(mark.edgeIds.length, FOLD_MARK_RADIUS);
this.curves.circle(x, y, radius).fill(color);
this.curves.circle(x, y, radius).stroke({ width: 1, color: Theme.colors.node });
const label = this.nextLabel();
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.chips
.roundRect(lx - 3, y - label.height / 2 - 1, label.width + 6, label.height + 2, 3)
.fill({ color: Theme.colors.node, alpha: 0.85 });
this.marks.push({ edgeIds: mark.edgeIds, others: mark.others, isOutput, x, y, text: label.text });
}
}
private nextLabel(): Text {
let label = this.labels[this.labelCount];
if (!label) {
label = new Text({ text: '', style: textStyle('small') });
this.labels.push(label);
this.labelLayer.addChild(label);
}
label.visible = true;
this.labelCount++;
return label;
}
}
/** Horizontal S-curve between two points. */
export 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);
}