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 };
}
|