Antibody Escape Dot Plot

Hover over points to get more information.

Code

vue
<template>
    <svg ref="svgContainer" id="svgContainerDotPlot"></svg>
    <Tooltip ref="tooltip" :data="tooltipData" />
</template>

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

// reactive variables
const tooltip = ref(null);
const tooltipData = ref([]);

// graphing variables
const width = 600;
const height = 400;
const marginTop = 10;
const marginRight = 20;
const marginBottom = 20;
const marginLeft = 60;
const jitterAmount = 0.75
const colorScale = d3.scaleOrdinal(d3.schemeCategory10);

// Load the CSV data
const dataFile = withBase('/data/all_antibodies_escape_filtered.csv');
const fetchData = async () => {
    try {
        const data = await d3.csv(dataFile, (d) => ({
            site: +d.site,
            wildtype: d.wildtype,
            mutant: d.mutant,
            escape: +d.escape_mean,
            antibody: d.antibody,
            effect: +d.effect,
        }));
        const dataset = data.filter(d => d.escape >= 0 && d.effect >= -2.5); // Filter out negative escape and effect values
        makeChart(dataset);

    } catch (error) {
        console.error('Error loading CSV data:', error);
    }
};
fetchData()

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

    svg.append('g')
        .attr('class', 'xaxis')
        .attr('transform', `translate(0, ${height - marginBottom})`)
});

function makeChart(dataset) {

    const xScale = d3
        .scaleBand()
        .domain([...new Set(dataset.map(d => d.antibody))])
        .range([marginLeft, width - marginRight])
        .padding(0.05);

    // setup scales
    const yScale = d3.scaleLinear()
        .domain([0, d3.max(dataset, d => d.escape)])
        .range([height - marginBottom, marginTop]);

    // Function to generate jittered x position
    function jitteredX(antibody) {
        const bandCenter = xScale(antibody) + xScale.bandwidth() / 2;
        const jitterRange = xScale.bandwidth() * jitterAmount;
        const jitter = (Math.random() - 0.5) * jitterRange;
        return bandCenter + jitter;
    }

    const circles = svg
        .append('g')
        .selectAll('circle')
        .data(dataset)
        .join('circle')
        .attr('cx', (d) => jitteredX(d.antibody)) // Use jittered position
        .attr('cy', (d) => yScale(d.escape))
        .attr('r', 4)
        .attr('fill', (d) => colorScale(d.antibody))
        .attr('opacity', 0.8)

    const { handleMouseOver, handleMouseMove, handleMouseOut } = createHoverEffects(circles);

    circles
        .on('mouseover', handleMouseOver)
        .on('mousemove', handleMouseMove)
        .on('mouseout', handleMouseOut);

    //x-axis
    svg.append('g')
        .attr('transform', `translate(0, ${height - marginBottom})`)
        .call(d3.axisBottom().scale(xScale).tickSizeOuter(0).tickSize(0))
        .call(g => g.select(".domain").remove())
        .selectAll("text")
        .attr("dy", "-8px")
        .style("font-weight", "bold")
        .style("font-size", "20px")
        .style("color", "white")

    //y-axis
    svg.append('g')
        .attr('transform', `translate(${marginLeft}, 0)`)
        .call(d3.axisLeft().scale(yScale).ticks(4).tickSizeOuter(0))
        .call(g => g.select(".domain").remove())
        .selectAll("text")
        .style("font-size", "18px")
        .style("color", "currentColor")

    // Add the y-axis label
    svg.append('text')
        .attr('x', -height / 2 - 50)
        .attr('y', 25)
        .attr('transform', 'rotate(-90)')
        .style('font-size', '18px')
        .style('font-weight', 'bold')
        .style('fill', 'currentColor')
        .text('Escape')
}

// tooltip data
const getTooltipData = (d) => [
    { label: 'Wildtype', value: d.wildtype },
    { label: 'Site', value: d.site },
    { label: 'Mutant', value: d.mutant },
    { label: 'Escape', value: d.escape.toFixed(1) },
    { label: 'Cell Entry', value: d.effect.toFixed(1) },
];


// Hover effect handlers 
const createHoverEffects = (selection) => {
    const handleMouseOver = function (event, d) {
        // Show tooltip
        tooltip.value.showTooltip(event);
        tooltipData.value = getTooltipData(d);

        //const hoveredObject = d.site;
        const hoveredElement = this;

        // Apply effects based on protein group
        selection.each(function (Data) {
            const object = d3.select(this);
            const isHovered = this === hoveredElement;

            object
                .transition()
                .duration(200)
                .attr('opacity', isHovered ? 1 : 0.8)
                .attr('r', isHovered ? 6 : 4)
        });

        // Bring hovered circle to front
        d3.select(this).raise();
    };

    const handleMouseMove = (event) => {
        if (tooltip.value?.updatePosition) {
            tooltip.value.updatePosition(event);
        }
    };

    const handleMouseOut = function () {
        // Hide tooltip
        tooltip.value.hideTooltip();
        tooltipData.value = [];

        // Reset all circles to original state
        selection
            .transition()
            .duration(200)
            .attr('opacity', 0.8)
            .attr('r', 4)

    };

    return { handleMouseOver, handleMouseMove, handleMouseOut };
};
</script>