Spotify Popularity Contest
Observable
javascript
D3
interactive
Analysis of artist follower counts on Spotify by music genre. Interactive beeswarm chart created with Observable + D3, table created with
reactable
.
raw = FileAttachment("data/spotify-artists.csv").csv({typed: true})
filtered = raw.filter(i => new RegExp(artistGenre, 'i').test(i.genres));
sorted_data = filtered.sort((a, b) => b.followers - a.followers);
data = sorted_data.slice(0, numberArtists);
chart = {
const X = d3.map(data, (d) => d.followers);
const Image = d3.map(data, (d) => d.image_url);
const backgroundColor = "black";
const strokeColor = "white";
const highlightStroke = "#32E875";
const gridColor = "#32E875";
const xAxisColor = "#32E875";
const xMax = d3.max(data, (d) => d.followers);
const xMin = d3.min(data, (d) => d.followers);
const xDomain = [xMin, xMax];
const height = 600;
const width = 900;
const marginBottom = 30;
const marginLeft = 40;
const marginRight = 30;
const marginTop = 10;
const padding = 1.5;
const xRange = [marginLeft, width - marginRight];
const radius = 15;
// all positional computations thanks to Mike Bostock! code inspired by his notebook: https://observablehq.com/@d3/beeswarm-mirrored?collection=@d3/charts
// Compute which data points are considered defined.
const I = d3.range(X.length).filter((i) => !isNaN(X[i]));
// Compute default domains.
if (xDomain === undefined) xDomain = d3.extent(X);
// Construct scales and axes.
const xScale = d3.scaleLog(xDomain, xRange);
const xAxis = d3.axisBottom(xScale).tickSizeOuter(0);
// Compute the y-positions.
const Y = dodge(
I.map((i) => xScale(X[i])),
radius * 2 + padding
);
// Compute the default height;
if (height === undefined)
height =
(d3.max(Y, Math.abs) + radius + padding) * 2 + marginTop + marginBottom;
// Given an array of x-values and a separation radius, returns an array of y-values.
function dodge(X, radius) {
const Y = new Float64Array(X.length);
const radius2 = radius ** 2;
const epsilon = 1e-3;
let head = null,
tail = null;
// Returns true if circle ⟨x,y⟩ intersects with any circle in the queue.
function intersects(x, y) {
let a = head;
while (a) {
const ai = a.index;
if (radius2 - epsilon > (X[ai] - x) ** 2 + (Y[ai] - y) ** 2)
return true;
a = a.next;
}
return false;
}
// Place each circle sequentially.
for (const bi of d3.range(X.length).sort((i, j) => X[i] - X[j])) {
// Remove circles from the queue that can’t intersect the new circle b.
while (head && X[head.index] < X[bi] - radius2) head = head.next;
// Choose the minimum non-intersecting tangent.
if (intersects(X[bi], (Y[bi] = 0))) {
let a = head;
Y[bi] = Infinity;
do {
const ai = a.index;
let y1 = Y[ai] + Math.sqrt(radius2 - (X[ai] - X[bi]) ** 2);
let y2 = Y[ai] - Math.sqrt(radius2 - (X[ai] - X[bi]) ** 2);
if (Math.abs(y1) < Math.abs(Y[bi]) && !intersects(X[bi], y1))
Y[bi] = y1;
if (Math.abs(y2) < Math.abs(Y[bi]) && !intersects(X[bi], y2))
Y[bi] = y2;
a = a.next;
} while (a);
}
// Add b to the queue.
const b = { index: bi, next: null };
if (head === null) head = tail = b;
else tail = tail.next = b;
}
return Y;
}
//set up backdrop
const svg = d3
.create("svg")
.attr("viewBox", [0, 0, width, height])
.attr(
"style",
"max-width: 100%; height: auto; height: intrinsic; background-color:" +
backgroundColor
);
// create grid lines
function xGrid(g) {
g.attr("class", "grid-lines")
.selectAll("line")
.data(xScale.ticks())
.join("line")
.attr("x1", (d) => xScale(d))
.attr("x2", (d) => xScale(d))
.attr("y1", marginTop)
.attr("y2", height - marginBottom)
.style("stroke", gridColor);
}
// append x Axis
svg
.append("g")
.attr("class", "x-axis")
.attr("transform", `translate(0,${height - marginBottom})`)
.call(xAxis)
.attr("color", xAxisColor);
// append X grid lines
svg.append("g").call(xGrid);
const imgLength = radius * 2;
const toolTip = d3.select("body").append("div").attr("class", "toolTip");
// modified function from Ben Oldenburg: https://observablehq.com/@benoldenburg/patterns-tooltip
function nodeMouseOver(event, i) {
const multiplier = 1.8;
const newSize = imgLength * multiplier;
const newRadius = radius * multiplier;
const indexItem = i;
// Get the toolTip, update the position, and append the inner html depending on your content
// I tend to use template literal to more easily output variables.
toolTip
.style("left", event.pageX + 0 + "px")
.style("top", event.pageY + 18 + "px")
.style("display", "block")
.html(`<strong>${data[i].name}</strong><br><span style='text-align:right;'>${data[i].followers.toLocaleString()}</span>`);
d3.select(this).raise().attr("width", newSize).attr("height", newSize);
// .attr("x", (i) => xScale(X[i]) - newRadius)
// .attr(
// "y",
// (i) => (marginTop + height - marginBottom) / 2 + Y[i] - newRadius
// );
dots.filter((d, i) => i === indexItem).raise();
dots
.filter((d, i) => i === indexItem)
.selectAll("circle")
.transition()
.attr("r", newRadius)
.attr("stroke", highlightStroke)
.attr("stroke-width", newRadius / 7);
dots
.filter((d, i) => i === indexItem)
.selectAll("image")
.transition()
.attr("width", imgLength * multiplier)
.attr("height", imgLength * multiplier)
.attr("x", -imgLength * 0.9)
.attr("y", -imgLength * 0.9)
.attr("object-fit","cover");
// .raise();
}
// modified function from Ben Oldenburg: https://observablehq.com/@benoldenburg/patterns-tooltip
function nodeMouseOut(event, i) {
// Hide tooltip on mouse out
toolTip.style("display", "none"); // Hide toolTip
// d3.select(this)
// .attr("width", imgLength)
// .attr("height", imgLength)
// .attr("x", (i) => xScale(X[i]) - radius)
// .attr(
// "y",
// (i) => (marginTop + height - marginBottom) / 2 + Y[i] - radius
// );
dots
.selectAll("circle")
.transition()
.attr("cx", 0)
.attr("cy", 0)
.attr("r", radius)
.attr("stroke", strokeColor)
.attr("stroke-width", radius / 7);
dots
.selectAll("image")
.transition()
.attr("x", -imgLength / 2)
.attr("y", -imgLength / 2)
.attr("href", (i) => Image[i])
.attr("width", imgLength)
.attr("height", imgLength)
.attr("object-fit","cover");
// dot.attr("stroke", strokeColor).attr("r", radius);
}
// create backdrop of image to create border effect
const dots = svg
.append("g")
.attr("class", "circles-group")
.selectAll("g")
.data(I)
.join("g")
.attr(
"transform",
(i) =>
`translate(${xScale(X[i])}, ${
(marginTop + height - marginBottom) / 2 + Y[i]
})`
);
dots
// hovering over an artist should increase the image to draw focus
.on("mouseover", nodeMouseOver)
// hovering out returns image to regular size
.on("mouseout", nodeMouseOut);
const circles = dots
.append("circle")
.attr("cx", 0)
.attr("cy", 0)
.attr("r", radius)
.attr("stroke", strokeColor)
.attr("stroke-width", radius / 7);
const images = dots
.append("image")
.attr("x", -imgLength / 2)
.attr("y", -imgLength / 2)
.attr("href", (i) => Image[i])
.attr("width", imgLength)
.attr("height", imgLength)
;
return svg.node();
}