chart-factory 0.1.2
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/LICENSE +21 -0
- package/README.md +162 -0
- package/dist/chart-factory-table.js +1400 -0
- package/dist/chart-factory.css +2368 -0
- package/dist/chart-factory.js +11398 -0
- package/dist/chart-factory.min.js +7 -0
- package/docs/API.md +1815 -0
- package/docs/chart-guide.md +872 -0
- package/docs/color-guide.md +134 -0
- package/docs/conformance-matrix.md +864 -0
- package/docs/theming.md +107 -0
- package/index.d.ts +10453 -0
- package/package.json +83 -0
- package/src/core/base.css +508 -0
- package/src/core/builder-metadata.generated.mjs +406 -0
- package/src/core/d3-base.js +249 -0
- package/src/core/d3-chart-factory.js +18547 -0
- package/src/core/d3-data.js +0 -0
- package/src/core/d3-suggest.js +565 -0
- package/src/core/example-nav.css +62 -0
- package/src/core/example-nav.js +249 -0
- package/src/core/palettes.js +310 -0
- package/src/core/theme-toggle.js +126 -0
- package/src/core/tokens.css +792 -0
- package/src/table/d3-table.js +1570 -0
- package/src/table/table.css +899 -0
- package/src/table/tokens.css +157 -0
|
@@ -0,0 +1,1570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D3Table - A flexible table component with intelligent column sizing
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Automatic column width calculation based on content
|
|
6
|
+
* - Sortable columns with visual indicators
|
|
7
|
+
* - Bar chart cells with animations
|
|
8
|
+
* - Heatmap cells with color scales
|
|
9
|
+
* - Row and column highlighting
|
|
10
|
+
* - Tooltips
|
|
11
|
+
* - Responsive design
|
|
12
|
+
*
|
|
13
|
+
* @requires d3 v7+
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as d3 from 'd3';
|
|
17
|
+
import { getCSSVar, TextMeasurer, createColorScale, getLuminance, getContrastColor, formatValue } from '../core/d3-base.js';
|
|
18
|
+
|
|
19
|
+
export class D3Table {
|
|
20
|
+
/**
|
|
21
|
+
* Create a D3Table instance
|
|
22
|
+
* @param {string} containerId - ID of the table element (without #)
|
|
23
|
+
* @param {Object} config - Table configuration
|
|
24
|
+
* @param {Array} config.columns - Column definitions
|
|
25
|
+
* @param {boolean} [config.sortable=false] - Enable sorting
|
|
26
|
+
* @param {boolean} [config.highlightSortedColumn=false] - Highlight sorted column
|
|
27
|
+
* @param {Array} [config.barColumns] - Bar chart column configs
|
|
28
|
+
* @param {Array} [config.heatmapColumns] - Heatmap column configs
|
|
29
|
+
* @param {Array} [config.highlightRows] - Row highlight conditions
|
|
30
|
+
*/
|
|
31
|
+
constructor(containerId, config) {
|
|
32
|
+
this.containerId = containerId;
|
|
33
|
+
this.container = d3.select(`#${containerId}`);
|
|
34
|
+
this.config = config;
|
|
35
|
+
this.textMeasurer = new TextMeasurer();
|
|
36
|
+
this.sortState = {
|
|
37
|
+
column: null,
|
|
38
|
+
direction: null,
|
|
39
|
+
originalOrder: []
|
|
40
|
+
};
|
|
41
|
+
this.currentData = [];
|
|
42
|
+
this.userSorted = false;
|
|
43
|
+
this.expandedRows = new Set();
|
|
44
|
+
this.expandInitialized = false;
|
|
45
|
+
this.showAllRows = false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Flatten parents + sub-rows into the display order and build a
|
|
50
|
+
* per-row metadata map. config.subRows names the row field holding
|
|
51
|
+
* an array of child rows (same column shape as parents). Children
|
|
52
|
+
* always render directly under their parent — hidden until expanded
|
|
53
|
+
* — and are sorted within the parent by the active sort column.
|
|
54
|
+
* @private
|
|
55
|
+
*/
|
|
56
|
+
flattenRows(tableData) {
|
|
57
|
+
const meta = new Map();
|
|
58
|
+
const key = this.config.subRows;
|
|
59
|
+
if (!key) {
|
|
60
|
+
tableData.forEach(d => meta.set(d, {}));
|
|
61
|
+
return { flat: tableData, meta };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const sortCol = this.sortState.column ?
|
|
65
|
+
this.config.columns.find(c => c.key === this.sortState.column) : null;
|
|
66
|
+
|
|
67
|
+
const flat = [];
|
|
68
|
+
tableData.forEach(parent => {
|
|
69
|
+
let children = Array.isArray(parent[key]) ? parent[key] : [];
|
|
70
|
+
if (children.length > 1 && sortCol && sortCol.sortable) {
|
|
71
|
+
const sortFn = this.getSortFunction(sortCol);
|
|
72
|
+
children = [...children].sort((a, b) =>
|
|
73
|
+
sortFn(a, b, this.sortState.column, this.sortState.direction));
|
|
74
|
+
}
|
|
75
|
+
flat.push(parent);
|
|
76
|
+
meta.set(parent, { hasChildren: children.length > 0 });
|
|
77
|
+
children.forEach(child => {
|
|
78
|
+
flat.push(child);
|
|
79
|
+
meta.set(child, { isChild: true, parent });
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
return { flat, meta };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Expand or collapse a parent row's sub-rows (no re-render — just
|
|
87
|
+
* visibility and toggle state).
|
|
88
|
+
*/
|
|
89
|
+
toggleRow(row) {
|
|
90
|
+
if (this.expandedRows.has(row)) {
|
|
91
|
+
this.expandedRows.delete(row);
|
|
92
|
+
} else {
|
|
93
|
+
this.expandedRows.add(row);
|
|
94
|
+
}
|
|
95
|
+
this.updateExpandedState();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Sync sub-row visibility and toggle chevrons/aria with expandedRows.
|
|
100
|
+
* @private
|
|
101
|
+
*/
|
|
102
|
+
updateExpandedState() {
|
|
103
|
+
const self = this;
|
|
104
|
+
this.container.select('tbody').selectAll('tr').each(function(d) {
|
|
105
|
+
const m = self.rowMeta?.get(d) || {};
|
|
106
|
+
if (m.isChild) {
|
|
107
|
+
this.style.display = self.expandedRows.has(m.parent) ? '' : 'none';
|
|
108
|
+
} else if (m.hasChildren) {
|
|
109
|
+
const expanded = self.expandedRows.has(d);
|
|
110
|
+
d3.select(this).classed('expanded', expanded)
|
|
111
|
+
.select('.row-toggle')
|
|
112
|
+
.attr('aria-expanded', expanded ? 'true' : 'false');
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Normalize the target DOM and apply the layout mode.
|
|
119
|
+
* - Warns (instead of failing silently) when the id matches nothing.
|
|
120
|
+
* - If the target is not a <table>, a <table><thead><tbody> is created
|
|
121
|
+
* inside it so divs work as containers.
|
|
122
|
+
* - Fill layout (default): the table fills its container, carries a
|
|
123
|
+
* computed min-width floor, and is wrapped in a horizontal-scroll
|
|
124
|
+
* container. `layout: 'fixed'` keeps the legacy content-measured
|
|
125
|
+
* pixel-width rendering.
|
|
126
|
+
* @private
|
|
127
|
+
*/
|
|
128
|
+
prepareDom() {
|
|
129
|
+
let node = this.container.node();
|
|
130
|
+
if (!node) {
|
|
131
|
+
console.warn(`D3Table: no element found for id "#${this.containerId}" — nothing rendered.`);
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (node.tagName !== 'TABLE') {
|
|
136
|
+
let inner = node.querySelector('table');
|
|
137
|
+
if (!inner) {
|
|
138
|
+
inner = document.createElement('table');
|
|
139
|
+
node.appendChild(inner);
|
|
140
|
+
}
|
|
141
|
+
this.container = d3.select(inner);
|
|
142
|
+
node = inner;
|
|
143
|
+
}
|
|
144
|
+
if (!node.querySelector('thead')) {
|
|
145
|
+
node.insertBefore(document.createElement('thead'), node.firstChild);
|
|
146
|
+
}
|
|
147
|
+
if (!node.querySelector('tbody')) {
|
|
148
|
+
node.appendChild(document.createElement('tbody'));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (this.config.layout !== 'fixed') {
|
|
152
|
+
node.classList.add('d3-table--fill');
|
|
153
|
+
node.style.minWidth = `${this.computeMinTableWidth()}px`;
|
|
154
|
+
const parent = node.parentElement;
|
|
155
|
+
if (parent && !parent.classList.contains('d3-table-scroll')) {
|
|
156
|
+
const scroll = document.createElement('div');
|
|
157
|
+
scroll.className = 'd3-table-scroll';
|
|
158
|
+
parent.insertBefore(scroll, node);
|
|
159
|
+
scroll.appendChild(node);
|
|
160
|
+
}
|
|
161
|
+
const wrapper = node.closest('.d3-table-wrapper');
|
|
162
|
+
if (wrapper) wrapper.classList.add('d3-table-wrapper--fill');
|
|
163
|
+
}
|
|
164
|
+
this.applyStickyOptions(node);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Sticky header / sticky first column setup.
|
|
170
|
+
* - stickyHeader + maxHeight: the scroll wrapper becomes a vertical
|
|
171
|
+
* scroll container and the header sticks inside it.
|
|
172
|
+
* - stickyHeader alone: sticks to the page. The horizontal scroll
|
|
173
|
+
* wrapper would swallow page-stickiness, so render() switches it
|
|
174
|
+
* to overflow-x: clip while the table fits its container.
|
|
175
|
+
* - stickyFirstColumn: first column pins during horizontal scroll,
|
|
176
|
+
* with an edge shadow only while actually scrolled.
|
|
177
|
+
* @private
|
|
178
|
+
*/
|
|
179
|
+
applyStickyOptions(node) {
|
|
180
|
+
const scroll = node.parentElement?.classList.contains('d3-table-scroll') ?
|
|
181
|
+
node.parentElement : null;
|
|
182
|
+
|
|
183
|
+
if (this.config.stickyHeader) {
|
|
184
|
+
node.classList.add('d3-table--sticky-header');
|
|
185
|
+
if (scroll && typeof this.config.maxHeight === 'number') {
|
|
186
|
+
scroll.classList.add('d3-table-scroll--vertical');
|
|
187
|
+
scroll.style.maxHeight = `${this.config.maxHeight}px`;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (this.config.stickyFirstColumn) {
|
|
192
|
+
node.classList.add('d3-table--sticky-first-col');
|
|
193
|
+
if (scroll && !scroll.dataset.stickyScrollBound) {
|
|
194
|
+
scroll.dataset.stickyScrollBound = '1';
|
|
195
|
+
scroll.addEventListener('scroll', () => {
|
|
196
|
+
scroll.classList.toggle('is-h-scrolled', scroll.scrollLeft > 0);
|
|
197
|
+
}, { passive: true });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Readable floor for fill layout: per-column-type minimums summed.
|
|
204
|
+
* Below this width the scroll container takes over instead of
|
|
205
|
+
* crushing columns further. Override per column via col.minWidth,
|
|
206
|
+
* or wholesale via config.minTableWidth.
|
|
207
|
+
* @private
|
|
208
|
+
*/
|
|
209
|
+
computeMinTableWidth() {
|
|
210
|
+
if (typeof this.config.minTableWidth === 'number') return this.config.minTableWidth;
|
|
211
|
+
|
|
212
|
+
const defaultMin = parseInt(this.getCSSVar('--table-min-col-width', '48'));
|
|
213
|
+
const primaryMin = parseInt(this.getCSSVar('--table-min-col-width-primary', '110'));
|
|
214
|
+
const secondaryMin = parseInt(this.getCSSVar('--table-min-col-width-secondary', '28'));
|
|
215
|
+
const barMin = parseInt(this.getCSSVar('--bar-column-min-width', '130'));
|
|
216
|
+
const barColumnKeys = (this.config.barColumns || []).map(b => b.key);
|
|
217
|
+
|
|
218
|
+
const imageSize = parseInt(this.getCSSVar('--table-image-size', '22'));
|
|
219
|
+
const imageGap = parseInt(this.getCSSVar('--table-image-gap', '8'));
|
|
220
|
+
|
|
221
|
+
return (this.config.columns || []).reduce((sum, col) => {
|
|
222
|
+
let floor;
|
|
223
|
+
if (barColumnKeys.includes(col.key)) floor = barMin;
|
|
224
|
+
else if (col.className === 'primary-cell' || col.className === 'team-cell') floor = primaryMin;
|
|
225
|
+
else if (col.className === 'secondary-cell' || col.className === 'rank-cell') floor = secondaryMin;
|
|
226
|
+
else floor = defaultMin;
|
|
227
|
+
if (col.image) floor += (col.imageSize || imageSize) + imageGap;
|
|
228
|
+
return sum + Math.max(floor, col.minWidth || 0);
|
|
229
|
+
}, 0);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get CSS variable value with fallback. Reads from the table's own
|
|
234
|
+
* container (computed style, so container-scoped token overrides like
|
|
235
|
+
* `#my-table-wrap { --bar-column-min-width: 80px }` work exactly like
|
|
236
|
+
* they do in CSS), falling back to :root and then the literal fallback.
|
|
237
|
+
* @private
|
|
238
|
+
*/
|
|
239
|
+
getCSSVar(name, fallback = '') {
|
|
240
|
+
const node = this.container?.node?.();
|
|
241
|
+
if (node && node.isConnected) {
|
|
242
|
+
const value = getComputedStyle(node).getPropertyValue(name).trim();
|
|
243
|
+
if (value) return value;
|
|
244
|
+
}
|
|
245
|
+
return getCSSVar(name, fallback);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Measure text width for a given string
|
|
250
|
+
* @private
|
|
251
|
+
*/
|
|
252
|
+
measureText(text, fontSize = null, fontWeight = null, options = {}) {
|
|
253
|
+
return this.textMeasurer.measure(text, fontSize, fontWeight, options);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Calculate optimal column widths based on content
|
|
258
|
+
* @private
|
|
259
|
+
*/
|
|
260
|
+
calculateColumnWidths(tableData, columns) {
|
|
261
|
+
const columnWidths = {};
|
|
262
|
+
|
|
263
|
+
// Get bar column keys for special handling
|
|
264
|
+
const barColumnKeys = (this.config.barColumns || []).map(b => b.key);
|
|
265
|
+
|
|
266
|
+
columns.forEach(col => {
|
|
267
|
+
let maxWidth = 0;
|
|
268
|
+
|
|
269
|
+
// Measure header width (headers are uppercase with letter-spacing)
|
|
270
|
+
let headerWidth = this.measureText(
|
|
271
|
+
col.header,
|
|
272
|
+
this.getCSSVar('--table-header-font-size', '10px'),
|
|
273
|
+
this.getCSSVar('--font-weight-normal', '400'),
|
|
274
|
+
{
|
|
275
|
+
letterSpacing: this.getCSSVar('--letter-spacing-wide', '0.8px'),
|
|
276
|
+
textTransform: 'uppercase'
|
|
277
|
+
}
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// Add space for sort indicator if column is sortable
|
|
281
|
+
if (col.sortable) {
|
|
282
|
+
const sortIndicatorSize = parseInt(this.getCSSVar('--sort-indicator-size', '10'));
|
|
283
|
+
const sortIndicatorMargin = parseInt(this.getCSSVar('--space-1', '4'));
|
|
284
|
+
headerWidth += sortIndicatorSize + sortIndicatorMargin;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
maxWidth = Math.max(maxWidth, headerWidth);
|
|
288
|
+
|
|
289
|
+
// Check if this is a bar column
|
|
290
|
+
const isBarColumn = barColumnKeys.includes(col.key);
|
|
291
|
+
|
|
292
|
+
// Measure content widths
|
|
293
|
+
const isPrimary = col.className === 'primary-cell' || col.className === 'team-cell';
|
|
294
|
+
const isSecondary = col.className === 'secondary-cell' || col.className === 'rank-cell';
|
|
295
|
+
|
|
296
|
+
if (isBarColumn) {
|
|
297
|
+
// Bar columns need special calculation
|
|
298
|
+
let maxLabelWidth = 0;
|
|
299
|
+
tableData.forEach(row => {
|
|
300
|
+
const value = row[col.key];
|
|
301
|
+
const labelText = col.format === 'percentage' ? `${value}%` : String(value);
|
|
302
|
+
const labelWidth = this.measureText(
|
|
303
|
+
labelText,
|
|
304
|
+
this.getCSSVar('--table-font-size'),
|
|
305
|
+
this.getCSSVar('--font-weight-semibold')
|
|
306
|
+
);
|
|
307
|
+
maxLabelWidth = Math.max(maxLabelWidth, labelWidth);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const barMaxPercent = parseInt(this.getCSSVar('--bar-max-width-percent', '70'));
|
|
311
|
+
const barTextPadding = parseInt(this.getCSSVar('--bar-text-padding', '4'));
|
|
312
|
+
const cellPaddingX = parseInt(this.getCSSVar('--table-cell-padding-x', '20'));
|
|
313
|
+
|
|
314
|
+
// The label starts 2% after the bar (up to barMaxPercent), so
|
|
315
|
+
// it lives in the remaining (100 - barMaxPercent - 2) share of
|
|
316
|
+
// the padded interior; only the label needs scaling up by that
|
|
317
|
+
// share — the padding is a constant added on top. (Dividing
|
|
318
|
+
// the padding by the share too was a long-standing bug that
|
|
319
|
+
// inflated every bar column by ~100px.)
|
|
320
|
+
const labelPercent = (100 - barMaxPercent - 2) / 100;
|
|
321
|
+
const minWidthForLabel =
|
|
322
|
+
(maxLabelWidth + 2 * barTextPadding) / labelPercent + 2 * cellPaddingX;
|
|
323
|
+
maxWidth = Math.max(maxWidth, minWidthForLabel);
|
|
324
|
+
|
|
325
|
+
let optimalWidth = maxWidth;
|
|
326
|
+
|
|
327
|
+
const barMinWidth = parseInt(this.getCSSVar('--bar-column-min-width', '130'));
|
|
328
|
+
const barMaxWidth = parseInt(this.getCSSVar('--bar-column-max-width', '250'));
|
|
329
|
+
optimalWidth = Math.max(optimalWidth, col.minWidth || barMinWidth);
|
|
330
|
+
// A cap (explicit or token) never shrinks below the label
|
|
331
|
+
// requirement — a clipped value label is always wrong.
|
|
332
|
+
const cap = Math.max(col.maxWidth || barMaxWidth, Math.ceil(minWidthForLabel));
|
|
333
|
+
if ((col.maxWidth || barMaxWidth) < minWidthForLabel) {
|
|
334
|
+
console.warn(`D3Table: bar column "${col.key}" max width ` +
|
|
335
|
+
`${col.maxWidth || barMaxWidth}px would clip its value ` +
|
|
336
|
+
`label; using ${cap}px.`);
|
|
337
|
+
}
|
|
338
|
+
optimalWidth = Math.min(optimalWidth, cap);
|
|
339
|
+
|
|
340
|
+
columnWidths[col.key] = optimalWidth;
|
|
341
|
+
} else {
|
|
342
|
+
// Regular column width calculation
|
|
343
|
+
tableData.forEach(row => {
|
|
344
|
+
let displayText;
|
|
345
|
+
|
|
346
|
+
if (col.render) {
|
|
347
|
+
// For columns with render functions, call render and
|
|
348
|
+
// extract text. render may return a number (or throw);
|
|
349
|
+
// measurement must degrade to the raw value, never
|
|
350
|
+
// kill the caller's page.
|
|
351
|
+
let rendered;
|
|
352
|
+
try {
|
|
353
|
+
rendered = col.render(row[col.key], row);
|
|
354
|
+
} catch (e) {
|
|
355
|
+
rendered = row[col.key];
|
|
356
|
+
}
|
|
357
|
+
// Strip HTML tags to get plain text for measurement
|
|
358
|
+
displayText = String(rendered ?? '').replace(/<[^>]*>/g, '').trim();
|
|
359
|
+
} else {
|
|
360
|
+
displayText = formatValue(row[col.key], col.format);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const fontSize = isPrimary ?
|
|
364
|
+
this.getCSSVar('--table-primary-font-size', this.getCSSVar('--table-team-font-size')) :
|
|
365
|
+
this.getCSSVar('--table-font-size');
|
|
366
|
+
const fontWeight = isPrimary ?
|
|
367
|
+
this.getCSSVar('--font-weight-bold') :
|
|
368
|
+
this.getCSSVar('--font-weight-normal');
|
|
369
|
+
|
|
370
|
+
const textWidth = this.measureText(displayText.toString(), fontSize, fontWeight,
|
|
371
|
+
isPrimary ? { fontFamily: this.getCSSVar('--table-primary-font', '') } : {});
|
|
372
|
+
maxWidth = Math.max(maxWidth, textWidth);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// Image columns reserve room for the leading image + gap
|
|
376
|
+
if (col.image) {
|
|
377
|
+
const imageSize = col.imageSize || parseInt(this.getCSSVar('--table-image-size', '22'));
|
|
378
|
+
const imageGap = parseInt(this.getCSSVar('--table-image-gap', '8'));
|
|
379
|
+
maxWidth += imageSize + imageGap;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const padding = isSecondary ?
|
|
383
|
+
parseInt(this.getCSSVar('--col-padding-secondary', '10')) :
|
|
384
|
+
parseInt(this.getCSSVar('--col-padding-total', '28'));
|
|
385
|
+
let optimalWidth = maxWidth + padding;
|
|
386
|
+
|
|
387
|
+
const primaryMaxWidth = parseInt(this.getCSSVar('--col-primary-max-width', '250'));
|
|
388
|
+
const secondaryMaxWidth = parseInt(this.getCSSVar('--col-secondary-max-width', '60'));
|
|
389
|
+
const defaultMaxWidth = parseInt(this.getCSSVar('--col-default-max-width', '150'));
|
|
390
|
+
|
|
391
|
+
if (isPrimary) {
|
|
392
|
+
optimalWidth = Math.min(optimalWidth, col.maxWidth || primaryMaxWidth);
|
|
393
|
+
if (col.minWidth) optimalWidth = Math.max(optimalWidth, col.minWidth);
|
|
394
|
+
} else if (isSecondary) {
|
|
395
|
+
optimalWidth = Math.min(optimalWidth, col.maxWidth || secondaryMaxWidth);
|
|
396
|
+
if (col.minWidth) optimalWidth = Math.max(optimalWidth, col.minWidth);
|
|
397
|
+
} else {
|
|
398
|
+
optimalWidth = Math.min(optimalWidth, col.maxWidth || defaultMaxWidth);
|
|
399
|
+
if (col.minWidth) optimalWidth = Math.max(optimalWidth, col.minWidth);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
columnWidths[col.key] = optimalWidth;
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
return columnWidths;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Label-aware minimum width for a bar column (fill layout).
|
|
411
|
+
* The end-of-bar value label is absolutely positioned after the bar
|
|
412
|
+
* (up to --bar-max-width-percent), so the cell must reserve room for
|
|
413
|
+
* the widest label in the remaining share or the label bleeds into
|
|
414
|
+
* the next column. Mirrors the fixed-layout bar branch of
|
|
415
|
+
* calculateColumnWidths, applied as min-width instead of width.
|
|
416
|
+
* @private
|
|
417
|
+
*/
|
|
418
|
+
computeBarColumnMinWidth(col, tableData) {
|
|
419
|
+
let maxLabelWidth = 0;
|
|
420
|
+
tableData.forEach(row => {
|
|
421
|
+
const value = row[col.key];
|
|
422
|
+
const labelText = col.format === 'percentage' ? `${value}%` : String(value);
|
|
423
|
+
maxLabelWidth = Math.max(maxLabelWidth, this.measureText(
|
|
424
|
+
labelText,
|
|
425
|
+
this.getCSSVar('--table-font-size'),
|
|
426
|
+
this.getCSSVar('--font-weight-semibold')
|
|
427
|
+
));
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
const barMaxPercent = parseInt(this.getCSSVar('--bar-max-width-percent', '70'));
|
|
431
|
+
const barTextPadding = parseInt(this.getCSSVar('--bar-text-padding', '4'));
|
|
432
|
+
const cellPaddingX = parseInt(this.getCSSVar('--table-cell-padding-x', '20'));
|
|
433
|
+
const barMinWidth = parseInt(this.getCSSVar('--bar-column-min-width', '130'));
|
|
434
|
+
const barMaxWidth = parseInt(this.getCSSVar('--bar-column-max-width', '250'));
|
|
435
|
+
|
|
436
|
+
// Same formula as the fixed-layout branch: scale only the label by
|
|
437
|
+
// its share of the interior (net of the 2% bar-to-label gap);
|
|
438
|
+
// padding is a constant on top. The cap never shrinks below the
|
|
439
|
+
// label requirement (no clipped labels).
|
|
440
|
+
const labelPercent = (100 - barMaxPercent - 2) / 100;
|
|
441
|
+
const minWidthForLabel =
|
|
442
|
+
(maxLabelWidth + 2 * barTextPadding) / labelPercent + 2 * cellPaddingX;
|
|
443
|
+
|
|
444
|
+
const floor = Math.max(minWidthForLabel, barMinWidth, col.minWidth || 0);
|
|
445
|
+
const cap = Math.max(col.maxWidth || barMaxWidth, Math.ceil(minWidthForLabel));
|
|
446
|
+
return Math.round(Math.min(floor, cap));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Format a value based on format type (instance method wrapper)
|
|
451
|
+
* @private
|
|
452
|
+
*/
|
|
453
|
+
formatValue(value, format) {
|
|
454
|
+
return formatValue(value, format);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Minimal HTML escaping for plain values entering .html() paths.
|
|
459
|
+
* @private
|
|
460
|
+
*/
|
|
461
|
+
escapeHtml(value) {
|
|
462
|
+
return String(value).replace(/[&<>"']/g, c => (
|
|
463
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Final content for a regular cell: format > render > escaped plain
|
|
468
|
+
* value, then col.emphasize wraps the result — return true for bold
|
|
469
|
+
* ("led league" style) or 'strong' for bold-italic ("led MLB").
|
|
470
|
+
* Used by body cells, image cells, and footer rows so emphasis
|
|
471
|
+
* behaves identically everywhere.
|
|
472
|
+
* @private
|
|
473
|
+
*/
|
|
474
|
+
formatCellContent(col, value, row) {
|
|
475
|
+
let html;
|
|
476
|
+
if (col.format) html = this.formatValue(value, col.format);
|
|
477
|
+
// String()-coerce renderer output: a render function returning a
|
|
478
|
+
// number (a natural mistake for value columns) must not leak a
|
|
479
|
+
// non-string into the html pipeline (width measurement coerces the
|
|
480
|
+
// same way — see calculateColumnWidths).
|
|
481
|
+
else if (col.render) html = String(col.render(value, row) ?? '');
|
|
482
|
+
else html = (value === undefined || value === null) ? '' : this.escapeHtml(value);
|
|
483
|
+
|
|
484
|
+
const level = col.emphasize ? col.emphasize(value, row) : null;
|
|
485
|
+
if (level === 'strong') return `<b><i>${html}</i></b>`;
|
|
486
|
+
if (level) return `<b>${html}</b>`;
|
|
487
|
+
return html;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Get a D3 color scale
|
|
492
|
+
* @param {string} type - Scale type name
|
|
493
|
+
* @param {Array} domain - [min, max] domain values
|
|
494
|
+
*/
|
|
495
|
+
getColorScale(type, domain) {
|
|
496
|
+
return createColorScale(type, domain);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Optional spanning group-header row above the column headers.
|
|
501
|
+
* Consecutive columns sharing the same col.group value are spanned
|
|
502
|
+
* by one th ("Passing" over passYds/passTd); runs of ungrouped
|
|
503
|
+
* columns get one empty spacer th. Rendered only if any column
|
|
504
|
+
* declares a group.
|
|
505
|
+
* @private
|
|
506
|
+
*/
|
|
507
|
+
renderGroupHeaderRow(thead, columns) {
|
|
508
|
+
if (!columns.some(c => c.group)) return;
|
|
509
|
+
const tr = thead.append('tr').attr('class', 'd3-table-group-row');
|
|
510
|
+
let i = 0;
|
|
511
|
+
while (i < columns.length) {
|
|
512
|
+
const group = columns[i].group;
|
|
513
|
+
let span = 1;
|
|
514
|
+
while (i + span < columns.length && columns[i + span].group === group) span++;
|
|
515
|
+
const th = tr.append('th').attr('colspan', span);
|
|
516
|
+
if (group) th.attr('class', 'group-header').text(group);
|
|
517
|
+
i += span;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Render table headers
|
|
523
|
+
* @private
|
|
524
|
+
*/
|
|
525
|
+
/**
|
|
526
|
+
* Apply a measured column width. Text columns get the fixed pixel width.
|
|
527
|
+
* In fill layout exactly one column stays flexible to absorb the
|
|
528
|
+
* container's spare width: the first `wrap: true` column when one
|
|
529
|
+
* exists (spare width un-wraps its text), else the bar column (spare
|
|
530
|
+
* width stretches the bars — the legacy behavior). A bar column that
|
|
531
|
+
* is NOT the flexible one pins at its cap so value labels sit right
|
|
532
|
+
* after the bars instead of a field of dead space before the next
|
|
533
|
+
* column.
|
|
534
|
+
* @private
|
|
535
|
+
*/
|
|
536
|
+
applyColumnWidth(node, col, px) {
|
|
537
|
+
const isBarColumn = (this.config.barColumns || []).some(b => b.key === col.key);
|
|
538
|
+
const fill = this.config.layout !== 'fixed';
|
|
539
|
+
const wrapCol = fill ? (this.config.columns || []).find(c => c.wrap) : null;
|
|
540
|
+
|
|
541
|
+
if (fill && wrapCol && col.key === wrapCol.key) {
|
|
542
|
+
// Wrap column: floor only, no width — it renders at its natural
|
|
543
|
+
// (unwrapped) width and wraps only when the container runs out.
|
|
544
|
+
node.style.minWidth = `${px}px`;
|
|
545
|
+
} else if (isBarColumn && fill) {
|
|
546
|
+
// Floor only here; adjustBarColumnFlex() grows the bar column
|
|
547
|
+
// toward its cap from the container slack measured after render
|
|
548
|
+
// (and on every container resize).
|
|
549
|
+
node.style.minWidth = `${px}px`;
|
|
550
|
+
} else {
|
|
551
|
+
node.style.setProperty('width', `${px}px`, 'important');
|
|
552
|
+
// Floor, not suggestion: the remainder-taking column must not
|
|
553
|
+
// squeeze text columns below their measured width (dates
|
|
554
|
+
// wrapping to three lines inflates every row).
|
|
555
|
+
node.style.minWidth = `${px}px`;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Explicit per-column `minWidth` / `maxWidth` config always reaches the
|
|
561
|
+
* cells as inline styles, so it outranks the stylesheet's auto-width
|
|
562
|
+
* clamps (e.g. the static secondary-cell max-width) and applies even in
|
|
563
|
+
* fill layout, where columns otherwise carry no width at all.
|
|
564
|
+
* `minWidth` is a floor (it only ever raises a computed floor);
|
|
565
|
+
* `maxWidth` is a hard cap.
|
|
566
|
+
* @private
|
|
567
|
+
*/
|
|
568
|
+
applyExplicitColumnBounds(node, col) {
|
|
569
|
+
if (typeof col.minWidth === 'number') {
|
|
570
|
+
const current = parseFloat(node.style.minWidth) || 0;
|
|
571
|
+
if (col.minWidth > current) node.style.minWidth = `${col.minWidth}px`;
|
|
572
|
+
}
|
|
573
|
+
if (typeof col.maxWidth === 'number') {
|
|
574
|
+
node.style.maxWidth = `${col.maxWidth}px`;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
renderHeaders(columns, columnWidths = {}) {
|
|
579
|
+
const thead = this.container.select('thead');
|
|
580
|
+
thead.selectAll('*').remove();
|
|
581
|
+
this.renderGroupHeaderRow(thead, columns);
|
|
582
|
+
const headerRow = thead.append('tr').attr('class', 'd3-table-header-row');
|
|
583
|
+
|
|
584
|
+
const barColumnKeys = (this.config.barColumns || []).map(b => b.key);
|
|
585
|
+
|
|
586
|
+
columns.forEach(col => {
|
|
587
|
+
let classes = [];
|
|
588
|
+
|
|
589
|
+
const isBarColumn = barColumnKeys.includes(col.key);
|
|
590
|
+
if (isBarColumn) {
|
|
591
|
+
classes.push('bar-cell');
|
|
592
|
+
} else {
|
|
593
|
+
if (col.align === 'right') classes.push('align-right');
|
|
594
|
+
if (col.align === 'center') classes.push('align-center');
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (col.autoWidth !== false) classes.push('auto-width');
|
|
598
|
+
if (col.wrap) classes.push('wrap-cell');
|
|
599
|
+
if (col.className) classes.push(col.className);
|
|
600
|
+
if (col.highlight) classes.push('highlight-header');
|
|
601
|
+
|
|
602
|
+
const isSortable = !!(this.config.sortable && col.sortable);
|
|
603
|
+
if (isSortable) classes.push('sortable-header');
|
|
604
|
+
|
|
605
|
+
const th = headerRow.append('th')
|
|
606
|
+
.attr('class', classes.join(' '));
|
|
607
|
+
|
|
608
|
+
if (isSortable) {
|
|
609
|
+
this.renderSortableHeaderContent(th, col, isBarColumn);
|
|
610
|
+
} else {
|
|
611
|
+
th.text(col.header);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (columnWidths[col.key]) {
|
|
615
|
+
this.applyColumnWidth(th.node(), col, columnWidths[col.key]);
|
|
616
|
+
} else if (this.barColumnMinWidths?.[col.key]) {
|
|
617
|
+
this.applyColumnWidth(th.node(), col, this.barColumnMinWidths[col.key]);
|
|
618
|
+
} else if (this.fillMinWidths?.[col.key]) {
|
|
619
|
+
this.applyColumnWidth(th.node(), col, this.fillMinWidths[col.key]);
|
|
620
|
+
}
|
|
621
|
+
this.applyExplicitColumnBounds(th.node(), col);
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Build a sortable header: a real <button> (Tab / Enter / Space,
|
|
627
|
+
* aria-sort lives on the th) containing the label with the sort
|
|
628
|
+
* indicator glued to the adjacent word so it can never wrap onto a
|
|
629
|
+
* line by itself. Placement is alignment-aware: right-aligned
|
|
630
|
+
* (numeric) columns carry the indicator BEFORE the label so the
|
|
631
|
+
* aligned number edge stays clean; all other columns carry it after.
|
|
632
|
+
* @private
|
|
633
|
+
*/
|
|
634
|
+
renderSortableHeaderContent(th, col, isBarColumn) {
|
|
635
|
+
const button = th.append('button')
|
|
636
|
+
.attr('type', 'button')
|
|
637
|
+
.attr('class', 'sort-button');
|
|
638
|
+
|
|
639
|
+
const words = String(col.header).split(' ');
|
|
640
|
+
const leadIndicator = col.align === 'right' && !isBarColumn;
|
|
641
|
+
|
|
642
|
+
if (leadIndicator) {
|
|
643
|
+
const first = words.shift();
|
|
644
|
+
const glue = button.append('span').attr('class', 'sort-glue-lead');
|
|
645
|
+
glue.append('span').attr('class', 'sort-indicator inactive');
|
|
646
|
+
glue.append(() => document.createTextNode(first));
|
|
647
|
+
if (words.length) {
|
|
648
|
+
button.append(() => document.createTextNode(` ${words.join(' ')}`));
|
|
649
|
+
}
|
|
650
|
+
} else {
|
|
651
|
+
const last = words.pop();
|
|
652
|
+
if (words.length) {
|
|
653
|
+
button.append(() => document.createTextNode(`${words.join(' ')} `));
|
|
654
|
+
}
|
|
655
|
+
const glue = button.append('span').attr('class', 'sort-glue');
|
|
656
|
+
glue.append(() => document.createTextNode(last));
|
|
657
|
+
glue.append('span').attr('class', 'sort-indicator inactive');
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Render table body
|
|
663
|
+
* @private
|
|
664
|
+
*/
|
|
665
|
+
renderBody(tableData, columns, columnWidths = {}) {
|
|
666
|
+
const tbody = this.container.select('tbody');
|
|
667
|
+
tbody.selectAll('*').remove();
|
|
668
|
+
|
|
669
|
+
// Parents + sub-rows in display order (sub-rows hidden until expanded)
|
|
670
|
+
const { flat, meta } = this.flattenRows(tableData);
|
|
671
|
+
this.rowMeta = meta;
|
|
672
|
+
|
|
673
|
+
// Calculate domains for special columns (sub-rows included, so
|
|
674
|
+
// expansion never changes the scales)
|
|
675
|
+
const barDomains = {};
|
|
676
|
+
if (this.config.barColumns) {
|
|
677
|
+
this.config.barColumns.forEach(barCol => {
|
|
678
|
+
const values = flat.map(d => parseFloat(d[barCol.key]) || 0);
|
|
679
|
+
barDomains[barCol.key] = [0, d3.max(values)];
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const heatmapDomains = {};
|
|
684
|
+
if (this.config.heatmapColumns) {
|
|
685
|
+
this.config.heatmapColumns.forEach(heatmapCol => {
|
|
686
|
+
// Missing values stay out of the domain — a null must not
|
|
687
|
+
// drag the scale's min down to 0.
|
|
688
|
+
const values = flat.map(d => parseFloat(d[heatmapCol.key]))
|
|
689
|
+
.filter(v => !isNaN(v));
|
|
690
|
+
heatmapDomains[heatmapCol.key] = [d3.min(values), d3.max(values)];
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const rows = tbody.selectAll('tr')
|
|
695
|
+
.data(flat)
|
|
696
|
+
.join('tr');
|
|
697
|
+
|
|
698
|
+
if (this.config.subRows) {
|
|
699
|
+
rows.classed('sub-row', d => !!meta.get(d).isChild)
|
|
700
|
+
.classed('expandable-row', d => !!meta.get(d).hasChildren);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Apply row highlighting
|
|
704
|
+
if (this.config.highlightRows) {
|
|
705
|
+
rows.classed('highlight-row', d => {
|
|
706
|
+
return this.config.highlightRows.some(highlight => {
|
|
707
|
+
if (typeof highlight === 'string') {
|
|
708
|
+
return Object.values(d).includes(highlight);
|
|
709
|
+
}
|
|
710
|
+
if (typeof highlight === 'object' && highlight.key && highlight.value !== undefined) {
|
|
711
|
+
return d[highlight.key] === highlight.value;
|
|
712
|
+
}
|
|
713
|
+
return false;
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const barColumnKeys = (this.config.barColumns || []).map(b => b.key);
|
|
719
|
+
|
|
720
|
+
columns.forEach((col) => {
|
|
721
|
+
const isBarColumn = barColumnKeys.includes(col.key);
|
|
722
|
+
|
|
723
|
+
const cells = rows.append('td')
|
|
724
|
+
.attr('class', () => {
|
|
725
|
+
let classes = [];
|
|
726
|
+
if (!isBarColumn) {
|
|
727
|
+
if (col.align === 'right') classes.push('align-right');
|
|
728
|
+
if (col.align === 'center') classes.push('align-center');
|
|
729
|
+
}
|
|
730
|
+
if (col.className) classes.push(col.className);
|
|
731
|
+
if (col.autoWidth !== false) classes.push('auto-width');
|
|
732
|
+
if (col.wrap) classes.push('wrap-cell');
|
|
733
|
+
if (col.highlight) classes.push('highlight-column');
|
|
734
|
+
return classes.join(' ');
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
if (columnWidths[col.key]) {
|
|
738
|
+
const applyWidth = (node) => this.applyColumnWidth(node, col, columnWidths[col.key]);
|
|
739
|
+
cells.each(function() { applyWidth(this); });
|
|
740
|
+
} else if (this.barColumnMinWidths?.[col.key]) {
|
|
741
|
+
const applyWidth = (node) => this.applyColumnWidth(node, col, this.barColumnMinWidths[col.key]);
|
|
742
|
+
cells.each(function() { applyWidth(this); });
|
|
743
|
+
} else if (this.fillMinWidths?.[col.key]) {
|
|
744
|
+
const applyWidth = (node) => this.applyColumnWidth(node, col, this.fillMinWidths[col.key]);
|
|
745
|
+
cells.each(function() { applyWidth(this); });
|
|
746
|
+
}
|
|
747
|
+
{
|
|
748
|
+
const applyBounds = (node) => this.applyExplicitColumnBounds(node, col);
|
|
749
|
+
cells.each(function() { applyBounds(this); });
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const barConfig = this.config.barColumns?.find(b => b.key === col.key);
|
|
753
|
+
const heatmapConfig = this.config.heatmapColumns?.find(h => h.key === col.key);
|
|
754
|
+
|
|
755
|
+
if (barConfig) {
|
|
756
|
+
this.renderBarCell(cells, col, barConfig, barDomains[col.key]);
|
|
757
|
+
} else if (heatmapConfig) {
|
|
758
|
+
this.renderHeatmapCell(cells, col, heatmapConfig, heatmapDomains[col.key]);
|
|
759
|
+
} else if (col.image) {
|
|
760
|
+
// Image module — composes with subtitle/format/render
|
|
761
|
+
this.renderImageCell(cells, col);
|
|
762
|
+
} else if (col.subtitle) {
|
|
763
|
+
// Render cell with subtitle
|
|
764
|
+
this.renderSubtitleCell(cells, col);
|
|
765
|
+
} else if (col.format || col.render || col.emphasize) {
|
|
766
|
+
cells.html(d => this.formatCellContent(col, d[col.key], d));
|
|
767
|
+
} else {
|
|
768
|
+
cells.text(d => d[col.key]);
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
if (this.config.subRows) {
|
|
773
|
+
this.attachRowToggles(rows, meta);
|
|
774
|
+
}
|
|
775
|
+
this.applyCapClasses();
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Prepend an expand/collapse chevron button to the first cell of
|
|
780
|
+
* every parent row that has sub-rows (a same-width spacer keeps
|
|
781
|
+
* childless parents aligned), then apply the current expanded state.
|
|
782
|
+
* @private
|
|
783
|
+
*/
|
|
784
|
+
attachRowToggles(rows, meta) {
|
|
785
|
+
const self = this;
|
|
786
|
+
rows.each(function(d) {
|
|
787
|
+
const m = meta.get(d);
|
|
788
|
+
if (m.isChild) return;
|
|
789
|
+
const td = this.querySelector('td');
|
|
790
|
+
if (!td) return;
|
|
791
|
+
|
|
792
|
+
if (m.hasChildren) {
|
|
793
|
+
const btn = document.createElement('button');
|
|
794
|
+
btn.type = 'button';
|
|
795
|
+
btn.className = 'row-toggle';
|
|
796
|
+
btn.setAttribute('aria-label', 'Toggle sub-rows');
|
|
797
|
+
btn.addEventListener('click', (e) => {
|
|
798
|
+
e.stopPropagation();
|
|
799
|
+
self.toggleRow(d);
|
|
800
|
+
});
|
|
801
|
+
td.insertBefore(btn, td.firstChild);
|
|
802
|
+
} else {
|
|
803
|
+
const spacer = document.createElement('span');
|
|
804
|
+
spacer.className = 'row-toggle-spacer';
|
|
805
|
+
td.insertBefore(spacer, td.firstChild);
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
this.updateExpandedState();
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Hide top-level rows beyond config.maxRows (sub-rows follow their
|
|
813
|
+
* parent) unless the user pressed Show all. Class-based so it
|
|
814
|
+
* composes with sub-row expand/collapse visibility.
|
|
815
|
+
* @private
|
|
816
|
+
*/
|
|
817
|
+
applyCapClasses() {
|
|
818
|
+
const max = this.config.maxRows;
|
|
819
|
+
if (!max) return;
|
|
820
|
+
const self = this;
|
|
821
|
+
let parentIdx = -1;
|
|
822
|
+
this.container.select('tbody').selectAll('tr').each(function(d) {
|
|
823
|
+
const m = self.rowMeta?.get(d) || {};
|
|
824
|
+
if (!m.isChild) parentIdx++;
|
|
825
|
+
this.classList.toggle('row-capped', !self.showAllRows && parentIdx >= max);
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* "Show all N" / "Show fewer" toggle below the table when maxRows
|
|
831
|
+
* caps it. Lives outside the scroll container so it never scrolls.
|
|
832
|
+
* @private
|
|
833
|
+
*/
|
|
834
|
+
renderShowAllControl() {
|
|
835
|
+
const max = this.config.maxRows;
|
|
836
|
+
const node = this.container.node();
|
|
837
|
+
// Host must be the element that CONTAINS the control after the
|
|
838
|
+
// afterend insertion below: the page wrapper when present, else the
|
|
839
|
+
// parent of the fill-layout scroll wrapper. Using the scroll wrapper
|
|
840
|
+
// itself (node.parentElement when the container is the wrapped
|
|
841
|
+
// <table>) made the dedupe lookup search a subtree the button sits
|
|
842
|
+
// AFTER — so every re-render minted another button.
|
|
843
|
+
const scroll = node.closest('.d3-table-scroll');
|
|
844
|
+
const host = node.closest('.d3-table-wrapper') ||
|
|
845
|
+
(scroll ? scroll.parentElement : node.parentElement);
|
|
846
|
+
if (!host) return;
|
|
847
|
+
|
|
848
|
+
let btn = host.querySelector(`.d3-table-showmore[data-for="${this.containerId}"]`);
|
|
849
|
+
const total = this.currentData.length;
|
|
850
|
+
if (!max || total <= max) {
|
|
851
|
+
if (btn) btn.remove();
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (!btn) {
|
|
856
|
+
btn = document.createElement('button');
|
|
857
|
+
btn.type = 'button';
|
|
858
|
+
btn.className = 'd3-table-showmore';
|
|
859
|
+
btn.dataset.for = this.containerId;
|
|
860
|
+
const anchor = host.querySelector('.d3-table-scroll') ||
|
|
861
|
+
(node.closest('.d3-table-scroll') || node);
|
|
862
|
+
anchor.insertAdjacentElement('afterend', btn);
|
|
863
|
+
btn.addEventListener('click', () => {
|
|
864
|
+
this.showAllRows = !this.showAllRows;
|
|
865
|
+
this.applyCapClasses();
|
|
866
|
+
this.renderShowAllControl();
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
btn.textContent = this.showAllRows ?
|
|
870
|
+
(this.config.showLessLabel || 'Show fewer') :
|
|
871
|
+
(this.config.showAllLabel || `Show all ${total}`);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Pinned summary rows (config.footerRows: array of row objects with
|
|
876
|
+
* the same keys as data rows; footerRow is a single-row shorthand).
|
|
877
|
+
* Rendered in tfoot — exempt from sorting and the row cap. The first
|
|
878
|
+
* footer row carries the heavy total rule; later rows get the normal
|
|
879
|
+
* row border. Values go through the column's format/render/emphasize;
|
|
880
|
+
* bar, heatmap, and image treatments don't apply.
|
|
881
|
+
* @private
|
|
882
|
+
*/
|
|
883
|
+
renderFooter(columns) {
|
|
884
|
+
let tfoot = this.container.select('tfoot');
|
|
885
|
+
const footerRows = this.config.footerRows ||
|
|
886
|
+
(this.config.footerRow ? [this.config.footerRow] : []);
|
|
887
|
+
if (!footerRows.length) {
|
|
888
|
+
tfoot.remove();
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
if (tfoot.empty()) tfoot = this.container.append('tfoot');
|
|
892
|
+
tfoot.selectAll('*').remove();
|
|
893
|
+
|
|
894
|
+
footerRows.forEach(rowData => {
|
|
895
|
+
const tr = tfoot.append('tr');
|
|
896
|
+
columns.forEach(col => {
|
|
897
|
+
const classes = [];
|
|
898
|
+
if (col.align === 'right') classes.push('align-right');
|
|
899
|
+
if (col.align === 'center') classes.push('align-center');
|
|
900
|
+
if (col.className) classes.push(col.className);
|
|
901
|
+
const td = tr.append('td').attr('class', classes.join(' '));
|
|
902
|
+
|
|
903
|
+
const value = rowData[col.key];
|
|
904
|
+
if (value === undefined || value === null) return;
|
|
905
|
+
td.html(this.formatCellContent(col, value, rowData));
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Render a cell with a leading image (flag / logo / headshot) next to
|
|
912
|
+
* the value. col.image names the row field holding the image URL.
|
|
913
|
+
* Options: imageShape 'circle' | 'rounded' (default) | 'square';
|
|
914
|
+
* imageSize px override (token --table-image-size otherwise);
|
|
915
|
+
* imageFit 'cover' | 'contain' (default: cover for circle —
|
|
916
|
+
* headshots crop well — contain otherwise, so logos letterbox).
|
|
917
|
+
* Composes with subtitle (image + two-line stack), format, and
|
|
918
|
+
* render. Rows with a falsy image field keep an invisible slot so
|
|
919
|
+
* text stays aligned; broken URLs hide themselves the same way.
|
|
920
|
+
* @private
|
|
921
|
+
*/
|
|
922
|
+
renderImageCell(cells, col) {
|
|
923
|
+
cells.classed('has-image', true);
|
|
924
|
+
|
|
925
|
+
const shape = col.imageShape || 'rounded';
|
|
926
|
+
const fit = col.imageFit || (shape === 'circle' ? 'cover' : 'contain');
|
|
927
|
+
const self = this;
|
|
928
|
+
|
|
929
|
+
cells.each(function(d) {
|
|
930
|
+
const cell = d3.select(this);
|
|
931
|
+
const wrapper = cell.append('div').attr('class', 'cell-with-image');
|
|
932
|
+
|
|
933
|
+
const src = d[col.image];
|
|
934
|
+
if (src) {
|
|
935
|
+
wrapper.append('img')
|
|
936
|
+
.attr('class', `d3-table-img img-${shape} img-fit-${fit}`)
|
|
937
|
+
.attr('src', src)
|
|
938
|
+
.attr('alt', '')
|
|
939
|
+
.attr('loading', 'lazy')
|
|
940
|
+
.on('error', function() { this.style.visibility = 'hidden'; })
|
|
941
|
+
.call(img => {
|
|
942
|
+
if (col.imageSize) {
|
|
943
|
+
img.style('width', `${col.imageSize}px`).style('height', `${col.imageSize}px`);
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
} else {
|
|
947
|
+
const placeholder = wrapper.append('span')
|
|
948
|
+
.attr('class', 'd3-table-img d3-table-img-placeholder');
|
|
949
|
+
if (col.imageSize) {
|
|
950
|
+
placeholder.style('width', `${col.imageSize}px`).style('height', `${col.imageSize}px`);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
if (col.subtitle) {
|
|
955
|
+
const stack = wrapper.append('div').attr('class', 'cell-with-subtitle');
|
|
956
|
+
stack.append('div').attr('class', 'cell-main-text').text(d[col.key]);
|
|
957
|
+
stack.append('div').attr('class', 'cell-subtitle-text').text(d[col.subtitle]);
|
|
958
|
+
} else {
|
|
959
|
+
wrapper.append('span').html(self.formatCellContent(col, d[col.key], d));
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
if (col.subtitle) {
|
|
964
|
+
cells.classed('has-subtitle', true);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Render a cell with main text and subtitle
|
|
970
|
+
* @private
|
|
971
|
+
*/
|
|
972
|
+
renderSubtitleCell(cells, col) {
|
|
973
|
+
cells.classed('has-subtitle', true);
|
|
974
|
+
|
|
975
|
+
cells.each(function(d) {
|
|
976
|
+
const cell = d3.select(this);
|
|
977
|
+
const mainValue = d[col.key];
|
|
978
|
+
const subtitleValue = d[col.subtitle];
|
|
979
|
+
|
|
980
|
+
const wrapper = cell.append('div')
|
|
981
|
+
.attr('class', 'cell-with-subtitle');
|
|
982
|
+
|
|
983
|
+
wrapper.append('div')
|
|
984
|
+
.attr('class', 'cell-main-text')
|
|
985
|
+
.text(mainValue);
|
|
986
|
+
|
|
987
|
+
wrapper.append('div')
|
|
988
|
+
.attr('class', 'cell-subtitle-text')
|
|
989
|
+
.text(subtitleValue);
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Render a bar chart cell
|
|
995
|
+
* @private
|
|
996
|
+
*/
|
|
997
|
+
renderBarCell(cells, col, barConfig, domain) {
|
|
998
|
+
cells.classed('bar-cell', true);
|
|
999
|
+
|
|
1000
|
+
const colorScale = this.getColorScale(
|
|
1001
|
+
barConfig.colorScale || 'orange',
|
|
1002
|
+
domain
|
|
1003
|
+
);
|
|
1004
|
+
|
|
1005
|
+
const animationDuration = parseInt(this.getCSSVar('--animation-duration-slow', '1000'));
|
|
1006
|
+
const staggerDelay = parseInt(this.getCSSVar('--animation-stagger-delay', '50'));
|
|
1007
|
+
const barMaxWidthPercent = parseInt(this.getCSSVar('--bar-max-width-percent', '70'));
|
|
1008
|
+
const barMaxWidthHighlighted = parseInt(this.getCSSVar('--bar-max-width-percent-highlighted', '65'));
|
|
1009
|
+
const barLabelMaxPosition = parseInt(this.getCSSVar('--bar-label-max-position', '75'));
|
|
1010
|
+
|
|
1011
|
+
cells.each(function(d, i) {
|
|
1012
|
+
const cell = d3.select(this);
|
|
1013
|
+
const value = parseFloat(d[col.key]) || 0;
|
|
1014
|
+
const maxValue = domain[1];
|
|
1015
|
+
|
|
1016
|
+
const isHighlighted = cell.classed('highlight-column');
|
|
1017
|
+
const maxWidth = isHighlighted ? barMaxWidthHighlighted : barMaxWidthPercent;
|
|
1018
|
+
const widthPercent = (value / maxValue) * maxWidth;
|
|
1019
|
+
const barWidth = Math.min(widthPercent, maxWidth);
|
|
1020
|
+
|
|
1021
|
+
// Bar + label live in a track spanning the padded interior, so
|
|
1022
|
+
// their percentages are interior-relative and can't overflow the
|
|
1023
|
+
// cell padding in tight columns.
|
|
1024
|
+
const track = cell.append('div').attr('class', 'bar-track');
|
|
1025
|
+
|
|
1026
|
+
const bar = track.append('div')
|
|
1027
|
+
.attr('class', 'bar-background')
|
|
1028
|
+
.style('width', '0%')
|
|
1029
|
+
.style('background-color', colorScale(value));
|
|
1030
|
+
|
|
1031
|
+
const labelText = col.format === 'percentage' ? `${value}%` : value;
|
|
1032
|
+
const labelLeft = Math.min(Math.max(barWidth + 2, 0), barLabelMaxPosition);
|
|
1033
|
+
const label = track.append('div')
|
|
1034
|
+
.attr('class', 'bar-value')
|
|
1035
|
+
.style('left', '0%')
|
|
1036
|
+
.text(labelText);
|
|
1037
|
+
|
|
1038
|
+
const delay = i * staggerDelay;
|
|
1039
|
+
bar.transition()
|
|
1040
|
+
.delay(delay)
|
|
1041
|
+
.duration(animationDuration)
|
|
1042
|
+
.ease(d3.easeCubicOut)
|
|
1043
|
+
.style('width', `${barWidth}%`);
|
|
1044
|
+
|
|
1045
|
+
label.transition()
|
|
1046
|
+
.delay(delay)
|
|
1047
|
+
.duration(animationDuration)
|
|
1048
|
+
.ease(d3.easeCubicOut)
|
|
1049
|
+
.style('left', `${labelLeft}%`);
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Render a heatmap cell. Display text goes through the column's
|
|
1055
|
+
* format/render/emphasize (formatCellContent) so a heatmap column
|
|
1056
|
+
* reads exactly like a plain column with a color behind it; a
|
|
1057
|
+
* missing value (null/undefined/non-numeric) renders an em dash
|
|
1058
|
+
* with no fill instead of being treated as 0.
|
|
1059
|
+
* @private
|
|
1060
|
+
*/
|
|
1061
|
+
renderHeatmapCell(cells, col, heatmapConfig, domain) {
|
|
1062
|
+
cells.classed('heatmap-cell-padded', true);
|
|
1063
|
+
|
|
1064
|
+
const colorScale = this.getColorScale(
|
|
1065
|
+
heatmapConfig.colorScale || 'orange',
|
|
1066
|
+
domain
|
|
1067
|
+
);
|
|
1068
|
+
|
|
1069
|
+
const animationDuration = parseInt(this.getCSSVar('--animation-duration-normal', '500'));
|
|
1070
|
+
const staggerDelay = parseInt(this.getCSSVar('--animation-stagger-delay', '50'));
|
|
1071
|
+
const self = this;
|
|
1072
|
+
|
|
1073
|
+
cells.each(function(d, i) {
|
|
1074
|
+
const cell = d3.select(this);
|
|
1075
|
+
const raw = d[col.key];
|
|
1076
|
+
const value = parseFloat(raw);
|
|
1077
|
+
const hasValue = raw !== null && raw !== undefined && raw !== '' && !isNaN(value);
|
|
1078
|
+
|
|
1079
|
+
const box = cell.append('div')
|
|
1080
|
+
.attr('class', 'heatmap-box')
|
|
1081
|
+
.style('opacity', 0);
|
|
1082
|
+
|
|
1083
|
+
if (hasValue) {
|
|
1084
|
+
const bgColor = colorScale(value);
|
|
1085
|
+
box.style('background-color', bgColor)
|
|
1086
|
+
.style('color', getContrastColor(bgColor))
|
|
1087
|
+
.html(self.formatCellContent(col, raw, d));
|
|
1088
|
+
} else {
|
|
1089
|
+
box.classed('heatmap-box-empty', true).text('—');
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const delay = i * staggerDelay;
|
|
1093
|
+
box.transition()
|
|
1094
|
+
.delay(delay)
|
|
1095
|
+
.duration(animationDuration)
|
|
1096
|
+
.ease(d3.easeCubicOut)
|
|
1097
|
+
.style('opacity', 1);
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// ===== SORTING =====
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Sort by numeric value
|
|
1105
|
+
* @private
|
|
1106
|
+
*/
|
|
1107
|
+
numericSort(a, b, key, direction) {
|
|
1108
|
+
const valA = typeof a[key] === 'number' ? a[key] : parseFloat(a[key]) || 0;
|
|
1109
|
+
const valB = typeof b[key] === 'number' ? b[key] : parseFloat(b[key]) || 0;
|
|
1110
|
+
return direction === 'asc' ? valA - valB : valB - valA;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Sort by text value
|
|
1115
|
+
* @private
|
|
1116
|
+
*/
|
|
1117
|
+
textSort(a, b, key, direction) {
|
|
1118
|
+
const valA = (a[key] || '').toString().toLowerCase();
|
|
1119
|
+
const valB = (b[key] || '').toString().toLowerCase();
|
|
1120
|
+
const result = valA.localeCompare(valB);
|
|
1121
|
+
return direction === 'asc' ? result : -result;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Sort by W-L record format
|
|
1126
|
+
* @private
|
|
1127
|
+
*/
|
|
1128
|
+
recordSort(a, b, key, direction) {
|
|
1129
|
+
const parseRecord = (record) => {
|
|
1130
|
+
const [wins] = (record || '0-0').split('-').map(Number);
|
|
1131
|
+
return wins || 0;
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
const winsA = parseRecord(a[key]);
|
|
1135
|
+
const winsB = parseRecord(b[key]);
|
|
1136
|
+
return direction === 'asc' ? winsA - winsB : winsB - winsA;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Get the appropriate sort function for a column
|
|
1141
|
+
* @private
|
|
1142
|
+
*/
|
|
1143
|
+
getSortFunction(column) {
|
|
1144
|
+
switch(column.sortType) {
|
|
1145
|
+
case 'numeric': return this.numericSort.bind(this);
|
|
1146
|
+
case 'text': return this.textSort.bind(this);
|
|
1147
|
+
case 'record': return this.recordSort.bind(this);
|
|
1148
|
+
default: return this.numericSort.bind(this);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* Sort data by a column
|
|
1154
|
+
* @private
|
|
1155
|
+
*/
|
|
1156
|
+
sortData(columnKey, direction) {
|
|
1157
|
+
if (!this.currentData || this.currentData.length === 0) return [];
|
|
1158
|
+
|
|
1159
|
+
const column = this.config.columns.find(col => col.key === columnKey);
|
|
1160
|
+
if (!column || !column.sortable) return this.currentData;
|
|
1161
|
+
|
|
1162
|
+
const sortFunction = this.getSortFunction(column);
|
|
1163
|
+
return [...this.currentData].sort((a, b) =>
|
|
1164
|
+
sortFunction(a, b, columnKey, direction)
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* Initialize sorting functionality
|
|
1170
|
+
* @private
|
|
1171
|
+
*/
|
|
1172
|
+
initializeSorting() {
|
|
1173
|
+
if (!this.config.sortable) return;
|
|
1174
|
+
|
|
1175
|
+
this.config.columns.forEach(col => {
|
|
1176
|
+
if (!col.sortable) return;
|
|
1177
|
+
|
|
1178
|
+
const colIndex = this.config.columns.indexOf(col) + 1;
|
|
1179
|
+
this.container.select(`thead tr.d3-table-header-row th:nth-child(${colIndex})`)
|
|
1180
|
+
.on('click', () => this.handleHeaderClick(col.key));
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* First-click direction for a column: explicit defaultSort wins;
|
|
1186
|
+
* otherwise text sorts ascending, numeric/record sort descending
|
|
1187
|
+
* (best-first, the data-table convention).
|
|
1188
|
+
* @private
|
|
1189
|
+
*/
|
|
1190
|
+
firstSortDirection(column) {
|
|
1191
|
+
if (column.defaultSort) return column.defaultSort;
|
|
1192
|
+
return column.sortType === 'text' ? 'asc' : 'desc';
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Handle header click for sorting
|
|
1197
|
+
* @private
|
|
1198
|
+
*/
|
|
1199
|
+
handleHeaderClick(columnKey) {
|
|
1200
|
+
const column = this.config.columns.find(col => col.key === columnKey);
|
|
1201
|
+
|
|
1202
|
+
let newDirection;
|
|
1203
|
+
if (this.sortState.column === columnKey) {
|
|
1204
|
+
newDirection = this.sortState.direction === 'asc' ? 'desc' : 'asc';
|
|
1205
|
+
} else {
|
|
1206
|
+
newDirection = this.firstSortDirection(column);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
this.sortState.column = columnKey;
|
|
1210
|
+
this.sortState.direction = newDirection;
|
|
1211
|
+
this.userSorted = true;
|
|
1212
|
+
|
|
1213
|
+
const sortedData = this.sortData(columnKey, newDirection);
|
|
1214
|
+
const columnWidths = this.config.layout === 'fixed' ?
|
|
1215
|
+
this.calculateColumnWidths(sortedData, this.config.columns) : {};
|
|
1216
|
+
this.renderBody(sortedData, this.config.columns, columnWidths);
|
|
1217
|
+
|
|
1218
|
+
setTimeout(() => this.updateSortIndicators(), 0);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Update sort indicator visuals, aria-sort, and the sorted-column
|
|
1223
|
+
* highlight. The quiet column wash (header included) appears for
|
|
1224
|
+
* user-initiated sorts by default; highlightSortedColumn: true
|
|
1225
|
+
* forces it always on, false disables it entirely.
|
|
1226
|
+
* @private
|
|
1227
|
+
*/
|
|
1228
|
+
updateSortIndicators() {
|
|
1229
|
+
this.container.selectAll('.sort-indicator')
|
|
1230
|
+
.classed('active asc desc', false)
|
|
1231
|
+
.classed('inactive', true);
|
|
1232
|
+
|
|
1233
|
+
this.container.selectAll('th, td')
|
|
1234
|
+
.classed('sorted-column', false);
|
|
1235
|
+
|
|
1236
|
+
this.container.selectAll('thead th').each(function() {
|
|
1237
|
+
this.removeAttribute('aria-sort');
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
if (!this.sortState.column) return;
|
|
1241
|
+
|
|
1242
|
+
const columnIndex = this.config.columns.findIndex(col => col.key === this.sortState.column);
|
|
1243
|
+
if (columnIndex === -1) return;
|
|
1244
|
+
|
|
1245
|
+
const th = this.container.select(`thead tr.d3-table-header-row th:nth-child(${columnIndex + 1})`);
|
|
1246
|
+
th.select('.sort-indicator')
|
|
1247
|
+
.classed('inactive', false)
|
|
1248
|
+
.classed('active', true)
|
|
1249
|
+
.classed(this.sortState.direction, true);
|
|
1250
|
+
|
|
1251
|
+
const thNode = th.node();
|
|
1252
|
+
if (thNode) {
|
|
1253
|
+
thNode.setAttribute('aria-sort', this.sortState.direction === 'asc' ? 'ascending' : 'descending');
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const highlight = this.config.highlightSortedColumn === true ||
|
|
1257
|
+
(this.config.highlightSortedColumn !== false && this.userSorted);
|
|
1258
|
+
if (highlight) {
|
|
1259
|
+
this.container.selectAll(`tr.d3-table-header-row th:nth-child(${columnIndex + 1}), tbody td:nth-child(${columnIndex + 1})`)
|
|
1260
|
+
.classed('sorted-column', true);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// ===== PUBLIC API =====
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Render the table with data
|
|
1268
|
+
* @param {Array} data - Array of row objects
|
|
1269
|
+
*/
|
|
1270
|
+
render(data) {
|
|
1271
|
+
if (!this.prepareDom()) return;
|
|
1272
|
+
|
|
1273
|
+
this.sortState.originalOrder = [...data];
|
|
1274
|
+
this.currentData = [...data];
|
|
1275
|
+
|
|
1276
|
+
// A sortable table ships pre-sorted: the first column with a
|
|
1277
|
+
// defaultSort is applied on load (indicator visible from first
|
|
1278
|
+
// paint, no column highlight — that's reserved for user sorts).
|
|
1279
|
+
// Re-renders (update()) keep whatever sort is active.
|
|
1280
|
+
let rows = data;
|
|
1281
|
+
if (this.config.sortable && !this.sortState.column) {
|
|
1282
|
+
const defaultCol = this.config.columns.find(col => col.sortable && col.defaultSort);
|
|
1283
|
+
if (defaultCol) {
|
|
1284
|
+
this.sortState.column = defaultCol.key;
|
|
1285
|
+
this.sortState.direction = defaultCol.defaultSort;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
if (this.sortState.column) {
|
|
1289
|
+
rows = this.sortData(this.sortState.column, this.sortState.direction);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// Expandable sub-rows: defaultExpanded opens every parent on the
|
|
1293
|
+
// first render only (later update() calls keep the user's state).
|
|
1294
|
+
if (this.config.subRows && !this.expandInitialized && this.config.defaultExpanded) {
|
|
1295
|
+
const key = this.config.subRows;
|
|
1296
|
+
rows.forEach(row => {
|
|
1297
|
+
if (Array.isArray(row[key]) && row[key].length) this.expandedRows.add(row);
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
this.expandInitialized = true;
|
|
1301
|
+
|
|
1302
|
+
// Fixed-mode measurement covers sub-rows too
|
|
1303
|
+
const measureRows = this.config.subRows ? this.flattenRows(rows).flat : rows;
|
|
1304
|
+
|
|
1305
|
+
const columnWidths = this.config.layout === 'fixed' ?
|
|
1306
|
+
this.calculateColumnWidths(measureRows, this.config.columns) : {};
|
|
1307
|
+
|
|
1308
|
+
// Fill layout: bar columns keep a label-aware min-width so the
|
|
1309
|
+
// end-of-bar value label can never bleed into the next column.
|
|
1310
|
+
this.barColumnMinWidths = {};
|
|
1311
|
+
this.fillMinWidths = {};
|
|
1312
|
+
if (this.config.layout !== 'fixed' && this.config.barColumns) {
|
|
1313
|
+
this.config.barColumns.forEach(barCol => {
|
|
1314
|
+
const col = this.config.columns.find(c => c.key === barCol.key);
|
|
1315
|
+
if (col) {
|
|
1316
|
+
this.barColumnMinWidths[col.key] = this.computeBarColumnMinWidth(col, measureRows);
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
// The greedy bar column (width 99%) would otherwise crush the
|
|
1320
|
+
// text columns to min-content (wrapped dates, ballooned rows) —
|
|
1321
|
+
// give every non-bar column its measured width as a floor.
|
|
1322
|
+
const measured = this.calculateColumnWidths(measureRows, this.config.columns);
|
|
1323
|
+
const barKeys = new Set(this.config.barColumns.map(b => b.key));
|
|
1324
|
+
const wrapFloor = parseInt(this.getCSSVar('--table-wrap-min-width', '110'));
|
|
1325
|
+
Object.keys(measured).forEach(key => {
|
|
1326
|
+
if (barKeys.has(key)) return;
|
|
1327
|
+
const col = this.config.columns.find(c => c.key === key);
|
|
1328
|
+
// A wrap column's whole point is shrinking below its
|
|
1329
|
+
// unwrapped content width — floor it at its explicit
|
|
1330
|
+
// minWidth (or the wrap-floor token), not the measurement.
|
|
1331
|
+
this.fillMinWidths[key] = col?.wrap ?
|
|
1332
|
+
Math.min(measured[key], col.minWidth || wrapFloor) :
|
|
1333
|
+
measured[key];
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
this.renderHeaders(this.config.columns, columnWidths);
|
|
1338
|
+
this.renderBody(rows, this.config.columns, columnWidths);
|
|
1339
|
+
this.renderFooter(this.config.columns);
|
|
1340
|
+
this.renderShowAllControl();
|
|
1341
|
+
this.initializeSorting();
|
|
1342
|
+
this.updateSortIndicators();
|
|
1343
|
+
this.adjustBarColumnFlex();
|
|
1344
|
+
this.observeContainerResize();
|
|
1345
|
+
|
|
1346
|
+
// Two-row sticky headers: the label row sticks below the group
|
|
1347
|
+
// row, offset by the group row's measured height.
|
|
1348
|
+
if (this.config.stickyHeader) {
|
|
1349
|
+
const groupRow = this.container.select('thead tr.d3-table-group-row').node();
|
|
1350
|
+
if (groupRow) {
|
|
1351
|
+
const offset = groupRow.getBoundingClientRect().height;
|
|
1352
|
+
this.container.selectAll('thead tr.d3-table-header-row th')
|
|
1353
|
+
.style('top', `${offset}px`);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// Page-level sticky header (no maxHeight): the horizontal scroll
|
|
1358
|
+
// wrapper would swallow position: sticky, so — re-checked every
|
|
1359
|
+
// render — a table that FITS its container gets real sticky via
|
|
1360
|
+
// overflow-x: clip, and a table that OVERFLOWS gets the floating
|
|
1361
|
+
// header (scroll-driven thead translate) instead.
|
|
1362
|
+
if (this.config.stickyHeader && typeof this.config.maxHeight !== 'number') {
|
|
1363
|
+
const node = this.container.node();
|
|
1364
|
+
const scroll = node.closest('.d3-table-scroll');
|
|
1365
|
+
if (scroll) {
|
|
1366
|
+
const fits = node.scrollWidth <= scroll.clientWidth + 1;
|
|
1367
|
+
scroll.classList.toggle('d3-table-scroll--clip', fits);
|
|
1368
|
+
this.setFloatingHeader(!fits);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
this.textMeasurer.destroy();
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Grow fill-layout bar columns from their label-fit floor toward their
|
|
1377
|
+
* cap (col.maxWidth or --bar-column-max-width) using the container
|
|
1378
|
+
* slack actually available: with the table at fit-content width, the
|
|
1379
|
+
* spare card width first widens the bars, and only what the bars
|
|
1380
|
+
* can't use stays outside the table as whitespace. Runs after every
|
|
1381
|
+
* render and on container resize; narrow containers keep the floor.
|
|
1382
|
+
* @private
|
|
1383
|
+
*/
|
|
1384
|
+
adjustBarColumnFlex() {
|
|
1385
|
+
if (this.config.layout === 'fixed') return;
|
|
1386
|
+
const barCols = (this.config.barColumns || [])
|
|
1387
|
+
.map(b => this.config.columns.find(c => c.key === b.key))
|
|
1388
|
+
.filter(Boolean);
|
|
1389
|
+
if (!barCols.length) return;
|
|
1390
|
+
|
|
1391
|
+
const tbl = this.container.node();
|
|
1392
|
+
if (!tbl || !tbl.isConnected) return;
|
|
1393
|
+
const scroll = tbl.closest('.d3-table-scroll');
|
|
1394
|
+
const avail = (scroll || tbl.parentElement)?.clientWidth || 0;
|
|
1395
|
+
if (!avail) return;
|
|
1396
|
+
|
|
1397
|
+
const cellsFor = (col) => {
|
|
1398
|
+
const idx = this.config.columns.indexOf(col) + 1;
|
|
1399
|
+
return tbl.querySelectorAll(
|
|
1400
|
+
`tr.d3-table-header-row > th:nth-child(${idx}), ` +
|
|
1401
|
+
`tbody > tr > td:nth-child(${idx}), tfoot > tr > td:nth-child(${idx})`);
|
|
1402
|
+
};
|
|
1403
|
+
|
|
1404
|
+
// Measure the table with every bar column at its floor…
|
|
1405
|
+
barCols.forEach(col => {
|
|
1406
|
+
const floor = this.barColumnMinWidths?.[col.key] || 0;
|
|
1407
|
+
cellsFor(col).forEach(n => { n.style.width = floor ? `${floor}px` : ''; });
|
|
1408
|
+
});
|
|
1409
|
+
const slack = Math.max(0, avail - tbl.offsetWidth);
|
|
1410
|
+
if (!slack) return;
|
|
1411
|
+
|
|
1412
|
+
// …then share the slack, capped per column.
|
|
1413
|
+
const barMaxWidth = parseInt(this.getCSSVar('--bar-column-max-width', '250'));
|
|
1414
|
+
barCols.forEach(col => {
|
|
1415
|
+
const floor = this.barColumnMinWidths?.[col.key] || 0;
|
|
1416
|
+
const cap = Math.max(floor, col.maxWidth || barMaxWidth);
|
|
1417
|
+
const width = Math.min(cap, floor + Math.floor(slack / barCols.length));
|
|
1418
|
+
cellsFor(col).forEach(n => { n.style.width = `${width}px`; });
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* Re-run the bar-column flex whenever the host container resizes.
|
|
1424
|
+
* One observer per table instance; disconnected in destroy().
|
|
1425
|
+
* @private
|
|
1426
|
+
*/
|
|
1427
|
+
observeContainerResize() {
|
|
1428
|
+
if (this._flexRO || typeof ResizeObserver === 'undefined') return;
|
|
1429
|
+
const tbl = this.container.node();
|
|
1430
|
+
const host = tbl?.closest('.d3-table-scroll')?.parentElement || tbl?.parentElement;
|
|
1431
|
+
if (!host) return;
|
|
1432
|
+
let scheduled = false;
|
|
1433
|
+
this._flexRO = new ResizeObserver(() => {
|
|
1434
|
+
if (scheduled) return;
|
|
1435
|
+
scheduled = true;
|
|
1436
|
+
requestAnimationFrame(() => {
|
|
1437
|
+
scheduled = false;
|
|
1438
|
+
this.adjustBarColumnFlex();
|
|
1439
|
+
});
|
|
1440
|
+
});
|
|
1441
|
+
this._flexRO.observe(host);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* Floating header: sticky emulation for tables that also scroll
|
|
1446
|
+
* horizontally. position: sticky cannot escape the horizontal
|
|
1447
|
+
* scroll container, so a page scroll/resize listener translates the
|
|
1448
|
+
* thead down by however far the table top has passed the viewport
|
|
1449
|
+
* top (clamped near the table bottom). Horizontal scroll still
|
|
1450
|
+
* moves the header with its columns. The thead is re-queried on
|
|
1451
|
+
* every tick so it survives re-renders. (Sports-reference solves
|
|
1452
|
+
* the same problem the same way. Note: transforms on table-row-
|
|
1453
|
+
* groups are solid in Chromium/Firefox; older Safari is the reason
|
|
1454
|
+
* this stays behind the overflow check rather than replacing real
|
|
1455
|
+
* position: sticky everywhere.)
|
|
1456
|
+
* @private
|
|
1457
|
+
*/
|
|
1458
|
+
setFloatingHeader(enable) {
|
|
1459
|
+
if (!enable) {
|
|
1460
|
+
if (this.floatingHeaderCleanup) {
|
|
1461
|
+
this.floatingHeaderCleanup();
|
|
1462
|
+
this.floatingHeaderCleanup = null;
|
|
1463
|
+
}
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
if (this.floatingHeaderCleanup) return;
|
|
1467
|
+
|
|
1468
|
+
const table = this.container.node();
|
|
1469
|
+
const update = () => {
|
|
1470
|
+
const thead = table.querySelector('thead');
|
|
1471
|
+
if (!thead) return;
|
|
1472
|
+
const rect = table.getBoundingClientRect();
|
|
1473
|
+
const theadH = thead.getBoundingClientRect().height;
|
|
1474
|
+
const maxOffset = Math.max(0, rect.height - theadH * 2);
|
|
1475
|
+
const offset = Math.min(Math.max(0, -rect.top), maxOffset);
|
|
1476
|
+
thead.style.transform = offset > 0 ? `translateY(${offset}px)` : '';
|
|
1477
|
+
thead.classList.toggle('d3-table-thead--floating', offset > 0);
|
|
1478
|
+
};
|
|
1479
|
+
window.addEventListener('scroll', update, { passive: true });
|
|
1480
|
+
window.addEventListener('resize', update, { passive: true });
|
|
1481
|
+
update();
|
|
1482
|
+
|
|
1483
|
+
this.floatingHeaderCleanup = () => {
|
|
1484
|
+
window.removeEventListener('scroll', update);
|
|
1485
|
+
window.removeEventListener('resize', update);
|
|
1486
|
+
const thead = table.querySelector('thead');
|
|
1487
|
+
if (thead) {
|
|
1488
|
+
thead.style.transform = '';
|
|
1489
|
+
thead.classList.remove('d3-table-thead--floating');
|
|
1490
|
+
}
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* Update table with new data
|
|
1496
|
+
* @param {Array} data - New data array
|
|
1497
|
+
*/
|
|
1498
|
+
update(data) {
|
|
1499
|
+
this.render(data);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/**
|
|
1503
|
+
* Re-render the current data in place; the active sort and expanded
|
|
1504
|
+
* sub-rows are kept (render() re-applies sortState). Use after a theme
|
|
1505
|
+
* change: bar and heatmap cell colors resolve design tokens at render
|
|
1506
|
+
* time, so a [data-theme] flip needs a re-render to show up.
|
|
1507
|
+
*/
|
|
1508
|
+
rerender() {
|
|
1509
|
+
this.render(this.currentData);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/**
|
|
1513
|
+
* Sort table by a column programmatically
|
|
1514
|
+
* @param {string} columnKey - Column key to sort by
|
|
1515
|
+
* @param {string} [direction='asc'] - Sort direction ('asc' or 'desc')
|
|
1516
|
+
*/
|
|
1517
|
+
sort(columnKey, direction = 'asc') {
|
|
1518
|
+
this.sortState.column = columnKey;
|
|
1519
|
+
this.sortState.direction = direction;
|
|
1520
|
+
|
|
1521
|
+
const sortedData = this.sortData(columnKey, direction);
|
|
1522
|
+
const columnWidths = this.config.layout === 'fixed' ?
|
|
1523
|
+
this.calculateColumnWidths(sortedData, this.config.columns) : {};
|
|
1524
|
+
this.renderBody(sortedData, this.config.columns, columnWidths);
|
|
1525
|
+
this.adjustBarColumnFlex();
|
|
1526
|
+
this.updateSortIndicators();
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* Reset table to original data order
|
|
1531
|
+
*/
|
|
1532
|
+
reset() {
|
|
1533
|
+
this.sortState.column = null;
|
|
1534
|
+
this.sortState.direction = null;
|
|
1535
|
+
this.userSorted = false;
|
|
1536
|
+
this.currentData = [...this.sortState.originalOrder];
|
|
1537
|
+
|
|
1538
|
+
const columnWidths = this.config.layout === 'fixed' ?
|
|
1539
|
+
this.calculateColumnWidths(this.currentData, this.config.columns) : {};
|
|
1540
|
+
this.renderBody(this.currentData, this.config.columns, columnWidths);
|
|
1541
|
+
this.adjustBarColumnFlex();
|
|
1542
|
+
this.updateSortIndicators();
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/**
|
|
1546
|
+
* Destroy the table and clean up
|
|
1547
|
+
*/
|
|
1548
|
+
destroy() {
|
|
1549
|
+
this.textMeasurer.destroy();
|
|
1550
|
+
this.setFloatingHeader(false);
|
|
1551
|
+
if (this._flexRO) {
|
|
1552
|
+
this._flexRO.disconnect();
|
|
1553
|
+
this._flexRO = null;
|
|
1554
|
+
}
|
|
1555
|
+
// The show-all control lives OUTSIDE thead/tbody (a sibling of the
|
|
1556
|
+
// scroll wrapper) — remove it explicitly or it outlives the table.
|
|
1557
|
+
const node = this.container.node();
|
|
1558
|
+
if (node) {
|
|
1559
|
+
const scroll = node.closest('.d3-table-scroll');
|
|
1560
|
+
const host = node.closest('.d3-table-wrapper') ||
|
|
1561
|
+
(scroll ? scroll.parentElement : node.parentElement);
|
|
1562
|
+
host?.querySelector(`.d3-table-showmore[data-for="${this.containerId}"]`)?.remove();
|
|
1563
|
+
}
|
|
1564
|
+
this.container.select('thead').selectAll('*').remove();
|
|
1565
|
+
this.container.select('tbody').selectAll('*').remove();
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// Default export
|
|
1570
|
+
export default D3Table;
|