function RadialStackedHistogram(data, {
x = (d) => d.x,
y = (d) => d.y,
xDomain = d3.extent(data, x),
yDomain = d3.extent(data, y),
xBins = 72,
yBins = 5,
xTicks = 5,
yTicks = 5,
showXTickLabels = true,
showYTickLabels = true,
yAxisLabel = "Count",
xTickFormat = "~s",
yTickFormat = "~s",
colorTickFormat = "~s",
showColorLegend = true,
colorLegendTitle,
maxCount,
width = 500,
height = 500,
margin = 0,
marginLeft = margin,
marginTop = margin,
marginRight = margin,
marginBottom = margin,
innerRadius = 0,
outerRadius = Math.min(
width - marginLeft - marginRight,
height - marginTop - marginBottom
) / 2,
} = {}) {
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);
const xScale = d3.scaleLinear()
.domain(xDomain)
.range([0, 2 * Math.PI]);
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");
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);
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})`)
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);
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);
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));
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);
}
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
);
const series = d3.stack()
.keys(yBinIndices)
.value(([, D], key) => D.get(key)?.count ?? 0)
(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});
}