import { api } from "../api.js";
import { navigate } from "../router.js";
import { escapeHtml } from "../utils.js";
import { catalogUnavailable } from "./datasets.js";
export async function renderLineage(container, params = {}) {
container.innerHTML = `<div class="empty">loading…</div>`;
let data;
try {
const p = new URLSearchParams();
if (params.root) {
p.set("root", params.root);
p.set("depth", "8");
}
data = await api(`/v1/catalog/lineage?${p}`);
} catch (e) {
container.innerHTML = `<div class="empty">${
catalogUnavailable(e)
? "The Data Movement Catalog endpoints are not available on this server " +
"(faucet was built without the `catalog` feature)."
: escapeHtml(e.message)
}</div>`;
return;
}
container.innerHTML = `
<div class="page page-wide">
<div class="page-head">
<h1>Lineage${params.root ? " (rooted)" : ""}</h1>
<button class="btn-ghost" id="l-datasets">← Datasets</button>
${params.root ? `<button class="btn-ghost" id="l-all">Whole graph</button>` : ""}
</div>
<p class="lineage-hint">Click a dataset to highlight its connections · double-click to open it · scroll inside a box to read the full name.</p>
<div id="graph" class="lineage-graph"></div>
</div>`;
container.querySelector("#l-datasets").onclick = () => navigate("#/catalog");
const all = container.querySelector("#l-all");
if (all) all.onclick = () => navigate("#/lineage");
const graph = container.querySelector("#graph");
if (!data.edges.length) {
graph.innerHTML = `<div class="empty">No lineage edges recorded yet — run a pipeline first.</div>`;
return;
}
const laid = layout(data.edges);
const paint = () => {
graph.innerHTML = "";
graph.appendChild(buildSvg(laid, params.root, graph.clientWidth));
};
paint();
let rt;
const onResize = () => { clearTimeout(rt); rt = setTimeout(paint, 150); };
window.addEventListener("resize", onResize);
return () => { clearTimeout(rt); window.removeEventListener("resize", onResize); };
}
export function layout(edges) {
const nodes = new Map(); for (const e of edges) {
if (!nodes.has(e.src_id)) nodes.set(e.src_id, { id: e.src_id, uri: e.src_uri, layer: 0 });
if (!nodes.has(e.dst_id)) nodes.set(e.dst_id, { id: e.dst_id, uri: e.dst_uri, layer: 0 });
}
for (let i = 0; i < nodes.size; i++) {
let changed = false;
for (const e of edges) {
const s = nodes.get(e.src_id);
const d = nodes.get(e.dst_id);
if (d.layer < s.layer + 1 && s.layer + 1 <= nodes.size) {
d.layer = s.layer + 1;
changed = true;
}
}
if (!changed) break;
}
const byLayer = new Map();
for (const n of [...nodes.values()].sort((a, b) => a.uri.localeCompare(b.uri))) {
const rows = byLayer.get(n.layer) || [];
n.row = rows.length;
rows.push(n);
byLayer.set(n.layer, rows);
}
const preds = new Map();
const succs = new Map();
const push = (m, k, v) => {
const a = m.get(k) || [];
a.push(v);
m.set(k, a);
};
for (const e of edges) {
push(succs, e.src_id, e.dst_id);
push(preds, e.dst_id, e.src_id);
}
const maxLayer = Math.max(0, ...[...nodes.values()].map((n) => n.layer));
const median = (arr) => {
if (!arr.length) return -1; const s = arr.slice().sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
};
const reorder = (layer, neighbours) => {
const rows = byLayer.get(layer);
if (!rows || rows.length < 2) return;
const key = new Map();
rows.forEach((n, i) => {
const nb = (neighbours.get(n.id) || []).map((id) => nodes.get(id).row).filter((r) => r >= 0);
const md = median(nb);
key.set(n.id, md < 0 ? i : md);
});
rows.sort((a, b) => key.get(a.id) - key.get(b.id) || a.uri.localeCompare(b.uri));
rows.forEach((n, i) => (n.row = i));
};
for (let sweep = 0; sweep < 4; sweep++) {
for (let L = 1; L <= maxLayer; L++) reorder(L, preds);
for (let L = maxLayer - 1; L >= 0; L--) reorder(L, succs);
}
return { nodes, edges };
}
const NODE_H = 26; const GAP_X = 120;
const GAP_Y = 12; const FONT_PX = 11;
const CHAR_W = 6.7; const LABEL_PAD = 24; const MIN_W = 240;
const MAX_W = 620;
function buildSvg({ nodes, edges }, rootId, containerWidth = 1000) {
const ns = "http://www.w3.org/2000/svg";
const layers = Math.max(...[...nodes.values()].map((n) => n.layer)) + 1;
const rows = Math.max(...[...nodes.values()].map((n) => n.row)) + 1;
const avail = Math.max(560, containerWidth - 16); let NODE_W = Math.floor((avail - 32 - (layers - 1) * GAP_X) / layers);
NODE_W = Math.max(280, Math.min(NODE_W, 640));
const width = layers * NODE_W + (layers - 1) * GAP_X + 32;
const height = rows * (NODE_H + GAP_Y) + 32;
const svg = document.createElementNS(ns, "svg");
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
svg.setAttribute("width", width);
svg.setAttribute("height", height);
svg.setAttribute("class", "lineage-svg");
const x = (n) => 16 + n.layer * (NODE_W + GAP_X);
const y = (n) => 16 + n.row * (NODE_H + GAP_Y);
const neighbours = new Map(); const link = (a, b) => { (neighbours.get(a) || neighbours.set(a, new Set()).get(a)).add(b); };
const edgeEls = []; const nodeEls = new Map();
for (const e of edges) {
link(e.src_id, e.dst_id);
link(e.dst_id, e.src_id);
const s = nodes.get(e.src_id);
const d = nodes.get(e.dst_id);
const x1 = x(s) + NODE_W;
const y1 = y(s) + NODE_H / 2;
const x2 = x(d);
const y2 = y(d) + NODE_H / 2;
const mid = (x1 + x2) / 2;
const path = document.createElementNS(ns, "path");
path.setAttribute("d", `M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2} ${y2}`);
path.setAttribute("class", "lineage-edge");
const title = document.createElementNS(ns, "title");
title.textContent = `${e.pipeline} (row ${e.row}) — ${e.runs} run(s), ${e.last_records} row(s) last`;
path.appendChild(title);
svg.appendChild(path);
edgeEls.push({ el: path, src: e.src_id, dst: e.dst_id });
}
for (const n of nodes.values()) {
const g = document.createElementNS(ns, "g");
g.setAttribute("class", `lineage-node${n.id === rootId ? " lineage-root" : ""}`);
g.setAttribute("transform", `translate(${x(n)}, ${y(n)})`);
g.dataset.id = n.id;
g.style.cursor = "pointer";
const rect = document.createElementNS(ns, "rect");
rect.setAttribute("width", NODE_W);
rect.setAttribute("height", NODE_H);
rect.setAttribute("rx", 8);
const fo = document.createElementNS(ns, "foreignObject");
fo.setAttribute("x", 1);
fo.setAttribute("y", 1);
fo.setAttribute("width", NODE_W - 2);
fo.setAttribute("height", NODE_H - 2);
const div = document.createElementNS("http://www.w3.org/1999/xhtml", "div");
div.setAttribute("class", "lnode-label");
div.setAttribute("title", n.uri);
div.textContent = n.uri;
fo.appendChild(div);
const title = document.createElementNS(ns, "title");
title.textContent = `${n.uri}\n(click to highlight connections · double-click to open · scroll the box to read the full name)`;
g.appendChild(rect);
g.appendChild(fo);
g.appendChild(title);
svg.appendChild(g);
nodeEls.set(n.id, g);
}
let selected = null;
const clear = () => {
selected = null;
svg.classList.remove("has-selection");
for (const g of nodeEls.values()) g.classList.remove("is-selected", "is-neighbour");
for (const { el } of edgeEls) el.classList.remove("is-active");
};
const select = (id) => {
if (selected === id) { clear(); return; }
clear();
selected = id;
svg.classList.add("has-selection");
nodeEls.get(id)?.classList.add("is-selected");
for (const nb of neighbours.get(id) || []) nodeEls.get(nb)?.classList.add("is-neighbour");
for (const e of edgeEls) if (e.src === id || e.dst === id) el_active(e.el);
};
const el_active = (el) => el.classList.add("is-active");
for (const [id, g] of nodeEls) {
g.addEventListener("click", (ev) => { ev.stopPropagation(); select(id); });
g.addEventListener("dblclick", (ev) => { ev.stopPropagation(); navigate(`#/catalog/${id}`); });
}
svg.addEventListener("click", clear); return svg;
}
function shorten(s, max) {
return s.length <= max ? s : `…${s.slice(s.length - max + 1)}`;
}