ROIBot / src /baseball-charts.js
Codex
Separate pitcher plot legend panel
c6ca76d
import { Chart, registerables } from 'chart.js';
import { Canvas } from 'skia-canvas';
Chart.register(...registerables);
const CHART_BACKGROUND_PLUGIN = {
id: 'baseball_chart_background',
beforeDraw(chart) {
const { ctx, canvas, chartArea } = chart;
const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
gradient.addColorStop(0, '#0f1222');
gradient.addColorStop(0.55, '#151b33');
gradient.addColorStop(1, '#1a2440');
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.035)');
boardGradient.addColorStop(1, 'rgba(108, 163, 255, 0.12)');
ctx.fillStyle = boardGradient;
roundRect(ctx, chartArea.left - 12, chartArea.top - 16, chartArea.right - chartArea.left + 24, chartArea.bottom - chartArea.top + 28, 20);
ctx.fill();
}
ctx.restore();
},
};
Chart.register(CHART_BACKGROUND_PLUGIN);
const DEFAULT_WIDTH = 1200;
const DEFAULT_HEIGHT = 680;
const HR_COLORS = {
primary: '#f59e0b',
secondary: '#f97316',
tertiary: '#ef4444',
fill: 'rgba(245, 158, 11, 0.18)',
};
const K_COLORS = {
primary: '#38bdf8',
secondary: '#60a5fa',
tertiary: '#93c5fd',
fill: 'rgba(56, 189, 248, 0.18)',
};
const PITCHER_COLORS = {
primary: '#22c55e',
secondary: '#38bdf8',
tertiary: '#f59e0b',
fill: 'rgba(34, 197, 94, 0.18)',
};
const TEXT = {
title: '#F7F8FF',
subtitle: '#D6DDF7',
axis: '#D9E2FF',
muted: '#A5B4D4',
grid: 'rgba(255,255,255,0.07)',
gridStrong: 'rgba(96, 165, 250, 0.28)',
outline: 'rgba(148, 163, 184, 0.20)',
};
function baseChartOptions(options = {}) {
return {
responsive: false,
maintainAspectRatio: false,
animation: false,
layout: {
padding: {
top: 28,
right: 26,
bottom: 22,
left: 22,
},
},
plugins: {
legend: {
display: options.showLegend ?? false,
position: 'top',
align: 'start',
labels: {
color: TEXT.axis,
boxWidth: 14,
usePointStyle: true,
padding: 16,
},
},
title: {
display: true,
text: options.title ?? 'Baseball Chart',
color: TEXT.title,
align: 'start',
font: {
size: 30,
weight: 'bold',
},
padding: {
bottom: 6,
},
},
subtitle: {
display: Boolean(options.subtitle),
text: options.subtitle ?? '',
color: TEXT.subtitle,
align: 'start',
font: {
size: 15,
},
padding: {
bottom: 18,
},
},
tooltip: {
enabled: true,
backgroundColor: '#12182b',
titleColor: TEXT.title,
bodyColor: TEXT.subtitle,
borderColor: 'rgba(255,255,255,0.12)',
borderWidth: 1,
},
},
scales: {
x: axisStyle(options.xAxis),
y: axisStyle(options.yAxis),
},
};
}
function axisStyle(overrides = {}) {
return {
ticks: {
color: TEXT.axis,
...overrides?.ticks,
},
grid: {
color: overrides?.grid?.color ?? TEXT.grid,
drawTicks: false,
...overrides?.grid,
},
border: {
color: 'rgba(148, 163, 184, 0.26)',
...overrides?.border,
},
...overrides,
};
}
async function renderChart(type, data, options = {}, plugins = []) {
const width = options.width ?? DEFAULT_WIDTH;
const height = options.height ?? DEFAULT_HEIGHT;
const canvas = new Canvas(width, height);
const ctx = canvas.getContext('2d');
const chart = new Chart(ctx, {
type,
data,
options: {
...baseChartOptions(options),
...options.chartOptions,
},
plugins,
});
const buffer = await canvas.toBuffer('png');
chart.destroy();
return buffer;
}
export async function createHrBoardChartPng(payload = {}) {
const rows = (payload.rows ?? []).slice(0, 10);
const safeRows = rows.length
? rows
: [{ label: 'No HR board data', matchup: 0, ceiling: 0 }];
return renderChart('bar', {
labels: safeRows.map((row) => truncateLabel(row.label ?? row.name ?? 'Unknown')),
datasets: [
{
label: 'Matchup',
data: safeRows.map((row) => numberOrZero(row.matchup)),
borderRadius: 8,
backgroundColor: safeRows.map((_, index) => index < 3 ? HR_COLORS.primary : HR_COLORS.secondary),
},
{
label: 'Ceiling',
data: safeRows.map((row) => numberOrZero(row.ceiling)),
borderRadius: 8,
backgroundColor: 'rgba(239, 68, 68, 0.55)',
},
],
}, {
title: payload.title ?? 'Home Run Board',
subtitle: payload.subtitle ?? 'Top HR targets on the active slate.',
showLegend: true,
chartOptions: {
indexAxis: 'y',
scales: {
x: axisStyle({
min: 0,
suggestedMax: 100,
}),
y: axisStyle({
grid: { display: false },
}),
},
},
});
}
export async function createHrTrendChartPng(payload = {}) {
return createTrendChartPng(payload, HR_COLORS, 'HR Trend');
}
export async function createKTrendChartPng(payload = {}) {
return createTrendChartPng(payload, K_COLORS, 'Pitcher K Trend');
}
export async function createPitcherTrendChartPng(payload = {}) {
if (payload.chartType === 'radar') {
return createRadarPng(payload, PITCHER_COLORS, payload.title ?? 'Pitcher Trend');
}
if (payload.chartType === 'bar') {
const labels = payload.labels?.length ? payload.labels : ['No Data'];
const datasets = payload.datasets?.length
? payload.datasets.map((dataset, index) => ({
label: dataset.label,
data: dataset.values ?? [],
backgroundColor: dataset.color ?? [PITCHER_COLORS.primary, PITCHER_COLORS.secondary, PITCHER_COLORS.tertiary][index % 3],
borderRadius: 8,
}))
: [{
label: 'Value',
data: [0],
backgroundColor: PITCHER_COLORS.primary,
borderRadius: 8,
}];
return renderChart('bar', { labels, datasets }, {
title: payload.title ?? 'Pitcher Trend',
subtitle: payload.subtitle ?? 'Pitcher baseline view.',
showLegend: true,
chartOptions: {
scales: {
y: axisStyle({
beginAtZero: true,
}),
},
},
});
}
return createTrendChartPng(payload, PITCHER_COLORS, payload.title ?? 'Pitcher Trend');
}
async function createTrendChartPng(payload, colors, fallbackTitle) {
const points = payload.points?.length
? payload.points
: [{ label: 'No data', value: 0 }];
const overlays = payload.overlays ?? [];
return renderChart('line', {
labels: points.map((point) => point.label),
datasets: [
{
label: payload.primaryLabel ?? 'Primary',
data: points.map((point) => numberOrZero(point.value)),
borderColor: colors.primary,
backgroundColor: colors.fill,
pointBackgroundColor: colors.primary,
pointBorderWidth: 0,
pointRadius: 4,
tension: 0.28,
fill: false,
borderWidth: 4,
},
...overlays.map((overlay, index) => ({
label: overlay.label,
data: overlay.values ?? [],
borderColor: overlay.color ?? [colors.secondary, colors.tertiary, '#c084fc'][index % 3],
backgroundColor: 'transparent',
pointRadius: 3,
pointBorderWidth: 0,
tension: 0.24,
fill: false,
borderDash: index === 0 ? [8, 6] : undefined,
borderWidth: 2.5,
})),
],
}, {
title: payload.title ?? fallbackTitle,
subtitle: payload.subtitle ?? 'Rolling slate-date trend view.',
showLegend: overlays.length > 0,
chartOptions: {
scales: {
x: axisStyle({
grid: { display: false },
}),
y: axisStyle({
suggestedMin: payload.suggestedMin,
suggestedMax: payload.suggestedMax,
ticks: payload.yTickFormatter ? { callback: payload.yTickFormatter, color: TEXT.axis } : undefined,
}),
},
},
});
}
export async function createHrProfileRadarPng(payload = {}) {
return createRadarPng(payload, HR_COLORS, 'HR Profile Radar');
}
export async function createKProfileRadarPng(payload = {}) {
return createRadarPng(payload, K_COLORS, 'Pitcher Strikeout Profile');
}
async function createRadarPng(payload, colors, fallbackTitle) {
const labels = payload.labels?.length ? payload.labels : ['No Data'];
const values = payload.values?.length ? payload.values : [0];
return renderChart('radar', {
labels,
datasets: [
{
label: payload.seriesLabel ?? 'Profile',
data: values,
borderColor: colors.primary,
backgroundColor: colors.fill,
pointBackgroundColor: colors.primary,
pointBorderColor: colors.primary,
borderWidth: 3,
},
],
}, {
title: payload.title ?? fallbackTitle,
subtitle: payload.subtitle ?? 'Normalized profile view.',
chartOptions: {
scales: {
r: {
min: 0,
max: 100,
ticks: {
display: false,
stepSize: 20,
},
angleLines: {
color: TEXT.grid,
},
grid: {
color: TEXT.grid,
},
pointLabels: {
color: TEXT.axis,
font: {
size: 13,
},
},
},
},
},
});
}
export async function createHrValueScatterPng(payload = {}) {
const points = payload.points?.length ? payload.points : [{ x: 0, y: 0, label: 'No data' }];
return renderChart('scatter', {
datasets: [
{
label: payload.seriesLabel ?? 'Players',
data: points.map((point) => ({ x: numberOrZero(point.x), y: numberOrZero(point.y), label: point.label })),
pointRadius: 6,
pointHoverRadius: 7,
pointBackgroundColor: points.map((point) => point.highlight ? HR_COLORS.tertiary : HR_COLORS.primary),
},
],
}, {
title: payload.title ?? 'HR Price vs Model',
subtitle: payload.subtitle ?? 'Implied probability versus matchup score.',
showLegend: false,
chartOptions: {
plugins: {
tooltip: {
callbacks: {
label(context) {
const raw = context.raw ?? {};
return `${raw.label ?? 'Player'} | Implied ${(Number(raw.x) * 100).toFixed(1)}% | Score ${Number(raw.y).toFixed(1)}`;
},
},
},
},
scales: {
x: axisStyle({
min: 0,
max: 1,
ticks: {
color: TEXT.axis,
callback(value) {
return `${(Number(value) * 100).toFixed(0)}%`;
},
},
}),
y: axisStyle({
min: 0,
max: 100,
}),
},
},
});
}
export async function createKLadderChartPng(payload = {}) {
const rows = payload.rows?.length ? payload.rows : [{ label: '5+', probability: 0, price: 'N/A' }];
return renderChart('bar', {
labels: rows.map((row) => row.label),
datasets: [
{
label: payload.seriesLabel ?? 'Implied Probability',
data: rows.map((row) => numberOrZero(row.probability) * 100),
backgroundColor: rows.map((_, index) => index === 0 ? K_COLORS.primary : K_COLORS.secondary),
borderRadius: 8,
},
],
}, {
title: payload.title ?? 'Pitcher K Ladder',
subtitle: payload.subtitle ?? 'Available strikeout ladder prices.',
showLegend: false,
chartOptions: {
scales: {
y: axisStyle({
min: 0,
max: 100,
ticks: {
color: TEXT.axis,
callback(value) {
return `${Number(value).toFixed(0)}%`;
},
},
}),
},
plugins: {
tooltip: {
callbacks: {
label(context) {
const row = rows[context.dataIndex];
return `${row.label} | ${row.price} | ${(row.probability * 100).toFixed(1)}%`;
},
},
},
},
},
});
}
export async function createKCountLeverageChartPng(payload = {}) {
const labels = payload.labels?.length ? payload.labels : ['No Data'];
const datasets = payload.datasets?.length
? payload.datasets.map((dataset, index) => ({
label: dataset.label,
data: dataset.values,
backgroundColor: dataset.color ?? [K_COLORS.primary, K_COLORS.secondary, K_COLORS.tertiary, '#c084fc'][index % 4],
borderRadius: 6,
}))
: [{
label: 'Usage',
data: [0],
backgroundColor: K_COLORS.primary,
borderRadius: 6,
}];
return renderChart('bar', { labels, datasets }, {
title: payload.title ?? 'Count Leverage',
subtitle: payload.subtitle ?? 'Pitch usage by count bucket.',
showLegend: true,
chartOptions: {
scales: {
y: axisStyle({
min: 0,
max: 100,
ticks: {
color: TEXT.axis,
callback(value) {
return `${Number(value).toFixed(0)}%`;
},
},
}),
},
},
});
}
export async function createHrZoneOverlayCardPng(payload = {}) {
const width = payload.width ?? 1080;
const height = payload.height ?? 760;
const canvas = new Canvas(width, height);
const ctx = canvas.getContext('2d');
paintCanvasBackground(ctx, width, height, ['#1b1123', '#231833', '#1b243f']);
drawHeader(
ctx,
width,
payload.title ?? 'HR Zone Overlay',
payload.subtitle ?? 'Batter damage zones against pitcher usage.',
HR_COLORS.primary
);
const boardX = 46;
const boardY = 124;
const boardWidth = width - 92;
const boardHeight = height - 170;
drawPanel(ctx, boardX, boardY, boardWidth, boardHeight);
ctx.fillStyle = TEXT.title;
ctx.font = 'bold 24px sans-serif';
ctx.fillText(payload.playerName ?? 'Unknown Hitter', boardX + 26, boardY + 44);
ctx.font = '18px sans-serif';
ctx.fillStyle = TEXT.subtitle;
ctx.fillText(
`${payload.team ?? 'N/A'} vs ${payload.opponentPitcher ?? 'Unknown Pitcher'}${payload.pitcherHand ? ` (${payload.pitcherHand})` : ''}`,
boardX + 26,
boardY + 74
);
ctx.font = 'bold 18px sans-serif';
ctx.fillStyle = HR_COLORS.primary;
ctx.fillText(`Zone Fit ${numberOrZero(payload.zoneFitScore).toFixed(3)}`, boardX + 26, boardY + 106);
const zoneX = boardX + 64;
const zoneY = boardY + 146;
const cellSize = 104;
const gap = 8;
const zoneEntries = new Map((payload.cells ?? []).map((cell) => [String(cell.zone), cell]));
const zoneOrder = [1, 2, 3, 4, 5, 6, 7, 8, 9];
zoneOrder.forEach((zone, index) => {
const row = Math.floor(index / 3);
const col = index % 3;
const x = zoneX + col * (cellSize + gap);
const y = zoneY + row * (cellSize + gap);
const cell = zoneEntries.get(String(zone)) ?? {};
const intensity = Math.max(0, Math.min(1, numberOrZero(cell.overlayValue)));
ctx.fillStyle = interpolateColor('#193929', '#ef4444', 1 - intensity);
roundRect(ctx, x, y, cellSize, cellSize, 16);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
roundRect(ctx, x, y, cellSize, cellSize, 16);
ctx.stroke();
ctx.fillStyle = TEXT.title;
ctx.font = 'bold 14px sans-serif';
ctx.fillText(`Z${zone}`, x + 12, y + 24);
ctx.font = '13px sans-serif';
ctx.fillText(`Batter ${(numberOrZero(cell.batterValue) * 100).toFixed(0)}%`, x + 12, y + 48);
ctx.fillText(`Pitch ${(numberOrZero(cell.pitcherValue) * 100).toFixed(0)}%`, x + 12, y + 68);
ctx.fillText(`Fit ${(numberOrZero(cell.overlayValue) * 100).toFixed(0)}%`, x + 12, y + 88);
});
const notesX = zoneX + 3 * (cellSize + gap) + 40;
const notesWidth = boardX + boardWidth - notesX - 28;
drawMiniSummary(ctx, notesX, zoneY, notesWidth, 140, 'Best Overlay', payload.bestOverlay ?? 'No clear overlay found.');
drawMiniSummary(ctx, notesX, zoneY + 162, notesWidth, 140, 'Power Shape', payload.shapeSummary ?? 'No shape note available.');
drawMiniSummary(ctx, notesX, zoneY + 324, notesWidth, 140, 'Read', payload.read ?? 'Overlay highlights where the hitter damage map matches the probable pitcher attack lanes.');
const buffer = await canvas.toBuffer('png');
return buffer;
}
export async function createKMatchupCardPng(payload = {}) {
const width = payload.width ?? 1080;
const height = payload.height ?? 700;
const canvas = new Canvas(width, height);
const ctx = canvas.getContext('2d');
paintCanvasBackground(ctx, width, height, ['#081321', '#10203a', '#1b2f4c']);
drawHeader(
ctx,
width,
payload.title ?? 'Pitcher K Matchup',
payload.subtitle ?? 'Pitcher whiff traits against opponent lineup pressure.',
K_COLORS.primary
);
drawPanel(ctx, 44, 120, width - 88, height - 164);
const left = payload.pitcherMetrics ?? [];
const right = payload.opponentMetrics ?? [];
const leftX = 80;
const rightX = width / 2 + 20;
const topY = 188;
ctx.fillStyle = TEXT.title;
ctx.font = 'bold 22px sans-serif';
ctx.fillText(payload.pitcherName ?? 'Unknown Pitcher', leftX, 162);
ctx.fillText(payload.opponentLabel ?? 'Opponent Context', rightX, 162);
drawMetricList(ctx, leftX, topY, 360, left, K_COLORS.primary);
drawMetricList(ctx, rightX, topY, 360, right, '#f59e0b');
drawMiniSummary(ctx, width / 2 - 170, height - 190, 340, 112, 'Matchup Read', payload.read ?? 'Balanced pitcher-opponent strikeout card.');
ctx.fillStyle = K_COLORS.primary;
ctx.font = 'bold 28px sans-serif';
ctx.fillText(`K Score ${numberOrZero(payload.strikeoutScore).toFixed(1)}`, width / 2 - 96, height - 214);
const buffer = await canvas.toBuffer('png');
return buffer;
}
export async function createPitcherArsenalChartPng(payload = {}) {
return createPitcherTableCardPng({
accent: PITCHER_COLORS.primary,
width: payload.width,
height: payload.height ?? 760,
title: payload.title ?? 'Pitcher Arsenal',
subtitle: payload.subtitle ?? 'Arsenal and pitch-quality view.',
playerName: payload.pitcherName ?? 'Unknown Pitcher',
teamLine: payload.teamLine ?? '',
read: payload.read,
columns: payload.columns,
rows: payload.rows,
});
}
export async function createPitcherLocationChartPng(payload = {}) {
if (payload.view === 'bypitch' && Array.isArray(payload.plotPoints)) {
return createPitcherLocationPlotPng(payload);
}
const width = payload.width ?? 1080;
const height = payload.height ?? 760;
const canvas = new Canvas(width, height);
const ctx = canvas.getContext('2d');
paintCanvasBackground(ctx, width, height, ['#081a1a', '#102432', '#1f2d3f']);
drawHeader(
ctx,
width,
payload.title ?? 'Pitcher Location',
payload.subtitle ?? 'Pitch distribution by zone bucket.',
PITCHER_COLORS.primary
);
const boardX = 46;
const boardY = 124;
const boardWidth = width - 92;
const boardHeight = height - 170;
drawPanel(ctx, boardX, boardY, boardWidth, boardHeight);
ctx.fillStyle = TEXT.title;
ctx.font = 'bold 24px sans-serif';
ctx.fillText(payload.pitcherName ?? 'Unknown Pitcher', boardX + 26, boardY + 44);
ctx.font = '18px sans-serif';
ctx.fillStyle = TEXT.subtitle;
ctx.fillText(payload.teamLine ?? '', boardX + 26, boardY + 74);
ctx.font = 'bold 18px sans-serif';
ctx.fillStyle = PITCHER_COLORS.primary;
ctx.fillText(payload.metricConfig?.headline ?? payload.metricLabel ?? 'Color = usage rate', boardX + 26, boardY + 106);
const zoneX = boardX + 64;
const zoneY = boardY + 146;
const cellSize = 118;
const gap = 10;
const zoneEntries = new Map((payload.cells ?? []).map((cell) => [String(cell.zone), cell]));
const metricConfig = payload.metricConfig ?? {
primaryLabel: 'Usage',
secondaryLabel: 'Miss',
suffix: '%',
};
[1, 2, 3, 4, 5, 6, 7, 8, 9].forEach((zone, index) => {
const row = Math.floor(index / 3);
const col = index % 3;
const x = zoneX + col * (cellSize + gap);
const y = zoneY + row * (cellSize + gap);
const cell = zoneEntries.get(String(zone)) ?? {};
const intensity = Math.max(0, Math.min(1, numberOrZero(cell.overlayValue) / 100));
ctx.fillStyle = interpolateColor('#123326', '#22c55e', intensity);
roundRect(ctx, x, y, cellSize, cellSize, 16);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
roundRect(ctx, x, y, cellSize, cellSize, 16);
ctx.stroke();
ctx.fillStyle = TEXT.title;
ctx.font = 'bold 14px sans-serif';
ctx.fillText(`Z${zone}`, x + 12, y + 24);
ctx.font = 'bold 22px sans-serif';
ctx.fillText(
`${numberOrZero(cell.overlayValue).toFixed(0)}${metricConfig.suffix}`,
x + 12,
y + 56
);
ctx.font = '12px sans-serif';
ctx.fillStyle = TEXT.subtitle;
ctx.fillText(
`${metricConfig.primaryLabel} ${metricConfig.primaryLabel === 'Usage'
? `${(numberOrZero(cell.pitcherValue) * 100).toFixed(0)}%`
: metricConfig.primaryLabel === 'Miss'
? `${(numberOrZero(cell.batterValue) * 100).toFixed(0)}%`
: metricConfig.primaryLabel === 'Chase'
? `${numberOrZero(cell.overlayValue).toFixed(0)}%`
: numberOrZero(cell.overlayValue).toFixed(0)}`,
x + 12,
y + 80
);
ctx.fillText(
`${metricConfig.secondaryLabel} ${metricConfig.secondaryLabel === 'Usage'
? `${(numberOrZero(cell.pitcherValue) * 100).toFixed(0)}%`
: `${(numberOrZero(cell.batterValue) * 100).toFixed(0)}%`}`,
x + 12,
y + 98
);
});
const notesX = zoneX + 3 * (cellSize + gap) + 40;
const notesWidth = boardX + boardWidth - notesX - 28;
drawMiniSummary(
ctx,
notesX,
zoneY,
notesWidth,
120,
'How To Read',
`${payload.metricConfig?.headline ?? 'Color = usage rate'}. Secondary line shows support by cell. Sample: ${payload.sampleSize ?? 0} pitches.`
);
drawMiniSummary(ctx, notesX, zoneY + 142, notesWidth, 120, 'Best Pocket', payload.bestOverlay ?? 'No clear hot zone found.');
drawMiniSummary(ctx, notesX, zoneY + 284, notesWidth, 150, 'Attack Shape', `${payload.shapeSummary ?? 'No attack summary available.'} ${payload.read ?? ''}`.trim());
return canvas.toBuffer('png');
}
async function createPitcherLocationPlotPng(payload = {}) {
const width = payload.width ?? 1080;
const height = payload.height ?? 760;
const canvas = new Canvas(width, height);
const ctx = canvas.getContext('2d');
paintCanvasBackground(ctx, width, height, ['#081a1a', '#102432', '#1f2d3f']);
drawHeader(
ctx,
width,
payload.title ?? 'Pitch Location Plot',
payload.subtitle ?? 'Pitch locations by pitch type.',
PITCHER_COLORS.primary
);
const boardX = 46;
const boardY = 124;
const boardWidth = width - 92;
const boardHeight = height - 170;
drawPanel(ctx, boardX, boardY, boardWidth, boardHeight);
ctx.fillStyle = TEXT.title;
ctx.font = 'bold 24px sans-serif';
ctx.fillText(payload.pitcherName ?? 'Unknown Pitcher', boardX + 26, boardY + 44);
ctx.font = '18px sans-serif';
ctx.fillStyle = TEXT.subtitle;
ctx.fillText(payload.teamLine ?? '', boardX + 26, boardY + 74);
ctx.font = 'bold 18px sans-serif';
ctx.fillStyle = PITCHER_COLORS.primary;
ctx.fillText(`Pitch plot by offering | Sample: ${payload.sampleSize ?? 0} pitches`, boardX + 26, boardY + 106);
const plotX = boardX + 54;
const plotY = boardY + 148;
const plotWidth = 430;
const plotHeight = 500;
drawPanel(ctx, plotX, plotY, plotWidth, plotHeight, 18);
const strikeZone = {
left: plotX + 120,
top: plotY + 92,
width: 190,
height: 220,
};
ctx.strokeStyle = 'rgba(255,255,255,0.45)';
ctx.lineWidth = 2;
ctx.strokeRect(strikeZone.left, strikeZone.top, strikeZone.width, strikeZone.height);
ctx.beginPath();
ctx.moveTo(strikeZone.left + strikeZone.width / 3, strikeZone.top);
ctx.lineTo(strikeZone.left + strikeZone.width / 3, strikeZone.top + strikeZone.height);
ctx.moveTo(strikeZone.left + (2 * strikeZone.width) / 3, strikeZone.top);
ctx.lineTo(strikeZone.left + (2 * strikeZone.width) / 3, strikeZone.top + strikeZone.height);
ctx.moveTo(strikeZone.left, strikeZone.top + strikeZone.height / 3);
ctx.lineTo(strikeZone.left + strikeZone.width, strikeZone.top + strikeZone.height / 3);
ctx.moveTo(strikeZone.left, strikeZone.top + (2 * strikeZone.height) / 3);
ctx.lineTo(strikeZone.left + strikeZone.width, strikeZone.top + (2 * strikeZone.height) / 3);
ctx.stroke();
ctx.strokeStyle = 'rgba(148, 163, 184, 0.25)';
ctx.lineWidth = 1;
ctx.strokeRect(plotX + 70, plotY + 40, plotWidth - 140, plotHeight - 110);
const pitchPalette = ['#22c55e', '#38bdf8', '#f59e0b', '#f97316', '#c084fc', '#f43f5e'];
const pitchColors = new Map();
const orderedPitches = (payload.pitchBreakdown ?? []).map((item) => item.pitchName);
orderedPitches.forEach((pitchName, index) => {
pitchColors.set(pitchName, pitchPalette[index % pitchPalette.length]);
});
const leftBound = -2.0;
const rightBound = 2.0;
const topBound = 4.8;
const bottomBound = 0.8;
for (const point of payload.plotPoints ?? []) {
const x = numberOrZero(point.x);
const y = numberOrZero(point.y);
const px = plotX + 70 + ((x - leftBound) / (rightBound - leftBound)) * (plotWidth - 140);
const py = plotY + 40 + ((topBound - y) / (topBound - bottomBound)) * (plotHeight - 110);
ctx.fillStyle = pitchColors.get(point.pitchName) ?? '#ffffff';
ctx.globalAlpha = 0.72;
ctx.beginPath();
ctx.arc(px, py, 4.6, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.fillStyle = TEXT.muted;
ctx.font = '13px sans-serif';
ctx.fillText('Glove side', plotX + 72, plotY + plotHeight - 28);
ctx.fillText('Arm side', plotX + plotWidth - 126, plotY + plotHeight - 28);
ctx.fillText('Up', plotX + plotWidth - 34, plotY + 54);
ctx.fillText('Down', plotX + plotWidth - 50, plotY + plotHeight - 72);
const notesX = plotX + plotWidth + 28;
const notesWidth = boardX + boardWidth - notesX - 28;
drawPanel(ctx, notesX, plotY, notesWidth, 148, 18);
ctx.fillStyle = TEXT.title;
ctx.font = 'bold 18px sans-serif';
ctx.fillText('Legend', notesX + 18, plotY + 30);
let legendY = plotY + 58;
for (const item of (payload.pitchBreakdown ?? []).slice(0, 6)) {
const color = pitchColors.get(item.pitchName) ?? '#ffffff';
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(notesX + 20, legendY, 6, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = TEXT.subtitle;
ctx.font = '14px sans-serif';
ctx.fillText(`${item.pitchName} (${item.pct.toFixed(0)}%)`, notesX + 36, legendY + 4);
legendY += 22;
}
drawMiniSummary(
ctx,
notesX,
plotY + 170,
notesWidth,
110,
'How To Read',
'Each dot is one pitch at its plate location. Colors identify pitch types so you can see how the arsenal is spread in and around the zone.'
);
drawMiniSummary(
ctx,
notesX,
plotY + 302,
notesWidth,
150,
'Pitch Mix',
(payload.pitchBreakdown ?? []).length
? payload.pitchBreakdown.map((item) => `${item.pitchName}: ${item.count} pitches (${item.pct.toFixed(0)}%)`).join(' | ')
: 'No pitch mix summary available.'
);
drawMiniSummary(
ctx,
notesX,
plotY + 474,
notesWidth,
98,
'Read',
payload.read ?? 'This chart shows where each pitch type actually finishes at the plate.'
);
return canvas.toBuffer('png');
}
export async function createPitcherApproachChartPng(payload = {}) {
const labels = payload.labels?.length ? payload.labels : ['No Data'];
const datasets = payload.datasets?.length
? payload.datasets.map((dataset, index) => ({
label: dataset.label,
data: dataset.values,
backgroundColor: dataset.color ?? [PITCHER_COLORS.primary, PITCHER_COLORS.secondary, PITCHER_COLORS.tertiary, '#c084fc'][index % 4],
borderRadius: 6,
}))
: [{
label: 'Usage',
data: [0],
backgroundColor: PITCHER_COLORS.primary,
borderRadius: 6,
}];
return renderChart('bar', { labels, datasets }, {
title: payload.title ?? 'Pitcher Approach',
subtitle: payload.subtitle ?? 'Count-state and game-plan view.',
showLegend: true,
chartOptions: {
plugins: {
tooltip: {
callbacks: {
footer() {
return payload.sampleSize ? `Sample: ${payload.sampleSize} pitches` : '';
},
},
},
},
scales: {
y: axisStyle({
min: 0,
max: 100,
ticks: {
color: TEXT.axis,
callback(value) {
return `${Number(value).toFixed(0)}%`;
},
},
}),
},
},
});
}
export async function createPitcherCompareChartPng(payload = {}) {
if (payload.chartType === 'scatter') {
const points = payload.points?.length ? payload.points : [{ x: 0, y: 0, label: 'No data' }];
return renderChart('scatter', {
datasets: [
{
label: payload.seriesLabel ?? 'Snapshots',
data: points.map((point) => ({ x: numberOrZero(point.x), y: numberOrZero(point.y), label: point.label })),
pointRadius: 6,
pointHoverRadius: 7,
pointBackgroundColor: points.map(() => PITCHER_COLORS.primary),
},
],
}, {
title: payload.title ?? 'Pitcher Compare',
subtitle: payload.subtitle ?? 'Risk and reward view.',
chartOptions: {
plugins: {
tooltip: {
callbacks: {
label(context) {
const raw = context.raw ?? {};
return `${raw.label ?? 'Snapshot'} | X ${Number(raw.x).toFixed(1)} | Y ${Number(raw.y).toFixed(1)}`;
},
},
},
},
},
});
}
return createPitcherTableCardPng({
accent: PITCHER_COLORS.secondary,
width: payload.width,
height: payload.height ?? 720,
title: payload.title ?? 'Pitcher Compare',
subtitle: payload.subtitle ?? 'Baseline and comparison view.',
playerName: payload.pitcherName ?? 'Unknown Pitcher',
teamLine: payload.teamLine ?? '',
read: payload.read,
columns: [
{ key: 'currentValue', label: payload.compareLabel ?? 'Current' },
{ key: 'baselineValue', label: payload.baselineLabel ?? 'Baseline' },
],
rows: payload.rows,
});
}
function drawMetricList(ctx, x, y, width, metrics, accent) {
const rows = metrics.length
? metrics
: [{ label: 'No data', value: 'N/A', normalized: 0 }];
rows.forEach((metric, index) => {
const top = y + index * 54;
ctx.fillStyle = TEXT.subtitle;
ctx.font = '16px sans-serif';
ctx.fillText(metric.label ?? 'Metric', x, top);
ctx.fillStyle = TEXT.title;
ctx.font = 'bold 16px sans-serif';
ctx.fillText(String(metric.value ?? 'N/A'), x + width - 72, top);
ctx.fillStyle = 'rgba(255,255,255,0.08)';
roundRect(ctx, x, top + 10, width, 14, 7);
ctx.fill();
ctx.fillStyle = accent;
roundRect(ctx, x, top + 10, Math.max(10, width * Math.max(0, Math.min(1, numberOrZero(metric.normalized)))), 14, 7);
ctx.fill();
});
}
function drawMiniSummary(ctx, x, y, width, height, title, text) {
drawPanel(ctx, x, y, width, height, 18);
ctx.fillStyle = TEXT.title;
ctx.font = 'bold 18px sans-serif';
ctx.fillText(title, x + 18, y + 30);
wrapText(ctx, String(text ?? ''), x + 18, y + 58, width - 36, 22, 15, TEXT.subtitle);
}
async function createPitcherTableCardPng(payload = {}) {
const width = payload.width ?? 1120;
const height = payload.height ?? 760;
const canvas = new Canvas(width, height);
const ctx = canvas.getContext('2d');
paintCanvasBackground(ctx, width, height, ['#111827', '#14243d', '#1a2a46']);
drawHeader(
ctx,
width,
payload.title ?? 'Pitcher Table',
payload.subtitle ?? 'Pitcher data view.',
payload.accent ?? PITCHER_COLORS.primary
);
drawPanel(ctx, 44, 120, width - 88, height - 164);
ctx.fillStyle = TEXT.title;
ctx.font = 'bold 24px sans-serif';
ctx.fillText(payload.playerName ?? 'Unknown Pitcher', 76, 164);
ctx.fillStyle = TEXT.subtitle;
ctx.font = '17px sans-serif';
if (payload.teamLine) {
ctx.fillText(payload.teamLine, 76, 192);
}
const tableX = 76;
const tableY = 238;
const tableWidth = width - 152;
const columns = payload.columns?.length ? payload.columns : [{ key: 'value', label: 'Value' }];
const rows = payload.rows?.length ? payload.rows : [{ label: 'No data', value: 'N/A' }];
const labelWidth = 180;
const dataWidth = (tableWidth - labelWidth) / Math.max(columns.length, 1);
ctx.fillStyle = TEXT.muted;
ctx.font = 'bold 14px sans-serif';
ctx.fillText('Pitch / Row', tableX, tableY);
columns.forEach((column, index) => {
ctx.fillText(column.label, tableX + labelWidth + index * dataWidth, tableY);
});
rows.slice(0, 8).forEach((row, index) => {
const top = tableY + 34 + index * 46;
ctx.fillStyle = index % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.01)';
roundRect(ctx, tableX - 10, top - 22, tableWidth + 20, 34, 10);
ctx.fill();
ctx.fillStyle = TEXT.title;
ctx.font = 'bold 15px sans-serif';
ctx.fillText(String(row.label ?? 'Row'), tableX, top);
ctx.font = '14px sans-serif';
columns.forEach((column, columnIndex) => {
ctx.fillStyle = columnIndex === 0 ? (payload.accent ?? PITCHER_COLORS.primary) : TEXT.subtitle;
const rawValue = row[column.key];
const text = formatTableMetric(rawValue, column.type);
ctx.fillText(text, tableX + labelWidth + columnIndex * dataWidth, top);
});
});
drawMiniSummary(ctx, 76, height - 184, width - 152, 110, 'Read', payload.read ?? 'This card summarizes the selected pitcher view.');
return canvas.toBuffer('png');
}
function drawHeader(ctx, width, title, subtitle, accentColor) {
ctx.fillStyle = TEXT.title;
ctx.font = 'bold 34px sans-serif';
ctx.fillText(title, 46, 52);
ctx.fillStyle = TEXT.subtitle;
ctx.font = '17px sans-serif';
ctx.fillText(subtitle, 46, 82);
ctx.strokeStyle = accentColor;
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(46, 96);
ctx.lineTo(width - 46, 96);
ctx.stroke();
}
function drawPanel(ctx, x, y, width, height, radius = 22) {
ctx.fillStyle = 'rgba(9, 13, 27, 0.52)';
roundRect(ctx, x, y, width, height, radius);
ctx.fill();
ctx.strokeStyle = TEXT.outline;
ctx.lineWidth = 1.1;
roundRect(ctx, x, y, width, height, radius);
ctx.stroke();
}
function paintCanvasBackground(ctx, width, height, stops) {
const gradient = ctx.createLinearGradient(0, 0, width, height);
const safeStops = stops.length ? stops : ['#111827', '#1f2937', '#374151'];
safeStops.forEach((color, index) => {
gradient.addColorStop(index / Math.max(safeStops.length - 1, 1), color);
});
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
}
function wrapText(ctx, text, x, y, maxWidth, lineHeight, maxLines, color) {
ctx.fillStyle = color;
ctx.font = '15px sans-serif';
const words = String(text ?? '').split(/\s+/).filter(Boolean);
const lines = [];
let current = '';
for (const word of words) {
const next = current ? `${current} ${word}` : word;
if (ctx.measureText(next).width > maxWidth && current) {
lines.push(current);
current = word;
if (lines.length >= maxLines - 1) {
break;
}
} else {
current = next;
}
}
if (current && lines.length < maxLines) {
lines.push(current);
}
lines.slice(0, maxLines).forEach((line, index) => {
ctx.fillText(index === maxLines - 1 && lines.length > maxLines ? `${line}...` : line, x, y + index * lineHeight);
});
}
function interpolateColor(low, high, amount) {
const start = hexToRgb(low);
const end = hexToRgb(high);
const ratio = Math.max(0, Math.min(1, amount));
const mix = (left, right) => Math.round(left + ((right - left) * ratio));
return `rgb(${mix(start.r, end.r)}, ${mix(start.g, end.g)}, ${mix(start.b, end.b)})`;
}
function hexToRgb(hex) {
const normalized = hex.replace('#', '');
const value = normalized.length === 3
? normalized.split('').map((char) => char + char).join('')
: normalized;
const numeric = Number.parseInt(value, 16);
return {
r: (numeric >> 16) & 255,
g: (numeric >> 8) & 255,
b: numeric & 255,
};
}
function numberOrZero(value) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : 0;
}
function formatTableMetric(value, type) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return String(value ?? 'N/A');
}
if (type === 'pct') {
const scaled = Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
return `${scaled.toFixed(1)}%`;
}
if (type === 'pct_signed') {
const scaled = Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
return `${scaled >= 0 ? '+' : ''}${scaled.toFixed(1)}%`;
}
if (type === 'decimal') {
return numeric.toFixed(3);
}
return numeric.toFixed(Math.abs(numeric) < 10 ? 2 : 1);
}
function truncateLabel(value, maxLength = 24) {
const text = String(value ?? '');
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 3)}...`;
}
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();
}