From eb1d8397ff3db2f0514391086d4639326a226e3e Mon Sep 17 00:00:00 2001 From: Even Stensberg Date: Fri, 28 Aug 2026 12:43:06 +0200 Subject: [PATCH] feat: explain the graph colours with a legend The module and chunk graphs encode size, build order, direction and the current selection, and none of it was written down in the UI. Each graph now draws a legend below itself, filled from the same colour scales and role colours the graph is rendered with, and worded per selection so it never lists a colour the current view cannot show. Closes #29 Co-Authored-By: Claude Opus 5 --- README.md | 14 +++++++ app/graphs/chunks.js | 31 +++++++++++++++ app/graphs/legend.js | 84 ++++++++++++++++++++++++++++++++++++++ app/graphs/modules.js | 93 +++++++++++++++++++++++++++++++++++++++++++ app/style.css | 54 +++++++++++++++++++++++++ 5 files changed, 276 insertions(+) create mode 100644 app/graphs/legend.js diff --git a/README.md b/README.md index 6303124..fbf6fd5 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,20 @@ a small hand-written stats file that covers every hint of the hints page and is loadable in the app as the "hint test cases" example. CI runs this before the build, so a failing test stops the run and nothing is deployed. +## Reading the graphs + +The module and chunk graphs carry their meaning in colour, size and direction. +Each one draws a legend underneath itself, and it says the same as this: + +| What you see | What it means | +| --- | --- | +| Dot colour and radius | The size of the module or chunk, green for the smallest and red for the largest in the build | +| Arrow | Points from the module that requires to the module required, and from a parent chunk to the chunk it loads | +| Edge colour (module graph) | When the module the arrow points at finished building, cyan early through magenta late. Only with `--profile`; without it the edge takes the colour of the module it points at | +| Edge width | Thinner when many modules require the same module, and thinner again when that module is loaded from an async chunk. In the chunk graph, thicker when a chunk has many parents | +| Black, red, green (module graph) | With a module open: the module itself, what requires it, and what it requires. With a chunk open: the modules in the chunk, and the edges into and out of it | +| Grey | Everything outside the current selection | + ## Typical workflow 1. Build your application with webpack in profiling mode. diff --git a/app/graphs/chunks.js b/app/graphs/chunks.js index 254a1b7..4f5b818 100644 --- a/app/graphs/chunks.js +++ b/app/graphs/chunks.js @@ -6,9 +6,12 @@ var FA2Layout = require("graphology-layout-forceatlas2/worker"); var forceAtlas2 = require("graphology-layout-forceatlas2"); var rescale = require("./rescale"); var theme = require("./theme"); +var legend = require("./legend"); +var formatSize = require("../formatSize"); var percentageToColor = require("../percentageToColor").greenRed; var element = document.getElementById("sigma-chunks"); +var legendElement = legend.create(element); var nodes = []; var edges = []; @@ -124,6 +127,32 @@ var s = new Sigma(graph, element, { } }); +// Same wording rule as the module graph: every line below describes what the +// code above actually draws. +legend.render(legendElement, [ + { + title: "chunk", + items: [ + { + shape: "dot", + gradient: legend.gradient(percentageToColor), + text: "colour and radius: size, 0 to " + formatSize(maxSize) + } + ] + }, + { + title: "edge", + items: [ + { glyph: "\u2192", text: "points from a parent chunk to its child" }, + { + shape: "line", + color: "rgba(120,130,140,0.6)", + text: "coloured like the child, thicker when it has many parents" + } + ] + } +]); + var layout = new FA2Layout(graph, { settings: forceAtlas2.inferSettings(graph) }); @@ -135,6 +164,7 @@ s.on("clickNode", function(e) { exports.show = function() { element.style.display = "block"; + legend.show(legendElement); // Zero-sized while hidden, so re-measure before refreshing. s.resize(); s.refresh(); @@ -143,5 +173,6 @@ exports.show = function() { exports.hide = function() { element.style.display = "none"; + legend.hide(legendElement); layout.stop(); }; diff --git a/app/graphs/legend.js b/app/graphs/legend.js new file mode 100644 index 0000000..134e141 --- /dev/null +++ b/app/graphs/legend.js @@ -0,0 +1,84 @@ +// The graphs say a lot with colour, size and arrow direction, and none of it +// was written down anywhere in the UI (webpack/analyse#29). +// +// A legend is only worth having if it cannot drift away from the drawing, so +// nothing here holds a colour of its own: swatches are filled by sampling the +// very scales the graphs are drawn with, and the graphs pass in the values +// their scales run over. + +// Inserts an empty legend after the graph container. Hidden until the graph it +// belongs to is shown. +exports.create = function create(graphElement) { + var element = document.createElement("div"); + element.className = "graph-legend"; + element.style.display = "none"; + graphElement.parentNode.insertBefore(element, graphElement.nextSibling); + return element; +}; + +exports.show = function show(element) { + element.style.display = ""; +}; + +exports.hide = function hide(element) { + element.style.display = "none"; +}; + +// Samples a percentageToColor scale into the stops of a CSS gradient, so a +// swatch shows the range a value is actually drawn in rather than an +// approximation of it. +exports.gradient = function gradient(scale, steps) { + var stops = []; + steps = steps || 6; + for (var i = 0; i < steps; i++) stops.push(scale(i / (steps - 1))); + return stops; +}; + +// groups: [{ title, items: [{ shape, color | gradient | glyph, text }] }] +// shape is "dot" for anything drawn as a node and "line" for anything drawn as +// an edge, which is the only distinction the graphs make. +exports.render = function render(element, groups) { + element.textContent = ""; + groups.forEach(function(group) { + var groupElement = document.createElement("div"); + groupElement.className = "graph-legend-group"; + if (group.title) { + var title = document.createElement("span"); + title.className = "graph-legend-title"; + title.textContent = group.title; + groupElement.appendChild(title); + } + group.items.forEach(function(item) { + groupElement.appendChild(renderItem(item)); + }); + element.appendChild(groupElement); + }); +}; + +function renderItem(item) { + var element = document.createElement("span"); + element.className = "graph-legend-item"; + element.appendChild(renderSwatch(item)); + var text = document.createElement("span"); + text.textContent = item.text; + element.appendChild(text); + return element; +} + +function renderSwatch(item) { + var swatch = document.createElement("span"); + if (item.glyph) { + swatch.className = "graph-legend-glyph"; + swatch.textContent = item.glyph; + return swatch; + } + swatch.className = + "graph-legend-swatch graph-legend-swatch-" + (item.shape || "dot"); + if (item.gradient) { + swatch.style.backgroundImage = + "linear-gradient(to right," + item.gradient.join(",") + ")"; + } else { + swatch.style.backgroundColor = item.color; + } + return swatch; +} diff --git a/app/graphs/modules.js b/app/graphs/modules.js index 08d4b8c..d1f983a 100644 --- a/app/graphs/modules.js +++ b/app/graphs/modules.js @@ -6,10 +6,13 @@ var FA2Layout = require("graphology-layout-forceatlas2/worker"); var forceAtlas2 = require("graphology-layout-forceatlas2"); var rescale = require("./rescale"); var theme = require("./theme"); +var legend = require("./legend"); +var formatSize = require("../formatSize"); var percentageToColor = require("../percentageToColor").greenRed; var percentageToColor2 = require("../percentageToColor").blue; var element = document.getElementById("sigma-modules"); +var legendElement = legend.create(element); var nodes = []; var edges = []; @@ -145,6 +148,91 @@ edges.forEach(function(edge) { }); }); +// What the graph draws, in words. The wording follows the code above: node +// colour and radius come from the module size, edge colour from the timestamp +// of the module the edge points at, and edge width from how many modules ask +// for that same module. +var GRAPH_LEGEND = [ + { + title: "module", + items: [ + { + shape: "dot", + gradient: legend.gradient(percentageToColor), + text: "colour and radius: size, 0 to " + formatSize(maxSize) + } + ] + }, + { + title: "dependency", + items: [ + { glyph: "\u2192", text: "requires the module it points at" }, + maxTimestamp > 0 + ? { + shape: "line", + gradient: legend.gradient(percentageToColor2), + text: "finished building, 0 to " + maxTimestamp + " ms" + } + : { + shape: "line", + color: "rgba(120,130,140,0.6)", + text: "coloured like the module it points at" + }, + { + shape: "line", + color: "rgba(120,130,140,0.6)", + text: + "thinner when many modules require the same one, and when it " + + "is loaded from an async chunk" + } + ] + } +]; + +// The roles a module selection paints. A chunk selection paints a different +// set, so the two are kept apart rather than listing every colour the graph +// could ever use next to a view that cannot show most of them. +var MODULE_SELECTION_LEGEND = { + title: "selected module", + items: [ + { shape: "dot", color: theme.ROLE_COLOR.active, text: "the module" }, + { shape: "dot", color: theme.ROLE_COLOR.reason, text: "requires it" }, + { + shape: "dot", + color: theme.ROLE_COLOR.dependency, + text: "required by it" + }, + { shape: "dot", color: theme.FADED_NODE_COLOR, text: "unrelated" } + ] +}; + +var CHUNK_SELECTION_LEGEND = { + title: "selected chunk", + items: [ + { shape: "dot", color: theme.ROLE_COLOR.member, text: "in the chunk" }, + { + shape: "line", + color: theme.ROLE_COLOR.dependency, + text: "requires a module outside it" + }, + { + shape: "line", + color: theme.ROLE_COLOR.reason, + text: "required from outside it" + }, + { shape: "dot", color: theme.FADED_NODE_COLOR, text: "outside the chunk" } + ] +}; + +function showLegend(selectionGroup) { + legend.render( + legendElement, + selectionGroup ? GRAPH_LEGEND.concat(selectionGroup) : GRAPH_LEGEND + ); +} + +showLegend(null); + // null when nothing is selected. Otherwise { nodes: {key: role}, edges: {key: // role} }. Selection drives appearance through the reducers rather than by // overwriting colour attributes, so clearing a selection needs no restore pass @@ -271,6 +359,7 @@ s.on("clickNode", function(e) { exports.show = function() { element.style.display = "block"; + legend.show(legendElement); // The container has zero dimensions while hidden, so re-measure before // refreshing or sigma keeps the stale 0x0 viewport. s.resize(); @@ -280,12 +369,14 @@ exports.show = function() { exports.hide = function() { element.style.display = "none"; + legend.hide(legendElement); layout.stop(); }; exports.setNormal = function() { activeModuleUid = null; selection = null; + showLegend(null); s.refresh(); }; @@ -316,6 +407,7 @@ exports.setActiveModule = function(activeModule) { edgeRoles[edge] = "dependency"; }); selection = buildSelection(nodeRoles, edgeRoles); + showLegend(MODULE_SELECTION_LEGEND); s.refresh(); }; @@ -337,5 +429,6 @@ exports.setActiveChunk = function(activeChunk) { else if (tc) edgeRoles[edge] = "reason"; }); selection = buildSelection(nodeRoles, edgeRoles); + showLegend(CHUNK_SELECTION_LEGEND); s.refresh(); }; diff --git a/app/style.css b/app/style.css index 26a5abc..1e4616f 100644 --- a/app/style.css +++ b/app/style.css @@ -4,3 +4,57 @@ table pre { .sortable-th { cursor: pointer; } + +/* Legend under the module and chunk graphs (webpack/analyse#29). Sits between + the graph and the page content, so it stays quiet: one line of small text + that reads as a caption rather than as another table. */ +.graph-legend { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 18px; + margin: 0 0 10px 8px; + color: #777; + font-size: 12px; + line-height: 18px; +} +.graph-legend-group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 10px; +} +.graph-legend-title { + color: #555; + font-weight: bold; +} +.graph-legend-title:after { + content: ":"; +} +.graph-legend-item { + display: flex; + align-items: center; + gap: 5px; + white-space: nowrap; +} +.graph-legend-swatch { + display: inline-block; + border: 1px solid rgba(0, 0, 0, 0.12); +} +.graph-legend-swatch-dot { + width: 26px; + height: 10px; + border-radius: 5px; +} +.graph-legend-swatch-line { + width: 26px; + height: 4px; + border-radius: 2px; +} +.graph-legend-glyph { + display: inline-block; + width: 26px; + color: #555; + text-align: center; + font-size: 14px; +}