/** * usePretextColumnWidths * * Computes flex-based column width parameters using pretext text measurement. * - flexBasis: mean (μ) of measured widths + padding * - flexGrow/flexShrink: derived from variance (σ²) * - minWidth: max(μ - 2*σ, 50) + padding * - maxWidth: 300px * * User can override any parameter via column definition. */ import { computed, type Ref } from "vue"; import { measureShrinkWrapWidth } from "./measureText"; import type { ListTableColumn } from "./types"; const DEFAULT_FONT = "14px Inter, sans-serif"; const DEFAULT_HEADER_FONT = "bold 14px Inter, sans-serif"; const DEFAULT_PADDING = 16; const CELL_PADDING = DEFAULT_PADDING * 2; // left + right const MAX_WIDTH = 300; const MIN_BASE_WIDTH = 50; const MIN_AUTO_FLEX = 1.1; const MAX_AUTO_FLEX = 2; export interface ColumnFlexConfig { key: string; flexBasis: number; flexGrow: number; flexShrink: number; minWidth: number; maxWidth: number; measuredMean: number; measuredVariance: number; measuredSampleCount: number; } /** * Sample rows evenly distributed across the dataset. * Takes first, last, and evenly spaced rows in between. */ function sampleRows(data: T[], sampleSize: number): T[] { if (data.length <= sampleSize) return data; const result: T[] = []; const step = (data.length - 1) / (sampleSize - 1); for (let i = 0; i < sampleSize; i++) { result.push(data[Math.round(i * step)]); } return result; } /** * Compute mean and variance of an array of numbers */ function computeStats(widths: number[]): { mean: number; variance: number } { if (widths.length === 0) return { mean: 0, variance: 0 }; const mean = widths.reduce((sum, w) => sum + w, 0) / widths.length; const variance = widths.reduce((sum, w) => sum + Math.pow(w - mean, 2), 0) / widths.length; return { mean, variance }; } /** * Derive flexGrow/flexShrink from variance score * varianceScore = min(1, σ / (μ * 0.5)) * flex = 1.1 + varianceScore * 0.9 → range [1.1, 2.0] */ function varianceToFlex(mean: number, variance: number): number { if (mean <= 0) return MIN_AUTO_FLEX; const stdDev = Math.sqrt(variance); const varianceScore = Math.min(1, stdDev / (mean * 0.5)); return MIN_AUTO_FLEX + varianceScore * (MAX_AUTO_FLEX - MIN_AUTO_FLEX); } /** * Compute actual column widths using a flexbox-inspired distribution algorithm. * - First, compute raw flex widths based on available space * - Then, enforce minWidth/maxWidth constraints iteratively * - If constraints cause overflow, reduce widths proportionally while respecting mins */ export function computeFlexWidths(configs: ColumnFlexConfig[], containerWidth: number): number[] { if (!configs.length || containerWidth <= 0) { return configs.map(() => 0); } // Calculate total flex basis const totalFlexBasis = configs.reduce((sum, c) => sum + c.flexBasis, 0); if (totalFlexBasis === 0) { // Edge case: all zero bases - distribute evenly const evenWidth = containerWidth / configs.length; return configs.map((c) => clamp(evenWidth, c.minWidth, c.maxWidth)); } // Calculate raw flex widths let widths = computeRawFlexWidths(configs, containerWidth, totalFlexBasis); // Apply min/max constraints iteratively until stable // This handles the case where clamping some columns causes others to overflow for (let iteration = 0; iteration < 10; iteration++) { // Apply constraints widths = widths.map((w, i) => clamp(w, configs[i].minWidth, configs[i].maxWidth)); const totalWidth = widths.reduce((sum, w) => sum + w, 0); if (totalWidth <= containerWidth) { // Fits! Distribute remaining space proportionally based on flexGrow const remaining = containerWidth - totalWidth; if (remaining > 0) { const totalFlexGrow = configs.reduce((sum, c) => sum + c.flexGrow * c.flexBasis, 0); if (totalFlexGrow > 0) { widths = widths.map((w, i) => { const growAmount = (configs[i].flexGrow * configs[i].flexBasis) / totalFlexGrow * remaining; return w + growAmount; }); } } break; } // Overflow - need to reduce widths that are above their minimums const overflow = totalWidth - containerWidth; // Calculate how much each column can be reduced (only those above minWidth) const reducible = widths.map((w, i) => Math.max(0, w - configs[i].minWidth)); const totalReducible = reducible.reduce((sum, r) => sum + r, 0); if (totalReducible === 0) { // All at minimum - force even distribution const evenWidth = containerWidth / configs.length; widths = configs.map((c) => clamp(evenWidth, c.minWidth, c.maxWidth)); break; } // Reduce proportionally based on how much each column can be reduced widths = widths.map((w, i) => { const reduction = reducible[i] / totalReducible * overflow; return Math.max(w - reduction, configs[i].minWidth); }); } // Final clamp widths = widths.map((w, i) => clamp(w, configs[i].minWidth, configs[i].maxWidth)); return widths; } /** * Compute raw flex widths without min/max constraints */ function computeRawFlexWidths(configs: ColumnFlexConfig[], containerWidth: number, totalFlexBasis: number): number[] { const availableSpace = containerWidth - totalFlexBasis; if (availableSpace === 0) { return configs.map((c) => c.flexBasis); } if (availableSpace > 0) { // Grow phase: distribute extra space proportionally by flexGrow * flexBasis const totalGrowFactor = configs.reduce((sum, c) => sum + c.flexGrow * c.flexBasis, 0); if (totalGrowFactor === 0) { return configs.map((c) => c.flexBasis); } return configs.map((c) => { const growAmount = (c.flexGrow * c.flexBasis) / totalGrowFactor * availableSpace; return c.flexBasis + growAmount; }); } else { // Shrink phase: remove space proportionally by flexShrink * flexBasis const totalShrinkFactor = configs.reduce((sum, c) => sum + c.flexShrink * c.flexBasis, 0); if (totalShrinkFactor === 0) { // No shrink - distribute loss evenly const evenShrink = (-availableSpace) / configs.length; return configs.map((c) => Math.max(0, c.flexBasis - evenShrink)); } return configs.map((c) => { const shrinkAmount = (c.flexShrink * c.flexBasis) / totalShrinkFactor * (-availableSpace); return Math.max(0, c.flexBasis - shrinkAmount); }); } } function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } export function usePretextColumnWidths( data: Ref, columns: Ref[]>, containerWidth: Ref, options?: { font?: string; headerFont?: string; sampleSize?: number; } ) { const font = options?.font ?? DEFAULT_FONT; const headerFont = options?.headerFont ?? DEFAULT_HEADER_FONT; const sampleSize = options?.sampleSize ?? 100; const computedConfigs = computed(() => { if (!columns.value.length) return []; const sampled = sampleRows(data.value, sampleSize); const configs: ColumnFlexConfig[] = []; for (const col of columns.value) { const colKey = String(col.key); // User overrides (convert string values to numbers) const userFlexGrow = col.flexGrow; const userFlexShrink = col.flexShrink; const userFlexBasis = col.flexBasis !== undefined && col.flexBasis !== "auto" ? Number(col.flexBasis) : undefined; const userMinWidth = col.minWidth !== undefined ? Number(col.minWidth) : undefined; const userMaxWidth = col.maxWidth !== undefined ? Number(col.maxWidth) : undefined; // Measure header width const headerText = col.name || col.i18n || colKey; const headerWidth = measureShrinkWrapWidth(headerText, headerFont) + CELL_PADDING; // Measure sampled cell widths const cellWidths: number[] = []; for (const row of sampled) { const rawValue = (row as any)[col.dataKey || colKey]; const cellText = rawValue == null ? "" : String(rawValue); if (cellText) { const w = measureShrinkWrapWidth(cellText, font) + CELL_PADDING; cellWidths.push(w); } } // Include header in stats const allWidths = [headerWidth, ...cellWidths]; const { mean, variance } = computeStats(allWidths); // Derived values const flexBasis = userFlexBasis ?? mean; const minWidth = userMinWidth ? Number(userMinWidth) : Math.max(mean - 2 * Math.sqrt(variance), MIN_BASE_WIDTH) + CELL_PADDING; const maxWidth = userMaxWidth ? Number(userMaxWidth) : MAX_WIDTH; const flexGrow = userFlexGrow ?? varianceToFlex(mean, variance); const flexShrink = userFlexShrink ?? varianceToFlex(mean, variance); configs.push({ key: colKey, flexBasis, flexGrow, flexShrink, minWidth, maxWidth, measuredMean: mean, measuredVariance: variance, measuredSampleCount: allWidths.length, }); } return configs; }); // Total flex basis (sum of all flexBasis values) const totalFlexBasis = computed(() => computedConfigs.value.reduce((sum, c) => sum + c.flexBasis, 0)); // Actual column widths computed using flexbox algorithm const columnWidths = computed(() => computeFlexWidths(computedConfigs.value, containerWidth.value)); return { computedConfigs, totalFlexBasis, columnWidths, }; }