feat: describe a pipeline edge's relationship on hover

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-30 16:43:02 +02:00
co-authored by Claude Opus 5.5
parent 26781d66b9
commit 358b7dec44
5 changed files with 218 additions and 6 deletions
@@ -28,6 +28,7 @@
type AssetUpstreamDelete
} from './assetsOnlyView'
import { assetsOnlyNodeWidth } from './assetNodeWidth'
import { describeEdge } from './edgeDescription'
import { isAssetsOnlyFolder, setAssetsOnlyFolder } from './assetsOnlyFolders'
import { formatAssetKind, formatShortAssetPath } from '$lib/components/assets/lib'
import { TRIGGER_NODE_STYLE } from './TriggerNode.svelte'
@@ -267,6 +268,34 @@
// Right-click on the empty canvas: a menu at the pointer whose entry opens the
// add-data-source menu there too.
let paneMenu = $state<{ x: number; y: number } | undefined>(undefined)
// Hovering an edge names the relationship it draws, after a short pause so
// sweeping the pointer across the canvas doesn't flash popovers.
let hoveredEdge = $state<{ id: string; x: number; y: number } | undefined>(undefined)
let edgeHoverTimer: ReturnType<typeof setTimeout> | undefined
function onEdgeEnter({ edge, event }: { edge: Edge; event: PointerEvent }) {
clearTimeout(edgeHoverTimer)
const at = { id: edge.id, x: event.clientX, y: event.clientY }
edgeHoverTimer = setTimeout(() => (hoveredEdge = at), 300)
}
function onEdgeLeave() {
clearTimeout(edgeHoverTimer)
hoveredEdge = undefined
}
let hoveredEdgeText = $derived.by(() => {
const e = hoveredEdge && view.edges.find((x) => x.id === hoveredEdge!.id)
if (!e) return undefined
const byId = new Map(view.nodes.map((n) => [n.id, n]))
const nameOf = (id: string) => {
const n = byId.get(id)
if (!n) return id.replace(/^[^:]+:/, '')
if (n.type === 'asset') return formatShortAssetPath({ kind: n.data.asset_kind, path: n.data.path })
if (n.type === 'no-asset') return 'the scripts that build no asset'
if (n.type === 'trigger') return n.data.summary || n.data.ref
return n.data.summary || n.data.path
}
return describeEdge(e, nameOf, (id) => byId.get(id)?.data)
})
let addMenuAt = $state<{ x: number; y: number }>({ x: 0, y: 0 })
let addMenuSignal = $state(0)
function handleContextMenu(e: MouseEvent) {
@@ -332,6 +361,8 @@
// whole lib when pulled in via `// use`) — rendered as a ƒ badge.
macro_names?: string[]
via_use?: boolean
// Assets-only edges: the scripts folded between the two assets.
via?: string[]
}
// Graph-id of the script the user just launched (zero-latency hint),
@@ -1635,6 +1666,8 @@
defaultEdgeOptions={{ type: 'asset' }}
proOptions={{ hideAttribution: true }}
onnodeclick={handleNodeClick}
onedgepointerenter={onEdgeEnter}
onedgepointerleave={onEdgeLeave}
--background-color={false}
>
<div class="absolute inset-0 !bg-surface-secondary h-full"></div>
@@ -1719,6 +1752,21 @@
</div>
{/if}
{#if hoveredEdge && hoveredEdgeText}
{@const d = hoveredEdgeText}
<div
class="fixed z-50 max-w-80 rounded-md border bg-surface px-2.5 py-1.5 text-xs text-primary shadow-md pointer-events-none"
style="left: {hoveredEdge.x + 12}px; top: {hoveredEdge.y + 12}px;"
transition:fly={{ duration: 120, y: -4 }}
role="tooltip"
>
<span class="font-semibold text-emphasis">{d.subject}</span>
{d.phrase}
{#if d.object}<span class="font-semibold text-emphasis">{d.object}</span>{/if}
{#if d.note}<div class="mt-0.5 text-2xs text-secondary">{d.note}</div>{/if}
</div>
{/if}
{#if onAddPipelineScript}
<div class="fixed w-0 h-0" style="left: {addMenuAt.x}px; top: {addMenuAt.y}px;">
<AddDataSourceMenu
@@ -179,7 +179,7 @@
path={edgePath}
{markerEnd}
{style}
interactionWidth={0}
interactionWidth={12}
label={undefined}
labelStyle={undefined}
/>
@@ -8,6 +8,8 @@ export type ViewEdge = {
target: string
kind: string
unsaved?: boolean
/** Assets-only edges: the folded runnables the data goes through. */
via?: string[]
}
/** The assets-only node standing for the scripts that build no asset. */
@@ -70,8 +72,19 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
const id = `flow:${r.source}->${assetId}`
const prev = out.get(id)
const unsaved = !!(w.unsaved || r.unsaved)
if (prev) prev.unsaved = prev.unsaved && unsaved
else out.set(id, { id, source: r.source, target: assetId, kind: 'asset-flow', unsaved })
if (prev) {
prev.unsaved = prev.unsaved && unsaved
if (!prev.via?.includes(w.source)) prev.via = [...(prev.via ?? []), w.source]
} else {
out.set(id, {
id,
source: r.source,
target: assetId,
kind: 'asset-flow',
unsaved,
via: [w.source]
})
}
}
}
}
@@ -123,15 +136,19 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
for (const r of inputsOf.get(id) ?? []) {
const eid = `flow:${r.source}->${NO_ASSET_NODE_ID}`
const prev = out.get(eid)
if (prev) prev.unsaved = prev.unsaved && !!r.unsaved
else
if (prev) {
prev.unsaved = prev.unsaved && !!r.unsaved
if (!prev.via?.includes(id)) prev.via = [...(prev.via ?? []), id]
} else {
out.set(eid, {
id: eid,
source: r.source,
target: NO_ASSET_NODE_ID,
kind: 'asset-flow',
unsaved: !!r.unsaved
unsaved: !!r.unsaved,
via: [id]
})
}
}
}
}
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { describeEdge } from './edgeDescription'
const names: Record<string, string> = {
'asset:ducklake:main/raw': 'raw',
'asset:ducklake:main/clean': 'clean',
'script:f/p/clean': 'Clean',
'trigger:schedule:f/p/daily': 'Daily'
}
const nameOf = (id: string) => names[id] ?? id
const dataOf = (id: string) =>
id === 'trigger:schedule:f/p/daily'
? { kind: 'schedule', schedule: '0 0 12 * * *', timezone: 'UTC' }
: undefined
describe('describeEdge', () => {
it('names an asset trigger by what runs it: any write, changed or not', () => {
expect(
describeEdge(
{ source: 'asset:ducklake:main/raw', target: 'script:f/p/clean', kind: 'trigger-asset' },
nameOf,
dataOf
)
).toEqual({ subject: 'Clean', phrase: 'runs after each write to', object: 'raw' })
})
it("gives a schedule's cadence", () => {
expect(
describeEdge(
{ source: 'trigger:schedule:f/p/daily', target: 'script:f/p/clean', kind: 'trigger-native' },
nameOf,
dataOf
)?.phrase
).toBe('runs on schedule: Every day at 12:00 UTC')
})
it('names the folded script on an assets-only edge', () => {
expect(
describeEdge(
{
source: 'asset:ducklake:main/raw',
target: 'asset:ducklake:main/clean',
kind: 'asset-flow',
via: ['script:f/p/clean']
},
nameOf,
dataOf
)
).toEqual({ subject: 'raw', phrase: 'feeds', object: 'clean', note: 'Via Clean.' })
})
})
@@ -0,0 +1,96 @@
import { describeSchedule } from '$lib/utils/describeCron'
/** What a hovered edge says: "`subject` `phrase` `object`", then a note. */
export type EdgeDescription = { subject: string; phrase: string; object?: string; note?: string }
/** The edge fields the description reads (the canvas model's edge). */
export type DescribedEdge = {
source: string
target: string
kind: string
unsaved?: boolean
muted?: boolean
missing?: boolean
via?: string[]
macro_names?: string[]
via_use?: boolean
}
// What starts a script, per trigger kind: "runs on <event>".
const TRIGGER_EVENT: Record<string, string> = {
kafka: 'Kafka message',
mqtt: 'MQTT message',
amqp: 'AMQP message',
nats: 'NATS message',
sqs: 'SQS message',
gcp: 'Pub/Sub message',
email: 'incoming email',
postgres: 'Postgres event',
webhook: 'webhook call',
data_upload: 'data upload'
}
/**
* The relationship an edge draws, as a sentence about its two ends. `nameOf`
* names a node, `dataOf` gives its data (a trigger's kind and cadence).
*/
export function describeEdge(
e: DescribedEdge,
nameOf: (id: string) => string,
dataOf: (id: string) => any
): EdgeDescription | undefined {
const src = nameOf(e.source)
const tgt = nameOf(e.target)
const draft = e.unsaved ? 'Not deployed yet.' : undefined
switch (e.kind) {
case 'lineage-write':
return { subject: src, phrase: 'writes to', object: tgt, note: draft }
case 'lineage-read':
return {
subject: tgt,
phrase: 'reads from',
object: src,
note: e.muted ? 'A write to it does not run the script (muted).' : draft
}
case 'trigger-asset':
return { subject: tgt, phrase: 'runs after each write to', object: src, note: draft }
case 'trigger-native': {
const t = dataOf(e.source) ?? {}
if (e.missing) {
return {
subject: tgt,
phrase: `declares \`// on ${t.kind}\`, but no trigger of that kind targets it`
}
}
const phrase =
t.kind === 'schedule' && t.schedule
? `runs on schedule: ${describeSchedule(t.schedule, t.timezone) ?? t.schedule}`
: `runs on ${TRIGGER_EVENT[t.kind] ?? `${t.kind} trigger`}`
return { subject: tgt, phrase, note: t.draft ? 'Draft trigger, created on save.' : draft }
}
case 'asset-flow': {
const via = (e.via ?? []).map(nameOf).join(', ')
return { subject: src, phrase: 'feeds', object: tgt, note: via ? `Via ${via}.` : undefined }
}
case 'dbt-ref':
return { subject: tgt, phrase: 'references', object: src, note: 'dbt ref().' }
case 'macro':
return {
subject: tgt,
phrase: e.via_use ? 'uses the macro library' : 'uses macros from',
object: src,
note: e.macro_names?.length ? e.macro_names.join(', ') : undefined
}
case 'test-dependency':
return {
subject: src,
phrase: 'must run before',
object: tgt,
note: 'Its data test reads what the first one builds.'
}
case 'data-test':
return { subject: tgt, phrase: 'tests', object: src }
default:
return undefined
}
}