Files
app/frontend/src/components/Flow/EndpointNode.tsx
T
stroblmeandClaude Opus 5 a71e0b3375
Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Successful in 4m17s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m43s
Playwright Tests / merge-reports (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 18m52s
Test Backend / test-backend (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Lay flows out so edges stop crossing where they need not
The canvas placed nodes with dagre, which orders nodes but knows nothing
about ports: an edge leaves a particular handle and lands on a particular
one, so two producers feeding a consumer in the other order crossed just
before they landed however well the nodes were arranged. The graph was not
a multigraph either, so two messages between one pair of nodes collapsed
into one before crossings were counted, and the endpoint labels were laid
out as 220-wide cards with every handle stacked on a single pixel.

ELK's layered algorithm replaces it. Every declared port is handed to it as
a fixed point on the node's rim, at the fraction `portFraction` puts the
handle at — which the node components now render from the same function —
so what the crossing count is minimised over is what ends up on the screen.
Measured on this installation's flows, with the labels included: `home`
goes from 51 crossings to 6 across and 70 to 29 down, `demo_training` 27 to
5 and 59 to 32, and every graph is the same size or smaller.

The engine answers asynchronously and is a chunk of its own, so positions
became state: the canvas draws nothing until the first layout lands, and an
edit keeps the arrangement it had rather than flashing through the corner.
The entry chunk is untouched and the flow route's own chunk came down 167
to 121 kB, since dagre used to be in it.

Two things the crossings made worse come with it. A hovered edge resolves
to full strength so one line can be followed through a busy rank, and a
feedback edge — one whose target the layout put behind its source — swings
out into a lane beside the graph instead of being drawn through everything
between its ends, which is what "House history" was reported for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6vyQvfX249hsv9YPmsYYg
2026-08-26 15:37:36 +02:00

91 lines
3.3 KiB
TypeScript

import { Handle, type NodeProps, Position } from "@xyflow/react"
import { LayoutDashboard, LogIn, LogOut, Workflow } from "lucide-react"
import { memo } from "react"
import { useIsMobile } from "@/hooks/useMobile"
import { cn } from "@/lib/utils"
import type { EndpointNodeData } from "./endpoints"
import { handleOffset, nodeHeight } from "./layout"
const KIND_ICONS = {
dashboard: LayoutDashboard,
flow: Workflow,
input: LogIn,
output: LogOut,
} as const
/**
* A thing wired into this flow that is not a node in it.
*
* Drawn as a label rather than a card on purpose: a dashboard with twenty
* tiles would otherwise bury the logic the canvas exists to show, which is
* 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) {
const { label, kind, detail, provides, requires } = data as EndpointNodeData
const Icon = KIND_ICONS[kind as keyof typeof KIND_ICONS] ?? Workflow
const messages = [...provides, ...requires]
// Follows the graph's own direction; see DESIGN-GUIDELINES.md → Responsive.
const vertical = useIsMobile()
// A dashboard reading six messages has six edges, and stacking their dots on
// one point is what made them leave in a knot. Spread and sized the way a
// node's ports are, so the layout can reserve exactly this much.
const ports = Math.max(provides.length, requires.length)
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",
// Quiet at rest so the logic reads first; legible when reached for.
"text-muted-foreground/55 transition-colors",
"hover:text-foreground",
selected && "text-foreground",
)}
title={messages.join("\n")}
>
{/* Both sides always exist so an edge can attach; only one is used. */}
{requires.map((message, index) => {
const offset = handleOffset(index, requires.length)
return (
<Handle
key={`in-${message}`}
type="target"
id={message}
position={vertical ? Position.Top : Position.Left}
className="!border-border !bg-card"
style={vertical ? { left: offset } : { top: offset }}
/>
)
})}
{provides.map((message, index) => {
const offset = handleOffset(index, provides.length)
return (
<Handle
key={`out-${message}`}
type="source"
id={message}
position={vertical ? Position.Bottom : Position.Right}
className="!border-border !bg-card"
style={vertical ? { left: offset } : { top: offset }}
/>
)
})}
<Icon className="size-4 shrink-0" />
<span className="grid min-w-0">
<span className="truncate text-sm leading-tight">{label}</span>
<span className="truncate text-xs tracking-wide uppercase opacity-70">
{detail}
</span>
</span>
</div>
)
}
export const EndpointNode = memo(EndpointNodeComponent)
export default EndpointNode