Antibody Escape

Toggle between antibody escape datasets aggregated by site.

Code

vue
<template>
    <div class="">
        <!-- 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"
                    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 availableAntibodies" :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 ref="svgContainer" id="svgContainerSumSelectable"></svg>
    <Tooltip ref="tooltip" :data="tooltipData" />
</template>

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

// reactive references
const tooltip = ref(null);
const tooltipData = ref([]);
const availableAntibodies = ref([]);
const selectedAntibody = ref('');

// non-reactive variable to hold the SVG element
let allAntibodyData = new Map();
let svg = null;
let chartGroup = null;

// CHART DIMENSIONS
const width = 800;
const height = 300;
const marginTop = 20;
const marginRight = 30;
const marginBottom = 60;
const marginLeft = 60;
const innerWidth = width - marginLeft - marginRight;
const innerHeight = height - marginTop - marginBottom;

const dataFile = withBase('/data/all_antibodies_escape_filtered_sum.csv');
// load data from single file and process it
const loadData = async () => {
    try {
        const rawData = await d3.csv(dataFile, d => ({
            antibody: d.antibody,
            site: +d.site,
            wildtype: d.wildtype,
            escape: +d.sum_escape,
        }));

        const antibodyGroups = d3.group(rawData, d => d.antibody);

        // Store processed data in Map for O(1) access
        antibodyGroups.forEach((data, antibody) => {
            allAntibodyData.set(antibody, processAntibodyData(data));
        });

        // Set available antibodies
        availableAntibodies.value = Array.from(allAntibodyData.keys()).sort();

        // Set initial selection
        if (availableAntibodies.value.length > 0) {
            selectedAntibody.value = availableAntibodies.value[0];
        }
    } catch (error) {
        console.error('Error loading data:', error);
    }
}
loadData();
// process the fetched data and return it
function processAntibodyData(data) {
    const siteMap = new Map();

    data.forEach(d => {
        if (!siteMap.has(d.site)) {
            siteMap.set(d.site, {
                site: d.site,
                wildtype: d.wildtype,
                escape: 0
            });
        }
        siteMap.get(d.site).escape += d.escape;
    });

    // Convert to array and round escape values
    return Array.from(siteMap.values())
        .map(d => ({
            ...d,
            escape: Math.round(d.escape * 100) / 100
        }))
        .sort((a, b) => a.site - b.site);
}


// Watch for antibody selection changes
watchEffect(() => {
    if (selectedAntibody.value) {
        updateChart(allAntibodyData.get(selectedAntibody.value));
    }
});


// Setup scales and axes
const xScale = d3.scaleLinear()
    .range([0, innerWidth]);
const xAxisGenerator = d3.axisBottom().scale(xScale).tickSizeOuter(0);


const yScale = d3.scaleLinear()
    .range([innerHeight, 0])
    .clamp(true);
const yAxisGenerator = d3.axisLeft(yScale).tickSizeOuter(0).ticks(6);

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

// Set colors for the different datasets
const colorScale = d3.scaleOrdinal(d3.schemeTableau10);

onMounted(() => {
    svg = d3.select("#svgContainerSumSelectable")
        .attr('viewBox', `0 0 ${width} ${height}`)

    chartGroup = svg.append('g')
        .attr('transform', `translate(${marginLeft}, ${marginTop})`);

    // X-axis group
    chartGroup.append('g')
        .attr('class', 'x-axis')
        .attr('transform', `translate(0, ${innerHeight})`);

    // X-axis label
    chartGroup.append('text')
        .attr('class', 'x-axis-label')
        .attr('x', innerWidth / 2)
        .attr('y', innerHeight + marginBottom - 15)
        .attr('fill', 'currentColor')
        .attr('text-anchor', 'middle')
        .attr('font-weight', 'bold')
        .attr('font-size', '20px')
        .text('Site');

    // Y-axis group
    chartGroup.append('g')
        .attr('class', 'y-axis');

    // Y-axis label
    chartGroup.append('text')
        .attr('class', 'y-axis-label')
        .attr('x', -innerHeight / 2)
        .attr('y', -marginLeft + 15)
        .attr('fill', 'currentColor')
        .attr('font-weight', 'bold')
        .attr('font-size', '20px')
        .attr('text-anchor', 'middle')
        .attr('transform', 'rotate(-90)')
        .text('Total Escape');
    
    //loadData();
});



// Render the chart
function updateChart(data) {
    // how long to transition for
    const t = d3.transition().duration(1500).ease(d3.easePoly);
    // Update scales based on the new data
    xScale.domain(d3.extent(data, (d) => d.site));
    yScale.domain([0, d3.max(data, (d) => d.escape)]);
    
    const circleColor = colorScale(selectedAntibody.value);

    //make and update circles. 
    const line = chartGroup.selectAll('.data-line')
        .data([data]) 

    line.enter()
        .append('path')
        .attr('class', 'data-line')
        .attr('fill', 'none')
        .attr('stroke-width', 1.5)
        .merge(line) 
        .transition(t)
        .attr('d', lineGenerator)
        .attr('stroke', circleColor)
        .attr('opacity', 1);


    const circles = chartGroup.selectAll('.data-point')
        .data(data, (d) => d.site)
    
    circles.enter()
        .append('circle')
        .attr('class', 'data-point')
        .attr('r', 4)
        .attr('stroke', 'currentColor')
        .attr('stroke-width', 0.5)
        .on('mouseover', (event, d) => {
            tooltip.value.showTooltip(event);
            tooltipData.value = [
                { label: 'Wildtype', value: d.wildtype },
                { label: 'Site', value: d.site },
                { label: 'Total Escape', value: d.escape.toFixed(1) },
            ];
        })
        .on('mousemove', (event) => {
            // Update position on every mouse move
            if (tooltip.value && tooltip.value.updatePosition) {
                tooltip.value.updatePosition(event);
            }
        })
        .on('mouseout', () => {
            tooltip.value.hideTooltip();
        })

        .attr('cx', (d) => xScale(d.site))
        .attr('cy', (d) => yScale(d.escape))
        .merge(circles)
        .transition(t)
        .attr('cx', (d) => xScale(d.site))
        .attr('cy', (d) => yScale(d.escape))
        .attr('fill', circleColor)
        .attr('opacity', 1);

    circles.exit()
        .transition(t)
        .attr('opacity', 0)
        .remove();
        
    // Update the x-axis
    chartGroup.select('.x-axis')
        .transition(t)
        .call(xAxisGenerator)
        .selectAll('text')
        .attr('font-size', '18px');

    chartGroup.select('.y-axis')
        .transition(t)
        .call(yAxisGenerator)
        .selectAll('text')
        .attr('font-size', '18px');
}

</script>

<style scoped>
select {
    padding: 8px 12px;
    font-size: 14px;
    border: 2px solid #ccc;
    border-radius: 4px;
    background-color: #fff;
    color: #333;
    outline: none;
}

select:focus {
    border-color: #888;
}
</style>