Antibody Escape Line Plot
Aggregate antibody escape data across all antibodies. Hover over lines to see details.
Code
vue
<template>
<svg ref="svgContainer" id="svgContainer"></svg>
</template>
<script setup>
import { onMounted } from 'vue';
import * as d3 from 'd3';
import { withBase } from 'vitepress';
// Dimensions and margins
const width = 800;
const height = 300;
const marginTop = 40;
const marginRight = 40;
const marginBottom = 60;
const marginLeft = 60;
let processedData = null;
let siteExtent = null;
let escapeExtent = null;
const dataFile = withBase('/data/all_antibodies_escape_filtered_sum.csv');
const fetchData = async () => {
try {
const rawData = await d3.csv(dataFile, (d) => ({
site: +d.site,
wildtype: d.wildtype,
sum_escape: +d.sum_escape,
antibody: d.antibody,
}));
return rawData;
} catch (error) {
console.error('Error loading CSV data:', error);
}
};
const processData = (rawData) => {
if (!rawData || rawData.length === 0) return [];
const antibodyMap = new Map();
// Pre-calculate extents while grouping
let minSite = Infinity, maxSite = -Infinity;
let maxEscape = -Infinity;
rawData.forEach(d => {
const { antibody, site, sum_escape } = d;
// Update extents
if (site < minSite) minSite = site;
if (site > maxSite) maxSite = site;
if (sum_escape > maxEscape) maxEscape = sum_escape;
// Group by antibody
if (!antibodyMap.has(antibody)) {
antibodyMap.set(antibody, []);
}
antibodyMap.get(antibody).push({
site,
escape: sum_escape
});
});
// Store extents for later use
siteExtent = [minSite, maxSite];
escapeExtent = [0, maxEscape];
// Convert Map to array and sort sites within each antibody
const result = Array.from(antibodyMap.entries()).map(([antibody, sites]) => ({
antibody,
sites: sites.sort((a, b) => a.site - b.site)
}));
return result;
};
// Run the code when the component is mounted
onMounted(async () => {
const data = await fetchData();
processedData = processData(data);
makeColorChart(processedData);
});
function makeColorChart(processedData) {
const colorScale = d3.scaleOrdinal(d3.schemeTableau10);
const xScale = d3.scaleLinear()
.domain(siteExtent)
.range([marginLeft, width - marginRight])
.clamp(true);
const xAxisGenerator = d3.axisBottom()
.scale(xScale)
.tickSizeOuter(0);
const yScale = d3.scaleLinear()
.domain(escapeExtent)
.range([height - marginBottom, marginTop])
.clamp(true);
const yAxisGenerator = d3.axisLeft()
.scale(yScale)
.ticks(4)
.tickSizeOuter(0);
const lineGenerator = d3.line()
.x((d) => xScale(d.site))
.y((d) => yScale(d.escape))
.curve(d3.curveMonotoneX);
const svg = d3.select("#svgContainer")
.attr('viewBox', `0 0 ${width} ${height}`);
const path = svg.append('g')
.attr('fill', 'none')
.attr('stroke-width', 1.5)
.attr('stroke-linejoin', 'round')
.attr('stroke-linecap', 'round')
.selectAll('path')
.data(processedData)
.join('path')
.attr('d', (d) => lineGenerator(d.sites))
.attr('stroke', (d) => colorScale(d.antibody))
.attr('mix-blend-mode', 'multiply');
//x-axis
svg.append('g')
.attr('transform', `translate(0, ${height - marginBottom})`)
.call(xAxisGenerator)
.attr('font-size', '14px')
.call((g) => g.selectAll('.domain').remove())
.call((g) => g.append('text')
.attr('x', width / 2)
.attr('y', marginBottom - 15)
.attr('font-size', '18px')
.attr('font-weight', 'bold')
.attr('fill', 'currentColor')
.attr('text-anchor', 'middle')
.text('Site')
);
//y-axis
svg.append('g')
.attr('transform', `translate(${marginLeft}, 0)`)
.call(yAxisGenerator)
.attr('font-size', '14px')
.call((d) => d.selectAll('.domain').remove())
.call((g) => g.append('text')
.attr('x', -height / 2)
.attr('y', -marginLeft + 15)
.attr('font-size', '18px')
.attr('font-weight', 'bold')
.attr('transform', 'rotate(-90)')
.attr('fill', 'currentColor')
.attr('text-anchor', 'middle')
.text('Total Escape at Site')
);
// Setup tooltip - create flat array of points with their data
const points = processedData.flatMap((d) =>
d.sites.map((s) => ({
x: xScale(s.site),
y: yScale(s.escape),
antibody: d.antibody,
site: s.site,
escape: s.escape
}))
);
const dot = svg.append('g').attr('display', 'none');
dot.append('circle').attr('r', 3).attr('fill', 'currentColor');
dot
.append('text')
.attr('font-size', 12)
.attr('text-anchor', 'middle')
.attr('fill', 'currentColor')
.attr('y', -8);
function pointermoved(event) {
const [xm, ym] = d3.pointer(event);
// Find the closest point
const i = d3.leastIndex(points, (p) => Math.hypot(p.x - xm, p.y - ym));
const point = points[i];
// Highlight the selected line
path
.style('stroke', ({ antibody }) => (antibody === point.antibody ? null : '#ddd'))
.filter(({ antibody }) => antibody === point.antibody)
.raise();
// Position and update the dot
dot.attr('transform', `translate(${point.x},${point.y})`);
dot.select('text').text(`Antibody: ${point.antibody}, Site: ${point.site}, Escape: ${point.escape.toFixed(1)}`);
// Dispatch the input event
svg.property('value', {
antibody: point.antibody,
site: point.site,
escape: point.escape
}).dispatch('input', { bubbles: true });
}
function pointerentered() {
path.style('mix-blend-mode', null).style('stroke', '#ddd');
dot.attr('display', null);
}
function pointerleft() {
path.style('mix-blend-mode', 'multiply').style('stroke', (d) => colorScale(d.antibody));
dot.attr('display', 'none');
svg.node().value = null;
svg.dispatch('input', { bubbles: true });
}
svg
.on('pointerenter', pointerentered)
.on('pointermove', pointermoved)
.on('pointerleave', pointerleft)
.on('touchstart', (event) => event.preventDefault());
}
</script>