Files
ContainerFlow/src/client/engine/layout.ts
T
2026-03-22 09:53:08 +00:00

359 lines
12 KiB
TypeScript

import type { Node, Edge } from "@xyflow/react";
import type { Service, Connection, Stats } from "../../shared/types";
const NODE_WIDTH = 240;
const NODE_HEIGHT = 160;
const NODE_GAP_X = 36;
const NODE_GAP_Y = 36;
const GROUP_PADDING = 28;
const GROUP_HEADER = 44;
const GROUP_GAP = 50;
function getComposeKey(file: string): string {
if (!file) return "default";
const match = file.match(/docker-compose\.?(.*)\.yml/);
const key = match?.[1] || "";
if (key === "") return "prod";
return key.replace(/^\./, "");
}
function getGroupKey(service: Service): string {
const compose = getComposeKey(service.compose_file);
return `${service.project}/${compose}`;
}
function getGroupLabel(key: string): string {
// "ninjasagacw/infra" → "NINJASAGACW / INFRA"
const parts = key.split("/");
return parts.map((p) => p.toUpperCase()).join(" / ");
}
// Color per group for visual distinction
const GROUP_COLORS: Record<string, string> = {
infra: "rgba(239, 68, 68, 0.08)",
dev: "rgba(59, 130, 246, 0.08)",
prod: "rgba(34, 197, 94, 0.08)",
};
const GROUP_BORDER_COLORS: Record<string, string> = {
infra: "rgba(239, 68, 68, 0.3)",
dev: "rgba(59, 130, 246, 0.3)",
prod: "rgba(34, 197, 94, 0.3)",
};
// Dynamic colors for project groups not matching known names
const DYNAMIC_COLORS = [
{ bg: "rgba(139, 92, 246, 0.08)", border: "rgba(139, 92, 246, 0.3)" },
{ bg: "rgba(6, 182, 212, 0.08)", border: "rgba(6, 182, 212, 0.3)" },
{ bg: "rgba(245, 158, 11, 0.08)", border: "rgba(245, 158, 11, 0.3)" },
{ bg: "rgba(236, 72, 153, 0.08)", border: "rgba(236, 72, 153, 0.3)" },
{ bg: "rgba(16, 185, 129, 0.08)", border: "rgba(16, 185, 129, 0.3)" },
];
let dynamicIdx = 0;
const dynamicAssigned = new Map<string, (typeof DYNAMIC_COLORS)[0]>();
function getDynamicColor(key: string) {
if (!dynamicAssigned.has(key)) {
dynamicAssigned.set(key, DYNAMIC_COLORS[dynamicIdx % DYNAMIC_COLORS.length]!);
dynamicIdx++;
}
return dynamicAssigned.get(key)!;
}
export interface LayoutResult {
nodes: Node[];
edges: Edge[];
}
export function buildLayout(
services: Service[],
connections: Connection[],
statsMap: Map<string, Stats>
): LayoutResult {
if (services.length === 0) return { nodes: [], edges: [] };
// Group services by project/compose_file (always consistent)
const groups = new Map<string, Service[]>();
for (const svc of services) {
const key = getGroupKey(svc);
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(svc);
}
const nodes: Node[] = [];
// Layout groups side by side
const MAX_COLS_PER_GROUP = 3;
let groupX = 0;
let maxGroupHeight = 0;
let groupRow = 0;
const groupPositions = new Map<string, { x: number; y: number; width: number; height: number }>();
const groupEntries = Array.from(groups.entries());
// Sort: infra groups first, then dev, then prod, then others
const COMPOSE_ORDER = ["infra", "dev", "prod"];
groupEntries.sort((a, b) => {
// Sort by project first, then by compose key order
const [projA, compA] = a[0].split("/");
const [projB, compB] = b[0].split("/");
if (projA !== projB) return projA.localeCompare(projB);
const ai = COMPOSE_ORDER.indexOf(compA);
const bi = COMPOSE_ORDER.indexOf(compB);
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
});
for (const [groupKey, svcs] of groupEntries) {
const cols = Math.min(svcs.length, MAX_COLS_PER_GROUP);
const rows = Math.ceil(svcs.length / cols);
const contentWidth = cols * (NODE_WIDTH + NODE_GAP_X) - NODE_GAP_X;
const contentHeight = rows * (NODE_HEIGHT + NODE_GAP_Y) - NODE_GAP_Y;
const groupWidth = Math.max(contentWidth + GROUP_PADDING * 2, NODE_WIDTH + GROUP_PADDING * 3);
const groupHeight = contentHeight + GROUP_PADDING * 2 + GROUP_HEADER + GROUP_PADDING;
groupPositions.set(groupKey, { x: groupX, y: 0, width: groupWidth, height: groupHeight });
if (groupHeight > maxGroupHeight) maxGroupHeight = groupHeight;
// Color based on compose part (infra/dev/prod), not full key
const composePart = groupKey.split("/")[1] || groupKey;
const knownBg = GROUP_COLORS[composePart];
const knownBorder = GROUP_BORDER_COLORS[composePart];
const dynamic = !knownBg ? getDynamicColor(groupKey) : null;
const bgColor = knownBg || dynamic!.bg;
const borderColor = knownBorder || dynamic!.border;
// Compose file subtitle — show unique compose files in this group
const composeFiles = [...new Set(svcs.map((s) => s.compose_file).filter(Boolean))]
.map((f) => f.split("/").pop() || "")
.filter(Boolean);
const subtitle = composeFiles.join(", ");
// Group node
nodes.push({
id: `group-${groupKey}`,
type: "group",
position: { x: groupX, y: 0 },
data: { label: getGroupLabel(groupKey), subtitle, count: svcs.length },
style: {
width: groupWidth,
height: groupHeight,
border: `1px dashed ${borderColor}`,
borderRadius: 16,
background: bgColor,
padding: 0,
fontSize: 13,
fontWeight: 600,
color: borderColor.replace("0.3", "0.8"),
},
});
// Service nodes inside group (grid layout)
svcs.forEach((svc, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const x = GROUP_PADDING + col * (NODE_WIDTH + NODE_GAP_X);
const y = GROUP_HEADER + GROUP_PADDING + row * (NODE_HEIGHT + NODE_GAP_Y);
nodes.push({
id: svc.uid,
type: "service",
parentId: `group-${groupKey}`,
position: { x, y },
data: {
...svc,
label: svc.name,
stats: statsMap.get(svc.uid) || null,
},
});
});
groupX += groupWidth + GROUP_GAP;
}
return { nodes, edges: [] };
}
// ── Recompute edges + activeHandles based on current node positions ──
const EDGE_COLORS: Record<string, string> = {
database: "#336791",
cache: "#F59E0B",
broker: "#A855F7",
proxy: "#22C55E",
};
export function computeEdges(
currentNodes: Node[],
connections: Connection[]
): { edges: Edge[]; activeHandles: Map<string, string[]> } {
// Build absolute positions from current nodes
const absPositions = new Map<string, { x: number; y: number }>();
for (const n of currentNodes) {
if (n.parentId) {
const parent = currentNodes.find((p) => p.id === n.parentId);
if (parent) {
absPositions.set(n.id, {
x: parent.position.x + n.position.x + NODE_WIDTH / 2,
y: parent.position.y + n.position.y + NODE_HEIGHT / 2,
});
}
}
}
function bestSide(fromId: string, toId: string): { sourceSide: string; targetSide: string } {
const from = absPositions.get(fromId);
const to = absPositions.get(toId);
if (!from || !to) return { sourceSide: "bottom", targetSide: "top" };
const dx = to.x - from.x;
const dy = to.y - from.y;
if (Math.abs(dx) > Math.abs(dy)) {
return dx > 0
? { sourceSide: "right", targetSide: "left" }
: { sourceSide: "left", targetSide: "right" };
} else {
return dy > 0
? { sourceSide: "bottom", targetSide: "top" }
: { sourceSide: "top", targetSide: "bottom" };
}
}
const nodeIds = new Set(currentNodes.map((n) => n.id));
const validConnections = connections.filter((c) => nodeIds.has(c.from) && nodeIds.has(c.to));
// Group ALL connections (source + target) by node+side so edges on the
// same side never share the same handle slot, regardless of direction.
interface SlotEntry { conn: Connection; nodeId: string; side: string; role: "source" | "target" }
const nodeSlotGroups = new Map<string, SlotEntry[]>();
for (const c of validConnections) {
const { sourceSide, targetSide } = bestSide(c.from, c.to);
// Both source and target go into the SAME group per node+side
const srcKey = `${c.from}:${sourceSide}`;
if (!nodeSlotGroups.has(srcKey)) nodeSlotGroups.set(srcKey, []);
nodeSlotGroups.get(srcKey)!.push({ conn: c, nodeId: c.from, side: sourceSide, role: "source" });
const tgtKey = `${c.to}:${targetSide}`;
if (!nodeSlotGroups.has(tgtKey)) nodeSlotGroups.set(tgtKey, []);
nodeSlotGroups.get(tgtKey)!.push({ conn: c, nodeId: c.to, side: targetSide, role: "target" });
}
// Sort each group by position of the other node so slots align spatially
const assignedSlots = new Map<string, number>();
for (const [groupKey, entries] of nodeSlotGroups) {
const side = groupKey.split(":")[1]; // "left", "right", "top", "bottom"
// For left/right sides, sort by Y of the other node (top→bottom = slot 0→2)
// For top/bottom sides, sort by X of the other node (left→right = slot 0→2)
entries.sort((a, b) => {
const otherA = absPositions.get(a.role === "source" ? a.conn.to : a.conn.from);
const otherB = absPositions.get(b.role === "source" ? b.conn.to : b.conn.from);
if (!otherA || !otherB) return 0;
if (side === "left" || side === "right") return otherA.y - otherB.y;
return otherA.x - otherB.x;
});
// If only 1 edge on this side, use middle slot (1)
// If 2, use slots 0 and 2. If 3, use 0, 1, 2
const slotMap: number[][] = [
[1], // 1 edge → middle
[0, 2], // 2 edges → top/left and bottom/right
[0, 1, 2], // 3 edges → all
];
const slots = slotMap[Math.min(entries.length, 3) - 1];
entries.forEach((entry, i) => {
const slot = slots[Math.min(i, slots.length - 1)];
const key = `${entry.conn.from}-${entry.conn.to}:${entry.nodeId}:${entry.role}`;
assignedSlots.set(key, slot);
});
}
// Build edges first, then compute corridor offsets
interface EdgeInfo {
conn: Connection;
sourceSide: string;
targetSide: string;
srcSlot: number;
tgtSlot: number;
}
const edgeInfos: EdgeInfo[] = validConnections.map((c) => {
const { sourceSide, targetSide } = bestSide(c.from, c.to);
const srcSlot = assignedSlots.get(`${c.from}-${c.to}:${c.from}:source`) ?? 1;
const tgtSlot = assignedSlots.get(`${c.from}-${c.to}:${c.to}:target`) ?? 1;
return { conn: c, sourceSide, targetSide, srcSlot, tgtSlot };
});
// Assign unique offsets per edge so smoothstep turns don't overlap
// Each edge from same node+side gets a different turn distance
const sideGroups = new Map<string, EdgeInfo[]>();
for (const ei of edgeInfos) {
const srcKey = `${ei.conn.from}:${ei.sourceSide}`;
if (!sideGroups.has(srcKey)) sideGroups.set(srcKey, []);
sideGroups.get(srcKey)!.push(ei);
const tgtKey = `${ei.conn.to}:${ei.targetSide}`;
if (!sideGroups.has(tgtKey)) sideGroups.set(tgtKey, []);
sideGroups.get(tgtKey)!.push(ei);
}
const edgeOffsets = new Map<string, number>();
const BASE_OFFSET = 10;
const OFFSET_STEP = 15;
for (const [, group] of sideGroups) {
if (group.length <= 1) continue;
group.forEach((ei, i) => {
const eid = `${ei.conn.from}-${ei.conn.to}`;
// Always positive: BASE + incremental step (never goes into node)
const newOffset = BASE_OFFSET + i * OFFSET_STEP;
const existing = edgeOffsets.get(eid);
if (existing === undefined || newOffset > existing) {
edgeOffsets.set(eid, newOffset);
}
});
}
const active = new Map<string, Set<string>>();
const edges: Edge[] = edgeInfos.map((ei) => {
const c = ei.conn;
const color = EDGE_COLORS[c.type || ""] || "#475569";
const sourceHandle = `${ei.sourceSide}-${ei.srcSlot}`;
const targetHandle = `${ei.targetSide}-${ei.tgtSlot}-target`;
if (!active.has(c.from)) active.set(c.from, new Set());
if (!active.has(c.to)) active.set(c.to, new Set());
active.get(c.from)!.add(sourceHandle);
active.get(c.to)!.add(`${ei.targetSide}-${ei.tgtSlot}`);
const offset = edgeOffsets.get(`${c.from}-${c.to}`) ?? 0;
return {
id: `${c.from}-${c.to}`,
source: c.from,
target: c.to,
sourceHandle,
targetHandle,
label: c.label || "",
type: "offsetSmooth",
animated: false,
data: { offset },
style: { stroke: color, strokeWidth: 2, opacity: 0.7 },
labelStyle: { fill: color, fontSize: 11, fontWeight: 500 },
labelBgStyle: { fill: "#0f172a", fillOpacity: 1 },
labelBgPadding: [6, 3] as [number, number],
labelBgBorderRadius: 4,
};
});
const activeHandles = new Map<string, string[]>();
for (const [id, set] of active) activeHandles.set(id, [...set]);
return { edges, activeHandles };
}