Public
Edited
Sep 20, 2023
2 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
updateSelectedFilmsList = function() {
const selectedFilms = getSelectedFilms();

selectedFilms.sort((a, b) => a[filmListOrder] > b[filmListOrder] ? 1 : -1);

filmDiv.selectAll("div.list").remove();
const films = filmDiv.append("div")
.attr("class", "list");

const film = films.selectAll("div.film").data(selectedFilms).join("div")
.attr("class", "film")
.on("mouseenter", (e, d) => {
svg.selectAll("rect.cell.film").classed("hover", di => {
return di[0] === d[ATTR_X] && di[1] === d[ATTR_Y];
});
})
.on("mouseleave", () => {
svg.selectAll("rect.cell.film").classed("hover", false);
});

film.append("div")
.attr("class", "type")
.style("background", d => scaleColorCategorical(d[ATTR_CAT]));
film.append("a")
.attr("href", d => `https://www.imdb.com/title/${d[ATTR_URL]}`)
.attr("target", "_blank")
.text(d => `${d[ATTR_TITLE]} (${d[ATTR_X]})`);
}
Insert cell
getSelectedFilms = () => filteredData
.filter(d => clickedCell.length === 0 || clickedCell.indexOf(d) > -1)
.filter(d => selectedInductionYears.length === 0 || selectedInductionYears.indexOf(+d[ATTR_Y]) > -1)
.filter(d => selectedReleaseYears.length === 0 || selectedReleaseYears.indexOf(+d[ATTR_X]) > -1)
Insert cell
drawAxes = function() {
const dateExtent = d3.extent(scaleX.domain()).map(d => new Date(`${d}-01-01`));
const timeScale = d3.scaleTime()
.domain(dateExtent)
.range(scaleX.range());
const axisX = d3.axisBottom(timeScale);
const axisY = d3.axisRight(scaleY);

const axes = svg.append("g").attr("class", "axes");
axes.append("g")
.attr("class", "axis x")
.attr("transform", `translate(0, ${scaleY.range()[1]})`)
.call(axisX);
axes.append("g")
.attr("class", "axis y")
.attr("transform", `translate(${scaleX.range()[1]},0)`)
.call(axisY);
}
Insert cell
drawAxisTitles = function() {
const titles = svg.append("g")
.attr("transform", `translate(${MARGIN},${MARGIN})`)
.attr("class", "titles")
.attr("font-weight", "bold")
.attr("text-anchor", "center");

titles.append("text")
.attr("class", "title x")
.attr("x", linearScaleX.range()[1] / 2)
.attr("y", -MARGIN * 0.75)
.text(ATTR_X);
titles.append("text")
.attr("class", "title y")
.style("transform", `rotate(270deg)translate(-${scaleY.range()[1] / 2}px,${-MARGIN * 0.8}px)`)
.text(ATTR_Y);
}
Insert cell
brushXHistogram = d3.brushX()
.on("end", (event, target) => {
xBrushSelection.length = 0;
if (event.selection === null) {
selectedReleaseYears.length = 0;
} else {
xBrushSelection.push(...event.selection)
}
drawMatrixCells();
updateHistogramBins();
updateSelectedFilmsList();
})
.on("brush", (event, target) => {
const minMaxYears = event.selection.map(d => Math.round(linearScaleX.invert(d)));
selectedReleaseYears.length = 0;
selectedReleaseYears.push(...d3.range(...minMaxYears));
selectedReleaseYears.push(minMaxYears[1]);

drawMatrixCells();
})
.extent([[MARGIN, 0], [MATRIX_WIDTH - MARGIN, MARGIN]]);
Insert cell
drawXHistogram = function() {
const histogram = svg.append("g")
.attr("class", "histogram x")
.attr("transform", `translate(0,${MARGIN})`)

histogram.append("path")
.attr("d", `M${MARGIN},0L${scaleX.range()[1]},0`)
.attr("stroke", "black")
.attr("stroke-width", 1);

const bins = histogram.append("g").attr("class", "bins");
updateHistogramBins();
histogram.append("g")
.attr("class", "brush x")
.attr("transform", `translate(0,${-MARGIN})`)
.call(brushXHistogram);

if (xBrushSelection.length > 0) {
histogram.select("g.brush.x").call(brushXHistogram.move, xBrushSelection);
}
}
Insert cell
brushYHistogram = d3.brushY()
.on("end", (event, target) => {
yBrushSelection.length = 0;
if (event.selection === null) {
selectedInductionYears.length = 0;
} else {
yBrushSelection.push(...event.selection)
}
drawMatrixCells();
updateHistogramBins();
updateSelectedFilmsList();
})
.on("brush", (event, target) => {
const minMaxYears = event.selection.map(d => Math.round(linearScaleY.invert(d)));
selectedInductionYears.length = 0;
selectedInductionYears.push(...d3.range(...minMaxYears));
selectedInductionYears.push(minMaxYears[1]);
drawMatrixCells();
})
.extent([[0, MARGIN], [MARGIN, HEIGHT - MARGIN]]);
Insert cell
drawYHistogram = function() {
const histogram = svg.append("g")
.attr("class", "histogram y")
.attr("transform", `translate(${MARGIN},0)`)

histogram.append("path")
.attr("d", `M0,${MARGIN}L0,${scaleY.range()[1]}`)
.attr("stroke", "black")
.attr("stroke-width", 1);

const bins = histogram.append("g").attr("class", "bins");

updateHistogramBins();

histogram.append("g")
.attr("class", "brush y")
.attr("transform", `translate(${-MARGIN},0)`)
.call(brushYHistogram);

if (yBrushSelection.length > 0) {
histogram.select("g.brush.y").call(brushYHistogram.move, xBrushSelection);
}
}
Insert cell
updateHistogramBins = function() {
const xMaxBinSize = d3.max(xBins.map(d => d.length));
const yMaxBinSize = d3.max(yBins.map(d => d.length));
const binScale = d3.scaleLinear([0, d3.max([xMaxBinSize, yMaxBinSize])], [0, MARGIN * 0.75])
const xGroupedFilms = d3.group(getSelectedFilms(), d => +d[ATTR_X]);

const xBinsContainer = svg.select("g.histogram.x g.bins").selectAll("g.bin").data(xBins).join("g")
.attr("class", "bin")
.attr("transform", d => `translate(${scaleX(+d.x0)},${-binScale(d.length)})`);
xBinsContainer.append("rect")
.attr("width", scaleX.bandwidth())
.attr("height", d => binScale(d.length))
.attr("fill", "steelblue");
xBinsContainer.append("rect")
.attr("width", scaleX.bandwidth())
.attr("height", d => xGroupedFilms.size === 0 ? 0 : binScale(d.length) - binScale(xGroupedFilms.get(+d.x0)?.length || 0))
.attr("fill", "#ccc");

const yGroupedFilms = d3.group(getSelectedFilms(), d => +d[ATTR_Y]);
const yBinsContainer = svg.select("g.histogram.y g.bins").selectAll("rect.bar").data(yBins).join("g")
.attr("class", "bin")
.attr("transform", d => `translate(${-binScale(d.length)},${scaleY(+d.x0)})`);
yBinsContainer.append("rect")
.attr("width", d => binScale(d.length))
.attr("height", scaleY.bandwidth())
.attr("fill", "steelblue");
yBinsContainer.append("rect")
.attr("width", d => yGroupedFilms.size === 0 ? 0 : binScale(d.length) - binScale(yGroupedFilms.get(+d.x0)?.length || 0))
.attr("height", scaleY.bandwidth())
.attr("fill", "#ccc");
}
Insert cell
isMatrixCellInSelection = function(cell) {
const isReleaseYearInSelection = selectedReleaseYears.length === 0 || selectedReleaseYears.indexOf(+cell[0]) > -1;
const isInductionYearInSelection = selectedInductionYears.length === 0 || selectedInductionYears.indexOf(+cell[1]) > -1;

return isReleaseYearInSelection && isInductionYearInSelection;
}
Insert cell
drawMatrixCells = function() {
svg.select("g.cells").remove();
svg.on("click", (e) => {
clickedCell.length = 0;
updateSelectedFilmsList();
e.stopPropagation();
});
const cells = svg.append("g")
.attr("class", "cells");

const filmCell = cells.selectAll("rect.cell.film").data(groupedData).join("rect")
.attr("class", "cell film")
.attr("width", scaleX.bandwidth())
.attr("height", scaleY.bandwidth())
.attr("transform", d => `translate(${scaleX(+d[0])},${scaleY(+d[1])})`)
.attr("fill", d => isMatrixCellInSelection(d)
? cellAggregate === "count"
? scaleColor(d[2].length)
: scaleRating(d3.mean(d[2].map(d => d[ATTR_RATING])))
: "#ccc")
.attr("fill-opacity", fadedMatrix ? 0.25 : 1);

filmCell.on("click", (e, d) => {
clickedCell.length = 0;
e.stopPropagation();
if (d3.select(e.target).classed("clicked")) {
filmCell.classed("clicked", false);
updateSelectedFilmsList();
return;
}
filmCell.classed("clicked", false);
d3.select(e.target).classed("clicked", true);

clickedCell.push(...d[2]);
updateSelectedFilmsList();
});
}
Insert cell
drawMedianAgePerYear = function() {
const medianAgePerYear = groupedByInductionYear.map(d => [d[0], Math.floor(d3.median(d[1].map(di => +di[ATTR_X])))]);

const medianIndicators = svg.append("g")
.attr("class", "median-indicators")
.style("display", fadedMatrix ? "block" : "none");
const medianIndicator = medianIndicators.selectAll("g.median-indicator").data(medianAgePerYear).join("g")
.attr("class", "median-indicator")
.attr("transform", d => `translate(${scaleX(+d[1]) + scaleX.bandwidth() / 2},${scaleY(+d[0]) + scaleY.bandwidth() / 2})`);

medianIndicator.append("circle")
.attr("r", scaleX.bandwidth() / 2)
.attr("fill", "firebrick");

medianIndicator.append("text")
.attr("x", -10)
.attr("text-anchor", "end")
.attr("dy", scaleY.bandwidth() * 0.25)
.text(d => d[1]);

medianIndicator.append("text")
.attr("x", 10)
.attr("dy", scaleY.bandwidth() * 0.25)
.attr("font-weight", "bold")
.text(d => `${d[0] - d[1]} years`);

const oldestMedianIndex = d3.maxIndex(medianAgePerYear.map(d => d[0] - d[1]));
const oldestMedianYear = medianAgePerYear[oldestMedianIndex];
const oldestMedian = medianIndicators.append("g")
.attr("class", "oldest-median")
.attr("transform", `translate(${scaleX(oldestMedianYear[1])},${scaleY(oldestMedianYear[0])})`);

oldestMedian.append("circle")
.attr("r", scaleX.bandwidth() / 2)
.attr("cx", scaleX.bandwidth() / 2 - 75)
.attr("cy", scaleY.bandwidth() / 2)
.attr("fill", "black");

oldestMedian.append("line")
.attr("x1", -35)
.attr("x2", -75)
.attr("y1", scaleY.bandwidth() / 2)
.attr("y2", scaleY.bandwidth() / 2)
.attr("stroke", "black");
oldestMedian.append("text")
.attr("x", -80)
.attr("y", scaleY.bandwidth() * 0.75)
.attr("text-anchor", "end")
.text("oldest median age");
}
Insert cell
drawFuture = function() {
const latestYear = valueRange[valueRange.length - 1];

const futureIndicator = svg.selectAll("g.future-indicator").data(yDomain).join("g")
.attr("class", "future-indicator")
.attr("transform", d => `translate(${scaleX(d)},${scaleY(d)})`);

futureIndicator.append("rect")
.attr("width", 2)
.attr("height", scaleY.bandwidth())
.attr("fill", "#ccc");

futureIndicator.append("line")
.attr("x1", 0)
.attr("x2", d => scaleX(latestYear) - scaleX(d) + 1)
.attr("y1", d => scaleY.bandwidth() / 2)
.attr("y2", d => scaleY.bandwidth() / 2)
.attr("stroke-width", 1)
.attr("stroke", "#ccc")
.attr("stroke-dasharray", "2 2");
}
Insert cell
drawEarliestInductee = function() {
const earliestOffset = 45;
const earliestInducteeIndex = d3.minIndex(groupedData, d => d[1] - d[0]);
const earliestInducteeFilm = groupedData[earliestInducteeIndex][2][0];
const earliestInductee = svg.append("g")
.attr("class", "earliest-inductee")
.attr("transform", `translate(${scaleX(+earliestInducteeFilm[ATTR_X])},${scaleY(+earliestInducteeFilm[ATTR_Y])})`);

earliestInductee.append("rect")
.attr("fill", "rgba(255,255,255,0.7)")
.attr("x", earliestOffset - FONT_SIZE)
.attr("width", 110)
.attr("height", 1.5 * FONT_SIZE);
earliestInductee.append("text")
.attr("dy", scaleY.bandwidth() * 0.75)
.attr("dx", earliestOffset)
.attr("font-size", FONT_SIZE)
.style("font-family", "sans-serif")
.text("earliest inductee");

earliestInductee.append("circle")
.attr("cx", earliestOffset - scaleY.bandwidth())
.attr("cy", scaleY.bandwidth() / 2)
.attr("r", scaleX.bandwidth() / 2)
.attr("fill", "black")

earliestInductee.append("line")
.attr("x1", scaleX.bandwidth() * 1.5)
.attr("x2", earliestOffset - scaleY.bandwidth())
.attr("y1", scaleY.bandwidth() / 2)
.attr("y2", scaleY.bandwidth() / 2)
.attr("stroke", "black")
}
Insert cell
drawMatrixColorLegend = function() {
const LEGEND_WIDTH = 300;
const LEGEND_HEIGHT = 10;
const LEGEND_MARGIN = FONT_SIZE;
const N_SEGMENTS = 20;
const legendSVG = d3.create("svg")
.attr("width", width)
.attr("height", LEGEND_HEIGHT + LEGEND_MARGIN);
const legend = legendSVG.append("g")
.attr("class", "legend")
.attr("transform", `translate(${MARGIN},0)`);

const noSegments = 10;
const scale = cellAggregate === "count" ? scaleColor : scaleRating;

const minSegment = Math.floor(d3.min(scale.domain()));
const maxSegment = Math.ceil(d3.max(scale.domain()));
const medianSegment = d3.median(scale.domain());
const segmentStep = (maxSegment - minSegment) / N_SEGMENTS;
const segmentValues = d3.range(minSegment, maxSegment, segmentStep);
const legendScaleX = d3.scaleBand(segmentValues, [LEGEND_MARGIN, LEGEND_WIDTH - LEGEND_MARGIN]);
const legendLinearScaleX = d3.scaleLinear(d3.extent(legendScaleX.domain()), legendScaleX.range());
legend.selectAll("rect.segment").data(segmentValues).join("rect")
.attr("class", "segment")
.attr("x", legendScaleX)
.attr("width", legendScaleX.bandwidth())
.attr("height", LEGEND_HEIGHT)
.attr("fill", scale);

legend.selectAll("text.label").data(legendLinearScaleX.domain().concat(medianSegment)).join("text")
.attr("class", "label")
.attr("x", legendLinearScaleX)
.attr("y", LEGEND_HEIGHT + LEGEND_MARGIN)
.attr("font-size", FONT_SIZE)
.attr("text-anchor", "middle")
.text((d, i) => i === 2 ? `${Math.round(d)} (median)` : Math.round(d));

return legendSVG.node();
}
Insert cell
renderGenres = function() {
const stackGenres = genreXPosition === "stack";
const genres = Array.from(genresMap.keys());
const genresList = Array.from(genresMap.entries());
const mostGenresPerFilm = d3.max(filteredData.map(d => d[ATTR_GENRES]).map(d => d.length));
const mostFilmsPerGenre = d3.max(Array.from(genresMap.values()).map(d => d.length));

const DEFAULT_OPACITY = 0.5;
const genreScaleX = d3.scaleBand(
stackGenres
? d3.range(0, mostFilmsPerGenre + 1)
: genreXPosition === ATTR_X
? xDomain
: genreXPosition === ATTR_RATING
? d3.range(0, 10.1, 0.1).map(d => Math.floor(d * 10) / 10)
: yDomain,
[2*MARGIN, width - MARGIN]
).padding(0.15);
const genreScaleY = d3.scaleBand(genres, [GENRE_HEIGHT - MARGIN, MARGIN]).padding(0.15);
let hoveredFilm = null;
let hoveredGenre = null;

function getHoveredFilms() {
// multiple films can be at the hovered location! But the way that the data join works, only one
// update event is fired, so we need a workaround here.
const hoveredFilms = hoveredGenre === null || hoveredFilm === null
? []
: stackGenres
? [hoveredFilm]
: hoveredGenre[1].filter(d => +d[genreXPosition] === +hoveredFilm[genreXPosition]);

return hoveredFilms;
}

function updateHoveredFilm() {
const hoveredFilms = getHoveredFilms();
genre.selectAll("rect.film")
.attr("fill-opacity", d => {
if (hoveredFilms.length === 0) {
return DEFAULT_OPACITY;
}
if (hoveredFilms.indexOf(d) > -1) {
return DEFAULT_OPACITY;
}
return DEFAULT_OPACITY / 2;
})
.attr("stroke", d => hoveredFilm === d ? "#000" : "#fff");
}

function updateTooltip(e) {
const hoveredFilms = getHoveredFilms();
const parent = genreSvg.node().getBoundingClientRect();
tooltip
.attr("display", hoveredFilm === null ? "none" : "block")
.attr("transform", `translate(${e.clientX - parent.left + 10},${e.clientY - parent.top + FONT_SIZE * 0.75})`);

tooltip.select("text").selectAll("tspan").data(hoveredFilms)
.join("tspan")
.attr("x", 0)
.attr("y", (d, i) => i * FONT_SIZE * 1.05)
.text(d => `${d[ATTR_TITLE]} (${d[ATTR_X]})`);

tooltip.select("rect")
.attr("width", tooltip.select("text").node().getBBox().width)
.attr("height", tooltip.select("text").node().getBBox().height);
}
const genreSvg = d3.create("svg")
.attr("width", width)
.attr("height", GENRE_HEIGHT)
.on("mousemove", updateTooltip);

const genreLinearScaleX = stackGenres || genreXPosition === ATTR_RATING
? d3.scaleLinear(d3.extent(genreScaleX.domain()), genreScaleX.range())
: d3.scaleTime(d3.extent(genreScaleX.domain()).map(d => new Date(`${d}-01-01`)), genreScaleX.range());
const axisX = d3.axisBottom(genreLinearScaleX);
genreSvg.append("g")
.attr("class", "axis x")
.attr("transform", `translate(0, ${genreScaleY.range()[0]})`)
.call(axisX);

const barchart = genreSvg.append("g")
.attr("class", "genre-barchart");
const genre = barchart.selectAll("g.bar").data(genresList).join("g")
.attr("class", d => d[0])
.attr("transform", d => `translate(0,${genreScaleY(d[0])})`)
.on("mouseenter", (e, d) => {
hoveredGenre = d;
updateHoveredFilm();
})
.on("mouseleave", () => {
hoveredGenre = null;
updateHoveredFilm();
});

genre.selectAll("rect.film").data(d => d[1]).join("rect")
.attr("class", "film")
.attr("x", (d, i) => stackGenres ? genreScaleX(i) : genreScaleX(+d[genreXPosition]))
.attr("width", genreScaleX.bandwidth())
.attr("height", genreScaleY.bandwidth())
.attr("fill", "steelblue")
.attr("fill-opacity", DEFAULT_OPACITY)
.attr("stroke-width", stackGenres ? 0 : 1)
.on("mouseenter", (e, d) => {
hoveredFilm = d;
updateHoveredFilm();
})
.on("mouseleave", (e, d) => {
hoveredFilm = null;
updateHoveredFilm();
});

genre.append("text")
.attr("x", d => d3.min(genreScaleX.range()) - 10)
.attr("y", genreScaleY.bandwidth() / 2)
.attr("text-anchor", "end")
.attr("font-family", "sans-serif")
.attr("font-size", FONT_SIZE)
.text(d => d[0]);
const tooltip = genreSvg.append("g").attr("class", "tooltip");
tooltip.append("rect")
.attr("x", 0)
.attr("y", -FONT_SIZE)
.attr("fill", "white")
.attr("fill-opacity", 0.73);
tooltip.append("text")
.attr("font-family", "sans-serif")
.attr("font-size", FONT_SIZE);

updateHoveredFilm();

return genreSvg.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