Distribution of Effects by Amino Acid Mutation

Animation showing how mutation effects vary by amino acid

This animation shows the distribution of effects for each specific amino acid. Most of the amino acid mutations have similar distributions, with a few exceptions. For example, mutations to Glycine (G) and Proline (P) are more likely to have strong negative effects.

Code

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

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

// define reactive variables
const allBinnedData = ref(new Map()); // Map to hold binned data for all amino acids
const selectedAminoAcid = ref('S'); // Currently selected amino acid for display
const isAnimating = ref(false); // Animation state
const animationDuration = ref(1500); // milliseconds
let animationInterval = null; // Interval ID for animation
let svgElement = null;
let xScale = null;
let yScale = null;
let yAxisGroup = null;
let barsGroup = null;
let labelText = null;

// define dimensions
const marginTop = 30;
const marginRight = 50;
const marginBottom = 50;
const marginLeft = 40;
const width = 600;
const height = 300;

// define non-reactive variables
const uniqueAminoAcids = ref([
    'R', 'K', 'H', 'D', 'E', 'Q', 'N', 'S', 'T', 'Y',
    'W', 'F', 'A', 'I', 'L', 'M', 'V', 'G', 'P', 'C',
]);

// Color scale for amino acids
const aminoAcidColorScale = d3.scaleOrdinal()
    .domain(uniqueAminoAcids.value)
    .range(d3.schemeTableau10);


// load data
const dataFile = withBase('/data/Nipah_F_func_effects_filtered.csv');
let data = [];
const fetchData = async () => {
    try {
        data = await d3.csv(dataFile, (d) => ({
            site: +d.site,
            wildtype: d.wildtype,
            mutant: d.mutant,
            effect: +d.effect,
        }));
        return data;
    } catch (error) {
        console.error('Error loading CSV data:', error);
    }
};

// process data after loading
onMounted(async () => {
    await fetchData();
    processAllData();
    initializeChart();
    updateHistogram(false); // Initial draw without animation
    startAnimation();
});

onUnmounted(() => {
    stopAnimation();
});

function processAllData() {
    const groupedData = d3.group(data, d => d.mutant);
    const allEffects = data.map(d => d.effect);
    const globalExtent = d3.extent(allEffects);

    const bin = d3.bin()
        .domain(globalExtent)
        .thresholds(20);

    groupedData.forEach((values, mutant) => {
        const effects = values.map(d => d.effect);
        allBinnedData.value.set(mutant, bin(effects));
    });
    console.log(allBinnedData.value);
}

function initializeChart() {
    const svg = d3.select("#svgContainer");

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

    // Create persistent scales
    xScale = d3.scaleLinear()
        .domain([-4, 0.5])
        .range([marginLeft, width - marginRight]);

    yScale = d3.scaleLinear()
        .range([height - marginBottom, marginTop]);

    // Create persistent groups for bars
    barsGroup = svgElement.append('g')
        .attr('class', 'bars');

    // Add x-axis (static)
    svgElement.append("g")
        .attr("transform", `translate(0,${height - marginBottom})`)
        .call(d3.axisBottom(xScale).ticks(4).tickSizeOuter(0))
        .call((g) => g.select(".domain").remove())
        .call((g) => g.append("text")
            .attr("x", width / 2)
            .attr("y", marginBottom - 10)
            .attr("fill", "currentColor")
            .attr("text-anchor", "middle")
            .attr("font-weight", "bold")
            .attr("font-size", "12px")
            .text("Cell Entry Effect"));

    // Create y-axis group (will be updated)
    yAxisGroup = svgElement.append("g")
        .attr("transform", `translate(${marginLeft},0)`);

    yAxisGroup.append("text")
        .attr("x", 0 - marginLeft)
        .attr("y", marginTop - 10)
        .attr("fill", "currentColor")
        .attr("text-anchor", "start")
        .attr("font-weight", "bold")
        .attr("font-size", "12px")
        .text("Count");

    // Create label for current amino acid
    labelText = svgElement.append("text")
        .attr("x", width - marginRight - 10)
        .attr("y", marginTop + 10)
        .attr("text-anchor", "end")
        .attr("font-size", "24px")
        .attr("font-weight", "bold");
}

function updateHistogram(animate = true) {
    const selectedBins = allBinnedData.value.get(selectedAminoAcid.value); // Get binned data for selected amino acid

    if (!selectedBins || !svgElement) return; // Safety check

    const maxCount = d3.max(selectedBins, d => d.length); // Maximum count for y-scale
    const duration = animate ? animationDuration.value : 0; // Transition duration

    // Update y-scale domain
    yScale.domain([0, maxCount * 1.1]).nice();

    // Update y-axis with transition
    const yAxis = d3.axisLeft(yScale)
        .ticks(4)
        .tickFormat(d3.format("d"));

    yAxisGroup
        .transition()
        .duration(duration)
        .call(yAxis)
        .on("start", function () {
            d3.select(this).select(".domain").remove();
        });

    // Data join for bars
    const bars = barsGroup.selectAll("rect")
        .data(selectedBins, (d, i) => i); // Use index as key for stable transitions

    // Exit old bars
    bars.exit()
        .transition()
        .duration(duration / 2)
        .attr("y", yScale(0))
        .attr("height", 0)
        .remove();

    // Enter new bars
    const barsEnter = bars.enter()
        .append("rect")
        .attr("x", d => xScale(d.x0) + 1)
        .attr("width", d => Math.max(0, xScale(d.x1) - xScale(d.x0) - 1))
        .attr("y", yScale(0))
        .attr("height", 0)
        .attr("opacity", 0.7);

    // Update all bars (enter + update)
    bars.merge(barsEnter)
        .transition()
        .duration(duration)
        .attr("fill", aminoAcidColorScale(selectedAminoAcid.value))
        .attr("x", d => xScale(d.x0) + 1)
        .attr("width", d => Math.max(0, xScale(d.x1) - xScale(d.x0) - 1))
        .attr("y", d => yScale(d.length))
        .attr("height", d => yScale(0) - yScale(d.length));

    // Update label with transition
    labelText
        .transition()
        .duration(duration / 2)
        .style("opacity", 0)
        .transition()
        .duration(duration / 2)
        .style("opacity", 1)
        .attr("fill", aminoAcidColorScale(selectedAminoAcid.value))
        .text(`${selectedAminoAcid.value}`);
}


function startAnimation() {
    isAnimating.value = true;
    let currentIndex = uniqueAminoAcids.value.indexOf(selectedAminoAcid.value);

    animationInterval = setInterval(() => {
        currentIndex = (currentIndex + 1) % uniqueAminoAcids.value.length;
        selectedAminoAcid.value = uniqueAminoAcids.value[currentIndex];
        updateHistogram(true);
    }, animationDuration.value + 750); // Add pause between transitions
}

function stopAnimation() {
    isAnimating.value = false;
    if (animationInterval) {
        clearInterval(animationInterval);
        animationInterval = null;
    }
}
</script>