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

291 lines
10 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 type { Point } from "./layout"
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 radius a routed edge turns its corners with. */
const CORNER = 10
/**
* The route the layout found, met up with the handles React Flow drew.
*
* The layout reserves a card's widest and a label's widest, and either is
* narrower when its text is, so a handle on screen can sit beside the port the
* route was found for. Each end moves onto its handle and takes the bend next
* to it along, which keeps every segment square. A straight run between two
* handles that no longer line up gets a step halfway.
*
* Null for a route that does not alternate along and across the flow, which
* is all this knows how to move.
*/
function stitch(
route: Point[],
from: Point,
to: Point,
vertical: boolean,
): Point[] | null {
// [along the flow, across it] and back.
const split = (p: Point) => (vertical ? [p.y, p.x] : [p.x, p.y])
const join = (along: number, across: number): Point =>
vertical ? { x: across, y: along } : { x: along, y: across }
// ELK can hand back a corner that is not one: two runs in the same line.
const points = route.filter((p, i) => {
const [a, b] = [route[i - 1], route[i + 1]]
return (
!a || !b || ((a.x !== p.x || b.x !== p.x) && (a.y !== p.y || b.y !== p.y))
)
})
for (let i = 1; i < points.length; i += 1) {
const [a0, c0] = split(points[i - 1])
const [a1, c1] = split(points[i])
// Odd segments run along the flow, even ones across it.
if (i % 2 ? c0 !== c1 : a0 !== a1) return null
}
if (points.length % 2) return null
const [fromAlong, fromAcross] = split(from)
const [toAlong, toAcross] = split(to)
if (points.length === 2) {
if (fromAcross === toAcross) return [from, to]
const halfway = (fromAlong + toAlong) / 2
return [from, join(halfway, fromAcross), join(halfway, toAcross), to]
}
const bends = points.slice(1, -1)
const first = bends[0]
const last = bends[bends.length - 1]
bends[0] = join(split(first)[0], fromAcross)
bends[bends.length - 1] = join(split(last)[0], toAcross)
return [from, ...bends, to]
}
/** A square route with its corners rounded off. */
function roundedPath(points: Point[]): string {
const towards = (from: Point, to: Point, by: number): Point => {
const length = Math.hypot(to.x - from.x, to.y - from.y) || 1
return {
x: from.x + ((to.x - from.x) * by) / length,
y: from.y + ((to.y - from.y) * by) / length,
}
}
let path = `M${points[0].x},${points[0].y}`
for (let i = 1; i < points.length - 1; i += 1) {
const [before, corner, after] = [points[i - 1], points[i], points[i + 1]]
// Never more than half of either run, or two corners would overlap.
const radius = Math.min(
CORNER,
Math.hypot(corner.x - before.x, corner.y - before.y) / 2,
Math.hypot(after.x - corner.x, after.y - corner.y) / 2,
)
const enter = towards(corner, before, radius)
const leave = towards(corner, after, radius)
path += ` L${enter.x},${enter.y} Q${corner.x},${corner.y} ${leave.x},${leave.y}`
}
const end = points[points.length - 1]
return `${path} L${end.x},${end.y}`
}
/** The point of a polyline closest to `to`. */
function nearest(points: Point[], to: Point): Point {
let best = points[0]
let gap = Number.POSITIVE_INFINITY
for (let i = 1; i < points.length; i += 1) {
const [a, b] = [points[i - 1], points[i]]
const [dx, dy] = [b.x - a.x, b.y - a.y]
const t = Math.max(
0,
Math.min(
1,
((to.x - a.x) * dx + (to.y - a.y) * dy) / (dx * dx + dy * dy || 1),
),
)
const at = { x: a.x + t * dx, y: a.y + t * dy }
const distance = Math.hypot(at.x - to.x, at.y - to.y)
if (distance < gap) [best, gap] = [at, distance]
}
return best
}
/**
* React Flow reads a handle's place out of the DOM through the zoom, which
* leaves noise in the last digits — enough for one flow to draw two different
* paths on two loads. Half a pixel is as fine as a handle is ever placed.
*/
const half = (value: number) => Math.round(value * 2) / 2
/**
* The path a feedback edge takes when the layout has no route for it.
*
* 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.
*
* How far out the lane sits is read off how far back the edge has to travel,
* not off what is in the way, which is good enough for the moment between an
* edit and the layout answering it.
*/
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)
}
// An empty string reads the way `null` and `[]` do, not as an empty pill.
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,
source,
target,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
selected,
}: EdgeProps) {
const { message, producerId, stream, route } = (data ?? {}) as FlowEdgeData
const live = useLiveValue(message)
const zoom = useStore((state) => state.transform[2])
// The route holds while both ends are where the layout put them. One that
// has moved since — dragged, or placed by a layout this edge is not from yet
// — takes the plain curve until the next layout routes it again.
const placed = useStore((state) => {
if (!route) return false
const at = (node: string, where: Point) => {
const now = state.nodeLookup.get(node)?.internals.positionAbsolute
return now?.x === where.x && now?.y === where.y
}
return at(source, route.from) && at(target, route.to)
})
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), [])
const vertical = sourcePosition === Position.Bottom
const square =
placed && route
? stitch(
route.points,
{ x: half(sourceX), y: half(sourceY) },
{ x: half(targetX), y: half(targetY) },
vertical,
)
: null
// 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 backwards = vertical ? targetY < sourceY : targetX < sourceX
const chip = square && route ? nearest(square, route.label) : null
const [path, labelX, labelY] =
square && chip
? [roundedPath(square), chip.x, chip.y]
: 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)