Brain: hover labels, activity falloff, rim-anchored directed edges

A name under every circle was most of what the graph drew, and none of it was
what someone looks at the brain for. Only the hovered or keyboard-focused
neuron names itself now — a failing one always does, since colour is never the
only carrier of a status. The label is absolute and the collide radius already
held that space, so revealing one moves nothing. Taking it out of flow also
fixed the off-centre edges: the node box was as wide as its widest label, so a
32px circle inside a 140px box sat 54px left of where the layout put it and
every edge aimed at the box rather than at the circle.

Neurons are filled discs rather than `bg-card` outlines, which were a hairline
against `--background` in light and close to nothing in dark. The fill is a
neutral `--muted-foreground` alpha and stays neutral: status still speaks only
through the border and the word beneath. Its lightness carries how recently the
open page saw that neuron publish — brightest just after it fires, decaying over
90s to a floor it never drops below, so a graph left open sorts itself into what
is busy and what is not. Edges do the same on their stroke. The signal is only
what this session has seen on the socket, so a fresh page starts everything at a
neutral middle rather than claiming a history it does not have. Both are CSS
transitions gated on `prefers-reduced-motion`, decayed out of and snapped into,
never the other way round.

`BrainEdge` now carries both circle radii and trims its path back to the rims,
with an arrowhead at the end it flows into. 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. Neither trim reaches past the midpoint, so neurons closer together
than their radii still get a line pointing the right way.

`edge-pulse` reads its landing colour from `--edge-rest` instead of naming
`--muted-foreground`, so a selected edge — which rests in blue — decays into its
own colour instead of crossing to grey and snapping back at the end.

Obsidian's graph view is the reference for the first three: filled nodes sized
by degree, labels on a fade threshold with hover to recover them, and arrows as
an explicit affordance. The falloff-by-opacity encoding is the standard one in
the dynamic-graph literature.

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 11:31:13 +02:00
co-authored by Claude Opus 5
parent 78c605bd37
commit 69421b857d
5 changed files with 255 additions and 58 deletions
+72 -4
View File
@@ -1,4 +1,4 @@
import { BaseEdge, type EdgeProps, getStraightPath } from "@xyflow/react"
import { BaseEdge, type EdgeProps } from "@xyflow/react"
import { memo, useEffect, useRef, useState } from "react"
import { duration } from "@/lib/motion"
@@ -8,9 +8,55 @@ 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. */
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.
*
* 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.
*/
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}`
}
/**
* A connection between neurons, lit by whatever passes along it.
*
@@ -26,12 +72,23 @@ function BrainEdgeComponent({
targetY,
data,
}: EdgeProps) {
const { messages } = (data ?? { messages: [] }) as BrainEdgeData
const { messages, sourceRadius, targetRadius } = (data ?? {
messages: [],
sourceRadius: 0,
targetRadius: 0,
}) as BrainEdgeData
const ts = useLatestTs(messages)
const [pulsing, setPulsing] = useState(false)
const lastTs = useRef(0)
const [path] = getStraightPath({ sourceX, sourceY, targetX, targetY })
const path = rimPath(
sourceX,
sourceY,
targetX,
targetY,
sourceRadius,
targetRadius,
)
useEffect(() => {
if (!ts || ts === lastTs.current) return
@@ -41,7 +98,18 @@ function BrainEdgeComponent({
return () => clearTimeout(timer)
}, [ts])
return <BaseEdge id={id} path={path} className={cn(pulsing && "edge-live")} />
return (
<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",
)}
/>
)
}
export const BrainEdge = memo(BrainEdgeComponent)
+52 -36
View File
@@ -1,6 +1,7 @@
import { Handle, type NodeProps, Position } from "@xyflow/react"
import { memo } from "react"
import { memo, useEffect, useState } from "react"
import { duration } from "@/lib/motion"
import { cn } from "@/lib/utils"
import { useGroupEmits, useGroupError } from "./liveStore"
@@ -19,46 +20,61 @@ function BrainNodeComponent({ data }: NodeProps) {
const { label, kind, members, flows, size } = data as BrainNodeData
const emits = useGroupEmits(members)
const failed = useGroupError(members)
// Brightest right after a publish, then left to decay. Only the peak needs
// holding: the floor is the resting style, so letting go is the whole decay.
const [firing, setFiring] = useState(false)
useEffect(() => {
if (!emits) return
setFiring(true)
const timer = setTimeout(() => setFiring(false), duration.pulse * 1000)
return () => clearTimeout(timer)
}, [emits])
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",
// Nothing seen yet this session is not the same as seen and gone quiet.
emits === 0 && "brain-idle",
firing && "brain-hot",
failed ? "border-destructive" : "border-border",
)}
style={{ width: size, height: size }}
title={`${label} · ${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}
{/* Remounting on each emit is what restarts the animation. */}
{emits > 0 ? <span key={emits} className="node-pulse" /> : null}
{/* Both ends sit at the centre; the edge trims itself back to the rim. */}
<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-foreground">
{flows.length > 1 ? flows.length : null}
</span>
{/*
* A name under every circle is what makes the graph unreadable, so only
* the neuron under the pointer or the keyboard says what it is — except a
* failing one, since colour is never the only carrier of a status.
*/}
<span className={cn("brain-label", failed && "brain-label-on")}>
<span className="max-w-[140px] truncate text-xs font-medium">
{label}
</span>
{failed ? (
<span className="text-xs font-medium text-destructive">failed</span>
) : null}
</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>
</span>
)
}
+14 -3
View File
@@ -22,7 +22,7 @@ import { useEffect, useMemo } from "react"
import type { BrainGraph } from "@/client"
import { scaleIn } from "@/lib/motion"
import { BrainEdge } from "./BrainEdge"
import { BrainEdge, type BrainEdgeData } from "./BrainEdge"
import { BrainNode, type BrainNodeData } from "./BrainNode"
import "./flow.css"
import { graphQueryOptions } from "./queries"
@@ -38,7 +38,10 @@ 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. */
/**
* Room around a circle for the label it shows on hover. Reserved in the layout
* rather than in the node box, so revealing a label moves nothing.
*/
const LABEL_ROOM = 26
type Placed = SimulationNodeDatum & { id: string; size: number; room: number }
@@ -105,6 +108,10 @@ 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.
const radius = new Map(placed.map((node) => [node.id, node.size / 2]))
return {
nodes: placed.map((node, index) => {
const source = (graph.nodes ?? [])[index]
@@ -130,7 +137,11 @@ function build(graph: BrainGraph): { nodes: Node[]; edges: Edge[] } {
type: "brain",
source: edge.source,
target: edge.target,
data: { messages: edge.messages ?? [] },
data: {
messages: edge.messages ?? [],
sourceRadius: radius.get(edge.source) ?? 0,
targetRadius: radius.get(edge.target) ?? 0,
} satisfies BrainEdgeData,
})),
}
}
+112 -6
View File
@@ -31,6 +31,19 @@
--xy-attribution-background-color: transparent;
}
/*
* Where a pulse lands: the opaque twin of the stroke the edge rests in, so the
* last step back is invisible. A selected edge rests in blue and so decays into
* it rather than crossing to grey and snapping back at the end.
*/
.react-flow__edge {
--edge-rest: var(--muted-foreground);
}
.react-flow__edge.selected {
--edge-rest: var(--primary);
}
/* A message arriving lights its edge, then decays back to rest. */
@media (prefers-reduced-motion: no-preference) {
.react-flow__edge-path.edge-live {
@@ -41,8 +54,7 @@
* 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.
* every pulse fluorescent yellow.
*/
@keyframes edge-pulse {
from {
@@ -50,7 +62,7 @@
stroke-width: 2.5;
}
to {
stroke: var(--muted-foreground);
stroke: var(--edge-rest);
stroke-width: var(--xy-edge-stroke-width);
}
}
@@ -102,8 +114,8 @@
}
/*
* 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 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-handle {
opacity: 0;
@@ -115,16 +127,110 @@
/*
* Embedded on Home rather than on a canvas of its own: the graph paints no
* surface, so the page's own background shows through behind the neurons.
*
* `--brain-decay` is how long a neuron or a connection takes to dim from its
* brightest back to the floor. Not a UI transition but the falloff of a signal,
* so it is measured in minutes rather than in the `--duration-*` tokens.
*/
.react-flow.brain-flat {
--xy-background-color: transparent;
--brain-decay: 90s;
}
/*
* A neuron is a filled disc rather than an outline: a `--card` circle on
* `--background` is a hairline in light and all but nothing in dark.
*
* The fill is neutral, and stays neutral — status still speaks only through the
* border and the word under it. What its lightness carries is how recently this
* page saw the neuron publish: brightest just after it fires, decaying to a
* floor it never drops below, so a graph left open sorts itself into what is
* busy and what is not. A fresh page has seen nothing either way, so everything
* starts at `.brain-idle`, halfway, claiming no history it does not have.
*/
.brain-cell {
cursor: pointer;
background-color: color-mix(
in srgb,
var(--muted-foreground) 28%,
transparent
);
transition: border-color var(--duration-base) var(--ease-standard);
}
.brain-cell:hover {
.brain-cell.brain-idle {
background-color: color-mix(
in srgb,
var(--muted-foreground) 42%,
transparent
);
}
/* Snapped into, decayed out of — never the other way round. */
.brain-cell.brain-hot {
background-color: color-mix(
in srgb,
var(--muted-foreground) 65%,
transparent
);
transition: none;
}
@media (prefers-reduced-motion: no-preference) {
.brain-cell {
transition:
border-color var(--duration-base) var(--ease-standard),
background-color var(--brain-decay) linear;
}
}
.brain-cell:hover,
.react-flow__node:focus-visible .brain-cell {
border-color: var(--primary);
}
/*
* A name under every circle is what makes the graph unreadable, so only the
* hovered or focused neuron carries one. Absolutely positioned: the layout's
* collide radius already holds this space clear, and revealing a label must not
* move anything.
*/
.brain-label {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 6px;
display: flex;
flex-direction: column;
align-items: center;
visibility: hidden;
}
.brain-cell:hover .brain-label,
.react-flow__node:focus-visible .brain-label,
.brain-label.brain-label-on {
visibility: visible;
}
/* The same falloff as the neurons, on the line instead of the disc. */
.react-flow__edge-path.brain-edge {
stroke: color-mix(in srgb, var(--muted-foreground) 38%, transparent);
stroke-linecap: round;
stroke-linejoin: round;
}
.react-flow__edge-path.brain-edge.brain-idle {
stroke: color-mix(in srgb, var(--muted-foreground) 55%, transparent);
}
.react-flow__edge-path.brain-edge.brain-hot {
stroke: color-mix(in srgb, var(--muted-foreground) 85%, transparent);
transition: none;
}
@media (prefers-reduced-motion: no-preference) {
.react-flow__edge-path.brain-edge {
transition: stroke var(--brain-decay) linear;
}
}