Brain: dots where a connection meets a neuron, inbound hollow and outbound filled

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB
This commit is contained in:
2026-08-17 12:52:31 +02:00
co-authored by Claude Opus 5
parent 02d841e10c
commit 1a589570b1
4 changed files with 111 additions and 76 deletions
+2
View File
@@ -190,6 +190,8 @@ as an em dash.
- CHORE/UX: free-form params (python nodes) get no suggestions, since there is no schema to key them off.
- PERF/UI: `BrainView` runs 300 force-layout ticks synchronously inside a `useMemo`, so the graph is laid out on the render thread.
- FEAT/UI: the brain is a band on a scrolling page now, so it neither pans nor zooms — the fit keeps the whole graph in view instead. An installation with enough flows to make the labels unreadable at that fit needs a way to open the graph larger.
- CHORE/UI: React Flow measures a node's handle bounds out of the DOM once and never again, and in the brain that one measurement falls inside the graph's `scaleIn` entrance — so every `sourceX`/`targetX` it hands an edge there is the entrance's 4% short of the centre, permanently. `BrainEdge` takes both ends from the layout instead (position + radius). Any future view that mounts a canvas inside a transform and reads node internals meets the same thing.
- CHORE/UI: a value passing lights the brain's connection blue for the pulse, but its two end dots only follow the slower falloff, so the flash stops where the line does.
- CHORE/UI: `flow.css` drops the focus outline on every `.react-flow__node`. Brain neurons now answer `:focus-visible` with the same border colour as hover, but the flow editor's own nodes still have no visible keyboard focus.
### Infrastructure
+62 -63
View File
@@ -1,4 +1,4 @@
import { BaseEdge, type EdgeProps } from "@xyflow/react"
import { BaseEdge, type EdgeProps, useInternalNode } from "@xyflow/react"
import { memo, useEffect, useRef, useState } from "react"
import { duration } from "@/lib/motion"
@@ -8,54 +8,20 @@ import { useLatestTs } from "./liveStore"
export type BrainEdgeData = {
/** Every message this connection carries, qualified. */
messages: string[]
/** Circle radii of the two neurons, so the line can stop at their rims. */
/** Circle radii of the two neurons: their centres, and where the line stops. */
sourceRadius: number
targetRadius: number
[key: string]: unknown
}
/** Arrowhead barb length, and how far its tip stops short of the target rim. */
const HEAD = 7
const RIM_GAP = 3
/**
* A line from rim to rim, with an arrowhead at the end it flows into.
* Radius of the dot that marks where the connection meets a neuron.
*
* Both handles sit at a neuron's centre, so the raw path runs under both
* circles: it looks off-centre wherever the circles differ in size, and it says
* nothing about which way the value travels. The barbs are two more segments of
* the same stroked path rather than an SVG marker, so they dim with the line
* they belong to instead of needing a fill of their own.
* A flow node's port handle is 12px across on a node some four times that; a
* neuron is 32px at its smallest, so the same proportion lands at 8px — small
* enough that several connections into one small neuron stay separate dots.
*/
function rimPath(
sourceX: number,
sourceY: number,
targetX: number,
targetY: number,
sourceRadius: number,
targetRadius: number,
): string {
const length = Math.hypot(targetX - sourceX, targetY - sourceY) || 1
const unitX = (targetX - sourceX) / length
const unitY = (targetY - sourceY) / length
// Neither trim reaches past the midpoint, so two neurons closer together than
// their radii still get a line pointing the right way rather than a backwards
// one. The force layout keeps them apart; this only stops it looking broken.
const from = Math.min(sourceRadius, length / 2)
const to = Math.min(targetRadius + RIM_GAP, length - from)
const startX = sourceX + unitX * from
const startY = sourceY + unitY * from
const tipX = targetX - unitX * to
const tipY = targetY - unitY * to
const line = `M${startX},${startY}L${tipX},${tipY}`
if (length - from - to < HEAD) return line
const backX = tipX - unitX * HEAD
const backY = tipY - unitY * HEAD
const spreadX = (-unitY * HEAD) / 2
const spreadY = (unitX * HEAD) / 2
return `${line}M${backX + spreadX},${backY + spreadY}L${tipX},${tipY}L${backX - spreadX},${backY - spreadY}`
}
const DOT = 4
/**
* A connection between neurons, lit by whatever passes along it.
@@ -63,32 +29,53 @@ function rimPath(
* `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.
*
* It runs centre to centre, so the line trims itself back to the two rims and
* then further, to clear the dot that sits on each of them: a flow node's port,
* filled where the value leaves and hollow where it arrives. The dots belong to
* the edge rather than to the node because a neuron meets its connections all
* round its rim, not at two fixed points — and being part of the edge is also
* what makes them dim along with it.
*/
function BrainEdgeComponent({
id,
sourceX,
sourceY,
targetX,
targetY,
data,
}: EdgeProps) {
function BrainEdgeComponent({ id, source, target, data }: EdgeProps) {
const { messages, sourceRadius, targetRadius } = (data ?? {
messages: [],
sourceRadius: 0,
targetRadius: 0,
}) as BrainEdgeData
const sourceNode = useInternalNode(source)
const targetNode = useInternalNode(target)
const ts = useLatestTs(messages)
const [pulsing, setPulsing] = useState(false)
const lastTs = useRef(0)
const path = rimPath(
sourceX,
sourceY,
targetX,
targetY,
sourceRadius,
targetRadius,
)
// The two centres, taken from the layout rather than from the `sourceX`/
// `targetX` React Flow hands an edge. Those come off the handles, which it
// measures out of the DOM exactly once per node — and here that once falls
// inside the graph's entrance scale, so every one of them is stored a few
// percent short of the centre and never measured again. A node's position
// plus its radius is the same point, exactly, with nothing measured at all.
const sourceX = (sourceNode?.internals.positionAbsolute.x ?? 0) + sourceRadius
const sourceY = (sourceNode?.internals.positionAbsolute.y ?? 0) + sourceRadius
const targetX = (targetNode?.internals.positionAbsolute.x ?? 0) + targetRadius
const targetY = (targetNode?.internals.positionAbsolute.y ?? 0) + targetRadius
const length = Math.hypot(targetX - sourceX, targetY - sourceY) || 1
const unitX = (targetX - sourceX) / length
const unitY = (targetY - sourceY) / length
// Neither trim reaches past the midpoint, so two neurons closer together than
// their radii still get a line pointing the right way rather than a backwards
// one. The force layout keeps them apart; this only stops it looking broken.
const from = Math.min(sourceRadius, length / 2)
const to = Math.min(targetRadius, length - from)
const startX = sourceX + unitX * from
const startY = sourceY + unitY * from
const endX = targetX - unitX * to
const endY = targetY - unitY * to
// The line stops at each dot rather than running under it, so a filled dot is
// one flat tone instead of a disc with a darker line showing through half of
// it. Never longer than the gap, or a short connection would draw backwards.
const trim = Math.min(DOT, (length - from - to) / 2)
const path = `M${startX + unitX * trim},${startY + unitY * trim}L${endX - unitX * trim},${endY - unitY * trim}`
useEffect(() => {
if (!ts || ts === lastTs.current) return
@@ -99,16 +86,28 @@ function BrainEdgeComponent({
}, [ts])
return (
// The tone lives on the group so the line and both dots share one falloff.
<g
className={cn(
"brain-wire",
// Nothing seen yet this session is not the same as seen and gone quiet.
ts === 0 && "brain-idle",
pulsing && "brain-hot",
)}
>
<BaseEdge
id={id}
path={path}
className={cn(
"brain-edge",
// Nothing seen yet this session is not the same as seen and gone quiet.
ts === 0 && "brain-idle",
pulsing && "brain-hot edge-live",
)}
className={cn("brain-edge", pulsing && "edge-live")}
/>
<circle
className="brain-dot brain-dot-out"
cx={startX}
cy={startY}
r={DOT}
/>
<circle className="brain-dot" cx={endX} cy={endY} r={DOT} />
</g>
)
}
+2 -2
View File
@@ -108,8 +108,8 @@ function build(graph: BrainGraph): { nodes: Node[]; edges: Edge[] } {
.stop()
.tick(300)
// Both of an edge's handles sit at a neuron's centre, so the edge is what has
// to trim itself back to the rims; the radius is only known here.
// An edge draws itself from the two centres outwards, so it needs the radius
// to find the rims — and the size that fixes it is only known here.
const radius = new Map(placed.map((node) => [node.id, node.size / 2]))
return {
+45 -11
View File
@@ -114,8 +114,10 @@
}
/*
* Brain graph. Both ends of a connection sit at the neuron's centre, which is
* what `BrainEdge` measures its rim trim and its arrowhead from.
* Brain graph. A neuron meets its connections all round its rim, so these two
* handles are only there to make React Flow treat the node as wired at all —
* where a connection actually lands is `BrainEdge`'s own geometry, measured
* from the centre they sit at.
*/
.brain-handle {
opacity: 0;
@@ -213,24 +215,56 @@
visibility: visible;
}
/* The same falloff as the neurons, on the line instead of the disc. */
/*
* The same falloff as the neurons, on the connection instead of the disc. It
* carries the line and its two end dots alike, so the ink is a variable on the
* group they share rather than a stroke on each.
*/
.brain-wire {
--brain-ink: color-mix(in srgb, var(--muted-foreground) 38%, transparent);
}
.brain-wire.brain-idle {
--brain-ink: color-mix(in srgb, var(--muted-foreground) 55%, transparent);
}
/* Snapped into, decayed out of — never the other way round. */
.brain-wire.brain-hot {
--brain-ink: color-mix(in srgb, var(--muted-foreground) 85%, transparent);
}
.react-flow__edge-path.brain-edge {
stroke: color-mix(in srgb, var(--muted-foreground) 38%, transparent);
stroke: var(--brain-ink);
stroke-linecap: round;
stroke-linejoin: round;
}
.react-flow__edge-path.brain-edge.brain-idle {
stroke: color-mix(in srgb, var(--muted-foreground) 55%, transparent);
/*
* Where a connection meets a neuron: a flow node's port, sat on the rim the same
* way, but told apart by direction — filled at the end the value leaves, hollow
* at the end it arrives. The hollow one takes the surface the band sits flat on,
* so it reads as a hole rather than as a disc of its own.
*/
.brain-dot {
fill: var(--card);
stroke: var(--brain-ink);
stroke-width: 1.5;
}
.react-flow__edge-path.brain-edge.brain-hot {
stroke: color-mix(in srgb, var(--muted-foreground) 85%, transparent);
transition: none;
.brain-dot.brain-dot-out {
fill: var(--brain-ink);
}
@media (prefers-reduced-motion: no-preference) {
.react-flow__edge-path.brain-edge {
transition: stroke var(--brain-decay) linear;
.react-flow__edge-path.brain-edge,
.brain-dot {
transition:
stroke var(--brain-decay) linear,
fill var(--brain-decay) linear;
}
.brain-wire.brain-hot .react-flow__edge-path.brain-edge,
.brain-wire.brain-hot .brain-dot {
transition: none;
}
}