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.
@@ -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
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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;