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:
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
### Infrastructure
|
||||||
|
|||||||
@@ -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 { memo, useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
import { duration } from "@/lib/motion"
|
import { duration } from "@/lib/motion"
|
||||||
@@ -8,54 +8,20 @@ import { useLatestTs } from "./liveStore"
|
|||||||
export type BrainEdgeData = {
|
export type BrainEdgeData = {
|
||||||
/** Every message this connection carries, qualified. */
|
/** Every message this connection carries, qualified. */
|
||||||
messages: string[]
|
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
|
sourceRadius: number
|
||||||
targetRadius: number
|
targetRadius: number
|
||||||
[key: string]: unknown
|
[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
|
* A flow node's port handle is 12px across on a node some four times that; a
|
||||||
* circles: it looks off-centre wherever the circles differ in size, and it says
|
* neuron is 32px at its smallest, so the same proportion lands at 8px — small
|
||||||
* nothing about which way the value travels. The barbs are two more segments of
|
* enough that several connections into one small neuron stay separate dots.
|
||||||
* 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(
|
const DOT = 4
|
||||||
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.
|
* 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
|
* `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
|
* 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.
|
* 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({
|
function BrainEdgeComponent({ id, source, target, data }: EdgeProps) {
|
||||||
id,
|
|
||||||
sourceX,
|
|
||||||
sourceY,
|
|
||||||
targetX,
|
|
||||||
targetY,
|
|
||||||
data,
|
|
||||||
}: EdgeProps) {
|
|
||||||
const { messages, sourceRadius, targetRadius } = (data ?? {
|
const { messages, sourceRadius, targetRadius } = (data ?? {
|
||||||
messages: [],
|
messages: [],
|
||||||
sourceRadius: 0,
|
sourceRadius: 0,
|
||||||
targetRadius: 0,
|
targetRadius: 0,
|
||||||
}) as BrainEdgeData
|
}) as BrainEdgeData
|
||||||
|
const sourceNode = useInternalNode(source)
|
||||||
|
const targetNode = useInternalNode(target)
|
||||||
const ts = useLatestTs(messages)
|
const ts = useLatestTs(messages)
|
||||||
const [pulsing, setPulsing] = useState(false)
|
const [pulsing, setPulsing] = useState(false)
|
||||||
const lastTs = useRef(0)
|
const lastTs = useRef(0)
|
||||||
|
|
||||||
const path = rimPath(
|
// The two centres, taken from the layout rather than from the `sourceX`/
|
||||||
sourceX,
|
// `targetX` React Flow hands an edge. Those come off the handles, which it
|
||||||
sourceY,
|
// measures out of the DOM exactly once per node — and here that once falls
|
||||||
targetX,
|
// inside the graph's entrance scale, so every one of them is stored a few
|
||||||
targetY,
|
// percent short of the centre and never measured again. A node's position
|
||||||
sourceRadius,
|
// plus its radius is the same point, exactly, with nothing measured at all.
|
||||||
targetRadius,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!ts || ts === lastTs.current) return
|
if (!ts || ts === lastTs.current) return
|
||||||
@@ -99,16 +86,28 @@ function BrainEdgeComponent({
|
|||||||
}, [ts])
|
}, [ts])
|
||||||
|
|
||||||
return (
|
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
|
<BaseEdge
|
||||||
id={id}
|
id={id}
|
||||||
path={path}
|
path={path}
|
||||||
className={cn(
|
className={cn("brain-edge", pulsing && "edge-live")}
|
||||||
"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",
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
<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()
|
.stop()
|
||||||
.tick(300)
|
.tick(300)
|
||||||
|
|
||||||
// Both of an edge's handles sit at a neuron's centre, so the edge is what has
|
// An edge draws itself from the two centres outwards, so it needs the radius
|
||||||
// to trim itself back to the rims; the radius is only known here.
|
// 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]))
|
const radius = new Map(placed.map((node) => [node.id, node.size / 2]))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -114,8 +114,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Brain graph. Both ends of a connection sit at the neuron's centre, which is
|
* Brain graph. A neuron meets its connections all round its rim, so these two
|
||||||
* what `BrainEdge` measures its rim trim and its arrowhead from.
|
* 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 {
|
.brain-handle {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -213,24 +215,56 @@
|
|||||||
visibility: visible;
|
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 {
|
.react-flow__edge-path.brain-edge {
|
||||||
stroke: color-mix(in srgb, var(--muted-foreground) 38%, transparent);
|
stroke: var(--brain-ink);
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
stroke-linejoin: 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 {
|
.brain-dot.brain-dot-out {
|
||||||
stroke: color-mix(in srgb, var(--muted-foreground) 85%, transparent);
|
fill: var(--brain-ink);
|
||||||
transition: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
.react-flow__edge-path.brain-edge {
|
.react-flow__edge-path.brain-edge,
|
||||||
transition: stroke var(--brain-decay) linear;
|
.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