---
title: obi-graph
summary: An Obsidian-style force-directed graph for React that stays interactive at 3,584 nodes, by replacing the two forces that dominate each tick.
date: 2026-11-05
kind: library
link: https://github.com/tmih06/obi-graph
draft: false
---

Live preview: [obi-graph.tmih06.com](https://obi-graph.tmih06.com)

```openui
root = DemoVideo("/uploads/obi-graph-demo.mp4", "/uploads/obi-graph-demo-poster.jpg", "1,420 species of real animal taxonomy: pan, zoom, hover focus, and a note panel that opens over the graph.")
```

## The idea

Obsidian's graph view is a good way to read a body of notes: it shows shape,
not just a list. I wanted that view as a React component — the drawing part
only, with the data and the styling left to the app.

```openui
root = CompareBoard("Obsidian graph view", "obi-graph", [{"label": "Force layout", "left": "Charge, link, collision, centre", "right": "The same four, same tuning"}, {"label": "Labels", "left": "Fade out below a zoom level", "right": "Same threshold, plus pinned hubs"}, {"label": "Hover", "left": "Highlight neighbours, dim the rest", "right": "Same, eased over 240 ms"}, {"label": "Navigation", "left": "Drag, inertial pan, pointer zoom", "right": "Same, plus a built-in toolbar"}, {"label": "Search and saved groups", "left": "Built in", "right": "Your UI: pass activeIds or emphasisIds"}, {"label": "Where it runs", "left": "Inside Obsidian, over a vault", "right": "A React component over your arrays"}, {"label": "Curved links, arrowheads", "left": "Yes", "right": "Straight strokes with optional arrowheads"}])
```

The comparison is about the interaction model, not the feature list. Obsidian
ships a note-taking app; this ships the canvas and gets out of the way.

## The approach

```mermaid
flowchart LR
  A[Your arrays] --> B[Accessors run<br/>on change, not per frame]
  B --> C[Force solver]
  C --> D[Hit-test buffer]
  D --> E[One canvas]
  E -->|idle| F[No frames scheduled]
  E -->|gesture or settle| C
```

Three decisions carry the design.

**Keep d3's maths, replace its allocations.** `d3-force` rebuilds a quadtree
object graph every tick for both many-body repulsion and collision. Both
replacements keep d3's public shape — `force(alpha)`, `initialize`, the same
chainable setters — but hold their state in `Int32Array` and `Float64Array`,
walk an explicit stack instead of recursing, and size a uniform grid to the
largest collision distance.

**Draw only when something changes.** Frames are scheduled for a settle, a
gesture, or a highlight transition. At rest the canvas is untouched, so an idle
graph costs nothing.

**Separate what reheats from what repaints.** Changing physics, topology, or a
node's radius keeps live positions and reheats the solver. Changing a label,
colour, width, or dash repaints without disturbing a settled layout.

## The result

```openui
root = BenchBars("Physics cost per tick, 3,584 nodes", "ms", 16, "Before", "After", [{"label": "Many-body repulsion", "before": 16, "after": 5}, {"label": "Collision", "before": 14, "after": 1.2}])
```

Two forces that each overran a 16 ms frame now fit inside one together, which
is what makes the demo interactive rather than a slideshow. The rest follows
from the same two ideas: idle frames cost nothing, and a repaint never wakes the
solver.

Capacity still depends on topology, zoom, hardware, and how many labels are
visible. This is Canvas 2D on the main thread, not WebGL; `onMetrics` reports
FPS and CPU milliseconds so you can measure your own graph instead of trusting
this one.

## Install

```sh
npm install obi-graph react
```

React 18+ is a peer dependency; `d3-force` comes with it. Import
`obi-graph/styles.css` once, wherever the graph mounts.

## Using it

```tsx
import { GraphCanvas } from "obi-graph";
import "obi-graph/styles.css";

const nodes = [
  { id: "lion", label: "Lion", radius: 8 },
  { id: "cat", label: "Cat", radius: 5 },
];
const links = [{ source: "cat", target: "lion" }];

export function Graph() {
  return (
    <div style={{ position: "relative", height: 500 }}>
      <GraphCanvas nodes={nodes} links={links} />
    </div>
  );
}
```

The canvas fills its parent, so the parent needs a height. Only `id`, `source`,
and `target` are required; everything else resolves per item, then by config,
then by default. Put your own payload on `data` — the renderer never reads it.

```tsx
type Note = { words: number; folder: "daily" | "project" };

const config: GraphConfig<Note> = {
  nodes: {
    radius: (node) => 2 + Math.sqrt(node.data?.words ?? 0) * 0.4,
    color: (node) => (node.data?.folder === "daily" ? "#10b981" : "#8b5cf6"),
  },
};
```

Accessors run when data or config changes, never per frame, and receive your
original objects. Layout fields are copied, so your arrays are never mutated.
Duplicate ids and links pointing at missing nodes throw with the offending id.

## Where it does not fit

Canvas 2D on the main thread has a ceiling. If you need 50,000 nodes, or
per-frame label collision, WebGL is the honest answer and this is not it. The
same applies to anything needing curved edges with routing or an editable graph
structure: those are deliberate omissions, not missing pieces.

Two smaller gaps: there is no built-in search, and the canvas is hidden from
screen readers by design — the accessible list has to come from your data, which
is the same data the graph is drawn from.

## Status

`0.1.0` is on npm (MIT). 0.x, so the API can still move.
Sixty-three tests cover the forces, layout, link geometry,
hit testing, and the model; CI adds formatting, lint, typecheck, a library and
demo build, and a check that every `exports` path resolves.
