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/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(
|
|
678
|
-
templates.set(
|
|
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 [{
|
|
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 ${
|
|
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
|
|
1535
|
-
if (!
|
|
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
|
|
1733
|
+
for (const req of template16.requiredParams) {
|
|
1553
1734
|
if (!params[req] && !(req === "players" && params.player)) {
|
|
1554
|
-
throw new Error(`Template "${
|
|
1735
|
+
throw new Error(`Template "${template16.id}" requires --${req}`);
|
|
1555
1736
|
}
|
|
1556
1737
|
}
|
|
1557
|
-
const adapterQuery =
|
|
1558
|
-
const preferredSources = options.source ? [options.source] :
|
|
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 =
|
|
1571
|
-
const columns =
|
|
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:
|
|
1580
|
-
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:
|
|
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:
|
|
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(
|
|
1649
|
-
templates2.set(
|
|
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
|
|
1848
|
-
if (!
|
|
1849
|
-
const sections =
|
|
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 `# ${
|
|
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
|
|
1878
|
-
if (!
|
|
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
|
|
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
|
|
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,
|
|
2764
|
+
validation = validateReport(content, template16.requiredSections);
|
|
1922
2765
|
}
|
|
1923
|
-
const formatted = options.format === "json" ? JSON.stringify({ content, validation, meta: { template:
|
|
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:
|
|
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
|