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:
@@ -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]))
|
||||
}
|
||||
Reference in New Issue
Block a user