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/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,
|
|
@@ -398,13 +400,88 @@ var BaseballReferenceAdapter = class {
|
|
|
398
400
|
}
|
|
399
401
|
};
|
|
400
402
|
|
|
403
|
+
// src/adapters/stdin.ts
|
|
404
|
+
var StdinAdapter = class {
|
|
405
|
+
source = "stdin";
|
|
406
|
+
description = "Local data from stdin (for sandboxed environments)";
|
|
407
|
+
data = [];
|
|
408
|
+
player = null;
|
|
409
|
+
loaded = false;
|
|
410
|
+
/**
|
|
411
|
+
* Load data from a pre-read stdin string.
|
|
412
|
+
* Called by the CLI before the adapter is used.
|
|
413
|
+
*/
|
|
414
|
+
load(raw) {
|
|
415
|
+
try {
|
|
416
|
+
const parsed = JSON.parse(raw);
|
|
417
|
+
if (Array.isArray(parsed)) {
|
|
418
|
+
this.data = parsed;
|
|
419
|
+
} else if (parsed.data && Array.isArray(parsed.data)) {
|
|
420
|
+
this.data = parsed.data;
|
|
421
|
+
if (parsed.player) {
|
|
422
|
+
this.player = parsed.player;
|
|
423
|
+
}
|
|
424
|
+
} else {
|
|
425
|
+
throw new Error('Expected JSON array or { "data": [...] } object');
|
|
426
|
+
}
|
|
427
|
+
this.loaded = true;
|
|
428
|
+
log.info(`Stdin adapter loaded ${this.data.length} records`);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
throw new Error(
|
|
431
|
+
`Failed to parse stdin data: ${error instanceof Error ? error.message : String(error)}`
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
supports(_query) {
|
|
436
|
+
return this.loaded && this.data.length > 0;
|
|
437
|
+
}
|
|
438
|
+
async fetch(query2) {
|
|
439
|
+
if (!this.loaded) {
|
|
440
|
+
throw new Error("Stdin adapter has no data \u2014 pipe JSON via stdin with --stdin flag");
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
data: this.data,
|
|
444
|
+
source: "stdin",
|
|
445
|
+
cached: false,
|
|
446
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
447
|
+
meta: {
|
|
448
|
+
rowCount: this.data.length,
|
|
449
|
+
season: query2.season,
|
|
450
|
+
query: query2
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
async resolvePlayer(name) {
|
|
455
|
+
if (this.player && this.player.name.toLowerCase() === name.toLowerCase()) {
|
|
456
|
+
return this.player;
|
|
457
|
+
}
|
|
458
|
+
const firstRecord = this.data[0];
|
|
459
|
+
if (firstRecord) {
|
|
460
|
+
const id = firstRecord.pitcher_id ?? firstRecord.player_id ?? "";
|
|
461
|
+
const recordName = firstRecord.pitcher_name ?? firstRecord.player_name ?? name;
|
|
462
|
+
if (id) {
|
|
463
|
+
return {
|
|
464
|
+
mlbam_id: id,
|
|
465
|
+
name: recordName
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
|
|
401
473
|
// src/adapters/index.ts
|
|
474
|
+
var stdinAdapter = new StdinAdapter();
|
|
402
475
|
var adapters = {
|
|
403
476
|
"mlb-stats-api": new MlbStatsApiAdapter(),
|
|
404
477
|
"savant": new SavantAdapter(),
|
|
405
478
|
"fangraphs": new FanGraphsAdapter(),
|
|
406
|
-
"baseball-reference": new BaseballReferenceAdapter()
|
|
479
|
+
"baseball-reference": new BaseballReferenceAdapter(),
|
|
480
|
+
"stdin": stdinAdapter
|
|
407
481
|
};
|
|
482
|
+
function getStdinAdapter() {
|
|
483
|
+
return stdinAdapter;
|
|
484
|
+
}
|
|
408
485
|
function resolveAdapters(preferred) {
|
|
409
486
|
return preferred.map((source) => adapters[source]).filter(Boolean);
|
|
410
487
|
}
|
|
@@ -583,10 +660,24 @@ function getTemplatesDir() {
|
|
|
583
660
|
return dir;
|
|
584
661
|
}
|
|
585
662
|
|
|
663
|
+
// src/utils/stdin.ts
|
|
664
|
+
function readStdin() {
|
|
665
|
+
return new Promise((resolve, reject) => {
|
|
666
|
+
if (process.stdin.isTTY) {
|
|
667
|
+
reject(new Error(`--stdin flag requires piped input. Usage: echo '{"data":[...]}' | bbdata query ... --stdin`));
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
const chunks = [];
|
|
671
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
672
|
+
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
673
|
+
process.stdin.on("error", reject);
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
586
677
|
// src/templates/queries/registry.ts
|
|
587
678
|
var templates = /* @__PURE__ */ new Map();
|
|
588
|
-
function registerTemplate(
|
|
589
|
-
templates.set(
|
|
679
|
+
function registerTemplate(template16) {
|
|
680
|
+
templates.set(template16.id, template16);
|
|
590
681
|
}
|
|
591
682
|
function getTemplate(id) {
|
|
592
683
|
return templates.get(id);
|
|
@@ -629,6 +720,10 @@ var PitchDataSchema = z2.object({
|
|
|
629
720
|
// exit velocity (mph)
|
|
630
721
|
launch_angle: z2.number().nullable(),
|
|
631
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)
|
|
632
727
|
description: z2.string(),
|
|
633
728
|
// called_strike, swinging_strike, ball, foul, hit_into_play, etc.
|
|
634
729
|
events: z2.string().nullable(),
|
|
@@ -1326,7 +1421,7 @@ var template11 = {
|
|
|
1326
1421
|
};
|
|
1327
1422
|
},
|
|
1328
1423
|
columns() {
|
|
1329
|
-
return ["Window", "Games", "AVG", "SLG", "K %", "Avg EV", "Hard Hit %"];
|
|
1424
|
+
return ["Window", "Window End", "Games", "AVG", "SLG", "K %", "Avg EV", "Hard Hit %"];
|
|
1330
1425
|
},
|
|
1331
1426
|
transform(data) {
|
|
1332
1427
|
const pitches = data;
|
|
@@ -1340,7 +1435,16 @@ var template11 = {
|
|
|
1340
1435
|
const dates = Array.from(byDate.keys()).sort();
|
|
1341
1436
|
const windowSize = 15;
|
|
1342
1437
|
if (dates.length < windowSize) {
|
|
1343
|
-
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
|
+
}];
|
|
1344
1448
|
}
|
|
1345
1449
|
const results = [];
|
|
1346
1450
|
for (let i = 0; i <= dates.length - windowSize; i += Math.max(1, Math.floor(windowSize / 3))) {
|
|
@@ -1359,8 +1463,10 @@ var template11 = {
|
|
|
1359
1463
|
const batted = windowPitches.filter((p) => p.launch_speed !== null && p.launch_speed > 0);
|
|
1360
1464
|
const avgEv = batted.length > 0 ? batted.reduce((s, p) => s + p.launch_speed, 0) / batted.length : null;
|
|
1361
1465
|
const hardHit = batted.filter((p) => p.launch_speed >= 95).length;
|
|
1466
|
+
const windowEnd = windowDates[windowDates.length - 1];
|
|
1362
1467
|
results.push({
|
|
1363
|
-
Window: `${windowDates[0]} \u2192 ${
|
|
1468
|
+
Window: `${windowDates[0]} \u2192 ${windowEnd}`,
|
|
1469
|
+
"Window End": windowEnd,
|
|
1364
1470
|
Games: windowDates.length,
|
|
1365
1471
|
AVG: pas.length > 0 ? (hits.length / pas.length).toFixed(3) : "\u2014",
|
|
1366
1472
|
SLG: pas.length > 0 ? (totalBases / pas.length).toFixed(3) : "\u2014",
|
|
@@ -1432,12 +1538,182 @@ function formatVal(n) {
|
|
|
1432
1538
|
}
|
|
1433
1539
|
registerTemplate(template12);
|
|
1434
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
|
+
|
|
1435
1705
|
// src/commands/query.ts
|
|
1436
1706
|
async function query(options) {
|
|
1707
|
+
if (options.stdin) {
|
|
1708
|
+
const raw = await readStdin();
|
|
1709
|
+
const adapter = getStdinAdapter();
|
|
1710
|
+
adapter.load(raw);
|
|
1711
|
+
options.source = "stdin";
|
|
1712
|
+
}
|
|
1437
1713
|
const config = getConfig();
|
|
1438
1714
|
const outputFormat = options.format ?? config.defaultFormat;
|
|
1439
|
-
const
|
|
1440
|
-
if (!
|
|
1715
|
+
const template16 = getTemplate(options.template);
|
|
1716
|
+
if (!template16) {
|
|
1441
1717
|
const available = listTemplates().map((t) => ` ${t.id} \u2014 ${t.description}`).join("\n");
|
|
1442
1718
|
throw new Error(`Unknown template "${options.template}". Available templates:
|
|
1443
1719
|
${available}`);
|
|
@@ -1454,13 +1730,13 @@ ${available}`);
|
|
|
1454
1730
|
top: options.top,
|
|
1455
1731
|
seasons: options.seasons
|
|
1456
1732
|
};
|
|
1457
|
-
for (const req of
|
|
1733
|
+
for (const req of template16.requiredParams) {
|
|
1458
1734
|
if (!params[req] && !(req === "players" && params.player)) {
|
|
1459
|
-
throw new Error(`Template "${
|
|
1735
|
+
throw new Error(`Template "${template16.id}" requires --${req}`);
|
|
1460
1736
|
}
|
|
1461
1737
|
}
|
|
1462
|
-
const adapterQuery =
|
|
1463
|
-
const preferredSources = options.source ? [options.source] :
|
|
1738
|
+
const adapterQuery = template16.buildQuery(params);
|
|
1739
|
+
const preferredSources = options.source ? [options.source] : template16.preferredSources;
|
|
1464
1740
|
const adapters2 = resolveAdapters(preferredSources);
|
|
1465
1741
|
let lastError;
|
|
1466
1742
|
let result;
|
|
@@ -1472,8 +1748,8 @@ ${available}`);
|
|
|
1472
1748
|
const adapterResult = await adapter.fetch(adapterQuery, {
|
|
1473
1749
|
bypassCache: options.cache === false
|
|
1474
1750
|
});
|
|
1475
|
-
const rows =
|
|
1476
|
-
const columns =
|
|
1751
|
+
const rows = template16.transform(adapterResult.data, params);
|
|
1752
|
+
const columns = template16.columns(params);
|
|
1477
1753
|
if (rows.length === 0) {
|
|
1478
1754
|
log.debug(`${adapter.source} returned 0 rows. Trying next source...`);
|
|
1479
1755
|
continue;
|
|
@@ -1481,8 +1757,8 @@ ${available}`);
|
|
|
1481
1757
|
result = {
|
|
1482
1758
|
rows,
|
|
1483
1759
|
columns,
|
|
1484
|
-
title:
|
|
1485
|
-
description:
|
|
1760
|
+
title: template16.name,
|
|
1761
|
+
description: template16.description,
|
|
1486
1762
|
source: adapter.source,
|
|
1487
1763
|
cached: adapterResult.cached
|
|
1488
1764
|
};
|
|
@@ -1502,13 +1778,13 @@ ${available}`);
|
|
|
1502
1778
|
queryTimeMs,
|
|
1503
1779
|
season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
|
|
1504
1780
|
sampleSize: result.rows.length,
|
|
1505
|
-
template:
|
|
1781
|
+
template: template16.id
|
|
1506
1782
|
}, outputFormat, { columns: result.columns });
|
|
1507
1783
|
return {
|
|
1508
1784
|
data: result.rows,
|
|
1509
1785
|
formatted: output.formatted,
|
|
1510
1786
|
meta: {
|
|
1511
|
-
template:
|
|
1787
|
+
template: template16.id,
|
|
1512
1788
|
source: result.source,
|
|
1513
1789
|
cached: result.cached,
|
|
1514
1790
|
rowCount: result.rows.length,
|
|
@@ -1548,10 +1824,660 @@ function formatGrade(grade) {
|
|
|
1548
1824
|
return `${gradeColor(grade)} (${gradeLabel(grade)})`;
|
|
1549
1825
|
}
|
|
1550
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
|
+
|
|
1551
2477
|
// src/templates/reports/registry.ts
|
|
1552
2478
|
var templates2 = /* @__PURE__ */ new Map();
|
|
1553
|
-
function registerReportTemplate(
|
|
1554
|
-
templates2.set(
|
|
2479
|
+
function registerReportTemplate(template16) {
|
|
2480
|
+
templates2.set(template16.id, template16);
|
|
1555
2481
|
}
|
|
1556
2482
|
function getReportTemplate(id) {
|
|
1557
2483
|
return templates2.get(id);
|
|
@@ -1733,6 +2659,10 @@ Handlebars.registerHelper("compare", (value, leagueAvg) => {
|
|
|
1733
2659
|
Handlebars.registerHelper("ifGt", function(a, b, options) {
|
|
1734
2660
|
return a > b ? options.fn(this) : options.inverse(this);
|
|
1735
2661
|
});
|
|
2662
|
+
Handlebars.registerHelper(
|
|
2663
|
+
"svgOrEmpty",
|
|
2664
|
+
(svg) => new Handlebars.SafeString(svg ?? "")
|
|
2665
|
+
);
|
|
1736
2666
|
var __dirname2 = dirname(fileURLToPath(import.meta.url));
|
|
1737
2667
|
var BUNDLED_TEMPLATES_DIR = join2(__dirname2, "..", "templates", "reports");
|
|
1738
2668
|
function loadTemplate(templateFile) {
|
|
@@ -1749,14 +2679,14 @@ function loadTemplate(templateFile) {
|
|
|
1749
2679
|
}
|
|
1750
2680
|
function generateFallbackTemplate(templateFile) {
|
|
1751
2681
|
const templateId = templateFile.replace(".hbs", "");
|
|
1752
|
-
const
|
|
1753
|
-
if (!
|
|
1754
|
-
const sections =
|
|
2682
|
+
const template16 = getReportTemplate(templateId);
|
|
2683
|
+
if (!template16) return "# Report\n\n{{data}}";
|
|
2684
|
+
const sections = template16.requiredSections.map((s) => `## ${s}
|
|
1755
2685
|
|
|
1756
2686
|
{{!-- ${s} data goes here --}}
|
|
1757
2687
|
*Data pending*
|
|
1758
2688
|
`).join("\n");
|
|
1759
|
-
return `# ${
|
|
2689
|
+
return `# ${template16.name}
|
|
1760
2690
|
|
|
1761
2691
|
**Player:** {{player}}
|
|
1762
2692
|
**Season:** {{season}}
|
|
@@ -1772,10 +2702,15 @@ ${sections}
|
|
|
1772
2702
|
`;
|
|
1773
2703
|
}
|
|
1774
2704
|
async function report(options) {
|
|
2705
|
+
if (options.stdin) {
|
|
2706
|
+
const raw = await readStdin();
|
|
2707
|
+
const adapter = getStdinAdapter();
|
|
2708
|
+
adapter.load(raw);
|
|
2709
|
+
}
|
|
1775
2710
|
const config = getConfig();
|
|
1776
2711
|
const audience = options.audience ?? config.defaultAudience;
|
|
1777
|
-
const
|
|
1778
|
-
if (!
|
|
2712
|
+
const template16 = getReportTemplate(options.template);
|
|
2713
|
+
if (!template16) {
|
|
1779
2714
|
const available = listReportTemplates().map((t) => ` ${t.id} \u2014 ${t.description}`).join("\n");
|
|
1780
2715
|
throw new Error(`Unknown report template "${options.template}". Available:
|
|
1781
2716
|
${available}`);
|
|
@@ -1784,14 +2719,15 @@ ${available}`);
|
|
|
1784
2719
|
const player = options.player ?? "Unknown";
|
|
1785
2720
|
const dataResults = {};
|
|
1786
2721
|
const dataSources = [];
|
|
1787
|
-
for (const req of
|
|
2722
|
+
for (const req of template16.dataRequirements) {
|
|
1788
2723
|
try {
|
|
1789
2724
|
const result = await query({
|
|
1790
2725
|
template: req.queryTemplate,
|
|
1791
2726
|
player: options.player,
|
|
1792
2727
|
team: options.team,
|
|
1793
2728
|
season,
|
|
1794
|
-
format: "json"
|
|
2729
|
+
format: "json",
|
|
2730
|
+
...options.stdin ? { source: "stdin" } : {}
|
|
1795
2731
|
});
|
|
1796
2732
|
dataResults[req.queryTemplate] = result.data;
|
|
1797
2733
|
if (!dataSources.includes(result.meta.source)) {
|
|
@@ -1804,7 +2740,14 @@ ${available}`);
|
|
|
1804
2740
|
dataResults[req.queryTemplate] = null;
|
|
1805
2741
|
}
|
|
1806
2742
|
}
|
|
1807
|
-
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);
|
|
1808
2751
|
const compiled = Handlebars.compile(hbsSource);
|
|
1809
2752
|
const content = compiled({
|
|
1810
2753
|
player,
|
|
@@ -1813,19 +2756,20 @@ ${available}`);
|
|
|
1813
2756
|
date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
|
|
1814
2757
|
sources: dataSources.join(", ") || "none",
|
|
1815
2758
|
data: dataResults,
|
|
2759
|
+
graphs,
|
|
1816
2760
|
...dataResults
|
|
1817
2761
|
});
|
|
1818
2762
|
let validation;
|
|
1819
2763
|
if (options.validate) {
|
|
1820
|
-
validation = validateReport(content,
|
|
2764
|
+
validation = validateReport(content, template16.requiredSections);
|
|
1821
2765
|
}
|
|
1822
|
-
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";
|
|
1823
2767
|
return {
|
|
1824
2768
|
content,
|
|
1825
2769
|
formatted,
|
|
1826
2770
|
validation,
|
|
1827
2771
|
meta: {
|
|
1828
|
-
template:
|
|
2772
|
+
template: template16.id,
|
|
1829
2773
|
player,
|
|
1830
2774
|
audience,
|
|
1831
2775
|
season,
|
|
@@ -1861,6 +2805,7 @@ export {
|
|
|
1861
2805
|
getConfig,
|
|
1862
2806
|
query,
|
|
1863
2807
|
report,
|
|
1864
|
-
setConfig
|
|
2808
|
+
setConfig,
|
|
2809
|
+
viz
|
|
1865
2810
|
};
|
|
1866
2811
|
//# sourceMappingURL=index.js.map
|