@vernikr/size-report 2.5.0 → 2.6.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/state.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { appDecode } from './payload.js';
2
+
1
3
  /* The page's choice state: what is switched on, how it survives a closing and how it travels as a link.
2
4
  *
3
5
  * An ordinary source file rather than a string inside the engine: a linter sees it, and it is pasted into the assembled
@@ -14,26 +16,45 @@
14
16
  * not applied while a vanished name simply means nothing. The same record goes into the address — which is what one
15
17
  * sends to a colleague.
16
18
  *
17
- * The page does not derive a number's accuracy itself: the marks of approximate cells arrive in the data, from the same
18
- * rule that names a metric's accuracy. The page draws no such conclusion from paths and formats there will be no
19
- * second rule of accuracy. */
19
+ * The page draws no conclusion about how a number was obtained: the method of each metric arrives in the data, and the
20
+ * page prints it. There is no second rule of counting here, and no vocabulary of precision either. */
20
21
 
21
- export const appData = JSON.parse(document.getElementById('data').textContent);
22
22
  export const appUi = JSON.parse(document.getElementById('ui').textContent);
23
- export const appView = { metrics: {}, files: [], folded: {} };
24
- appData.metrics.forEach((m) => { appView.metrics[m.key] = true; });
25
- appData.files.forEach(() => { appView.files.push(true); });
26
23
 
27
- /* A metric's description by key: the tooltip of an approximate cell names the way its number was obtained the same one
28
- * that stands in the metric's caption, so the page holds no two answers about "counted with what". */
29
- export const appMetric = {};
30
- appData.metrics.forEach((m) => { appMetric[m.key] = m; });
24
+ /* The model is not a constant any longer: the block in the file is **packed** (gzipped and base64 encoded,
25
+ * `appUnpack` of the payload chapter), so unpacking is asynchronous and the model is set once, by `appBoot`,
26
+ * before anything is drawn and before any chapter below reads it. The names, their shapes and their order are what
27
+ * they were; only their appearance moved — from the parse to that one call. */
28
+ export let appData = null;
29
+ export let appView = null;
30
+ export let appMetric = null;
31
+ export let appMeasured = null;
32
+ let appKey = null;
33
+ let appFoldKey = null;
31
34
 
32
- /* A column by a file's path: the page's tree is the project's tree (every path of the catalogue) while the numbers
33
- * belong to columns only, so this pointer is what decides whether a leaf is a checkbox or a caption. The name follows the
34
- * same rule as the choice's record (`appFileAt`), so the tree and the reader's memory cannot drift apart. */
35
- export const appMeasured = {};
36
- appData.files.forEach((_f, i) => { appMeasured[appFileAt(i)] = i; });
35
+ /* One place that turns the unpacked block into what the chapters speak: the sparse form is unrolled by the payload
36
+ * chapter into the dense contract every number comes from snapshots per commit, the texts themselves, "now" as
37
+ * the state at HEAD and the view starts switched on whole.
38
+ *
39
+ * A metric's description by key stands here too: the way the number was obtained is text under the switches, so the
40
+ * page holds no second answer about "counted with what".
41
+ *
42
+ * A column by a file's path is the pointer that decides whether a leaf of the tree is a checkbox or a caption (the
43
+ * page's tree is the project's tree while the numbers belong to columns only); the name follows the same rule as the
44
+ * choice's record (`appFileAt`), so the tree and the reader's memory cannot drift apart.
45
+ *
46
+ * The key the reader's memory lives under is counted here as well, because it is the report's passport: it depends
47
+ * on the data, and until the block is unpacked there is nothing to count it from. */
48
+ export function appBoot(text) {
49
+ appData = appDecode(JSON.parse(text));
50
+ appView = { metrics: {}, files: [], folded: {} };
51
+ appMetric = {};
52
+ appMeasured = {};
53
+ appData.metrics.forEach((m) => { appView.metrics[m.key] = true; appMetric[m.key] = m; });
54
+ appData.files.forEach((_f, i) => { appView.files.push(true); appMeasured[appFileAt(i)] = i; });
55
+ appKey = 'size-report:' + appPassport();
56
+ appFoldKey = appKey + ':tree';
57
+ }
37
58
 
38
59
  /* A link is that same choice in the address, under a name of its own: someone else's anchor on the page does not count
39
60
  * as a link, and there is nothing to argue with it about. */
@@ -70,12 +91,19 @@ function appHash(text) {
70
91
  * report's order. It is what tells one report from another — the record's key is chosen by it, so a choice made in
71
92
  * someone else's report is not picked up. The package version and the top of the history are absent on purpose: this is
72
93
  * the same report — updating the tool does not change what a column means, while a grown history is the very history the
73
- * reader comes back to. */
94
+ * reader comes back to.
95
+ *
96
+ * Counted once per document: it is a constant of the report, which depends on nothing the reader can change, and every
97
+ * click asks for it (the key of the memory and the passport of the record). A second count would be a second answer
98
+ * waiting to happen, and the labels it reads do not change while the page is open. */
99
+ let appPassportValue = null;
74
100
  function appPassport() {
75
- return appHash([appData.tool.name, appData.schema, appData.report.artifact,
76
- appData.report.title, appData.files.map((f) => f.label).join('|')].join('\n'));
101
+ if (appPassportValue === null) {
102
+ appPassportValue = appHash([appData.tool.name, appData.schema, appData.report.artifact,
103
+ appData.report.title, appData.files.map((f) => f.label).join('|')].join('\n'));
104
+ }
105
+ return appPassportValue;
77
106
  }
78
- const appKey = 'size-report:' + appPassport();
79
107
 
80
108
  /* One record of the choice for everything: it goes both into the memory and into the address, so there are no two formats
81
109
  * of one state. Only what is switched off is kept, by name: "switched on" and "no record" are the same state, which is
@@ -93,27 +121,54 @@ function appRecordOk(rec) {
93
121
  return rec !== null && typeof rec === 'object' && rec.v === 1 && rec.passport === appPassport();
94
122
  }
95
123
 
124
+ /* The address is the link for a colleague, while the memory is the reader's own: the memory is written on the click
125
+ * itself — that is what survives a closing — and the address 200 ms after the last of a burst of switches, because a
126
+ * burst is one link rather than five history entries and five URL parses. The delay is short enough for a person and
127
+ * long enough to swallow a run of clicks; a timer that fires after the page is gone writes nothing useful, which is the
128
+ * price of not writing the address five times. */
129
+ const APP_ADDRESS_DELAY = 200;
130
+ let appAddressTimer = null;
131
+
132
+ /* An address that came in from outside wins over a write this page has not made yet: a click arms a write, a link
133
+ * arrives within the delay, and the choice left behind must not land on the address the reader was sent — a refused link
134
+ * arms nothing to replace it, so without this the page would rewrite someone else's address a fifth of a second later. */
135
+ export function appAddressDrop() {
136
+ if (appAddressTimer === null) return;
137
+ clearTimeout(appAddressTimer);
138
+ appAddressTimer = null;
139
+ }
140
+
141
+ function appAddressLater(text) {
142
+ appAddressDrop();
143
+ appAddressTimer = setTimeout(() => {
144
+ appAddressTimer = null;
145
+ try {
146
+ window.history.replaceState(null, '', APP_LINK + encodeURIComponent(text));
147
+ } catch (_e) {
148
+ /* The browser grants no change of the address: the link is then taken from the browser's memory. */
149
+ }
150
+ }, APP_ADDRESS_DELAY);
151
+ }
152
+
153
+ /* One record for a click and two destinations: the same text goes into the memory and — a moment later — into the
154
+ * address, so the two cannot describe different choices. */
96
155
  export function appWrite() {
97
156
  const rec = appRecord();
157
+ const text = JSON.stringify(rec);
98
158
  const empty = Object.keys(rec.metrics).length === 0 && Object.keys(rec.files).length === 0;
99
159
  if (!appTransient) {
100
160
  try {
101
161
  if (empty) window.localStorage.removeItem(appKey);
102
- else window.localStorage.setItem(appKey, JSON.stringify(rec));
162
+ else window.localStorage.setItem(appKey, text);
103
163
  } catch (_e) {
104
164
  /* There is no memory (the browser grants this page none): the choice will not survive a closing, while the numbers
105
165
  * and the markup do not depend on it. */
106
166
  }
107
167
  }
108
- /* The address is the link for a colleague, which is why it repeats the choice. But not during the first drawing and not
109
- * when the link turned out to be someone else's: an address that came in is not ours, and the reader has yet to read
110
- * it. */
168
+ /* But not during the first drawing and not when the link turned out to be someone else's: an address that came in is
169
+ * not ours, and the reader has yet to read it. */
111
170
  if (appStartup || appForeign) return;
112
- try {
113
- window.history.replaceState(null, '', APP_LINK + encodeURIComponent(JSON.stringify(rec)));
114
- } catch (_e) {
115
- /* The browser grants no change of the address: the link is then taken from the browser's memory. */
116
- }
171
+ appAddressLater(text);
117
172
  }
118
173
 
119
174
  /* A reset to "everything on": the border between "this is no longer in the report" and "switched off" is the record
@@ -220,12 +275,12 @@ export function appApply(rec) {
220
275
 
221
276
  /* -------- the folded tree -------- */
222
277
 
278
+
223
279
  /* Folded folders are a memory of the same kind as the choice, but of a record of their own: it is about how much of the
224
280
  * tree is visible rather than about which numbers are read. Hence it does not go into the address: a link is sent for the
225
281
  * sake of the numbers, while an unfolded tree is the onlooker's business. As with the choice, only what is folded is kept
226
- * (`true`), and a folder's name is its path ("src/page"), so a vanished name simply means nothing. */
227
- const appFoldKey = appKey + ':tree';
228
-
282
+ * (`true`), and a folder's name is its path ("src/page"), so a vanished name simply means nothing. `appFoldKey` is
283
+ * set with the rest of the model (`appBoot`), for the reason the key itself is. */
229
284
  export function appFoldRead() {
230
285
  let text = null;
231
286
  try {
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,211 @@ 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);
119
217
  });
120
- model.files.forEach((cells, fi) => cells.forEach((cell, mi) => {
121
- tr.appendChild(appCell(cell, mi === 0, appApprox(r, [files[fi]], metrics[mi])));
122
- }));
218
+ appRowCells(tr, model, files, cache, appCell);
123
219
  return tr;
124
220
  }
125
221
 
126
- /* The body: the commit rows built from the newest down, plus the "now" row with the absolute sizes at HEAD. The deltas
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);
235
+ });
236
+ appRowCells(tr, model, files, cache, appValueCell);
237
+ return tr;
238
+ }
239
+
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: [], spans: [], cells: [], sums: [], all: [] };
257
+ metrics.forEach(() => {
258
+ cache.cells.push([]);
259
+ cache.sums.push([]);
260
+ });
261
+ grid.textContent = '';
262
+ grid.appendChild(appHead(files, metrics, cache));
263
+ grid.appendChild(appBody(files, metrics, cache));
264
+ /* The columns stand before the rows rather than beside them: a `<colgroup>` is where the fixed layout reads its
265
+ * widths from, and it has to be in the markup before the browser settles on a layout — the drawing is one pass, not
266
+ * two. */
267
+ grid.insertBefore(appCols(files, appWidest(files, metrics), cache), grid.firstChild);
268
+ cache.all = cache.sums.map((row) => row.slice());
269
+ return cache;
270
+ }
271
+
272
+ /* A file's column shown or hidden: its cells and its headings together, one class per node and no new node. A hidden
273
+ * column keeps its place in the markup — the order of the columns is the files' business, not the choice's. */
274
+ export function appColumn(cache, i, on) {
275
+ cache.cols[i].forEach((node) => node.classList.toggle('off', !on));
276
+ }
277
+
278
+ /* A file's share of the totals: switched off it takes exactly its own numbers out of the running sums, switched on
279
+ * 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
280
+ * exact in either direction. */
281
+ export function appContribute(cache, i, on) {
282
+ const sign = on ? 1 : -1;
283
+ appData.rows.forEach((row, r) => {
284
+ const values = row.values[i];
285
+ if (values === null) return;
286
+ cache.keys.forEach((key, mi) => { cache.sums[mi][r] += sign * values[key]; });
287
+ });
288
+ const values = appData.now[i];
289
+ if (values === null) return;
290
+ const last = appData.rows.length;
291
+ cache.keys.forEach((key, mi) => { cache.sums[mi][last] += sign * values[key]; });
292
+ }
293
+
294
+ /* The totals written where they stand: a data row shows the change against the row below it, the "now" row the
295
+ * absolute size — the same rules the first drawing used (`cellParts`, `valueParts`), so the reader sees one kind of
296
+ * number and not two. */
297
+ export function appTotals(cache) {
298
+ const last = appData.rows.length;
299
+ cache.cells.forEach((cells, mi) => {
300
+ cells.forEach((td, r) => {
301
+ if (r === last) { appFill(td, valueParts(cache.sums[mi][r])); return; }
302
+ appFill(td, cellParts(cache.sums[mi][r], deltaOf(cache.sums[mi][r], r === 0 ? null : cache.sums[mi][r - 1]), '−'));
303
+ });
304
+ });
305
+ }
306
+
307
+ /* The running sums of the whole view again: a link, a record from the memory and the first drawing change the choice
308
+ * as a whole rather than a column, so the sums start from the sums with every column on and take the switched-off
309
+ * ones out — one copy of the numbers instead of a recount. */
310
+ export function appTotalsReset(cache) {
311
+ cache.sums = cache.all.map((row) => row.slice());
312
+ appData.files.forEach((_f, i) => { if (!appView.files[i]) appContribute(cache, i, false); });
313
+ appTotals(cache);
314
+ }
315
+
316
+ /* The metrics of the choice: a class on the table hides every cell of a metric at once, and the group headings follow
317
+ * how many are left — two things that change, instead of a pass over the metric's cells (measured: 2.20 ms and 265
318
+ * operations against 433 ms and 56 496 for walking them). `.m-none` is what the styling reads to drop the group's
319
+ * left border when there is nothing left to separate. */
320
+ export function appMetrics(cache) {
321
+ let on = 0;
322
+ cache.keys.forEach((key, mi) => {
323
+ const visible = appView.metrics[key] === true;
324
+ if (visible) on++;
325
+ cache.grid.classList.toggle('m-off-' + mi, !visible);
326
+ });
327
+ cache.grid.classList.toggle('m-none', on === 0);
328
+ cache.spans.forEach((th) => { th.colSpan = on === 0 ? 1 : on; });
329
+ }
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) {