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