Antibody Escape Heatmaps
Switch between different antibodies to see how mutations affect neutralization.
Code
vue
<template>
<div class="flex flex-col gap-4 pb-4">
<!-- Dropdown -->
<div class="flex flex-row justify-left items-center text-center">
<label for="antibody-select-1" class="font-bold tracking-tight text-xs lg:text-md mr-4">
Antibody:
</label>
<div class="relative">
<select id="antibody-select-1" v-model="selectedAntibody" @change="changeDataset"
class="appearance-none px-5 py-1.5 pr-10 bg-gradient-to-r from-purple-50 to-pink-50 border-2 border-cyan-200 rounded-lg text-xs lg:text-md text-zinc-700 font-medium shadow-md transition-all duration-300 hover:border-cyan-400 hover:shadow-lg focus:border-cyan-500 focus:outline-none focus:ring-3 focus:ring-cyan-500 focus:ring-opacity-30 cursor-pointer min-w-[100px]">
<option v-for="antibody in uniqueAntibodies" :key="antibody" :value="antibody">
{{ antibody }}
</option>
</select>
<!-- Custom dropdown arrow -->
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-3 text-cyan-600">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z" />
</svg>
</div>
</div>
</div>
</div>
<svg id="svgContainer"></svg>
<svg id="legend" viewBox="0 0 500 60" style="width: 400px; height: 60px; display: block; "></svg>
<Tooltip ref="tooltip" :data="tooltipData" />
</template>
<script setup>
import { ref } from 'vue';
import * as d3 from 'd3';
import { withBase } from 'vitepress';
import Tooltip from '../components/simpleTooltip.vue';
import { Legend } from '../utilities/legend.js';
// reactive state variables
const uniqueAntibodies = ref([]); // list of available antibodies
const selectedAntibody = ref('12B2'); // currently selected antibody
const tooltip = ref(null); // reference to the tooltip component
const tooltipData = ref([]); // data to show in the tooltip
const rows = ref(5); // number of rows to wrap the heatmap
const min_func_effect = -2.5; // minimum effect threshold for coloring
// plot layout constants
const margin = { top: 20, right: 20, bottom: 60, left: 50 }; // margin
const rowPadding = 35; // amount of padding between the rows
const squareSize = 10; // size of each square in the heatmap
// non-reactive constants
const amino_acids = [
'R',
'K',
'H',
'D',
'E',
'Q',
'N',
'S',
'T',
'Y',
'W',
'F',
'A',
'I',
'L',
'M',
'V',
'G',
'P',
'C',
];
const interpolator = d3.interpolatePRGn;
const reversedInterpolator = (t) => interpolator(1 - t);
// data file path
const dataFile = withBase('/data/all_antibodies_escape_filtered.csv');
// main data structures
const dataLookupByAntibody = {}; // Nested lookup: antibody -> (site-mutant) -> data
const wildtypeLookupByAntibody = {}; // Nested lookup: antibody -> site -> wildtype
const siteEffectsMapByAntibody = {}; // Nested map: antibody -> site -> [effects]
let processed = {}; // Store processed data by antibody
// Function to fetch and process data
const fetchData = async () => {
try {
const data = await d3.csv(dataFile, (d) => ({
antibody: d.antibody,
site: +d.site,
wildtype: d.wildtype,
mutant: d.mutant,
escape: +d.escape_mean,
effect: +d.effect,
times_seen: +d.times_seen,
}));
const antibodyGroups = d3.group(data, d => d.antibody); // Group data by antibody
uniqueAntibodies.value = Array.from(antibodyGroups.keys()).sort(); // Extract and sort antibody names
// Process each antibody group
for (const [antibodyKey, antibodyData] of antibodyGroups) {
processed[antibodyKey] = antibodyData;
dataLookupByAntibody[antibodyKey] = {};
wildtypeLookupByAntibody[antibodyKey] = {};
siteEffectsMapByAntibody[antibodyKey] = new Map();
// Build lookups
antibodyData.forEach(d => {
const site = +d.site;
dataLookupByAntibody[antibodyKey][`${site}-${d.mutant}`] = d;
wildtypeLookupByAntibody[antibodyKey][site] = d.wildtype;
if (!siteEffectsMapByAntibody[antibodyKey].has(site)) {
siteEffectsMapByAntibody[antibodyKey].set(site, []);
}
siteEffectsMapByAntibody[antibodyKey].get(site).push(d.escape);
});
}
updateHeatmap(selectedAntibody.value);
} catch (error) {
console.error('Error loading CSV data:', error);
}
};
fetchData();
// Change dataset handler
const changeDataset = () => {
if (selectedAntibody.value) {
updateHeatmap(selectedAntibody.value);
}
};
// Function to update the heatmap based on the selected antibody
function updateHeatmap(antibodyKey) {
const dataLookup = dataLookupByAntibody[antibodyKey];
const wildtypeLookup = wildtypeLookupByAntibody[antibodyKey];
const siteEffectsMap = siteEffectsMapByAntibody[antibodyKey];
const sites = Array.from(siteEffectsMap.keys()).sort((a, b) => a - b);
const sitesPerRow = Math.ceil(sites.length / rows.value);
const siteRows = Array.from({ length: rows.value }, (_, i) =>
sites.slice(i * sitesPerRow, (i + 1) * sitesPerRow)
);
const totalRows = siteRows.length;
const maxSitesInRow = Math.max(...siteRows.map((row) => row.length));
const innerWidth = squareSize * maxSitesInRow;
const innerHeight = squareSize * amino_acids.length * rows.value +
rowPadding * (totalRows - 1);
const width = innerWidth + margin.left + margin.right;
const height = innerHeight + margin.top + margin.bottom;
const svg = d3.select('#svgContainer')
.attr('viewBox', `0 0 ${width} ${height}`);
svg.selectAll('*').remove();
const g = svg.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// Define scales
const xScale = d3.scaleBand()
.domain(Array.from({ length: maxSitesInRow }, (_, i) => i))
.range([0, innerWidth]);
const yScale = d3.scaleBand()
.domain(amino_acids)
.range([0, squareSize * amino_acids.length])
// Define color scale, add floor if needed
const minColor = d3.min([-1, d3.min(Array.from(siteEffectsMap.values()).flat())]);
const maxColor = d3.max([1, d3.max(Array.from(siteEffectsMap.values()).flat())]);
const color = d3.scaleDiverging(reversedInterpolator)
.domain([0, 0, maxColor]);
//Plot heatmap squares by row for wrapping
siteRows.forEach((siteRow, rowIndex) => {
const rowOffset = (yScale.range()[1] + rowPadding) * rowIndex;
const rowG = g.append('g')
.attr('transform', `translate(0,${rowOffset})`);
// Create data for rectangles
const rectData = [];
siteRow.forEach((site, siteIndex) => {
amino_acids.forEach(mutant => {
rectData.push({ site, mutant, siteIndex, key: `${site}-${mutant}` });
});
});
const rects = rowG.selectAll('rect')
.data(rectData, d => d.key);
rects.enter()
.append('rect')
.join(rects)
.attr('x', d => xScale(d.siteIndex))
.attr('y', d => yScale(d.mutant))
.attr('width', xScale.bandwidth())
.attr('height', yScale.bandwidth())
.attr('stroke', 'white')
.attr('stroke-width', 1)
.attr('fill', d => {
const dataPoint = dataLookup[d.key];
if (dataPoint) {
return (dataPoint.effect < min_func_effect) ? 'lightgray' : color(dataPoint.escape);
}
return wildtypeLookup[d.site] === d.mutant ? 'white' : 'lightgray';
})
.on('mouseover', function (event, d) {
const key = `${d.site}-${d.mutant}`;
const dataPoint = dataLookup[key];
if (dataPoint) {
tooltip.value.showTooltip(event);
tooltipData.value = [
{ label: 'Wildtype', value: dataPoint.wildtype },
{ label: 'Site', value: dataPoint.site },
{ label: 'Mutant', value: dataPoint.mutant },
{ label: 'Escape', value: dataPoint.escape.toFixed(1) },
{ label: 'Cell Entry', value: dataPoint.effect.toFixed(1) },
{ label: 'Times Seen', value: dataPoint.times_seen }
];
}
})
.on('mousemove', (event) => {
if (tooltip.value?.updatePosition) {
tooltip.value.updatePosition(event);
}
})
.on('mouseout', () => {
tooltip.value.hideTooltip();
tooltipData.value = [];
})
rects.exit().remove();
// Draw wildtype X markers
rowG.selectAll('.wildtype')
.data(siteRow)
.join('text')
.attr('class', 'wildtype')
.attr('x', (d, i) => xScale(i) + xScale.bandwidth() / 2)
.attr('y', d => yScale(wildtypeLookup[d]) + yScale.bandwidth() / 2 + 3)
.attr('text-anchor', 'middle')
.attr('font-size', '8px')
.attr('pointer-events', 'none')
.text('X');
// Add the site numbers to the x-axis, only plotting every 10 sites
const xAxis = d3.axisBottom(xScale).tickSizeOuter(0);
xAxis.tickFormat((d, i) => (i % 10 === 0 ? siteRow[d] : ''));
// ADD THE X AND Y AXES
// Add the site numbers to the x-axis
rowG.append('g')
.attr('transform', `translate(0,${yScale.range()[1]})`)
.call(xAxis)
.selectAll('text')
.attr('transform', 'rotate(-90)')
.attr('text-anchor', 'end')
.attr('dx', '-8px')
.attr('dy', '-5px')
.attr('font-size', '12px');
// Y axis
rowG.append('g')
.call(d3.axisLeft(yScale).tickSizeOuter(0))
.attr('font-size', '10px');
});
// Add the row title
g.append('text')
.attr('class', 'axis-title-x')
.attr('x', innerWidth / 2)
.attr('y', height - margin.bottom + 35)
.attr('font-size', '20px')
.attr('font-weight', 'bold')
.attr('text-anchor', 'middle')
.attr('text-align', 'center')
.attr('fill', 'currentColor')
.text('Site');
// Add the column title
g.append('text')
.attr('class', 'axis-title-y')
.attr('x', -innerHeight / 2)
.attr('y', -35)
.attr('font-size', '20px')
.attr('font-weight', 'bold')
.attr('text-anchor', 'middle')
.attr('transform', 'rotate(-90)')
.attr('fill', 'currentColor')
.text('Amino Acid');
// Legend
d3.select('#legend').selectAll('*').remove();
Legend(d3.scaleSequential([0, maxColor], d3.interpolatePurples).clamp(true), {
svgRef: '#legend',
title: 'Antibody Escape',
width: 150,
tickValues: [0, maxColor],
xcoord: 20,
ycoord: 0,
fontSize: 16,
});
}
</script>