mirror of
https://github.com/go-gitea/gitea
synced 2026-06-11 21:23:09 +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};
|
|
}
|