Antibody Escape Interactive Heatmap

Explore antibody escape data in twenty-residue windows

Site: 192

Code

vue
<template>
    <svg ref="svgContainer" id="antibodyheatmapsite"></svg>
    <!-- Dropdown -->
    <div class="flex flex-row justify-left items-center text-center my-6">
        <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"
                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>
    <CustomSlider v-for="(slider, index) in sliders" :key="index" :label="slider.label" :min="slider.min"
        :max="slider.max" :step="slider.step" v-model="slider.value" />
    <Tooltip ref="tooltip" :data="tooltipData" />
</template>

<script setup>
import { ref, watch, computed } from 'vue';
import * as d3 from 'd3';
import { withBase } from 'vitepress';
import CustomSlider from '../components/CustomSlider.vue';
import Tooltip from '../components/simpleTooltip.vue';

// DEFINE REACTIVE VARIABLES
const allCombinationsComplete = ref([]);
const wildtypeDataComplete = ref([]);
const uniqueAntibodies = ref([]);
const selectedAntibody = ref('12B2');
const tooltip = ref(null);
const tooltipData = ref([]);

const sliders = ref([
    { label: 'Site', value: 192, min: 29, max: 481, step: 1 },
]);

// DEFINE DIMENSIONS
const marginTop = 5;
const marginRight = 30;
const marginBottom = 30;
const marginLeft = 30;
const squareSize = 5;
const range = 10;

// DEFINE NON-REACTIVE VARIABLES
const amino_acids = ['R', 'K', 'H', 'D', 'E', 'Q', 'N', 'S', 'T', 'Y', 'W', 'F', 'A', 'I', 'L', 'M', 'V', 'G', 'P', 'C'];

// LOAD DATA
const dataFile = withBase('/data/all_antibodies_escape_filtered.csv');
let data = [];
const fetchData = async () => {
    try {
        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,
        }));
        preprocessData(); // Process data once after loading
    } catch (error) {
        console.error('Error loading CSV data:', error);
    }
};
fetchData();


function preprocessData() {
    const antibodyGroups = d3.group(data, d => d.antibody);

    // Get unique antibodies
    uniqueAntibodies.value = Array.from(antibodyGroups.keys()).sort();
    const filtered = data.filter(d => d.antibody === selectedAntibody.value);

    // Get site data and wildtypes
    const siteData = d3.rollup(
        filtered,
        v => v[0].wildtype,
        d => d.site
    );

    // Create wildtype data array
    wildtypeDataComplete.value = Array.from(siteData, ([site, wildtype]) => ({
        site,
        wildtype
    }));

    // Create a Map
    const dataMap = new Map(
        filtered.map(d => [`${d.site}-${d.mutant}`, d])
    );

    // Generate all combinations using flatMap
    allCombinationsComplete.value = Array.from(siteData).flatMap(([site, wildtype]) =>
        amino_acids.map(mutant =>
            dataMap.get(`${site}-${mutant}`) || {
                antibody: selectedAntibody.value,
                site,
                wildtype,
                mutant,
                escape: null,
                effect: null,


            }
        )
    );
    updateHeatmap();
}

const filteredData = computed(() => {
    const siteValue = sliders.value[0].value;
    const minSite = siteValue - range;
    const maxSite = siteValue + range;

    return allCombinationsComplete.value.filter(
        d => d.site >= minSite && d.site <= maxSite
    );
});

const filteredWildtypeData = computed(() => {
    const siteValue = sliders.value[0].value;
    const minSite = siteValue - range;
    const maxSite = siteValue + range;

    return wildtypeDataComplete.value.filter(
        d => d.site >= minSite && d.site <= maxSite
    );
});

// Watch the slider value for changes
watch(() => selectedAntibody.value, () => {
    preprocessData();
});

watch(() => sliders.value[0].value, () => {
    updateHeatmap();
});

function updateHeatmap() {
    if (!filteredData.value.length) return;

    // Get unique sites from filtered data
    const uniqueSites = [...new Set(filteredData.value.map(d => d.site))];

    // Calculate dimensions
    const height = squareSize * amino_acids.length + marginTop + marginBottom;
    const width = squareSize * uniqueSites.length + marginLeft + marginRight;

    // Clear and setup SVG
    const svg = d3.select("#antibodyheatmapsite");
    svg.selectAll('*').remove();

    const svgElement = svg
        .attr('viewBox', `0 0 ${width} ${height}`)

    // Setup scales
    const xScale = d3
        .scaleBand()
        .domain(uniqueSites)
        .range([marginLeft, width - marginRight])

    const yScale = d3
        .scaleBand()
        .domain(amino_acids)
        .range([marginTop, height - marginBottom])

    // Set colors for antibody escape. Floor sensitizing mutations to reduce influence on color scale
    const minEscape = Math.min(...allCombinationsComplete.value.map(d => d.escape), -10);
    const maxEscape = Math.max(...allCombinationsComplete.value.map(d => d.escape), 1);
    const colorScale = d3.scaleDiverging([minEscape, 0, maxEscape], d3.interpolateRdBu);

    // Draw rectangles using filtered data
    svgElement
        .selectAll('.heatmap-cell')
        .data(filteredData.value)
        .enter()
        .append('rect')
        .attr('class', 'heatmap-cell')
        .attr('x', d => xScale(d.site))
        .attr('y', d => yScale(d.mutant))
        .attr('width', xScale.bandwidth())
        .attr('height', yScale.bandwidth())
        .attr('stroke', 'white')
        .attr('stroke-width', 0.75)
        .attr('fill', d => (d.escape === null || d.effect < -2.5) ? '#d3d3d3' : colorScale(d.escape))
        .on('mouseover', (event, d) => {
            tooltip.value.showTooltip(event);
            tooltipData.value = [
                { label: 'Wildtype', value: d.wildtype },
                { label: 'Site', value: d.site },
                { label: 'Mutant', value: d.mutant },
                { label: 'Escape', value: d.escape !== null ? d.escape.toFixed(1) : 'N/A' },
                { label: 'Cell Entry', value: d.effect !== null ? d.effect.toFixed(1) : 'N/A' },
                { label: 'Times Seen', value: d.times_seen !== null ? d.times_seen : 'N/A' },
            ];
        })
        .on('mousemove', (event) => {
            // Update position on every mouse move
            if (tooltip.value && tooltip.value.updatePosition) {
                tooltip.value.updatePosition(event);
            }
        })
        .on('mouseout', () => {
            tooltip.value.hideTooltip();
        });

    // Plot wildtype sites using filtered data
    svgElement
        .selectAll('.wildtype-square')
        .data(filteredWildtypeData.value)
        .enter()
        .append('rect')
        .attr('class', 'wildtype-square')
        .attr('x', d => xScale(d.site))
        .attr('y', d => yScale(d.wildtype))
        .attr('width', xScale.bandwidth())
        .attr('height', yScale.bandwidth())
        .attr('fill', 'white')
        .on('mouseover', (event, d) => {
            tooltip.value.showTooltip(event);
            tooltipData.value = [
                { label: 'Site', value: d.site },
                { label: 'Wildtype', value: d.wildtype },
            ];
        })
        .on('mousemove', (event) => {
            // Update position on every mouse move
            if (tooltip.value && tooltip.value.updatePosition) {
                tooltip.value.updatePosition(event);
            }
        })
        .on('mouseout', () => {
            tooltip.value.hideTooltip();
        });

    svgElement
        .selectAll('.wildtype-marker')
        .data(filteredWildtypeData.value)
        .enter()
        .append('text')
        .attr('class', 'wildtype-marker')
        .attr('x', d => xScale(d.site) + xScale.bandwidth() / 2)
        .attr('y', d => yScale(d.wildtype) + yScale.bandwidth() / 2)
        .attr('text-anchor', 'middle')
        .attr('dominant-baseline', 'central')
        .style('font-size', '4px')
        .style('fill', 'black')
        .text('X')
        .style('pointer-events', 'none');


    // Add axes
    svgElement
        .append('g')
        .attr('transform', `translate(0, ${height - marginBottom})`)
        .call(d3.axisBottom(xScale).tickSize(2).tickSizeOuter(0))
        .call(g => g.select('.domain').remove())
        .attr('stroke-width', 0.5)
        .selectAll('text')
        .attr('transform', 'rotate(-90)')
        .attr('dx', '-0.5em')
        .attr('dy', '-0.7em')
        .style('text-anchor', 'end')
        .style('font-size', '5px');

    svgElement
        .append('g')
        .attr('transform', `translate(${marginLeft}, 0)`)
        .call(d3.axisLeft(yScale).tickSize(2).tickSizeOuter(0))
        .call(g => g.select('.domain').remove())
        .attr('stroke-width', 0.5)
        .selectAll('text')
        .attr('dx', '0em')
        .attr('text-anchor', 'middle')
        .style('font-size', '5px');

    svgElement
        .append('text')
        .attr('class', 'axis-title-x')
        .attr('x', (width) / 2)
        .attr('y', height - 10)
        .attr('font-size', '5px')
        .attr('font-weight', 'bold')
        .attr('text-anchor', 'middle')
        .attr('text-align', 'center')
        .attr('fill', 'currentColor')
        .text('Site');

    svgElement
        .append('text')
        .attr('class', 'axis-title-y')
        .attr('x', -55)
        .attr('y', 15)
        .attr('font-size', '5px')
        .attr('transform', `rotate(-90)`)
        .attr('font-weight', 'bold')
        .attr('text-anchor', 'middle')
        .attr('text-align', 'center')
        .attr('fill', 'currentColor')
        .text('Amino Acid');
}
</script>