Files
app/frontend/src/components/Flow/LiveEdge.tsx
T

156 lines
5.5 KiB
TypeScript

import {
BaseEdge,
EdgeLabelRenderer,
type EdgeProps,
getBezierPath,
Position,
useStore,
} from "@xyflow/react"
import { memo, useEffect, useRef, useState } from "react"
import { describeArtifact, isRef } from "@/lib/media"
import { duration } from "@/lib/motion"
import { cn } from "@/lib/utils"
import type { FlowEdgeData } from "./deriveEdges"
import { useLiveValue } from "./liveStore"
/** Below this zoom the value chips would be unreadable, so they step aside. */
const CHIP_MIN_ZOOM = 0.5
/** How far a feedback edge stands off the ranks it has to get back past. */
const BOW = 60
/**
* The path a feedback edge takes.
*
* The layout ranks a flow in the direction it runs, so an edge closing a cycle
* is the one that goes the other way — a widget setting the window a node
* upstream of it reads. A curve drawn straight between those two handles is
* drawn straight through everything between them. This one leaves its handle
* the way every other edge does, swings out into a lane beside the graph, and
* comes back into its target from the far side, so what it passes it passes
* around.
*
* ponytail: how far out the lane sits is read off how far back the edge has to
* travel, not off what is actually in the way — this stands a cycle apart from
* the flow rather than guaranteeing it clears every node. The layout engine
* routes these properly and React Flow draws its own curves; taking its bend
* points is the real fix, and a bigger one.
*/
function cyclePath(
sourceX: number,
sourceY: number,
targetX: number,
targetY: number,
vertical: boolean,
): [string, number, number] {
const span = vertical
? Math.abs(targetY - sourceY)
: Math.abs(targetX - sourceX)
const lane = Math.max(BOW, span * 0.3)
const [c1x, c1y] = vertical
? [sourceX + lane, sourceY + BOW]
: [sourceX + BOW, sourceY + lane]
const [c2x, c2y] = vertical
? [targetX + lane, targetY - BOW]
: [targetX - BOW, targetY + lane]
return [
`M${sourceX},${sourceY} C${c1x},${c1y} ${c2x},${c2y} ${targetX},${targetY}`,
// Halfway along a cubic, which is where the chip rides.
(sourceX + 3 * c1x + 3 * c2x + targetX) / 8,
(sourceY + 3 * c1y + 3 * c2y + targetY) / 8,
]
}
function formatValue(value: unknown): string {
if (typeof value === "number") {
return Number.isInteger(value) ? String(value) : value.toFixed(2)
}
if (typeof value === "string") return value
// A media reference serialised whole is a line of hash: what belongs in a
// chip this size is what kind of bytes crossed the edge.
if (isRef(value)) return describeArtifact(value as Record<string, unknown>)
return JSON.stringify(value) ?? ""
}
function LiveEdgeComponent({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
selected,
}: EdgeProps) {
const { message, producerId, stream } = (data ?? {}) as FlowEdgeData
const live = useLiveValue(message)
const zoom = useStore((state) => state.transform[2])
const [pulsing, setPulsing] = useState(false)
const lastTs = useRef<number | null>(null)
// Owned by the pulse rather than by the effect: a value from another
// producer re-runs the effect too, and must not cancel a reset it does not
// replace, or the edge stays lit and the next pulse cannot restart.
const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
useEffect(() => () => clearTimeout(timer.current), [])
// Every rank sits further along the flow than the one before it, so an edge
// whose target has ended up behind its source is one closing a cycle.
const vertical = sourcePosition === Position.Bottom
const backwards = vertical ? targetY < sourceY : targetX < sourceX
const [path, labelX, labelY] = backwards
? cyclePath(sourceX, sourceY, targetX, targetY, vertical)
: getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
})
// Restart the stroke animation whenever a newer message lands — but only
// for the producer that actually published it. A message can have several
// producers, and can also be set from a dashboard or another flow, so
// pulsing on the value alone claims things happened that did not.
useEffect(() => {
if (!live?.ts || live.ts === lastTs.current) return
lastTs.current = live.ts
// A snapshot catching the page up is not a value passing now.
if (live.prior) return
const from = live.source
// No source at all is an older engine; pulse rather than go silent.
if (from && from.id !== producerId) return
setPulsing(true)
clearTimeout(timer.current)
timer.current = setTimeout(() => setPulsing(false), duration.pulse * 1000)
}, [live?.ts, live?.source, live?.prior, producerId])
return (
<>
<BaseEdge
id={id}
path={path}
className={cn(pulsing && "edge-live", stream && "edge-stream")}
style={selected ? { stroke: "var(--primary)" } : undefined}
/>
{live !== undefined && zoom >= CHIP_MIN_ZOOM ? (
<EdgeLabelRenderer>
<div
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
}}
className="pointer-events-none absolute max-w-[140px] truncate rounded-full border border-border bg-card px-2 py-0.5 font-mono text-xs text-foreground"
>
{formatValue(live.value)}
</div>
</EdgeLabelRenderer>
) : null}
</>
)
}
export const LiveEdge = memo(LiveEdgeComponent)