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', () => { describe('Color by value mappings', () => {
@@ -49,12 +49,12 @@ interface Pill {
const SPLIT_RE = /\s*,\s*/; const SPLIT_RE = /\s*,\s*/;
const TRANSPARENT = 'rgba(0,0,0,0)'; 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) => { return pillValues.map((pill, index) => {
const bgColor = getPillColor(pill, field, theme); const bgColor = getPillColor(pill, field, theme);
const textColor = colorManipulator.getContrastRatio('#FFFFFF', bgColor) >= 4.5 ? '#FFFFFF' : '#000000'; const textColor = colorManipulator.getContrastRatio('#FFFFFF', bgColor) >= 4.5 ? '#FFFFFF' : '#000000';
return { return {
value: pill, value: String(pill),
key: `${pill}-${index}`, key: `${pill}-${index}`,
bgColor, bgColor,
color: textColor, 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) { if (rawValue === '' || rawValue == null) {
return []; return [];
} }
@@ -81,7 +81,7 @@ export function inferPills(rawValue: TableCellValue): string[] {
} }
// FIXME: this does not yet support "shades of a color" // 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; const cfg = field.config;
if (cfg.mappings?.length ?? 0 > 0) { 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 }) => export const getStyles: TableCellStyles = (theme, { textWrap, shouldOverflow }) =>
@@ -477,7 +477,7 @@ describe('TableNG hooks', () => {
}), }),
columnWidths: [100, 100, 100], columnWidths: [100, 100, 100],
enabled: true, enabled: true,
typographyCtx: { ...typographyCtx, avgCharWidth: 5, wrappedCount: jest.fn(() => 2) }, typographyCtx: { ...typographyCtx, avgCharWidth: 5, measureHeight: jest.fn(() => 38) },
sortColumns: [], 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', () => { 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(); const { fields } = setupData();
@@ -512,13 +512,13 @@ describe('TableNG hooks', () => {
fields: modifiedFields, fields: modifiedFields,
columnWidths: [100, 100, 100], columnWidths: [100, 100, 100],
enabled: true, enabled: true,
typographyCtx: { ...typographyCtx, wrappedCount: countFn }, typographyCtx: { ...typographyCtx, measureHeight: heightFn },
sortColumns: [], sortColumns: [],
showTypeIcons: false, 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) => { modifiedFields = fields.map((field) => {
if (field.name === 'name') { if (field.name === 'name') {
@@ -543,13 +543,13 @@ describe('TableNG hooks', () => {
fields: modifiedFields, fields: modifiedFields,
columnWidths: [100, 100, 100], columnWidths: [100, 100, 100],
enabled: true, enabled: true,
typographyCtx: { ...typographyCtx, wrappedCount: countFn }, typographyCtx: { ...typographyCtx, measureHeight: heightFn },
sortColumns: [{ columnKey: 'Longer name that needs wrapping', direction: 'ASC' }], sortColumns: [{ columnKey: 'Longer name that needs wrapping', direction: 'ASC' }],
showTypeIcons: true, 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 // we test the cell height measurerers and getRowHeight directly to check
// math is working correctly. we mainly want to confirm here that the //that all of that math is working correctly. we mainly want to confirm that
// cache is clearing and that the local logic in this hook works. // the cache is clearing and that the local logic in this hook works.
describe('wrapped columns', () => { describe('wrapped columns', () => {
let rows: TableRow[]; let rows: TableRow[];
let fieldsWithWrappedText: Field[]; let fieldsWithWrappedText: Field[];
@@ -749,14 +749,14 @@ describe('TableNG hooks', () => {
it('adjusts the width of the columns based on the cell padding and border', () => { it('adjusts the width of the columns based on the cell padding and border', () => {
fieldsWithWrappedText[0].values[0] = 'Annie Lennox'; fieldsWithWrappedText[0].values[0] = 'Annie Lennox';
const wrappedCountFn = jest.fn(() => 2); const measureHeightFn = jest.fn(() => 40);
const estimateLinesFn = jest.fn(() => 2); const estimateHeightFn = jest.fn(() => 40);
const { result } = renderHook(() => { const { result } = renderHook(() => {
const rowHeight = useRowHeight({ const rowHeight = useRowHeight({
fields: fieldsWithWrappedText, fields: fieldsWithWrappedText,
columnWidths: [100, 100, 100], columnWidths: [100, 100, 100],
defaultHeight: 40, defaultHeight: 40,
typographyCtx: { ...typographyCtx, wrappedCount: wrappedCountFn, estimateLines: estimateLinesFn }, typographyCtx: { ...typographyCtx, measureHeight: measureHeightFn, estimateHeight: estimateHeightFn },
hasNestedFrames: false, hasNestedFrames: false,
expandedRows: new Set(), expandedRows: new Set(),
}); });
@@ -768,11 +768,12 @@ describe('TableNG hooks', () => {
expect(result.current(rows[0])).toEqual(expect.any(Number)); expect(result.current(rows[0])).toEqual(expect.any(Number));
expect(estimateLinesFn).toHaveBeenCalledWith( expect(measureHeightFn).toHaveBeenCalledWith(
'Annie Lennox', 'Annie Lennox',
100 - TABLE.CELL_PADDING * 2 - TABLE.BORDER_RIGHT, 100 - TABLE.CELL_PADDING * 2 - TABLE.BORDER_RIGHT,
fieldsWithWrappedText[0], 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 { Field, fieldReducers, FieldType, formattedValueToString, reduceField } from '@grafana/data';
import { TableCellDisplayMode, TableColumnResizeActionCallback } from '../types'; import { TableColumnResizeActionCallback } from '../types';
import { TABLE } from './constants'; import { TABLE } from './constants';
import { FilterType, TableFooterCalc, TableRow, TableSortByFieldState, TableSummaryRow, TypographyCtx } from './types'; import { FilterType, TableFooterCalc, TableRow, TableSortByFieldState, TableSummaryRow, TypographyCtx } from './types';
@@ -13,9 +13,8 @@ import {
applySort, applySort,
getColumnTypes, getColumnTypes,
getRowHeight, getRowHeight,
buildHeaderLineCounters, buildHeaderHeightMeasurers,
buildRowLineCounters, buildCellHeightMeasurers,
getCellOptions,
} from './utils'; } from './utils';
// Helper function to get displayed value // Helper function to get displayed value
@@ -341,7 +340,7 @@ export function useHeaderHeight({
}: UseHeaderHeightOptions): number { }: UseHeaderHeightOptions): number {
const perIconSpace = ICON_WIDTH + ICON_GAP; 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( const columnAvailableWidths = useMemo(
() => () =>
@@ -369,16 +368,8 @@ export function useHeaderHeight({
if (!enabled) { if (!enabled) {
return 0; return 0;
} }
return getRowHeight( return getRowHeight(fields, -1, columnAvailableWidths, TABLE.HEADER_HEIGHT, measurers, TABLE.CELL_PADDING);
fields, }, [fields, enabled, columnAvailableWidths, measurers]);
-1,
columnAvailableWidths,
TABLE.HEADER_HEIGHT,
lineCounters,
TABLE.LINE_HEIGHT,
TABLE.CELL_PADDING
);
}, [fields, enabled, columnAvailableWidths, lineCounters]);
return headerHeight; return headerHeight;
} }
@@ -400,8 +391,8 @@ export function useRowHeight({
expandedRows, expandedRows,
typographyCtx, typographyCtx,
}: UseRowHeightOptions): NonNullable<CSSProperties['height']> | ((row: TableRow) => number) { }: UseRowHeightOptions): NonNullable<CSSProperties['height']> | ((row: TableRow) => number) {
const lineCounters = useMemo(() => buildRowLineCounters(fields, typographyCtx), [fields, typographyCtx]); const measurers = useMemo(() => buildCellHeightMeasurers(fields, typographyCtx), [fields, typographyCtx]);
const hasWrappedCols = useMemo(() => lineCounters?.length ?? 0 > 0, [lineCounters]); const hasWrappedCols = useMemo(() => measurers?.length ?? 0 > 0, [measurers]);
const colWidths = useMemo(() => { const colWidths = useMemo(() => {
const columnWidthAffordance = 2 * TABLE.CELL_PADDING + TABLE.BORDER_RIGHT; const columnWidthAffordance = 2 * TABLE.CELL_PADDING + TABLE.BORDER_RIGHT;
@@ -437,27 +428,11 @@ export function useRowHeight({
// regular rows // regular rows
let result = cache[row.__index]; let result = cache[row.__index];
if (!result) { if (!result) {
result = cache[row.__index] = getRowHeight( result = cache[row.__index] = getRowHeight(fields, row.__index, colWidths, defaultHeight, measurers);
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;
}
);
} }
return result; return result;
}; };
}, [hasNestedFrames, hasWrappedCols, defaultHeight, fields, colWidths, lineCounters, expandedRows]); }, [hasNestedFrames, hasWrappedCols, defaultHeight, fields, colWidths, measurers, expandedRows]);
return rowHeight; return rowHeight;
} }
@@ -279,24 +279,30 @@ export interface TypographyCtx {
fontFamily: string; fontFamily: string;
letterSpacing: number; letterSpacing: number;
avgCharWidth: number; avgCharWidth: number;
estimateLines: LineCounter; estimateHeight: MeasureCellHeight;
wrappedCount: LineCounter; measureHeight: MeasureCellHeight;
} }
export type LineCounter = (value: unknown, width: number, field: Field, rowIdx: number) => number; export type MeasureCellHeight = (
export interface LineCounterEntry { 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 * 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 * 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. * 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[]; fieldIdxs: number[];
} }
@@ -18,7 +18,7 @@ import { BarGaugeDisplayMode, TableCellBackgroundDisplayMode, TableCellHeight }
import { TableCellDisplayMode } from '../types'; import { TableCellDisplayMode } from '../types';
import { COLUMN, TABLE } from './constants'; import { COLUMN, TABLE } from './constants';
import { LineCounterEntry } from './types'; import { MeasureCellHeightEntry } from './types';
import { import {
extractPixelValue, extractPixelValue,
frameToRecords, frameToRecords,
@@ -34,15 +34,15 @@ import {
getColumnTypes, getColumnTypes,
computeColWidths, computeColWidths,
getRowHeight, getRowHeight,
buildRowLineCounters, buildCellHeightMeasurers,
buildHeaderLineCounters, buildHeaderHeightMeasurers,
getTextLineEstimator, getTextHeightEstimator,
createTypographyContext, createTypographyContext,
applySort, applySort,
SINGLE_LINE_ESTIMATE_THRESHOLD, SINGLE_LINE_ESTIMATE_THRESHOLD,
wrapUwrapCount, getTextHeightMeasurerFromUwrapCount,
getDataLinksCounter, getDataLinksHeightMeasurer,
getPillLineCounter, getPillCellHeightMeasurer,
getDefaultRowHeight, getDefaultRowHeight,
getDisplayName, getDisplayName,
predicateByName, predicateByName,
@@ -850,49 +850,49 @@ describe('TableNG utils', () => {
ctx: expect.any(CanvasRenderingContext2D), ctx: expect.any(CanvasRenderingContext2D),
fontFamily: 'sans-serif', fontFamily: 'sans-serif',
letterSpacing: 0.15, letterSpacing: 0.15,
wrappedCount: expect.any(Function), measureHeight: expect.any(Function),
estimateLines: expect.any(Function), estimateHeight: expect.any(Function),
avgCharWidth: expect.any(Number), 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.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) expect.any(Number)
); );
}); });
}); });
describe('wrapUwrapCount', () => { describe('getTextHeightMeasurerFromUwrapCount', () => {
const field: Field = { name: 'test', type: FieldType.string, config: {}, values: ['foo', 'bar', 'baz'] }; const field: Field = { name: 'test', type: FieldType.string, config: {}, values: ['foo', 'bar', 'baz'] };
it('wraps the uwrap count function', () => { it('wraps the uwrap count function', () => {
const wrappedCount = wrapUwrapCount(jest.fn(() => 2)); const measureHeight = getTextHeightMeasurerFromUwrapCount(jest.fn(() => 2));
expect(wrappedCount('test string', 100, field, 0)).toBe(2); expect(measureHeight('test string', 100, field, 0, 20)).toBe(40);
}); });
it('returns 1 for null or undefined values', () => { it("returns a single line's height for null or undefined values", () => {
const wrappedCount = wrapUwrapCount(jest.fn(() => 2)); const measureHeight = getTextHeightMeasurerFromUwrapCount(jest.fn(() => 2));
expect(wrappedCount(null, 100, field, 0)).toBe(1); expect(measureHeight(null, 100, field, 0, 20)).toBe(20);
expect(wrappedCount(undefined, 100, field, 0)).toBe(1); expect(measureHeight(undefined, 100, field, 0, 20)).toBe(20);
}); });
}); });
describe('getTextLineEstimator', () => { describe('getTextHeightEstimator', () => {
const counter = getTextLineEstimator(10); const estimator = getTextHeightEstimator(10);
const field: Field = { name: 'test', type: FieldType.string, config: {}, values: ['foo', 'bar', 'baz'] }; 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', () => { 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', () => { 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', () => { it('counts number of valid links using getCellLinks', () => {
const field: Field = { const field: Field = {
name: 'test', name: 'test',
@@ -911,58 +911,58 @@ describe('TableNG utils', () => {
values: ['value1'], values: ['value1'],
}; };
const counter = getDataLinksCounter(); const measurer = getDataLinksHeightMeasurer();
expect(counter('my value', 100, field, 0)).toBe(2); 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', () => { it('counts up the number of lines using the pill measuring method', () => {
const counter = getPillLineCounter(jest.fn((str) => str.length * 5)); const measurer = getPillCellHeightMeasurer(jest.fn((str) => str.length * 5));
expect(counter('tag1,tag2', 100, {} as Field, 0)).toBe(1); expect(measurer('tag1,tag2', 100, {} as Field, 0, 20)).toBe(20);
expect(counter('tag1,tag2,tag3,tag4,tag5,tag6', 100, {} as Field, 0)).toBe(3); expect(measurer('tag1,tag2,tag3,tag4,tag5,tag6', 100, {} as Field, 0, 20)).toBe(68);
}); });
it('returns 0 if value is null', () => { it('returns 0 if value is null', () => {
const counter = getPillLineCounter(jest.fn((str) => str.length * 5)); const measurer = getPillCellHeightMeasurer(jest.fn((str) => str.length * 5));
expect(counter(null, 100, {} as Field, 0)).toBe(0); expect(measurer(null, 100, {} as Field, 0, 20)).toBe(0);
}); });
it('returns 0 if no pills are inferred', () => { it('returns 0 if no pills are inferred', () => {
const counter = getPillLineCounter(jest.fn((str) => str.length * 5)); const measurer = getPillCellHeightMeasurer(jest.fn((str) => str.length * 5));
expect(counter('', 100, {} as Field, 0)).toBe(0); expect(measurer('', 100, {} as Field, 0, 20)).toBe(0);
}); });
it('caches the width measurement for the same value', () => { it('caches the width measurement for the same value', () => {
const widthMeasurement = jest.fn((str) => str.length * 5); const widthMeasurement = jest.fn((str) => str.length * 5);
const counter = getPillLineCounter(widthMeasurement); const measurer = getPillCellHeightMeasurer(widthMeasurement);
counter('tag1,tag2,tag3,tag4,tag5,tag6', 100, {} as Field, 0); measurer('tag1,tag2,tag3,tag4,tag5,tag6', 100, {} as Field, 0, 20);
counter('tag1,tag2', 100, {} as Field, 0); measurer('tag1,tag2', 100, {} as Field, 0, 20);
counter('tag2', 200, {} as Field, 0); measurer('tag2', 200, {} as Field, 0, 20);
counter('tag2,tag3,tag2,tag4,tag4,tag2,tag5', 300, {} as Field, 0); measurer('tag2,tag3,tag2,tag4,tag4,tag2,tag5', 300, {} as Field, 0, 20);
expect(widthMeasurement).toHaveBeenCalledTimes(6); // Should only call for unique values expect(widthMeasurement).toHaveBeenCalledTimes(6); // Should only call for unique values
}); });
}); });
describe('buildHeaderLineCounters', () => { describe('buildHeaderHeightMeasurers', () => {
const ctx = { const ctx = {
fontFamily: 'sans-serif', fontFamily: 'sans-serif',
letterSpacing: 0.15, letterSpacing: 0.15,
ctx: {} as CanvasRenderingContext2D, ctx: {} as CanvasRenderingContext2D,
count: jest.fn(() => 2), count: jest.fn(() => 2),
avgCharWidth: 7, avgCharWidth: 7,
wrappedCount: jest.fn(() => 2), measureHeight: jest.fn(() => 2),
estimateLines: 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[] = [ const fields: Field[] = [
{ name: 'Name', type: FieldType.string, values: [], config: { custom: { wrapHeaderText: true } } }, { name: 'Name', type: FieldType.string, values: [], config: { custom: { wrapHeaderText: true } } },
{ name: 'Age', type: FieldType.number, values: [], config: { custom: { wrapHeaderText: true } } }, { name: 'Age', type: FieldType.number, values: [], config: { custom: { wrapHeaderText: true } } },
]; ];
const counters = buildHeaderLineCounters(fields, ctx); const measurers = buildHeaderHeightMeasurers(fields, ctx);
expect(counters![0].counter).toEqual(expect.any(Function)); expect(measurers![0].measure).toEqual(expect.any(Function));
expect(counters![0].fieldIdxs).toEqual([0, 1]); expect(measurers![0].fieldIdxs).toEqual([0, 1]);
}); });
it('does not return the index of columns which are not wrapped', () => { 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 } } }, { name: 'Age', type: FieldType.number, values: [], config: { custom: { wrapHeaderText: true } } },
]; ];
const counters = buildHeaderLineCounters(fields, ctx); const measurers = buildHeaderHeightMeasurers(fields, ctx);
expect(counters![0].fieldIdxs).toEqual([1]); expect(measurers![0].fieldIdxs).toEqual([1]);
}); });
it('returns undefined if no columns are wrapped', () => { it('returns undefined if no columns are wrapped', () => {
@@ -981,22 +981,22 @@ describe('TableNG utils', () => {
{ name: 'Age', type: FieldType.number, values: [], config: { custom: {} } }, { name: 'Age', type: FieldType.number, values: [], config: { custom: {} } },
]; ];
const counters = buildHeaderLineCounters(fields, ctx); const measurers = buildHeaderHeightMeasurers(fields, ctx);
expect(counters).toBeUndefined(); expect(measurers).toBeUndefined();
}); });
}); });
describe('buildRowLineCounters', () => { describe('buildCellHeightMeasurers', () => {
const ctx = { const ctx = {
fontFamily: 'sans-serif', fontFamily: 'sans-serif',
letterSpacing: 0.15, letterSpacing: 0.15,
ctx: {} as CanvasRenderingContext2D, ctx: {} as CanvasRenderingContext2D,
wrappedCount: jest.fn(() => 2), measureHeight: jest.fn(() => 2),
estimateLines: jest.fn(() => 2), estimateHeight: jest.fn(() => 2),
avgCharWidth: 7, 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[] = [ const fields: Field[] = [
{ name: 'Name', type: FieldType.string, values: [], config: { custom: { cellOptions: { wrapText: true } } } }, { name: 'Name', type: FieldType.string, values: [], config: { custom: { cellOptions: { wrapText: true } } } },
{ {
@@ -1006,9 +1006,9 @@ describe('TableNG utils', () => {
config: { custom: { cellOptions: { wrapText: true } } }, config: { custom: { cellOptions: { wrapText: true } } },
}, },
]; ];
const counters = buildRowLineCounters(fields, ctx); const measurers = buildCellHeightMeasurers(fields, ctx);
expect(counters![0].counter).toEqual(expect.any(Function)); expect(measurers![0].measure).toEqual(expect.any(Function));
expect(counters![0].fieldIdxs).toEqual([0, 1]); expect(measurers![0].fieldIdxs).toEqual([0, 1]);
}); });
it('does not return the index of columns which are not wrapped', () => { it('does not return the index of columns which are not wrapped', () => {
@@ -1022,8 +1022,8 @@ describe('TableNG utils', () => {
}, },
]; ];
const counters = buildRowLineCounters(fields, ctx); const measurers = buildCellHeightMeasurers(fields, ctx);
expect(counters![0].fieldIdxs).toEqual([1]); expect(measurers![0].fieldIdxs).toEqual([1]);
}); });
it('sets up line counting for pills if present and wrapping is on', () => { 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 } } }, config: { custom: { cellOptions: { type: TableCellDisplayMode.Pill, wrapText: true } } },
}, },
]; ];
const counters = buildRowLineCounters(fields, ctx); const measurers = buildCellHeightMeasurers(fields, ctx);
expect(counters![0].estimate).toEqual(expect.any(Function)); expect(measurers![0].estimate).toEqual(expect.any(Function));
expect(counters![0].estimate!('tag1,tag2', 100, fields[0], 0)).toEqual(expect.any(Number)); expect(measurers![0].estimate!('tag1,tag2', 100, fields[0], 0, 22)).toEqual(expect.any(Number));
expect(counters![0].counter).toEqual(expect.any(Function)); expect(measurers![0].measure).toEqual(expect.any(Function));
expect(counters![0].counter('tag1,tag2', 100, fields[0], 0)).toEqual(expect.any(Number)); expect(measurers![0].measure('tag1,tag2', 100, fields[0], 0, 22)).toEqual(expect.any(Number));
expect(counters![0].fieldIdxs).toEqual([0]); expect(measurers![0].fieldIdxs).toEqual([0]);
}); });
it('sets up line counting for datalinks if present and wrapping is on', () => { 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); const measurers = buildCellHeightMeasurers(fields, ctx);
expect(counters![0].counter).toEqual(expect.any(Function)); expect(measurers![0].measure).toEqual(expect.any(Function));
expect(counters![0].counter('http://example.com/1', 100, fields[0], 0)).toEqual(expect.any(Number)); expect(measurers![0].measure('http://example.com/1', 100, fields[0], 0, 22)).toEqual(expect.any(Number));
expect(counters![0].fieldIdxs).toEqual([0]); expect(measurers![0].fieldIdxs).toEqual([0]);
}); });
it('does not enable text counting for non-string fields', () => { 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 } } } }, { 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 // 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', () => { it('returns an undefined if no columns are wrapped', () => {
@@ -1079,14 +1079,14 @@ describe('TableNG utils', () => {
{ name: 'Age', type: FieldType.number, values: [], config: { custom: {} } }, { name: 'Age', type: FieldType.number, values: [], config: { custom: {} } },
]; ];
const counters = buildRowLineCounters(fields, ctx); const measurers = buildCellHeightMeasurers(fields, ctx);
expect(counters).toBeUndefined(); expect(measurers).toBeUndefined();
}); });
}); });
describe('getRowHeight', () => { describe('getRowHeight', () => {
let fields: Field[]; let fields: Field[];
let counters: LineCounterEntry[]; let measurers: MeasureCellHeightEntry[];
beforeEach(() => { beforeEach(() => {
fields = [ fields = [
@@ -1103,91 +1103,109 @@ describe('TableNG utils', () => {
config: { custom: { cellOptions: { wrapText: true } } }, config: { custom: { cellOptions: { wrapText: true } } },
}, },
]; ];
counters = [ measurers = [
{ 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 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', () => { it('should use the default height for single-line rows', () => {
// 1 line @ 20px, 10px vertical padding = 30, minimum is 36 // 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', () => { 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 // 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 // 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 // 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', () => { it('should take colWidths into account when calculating max wrap cell', () => {
getRowHeight(fields, 3, [50, 60], 36, counters, 20, 10); getRowHeight(fields, 3, [50, 60], 36, measurers, 20, 10);
expect(counters[0].counter).toHaveBeenCalledWith('longer one here', 50, fields[0], 3); expect(measurers[0].measure).toHaveBeenCalledWith('longer one here', 50, fields[0], 3, 20);
expect(counters[1].counter).toHaveBeenCalledWith(123456, 60, fields[1], 3); expect(measurers[1].measure).toHaveBeenCalledWith(123456, 60, fields[1], 3, 20);
}); });
// this is used to calc wrapped header height // this is used to calc wrapped header height
it('should use the display name if the rowIdx is -1', () => { it('should use the display name if the rowIdx is -1', () => {
getRowHeight(fields, -1, [50, 60], 36, counters, 20, 10); getRowHeight(fields, -1, [50, 60], 36, measurers, 20, 10);
expect(counters[0].counter).toHaveBeenCalledWith('Name', 50, fields[0], -1); expect(measurers[0].measure).toHaveBeenCalledWith('Name', 50, fields[0], -1, 20);
expect(counters[1].counter).toHaveBeenCalledWith('Age', 60, fields[1], -1); expect(measurers[1].measure).toHaveBeenCalledWith('Age', 60, fields[1], -1, 20);
}); });
it('should ignore columns which do not have line counters', () => { it('should ignore columns which do not have measurers', () => {
const height = getRowHeight(fields, 3, [30, 30], 36, [counters[1]], 20, 10); 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) // 2 lines @ 20px, 10px vertical padding (not 3 lines, since we don't line count Name)
expect(height).toBe(50); 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); const height = getRowHeight(fields, 3, [30, 30], 36, [], 20, 10);
expect(height).toBe(36); expect(height).toBe(36);
}); });
describe('estimations vs. precise counts', () => { describe('estimations vs. precise counts', () => {
beforeEach(() => { beforeEach(() => {
counters = [ measurers = [
{ counter: jest.fn((value, _length: number) => String(value).split(' ').length), fieldIdxs: [0] }, // Mocked to count words as lines
{ {
estimate: jest.fn((value) => String(value).length), // Mocked to return a line for every digits of a number measure: jest.fn(
counter: jest.fn((value, _length: number) => Math.ceil(String(value).length / 3)), (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], 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. // 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. // 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', () => { 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', () => { 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(measurers[0].measure).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.3);
jest.mocked(counters[1].estimate!).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.1); 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. // this is what we really care about - we want to save on performance by not calling the measure in this case.
expect(counters[1].counter).not.toHaveBeenCalled(); expect(measurers[1].measure).not.toHaveBeenCalled();
}); });
it('uses the precise count if the estimate is above the threshold, even if its below 1', () => { 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 // NOTE: if this fails, just change the test to use a different value besides 1
expect(SINGLE_LINE_ESTIMATE_THRESHOLD + 0.1).toBeLessThan(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(measurers[0].measure).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - thresholdOffset);
jest.mocked(counters[1].estimate!).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD + 0.1); 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, FrameToRowsConverter,
Comparator, Comparator,
TypographyCtx, TypographyCtx,
LineCounter, MeasureCellHeight,
LineCounterEntry, MeasureCellHeightEntry,
} from './types'; } from './types';
/* ---------------------------- Cell calculations --------------------------- */ /* ---------------------------- Cell calculations --------------------------- */
@@ -112,29 +112,30 @@ export function createTypographyContext(fontSize: number, fontFamily: string, le
fontFamily, fontFamily,
letterSpacing, letterSpacing,
avgCharWidth, avgCharWidth,
estimateLines: getTextLineEstimator(avgCharWidth), estimateHeight: getTextHeightEstimator(avgCharWidth),
wrappedCount: wrapUwrapCount(count), measureHeight: getTextHeightMeasurerFromUwrapCount(count),
}; };
} }
/** /**
* @internal wraps the uwrap count function to ensure that it is given a string. * @internal wraps the uwrap count function to ensure that it is given a string.
*/ */
export function wrapUwrapCount(count: Count): LineCounter { export function getTextHeightMeasurerFromUwrapCount(count: Count): MeasureCellHeight {
return (value, width) => { return (value, width, _field, _rowIdx, lineHeight) => {
if (value == null) { 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 { export function getTextHeightEstimator(avgCharWidth: number): MeasureCellHeight {
return (value, width) => { return (value, width, _field, _rowIdx, lineHeight) => {
if (!value) { if (!value) {
return -1; return -1;
} }
@@ -147,20 +148,21 @@ export function getTextLineEstimator(avgCharWidth: number): LineCounter {
} }
const charsPerLine = width / avgCharWidth; const charsPerLine = width / avgCharWidth;
return strValue.length / charsPerLine; const lines = Math.ceil(strValue.length / charsPerLine);
return lines * lineHeight;
}; };
} }
/** /**
* @internal * @internal
*/ */
export function getDataLinksCounter(): LineCounter { export function getDataLinksHeightMeasurer(): MeasureCellHeight {
const linksCountCache: Record<string, number> = {}; const linksCountCache: Record<string, number> = {};
// when we render links, we need to filter out the invalid links. since the call to `getLinks` is expensive, // 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 // 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. // 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); const cacheKey = getDisplayName(field);
if (linksCountCache[cacheKey] === undefined) { if (linksCountCache[cacheKey] === undefined) {
let count = 0; let count = 0;
@@ -172,7 +174,7 @@ export function getDataLinksCounter(): LineCounter {
linksCountCache[cacheKey] = count; 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_SPACING = 12; // 6px horizontal padding on each side
const PILLS_GAP = 4; // gap between pills 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> = {}; const widthCache: Record<string, number> = {};
return (value, width) => { return (value, width, _field, _rowIdx, lineHeight) => {
if (value == null) { if (value == null) {
return 0; return 0;
} }
@@ -197,10 +199,11 @@ export function getPillLineCounter(measureWidth: (value: string) => number): Lin
let currentLineUse = width; let currentLineUse = width;
for (const pillValue of pillValues) { for (const pillValue of pillValues) {
let rawWidth = widthCache[pillValue]; const strPill = String(pillValue);
let rawWidth = widthCache[strPill];
if (rawWidth === undefined) { if (rawWidth === undefined) {
rawWidth = measureWidth(pillValue); rawWidth = measureWidth(strPill);
widthCache[pillValue] = rawWidth; widthCache[strPill] = rawWidth;
} }
const pillWidth = rawWidth + PILLS_SPACING; 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) => { const wrappedColIdxs = fields.reduce((acc: number[], field, idx) => {
if (field.config?.custom?.wrapHeaderText) { if (field.config?.custom?.wrapHeaderText) {
acc.push(idx); 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 // 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. // 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-]/; 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. * 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 { export function buildCellHeightMeasurers(
const result: Record<string, LineCounterEntry> = {}; fields: Field[],
typographyCtx: TypographyCtx
): MeasureCellHeightEntry[] | undefined {
const result: Record<string, MeasureCellHeightEntry> = {};
let wrappedFields = 0; 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++) { for (let fieldIdx = 0; fieldIdx < fields.length; fieldIdx++) {
const field = fields[fieldIdx]; const field = fields[fieldIdx];
if (shouldTextWrap(field)) { if (shouldTextWrap(field)) {
@@ -253,36 +297,11 @@ export function buildRowLineCounters(fields: Field[], typographyCtx: TypographyC
const cellType = getCellOptions(field).type; const cellType = getCellOptions(field).type;
if (cellType === TableCellDisplayMode.DataLinks) { if (cellType === TableCellDisplayMode.DataLinks) {
result.dataLinksCounter = result.dataLinksCounter ?? { setupMeasurerForIdx(TableCellDisplayMode.DataLinks, fieldIdx);
counter: getDataLinksCounter(),
fieldIdxs: [],
};
result.dataLinksCounter.fieldIdxs.push(fieldIdx);
} else if (cellType === TableCellDisplayMode.Pill) { } else if (cellType === TableCellDisplayMode.Pill) {
if (!result.pillCounter) { setupMeasurerForIdx(TableCellDisplayMode.Pill, fieldIdx);
const pillTypographyCtx = createTypographyContext( } else if (field.type === FieldType.string) {
PILLS_FONT_SIZE, setupMeasurerForIdx(TableCellDisplayMode.Auto, fieldIdx);
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);
} }
} }
} }
@@ -294,10 +313,10 @@ export function buildRowLineCounters(fields: Field[], typographyCtx: TypographyC
return Object.values(result); 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 // 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. // 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 * @internal
@@ -309,27 +328,25 @@ export function getRowHeight(
rowIdx: number, rowIdx: number,
columnWidths: number[], columnWidths: number[],
defaultHeight: number, defaultHeight: number,
lineCounters?: LineCounterEntry[], measurers?: MeasureCellHeightEntry[],
lineHeight = TABLE.LINE_HEIGHT, 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 verticalPadding = TABLE.CELL_PADDING * 2
// 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
): number { ): number {
if (!lineCounters?.length) { if (!measurers?.length) {
return defaultHeight; return defaultHeight;
} }
let maxLines = -1; let maxHeight = -1;
let maxValue = ''; let maxValue = '';
let maxWidth = 0; let maxWidth = 0;
let maxField: Field | undefined; let maxField: Field | undefined;
let preciseCounter: LineCounter | undefined; let preciseMeasurer: MeasureCellHeight | undefined;
for (const { estimate, counter, fieldIdxs } of lineCounters) { for (const { estimate, measure, fieldIdxs } of measurers) {
// for some of the line counters, getting the precise count of the lines is expensive. those line counters // for some of the cell height measurers, getting the precise height is expensive. those entries set
// set both an "estimate" and a "counter" function. if the cell we find to be the max was estimated, we will // 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 hanging onto a reference to the counter fn. // get the "true" value right before calculating the row height by keeping a reference to the measure fn.
const count = estimate ?? counter; const measurer = (estimate ?? measure) satisfies MeasureCellHeight;
const isEstimating = estimate !== undefined; const isEstimating = estimate !== undefined;
for (const fieldIdx of fieldIdxs) { for (const fieldIdx of fieldIdxs) {
@@ -338,38 +355,32 @@ export function getRowHeight(
const cellValueRaw = rowIdx === -1 ? getDisplayName(field) : field.values[rowIdx]; const cellValueRaw = rowIdx === -1 ? getDisplayName(field) : field.values[rowIdx];
if (cellValueRaw != null) { if (cellValueRaw != null) {
const colWidth = columnWidths[fieldIdx]; const colWidth = columnWidths[fieldIdx];
const approxLines = count(cellValueRaw, colWidth, field, rowIdx); const estimatedHeight = measurer(cellValueRaw, colWidth, field, rowIdx, lineHeight);
if (approxLines > maxLines) { if (estimatedHeight > maxHeight) {
maxLines = approxLines; maxHeight = estimatedHeight;
maxValue = cellValueRaw; maxValue = cellValueRaw;
maxWidth = colWidth; maxWidth = colWidth;
maxField = field; 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 // 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. // that the estimator correctly identified that no text wrapping is needed for this row, skipping the preciseMeasurer.
if (maxField === undefined || maxLines < SINGLE_LINE_ESTIMATE_THRESHOLD) { if (maxField === undefined || maxHeight < SINGLE_LINE_ESTIMATE_THRESHOLD) {
return defaultHeight; return defaultHeight;
} }
// if we finished this row height loop with an estimate, we need to call // if we finished this row height loop with an estimate, we need to call
// the `preciseCounter` method to get the exact line count. // the `preciseMeasurer` method to get the exact line count.
if (preciseCounter !== undefined) { if (preciseMeasurer !== undefined) {
maxLines = preciseCounter(maxValue, maxWidth, maxField, rowIdx); maxHeight = preciseMeasurer(maxValue, maxWidth, maxField, rowIdx, lineHeight);
} }
// round up to the nearest line before doing math // adjust for vertical padding, and clamp to a minimum default height
maxLines = Math.ceil(maxLines); return Math.max(maxHeight + verticalPadding, defaultHeight);
// 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);
} }
/** /**