Charting genomic data
Build Manhattan plots, volcano plots, heatmaps and sequencing-depth plots into a teaching page. Vega-Lite suits most pages, because each chart is a short description that students can read and change. Use Plotly.js when students need to zoom.
Which charting library to use
| Feature | Vega-Lite | Observable Plot | Plotly.js |
|---|---|---|---|
| What it is | A chart written as a description: the data, the marks, and which field goes where | A chart built by a short JavaScript function, from the makers of d3 | Charts built from “traces”, with zooming and selection built in |
| Download, compressed | 280 kB for three scripts | 160 kB with d3 | 500 kB for the common chart types |
| Best for | Most teaching pages, and charts that students edit | Short code and fast drawing of large scatter plots | Zooming into a region and selecting points |
| Screen readers | Labels the chart and each mark from text you give it | Labels from text you give it | Nothing on the data: describe the chart yourself |
| Limits | No zooming unless you add it; slows down above about 10,000 marks | Still version 0.6, so updates can change how it works; no zooming | Largest; a “Share chart” button sends charts to Plotly’s cloud unless you turn it off |
| Licence | BSD 3-Clause | ISC | MIT |
Vega-Lite [1] describes a chart as a JavaScript object, or a JSON file: where the data are, which mark to draw, and which field goes on each axis and colour. Python’s Altair library writes the same descriptions, so the grammar students learn here also works in a notebook. We built the same charts with Observable Plot 0.6.17: its code was the shortest and it drew the 15,804 points of the volcano plot in under 0.2 seconds, but it cannot zoom and has had no release since February 2025.
The examples below use real public data: genome-wide association results from FinnGen, RNA-seq results from the Galaxy Training Network and sequencing depth from gnomAD. All were tested on 15 October 2026 in Chromium 141, with Vega 6.4.0, Vega-Lite 6.4.3 and vega-embed 7.3.0, and Plotly.js 4.1.1 for the zoomable plot.
Start with a simple chart
Three script tags and one call to vegaEmbed give you a chart. The chart itself is a plain object, called a specification, with the data, a mark and an encoding.
Code vega-lite-basic.html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vega-Lite: the lengths of the human chromosomes</title>
<!-- Vega-Lite needs three scripts, in this order: Vega draws the chart, Vega-Lite turns
a short description into a full Vega one, and vega-embed puts the result in the page.
The integrity hashes stop a changed file from running. -->
<script src="https://cdn.jsdelivr.net/npm/vega@6.4.0/build/vega.min.js"
integrity="sha384-VKdcJr3ZaBIJMbVcopTAI/JEuUkSY6qnwVu9iLuw0DnQ9gQ1JjsfZJhXFQAgNi43"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-lite@6.4.3/build/vega-lite.min.js"
integrity="sha384-9/70gNCfOu6G7xXvkdreMfuqAEsoaGJVXV2BN/JLRXkSmcGvnMqtsRx8HZtUWAvI"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-embed@7.3.0/build/vega-embed.min.js"
integrity="sha384-Muy1QRxYFeNrA1zShc1KtN4OfipkR/61gt+HWqN57c1Zxu3Oe16TsilsruVVjfKL"
crossorigin="anonymous"></script>
<style>
body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 12px 16px 16px; color: #172033; background: #fff; }
h1 { font-size: 1.05rem; margin: 0 0 8px; }
#chart { width: 100%; }
</style>
</head>
<body>
<h1>The lengths of the human chromosomes (GRCh38)</h1>
<div id="chart"></div>
<script>
const spec = {
$schema: "https://vega.github.io/schema/vega-lite/v6.json",
// Screen readers read this description as the chart's label
description: "Bar chart of the lengths of the 24 human chromosomes in GRCh38, from 249 Mb for chromosome 1 down to 47 Mb for chromosome 21.",
width: "container", // as wide as the element it sits in
height: 220,
data: {
values: [ // lengths in megabases, from UCSC's hg38.chrom.sizes
{ chrom: "1", mb: 248.96 }, { chrom: "2", mb: 242.19 }, { chrom: "3", mb: 198.30 },
{ chrom: "4", mb: 190.21 }, { chrom: "5", mb: 181.54 }, { chrom: "6", mb: 170.81 },
{ chrom: "7", mb: 159.35 }, { chrom: "8", mb: 145.14 }, { chrom: "9", mb: 138.39 },
{ chrom: "10", mb: 133.80 }, { chrom: "11", mb: 135.09 }, { chrom: "12", mb: 133.28 },
{ chrom: "13", mb: 114.36 }, { chrom: "14", mb: 107.04 }, { chrom: "15", mb: 101.99 },
{ chrom: "16", mb: 90.34 }, { chrom: "17", mb: 83.26 }, { chrom: "18", mb: 80.37 },
{ chrom: "19", mb: 58.62 }, { chrom: "20", mb: 64.44 }, { chrom: "21", mb: 46.71 },
{ chrom: "22", mb: 50.82 }, { chrom: "X", mb: 156.04 }, { chrom: "Y", mb: 57.23 }
]
},
mark: { type: "bar", color: "#2a78d6", width: { band: 0.6 }, cornerRadiusEnd: 3, tooltip: true },
encoding: {
// sort: null keeps the order of the data, rather than sorting the names as text (1, 10, 11…)
x: { field: "chrom", type: "nominal", sort: null, title: "Chromosome",
axis: { labelAngle: 0, labelOverlap: "parity" } }, // on a narrow screen, drop every other label rather than overlap
y: { field: "mb", type: "quantitative", title: "Length (Mb)" }
},
config: { font: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" }
};
if (window.vegaEmbed) {
// actions: false hides vega-embed's menu, which can send the chart to the online Vega Editor
vegaEmbed("#chart", spec, { actions: false });
} else { // the library did not arrive: say so rather than leave a blank space
document.getElementById("chart").textContent = "The chart library did not load. Check the connection and reload the page.";
}
</script>
</body>
</html>How it works:
- Three scripts, in this order. Vega draws the chart, Vega-Lite turns the short specification into a full Vega one, and vega-embed puts it in the page and adds tooltips.
- The specification has
data, amark(herebar) and anencoding, which says which field goes onx,y,colorortooltip. Thetypeof each field tells Vega-Lite how to treat it:nominalfor names,quantitativefor numbers. width: "container"makes the chart as wide as the element it sits in, and redraws it when the window changes size.descriptionbecomes the chart’s label for screen readers. Vega also describes each axis and each bar.actions: falsehides vega-embed’s menu. The menu offers downloads, but also “Open in Vega Editor”, which sends the specification, with any data written into it, to vega.github.io.
To try changes without a page of your own, use the online Vega Editor. It takes the same specification written as JSON, so students can paste one in and change it.
Draw a Manhattan plot from FinnGen’s results
A Manhattan plot places every tested variant along the genome, with −log₁₀ of its p-value upwards, so the strongest associations stand tallest. This one reads FinnGen’s published results for alcohol use disorder [2] from FinnGen’s own server each time it opens.
FinnGen release R13, endpoint AUD_SWEDISH: 29,711 cases and 470,475 controls. Open the table under the chart to compare the height of each peak with its effect size and allele frequency.
Code manhattan-finngen.html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vega-Lite: a Manhattan plot of FinnGen results for alcohol use disorder</title>
<script src="https://cdn.jsdelivr.net/npm/vega@6.4.0/build/vega.min.js"
integrity="sha384-VKdcJr3ZaBIJMbVcopTAI/JEuUkSY6qnwVu9iLuw0DnQ9gQ1JjsfZJhXFQAgNi43"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-lite@6.4.3/build/vega-lite.min.js"
integrity="sha384-9/70gNCfOu6G7xXvkdreMfuqAEsoaGJVXV2BN/JLRXkSmcGvnMqtsRx8HZtUWAvI"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-embed@7.3.0/build/vega-embed.min.js"
integrity="sha384-Muy1QRxYFeNrA1zShc1KtN4OfipkR/61gt+HWqN57c1Zxu3Oe16TsilsruVVjfKL"
crossorigin="anonymous"></script>
<style>
body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 12px 16px 16px; color: #172033; background: #fff; }
h1 { font-size: 1.05rem; margin: 0 0 4px; }
p { margin: 0 0 6px; }
#status { font-size: 14px; color: #3b4659; }
#chart { width: 100%; }
summary { cursor: pointer; font-weight: 600; margin-top: 4px; }
.scroll { overflow-x: auto; }
table { border-collapse: collapse; font-size: 13px; margin: 6px 0; }
th, td { padding: 3px 12px 3px 0; text-align: left; border-bottom: 1px solid #dde2ea; white-space: nowrap; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.credit { font-size: 13px; color: #3b4659; margin-top: 8px; }
</style>
</head>
<body>
<h1>Genome-wide association with alcohol use disorder (FinnGen R13)</h1>
<p id="status" role="status" aria-live="polite">Loading FinnGen's results (145 kB)…</p>
<div id="chart"></div>
<details>
<summary>The lead variants that pass p = 5 × 10⁻⁸, as a table</summary>
<div class="scroll">
<table>
<thead><tr><th>Variant</th><th>Nearest gene</th><th>Position (GRCh38)</th><th class="num">p</th><th class="num">Effect (beta)</th><th class="num">Frequency of the alternative allele</th></tr></thead>
<tbody id="leads"></tbody>
</table>
</div>
</details>
<p class="credit">Data: FinnGen release R13, endpoint AUD_SWEDISH (alcohol use disorder, Swedish definition): 29,711 cases and 470,475 controls.
We acknowledge the participants and investigators of the FinnGen study. Kurki MI et al. (2023) <i>Nature</i> 613: 508–518.</p>
<script>
const URL = "https://r13.finngen.fi/api/manhattan/pheno/AUD_SWEDISH";
const THRESHOLD = 5e-8; // the usual genome-wide significance line
// GRCh38 chromosome lengths (UCSC hg38.chrom.sizes). FinnGen calls chromosome X "23".
const LENGTH = { 1: 248956422, 2: 242193529, 3: 198295559, 4: 190214555, 5: 181538259, 6: 170805979,
7: 159345973, 8: 145138636, 9: 138394717, 10: 133797422, 11: 135086622, 12: 133275309, 13: 114364328,
14: 107043718, 15: 101991189, 16: 90338345, 17: 83257441, 18: 80373285, 19: 58617616, 20: 64444167,
21: 46709983, 22: 50818468, 23: 156040895 };
// A chart has one x axis, so lay the chromosomes end to end: each starts where the last one ended.
const start = {};
let genome = 0;
for (let c = 1; c <= 23; c++) { start[c] = genome; genome += LENGTH[c]; }
const name = c => (+c === 23 ? "X" : String(c));
const along = (c, pos) => (start[+c] + pos) / 1e6; // position along the whole genome, in Mb
async function getJSON(url, timeoutMs = 20000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const r = await fetch(url, { signal: ctrl.signal });
if (!r.ok) throw new Error("HTTP " + r.status);
return await r.json();
} finally {
clearTimeout(timer);
}
}
getJSON(URL).then(async d => {
// FinnGen's server (PheWeb) lists the strongest variants one by one...
const listed = d.unbinned_variants.map(v => {
const id = v.rsids !== "NA" ? v.rsids : name(v.chrom) + ":" + v.pos + ":" + v.ref + ":" + v.alt;
const gene = v.nearest_genes !== "NA" ? v.nearest_genes : "none listed";
return {
x: along(v.chrom, v.pos), mlogp: v.mlogp, odd: +v.chrom % 2, id, gene,
chrom: name(v.chrom), pos: v.pos, p: v.pval, beta: v.beta, af: v.af_alt,
lead: v.peak && v.pval < THRESHOLD, // the top variant of each peak
desc: id + ", nearest gene " + gene + ", chromosome " + name(v.chrom) + " position " + v.pos + ", p = " + v.pval.toExponential(1)
};
});
// ...and sends everything else already summarised in 3 Mb bins, as single values and ranges of -log10(p)
const binDots = d.variant_bins.flatMap(b =>
b.neglog10_pvals.map(m => ({ x: along(b.chrom, b.pos), mlogp: m, odd: +b.chrom % 2 })));
const binRanges = d.variant_bins.flatMap(b =>
b.neglog10_pval_extents.map(([lo, hi]) => ({ x: along(b.chrom, b.pos), lo, hi, odd: +b.chrom % 2 })));
const chroms = Object.keys(LENGTH).map(c => ({ chrom: name(c), mid: (start[c] + LENGTH[c] / 2) / 1e6, mb: LENGTH[c] / 1e6 }));
const top = listed.reduce((a, b) => (b.p < a.p ? b : a));
const x = { field: "x", type: "quantitative", title: "Chromosome",
scale: { domain: [0, genome / 1e6], nice: false, zero: false },
axis: { labels: false, ticks: false, grid: false, titlePadding: 20 } };
const colour = { field: "odd", type: "nominal", legend: null,
scale: { domain: [1, 0], range: ["#2a78d6", "#eb6834"] } }; // odd blue, even orange
const line = -Math.log10(THRESHOLD); // 7.3
const spec = {
$schema: "https://vega.github.io/schema/vega-lite/v6.json",
// Built from the data, so that it stays true when you change the endpoint
description: "Manhattan plot of FinnGen genome-wide association results. " +
"The strongest signal is on chromosome " + top.chrom + " at " + top.id + " near " + top.gene + ", p = " + top.p.toExponential(1) +
". " + listed.filter(v => v.lead).length + " lead variants pass the genome-wide significance line at p = 5e-8.",
width: "container", height: 300,
layer: [
{ data: { values: binRanges }, // aria: false keeps thousands of background marks away from screen readers
mark: { type: "rule", strokeWidth: 2, aria: false },
encoding: { x, color: colour, y: { field: "lo", type: "quantitative" }, y2: { field: "hi" } } },
{ data: { values: binDots },
mark: { type: "circle", size: 10, opacity: 0.9, aria: false },
encoding: { x, color: colour, y: { field: "mlogp", type: "quantitative" } } },
{ data: { values: listed },
// nearest: the tooltip follows the closest point, so the pointer need not land exactly on a dot
params: [ { name: "near", select: { type: "point", nearest: true, on: "pointerover", clear: "pointerout" } } ],
mark: { type: "circle", opacity: 0.9 },
encoding: {
x, color: colour,
y: { field: "mlogp", type: "quantitative", title: "−log₁₀(p)" },
size: { condition: { param: "near", empty: false, value: 90 }, value: 26 },
description: { field: "desc" }, // what a screen reader announces for each point
tooltip: [
{ field: "id", title: "Variant" }, { field: "gene", title: "Nearest gene" },
{ field: "chrom", title: "Chromosome" }, { field: "pos", title: "Position", format: "," },
{ field: "p", title: "p", format: ".2e" }, { field: "beta", title: "Effect (beta)", format: ".3f" },
{ field: "af", title: "Alt. allele frequency", format: ".3f" } ] } },
{ data: { values: [ { y: line } ] },
mark: { type: "rule", strokeDash: [6, 4], color: "#172033", aria: false },
encoding: { y: { field: "y", type: "quantitative" } } },
{ data: { values: [ { y: line } ] },
mark: { type: "text", align: "right", baseline: "bottom", dy: -3, x: "width", fontSize: 11, color: "#172033", aria: false },
encoding: { y: { field: "y", type: "quantitative" }, text: { value: "p = 5 × 10⁻⁸" } } },
{ data: { values: [ top ] }, // label only the highest point
mark: { type: "text", align: "left", dx: 7, fontSize: 11, color: "#172033", aria: false },
encoding: { x, y: { field: "mlogp", type: "quantitative" }, text: { value: top.id + " (" + top.gene + ")" } } },
{ data: { values: chroms },
// Hide the label of any chromosome narrower than 13 pixels, so that labels do not overlap on small screens
transform: [ { calculate: "datum.mb / " + genome / 1e6 + " * width >= 13 ? datum.chrom : ''", as: "label" } ],
mark: { type: "text", y: "height", dy: 9, baseline: "top", fontSize: 10, color: "#3b4659", aria: false },
encoding: { x: { field: "mid", type: "quantitative" }, text: { field: "label" } } }
],
config: { font: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" }
};
await vegaEmbed("#chart", spec, { actions: false });
// The table is the text version of the chart: one row for the top variant of each peak past the line
const tbody = document.getElementById("leads");
listed.filter(v => v.lead).sort((a, b) => a.p - b.p).forEach(v => {
const row = tbody.insertRow();
[v.id, v.gene, v.chrom + ":" + v.pos.toLocaleString("en-GB"), v.p.toExponential(1), v.beta.toFixed(3), v.af.toFixed(3)]
.forEach((text, i) => {
const cell = row.insertCell();
cell.textContent = text; // textContent, so data can never inject HTML
if (i >= 3) cell.className = "num";
});
});
document.getElementById("status").textContent =
listed.length.toLocaleString("en-GB") + " variants listed one by one, " +
binDots.length.toLocaleString("en-GB") + " binned values. " + tbody.rows.length + " peaks pass p = 5 × 10⁻⁸.";
}).catch(err => {
document.getElementById("status").textContent =
"FinnGen's data did not arrive (" + (err.name === "AbortError" ? "no answer within 20 seconds" : err.message) + "). Try again later.";
});
</script>
</body>
</html>How the code works:
- The data come from FinnGen’s results browser, PheWeb [3], as one file from
r13.finngen.fi(145 kB to download, 1 MB unpacked) at/api/manhattan/pheno/AUD_SWEDISH. It lists the 2,080 strongest variants one by one and sends the rest already summarised in 3 Mb bins, which is all a picture of the whole genome needs. Other endpoints work the same way: change the code at the end of the address (T2Dgives type 2 diabetes, 663 kB with 10,122 variants listed). - One axis for 23 chromosomes. Each variant goes at its chromosome’s start plus its own position, using the GRCh38 chromosome lengths. The labels sit at each chromosome’s middle, and any chromosome narrower than 13 pixels loses its label, so that they do not overlap on a phone.
- Layers draw the binned ranges, the binned values, the listed variants, the line at p = 5 × 10⁻⁸ (7.3 on the −log₁₀ scale) and the labels. Each layer can have its own
data. - Tooltips that are easy to hit. A
paramsentry withnearest: truegives the variants an invisible Voronoi layer, so the tooltip opens for whichever point is nearest and the pointer need not land on a dot a few pixels wide. - Screen readers get the chart’s
descriptionand adescriptionfor each listed variant. The binned layers havearia: false; without it, Vega would label almost 8,000 marks. - The table is the text version of the chart: the top variant of each of the 19 peaks past the line, with FinnGen’s effect size (beta) and the frequency of the allele.
FinnGen’s server answers requests from any website, and from pages opened from your own disk, so the page needs no copy of FinnGen’s data.
Show what chance alone produces
A scan of a genome with no real associations still produces peaks. This example simulates one: a million random p-values, which is what a million independent tests of variants with no effect would give. Students can run it again and again, and count how often anything crosses 5 × 10⁻⁸. The educators’ page turns it into a class activity.
Simulated data: no genome or person is involved.
Code null-scan.html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vega-Lite: a simulated genome-wide scan with no real associations</title>
<script src="https://cdn.jsdelivr.net/npm/vega@6.4.0/build/vega.min.js"
integrity="sha384-VKdcJr3ZaBIJMbVcopTAI/JEuUkSY6qnwVu9iLuw0DnQ9gQ1JjsfZJhXFQAgNi43"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-lite@6.4.3/build/vega-lite.min.js"
integrity="sha384-9/70gNCfOu6G7xXvkdreMfuqAEsoaGJVXV2BN/JLRXkSmcGvnMqtsRx8HZtUWAvI"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-embed@7.3.0/build/vega-embed.min.js"
integrity="sha384-Muy1QRxYFeNrA1zShc1KtN4OfipkR/61gt+HWqN57c1Zxu3Oe16TsilsruVVjfKL"
crossorigin="anonymous"></script>
<style>
body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 12px 16px 16px; color: #172033; background: #fff; }
h1 { font-size: 1.05rem; margin: 0 0 4px; }
p { margin: 0 0 6px; }
.controls { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0; }
button { font: inherit; font-size: 14px; padding: 4px 10px; }
#status { font-weight: 600; min-height: 1.5em; }
#chart { width: 100%; }
.scroll { overflow-x: auto; }
table { border-collapse: collapse; font-size: 14px; margin: 4px 0 6px; }
th, td { padding: 3px 14px 3px 0; text-align: left; border-bottom: 1px solid #dde2ea; white-space: nowrap; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<h1>A genome-wide scan with no real associations (simulated)</h1>
<p>Each scan tests 1,000,000 made-up variants that have no effect at all, so every p-value comes from chance.
The chart shows the tests with p < 0.01; the other 99% sit below its floor.</p>
<div class="controls">
<button type="button" id="one">Run another scan</button>
<button type="button" id="hundred">Run 100 scans</button>
</div>
<p id="status" role="status" aria-live="polite"></p>
<div id="chart"></div>
<div class="scroll">
<table>
<thead><tr><th>Tests with</th><th class="num">Expected by chance</th><th class="num">In the last scan</th></tr></thead>
<tbody id="counts"></tbody>
</table>
</div>
<script>
const N = 1000000; // tests in one scan
const LINES = [ // thresholds to count, with the number expected by chance
{ label: "p < 0.05", p: 0.05 }, { label: "p < 0.01", p: 0.01 },
{ label: "p < 10⁻⁵", p: 1e-5 }, { label: "p < 5 × 10⁻⁸", p: 5e-8 } ];
// Lengths of chromosomes 1 to 22 in Mb (GRCh38). The tests are spread evenly along them.
const MB = [248.96, 242.19, 198.30, 190.21, 181.54, 170.81, 159.35, 145.14, 138.39, 133.80, 135.09,
133.28, 114.36, 107.04, 101.99, 90.34, 83.26, 80.37, 58.62, 64.44, 46.71, 50.82];
const ends = [];
MB.reduce((sum, mb, i) => (ends[i] = sum + mb), 0);
const total = ends[ends.length - 1];
const chromAt = x => ends.findIndex(e => x < e) + 1;
function runScan(keepPoints) {
const counts = LINES.map(() => 0);
const points = [];
for (let i = 0; i < N; i++) {
// With no real effect, a p-value is equally likely to fall anywhere between 0 and 1.
// (1 - Math.random() can never be exactly 0, so -log10(p) stays finite.)
const p = 1 - Math.random();
if (p >= 0.05) continue;
LINES.forEach((t, j) => { if (p < t.p) counts[j]++; });
if (keepPoints && p < 0.01) {
const x = (i / N) * total;
points.push({ x, mlogp: -Math.log10(p), odd: chromAt(x) % 2 });
}
}
return { counts, points };
}
// Keep a tally across scans, and show the counts for the latest one
const status = document.getElementById("status");
const buttons = document.querySelectorAll("button");
let scans = 0, scansPastLine = 0;
function record(result) {
scans++;
if (result.counts[3] > 0) scansPastLine++;
const tbody = document.getElementById("counts");
tbody.textContent = "";
LINES.forEach((t, j) => {
const row = tbody.insertRow();
[t.label, (N * t.p).toLocaleString("en-GB"), result.counts[j].toLocaleString("en-GB")].forEach((text, k) => {
const cell = row.insertCell();
cell.textContent = text;
if (k) cell.className = "num";
});
});
status.textContent = "Scans so far: " + scans.toLocaleString("en-GB") + ". Scans with at least one test past 5 × 10⁻⁸: " +
scansPastLine.toLocaleString("en-GB") + " (" + Math.round((100 * scansPastLine) / scans) + "%).";
}
const first = runScan(true);
record(first);
const chroms = MB.map((mb, i) => ({ chrom: String(i + 1), mid: ends[i] - mb / 2, mb }));
const x = { field: "x", type: "quantitative", title: "Chromosome",
scale: { domain: [0, total], nice: false, zero: false },
axis: { labels: false, ticks: false, grid: false, titlePadding: 20 } };
const y = { field: "mlogp", type: "quantitative", title: "−log₁₀(p)",
scale: { domain: { unionWith: [2, 8] }, zero: false } }; // always show 2 to 8, more if needed
const spec = {
$schema: "https://vega.github.io/schema/vega-lite/v6.json",
description: "Manhattan plot of a simulated scan of 1,000,000 tests with no real associations. " +
"Only tests with p below 0.01 are drawn. Lines mark p = 1e-5 and p = 5e-8.",
width: "container", height: 240,
datasets: { points: first.points }, // a named dataset, so the page can replace it later
layer: [
{ data: { name: "points" },
mark: { type: "circle", size: 9, opacity: 0.85, aria: false },
encoding: { x, y, color: { field: "odd", type: "nominal", legend: null,
scale: { domain: [1, 0], range: ["#2a78d6", "#eb6834"] } } } },
{ data: { values: [ { y: 5, label: "p = 10⁻⁵" }, { y: -Math.log10(5e-8), label: "p = 5 × 10⁻⁸" } ] },
layer: [
{ mark: { type: "rule", strokeDash: [6, 4], color: "#172033", aria: false },
encoding: { y: { field: "y", type: "quantitative" } } },
{ mark: { type: "text", align: "right", baseline: "bottom", dy: -3, x: "width", fontSize: 11, color: "#172033", aria: false },
encoding: { y: { field: "y", type: "quantitative" }, text: { field: "label" } } } ] },
{ data: { values: chroms },
transform: [ { calculate: "datum.mb / " + total + " * width >= 13 ? datum.chrom : ''", as: "label" } ],
mark: { type: "text", y: "height", dy: 9, baseline: "top", fontSize: 10, color: "#3b4659", aria: false },
encoding: { x: { field: "mid", type: "quantitative" }, text: { field: "label" } } }
],
config: { font: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" }
};
vegaEmbed("#chart", spec, { actions: false }).then(({ view }) => {
// view.data() swaps in new points without rebuilding the chart
const draw = result => view.data("points", result.points).runAsync();
document.getElementById("one").addEventListener("click", () => {
const result = runScan(true);
record(result);
draw(result);
});
document.getElementById("hundred").addEventListener("click", async () => {
buttons.forEach(b => (b.disabled = true));
status.textContent = "Running 100 scans…"; // one message now, and one with the result at the end
let last;
for (let k = 1; k <= 100; k++) {
last = runScan(k === 100); // keep the points of the last scan only
if (k < 100) { scans++; if (last.counts[3] > 0) scansPastLine++; }
if (k % 10 === 0) await new Promise(r => setTimeout(r, 0)); // let the page breathe every ten scans
}
record(last); // counts the 100th scan and fills the table
await draw(last);
buttons.forEach(b => (b.disabled = false));
});
});
</script>
</body>
</html>What the code does:
- With no real effect, a p-value is equally likely to fall anywhere between 0 and 1, so
1 - Math.random()stands in for one test. In our tests, one scan of a million, drawn on the chart, took about 0.4 seconds, and 100 scans, of which only the last is drawn, took about 2 seconds. - Only tests with p < 0.01 are drawn, about 10,000 of them, in the same spirit as FinnGen’s bins.
- A named dataset. The points sit in
datasets: { points: … }, so each new scan replaces them withview.data("points", …).runAsync()instead of rebuilding the chart. - The table sets each count beside what chance predicts: a million times the threshold.
Chance predicts 50,000 tests below 0.05 and 10 below 10⁻⁵ in every scan. With the line at 5 × 10⁻⁸, the probability that at least one test in a million crosses it is 1 − (1 − 5 × 10⁻⁸)1,000,000, about 5%, and in our runs about 5 scans in 100 did. That is what the threshold is for. It is 0.05 divided by one million, the number of independent tests that Pe’er and colleagues estimated for common variants in European genomes [4].
Let students move the cut-offs on a volcano plot
A volcano plot shows a whole table of differential-expression results at once: fold change across, −log₁₀ of the p-value upwards. The genes people talk about are the ones past two cut-offs, and where the cut-offs go is a choice. Here students move them and watch the list of genes change.
limma-voom results [5, 6] for luminal cells from pregnant and lactating mice [7], from the Galaxy Training Network’s training data [8, 9], CC BY 4.0.
Code volcano-thresholds.html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vega-Lite: a volcano plot with cut-offs you can move</title>
<script src="https://cdn.jsdelivr.net/npm/vega@6.4.0/build/vega.min.js"
integrity="sha384-VKdcJr3ZaBIJMbVcopTAI/JEuUkSY6qnwVu9iLuw0DnQ9gQ1JjsfZJhXFQAgNi43"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-lite@6.4.3/build/vega-lite.min.js"
integrity="sha384-9/70gNCfOu6G7xXvkdreMfuqAEsoaGJVXV2BN/JLRXkSmcGvnMqtsRx8HZtUWAvI"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-embed@7.3.0/build/vega-embed.min.js"
integrity="sha384-Muy1QRxYFeNrA1zShc1KtN4OfipkR/61gt+HWqN57c1Zxu3Oe16TsilsruVVjfKL"
crossorigin="anonymous"></script>
<style>
body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 12px 16px 16px; color: #172033; background: #fff; }
h1 { font-size: 1.05rem; margin: 0 0 6px; }
p { margin: 0 0 6px; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; margin: 0 0 6px; font-size: 14px; }
.controls div { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; }
label { white-space: nowrap; }
select, input { font: inherit; max-width: 100%; }
output { font-variant-numeric: tabular-nums; min-width: 9.5em; }
#status { font-weight: 600; }
#chart { width: 100%; }
summary { cursor: pointer; font-weight: 600; }
.scroll { overflow-x: auto; }
table { border-collapse: collapse; font-size: 13px; margin: 6px 0; }
th, td { padding: 3px 12px 3px 0; text-align: left; border-bottom: 1px solid #dde2ea; white-space: nowrap; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.credit { font-size: 13px; color: #3b4659; margin-top: 8px; }
</style>
</head>
<body>
<h1>Differential expression in mouse luminal cells: pregnant against lactating</h1>
<div class="controls">
<div>
<label for="lfc">Fold-change cut-off</label>
<input type="range" id="lfc" min="0" max="3" step="0.01" value="0.58">
<output id="lfc-out" for="lfc"></output>
</div>
<div>
<label for="pcut">p-value cut-off</label>
<select id="pcut"><option>0.05</option><option selected>0.01</option><option>0.001</option></select>
</div>
<div>
<label for="ptype">Use</label>
<select id="ptype">
<option value="adj" selected>adjusted p-values (false discovery rate)</option>
<option value="raw">raw p-values, not adjusted</option>
</select>
</div>
</div>
<p id="status" role="status" aria-live="polite">Loading the results table (1.1 MB)…</p>
<div id="chart"></div>
<details>
<summary>The 10 genes with the smallest adjusted p-values, as a table</summary>
<div class="scroll">
<table>
<thead><tr><th>Gene</th><th>Name</th><th class="num">log₂ fold change</th><th class="num">Adjusted p</th></tr></thead>
<tbody id="top"></tbody>
</table>
</div>
</details>
<p class="credit">Data: limma-voom results from the Galaxy Training Network's “RNA-seq genes to pathways” training data
(Doyle M, Zenodo, <a href="https://doi.org/10.5281/zenodo.2596382" target="_blank" rel="noopener">doi:10.5281/zenodo.2596382</a>, CC BY 4.0),
from Fu NY et al. (2015) <i>Nature Cell Biology</i> 17: 365–375. Five of the nine columns, stored next to this page.</p>
<script>
// A copy of the columns this page needs, stored next to it (see data/README.txt). To read the full
// table straight from Zenodo, use this address instead; "?download=1" links do not allow use from other sites:
// https://zenodo.org/api/records/2596382/files/limma-voom_luminalpregnant-luminallactate/content
const URL = "data/gtn-luminal-pregnant-lactating.tsv";
const lfc = document.getElementById("lfc"), pcut = document.getElementById("pcut"), ptype = document.getElementById("ptype");
const status = document.getElementById("status");
function describeCut() {
const v = +lfc.value;
document.getElementById("lfc-out").textContent = "|log₂FC| > " + v.toFixed(2) + " (" + (2 ** v).toFixed(1) + "-fold)";
}
describeCut();
fetch(URL).then(r => {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.text();
}).then(async text => {
// vega.read parses the tab-separated table; parse: "auto" turns numbers into numbers
const genes = vega.read(text, { type: "tsv", parse: "auto" }).map(r => ({
// 163 genes have no symbol or name in the annotation, and the file keeps no other identifier for them
gene: r.SYMBOL !== "NA" ? r.SYMBOL : "(no symbol)",
name: r.GENENAME !== "NA" ? r.GENENAME : "not annotated", lfc: r.logFC, p: r["P.Value"], fdr: r["adj.P.Val"] // names with dots need brackets
}));
const spec = {
$schema: "https://vega.github.io/schema/vega-lite/v6.json",
description: "Volcano plot of " + genes.length.toLocaleString("en-GB") + " mouse genes. Each point is a gene: across, the log2 fold change " +
"between luminal cells of pregnant and lactating mice; up, minus log10 of the p-value. Genes past both cut-offs are coloured: " +
"red when higher in pregnancy, blue when higher in lactation.",
width: "container", height: 330,
// Parameters: the page changes these with view.signal() when the controls change
params: [ { name: "lfcCut", value: +lfc.value }, { name: "pCut", value: +pcut.value }, { name: "useAdj", value: true } ],
data: { values: genes },
transform: [
{ calculate: "useAdj ? datum.fdr : datum.p", as: "pUsed" },
{ calculate: "-log(datum.pUsed) / LN10", as: "y" }, // Vega has no log10, so divide by ln(10)
{ calculate: "datum.pUsed < pCut && datum.lfc > lfcCut ? 'Higher in pregnancy' : " +
"datum.pUsed < pCut && datum.lfc < -lfcCut ? 'Higher in lactation' : 'Not past both cut-offs'", as: "group" }
],
layer: [
{ mark: { type: "circle", size: 14, opacity: 0.75, aria: false },
encoding: {
x: { field: "lfc", type: "quantitative", title: "log₂ fold change (pregnant − lactating)", axis: { tickMinStep: 1 } },
y: { field: "y", type: "quantitative", title: "−log₁₀(p)" },
color: { field: "group", type: "nominal", title: null,
legend: { orient: "top", columns: { expr: "width < 420 ? 2 : 3" } }, // wrap on phones
scale: { domain: ["Higher in lactation", "Not past both cut-offs", "Higher in pregnancy"],
range: ["#2a78d6", "#b9b8b0", "#e34948"] } },
tooltip: [ { field: "gene", title: "Gene" }, { field: "name", title: "Name" },
{ field: "lfc", title: "log₂ fold change", format: ".2f" },
{ field: "p", title: "Raw p", format: ".2e" }, { field: "fdr", title: "Adjusted p", format: ".2e" } ] } },
{ // label the three genes furthest out on each side (fold change times -log10 p), placed towards the middle
transform: [ { filter: "datum.group !== 'Not past both cut-offs'" },
{ calculate: "abs(datum.lfc) * datum.y", as: "score" },
{ window: [ { op: "row_number", as: "rank" } ], groupby: ["group"], sort: [ { field: "score", order: "descending" } ] },
{ filter: "datum.rank <= 3" } ],
mark: { type: "text", dy: -6, fontSize: 11, fontStyle: "italic", color: "#172033",
align: { expr: "datum.lfc > 0 ? 'right' : 'left'" }, dx: { expr: "datum.lfc > 0 ? -3 : 3" } },
encoding: { x: { field: "lfc", type: "quantitative" }, y: { field: "y", type: "quantitative" },
text: { field: "gene" }, description: { field: "gene" } } },
{ // the two fold-change cut-offs, recalculated whenever lfcCut changes
data: { values: [ { side: -1 }, { side: 1 } ] },
transform: [ { calculate: "datum.side * lfcCut", as: "x" } ],
mark: { type: "rule", strokeDash: [4, 4], color: "#3b4659", aria: false },
encoding: { x: { field: "x", type: "quantitative" } } },
{ // the p-value cut-off, with a label saying which kind of p-value it is
data: { values: [ { } ] },
transform: [ { calculate: "-log(pCut) / LN10", as: "y" },
{ calculate: "(useAdj ? 'adjusted p = ' : 'raw p = ') + pCut", as: "label" } ],
layer: [
{ mark: { type: "rule", strokeDash: [4, 4], color: "#3b4659", aria: false },
encoding: { y: { field: "y", type: "quantitative" } } },
{ mark: { type: "text", align: "right", baseline: "bottom", x: "width", dy: -3, fontSize: 11, color: "#172033", aria: false },
encoding: { y: { field: "y", type: "quantitative" }, text: { field: "label" } } } ] }
],
config: { font: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" }
};
const { view } = await vegaEmbed("#chart", spec, { actions: false });
// Count the genes past both cut-offs, for the status line (and for screen readers)
function count() {
const cut = +lfc.value, alpha = +pcut.value, adj = ptype.value === "adj";
let up = 0, down = 0;
for (const g of genes) {
if ((adj ? g.fdr : g.p) >= alpha) continue;
if (g.lfc > cut) up++;
else if (g.lfc < -cut) down++;
}
status.textContent = (up + down).toLocaleString("en-GB") + " of " + genes.length.toLocaleString("en-GB") +
" genes pass both cut-offs: " + up.toLocaleString("en-GB") + " higher in pregnancy and " +
down.toLocaleString("en-GB") + " higher in lactation.";
}
function update() {
count();
view.signal("lfcCut", +lfc.value).signal("pCut", +pcut.value).signal("useAdj", ptype.value === "adj").runAsync();
}
lfc.addEventListener("input", describeCut); // move the readout while dragging…
lfc.addEventListener("change", update); // …and redraw 15,804 points when the slider is let go
pcut.addEventListener("change", update);
ptype.addEventListener("change", update);
count();
// The text version: the ten genes with the smallest adjusted p-values
const tbody = document.getElementById("top");
genes.slice().sort((a, b) => a.fdr - b.fdr).slice(0, 10).forEach(g => {
const row = tbody.insertRow();
[g.gene, g.name, g.lfc.toFixed(2), g.fdr.toExponential(1)].forEach((text, i) => {
const cell = row.insertCell();
cell.textContent = text;
if (i >= 2) cell.className = "num";
});
});
}).catch(err => {
status.textContent = window.vegaEmbed
? "The results table did not load (" + err.message + ")."
: "The chart library (Vega) did not load. Check the connection and reload the page.";
});
</script>
</body>
</html>Points to note in the code:
- The cut-offs are parameters.
paramsdeclareslfcCut,pCutanduseAdj, and the calculations intransformuse them to give each gene its height and its group. The page changes them withview.signal("lfcCut", 1).runAsync(), and Vega recalculates only what depends on them: 0.2 to 0.4 seconds for 15,804 genes in our tests. - The controls are ordinary HTML, a slider and two menus with labels, so they work from a keyboard. The chart redraws when the slider is let go (its
changeevent), while the text beside it follows every move (input). Vega-Lite can also make controls for you withbind, but you then have less say over how they look and behave. - The dashed lines move because their positions are calculated from the same parameters, in small layers of their own.
- The status line counts the genes past both cut-offs, and screen readers announce it after each change. At the start (adjusted p < 0.01 and a 1.5-fold change) it is 4,429 genes: 2,101 higher in pregnancy and 2,328 higher in lactation.
- Labels. Vega-Lite does not move labels apart when they collide. The page labels only the three genes on each side that sit furthest out (fold change times −log₁₀ p), placed towards the middle so that they cannot run off the edge.
- The data are 5 of the 9 columns of the published table, rounded to 6 significant figures and stored next to the page: 1.1 MB instead of 2.5 MB. No gene changes group at any setting the page offers, and
data/README.txtsays how the file was made.
The columns adj.P.Val and P.Value have dots in their names, which Vega reads as paths into nested objects. The page renames them as it reads the table. Inside a Vega expression you can also write datum['adj.P.Val'].
Choose the scaling, order and colours of a heatmap
A heatmap shows a table of numbers as colours, and what it shows depends on three choices: how the values are scaled, how the rows are ordered and which colours are used. This example lets students change all three, so it can carry a “makeover” activity.
Normalised expression from the Galaxy Training Network’s heatmap training data [10], CC BY 4.0. The address can set the starting choices, as in this poor version.
Code heatmap-makeover.html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vega-Lite: an expression heatmap, with choices of scaling, order and colours</title>
<script src="https://cdn.jsdelivr.net/npm/vega@6.4.0/build/vega.min.js"
integrity="sha384-VKdcJr3ZaBIJMbVcopTAI/JEuUkSY6qnwVu9iLuw0DnQ9gQ1JjsfZJhXFQAgNi43"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-lite@6.4.3/build/vega-lite.min.js"
integrity="sha384-9/70gNCfOu6G7xXvkdreMfuqAEsoaGJVXV2BN/JLRXkSmcGvnMqtsRx8HZtUWAvI"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-embed@7.3.0/build/vega-embed.min.js"
integrity="sha384-Muy1QRxYFeNrA1zShc1KtN4OfipkR/61gt+HWqN57c1Zxu3Oe16TsilsruVVjfKL"
crossorigin="anonymous"></script>
<style>
body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 12px 16px 16px; color: #172033; background: #fff; }
h1 { font-size: 1.05rem; margin: 0 0 6px; }
p { margin: 0 0 6px; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; margin: 0 0 6px; font-size: 14px; }
.controls div { display: flex; align-items: center; gap: 6px; }
select { font: inherit; max-width: 100%; }
#status { font-size: 14px; color: #3b4659; }
#chart { width: 100%; }
summary { cursor: pointer; font-weight: 600; }
.scroll { overflow-x: auto; }
table { border-collapse: collapse; font-size: 13px; margin: 6px 0; }
th, td { padding: 2px 12px 2px 0; text-align: left; border-bottom: 1px solid #dde2ea; white-space: nowrap; }
.credit { font-size: 13px; color: #3b4659; margin-top: 8px; }
</style>
</head>
<body>
<h1>Growth-factor genes in mouse mammary cells</h1>
<div class="controls">
<div><label for="values">Values</label>
<select id="values">
<option value="z">z-scores within each gene</option>
<option value="raw">log₂ counts per million, not scaled</option>
</select></div>
<div><label for="order">Rows</label>
<select id="order">
<option value="cluster">clustered</option>
<option value="alpha">alphabetical</option>
</select></div>
<div><label for="colours">Colours</label>
<select id="colours">
<option value="bluered">blue-white-red</option>
<option value="viridis">viridis</option>
<option value="redgreen">red-green</option>
<option value="rainbow">rainbow</option>
</select></div>
</div>
<p id="status" role="status" aria-live="polite">Loading…</p>
<div id="chart"></div>
<details>
<summary>Where each gene is highest and lowest, as a table</summary>
<div class="scroll">
<table>
<thead><tr><th>Gene</th><th>Highest in</th><th>Lowest in</th></tr></thead>
<tbody id="summary"></tbody>
</table>
</div>
</details>
<p class="credit">Data: normalised log₂ counts per million from the Galaxy Training Network's heatmap training data
(Doyle M, Zenodo, <a href="https://doi.org/10.5281/zenodo.2529926" target="_blank" rel="noopener">doi:10.5281/zenodo.2529926</a>, CC BY 4.0),
from Fu NY et al. (2015) <i>Nature Cell Biology</i> 17: 365–375. The 31 rows the chart needs are stored next to this page.</p>
<script>
// The 31 genes of the full table (4 MB on Zenodo), stored next to the page: see data/README.txt
const URL = "data/gtn-growth-factor-genes.tsv";
// Clustered order, top to bottom. Vega-Lite cannot cluster, so this was worked out beforehand:
// z-scores, average-linkage clustering on Euclidean distance, optimal leaf ordering (SciPy).
const CLUSTERED = ["Wnt5a", "Ptn", "Cx3cl1", "Bmp1", "Kitl", "Pdgfa", "Cmtm3", "Ctgf", "Csf1", "Pdgfb", "Hbegf",
"Lif", "Edn1", "Mcl1", "Cxcl16", "Tgfb2", "Spp1", "Gmfg", "Cmtm8", "Bmp3", "Cxcl2", "Cxcl3", "Cxcl1",
"Cmtm7", "Nov", "Ccl28", "Egf", "Il15", "Cmtm6", "Pdgfd", "Il34"];
// Each sample and its group, from the training data's factordata file
const SAMPLES = [
["MCL1.DG", "basal virgin"], ["MCL1.DH", "basal virgin"], ["MCL1.DI", "basal pregnant"], ["MCL1.DJ", "basal pregnant"],
["MCL1.DK", "basal lactating"], ["MCL1.DL", "basal lactating"], ["MCL1.LA", "luminal virgin"], ["MCL1.LB", "luminal virgin"],
["MCL1.LC", "luminal pregnant"], ["MCL1.LD", "luminal pregnant"], ["MCL1.LE", "luminal lactating"], ["MCL1.LF", "luminal lactating"]];
const column = (sample, group) => group + " · " + sample.slice(5);
// The colour scales on offer. Two of them are here to be criticised.
const COLOURS = {
bluered: { name: "blue-white-red", scale: { scheme: "redblue", reverse: true } }, // ColorBrewer RdBu, blue low
viridis: { name: "viridis", scale: { scheme: "viridis" } },
redgreen: { name: "red-green", scale: { range: ["#00b300", "#000000", "#e60000"] } }, // as in old microarray figures
rainbow: { name: "rainbow", scale: { scheme: "turbo" } }
};
const controls = { values: document.getElementById("values"), order: document.getElementById("order"),
colours: document.getElementById("colours") };
// Start from the address if it says so, e.g. heatmap-makeover.html?values=raw&order=alpha&colours=rainbow
const asked = new URLSearchParams(location.search);
for (const [key, select] of Object.entries(controls)) {
if ([...select.options].some(o => o.value === asked.get(key))) select.value = asked.get(key);
}
function makeSpec(rows, width) {
const z = controls.values.value === "z";
const colours = COLOURS[controls.colours.value];
const order = controls.order.value === "cluster" ? CLUSTERED : CLUSTERED.slice().sort();
const all = rows.flatMap(r => SAMPLES.map(([s]) => r[s]));
const [lo, hi] = [Math.min(...all), Math.max(...all)];
const scale = Object.assign({}, colours.scale,
z ? { domain: [-2.5, 2.5], domainMid: 0, clamp: true } // beyond ±2.5 gets the end colour
: { domain: [lo, hi], domainMid: (lo + hi) / 2 }); // the middle of the range, so three-colour scales work
return {
$schema: "https://vega.github.io/schema/vega-lite/v6.json",
description: "Heatmap of 31 growth-factor genes (rows) in 12 mouse mammary samples (columns). Colour shows " +
(z ? "each gene's z-score: how far each sample is above or below that gene's own average." : "log2 counts per million, not scaled."),
data: { values: rows },
transform: [
// One row per gene and sample. Names with dots ("MCL1.DG") must be escaped, or Vega reads them as nested fields.
{ fold: SAMPLES.map(([s]) => s.replace(".", "\\.")), as: ["sample", "log2cpm"] },
{ lookup: "sample", from: { data: { values: SAMPLES.map(([s, g]) => ({ s, g })) }, key: "s", fields: ["g"] }, as: ["group"] },
{ calculate: "datum.group + ' · ' + slice(datum.sample, 5)", as: "column" },
// z-score: (value - the gene's mean) / the gene's standard deviation
{ joinaggregate: [ { op: "mean", field: "log2cpm", as: "mean" }, { op: "stdev", field: "log2cpm", as: "sd" } ], groupby: ["SYMBOL"] },
{ calculate: "(datum.log2cpm - datum.mean) / datum.sd", as: "z" },
{ calculate: "datum.SYMBOL + ', ' + datum.column + ': ' + (" + (z ? "'z = ' + format(datum.z, '.1f')" : "format(datum.log2cpm, '.1f') + ' log2 CPM'") + ")", as: "desc" }
],
mark: { type: "rect", stroke: "#fff", strokeWidth: 1 },
encoding: {
x: { field: "column", type: "nominal", title: null, sort: SAMPLES.map(([s, g]) => column(s, g)),
axis: { orient: "top", labelAngle: -40, labelLimit: 140 } },
y: { field: "SYMBOL", type: "nominal", title: null, sort: order, axis: { labelFontStyle: "italic" } },
color: { field: z ? "z" : "log2cpm", type: "quantitative", title: z ? "z-score" : "log₂ CPM", scale },
tooltip: [ { field: "SYMBOL", title: "Gene" }, { field: "group", title: "Group" }, { field: "sample", title: "Sample" },
{ field: "log2cpm", title: "log₂ CPM", format: ".2f" }, { field: "z", title: "z-score", format: ".2f" } ],
description: { field: "desc" } // what a screen reader announces for each cell
},
width: width, height: { step: 15 },
config: { font: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" }
};
}
// The text version: the group where each gene's average is highest and lowest
function fillTable(rows, order) {
const groups = [...new Set(SAMPLES.map(([, g]) => g))];
const tbody = document.getElementById("summary");
tbody.textContent = "";
order.forEach(gene => {
const r = rows.find(row => row.SYMBOL === gene);
const means = groups.map(g => {
const v = SAMPLES.filter(([, sg]) => sg === g).map(([s]) => r[s]);
return v.reduce((a, b) => a + b, 0) / v.length;
});
const row = tbody.insertRow();
[gene, groups[means.indexOf(Math.max(...means))], groups[means.indexOf(Math.min(...means))]]
.forEach(text => { row.insertCell().textContent = text; });
});
}
fetch(URL).then(r => {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.text();
}).then(text => {
const rows = vega.read(text, { type: "tsv", parse: "auto" });
async function draw() {
// 34 pixels a column when there is room; narrower on a phone
const width = Math.max(200, Math.min(12 * 34, document.getElementById("chart").clientWidth - 160));
await vegaEmbed("#chart", makeSpec(rows, width), { actions: false });
const order = controls.order.value === "cluster" ? CLUSTERED : CLUSTERED.slice().sort();
fillTable(rows, order);
document.getElementById("status").textContent = "Showing " + controls.values.selectedOptions[0].text +
"; rows " + controls.order.selectedOptions[0].text + "; " + COLOURS[controls.colours.value].name + " colours.";
}
Object.values(controls).forEach(select => select.addEventListener("change", draw));
draw();
}).catch(err => {
document.getElementById("status").textContent = "The data did not load (" + err.message + ").";
});
</script>
</body>
</html>How the example is put together:
- From a wide table to long data.
foldturns the 12 sample columns into rows of gene, sample and value. Sample names with dots, such asMCL1.DG, must be escaped ("MCL1\\.DG"in JavaScript), for the reason given above. - z-scores in the specification.
joinaggregateadds each gene’s mean and standard deviation to its rows, andcalculateturns each value into a z-score: how far that sample sits above or below the gene’s own average. - Clustering happens beforehand. Vega-Lite cannot cluster rows or draw a dendrogram. The clustered order was worked out in Python with SciPy, by average linkage on the z-scores, and pasted into the page as a list.
- The colour scale
redblue, reversed and withdomainMid: 0, puts white at each gene’s average, blue below and red above, andclampgives anything beyond ±2.5 the end colours. The red-green and rainbow choices are there to be criticised. - Each change rebuilds the chart. The page writes a new specification and calls
vegaEmbedagain, which is quick for 372 cells. - The text version names the group in which each gene is highest and lowest: 31 short rows instead of 372 numbers.
The file next to the page holds the 31 rows the chart needs, unchanged: 8 kB, where the full table on Zenodo is 4 MB.
Plot sequencing depth across exons
A coverage plot shows how many sequencing reads cover each base. This one sets gnomAD’s genomes beside its exomes across exons 4 to 9 of CFTR [11, 12]. It shows students that exome sequencing reads little beyond the exons, and that its depth changes from exon to exon.
Code coverage-cftr.html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vega-Lite: sequencing depth across CFTR exons in gnomAD genomes and exomes</title>
<script src="https://cdn.jsdelivr.net/npm/vega@6.4.0/build/vega.min.js"
integrity="sha384-VKdcJr3ZaBIJMbVcopTAI/JEuUkSY6qnwVu9iLuw0DnQ9gQ1JjsfZJhXFQAgNi43"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-lite@6.4.3/build/vega-lite.min.js"
integrity="sha384-9/70gNCfOu6G7xXvkdreMfuqAEsoaGJVXV2BN/JLRXkSmcGvnMqtsRx8HZtUWAvI"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-embed@7.3.0/build/vega-embed.min.js"
integrity="sha384-Muy1QRxYFeNrA1zShc1KtN4OfipkR/61gt+HWqN57c1Zxu3Oe16TsilsruVVjfKL"
crossorigin="anonymous"></script>
<style>
body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 12px 16px 16px; color: #172033; background: #fff; }
h1 { font-size: 1.05rem; margin: 0 0 4px; }
p { margin: 0 0 6px; }
#status { font-size: 14px; color: #3b4659; }
#chart { width: 100%; }
summary { cursor: pointer; font-weight: 600; }
.scroll { overflow-x: auto; }
table { border-collapse: collapse; font-size: 13px; margin: 6px 0; }
th, td { padding: 3px 12px 3px 0; text-align: left; border-bottom: 1px solid #dde2ea; white-space: nowrap; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.credit { font-size: 13px; color: #3b4659; margin-top: 8px; }
</style>
</head>
<body>
<h1>Sequencing depth across <i>CFTR</i> exons 4 to 9: genomes against exomes</h1>
<p id="status" role="status" aria-live="polite">Asking the UCSC API for gnomAD's coverage: three requests, one a second…</p>
<div id="chart"></div>
<details>
<summary>Average depth across each exon, as a table</summary>
<div class="scroll">
<table>
<thead><tr><th>Exon</th><th>Position on chr7 (GRCh38)</th><th class="num">Genomes</th><th class="num">Exomes</th></tr></thead>
<tbody id="exons"></tbody>
</table>
</div>
</details>
<p class="credit">Data: gnomAD mean depth per base, genomes v3.0.1 and exomes v4.0 (Karczewski KJ et al. 2020, <i>Nature</i> 581: 434–443;
Chen S et al. 2024, <i>Nature</i> 625: 92–100), and the RefSeq Select transcript NM_000492.4, read from the UCSC Genome Browser's API.</p>
<script>
// 12 kb of chromosome 7 around CFTR exons 4 to 9. The UCSC API counts from 0, like BED files.
const CHROM = "chr7", START = 117530500, END = 117542500;
const API = "https://api.genome.ucsc.edu/getData/track?genome=hg38;chrom=" + CHROM + ";start=" + START + ";end=" + END + ";track=";
const GENOMES = "Genomes (gnomAD v3.0.1)", EXOMES = "Exomes (gnomAD v4.0)";
const status = document.getElementById("status");
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
async function getTrack(track) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 20000); // give up after 20 seconds rather than wait for ever
try {
const r = await fetch(API + track, { signal: ctrl.signal });
if (!r.ok) throw new Error("HTTP " + r.status);
return (await r.json())[track]; // the rows sit under the track's own name
} catch (err) {
throw err.name === "AbortError" ? new Error("no answer within 20 seconds") : err;
} finally {
clearTimeout(timer);
}
}
(async () => {
// UCSC asks for no more than one request a second, so fetch the three tracks in turn
const genome = await getTrack("gnomad3MeanCoverage");
await pause(1000);
const exome = await getTrack("gnomad4ExomeMeanCoverage");
await pause(1000);
const [tx] = await getTrack("ncbiRefSeqSelect");
// One row per base, counted from 1 as genome browsers show it. Exome depth exists only near exons;
// elsewhere it is null, which breaks the exome line instead of joining across the gap.
const exomeAt = new Map(exome.map(d => [d.start, d.value]));
const rows = genome.map(d => ({ pos: d.start + 1, genome: d.value, exome: exomeAt.get(d.start) ?? null }));
rows.forEach(r => { r.exomeText = r.exome === null ? "not captured" : r.exome.toFixed(1); });
// Exons from the transcript record: comma-separated starts (from 0) and ends
const starts = tx.exonStarts.split(",").filter(Boolean).map(Number);
const ends = tx.exonEnds.split(",").filter(Boolean).map(Number);
const exons = starts.map((s, i) => ({
exon: tx.strand === "+" ? i + 1 : starts.length - i, // number exons along the gene
from: s + 1, to: ends[i] }))
.filter(e => e.to > START && e.from <= END);
const x = { field: "pos", type: "quantitative", title: "Position on chromosome 7 (GRCh38)",
// every layer that uses x needs this same domain, or the exon bands pull the axis back to 0
scale: { domain: [START + 1, END], nice: false, zero: false }, axis: { format: ",d", tickCount: 5 } };
// both lines share one axis title; different titles would be joined together
const depth = field => ({ field, type: "quantitative", title: "Mean depth (reads per base)" });
const colour = { scale: { domain: [GENOMES, EXOMES], range: ["#2a78d6", "#eb6834"] }, title: null, legend: { orient: "top" } };
const spec = {
$schema: "https://vega.github.io/schema/vega-lite/v6.json",
description: "Line chart of the mean sequencing depth per base across 12,000 bases of CFTR, exons 4 to 9. " +
"In genomes the depth is flat at about 31 reads per base. In exomes there is depth only around the six exons, between about 20 and 36.",
width: "container", height: 260,
layer: [
{ data: { values: exons }, // exons as grey bands, labelled at the top of the plot
transform: [ { calculate: "(width < 560 ? '' : 'exon ') + datum.exon", as: "label" } ], // just the number on phones
layer: [
{ mark: { type: "rect", color: "#e8ecf3", aria: false },
encoding: { x: Object.assign({}, x, { field: "from" }), x2: { field: "to" } } },
{ mark: { type: "text", y: 0, dy: 3, dx: 2, align: "left", baseline: "top", fontSize: 11, color: "#3b4659", aria: false },
encoding: { x: Object.assign({}, x, { field: "from" }), text: { field: "label" } } } ] },
{ data: { values: rows },
layer: [
{ mark: { type: "line", strokeWidth: 1.5, aria: false },
encoding: { x, y: depth("genome"), color: Object.assign({ datum: GENOMES }, colour) } },
{ mark: { type: "line", strokeWidth: 1.5, aria: false },
encoding: { x, y: depth("exome"), color: Object.assign({ datum: EXOMES }, colour) } },
{ // an invisible line at every base: the one nearest the pointer appears, with both depths in its tooltip
params: [ { name: "hover", select: { type: "point", encodings: ["x"], nearest: true, on: "pointerover", clear: "pointerout" } } ],
mark: { type: "rule", color: "#172033", aria: false },
encoding: { x, opacity: { condition: { param: "hover", empty: false, value: 0.4 }, value: 0 },
tooltip: [ { field: "pos", title: "Position", format: ",d" }, { field: "genome", title: "Genomes", format: ".1f" },
{ field: "exomeText", title: "Exomes" } ] } } ] }
],
config: { font: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" }
};
await vegaEmbed("#chart", spec, { actions: false });
// The text version: average depth across each exon
const mean = values => values.reduce((a, b) => a + b, 0) / values.length;
const tbody = document.getElementById("exons");
exons.forEach(e => {
const inside = rows.filter(r => r.pos >= e.from && r.pos <= e.to);
const ex = inside.filter(r => r.exome !== null).map(r => r.exome);
const row = tbody.insertRow();
[e.exon, e.from.toLocaleString("en-GB") + "–" + e.to.toLocaleString("en-GB"),
mean(inside.map(r => r.genome)).toFixed(1), ex.length ? mean(ex).toFixed(1) : "not captured"]
.forEach((text, i) => {
const cell = row.insertCell();
cell.textContent = text;
if (i >= 2) cell.className = "num";
});
});
status.textContent = rows.length.toLocaleString("en-GB") + " bases from " + CHROM + ":" + (START + 1).toLocaleString("en-GB") +
" to " + END.toLocaleString("en-GB") + ". Grey bands mark " + exons.length + " exons of " + tx.name + ".";
})().catch(err => {
status.textContent = "The UCSC API did not answer (" + err.message + "). Try again in a minute.";
});
</script>
</body>
</html>A few points about the code:
- The UCSC Genome Browser’s API [13] returns any track in a region as JSON. The page asks
api.genome.ucsc.eduforgetData/trackwith the genome, chromosome, start, end and track name (APIin the code). It allows requests from any website. Its positions count from 0, as in BED files, so the page adds 1 to match what genome browsers show. - One request a second. UCSC recommends no more than one request a second to its API, so the page fetches its three tracks in turn, with a pause between them. The data take about 3 seconds to arrive.
- Gaps in a line. Exome depth exists only near the exons. Elsewhere the page sets it to
null, and Vega-Lite breaks the line there instead of joining across the gap. - Layers that share an axis need the same explicit
domain. Without it, the exon bands pull the axis back to 0, because rectangles start their scales at zero by default. - The tooltip comes from an invisible rule at every base with
nearest: true, so it always shows both depths at the base nearest the pointer. - The text version gives the average depth across each exon.
Zoom into a peak with Plotly.js
Vega-Lite charts do not zoom unless you build it in. Plotly.js zooms and pans with no extra code, which suits tasks such as “zoom into the peak on chromosome 4 and count the variants in it”. This is the FinnGen plot again, with buttons that go to four peaks.
Code plotly-manhattan-zoom.html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Plotly.js: zoom into the peaks of a Manhattan plot</title>
<!-- Plotly's "cartesian" bundle has the common chart types: 1.5 MB, about 500 kB to download.
The full plotly.min.js is 4.8 MB. -->
<script src="https://cdn.jsdelivr.net/npm/plotly.js-cartesian-dist-min@4.1.1/plotly-cartesian.min.js"
integrity="sha384-zc4SwKObGL/W0M3RUI09UPWjlgIZHfbwMgcZO6713mRh78tfUmIQKhzCyE2LIiUg"
crossorigin="anonymous"></script>
<style>
body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 12px 16px 16px; color: #172033; background: #fff; }
h1 { font-size: 1.05rem; margin: 0 0 6px; }
p { margin: 0 0 6px; }
.controls { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 6px; }
button { font: inherit; font-size: 14px; padding: 4px 9px; }
#status { font-size: 14px; color: #3b4659; min-height: 1.5em; }
figure { margin: 0; }
#chart { width: 100%; height: 330px; }
figcaption, .credit { font-size: 13px; color: #3b4659; margin-top: 6px; }
</style>
</head>
<body>
<h1>Zoom into the peaks: alcohol use disorder in FinnGen R13</h1>
<div class="controls" role="group" aria-label="Places to go">
<button type="button" data-chrom="0">Whole genome</button>
<button type="button" data-chrom="4" data-pos="99318162">Chromosome 4: ADH1B</button>
<button type="button" data-chrom="6" data-pos="19313150">Chromosome 6: 19.3 Mb</button>
<button type="button" data-chrom="2" data-pos="143468633">Chromosome 2: ARHGAP15</button>
<button type="button" data-chrom="18" data-pos="37402280">Chromosome 18: CELF4</button>
</div>
<p id="status" role="status" aria-live="polite">Loading FinnGen's results (145 kB)…</p>
<figure>
<div id="chart"></div>
<figcaption>Drag across the chart to zoom in, double-click to zoom out, or use the buttons. The line is p = 5 × 10⁻⁸.</figcaption>
</figure>
<p class="credit">Data: FinnGen release R13, endpoint AUD_SWEDISH: 29,711 cases and 470,475 controls.
We acknowledge the participants and investigators of the FinnGen study. Kurki MI et al. (2023) <i>Nature</i> 613: 508–518.</p>
<script>
const URL = "https://r13.finngen.fi/api/manhattan/pheno/AUD_SWEDISH";
const LENGTH = { 1: 248956422, 2: 242193529, 3: 198295559, 4: 190214555, 5: 181538259, 6: 170805979,
7: 159345973, 8: 145138636, 9: 138394717, 10: 133797422, 11: 135086622, 12: 133275309, 13: 114364328,
14: 107043718, 15: 101991189, 16: 90338345, 17: 83257441, 18: 80373285, 19: 58617616, 20: 64444167,
21: 46709983, 22: 50818468, 23: 156040895 }; // GRCh38; FinnGen calls X "23"
const start = {};
let genome = 0;
for (let c = 1; c <= 23; c++) { start[c] = genome; genome += LENGTH[c]; }
const name = c => (+c === 23 ? "X" : String(c));
const along = (c, pos) => (start[+c] + pos) / 1e6; // Mb along the whole genome
const chromAt = mb => { let c = 1; while (c < 23 && mb * 1e6 >= start[c + 1]) c++; return c; };
const COLOURS = { 1: "#2a78d6", 0: "#eb6834" }; // odd chromosomes blue, even orange
const chart = document.getElementById("chart"), status = document.getElementById("status");
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 20000); // give up after 20 seconds rather than wait for ever
fetch(URL, { signal: ctrl.signal }).then(r => {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
}).finally(() => clearTimeout(timer)).then(d => {
const listed = d.unbinned_variants.map(v => ({
x: along(v.chrom, v.pos), y: v.mlogp, odd: +v.chrom % 2, p: v.pval,
id: v.rsids !== "NA" ? v.rsids : name(v.chrom) + ":" + v.pos + ":" + v.ref + ":" + v.alt,
gene: v.nearest_genes !== "NA" ? v.nearest_genes : "none listed", where: "chr" + name(v.chrom) + ":" + v.pos.toLocaleString("en-GB") }));
// Plotly wants one array per axis for each "trace". Three traces for each colour:
const traces = [];
for (const odd of [1, 0]) {
const bins = d.variant_bins.filter(b => +b.chrom % 2 === odd);
// binned ranges of -log10(p) as vertical strokes; a null between strokes stops Plotly joining them
const rx = [], ry = [];
bins.forEach(b => b.neglog10_pval_extents.forEach(([lo, hi]) => { rx.push(along(b.chrom, b.pos), along(b.chrom, b.pos), null); ry.push(lo, hi, null); }));
traces.push({ type: "scatter", mode: "lines", x: rx, y: ry, line: { color: COLOURS[odd], width: 2 }, hoverinfo: "skip", showlegend: false });
const dots = bins.flatMap(b => b.neglog10_pvals.map(m => [along(b.chrom, b.pos), m]));
traces.push({ type: "scatter", mode: "markers", x: dots.map(p => p[0]), y: dots.map(p => p[1]),
marker: { color: COLOURS[odd], size: 3 }, hoverinfo: "skip", showlegend: false });
const v = listed.filter(v => v.odd === odd);
traces.push({ type: "scatter", mode: "markers", x: v.map(v => v.x), y: v.map(v => v.y), showlegend: false,
marker: { color: COLOURS[odd], size: 5 },
customdata: v.map(v => [v.id, v.gene, v.where, v.p]),
// <extra></extra> hides the trace name that Plotly adds to every hover label
hovertemplate: "<b>%{customdata[0]}</b><br>Nearest gene: %{customdata[1]}<br>%{customdata[2]}<br>p = %{customdata[3]:.2e}<extra></extra>" });
}
const line = -Math.log10(5e-8);
const layout = {
hovermode: "closest", dragmode: "zoom",
xaxis: { title: { text: "Chromosome" }, range: [0, genome / 1e6], showgrid: false, zeroline: false },
yaxis: { title: { text: "−log<sub>10</sub>(p)" }, range: [0, 24.5], fixedrange: true, zeroline: false }, // zoom along the genome only
shapes: [ { type: "line", xref: "paper", x0: 0, x1: 1, y0: line, y1: line, line: { dash: "dash", width: 1, color: "#172033" } } ],
margin: { t: 10, r: 10, b: 50, l: 55 },
font: { family: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" }
};
const config = {
responsive: true,
displayModeBar: true, // always show the toolbar, so keyboard users can find its buttons
displaylogo: false, // no link to Plotly's website
showSendToCloud: false, // no "Share chart" button, which would upload the chart to Plotly's cloud service
modeBarButtonsToRemove: ["select2d", "lasso2d"]
};
return Plotly.newPlot(chart, traces, layout, config).then(() => {
// Label the x axis by chromosome for wide views, and by position for close-ups
function ticksFor([a, b]) {
if (b - a > 400) {
// label chromosomes at their middles, leaving out any less than 14 pixels wide on screen
const pxPerMb = (chart.clientWidth - 65) / (b - a);
const shown = Object.keys(LENGTH).filter(c => (LENGTH[c] / 1e6) * pxPerMb >= 14);
return { tickvals: shown.map(c => (start[c] + LENGTH[c] / 2) / 1e6), ticktext: shown.map(name) };
}
const step = [0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50].find(s => (b - a) / s <= 6);
const tickvals = [], ticktext = [];
for (let c = 1; c <= 23; c++) { // round numbers within each chromosome in view
const s = start[c] / 1e6, e = s + LENGTH[c] / 1e6;
if (e < a || s > b) continue;
for (let t = Math.ceil((Math.max(a, s) - s) / step) * step; s + t <= Math.min(b, e); t += step) {
tickvals.push(s + t);
ticktext.push(name(c) + ":" + t.toFixed(step < 1 ? 1 : 0) + " Mb");
}
}
return { tickvals, ticktext };
}
// Say in words what the view shows, for everyone and for screen readers
function describe() {
const [a, b] = chart.layout.xaxis.range;
const inView = listed.filter(v => v.x >= a && v.x <= b);
const [c1, c2] = [chromAt(Math.max(a, 0)), chromAt(Math.min(b, genome / 1e6 - 1e-6))];
const where = b - a > 400 ? "the whole genome" :
"chromosome " + name(c1) + (c1 === c2 ? ", " + (a - start[c1] / 1e6).toFixed(1) + " to " + (b - start[c1] / 1e6).toFixed(1) + " Mb" : " to " + name(c2));
const best = inView.reduce((m, v) => (!m || v.p < m.p ? v : m), null);
status.textContent = "Showing " + where + ": " + inView.length.toLocaleString("en-GB") + " variants listed. " +
(best ? "The strongest is " + best.id + " (nearest gene " + best.gene + "), p = " + best.p.toExponential(1) + "." : "");
const ticks = ticksFor([a, b]);
Plotly.relayout(chart, { "xaxis.tickvals": ticks.tickvals, "xaxis.ticktext": ticks.ticktext });
}
chart.on("plotly_relayout", ev => {
if (Object.keys(ev).some(k => k.startsWith("xaxis.range") || k === "xaxis.autorange")) describe();
});
document.querySelectorAll("button[data-chrom]").forEach(btn => btn.addEventListener("click", () => {
const c = +btn.dataset.chrom;
const range = c ? [along(c, +btn.dataset.pos) - 1, along(c, +btn.dataset.pos) + 1] // 2 Mb around the variant
: [0, genome / 1e6];
Plotly.relayout(chart, { "xaxis.range": range });
}));
describe();
});
}).catch(err => {
status.textContent = !window.Plotly
? "The chart library (Plotly.js) did not load. Check the connection and reload the page."
: "FinnGen's data did not arrive (" + (err.name === "AbortError" ? "no answer within 20 seconds" : err.message) + "). Try again later.";
});
</script>
</body>
</html>What the code does:
- Traces. Plotly takes one array per axis for each set of points, called a trace. A
nullin the arrays breaks a line, which is how the binned ranges become separate strokes. - The toolbar. Plotly 4 adds a “Share chart” button unless told not to. After a dialog, it opens Plotly Cloud in a new tab and sends it the chart, data included. Turn it off with
showSendToCloud: false, remove the link to Plotly’s website withdisplaylogo: false, and take out tools you do not need withmodeBarButtonsToRemove. - Hover labels come from
hovertemplate;<extra></extra>hides the trace name that Plotly adds to each one. - Words for every view. Plotly adds nothing that a screen reader can use. After every zoom (the
plotly_relayoutevent), the page says in a status line what is in view and which variant is strongest, and relabels the axis with positions such as4:99.5 Mb. - Keyboard. The buttons above the chart and Plotly’s toolbar work from the keyboard; dragging does not. The chart sits in a
<figure>with a caption. Do not give itrole="img", which would hide the toolbar’s buttons from screen readers.
The cartesian bundle used here holds the common chart types, including scatter, bar, box and heatmap: 1.5 MB, or about 500 kB to download. The full plotly.min.js is 4.8 MB.
Make charts that everyone can read
- Colour. The examples use blue (
#2a78d6) with orange (#eb6834) for alternating chromosomes and for the two coverage lines, and blue with red (#e34948) on the volcano plot. Both pairs stay far apart under simulated red-green colour blindness, which affects about 8% of men and 0.4% of women of European ancestry [14]. On the volcano plot, grey means “neither”, and the dashed lines say the same thing by position. - Colour scales. For values either side of zero, such as z-scores, use a diverging scale with a pale middle, such as ColorBrewer’s red-blue. For values from low to high, use a sequential scale such as viridis. Rainbow and red-green scales distort the data, and many colour-blind readers cannot read them [15].
- Never colour alone. Give every chart with two or more colours a legend, or label the groups on the chart itself.
- Text versions. In Vega-Lite, the chart’s
descriptionand thedescriptionencoding become labels for screen readers, andaria: falsekeeps dense background layers out of the way. Put a short table or summary next to each chart; a table of every point is too long to use [16]. - Keyboard. Nothing inside a Vega-Lite chart can be reached with the Tab key, and tooltips need a pointer. The examples put their controls in ordinary HTML and give the key values in text, so nothing depends on hovering.
- Phones.
width: "container"fits a chart to the screen. Leave out labels that would overlap, as the Manhattan plots do for small chromosomes, and remember that a tap stands in for hovering.
The accessibility page has more.
Things that catch people out
- Large data. Drawn as SVG, every point is an element in the page. Vega drew the 15,804 points of the volcano plot in about 140 ms as SVG and 90 ms with
renderer: "canvas"in our tests, but a canvas has no labels for screen readers, and phones are slower. For millions of points, do what PheWeb does: draw the strong signals one by one and summarise the rest. - Data that do not load. With
data: { url: … }inside a specification, a failed download leaves empty axes, and the only sign is a warning in the browser’s console. Fetch the data yourself, as these examples do, and say on the page when it fails. - Zenodo addresses. Zenodo’s
?download=1links do not allow use from other websites, but its API addresses (api/records/…/files/…/content) do; the volcano example’s code has one. Zenodo does not compress files, and it limits the requests it accepts from one address (its replies gave a limit of 133 when we tested). Two of our requests failed and showed in the browser as CORS errors before a third worked. For a class, store a copy next to the page, as the volcano and heatmap examples do, if the licence allows it. - Opening a downloaded example. A page opened from your disk cannot read files beside it. Run
python3 -m http.serverin the folder and openhttp://localhost:8000/, or point the address at a published copy. The basic, FinnGen, simulated and UCSC examples work straight from disk. - Thresholds hidden in code. Keep each cut-off in one named place (
THRESHOLD, or a parameter), draw it on the chart, and say whether the p-values were adjusted. Vega expressions have nolog10: write-log(p) / LN10. A p-value stored as 0 gives an infinite height; replace it with the smallest value the tool can report, and say so. - Versions. Each example names exact versions. Vega-Lite 6.5.0 came out on 8 October 2026 and Plotly.js 4.1.2 on 5 October 2026, and we have not tested them yet. When you update, read the release notes and test the page again.
Licences and credits
Vega, Vega-Lite and vega-embed are under the BSD 3-Clause licence, Plotly.js is under MIT, and Observable Plot and d3 are under ISC. If you publish teaching material built on Vega-Lite, cite its paper [1].
| Data | Terms | Cite |
|---|---|---|
| FinnGen R13 | Free to download. FinnGen asks you to acknowledge “the participants and investigators of the FinnGen study”. We found no licence for copying the data, so the examples read them from FinnGen’s server. | [2] |
| Galaxy Training Network files on Zenodo | CC BY 4.0: you may copy and adapt them if you give credit and say what you changed, as data/README.txt does |
[7, 9, 10] |
| gnomAD sequencing depth | Released “without restriction on use”, according to gnomAD’s entry in the Registry of Open Data on AWS. We could not open gnomAD’s own terms page when we tested, so check it before you store a copy. | [11, 12] |
| UCSC Genome Browser data | Free for any use, with restrictions on some tracks noted on their description pages. UCSC asks to be cited. | [13] |
References
- Satyanarayan A, Moritz D, Wongsuphasawat K, Heer J (2017). Vega-Lite: a grammar of interactive graphics. IEEE Transactions on Visualization and Computer Graphics 23(1): 341–350. doi:10.1109/TVCG.2016.2599030
- Kurki MI, Karjalainen J, Palta P, et al. (2023). FinnGen provides genetic insights from a well-phenotyped isolated population. Nature 613: 508–518. doi:10.1038/s41586-022-05473-8
- Gagliano Taliun SA, VandeHaar P, Boughton AP, et al. (2020). Exploring and visualizing large-scale genetic associations by using PheWeb. Nature Genetics 52: 550–552. doi:10.1038/s41588-020-0622-5
- Pe’er I, Yelensky R, Altshuler D, Daly MJ (2008). Estimation of the multiple testing burden for genomewide association studies of nearly all common variants. Genetic Epidemiology 32(4): 381–385. doi:10.1002/gepi.20303
- Law CW, Chen Y, Shi W, Smyth GK (2014). voom: precision weights unlock linear model analysis tools for RNA-seq read counts. Genome Biology 15: R29. doi:10.1186/gb-2014-15-2-r29
- Ritchie ME, Phipson B, Wu D, et al. (2015). limma powers differential expression analyses for RNA-sequencing and microarray studies. Nucleic Acids Research 43(7): e47. doi:10.1093/nar/gkv007
- Fu NY, Rios AC, Pal B, et al. (2015). EGF-mediated induction of Mcl-1 at the switch to lactation is essential for alveolar cell survival. Nature Cell Biology 17(4): 365–375. doi:10.1038/ncb3117
- Hiltemann S, Rasche H, Gladman S, et al. (2023). Galaxy Training: a powerful framework for teaching! PLOS Computational Biology 19(1): e1010752. doi:10.1371/journal.pcbi.1010752
- Doyle M (2018). RNA-seq genes to pathways (training data). Zenodo. doi:10.5281/zenodo.2596382
- Doyle M (2018). Heatmap2 (training data). Zenodo. doi:10.5281/zenodo.2529926
- Karczewski KJ, Francioli LC, Tiao G, et al. (2020). The mutational constraint spectrum quantified from variation in 141,456 humans. Nature 581: 434–443. doi:10.1038/s41586-020-2308-7
- Chen S, Francioli LC, Goodrich JK, et al. (2024). A genomic mutational constraint map using variation in 76,156 human genomes. Nature 625: 92–100. doi:10.1038/s41586-023-06045-0
- Nassar LR, Barber GP, Benet-Pagès A, et al. (2023). The UCSC Genome Browser database: 2023 update. Nucleic Acids Research 51(D1): D1188–D1195. doi:10.1093/nar/gkac1072
- Birch J (2012). Worldwide prevalence of red-green color deficiency. Journal of the Optical Society of America A 29(3): 313–320. doi:10.1364/JOSAA.29.000313
- Crameri F, Shephard GE, Heron PJ (2020). The misuse of colour in science communication. Nature Communications 11: 5444. doi:10.1038/s41467-020-19160-7
- Smits TC, Weru L, Gehlenborg N, L’Yi S (2025). Ten simple rules for making biomedical data resources accessible. PLOS Computational Biology 21(11): e1013657. doi:10.1371/journal.pcbi.1013657