@pauloricca/spagbol 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +330 -2
  3. package/dist/controls.css +122 -0
  4. package/dist/core/controller.d.ts +89 -0
  5. package/dist/core/controller.d.ts.map +1 -0
  6. package/dist/core/controller.js +732 -0
  7. package/dist/core/controller.js.map +1 -0
  8. package/dist/core/graph.d.ts +30 -0
  9. package/dist/core/graph.d.ts.map +1 -0
  10. package/dist/core/graph.js +318 -0
  11. package/dist/core/graph.js.map +1 -0
  12. package/dist/core/grouping.d.ts +10 -0
  13. package/dist/core/grouping.d.ts.map +1 -0
  14. package/dist/core/grouping.js +130 -0
  15. package/dist/core/grouping.js.map +1 -0
  16. package/dist/core/groups.d.ts +40 -0
  17. package/dist/core/groups.d.ts.map +1 -0
  18. package/dist/core/groups.js +164 -0
  19. package/dist/core/groups.js.map +1 -0
  20. package/dist/core/index.d.ts +8 -0
  21. package/dist/core/index.d.ts.map +1 -0
  22. package/dist/core/index.js +8 -0
  23. package/dist/core/index.js.map +1 -0
  24. package/dist/core/model.d.ts +182 -0
  25. package/dist/core/model.d.ts.map +1 -0
  26. package/dist/core/model.js +149 -0
  27. package/dist/core/model.js.map +1 -0
  28. package/dist/core/observations.d.ts +10 -0
  29. package/dist/core/observations.d.ts.map +1 -0
  30. package/dist/core/observations.js +36 -0
  31. package/dist/core/observations.js.map +1 -0
  32. package/dist/core/serialization.d.ts +9 -0
  33. package/dist/core/serialization.d.ts.map +1 -0
  34. package/dist/core/serialization.js +148 -0
  35. package/dist/core/serialization.js.map +1 -0
  36. package/dist/index.d.ts +4 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +4 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/link-controls.css +123 -0
  41. package/dist/react/Controls.d.ts +18 -0
  42. package/dist/react/Controls.d.ts.map +1 -0
  43. package/dist/react/Controls.js +301 -0
  44. package/dist/react/Controls.js.map +1 -0
  45. package/dist/react/SpagbolCanvas.d.ts +38 -0
  46. package/dist/react/SpagbolCanvas.d.ts.map +1 -0
  47. package/dist/react/SpagbolCanvas.js +1210 -0
  48. package/dist/react/SpagbolCanvas.js.map +1 -0
  49. package/dist/react/SpagbolCanvasControls.d.ts +36 -0
  50. package/dist/react/SpagbolCanvasControls.d.ts.map +1 -0
  51. package/dist/react/SpagbolCanvasControls.js +113 -0
  52. package/dist/react/SpagbolCanvasControls.js.map +1 -0
  53. package/dist/react/SpagbolCanvasFlow.d.ts +14 -0
  54. package/dist/react/SpagbolCanvasFlow.d.ts.map +1 -0
  55. package/dist/react/SpagbolCanvasFlow.js +6 -0
  56. package/dist/react/SpagbolCanvasFlow.js.map +1 -0
  57. package/dist/react/SpagbolCanvasLinkControls.d.ts +16 -0
  58. package/dist/react/SpagbolCanvasLinkControls.d.ts.map +1 -0
  59. package/dist/react/SpagbolCanvasLinkControls.js +7 -0
  60. package/dist/react/SpagbolCanvasLinkControls.js.map +1 -0
  61. package/dist/react/SpagbolCanvasMenu.d.ts +12 -0
  62. package/dist/react/SpagbolCanvasMenu.d.ts.map +1 -0
  63. package/dist/react/SpagbolCanvasMenu.js +65 -0
  64. package/dist/react/SpagbolCanvasMenu.js.map +1 -0
  65. package/dist/react/SpagbolControlNumber.d.ts +25 -0
  66. package/dist/react/SpagbolControlNumber.d.ts.map +1 -0
  67. package/dist/react/SpagbolControlNumber.js +209 -0
  68. package/dist/react/SpagbolControlNumber.js.map +1 -0
  69. package/dist/react/SpagbolControlNumberLink.d.ts +6 -0
  70. package/dist/react/SpagbolControlNumberLink.d.ts.map +1 -0
  71. package/dist/react/SpagbolControlNumberLink.js +159 -0
  72. package/dist/react/SpagbolControlNumberLink.js.map +1 -0
  73. package/dist/react/SpagbolControlZoom.d.ts +11 -0
  74. package/dist/react/SpagbolControlZoom.d.ts.map +1 -0
  75. package/dist/react/SpagbolControlZoom.js +24 -0
  76. package/dist/react/SpagbolControlZoom.js.map +1 -0
  77. package/dist/react/SpagbolNodeFrame.d.ts +35 -0
  78. package/dist/react/SpagbolNodeFrame.d.ts.map +1 -0
  79. package/dist/react/SpagbolNodeFrame.js +169 -0
  80. package/dist/react/SpagbolNodeFrame.js.map +1 -0
  81. package/dist/react/SpagbolNodeFrameTypePicker.d.ts +9 -0
  82. package/dist/react/SpagbolNodeFrameTypePicker.d.ts.map +1 -0
  83. package/dist/react/SpagbolNodeFrameTypePicker.js +68 -0
  84. package/dist/react/SpagbolNodeFrameTypePicker.js.map +1 -0
  85. package/dist/react/SpagbolNodeGroupPort.d.ts +15 -0
  86. package/dist/react/SpagbolNodeGroupPort.d.ts.map +1 -0
  87. package/dist/react/SpagbolNodeGroupPort.js +62 -0
  88. package/dist/react/SpagbolNodeGroupPort.js.map +1 -0
  89. package/dist/react/SpagbolNodeGroupPortAdd.d.ts +5 -0
  90. package/dist/react/SpagbolNodeGroupPortAdd.d.ts.map +1 -0
  91. package/dist/react/SpagbolNodeGroupPortAdd.js +27 -0
  92. package/dist/react/SpagbolNodeGroupPortAdd.js.map +1 -0
  93. package/dist/react/context.d.ts +84 -0
  94. package/dist/react/context.d.ts.map +1 -0
  95. package/dist/react/context.js +43 -0
  96. package/dist/react/context.js.map +1 -0
  97. package/dist/react/duplicateDrag.d.ts +19 -0
  98. package/dist/react/duplicateDrag.d.ts.map +1 -0
  99. package/dist/react/duplicateDrag.js +71 -0
  100. package/dist/react/duplicateDrag.js.map +1 -0
  101. package/dist/react/flowPresentation.d.ts +11 -0
  102. package/dist/react/flowPresentation.d.ts.map +1 -0
  103. package/dist/react/flowPresentation.js +50 -0
  104. package/dist/react/flowPresentation.js.map +1 -0
  105. package/dist/react/geometry.d.ts +13 -0
  106. package/dist/react/geometry.d.ts.map +1 -0
  107. package/dist/react/geometry.js +43 -0
  108. package/dist/react/geometry.js.map +1 -0
  109. package/dist/react/index.d.ts +11 -0
  110. package/dist/react/index.d.ts.map +1 -0
  111. package/dist/react/index.js +11 -0
  112. package/dist/react/index.js.map +1 -0
  113. package/dist/react/portState.d.ts +10 -0
  114. package/dist/react/portState.d.ts.map +1 -0
  115. package/dist/react/portState.js +35 -0
  116. package/dist/react/portState.js.map +1 -0
  117. package/dist/react/viewport.d.ts +24 -0
  118. package/dist/react/viewport.d.ts.map +1 -0
  119. package/dist/react/viewport.js +55 -0
  120. package/dist/react/viewport.js.map +1 -0
  121. package/dist/styles.css +748 -0
  122. package/package.json +58 -4
  123. package/src/core/controller.ts +844 -0
  124. package/src/core/graph.ts +451 -0
  125. package/src/core/grouping.ts +159 -0
  126. package/src/core/groups.ts +125 -0
  127. package/src/core/index.ts +7 -0
  128. package/src/core/model.ts +304 -0
  129. package/src/core/observations.ts +31 -0
  130. package/src/core/serialization.ts +184 -0
  131. package/src/index.ts +9 -0
  132. package/src/react/Controls.tsx +355 -0
  133. package/src/react/SpagbolCanvas.tsx +1859 -0
  134. package/src/react/SpagbolCanvasControls.tsx +255 -0
  135. package/src/react/SpagbolCanvasFlow.tsx +38 -0
  136. package/src/react/SpagbolCanvasLinkControls.tsx +21 -0
  137. package/src/react/SpagbolCanvasMenu.tsx +121 -0
  138. package/src/react/SpagbolControlNumber.tsx +297 -0
  139. package/src/react/SpagbolControlNumberLink.tsx +209 -0
  140. package/src/react/SpagbolControlZoom.tsx +102 -0
  141. package/src/react/SpagbolNodeFrame.tsx +329 -0
  142. package/src/react/SpagbolNodeFrameTypePicker.tsx +51 -0
  143. package/src/react/SpagbolNodeGroupPort.tsx +111 -0
  144. package/src/react/SpagbolNodeGroupPortAdd.tsx +48 -0
  145. package/src/react/context.tsx +146 -0
  146. package/src/react/controls.css +122 -0
  147. package/src/react/duplicateDrag.ts +97 -0
  148. package/src/react/flowPresentation.ts +56 -0
  149. package/src/react/geometry.ts +53 -0
  150. package/src/react/index.ts +61 -0
  151. package/src/react/link-controls.css +123 -0
  152. package/src/react/portState.ts +24 -0
  153. package/src/react/styles.css +748 -0
  154. package/src/react/viewport.ts +90 -0
@@ -0,0 +1,125 @@
1
+ import { clone, createId, spagbolGroupKinds, type GraphDocument, type GraphNode, type NodeRegistry, type PortDefinition, type NodeData, type PortEndpoint, type LinkTarget } from './model.js';
2
+ import { pruneInvalidPorts } from './graph.js';
3
+
4
+ export function isGroupBoundary(node: GraphNode): boolean {
5
+ return node.kind === spagbolGroupKinds.inputs || node.kind === spagbolGroupKinds.outputs;
6
+ }
7
+ export function normalizeGroupPortLabel(label: string): string {
8
+ const normalized = label.trim().toLowerCase().replace(/\s+/g, ' ');
9
+ if (!/^[a-z0-9]+(?: [a-z0-9]+)*$/.test(normalized)) throw new Error('Use a lowercase, human-readable port name with spaces.');
10
+ return normalized;
11
+ }
12
+ export function createGroupPort(node: GraphNode, label: string, defaultValue = 0): PortDefinition {
13
+ if (!isGroupBoundary(node)) throw new Error('Ports can be added to Ins or Outs nodes.');
14
+ const port: PortDefinition = { id: createId('port'), label: normalizeGroupPortLabel(label), direction: node.kind === spagbolGroupKinds.inputs ? 'output' : 'input', defaultValue };
15
+ node.ports ??= [];
16
+ if (node.ports.some(item => item.label === port.label)) throw new Error('That port name already exists.');
17
+ node.ports.push(port);
18
+ if (node.kind === spagbolGroupKinds.inputs) node.values[port.id] = defaultValue;
19
+ return port;
20
+ }
21
+
22
+ export function createEditablePort(node: GraphNode, registry: NodeRegistry, label: string, defaultValue = 0): PortDefinition {
23
+ if (isGroupBoundary(node)) return createGroupPort(node, label, defaultValue);
24
+ const definition = registry.get(node.kind);
25
+ if (!definition?.editablePorts || !definition.createPort) throw new Error('This node does not expose editable ports.');
26
+ const normalized = normalizeGroupPortLabel(label);
27
+ if (node.ports?.some(port => port.label === normalized)) throw new Error('That port name already exists.');
28
+ const port = definition.createPort(node, normalized);
29
+ node.ports ??= []; node.ports.push(port); node.values[port.id] = port.defaultValue ?? defaultValue;
30
+ return port;
31
+ }
32
+
33
+ /** Apply a boundary change to every linked group instance and prune dependent cables atomically. */
34
+ export function synchronizeGroupBoundaries(document: GraphDocument, registry: NodeRegistry): void {
35
+ for (const node of document.nodes) if (node.graph) {
36
+ synchronizeGroupBoundaries(node.graph, registry);
37
+ if (node.kind !== spagbolGroupKinds.group) continue;
38
+ node.ports = [...new Map(node.graph.nodes.filter(isGroupBoundary).flatMap(boundary => (boundary.ports ?? []).map(port => ({ ...port, direction: boundary.kind === spagbolGroupKinds.inputs ? 'input' as const : 'output' as const }))).map(port => [`${port.direction}:${port.id}`, port])).values()];
39
+ const inputs = new Set(node.ports.filter(port => port.direction === 'input').map(port => port.id));
40
+ for (const id of Object.keys(node.values)) if (!inputs.has(id)) delete node.values[id];
41
+ for (const port of node.ports) if (port.direction === 'input' && node.values[port.id] === undefined) node.values[port.id] = port.defaultValue ?? 0;
42
+ pruneInvalidPorts(document, registry, node.id);
43
+ const ids = new Set(node.graph.nodes.map(item => item.id));
44
+ for (const overrides of [node.overrides, node.dataOverrides])
45
+ if (overrides) for (const id of Object.keys(overrides)) if (!ids.has(groupControlPath(id)[0]!)) delete overrides[id];
46
+ }
47
+ }
48
+
49
+ export function groupControlKey(path: string | readonly string[]): string {
50
+ return typeof path === 'string' ? path : path.length === 1 ? path[0]! : JSON.stringify(path);
51
+ }
52
+ export function groupControlPath(key: string): string[] {
53
+ if (key.startsWith('[')) try { const path: unknown = JSON.parse(key); if (Array.isArray(path) && path.every(id => typeof id === 'string')) return path; } catch { /* A literal node id. */ }
54
+ return [key];
55
+ }
56
+ export function findGroupControl(group: GraphNode, path: string | readonly string[]): GraphNode | undefined {
57
+ const ids = typeof path === 'string' ? groupControlPath(path) : path;
58
+ let graph = group.graph;
59
+ let node: GraphNode | undefined;
60
+ for (const id of ids) { node = graph?.nodes.find(item => item.id === id); if (!node) return; graph = node.graph; }
61
+ return node;
62
+ }
63
+ export function effectiveGroupGraph(group: GraphNode): GraphDocument | undefined {
64
+ if (!group.graph) return;
65
+ const result: GraphNode = { ...group, graph: clone(group.graph) };
66
+ for (const node of result.graph!.nodes) if (node.graph) {
67
+ node.graph = effectiveGroupGraph(node)!;
68
+ delete node.overrides; delete node.dataOverrides;
69
+ }
70
+ for (const [id, values] of Object.entries(group.overrides ?? {})) {
71
+ const node = findGroupControl(result, id);
72
+ if (node) Object.assign(node.values, values);
73
+ }
74
+ for (const [id, data] of Object.entries(group.dataOverrides ?? {})) {
75
+ const node = findGroupControl(result, id);
76
+ if (node) Object.assign(node.data, data);
77
+ }
78
+ return result.graph;
79
+ }
80
+
81
+ /** Teia's UI/Controls area projection, with explicit exposure also supported. */
82
+ const panelCache = new WeakMap<GraphNode, ReturnType<typeof computeGroupPanelLayout>>();
83
+ export function groupPanelLayout(group: GraphNode) {
84
+ if (!Object.isFrozen(group)) return computeGroupPanelLayout(group);
85
+ if (!panelCache.has(group)) panelCache.set(group, computeGroupPanelLayout(group));
86
+ return panelCache.get(group);
87
+ }
88
+ function computeGroupPanelLayout(group: GraphNode) {
89
+ const graph = effectiveGroupGraph(group);
90
+ if (!graph) return;
91
+ const areas = graph.areas.filter(area => area.data?.role === 'controls' || (!area.ownerNodeId && ['ui', 'controls'].includes(area.title.trim().toLowerCase())));
92
+ const exposed = graph.data?.exposedNodeIds;
93
+ const ids = new Set<string>([...(group.panelNodeIds ?? []), ...(Array.isArray(exposed) ? exposed.filter((id): id is string => typeof id === 'string') : [])]);
94
+ for (const area of areas) for (const node of graph.nodes) {
95
+ if (area.locked ? area.nodeIds?.includes(node.id) : node.position.x >= area.position.x && node.position.x < area.position.x + area.size.width && node.position.y >= area.position.y && node.position.y < area.position.y + area.size.height) ids.add(node.id);
96
+ }
97
+ const nodes = graph.nodes.filter(node => ids.has(node.id) && !isGroupBoundary(node));
98
+ if (!areas.length && !nodes.length) return;
99
+ const left = areas.length ? Math.min(...areas.map(area => area.position.x)) : Math.min(...nodes.map(node => node.position.x));
100
+ const top = Math.min(...areas.map(area => area.position.y + 32), ...nodes.map(node => node.position.y));
101
+ const right = areas.length ? Math.max(...areas.map(area => area.position.x + area.size.width)) : Math.max(...nodes.map(node => node.position.x + ((node.size?.width ?? 220) * (node.scale ?? 1))));
102
+ const bottom = areas.length ? Math.max(...areas.map(area => area.position.y + area.size.height)) : Math.max(...nodes.map(node => node.position.y + ((node.size?.height ?? 120) * (node.scale ?? 1))));
103
+ return { position: { x: left, y: top }, size: { width: right - left, height: bottom - top }, nodes };
104
+ }
105
+
106
+ /** Transient handle ID; it is resolved to a persistent boundary port in one edit. */
107
+ export const spagbolNewPortId = 'spagbol.new-port';
108
+ export function resolveNewBoundaryPorts(graph: GraphDocument, source: PortEndpoint, target: LinkTarget, registry: NodeRegistry) {
109
+ const from = { ...source }, to = { ...target };
110
+ const label = (nodeId: string, portId: string) => {
111
+ const node = graph.nodes.find(node => node.id === nodeId);
112
+ return node && (node.ports ?? registry.get(node.kind)?.ports(node) ?? []).find(port => port.id === portId)?.label;
113
+ };
114
+ const create = (nodeId: string, direction: 'input' | 'output', preferred?: string) => {
115
+ const node = graph.nodes.find(node => node.id === nodeId && (node.kind === (direction === 'output' ? spagbolGroupKinds.inputs : spagbolGroupKinds.outputs) || registry.get(node.kind)?.editablePorts === direction));
116
+ if (!node) return spagbolNewPortId;
117
+ const base = preferred?.toLowerCase().replace(/[^a-z0-9 ]/g, ' ').replace(/\s+/g, ' ').trim() || 'value';
118
+ let name = base, suffix = 2;
119
+ while (node.ports?.some(port => port.label === name)) name = `${base} ${suffix++}`;
120
+ return createEditablePort(node, registry, name).id;
121
+ };
122
+ if (from.portId === spagbolNewPortId) from.portId = create(from.nodeId, "output", to.type === 'port' ? label(to.nodeId, to.portId) : 'strength');
123
+ if (to.type === 'port' && to.portId === spagbolNewPortId) to.portId = create(to.nodeId, "input", label(from.nodeId, from.portId));
124
+ return { source: from, target: to };
125
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./model.js";
2
+ export * from "./graph.js";
3
+ export * from "./controller.js";
4
+ export * from "./serialization.js";
5
+ export * from "./observations.js";
6
+ export * from "./grouping.js";
7
+ export * from "./groups.js";
@@ -0,0 +1,304 @@
1
+ /** Saved data is JSON only. Functions, observers and UI state never enter documents. */
2
+ export type JsonValue =
3
+ | null
4
+ | boolean
5
+ | number
6
+ | string
7
+ | JsonValue[]
8
+ | { [key: string]: JsonValue };
9
+ export type NodeData = { [key: string]: JsonValue };
10
+ export interface Point {
11
+ x: number;
12
+ y: number;
13
+ }
14
+ export interface Size {
15
+ width: number;
16
+ height: number;
17
+ }
18
+ export interface PortDefinition {
19
+ id: string;
20
+ label: string;
21
+ direction: "input" | "output";
22
+ dataType?: string;
23
+ connectable?: boolean;
24
+ valueEditor?: boolean;
25
+ defaultValue?: number;
26
+ min?: number;
27
+ max?: number;
28
+ step?: number;
29
+ integer?: boolean;
30
+ }
31
+ export interface GraphNode<D extends NodeData = NodeData> {
32
+ id: string;
33
+ kind: string;
34
+ label?: string;
35
+ position: Point;
36
+ size?: Size;
37
+ compact?: boolean;
38
+ compactLabel?: string;
39
+ scale?: number;
40
+ enabled?: boolean;
41
+ data: D;
42
+ values: Record<string, number>;
43
+ /** Explicit/dynamic instance ports; a definition may derive ports instead. */
44
+ ports?: PortDefinition[];
45
+ graph?: GraphDocument;
46
+ /** Equal IDs opt into shared nested structure; instance values stay local. */
47
+ sharedGraphId?: string;
48
+ /** Explicit nested nodes to show on this parent. No title conventions in core. */
49
+ panelNodeIds?: string[];
50
+ overrides?: Record<string, Record<string, number>>;
51
+ dataOverrides?: Record<string, NodeData>;
52
+ }
53
+ export interface PortEndpoint {
54
+ nodeId: string;
55
+ portId: string;
56
+ }
57
+ export type LinkTarget =
58
+ | ({ type: "port" } & PortEndpoint)
59
+ | { type: "link"; linkId: string; property: string };
60
+ export interface GraphLink {
61
+ id: string;
62
+ source: PortEndpoint;
63
+ target: LinkTarget;
64
+ mode: string;
65
+ weight: number;
66
+ enabled?: boolean;
67
+ data?: NodeData;
68
+ }
69
+ export interface GraphArea {
70
+ id: string;
71
+ /** A domain node whose UI/layout is owned by this area. */
72
+ ownerNodeId?: string;
73
+ title: string;
74
+ position: Point;
75
+ size: Size;
76
+ collapsed?: boolean;
77
+ locked?: boolean;
78
+ nodeIds?: string[];
79
+ areaIds?: string[];
80
+ /** The top portion remains operable while the rest is collapsed. */
81
+ controlHeight?: number;
82
+ data?: NodeData;
83
+ }
84
+ export interface GraphDocument {
85
+ format: "spagbol";
86
+ version: 1;
87
+ nodes: GraphNode[];
88
+ links: GraphLink[];
89
+ areas: GraphArea[];
90
+ data?: NodeData;
91
+ }
92
+ export interface Selection {
93
+ port?: { nodeId: string; portId: string; direction: "input" | "output" };
94
+ nodeIds: string[];
95
+ linkIds: string[];
96
+ areaIds: string[];
97
+ }
98
+ export interface Diagnostic {
99
+ severity: "info" | "warning" | "error";
100
+ code: string;
101
+ message: string;
102
+ path: readonly string[];
103
+ nodeId?: string;
104
+ linkId?: string;
105
+ portId?: string;
106
+ }
107
+ export interface LinkModeDefinition {
108
+ id: string;
109
+ label: string;
110
+ color: string;
111
+ shortcut?: string;
112
+ description: string;
113
+ }
114
+ export interface NodeDefinition<D extends NodeData = NodeData> {
115
+ kind: string;
116
+ label: string;
117
+ category?: string;
118
+ description?: string;
119
+ keywords?: string[];
120
+ createData(): D;
121
+ createGraph?(): GraphDocument;
122
+ editablePorts?: "input" | "output";
123
+ newPortLabel?: string;
124
+ createPort?(node: GraphNode<D>, label: string): PortDefinition;
125
+ ports(node: GraphNode<D>): readonly PortDefinition[];
126
+ defaultSize?: Size;
127
+ validate?(node: GraphNode<D>): readonly string[];
128
+ /** App code can remap asset or domain references when duplicating. */
129
+ cloneData?(data: D, ids: ReadonlyMap<string, string>): D;
130
+ }
131
+ export interface GraphPolicy {
132
+ /** All registered modes are allowed unless the consumer supplies a subset. */
133
+ modes?: readonly string[];
134
+ linkProperties?: readonly string[];
135
+ allowSelfLinks?: boolean;
136
+ allowDuplicateLinks?: boolean;
137
+ /** Host metadata update when deleting a selected editable domain port. */
138
+ deletePort?(node: GraphNode, port: PortDefinition): void;
139
+ canConnect?(
140
+ source: PortEndpoint,
141
+ target: LinkTarget,
142
+ graph: GraphDocument,
143
+ ): string | undefined;
144
+ }
145
+ export interface ChangeEvent {
146
+ label: string;
147
+ kinds: readonly ChangeKind[];
148
+ document: GraphDocument;
149
+ path: readonly string[];
150
+ origin: "edit" | "undo" | "redo" | "replace";
151
+ }
152
+ export type ChangeKind = "structure" | "values" | "layout" | "metadata";
153
+ export function emptyGraph(): GraphDocument {
154
+ return { format: "spagbol", version: 1, nodes: [], links: [], areas: [] };
155
+ }
156
+ export function emptySelection(): Selection {
157
+ return { nodeIds: [], linkIds: [], areaIds: [] };
158
+ }
159
+ export function clone<T>(value: T): T {
160
+ return JSON.parse(JSON.stringify(value)) as T;
161
+ }
162
+ export function createId(prefix = "item"): string {
163
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
164
+ }
165
+ export function graphAt(
166
+ document: GraphDocument,
167
+ path: readonly string[],
168
+ ): GraphDocument {
169
+ let graph = document;
170
+ for (const id of path) {
171
+ const nested = graph.nodes.find((node) => node.id === id)?.graph;
172
+ if (!nested) throw new Error(`Nested graph ${id} does not exist.`);
173
+ graph = nested;
174
+ }
175
+ return graph;
176
+ }
177
+ export function validPath(
178
+ document: GraphDocument,
179
+ path: readonly string[],
180
+ ): string[] {
181
+ const result: string[] = [];
182
+ let graph = document;
183
+ for (const id of path) {
184
+ const nested = graph.nodes.find((node) => node.id === id)?.graph;
185
+ if (!nested) break;
186
+ result.push(id);
187
+ graph = nested;
188
+ }
189
+ return result;
190
+ }
191
+ const immutablePortCache = new WeakMap<GraphNode, WeakMap<NodeRegistry, readonly PortDefinition[]>>();
192
+ export function portsFor(
193
+ node: GraphNode,
194
+ definitions: NodeRegistry,
195
+ ): readonly PortDefinition[] {
196
+ if (!Object.isFrozen(node)) return definitions.get(node.kind)?.ports(node) ?? node.ports ?? [];
197
+ let registries = immutablePortCache.get(node);
198
+ if (!registries) { registries = new WeakMap(); immutablePortCache.set(node, registries); }
199
+ let ports = registries.get(definitions);
200
+ if (!ports) { ports = definitions.get(node.kind)?.ports(node) ?? node.ports ?? []; registries.set(definitions, ports); }
201
+ return ports;
202
+ }
203
+ export const spagbolGroupKinds = { group: "spagbol.group", inputs: "spagbol.group.inputs", outputs: "spagbol.group.outputs" } as const;
204
+ const groupDefinitions: readonly NodeDefinition[] = [
205
+ { kind: spagbolGroupKinds.group, label: 'Group', category: 'graph', createData: () => ({}), ports: node => node.ports ?? [], createGraph: () => ({ ...emptyGraph(), nodes: [
206
+ { id: createId('node'), kind: spagbolGroupKinds.inputs, position: { x: 0, y: 0 }, data: {}, values: {}, ports: [] },
207
+ { id: createId('node'), kind: spagbolGroupKinds.outputs, position: { x: 500, y: 0 }, data: {}, values: {}, ports: [] },
208
+ ] }) },
209
+ { kind: spagbolGroupKinds.inputs, label: 'Ins', category: 'graph', createData: () => ({}), ports: node => node.ports ?? [] },
210
+ { kind: spagbolGroupKinds.outputs, label: 'Outs', category: 'graph', createData: () => ({}), ports: node => node.ports ?? [] },
211
+ ];
212
+
213
+ export class NodeRegistry {
214
+ readonly #definitions = new Map<string, NodeDefinition>();
215
+ constructor(definitions: readonly NodeDefinition[] = []) {
216
+ for (const definition of [...groupDefinitions, ...definitions]) {
217
+ if (this.#definitions.has(definition.kind))
218
+ throw new Error(`Duplicate node kind ${definition.kind}.`);
219
+ this.#definitions.set(definition.kind, definition);
220
+ }
221
+ }
222
+ get(kind: string): NodeDefinition | undefined {
223
+ return this.#definitions.get(kind);
224
+ }
225
+ list(): readonly NodeDefinition[] {
226
+ return [...this.#definitions.values()];
227
+ }
228
+ create(kind: string, position: Point, id = createId("node")): GraphNode {
229
+ const definition = this.get(kind);
230
+ if (!definition) throw new Error(`Unknown node kind ${kind}.`);
231
+ const node: GraphNode = {
232
+ id,
233
+ kind,
234
+ position: { ...position },
235
+ data: definition.createData(),
236
+ values: {},
237
+ };
238
+ if (definition.createGraph) {
239
+ node.graph = definition.createGraph();
240
+ node.sharedGraphId = createId('shared');
241
+ node.ports = [];
242
+ }
243
+ if (definition.defaultSize) node.size = { ...definition.defaultSize };
244
+ node.values = Object.fromEntries(
245
+ definition
246
+ .ports(node)
247
+ .filter((port) => port.direction === "input")
248
+ .map((port) => [port.id, port.defaultValue ?? 0]),
249
+ );
250
+ return node;
251
+ }
252
+ }
253
+ export function defineNode<D extends NodeData>(
254
+ definition: NodeDefinition<D>,
255
+ ): NodeDefinition<D> {
256
+ return definition;
257
+ }
258
+ export const standardLinkModes: readonly LinkModeDefinition[] = Object.freeze([
259
+ {
260
+ id: "set",
261
+ label: "set",
262
+ color: "#fff",
263
+ shortcut: "s",
264
+ description:
265
+ "Average weighted set sources, replacing the local base value.",
266
+ },
267
+ {
268
+ id: "add",
269
+ label: "add",
270
+ color: "#ffe45c",
271
+ shortcut: "a",
272
+ description: "Add weighted sources after set values or the local base.",
273
+ },
274
+ {
275
+ id: "multiply",
276
+ label: "multiply",
277
+ color: "#8fdcff",
278
+ shortcut: "m",
279
+ description: "Multiply by each weighted source after addition.",
280
+ },
281
+ {
282
+ id: "bend",
283
+ label: "bend",
284
+ color: "#c084fc",
285
+ shortcut: "b",
286
+ description: "Scale by 2^(source × weight) after multiplication.",
287
+ },
288
+ ]);
289
+ export class LinkModeRegistry {
290
+ readonly #definitions = new Map<string, LinkModeDefinition>();
291
+ constructor(definitions: readonly LinkModeDefinition[] = standardLinkModes) {
292
+ for (const definition of definitions) {
293
+ if (this.#definitions.has(definition.id))
294
+ throw new Error(`Duplicate link mode ${definition.id}.`);
295
+ this.#definitions.set(definition.id, Object.freeze({ ...definition }));
296
+ }
297
+ }
298
+ get(id: string): LinkModeDefinition | undefined {
299
+ return this.#definitions.get(id);
300
+ }
301
+ list(): readonly LinkModeDefinition[] {
302
+ return [...this.#definitions.values()];
303
+ }
304
+ }
@@ -0,0 +1,31 @@
1
+ /** Runtime data is instance-scoped, externally supplied and absent from history. */
2
+ export class ObservationStore<T = unknown> {
3
+ readonly #values = new Map<string, T>();
4
+ readonly #listeners = new Map<string, Set<() => void>>();
5
+ key(path: readonly string[], nodeId: string, instance = ""): string {
6
+ return JSON.stringify([path, nodeId, instance]);
7
+ }
8
+ get(key: string): T | undefined {
9
+ return this.#values.get(key);
10
+ }
11
+ set(key: string, value: T): void {
12
+ if (Object.is(this.#values.get(key), value)) return;
13
+ this.#values.set(key, value);
14
+ for (const listener of this.#listeners.get(key) ?? []) listener();
15
+ }
16
+ subscribe(key: string, listener: () => void): () => void {
17
+ const listeners = this.#listeners.get(key) ?? new Set();
18
+ listeners.add(listener);
19
+ this.#listeners.set(key, listeners);
20
+ return () => {
21
+ listeners.delete(listener);
22
+ if (!listeners.size) this.#listeners.delete(key);
23
+ };
24
+ }
25
+ clear(): void {
26
+ const keys = [...this.#values.keys()];
27
+ this.#values.clear();
28
+ for (const key of keys)
29
+ for (const listener of this.#listeners.get(key) ?? []) listener();
30
+ }
31
+ }
@@ -0,0 +1,184 @@
1
+ import { clone, type GraphDocument, type JsonValue } from "./model.js";
2
+ import type { GraphFragment } from "./graph.js";
3
+
4
+ function record(value: unknown): value is Record<string, unknown> {
5
+ return !!value && typeof value === "object" && !Array.isArray(value);
6
+ }
7
+ function finite(value: unknown): value is number {
8
+ return typeof value === "number" && Number.isFinite(value);
9
+ }
10
+ function string(value: unknown): value is string {
11
+ return typeof value === "string" && value.length > 0;
12
+ }
13
+ function point(value: unknown): boolean {
14
+ return record(value) && finite(value.x) && finite(value.y);
15
+ }
16
+ function size(value: unknown): boolean {
17
+ return (
18
+ record(value) &&
19
+ finite(value.width) &&
20
+ value.width > 0 &&
21
+ finite(value.height) &&
22
+ value.height > 0
23
+ );
24
+ }
25
+ function endpoint(value: unknown): boolean {
26
+ return record(value) && string(value.nodeId) && string(value.portId);
27
+ }
28
+ function optional(
29
+ value: unknown,
30
+ predicate: (value: unknown) => boolean,
31
+ ): boolean {
32
+ return value === undefined || predicate(value);
33
+ }
34
+ function numberRecord(value: unknown): boolean {
35
+ return record(value) && Object.values(value).every(finite);
36
+ }
37
+ export function isJsonValue(
38
+ value: unknown,
39
+ ancestors = new Set<object>(),
40
+ ): value is JsonValue {
41
+ if (
42
+ value === null ||
43
+ typeof value === "string" ||
44
+ typeof value === "boolean" ||
45
+ finite(value)
46
+ )
47
+ return true;
48
+ if (!Array.isArray(value) && !record(value)) return false;
49
+ if (ancestors.has(value)) return false;
50
+ const next = new Set([...ancestors, value]);
51
+ return (
52
+ Object.getPrototypeOf(value) ===
53
+ (Array.isArray(value) ? Array.prototype : Object.prototype) &&
54
+ Object.values(value).every((child) => isJsonValue(child, next))
55
+ );
56
+ }
57
+ function boolean(value: unknown): boolean {
58
+ return typeof value === "boolean";
59
+ }
60
+ function strings(value: unknown): boolean {
61
+ return Array.isArray(value) && value.every(string);
62
+ }
63
+ function node(value: unknown, depth: number): boolean {
64
+ return (
65
+ record(value) &&
66
+ string(value.id) &&
67
+ string(value.kind) &&
68
+ point(value.position) &&
69
+ record(value.data) &&
70
+ isJsonValue(value.data) &&
71
+ numberRecord(value.values) &&
72
+ optional(value.label, (text) => typeof text === "string") &&
73
+ optional(value.size, size) &&
74
+ optional(value.enabled, boolean) &&
75
+ optional(value.compact, boolean) &&
76
+ optional(value.compactLabel, text => typeof text === "string") &&
77
+ optional(value.scale, scale => finite(scale) && scale > 0) &&
78
+ optional(value.sharedGraphId, string) &&
79
+ optional(value.panelNodeIds, strings) &&
80
+ optional(value.dataOverrides, values => record(values) && Object.values(values).every(item => record(item) && isJsonValue(item))) &&
81
+ optional(
82
+ value.overrides,
83
+ (overrides) =>
84
+ record(overrides) && Object.values(overrides).every(numberRecord),
85
+ ) &&
86
+ optional(
87
+ value.ports,
88
+ (ports) =>
89
+ Array.isArray(ports) &&
90
+ ports.every(
91
+ (port) =>
92
+ record(port) &&
93
+ string(port.id) &&
94
+ string(port.label) &&
95
+ (port.direction === "input" || port.direction === "output") &&
96
+ optional(port.dataType, string) &&
97
+ optional(port.connectable, boolean) &&
98
+ optional(port.valueEditor, boolean) &&
99
+ optional(port.integer, boolean) &&
100
+ ["defaultValue", "min", "max", "step"].every((key) =>
101
+ optional(port[key], finite),
102
+ ) &&
103
+ !(finite(port.min) && finite(port.max) && port.min > port.max),
104
+ ),
105
+ ) &&
106
+ optional(value.graph, (graph) => document(graph, depth + 1))
107
+ );
108
+ }
109
+ function link(value: unknown): boolean {
110
+ return (
111
+ record(value) &&
112
+ string(value.id) &&
113
+ endpoint(value.source) &&
114
+ record(value.target) &&
115
+ ((value.target.type === "port" && endpoint(value.target)) ||
116
+ (value.target.type === "link" &&
117
+ string(value.target.linkId) &&
118
+ string(value.target.property))) &&
119
+ string(value.mode) &&
120
+ finite(value.weight) &&
121
+ optional(value.enabled, boolean) &&
122
+ optional(value.data, (data) => record(data) && isJsonValue(data))
123
+ );
124
+ }
125
+ function area(value: unknown): boolean {
126
+ return (
127
+ record(value) &&
128
+ optional(value.ownerNodeId, string) &&
129
+ string(value.id) &&
130
+ typeof value.title === "string" &&
131
+ point(value.position) &&
132
+ size(value.size) &&
133
+ optional(value.locked, boolean) &&
134
+ optional(value.collapsed, boolean) &&
135
+ optional(value.nodeIds, strings) &&
136
+ optional(value.areaIds, strings) &&
137
+ optional(value.controlHeight, (height) => finite(height) && height >= 0) &&
138
+ optional(value.data, (data) => record(data) && isJsonValue(data))
139
+ );
140
+ }
141
+ function document(value: unknown, depth: number): boolean {
142
+ return (
143
+ depth <= 32 &&
144
+ record(value) &&
145
+ value.format === "spagbol" &&
146
+ value.version === 1 &&
147
+ Array.isArray(value.nodes) &&
148
+ value.nodes.every((candidate) => node(candidate, depth)) &&
149
+ Array.isArray(value.links) &&
150
+ value.links.every(link) &&
151
+ Array.isArray(value.areas) &&
152
+ value.areas.every(area) &&
153
+ optional(value.data, (data) => record(data) && isJsonValue(data))
154
+ );
155
+ }
156
+ export function assertDocument(value: unknown): asserts value is GraphDocument {
157
+ if (!isJsonValue(value) || !document(value, 0))
158
+ throw new Error(
159
+ "Invalid Spagbol document. Expected format spagbol/version 1, finite JSON data and valid node/link/area shapes (maximum nesting 32).",
160
+ );
161
+ }
162
+ /** Preserve array order. Never sort links as a side effect of serialization. */
163
+ export function writeDocument(value: GraphDocument): string {
164
+ assertDocument(value);
165
+ return `${JSON.stringify(value, null, 2)}\n`;
166
+ }
167
+ export function readDocument(input: string | unknown): GraphDocument {
168
+ const parsed: unknown = typeof input === "string" ? JSON.parse(input) : input;
169
+ assertDocument(parsed);
170
+ return clone(parsed);
171
+ }
172
+ export function readFragment(input: string | unknown): GraphFragment {
173
+ const parsed: unknown = typeof input === "string" ? JSON.parse(input) : input;
174
+ if (
175
+ !record(parsed) ||
176
+ parsed.format !== "spagbol-fragment" ||
177
+ parsed.version !== 1 ||
178
+ !Array.isArray(parsed.boundaryLinks) ||
179
+ !parsed.boundaryLinks.every(link)
180
+ )
181
+ throw new Error("Invalid Spagbol clipboard fragment.");
182
+ assertDocument({ ...parsed, format: "spagbol" });
183
+ return clone(parsed) as unknown as GraphFragment;
184
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export * from "./core/index.js";
2
+ export * from "./react/index.js";
3
+ export {
4
+ SpagbolControlSlider,
5
+ SpagbolControlButton,
6
+ type SpagbolControlSliderProps,
7
+ type SpagbolControlButtonProps,
8
+ type SpagbolControlButtonMode,
9
+ } from "./react/Controls.js";