Endpoint labels sweep instead of pulse: left to right on a label publishing into the flow, right to left on one reading out of it, one pulse long, off under reduced motion; snapshot values are marked prior so catching up sweeps nothing
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { Handle, type NodeProps, Position } from "@xyflow/react"
|
||||
import { LayoutDashboard, LogIn, LogOut, Workflow } from "lucide-react"
|
||||
import { memo } from "react"
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/useMobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { EndpointNodeData } from "./endpoints"
|
||||
import { handleOffset, nodeHeight } from "./layout"
|
||||
import { useLiveValues } from "./liveStore"
|
||||
|
||||
const KIND_ICONS = {
|
||||
dashboard: LayoutDashboard,
|
||||
@@ -22,10 +23,21 @@ const KIND_ICONS = {
|
||||
* the same reason dashboards are their own documents. It is here to account
|
||||
* for a value, not to compete with the nodes for attention.
|
||||
*/
|
||||
function EndpointNodeComponent({ data, selected }: NodeProps) {
|
||||
function EndpointNodeComponent({ id, data, selected }: NodeProps) {
|
||||
const { label, kind, detail, provides, requires } = data as EndpointNodeData
|
||||
const Icon = KIND_ICONS[kind as keyof typeof KIND_ICONS] ?? Workflow
|
||||
const messages = [...provides, ...requires]
|
||||
const messages = useMemo(
|
||||
() => [...provides, ...requires],
|
||||
[provides, requires],
|
||||
)
|
||||
const values = useLiveValues(messages)
|
||||
// Which way the last value went, and a count whose change remounts the sweep
|
||||
// — remounting is what restarts it. See `.endpoint-sweep` in flow.css.
|
||||
const [sweep, setSweep] = useState<{ dir: "in" | "out"; n: number }>()
|
||||
// When each message last carried a value, as far as this label has seen.
|
||||
// The first look only takes notes: mounting is not a value passing, and
|
||||
// neither is a snapshot catching the page up.
|
||||
const seen = useRef<Map<string, number>>(null)
|
||||
// Follows the graph's own direction; see DESIGN-GUIDELINES.md → Responsive.
|
||||
const vertical = useIsMobile()
|
||||
|
||||
@@ -34,12 +46,35 @@ function EndpointNodeComponent({ data, selected }: NodeProps) {
|
||||
// node's ports are, so the layout can reserve exactly this much.
|
||||
const ports = Math.max(provides.length, requires.length)
|
||||
|
||||
useEffect(() => {
|
||||
const first = seen.current === null
|
||||
seen.current ??= new Map()
|
||||
let dir: "in" | "out" | undefined
|
||||
for (const [index, name] of messages.entries()) {
|
||||
const live = values[index]
|
||||
// An empty store is a reconnect on its way, not a value.
|
||||
if (!live?.ts || seen.current.get(name) === live.ts) continue
|
||||
seen.current.set(name, live.ts)
|
||||
if (first || live.prior) continue
|
||||
// Published by this label, by the rule its own edges pulse on (LiveEdge).
|
||||
if (provides.includes(name) && (!live.source || live.source.id === id)) {
|
||||
dir = "in"
|
||||
} else if (requires.includes(name)) {
|
||||
dir ??= "out"
|
||||
}
|
||||
}
|
||||
if (!dir) return
|
||||
const next = dir
|
||||
setSweep((last) => ({ dir: next, n: (last?.n ?? 0) + 1 }))
|
||||
}, [values, messages, provides, requires, id])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={vertical ? undefined : { minHeight: nodeHeight(ports) }}
|
||||
className={cn(
|
||||
// Padding keeps the text off the connector dot, which sits on the edge.
|
||||
"flex max-w-48 cursor-pointer items-center gap-2 px-3 py-1",
|
||||
// Isolated so the sweep can sit behind the text without leaving it.
|
||||
"relative isolate flex max-w-48 cursor-pointer items-center gap-2 px-3 py-1",
|
||||
// Quiet at rest so the logic reads first; legible when reached for.
|
||||
"text-muted-foreground/55 transition-colors",
|
||||
"hover:text-foreground",
|
||||
@@ -75,6 +110,15 @@ function EndpointNodeComponent({ data, selected }: NodeProps) {
|
||||
)
|
||||
})}
|
||||
|
||||
{sweep ? (
|
||||
<span
|
||||
key={sweep.n}
|
||||
className={cn(
|
||||
"endpoint-sweep rounded-sm",
|
||||
sweep.dir === "out" && "endpoint-sweep-out",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className="grid min-w-0">
|
||||
<span className="truncate text-sm leading-tight">{label}</span>
|
||||
|
||||
@@ -223,6 +223,69 @@
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* An endpoint label has no stroke to pulse and no rim to lap, and lighting all
|
||||
* of it at once would not say which way the value went. So a value passing
|
||||
* sweeps across it: left to right on a label publishing into the flow, right
|
||||
* to left on one reading out of it, then fades back to rest. One pulse long,
|
||||
* like everything else on the canvas that answers a message.
|
||||
*
|
||||
* Where the light has got to is registered so it can travel, outside the guard
|
||||
* below for the same reason as the node's angle: it is a type, not motion.
|
||||
*/
|
||||
@property --endpoint-sweep {
|
||||
syntax: "<percentage>";
|
||||
inherits: false;
|
||||
initial-value: 0%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
/*
|
||||
* Behind the text rather than over it, so the label stays as legible as it
|
||||
* was; the label isolates itself, so behind does not mean behind the canvas.
|
||||
* A brighter leading edge is what reads as travel; the wash it leaves is
|
||||
* what reads as lit.
|
||||
*/
|
||||
.endpoint-sweep {
|
||||
--endpoint-sweep-to: to right;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
var(--endpoint-sweep-to),
|
||||
color-mix(in srgb, var(--primary) 14%, transparent)
|
||||
calc(var(--endpoint-sweep) - 40%),
|
||||
color-mix(in srgb, var(--primary) 32%, transparent)
|
||||
calc(var(--endpoint-sweep) - 8%),
|
||||
transparent var(--endpoint-sweep)
|
||||
);
|
||||
animation: endpoint-sweep var(--duration-pulse) linear forwards;
|
||||
}
|
||||
|
||||
.endpoint-sweep.endpoint-sweep-out {
|
||||
--endpoint-sweep-to: to left;
|
||||
}
|
||||
|
||||
/*
|
||||
* Constant rate while it travels, as the node's arc does. It stays mounted
|
||||
* until the next value remounts it, so it has to go out on its own.
|
||||
*/
|
||||
@keyframes endpoint-sweep {
|
||||
from {
|
||||
--endpoint-sweep: 0%;
|
||||
}
|
||||
60% {
|
||||
--endpoint-sweep: 140%;
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
--endpoint-sweep: 140%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Handles are neutral: the one brand-secondary affordance here is Run. */
|
||||
.react-flow__handle {
|
||||
width: 12px;
|
||||
|
||||
@@ -22,6 +22,11 @@ export type LiveValue = {
|
||||
value: unknown
|
||||
ts: number | null
|
||||
source?: ValueSource
|
||||
/**
|
||||
* Caught up from a snapshot rather than seen arriving. It happened, but not
|
||||
* while anyone was watching — the same distinction as `priorEmits`.
|
||||
*/
|
||||
prior?: boolean
|
||||
}
|
||||
export type LiveStatus = {
|
||||
status: "active" | "error" | "queued" | "running" | "success"
|
||||
@@ -127,7 +132,7 @@ export const liveStore = {
|
||||
},
|
||||
setValues(entries: Record<string, LiveValue>) {
|
||||
for (const [name, value] of Object.entries(entries)) {
|
||||
values.set(name, value)
|
||||
values.set(name, { ...value, prior: true })
|
||||
notify(`value:${name}`)
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user