0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Every orchestrator — Airflow, Dagster, dbt — is one idea wearing different clothes: tasks form a directed acyclic graph, and the scheduler runs whatever has no unfinished parents. This is that idea with the covers off. It opens on a wired seven-task ETL graph, and Run pipeline walks it in waves, lighting up everything that can go in parallel while a mono log ticks out timestamps beside it — but the interesting part is what it refuses to do: drag the + from publish_kpis back onto extract_orders and the edge is rejected with the exact loop spelled out in red, because "acyclic" lands much faster when you can see the cycle you just tried to make. Try this first — select clean_orders, tick make this task fail when it runs, and run again to watch one red task drag four SKIPPED upstream_failed down behind it; then hit Highlight critical path and pull a duration slider until a different chain owns the 7.20s wall clock.
Source — the code you see is the code that just ran 1299 lines 63.1 KB index.html
<!doctype html>
<html lang="en" data-theme="">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pipeline DAG Builder</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end token block ──────────────────────────────────────────────────────── */
/* native controls (range, checkbox, scrollbars) follow the resolved theme */
:root, :root[data-theme="light"] { color-scheme: light; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { color-scheme: dark; } }
:root[data-theme="dark"] { color-scheme: dark; }
:root { --danger-soft: var(--accent-soft); }
:root { --danger-soft: color-mix(in srgb, var(--danger) 9%, var(--surface)); }
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; min-height: 100vh; overflow-x: hidden; }
.sr { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden;
clip:rect(0 0 0 0); white-space:nowrap; border:0; }
.wrap { max-width: 1280px; margin: 0 auto; padding: 20px 16px 26px; }
header h1 { margin: 0 0 5px; font-size: 19px; font-weight: 650; letter-spacing: -.01em; }
header p { margin: 0; font-size: 13.5px; line-height: 1.5; color: var(--muted); max-width: 68ch; }
header .kbd { font: 600 12px var(--font-mono); color: var(--ink);
background: var(--surface); border: 1px solid var(--line);
border-radius: 5px; padding: 1px 5px; }
/* ── controls ─────────────────────────────────────────────────────────────── */
.bar { display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin:16px 0 10px; }
.grow { flex: 1 1 8px; }
.btn { font: 600 13px/1 var(--font-sans); color: var(--ink); background: var(--surface);
border: 1px solid var(--line); border-radius: 8px; padding: 9px 12px;
display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
white-space: nowrap; transition: border-color .12s, color .12s, background .12s; }
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--surface); }
.btn.primary:hover { filter: brightness(1.08); color: var(--surface); }
.btn.stop { background: var(--danger); border-color: var(--danger); color: var(--surface); }
.btn.stop:hover { color: var(--surface); filter: brightness(1.08); }
.btn.sm { padding: 6px 9px; font-size: 12px; }
.btn .gl { font-size: 11px; line-height: 1; }
form.add { display: flex; gap: 6px; align-items: center; flex: 0 1 320px; min-width: 190px; }
input[type=text] { flex: 1 1 auto; min-width: 0; font: 500 13px var(--font-mono);
color: var(--ink); background: var(--surface); border: 1px solid var(--line);
border-radius: 8px; padding: 9px 10px; }
input[type=text]::placeholder { color: var(--muted); opacity: .8; }
input[type=text]:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
/* ── status strip ─────────────────────────────────────────────────────────── */
.strip { display:flex; flex-wrap:wrap; align-items:center; gap:8px 14px;
font: 500 11.5px var(--font-mono); color: var(--muted);
border: 1px solid var(--line); background: var(--surface);
border-radius: var(--radius); padding: 9px 12px; }
.pill { font: 650 11px var(--font-mono); letter-spacing:.1em; padding: 3px 8px;
border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
.pill[data-s="RUNNING"] { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.pill[data-s="OK"] { color: var(--run); border-color: var(--run); background: var(--run-soft); }
.pill[data-s="FAILED"] { color: var(--danger); border-color: var(--danger); background: var(--danger-soft); }
.strip b { color: var(--ink); font-weight: 600; }
.rail { display: inline-flex; gap: 3px; vertical-align: -1px; }
.rail i { width: 13px; height: 7px; border-radius: 2px; background: var(--line); display:block; }
.rail i.on { background: var(--accent); }
.rail i.done { background: var(--run); }
.rail i.fell { background: var(--danger); }
/* ── banner ───────────────────────────────────────────────────────────────── */
.banner { margin: 10px 0 0; border: 1px solid var(--line); border-left-width: 3px;
background: var(--surface); border-radius: 8px; padding: 10px 12px; font-size: 13px; }
.banner[hidden] { display: none; }
.banner .hd { font-weight: 600; display: flex; gap: 7px; align-items: baseline; }
.banner .mono { display: block; margin-top: 6px; font: 500 12px var(--font-mono);
color: var(--ink); word-break: break-word; line-height: 1.6; }
.banner[data-k="err"] { border-left-color: var(--danger); background: var(--danger-soft); }
.banner[data-k="err"] .hd { color: var(--danger); }
.banner[data-k="ok"] { border-left-color: var(--run); background: var(--run-soft); }
.banner[data-k="ok"] .hd { color: var(--run); }
.banner[data-k="info"] { border-left-color: var(--accent); background: var(--accent-soft); }
.banner[data-k="info"] .hd { color: var(--accent); }
.banner[data-k="hint"] { border-left-color: var(--line); }
.banner[data-k="hint"] .hd { color: var(--muted); font-weight: 500; }
.banner[data-k="hint"] .mono { color: var(--muted); }
/* ── stage ────────────────────────────────────────────────────────────────── */
.stage { display: grid; grid-template-columns: minmax(0,1fr) 252px; gap: 14px; margin-top: 12px; }
#pbody { display: flex; flex-direction: column; gap: 11px; align-items: stretch; }
/* below this the graph needs the full width more than the panel needs a column */
@media (max-width: 1080px) { .stage { grid-template-columns: minmax(0,1fr); } }
@media (min-width: 601px) and (max-width: 1080px) {
#pbody { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 10px 20px; align-items: start; }
#pbody > .hint { grid-column: 1 / -1; columns: 2; column-gap: 24px; padding-left: 19px; }
.panel { gap: 10px; }
}
.canvas { position: relative; border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); overflow: hidden; height: clamp(378px, 46vh, 440px); }
svg.board { display:block; width:100%; height:100%; touch-action: pan-y; cursor: grab; }
svg.board.panning { cursor: grabbing; }
.zoom { position: absolute; right: 10px; bottom: 10px; display: flex; gap: 5px; }
.zoom .btn { background: var(--surface); box-shadow: 0 1px 3px rgba(0,0,0,.10); }
/* graph */
.dots { fill: var(--muted); opacity: .28; }
#ah path { fill: var(--muted); fill-opacity: .5; }
#ah-a path { fill: var(--accent); fill-opacity: 1; }
#ah-x path { fill: var(--danger); fill-opacity: 1; }
.edge .hit { stroke: transparent; stroke-width: 16; fill: none; pointer-events: stroke; cursor: pointer; }
.edge .wire { stroke: var(--muted); stroke-opacity: .5; stroke-width: 1.6; fill: none;
marker-end: url(#ah); pointer-events: none; }
.edge:hover .wire, .edge.sel .wire { stroke: var(--accent); stroke-opacity: 1; stroke-width: 2.2; marker-end: url(#ah-a); }
.edge.live .wire { stroke: var(--accent); stroke-opacity: 1; stroke-width: 2.4; marker-end: url(#ah-a); }
.edge.crit .wire { stroke: var(--accent); stroke-opacity: .95; stroke-width: 2.4; stroke-dasharray: 7 4; marker-end: url(#ah-a); }
.edge.bad .wire { stroke: var(--danger); stroke-opacity: 1; stroke-width: 2.6; marker-end: url(#ah-x); }
.edge .del { cursor: pointer; }
.edge .del circle { fill: var(--surface); stroke: var(--danger); stroke-width: 1.4; }
.edge .del text { fill: var(--danger); font: 700 11px var(--font-mono); }
.ghost { stroke: var(--accent); stroke-width: 2.2; stroke-dasharray: 6 5; fill: none;
marker-end: url(#ah-a); pointer-events: none; }
.node { cursor: grab; touch-action: none; outline: none; }
.node.dragging { cursor: grabbing; }
.node .box { fill: var(--surface); stroke: var(--line); stroke-width: 1.5; }
.node .bar4 { fill: var(--line); }
.node .nm { fill: var(--ink); font: 600 13px var(--font-mono); }
.node .st { fill: var(--muted); font: 500 10px var(--font-mono); letter-spacing: .09em; }
.node .du { fill: var(--muted); font: 500 10px var(--font-mono); }
.node .ring { fill: none; stroke: var(--accent); stroke-width: 2; stroke-dasharray: 5 4; opacity: 0; }
.node.sel .ring { opacity: .95; }
.node:focus-visible .ring { opacity: 1; stroke-dasharray: none; stroke-width: 2.5; }
.node .halo { fill: none; stroke: var(--accent); stroke-width: 2; opacity: 0; }
.node .port { fill: var(--surface); stroke: var(--muted); stroke-width: 1.5; pointer-events: none; }
.node .portx { stroke: var(--muted); stroke-width: 1.5; pointer-events: none; }
.node .porthit { fill: transparent; cursor: crosshair; touch-action: none; }
.node:hover .port, .node.sel .port, .node:focus-visible .port { stroke: var(--accent); fill: var(--accent-soft); }
.node:hover .portx, .node.sel .portx, .node:focus-visible .portx { stroke: var(--accent); }
.node .porthit:hover + .port, .node .porthit:hover ~ .portx { stroke: var(--accent); }
.st-queued .box { stroke-dasharray: 5 4; }
.st-queued .st { fill: var(--muted); }
.st-run .box { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 2; }
.st-run .bar4 { fill: var(--accent); } .st-run .st { fill: var(--accent); } .st-run .du { fill: var(--accent); }
.st-run .halo { opacity: .55; animation: pulse 1.15s ease-out infinite; }
.st-ok .box { fill: var(--run-soft); stroke: var(--run); }
.st-ok .bar4 { fill: var(--run); } .st-ok .st { fill: var(--run); } .st-ok .du { fill: var(--run); }
.st-fail .box { fill: var(--danger-soft); stroke: var(--danger); stroke-width: 2; }
.st-fail .bar4{ fill: var(--danger); } .st-fail .st { fill: var(--danger); } .st-fail .du { fill: var(--danger); }
.st-skip { opacity: .66; }
.st-skip .box { stroke-dasharray: 3 4; }
.st-skip .st { fill: var(--ink); }
.node.crit .box { stroke: var(--accent); stroke-width: 2; }
.node.bad .box { stroke: var(--danger); stroke-width: 2.5; }
.node.willfail .st { fill: var(--danger); }
.node.willfail .bar4 { fill: var(--danger); }
.node.willfail .box { stroke-dasharray: 4 3; }
@keyframes pulse { 0% { opacity:.6; stroke-width:2; } 100% { opacity:0; stroke-width:9; } }
/* ── panel ────────────────────────────────────────────────────────────────── */
.panel { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface);
padding: 13px; display: flex; flex-direction: column; gap: 12px; align-self: start; }
.panel h2 { margin: 0; font-size: 11px; font-weight: 650; letter-spacing: .1em;
text-transform: uppercase; color: var(--muted); font-family: var(--font-mono); }
.panel .hint { margin: 0; padding-left: 17px; font-size: 12.5px; line-height: 1.65; color: var(--muted); }
.panel .hint li { margin-bottom: 5px; }
.panel .hint b { color: var(--ink); font-weight: 600; }
.field { display: flex; flex-direction: column; gap: 5px; }
.field label { font: 600 10.5px var(--font-mono); letter-spacing: .09em;
text-transform: uppercase; color: var(--muted); }
.row { display: flex; align-items: center; gap: 8px; justify-content: space-between; }
.chk { display: flex; align-items: center; gap: 8px; font-size: 12.5px; cursor: pointer; }
.chk input { accent-color: var(--danger); width: 15px; height: 15px; cursor: pointer; }
.chk input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
input[type=range] { width: 100%; accent-color: var(--accent); }
input[type=range]:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.mono { font: 500 11.5px var(--font-mono); color: var(--muted); }
.mono b { color: var(--ink); font-weight: 600; }
.deps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.deps li { display: flex; align-items: center; gap: 6px; justify-content: space-between;
font: 500 11.5px var(--font-mono); background: var(--bg); border: 1px solid var(--line);
border-radius: 6px; padding: 4px 4px 4px 8px; }
.deps li span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.x { border: 0; background: transparent; color: var(--muted); cursor: pointer;
font: 700 12px var(--font-mono); line-height: 1; padding: 3px 5px; border-radius: 5px; }
.x:hover { color: var(--danger); background: var(--danger-soft); }
.x:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.legend { display: flex; flex-wrap: wrap; gap: 5px 12px; border-top: 1px solid var(--line); padding-top: 11px; }
.legend span { display: inline-flex; align-items: center; gap: 5px; font: 500 11px var(--font-mono); color: var(--muted); }
.sw { width: 9px; height: 9px; border-radius: 3px; border: 1.5px solid var(--line); background: var(--surface); }
.sw.ok { border-color: var(--run); background: var(--run-soft); }
.sw.rn { border-color: var(--accent); background: var(--accent-soft); }
.sw.fl { border-color: var(--danger); background: var(--danger-soft); }
.sw.sk { border-style: dashed; opacity: .6; }
.sep { border: 0; border-top: 1px solid var(--line); margin: 0; }
/* ── log ──────────────────────────────────────────────────────────────────── */
.logwrap { margin-top: 14px; border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); overflow: hidden; }
.loghd { display:flex; align-items:center; justify-content:space-between; gap:10px;
padding: 8px 12px; border-bottom: 1px solid var(--line); }
.loghd h2 { margin:0; font: 650 10.5px var(--font-mono); letter-spacing:.11em;
text-transform: uppercase; color: var(--muted); }
.log { margin: 0; padding: 9px 12px 11px; max-height: 196px; overflow: auto; }
.ln { display: grid; grid-template-columns: 58px 13px minmax(0,22ch) minmax(0,1fr); gap: 8px;
font: 500 11.5px/1.75 var(--font-mono); color: var(--muted); }
.ln .t { color: var(--muted); opacity: .88; }
.ln .n { color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ln .d { white-space: nowrap; }
.ln.wide .n { grid-column: 3 / -1; }
.ln.wide .d { display: none; }
.ln.ok .i, .ln.ok .d { color: var(--run); }
.ln.fail .i, .ln.fail .d, .ln.fail .n { color: var(--danger); }
.ln.run .i { color: var(--accent); }
.ln.info .n { color: var(--accent); }
.ln.skip { opacity: .9; }
.log .empty { font: 500 11.5px var(--font-mono); color: var(--muted); }
footer { margin-top: 18px; font: 500 11.5px var(--font-mono); color: var(--muted); }
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
.st-run .halo { opacity: .45; stroke-width: 4; }
}
@media (max-width: 520px) {
.ln { grid-template-columns: 50px 11px minmax(0,1fr) auto; gap: 6px; font-size: 11px; }
}
/* one column: the canvas gets the height the side panel used to take */
@media (max-width: 1080px) { .canvas { height: clamp(380px, 52vh, 500px); } }
/* on a phone, spend the vertical budget on the graph, not on prose */
@media (max-width: 560px) {
header p { font-size: 13px; line-height: 1.45; }
header p .wide { display: none; }
.banner[data-k="hint"] .mono { display: none !important; }
.strip { gap: 6px 11px; padding: 8px 10px; }
.canvas { height: clamp(340px, 52vh, 460px); }
}
@media (max-width: 420px) {
.wrap { padding: 16px 12px 22px; }
.btn { padding: 8px 10px; font-size: 12.5px; }
form.add { flex: 1 1 100%; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Pipeline DAG Builder</h1>
<p>A data pipeline is a graph of tasks: each one waits for the ones it depends on.
Wire tasks together, press <b>Run</b>, and watch the scheduler execute them in waves —
everything that <em>can</em> go in parallel does. <span class="wide">Loop a dependency back on
itself and the graph refuses it, and tells you exactly where the loop is.</span></p>
</header>
<div class="bar">
<button class="btn primary" id="run" type="button"><span class="gl" aria-hidden="true">▶</span><span id="runlbl">Run pipeline</span></button>
<button class="btn" id="reset" type="button">Reset</button>
<button class="btn" id="tidy" type="button">Tidy layout</button>
<button class="btn" id="seed" type="button">Reload example</button>
<span class="grow"></span>
<form class="add" id="addf">
<label class="sr" for="newname">New task name</label>
<input type="text" id="newname" placeholder="new_task_name" maxlength="32" autocomplete="off" spellcheck="false">
<button class="btn" type="submit"><span class="gl" aria-hidden="true">+</span>Add task</button>
</form>
</div>
<div class="strip">
<span class="pill" id="pill" data-s="IDLE">IDLE</span>
<span><span class="rail" id="rail" aria-hidden="true"></span> <span id="wavetxt">wave 0/0</span></span>
<span>tasks <b id="ntasks">0</b></span>
<span>deps <b id="nedges">0</b></span>
<span>waves <b id="nwaves">0</b></span>
<span>critical path <b id="cpath">0.00s</b></span>
<span class="grow"></span>
<span id="addhint">↳ new tasks land unconnected</span>
</div>
<div class="banner" id="banner" role="status" data-k="hint">
<div class="hd"><span id="bico" aria-hidden="true">✗</span><span id="btxt"></span></div>
<span class="mono" id="bsub"></span>
</div>
<div class="stage">
<div class="canvas">
<svg class="board" id="board" role="application" aria-label="Pipeline graph editor. Tab to a task, then use arrow keys to move it, C to start a dependency, Enter to finish it, Delete to remove it.">
<defs>
<pattern id="dot" width="26" height="26" patternUnits="userSpaceOnUse">
<circle class="dots" cx="1.2" cy="1.2" r="1.2"></circle>
</pattern>
<marker id="ah" viewBox="0 0 10 8" refX="9.2" refY="4" markerWidth="8" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 4 L0 8 z" fill="var(--muted)" fill-opacity=".5"></path>
</marker>
<marker id="ah-a" viewBox="0 0 10 8" refX="9.2" refY="4" markerWidth="8" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 4 L0 8 z" fill="var(--accent)"></path>
</marker>
<marker id="ah-x" viewBox="0 0 10 8" refX="9.2" refY="4" markerWidth="8" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 4 L0 8 z" fill="var(--danger)"></path>
</marker>
</defs>
<g id="world">
<rect x="-6000" y="-6000" width="12000" height="12000" fill="url(#dot)"></rect>
<g id="glayer"></g>
<g id="elayer"></g>
<g id="nlayer"></g>
</g>
</svg>
<div class="zoom">
<button class="btn sm" id="zout" type="button" aria-label="Zoom out">−</button>
<button class="btn sm" id="zin" type="button" aria-label="Zoom in">+</button>
<button class="btn sm" id="zfit" type="button">Fit</button>
</div>
</div>
<aside class="panel" id="panel">
<h2 id="ptitle">How it works</h2>
<div id="pbody"></div>
<hr class="sep">
<button class="btn sm" id="critbtn" type="button" aria-pressed="false">Highlight critical path</button>
<div class="legend">
<span><i class="sw"></i>queued</span>
<span><i class="sw rn"></i>running</span>
<span><i class="sw ok"></i>ok</span>
<span><i class="sw fl"></i>failed</span>
<span><i class="sw sk"></i>skipped</span>
</div>
</aside>
</div>
<div class="logwrap">
<div class="loghd">
<h2>scheduler log</h2>
<span class="mono" id="logmeta">idle</span>
</div>
<div class="log" id="log"><div class="empty">press “Run pipeline” to execute the graph…</div></div>
</div>
<footer>demo · sloprun.dev</footer>
</div>
<script>
(function () {
"use strict";
/* ── theme bridge ───────────────────────────────────────────────────────── */
addEventListener("message", function (e) {
var d = e && e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
document.documentElement.setAttribute("data-theme", d.theme);
}
});
var SVGNS = "http://www.w3.org/2000/svg";
var W = 152, H = 50;
var mqReduce = matchMedia("(prefers-reduced-motion: reduce)");
var $ = function (id) { return document.getElementById(id); };
var board = $("board"), world = $("world"),
elayer = $("elayer"), nlayer = $("nlayer"), glayer = $("glayer");
/* ── model ──────────────────────────────────────────────────────────────── */
var nodes = [], edges = [], seq = 0;
var sel = null, selEdge = null, orient = "LR", showCrit = false;
var view = { x: 0, y: 0, k: 1 };
var runToken = 0, running = false, runState = { wave: 0, total: 0 }, runOk = null;
var connect = null; // { from, gx, gy }
var touched = false; // user has taken control of the viewport
var nodeEls = new Map(), edgeEls = new Map();
var SEED_N = [
["extract_orders", 1.80], ["extract_users", 0.60], ["clean_orders", 1.10],
["join_orders", 2.40], ["daily_revenue", 0.90], ["cohort_table", 1.50],
["publish_kpis", 0.40]
];
var SEED_E = [[0,2],[2,3],[1,3],[3,4],[3,5],[4,6],[5,6]];
function byId(id) { for (var i=0;i<nodes.length;i++) if (nodes[i].id === id) return nodes[i]; return null; }
function key(a,b) { return a + ">" + b; }
function hasEdge(a,b) { for (var i=0;i<edges.length;i++) if (edges[i].f===a && edges[i].t===b) return true; return false; }
function outOf(id) { var r=[]; for (var i=0;i<edges.length;i++) if (edges[i].f===id) r.push(edges[i].t); return r; }
function depsOf(id) { var r=[]; for (var i=0;i<edges.length;i++) if (edges[i].t===id) { var n=byId(edges[i].f); if(n) r.push(n); } return r; }
function loadSeed() {
cancelRun();
nodes = []; edges = []; seq = 0; sel = null; selEdge = null; showCrit = false;
$("critbtn").setAttribute("aria-pressed", "false");
var ids = SEED_N.map(function (s) {
seq++;
var n = { id: "t" + seq, name: s[0], dur: s[1], x: 0, y: 0, st: "idle", fail: false };
nodes.push(n); return n.id;
});
SEED_E.forEach(function (e) { edges.push({ f: ids[e[0]], t: ids[e[1]] }); });
runState = { wave: 0, total: 0 };
pill("IDLE"); setRunBtn(false);
autoLayout(); syncAll(); clearLog(); fit();
banner(null);
}
/* ── graph algorithms ───────────────────────────────────────────────────── */
function ranks() {
var order = topoOrder(); if (!order) return null;
var r = {};
order.forEach(function (n) {
var m = 0, d = depsOf(n.id);
for (var i=0;i<d.length;i++) m = Math.max(m, (r[d[i].id] || 0) + 1);
r[n.id] = m;
});
return r;
}
function topoWaves() {
var indeg = {}, adj = {};
nodes.forEach(function (n) { indeg[n.id] = 0; adj[n.id] = []; });
edges.forEach(function (e) { if (indeg[e.t] !== undefined && adj[e.f]) { indeg[e.t]++; adj[e.f].push(e.t); } });
var cur = nodes.filter(function (n) { return indeg[n.id] === 0; }), waves = [], seen = 0;
while (cur.length) {
waves.push(cur); seen += cur.length;
var next = [];
cur.forEach(function (n) {
adj[n.id].forEach(function (t) { if (--indeg[t] === 0) next.push(byId(t)); });
});
cur = next;
}
return { waves: waves, ok: seen === nodes.length };
}
function topoOrder() {
var w = topoWaves(); if (!w.ok) return null;
var out = []; w.waves.forEach(function (x) { out = out.concat(x); }); return out;
}
/* path from `s` to `g` following dependency direction; null if none */
function pathTo(s, g) {
var q = [s], prev = {}, seenSet = {}; seenSet[s] = 1;
while (q.length) {
var c = q.shift();
if (c === g) { var p = [c]; while (prev[c]) { c = prev[c]; p.unshift(c); } return p; }
outOf(c).forEach(function (n) { if (!seenSet[n]) { seenSet[n] = 1; prev[n] = c; q.push(n); } });
}
return null;
}
function criticalPath() {
var order = topoOrder(); if (!order || !order.length) return { ids: [], total: 0 };
var fin = {}, prev = {};
order.forEach(function (n) {
var best = 0, bp = null, d = depsOf(n.id);
for (var i=0;i<d.length;i++) if (fin[d[i].id] > best) { best = fin[d[i].id]; bp = d[i].id; }
fin[n.id] = best + n.dur; prev[n.id] = bp;
});
var top = null;
order.forEach(function (n) { if (top === null || fin[n.id] > fin[top]) top = n.id; });
var ids = [], c = top;
while (c) { ids.unshift(c); c = prev[c]; }
return { ids: ids, total: fin[top] };
}
/* ── layout ─────────────────────────────────────────────────────────────── */
function gaps(o) { return o === "LR" ? { x: W + 58, y: H + 40 } : { x: W + 24, y: H + 38 }; }
/* footprint a given orientation would occupy, without moving anything */
function layoutSize(o) {
var r = ranks(); if (!r || !nodes.length) return null;
var counts = {}, maxR = 0;
nodes.forEach(function (n) { var k = r[n.id]; maxR = Math.max(maxR, k); counts[k] = (counts[k] || 0) + 1; });
var widest = 0;
for (var k = 0; k <= maxR; k++) widest = Math.max(widest, counts[k] || 0);
var g = gaps(o), R = maxR + 1, M = widest;
return o === "LR"
? { w: (R - 1) * g.x + W, h: (M - 1) * g.y + H }
: { w: (M - 1) * g.x + W, h: (R - 1) * g.y + H };
}
function autoLayout() {
var r = ranks(); if (!r) return;
var groups = {}, maxR = 0;
nodes.forEach(function (n) {
var k = r[n.id]; maxR = Math.max(maxR, k);
(groups[k] = groups[k] || []).push(n);
});
var g0 = gaps(orient), GX = g0.x, GY = g0.y;
var widest = 0;
for (var k = 0; k <= maxR; k++) widest = Math.max(widest, (groups[k] || []).length);
for (var k2 = 0; k2 <= maxR; k2++) {
var g = groups[k2] || [];
g.sort(function (a, b) { return orient === "LR" ? a.y - b.y : a.x - b.x; });
for (var i = 0; i < g.length; i++) {
var off = (widest - g.length) / 2;
if (orient === "LR") { g[i].x = 40 + k2 * GX; g[i].y = 40 + (i + off) * GY; }
else { g[i].y = 40 + k2 * GY; g[i].x = 40 + (i + off) * GX; }
}
}
}
function bbox() {
if (!nodes.length) return { x: 0, y: 0, w: W, h: H };
var x1=Infinity,y1=Infinity,x2=-Infinity,y2=-Infinity;
nodes.forEach(function (n) {
x1=Math.min(x1,n.x); y1=Math.min(y1,n.y); x2=Math.max(x2,n.x+W); y2=Math.max(y2,n.y+H);
});
return { x: x1, y: y1, w: x2-x1, h: y2-y1 };
}
function fit() {
var rc = board.getBoundingClientRect(); if (!rc.width) return;
touched = false;
var b = bbox(), padX = 22, padT = 18, padB = 48;
var k = Math.min((rc.width - padX * 2) / b.w, (rc.height - padT - padB) / b.h, 1.12);
k = Math.max(0.26, Math.min(2.2, k));
view.k = k;
view.x = (rc.width - b.w * k) / 2 - b.x * k;
view.y = padT + (rc.height - padT - padB - b.h * k) / 2 - b.y * k;
applyView();
}
function applyView() {
world.setAttribute("transform", "translate(" + view.x.toFixed(2) + "," + view.y.toFixed(2) + ") scale(" + view.k.toFixed(4) + ")");
}
function zoomBy(f) {
touched = true;
var rc = board.getBoundingClientRect(), cx = rc.width/2, cy = rc.height/2;
var nk = Math.max(0.28, Math.min(2.2, view.k * f));
view.x = cx - (cx - view.x) * (nk / view.k);
view.y = cy - (cy - view.y) * (nk / view.k);
view.k = nk; applyView();
}
function toWorld(clientX, clientY) {
var rc = board.getBoundingClientRect();
return { x: (clientX - rc.left - view.x) / view.k, y: (clientY - rc.top - view.y) / view.k };
}
function nodeAt(p) {
for (var i = nodes.length - 1; i >= 0; i--) {
var n = nodes[i];
if (p.x >= n.x && p.x <= n.x + W && p.y >= n.y && p.y <= n.y + H) return n;
}
return null;
}
/* ── geometry ───────────────────────────────────────────────────────────── */
function anchor(a, b) {
var cx = a.x + W/2, cy = a.y + H/2, dx = (b.x + W/2) - cx, dy = (b.y + H/2) - cy;
if (!dx && !dy) return { x: cx, y: cy };
var hw = W/2 + 3, hh = H/2 + 3;
var t = Math.min(dx ? hw/Math.abs(dx) : Infinity, dy ? hh/Math.abs(dy) : Infinity);
return { x: cx + dx*t, y: cy + dy*t };
}
function curve(p, q) {
var dx = q.x - p.x, dy = q.y - p.y, c1, c2;
if (Math.abs(dx) >= Math.abs(dy)) {
var o = Math.max(26, Math.abs(dx) * 0.42);
c1 = [p.x + o, p.y]; c2 = [q.x - o, q.y];
} else {
var o2 = Math.max(24, Math.abs(dy) * 0.42);
c1 = [p.x, p.y + o2]; c2 = [q.x, q.y - o2];
}
return "M" + p.x.toFixed(1) + " " + p.y.toFixed(1) + "C" + c1[0].toFixed(1) + " " + c1[1].toFixed(1) +
" " + c2[0].toFixed(1) + " " + c2[1].toFixed(1) + " " + q.x.toFixed(1) + " " + q.y.toFixed(1);
}
function portPos(n) {
return orient === "LR" ? { x: n.x + W, y: n.y + H/2 } : { x: n.x + W/2, y: n.y + H };
}
/* ── DOM build ──────────────────────────────────────────────────────────── */
function mk(tag, attrs) {
var e = document.createElementNS(SVGNS, tag);
for (var k in attrs) e.setAttribute(k, attrs[k]);
return e;
}
function buildNode(n) {
var g = mk("g", { "class": "node", tabindex: "0", role: "button" });
g.dataset.id = n.id;
var ttl = document.createElementNS(SVGNS, "title");
g.appendChild(ttl);
g.appendChild(mk("rect", { "class": "ring", x: -5, y: -5, width: W+10, height: H+10, rx: 14 }));
g.appendChild(mk("rect", { "class": "halo", x: 0, y: 0, width: W, height: H, rx: 10 }));
g.appendChild(mk("rect", { "class": "box", x: 0, y: 0, width: W, height: H, rx: 10 }));
g.appendChild(mk("rect", { "class": "bar4", x: 1.6, y: 1.6, width: 3.2, height: H-3.2, rx: 1.6 }));
var nm = mk("text", { "class": "nm", x: 13, y: 21 }); g.appendChild(nm);
var st = mk("text", { "class": "st", x: 13, y: 38 }); g.appendChild(st);
var du = mk("text", { "class": "du", x: W-12, y: 38, "text-anchor": "end" }); g.appendChild(du);
var ph = mk("circle", { "class": "porthit", r: 14 });
var pc = mk("circle", { "class": "port", r: 6.5 });
var p1 = mk("line", { "class": "portx" });
var p2 = mk("line", { "class": "portx" });
g.appendChild(ph); g.appendChild(pc); g.appendChild(p1); g.appendChild(p2);
nlayer.appendChild(g);
var rec = { g: g, nm: nm, st: st, du: du, ttl: ttl, ph: ph, pc: pc, p1: p1, p2: p2 };
nodeEls.set(n.id, rec);
wireNode(g, n.id, ph);
return rec;
}
function buildEdge(e) {
var g = mk("g", { "class": "edge" });
g.appendChild(mk("path", { "class": "hit" }));
g.appendChild(mk("path", { "class": "wire" }));
var del = mk("g", { "class": "del" });
del.setAttribute("display", "none");
del.appendChild(mk("circle", { r: 9 }));
var t = mk("text", { "text-anchor": "middle", y: 3.8 }); t.textContent = "✕"; del.appendChild(t);
g.appendChild(del);
elayer.appendChild(g);
var rec = { g: g, hit: g.children[0], wire: g.children[1], del: del };
edgeEls.set(key(e.f, e.t), rec);
g.children[0].addEventListener("pointerdown", function (ev) { ev.stopPropagation(); });
g.children[0].addEventListener("click", function (ev) {
ev.stopPropagation(); selEdge = key(e.f, e.t); sel = null; paint(); stats(); panel();
});
del.addEventListener("click", function (ev) {
ev.stopPropagation(); removeEdge(e.f, e.t);
});
del.addEventListener("pointerdown", function (ev) { ev.stopPropagation(); });
return rec;
}
function syncAll() {
var live = {};
nodes.forEach(function (n) { live[n.id] = 1; if (!nodeEls.has(n.id)) buildNode(n); });
nodeEls.forEach(function (rec, id) { if (!live[id]) { rec.g.remove(); nodeEls.delete(id); } });
var liveE = {};
edges.forEach(function (e) { var k = key(e.f, e.t); liveE[k] = 1; if (!edgeEls.has(k)) buildEdge(e); });
edgeEls.forEach(function (rec, k) { if (!liveE[k]) { rec.g.remove(); edgeEls.delete(k); } });
// keep node DOM order matching model so z-order is stable
nodes.forEach(function (n) { nlayer.appendChild(nodeEls.get(n.id).g); });
geom(); paint(); stats(); panel();
}
function geom() {
nodes.forEach(function (n) {
var r = nodeEls.get(n.id); if (!r) return;
r.g.setAttribute("transform", "translate(" + n.x.toFixed(1) + "," + n.y.toFixed(1) + ")");
var p = orient === "LR" ? { x: W, y: H/2 } : { x: W/2, y: H };
r.ph.setAttribute("cx", p.x); r.ph.setAttribute("cy", p.y);
r.pc.setAttribute("cx", p.x); r.pc.setAttribute("cy", p.y);
r.p1.setAttribute("x1", p.x-3); r.p1.setAttribute("x2", p.x+3);
r.p1.setAttribute("y1", p.y); r.p1.setAttribute("y2", p.y);
r.p2.setAttribute("x1", p.x); r.p2.setAttribute("x2", p.x);
r.p2.setAttribute("y1", p.y-3); r.p2.setAttribute("y2", p.y+3);
});
edges.forEach(function (e) {
var r = edgeEls.get(key(e.f, e.t)); if (!r) return;
var a = byId(e.f), b = byId(e.t); if (!a || !b) return;
var d = curve(anchor(a, b), anchor(b, a));
r.wire.setAttribute("d", d); r.hit.setAttribute("d", d);
if (r.del.getAttribute("display") !== "none") {
try {
var m = r.wire.getPointAtLength(r.wire.getTotalLength() / 2);
r.del.setAttribute("transform", "translate(" + m.x.toFixed(1) + "," + m.y.toFixed(1) + ")");
} catch (err) {}
}
});
}
var STLABEL = { idle: "READY", queued: "QUEUED", running: "RUNNING", ok: "OK", failed: "FAILED", skipped: "SKIPPED" };
function fitText(el, full, max) {
el.textContent = full;
var len;
try { len = el.getComputedTextLength(); } catch (e) { return; }
if (len <= max) return;
var s = full;
while (s.length > 3) {
s = s.slice(0, -1);
el.textContent = s + "…";
try { if (el.getComputedTextLength() <= max) return; } catch (e) { return; }
}
}
function paint() {
var crit = showCrit ? criticalPath() : { ids: [] };
var critSet = {}; crit.ids.forEach(function (i) { critSet[i] = 1; });
nodes.forEach(function (n) {
var r = nodeEls.get(n.id); if (!r) return;
var cls = "node st-" + (n.st === "running" ? "run" : n.st === "failed" ? "fail" : n.st === "skipped" ? "skip" : n.st);
if (sel === n.id) cls += " sel";
if (n.fail) cls += " willfail";
if (showCrit && critSet[n.id]) cls += " crit";
if (n._bad) cls += " bad";
r.g.setAttribute("class", cls);
fitText(r.nm, n.name, W - 26);
r.ttl.textContent = n.name + " · " + n.dur.toFixed(2) + "s";
var lbl = STLABEL[n.st] || "READY";
if (n.fail && (n.st === "idle" || n.st === "queued")) lbl = "WILL FAIL";
r.st.textContent = lbl;
r.du.textContent = n.dur.toFixed(2) + "s";
r.g.setAttribute("aria-label", n.name + ", " + lbl.toLowerCase() + ", " +
n.dur.toFixed(2) + " seconds, " + depsOf(n.id).length + " dependencies");
});
edges.forEach(function (e) {
var r = edgeEls.get(key(e.f, e.t)); if (!r) return;
var k = key(e.f, e.t), cls = "edge";
if (selEdge === k) cls += " sel";
if (e._bad) cls += " bad";
else if (showCrit && critSet[e.f] && critSet[e.t] && crit.ids.indexOf(e.t) === crit.ids.indexOf(e.f) + 1) cls += " crit";
var a = byId(e.f);
if (a && a.st === "ok" && byId(e.t) && byId(e.t).st === "running") cls += " live";
r.g.setAttribute("class", cls);
r.del.setAttribute("display", selEdge === k ? "" : "none");
});
if (selEdge) geom();
}
/* ── stats strip ────────────────────────────────────────────────────────── */
function stats() {
var w = topoWaves();
$("ntasks").textContent = String(nodes.length);
$("nedges").textContent = String(edges.length);
$("nwaves").textContent = w.ok ? String(w.waves.length) : "—";
var cp = criticalPath();
$("cpath").textContent = cp.total.toFixed(2) + "s";
var rail = $("rail"); rail.textContent = "";
var tot = runState.total || (w.ok ? w.waves.length : 0);
for (var i = 0; i < tot; i++) {
var b = document.createElement("i");
if (running) { if (i + 1 < runState.wave) b.className = "done"; else if (i + 1 === runState.wave) b.className = "on"; }
else if (runState.wave && i < runState.wave)
b.className = (runOk === false && runState.fail && i + 1 >= runState.fail) ? "fell" : "done";
rail.appendChild(b);
}
$("wavetxt").textContent = "wave " + (runState.wave || 0) + "/" + tot;
var s = sel ? byId(sel) : null;
$("addhint").textContent = s ? "↳ new task wires after " + s.name : "↳ new tasks land unconnected";
}
function pill(s) { var p = $("pill"); p.dataset.s = s; p.textContent = s; }
function plural(n, word) { return n + " " + word + (n === 1 ? "" : "s"); }
/* ── banner ─────────────────────────────────────────────────────────────── */
/* the banner is a permanent status line: it never vanishes, it is only
replaced. keeping the slot filled means the graph below never jumps. */
function banner(kind, text, sub) {
var b = $("banner");
if (!kind) {
var w = topoWaves();
kind = "hint";
text = "Nothing running.";
sub = "drag a task to move it · drag + from a task's edge onto another task to wire a dependency";
if (!w.ok) { kind = "err"; text = "Graph has a cycle."; sub = "remove a dependency to break the loop"; }
}
b.dataset.k = kind;
$("bico").textContent = kind === "err" ? "✗" : kind === "ok" ? "✓" : kind === "hint" ? "·" : "▸";
$("btxt").textContent = text;
$("bsub").textContent = sub || "";
$("bsub").style.display = sub ? "" : "none";
}
/* ── log ────────────────────────────────────────────────────────────────── */
function clearLog() {
var l = $("log"); l.textContent = "";
var d = document.createElement("div"); d.className = "empty";
d.textContent = "press “Run pipeline” to execute the graph…";
l.appendChild(d);
$("logmeta").textContent = "idle";
}
function clock(sec) {
var m = Math.floor(sec / 60), s = sec - m * 60;
return String(m).padStart(2, "0") + ":" + s.toFixed(2).padStart(5, "0");
}
function logLine(t, icon, name, detail, kind) {
var l = $("log");
var e = l.querySelector(".empty"); if (e) e.remove();
var row = document.createElement("div");
row.className = "ln " + (kind || "") + (detail ? "" : " wide");
var a = document.createElement("span"); a.className = "t"; a.textContent = t === null ? "" : clock(t);
var b = document.createElement("span"); b.className = "i"; b.textContent = icon;
var c = document.createElement("span"); c.className = "n"; c.textContent = name;
var d = document.createElement("span"); d.className = "d"; d.textContent = detail || "";
row.appendChild(a); row.appendChild(b); row.appendChild(c); row.appendChild(d);
l.appendChild(row);
l.scrollTop = l.scrollHeight;
}
/* ── mutations ──────────────────────────────────────────────────────────── */
function cancelRun(silent) {
if (!running) return;
runToken++; running = false; runOk = null;
nodes.forEach(function (n) { if (n.st === "queued" || n.st === "running") n.st = "idle"; });
runState = { wave: 0, total: 0 };
pill("IDLE"); setRunBtn(false);
if (!silent) {
logLine(null, "▪", "run cancelled", "", "");
$("logmeta").textContent = "cancelled";
banner("info", "Run cancelled.", "graph kept · press Run pipeline to start over");
}
paint(); stats();
}
function invalidateRun() {
if (running) { cancelRun(true); }
var dirty = nodes.some(function (n) { return n.st !== "idle"; }) || $("pill").textContent !== "IDLE";
if (!dirty) return;
nodes.forEach(function (n) { n.st = "idle"; });
runState = { wave: 0, total: 0 }; runOk = null;
pill("IDLE"); setRunBtn(false);
if ($("log").querySelector(".ln")) logLine(null, "▪", "graph changed · previous run cleared", "", "");
paint(); stats();
}
function addEdge(f, t, quiet) {
if (f === t) { banner("err", "A task cannot depend on itself.", byId(f).name + " → " + byId(f).name); return false; }
if (hasEdge(f, t)) { banner("info", "That dependency already exists.", byId(f).name + " → " + byId(t).name); return false; }
var p = pathTo(t, f);
if (p) {
var names = p.map(function (i) { return byId(i).name; });
var loop = [byId(f).name].concat(names).join(" → ");
banner("err", "Cycle rejected — a pipeline must be acyclic.", loop);
flashCycle([f].concat(p));
return false;
}
invalidateRun();
edges.push({ f: f, t: t });
syncAll();
if (!quiet) banner("ok", "Dependency added.", byId(t).name + " waits for " + byId(f).name);
return true;
}
function removeEdge(f, t) {
invalidateRun();
edges = edges.filter(function (e) { return !(e.f === f && e.t === t); });
if (selEdge === key(f, t)) selEdge = null;
syncAll();
}
function flashCycle(ids) {
var mark = [];
for (var i = 0; i < ids.length - 1; i++) {
for (var j = 0; j < edges.length; j++)
if (edges[j].f === ids[i] && edges[j].t === ids[i+1]) { edges[j]._bad = 1; mark.push(edges[j]); }
}
var ns = ids.map(byId).filter(Boolean);
ns.forEach(function (n) { n._bad = 1; });
paint();
setTimeout(function () {
mark.forEach(function (e) { delete e._bad; });
ns.forEach(function (n) { delete n._bad; });
paint();
}, 2200);
}
function cleanName(raw) {
return raw.trim().replace(/\s+/g, "_").replace(/[^A-Za-z0-9_.\-]/g, "").slice(0, 32);
}
function addTask(raw) {
var name = cleanName(raw);
if (!name) { banner("err", "Give the task a name.", "letters, digits, _ . - — up to 32 chars"); return; }
if (nodes.some(function (n) { return n.name === name; })) {
banner("err", "A task called " + name + " already exists.", "task names must be unique"); return;
}
invalidateRun();
seq++;
var s = sel ? byId(sel) : null;
var n = { id: "t" + seq, name: name, dur: 1.00, st: "idle", fail: false, x: 0, y: 0 };
if (s) {
n.x = orient === "LR" ? s.x + W + 58 : s.x;
n.y = orient === "LR" ? s.y : s.y + H + 46;
} else {
var b = bbox();
n.x = b.x + b.w / 2 - W / 2; n.y = b.y + b.h + 40;
}
var guard = 0;
while (guard++ < 40 && nodes.some(function (o) {
return o !== n && Math.abs(o.x - n.x) < W - 6 && Math.abs(o.y - n.y) < H - 4;
})) { n.y += H + 20; }
nodes.push(n);
if (s) edges.push({ f: s.id, t: n.id });
sel = n.id; selEdge = null;
syncAll(); fit();
banner("ok", "Task added.", s ? name + " waits for " + s.name : name + " · no dependencies yet");
}
function removeTask(id) {
var n = byId(id); if (!n) return;
invalidateRun();
nodes = nodes.filter(function (x) { return x.id !== id; });
edges = edges.filter(function (e) { return e.f !== id && e.t !== id; });
if (sel === id) sel = null;
selEdge = null;
syncAll();
if (!nodes.length) banner("info", "Graph is empty.", "type a name above and press Add task, or press Reload example");
else banner("info", "Removed " + n.name + ".", "");
}
/* ── run ────────────────────────────────────────────────────────────────── */
function sleep(ms) {
if (ms <= 0) return Promise.resolve();
return new Promise(function (r) { setTimeout(r, ms); });
}
/* wall-clock the sim spends on a task. reduced-motion keeps the ordering
(that IS the lesson) but compresses it, and CSS drops the pulsing halo. */
function pb(dur) {
var base = Math.max(260, Math.min(900, dur * 420));
return mqReduce.matches ? Math.round(base * 0.3) : base;
}
function setRunBtn(on) {
var b = $("run");
b.className = on ? "btn stop" : "btn primary";
b.querySelector(".gl").textContent = on ? "■" : "▶";
$("runlbl").textContent = on ? "Stop" : "Run pipeline";
}
async function runPipeline() {
if (!nodes.length) { banner("err", "Nothing to run.", "add a task first"); return; }
var tw = topoWaves();
if (!tw.ok) { banner("err", "Graph has a cycle — cannot run.", "remove a dependency to break the loop"); return; }
var tok = ++runToken;
running = true; runOk = null; setRunBtn(true); pill("RUNNING");
selEdge = null;
nodes.forEach(function (n) { n.st = "queued"; });
runState = { wave: 0, total: tw.waves.length, fail: null };
$("log").textContent = ""; $("logmeta").textContent = "running…";
paint(); stats();
logLine(0, "▸", "run started · " + plural(nodes.length, "task") + " · " + plural(tw.waves.length, "wave"), "", "info");
banner("info", "Scheduler started.", plural(nodes.length, "task") + " · " + plural(tw.waves.length, "wave"));
var simT = 0, nOk = 0, nFail = 0, nSkip = 0;
for (var w = 0; w < tw.waves.length; w++) {
if (tok !== runToken) return;
runState.wave = w + 1; stats();
var wave = tw.waves[w], active = [];
for (var i = 0; i < wave.length; i++) {
var n = wave[i];
var blocked = depsOf(n.id).some(function (d) { return d.st === "failed" || d.st === "skipped"; });
if (blocked) { n.st = "skipped"; nSkip++; if (!runState.fail) runState.fail = w + 1;
logLine(simT, "–", n.name, "SKIPPED upstream_failed", "skip"); }
else active.push(n);
}
paint();
if (!active.length) continue;
logLine(simT, "▸", "wave " + (w + 1) + "/" + tw.waves.length + " · " + (active.length > 1 ? active.length + " tasks in parallel" : "1 task"), "", "info");
active.forEach(function (n) { n.st = "running"; logLine(simT, "·", n.name, "RUNNING", "run"); });
paint();
var order = active.slice().sort(function (a, b) { return a.dur - b.dur; });
var elapsed = 0;
for (var j = 0; j < order.length; j++) {
var t = pb(order[j].dur);
await sleep(t - elapsed);
if (tok !== runToken) return;
elapsed = t;
var nn = order[j];
if (nn.fail) { nn.st = "failed"; nFail++; if (!runState.fail) runState.fail = w + 1;
logLine(simT + nn.dur, "✗", nn.name, "FAILED after " + nn.dur.toFixed(2) + "s", "fail"); }
else { nn.st = "ok"; nOk++; logLine(simT + nn.dur, "✓", nn.name, "OK " + nn.dur.toFixed(2) + "s", "ok"); }
paint();
}
simT += Math.max.apply(null, active.map(function (n) { return n.dur; }));
}
if (tok !== runToken) return;
running = false; setRunBtn(false);
var clean = nFail === 0 && nSkip === 0;
runOk = clean;
var counts = nOk + " ok" + (nFail ? " · " + nFail + " failed" : "") +
(nSkip ? " · " + nSkip + " skipped" : "") + " · " + simT.toFixed(2) + "s wall clock";
var summary = clean
? "pipeline complete · " + nOk + "/" + nodes.length + " ok · " + simT.toFixed(2) + "s"
: "pipeline finished with failures · " + nOk + " ok · " + nFail + " failed · " + nSkip + " skipped · " + simT.toFixed(2) + "s";
logLine(simT, clean ? "✓" : "✗", summary, "", clean ? "ok" : "fail");
$("logmeta").textContent = simT.toFixed(2) + "s simulated";
pill(clean ? "OK" : "FAILED");
banner(clean ? "ok" : "err", clean ? "Pipeline complete." : "Pipeline finished with failures.", counts);
stats(); panel();
}
/* ── panel ──────────────────────────────────────────────────────────────── */
function panel() {
var body = $("pbody"), title = $("ptitle");
body.textContent = "";
var n = sel ? byId(sel) : null;
if (!n) {
title.textContent = selEdge ? "Dependency" : "How it works";
var ul = document.createElement("ul"); ul.className = "hint";
[["Drag a task", " to move it. Drag the ", "+", " on its edge onto another task to add a dependency."],
["Press Run", " and the scheduler executes in waves — everything in a wave runs at once."],
["Select a task", " to rename it, change how long it takes, or make it fail on purpose."],
["Try a loop", " — wire the last task back to the first. The graph will refuse it and show you the cycle."]]
.forEach(function (parts) {
var li = document.createElement("li");
var b = document.createElement("b"); b.textContent = parts[0]; li.appendChild(b);
for (var i = 1; i < parts.length; i++) {
if (parts[i] === "+") { var c = document.createElement("b"); c.textContent = "+"; li.appendChild(c); }
else li.appendChild(document.createTextNode(parts[i]));
}
ul.appendChild(li);
});
if (selEdge) {
var parts = selEdge.split(">"), a = byId(parts[0]), b2 = byId(parts[1]);
if (a && b2) {
var d = document.createElement("div"); d.className = "mono";
d.style.lineHeight = "1.7";
d.innerHTML = "";
var s1 = document.createElement("b"); s1.textContent = b2.name;
var s2 = document.createElement("b"); s2.textContent = a.name;
d.appendChild(s1); d.appendChild(document.createTextNode(" waits for ")); d.appendChild(s2);
var rm = document.createElement("button"); rm.className = "btn sm"; rm.type = "button";
rm.textContent = "Remove dependency"; rm.style.marginTop = "10px";
rm.addEventListener("click", function () { removeEdge(parts[0], parts[1]); });
body.appendChild(d); body.appendChild(rm);
return;
}
}
body.appendChild(ul);
return;
}
title.textContent = "Task";
var f1 = document.createElement("div"); f1.className = "field";
var l1 = document.createElement("label"); l1.textContent = "name"; l1.htmlFor = "fname";
var i1 = document.createElement("input"); i1.type = "text"; i1.id = "fname"; i1.value = n.name; i1.maxLength = 32;
i1.addEventListener("change", function () {
var v = cleanName(i1.value);
if (!v) { i1.value = n.name; return; }
if (nodes.some(function (o) { return o !== n && o.name === v; })) {
banner("err", "A task called " + v + " already exists.", "task names must be unique");
i1.value = n.name; return;
}
n.name = v; i1.value = v; invalidateRun(); paint(); stats();
});
f1.appendChild(l1); f1.appendChild(i1);
var f2 = document.createElement("div"); f2.className = "field";
var r2 = document.createElement("div"); r2.className = "row";
var l2 = document.createElement("label"); l2.textContent = "duration"; l2.htmlFor = "fdur";
var v2 = document.createElement("span"); v2.className = "mono"; v2.innerHTML = "";
var vb = document.createElement("b"); vb.textContent = n.dur.toFixed(2) + "s"; v2.appendChild(vb);
r2.appendChild(l2); r2.appendChild(v2);
var i2 = document.createElement("input"); i2.type = "range"; i2.id = "fdur";
i2.min = "0.1"; i2.max = "5"; i2.step = "0.1"; i2.value = String(n.dur);
i2.addEventListener("input", function () {
n.dur = parseFloat(i2.value); vb.textContent = n.dur.toFixed(2) + "s"; invalidateRun(); paint(); stats();
});
f2.appendChild(r2); f2.appendChild(i2);
var lab = document.createElement("label"); lab.className = "chk";
var cb = document.createElement("input"); cb.type = "checkbox"; cb.checked = !!n.fail;
cb.addEventListener("change", function () { n.fail = cb.checked; invalidateRun(); paint(); });
lab.appendChild(cb); lab.appendChild(document.createTextNode("make this task fail when it runs"));
var f3 = document.createElement("div"); f3.className = "field";
var l3 = document.createElement("label");
var ds = depsOf(n.id);
l3.textContent = "waits for (" + ds.length + ")";
f3.appendChild(l3);
if (!ds.length) {
var e0 = document.createElement("div"); e0.className = "mono"; e0.textContent = "nothing — starts in wave 1";
f3.appendChild(e0);
} else {
var ul2 = document.createElement("ul"); ul2.className = "deps";
ds.forEach(function (d) {
var li = document.createElement("li");
var s = document.createElement("span"); s.textContent = d.name;
var x = document.createElement("button"); x.className = "x"; x.type = "button";
x.textContent = "✕"; x.setAttribute("aria-label", "Remove dependency on " + d.name);
x.addEventListener("click", function () { removeEdge(d.id, n.id); });
li.appendChild(s); li.appendChild(x); ul2.appendChild(li);
});
f3.appendChild(ul2);
}
var meta = document.createElement("div"); meta.className = "mono";
var rk = ranks();
meta.textContent = "wave " + (rk ? rk[n.id] + 1 : "—") + " · feeds " + outOf(n.id).length + " task" + (outOf(n.id).length === 1 ? "" : "s");
var del = document.createElement("button"); del.className = "btn sm"; del.type = "button";
del.textContent = "Delete task";
del.addEventListener("click", function () { removeTask(n.id); });
[f1, f2, lab, f3, meta, del].forEach(function (el) { body.appendChild(el); });
}
/* ── interaction: nodes ─────────────────────────────────────────────────── */
function wireNode(g, id, porthit) {
var drag = null;
g.addEventListener("pointerdown", function (e) {
if (e.button !== undefined && e.button !== 0) return;
e.stopPropagation();
if (connect) return;
sel = id; selEdge = null; paint(); stats(); panel();
var n = byId(id); if (!n) return;
var p = toWorld(e.clientX, e.clientY);
drag = { dx: p.x - n.x, dy: p.y - n.y, moved: false };
try { g.setPointerCapture(e.pointerId); } catch (err) {}
g.classList.add("dragging");
});
g.addEventListener("pointermove", function (e) {
if (!drag) return;
var n = byId(id); if (!n) return;
var p = toWorld(e.clientX, e.clientY);
n.x = p.x - drag.dx; n.y = p.y - drag.dy; drag.moved = true; touched = true;
geom();
});
var end = function (e) {
if (!drag) return;
drag = null; g.classList.remove("dragging");
try { g.releasePointerCapture(e.pointerId); } catch (err) {}
};
g.addEventListener("pointerup", end);
g.addEventListener("pointercancel", end);
g.addEventListener("focus", function () { sel = id; selEdge = null; paint(); stats(); panel(); });
porthit.addEventListener("pointerdown", function (e) {
e.stopPropagation(); e.preventDefault();
var n = byId(id); if (!n) return;
var pp = portPos(n);
connect = { from: id, gx: pp.x, gy: pp.y, pointer: e.pointerId };
try { board.setPointerCapture(e.pointerId); } catch (err) {}
drawGhost(pp.x, pp.y);
});
}
function drawGhost(x, y, target) {
glayer.textContent = "";
if (!connect) return;
var a = byId(connect.from); if (!a) return;
var p = anchor(a, { x: x - W/2, y: y - H/2 });
var path = mk("path", { "class": "ghost", d: curve(p, { x: x, y: y }) });
glayer.appendChild(path);
nodes.forEach(function (n) {
var r = nodeEls.get(n.id); if (!r) return;
r.g.classList.toggle("sel", !!target && target.id === n.id);
});
}
/* board-level pointer handling: pan + connect */
var pan = null;
board.addEventListener("pointerdown", function (e) {
if (connect) return;
if (e.button !== undefined && e.button !== 0) return;
pan = { sx: e.clientX, sy: e.clientY, vx: view.x, vy: view.y, moved: false };
try { board.setPointerCapture(e.pointerId); } catch (err) {}
board.classList.add("panning");
});
board.addEventListener("pointermove", function (e) {
if (connect) {
var p = toWorld(e.clientX, e.clientY);
drawGhost(p.x, p.y, nodeAt(p));
return;
}
if (!pan) return;
touched = true;
view.x = pan.vx + (e.clientX - pan.sx);
view.y = pan.vy + (e.clientY - pan.sy);
if (Math.abs(e.clientX - pan.sx) + Math.abs(e.clientY - pan.sy) > 3) pan.moved = true;
applyView();
});
function boardUp(e) {
if (connect) {
var p = toWorld(e.clientX, e.clientY);
var tgt = nodeAt(p);
var from = connect.from;
connect = null; glayer.textContent = "";
try { board.releasePointerCapture(e.pointerId); } catch (err) {}
paint();
if (tgt) addEdge(from, tgt.id);
else banner("info", "Drop the arrow on a task to create a dependency.", "");
return;
}
if (pan) {
if (!pan.moved) { sel = null; selEdge = null; paint(); stats(); panel(); }
pan = null; board.classList.remove("panning");
try { board.releasePointerCapture(e.pointerId); } catch (err) {}
}
}
board.addEventListener("pointerup", boardUp);
board.addEventListener("pointercancel", function (e) {
connect = null; glayer.textContent = ""; pan = null;
board.classList.remove("panning"); paint();
});
board.addEventListener("wheel", function (e) {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
var rc = board.getBoundingClientRect(), cx = e.clientX - rc.left, cy = e.clientY - rc.top;
var nk = Math.max(0.28, Math.min(2.2, view.k * (e.deltaY < 0 ? 1.12 : 1 / 1.12)));
view.x = cx - (cx - view.x) * (nk / view.k);
view.y = cy - (cy - view.y) * (nk / view.k);
view.k = nk; touched = true; applyView();
}, { passive: false });
/* ── keyboard ───────────────────────────────────────────────────────────── */
var kbConnect = null;
document.addEventListener("keydown", function (e) {
/* never hijack keys aimed at a real control — buttons, inputs, sliders */
var ctl = e.target && e.target.closest && e.target.closest("button, input, select, textarea, a[href]");
if (ctl) {
if (e.key === "Escape" && ctl.blur) ctl.blur();
return;
}
var onNode = e.target && e.target.classList && e.target.classList.contains("node");
var id = onNode ? e.target.dataset.id : sel;
var n = id ? byId(id) : null;
if (e.key === "Escape") {
if (kbConnect) { kbConnect = null; banner("info", "Connection cancelled.", ""); }
else { sel = null; selEdge = null; }
connect = null; glayer.textContent = "";
paint(); stats(); panel(); return;
}
if ((e.key === "Delete" || e.key === "Backspace")) {
if (selEdge) { e.preventDefault(); var pp = selEdge.split(">"); removeEdge(pp[0], pp[1]); return; }
if (n) { e.preventDefault(); removeTask(n.id); return; }
}
if (!n) return;
if (e.key === "c" || e.key === "C") {
e.preventDefault();
kbConnect = n.id;
banner("info", "Connect mode — Tab to another task and press Enter.", "from " + n.name);
return;
}
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (kbConnect && kbConnect !== n.id) {
var f = kbConnect; kbConnect = null;
if (addEdge(f, n.id)) { /* banner set by addEdge */ }
var el = nodeEls.get(n.id); if (el) el.g.focus();
} else { sel = n.id; selEdge = null; paint(); stats(); panel(); }
return;
}
var step = e.shiftKey ? 40 : 10, moved = false;
if (e.key === "ArrowLeft") { n.x -= step; moved = true; }
if (e.key === "ArrowRight") { n.x += step; moved = true; }
if (e.key === "ArrowUp") { n.y -= step; moved = true; }
if (e.key === "ArrowDown") { n.y += step; moved = true; }
if (moved) { e.preventDefault(); touched = true; geom(); }
});
/* ── controls ───────────────────────────────────────────────────────────── */
$("run").addEventListener("click", function () {
if (running) { cancelRun(); return; }
runPipeline();
});
$("reset").addEventListener("click", function () {
cancelRun(true);
runToken++;
nodes.forEach(function (n) { n.st = "idle"; });
runState = { wave: 0, total: 0 }; runOk = null;
pill("IDLE"); setRunBtn(false); clearLog(); paint(); stats(); panel();
banner("info", "Run state cleared.", "graph kept · " + plural(nodes.length, "task"));
});
$("tidy").addEventListener("click", function () { autoLayout(); geom(); fit(); banner("info", "Layout tidied by wave.", ""); });
$("seed").addEventListener("click", function () { loadSeed(); banner("info", "Example ETL pipeline reloaded.", "7 tasks · 7 dependencies"); });
$("addf").addEventListener("submit", function (e) {
e.preventDefault();
addTask($("newname").value);
$("newname").value = "";
});
$("zin").addEventListener("click", function () { zoomBy(1.18); });
$("zout").addEventListener("click", function () { zoomBy(1 / 1.18); });
$("zfit").addEventListener("click", fit);
$("critbtn").addEventListener("click", function () {
showCrit = !showCrit;
this.setAttribute("aria-pressed", showCrit ? "true" : "false");
paint();
if (showCrit) {
var cp = criticalPath();
if (!cp.ids.length) banner("info", "Nothing to highlight.", "the graph is empty");
else banner("info", "Critical path — the chain that sets the wall clock.",
cp.ids.map(function (i) { return byId(i).name; }).join(" → ") + " = " + cp.total.toFixed(2) + "s");
} else banner(null);
});
/* ── responsive ─────────────────────────────────────────────────────────── */
/* pick the orientation that lets the whole graph render biggest in this canvas */
function wantOrient() {
var rc = board.getBoundingClientRect();
if (!rc.width) return orient;
var a = layoutSize("LR"), b = layoutSize("TB");
if (!a || !b) return rc.width >= 600 ? "LR" : "TB";
var scale = function (s) { return Math.min((rc.width - 44) / s.w, (rc.height - 66) / s.h, 1.12); };
/* dependency graphs read left-to-right, so LR wins ties and near-ties */
return scale(a) * 1.18 >= scale(b) ? "LR" : "TB";
}
var ro = new ResizeObserver(function () {
var w = wantOrient();
if (w !== orient) { orient = w; autoLayout(); geom(); touched = false; fit(); }
else if (!touched) fit();
});
/* ── boot ───────────────────────────────────────────────────────────────── */
orient = "LR";
loadSeed();
requestAnimationFrame(function () {
orient = wantOrient();
autoLayout(); syncAll(); fit();
ro.observe(board);
});
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.