ROIBot / src /chart.js
Codex
Unify Circa paging and purple rebrand
a24bf4c
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();
}