| <!DOCTYPE html> |
| <meta charset="utf-8"> |
| <style> |
| body { |
| font-family: "Segoe UI", sans-serif; |
| text-align: center; |
| margin-top: 20px; |
| } |
| |
| svg { |
| font: 10px sans-serif; |
| } |
| |
| .label { |
| font-weight: bold; |
| font-size: 12px; |
| text-anchor: middle; |
| } |
| |
| .legend { |
| font-size: 12px; |
| text-anchor: start; |
| } |
| |
| .legend rect { |
| stroke-width: 1; |
| stroke: #000; |
| } |
| </style> |
| <body> |
| <h2>Sunburst Chart: Country → Profession</h2> |
| <button id="downloadBtn">Download SVG</button> |
| <svg width="800" height="800"></svg> |
| <script src="https://d3js.org/d3.v6.min.js"></script> |
| <script> |
| const width = 800; |
| const radius = width / 3; |
| |
| const colorMap = { |
| "Adult Performer": "#8A2BE2", |
| "Model": "#DC143C", |
| "Actor": "#FF7F50", |
| "Performer": "magenta", |
| "Singer, Musician": "wheat", |
| "TV Personality": "#708090", |
| "Sports Professional": "gold", |
| "Public Figure": "maroon", |
| "Voice Actor": "lightgreen", |
| "Online Personality": "#4682B4", |
| "Other": "#ccc" |
| }; |
| |
| |
| |
| |
| |
| const professionOrder = [ |
| "Adult Performer", "Actor", "Singer, Musician", "Model", "Online Personality", "Public Figure", "Sports Professional", "Voice Actor", "TV Personality", "Other" |
| ]; |
| const manualOrder = [ |
| "United States", "Japan", "South Korea", "United Kingdom", |
| "Russia", "China", "Canada", "India", "Australia", "France", "Germany", "Other" |
| ]; |
| |
| function customSort(a, b, order) { |
| const aIndex = order.indexOf(a.data.name); |
| const bIndex = order.indexOf(b.data.name); |
| return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex); |
| } |
| |
| function partition(data) { |
| const root = d3.hierarchy(data).sum(d => d.value); |
| |
| |
| root.children.sort((a, b) => customSort(a, b, manualOrder)); |
| |
| |
| root.children.forEach(country => { |
| if (country.children) { |
| country.children.sort((a, b) => { |
| if (a.data.name === "Other") return 1; |
| if (b.data.name === "Other") return -1; |
| return b.value - a.value; |
| }); |
| } |
| }); |
| |
| return d3.partition().size([2 * Math.PI, root.height + 1])(root); |
| } |
| |
| |
| |
| d3.json("json/sunburst_gender.json").then(data => { |
| const root = partition(data); |
| root.each(d => d.current = d); |
| |
| |
| root.children.sort((a, b) => customSort(a, b, manualOrder)); |
| |
| |
| root.children.forEach(country => { |
| if (country.children) { |
| country.children.sort((a, b) => customSort(a, b, professionOrder)); |
| } |
| }); |
| |
| const svg = d3.select("svg") |
| .attr("viewBox", [0, 0, width, width]) |
| .style("font", "10px sans-serif"); |
| |
| const g = svg.append("g") |
| .attr("transform", `translate(${width / 2},${width / 2})`); |
| |
| const angleOffset = -80 * Math.PI / 180; |
| |
| const arc = d3.arc() |
| .startAngle(d => d.x0 + angleOffset) |
| .endAngle(d => d.x1 + angleOffset) |
| .innerRadius(d => { |
| if (d.depth === 1) return radius * 0.1; |
| if (d.depth === 2) return radius * 0.5; |
| }) |
| .outerRadius(d => { |
| if (d.depth === 1) return radius * 0.5; |
| if (d.depth === 2) return radius * 0.65; |
| }); |
| |
| |
| const path = g.append("g") |
| .selectAll("path") |
| .data(root.descendants().slice(1)) |
| .join("path") |
| .attr("fill", d => { |
| if (d.depth === 2) return colorMap[d.data.name] || "#ccc"; |
| if (d.depth === 1) return "#fff"; |
| return "none"; |
| }) |
| .attr("stroke", d => d.depth === 1 ? "#000" : null) |
| .attr("stroke-width", d => d.depth > 0 ? 2 : null) |
| .attr("d", arc); |
| |
| path.append("title") |
| .text(d => `${d.ancestors().map(d => d.data.name).reverse().join(" → ")}\n${d.value}`); |
| |
| const genderCounts = {}; |
| root.children.forEach(d => { |
| const gender = d.data.name; |
| genderCounts[gender] = d.value; |
| }); |
| |
| |
| g.selectAll("text") |
| .data(root.children) |
| .join("text") |
| .attr("transform", function(d) { |
| const angle = ((d.x0 + d.x1) / 2) + angleOffset; |
| const x = Math.cos(angle - Math.PI / 2) * (radius / root.height + -50); |
| const y = Math.sin(angle - Math.PI / 2) * (radius / root.height + -50); |
| return `translate(${x},${y}) rotate(${(angle / Math.PI)})`; |
| }) |
| .attr("dy", "0.35em") |
| .attr("class", "label") |
| .text(d => `${d.data.name}: ${genderCounts[d.data.name] || 0}`); |
| |
| |
| |
| const professionCounts = {}; |
| root.descendants().forEach(d => { |
| if (d.depth === 2) { |
| const name = d.data.name; |
| professionCounts[name] = (professionCounts[name] || 0) + d.value; |
| } |
| }); |
| |
| |
| const legend = svg.append("g") |
| .attr("class", "legend") |
| .attr("transform", `translate(20,20)`); |
| |
| professionOrder.forEach((prof, i) => { |
| const row = legend.append("g") |
| .attr("transform", `translate(0,${i * 20})`); |
| |
| row.append("rect") |
| .attr("width", 16) |
| .attr("height", 16) |
| .attr("fill", colorMap[prof]); |
| |
| row.append("text") |
| .attr("x", 22) |
| .attr("y", 12) |
| .text(`${prof} (${professionCounts[prof] || 0})`); |
| }); |
| }); |
| |
| document.getElementById("downloadBtn").addEventListener("click", () => { |
| const svgNode = document.querySelector("svg"); |
| |
| |
| const clonedSvg = svgNode.cloneNode(true); |
| const outer = document.createElement("div"); |
| outer.appendChild(clonedSvg); |
| |
| |
| clonedSvg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); |
| |
| |
| const svgData = new XMLSerializer().serializeToString(clonedSvg); |
| const svgBlob = new Blob([svgData], { type: "image/svg+xml;charset=utf-8" }); |
| |
| |
| const url = URL.createObjectURL(svgBlob); |
| const a = document.createElement("a"); |
| a.href = url; |
| a.download = "sunburst_chart.svg"; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| URL.revokeObjectURL(url); |
| }); |
| |
| </script> |
| </body> |
|
|