Published
Edited
Aug 10, 2020
2 forks
4 stars
Insert cell
md`# Animated Longitudinal Scatter Plot with Transitions`
Insert cell
md`a prototype of an animated, longtitudinal scatter plot using transitions; circles are colored by treatment group, lines are colored by magnitude of change from previous visit`
Insert cell
chart = {
var svg = d3.create('svg').attr('viewBox', [0, 0, width, height]);

var visitText = svg
.append('text')
.attr('x', width / 2)
.attr('y', margin.top)
.attr('text-anchor', 'center')
.text(`${visits[0]} (n=${ids.length})`);

svg
.append("g")
.classed('axis axis--x', true)
.call(xAxis);

svg
.append("g")
.classed('axis axis--y', true)
.call(yAxis);
svg
.append("text")
.attr('transform', `rotate(-90 10 ${(height - margin.top) / 2})`)
.attr("x", 10)
.attr("y", height / 2)
.attr("fill", "currentColor")
.attr("text-anchor", "middle")
.text("Result");
var g = svg.append('g').classed('canvas', true);

var lines = g
.append('g')
.classed('lines canvas__lines', true)
.selectAll('line')
.data(ids, d => d[0])
.join('line')
.attr('x1', d => x(d[1][0].ADY))
.attr('y1', d => y(d[1][0].AVAL))
.attr('stroke', d => colorScaleLine(Math.abs(d[1][0].AVAL - median) * 2))
.attr('stroke-opacity', 0);

var circles = g
.append('g')
.classed('circles canvas__circles', true)
.selectAll('circle')
.data(ids, d => d[0])
.join('circle')
.attr('cx', d => x(d[1][0].ADY))
.attr('cy', d => y(d[1][0].AVAL))
.attr('r', 5)
.attr('fill', 'steelblue')
.attr('fill-opacity', .75)
.attr('stroke', 'steelblue')
.attr('stroke-opacity', 1);

let visitIndex = 1;
d3.interval(() => {
// reset visit index
if (visitIndex >= visits.length) visitIndex = 0;
var visit1 = visits[visitIndex - 1];
var visit2 = visits[visitIndex];

// subset on visit
var subset = measure.filter(d => d.AVISIT === visit2);

// update visit text
visitText
.transition()
.delay(500)
.text(`${visit2} (n=${subset.length})`);

// Circles should move from the previous visit to the next visit.
circles.each(function(data) {
const d = data[1].find(di => di.AVISIT === visit2);
const circle = d3
.select(this)
.transition()
.ease(d3.easeQuad)
.duration(1000)
.delay(500);
if (d)
circle
.attr('cx', x(d.ADY))
.attr('cy', y(d.AVAL))
.attr('fill-opacity', .75);
else circle.attr('fill-opacity', 0.25).attr('stroke-opacity', 0.5);
});

// Lines should move from the previous visit to the next visit and be colored by the size of the change.
lines.each(function(data) {
const d1 = data[1].find(di => di.AVISIT === visit1);
const d2 = data[1].find(di => di.AVISIT === visit2);
const line = d3.select(this);
if (d1 && d2)
line
.attr('x1', x(d1.ADY))
.attr('y1', y(d1.AVAL))
.attr('x2', x(d1.ADY))
.attr('y2', y(d1.AVAL))
.attr('stroke', colorScaleLine(Math.abs(d2.AVAL - median) * 2))
.attr('stroke-opacity', .5)
.transition()
.ease(d3.easeQuad)
.duration(1000)
.attr('x2', x(d2.ADY))
.attr('y2', y(d2.AVAL));
else
line
.transition()
.duration(1000)
.attr('stroke-opacity', 0);
});

// increment visit
visitIndex++;
}, 2000);

return svg.node();
}
Insert cell
html`<style>
.axis path,
.axis line {
fill: none;
stroke: #000;
shape-rendering: crispEdges;
}

.axis text {
font: 10px sans-serif;
}

.cells path {
fill: none;
stroke: #aaa;
pointer-events: all;
}

.cells :hover circle {
fill: red;
}
</style`
Insert cell
measure = measures.get('Platelet count (x 109/L)').filter(d => !(d.AVISITN%1))
Insert cell
ids = d3.groups(measure, d => d.USUBJID)
Insert cell
median = d3.median(measure, d => d.AVAL)
Insert cell
visits = [...new Set(measure.map(d => d.AVISIT + '|' + d.AVISITN)).values()].sort((a,b) => a.split('|')[1] - b.split('|')[1]).map(value => value.split('|')[0])
Insert cell
measures = d3.group(data.filter(d => ~d.AVAL), d => d.PARAM)
Insert cell
data = {
return (await d3.csv('https://raw.githubusercontent.com/RhoInc/data-library/master/data/clinical-trials/adam/adlb.csv', d3.autoType));
}
Insert cell
x = d3.scaleLinear()
.domain(d3.extent(measure, d => d.ADY))
.rangeRound([margin.left, width - margin.right])
Insert cell
xAxis = g => g
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).ticks(width / 80))
.call(g => g.append("text")
.attr("x", (width - margin.left)/2)
.attr("y", margin.bottom/2 + 4)
.attr("fill", "currentColor")
.attr("text-anchor", "center")
.attr('alignment-baseline', 'hanging')
.text("Study Day"))
Insert cell
y = d3.scaleLinear()
.domain(d3.extent(measure, d => d.AVAL))//.nice()
.rangeRound([height - margin.bottom, margin.top])
Insert cell
yAxis = g => {
g
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y))
.call(g => g.select(".domain").remove())
.call(g => g.append("g")
.attr("stroke", "currentColor")
.attr("stroke-opacity", 0.1)
.selectAll("line")
.data(y.ticks())
.join("line")
.attr("y1", d => 0.5 + y(d))
.attr("y2", d => 0.5 + y(d))
.attr("x1", 0)
.attr("x2", width - margin.right - margin.left))

return g;
}
Insert cell
colorScaleLine = d3.scaleSequential().domain(y.domain()).interpolator(d3.interpolateViridis)
Insert cell
colorScaleCircle = d3.scaleOrdinal(d3.schemeCategory10).domain([...new Set(data.map(d => d.ARM)).values()])
Insert cell
height = width * 2/3
Insert cell
margin = ({top: 25, right: 10, bottom: 40, left: 60});
Insert cell
d3 = require('d3@5', 'd3-array@2')
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more