291 lines
10 KiB
TypeScript
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)
|