import { Chart, registerables } from 'chart.js'; import { Canvas } from 'skia-canvas'; Chart.register(...registerables); const CHART_BACKGROUND_PLUGIN = { id: 'custom_chart_background', beforeDraw(chart) { const { ctx, canvas, chartArea } = chart; const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height); gradient.addColorStop(0, '#110f1f'); gradient.addColorStop(0.55, '#1c1733'); gradient.addColorStop(1, '#2b1640'); ctx.save(); ctx.fillStyle = gradient; ctx.fillRect(0, 0, canvas.width, canvas.height); if (chartArea) { const boardGradient = ctx.createLinearGradient(chartArea.left, chartArea.top, chartArea.right, chartArea.bottom); boardGradient.addColorStop(0, 'rgba(255,255,255,0.04)'); boardGradient.addColorStop(1, 'rgba(207,107,243,0.12)'); ctx.fillStyle = boardGradient; roundRect(ctx, chartArea.left - 12, chartArea.top - 16, chartArea.right - chartArea.left + 24, chartArea.bottom - chartArea.top + 28, 18); ctx.fill(); } ctx.restore(); }, }; Chart.register(CHART_BACKGROUND_PLUGIN); export async function createProfitChartPng(points, options = {}) { const width = options.width ?? 1200; const height = options.height ?? 680; const canvas = new Canvas(width, height); const ctx = canvas.getContext('2d'); const safePoints = points.length > 0 ? points : [{ label: 'Start', runningProfit: 0 }]; const labels = safePoints.map((point) => point.label); const values = safePoints.map((point) => point.runningProfit); const profitGradient = ctx.createLinearGradient(0, 0, width, 0); profitGradient.addColorStop(0, '#9D91FB'); profitGradient.addColorStop(0.55, '#B883FD'); profitGradient.addColorStop(1, '#CF6BF3'); const pointGradient = safePoints.map((point) => (point.runningProfit >= 0 ? '#CF6BF3' : '#fb7185')); const latestValue = safePoints[safePoints.length - 1].runningProfit; const chart = new Chart(ctx, { type: 'line', data: { labels, datasets: [ { label: 'Cumulative Profit', data: values, borderColor: profitGradient, borderWidth: 5, pointRadius: 4, pointHoverRadius: 5, pointBorderWidth: 0, pointBackgroundColor: pointGradient, tension: 0.32, fill: false, }, ], }, options: { responsive: false, maintainAspectRatio: false, animation: false, layout: { padding: { top: 28, right: 28, bottom: 18, left: 18, }, }, plugins: { legend: { display: false, }, title: { display: true, text: options.title ?? 'Profit Trend', color: '#F4F0FF', align: 'start', font: { size: 30, weight: 'bold', }, padding: { bottom: 6, }, }, subtitle: { display: true, text: options.subtitle ?? buildChartSummaryText(points), color: '#D9CCFF', align: 'start', font: { size: 15, }, padding: { bottom: 18, }, }, tooltip: { enabled: true, backgroundColor: '#161225', titleColor: '#F4F0FF', bodyColor: '#E8DEFF', borderColor: '#9D91FB', borderWidth: 1, displayColors: false, callbacks: { label(context) { return ` ${formatCurrency(context.parsed.y)}`; }, }, }, }, scales: { x: { ticks: { color: '#D9CCFF', maxRotation: 0, autoSkip: true, maxTicksLimit: 10, }, grid: { color: 'rgba(255,255,255,0.05)', drawTicks: false, }, border: { color: 'rgba(157,145,251,0.30)', }, }, y: { ticks: { color: '#D9CCFF', callback(value) { return formatCurrency(Number(value)); }, }, grid: { color(context) { return Number(context.tick.value) === 0 ? 'rgba(157,145,251,0.42)' : 'rgba(255,255,255,0.06)'; }, lineWidth(context) { return Number(context.tick.value) === 0 ? 2 : 1; }, }, border: { color: 'rgba(157,145,251,0.30)', }, }, }, }, plugins: [ { id: 'latest-value-pill', afterDatasetsDraw(innerChart) { const datasetMeta = innerChart.getDatasetMeta(0); const lastPoint = datasetMeta.data[datasetMeta.data.length - 1]; if (!lastPoint) { return; } const pillText = `Now ${formatCurrency(latestValue)}`; const { x, y } = lastPoint.getProps(['x', 'y'], true); const paddingX = 12; const paddingY = 8; innerChart.ctx.save(); innerChart.ctx.font = 'bold 14px sans-serif'; const textWidth = innerChart.ctx.measureText(pillText).width; const pillWidth = textWidth + paddingX * 2; const pillHeight = 34; const pillX = Math.min(innerChart.canvas.width - pillWidth - 20, x + 14); const pillY = Math.max(28, y - pillHeight - 10); innerChart.ctx.fillStyle = 'rgba(18, 15, 32, 0.92)'; roundRect(innerChart.ctx, pillX, pillY, pillWidth, pillHeight, 17); innerChart.ctx.fill(); innerChart.ctx.strokeStyle = 'rgba(207, 107, 243, 0.70)'; innerChart.ctx.lineWidth = 1.2; roundRect(innerChart.ctx, pillX, pillY, pillWidth, pillHeight, 17); innerChart.ctx.stroke(); innerChart.ctx.fillStyle = '#F4F0FF'; innerChart.ctx.fillText(pillText, pillX + paddingX, pillY + 22); innerChart.ctx.restore(); }, }, ], }); const buffer = await canvas.toBuffer('png'); chart.destroy(); return buffer; } export function buildChartSummaryText(points) { if (points.length === 0) { return 'No resolved bets yet. Log and grade bets to build your trend line.'; } const latest = points[points.length - 1]; return `Cumulative profit after ${points.length} resolved bet${points.length === 1 ? '' : 's'}: ${formatCurrency(latest.runningProfit)}`; } function formatCurrency(value) { const sign = value >= 0 ? '+' : '-'; return `${sign}$${Math.abs(value).toFixed(2)}`; } function roundRect(ctx, x, y, width, height, radius) { ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.arcTo(x + width, y, x + width, y + height, radius); ctx.arcTo(x + width, y + height, x, y + height, radius); ctx.arcTo(x, y + height, x, y, radius); ctx.arcTo(x, y, x + width, y, radius); ctx.closePath(); }