"use client"; import { useEffect, useMemo, useState } from "react"; import Chart from "chart.js/auto"; import type { ChartConfiguration, ChartDataset, ChartOptions, Plugin, } from "chart.js"; import { Group, Image as KonvaImage, Rect } from "react-konva"; import { ellipsizeChartText, markdownToPlainChartText, normalizeChartTypeName, } from "@/components/slide-editor/charts/chart-data"; import type { DataLabelPosition } from "@/components/slide-editor/types"; import { asRecord, clamp, isRecord, readArray, readBoolean, readNumber, readString, withHash, type RawElement, } from "@/components/slide-editor/model/core"; type RawChartPoint = { x: number; y: number; r?: number; }; type RawChartDataset = { categoryColors?: string[]; color: string; name: string; points: RawChartPoint[]; values: number[]; }; type ChartJsKind = { area: boolean; chartJsType: | "bar" | "bubble" | "doughnut" | "line" | "pie" | "polarArea" | "radar" | "scatter"; horizontal: boolean; pieLike: boolean; stacked: boolean; }; type LabelBounds = { bottom: number; left: number; right: number; top: number; }; type LineSegment = { end: { x: number; y: number }; start: { x: number; y: number }; }; const DEFAULT_CHART_COLORS = [ "#7F22FE", "#155DFC", "#F59E0B", "#12B76A", "#EF4444", "#06B6D4", "#8B5CF6", "#64748B", ]; const CHART_FONT_FAMILY = "Inter, Arial, sans-serif"; const DATA_LABEL_POSITIONS = new Set(["base", "mid", "top", "outside"]); const CHART_TITLE_DISPLAY_MAX_LENGTH = 44; const CHART_AXIS_TITLE_DISPLAY_MAX_LENGTH = 36; const CHART_AXIS_TICK_DISPLAY_MAX_LENGTH = 22; const CHART_LEGEND_DISPLAY_MAX_LENGTH = 28; export function TemplateV2ChartJsElement({ element, height, interactive, width, }: { element: RawElement; height: number; interactive: boolean; width: number; }) { const [canvas, setCanvas] = useState(null); const logicalWidth = Math.max(1, Math.round(width)); const logicalHeight = Math.max(1, Math.round(height)); const pixelRatio = typeof window === "undefined" ? 1 : clamp(window.devicePixelRatio || 1, 1, interactive ? 2 : 3); const renderSignature = useMemo( () => chartRenderSignature( element, logicalWidth, logicalHeight, pixelRatio, ), [element, logicalHeight, logicalWidth, pixelRatio], ); const renderInput = useMemo( () => ({ element, logicalHeight, logicalWidth, pixelRatio, renderSignature, }), [element, logicalHeight, logicalWidth, pixelRatio, renderSignature], ); useEffect(() => { if (typeof document === "undefined") return; const nextCanvas = document.createElement("canvas"); nextCanvas.width = renderInput.logicalWidth; nextCanvas.height = renderInput.logicalHeight; nextCanvas.style.width = `${renderInput.logicalWidth}px`; nextCanvas.style.height = `${renderInput.logicalHeight}px`; const config = createChartJsConfig( renderInput.element, renderInput.logicalHeight, renderInput.pixelRatio, ); let chart: Chart | null = null; try { chart = new Chart(nextCanvas, config); chart.update("none"); setCanvas(nextCanvas); } catch (error) { console.error("Failed to render template v2 Chart.js chart:", error); setCanvas(null); } return () => { chart?.destroy(); }; }, [renderInput]); return ( {canvas ? ( ) : null} ); } function chartRenderSignature( element: RawElement, width: number, height: number, pixelRatio: number, ) { return stableChartStringify({ axis_color: element.axis_color, axisColor: element.axisColor, categories: element.categories, chart_type: element.chart_type, chartType: element.chartType, color: element.color, colors: element.colors, data: element.data, data_labels: element.data_labels, dataLabels: element.dataLabels, grid_color: element.grid_color, gridColor: element.gridColor, height, legend: element.legend, pixelRatio, series: element.series, showLegend: element.showLegend, text_color: element.text_color, textColor: element.textColor, title: element.title, title_color: element.title_color, titleColor: element.titleColor, width, x_axis: element.x_axis, x_axis_grid: element.x_axis_grid, x_axis_title: element.x_axis_title, xAxis: element.xAxis, xAxisGrid: element.xAxisGrid, xAxisTitle: element.xAxisTitle, y_axis: element.y_axis, y_axis_grid: element.y_axis_grid, y_axis_title: element.y_axis_title, yAxis: element.yAxis, yAxisGrid: element.yAxisGrid, yAxisTitle: element.yAxisTitle, }); } function stableChartStringify(value: unknown) { return JSON.stringify(value, (_key, currentValue) => { if (!isRecord(currentValue)) return currentValue; return Object.keys(currentValue) .sort() .reduce>((result, key) => { result[key] = currentValue[key]; return result; }, {}); }); } function createChartJsConfig( element: RawElement, height: number, pixelRatio: number, ): ChartConfiguration { const kind = rawChartJsKind(element.chart_type ?? element.chartType); const primaryColor = safeChartColor( readString(element.color), DEFAULT_CHART_COLORS[0], ); const sourceColors = readArray(element.colors).map((value) => safeChartColor(String(value)), ); const rawColors = sourceColors.length > 0 ? sourceColors : [primaryColor]; const categories = rawChartCategories(element, kind.pieLike); const datasets = rawChartDatasets( element, categories, rawColors, kind.pieLike, ); const axisColor = safeChartColor( readString(element.axis_color ?? element.axisColor), "#98A2B3", ); const gridColor = safeChartColor( readString(element.grid_color ?? element.gridColor), axisColor, ); const textColor = safeChartColor( readString(element.text_color ?? element.textColor), "#475467", ); const titleColor = safeChartColor( readString(element.title_color ?? element.titleColor), "#344054", ); const title = markdownToPlainChartText(readString(element.title) ?? ""); const dataLabelPosition = readDataLabelPosition( hasOwn(element, "data_labels") ? element.data_labels : element.dataLabels, ); const showValues = dataLabelPosition != null; const showXAxisGrid = readBoolean(element.x_axis_grid ?? element.xAxisGrid) ?? true; const showYAxisGrid = readBoolean(element.y_axis_grid ?? element.yAxisGrid) ?? true; const showXAxis = readBoolean(element.x_axis ?? element.xAxis) ?? true; const showYAxis = readBoolean(element.y_axis ?? element.yAxis) ?? true; const xAxisTitle = markdownToPlainChartText( readString( "x_axis_title" in element ? element.x_axis_title : element.xAxisTitle, ) ?? "", ); const yAxisTitle = markdownToPlainChartText( readString( "y_axis_title" in element ? element.y_axis_title : element.yAxisTitle, ) ?? "", ); const fontSize = clamp(height * 0.033, 9, 18); const titleFontSize = clamp(height * 0.044, 11, 26); const valueFontSize = clamp(height * 0.029, 8, 15); const chartDatasets = createChartJsDatasets(kind, datasets); const autoShowLegend = kind.pieLike || datasets.length > 1 || Boolean(datasets[0]?.name && datasets[0].name !== "Series 1"); const showLegend = readBoolean( "legend" in element ? element.legend : element.showLegend, ) ?? autoShowLegend; return { type: kind.chartJsType, data: { labels: categories.map((category) => displayChartCategoryLabel(category)), datasets: chartDatasets, }, options: { animation: false, color: textColor, devicePixelRatio: pixelRatio, font: { family: CHART_FONT_FAMILY, }, indexAxis: kind.horizontal ? "y" : "x", layout: { padding: kind.pieLike ? { top: 16, right: 20, bottom: 12, left: 20 } : { top: 12, right: 22, bottom: 8, left: 12 }, }, maintainAspectRatio: false, normalized: true, plugins: { legend: showLegend ? { display: showLegend, labels: { boxHeight: Math.max(8, fontSize * 0.8), boxWidth: Math.max(8, fontSize * 0.8), color: textColor, font: { family: CHART_FONT_FAMILY, size: fontSize, weight: 600, }, padding: Math.max(8, fontSize), usePointStyle: true, }, position: "bottom", } : { display: false, }, title: { color: titleColor, display: Boolean(title), font: { family: CHART_FONT_FAMILY, size: titleFontSize, weight: 700, }, padding: { bottom: Math.max(16, titleFontSize * 0.8), top: 0, }, text: title .split(/\r?\n/) .filter(Boolean) .map((line) => ellipsizeChartText(line, CHART_TITLE_DISPLAY_MAX_LENGTH), ), }, tooltip: { enabled: false, }, }, responsive: false, scales: chartScales({ axisColor, fontSize, gridColor, kind, showXAxis, showXAxisGrid, showYAxis, showYAxisGrid, xAxisTitle, yAxisTitle, }), } as ChartOptions, plugins: [ chartValueLabelsPlugin({ enabled: showValues, fontSize: valueFontSize, horizontal: kind.horizontal, outsideColor: textColor, position: dataLabelPosition ?? "top", }), ], }; } function createChartJsDatasets( kind: ChartJsKind, datasets: RawChartDataset[], ): ChartDataset[] { if (kind.chartJsType === "pie" || kind.chartJsType === "doughnut") { const dataset = datasets[0] ?? emptyDataset(); return [ { backgroundColor: categoryColors(dataset), borderColor: "#FFFFFF", borderWidth: 1, data: dataset.values, hoverOffset: 0, label: displayChartLegendLabel(dataset.name), }, ]; } if (kind.chartJsType === "polarArea") { const sourceDatasets = datasets.length > 0 ? datasets : [emptyDataset()]; return sourceDatasets.map((dataset) => { const colors = sourceDatasets.length === 1 ? categoryColors(dataset) : dataset.values.map(() => dataset.color); return { backgroundColor: colors.map((color) => withAlpha(color, 0.78)), borderColor: colors, borderWidth: 1, data: dataset.values, label: displayChartLegendLabel(dataset.name), }; }); } if (kind.chartJsType === "scatter" || kind.chartJsType === "bubble") { return datasets.map((dataset) => { const colors = datasets.length === 1 ? categoryColors(dataset) : [withHash(dataset.color) ?? "#7F22FE"]; return { backgroundColor: colors.map((color) => withAlpha(color, 0.78)), borderColor: colors, borderWidth: 2, data: kind.chartJsType === "bubble" ? dataset.points.map((point) => ({ ...point, r: point.r ?? 6 })) : dataset.points.map(({ x, y }) => ({ x, y })), label: displayChartLegendLabel(dataset.name), pointRadius: kind.chartJsType === "scatter" ? 4 : undefined, pointHoverRadius: 4, }; }); } return datasets.map((dataset, index) => { const color = withHash(dataset.color) ?? DEFAULT_CHART_COLORS[index % DEFAULT_CHART_COLORS.length]; const isLineLike = kind.chartJsType === "line"; const datasetCategoryColors = datasets.length === 1 && dataset.categoryColors?.length ? categoryColors(dataset) : null; return { backgroundColor: kind.area ? withAlpha(color, 0.24) : isLineLike ? color : datasetCategoryColors ? datasetCategoryColors : color, borderColor: color, borderRadius: kind.chartJsType === "bar" ? kind.stacked ? 7 : (context: { raw: unknown }) => barBorderRadius(context.raw, kind.horizontal) : undefined, borderSkipped: kind.chartJsType === "bar" && kind.stacked ? "start" : false, borderWidth: isLineLike ? 3 : 0, data: dataset.values, fill: kind.area, label: displayChartLegendLabel(dataset.name), maxBarThickness: 62, pointBackgroundColor: datasetCategoryColors ?? color, pointBorderColor: "#FFFFFF", pointBorderWidth: isLineLike ? 1.5 : 0, pointRadius: isLineLike ? 3.5 : 0, tension: isLineLike ? 0.35 : 0, }; }); } function barBorderRadius(rawValue: unknown, horizontal: boolean) { const radius = 7; const value = chartValue(rawValue); if (horizontal) { return value < 0 ? { bottomLeft: radius, bottomRight: 0, topLeft: radius, topRight: 0, } : { bottomLeft: 0, bottomRight: radius, topLeft: 0, topRight: radius, }; } return value < 0 ? { bottomLeft: radius, bottomRight: radius, topLeft: 0, topRight: 0, } : { bottomLeft: 0, bottomRight: 0, topLeft: radius, topRight: radius, }; } function chartScales({ axisColor, fontSize, gridColor, kind, showXAxis, showXAxisGrid, showYAxis, showYAxisGrid, xAxisTitle, yAxisTitle, }: { axisColor: string; fontSize: number; gridColor: string; kind: ChartJsKind; showXAxis: boolean; showXAxisGrid: boolean; showYAxis: boolean; showYAxisGrid: boolean; xAxisTitle: string; yAxisTitle: string; }) { if (kind.pieLike || kind.chartJsType === "polarArea") return undefined; if (kind.chartJsType === "radar") { return { r: { angleLines: { color: withAlpha(gridColor, showXAxisGrid ? 0.35 : 0), display: showXAxisGrid, }, beginAtZero: true, grid: { color: withAlpha(gridColor, showYAxisGrid ? 0.35 : 0), display: showYAxisGrid, }, pointLabels: { color: axisColor, display: showXAxis, font: { family: CHART_FONT_FAMILY, size: fontSize, weight: 600, }, }, ticks: { backdropColor: "transparent", callback: (value: string | number) => formatAxisTick(value), color: axisColor, display: showYAxis, font: { family: CHART_FONT_FAMILY, size: Math.max(8, fontSize - 1), }, }, }, }; } const showCategoryGrid = kind.horizontal ? showXAxisGrid : showYAxisGrid; const showLinearGrid = kind.horizontal ? showYAxisGrid : showXAxisGrid; const showCategoryAxis = kind.horizontal ? showYAxis : showXAxis; const showLinearAxis = kind.horizontal ? showXAxis : showYAxis; const categoryAxis = { display: showCategoryAxis || showCategoryGrid, border: { color: axisColor, display: showCategoryAxis, }, grid: { color: withAlpha(gridColor, showCategoryGrid ? 0.25 : 0), display: showCategoryGrid, drawTicks: showCategoryAxis, }, stacked: kind.stacked, ticks: { color: axisColor, display: showCategoryAxis, font: { family: CHART_FONT_FAMILY, size: fontSize, weight: 600, }, }, title: { color: axisColor, display: showCategoryAxis && Boolean(kind.horizontal ? yAxisTitle : xAxisTitle), font: { family: CHART_FONT_FAMILY, size: fontSize, weight: 700, }, text: ellipsizeChartText( kind.horizontal ? yAxisTitle : xAxisTitle, CHART_AXIS_TITLE_DISPLAY_MAX_LENGTH, ), }, type: "category", }; const linearAxis = { beginAtZero: true, display: showLinearAxis || showLinearGrid, border: { color: axisColor, display: showLinearAxis, }, grace: "8%", grid: { color: withAlpha(gridColor, showLinearGrid ? 0.35 : 0), display: showLinearGrid, drawTicks: showLinearAxis, }, stacked: kind.stacked, ticks: { callback: (value: string | number) => formatAxisTick(value), color: axisColor, display: showLinearAxis, font: { family: CHART_FONT_FAMILY, size: Math.max(8, fontSize - 2), weight: 600, }, }, title: { color: axisColor, display: showLinearAxis && Boolean(kind.horizontal ? xAxisTitle : yAxisTitle), font: { family: CHART_FONT_FAMILY, size: fontSize, weight: 700, }, text: ellipsizeChartText( kind.horizontal ? xAxisTitle : yAxisTitle, CHART_AXIS_TITLE_DISPLAY_MAX_LENGTH, ), }, type: "linear", }; if (kind.chartJsType === "scatter" || kind.chartJsType === "bubble") { return { x: { ...linearAxis, display: showXAxis || showYAxisGrid, border: { ...linearAxis.border, display: showXAxis, }, grid: { color: withAlpha(gridColor, showYAxisGrid ? 0.35 : 0), display: showYAxisGrid, drawTicks: showXAxis, }, ticks: { ...linearAxis.ticks, display: showXAxis, }, title: { ...linearAxis.title, display: showXAxis && Boolean(xAxisTitle), text: ellipsizeChartText( xAxisTitle, CHART_AXIS_TITLE_DISPLAY_MAX_LENGTH, ), }, }, y: { ...linearAxis, display: showYAxis || showXAxisGrid, border: { ...linearAxis.border, display: showYAxis, }, grid: { color: withAlpha(gridColor, showXAxisGrid ? 0.35 : 0), display: showXAxisGrid, drawTicks: showYAxis, }, ticks: { ...linearAxis.ticks, display: showYAxis, }, title: { ...linearAxis.title, display: showYAxis && Boolean(yAxisTitle), text: ellipsizeChartText( yAxisTitle, CHART_AXIS_TITLE_DISPLAY_MAX_LENGTH, ), }, }, }; } return kind.horizontal ? { x: linearAxis, y: categoryAxis } : { x: categoryAxis, y: linearAxis }; } function rawChartJsKind(value: unknown): ChartJsKind { const normalized = normalizeChartTypeName(value); switch (normalized) { case "area": return baseKind("line", { area: true }); case "bubble": return baseKind("bubble"); case "donut": case "doughnut": return baseKind("doughnut", { pieLike: true }); case "horizontal_bar": case "bar_horizontal": return baseKind("bar", { horizontal: true }); case "line": return baseKind("line"); case "pie": return baseKind("pie", { pieLike: true }); case "polar": case "polar_area": return baseKind("polarArea"); case "radar": return baseKind("radar"); case "scatter": return baseKind("scatter"); case "stackedbar": case "stacked_bar": case "bar_stacked": case "stacked": return baseKind("bar", { stacked: true }); case "horizontalstackbar": case "horizontalstackedbar": case "horizontal_stack_bar": case "horizontal_stacked_bar": return baseKind("bar", { horizontal: true, stacked: true }); case "bar": default: return baseKind("bar"); } } function baseKind( chartJsType: ChartJsKind["chartJsType"], overrides: Partial = {}, ): ChartJsKind { return { area: false, chartJsType, horizontal: false, pieLike: false, stacked: false, ...overrides, }; } function rawChartCategories( element: RawElement, singleSeriesOnly = false, ): string[] { const categories = readArray(element.categories).map(String); const dataLabels = readArray(element.data) .map((item) => readString(asRecord(item)?.label)) .filter((label): label is string => Boolean(label)); const rawSeries = readArray(element.series).filter(isRecord); const series = singleSeriesOnly ? rawSeries.slice(0, 1) : rawSeries; const length = Math.min( 24, Math.max( 1, categories.length, dataLabels.length, ...series.map((item) => readArray(item.values ?? item.data).length), ), ); if (categories.length > 0) { return Array.from( { length }, (_, index) => categories[index] ?? `Item ${index + 1}`, ); } if (dataLabels.length > 0) { return Array.from( { length }, (_, index) => dataLabels[index] ?? `Item ${index + 1}`, ); } return Array.from({ length }, (_, index) => `Item ${index + 1}`); } function rawChartDatasets( element: RawElement, categories: string[], colors: string[], singleSeriesOnly = false, ): RawChartDataset[] { const rawSeries = readArray(element.series).filter(isRecord); const effectiveSeries = singleSeriesOnly ? rawSeries.slice(0, 1) : rawSeries; const categoryLength = Math.max(1, categories.length); if (effectiveSeries.length > 0) { return effectiveSeries.slice(0, 12).map((item, index) => { const rawValues = readArray(item.values ?? item.data); const values = normalizeValues( rawValues.map((value) => chartValue(value)), categoryLength, ); const points = normalizePoints( rawValues.map((value, valueIndex) => chartPoint(value, valueIndex)), categoryLength, values, ); return { color: colors[index % colors.length] ?? DEFAULT_CHART_COLORS[index % DEFAULT_CHART_COLORS.length], name: readString(item.name) ?? `Series ${index + 1}`, categoryColors: effectiveSeries.length === 1 ? colors : undefined, points, values, }; }); } const rawData = readArray(element.data).filter(isRecord); const values = normalizeValues( rawData.map((item) => chartValue(item)), categoryLength, ); return [ { color: colors[0] ?? safeChartColor(readString(element.color), DEFAULT_CHART_COLORS[0]), name: readString(element.title) ?? "Series 1", categoryColors: colors, points: normalizePoints( rawData.map((value, valueIndex) => chartPoint(value, valueIndex)), categoryLength, values, ), values, }, ]; } function chartValue(value: unknown) { const directNumber = readNumber(value); if (directNumber != null) return directNumber; const record = asRecord(value); return ( readNumber(record?.value) ?? readNumber(record?.y) ?? readNumber(record?.data) ?? 0 ); } function chartPoint(value: unknown, index: number): RawChartPoint { const record = asRecord(value); if (!record) { return { x: index + 1, y: chartValue(value) }; } return { x: readNumber(record.x) ?? index + 1, y: chartValue(record), ...(readNumber(record.r ?? record.radius) != null ? { r: readNumber(record.r ?? record.radius) ?? undefined } : {}), }; } function normalizeValues(values: number[], length: number) { const normalized = values.slice(0, length); while (normalized.length < length) normalized.push(0); return normalized; } function normalizePoints( points: RawChartPoint[], length: number, fallbackValues: number[], ) { const normalized = points.slice(0, length); while (normalized.length < length) { const index = normalized.length; normalized.push({ x: index + 1, y: fallbackValues[index] ?? 0 }); } return normalized; } function emptyDataset(): RawChartDataset { return { color: DEFAULT_CHART_COLORS[0], name: "Series 1", points: [{ x: 1, y: 0 }], values: [0], }; } function categoryColors(dataset: RawChartDataset) { const colors = dataset.categoryColors ?? []; return dataset.values.map( (_, index) => withHash(colors[index % colors.length]) ?? withHash(dataset.color) ?? DEFAULT_CHART_COLORS[index % DEFAULT_CHART_COLORS.length], ); } function readDataLabelPosition(value: unknown): DataLabelPosition | null { if (value === true) return "top"; if (value === false || value == null) return null; const text = readString(value); return text && DATA_LABEL_POSITIONS.has(text) ? (text as DataLabelPosition) : null; } function hasOwn(record: RawElement, key: string) { return Object.prototype.hasOwnProperty.call(record, key); } function chartValueLabelsPlugin({ enabled, fontSize, horizontal, outsideColor, position, }: { enabled: boolean; fontSize: number; horizontal: boolean; outsideColor: string; position: DataLabelPosition; }): Plugin { return { id: "templateV2ChartValueLabels", afterDatasetsDraw(chart) { if (!enabled) return; const ctx = chart.ctx; ctx.save(); ctx.font = `600 ${fontSize}px ${CHART_FONT_FAMILY}`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; const occupiedLabels: LabelBounds[] = []; const lineObstacles: LineSegment[] = []; const pointObstacles: LabelBounds[] = []; chart.data.datasets.forEach((_, datasetIndex) => { const meta = chart.getDatasetMeta(datasetIndex); const metaType = readString((meta as { type?: unknown }).type); if (meta.hidden || !isPointChartType(metaType)) return; meta.data.forEach((element) => { const bounds = pointElementBounds(element); if (bounds) pointObstacles.push(bounds); }); if (metaType === "line" || metaType === "radar") { for (let index = 1; index < meta.data.length; index += 1) { const start = chartElementPoint(meta.data[index - 1]); const end = chartElementPoint(meta.data[index]); if (start && end) lineObstacles.push({ start, end }); } } }); chart.data.datasets.forEach((dataset, datasetIndex) => { const meta = chart.getDatasetMeta(datasetIndex); if (meta.hidden) return; meta.data.forEach((element, index) => { const rawValue = readArray(dataset.data)[index]; const numeric = chartValue(rawValue); const metaType = readString((meta as { type?: unknown }).type); const label = formatChartValue(numeric); if (metaType === "bar") { drawBarValueLabel({ color: datasetBackgroundColor(dataset, index), ctx, element, fontSize, horizontal, label, outsideColor, position, value: numeric, }); return; } if (isPointChartType(metaType)) { drawPointValueLabel({ chartArea: chart.chartArea, ctx, datasetIndex, element, fontSize, index, label, lineLike: metaType === "line" || metaType === "radar", lineObstacles, metaElements: meta.data, occupiedLabels, outsideColor, position, pointObstacles, }); return; } if ( metaType === "pie" || metaType === "doughnut" || metaType === "polarArea" ) { drawArcValueLabel({ color: datasetBackgroundColor(dataset, index), ctx, element, fontSize, label, outsideColor, position, }); return; } const fallbackPosition = typeof element.tooltipPosition === "function" ? element.tooltipPosition(true) : null; if (!fallbackPosition) return; ctx.fillStyle = metaType === "pie" || metaType === "doughnut" || metaType === "polarArea" ? contrastTextColor( datasetBackgroundColor(dataset, index), outsideColor, ) : outsideColor; ctx.fillText(label, (fallbackPosition.x ?? 0), (fallbackPosition.y ?? 0)); }); }); ctx.restore(); }, }; } function drawBarValueLabel({ color, ctx, element, fontSize, horizontal, label, outsideColor, position, value, }: { color: string | null; ctx: CanvasRenderingContext2D; element: unknown; fontSize: number; horizontal: boolean; label: string; outsideColor: string; position: DataLabelPosition; value: number; }) { const bar = asRecord(element); const x = readNumber(bar?.x); const y = readNumber(bar?.y); const base = readNumber(bar?.base); const width = Math.abs(readNumber(bar?.width) ?? 0); const height = Math.abs(readNumber(bar?.height) ?? 0); if (x == null || y == null || base == null) return; const textWidth = ctx.measureText(label).width; const padding = 5; const fitsInside = horizontal ? width >= textWidth + padding * 2 && height >= fontSize * 1.35 : height >= fontSize * 1.65 && width >= textWidth + padding * 2; const resolvedPosition = position === "outside" || !fitsInside ? "outside" : position; if (resolvedPosition !== "outside") { ctx.fillStyle = contrastTextColor(color, outsideColor); if (horizontal) { const direction = value < 0 ? -1 : 1; const labelX = resolvedPosition === "base" ? base + direction * (textWidth / 2 + padding) : resolvedPosition === "top" ? x - direction * (textWidth / 2 + padding) : (x + base) / 2; ctx.fillText(label, labelX, y); return; } const direction = value < 0 ? 1 : -1; const labelY = resolvedPosition === "base" ? base + direction * (fontSize / 2 + padding) : resolvedPosition === "top" ? y - direction * (fontSize / 2 + padding) : (y + base) / 2; ctx.fillText( label, x, labelY, ); return; } ctx.fillStyle = outsideColor; if (horizontal) { const direction = value < 0 ? -1 : 1; ctx.fillText( label, x + direction * (textWidth / 2 + padding), y, ); return; } const direction = value < 0 ? 1 : -1; ctx.fillText(label, x, y + direction * (fontSize / 2 + padding)); } function drawPointValueLabel({ chartArea, ctx, datasetIndex, element, fontSize, index, label, lineLike, lineObstacles, metaElements, occupiedLabels, outsideColor, position, pointObstacles, }: { chartArea: LabelBounds; ctx: CanvasRenderingContext2D; datasetIndex: number; element: unknown; fontSize: number; index: number; label: string; lineLike: boolean; lineObstacles: LineSegment[]; metaElements: unknown[]; occupiedLabels: LabelBounds[]; outsideColor: string; position: DataLabelPosition; pointObstacles: LabelBounds[]; }) { const point = asRecord(element); const x = readNumber(point?.x); const y = readNumber(point?.y); if (!point || x == null || y == null) return; const radius = pointElementRadius(point); const textWidth = ctx.measureText(label).width; const textHeight = fontSize * 1.15; const verticalDirection = lineLike ? lineLabelDirection(metaElements, index, datasetIndex) : (index + datasetIndex) % 2 === 0 ? -1 : 1; const verticalOffset = radius + textHeight / 2 + 5; const horizontalOffset = radius + textWidth / 2 + 5; if (position !== "outside") { const candidate = position === "base" ? { x, y: y + verticalOffset } : position === "top" ? { x, y: y - verticalOffset } : { x, y }; const bounds = valueLabelBounds( candidate.x, candidate.y, textWidth, textHeight, ); const intersectsPoint = position !== "mid" && pointObstacles.some((obstacle) => labelBoundsOverlap(bounds, obstacle)); if ( labelFitsChartArea(bounds, chartArea) && occupiedLabels.every( (occupied) => !labelBoundsOverlap(bounds, occupied), ) && !intersectsPoint && lineObstacles.every( (segment) => !lineSegmentIntersectsBounds(segment, bounds), ) ) { occupiedLabels.push(bounds); ctx.fillStyle = outsideColor; ctx.fillText(label, candidate.x, candidate.y); return; } } const candidates = [ { x, y: y + verticalDirection * verticalOffset }, { x, y: y - verticalDirection * verticalOffset }, { x: x + horizontalOffset, y }, { x: x - horizontalOffset, y }, { x: x + horizontalOffset, y: y + verticalDirection * verticalOffset, }, { x: x - horizontalOffset, y: y + verticalDirection * verticalOffset, }, { x: x + horizontalOffset, y: y - verticalDirection * verticalOffset, }, { x: x - horizontalOffset, y: y - verticalDirection * verticalOffset, }, { x, y: y + verticalDirection * verticalOffset * 1.7 }, { x, y: y - verticalDirection * verticalOffset * 1.7 }, ]; const availableCandidate = candidates .map((candidate) => ({ ...candidate, bounds: valueLabelBounds( candidate.x, candidate.y, textWidth, textHeight, ), })) .find( (candidate) => labelFitsChartArea(candidate.bounds, chartArea) && occupiedLabels.every( (occupied) => !labelBoundsOverlap(candidate.bounds, occupied), ) && pointObstacles.every( (obstacle) => !labelBoundsOverlap(candidate.bounds, obstacle), ) && lineObstacles.every( (segment) => !lineSegmentIntersectsBounds(segment, candidate.bounds), ), ); if (!availableCandidate) return; const resolved = availableCandidate; const bounds = valueLabelBounds( resolved.x, resolved.y, textWidth, textHeight, ); occupiedLabels.push(bounds); ctx.fillStyle = outsideColor; ctx.fillText(label, resolved.x, resolved.y); } function drawArcValueLabel({ color, ctx, element, fontSize, label, outsideColor, position, }: { color: string | null; ctx: CanvasRenderingContext2D; element: unknown; fontSize: number; label: string; outsideColor: string; position: DataLabelPosition; }) { const arc = asRecord(element); const centerX = readNumber(arc?.x); const centerY = readNumber(arc?.y); const startAngle = readNumber(arc?.startAngle); const endAngle = readNumber(arc?.endAngle); const innerRadius = Math.max(0, readNumber(arc?.innerRadius) ?? 0); const outerRadius = Math.max(innerRadius, readNumber(arc?.outerRadius) ?? 0); let point: { x: number; y: number } | null = null; if ( centerX != null && centerY != null && startAngle != null && endAngle != null && outerRadius > 0 ) { const angle = (startAngle + endAngle) / 2; const ringWidth = Math.max(1, outerRadius - innerRadius); const textHeight = fontSize * 1.15; const radius = position === "outside" ? outerRadius + textHeight / 2 + 7 : position === "top" ? Math.max(innerRadius + textHeight / 2, outerRadius - textHeight / 2 - 5) : position === "base" ? innerRadius > 0 ? innerRadius + Math.min(ringWidth * 0.25, textHeight + 5) : outerRadius * 0.35 : innerRadius + ringWidth / 2; point = { x: centerX + Math.cos(angle) * radius, y: centerY + Math.sin(angle) * radius, }; } else if (typeof arc?.tooltipPosition === "function") { point = arc.tooltipPosition(true); } if (!point) return; ctx.fillStyle = position === "outside" ? outsideColor : contrastTextColor(color, outsideColor); ctx.fillText(label, point.x, point.y); } function isPointChartType(metaType: string | null) { return ( metaType === "line" || metaType === "scatter" || metaType === "bubble" || metaType === "radar" ); } function pointElementRadius(point: RawElement) { const options = asRecord(point.options); return Math.max( 0, readNumber(options?.radius) ?? readNumber(point.radius) ?? 3, ); } function pointElementBounds(element: unknown): LabelBounds | null { const point = asRecord(element); const x = readNumber(point?.x); const y = readNumber(point?.y); if (!point || x == null || y == null) return null; const radius = pointElementRadius(point) + 2; return { bottom: y + radius, left: x - radius, right: x + radius, top: y - radius, }; } function lineLabelDirection( elements: unknown[], index: number, datasetIndex: number, ) { const currentY = readNumber(asRecord(elements[index])?.y); const previousY = readNumber(asRecord(elements[index - 1])?.y); const nextY = readNumber(asRecord(elements[index + 1])?.y); if (currentY == null) return datasetIndex % 2 === 0 ? -1 : 1; if (previousY != null && nextY != null) { if (currentY <= previousY && currentY <= nextY) return -1; if (currentY >= previousY && currentY >= nextY) return 1; } if (nextY != null && previousY == null) return nextY < currentY ? 1 : -1; if (previousY != null && nextY == null) { return previousY < currentY ? 1 : -1; } return datasetIndex % 2 === 0 ? -1 : 1; } function chartElementPoint(element: unknown) { const point = asRecord(element); const x = readNumber(point?.x); const y = readNumber(point?.y); return x == null || y == null ? null : { x, y }; } function lineSegmentIntersectsBounds( segment: LineSegment, bounds: LabelBounds, ) { if ( pointInsideBounds(segment.start, bounds) || pointInsideBounds(segment.end, bounds) ) { return true; } const topLeft = { x: bounds.left, y: bounds.top }; const topRight = { x: bounds.right, y: bounds.top }; const bottomLeft = { x: bounds.left, y: bounds.bottom }; const bottomRight = { x: bounds.right, y: bounds.bottom }; return ( lineSegmentsIntersect(segment.start, segment.end, topLeft, topRight) || lineSegmentsIntersect(segment.start, segment.end, topRight, bottomRight) || lineSegmentsIntersect(segment.start, segment.end, bottomRight, bottomLeft) || lineSegmentsIntersect(segment.start, segment.end, bottomLeft, topLeft) ); } function pointInsideBounds( point: { x: number; y: number }, bounds: LabelBounds, ) { return ( point.x >= bounds.left && point.x <= bounds.right && point.y >= bounds.top && point.y <= bounds.bottom ); } function lineSegmentsIntersect( firstStart: { x: number; y: number }, firstEnd: { x: number; y: number }, secondStart: { x: number; y: number }, secondEnd: { x: number; y: number }, ) { if ( Math.max(firstStart.x, firstEnd.x) < Math.min(secondStart.x, secondEnd.x) || Math.min(firstStart.x, firstEnd.x) > Math.max(secondStart.x, secondEnd.x) || Math.max(firstStart.y, firstEnd.y) < Math.min(secondStart.y, secondEnd.y) || Math.min(firstStart.y, firstEnd.y) > Math.max(secondStart.y, secondEnd.y) ) { return false; } const firstA = lineSide(firstStart, firstEnd, secondStart); const firstB = lineSide(firstStart, firstEnd, secondEnd); const secondA = lineSide(secondStart, secondEnd, firstStart); const secondB = lineSide(secondStart, secondEnd, firstEnd); return firstA * firstB <= 0 && secondA * secondB <= 0; } function lineSide( start: { x: number; y: number }, end: { x: number; y: number }, point: { x: number; y: number }, ) { return ( (end.x - start.x) * (point.y - start.y) - (end.y - start.y) * (point.x - start.x) ); } function valueLabelBounds( x: number, y: number, width: number, height: number, ): LabelBounds { const padding = 2; return { bottom: y + height / 2 + padding, left: x - width / 2 - padding, right: x + width / 2 + padding, top: y - height / 2 - padding, }; } function labelFitsChartArea(bounds: LabelBounds, chartArea: LabelBounds) { return ( bounds.left >= chartArea.left && bounds.right <= chartArea.right && bounds.top >= chartArea.top && bounds.bottom <= chartArea.bottom ); } function labelBoundsOverlap(first: LabelBounds, second: LabelBounds) { return !( first.right < second.left || first.left > second.right || first.bottom < second.top || first.top > second.bottom ); } function datasetBackgroundColor(dataset: ChartDataset, index: number) { const backgroundColor = (dataset as { backgroundColor?: unknown }) .backgroundColor; const color = Array.isArray(backgroundColor) ? backgroundColor[index] : backgroundColor; return typeof color === "string" ? color : null; } function contrastTextColor(backgroundColor: string | null, fallback: string) { const background = parseChartColor(backgroundColor); if (!background) return fallback; const [red, green, blue, alpha] = background; const composite = [red, green, blue].map( (channel) => channel * alpha + 255 * (1 - alpha), ); const backgroundLuminance = relativeLuminance(composite); const dark = [16, 24, 40]; const light = [255, 255, 255]; const darkContrast = contrastRatio( backgroundLuminance, relativeLuminance(dark), ); const lightContrast = contrastRatio( backgroundLuminance, relativeLuminance(light), ); return lightContrast >= darkContrast ? "#FFFFFF" : "#101828"; } function parseChartColor( color: string | null, ): [number, number, number, number] | null { if (!color) return null; const hex = color.match(/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/); if (hex) { const raw = hex[1].length === 3 ? hex[1] .split("") .map((character) => character + character) .join("") : hex[1]; const value = Number.parseInt(raw, 16); return [(value >> 16) & 255, (value >> 8) & 255, value & 255, 1]; } const rgb = color.match(/^rgba?\(([^)]+)\)$/i); if (!rgb) return null; const channels = rgb[1].split(",").map((part) => Number(part.trim())); if (channels.length < 3 || channels.slice(0, 3).some(Number.isNaN)) { return null; } return [ clamp(channels[0], 0, 255), clamp(channels[1], 0, 255), clamp(channels[2], 0, 255), clamp(Number.isFinite(channels[3]) ? channels[3] : 1, 0, 1), ]; } function relativeLuminance(channels: number[]) { const [red, green, blue] = channels.map((channel) => { const normalized = channel / 255; return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; }); return red * 0.2126 + green * 0.7152 + blue * 0.0722; } function contrastRatio(first: number, second: number) { const lighter = Math.max(first, second); const darker = Math.min(first, second); return (lighter + 0.05) / (darker + 0.05); } function formatChartValue(value: number) { if (!Number.isFinite(value)) return "0"; if (Math.abs(value) >= 1000) { return Intl.NumberFormat("en", { notation: "compact" }).format(value); } return Number.isInteger(value) ? `${value}` : value.toFixed(1).replace(/\.0$/, ""); } function formatAxisTick(value: string | number) { const numeric = Number(value); return Number.isFinite(numeric) ? formatChartValue(numeric) : String(value); } function displayChartCategoryLabel(value: string) { return ellipsizeChartText(value, CHART_AXIS_TICK_DISPLAY_MAX_LENGTH); } function displayChartLegendLabel(value: string) { return ellipsizeChartText(value, CHART_LEGEND_DISPLAY_MAX_LENGTH); } function safeChartColor( value: string | null | undefined, fallback = DEFAULT_CHART_COLORS[0], ) { const color = withHash(value) ?? fallback; if ( /^#[0-9A-Fa-f]{3}$/.test(color) || /^#[0-9A-Fa-f]{6}$/.test(color) || /^rgba?\(/i.test(color) ) { return color; } return fallback; } function withAlpha(color: string, alpha: number) { const normalized = safeChartColor(color); const hex = normalized.match(/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/); if (!hex) { const rgb = normalized.match(/^rgba?\(([^)]+)\)$/i); if (rgb) { const channels = rgb[1] .split(",") .slice(0, 3) .map((part) => part.trim()); return `rgba(${channels.join(", ")}, ${alpha})`; } return normalized; } const raw = hex[1].length === 3 ? hex[1].split("").map((char) => char + char).join("") : hex[1]; const int = Number.parseInt(raw, 16); return `rgba(${(int >> 16) & 255}, ${(int >> 8) & 255}, ${int & 255}, ${alpha})`; }