Codex commited on
Commit ·
a32588e
1
Parent(s): 6bec453
Render pitcher by-pitch location plots
Browse files- src/baseball-charts.js +146 -0
- src/matchups.js +51 -0
src/baseball-charts.js
CHANGED
|
@@ -624,6 +624,9 @@ export async function createPitcherArsenalChartPng(payload = {}) {
|
|
| 624 |
}
|
| 625 |
|
| 626 |
export async function createPitcherLocationChartPng(payload = {}) {
|
|
|
|
|
|
|
|
|
|
| 627 |
const width = payload.width ?? 1080;
|
| 628 |
const height = payload.height ?? 760;
|
| 629 |
const canvas = new Canvas(width, height);
|
|
@@ -731,6 +734,149 @@ export async function createPitcherLocationChartPng(payload = {}) {
|
|
| 731 |
return canvas.toBuffer('png');
|
| 732 |
}
|
| 733 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 734 |
export async function createPitcherApproachChartPng(payload = {}) {
|
| 735 |
const labels = payload.labels?.length ? payload.labels : ['No Data'];
|
| 736 |
const datasets = payload.datasets?.length
|
|
|
|
| 624 |
}
|
| 625 |
|
| 626 |
export async function createPitcherLocationChartPng(payload = {}) {
|
| 627 |
+
if (payload.view === 'bypitch' && Array.isArray(payload.plotPoints)) {
|
| 628 |
+
return createPitcherLocationPlotPng(payload);
|
| 629 |
+
}
|
| 630 |
const width = payload.width ?? 1080;
|
| 631 |
const height = payload.height ?? 760;
|
| 632 |
const canvas = new Canvas(width, height);
|
|
|
|
| 734 |
return canvas.toBuffer('png');
|
| 735 |
}
|
| 736 |
|
| 737 |
+
async function createPitcherLocationPlotPng(payload = {}) {
|
| 738 |
+
const width = payload.width ?? 1080;
|
| 739 |
+
const height = payload.height ?? 760;
|
| 740 |
+
const canvas = new Canvas(width, height);
|
| 741 |
+
const ctx = canvas.getContext('2d');
|
| 742 |
+
|
| 743 |
+
paintCanvasBackground(ctx, width, height, ['#081a1a', '#102432', '#1f2d3f']);
|
| 744 |
+
drawHeader(
|
| 745 |
+
ctx,
|
| 746 |
+
width,
|
| 747 |
+
payload.title ?? 'Pitch Location Plot',
|
| 748 |
+
payload.subtitle ?? 'Pitch locations by pitch type.',
|
| 749 |
+
PITCHER_COLORS.primary
|
| 750 |
+
);
|
| 751 |
+
|
| 752 |
+
const boardX = 46;
|
| 753 |
+
const boardY = 124;
|
| 754 |
+
const boardWidth = width - 92;
|
| 755 |
+
const boardHeight = height - 170;
|
| 756 |
+
drawPanel(ctx, boardX, boardY, boardWidth, boardHeight);
|
| 757 |
+
|
| 758 |
+
ctx.fillStyle = TEXT.title;
|
| 759 |
+
ctx.font = 'bold 24px sans-serif';
|
| 760 |
+
ctx.fillText(payload.pitcherName ?? 'Unknown Pitcher', boardX + 26, boardY + 44);
|
| 761 |
+
ctx.font = '18px sans-serif';
|
| 762 |
+
ctx.fillStyle = TEXT.subtitle;
|
| 763 |
+
ctx.fillText(payload.teamLine ?? '', boardX + 26, boardY + 74);
|
| 764 |
+
|
| 765 |
+
ctx.font = 'bold 18px sans-serif';
|
| 766 |
+
ctx.fillStyle = PITCHER_COLORS.primary;
|
| 767 |
+
ctx.fillText(`Pitch plot by offering | Sample: ${payload.sampleSize ?? 0} pitches`, boardX + 26, boardY + 106);
|
| 768 |
+
|
| 769 |
+
const plotX = boardX + 54;
|
| 770 |
+
const plotY = boardY + 148;
|
| 771 |
+
const plotWidth = 430;
|
| 772 |
+
const plotHeight = 500;
|
| 773 |
+
drawPanel(ctx, plotX, plotY, plotWidth, plotHeight, 18);
|
| 774 |
+
|
| 775 |
+
const strikeZone = {
|
| 776 |
+
left: plotX + 120,
|
| 777 |
+
top: plotY + 92,
|
| 778 |
+
width: 190,
|
| 779 |
+
height: 220,
|
| 780 |
+
};
|
| 781 |
+
|
| 782 |
+
ctx.strokeStyle = 'rgba(255,255,255,0.45)';
|
| 783 |
+
ctx.lineWidth = 2;
|
| 784 |
+
ctx.strokeRect(strikeZone.left, strikeZone.top, strikeZone.width, strikeZone.height);
|
| 785 |
+
ctx.beginPath();
|
| 786 |
+
ctx.moveTo(strikeZone.left + strikeZone.width / 3, strikeZone.top);
|
| 787 |
+
ctx.lineTo(strikeZone.left + strikeZone.width / 3, strikeZone.top + strikeZone.height);
|
| 788 |
+
ctx.moveTo(strikeZone.left + (2 * strikeZone.width) / 3, strikeZone.top);
|
| 789 |
+
ctx.lineTo(strikeZone.left + (2 * strikeZone.width) / 3, strikeZone.top + strikeZone.height);
|
| 790 |
+
ctx.moveTo(strikeZone.left, strikeZone.top + strikeZone.height / 3);
|
| 791 |
+
ctx.lineTo(strikeZone.left + strikeZone.width, strikeZone.top + strikeZone.height / 3);
|
| 792 |
+
ctx.moveTo(strikeZone.left, strikeZone.top + (2 * strikeZone.height) / 3);
|
| 793 |
+
ctx.lineTo(strikeZone.left + strikeZone.width, strikeZone.top + (2 * strikeZone.height) / 3);
|
| 794 |
+
ctx.stroke();
|
| 795 |
+
|
| 796 |
+
ctx.strokeStyle = 'rgba(148, 163, 184, 0.25)';
|
| 797 |
+
ctx.lineWidth = 1;
|
| 798 |
+
ctx.strokeRect(plotX + 70, plotY + 40, plotWidth - 140, plotHeight - 110);
|
| 799 |
+
|
| 800 |
+
const pitchPalette = ['#22c55e', '#38bdf8', '#f59e0b', '#f97316', '#c084fc', '#f43f5e'];
|
| 801 |
+
const pitchColors = new Map();
|
| 802 |
+
const orderedPitches = (payload.pitchBreakdown ?? []).map((item) => item.pitchName);
|
| 803 |
+
orderedPitches.forEach((pitchName, index) => {
|
| 804 |
+
pitchColors.set(pitchName, pitchPalette[index % pitchPalette.length]);
|
| 805 |
+
});
|
| 806 |
+
|
| 807 |
+
const leftBound = -2.0;
|
| 808 |
+
const rightBound = 2.0;
|
| 809 |
+
const topBound = 4.8;
|
| 810 |
+
const bottomBound = 0.8;
|
| 811 |
+
|
| 812 |
+
for (const point of payload.plotPoints ?? []) {
|
| 813 |
+
const x = numberOrZero(point.x);
|
| 814 |
+
const y = numberOrZero(point.y);
|
| 815 |
+
const px = plotX + 70 + ((x - leftBound) / (rightBound - leftBound)) * (plotWidth - 140);
|
| 816 |
+
const py = plotY + 40 + ((topBound - y) / (topBound - bottomBound)) * (plotHeight - 110);
|
| 817 |
+
ctx.fillStyle = pitchColors.get(point.pitchName) ?? '#ffffff';
|
| 818 |
+
ctx.globalAlpha = 0.72;
|
| 819 |
+
ctx.beginPath();
|
| 820 |
+
ctx.arc(px, py, 4.6, 0, Math.PI * 2);
|
| 821 |
+
ctx.fill();
|
| 822 |
+
}
|
| 823 |
+
ctx.globalAlpha = 1;
|
| 824 |
+
|
| 825 |
+
ctx.fillStyle = TEXT.muted;
|
| 826 |
+
ctx.font = '13px sans-serif';
|
| 827 |
+
ctx.fillText('Glove side', plotX + 72, plotY + plotHeight - 28);
|
| 828 |
+
ctx.fillText('Arm side', plotX + plotWidth - 126, plotY + plotHeight - 28);
|
| 829 |
+
ctx.fillText('Up', plotX + plotWidth - 34, plotY + 54);
|
| 830 |
+
ctx.fillText('Down', plotX + plotWidth - 50, plotY + plotHeight - 72);
|
| 831 |
+
|
| 832 |
+
const notesX = plotX + plotWidth + 28;
|
| 833 |
+
const notesWidth = boardX + boardWidth - notesX - 28;
|
| 834 |
+
drawMiniSummary(
|
| 835 |
+
ctx,
|
| 836 |
+
notesX,
|
| 837 |
+
plotY,
|
| 838 |
+
notesWidth,
|
| 839 |
+
122,
|
| 840 |
+
'How To Read',
|
| 841 |
+
'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.'
|
| 842 |
+
);
|
| 843 |
+
drawMiniSummary(
|
| 844 |
+
ctx,
|
| 845 |
+
notesX,
|
| 846 |
+
plotY + 144,
|
| 847 |
+
notesWidth,
|
| 848 |
+
170,
|
| 849 |
+
'Pitch Mix',
|
| 850 |
+
(payload.pitchBreakdown ?? []).length
|
| 851 |
+
? payload.pitchBreakdown.map((item) => `${item.pitchName}: ${item.count} pitches (${item.pct.toFixed(0)}%)`).join(' | ')
|
| 852 |
+
: 'No pitch mix summary available.'
|
| 853 |
+
);
|
| 854 |
+
drawMiniSummary(
|
| 855 |
+
ctx,
|
| 856 |
+
notesX,
|
| 857 |
+
plotY + 336,
|
| 858 |
+
notesWidth,
|
| 859 |
+
122,
|
| 860 |
+
'Read',
|
| 861 |
+
payload.read ?? 'This chart shows where each pitch type actually finishes at the plate.'
|
| 862 |
+
);
|
| 863 |
+
|
| 864 |
+
let legendY = plotY + 16;
|
| 865 |
+
for (const item of (payload.pitchBreakdown ?? []).slice(0, 6)) {
|
| 866 |
+
const color = pitchColors.get(item.pitchName) ?? '#ffffff';
|
| 867 |
+
ctx.fillStyle = color;
|
| 868 |
+
ctx.beginPath();
|
| 869 |
+
ctx.arc(notesX + 18, legendY, 6, 0, Math.PI * 2);
|
| 870 |
+
ctx.fill();
|
| 871 |
+
ctx.fillStyle = TEXT.subtitle;
|
| 872 |
+
ctx.font = '14px sans-serif';
|
| 873 |
+
ctx.fillText(item.pitchName, notesX + 34, legendY + 4);
|
| 874 |
+
legendY += 24;
|
| 875 |
+
}
|
| 876 |
+
|
| 877 |
+
return canvas.toBuffer('png');
|
| 878 |
+
}
|
| 879 |
+
|
| 880 |
export async function createPitcherApproachChartPng(payload = {}) {
|
| 881 |
const labels = payload.labels?.length ? payload.labels : ['No Data'];
|
| 882 |
const datasets = payload.datasets?.length
|
src/matchups.js
CHANGED
|
@@ -1414,6 +1414,8 @@ function pitcherLocationTitle(view) {
|
|
| 1414 |
|
| 1415 |
function pitcherLocationRead(view) {
|
| 1416 |
switch (view) {
|
|
|
|
|
|
|
| 1417 |
case 'damage':
|
| 1418 |
return 'Hotter cells flag where contact quality has done the most damage.';
|
| 1419 |
case 'miss':
|
|
@@ -1613,6 +1615,36 @@ function locationMetricSuffix(view) {
|
|
| 1613 |
return '%';
|
| 1614 |
}
|
| 1615 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1616 |
function buildPitcherApproachDatasets(rows, view) {
|
| 1617 |
const buckets = view === 'ahead_behind'
|
| 1618 |
? ['ahead', 'even', 'behind']
|
|
@@ -3469,6 +3501,25 @@ export class MatchupService {
|
|
| 3469 |
if (!filtered.length) {
|
| 3470 |
throw new Error(`No location rows matched the ${countBucketLabel(countBucket).toLowerCase()} filter for ${context.pitcherName}.`);
|
| 3471 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3472 |
const cells = buildPitcherLocationCells(filtered, view);
|
| 3473 |
const bestCell = [...cells].sort((left, right) => compareNullableDescending(left.overlayValue, right.overlayValue))[0];
|
| 3474 |
return {
|
|
|
|
| 1414 |
|
| 1415 |
function pitcherLocationRead(view) {
|
| 1416 |
switch (view) {
|
| 1417 |
+
case 'bypitch':
|
| 1418 |
+
return 'Each dot is a pitch. Colors separate pitch types so you can see where each offering lives.';
|
| 1419 |
case 'damage':
|
| 1420 |
return 'Hotter cells flag where contact quality has done the most damage.';
|
| 1421 |
case 'miss':
|
|
|
|
| 1615 |
return '%';
|
| 1616 |
}
|
| 1617 |
|
| 1618 |
+
function buildPitchLocationPlot(rows) {
|
| 1619 |
+
const counts = new Map();
|
| 1620 |
+
const points = rows
|
| 1621 |
+
.filter((row) => numberOrNull(row.plate_x) !== null && numberOrNull(row.plate_z) !== null)
|
| 1622 |
+
.slice(0, 600)
|
| 1623 |
+
.map((row) => {
|
| 1624 |
+
const pitchName = firstNonBlankValue(row.pitch_name, row.pitch_type, 'Unknown');
|
| 1625 |
+
counts.set(pitchName, (counts.get(pitchName) ?? 0) + 1);
|
| 1626 |
+
return {
|
| 1627 |
+
x: numberOrNull(row.plate_x) ?? 0,
|
| 1628 |
+
y: numberOrNull(row.plate_z) ?? 0,
|
| 1629 |
+
pitchName,
|
| 1630 |
+
};
|
| 1631 |
+
});
|
| 1632 |
+
|
| 1633 |
+
const breakdown = [...counts.entries()]
|
| 1634 |
+
.sort((left, right) => right[1] - left[1])
|
| 1635 |
+
.slice(0, 5)
|
| 1636 |
+
.map(([pitchName, count]) => ({
|
| 1637 |
+
pitchName,
|
| 1638 |
+
count,
|
| 1639 |
+
pct: points.length ? (count / points.length) * 100 : 0,
|
| 1640 |
+
}));
|
| 1641 |
+
|
| 1642 |
+
return {
|
| 1643 |
+
points,
|
| 1644 |
+
breakdown,
|
| 1645 |
+
};
|
| 1646 |
+
}
|
| 1647 |
+
|
| 1648 |
function buildPitcherApproachDatasets(rows, view) {
|
| 1649 |
const buckets = view === 'ahead_behind'
|
| 1650 |
? ['ahead', 'even', 'behind']
|
|
|
|
| 3501 |
if (!filtered.length) {
|
| 3502 |
throw new Error(`No location rows matched the ${countBucketLabel(countBucket).toLowerCase()} filter for ${context.pitcherName}.`);
|
| 3503 |
}
|
| 3504 |
+
if (view === 'bypitch') {
|
| 3505 |
+
const plot = buildPitchLocationPlot(filtered);
|
| 3506 |
+
if (!plot.points.length) {
|
| 3507 |
+
throw new Error(`No pitch plot points were available for ${context.pitcherName}.`);
|
| 3508 |
+
}
|
| 3509 |
+
return {
|
| 3510 |
+
...context,
|
| 3511 |
+
view,
|
| 3512 |
+
split,
|
| 3513 |
+
pitchType,
|
| 3514 |
+
countBucket,
|
| 3515 |
+
title: `${pitcherLocationTitle(view)} - ${context.pitcherName}`,
|
| 3516 |
+
subtitle: `${context.team ?? 'N/A'} | ${splitLabel(split)} | ${countBucketLabel(countBucket)}${pitchType ? ` | ${pitchType}` : ''}`,
|
| 3517 |
+
sampleSize: filtered.length,
|
| 3518 |
+
plotPoints: plot.points,
|
| 3519 |
+
pitchBreakdown: plot.breakdown,
|
| 3520 |
+
read: pitcherLocationRead(view),
|
| 3521 |
+
};
|
| 3522 |
+
}
|
| 3523 |
const cells = buildPitcherLocationCells(filtered, view);
|
| 3524 |
const bestCell = [...cells].sort((left, right) => compareNullableDescending(left.overlayValue, right.overlayValue))[0];
|
| 3525 |
return {
|