diff --git a/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.css b/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.css index e7c9f71a..f9a486c0 100644 --- a/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.css +++ b/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.css @@ -36,37 +36,6 @@ flex-wrap: wrap; } -/* Dependency-edge gutter (only present on rows in a sibling list that has - at least one `Node`-kind dep among it — see hive-jobq-graph.js - `renderGroup`). A plain rail (no line, no dot) on every other row in - that list keeps the state glyph aligned; rows outside such a list carry - no rail element at all, so the common case is untouched. */ -.jg-edge-rail { - position: relative; - align-self: stretch; - flex: none; - width: 0.7em; -} -.jg-edge-rail.jg-edge-on::after { - content: ''; - position: absolute; - left: 50%; - top: var(--jg-rail-top, 50%); - bottom: var(--jg-rail-bottom, 50%); - width: 0; - border-left: 2px solid var(--cyan); -} -.jg-edge-rail.jg-edge-dot::before { - content: ''; - position: absolute; - left: calc(50% - 0.19em); - top: calc(50% - 0.19em); - width: 0.38em; - height: 0.38em; - border-radius: 50%; - background: var(--cyan); -} - .jg-state { font-weight: bold; min-width: 1.2em; diff --git a/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js b/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js index 0a9bcbb5..c593587d 100644 --- a/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js +++ b/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js @@ -10,10 +10,6 @@ // a build-log link, ...) does its own thing on top; this is the generic // floor every jobq gets for free. // -// `Node`-kind dep edges get a small connecting rail in each sibling list's -// left gutter — see `renderGroup`/`orderSiblings` below for why that's -// always sibling-local, never a whole-graph layout question. -// // Usage: — // self-fetches on connect. `.refresh()` (public) re-fetches + re-renders; // `.render(nodes)` (public) renders host-pushed data directly, no fetch. @@ -43,7 +39,11 @@ const STATE_GLYPH = { }; // Build a parent/child tree from the flat wire array. `parent` (structural -// grouping) defines tree shape. +// grouping) defines tree shape. Sibling order follows array order, which +// is already root-then-subtree per root per `GraphWire::wire_snapshot`'s +// own doc contract — no client-side topo-sort needed for *display* order +// (dependency edges are for a consumer's own logic, e.g. cancel-gating, +// not for render order). function buildTree(nodes) { const byId = new Map(nodes.map((n) => [n.id, { ...n, _children: [] }])); const roots = []; @@ -55,87 +55,6 @@ function buildTree(nodes) { return roots; } -// A `Node`-kind dep only ever names a sibling under the same parent (product -// decision on the dep-edge-visibility issue — nothing crosses a group -// boundary), so a dependency edge is always local to one sibling list. This -// reorders that list so a dependency always renders before what depends on -// it, and returns the edges as index ranges into the *new* order for the -// gutter-rail renderer below. -// -// Ordering is scoped **per connected component** of the local dependency -// graph, not one flat topo sort over the whole list — two independent dep -// pairs (no edge relates them, directly or transitively) must never end up -// interleaved, or the single-column rail below would draw one continuous -// line across both and imply a relationship that doesn't exist. Emitting -// each component as a contiguous block (in first-seen order, so an already- -// correct list doesn't reorder unnecessarily) keeps every edge's [lo, hi] -// span either fully inside its own component's block or, within a -// component, genuinely overlapping because the nodes really are related -// (e.g. a diamond: two siblings both depending on the same third one). -// -// O(n^2) worst case (component discovery + each component's own topo sort -// rescans its remaining members per pass) — fine here, sibling-list sizes -// are small (tens, not thousands) and this runs once per render, not per -// frame. -function orderSiblings(list) { - if (list.length < 2) return { order: list, ranges: [] }; - const idx = new Map(list.map((n, i) => [n.id, i])); - // Deps whose target isn't in this list (shouldn't happen per the product - // decision above, but a filtered view could still omit a target) are - // dropped rather than crashing the sort. - const localDeps = list.map((n) => - (n.deps || []) - .filter((d) => d.kind === 'Node' && idx.has(d.id)) - .map((d) => idx.get(d.id)), - ); - - const adjacency = list.map(() => []); - localDeps.forEach((deps, i) => { - for (const d of deps) { adjacency[i].push(d); adjacency[d].push(i); } - }); - const componentOf = new Array(list.length).fill(-1); - let numComponents = 0; - for (let start = 0; start < list.length; start++) { - if (componentOf[start] !== -1) continue; - const stack = [start]; - componentOf[start] = numComponents; - while (stack.length) { - const i = stack.pop(); - for (const j of adjacency[i]) { - if (componentOf[j] === -1) { componentOf[j] = numComponents; stack.push(j); } - } - } - numComponents++; - } - - const orderIdx = []; - const emitted = new Array(list.length).fill(false); - for (let start = 0; start < list.length; start++) { - if (emitted[start]) continue; - const members = []; - for (let i = 0; i < list.length; i++) if (componentOf[i] === componentOf[start]) members.push(i); - const placed = new Set(); - let remaining = members; - while (remaining.length) { - const ready = remaining.filter((i) => localDeps[i].every((d) => placed.has(d))); - // A cycle can't happen from a well-formed graph, but if it ever - // does, dump whatever's left of this component in original order - // rather than looping forever. - const take = ready.length ? ready : remaining; - for (const i of take) { orderIdx.push(i); placed.add(i); emitted[i] = true; } - remaining = remaining.filter((i) => !placed.has(i)); - } - } - - const posOf = new Array(list.length); - orderIdx.forEach((origIdx, pos) => { posOf[origIdx] = pos; }); - const ranges = []; - localDeps.forEach((deps, i) => { - for (const d of deps) ranges.push({ lo: Math.min(posOf[i], posOf[d]), hi: Math.max(posOf[i], posOf[d]) }); - }); - return { order: orderIdx.map((i) => list[i]), ranges }; -} - // `payload.data` is an opaque JSON value from the host's `WireNode::data` // — render it as a generic key/value list when it's a plain object (the // only shape a host is expected to send; anything else falls back to a @@ -155,23 +74,9 @@ function renderDataList(data) { return dl; } -// A rail span for the dependency-edge gutter (see `renderGroup` below). -// `top`/`bottom` say whether the connecting line extends into the top/ -// bottom half of this row; `dot` marks the row that's actually waiting -// (the range's "hi" end). No edges touch this row → a plain empty span, -// same DOM shape rendering always had before edges existed. -function renderRail(top, bottom, dot, title) { - if (!top && !bottom && !dot) return el('span', { class: 'jg-edge-rail' }); - const rail = el('span', { class: 'jg-edge-rail jg-edge-on' + (dot ? ' jg-edge-dot' : ''), title: title || '' }); - rail.style.setProperty('--jg-rail-top', top ? '0' : '50%'); - rail.style.setProperty('--jg-rail-bottom', bottom ? '0' : '50%'); - return rail; -} - -function renderNode(n, rail) { +function renderNode(n) { const glyph = STATE_GLYPH[n.state] || '?'; const row = el('div', { class: 'jg-row' }, - rail, el('span', { class: 'jg-state jg-state-' + n.state.toLowerCase(), title: n.state + (n.error ? ' — ' + n.error : ''), @@ -183,43 +88,10 @@ function renderNode(n, rail) { const data = renderDataList(n.payload.data); if (data) wrap.append(data); if (n.error) wrap.append(el('pre', { class: 'jg-error' }, n.error)); - for (const child of renderGroup(n._children)) wrap.append(child); + for (const child of n._children) wrap.append(renderNode(child)); return wrap; } -// Render one sibling list (either `_children` of a node, or the top-level -// roots), reordered so dependencies render before what depends on them, with -// a gutter rail marking `Node`-kind dep edges between siblings. Node-kind -// deps only ever name a sibling under the same parent — nothing crosses a -// group boundary — so this is the only place edges need -// rendering; the common case (no deps in this list) skips the rail entirely, -// same markup as before edges existed. -function renderGroup(list) { - const { order, ranges } = orderSiblings(list); - if (!ranges.length) return order.map((n) => renderNode(n)); - const top = new Array(order.length).fill(false); - const bottom = new Array(order.length).fill(false); - const dot = new Array(order.length).fill(false); - const titles = new Array(order.length).fill(''); - const addTitle = (i, text) => { titles[i] = titles[i] ? titles[i] + '; ' + text : text; }; - for (const { lo, hi } of ranges) { - bottom[lo] = true; - top[hi] = true; - dot[hi] = true; - addTitle(hi, 'waits on: ' + order[lo].payload.label); - addTitle(lo, 'blocks: ' + order[hi].payload.label); - for (let k = lo + 1; k < hi; k++) { - top[k] = true; - bottom[k] = true; - // This row isn't itself either end of the edge, just sitting between - // them in render order — say what's passing through so the rail - // doesn't read as an unexplained line. - addTitle(k, order[lo].payload.label + ' → ' + order[hi].payload.label + ' passes through here'); - } - } - return order.map((n, i) => renderNode(n, renderRail(top[i], bottom[i], dot[i], titles[i]))); -} - class HiveJobqGraph extends HTMLElement { connectedCallback() { // Reconnect-without-detach guard — same hazard / @@ -259,7 +131,7 @@ class HiveJobqGraph extends HTMLElement { this._body.append(el('p', { class: 'jg-empty' }, 'empty')); } else { const roots = buildTree(nodes); - for (const root of renderGroup(roots)) this._body.append(root); + for (const root of roots) this._body.append(renderNode(root)); } this.dispatchEvent(new CustomEvent('hive-jobq-graph-update', { detail: { nodes: nodes || [] },