← Projects
library

obi-graph

An Obsidian-style force-directed graph for React that stays interactive at 3,584 nodes, by replacing the two forces that dominate each tick.

tmih06/obi-graph

Live preview: obi-graph.tmih06.com

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.

CapabilityObsidian graph viewobi-graph
Force layoutCharge, link, collision, centreThe same four, same tuning
LabelsFade out below a zoom levelSame threshold, plus pinned hubs
HoverHighlight neighbours, dim the restSame, eased over 240 ms
NavigationDrag, inertial pan, pointer zoomSame, plus a built-in toolbar
Search and saved groupsBuilt inYour UI: pass activeIds or emphasisIds
Where it runsInside Obsidian, over a vaultA React component over your arrays
Curved links, arrowheadsYesStraight 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

idlegesture or settleYour arraysAccessors runon change, not per frameForce solverHit-test bufferOne canvasNo frames scheduled

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

Physics cost per tick, 3,584 nodesBeforeAfter

16ms budget

  • Many-body repulsion16ms5ms69% less
  • Collision14ms1.2ms91% less

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

Shell
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.