Public
Edited
May 5
Fork of Simple SVG
Insert cell
Insert cell
d3 = require("d3@7")
Insert cell
// Load and safely parse the dataset, coercing relevant values to numbers
songs = await FileAttachment("songs_normalize.csv").csv(d => ({
artist: d.artist,
song: d.song,
year: +d.year,
genre: d.genre,
tempo: +d.tempo,
energy: +d.energy,
valence: +d.valence
}))
Insert cell
spiralPlot = {
const width = 900, height = 900, radius = 350;

// Create an SVG canvas, centered with a dark background
const svg = d3.create("svg")
.attr("viewBox", [-width / 2, -height / 2, width, height])
.style("background", "#0e0e0e")
.style("font-family", "sans-serif");

// Sequential color scale to represent valence (mood)
const color = d3.scaleSequential(d3.interpolateTurbo).domain([0, 1]);

// Filter data for valid values and sort by year
const songsSorted = songs
.filter(d => d.energy >= 0 && d.valence >= 0 && d.tempo >= 0)
.sort((a, b) => a.year - b.year);

// Determine spiral layout parameters
const spiralTurns = 10;
const total = songsSorted.length;

// Calculate (x, y) positions on the spiral for each song
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;
});

// Draw each song as a circle, encoding:
// - Color = valence (mood)
// - Size = tempo (BPM)
// - Radial distance = energy
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();
}
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