TLDR: If you're placing N nodes on a canvas by accumulating a running offset, stop. Compute position from index. It's deterministic, it's idempotent, and your canvas won't look like a diagonal car crash.

What We Were Building

The SOP documentation canvas is a React Flow (node-graph canvas library) app that renders operational procedures as interconnected nodes, with a password gate, a RAG (retrieval-augmented generation) chat drawer, and an import pipeline that converts Make (the automation platform) blueprints directly into node stubs.

The happy path: you drop a blueprint JSON into content/blueprints/, run npm run import-make, and a fresh set of nodes appears on the canvas ready to wire up.

That part worked beautifully.

The layout side… did not.

The Wall

Every import placed each new node at an offset from the previous one.

Something like: x = lastX + deltaX, y = lastY + deltaY.

Looked fine for three nodes. Looked fine for six. Import thirty Make scenarios in one pass and you get a diagonal staircase marching off the bottom-right of the canvas, nodes running into each other, the canvas scrolling into oblivion, nothing readable.

I actually tried to fix it by tweaking the deltas first. Classic mistake. Bigger gaps just meant a bigger, emptier staircase.

The deeper problem: offset accumulation makes each node's position depend on the history of every node before it. One re-import, one reorder, one addition in the middle — and everything downstream shifts. It's not deterministic. It's not repeatable. It's a cursor, not a layout.

The Fix That Worked

Grid-pack. Embarrassingly simple in hindsight.

Instead of threading a running cursor, compute position from index:

col = i % cols
row = Math.floor(i / cols)
x = col * colWidth
y = row * rowHeight

No history. No accumulation. Position is a pure function of index.

Wired it up as a standalone script — npm run layout — so I can re-pack the entire canvas.json anytime without touching the nodes themselves.

Default params: 5 columns, 170px column width, 280px row height. Run it as layout [cols [colW [rowH]]] to override.

Why This Matters

The idempotency is the thing.

Offset accumulation means "run it twice, get two different layouts." Grid-pack means "run it a hundred times, get the same clean grid." That's the property that matters when you have an ongoing import pipeline — re-imports should be boring, not surprising.

There's a general principle hiding here that I now apply everywhere: if your placement logic uses any running state between items, ask whether index math could replace it. Usually it can. And when it can, do it — you get determinism for free.

Thirty nodes on a canvas, five columns, zero drama. Should have done it from the start.

P.S. The layout script works as a one-off repair pass too — if you inherited a canvas with chaotic manual positions and just want a clean starting grid, run it once. It doesn't touch edges or metadata, just rewrites x/y on each node.