Codex commited on
Commit
a32588e
·
1 Parent(s): 6bec453

Render pitcher by-pitch location plots

Browse files
Files changed (2) hide show
  1. src/baseball-charts.js +146 -0
  2. 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 {