Brain force layout runs in a worker: 300 ticks took ~75 ms cold, ~30 ms warm on the Demo's render thread

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
2026-10-15 17:48:52 +02:00
co-authored by Claude Opus 5.5
parent 868a10d94b
commit 92c562a7d5
2 changed files with 87 additions and 42 deletions
+33 -42
View File
@@ -9,15 +9,6 @@ import {
ReactFlowProvider,
useReactFlow,
} from "@xyflow/react"
import {
forceCollide,
forceLink,
forceManyBody,
forceSimulation,
forceX,
forceY,
type SimulationNodeDatum,
} from "d3-force"
import { motion } from "motion/react"
import { useEffect, useMemo, useState } from "react"
@@ -27,6 +18,8 @@ import { useIsMobile } from "@/hooks/useMobile"
import { scaleIn } from "@/lib/motion"
import { BrainEdge, type BrainEdgeData } from "./BrainEdge"
import { BrainNode, type BrainNodeData } from "./BrainNode"
import type { LayoutRequest, Seed } from "./brainLayout.worker"
import BrainLayout from "./brainLayout.worker?worker"
import "./flow.css"
import { liveStore } from "./liveStore"
import { graphQueryOptions } from "./queries"
@@ -48,21 +41,22 @@ const SIZE_MAX = 96
*/
const LABEL_ROOM = 26
type Placed = SimulationNodeDatum & { id: string; size: number; room: number }
type Placed = Seed & { size: number }
type Layout = { nodes: Node[]; edges: Edge[] }
const EMPTY: Layout = { nodes: [], edges: [] }
/**
* Where the neurons sit: a force layout run to rest once and then frozen.
* Where the neurons start: a force layout (`brainLayout.worker.ts`) then runs
* them to rest once, and they stay frozen there.
*
* 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
* fixed ellipse 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[] } {
function seed(graph: BrainGraph): LayoutRequest & { placed: Placed[] } {
const degree = new Map<string, number>()
for (const edge of graph.edges ?? []) {
degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1)
@@ -89,29 +83,11 @@ function build(graph: BrainGraph): { nodes: Node[]; edges: Edge[] } {
source: edge.source,
target: edge.target,
}))
return { placed, links }
}
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),
)
// Centring pulls nearly three times as hard vertically as horizontally, so
// the graph compresses into a band roughly twice as wide as it is tall
// instead of the square an even pull settles into. Coming out of the
// simulation rather than out of a transform keeps the circles round and the
// edges the length the link force asked for.
.force("x", forceX(0).strength(0.05))
.force("y", forceY(0).strength(0.14))
.stop()
.tick(300)
/** The settled neurons as React Flow nodes, and the edges between them. */
function assemble(graph: BrainGraph, placed: Placed[]): Layout {
// 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]))
@@ -165,10 +141,25 @@ function BrainCanvas() {
// anywhere re-fetches this and the layout runs again.
const { data } = useQuery(graphQueryOptions())
const { nodes, edges } = useMemo(
() => build(data ?? { nodes: [], edges: [] }),
[data],
)
// The previous layout stays up until the next one has settled.
const [{ nodes, edges }, setLayout] = useState(EMPTY)
useEffect(() => {
if (!data) return
const { placed, links } = seed(data)
const worker = new BrainLayout()
worker.onmessage = ({
data: settled,
}: MessageEvent<[number, number][]>) => {
settled.forEach(([x, y], index) => {
placed[index].x = x
placed[index].y = y
})
setLayout(assemble(data, placed))
worker.terminate()
}
worker.postMessage({ placed, links } satisfies LayoutRequest)
return () => worker.terminate()
}, [data])
// A name is revealed on hover, which a finger does not have. On a phone the
// first tap says which neuron this is and the second follows it — kept out
@@ -0,0 +1,54 @@
/**
* The brain's force layout, off the render thread.
*
* Run to rest in one go, it took several frames on the main thread: about
* 75 ms on a page's first layout and 30 ms after for the Demo's 29 neurons,
* and 250 ms at five times that. Answers with where each neuron settled, in
* the order they came.
*
* `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.
*/
import {
forceCollide,
forceLink,
forceManyBody,
forceSimulation,
forceX,
forceY,
type SimulationNodeDatum,
} from "d3-force"
export type Seed = SimulationNodeDatum & { id: string; room: number }
export type LayoutRequest = {
placed: Seed[]
links: { source: string; target: string }[]
}
self.onmessage = ({ data }: MessageEvent<LayoutRequest>) => {
const { placed, links } = data
forceSimulation(placed)
.force(
"link",
forceLink(links)
.id((node) => (node as Seed).id)
.distance(130),
)
.force("charge", forceManyBody().strength(-320).distanceMax(500))
.force(
"collide",
forceCollide<Seed>((node) => node.room),
)
// Centring pulls nearly three times as hard vertically as horizontally, so
// the graph compresses into a band roughly twice as wide as it is tall
// instead of the square an even pull settles into. Coming out of the
// simulation rather than out of a transform keeps the circles round and the
// edges the length the link force asked for.
.force("x", forceX(0).strength(0.05))
.force("y", forceY(0).strength(0.14))
.stop()
.tick(300)
self.postMessage(placed.map((node) => [node.x ?? 0, node.y ?? 0]))
}