Reorg

An org chart that trades text for department hue as it zooms out, and that you can rewire without aiming.

Pan across up to five thousand generated people, search them, walk the tree with the keyboard, then add, rename, move or remove someone and undo it. Edges are always Canvas 2D. Cards are DOM while the zoom is close enough to read them; below that the canvas draws name pills, then department-hued slabs carrying the headcount beneath them, and at the zoom floor hue and position alone — the breadcrumb, list pane and minimap carry identity there. Every boundary comes from a type size the card already uses; the breakdown shows the arithmetic.

A plain click focuses a branch; collapsing is the card’s Expand/Collapse button and Enter. Drag the background to pan; zoom with ⌘- or ctrl-scroll — a plain scroll belongs to the page — the − / + / Fit buttons, or +, − and 0 anywhere while the chart is on screen. Press m to move someone: the chart asks for a new manager and takes a click on any node, an arrow-key selection confirmed with Enter, or a search, so both ends of a move need never be on screen together; dragging onto a manager still does the local case, auto-panning near the edge. Press l for the list pane — the same org as an indented tree beside the chart, sharing one selection and one set of collapsed branches.

Generated data: the organization, its names, titles and reporting lines are procedurally generated from a seed. They describe no real company or person.

Generated data — names, titles and reporting lines come from a seed, not from a real company.

org size
100%
dataset
…
rendered
…
layout
…
tier
…

Generating organization…

FPS
measuring
ms/frame p95
measuring
KB transfer
—

Click a node to focus its branch; collapsing it moved to the card’s own Expand/Collapse button./ searchl list paneEnter expand/collapsea addF2 renamem moveDelete remove+ − 0 zoom⌘Z undo

How this is built

The layout is one pass of a tidy tree

Ines Calder225 pxRui Vance210 pxLena Poe190 pxBo Hale205 pxMarguerite Ashcombe241 pxKonstantin Werribee249 pxPerpetua Stanislavsky245 pxWren Ott203 pxAda Fen201 pxIvo Ma199 pxTam Ord197 pxNils Crane208 px
Drawn by layoutTree() — the same function the route calls after every command — over a fixed twelve-person sample. Card widths come from cardWidth(node), so they are the widths the renderer draws.

The contract is small and worth stating plainly: children are centred under their manager, siblings keep their order, and two subtrees never overlap no matter how deep or lopsided they are. The tree is placed in one linear pass — each subtree is laid out once, then shifted as a unit — which is why a relayout costs about the same whether one person moved or a hundred did. That algorithm is Reingold–Tilford as generalised by Buchheim, and it ships here as d3-hierarchy's tree().

Variable card widths are the one thing tree() does not do by itself, and they do not need a different library: nodeSize([1, ROW_STEP]) makes the horizontal unit one pixel, so a separation function returning cardWidth(a)/2 + cardWidth(b)/2 + 24 is an absolute pixel clearance. In the sample above that is 251 px between the centres of the widest adjacent pair — exactly the 251 px the rule asks for.

Two measurements decided the dependency, both taken before it was added (esbuild bundle, gzip -9; Node 24.6.0 on macOS arm64, 7 runs, 2026-08-31). Importing { hierarchy, tree } costs 1 831 B gzipped, and the median layout of a 5 000-person org takes 1.26 ms. Those are Node numbers on one machine. The figure to trust is the layout reading in the scene readout above: it is the real cost of the last pass, measured in your browser, on your machine. On the machine this was verified on, the shipped route relaid out all 5 000 expanded people in a median 3.4 ms (seven expand-all cycles, 2.5–4.7 ms) — more than the isolated prototype, because this figure includes building the hierarchy from the store, and still far inside a frame. The first relayout after switching dataset costs three to five times that while the code paths warm up. That headroom is the whole reason a full relayout on every edit is a design rather than a compromise.

What the diagram shows

  • Twelve cards over 4 levels; each level sits 120 px below the one above it, and a card is 64 px tall.
  • Every card's width is derived from its name and title, so it ranges across 190–249 px in this sample.
  • Adjacent cards on the same level are separated by half of each card's width plus a 24 px gap, so the clearance between two boxes is 24 px whatever they are named.
  • Edges leave the bottom centre of a manager, step down, across, and down again into the top centre of the report — the same orthogonal elbow the canvas layer strokes on the live route.

DOM, SVG or Canvas — and why editing settled it

This is the question the route exists to answer, and it does not have a single-renderer answer. Each column below is honest about what it is good at; the three rows marked with a dot are the ones that stopped being theoretical the moment the org became editable.

Rendering a large org chart: what each technology gives you
CriterionDOM cardsSVGCanvas 2D
All 5,000 at onceone box per node; mount cost and style recalc scale with the countthe same per-node cost, plus an element per edgeone pass over 9,999 primitives — a rectangle per person and an elbow per person who has a manager — the only one that holds
Textnative: wraps, selectable, respects the reader's font settings<text>: no wrapping, poor selection ergonomicsfillText per node per frame; nothing to select
Text input (rename, add) — decided by editinga real <input> inside the cardforeignObject — inconsistent across enginesa DOM input has to be overlaid anyway
Hit-testing (click, drag, drop target) — decided by editingfree — the event target is the nodefreehand-rolled against world coordinates
Accessibility — decided by editingreal role="treeitem" elements; aria-activedescendant needs onepossible, weakernone — the whole tree would have to be rebuilt
Cost of one pan frameone transform write on the containerone transform writea full repaint

The rule as shipped: three tiers, one per frame

The split is not DOM-or-canvas, it is a zoom ladder with three rungs. Cards render as DOM when the zoom is at least CARD_TIER_MIN_K = 0.8 and the culled set is at most MAX_DOM_CARDS = 400. Below either bound the node moves to the canvas — a name pill down to PILL_TIER_MIN_K = 0.28, and a department-hued slab carrying its headcount under that. Zoom alone would not be enough for the first bound: a wide org just above the threshold can still put thousands of cards on screen, which is exactly the case the count catches.

The tier is chosen once per frame for the whole scene, so a view is never half cards and half pills — mixing them reads as a rendering bug rather than as a level of detail. What is decided per node is only whether the text fits: a pill keeps its name while the pill affords at least two characters of it, and a slab keeps its digit while the slab is at least SLAB_TEXT_MIN_W = 24 screen px wide.

The three tiers, what each draws, and where its boundary comes from
TierHolds whileDrawsBoundary derived from
cardk ≥ 0.8, and at most 400 candidatesa real DOM card: name, title, department, headcount, the four edit actions and the collapse button9 ÷ 11 — the card's own name reaching the card's own smallest type size
pill0.28 ≤ k < 0.8a canvas pill: department hue as a wash plus a saturated bar, and the name in screen-space type, truncated to fit56 ÷ 200 — the screen width a truncated name plus its hue bar needs, over a deliberately narrow card: the generated org's own median is 238, and understating it makes the rule fail toward slabs
slabk < 0.28a hue-filled slab, with the number of people below it whenever the slab is wide enough to hold a digit24 screen px — the width below which a digit stops being a digit

Edges are always canvas — every ordinary elbow batched into one path and one stroke, with a second pair for the handful on the path from the root to the selection, which is drawn in the accent. They carry no text, no focus and no input, so they lose nothing by never being elements, and they are the majority of the primitives on screen.

Screen space is what moved the boundary

The first version of this route drew the low tier as filled rectangles with no text, and defended it with a real argument: below the zoom threshold it used, k = 0.35, the narrowest card is under 59 px wide, so its text would be illegible. That argument is true of world-space text — type that lives inside the transformed card layer and is scaled by the camera along with everything else. It is false of screen-space text, which is drawn with the transform reset to the identity and each node’s rectangle projected by hand, so the glyphs stay the size the reader needs however far out the camera is.

Once the label stops shrinking with the camera, the question stops being can we draw a card and becomes can a person read this. That is the whole of the change: the old threshold marked the point where cards stop being drawable, and all three v2 boundaries mark points where something stops being readable. MIN_LABEL_PX = 9 is not a taste — it is the size the shipped card already uses for its smallest line, so the floor was in the stylesheet before it was in the camera.

What the slab tier does not claim

A slab shows its headcount only when it is at least SLAB_TEXT_MIN_W = 24 screen px wide, and a card is between 168 and 288 world px, so the digit band is arithmetic rather than judgement: the widest card first affords a digit at k = 0.0833 and the narrowest one at k = 0.1429. Below that band the number is gone, and at the zoom floor MIN_ZOOM = 0.05 it is gone for every node without exception — a digit would need 480 world px of card and the widest card the width rule can produce is 288.

So this page will not tell you the slab tier is labelled all the way down. At the floor a node’s hue and position are the information: which department, and where in the shape of the org. Identity comes from the surfaces that are still text at that zoom — the breadcrumb naming the path to the selection, and the list pane, which is an indented tree of names and never changes size with the camera at all — while the minimap says where the camera is inside the whole layout, which is the other half of being lost. That is the honest trade: the chart gives up names at the floor, and the surfaces around it do not.

Why the label pass stays cheap

Drawing a label for every node sounds like the expensive choice, and it is bounded by construction instead: a legible label is large, and large labels do not fit next to each other, so the number of them a viewport can hold is capped by geometry rather than by the size of the org. There is one ctx.font assignment per pass, truncation is estimated from an average glyph advance instead of measured, and no per-node state survives a frame.

Measured on the 5,000-person org with every branch expanded, sweeping the camera across the whole tree for the densest region it contains: 76 labels at k = 0.30, 72 at k = 0.15, and 0 at k = 0.05, where nothing is wide enough to label. The whole loop callback — physics, culling and both canvas passes — held a p95 of 0.6–1.7 ms across the three tiers, worst single frame 2.3 ms, against a 25 ms budget. Repeating each drag with the label pass switched off moved the p95 by at most 0.3 ms and made one tier measure slower, so the labels cost less than the spread between two runs. No optimisation was applied, because none was warranted. (2026-09-02, production build, 1360×580 chart in a 1440×900 window, Apple silicon, macOS 26.5, headless Chromium.)

Why editing moved the answer

A viewer could have been pure canvas. An editor cannot. Renaming needs a text field where the card is; dragging needs a hit-test that knows which person is under the pointer; a screen reader needs a real element to point aria-activedescendant at. In DOM all three are free. In SVG the input means foreignObject, which works until it does not. On canvas all three would have to be rebuilt from scratch — more code, worse result. So canvas carries the edges, the pills and the slabs — every label below the card tier — and DOM carries the tier you can type into.

What the switch gives up is the input and the hit-target, not the information. The card tier ends at 0.8 because that is where the card’s own 11 px name, scaled by the camera, reaches the 9 px floor; below it the same name is drawn in screen space on the canvas instead. What does disappear is the field you could type into and the target you could aim a drop at. That is also why editing zooms in first — starting a mutation from an overview tier brings the person into view at 0.8 or closer before the input appears, so an inline editor can never mount on a pill. Selecting a person from the list pane or arriving on one from search goes through the same guard, for the same reason.

The three single-renderer answers, and why each lost

Pure SVG
The same per-element ceiling as DOM, with a worse text story and foreignObject for inline rename. It buys nothing DOM does not already have.
Pure canvas
Focus, text input, hit-testing and the entire accessibility tree would be ours to reimplement — strictly more code, ending up strictly worse.
Pure DOM
No answer for the fit-to-screen view of 5,000 people, which is the first thing a visitor does.

What keeps the DOM bounded

The render set is the culled set plus the focused card plus the dragged one, so the two cards that must exist always do, even when they are off screen. It is a rectangle intersection over an array we already own — no windowing library — and the camera never enters React state, so a pan is one transform write plus one canvas pass and zero re-renders. Watch rendered in the readout above while you pan: it stays a small number.

Smaller, now, than the cap that is supposed to protect it. The card tier only holds at k ≥ 0.8; a card is never narrower than 168 world px and rows sit 120 world px apart, so at that zoom a 1360×580 chart has room for a few dozen cards and no more. The viewport is the real bound and MAX_DOM_CARDS = 400 is the bound of last resort. Swept across all three dataset sizes at the card tier’s floor, with the list pane open and closed — 15,645 camera positions — the count peaked at 37 cards — an order of magnitude under the cap, which at this viewport size is no longer reachable at all. The cap stays because a much larger window, or a much wider card, would make it reachable again; it is honest to say it is not what is doing the work today. (2026-09-02, production build, 1360×580 chart in a 1440×900 window, Apple silicon, macOS 26.5, headless Chromium.)

d3-hierarchy tree() with a separation function

Decision
Layout is d3-hierarchy's tree() with nodeSize([1, 120]) and a separation that returns half of each card's width plus a 24 px gap, so variable-width cards are placed in absolute pixels.
Why
Measured at zero overlaps across 7,081 adjacent same-depth pairs on 12 random 600-person trees, with the minimum clearance landing on exactly the 24 px requested (Node 24.6.0, macOS 26.5 arm64, 2026-09-02) — and the same property is re-checked at build time by the no-overlap invariant.
Rejected
d3-flextree 2.1.2, which solves variable widths directly: +2,703 bytes gzipped, a second copy of d3-hierarchy (1.1.9, since it still asks for ^1.1.5) pulled into the tree, no bundled types, and no release since November 2021 — for a capability separation already provides. Also rejected: hand-rolling Buchheim. Apportion and contour threading are the 237 lines d3-hierarchy already ships in its own tree.js, and a bug there would be the exact bug the overlap invariant exists to catch, not to cause.

Undo as an invertible command log, not snapshots

Decision
Every edit is a command that carries what it needs to invert itself. A promote-mode removal stores the person, their manager, their index and their reports' ids — proportional to the number of reports, not to the size of the org.
Why
A snapshot per edit would copy the whole org every time: thousands of objects per step at 5 000 people, multiplied by UNDO_DEPTH = 200, and all of it serialized to localStorage on every commit. The log is also what makes persistence cheap — we store a seed and a list of edits and regenerate the base org on load.
Consequence
Because the base is regenerated, changing the generator invalidates every stored edit; the envelope carries a generatorVersion and a mismatch discards the log with a note rather than replaying onto a different org. And one command genuinely breaks the proportionality rule, which is the next decision.
Rejected
State snapshots, on memory and storage grounds. Also rejected: a diff or patch library — a dependency to express four operations we already own and have to validate ourselves anyway.

Deleting a manager is the visitor's choice, with the cost on screen

Decision
Removing someone who has reports asks which removal it is: promote the reports to that person's manager, keeping their order and position, or delete the whole subtree. Both are one remove command discriminated by a mode, both invert exactly, and both are operable from the keyboard alone.
Why
The asymmetry is the point, and the confirm says it out loud instead of hiding it. A promote record holds a handful of ids — 224 bytes for a 7-report manager. A subtree record has to hold every person it removed so undo can put them all back: 34,544 bytes for a 317-person branch, about 154× the same command in its other mode (Node 24.6.0, macOS 26.5 arm64, 2026-09-02). That is why the confirm names the count before it happens, and why “delete 316 people below” is a sentence rather than an icon.
Consequence
A large deletion makes the saved envelope large — a 3,161-person subtree serializes to 352,707 bytes (Node 24.6.0, macOS 26.5 arm64, 2026-09-02). The log is never truncated to make it fit: if the write fails, persistence turns off and says so, and the edits stay live in memory.
Rejected
Promote-only, which was this plan's original recommendation: it is the safer default, but it quietly decides for the visitor, and what happens to the people underneath is exactly the question an org chart should let you answer. Also rejected: delete-subtree-only, where a mis-aimed keystroke removes hundreds of people; and a preference in a settings panel, because the choice belongs at the moment of the decision, beside the number that informs it.

One animation loop owns everything

Decision
The route already runs a single requestAnimationFrame loop for the canvas and the HUD, so card springs are integrated in that same loop at a fixed step, and only for nodes currently in the render set. Everything added since rides it: the minimap's blit, the camera flights, and the auto-pan that runs while a card is dragged to the edge.
Why
Off-screen nodes jump to their new positions and nobody can see it, which is what keeps expanding 5 000 people bounded. The fixed-step integrator is the one Type Pressure Field already uses here, so frame-rate independence is a solved problem in this repo rather than a new one. A second loop would also make the HUD a lie: the counter beside it warns in development if the loop count ever passes one.
Rejected
motion — already a dependency of this repo and deliberately unused on this route — with an animated component per node: hundreds of animation components, and a HUD that would end up measuring a library's scheduler instead of this experiment's own work. Also rejected, later: a second loop for the minimap and an interval for the drag auto-pan, both of which would have been easier to write and impossible to measure honestly.

Fit means readable, not everything

Decision
Fit floors the zoom at PILL_TIER_MIN_K = 0.28. What fits above that floor is centred; what does not is centred horizontally on the visible root and top-aligned, with the rest reachable by panning. Fitting never magnifies past world scale either, so a single selected leaf stops at 1× instead of filling the frame.
Why
This is the defect the rework started from. On the two larger orgs first paint collapses everything below depth 2, so the visible tree is exactly 304 world px tall and thousands wide, and a fit to the layout's extent takes the smaller of the two ratios — the width one, by an order of magnitude. It bottomed out at the zoom floor of 0.05: technically the entire tree on screen, practically an unlabelled band across the middle of it. A tree roughly twenty times wider than it is tall cannot fill a 16:9 frame at any single zoom; that is arithmetic, not a bug waiting for a better fit function. What a viewer is owed instead is legibility plus a way to reach the rest, which is what the floor, the minimap, the breadcrumb and the list pane are.
Consequence
Fit no longer shows the whole org when the whole org cannot be shown readably, so the minimap is not a nicety here — it is where the overview went. Fit is idempotent by construction because it never reads the current camera, and both the floor and the idempotence are asserted at build time rather than described here.
Rejected
Fit to extent, which is what shipped in v1 and is now deleted rather than left as a second option — two fits would mean two answers to one button. Also rejected: forcing the chart to fill vertically, which for a 304 px tall tree in a 700 px chart means a zoom of about 2.3× — a full frame holding four people, which is a worse answer to "I cannot see anything" than the one it replaces.

Department hues are rotated out of the theme, not authored

Decision
Each of the 7 departments takes the current theme's canvas seed colour, converts it to Oklab, and rotates it by its share of a full turn — lightness and chroma untouched. The ramp is computed from the tweening theme snapshot, so it morphs with a theme change instead of cutting on the boundary.
Why
Authored palettes would need five hand-tuned sets, one per theme, and would drift the moment a theme moved. Rotating one seed keeps every department in the theme's own key — seven muted inks on the paper theme, seven signs on the neon one — and it is six lines of trigonometry against colour primitives the repo already has. Two properties are enforced at build time across every theme: each hue clears a contrast ratio against the ground it is drawn on, and every pair of hues stays a stated distance apart in Oklab, so a theme change cannot quietly make two departments the same colour.
Consequence
Colour is never the only carrier. Every hue is redundant with something a reader who cannot use it still gets: the department named in words on the card, the headcount digit on a slab, the name on a pill, the coloured bar repeated on the list row, and the breadcrumb path in text. Losing the hue costs discrimination between departments at a glance, never identity.
Rejected
Interpolating between two theme colours for seven steps, which produces a ramp whose middle entries are not discriminable. Also rejected: hardcoded hex values, which the repo forbids outright, would not tween, and would have to be re-picked for every theme added later. And rejected: putting the rotation in the shared colour module — it is one experiment's ramp, not a theme-system primitive.

Two storage keys, so a panel position can never cost an edit

Decision
The command log keeps its own key, lab.reorg.v1, at schema version 2, exactly as it was. View preferences — the list pane's width and open state, the minimap's corner and collapsed state — live in a second, independent key, lab.reorg.view.v1, with its own schema version.
Why
The org envelope is fail-closed on purpose: a schema mismatch discards the whole log rather than replaying half of it. That is the right behaviour for data, and the wrong risk to take with a panel position. Adding view fields to the org envelope would have meant either a version bump, which throws away every visitor's edits, or an unbumped optional field, which works but couples the thing that must survive to the thing nobody would miss. Two keys means neither schema has to move when the other does, and every edit log written before the rework still replays without a migration.
Consequence
The view key is read defensively and never trusted: malformed JSON, a wrong schema version, or a field of the wrong type all fall back to defaults silently, and deleting the key mid-session costs nothing but the panel positions. Verified by deleting and corrupting it in the browser — the org came back with the same people and the same edits every time.
Rejected
One envelope for both, which is fewer moving parts and the wrong trade: it makes the cheapest possible corruption — a stale panel coordinate — able to take a session's edits with it. Also rejected: keeping view preferences in memory only, which loses a deliberately positioned panel on every reload.

Move mode over drag-only reparenting

Decision
Pressing m on a selected person opens move mode: the chart says who is moving, and the new manager is named by clicking any node in any tier, by arrowing to one and pressing Enter, or by searching for one. Drag stays for the local case, and gains an auto-pan when a dragged card is held near the edge of the chart.
Why
Dragging can only reach what is on screen. In a five-thousand-person org the two ends of a move are usually nowhere near each other, and the old answer — zoom out until both are visible — lands below the card tier, where there is nothing to aim at. Move mode is distance-free by construction because it never needs the two ends in one view: it asks a question and takes an answer from whichever surface the visitor is already using. The commit funnels through the same code the drag uses, so a picked manager and a dropped card agree on what happened, what the camera does next, and which branch opens.
Consequence
Both routes zoom to the card tier at k = 0.8 before anything commits, so a mutation can never be aimed at a pill — though that zoom is not the editing guard's own: one camera follow serves every focus move, so arrowing, a search hit, a list-pane row and a click on a node all rise to k = 0.8 from below it too. Move mode also refuses the moves that are not moves — yourself, your own descendants, and the manager you already report to — and says why rather than failing quietly.
Rejected
Drag-only with a longer auto-pan, which makes a cross-org move a several-second gesture the visitor has to hold; and a second dedicated picker component, when the search panel already knows how to find a person by name, title or department.

An undo strip that stays, not a toast that leaves

Decision
After every change the strip above the chart names what just happened, keeps Undo and Redo with their keyboard chords printed on them, shows the edit count, and briefly emphasises itself so the change is noticed. It is in the document flow, and it never disappears.
Why
The lab's standing rule is that nothing floats over an experiment unless it is movable, keyboard-reachable and persisted — the minimap earns its corner by being all three. A toast is none of them: it covers part of the chart, it cannot be reached by keyboard while it is up, and it takes the undo affordance away again after a few seconds, which is exactly when someone realises they wanted it. A strip that is always there is strictly more available than one that fades, and it costs a row of chrome rather than a layer over the work.
Consequence
Under reduced motion the emphasis appears and disappears with no transition — the information stays, the animation goes.
Rejected
A floating toast, which is what the rework was originally asked for; the substitution is a superset of it and is recorded here rather than quietly made. Also rejected: showing undo only inside a menu, which hides the one control a visitor needs fastest after a mistake.