@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.
Files changed (37) hide show
  1. package/README.md +10 -4
  2. package/dist/renderengine-workspace.umd.js +5779 -3731
  3. package/dist/renderengine.umd.js +5757 -3725
  4. package/documentation/REFERENCE.md +3291 -3077
  5. package/package.json +2 -2
  6. package/src/charting/Axis.js +47 -5
  7. package/src/charting/Chart.js +25 -0
  8. package/src/charting/ChartChooser.js +5 -4
  9. package/src/charting/DiagramEditor.js +239 -34
  10. package/src/charting/Legend.js +16 -0
  11. package/src/charting/ModelFileFormat.js +47 -0
  12. package/src/charting/archimate/ArchimateCanvasEditor.js +1108 -1012
  13. package/src/charting/archimate/deleteFromModel.js +57 -0
  14. package/src/charting/archimate/elementMenuItems.js +3 -1
  15. package/src/charting/archimate/heatMaps.js +169 -0
  16. package/src/charting/archimate/index.js +1 -0
  17. package/src/charting/charts/BarChart.js +5 -5
  18. package/src/charting/charts/BarChartBase.js +21 -3
  19. package/src/charting/charts/GroupedBarChart.js +42 -22
  20. package/src/charting/charts/LineChart.js +9 -8
  21. package/src/charting/charts/PieChart.js +2 -2
  22. package/src/charting/charts/TableChart.js +34 -5
  23. package/src/charting/diagrams/ArchimateModel.js +33 -0
  24. package/src/charting/diagrams/DeleteFromModelCommand.js +81 -0
  25. package/src/charting/diagrams/EdgeGeometry.js +16 -0
  26. package/src/charting/edgeStyle/EdgeStyleSchema.js +6 -2
  27. package/src/charting/pivot/PivotChart.js +50 -0
  28. package/src/charting/pivot/PivotGridChart.js +191 -0
  29. package/src/charting/pivot/PivotView.js +740 -0
  30. package/src/charting/table/TableView.js +84 -15
  31. package/src/charting/table/TableViewChart.js +5 -3
  32. package/src/charting/templates/Template.js +10 -0
  33. package/src/exportImage.js +45 -18
  34. package/src/index.js +2899 -2807
  35. package/src/workspace/parts/ArchimateCanvasPart.js +3 -0
  36. package/src/workspace/parts/DiagramEditorPart.js +2 -0
  37. 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
+ }