import { LineController, defaults } from "chart.js"; import numeral from "numeral"; export class MetricChart extends LineController { static id = "metric"; static overrides = { plugins: { legend: { display: false, }, }, scales: { x: { display: false, }, y: { display: false, }, }, }; draw() { const meta = this.getDataset(); const pt0 = meta.data[0]; const ctx = this.chart.ctx; const height = this.chart.chartArea.height; const width = this.chart.chartArea.width; } } const drawChart = (ctx: CanvasRenderingContext2D, value: string, label: string, options: { left: number, top: number, height: number, width: number, align: "start" | "end" | "center", labelPosition: "vertical" | "horizontal", font?: { weight: string, size: number, } }) => { // ctx.save(); // ctx.clearRect(0, 0, options.width, options.height); // Draw the text ctx.fillStyle = defaults.color as string; let largest = 1; ctx.font = "16px " + defaults.font.family; const labelMetrics = ctx.measureText(label + ""); let labelWidth = labelMetrics.width; let labelHeight = labelMetrics.actualBoundingBoxAscent + labelMetrics.actualBoundingBoxDescent; let labelVerticalOffset = 0; let labelHorizontalOffset = 0; let valueWidth = 0; let valueHeight = 0; let valueVerticalOffset = 0; let valueHorizontalOffset = 0; let descender = 0; if (options.font?.size) { largest = options.font.size; ctx.font = `${options.font?.weight || 600} ${largest}px ${defaults.font.family}`; const metrics = ctx.measureText(value + ""); valueWidth = metrics.width; valueHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; descender = metrics.actualBoundingBoxDescent; } else { ctx.font = `${options.font?.weight || 600} ${largest}px ${defaults.font.family}`; const metrics = ctx.measureText(value + ""); valueWidth = metrics.width; valueHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; descender = metrics.actualBoundingBoxDescent; let fullWidth = options.labelPosition === "horizontal" ? valueWidth + labelWidth : valueWidth; while (fullWidth < options.width && (valueHeight + 6) < options.height * 0.8) { largest += 1; ctx.font = `${options.font?.weight || 600} ${largest}px ${defaults.font.family}`; const metrics = ctx.measureText(value + ""); valueWidth = metrics.width; valueHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; fullWidth = options.labelPosition === "horizontal" ? valueWidth + labelWidth : valueWidth; descender = metrics.actualBoundingBoxDescent; }; } ctx.textBaseline = "alphabetic"; ctx.textAlign = "left"; if (options.labelPosition === "horizontal") { valueVerticalOffset = options.top + options.height; labelVerticalOffset = options.top + options.height; if (options.align === "start") { valueHorizontalOffset = options.left; labelHorizontalOffset = options.left + valueWidth + 6; } else if (options.align === "center") { const totalWidth = valueWidth + labelWidth + 6; let start = ((options.width - totalWidth) / 2) + options.left; valueHorizontalOffset = start; labelHorizontalOffset = start + valueWidth + 6; } else if (options.align === "end") { valueHorizontalOffset = options.left + options.width - valueWidth - labelWidth - 6; labelHorizontalOffset = options.left + options.width - labelWidth; } } else if (options.labelPosition === "vertical") { valueVerticalOffset = options.top + options.height - 18 - descender; labelVerticalOffset = options.top + options.height; if (options.align === "start") { valueHorizontalOffset = options.left; labelHorizontalOffset = options.left; } else if (options.align === "center") { const totalWidth = valueWidth + labelWidth + 6; valueHorizontalOffset = options.left + (totalWidth / 2) - (valueWidth / 2); labelHorizontalOffset = options.left + (totalWidth / 2) - (labelWidth); } else if (options.align === "end") { valueHorizontalOffset = options.left + options.width - valueWidth; labelHorizontalOffset = options.left + options.width - labelWidth; } } labelHorizontalOffset = Math.max(options.left, labelHorizontalOffset); valueHorizontalOffset = Math.max(options.left, valueHorizontalOffset); labelHorizontalOffset = Math.min(options.left + options.width - labelWidth, labelHorizontalOffset); valueHorizontalOffset = Math.min(options.left + options.width - valueWidth, valueHorizontalOffset); ctx.fillText(value, valueHorizontalOffset, valueVerticalOffset, options.width); ctx.font = "16px " + defaults.font.family; ctx.fillText(label, labelHorizontalOffset, labelVerticalOffset, options.width); ctx.restore(); } export const MetricPlugin = { id: "metric", afterDraw: (chart: any, args: any, options: any) => { const { ctx } = chart; if (chart.config.type !== "metric") return; const dataset = chart.data.datasets[0]; // @ts-ignore const label = dataset.label || ""; let raw = dataset.data[0]; let value: string; if (typeof raw === "number") { value = numeral(raw).format("0,0.[00]"); } else if (typeof raw === "string") { const s = raw.trim(); // Only format if the entire string is numeric (allowing optional commas and decimals) const numericPattern = /^-?\d{1,3}(?:,\d{3})*(?:\.\d+)?$|^-?\d+(?:\.\d+)?$/; if (numericPattern.test(s)) { value = numeral(s).format("0,0.[00]"); } else { value = raw; } } else { value = raw + ""; } drawChart(ctx, value, label, { left: chart.chartArea.left, top: chart.chartArea.top, height: chart.chartArea.height, width: chart.chartArea.width, align: options.align || "start", labelPosition: options.labelPosition || "horizontal", font: options.font || undefined }); }, }; export default MetricChart;