Effects of Nipah F Mutations on Cell Entry

Heatmap is zoomable and draggable inside container.

The color of the cell indicates the effect of the mutation on cell entry, with red indicating a strong negative effect. The unmutated residue is indicated with a black 'X', and missing residues are shown in gray. Hover over the heatmap squares to get more information.

Code

vue
<template>
    <svg ref="svgContainer" id="svgContainerZoomable"></svg>
    <svg id="legend" viewBox="0 0 500 20" style="width: 400px; height: 60px; display: block; "></svg>
    <Tooltip ref="tooltip" :data="tooltipData" />
</template>

<script setup>
import { shallowRef, onUnmounted } from 'vue';
import * as d3 from 'd3';
import { Legend } from '/main/utilities/legend.js';
import Tooltip from '/main/components/simpleTooltip.vue';
import { withBase } from 'vitepress';

// Reactive references
const tooltip = shallowRef(null);
const tooltipData = shallowRef([]);

// Constant variables
const marginTop = 20;
const marginRight = 20;
const marginBottom = 50;
const marginLeft = 50;
const rowPadding = 30;
const rows = 5;
const squareSize = 10;
const colorScheme = 'interpolateRdBu';
const colorDomain = { min: -4, max: 2 };
const aminoAcids = ['R', 'K', 'H', 'D', 'E', 'Q', 'N', 'S', 'T', 'Y', 'W', 'F', 'A', 'I', 'L', 'M', 'V', 'G', 'P', 'C'];
const xAxisTickInterval = 10;

// Data
const dataFile = withBase('/data/Nipah_F_func_effects_filtered.csv');
let svg = null;
let zoom = null;

let dataLookup = new Map();
let wildtypeLookup = new Map();
let sites = new Set();
let siteRows = [];
let maxSitesInRow;

const fetchData = async () => {
    try {
        const data = await d3.csv(dataFile, d => ({
            site: +d.site,
            wildtype: d.wildtype,
            mutant: d.mutant,
            effect: +d.effect,
            times_seen: +d.times_seen
        }));

        data.forEach(d => {
            const site = +d.site;
            const key = `${site}-${d.mutant}`;

            dataLookup.set(key, {
                site,
                wildtype: d.wildtype,
                mutant: d.mutant,
                effect: d.effect,
                times_seen: d.times_seen
            });

            wildtypeLookup.set(site, d.wildtype);
            sites.add(site);
        });
        
        // Calculate row distribution
        const sortedSites = Array.from(sites).sort((a, b) => a - b); // Sort sites numerically
        maxSitesInRow = Math.ceil(sortedSites.length / rows); // Calculate max sites per row
        
        siteRows = Array.from({ length: rows }, (_, i) =>
            sortedSites.slice(i * maxSitesInRow, (i + 1) * maxSitesInRow)
        ); // Distribute sites into rows
        
        // Render heatmap after data is ready
        renderHeatmap();
    
    } catch (error) {
        console.error('Error loading CSV data:', error);
    }
};
fetchData();



// Render heatmap
const renderHeatmap = () => {
    // Calculate dimensions
    const innerWidth = squareSize * maxSitesInRow;
    const innerHeight = squareSize * aminoAcids.length * rows +
        rowPadding * (rows - 1);
    const width = innerWidth + marginLeft + marginRight;
    const height = innerHeight + marginTop + marginBottom;

    // Scales
    const x = d3.scaleBand()
        .domain(d3.range(maxSitesInRow))
        .range([0, innerWidth])

    const y = d3.scaleBand()
        .domain(aminoAcids)
        .range([0, squareSize * aminoAcids.length]);

    const color = d3.scaleDiverging(d3[colorScheme])
        .domain([colorDomain.min, 0, colorDomain.max]);
    
    // Setup SVG with zoom
    svg = d3.select("#svgContainerZoomable");
    
    // Clear previous content
    svg.selectAll('*').remove();

    // Define zoom behavior
    zoom = d3.zoom()
        .scaleExtent([0.5, 8])
        .on('zoom', (event) => chartGroup.attr('transform', event.transform));
    
    // Set initial viewBox and call zoom
    svg.attr('viewBox', `0 0 ${width} ${height}`)
       .call(zoom);
    
    // Main chart group
    const chartGroup = svg.append('g')
        .attr('transform', `translate(${marginLeft}, ${marginTop})`);

    // Render rows
    siteRows.forEach((siteRow, rowIndex) => {
        const rowGroup = chartGroup.append('g')
            .attr('class', `row-${rowIndex}`)
            .attr('transform', `translate(0, ${(y.range()[1] + rowPadding) * rowIndex})`);
        
        // main heatmap function
        const cellData = [];
        siteRow.forEach((site, siteIndex) => {
            aminoAcids.forEach(mutant => {
                cellData.push({ site, mutant, siteIndex });
            });
        });
    
        // Use enter-update-exit pattern
        const cells = rowGroup.selectAll('.cell')
            .data(cellData, d => `${d.site}-${d.mutant}`);
    
        cells.enter()
            .append('rect')
            .attr('class', 'cell')
            .attr('x', d => x(d.siteIndex))
            .attr('y', d => y(d.mutant))
            .attr('width', x.bandwidth())
            .attr('height', y.bandwidth())
            .attr('stroke', 'white')
            .attr('stroke-width', 1)
            .attr('fill', d => {
                const key = `${d.site}-${d.mutant}`;
                const dataPoint = dataLookup.get(key);
                if (dataPoint) {
                    return color(dataPoint.effect);
                }
                return wildtypeLookup.get(d.site) === d.mutant ? 'white' : 'lightgray';
            })
            .on('mouseover', function (event, d) {
                const key = `${d.site}-${d.mutant}`;
                const dataPoint = dataLookup.get(key);

                if (dataPoint) {
                    tooltip.value.showTooltip(event);
                    tooltipData.value = [
                        { label: 'wildtype', value: dataPoint.wildtype },
                        { label: 'site', value: dataPoint.site },
                        { label: 'mutant', value: dataPoint.mutant },
                        { label: 'cell entry effect', value: dataPoint.effect },
                        { label: 'times seen', value: dataPoint.times_seen }
                    ];
                }
            })
            .on('mousemove', (event) => {
                if (tooltip.value?.updatePosition) {
                    tooltip.value.updatePosition(event);
                }
            })
            .on('mouseout', () => {
                tooltip.value.hideTooltip();
                tooltipData.value = [];
            });
        
       
         // X-axis
        const xAxis = d3.axisBottom(x)
            .tickSizeOuter(0)
            .tickFormat((d) => d % xAxisTickInterval === 0 ? siteRow[d] : '');
    
        // Y-axis
        rowGroup.append('g')
            .attr('class', 'x-axis')
            .attr('transform', `translate(0, ${y.range()[1]})`)
            .call(xAxis)
            .selectAll('text')
            .attr('dx', '-7px')
            .attr('dy', '-5px')
            .attr('transform', 'rotate(-90)')
            .attr('text-anchor', 'end');
    
        rowGroup.append('g')
            .attr('class', 'y-axis')
            .call(d3.axisLeft(y).tickSizeOuter(0));
        
        // plot wildtype markers
        const markers = rowGroup.selectAll('.wildtype-marker')
            .data(siteRow.map((site, index) => ({ site, index, wildtype: wildtypeLookup.get(site) })));
    
        markers.enter()
            .append('text')
            .attr('class', 'wildtype-marker')
            .attr('x', d => x(d.index) + x.bandwidth() / 2)
            .attr('y', d => y(d.wildtype) + y.bandwidth() / 2 + 3)
            .attr('text-anchor', 'middle')
            .attr('font-size', '8px')
            .attr('fill', 'black')
            .attr('pointer-events', 'none')
            .text('X');
        });
    
    // Add axis labels
    chartGroup.append('text')
        .attr('class', 'x-label')
        .attr('x', innerWidth / 2)
        .attr('y', innerHeight + marginBottom - 10)
        .attr('font-size', '18px')
        .attr('font-weight', 'bold')
        .attr('text-anchor', 'middle')
        .attr('fill', 'currentColor')
        .text('Site');
    
    // Y-axis label
    chartGroup.append('text')
        .attr('class', 'y-label')
        .attr('x', -innerHeight / 2)
        .attr('y', -marginLeft)
        .attr('dy', '1em')
        .attr('font-size', '18px')
        .attr('font-weight', 'bold')
        .attr('text-anchor', 'middle')
        .attr('fill', 'currentColor')
        .attr('transform', 'rotate(-90)')
        .text('Amino Acid');

    // Render legend
    d3.select('#legend').selectAll('*').remove();
    Legend(d3.scaleDiverging([-4, 0, 2], d3.interpolateRdBu).clamp(true), {
        svgRef: '#legend',
        title: 'Cell Entry',
        width: 125,
        tickValues: [-4, 0, 2],
        xcoord: 20,
        ycoord: 0,
        fontSize: 14,

    });
};


onUnmounted(() => {
    // Clean up D3 elements and event listeners
    if (svg) {
        svg.selectAll('*').remove();
        svg.on('.zoom', null);
    }
});
</script>