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