@vernikr/size-report 2.6.0 → 2.8.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,51 +1,88 @@
1
- import { cellParts, commitParts, deltaOf, nowModel, rowModel, valueParts } from '../derived.js';
1
+ import { cellParts, commitParts, nowModel, rowModel, valueParts } from '../derived.js';
2
2
  import { appEl } from './dom.js';
3
3
  import { appData, appUi, appView } from './state.js';
4
4
 
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.
5
+ /* The table: a grid of plain elements (`<div>`), of which only the part the reader looks at is built. The file's
6
+ * length is the report's, but the price of a report is paid by whoever opens it, so the table is no longer a
7
+ * `<table>` with every cell of every column in it.
9
8
  *
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.
9
+ * **Why not a `<table>`.** Measured on this repository's own report (238 500 cells = 370 758 nodes, a table 71 712 ×
10
+ * 5 982 px): about 1.4 GB of a browser's memory, of which roughly half the nodes and half the painted area, and a
11
+ * browser's relayout of it costs close to a second on any switch (`probes/step-12-columns.mjs`). `content-visibility:
12
+ * auto`, the cheap way out, is ignored on a table row by Chrome 153 (`probes/step-10-tables.mjs`), so the answer is
13
+ * to build less rather than to promise the browser will skip it. A grid of `position: absolute` rows has no layout to
14
+ * be redone: a row is placed by its `top`, a column by the `left` of the group of cells that starts it, and the
15
+ * browser never measures a cell to decide a width — every column is `--col` wide (70px), which is what the numbers
16
+ * need and no more (the counted widths this step replaced were 47–70px).
14
17
  *
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.
18
+ * **Why not a library.** A virtualizer for two axes is not a solved problem for a page like this one — measured from
19
+ * the tarballs, `@tanstack/virtual-core` is ~6.7 kB gzip and headless (the rows and columns are two virtualizers and
20
+ * every node is yours to write), `virtua` (~6.1 kB) calls its grid `experimental_VGrid` and has no sticky pieces,
21
+ * `Clusterize.js` virtualizes rows from a string of all of them and knows nothing of columns. All three would have to
22
+ * be vendored into the artifact — the page is one file, opens from disk and resolves no import — and the report
23
+ * measures its own bytes, so the library would be measured by the very tool it is inside. What is left to write
24
+ * after any of them is what this file is: the window, the cells, the header and the pinned column.
25
+ *
26
+ * **What is built.** The window is the rows and the columns the shell shows, plus `APP_OVER` beyond each edge: what
27
+ * the reader is about to reach is already there, so the edge of the window is never seen empty. Scrolling costs no
28
+ * JavaScript, the rows sit in the scrolled content at their own `top`; the script works only when the window has
29
+ * really moved, and then only on what left it and what entered it (measured on the prototype: 0.9 ms a step down the
30
+ * table, against 6.5 ms for building the whole window again). The header sticks to the shell's top and the commit
31
+ * column to its left (both `position: sticky`), so the row and the column a number belongs to are always in sight.
32
+ *
33
+ * The choice is applied by building the window again: the columns of a switched-off file are simply not among the
34
+ * columns that are built, so there is nothing to hide and nothing to recount — the totals are the sum over the files
35
+ * that are on, counted by `rowModel` for the rows the window holds (`src/derived.js`, one place for that
36
+ * arithmetic).
17
37
  */
18
38
 
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
- }
39
+ /* The geometry in pixels: written here and read by the styling (`src/table.css`), which is one copy too many — hence
40
+ * `test/page-grid.test.js` holds the two together, and the report says the same numbers in its journal. */
41
+ export const APP_COL = 70;
42
+ export const APP_ROW = 25;
43
+ export const APP_HEAD = 44;
44
+
45
+ /* How much more than the visible window is built, in rows and in columns. A window that ends exactly at the edge of
46
+ * the shell shows an empty band while the browser scrolls a notch; four rows and four columns of slack are cheaper
47
+ * than that band, and they are what makes a scroll with the wheel or the trackpad look like a scroll. */
48
+ export const APP_OVER = 4;
25
49
 
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 = '';
30
- if (parts.dir === null) td.textContent = parts.text;
31
- else td.appendChild(appEl('span', 'delta ' + parts.dir, parts.text));
32
- return td;
50
+ /* The window of a shell that has no size: jsdom lays nothing out, and a shell with no metrics is hidden. A page is
51
+ * not written for that case, but its checks are: without these figures a check would read an empty table and say
52
+ * nothing rather than say it about the right thing. */
53
+ export const APP_MIN_ROWS = 24;
54
+ export const APP_MIN_COLS = 10;
55
+
56
+ /* The order of the columns: the files the last commit touched come first — the report is rebuilt after every
57
+ * commit, and a reader's first question is what that edit brought. Inside each part the order is the settings', and
58
+ * it depends on the files rather than on the choice: that is what lets a column be switched off without moving the
59
+ * others. */
60
+ export function appOrder() {
61
+ const files = [];
62
+ appData.files.forEach((_f, i) => files.push(i));
63
+ files.sort((a, b) => (appData.last[a] === true ? 0 : 1) - (appData.last[b] === true ? 0 : 1));
64
+ return files;
33
65
  }
34
66
 
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);
67
+ /* The files whose columns are built, in the order of the columns: what is switched off is not among them, which is
68
+ * the whole of what a switch changes about the grid. */
69
+ function appList() {
70
+ const out = [];
71
+ appOrder().forEach((i) => { if (appView.files[i] === true) out.push(i); });
72
+ return out;
39
73
  }
40
74
 
41
- // The markup of the top row's cell: the rules live in valueParts.
42
- export function appValueCell(value, mi) {
43
- const parts = valueParts(value);
44
- return appFill(appEl('td', appCellClass(mi, parts.miss)), parts);
75
+ /* A number with its sign: the rules of a cell's content live in `cellParts` and `valueParts`, only the node is here
76
+ * and its class, which carries the alignment (`num`), the group's left edge (`g`), the gap (`miss`) and the colour of
77
+ * the change (`up`/`down`). The colour stands on the cell itself rather than on a child of it: a report of this
78
+ * repository's size has a quarter of a million of these numbers, and one node instead of two is half of the window. */
79
+ function appNum(parts, cls) {
80
+ return appEl('span', cls + (parts.miss ? ' miss' : '') + (parts.dir === null ? '' : ' ' + parts.dir), parts.text);
45
81
  }
46
82
 
47
- /* A commit's caption: the date, the subject, the journal section's mark. The column's width and the clipping of a long
48
- * subject come from the shared part of the styling, which is why the column does not jump when files are switched. */
83
+ /* A commit's caption: the date, the subject, the journal section's mark. The column has a fixed width, so the
84
+ * caption is clipped with an ellipsis rather than wrapped (`.clip` in the styling), while the whole subject stands
85
+ * in the tooltip. */
49
86
  export function appCommit(row) {
50
87
  const parts = commitParts(row, appData.report.showSha, row.href);
51
88
  const name = parts.href ? appEl('a', 'subj', parts.subject) : appEl('span', 'subj', parts.subject);
@@ -58,14 +95,12 @@ export function appCommit(row) {
58
95
  clip.appendChild(appEl('span', 'when', parts.when));
59
96
  clip.appendChild(name);
60
97
  clip.appendChild(mark);
61
- const th = appEl('th', 'c-commit');
62
- th.appendChild(clip);
63
- return th;
98
+ return clip;
64
99
  }
65
100
 
66
- /* The empty states: when there will be no numbers at all, the page says so in words rather than showing a grid without
67
- * columns. Every file can be switched off — then the total volume remains, and the note explains why there are no
68
- * columns. The table itself stands there either way: this is about what is shown, not about what exists. */
101
+ /* The empty states: when there will be no numbers at all, the page says so in words rather than showing a grid
102
+ * without columns. Every file can be switched off — then the total volume remains, and the note explains why there
103
+ * are no columns. The shell stays where it is either way: this is about what is shown, not about what exists. */
69
104
  export function appState(metricsCount, filesCount) {
70
105
  const state = document.getElementById('state');
71
106
  const text = metricsCount === 0 ? appUi.empty : (filesCount === 0 ? appUi.noFiles : '');
@@ -74,256 +109,168 @@ export function appState(metricsCount, filesCount) {
74
109
  document.getElementById('shell').hidden = metricsCount === 0;
75
110
  }
76
111
 
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) {
92
- const head = appEl('tr');
93
- const commit = appEl('th', 'c-commit', appUi.commit);
94
- commit.rowSpan = 2;
95
- head.appendChild(commit);
96
- const subs = appEl('tr');
97
- const group = (label, i) => {
98
- const th = appEl('th', 'gh', label);
99
- th.colSpan = metrics.length;
100
- head.appendChild(th);
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);
115
- });
116
- const thead = appEl('thead');
117
- thead.appendChild(head);
118
- thead.appendChild(subs);
119
- return thead;
120
- }
121
-
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;
112
+ /* One cell of the window: the column decides what it holds. The group is the column's ordinal divided by the number of
113
+ * metrics `0` is the total over the files, `g 1` is the g-th column of the order and it is the same arithmetic
114
+ * the header is placed by, so a column and its caption cannot drift apart. The model lists the *enabled* files in the
115
+ * order of the data (that is what its mask means), while the columns stand in the order of the settings, which is why
116
+ * the ordinal of the column is turned into the ordinal of the model by `slot` (counted once per window). */
117
+ function appCell(model, c, now, cache) {
118
+ const mi = c % cache.keys.length;
119
+ const group = (c - mi) / cache.keys.length;
120
+ const cls = 'num' + (mi === 0 ? ' g' : '');
121
+ const at = group === 0 ? null : cache.slot[group - 1];
122
+ const cell = at === null ? model.total[mi] : (model.files[at] || [])[mi];
123
+ if (cell === undefined) return appEl('span', cls);
124
+ return appNum(now ? valueParts(cell) : cellParts(cell.value, cell.delta, '−'), cls);
132
125
  }
133
126
 
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 };
127
+ /* A row of the window: the caption of the commit and the numbers of the columns the window holds. The top row is the
128
+ * state at HEAD (`nowModel`) rather than the last commit's cells: an absolute number stands in the table once, and
129
+ * the deltas below it add up to it. */
130
+ function appRow(cache, r, span) {
131
+ const last = appData.rows.length;
132
+ const now = r === 0;
133
+ const i = last - r;
134
+ const model = now
135
+ ? nowModel(appData.now, cache.keys, appView.files)
136
+ : rowModel(appData.rows[i].values, i === 0 ? null : appData.rows[i - 1].values, cache.keys, appView.files);
137
+ const row = appEl('div', 'row' + (now ? ' now' : ''));
138
+ row.style.top = (APP_HEAD + r * APP_ROW) + 'px';
139
+ const commit = appEl('div', 'c-commit');
140
+ if (now) commit.textContent = appUi.now;
141
+ else commit.appendChild(appCommit(appData.rows[i]));
142
+ row.appendChild(commit);
143
+ const cells = appEl('div', 'cells');
144
+ cells.style.left = (span.c0 * APP_COL) + 'px';
145
+ for (let c = span.c0; c <= span.c1; c++) cells.appendChild(appCell(model, c, now, cache));
146
+ row.appendChild(cells);
147
+ return row;
157
148
  }
158
149
 
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));
150
+ /* A built row into the window: the map is what says which rows are in the markup, so a row goes into it where it is
151
+ * made otherwise a window that moved would build its rows beside the ones that are still there. */
152
+ function appPlace(cache, r, span) {
153
+ const row = appRow(cache, r, span);
154
+ cache.rows.set(r, row);
155
+ cache.grid.appendChild(row);
156
+ return row;
167
157
  }
168
158
 
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;
159
+ /* A caption of the header: a file's name or a metric's. The room is a fixed number of columns, so a name that does
160
+ * not fit is cut with an ellipsis (the styling) rather than wrapped the header is one line high, and the whole name
161
+ * is reachable in the tooltip. */
162
+ function appCaption(text, cls, span) {
163
+ const el = appEl('span', cls, text);
164
+ if (span > 1) el.style.gridColumn = 'span ' + span;
165
+ el.title = text;
166
+ return el;
187
167
  }
188
168
 
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
- });
169
+ /* The header: the total and the enabled files over their columns, and one caption per metric column under them. It
170
+ * holds the window's columns alone and is built again only when the window moves sideways scrolling down leaves it
171
+ * untouched, and the two rows of it are placed by `grid-column`, so a group of metrics is a group in the grid. */
172
+ function appHead(cache, span) {
173
+ const count = cache.keys.length;
174
+ const g0 = Math.floor(span.c0 / count);
175
+ const g1 = Math.floor(span.c1 / count);
176
+ const left = (g0 * count * APP_COL) + 'px';
177
+ const head = appEl('div', 'head');
178
+ head.appendChild(appEl('div', 'c-commit', appUi.commit));
179
+ const groups = appEl('div', 'hgroups');
180
+ groups.style.left = left;
181
+ const metrics = appEl('div', 'hmetrics');
182
+ metrics.style.left = left;
183
+ for (let g = g0; g <= g1; g++) {
184
+ const label = g === 0 ? appUi.total : appData.files[cache.list[g - 1]].label;
185
+ groups.appendChild(appCaption(label, 'gh', count));
186
+ for (let mi = 0; mi < count; mi++) {
187
+ metrics.appendChild(appCaption(cache.labels[mi], mi === 0 ? 'g' : '', 1));
188
+ }
189
+ }
190
+ head.appendChild(groups);
191
+ head.appendChild(metrics);
192
+ return head;
200
193
  }
201
194
 
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) {
207
- const row = appData.rows[r];
208
- const prev = r === 0 ? null : appData.rows[r - 1];
209
- const model = rowModel(row.values, prev === null ? null : prev.values, metrics);
210
- const tr = appEl('tr');
211
- tr.appendChild(appCommit(row));
212
- model.total.forEach((cell, mi) => {
213
- const td = appCell(cell, mi);
214
- cache.sums[mi][r] = cell.value;
215
- cache.cells[mi][r] = td;
216
- tr.appendChild(td);
195
+ /* The window the shell shows: the rows and the columns, in the ordinals of the whole grid, and the size of that
196
+ * grid. The rows are counted from the top of the content, under the header: the header is stuck to the top of the
197
+ * shell and covers the first rows of the content, hence the offset at both ends of the window. */
198
+ export function appSpan(cache) {
199
+ const shell = cache.shell;
200
+ /* The metrics and the files of the window are read from the view here rather than kept: a switch changes them, and
201
+ * what is built has to be the choice as it is now — the keys, their captions and the ordinals of both below. */
202
+ cache.keys = [];
203
+ cache.labels = [];
204
+ appData.metrics.forEach((m) => {
205
+ if (appView.metrics[m.key] === true) { cache.keys.push(m.key); cache.labels.push(m.label); }
217
206
  });
218
- appRowCells(tr, model, files, cache, appCell);
219
- return tr;
207
+ const count = cache.keys.length;
208
+ cache.list = appList();
209
+ /* The ordinals of the model: `rowModel` lists the enabled files in the order of the data, the columns stand in the
210
+ * order of the settings, and `rank` is the bridge between the two — counted once per window, asked per cell. */
211
+ let rank = 0;
212
+ cache.rank = [];
213
+ appData.files.forEach((_f, i) => { cache.rank[i] = appView.files[i] === true ? rank++ : -1; });
214
+ cache.slot = cache.list.map((i) => cache.rank[i]);
215
+ const cols = count * (cache.list.length + 1);
216
+ const rows = appData.rows.length + 1;
217
+ cache.grid.style.width = (cols * APP_COL) + 'px';
218
+ cache.grid.style.height = (APP_HEAD + rows * APP_ROW) + 'px';
219
+ if (count === 0) return { r0: 0, r1: -1, c0: 0, c1: -1 };
220
+ const high = shell.clientHeight || APP_MIN_ROWS * APP_ROW;
221
+ const wide = shell.clientWidth || APP_MIN_COLS * APP_COL;
222
+ const top = shell.scrollTop + APP_HEAD;
223
+ return {
224
+ r0: Math.max(0, Math.floor(top / APP_ROW) - APP_OVER),
225
+ r1: Math.min(rows - 1, Math.floor((top + high) / APP_ROW) + APP_OVER),
226
+ c0: Math.max(0, Math.floor(shell.scrollLeft / APP_COL) - APP_OVER),
227
+ c1: Math.min(cols - 1, Math.floor((shell.scrollLeft + wide) / APP_COL) + APP_OVER)
228
+ };
220
229
  }
221
230
 
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);
231
+ /* The window drawn. A scroll costs what left the window and what entered it, and nothing else: the row that is
232
+ * already built is not touched. Sideways the window of columns is another window the header and the rows are
233
+ * built again, because a column that is not there cannot be shown; the reader's own choice (`redraw`) is the same
234
+ * kind of change, and comes here by the same road. */
235
+ export function appWindow(cache, redraw) {
236
+ const span = appSpan(cache);
237
+ const was = cache.win;
238
+ cache.win = span;
239
+ if (redraw === true || was === null || was.c0 !== span.c0 || was.c1 !== span.c1) {
240
+ const head = cache.grid.querySelector('.head');
241
+ if (head !== null) head.remove();
242
+ cache.grid.insertBefore(appHead(cache, span), cache.grid.firstChild);
243
+ cache.rows.clear();
244
+ [...cache.grid.querySelectorAll('.row')].forEach((row) => row.remove());
245
+ for (let r = span.r0; r <= span.r1; r++) appPlace(cache, r, span);
246
+ return;
247
+ }
248
+ [...cache.rows.keys()].forEach((r) => {
249
+ if (r >= span.r0 && r <= span.r1) return;
250
+ cache.rows.get(r).remove();
251
+ cache.rows.delete(r);
235
252
  });
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
241
- * under it add up to it, which is why it stands first. */
242
- export function appBody(files, metrics, cache) {
243
- const body = appEl('tbody');
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);
246
- return body;
253
+ for (let r = span.r0; r <= span.r1; r++) {
254
+ if (!cache.rows.has(r)) appPlace(cache, r, span);
255
+ }
247
256
  }
248
257
 
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. */
258
+ /* The grid: the shell whose scroll it follows, the metrics it counts in and the window at the place the reader is.
259
+ * The first drawing happens where the view is known (`appPaint` of the assembling chapter): what is built here is the
260
+ * place the table stands in, and no cell of it. */
253
261
  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());
262
+ const cache = {
263
+ grid: grid,
264
+ shell: document.getElementById('shell'),
265
+ keys: [],
266
+ labels: [],
267
+ list: [],
268
+ rank: [],
269
+ slot: [],
270
+ rows: new Map(),
271
+ win: null
272
+ };
273
+ cache.shell.addEventListener('scroll', () => appWindow(cache));
274
+ window.addEventListener('resize', () => appWindow(cache, true));
269
275
  return cache;
270
276
  }
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
- }