Spaces:
Paused
Paused
File size: 6,149 Bytes
8ffc9f7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 | import type { SpecDataProcessing, DagNode } from "@/lib/types";
/**
* Renders a data-processing pipeline as a DAG: stages are columns (left→right), nodes are boxes stacked
* within their stage, and dependency edges are SVG connectors. Node order carries no meaning — the edges do.
* Edge kinds are color-coded (sequential/branch/fan_in); per-cell vs bulk scope and viz-only terminals are
* styled distinctly.
*/
const COLW = 190;
const ROWH = 64;
const NODEW = 168;
const NODEH = 48;
const PADX = 4;
const HEADER = 22;
const GUTTER = COLW - NODEW; // empty lane between two node columns
const EDGE_COLOR: Record<string, string> = {
sequential: "var(--color-border)",
branch: "#f59e0b",
fan_in: "#0ea5e9",
};
/**
* Orthogonal, gutter-routed connector from the right border of `a` to the left border of `b`, with
* lightly rounded corners. Anchors sit on the box borders (never inside a box); vertical travel runs
* in a column gutter rather than diagonally across the boxes between the two nodes. Any horizontal
* segment that still grazes an intervening box is occluded by the opaque node layer drawn on top.
*/
function edgePath(a: { x: number; y: number }, b: { x: number; y: number }): string {
const sx = a.x + NODEW; // exit at the source's right border
const sy = a.y + NODEH / 2;
const tx = b.x; // enter at the target's left border
const ty = b.y + NODEH / 2;
if (Math.abs(ty - sy) < 1) return `M ${sx} ${sy} L ${tx} ${ty}`; // same row → straight
// vertical lane: in the gutter just before the target column for forward edges, just past the
// source for same-column / backward edges — always a clear gutter, never over a box.
const midx =
tx - sx > GUTTER ? tx - GUTTER / 2 : tx > sx ? (sx + tx) / 2 : sx + GUTTER / 2;
const v = ty > sy ? 1 : -1;
const h1 = midx > sx ? 1 : -1;
const h2 = tx > midx ? 1 : -1;
const r = Math.min(
6,
Math.abs(ty - sy) / 2,
Math.abs(midx - sx) || 6,
Math.abs(tx - midx) || 6,
);
return [
`M ${sx} ${sy}`,
`L ${midx - h1 * r} ${sy}`,
`Q ${midx} ${sy} ${midx} ${sy + v * r}`,
`L ${midx} ${ty - v * r}`,
`Q ${midx} ${ty} ${midx + h2 * r} ${ty}`,
`L ${tx} ${ty}`,
].join(" ");
}
export function DataProcessingDag({ dp }: { dp: SpecDataProcessing }) {
const nodes = dp.nodes ?? [];
const edges = dp.edges ?? [];
if (nodes.length === 0) return null;
// stage ids in declared order, else discovered order
const stageDefs = dp.stages ?? [];
const declared = stageDefs.map((s) => s.id);
const discovered = Array.from(new Set(nodes.map((n) => n.stage ?? "_")));
const stageIds = declared.length ? declared : discovered;
// any node whose stage isn't declared falls into a trailing "_" column
for (const s of discovered) if (!stageIds.includes(s)) stageIds.push(s);
const stageLabel = new Map(stageDefs.map((s) => [s.id, s.label]));
const perStage = new Map<string, DagNode[]>();
for (const id of stageIds) perStage.set(id, []);
for (const n of nodes) perStage.get(n.stage && perStage.has(n.stage) ? n.stage : stageIds[stageIds.length - 1])!.push(n);
const pos = new Map<string, { x: number; y: number }>();
stageIds.forEach((sid, si) => {
(perStage.get(sid) ?? []).forEach((n, ni) => pos.set(n.id, { x: si * COLW, y: HEADER + ni * ROWH }));
});
const maxRows = Math.max(1, ...stageIds.map((s) => perStage.get(s)?.length ?? 0));
const width = Math.max(NODEW, (stageIds.length - 1) * COLW + NODEW) + PADX;
const height = HEADER + maxRows * ROWH + 8;
return (
<div className="overflow-x-auto">
<div className="relative" style={{ width, height }}>
{/* edges */}
<svg width={width} height={height} className="pointer-events-none absolute inset-0">
{edges.map((e, i) => {
const a = pos.get(e.from);
const b = pos.get(e.to);
if (!a || !b) return null;
const c = EDGE_COLOR[e.kind ?? "sequential"] ?? EDGE_COLOR.sequential;
return (
<path
key={i}
d={edgePath(a, b)}
fill="none"
stroke={c}
strokeWidth={1.5}
strokeLinejoin="round"
strokeDasharray={e.kind === "fan_in" ? "4 3" : undefined}
opacity={0.8}
/>
);
})}
</svg>
{/* stage headers */}
{stageIds.map((sid, si) =>
stageLabel.get(sid) ? (
<div
key={`h-${sid}`}
className="text-muted-foreground absolute truncate text-[10px] font-medium tracking-wide uppercase"
style={{ left: si * COLW, top: 0, width: NODEW }}
>
{stageLabel.get(sid)}
</div>
) : null,
)}
{/* nodes */}
{nodes.map((n) => {
const p = pos.get(n.id);
if (!p) return null;
return (
<div
key={n.id}
className={[
// opaque fills + z-10 so a node always occludes any edge routed behind it
"absolute z-10 flex flex-col justify-center rounded-md border px-2 py-1 text-xs leading-tight",
n.viz_only
? "border-dashed border-border/60 bg-muted text-muted-foreground"
: n.scope === "per_cell"
? "border-sky-500/50 bg-sky-50 dark:bg-sky-950"
: "border-border/70 bg-muted",
n.terminal ? "ring-1 ring-primary/40" : "",
].join(" ")}
style={{ left: p.x, top: p.y, width: NODEW, minHeight: NODEH }}
>
<span className="line-clamp-2 font-medium">{n.label}</span>
{(n.tool || n.scope) && (
<span className="text-muted-foreground mt-0.5 truncate text-[10px]">
{n.tool}
{n.tool && n.scope ? " · " : ""}
{n.scope === "per_cell" ? "per-cell" : n.scope === "bulk" ? "bulk" : ""}
</span>
)}
</div>
);
})}
</div>
</div>
);
}
|