Each bar in the top chart represents an individual's time of entry and exit from a study. The color of the bar indicates which type of exit they had. The bottom chart is a Kaplan-Meier survival curve representing the "survival" of individuals at a given time point. E.g. If the K-M curve = 0.7 at a time of 6 years then 70% of individuals have yet to have the event of study at that time (have survived).
Drag the grey bar across the x-axis to regenerate the K-M curve for the individuals who entered the study past the filter point.
The fact that the survival curve drops to 0 almost immediately when considering all the data illustrates a pitfall of non-parametric methods for generating a survival curve when you have left truncated data. By filtering the data that the curve is generated with, we are generating a conditional survival curve, or he survival curve for an individual given that they have survived to time t.
Data Source
Klein and Moeschberger (1997) Survival Analysis Techniques for Censored and truncated data, Springer. Hyde Biometrika (1977), 225-230.
xxxxxxxxxx
<meta charset="utf-8">
<style>
.axis path,
.axis line {
fill: none;
stroke: #000;
shape-rendering: crispEdges;
}
svg text{
font-family:optima;
}
svg text .title{
font-size: 2em;
}
.lifespan .outside_brush {
fill-opacity: 0.1
}
.line {
fill: none;
stroke: steelblue;
stroke-width: 2px;
}
</style>
<body>
<div id = "chart"></div>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://d3js.org/d3-selection-multi.v1.min.js"></script>
<script>
//load survival data in.
d3.csv("data.csv", function(raw_data){
//Data cleaning:
var data = raw_data.map(function(d){
var new_d = {}
new_d.age_entry_year = +d.ageentry/12; //patient start
new_d.age_year = +d.age/12; //patient end
new_d.death = d.death == "1" ? true : false;
new_d.gender = d.gender == "2" ? "female" : "male"
return new_d;
})
.sort((a,b) => b.age_entry_year - a.age_entry_year)
.filter(d => d.gender == "male")
//charting code goes here.
var chartWidth = 960, // default width
chartHeight = 500;
var margin = {top: 20, right: 40, bottom: 35, left: 30},
width = chartWidth - margin.left - margin.right,
height = chartHeight - margin.top - margin.bottom;
// var svg = d3.select(this).append("svg") //leaving for when packaged up.
var svg = d3.select("#chart").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
var min_date = d3.min(data, (d) => d.age_entry_year)
var max_date = d3.max(data, (d) => d.age_year)
var x = d3.scaleLinear()
.range([0, width])
.domain([min_date, max_date]);
//y scale for the data chart on top
var y = d3.scaleLinear()
.range([height/2 - 20, 0])
.domain([0,data.length]);
//y scale for the survival plot on bottom
var y_surv = d3.scaleLinear()
.range([height, height/2 + 10])
.domain([0,1]);
var lines = svg.selectAll(".lifespan")
.data(data)
.enter().append("line")
.attr("class", "lifespan")
.attrs({
"x1": (d) => x(d.age_entry_year),
"x2": (d) => x(d.age_entry_year),
"y1": (d,i) => y(i),
"y2": (d,i) => y(i),
"stroke": (d) => d.death ? "steelblue": "orangered",
"stroke-width": 1
})
.on("mouseover", highlight)
.on("mouseout", unhighlight)
.transition().duration(500)
.delay(function(d, i) { return (data.length - i) * 5; })
.attr("x2", (d) => x(d.age_year))
svg.append("g")
.attr("class", "axis axis--x")
.attr("transform", "translate(0," + height + ")")
.call(d3.axisBottom(x))
.append("text")
.attrs({
"class": "axis-title",
"y": 20,
"x": width/2,
"dy": "0.8em"
})
.styles({
"text-anchor": "center",
"fill": "black",
"font-size": 15
})
.text("Time in Years");
//////////////////////////////// Make legend ////////////////////////////////////////////////
var legend = svg.append("g")
.attr("class", "legend")
.attr("transform", "translate(" + width + "," + margin.top + ")");
var titleAttributes = {
"fill": "#636363",
"text-anchor": "end",
"font-size": "2em"
}
var legendAttributes = {
"x": -5,
"text-anchor": "end",
}
legend.append("text")
.text("Data/Individuals")
.attrs(titleAttributes)
legend.append("text")
.text("Event")
.attrs(legendAttributes)
.attrs({
"y": "2.5em",
"fill": "steelblue"
})
legend.append("text")
.text("Censored")
.attrs(legendAttributes)
.attrs({
"y": "3.5em",
"fill": "orangered"
})
svg.append("text")
.text("K-M Survival Curve")
.attrs(titleAttributes)
.attr("transform", "translate(" + width + "," + (height/2 + margin.top*2) + ")");
//////////////// Drawing the Survival Function //////////////
//draw bar above to seperate the two plots from eachother
svg.append("line")
.attrs({
x1: 0, x2: width,
y1: height/2, y2: height/2,
stroke: "black",
"stroke-width": 1,
opacity: 0.3
})
var km_surv = KM_Curve(data);
km_surv.unshift({"t_i": min_date, "d_i": 0, "Y_i": 0, "s_t": 0, "S_t": 1})
var surv_func = svg.append("g")
.attr("class", "survival_function")
var line = d3.line()
.curve(d3.curveStepAfter)
.x(d => x(d.t_i) )
.y(d => y_surv(d.S_t));
surv_func.append("path")
.datum(km_surv)
.attr("class", "line")
.attr("d", line);
function update_survival(newData){
surv_func.select(".line")
.datum(newData)
.transition().duration(500)
.attr("d", line);
}
svg.append("g")
.attr("class", "axis axis--y")
.call(d3.axisLeft(y_surv))
////////////// A bar to drag to filter the conditional entry time. //////////////
drag_behavior = d3.drag()
.on("drag", function(){
var x_loc = d3.event.x;
x_loc = x_loc < 0 ? 0 : x_loc,
x_loc = x_loc > width ? width : x_loc,
time_loc = x.invert(x_loc);
//move bar.
d3.select(".drag_bar")
.attr("transform", "translate(" + x_loc + ",0)")
.select("text")
.text("entry ≥ " + time_loc.roundTo(2) + " years");
d3.select(this)
.attr("x", x_loc - 15);
svg.selectAll(".lifespan")
.attr("opacity", (d) => d.age_entry_year < time_loc ? 0.1: 1)
.classed("ignored", (d) => d.age_entry_year < time_loc)
.classed("dragging", true)
} )
.on("end", function(){
svg.selectAll(".lifespan")
.classed("dragging", false)
//generate new KM curve data.
//only include current individuals.
km_data = data.filter((d) => d.age_entry_year >= time_loc)
km_surv = KM_Curve(km_data)
km_surv.unshift({"t_i": time_loc, "d_i": 0, "Y_i": 0, "s_t": 0, "S_t": 1})
update_survival(km_surv);
})
//make grabber bar
//Function javascript should really already have.
Number.prototype.roundTo = function(digits){
return +(Math.round(this + "e+" + digits) + "e-" + digits);
}
//grabber box to do the dragging.
svg.append("rect")
.attrs({
x: -15,
y: height/2 - 25,
rx: 2, ry:2,
width: 15,
height: 25,
fill: "#636363",
class: "drag_screen"
})
.call(drag_behavior);
var drag_bar = svg.append("g")
.attr("class", "drag_bar")
.style("pointer-events", "none")
drag_bar.append("rect")
.attrs({
x: -1,
width: 2,
height: height,
fill: "#636363",
opacity: 0.5,
class: "drag_screen"
})
drag_bar.append("text")
.text("entry ≥ " + min_date.roundTo(2) + " years")
.attrs({
"y": (height/2) - 3,
"x": 3,
"fill": "#636363",
"text-anchor": "start"
})
////////////// End drag bar //////////////
function highlight(){
var individual = d3.select(this);
//dont do behavior for the non-selected individuals or when dragging the bar.
if(individual.classed("ignored") || individual.classed("dragging")) return;
var y_pos = individual.attr("y1")
individual
.attr("stroke-width", "5")
var ind_data = individual.data()
.map(function(d){
return {
"entry": d.age_entry_year,
"exit" : d.age_year
}
})[0]
var bound_bar_data = [
{side: "left", x:individual.attr("x1"), value: ind_data.entry.roundTo(3)},
{side: "right", x:individual.attr("x2"), value: ind_data.exit.roundTo(3)}
]
var bounds_bars = svg.append("g")
.attr("class", "bounds_bars")
bounds_bars.selectAll("line")
.data(bound_bar_data)
.enter().append("line")
.attrs({
"x1": (d) => d.x,
"x2": (d) => d.x,
"y1": y_pos,
"y2": height,
"stroke": "black",
"stroke-width": 1
})
bounds_bars.selectAll("text")
.data(bound_bar_data)
.enter().append("text")
.text( (d) => d.value )
.attrs({
"x": (d) => d.x,
"y": height,
"text-anchor": (d) => d.side == "left" ? "end" : "start",
"font-size": 12
})
}
function unhighlight(){
d3.select(this).attr("stroke-width", 1)
svg.select(".bounds_bars").remove()
}
///////////////////////////// Generate a K-M non-parametric survival curve for data.
function KM_Curve(data){
//Source https://stackoverflow.com/questions/11246758/how-to-get-unique-values-in-an-array
Array.prototype.contains = function(v) {
for(var i = 0; i < this.length; i++) {
if(this[i] === v) return true;
}
return false;
};
Array.prototype.unique = function() {
var arr = [];
for(var i = 0; i < this.length; i++) {
if(!arr.contains(this[i])) {
arr.push(this[i]);
}
}
return arr;
}
//get unique times of death.
death_times = data.filter(d => d.death)
.map(d => d.age_year)
.unique()
.sort()
km_table = death_times.map(t_i => {
//Number of deaths at t_i
var d_i = data.filter(d => d.age_year == t_i && d.death).length
//number at risk. Aka in study, but havent had event yet.
var Y_i = data.filter(d => d.age_entry_year <= t_i && d.age_year >= t_i).length
var s_t = 1 - (d_i/Y_i);
return {"t_i": t_i, "d_i": d_i, "Y_i": Y_i, "s_t": s_t}
})
for (let [i, row] of km_table.entries()) {
var t = row.t_i,
s_t = row.s_t,
last_S_t = i != 0 ? km_table[i-1].S_t : 1;
km_table[i].S_t = last_S_t * s_t;
}
// console.table(km_table)
return km_table;
}
}) //close csv loader
</script>
https://d3js.org/d3.v4.min.js
https://d3js.org/d3-selection-multi.v1.min.js