bbdata-cli 0.2.0 → 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.
package/dist/src/index.js CHANGED
@@ -210,6 +210,8 @@ var SavantAdapter = class {
210
210
  plate_z: Number(row.plate_z) || 0,
211
211
  launch_speed: row.launch_speed != null ? Number(row.launch_speed) || null : null,
212
212
  launch_angle: row.launch_angle != null ? Number(row.launch_angle) || null : null,
213
+ hc_x: row.hc_x != null && row.hc_x !== "" ? Number(row.hc_x) || null : null,
214
+ hc_y: row.hc_y != null && row.hc_y !== "" ? Number(row.hc_y) || null : null,
213
215
  description: String(row.description ?? ""),
214
216
  events: row.events ? String(row.events) : null,
215
217
  bb_type: row.bb_type ? String(row.bb_type) : null,
@@ -674,8 +676,8 @@ function readStdin() {
674
676
 
675
677
  // src/templates/queries/registry.ts
676
678
  var templates = /* @__PURE__ */ new Map();
677
- function registerTemplate(template13) {
678
- templates.set(template13.id, template13);
679
+ function registerTemplate(template16) {
680
+ templates.set(template16.id, template16);
679
681
  }
680
682
  function getTemplate(id) {
681
683
  return templates.get(id);
@@ -718,6 +720,10 @@ var PitchDataSchema = z2.object({
718
720
  // exit velocity (mph)
719
721
  launch_angle: z2.number().nullable(),
720
722
  // degrees
723
+ hc_x: z2.number().nullable(),
724
+ // Statcast hit coordinate x (horizontal)
725
+ hc_y: z2.number().nullable(),
726
+ // Statcast hit coordinate y (distance from home)
721
727
  description: z2.string(),
722
728
  // called_strike, swinging_strike, ball, foul, hit_into_play, etc.
723
729
  events: z2.string().nullable(),
@@ -1415,7 +1421,7 @@ var template11 = {
1415
1421
  };
1416
1422
  },
1417
1423
  columns() {
1418
- return ["Window", "Games", "AVG", "SLG", "K %", "Avg EV", "Hard Hit %"];
1424
+ return ["Window", "Window End", "Games", "AVG", "SLG", "K %", "Avg EV", "Hard Hit %"];
1419
1425
  },
1420
1426
  transform(data) {
1421
1427
  const pitches = data;
@@ -1429,7 +1435,16 @@ var template11 = {
1429
1435
  const dates = Array.from(byDate.keys()).sort();
1430
1436
  const windowSize = 15;
1431
1437
  if (dates.length < windowSize) {
1432
- return [{ Window: "Insufficient data", Games: dates.length, AVG: "\u2014", SLG: "\u2014", "K %": "\u2014", "Avg EV": "\u2014", "Hard Hit %": "\u2014" }];
1438
+ return [{
1439
+ Window: "Insufficient data",
1440
+ "Window End": "",
1441
+ Games: dates.length,
1442
+ AVG: "\u2014",
1443
+ SLG: "\u2014",
1444
+ "K %": "\u2014",
1445
+ "Avg EV": "\u2014",
1446
+ "Hard Hit %": "\u2014"
1447
+ }];
1433
1448
  }
1434
1449
  const results = [];
1435
1450
  for (let i = 0; i <= dates.length - windowSize; i += Math.max(1, Math.floor(windowSize / 3))) {
@@ -1448,8 +1463,10 @@ var template11 = {
1448
1463
  const batted = windowPitches.filter((p) => p.launch_speed !== null && p.launch_speed > 0);
1449
1464
  const avgEv = batted.length > 0 ? batted.reduce((s, p) => s + p.launch_speed, 0) / batted.length : null;
1450
1465
  const hardHit = batted.filter((p) => p.launch_speed >= 95).length;
1466
+ const windowEnd = windowDates[windowDates.length - 1];
1451
1467
  results.push({
1452
- Window: `${windowDates[0]} \u2192 ${windowDates[windowDates.length - 1]}`,
1468
+ Window: `${windowDates[0]} \u2192 ${windowEnd}`,
1469
+ "Window End": windowEnd,
1453
1470
  Games: windowDates.length,
1454
1471
  AVG: pas.length > 0 ? (hits.length / pas.length).toFixed(3) : "\u2014",
1455
1472
  SLG: pas.length > 0 ? (totalBases / pas.length).toFixed(3) : "\u2014",
@@ -1521,6 +1538,170 @@ function formatVal(n) {
1521
1538
  }
1522
1539
  registerTemplate(template12);
1523
1540
 
1541
+ // src/templates/queries/pitcher-raw-pitches.ts
1542
+ var template13 = {
1543
+ id: "pitcher-raw-pitches",
1544
+ name: "Pitcher Raw Pitches",
1545
+ category: "pitcher",
1546
+ description: "One row per pitch with coordinate columns for visualization (movement, location)",
1547
+ preferredSources: ["savant"],
1548
+ requiredParams: ["player"],
1549
+ optionalParams: ["season", "pitchType"],
1550
+ examples: [
1551
+ 'bbdata query pitcher-raw-pitches --player "Corbin Burnes" --season 2025 --format json'
1552
+ ],
1553
+ buildQuery(params) {
1554
+ return {
1555
+ player_name: params.player,
1556
+ season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
1557
+ stat_type: "pitching",
1558
+ pitch_type: params.pitchType ? [params.pitchType] : void 0
1559
+ };
1560
+ },
1561
+ columns() {
1562
+ return [
1563
+ "pitch_type",
1564
+ "release_speed",
1565
+ "release_spin_rate",
1566
+ "pfx_x",
1567
+ "pfx_z",
1568
+ "plate_x",
1569
+ "plate_z",
1570
+ "game_date"
1571
+ ];
1572
+ },
1573
+ transform(data) {
1574
+ const pitches = data;
1575
+ if (pitches.length === 0) return [];
1576
+ return pitches.filter((p) => p.pitch_type).map((p) => ({
1577
+ pitch_type: p.pitch_type,
1578
+ release_speed: p.release_speed,
1579
+ release_spin_rate: p.release_spin_rate,
1580
+ pfx_x: p.pfx_x,
1581
+ pfx_z: p.pfx_z,
1582
+ plate_x: p.plate_x,
1583
+ plate_z: p.plate_z,
1584
+ game_date: p.game_date
1585
+ }));
1586
+ }
1587
+ };
1588
+ registerTemplate(template13);
1589
+
1590
+ // src/templates/queries/hitter-raw-bip.ts
1591
+ var template14 = {
1592
+ id: "hitter-raw-bip",
1593
+ name: "Hitter Raw Batted Balls",
1594
+ category: "hitter",
1595
+ description: "One row per batted ball with hit coordinates, exit velo, and launch angle",
1596
+ preferredSources: ["savant"],
1597
+ requiredParams: ["player"],
1598
+ optionalParams: ["season"],
1599
+ examples: [
1600
+ 'bbdata query hitter-raw-bip --player "Aaron Judge" --season 2025 --format json'
1601
+ ],
1602
+ buildQuery(params) {
1603
+ return {
1604
+ player_name: params.player,
1605
+ season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
1606
+ stat_type: "batting"
1607
+ };
1608
+ },
1609
+ columns() {
1610
+ return [
1611
+ "hc_x",
1612
+ "hc_y",
1613
+ "launch_speed",
1614
+ "launch_angle",
1615
+ "events",
1616
+ "bb_type",
1617
+ "game_date"
1618
+ ];
1619
+ },
1620
+ transform(data) {
1621
+ const pitches = data;
1622
+ if (pitches.length === 0) return [];
1623
+ return pitches.filter(
1624
+ (p) => p.launch_speed != null && p.launch_speed > 0 && p.hc_x != null && p.hc_y != null
1625
+ ).map((p) => ({
1626
+ hc_x: p.hc_x,
1627
+ hc_y: p.hc_y,
1628
+ launch_speed: p.launch_speed,
1629
+ launch_angle: p.launch_angle,
1630
+ events: p.events ?? "unknown",
1631
+ bb_type: p.bb_type ?? "unknown",
1632
+ game_date: p.game_date
1633
+ }));
1634
+ }
1635
+ };
1636
+ registerTemplate(template14);
1637
+
1638
+ // src/templates/queries/hitter-zone-grid.ts
1639
+ var ZONES2 = [
1640
+ { name: "High-In", row: 0, col: 0, xMin: -0.83, xMax: -0.28, zMin: 2.83, zMax: 3.5 },
1641
+ { name: "High-Mid", row: 0, col: 1, xMin: -0.28, xMax: 0.28, zMin: 2.83, zMax: 3.5 },
1642
+ { name: "High-Out", row: 0, col: 2, xMin: 0.28, xMax: 0.83, zMin: 2.83, zMax: 3.5 },
1643
+ { name: "Mid-In", row: 1, col: 0, xMin: -0.83, xMax: -0.28, zMin: 2.17, zMax: 2.83 },
1644
+ { name: "Mid-Mid", row: 1, col: 1, xMin: -0.28, xMax: 0.28, zMin: 2.17, zMax: 2.83 },
1645
+ { name: "Mid-Out", row: 1, col: 2, xMin: 0.28, xMax: 0.83, zMin: 2.17, zMax: 2.83 },
1646
+ { name: "Low-In", row: 2, col: 0, xMin: -0.83, xMax: -0.28, zMin: 1.5, zMax: 2.17 },
1647
+ { name: "Low-Mid", row: 2, col: 1, xMin: -0.28, xMax: 0.28, zMin: 1.5, zMax: 2.17 },
1648
+ { name: "Low-Out", row: 2, col: 2, xMin: 0.28, xMax: 0.83, zMin: 1.5, zMax: 2.17 }
1649
+ ];
1650
+ var template15 = {
1651
+ id: "hitter-zone-grid",
1652
+ name: "Hitter Zone Grid (numeric)",
1653
+ category: "hitter",
1654
+ description: "3x3 strike zone grid with numeric row/col/xwoba for heatmap visualization",
1655
+ preferredSources: ["savant"],
1656
+ requiredParams: ["player"],
1657
+ optionalParams: ["season"],
1658
+ examples: [
1659
+ 'bbdata query hitter-zone-grid --player "Shohei Ohtani" --format json'
1660
+ ],
1661
+ buildQuery(params) {
1662
+ return {
1663
+ player_name: params.player,
1664
+ season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
1665
+ stat_type: "batting"
1666
+ };
1667
+ },
1668
+ columns() {
1669
+ return ["zone", "row", "col", "pitches", "xwoba"];
1670
+ },
1671
+ transform(data) {
1672
+ const pitches = data;
1673
+ if (pitches.length === 0) return [];
1674
+ return ZONES2.map((z3) => {
1675
+ const inZone = pitches.filter(
1676
+ (p) => p.plate_x >= z3.xMin && p.plate_x < z3.xMax && p.plate_z >= z3.zMin && p.plate_z < z3.zMax
1677
+ );
1678
+ const paEnding = inZone.filter((p) => p.events != null);
1679
+ let xwobaSum = 0;
1680
+ for (const p of paEnding) {
1681
+ if (p.events === "walk") {
1682
+ xwobaSum += 0.69;
1683
+ } else if (p.events === "hit_by_pitch") {
1684
+ xwobaSum += 0.72;
1685
+ } else if (p.events === "strikeout") {
1686
+ xwobaSum += 0;
1687
+ } else {
1688
+ xwobaSum += p.estimated_woba ?? 0;
1689
+ }
1690
+ }
1691
+ const xwoba = paEnding.length > 0 ? xwobaSum / paEnding.length : 0;
1692
+ return {
1693
+ zone: z3.name,
1694
+ row: z3.row,
1695
+ col: z3.col,
1696
+ pitches: inZone.length,
1697
+ pa: paEnding.length,
1698
+ xwoba: Number(xwoba.toFixed(3))
1699
+ };
1700
+ });
1701
+ }
1702
+ };
1703
+ registerTemplate(template15);
1704
+
1524
1705
  // src/commands/query.ts
1525
1706
  async function query(options) {
1526
1707
  if (options.stdin) {
@@ -1531,8 +1712,8 @@ async function query(options) {
1531
1712
  }
1532
1713
  const config = getConfig();
1533
1714
  const outputFormat = options.format ?? config.defaultFormat;
1534
- const template13 = getTemplate(options.template);
1535
- if (!template13) {
1715
+ const template16 = getTemplate(options.template);
1716
+ if (!template16) {
1536
1717
  const available = listTemplates().map((t) => ` ${t.id} \u2014 ${t.description}`).join("\n");
1537
1718
  throw new Error(`Unknown template "${options.template}". Available templates:
1538
1719
  ${available}`);
@@ -1549,13 +1730,13 @@ ${available}`);
1549
1730
  top: options.top,
1550
1731
  seasons: options.seasons
1551
1732
  };
1552
- for (const req of template13.requiredParams) {
1733
+ for (const req of template16.requiredParams) {
1553
1734
  if (!params[req] && !(req === "players" && params.player)) {
1554
- throw new Error(`Template "${template13.id}" requires --${req}`);
1735
+ throw new Error(`Template "${template16.id}" requires --${req}`);
1555
1736
  }
1556
1737
  }
1557
- const adapterQuery = template13.buildQuery(params);
1558
- const preferredSources = options.source ? [options.source] : template13.preferredSources;
1738
+ const adapterQuery = template16.buildQuery(params);
1739
+ const preferredSources = options.source ? [options.source] : template16.preferredSources;
1559
1740
  const adapters2 = resolveAdapters(preferredSources);
1560
1741
  let lastError;
1561
1742
  let result;
@@ -1567,8 +1748,8 @@ ${available}`);
1567
1748
  const adapterResult = await adapter.fetch(adapterQuery, {
1568
1749
  bypassCache: options.cache === false
1569
1750
  });
1570
- const rows = template13.transform(adapterResult.data, params);
1571
- const columns = template13.columns(params);
1751
+ const rows = template16.transform(adapterResult.data, params);
1752
+ const columns = template16.columns(params);
1572
1753
  if (rows.length === 0) {
1573
1754
  log.debug(`${adapter.source} returned 0 rows. Trying next source...`);
1574
1755
  continue;
@@ -1576,8 +1757,8 @@ ${available}`);
1576
1757
  result = {
1577
1758
  rows,
1578
1759
  columns,
1579
- title: template13.name,
1580
- description: template13.description,
1760
+ title: template16.name,
1761
+ description: template16.description,
1581
1762
  source: adapter.source,
1582
1763
  cached: adapterResult.cached
1583
1764
  };
@@ -1597,13 +1778,13 @@ ${available}`);
1597
1778
  queryTimeMs,
1598
1779
  season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
1599
1780
  sampleSize: result.rows.length,
1600
- template: template13.id
1781
+ template: template16.id
1601
1782
  }, outputFormat, { columns: result.columns });
1602
1783
  return {
1603
1784
  data: result.rows,
1604
1785
  formatted: output.formatted,
1605
1786
  meta: {
1606
- template: template13.id,
1787
+ template: template16.id,
1607
1788
  source: result.source,
1608
1789
  cached: result.cached,
1609
1790
  rowCount: result.rows.length,
@@ -1643,10 +1824,660 @@ function formatGrade(grade) {
1643
1824
  return `${gradeColor(grade)} (${gradeLabel(grade)})`;
1644
1825
  }
1645
1826
 
1827
+ // src/commands/viz.ts
1828
+ import { writeFileSync as writeFileSync2 } from "fs";
1829
+ import { resolve as resolvePath } from "path";
1830
+
1831
+ // src/viz/audience.ts
1832
+ var AUDIENCE_DEFAULTS = {
1833
+ coach: {
1834
+ width: 800,
1835
+ height: 600,
1836
+ titleFontSize: 22,
1837
+ axisLabelFontSize: 18,
1838
+ axisTitleFontSize: 18,
1839
+ legendLabelFontSize: 16,
1840
+ legendTitleFontSize: 16,
1841
+ scheme: "tableau10",
1842
+ labelDensity: "low",
1843
+ padding: 24
1844
+ },
1845
+ analyst: {
1846
+ width: 640,
1847
+ height: 480,
1848
+ titleFontSize: 16,
1849
+ axisLabelFontSize: 12,
1850
+ axisTitleFontSize: 13,
1851
+ legendLabelFontSize: 11,
1852
+ legendTitleFontSize: 12,
1853
+ scheme: "tableau10",
1854
+ labelDensity: "high",
1855
+ padding: 12
1856
+ },
1857
+ frontoffice: {
1858
+ width: 720,
1859
+ height: 540,
1860
+ titleFontSize: 18,
1861
+ axisLabelFontSize: 13,
1862
+ axisTitleFontSize: 14,
1863
+ legendLabelFontSize: 12,
1864
+ legendTitleFontSize: 13,
1865
+ scheme: "tableau10",
1866
+ labelDensity: "medium",
1867
+ padding: 16
1868
+ },
1869
+ presentation: {
1870
+ width: 960,
1871
+ height: 720,
1872
+ titleFontSize: 24,
1873
+ axisLabelFontSize: 16,
1874
+ axisTitleFontSize: 18,
1875
+ legendLabelFontSize: 14,
1876
+ legendTitleFontSize: 16,
1877
+ scheme: "tableau10",
1878
+ labelDensity: "low",
1879
+ padding: 24
1880
+ }
1881
+ };
1882
+ function audienceConfig(audience, colorblind) {
1883
+ const d = AUDIENCE_DEFAULTS[audience];
1884
+ return {
1885
+ font: "Arial, Helvetica, sans-serif",
1886
+ padding: d.padding,
1887
+ title: {
1888
+ fontSize: d.titleFontSize,
1889
+ anchor: "start",
1890
+ font: "Arial, Helvetica, sans-serif"
1891
+ },
1892
+ axis: {
1893
+ labelFontSize: d.axisLabelFontSize,
1894
+ titleFontSize: d.axisTitleFontSize,
1895
+ labelFont: "Arial, Helvetica, sans-serif",
1896
+ titleFont: "Arial, Helvetica, sans-serif",
1897
+ grid: true
1898
+ },
1899
+ legend: {
1900
+ labelFontSize: d.legendLabelFontSize,
1901
+ titleFontSize: d.legendTitleFontSize,
1902
+ labelFont: "Arial, Helvetica, sans-serif",
1903
+ titleFont: "Arial, Helvetica, sans-serif"
1904
+ },
1905
+ range: colorblind ? { category: { scheme: "viridis" }, ramp: { scheme: "viridis" } } : { category: { scheme: d.scheme } },
1906
+ view: { stroke: "transparent" },
1907
+ background: "white"
1908
+ };
1909
+ }
1910
+
1911
+ // src/viz/charts/movement.ts
1912
+ var movementBuilder = {
1913
+ id: "movement",
1914
+ dataRequirements: [
1915
+ { queryTemplate: "pitcher-raw-pitches", required: true }
1916
+ ],
1917
+ defaultTitle({ player, season }) {
1918
+ return `${player} \u2014 Pitch Movement (${season})`;
1919
+ },
1920
+ buildSpec(rows, options) {
1921
+ const pitches = rows["pitcher-raw-pitches"] ?? [];
1922
+ const values = pitches.map((p) => ({
1923
+ pitch_type: p.pitch_type,
1924
+ hBreak: -p.pfx_x * 12,
1925
+ // feet → inches, flipped
1926
+ vBreak: p.pfx_z * 12,
1927
+ velo: p.release_speed
1928
+ }));
1929
+ return {
1930
+ $schema: "https://vega.github.io/schema/vega-lite/v5.json",
1931
+ title: options.title,
1932
+ width: options.width,
1933
+ height: options.height,
1934
+ data: { values },
1935
+ layer: [
1936
+ {
1937
+ mark: { type: "rule", stroke: "#888", strokeDash: [4, 4] },
1938
+ encoding: { x: { datum: 0 } }
1939
+ },
1940
+ {
1941
+ mark: { type: "rule", stroke: "#888", strokeDash: [4, 4] },
1942
+ encoding: { y: { datum: 0 } }
1943
+ },
1944
+ {
1945
+ mark: { type: "point", filled: true, opacity: 0.65, size: 60 },
1946
+ encoding: {
1947
+ x: {
1948
+ field: "hBreak",
1949
+ type: "quantitative",
1950
+ scale: { domain: [-25, 25] },
1951
+ axis: { title: "Horizontal Break (in, catcher POV)" }
1952
+ },
1953
+ y: {
1954
+ field: "vBreak",
1955
+ type: "quantitative",
1956
+ scale: { domain: [-25, 25] },
1957
+ axis: { title: "Induced Vertical Break (in)" }
1958
+ },
1959
+ color: {
1960
+ field: "pitch_type",
1961
+ type: "nominal",
1962
+ legend: { title: "Pitch" }
1963
+ },
1964
+ tooltip: [
1965
+ { field: "pitch_type", title: "Type" },
1966
+ { field: "velo", title: "Velo (mph)", format: ".1f" },
1967
+ { field: "hBreak", title: "H Break (in)", format: ".1f" },
1968
+ { field: "vBreak", title: "V Break (in)", format: ".1f" }
1969
+ ]
1970
+ }
1971
+ },
1972
+ {
1973
+ mark: {
1974
+ type: "point",
1975
+ shape: "cross",
1976
+ size: 500,
1977
+ strokeWidth: 3,
1978
+ filled: false
1979
+ },
1980
+ encoding: {
1981
+ x: { aggregate: "mean", field: "hBreak", type: "quantitative" },
1982
+ y: { aggregate: "mean", field: "vBreak", type: "quantitative" },
1983
+ color: { field: "pitch_type", type: "nominal" },
1984
+ detail: { field: "pitch_type" }
1985
+ }
1986
+ }
1987
+ ],
1988
+ config: audienceConfig(options.audience, options.colorblind)
1989
+ };
1990
+ }
1991
+ };
1992
+
1993
+ // src/viz/charts/spray.ts
1994
+ var sprayBuilder = {
1995
+ id: "spray",
1996
+ dataRequirements: [
1997
+ { queryTemplate: "hitter-raw-bip", required: true }
1998
+ ],
1999
+ defaultTitle({ player, season }) {
2000
+ return `${player} \u2014 Spray Chart (${season})`;
2001
+ },
2002
+ buildSpec(rows, options) {
2003
+ const bip = rows["hitter-raw-bip"] ?? [];
2004
+ const SCALE = 2.5;
2005
+ const points = bip.map((b) => ({
2006
+ x: (b.hc_x - 125.42) * SCALE,
2007
+ y: (204 - b.hc_y) * SCALE,
2008
+ launch_speed: b.launch_speed ?? 0,
2009
+ launch_angle: b.launch_angle ?? 0,
2010
+ events: b.events
2011
+ }));
2012
+ const arc = Array.from({ length: 37 }, (_, i) => {
2013
+ const t = Math.PI / 4 + Math.PI / 2 * (i / 36);
2014
+ return { x: Math.cos(t) * 420 * -1, y: Math.sin(t) * 420 };
2015
+ });
2016
+ return {
2017
+ $schema: "https://vega.github.io/schema/vega-lite/v5.json",
2018
+ title: options.title,
2019
+ width: options.width,
2020
+ height: options.height,
2021
+ layer: [
2022
+ // Batted-ball points (first layer controls scales/axes for the chart)
2023
+ {
2024
+ data: { values: points },
2025
+ mark: { type: "circle", opacity: 0.75, stroke: "#333", strokeWidth: 0.5 },
2026
+ encoding: {
2027
+ x: {
2028
+ field: "x",
2029
+ type: "quantitative",
2030
+ scale: { domain: [-450, 450] },
2031
+ axis: null
2032
+ },
2033
+ y: {
2034
+ field: "y",
2035
+ type: "quantitative",
2036
+ scale: { domain: [-50, 500] },
2037
+ axis: null
2038
+ },
2039
+ size: {
2040
+ field: "launch_speed",
2041
+ type: "quantitative",
2042
+ scale: { domain: [60, 115], range: [40, 400] },
2043
+ legend: { title: "Exit Velo" }
2044
+ },
2045
+ color: {
2046
+ field: "events",
2047
+ type: "nominal",
2048
+ scale: {
2049
+ domain: [
2050
+ "single",
2051
+ "double",
2052
+ "triple",
2053
+ "home_run",
2054
+ "field_out",
2055
+ "force_out",
2056
+ "grounded_into_double_play"
2057
+ ],
2058
+ range: [
2059
+ "#4e79a7",
2060
+ "#59a14f",
2061
+ "#edc948",
2062
+ "#e15759",
2063
+ "#bab0ac",
2064
+ "#bab0ac",
2065
+ "#bab0ac"
2066
+ ]
2067
+ },
2068
+ legend: { title: "Result" }
2069
+ },
2070
+ tooltip: [
2071
+ { field: "events", title: "Result" },
2072
+ { field: "launch_speed", title: "EV", format: ".1f" },
2073
+ { field: "launch_angle", title: "LA", format: ".0f" }
2074
+ ]
2075
+ }
2076
+ },
2077
+ // Foul lines — left field
2078
+ {
2079
+ data: { values: [{ x: 0, y: 0 }, { x: -297, y: 297 }] },
2080
+ mark: { type: "line", stroke: "#888", strokeWidth: 1.5 },
2081
+ encoding: {
2082
+ x: { field: "x", type: "quantitative" },
2083
+ y: { field: "y", type: "quantitative" }
2084
+ }
2085
+ },
2086
+ // Foul lines — right field
2087
+ {
2088
+ data: { values: [{ x: 0, y: 0 }, { x: 297, y: 297 }] },
2089
+ mark: { type: "line", stroke: "#888", strokeWidth: 1.5 },
2090
+ encoding: {
2091
+ x: { field: "x", type: "quantitative" },
2092
+ y: { field: "y", type: "quantitative" }
2093
+ }
2094
+ },
2095
+ // Outfield arc
2096
+ {
2097
+ data: { values: arc },
2098
+ mark: { type: "line", stroke: "#999", strokeDash: [6, 4], strokeWidth: 1.5 },
2099
+ encoding: {
2100
+ x: { field: "x", type: "quantitative" },
2101
+ y: { field: "y", type: "quantitative" }
2102
+ }
2103
+ }
2104
+ ],
2105
+ config: {
2106
+ ...audienceConfig(options.audience, options.colorblind),
2107
+ axis: { grid: false, domain: false, ticks: false, labels: false }
2108
+ }
2109
+ };
2110
+ }
2111
+ };
2112
+
2113
+ // src/viz/charts/zone.ts
2114
+ var zoneBuilder = {
2115
+ id: "zone",
2116
+ dataRequirements: [
2117
+ { queryTemplate: "hitter-zone-grid", required: true }
2118
+ ],
2119
+ defaultTitle({ player, season }) {
2120
+ return `${player} \u2014 Zone Profile, xwOBA (${season})`;
2121
+ },
2122
+ buildSpec(rows, options) {
2123
+ const grid = rows["hitter-zone-grid"] ?? [];
2124
+ return {
2125
+ $schema: "https://vega.github.io/schema/vega-lite/v5.json",
2126
+ title: options.title,
2127
+ width: options.width,
2128
+ height: options.height,
2129
+ data: { values: grid },
2130
+ layer: [
2131
+ {
2132
+ mark: { type: "rect", stroke: "#222", strokeWidth: 1.5 },
2133
+ encoding: {
2134
+ x: {
2135
+ field: "col",
2136
+ type: "ordinal",
2137
+ axis: { title: "Inside \u2192 Outside", labels: false, ticks: false }
2138
+ },
2139
+ y: {
2140
+ field: "row",
2141
+ type: "ordinal",
2142
+ axis: { title: "High \u2192 Low", labels: false, ticks: false }
2143
+ },
2144
+ color: {
2145
+ field: "xwoba",
2146
+ type: "quantitative",
2147
+ // Domain covers the league-wide realistic range for xwOBA
2148
+ // (~.200 is Mendoza-esque; ~.500 is MVP-tier).
2149
+ // `clamp: true` caps values outside the range to the endpoint
2150
+ // colors so elite hitters still render cleanly.
2151
+ scale: options.colorblind ? { scheme: "viridis", domain: [0.2, 0.5], clamp: true } : {
2152
+ scheme: "redyellowblue",
2153
+ reverse: true,
2154
+ domain: [0.2, 0.5],
2155
+ clamp: true
2156
+ },
2157
+ legend: { title: "xwOBA" }
2158
+ },
2159
+ tooltip: [
2160
+ { field: "zone", title: "Zone" },
2161
+ { field: "pitches", title: "Pitches" },
2162
+ { field: "pa", title: "PAs" },
2163
+ { field: "xwoba", title: "xwOBA", format: ".3f" }
2164
+ ]
2165
+ }
2166
+ },
2167
+ {
2168
+ mark: {
2169
+ type: "text",
2170
+ fontSize: 18,
2171
+ fontWeight: "bold",
2172
+ // Halo stroke keeps text legible against every cell color —
2173
+ // light (yellow) and dark (saturated red or blue) alike.
2174
+ stroke: "white",
2175
+ strokeWidth: 3,
2176
+ strokeOpacity: 0.9,
2177
+ paintOrder: "stroke"
2178
+ },
2179
+ encoding: {
2180
+ x: { field: "col", type: "ordinal" },
2181
+ y: { field: "row", type: "ordinal" },
2182
+ text: { field: "xwoba", type: "quantitative", format: ".3f" },
2183
+ color: { value: "black" }
2184
+ }
2185
+ }
2186
+ ],
2187
+ config: audienceConfig(options.audience, options.colorblind)
2188
+ };
2189
+ }
2190
+ };
2191
+
2192
+ // src/viz/charts/rolling.ts
2193
+ function parseNumeric(v) {
2194
+ if (v == null) return null;
2195
+ if (typeof v === "number") return Number.isFinite(v) ? v : null;
2196
+ const s = String(v).replace(/[^\d.\-]/g, "");
2197
+ if (!s) return null;
2198
+ const n = parseFloat(s);
2199
+ return Number.isFinite(n) ? n : null;
2200
+ }
2201
+ var rollingBuilder = {
2202
+ id: "rolling",
2203
+ dataRequirements: [
2204
+ { queryTemplate: "trend-rolling-average", required: true }
2205
+ ],
2206
+ defaultTitle({ player, season }) {
2207
+ return `${player} \u2014 Rolling Performance (${season})`;
2208
+ },
2209
+ buildSpec(rows, options) {
2210
+ const wideRows = rows["trend-rolling-average"] ?? [];
2211
+ const preferredKeys = ["Window End", "window_end", "Date", "date", "End Date"];
2212
+ let dateKey = null;
2213
+ if (wideRows.length > 0) {
2214
+ const first = wideRows[0];
2215
+ for (const k of preferredKeys) {
2216
+ if (k in first && isParseableDate(first[k])) {
2217
+ dateKey = k;
2218
+ break;
2219
+ }
2220
+ }
2221
+ }
2222
+ const metricKeys = /* @__PURE__ */ new Set();
2223
+ const excluded = new Set([dateKey, "Window", "Games"].filter(Boolean));
2224
+ for (const r of wideRows) {
2225
+ for (const k of Object.keys(r)) {
2226
+ if (excluded.has(k)) continue;
2227
+ if (parseNumeric(r[k]) != null) metricKeys.add(k);
2228
+ }
2229
+ }
2230
+ const tidy = [];
2231
+ for (const r of wideRows) {
2232
+ const date = dateKey ? String(r[dateKey] ?? "") : "";
2233
+ if (!date || !isParseableDate(date)) continue;
2234
+ for (const k of metricKeys) {
2235
+ const n = parseNumeric(r[k]);
2236
+ if (n != null) tidy.push({ window_end: date, metric: k, value: n });
2237
+ }
2238
+ }
2239
+ if (tidy.length === 0) {
2240
+ return {
2241
+ $schema: "https://vega.github.io/schema/vega-lite/v5.json",
2242
+ title: options.title,
2243
+ width: options.width,
2244
+ height: options.height,
2245
+ data: { values: [{ msg: "Insufficient data for rolling trend (need 15+ games)" }] },
2246
+ mark: { type: "text", fontSize: 14, color: "#888" },
2247
+ encoding: { text: { field: "msg", type: "nominal" } },
2248
+ config: audienceConfig(options.audience, options.colorblind)
2249
+ };
2250
+ }
2251
+ const metricOrder = Array.from(metricKeys);
2252
+ const panelHeight = Math.max(80, Math.floor(options.height / metricOrder.length) - 30);
2253
+ return {
2254
+ $schema: "https://vega.github.io/schema/vega-lite/v5.json",
2255
+ title: options.title,
2256
+ data: { values: tidy },
2257
+ facet: {
2258
+ row: {
2259
+ field: "metric",
2260
+ type: "nominal",
2261
+ title: null,
2262
+ header: { labelAngle: 0, labelAlign: "left", labelFontWeight: "bold" },
2263
+ sort: metricOrder
2264
+ }
2265
+ },
2266
+ spec: {
2267
+ width: options.width - 120,
2268
+ height: panelHeight,
2269
+ layer: [
2270
+ {
2271
+ mark: { type: "line", point: true, strokeWidth: 2 },
2272
+ encoding: {
2273
+ x: {
2274
+ field: "window_end",
2275
+ type: "temporal",
2276
+ axis: { title: "Window End", format: "%b %d" }
2277
+ },
2278
+ y: {
2279
+ field: "value",
2280
+ type: "quantitative",
2281
+ axis: { title: null },
2282
+ scale: { zero: false }
2283
+ },
2284
+ color: {
2285
+ field: "metric",
2286
+ type: "nominal",
2287
+ legend: null
2288
+ },
2289
+ tooltip: [
2290
+ { field: "window_end", type: "temporal", format: "%Y-%m-%d" },
2291
+ { field: "metric", title: "Metric" },
2292
+ { field: "value", title: "Value", format: ".3f" }
2293
+ ]
2294
+ }
2295
+ },
2296
+ {
2297
+ mark: { type: "rule", strokeDash: [4, 4], opacity: 0.4 },
2298
+ encoding: {
2299
+ y: { aggregate: "mean", field: "value", type: "quantitative" },
2300
+ color: { field: "metric", type: "nominal", legend: null }
2301
+ }
2302
+ }
2303
+ ]
2304
+ },
2305
+ resolve: { scale: { y: "independent" } },
2306
+ config: audienceConfig(options.audience, options.colorblind)
2307
+ };
2308
+ }
2309
+ };
2310
+ function isParseableDate(v) {
2311
+ if (v == null || v === "") return false;
2312
+ const s = String(v);
2313
+ if (!/[-/]/.test(s)) return false;
2314
+ const t = Date.parse(s);
2315
+ return Number.isFinite(t);
2316
+ }
2317
+
2318
+ // src/viz/charts/index.ts
2319
+ var builders = {
2320
+ movement: movementBuilder,
2321
+ spray: sprayBuilder,
2322
+ zone: zoneBuilder,
2323
+ rolling: rollingBuilder
2324
+ };
2325
+ function getChartBuilder(type) {
2326
+ const b = builders[type];
2327
+ if (!b) {
2328
+ throw new Error(
2329
+ `Unknown chart type: "${type}". Available: ${Object.keys(builders).join(", ")}`
2330
+ );
2331
+ }
2332
+ return b;
2333
+ }
2334
+
2335
+ // src/viz/render.ts
2336
+ import { parse as vegaParse, View, Warn } from "vega";
2337
+ import { compile } from "vega-lite";
2338
+ async function specToSvg(vlSpec) {
2339
+ const { spec: vgSpec } = compile(vlSpec);
2340
+ const runtime = vegaParse(vgSpec);
2341
+ const view = new View(runtime, { renderer: "none" });
2342
+ view.logLevel(Warn);
2343
+ const svg = await view.toSVG();
2344
+ view.finalize();
2345
+ return ensureTextPaintOrder(svg);
2346
+ }
2347
+ function ensureTextPaintOrder(svg) {
2348
+ return svg.replace(/<text\b([^>]*)>/g, (match, attrs) => {
2349
+ if (/\bpaint-order\s*=/.test(attrs)) return match;
2350
+ if (!/\bfill\s*=/.test(attrs)) return match;
2351
+ if (!/\bstroke\s*=/.test(attrs)) return match;
2352
+ return `<text${attrs} paint-order="stroke">`;
2353
+ });
2354
+ }
2355
+
2356
+ // src/viz/types.ts
2357
+ function resolveVizAudience(a) {
2358
+ if (!a) return "analyst";
2359
+ switch (a) {
2360
+ case "gm":
2361
+ return "frontoffice";
2362
+ case "scout":
2363
+ return "analyst";
2364
+ case "coach":
2365
+ case "analyst":
2366
+ case "frontoffice":
2367
+ case "presentation":
2368
+ return a;
2369
+ default:
2370
+ return "analyst";
2371
+ }
2372
+ }
2373
+
2374
+ // src/commands/viz.ts
2375
+ async function viz(options) {
2376
+ if (options.stdin) {
2377
+ const raw = await readStdin();
2378
+ getStdinAdapter().load(raw);
2379
+ }
2380
+ const config = getConfig();
2381
+ const audience = resolveVizAudience(
2382
+ options.audience ?? config.defaultAudience
2383
+ );
2384
+ const defaults = AUDIENCE_DEFAULTS[audience];
2385
+ const builder = getChartBuilder(options.type);
2386
+ const season = options.season ?? (/* @__PURE__ */ new Date()).getFullYear();
2387
+ const player = options.player ?? "Unknown";
2388
+ const width = options.width ?? defaults.width;
2389
+ const height = options.height ?? defaults.height;
2390
+ const rows = {};
2391
+ let source = "unknown";
2392
+ for (const req of builder.dataRequirements) {
2393
+ try {
2394
+ const result = await query({
2395
+ template: req.queryTemplate,
2396
+ player: options.player,
2397
+ season,
2398
+ format: "json",
2399
+ ...options.stdin ? { source: "stdin" } : {},
2400
+ ...options.source && !options.stdin ? { source: options.source } : {}
2401
+ });
2402
+ rows[req.queryTemplate] = result.data;
2403
+ if (result.meta.source) source = result.meta.source;
2404
+ } catch (err) {
2405
+ if (req.required) throw err;
2406
+ rows[req.queryTemplate] = [];
2407
+ }
2408
+ }
2409
+ const resolved = {
2410
+ type: options.type,
2411
+ player,
2412
+ season,
2413
+ audience,
2414
+ format: options.format ?? "svg",
2415
+ width,
2416
+ height,
2417
+ colorblind: options.colorblind ?? false,
2418
+ title: options.title ?? builder.defaultTitle({ player, season }),
2419
+ players: options.players
2420
+ };
2421
+ const spec = builder.buildSpec(rows, resolved);
2422
+ const svg = await specToSvg(spec);
2423
+ if (options.output) {
2424
+ writeFileSync2(resolvePath(options.output), svg, "utf-8");
2425
+ log.success(`Wrote ${options.output}`);
2426
+ }
2427
+ return {
2428
+ svg,
2429
+ spec,
2430
+ meta: {
2431
+ chartType: options.type,
2432
+ player,
2433
+ season,
2434
+ audience,
2435
+ rowCount: Object.values(rows).reduce((a, r) => a + r.length, 0),
2436
+ source,
2437
+ width,
2438
+ height
2439
+ }
2440
+ };
2441
+ }
2442
+
2443
+ // src/viz/embed.ts
2444
+ var REPORT_GRAPH_MAP = {
2445
+ "advance-sp": [
2446
+ { slot: "movementChart", type: "movement" }
2447
+ ],
2448
+ "pro-pitcher-eval": [
2449
+ { slot: "movementChart", type: "movement" },
2450
+ { slot: "rollingChart", type: "rolling" }
2451
+ ],
2452
+ "pro-hitter-eval": [
2453
+ { slot: "sprayChart", type: "spray" },
2454
+ { slot: "zoneChart", type: "zone" }
2455
+ ]
2456
+ };
2457
+ async function generateReportGraphs(reportId, player, season, audience, opts = {}) {
2458
+ const slots = REPORT_GRAPH_MAP[reportId] ?? [];
2459
+ const out = {};
2460
+ for (const { slot, type } of slots) {
2461
+ try {
2462
+ const r = await viz({
2463
+ type,
2464
+ player,
2465
+ season,
2466
+ audience,
2467
+ ...opts.stdin ? { source: "stdin" } : {}
2468
+ });
2469
+ out[slot] = r.svg;
2470
+ } catch {
2471
+ out[slot] = "";
2472
+ }
2473
+ }
2474
+ return out;
2475
+ }
2476
+
1646
2477
  // src/templates/reports/registry.ts
1647
2478
  var templates2 = /* @__PURE__ */ new Map();
1648
- function registerReportTemplate(template13) {
1649
- templates2.set(template13.id, template13);
2479
+ function registerReportTemplate(template16) {
2480
+ templates2.set(template16.id, template16);
1650
2481
  }
1651
2482
  function getReportTemplate(id) {
1652
2483
  return templates2.get(id);
@@ -1828,6 +2659,10 @@ Handlebars.registerHelper("compare", (value, leagueAvg) => {
1828
2659
  Handlebars.registerHelper("ifGt", function(a, b, options) {
1829
2660
  return a > b ? options.fn(this) : options.inverse(this);
1830
2661
  });
2662
+ Handlebars.registerHelper(
2663
+ "svgOrEmpty",
2664
+ (svg) => new Handlebars.SafeString(svg ?? "")
2665
+ );
1831
2666
  var __dirname2 = dirname(fileURLToPath(import.meta.url));
1832
2667
  var BUNDLED_TEMPLATES_DIR = join2(__dirname2, "..", "templates", "reports");
1833
2668
  function loadTemplate(templateFile) {
@@ -1844,14 +2679,14 @@ function loadTemplate(templateFile) {
1844
2679
  }
1845
2680
  function generateFallbackTemplate(templateFile) {
1846
2681
  const templateId = templateFile.replace(".hbs", "");
1847
- const template13 = getReportTemplate(templateId);
1848
- if (!template13) return "# Report\n\n{{data}}";
1849
- const sections = template13.requiredSections.map((s) => `## ${s}
2682
+ const template16 = getReportTemplate(templateId);
2683
+ if (!template16) return "# Report\n\n{{data}}";
2684
+ const sections = template16.requiredSections.map((s) => `## ${s}
1850
2685
 
1851
2686
  {{!-- ${s} data goes here --}}
1852
2687
  *Data pending*
1853
2688
  `).join("\n");
1854
- return `# ${template13.name}
2689
+ return `# ${template16.name}
1855
2690
 
1856
2691
  **Player:** {{player}}
1857
2692
  **Season:** {{season}}
@@ -1874,8 +2709,8 @@ async function report(options) {
1874
2709
  }
1875
2710
  const config = getConfig();
1876
2711
  const audience = options.audience ?? config.defaultAudience;
1877
- const template13 = getReportTemplate(options.template);
1878
- if (!template13) {
2712
+ const template16 = getReportTemplate(options.template);
2713
+ if (!template16) {
1879
2714
  const available = listReportTemplates().map((t) => ` ${t.id} \u2014 ${t.description}`).join("\n");
1880
2715
  throw new Error(`Unknown report template "${options.template}". Available:
1881
2716
  ${available}`);
@@ -1884,7 +2719,7 @@ ${available}`);
1884
2719
  const player = options.player ?? "Unknown";
1885
2720
  const dataResults = {};
1886
2721
  const dataSources = [];
1887
- for (const req of template13.dataRequirements) {
2722
+ for (const req of template16.dataRequirements) {
1888
2723
  try {
1889
2724
  const result = await query({
1890
2725
  template: req.queryTemplate,
@@ -1905,7 +2740,14 @@ ${available}`);
1905
2740
  dataResults[req.queryTemplate] = null;
1906
2741
  }
1907
2742
  }
1908
- const hbsSource = loadTemplate(template13.templateFile);
2743
+ const graphs = await generateReportGraphs(
2744
+ template16.id,
2745
+ player,
2746
+ season,
2747
+ audience,
2748
+ { stdin: options.stdin }
2749
+ );
2750
+ const hbsSource = loadTemplate(template16.templateFile);
1909
2751
  const compiled = Handlebars.compile(hbsSource);
1910
2752
  const content = compiled({
1911
2753
  player,
@@ -1914,19 +2756,20 @@ ${available}`);
1914
2756
  date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
1915
2757
  sources: dataSources.join(", ") || "none",
1916
2758
  data: dataResults,
2759
+ graphs,
1917
2760
  ...dataResults
1918
2761
  });
1919
2762
  let validation;
1920
2763
  if (options.validate) {
1921
- validation = validateReport(content, template13.requiredSections);
2764
+ validation = validateReport(content, template16.requiredSections);
1922
2765
  }
1923
- const formatted = options.format === "json" ? JSON.stringify({ content, validation, meta: { template: template13.id, player, audience, season, dataSources } }, null, 2) + "\n" : content + "\n";
2766
+ const formatted = options.format === "json" ? JSON.stringify({ content, validation, meta: { template: template16.id, player, audience, season, dataSources } }, null, 2) + "\n" : content + "\n";
1924
2767
  return {
1925
2768
  content,
1926
2769
  formatted,
1927
2770
  validation,
1928
2771
  meta: {
1929
- template: template13.id,
2772
+ template: template16.id,
1930
2773
  player,
1931
2774
  audience,
1932
2775
  season,
@@ -1962,6 +2805,7 @@ export {
1962
2805
  getConfig,
1963
2806
  query,
1964
2807
  report,
1965
- setConfig
2808
+ setConfig,
2809
+ viz
1966
2810
  };
1967
2811
  //# sourceMappingURL=index.js.map