@peter.prib/renderengine 0.9.0 → 0.12.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.
@@ -51,8 +51,9 @@ export class TableView {
51
51
  * @param {object} [options.menus] the host's own items: { cell, row, column, table } → items
52
52
  * @param {function} [options.copy] writes text to the clipboard (navigator.clipboard by default)
53
53
  * @param {ChartChooser} [options.chooser]
54
+ * @param {function} [options.highlight] row → true for a row drawn highlighted (a host's selection)
54
55
  */
55
- constructor(rows, { columns = null, complete = true, pageSize = 50, onChange = null, menus = {}, copy = null, chooser = null } = {}) {
56
+ constructor(rows, { columns = null, complete = true, pageSize = 50, onChange = null, menus = {}, copy = null, chooser = null, highlight = null } = {}) {
56
57
  this.rows = rows;
57
58
  this.columns = ChartChooser.describe(rows, columns);
58
59
  this.complete = complete;
@@ -61,7 +62,9 @@ export class TableView {
61
62
  this.menus = menus;
62
63
  this.copy = copy ?? TableView.clipboard;
63
64
  this.chooser = chooser ?? new ChartChooser();
64
- this.sort = null; // { column, direction: 'asc' | 'desc' }
65
+ this.highlight = highlight;
66
+ /** The sort keys, most significant first: `{ column, direction: 'asc' | 'desc' }`. */
67
+ this.sorts = [];
65
68
  this.filters = []; // { column, op, value }
66
69
  this.hidden = new Set();
67
70
  this.page = 0;
@@ -75,19 +78,44 @@ export class TableView {
75
78
 
76
79
  visibleColumns() { return this.columns.filter(c => !this.hidden.has(c.name)); }
77
80
 
81
+ /** Whether a row is drawn highlighted: the host's `highlight` says (its selection, say). */
82
+ isHighlighted(row) { return this.highlight?.(row) === true; }
83
+
78
84
  filtered() {
79
85
  return this.filters.length === 0 ? this.rows
80
86
  : this.rows.filter(r => this.filters.every(f => FILTER_OPS[f.op](r[f.column], f.value)));
81
87
  }
82
88
 
83
- /** The filtered rows in sort order (a copy; equal values keep their order). */
89
+ /** The first sort key, or null — the one a single-column sort has. */
90
+ get sort() { return this.sorts[0] ?? null; }
91
+
92
+ /** Where `column` is among the sort keys: `{ direction, rank }` (rank from 1), or null. */
93
+ sortOf(column) {
94
+ const at = this.sorts.findIndex(s => s.column === column);
95
+ return at < 0 ? null : { direction: this.sorts[at].direction, rank: at + 1 };
96
+ }
97
+
98
+ /**
99
+ * The filtered rows in sort order (a copy): by the first key, then the next among equals, and so
100
+ * on; rows equal on every key keep their order.
101
+ */
84
102
  ordered() {
85
103
  const rows = this.filtered();
86
- if (this.sort === null) return rows;
87
- const { column, direction } = this.sort;
88
- const sign = direction === 'desc' ? -1 : 1;
89
- // Nulls stay last whichever way: a reader looks for values, not for blanks.
90
- return [...rows].sort((a, b) => (a[column] == null || b[column] == null ? compareValues(a[column], b[column]) : sign * compareValues(a[column], b[column])));
104
+ if (this.sorts.length === 0) return rows;
105
+ const keys = this.sorts.map(({ column, direction }) => ({ column, sign: direction === 'desc' ? -1 : 1 }));
106
+ return [...rows].sort((a, b) => {
107
+ for (const { column, sign } of keys) {
108
+ // Nulls stay last whichever way: a reader looks for values, not for blanks.
109
+ const c = a[column] == null || b[column] == null ? compareValues(a[column], b[column]) : sign * compareValues(a[column], b[column]);
110
+ if (c !== 0) return c;
111
+ }
112
+ return 0;
113
+ });
114
+ }
115
+
116
+ /** "Region ▲, then Revenue ▼", or '' when unsorted. */
117
+ describeSort() {
118
+ return this.sorts.map(s => `${s.column} ${s.direction === 'asc' ? '▲' : '▼'}`).join(', then ');
91
119
  }
92
120
 
93
121
  pageCount() {
@@ -114,14 +142,41 @@ export class TableView {
114
142
  this.onChange?.(this);
115
143
  }
116
144
 
117
- /** A header click: unsorted → ascending → descending → unsorted. */
118
- toggleSort(column) {
119
- const current = this.sort?.column === column ? this.sort.direction : null;
120
- this.sortBy(column, current === null ? 'asc' : current === 'asc' ? 'desc' : null);
145
+ /**
146
+ * A header click: unsorted → ascending → descending → unsorted, by this column alone. With
147
+ * `{ then: true }` (a shift-click) the column is a further key instead: added after the others,
148
+ * or, if it is one already, cycled in its place — the other keys stay.
149
+ */
150
+ toggleSort(column, { then = false } = {}) {
151
+ const next = (d) => (d === null ? 'asc' : d === 'asc' ? 'desc' : null);
152
+ if (then) {
153
+ this.thenSortBy(column, next(this.sortOf(column)?.direction ?? null));
154
+ return;
155
+ }
156
+ const alone = this.sorts.length === 1 && this.sorts[0].column === column ? this.sorts[0].direction : null;
157
+ this.sortBy(column, next(alone));
121
158
  }
122
159
 
160
+ /** Sorts by `column` alone (null: unsorted). */
123
161
  sortBy(column, direction) {
124
- this.sort = direction === null ? null : { column, direction };
162
+ this.sorts = direction === null ? [] : [{ column, direction }];
163
+ this.page = 0;
164
+ this.changed();
165
+ }
166
+
167
+ /** `column` as a further sort key: its direction changed in place, appended if new, removed with null. */
168
+ thenSortBy(column, direction) {
169
+ const at = this.sorts.findIndex(s => s.column === column);
170
+ if (direction === null) this.sorts = this.sorts.filter(s => s.column !== column);
171
+ else if (at >= 0) this.sorts = this.sorts.map(s => (s.column === column ? { column, direction } : s));
172
+ else this.sorts = [...this.sorts, { column, direction }];
173
+ this.page = 0;
174
+ this.changed();
175
+ }
176
+
177
+ /** Unsorted: rows in the order given. */
178
+ clearSort() {
179
+ this.sorts = [];
125
180
  this.page = 0;
126
181
  this.changed();
127
182
  }
@@ -201,13 +256,23 @@ export class TableView {
201
256
  }
202
257
 
203
258
  columnMenu(column) {
204
- const sorted = this.sort?.column === column ? this.sort.direction : null;
259
+ const key = this.sortOf(column);
260
+ const alone = this.sorts.length === 1 && key !== null;
261
+ const sorted = alone ? key.direction : null;
262
+ // A further key: offered once something else is sorted (so this column is never the only key
263
+ // here); ticked where this column already is one.
264
+ const further = this.sorts.some(s => s.column !== column) ? [
265
+ { label: 'Then sort ascending', checked: key?.direction === 'asc', action: () => this.thenSortBy(column, 'asc') },
266
+ { label: 'Then sort descending', checked: key?.direction === 'desc', action: () => this.thenSortBy(column, 'desc') },
267
+ ...(key !== null ? [{ label: 'Stop sorting by this column', action: () => this.thenSortBy(column, null) }] : []),
268
+ ] : [];
205
269
  const values = [...new Set(this.filtered().map(r => r[column]))].sort(compareValues);
206
270
  const listed = values.slice(0, TableView.MAX_FILTER_VALUES);
207
271
  return [
208
272
  { label: 'Sort ascending', checked: sorted === 'asc', action: () => this.sortBy(column, 'asc') },
209
273
  { label: 'Sort descending', checked: sorted === 'desc', action: () => this.sortBy(column, 'desc') },
210
- ...(sorted ? [{ label: 'Clear sort', action: () => this.sortBy(column, null) }] : []),
274
+ ...further,
275
+ ...(this.sorts.length > 0 ? [{ label: 'Clear sort', action: () => this.clearSort() }] : []),
211
276
  { separator: true },
212
277
  {
213
278
  label: 'Filter to',
@@ -16,6 +16,7 @@ export class TableViewChart extends TableChart {
16
16
  static GUTTER_W = 44;
17
17
  static MENU_GLYPH = '▾';
18
18
  static TABLE_GLYPH = '⋮';
19
+ static HIGHLIGHT_FILL = '#fdeeb5';
19
20
 
20
21
  /**
21
22
  * @param renderer
@@ -57,13 +58,15 @@ export class TableViewChart extends TableChart {
57
58
 
58
59
  columns.forEach((c, i) => {
59
60
  const left = x0 + i * colW;
60
- const sorted = view.sort?.column === c.key ? (view.sort.direction === 'asc' ? ' ▲' : ' ▼') : '';
61
+ // ▲ / ▼, and with several keys their order: "Region ▲1", "Revenue ▼2".
62
+ const key = view.sortOf(c.key);
63
+ const sorted = key === null ? '' : ` ${key.direction === 'asc' ? '▲' : '▼'}${view.sorts.length > 1 ? key.rank : ''}`;
61
64
  // In words: an arrow-like mark here read as a second sort arrow.
62
65
  const filtered = view.filters.some(f => f.column === c.key) ? ' (filtered)' : '';
63
66
  const head = this.renderer.rect(left, top, colW, ROW_H, TableChart.HEADER_FILL, null);
64
67
  // The label leaves room at the right for the ▾.
65
68
  const label = this._cell(`${c.label}${sorted}${filtered}`, c, left, top, colW - 16, { fontWeight: 'bold', fill: '#333' });
66
- this._on(interactive, [head, label], () => view.toggleSort(c.key), 'Sort');
69
+ this._on(interactive, [head, label], (e) => view.toggleSort(c.key, { then: e?.shiftKey === true }), 'Sort (shift-click: then sort by)');
67
70
  const glyph = this._text(TableViewChart.MENU_GLYPH, left + colW - 8, top, { textAnchor: 'middle', fill: '#777' });
68
71
  this._on(interactive, [glyph], (e) => this.showMenu(e.clientX, e.clientY, view.columnMenu(c.key)), `${c.label} menu`);
69
72
  });
@@ -72,7 +75,8 @@ export class TableViewChart extends TableChart {
72
75
 
73
76
  // Rows: a number in the gutter (row menu), a cell per column (cell menu).
74
77
  view.pageRows().forEach(({ row, index }, r) => {
75
- if (r % 2 === 1) this.renderer.rect(box.x, top, box.width, ROW_H, TableChart.STRIPE_FILL, null);
78
+ if (view.isHighlighted(row)) this.renderer.rect(box.x, top, box.width, ROW_H, TableViewChart.HIGHLIGHT_FILL, null);
79
+ else if (r % 2 === 1) this.renderer.rect(box.x, top, box.width, ROW_H, TableChart.STRIPE_FILL, null);
76
80
  const number = this._text(String(index + 1), box.x + gutter - PAD_X, top, { textAnchor: 'end', fill: '#999', fontSize: '11px' });
77
81
  this._on(interactive, [number], (e) => this.showMenu(e.clientX, e.clientY, view.rowMenu(row)), 'Row menu');
78
82
  columns.forEach((c, i) => {
@@ -2,7 +2,7 @@
2
2
  * Abstract base for "own a bounded collection, push one more item, drop the
3
3
  * oldest once past a cap" — the one behavior every streamable chart data
4
4
  * shape shares, however differently each stores its collection(s). See
5
- * ARCHITECTURE.md's "Data windows — streaming" section for the concrete
5
+ * documentation/ARCHITECTURE.md's "Data windows — streaming" section for the concrete
6
6
  * subtypes and why each exists as its own class rather than a branch here.
7
7
  */
8
8
  export class BoundedWindow {
@@ -2,7 +2,7 @@ import { BoundedWindow } from './BoundedWindow.js';
2
2
 
3
3
  /**
4
4
  * A nodes+edges pair — the shape behind `graphDiagram`/`graphDiagram3D`.
5
- * Not a `CHART_CATALOG` entry (see ARCHITECTURE.md), so it isn't dispatched
5
+ * Not a `CHART_CATALOG` entry (see documentation/ARCHITECTURE.md), so it isn't dispatched
6
6
  * through `createDataWindow`/`streamShape`; `RenderEngine.addGraphNode`
7
7
  * constructs it directly. Trimming drops the oldest nodes FIFO, then any
8
8
  * edge left dangling off a removed node — dropping nodes without this would