From 952697b369c9cce10428afe1bc613b56f0a68440 Mon Sep 17 00:00:00 2001 From: Dominique Broeglin Date: Fri, 26 Jun 2026 12:34:02 +0200 Subject: [PATCH] Add architecture visualizer canvas Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../architecture-visualizer/extension.mjs | 1277 +++++++++++++++++ .../architecture-visualizer/graph-layout.mjs | 354 +++++ 2 files changed, 1631 insertions(+) create mode 100644 .github/extensions/architecture-visualizer/extension.mjs create mode 100644 .github/extensions/architecture-visualizer/graph-layout.mjs diff --git a/.github/extensions/architecture-visualizer/extension.mjs b/.github/extensions/architecture-visualizer/extension.mjs new file mode 100644 index 0000000..e239fe5 --- /dev/null +++ b/.github/extensions/architecture-visualizer/extension.mjs @@ -0,0 +1,1277 @@ +import { createServer } from "node:http"; +import { createCanvas, CanvasError, joinSession } from "@github/copilot-sdk/extension"; +import { layoutLayeredGraph } from "./graph-layout.mjs"; + +const COMPONENT_IDS = [ + "task-authoring", + "deepswe-import", + "scaffold-template", + "typer-cli", + "pier-backend", + "pier-job", + "pier-environment", + "pier-verifier", + "copilot-installed-agent", + "real-copilot-cli", + "native-session-logs", + "otel-atif", + "job-artifacts", + "session-analysis", + "summary-reporting", + "derived-index", + "legacy-runner", +]; + +const ARCHITECTURE = { + name: "copilot-experiments", + tagline: + "A Pier-first experiment harness for running GitHub Copilot CLI in sandboxed coding tasks and analyzing native Copilot session output.", + invariants: [ + "Pier jobs are canonical for new runs.", + "Copilot CLI remains the system under test; the package shells out to the real binary.", + "Native Copilot events.jsonl logs are primary for Copilot-specific metrics.", + "SQLite indexes are derived caches and can be rebuilt from artifacts.", + "Tests stay offline by using fixtures, config parsing, and mock invokers.", + "Secrets are injected at run time and kept out of persisted result config.", + ], + layers: [ + { + id: "authoring", + name: "Experiment authoring", + summary: "Tasks, imported corpora, and scaffolded repos define what to run.", + }, + { + id: "interface", + name: "CLI and adapters", + summary: "Typer commands normalize input, scaffold repos, and call Pier.", + }, + { + id: "execution", + name: "Pier execution", + summary: "Pier owns jobs, sandboxes, trial orchestration, and verification.", + }, + { + id: "agent", + name: "Copilot agent", + summary: "The installed Pier agent invokes the real GitHub Copilot CLI.", + }, + { + id: "capture", + name: "Telemetry capture", + summary: "Native session logs, OTel, ATIF, and trial artifacts are persisted.", + }, + { + id: "analysis", + name: "Analysis and reporting", + summary: "Session metrics, summaries, terminal rendering, and indexes are derived.", + }, + ], + nodes: [ + { + id: "task-authoring", + layer: "authoring", + title: "Harbor/Pier task directories", + subtitle: "task.toml, instruction.md, environment/, tests/", + x: 40, + y: 80, + files: ["tasks//", "experiments/*.yaml", "docs/authoring-experiments.md"], + details: + "Experiment authors describe tasks and Pier JobConfig YAML. Each task carries the instruction, environment, verifier tests, and optional solution artifacts.", + }, + { + id: "deepswe-import", + layer: "authoring", + title: "DeepSWE import", + subtitle: "Generate Pier jobs from large task corpora", + x: 40, + y: 210, + files: ["src/copilot_experiments/deepswe.py", "docs/deepswe.md"], + details: + "The import command converts DeepSWE-style inputs into Pier task/job structure so large benchmark protocols can use the same execution path.", + }, + { + id: "scaffold-template", + layer: "authoring", + title: "Experiment repo template", + subtitle: "Standalone repo bootstrap", + x: 40, + y: 340, + files: [ + "src/copilot_experiments/scaffold.py", + "src/copilot_experiments/templates/experiment_repo/", + ], + details: + "The init command renders package-data templates into a separate experiment repository with example task, job config, APM context, and docs.", + }, + { + id: "typer-cli", + layer: "interface", + title: "Typer CLI", + subtitle: "init, run, list, show, inspect, analyze, reindex", + x: 270, + y: 80, + files: ["src/copilot_experiments/cli.py", "README.md"], + details: + "The console entry point exposes the library as copilot-experiments. Commands dispatch to Pier-first paths and keep legacy behavior available when no Pier configs exist.", + }, + { + id: "pier-backend", + layer: "interface", + title: "Pier backend adapter", + subtitle: "Normalize JobConfig and inject runtime auth", + x: 270, + y: 240, + files: ["src/copilot_experiments/pier_backend.py"], + details: + "This adapter discovers Pier YAML/JSON configs, maps name: copilot-cli to the local installed-agent import path, redacts persisted secrets, and calls Pier's Python API.", + }, + { + id: "pier-job", + layer: "execution", + title: "pier.job.Job", + subtitle: "Trial orchestration and artifact transfer", + x: 500, + y: 80, + files: ["jobs//", "docs/results-format.md"], + details: + "Pier is the execution substrate. It creates job/trial directories, launches environments, runs installed agents, executes verifiers, and copies artifacts out.", + }, + { + id: "pier-environment", + layer: "execution", + title: "Pier environment", + subtitle: "Docker, Modal, Daytona", + x: 500, + y: 210, + files: ["tasks//environment/", "tests/test_pier_backend.py"], + details: + "Task environments provide the sandbox where the agent edits code and tests run. The harness delegates backend-specific isolation to Pier.", + }, + { + id: "pier-verifier", + layer: "execution", + title: "Pier verifier", + subtitle: "tests/test.sh -> reward.txt or reward.json", + x: 500, + y: 340, + files: ["tasks//tests/test.sh", "tests/test_pier_results.py"], + details: + "After an agent attempt, Pier runs task tests and writes reward signals. Reporting adapts those results into show/inspect summaries.", + }, + { + id: "copilot-installed-agent", + layer: "agent", + title: "Copilot CLI installed agent", + subtitle: "Pier BaseInstalledAgent wrapper", + x: 730, + y: 80, + files: ["src/copilot_experiments/pier_agents/copilot_cli.py"], + details: + "The local Pier agent installs or locates GitHub Copilot CLI, configures network allowlists and telemetry, passes model/effort/mode kwargs, and emits ATIF trajectory output.", + }, + { + id: "real-copilot-cli", + layer: "agent", + title: "Real GitHub Copilot CLI", + subtitle: "copilot -p --output-format json --session-id --log-dir", + x: 730, + y: 240, + files: ["src/copilot_experiments/invoker.py", "docs/collecting-run-data.md"], + details: + "Copilot itself is not reimplemented. Runs shell out to the actual CLI so sessions, tool calls, pricing events, and shutdown records match production behavior.", + }, + { + id: "native-session-logs", + layer: "capture", + title: "Native session logs", + subtitle: "copilot-session//events.jsonl", + x: 960, + y: 80, + files: ["src/copilot_experiments/sessionlog.py", "docs/analysis.md"], + details: + "The installed agent copies Copilot's session-state directory into Pier logs. sessionlog.py parses events.jsonl into metrics, including token and AIU economics.", + }, + { + id: "otel-atif", + layer: "capture", + title: "OTel + ATIF trajectory", + subtitle: "Per-call telemetry and cross-agent trace", + x: 960, + y: 210, + files: [ + "docs/collecting-run-data.md", + "src/copilot_experiments/pier_agents/copilot_cli.py", + ], + details: + "When no explicit OTLP destination is configured, Copilot OTel data is written to a local JSONL file. ATIF remains available for cross-agent compatibility and fallback metrics.", + }, + { + id: "job-artifacts", + layer: "capture", + title: "Pier job artifacts", + subtitle: "jobs///", + x: 960, + y: 340, + files: ["jobs//", "src/copilot_experiments/storage.py"], + details: + "Trial directories store verifier output, Copilot logs, trajectories, raw CLI output, and run metadata. storage.py locates canonical jobs plus legacy results.", + }, + { + id: "session-analysis", + layer: "analysis", + title: "Session analysis", + subtitle: "Tools, turns, tokens, AIU economics", + x: 1190, + y: 80, + files: [ + "src/copilot_experiments/analysis.py", + "src/copilot_experiments/render.py", + "src/copilot_experiments/pricing.py", + ], + details: + "analysis.py builds rendering-agnostic session views, pricing.py decomposes token economics, and render.py presents the rich terminal analyze command.", + }, + { + id: "summary-reporting", + layer: "analysis", + title: "Summary and inspection", + subtitle: "summary.json, summary.md, show, inspect", + x: 1190, + y: 240, + files: ["src/copilot_experiments/pier_results.py", "src/copilot_experiments/report.py"], + details: + "Pier job directories are adapted into the existing result shape and aggregated into human-readable and machine-readable summaries.", + }, + { + id: "derived-index", + layer: "analysis", + title: "Derived SQLite index", + subtitle: "results/index.db rebuilt from disk", + x: 1190, + y: 390, + files: ["src/copilot_experiments/index.py", "src/copilot_experiments/storage.py"], + details: + "The index is a rebuildable cache over canonical jobs and legacy results. reindex always reconstructs it from filesystem artifacts.", + }, + { + id: "legacy-runner", + layer: "execution", + title: "Legacy compatibility path", + subtitle: "Experiment x Variant x Task x Trial", + x: 500, + y: 510, + files: [ + "src/copilot_experiments/models.py", + "src/copilot_experiments/runner.py", + "src/copilot_experiments/workspace.py", + "src/copilot_experiments/invoker.py", + ], + details: + "When no Pier JobConfig exists, the original Python runner remains available. It provisions workspaces, invokes Copilot or MockInvoker, captures diffs, and writes legacy results.", + }, + ], + edges: [ + { + from: "task-authoring", + to: "typer-cli", + label: "run/show/analyze commands", + kind: "control", + }, + { + from: "deepswe-import", + to: "typer-cli", + label: "deepswe-import", + kind: "control", + }, + { + from: "scaffold-template", + to: "task-authoring", + label: "init creates", + kind: "artifact", + }, + { + from: "typer-cli", + to: "pier-backend", + label: "Pier config discovery", + kind: "control", + }, + { + from: "pier-backend", + to: "pier-job", + label: "normalized JobConfig", + kind: "control", + }, + { + from: "pier-job", + to: "pier-environment", + label: "launch sandbox", + kind: "control", + }, + { + from: "pier-job", + to: "copilot-installed-agent", + label: "run agent", + kind: "control", + }, + { + from: "copilot-installed-agent", + to: "real-copilot-cli", + label: "shell out", + kind: "control", + }, + { + from: "real-copilot-cli", + to: "native-session-logs", + label: "events.jsonl", + kind: "telemetry", + }, + { + from: "real-copilot-cli", + to: "otel-atif", + label: "raw output + OTel", + kind: "telemetry", + }, + { + from: "copilot-installed-agent", + to: "otel-atif", + label: "trajectory.json", + kind: "telemetry", + }, + { + from: "pier-job", + to: "pier-verifier", + label: "verify trial", + kind: "control", + }, + { + from: "pier-verifier", + to: "job-artifacts", + label: "reward output", + kind: "artifact", + }, + { + from: "native-session-logs", + to: "job-artifacts", + label: "persisted logs", + kind: "artifact", + }, + { + from: "otel-atif", + to: "job-artifacts", + label: "persisted traces", + kind: "artifact", + }, + { + from: "job-artifacts", + to: "session-analysis", + label: "events + OTel", + kind: "analysis", + }, + { + from: "native-session-logs", + to: "session-analysis", + label: "primary metrics", + kind: "analysis", + }, + { + from: "job-artifacts", + to: "summary-reporting", + label: "trial status", + kind: "analysis", + }, + { + from: "session-analysis", + to: "summary-reporting", + label: "analyze view", + kind: "analysis", + }, + { + from: "job-artifacts", + to: "derived-index", + label: "reindex scan", + kind: "analysis", + }, + { + from: "legacy-runner", + to: "job-artifacts", + label: "legacy results", + kind: "artifact", + }, + { + from: "legacy-runner", + to: "summary-reporting", + label: "compat summaries", + kind: "analysis", + }, + { + from: "legacy-runner", + to: "derived-index", + label: "legacy scan", + kind: "analysis", + }, + ], +}; + +const LAID_OUT_ARCHITECTURE = layoutLayeredGraph(ARCHITECTURE); + +const servers = new Map(); + +const openInputSchema = { + type: "object", + additionalProperties: false, + properties: { + focus: { + type: "string", + enum: COMPONENT_IDS, + description: "Optional component to select when the canvas opens.", + }, + }, +}; + +const focusActionSchema = { + type: "object", + additionalProperties: false, + required: ["component_id"], + properties: { + component_id: { + type: "string", + enum: COMPONENT_IDS, + description: "Component id to describe.", + }, + }, +}; + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function findNode(componentId) { + return LAID_OUT_ARCHITECTURE.nodes.find((node) => node.id === componentId); +} + +function componentContext(componentId) { + const component = findNode(componentId); + if (!component) { + throw new CanvasError("component_not_found", `Unknown architecture component: ${componentId}`); + } + + return { + component, + incoming: LAID_OUT_ARCHITECTURE.edges.filter((edge) => edge.to === componentId), + outgoing: LAID_OUT_ARCHITECTURE.edges.filter((edge) => edge.from === componentId), + }; +} + +function normalizeFocus(input) { + if (input && typeof input.focus === "string") { + return input.focus; + } + + return "pier-backend"; +} + +function writeResponse(res, statusCode, contentType, body) { + res.writeHead(statusCode, { + "Cache-Control": "no-store", + "Content-Type": contentType, + }); + res.end(body); +} + +function renderHtml(instanceId, focus) { + const data = JSON.stringify(LAID_OUT_ARCHITECTURE); + const initialFocus = JSON.stringify(focus); + const escapedInstanceId = escapeHtml(instanceId); + + return ` + + + + +copilot-experiments architecture + + + +
+
+
+
Architecture visualization
+

copilot-experiments

+

${escapeHtml(ARCHITECTURE.tagline)}

+
+
+ Canvas instance + ${escapedInstanceId} +
+
+ + + +
+
+
+
+ Control flow + Telemetry + Artifacts + Analysis +
+ Click any component to focus its dependencies and outputs. +
+
+ +
+
+ + +
+
+ + + +`; +} + +function handleRequest(req, res, instanceId, state) { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + + if (req.method !== "GET") { + writeResponse(res, 405, "text/plain; charset=utf-8", "Method not allowed"); + return; + } + + if (url.pathname === "/") { + writeResponse(res, 200, "text/html; charset=utf-8", renderHtml(instanceId, state.focus)); + return; + } + + if (url.pathname === "/architecture.json") { + writeResponse( + res, + 200, + "application/json; charset=utf-8", + JSON.stringify(LAID_OUT_ARCHITECTURE), + ); + return; + } + + writeResponse(res, 404, "text/plain; charset=utf-8", "Not found"); +} + +async function startServer(instanceId) { + const state = { focus: "pier-backend" }; + const server = createServer((req, res) => handleRequest(req, res, instanceId, state)); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + return { server, state, url: `http://127.0.0.1:${port}/` }; +} + +await joinSession({ + canvases: [ + createCanvas({ + id: "architecture-visualizer", + displayName: "Architecture Visualizer", + description: + "Interactive architecture diagram for the copilot-experiments application.", + inputSchema: openInputSchema, + actions: [ + { + name: "get_architecture", + description: "Return the architecture nodes, edges, layers, and invariants.", + handler: () => LAID_OUT_ARCHITECTURE, + }, + { + name: "focus_component", + description: + "Return details and connected flows for a specific architecture component.", + inputSchema: focusActionSchema, + handler: (ctx) => componentContext(ctx.input.component_id), + }, + ], + open: async (ctx) => { + let entry = servers.get(ctx.instanceId); + if (!entry) { + entry = await startServer(ctx.instanceId); + servers.set(ctx.instanceId, entry); + } + entry.state.focus = normalizeFocus(ctx.input); + + return { + title: "copilot-experiments architecture", + status: "Interactive module and data-flow visualization", + url: entry.url, + }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (entry) { + servers.delete(ctx.instanceId); + await new Promise((resolve) => entry.server.close(() => resolve())); + } + }, + }), + ], +}); diff --git a/.github/extensions/architecture-visualizer/graph-layout.mjs b/.github/extensions/architecture-visualizer/graph-layout.mjs new file mode 100644 index 0000000..db05d1b --- /dev/null +++ b/.github/extensions/architecture-visualizer/graph-layout.mjs @@ -0,0 +1,354 @@ +const DEFAULT_OPTIONS = { + nodeWidth: 204, + nodeHeight: 76, + rankGap: 40, + nodeGap: 28, + marginX: 30, + marginTop: 72, + marginBottom: 34, + lanePaddingX: 14, + lanePaddingY: 18, +}; + +function mean(values) { + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function roundedPolylinePath(points, radius = 12) { + if (points.length < 2) { + throw new Error("At least two points are required to route an edge"); + } + + const [start, ...rest] = points; + const commands = [`M ${start.x.toFixed(1)} ${start.y.toFixed(1)}`]; + + for (let index = 1; index < points.length - 1; index += 1) { + const previous = points[index - 1]; + const current = points[index]; + const next = points[index + 1]; + const previousLength = Math.hypot(current.x - previous.x, current.y - previous.y); + const nextLength = Math.hypot(next.x - current.x, next.y - current.y); + const cornerRadius = Math.min(radius, previousLength / 2, nextLength / 2); + + if (cornerRadius <= 0) { + commands.push(`L ${current.x.toFixed(1)} ${current.y.toFixed(1)}`); + continue; + } + + const beforeCorner = { + x: current.x - ((current.x - previous.x) / previousLength) * cornerRadius, + y: current.y - ((current.y - previous.y) / previousLength) * cornerRadius, + }; + const afterCorner = { + x: current.x + ((next.x - current.x) / nextLength) * cornerRadius, + y: current.y + ((next.y - current.y) / nextLength) * cornerRadius, + }; + + commands.push(`L ${beforeCorner.x.toFixed(1)} ${beforeCorner.y.toFixed(1)}`); + commands.push( + `Q ${current.x.toFixed(1)} ${current.y.toFixed(1)}, ${afterCorner.x.toFixed(1)} ${afterCorner.y.toFixed(1)}`, + ); + } + + const end = rest.at(-1); + commands.push(`L ${end.x.toFixed(1)} ${end.y.toFixed(1)}`); + return commands.join(" "); +} + +function groupedByRank(graph, layerRank) { + const ranks = graph.layers.map(() => []); + + graph.nodes.forEach((node, index) => { + const rank = layerRank.get(node.layer); + if (rank === undefined) { + throw new Error(`Node ${node.id} references unknown layer ${node.layer}`); + } + ranks[rank].push({ ...node, originalIndex: index, rank }); + }); + + return ranks.map((nodes) => nodes.map((node) => node.id)); +} + +function rankMetadata(graph, ranks) { + const nodeById = new Map( + graph.nodes.map((node, index) => [node.id, { ...node, originalIndex: index }]), + ); + const rankById = new Map(); + + ranks.forEach((rank, rankIndex) => { + rank.forEach((nodeId) => { + rankById.set(nodeId, rankIndex); + }); + }); + + return { nodeById, rankById }; +} + +function orderById(ranks) { + const order = new Map(); + + ranks.forEach((rank, rankIndex) => { + rank.forEach((nodeId, orderIndex) => { + order.set(nodeId, { + rank: rankIndex, + index: orderIndex, + normalized: (orderIndex + 0.5) / rank.length, + }); + }); + }); + + return order; +} + +function sortedRank(rank, graph, rankById, nodeById, order, direction) { + const scored = rank.map((nodeId, fallbackIndex) => { + const neighborScores = graph.edges + .filter((edge) => { + if (direction === "forward") { + return edge.to === nodeId && rankById.get(edge.from) < rankById.get(nodeId); + } + return edge.from === nodeId && rankById.get(edge.to) > rankById.get(nodeId); + }) + .map((edge) => { + const neighborId = direction === "forward" ? edge.from : edge.to; + return order.get(neighborId)?.normalized; + }) + .filter((score) => score !== undefined); + + return { + nodeId, + fallbackIndex, + originalIndex: nodeById.get(nodeId).originalIndex, + score: neighborScores.length ? mean(neighborScores) : undefined, + }; + }); + + scored.sort((left, right) => { + if (left.score !== undefined && right.score !== undefined && left.score !== right.score) { + return left.score - right.score; + } + if (left.score !== undefined && right.score === undefined) { + return -1; + } + if (left.score === undefined && right.score !== undefined) { + return 1; + } + return left.originalIndex - right.originalIndex || left.fallbackIndex - right.fallbackIndex; + }); + + return scored.map((item) => item.nodeId); +} + +function reduceCrossings(graph, initialRanks, metadata) { + const ranks = initialRanks.map((rank) => [...rank]); + + for (let iteration = 0; iteration < 8; iteration += 1) { + let order = orderById(ranks); + for (let rankIndex = 1; rankIndex < ranks.length; rankIndex += 1) { + ranks[rankIndex] = sortedRank( + ranks[rankIndex], + graph, + metadata.rankById, + metadata.nodeById, + order, + "forward", + ); + order = orderById(ranks); + } + + order = orderById(ranks); + for (let rankIndex = ranks.length - 2; rankIndex >= 0; rankIndex -= 1) { + ranks[rankIndex] = sortedRank( + ranks[rankIndex], + graph, + metadata.rankById, + metadata.nodeById, + order, + "backward", + ); + order = orderById(ranks); + } + } + + return ranks; +} + +function rankWidth(rank, options) { + return rank.length * options.nodeWidth + Math.max(0, rank.length - 1) * options.nodeGap; +} + +function assignNodeGeometry(graph, ranks, nodeById, options) { + const maxRankWidth = Math.max(...ranks.map((rank) => rankWidth(rank, options))); + const width = options.marginX * 2 + maxRankWidth; + const height = + options.marginTop + + graph.layers.length * options.nodeHeight + + Math.max(0, graph.layers.length - 1) * options.rankGap + + options.marginBottom; + + const nodes = []; + const layoutById = new Map(); + + ranks.forEach((rank, rankIndex) => { + const y = options.marginTop + rankIndex * (options.nodeHeight + options.rankGap); + const firstX = options.marginX + (maxRankWidth - rankWidth(rank, options)) / 2; + + rank.forEach((nodeId, orderIndex) => { + const node = { + ...nodeById.get(nodeId), + rank: rankIndex, + order: orderIndex, + x: firstX + orderIndex * (options.nodeWidth + options.nodeGap), + y, + width: options.nodeWidth, + height: options.nodeHeight, + }; + nodes.push(node); + layoutById.set(node.id, node); + }); + }); + + return { nodes, layoutById, width, height }; +} + +function portMap(edges, layoutById, direction) { + const grouped = new Map(); + for (const edge of edges) { + const key = direction === "outgoing" ? edge.from : edge.to; + if (!grouped.has(key)) { + grouped.set(key, []); + } + grouped.get(key).push(edge); + } + + const ports = new Map(); + for (const [nodeId, nodeEdges] of grouped.entries()) { + nodeEdges.sort((left, right) => { + const leftOther = layoutById.get(direction === "outgoing" ? left.to : left.from); + const rightOther = layoutById.get(direction === "outgoing" ? right.to : right.from); + return leftOther.x - rightOther.x || left.label.localeCompare(right.label); + }); + + const node = layoutById.get(nodeId); + nodeEdges.forEach((edge, index) => { + const portX = node.x + ((index + 1) * node.width) / (nodeEdges.length + 1); + ports.set(edge.from + "->" + edge.to + ":" + direction, portX); + }); + } + + return ports; +} + +function sameRankEdge(edge, index, from, to) { + const routeAbove = from.rank < 2; + const routeY = routeAbove ? from.y - 28 : from.y + from.height + 28; + const start = { + x: from.x + from.width / 2, + y: routeAbove ? from.y : from.y + from.height, + }; + const end = { + x: to.x + to.width / 2, + y: routeAbove ? to.y : to.y + to.height, + }; + const labelY = routeY + (routeAbove ? -4 : 12) + ((index % 2) * 8); + + return { + d: roundedPolylinePath([start, { x: start.x, y: routeY }, { x: end.x, y: routeY }, end]), + labelX: (start.x + end.x) / 2, + labelY, + }; +} + +function crossRankEdge(edge, index, from, to, outgoingPorts, incomingPorts) { + const key = edge.from + "->" + edge.to; + const forward = from.rank <= to.rank; + const start = { + x: outgoingPorts.get(key + ":outgoing") ?? from.x + from.width / 2, + y: forward ? from.y + from.height : from.y, + }; + const end = { + x: incomingPorts.get(key + ":incoming") ?? to.x + to.width / 2, + y: forward ? to.y : to.y + to.height, + }; + const midY = (start.y + end.y) / 2 + ((index % 5) - 2) * 4; + + return { + d: roundedPolylinePath([start, { x: start.x, y: midY }, { x: end.x, y: midY }, end]), + labelX: (start.x + end.x) / 2, + labelY: midY - 7, + }; +} + +function assignEdgeGeometry(graph, layoutById) { + const outgoingPorts = portMap(graph.edges, layoutById, "outgoing"); + const incomingPorts = portMap(graph.edges, layoutById, "incoming"); + + return graph.edges.map((edge, index) => { + const from = layoutById.get(edge.from); + const to = layoutById.get(edge.to); + const route = + from.rank === to.rank + ? sameRankEdge(edge, index, from, to) + : crossRankEdge(edge, index, from, to, outgoingPorts, incomingPorts); + + return { + ...edge, + d: route.d, + labelX: Number(route.labelX.toFixed(1)), + labelY: Number(route.labelY.toFixed(1)), + labelWidth: clamp(edge.label.length * 6.4 + 16, 54, 156), + }; + }); +} + +function laneGeometry(graph, options, width) { + return graph.layers.map((layer, rankIndex) => { + const y = + options.marginTop + + rankIndex * (options.nodeHeight + options.rankGap) - + options.lanePaddingY; + + return { + ...layer, + x: options.lanePaddingX, + y, + width: width - options.lanePaddingX * 2, + height: options.nodeHeight + options.lanePaddingY * 2, + labelX: options.lanePaddingX + 14, + labelY: y + 24, + }; + }); +} + +export function layoutLayeredGraph(graph, options = {}) { + const resolvedOptions = { ...DEFAULT_OPTIONS, ...options }; + const layerRank = new Map(graph.layers.map((layer, index) => [layer.id, index])); + const initialRanks = groupedByRank(graph, layerRank); + const metadata = rankMetadata(graph, initialRanks); + const ranks = reduceCrossings(graph, initialRanks, metadata); + const nodeGeometry = assignNodeGeometry( + graph, + ranks, + metadata.nodeById, + resolvedOptions, + ); + + return { + ...graph, + layout: { + width: nodeGeometry.width, + height: nodeGeometry.height, + nodeWidth: resolvedOptions.nodeWidth, + nodeHeight: resolvedOptions.nodeHeight, + algorithm: "layered-barycentric", + direction: "top-to-bottom", + }, + lanes: laneGeometry(graph, resolvedOptions, nodeGeometry.width), + nodes: nodeGeometry.nodes, + edges: assignEdgeGeometry(graph, nodeGeometry.layoutById), + }; +}