Public
Edited
May 1
Insert cell
Insert cell
Insert cell
data.csv
Type Table, then Shift-Enter. Ctrl-space for more options.

Insert cell
Insert cell
Insert cell
d3 = require("d3@7")

Insert cell
parsedData = d3.csvParse(await FileAttachment("data.csv").text(), d3.autoType)
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
/**
* Processes and formats the CSV data to calculate average metascores per year.
* @example
* yearlyScores = formatScoreTrendsData(parsedData);
* @param {Array<Object>} data - The parsed CSV array where each object contains:
* 'Name' (string), 'Platform' (string), 'Metascore' (number), and 'Date' (string).
* @returns {Array<Object>} - array of { year, avgScore }
*/
// Used ChatGPT for understanding how to use the metascores and years in our graphing
function formatScoreTrendsData(data) {
const parseYear = d3.timeParse("%d-%b-%y");

const processedData = data
.map(d => {
const parsedDate = parseYear(d.Date);
let year;
if (parsedData) {
year = parsedDate.getFullYear();
}
return { name: d.Name,
platform: d.Platform,
metascore: d.Metascore,
year: year
};
})
.filter(d => d.metascore && d.year);

const yearlyScores = Array.from(
d3.rollup(processedData, v => d3.mean(v, d => d.metascore), d => d.year),
([year, avgScore]) => ({ year, avgScore })
).sort((a, b) => d3.ascending(a.year, b.year));

return yearlyScores;
}

Insert cell
Insert cell
/**
* Plots a line chart of average metascores over time
* @example
* plotScoreTrendsChart(yearlyScores)
* @param {Array<Object>} yearlyScores - formatted array with 'year' and 'avgScore'
* @returns {SVGElement} - A D3 line chart
*/
// Used https://observablehq.com/@d3/line-chart/2 for reference when creating the chart
function plotScoreTrendsChart(yearlyScores) {
const width = 800;
const height = 400;
const marginTop = 40;
const marginRight = 30;
const marginBottom = 40;
const marginLeft = 60;

// Declare the x (horizontal position) scale.
const x = d3.scaleLinear()
.domain(d3.extent(yearlyScores, d => d.year))
.nice()
.range([marginLeft, width - marginRight]);

// Declare the y (vertical position) scale.
const y = d3.scaleLinear()
.domain([d3.min(yearlyScores, d => d.avgScore) - 1, d3.max(yearlyScores, d => d.avgScore) + 1])
.nice()
.range([height - marginBottom, marginTop]);

// Declare the line generator.
const line = d3.line()
.x(d => x(d.year))
.y(d => y(d.avgScore));

// Create the SVG container.
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");

// Add the x-axis.
svg.append("g")
.attr("transform", `translate(0,${height - marginBottom})`)
.call(d3.axisBottom(x).tickFormat(d3.format("d")))
.call(g => g.append("text")
.attr("x", (width - marginLeft - marginRight))
.attr("y", marginBottom - 5)
.attr("fill", "currentColor")
.attr("text-anchor", "middle")
.text("Year"));

// Add the y-axis and add grid lines/label.
svg.append("g")
.attr("transform", `translate(${marginLeft},0)`)
.call(d3.axisLeft(y))
.call(g => g.select(".domain").remove())
.call(g => g.selectAll(".tick line").clone()
.attr("x2", width - marginLeft - marginRight)
.attr("stroke-opacity", 0.1))
.call(g => g.append("text")
.attr("x", -marginLeft)
.attr("y", 10)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.text("Average Metascore"));

svg.append("path")
.datum(yearlyScores)
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 2)
.attr("d", line);

// draws the circles for the data points
svg.append("g")
.selectAll("circle")
.data(yearlyScores)
.enter()
.append("circle")
.attr("cx", d => x(d.year))
.attr("cy", d => y(d.avgScore))
.attr("r", 3)
.attr("fill", "black");

return svg.node();
}

Insert cell
yearlyScores = formatScoreTrendsData(parsedData)

Insert cell
plotScoreTrendsChart(yearlyScores)
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
/**
* Aggregates total number of games per platform from parsedData (no score filtering)
* @example preparePieData(parsedData)
* @param {Array<Object>} data - The parsed data with 'array'
* @returns {Array<Object>} - array of { platform, count }
*/
function preparePieData(data) {
return Array.from(
d3.rollup(
data,
v => v.length, // Count number of games
d => d.Platform // Group by platform
),
([platform, count]) => ({ platform, count })
).sort((a, b) => d3.descending(a.count, b.count));
}

Insert cell
pieData = preparePieData(parsedData)
Insert cell
// https://observablehq.com/@d3/pie-chart-component for reference
/**
* Plots a pie chart of percentage of games which take up a certain console
* @param {Array<Object>} yearlyScores - formatted array with 'platform' and 'count'
* @returns {SVGElement} - A D3 pie chart
*/
// used ChatGPT to understand and incorporate the polylines as well as adjust the labels of the image outside and the viewport
function drawPieChart(data) {
const width = 600;
const height = 600;
const radius = Math.min(width, height) / 2;
const padding = 100;

const color = d3.scaleOrdinal()
.domain(data.map(d => d.platform))
.range(d3.schemeTableau10);

const pie = d3.pie()
.value(d => d.count)
.sort(null);

const arc = d3.arc()
.innerRadius(0)
.outerRadius(radius - 10);

const labelArc = d3.arc()
.innerRadius(radius * 0.5)
.outerRadius(radius * 0.5);

const outerLabelArc = d3.arc()
.innerRadius(radius + 20)
.outerRadius(radius + 20);

// Define line start arc to avoid overlapping percentages
const lineStartArc = d3.arc()
.innerRadius(radius * 0.7)
.outerRadius(radius * 0.7);

const total = d3.sum(data, d => d.count);

const svg = d3.create("svg")
.attr("width", width + padding * 2)
.attr("height", height + padding * 2)
.attr("viewBox", [-width / 2 - padding, -height / 2 - padding, width + padding * 2, height + padding * 2])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");

const g = svg.append("g");

const slices = g.selectAll("g")
.data(pie(data))
.enter()
.append("g");

// draws pie slices
slices.append("path")
.attr("d", arc)
.attr("fill", d => color(d.data.platform))
.append("title")
.text(d => `${d.data.platform}: ${d.data.count} games (${((d.data.count / total) * 100).toFixed(1)}%)`);

// percentages inside the pie
slices.append("text")
.attr("transform", d => `translate(${labelArc.centroid(d)})`)
.attr("dy", "0.35em")
.attr("text-anchor", "middle")
.attr("font-size", "10px")
.text(d => `${((d.data.count / total) * 100).toFixed(1)}%`);

// add the polylines to make it much easier to distinguish each slice
slices.append("polyline")
.attr("points", d => {
const lineStart = lineStartArc.centroid(d);
const intermediate = outerLabelArc.centroid(d);
const midAngle = (d.startAngle + d.endAngle) / 2;
const offset = 30;
const outsidePos = [
intermediate[0] + (midAngle > Math.PI ? -offset : offset),
intermediate[1]
];
return [lineStart, intermediate, outsidePos];
})
.attr("stroke", "black")
.attr("fill", "none")
.attr("stroke-width", 0.5);

// outside labels connected by a line
const labels = slices.append("text")
.attr("dy", "0.35em")
.attr("font-size", "10px")
.attr("text-anchor", d => {
const midAngle = (d.startAngle + d.endAngle) / 2;
return midAngle > Math.PI ? "end" : "start";
})
.text(d => d.data.platform)
.each(function(d) {
const pos = outerLabelArc.centroid(d);
const midAngle = (d.startAngle + d.endAngle) / 2;
const offset = 35;
const outsidePos = [
pos[0] + (midAngle > Math.PI ? -offset : offset),
pos[1]
];
d.labelPos = outsidePos; // Save label position for relaxation
});

/**
* Pushes the labels away from each other if they overlap
* @returns {boolean} which specifies whether labels were adjusted during the iteration
**/
// Used ChatGPT to figure out a way to prevent overlapping labels in the pie chart
function relaxLabels() {
let again = false;
const spacing = 14;

labels.each(function(d, i) {
const a = d.labelPos;
labels.each(function(d2, j) {
if (i === j) return;
const b = d2.labelPos;
if (Math.abs(a[1] - b[1]) < spacing) {
again = true;
const adjust = (spacing - Math.abs(a[1] - b[1])) / 2;
if (a[1] > b[1]) {
a[1] += adjust;
b[1] -= adjust;
} else {
a[1] -= adjust;
b[1] += adjust;
}
}
});
});

labels.attr("transform", d => `translate(${d.labelPos})`);

// update the polylines to match label positions
slices.selectAll("polyline")
.attr("points", d => {
const lineStart = lineStartArc.centroid(d);
const intermediate = outerLabelArc.centroid(d);
return [lineStart, intermediate, d.labelPos];
});

if (again) setTimeout(relaxLabels, 20);
}

relaxLabels();

return svg.node();
}

Insert cell
drawPieChart(pieData)
Insert cell
Insert cell
Insert cell
Insert cell
sankeyLib = require("d3-sankey@0.12.3")
Insert cell
formatParallelSetData = (parsedData) => {
const bucket = score => {
if (score === 94) return "Fair";
if (score === 95) return "Decent";
if (score === 96) return "Good";
if (score === 97) return "Great";
if (score === 98 || score === 99) return "Excellent";
return "Unknown";
};

const counts = d3.rollup(
parsedData,
v => v.length,
d => d.Platform,
d => new Date(d.Date).getFullYear(),
d => bucket(d.Metascore)
);

const nodes = new Map();
const links = [];

const getNode = name => {
if (!nodes.has(name)) nodes.set(name, { name });
return nodes.get(name);
};

for (const [platform, years] of counts.entries()) {
for (const [year, buckets] of years.entries()) {
const yearStr = year.toString();
const platformNode = getNode(platform);
const yearNode = getNode(yearStr);

let total = 0;
for (const count of buckets.values()) total += count;

links.push({
names: [platform, yearStr],
source: platformNode,
target: yearNode,
value: total
});

for (const [bucketName, count] of buckets.entries()) {
const bucketNode = getNode(bucketName);
links.push({
names: [yearStr, bucketName],
source: yearNode,
target: bucketNode,
value: count
});
}
}
}

return {
nodes: Array.from(nodes.values()),
links: links
};
}

Insert cell
/**
* Plots a parallel sets (Sankey-style) diagram to show the flow of games across categories.
* Highlights all upstream paths that lead to the "excellent" category.
*
* @param {Object} formattedData - An object with `nodes` and `links` arrays representing the graph structure.
* @returns {SVGElement} - A D3-rendered Sankey-style diagram.
*/
// Used ChatGPT to implement backtracking logic to highlight all links that eventually lead to "excellent"
function drawParallelSet(formattedData) {
const width = 900;
const height = 600;
const margin = { top: 20, right: 150, bottom: 20, left: 150 };

const svg = d3.create("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.attr("viewBox", [0, 0, width + margin.left + margin.right, height + margin.top + margin.bottom])
.attr("style", "max-width: 100%; height: auto; font: 10px sans-serif;");

const g = svg.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);

const { sankey, sankeyLinkHorizontal } = sankeyLib;

const sankeyGen = sankey()
.nodeId(d => d.name)
.nodeWidth(15)
.nodePadding(10)
.extent([[0, 0], [width, height]]);

const { nodes: sankeyNodes, links: sankeyLinks } = sankeyGen({
nodes: formattedData.nodes,
links: formattedData.links
});

/**
* Recursively traces all links that lead to a given target node.
*
* @param {string} targetName - Name of the node to trace upstream from.
* @param {Array<Object>} links - Array of Sankey-style link objects.
* @param {Set<Object>} [visited=new Set()] - Used internally to avoid revisiting links.
* @returns {Array<Object>} - Array of links that lead to the target node.
*/
// Used ChatGPT to implement this recursive backtrace to highlight entire upstream trail
function getUpstreamLinks(targetName, links, visited = new Set()) {
const relatedLinks = [];

function dfs(currentTarget) {
for (const link of links) {
if (link.target.name === currentTarget && !visited.has(link)) {
visited.add(link);
relatedLinks.push(link);
dfs(link.source.name);
}
}
}

dfs(targetName);
return relatedLinks;
}

const excellentTargets = sankeyNodes.filter(d => d.name.toLowerCase().includes("excellent"));
const highlightedLinks = new Set();
const highlightedNodeNames = new Set();

excellentTargets.forEach(node => {
const upstream = getUpstreamLinks(node.name, sankeyLinks);
upstream.forEach(link => {
highlightedLinks.add(link);
highlightedNodeNames.add(link.source.name);
highlightedNodeNames.add(link.target.name);
});
});

// draw the connecting trails
g.append("g")
.attr("fill", "none")
.selectAll("path")
.data(sankeyLinks)
.join("path")
.attr("d", sankeyLinkHorizontal())
.attr("stroke", d => highlightedLinks.has(d) ? "#ff5733" : "#ccc")
.attr("stroke-opacity", d => highlightedLinks.has(d) ? 0.9 : 0.15)
.attr("stroke-width", d => Math.max(1, d.width))
.append("title")
.text(d => `${d.source.name} → ${d.target.name}\n${d.value} games`);
g.append("g")
.attr("stroke", "#000")
.selectAll("rect")
.data(sankeyNodes)
.join("rect")
.attr("x", d => d.x0)
.attr("y", d => d.y0)
.attr("height", d => d.y1 - d.y0)
.attr("width", d => d.x1 - d.x0)
.attr("fill", d => highlightedNodeNames.has(d.name) ? "#ffb347" : "green")
.append("title")
.text(d => `${d.name}\n${d.value} games`);

// labels for the nodes
g.append("g")
.style("font", "10px sans-serif")
.selectAll("text")
.data(sankeyNodes)
.join("text")
.attr("x", d => d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6)
.attr("y", d => (d.y1 + d.y0) / 2)
.attr("dy", "0.35em")
.attr("text-anchor", d => d.x0 < width / 2 ? "start" : "end")
.attr("font-weight", d => highlightedNodeNames.has(d.name) ? "bold" : "normal")
.text(d => d.name);

return svg.node();
}

Insert cell
formattedData = formatParallelSetData(parsedData)
Insert cell
drawParallelSet(formattedData)
Insert cell
Insert cell
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