Published
Edited
Jul 13, 2021
Insert cell
Insert cell
Insert cell
Insert cell
_defaults = {
return {
fontFamily:
"\"Lucida Grande\", \"Lucida Sans Unicode\", Verdana, Arial, Helvetica, sans-serif",
fontSize: "14px",
legend: {},
margin: { top: 20, right: 0, bottom: 40, left: 80 },
series: {},
style: {},
title: {},
xAxes: {},
yAxes: {}
};
}
Insert cell
Insert cell
class D3XYChart {
constructor(data, options) {
options.fontFamily = options.fontFamily || _defaults.fontFamily;
options.fontSize = options.fontSize || _defaults.fontSize;
options.margin = options.margin || _defaults.margin;

const barSeries = [];
const lineSeries = [];
for (const series of options.series) {
if (series.type === "bar") barSeries.push(series);
else if (series.type === "line") lineSeries.push(series);
}

const svg = d3
.create("svg")
.attr("preserveAspectRatio", "none")
.attr("viewBox", [0, 0, options.width, options.height]);

const xScales = [];
for (const xAxis of options.xAxes) {
xScales.push(buildXScale(data, options, xAxis));
}

// Do we need a cluster scale for each xScale? Do we need to limit this to bar series?
const xClusterScale = buildXClusterScale(barSeries, xScales[0]);

const yScales = [];
for (const yAxis of options.yAxes) {
yScales.push(buildYScale(data, options, yAxis));
}

plotBarSeries(data, barSeries, svg, xScales[0], xClusterScale, yScales[0]);

for (const series of lineSeries) {
plotLineSeries(data, options, svg, series, xScales[0], yScales[0]);
plotLineSymbols(data, options, svg, series, xScales[0], yScales[0]);
}

for (const [index, xAxis] of options.xAxes.entries()) {
svg.append("g").call(buildXAxis(data, options, xAxis, xScales[index]));
//svg.call(buildXTitle(options, xAxis));
}

for (const [index, yAxis] of options.yAxes.entries()) {
svg.append("g").call(buildYAxis(data, options, yAxis, yScales[index]));
//svg.call(buildYTitle(options, yAxis));
}

this.chart = svg.node();
}
}
Insert cell
Insert cell
function plotBarSeries(data, barSeries, svg, xScale, xClusterScale, yScale) {
svg
.append("g")
.selectAll("g")
.data(data)
.join("g")
.attr("transform", d => `translate(${xScale(d.month)}, 0)`)
.selectAll("rect")
.data(d =>
barSeries.map(key => ({ color: key.color, y: key.y, value: d[key.y] }))
)
.join("rect")
.attr("x", d => xClusterScale(d.y))
.attr("y", d => yScale(d.value))
.attr("width", xClusterScale.bandwidth())
.attr("height", d => yScale(0) - yScale(d.value - yScale.domain()[0]))
.attr("fill", d => d.color);
}
Insert cell
function plotLineSeries(data, options, svg, series, xScale, yScale) {
svg
.append("path")
.datum(data)
//.join("path")
.attr("d", buildLine(options, series, xScale, yScale))
.attr("fill", "none")
.attr("stroke", series.color)
.attr("stroke-width", 2)
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round");
}
Insert cell
function buildLine(options, series, xScale, yScale) {
return d3
.line()
.defined((d) => !isNaN(d[series.y]))
.x((d) => xScale(d[series.x]) + xScale.bandwidth() / 2)
.y((d) => yScale(d[series.y]));
}
Insert cell
function plotLineSymbols(data, options, svg, series, xScale, yScale) {
svg
.selectAll(`symbol-${series.y}`)
.data(data)
.enter()
.append("path")
.attr("class", "`symbol-${series.y}`")
.attr("fill", (d, i) => series.color)
//.attr("stroke", (d, i) => series.color)
.attr("d", d3.symbol(d3.symbols[0])())
.attr(
"transform",
(d) =>
`translate(${xScale(d[series.x]) + xScale.bandwidth() / 2}, ${yScale(
d[series.y]
)})`
);
}
Insert cell
function buildSymbols(options, series, xScale, yScale) {
return d3
.path()
.defined((d) => !isNaN(d[series.y]))
.x((d) => xScale(d[series.x]) + xScale.bandwidth() / 2)
.y((d) => yScale(d[series.y]));
}
Insert cell
Insert cell
function buildXClusterScale(barSeries, xScale) {
return d3
.scaleBand()
.domain(barSeries.map(series => series.y))
.rangeRound([0, xScale.bandwidth()])
.padding(0.05);
}
Insert cell
Insert cell
function buildXScale(data, options, xAxis) {
// Identify all the series that use this axis.
// Build a domain across all the series using their x value.
// Should only perform the following logic if the axis is banded.
switch (xAxis.type) {
case "utc":
return (
d3
.scaleUtc()
// .domain(d3.extent(data, d => d[xAxis.value]))
.domain(xAxis.bands)
.range([options.margin.left, options.width - options.margin.right])
);
default:
return d3
.scaleBand()
.domain(xAxis.bands)
.rangeRound([options.margin.left, options.width - options.margin.right])
.paddingInner(0.1);
}
}
Insert cell
function buildXAxis(data, options, xAxis, xScale) {
return g =>
g
.attr(
"transform",
`translate(0, ${options.height - options.margin.bottom})`
)
.style("font-family", options.fontFamily)
.style("font-size", options.fontSize)
.call(d3.axisBottom(xScale).tickSizeOuter(0));
}
Insert cell
function buildXTitle(options) {
return g =>
g
.append("text")
.attr("font-family", "sans-serif")
.attr("font-size", 10)
.attr("y", 10)
.text("↑ Frequency");
}
Insert cell
Insert cell
function buildYScale(data, options, yAxis) {
// Identify the minimum value across all the series that are linked to this axis.
let minimum = options.series.reduce((min, series) => {
if (!min) return d3.min(data, r => r[series.y]);
return Math.min(min, d3.min(data, r => r[series.y]));
}, undefined);
// Set minimum value to 0 if axis is zeroBased and minimum is not negative.
if (yAxis.zeroBased && minimum > 0) minimum = 0;

// Identify the maximum value across all the series that are linked to this axis.
const maximum = options.series.reduce((max, series) => {
if (!max) return d3.max(data, r => r[series.y]);
return Math.max(max, d3.max(data, r => r[series.y]));
}, undefined);

return d3
.scaleLinear()
.domain([minimum, maximum])
.nice()
.range([options.height - options.margin.bottom, options.margin.top]);
}
Insert cell
function buildYAxis(data, options, yAxis, yScale) {
return (g) =>
g
.attr("transform", `translate(${options.margin.left}, 0)`)
.style("font-family", options.fontFamily)
.style("font-size", options.fontSize)
.call(d3.axisLeft(yScale));
//.call(g => g.select(".domain").remove());
}
Insert cell
function buildYTitle(options) {
return g =>
g
.append("text")
.attr("font-family", "sans-serif")
.attr("font-size", 10)
.attr("y", 10)
.text("↑ Frequency");
}
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