spiralPlot = {
const width = 900, height = 900, radius = 350;
const svg = d3.create("svg")
.attr("viewBox", [-width / 2, -height / 2, width, height])
.style("background", "#0e0e0e")
.style("font-family", "sans-serif");
const color = d3.scaleSequential(d3.interpolateTurbo).domain([0, 1]);
const songsSorted = songs
.filter(d => d.energy >= 0 && d.valence >= 0 && d.tempo >= 0)
.sort((a, b) => a.year - b.year);
const spiralTurns = 10;
const total = songsSorted.length;
songsSorted.forEach((d, i) => {
const angle = (spiralTurns * 2 * Math.PI) * (i / total);
const radialDistance = radius * (d.energy || 0.01);
d.x = Math.cos(angle) * radialDistance;
d.y = Math.sin(angle) * radialDistance;
d.angle = angle;
});
svg.selectAll("circle.song")
.data(songsSorted)
.join("circle")
.attr("class", "song")
.attr("cx", d => d.x)
.attr("cy", d => d.y)
.attr("r", d => d.tempo < 60 ? 2 : d.tempo > 150 ? 6 : 4)
.attr("fill", d => color(d.valence))
.attr("stroke", "#000")
.attr("stroke-width", 0.4)
.attr("opacity", 0.8)
.append("title")
.text(d => `${d.song} by ${d.artist}
Genre: ${d.genre}
Tempo: ${Math.round(d.tempo)} BPM
Energy: ${d.energy}
Valence: ${d.valence}`);
// Draw radial grid lines and year labels
const years = [...new Set(songsSorted.map(d => d.year))];
years.forEach((year, i) => {
const idx = songsSorted.findIndex(d => d.year === year);
if (idx > -1) {
const d = songsSorted[idx];
svg.append("line")
.attr("x1", 0).attr("y1", 0)
.attr("x2", d.x).attr("y2", d.y)
.attr("stroke", "#555")
.attr("stroke-dasharray", "2,2")
.attr("stroke-width", 0.5);
svg.append("text")
.attr("x", d.x * 1.05)
.attr("y", d.y * 1.05)
.attr("fill", "#ddd")
.attr("text-anchor", "middle")
.attr("alignment-baseline", "middle")
.attr("transform", `rotate(${(d.angle * 180 / Math.PI).toFixed(1)},${d.x * 1.05},${d.y * 1.05})`)
.style("font-size", "10px")
.text(year);
}
});
// Add chart title and subtitle
svg.append("text")
.attr("x", 0).attr("y", -radius - 60)
.attr("text-anchor", "middle")
.attr("fill", "white")
.style("font-size", "20px")
.text("Spin Cycle: The Rhythm of Each Year");
svg.append("text")
.attr("x", 0).attr("y", -radius - 40)
.attr("text-anchor", "middle")
.attr("fill", "#aaa")
.style("font-size", "13px")
.text("Each dot is a song — placement by energy, color by valence, size by tempo");
// Set legend position to stay inside viewBox
const legendVals = [0, 0.25, 0.5, 0.75, 1];
const legendOffsetX = radius - 40;
const legendOffsetY = -radius + 20;
// Color legend (valence)
const legendGroup = svg.append("g")
.attr("transform", `translate(${legendOffsetX}, ${legendOffsetY})`);
legendGroup.selectAll("rect")
.data(legendVals)
.join("rect")
.attr("x", 0)
.attr("y", (d, i) => i * 20)
.attr("width", 18)
.attr("height", 18)
.attr("fill", d => color(d));
legendGroup.selectAll("text")
.data(legendVals)
.join("text")
.attr("x", 24)
.attr("y", (d, i) => i * 20 + 13)
.attr("fill", "white")
.text(d => `Valence: ${d}`);
legendGroup.append("text")
.attr("x", 0)
.attr("y", -10)
.attr("fill", "#aaa")
.style("font-size", "11px")
.text("Mood (Valence)");
// Size legend (tempo)
const sizeLegend = svg.append("g")
.attr("transform", `translate(${legendOffsetX}, ${legendOffsetY + 140})`);
[60, 120, 180].forEach((tempo, i) => {
sizeLegend.append("circle")
.attr("cx", 10)
.attr("cy", i * 30)
.attr("r", tempo < 80 ? 3 : tempo < 150 ? 5 : 7)
.attr("fill", "#ccc")
.attr("stroke", "#000");
sizeLegend.append("text")
.attr("x", 24)
.attr("y", i * 30 + 4)
.attr("fill", "#ddd")
.text(`${tempo} BPM`);
});
sizeLegend.append("text")
.attr("x", 0)
.attr("y", -10)
.attr("fill", "#aaa")
.style("font-size", "11px")
.text("Tempo (Bubble Size)");
return svg.node();
}