import type { Node, Edge } from "@xyflow/react"; import type { Service, Connection } from "../../shared/types"; export const NODE_WIDTH = 240; export const NODE_HEIGHT = 160; const NODE_GAP_X = 36; const NODE_GAP_Y = 36; export const GROUP_PADDING = 28; export 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 = { 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 = { 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(); 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[] ): LayoutResult { if (services.length === 0) return { nodes: [], edges: [] }; // Group services by project/compose_file (always consistent) const groups = new Map(); 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(); 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, }, }); }); groupX += groupWidth + GROUP_GAP; } return { nodes, edges: [] }; } // ── Recompute edges + activeHandles based on current node positions ── const EDGE_COLORS: Record = { database: "#336791", cache: "#F59E0B", broker: "#A855F7", proxy: "#22C55E", }; export function computeEdges( currentNodes: Node[], connections: Connection[] ): { edges: Edge[]; activeHandles: Map } { // Build absolute positions from current nodes const absPositions = new Map(); 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(); 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(); 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(); 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(); 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>(); 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(); for (const [id, set] of active) activeHandles.set(id, [...set]); return { edges, activeHandles }; }