mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 16:02:56 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b91ecbe458
commit
486de8f755
@@ -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<string | undefined>(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<typeof setTimeout> | 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<string>()
|
||||
)
|
||||
let lineageSheet = $state<HTMLStyleElement | undefined>(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}
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string> {
|
||||
const next = new Map<string, Set<string>>()
|
||||
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<string>([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<string>()
|
||||
for (const e of edges) if (e.source !== e.target && reachableAround(e.source, e.target)) implied.add(e.id)
|
||||
return implied
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user