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/bin/bbdata.js +935 -30
- package/dist/bin/bbdata.js.map +1 -1
- package/dist/src/index.d.ts +52 -1
- package/dist/src/index.js +875 -31
- package/dist/src/index.js.map +1 -1
- package/dist/templates/queries/hitter-raw-bip.ts +65 -0
- package/dist/templates/queries/hitter-zone-grid.ts +90 -0
- package/dist/templates/queries/index.ts +27 -24
- package/dist/templates/queries/pitcher-raw-pitches.ts +62 -0
- package/dist/templates/queries/trend-rolling-average.ts +15 -3
- package/dist/templates/reports/advance-sp.hbs +66 -60
- package/dist/templates/reports/pro-hitter-eval.hbs +77 -65
- package/dist/templates/reports/pro-pitcher-eval.hbs +81 -69
- package/package.json +68 -63
- package/src/templates/reports/advance-sp.hbs +66 -60
- package/src/templates/reports/pro-hitter-eval.hbs +77 -65
- package/src/templates/reports/pro-pitcher-eval.hbs +81 -69
package/dist/bin/bbdata.js
CHANGED
|
@@ -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,
|
|
@@ -669,8 +671,8 @@ function readStdin() {
|
|
|
669
671
|
|
|
670
672
|
// src/templates/queries/registry.ts
|
|
671
673
|
var templates = /* @__PURE__ */ new Map();
|
|
672
|
-
function registerTemplate(
|
|
673
|
-
templates.set(
|
|
674
|
+
function registerTemplate(template16) {
|
|
675
|
+
templates.set(template16.id, template16);
|
|
674
676
|
}
|
|
675
677
|
function getTemplate(id) {
|
|
676
678
|
return templates.get(id);
|
|
@@ -713,6 +715,10 @@ var PitchDataSchema = z2.object({
|
|
|
713
715
|
// exit velocity (mph)
|
|
714
716
|
launch_angle: z2.number().nullable(),
|
|
715
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)
|
|
716
722
|
description: z2.string(),
|
|
717
723
|
// called_strike, swinging_strike, ball, foul, hit_into_play, etc.
|
|
718
724
|
events: z2.string().nullable(),
|
|
@@ -1410,7 +1416,7 @@ var template11 = {
|
|
|
1410
1416
|
};
|
|
1411
1417
|
},
|
|
1412
1418
|
columns() {
|
|
1413
|
-
return ["Window", "Games", "AVG", "SLG", "K %", "Avg EV", "Hard Hit %"];
|
|
1419
|
+
return ["Window", "Window End", "Games", "AVG", "SLG", "K %", "Avg EV", "Hard Hit %"];
|
|
1414
1420
|
},
|
|
1415
1421
|
transform(data) {
|
|
1416
1422
|
const pitches = data;
|
|
@@ -1424,7 +1430,16 @@ var template11 = {
|
|
|
1424
1430
|
const dates = Array.from(byDate.keys()).sort();
|
|
1425
1431
|
const windowSize = 15;
|
|
1426
1432
|
if (dates.length < windowSize) {
|
|
1427
|
-
return [{
|
|
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
|
+
}];
|
|
1428
1443
|
}
|
|
1429
1444
|
const results = [];
|
|
1430
1445
|
for (let i = 0; i <= dates.length - windowSize; i += Math.max(1, Math.floor(windowSize / 3))) {
|
|
@@ -1443,8 +1458,10 @@ var template11 = {
|
|
|
1443
1458
|
const batted = windowPitches.filter((p) => p.launch_speed !== null && p.launch_speed > 0);
|
|
1444
1459
|
const avgEv = batted.length > 0 ? batted.reduce((s, p) => s + p.launch_speed, 0) / batted.length : null;
|
|
1445
1460
|
const hardHit = batted.filter((p) => p.launch_speed >= 95).length;
|
|
1461
|
+
const windowEnd = windowDates[windowDates.length - 1];
|
|
1446
1462
|
results.push({
|
|
1447
|
-
Window: `${windowDates[0]} \u2192 ${
|
|
1463
|
+
Window: `${windowDates[0]} \u2192 ${windowEnd}`,
|
|
1464
|
+
"Window End": windowEnd,
|
|
1448
1465
|
Games: windowDates.length,
|
|
1449
1466
|
AVG: pas.length > 0 ? (hits.length / pas.length).toFixed(3) : "\u2014",
|
|
1450
1467
|
SLG: pas.length > 0 ? (totalBases / pas.length).toFixed(3) : "\u2014",
|
|
@@ -1516,6 +1533,170 @@ function formatVal(n) {
|
|
|
1516
1533
|
}
|
|
1517
1534
|
registerTemplate(template12);
|
|
1518
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
|
+
|
|
1519
1700
|
// src/commands/query.ts
|
|
1520
1701
|
async function query(options) {
|
|
1521
1702
|
if (options.stdin) {
|
|
@@ -1526,8 +1707,8 @@ async function query(options) {
|
|
|
1526
1707
|
}
|
|
1527
1708
|
const config = getConfig();
|
|
1528
1709
|
const outputFormat = options.format ?? config.defaultFormat;
|
|
1529
|
-
const
|
|
1530
|
-
if (!
|
|
1710
|
+
const template16 = getTemplate(options.template);
|
|
1711
|
+
if (!template16) {
|
|
1531
1712
|
const available = listTemplates().map((t) => ` ${t.id} \u2014 ${t.description}`).join("\n");
|
|
1532
1713
|
throw new Error(`Unknown template "${options.template}". Available templates:
|
|
1533
1714
|
${available}`);
|
|
@@ -1544,13 +1725,13 @@ ${available}`);
|
|
|
1544
1725
|
top: options.top,
|
|
1545
1726
|
seasons: options.seasons
|
|
1546
1727
|
};
|
|
1547
|
-
for (const req of
|
|
1728
|
+
for (const req of template16.requiredParams) {
|
|
1548
1729
|
if (!params[req] && !(req === "players" && params.player)) {
|
|
1549
|
-
throw new Error(`Template "${
|
|
1730
|
+
throw new Error(`Template "${template16.id}" requires --${req}`);
|
|
1550
1731
|
}
|
|
1551
1732
|
}
|
|
1552
|
-
const adapterQuery =
|
|
1553
|
-
const preferredSources = options.source ? [options.source] :
|
|
1733
|
+
const adapterQuery = template16.buildQuery(params);
|
|
1734
|
+
const preferredSources = options.source ? [options.source] : template16.preferredSources;
|
|
1554
1735
|
const adapters2 = resolveAdapters(preferredSources);
|
|
1555
1736
|
let lastError;
|
|
1556
1737
|
let result;
|
|
@@ -1562,8 +1743,8 @@ ${available}`);
|
|
|
1562
1743
|
const adapterResult = await adapter.fetch(adapterQuery, {
|
|
1563
1744
|
bypassCache: options.cache === false
|
|
1564
1745
|
});
|
|
1565
|
-
const rows =
|
|
1566
|
-
const columns =
|
|
1746
|
+
const rows = template16.transform(adapterResult.data, params);
|
|
1747
|
+
const columns = template16.columns(params);
|
|
1567
1748
|
if (rows.length === 0) {
|
|
1568
1749
|
log.debug(`${adapter.source} returned 0 rows. Trying next source...`);
|
|
1569
1750
|
continue;
|
|
@@ -1571,8 +1752,8 @@ ${available}`);
|
|
|
1571
1752
|
result = {
|
|
1572
1753
|
rows,
|
|
1573
1754
|
columns,
|
|
1574
|
-
title:
|
|
1575
|
-
description:
|
|
1755
|
+
title: template16.name,
|
|
1756
|
+
description: template16.description,
|
|
1576
1757
|
source: adapter.source,
|
|
1577
1758
|
cached: adapterResult.cached
|
|
1578
1759
|
};
|
|
@@ -1592,13 +1773,13 @@ ${available}`);
|
|
|
1592
1773
|
queryTimeMs,
|
|
1593
1774
|
season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
|
|
1594
1775
|
sampleSize: result.rows.length,
|
|
1595
|
-
template:
|
|
1776
|
+
template: template16.id
|
|
1596
1777
|
}, outputFormat, { columns: result.columns });
|
|
1597
1778
|
return {
|
|
1598
1779
|
data: result.rows,
|
|
1599
1780
|
formatted: output.formatted,
|
|
1600
1781
|
meta: {
|
|
1601
|
-
template:
|
|
1782
|
+
template: template16.id,
|
|
1602
1783
|
source: result.source,
|
|
1603
1784
|
cached: result.cached,
|
|
1604
1785
|
rowCount: result.rows.length,
|
|
@@ -1688,10 +1869,721 @@ function formatGrade(grade) {
|
|
|
1688
1869
|
return `${gradeColor(grade)} (${gradeLabel(grade)})`;
|
|
1689
1870
|
}
|
|
1690
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
|
+
|
|
1691
2583
|
// src/templates/reports/registry.ts
|
|
1692
2584
|
var templates2 = /* @__PURE__ */ new Map();
|
|
1693
|
-
function registerReportTemplate(
|
|
1694
|
-
templates2.set(
|
|
2585
|
+
function registerReportTemplate(template16) {
|
|
2586
|
+
templates2.set(template16.id, template16);
|
|
1695
2587
|
}
|
|
1696
2588
|
function getReportTemplate(id) {
|
|
1697
2589
|
return templates2.get(id);
|
|
@@ -1873,6 +2765,10 @@ Handlebars.registerHelper("compare", (value, leagueAvg) => {
|
|
|
1873
2765
|
Handlebars.registerHelper("ifGt", function(a, b, options) {
|
|
1874
2766
|
return a > b ? options.fn(this) : options.inverse(this);
|
|
1875
2767
|
});
|
|
2768
|
+
Handlebars.registerHelper(
|
|
2769
|
+
"svgOrEmpty",
|
|
2770
|
+
(svg) => new Handlebars.SafeString(svg ?? "")
|
|
2771
|
+
);
|
|
1876
2772
|
var __dirname2 = dirname(fileURLToPath(import.meta.url));
|
|
1877
2773
|
var BUNDLED_TEMPLATES_DIR = join2(__dirname2, "..", "templates", "reports");
|
|
1878
2774
|
function loadTemplate(templateFile) {
|
|
@@ -1889,14 +2785,14 @@ function loadTemplate(templateFile) {
|
|
|
1889
2785
|
}
|
|
1890
2786
|
function generateFallbackTemplate(templateFile) {
|
|
1891
2787
|
const templateId = templateFile.replace(".hbs", "");
|
|
1892
|
-
const
|
|
1893
|
-
if (!
|
|
1894
|
-
const sections =
|
|
2788
|
+
const template16 = getReportTemplate(templateId);
|
|
2789
|
+
if (!template16) return "# Report\n\n{{data}}";
|
|
2790
|
+
const sections = template16.requiredSections.map((s) => `## ${s}
|
|
1895
2791
|
|
|
1896
2792
|
{{!-- ${s} data goes here --}}
|
|
1897
2793
|
*Data pending*
|
|
1898
2794
|
`).join("\n");
|
|
1899
|
-
return `# ${
|
|
2795
|
+
return `# ${template16.name}
|
|
1900
2796
|
|
|
1901
2797
|
**Player:** {{player}}
|
|
1902
2798
|
**Season:** {{season}}
|
|
@@ -1919,8 +2815,8 @@ async function report(options) {
|
|
|
1919
2815
|
}
|
|
1920
2816
|
const config = getConfig();
|
|
1921
2817
|
const audience = options.audience ?? config.defaultAudience;
|
|
1922
|
-
const
|
|
1923
|
-
if (!
|
|
2818
|
+
const template16 = getReportTemplate(options.template);
|
|
2819
|
+
if (!template16) {
|
|
1924
2820
|
const available = listReportTemplates().map((t) => ` ${t.id} \u2014 ${t.description}`).join("\n");
|
|
1925
2821
|
throw new Error(`Unknown report template "${options.template}". Available:
|
|
1926
2822
|
${available}`);
|
|
@@ -1929,7 +2825,7 @@ ${available}`);
|
|
|
1929
2825
|
const player = options.player ?? "Unknown";
|
|
1930
2826
|
const dataResults = {};
|
|
1931
2827
|
const dataSources = [];
|
|
1932
|
-
for (const req of
|
|
2828
|
+
for (const req of template16.dataRequirements) {
|
|
1933
2829
|
try {
|
|
1934
2830
|
const result = await query({
|
|
1935
2831
|
template: req.queryTemplate,
|
|
@@ -1950,7 +2846,14 @@ ${available}`);
|
|
|
1950
2846
|
dataResults[req.queryTemplate] = null;
|
|
1951
2847
|
}
|
|
1952
2848
|
}
|
|
1953
|
-
const
|
|
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);
|
|
1954
2857
|
const compiled = Handlebars.compile(hbsSource);
|
|
1955
2858
|
const content = compiled({
|
|
1956
2859
|
player,
|
|
@@ -1959,19 +2862,20 @@ ${available}`);
|
|
|
1959
2862
|
date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
|
|
1960
2863
|
sources: dataSources.join(", ") || "none",
|
|
1961
2864
|
data: dataResults,
|
|
2865
|
+
graphs,
|
|
1962
2866
|
...dataResults
|
|
1963
2867
|
});
|
|
1964
2868
|
let validation;
|
|
1965
2869
|
if (options.validate) {
|
|
1966
|
-
validation = validateReport(content,
|
|
2870
|
+
validation = validateReport(content, template16.requiredSections);
|
|
1967
2871
|
}
|
|
1968
|
-
const formatted = options.format === "json" ? JSON.stringify({ content, validation, meta: { template:
|
|
2872
|
+
const formatted = options.format === "json" ? JSON.stringify({ content, validation, meta: { template: template16.id, player, audience, season, dataSources } }, null, 2) + "\n" : content + "\n";
|
|
1969
2873
|
return {
|
|
1970
2874
|
content,
|
|
1971
2875
|
formatted,
|
|
1972
2876
|
validation,
|
|
1973
2877
|
meta: {
|
|
1974
|
-
template:
|
|
2878
|
+
template: template16.id,
|
|
1975
2879
|
player,
|
|
1976
2880
|
audience,
|
|
1977
2881
|
season,
|
|
@@ -2058,6 +2962,7 @@ var program = new Command();
|
|
|
2058
2962
|
program.name("bbdata").description("Baseball data CLI \u2014 query stats, generate scouting reports, and build analytics pipelines").version("0.1.0");
|
|
2059
2963
|
registerQueryCommand(program);
|
|
2060
2964
|
registerReportCommand(program);
|
|
2965
|
+
registerVizCommand(program);
|
|
2061
2966
|
|
|
2062
2967
|
// bin/bbdata.ts
|
|
2063
2968
|
program.parse(process.argv);
|