commits
tags
// Grafhjälpare. Formatet ÄR nod/kant-grafen som backend kör (rpa/graph_engine.py),
// så det finns ingen översättning kvar – bara normalisering, id-säkring, val av
// startnod och en enkel auto-layout.
import type { Flow, GraphEdge, Step, Subflow } from "./api";
import { uid } from "./ids";
export const Y_GAP = 230;
export const X_GAP = 380;
/** Ser till att noden har ett id. Muterar och returnerar. */
export function ensureNodeId(n: Step): Step {
if (!n.id) n.id = uid();
return n;
}
function normGraph(nodes: Step[] | undefined, edges: GraphEdge[] | undefined) {
const ns = (nodes ?? []).map((n) => ({ ...n, id: n.id ?? uid() }));
const known = new Set(ns.map((n) => n.id));
const es = (edges ?? [])
.filter((e) => known.has(e.source) && known.has(e.target))
.map((e) => ({ ...e, id: e.id ?? uid() }));
return { nodes: ns, edges: es };
}
let subCounter = 0;
function slug(name: string): string {
const s = (name || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
return s || `sub-${++subCounter}`;
}
/** Fyll i alla nycklar + säkra id på noder/kanter (även i subflöden). */
export function normalizeFlow(f: Partial<Flow> | null | undefined): Flow {
const { nodes, edges } = normGraph(f?.nodes, f?.edges);
const subflows: Subflow[] = (f?.subflows ?? []).map((sf) => {
const g = normGraph(sf.nodes, sf.edges);
const name = sf.name || sf.id || "Subflöde";
return { id: sf.id || slug(name), name, nodes: g.nodes, edges: g.edges, start: sf.start ?? null, inputs: sf.inputs ?? [] };
});
return {
format: "graph",
nodes,
edges,
start: f?.start ?? null,
settings: f?.settings ?? {},
layout: f?.layout ?? {},
inputs: f?.inputs ?? [],
input_sets: f?.input_sets ?? {},
subflows,
};
}
/** Startnoden: den (topp)nod som saknar inkommande kant, annars första noden. */
export function pickStart(nodes: Step[], edges: GraphEdge[]): string | null {
if (nodes.length === 0) return null;
const targeted = new Set(edges.map((e) => e.target));
const roots = nodes.filter((n) => !targeted.has(n.id!));
const pool = roots.length ? roots : nodes;
return [...pool].sort((a, b) => (a.position?.y ?? 0) - (b.position?.y ?? 0))[0].id ?? null;
}
/** Enkel lagerbaserad placering: BFS från startnoden, ett lager per djup uppifrån
* och ner. Noder som inte nås av någon kant staplas i en egen rad under. */
export function autoLayout(nodes: Step[], edges: GraphEdge[]): Record<string, { x: number; y: number }> {
const out = new Map<string, string[]>();
for (const e of edges) {
const arr = out.get(e.source) ?? [];
arr.push(e.target);
out.set(e.source, arr);
}
const start = pickStart(nodes, edges);
const depth = new Map<string, number>();
const queue: string[] = start ? [start] : [];
if (start) depth.set(start, 0);
while (queue.length) {
const id = queue.shift()!;
const d = depth.get(id)!;
for (const nxt of out.get(id) ?? []) {
if (!depth.has(nxt)) {
depth.set(nxt, d + 1);
queue.push(nxt);
}
}
}
const perDepth = new Map<number, number>();
const maxDepth = Math.max(0, ...[...depth.values()]);
const pos: Record<string, { x: number; y: number }> = {};
let looseCol = 0;
for (const n of nodes) {
const d = depth.get(n.id!);
if (d === undefined) {
pos[n.id!] = { x: looseCol++ * X_GAP, y: (maxDepth + 2) * Y_GAP };
continue;
}
// syskon på samma djup breddas i x-led, djupet växer nedåt i y-led
const col = perDepth.get(d) ?? 0;
perDepth.set(d, col + 1);
pos[n.id!] = { x: col * X_GAP, y: d * Y_GAP };
}
return pos;
}
/** Ge en uppsättning noder+kanter nya id (för att klistra in ett block). */
export function remapIds(
nodes: Step[],
edges: GraphEdge[],
): { nodes: Step[]; edges: GraphEdge[] } {
const map = new Map<string, string>();
const fresh = nodes.map((n) => {
const id = uid();
map.set(n.id!, id);
return { ...n, id };
});
const freshEdges = edges
.filter((e) => map.has(e.source) && map.has(e.target))
.map((e) => ({ ...e, id: uid(), source: map.get(e.source)!, target: map.get(e.target)! }));
return { nodes: fresh, edges: freshEdges };
}