mirror of
https://github.com/go-gitea/gitea.git
synced 2026-06-10 13:28:24 +00:00
- Fix workflow dependency graph overflow by making the graph container scrollable (no more clipped DAGs; addresses #37493). - Improve Actions job list readability by keeping durations fixed-width/right-aligned so long times don’t squeeze job names. - Make workflow graph layout more intuitive by vertically centering shorter columns to reduce misleading “looks like it depends on” alignments (addresses #37395). ### Screenshot <img width="966" height="439" src="https://github.com/user-attachments/assets/c180c5a2-4f56-4287-bcaa-f2735ba72949" /> <img width="949" height="559" src="https://github.com/user-attachments/assets/a383511d-a962-4920-b792-69f556847eff" /> Fixes #37493 Fixes #37395 --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
560 lines
20 KiB
TypeScript
560 lines
20 KiB
TypeScript
import type {ActionsJob, ActionsStatus} from '../modules/gitea-actions.ts';
|
||
|
||
export type GraphNodeType = 'job' | 'matrix' | 'group';
|
||
|
||
export type GraphNode = {
|
||
id: string;
|
||
type: GraphNodeType;
|
||
name: string;
|
||
status: ActionsStatus;
|
||
duration: string;
|
||
x: number;
|
||
y: number;
|
||
level: number;
|
||
displayHeight: number;
|
||
jobs: ActionsJob[];
|
||
matrixKey?: string;
|
||
};
|
||
|
||
export type Edge = {
|
||
fromId: string;
|
||
toId: string;
|
||
key: string;
|
||
};
|
||
|
||
export type RoutedEdge = Edge & {
|
||
path: string;
|
||
fromNode: GraphNode;
|
||
toNode: GraphNode;
|
||
};
|
||
|
||
export type SharedSegment = {
|
||
key: string;
|
||
edgeKeys: string[];
|
||
path: string;
|
||
};
|
||
|
||
export type GraphHighlightState = {
|
||
nodeIds: Set<string>;
|
||
edgeKeys: Set<string>;
|
||
};
|
||
|
||
export type WorkflowGraphLayoutOptions = {
|
||
margin: number;
|
||
nodeWidth: number;
|
||
nodeHeight: number;
|
||
columnGap: number;
|
||
laneGap: number;
|
||
groupRowHeight: number;
|
||
groupPadY: number;
|
||
matrixCollapsedHeight: number;
|
||
matrixHeaderHeight: number;
|
||
matrixRowHeight: number;
|
||
matrixPadY: number;
|
||
};
|
||
|
||
export type WorkflowGraphModel = {
|
||
nodes: GraphNode[];
|
||
edges: Edge[];
|
||
routedEdges: RoutedEdge[];
|
||
sharedSegments: SharedSegment[];
|
||
adjacency: NodeAdjacency;
|
||
};
|
||
|
||
export type NodeAdjacency = {
|
||
incomingByNodeId: Map<string, string[]>;
|
||
outgoingByNodeId: Map<string, string[]>;
|
||
};
|
||
|
||
const defaultLayoutOptions: WorkflowGraphLayoutOptions = {
|
||
margin: 24,
|
||
nodeWidth: 220,
|
||
nodeHeight: 40,
|
||
columnGap: 96,
|
||
laneGap: 32,
|
||
groupRowHeight: 28,
|
||
groupPadY: 8,
|
||
matrixCollapsedHeight: 78,
|
||
matrixHeaderHeight: 24,
|
||
matrixRowHeight: 26,
|
||
matrixPadY: 6,
|
||
};
|
||
|
||
function canonicalKey(ids: Iterable<string>): string {
|
||
return Array.from(ids).sort().join('');
|
||
}
|
||
|
||
function graphIdForJob(job: ActionsJob): string {
|
||
return `job:${job.id}`;
|
||
}
|
||
|
||
export function matrixKeyFromJobName(name: string): string | null {
|
||
const idx = name.indexOf(' (');
|
||
if (idx === -1) return null;
|
||
return name.slice(0, idx).trim() || null;
|
||
}
|
||
|
||
export function boxBottom(node: GraphNode): number {
|
||
return node.y + node.displayHeight;
|
||
}
|
||
|
||
export function boxCenterY(node: GraphNode): number {
|
||
return node.y + node.displayHeight / 2;
|
||
}
|
||
|
||
function matrixPanelHeight(rowCount: number, expanded: boolean, options: WorkflowGraphLayoutOptions): number {
|
||
if (rowCount <= 0) return options.nodeHeight;
|
||
if (!expanded) return options.matrixCollapsedHeight;
|
||
return options.matrixHeaderHeight + rowCount * options.matrixRowHeight + options.matrixPadY * 2;
|
||
}
|
||
|
||
function groupPanelHeight(rowCount: number, options: WorkflowGraphLayoutOptions): number {
|
||
return rowCount * options.groupRowHeight + options.groupPadY * 2;
|
||
}
|
||
|
||
function compareStatusWorstFirst(a: ActionsStatus, b: ActionsStatus): number {
|
||
const rank = (s: ActionsStatus) => {
|
||
if (s === 'failure') return 0;
|
||
if (s === 'cancelled') return 1;
|
||
if (s === 'running') return 2;
|
||
if (s === 'waiting') return 3;
|
||
if (s === 'blocked') return 4;
|
||
if (s === 'success') return 5;
|
||
if (s === 'skipped') return 6;
|
||
return 7;
|
||
};
|
||
return rank(a) - rank(b);
|
||
}
|
||
|
||
function aggregateStatus(children: ActionsJob[]): ActionsStatus {
|
||
return children.map((c) => c.status).slice().sort(compareStatusWorstFirst)[0] ?? 'unknown';
|
||
}
|
||
|
||
function buildDirectNeedsMap(jobs: ActionsJob[]): Map<string, string[]> {
|
||
const directNeedsByJobId = new Map<string, string[]>();
|
||
const dependentsByJobId = new Map<string, Set<string>>();
|
||
|
||
for (const job of jobs) {
|
||
const needs = job.needs || [];
|
||
directNeedsByJobId.set(job.jobId, needs);
|
||
for (const need of needs) {
|
||
if (!dependentsByJobId.has(need)) dependentsByJobId.set(need, new Set());
|
||
dependentsByJobId.get(need)!.add(job.jobId);
|
||
}
|
||
}
|
||
|
||
const reachabilityCache = new Map<string, boolean>();
|
||
function canReach(fromJobId: string, toJobId: string): boolean {
|
||
const cacheKey = `${fromJobId}->${toJobId}`;
|
||
if (reachabilityCache.has(cacheKey)) return reachabilityCache.get(cacheKey)!;
|
||
const visited = new Set<string>();
|
||
const stack = Array.from(dependentsByJobId.get(fromJobId) || []);
|
||
while (stack.length > 0) {
|
||
const current = stack.pop()!;
|
||
if (current === toJobId) {
|
||
reachabilityCache.set(cacheKey, true);
|
||
return true;
|
||
}
|
||
if (visited.has(current)) continue;
|
||
visited.add(current);
|
||
stack.push(...(dependentsByJobId.get(current) || []));
|
||
}
|
||
reachabilityCache.set(cacheKey, false);
|
||
return false;
|
||
}
|
||
|
||
const reducedNeedsByJobId = new Map<string, string[]>();
|
||
for (const [jobId, needs] of directNeedsByJobId) {
|
||
reducedNeedsByJobId.set(jobId, needs.filter((need) => {
|
||
return !needs.some((other) => other !== need && canReach(need, other));
|
||
}));
|
||
}
|
||
return reducedNeedsByJobId;
|
||
}
|
||
|
||
export function computeJobLevels(jobs: ActionsJob[]): Map<string, number> {
|
||
const jobMap = new Map<string, ActionsJob>();
|
||
for (const job of jobs) {
|
||
jobMap.set(job.name, job);
|
||
if (job.jobId) jobMap.set(job.jobId, job);
|
||
}
|
||
|
||
const levels = new Map<string, number>();
|
||
const visited = new Set<string>();
|
||
const recursionStack = new Set<string>();
|
||
|
||
function dfs(jobNameOrId: string): number {
|
||
if (recursionStack.has(jobNameOrId)) return 0;
|
||
if (visited.has(jobNameOrId)) return levels.get(jobNameOrId) ?? 0;
|
||
recursionStack.add(jobNameOrId);
|
||
visited.add(jobNameOrId);
|
||
|
||
const job = jobMap.get(jobNameOrId);
|
||
if (!job) {
|
||
recursionStack.delete(jobNameOrId);
|
||
return 0;
|
||
}
|
||
if (!job.needs?.length) {
|
||
levels.set(job.jobId, 0);
|
||
if (job.jobId !== job.name) levels.set(job.name, 0);
|
||
recursionStack.delete(jobNameOrId);
|
||
return 0;
|
||
}
|
||
|
||
let maxLevel = -1;
|
||
for (const need of job.needs) {
|
||
if (!jobMap.has(need)) continue;
|
||
maxLevel = Math.max(maxLevel, dfs(need));
|
||
}
|
||
const level = maxLevel + 1;
|
||
levels.set(job.name, level);
|
||
levels.set(job.jobId, level);
|
||
recursionStack.delete(jobNameOrId);
|
||
return level;
|
||
}
|
||
|
||
for (const job of jobs) {
|
||
if (!visited.has(job.jobId)) dfs(job.jobId);
|
||
}
|
||
return levels;
|
||
}
|
||
|
||
export function computeGraphHighlightState(hoveredId: string | null, adjacency: NodeAdjacency): GraphHighlightState {
|
||
if (!hoveredId) return {nodeIds: new Set(), edgeKeys: new Set()};
|
||
const {incomingByNodeId, outgoingByNodeId} = adjacency;
|
||
|
||
const edgeKeys = new Set<string>();
|
||
const collect = (startId: string, adj: Map<string, string[]>, edgeKeyForward: boolean): Set<string> => {
|
||
const seen = new Set<string>();
|
||
const queue = [startId];
|
||
while (queue.length > 0) {
|
||
const current = queue.shift()!;
|
||
if (seen.has(current)) continue;
|
||
seen.add(current);
|
||
for (const next of adj.get(current) || []) {
|
||
edgeKeys.add(edgeKeyForward ? `${current}->${next}` : `${next}->${current}`);
|
||
if (!seen.has(next)) queue.push(next);
|
||
}
|
||
}
|
||
return seen;
|
||
};
|
||
|
||
const ancestors = collect(hoveredId, incomingByNodeId, false);
|
||
const descendants = collect(hoveredId, outgoingByNodeId, true);
|
||
return {nodeIds: new Set([...ancestors, ...descendants]), edgeKeys};
|
||
}
|
||
|
||
type VisualGraphBuild = {
|
||
nodes: GraphNode[];
|
||
edges: Edge[];
|
||
};
|
||
|
||
function buildVisualGraph(
|
||
jobs: ActionsJob[],
|
||
expandedMatrixKeys: ReadonlySet<string>,
|
||
options: WorkflowGraphLayoutOptions,
|
||
): VisualGraphBuild {
|
||
const jobsByJobId = new Map<string, ActionsJob[]>();
|
||
const jobIndexById = new Map<number, number>();
|
||
for (const [index, job] of jobs.entries()) {
|
||
jobIndexById.set(job.id, index);
|
||
if (!jobsByJobId.has(job.jobId)) jobsByJobId.set(job.jobId, []);
|
||
jobsByJobId.get(job.jobId)!.push(job);
|
||
}
|
||
|
||
const matrixJobsByKey = new Map<string, ActionsJob[]>();
|
||
for (const job of jobs) {
|
||
// Reusable callers are distinct workflow files — never fold them into a matrix bucket
|
||
// even if their display name happens to look like "name (variant)".
|
||
if (job.isReusableCaller) continue;
|
||
const matrixKey = matrixKeyFromJobName(job.name);
|
||
if (!matrixKey) continue;
|
||
if (!matrixJobsByKey.has(matrixKey)) matrixJobsByKey.set(matrixKey, []);
|
||
matrixJobsByKey.get(matrixKey)!.push(job);
|
||
}
|
||
for (const list of matrixJobsByKey.values()) {
|
||
list.sort((a, b) => (jobIndexById.get(a.id) ?? 0) - (jobIndexById.get(b.id) ?? 0));
|
||
}
|
||
|
||
const directNeedsByJobId = buildDirectNeedsMap(jobs);
|
||
const rawLevels = computeJobLevels(jobs);
|
||
const dependentsByJobId = new Map<string, string[]>();
|
||
const rawEdges: Array<{from: ActionsJob; to: ActionsJob}> = [];
|
||
|
||
for (const job of jobs) {
|
||
for (const need of directNeedsByJobId.get(job.jobId) || []) {
|
||
for (const upstream of jobsByJobId.get(need) || []) {
|
||
rawEdges.push({from: upstream, to: job});
|
||
if (!dependentsByJobId.has(upstream.jobId)) dependentsByJobId.set(upstream.jobId, []);
|
||
dependentsByJobId.get(upstream.jobId)!.push(job.jobId);
|
||
}
|
||
}
|
||
}
|
||
for (const list of dependentsByJobId.values()) list.sort();
|
||
|
||
// Group sibling jobs that share an identical (parents, children) signature into a single
|
||
// collapsed "group" node. This is a visual aggregation only - the underlying jobs are
|
||
// preserved on the node so the panel can list them.
|
||
const groupedJobIds = new Map<number, string>();
|
||
const groupsById = new Map<string, ActionsJob[]>();
|
||
const groupCandidateBuckets = new Map<string, ActionsJob[]>();
|
||
for (const job of jobs) {
|
||
if (matrixKeyFromJobName(job.name)) continue;
|
||
// Reusable callers represent distinct workflow files — keep each as its own node so the
|
||
// graph mirrors GitHub Actions, where every caller shows up as its own box even when
|
||
// siblings share an identical (parents, children) dependency signature.
|
||
if (job.isReusableCaller) continue;
|
||
const needsKey = canonicalKey(directNeedsByJobId.get(job.jobId) || []);
|
||
const childrenKey = (dependentsByJobId.get(job.jobId) || []).join('');
|
||
if (!needsKey && !childrenKey) continue;
|
||
const level = rawLevels.get(job.jobId) ?? 0;
|
||
const key = `group:${level}:${needsKey}:${childrenKey}`;
|
||
if (!groupCandidateBuckets.has(key)) groupCandidateBuckets.set(key, []);
|
||
groupCandidateBuckets.get(key)!.push(job);
|
||
}
|
||
for (const [groupId, groupJobs] of groupCandidateBuckets) {
|
||
if (groupJobs.length < 2) continue;
|
||
groupJobs.sort((a, b) => (jobIndexById.get(a.id) ?? 0) - (jobIndexById.get(b.id) ?? 0));
|
||
groupsById.set(groupId, groupJobs);
|
||
for (const job of groupJobs) groupedJobIds.set(job.id, groupId);
|
||
}
|
||
|
||
const visualIdByJobId = new Map<number, string>();
|
||
for (const job of jobs) {
|
||
const matrixKey = matrixKeyFromJobName(job.name);
|
||
// Symmetric with the matrix-bucket loop above: a reusable caller whose display name
|
||
// happens to look like "name (variant)" must never be folded into the matrix node, or it
|
||
// would silently vanish (its visualId would point at a matrix node it isn't part of).
|
||
if (matrixKey && !job.isReusableCaller && (matrixJobsByKey.get(matrixKey)?.length ?? 0) > 1) {
|
||
visualIdByJobId.set(job.id, `matrix:${matrixKey}`);
|
||
continue;
|
||
}
|
||
visualIdByJobId.set(job.id, groupedJobIds.get(job.id) || graphIdForJob(job));
|
||
}
|
||
|
||
const emittedNodeIds = new Set<string>();
|
||
const nodes: GraphNode[] = [];
|
||
for (const job of jobs) {
|
||
const visualId = visualIdByJobId.get(job.id);
|
||
if (!visualId || emittedNodeIds.has(visualId)) continue;
|
||
emittedNodeIds.add(visualId);
|
||
|
||
const matrixKey = matrixKeyFromJobName(job.name);
|
||
if (matrixKey && visualId.startsWith('matrix:')) {
|
||
const matrixJobs = matrixJobsByKey.get(matrixKey) || [];
|
||
nodes.push({
|
||
id: visualId,
|
||
type: 'matrix',
|
||
name: matrixKey,
|
||
status: aggregateStatus(matrixJobs),
|
||
duration: '',
|
||
x: 0, y: 0, level: 0,
|
||
displayHeight: matrixPanelHeight(matrixJobs.length, expandedMatrixKeys.has(matrixKey), options),
|
||
jobs: matrixJobs,
|
||
matrixKey,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
const groupJobs = groupsById.get(visualId);
|
||
if (groupJobs) {
|
||
nodes.push({
|
||
id: visualId,
|
||
type: 'group',
|
||
name: groupJobs.map((g) => g.name).join(', '),
|
||
status: aggregateStatus(groupJobs),
|
||
duration: '',
|
||
x: 0, y: 0, level: 0,
|
||
displayHeight: groupPanelHeight(groupJobs.length, options),
|
||
jobs: groupJobs,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
nodes.push({
|
||
id: visualId,
|
||
type: 'job',
|
||
name: job.name,
|
||
status: job.status,
|
||
duration: job.duration,
|
||
x: 0, y: 0, level: 0,
|
||
displayHeight: options.nodeHeight,
|
||
jobs: [job],
|
||
});
|
||
}
|
||
|
||
const seenEdges = new Set<string>();
|
||
const edges: Edge[] = [];
|
||
for (const {from, to} of rawEdges) {
|
||
const fromId = visualIdByJobId.get(from.id);
|
||
const toId = visualIdByJobId.get(to.id);
|
||
if (!fromId || !toId || fromId === toId) continue;
|
||
const key = `${fromId}->${toId}`;
|
||
if (seenEdges.has(key)) continue;
|
||
seenEdges.add(key);
|
||
edges.push({fromId, toId, key});
|
||
}
|
||
|
||
return {nodes, edges};
|
||
}
|
||
|
||
function buildNodeAdjacency(edges: Edge[]): NodeAdjacency {
|
||
const incomingByNodeId = new Map<string, string[]>();
|
||
const outgoingByNodeId = new Map<string, string[]>();
|
||
for (const edge of edges) {
|
||
if (!incomingByNodeId.has(edge.toId)) incomingByNodeId.set(edge.toId, []);
|
||
incomingByNodeId.get(edge.toId)!.push(edge.fromId);
|
||
if (!outgoingByNodeId.has(edge.fromId)) outgoingByNodeId.set(edge.fromId, []);
|
||
outgoingByNodeId.get(edge.fromId)!.push(edge.toId);
|
||
}
|
||
return {incomingByNodeId, outgoingByNodeId};
|
||
}
|
||
|
||
function assignNodeLevels(nodes: GraphNode[], {incomingByNodeId}: NodeAdjacency): void {
|
||
const cache = new Map<string, number>();
|
||
function levelFor(id: string, visiting = new Set<string>()): number {
|
||
if (cache.has(id)) return cache.get(id)!;
|
||
if (visiting.has(id)) return 0;
|
||
visiting.add(id);
|
||
const incoming = incomingByNodeId.get(id) || [];
|
||
const level = incoming.length > 0 ?
|
||
Math.max(...incoming.map((fromId) => levelFor(fromId, visiting))) + 1 :
|
||
0;
|
||
visiting.delete(id);
|
||
cache.set(id, level);
|
||
return level;
|
||
}
|
||
for (const node of nodes) node.level = levelFor(node.id);
|
||
}
|
||
|
||
// Roots stay in input order; later levels are sorted by the mean parent Y so that simple
|
||
// chains stay on a straight horizontal line.
|
||
function assignNodeCoordinates(nodesById: Map<string, GraphNode>, nodes: GraphNode[], adjacency: NodeAdjacency, options: WorkflowGraphLayoutOptions): void {
|
||
const {incomingByNodeId} = adjacency;
|
||
const inputRank = (node: GraphNode): number => Math.min(...node.jobs.map((j) => j.id));
|
||
|
||
const nodesByLevel = new Map<number, GraphNode[]>();
|
||
for (const node of nodes) {
|
||
if (!nodesByLevel.has(node.level)) nodesByLevel.set(node.level, []);
|
||
nodesByLevel.get(node.level)!.push(node);
|
||
}
|
||
const orderedLevels = Array.from(nodesByLevel.keys()).sort((a, b) => a - b);
|
||
|
||
// Initial X assignment and a default Y so barycenters can use a finite value.
|
||
for (const level of orderedLevels) {
|
||
const list = nodesByLevel.get(level)!;
|
||
list.sort((a, b) => inputRank(a) - inputRank(b));
|
||
let yCursor = options.margin;
|
||
for (const node of list) {
|
||
node.x = options.margin + level * (options.nodeWidth + options.columnGap);
|
||
node.y = yCursor;
|
||
yCursor += node.displayHeight + options.laneGap;
|
||
}
|
||
}
|
||
|
||
function packLevel(level: number, anchorOf: (n: GraphNode) => number): void {
|
||
const list = nodesByLevel.get(level)!;
|
||
const sorted = Array.from(list).sort((a, b) => anchorOf(a) - anchorOf(b) || inputRank(a) - inputRank(b));
|
||
// Pack tight to top after sorting. Using barycenter only for order (not Y) keeps terminal
|
||
// nodes like build-image close to the top of their column instead of being pulled down to
|
||
// the mean Y of their parents — matching GitHub Actions' compact layout.
|
||
let prevBottom = options.margin - options.laneGap;
|
||
for (const node of sorted) {
|
||
node.y = prevBottom + options.laneGap;
|
||
prevBottom = boxBottom(node);
|
||
}
|
||
nodesByLevel.set(level, sorted);
|
||
}
|
||
|
||
function meanCenterOf(ids: string[]): number | null {
|
||
if (ids.length === 0) return null;
|
||
let sum = 0;
|
||
for (const id of ids) sum += boxCenterY(nodesById.get(id)!);
|
||
return sum / ids.length;
|
||
}
|
||
|
||
// Down-only barycenter pass: each child is anchored to the mean Y of its parents. Roots
|
||
// keep their initial yaml-declaration order (via inputRank), matching how GitHub Actions
|
||
// arranges root jobs. This produces a "main chain on top" layout where job-100 → job-101 →
|
||
// job-102 stays on a straight horizontal line.
|
||
for (const level of orderedLevels) {
|
||
if (level === 0) continue;
|
||
packLevel(level, (node) => meanCenterOf(incomingByNodeId.get(node.id) || []) ?? boxCenterY(node));
|
||
}
|
||
}
|
||
|
||
// Per-edge connector: source stub → cubic-bezier corner down/up to column midpoint →
|
||
// vertical run → cubic-bezier corner back to horizontal → target stub. The corner radius is
|
||
// fixed (not clamped to the row delta) so any two edges sharing the same source produce the
|
||
// same source-side path and overlap into a single visual line until they diverge at the V.
|
||
const cornerRadius = 12;
|
||
|
||
function connectorPath(sx: number, sy: number, ex: number, ey: number, options: WorkflowGraphLayoutOptions): string {
|
||
if (Math.abs(sy - ey) < 0.5) return `M ${sx} ${sy} H ${ex}`;
|
||
// Anchor the V segment in the column gap immediately before the target instead of the
|
||
// horizontal midpoint. The long H stays at the source's Y, matching GitHub Actions' style
|
||
// — a multi-column edge runs along the source row across intermediate columns, then turns
|
||
// up/down only when it reaches the target column.
|
||
const midX = Math.max(ex - options.columnGap / 2, (sx + ex) / 2);
|
||
const dy = ey > sy ? 1 : -1;
|
||
// Keep the same H prefix to `midX - cornerRadius` for every edge so that edges sharing a
|
||
// source overlap visually until they fork. When there isn't 2*cornerRadius of vertical
|
||
// room for the V segment, emit a single S-curve between (midX - r, sy) and (midX + r, ey)
|
||
// instead of a backward V kink.
|
||
if (Math.abs(ey - sy) < cornerRadius * 2) {
|
||
return [
|
||
`M ${sx} ${sy}`,
|
||
`H ${midX - cornerRadius}`,
|
||
`C ${midX} ${sy} ${midX} ${ey} ${midX + cornerRadius} ${ey}`,
|
||
`H ${ex}`,
|
||
].join(' ');
|
||
}
|
||
const half = cornerRadius / 2;
|
||
return [
|
||
`M ${sx} ${sy}`,
|
||
`H ${midX - cornerRadius}`,
|
||
`C ${midX - half} ${sy} ${midX} ${sy + half * dy} ${midX} ${sy + cornerRadius * dy}`,
|
||
`V ${ey - cornerRadius * dy}`,
|
||
`C ${midX} ${ey - half * dy} ${midX + half} ${ey} ${midX + cornerRadius} ${ey}`,
|
||
`H ${ex}`,
|
||
].join(' ');
|
||
}
|
||
|
||
function buildRoutedEdges(
|
||
nodesById: Map<string, GraphNode>,
|
||
edges: Edge[],
|
||
options: WorkflowGraphLayoutOptions,
|
||
): Pick<WorkflowGraphModel, 'routedEdges' | 'sharedSegments'> {
|
||
const routedEdges: RoutedEdge[] = [];
|
||
for (const edge of edges) {
|
||
const fromNode = nodesById.get(edge.fromId);
|
||
const toNode = nodesById.get(edge.toId);
|
||
if (!fromNode || !toNode) continue;
|
||
const startX = fromNode.x + options.nodeWidth;
|
||
const endX = toNode.x;
|
||
const startY = boxCenterY(fromNode);
|
||
const endY = boxCenterY(toNode);
|
||
routedEdges.push({...edge, fromNode, toNode, path: connectorPath(startX, startY, endX, endY, options)});
|
||
}
|
||
|
||
return {routedEdges, sharedSegments: []};
|
||
}
|
||
|
||
export function createWorkflowGraphModel(
|
||
jobs: ActionsJob[],
|
||
expandedMatrixKeys: ReadonlySet<string> = new Set(),
|
||
partialOptions: Partial<WorkflowGraphLayoutOptions> = {},
|
||
): WorkflowGraphModel {
|
||
const options = {...defaultLayoutOptions, ...partialOptions};
|
||
const {nodes, edges} = buildVisualGraph(jobs, expandedMatrixKeys, options);
|
||
const nodesById = new Map(nodes.map((n) => [n.id, n]));
|
||
const adjacency = buildNodeAdjacency(edges);
|
||
assignNodeLevels(nodes, adjacency);
|
||
assignNodeCoordinates(nodesById, nodes, adjacency, options);
|
||
return {nodes, edges, ...buildRoutedEdges(nodesById, edges, options), adjacency};
|
||
}
|
||
|
||
export function getWorkflowGraphLayoutOptions(partialOptions: Partial<WorkflowGraphLayoutOptions> = {}): WorkflowGraphLayoutOptions {
|
||
return {...defaultLayoutOptions, ...partialOptions};
|
||
}
|