Public
Edited
Feb 2
Insert cell
Insert cell
{
const width = 800;
const height = 700;
const innerRadius = 100;
const outerRadius = 250;

// Data
const data = [
{ date: "2021-03", SJL: 0.259192 },
{ date: "2021-04", SJL: 0.337292 },
{ date: "2021-05", SJL: 0.423475 },
{ date: "2021-06", SJL: 1.084042 },
{ date: "2021-07", SJL: 0.702609 },
{ date: "2021-08", SJL: 0.694872 },
{ date: "2021-09", SJL: 0.300436 },
{ date: "2021-10", SJL: -0.454092 },
{ date: "2021-11", SJL: 0.395537 },
{ date: "2021-12", SJL: 0.289461 },
{ date: "2022-01", SJL: 0.346922 },
{ date: "2022-02", SJL: 0.922683 },
{ date: "2022-03", SJL: -0.048084 },
{ date: "2022-04", SJL: 1.062598 },
{ date: "2022-05", SJL: 0.842379 },
{ date: "2022-06", SJL: 0.448594 },
{ date: "2022-07", SJL: 1.411242 },
{ date: "2022-08", SJL: -0.336 }
];

// Extract unique years
const years = [...new Set(data.map((d) => d.date.split("-")[0]))];
const months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
];

const minVal = d3.min(data.map((d) => d.SJL));
const maxVal = d3.max(data.map((d) => d.SJL));

// Scale for color
const blueScale = d3.scaleSequential(d3.interpolateBlues).domain([0, maxVal]);

//const redScale = d3.scaleSequential(d3.interpolateReds).domain([-1, 0]);
const redScale = d3
.scaleSequential((t) => d3.interpolateReds(1 - t))
.domain([minVal, 0]);

const svg = d3
.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", `${-width / 2} ${-height / 2 + 20} ${width} ${height}`)
.attr("preserveAspectRatio", "xMidYMid meet")
.style("background", "#2e2e2e");

const g = svg
.append("g")
.attr("transform", `translate(${width * 0.7}, ${height * 0.5})`);

const tooltip = d3
.select("body")
.append("div")
.style("position", "absolute")
.style("background", "rgba(255, 255, 255, 0.8)")
.style("border", "1px solid #ddd")
.style("border-radius", "5px")
.style("padding", "8px")
.style("font-size", "12px")
.style("pointer-events", "none")
.style("opacity", 0);

// Create heatmap
data.forEach((d) => {
const [year, month] = d.date.split("-");
//const monthIndex = months.indexOf(month);
const monthIndex = parseInt(month, 10) - 1;
const yearIndex = years.indexOf(year);

const startAngle = (monthIndex / 12) * 2 * Math.PI;
const endAngle = ((monthIndex + 1) / 12) * 2 * Math.PI;
const radiusStart = innerRadius + yearIndex * 50;
const radiusEnd = radiusStart + 40;

const arcGenerator = d3
.arc()
.innerRadius(radiusStart)
.outerRadius(radiusEnd)
.startAngle(startAngle)
.endAngle(endAngle);

const fillColor = d.SJL >= 0 ? blueScale(d.SJL) : redScale(d.SJL);

svg
.append("path")
.attr("d", arcGenerator)
.attr("fill", fillColor)
.on("mouseover", function (event) {
tooltip.style("opacity", 1);
d3.select(this).style("stroke", "white").style("stroke-width", "2px");
})
.on("mousemove", function (event) {
tooltip
.html(
`<strong>Year:</strong> ${year} <br> <strong>Month:</strong> ${
months[monthIndex]
} <br> <strong>SJL:</strong> ${d.SJL.toFixed(3)}`
)
.style("left", event.pageX + 10 + "px")
.style("top", event.pageY - 30 + "px");
})
.on("mouseleave", function () {
tooltip.style("opacity", 0);
d3.select(this).style("stroke", "none");
});
//.attr("stroke", "#fff");
});

// Labels for years
//years.forEach((year, i) => {
//svg
//.append("text")
//.attr("x", 0)
//.attr("y", -innerRadius - i * 50 - 20)
//.attr("text-anchor", "middle")
//.style("font-size", "16px")
//.style("fill", "white")
//.style("stroke", "white")
//.text(year);
//});

const defs = svg.append("defs");

years.forEach((year, i) => {
const radius = innerRadius + i * 50 + 15; // Move outward per year
const pathId = `yearPath${i}`;

defs
.append("path")
.attr("id", pathId)
.attr(
"d",
d3
.arc()
.innerRadius(radius)
.outerRadius(radius)
.startAngle(-Math.PI / 2)
.endAngle(Math.PI / 2)
)
.attr("fill", "none");

svg
.append("text")
.attr("fill", "white")
.style("font-size", "16px")
.append("textPath")
.attr("xlink:href", `#${pathId}`)
.attr("startOffset", "25%")
.text(year);
});

// Labels for months
//const labelRadius = (innerRadius + outerRadius) / 2;

months.forEach((month, i) => {
const angle = ((i + 0.5) / 12) * 2 * Math.PI - Math.PI / 2;
svg
.append("text")
.attr("x", Math.cos(angle) * (outerRadius - 20))
.attr("y", Math.sin(angle) * (outerRadius - 20))
.attr("text-anchor", "middle")
.attr("alignment-baseline", "middle")
.style("font-size", "14px")
.style("fill", "white")
.text(month);
});

//Add Blue Legend
const legendWidth = 150;
const legendHeight = 15;
const blueLegendX = 0;
const blueLegendY = 320;

const blueLegend = svg
.append("g")
.attr("transform", `translate(${blueLegendX}, ${blueLegendY})`);

const legendDefs = svg.append("defs");
const linearGradient = legendDefs
.append("linearGradient")
.attr("id", "blue-heatmap-gradient")
.attr("x1", "0%")
.attr("y1", "0%")
.attr("x2", "100%")
.attr("y2", "0%");

linearGradient
.selectAll("stop")
.data([0, maxVal])
.enter()
.append("stop")
.attr("offset", (d) => `${d * 100}%`)
.attr("stop-color", (d) => blueScale(d));

blueLegend
.append("rect")
.attr("width", legendWidth)
.attr("height", legendHeight)
.style("fill", "url(#blue-heatmap-gradient)");

const legendScale = d3
.scaleLinear()
.domain([0, maxVal])
.range([0, legendWidth]);

const legendAxis = d3.axisBottom(legendScale).ticks(5);

blueLegend
.append("g")
.attr("transform", `translate(0, ${legendHeight})`)
.call(legendAxis);

blueLegend
.append("g")
.attr("transform", `translate(0, ${legendHeight})`)
.call(legendAxis)
.selectAll("text")
.style("fill", "white");

//Add Red Legend
const redLegendX = -150;
const redLegendY = 320;

const redLegend = svg
.append("g")
.attr("transform", `translate(${redLegendX}, ${redLegendY})`);

const redLegendDefs = svg.append("defs");
const redLinearGradient = redLegendDefs
.append("linearGradient")
.attr("id", "red-heatmap-gradient")
.attr("x1", "0%")
.attr("y1", "0%")
.attr("x2", "100%")
.attr("y2", "0%");

redLinearGradient
.selectAll("stop")
.data([minVal, 0])
.enter()
.append("stop")
.attr("offset", (d, i, n) => `${(i / (n.length - 1)) * 100}%`)
.attr("stop-color", (d) => redScale(d));

redLegend
.append("rect")
.attr("width", legendWidth)
.attr("height", legendHeight)
.style("fill", "url(#red-heatmap-gradient)");

const redLegendScale = d3
.scaleLinear()
.domain([minVal, 0])
.range([0, legendWidth]);

const redLegendAxis = d3.axisBottom(redLegendScale).ticks(5);

redLegend
.append("g")
.attr("transform", `translate(0, ${legendHeight})`)
.call(redLegendAxis);

redLegend
.append("g")
.attr("transform", `translate(0, ${legendHeight})`)
.call(redLegendAxis)
.selectAll("text")
.style("fill", "white");

redLegend
.append("text")
.attr("x", legendWidth / 2 + 35)
.attr("y", -5)
.attr("text-anchor", "start")
.style("fill", "white")
.style("font-size", "14px")
.text("Value Intensity");

return svg.node();
}
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