Public
Edited
May 16
Insert cell
Insert cell
chart = Sunburst(data)
Insert cell
{
const button = html`<button>⬇️ Download PDF</button>`;

button.onclick = async () => {
const svgElement = chart; // chart = Sunburst(data)
if (!svgElement || svgElement.nodeName !== "svg") {
console.error("❌ 'chart' is not an SVG element.");
return;
}

const svgString = new XMLSerializer().serializeToString(svgElement);
const blob = new Blob([svgString], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);

const img = new Image();
img.onload = async () => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;

const ctx = canvas.getContext("2d");
ctx.fillStyle = "white";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);

const imgData = canvas.toDataURL("image/png");

const { jsPDF } = await import("https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js");
const pdf = new jsPDF({
orientation: "landscape",
unit: "px",
format: [canvas.width, canvas.height]
});

pdf.addImage(imgData, "PNG", 0, 0, canvas.width, canvas.height);
pdf.save("sunburst_chart.pdf");

URL.revokeObjectURL(url);
};

img.onerror = (e) => {
console.error("❌ Failed to load SVG as image", e);
};

img.src = url;
};

return button;
}

Insert cell
viewof downloadButton = {
const button = html`<button>⬇️ Download PDF</button>`;

button.onclick = async () => {
const svgElement = chart instanceof SVGElement ? chart : chart.node?.();
if (!svgElement || svgElement.nodeName !== "svg") {
console.error("❌ 'chart' is not a valid SVG element.");
return;
}

const { jsPDF } = await import("https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js");
const { Canvg } = await import("https://cdn.jsdelivr.net/npm/canvg@4.0.1/lib/umd.min.js");

const canvas = document.createElement("canvas");
canvas.width = svgElement.clientWidth;
canvas.height = svgElement.clientHeight;
const ctx = canvas.getContext("2d");

const svgString = new XMLSerializer().serializeToString(svgElement);
const v = await Canvg.fromString(ctx, svgString);
await v.render();

const imgData = canvas.toDataURL("image/png");

const pdf = new jsPDF({
orientation: "landscape",
unit: "px",
format: [canvas.width, canvas.height]
});

pdf.addImage(imgData, "PNG", 0, 0, canvas.width, canvas.height);
pdf.save("sunburst_chart.pdf");
};

return button;
}

Insert cell
// data = FileAttachment("final_ava_sunburst.json").json()
data = FileAttachment("data@3.json").json()

Insert cell
Insert cell
function Sunburst(data, {
/* ---------- Tunables ---------- */
width = 700,
height = 700,

fontCenter = 32,
fontTask = 15,
fontDataset = 13,
fontPercent = 12,

innerPad = 0,
outerPad = 0,
percentRingW = 60,

startAngle = 0,
endAngle = 2 * Math.PI,
colorScheme = d3.interpolateRainbow,
value = d => d.value ?? 0 // removed MiT weight tweak
} = {}) {
const radius = Math.min(width, height) / 2 - outerPad - percentRingW;

/* ---------- Hierarchy ---------- */
const children = d => d.children?.filter(c => !c.is_percentage_ring);
const root = d3.hierarchy(data, children).sum(d => Math.max(0, value(d)));
d3.partition().size([endAngle - startAngle, radius])(root);

/* ---------- Colors ---------- */
const hues = d3.quantize(colorScheme, 16);
root.children.forEach((t, i) => {
const base = d3.color(hues[i]);
const white = d3.color("#ffffff");
t.hue = d3.interpolateRgb(base, white)(0.3); // 0.5 = 50% toward white (lighter)
});

const taskHue = d => {
if (d.depth === 0) return "#ddd";
if (d.depth === 1) return d.hue;
const task = d.ancestors().find(a => a.depth === 1);
return task?.hue ?? "#999";
};

const arc = d3.arc()
.startAngle(d => d.x0 + startAngle)
.endAngle( d => d.x1 + startAngle)
.innerRadius(d => d.y0 + innerPad)
.outerRadius(d => d.y1 - innerPad);

const svg = d3.create("svg")
.attr("viewBox", [-width/2, -height/2, width, height])
.attr("width", width)
.attr("height", height)
.attr("style", "max-width:100%;height:auto;display:block;margin:auto;")
.attr("text-anchor", "middle");

svg.append("g")
.selectAll("path")
.data(root.descendants())
.join("path")
.attr("d", arc)
.attr("fill", taskHue)
.attr("stroke", "#fff")
.attr("stroke-width", 0.4);

svg.append("text")
.attr("font-family", "Playfair Display, Georgia, serif")
.attr("font-size", fontCenter)
.attr("text-anchor", "middle")
.attr("y", -fontCenter * 0.3) // ✅ this moves the whole label upward
.selectAll("tspan")
.data(["AVA", "Bench"])
.join("tspan")
.attr("x", 0)
.attr("dy", (d, i) => i === 0 ? "0em" : "1.2em")
.text(d => d);

const LONG_TASKS = new Set(["Action Recognition", "Object Recognition", "Scene Recognition"]);
const LONG_DATA = new Set(["Crowd Surveillance Dataset"]);

svg.append("g")
.selectAll("text")
.data(root.descendants().filter(d => d.depth > 0 && (d.y1 - d.y0) * (d.x1 - d.x0) > 6))
.join("text")
.attr("transform", d => {
const a = ((d.x0 + d.x1)/2 + startAngle) * 180/Math.PI;
const r = (d.y0 + d.y1)/2;
return `rotate(${a-90}) translate(${r},0) rotate(${a<180?0:180})`;
})
.attr("font-family", "sans-serif")
.attr("font-size", d => {
if (d.depth === 1) {
const base = d.data.name.replace(/\s*\(.*/, "");
return base === "Action Recognition" ? fontTask - 4 : fontTask; // smaller for Action Recognition
}
return fontDataset;
})
.attr("fill", "#000")
.selectAll("tspan")
.data(d => {
if (d.depth === 1) {
const base = d.data.name.replace(/\s*\(.*/, "");
return LONG_TASKS.has(base) ? base.split(" ") : [base];
}
if (d.depth > 1) {
const name = d.data.name;
if (LONG_DATA.has(name)) return ["Crowd Surveillance", "Dataset"];
return [name];
}
return [];
})
.join("tspan")
.attr("x", 0)
.attr("dy", (d,i)=> i ? "1em" : 0)
.text(d => d);

const percentArc = d3.arc()
.startAngle(d => d.x0 + startAngle)
.endAngle( d => d.x1 + startAngle)
.innerRadius(radius + outerPad)
.outerRadius(radius + outerPad + percentRingW);

const band = svg.append("g").selectAll("g").data(root.children).join("g");

band.append("path")
.attr("d", percentArc)
.attr("fill", d => d.hue)
.attr("fill-opacity", 0.18)
.attr("stroke", d => d.hue)
.attr("stroke-width", 0.4);

band.append("text")
.attr("transform", d => {
const a = ((d.x0 + d.x1)/2 + startAngle) * 180/Math.PI;
const r = radius + outerPad + percentRingW/2;
return `rotate(${a-90}) translate(${r},0) rotate(${a<180?0:180})`;
})
.attr("font-family", "sans-serif")
.attr("font-size", fontPercent)
.attr("font-weight", "bold")
.attr("fill", d => "#000")
.attr("text-anchor", "middle")
.selectAll("tspan")
.data(d => {
const rawTotal = d.data.name.match(/\((\d+)\)$/)?.[1];
const total = rawTotal ? d3.format(".1f")(+rawTotal / 1000) + "K" : "";
const pct = d.data.children.find(c => c.is_percentage_ring)?.name ?? "";
return [`${total}`, pct];
})
.join("tspan")
.attr("x", 0)
.attr("dy", (d,i)=> i ? "1em" : 0)
.text(d => d);

return svg.node();
}

Insert cell
import {howto} from "@d3/example-components"
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