// Shared text rendering helpers for widgets /** * Renders a text element with optional wrapping. * 1) If the text ends with a parenthetical ("... (...)"), and is long (> 36), split conservatively into two lines. * 2) Otherwise, if maxWidthPx is provided and the estimated width exceeds it, split into two balanced lines by words. * 3) Else render as a single line. */ export function renderWrappedText( text: string, x: number, y: number, className: string, lineHeight = "1.1em", maxWidthPx?: number, approxCharWidthPx = 8 ): string { let lines: string[] = [] const titlePattern = /^(.*)\s+(\(.+\))$/ const m = text.match(titlePattern) if (m?.[1] && m[2]) { const shouldSplit = text.length > 36 lines = shouldSplit ? [m[1].trim(), m[2].trim()] : [text] } else { const estimated = text.length * approxCharWidthPx if (maxWidthPx && estimated > maxWidthPx) { const words = text.split(/\s+/).filter(Boolean) if (words.length > 1) { const wordWidths = words.map((w) => w.length * approxCharWidthPx) const total = wordWidths.reduce((a, b) => a + b, 0) + (words.length - 1) * approxCharWidthPx const target = total / 2 let acc = 0 let splitIdx = 1 for (let i = 0; i < words.length - 1; i++) { const w = wordWidths[i] ?? 0 acc += w + approxCharWidthPx if (acc >= target) { splitIdx = i + 1 break } } const left = words.slice(0, splitIdx).join(" ") const right = words.slice(splitIdx).join(" ") lines = [left, right] } else { lines = [text] } } else { lines = [text] } } let tspans = "" lines.forEach((line, i) => { const dy = i === 0 ? "0" : lineHeight tspans += `${line}` }) return `${tspans}` } /** * Renders a Y-axis label that wraps to fit the chart height and is rotated -90 degrees around (x, yCenter). * The wrapping width is derived from the available chart height. */ export function renderRotatedWrappedYAxisLabel( text: string, x: number, yCenter: number, chartHeightPx: number, className = "axis-label", lineHeight = "1.1em", approxCharWidthPx = 8, paddingPx = 10 ): string { const maxWrappedWidth = Math.max(20, chartHeightPx - 2 * paddingPx) let wrapped = renderWrappedText( text, x, yCenter, className, lineHeight, maxWrappedWidth, approxCharWidthPx ) // Inject rotation transform with pivot wrapped = wrapped.replace( " alt text = text.replace(/!\[([^\]]*)]\([^)]*\)/g, "$1") // Inline links: [text](url) -> text text = text.replace(/\[([^\]]+)\]\((?:[^)]+)\)/g, "$1") // Reference-style links: [text][ref] -> text text = text.replace(/\[([^\]]+)\]\[[^\]]*\]/g, "$1") // Autolinks: or -> remove entirely text = text.replace(/<(?:(?:https?:\/\/)|mailto:)[^>]+>/gim, "") // Code fences (```lang\ncode\n```): drop fences, keep inner text text = text.replace(/```[\s\S]*?```/g, (m) => m.replace(/```/g, "")) // Inline code: `code` -> code text = text.replace(/`([^`]+)`/g, "$1") // Emphasis/strong/strike markers -> keep inner text text = text.replace(/(\*\*|__)(.*?)\1/g, "$2") text = text.replace(/(\*|_)(.*?)\1/g, "$2") text = text.replace(/~~(.*?)~~/g, "$1") // Headings: remove leading # markup text = text.replace(/^#{1,6}\s+/gm, "") // Blockquotes: remove '>' markers text = text.replace(/^>\s?/gm, "") // Lists: bullets and ordered markers at line starts text = text.replace(/^\s*[-*+]\s+/gm, "") text = text.replace(/^\s*\d+\.\s+/gm, "") // HTML tags: strip tags but keep inner text text = text.replace(/<\/?[^>]+>/g, "") // Unescape common backslash-escaped punctuation used by markdown text = text.replace(/\\([\\`*_{}[\]()#+\-.!>])/g, "$1") // Collapse whitespace text = text.replace(/\u00A0/g, " ") // nbsp text = text.replace(/\s+/g, " ").trim() // If the remaining text is just a URL, drop it if (/^(?:https?:\/\/|mailto:)[^\s]+$/i.test(text)) { return "" } return text } /** * Estimates the final dimensions (width and height) of a text element after wrapping. * This function uses the same wrapping logic as CanvasImpl.wrapText for accurate prediction. * * @returns An object with the total height and maximum width of the wrapped text. */ export function estimateWrappedTextDimensions( text: string, maxWidthPx: number, fontSize = 16, lineHeight = 1.2 // approxCharWidthPx = 8 // Unused parameter ): { height: number; maxWidth: number } { // Use shared wrapText logic for accurate prediction const lines = wrapText(text, maxWidthPx, fontSize) const totalHeight = fontSize * lines.length * lineHeight const maxWidth = Math.max( ...lines.map((line) => estimateTextWidth(line, fontSize)) ) return { height: totalHeight, maxWidth } } /** * Shared text wrapping function that matches CanvasImpl.wrapText logic */ function wrapText(text: string, maxWidth: number, fontPx: number): string[] { // First check for explicit line breaks if (text.includes("\n")) { return text.split("\n") } // Simple word-based wrapping const words = text.split(" ") const lines: string[] = [] let currentLine = "" for (const word of words) { const testLine = currentLine ? `${currentLine} ${word}` : word const testWidth = estimateTextWidth(testLine, fontPx) if (testWidth > maxWidth && currentLine) { // Word doesn't fit, start new line lines.push(currentLine) currentLine = word } else { // Word fits, add it to current line currentLine = testLine } } // Add the last line if (currentLine) { lines.push(currentLine) } return lines.length > 0 ? lines : [text] } /** * Shared text width estimation that matches CanvasImpl.estimateTextWidth */ function estimateTextWidth(text: string, fontPx: number): number { // Simple heuristic: average character width is about 0.6 * font size // This is server-safe and deterministic return text.length * fontPx * 0.6 }