Published
Edited
Jul 2, 2020
Importers
Insert cell
Insert cell
Insert cell
data2year = {
const data = [];
const today = luxon.DateTime.local();
const interval = luxon.Interval.after(today, {years: 1});
const dates = interval.splitBy({weeks: 2}).map(i => i.start);

let v = 10000;
for (let i = 0; i < dates.length; ++i) {
v += (Math.random() - 0.3)*2500;
//v = Math.max(Math.min(v, 4), 0);
const d = dates[i];
data.push({
date: d,
value: v
});
}
return data
}
Insert cell
chart1 = timeSeries(data1year);
Insert cell
{
const [start, end] = d3.extent(data1year, d => d.date);
return {
diff: end.diff(start),
weeks: end.diff(start).as('years'),

start: start.toJSDate(),
end: end.toJSDate()
}
}
Insert cell
d3.timeMonth.every(5)(new Date())
Insert cell
new Date('2020-05-01')
Insert cell
Insert cell
timeSeriesMulti([
{label: 'd1', data: data1year},
{label: 'd2', data: data2year},
{label: 'd3', data: data2year.map(x => {return{...x, value: x.value - 1000}})},
{label: 'd4', data: data2year.map(x => {return{...x, value: x.value - 3000}})},
])
Insert cell
timeSeriesMulti = (series) => {
// Assumes structure:
// [ {
// 'label': name,
// 'data': [
// {date: luxon.DateTime, value: float}
// ]
// ]
// TODO: this makes a lot of assumptions about the 2 data sets...
let data = series[0].data;
const width = 600;
const height = 300;
const margin = { top: 20, right: 30, bottom: 30, left: 40 }
const viewportHeight = height;
const viewportWidth = width;
const xMapper = d3
.scaleUtc()
.domain(d3.extent(data, d => d.date))
.range([margin.left, viewportWidth - margin.right]);

const yMapper = d3
.scaleLinear()
.domain([0, d3.max(data, d => d.value)*1.3])
.range([viewportHeight - margin.bottom, margin.top]);

const line = d3
.line()
.x(d => xMapper(d.date))
.y(d => yMapper(d.value));
// Determine tick width
const [start, end] = d3.extent(data, d => d.date);
const years = end.diff(start).as('years');
let tickFn, tickEvery, fmt;
if (years > 6) {
tickFn = d3.timeYear;
tickEvery = d3.timeYear.every(3);
fmt = 'yyyy';
} else {
tickFn = d3.timeMonth;
tickEvery = d3.timeMonth.every(3);
fmt = 'LLL dd';
}

const xAxis = function(g) {
return g.attr("transform", `translate(0,${height - margin.bottom})`).call(
d3
.axisBottom(xMapper)
.ticks(tickEvery)
.tickFormat(d => d <= tickFn(d) ? luxon.DateTime.fromJSDate(d).toFormat(fmt) /*(fmt)*/: null)
.tickSizeOuter(0)
);
};
function formatTick(d) {
if (d >= 1000) {
const s = (d / 1000).toFixed(0);
return this.parentNode.nextSibling ? `\xa0${s}` : `$${s} thousand`;
}
return d;
}

const yAxis = function(g) {
return g.attr("transform", `translate(${margin.left},0)`).call(
d3
// .axisLeft(yMapper) // this will put the axis on the left, and no horizontal lines
.axisRight(yMapper) // start on right, scoot to left to make value lines
.tickSize(width - margin.left - margin.right) // make the lines really long
.tickFormat(formatTick)
.ticks(5)

// .tickSizeOuter(0) // how long the line sticks out
)
// to remove the axis line, add the following
.call(g => g.select(".domain").remove())
// Make the axis lines dashed
.call(g => g.selectAll(".tick:not(:first-of-type) line")
.attr("stroke-opacity", 0.5)
.attr("stroke-dasharray", "2,2"))
// Scoot text to left axis
.call(g => g.selectAll(".tick text")
.attr("x", 4)
.attr("dy", -4))
;
};

const svg = d3
.create("svg")
.attr("width", width)
.attr("height", height)
.attr("style", "border:1px solid black");

// TODO: some kind of color scheme
const colors = ['steelblue', 'orange', 'goldenrod'];
series.forEach((s, i) => {
svg
.append("path")
.datum(s.data)
.attr("d", line)
.attr("fill", "none")
.attr("stroke", colors[i % colors.length])
.attr("stroke-width", 1.5)
.attr("stroke-miterlimit", 1)
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round");
});

svg.append("g").call(xAxis);

svg.append("g").call(yAxis);

return svg.node();
}
Insert cell
Insert cell
timeSeriesBar(data1year.map(x => {return {...x, value: (x.value-20000)*0.5}}), 'weeks')
Insert cell
timeSeriesBar = (data, bucketSize) => {
// Assumes structure:
// [
// {date: luxon.DateTime, value: float}
// ]
// TODO: allow overriding
const width = 600;
const height = 300;
const margin = { top: 20, right: 30, bottom: 30, left: 40 }
const viewportHeight = height;
const viewportWidth = width;
// extract commonality here?
const xMapper = d3
.scaleUtc()
.domain(d3.extent(data, d => d.date))
.range([margin.left, viewportWidth - margin.right]);

const yMapper = d3
.scaleLinear()
.domain([Math.min(0, d3.min(data, d => d.value))*1.3, d3.max(data, d => d.value)*1.3])
.range([viewportHeight - margin.bottom, margin.top]);
// Determine tick width
const [start, end] = d3.extent(data, d => d.date);
const dateDiff = end.diff(start);
const years = dateDiff.as('years');
let tickFn, tickEvery, fmt;
if (years > 6) {
tickFn = d3.timeYear;
tickEvery = d3.timeYear.every(3);
fmt = 'yyyy';
} else {
tickFn = d3.timeMonth;
tickEvery = d3.timeMonth.every(3);
fmt = 'LLL dd';
}

const xAxis = function(g) {
return g.attr("transform", `translate(0,${height - margin.bottom})`).call(
d3
.axisBottom(xMapper)
.ticks(tickEvery)
.tickFormat(d => d <= tickFn(d) ? luxon.DateTime.fromJSDate(d).toFormat(fmt) /*(fmt)*/: null)
.tickSizeOuter(0)
);
};
function formatTick(d) {
if (d >= 1000) {
const s = (d / 1000).toFixed(0);
return this.parentNode.nextSibling ? `\xa0${s}` : `$${s} thousand`;
}
return d;
}

const yAxis = function(g) {
return g.attr("transform", `translate(${margin.left},0)`).call(
d3
// .axisLeft(yMapper) // this will put the axis on the left, and no horizontal lines
.axisRight(yMapper) // start on right, scoot to left to make value lines
.tickSize(width - margin.left - margin.right) // make the lines really long
.tickFormat(formatTick)
.ticks(5)

// .tickSizeOuter(0) // how long the line sticks out
)
// to remove the axis line, add the following
.call(g => g.select(".domain").remove())
// Make the axis lines dashed
.call(g => g.selectAll(".tick:not(:first-of-type) line")
.attr("stroke-opacity", 0.5)
.attr("stroke-dasharray", "2,2"))
// Scoot text to left axis
.call(g => g.selectAll(".tick text")
.attr("x", 4)
.attr("dy", -4))
;
};

const svg = d3
.create("svg")
.attr("width", width)
.attr("height", height)
.attr("style", "border:1px solid black");

// Should it be by month or by day?
const bucket = bucketSize || 'weeks';
const numDays = dateDiff.as(bucket);
const barWidth = ((xMapper(end) - xMapper(start))/numDays)*0.9;
svg.append("g")
.attr("fill", "steelblue")
.selectAll("rect")
.data(data)
.join("rect")
.attr("x", d => xMapper(d.date))
.attr("y", d => yMapper(Math.max(d.value, 0)))
.attr("height", d => {
return Math.abs(yMapper(0) - yMapper(d.value));
})
.attr("width", barWidth);

// const line = d3
// .line()
// .x(d => xMapper(d.date))
// .y(d => yMapper(d.value));
// svg
// .append("path")
// .datum(data)
// .attr("d", line)
// .attr("fill", "none")
// .attr("stroke", "steelblue")
// .attr("stroke-width", 1.5)
// .attr("stroke-miterlimit", 1)
// .attr("stroke-linejoin", "round")
// .attr("stroke-linecap", "round");

svg.append("g").call(xAxis);

svg.append("g").call(yAxis);

return svg.node();
}
Insert cell
md`# Grouping data`
Insert cell
groupByStart = (data, period) => {
const byTs = {};
data.forEach(x => {
const ts = x.date.startOf(period).ts;
const arr = byTs[ts] || [];
arr.push(x);
byTs[ts] = arr;
});
const result = [];
for (let ts in byTs) {
result.push({
date: luxon.DateTime.fromMillis(parseFloat(ts)),
values: byTs[ts]
})
}
return result
}
Insert cell
{
const d = luxon.DateTime.fromISO('2020-03-21');
return md`
## Ticks

* d3.timeMonth / d3.timeYear : takes an input timestamp and rounds to the interval specified by the passed in number. For example, starting with ${d.toLocaleString()}:
* \`d3.timeYear.every(5)\` = ${d3.timeYear.every(5)(d).toLocaleString()} (rounded to the nearesd 5 year mark)
* \`d3.timeMonth.every(2)\` = ${d3.timeMonth.every(2)(d).toLocaleString()}
`
}

Insert cell
Insert cell
Insert cell
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