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

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