Antibody Escape Animation

Animation showing escape aggregated by site for the different antibodies.

Code

vue
<template>
    <svg ref='svgContainer' id="svgContainer"></svg>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import * as d3 from 'd3';
import { withBase } from 'vitepress';

// Dimensions and margins
const width = 800;
const height = 400;
const marginTop = 40;
const marginRight = 40;
const marginBottom = 60;
const marginLeft = 60;

// Animation variables
let path;
let antibodies;
let currentAntibodyIndex = -1;
const loopInterval = 2500;
let antibodyText;
let animationId = null;

// Data fetching and processing
const dataFile = withBase('/data/all_antibodies_escape_filtered_sum.csv');
let processedData = null;
let siteExtent = null;
let escapeExtent = null;

const fetchData = async () => {
    try {
        const rawData = await d3.csv(dataFile, (d) => ({
            site: +d.site,
            wildtype: d.wildtype,
            sum_escape: +d.sum_escape,
            antibody: d.antibody,
        }));
        return rawData;
    } catch (error) {
        console.error('Error loading CSV data:', error);
        return [];
    }
};

const processData = (rawData) => {
    if (!rawData || rawData.length === 0) return [];

    const antibodyMap = new Map();

    // Pre-calculate extents while grouping
    let minSite = Infinity, maxSite = -Infinity;
    let maxEscape = -Infinity;

    rawData.forEach(d => {
        const { antibody, site, sum_escape } = d;

        // Update extents
        if (site < minSite) minSite = site;
        if (site > maxSite) maxSite = site;
        if (sum_escape > maxEscape) maxEscape = sum_escape;

        // Group by antibody
        if (!antibodyMap.has(antibody)) {
            antibodyMap.set(antibody, []);
        }
        antibodyMap.get(antibody).push({
            site,
            escape: sum_escape
        });
    });

    // Store extents for later use
    siteExtent = [minSite, maxSite];
    escapeExtent = [0, maxEscape];

    // Convert Map to array and sort sites within each antibody
    const result = Array.from(antibodyMap.entries()).map(([antibody, sites]) => ({
        antibody,
        sites: sites.sort((a, b) => a.site - b.site)
    }));

    return result;
};

// Start the component lifecycle
onMounted(async () => {
    const rawData = await fetchData();
    processedData = processData(rawData);
    makeColorChart(processedData);

});

const colorScale = d3.scaleOrdinal().range(d3.schemeTableau10);

function makeColorChart(data) {
    if (!data || data.length === 0) {
        console.error('No data to plot');
        return;
    }
    // Use pre-calculated extents
    const xScale = d3.scaleLinear()
        .domain(siteExtent)
        .range([marginLeft, width - marginRight]);

    const yScale = d3.scaleLinear()
        .domain(escapeExtent)
        .range([height - marginBottom, marginTop])
        .clamp(true);

    // Create axis generators
    const xAxisGenerator = d3.axisBottom(xScale)
        .tickSizeOuter(0).ticks(4);
    const yAxisGenerator = d3.axisLeft(yScale)
        .ticks(4)
        .tickSizeOuter(0);

    // Line generator
    const lineGenerator = d3.line()
        .x(d => xScale(d.site))
        .y(d => yScale(d.escape))
        .curve(d3.curveMonotoneX);

    const svg = d3.select("#svgContainer")
        .attr('viewBox', `0 0 ${width} ${height}`);

    // Clear existing content
    svg.selectAll('*').remove();

    // Create paths 
    path = svg.append('g')
        .attr('fill', 'none')
        .attr('stroke-width', 1.5)
        .attr('stroke-linejoin', 'round')
        .attr('stroke-linecap', 'round')
        .selectAll('path')
        .data(data)
        .join('path')
        .attr('d', d => lineGenerator(d.sites))
        .attr('stroke', d => colorScale(d.antibody))
        .attr('mix-blend-mode', 'multiply')
        .style('vector-effect', 'non-scaling-stroke');

    // Add axes
    svg.append('g')
        .attr('transform', `translate(0, ${height - marginBottom})`)
        .call(xAxisGenerator)
        .attr('font-size', '18px')
        .call(g => g.selectAll('.domain').remove())
        .call(g => g.append('text')
            .attr('x', width / 2)
            .attr('y', marginBottom - 10)
            .attr('font-size', '20px')
            .attr('fill', 'currentColor')
            .attr('font-weight', 'bold')
            .attr('text-anchor', 'middle')
            .text('Site'));

    svg.append('g')
        .attr('transform', `translate(${marginLeft}, 0)`)
        .call(yAxisGenerator)
        .attr('font-size', '18px')
        .call(g => g.selectAll('.domain').remove())
        .call(g => g.append('text')
            .attr('x', -height / 2)
            .attr('y', -marginLeft + 20)
            .attr('font-size', '20px')
            .attr('transform', 'rotate(-90)')
            .attr('fill', 'currentColor')
            .attr('font-weight', 'bold')
            .attr('text-anchor', 'middle')
            .text('Total Escape'));

    // Add antibody label in upper right corner
    antibodyText = svg.append("text")
        .attr("x", width - marginRight)
        .attr("y", marginTop)
        .attr("text-anchor", "end")
        .attr('font-weight', 'bold')
        .attr("font-size", "20px")
        .text('All antibodies');

    // Initialize animation
    antibodies = data.map(d => d.antibody);
    startLoop();
}

function startLoop() {
    // Clear any existing interval
    if (animationId) {
        clearInterval(animationId);
    }

    animationId = setInterval(() => {
        currentAntibodyIndex = (currentAntibodyIndex + 1) % (antibodies.length + 1);
        updateColors();
    }, loopInterval);
}

function updateColors() {
    if (currentAntibodyIndex === 0) {
        // Show all antibodies
        path.style("stroke", d => colorScale(d.antibody))
            .attr("stroke-width", 1.5)
            .style("opacity", 1);

        antibodyText.text("All antibodies")
            .attr("fill", "currentColor");
    } else {
        // Highlight current antibody
        const currentAntibody = antibodies[currentAntibodyIndex - 1];

        path.style("stroke", d => d.antibody === currentAntibody ? colorScale(d.antibody) : "#ddd")
            .attr("stroke-width", d => d.antibody === currentAntibody ? 2 : 1.5)
            .style("opacity", d => d.antibody === currentAntibody ? 1 : 0.75);

        // Bring current antibody to front
        path.filter(d => d.antibody === currentAntibody).raise();

        antibodyText.text(currentAntibody)
            .attr("fill", colorScale(currentAntibody));
    }
}


onUnmounted(() => {
    if (animationId) {
        clearInterval(animationId);
        animationId = null;
    }
});
</script>