Table: Refactor row height to work in pxs instead of lines (#109735)

* Table: Refactor row height to work in pxs instead of lines

* non-string pill value handling, internal cleanup on buildCellHeightMeasurers

* no need to round up to max line
This commit is contained in:
Paul Marbach
2025-08-20 16:31:20 -04:00
committed by GitHub
parent fbca70747c
commit 950423903f
7 changed files with 278 additions and 256 deletions
@@ -61,6 +61,17 @@ describe('PillCell', () => {
`
);
});
it('non-string values', () => {
expectHTML(
render(<PillCell field={fieldWithValues(['[100,200,300]'])} rowIdx={0} theme={theme} />),
`
<span style="background-color: rgb(252, 226, 222); color: rgb(0, 0, 0);">100</span>
<span style="background-color: rgb(222, 218, 247); color: rgb(0, 0, 0);">200</span>
<span style="background-color: rgb(249, 217, 249); color: rgb(0, 0, 0);">300</span>
`
);
});
});
describe('Color by value mappings', () => {
@@ -49,12 +49,12 @@ interface Pill {
const SPLIT_RE = /\s*,\s*/;
const TRANSPARENT = 'rgba(0,0,0,0)';
function createPills(pillValues: string[], field: Field, theme: GrafanaTheme2): Pill[] {
function createPills(pillValues: unknown[], field: Field, theme: GrafanaTheme2): Pill[] {
return pillValues.map((pill, index) => {
const bgColor = getPillColor(pill, field, theme);
const textColor = colorManipulator.getContrastRatio('#FFFFFF', bgColor) >= 4.5 ? '#FFFFFF' : '#000000';
return {
value: pill,
value: String(pill),
key: `${pill}-${index}`,
bgColor,
color: textColor,
@@ -62,7 +62,7 @@ function createPills(pillValues: string[], field: Field, theme: GrafanaTheme2):
});
}
export function inferPills(rawValue: TableCellValue): string[] {
export function inferPills(rawValue: TableCellValue): unknown[] {
if (rawValue === '' || rawValue == null) {
return [];
}
@@ -81,7 +81,7 @@ export function inferPills(rawValue: TableCellValue): string[] {
}
// FIXME: this does not yet support "shades of a color"
function getPillColor(value: string, field: Field, theme: GrafanaTheme2): string {
function getPillColor(value: unknown, field: Field, theme: GrafanaTheme2): string {
const cfg = field.config;
if (cfg.mappings?.length ?? 0 > 0) {
@@ -101,7 +101,7 @@ function getPillColor(value: string, field: Field, theme: GrafanaTheme2): string
}
}
return getColorByStringHash(colors, value);
return getColorByStringHash(colors, String(value));
}
export const getStyles: TableCellStyles = (theme, { textWrap, shouldOverflow }) =>
@@ -477,7 +477,7 @@ describe('TableNG hooks', () => {
}),
columnWidths: [100, 100, 100],
enabled: true,
typographyCtx: { ...typographyCtx, avgCharWidth: 5, wrappedCount: jest.fn(() => 2) },
typographyCtx: { ...typographyCtx, avgCharWidth: 5, measureHeight: jest.fn(() => 38) },
sortColumns: [],
});
});
@@ -486,7 +486,7 @@ describe('TableNG hooks', () => {
});
it('should calculate the available width for a header cell based on the icons rendered within it', () => {
const countFn = jest.fn(() => 1);
const heightFn = jest.fn(() => 20);
const { fields } = setupData();
@@ -512,13 +512,13 @@ describe('TableNG hooks', () => {
fields: modifiedFields,
columnWidths: [100, 100, 100],
enabled: true,
typographyCtx: { ...typographyCtx, wrappedCount: countFn },
typographyCtx: { ...typographyCtx, measureHeight: heightFn },
sortColumns: [],
showTypeIcons: false,
});
});
expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 86, modifiedFields[0], -1);
expect(heightFn).toHaveBeenCalledWith('Longer name that needs wrapping', 86, modifiedFields[0], -1, 6);
modifiedFields = fields.map((field) => {
if (field.name === 'name') {
@@ -543,13 +543,13 @@ describe('TableNG hooks', () => {
fields: modifiedFields,
columnWidths: [100, 100, 100],
enabled: true,
typographyCtx: { ...typographyCtx, wrappedCount: countFn },
typographyCtx: { ...typographyCtx, measureHeight: heightFn },
sortColumns: [{ columnKey: 'Longer name that needs wrapping', direction: 'ASC' }],
showTypeIcons: true,
});
});
expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 26, modifiedFields[0], -1);
expect(heightFn).toHaveBeenCalledWith('Longer name that needs wrapping', 26, modifiedFields[0], -1, 6);
});
});
@@ -685,9 +685,9 @@ describe('TableNG hooks', () => {
});
});
// we test the lineCounters and getRowHeight directly to check that all of that
// math is working correctly. we mainly want to confirm here that the
// cache is clearing and that the local logic in this hook works.
// we test the cell height measurerers and getRowHeight directly to check
//that all of that math is working correctly. we mainly want to confirm that
// the cache is clearing and that the local logic in this hook works.
describe('wrapped columns', () => {
let rows: TableRow[];
let fieldsWithWrappedText: Field[];
@@ -749,14 +749,14 @@ describe('TableNG hooks', () => {
it('adjusts the width of the columns based on the cell padding and border', () => {
fieldsWithWrappedText[0].values[0] = 'Annie Lennox';
const wrappedCountFn = jest.fn(() => 2);
const estimateLinesFn = jest.fn(() => 2);
const measureHeightFn = jest.fn(() => 40);
const estimateHeightFn = jest.fn(() => 40);
const { result } = renderHook(() => {
const rowHeight = useRowHeight({
fields: fieldsWithWrappedText,
columnWidths: [100, 100, 100],
defaultHeight: 40,
typographyCtx: { ...typographyCtx, wrappedCount: wrappedCountFn, estimateLines: estimateLinesFn },
typographyCtx: { ...typographyCtx, measureHeight: measureHeightFn, estimateHeight: estimateHeightFn },
hasNestedFrames: false,
expandedRows: new Set(),
});
@@ -768,11 +768,12 @@ describe('TableNG hooks', () => {
expect(result.current(rows[0])).toEqual(expect.any(Number));
expect(estimateLinesFn).toHaveBeenCalledWith(
expect(measureHeightFn).toHaveBeenCalledWith(
'Annie Lennox',
100 - TABLE.CELL_PADDING * 2 - TABLE.BORDER_RIGHT,
fieldsWithWrappedText[0],
0
0,
22
);
});
});
@@ -3,7 +3,7 @@ import { Column, DataGridHandle, DataGridProps, SortColumn } from 'react-data-gr
import { Field, fieldReducers, FieldType, formattedValueToString, reduceField } from '@grafana/data';
import { TableCellDisplayMode, TableColumnResizeActionCallback } from '../types';
import { TableColumnResizeActionCallback } from '../types';
import { TABLE } from './constants';
import { FilterType, TableFooterCalc, TableRow, TableSortByFieldState, TableSummaryRow, TypographyCtx } from './types';
@@ -13,9 +13,8 @@ import {
applySort,
getColumnTypes,
getRowHeight,
buildHeaderLineCounters,
buildRowLineCounters,
getCellOptions,
buildHeaderHeightMeasurers,
buildCellHeightMeasurers,
} from './utils';
// Helper function to get displayed value
@@ -341,7 +340,7 @@ export function useHeaderHeight({
}: UseHeaderHeightOptions): number {
const perIconSpace = ICON_WIDTH + ICON_GAP;
const lineCounters = useMemo(() => buildHeaderLineCounters(fields, typographyCtx), [fields, typographyCtx]);
const measurers = useMemo(() => buildHeaderHeightMeasurers(fields, typographyCtx), [fields, typographyCtx]);
const columnAvailableWidths = useMemo(
() =>
@@ -369,16 +368,8 @@ export function useHeaderHeight({
if (!enabled) {
return 0;
}
return getRowHeight(
fields,
-1,
columnAvailableWidths,
TABLE.HEADER_HEIGHT,
lineCounters,
TABLE.LINE_HEIGHT,
TABLE.CELL_PADDING
);
}, [fields, enabled, columnAvailableWidths, lineCounters]);
return getRowHeight(fields, -1, columnAvailableWidths, TABLE.HEADER_HEIGHT, measurers, TABLE.CELL_PADDING);
}, [fields, enabled, columnAvailableWidths, measurers]);
return headerHeight;
}
@@ -400,8 +391,8 @@ export function useRowHeight({
expandedRows,
typographyCtx,
}: UseRowHeightOptions): NonNullable<CSSProperties['height']> | ((row: TableRow) => number) {
const lineCounters = useMemo(() => buildRowLineCounters(fields, typographyCtx), [fields, typographyCtx]);
const hasWrappedCols = useMemo(() => lineCounters?.length ?? 0 > 0, [lineCounters]);
const measurers = useMemo(() => buildCellHeightMeasurers(fields, typographyCtx), [fields, typographyCtx]);
const hasWrappedCols = useMemo(() => measurers?.length ?? 0 > 0, [measurers]);
const colWidths = useMemo(() => {
const columnWidthAffordance = 2 * TABLE.CELL_PADDING + TABLE.BORDER_RIGHT;
@@ -437,27 +428,11 @@ export function useRowHeight({
// regular rows
let result = cache[row.__index];
if (!result) {
result = cache[row.__index] = getRowHeight(
fields,
row.__index,
colWidths,
defaultHeight,
lineCounters,
TABLE.LINE_HEIGHT,
(field, numLines) => {
// Pill cells have vertical padding between each row
if (getCellOptions(field).type === TableCellDisplayMode.Pill) {
return TABLE.CELL_PADDING * (numLines - 1) + TABLE.CELL_PADDING * 2;
}
// default vertical padding for cells
return TABLE.CELL_PADDING * 2;
}
);
result = cache[row.__index] = getRowHeight(fields, row.__index, colWidths, defaultHeight, measurers);
}
return result;
};
}, [hasNestedFrames, hasWrappedCols, defaultHeight, fields, colWidths, lineCounters, expandedRows]);
}, [hasNestedFrames, hasWrappedCols, defaultHeight, fields, colWidths, measurers, expandedRows]);
return rowHeight;
}
@@ -279,24 +279,30 @@ export interface TypographyCtx {
fontFamily: string;
letterSpacing: number;
avgCharWidth: number;
estimateLines: LineCounter;
wrappedCount: LineCounter;
estimateHeight: MeasureCellHeight;
measureHeight: MeasureCellHeight;
}
export type LineCounter = (value: unknown, width: number, field: Field, rowIdx: number) => number;
export interface LineCounterEntry {
export type MeasureCellHeight = (
value: unknown,
width: number,
field: Field,
rowIdx: number,
lineHeight: number
) => number;
export interface MeasureCellHeightEntry {
/**
* given a values and the available width, returns the line count for that value
*/
counter: LineCounter;
measure: MeasureCellHeight;
/**
* if getting an accurate line count is expensive, you can provide an estimate method
* which will be used when looping over the row. the counter method will only be invoked
* which will be used when looping over the row. the method will only be invoked
* for the cell which is the maximum line count for the row.
*/
estimate?: LineCounter;
estimate?: MeasureCellHeight;
/**
* indicates which field indexes of the visible fields this line counter applies to.
* indicates which field indexes of the visible fields this measurer applies to.
*/
fieldIdxs: number[];
}
@@ -18,7 +18,7 @@ import { BarGaugeDisplayMode, TableCellBackgroundDisplayMode, TableCellHeight }
import { TableCellDisplayMode } from '../types';
import { COLUMN, TABLE } from './constants';
import { LineCounterEntry } from './types';
import { MeasureCellHeightEntry } from './types';
import {
extractPixelValue,
frameToRecords,
@@ -34,15 +34,15 @@ import {
getColumnTypes,
computeColWidths,
getRowHeight,
buildRowLineCounters,
buildHeaderLineCounters,
getTextLineEstimator,
buildCellHeightMeasurers,
buildHeaderHeightMeasurers,
getTextHeightEstimator,
createTypographyContext,
applySort,
SINGLE_LINE_ESTIMATE_THRESHOLD,
wrapUwrapCount,
getDataLinksCounter,
getPillLineCounter,
getTextHeightMeasurerFromUwrapCount,
getDataLinksHeightMeasurer,
getPillCellHeightMeasurer,
getDefaultRowHeight,
getDisplayName,
predicateByName,
@@ -850,49 +850,49 @@ describe('TableNG utils', () => {
ctx: expect.any(CanvasRenderingContext2D),
fontFamily: 'sans-serif',
letterSpacing: 0.15,
wrappedCount: expect.any(Function),
estimateLines: expect.any(Function),
measureHeight: expect.any(Function),
estimateHeight: expect.any(Function),
avgCharWidth: expect.any(Number),
})
);
expect(ctx.wrappedCount('the quick brown fox jumps over the lazy dog', 100, field, 0)).toEqual(
expect(ctx.measureHeight('the quick brown fox jumps over the lazy dog', 100, field, 0, 20)).toEqual(
expect.any(Number)
);
expect(ctx.estimateLines('the quick brown fox jumps over the lazy dog', 100, field, 0)).toEqual(
expect(ctx.estimateHeight('the quick brown fox jumps over the lazy dog', 100, field, 0, 20)).toEqual(
expect.any(Number)
);
});
});
describe('wrapUwrapCount', () => {
describe('getTextHeightMeasurerFromUwrapCount', () => {
const field: Field = { name: 'test', type: FieldType.string, config: {}, values: ['foo', 'bar', 'baz'] };
it('wraps the uwrap count function', () => {
const wrappedCount = wrapUwrapCount(jest.fn(() => 2));
expect(wrappedCount('test string', 100, field, 0)).toBe(2);
const measureHeight = getTextHeightMeasurerFromUwrapCount(jest.fn(() => 2));
expect(measureHeight('test string', 100, field, 0, 20)).toBe(40);
});
it('returns 1 for null or undefined values', () => {
const wrappedCount = wrapUwrapCount(jest.fn(() => 2));
expect(wrappedCount(null, 100, field, 0)).toBe(1);
expect(wrappedCount(undefined, 100, field, 0)).toBe(1);
it("returns a single line's height for null or undefined values", () => {
const measureHeight = getTextHeightMeasurerFromUwrapCount(jest.fn(() => 2));
expect(measureHeight(null, 100, field, 0, 20)).toBe(20);
expect(measureHeight(undefined, 100, field, 0, 20)).toBe(20);
});
});
describe('getTextLineEstimator', () => {
const counter = getTextLineEstimator(10);
describe('getTextHeightEstimator', () => {
const estimator = getTextHeightEstimator(10);
const field: Field = { name: 'test', type: FieldType.string, config: {}, values: ['foo', 'bar', 'baz'] };
it('returns -1 if there are no strings or dashes within the string', () => {
expect(counter('asdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdf', 5, field, 0)).toBe(-1);
expect(estimator('asdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdf', 5, field, 0, 22)).toBe(-1);
});
it('calculates an approximate rendered height for the text based on the width and avgCharWidth', () => {
expect(counter('asdfas dfasdfasdf asdfasdfasdfa sdfasdfasdfasdf 23', 200, field, 0)).toBe(2.5);
expect(estimator('asdfas dfasdfasdf asdfasdfasdfa sdfasdfasdfasdf 23', 200, field, 0, 20)).toBe(60);
});
});
describe('getDataLinksCounter', () => {
describe('getDataLinksHeightMeasurer', () => {
it('counts number of valid links using getCellLinks', () => {
const field: Field = {
name: 'test',
@@ -911,58 +911,58 @@ describe('TableNG utils', () => {
values: ['value1'],
};
const counter = getDataLinksCounter();
expect(counter('my value', 100, field, 0)).toBe(2);
const measurer = getDataLinksHeightMeasurer();
expect(measurer('my value', 100, field, 0, 20)).toBe(40);
});
});
describe('getPillLineCounter', () => {
describe('getPillCellHeightMeasurer', () => {
it('counts up the number of lines using the pill measuring method', () => {
const counter = getPillLineCounter(jest.fn((str) => str.length * 5));
expect(counter('tag1,tag2', 100, {} as Field, 0)).toBe(1);
expect(counter('tag1,tag2,tag3,tag4,tag5,tag6', 100, {} as Field, 0)).toBe(3);
const measurer = getPillCellHeightMeasurer(jest.fn((str) => str.length * 5));
expect(measurer('tag1,tag2', 100, {} as Field, 0, 20)).toBe(20);
expect(measurer('tag1,tag2,tag3,tag4,tag5,tag6', 100, {} as Field, 0, 20)).toBe(68);
});
it('returns 0 if value is null', () => {
const counter = getPillLineCounter(jest.fn((str) => str.length * 5));
expect(counter(null, 100, {} as Field, 0)).toBe(0);
const measurer = getPillCellHeightMeasurer(jest.fn((str) => str.length * 5));
expect(measurer(null, 100, {} as Field, 0, 20)).toBe(0);
});
it('returns 0 if no pills are inferred', () => {
const counter = getPillLineCounter(jest.fn((str) => str.length * 5));
expect(counter('', 100, {} as Field, 0)).toBe(0);
const measurer = getPillCellHeightMeasurer(jest.fn((str) => str.length * 5));
expect(measurer('', 100, {} as Field, 0, 20)).toBe(0);
});
it('caches the width measurement for the same value', () => {
const widthMeasurement = jest.fn((str) => str.length * 5);
const counter = getPillLineCounter(widthMeasurement);
counter('tag1,tag2,tag3,tag4,tag5,tag6', 100, {} as Field, 0);
counter('tag1,tag2', 100, {} as Field, 0);
counter('tag2', 200, {} as Field, 0);
counter('tag2,tag3,tag2,tag4,tag4,tag2,tag5', 300, {} as Field, 0);
const measurer = getPillCellHeightMeasurer(widthMeasurement);
measurer('tag1,tag2,tag3,tag4,tag5,tag6', 100, {} as Field, 0, 20);
measurer('tag1,tag2', 100, {} as Field, 0, 20);
measurer('tag2', 200, {} as Field, 0, 20);
measurer('tag2,tag3,tag2,tag4,tag4,tag2,tag5', 300, {} as Field, 0, 20);
expect(widthMeasurement).toHaveBeenCalledTimes(6); // Should only call for unique values
});
});
describe('buildHeaderLineCounters', () => {
describe('buildHeaderHeightMeasurers', () => {
const ctx = {
fontFamily: 'sans-serif',
letterSpacing: 0.15,
ctx: {} as CanvasRenderingContext2D,
count: jest.fn(() => 2),
avgCharWidth: 7,
wrappedCount: jest.fn(() => 2),
estimateLines: jest.fn(() => 2),
measureHeight: jest.fn(() => 2),
estimateHeight: jest.fn(() => 2),
};
it('returns an array of line counters for each column', () => {
it('returns an array of measurers for each column', () => {
const fields: Field[] = [
{ name: 'Name', type: FieldType.string, values: [], config: { custom: { wrapHeaderText: true } } },
{ name: 'Age', type: FieldType.number, values: [], config: { custom: { wrapHeaderText: true } } },
];
const counters = buildHeaderLineCounters(fields, ctx);
expect(counters![0].counter).toEqual(expect.any(Function));
expect(counters![0].fieldIdxs).toEqual([0, 1]);
const measurers = buildHeaderHeightMeasurers(fields, ctx);
expect(measurers![0].measure).toEqual(expect.any(Function));
expect(measurers![0].fieldIdxs).toEqual([0, 1]);
});
it('does not return the index of columns which are not wrapped', () => {
@@ -971,8 +971,8 @@ describe('TableNG utils', () => {
{ name: 'Age', type: FieldType.number, values: [], config: { custom: { wrapHeaderText: true } } },
];
const counters = buildHeaderLineCounters(fields, ctx);
expect(counters![0].fieldIdxs).toEqual([1]);
const measurers = buildHeaderHeightMeasurers(fields, ctx);
expect(measurers![0].fieldIdxs).toEqual([1]);
});
it('returns undefined if no columns are wrapped', () => {
@@ -981,22 +981,22 @@ describe('TableNG utils', () => {
{ name: 'Age', type: FieldType.number, values: [], config: { custom: {} } },
];
const counters = buildHeaderLineCounters(fields, ctx);
expect(counters).toBeUndefined();
const measurers = buildHeaderHeightMeasurers(fields, ctx);
expect(measurers).toBeUndefined();
});
});
describe('buildRowLineCounters', () => {
describe('buildCellHeightMeasurers', () => {
const ctx = {
fontFamily: 'sans-serif',
letterSpacing: 0.15,
ctx: {} as CanvasRenderingContext2D,
wrappedCount: jest.fn(() => 2),
estimateLines: jest.fn(() => 2),
measureHeight: jest.fn(() => 2),
estimateHeight: jest.fn(() => 2),
avgCharWidth: 7,
};
it('sets up text line counters for each text column if wrapping is on', () => {
it('sets up text height measurers for each text column if wrapping is on', () => {
const fields: Field[] = [
{ name: 'Name', type: FieldType.string, values: [], config: { custom: { cellOptions: { wrapText: true } } } },
{
@@ -1006,9 +1006,9 @@ describe('TableNG utils', () => {
config: { custom: { cellOptions: { wrapText: true } } },
},
];
const counters = buildRowLineCounters(fields, ctx);
expect(counters![0].counter).toEqual(expect.any(Function));
expect(counters![0].fieldIdxs).toEqual([0, 1]);
const measurers = buildCellHeightMeasurers(fields, ctx);
expect(measurers![0].measure).toEqual(expect.any(Function));
expect(measurers![0].fieldIdxs).toEqual([0, 1]);
});
it('does not return the index of columns which are not wrapped', () => {
@@ -1022,8 +1022,8 @@ describe('TableNG utils', () => {
},
];
const counters = buildRowLineCounters(fields, ctx);
expect(counters![0].fieldIdxs).toEqual([1]);
const measurers = buildCellHeightMeasurers(fields, ctx);
expect(measurers![0].fieldIdxs).toEqual([1]);
});
it('sets up line counting for pills if present and wrapping is on', () => {
@@ -1035,12 +1035,12 @@ describe('TableNG utils', () => {
config: { custom: { cellOptions: { type: TableCellDisplayMode.Pill, wrapText: true } } },
},
];
const counters = buildRowLineCounters(fields, ctx);
expect(counters![0].estimate).toEqual(expect.any(Function));
expect(counters![0].estimate!('tag1,tag2', 100, fields[0], 0)).toEqual(expect.any(Number));
expect(counters![0].counter).toEqual(expect.any(Function));
expect(counters![0].counter('tag1,tag2', 100, fields[0], 0)).toEqual(expect.any(Number));
expect(counters![0].fieldIdxs).toEqual([0]);
const measurers = buildCellHeightMeasurers(fields, ctx);
expect(measurers![0].estimate).toEqual(expect.any(Function));
expect(measurers![0].estimate!('tag1,tag2', 100, fields[0], 0, 22)).toEqual(expect.any(Number));
expect(measurers![0].measure).toEqual(expect.any(Function));
expect(measurers![0].measure('tag1,tag2', 100, fields[0], 0, 22)).toEqual(expect.any(Number));
expect(measurers![0].fieldIdxs).toEqual([0]);
});
it('sets up line counting for datalinks if present and wrapping is on', () => {
@@ -1056,10 +1056,10 @@ describe('TableNG utils', () => {
]),
},
];
const counters = buildRowLineCounters(fields, ctx);
expect(counters![0].counter).toEqual(expect.any(Function));
expect(counters![0].counter('http://example.com/1', 100, fields[0], 0)).toEqual(expect.any(Number));
expect(counters![0].fieldIdxs).toEqual([0]);
const measurers = buildCellHeightMeasurers(fields, ctx);
expect(measurers![0].measure).toEqual(expect.any(Function));
expect(measurers![0].measure('http://example.com/1', 100, fields[0], 0, 22)).toEqual(expect.any(Number));
expect(measurers![0].fieldIdxs).toEqual([0]);
});
it('does not enable text counting for non-string fields', () => {
@@ -1068,9 +1068,9 @@ describe('TableNG utils', () => {
{ name: 'Age', type: FieldType.number, values: [], config: { custom: { cellOptions: { wrapText: true } } } },
];
const counters = buildRowLineCounters(fields, ctx);
const measurers = buildCellHeightMeasurers(fields, ctx);
// empty array - we had one column that indicated it wraps, but it was numeric, so we just ignore it
expect(counters).toEqual([]);
expect(measurers).toEqual([]);
});
it('returns an undefined if no columns are wrapped', () => {
@@ -1079,14 +1079,14 @@ describe('TableNG utils', () => {
{ name: 'Age', type: FieldType.number, values: [], config: { custom: {} } },
];
const counters = buildRowLineCounters(fields, ctx);
expect(counters).toBeUndefined();
const measurers = buildCellHeightMeasurers(fields, ctx);
expect(measurers).toBeUndefined();
});
});
describe('getRowHeight', () => {
let fields: Field[];
let counters: LineCounterEntry[];
let measurers: MeasureCellHeightEntry[];
beforeEach(() => {
fields = [
@@ -1103,91 +1103,109 @@ describe('TableNG utils', () => {
config: { custom: { cellOptions: { wrapText: true } } },
},
];
counters = [
{ counter: jest.fn((value, _length: number) => String(value).split(' ').length), fieldIdxs: [0] }, // Mocked to count words as lines
{ counter: jest.fn((value, _length: number) => Math.ceil(String(value).length / 3)), fieldIdxs: [1] }, // Mocked to return a line for every 3 digits of a number
measurers = [
{
measure: jest.fn(
(value, _length, _field, _rowIdx, lineHeight) => String(value).split(' ').length * lineHeight
),
fieldIdxs: [0],
}, // Mocked to count words as lines
{
measure: jest.fn(
(value, _length, _field, _rowIdx, lineHeight) => Math.ceil(String(value).length / 3) * lineHeight
),
fieldIdxs: [1],
}, // Mocked to return a line for every 3 digits of a number
];
});
it('should use the default height for single-line rows', () => {
// 1 line @ 20px, 10px vertical padding = 30, minimum is 36
expect(getRowHeight(fields, 0, [30, 30], 36, counters, 20, 10)).toBe(36);
expect(getRowHeight(fields, 0, [30, 30], 36, measurers, 20, 10)).toBe(36);
});
it('should use the default height for multi-line rows which are shorter than the default height', () => {
// 3 lines @ 5px, 5px vertical padding = 20, minimum is 36
expect(getRowHeight(fields, 3, [30, 30], 36, counters, 5, 5)).toBe(36);
expect(getRowHeight(fields, 3, [30, 30], 36, measurers, 5, 5)).toBe(36);
});
it('should return the row height using line counters for multi-line', () => {
it('should return the row height using line measurers for multi-line', () => {
// 3 lines @ 20px ('longer', 'one', 'here'), 10px vertical padding
expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(70);
expect(getRowHeight(fields, 3, [30, 30], 36, measurers, 20, 10)).toBe(70);
// 4 lines @ 15px (789 122 349 932), 15px vertical padding
expect(getRowHeight(fields, 4, [30, 30], 36, counters, 15, 15)).toBe(75);
expect(getRowHeight(fields, 4, [30, 30], 36, measurers, 15, 15)).toBe(75);
});
it('should take colWidths into account when calculating max wrap cell', () => {
getRowHeight(fields, 3, [50, 60], 36, counters, 20, 10);
expect(counters[0].counter).toHaveBeenCalledWith('longer one here', 50, fields[0], 3);
expect(counters[1].counter).toHaveBeenCalledWith(123456, 60, fields[1], 3);
getRowHeight(fields, 3, [50, 60], 36, measurers, 20, 10);
expect(measurers[0].measure).toHaveBeenCalledWith('longer one here', 50, fields[0], 3, 20);
expect(measurers[1].measure).toHaveBeenCalledWith(123456, 60, fields[1], 3, 20);
});
// this is used to calc wrapped header height
it('should use the display name if the rowIdx is -1', () => {
getRowHeight(fields, -1, [50, 60], 36, counters, 20, 10);
expect(counters[0].counter).toHaveBeenCalledWith('Name', 50, fields[0], -1);
expect(counters[1].counter).toHaveBeenCalledWith('Age', 60, fields[1], -1);
getRowHeight(fields, -1, [50, 60], 36, measurers, 20, 10);
expect(measurers[0].measure).toHaveBeenCalledWith('Name', 50, fields[0], -1, 20);
expect(measurers[1].measure).toHaveBeenCalledWith('Age', 60, fields[1], -1, 20);
});
it('should ignore columns which do not have line counters', () => {
const height = getRowHeight(fields, 3, [30, 30], 36, [counters[1]], 20, 10);
it('should ignore columns which do not have measurers', () => {
const height = getRowHeight(fields, 3, [30, 30], 36, [measurers[1]], 20, 10);
// 2 lines @ 20px, 10px vertical padding (not 3 lines, since we don't line count Name)
expect(height).toBe(50);
});
it('should return the default height if there are no counters to apply', () => {
it('should return the default height if there are no measurers to apply', () => {
const height = getRowHeight(fields, 3, [30, 30], 36, [], 20, 10);
expect(height).toBe(36);
});
describe('estimations vs. precise counts', () => {
beforeEach(() => {
counters = [
{ counter: jest.fn((value, _length: number) => String(value).split(' ').length), fieldIdxs: [0] }, // Mocked to count words as lines
measurers = [
{
estimate: jest.fn((value) => String(value).length), // Mocked to return a line for every digits of a number
counter: jest.fn((value, _length: number) => Math.ceil(String(value).length / 3)),
measure: jest.fn(
(value, _length, _field, _rowIdx, lineHeight) => String(value).split(' ').length * lineHeight
),
fieldIdxs: [0],
}, // Mocked to count words as lines
{
measure: jest.fn(
(value, _length, _field, _rowIdx, lineHeight) => Math.ceil(String(value).length / 3) * lineHeight
),
estimate: jest.fn((value, _length, _field, _rowIdx, lineHeight) => String(value).length * lineHeight), // Mocked to return a line for every digits of a number
fieldIdxs: [1],
},
}, // Mocked to return a line for every 3 digits of a number
];
});
// 2 lines @ 20px (123,456), 10px vertical padding. when we did this before, 'longer one here' would win, making it 70px.
// the `estimate` function is picking `123456` as the longer one now (6 lines), then the `counter` function is used
// the `estimate` function is picking `123456` as the longer one now (6 lines), then the `measure` function is used
// to calculate the height (2 lines). this is a very forced case, but we just want to prove that it actually works.
it('uses the estimate value rather than the precise value to select the row height', () => {
expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(50);
expect(getRowHeight(fields, 3, [30, 30], 36, measurers, 20, 10)).toBe(50);
});
it('returns doesnt bother getting the precise count if the estimates are all below the threshold', () => {
jest.mocked(counters[0].counter).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.3);
jest.mocked(counters[1].estimate!).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.1);
jest.mocked(measurers[0].measure).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.3);
jest.mocked(measurers[1].estimate!).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.1);
expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(36);
expect(getRowHeight(fields, 3, [30, 30], 36, measurers, 20, 10)).toBe(36);
// this is what we really care about - we want to save on performance by not calling the counter in this case.
expect(counters[1].counter).not.toHaveBeenCalled();
// this is what we really care about - we want to save on performance by not calling the measure in this case.
expect(measurers[1].measure).not.toHaveBeenCalled();
});
it('uses the precise count if the estimate is above the threshold, even if its below 1', () => {
// NOTE: if this fails, just change the test to use a different value besides 0.1
expect(SINGLE_LINE_ESTIMATE_THRESHOLD + 0.1).toBeLessThan(1);
// NOTE: if this fails, just change the test to use a different value besides 1
const thresholdOffset = 1;
expect(SINGLE_LINE_ESTIMATE_THRESHOLD + thresholdOffset).toBeLessThan(TABLE.LINE_HEIGHT);
jest.mocked(counters[0].counter).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.3);
jest.mocked(counters[1].estimate!).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD + 0.1);
jest.mocked(measurers[0].measure).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - thresholdOffset);
jest.mocked(measurers[1].estimate!).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD + thresholdOffset);
expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(50);
expect(getRowHeight(fields, 3, [30, 30], 36, measurers, 20, 10)).toBe(50);
});
});
});
@@ -34,8 +34,8 @@ import {
FrameToRowsConverter,
Comparator,
TypographyCtx,
LineCounter,
LineCounterEntry,
MeasureCellHeight,
MeasureCellHeightEntry,
} from './types';
/* ---------------------------- Cell calculations --------------------------- */
@@ -112,29 +112,30 @@ export function createTypographyContext(fontSize: number, fontFamily: string, le
fontFamily,
letterSpacing,
avgCharWidth,
estimateLines: getTextLineEstimator(avgCharWidth),
wrappedCount: wrapUwrapCount(count),
estimateHeight: getTextHeightEstimator(avgCharWidth),
measureHeight: getTextHeightMeasurerFromUwrapCount(count),
};
}
/**
* @internal wraps the uwrap count function to ensure that it is given a string.
*/
export function wrapUwrapCount(count: Count): LineCounter {
return (value, width) => {
export function getTextHeightMeasurerFromUwrapCount(count: Count): MeasureCellHeight {
return (value, width, _field, _rowIdx, lineHeight) => {
if (value == null) {
return 1;
return lineHeight;
}
return count(String(value), width);
const lines = count(String(value), width);
return lines * lineHeight;
};
}
/**
* @internal returns a line counter which guesstimates a number of lines in a text cell based on the typography context's avgCharWidth.
* @internal returns a measurer which guesstimates a number of lines in a text cell based on the typography context's avgCharWidth.
*/
export function getTextLineEstimator(avgCharWidth: number): LineCounter {
return (value, width) => {
export function getTextHeightEstimator(avgCharWidth: number): MeasureCellHeight {
return (value, width, _field, _rowIdx, lineHeight) => {
if (!value) {
return -1;
}
@@ -147,20 +148,21 @@ export function getTextLineEstimator(avgCharWidth: number): LineCounter {
}
const charsPerLine = width / avgCharWidth;
return strValue.length / charsPerLine;
const lines = Math.ceil(strValue.length / charsPerLine);
return lines * lineHeight;
};
}
/**
* @internal
*/
export function getDataLinksCounter(): LineCounter {
export function getDataLinksHeightMeasurer(): MeasureCellHeight {
const linksCountCache: Record<string, number> = {};
// when we render links, we need to filter out the invalid links. since the call to `getLinks` is expensive,
// we'll cache the result and reuse it for every row in the table. this cache is cleared when line counts are
// rebuilt anytime from the `useRowHeight` hook, and that includes adding and removing data links.
return (_value, _width, field) => {
return (_value, _width, field, _rowIdx, lineHeight) => {
const cacheKey = getDisplayName(field);
if (linksCountCache[cacheKey] === undefined) {
let count = 0;
@@ -172,7 +174,7 @@ export function getDataLinksCounter(): LineCounter {
linksCountCache[cacheKey] = count;
}
return linksCountCache[cacheKey];
return linksCountCache[cacheKey] * lineHeight;
};
}
@@ -180,10 +182,10 @@ const PILLS_FONT_SIZE = 12;
const PILLS_SPACING = 12; // 6px horizontal padding on each side
const PILLS_GAP = 4; // gap between pills
export function getPillLineCounter(measureWidth: (value: string) => number): LineCounter {
export function getPillCellHeightMeasurer(measureWidth: (value: string) => number): MeasureCellHeight {
const widthCache: Record<string, number> = {};
return (value, width) => {
return (value, width, _field, _rowIdx, lineHeight) => {
if (value == null) {
return 0;
}
@@ -197,10 +199,11 @@ export function getPillLineCounter(measureWidth: (value: string) => number): Lin
let currentLineUse = width;
for (const pillValue of pillValues) {
let rawWidth = widthCache[pillValue];
const strPill = String(pillValue);
let rawWidth = widthCache[strPill];
if (rawWidth === undefined) {
rawWidth = measureWidth(pillValue);
widthCache[pillValue] = rawWidth;
rawWidth = measureWidth(strPill);
widthCache[strPill] = rawWidth;
}
const pillWidth = rawWidth + PILLS_SPACING;
@@ -212,14 +215,19 @@ export function getPillLineCounter(measureWidth: (value: string) => number): Lin
}
}
return lines;
// default line height happens to be the height of a pill, but maybe we need a custom
// const here to make sure this doesn't get out of sync with the actual pill height.
return lines * lineHeight + (lines - 1) * PILLS_GAP;
};
}
/**
* @internal return a text line counter for every field which has wrapHeaderText enabled.
* @internal return a text measurer for every field which has wrapHeaderText enabled.
*/
export function buildHeaderLineCounters(fields: Field[], typographyCtx: TypographyCtx): LineCounterEntry[] | undefined {
export function buildHeaderHeightMeasurers(
fields: Field[],
typographyCtx: TypographyCtx
): MeasureCellHeightEntry[] | undefined {
const wrappedColIdxs = fields.reduce((acc: number[], field, idx) => {
if (field.config?.custom?.wrapHeaderText) {
acc.push(idx);
@@ -233,19 +241,55 @@ export function buildHeaderLineCounters(fields: Field[], typographyCtx: Typograp
// don't bother with estimating the line counts for the headers, because it's punishing
// when we get it wrong and there won't be that many compared to how many rows a table might contain.
return [{ counter: typographyCtx.wrappedCount, fieldIdxs: wrappedColIdxs }];
return [{ measure: typographyCtx.measureHeight, fieldIdxs: wrappedColIdxs }];
}
const spaceRegex = /[\s-]/;
/**
* @internal return a text line counter for every field which has wrapHeaderText enabled. we do this once as we're rendering
* @internal return a text height measurer for every field which has wrapHeaderText enabled. we do this once as we're rendering
* the table, and then getRowHeight uses the output of this to caluclate the height of each row.
*/
export function buildRowLineCounters(fields: Field[], typographyCtx: TypographyCtx): LineCounterEntry[] | undefined {
const result: Record<string, LineCounterEntry> = {};
export function buildCellHeightMeasurers(
fields: Field[],
typographyCtx: TypographyCtx
): MeasureCellHeightEntry[] | undefined {
const result: Record<string, MeasureCellHeightEntry> = {};
let wrappedFields = 0;
const measurerFactory: Record<
TableCellDisplayMode.Auto | TableCellDisplayMode.DataLinks | TableCellDisplayMode.Pill,
() => [MeasureCellHeight, MeasureCellHeight?]
> = {
// for string fields, we estimate the length of a line using `avgCharWidth` to limit expensive calls `count`.
[TableCellDisplayMode.Auto]: () => [typographyCtx.measureHeight, typographyCtx.estimateHeight],
[TableCellDisplayMode.DataLinks]: () => [getDataLinksHeightMeasurer(), undefined],
// pills use a different font size, so they require their own typography context.
[TableCellDisplayMode.Pill]: () => {
const pillTypographyCtx = createTypographyContext(
PILLS_FONT_SIZE,
typographyCtx.fontFamily,
typographyCtx.letterSpacing
);
return [
getPillCellHeightMeasurer((value) => pillTypographyCtx.ctx.measureText(value).width),
getPillCellHeightMeasurer((value) => value.length * pillTypographyCtx.avgCharWidth),
];
},
} as const;
const setupMeasurerForIdx = (measurerFactoryKey: keyof typeof measurerFactory, fieldIdx: number) => {
if (!result[measurerFactoryKey]) {
const [measure, estimate] = measurerFactory[measurerFactoryKey]();
result[measurerFactoryKey] = {
measure,
estimate,
fieldIdxs: [],
};
}
result[measurerFactoryKey].fieldIdxs.push(fieldIdx);
};
for (let fieldIdx = 0; fieldIdx < fields.length; fieldIdx++) {
const field = fields[fieldIdx];
if (shouldTextWrap(field)) {
@@ -253,36 +297,11 @@ export function buildRowLineCounters(fields: Field[], typographyCtx: TypographyC
const cellType = getCellOptions(field).type;
if (cellType === TableCellDisplayMode.DataLinks) {
result.dataLinksCounter = result.dataLinksCounter ?? {
counter: getDataLinksCounter(),
fieldIdxs: [],
};
result.dataLinksCounter.fieldIdxs.push(fieldIdx);
setupMeasurerForIdx(TableCellDisplayMode.DataLinks, fieldIdx);
} else if (cellType === TableCellDisplayMode.Pill) {
if (!result.pillCounter) {
const pillTypographyCtx = createTypographyContext(
PILLS_FONT_SIZE,
typographyCtx.fontFamily,
typographyCtx.letterSpacing
);
result.pillCounter = {
estimate: getPillLineCounter((value) => value.length * pillTypographyCtx.avgCharWidth),
counter: getPillLineCounter((value) => pillTypographyCtx.ctx.measureText(value).width),
fieldIdxs: [],
};
}
result.pillCounter.fieldIdxs.push(fieldIdx);
}
// for string fields, we estimate the length of a line using `avgCharWidth` to limit expensive calls `count`.
else if (field.type === FieldType.string) {
result.textCounter = result.textCounter ?? {
counter: typographyCtx.wrappedCount,
estimate: typographyCtx.estimateLines,
fieldIdxs: [],
};
result.textCounter.fieldIdxs.push(fieldIdx);
setupMeasurerForIdx(TableCellDisplayMode.Pill, fieldIdx);
} else if (field.type === FieldType.string) {
setupMeasurerForIdx(TableCellDisplayMode.Auto, fieldIdx);
}
}
}
@@ -294,10 +313,10 @@ export function buildRowLineCounters(fields: Field[], typographyCtx: TypographyC
return Object.values(result);
}
// in some cases, the estimator might return a value that is less than 1, but when measured by the counter, it actually
// in some cases, the estimator might return a value that is less than 1, but when calculated by the measurer, it actually
// realizes that it's a multi-line cell. to avoid this, we want to give a little buffer away from 1 before we fully trust
// the estimator to have told us that a cell is single-line.
export const SINGLE_LINE_ESTIMATE_THRESHOLD = 0.85;
export const SINGLE_LINE_ESTIMATE_THRESHOLD = 18.5;
/**
* @internal
@@ -309,27 +328,25 @@ export function getRowHeight(
rowIdx: number,
columnWidths: number[],
defaultHeight: number,
lineCounters?: LineCounterEntry[],
measurers?: MeasureCellHeightEntry[],
lineHeight = TABLE.LINE_HEIGHT,
// when this is a function, the field which was measured as the maximum size will be returned, as well as the
// calculated number of lines, so that the consumer can use it in case the vertical padding value differs field-by-field.
verticalPadding: number | ((field: Field, numLines: number) => number) = TABLE.CELL_PADDING
verticalPadding = TABLE.CELL_PADDING * 2
): number {
if (!lineCounters?.length) {
if (!measurers?.length) {
return defaultHeight;
}
let maxLines = -1;
let maxHeight = -1;
let maxValue = '';
let maxWidth = 0;
let maxField: Field | undefined;
let preciseCounter: LineCounter | undefined;
let preciseMeasurer: MeasureCellHeight | undefined;
for (const { estimate, counter, fieldIdxs } of lineCounters) {
// for some of the line counters, getting the precise count of the lines is expensive. those line counters
// set both an "estimate" and a "counter" function. if the cell we find to be the max was estimated, we will
// get the "true" value right before calculating the row height by hanging onto a reference to the counter fn.
const count = estimate ?? counter;
for (const { estimate, measure, fieldIdxs } of measurers) {
// for some of the cell height measurers, getting the precise height is expensive. those entries set
// both "estimate" and "measure" functions. if the cell we find to be the max was estimated, we will
// get the "true" value right before calculating the row height by keeping a reference to the measure fn.
const measurer = (estimate ?? measure) satisfies MeasureCellHeight;
const isEstimating = estimate !== undefined;
for (const fieldIdx of fieldIdxs) {
@@ -338,38 +355,32 @@ export function getRowHeight(
const cellValueRaw = rowIdx === -1 ? getDisplayName(field) : field.values[rowIdx];
if (cellValueRaw != null) {
const colWidth = columnWidths[fieldIdx];
const approxLines = count(cellValueRaw, colWidth, field, rowIdx);
if (approxLines > maxLines) {
maxLines = approxLines;
const estimatedHeight = measurer(cellValueRaw, colWidth, field, rowIdx, lineHeight);
if (estimatedHeight > maxHeight) {
maxHeight = estimatedHeight;
maxValue = cellValueRaw;
maxWidth = colWidth;
maxField = field;
preciseCounter = isEstimating ? counter : undefined;
preciseMeasurer = isEstimating ? measure : undefined;
}
}
}
}
// if the value is -1 or the estimate for the max cell was less than the SINGLE_LINE_ESTIMATE_THRESHOLD, we trust
// that the estimator correctly identified that no text wrapping is needed for this row, skipping the preciseCounter.
if (maxField === undefined || maxLines < SINGLE_LINE_ESTIMATE_THRESHOLD) {
// that the estimator correctly identified that no text wrapping is needed for this row, skipping the preciseMeasurer.
if (maxField === undefined || maxHeight < SINGLE_LINE_ESTIMATE_THRESHOLD) {
return defaultHeight;
}
// if we finished this row height loop with an estimate, we need to call
// the `preciseCounter` method to get the exact line count.
if (preciseCounter !== undefined) {
maxLines = preciseCounter(maxValue, maxWidth, maxField, rowIdx);
// the `preciseMeasurer` method to get the exact line count.
if (preciseMeasurer !== undefined) {
maxHeight = preciseMeasurer(maxValue, maxWidth, maxField, rowIdx, lineHeight);
}
// round up to the nearest line before doing math
maxLines = Math.ceil(maxLines);
// adjust for vertical padding and line height, and clamp to a minimum default height
const verticalPaddingValue =
typeof verticalPadding === 'function' ? verticalPadding(maxField, maxLines) : verticalPadding;
const totalHeight = maxLines * lineHeight + verticalPaddingValue;
return Math.max(totalHeight, defaultHeight);
// adjust for vertical padding, and clamp to a minimum default height
return Math.max(maxHeight + verticalPadding, defaultHeight);
}
/**