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