@vernikr/size-report 2.5.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/page/table.js CHANGED
@@ -1,45 +1,47 @@
1
- import { cellParts, commitParts, nowModel, rowModel, valueParts } from '../derived.js';
1
+ import { cellParts, commitParts, deltaOf, nowModel, rowModel, valueParts } from '../derived.js';
2
2
  import { appEl } from './dom.js';
3
- import { appData, appUi, appView, appMetric } from './state.js';
3
+ import { appData, appUi, appView } from './state.js';
4
4
 
5
- /* An approximate cell: the mark comes from the engine's data rather than being derived here from a file's path the rule
6
- * of accuracy lives where the numbers are counted. For a file's cell it is that number itself, for a total the worst of
7
- * what went into it, or the sum would promise an accuracy its terms do not have. Returns the metric's description (its way
8
- * of counting is what goes into the cell's tooltip) or nothing. */
9
- export function appApprox(where, files, key) {
10
- const marks = appData.approx[key];
11
- if (marks === undefined) return null;
12
- const row = where === 'now' ? marks.now : marks.rows;
13
- const base = where === 'now' ? 0 : where * appData.files.length;
14
- for (let i = 0; i < files.length; i++) {
15
- if (row.charAt(base + files[i]) === '1') return appMetric[key];
16
- }
17
- return null;
18
- }
5
+ /* The table is built once and then only shown and hidden. That rests on three things that do not depend on the
6
+ * reader: a cell's content and colour come from the pair (commit, file) and a metric (`cellParts`, `valueParts`),
7
+ * the order of the columns comes from the files alone, and only the visibility and the totals are the choice's.
8
+ * Hence nothing below builds a node after the first drawing a click writes a class and a number.
9
+ *
10
+ * What makes this cheap is a cache of references to the nodes of every column (`appTable` returns it): reaching a
11
+ * cell through the markup costs several times what changing it does (measured: `tr.children[i]` 0.0254 ms against
12
+ * `classList.toggle` 0.0070 ms), and the first drawing creates every node anyway, so collecting them is free. The
13
+ * columns of the fixed layout a `<col>` per column, carrying the counted width — are collected there too.
14
+ *
15
+ * The totals are the only numbers that move with the choice, and they are carried rather than recounted: a sum is
16
+ * linear, so a file switched off subtracts exactly its own numbers — the same integers the first drawing wrote.
17
+ */
19
18
 
20
- /* The cell's class is assembled in one place: both the mark of approximation and the gap ("no such file") are properties
21
- * of the cell itself rather than of its content. */
22
- function appCellClass(first, miss, approx) {
23
- return 'num' + (first ? ' g' : '') + (miss ? ' miss' : '') + (approx === null ? '' : ' approx');
19
+ /* The cell's class: the metric's track (`m0`, `m1`, one class per metric, which is what hides a whole metric
20
+ * at once), the group it belongs to (every cell of a group, not the first one: the group's left border is carried
21
+ * by the first *enabled* metric, and the styling decides which that is), and the gap ("no such file"). */
22
+ function appCellClass(mi, miss) {
23
+ return 'num g m' + mi + (miss ? ' miss' : '');
24
24
  }
25
25
 
26
- // The markup of a commit row's cell: the rules live in cellParts, only the node is here.
27
- export function appCell(cell, first, approx) {
28
- const parts = cellParts(cell.value, cell.delta, '−');
29
- const td = appEl('td', appCellClass(first, parts.miss, approx));
30
- if (approx !== null) td.title = appUi.approxCell + approx.method;
26
+ /* A cell filled where it stands: the totals are written again on every click, and a cell has to forget its own
27
+ * content first — a delta is a node of its own, so `textContent` alone would leave it behind. */
28
+ function appFill(td, parts) {
29
+ td.textContent = '';
31
30
  if (parts.dir === null) td.textContent = parts.text;
32
31
  else td.appendChild(appEl('span', 'delta ' + parts.dir, parts.text));
33
32
  return td;
34
33
  }
35
34
 
35
+ // The markup of a commit row's cell: the rules live in cellParts, only the node is here.
36
+ export function appCell(cell, mi) {
37
+ const parts = cellParts(cell.value, cell.delta, '−');
38
+ return appFill(appEl('td', appCellClass(mi, parts.miss)), parts);
39
+ }
40
+
36
41
  // The markup of the top row's cell: the rules live in valueParts.
37
- export function appValueCell(value, first, approx) {
42
+ export function appValueCell(value, mi) {
38
43
  const parts = valueParts(value);
39
- const td = appEl('td', appCellClass(first, parts.miss, approx));
40
- if (approx !== null) td.title = appUi.approxCell + approx.method;
41
- td.textContent = parts.text;
42
- return td;
44
+ return appFill(appEl('td', appCellClass(mi, parts.miss)), parts);
43
45
  }
44
46
 
45
47
  /* A commit's caption: the date, the subject, the journal section's mark. The column's width and the clipping of a long
@@ -61,15 +63,9 @@ export function appCommit(row) {
61
63
  return th;
62
64
  }
63
65
 
64
- export function appSubHead(metrics) {
65
- const tr = appEl('tr');
66
- metrics.forEach((m, mi) => tr.appendChild(appEl('th', mi === 0 ? 'g' : '', m.label)));
67
- return tr;
68
- }
69
-
70
66
  /* The empty states: when there will be no numbers at all, the page says so in words rather than showing a grid without
71
67
  * columns. Every file can be switched off — then the total volume remains, and the note explains why there are no
72
- * columns. */
68
+ * columns. The table itself stands there either way: this is about what is shown, not about what exists. */
73
69
  export function appState(metricsCount, filesCount) {
74
70
  const state = document.getElementById('state');
75
71
  const text = metricsCount === 0 ? appUi.empty : (filesCount === 0 ? appUi.noFiles : '');
@@ -78,26 +74,44 @@ export function appState(metricsCount, filesCount) {
78
74
  document.getElementById('shell').hidden = metricsCount === 0;
79
75
  }
80
76
 
81
- /* The header: a row of groups (the total and the files) and a row of metrics under it. The metrics repeat for every file,
82
- * so the sub-header is assembled once and its nodes then move on into the following ones no copies of the markup are
83
- * made. */
84
- export function appHead(shown, files, metrics) {
77
+ /* The order of the columns: the files the last commit touched come first the report is rebuilt after every
78
+ * commit, and a reader's first question is what that edit brought. Inside each part the order stays as it comes
79
+ * from the settings, and it depends on the files rather than on the choice: that is what lets a column be hidden
80
+ * without moving the others. */
81
+ export function appOrder() {
82
+ const files = [];
83
+ appData.files.forEach((_f, i) => files.push(i));
84
+ files.sort((a, b) => (appData.last[a] === true ? 0 : 1) - (appData.last[b] === true ? 0 : 1));
85
+ return files;
86
+ }
87
+
88
+ /* The header: a row of groups (the total and the files) and a row of metrics under it — every metric of every file,
89
+ * because nothing here knows the choice. The headings whose `colSpan` follows the number of enabled metrics and the
90
+ * headings of each file's column are collected in the cache: they are the nodes a click has to touch. */
91
+ export function appHead(files, metrics, cache) {
85
92
  const head = appEl('tr');
86
93
  const commit = appEl('th', 'c-commit', appUi.commit);
87
94
  commit.rowSpan = 2;
88
95
  head.appendChild(commit);
89
- const total = appEl('th', 'g', appUi.total);
90
- total.colSpan = metrics.length;
91
- head.appendChild(total);
92
- files.forEach((i) => {
93
- const th = appEl('th', 'g', appData.files[i].label);
96
+ const subs = appEl('tr');
97
+ const group = (label, i) => {
98
+ const th = appEl('th', 'gh', label);
94
99
  th.colSpan = metrics.length;
95
100
  head.appendChild(th);
96
- });
97
- const subs = appSubHead(shown);
98
- files.forEach(() => {
99
- const more = appSubHead(shown);
100
- while (more.firstChild) subs.appendChild(more.firstChild);
101
+ cache.spans.push(th);
102
+ /* The group's own heading belongs to the column: hiding a file has to take its caption with it, or the header
103
+ * would keep a name over numbers that are gone. */
104
+ if (i !== null) cache.cols[i].push(th);
105
+ metrics.forEach((_key, mi) => {
106
+ const cell = appEl('th', 'g m' + mi, appData.metrics[mi].label);
107
+ subs.appendChild(cell);
108
+ if (i !== null) cache.cols[i].push(cell);
109
+ });
110
+ };
111
+ group(appUi.total, null);
112
+ files.forEach((i) => {
113
+ cache.cols[i] = [];
114
+ group(appData.files[i].label, i);
101
115
  });
102
116
  const thead = appEl('thead');
103
117
  thead.appendChild(head);
@@ -105,38 +119,231 @@ export function appHead(shown, files, metrics) {
105
119
  return thead;
106
120
  }
107
121
 
108
- /* A commit row: the caption and the numbers. The deltas come from the shared calculation (`rowModel`) rather than from
109
- * here two ways to count one row would be two answers. The mark of approximation is each cell's own: for a total it
110
- * covers every file that went into it, for a file that file alone. */
111
- export function appRow(r, metrics, files) {
122
+ /* A text's breadth in `ch`, the unit a column is measured in: a digit is exactly one `ch` in this table's font
123
+ * (measured in Chrome: 8.67px against 8.67px), and a thin space is counted as a third of a digit nothing else is
124
+ * discounted. Without that one fraction every number column would come out a fifth wider than the number in it (the
125
+ * grouping makes a text 21 % narrower than its characters, measured), which would give back more than the padding and
126
+ * the clipping of this step save. What is left over-measures — a letter is 0.84 of a `ch`, a slash 0.48 — and that is
127
+ * the safe side: a cell clips nothing, so a column a character short would show a number running over its neighbour. */
128
+ function appBreadth(text) {
129
+ let wide = 0;
130
+ for (let i = 0; i < text.length; i++) wide += text[i] === '\u2009' ? 0.35 : 1;
131
+ return wide;
132
+ }
133
+
134
+ /* The widest text of every column, counted before a single node is made: the numbers of the commit rows — through
135
+ * `rowModel`, the very model the cells are drawn from, so a column cannot be sized for a number other than the one
136
+ * that will stand in it — the absolute sizes of the "now" row and the metric's own caption. */
137
+ function appWidest(files, keys) {
138
+ const wide = files.map(() => keys.map(() => 0));
139
+ const total = keys.map(() => 0);
140
+ const put = (into, mi, breadth) => { if (breadth > into[mi]) into[mi] = breadth; };
141
+ const delta = (cell) => appBreadth(cellParts(cell.value, cell.delta, '−').text);
142
+ const value = (v) => appBreadth(valueParts(v).text);
143
+ appData.rows.forEach((row, r) => {
144
+ const model = rowModel(row.values, r === 0 ? null : appData.rows[r - 1].values, keys);
145
+ model.total.forEach((cell, mi) => put(total, mi, delta(cell)));
146
+ files.forEach((i) => model.files[i].forEach((cell, mi) => put(wide[i], mi, delta(cell))));
147
+ });
148
+ const now = nowModel(appData.now, keys);
149
+ now.total.forEach((v, mi) => put(total, mi, value(v)));
150
+ files.forEach((i) => now.files[i].forEach((v, mi) => put(wide[i], mi, value(v))));
151
+ keys.forEach((_key, mi) => {
152
+ const caption = appBreadth(appData.metrics[mi].label);
153
+ put(total, mi, caption);
154
+ wide.forEach((per) => put(per, mi, caption));
155
+ });
156
+ return { total: total, files: wide };
157
+ }
158
+
159
+ /* A group's heading — a file's name, the word over the total — is one line over the columns of that group
160
+ * (`white-space: nowrap` in the shared styling), so the group as a whole has to be wide enough for it, or the names of
161
+ * two neighbouring groups would run into one another. The caption is divided among the metrics of the group rather than
162
+ * weighed against their sum: every column carries at least its share, so the group can never come out narrower than the
163
+ * caption, while a column that is wider anyway keeps its own count. */
164
+ function appGroupWide(per, label) {
165
+ const share = Math.ceil(appBreadth(label) / per.length);
166
+ return per.map((n) => Math.max(n, share));
167
+ }
168
+
169
+ /* The columns, before the rows: `table-layout: fixed` reads the first row of the table and takes the widths from there,
170
+ * so they are known while the table is built rather than measured by the browser over every cell of it. A column
171
+ * carries the metric's class (`m0`, `m1`, …) — which is what hides a whole metric at once — and its counted width in
172
+ * `ch`; the styling adds the padding and the border to it (`#grid col`), because a count and a drawing are one sum
173
+ * rather than two. The columns of a file are collected where its cells are, so one switch writes one class over both. */
174
+ function appCols(files, wide, cache) {
175
+ const group = appEl('colgroup');
176
+ const add = (n, mi) => {
177
+ const col = appEl('col', 'm' + mi);
178
+ col.style.setProperty('--ch', n + 'ch');
179
+ group.appendChild(col);
180
+ return col;
181
+ };
182
+ group.appendChild(appEl('col', 'c-commit'));
183
+ appGroupWide(wide.total, appUi.total).forEach(add);
184
+ files.forEach((i) => appGroupWide(wide.files[i], appData.files[i].label)
185
+ .forEach((n, mi) => { cache.cols[i].push(add(n, mi)); }));
186
+ return group;
187
+ }
188
+
189
+ /* A file's share of a row: one cell per metric, in the order of the table's columns, and each is put into its
190
+ * column's cache while it is made. The maker is what tells a commit's delta from an absolute size at HEAD — the walk
191
+ * over the files is one, and there is no second one to drift away. */
192
+ function appRowCells(tr, model, files, cache, make) {
193
+ files.forEach((i) => {
194
+ model.files[i].forEach((value, mi) => {
195
+ const td = make(value, mi);
196
+ cache.cols[i].push(td);
197
+ tr.appendChild(td);
198
+ });
199
+ });
200
+ }
201
+
202
+ /* A commit row: the caption and the numbers. The deltas come from the shared calculation (`rowModel`) rather than
203
+ * from here — two ways to count one row would be two answers. The model is taken whole (every file) because the
204
+ * table holds every column, and the column order of the table is applied by taking the model by the file's own
205
+ * index: the model lists the files the way the data does, not the way the columns stand. */
206
+ export function appRow(r, files, metrics, cache) {
112
207
  const row = appData.rows[r];
113
208
  const prev = r === 0 ? null : appData.rows[r - 1];
114
- const model = rowModel(row.values, prev === null ? null : prev.values, metrics, appView.files);
209
+ const model = rowModel(row.values, prev === null ? null : prev.values, metrics);
115
210
  const tr = appEl('tr');
116
211
  tr.appendChild(appCommit(row));
117
212
  model.total.forEach((cell, mi) => {
118
- tr.appendChild(appCell(cell, mi === 0, appApprox(r, files, metrics[mi])));
213
+ const td = appCell(cell, mi);
214
+ cache.sums[mi][r] = cell.value;
215
+ cache.cells[mi][r] = td;
216
+ tr.appendChild(td);
217
+ });
218
+ appRowCells(tr, model, files, cache, appCell);
219
+ return tr;
220
+ }
221
+
222
+ /* The top row holds the absolute sizes at HEAD: an absolute number stands in the table once, and it is the one every
223
+ * delta below it adds up to. Its numbers are the state at HEAD rather than the last commit's cells, which is why the
224
+ * running sums take them from here instead of assuming they are the row above. */
225
+ export function appNow(files, metrics, cache) {
226
+ const tr = appEl('tr', 'now');
227
+ tr.appendChild(appEl('th', 'c-commit', appUi.now));
228
+ const model = nowModel(appData.now, metrics);
229
+ const last = appData.rows.length;
230
+ model.total.forEach((v, mi) => {
231
+ const td = appValueCell(v, mi);
232
+ cache.sums[mi][last] = v;
233
+ cache.cells[mi][last] = td;
234
+ tr.appendChild(td);
119
235
  });
120
- model.files.forEach((cells, fi) => cells.forEach((cell, mi) => {
121
- tr.appendChild(appCell(cell, mi === 0, appApprox(r, [files[fi]], metrics[mi])));
122
- }));
236
+ appRowCells(tr, model, files, cache, appValueCell);
123
237
  return tr;
124
238
  }
125
239
 
126
- /* The body: the commit rows built from the newest down, plus the "now" row with the absolute sizes at HEAD. The deltas
240
+ /* The body: the commit rows from the newest down, plus the "now" row with the absolute sizes at HEAD. The deltas
127
241
  * under it add up to it, which is why it stands first. */
128
- export function appBody(metrics, files) {
242
+ export function appBody(files, metrics, cache) {
129
243
  const body = appEl('tbody');
130
- for (let r = appData.rows.length - 1; r >= 0; r--) body.appendChild(appRow(r, metrics, files));
131
-
132
- const now = appEl('tr', 'now');
133
- now.appendChild(appEl('th', 'c-commit', appUi.now));
134
- const nowCells = nowModel(appData.now, metrics, appView.files);
135
- nowCells.total.forEach((v, mi) => now.appendChild(appValueCell(v, mi === 0,
136
- appApprox('now', files, metrics[mi]))));
137
- nowCells.files.forEach((cells, fi) => cells.forEach((v, mi) => {
138
- now.appendChild(appValueCell(v, mi === 0, appApprox('now', [files[fi]], metrics[mi])));
139
- }));
140
- body.insertBefore(now, body.firstChild);
244
+ for (let r = appData.rows.length - 1; r >= 0; r--) body.appendChild(appRow(r, files, metrics, cache));
245
+ body.insertBefore(appNow(files, metrics, cache), body.firstChild);
141
246
  return body;
142
247
  }
248
+
249
+ /* The whole table, built once. What the cache holds is what the reader's choice works with: the nodes of each file's
250
+ * column — its cells, its headings and its `<col>` (`cols`), the group headings whose colSpan follows the enabled
251
+ * metrics (`spans`), the cells of the totals (`cells`), the running sums (`sums`) and those same sums with every
252
+ * column on (`all`) — the sums a view painted again starts from. */
253
+ export function appTable(grid) {
254
+ const metrics = appData.metrics.map((m) => m.key);
255
+ const files = appOrder();
256
+ const cache = { grid: grid, keys: metrics, cols: [], drawn: [], spans: [], cells: [], sums: [], all: [] };
257
+ /* The columns are built shown: that is the state the nodes carry before anything is drawn. */
258
+ files.forEach((i) => { cache.drawn[i] = true; });
259
+ metrics.forEach(() => {
260
+ cache.cells.push([]);
261
+ cache.sums.push([]);
262
+ });
263
+ grid.textContent = '';
264
+ grid.appendChild(appHead(files, metrics, cache));
265
+ grid.appendChild(appBody(files, metrics, cache));
266
+ /* The columns stand before the rows rather than beside them: a `<colgroup>` is where the fixed layout reads its
267
+ * widths from, and it has to be in the markup before the browser settles on a layout — the drawing is one pass, not
268
+ * two. */
269
+ grid.insertBefore(appCols(files, appWidest(files, metrics), cache), grid.firstChild);
270
+ cache.all = cache.sums.map((row) => row.slice());
271
+ return cache;
272
+ }
273
+
274
+ /* How many nodes a file's column holds — its cells, its captions and its `<col>`. It is the unit the page's bar
275
+ * counts in (`appDraw`), so it is answered here, where the column's nodes are: the choice's state costs arithmetic,
276
+ * and the nodes are the table's business. */
277
+ export function appColumnSize(cache, i) {
278
+ return cache.cols[i].length;
279
+ }
280
+
281
+ /* Whether a file's column has to be drawn at all: `drawn` is the state its nodes carry (the table is built with every
282
+ * column shown), so a column that is on and was never drawn is already right. The first drawing of a report has
283
+ * nothing switched off and therefore costs nothing, and a record from the memory or a link queues exactly the columns
284
+ * that differ from it — on a table of a few hundred thousand cells that is the difference between a page that opens
285
+ * and a page that works for a minute after opening. */
286
+ export function appColumnStale(cache, i) {
287
+ return cache.drawn[i] !== (appView.files[i] === true);
288
+ }
289
+
290
+ /* A file's column shown or hidden: its cells and its headings together, one class per node and no new node. A hidden
291
+ * column keeps its place in the markup — the order of the columns is the files' business, not the choice's. What the
292
+ * nodes carry is remembered (`drawn`), or a repeated switch would walk a column nobody has to see again. */
293
+ export function appColumn(cache, i, on) {
294
+ cache.cols[i].forEach((node) => node.classList.toggle('off', !on));
295
+ cache.drawn[i] = on;
296
+ }
297
+
298
+ /* A file's share of the totals: switched off it takes exactly its own numbers out of the running sums, switched on
299
+ * it puts them back. Two rows per file are the whole of it — the numbers are the data's, and a sum of integers is
300
+ * exact in either direction. */
301
+ export function appContribute(cache, i, on) {
302
+ const sign = on ? 1 : -1;
303
+ appData.rows.forEach((row, r) => {
304
+ const values = row.values[i];
305
+ if (values === null) return;
306
+ cache.keys.forEach((key, mi) => { cache.sums[mi][r] += sign * values[key]; });
307
+ });
308
+ const values = appData.now[i];
309
+ if (values === null) return;
310
+ const last = appData.rows.length;
311
+ cache.keys.forEach((key, mi) => { cache.sums[mi][last] += sign * values[key]; });
312
+ }
313
+
314
+ /* The totals written where they stand: a data row shows the change against the row below it, the "now" row the
315
+ * absolute size — the same rules the first drawing used (`cellParts`, `valueParts`), so the reader sees one kind of
316
+ * number and not two. */
317
+ export function appTotals(cache) {
318
+ const last = appData.rows.length;
319
+ cache.cells.forEach((cells, mi) => {
320
+ cells.forEach((td, r) => {
321
+ if (r === last) { appFill(td, valueParts(cache.sums[mi][r])); return; }
322
+ appFill(td, cellParts(cache.sums[mi][r], deltaOf(cache.sums[mi][r], r === 0 ? null : cache.sums[mi][r - 1]), '−'));
323
+ });
324
+ });
325
+ }
326
+
327
+ /* The running sums of the whole view again: a link, a record from the memory and the first drawing change the choice
328
+ * as a whole rather than a column, so the sums start from the sums with every column on and take the switched-off
329
+ * ones out — one copy of the numbers instead of a recount. */
330
+ export function appTotalsReset(cache) {
331
+ cache.sums = cache.all.map((row) => row.slice());
332
+ appData.files.forEach((_f, i) => { if (!appView.files[i]) appContribute(cache, i, false); });
333
+ appTotals(cache);
334
+ }
335
+
336
+ /* The metrics of the choice: a class on the table hides every cell of a metric at once, and the group headings follow
337
+ * how many are left — two things that change, instead of a pass over the metric's cells (measured: 2.20 ms and 265
338
+ * operations against 433 ms and 56 496 for walking them). `.m-none` is what the styling reads to drop the group's
339
+ * left border when there is nothing left to separate. */
340
+ export function appMetrics(cache) {
341
+ let on = 0;
342
+ cache.keys.forEach((key, mi) => {
343
+ const visible = appView.metrics[key] === true;
344
+ if (visible) on++;
345
+ cache.grid.classList.toggle('m-off-' + mi, !visible);
346
+ });
347
+ cache.grid.classList.toggle('m-none', on === 0);
348
+ cache.spans.forEach((th) => { th.colSpan = on === 0 ? 1 : on; });
349
+ }
@@ -0,0 +1,55 @@
1
+ /* The page's long work, and the stripe that says it is going on.
2
+ *
3
+ * A switch on a file's box changes a class on every node of that file's column, and a folder or a category is that same
4
+ * work over every file below it. That alone is cheap — measured at about 2 µs a node, so a whole category of this
5
+ * repository's report (73 columns, 55 042 nodes) is 120 ms of it — but it is not the price. **The browser lays this
6
+ * table out again for any change of a column's visibility, and that is close to a second on a table of a few hundred
7
+ * thousand cells** (`probes/step-12-columns.mjs`: 1 cell toggled 234 ms of layout, 750 cells 287 ms, 6 000 cells
8
+ * 539 ms, and the whole click on the shipped page 867 ms blocked with a 742 ms task).
9
+ *
10
+ * Hence the shape of this chapter, and it is a decision rather than a default. The drawing of a switch is **one task**:
11
+ * what is queued is drawn in a single go, one layout, one repaint. Slicing it — a queue worked off between timeouts —
12
+ * was written first and measured (Chrome 153, this repository's report): 37 slices of two columns each paid the table's
13
+ * relayout 37 times, 2.5–4 s a slice, 95 frames and 169 layouts of 151.9 s of pure layout time against 1.04 s for the
14
+ * same click when it is not sliced, with the tab growing to several gigabytes of repaint. The slice is the thing that
15
+ * freezes the page, only more often, so there is none.
16
+ *
17
+ * What is left is honesty about it: work short enough to be over before the browser could paint a stripe is done on the
18
+ * click itself, and above that the stripe appears, the drawing happens in the next task, and the stripe goes away —
19
+ * so a reader sees that the page is working rather than wondering whether it hung. The stripe carries no share: within
20
+ * one task the browser cannot repaint, so a bar that filled would be a bar that lies, and the page already knows the
21
+ * one thing that is true about it (the work is going on).
22
+ */
23
+
24
+ /* Above this much node work the drawing is one task with a stripe rather than a task on the click: a stripe that
25
+ * appears and disappears within the same frame is worse than none, and the figure is one file's column of a report of
26
+ * this repository's size (754 nodes). */
27
+ export const APP_LONG = 2000;
28
+
29
+ /* The stripe: on while a switch is being drawn, off when it is done. There is nothing to count here, and the length is
30
+ * carried by the styling (an indeterminate stripe), which is why this function takes a switch rather than a share. */
31
+ export function appBar(shown) {
32
+ const bar = document.getElementById('bar');
33
+ if (bar !== null) bar.hidden = !shown;
34
+ }
35
+
36
+ /* What a switch asks for: `items` are the columns that have to be drawn (`{i, units}` — the file's index and the nodes
37
+ * of its column), `step` draws one of them from the view, and the total decides whether the drawing is the reader's own
38
+ * click or the next task with the stripe over it. Only the columns out of step with the view are asked for at all
39
+ * (`appColumnStale`), so a report opened with everything switched on has nothing to draw here. */
40
+ export function appDraw(step, items) {
41
+ /* Counted by a plain walk rather than by `reduce`: the page's shell is held to a rule that it counts no totals of the
42
+ * table itself, and a guard that has to tell a sum of nodes from a sum of numbers is a guard that will be argued with
43
+ * one day. The count of nodes is not one of the report's numbers. */
44
+ let units = 0;
45
+ items.forEach((item) => { units += item.units; });
46
+ if (units <= APP_LONG) {
47
+ items.forEach((item) => step(item.i));
48
+ return;
49
+ }
50
+ appBar(true);
51
+ setTimeout(() => {
52
+ items.forEach((item) => step(item.i));
53
+ appBar(false);
54
+ }, 0);
55
+ }
package/src/parse.js CHANGED
@@ -126,7 +126,7 @@ function onNodeCheck(text) {
126
126
  } catch (e) {
127
127
  const lines = String((e && e.stderr) || (e && e.message) || e).split('\n')
128
128
  .map((l) => l.trim()).filter((l) => l !== '');
129
- // The fallback reason travels into a printed refusal, so it is Russian like the rest of the output.
129
+ // The fallback reason travels into a printed refusal, so it is worded like the rest of the output.
130
130
  return lines.find((l) => /^\w*Error\b/.test(l)) || lines[0] || 'the module does not parse';
131
131
  } finally {
132
132
  fs.rmSync(tmp, { force: true });
package/src/project.js CHANGED
@@ -220,8 +220,8 @@ export function projectConfig(root) {
220
220
  output: output,
221
221
  fixCommand: fixCommandOf(root),
222
222
  metrics: ['raw', 'min', 'tok'],
223
- // Real compression and a real dictionary rather than approximations: a new project must not start
224
- // with numbers that are honest only by half. Without the optional dependency the metric falls back
223
+ // Real compression and a real dictionary rather than their cheaper substitutes: a new project must
224
+ // not start with numbers counted another way. Without the optional dependency the metric falls back
225
225
  // to another count and the run returns code 4 — said by the metric label rather than left to a
226
226
  // default.
227
227
  minify: { engine: 'esbuild' },
@@ -41,9 +41,10 @@ export function assertCompilable(min, rev, p, src) {
41
41
  + ' fix: remove this extension from minify.guard or give it '
42
42
  + 'minify.ext — for example { "' + path.extname(p).toLowerCase() + '": "strip-lines" }');
43
43
  }
44
- // The reason comes from the parse the file actually was: blaming the other shape would
45
- // explain nothing.
46
- throw new Error('the stripper broke ' + p + ' at ' + rev.slice(0, 7) + ': '
44
+ /* The reason comes from the parse the file actually was: blaming the other shape would explain nothing.
45
+ * A revision is optional: the assembler of the report's page squeezes a text it built itself, and there is no
46
+ * revision to name it by then the message says only what broke. */
47
+ throw new Error('the stripper broke ' + p + (rev === '' ? '' : ' at ' + rev.slice(0, 7)) + ': '
47
48
  + (shape ? asModule : asScript));
48
49
  }
49
50
 
package/src/strip.js CHANGED
@@ -4,8 +4,7 @@ import { compactJson, stripCss, stripHtml, stripLines } from './strip/forms.js';
4
4
 
5
5
  /* Stripping ballast: which form of text applies to which file. Only text transformation —
6
6
  * the module knows no history and reads no settings, only the strategy it is handed. What
7
- * binds the forms to a file lives here: the extension, the strategy, and which strategies
8
- * count as exact.
7
+ * binds the forms to a file lives here: the extension and the strategy.
9
8
  *
10
9
  * The forms are re-exported from here, so that the package entry point has one address for
11
10
  * them and a move inside the parsing stays invisible to whoever relied on them. */
@@ -31,9 +30,9 @@ export const STRATEGIES = ['strip-js', 'strip-html', 'strip-css', 'json', 'strip
31
30
 
32
31
  /* The strategies that are minification itself: JSON loses only insignificant whitespace
33
32
  * (numbers take their shortest form) and nobody can make it shorter. The rest are a
34
- * simplification — they drop ballast but neither rename nor restructure code, so no exact
35
- * number can be promised for them. The list is owned here, next to the strategies, and the
36
- * metric reads it to decide whether its number is exact or approximate. */
33
+ * simplification — they drop ballast but neither rename nor restructure code. The list is owned
34
+ * here, next to the strategies, and the metric reads it to decide which formats it counts
35
+ * another way. */
37
36
  export const EXACT_STRATEGIES = ['json'];
38
37
 
39
38
  export function strategyFor(file, cfg) {
package/src/table.css CHANGED
@@ -1,23 +1,59 @@
1
1
  /* One type face for the whole table; numbers line up by their digits thanks to tabular-nums rather than through a
2
- * monospaced font. */
3
- table { border-collapse: collapse; font-variant-numeric: tabular-nums; }
4
- th, td { padding: 2px 7px; border-bottom: 1px solid rgba(127, 127, 127, .25); white-space: nowrap; }
2
+ * monospaced font.
3
+ *
4
+ * The layout is **fixed**, and the widths come from the `<colgroup>` the page builds along with the table (`appCols`):
5
+ * laying this table out as `auto` means measuring every cell of it — 174 468 of them here — to assign the widths of
6
+ * 265 columns.
7
+ *
8
+ * **Both declarations are needed, and the width is the one that is easy to lose.** A fixed layout with `width: auto`
9
+ * falls back to the automatic algorithm — the specification says so, and Chrome does it: the columns' widths are then
10
+ * ignored, the cells are measured all the same, and nothing looks wrong except that nothing is won. `100%` is what
11
+ * gives the table a definite width: it is the window, and the columns are wider than it, so the table keeps the widths
12
+ * it was given (`check` and the browser probe both watch this). */
13
+ table { border-collapse: collapse; font-variant-numeric: tabular-nums; table-layout: fixed; width: 100%; }
14
+ /* A counted width is the **border box** of a column — the padding of its cells and the group's border are inside it
15
+ * (measured in Chrome: `7ch` gave a 47px content box with 4px of padding each side). `--ch` is that count in
16
+ * characters, written by the page per column; `--clip` is the fixed measure a commit's caption is clipped to, and the
17
+ * one a narrow window may shorten (app.css). */
18
+ #grid { --cell-pad: 4px; --col-line: 1px; --clip: 190px; }
19
+ th, td { padding: 2px var(--cell-pad); border-bottom: 1px solid rgba(127, 127, 127, .25); white-space: nowrap; }
20
+ /* The count over-measures a sign and a thin space, which is the safe side: a cell clips nothing, so a column a
21
+ * character short would show a number running over its neighbour. */
22
+ #grid col { width: calc(var(--ch, 0px) + var(--cell-pad) * 2 + var(--col-line)); }
23
+ #grid col.c-commit { width: calc(var(--clip) + var(--cell-pad) * 2 + var(--col-line)); }
5
24
  /* A two-row header: both rows stick, so the second is offset by exactly the first one's height (line-height 20 plus the
6
25
  * 2px bottom border), or the rows would overlap. */
7
- thead th { position: sticky; top: 0; z-index: 3; background: Canvas; text-align: center; line-height: 20px; padding: 0 7px; }
26
+ thead th { position: sticky; top: 0; z-index: 3; background: Canvas; text-align: center; line-height: 20px; padding: 0 var(--cell-pad); }
8
27
  thead tr:first-child th { border-bottom-width: 2px; }
9
28
  thead tr:last-child th { top: 22px; }
10
29
  .num { text-align: right; }
11
- .g { border-left: 1px solid rgba(127, 127, 127, .35); }
30
+ /* The group's left border is carried by the first *enabled* metric: four rules instead of the eight combinations of
31
+ * "which of them is first", so switching a metric off moves the border by itself and no line of JS knows about it.
32
+ * The registry holds four metrics (`raw`, `min`, `tok`, `gzip`) — that is the count below, and `test/page-view.test.js`
33
+ * reddens if a fifth name appears. A group's heading (`.gh`) spans its metrics rather than belonging to one, so its
34
+ * own left edge is there while any metric is on (`.m-none` is set when the reader switched them all off). */
35
+ #grid .g { border-left: 0; }
36
+ #grid:not(.m-off-0) .g.m0,
37
+ #grid.m-off-0:not(.m-off-1) .g.m1,
38
+ #grid.m-off-0.m-off-1:not(.m-off-2) .g.m2,
39
+ #grid.m-off-0.m-off-1.m-off-2:not(.m-off-3) .g.m3 { border-left: 1px solid rgba(127, 127, 127, .35); }
40
+ #grid:not(.m-none) .gh { border-left: 1px solid rgba(127, 127, 127, .35); }
41
+ /* A metric switched off hides all of its cells and both of its headings at once — one class on the table instead of a
42
+ * pass over the metric's cells (measured: 265 operations against 56 496). */
43
+ #grid.m-off-0 .m0, #grid.m-off-1 .m1, #grid.m-off-2 .m2, #grid.m-off-3 .m3 { display: none; }
44
+ /* A file's column switched off: its cells and its headings together. The hidden column keeps its place in the markup,
45
+ * so the columns that stay do not move. */
46
+ #grid .off { display: none; }
12
47
  /* The sticky left column: an opaque background (Canvas), or numbers would show through the cell when scrolling sideways. A
13
48
  * tier above the neighbouring cells (2) and below the header (3); the header's corner is above them all, or the column groups
14
49
  * would crawl over the commit column. */
15
50
  .c-commit { position: sticky; left: 0; z-index: 2; background: Canvas; text-align: left; font-weight: 400; }
16
51
  .c-commit a { color: inherit; }
17
52
  thead .c-commit { z-index: 6; }
18
- /* This block sets the column's width. Without it the cell's content went past its borders and was drawn over the neighbouring
19
- * numbers: a table cell clips nothing. */
20
- .clip { display: flex; align-items: baseline; gap: 6px; width: 300px; }
53
+ /* This block fills the commit column, whose width is set by the column itself (`#grid col.c-commit`). It took the
54
+ * column's measure with it: the cell's content would otherwise go past its borders and be drawn over the neighbouring
55
+ * numbers, because a table cell clips nothing. */
56
+ .clip { display: flex; align-items: baseline; gap: 6px; width: 100%; }
21
57
  .when { flex: none; opacity: .7; }
22
58
  .subj { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; display: block; }
23
59
  .subj.plain { opacity: .7; }