Static Cell Entry Heatmap
Focus on specific sites or change the visualization parameters.
Heatmap Options
Code
vue
<template>
<div class="flex flex-row">
<section class="max-w-[250px] my-4 flex-col">
<sidebar class='text-sm'>
<template #title>Heatmap Options</template>
<template #content>
<!-- Add the sidebar content here -->
<label class="label-select">Enter Specific Sites:</label>
<input class="form-input" @input="siteInputValue = $event.target.value"
placeholder="e.g., 30-35,42" />
<button class="btn-primary" @click="selectedSites = parseSites(siteInputValue)">Update
Sites</button>
<label class="label-select">Select Color Scale:</label>
<select class="form-select" v-model="selectedColorScale">
<option value="RdBu">Red-Blue</option>
<option value="BrBG">Brown-Green</option>
<option value="PRGn">Purple-Green</option>
<option value="PiYG">Pink-Yellow-Green</option>
<option value="PuOr">Purple-Orange</option>
<option value="Spectral">Spectral</option>
</select>
<label class="label-select">Select minimum times seen:</label>
<select class="form-select" v-model="minTimesSeen">
<option v-for="option in timesSeenOptions" :value="option" :key="option">{{ option }}</option>
</select>
<label class="label-select">Select Number of Rows:</label>
<select class="form-select" v-model="rows">
<option v-for="option in rowOptions" :value="option" :key="option">{{ option }}</option>
</select>
<label class="label-select">Select WT Amino Acid:</label>
<select class="form-select" v-model="selectedAminoAcid">
<option value="">All</option>
<option v-for="acid in amino_acids" :value="acid" :key="acid">{{ acid }}</option>
</select>
<label class="label-select">Select Effect Filter:</label>
<select class="form-select" v-model="selectedEffectFilter">
<option value="">All</option>
<option value="negative">Highly constrained sites</option>
</select>
</template>
</sidebar>
</section>
<main class="px-2">
<svg id="svgContainer" ref="svgContainer"></svg>
<svg id="legend" viewBox="0 0 500 60" style="width: 400px; height: 60px; display: block; "></svg>
</main>
<Tooltip ref="tooltip" :data="tooltipData" />
</div>
</template>
<script setup>
import { ref, watch, computed, shallowRef, onMounted } from 'vue';
import * as d3 from 'd3';
import sidebar from '/main/components/sidebar.vue';
import Tooltip from '/main/components/simpleTooltip.vue';
import { Legend } from '/main/utilities/legend.js';
import { withBase } from 'vitepress';
// DEFINE REACTIVE VARIABLES
const processedData = shallowRef(null);
const tooltip = ref(null);
const tooltipData = ref([]);
// sidebar inputs
const siteInputValue = shallowRef('');
const selectedSites = shallowRef([]);
const selectedColorScale = shallowRef('RdBu');
const rows = shallowRef(4);
const selectedAminoAcid = shallowRef('');
const selectedEffectFilter = shallowRef('');
const minTimesSeen = shallowRef(2);
// sidebar dropdown options
const rowOptions = [1, 2, 3, 4, 5, 6];
const timesSeenOptions = [2, 3, 4, 5, 6,7,8,9,10];
// graphing defaults
const minColor = -4;
const maxColor = 2;
const margin = { top: 20, right: 20, bottom: 50, left: 50 }; // margin for the SVG
const rowPadding = 35; // amount of padding between the rows
const squareSize = 10; // size of each square in the heatmap
// normal variables
const amino_acids = [
'R',
'K',
'H',
'D',
'E',
'Q',
'N',
'S',
'T',
'Y',
'W',
'F',
'A',
'I',
'L',
'M',
'V',
'G',
'P',
'C',
];
// input data file
const dataFile = withBase('/data/Nipah_F_func_effects_filtered.csv');
// Data storage variables
let data = [];
let dataLookup = {};
let wildtypeLookup = {};
let siteEffectsMap = new Map();
const fetchData = async () => {
try {
data = await d3.csv(dataFile, (d) => ({
site: +d.site,
wildtype: d.wildtype,
mutant: d.mutant,
effect: +d.effect,
times_seen: +d.times_seen,
}));
return data;
} catch (error) {
console.error('Error loading CSV data:', error);
}
};
// Run the code when the component is mounted
onMounted(async () => {
data = await fetchData();
processedData.value = data;
processData(data)
});
const processData = (data) => {
dataLookup = {};
wildtypeLookup = {};
siteEffectsMap.clear();
data.forEach(d => {
const site = +d.site;
const effect = +d.effect;
// Build lookup tables
dataLookup[`${site}-${d.mutant}`] = d;
wildtypeLookup[site] = d.wildtype;
// Track effects per site for filtering
if (!siteEffectsMap.has(site)) {
siteEffectsMap.set(site, []);
}
siteEffectsMap.get(site).push(effect);
});
};
// Function to parse sites entered by the user
function parseSites(input) {
const ranges = input.split(',').map((s) => s.trim());
let sites = [];
ranges.forEach((range) => {
if (range.includes('-')) {
const [start, end] = range.split('-').map(Number);
sites = sites.concat(Array.from({ length: end - start + 1 }, (_, i) => start + i));
} else {
sites.push(Number(range));
}
});
return sites;
}
// Function to update sites based on user input
const filteredSites = computed(() => {
let sites = Array.from(siteEffectsMap.keys()).sort((a, b) => a - b);
// Filter by selected wildtype amino acid
if (selectedAminoAcid.value) {
sites = sites.filter(site => wildtypeLookup[site] === selectedAminoAcid.value);
}
// Filter sites that are mainly negative effects for all mutations
if (selectedEffectFilter.value === 'negative') {
sites = sites.filter(site => {
const effects = siteEffectsMap.get(site)
return effects.every(effect => effect <= -0.5);
});
}
// Only show sites entered in box
if (selectedSites.value.length > 0) {
sites = sites.filter(site => selectedSites.value.includes(site));
}
return sites;
});
const siteRows = computed(() => {
const sites = filteredSites.value;
if (sites.length === 0) return [];
// Single row for filtered views
if (selectedAminoAcid.value || selectedEffectFilter.value || selectedSites.value.length > 0) {
return [sites];
}
// Multiple rows for full view
const sitesPerRow = Math.ceil(sites.length / rows.value);
return Array.from({ length: rows.value }, (_, i) =>
sites.slice(i * sitesPerRow, (i + 1) * sitesPerRow)
);
});
// Color scale
const colorScaleMap = {
'RdBu': d3.interpolateRdBu,
'BrBG': d3.interpolateBrBG,
'PRGn': d3.interpolatePRGn,
'PiYG': d3.interpolatePiYG,
'PuOr': d3.interpolatePuOr,
'Spectral': d3.interpolateSpectral
};
// Vue function to update the heatmap when the data or settings change
watch([processedData, rows, selectedSites, selectedAminoAcid, selectedColorScale, selectedEffectFilter, minTimesSeen], () => {
updateHeatmap();
});
// Main function to draw the heatmap
function updateHeatmap() {
// calculate some graphing parameters based on the data
const maxSitesInRow = Math.max(...siteRows.value.map((row) => row.length));
const innerWidth = squareSize * maxSitesInRow;
const totalRows = siteRows.value.length;
const height = squareSize * amino_acids.length * totalRows + margin.top + margin.bottom + (totalRows - 1) * rowPadding + margin.bottom;
const width = innerWidth + margin.left + margin.right;
const innerHeight = height - margin.top - margin.bottom;
const svg = d3.select("#svgContainer");
svg.selectAll('*').remove();
svg.attr('width', width).attr('height', height);
const g = svg.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// Define scales
const xScale = d3.scaleBand()
.domain(Array.from({ length: maxSitesInRow }, (_, i) => i))
.range([0, innerWidth]);
const yScale = d3.scaleBand()
.domain(amino_acids)
.range([0, squareSize * amino_acids.length])
const color = d3.scaleDiverging(colorScaleMap[selectedColorScale.value])
.domain([minColor, 0, maxColor]);
//Plot heatmap squares by row for wrapping
siteRows.value.forEach((siteRow, rowIndex) => {
const rowOffset = (yScale.range()[1] + rowPadding) * rowIndex;
const rowG = g.append('g')
.attr('transform', `translate(0,${rowOffset})`);
// Create data for all possible mutants/sites in this row
const rectData = [];
siteRow.forEach((site, siteIndex) => {
amino_acids.forEach(mutant => {
rectData.push({ site, mutant, siteIndex });
});
});
// Draw rectangles
rowG.selectAll('rect')
.data(rectData)
.join('rect')
.attr('x', d => xScale(d.siteIndex))
.attr('y', d => yScale(d.mutant))
.attr('width', xScale.bandwidth())
.attr('height', yScale.bandwidth())
.attr('stroke', 'white')
.attr('stroke-width', 1)
.attr('fill', d => {
const key = `${d.site}-${d.mutant}`;
const dataPoint = dataLookup[key];
if (dataPoint) {
return (dataPoint.times_seen < minTimesSeen.value) ? 'lightgray' : color(dataPoint.effect);
}
return wildtypeLookup[d.site] === d.mutant ? 'white' : 'lightgray';
})
.on('mouseover', function (event, d) {
const key = `${d.site}-${d.mutant}`;
const dataPoint = dataLookup[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: 'Effect', value: dataPoint.effect },
{ label: 'Times Seen', value: dataPoint.times_seen }
];
}
else {
tooltip.value.showTooltip(event);
tooltipData.value = [
{ label: 'Wildtype', value: wildtypeLookup[d.site] },
{ label: 'Site', value: d.site },
{ label: 'Mutant', value: d.mutant },
{ label: 'Effect', value: 'N/A' },
{ label: 'Times Seen', value: 0 }
];
}
})
.on('mousemove', (event) => {
if (tooltip.value?.updatePosition) {
tooltip.value.updatePosition(event);
}
})
.on('mouseout', () => {
tooltip.value.hideTooltip();
tooltipData.value = [];
});
// Draw wildtype X markers
rowG.selectAll('.wildtype')
.data(siteRow)
.join('text')
.attr('class', 'wildtype')
.attr('x', (d, i) => xScale(i) + xScale.bandwidth() / 2)
.attr('y', d => yScale(wildtypeLookup[d]) + yScale.bandwidth() / 2 + 3)
.attr('text-anchor', 'middle')
.attr('font-size', '8px')
.attr('pointer-events', 'none')
.text('X');
// Add the site numbers to the x-axis, only plotting every 10 sites
const xAxis = d3.axisBottom(xScale).tickSizeOuter(0);
if (selectedSites.value.length > 0 || selectedAminoAcid.value || selectedEffectFilter.value) {
xAxis.tickFormat((d) => siteRow[d]);
} else {
xAxis.tickFormat((d, i) => (i % 10 === 0 ? siteRow[d] : ''));
}
// ADD THE X AND Y AXES
// Add the site numbers to the x-axis
rowG.append('g')
.attr('transform', `translate(0,${yScale.range()[1]})`)
.call(xAxis)
.call(g => g.select('.domain').remove()) // Remove the axis line
.selectAll('text')
.attr('transform', 'rotate(-90)')
.attr('text-anchor', 'end')
.attr('dx', '-8px')
.attr('dy', '-5px')
.attr('font-size', '10px');
// Y axis
rowG.append('g')
.call(d3.axisLeft(yScale).tickSizeOuter(0))
.call(g => g.select('.domain').remove())
.attr('font-size', '10px');
});
// Add the row title
g.append('text')
.attr('class', 'axis-title-x')
.attr('x', innerWidth / 2)
.attr('y', innerHeight)
.attr('font-size', '14px')
.attr('font-weight', 'bold')
.attr('text-anchor', 'middle')
.attr('text-align', 'center')
.attr('fill', 'currentColor')
.text('Site');
// Add the column title
g.append('text')
.attr('class', 'axis-title-y')
.attr('x', -innerHeight / 2 + 20)
.attr('y', 0 - 30)
.attr('font-size', '14px')
.attr('font-weight', 'bold')
.attr('text-anchor', 'middle')
.attr('transform', 'rotate(-90)')
.attr('fill', 'currentColor')
.text('Amino Acid');
d3.select('#legend').selectAll('*').remove();
Legend(d3.scaleDiverging([minColor, 0, maxColor], colorScaleMap[selectedColorScale.value]).clamp(true), {
svgRef: '#legend',
title: 'Cell Entry',
width: 100,
tickValues: [minColor, 0, maxColor],
xcoord: margin.left,
ycoord: -10,
fontSize: 12,
});
}
</script>