Public
Edited
Sep 9, 2024
Importers
Insert cell
Insert cell
RadialStackedHistogram(data, {
x: (d) => d.wd_006,
y: (d) => d.ws_006,
xDomain: [0, 360],
marginLeft: 40,
marginTop: 40,
marginRight: 40,
marginBottom: 60,
xTicks: [90, 180, 270],
xTickFormat: (d) => `${d}°`,
colorLegendTitle: "Wind speed (m/s)",
})
Insert cell
function RadialStackedHistogram(data, {
// Accessor function for the first value that is binned.
// Each bin corresponds to one bar.
x = (d) => d.x,
// Accessor function for the second value that is binned.
// Each bin corresponds to one color or layer in the bars.
y = (d) => d.y,
// Min and max values for the bins.
xDomain = d3.extent(data, x),
yDomain = d3.extent(data, y),
// Integer number of bins or an array of bin thresholds.
xBins = 72,
yBins = 5,
// Integer number of ticks or an array of tick values for the axes.
xTicks = 5,
yTicks = 5,
// whether or not to show tick labels
showXTickLabels = true,
showYTickLabels = true,
// Label for y-axis
yAxisLabel = "Count",
// Format string or function for the ticks.
xTickFormat = "~s",
yTickFormat = "~s",
colorTickFormat = "~s",
// Show color legend
showColorLegend = true,
// Title for color legend
colorLegendTitle,
// The maximum count in the y-axis.
// If undefined, it is calculated based on the passed in data.
maxCount,
// Dimensions
width = 500,
height = 500,
// Margins
margin = 0,
marginLeft = margin,
marginTop = margin,
marginRight = margin,
marginBottom = margin,
// Radii
innerRadius = 0,
outerRadius = Math.min(
width - marginLeft - marginRight,
height - marginTop - marginBottom
) / 2,
} = {}) {

// data

const xThresholds = Array.isArray(xBins) ? xBins : linspace(xDomain[0], xDomain[1], xBins + 1);
const yThresholds = Array.isArray(yBins) ? yBins : linspace(yDomain[0], yDomain[1], yBins + 1);

const xBinIndices = d3.range(xThresholds.length - 1);
const yBinIndices = d3.range(yThresholds.length - 1);

const series = getStackedData(data);

// scales

// An angular x-scale
const xScale = d3.scaleLinear()
.domain(xDomain)
.range([0, 2 * Math.PI]);

// A radial y-scale maintains area proportionality of radial bars

const yMax = maxCount === undefined ? d3.max(series, d => d3.max(d, d => d[1])) : maxCount;
const yScale = d3.scaleRadial()
.domain([0, yMax])
.range([innerRadius, outerRadius]);

const color = d3.scaleOrdinal()
.domain(yBinIndices)
.range(d3.schemeBlues[yBinIndices.length])
.unknown("#ccc");

// generator

const arc = d3.arc()
.innerRadius(d => yScale(d[0]))
.outerRadius(d => yScale(d[1]))
.startAngle(d => xScale(d.data[1].get(d.key)?.xBinThresholds[0] ?? 0))
.endAngle(d => xScale(d.data[1].get(d.key)?.xBinThresholds[1] ?? 0))
.padAngle(1.5 / innerRadius)
.padRadius(innerRadius);

// drawing

const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("style", "font: 10px sans-serif;");

const g = svg.append('g')
.attr('transform', `translate(${marginLeft + outerRadius},${marginTop + outerRadius})`)

// A group for each series, and a rect for each element in the series
let visGroup = g.append("g");

visGroup.selectAll("g")
.data(series)
.join("g")
.attr("fill", d => color(d.key))
.selectAll("path")
.data(D => D.map(d => (d.key = D.key, d)))
.join("path")
.attr("d", arc);

// x axis

const xTickValues = Array.isArray(xTicks) ? xTicks : xScale.ticks(xTicks);
const xFormat = getFormat(xTickFormat);
g.append("g")
.attr("text-anchor", "middle")
.selectAll()
.data(xTickValues)
.join("g")
.attr("transform", d => `
rotate(${(xScale(d) * 180 / Math.PI - 90)})
translate(${outerRadius},0)
`)
.call(g =>
g.append("line")
.attr("x2", 6)
.attr("stroke", "#000"))
.call(g =>
showXTickLabels ?
g.append("text")
.attr("transform", "rotate(90)translate(0,-8)")
.text(d => xFormat(d)) :
g);

// y axis

const yFormat = getFormat(yTickFormat);
const yTickValues = Array.isArray(yTicks) ? yTicks : yScale.ticks(yTicks);
g.append("g")
.attr("text-anchor", "middle")
.call(g =>
yAxisLabel ?
g.append("text")
.attr("y", d => -yScale(yTickValues[yTickValues.length - 1]))
.attr("dy", "-1em")
.text(yAxisLabel) :
g)
.call(g =>
g.selectAll("g")
.data(yTickValues.slice(1))
.join("g")
.attr("fill", "none")
.call(g =>
g.append("circle")
.attr("stroke", "#000")
.attr("stroke-opacity", 0.5)
.attr("r", yScale))
.call(g =>
showXTickLabels ?
g.append("text")
.attr("y", d => -yScale(d))
.attr("dy", "0.35em")
.attr("stroke", "#fff")
.attr("stroke-width", 5)
.text(d => yFormat(d))
.clone(true)
.attr("fill", "#000")
.attr("stroke", "none") :
g));

// color legend

if (showColorLegend) {
const legendScale = d3.scaleThreshold()
.domain(yThresholds.slice(1, -1))
.range(d3.schemeBlues[yBinIndices.length]);

const legend = Legend(legendScale, {
width: width - marginLeft - marginRight,
title: colorLegendTitle,
tickFormat: colorTickFormat
});
svg.append('g')
.attr("transform", `translate(${marginLeft},${height - marginBottom + 10})`)
.append(() => legend);
}

// functions

function getStackedData(data) {
const discretizedData = data
.filter(d => !(Number.isNaN(x(d)) || x(d) === null || Number.isNaN(y(d)) || y(d) === null))
.map(d => {
const xBinIndex = d3.bisectRight(xThresholds, x(d), 0, xThresholds.length - 1) - 1;
const yBinIndex = d3.bisectRight(yThresholds, y(d), 0, yThresholds.length - 1) - 1;
return {
x: x(d),
y: y(d),
xBinIndex,
yBinIndex,
xBinThresholds: [xThresholds[xBinIndex], xThresholds[xBinIndex + 1]],
yBinThresholds: [yThresholds[yBinIndex], yThresholds[yBinIndex + 1]]
};
});

const counts = d3.rollup(
discretizedData,
g => ({
xBinIndex: g[0].xBinIndex,
yBinIndex: g[0].yBinIndex,
xBinThresholds: g[0].xBinThresholds,
yBinThresholds: g[0].yBinThresholds,
count: g.length
}),
d => d.xBinIndex,
d => d.yBinIndex
);

// Stack the data into series by age
const series = d3.stack()
.keys(yBinIndices) // distinct series keys, in input order
.value(([, D], key) => D.get(key)?.count ?? 0) // get value for each series key and stack
(counts);

return series;
}

function update(data) {
const series = getStackedData(data);

visGroup.selectAll("g")
.data(series)
.join("g")
.attr("fill", d => color(d.key))
.selectAll("path")
.data(D => D.map(d => (d.key = D.key, d)))
.join("path")
.attr("d", arc);
}

return Object.assign(svg.node(), {update});
}
Insert cell
data = FileAttachment("scada-all-wd.csv").csv({ typed: true })
Insert cell
function linspace(start, end, count, inclusive=true) {
const diff = end - start;
const step = diff / (inclusive ? count - 1 : count);

const result = Array.from({ length: count }, (d, i) => start + i * step);

return result;
}
Insert cell
function getFormat(format) {
// From https://observablehq.com/@d3/color-legend
return format === undefined ? d => d
: typeof format === "string" ? d3.format(format)
: format;
}
Insert cell
import {Legend} from "@d3/color-legend"
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