File size: 7,073 Bytes
e38d2c0
 
e1e33d1
bc55b9c
e38d2c0
 
 
 
 
 
 
 
bc55b9c
e38d2c0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bc55b9c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1e33d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bc55b9c
 
 
 
e1e33d1
bc55b9c
e1e33d1
 
 
bc55b9c
b4ad384
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
import type { Pipeline, PipelineNode } from "./types";

export type LayoutMode = "circular" | "rows" | "grid";

export type LayoutResult = {
  positions: Record<string, { x: number; y: number }>;
  flatChildren: Record<string, PipelineNode>;
};

const RING_1_RADIUS = 460;
const RING_2_RADIUS = 230;

function computeCircularLayout(pipeline: Pipeline, expandedIds: Set<string>): LayoutResult {
  const top = pipeline.nodes.filter((n) => n.category !== "orchestration");
  const hub = pipeline.nodes.find((n) => n.category === "orchestration");
  const count = top.length;
  const positions: Record<string, { x: number; y: number }> = {};
  const flatChildren: Record<string, PipelineNode> = {};

  if (hub) positions[hub.id] = { x: 0, y: 0 };

  top.forEach((n, i) => {
    const angle = -Math.PI / 2 + i * ((2 * Math.PI) / count);
    positions[n.id] = {
      x: RING_1_RADIUS * Math.cos(angle),
      y: RING_1_RADIUS * Math.sin(angle),
    };

    if (expandedIds.has(n.id) && n.children?.length) {
      const childCount = n.children.length;
      const spread = Math.min(0.9, 0.28 * childCount);
      n.children.forEach((child, ci) => {
        flatChildren[child.id] = child;
        const childAngle =
          angle + (ci - (childCount - 1) / 2) * (spread / Math.max(childCount - 1, 1));
        positions[child.id] = {
          x: positions[n.id].x + RING_2_RADIUS * Math.cos(childAngle),
          y: positions[n.id].y + RING_2_RADIUS * Math.sin(childAngle),
        };
      });
    }
  });

  return { positions, flatChildren };
}

// Rows: nodes grouped into horizontal bands by pipeline order (top-down DAG),
// matching a layered "structural" view rather than a circular loop.
const ROW_ORDER: Record<string, number> = {
  vision: 0,
  asr: 0,
  llm: 1,
  tts: 2,
  keyframer: 3,
  avatar: 3,
  video: 4,
  orchestration: 5,
};

const ROW_HEIGHT = 190;
const COLUMN_WIDTH = 260;
const CHILD_ROW_HEIGHT = 110;

function computeRowsLayout(pipeline: Pipeline, expandedIds: Set<string>): LayoutResult {
  const positions: Record<string, { x: number; y: number }> = {};
  const flatChildren: Record<string, PipelineNode> = {};

  const rows = new Map<number, PipelineNode[]>();
  pipeline.nodes.forEach((n) => {
    const row = ROW_ORDER[n.category] ?? 0;
    if (!rows.has(row)) rows.set(row, []);
    rows.get(row)!.push(n);
  });

  const sortedRowIndices = Array.from(rows.keys()).sort((a, b) => a - b);

  sortedRowIndices.forEach((rowIndex) => {
    const nodesInRow = rows.get(rowIndex)!;
    const rowWidth = (nodesInRow.length - 1) * COLUMN_WIDTH;
    nodesInRow.forEach((n, i) => {
      const x = i * COLUMN_WIDTH - rowWidth / 2;
      const y = rowIndex * ROW_HEIGHT;
      positions[n.id] = { x, y };

      if (expandedIds.has(n.id) && n.children?.length) {
        const childCount = n.children.length;
        const childRowWidth = (childCount - 1) * (COLUMN_WIDTH * 0.7);
        n.children.forEach((child, ci) => {
          flatChildren[child.id] = child;
          positions[child.id] = {
            x: x + ci * (COLUMN_WIDTH * 0.7) - childRowWidth / 2,
            y: y + CHILD_ROW_HEIGHT,
          };
        });
      }
    });
  });

  return { positions, flatChildren };
}

// Grid: an even N-column grid with no hub/ring bias — used by the Test Bench,
// where nodes are picked/inspected individually rather than read as a flow.
// The Test Bench renders compact node cards (code's already been read on the
// main frontend), so its grid cells are noticeably tighter than the default.
const GRID_COLS = 3;
const GRID_CELL_W = 300;
const GRID_CELL_H = 230;
const GRID_COMPACT_COLS = 4;
const GRID_COMPACT_CELL_W = 190;
const GRID_COMPACT_CELL_H = 130;

function computeGridLayout(
  pipeline: Pipeline,
  expandedIds: Set<string>,
  compact = false,
): LayoutResult {
  const positions: Record<string, { x: number; y: number }> = {};
  const flatChildren: Record<string, PipelineNode> = {};
  const cols = compact ? GRID_COMPACT_COLS : GRID_COLS;
  const cellW = compact ? GRID_COMPACT_CELL_W : GRID_CELL_W;
  const cellH = compact ? GRID_COMPACT_CELL_H : GRID_CELL_H;

  pipeline.nodes.forEach((n, i) => {
    const col = i % cols;
    const row = Math.floor(i / cols);
    const x = col * cellW;
    const y = row * cellH;
    positions[n.id] = { x, y };

    if (expandedIds.has(n.id) && n.children?.length) {
      n.children.forEach((child, ci) => {
        flatChildren[child.id] = child;
        positions[child.id] = { x: x + (ci + 1) * 40, y: y + cellH * 0.7 };
      });
    }
  });

  return { positions, flatChildren };
}

export function computeLayout(
  pipeline: Pipeline,
  expandedIds: Set<string>,
  mode: LayoutMode = "circular",
  compactGrid = false,
): LayoutResult {
  if (mode === "rows") return computeRowsLayout(pipeline, expandedIds);
  if (mode === "grid") return computeGridLayout(pipeline, expandedIds, compactGrid);
  return computeCircularLayout(pipeline, expandedIds);
}

// Body-layer grouping: cluster nodes by functional layer — Senses (input),
// Brain (cognitive + serving), Face (generative output) — matching the
// biological-metaphor architecture (senses -> brain -> face), not literal
// anatomy. A distinct grouping mode from circular/rows: it repositions nodes
// without remounting the graph, so the transition can glide instead of
// re-landing.
export type BodyGroup = "senses" | "brain" | "face";

export const BODY_GROUP_OF: Record<string, BodyGroup> = {
  vision: "senses",
  asr: "senses",
  llm: "brain",
  orchestration: "brain",
  tts: "face",
  avatar: "face",
  video: "face",
  keyframer: "face",
};

export const BODY_GROUP_LABEL: Record<BodyGroup, string> = {
  senses: "THE SENSES",
  brain: "THE BRAIN",
  face: "THE FACE",
};

export const BODY_GROUP_COLOR: Record<BodyGroup, string> = {
  senses: "#38bdf8",
  brain: "#2dd4bf",
  face: "#fbbf24",
};

const BODY_GROUP_X: Record<BodyGroup, number> = {
  senses: -480,
  brain: 0,
  face: 480,
};

const BODY_GROUP_ORDER: BodyGroup[] = ["senses", "brain", "face"];
const BODY_NODE_V_GAP = 200;
const BODY_HEADER_MARGIN = 110;

export type BodyLayoutResult = {
  positions: Record<string, { x: number; y: number }>;
  groupHeaders: { group: BodyGroup; x: number; y: number }[];
};

export function computeBodyGroupLayout(pipeline: Pipeline): BodyLayoutResult {
  const positions: Record<string, { x: number; y: number }> = {};
  const buckets: Record<BodyGroup, string[]> = { senses: [], brain: [], face: [] };

  pipeline.nodes.forEach((n) => {
    const group = BODY_GROUP_OF[n.category] ?? "brain";
    buckets[group].push(n.id);
  });

  const groupHeaders: BodyLayoutResult["groupHeaders"] = [];

  BODY_GROUP_ORDER.forEach((group) => {
    const x = BODY_GROUP_X[group];
    const ids = buckets[group];
    const totalHeight = (ids.length - 1) * BODY_NODE_V_GAP;
    const topY = -totalHeight / 2;
    ids.forEach((id, i) => {
      positions[id] = { x, y: topY + i * BODY_NODE_V_GAP };
    });
    groupHeaders.push({ group, x, y: topY - BODY_HEADER_MARGIN });
  });

  return { positions, groupHeaders };
}