From e3cfca6d991d7f0d439a5c218078be821090fcd4 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Thu, 1 Oct 2026 12:44:31 +0200 Subject: [PATCH] feat: hovering a pipeline graph node fades everything outside its lineage Co-Authored-By: Claude Opus 5.5 (1M context) --- .../assets/AssetGraph/AssetGraphCanvas.svelte | 36 ++++++++++++++++- .../assets/AssetGraph/graphTraversal.test.ts | 29 ++++++++++++++ .../assets/AssetGraph/graphTraversal.ts | 40 +++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte index d9bfbb52c4..9422ad4045 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte @@ -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(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(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() 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; } diff --git a/frontend/src/lib/components/assets/AssetGraph/graphTraversal.test.ts b/frontend/src/lib/components/assets/AssetGraph/graphTraversal.test.ts index 197d7eca6c..fc2b355f73 100644 --- a/frontend/src/lib/components/assets/AssetGraph/graphTraversal.test.ts +++ b/frontend/src/lib/components/assets/AssetGraph/graphTraversal.test.ts @@ -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' } + ]) + }) +}) diff --git a/frontend/src/lib/components/assets/AssetGraph/graphTraversal.ts b/frontend/src/lib/components/assets/AssetGraph/graphTraversal.ts index cc59e8f3a8..d79979342a 100644 --- a/frontend/src/lib/components/assets/AssetGraph/graphTraversal.ts +++ b/frontend/src/lib/components/assets/AssetGraph/graphTraversal.ts @@ -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 + downstream: Set + 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() + 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)) + } +}