Building a Spatial Interface for the Protein Data Bank: Notes on NexoPeek

The first time I opened a 4.2 GB cryo-EM map on my laptop, the fan spun up like a tiny jet engine and the viewer crashed. That was three years ago, and it's the moment I started thinking seriously about what a "spatial interface" for the Protein Data Bank would actually require. Not a prettier viewer — an architecture that treats structural biology data as a streaming, spatially-indexed workload rather than a file you drag onto a desktop.

This is a build log of sorts. NexoPeek is the result, and I want to walk through the engineering decisions honestly, including the ones I'm still not sure about.

The problem is not rendering. It's I/O.

Most molecular viewers spend their time on GPU shading and bond tessellation. Fine. But when I profiled our early prototype against a realistic session — pull an entry, inspect a ligand, cross-reference a related structure, load the associated density map — more than 70% of wall-clock time was disk and network I/O. The renderer was idle. The PDB is huge, and entries are heterogeneous: small NMR ensembles, giant ribosome complexes, hybrid models, maps stored separately from coordinates.

So the first architectural rule for NexoPeek: never load a whole entry to show a whole entry. We parse mmCIF lazily, extract a spatial bounding hierarchy, and stream atoms by region of interest. The client keeps a coarse octree of what it has; the server knows what it hasn't sent yet.

Spatial indexing, not just metadata search.

Keyword search over PDB metadata is a solved-ish problem. The interesting query is spatial: "show me everything within 8 Å of this binding site, across every entry." Doing that naively means downloading the PDB. Instead we build per-entry spatial summaries at ingest time — quantized coordinate cells with a resolution chosen per entry, so a 10,000-atom structure and a 500,000-atom structure both produce a bounded index. Queries hit the summaries first, then fetch only the atoms that survive the filter.

A simplified shape of the ingest step:

def index_entry(atoms, cell_size=4.0): grid = defaultdict(list) for atom in atoms: key = quantize(atom.coord, cell_size) grid[key].append(atom.serial) return { "cell_size": cell_size, "cells": dict(grid), "bounds": compute_bounds(atoms), } 

That's it. The cleverness is downstream: neighbor queries become cell enumeration, and rendering becomes "give me the cells intersecting the frustum."

Trade-offs I'd defend and trade-offs I wouldn't.

Cell size is the obvious knob. Small cells mean tighter queries but fatter indexes; large cells mean cheap indexes but you over-fetch. We picked 4 Å as a default because it's near the scale of a hydrogen bond and it kept our index under ~15% of raw coordinate size in our own measurements. That number is workload-dependent — I'd expect it to move for, say, a membrane-protein-heavy corpus.

The harder trade-off was client vs. server authority. We let the client own camera and culling; the server owns truth. That means a fast client can feel instant while a slow network still shows something coherent. It also means two clients can disagree about what's "loaded," and we had to be disciplined about never rendering a claim the server hadn't confirmed.

What spatial interfaces actually change.

When pan and zoom are cheap, you stop thinking in terms of "opening a file." You start thinking in terms of a continuous space where every entry is a place. Cross-referencing stops being a tab switch and becomes a movement. For a working structural biologist that's a small shift; for someone learning, it's the difference between a database and a map.

NexoPeek is at https://nexopeek.com/?utm_source=devto if you want to poke at it. I'd especially like feedback from anyone who's tried to build spatial indexes over heterogeneous scientific data — I suspect our cell-size heuristic is the weakest part of the design, and I'd rather hear that now than after it ships to more people.