bbdata-cli 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/bin/bbdata.js +1044 -36
  2. package/dist/bin/bbdata.js.map +1 -1
  3. package/dist/src/index.d.ts +55 -2
  4. package/dist/src/index.js +978 -33
  5. package/dist/src/index.js.map +1 -1
  6. package/dist/templates/queries/hitter-batted-ball.ts +66 -0
  7. package/dist/templates/queries/hitter-hot-cold-zones.ts +81 -0
  8. package/dist/templates/queries/hitter-raw-bip.ts +65 -0
  9. package/dist/templates/queries/hitter-vs-pitch-type.ts +78 -0
  10. package/dist/templates/queries/hitter-zone-grid.ts +90 -0
  11. package/dist/templates/queries/index.ts +27 -0
  12. package/dist/templates/queries/leaderboard-comparison.ts +72 -0
  13. package/dist/templates/queries/leaderboard-custom.ts +90 -0
  14. package/dist/templates/queries/matchup-pitcher-vs-hitter.ts +81 -0
  15. package/dist/templates/queries/matchup-situational.ts +68 -0
  16. package/dist/templates/queries/pitcher-arsenal.ts +89 -0
  17. package/dist/templates/queries/pitcher-handedness-splits.ts +81 -0
  18. package/dist/templates/queries/pitcher-raw-pitches.ts +62 -0
  19. package/dist/templates/queries/pitcher-velocity-trend.ts +73 -0
  20. package/dist/templates/queries/registry.ts +73 -0
  21. package/dist/templates/queries/trend-rolling-average.ts +98 -0
  22. package/dist/templates/queries/trend-year-over-year.ts +73 -0
  23. package/dist/templates/reports/advance-lineup.hbs +29 -0
  24. package/dist/templates/reports/advance-sp.hbs +66 -0
  25. package/dist/templates/reports/college-hitter-draft.hbs +49 -0
  26. package/dist/templates/reports/college-pitcher-draft.hbs +48 -0
  27. package/dist/templates/reports/dev-progress.hbs +29 -0
  28. package/dist/templates/reports/draft-board-card.hbs +35 -0
  29. package/dist/templates/reports/hs-prospect.hbs +48 -0
  30. package/dist/templates/reports/partials/footer.hbs +7 -0
  31. package/dist/templates/reports/partials/header.hbs +12 -0
  32. package/dist/templates/reports/post-promotion.hbs +25 -0
  33. package/dist/templates/reports/pro-hitter-eval.hbs +77 -0
  34. package/dist/templates/reports/pro-pitcher-eval.hbs +81 -0
  35. package/dist/templates/reports/registry.ts +215 -0
  36. package/dist/templates/reports/relief-pitcher-quick.hbs +29 -0
  37. package/dist/templates/reports/trade-target-onepager.hbs +45 -0
  38. package/package.json +68 -63
  39. package/src/templates/reports/advance-sp.hbs +66 -60
  40. package/src/templates/reports/pro-hitter-eval.hbs +77 -65
  41. package/src/templates/reports/pro-pitcher-eval.hbs +81 -69
@@ -213,6 +213,8 @@ var SavantAdapter = class {
213
213
  plate_z: Number(row.plate_z) || 0,
214
214
  launch_speed: row.launch_speed != null ? Number(row.launch_speed) || null : null,
215
215
  launch_angle: row.launch_angle != null ? Number(row.launch_angle) || null : null,
216
+ hc_x: row.hc_x != null && row.hc_x !== "" ? Number(row.hc_x) || null : null,
217
+ hc_y: row.hc_y != null && row.hc_y !== "" ? Number(row.hc_y) || null : null,
216
218
  description: String(row.description ?? ""),
217
219
  events: row.events ? String(row.events) : null,
218
220
  bb_type: row.bb_type ? String(row.bb_type) : null,
@@ -401,13 +403,88 @@ var BaseballReferenceAdapter = class {
401
403
  }
402
404
  };
403
405
 
406
+ // src/adapters/stdin.ts
407
+ var StdinAdapter = class {
408
+ source = "stdin";
409
+ description = "Local data from stdin (for sandboxed environments)";
410
+ data = [];
411
+ player = null;
412
+ loaded = false;
413
+ /**
414
+ * Load data from a pre-read stdin string.
415
+ * Called by the CLI before the adapter is used.
416
+ */
417
+ load(raw) {
418
+ try {
419
+ const parsed = JSON.parse(raw);
420
+ if (Array.isArray(parsed)) {
421
+ this.data = parsed;
422
+ } else if (parsed.data && Array.isArray(parsed.data)) {
423
+ this.data = parsed.data;
424
+ if (parsed.player) {
425
+ this.player = parsed.player;
426
+ }
427
+ } else {
428
+ throw new Error('Expected JSON array or { "data": [...] } object');
429
+ }
430
+ this.loaded = true;
431
+ log.info(`Stdin adapter loaded ${this.data.length} records`);
432
+ } catch (error) {
433
+ throw new Error(
434
+ `Failed to parse stdin data: ${error instanceof Error ? error.message : String(error)}`
435
+ );
436
+ }
437
+ }
438
+ supports(_query) {
439
+ return this.loaded && this.data.length > 0;
440
+ }
441
+ async fetch(query2) {
442
+ if (!this.loaded) {
443
+ throw new Error("Stdin adapter has no data \u2014 pipe JSON via stdin with --stdin flag");
444
+ }
445
+ return {
446
+ data: this.data,
447
+ source: "stdin",
448
+ cached: false,
449
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
450
+ meta: {
451
+ rowCount: this.data.length,
452
+ season: query2.season,
453
+ query: query2
454
+ }
455
+ };
456
+ }
457
+ async resolvePlayer(name) {
458
+ if (this.player && this.player.name.toLowerCase() === name.toLowerCase()) {
459
+ return this.player;
460
+ }
461
+ const firstRecord = this.data[0];
462
+ if (firstRecord) {
463
+ const id = firstRecord.pitcher_id ?? firstRecord.player_id ?? "";
464
+ const recordName = firstRecord.pitcher_name ?? firstRecord.player_name ?? name;
465
+ if (id) {
466
+ return {
467
+ mlbam_id: id,
468
+ name: recordName
469
+ };
470
+ }
471
+ }
472
+ return null;
473
+ }
474
+ };
475
+
404
476
  // src/adapters/index.ts
477
+ var stdinAdapter = new StdinAdapter();
405
478
  var adapters = {
406
479
  "mlb-stats-api": new MlbStatsApiAdapter(),
407
480
  "savant": new SavantAdapter(),
408
481
  "fangraphs": new FanGraphsAdapter(),
409
- "baseball-reference": new BaseballReferenceAdapter()
482
+ "baseball-reference": new BaseballReferenceAdapter(),
483
+ "stdin": stdinAdapter
410
484
  };
485
+ function getStdinAdapter() {
486
+ return stdinAdapter;
487
+ }
411
488
  function resolveAdapters(preferred) {
412
489
  return preferred.map((source) => adapters[source]).filter(Boolean);
413
490
  }
@@ -578,10 +655,24 @@ function getTemplatesDir() {
578
655
  return dir;
579
656
  }
580
657
 
658
+ // src/utils/stdin.ts
659
+ function readStdin() {
660
+ return new Promise((resolve, reject) => {
661
+ if (process.stdin.isTTY) {
662
+ reject(new Error(`--stdin flag requires piped input. Usage: echo '{"data":[...]}' | bbdata query ... --stdin`));
663
+ return;
664
+ }
665
+ const chunks = [];
666
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
667
+ process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
668
+ process.stdin.on("error", reject);
669
+ });
670
+ }
671
+
581
672
  // src/templates/queries/registry.ts
582
673
  var templates = /* @__PURE__ */ new Map();
583
- function registerTemplate(template13) {
584
- templates.set(template13.id, template13);
674
+ function registerTemplate(template16) {
675
+ templates.set(template16.id, template16);
585
676
  }
586
677
  function getTemplate(id) {
587
678
  return templates.get(id);
@@ -624,6 +715,10 @@ var PitchDataSchema = z2.object({
624
715
  // exit velocity (mph)
625
716
  launch_angle: z2.number().nullable(),
626
717
  // degrees
718
+ hc_x: z2.number().nullable(),
719
+ // Statcast hit coordinate x (horizontal)
720
+ hc_y: z2.number().nullable(),
721
+ // Statcast hit coordinate y (distance from home)
627
722
  description: z2.string(),
628
723
  // called_strike, swinging_strike, ball, foul, hit_into_play, etc.
629
724
  events: z2.string().nullable(),
@@ -1321,7 +1416,7 @@ var template11 = {
1321
1416
  };
1322
1417
  },
1323
1418
  columns() {
1324
- return ["Window", "Games", "AVG", "SLG", "K %", "Avg EV", "Hard Hit %"];
1419
+ return ["Window", "Window End", "Games", "AVG", "SLG", "K %", "Avg EV", "Hard Hit %"];
1325
1420
  },
1326
1421
  transform(data) {
1327
1422
  const pitches = data;
@@ -1335,7 +1430,16 @@ var template11 = {
1335
1430
  const dates = Array.from(byDate.keys()).sort();
1336
1431
  const windowSize = 15;
1337
1432
  if (dates.length < windowSize) {
1338
- return [{ Window: "Insufficient data", Games: dates.length, AVG: "\u2014", SLG: "\u2014", "K %": "\u2014", "Avg EV": "\u2014", "Hard Hit %": "\u2014" }];
1433
+ return [{
1434
+ Window: "Insufficient data",
1435
+ "Window End": "",
1436
+ Games: dates.length,
1437
+ AVG: "\u2014",
1438
+ SLG: "\u2014",
1439
+ "K %": "\u2014",
1440
+ "Avg EV": "\u2014",
1441
+ "Hard Hit %": "\u2014"
1442
+ }];
1339
1443
  }
1340
1444
  const results = [];
1341
1445
  for (let i = 0; i <= dates.length - windowSize; i += Math.max(1, Math.floor(windowSize / 3))) {
@@ -1354,8 +1458,10 @@ var template11 = {
1354
1458
  const batted = windowPitches.filter((p) => p.launch_speed !== null && p.launch_speed > 0);
1355
1459
  const avgEv = batted.length > 0 ? batted.reduce((s, p) => s + p.launch_speed, 0) / batted.length : null;
1356
1460
  const hardHit = batted.filter((p) => p.launch_speed >= 95).length;
1461
+ const windowEnd = windowDates[windowDates.length - 1];
1357
1462
  results.push({
1358
- Window: `${windowDates[0]} \u2192 ${windowDates[windowDates.length - 1]}`,
1463
+ Window: `${windowDates[0]} \u2192 ${windowEnd}`,
1464
+ "Window End": windowEnd,
1359
1465
  Games: windowDates.length,
1360
1466
  AVG: pas.length > 0 ? (hits.length / pas.length).toFixed(3) : "\u2014",
1361
1467
  SLG: pas.length > 0 ? (totalBases / pas.length).toFixed(3) : "\u2014",
@@ -1427,12 +1533,182 @@ function formatVal(n) {
1427
1533
  }
1428
1534
  registerTemplate(template12);
1429
1535
 
1536
+ // src/templates/queries/pitcher-raw-pitches.ts
1537
+ var template13 = {
1538
+ id: "pitcher-raw-pitches",
1539
+ name: "Pitcher Raw Pitches",
1540
+ category: "pitcher",
1541
+ description: "One row per pitch with coordinate columns for visualization (movement, location)",
1542
+ preferredSources: ["savant"],
1543
+ requiredParams: ["player"],
1544
+ optionalParams: ["season", "pitchType"],
1545
+ examples: [
1546
+ 'bbdata query pitcher-raw-pitches --player "Corbin Burnes" --season 2025 --format json'
1547
+ ],
1548
+ buildQuery(params) {
1549
+ return {
1550
+ player_name: params.player,
1551
+ season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
1552
+ stat_type: "pitching",
1553
+ pitch_type: params.pitchType ? [params.pitchType] : void 0
1554
+ };
1555
+ },
1556
+ columns() {
1557
+ return [
1558
+ "pitch_type",
1559
+ "release_speed",
1560
+ "release_spin_rate",
1561
+ "pfx_x",
1562
+ "pfx_z",
1563
+ "plate_x",
1564
+ "plate_z",
1565
+ "game_date"
1566
+ ];
1567
+ },
1568
+ transform(data) {
1569
+ const pitches = data;
1570
+ if (pitches.length === 0) return [];
1571
+ return pitches.filter((p) => p.pitch_type).map((p) => ({
1572
+ pitch_type: p.pitch_type,
1573
+ release_speed: p.release_speed,
1574
+ release_spin_rate: p.release_spin_rate,
1575
+ pfx_x: p.pfx_x,
1576
+ pfx_z: p.pfx_z,
1577
+ plate_x: p.plate_x,
1578
+ plate_z: p.plate_z,
1579
+ game_date: p.game_date
1580
+ }));
1581
+ }
1582
+ };
1583
+ registerTemplate(template13);
1584
+
1585
+ // src/templates/queries/hitter-raw-bip.ts
1586
+ var template14 = {
1587
+ id: "hitter-raw-bip",
1588
+ name: "Hitter Raw Batted Balls",
1589
+ category: "hitter",
1590
+ description: "One row per batted ball with hit coordinates, exit velo, and launch angle",
1591
+ preferredSources: ["savant"],
1592
+ requiredParams: ["player"],
1593
+ optionalParams: ["season"],
1594
+ examples: [
1595
+ 'bbdata query hitter-raw-bip --player "Aaron Judge" --season 2025 --format json'
1596
+ ],
1597
+ buildQuery(params) {
1598
+ return {
1599
+ player_name: params.player,
1600
+ season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
1601
+ stat_type: "batting"
1602
+ };
1603
+ },
1604
+ columns() {
1605
+ return [
1606
+ "hc_x",
1607
+ "hc_y",
1608
+ "launch_speed",
1609
+ "launch_angle",
1610
+ "events",
1611
+ "bb_type",
1612
+ "game_date"
1613
+ ];
1614
+ },
1615
+ transform(data) {
1616
+ const pitches = data;
1617
+ if (pitches.length === 0) return [];
1618
+ return pitches.filter(
1619
+ (p) => p.launch_speed != null && p.launch_speed > 0 && p.hc_x != null && p.hc_y != null
1620
+ ).map((p) => ({
1621
+ hc_x: p.hc_x,
1622
+ hc_y: p.hc_y,
1623
+ launch_speed: p.launch_speed,
1624
+ launch_angle: p.launch_angle,
1625
+ events: p.events ?? "unknown",
1626
+ bb_type: p.bb_type ?? "unknown",
1627
+ game_date: p.game_date
1628
+ }));
1629
+ }
1630
+ };
1631
+ registerTemplate(template14);
1632
+
1633
+ // src/templates/queries/hitter-zone-grid.ts
1634
+ var ZONES2 = [
1635
+ { name: "High-In", row: 0, col: 0, xMin: -0.83, xMax: -0.28, zMin: 2.83, zMax: 3.5 },
1636
+ { name: "High-Mid", row: 0, col: 1, xMin: -0.28, xMax: 0.28, zMin: 2.83, zMax: 3.5 },
1637
+ { name: "High-Out", row: 0, col: 2, xMin: 0.28, xMax: 0.83, zMin: 2.83, zMax: 3.5 },
1638
+ { name: "Mid-In", row: 1, col: 0, xMin: -0.83, xMax: -0.28, zMin: 2.17, zMax: 2.83 },
1639
+ { name: "Mid-Mid", row: 1, col: 1, xMin: -0.28, xMax: 0.28, zMin: 2.17, zMax: 2.83 },
1640
+ { name: "Mid-Out", row: 1, col: 2, xMin: 0.28, xMax: 0.83, zMin: 2.17, zMax: 2.83 },
1641
+ { name: "Low-In", row: 2, col: 0, xMin: -0.83, xMax: -0.28, zMin: 1.5, zMax: 2.17 },
1642
+ { name: "Low-Mid", row: 2, col: 1, xMin: -0.28, xMax: 0.28, zMin: 1.5, zMax: 2.17 },
1643
+ { name: "Low-Out", row: 2, col: 2, xMin: 0.28, xMax: 0.83, zMin: 1.5, zMax: 2.17 }
1644
+ ];
1645
+ var template15 = {
1646
+ id: "hitter-zone-grid",
1647
+ name: "Hitter Zone Grid (numeric)",
1648
+ category: "hitter",
1649
+ description: "3x3 strike zone grid with numeric row/col/xwoba for heatmap visualization",
1650
+ preferredSources: ["savant"],
1651
+ requiredParams: ["player"],
1652
+ optionalParams: ["season"],
1653
+ examples: [
1654
+ 'bbdata query hitter-zone-grid --player "Shohei Ohtani" --format json'
1655
+ ],
1656
+ buildQuery(params) {
1657
+ return {
1658
+ player_name: params.player,
1659
+ season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
1660
+ stat_type: "batting"
1661
+ };
1662
+ },
1663
+ columns() {
1664
+ return ["zone", "row", "col", "pitches", "xwoba"];
1665
+ },
1666
+ transform(data) {
1667
+ const pitches = data;
1668
+ if (pitches.length === 0) return [];
1669
+ return ZONES2.map((z3) => {
1670
+ const inZone = pitches.filter(
1671
+ (p) => p.plate_x >= z3.xMin && p.plate_x < z3.xMax && p.plate_z >= z3.zMin && p.plate_z < z3.zMax
1672
+ );
1673
+ const paEnding = inZone.filter((p) => p.events != null);
1674
+ let xwobaSum = 0;
1675
+ for (const p of paEnding) {
1676
+ if (p.events === "walk") {
1677
+ xwobaSum += 0.69;
1678
+ } else if (p.events === "hit_by_pitch") {
1679
+ xwobaSum += 0.72;
1680
+ } else if (p.events === "strikeout") {
1681
+ xwobaSum += 0;
1682
+ } else {
1683
+ xwobaSum += p.estimated_woba ?? 0;
1684
+ }
1685
+ }
1686
+ const xwoba = paEnding.length > 0 ? xwobaSum / paEnding.length : 0;
1687
+ return {
1688
+ zone: z3.name,
1689
+ row: z3.row,
1690
+ col: z3.col,
1691
+ pitches: inZone.length,
1692
+ pa: paEnding.length,
1693
+ xwoba: Number(xwoba.toFixed(3))
1694
+ };
1695
+ });
1696
+ }
1697
+ };
1698
+ registerTemplate(template15);
1699
+
1430
1700
  // src/commands/query.ts
1431
1701
  async function query(options) {
1702
+ if (options.stdin) {
1703
+ const raw = await readStdin();
1704
+ const adapter = getStdinAdapter();
1705
+ adapter.load(raw);
1706
+ options.source = "stdin";
1707
+ }
1432
1708
  const config = getConfig();
1433
1709
  const outputFormat = options.format ?? config.defaultFormat;
1434
- const template13 = getTemplate(options.template);
1435
- if (!template13) {
1710
+ const template16 = getTemplate(options.template);
1711
+ if (!template16) {
1436
1712
  const available = listTemplates().map((t) => ` ${t.id} \u2014 ${t.description}`).join("\n");
1437
1713
  throw new Error(`Unknown template "${options.template}". Available templates:
1438
1714
  ${available}`);
@@ -1449,13 +1725,13 @@ ${available}`);
1449
1725
  top: options.top,
1450
1726
  seasons: options.seasons
1451
1727
  };
1452
- for (const req of template13.requiredParams) {
1728
+ for (const req of template16.requiredParams) {
1453
1729
  if (!params[req] && !(req === "players" && params.player)) {
1454
- throw new Error(`Template "${template13.id}" requires --${req}`);
1730
+ throw new Error(`Template "${template16.id}" requires --${req}`);
1455
1731
  }
1456
1732
  }
1457
- const adapterQuery = template13.buildQuery(params);
1458
- const preferredSources = options.source ? [options.source] : template13.preferredSources;
1733
+ const adapterQuery = template16.buildQuery(params);
1734
+ const preferredSources = options.source ? [options.source] : template16.preferredSources;
1459
1735
  const adapters2 = resolveAdapters(preferredSources);
1460
1736
  let lastError;
1461
1737
  let result;
@@ -1467,8 +1743,8 @@ ${available}`);
1467
1743
  const adapterResult = await adapter.fetch(adapterQuery, {
1468
1744
  bypassCache: options.cache === false
1469
1745
  });
1470
- const rows = template13.transform(adapterResult.data, params);
1471
- const columns = template13.columns(params);
1746
+ const rows = template16.transform(adapterResult.data, params);
1747
+ const columns = template16.columns(params);
1472
1748
  if (rows.length === 0) {
1473
1749
  log.debug(`${adapter.source} returned 0 rows. Trying next source...`);
1474
1750
  continue;
@@ -1476,8 +1752,8 @@ ${available}`);
1476
1752
  result = {
1477
1753
  rows,
1478
1754
  columns,
1479
- title: template13.name,
1480
- description: template13.description,
1755
+ title: template16.name,
1756
+ description: template16.description,
1481
1757
  source: adapter.source,
1482
1758
  cached: adapterResult.cached
1483
1759
  };
@@ -1497,13 +1773,13 @@ ${available}`);
1497
1773
  queryTimeMs,
1498
1774
  season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
1499
1775
  sampleSize: result.rows.length,
1500
- template: template13.id
1776
+ template: template16.id
1501
1777
  }, outputFormat, { columns: result.columns });
1502
1778
  return {
1503
1779
  data: result.rows,
1504
1780
  formatted: output.formatted,
1505
1781
  meta: {
1506
- template: template13.id,
1782
+ template: template16.id,
1507
1783
  source: result.source,
1508
1784
  cached: result.cached,
1509
1785
  rowCount: result.rows.length,
@@ -1512,7 +1788,7 @@ ${available}`);
1512
1788
  };
1513
1789
  }
1514
1790
  function registerQueryCommand(program2) {
1515
- const cmd = program2.command("query [template]").description("Query baseball data using pre-built templates").option("-p, --player <name>", "Player name").option("--players <names>", "Comma-separated player names (for matchups/comparisons)").option("-s, --season <year>", "Season year", String((/* @__PURE__ */ new Date()).getFullYear())).option("-f, --format <fmt>", "Output: json, table, csv, markdown", "json").option("--source <src>", "Force a data source: savant, fangraphs, mlb-stats-api").option("--stat <stat>", "Stat to query (for leaderboards)").option("--pitch-type <type>", "Filter by pitch type (e.g., FF, SL)").option("--min-pa <n>", "Minimum plate appearances", parseInt).option("--min-ip <n>", "Minimum innings pitched", parseInt).option("--top <n>", "Number of results for leaderboards", parseInt).option("--seasons <range>", "Season range (e.g., 2023-2025)").option("--no-cache", "Bypass cache").addHelpText("after", `
1791
+ const cmd = program2.command("query [template]").description("Query baseball data using pre-built templates").option("-p, --player <name>", "Player name").option("--players <names>", "Comma-separated player names (for matchups/comparisons)").option("-s, --season <year>", "Season year", String((/* @__PURE__ */ new Date()).getFullYear())).option("-f, --format <fmt>", "Output: json, table, csv, markdown", "json").option("--source <src>", "Force a data source: savant, fangraphs, mlb-stats-api").option("--stat <stat>", "Stat to query (for leaderboards)").option("--pitch-type <type>", "Filter by pitch type (e.g., FF, SL)").option("--min-pa <n>", "Minimum plate appearances", parseInt).option("--min-ip <n>", "Minimum innings pitched", parseInt).option("--top <n>", "Number of results for leaderboards", parseInt).option("--seasons <range>", "Season range (e.g., 2023-2025)").option("--no-cache", "Bypass cache").option("--stdin", "Read pre-fetched JSON data from stdin instead of fetching from APIs").addHelpText("after", `
1516
1792
  Examples:
1517
1793
  bbdata query pitcher-arsenal --player "Corbin Burnes" --season 2025
1518
1794
  bbdata query hitter-batted-ball --player "Aaron Judge" --format table
@@ -1551,7 +1827,8 @@ Available templates:
1551
1827
  seasons: opts.seasons,
1552
1828
  format: opts.format,
1553
1829
  source: opts.source,
1554
- cache: opts.cache
1830
+ cache: opts.cache,
1831
+ stdin: opts.stdin
1555
1832
  });
1556
1833
  log.data(result.formatted);
1557
1834
  } catch (error) {
@@ -1592,10 +1869,721 @@ function formatGrade(grade) {
1592
1869
  return `${gradeColor(grade)} (${gradeLabel(grade)})`;
1593
1870
  }
1594
1871
 
1872
+ // src/commands/viz.ts
1873
+ import { writeFileSync as writeFileSync2 } from "fs";
1874
+ import { resolve as resolvePath } from "path";
1875
+
1876
+ // src/viz/audience.ts
1877
+ var AUDIENCE_DEFAULTS = {
1878
+ coach: {
1879
+ width: 800,
1880
+ height: 600,
1881
+ titleFontSize: 22,
1882
+ axisLabelFontSize: 18,
1883
+ axisTitleFontSize: 18,
1884
+ legendLabelFontSize: 16,
1885
+ legendTitleFontSize: 16,
1886
+ scheme: "tableau10",
1887
+ labelDensity: "low",
1888
+ padding: 24
1889
+ },
1890
+ analyst: {
1891
+ width: 640,
1892
+ height: 480,
1893
+ titleFontSize: 16,
1894
+ axisLabelFontSize: 12,
1895
+ axisTitleFontSize: 13,
1896
+ legendLabelFontSize: 11,
1897
+ legendTitleFontSize: 12,
1898
+ scheme: "tableau10",
1899
+ labelDensity: "high",
1900
+ padding: 12
1901
+ },
1902
+ frontoffice: {
1903
+ width: 720,
1904
+ height: 540,
1905
+ titleFontSize: 18,
1906
+ axisLabelFontSize: 13,
1907
+ axisTitleFontSize: 14,
1908
+ legendLabelFontSize: 12,
1909
+ legendTitleFontSize: 13,
1910
+ scheme: "tableau10",
1911
+ labelDensity: "medium",
1912
+ padding: 16
1913
+ },
1914
+ presentation: {
1915
+ width: 960,
1916
+ height: 720,
1917
+ titleFontSize: 24,
1918
+ axisLabelFontSize: 16,
1919
+ axisTitleFontSize: 18,
1920
+ legendLabelFontSize: 14,
1921
+ legendTitleFontSize: 16,
1922
+ scheme: "tableau10",
1923
+ labelDensity: "low",
1924
+ padding: 24
1925
+ }
1926
+ };
1927
+ function audienceConfig(audience, colorblind) {
1928
+ const d = AUDIENCE_DEFAULTS[audience];
1929
+ return {
1930
+ font: "Arial, Helvetica, sans-serif",
1931
+ padding: d.padding,
1932
+ title: {
1933
+ fontSize: d.titleFontSize,
1934
+ anchor: "start",
1935
+ font: "Arial, Helvetica, sans-serif"
1936
+ },
1937
+ axis: {
1938
+ labelFontSize: d.axisLabelFontSize,
1939
+ titleFontSize: d.axisTitleFontSize,
1940
+ labelFont: "Arial, Helvetica, sans-serif",
1941
+ titleFont: "Arial, Helvetica, sans-serif",
1942
+ grid: true
1943
+ },
1944
+ legend: {
1945
+ labelFontSize: d.legendLabelFontSize,
1946
+ titleFontSize: d.legendTitleFontSize,
1947
+ labelFont: "Arial, Helvetica, sans-serif",
1948
+ titleFont: "Arial, Helvetica, sans-serif"
1949
+ },
1950
+ range: colorblind ? { category: { scheme: "viridis" }, ramp: { scheme: "viridis" } } : { category: { scheme: d.scheme } },
1951
+ view: { stroke: "transparent" },
1952
+ background: "white"
1953
+ };
1954
+ }
1955
+
1956
+ // src/viz/charts/movement.ts
1957
+ var movementBuilder = {
1958
+ id: "movement",
1959
+ dataRequirements: [
1960
+ { queryTemplate: "pitcher-raw-pitches", required: true }
1961
+ ],
1962
+ defaultTitle({ player, season }) {
1963
+ return `${player} \u2014 Pitch Movement (${season})`;
1964
+ },
1965
+ buildSpec(rows, options) {
1966
+ const pitches = rows["pitcher-raw-pitches"] ?? [];
1967
+ const values = pitches.map((p) => ({
1968
+ pitch_type: p.pitch_type,
1969
+ hBreak: -p.pfx_x * 12,
1970
+ // feet → inches, flipped
1971
+ vBreak: p.pfx_z * 12,
1972
+ velo: p.release_speed
1973
+ }));
1974
+ return {
1975
+ $schema: "https://vega.github.io/schema/vega-lite/v5.json",
1976
+ title: options.title,
1977
+ width: options.width,
1978
+ height: options.height,
1979
+ data: { values },
1980
+ layer: [
1981
+ {
1982
+ mark: { type: "rule", stroke: "#888", strokeDash: [4, 4] },
1983
+ encoding: { x: { datum: 0 } }
1984
+ },
1985
+ {
1986
+ mark: { type: "rule", stroke: "#888", strokeDash: [4, 4] },
1987
+ encoding: { y: { datum: 0 } }
1988
+ },
1989
+ {
1990
+ mark: { type: "point", filled: true, opacity: 0.65, size: 60 },
1991
+ encoding: {
1992
+ x: {
1993
+ field: "hBreak",
1994
+ type: "quantitative",
1995
+ scale: { domain: [-25, 25] },
1996
+ axis: { title: "Horizontal Break (in, catcher POV)" }
1997
+ },
1998
+ y: {
1999
+ field: "vBreak",
2000
+ type: "quantitative",
2001
+ scale: { domain: [-25, 25] },
2002
+ axis: { title: "Induced Vertical Break (in)" }
2003
+ },
2004
+ color: {
2005
+ field: "pitch_type",
2006
+ type: "nominal",
2007
+ legend: { title: "Pitch" }
2008
+ },
2009
+ tooltip: [
2010
+ { field: "pitch_type", title: "Type" },
2011
+ { field: "velo", title: "Velo (mph)", format: ".1f" },
2012
+ { field: "hBreak", title: "H Break (in)", format: ".1f" },
2013
+ { field: "vBreak", title: "V Break (in)", format: ".1f" }
2014
+ ]
2015
+ }
2016
+ },
2017
+ {
2018
+ mark: {
2019
+ type: "point",
2020
+ shape: "cross",
2021
+ size: 500,
2022
+ strokeWidth: 3,
2023
+ filled: false
2024
+ },
2025
+ encoding: {
2026
+ x: { aggregate: "mean", field: "hBreak", type: "quantitative" },
2027
+ y: { aggregate: "mean", field: "vBreak", type: "quantitative" },
2028
+ color: { field: "pitch_type", type: "nominal" },
2029
+ detail: { field: "pitch_type" }
2030
+ }
2031
+ }
2032
+ ],
2033
+ config: audienceConfig(options.audience, options.colorblind)
2034
+ };
2035
+ }
2036
+ };
2037
+
2038
+ // src/viz/charts/spray.ts
2039
+ var sprayBuilder = {
2040
+ id: "spray",
2041
+ dataRequirements: [
2042
+ { queryTemplate: "hitter-raw-bip", required: true }
2043
+ ],
2044
+ defaultTitle({ player, season }) {
2045
+ return `${player} \u2014 Spray Chart (${season})`;
2046
+ },
2047
+ buildSpec(rows, options) {
2048
+ const bip = rows["hitter-raw-bip"] ?? [];
2049
+ const SCALE = 2.5;
2050
+ const points = bip.map((b) => ({
2051
+ x: (b.hc_x - 125.42) * SCALE,
2052
+ y: (204 - b.hc_y) * SCALE,
2053
+ launch_speed: b.launch_speed ?? 0,
2054
+ launch_angle: b.launch_angle ?? 0,
2055
+ events: b.events
2056
+ }));
2057
+ const arc = Array.from({ length: 37 }, (_, i) => {
2058
+ const t = Math.PI / 4 + Math.PI / 2 * (i / 36);
2059
+ return { x: Math.cos(t) * 420 * -1, y: Math.sin(t) * 420 };
2060
+ });
2061
+ return {
2062
+ $schema: "https://vega.github.io/schema/vega-lite/v5.json",
2063
+ title: options.title,
2064
+ width: options.width,
2065
+ height: options.height,
2066
+ layer: [
2067
+ // Batted-ball points (first layer controls scales/axes for the chart)
2068
+ {
2069
+ data: { values: points },
2070
+ mark: { type: "circle", opacity: 0.75, stroke: "#333", strokeWidth: 0.5 },
2071
+ encoding: {
2072
+ x: {
2073
+ field: "x",
2074
+ type: "quantitative",
2075
+ scale: { domain: [-450, 450] },
2076
+ axis: null
2077
+ },
2078
+ y: {
2079
+ field: "y",
2080
+ type: "quantitative",
2081
+ scale: { domain: [-50, 500] },
2082
+ axis: null
2083
+ },
2084
+ size: {
2085
+ field: "launch_speed",
2086
+ type: "quantitative",
2087
+ scale: { domain: [60, 115], range: [40, 400] },
2088
+ legend: { title: "Exit Velo" }
2089
+ },
2090
+ color: {
2091
+ field: "events",
2092
+ type: "nominal",
2093
+ scale: {
2094
+ domain: [
2095
+ "single",
2096
+ "double",
2097
+ "triple",
2098
+ "home_run",
2099
+ "field_out",
2100
+ "force_out",
2101
+ "grounded_into_double_play"
2102
+ ],
2103
+ range: [
2104
+ "#4e79a7",
2105
+ "#59a14f",
2106
+ "#edc948",
2107
+ "#e15759",
2108
+ "#bab0ac",
2109
+ "#bab0ac",
2110
+ "#bab0ac"
2111
+ ]
2112
+ },
2113
+ legend: { title: "Result" }
2114
+ },
2115
+ tooltip: [
2116
+ { field: "events", title: "Result" },
2117
+ { field: "launch_speed", title: "EV", format: ".1f" },
2118
+ { field: "launch_angle", title: "LA", format: ".0f" }
2119
+ ]
2120
+ }
2121
+ },
2122
+ // Foul lines — left field
2123
+ {
2124
+ data: { values: [{ x: 0, y: 0 }, { x: -297, y: 297 }] },
2125
+ mark: { type: "line", stroke: "#888", strokeWidth: 1.5 },
2126
+ encoding: {
2127
+ x: { field: "x", type: "quantitative" },
2128
+ y: { field: "y", type: "quantitative" }
2129
+ }
2130
+ },
2131
+ // Foul lines — right field
2132
+ {
2133
+ data: { values: [{ x: 0, y: 0 }, { x: 297, y: 297 }] },
2134
+ mark: { type: "line", stroke: "#888", strokeWidth: 1.5 },
2135
+ encoding: {
2136
+ x: { field: "x", type: "quantitative" },
2137
+ y: { field: "y", type: "quantitative" }
2138
+ }
2139
+ },
2140
+ // Outfield arc
2141
+ {
2142
+ data: { values: arc },
2143
+ mark: { type: "line", stroke: "#999", strokeDash: [6, 4], strokeWidth: 1.5 },
2144
+ encoding: {
2145
+ x: { field: "x", type: "quantitative" },
2146
+ y: { field: "y", type: "quantitative" }
2147
+ }
2148
+ }
2149
+ ],
2150
+ config: {
2151
+ ...audienceConfig(options.audience, options.colorblind),
2152
+ axis: { grid: false, domain: false, ticks: false, labels: false }
2153
+ }
2154
+ };
2155
+ }
2156
+ };
2157
+
2158
+ // src/viz/charts/zone.ts
2159
+ var zoneBuilder = {
2160
+ id: "zone",
2161
+ dataRequirements: [
2162
+ { queryTemplate: "hitter-zone-grid", required: true }
2163
+ ],
2164
+ defaultTitle({ player, season }) {
2165
+ return `${player} \u2014 Zone Profile, xwOBA (${season})`;
2166
+ },
2167
+ buildSpec(rows, options) {
2168
+ const grid = rows["hitter-zone-grid"] ?? [];
2169
+ return {
2170
+ $schema: "https://vega.github.io/schema/vega-lite/v5.json",
2171
+ title: options.title,
2172
+ width: options.width,
2173
+ height: options.height,
2174
+ data: { values: grid },
2175
+ layer: [
2176
+ {
2177
+ mark: { type: "rect", stroke: "#222", strokeWidth: 1.5 },
2178
+ encoding: {
2179
+ x: {
2180
+ field: "col",
2181
+ type: "ordinal",
2182
+ axis: { title: "Inside \u2192 Outside", labels: false, ticks: false }
2183
+ },
2184
+ y: {
2185
+ field: "row",
2186
+ type: "ordinal",
2187
+ axis: { title: "High \u2192 Low", labels: false, ticks: false }
2188
+ },
2189
+ color: {
2190
+ field: "xwoba",
2191
+ type: "quantitative",
2192
+ // Domain covers the league-wide realistic range for xwOBA
2193
+ // (~.200 is Mendoza-esque; ~.500 is MVP-tier).
2194
+ // `clamp: true` caps values outside the range to the endpoint
2195
+ // colors so elite hitters still render cleanly.
2196
+ scale: options.colorblind ? { scheme: "viridis", domain: [0.2, 0.5], clamp: true } : {
2197
+ scheme: "redyellowblue",
2198
+ reverse: true,
2199
+ domain: [0.2, 0.5],
2200
+ clamp: true
2201
+ },
2202
+ legend: { title: "xwOBA" }
2203
+ },
2204
+ tooltip: [
2205
+ { field: "zone", title: "Zone" },
2206
+ { field: "pitches", title: "Pitches" },
2207
+ { field: "pa", title: "PAs" },
2208
+ { field: "xwoba", title: "xwOBA", format: ".3f" }
2209
+ ]
2210
+ }
2211
+ },
2212
+ {
2213
+ mark: {
2214
+ type: "text",
2215
+ fontSize: 18,
2216
+ fontWeight: "bold",
2217
+ // Halo stroke keeps text legible against every cell color —
2218
+ // light (yellow) and dark (saturated red or blue) alike.
2219
+ stroke: "white",
2220
+ strokeWidth: 3,
2221
+ strokeOpacity: 0.9,
2222
+ paintOrder: "stroke"
2223
+ },
2224
+ encoding: {
2225
+ x: { field: "col", type: "ordinal" },
2226
+ y: { field: "row", type: "ordinal" },
2227
+ text: { field: "xwoba", type: "quantitative", format: ".3f" },
2228
+ color: { value: "black" }
2229
+ }
2230
+ }
2231
+ ],
2232
+ config: audienceConfig(options.audience, options.colorblind)
2233
+ };
2234
+ }
2235
+ };
2236
+
2237
+ // src/viz/charts/rolling.ts
2238
+ function parseNumeric(v) {
2239
+ if (v == null) return null;
2240
+ if (typeof v === "number") return Number.isFinite(v) ? v : null;
2241
+ const s = String(v).replace(/[^\d.\-]/g, "");
2242
+ if (!s) return null;
2243
+ const n = parseFloat(s);
2244
+ return Number.isFinite(n) ? n : null;
2245
+ }
2246
+ var rollingBuilder = {
2247
+ id: "rolling",
2248
+ dataRequirements: [
2249
+ { queryTemplate: "trend-rolling-average", required: true }
2250
+ ],
2251
+ defaultTitle({ player, season }) {
2252
+ return `${player} \u2014 Rolling Performance (${season})`;
2253
+ },
2254
+ buildSpec(rows, options) {
2255
+ const wideRows = rows["trend-rolling-average"] ?? [];
2256
+ const preferredKeys = ["Window End", "window_end", "Date", "date", "End Date"];
2257
+ let dateKey = null;
2258
+ if (wideRows.length > 0) {
2259
+ const first = wideRows[0];
2260
+ for (const k of preferredKeys) {
2261
+ if (k in first && isParseableDate(first[k])) {
2262
+ dateKey = k;
2263
+ break;
2264
+ }
2265
+ }
2266
+ }
2267
+ const metricKeys = /* @__PURE__ */ new Set();
2268
+ const excluded = new Set([dateKey, "Window", "Games"].filter(Boolean));
2269
+ for (const r of wideRows) {
2270
+ for (const k of Object.keys(r)) {
2271
+ if (excluded.has(k)) continue;
2272
+ if (parseNumeric(r[k]) != null) metricKeys.add(k);
2273
+ }
2274
+ }
2275
+ const tidy = [];
2276
+ for (const r of wideRows) {
2277
+ const date = dateKey ? String(r[dateKey] ?? "") : "";
2278
+ if (!date || !isParseableDate(date)) continue;
2279
+ for (const k of metricKeys) {
2280
+ const n = parseNumeric(r[k]);
2281
+ if (n != null) tidy.push({ window_end: date, metric: k, value: n });
2282
+ }
2283
+ }
2284
+ if (tidy.length === 0) {
2285
+ return {
2286
+ $schema: "https://vega.github.io/schema/vega-lite/v5.json",
2287
+ title: options.title,
2288
+ width: options.width,
2289
+ height: options.height,
2290
+ data: { values: [{ msg: "Insufficient data for rolling trend (need 15+ games)" }] },
2291
+ mark: { type: "text", fontSize: 14, color: "#888" },
2292
+ encoding: { text: { field: "msg", type: "nominal" } },
2293
+ config: audienceConfig(options.audience, options.colorblind)
2294
+ };
2295
+ }
2296
+ const metricOrder = Array.from(metricKeys);
2297
+ const panelHeight = Math.max(80, Math.floor(options.height / metricOrder.length) - 30);
2298
+ return {
2299
+ $schema: "https://vega.github.io/schema/vega-lite/v5.json",
2300
+ title: options.title,
2301
+ data: { values: tidy },
2302
+ facet: {
2303
+ row: {
2304
+ field: "metric",
2305
+ type: "nominal",
2306
+ title: null,
2307
+ header: { labelAngle: 0, labelAlign: "left", labelFontWeight: "bold" },
2308
+ sort: metricOrder
2309
+ }
2310
+ },
2311
+ spec: {
2312
+ width: options.width - 120,
2313
+ height: panelHeight,
2314
+ layer: [
2315
+ {
2316
+ mark: { type: "line", point: true, strokeWidth: 2 },
2317
+ encoding: {
2318
+ x: {
2319
+ field: "window_end",
2320
+ type: "temporal",
2321
+ axis: { title: "Window End", format: "%b %d" }
2322
+ },
2323
+ y: {
2324
+ field: "value",
2325
+ type: "quantitative",
2326
+ axis: { title: null },
2327
+ scale: { zero: false }
2328
+ },
2329
+ color: {
2330
+ field: "metric",
2331
+ type: "nominal",
2332
+ legend: null
2333
+ },
2334
+ tooltip: [
2335
+ { field: "window_end", type: "temporal", format: "%Y-%m-%d" },
2336
+ { field: "metric", title: "Metric" },
2337
+ { field: "value", title: "Value", format: ".3f" }
2338
+ ]
2339
+ }
2340
+ },
2341
+ {
2342
+ mark: { type: "rule", strokeDash: [4, 4], opacity: 0.4 },
2343
+ encoding: {
2344
+ y: { aggregate: "mean", field: "value", type: "quantitative" },
2345
+ color: { field: "metric", type: "nominal", legend: null }
2346
+ }
2347
+ }
2348
+ ]
2349
+ },
2350
+ resolve: { scale: { y: "independent" } },
2351
+ config: audienceConfig(options.audience, options.colorblind)
2352
+ };
2353
+ }
2354
+ };
2355
+ function isParseableDate(v) {
2356
+ if (v == null || v === "") return false;
2357
+ const s = String(v);
2358
+ if (!/[-/]/.test(s)) return false;
2359
+ const t = Date.parse(s);
2360
+ return Number.isFinite(t);
2361
+ }
2362
+
2363
+ // src/viz/charts/index.ts
2364
+ var builders = {
2365
+ movement: movementBuilder,
2366
+ spray: sprayBuilder,
2367
+ zone: zoneBuilder,
2368
+ rolling: rollingBuilder
2369
+ };
2370
+ function getChartBuilder(type) {
2371
+ const b = builders[type];
2372
+ if (!b) {
2373
+ throw new Error(
2374
+ `Unknown chart type: "${type}". Available: ${Object.keys(builders).join(", ")}`
2375
+ );
2376
+ }
2377
+ return b;
2378
+ }
2379
+ function listChartTypes() {
2380
+ return Object.keys(builders);
2381
+ }
2382
+
2383
+ // src/viz/render.ts
2384
+ import { parse as vegaParse, View, Warn } from "vega";
2385
+ import { compile } from "vega-lite";
2386
+ async function specToSvg(vlSpec) {
2387
+ const { spec: vgSpec } = compile(vlSpec);
2388
+ const runtime = vegaParse(vgSpec);
2389
+ const view = new View(runtime, { renderer: "none" });
2390
+ view.logLevel(Warn);
2391
+ const svg = await view.toSVG();
2392
+ view.finalize();
2393
+ return ensureTextPaintOrder(svg);
2394
+ }
2395
+ function ensureTextPaintOrder(svg) {
2396
+ return svg.replace(/<text\b([^>]*)>/g, (match, attrs) => {
2397
+ if (/\bpaint-order\s*=/.test(attrs)) return match;
2398
+ if (!/\bfill\s*=/.test(attrs)) return match;
2399
+ if (!/\bstroke\s*=/.test(attrs)) return match;
2400
+ return `<text${attrs} paint-order="stroke">`;
2401
+ });
2402
+ }
2403
+
2404
+ // src/viz/types.ts
2405
+ function resolveVizAudience(a) {
2406
+ if (!a) return "analyst";
2407
+ switch (a) {
2408
+ case "gm":
2409
+ return "frontoffice";
2410
+ case "scout":
2411
+ return "analyst";
2412
+ case "coach":
2413
+ case "analyst":
2414
+ case "frontoffice":
2415
+ case "presentation":
2416
+ return a;
2417
+ default:
2418
+ return "analyst";
2419
+ }
2420
+ }
2421
+
2422
+ // src/commands/viz.ts
2423
+ async function viz(options) {
2424
+ if (options.stdin) {
2425
+ const raw = await readStdin();
2426
+ getStdinAdapter().load(raw);
2427
+ }
2428
+ const config = getConfig();
2429
+ const audience = resolveVizAudience(
2430
+ options.audience ?? config.defaultAudience
2431
+ );
2432
+ const defaults = AUDIENCE_DEFAULTS[audience];
2433
+ const builder = getChartBuilder(options.type);
2434
+ const season = options.season ?? (/* @__PURE__ */ new Date()).getFullYear();
2435
+ const player = options.player ?? "Unknown";
2436
+ const width = options.width ?? defaults.width;
2437
+ const height = options.height ?? defaults.height;
2438
+ const rows = {};
2439
+ let source = "unknown";
2440
+ for (const req of builder.dataRequirements) {
2441
+ try {
2442
+ const result = await query({
2443
+ template: req.queryTemplate,
2444
+ player: options.player,
2445
+ season,
2446
+ format: "json",
2447
+ ...options.stdin ? { source: "stdin" } : {},
2448
+ ...options.source && !options.stdin ? { source: options.source } : {}
2449
+ });
2450
+ rows[req.queryTemplate] = result.data;
2451
+ if (result.meta.source) source = result.meta.source;
2452
+ } catch (err) {
2453
+ if (req.required) throw err;
2454
+ rows[req.queryTemplate] = [];
2455
+ }
2456
+ }
2457
+ const resolved = {
2458
+ type: options.type,
2459
+ player,
2460
+ season,
2461
+ audience,
2462
+ format: options.format ?? "svg",
2463
+ width,
2464
+ height,
2465
+ colorblind: options.colorblind ?? false,
2466
+ title: options.title ?? builder.defaultTitle({ player, season }),
2467
+ players: options.players
2468
+ };
2469
+ const spec = builder.buildSpec(rows, resolved);
2470
+ const svg = await specToSvg(spec);
2471
+ if (options.output) {
2472
+ writeFileSync2(resolvePath(options.output), svg, "utf-8");
2473
+ log.success(`Wrote ${options.output}`);
2474
+ }
2475
+ return {
2476
+ svg,
2477
+ spec,
2478
+ meta: {
2479
+ chartType: options.type,
2480
+ player,
2481
+ season,
2482
+ audience,
2483
+ rowCount: Object.values(rows).reduce((a, r) => a + r.length, 0),
2484
+ source,
2485
+ width,
2486
+ height
2487
+ }
2488
+ };
2489
+ }
2490
+ function registerVizCommand(program2) {
2491
+ program2.command("viz [type]").description("Generate data visualizations (SVG)").option("--type <type>", "Chart type: movement, spray, zone, rolling").option("-p, --player <name>", "Player name").option("--players <names>", "Comma-separated player names (for comparisons)").option("-s, --season <year>", "Season year", String((/* @__PURE__ */ new Date()).getFullYear())).option(
2492
+ "-a, --audience <role>",
2493
+ "Audience: coach, analyst, frontoffice, presentation, gm, scout"
2494
+ ).option("-f, --format <fmt>", "Output format (svg only in v1)", "svg").option("--size <WxH>", "Chart dimensions, e.g. 800x600").option("--colorblind", "Use a colorblind-safe palette (viridis)").option("-o, --output <path>", "Write SVG to a file (otherwise prints to stdout)").option("--source <src>", "Force a data source (savant, fangraphs, ...)").option("--stdin", "Read pre-fetched JSON data from stdin").addHelpText("after", `
2495
+ Examples:
2496
+ bbdata viz movement --player "Corbin Burnes" --season 2025 -o burnes_movement.svg
2497
+ bbdata viz spray --player "Aaron Judge" --audience coach
2498
+ bbdata viz zone --player "Shohei Ohtani" --colorblind
2499
+ bbdata viz rolling --player "Freddie Freeman"
2500
+
2501
+ Chart types:
2502
+ movement \u2014 pitch movement plot (H break vs V break, per pitch type)
2503
+ spray \u2014 spray chart (batted ball landing positions on a field)
2504
+ zone \u2014 3x3 zone profile heatmap (xwOBA per plate region)
2505
+ rolling \u2014 rolling performance trend (time-series)
2506
+ `).action(async (typeArg, opts) => {
2507
+ const type = typeArg ?? opts.type;
2508
+ if (!type) {
2509
+ log.data("\nAvailable chart types:\n\n");
2510
+ for (const t of listChartTypes()) {
2511
+ log.data(` ${t}
2512
+ `);
2513
+ }
2514
+ log.data('\nUsage: bbdata viz <type> --player "Name" [options]\n\n');
2515
+ return;
2516
+ }
2517
+ let width;
2518
+ let height;
2519
+ if (opts.size) {
2520
+ const [w, h] = String(opts.size).split("x").map((n) => parseInt(n, 10));
2521
+ if (w && h) {
2522
+ width = w;
2523
+ height = h;
2524
+ }
2525
+ }
2526
+ try {
2527
+ const result = await viz({
2528
+ type,
2529
+ player: opts.player,
2530
+ players: opts.players ? String(opts.players).split(",").map((s) => s.trim()) : void 0,
2531
+ season: opts.season ? parseInt(opts.season) : void 0,
2532
+ audience: opts.audience,
2533
+ format: opts.format,
2534
+ width,
2535
+ height,
2536
+ colorblind: opts.colorblind,
2537
+ output: opts.output,
2538
+ source: opts.source,
2539
+ stdin: opts.stdin
2540
+ });
2541
+ if (!opts.output) log.data(result.svg + "\n");
2542
+ } catch (error) {
2543
+ log.error(error instanceof Error ? error.message : String(error));
2544
+ process.exitCode = 1;
2545
+ }
2546
+ });
2547
+ }
2548
+
2549
+ // src/viz/embed.ts
2550
+ var REPORT_GRAPH_MAP = {
2551
+ "advance-sp": [
2552
+ { slot: "movementChart", type: "movement" }
2553
+ ],
2554
+ "pro-pitcher-eval": [
2555
+ { slot: "movementChart", type: "movement" },
2556
+ { slot: "rollingChart", type: "rolling" }
2557
+ ],
2558
+ "pro-hitter-eval": [
2559
+ { slot: "sprayChart", type: "spray" },
2560
+ { slot: "zoneChart", type: "zone" }
2561
+ ]
2562
+ };
2563
+ async function generateReportGraphs(reportId, player, season, audience, opts = {}) {
2564
+ const slots = REPORT_GRAPH_MAP[reportId] ?? [];
2565
+ const out = {};
2566
+ for (const { slot, type } of slots) {
2567
+ try {
2568
+ const r = await viz({
2569
+ type,
2570
+ player,
2571
+ season,
2572
+ audience,
2573
+ ...opts.stdin ? { source: "stdin" } : {}
2574
+ });
2575
+ out[slot] = r.svg;
2576
+ } catch {
2577
+ out[slot] = "";
2578
+ }
2579
+ }
2580
+ return out;
2581
+ }
2582
+
1595
2583
  // src/templates/reports/registry.ts
1596
2584
  var templates2 = /* @__PURE__ */ new Map();
1597
- function registerReportTemplate(template13) {
1598
- templates2.set(template13.id, template13);
2585
+ function registerReportTemplate(template16) {
2586
+ templates2.set(template16.id, template16);
1599
2587
  }
1600
2588
  function getReportTemplate(id) {
1601
2589
  return templates2.get(id);
@@ -1777,6 +2765,10 @@ Handlebars.registerHelper("compare", (value, leagueAvg) => {
1777
2765
  Handlebars.registerHelper("ifGt", function(a, b, options) {
1778
2766
  return a > b ? options.fn(this) : options.inverse(this);
1779
2767
  });
2768
+ Handlebars.registerHelper(
2769
+ "svgOrEmpty",
2770
+ (svg) => new Handlebars.SafeString(svg ?? "")
2771
+ );
1780
2772
  var __dirname2 = dirname(fileURLToPath(import.meta.url));
1781
2773
  var BUNDLED_TEMPLATES_DIR = join2(__dirname2, "..", "templates", "reports");
1782
2774
  function loadTemplate(templateFile) {
@@ -1793,14 +2785,14 @@ function loadTemplate(templateFile) {
1793
2785
  }
1794
2786
  function generateFallbackTemplate(templateFile) {
1795
2787
  const templateId = templateFile.replace(".hbs", "");
1796
- const template13 = getReportTemplate(templateId);
1797
- if (!template13) return "# Report\n\n{{data}}";
1798
- const sections = template13.requiredSections.map((s) => `## ${s}
2788
+ const template16 = getReportTemplate(templateId);
2789
+ if (!template16) return "# Report\n\n{{data}}";
2790
+ const sections = template16.requiredSections.map((s) => `## ${s}
1799
2791
 
1800
2792
  {{!-- ${s} data goes here --}}
1801
2793
  *Data pending*
1802
2794
  `).join("\n");
1803
- return `# ${template13.name}
2795
+ return `# ${template16.name}
1804
2796
 
1805
2797
  **Player:** {{player}}
1806
2798
  **Season:** {{season}}
@@ -1816,10 +2808,15 @@ ${sections}
1816
2808
  `;
1817
2809
  }
1818
2810
  async function report(options) {
2811
+ if (options.stdin) {
2812
+ const raw = await readStdin();
2813
+ const adapter = getStdinAdapter();
2814
+ adapter.load(raw);
2815
+ }
1819
2816
  const config = getConfig();
1820
2817
  const audience = options.audience ?? config.defaultAudience;
1821
- const template13 = getReportTemplate(options.template);
1822
- if (!template13) {
2818
+ const template16 = getReportTemplate(options.template);
2819
+ if (!template16) {
1823
2820
  const available = listReportTemplates().map((t) => ` ${t.id} \u2014 ${t.description}`).join("\n");
1824
2821
  throw new Error(`Unknown report template "${options.template}". Available:
1825
2822
  ${available}`);
@@ -1828,14 +2825,15 @@ ${available}`);
1828
2825
  const player = options.player ?? "Unknown";
1829
2826
  const dataResults = {};
1830
2827
  const dataSources = [];
1831
- for (const req of template13.dataRequirements) {
2828
+ for (const req of template16.dataRequirements) {
1832
2829
  try {
1833
2830
  const result = await query({
1834
2831
  template: req.queryTemplate,
1835
2832
  player: options.player,
1836
2833
  team: options.team,
1837
2834
  season,
1838
- format: "json"
2835
+ format: "json",
2836
+ ...options.stdin ? { source: "stdin" } : {}
1839
2837
  });
1840
2838
  dataResults[req.queryTemplate] = result.data;
1841
2839
  if (!dataSources.includes(result.meta.source)) {
@@ -1848,7 +2846,14 @@ ${available}`);
1848
2846
  dataResults[req.queryTemplate] = null;
1849
2847
  }
1850
2848
  }
1851
- const hbsSource = loadTemplate(template13.templateFile);
2849
+ const graphs = await generateReportGraphs(
2850
+ template16.id,
2851
+ player,
2852
+ season,
2853
+ audience,
2854
+ { stdin: options.stdin }
2855
+ );
2856
+ const hbsSource = loadTemplate(template16.templateFile);
1852
2857
  const compiled = Handlebars.compile(hbsSource);
1853
2858
  const content = compiled({
1854
2859
  player,
@@ -1857,19 +2862,20 @@ ${available}`);
1857
2862
  date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
1858
2863
  sources: dataSources.join(", ") || "none",
1859
2864
  data: dataResults,
2865
+ graphs,
1860
2866
  ...dataResults
1861
2867
  });
1862
2868
  let validation;
1863
2869
  if (options.validate) {
1864
- validation = validateReport(content, template13.requiredSections);
2870
+ validation = validateReport(content, template16.requiredSections);
1865
2871
  }
1866
- const formatted = options.format === "json" ? JSON.stringify({ content, validation, meta: { template: template13.id, player, audience, season, dataSources } }, null, 2) + "\n" : content + "\n";
2872
+ const formatted = options.format === "json" ? JSON.stringify({ content, validation, meta: { template: template16.id, player, audience, season, dataSources } }, null, 2) + "\n" : content + "\n";
1867
2873
  return {
1868
2874
  content,
1869
2875
  formatted,
1870
2876
  validation,
1871
2877
  meta: {
1872
- template: template13.id,
2878
+ template: template16.id,
1873
2879
  player,
1874
2880
  audience,
1875
2881
  season,
@@ -1902,7 +2908,7 @@ function validateReport(content, requiredSections) {
1902
2908
  };
1903
2909
  }
1904
2910
  function registerReportCommand(program2) {
1905
- program2.command("report [template]").description("Generate scouting reports using pre-built templates").option("-p, --player <name>", "Player name").option("-t, --team <code>", "Team abbreviation").option("-s, --season <year>", "Season year", String((/* @__PURE__ */ new Date()).getFullYear())).option("-a, --audience <role>", "Target audience: coach, gm, scout, analyst").option("-f, --format <fmt>", "Output: markdown, json", "markdown").option("--validate", "Run validation checklist on the report").addHelpText("after", `
2911
+ program2.command("report [template]").description("Generate scouting reports using pre-built templates").option("-p, --player <name>", "Player name").option("-t, --team <code>", "Team abbreviation").option("-s, --season <year>", "Season year", String((/* @__PURE__ */ new Date()).getFullYear())).option("-a, --audience <role>", "Target audience: coach, gm, scout, analyst").option("-f, --format <fmt>", "Output: markdown, json", "markdown").option("--validate", "Run validation checklist on the report").option("--stdin", "Read pre-fetched JSON data from stdin instead of fetching from APIs").addHelpText("after", `
1906
2912
  Examples:
1907
2913
  bbdata report pro-pitcher-eval --player "Corbin Burnes"
1908
2914
  bbdata report advance-sp --player "Gerrit Cole" --audience coach --validate
@@ -1933,7 +2939,8 @@ Available templates:
1933
2939
  season: opts.season ? parseInt(opts.season) : void 0,
1934
2940
  audience: opts.audience,
1935
2941
  format: opts.format,
1936
- validate: opts.validate
2942
+ validate: opts.validate,
2943
+ stdin: opts.stdin
1937
2944
  });
1938
2945
  log.data(result.formatted);
1939
2946
  if (result.validation && !result.validation.passed) {
@@ -1955,6 +2962,7 @@ var program = new Command();
1955
2962
  program.name("bbdata").description("Baseball data CLI \u2014 query stats, generate scouting reports, and build analytics pipelines").version("0.1.0");
1956
2963
  registerQueryCommand(program);
1957
2964
  registerReportCommand(program);
2965
+ registerVizCommand(program);
1958
2966
 
1959
2967
  // bin/bbdata.ts
1960
2968
  program.parse(process.argv);