mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 16:02:56 +00:00
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:
co-authored by
Claude Opus 5.5
parent
549f0427f1
commit
e3cfca6d99
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user