@peter.prib/renderengine 0.10.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.
@@ -63,7 +63,8 @@ export class TableView {
63
63
  this.copy = copy ?? TableView.clipboard;
64
64
  this.chooser = chooser ?? new ChartChooser();
65
65
  this.highlight = highlight;
66
- this.sort = null; // { column, direction: 'asc' | 'desc' }
66
+ /** The sort keys, most significant first: `{ column, direction: 'asc' | 'desc' }`. */
67
+ this.sorts = [];
67
68
  this.filters = []; // { column, op, value }
68
69
  this.hidden = new Set();
69
70
  this.page = 0;
@@ -85,14 +86,36 @@ export class TableView {
85
86
  : this.rows.filter(r => this.filters.every(f => FILTER_OPS[f.op](r[f.column], f.value)));
86
87
  }
87
88
 
88
- /** 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
+ */
89
102
  ordered() {
90
103
  const rows = this.filtered();
91
- if (this.sort === null) return rows;
92
- const { column, direction } = this.sort;
93
- const sign = direction === 'desc' ? -1 : 1;
94
- // Nulls stay last whichever way: a reader looks for values, not for blanks.
95
- 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 ');
96
119
  }
97
120
 
98
121
  pageCount() {
@@ -119,14 +142,41 @@ export class TableView {
119
142
  this.onChange?.(this);
120
143
  }
121
144
 
122
- /** A header click: unsorted → ascending → descending → unsorted. */
123
- toggleSort(column) {
124
- const current = this.sort?.column === column ? this.sort.direction : null;
125
- 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));
126
158
  }
127
159
 
160
+ /** Sorts by `column` alone (null: unsorted). */
128
161
  sortBy(column, direction) {
129
- 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 = [];
130
180
  this.page = 0;
131
181
  this.changed();
132
182
  }
@@ -206,13 +256,23 @@ export class TableView {
206
256
  }
207
257
 
208
258
  columnMenu(column) {
209
- 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
+ ] : [];
210
269
  const values = [...new Set(this.filtered().map(r => r[column]))].sort(compareValues);
211
270
  const listed = values.slice(0, TableView.MAX_FILTER_VALUES);
212
271
  return [
213
272
  { label: 'Sort ascending', checked: sorted === 'asc', action: () => this.sortBy(column, 'asc') },
214
273
  { label: 'Sort descending', checked: sorted === 'desc', action: () => this.sortBy(column, 'desc') },
215
- ...(sorted ? [{ label: 'Clear sort', action: () => this.sortBy(column, null) }] : []),
274
+ ...further,
275
+ ...(this.sorts.length > 0 ? [{ label: 'Clear sort', action: () => this.clearSort() }] : []),
216
276
  { separator: true },
217
277
  {
218
278
  label: 'Filter to',
@@ -58,13 +58,15 @@ export class TableViewChart extends TableChart {
58
58
 
59
59
  columns.forEach((c, i) => {
60
60
  const left = x0 + i * colW;
61
- 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 : ''}`;
62
64
  // In words: an arrow-like mark here read as a second sort arrow.
63
65
  const filtered = view.filters.some(f => f.column === c.key) ? ' (filtered)' : '';
64
66
  const head = this.renderer.rect(left, top, colW, ROW_H, TableChart.HEADER_FILL, null);
65
67
  // The label leaves room at the right for the ▾.
66
68
  const label = this._cell(`${c.label}${sorted}${filtered}`, c, left, top, colW - 16, { fontWeight: 'bold', fill: '#333' });
67
- 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)');
68
70
  const glyph = this._text(TableViewChart.MENU_GLYPH, left + colW - 8, top, { textAnchor: 'middle', fill: '#777' });
69
71
  this._on(interactive, [glyph], (e) => this.showMenu(e.clientX, e.clientY, view.columnMenu(c.key)), `${c.label} menu`);
70
72
  });