Public
Edited
May 1
Insert cell
Insert cell
Insert cell
Insert cell
d3 = require("d3@6")
Insert cell
Insert cell
data = d3.csvParse(
await FileAttachment("data.csv").text(),
d => {
const row = d3.autoType(d);
return {
...row,
// Refine and standerlize
platform: row.Platform,
score: row.Metascore,
year: parseYear(row.Date)
};
}
);

Insert cell
Insert cell
// Get the year of the game published
/**
* Extracts the four-digit year from a date string.
*
* @param {string} dateString - The original release date in the format "DD-MMM-YY", e.g. "23-Nov-98".
* @returns {number} The full four-digit year, e.g. 1998.
*
* @example
* parseYear("05-Jul-07"); will return 2007
*/
parseYear = (dateString, format = "%d-%b-%y") => {
const parseDate = d3.timeParse(format);
const d = parseDate(dateString);
return d.getFullYear();
};

Insert cell
Insert cell
chart = {
// Get score and year
data.forEach(d => {
d.score = +d.Metascore;
d.year = parseYear(d.Date);
});

// Group the data by platform
// Create a Map where the key is the platform name and the value
// This is an array of all games released on that platform.
const byPlatform = d3.group(data, d => d.platform);

// A debug session to show the group, which makes sure I can monitor the change of group
console.log(
"Groups (platform: count):",
Array.from(byPlatform, ([platform, items]) => `${platform}: ${items.length}`)
);

// Within each platform, bucket scores into 5-point bins and count per year.
// Define a function that, given a score, returns its 5-point interval label.
const scoreBin = d => {
const f = Math.floor(d.score / 5) * 5;
return `${f}-${f + 5}`;
};

// Prepare an array to hold our flattened results.
// Each element will be { platform, scoreRange, year, count }.
const bubbleData = [];
// For each platform group:
for (const [platform, items] of byPlatform) {
// Roll up the platform’s items into a nested Map: Map<scoreRange, Map<year, count>>
const byBinYear = d3.rollup(
items,
v => v.length,
scoreBin,
d => d.year
);

// Turn that nested Map into individual objects
for (const [scoreRange, yearMap] of byBinYear) {
for (const [year, count] of yearMap) {
bubbleData.push({ platform, scoreRange, year, count });
}
}
}

// Set up chart dimensions and margins
const width = 800;
const height = 500;
const margin = { top: 20, right: 50, bottom: 40, left: 100 };

// Build D3 scales
// X scale shows the year the game was published
// data domain = years
// d3.scaleLinear for the X axis (year)
const x = d3.scaleLinear()
.domain(d3.extent(bubbleData, d => d.year)).nice()
.range([margin.left, width - margin.right]);

// Y scale shows the platform of the games
// d3.scaleBand for the Y axis (platform)
const platforms = Array.from(new Set(bubbleData.map(d => d.platform)));
const y = d3.scaleBand()
.domain(platforms)
.range([margin.top, height - margin.bottom])
.padding(0.2);

// R-scale is the size of the circles
// data domain = counts
// d3.scaleSqrt for bubble radius (count)
const rScale = d3.scaleSqrt()
.domain([0, d3.max(bubbleData, d => d.count)])
.range([0, 20]);

// Indicate the color of the circles. The deep color indicates a higher volume of games in the group.
// d3.scaleSequential + d3.interpolateReds for bubble color (score bin lower bound)
const lows = bubbleData.map(d => +d.scoreRange.split("-")[0]);
const colorScale = d3.scaleSequential(d3.interpolateReds)
.domain(d3.extent(lows));

// Create the image
// d3.create for creating the image
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.style("width", "100%");

// Create X and Y axis in the image
// d3.axisBottom generates a bottom-oriented axis using the x scale, and .tickFormat(d3.format("d")) formats tick values as integers
svg.append("g")
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).tickFormat(d3.format("d")));

// Draw Y axis on the left
// d3.axisLeft generates a left-oriented axis using the Y scale
svg.append("g")
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y));

// Labels
svg.append("text")
.attr("x", margin.left + (width - margin.left - margin.right) / 2)
.attr("y", height - margin.bottom + 40)
.attr("text-anchor", "middle")
.attr("font-size", 12)
.text("Year of Release");
svg.append("text")
.attr("transform", `rotate(-90)`)
.attr("x", - (margin.top + (height - margin.top - margin.bottom) / 2))
.attr("y", margin.left - 90)
.attr("text-anchor", "middle")
.attr("font-size", 12)
.text("Platform");

// Draw circles
svg.selectAll("circle")
.data(bubbleData)
.join("circle")
.attr("cx", d => x(d.year))
.attr("cy", d => y(d.platform) + y.bandwidth()/2)
.attr("r", d => rScale(d.count))
.attr("fill", d => colorScale(+d.scoreRange.split("-")[0]))
.attr("fill-opacity", 0.7)
.append("title")
.text(d => `${d.platform}, ${d.year}\n${d.scoreRange}: ${d.count} games`);

// Legend of color gradient bar for score bins (lightest to darkest)
const defs = svg.append("defs");
// Define a horizontal linear gradient from min to max score bin
const gradient = defs.append("linearGradient")
.attr("id", "score-gradient")
.attr("x1", "0%").attr("y1", "0%")
.attr("x2", "100%").attr("y2", "0%");
// Lightest color at 0%
gradient.append("stop")
.attr("offset", "0%")
.attr("stop-color", colorScale(d3.min(lows)));
// Darkest color at 100%
gradient.append("stop")
.attr("offset", "100%")
.attr("stop-color", colorScale(d3.max(lows)));
// Legend dimensions and position
const legendWidth = 200;
const legendHeight = 10;
const legendX = width - margin.right - legendWidth;
const legendY = margin.top;
// Draw the gradient rectangle
svg.append("rect")
.attr("x", legendX)
.attr("y", legendY)
.attr("width", legendWidth)
.attr("height", legendHeight)
.style("fill", "url(#score-gradient)");
// Labels for the ends of the gradient
svg.append("text")
.attr("x", legendX)
.attr("y", legendY - 5)
.attr("text-anchor", "start")
.text("Lowest bin");
svg.append("text")
.attr("x", legendX + legendWidth)
.attr("y", legendY - 5)
.attr("text-anchor", "end")
.text("Highest bin");

return svg.node();
}

Insert cell
Insert cell
Insert cell
streamChart = {
// Compute unique platform list and then sort the years
const platforms = Array.from(new Set(data.map(d => d.platform)));
const years = Array.from(new Set(data.map(d => d.year))).sort((a, b) => a - b);

// Roll up counts by year and platform
// d3.rollup: takes an array, an aggregator, and one or more key functions
// Here it produces the maap that represents Map<year, Map<platform, count>>
const counts = d3.rollup(
data,
v => v.length,
d => d.year,
d => d.platform
);

// Pivot into into a wider array for stacking, which is looks like an array of {year, [platform]: count, …}
const seriesData = years.map(year => {
const row = { year };
platforms.forEach(p => {
row[p] = counts.get(year)?.get(p) ?? 0;
});
return row;
});

// Compute the stream‐graph stack
// d3.stack prepares layers for each key, and .offset(d3.stackOffsetWiggle) makes the layers flow around a central axis
const stackGen = d3.stack()
.keys(platforms)
.offset(d3.stackOffsetWiggle);

const series = stackGen(seriesData);

// Set up dimensions and margins
const width = 800;
const height = 400;
const margin = { top: 20, right: 200, bottom: 50, left: 50 };

// Set up Scales
// X scale is map [minYear, maxYear] → pixel range
// d3.extent computes [min, max] of the years
const x = d3.scaleLinear()
.domain(d3.extent(years))
.range([margin.left, width - margin.right]);

// Y scale is map [minStack, maxStack] → pixel range
const yExtent = [
d3.min(series, s => d3.min(s, d => d[0])),
d3.max(series, s => d3.max(s, d => d[1]))
];
const y = d3.scaleLinear()
.domain(yExtent)
.range([height - margin.bottom, margin.top]);

// Color scale is to assign each platform a distinct color
const color = d3.scaleOrdinal()
.domain(platforms)
.range(d3.schemeCategory10);

// Area generator for each layer
// d3.area constructs an area path from data points and .curve(d3.curveBasis) smooths the edges
const area = d3.area()
.x((d, i) => x(years[i]))
.y0(d => y(d[0]))
.y1(d => y(d[1]))
.curve(d3.curveBasis);

// Build SVG container
// d3.create for creating the container
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.style("width", "100%");

// Draw each platform’s stream
svg.selectAll("path")
.data(series)
.join("path")
.attr("fill", d => color(d.key))
.attr("fill-opacity", 0.7)
.attr("stroke", "none")
.attr("d", area);

// Add X axis (years)
svg.append("g")
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).tickFormat(d3.format("d")));

// Add Y axis (stack offset value)
svg.append("g")
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y).ticks(5));

// Labels
svg.append("text")
.attr("x", margin.left + (width - margin.left - margin.right) / 2)
.attr("y", height - margin.bottom + 50)
.attr("text-anchor", "middle")
.attr("font-size", 12)
.text("Year of Release");
svg.append("text")
.attr("transform", `rotate(-90)`)
.attr("x", - (margin.top + (height - margin.top - margin.bottom) / 2))
.attr("y", margin.left - 30)
.attr("text-anchor", "middle")
.attr("font-size", 12)
.text("Stack Offset Value");

// Legend to show color for each platform
const legend = svg.append("g")
.attr("transform", `translate(${width - margin.right + 20}, ${margin.top})`);
platforms.forEach((p, i) => {
const entry = legend.append("g")
.attr("transform", `translate(0, ${i * 20})`);
entry.append("rect")
.attr("width", 15)
.attr("height", 15)
.attr("fill", color(p))
.attr("fill-opacity", 0.8);
entry.append("text")
.attr("x", 20)
.attr("y", 12)
.text(p);
});

return svg.node();
}

Insert cell
Insert cell
Insert cell
parallelChart = {
// Extract unique platforms and the list of axes
const platforms = Array.from(new Set(data.map(d => d.platform)));
const dimensions = ["platform", "year", "score"];
// Prepare dimensions and margins
const width = 800;
const height = 500;
const margin = { top: 40, right: 10, bottom: 40, left: 120 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;

// Build a scale for each axis
// d3.scalePoint for categorical platform axis
// d3.scaleLinear for numeric year and score axes
const scales = {
platform: d3.scalePoint()
.domain(platforms)
.range([innerHeight, 0]),
year: d3.scaleLinear()
.domain(d3.extent(data, d => d.year)).nice()
.range([innerHeight, 0]),
score: d3.scaleLinear()
.domain(d3.extent(data, d => d.score)).nice()
.range([innerHeight, 0])
};

// X position for each axis, map dimension name to horizontal position
const x = d3.scalePoint()
.domain(dimensions)
.range([0, innerWidth]);

// Line generator that steps through each dimension
// d3.line constructs a path from points and .curve(d3.curveMonotoneX) smooths the lines
const line = d3.line()
.curve(d3.curveMonotoneX)
.x((_, i) => x(dimensions[i]))
.y((_, i, dataPoint) => {
// dataPoint is the array [d.platform, d.year, d.score]
const key = dimensions[i];
return scales[key](dataPoint[i]);
});

// Color scale by platform
// d3.scaleOrdinal() creates an ordinal scale: a mapping from a discrete set of input values to a discrete set of output values (colors).
// d3.schemeCategory10 is D3’s built-in array of ten categorical colors.
const color = d3.scaleOrdinal()
.domain(platforms)
.range(d3.schemeCategory10);

// Create SVG container and group by drawing
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.style("width", "100%");
const g = svg.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);

// Draw the parallel‐coordinate lines
g.append("g")
.selectAll("path")
.data(data)
.join("path")
.attr("d", d => line([d.platform, d.year, d.score]))
.attr("fill", "none")
.attr("stroke", d => color(d.platform))
.attr("stroke-opacity", 0.3);

// Draw axises
// d3.axisLeft(...) creates a left-oriented axis generator using the given scale and .call(...) invokes that generator on the group, rendering tick lines and labels
dimensions.forEach(key => {
const axisG = g.append("g")
.attr("transform", `translate(${x(key)},0)`);
const axis = key === "year"
? d3.axisLeft(scales[key]).tickFormat(d3.format("d"))
: d3.axisLeft(scales[key]);
axisG.call(d3.axisLeft(scales[key]));
axisG.append("text")
.attr("y", -12)
.attr("x", 0)
.attr("text-anchor", "middle")
.text(key.charAt(0).toUpperCase() + key.slice(1));

axisG.append("text")
.attr("y", innerHeight + 20)
.attr("x", 0)
.attr("text-anchor", "middle")
.text(key.charAt(0).toUpperCase() + key.slice(1));
});

return svg.node();
}

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