From d99b9313aa222b27bab6d811a24a13658bcc28d1 Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Mon, 24 Mar 2025 22:20:10 -0500 Subject: [PATCH] uWrap PoC --- .../src/components/Table/TableNG/TableNG.tsx | 28 +- .../Table/TableNG/canvas-hypertxt-mod.ts | 257 ------------------ .../src/components/Table/TableNG/uWrap.ts | 118 ++++++++ .../src/components/Table/TableNG/utils.ts | 44 ++- 4 files changed, 151 insertions(+), 296 deletions(-) delete mode 100644 packages/grafana-ui/src/components/Table/TableNG/canvas-hypertxt-mod.ts create mode 100644 packages/grafana-ui/src/components/Table/TableNG/uWrap.ts diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index e24e6cf6c9b..95ec7567384 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -43,6 +43,7 @@ import { } from './types'; import { frameToRecords, + getCellHeightCalculator, getComparator, getDefaultRowHeight, getFooterItemNG, @@ -386,15 +387,19 @@ export function TableNG(props: TableNGProps) { setResizeTrigger((prev) => prev + 1); }; - const { ctx, avgCharWidth, font } = useMemo(() => { + const { ctx, avgCharWidth } = useMemo(() => { const font = `${theme.typography.fontSize}px ${theme.typography.fontFamily}`; const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d')!; + // set in grafana/data in createTypography.ts + const letterSpacing = 0.15; + + ctx.letterSpacing = `${letterSpacing}px`; ctx.font = font; 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"; - const txtWidth = ctx.measureText(txt).width * devicePixelRatio; - const avgCharWidth = txtWidth / txt.length; + const txtWidth = ctx.measureText(txt).width; + const avgCharWidth = txtWidth / txt.length + letterSpacing; return { 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( (row: TableRow) => { // Logic for sub-tables @@ -469,18 +478,9 @@ export function TableNG(props: TableNGProps) { const headerCount = row?.data?.meta?.custom?.noHeader ? 0 : 1; return defaultRowHeight * (row.data?.length ?? 0 + headerCount); // TODO this probably isn't very robust } - return getRowHeight( - row, - ctx, - font, - avgCharWidth, - defaultLineHeight, - defaultRowHeight, - TABLE.CELL_PADDING, - fieldsData - ); + return getRowHeight(row, cellHeightCalc, avgCharWidth, defaultRowHeight, fieldsData); }, - [expandedRows, ctx, font, avgCharWidth, defaultLineHeight, defaultRowHeight, fieldsData] + [expandedRows, avgCharWidth, defaultRowHeight, fieldsData, cellHeightCalc] ); const handleScroll = (event: React.UIEvent) => { diff --git a/packages/grafana-ui/src/components/Table/TableNG/canvas-hypertxt-mod.ts b/packages/grafana-ui/src/components/Table/TableNG/canvas-hypertxt-mod.ts deleted file mode 100644 index 2466c2d3fae..00000000000 --- a/packages/grafana-ui/src/components/Table/TableNG/canvas-hypertxt-mod.ts +++ /dev/null @@ -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 = new Map(); - -const hyperMaps: Map> = new Map(); - -type BreakCallback = (str: string) => readonly number[]; - -export function backProp( - text: string, - realWidth: number, - keyMap: Record, - temperature: number, - avgSize: number -) { - let guessWidth = 0; - const contribMap: Record = {}; - 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 { - const result: Record = {}; - 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; -} diff --git a/packages/grafana-ui/src/components/Table/TableNG/uWrap.ts b/packages/grafana-ui/src/components/Table/TableNG/uWrap.ts new file mode 100644 index 00000000000..2aeb1530455 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/uWrap.ts @@ -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 = {}; + + 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> = {}; + + 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, + }; +} diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index 737bfa48539..3cac59dc30a 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -27,7 +27,6 @@ import { import { TableCellInspectorMode } from '../..'; import { getTextColorForAlphaBackground } from '../../../utils'; -import { countMultiLines } from './canvas-hypertxt-mod'; import { TABLE } from './constants'; import { CellColors, @@ -40,6 +39,7 @@ import { Comparator, TableFooterCalc, } from './types'; +import { uWrap } from './uWrap'; /* ---------------------------- Cell calculations --------------------------- */ // NOTE: This is now a fallback in case canvas-hypertxt fails @@ -99,22 +99,29 @@ export function getCellHeight( return defaultRowHeight; } -export function calculateCellHeight( - text: string, - font: string, - cellWidth: number, +export type CellHeightCalculator = (text: string, cellWidth: number) => number; + +export function getCellHeightCalculator( + // should be pre-configured with font and letterSpacing ctx: CanvasRenderingContext2D, lineHeight: number, defaultRowHeight: number, padding = 0 ) { - const effectiveCellWidth = Math.max(cellWidth, 20); // Minimum width to work with - const TOTAL_PADDING = padding * 2; + let uw = uWrap(ctx); - const numLines = countMultiLines(ctx, text, font, effectiveCellWidth, true); + return (text: string, cellWidth: number) => { + const effectiveCellWidth = Math.max(cellWidth, 20); // Minimum width to work with + const TOTAL_PADDING = padding * 2; + + let numLines = 0; + uw.each(text, effectiveCellWidth, () => { + numLines++; + }); - const totalHeight = numLines * lineHeight + TOTAL_PADDING; - return Math.max(totalHeight, defaultRowHeight); + const totalHeight = numLines * lineHeight + TOTAL_PADDING; + return Math.max(totalHeight, defaultRowHeight); + }; } export function getDefaultRowHeight(theme: GrafanaTheme2, cellHeight: TableCellHeight | undefined): number { @@ -139,12 +146,9 @@ export function getDefaultRowHeight(theme: GrafanaTheme2, cellHeight: TableCellH */ export function getRowHeight( row: TableRow, - ctx: CanvasRenderingContext2D, - font: string, + calc: CellHeightCalculator, avgCharWidth: number, - lineHeight: number, defaultRowHeight: number, - padding: number, fieldsData: { headersLength: number; textWraps: { [key: string]: boolean }; @@ -176,17 +180,7 @@ export function getRowHeight( } } - return maxLinesCol === '' - ? defaultRowHeight - : calculateCellHeight( - row[maxLinesCol] as string, - font, - fieldsData.columnWidths[maxLinesCol], - ctx, - lineHeight, - defaultRowHeight, - padding - ); + return maxLinesCol === '' ? defaultRowHeight : calc(row[maxLinesCol] as string, fieldsData.columnWidths[maxLinesCol]); } export function isTextCell(key: string, columnTypes: Record): boolean {