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