A decade ago, building a single-cell atlas meant an HPC cluster, a pipeline of a dozen command-line tools, and weeks of curation. In 2026 you can do it in a browser tab. This tutorial walks through the full pipeline — from a raw counts file to an interactive scatter plot — using only the SciRouter Cell Atlas API and a handful of JavaScript libraries.
The endpoint does the heavy lifting. Your browser code handles the file parsing, the API calls, the 2D projection, and the plotting. Three API calls plus some glue code is all you need.
Architecture
Three API calls make up the pipeline, and each one does a distinct job:
- POST /v1/singlecell/annotate — returns cell-type labels, confidence scores, and top marker genes per cell.
- POST /v1/singlecell/embed — returns a 256- or 512-dimensional embedding per cell that you can feed into UMAP or PCA for 2D projection.
- POST /v1/singlecell/marker-genes — returns a ranked marker gene list per cell-type label so you can build a legend or cluster annotation panel.
Everything else runs client-side: parsing the matrix, calling UMAP, drawing the scatter plot, handling hover and click events.
Step 1 — parsing the counts file
The simplest input format for a browser app is a gzipped CSV with rows as cells and columns as genes, plus a header row naming the genes. Something like Papa Parse plus pako for gzip works:
import Papa from "papaparse";
import pako from "pako";
async function loadCounts(file) {
const compressed = new Uint8Array(await file.arrayBuffer());
const csvText = new TextDecoder().decode(pako.ungzip(compressed));
const parsed = Papa.parse(csvText, { header: true });
const genes = parsed.meta.fields.slice(1); // first column is cell id
const cells = parsed.data.map((row) => genes.map((g) => +row[g] || 0));
return { genes, cells };
}
This yields a dense 2D array. For datasets larger than about 5,000 cells, build a CSR representation client-side before shipping to the API.
Step 2 — calling the annotation endpoint
The annotation endpoint returns cell-type labels, confidence scores, and top marker genes in a single response:
const API = "https://scirouter-gateway-production.up.railway.app/v1/singlecell";
const apiKey = "sk-sci-your-key";
async function annotate(genes, cells) {
const res = await fetch(`${API}/annotate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "geneformer",
genes,
matrix: toCSR(cells),
reference_atlas: "human-core-2026",
}),
});
if (!res.ok) throw new Error(`annotate failed: ${res.status}`);
return res.json();
}
The helper toCSR converts your 2D array to the indptr/indices/data representation described in the sparse-matrix tutorial on this blog.
Step 3 — extracting embeddings
The embedding endpoint returns a fixed-dimensional vector per cell. These vectors live in the foundation model's latent space and are what you feed to UMAP:
async function embed(genes, cells) {
const res = await fetch(`${API}/embed`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "geneformer",
genes,
matrix: toCSR(cells),
}),
});
return (await res.json()).embeddings;
}
Step 4 — running UMAP in the browser
umap-js is a pure-JavaScript UMAP implementation that runs happily in the browser. It takes a couple of seconds for 10,000 cells:
import { UMAP } from "umap-js";
function runUmap(embeddings) {
const umap = new UMAP({
nComponents: 2,
nNeighbors: 15,
minDist: 0.1,
});
return umap.fit(embeddings);
}
The output is an array of [x, y] coordinates, one per cell. Feed that straight into your plotting library.
Step 5 — the scatter plot
DOM-based SVG plots choke above about 5,000 points. For a real atlas, use a WebGL-backed library. Here is the outline with regl-scatterplot:
import createScatterplot from "regl-scatterplot";
function drawAtlas(canvas, coords, labels) {
const uniqueLabels = [...new Set(labels)];
const colorMap = Object.fromEntries(
uniqueLabels.map((l, i) => [l, hsl(i / uniqueLabels.length)])
);
const points = coords.map(([x, y], i) => [
x, y, uniqueLabels.indexOf(labels[i])
]);
const scatter = createScatterplot({ canvas, pointSize: 4 });
scatter.draw(points);
}
Use a d3 color scale for the hsl helper and you have a serviceable atlas in under 200 lines of client code.
Step 6 — interactivity
The annotation response already contains everything you need for a click handler. Store the result array globally or in React state, and when a cell is clicked look up its entry:
scatter.subscribe("pointOver", ({ points }) => {
const idx = points[0];
const call = annotationResult.calls[idx];
showTooltip({
label: call.label,
confidence: call.confidence,
markers: call.markers.slice(0, 5),
});
});
No additional API calls needed. The marker genes were fetched in step 2 and are already in memory.
Performance tips
- Cap dataset size at 50k cells. Beyond that, browser memory and UMAP runtime both become painful. Cluster-level downsampling is a fine compromise.
- Ship CSR, not dense. Sparse payloads are 10 to 100x smaller for typical scRNA-seq data.
- Run UMAP in a web worker. Keeps the main thread responsive while the projection runs.
- Cache embeddings in IndexedDB. Users re-opening the app should not pay for another API call unless the data changed.
Where to go from here
Once the basic atlas works, the interesting extensions are all straightforward:
- Overlay gene expression on the scatter plot with a color ramp per gene.
- Add a brush selection tool and run differential expression on the selected cells through a separate endpoint.
- Compare two datasets side by side by embedding both with the same foundation model and plotting them in the same latent space.
All of these hang off the same three core endpoints. The Geneformer tool page documents the full request and response schemas if you want to go deeper.
Bottom line
A browser-based single-cell atlas used to be a research project. With a hosted foundation model API and a WebGL plotting library it is now a weekend project. The code above is the entire pipeline — parse, annotate, embed, project, plot. Everything else is polish.