@peter.prib/renderengine 0.10.0 → 0.13.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/README.md +10 -4
- package/dist/renderengine-workspace.umd.js +5779 -3731
- package/dist/renderengine.umd.js +5757 -3725
- package/documentation/REFERENCE.md +3291 -3077
- package/package.json +2 -2
- package/src/charting/Axis.js +47 -5
- package/src/charting/Chart.js +25 -0
- package/src/charting/ChartChooser.js +5 -4
- package/src/charting/DiagramEditor.js +239 -34
- package/src/charting/Legend.js +16 -0
- package/src/charting/ModelFileFormat.js +47 -0
- package/src/charting/archimate/ArchimateCanvasEditor.js +1108 -1012
- package/src/charting/archimate/deleteFromModel.js +57 -0
- package/src/charting/archimate/elementMenuItems.js +3 -1
- package/src/charting/archimate/heatMaps.js +169 -0
- package/src/charting/archimate/index.js +1 -0
- package/src/charting/charts/BarChart.js +5 -5
- package/src/charting/charts/BarChartBase.js +21 -3
- package/src/charting/charts/GroupedBarChart.js +42 -22
- package/src/charting/charts/LineChart.js +9 -8
- package/src/charting/charts/PieChart.js +2 -2
- package/src/charting/charts/TableChart.js +34 -5
- package/src/charting/diagrams/ArchimateModel.js +33 -0
- package/src/charting/diagrams/DeleteFromModelCommand.js +81 -0
- package/src/charting/diagrams/EdgeGeometry.js +16 -0
- package/src/charting/edgeStyle/EdgeStyleSchema.js +6 -2
- package/src/charting/pivot/PivotChart.js +50 -0
- package/src/charting/pivot/PivotGridChart.js +191 -0
- package/src/charting/pivot/PivotView.js +740 -0
- package/src/charting/table/TableView.js +84 -15
- package/src/charting/table/TableViewChart.js +5 -3
- package/src/charting/templates/Template.js +10 -0
- package/src/exportImage.js +45 -18
- package/src/index.js +2899 -2807
- package/src/workspace/parts/ArchimateCanvasPart.js +3 -0
- package/src/workspace/parts/DiagramEditorPart.js +2 -0
- package/src/workspace/parts/ModelHosted.js +15 -4
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
import { TableChart } from '../charts/TableChart.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PivotView — the state of a pivot grid (a cube's grid: dimensions on a row axis and a column axis,
|
|
5
|
+
* measures in the cells), kept apart from drawing so all of it can be tested without a DOM: the
|
|
6
|
+
* header trees of both axes, which members are collapsed, which axis is which, and the menus a grid
|
|
7
|
+
* offers on a member and on the grid itself (as PopupMenu item specs).
|
|
8
|
+
*
|
|
9
|
+
* The grid never computes a total. Subtotals come in the rows, as a rolled-up query returns them
|
|
10
|
+
* (sqlengine's GROUP BY ROLLUP; semanticengine's `rollup`): a `_rollup` column says, as bits over
|
|
11
|
+
* `dimensions` (first highest), which dimensions a row totals. Adding up cells here would be wrong for
|
|
12
|
+
* any measure that does not add up (an average, a balance), so without subtotal rows a member simply
|
|
13
|
+
* has no total to show, and cannot be collapsed onto one.
|
|
14
|
+
*
|
|
15
|
+
* Expanding and collapsing a member is the grid's own; drilling to a level the rows do not have is the
|
|
16
|
+
* host's: the member menu's "Drill down" / "Drill up" call `onDrill({ axis, dimension, direction,
|
|
17
|
+
* member })`, and the host answers the new question and calls `update()`. Every change calls
|
|
18
|
+
* `onChange(view)`; the host redraws.
|
|
19
|
+
*
|
|
20
|
+
* The layout is the host's too (Alphablox's PresentBlox): with `onLayout`, each dimension's menu moves
|
|
21
|
+
* it between the rows, the columns and the page filters (a dimension held to one member, or all),
|
|
22
|
+
* adds one from `available`, or removes it; the view says the new layout, `{ rowAxis, columnAxis,
|
|
23
|
+
* pageAxis, members }`, and the host asks that question and calls `update()`. A page filter's members
|
|
24
|
+
* come from `members(dimension)`, the host's. A member's menu keeps only it or removes it
|
|
25
|
+
* (`members`: `{ dimension, keep }` or `{ dimension, remove }`) — filters the host asks again, never
|
|
26
|
+
* rows hidden here, so every total stays the total of what is showing.
|
|
27
|
+
*/
|
|
28
|
+
export class PivotView {
|
|
29
|
+
static ROLLUP_COLUMN = '_rollup';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {Array<object>} rows result rows: dimension values, measure values, and `_rollup`
|
|
33
|
+
* @param {object} [options]
|
|
34
|
+
* @param {string[]} [options.rowAxis] dimensions down the side, outermost first
|
|
35
|
+
* @param {string[]} [options.columnAxis] dimensions across the top, outermost first
|
|
36
|
+
* @param {string[]} [options.measures] the cells' values; by default every numeric column not on an axis
|
|
37
|
+
* @param {Array<{name, kind, dimensions?}>} [options.columns] column hints (semanticengine's `columns`):
|
|
38
|
+
* the `kind: 'rollup'` one names the rollup column and the dimensions its bits are over
|
|
39
|
+
* @param {string[]} [options.dimensions] the dimensions the rollup bits are over, first highest
|
|
40
|
+
* (from `columns` when given there; else the row axis then the column axis)
|
|
41
|
+
* @param {function} [options.onChange] called with the view after every change
|
|
42
|
+
* @param {function} [options.onDrill] ({ axis, dimension, direction: 'down'|'up', member }) — the host drills
|
|
43
|
+
* @param {{list, open, save, remove}} [options.views] saved views, the host's (where they are stored is
|
|
44
|
+
* its choice): list() → names; open(name), save(), remove(name) — the grid's Views menu calls them
|
|
45
|
+
* @param {function} [options.onDrillThrough] ({ cell, measure }) — the host shows the rows behind a cell;
|
|
46
|
+
* `cell` names its members, `{ dimension: value }` (a total names fewer)
|
|
47
|
+
* @param {object} [options.menus] the host's own items: { member, grid } → items
|
|
48
|
+
* @param {Array<{dimension, value}>} [options.pageAxis] page filters: a dimension held to `value`, or to all when null
|
|
49
|
+
* @param {Array<{dimension, keep?, remove?}>} [options.memberFilters] members kept only, or removed, per dimension
|
|
50
|
+
* @param {string[]} [options.available] dimensions the layout may add (those placed are left out)
|
|
51
|
+
* @param {function} [options.onLayout] ({ rowAxis, columnAxis, pageAxis }) — the host asks the new layout's question
|
|
52
|
+
* @param {function} [options.members] (dimension) → its members, for a page filter's menu
|
|
53
|
+
* @param {Array<{measure?, below?, atLeast?, fill, label?}>} [options.trafficLights] the host's rules:
|
|
54
|
+
* the first whose measure (any, when absent) and range match colours a detail cell
|
|
55
|
+
* @param {function} [options.copy] writes text to the clipboard (navigator.clipboard by default)
|
|
56
|
+
* @param {function|Intl.NumberFormat|object} [options.format] how numbers show, for every measure or per
|
|
57
|
+
* measure (`TableChart.formatFor`): cells, totals and the traffic lights' cut-offs. A copy keeps the raw numbers.
|
|
58
|
+
*/
|
|
59
|
+
constructor(rows, options = {}) {
|
|
60
|
+
this.onChange = options.onChange ?? null;
|
|
61
|
+
this.onDrill = options.onDrill ?? null;
|
|
62
|
+
this.onDrillThrough = options.onDrillThrough ?? null;
|
|
63
|
+
this.views = options.views ?? null;
|
|
64
|
+
this.onLayout = options.onLayout ?? null;
|
|
65
|
+
this.members = options.members ?? null;
|
|
66
|
+
this.menus = options.menus ?? {};
|
|
67
|
+
this.copy = options.copy ?? PivotView.clipboard;
|
|
68
|
+
this.format = options.format ?? null;
|
|
69
|
+
this._listeners = new Set();
|
|
70
|
+
this.lightRules = options.trafficLights ?? null;
|
|
71
|
+
/** Traffic lights showing: null (off), 'rules' (the host's), 'thirds-high' or 'thirds-low' (by thirds, higher or lower is better). */
|
|
72
|
+
this.lights = this.lightRules ? 'rules' : null;
|
|
73
|
+
this._load(rows, options);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
static clipboard(text) {
|
|
77
|
+
return globalThis.navigator?.clipboard?.writeText(text);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** New rows and axes (the host's answer to a drill), keeping what is collapsed where it still applies. */
|
|
81
|
+
update(rows, options = {}) {
|
|
82
|
+
this._load(rows, { ...this._options, ...options });
|
|
83
|
+
this._changed();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_load(rows, options) {
|
|
87
|
+
this._options = options;
|
|
88
|
+
this.rows = rows;
|
|
89
|
+
this.rowAxis = [...(options.rowAxis ?? [])];
|
|
90
|
+
this.columnAxis = [...(options.columnAxis ?? [])];
|
|
91
|
+
this.pageAxis = (options.pageAxis ?? []).map(p => ({ dimension: p.dimension, value: p.value ?? null }));
|
|
92
|
+
this.available = [...(options.available ?? [])];
|
|
93
|
+
this.memberFilters = (options.memberFilters ?? []).map(m => ({ ...m }));
|
|
94
|
+
const rollup = (options.columns ?? []).find(c => c.kind === 'rollup');
|
|
95
|
+
this.rollupColumn = rollup?.name ?? PivotView.ROLLUP_COLUMN;
|
|
96
|
+
this.dimensions = options.dimensions ?? rollup?.dimensions ?? [...this.rowAxis, ...this.columnAxis];
|
|
97
|
+
this.measures = options.measures ?? this._defaultMeasures(rows, options.columns);
|
|
98
|
+
this.collapsed = { rows: new Set(this.collapsed?.rows ?? []), columns: new Set(this.collapsed?.columns ?? []) };
|
|
99
|
+
this._index();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_defaultMeasures(rows, columns) {
|
|
103
|
+
const onAxis = new Set([...this.rowAxis, ...this.columnAxis, this.rollupColumn]);
|
|
104
|
+
if (columns) return columns.filter(c => c.kind === 'numeric' && !onAxis.has(c.name)).map(c => c.name);
|
|
105
|
+
const first = rows[0] ?? {};
|
|
106
|
+
return Object.keys(first).filter(k => !onAxis.has(k) && rows.every(r => r[k] == null || typeof r[k] === 'number'));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── The cube's cells and header trees ──────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/** Whether `row` totals `dimension` (its rollup bit is set). */
|
|
112
|
+
isTotal(row, dimension) {
|
|
113
|
+
const bits = row[this.rollupColumn];
|
|
114
|
+
const i = this.dimensions.indexOf(dimension);
|
|
115
|
+
if (typeof bits !== 'number' || i < 0) return false;
|
|
116
|
+
return Math.floor(bits / 2 ** (this.dimensions.length - 1 - i)) % 2 === 1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* How many of an axis's dimensions `row` is grouped by: the leading ones not totalled. A row that
|
|
121
|
+
* totals an outer dimension but not an inner one (as a CUBE has) has no place on the axis: null.
|
|
122
|
+
*/
|
|
123
|
+
_depth(row, axis) {
|
|
124
|
+
const totalled = axis.map(d => this.isTotal(row, d));
|
|
125
|
+
const depth = totalled.indexOf(true) < 0 ? axis.length : totalled.indexOf(true);
|
|
126
|
+
return totalled.slice(depth).every(Boolean) ? depth : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
_index() {
|
|
130
|
+
this.cells = new Map();
|
|
131
|
+
this.unplaced = 0;
|
|
132
|
+
this.rowRoot = PivotView._node([], null);
|
|
133
|
+
this.columnRoot = PivotView._node([], null);
|
|
134
|
+
for (const row of this.rows) {
|
|
135
|
+
const rd = this._depth(row, this.rowAxis);
|
|
136
|
+
const cd = this._depth(row, this.columnAxis);
|
|
137
|
+
if (rd === null || cd === null) { this.unplaced++; continue; }
|
|
138
|
+
const r = PivotView._add(this.rowRoot, this.rowAxis.slice(0, rd).map(d => row[d]));
|
|
139
|
+
const c = PivotView._add(this.columnRoot, this.columnAxis.slice(0, cd).map(d => row[d]));
|
|
140
|
+
this.cells.set(`${r.key}|${c.key}`, row);
|
|
141
|
+
r.hasCells = true;
|
|
142
|
+
c.hasCells = true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
static _node(path, parent) {
|
|
147
|
+
return { path, key: JSON.stringify(path), parent, children: new Map(), hasCells: false };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
static _add(root, values) {
|
|
151
|
+
let node = root;
|
|
152
|
+
for (const value of values) {
|
|
153
|
+
const k = JSON.stringify(value);
|
|
154
|
+
if (!node.children.has(k)) node.children.set(k, PivotView._node([...node.path, value], node));
|
|
155
|
+
node = node.children.get(k);
|
|
156
|
+
}
|
|
157
|
+
return node;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The row holding the cells where a row-axis member and a column-axis member meet, or null. */
|
|
161
|
+
cell(rowNode, columnNode) { return this.cells.get(`${rowNode.key}|${columnNode.key}`) ?? null; }
|
|
162
|
+
|
|
163
|
+
/** A member's label: its value as the table shows one; '(empty)' for a NULL in the data. */
|
|
164
|
+
static label(node) {
|
|
165
|
+
if (node.path.length === 0) return 'Total';
|
|
166
|
+
const value = node.path.at(-1);
|
|
167
|
+
return value == null ? '(empty)' : TableChart.cellText(value);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Whether a member can be collapsed onto its own total: it has members below, and a total to show. */
|
|
171
|
+
collapsible(node) { return node.children.size > 0 && node.hasCells; }
|
|
172
|
+
|
|
173
|
+
isCollapsed(axis, node) { return this.collapsed[axis].has(node.key); }
|
|
174
|
+
|
|
175
|
+
// ── What is showing ───────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The rows drawn, top to bottom: each member with its own totals, then (unless collapsed) the
|
|
179
|
+
* members below it, indented; the grand total last. `{ node, depth }`.
|
|
180
|
+
*/
|
|
181
|
+
visibleRows() {
|
|
182
|
+
const out = [];
|
|
183
|
+
const walk = (node) => {
|
|
184
|
+
for (const child of node.children.values()) {
|
|
185
|
+
out.push({ node: child, depth: child.path.length - 1 });
|
|
186
|
+
if (!this.isCollapsed('rows', child)) walk(child);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
walk(this.rowRoot);
|
|
190
|
+
if (this.rowRoot.hasCells || this.rowAxis.length === 0) out.push({ node: this.rowRoot, depth: 0 });
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The value columns drawn, left to right: each column-axis member's members (unless it is
|
|
196
|
+
* collapsed, or has none), then its own total; the grand total last. One per measure for each.
|
|
197
|
+
* `{ node, measure, total }` — `total` when the column is a member's total beside its members.
|
|
198
|
+
*/
|
|
199
|
+
visibleColumns() {
|
|
200
|
+
const members = [];
|
|
201
|
+
const walk = (node) => {
|
|
202
|
+
for (const child of node.children.values()) {
|
|
203
|
+
if (child.children.size > 0 && !this.isCollapsed('columns', child)) {
|
|
204
|
+
walk(child);
|
|
205
|
+
if (child.hasCells) members.push({ node: child, total: true });
|
|
206
|
+
} else {
|
|
207
|
+
members.push({ node: child, total: false });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
walk(this.columnRoot);
|
|
212
|
+
if (this.columnRoot.hasCells) members.push({ node: this.columnRoot, total: this.columnAxis.length > 0 });
|
|
213
|
+
return members.flatMap(m => this.measures.map(measure => ({ ...m, measure })));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The header above the value columns: a line per column-axis dimension (and one for the measures
|
|
218
|
+
* when there are several), each a list of `{ text, span, node }` — a member spans the columns
|
|
219
|
+
* beneath it; `node` is set where the text is a member, so it can take a click.
|
|
220
|
+
*/
|
|
221
|
+
columnHeaders() {
|
|
222
|
+
const columns = this.visibleColumns();
|
|
223
|
+
const levels = this.columnAxis.length;
|
|
224
|
+
const lines = [];
|
|
225
|
+
for (let level = 0; level < levels; level++) {
|
|
226
|
+
const cells = columns.map((c) => {
|
|
227
|
+
const depth = c.node.path.length; // a member at depth d is labelled on line d - 1
|
|
228
|
+
if (level < depth) {
|
|
229
|
+
const owner = PivotView._ancestor(c.node, level + 1);
|
|
230
|
+
return { id: owner.key, text: PivotView.label(owner), node: owner };
|
|
231
|
+
}
|
|
232
|
+
if (level === depth && c.total) return { id: `total|${c.node.key}`, text: 'Total', node: null };
|
|
233
|
+
return { id: null, text: '', node: null };
|
|
234
|
+
});
|
|
235
|
+
lines.push(PivotView._spans(cells));
|
|
236
|
+
}
|
|
237
|
+
if (this.measures.length > 1 || levels === 0) {
|
|
238
|
+
lines.push(columns.map(c => ({ text: c.measure, span: 1, node: null })));
|
|
239
|
+
}
|
|
240
|
+
return lines;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The member on `node`'s path at `depth` (1 = outermost). */
|
|
244
|
+
static _ancestor(node, depth) {
|
|
245
|
+
let n = node;
|
|
246
|
+
while (n.path.length > depth) n = n.parent;
|
|
247
|
+
return n;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Runs of the same member merged into one spanning cell. */
|
|
251
|
+
static _spans(cells) {
|
|
252
|
+
const out = [];
|
|
253
|
+
for (const cell of cells) {
|
|
254
|
+
const last = out.at(-1);
|
|
255
|
+
if (last && cell.id !== null && last.id === cell.id) last.span++;
|
|
256
|
+
else out.push({ ...cell, span: 1 });
|
|
257
|
+
}
|
|
258
|
+
return out.map(({ id, ...cell }) => cell);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The value at a row and column, from the row the data gave for that pair; null when it gave none. */
|
|
262
|
+
value(rowNode, column) {
|
|
263
|
+
const row = this.cell(rowNode, column.node);
|
|
264
|
+
return row === null ? null : row[column.measure] ?? null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Changes ───────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
toggle(axis, node) {
|
|
270
|
+
if (!this.collapsible(node)) return;
|
|
271
|
+
const set = this.collapsed[axis];
|
|
272
|
+
if (set.has(node.key)) set.delete(node.key); else set.add(node.key);
|
|
273
|
+
this._changed();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Collapse (or expand) every member of an axis that can be. */
|
|
277
|
+
collapseAll(axis, collapse = true) {
|
|
278
|
+
this.collapsed[axis].clear();
|
|
279
|
+
if (collapse) {
|
|
280
|
+
const walk = (node) => { for (const c of node.children.values()) { if (this.collapsible(c)) this.collapsed[axis].add(c.key); walk(c); } };
|
|
281
|
+
walk(axis === 'rows' ? this.rowRoot : this.columnRoot);
|
|
282
|
+
}
|
|
283
|
+
this._changed();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Rows become columns and columns rows. */
|
|
287
|
+
swapAxes() {
|
|
288
|
+
[this.rowAxis, this.columnAxis] = [this.columnAxis, this.rowAxis];
|
|
289
|
+
this.collapsed = { rows: this.collapsed.columns, columns: this.collapsed.rows };
|
|
290
|
+
this._index();
|
|
291
|
+
this._changed();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
_changed() {
|
|
295
|
+
this.onChange?.(this);
|
|
296
|
+
for (const listener of this._listeners) listener(this);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Another listener for every change, beside onChange — a linked chart. Returns what removes it. */
|
|
300
|
+
listen(listener) {
|
|
301
|
+
this._listeners.add(listener);
|
|
302
|
+
return () => this._listeners.delete(listener);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── Layout (PresentBlox) ───────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
/** The layout as showing: what the host asks a question for. */
|
|
308
|
+
layout() {
|
|
309
|
+
return {
|
|
310
|
+
rowAxis: [...this.rowAxis], columnAxis: [...this.columnAxis], pageAxis: this.pageAxis.map(p => ({ ...p })),
|
|
311
|
+
members: this.memberFilters.map(m => ({ ...m })),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Where a dimension is: 'rows', 'columns', 'page', 'available', or null. */
|
|
316
|
+
zoneOf(dimension) {
|
|
317
|
+
if (this.rowAxis.includes(dimension)) return 'rows';
|
|
318
|
+
if (this.columnAxis.includes(dimension)) return 'columns';
|
|
319
|
+
if (this.pageAxis.some(p => p.dimension === dimension)) return 'page';
|
|
320
|
+
return this.available.includes(dimension) ? 'available' : null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The dimensions that may be added: available, and not placed. */
|
|
324
|
+
addable() { return this.available.filter(d => this.zoneOf(d) === 'available'); }
|
|
325
|
+
|
|
326
|
+
/** The layout with `dimension` moved to `zone` ('rows', 'columns', 'page', or null to remove it). */
|
|
327
|
+
moved(dimension, zone) {
|
|
328
|
+
const layout = this.layout();
|
|
329
|
+
layout.rowAxis = layout.rowAxis.filter(d => d !== dimension);
|
|
330
|
+
layout.columnAxis = layout.columnAxis.filter(d => d !== dimension);
|
|
331
|
+
layout.pageAxis = layout.pageAxis.filter(p => p.dimension !== dimension);
|
|
332
|
+
// Removed, or held to one member on the page: a keep or remove of its members no longer applies.
|
|
333
|
+
if (zone === null || zone === 'page') layout.members = layout.members.filter(m => m.dimension !== dimension);
|
|
334
|
+
if (zone === 'rows') layout.rowAxis.push(dimension);
|
|
335
|
+
if (zone === 'columns') layout.columnAxis.push(dimension);
|
|
336
|
+
if (zone === 'page') layout.pageAxis.push({ dimension, value: null });
|
|
337
|
+
return layout;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The layout with `dimension` one place outward (-1) or inward (+1) on its axis. */
|
|
341
|
+
shifted(dimension, by) {
|
|
342
|
+
const layout = this.layout();
|
|
343
|
+
const axis = layout.rowAxis.includes(dimension) ? layout.rowAxis : layout.columnAxis;
|
|
344
|
+
const at = axis.indexOf(dimension);
|
|
345
|
+
[axis[at], axis[at + by]] = [axis[at + by], axis[at]];
|
|
346
|
+
return layout;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** The layout with page filter `dimension` held to `value` (null: all members). */
|
|
350
|
+
paged(dimension, value) {
|
|
351
|
+
const layout = this.layout();
|
|
352
|
+
layout.pageAxis = layout.pageAxis.map(p => (p.dimension === dimension ? { dimension, value } : p));
|
|
353
|
+
return layout;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** The keep or remove of a dimension's members, or null. */
|
|
357
|
+
memberFilterOf(dimension) { return this.memberFilters.find(m => m.dimension === dimension) ?? null; }
|
|
358
|
+
|
|
359
|
+
/** How a chip says a dimension's keep or remove: " (only West)", " (without 2)", or nothing. */
|
|
360
|
+
memberFilterText(dimension) {
|
|
361
|
+
const m = this.memberFilterOf(dimension);
|
|
362
|
+
if (!m) return '';
|
|
363
|
+
const values = m.keep ?? m.remove;
|
|
364
|
+
const what = values.length === 1 ? TableChart.cellText(values[0]) : String(values.length);
|
|
365
|
+
return m.keep ? ` (only ${what})` : ` (without ${what})`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The layout keeping only `value` of `dimension`. */
|
|
369
|
+
keptOnly(dimension, value) {
|
|
370
|
+
const layout = this.layout();
|
|
371
|
+
layout.members = [...layout.members.filter(m => m.dimension !== dimension), { dimension, keep: [value] }];
|
|
372
|
+
return layout;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The layout without `value` of `dimension`: added to its removed members, or taken from its kept
|
|
377
|
+
* ones. Null when that would keep nothing — then the menu does not offer it.
|
|
378
|
+
*/
|
|
379
|
+
removedMember(dimension, value) {
|
|
380
|
+
const layout = this.layout();
|
|
381
|
+
const current = layout.members.find(m => m.dimension === dimension);
|
|
382
|
+
const others = layout.members.filter(m => m.dimension !== dimension);
|
|
383
|
+
if (current?.keep) {
|
|
384
|
+
const keep = current.keep.filter(v => v !== value);
|
|
385
|
+
if (keep.length === 0) return null;
|
|
386
|
+
layout.members = [...others, { dimension, keep }];
|
|
387
|
+
} else {
|
|
388
|
+
layout.members = [...others, { dimension, remove: [...(current?.remove ?? []), value] }];
|
|
389
|
+
}
|
|
390
|
+
return layout;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** The layout showing every member of `dimension` again. */
|
|
394
|
+
allMembers(dimension) {
|
|
395
|
+
const layout = this.layout();
|
|
396
|
+
layout.members = layout.members.filter(m => m.dimension !== dimension);
|
|
397
|
+
return layout;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** A member's keep-or-remove items, the host's through onLayout; none for a NULL member (no filter holds one). */
|
|
401
|
+
_memberFilterItems(dimension, node) {
|
|
402
|
+
if (!this.onLayout || !dimension) return [];
|
|
403
|
+
const value = node.path.at(-1);
|
|
404
|
+
const ask = (layout) => () => this.onLayout(layout);
|
|
405
|
+
const items = [];
|
|
406
|
+
if (value != null) {
|
|
407
|
+
const label = PivotView.label(node);
|
|
408
|
+
items.push({ label: `Keep only ${label}`, action: ask(this.keptOnly(dimension, value)) });
|
|
409
|
+
const removed = this.removedMember(dimension, value);
|
|
410
|
+
if (removed) items.push({ label: `Remove ${label}`, action: ask(removed) });
|
|
411
|
+
}
|
|
412
|
+
if (this.memberFilterOf(dimension)) items.push({ label: `Show all ${dimension}`, action: ask(this.allMembers(dimension)) });
|
|
413
|
+
return items.length > 0 ? [{ separator: true }, ...items] : [];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** The most members a page filter's menu lists. */
|
|
417
|
+
static MAX_PAGE_MEMBERS = 30;
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* A dimension's layout menu: for a page filter, its members to hold it to (and All); then where
|
|
421
|
+
* to move it — rows, columns, page — outward or inward on its axis, or remove it. Each item gives
|
|
422
|
+
* the host the new layout (`onLayout`). Empty without onLayout.
|
|
423
|
+
*/
|
|
424
|
+
layoutMenu(dimension) {
|
|
425
|
+
if (!this.onLayout) return [];
|
|
426
|
+
const zone = this.zoneOf(dimension);
|
|
427
|
+
const ask = (layout) => () => this.onLayout(layout);
|
|
428
|
+
const items = [];
|
|
429
|
+
if (zone === 'page') {
|
|
430
|
+
const current = this.pageAxis.find(p => p.dimension === dimension).value;
|
|
431
|
+
const members = this.members?.(dimension) ?? [];
|
|
432
|
+
const listed = members.slice(0, PivotView.MAX_PAGE_MEMBERS);
|
|
433
|
+
items.push({ label: `All${current === null ? ' ✓' : ''}`, action: ask(this.paged(dimension, null)) });
|
|
434
|
+
for (const m of listed) items.push({ label: `${TableChart.cellText(m)}${m === current ? ' ✓' : ''}`, action: ask(this.paged(dimension, m)) });
|
|
435
|
+
if (members.length > listed.length) items.push({ label: `(${members.length - listed.length} more not listed)`, disabled: true });
|
|
436
|
+
items.push({ separator: true });
|
|
437
|
+
}
|
|
438
|
+
if (zone !== 'rows') items.push({ label: 'Move to rows', action: ask(this.moved(dimension, 'rows')) });
|
|
439
|
+
if (zone !== 'columns') items.push({ label: 'Move to columns', action: ask(this.moved(dimension, 'columns')) });
|
|
440
|
+
if (zone !== 'page') items.push({ label: 'Move to filters', action: ask(this.moved(dimension, 'page')) });
|
|
441
|
+
if (zone === 'rows' || zone === 'columns') {
|
|
442
|
+
const axis = this.axisOf(zone);
|
|
443
|
+
if (axis.indexOf(dimension) > 0) items.push({ label: 'Move outward', action: ask(this.shifted(dimension, -1)) });
|
|
444
|
+
if (axis.indexOf(dimension) < axis.length - 1) items.push({ label: 'Move inward', action: ask(this.shifted(dimension, 1)) });
|
|
445
|
+
}
|
|
446
|
+
if (this.memberFilterOf(dimension)) items.push({ separator: true }, { label: `Show all ${dimension}`, action: ask(this.allMembers(dimension)) });
|
|
447
|
+
if (zone !== 'available') items.push({ separator: true }, { label: 'Remove', action: ask(this.moved(dimension, null)) });
|
|
448
|
+
return items;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── As a chart (ChartBlox) ─────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* What the grid shows, shaped for a chart: a row per row-axis member that is showing and not
|
|
455
|
+
* broken down further on screen (collapsed, or at the finest level showing), and a numeric
|
|
456
|
+
* column per such column-axis member (per measure, when there are several). A member and its
|
|
457
|
+
* parts never both appear, and totals are left out, so no value is drawn twice. Labels are the
|
|
458
|
+
* members' own, or their paths when two would read the same.
|
|
459
|
+
* @returns {{ rows, columns, complete, members: Map<string, object> }} rows and column hints for
|
|
460
|
+
* ChartChooser; `members` maps a row's label back to its member, for a click on the chart
|
|
461
|
+
*/
|
|
462
|
+
chartData() {
|
|
463
|
+
const showing = (axis, node) => node.children.size === 0 || this.isCollapsed(axis, node);
|
|
464
|
+
const rowNodes = this.rowAxis.length === 0
|
|
465
|
+
? [this.rowRoot]
|
|
466
|
+
: this.visibleRows().map(r => r.node).filter(n => n.path.length > 0 && showing('rows', n));
|
|
467
|
+
const columns = this.visibleColumns().filter(c => !c.total && (this.columnAxis.length === 0 || c.node.path.length > 0));
|
|
468
|
+
const rowLabels = PivotView._labels(rowNodes);
|
|
469
|
+
const columnLabels = PivotView._labels(columns.map(c => c.node));
|
|
470
|
+
const seriesName = (c, i) => (this.columnAxis.length === 0 ? c.measure
|
|
471
|
+
: this.measures.length > 1 ? `${columnLabels[i]} · ${c.measure}` : columnLabels[i]);
|
|
472
|
+
const category = this.rowAxis.length === 0 ? 'Total' : this.rowAxis.join(' › ');
|
|
473
|
+
const names = columns.map(seriesName);
|
|
474
|
+
const rows = rowNodes.map((node, r) => Object.fromEntries([
|
|
475
|
+
[category, rowLabels[r]],
|
|
476
|
+
...columns.map((c, i) => [names[i], this.value(node, c)]),
|
|
477
|
+
]));
|
|
478
|
+
const hint = (name) => (this._options.columns ?? []).find(c => c.name === name) ?? {};
|
|
479
|
+
const depths = new Set(rowNodes.map(n => n.path.length));
|
|
480
|
+
const innermost = depths.size === 1 ? this.rowAxis[[...depths][0] - 1] : null;
|
|
481
|
+
const measureOf = (c) => hint(c.measure).measure ?? null;
|
|
482
|
+
return {
|
|
483
|
+
rows,
|
|
484
|
+
columns: [
|
|
485
|
+
{ name: category, kind: hint(innermost).kind === 'time' ? 'time' : 'categorical' },
|
|
486
|
+
...columns.map((c, i) => ({ name: names[i], kind: 'numeric', measure: measureOf(c) })),
|
|
487
|
+
],
|
|
488
|
+
// Members not broken down further cover the whole answer between them, unless rows went unplaced.
|
|
489
|
+
complete: this.unplaced === 0,
|
|
490
|
+
members: new Map(rowNodes.map((node, r) => [rowLabels[r], node])),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Each member's label; its path (` › `) only where two members' labels would read the same. */
|
|
495
|
+
static _labels(nodes) {
|
|
496
|
+
const short = nodes.map(n => PivotView.label(n));
|
|
497
|
+
const count = new Map();
|
|
498
|
+
for (const s of short) count.set(s, (count.get(s) ?? 0) + 1);
|
|
499
|
+
return nodes.map((n, i) => (count.get(short[i]) === 1 ? short[i]
|
|
500
|
+
: n.path.map((_, d) => PivotView.label({ path: n.path.slice(0, d + 1) })).join(' › ')));
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* A click on a charted member (ChartBlox): a collapsed member expands; a member at the row axis's
|
|
505
|
+
* finest level drills down (the host's, through onDrill). Says which it did, or null.
|
|
506
|
+
*/
|
|
507
|
+
drillFrom(label) {
|
|
508
|
+
const node = this.chartData().members.get(label);
|
|
509
|
+
if (!node || node.path.length === 0) return null;
|
|
510
|
+
if (this.isCollapsed('rows', node)) {
|
|
511
|
+
this.toggle('rows', node);
|
|
512
|
+
return 'expanded';
|
|
513
|
+
}
|
|
514
|
+
if (node.path.length === this.rowAxis.length && this.onDrill) {
|
|
515
|
+
this.onDrill({ axis: 'rows', dimension: this.rowAxis.at(-1), direction: 'down', member: node.path });
|
|
516
|
+
return 'drilled';
|
|
517
|
+
}
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ── Menus ─────────────────────────────────────────────────────────────────
|
|
522
|
+
|
|
523
|
+
axisOf(axis) { return axis === 'rows' ? this.rowAxis : this.columnAxis; }
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* A member's menu: expand or collapse it; drill down from the axis's innermost level, or up to a
|
|
527
|
+
* member's level from a finer one (the host's, through `onDrill`); expand or collapse the axis.
|
|
528
|
+
*/
|
|
529
|
+
memberMenu(axis, node) {
|
|
530
|
+
const dims = this.axisOf(axis);
|
|
531
|
+
const depth = node.path.length;
|
|
532
|
+
const dimension = dims[depth - 1];
|
|
533
|
+
const items = [];
|
|
534
|
+
if (this.collapsible(node)) {
|
|
535
|
+
items.push(this.isCollapsed(axis, node)
|
|
536
|
+
? { label: `Expand ${PivotView.label(node)}`, action: () => this.toggle(axis, node) }
|
|
537
|
+
: { label: `Collapse ${PivotView.label(node)}`, action: () => this.toggle(axis, node) });
|
|
538
|
+
}
|
|
539
|
+
if (this.onDrill && depth > 0) {
|
|
540
|
+
if (depth === dims.length) {
|
|
541
|
+
items.push({ label: `Drill down from ${dimension}`, action: () => this.onDrill({ axis, dimension, direction: 'down', member: node.path }) });
|
|
542
|
+
}
|
|
543
|
+
if (depth < dims.length) {
|
|
544
|
+
items.push({ label: `Drill up to ${dimension}`, action: () => this.onDrill({ axis, dimension, direction: 'up', member: node.path }) });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
items.push(...this._memberFilterItems(dimension, node));
|
|
548
|
+
items.push({ separator: true },
|
|
549
|
+
{ label: `Expand all ${axis}`, action: () => this.collapseAll(axis, false) },
|
|
550
|
+
{ label: `Collapse all ${axis}`, action: () => this.collapseAll(axis, true) });
|
|
551
|
+
return [...items, ...this._extra('member', { axis, node, dimension })];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ── Traffic lights ─────────────────────────────────────────────────────────
|
|
555
|
+
|
|
556
|
+
static LIGHTS = { low: '#f6d3cf', mid: '#fbe7b0', high: '#d3ebd4' };
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Whether a cell is a detail cell — a member at the finest level of both axes — the only cells
|
|
560
|
+
* traffic lights colour: a subtotal is on another scale from the cells it sums, so one set of
|
|
561
|
+
* thresholds would colour it wrongly.
|
|
562
|
+
*/
|
|
563
|
+
isDetail(rowNode, column) {
|
|
564
|
+
return rowNode.path.length === this.rowAxis.length && column.node.path.length === this.columnAxis.length;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Shows traffic lights: null (off), 'rules', 'thirds-high' or 'thirds-low'. */
|
|
568
|
+
setLights(mode) {
|
|
569
|
+
this.lights = mode === 'rules' && !this.lightRules ? null : mode;
|
|
570
|
+
this._changed();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* By thirds: per measure, the values below which a third, and two thirds, of the detail cells
|
|
575
|
+
* showing fall. A cell under the first is the low third, under the second the middle, else the high.
|
|
576
|
+
*/
|
|
577
|
+
thirds() {
|
|
578
|
+
const values = new Map();
|
|
579
|
+
const rows = this.visibleRows().map(r => r.node);
|
|
580
|
+
for (const c of this.visibleColumns()) {
|
|
581
|
+
for (const node of rows) {
|
|
582
|
+
const v = this.value(node, c);
|
|
583
|
+
if (typeof v !== 'number' || !this.isDetail(node, c)) continue;
|
|
584
|
+
if (!values.has(c.measure)) values.set(c.measure, []);
|
|
585
|
+
values.get(c.measure).push(v);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const cuts = new Map();
|
|
589
|
+
for (const [measure, list] of values) {
|
|
590
|
+
const sorted = [...list].sort((a, b) => a - b);
|
|
591
|
+
const at = (q) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * q))];
|
|
592
|
+
cuts.set(measure, [at(1 / 3), at(2 / 3)]);
|
|
593
|
+
}
|
|
594
|
+
return cuts;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** The fill traffic lights give a cell, or null: off, not a detail cell, not a number, or no rule matching. */
|
|
598
|
+
cellFill(rowNode, column, cuts = null) {
|
|
599
|
+
if (!this.lights || !this.isDetail(rowNode, column)) return null;
|
|
600
|
+
const v = this.value(rowNode, column);
|
|
601
|
+
if (typeof v !== 'number') return null;
|
|
602
|
+
if (this.lights === 'rules') {
|
|
603
|
+
const rule = this.lightRules.find(r => (r.measure === undefined || r.measure === column.measure)
|
|
604
|
+
&& (r.below === undefined || v < r.below) && (r.atLeast === undefined || v >= r.atLeast));
|
|
605
|
+
return rule?.fill ?? null;
|
|
606
|
+
}
|
|
607
|
+
const [t1, t2] = (cuts ?? this.thirds()).get(column.measure) ?? [];
|
|
608
|
+
if (t1 === undefined) return null;
|
|
609
|
+
const band = v < t1 ? 'low' : v < t2 ? 'mid' : 'high';
|
|
610
|
+
const good = this.lights === 'thirds-high' ? band : band === 'low' ? 'high' : band === 'high' ? 'low' : band;
|
|
611
|
+
return PivotView.LIGHTS[good];
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** A measure's value as the grid shows it, in the measure's `format` when there is one. */
|
|
615
|
+
valueText(measure, value, nullText = '—') {
|
|
616
|
+
return TableChart.cellText(value, nullText, TableChart.formatFor(this.format, measure));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** The traffic lights in words, for a line under the grid; null when off. */
|
|
620
|
+
lightsLegend() {
|
|
621
|
+
if (!this.lights) return null;
|
|
622
|
+
const tail = ' (detail cells; totals not coloured)';
|
|
623
|
+
if (this.lights === 'rules') {
|
|
624
|
+
const said = this.lightRules.map(r => r.label ?? [
|
|
625
|
+
r.measure, r.atLeast !== undefined ? '≥ ' + this.valueText(r.measure, r.atLeast) : null,
|
|
626
|
+
r.below !== undefined ? '< ' + this.valueText(r.measure, r.below) : null,
|
|
627
|
+
].filter(Boolean).join(' '));
|
|
628
|
+
return 'Traffic lights: ' + said.join('; ') + tail;
|
|
629
|
+
}
|
|
630
|
+
const better = this.lights === 'thirds-high' ? 'higher' : 'lower';
|
|
631
|
+
const cuts = [...this.thirds()].map(([m, [a, b]]) => m + ' ' + this.valueText(m, a) + ' / ' + this.valueText(m, b)).join('; ');
|
|
632
|
+
return 'Traffic lights by thirds, ' + better + ' is better — cut at ' + (cuts || 'no values') + tail;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ── Saved views ────────────────────────────────────────────────────────────
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* How the grid shows its rows, as plain data a saved view keeps beside its layout: the members
|
|
639
|
+
* collapsed on each axis, and the traffic lights. The layout itself is `layout()`.
|
|
640
|
+
*/
|
|
641
|
+
displayState() {
|
|
642
|
+
return { collapsed: { rows: [...this.collapsed.rows], columns: [...this.collapsed.columns] }, lights: this.lights };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Shows the rows as a saved view's display state says. A member the rows no longer have is simply
|
|
647
|
+
* not collapsed; host rules' traffic lights only when the host gives rules.
|
|
648
|
+
*/
|
|
649
|
+
restoreDisplay(state = {}) {
|
|
650
|
+
this.collapsed = { rows: new Set(state.collapsed?.rows ?? []), columns: new Set(state.collapsed?.columns ?? []) };
|
|
651
|
+
const lights = state.lights ?? null;
|
|
652
|
+
this.lights = ['thirds-high', 'thirds-low'].includes(lights) || (lights === 'rules' && this.lightRules) ? lights : null;
|
|
653
|
+
this._changed();
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** The Views submenu: open a saved view, save this one, delete one — each the host's. */
|
|
657
|
+
_viewItems() {
|
|
658
|
+
if (!this.views) return [];
|
|
659
|
+
const names = this.views.list?.() ?? [];
|
|
660
|
+
const none = [{ label: '(none saved)', disabled: true }];
|
|
661
|
+
return [{
|
|
662
|
+
label: 'Views',
|
|
663
|
+
items: [
|
|
664
|
+
...(names.length ? names.map(name => ({ label: name, action: () => this.views.open(name) })) : none),
|
|
665
|
+
{ separator: true },
|
|
666
|
+
{ label: 'Save this view…', action: () => this.views.save() },
|
|
667
|
+
{ label: 'Delete', items: names.length ? names.map(name => ({ label: name, danger: true, action: () => this.views.remove(name) })) : none },
|
|
668
|
+
],
|
|
669
|
+
}];
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ── Drill-through ──────────────────────────────────────────────────────────
|
|
673
|
+
|
|
674
|
+
/** A cell's members, `{ dimension: value }`: its row member's path and its column member's (a total names fewer). */
|
|
675
|
+
cellMembers(rowNode, column) {
|
|
676
|
+
return Object.fromEntries([
|
|
677
|
+
...rowNode.path.map((value, i) => [this.rowAxis[i], value]),
|
|
678
|
+
...column.node.path.map((value, i) => [this.columnAxis[i], value]),
|
|
679
|
+
]);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Whether a cell's menu has anything: drill-through, or the host's own cell items. */
|
|
683
|
+
cellHasMenu() { return typeof this.onDrillThrough === 'function' || typeof this.menus?.cell === 'function'; }
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* A value cell's menu: the rows behind it (the host's, through onDrillThrough) — not offered for a
|
|
687
|
+
* cell of an empty member, which no filter can hold to — then the host's own cell items.
|
|
688
|
+
*/
|
|
689
|
+
cellMenu(rowNode, column) {
|
|
690
|
+
const cell = this.cellMembers(rowNode, column);
|
|
691
|
+
const items = [];
|
|
692
|
+
if (this.onDrillThrough) {
|
|
693
|
+
const empty = Object.entries(cell).find(([, value]) => value == null);
|
|
694
|
+
items.push(empty
|
|
695
|
+
? { label: `Show the rows behind this (not for an empty ${empty[0]})`, disabled: true }
|
|
696
|
+
: { label: 'Show the rows behind this', action: () => this.onDrillThrough({ cell, measure: column.measure }) });
|
|
697
|
+
}
|
|
698
|
+
const extra = this._extra('cell', { cell, measure: column.measure, row: this.cell(rowNode, column.node) });
|
|
699
|
+
return items.length === 0 ? extra.slice(1) : [...items, ...extra];
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** The grid's menu: swap the axes, expand or collapse everything, traffic lights, copy the grid. */
|
|
703
|
+
gridMenu() {
|
|
704
|
+
const light = (label, mode) => ({ label: label + (this.lights === mode ? ' ✓' : ''), action: () => this.setLights(mode) });
|
|
705
|
+
return [
|
|
706
|
+
{ label: 'Swap rows and columns', action: () => this.swapAxes() },
|
|
707
|
+
{ label: 'Expand all', action: () => { this.collapsed.rows.clear(); this.collapsed.columns.clear(); this._changed(); } },
|
|
708
|
+
{
|
|
709
|
+
label: 'Traffic lights',
|
|
710
|
+
items: [
|
|
711
|
+
light('Off', null),
|
|
712
|
+
light('By thirds — higher is better', 'thirds-high'),
|
|
713
|
+
light('By thirds — lower is better', 'thirds-low'),
|
|
714
|
+
...(this.lightRules ? [light('The rules given', 'rules')] : []),
|
|
715
|
+
],
|
|
716
|
+
},
|
|
717
|
+
...this._viewItems(),
|
|
718
|
+
{ separator: true },
|
|
719
|
+
{ label: 'Copy grid', action: () => this.copy(this.asTsv()) },
|
|
720
|
+
...this._extra('grid', {}),
|
|
721
|
+
];
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
_extra(kind, context) {
|
|
725
|
+
const items = this.menus?.[kind]?.({ view: this, ...context }) ?? [];
|
|
726
|
+
return items.length > 0 ? [{ separator: true }, ...items] : [];
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** The grid as showing, tab-separated: the column headers' lines, then a line per row. */
|
|
730
|
+
asTsv() {
|
|
731
|
+
const indent = this.rowAxis.length > 0 ? 1 : 0;
|
|
732
|
+
const head = this.columnHeaders().map(line => [...Array(indent).fill(''), ...line.flatMap(c => [c.text, ...Array(c.span - 1).fill('')])]);
|
|
733
|
+
const columns = this.visibleColumns();
|
|
734
|
+
const body = this.visibleRows().map(({ node, depth }) => [
|
|
735
|
+
...(indent ? [`${' '.repeat(depth)}${PivotView.label(node)}`] : []),
|
|
736
|
+
...columns.map(c => { const v = this.value(node, c); return v == null ? '' : String(v); }),
|
|
737
|
+
]);
|
|
738
|
+
return [...head, ...body].map(r => r.join('\t')).join('\n');
|
|
739
|
+
}
|
|
740
|
+
}
|