docs: draft ECS component interfaces and World type

Example interfaces for all 7 entity kinds (Node, Link, Slot, Widget,
Reroute, Group, Subgraph), branded entity ID types with cast helpers,
and a Map-backed World implementation. Reuses existing litegraph types
(Point, Size, INodeFlags, ISlotType, etc.) for migration compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alexander Brown
2026-03-23 20:25:33 -07:00
committed by DrJKL
parent ba9f3481fb
commit 97c61eeff3
10 changed files with 720 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
/**
* Group components.
*
* Groups are visual containers that hold nodes and reroutes.
* Currently no state has been extracted from LGraphGroup — these
* components represent the full extraction target.
*/
import type { NodeEntityId, RerouteEntityId } from '../entityId'
/** Metadata for a group. */
export interface GroupMeta {
title: string
font?: string
fontSize: number
}
/** Visual properties for group rendering. */
export interface GroupVisual {
color?: string
}
/**
* Entities contained within a group.
*
* Replaces LGraphGroup._children (Set<Positionable>) and
* LGraphGroup._nodes (LGraphNode[]).
*/
export interface GroupChildren {
nodeIds: ReadonlySet<NodeEntityId>
rerouteIds: ReadonlySet<RerouteEntityId>
}

View File

@@ -0,0 +1,51 @@
/**
* Link components.
*
* Decomposes LLink into endpoint topology, visual state, and
* transient interaction state.
*/
import type {
CanvasColour,
ISlotType,
Point
} from '@/lib/litegraph/src/interfaces'
import type { NodeEntityId, RerouteEntityId } from '../entityId'
/**
* The topological endpoints of a link.
*
* Replaces origin_id/origin_slot/target_id/target_slot/type on LLink.
* Slot indices will migrate to SlotEntityId references once slots
* have independent IDs.
*/
export interface LinkEndpoints {
originNodeId: NodeEntityId
originSlotIndex: number
targetNodeId: NodeEntityId
targetSlotIndex: number
/** Data type flowing through this link (e.g., 'IMAGE', 'MODEL'). */
type: ISlotType
/** Reroute that owns this link segment, if any. */
parentRerouteId?: RerouteEntityId
}
/** Visual properties for link rendering. */
export interface LinkVisual {
color?: CanvasColour
/** Cached rendered path (invalidated on position change). */
path?: Path2D
/** Cached center point of the link curve. */
centerPos?: Point
/** Cached angle at the center point. */
centerAngle?: number
}
/** Transient interaction state for a link. */
export interface LinkState {
/** True while the user is dragging this link. */
dragging: boolean
/** Arbitrary data payload flowing through the link. */
data?: unknown
}

View File

@@ -0,0 +1,87 @@
/**
* Node-specific components.
*
* These decompose the ~4,300-line LGraphNode class into focused data
* objects. Each component captures one concern; systems provide behavior.
*
* Reuses existing types from litegraph where possible to ease migration.
*/
import type { Dictionary, INodeFlags } from '@/lib/litegraph/src/interfaces'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type {
LGraphEventMode,
RenderShape
} from '@/lib/litegraph/src/types/globalEnums'
import type { SlotEntityId, WidgetEntityId } from '../entityId'
/** Static identity and classification of a node. */
export interface NodeType {
/** Registered node type string (e.g., 'KSampler', 'CLIPTextEncode'). */
type: string
/** Display title. */
title: string
/** Category path for the node menu (e.g., 'sampling'). */
category?: string
/** Backend node definition data, if resolved. */
nodeData?: unknown
/** Optional description shown in tooltips/docs. */
description?: string
}
/** Visual / rendering properties of a node. */
export interface NodeVisual {
color?: string
bgcolor?: string
boxcolor?: string
shape?: RenderShape
}
/**
* Connectivity — references to this node's slot entities.
*
* Replaces the `inputs[]` and `outputs[]` arrays on LGraphNode.
* Actual slot data lives on SlotIdentity / SlotConnection components
* keyed by SlotEntityId.
*/
export interface Connectivity {
inputSlotIds: readonly SlotEntityId[]
outputSlotIds: readonly SlotEntityId[]
}
/** Execution scheduling state. */
export interface Execution {
/** Computed execution order (topological sort index). */
order: number
/** How this node participates in execution. */
mode: LGraphEventMode
/** Behavioral flags (pinned, collapsed, ghost, etc.). */
flags: INodeFlags
}
/** User-defined key-value properties on a node. */
export interface Properties {
properties: Dictionary<NodeProperty | undefined>
propertiesInfo: readonly PropertyInfo[]
}
export interface PropertyInfo {
name?: string
type?: string
default_value?: NodeProperty
widget?: string
label?: string
values?: unknown[]
}
/**
* Container for widget entities owned by this node.
*
* Replaces the `widgets[]` array on LGraphNode.
* Actual widget data lives on WidgetIdentity / WidgetValue components
* keyed by WidgetEntityId.
*/
export interface WidgetContainer {
widgetIds: readonly WidgetEntityId[]
}

View File

@@ -0,0 +1,24 @@
/**
* Position component — shared by Node, Reroute, and Group entities.
*
* Plain data object. No methods, no back-references.
* Corresponds to the spatial data currently on LGraphNode.pos/size,
* Reroute.pos, and LGraphGroup._bounding.
*
* During the bridge phase, this mirrors data from the LayoutStore
* (Y.js CRDTs). See migration plan Phase 2a.
*/
import type { Point, Size } from '@/lib/litegraph/src/interfaces'
export interface Position {
/** Position in graph coordinates (top-left for nodes/groups, center for reroutes). */
pos: Point
/** Width and height. Undefined for point-like entities (reroutes). */
size?: Size
/**
* Bounding rectangle as [x, y, width, height].
* May extend beyond pos/size (e.g., nodes with title overhang).
*/
bounding: readonly [x: number, y: number, w: number, h: number]
}

View File

@@ -0,0 +1,35 @@
/**
* Reroute components.
*
* Reroutes are waypoints on link paths. Position is shared via the
* Position component. These components capture the link topology
* and visual state specific to reroutes.
*/
import type { CanvasColour } from '@/lib/litegraph/src/interfaces'
import type { LinkEntityId, RerouteEntityId } from '../entityId'
/**
* Link topology for a reroute.
*
* A reroute can be chained (parentId) and carries a set of links
* that pass through it.
*/
export interface RerouteLinks {
/** Parent reroute in the chain, if any. */
parentId?: RerouteEntityId
/** Links that pass through this reroute. */
linkIds: ReadonlySet<LinkEntityId>
/** Floating (in-progress) links passing through this reroute. */
floatingLinkIds: ReadonlySet<LinkEntityId>
}
/** Visual state specific to reroute rendering. */
export interface RerouteVisual {
color?: CanvasColour
/** Cached path for the link segment. */
path?: Path2D
/** Angle at the reroute center (for directional rendering). */
centerAngle?: number
}

View File

@@ -0,0 +1,76 @@
/**
* Slot components.
*
* Slots currently lack independent IDs — they're identified by their
* index on a parent node's inputs/outputs array. The ECS assigns each
* slot a synthetic SlotEntityId, making them first-class entities.
*
* Decomposes SlotBase / INodeInputSlot / INodeOutputSlot into identity,
* connection topology, and visual state.
*/
import type {
CanvasColour,
ISlotType,
Point
} from '@/lib/litegraph/src/interfaces'
import type {
LinkDirection,
RenderShape
} from '@/lib/litegraph/src/types/globalEnums'
import type { LinkEntityId, NodeEntityId } from '../entityId'
/** Immutable identity of a slot. */
export interface SlotIdentity {
/** Display name (e.g., 'model', 'positive'). */
name: string
/** Localized display name, if available. */
localizedName?: string
/** Optional label override. */
label?: string
/** Data type accepted/produced by this slot. */
type: ISlotType
/** Whether this is an input or output slot. */
direction: 'input' | 'output'
/** The node that owns this slot. */
parentNodeId: NodeEntityId
/** Position index on the parent node (0-based). */
index: number
}
/**
* Connection state of a slot.
*
* Input slots have at most one link. Output slots can have many.
*/
export interface SlotConnection {
/**
* For input slots: the single connected link, or null.
* For output slots: all connected links.
*/
linkIds: readonly LinkEntityId[]
/** Widget locator, if this slot backs a promoted widget. */
widgetLocator?: SlotWidgetLocator
}
export interface SlotWidgetLocator {
name: string
nodeId: NodeEntityId
}
/** Visual / rendering properties of a slot. */
export interface SlotVisual {
/** Computed position relative to the node. */
pos?: Point
/** Bounding rectangle for hit testing. */
boundingRect: readonly [x: number, y: number, w: number, h: number]
/** Color when connected. */
colorOn?: CanvasColour
/** Color when disconnected. */
colorOff?: CanvasColour
/** Render shape (circle, arrow, grid, etc.). */
shape?: RenderShape
/** Flow direction for link rendering. */
dir?: LinkDirection
}

View File

@@ -0,0 +1,34 @@
/**
* Subgraph components.
*
* A subgraph is a graph that lives inside a SubgraphNode. It inherits
* all node components (via its SubgraphNode entity) and adds structural
* and metadata components for its interior.
*
* This is the most complex entity kind — it depends on Node and Link
* extraction being complete first. See migration plan Phase 2.
*/
import type { LinkEntityId, NodeEntityId, RerouteEntityId } from '../entityId'
/**
* The interior structure of a subgraph.
*
* Replaces the recursive LGraph container that Subgraph inherits.
* Entity IDs reference entities that live in the World — not in a
* nested graph instance.
*/
export interface SubgraphStructure {
/** Nodes contained within the subgraph. */
nodeIds: readonly NodeEntityId[]
/** Internal links (both endpoints inside the subgraph). */
linkIds: readonly LinkEntityId[]
/** Internal reroutes. */
rerouteIds: readonly RerouteEntityId[]
}
/** Descriptive metadata for a subgraph definition. */
export interface SubgraphMeta {
name: string
description?: string
}

View File

@@ -0,0 +1,75 @@
/**
* Widget components.
*
* Widget value extraction is already complete (WidgetValueStore).
* These interfaces formalize the target shape and add the layout
* component that remains on the BaseWidget class.
*
* The 23+ widget subclasses (NumberWidget, ComboWidget, etc.) become
* configuration data here. Widget-type-specific rendering behavior
* will live in the RenderSystem.
*/
import type {
IWidgetOptions,
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import type { NodeEntityId } from '../entityId'
/** Immutable identity of a widget within its parent node. */
export interface WidgetIdentity {
/** Widget name (unique within a node). */
name: string
/**
* Widget type string (e.g., 'number', 'combo', 'toggle', 'text').
* Determines which system handles rendering and interaction.
*/
widgetType: string
/** The node that owns this widget. */
parentNodeId: NodeEntityId
}
/**
* Widget value and configuration.
*
* Structurally equivalent to the existing WidgetState in
* WidgetValueStore — the bridge layer can share the same objects.
*/
export interface WidgetValue {
/** Current value (type depends on widgetType). */
value: TWidgetValue
/** Configuration options (min, max, step, values, etc.). */
options: IWidgetOptions
/** Display label override. */
label?: string
/** Whether the widget is disabled. */
disabled?: boolean
/** Whether to include this widget's value in serialized workflow JSON. */
serialize?: boolean
}
/**
* Layout metrics computed during the arrange phase.
*
* Currently lives as mutable properties on BaseWidget (y,
* computedHeight, width). The LayoutSystem will own these writes;
* the RenderSystem reads them.
*/
export interface WidgetLayout {
/** Vertical position relative to the node body. */
y: number
/** Computed height after layout distribution. */
computedHeight: number
/** Width override (undefined = use node width). */
width?: number
/** Layout size constraints from computeLayoutSize(). */
constraints?: WidgetLayoutConstraints
}
export interface WidgetLayoutConstraints {
minHeight: number
maxHeight?: number
minWidth?: number
maxWidth?: number
}

64
src/ecs/entityId.ts Normal file
View File

@@ -0,0 +1,64 @@
/**
* Branded entity ID types for compile-time cross-kind safety.
*
* Each entity kind gets a nominal type wrapping its underlying primitive.
* The brand prevents accidentally passing a LinkEntityId where a
* NodeEntityId is expected — a class of bugs that plain `number` allows.
*
* At runtime these are just numbers (or strings for subgraphs). The brand
* is erased by TypeScript and has zero runtime cost.
*
* @see {@link ../../../docs/adr/0008-entity-component-system.md}
*/
// -- Branded ID types -------------------------------------------------------
type Brand<T, B extends string> = T & { readonly __brand: B }
export type NodeEntityId = Brand<number, 'NodeEntityId'>
export type LinkEntityId = Brand<number, 'LinkEntityId'>
export type SubgraphEntityId = Brand<string, 'SubgraphEntityId'>
export type WidgetEntityId = Brand<number, 'WidgetEntityId'>
export type SlotEntityId = Brand<number, 'SlotEntityId'>
export type RerouteEntityId = Brand<number, 'RerouteEntityId'>
export type GroupEntityId = Brand<number, 'GroupEntityId'>
/** Union of all entity ID types. */
export type EntityId =
| NodeEntityId
| LinkEntityId
| SubgraphEntityId
| WidgetEntityId
| SlotEntityId
| RerouteEntityId
| GroupEntityId
// -- Cast helpers (for use at system boundaries) ----------------------------
export function asNodeEntityId(id: number): NodeEntityId {
return id as NodeEntityId
}
export function asLinkEntityId(id: number): LinkEntityId {
return id as LinkEntityId
}
export function asSubgraphEntityId(id: string): SubgraphEntityId {
return id as SubgraphEntityId
}
export function asWidgetEntityId(id: number): WidgetEntityId {
return id as WidgetEntityId
}
export function asSlotEntityId(id: number): SlotEntityId {
return id as SlotEntityId
}
export function asRerouteEntityId(id: number): RerouteEntityId {
return id as RerouteEntityId
}
export function asGroupEntityId(id: number): GroupEntityId {
return id as GroupEntityId
}

242
src/ecs/world.ts Normal file
View File

@@ -0,0 +1,242 @@
/**
* World — the central registry for all entity state.
*
* The World is a typed container mapping branded entity IDs to their
* component sets. Systems query the World to read/write components;
* entities never reference each other directly.
*
* This is the initial type definition (Phase 1c of the migration plan).
* The implementation starts as plain Maps. CRDT backing, transactions,
* and reactivity are future concerns.
*/
import type {
Connectivity,
Execution,
NodeType,
NodeVisual,
Properties,
WidgetContainer
} from './components/node'
import type { GroupChildren, GroupMeta, GroupVisual } from './components/group'
import type { LinkEndpoints, LinkState, LinkVisual } from './components/link'
import type { Position } from './components/position'
import type { RerouteLinks, RerouteVisual } from './components/reroute'
import type {
SlotConnection,
SlotIdentity,
SlotVisual
} from './components/slot'
import type { SubgraphMeta, SubgraphStructure } from './components/subgraph'
import type {
WidgetIdentity,
WidgetLayout,
WidgetValue
} from './components/widget'
import type {
GroupEntityId,
LinkEntityId,
NodeEntityId,
RerouteEntityId,
SlotEntityId,
SubgraphEntityId,
WidgetEntityId
} from './entityId'
// -- Component bundles per entity kind --------------------------------------
export interface NodeComponents {
position: Position
nodeType: NodeType
visual: NodeVisual
connectivity: Connectivity
execution: Execution
properties: Properties
widgetContainer: WidgetContainer
}
export interface LinkComponents {
endpoints: LinkEndpoints
visual: LinkVisual
state: LinkState
}
export interface SlotComponents {
identity: SlotIdentity
connection: SlotConnection
visual: SlotVisual
}
export interface WidgetComponents {
identity: WidgetIdentity
value: WidgetValue
layout: WidgetLayout
}
export interface RerouteComponents {
position: Position
links: RerouteLinks
visual: RerouteVisual
}
export interface GroupComponents {
position: Position
meta: GroupMeta
visual: GroupVisual
children: GroupChildren
}
export interface SubgraphComponents {
structure: SubgraphStructure
meta: SubgraphMeta
}
// -- Entity kind registry ---------------------------------------------------
export interface EntityKindMap {
node: { id: NodeEntityId; components: NodeComponents }
link: { id: LinkEntityId; components: LinkComponents }
slot: { id: SlotEntityId; components: SlotComponents }
widget: { id: WidgetEntityId; components: WidgetComponents }
reroute: { id: RerouteEntityId; components: RerouteComponents }
group: { id: GroupEntityId; components: GroupComponents }
subgraph: { id: SubgraphEntityId; components: SubgraphComponents }
}
export type EntityKind = keyof EntityKindMap
// -- World interface --------------------------------------------------------
export interface World {
/** Per-kind entity stores. */
nodes: Map<NodeEntityId, NodeComponents>
links: Map<LinkEntityId, LinkComponents>
slots: Map<SlotEntityId, SlotComponents>
widgets: Map<WidgetEntityId, WidgetComponents>
reroutes: Map<RerouteEntityId, RerouteComponents>
groups: Map<GroupEntityId, GroupComponents>
subgraphs: Map<SubgraphEntityId, SubgraphComponents>
/**
* Create a new entity of the given kind, returning its branded ID.
* The entity starts with no components — call setComponent() to populate.
*/
createEntity<K extends EntityKind>(kind: K): EntityKindMap[K]['id']
/**
* Remove an entity and all its components.
* Returns true if the entity existed, false otherwise.
*/
deleteEntity<K extends EntityKind>(
kind: K,
id: EntityKindMap[K]['id']
): boolean
/**
* Get a single component from an entity.
* Returns undefined if the entity or component doesn't exist.
*/
getComponent<
K extends EntityKind,
C extends keyof EntityKindMap[K]['components']
>(
kind: K,
id: EntityKindMap[K]['id'],
component: C
): EntityKindMap[K]['components'][C] | undefined
/**
* Set a single component on an entity.
* Creates the component if it doesn't exist, overwrites if it does.
*/
setComponent<
K extends EntityKind,
C extends keyof EntityKindMap[K]['components']
>(
kind: K,
id: EntityKindMap[K]['id'],
component: C,
data: EntityKindMap[K]['components'][C]
): void
}
// -- Factory ----------------------------------------------------------------
export function createWorld(): World {
const counters = {
node: 0,
link: 0,
slot: 0,
widget: 0,
reroute: 0,
group: 0,
subgraph: 0
}
const stores = {
nodes: new Map<NodeEntityId, NodeComponents>(),
links: new Map<LinkEntityId, LinkComponents>(),
slots: new Map<SlotEntityId, SlotComponents>(),
widgets: new Map<WidgetEntityId, WidgetComponents>(),
reroutes: new Map<RerouteEntityId, RerouteComponents>(),
groups: new Map<GroupEntityId, GroupComponents>(),
subgraphs: new Map<SubgraphEntityId, SubgraphComponents>()
}
const storeForKind: Record<EntityKind, Map<unknown, unknown>> = {
node: stores.nodes,
link: stores.links,
slot: stores.slots,
widget: stores.widgets,
reroute: stores.reroutes,
group: stores.groups,
subgraph: stores.subgraphs
}
return {
...stores,
createEntity<K extends EntityKind>(kind: K): EntityKindMap[K]['id'] {
const id = ++counters[kind]
const store = storeForKind[kind]
store.set(id, {} as never)
return id as EntityKindMap[K]['id']
},
deleteEntity<K extends EntityKind>(
kind: K,
id: EntityKindMap[K]['id']
): boolean {
return storeForKind[kind].delete(id)
},
getComponent<
K extends EntityKind,
C extends keyof EntityKindMap[K]['components']
>(
kind: K,
id: EntityKindMap[K]['id'],
component: C
): EntityKindMap[K]['components'][C] | undefined {
const entity = storeForKind[kind].get(id) as
| EntityKindMap[K]['components']
| undefined
return entity?.[component]
},
setComponent<
K extends EntityKind,
C extends keyof EntityKindMap[K]['components']
>(
kind: K,
id: EntityKindMap[K]['id'],
component: C,
data: EntityKindMap[K]['components'][C]
): void {
const entity = storeForKind[kind].get(id)
if (entity) {
Object.assign(entity, { [component]: data })
}
}
}
}