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