From 486de8f75597cc38a972778e37db3ed933986e3a Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Thu, 1 Oct 2026 13:00:47 +0200 Subject: [PATCH] feat: gentler hover fade without flicker on click; implied trigger edges recede in the assets-only view Co-Authored-By: Claude Opus 5.5 (1M context) --- .../assets/AssetGraph/AssetGraphCanvas.svelte | 39 ++++++++++++++++--- .../assets/AssetGraph/graphTraversal.test.ts | 14 +++++++ .../assets/AssetGraph/graphTraversal.ts | 33 ++++++++++++++++ 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte index a2037d438c..5ea27be717 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte @@ -56,7 +56,7 @@ CONTEXT_MENU_ITEM_HOVER_CLASS } from '$lib/components/common/contextmenu/contextMenuStyles' import { computeMutedReadKeys, dbtAssociations } from './resolveGraph' - import { buildDownstreamMap, hoverLineage } from './graphTraversal' + import { buildDownstreamMap, hoverLineage, transitivelyImpliedEdges } from './graphTraversal' import { buildLineageDownstreamMap } from './boundedCascade' import type { AssetGraphResponse, @@ -1620,6 +1620,23 @@ // 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) + // A click re-renders the graph under the cursor (the selection rebuilds the + // nodes, the details pane resizes or covers the canvas), which fires bursts of + // leave/enter. The fade waits for the pointer to settle on a node, outlives a + // brief leave, and stays off on a node just clicked until another is hovered. + let hoverTimer: ReturnType | undefined + let clickedNodeId: string | undefined + function onNodePointerEnter(id: string) { + clearTimeout(hoverTimer) + if (id === clickedNodeId) return + clickedNodeId = undefined + hoverTimer = setTimeout(() => (hoveredNodeId = id), 120) + } + function onNodePointerLeave() { + clearTimeout(hoverTimer) + hoverTimer = setTimeout(() => (hoveredNodeId = undefined), 80) + } + $effect(() => () => clearTimeout(hoverTimer)) let lineage = $derived( hoveredNodeId && hoveredNodeId !== ADD_NODE_ID && !boundPick && !onDrag ? hoverLineage( @@ -1643,10 +1660,17 @@ .filter((e) => e.kind !== 'add-anchor' && !lineage!.hasEdge(e)) .map((e) => `${scope} .svelte-flow__edge[data-id="${CSS.escape(e.id)}"]`) return [ - nodes.length ? `${nodes.join(',')}{opacity:0.25}` : '', - edges.length ? `${edges.join(',')}{opacity:0.15}` : '' + nodes.length ? `${nodes.join(',')}{opacity:0.5}` : '', + edges.length ? `${edges.join(',')}{opacity:0.35}` : '' ].join('') }) + // Assets-only: a trigger edge A → C that A → B → … → C already implies says + // nothing new, so it recedes; the chain carries the cascade. + let impliedTriggerEdges = $derived( + assetsOnly + ? transitivelyImpliedEdges(view.edges.filter((e) => e.kind === 'asset-flow' && e.reactive)) + : new Set() + ) let lineageSheet = $state(undefined) onMount(() => { const sheet = document.createElement('style') @@ -1972,7 +1996,7 @@ // Assets-only: a write upstream reruns what builds the target // (solid, like a trigger), or the target only reads it (dashed gray). if (e.reactive) { - style = 'stroke: rgb(59 130 246); stroke-width: 1.5px;' + style = `stroke: rgb(59 130 246); stroke-width: 1.5px;${impliedTriggerEdges.has(e.id) ? ' opacity: 0.35;' : ''}` markerColor = 'rgb(59 130 246)' } else { style = 'stroke: rgb(156 163 175); stroke-width: 1px;' @@ -2135,6 +2159,9 @@ } function handleNodeClick({ node }: { node: Node }) { + clearTimeout(hoverTimer) + hoveredNodeId = undefined + clickedNodeId = node.id // Bounded-run pick mode intercepts clicks: an eligible (downstream) // node toggles as an end bound; the start, dimmed nodes, and // triggers/+ are inert. Selection (details pane) is suppressed so the @@ -2209,8 +2236,8 @@ defaultEdgeOptions={{ type: 'asset' }} proOptions={{ hideAttribution: true }} onnodeclick={handleNodeClick} - onnodepointerenter={({ node }) => (hoveredNodeId = node.id)} - onnodepointerleave={() => (hoveredNodeId = undefined)} + onnodepointerenter={({ node }) => onNodePointerEnter(node.id)} + onnodepointerleave={onNodePointerLeave} onedgepointerenter={onEdgeEnter} onedgepointerleave={onEdgeLeave} --background-color={false} diff --git a/frontend/src/lib/components/assets/AssetGraph/graphTraversal.test.ts b/frontend/src/lib/components/assets/AssetGraph/graphTraversal.test.ts index fc2b355f73..d6cf76c389 100644 --- a/frontend/src/lib/components/assets/AssetGraph/graphTraversal.test.ts +++ b/frontend/src/lib/components/assets/AssetGraph/graphTraversal.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { AssetGraphResponse } from './types' import { hoverLineage, + transitivelyImpliedEdges, buildDownstreamMap, computeDownstreamClosure, computeInducedSchedule @@ -254,3 +255,16 @@ describe('hoverLineage', () => { ]) }) }) + +describe('transitivelyImpliedEdges', () => { + it('flags an edge another path already implies, and only that one', () => { + const edges = [ + { id: 'ab', source: 'a', target: 'b' }, + { id: 'bc', source: 'b', target: 'c' }, + { id: 'ac', source: 'a', target: 'c' }, + { id: 'ad', source: 'a', target: 'd' }, + { id: 'ad2', source: 'a', target: 'd' } + ] + expect([...transitivelyImpliedEdges(edges)]).toEqual(['ac']) + }) +}) diff --git a/frontend/src/lib/components/assets/AssetGraph/graphTraversal.ts b/frontend/src/lib/components/assets/AssetGraph/graphTraversal.ts index d79979342a..0c9fad6c83 100644 --- a/frontend/src/lib/components/assets/AssetGraph/graphTraversal.ts +++ b/frontend/src/lib/components/assets/AssetGraph/graphTraversal.ts @@ -266,3 +266,36 @@ export function hoverLineage( (downstream.has(e.source) && downstream.has(e.target)) } } + +/** Ids of the edges another path already implies: an edge A → C when C can be + * reached from A through at least one other node (A → B → … → C). Edges joining + * the same two nodes don't make each other redundant. */ +export function transitivelyImpliedEdges( + edges: ReadonlyArray<{ id: string; source: string; target: string }> +): Set { + const next = new Map>() + for (const e of edges) { + if (e.source === e.target) continue + next.set(e.source, (next.get(e.source) ?? new Set()).add(e.target)) + } + // Whether `to` is reachable from `from` without taking the direct step. + const reachableAround = (from: string, to: string) => { + const seen = new Set([from]) + const stack = [...(next.get(from) ?? [])].filter((n) => n !== to) + for (const n of stack) seen.add(n) + while (stack.length) { + const cur = stack.pop()! + for (const n of next.get(cur) ?? []) { + if (n === to) return true + if (!seen.has(n)) { + seen.add(n) + stack.push(n) + } + } + } + return false + } + const implied = new Set() + for (const e of edges) if (e.source !== e.target && reachableAround(e.source, e.target)) implied.add(e.id) + return implied +}