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

One platform to build and deploy the best data apps

Experiment and prototype by building visualizations in live JavaScript notebooks. Collaborate with your team and decide which concepts to build out.
Use Observable Framework to build data apps locally. Use data loaders to build in any language or library, including Python, SQL, and R.
Seamlessly deploy to Observable. Test before you ship, use automatic deploy-on-commit, and ensure your projects are always up-to-date.
Learn more