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
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
91 lines
3.3 KiB
TypeScript
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
|