Table Panel: Enable text wrapping (#86895)
* Calculate row height * Move things around * Update getItemSize to use text bounding box * Update types * Cleanups and reminders * Calculate line height * Update line height calculation * Remove debugging * Add cell option editing * Prettier * Use field configured for text wrapping * Add TODO * Make sure column configuration is correct * Update height heuristic and hover behavior * Disable overflow on hover with text wrapping * Update heuristic * Clean things up * Color background cell options * Fix tests * Prettier * React deps * Remove old hook dep * Fix type errors * Update label and description for editor * Fix non-wrapped case * Make sure color background works * Prettier * Address review comments * fix prettier * Add heuristic for field sizing * Fix up logic * Prettier * Fix test * Oh prettier 🙈 * Don't wrap text on non-string fields * Add wrapping to color text cell * Prettier * Fix option not showing for auto cell type * Move longest field guessing into function * Clean things up * Add tests * Make sure text won't flake * Prettier * Remove spurious import * Ignore any in this case * Add alpha label * Prettier * Fix typecheck * Fix crash when sampling when there are undefined records * Update heuristic to take into account long strings * Prettier * Update scale factors * Update field index selection * Prettier --------- Co-authored-by: jev forsberg <jev.forsberg@grafana.com> Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
This commit is contained in:
co-authored by
jev forsberg
nmarrs
parent
694499ae6d
commit
8aa1bbe27c
@@ -749,6 +749,7 @@ export const defaultTableFooterOptions: Partial<TableFooterOptions> = {
|
||||
*/
|
||||
export interface TableAutoCellOptions {
|
||||
type: TableCellDisplayMode.Auto;
|
||||
wrapText?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -756,6 +757,7 @@ export interface TableAutoCellOptions {
|
||||
*/
|
||||
export interface TableColorTextCellOptions {
|
||||
type: TableCellDisplayMode.ColorText;
|
||||
wrapText?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -803,12 +805,14 @@ export interface TableColoredBackgroundCellOptions {
|
||||
applyToRow?: boolean;
|
||||
mode?: TableCellBackgroundDisplayMode;
|
||||
type: TableCellDisplayMode.ColorBackground;
|
||||
wrapText?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Height of a table cell
|
||||
*/
|
||||
export enum TableCellHeight {
|
||||
Auto = 'auto',
|
||||
Lg = 'lg',
|
||||
Md = 'md',
|
||||
Sm = 'sm',
|
||||
|
||||
@@ -31,11 +31,13 @@ TableFooterOptions: {
|
||||
// Auto mode table cell options
|
||||
TableAutoCellOptions: {
|
||||
type: TableCellDisplayMode & "auto"
|
||||
wrapText?: bool
|
||||
} @cuetsy(kind="interface")
|
||||
|
||||
// Colored text cell options
|
||||
TableColorTextCellOptions: {
|
||||
type: TableCellDisplayMode & "color-text"
|
||||
wrapText?: bool
|
||||
} @cuetsy(kind="interface")
|
||||
|
||||
// Json view cell options
|
||||
@@ -72,10 +74,11 @@ TableColoredBackgroundCellOptions: {
|
||||
type: TableCellDisplayMode & "color-background"
|
||||
mode?: TableCellBackgroundDisplayMode
|
||||
applyToRow?: bool
|
||||
wrapText?: bool
|
||||
} @cuetsy(kind="interface")
|
||||
|
||||
// Height of a table cell
|
||||
TableCellHeight: "sm" | "md" | "lg" @cuetsy(kind="enum")
|
||||
TableCellHeight: "sm" | "md" | "lg" | "auto" @cuetsy(kind="enum")
|
||||
|
||||
// Table cell options. Each cell has a display mode
|
||||
// and other potential options for that display.
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@grafana/tsconfig": "^1.3.0-rc1",
|
||||
"@rollup/plugin-node-resolve": "15.2.3",
|
||||
"@storybook/addon-a11y": "^8.1.6",
|
||||
|
||||
@@ -15,7 +15,7 @@ import { TableCellProps, CustomCellRendererProps, TableCellOptions } from './typ
|
||||
import { getCellColors, getCellOptions } from './utils';
|
||||
|
||||
export const DefaultCell = (props: TableCellProps) => {
|
||||
const { field, cell, tableStyles, row, cellProps, frame, rowStyled } = props;
|
||||
const { field, cell, tableStyles, row, cellProps, frame, rowStyled, textWrapped, height } = props;
|
||||
|
||||
const inspectEnabled = Boolean(field.config.custom?.inspect);
|
||||
const displayValue = field.display!(cell.value);
|
||||
@@ -59,6 +59,7 @@ export const DefaultCell = (props: TableCellProps) => {
|
||||
inspectEnabled,
|
||||
isStringValue,
|
||||
textShouldWrap,
|
||||
textWrapped,
|
||||
rowStyled
|
||||
);
|
||||
|
||||
@@ -72,6 +73,14 @@ export const DefaultCell = (props: TableCellProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (height) {
|
||||
cellProps.style = { ...cellProps.style, height };
|
||||
}
|
||||
|
||||
if (textWrapped) {
|
||||
cellProps.style = { ...cellProps.style, textWrap: 'wrap' };
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...cellProps}
|
||||
@@ -112,6 +121,7 @@ function getCellStyle(
|
||||
disableOverflowOnHover = false,
|
||||
isStringValue = false,
|
||||
shouldWrapText = false,
|
||||
textWrapped = false,
|
||||
rowStyled = false
|
||||
) {
|
||||
// Setup color variables
|
||||
@@ -134,6 +144,7 @@ function getCellStyle(
|
||||
!disableOverflowOnHover,
|
||||
isStringValue,
|
||||
shouldWrapText,
|
||||
textWrapped,
|
||||
rowStyled
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { CSSProperties, UIEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Cell, Row, TableState } from 'react-table';
|
||||
import { Cell, Row, TableState, HeaderGroup } from 'react-table';
|
||||
import { VariableSizeList } from 'react-window';
|
||||
import { Subscription, debounceTime } from 'rxjs';
|
||||
|
||||
@@ -23,7 +23,12 @@ import { ExpandedRow, getExpandedRowHeight } from './ExpandedRow';
|
||||
import { TableCell } from './TableCell';
|
||||
import { TableStyles } from './styles';
|
||||
import { CellColors, TableFieldOptions, TableFilterActionCallback } from './types';
|
||||
import { calculateAroundPointThreshold, getCellColors, isPointTimeValAroundTableTimeVal } from './utils';
|
||||
import {
|
||||
calculateAroundPointThreshold,
|
||||
getCellColors,
|
||||
isPointTimeValAroundTableTimeVal,
|
||||
guessTextBoundingBox,
|
||||
} from './utils';
|
||||
|
||||
interface RowsListProps {
|
||||
data: DataFrame;
|
||||
@@ -45,6 +50,8 @@ interface RowsListProps {
|
||||
timeRange?: TimeRange;
|
||||
footerPaginationEnabled: boolean;
|
||||
initialRowIndex?: number;
|
||||
headerGroups: HeaderGroup[];
|
||||
longestField?: Field;
|
||||
}
|
||||
|
||||
export const RowsList = (props: RowsListProps) => {
|
||||
@@ -68,6 +75,8 @@ export const RowsList = (props: RowsListProps) => {
|
||||
listRef,
|
||||
enableSharedCrosshair = false,
|
||||
initialRowIndex = undefined,
|
||||
headerGroups,
|
||||
longestField,
|
||||
} = props;
|
||||
|
||||
const [rowHighlightIndex, setRowHighlightIndex] = useState<number | undefined>(initialRowIndex);
|
||||
@@ -75,6 +84,23 @@ export const RowsList = (props: RowsListProps) => {
|
||||
const theme = useTheme2();
|
||||
const panelContext = usePanelContext();
|
||||
|
||||
// Create off-screen canvas for measuring rows for virtualized rendering
|
||||
// This line is like this because Jest doesn't have OffscreenCanvas mocked
|
||||
// nor is it a part of the jest-canvas-mock package
|
||||
let osContext = null;
|
||||
if (window.OffscreenCanvas !== undefined) {
|
||||
// The canvas size is defined arbitrarily
|
||||
// As we never actually visualize rendered content
|
||||
// from the offscreen canvas, only perform text measurements
|
||||
osContext = new OffscreenCanvas(256, 1024).getContext('2d');
|
||||
}
|
||||
|
||||
// Set font property using theme info
|
||||
// This will make text measurement accurate
|
||||
if (osContext !== undefined && osContext !== null) {
|
||||
osContext.font = `${theme.typography.fontSize}px ${theme.typography.body.fontFamily}`;
|
||||
}
|
||||
|
||||
const threshold = useMemo(() => {
|
||||
const timeField = data.fields.find((f) => f.type === FieldType.time);
|
||||
|
||||
@@ -202,13 +228,14 @@ export const RowsList = (props: RowsListProps) => {
|
||||
);
|
||||
|
||||
let rowBg: Function | undefined = undefined;
|
||||
let textWrapField: Field | undefined = undefined;
|
||||
for (const field of data.fields) {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const fieldOptions = field.config.custom as TableFieldOptions;
|
||||
const cellOptionsExist = fieldOptions !== undefined && fieldOptions.cellOptions !== undefined;
|
||||
|
||||
if (
|
||||
fieldOptions !== undefined &&
|
||||
fieldOptions.cellOptions !== undefined &&
|
||||
cellOptionsExist &&
|
||||
fieldOptions.cellOptions.type === TableCellDisplayMode.ColorBackground &&
|
||||
fieldOptions.cellOptions.applyToRow
|
||||
) {
|
||||
@@ -218,6 +245,18 @@ export const RowsList = (props: RowsListProps) => {
|
||||
return colors;
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
cellOptionsExist &&
|
||||
(fieldOptions.cellOptions.type === TableCellDisplayMode.Auto ||
|
||||
fieldOptions.cellOptions.type === TableCellDisplayMode.ColorBackground ||
|
||||
fieldOptions.cellOptions.type === TableCellDisplayMode.ColorText) &&
|
||||
fieldOptions.cellOptions.wrapText
|
||||
) {
|
||||
textWrapField = field;
|
||||
} else if (longestField !== undefined) {
|
||||
textWrapField = longestField;
|
||||
}
|
||||
}
|
||||
|
||||
const RenderRow = useCallback(
|
||||
@@ -236,12 +275,27 @@ export const RowsList = (props: RowsListProps) => {
|
||||
};
|
||||
}
|
||||
|
||||
// Color rows if enabled
|
||||
if (rowBg) {
|
||||
const { bgColor, textColor } = rowBg(row.index);
|
||||
style.background = bgColor;
|
||||
style.color = textColor;
|
||||
}
|
||||
|
||||
// If there's a text wrapping field we set the height of it here
|
||||
if (textWrapField) {
|
||||
const seriesIndex = data.fields.findIndex((field) => field.name === textWrapField.name);
|
||||
const pxLineHeight = theme.typography.body.lineHeight * theme.typography.fontSize;
|
||||
const bbox = guessTextBoundingBox(
|
||||
textWrapField.values[index],
|
||||
headerGroups[0].headers[seriesIndex],
|
||||
osContext,
|
||||
pxLineHeight,
|
||||
tableStyles.rowHeight
|
||||
);
|
||||
style.height = bbox.height;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...row.getRowProps({ style, ...additionalProps })}
|
||||
@@ -272,6 +326,8 @@ export const RowsList = (props: RowsListProps) => {
|
||||
timeRange={timeRange}
|
||||
frame={data}
|
||||
rowStyled={rowBg !== undefined}
|
||||
textWrapped={textWrapField !== undefined}
|
||||
height={Number(style.height)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -289,20 +345,38 @@ export const RowsList = (props: RowsListProps) => {
|
||||
rows,
|
||||
tableState.expanded,
|
||||
tableStyles,
|
||||
textWrapField,
|
||||
theme.components.table.rowHoverBackground,
|
||||
theme.typography.fontSize,
|
||||
theme.typography.body.lineHeight,
|
||||
timeRange,
|
||||
width,
|
||||
rowBg,
|
||||
headerGroups,
|
||||
osContext,
|
||||
]
|
||||
);
|
||||
|
||||
const getItemSize = (index: number): number => {
|
||||
const indexForPagination = rowIndexForPagination(index);
|
||||
const row = rows[indexForPagination];
|
||||
|
||||
if (tableState.expanded[row.id] && nestedDataField) {
|
||||
return getExpandedRowHeight(nestedDataField, row.index, tableStyles);
|
||||
}
|
||||
|
||||
if (textWrapField) {
|
||||
const seriesIndex = data.fields.findIndex((field) => field.name === textWrapField.name);
|
||||
const pxLineHeight = theme.typography.fontSize * theme.typography.body.lineHeight;
|
||||
return guessTextBoundingBox(
|
||||
textWrapField.values[index],
|
||||
headerGroups[0].headers[seriesIndex],
|
||||
osContext,
|
||||
pxLineHeight,
|
||||
tableStyles.rowHeight
|
||||
).height;
|
||||
}
|
||||
|
||||
return tableStyles.rowHeight;
|
||||
};
|
||||
|
||||
|
||||
@@ -25,7 +25,14 @@ import { useFixScrollbarContainer, useResetVariableListSizeCache } from './hooks
|
||||
import { getInitialState, useTableStateReducer } from './reducer';
|
||||
import { useTableStyles } from './styles';
|
||||
import { FooterItem, GrafanaTableState, Props } from './types';
|
||||
import { getColumns, sortCaseInsensitive, sortNumber, getFooterItems, createFooterCalculationValues } from './utils';
|
||||
import {
|
||||
getColumns,
|
||||
sortCaseInsensitive,
|
||||
sortNumber,
|
||||
getFooterItems,
|
||||
createFooterCalculationValues,
|
||||
guessLongestField,
|
||||
} from './utils';
|
||||
|
||||
const COLUMN_MIN_WIDTH = 150;
|
||||
const FOOTER_ROW_HEIGHT = 36;
|
||||
@@ -287,6 +294,9 @@ export const Table = memo((props: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Try to determine the longet field
|
||||
const longestField = guessLongestField(fieldConfig, data);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...getTableProps()}
|
||||
@@ -304,6 +314,7 @@ export const Table = memo((props: Props) => {
|
||||
{itemCount > 0 ? (
|
||||
<div data-testid={selectors.components.Panels.Visualization.Table.body} ref={variableSizeListScrollbarRef}>
|
||||
<RowsList
|
||||
headerGroups={headerGroups}
|
||||
data={data}
|
||||
rows={rows}
|
||||
width={width}
|
||||
@@ -323,6 +334,7 @@ export const Table = memo((props: Props) => {
|
||||
footerPaginationEnabled={Boolean(enablePagination)}
|
||||
enableSharedCrosshair={enableSharedCrosshair}
|
||||
initialRowIndex={initialRowIndex}
|
||||
longestField={longestField}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -16,9 +16,21 @@ export interface Props {
|
||||
userProps?: object;
|
||||
frame: DataFrame;
|
||||
rowStyled?: boolean;
|
||||
textWrapped?: boolean;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export const TableCell = ({ cell, tableStyles, onCellFilterAdded, timeRange, userProps, frame, rowStyled }: Props) => {
|
||||
export const TableCell = ({
|
||||
cell,
|
||||
tableStyles,
|
||||
onCellFilterAdded,
|
||||
timeRange,
|
||||
userProps,
|
||||
frame,
|
||||
rowStyled,
|
||||
textWrapped,
|
||||
height,
|
||||
}: Props) => {
|
||||
const cellProps = cell.getCellProps();
|
||||
const field = (cell.column as unknown as GrafanaTableColumn).field;
|
||||
|
||||
@@ -45,6 +57,8 @@ export const TableCell = ({ cell, tableStyles, onCellFilterAdded, timeRange, use
|
||||
userProps,
|
||||
frame,
|
||||
rowStyled,
|
||||
textWrapped,
|
||||
height,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell
|
||||
overflowOnHover?: boolean,
|
||||
asCellText?: boolean,
|
||||
textShouldWrap?: boolean,
|
||||
textWrapped?: boolean,
|
||||
rowStyled?: boolean
|
||||
) => {
|
||||
return css({
|
||||
@@ -26,6 +27,7 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell
|
||||
width: '100%',
|
||||
// Cell height need to account for row border
|
||||
height: `${rowHeight - 1}px`,
|
||||
wordBreak: textWrapped ? 'break-all' : 'inherit',
|
||||
|
||||
display: 'flex',
|
||||
|
||||
@@ -50,9 +52,9 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell
|
||||
},
|
||||
|
||||
'&:hover': {
|
||||
overflow: overflowOnHover ? 'visible' : undefined,
|
||||
overflow: overflowOnHover && !textWrapped ? 'visible' : undefined,
|
||||
width: textShouldWrap || !overflowOnHover ? 'auto' : 'auto !important',
|
||||
height: textShouldWrap || overflowOnHover ? 'auto !important' : `${rowHeight - 1}px`,
|
||||
height: (textShouldWrap || overflowOnHover) && !textWrapped ? 'auto !important' : `${rowHeight - 1}px`,
|
||||
minHeight: `${rowHeight - 1}px`,
|
||||
wordBreak: textShouldWrap ? 'break-word' : undefined,
|
||||
whiteSpace: textShouldWrap && overflowOnHover ? 'normal' : 'nowrap',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { Row } from 'react-table';
|
||||
|
||||
import { Field, FieldType, MutableDataFrame, SelectableValue } from '@grafana/data';
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
sortNumber,
|
||||
sortOptions,
|
||||
valuesToOptions,
|
||||
guessLongestField,
|
||||
} from './utils';
|
||||
|
||||
function getData() {
|
||||
@@ -43,6 +45,45 @@ function getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
function getWrappableData(numRecords: number) {
|
||||
const data = new MutableDataFrame({
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, values: [] },
|
||||
{
|
||||
name: 'Lorem 5',
|
||||
type: FieldType.string,
|
||||
values: [],
|
||||
config: {
|
||||
custom: {
|
||||
align: 'center',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Lorem 10',
|
||||
type: FieldType.string,
|
||||
values: [],
|
||||
config: {
|
||||
custom: {
|
||||
align: 'center',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Set values for the dataframe
|
||||
// We're not concerned about time in
|
||||
// this case so we simply leave it as zero
|
||||
for (let i = 0; i < numRecords; i++) {
|
||||
data.fields[0].values[i] = 0;
|
||||
data.fields[1].values[i] = faker.lorem.paragraphs(9);
|
||||
data.fields[2].values[i] = faker.lorem.paragraphs(11);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
describe('Table utils', () => {
|
||||
describe('getColumns', () => {
|
||||
it('Should build columns from DataFrame', () => {
|
||||
@@ -502,4 +543,54 @@ describe('Table utils', () => {
|
||||
expect(diff).toBeLessThanOrEqual(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guessLongestField', () => {
|
||||
it('should guess the longest field correct if there are few records', () => {
|
||||
const data = getWrappableData(10);
|
||||
const config = {
|
||||
defaults: {
|
||||
custom: {
|
||||
cellOptions: {
|
||||
wrapText: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const longestField = guessLongestField(config, data);
|
||||
expect(longestField?.name).toBe('Lorem 10');
|
||||
});
|
||||
|
||||
it('should guess the longest field correctly if there are many records', () => {
|
||||
const data = getWrappableData(1000);
|
||||
const config = {
|
||||
defaults: {
|
||||
custom: {
|
||||
cellOptions: {
|
||||
wrapText: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const longestField = guessLongestField(config, data);
|
||||
expect(longestField?.name).toBe('Lorem 10');
|
||||
});
|
||||
|
||||
it('should return undefined if there is no data', () => {
|
||||
const data = getData();
|
||||
const config = {
|
||||
defaults: {
|
||||
custom: {
|
||||
cellOptions: {
|
||||
wrapText: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const longestField = guessLongestField(config, data);
|
||||
expect(longestField).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Property } from 'csstype';
|
||||
import { clone } from 'lodash';
|
||||
import { clone, sampleSize } from 'lodash';
|
||||
import memoize from 'micro-memoize';
|
||||
import { Row } from 'react-table';
|
||||
import { Row, HeaderGroup } from 'react-table';
|
||||
import tinycolor from 'tinycolor2';
|
||||
|
||||
import {
|
||||
@@ -633,3 +633,130 @@ export function getCellColors(
|
||||
|
||||
return { textColor, bgColor, bgHoverColor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate an estimated bounding box for a block
|
||||
* of text using an offscreen canvas.
|
||||
*/
|
||||
export function guessTextBoundingBox(
|
||||
text: string,
|
||||
headerGroup: HeaderGroup,
|
||||
osContext: OffscreenCanvasRenderingContext2D | null,
|
||||
lineHeight: number,
|
||||
defaultRowHeight: number
|
||||
) {
|
||||
const width = Number(headerGroup.width ?? 300);
|
||||
const LINE_SCALE_FACTOR = 1.17;
|
||||
const LOW_LINE_PAD = 42;
|
||||
|
||||
if (osContext !== null && typeof text === 'string') {
|
||||
const words = text.split(/\s/);
|
||||
const lines = [];
|
||||
let currentLine = '';
|
||||
let wordCount = 0;
|
||||
let extraLines = 0;
|
||||
|
||||
// Let's just wrap the lines and see how well the measurement works
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const currentWord = words[i];
|
||||
let lineWidth = osContext.measureText(currentLine + ' ' + currentWord).width;
|
||||
|
||||
if (lineWidth < width) {
|
||||
currentLine += ' ' + currentWord;
|
||||
wordCount++;
|
||||
} else {
|
||||
lines.push({
|
||||
width: lineWidth,
|
||||
line: currentLine,
|
||||
});
|
||||
|
||||
currentLine = currentWord;
|
||||
wordCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// We can have extra long strings, for these
|
||||
// we estimate if it overshoots the line by
|
||||
// at least one other line
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].width > width) {
|
||||
let extra = Math.floor(lines[i].width / width) - 1;
|
||||
extraLines += extra;
|
||||
}
|
||||
}
|
||||
|
||||
// Estimated height would be lines multiplied
|
||||
// by the line height
|
||||
let lineNumber = lines.length + extraLines;
|
||||
let height = 38;
|
||||
if (lineNumber > 5) {
|
||||
height = lineNumber * lineHeight * LINE_SCALE_FACTOR;
|
||||
} else {
|
||||
height = lineNumber * lineHeight + LOW_LINE_PAD;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
return { width, height: defaultRowHeight };
|
||||
}
|
||||
|
||||
/**
|
||||
* A function to guess at which field has the longest text.
|
||||
* To do this we either select a single record if there aren't many records
|
||||
* or we select records at random and sample their size.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function guessLongestField(fieldConfig: any, data: DataFrame) {
|
||||
let longestField = undefined;
|
||||
const SAMPLE_SIZE = 3;
|
||||
|
||||
// If the default field option is set to allow text wrapping
|
||||
// we determine the field to wrap text with here and then
|
||||
// pass it to the RowsList
|
||||
if (
|
||||
fieldConfig !== undefined &&
|
||||
fieldConfig.defaults.custom !== undefined &&
|
||||
fieldConfig.defaults.custom.cellOptions.wrapText
|
||||
) {
|
||||
const stringFields = data.fields.filter((field: Field) => field.type === FieldType.string);
|
||||
|
||||
if (stringFields.length >= 1 && stringFields[0].values.length > 0) {
|
||||
const numValues = stringFields[0].values.length;
|
||||
let longestLength = 0;
|
||||
|
||||
// If we have less than 30 values we assume
|
||||
// that the first record is representative
|
||||
// of the overall data
|
||||
if (numValues <= 30) {
|
||||
for (const field of stringFields) {
|
||||
const fieldLength = field.values[0].length;
|
||||
if (fieldLength > longestLength) {
|
||||
longestLength = fieldLength;
|
||||
longestField = field;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Otherwise we randomly sample SAMPLE_SIZE values and take
|
||||
// the mean length
|
||||
else {
|
||||
for (const field of stringFields) {
|
||||
// This could result in duplicate values but
|
||||
// that should be fairly unlikely. This could potentially
|
||||
// be improved using a Set datastructure but
|
||||
// going to leave that one as an exercise for
|
||||
// the reader to contemplate and possibly code
|
||||
const vals = sampleSize(field.values, SAMPLE_SIZE);
|
||||
const meanLength = (vals[0]?.length + vals[1]?.length + vals[2]?.length) / 3;
|
||||
|
||||
if (meanLength > longestLength) {
|
||||
longestLength = meanLength;
|
||||
longestField = field;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return longestField;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user