Published
Edited
May 8, 2021
2 forks
14 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
// see: https://observablehq.com/@tmcw/observable-anti-patterns-and-code-smells#mutation
sortedIntervals = intervals.slice()
.sort((a, b) => d3[order](a[sortBy], b[sortBy]));
Insert cell
intervals = literals.map(createInterval)
Insert cell
// plain objects containing string literals, including arrays (of plain objects containing string literals)
literals = [
{
name: "Lifetime (died aged 74)",
startDate: "1878-12-18",
endDate: "1953-03-05",
url: "https://en.wikipedia.org/wiki/Joseph_Stalin"
},
{
name: "Marriage #01",
startDate: "1906-07-16", // church wedding
endDate: "1907-11-22", // death (from typhus / tuberculosis, aged 22)
url: "https://en.wikipedia.org/wiki/Kato_Svanidze"
},
{
name: "Marriage #02",
startDate: "1919-03-24", // official registration
endDate: "1932-11-09", // death (by gunshot / suicide, aged 31)
url: "https://en.wikipedia.org/wiki/Nadezhda_Alliluyeva"
},
{
name: "(General) Secretary",
startDate: "1922-03-03",
endDate: "1952-10-16",
url: "https://en.wikipedia.org/wiki/General_Secretary_of_the_Communist_Party_of_the_Soviet_Union"
},
{
name: "Chairman of the Council of Ministers of the Soviet Union", // excess length for text-anchor adjustment testing
startDate: "1941-05-06",
endDate: "1953-03-05",
url: "https://en.wikipedia.org/wiki/Premier_of_the_Soviet_Union"
},
{
name: "Military service",
subintervals: [
{
startDate: "1918", // approximated as 1918-01-01
endDate: "1922-12-31" // approximated
},
{
startDate: "1941",
endDate: "1953-03-05" // date of death
}
],
url: "https://upload.wikimedia.org/wikipedia/commons/c/cf/Teheran_conference-1943.jpg"
}
]
Insert cell
createInterval = (literal) => {
const interval = {
name: literal.name
};
interval.subintervals = createSubintervals(literal);
interval.startDate = d3.min(interval.subintervals, d => d.startDate);
interval.endDate = d3.max(interval.subintervals, d => d.endDate);
interval.timespanInDays = getTimespanInDays(interval.subintervals);
if (literal.url !== undefined) {
interval.url = literal.url;
}
return interval;
}
Insert cell
createSubintervals = (literal) => {
if (literal.subintervals !== undefined) {
return literal.subintervals.map(parseInterval);
}
return [parseInterval(literal)];
}
Insert cell
parseInterval = (interval) => {
return {
startDate: d3.isoParse(interval.startDate),
endDate: d3.isoParse(interval.endDate)
}
}
Insert cell
d3 = require("d3@6")
Insert cell
getTimespanInDays = (subintervals) => d3.sum(subintervals, d => d3.utcDay.count(d.startDate, d.endDate))
Insert cell
Insert cell
Insert cell
chart = () =>
createCanvas()
.call(addHorizontalAxis)
.call(addIntervals, sortedIntervals)
.node()
Insert cell
createCanvas = () =>
d3.create("svg")
.attr("class", "timeline")
.attr("width", width)
.attr("height", height)
Insert cell
height = margin.top + (intervals.length * intervalHeight) + margin.bottom;
Insert cell
Insert cell
Insert cell
addHorizontalAxis = (parent) =>
parent.append("g")
.attr("class", "x-axis")
.attr("transform", `translate(0,${margin.top})`)
.call(d3.axisTop(scale))
.call(cleanAxis);
Insert cell
scale = d3.scaleTime()
.range(range)
.domain(domain)
Insert cell
range = [
margin.left,
width - (margin.left + margin.right)
]
Insert cell
domain = [
d3.min(intervals, d => d.startDate),
d3.max(intervals, d => d.endDate)
]
Insert cell
cleanAxis = (container) => {
const attributes = [
"fill",
"font-family",
"font-size",
"text-anchor"
];
attributes.forEach(d => container.attr(d, null));
container.select(".domain")
.remove();
container.selectAll(".tick")
.attr("opacity", null);
container.selectAll(".tick line")
.attr("stroke", null);
container.selectAll(".tick text")
.attr("fill", null);
}
Insert cell
addIntervals = (parent, intervals) => {
const container = parent.append("g")
.attr("class", "intervals")
.attr("transform", `translate(0,${margin.top + 15})`);
intervals.forEach((interval, index) => addInterval(container, interval, index));
}
Insert cell
addInterval = (parent, interval, index) => {
let container = parent.append("g")
.attr("class", "interval");

const startPoint = {
x: scale(interval.startDate),
y: intervalHeight * index
};

const endPoint = {
x: scale(interval.endDate),
y: startPoint.y
};

if (interval.url) {
container = container.append("a")
.attr("href", interval.url)
.attr("target", "_blank"); // hangs in the notebook's iframe otherwise

const intervalWidth = endPoint.x - startPoint.x;
container.append("rect")
.attr("class", "hoverable-area")
.attr("x", startPoint.x)
.attr("y", startPoint.y)
.attr("width", intervalWidth)
.attr("height", intervalHeight);
}

addSubintervals(container, startPoint, endPoint, interval.subintervals);
addText(container, startPoint, endPoint, interval.name);
}
Insert cell
addSubintervals = (parent, startPoint, endPoint, subintervals) => {
if (subintervals.length > 1) {
parent.append("line")
.attr("class", "guide-line") // bridges interval gaps
.attr("x1", startPoint.x)
.attr("y1", startPoint.y)
.attr("x2", endPoint.x)
.attr("y2", endPoint.y);
}
subintervals.forEach(subinterval => {
parent.append("line")
.attr("class", "subinterval")
.attr("x1", scale(subinterval.startDate))
.attr("y1", startPoint.y)
.attr("x2", scale(subinterval.endDate))
.attr("y2", endPoint.y);
});
}
Insert cell
addText = (parent, startPoint, endPoint, text) => {
const remainingPlaneWidth = range[1] - startPoint.x;
const textWidth = measureWidth(text);
if (textWidth <= remainingPlaneWidth) {
parent.append("text")
.attr("x", startPoint.x)
.attr("y", startPoint.y + 10)
.attr("text-anchor", "start")
.text(text);
} else {
parent.append("text")
.attr("x", endPoint.x)
.attr("y", endPoint.y + 10)
.attr("text-anchor", "end")
.text(text);
}
}
Insert cell
measureWidth = (text) => {
const context = document.createElement("canvas")
.getContext("2d");
// '10px sans-serif' as default font
return context.measureText(text).width;
}
Insert cell
html`<style>
.tick line {
stroke: currentColor;
}

.tick text {
fill: currentColor;
text-anchor: middle;
}

.tick text,
.interval text {
font-family: sans-serif;
font-size: 10px;
}

.interval line {
stroke-width: 2;
}

.guide-line {
stroke: #efefef;
}

.subinterval {
stroke: #000000;
}

.interval text,
.interval a[href] text {
fill: #808080;
}

.interval a[href] .hoverable-area {
fill: #ffffff; /* has to be white, not 'none' */
}

.interval a[href]:hover {
text-decoration: none;
}

.interval a[href]:hover text {
fill: #3182bd;
}

.interval a[href]:hover .guide-line {
stroke: #f3f8fb;
}

.interval a[href]:hover .subinterval {
stroke: #3182bd;
}
</style>`
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