feat: hovering a pipeline graph node fades everything outside its lineage

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-10-01 12:44:31 +02:00
co-authored by Claude Opus 5.5
parent 549f0427f1
commit e3cfca6d99
3 changed files with 103 additions and 2 deletions
@@ -54,7 +54,7 @@
CONTEXT_MENU_ITEM_HOVER_CLASS
} from '$lib/components/common/contextmenu/contextMenuStyles'
import { computeMutedReadKeys, dbtAssociations } from './resolveGraph'
import { buildDownstreamMap } from './graphTraversal'
import { buildDownstreamMap, hoverLineage } from './graphTraversal'
import { buildLineageDownstreamMap } from './boundedCascade'
import type {
AssetGraphResponse,
@@ -1613,6 +1613,21 @@
// project node lights up every model it owns. Clicking the badge selects the
// project node, so the association survives the pointer leaving.
let dbtHoverId = $state<string | undefined>(undefined)
// Hovering a node keeps its lineage — everything upstream and downstream of
// it, recursively — at full strength and fades the rest. Off while a bounded
// pick or a subscribe drag owns the graph's dimming.
let hoveredNodeId = $state<string | undefined>(undefined)
let lineage = $derived(
hoveredNodeId && hoveredNodeId !== ADD_NODE_ID && !boundPick && !onDrag
? hoverLineage(
view.edges.filter((e) => e.kind !== 'add-anchor'),
hoveredNodeId
)
: undefined
)
const outsideLineage = (id: string) =>
!!lineage && id !== ADD_NODE_ID && !lineage.upstream.has(id) && !lineage.downstream.has(id)
let dbtEmphasisIds = $derived.by(() => {
if (!dbtHoverId) return new Set<string>()
const owned = model.dbtWritesByOwner.get(dbtHoverId)
@@ -1799,7 +1814,13 @@
// The layout places centers; a node's position is its top-left corner.
position: { x: p.x - nodeWidth(n) / 2 + xCenter, y: p.y + 40 },
data,
class: dragClass ?? boundClass ?? dbtClass ?? runClass ?? assetClass,
class:
[
dragClass ?? boundClass ?? dbtClass ?? runClass ?? assetClass,
outsideLineage(n.id) ? 'wm-hover-dim' : undefined
]
.filter(Boolean)
.join(' ') || undefined,
selected: n.id === selectedId,
// All nodes non-draggable: the layout is sugiyama-computed,
// dragging would fight the reactive re-layout. Selection is
@@ -2029,6 +2050,7 @@
if (strokeDasharray) {
style = `${style} stroke-dasharray: ${strokeDasharray};`
}
if (lineage && !lineage.hasEdge(e)) style = `${style} opacity: 0.15;`
return {
id: e.id,
source: e.source,
@@ -2162,6 +2184,8 @@
defaultEdgeOptions={{ type: 'asset' }}
proOptions={{ hideAttribution: true }}
onnodeclick={handleNodeClick}
onnodepointerenter={({ node }) => (hoveredNodeId = node.id)}
onnodepointerleave={() => (hoveredNodeId = undefined)}
onedgepointerenter={onEdgeEnter}
onedgepointerleave={onEdgeLeave}
--background-color={false}
@@ -2427,6 +2451,14 @@
:global(.svelte-flow__node.wm-bound-end .drop-shadow-sm) {
@apply outline outline-[3px] outline-amber-500;
}
/* Hovering a node: what is not in its lineage recedes. */
:global(.svelte-flow__node.wm-hover-dim) {
opacity: 0.25;
}
:global(.svelte-flow__node),
:global(.svelte-flow__edge path) {
transition: opacity 120ms;
}
:global(.svelte-flow__node.wm-bound-dim) {
@apply opacity-30;
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { AssetGraphResponse } from './types'
import {
hoverLineage,
buildDownstreamMap,
computeDownstreamClosure,
computeInducedSchedule
@@ -225,3 +226,31 @@ describe('computeInducedSchedule', () => {
expect(sched.cyclic.sort()).toEqual(['b', 'c'])
})
})
describe('hoverLineage', () => {
// a → b → c → d, with a side branch x → c and c → y; z is unrelated to b.
const edges = [
{ source: 'a', target: 'b' },
{ source: 'b', target: 'c' },
{ source: 'x', target: 'c' },
{ source: 'c', target: 'd' },
{ source: 'c', target: 'y' },
{ source: 'z', target: 'y' }
]
it('reaches everything upstream and downstream, recursively', () => {
const l = hoverLineage(edges, 'b')
expect([...l.upstream].sort()).toEqual(['a', 'b'])
expect([...l.downstream].sort()).toEqual(['b', 'c', 'd', 'y'])
})
it('keeps only the edges on a path through the node', () => {
const l = hoverLineage(edges, 'b')
expect(edges.filter(l.hasEdge)).toEqual([
{ source: 'a', target: 'b' },
{ source: 'b', target: 'c' },
{ source: 'c', target: 'd' },
{ source: 'c', target: 'y' }
])
})
})
@@ -226,3 +226,43 @@ export function computeInducedSchedule(
const roots = ordered.filter((n) => (cleanIndegree.get(n) ?? 0) === 0)
return { nodes: ordered, edges: cleanEdges, indegree: cleanIndegree, roots, cyclic }
}
/** What is linked to a node through the drawn graph: everything it depends on
* (upstream) and everything depending on it (downstream), both recursive, the
* node itself in each. An edge lies on a path through the node exactly when
* both its ends are upstream, or both downstream. */
export type HoverLineage = {
upstream: Set<string>
downstream: Set<string>
hasEdge: (e: { source: string; target: string }) => boolean
}
export function hoverLineage(
edges: ReadonlyArray<{ source: string; target: string }>,
id: string
): HoverLineage {
const walk = (from: 'source' | 'target', to: 'source' | 'target') => {
const next = new Map<string, string[]>()
for (const e of edges) next.set(e[from], [...(next.get(e[from]) ?? []), e[to]])
const seen = new Set([id])
const stack = [id]
while (stack.length) {
for (const n of next.get(stack.pop()!) ?? []) {
if (!seen.has(n)) {
seen.add(n)
stack.push(n)
}
}
}
return seen
}
const upstream = walk('target', 'source')
const downstream = walk('source', 'target')
return {
upstream,
downstream,
hasEdge: (e) =>
(upstream.has(e.source) && upstream.has(e.target)) ||
(downstream.has(e.source) && downstream.has(e.target))
}
}