Published
Edited
Nov 23, 2020
Insert cell
Insert cell
Insert cell
Insert cell
chart = {
var container = d3.create('div');

var charts = [];
measures.forEach((value, key) => {
var measure = getMeasure(key).sort((a, b) => a.AVISITN - b.AVISITN);
var measureStatistics = visitStatistics[key];
var ids = getIds(measure);
var median = getMedian(measure);
var x = getX(measure);
var y = getY(measure);
var colorScaleLine = getColorScaleLine(y);

var svg = container
.append('svg')
.attr('viewBox', [0, 0, width, height])
.datum({ x, y });

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

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

var gy = 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(key);
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('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(getValue(d, 0, 'ADY')))
.attr('cy', d => y(getValue(d, 0, 'AVAL')))
.attr('r', 1)
.attr('fill', colorScaleLine(0))
.attr('fill-opacity', .25)
.attr('stroke', colorScaleLine(0))
.attr('stroke-opacity', .5)
.style('display', d => (getDatum(d, 0) ? null : 'none'));

var statLine = g
.selectAll('line.stat')
.data(d3.pairs(measureStatistics))
.join('line')
.classed('stat', true)
.attr('x1', (d, i) => x(getStatistic(ids, i, 'ADY', 'mean')))
.attr('x2', (d, i) => x(getStatistic(ids, i, 'ADY', 'mean')))
.attr('y1', d => y(d[0]))
.attr('y2', d => y(d[0]))
.attr('stroke', d => colorScaleLine(d[1] - d[0]))
.attr('stroke-width', 3);

var statMark = g
.append('circle')
.datum(measureStatistics)
.attr('cx', x(getStatistic(ids, 0, 'ADY', 'mean')))
.attr('cy', d => y(d[0]))
.attr('r', 4)
.attr('fill', colorScaleLine(0))
.attr('fill-opacity', 1)
.attr('stroke', 'black') //colorScaleLine(0))
.attr('stroke-opacity', 1);

charts.push({
measure,
ids,
median,
x,
y,
colorScaleLine,
svg,
circles,
lines,
statLine,
statMark,
visitText,
g
});
});

let visitIndex = 1;
d3.interval(() => {
// reset visit index
if (visitIndex >= visits.length) {
//setTimeout(() => {
visitIndex = 0;
container.selectAll('circle.clone').remove();
container
.selectAll('line.stat')
.transition()
.duration(1000)
.delay(500)
.attr('x2', function() {
return this.getAttribute('x1');
})
.attr('y2', function() {
return this.getAttribute('y1');
});
//}, 5000);
} // else {
var visit1 = visits[visitIndex - 1];
var visit2 = visits[visitIndex];

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

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

// Lines should move from the previous visit to the next visit and be colored by the size of the change.
updateLines(chart, visit2);

// Circles should move from the previous visit to the next visit.
updateCircles(chart, visit2);

// TODO: draw each line from the previous visit to the current visit, i.e. the first element in the datum to the second element in the datum, from the median study day of the previous visit to the median study day of the current visit
chart.statLine
.filter((d, i) => i === visitIndex - 1)
.transition()
.duration(1000)
.delay(500)
.attr('x2', (d, i) =>
chart.x(getStatistic(chart.ids, visitIndex, 'ADY', 'mean'))
)
.attr('y2', d => chart.y(d[1]));

chart.statMark
.transition()
.ease(d3.easeQuad)
.duration(1000)
.delay(1000)
.attr('cx', chart.x(getStatistic(chart.ids, visitIndex, 'ADY', 'mean')))
.attr('cy', d => chart.y(d[visitIndex]))
.attr('fill', d => chart.colorScaleLine(d[visitIndex] - d[0]));
// .attr('stroke', d => chart.colorScaleLine(d[visitIndex] - d[0]));

chart.statMark.clone().classed('clone', true);
});

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

return container.node();
}
Insert cell
updateCircles = (chart, visit, visitIndex) => {
chart.circles.each(function(data) {
const d = data[1].find(di => di.AVISIT === visit);
const baseline = data[1].find(d => !!d.AVAL);

const circle = d3.select(this);

// Hide points that are missing
if (visitIndex === 0 && !d) circle.style('display', 'none');
else if (circle.style('display') === 'none' && !!d)
circle.attr('cx', chart.x(d.ADY)).attr('cy', chart.y(d.AVAL));

const transition = circle
.transition()
.ease(d3.easeQuad)
.duration(1000)
.delay(500);

if (d)
transition
.attr('cx', chart.x(d.ADY))
.attr('cy', chart.y(d.AVAL))
.attr(
'fill',
baseline ? chart.colorScaleLine(d.AVAL - baseline.AVAL) : 0
)
.attr('fill-opacity', .25)
.attr(
'stroke',
baseline ? chart.colorScaleLine(d.AVAL - baseline.AVAL) : 0
)
.style('display', null);
else transition.attr('fill-opacity', 0.25).attr('stroke-opactiy', 0.5);
});
}
Insert cell
updateLines = (chart, visit) => {
chart.lines.each(function(data) {
const d2 = data[1].find(di => di.AVISIT === visit);
const index = data[1].findIndex(di => di.AVISIT === visit);
const previousVisits = data[1].slice(0, index);
const d1 = previousVisits.pop();

const line = d3.select(this);
if (d1 && d2)
line
.attr('x1', chart.x(d1.ADY))
.attr('y1', chart.y(d1.AVAL))
.attr('x2', chart.x(d1.ADY))
.attr('y2', chart.y(d1.AVAL))
.attr('stroke', chart.colorScaleLine(d2.AVAL - d1.AVAL))
.attr('stroke-opacity', .25)
.transition()
.ease(d3.easeQuad)
.duration(1000)
.attr('x2', chart.x(d2.ADY))
.attr('y2', chart.y(d2.AVAL));
else
line
.transition()
.duration(1000)
.attr('stroke-opacity', 0);
});
}
Insert cell
getDatum = (d, visitIndex) => d[1].find(di => di.AVISIT === visits[visitIndex]);
Insert cell
getValue = (d, visitIndex, prop) => {
var datum = getDatum(d, visitIndex);
return datum ? datum[prop] : null;
};
Insert cell
getStatistic = function(data, visitIndex, prop, statistic) {
var values = data.map(d => getValue(d, visitIndex, prop));
var stat = d3[statistic](values);
return stat;
};
Insert cell
getX = measure => d3.scaleLinear()
.domain(d3.extent(measure, d => d.ADY))
.rangeRound([margin.left, width - margin.right])
Insert cell
xAxis = g => {
return g
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(g.datum().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
getY = measure => 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(g.datum().y))
.call(g => g.select(".domain").remove())
.call(g =>
g
.append("g")
.attr("stroke", "currentColor")
.attr("stroke-opacity", 0.1)
.selectAll("line")
.data(g.datum().y.ticks())
.join("line")
.attr("y1", d => 0.5 + g.datum().y(d))
.attr("y2", d => 0.5 + g.datum().y(d))
.attr("x1", 0)
.attr("x2", width - margin.right - margin.left)
);

return g;
}
Insert cell
getColorScaleLine = y =>
d3
.scaleSequential()
.domain([
-Math.abs(y.domain()[1] - y.domain()[0]),
Math.abs(y.domain()[1] - y.domain()[0])
])
.interpolator(d3.interpolateViridis)
Insert cell
colorScaleCircle = d3.scaleOrdinal(d3.schemeCategory10).domain([...new Set(data.map(d => d.ARM)).values()])
Insert cell
getMedian = measure => d3.median(measure, d => d.AVAL)
Insert cell
getIds = measure => d3.groups(measure, d => d.USUBJID)
Insert cell
getMeasure = key => measures.get(key)
Insert cell
visitStatistics = {
var statistics = {};
for (var [key, value] of measures.entries()) {
statistics[key] = visits.map(visit =>
d3.mean(value.filter(d => d.AVISIT === visit), d => d.AVAL)
);
}
return statistics;
}
Insert cell
measures = d3.group(
data.filter(
d =>
~d.AVAL &&
d.AVAL !== null &&
visits.includes(d.AVISIT) &&
/count/i.test(d.PARAM)
),
d => d.PARAM
)
Insert cell
visits = [...new Set(data.map(d => d.AVISIT + '|' + d.AVISITN)).values()]
.map(value => value.split('|'))
.filter(value => !(value[1] % 1))
.sort((a, b) => a[1] - b[1])
.map(value => value[0])
Insert cell
data = {
return await d3.csv(
'https://raw.githubusercontent.com/samussiah/data-library/master/data/clinical-trials/adam/adlb-trend.csv',
d3.autoType
);
}
Insert cell
margin = ({top: 25, right: 10, bottom: 40, left: 60});
Insert cell
height = (width * 2) / 3 / 2
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
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