Files
app/frontend/src/components/Flow/BrainView.tsx
T

291 lines
10 KiB
TypeScript

import { useQuery } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import {
Background,
BackgroundVariant,
type Edge,
type Node,
ReactFlow,
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"
import type { BrainGraph } from "@/client"
import { useTheme } from "@/components/theme-provider"
import { useIsMobile } from "@/hooks/useMobile"
import { scaleIn } from "@/lib/motion"
import { BrainEdge, type BrainEdgeData } from "./BrainEdge"
import { BrainNode, type BrainNodeData } from "./BrainNode"
import "./flow.css"
import { liveStore } from "./liveStore"
import { graphQueryOptions } from "./queries"
import { useFlowSocket } from "./useFlowSocket"
// Never past 1: the labels are `text-xs`, and a graph small enough to fit
// twice over should not render its text at twice the size of the sidebar.
const FIT = { padding: 0.2, maxZoom: 1 }
const nodeTypes = { brain: BrainNode }
const edgeTypes = { brain: BrainEdge }
/** Circle diameter, from how many neurons this one is wired to. */
const SIZE_MIN = 32
const SIZE_MAX = 96
/**
* Room around a circle for the label it shows on hover. Reserved in the layout
* rather than in the node box, so revealing a label moves nothing.
*/
const LABEL_ROOM = 26
type Placed = SimulationNodeDatum & { id: string; size: number; room: number }
/**
* Where the neurons sit: a force layout run to rest once and then frozen.
*
* 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[] } {
const degree = new Map<string, number>()
for (const edge of graph.edges ?? []) {
degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1)
degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1)
}
const placed: Placed[] = (graph.nodes ?? []).map((node, index, all) => {
const size = Math.min(SIZE_MAX, SIZE_MIN + 8 * (degree.get(node.id) ?? 0))
const angle = (index / all.length) * 2 * Math.PI
const radius = 60 + all.length * 10
return {
id: node.id,
size,
room: size / 2 + LABEL_ROOM,
// An ellipse, not a circle: the band across Home is far wider than it is
// tall, so the layout is meant to come out that way too. Seeding the
// shape it should settle into beats making the forces below fight a
// circle into one.
x: Math.cos(angle) * radius * 1.8,
y: Math.sin(angle) * radius * 0.55,
}
})
const links = (graph.edges ?? []).map((edge) => ({
source: edge.source,
target: edge.target,
}))
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)
// 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 {
nodes: placed.map((node, index) => {
const source = (graph.nodes ?? [])[index]
return {
id: node.id,
type: "brain",
// React Flow places by the top-left corner; the layout means centres.
// Rounded, because the force simulation settles on arbitrary floats and
// a neuron on a half pixel has its ring, its gap and its disc rounded
// one edge at a time — which on the smallest ones is a visibly
// off-centre ring. `size` is already whole, so this is the last
// fractional term.
position: {
x: Math.round((node.x ?? 0) - node.size / 2),
y: Math.round((node.y ?? 0) - node.size / 2),
},
data: {
label: source.label,
kind: source.kind,
members: source.members ?? [],
flows: source.flows ?? [],
size: node.size,
issue: source.issue,
} satisfies BrainNodeData,
}
}),
edges: (graph.edges ?? []).map((edge) => ({
id: `${edge.source}->${edge.target}`,
type: "brain",
source: edge.source,
target: edge.target,
data: {
messages: edge.messages ?? [],
sourceRadius: radius.get(edge.source) ?? 0,
targetRadius: radius.get(edge.target) ?? 0,
} satisfies BrainEdgeData,
})),
}
}
function BrainCanvas() {
const navigate = useNavigate()
const { fitView } = useReactFlow()
const { resolvedTheme } = useTheme()
useFlowSocket()
// The socket invalidates every "flows" key on a rebuild, so publishing
// anywhere re-fetches this and the layout runs again.
const { data } = useQuery(graphQueryOptions())
const { nodes, edges } = useMemo(
() => build(data ?? { nodes: [], edges: [] }),
[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
// of the layout memo so revealing one does not re-run the simulation.
const isMobile = useIsMobile()
const [revealed, setRevealed] = useState<string | null>(null)
const shown = useMemo(
() =>
revealed
? nodes.map((node) =>
node.id === revealed
? { ...node, data: { ...node.data, revealed: true } }
: node,
)
: nodes,
[nodes, revealed],
)
// A rebuild lays the whole graph out afresh, so the viewport someone was
// looking through no longer frames anything. Only when the set of neurons
// actually changed: a value arriving must not move the canvas.
const shape = nodes.map((node) => node.id).join(" ")
useEffect(() => {
if (!shape) return
// After the new nodes have been measured, or the fit is of the old ones.
// Instant, never animated: an animated fit travels from React Flow's
// default viewport to the content, which on first load is the whole graph
// visibly sliding in from the corner.
const frame = requestAnimationFrame(() => fitView(FIT))
return () => cancelAnimationFrame(frame)
}, [shape, fitView])
return (
// Held back until there is a layout, so the graph grows in from the centre
// of the band it is already fitted to rather than appearing mid-pan.
<motion.div
variants={scaleIn}
initial="hidden"
animate={nodes.length ? "visible" : "hidden"}
className="h-full w-full"
>
<ReactFlow
nodes={shown}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
proOptions={{ hideAttribution: true }}
// React Flow stamps this on the wrapper as a class, and the app's own
// token scopes are named `.light` / `.dark` (index.css) — so leaving it
// unset re-themes the whole canvas to light inside a dark shell.
colorMode={resolvedTheme}
fitView
fitViewOptions={FIT}
minZoom={0.1}
maxZoom={2}
// A band inside a page that scrolls. React Flow claims the wheel by
// default, so leaving these on would trap the page under the pointer;
// the fit above keeps the whole graph in view without panning anyway.
zoomOnScroll={false}
zoomOnDoubleClick={false}
panOnDrag={false}
preventScrolling={false}
onNodeClick={(_event, node) => {
if (isMobile && revealed !== node.id) {
setRevealed(node.id)
return
}
// A neuron merges every node talking to the same thing, and its
// flows are in the order they contributed one — which is rarely the
// one that failed. So the failing member picks the flow, and takes
// its node with it; `flow.node_id`, and neither half can hold a dot.
const { members, flows } = node.data as BrainNodeData
const failing = members.find(
(member) =>
liveStore.getFailure(member) ||
liveStore.getStatus(member)?.status === "error",
)
const [memberFlow, nodeId] = (failing ?? "").split(".")
const flowName = memberFlow || flows[0]
if (flowName)
navigate({
to: "/flows/$flowName",
params: { flowName },
search: nodeId ? { node: nodeId } : {},
})
}}
onPaneClick={() => setRevealed(null)}
className="brain-flat h-full w-full"
>
{/* The same dot grid the editor's canvas uses, quieter and masked back
to nothing at the edges — see flow.css. No zoom compensation here:
this canvas only ever sits at whatever `fitView` chose and cannot
be zoomed, so there is no moiré to chase. */}
<Background variant={BackgroundVariant.Dots} gap={24} size={1.5} />
</ReactFlow>
</motion.div>
)
}
/**
* Every flow at once, merged on what each node talks to.
*
* Sits flat on whatever surface the page paints — no card, no canvas of its
* own — because it is the overview at the top of Home rather than a screen you
* navigate to. Read-only by design: what a neuron stands for lives in the flow
* it came from, and clicking one goes there.
*/
export function BrainView() {
return (
<div className="h-72 w-full sm:h-96">
<ReactFlowProvider>
<BrainCanvas />
</ReactFlowProvider>
</div>
)
}