Recurring cleanup round. Behaviour is unchanged. Duplicated code in
src went from 27 clones with 299 lines to 6 clones with 56 lines
(jscpd, the two identical OFL licence texts not counted).
- SheetCanvas is the new base of the texture, pixel art and vector art
canvases. It holds background, mask, hint, panning, wheel zoom, fit,
view save and restore and the screen mapping. Pixel art keeps its
whole-pixel panning and fixed zoom steps.
- mixColor in src/core/util/color.ts replaces three copies in
animation, gradient and particles. It clamps t like the particle one.
- fingerprint now lives once in generated.ts, next to the revision
contract from #185.
- MoveCommand next to RenameCommand is the base of the three reorder
commands for tile pictures, tilemap layers and vector shapes.
- The two raycast nodes share inputs, settings and filter via rayOf.
- canvasRect() lives once in AssetTabEditor.
- Removed unused code: findPixelArt, isDrawableShape,
GeneratedPictures.has and SnapToggle.snapping. Tests now check the
same things through equivalent paths.
- 38 values used only in their own file lost their export.
- Three comments that told history now describe the current state.
The bundle stayed md5-identical for those edits.
- README lists vector art, pixel art and palette, and links the two
wiki pages.
Findings:
- PaintTilesCommand.changes is meant to prevent empty undo steps but
has not been called since #128. The rectangle tool therefore records
a no-op undo step when it paints the same tile over itself. Left as
is, because fixing it changes behaviour.
- No test covered middle-click panning in any sheet tab before. 13f
and 48a now check it.
- The default values of the ray inputs were untested. Length 1000
could become 10 without a red test. tests/core/raycast.test.ts now
covers it.
- CI measured 1308 editor checks at d7c0b7c, not the 1307 counted for
#164. The sum is now 1310 (1308 + 2), computed.
Tests:
- ./check.sh: 144 files, 2143 tests, exit 0 (before 2142).
- e2e 1-16,37,39-43,47,48 fresh: ALL PASSED, 729 checks.
- e2e 1-13,47,48 with the new checks: ALL PASSED, 518 checks.
- Counter-checks, each restored via cp and cmp: halved panning fails
both new pan checks; unclamped mix fails 1 test, no blue channel 3;
coarser hash 3; broken reorder undo 2; triggers setting forced on 2;
bent ray length 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
507 lines
18 KiB
TypeScript
507 lines
18 KiB
TypeScript
import { CanvasTextMetrics, Graphics, Rectangle, 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 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 } from '../../ui';
|
||
import { keyLabel, referenceLabel } from '../inspector/fieldFactory';
|
||
import { collapsedNodeWidth } from './folding';
|
||
|
||
/** 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;
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
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 'any':
|
||
return c.portAny;
|
||
}
|
||
}
|
||
|
||
function rowStyle(port: PortDef): TextStyleToken {
|
||
if (port.ghost) return 'warning';
|
||
return port.type === 'exec' ? 'body' : 'muted';
|
||
}
|
||
|
||
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[] = [];
|
||
/** 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 }>();
|
||
|
||
/**
|
||
* 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,
|
||
) {
|
||
super();
|
||
this.def = def;
|
||
this.cursor = 'grab';
|
||
this.addChild(this.box, this.ports);
|
||
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;
|
||
const hidden = new Set(this.def.hiddenParams?.(node.params) ?? []);
|
||
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();
|
||
for (const port of inlineInputs(this.def)) {
|
||
const value = inlineValue(node, port);
|
||
if (!this.connected.has(port.key) && value !== null && value !== '')
|
||
this.inlineTexts.set(port.key, formatField(inlineInputDef(port), value, project));
|
||
}
|
||
this.linked = {
|
||
inputs: graph.edges.some((e) => e.to.node === node.id),
|
||
outputs: graph.edges.some((e) => e.from.node === node.id),
|
||
};
|
||
const height = this.collapsed ? TITLE_HEIGHT : this.headerHeight() + this.rowCount() * ROW_HEIGHT + PAD;
|
||
// Sized before the texts: output labels are placed against the right edge.
|
||
this.resize(this.collapsed ? this.collapsedWidth() : NODE_WIDTH, height);
|
||
this.rebuildTexts();
|
||
this.position.set(node.x, node.y);
|
||
}
|
||
|
||
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));
|
||
}
|
||
|
||
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 = new Text({ 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;
|
||
}
|
||
|
||
/** 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) };
|
||
}
|
||
|
||
private rowY(index: number): number {
|
||
return this.headerHeight() + 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.valueSlots = new Map();
|
||
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);
|
||
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);
|
||
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;
|
||
let inputEnd = PAD;
|
||
if (input) {
|
||
const text = add(
|
||
this.inputText(input),
|
||
rowStyle(input),
|
||
PAD + PORT_RADIUS + 4,
|
||
rowTop,
|
||
'left',
|
||
NODE_WIDTH / 2 + 20,
|
||
);
|
||
inputEnd = text.x + text.width;
|
||
}
|
||
if (output) {
|
||
const text = add(
|
||
output.label,
|
||
rowStyle(output),
|
||
this.w - PAD - PORT_RADIUS - 4,
|
||
rowTop,
|
||
'right',
|
||
NODE_WIDTH / 2 - 10,
|
||
);
|
||
if (output.type !== 'exec' && !output.ghost)
|
||
this.valueSlots.set(output.key, { left: inputEnd + 6, right: text.x - 6, top: rowTop + 1 });
|
||
}
|
||
}
|
||
this.placeValues();
|
||
}
|
||
|
||
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));
|
||
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 });
|
||
|
||
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 === 'enum') return def.options.find((o) => o.value === value)?.label ?? formatValue(value);
|
||
return referenceLabel(def, project, asString(value)) ?? formatValue(value);
|
||
}
|
||
|
||
function formatLive(output: PortDef, value: Value, project: Project | null): string {
|
||
if (value === null) return '—';
|
||
if (value === '') return '""';
|
||
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';
|
||
const head = value.slice(0, 4).map(formatValue).join(', ');
|
||
return value.length > 4 ? `${head}, … (${value.length})` : head;
|
||
}
|
||
return asString(value);
|
||
}
|