@tuongaz/seeflow 0.1.31 → 0.1.39

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.
package/src/layout.ts ADDED
@@ -0,0 +1,217 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { runInThisContext } from 'node:vm';
4
+ import ELK from 'elkjs/lib/elk-api.js';
5
+ import type { FlowNode } from './schema.ts';
6
+
7
+ // elkjs Bun-compat shim. The `elk-worker.min.js` file inspects `self` at the
8
+ // bottom to decide between its browser-worker branch and its CJS-export
9
+ // branch. Bun exposes `self` globally, which makes it take the
10
+ // browser-worker branch — and skip the export — so we'd get an empty
11
+ // module otherwise. We load the vendored worker source via vm and run it
12
+ // inside a wrapper that shadows `self`, then pluck the Worker class out of
13
+ // the resulting module.exports. One-time cost at module load.
14
+ const requireFromHere = createRequire(import.meta.url);
15
+ const workerSource = readFileSync(requireFromHere.resolve('elkjs/lib/elk-worker.min.js'), 'utf8');
16
+ type WorkerCtor = new () => { postMessage: (msg: unknown) => unknown };
17
+ const workerModule: { exports: { Worker?: WorkerCtor } } = { exports: {} };
18
+ const wrappedSource = `(function (module, exports) { var self; ${workerSource} })`;
19
+ const wrapped = runInThisContext(wrappedSource, {
20
+ filename: 'elkjs/lib/elk-worker.min.js',
21
+ }) as (m: typeof workerModule, e: typeof workerModule.exports) => void;
22
+ wrapped(workerModule, workerModule.exports);
23
+ const ElkWorker = workerModule.exports.Worker;
24
+ if (!ElkWorker) throw new Error('elkjs worker class not found after Bun shim');
25
+
26
+ // ELK's type expects a full DOM Worker; our shim provides the minimal duck
27
+ // (postMessage / addEventListener). ELK only checks `typeof postMessage ===
28
+ // 'function'` at runtime, so the cast is safe.
29
+ const elk = new ELK({
30
+ workerFactory: () => new ElkWorker() as unknown as Worker,
31
+ });
32
+
33
+ export type LayoutDirection = 'RIGHT' | 'DOWN' | 'LEFT' | 'UP';
34
+ export type SourceHandle = 'r' | 'b';
35
+ export type TargetHandle = 't' | 'l';
36
+
37
+ export interface LayoutOptions {
38
+ direction?: LayoutDirection;
39
+ spacing?: { layer?: number; node?: number };
40
+ }
41
+
42
+ export interface LayoutResult {
43
+ nodes: Record<string, { position: { x: number; y: number } }>;
44
+ connectors: Record<string, { sourceHandle: SourceHandle; targetHandle: TargetHandle }>;
45
+ }
46
+
47
+ // Structural shape computeLayout cares about. Decoupled from the strict
48
+ // FlowSchema so callers like assembleDemo (which carries pre-validation
49
+ // passthrough data) can feed in their loose nodes without round-tripping
50
+ // through Zod.
51
+ export interface LayoutNode {
52
+ id: string;
53
+ type: FlowNode['type'];
54
+ data?: { width?: number; height?: number; shape?: string };
55
+ }
56
+
57
+ export interface LayoutEdge {
58
+ id: string;
59
+ source: string;
60
+ target: string;
61
+ }
62
+
63
+ const DEFAULT_DIMENSIONS: Record<FlowNode['type'], { width: number; height: number }> = {
64
+ playNode: { width: 220, height: 100 },
65
+ stateNode: { width: 220, height: 100 },
66
+ shapeNode: { width: 160, height: 80 },
67
+ iconNode: { width: 80, height: 80 },
68
+ htmlNode: { width: 320, height: 200 },
69
+ imageNode: { width: 200, height: 150 },
70
+ };
71
+
72
+ const SHAPE_OVERRIDES: Record<string, { width: number; height: number }> = {
73
+ text: { width: 160, height: 40 },
74
+ sticky: { width: 160, height: 180 },
75
+ };
76
+
77
+ const FLOATING_SHAPES = new Set(['sticky', 'text']);
78
+
79
+ const nodeDimensions = (node: LayoutNode): { width: number; height: number } => {
80
+ const data = node.data ?? {};
81
+ if (typeof data.width === 'number' && typeof data.height === 'number') {
82
+ return { width: data.width, height: data.height };
83
+ }
84
+ if (node.type === 'shapeNode' && data.shape) {
85
+ const override = SHAPE_OVERRIDES[data.shape];
86
+ if (override) return override;
87
+ }
88
+ return DEFAULT_DIMENSIONS[node.type];
89
+ };
90
+
91
+ // Sticky / text shapes are floating annotations. They never participate in
92
+ // layered layout — they sit in a side column so the orthogonal flow stays
93
+ // clean.
94
+ const isFloatingAnnotation = (node: LayoutNode): boolean => {
95
+ if (node.type !== 'shapeNode') return false;
96
+ const shape = node.data?.shape;
97
+ return shape !== undefined && FLOATING_SHAPES.has(shape);
98
+ };
99
+
100
+ // Schema vocabulary: SourceHandle ∈ {r, b}, TargetHandle ∈ {t, l}. After
101
+ // ELK lays out positions we pick handles geometrically — the layered LR
102
+ // algorithm puts most edges going east, so the default is source.r →
103
+ // target.l. Back-edges (target to the left or directly below) route via
104
+ // source.b → target.t so they don't try to enter from a side that has no
105
+ // target handle.
106
+ const pickHandles = (
107
+ src: { x: number; y: number; w: number; h: number },
108
+ tgt: { x: number; y: number; w: number; h: number },
109
+ ): { sourceHandle: SourceHandle; targetHandle: TargetHandle } => {
110
+ const sCenter = { x: src.x + src.w / 2, y: src.y + src.h / 2 };
111
+ const tCenter = { x: tgt.x + tgt.w / 2, y: tgt.y + tgt.h / 2 };
112
+ const dx = tCenter.x - sCenter.x;
113
+ const dy = tCenter.y - sCenter.y;
114
+
115
+ if (dx > 0 && Math.abs(dx) >= Math.abs(dy)) {
116
+ return { sourceHandle: 'r', targetHandle: 'l' };
117
+ }
118
+ return { sourceHandle: 'b', targetHandle: 't' };
119
+ };
120
+
121
+ export const computeLayout = async (
122
+ nodes: readonly LayoutNode[],
123
+ edges: readonly LayoutEdge[],
124
+ options?: LayoutOptions,
125
+ ): Promise<LayoutResult> => {
126
+ // Stable input ordering keeps ELK output deterministic across runs.
127
+ const allNodes = [...nodes].sort((a, b) => a.id.localeCompare(b.id));
128
+ const connectors = [...edges].sort((a, b) => a.id.localeCompare(b.id));
129
+
130
+ const result: LayoutResult = { nodes: {}, connectors: {} };
131
+
132
+ if (allNodes.length === 0) return result;
133
+
134
+ const dims = new Map<string, { width: number; height: number }>();
135
+ for (const n of allNodes) dims.set(n.id, nodeDimensions(n));
136
+
137
+ const referenced = new Set<string>();
138
+ for (const c of connectors) {
139
+ referenced.add(c.source);
140
+ referenced.add(c.target);
141
+ }
142
+ const laidOut = allNodes.filter((n) => referenced.has(n.id) && !isFloatingAnnotation(n));
143
+ const floatingNodes = allNodes.filter((n) => !laidOut.includes(n));
144
+
145
+ const layerSpacing = options?.spacing?.layer ?? 220;
146
+ const nodeSpacing = options?.spacing?.node ?? 140;
147
+ const direction = options?.direction ?? 'RIGHT';
148
+
149
+ if (laidOut.length > 0) {
150
+ const elkGraph = {
151
+ id: 'root',
152
+ layoutOptions: {
153
+ 'elk.algorithm': 'layered',
154
+ 'elk.direction': direction,
155
+ 'elk.layered.spacing.nodeNodeBetweenLayers': String(layerSpacing),
156
+ 'elk.spacing.nodeNode': String(nodeSpacing),
157
+ 'elk.spacing.edgeNode': '60',
158
+ 'elk.spacing.edgeEdge': '30',
159
+ 'elk.spacing.edgeLabel': '12',
160
+ 'elk.layered.edgeLabels.sideSelection': 'SMART_DOWN',
161
+ 'elk.edgeRouting': 'ORTHOGONAL',
162
+ 'elk.separateConnectedComponents': 'true',
163
+ },
164
+ children: laidOut.map((n) => {
165
+ const d = dims.get(n.id) ?? DEFAULT_DIMENSIONS.playNode;
166
+ return { id: n.id, width: d.width, height: d.height };
167
+ }),
168
+ edges: connectors
169
+ .filter((c) => referenced.has(c.source) && referenced.has(c.target))
170
+ .map((c) => ({ id: c.id, sources: [c.source], targets: [c.target] })),
171
+ };
172
+
173
+ const out = await elk.layout(elkGraph);
174
+ for (const child of out.children ?? []) {
175
+ if (typeof child.x !== 'number' || typeof child.y !== 'number') continue;
176
+ result.nodes[child.id] = {
177
+ position: { x: Math.round(child.x), y: Math.round(child.y) },
178
+ };
179
+ }
180
+ }
181
+
182
+ // Floating nodes get a right-side column at x = maxLaidOutX + gap. Avoids
183
+ // the (0,0) pile-up that the old hand-authored positions had whenever a
184
+ // sticky note slipped through without an explicit position.
185
+ if (floatingNodes.length > 0) {
186
+ let maxRight = 0;
187
+ for (const id of Object.keys(result.nodes)) {
188
+ const pos = result.nodes[id]?.position;
189
+ const d = dims.get(id);
190
+ if (!pos || !d) continue;
191
+ maxRight = Math.max(maxRight, pos.x + d.width);
192
+ }
193
+ const columnX = laidOut.length > 0 ? maxRight + 200 : 0;
194
+ let cursorY = 0;
195
+ for (const n of floatingNodes) {
196
+ const d = dims.get(n.id) ?? DEFAULT_DIMENSIONS.shapeNode;
197
+ result.nodes[n.id] = { position: { x: columnX, y: cursorY } };
198
+ cursorY += d.height + nodeSpacing;
199
+ }
200
+ }
201
+
202
+ // Geometric handle assignment runs after positions are known so it can
203
+ // see whether each edge ended up going east, south, or backwards.
204
+ for (const c of connectors) {
205
+ const sPos = result.nodes[c.source]?.position;
206
+ const tPos = result.nodes[c.target]?.position;
207
+ const sDim = dims.get(c.source);
208
+ const tDim = dims.get(c.target);
209
+ if (!sPos || !tPos || !sDim || !tDim) continue;
210
+ result.connectors[c.id] = pickHandles(
211
+ { x: sPos.x, y: sPos.y, w: sDim.width, h: sDim.height },
212
+ { x: tPos.x, y: tPos.y, w: tDim.width, h: tDim.height },
213
+ );
214
+ }
215
+
216
+ return result;
217
+ };
package/src/mcp.ts CHANGED
@@ -98,7 +98,7 @@ const DemoNodeIdBaseSchema = z.object({
98
98
  });
99
99
 
100
100
  // add_node input: { flowId, node: <node payload> }. The inner `node` object is
101
- // loose here (additionalProperties=true via passthrough) because FlowSchema
101
+ // loose here (additionalProperties=true via passthrough) because ResolvedFlowSchema
102
102
  // runs the full validation server-side after the new node is merged in.
103
103
  const AddNodeInputSchema = z.object({
104
104
  flowId: z.string().min(1),
@@ -139,7 +139,7 @@ const PatchNodeInputSchema = NodePatchBodySchema.extend({
139
139
 
140
140
  // add_connector input: { flowId, connector: <connector payload> }. The inner
141
141
  // `connector` object is loose (additionalProperties=true via z.record) because
142
- // FlowSchema runs the full validation server-side after the new connector is
142
+ // ResolvedFlowSchema runs the full validation server-side after the new connector is
143
143
  // merged in (post-mutation parse catches dangling source/target refs and
144
144
  // kind-discriminator violations).
145
145
  const AddConnectorInputSchema = z.object({
@@ -174,25 +174,25 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
174
174
  {
175
175
  name: 'validate_seeflow',
176
176
  description:
177
- 'Validate an architecture.json (and optional style.json) against the ' +
178
- 'SeeFlow schemas. Stateless: no flow id, no file:// resolution, no ' +
179
- 'registry side-effects. Returns { ok: true } or { ok: false, issues }.',
177
+ 'Validate a flow.json (and optional style.json) against the SeeFlow ' +
178
+ 'schemas. Stateless: no flow id, no file:// resolution, no registry ' +
179
+ 'side-effects. Returns { ok: true } or { ok: false, issues }.',
180
180
  inputSchema: {
181
181
  type: 'object',
182
182
  properties: {
183
- architecture: { type: 'object' },
183
+ flow: { type: 'object' },
184
184
  style: { type: 'object' },
185
185
  },
186
- required: ['architecture'],
186
+ required: ['flow'],
187
187
  additionalProperties: false,
188
188
  },
189
189
  handler: async (args) => {
190
190
  const body = args as Record<string, unknown> | undefined;
191
- if (!body || !('architecture' in body)) {
192
- return errorResult('Body must include `architecture`');
191
+ if (!body || !('flow' in body)) {
192
+ return errorResult('Body must include `flow`');
193
193
  }
194
194
  const result = validateImpl({
195
- architecture: body.architecture,
195
+ flow: body.flow,
196
196
  style: body.style as unknown,
197
197
  });
198
198
  return okResult(result);
package/src/merge.ts CHANGED
@@ -1,19 +1,19 @@
1
- import type { Architecture, Flow, Style } from './schema.ts';
1
+ import type { Flow, ResolvedFlow, Style } from './schema.ts';
2
2
 
3
3
  /**
4
- * Merge architecture.json (semantic data) and the optional style.json
5
- * (presentation overrides) into the merged Flow shape consumed by the API,
6
- * the canvas, and the rest of the studio.
4
+ * Merge flow.json (semantic data) and the optional style.json (presentation
5
+ * overrides) into the merged ResolvedFlow shape consumed by the API, the
6
+ * canvas, and the rest of the studio.
7
7
  *
8
- * Style entries with no matching architecture id are silently dropped — the
9
- * write path strips dangling entries after delete, but a stale file on disk
8
+ * Style entries with no matching flow id are silently dropped — the write
9
+ * path strips dangling entries after delete, but a stale file on disk
10
10
  * shouldn't break the read path.
11
11
  */
12
- export function mergeArchitectureAndStyle(arch: Architecture, style: Style): Flow {
12
+ export function mergeFlowAndStyle(flow: Flow, style: Style): ResolvedFlow {
13
13
  const nodeStyles = style.nodes ?? {};
14
14
  const connectorStyles = style.connectors ?? {};
15
15
 
16
- const mergedNodes = arch.nodes.map((node) => {
16
+ const mergedNodes = flow.nodes.map((node) => {
17
17
  const s = nodeStyles[node.id] ?? {};
18
18
  const { position, ...visual } = s;
19
19
  return {
@@ -23,23 +23,23 @@ export function mergeArchitectureAndStyle(arch: Architecture, style: Style): Flo
23
23
  };
24
24
  });
25
25
 
26
- const mergedConnectors = arch.connectors.map((conn) => {
26
+ const mergedConnectors = flow.connectors.map((conn) => {
27
27
  const s = connectorStyles[conn.id] ?? {};
28
28
  return { ...conn, ...s };
29
29
  });
30
30
 
31
31
  return {
32
- version: arch.version,
33
- name: arch.name,
34
- ...(arch.resetAction ? { resetAction: arch.resetAction } : {}),
32
+ version: flow.version,
33
+ name: flow.name,
34
+ ...(flow.resetAction ? { resetAction: flow.resetAction } : {}),
35
35
  nodes: mergedNodes,
36
36
  connectors: mergedConnectors,
37
- } as Flow;
37
+ } as ResolvedFlow;
38
38
  }
39
39
 
40
- // Fields that live in a node's `data` block on architecture.json. Every other
41
- // data field is visual and routes to style.json.
42
- const NODE_DATA_ARCH_KEYS = new Set([
40
+ // Fields that live in a node's `data` block on flow.json. Every other data
41
+ // field is visual and routes to style.json.
42
+ const NODE_DATA_FLOW_KEYS = new Set([
43
43
  'name',
44
44
  'kind',
45
45
  'stateSource',
@@ -65,14 +65,13 @@ const NODE_STYLE_KEYS = new Set([
65
65
  'fontSize',
66
66
  'textColor',
67
67
  'cornerRadius',
68
- 'locked',
69
68
  'borderWidth',
70
69
  'color',
71
70
  'strokeWidth',
72
71
  'autoSize',
73
72
  ]);
74
73
 
75
- const CONNECTOR_ARCH_KEYS = new Set([
74
+ const CONNECTOR_FLOW_KEYS = new Set([
76
75
  'id',
77
76
  'source',
78
77
  'target',
@@ -100,28 +99,28 @@ const CONNECTOR_STYLE_KEYS = new Set([
100
99
  ]);
101
100
 
102
101
  /**
103
- * Split a merged Flow back into (architecture, style) for atomic write. The
104
- * inverse of mergeArchitectureAndStyle: position and every visual field on
105
- * each node moves to `style.nodes[id]`; handles, pins, and visual fields on
106
- * each connector move to `style.connectors[id]`. Architecture keeps every
107
- * semantic data field — the routing tables above are the source of truth.
102
+ * Split a merged ResolvedFlow back into (flow, style) for atomic write. The
103
+ * inverse of mergeFlowAndStyle: position and every visual field on each node
104
+ * moves to `style.nodes[id]`; handles, pins, and visual fields on each
105
+ * connector move to `style.connectors[id]`. Flow keeps every semantic data
106
+ * field — the routing tables above are the source of truth.
108
107
  *
109
108
  * Style entries that end up empty are omitted from the output so the file
110
109
  * stays compact (matches the design's "delete style.json when {}" rule).
111
110
  */
112
- export function splitFlow(flow: {
111
+ export function splitFlow(resolved: {
113
112
  version: number;
114
113
  name: string;
115
114
  resetAction?: unknown;
116
115
  nodes: Array<Record<string, unknown>>;
117
116
  connectors: Array<Record<string, unknown>>;
118
- }): { architecture: Record<string, unknown>; style: Record<string, unknown> } {
119
- const archNodes: Array<Record<string, unknown>> = [];
117
+ }): { flow: Record<string, unknown>; style: Record<string, unknown> } {
118
+ const flowNodes: Array<Record<string, unknown>> = [];
120
119
  const styleNodes: Record<string, Record<string, unknown>> = {};
121
120
 
122
- for (const node of flow.nodes) {
121
+ for (const node of resolved.nodes) {
123
122
  const id = node.id as string;
124
- const archNode: Record<string, unknown> = { id, type: node.type };
123
+ const flowNode: Record<string, unknown> = { id, type: node.type };
125
124
  const styleEntry: Record<string, unknown> = {};
126
125
 
127
126
  if (node.position && typeof node.position === 'object') {
@@ -129,62 +128,62 @@ export function splitFlow(flow: {
129
128
  }
130
129
 
131
130
  const data = (node.data ?? {}) as Record<string, unknown>;
132
- const archData: Record<string, unknown> = {};
131
+ const flowData: Record<string, unknown> = {};
133
132
  for (const [k, v] of Object.entries(data)) {
134
133
  if (v === undefined) continue;
135
- if (NODE_DATA_ARCH_KEYS.has(k)) {
136
- archData[k] = v;
134
+ if (NODE_DATA_FLOW_KEYS.has(k)) {
135
+ flowData[k] = v;
137
136
  } else if (NODE_STYLE_KEYS.has(k)) {
138
137
  styleEntry[k] = v;
139
138
  } else {
140
- // Unknown forward-compat key — keep on architecture side so the
141
- // schema's strict() will catch typos but extension is possible by
142
- // updating the routing tables here.
143
- archData[k] = v;
139
+ // Unknown forward-compat key — keep on flow side so the schema's
140
+ // strict() will catch typos but extension is possible by updating
141
+ // the routing tables here.
142
+ flowData[k] = v;
144
143
  }
145
144
  }
146
- archNode.data = archData;
147
- archNodes.push(archNode);
145
+ flowNode.data = flowData;
146
+ flowNodes.push(flowNode);
148
147
 
149
148
  if (Object.keys(styleEntry).length > 0) {
150
149
  styleNodes[id] = styleEntry;
151
150
  }
152
151
  }
153
152
 
154
- const archConnectors: Array<Record<string, unknown>> = [];
153
+ const flowConnectors: Array<Record<string, unknown>> = [];
155
154
  const styleConnectors: Record<string, Record<string, unknown>> = {};
156
155
 
157
- for (const conn of flow.connectors) {
156
+ for (const conn of resolved.connectors) {
158
157
  const id = conn.id as string;
159
- const archConn: Record<string, unknown> = {};
158
+ const flowConn: Record<string, unknown> = {};
160
159
  const styleEntry: Record<string, unknown> = {};
161
160
  for (const [k, v] of Object.entries(conn)) {
162
161
  if (v === undefined) continue;
163
- if (CONNECTOR_ARCH_KEYS.has(k)) {
164
- archConn[k] = v;
162
+ if (CONNECTOR_FLOW_KEYS.has(k)) {
163
+ flowConn[k] = v;
165
164
  } else if (CONNECTOR_STYLE_KEYS.has(k)) {
166
165
  styleEntry[k] = v;
167
166
  } else {
168
- archConn[k] = v;
167
+ flowConn[k] = v;
169
168
  }
170
169
  }
171
- archConnectors.push(archConn);
170
+ flowConnectors.push(flowConn);
172
171
  if (Object.keys(styleEntry).length > 0) {
173
172
  styleConnectors[id] = styleEntry;
174
173
  }
175
174
  }
176
175
 
177
- const architecture: Record<string, unknown> = {
178
- version: flow.version,
179
- name: flow.name,
180
- nodes: archNodes,
181
- connectors: archConnectors,
176
+ const flow: Record<string, unknown> = {
177
+ version: resolved.version,
178
+ name: resolved.name,
179
+ nodes: flowNodes,
180
+ connectors: flowConnectors,
182
181
  };
183
- if (flow.resetAction !== undefined) architecture.resetAction = flow.resetAction;
182
+ if (resolved.resetAction !== undefined) flow.resetAction = resolved.resetAction;
184
183
 
185
184
  const style: Record<string, unknown> = {};
186
185
  if (Object.keys(styleNodes).length > 0) style.nodes = styleNodes;
187
186
  if (Object.keys(styleConnectors).length > 0) style.connectors = styleConnectors;
188
187
 
189
- return { architecture, style };
188
+ return { flow, style };
190
189
  }