/* * This file is part of TREB. * * TREB is free software: you can redistribute it and/or modify it under the * terms of the GNU General Public License as published by the Free Software * Foundation, either version 3 of the License, or (at your option) any * later version. * * TREB is distributed in the hope that it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more * details. * * You should have received a copy of the GNU General Public License along * with TREB. If not, see . * * Copyright 2022-2026 trebco, llc. * info@treb.app * */ /** size, really */ export interface Metrics { width: number; height: number; } export class Measurement { /** canvas used for color measurement */ private static color_measurement_canvas: HTMLCanvasElement; /** * node used for text metrics. this has to be added to * the DOM, so it's fixed and shifted off screen. */ private static text_measurement_node: HTMLElement; /** cache for color lookups */ private static color_cache: {[index: string]: Uint8ClampedArray} = {}; public static MeasureColorARGB(color: string): string { const bytes = this.MeasureColor(color); let argb = 'FF'; // always 100% for (let i = 0; i < 3; i++) { const hex = bytes[i].toString(16); if (hex.length === 0) argb += '00'; else if (hex.length === 1) argb += `0${hex}`; else argb += hex; } return argb.toUpperCase(); } /** * measure a color. turns symbolic or rgb colors into rgb values. * * UPDATE: prefill with #fff. that prevents it from randomly returning * the last value, if the color doesn't work for some reason. */ public static MeasureColor(color: string): Uint8ClampedArray { let cached = this.color_cache[color]; if (cached) { return cached; } // document not available. we can't handle symbolic colors // but we can handle rgb if (typeof document === 'undefined') { let parsed = [0, 0, 0]; let match = color.match(/^#{0,1}([A-Za-z0-9]{2})([A-Za-z0-9]{2})([A-Za-z0-9]{2})/); if (match) { parsed = [ Number.parseInt(match[1], 16), Number.parseInt(match[2], 16), Number.parseInt(match[3], 16), ]; } if (!match) { match = color.match(/^#{0,1}([A-Za-z0-9]{1})([A-Za-z0-9]{1})([A-Za-z0-9]{1})/); if (match) { parsed = [ Number.parseInt(match[1], 16), Number.parseInt(match[2], 16), Number.parseInt(match[3], 16), ]; } } if (!match) { match = color.match(/^rgb\((\d+),\s*(\d+)\s*(\d+)\)/); if (match) { parsed = [ Number.parseInt(match[1]), Number.parseInt(match[2]), Number.parseInt(match[3]), ]; } } cached = new Uint8ClampedArray(parsed); this.color_cache[color] = cached; return cached; } if (!this.color_measurement_canvas) { this.color_measurement_canvas = document.createElement('canvas'); this.color_measurement_canvas.width = 1; this.color_measurement_canvas.height = 1; } const context = this.color_measurement_canvas.getContext('2d', { willReadFrequently: true, }); if (context) { context.fillStyle = '#fff'; context.fillRect(0, 0, 1, 1); context.fillStyle = color; context.fillRect(0, 0, 1, 1); // cached = context.getImageData(0, 0, 1, 1).data; // FIXME: should clone this cached = new Uint8ClampedArray(context.getImageData(0, 0, 1, 1).data); this.color_cache[color] = cached; return cached; } return new Uint8ClampedArray(3); } public static EnsureMeasurementNode(): void { if (!this.text_measurement_node) { const node = document.querySelector('.treb-chart-measurement-node'); if (node) { this.text_measurement_node = node as HTMLElement; } else { this.text_measurement_node = document.createElement('div'); this.text_measurement_node.classList.add('treb-chart-measurement-node'); this.text_measurement_node.style.margin = '0px'; this.text_measurement_node.style.padding = '0px'; this.text_measurement_node.style.height = 'initial'; this.text_measurement_node.style.width = 'initial'; this.text_measurement_node.style.whiteSpace = 'nowrap'; this.text_measurement_node.style.position = 'fixed'; this.text_measurement_node.style.border = '0px'; this.text_measurement_node.style.border = '1px solid red'; this.text_measurement_node.style.boxSizing = 'content-box'; this.text_measurement_node.style.top = this.text_measurement_node.style.left = '-1000px'; document.body.appendChild(this.text_measurement_node); } } } /** * check if font is loaded, based on the theory that the alternatives * will be different sizes. note that this probably doesn't test weights * or italics properly, as those can be emulated without the specific face. * * I guess the thing to do in that case would be to load the alternate faces * first, and assume they are loaded serially (they're not). * * @param font_face * @param italic * @param bold */ public static FontLoaded(font_face: string, italic = false, weight = 400): boolean { const face = `${italic ? 'italic' : ''} ${weight} 20pt ${font_face}`; const m1 = this.MeasureText(`${face}, sans-serif`, `check font`); const m2 = this.MeasureText(`${face}, serif`, `check font`); const m3 = this.MeasureText(`${face}, monospace`, `check font`); return (m1.width === m2.width && m2.width === m3.width); } /** * measure width, height of text, accounting for rotation */ public static MeasureText(font: string, text: string, angle = 0): Metrics { this.EnsureMeasurementNode(); this.text_measurement_node.style.font = font; if (/\n/.test(text)) { text = text.replace(/\n/g, '
'); this.text_measurement_node.innerHTML = text; } else { this.text_measurement_node.textContent = text; } this.text_measurement_node.style.lineHeight = '1em'; if (angle) { this.text_measurement_node.style.transform = `rotate(${angle}deg)`; } else this.text_measurement_node.style.transform = ''; const rect = this.text_measurement_node.getBoundingClientRect(); return { width: rect.width, height: rect.height, }; } }