156 lines
5.5 KiB
TypeScript
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)
|