Codex commited on
Commit
d2a67b5
·
1 Parent(s): 61312e4

Add full pitcher chart suite

Browse files
src/baseball-charts.js CHANGED
@@ -48,6 +48,13 @@ const K_COLORS = {
48
  fill: 'rgba(56, 189, 248, 0.18)',
49
  };
50
 
 
 
 
 
 
 
 
51
  const TEXT = {
52
  title: '#F7F8FF',
53
  subtitle: '#D6DDF7',
@@ -211,6 +218,42 @@ export async function createKTrendChartPng(payload = {}) {
211
  return createTrendChartPng(payload, K_COLORS, 'Pitcher K Trend');
212
  }
213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  async function createTrendChartPng(payload, colors, fallbackTitle) {
215
  const points = payload.points?.length
216
  ? payload.points
@@ -565,6 +608,179 @@ export async function createKMatchupCardPng(payload = {}) {
565
  return buffer;
566
  }
567
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
568
  function drawMetricList(ctx, x, y, width, metrics, accent) {
569
  const rows = metrics.length
570
  ? metrics
@@ -595,6 +811,68 @@ function drawMiniSummary(ctx, x, y, width, height, title, text) {
595
  wrapText(ctx, String(text ?? ''), x + 18, y + 58, width - 36, 22, 15, TEXT.subtitle);
596
  }
597
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
598
  function drawHeader(ctx, width, title, subtitle, accentColor) {
599
  ctx.fillStyle = TEXT.title;
600
  ctx.font = 'bold 34px sans-serif';
@@ -682,6 +960,25 @@ function numberOrZero(value) {
682
  return Number.isFinite(numeric) ? numeric : 0;
683
  }
684
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
685
  function truncateLabel(value, maxLength = 24) {
686
  const text = String(value ?? '');
687
  return text.length <= maxLength ? text : `${text.slice(0, maxLength - 3)}...`;
 
48
  fill: 'rgba(56, 189, 248, 0.18)',
49
  };
50
 
51
+ const PITCHER_COLORS = {
52
+ primary: '#22c55e',
53
+ secondary: '#38bdf8',
54
+ tertiary: '#f59e0b',
55
+ fill: 'rgba(34, 197, 94, 0.18)',
56
+ };
57
+
58
  const TEXT = {
59
  title: '#F7F8FF',
60
  subtitle: '#D6DDF7',
 
218
  return createTrendChartPng(payload, K_COLORS, 'Pitcher K Trend');
219
  }
220
 
221
+ export async function createPitcherTrendChartPng(payload = {}) {
222
+ if (payload.chartType === 'radar') {
223
+ return createRadarPng(payload, PITCHER_COLORS, payload.title ?? 'Pitcher Trend');
224
+ }
225
+ if (payload.chartType === 'bar') {
226
+ const labels = payload.labels?.length ? payload.labels : ['No Data'];
227
+ const datasets = payload.datasets?.length
228
+ ? payload.datasets.map((dataset, index) => ({
229
+ label: dataset.label,
230
+ data: dataset.values ?? [],
231
+ backgroundColor: dataset.color ?? [PITCHER_COLORS.primary, PITCHER_COLORS.secondary, PITCHER_COLORS.tertiary][index % 3],
232
+ borderRadius: 8,
233
+ }))
234
+ : [{
235
+ label: 'Value',
236
+ data: [0],
237
+ backgroundColor: PITCHER_COLORS.primary,
238
+ borderRadius: 8,
239
+ }];
240
+
241
+ return renderChart('bar', { labels, datasets }, {
242
+ title: payload.title ?? 'Pitcher Trend',
243
+ subtitle: payload.subtitle ?? 'Pitcher baseline view.',
244
+ showLegend: true,
245
+ chartOptions: {
246
+ scales: {
247
+ y: axisStyle({
248
+ beginAtZero: true,
249
+ }),
250
+ },
251
+ },
252
+ });
253
+ }
254
+ return createTrendChartPng(payload, PITCHER_COLORS, payload.title ?? 'Pitcher Trend');
255
+ }
256
+
257
  async function createTrendChartPng(payload, colors, fallbackTitle) {
258
  const points = payload.points?.length
259
  ? payload.points
 
608
  return buffer;
609
  }
610
 
611
+ export async function createPitcherArsenalChartPng(payload = {}) {
612
+ return createPitcherTableCardPng({
613
+ accent: PITCHER_COLORS.primary,
614
+ width: payload.width,
615
+ height: payload.height ?? 760,
616
+ title: payload.title ?? 'Pitcher Arsenal',
617
+ subtitle: payload.subtitle ?? 'Arsenal and pitch-quality view.',
618
+ playerName: payload.pitcherName ?? 'Unknown Pitcher',
619
+ teamLine: payload.teamLine ?? '',
620
+ read: payload.read,
621
+ columns: payload.columns,
622
+ rows: payload.rows,
623
+ });
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);
630
+ const ctx = canvas.getContext('2d');
631
+
632
+ paintCanvasBackground(ctx, width, height, ['#081a1a', '#102432', '#1f2d3f']);
633
+ drawHeader(
634
+ ctx,
635
+ width,
636
+ payload.title ?? 'Pitcher Location',
637
+ payload.subtitle ?? 'Pitch distribution by zone bucket.',
638
+ PITCHER_COLORS.primary
639
+ );
640
+
641
+ const boardX = 46;
642
+ const boardY = 124;
643
+ const boardWidth = width - 92;
644
+ const boardHeight = height - 170;
645
+ drawPanel(ctx, boardX, boardY, boardWidth, boardHeight);
646
+
647
+ ctx.fillStyle = TEXT.title;
648
+ ctx.font = 'bold 24px sans-serif';
649
+ ctx.fillText(payload.pitcherName ?? 'Unknown Pitcher', boardX + 26, boardY + 44);
650
+ ctx.font = '18px sans-serif';
651
+ ctx.fillStyle = TEXT.subtitle;
652
+ ctx.fillText(payload.teamLine ?? '', boardX + 26, boardY + 74);
653
+
654
+ ctx.font = 'bold 18px sans-serif';
655
+ ctx.fillStyle = PITCHER_COLORS.primary;
656
+ ctx.fillText(payload.metricLabel ?? 'Zone pressure', boardX + 26, boardY + 106);
657
+
658
+ const zoneX = boardX + 64;
659
+ const zoneY = boardY + 146;
660
+ const cellSize = 104;
661
+ const gap = 8;
662
+ const zoneEntries = new Map((payload.cells ?? []).map((cell) => [String(cell.zone), cell]));
663
+
664
+ [1, 2, 3, 4, 5, 6, 7, 8, 9].forEach((zone, index) => {
665
+ const row = Math.floor(index / 3);
666
+ const col = index % 3;
667
+ const x = zoneX + col * (cellSize + gap);
668
+ const y = zoneY + row * (cellSize + gap);
669
+ const cell = zoneEntries.get(String(zone)) ?? {};
670
+ const intensity = Math.max(0, Math.min(1, numberOrZero(cell.overlayValue) / 100));
671
+
672
+ ctx.fillStyle = interpolateColor('#123326', '#22c55e', intensity);
673
+ roundRect(ctx, x, y, cellSize, cellSize, 16);
674
+ ctx.fill();
675
+
676
+ ctx.strokeStyle = 'rgba(255,255,255,0.15)';
677
+ ctx.lineWidth = 1;
678
+ roundRect(ctx, x, y, cellSize, cellSize, 16);
679
+ ctx.stroke();
680
+
681
+ ctx.fillStyle = TEXT.title;
682
+ ctx.font = 'bold 14px sans-serif';
683
+ ctx.fillText(`Z${zone}`, x + 12, y + 24);
684
+ ctx.font = '13px sans-serif';
685
+ ctx.fillText(`Usage ${(numberOrZero(cell.pitcherValue) * 100).toFixed(0)}%`, x + 12, y + 48);
686
+ ctx.fillText(`Miss ${(numberOrZero(cell.batterValue) * 100).toFixed(0)}%`, x + 12, y + 68);
687
+ ctx.fillText(`Value ${numberOrZero(cell.overlayValue).toFixed(0)}`, x + 12, y + 88);
688
+ });
689
+
690
+ const notesX = zoneX + 3 * (cellSize + gap) + 40;
691
+ const notesWidth = boardX + boardWidth - notesX - 28;
692
+ drawMiniSummary(ctx, notesX, zoneY, notesWidth, 140, 'Best Pocket', payload.bestOverlay ?? 'No clear hot zone found.');
693
+ drawMiniSummary(ctx, notesX, zoneY + 162, notesWidth, 140, 'Attack Shape', payload.shapeSummary ?? 'No attack summary available.');
694
+ drawMiniSummary(ctx, notesX, zoneY + 324, notesWidth, 140, 'Read', payload.read ?? 'The heatmap shows where the pitcher lives in the selected location view.');
695
+
696
+ return canvas.toBuffer('png');
697
+ }
698
+
699
+ export async function createPitcherApproachChartPng(payload = {}) {
700
+ const labels = payload.labels?.length ? payload.labels : ['No Data'];
701
+ const datasets = payload.datasets?.length
702
+ ? payload.datasets.map((dataset, index) => ({
703
+ label: dataset.label,
704
+ data: dataset.values,
705
+ backgroundColor: dataset.color ?? [PITCHER_COLORS.primary, PITCHER_COLORS.secondary, PITCHER_COLORS.tertiary, '#c084fc'][index % 4],
706
+ borderRadius: 6,
707
+ }))
708
+ : [{
709
+ label: 'Usage',
710
+ data: [0],
711
+ backgroundColor: PITCHER_COLORS.primary,
712
+ borderRadius: 6,
713
+ }];
714
+
715
+ return renderChart('bar', { labels, datasets }, {
716
+ title: payload.title ?? 'Pitcher Approach',
717
+ subtitle: payload.subtitle ?? 'Count-state and game-plan view.',
718
+ showLegend: true,
719
+ chartOptions: {
720
+ scales: {
721
+ y: axisStyle({
722
+ min: 0,
723
+ max: 100,
724
+ ticks: {
725
+ color: TEXT.axis,
726
+ callback(value) {
727
+ return `${Number(value).toFixed(0)}%`;
728
+ },
729
+ },
730
+ }),
731
+ },
732
+ },
733
+ });
734
+ }
735
+
736
+ export async function createPitcherCompareChartPng(payload = {}) {
737
+ if (payload.chartType === 'scatter') {
738
+ const points = payload.points?.length ? payload.points : [{ x: 0, y: 0, label: 'No data' }];
739
+ return renderChart('scatter', {
740
+ datasets: [
741
+ {
742
+ label: payload.seriesLabel ?? 'Snapshots',
743
+ data: points.map((point) => ({ x: numberOrZero(point.x), y: numberOrZero(point.y), label: point.label })),
744
+ pointRadius: 6,
745
+ pointHoverRadius: 7,
746
+ pointBackgroundColor: points.map(() => PITCHER_COLORS.primary),
747
+ },
748
+ ],
749
+ }, {
750
+ title: payload.title ?? 'Pitcher Compare',
751
+ subtitle: payload.subtitle ?? 'Risk and reward view.',
752
+ chartOptions: {
753
+ plugins: {
754
+ tooltip: {
755
+ callbacks: {
756
+ label(context) {
757
+ const raw = context.raw ?? {};
758
+ return `${raw.label ?? 'Snapshot'} | X ${Number(raw.x).toFixed(1)} | Y ${Number(raw.y).toFixed(1)}`;
759
+ },
760
+ },
761
+ },
762
+ },
763
+ },
764
+ });
765
+ }
766
+
767
+ return createPitcherTableCardPng({
768
+ accent: PITCHER_COLORS.secondary,
769
+ width: payload.width,
770
+ height: payload.height ?? 720,
771
+ title: payload.title ?? 'Pitcher Compare',
772
+ subtitle: payload.subtitle ?? 'Baseline and comparison view.',
773
+ playerName: payload.pitcherName ?? 'Unknown Pitcher',
774
+ teamLine: payload.teamLine ?? '',
775
+ read: payload.read,
776
+ columns: [
777
+ { key: 'currentValue', label: payload.compareLabel ?? 'Current' },
778
+ { key: 'baselineValue', label: payload.baselineLabel ?? 'Baseline' },
779
+ ],
780
+ rows: payload.rows,
781
+ });
782
+ }
783
+
784
  function drawMetricList(ctx, x, y, width, metrics, accent) {
785
  const rows = metrics.length
786
  ? metrics
 
811
  wrapText(ctx, String(text ?? ''), x + 18, y + 58, width - 36, 22, 15, TEXT.subtitle);
812
  }
813
 
814
+ async function createPitcherTableCardPng(payload = {}) {
815
+ const width = payload.width ?? 1120;
816
+ const height = payload.height ?? 760;
817
+ const canvas = new Canvas(width, height);
818
+ const ctx = canvas.getContext('2d');
819
+
820
+ paintCanvasBackground(ctx, width, height, ['#111827', '#14243d', '#1a2a46']);
821
+ drawHeader(
822
+ ctx,
823
+ width,
824
+ payload.title ?? 'Pitcher Table',
825
+ payload.subtitle ?? 'Pitcher data view.',
826
+ payload.accent ?? PITCHER_COLORS.primary
827
+ );
828
+ drawPanel(ctx, 44, 120, width - 88, height - 164);
829
+
830
+ ctx.fillStyle = TEXT.title;
831
+ ctx.font = 'bold 24px sans-serif';
832
+ ctx.fillText(payload.playerName ?? 'Unknown Pitcher', 76, 164);
833
+ ctx.fillStyle = TEXT.subtitle;
834
+ ctx.font = '17px sans-serif';
835
+ if (payload.teamLine) {
836
+ ctx.fillText(payload.teamLine, 76, 192);
837
+ }
838
+
839
+ const tableX = 76;
840
+ const tableY = 238;
841
+ const tableWidth = width - 152;
842
+ const columns = payload.columns?.length ? payload.columns : [{ key: 'value', label: 'Value' }];
843
+ const rows = payload.rows?.length ? payload.rows : [{ label: 'No data', value: 'N/A' }];
844
+ const labelWidth = 180;
845
+ const dataWidth = (tableWidth - labelWidth) / Math.max(columns.length, 1);
846
+
847
+ ctx.fillStyle = TEXT.muted;
848
+ ctx.font = 'bold 14px sans-serif';
849
+ ctx.fillText('Pitch / Row', tableX, tableY);
850
+ columns.forEach((column, index) => {
851
+ ctx.fillText(column.label, tableX + labelWidth + index * dataWidth, tableY);
852
+ });
853
+
854
+ rows.slice(0, 8).forEach((row, index) => {
855
+ const top = tableY + 34 + index * 46;
856
+ ctx.fillStyle = index % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.01)';
857
+ roundRect(ctx, tableX - 10, top - 22, tableWidth + 20, 34, 10);
858
+ ctx.fill();
859
+
860
+ ctx.fillStyle = TEXT.title;
861
+ ctx.font = 'bold 15px sans-serif';
862
+ ctx.fillText(String(row.label ?? 'Row'), tableX, top);
863
+ ctx.font = '14px sans-serif';
864
+ columns.forEach((column, columnIndex) => {
865
+ ctx.fillStyle = columnIndex === 0 ? (payload.accent ?? PITCHER_COLORS.primary) : TEXT.subtitle;
866
+ const rawValue = row[column.key];
867
+ const text = formatTableMetric(rawValue, column.type);
868
+ ctx.fillText(text, tableX + labelWidth + columnIndex * dataWidth, top);
869
+ });
870
+ });
871
+
872
+ drawMiniSummary(ctx, 76, height - 184, width - 152, 110, 'Read', payload.read ?? 'This card summarizes the selected pitcher view.');
873
+ return canvas.toBuffer('png');
874
+ }
875
+
876
  function drawHeader(ctx, width, title, subtitle, accentColor) {
877
  ctx.fillStyle = TEXT.title;
878
  ctx.font = 'bold 34px sans-serif';
 
960
  return Number.isFinite(numeric) ? numeric : 0;
961
  }
962
 
963
+ function formatTableMetric(value, type) {
964
+ const numeric = Number(value);
965
+ if (!Number.isFinite(numeric)) {
966
+ return String(value ?? 'N/A');
967
+ }
968
+ if (type === 'pct') {
969
+ const scaled = Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
970
+ return `${scaled.toFixed(1)}%`;
971
+ }
972
+ if (type === 'pct_signed') {
973
+ const scaled = Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
974
+ return `${scaled >= 0 ? '+' : ''}${scaled.toFixed(1)}%`;
975
+ }
976
+ if (type === 'decimal') {
977
+ return numeric.toFixed(3);
978
+ }
979
+ return numeric.toFixed(Math.abs(numeric) < 10 ? 2 : 1);
980
+ }
981
+
982
  function truncateLabel(value, maxLength = 24) {
983
  const text = String(value ?? '');
984
  return text.length <= maxLength ? text : `${text.slice(0, maxLength - 3)}...`;
src/commands.js CHANGED
@@ -233,6 +233,68 @@ function addMatchupOptions(command, options = {}) {
233
  }
234
 
235
  const BASEBALL_CHART_BOOK_CHOICES = SPORTSBOOK_BOOK_CHOICES;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
 
237
  const DIRECT_ODDS_MARKET_CHOICES = [
238
  { name: 'Home Runs', value: 'batter_home_runs' },
@@ -286,6 +348,74 @@ function addBaseballChartBookOption(command) {
286
  );
287
  }
288
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  export const commands = [
290
  new SlashCommandBuilder()
291
  .setName('bet')
@@ -858,6 +988,84 @@ export const commands = [
858
  .setRequired(true)
859
  )
860
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
861
  new SlashCommandBuilder()
862
  .setName('alerts')
863
  .setDescription('Post the analyst alert-role embed to the welcome channel.'),
 
233
  }
234
 
235
  const BASEBALL_CHART_BOOK_CHOICES = SPORTSBOOK_BOOK_CHOICES;
236
+ const PITCHER_SUITE_WINDOW_CHOICES = [
237
+ { name: 'Last 5', value: 'last_5' },
238
+ { name: 'Last 10', value: 'last_10' },
239
+ { name: 'Season 2026', value: 'season_2026' },
240
+ { name: 'Career', value: 'career' },
241
+ ];
242
+ const PITCHER_SUITE_SPLIT_CHOICES = [
243
+ { name: 'Overall', value: 'overall' },
244
+ { name: 'Vs LHB', value: 'vs_lhb' },
245
+ { name: 'Vs RHB', value: 'vs_rhb' },
246
+ ];
247
+ const PITCHER_TREND_VIEW_CHOICES = [
248
+ { name: 'Velocity', value: 'velo' },
249
+ { name: 'Spin', value: 'spin' },
250
+ { name: 'Release', value: 'release' },
251
+ { name: 'Form', value: 'form' },
252
+ { name: 'Results', value: 'results' },
253
+ { name: 'Baseline', value: 'baseline' },
254
+ ];
255
+ const PITCHER_ARSENAL_VIEW_CHOICES = [
256
+ { name: 'Shape', value: 'shape' },
257
+ { name: 'Movement', value: 'movement' },
258
+ { name: 'Usage', value: 'usage' },
259
+ { name: 'Evolution', value: 'evolution' },
260
+ { name: 'Outcomes', value: 'outcomes' },
261
+ { name: 'Platoon', value: 'platoon' },
262
+ ];
263
+ const PITCHER_LOCATION_VIEW_CHOICES = [
264
+ { name: 'Heatmap', value: 'heatmap' },
265
+ { name: 'By Pitch', value: 'bypitch' },
266
+ { name: 'Two Strike', value: 'twostrike' },
267
+ { name: 'Chase', value: 'chase' },
268
+ { name: 'Damage', value: 'damage' },
269
+ { name: 'Miss', value: 'miss' },
270
+ ];
271
+ const PITCHER_APPROACH_VIEW_CHOICES = [
272
+ { name: 'Count Usage', value: 'count_usage' },
273
+ { name: 'Count Whiff', value: 'count_whiff' },
274
+ { name: 'Ahead / Behind', value: 'ahead_behind' },
275
+ { name: 'First Pitch', value: 'first_pitch' },
276
+ { name: 'Putaway', value: 'putaway' },
277
+ ];
278
+ const PITCHER_COMPARE_VIEW_CHOICES = [
279
+ { name: 'Current vs Career', value: 'current_vs_career' },
280
+ { name: 'Recent vs Baseline', value: 'recent_vs_baseline' },
281
+ { name: 'Year Over Year', value: 'year_over_year' },
282
+ { name: 'Risk Reward', value: 'risk_reward' },
283
+ ];
284
+ const PITCHER_COMPARE_TO_CHOICES = [
285
+ { name: 'Season 2026', value: 'season_2026' },
286
+ { name: 'Career', value: 'career' },
287
+ { name: 'Prior 5', value: 'prior_5' },
288
+ { name: 'Prior 10', value: 'prior_10' },
289
+ ];
290
+ const PITCHER_COUNT_BUCKET_CHOICES = [
291
+ { name: 'All', value: 'all' },
292
+ { name: 'First Pitch', value: 'first_pitch' },
293
+ { name: 'Ahead', value: 'ahead' },
294
+ { name: 'Behind', value: 'behind' },
295
+ { name: 'Putaway', value: 'putaway' },
296
+ { name: 'Two Strike', value: 'two_strike' },
297
+ ];
298
 
299
  const DIRECT_ODDS_MARKET_CHOICES = [
300
  { name: 'Home Runs', value: 'batter_home_runs' },
 
348
  );
349
  }
350
 
351
+ function addPitcherSuitePitcherOption(command) {
352
+ return command.addStringOption((option) =>
353
+ option
354
+ .setName('pitcher')
355
+ .setDescription('Pitcher name to chart.')
356
+ .setRequired(true)
357
+ );
358
+ }
359
+
360
+ function addPitcherSuiteViewOption(command, choices) {
361
+ return command.addStringOption((option) =>
362
+ option
363
+ .setName('view')
364
+ .setDescription('Chart view to render.')
365
+ .setRequired(true)
366
+ .addChoices(...choices)
367
+ );
368
+ }
369
+
370
+ function addPitcherSuitePitchTypeOption(command) {
371
+ return command.addStringOption((option) =>
372
+ option
373
+ .setName('pitch_type')
374
+ .setDescription('Optional pitch type filter, for example FF, SL, or CH.')
375
+ .setRequired(false)
376
+ );
377
+ }
378
+
379
+ function addPitcherSuiteWindowOption(command) {
380
+ return command.addStringOption((option) =>
381
+ option
382
+ .setName('window')
383
+ .setDescription('Optional time window.')
384
+ .setRequired(false)
385
+ .addChoices(...PITCHER_SUITE_WINDOW_CHOICES)
386
+ );
387
+ }
388
+
389
+ function addPitcherSuiteSplitOption(command) {
390
+ return command.addStringOption((option) =>
391
+ option
392
+ .setName('split')
393
+ .setDescription('Optional batter-side split.')
394
+ .setRequired(false)
395
+ .addChoices(...PITCHER_SUITE_SPLIT_CHOICES)
396
+ );
397
+ }
398
+
399
+ function addPitcherSuiteCompareToOption(command) {
400
+ return command.addStringOption((option) =>
401
+ option
402
+ .setName('compare_to')
403
+ .setDescription('Optional comparison baseline.')
404
+ .setRequired(false)
405
+ .addChoices(...PITCHER_COMPARE_TO_CHOICES)
406
+ );
407
+ }
408
+
409
+ function addPitcherSuiteCountBucketOption(command) {
410
+ return command.addStringOption((option) =>
411
+ option
412
+ .setName('count_bucket')
413
+ .setDescription('Optional count bucket filter.')
414
+ .setRequired(false)
415
+ .addChoices(...PITCHER_COUNT_BUCKET_CHOICES)
416
+ );
417
+ }
418
+
419
  export const commands = [
420
  new SlashCommandBuilder()
421
  .setName('bet')
 
988
  .setRequired(true)
989
  )
990
  ),
991
+ addBaseballChartDateOption(
992
+ addPitcherSuiteSplitOption(
993
+ addPitcherSuiteCompareToOption(
994
+ addPitcherSuiteWindowOption(
995
+ addPitcherSuitePitchTypeOption(
996
+ addPitcherSuiteViewOption(
997
+ addPitcherSuitePitcherOption(
998
+ new SlashCommandBuilder()
999
+ .setName('pitchertrend')
1000
+ .setDescription('Render time-based pitcher analysis charts beyond the daily slate.')
1001
+ ),
1002
+ PITCHER_TREND_VIEW_CHOICES
1003
+ )
1004
+ )
1005
+ )
1006
+ )
1007
+ )
1008
+ ),
1009
+ addBaseballChartDateOption(
1010
+ addPitcherSuiteSplitOption(
1011
+ addPitcherSuiteWindowOption(
1012
+ addPitcherSuitePitchTypeOption(
1013
+ addPitcherSuiteViewOption(
1014
+ addPitcherSuitePitcherOption(
1015
+ new SlashCommandBuilder()
1016
+ .setName('pitcherarsenal')
1017
+ .setDescription('Render arsenal, movement, usage, and pitch outcome views for one pitcher.')
1018
+ ),
1019
+ PITCHER_ARSENAL_VIEW_CHOICES
1020
+ )
1021
+ )
1022
+ )
1023
+ )
1024
+ ),
1025
+ addBaseballChartDateOption(
1026
+ addPitcherSuiteCountBucketOption(
1027
+ addPitcherSuiteSplitOption(
1028
+ addPitcherSuitePitchTypeOption(
1029
+ addPitcherSuiteViewOption(
1030
+ addPitcherSuitePitcherOption(
1031
+ new SlashCommandBuilder()
1032
+ .setName('pitcherlocation')
1033
+ .setDescription('Render location, zone, and attack-pattern charts for one pitcher.')
1034
+ ),
1035
+ PITCHER_LOCATION_VIEW_CHOICES
1036
+ )
1037
+ )
1038
+ )
1039
+ )
1040
+ ),
1041
+ addBaseballChartDateOption(
1042
+ addPitcherSuiteSplitOption(
1043
+ addPitcherSuiteWindowOption(
1044
+ addPitcherSuitePitchTypeOption(
1045
+ addPitcherSuiteViewOption(
1046
+ addPitcherSuitePitcherOption(
1047
+ new SlashCommandBuilder()
1048
+ .setName('pitcherapproach')
1049
+ .setDescription('Render count-state and game-plan charts for one pitcher.')
1050
+ ),
1051
+ PITCHER_APPROACH_VIEW_CHOICES
1052
+ )
1053
+ )
1054
+ )
1055
+ )
1056
+ ),
1057
+ addBaseballChartDateOption(
1058
+ addPitcherSuiteWindowOption(
1059
+ addPitcherSuiteViewOption(
1060
+ addPitcherSuitePitcherOption(
1061
+ new SlashCommandBuilder()
1062
+ .setName('pitchercompare')
1063
+ .setDescription('Render baseline, year-over-year, and risk/reward comparison charts for one pitcher.')
1064
+ ),
1065
+ PITCHER_COMPARE_VIEW_CHOICES
1066
+ )
1067
+ )
1068
+ ),
1069
  new SlashCommandBuilder()
1070
  .setName('alerts')
1071
  .setDescription('Post the analyst alert-role embed to the welcome channel.'),
src/embeds.js CHANGED
@@ -323,7 +323,7 @@ export function buildCommandsEmbed() {
323
  { name: '/scanreport', value: 'Post the morning scan reports immediately. Admin only.' },
324
  { name: 'Sharp Market Commands', value: '`/edgeboard`, `/playeredge`, `/marketedge`, `/widthboard`, `/consensusvs`, `/steam`, `/sharpboard`, `/bookscoreboard`, `/markethealth` cover sportsbook comparisons. `/dfsedgeboard`, `/dfsplayeredge`, `/dfsmarketedge`, `/dfswidthboard`, `/dfsconsensusvs` and `/exchangeedgeboard`, `/exchangeplayeredge`, `/exchangemarketedge`, `/exchangewidthboard`, `/exchangeconsensusvs` mirror those views for DFS and exchange lanes.' },
325
  { name: '/hrodds', value: 'Show live Home Run odds for one player across the supported sportsbook books, including Circa, FanDuel, DraftKings, Caesars, Fanatics, Bally Bet, Hard Rock Bet, and BetMGM when available.' },
326
- { name: 'Other Odds Commands', value: '`/hitodds`, `/tbodds`, `/rbiodds`, `/runodds`, `/sbodds`, `/kodds` stay sportsbook-only. `/dfsodds` and `/exchangeodds` show direct prices inside the DFS or exchange lane.' },
327
  { name: '/circatest', value: 'Run a Circa OCR diagnostic preview. Admin only.' },
328
  { name: 'Circa Market Commands', value: '`/circamarket`, `/circahr`, `/circahits`, `/circatb`, `/circarbis`, `/circaruns`, `/circasb`, `/circahrri`, `/circak` post the latest parsed Circa markets in the channel where you run them.' },
329
  { name: '/alerts', value: 'Post the public analyst alert-role panel to the welcome channel. Only for jew_olympics.' },
@@ -1148,7 +1148,7 @@ export function buildMatchupHealthEmbed(result) {
1148
  ));
1149
  }
1150
 
1151
- function buildBaseballChartEmbed({ title, description, fileName, color = PALETTE.primary, read, source, resolvedDate, player, team, opponent, book }) {
1152
  const details = [
1153
  description ?? null,
1154
  source ? `Source: **${String(source).toUpperCase()}**` : null,
@@ -1156,6 +1156,12 @@ function buildBaseballChartEmbed({ title, description, fileName, color = PALETTE
1156
  player ? `Player: **${player}**` : null,
1157
  team ? `Team: **${team}**` : null,
1158
  opponent ? `Opponent: **${opponent}**` : null,
 
 
 
 
 
 
1159
  book ? `Book: **${book}**` : null,
1160
  read ? `Read: ${read}` : null,
1161
  ].filter(Boolean);
@@ -1315,6 +1321,100 @@ export function buildKCountEmbed(result, fileName = 'k-count-chart.png') {
1315
  });
1316
  }
1317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1318
  function escapeCsv(value) {
1319
  const stringValue = String(value);
1320
  if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
 
323
  { name: '/scanreport', value: 'Post the morning scan reports immediately. Admin only.' },
324
  { name: 'Sharp Market Commands', value: '`/edgeboard`, `/playeredge`, `/marketedge`, `/widthboard`, `/consensusvs`, `/steam`, `/sharpboard`, `/bookscoreboard`, `/markethealth` cover sportsbook comparisons. `/dfsedgeboard`, `/dfsplayeredge`, `/dfsmarketedge`, `/dfswidthboard`, `/dfsconsensusvs` and `/exchangeedgeboard`, `/exchangeplayeredge`, `/exchangemarketedge`, `/exchangewidthboard`, `/exchangeconsensusvs` mirror those views for DFS and exchange lanes.' },
325
  { name: '/hrodds', value: 'Show live Home Run odds for one player across the supported sportsbook books, including Circa, FanDuel, DraftKings, Caesars, Fanatics, Bally Bet, Hard Rock Bet, and BetMGM when available.' },
326
+ { name: 'Other Odds Commands', value: '`/hitodds`, `/tbodds`, `/rbiodds`, `/runodds`, `/sbodds`, `/kodds` stay sportsbook-only. `/dfsodds` and `/exchangeodds` show direct prices inside the DFS or exchange lane. Pitcher charts now include `/ktrend`, `/kladder`, `/kprofile`, `/kmatchup`, `/kcount`, plus `/pitchertrend`, `/pitcherarsenal`, `/pitcherlocation`, `/pitcherapproach`, and `/pitchercompare` for deeper pitch-tracking analysis.' },
327
  { name: '/circatest', value: 'Run a Circa OCR diagnostic preview. Admin only.' },
328
  { name: 'Circa Market Commands', value: '`/circamarket`, `/circahr`, `/circahits`, `/circatb`, `/circarbis`, `/circaruns`, `/circasb`, `/circahrri`, `/circak` post the latest parsed Circa markets in the channel where you run them.' },
329
  { name: '/alerts', value: 'Post the public analyst alert-role panel to the welcome channel. Only for jew_olympics.' },
 
1148
  ));
1149
  }
1150
 
1151
+ function buildBaseballChartEmbed({ title, description, fileName, color = PALETTE.primary, read, source, resolvedDate, player, team, opponent, book, view, window, split, pitchType, compareTo, countBucket }) {
1152
  const details = [
1153
  description ?? null,
1154
  source ? `Source: **${String(source).toUpperCase()}**` : null,
 
1156
  player ? `Player: **${player}**` : null,
1157
  team ? `Team: **${team}**` : null,
1158
  opponent ? `Opponent: **${opponent}**` : null,
1159
+ view ? `View: **${view}**` : null,
1160
+ window ? `Window: **${window}**` : null,
1161
+ split && split !== 'overall' ? `Split: **${split}**` : null,
1162
+ pitchType ? `Pitch: **${pitchType}**` : null,
1163
+ compareTo ? `Compare To: **${compareTo}**` : null,
1164
+ countBucket && countBucket !== 'all' ? `Count Bucket: **${countBucket}**` : null,
1165
  book ? `Book: **${book}**` : null,
1166
  read ? `Read: ${read}` : null,
1167
  ].filter(Boolean);
 
1321
  });
1322
  }
1323
 
1324
+ export function buildPitcherTrendEmbed(result, fileName = 'pitcher-trend-chart.png') {
1325
+ return buildBaseballChartEmbed({
1326
+ title: 'Pitcher Trend Suite',
1327
+ description: 'Time-based pitcher analysis using Cockroach pitch tracking.',
1328
+ fileName,
1329
+ color: PALETTE.success,
1330
+ read: result.read,
1331
+ source: result.source,
1332
+ resolvedDate: result.resolvedDate,
1333
+ player: result.pitcherName,
1334
+ team: result.team,
1335
+ opponent: result.opponentTeam,
1336
+ view: result.view,
1337
+ window: result.window,
1338
+ split: result.split,
1339
+ pitchType: result.pitchType,
1340
+ compareTo: result.compareTo,
1341
+ });
1342
+ }
1343
+
1344
+ export function buildPitcherArsenalEmbed(result, fileName = 'pitcher-arsenal-chart.png') {
1345
+ return buildBaseballChartEmbed({
1346
+ title: 'Pitcher Arsenal Suite',
1347
+ description: 'Pitch shape, movement, usage, and outcome view for the selected pitcher.',
1348
+ fileName,
1349
+ color: PALETTE.success,
1350
+ read: result.read,
1351
+ source: result.source,
1352
+ resolvedDate: result.resolvedDate,
1353
+ player: result.pitcherName,
1354
+ team: result.team,
1355
+ opponent: result.opponentTeam,
1356
+ view: result.view,
1357
+ window: result.window,
1358
+ split: result.split,
1359
+ pitchType: result.pitchType,
1360
+ });
1361
+ }
1362
+
1363
+ export function buildPitcherLocationEmbed(result, fileName = 'pitcher-location-chart.png') {
1364
+ return buildBaseballChartEmbed({
1365
+ title: 'Pitcher Location Suite',
1366
+ description: 'Attack pattern and zone distribution for the selected pitcher.',
1367
+ fileName,
1368
+ color: PALETTE.success,
1369
+ read: result.read,
1370
+ source: result.source,
1371
+ resolvedDate: result.resolvedDate,
1372
+ player: result.pitcherName,
1373
+ team: result.team,
1374
+ opponent: result.opponentTeam,
1375
+ view: result.view,
1376
+ split: result.split,
1377
+ pitchType: result.pitchType,
1378
+ countBucket: result.countBucket,
1379
+ });
1380
+ }
1381
+
1382
+ export function buildPitcherApproachEmbed(result, fileName = 'pitcher-approach-chart.png') {
1383
+ return buildBaseballChartEmbed({
1384
+ title: 'Pitcher Approach Suite',
1385
+ description: 'Count-state and sequencing view for the selected pitcher.',
1386
+ fileName,
1387
+ color: PALETTE.success,
1388
+ read: result.read,
1389
+ source: result.source,
1390
+ resolvedDate: result.resolvedDate,
1391
+ player: result.pitcherName,
1392
+ team: result.team,
1393
+ opponent: result.opponentTeam,
1394
+ view: result.view,
1395
+ window: result.window,
1396
+ split: result.split,
1397
+ pitchType: result.pitchType,
1398
+ });
1399
+ }
1400
+
1401
+ export function buildPitcherCompareEmbed(result, fileName = 'pitcher-compare-chart.png') {
1402
+ return buildBaseballChartEmbed({
1403
+ title: 'Pitcher Compare Suite',
1404
+ description: 'Baseline, year-over-year, and risk/reward comparison for the selected pitcher.',
1405
+ fileName,
1406
+ color: PALETTE.success,
1407
+ read: result.read,
1408
+ source: result.source,
1409
+ resolvedDate: result.resolvedDate,
1410
+ player: result.pitcherName,
1411
+ team: result.team,
1412
+ opponent: result.opponentTeam,
1413
+ view: result.view,
1414
+ window: result.window,
1415
+ });
1416
+ }
1417
+
1418
  function escapeCsv(value) {
1419
  const stringValue = String(value);
1420
  if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
src/index.js CHANGED
@@ -66,6 +66,11 @@ import {
66
  buildKMatchupEmbed,
67
  buildKProfileEmbed,
68
  buildKTrendEmbed,
 
 
 
 
 
69
  buildExportAttachment,
70
  buildMatchupHealthEmbed,
71
  buildMatchupHittersEmbed,
@@ -99,6 +104,11 @@ import {
99
  createKMatchupCardPng,
100
  createKProfileRadarPng,
101
  createKTrendChartPng,
 
 
 
 
 
102
  } from './baseball-charts.js';
103
 
104
  const BET_MODAL_PREFIX = 'bet-entry-modal';
@@ -580,6 +590,11 @@ async function handleChatInput(interaction, store, config) {
580
  'kprofile',
581
  'kmatchup',
582
  'kcount',
 
 
 
 
 
583
  ].includes(commandName)) {
584
  await handleBaseballChartCommand(interaction, config, commandName);
585
  return;
@@ -638,8 +653,13 @@ function getBaseballChartFilters(interaction) {
638
  pitcher: interaction.options.getString('pitcher') ?? undefined,
639
  date: interaction.options.getString('date') ?? undefined,
640
  limit: interaction.options.getInteger('limit') ?? undefined,
641
- window: interaction.options.getInteger('window') ?? undefined,
642
  book: interaction.options.getString('book') ?? undefined,
 
 
 
 
 
643
  };
644
  }
645
 
@@ -1790,6 +1810,105 @@ async function handleBaseballChartCommand(interaction, config, commandName) {
1790
  return;
1791
  }
1792
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1793
  await finalizeDeferredInteraction(interaction, {
1794
  embeds: [buildErrorEmbed('Command unavailable', 'That baseball chart command is not wired up yet.')],
1795
  });
@@ -1845,6 +1964,11 @@ function shouldDeferImmediately(commandName) {
1845
  'kprofile',
1846
  'kmatchup',
1847
  'kcount',
 
 
 
 
 
1848
  ].includes(commandName);
1849
  }
1850
 
 
66
  buildKMatchupEmbed,
67
  buildKProfileEmbed,
68
  buildKTrendEmbed,
69
+ buildPitcherApproachEmbed,
70
+ buildPitcherArsenalEmbed,
71
+ buildPitcherCompareEmbed,
72
+ buildPitcherLocationEmbed,
73
+ buildPitcherTrendEmbed,
74
  buildExportAttachment,
75
  buildMatchupHealthEmbed,
76
  buildMatchupHittersEmbed,
 
104
  createKMatchupCardPng,
105
  createKProfileRadarPng,
106
  createKTrendChartPng,
107
+ createPitcherApproachChartPng,
108
+ createPitcherArsenalChartPng,
109
+ createPitcherCompareChartPng,
110
+ createPitcherLocationChartPng,
111
+ createPitcherTrendChartPng,
112
  } from './baseball-charts.js';
113
 
114
  const BET_MODAL_PREFIX = 'bet-entry-modal';
 
590
  'kprofile',
591
  'kmatchup',
592
  'kcount',
593
+ 'pitchertrend',
594
+ 'pitcherarsenal',
595
+ 'pitcherlocation',
596
+ 'pitcherapproach',
597
+ 'pitchercompare',
598
  ].includes(commandName)) {
599
  await handleBaseballChartCommand(interaction, config, commandName);
600
  return;
 
653
  pitcher: interaction.options.getString('pitcher') ?? undefined,
654
  date: interaction.options.getString('date') ?? undefined,
655
  limit: interaction.options.getInteger('limit') ?? undefined,
656
+ window: interaction.options.getString('window') ?? interaction.options.getInteger('window') ?? undefined,
657
  book: interaction.options.getString('book') ?? undefined,
658
+ view: interaction.options.getString('view') ?? undefined,
659
+ pitchType: interaction.options.getString('pitch_type') ?? undefined,
660
+ split: interaction.options.getString('split') ?? undefined,
661
+ compareTo: interaction.options.getString('compare_to') ?? undefined,
662
+ countBucket: interaction.options.getString('count_bucket') ?? undefined,
663
  };
664
  }
665
 
 
1810
  return;
1811
  }
1812
 
1813
+ if (commandName === 'pitchertrend') {
1814
+ const result = await matchupService.getPitcherTrendChartData(filters);
1815
+ const png = await createPitcherTrendChartPng({
1816
+ chartType: result.chartType,
1817
+ title: result.title,
1818
+ subtitle: result.subtitle,
1819
+ points: result.points,
1820
+ primaryLabel: result.primaryLabel,
1821
+ overlays: result.overlays,
1822
+ labels: result.labels,
1823
+ values: result.values,
1824
+ datasets: result.datasets,
1825
+ });
1826
+ const fileName = 'pitcher-trend-chart.png';
1827
+ await finalizeDeferredInteraction(interaction, {
1828
+ embeds: [buildPitcherTrendEmbed(result, fileName)],
1829
+ files: [buildBaseballChartAttachment(png, fileName)],
1830
+ });
1831
+ return;
1832
+ }
1833
+
1834
+ if (commandName === 'pitcherarsenal') {
1835
+ const result = await matchupService.getPitcherArsenalChartData(filters);
1836
+ const png = await createPitcherArsenalChartPng({
1837
+ title: result.title,
1838
+ subtitle: result.subtitle,
1839
+ pitcherName: result.pitcherName,
1840
+ teamLine: `${result.team ?? 'N/A'}${result.opponentTeam ? ` vs ${result.opponentTeam}` : ''}`,
1841
+ columns: result.columns,
1842
+ rows: result.rows,
1843
+ read: result.read,
1844
+ });
1845
+ const fileName = 'pitcher-arsenal-chart.png';
1846
+ await finalizeDeferredInteraction(interaction, {
1847
+ embeds: [buildPitcherArsenalEmbed(result, fileName)],
1848
+ files: [buildBaseballChartAttachment(png, fileName)],
1849
+ });
1850
+ return;
1851
+ }
1852
+
1853
+ if (commandName === 'pitcherlocation') {
1854
+ const result = await matchupService.getPitcherLocationChartData(filters);
1855
+ const png = await createPitcherLocationChartPng({
1856
+ title: result.title,
1857
+ subtitle: result.subtitle,
1858
+ pitcherName: result.pitcherName,
1859
+ teamLine: `${result.team ?? 'N/A'}${result.opponentTeam ? ` vs ${result.opponentTeam}` : ''}`,
1860
+ metricLabel: result.view,
1861
+ cells: result.cells,
1862
+ bestOverlay: result.bestOverlay,
1863
+ shapeSummary: result.shapeSummary,
1864
+ read: result.read,
1865
+ });
1866
+ const fileName = 'pitcher-location-chart.png';
1867
+ await finalizeDeferredInteraction(interaction, {
1868
+ embeds: [buildPitcherLocationEmbed(result, fileName)],
1869
+ files: [buildBaseballChartAttachment(png, fileName)],
1870
+ });
1871
+ return;
1872
+ }
1873
+
1874
+ if (commandName === 'pitcherapproach') {
1875
+ const result = await matchupService.getPitcherApproachChartData(filters);
1876
+ const png = await createPitcherApproachChartPng({
1877
+ title: result.title,
1878
+ subtitle: result.subtitle,
1879
+ labels: result.labels,
1880
+ datasets: result.datasets,
1881
+ });
1882
+ const fileName = 'pitcher-approach-chart.png';
1883
+ await finalizeDeferredInteraction(interaction, {
1884
+ embeds: [buildPitcherApproachEmbed(result, fileName)],
1885
+ files: [buildBaseballChartAttachment(png, fileName)],
1886
+ });
1887
+ return;
1888
+ }
1889
+
1890
+ if (commandName === 'pitchercompare') {
1891
+ const result = await matchupService.getPitcherCompareChartData(filters);
1892
+ const png = await createPitcherCompareChartPng({
1893
+ chartType: result.chartType,
1894
+ title: result.title,
1895
+ subtitle: result.subtitle,
1896
+ pitcherName: result.pitcherName,
1897
+ teamLine: `${result.team ?? 'N/A'}${result.opponentTeam ? ` vs ${result.opponentTeam}` : ''}`,
1898
+ points: result.points,
1899
+ rows: result.rows,
1900
+ compareLabel: result.compareLabel,
1901
+ baselineLabel: result.baselineLabel,
1902
+ read: result.read,
1903
+ });
1904
+ const fileName = 'pitcher-compare-chart.png';
1905
+ await finalizeDeferredInteraction(interaction, {
1906
+ embeds: [buildPitcherCompareEmbed(result, fileName)],
1907
+ files: [buildBaseballChartAttachment(png, fileName)],
1908
+ });
1909
+ return;
1910
+ }
1911
+
1912
  await finalizeDeferredInteraction(interaction, {
1913
  embeds: [buildErrorEmbed('Command unavailable', 'That baseball chart command is not wired up yet.')],
1914
  });
 
1964
  'kprofile',
1965
  'kmatchup',
1966
  'kcount',
1967
+ 'pitchertrend',
1968
+ 'pitcherarsenal',
1969
+ 'pitcherlocation',
1970
+ 'pitcherapproach',
1971
+ 'pitchercompare',
1972
  ].includes(commandName);
1973
  }
1974
 
src/matchups.js CHANGED
@@ -1254,6 +1254,379 @@ function formatMetricValue(value, type = 'number') {
1254
  return numeric.toFixed(1);
1255
  }
1256
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1257
  function findBestPlayerMatch(rows, key, playerName) {
1258
  const normalizedNeedle = normalizeText(playerName);
1259
  const exact = rows.find((row) => normalizeText(row[key]) === normalizedNeedle);
@@ -2522,6 +2895,714 @@ export class MatchupService {
2522
  });
2523
  }
2524
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2525
  async buildHostedTrendPoints({ date, window, kind, playerName }) {
2526
  const requestedWindow = Math.max(3, Math.min(14, Number(window ?? 7)));
2527
  if (!this.hosted?.isConfigured?.()) {
 
1254
  return numeric.toFixed(1);
1255
  }
1256
 
1257
+ function getPitcherWindowPointLimit(window) {
1258
+ switch (String(window ?? 'last_5')) {
1259
+ case 'last_10':
1260
+ return 10;
1261
+ case 'season_2026':
1262
+ return 15;
1263
+ case 'career':
1264
+ return 6;
1265
+ default:
1266
+ return 5;
1267
+ }
1268
+ }
1269
+
1270
+ function windowLabel(window) {
1271
+ switch (String(window ?? 'last_5')) {
1272
+ case 'last_10':
1273
+ return 'last 10';
1274
+ case 'season_2026':
1275
+ return 'season 2026';
1276
+ case 'career':
1277
+ return 'career';
1278
+ default:
1279
+ return 'last 5';
1280
+ }
1281
+ }
1282
+
1283
+ function splitLabel(split) {
1284
+ switch (String(split ?? 'overall')) {
1285
+ case 'vs_lhb':
1286
+ return 'vs LHB';
1287
+ case 'vs_rhb':
1288
+ return 'vs RHB';
1289
+ default:
1290
+ return 'overall';
1291
+ }
1292
+ }
1293
+
1294
+ function countBucketLabel(bucket) {
1295
+ switch (String(bucket ?? 'all')) {
1296
+ case 'first_pitch':
1297
+ return 'first pitch';
1298
+ case 'ahead':
1299
+ return 'ahead';
1300
+ case 'behind':
1301
+ return 'behind';
1302
+ case 'putaway':
1303
+ return 'putaway';
1304
+ case 'two_strike':
1305
+ return 'two strike';
1306
+ default:
1307
+ return 'all counts';
1308
+ }
1309
+ }
1310
+
1311
+ function labelForCompareTarget(compareTo) {
1312
+ switch (String(compareTo ?? 'career')) {
1313
+ case 'season_2026':
1314
+ return 'Season 2026';
1315
+ case 'prior_5':
1316
+ return 'Prior 5';
1317
+ case 'prior_10':
1318
+ return 'Prior 10';
1319
+ default:
1320
+ return 'Career';
1321
+ }
1322
+ }
1323
+
1324
+ function pitcherTrendTitle(view) {
1325
+ switch (view) {
1326
+ case 'velo':
1327
+ return 'Velocity Trend';
1328
+ case 'spin':
1329
+ return 'Spin Trend';
1330
+ case 'release':
1331
+ return 'Release Trend';
1332
+ default:
1333
+ return 'Pitcher Trend';
1334
+ }
1335
+ }
1336
+
1337
+ function pitcherTrendPrimaryLabel(view) {
1338
+ switch (view) {
1339
+ case 'velo':
1340
+ return 'Release Speed';
1341
+ case 'spin':
1342
+ return 'Spin Rate';
1343
+ case 'release':
1344
+ return 'Extension';
1345
+ default:
1346
+ return 'Pitcher Trend';
1347
+ }
1348
+ }
1349
+
1350
+ function pitcherTrendOverlayLabel(view, index) {
1351
+ if (view === 'velo') {
1352
+ return index === 0 ? 'Effective Speed' : 'Move Z';
1353
+ }
1354
+ if (view === 'spin') {
1355
+ return index === 0 ? 'Spin Efficiency Proxy' : 'Spin Axis';
1356
+ }
1357
+ return index === 0 ? 'Release X' : 'Release Z';
1358
+ }
1359
+
1360
+ function formatTrendOverlayValue(view, index, value) {
1361
+ const numeric = numberOrNull(value) ?? 0;
1362
+ if (view === 'spin' && index === 0) {
1363
+ return numeric * 100;
1364
+ }
1365
+ return numeric;
1366
+ }
1367
+
1368
+ function pitcherTrendRead(view, points, pitchType) {
1369
+ const latest = points[points.length - 1];
1370
+ if (view === 'velo') {
1371
+ return `${pitchType ? `${pitchType} ` : ''}velocity most recently checked in at ${formatMetricValue(latest?.value)}.`;
1372
+ }
1373
+ if (view === 'spin') {
1374
+ return `${pitchType ? `${pitchType} ` : ''}spin most recently checked in at ${formatMetricValue(latest?.value)}.`;
1375
+ }
1376
+ return `${pitchType ? `${pitchType} ` : ''}release metrics track extension with position overlays across the selected window.`;
1377
+ }
1378
+
1379
+ function pitcherArsenalTitle(view) {
1380
+ switch (view) {
1381
+ case 'shape':
1382
+ return 'Pitch Shape Card';
1383
+ case 'movement':
1384
+ return 'Movement Cluster';
1385
+ case 'usage':
1386
+ return 'Pitch Mix Usage';
1387
+ case 'evolution':
1388
+ return 'Arsenal Evolution';
1389
+ case 'outcomes':
1390
+ return 'Pitch Outcomes';
1391
+ case 'platoon':
1392
+ return 'Platoon Arsenal';
1393
+ default:
1394
+ return 'Pitcher Arsenal';
1395
+ }
1396
+ }
1397
+
1398
+ function pitcherLocationTitle(view) {
1399
+ switch (view) {
1400
+ case 'bypitch':
1401
+ return 'Location by Pitch';
1402
+ case 'twostrike':
1403
+ return 'Two Strike Location';
1404
+ case 'chase':
1405
+ return 'Chase Attack Map';
1406
+ case 'damage':
1407
+ return 'Damage Allowed Map';
1408
+ case 'miss':
1409
+ return 'Miss Location Map';
1410
+ default:
1411
+ return 'Location Heatmap';
1412
+ }
1413
+ }
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':
1420
+ return 'Hotter cells flag where swings and misses show up most often.';
1421
+ case 'chase':
1422
+ return 'Hotter cells flag where the pitcher attacks for chase or edge pressure.';
1423
+ default:
1424
+ return 'Hotter cells show where the pitcher lives most often in the selected view.';
1425
+ }
1426
+ }
1427
+
1428
+ function pitcherApproachTitle(view) {
1429
+ switch (view) {
1430
+ case 'count_whiff':
1431
+ return 'Whiff by Count';
1432
+ case 'ahead_behind':
1433
+ return 'Ahead vs Behind';
1434
+ case 'first_pitch':
1435
+ return 'First Pitch Usage';
1436
+ case 'putaway':
1437
+ return 'Putaway Distribution';
1438
+ default:
1439
+ return 'Pitch Usage by Count';
1440
+ }
1441
+ }
1442
+
1443
+ function pitcherCompareTitle(view) {
1444
+ switch (view) {
1445
+ case 'recent_vs_baseline':
1446
+ return 'Recent vs Baseline';
1447
+ case 'year_over_year':
1448
+ return 'Year Over Year';
1449
+ default:
1450
+ return 'Current vs Career';
1451
+ }
1452
+ }
1453
+
1454
+ function buildPitchTypeSqlFilter(pitchTypeColumn, pitchNameColumn, parameterIndex) {
1455
+ const pitchTypeExpr = pitchTypeColumn === 'NULL'
1456
+ ? 'NULL'
1457
+ : `UPPER(COALESCE(NULLIF(${pitchTypeColumn}, ''), NULLIF(${pitchNameColumn}, '')))`;
1458
+ const pitchNameExpr = `UPPER(COALESCE(NULLIF(${pitchNameColumn}, ''), NULLIF(${pitchTypeColumn}, '')))`;
1459
+ return `AND ($${parameterIndex}::text IS NULL OR ${pitchTypeExpr} = UPPER($${parameterIndex}) OR ${pitchNameExpr} = UPPER($${parameterIndex}))`;
1460
+ }
1461
+
1462
+ function buildSplitSqlFilter(columnExpression, parameterIndex) {
1463
+ return `AND ($${parameterIndex}::text IS NULL OR $${parameterIndex}::text = 'overall' OR LOWER(${columnExpression}) = CASE WHEN $${parameterIndex}::text = 'vs_lhb' THEN 'l' WHEN $${parameterIndex}::text = 'vs_rhb' THEN 'r' ELSE LOWER(${columnExpression}) END)`;
1464
+ }
1465
+
1466
+ function deriveCountBucket(row) {
1467
+ const balls = numberOrNull(row.balls) ?? 0;
1468
+ const strikes = numberOrNull(row.strikes) ?? 0;
1469
+ if (balls === 0 && strikes === 0) {
1470
+ return 'first_pitch';
1471
+ }
1472
+ if (strikes >= 2) {
1473
+ return 'putaway';
1474
+ }
1475
+ if (strikes > balls) {
1476
+ return 'ahead';
1477
+ }
1478
+ if (balls > strikes) {
1479
+ return 'behind';
1480
+ }
1481
+ return 'even';
1482
+ }
1483
+
1484
+ function matchesCountBucket(row, countBucket) {
1485
+ if (!countBucket || countBucket === 'all') {
1486
+ return true;
1487
+ }
1488
+ if (countBucket === 'two_strike') {
1489
+ return (numberOrNull(row.strikes) ?? 0) >= 2;
1490
+ }
1491
+ return deriveCountBucket(row) === countBucket;
1492
+ }
1493
+
1494
+ function bucketPlateCell(plateX, plateZ) {
1495
+ const x = numberOrNull(plateX) ?? 0;
1496
+ const z = numberOrNull(plateZ) ?? 2.8;
1497
+ const col = x < -0.28 ? 0 : x > 0.28 ? 2 : 1;
1498
+ const row = z > 3.3 ? 0 : z < 2.5 ? 2 : 1;
1499
+ return row * 3 + col + 1;
1500
+ }
1501
+
1502
+ function isWhiffDescription(description) {
1503
+ const normalized = normalizeText(description);
1504
+ return ['swinging strike', 'swinging strike blocked', 'foul tip'].some((token) => normalized.includes(token));
1505
+ }
1506
+
1507
+ function isChaseLikeLocation(plateX, plateZ) {
1508
+ const x = Math.abs(numberOrNull(plateX) ?? 0);
1509
+ const z = numberOrNull(plateZ) ?? 2.8;
1510
+ return x > 0.85 || z < 1.7 || z > 3.9;
1511
+ }
1512
+
1513
+ function buildPitcherLocationCells(rows, view) {
1514
+ const buckets = new Map();
1515
+ const totalRows = rows.length || 1;
1516
+ for (const row of rows) {
1517
+ const zone = bucketPlateCell(row.plate_x, row.plate_z);
1518
+ const entry = buckets.get(zone) ?? { zone, pitches: 0, whiffs: 0, xwobaTotal: 0, xwobaCount: 0, chasePressure: 0 };
1519
+ entry.pitches += 1;
1520
+ if (isWhiffDescription(row.description)) {
1521
+ entry.whiffs += 1;
1522
+ }
1523
+ const xwoba = numberOrNull(row.estimated_woba_using_speedangle);
1524
+ if (xwoba !== null) {
1525
+ entry.xwobaTotal += xwoba;
1526
+ entry.xwobaCount += 1;
1527
+ }
1528
+ if (isChaseLikeLocation(row.plate_x, row.plate_z)) {
1529
+ entry.chasePressure += 1;
1530
+ }
1531
+ buckets.set(zone, entry);
1532
+ }
1533
+
1534
+ return [1, 2, 3, 4, 5, 6, 7, 8, 9].map((zone) => {
1535
+ const entry = buckets.get(zone) ?? { zone, pitches: 0, whiffs: 0, xwobaTotal: 0, xwobaCount: 0, chasePressure: 0 };
1536
+ const usage = entry.pitches / totalRows;
1537
+ const whiffRate = entry.pitches > 0 ? entry.whiffs / entry.pitches : 0;
1538
+ const damage = entry.xwobaCount > 0 ? entry.xwobaTotal / entry.xwobaCount : 0;
1539
+ const chaseRate = entry.pitches > 0 ? entry.chasePressure / entry.pitches : 0;
1540
+ let overlayValue = usage * 100;
1541
+ if (view === 'miss') {
1542
+ overlayValue = whiffRate * 100;
1543
+ } else if (view === 'damage') {
1544
+ overlayValue = damage * 100;
1545
+ } else if (view === 'chase') {
1546
+ overlayValue = chaseRate * 100;
1547
+ }
1548
+ return {
1549
+ zone,
1550
+ batterValue: whiffRate,
1551
+ pitcherValue: usage,
1552
+ overlayValue,
1553
+ };
1554
+ });
1555
+ }
1556
+
1557
+ function buildPitcherLocationSummary(cells, view) {
1558
+ const ranked = [...cells].sort((left, right) => compareNullableDescending(left.overlayValue, right.overlayValue)).slice(0, 3);
1559
+ const zones = ranked.map((cell) => `Z${cell.zone}`).join(', ');
1560
+ if (!zones) {
1561
+ return 'No location summary available.';
1562
+ }
1563
+ if (view === 'damage') {
1564
+ return `Most dangerous contact pockets sit in ${zones}.`;
1565
+ }
1566
+ if (view === 'miss') {
1567
+ return `Best miss pockets sit in ${zones}.`;
1568
+ }
1569
+ return `Most frequent attack lanes sit in ${zones}.`;
1570
+ }
1571
+
1572
+ function locationMetricSuffix(view) {
1573
+ if (view === 'damage') {
1574
+ return 'xwOBA';
1575
+ }
1576
+ return '%';
1577
+ }
1578
+
1579
+ function buildPitcherApproachDatasets(rows, view) {
1580
+ const buckets = view === 'ahead_behind'
1581
+ ? ['ahead', 'even', 'behind']
1582
+ : view === 'first_pitch'
1583
+ ? ['first_pitch']
1584
+ : view === 'putaway'
1585
+ ? ['putaway']
1586
+ : ['first_pitch', 'ahead', 'even', 'behind', 'putaway'];
1587
+
1588
+ const pitchUsage = new Map();
1589
+ const pitchWhiffs = new Map();
1590
+ const topCounts = new Map();
1591
+
1592
+ for (const row of rows) {
1593
+ const bucket = deriveCountBucket(row);
1594
+ const pitch = firstNonBlankValue(row.pitch_name, row.pitch_type, 'Unknown');
1595
+ const key = `${pitch}|${bucket}`;
1596
+ pitchUsage.set(key, (pitchUsage.get(key) ?? 0) + 1);
1597
+ if (isWhiffDescription(row.description)) {
1598
+ pitchWhiffs.set(key, (pitchWhiffs.get(key) ?? 0) + 1);
1599
+ }
1600
+ topCounts.set(pitch, (topCounts.get(pitch) ?? 0) + 1);
1601
+ }
1602
+
1603
+ const topPitches = [...topCounts.entries()]
1604
+ .sort((left, right) => right[1] - left[1])
1605
+ .slice(0, 4)
1606
+ .map(([pitch]) => pitch);
1607
+
1608
+ const datasets = topPitches.map((pitch) => ({
1609
+ label: pitch,
1610
+ values: buckets.map((bucket) => {
1611
+ const key = `${pitch}|${bucket}`;
1612
+ const usage = pitchUsage.get(key) ?? 0;
1613
+ if (view === 'count_whiff') {
1614
+ return usage > 0 ? ((pitchWhiffs.get(key) ?? 0) / usage) * 100 : 0;
1615
+ }
1616
+ const bucketTotal = [...topPitches].reduce((sum, name) => sum + (pitchUsage.get(`${name}|${bucket}`) ?? 0), 0);
1617
+ return bucketTotal > 0 ? (usage / bucketTotal) * 100 : 0;
1618
+ }),
1619
+ })).filter((dataset) => dataset.values.some((value) => value > 0));
1620
+
1621
+ return {
1622
+ labels: buckets.map((bucket) => countBucketLabel(bucket)),
1623
+ datasets,
1624
+ read: view === 'count_whiff'
1625
+ ? 'Higher bars show where each pitch shape generates the most swing-and-miss pressure.'
1626
+ : 'Higher bars show which pitch shapes own the selected count states most often.',
1627
+ };
1628
+ }
1629
+
1630
  function findBestPlayerMatch(rows, key, playerName) {
1631
  const normalizedNeedle = normalizeText(playerName);
1632
  const exact = rows.find((row) => normalizeText(row[key]) === normalizedNeedle);
 
2895
  });
2896
  }
2897
 
2898
+ async queryCockroachRows(text, values = []) {
2899
+ if (typeof this.fallback?.query !== 'function') {
2900
+ throw new Error('Cockroach query access is required for this pitcher chart.');
2901
+ }
2902
+ const result = await this.fallback.query(text, values);
2903
+ return result?.rows ?? [];
2904
+ }
2905
+
2906
+ async resolvePitcherSuiteContext(playerName, options = {}) {
2907
+ return this.getCachedChartValue(this.buildChartCacheKey('pitcher-suite-context', {
2908
+ pitcher: playerName,
2909
+ date: options.date,
2910
+ }), async () => {
2911
+ const candidates = await this.queryCockroachRows(
2912
+ `
2913
+ WITH candidates AS (
2914
+ SELECT
2915
+ pitcher_name AS pitcher_name,
2916
+ pitcher_id::text AS pitcher_id,
2917
+ team,
2918
+ opponent_team,
2919
+ p_throws AS pitcher_hand,
2920
+ slate_date::date AS latest_date,
2921
+ 1 AS source_rank
2922
+ FROM public.pitcher_model_snapshots
2923
+ WHERE LOWER(pitcher_name) LIKE LOWER($1)
2924
+
2925
+ UNION ALL
2926
+
2927
+ SELECT
2928
+ pitcher_name AS pitcher_name,
2929
+ pitcher_id::text AS pitcher_id,
2930
+ team,
2931
+ NULL::text AS opponent_team,
2932
+ NULL::text AS pitcher_hand,
2933
+ slate_date::date AS latest_date,
2934
+ 2 AS source_rank
2935
+ FROM public.pitcher_game_outcomes
2936
+ WHERE LOWER(pitcher_name) LIKE LOWER($1)
2937
+
2938
+ UNION ALL
2939
+
2940
+ SELECT
2941
+ player_name AS pitcher_name,
2942
+ pitcher::text AS pitcher_id,
2943
+ NULL::text AS team,
2944
+ NULL::text AS opponent_team,
2945
+ p_throws AS pitcher_hand,
2946
+ game_date::date AS latest_date,
2947
+ 3 AS source_rank
2948
+ FROM public.live_pitch_mix_2026
2949
+ WHERE LOWER(player_name) LIKE LOWER($1)
2950
+
2951
+ UNION ALL
2952
+
2953
+ SELECT
2954
+ player_name AS pitcher_name,
2955
+ pitcher::text AS pitcher_id,
2956
+ NULL::text AS team,
2957
+ NULL::text AS opponent_team,
2958
+ COALESCE(pitcher_hand, p_throws) AS pitcher_hand,
2959
+ game_date::date AS latest_date,
2960
+ 4 AS source_rank
2961
+ FROM public.shared_pitcher_baseline_event_rows
2962
+ WHERE LOWER(player_name) LIKE LOWER($1)
2963
+ )
2964
+ SELECT pitcher_name, pitcher_id, team, opponent_team, pitcher_hand, latest_date
2965
+ FROM candidates
2966
+ ORDER BY
2967
+ CASE WHEN LOWER(pitcher_name) = LOWER($2) THEN 0 ELSE 1 END,
2968
+ source_rank,
2969
+ latest_date DESC NULLS LAST
2970
+ LIMIT 1
2971
+ `,
2972
+ [`%${playerName}%`, playerName]
2973
+ );
2974
+
2975
+ const identity = candidates[0];
2976
+ if (!identity) {
2977
+ throw new Error(`No pitcher chart profile matched "${playerName}".`);
2978
+ }
2979
+
2980
+ const overviewRows = await this.queryCockroachRows(
2981
+ `
2982
+ SELECT
2983
+ slate_date::date AS slate_date,
2984
+ team,
2985
+ opponent_team,
2986
+ pitcher_name,
2987
+ p_throws,
2988
+ pitcher_score,
2989
+ strikeout_score,
2990
+ pitcher_matchup_adjustment,
2991
+ strikeout_matchup_adjustment,
2992
+ opponent_lineup_quality,
2993
+ opponent_contact_threat,
2994
+ opponent_whiff_tendency,
2995
+ xwoba,
2996
+ csw_pct,
2997
+ swstr_pct,
2998
+ putaway_pct,
2999
+ ball_pct,
3000
+ siera,
3001
+ gb_pct,
3002
+ gb_fb_ratio,
3003
+ barrel_bip_pct,
3004
+ hard_hit_pct
3005
+ FROM public.pitcher_model_snapshots
3006
+ WHERE LOWER(pitcher_name) = LOWER($1)
3007
+ AND ($2::date IS NULL OR slate_date::date = $2::date)
3008
+ AND split_key = $3
3009
+ AND recent_window = $4
3010
+ AND weighted_mode = $5
3011
+ ORDER BY slate_date::date DESC
3012
+ LIMIT 1
3013
+ `,
3014
+ [identity.pitcher_name, options.date ?? null, DEFAULT_SPLIT_KEY, DEFAULT_RECENT_WINDOW, DEFAULT_WEIGHTED_MODE]
3015
+ );
3016
+
3017
+ let continuityContext = null;
3018
+ try {
3019
+ continuityContext = await this.getFastPitcherChartContext(identity.pitcher_name, options);
3020
+ } catch {
3021
+ continuityContext = null;
3022
+ }
3023
+
3024
+ const overview = overviewRows[0] ?? continuityContext?.overview ?? {};
3025
+
3026
+ return {
3027
+ source: 'cockroach',
3028
+ resolvedDate: continuityContext?.resolvedDate ?? identity.latest_date ?? overview.slate_date ?? null,
3029
+ pitcherName: continuityContext?.name ?? identity.pitcher_name,
3030
+ pitcherId: String(identity.pitcher_id ?? '').trim() || null,
3031
+ team: continuityContext?.team ?? identity.team ?? overview.team ?? null,
3032
+ opponentTeam: continuityContext?.opponentTeam ?? identity.opponent_team ?? overview.opponent_team ?? null,
3033
+ hand: continuityContext?.hand ?? identity.pitcher_hand ?? overview.p_throws ?? null,
3034
+ overview,
3035
+ };
3036
+ });
3037
+ }
3038
+
3039
+ async getPitcherTrendChartData(options = {}) {
3040
+ return this.getCachedChartValue(this.buildChartCacheKey('pitcher-trend-suite', options), async () => {
3041
+ const context = await this.resolvePitcherSuiteContext(options.pitcher, options);
3042
+ const view = String(options.view ?? 'velo');
3043
+ const window = String(options.window ?? 'last_5');
3044
+ const split = String(options.split ?? 'overall');
3045
+ const pitchType = options.pitchType ?? options.pitch_type ?? null;
3046
+
3047
+ if (view === 'results') {
3048
+ const rows = await this.queryCockroachRows(
3049
+ `
3050
+ SELECT slate_date::date AS point_date, strikeouts, walks, hits_allowed, home_runs_allowed, outs_recorded
3051
+ FROM public.pitcher_game_outcomes
3052
+ WHERE LOWER(pitcher_name) = LOWER($1)
3053
+ ORDER BY slate_date::date DESC
3054
+ LIMIT $2
3055
+ `,
3056
+ [context.pitcherName, getPitcherWindowPointLimit(window)]
3057
+ );
3058
+ if (!rows.length) {
3059
+ throw new Error(`No game outcome trend was available for ${context.pitcherName}.`);
3060
+ }
3061
+ const ordered = [...rows].reverse();
3062
+ return {
3063
+ ...context,
3064
+ chartType: 'line',
3065
+ view,
3066
+ window,
3067
+ title: `Pitcher Results - ${context.pitcherName}`,
3068
+ subtitle: `${context.team ?? 'N/A'} | ${windowLabel(window)}`,
3069
+ points: ordered.map((row) => ({ label: formatDateLabel(row.point_date), value: numberOrNull(row.strikeouts) ?? 0 })),
3070
+ primaryLabel: 'Strikeouts',
3071
+ overlays: [
3072
+ { label: 'Walks', values: ordered.map((row) => numberOrNull(row.walks) ?? 0), color: '#f59e0b' },
3073
+ { label: 'Hits Allowed', values: ordered.map((row) => numberOrNull(row.hits_allowed) ?? 0), color: '#f87171' },
3074
+ ],
3075
+ read: `Recent results show ${context.pitcherName} averaging ${averageOrNull(rows.map((row) => numberOrNull(row.strikeouts)))?.toFixed(1) ?? 'N/A'} strikeouts across the selected window.`,
3076
+ };
3077
+ }
3078
+
3079
+ if (view === 'form') {
3080
+ const rows = await this.queryCockroachRows(
3081
+ `
3082
+ SELECT *
3083
+ FROM public.shared_pitcher_rolling_summary
3084
+ WHERE LOWER(player_name) = LOWER($1)
3085
+ LIMIT 1
3086
+ `,
3087
+ [context.pitcherName]
3088
+ );
3089
+ const row = rows[0];
3090
+ if (!row) {
3091
+ throw new Error(`No rolling form summary was available for ${context.pitcherName}.`);
3092
+ }
3093
+ return {
3094
+ ...context,
3095
+ chartType: 'radar',
3096
+ view,
3097
+ window,
3098
+ title: `Pitcher Form - ${context.pitcherName}`,
3099
+ subtitle: `${context.team ?? 'N/A'} | rolling dashboard`,
3100
+ labels: ['Velo 5G', 'Spin 5G', 'EV Allowed', 'HH Allowed', 'Barrel Allowed', 'HR Allowed'],
3101
+ values: [
3102
+ normalizeToRadarScore(row.pitcher_avg_release_speed_5g, { min: 88, max: 100 }),
3103
+ normalizeToRadarScore(row.pitcher_avg_release_spin_rate_5g, { min: 1800, max: 2800 }),
3104
+ normalizeToRadarScore(row.pitcher_ev_allowed_5g, { min: 80, max: 95, inverse: true }),
3105
+ normalizeToRadarScore(row.pitcher_hard_hit_rate_allowed_5g, { min: 20, max: 50, inverse: true }),
3106
+ normalizeToRadarScore(row.pitcher_barrel_rate_allowed_5g, { min: 2, max: 14, inverse: true }),
3107
+ normalizeToRadarScore(row.pitcher_hr_allowed_rate_5g, { min: 0.005, max: 0.08, inverse: true }),
3108
+ ],
3109
+ read: `Recent form confidence is ${formatMetricValue(row.pitcher_rolling_confidence)} with ${numberOrNull(row.pitcher_games_in_window_5g) ?? 0} games in the five-game window.`,
3110
+ };
3111
+ }
3112
+
3113
+ if (view === 'baseline') {
3114
+ const currentRows = await this.queryCockroachRows(
3115
+ `
3116
+ SELECT
3117
+ AVG(release_speed) AS avg_release_speed,
3118
+ AVG(release_spin_rate) AS avg_release_spin_rate,
3119
+ AVG(release_extension) AS avg_release_extension,
3120
+ AVG(pfx_x) AS avg_pfx_x,
3121
+ AVG(pfx_z) AS avg_pfx_z
3122
+ FROM public.live_pitch_mix_2026
3123
+ WHERE LOWER(player_name) = LOWER($1)
3124
+ ${buildPitchTypeSqlFilter('pitch_type', 'pitch_name', 2)}
3125
+ ${buildSplitSqlFilter('stand', 3)}
3126
+ `,
3127
+ [context.pitcherName, pitchType, split]
3128
+ );
3129
+ const baselineRows = await this.queryCockroachRows(
3130
+ `
3131
+ SELECT
3132
+ AVG(release_speed) AS avg_release_speed,
3133
+ AVG(release_spin_rate) AS avg_release_spin_rate,
3134
+ AVG(release_extension) AS avg_release_extension,
3135
+ AVG(pfx_x) AS avg_pfx_x,
3136
+ AVG(pfx_z) AS avg_pfx_z
3137
+ FROM public.shared_pitcher_baseline_event_rows
3138
+ WHERE LOWER(player_name) = LOWER($1)
3139
+ ${buildPitchTypeSqlFilter('pitch_type', 'pitch_name', 2)}
3140
+ ${buildSplitSqlFilter('COALESCE(batter_stand, stand)', 3)}
3141
+ `,
3142
+ [context.pitcherName, pitchType, split]
3143
+ );
3144
+ const current = currentRows[0] ?? {};
3145
+ const baseline = baselineRows[0] ?? {};
3146
+ return {
3147
+ ...context,
3148
+ chartType: 'bar',
3149
+ view,
3150
+ window,
3151
+ title: `Pitcher Baseline - ${context.pitcherName}`,
3152
+ subtitle: `${context.team ?? 'N/A'} | current vs baseline`,
3153
+ labels: ['Velocity', 'Spin', 'Extension', 'Move X', 'Move Z'],
3154
+ datasets: [
3155
+ {
3156
+ label: 'Season 2026',
3157
+ values: [
3158
+ numberOrNull(current.avg_release_speed) ?? 0,
3159
+ (numberOrNull(current.avg_release_spin_rate) ?? 0) / 100,
3160
+ (numberOrNull(current.avg_release_extension) ?? 0) * 10,
3161
+ (numberOrNull(current.avg_pfx_x) ?? 0) * 10,
3162
+ (numberOrNull(current.avg_pfx_z) ?? 0) * 10,
3163
+ ],
3164
+ },
3165
+ {
3166
+ label: labelForCompareTarget(options.compareTo ?? 'career'),
3167
+ values: [
3168
+ numberOrNull(baseline.avg_release_speed) ?? 0,
3169
+ (numberOrNull(baseline.avg_release_spin_rate) ?? 0) / 100,
3170
+ (numberOrNull(baseline.avg_release_extension) ?? 0) * 10,
3171
+ (numberOrNull(baseline.avg_pfx_x) ?? 0) * 10,
3172
+ (numberOrNull(baseline.avg_pfx_z) ?? 0) * 10,
3173
+ ],
3174
+ },
3175
+ ],
3176
+ read: `This view compares the current-season pitch shape and release baseline against ${labelForCompareTarget(options.compareTo ?? 'career').toLowerCase()}.`,
3177
+ };
3178
+ }
3179
+
3180
+ const tableName = window === 'career' ? 'public.shared_pitcher_baseline_event_rows' : 'public.live_pitch_mix_2026';
3181
+ const dateExpr = window === 'career' ? 'source_season::text' : 'game_date::date';
3182
+ const splitExpr = window === 'career' ? 'COALESCE(batter_stand, stand)' : 'stand';
3183
+ const primaryExpr = view === 'velo'
3184
+ ? 'AVG(release_speed)'
3185
+ : view === 'spin'
3186
+ ? 'AVG(release_spin_rate)'
3187
+ : 'AVG(release_extension)';
3188
+ const overlayAExpr = view === 'velo'
3189
+ ? 'AVG(effective_speed)'
3190
+ : view === 'spin'
3191
+ ? 'AVG(spin_efficiency_proxy)'
3192
+ : 'AVG(release_pos_x)';
3193
+ const overlayBExpr = view === 'velo'
3194
+ ? 'AVG(pfx_z)'
3195
+ : view === 'spin'
3196
+ ? 'AVG(spin_axis)'
3197
+ : 'AVG(release_pos_z)';
3198
+ const rows = await this.queryCockroachRows(
3199
+ `
3200
+ WITH grouped AS (
3201
+ SELECT
3202
+ ${dateExpr} AS point_key,
3203
+ ${primaryExpr} AS primary_value,
3204
+ ${overlayAExpr} AS overlay_a,
3205
+ ${overlayBExpr} AS overlay_b
3206
+ FROM ${tableName}
3207
+ WHERE LOWER(player_name) = LOWER($1)
3208
+ ${buildPitchTypeSqlFilter('pitch_type', 'pitch_name', 2)}
3209
+ ${buildSplitSqlFilter(splitExpr, 3)}
3210
+ GROUP BY point_key
3211
+ )
3212
+ SELECT point_key, primary_value, overlay_a, overlay_b
3213
+ FROM grouped
3214
+ ORDER BY point_key DESC
3215
+ LIMIT $4
3216
+ `,
3217
+ [context.pitcherName, pitchType, split, getPitcherWindowPointLimit(window)]
3218
+ );
3219
+ if (!rows.length) {
3220
+ throw new Error(`No ${view} trend points were available for ${context.pitcherName}.`);
3221
+ }
3222
+ const ordered = [...rows].reverse();
3223
+ return {
3224
+ ...context,
3225
+ chartType: 'line',
3226
+ view,
3227
+ window,
3228
+ pitchType,
3229
+ split,
3230
+ title: `${pitcherTrendTitle(view)} - ${context.pitcherName}`,
3231
+ subtitle: `${context.team ?? 'N/A'} | ${windowLabel(window)}${pitchType ? ` | ${pitchType}` : ''}${split !== 'overall' ? ` | ${splitLabel(split)}` : ''}`,
3232
+ points: ordered.map((row) => ({ label: String(row.point_key), value: numberOrNull(row.primary_value) ?? 0 })),
3233
+ primaryLabel: pitcherTrendPrimaryLabel(view),
3234
+ overlays: [
3235
+ { label: pitcherTrendOverlayLabel(view, 0), values: ordered.map((row) => formatTrendOverlayValue(view, 0, row.overlay_a)), color: '#93c5fd' },
3236
+ { label: pitcherTrendOverlayLabel(view, 1), values: ordered.map((row) => formatTrendOverlayValue(view, 1, row.overlay_b)), color: '#c084fc' },
3237
+ ],
3238
+ read: pitcherTrendRead(view, ordered, pitchType),
3239
+ };
3240
+ });
3241
+ }
3242
+
3243
+ async getPitcherArsenalChartData(options = {}) {
3244
+ return this.getCachedChartValue(this.buildChartCacheKey('pitcher-arsenal-suite', options), async () => {
3245
+ const context = await this.resolvePitcherSuiteContext(options.pitcher, options);
3246
+ const view = String(options.view ?? 'shape');
3247
+ const window = String(options.window ?? 'last_5');
3248
+ const split = String(options.split ?? 'overall');
3249
+ const pitchType = options.pitchType ?? options.pitch_type ?? null;
3250
+
3251
+ if (['shape', 'movement'].includes(view)) {
3252
+ const rows = await this.queryCockroachRows(
3253
+ `
3254
+ SELECT pitch_type, usage_rate, avg_velocity, avg_spin_rate, avg_extension, avg_pfx_x, avg_pfx_z, avg_spin_axis
3255
+ FROM public.pitcher_arsenal_profiles
3256
+ WHERE pitcher::text = $1
3257
+ AND ($2::text IS NULL OR UPPER(pitch_type) = UPPER($2))
3258
+ ORDER BY usage_rate DESC NULLS LAST, sample_size DESC NULLS LAST
3259
+ LIMIT 6
3260
+ `,
3261
+ [context.pitcherId, pitchType]
3262
+ );
3263
+ if (!rows.length) {
3264
+ throw new Error(`No arsenal shape profile was available for ${context.pitcherName}.`);
3265
+ }
3266
+ return {
3267
+ ...context,
3268
+ view,
3269
+ window,
3270
+ pitchType,
3271
+ title: `${pitcherArsenalTitle(view)} - ${context.pitcherName}`,
3272
+ subtitle: `${context.team ?? 'N/A'} | ${windowLabel(window)}`,
3273
+ columns: view === 'shape'
3274
+ ? [
3275
+ { key: 'usage_rate', label: 'Usage', type: 'pct' },
3276
+ { key: 'avg_velocity', label: 'Velo' },
3277
+ { key: 'avg_spin_rate', label: 'Spin' },
3278
+ { key: 'avg_pfx_z', label: 'Move Z' },
3279
+ { key: 'avg_pfx_x', label: 'Move X' },
3280
+ ]
3281
+ : [
3282
+ { key: 'usage_rate', label: 'Usage', type: 'pct' },
3283
+ { key: 'avg_extension', label: 'Ext' },
3284
+ { key: 'avg_pfx_x', label: 'Move X' },
3285
+ { key: 'avg_pfx_z', label: 'Move Z' },
3286
+ { key: 'avg_spin_axis', label: 'Axis' },
3287
+ ],
3288
+ rows: rows.map((row) => ({ label: row.pitch_type, ...row })),
3289
+ read: `The arsenal card highlights ${rows[0]?.pitch_type ?? 'the primary pitch'} as the backbone of ${context.pitcherName}'s current shape profile.`,
3290
+ };
3291
+ }
3292
+
3293
+ const rows = await this.queryCockroachRows(
3294
+ `
3295
+ WITH latest_date AS (
3296
+ SELECT MAX(slate_date)::date AS slate_date
3297
+ FROM public.pitcher_arsenal_snapshots
3298
+ WHERE LOWER(pitcher_name) = LOWER($1)
3299
+ )
3300
+ SELECT slate_date::date AS slate_date, pitch_name, batter_side_key, usage_pct, swstr_pct, hard_hit_pct, avg_release_speed, avg_spin_rate, xwoba_con
3301
+ FROM public.pitcher_arsenal_snapshots
3302
+ WHERE LOWER(pitcher_name) = LOWER($1)
3303
+ AND slate_date::date = (SELECT slate_date FROM latest_date)
3304
+ AND ($2::text IS NULL OR LOWER(batter_side_key) = LOWER($2))
3305
+ AND ($3::text IS NULL OR UPPER(pitch_name) = UPPER($3))
3306
+ ORDER BY usage_pct DESC NULLS LAST
3307
+ `,
3308
+ [context.pitcherName, split === 'overall' ? null : split, pitchType]
3309
+ );
3310
+ if (!rows.length) {
3311
+ throw new Error(`No arsenal snapshot rows were available for ${context.pitcherName}.`);
3312
+ }
3313
+
3314
+ if (view === 'evolution') {
3315
+ const evolutionRows = await this.queryCockroachRows(
3316
+ `
3317
+ SELECT
3318
+ pitch_name,
3319
+ MIN(slate_date::date) AS first_date,
3320
+ MAX(slate_date::date) AS last_date,
3321
+ AVG(CASE WHEN slate_date::date = (SELECT MIN(slate_date::date) FROM public.pitcher_arsenal_snapshots WHERE LOWER(pitcher_name) = LOWER($1)) THEN usage_pct END) AS early_usage_pct,
3322
+ AVG(CASE WHEN slate_date::date = (SELECT MAX(slate_date::date) FROM public.pitcher_arsenal_snapshots WHERE LOWER(pitcher_name) = LOWER($1)) THEN usage_pct END) AS latest_usage_pct,
3323
+ AVG(CASE WHEN slate_date::date = (SELECT MAX(slate_date::date) FROM public.pitcher_arsenal_snapshots WHERE LOWER(pitcher_name) = LOWER($1)) THEN avg_spin_rate END) AS latest_spin_rate
3324
+ FROM public.pitcher_arsenal_snapshots
3325
+ WHERE LOWER(pitcher_name) = LOWER($1)
3326
+ GROUP BY pitch_name
3327
+ ORDER BY latest_usage_pct DESC NULLS LAST
3328
+ LIMIT 6
3329
+ `,
3330
+ [context.pitcherName]
3331
+ );
3332
+ return {
3333
+ ...context,
3334
+ view,
3335
+ window,
3336
+ title: `${pitcherArsenalTitle(view)} - ${context.pitcherName}`,
3337
+ subtitle: `${context.team ?? 'N/A'} | season evolution`,
3338
+ columns: [
3339
+ { key: 'early_usage_pct', label: 'Early', type: 'pct' },
3340
+ { key: 'latest_usage_pct', label: 'Latest', type: 'pct' },
3341
+ { key: 'usage_delta', label: 'Delta', type: 'pct_signed' },
3342
+ { key: 'latest_spin_rate', label: 'Spin' },
3343
+ ],
3344
+ rows: evolutionRows.map((row) => ({
3345
+ label: row.pitch_name,
3346
+ ...row,
3347
+ usage_delta: (numberOrNull(row.latest_usage_pct) ?? 0) - (numberOrNull(row.early_usage_pct) ?? 0),
3348
+ })),
3349
+ read: `${context.pitcherName}'s pitch mix evolution shows where usage has moved most since the start of 2026.`,
3350
+ };
3351
+ }
3352
+
3353
+ if (view === 'platoon') {
3354
+ const platoonRows = await this.queryCockroachRows(
3355
+ `
3356
+ SELECT
3357
+ pitch_name,
3358
+ AVG(CASE WHEN LOWER(batter_side_key) = 'vs_lhb' THEN usage_pct END) AS usage_vs_lhb,
3359
+ AVG(CASE WHEN LOWER(batter_side_key) = 'vs_rhb' THEN usage_pct END) AS usage_vs_rhb,
3360
+ AVG(CASE WHEN LOWER(batter_side_key) = 'vs_lhb' THEN swstr_pct END) AS swstr_vs_lhb,
3361
+ AVG(CASE WHEN LOWER(batter_side_key) = 'vs_rhb' THEN swstr_pct END) AS swstr_vs_rhb
3362
+ FROM public.pitcher_arsenal_snapshots
3363
+ WHERE LOWER(pitcher_name) = LOWER($1)
3364
+ GROUP BY pitch_name
3365
+ ORDER BY GREATEST(COALESCE(AVG(usage_pct), 0), COALESCE(AVG(swstr_pct), 0)) DESC
3366
+ LIMIT 6
3367
+ `,
3368
+ [context.pitcherName]
3369
+ );
3370
+ return {
3371
+ ...context,
3372
+ view,
3373
+ window,
3374
+ title: `${pitcherArsenalTitle(view)} - ${context.pitcherName}`,
3375
+ subtitle: `${context.team ?? 'N/A'} | platoon split`,
3376
+ columns: [
3377
+ { key: 'usage_vs_lhb', label: 'Vs LHB', type: 'pct' },
3378
+ { key: 'usage_vs_rhb', label: 'Vs RHB', type: 'pct' },
3379
+ { key: 'swstr_vs_lhb', label: 'Whiff L', type: 'pct' },
3380
+ { key: 'swstr_vs_rhb', label: 'Whiff R', type: 'pct' },
3381
+ ],
3382
+ rows: platoonRows.map((row) => ({ label: row.pitch_name, ...row })),
3383
+ read: `The platoon view shows how ${context.pitcherName} changes usage and whiff shape by hitter handedness.`,
3384
+ };
3385
+ }
3386
+
3387
+ return {
3388
+ ...context,
3389
+ view,
3390
+ window,
3391
+ split,
3392
+ pitchType,
3393
+ title: `${pitcherArsenalTitle(view)} - ${context.pitcherName}`,
3394
+ subtitle: `${context.team ?? 'N/A'} | ${splitLabel(split)}${pitchType ? ` | ${pitchType}` : ''}`,
3395
+ columns: view === 'usage'
3396
+ ? [
3397
+ { key: 'usage_pct', label: 'Usage', type: 'pct' },
3398
+ { key: 'avg_release_speed', label: 'Velo' },
3399
+ { key: 'swstr_pct', label: 'Whiff', type: 'pct' },
3400
+ { key: 'hard_hit_pct', label: 'HH', type: 'pct' },
3401
+ ]
3402
+ : [
3403
+ { key: 'usage_pct', label: 'Usage', type: 'pct' },
3404
+ { key: 'swstr_pct', label: 'Whiff', type: 'pct' },
3405
+ { key: 'hard_hit_pct', label: 'HH', type: 'pct' },
3406
+ { key: 'xwoba_con', label: 'xwOBAcon', type: 'decimal' },
3407
+ ],
3408
+ rows: rows.slice(0, 6).map((row) => ({ label: row.pitch_name, ...row })),
3409
+ read: `${context.pitcherName}'s ${view === 'usage' ? 'usage tree' : 'pitch outcomes'} are led by ${rows[0]?.pitch_name ?? 'the primary offering'}.`,
3410
+ };
3411
+ });
3412
+ }
3413
+
3414
+ async getPitcherLocationChartData(options = {}) {
3415
+ return this.getCachedChartValue(this.buildChartCacheKey('pitcher-location-suite', options), async () => {
3416
+ const context = await this.resolvePitcherSuiteContext(options.pitcher, options);
3417
+ const view = String(options.view ?? 'heatmap');
3418
+ const split = String(options.split ?? 'overall');
3419
+ const pitchType = options.pitchType ?? options.pitch_type ?? null;
3420
+ const countBucket = String(options.countBucket ?? options.count_bucket ?? 'all');
3421
+ const rows = await this.queryCockroachRows(
3422
+ `
3423
+ SELECT plate_x, plate_z, zone, pitch_type, pitch_name, stand, balls, strikes, description, events, estimated_woba_using_speedangle
3424
+ FROM public.live_pitch_mix_2026
3425
+ WHERE LOWER(player_name) = LOWER($1)
3426
+ ${buildPitchTypeSqlFilter('pitch_type', 'pitch_name', 2)}
3427
+ ${buildSplitSqlFilter('stand', 3)}
3428
+ ORDER BY game_date::date DESC, pitch_number DESC
3429
+ LIMIT 4000
3430
+ `,
3431
+ [context.pitcherName, pitchType, split]
3432
+ );
3433
+ if (!rows.length) {
3434
+ throw new Error(`No location rows were available for ${context.pitcherName}.`);
3435
+ }
3436
+ const filtered = rows.filter((row) => matchesCountBucket(row, countBucket));
3437
+ if (!filtered.length) {
3438
+ throw new Error(`No location rows matched the ${countBucketLabel(countBucket).toLowerCase()} filter for ${context.pitcherName}.`);
3439
+ }
3440
+ const cells = buildPitcherLocationCells(filtered, view);
3441
+ const bestCell = [...cells].sort((left, right) => compareNullableDescending(left.overlayValue, right.overlayValue))[0];
3442
+ return {
3443
+ ...context,
3444
+ view,
3445
+ split,
3446
+ pitchType,
3447
+ countBucket,
3448
+ title: `${pitcherLocationTitle(view)} - ${context.pitcherName}`,
3449
+ subtitle: `${context.team ?? 'N/A'} | ${splitLabel(split)} | ${countBucketLabel(countBucket)}${pitchType ? ` | ${pitchType}` : ''}`,
3450
+ cells,
3451
+ bestOverlay: bestCell ? `Zone ${bestCell.zone} at ${(numberOrNull(bestCell.overlayValue) ?? 0).toFixed(0)} ${locationMetricSuffix(view)}` : 'No clear hot zone',
3452
+ shapeSummary: buildPitcherLocationSummary(cells, view),
3453
+ read: pitcherLocationRead(view),
3454
+ };
3455
+ });
3456
+ }
3457
+
3458
+ async getPitcherApproachChartData(options = {}) {
3459
+ return this.getCachedChartValue(this.buildChartCacheKey('pitcher-approach-suite', options), async () => {
3460
+ const context = await this.resolvePitcherSuiteContext(options.pitcher, options);
3461
+ const view = String(options.view ?? 'count_usage');
3462
+ const split = String(options.split ?? 'overall');
3463
+ const pitchType = options.pitchType ?? options.pitch_type ?? null;
3464
+ const rows = await this.queryCockroachRows(
3465
+ `
3466
+ SELECT pitch_name, balls, strikes, description, stand
3467
+ FROM public.live_pitch_mix_2026
3468
+ WHERE LOWER(player_name) = LOWER($1)
3469
+ ${buildPitchTypeSqlFilter('NULL', 'pitch_name', 2)}
3470
+ ${buildSplitSqlFilter('stand', 3)}
3471
+ ORDER BY game_date::date DESC, pitch_number DESC
3472
+ LIMIT 4000
3473
+ `,
3474
+ [context.pitcherName, pitchType, split]
3475
+ );
3476
+ if (!rows.length) {
3477
+ throw new Error(`No approach rows were available for ${context.pitcherName}.`);
3478
+ }
3479
+
3480
+ const grouped = buildPitcherApproachDatasets(rows, view);
3481
+ if (!grouped.datasets.length) {
3482
+ throw new Error(`No ${view.replaceAll('_', ' ')} rows were available for ${context.pitcherName}.`);
3483
+ }
3484
+ return {
3485
+ ...context,
3486
+ view,
3487
+ split,
3488
+ pitchType,
3489
+ title: `${pitcherApproachTitle(view)} - ${context.pitcherName}`,
3490
+ subtitle: `${context.team ?? 'N/A'} | ${splitLabel(split)}${pitchType ? ` | ${pitchType}` : ''}`,
3491
+ labels: grouped.labels,
3492
+ datasets: grouped.datasets,
3493
+ read: grouped.read,
3494
+ };
3495
+ });
3496
+ }
3497
+
3498
+ async getPitcherCompareChartData(options = {}) {
3499
+ return this.getCachedChartValue(this.buildChartCacheKey('pitcher-compare-suite', options), async () => {
3500
+ const context = await this.resolvePitcherSuiteContext(options.pitcher, options);
3501
+ const view = String(options.view ?? 'current_vs_career');
3502
+ const window = String(options.window ?? 'last_5');
3503
+
3504
+ if (view === 'risk_reward') {
3505
+ const rows = await this.queryCockroachRows(
3506
+ `
3507
+ SELECT slate_date::date AS point_date, strikeout_score, hard_hit_pct, xwoba, pitcher_score
3508
+ FROM public.pitcher_model_snapshots
3509
+ WHERE LOWER(pitcher_name) = LOWER($1)
3510
+ ORDER BY slate_date::date DESC
3511
+ LIMIT $2
3512
+ `,
3513
+ [context.pitcherName, getPitcherWindowPointLimit(window)]
3514
+ );
3515
+ if (!rows.length) {
3516
+ throw new Error(`No risk/reward snapshot points were available for ${context.pitcherName}.`);
3517
+ }
3518
+ return {
3519
+ ...context,
3520
+ chartType: 'scatter',
3521
+ view,
3522
+ window,
3523
+ title: `Risk Reward - ${context.pitcherName}`,
3524
+ subtitle: `${context.team ?? 'N/A'} | ${windowLabel(window)}`,
3525
+ points: rows.map((row) => ({
3526
+ x: numberOrNull(row.hard_hit_pct) ?? 0,
3527
+ y: numberOrNull(row.strikeout_score) ?? 0,
3528
+ label: formatDateLabel(row.point_date),
3529
+ highlight: false,
3530
+ })),
3531
+ read: `Upper-left points pair stronger strikeout upside with lower hard-hit risk across recent snapshot dates.`,
3532
+ };
3533
+ }
3534
+
3535
+ const currentRows = await this.queryCockroachRows(
3536
+ `
3537
+ SELECT AVG(release_speed) AS avg_release_speed, AVG(release_spin_rate) AS avg_release_spin_rate, AVG(release_extension) AS avg_release_extension, AVG(pfx_x) AS avg_pfx_x, AVG(pfx_z) AS avg_pfx_z
3538
+ FROM public.live_pitch_mix_2026
3539
+ WHERE LOWER(player_name) = LOWER($1)
3540
+ `,
3541
+ [context.pitcherName]
3542
+ );
3543
+ const baselineRows = await this.queryCockroachRows(
3544
+ `
3545
+ SELECT AVG(release_speed) AS avg_release_speed, AVG(release_spin_rate) AS avg_release_spin_rate, AVG(release_extension) AS avg_release_extension, AVG(pfx_x) AS avg_pfx_x, AVG(pfx_z) AS avg_pfx_z
3546
+ FROM public.shared_pitcher_baseline_event_rows
3547
+ WHERE LOWER(player_name) = LOWER($1)
3548
+ `,
3549
+ [context.pitcherName]
3550
+ );
3551
+ const current = currentRows[0] ?? {};
3552
+ const baseline = baselineRows[0] ?? {};
3553
+
3554
+ if (view === 'year_over_year') {
3555
+ const seasonRows = await this.queryCockroachRows(
3556
+ `
3557
+ SELECT source_season::text AS season_label, AVG(release_speed) AS avg_release_speed, AVG(release_spin_rate) AS avg_release_spin_rate, AVG(release_extension) AS avg_release_extension
3558
+ FROM public.shared_pitcher_baseline_event_rows
3559
+ WHERE LOWER(player_name) = LOWER($1)
3560
+ GROUP BY source_season
3561
+ ORDER BY source_season DESC
3562
+ LIMIT 6
3563
+ `,
3564
+ [context.pitcherName]
3565
+ );
3566
+ return {
3567
+ ...context,
3568
+ chartType: 'compare',
3569
+ view,
3570
+ window,
3571
+ title: `Year Over Year - ${context.pitcherName}`,
3572
+ subtitle: `${context.team ?? 'N/A'} | arsenal evolution`,
3573
+ compareLabel: 'Season',
3574
+ baselineLabel: 'Metrics',
3575
+ rows: seasonRows.map((row) => ({
3576
+ label: row.season_label,
3577
+ currentValue: formatMetricValue(row.avg_release_speed),
3578
+ baselineValue: `${formatMetricValue(row.avg_release_spin_rate)} spin | ${formatMetricValue(row.avg_release_extension)} ext`,
3579
+ })),
3580
+ read: `${context.pitcherName}'s year-over-year card shows how velocity, spin, and extension have changed season to season.`,
3581
+ };
3582
+ }
3583
+
3584
+ const baselineLabel = view === 'recent_vs_baseline' ? 'Baseline' : 'Career';
3585
+ return {
3586
+ ...context,
3587
+ chartType: 'compare',
3588
+ view,
3589
+ window,
3590
+ title: `${pitcherCompareTitle(view)} - ${context.pitcherName}`,
3591
+ subtitle: `${context.team ?? 'N/A'} | ${windowLabel(window)}`,
3592
+ compareLabel: 'Current',
3593
+ baselineLabel,
3594
+ rows: [
3595
+ { label: 'Velocity', currentValue: formatMetricValue(current.avg_release_speed), baselineValue: formatMetricValue(baseline.avg_release_speed) },
3596
+ { label: 'Spin', currentValue: formatMetricValue(current.avg_release_spin_rate), baselineValue: formatMetricValue(baseline.avg_release_spin_rate) },
3597
+ { label: 'Extension', currentValue: formatMetricValue(current.avg_release_extension), baselineValue: formatMetricValue(baseline.avg_release_extension) },
3598
+ { label: 'Move X', currentValue: formatMetricValue(current.avg_pfx_x), baselineValue: formatMetricValue(baseline.avg_pfx_x) },
3599
+ { label: 'Move Z', currentValue: formatMetricValue(current.avg_pfx_z), baselineValue: formatMetricValue(baseline.avg_pfx_z) },
3600
+ ],
3601
+ read: `This compare card shows where ${context.pitcherName}'s current pitch traits sit versus ${baselineLabel.toLowerCase()}.`,
3602
+ };
3603
+ });
3604
+ }
3605
+
3606
  async buildHostedTrendPoints({ date, window, kind, playerName }) {
3607
  const requestedWindow = Math.max(3, Math.min(14, Number(window ?? 7)));
3608
  if (!this.hosted?.isConfigured?.()) {
test/baseball-charts.test.js CHANGED
@@ -9,6 +9,11 @@ import {
9
  createKMatchupCardPng,
10
  createKProfileRadarPng,
11
  createKTrendChartPng,
 
 
 
 
 
12
  } from '../src/baseball-charts.js';
13
 
14
  test('baseball chart renderers return png buffers', async () => {
@@ -48,6 +53,22 @@ test('baseball chart renderers return png buffers', async () => {
48
  { label: 'Slider', values: [20, 28, 46] },
49
  ],
50
  }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  ]);
52
 
53
  for (const buffer of buffers) {
@@ -58,7 +79,7 @@ test('baseball chart renderers return png buffers', async () => {
58
  });
59
 
60
  test('custom baseball cards render png buffers', async () => {
61
- const [zoneBuffer, matchupBuffer] = await Promise.all([
62
  createHrZoneOverlayCardPng({
63
  playerName: 'Yordan Alvarez',
64
  team: 'HOU',
@@ -86,9 +107,48 @@ test('custom baseball cards render png buffers', async () => {
86
  ],
87
  read: 'Whiff traits remain strong against a mid-pack strikeout lineup.',
88
  }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  ]);
90
 
91
- for (const buffer of [zoneBuffer, matchupBuffer]) {
92
  assert.ok(Buffer.isBuffer(buffer));
93
  assert.ok(buffer.length > 1000);
94
  assert.equal(buffer.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
 
9
  createKMatchupCardPng,
10
  createKProfileRadarPng,
11
  createKTrendChartPng,
12
+ createPitcherApproachChartPng,
13
+ createPitcherArsenalChartPng,
14
+ createPitcherCompareChartPng,
15
+ createPitcherLocationChartPng,
16
+ createPitcherTrendChartPng,
17
  } from '../src/baseball-charts.js';
18
 
19
  test('baseball chart renderers return png buffers', async () => {
 
53
  { label: 'Slider', values: [20, 28, 46] },
54
  ],
55
  }),
56
+ createPitcherTrendChartPng({
57
+ title: 'Velocity Trend - Paul Skenes',
58
+ points: [
59
+ { label: '03-29', value: 98.2 },
60
+ { label: '04-01', value: 98.5 },
61
+ { label: '04-04', value: 98.8 },
62
+ ],
63
+ overlays: [{ label: 'Effective Speed', values: [96.4, 96.6, 96.9] }],
64
+ }),
65
+ createPitcherApproachChartPng({
66
+ labels: ['first pitch', 'ahead', 'behind'],
67
+ datasets: [
68
+ { label: 'Four-Seam', values: [52, 34, 19] },
69
+ { label: 'Slider', values: [18, 29, 36] },
70
+ ],
71
+ }),
72
  ]);
73
 
74
  for (const buffer of buffers) {
 
79
  });
80
 
81
  test('custom baseball cards render png buffers', async () => {
82
+ const [zoneBuffer, matchupBuffer, arsenalBuffer, locationBuffer, compareBuffer] = await Promise.all([
83
  createHrZoneOverlayCardPng({
84
  playerName: 'Yordan Alvarez',
85
  team: 'HOU',
 
107
  ],
108
  read: 'Whiff traits remain strong against a mid-pack strikeout lineup.',
109
  }),
110
+ createPitcherArsenalChartPng({
111
+ title: 'Pitch Shape Card - Paul Skenes',
112
+ pitcherName: 'Paul Skenes',
113
+ teamLine: 'PIT vs CHC',
114
+ columns: [
115
+ { key: 'usage_rate', label: 'Usage', type: 'pct' },
116
+ { key: 'avg_velocity', label: 'Velo' },
117
+ { key: 'avg_spin_rate', label: 'Spin' },
118
+ ],
119
+ rows: [
120
+ { label: 'FF', usage_rate: 0.42, avg_velocity: 99.1, avg_spin_rate: 2412 },
121
+ { label: 'SL', usage_rate: 0.31, avg_velocity: 87.4, avg_spin_rate: 2699 },
122
+ ],
123
+ read: 'The fastball-slider core drives the shape profile.',
124
+ }),
125
+ createPitcherLocationChartPng({
126
+ title: 'Location Heatmap - Paul Skenes',
127
+ pitcherName: 'Paul Skenes',
128
+ teamLine: 'PIT vs CHC',
129
+ cells: [
130
+ { zone: 1, batterValue: 0.14, pitcherValue: 0.08, overlayValue: 12 },
131
+ { zone: 5, batterValue: 0.28, pitcherValue: 0.22, overlayValue: 25 },
132
+ ],
133
+ bestOverlay: 'Zone 5 at 25 %',
134
+ shapeSummary: 'Center pocket usage dominates.',
135
+ read: 'Hotter cells show the main attack lanes.',
136
+ }),
137
+ createPitcherCompareChartPng({
138
+ title: 'Current vs Career - Paul Skenes',
139
+ pitcherName: 'Paul Skenes',
140
+ teamLine: 'PIT vs CHC',
141
+ compareLabel: 'Current',
142
+ baselineLabel: 'Career',
143
+ rows: [
144
+ { label: 'Velocity', currentValue: 98.6, baselineValue: 97.9 },
145
+ { label: 'Spin', currentValue: 2412, baselineValue: 2368 },
146
+ ],
147
+ read: 'Current stuff is slightly firmer than baseline.',
148
+ }),
149
  ]);
150
 
151
+ for (const buffer of [zoneBuffer, matchupBuffer, arsenalBuffer, locationBuffer, compareBuffer]) {
152
  assert.ok(Buffer.isBuffer(buffer));
153
  assert.ok(buffer.length > 1000);
154
  assert.equal(buffer.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
test/matchups.test.js CHANGED
@@ -631,6 +631,155 @@ test('commands include new matchup commands', () => {
631
  assert.ok(names.includes('kprofile'));
632
  assert.ok(names.includes('kmatchup'));
633
  assert.ok(names.includes('kcount'));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
634
  });
635
 
636
  test('matchup embeds render core matchup data clearly', () => {
 
631
  assert.ok(names.includes('kprofile'));
632
  assert.ok(names.includes('kmatchup'));
633
  assert.ok(names.includes('kcount'));
634
+ assert.ok(names.includes('pitchertrend'));
635
+ assert.ok(names.includes('pitcherarsenal'));
636
+ assert.ok(names.includes('pitcherlocation'));
637
+ assert.ok(names.includes('pitcherapproach'));
638
+ assert.ok(names.includes('pitchercompare'));
639
+ });
640
+
641
+ test('pitcher suite chart methods build cockroach-backed payloads from query access', async () => {
642
+ const service = new MatchupService(
643
+ { databaseUrl: 'postgresql://test', hosted: { baseUrl: 'https://example.test' } },
644
+ {
645
+ hosted: {
646
+ isConfigured: () => false,
647
+ async getHealth() {
648
+ return { configured: false, latestDate: null };
649
+ },
650
+ },
651
+ fallback: {
652
+ async query(text) {
653
+ if (text.includes('WITH candidates')) {
654
+ return {
655
+ rows: [{
656
+ pitcher_name: 'Paul Skenes',
657
+ pitcher_id: '301',
658
+ team: 'PIT',
659
+ opponent_team: 'CHC',
660
+ pitcher_hand: 'R',
661
+ latest_date: '2026-04-07',
662
+ }],
663
+ };
664
+ }
665
+ if (text.includes('FROM public.pitcher_model_snapshots') && text.includes('pitcher_score')) {
666
+ return {
667
+ rows: [{
668
+ slate_date: '2026-04-07',
669
+ team: 'PIT',
670
+ opponent_team: 'CHC',
671
+ pitcher_name: 'Paul Skenes',
672
+ p_throws: 'R',
673
+ pitcher_score: 92.1,
674
+ strikeout_score: 88.7,
675
+ pitcher_matchup_adjustment: 4.2,
676
+ strikeout_matchup_adjustment: 6.1,
677
+ opponent_lineup_quality: 87.2,
678
+ opponent_contact_threat: 82.4,
679
+ opponent_whiff_tendency: 27.8,
680
+ xwoba: 0.262,
681
+ csw_pct: 0.311,
682
+ swstr_pct: 0.178,
683
+ putaway_pct: 0.274,
684
+ ball_pct: 0.319,
685
+ siera: 2.94,
686
+ gb_pct: 0.44,
687
+ gb_fb_ratio: 1.23,
688
+ barrel_bip_pct: 0.051,
689
+ hard_hit_pct: 0.311,
690
+ }],
691
+ };
692
+ }
693
+ if (text.includes('FROM public.live_pitch_mix_2026') && text.includes('GROUP BY point_key')) {
694
+ return {
695
+ rows: [
696
+ { point_key: '2026-04-01', primary_value: 98.2, overlay_a: 96.4, overlay_b: 15.1 },
697
+ { point_key: '2026-04-04', primary_value: 98.8, overlay_a: 96.9, overlay_b: 15.5 },
698
+ ],
699
+ };
700
+ }
701
+ if (text.includes('FROM public.pitcher_arsenal_profiles')) {
702
+ return {
703
+ rows: [
704
+ { pitch_type: 'FF', usage_rate: 0.42, avg_velocity: 99.1, avg_spin_rate: 2412, avg_extension: 6.7, avg_pfx_x: -0.4, avg_pfx_z: 1.8, avg_spin_axis: 212 },
705
+ { pitch_type: 'SL', usage_rate: 0.31, avg_velocity: 87.4, avg_spin_rate: 2699, avg_extension: 6.3, avg_pfx_x: 0.9, avg_pfx_z: -0.2, avg_spin_axis: 101 },
706
+ ],
707
+ };
708
+ }
709
+ if (text.includes('SELECT plate_x, plate_z, zone')) {
710
+ return {
711
+ rows: [
712
+ { plate_x: -0.3, plate_z: 3.5, zone: 1, pitch_name: 'FF', pitch_type: 'FF', stand: 'L', balls: 0, strikes: 0, description: 'called_strike', events: null, estimated_woba_using_speedangle: null },
713
+ { plate_x: 0.0, plate_z: 2.9, zone: 5, pitch_name: 'SL', pitch_type: 'SL', stand: 'R', balls: 1, strikes: 2, description: 'swinging_strike', events: null, estimated_woba_using_speedangle: 0.18 },
714
+ { plate_x: 0.2, plate_z: 2.6, zone: 6, pitch_name: 'FF', pitch_type: 'FF', stand: 'R', balls: 1, strikes: 2, description: 'foul_tip', events: null, estimated_woba_using_speedangle: 0.11 },
715
+ ],
716
+ };
717
+ }
718
+ if (text.includes('SELECT pitch_name, balls, strikes, description, stand')) {
719
+ return {
720
+ rows: [
721
+ { pitch_name: 'FF', balls: 0, strikes: 0, description: 'called_strike', stand: 'L' },
722
+ { pitch_name: 'FF', balls: 1, strikes: 2, description: 'swinging_strike', stand: 'R' },
723
+ { pitch_name: 'SL', balls: 1, strikes: 2, description: 'swinging_strike', stand: 'R' },
724
+ { pitch_name: 'SL', balls: 2, strikes: 1, description: 'ball', stand: 'R' },
725
+ ],
726
+ };
727
+ }
728
+ if (text.includes('SELECT AVG(release_speed) AS avg_release_speed') && text.includes('public.shared_pitcher_baseline_event_rows')) {
729
+ return {
730
+ rows: [{
731
+ avg_release_speed: 97.9,
732
+ avg_release_spin_rate: 2368,
733
+ avg_release_extension: 6.5,
734
+ avg_pfx_x: -0.3,
735
+ avg_pfx_z: 1.6,
736
+ }],
737
+ };
738
+ }
739
+ if (text.includes('SELECT AVG(release_speed) AS avg_release_speed') && text.includes('public.live_pitch_mix_2026')) {
740
+ return {
741
+ rows: [{
742
+ avg_release_speed: 98.6,
743
+ avg_release_spin_rate: 2412,
744
+ avg_release_extension: 6.7,
745
+ avg_pfx_x: -0.4,
746
+ avg_pfx_z: 1.8,
747
+ }],
748
+ };
749
+ }
750
+ if (text.includes('SELECT slate_date::date AS point_date, strikeout_score, hard_hit_pct')) {
751
+ return {
752
+ rows: [
753
+ { point_date: '2026-04-01', strikeout_score: 84.2, hard_hit_pct: 28.1, xwoba: 0.252, pitcher_score: 88.4 },
754
+ { point_date: '2026-04-04', strikeout_score: 88.7, hard_hit_pct: 24.9, xwoba: 0.241, pitcher_score: 92.1 },
755
+ ],
756
+ };
757
+ }
758
+ return { rows: [] };
759
+ },
760
+ async getPlayerContext() {
761
+ throw new Error('not used');
762
+ },
763
+ async close() {},
764
+ },
765
+ logger: { debug() {}, warn() {} },
766
+ }
767
+ );
768
+
769
+ const [trend, arsenal, location, approach, compare] = await Promise.all([
770
+ service.getPitcherTrendChartData({ pitcher: 'paul skenes', view: 'velo', window: 'last_5' }),
771
+ service.getPitcherArsenalChartData({ pitcher: 'paul skenes', view: 'shape' }),
772
+ service.getPitcherLocationChartData({ pitcher: 'paul skenes', view: 'miss' }),
773
+ service.getPitcherApproachChartData({ pitcher: 'paul skenes', view: 'count_usage' }),
774
+ service.getPitcherCompareChartData({ pitcher: 'paul skenes', view: 'risk_reward', window: 'last_5' }),
775
+ ]);
776
+
777
+ assert.equal(trend.pitcherName, 'Paul Skenes');
778
+ assert.equal(trend.points.length, 2);
779
+ assert.equal(arsenal.rows[0].label, 'FF');
780
+ assert.equal(location.cells.length, 9);
781
+ assert.ok(approach.datasets.length > 0);
782
+ assert.equal(compare.chartType, 'scatter');
783
  });
784
 
785
  test('matchup embeds render core matchup data clearly', () => {