pull/102445/head
Leon Sorokin 3 months ago
parent c72530350e
commit d99b9313aa
  1. 28
      packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx
  2. 257
      packages/grafana-ui/src/components/Table/TableNG/canvas-hypertxt-mod.ts
  3. 118
      packages/grafana-ui/src/components/Table/TableNG/uWrap.ts
  4. 36
      packages/grafana-ui/src/components/Table/TableNG/utils.ts

@ -43,6 +43,7 @@ import {
} from './types'; } from './types';
import { import {
frameToRecords, frameToRecords,
getCellHeightCalculator,
getComparator, getComparator,
getDefaultRowHeight, getDefaultRowHeight,
getFooterItemNG, getFooterItemNG,
@ -386,15 +387,19 @@ export function TableNG(props: TableNGProps) {
setResizeTrigger((prev) => prev + 1); setResizeTrigger((prev) => prev + 1);
}; };
const { ctx, avgCharWidth, font } = useMemo(() => { const { ctx, avgCharWidth } = useMemo(() => {
const font = `${theme.typography.fontSize}px ${theme.typography.fontFamily}`; const font = `${theme.typography.fontSize}px ${theme.typography.fontFamily}`;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!; const ctx = canvas.getContext('2d')!;
// set in grafana/data in createTypography.ts
const letterSpacing = 0.15;
ctx.letterSpacing = `${letterSpacing}px`;
ctx.font = font; ctx.font = font;
let txt = let txt =
"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s"; "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s";
const txtWidth = ctx.measureText(txt).width * devicePixelRatio; const txtWidth = ctx.measureText(txt).width;
const avgCharWidth = txtWidth / txt.length; const avgCharWidth = txtWidth / txt.length + letterSpacing;
return { return {
ctx, ctx,
@ -460,6 +465,10 @@ export function TableNG(props: TableNGProps) {
); );
}; };
const cellHeightCalc = useMemo(() => {
return getCellHeightCalculator(ctx, defaultLineHeight, defaultRowHeight, TABLE.CELL_PADDING);
}, [ctx, defaultLineHeight, defaultRowHeight]);
const calculateRowHeight = useCallback( const calculateRowHeight = useCallback(
(row: TableRow) => { (row: TableRow) => {
// Logic for sub-tables // Logic for sub-tables
@ -469,18 +478,9 @@ export function TableNG(props: TableNGProps) {
const headerCount = row?.data?.meta?.custom?.noHeader ? 0 : 1; const headerCount = row?.data?.meta?.custom?.noHeader ? 0 : 1;
return defaultRowHeight * (row.data?.length ?? 0 + headerCount); // TODO this probably isn't very robust return defaultRowHeight * (row.data?.length ?? 0 + headerCount); // TODO this probably isn't very robust
} }
return getRowHeight( return getRowHeight(row, cellHeightCalc, avgCharWidth, defaultRowHeight, fieldsData);
row,
ctx,
font,
avgCharWidth,
defaultLineHeight,
defaultRowHeight,
TABLE.CELL_PADDING,
fieldsData
);
}, },
[expandedRows, ctx, font, avgCharWidth, defaultLineHeight, defaultRowHeight, fieldsData] [expandedRows, avgCharWidth, defaultRowHeight, fieldsData, cellHeightCalc]
); );
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => { const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {

@ -1,257 +0,0 @@
// A vendored and heavily optimized modification of canvas-hypertxt:
// https://github.com/glideapps/canvas-hypertxt/blob/main/src/multi-line.ts
// TODO: instance per font style to avoid extra map
// font -> avg pixels per char
const metrics: Map<string, { count: number; size: number }> = new Map();
const hyperMaps: Map<string, Record<string, number>> = new Map();
type BreakCallback = (str: string) => readonly number[];
export function backProp(
text: string,
realWidth: number,
keyMap: Record<string, number>,
temperature: number,
avgSize: number
) {
let guessWidth = 0;
const contribMap: Record<string, number> = {};
for (let i = 0; i < text.length; i++) {
const char = text[i];
const v = keyMap[char] ?? avgSize;
guessWidth += v;
contribMap[char] = (contribMap[char] ?? 0) + 1;
}
const diff = realWidth - guessWidth;
for (const key of Object.keys(contribMap)) {
const numContribution = contribMap[key];
const contribWidth = keyMap[key] ?? avgSize;
const contribAmount = (contribWidth * numContribution) / guessWidth;
const adjustment = (diff * contribAmount * temperature) / numContribution;
const newVal = contribWidth + adjustment;
keyMap[key] = newVal;
}
}
const CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890,.-+=?';
function makeHyperMap(ctx: CanvasRenderingContext2D, avgSize: number): Record<string, number> {
const result: Record<string, number> = {};
let total = 0;
for (let i = 0; i < CHARS.length; i++) {
const c = CHARS[i];
const w = ctx.measureText(c).width;
result[c] = w;
total += w;
}
const avg = total / CHARS.length;
// Artisnal hand-tuned constants that have no real meaning other than they make it work better for most fonts
// These don't really need to be accurate, we are going to be adjusting the weights. It just converges faster
// if they start somewhere close.
const damper = 3;
const scaler = (avgSize / avg + damper) / (damper + 1);
for (const key in result) {
result[key] = (result[key] ?? avg) * scaler;
}
return result;
}
function measureText(ctx: CanvasRenderingContext2D, text: string, fontStyle: string, hyperMode: boolean): number {
const current = metrics.get(fontStyle);
if (hyperMode && current !== undefined && current.count > 20_000) {
let hyperMap = hyperMaps.get(fontStyle);
if (hyperMap === undefined) {
hyperMap = makeHyperMap(ctx, current.size);
hyperMaps.set(fontStyle, hyperMap);
}
if (current.count > 500_000) {
let final = 0;
for (let i = 0; i < text.length; i++) {
final += hyperMap[text[i]] ?? current.size;
}
return final * 1.01; //safety margin
}
const result = ctx.measureText(text);
backProp(text, result.width, hyperMap, Math.max(0.05, 1 - current.count / 200_000), current.size);
metrics.set(fontStyle, {
count: current.count + text.length,
size: current.size,
});
return result.width;
}
const result = ctx.measureText(text);
const avg = result.width / text.length;
// we've collected enough data
if ((current?.count ?? 0) > 20_000) {
return result.width;
}
if (current === undefined) {
metrics.set(fontStyle, {
count: text.length,
size: avg,
});
} else {
const diff = avg - current.size;
const contribution = text.length / (current.count + text.length);
const newVal = current.size + diff * contribution;
metrics.set(fontStyle, {
count: current.count + text.length,
size: newVal,
});
}
return result.width;
}
function getSplitPoint(
ctx: CanvasRenderingContext2D,
text: string,
width: number,
fontStyle: string,
totalWidth: number,
measuredChars: number,
hyperMode: boolean,
getBreakOpportunities?: BreakCallback
): number {
if (text.length <= 1) {
return text.length;
}
// this should never happen, but we are protecting anyway
if (totalWidth < width) {
return -1;
}
let guess = Math.floor((width / totalWidth) * measuredChars);
let guessWidth = measureText(ctx, text.slice(0, Math.max(0, guess)), fontStyle, hyperMode);
const oppos = getBreakOpportunities?.(text);
if (guessWidth === width) {
// NAILED IT
} else if (guessWidth < width) {
while (guessWidth < width) {
guess++;
guessWidth = measureText(ctx, text.slice(0, Math.max(0, guess)), fontStyle, hyperMode);
}
guess--;
} else {
// we only need to check for spaces as we go back
while (guessWidth > width) {
const lastSpace = oppos !== undefined ? 0 : text.lastIndexOf(' ', guess - 1);
if (lastSpace > 0) {
guess = lastSpace;
} else {
guess--;
}
guessWidth = measureText(ctx, text.slice(0, Math.max(0, guess)), fontStyle, hyperMode);
}
}
if (text[guess] !== ' ') {
let greedyBreak = 0;
if (oppos === undefined) {
greedyBreak = text.lastIndexOf(' ', guess);
} else {
for (const o of oppos) {
if (o > guess) {
break;
}
greedyBreak = o;
}
}
if (greedyBreak > 0) {
guess = greedyBreak;
}
}
return guess;
}
export function countMultiLines(
ctx: CanvasRenderingContext2D,
value: string,
fontStyle: string,
width: number,
hyperWrappingAllowed: boolean,
getBreakOpportunities?: BreakCallback
) {
if (width <= 0) {
// dont render 0 width stuff
return 1;
}
if (value.indexOf('\n') !== -1) {
let result = 0;
value.split('\n').forEach((value) => {
result += countLines(ctx, value, fontStyle, width, hyperWrappingAllowed, getBreakOpportunities);
});
return result;
} else {
return countLines(ctx, value, fontStyle, width, hyperWrappingAllowed, getBreakOpportunities);
}
}
// Algorithm improved from https://github.com/geongeorge/Canvas-Txt/blob/master/src/index.js
function countLines(
ctx: CanvasRenderingContext2D,
line: string,
fontStyle: string,
width: number,
hyperWrappingAllowed: boolean,
getBreakOpportunities?: BreakCallback
): number {
let result = 0;
const fontMetrics = metrics.get(fontStyle);
const safeLineGuess = fontMetrics === undefined ? line.length : (width / fontMetrics.size) * 1.5;
const hyperMode = hyperWrappingAllowed && fontMetrics !== undefined && fontMetrics.count > 20_000;
let textWidth = measureText(ctx, line.slice(0, Math.max(0, safeLineGuess)), fontStyle, hyperMode);
let measuredChars = Math.min(line.length, safeLineGuess);
if (textWidth <= width) {
// line fits, just push it
result++;
} else {
while (textWidth > width) {
const splitPoint = getSplitPoint(
ctx,
line,
width,
fontStyle,
textWidth,
measuredChars,
hyperMode,
getBreakOpportunities
);
const subLine = line.slice(0, Math.max(0, splitPoint));
line = line.slice(subLine.length);
result++;
textWidth = measureText(ctx, line.slice(0, Math.max(0, safeLineGuess)), fontStyle, hyperMode);
measuredChars = Math.min(line.length, safeLineGuess);
}
if (textWidth > 0) {
result++;
}
}
return result;
}

@ -0,0 +1,118 @@
// BREAKS
const D = '-'.charCodeAt(0);
// const N = "\n".charCodeAt(0);
// const R = "\r".charCodeAt(0);
const S = ' '.charCodeAt(0);
// const T = "\t".charCodeAt(0);
const SYMBS = `\`~!@#$%^&*()_+-=[]\\{}|;':",./<>?`;
const NUMS = '1234567890';
const UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const LOWER = 'abcdefghijklmnopqrstuvwxyz';
const CHARS = `${UPPER}${LOWER}${NUMS}${SYMBS}`;
// TODO: customize above via opts
// TODO: respect explicit breaks
// TODO: instead of ctx, pass in font, letterSpacing, wordSpacing
export function uWrap(ctx: CanvasRenderingContext2D) {
// TODO: wordSpacing
const letterSpacing = parseFloat(ctx.letterSpacing);
// single-char widths in isolation
const WIDTHS: Record<number, number> = {};
for (let i = 0; i < CHARS.length; i++) {
WIDTHS[CHARS.charCodeAt(i)] = ctx.measureText(CHARS[i]).width + letterSpacing;
}
// build kerning/spacing LUT of upper+lower, upper+sym, upper+upper pairs. (this includes letterSpacing)
const PAIRS: Record<number, Record<number, number>> = {};
for (let i = 0; i < UPPER.length; i++) {
let uc = UPPER.charCodeAt(i);
PAIRS[uc] = {};
for (let j = 0; j < CHARS.length; j++) {
let ch = CHARS.charCodeAt(j);
let wid = ctx.measureText(`${UPPER[i]}${CHARS[j]}`).width - WIDTHS[ch] - letterSpacing;
PAIRS[uc][ch] = wid;
}
}
type EachLine = (idx0: number, idx1: number) => void;
const eachLine: EachLine = () => {};
function each(text: string, width: number, cb: EachLine = eachLine) {
let headIdx = 0;
let headEnd = 0;
let headWid = 0;
let tailIdx = -1; // wrap candidate
let tailWid = 0;
let inWS = false;
for (let i = 0; i < text.length; i++) {
let c = text.charCodeAt(i);
let w = 0;
if (c in PAIRS) {
let n = text.charCodeAt(i + 1);
if (n in PAIRS[c]) {
w = PAIRS[c][n];
}
}
if (w === 0) {
w = WIDTHS[c] ?? (WIDTHS[c] = ctx.measureText(text[i]).width);
}
if (c === S) {
// || c === T || c === N || c === R
// set possible wrap point
if (text.charCodeAt(i + 1) !== c) {
tailIdx = i + 1;
tailWid = 0;
}
if (!inWS && headWid > 0) {
headWid += w;
headEnd = i;
}
inWS = true;
} else {
if (headWid + w > width) {
cb(headIdx, headEnd);
headWid = tailWid + w;
headIdx = headEnd = tailIdx;
tailWid = 0;
tailIdx = -1;
} else {
if (c === D) {
// set possible wrap point
if (text.charCodeAt(i + 1) !== c) {
tailIdx = headEnd = i + 1;
tailWid = 0;
}
}
headWid += w;
tailWid += w;
}
inWS = false;
}
}
cb(headIdx, text.length - 1);
}
return {
each,
};
}

@ -27,7 +27,6 @@ import {
import { TableCellInspectorMode } from '../..'; import { TableCellInspectorMode } from '../..';
import { getTextColorForAlphaBackground } from '../../../utils'; import { getTextColorForAlphaBackground } from '../../../utils';
import { countMultiLines } from './canvas-hypertxt-mod';
import { TABLE } from './constants'; import { TABLE } from './constants';
import { import {
CellColors, CellColors,
@ -40,6 +39,7 @@ import {
Comparator, Comparator,
TableFooterCalc, TableFooterCalc,
} from './types'; } from './types';
import { uWrap } from './uWrap';
/* ---------------------------- Cell calculations --------------------------- */ /* ---------------------------- Cell calculations --------------------------- */
// NOTE: This is now a fallback in case canvas-hypertxt fails // NOTE: This is now a fallback in case canvas-hypertxt fails
@ -99,22 +99,29 @@ export function getCellHeight(
return defaultRowHeight; return defaultRowHeight;
} }
export function calculateCellHeight( export type CellHeightCalculator = (text: string, cellWidth: number) => number;
text: string,
font: string, export function getCellHeightCalculator(
cellWidth: number, // should be pre-configured with font and letterSpacing
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
lineHeight: number, lineHeight: number,
defaultRowHeight: number, defaultRowHeight: number,
padding = 0 padding = 0
) { ) {
let uw = uWrap(ctx);
return (text: string, cellWidth: number) => {
const effectiveCellWidth = Math.max(cellWidth, 20); // Minimum width to work with const effectiveCellWidth = Math.max(cellWidth, 20); // Minimum width to work with
const TOTAL_PADDING = padding * 2; const TOTAL_PADDING = padding * 2;
const numLines = countMultiLines(ctx, text, font, effectiveCellWidth, true); let numLines = 0;
uw.each(text, effectiveCellWidth, () => {
numLines++;
});
const totalHeight = numLines * lineHeight + TOTAL_PADDING; const totalHeight = numLines * lineHeight + TOTAL_PADDING;
return Math.max(totalHeight, defaultRowHeight); return Math.max(totalHeight, defaultRowHeight);
};
} }
export function getDefaultRowHeight(theme: GrafanaTheme2, cellHeight: TableCellHeight | undefined): number { export function getDefaultRowHeight(theme: GrafanaTheme2, cellHeight: TableCellHeight | undefined): number {
@ -139,12 +146,9 @@ export function getDefaultRowHeight(theme: GrafanaTheme2, cellHeight: TableCellH
*/ */
export function getRowHeight( export function getRowHeight(
row: TableRow, row: TableRow,
ctx: CanvasRenderingContext2D, calc: CellHeightCalculator,
font: string,
avgCharWidth: number, avgCharWidth: number,
lineHeight: number,
defaultRowHeight: number, defaultRowHeight: number,
padding: number,
fieldsData: { fieldsData: {
headersLength: number; headersLength: number;
textWraps: { [key: string]: boolean }; textWraps: { [key: string]: boolean };
@ -176,17 +180,7 @@ export function getRowHeight(
} }
} }
return maxLinesCol === '' return maxLinesCol === '' ? defaultRowHeight : calc(row[maxLinesCol] as string, fieldsData.columnWidths[maxLinesCol]);
? defaultRowHeight
: calculateCellHeight(
row[maxLinesCol] as string,
font,
fieldsData.columnWidths[maxLinesCol],
ctx,
lineHeight,
defaultRowHeight,
padding
);
} }
export function isTextCell(key: string, columnTypes: Record<string, string>): boolean { export function isTextCell(key: string, columnTypes: Record<string, string>): boolean {

Loading…
Cancel
Save