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:
@@ -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 (
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={path}
|
||||
// The tone lives on the group so the line and both dots share one falloff.
|
||||
<g
|
||||
className={cn(
|
||||
"brain-edge",
|
||||
"brain-wire",
|
||||
// Nothing seen yet this session is not the same as seen and gone quiet.
|
||||
ts === 0 && "brain-idle",
|
||||
pulsing && "brain-hot edge-live",
|
||||
pulsing && "brain-hot",
|
||||
)}
|
||||
/>
|
||||
>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={path}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user