foxygit / RPA-Remisser Log in
commits tags

/webui/frontend/src/lib/graph.ts · 4.15 KB

raw
// 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 };
}