|
|
|
|
/**
|
|
|
|
|
* 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: unconstrained by default
|
|
|
|
|
*
|
|
|
|
|
* 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 = Number.POSITIVE_INFINITY;
|
|
|
|
|
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<T>(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<T>(
|
|
|
|
|
data: Ref<T[]>,
|
|
|
|
|
columns: Ref<ListTableColumn<T>[]>,
|
|
|
|
|
containerWidth: Ref<number>,
|
|
|
|
|
options?: {
|
|
|
|
|
font?: string;
|
|
|
|
|
headerFont?: string;
|
|
|
|
|
sampleSize?: number;
|
|
|
|
|
formatCellValue?: (row: T, col: ListTableColumn<T>) => string;
|
|
|
|
|
}
|
|
|
|
|
) {
|
|
|
|
|
const font = options?.font ?? DEFAULT_FONT;
|
|
|
|
|
const headerFont = options?.headerFont ?? DEFAULT_HEADER_FONT;
|
|
|
|
|
const sampleSize = options?.sampleSize ?? 100;
|
|
|
|
|
const formatCellValue = options?.formatCellValue;
|
|
|
|
|
|
|
|
|
|
const computedConfigs = computed<ColumnFlexConfig[]>(() => {
|
|
|
|
|
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.isFinite(Number(col.maxWidth)) ? 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 = formatCellValue?.(row, col) ?? (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 ?? 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,
|
|
|
|
|
};
|
|
|
|
|
}
|