| 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(); |
| } |
|
|