chart-factory 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/dist/chart-factory-table.js +1400 -0
- package/dist/chart-factory.css +2368 -0
- package/dist/chart-factory.js +11398 -0
- package/dist/chart-factory.min.js +7 -0
- package/docs/API.md +1815 -0
- package/docs/chart-guide.md +872 -0
- package/docs/color-guide.md +134 -0
- package/docs/conformance-matrix.md +864 -0
- package/docs/theming.md +107 -0
- package/index.d.ts +10453 -0
- package/package.json +83 -0
- package/src/core/base.css +508 -0
- package/src/core/builder-metadata.generated.mjs +406 -0
- package/src/core/d3-base.js +249 -0
- package/src/core/d3-chart-factory.js +18547 -0
- package/src/core/d3-data.js +0 -0
- package/src/core/d3-suggest.js +565 -0
- package/src/core/example-nav.css +62 -0
- package/src/core/example-nav.js +249 -0
- package/src/core/palettes.js +310 -0
- package/src/core/theme-toggle.js +126 -0
- package/src/core/tokens.css +792 -0
- package/src/table/d3-table.js +1570 -0
- package/src/table/table.css +899 -0
- package/src/table/tokens.css +157 -0
|
@@ -0,0 +1,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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
+
};
|