From 87ee7e34291a75e80c6e65b41f9546ef44bcd107 Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 15 Oct 2026 17:48:52 +0200 Subject: [PATCH] Brain force layout runs in a worker: 300 ticks took ~75 ms cold, ~30 ms warm on the Demo's render thread --- frontend/src/components/Flow/BrainView.tsx | 75 ++++++++----------- .../src/components/Flow/brainLayout.worker.ts | 54 +++++++++++++ 2 files changed, 87 insertions(+), 42 deletions(-) create mode 100644 frontend/src/components/Flow/brainLayout.worker.ts diff --git a/frontend/src/components/Flow/BrainView.tsx b/frontend/src/components/Flow/BrainView.tsx index 82ab43d..15f1657 100644 --- a/frontend/src/components/Flow/BrainView.tsx +++ b/frontend/src/components/Flow/BrainView.tsx @@ -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() 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((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 diff --git a/frontend/src/components/Flow/brainLayout.worker.ts b/frontend/src/components/Flow/brainLayout.worker.ts new file mode 100644 index 0000000..2651245 --- /dev/null +++ b/frontend/src/components/Flow/brainLayout.worker.ts @@ -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) => { + 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((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])) +}