Draw every flow as one graph, merged on what it talks to

A node type can now say which outside thing its parameters point at, and
nodes sharing one — a broker topic, a URL, a bucket — are drawn as a single
neuron on a new /brain canvas. That makes the wiring which runs between
flows through a broker visible for the first time; no single flow's canvas
can show it. The key is read off stored parameters, so a credential
reference never reaches an id.

Layout is a d3 force simulation settled once and then frozen, lit by the
socket the editor already listens to: a neuron pulses when any node behind
it publishes, and its connections light as values pass.

Fixes the message pulse while here: interpolating the stroke against the
edge's `color-mix()` resting colour went through oklab and left the gamut,
which turned every pulse on both canvases fluorescent yellow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MeiWk3Yq12n2pTvnQWYvt
This commit is contained in:
2026-08-16 22:59:31 +02:00
co-authored by Claude Fable 5
parent af3ba51571
commit 4bcd38354b
25 changed files with 790 additions and 4 deletions
@@ -0,0 +1,47 @@
import { BaseEdge, type EdgeProps, getStraightPath } from "@xyflow/react"
import { memo, useEffect, useRef, useState } from "react"
import { duration } from "@/lib/motion"
import { cn } from "@/lib/utils"
import { useLatestTs } from "./liveStore"
export type BrainEdgeData = {
/** Every message this connection carries, qualified. */
messages: string[]
[key: string]: unknown
}
/**
* A connection between neurons, lit by whatever passes along it.
*
* `LiveEdge` without the value chip or the producer check: a neuron can stand
* for several nodes, so "did *this* node publish it" has no answer here, and a
* circle the size of a coin has no room for a payload anyway.
*/
function BrainEdgeComponent({
id,
sourceX,
sourceY,
targetX,
targetY,
data,
}: EdgeProps) {
const { messages } = (data ?? { messages: [] }) as BrainEdgeData
const ts = useLatestTs(messages)
const [pulsing, setPulsing] = useState(false)
const lastTs = useRef(0)
const [path] = getStraightPath({ sourceX, sourceY, targetX, targetY })
useEffect(() => {
if (!ts || ts === lastTs.current) return
lastTs.current = ts
setPulsing(true)
const timer = setTimeout(() => setPulsing(false), duration.pulse * 1000)
return () => clearTimeout(timer)
}, [ts])
return <BaseEdge id={id} path={path} className={cn(pulsing && "edge-live")} />
}
export const BrainEdge = memo(BrainEdgeComponent)
@@ -0,0 +1,65 @@
import { Handle, type NodeProps, Position } from "@xyflow/react"
import { memo } from "react"
import { cn } from "@/lib/utils"
import { useGroupEmits, useGroupError } from "./liveStore"
export type BrainNodeData = {
label: string
kind: string
/** The `flow.node_id` names behind this neuron; live events key by these. */
members: string[]
flows: string[]
/** Circle diameter in pixels, from how many neurons this one is wired to. */
size: number
[key: string]: unknown
}
function BrainNodeComponent({ data }: NodeProps) {
const { label, kind, members, flows, size } = data as BrainNodeData
const emits = useGroupEmits(members)
const failed = useGroupError(members)
return (
<div
className="flex flex-col items-center"
title={`${kind} · ${members.join(", ")}`}
>
<span
className={cn(
"brain-cell relative flex items-center justify-center rounded-full border bg-card shadow-e1",
failed ? "border-destructive" : "border-border",
)}
style={{ width: size, height: size }}
>
{/* Remounting on each emit is what restarts the animation. */}
{emits > 0 ? <span key={emits} className="node-pulse" /> : null}
{/* Both ends sit at the centre, so an edge runs neuron to neuron. */}
<Handle
type="target"
position={Position.Left}
className="brain-handle"
isConnectable={false}
/>
<Handle
type="source"
position={Position.Right}
className="brain-handle"
isConnectable={false}
/>
<span className="px-1 text-center text-xs font-medium text-muted-foreground">
{flows.length > 1 ? flows.length : null}
</span>
</span>
<span className="mt-1.5 max-w-[140px] truncate text-center text-xs font-medium">
{label}
</span>
{/* Colour is never the only carrier of a status. */}
{failed ? (
<span className="text-xs font-medium text-destructive">failed</span>
) : null}
</div>
)
}
export const BrainNode = memo(BrainNodeComponent)
+206
View File
@@ -0,0 +1,206 @@
import { useQuery } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import {
Background,
BackgroundVariant,
type Edge,
type Node,
ReactFlow,
ReactFlowProvider,
useReactFlow,
} from "@xyflow/react"
import "@xyflow/react/dist/style.css"
import {
forceCollide,
forceLink,
forceManyBody,
forceSimulation,
forceX,
forceY,
type SimulationNodeDatum,
} from "d3-force"
import { Brain } from "lucide-react"
import { useEffect, useMemo } from "react"
import type { BrainGraph } from "@/client"
import { BrainEdge } from "./BrainEdge"
import { BrainNode, type BrainNodeData } from "./BrainNode"
import { CanvasTitle } from "./CanvasTitle"
import "./flow.css"
import { graphQueryOptions } from "./queries"
import { useFlowSocket } from "./useFlowSocket"
// Never past 1: the labels are `text-xs`, and a graph small enough to fit
// twice over should not render its text at twice the size of the sidebar.
const FIT = { padding: 0.2, maxZoom: 1 }
const nodeTypes = { brain: BrainNode }
const edgeTypes = { brain: BrainEdge }
/** Circle diameter, from how many neurons this one is wired to. */
const SIZE_MIN = 32
const SIZE_MAX = 96
/** Room around a circle for its label, so the layout does not overlap them. */
const LABEL_ROOM = 26
type Placed = SimulationNodeDatum & { id: string; size: number; room: number }
/**
* Where the neurons sit: a force layout run to rest once and then frozen.
*
* A live simulation would keep nudging nodes under the pointer while someone is
* panning, and a graph that never stops moving is unreadable. Starting from a
* circle rather than d3's own random phyllotaxis also means the same flows lay
* out the same way twice.
*
* `forceX`/`forceY` rather than `forceCenter`: centering only translates the
* whole thing, so unconnected flows — which is most of them — would push each
* other apart forever with nothing pulling back.
*/
function build(graph: BrainGraph): { nodes: Node[]; edges: Edge[] } {
const degree = new Map<string, number>()
for (const edge of graph.edges ?? []) {
degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1)
degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1)
}
const placed: Placed[] = (graph.nodes ?? []).map((node, index, all) => {
const size = Math.min(SIZE_MAX, SIZE_MIN + 8 * (degree.get(node.id) ?? 0))
const angle = (index / all.length) * 2 * Math.PI
const radius = 60 + all.length * 10
return {
id: node.id,
size,
room: size / 2 + LABEL_ROOM,
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
}
})
const links = (graph.edges ?? []).map((edge) => ({
source: edge.source,
target: edge.target,
}))
forceSimulation(placed)
.force(
"link",
forceLink(links)
.id((node) => (node as Placed).id)
.distance(130),
)
.force("charge", forceManyBody().strength(-320).distanceMax(500))
.force(
"collide",
forceCollide<Placed>((node) => node.room),
)
.force("x", forceX(0).strength(0.06))
.force("y", forceY(0).strength(0.06))
.stop()
.tick(300)
return {
nodes: placed.map((node, index) => {
const source = (graph.nodes ?? [])[index]
return {
id: node.id,
type: "brain",
// React Flow places by the top-left corner; the layout means centres.
position: {
x: (node.x ?? 0) - node.size / 2,
y: (node.y ?? 0) - node.size / 2,
},
data: {
label: source.label,
kind: source.kind,
members: source.members ?? [],
flows: source.flows ?? [],
size: node.size,
} satisfies BrainNodeData,
}
}),
edges: (graph.edges ?? []).map((edge) => ({
id: `${edge.source}->${edge.target}`,
type: "brain",
source: edge.source,
target: edge.target,
data: { messages: edge.messages ?? [] },
})),
}
}
function BrainCanvas() {
const navigate = useNavigate()
const { fitView } = useReactFlow()
useFlowSocket()
// The socket invalidates every "flows" key on a rebuild, so publishing
// anywhere re-fetches this and the layout runs again.
const { data } = useQuery(graphQueryOptions())
const { nodes, edges } = useMemo(
() => build(data ?? { nodes: [], edges: [] }),
[data],
)
// A rebuild lays the whole graph out afresh, so the viewport someone was
// looking through no longer frames anything. Only when the set of neurons
// actually changed: a value arriving must not move the canvas.
const shape = nodes.map((node) => node.id).join(" ")
useEffect(() => {
if (!shape) return
// After the new nodes have been measured, or the fit is of the old ones.
const frame = requestAnimationFrame(() =>
fitView({ ...FIT, duration: 300 }),
)
return () => cancelAnimationFrame(frame)
}, [shape, fitView])
return (
<>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
proOptions={{ hideAttribution: true }}
fitView
fitViewOptions={FIT}
minZoom={0.1}
maxZoom={2}
onNodeClick={(_event, node) => {
const [flow] = (node.data as BrainNodeData).flows
if (flow)
navigate({ to: "/flows/$flowName", params: { flowName: flow } })
}}
className="h-full w-full"
>
<Background variant={BackgroundVariant.Dots} gap={24} size={1.5} />
</ReactFlow>
<div className="pointer-events-none absolute inset-0">
<CanvasTitle>
<span className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium">
<Brain className="size-4 text-muted-foreground" />
Brain
</span>
</CanvasTitle>
</div>
</>
)
}
/**
* Every flow at once, merged on what each node talks to.
*
* Read-only by design: what a neuron stands for lives in the flow it came
* from, and clicking one goes there.
*/
export function BrainView() {
return (
<ReactFlowProvider>
<BrainCanvas />
</ReactFlowProvider>
)
}
+29 -3
View File
@@ -38,15 +38,21 @@
}
/*
* No `to` on purpose: the implied one is the edge's own resting style, so
* the decay lands exactly where it started rather than on a second colour
* that has to snap back — and a selected edge decays to its own blue.
* Both ends are plain colours. The implied `to` would be the edge's resting
* stroke, which is a `color-mix()` — and interpolating a hex against one of
* those goes through oklab and leaves the gamut on the way, which turned
* every pulse fluorescent yellow. Landing on `--muted-foreground` instead is
* the same hue the edge rests in, so the last step back is invisible.
*/
@keyframes edge-pulse {
from {
stroke: var(--primary);
stroke-width: 2.5;
}
to {
stroke: var(--muted-foreground);
stroke-width: var(--xy-edge-stroke-width);
}
}
}
@@ -94,3 +100,23 @@
.react-flow__node:focus {
outline: none;
}
/*
* Brain graph. Both ends of a connection sit at the neuron's centre, so an
* edge runs straight from circle to circle and disappears under them.
*/
.brain-handle {
opacity: 0;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.brain-cell {
cursor: pointer;
transition: border-color var(--duration-base) var(--ease-standard);
}
.brain-cell:hover {
border-color: var(--primary);
}
Binary file not shown.
+9
View File
@@ -23,6 +23,9 @@ export const flowKeys = {
["flows", name, "history", message] as const,
nodeTypes: ["flows", "node-types"] as const,
library: ["flows", "library"] as const,
// Under `all` on purpose: the socket invalidates that prefix on every
// rebuild, so the brain graph re-layouts when someone publishes.
graph: ["flows", "graph"] as const,
}
export const flowsQueryOptions = () => ({
@@ -41,6 +44,12 @@ export const secretsQueryOptions = () => ({
queryFn: () => SecretsService.readSecrets(),
})
/** Every flow at once, merged on what its nodes talk to. */
export const graphQueryOptions = () => ({
queryKey: flowKeys.graph,
queryFn: () => FlowsService.readGraph(),
})
/** Node sources shared across flows, with the nodes using each. */
export const libraryQueryOptions = () => ({
queryKey: flowKeys.library,
@@ -1,6 +1,7 @@
import {
Activity,
Bell,
Brain,
Home,
KeyRound,
LayoutDashboard,
@@ -26,6 +27,7 @@ const baseItems: Item[] = [
// "Home" rather than "Dashboard": dashboards are their own thing now.
{ icon: Home, title: "Home", path: "/" },
{ icon: Workflow, title: "Flows", path: "/flows" },
{ icon: Brain, title: "Brain", path: "/brain" },
{ icon: LayoutDashboard, title: "Dashboards", path: "/dashboards" },
{ icon: Activity, title: "Health", path: "/health" },
// Both are engine-wide operator settings rather than personal ones, so they