Public
Edited
Oct 26, 2021
1 fork
Importers
118 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
interval = d3.utcMinute.every(10) // sample frequency
Insert cell
stop = interval() // exclusive
Insert cell
start = interval.offset(stop, -width) // inclusive
Insert cell
Insert cell
Insert cell
signups = wave({min: 0, max: 200, period: 120, round: true}) // fake data!
Insert cell
Insert cell
TimeAxis({interval, start, stop})
Insert cell
Insert cell
TimeChart(signups, {interval, start, stop, title: "Sign-ups", max: 240})
Insert cell
Insert cell
TimeChart(signups, {interval, start, stop, title: "Sign-ups", max: 240, bands: 1})
Insert cell
Insert cell
TimeChart(signups, {interval, start, stop, title: "Sign-ups", max: 240, scheme: "PuRd"})
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
TimeChart(wave({min: -1, max: 1, noise: 0}), {interval, start, stop, bands: 1, mode: "offset"})
Insert cell
TimeChart(wave({min: -1, max: 1, noise: 0}), {interval, start, stop, bands: 1, mode: "mirror"})
Insert cell
Insert cell
TimeChart(posneg, {interval, start, stop, scheme: "piyg"})
Insert cell
TimeChart(posneg, {interval, start, stop, scheme: "rdbu"})
Insert cell
TimeChart(posneg, {interval, start, stop, scheme: "blues"})
Insert cell
posneg = wave({min: -1, max: 1}) // fake data with some negative values
Insert cell
Insert cell
timeChart = TimeChart.defaults({interval, start, stop, scheme: "purples"})
Insert cell
timeAxis = TimeAxis.defaults({interval, start, stop})
Insert cell
timeAxis()
Insert cell
timeChart(signups, {title: "Sign-ups"})
Insert cell
Insert cell
timeChart(signups, {
title: "Sign-ups",
onclick(event) {
const {date} = this.invert(event);
open(`/d/e817ba556034bba5?date=${date.toISOString()}`, `target=_blank`);
}
})
Insert cell
Insert cell
viewof florps = timeChart(wave(), {title: "Florps"})
Insert cell
florps
Insert cell
Insert cell
html`${timeAxis()}${[wave(), wave()].map((data, i) => timeChart(data, {title: `wave ${i}`}))}`
Insert cell
Insert cell
TimeChart = {
let clientX = document.body.clientWidth + 14;

function TimeChart(data, options = {}) {

// If data is a promise, render nothing, then replace it with the actual chart later.
if (typeof data.then === "function") {
const chart = TimeChart([], options);
Promise.resolve(data).then((data) => chart.replaceWith(TimeChart(data, options)));
return chart;
}

// Extract option.s
let {
interval,
max = d3.quantile(data, 0.99, d => Math.abs(d.value) || NaN) || 1,
label, // alias for title
title = label,
locale = "en-US",
dateFormat = localeFormat(locale),
format = localeFormat(locale),
marginTop = 0, // try -16 to remove the gap between cells
marginLeft = 0,
marginRight = 0,
height = 49, // inclusive of margin
width,
stop,
start,
bands = 4,
onclick,
curve = d3.curveStepBefore,
scheme = d3.schemeRdGy,
mode = "offset"
} = options;
// Normalize string arguments
if (typeof format === "string") format = d3.format(format);
else if (typeof format !== "function") format = localeFormat(locale, format);
if (typeof dateFormat === "string") dateFormat = d3.utcFormat(dateFormat);
else if (typeof dateFormat !== "function") dateFormat = localeFormat(locale, dateFormat);
interval = maybeInterval(interval);
curve = maybeCurve(curve);
scheme = maybeScheme(scheme);
mode = maybeMode(mode);
bands = Math.floor(bands);
if (!(bands >= 1 && bands < scheme.length)) throw new Error(`invalid bands: ${bands}`);
if (stop === undefined) stop = interval();
if (start === undefined) start = interval.offset(stop, -width);
// Normalize the color scheme
let colors;
if (scheme.length < 11) { // assume sequential, pad with greys
colors = scheme[Math.max(3, bands)];
if (bands < 3) colors = colors.slice(3 - bands).concat(new Array(3 - bands));
colors = [...d3.reverse(d3.schemeGreys[colors.length]), undefined, ...colors];
} else { // otherwise assume diverging
colors = scheme[Math.max(3, 2 * bands + 1)];
}

// Normalize the data to the given interval, filling in any missing data with zeroes.
const values = new Map(data.map(d => [+d.date, +d.value]));
const [ymin, ymax] = d3.extent(values, ([, value]) => value);
data = interval.range(start, stop).map(date => ({date, value: values.get(+date) || 0}));
if (width === undefined) width = data.length;

const x = d3.scaleUtc([start, stop], [marginLeft, width - marginRight]);
const y = d3.scaleLinear([0, max], [0, -bands * height]);
const clip = DOM.uid("clip");
const path = DOM.uid("path");

const svg = d3.create("svg")
.attr("viewBox", `0 ${-marginTop} ${width} ${height}`)
.attr("width", width)
.attr("height", height)
.property("style", `
display: block;
font: 12px var(--sans-serif, system-ui, sans-serif);
font-variant-numeric: tabular-nums;
margin: 0 0 ${+marginTop}px calc(100% - ${width}px);
overflow: visible;
`);

const tooltip = svg.append("title");

svg.append("clipPath")
.attr("id", clip.id)
.append("rect")
.attr("y", 0)
.attr("width", width)
.attr("height", height);

svg.append("defs").append("path")
.attr("id", path.id)
.attr("d", d3.area()
.curve(curve)
.defined(d => !isNaN(d.value))
.x(d => round(x(d.date)))
.y0(0)
.y1(d => round(y(d.value)))
(data));

const g = svg.append("g")
.attr("clip-path", clip);

g.append("g")
.selectAll("use")
.data(d3.range(bands)
.map(i => [i, colors[i + 1 + (colors.length >> 1)]])
.filter(([i, color]) => color != null && ymax > max * i / bands))
.join("use")
.attr("fill", ([, color]) => color)
.attr("transform", ([i]) => `translate(0,${(i + 1) * height})`)
.attr("xlink:href", path.href);

g.append("g")
.selectAll("use")
.data(d3.range(bands)
.map(i => [i, colors[(colors.length >> 1) - 1 - i]])
.filter(([i, color]) => color != null && -ymin > max * i / bands))
.join("use")
.attr("fill", ([, color]) => color)
.attr("transform", mode === "mirror"
? ([i]) => `translate(0,${(i + 1) * height}) scale(1,-1)`
: ([i]) => `translate(0,${-i * height})`)
.attr("xlink:href", path.href);

const overlay = svg.append("g");

if (title != null) overlay.append("text")
.attr("class", "title")
.attr("font-weight", "bold")
.attr("stroke-linecap", "round")
.attr("stroke-linejoin", "round")
.attr("y", 2 * 16)
.attr("dy", "0.32em")
.text(title + "");

overlay.append("text")
.attr("class", "label")
.attr("stroke-linecap", "round")
.attr("stroke-linejoin", "round")
.attr("text-anchor", "end")
.attr("y", height - 16 - 1)
.attr("dx", -3)
.attr("dy", "0.32em");

overlay.selectAll("text")
.select(function() {
const clone = this.cloneNode(true);
return this.parentNode.insertBefore(clone, this);
})
.attr("fill", "none")
.attr("stroke", "white")
.attr("stroke-width", 4);

overlay.append("line")
.attr("class", "line")
.attr("stroke", "white")
.attr("stroke-dasharray", "1,1")
.style("mix-blend-mode", "screen")
.attr("y1", 0)
.attr("y2", height);

overlay.select("line").clone(true)
.attr("stroke", "black")
.attr("stroke-dashoffset", 1);

const overlayLine = overlay.selectAll(".line");
const overlayLabel = overlay.selectAll(".label");
const overlayText = overlay.selectAll(".title");

function invert(event) {
const [mx] = d3.pointer(event, svg.node());
const i = d3.bisector(d => d.date).left(data, x.invert(mx), 0, data.length - 1);
return data[i];
}

function mousemoved(event) {
clientX = event.clientX;
const d = invert(event);
overlayLabel.attr("x", x(d.date)).text(format(d.value));
overlayLine.attr("x1", x(d.date) - 0.5).attr("x2", x(d.date) - 0.5);
tooltip.text(dateFormat(d.date));
}

function resized() {
overlayText.attr("x", Math.max(0, width - document.body.clientWidth) + 4);
}

resized();
addEventListener("resize", resized);
addEventListener("mousemove", mousemoved);
requestAnimationFrame(() => mousemoved({clientX, clientY: 0}));

Inputs.disposal(svg.node()).then(() => {
removeEventListener("resize", resized);
removeEventListener("mousemove", mousemoved);
});

return Object.assign(svg.node(), {onclick, value: data, invert});
}

TimeChart.defaults = defaults => {
return (data, options) => {
return TimeChart(data, {...defaults, ...options});
};
};

return TimeChart;
}
Insert cell
TimeAxis = {
function TimeAxis({
interval,
width,
height = 33,
marginLeft = 0,
marginRight = 0,
stop,
start,
} = {}) {
interval = maybeInterval(interval);
if (stop === undefined) stop = interval();
if (start === undefined) start = interval.offset(stop, -width);
if (width === undefined) width = interval.range(start, stop).length;
return html`<svg viewBox="0 0 ${width} ${height}" width=${width} height=${height} style="display: block; margin-left: calc(100% - ${width}px);">
${d3.create("svg:g")
.call(d3.axisTop(d3.scaleTime([start, stop], [marginLeft, width - marginRight])).ticks(width / 120))
.call(g => g.select(".domain").remove())
.call(g => g.selectAll(".tick line").clone(true).attr("y2", "100vh").attr("stroke-opacity", 0.12))
.attr("transform", `translate(0, 33)`)
.node()}
</svg>`;
}

TimeAxis.defaults = defaults => {
return options => {
return TimeAxis({...defaults, ...options});
};
};

return TimeAxis;
}
Insert cell
function round(x) {
return Math.round(x * 2) / 2;
}
Insert cell
function maybeInterval(interval) {
if (interval == null) throw new Error("missing interval");
if (!(interval && typeof interval.range === "function")) {
const i = (interval + "").toLowerCase();
switch (i) {
case "millisecond":
case "second":
case "minute":
case "hour":
case "day":
case "week":
case "sunday":
case "monday":
case "tuesday":
case "wednesday":
case "thursday":
case "friday":
case "saturday":
case "month":
case "year":
return d3[`utc${camelize(i)}`];
}
throw new Error(`invalid interval: ${interval}`);
}
return interval;
}
Insert cell
function maybeCurve(curve) {
if (curve == null) throw new Error("missing curve");
if (typeof curve !== "function") {
const c = d3[`curve${camelize(curve)}`];
if (c === undefined) throw new Error(`unknown curve: ${curve}`);
curve = c;
}
return curve;
}
Insert cell
function maybeScheme(scheme) {
if (scheme == null) throw new Error("missing scheme");
if (!Array.isArray(scheme)) {
const s = (scheme + "").toLowerCase();
switch (s) {
case "brbg": return d3.schemeBrBG;
case "prgn": return d3.schemePRGn;
case "piyg": return d3.schemePiYG;
case "puor": return d3.schemePuOr;
case "rdbu": return d3.schemeRdBu;
case "rdgy": return d3.schemeRdGy;
case "rdylbu": return d3.schemeRdYlBu;
case "rdylgn": return d3.schemeRdYlGn;
case "spectral": return d3.schemeSpectral;
case "blues": return d3.schemeBlues;
case "greens": return d3.schemeGreens;
case "greys": return d3.schemeGreys;
case "oranges": return d3.schemeOranges;
case "purples": return d3.schemePurples;
case "reds": return d3.schemeReds;
case "bugn": return d3.schemeBuGn;
case "bupu": return d3.schemeBuPu;
case "gnbu": return d3.schemeGnBu;
case "orrd": return d3.schemeOrRd;
case "pubu": return d3.schemePuBu;
case "pubugn": return d3.schemePuBuGn;
case "purd": return d3.schemePuRd;
case "rdpu": return d3.schemeRdPu;
case "ylgn": return d3.schemeYlGn;
case "ylgnbu": return d3.schemeYlGnBu;
case "ylorbr": return d3.schemeYlOrBr;
case "ylorrd": return d3.schemeYlOrRd;
}
throw new Error(`invalid scheme: ${scheme}`);
}
return scheme;
}
Insert cell
function maybeMode(mode) {
switch (mode = (mode + "").toLowerCase()) {
case "offset": case "mirror": return mode;
}
throw new Error(`unknown mode: ${mode}`);
}
Insert cell
function camelize(string) {
return string
.toLowerCase()
.split(/-/g)
.map(([f, ...r]) => `${f.toUpperCase()}${r.join("")}`)
.join("");
}
Insert cell
function localeFormat(locale, format) {
return date => date.toLocaleString(locale, format);
}
Insert cell
function wave({
min = 0,
max = 1,
shift = 0,
period = 24 * 6, // matching the default 10-minute interval
noise = 0.2,
pow = 1,
round = false
} = {}) {
return interval.range(start, stop).map((date, i) => {
const t = (Math.sin((i - shift) / period * 2 * Math.PI) + 1) / 2;
const n = Math.random();
let value = +min + (max - min) * (t ** pow * (1 - noise) + n * noise);
if (round) value = Math.round(value);
return {date, value};
});
}
Insert cell
function add(first, ...series) {
return first.map(({date, value}, i) => ({date, value: value + d3.sum(series, s => s[i].value)}));
}
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