Skip to main content

Developer blog

The Subprocessor Map Felt Wrong. We Rebuilt Its Interaction Engine.

How a blunt verdict, 'the feeling is not smooth at all,' turned into a full rewrite of our network map's rendering loop: animation-frame scheduling, an eased camera, per-view force layouts, and design lessons from Reddit's design communities.

ShareLinkedIn
data visualizationweb developmentcanvasUX
On this page
  1. The verdict
  2. The engine rewrite
  3. The blob
  4. Asking the internet
  5. The honest build log
  6. What it feels like now

Key takeaways

  • Our interactive subprocessor map (30-node curated view, 147-node full view, EN + FR) rendered every pointer movement as a full immediate canvas redraw. Wheel zoom jumped, touch did nothing useful, and the default view had silently collapsed into a blob.
  • The rewrite: animation-frame render scheduling, an eased camera that wheel zoom, buttons, reset, and selection centering all glide through, cached label measurements, batched edge strokes, one-finger pan with inertia, two-finger pinch, and reduced-motion support.
  • Real-browser QA caught the worst bug after the rewrite: the curated view’s 30 densest hubs collapsed into a single blob because the layout reused full-graph positions. Each view now gets its own force-layout pass.
  • Design decisions came from Reddit’s design communities, not our instincts: diamond markers for shared subprocessors, type tags in search results, and a one-line hint for first-time users.

The verdict

“The UI/UX is bad and the feeling is not smooth at all.”

That was the whole brief. The map worked: 147 nodes, click to inspect, search, zoom, a French version, text alternatives for screen readers. Every feature on the checklist, checked. And it felt wrong in a way no checklist captures. Panning stuttered. Zooming jumped. On a phone, the map was something you tolerated, not something you used.

Vague complaints get vague fixes. So the first job was translating a feeling into a list of mechanisms. We opened the component source and read it like a crime scene. What we found:

Every pointer movement triggered a full, immediate canvas redraw. Not a scheduled one, an immediate one, so a fast drag queued more work than a frame could hold. Wheel zoom, the zoom buttons, reset, and node centering all set the camera position directly: jump, not glide. Edges were stroked one by one, each with its own canvas state changes, instead of in batches. Label widths were re-measured on every frame instead of once per view. Hit testing computed a square root per node per event. Touch had no pinch handling and no momentum, so panning on a phone felt like dragging through sand. Search covered companies only, in a map where the interesting nodes are the shared subprocessors. Second-level nodes popped in with no transition.

None of these is exotic. They are the standard ways a canvas visualization gets slow, collected in one component. The fix was equally standard, which is worth saying plainly: there was no clever trick here, just the unglamorous work of removing per-frame waste and giving the camera a physics model.

The engine rewrite

The core change is a render loop with a camera that eases. Pointer input, wheel ticks, and button clicks no longer move the rendered camera directly. They move a target camera. Once per animation frame, the rendered camera moves a fraction of the remaining distance toward the target, and the frame only draws when something actually changed: the camera is still settling, a fade is running, or inertia is decaying. A fast drag now produces one draw per frame instead of a pile of them.

Around that loop, the per-frame waste went away. Label widths are measured once per view and cached in degree order, so the collision pass that keeps labels from overlapping reads numbers instead of calling measureText hundreds of times. Edges draw in two batched strokes (dim pass, focus pass) instead of one stroke per edge. Hit testing compares squared distances, which sounds trivial until you multiply a square root by 147 nodes by every mousemove.

Touch became a first-class input instead of a fallback. One finger pans, two fingers pinch around their midpoint, a tap selects. Releasing a pan carries velocity into the camera target with friction decay, which is the difference between “dragging through sand” and a map that feels alive. Double-click zooms toward the cursor. And prefers-reduced-motion is honored throughout: with it set, the camera snaps instead of gliding, fades are skipped, and inertia never engages. Motion is an enhancement, not a requirement.

Search now covers every node in the full graph, companies and subprocessors alike, with a small type tag on each result so “Microsoft” the company and “Microsoft” the shared subprocessor stop being ambiguous. The Connections panel dedupes its rows (one list had a company appearing four times). Toggling between the curated and full views preserves the selected node instead of wiping the panel.

The blob

Then QA found the thing that mattered most, and it wasn’t a smoothness problem at all.

The default view, the first thing every reader sees, showed all 30 curated nodes collapsed into a single blob at the center of the map. One label visible. It looked like a loading failure. It reproduced on fresh loads, in both languages.

The cause was in the build-time layout. Node positions are computed once, deterministically, with a seeded force-directed layout, and baked into the page. The curated view reused the full-graph positions of its 30 nodes. Those 30 are the densest hubs in the index: hundreds of edges among them, every one pulling on every other. In the full graph that dense core sits at the center of a ring, which is honest. Lifted out alone, with the same coordinates, it collapses into a dot.

The fix: each view gets its own layout pass. The curated 30 nodes are laid out as their own 30-node, 311-edge graph, with positions centered in the frame. Node sizes still use full-graph degree so the two views stay visually comparable, but positions belong to the view you’re looking at. This is the kind of bug that only a real browser catches. Unit tests saw 30 nodes with valid coordinates and passed. A human saw a blob.

Asking the internet

Before finalizing the visual decisions, we did what we have made our practice for UI work on this site: we asked Reddit’s design communities. Seven threads across r/web_design, r/UI_Design, r/Frontend, and r/dataisbeautiful, read in a signed-in session, no posting. Three of their lessons changed the map.

First, identical colored dots are not scannable. Shared subprocessors are now diamonds, companies are circles, in the canvas, in the SVG fallback, and in the legend swatch. You can tell the two node kinds apart without reading anything.

Second, mobile panning without momentum feels broken. That confirmed the inertia work above; it also would have been easy to skip as polish, and the threads were blunt that it isn’t.

Third, frame the map as an exploratory view and tell first-time users what to do. There is now a one-line hint under the controls: drag to pan, scroll to zoom, click any node. It is the smallest change in this post and possibly the most useful.

Two more lessons confirmed things we’d already done: manual hit testing with device-pixel-ratio scaling on canvas, and no red/green category encoding (our palette never had it). One suggestion we declined: aggregating nodes into clusters on small screens. Our mobile story is a hidden map behind an explicit show action plus the ranked text list, and clustering would have changed what the data says. Pretty but practical beats pretty but impractical; that line came from the threads too.

The honest build log

Engineering posts that skip the failures are launch announcements with better fonts.

The blob shipped. Covered above. It was live, in both languages, and the test suite said everything was fine. Lesson: layout correctness needs eyes, not assertions. A screenshot diff of the default view would have caught it; we now know to look.

Mobile at 390px is unverified. The QA tooling available to us could not drive a real 390-pixel viewport, so the touch flow, the show-map action, and scroll behavior on a phone are tested by code review and reasoning, not by observation. The touch handlers, the 44px targets, and the opt-in map display are all in place, but this is the one item on the list we’d like a human to confirm on a real device.

Dense centers still crowd. The curated view’s middle has label overlap on some loads; the collision filter shows the highest-degree labels first and drops the rest. A denser hub graph will always have a busy center. We chose readable priority labels over a sparser graph that says less.

The search cap is silent. Results cap at eight with no “see more.” For a 147-node graph that is usually enough, and the type tags make scanning fast, but a query like “a” will never show you everything. Known tradeoff, documented here.

What it feels like now

Pan: the map follows your finger and coasts to a stop. Wheel: the view glides toward the cursor. Click a node: the camera eases toward it and the Connections panel fills in. Toggle to the full 147-node view: a readable ring, no blob, your selection intact. Search “aws”: one result, typed, and the panel shows twenty companies naming it, each exactly once.

None of this is novel technology. It is the difference between a visualization that was assembled and one that was finished. The verdict that started it was three sentences long and entirely correct.