mirror of https://github.com/grafana/grafana
parent
c72530350e
commit
d99b9313aa
@ -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, |
||||||
|
}; |
||||||
|
} |
Loading…
Reference in new issue