Merge Wave 5B (wiring/data): pitcher arsenal via Baseball Savant (D4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+22
-1
@@ -2,7 +2,8 @@ const express = require('express');
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
const { getStatFilters } = require('../config/statFilters');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
const { getPlayerIntel, getLeaders } = require('../services/playerIntelService');
|
||||
const { getPlayerIntel, getLeaders, sanitizePlayerName } = require('../services/playerIntelService');
|
||||
const { getPitcherArsenal } = require('../services/adapters/savantAdapter');
|
||||
const depthChart = require('../services/depthChartService');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -139,6 +140,26 @@ router.get('/player/:name', intelLimit, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /pitcher/:name/arsenal?sport=mlb — Baseball Savant pitch arsenal (Wave 5B).
|
||||
// Pitch mix % + velo + whiff% (the mockup's PITCHER IDENTITY lens). FREE Statcast
|
||||
// source; the adapter resolves name→MLBAM id + caches 24h. MLB only — any other
|
||||
// sport (or a miss) returns { found:false } and the card self-hides. Never fabricates.
|
||||
router.get('/pitcher/:name/arsenal', intelLimit, async (req, res) => {
|
||||
try {
|
||||
const sport = String(req.query.sport || 'mlb').toLowerCase();
|
||||
if (sport !== 'mlb') {
|
||||
return res.set(MISSION_HEADER).json({ found: false, reason: 'arsenal is MLB-only' });
|
||||
}
|
||||
const name = sanitizePlayerName(req.params.name);
|
||||
const arsenal = await getPitcherArsenal(name);
|
||||
res.set(MISSION_HEADER).json(arsenal || { found: false });
|
||||
} catch (err) {
|
||||
console.error('[stats/pitcher/arsenal]', err.message);
|
||||
// Honesty: an error is an ABSENT arsenal, not a fabricated one. Card self-hides.
|
||||
res.set(MISSION_HEADER).json({ found: false });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /leaders?sport=mlb&stat=hits&limit=10 — tonight's stat leaders (top
|
||||
// graded props by confidence) for the Terminal / Stats Explorer.
|
||||
router.get('/leaders', intelLimit, async (req, res) => {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Baseball Savant / Statcast adapter (Wave 5B — Pitcher Arsenal).
|
||||
*
|
||||
* Pitch-level identity (mix / velo / usage% / whiff%) is NOT in statsapi.mlb.com —
|
||||
* it lives on Baseball Savant (baseballsavant.mlb.com), which is FREE + public.
|
||||
* `mlbStatsAdapter` already gives probable pitchers + ERA + game logs; the arsenal
|
||||
* is the missing "one identity → many props" lens (the mockup's PITCHER IDENTITY).
|
||||
*
|
||||
* ZERO-OUT-OF-POCKET + HONESTY doctrine (same as espnStatsAdapter):
|
||||
* - Free source → NO gateway / NO quota tracking.
|
||||
* - Prefer a CSV/JSON leaderboard endpoint so NO new parsing dependency is
|
||||
* needed (a tiny quote-aware CSV parser lives here; no cheerio/HTML scrape).
|
||||
* - Defensive parsing: `null` on any unrecognized shape. A missing velo/whiff
|
||||
* is ABSENT (null), NEVER 0 — `Number(null) === 0` is the fabrication trap.
|
||||
* - Never fabricate: no pitcher → `{ found:false }`; the card self-hides.
|
||||
* - Injectable (`opts.fetchImpl` / `opts.statsCsv` / `opts.veloCsv` /
|
||||
* `opts.resolveId` / cacheGet/cacheSet) → tests never hit the network.
|
||||
*
|
||||
* SOURCE ENDPOINTS (public, `csv=true` → no dependency):
|
||||
* 1. Pitch-arsenal-stats (usage% + whiff% + K%) — LONG format, one row per
|
||||
* (pitcher, pitch_type):
|
||||
* https://baseballsavant.mlb.com/leaderboard/pitch-arsenal-stats?type=pitcher&pitchType=&year={year}&min=1&csv=true
|
||||
* 2. Pitch-arsenals avg velo (best-effort enrichment) — WIDE format, one row
|
||||
* per pitcher with a `{abbr}_avg_speed` column per pitch:
|
||||
* https://baseballsavant.mlb.com/leaderboard/pitch-arsenals?year={year}&min=1&type=avg_speed&hand=&csv=true
|
||||
* Both are LEAGUE-WIDE → fetched once, cached 24h, indexed by MLBAM pitcher id.
|
||||
* Velo is OPTIONAL: if endpoint #2's shape drifts, velo stays absent (null) —
|
||||
* never a fabricated 0. NEEDS PROD VERIFICATION of the live column names.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { cacheGet: redisGet, cacheSet: redisSet } = require('../../utils/redis');
|
||||
|
||||
const HTTP_TIMEOUT_MS = 12_000;
|
||||
const ARSENAL_TTL = 24 * 3600; // 24h — pitch mix is a slow-moving season identity
|
||||
const LEAGUE_TTL = 24 * 3600;
|
||||
const DEFAULT_SEASON = 2026;
|
||||
const TOP_PITCHES = 6; // cap the strip — nobody throws more than ~6 real pitches
|
||||
|
||||
// In-memory mirror so a single process serves the merged per-pitcher arsenal
|
||||
// without a Redis round-trip (and so it degrades when Redis is down).
|
||||
const _mem = new Map(); // key -> { value, exp }
|
||||
function memGet(key) {
|
||||
const hit = _mem.get(key);
|
||||
if (!hit) return null;
|
||||
if (hit.exp && hit.exp < Date.now()) { _mem.delete(key); return null; }
|
||||
return hit.value;
|
||||
}
|
||||
function memSet(key, value, ttl) {
|
||||
_mem.set(key, { value, exp: Date.now() + ttl * 1000 });
|
||||
}
|
||||
|
||||
function statsUrl(year) {
|
||||
return `https://baseballsavant.mlb.com/leaderboard/pitch-arsenal-stats?type=pitcher&pitchType=&year=${year}&min=1&csv=true`;
|
||||
}
|
||||
function veloUrl(year) {
|
||||
return `https://baseballsavant.mlb.com/leaderboard/pitch-arsenals?year=${year}&min=1&type=avg_speed&hand=&csv=true`;
|
||||
}
|
||||
|
||||
// ── Strict numeric parsing — absent beats zero ──────────────────────────────
|
||||
// A blank / non-numeric field is null, NOT 0. Percentages arrive either as a
|
||||
// whole number ("32.4") or a fraction ("0.324"); we normalize to a whole-number
|
||||
// percent only when the source is clearly a fraction (0..1).
|
||||
function numOrNull(raw) {
|
||||
if (raw === null || raw === undefined) return null;
|
||||
const s = String(raw).trim().replace('%', '');
|
||||
if (s === '' || s === 'NA' || s === 'null') return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
function pctOrNull(raw) {
|
||||
const n = numOrNull(raw);
|
||||
if (n === null) return null;
|
||||
// Savant returns whole-number percents ("32.4"); a value in (0,1] is a
|
||||
// fraction we scale up. Never invent a value where none exists.
|
||||
return n > 0 && n <= 1 ? Math.round(n * 1000) / 10 : n;
|
||||
}
|
||||
|
||||
// ── Minimal quote-aware CSV parser (NO dependency) ──────────────────────────
|
||||
// Savant's first column header is literally `"last_name, first_name"` (a quoted
|
||||
// field containing a comma), so a naive split is wrong — we must honor quotes.
|
||||
function parseCsvLine(line) {
|
||||
const out = [];
|
||||
let cur = '';
|
||||
let inQ = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (inQ) {
|
||||
if (ch === '"') {
|
||||
if (line[i + 1] === '"') { cur += '"'; i++; } else inQ = false;
|
||||
} else cur += ch;
|
||||
} else if (ch === '"') {
|
||||
inQ = true;
|
||||
} else if (ch === ',') {
|
||||
out.push(cur); cur = '';
|
||||
} else cur += ch;
|
||||
}
|
||||
out.push(cur);
|
||||
return out;
|
||||
}
|
||||
function parseCsv(text) {
|
||||
if (typeof text !== 'string' || text.trim() === '') return [];
|
||||
const rows = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').filter((l) => l.length > 0);
|
||||
if (rows.length < 2) return [];
|
||||
const header = parseCsvLine(rows[0]).map((h) => h.trim());
|
||||
const objs = [];
|
||||
for (let r = 1; r < rows.length; r++) {
|
||||
const cols = parseCsvLine(rows[r]);
|
||||
if (cols.length === 0) continue;
|
||||
const obj = {};
|
||||
for (let c = 0; c < header.length; c++) obj[header[c]] = cols[c] !== undefined ? cols[c].trim() : '';
|
||||
objs.push(obj);
|
||||
}
|
||||
return objs;
|
||||
}
|
||||
|
||||
// A row must carry a pitcher id + a pitch_type to be usable. Unknown shape → [].
|
||||
function idOf(row) {
|
||||
return row.player_id ?? row.pitcher ?? row.playerId ?? row.mlbam_id ?? null;
|
||||
}
|
||||
|
||||
// ── League-wide fetch + index (cached 24h, indexed by pitcher id) ───────────
|
||||
async function fetchText(url, opts) {
|
||||
if (typeof opts.fetchImpl === 'function') return opts.fetchImpl(url);
|
||||
const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS, responseType: 'text' });
|
||||
return typeof res.data === 'string' ? res.data : null;
|
||||
}
|
||||
|
||||
// stats leaderboard → { [pitcherId]: [ { pitch_type, pitch_name, pitch_usage, whiff_percent, k_percent } ] }
|
||||
async function loadStatsIndex(year, opts) {
|
||||
const cacheGet = opts.cacheGet || redisGet;
|
||||
const cacheSet = opts.cacheSet || redisSet;
|
||||
const key = `savant:arsenal-stats:${year}`;
|
||||
let csv = opts.statsCsv;
|
||||
if (csv === undefined) {
|
||||
const cached = await cacheGet(key).catch(() => null);
|
||||
if (cached && typeof cached === 'object') return cached; // already indexed
|
||||
csv = await fetchText(statsUrl(year), opts).catch(() => null);
|
||||
}
|
||||
const rows = parseCsv(csv);
|
||||
if (rows.length === 0) return null; // unrecognized / empty → caller decides
|
||||
const index = {};
|
||||
let usable = 0;
|
||||
for (const row of rows) {
|
||||
const id = idOf(row);
|
||||
const type = row.pitch_type != null ? String(row.pitch_type).trim() : '';
|
||||
if (id == null || String(id).trim() === '' || type === '') continue;
|
||||
const key2 = String(id).trim();
|
||||
(index[key2] = index[key2] || []).push({
|
||||
type,
|
||||
name: row.pitch_name ? String(row.pitch_name).trim() : type,
|
||||
usagePct: pctOrNull(row.pitch_usage),
|
||||
whiffPct: pctOrNull(row.whiff_percent),
|
||||
kPct: pctOrNull(row.k_percent),
|
||||
});
|
||||
usable++;
|
||||
}
|
||||
if (usable === 0) return null; // header present but no id/pitch columns → unknown shape
|
||||
if (opts.statsCsv === undefined) await (opts.cacheSet || redisSet)(key, index, LEAGUE_TTL).catch(() => {});
|
||||
return index;
|
||||
}
|
||||
|
||||
// velo leaderboard (WIDE) → { [pitcherId]: { ff: 99.1, sl: 87.0, ... } } (lowercased abbr → velo)
|
||||
async function loadVeloIndex(year, opts) {
|
||||
const cacheGet = opts.cacheGet || redisGet;
|
||||
const key = `savant:arsenal-velo:${year}`;
|
||||
let csv = opts.veloCsv;
|
||||
if (csv === undefined) {
|
||||
const cached = await cacheGet(key).catch(() => null);
|
||||
if (cached && typeof cached === 'object') return cached;
|
||||
csv = await fetchText(veloUrl(year), opts).catch(() => null);
|
||||
}
|
||||
const rows = parseCsv(csv);
|
||||
if (rows.length === 0) return {}; // velo is OPTIONAL — absent index is fine
|
||||
const index = {};
|
||||
for (const row of rows) {
|
||||
const id = idOf(row);
|
||||
if (id == null || String(id).trim() === '') continue;
|
||||
const speeds = {};
|
||||
for (const col of Object.keys(row)) {
|
||||
const m = /^([a-z]{1,3})_avg_speed$/i.exec(col);
|
||||
if (!m) continue;
|
||||
const v = numOrNull(row[col]);
|
||||
if (v !== null) speeds[m[1].toLowerCase()] = v;
|
||||
}
|
||||
if (Object.keys(speeds).length) index[String(id).trim()] = speeds;
|
||||
}
|
||||
if (opts.veloCsv === undefined) await (opts.cacheSet || redisSet)(key, index, LEAGUE_TTL).catch(() => {});
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a pitcher's arsenal by MLBAM id (preferred) or name.
|
||||
* @returns {Promise<{found:boolean, playerId?:(number|string), pitches?:Array, source?:string}>}
|
||||
* pitches: [{ type, name, usagePct, velo|null, whiffPct|null, kPct|null }], ranked by usage desc.
|
||||
* Always graceful — any miss / unrecognized shape → { found:false }.
|
||||
*/
|
||||
async function getPitcherArsenal(idOrName, opts = {}) {
|
||||
try {
|
||||
const year = opts.year || DEFAULT_SEASON;
|
||||
|
||||
// Resolve name → MLBAM id when we weren't given a numeric id.
|
||||
let id = idOrName;
|
||||
const isNumericId = id != null && /^\d+$/.test(String(id).trim());
|
||||
if (!isNumericId) {
|
||||
const name = String(idOrName || '').trim();
|
||||
if (!name) return { found: false };
|
||||
const resolveId = opts.resolveId || (async (n) => {
|
||||
const mlb = opts.mlbAdapter || require('./mlbStatsAdapter');
|
||||
const person = await mlb.searchPlayer(n).catch(() => null);
|
||||
return person && person.id != null ? person.id : null;
|
||||
});
|
||||
id = await resolveId(name);
|
||||
if (id == null) return { found: false };
|
||||
}
|
||||
const key = String(id).trim();
|
||||
|
||||
// Merged per-pitcher cache (Redis + in-memory mirror), only for the live path
|
||||
// (injected CSV fixtures skip the cache so tests are deterministic).
|
||||
const usingFixtures = opts.statsCsv !== undefined || opts.veloCsv !== undefined;
|
||||
const memKey = `savant:arsenal:${key}:${year}`;
|
||||
if (!usingFixtures) {
|
||||
const m = memGet(memKey);
|
||||
if (m) return m;
|
||||
const cached = await (opts.cacheGet || redisGet)(memKey).catch(() => null);
|
||||
if (cached && typeof cached === 'object') { memSet(memKey, cached, ARSENAL_TTL); return cached; }
|
||||
}
|
||||
|
||||
const statsIndex = await loadStatsIndex(year, opts);
|
||||
if (!statsIndex) return { found: false }; // unrecognized / empty stats feed
|
||||
const rows = statsIndex[key];
|
||||
if (!Array.isArray(rows) || rows.length === 0) return { found: false };
|
||||
|
||||
// Velo is best-effort enrichment; a failure leaves velo absent (null).
|
||||
// In fixture mode (statsCsv injected) with NO veloCsv, DON'T hit the network —
|
||||
// tests stay hermetic and velo is honestly absent.
|
||||
let veloIndex = {};
|
||||
if (opts.veloCsv !== undefined || !usingFixtures) {
|
||||
try { veloIndex = await loadVeloIndex(year, opts); } catch { veloIndex = {}; }
|
||||
}
|
||||
const speeds = (veloIndex && veloIndex[key]) || {};
|
||||
|
||||
const pitches = rows
|
||||
.map((p) => ({
|
||||
type: p.type,
|
||||
name: p.name,
|
||||
usagePct: p.usagePct,
|
||||
velo: (speeds[p.type.toLowerCase()] !== undefined ? speeds[p.type.toLowerCase()] : null),
|
||||
whiffPct: p.whiffPct,
|
||||
kPct: p.kPct,
|
||||
}))
|
||||
// rank by usage desc; a null usage sorts last (never fabricated to 0)
|
||||
.sort((a, b) => (b.usagePct ?? -1) - (a.usagePct ?? -1))
|
||||
.slice(0, TOP_PITCHES);
|
||||
|
||||
const result = { found: true, playerId: /^\d+$/.test(key) ? Number(key) : key, pitches, source: 'baseball_savant' };
|
||||
if (!usingFixtures) {
|
||||
memSet(memKey, result, ARSENAL_TTL);
|
||||
await (opts.cacheSet || redisSet)(memKey, result, ARSENAL_TTL).catch(() => {});
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.warn('[savant] getPitcherArsenal failed:', idOrName, err && err.message);
|
||||
return { found: false };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPitcherArsenal,
|
||||
__internals: {
|
||||
parseCsv, parseCsvLine, numOrNull, pctOrNull, idOf,
|
||||
loadStatsIndex, loadVeloIndex, statsUrl, veloUrl,
|
||||
DEFAULT_SEASON, ARSENAL_TTL, TOP_PITCHES, _mem,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
'use strict';
|
||||
|
||||
// Wave 5B — PitcherArsenal component source locks. The card is CONTEXT (the
|
||||
// arsenal read), never a graded market value. Honesty invariants:
|
||||
// • SELF-HIDES (returns null) when Savant has no arsenal — no empty shell.
|
||||
// • ALL pitch data (velo / usage / whiff) is mono + tabular (brand rule).
|
||||
// • A missing velo/whiff renders "—" (absent), NEVER 0.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||
|
||||
describe('PitcherArsenal component', () => {
|
||||
const src = read('components/vyndr/PitcherArsenal.tsx');
|
||||
|
||||
test('self-hides (returns null) when there are no pitches', () => {
|
||||
expect(src).toMatch(/if\s*\(\s*pitches\.length\s*===\s*0\s*\)\s*return null/);
|
||||
});
|
||||
|
||||
test('renders the arsenal strip headers (VELO / USE / WHIFF)', () => {
|
||||
expect(src).toContain('VELO');
|
||||
expect(src).toContain('USE');
|
||||
expect(src).toContain('WHIFF');
|
||||
expect(src).toContain('PITCH');
|
||||
});
|
||||
|
||||
test('pitch data is mono + tabular (brand rule: data is mono)', () => {
|
||||
expect(src).toContain('className="mono"');
|
||||
expect(src).toContain('tabular-nums');
|
||||
// the three data cells map the normalized fields
|
||||
expect(src).toContain('fmtVelo(p.velo)');
|
||||
expect(src).toContain('fmtPct(p.usagePct)');
|
||||
expect(src).toContain('fmtPct(p.whiffPct)');
|
||||
});
|
||||
|
||||
test('absent velo/whiff renders an em-dash, never 0 (absent beats zero)', () => {
|
||||
// fmtVelo / fmtPct return '—' for null|undefined
|
||||
expect(src).toMatch(/function fmtVelo[\s\S]*?===\s*null[\s\S]*?['"]—['"]/);
|
||||
expect(src).toMatch(/function fmtPct[\s\S]*?===\s*null[\s\S]*?['"]—['"]/);
|
||||
// no `?? 0` / `|| 0` coercion on the numeric fields
|
||||
expect(src).not.toMatch(/velo\s*(\?\?|\|\|)\s*0/);
|
||||
expect(src).not.toMatch(/whiffPct\s*(\?\?|\|\|)\s*0/);
|
||||
});
|
||||
|
||||
test('cites Baseball Savant as the source (honest provenance)', () => {
|
||||
expect(src).toMatch(/BASEBALL SAVANT/i);
|
||||
});
|
||||
|
||||
test('ranks the strip by the normalized fields from the adapter contract', () => {
|
||||
// consumes { found, pitches:[{ type, name, usagePct, velo, whiffPct }] }
|
||||
expect(src).toContain('fetched.found');
|
||||
expect(src).toContain('fetched.pitches');
|
||||
});
|
||||
|
||||
test('is exported from the vyndr barrel', () => {
|
||||
const barrel = read('components/vyndr/index.ts');
|
||||
expect(barrel).toContain("export { default as PitcherArsenal }");
|
||||
});
|
||||
|
||||
test('the arsenal proxy route exists (S25 rule) and forwards to the backend', () => {
|
||||
const proxy = read('app/api/stats/pitcher/[name]/arsenal/route.ts');
|
||||
expect(proxy).toContain('/api/stats/pitcher/');
|
||||
expect(proxy).toContain('/arsenal');
|
||||
expect(proxy).toContain('found: false'); // honest fallback, not an error card
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
'use strict';
|
||||
|
||||
const savant = require('../../src/services/adapters/savantAdapter');
|
||||
|
||||
// Fixture CSVs shaped like the real Baseball Savant leaderboard exports.
|
||||
// NOTE the first header column is a QUOTED field containing a comma
|
||||
// (`"last_name, first_name"`) — the parser must be quote-aware.
|
||||
const STATS_CSV = [
|
||||
'"last_name, first_name",player_id,team_name_alt,pitch_type,pitch_name,run_value_per_100,pitches,pitch_usage,pa,whiff_percent,k_percent',
|
||||
'"Skenes, Paul",694973,PIT,FF,4-Seam Fastball,1.2,500,32.0,300,26.0,30.0',
|
||||
'"Skenes, Paul",694973,PIT,SL,Slider,2.1,350,22.0,200,41.0,38.0',
|
||||
'"Skenes, Paul",694973,PIT,FS,Splitter,1.8,300,24.0,180,38.0,",', // trailing malformed cell → whiff null-ish; still parses
|
||||
'"Skenes, Paul",694973,PIT,CU,Curveball,0.5,120,14.0,90,33.0,20.0',
|
||||
'"Other, Guy",111111,LAD,CH,Changeup,0.1,50,,40,,', // usage/whiff BLANK → must be null, never 0
|
||||
].join('\n');
|
||||
|
||||
// WIDE velo leaderboard — one row per pitcher, `{abbr}_avg_speed` columns.
|
||||
const VELO_CSV = [
|
||||
'"last_name, first_name",pitcher,team,ff_avg_speed,sl_avg_speed,fs_avg_speed,cu_avg_speed',
|
||||
'"Skenes, Paul",694973,PIT,99.1,87.0,94.2,82.5',
|
||||
].join('\n');
|
||||
|
||||
describe('savantAdapter.getPitcherArsenal', () => {
|
||||
test('normalizes a fake Savant CSV payload into a ranked arsenal (usage desc)', async () => {
|
||||
const out = await savant.getPitcherArsenal(694973, { statsCsv: STATS_CSV, veloCsv: VELO_CSV });
|
||||
expect(out.found).toBe(true);
|
||||
expect(out.playerId).toBe(694973);
|
||||
expect(out.source).toBe('baseball_savant');
|
||||
expect(Array.isArray(out.pitches)).toBe(true);
|
||||
// ranked by usage: FF(32) > FS(24) > SL(22) > CU(14)
|
||||
expect(out.pitches.map((p) => p.type)).toEqual(['FF', 'FS', 'SL', 'CU']);
|
||||
const ff = out.pitches[0];
|
||||
expect(ff.usagePct).toBe(32.0);
|
||||
expect(ff.velo).toBe(99.1); // merged from the WIDE velo CSV by pitch abbr
|
||||
expect(ff.whiffPct).toBe(26.0);
|
||||
const sl = out.pitches.find((p) => p.type === 'SL');
|
||||
expect(sl.velo).toBe(87.0);
|
||||
expect(sl.whiffPct).toBe(41.0);
|
||||
});
|
||||
|
||||
test('missing velo is ABSENT (null), never 0 — velo CSV omitted entirely', async () => {
|
||||
const out = await savant.getPitcherArsenal(694973, { statsCsv: STATS_CSV /* no veloCsv */ });
|
||||
expect(out.found).toBe(true);
|
||||
for (const p of out.pitches) expect(p.velo).toBeNull();
|
||||
});
|
||||
|
||||
test('blank whiff/usage cells parse to null, not 0 (absent beats zero)', async () => {
|
||||
const out = await savant.getPitcherArsenal(111111, { statsCsv: STATS_CSV });
|
||||
expect(out.found).toBe(true);
|
||||
expect(out.pitches).toHaveLength(1);
|
||||
expect(out.pitches[0].usagePct).toBeNull();
|
||||
expect(out.pitches[0].whiffPct).toBeNull();
|
||||
expect(out.pitches[0].velo).toBeNull();
|
||||
});
|
||||
|
||||
test('unrecognized shape → { found:false } (defensive parsing)', async () => {
|
||||
const junk = 'totally,unrelated,columns\n1,2,3';
|
||||
const out = await savant.getPitcherArsenal(694973, { statsCsv: junk });
|
||||
expect(out.found).toBe(false);
|
||||
const empty = await savant.getPitcherArsenal(694973, { statsCsv: '' });
|
||||
expect(empty.found).toBe(false);
|
||||
});
|
||||
|
||||
test('a pitcher with no rows in the feed → { found:false }', async () => {
|
||||
const out = await savant.getPitcherArsenal(999999, { statsCsv: STATS_CSV });
|
||||
expect(out.found).toBe(false);
|
||||
});
|
||||
|
||||
test('resolves a NAME → id via injected resolver, then returns arsenal', async () => {
|
||||
const out = await savant.getPitcherArsenal('Paul Skenes', {
|
||||
statsCsv: STATS_CSV,
|
||||
veloCsv: VELO_CSV,
|
||||
resolveId: async (n) => (/skenes/i.test(n) ? 694973 : null),
|
||||
});
|
||||
expect(out.found).toBe(true);
|
||||
expect(out.playerId).toBe(694973);
|
||||
});
|
||||
|
||||
test('unresolvable name / empty input → { found:false } (never throws)', async () => {
|
||||
const noId = await savant.getPitcherArsenal('Nobody Here', { statsCsv: STATS_CSV, resolveId: async () => null });
|
||||
expect(noId.found).toBe(false);
|
||||
const blank = await savant.getPitcherArsenal('', { statsCsv: STATS_CSV });
|
||||
expect(blank.found).toBe(false);
|
||||
});
|
||||
|
||||
test('quote-aware CSV parser keeps a comma inside a quoted field', () => {
|
||||
const rows = savant.__internals.parseCsv('"last, first",id\n"Skenes, Paul",694973');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].id).toBe('694973');
|
||||
expect(rows[0]['last, first']).toBe('Skenes, Paul');
|
||||
});
|
||||
|
||||
test('pctOrNull scales a fraction but leaves whole percents alone; null stays null', () => {
|
||||
const { pctOrNull } = savant.__internals;
|
||||
expect(pctOrNull('0.324')).toBe(32.4);
|
||||
expect(pctOrNull('32.4')).toBe(32.4);
|
||||
expect(pctOrNull('')).toBeNull();
|
||||
expect(pctOrNull(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Pitcher arsenal proxy (Wave 5B). Forwards GET
|
||||
* /api/stats/pitcher/:name/arsenal to the Express stats route (Baseball Savant
|
||||
* pitch mix / velo / whiff). Thin pass-through; preserves ?sport=. On any
|
||||
* upstream failure it returns { found:false } so the PitcherArsenal card
|
||||
* self-hides honestly rather than showing an error.
|
||||
*/
|
||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ name: string }> }) {
|
||||
const { name } = await ctx.params;
|
||||
const qs = req.nextUrl.search;
|
||||
try {
|
||||
const upstream = await fetch(
|
||||
`${BACKEND_URL}/api/stats/pitcher/${encodeURIComponent(name)}/arsenal${qs}`,
|
||||
{ method: 'GET', headers: { Accept: 'application/json' } },
|
||||
);
|
||||
const data = await upstream.json().catch(() => ({ found: false }));
|
||||
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ found: false }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -1333,6 +1333,9 @@ html[data-font="readable"] .wm::after { opacity: 0.45 !important; }
|
||||
/* Terminal's multi-column grid stacks on mobile. */
|
||||
@media (max-width: 768px) {
|
||||
.terminal-grid { grid-template-columns: 1fr !important; }
|
||||
/* Pitcher-identity (Wave 5B): identity + arsenal columns stack; the arsenal
|
||||
table keeps its own internal grid. */
|
||||
.parsenal-grid { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
|
||||
/* ── Session 59 (work-order 3.2) — overflow containment at 390px ──────── */
|
||||
|
||||
@@ -8,6 +8,7 @@ import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
|
||||
import ModelRecord from '@/components/vyndr/ModelRecord';
|
||||
import PlayerStreaks from '@/components/vyndr/PlayerStreaks';
|
||||
import PitcherArsenal from '@/components/vyndr/PitcherArsenal';
|
||||
import { archetypeInfo } from '@/lib/archetypes';
|
||||
import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter';
|
||||
|
||||
@@ -201,6 +202,13 @@ export default function PlayerProfilePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* C2. PITCHER IDENTITY (Wave 5B) — Baseball Savant arsenal lens. MLB only;
|
||||
the component (heading included) SELF-HIDES for non-pitchers / when
|
||||
Savant has no arsenal. Context, not a graded market value. */}
|
||||
{p.sport === 'mlb' && (
|
||||
<PitcherArsenal name={p.player} sport={p.sport} heading pitcher={{ name: p.player, team: p.team }} />
|
||||
)}
|
||||
|
||||
{/* D. ACTIVE PROPS */}
|
||||
{p.activeProps?.length > 0 && (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* PitcherArsenal (Wave 5B) — the mockup's PITCHER IDENTITY lens: pitch mix % +
|
||||
* velo + whiff%, mono/tabular, ranked by usage. Fed by the Baseball Savant
|
||||
* arsenal adapter (FREE Statcast). "ONE IDENTITY → MANY PROPS".
|
||||
*
|
||||
* HONESTY: arsenal is CONTEXT (the read), never a graded market value. The card
|
||||
* SELF-HIDES (returns null) when Savant has no arsenal for the pitcher — no empty
|
||||
* box, no fabricated numbers. A missing velo/whiff renders as "—" (absent), NOT 0.
|
||||
*
|
||||
* Two modes:
|
||||
* • pass `arsenal` (already fetched by a parent) — renders synchronously.
|
||||
* • pass `name` (+ optional pitcher meta) — fetches /api/stats/pitcher/:name/arsenal.
|
||||
*/
|
||||
|
||||
export interface ArsenalPitch {
|
||||
type: string; // FF / SL / FS / CU …
|
||||
name: string; // "4-Seam Fastball"
|
||||
usagePct: number | null;
|
||||
velo: number | null;
|
||||
whiffPct: number | null;
|
||||
kPct?: number | null;
|
||||
}
|
||||
export interface PitcherArsenalData {
|
||||
found: boolean;
|
||||
playerId?: number | string;
|
||||
pitches?: ArsenalPitch[];
|
||||
}
|
||||
export interface PitcherMeta {
|
||||
name?: string;
|
||||
hand?: string; // RHP / LHP
|
||||
number?: string | number; // jersey
|
||||
team?: string;
|
||||
vs?: string; // opponent abbr
|
||||
confirmed?: boolean; // probable-pitcher confirmed
|
||||
}
|
||||
interface Props {
|
||||
arsenal?: PitcherArsenalData | null;
|
||||
name?: string; // fetch by name when arsenal not supplied
|
||||
sport?: string; // default 'mlb'
|
||||
pitcher?: PitcherMeta; // optional identity header
|
||||
heading?: boolean; // render the "PITCHER IDENTITY" label above the card
|
||||
}
|
||||
|
||||
// Pitch color dots (Statcast-flavored, matching the mockup). Data chrome — no glitch.
|
||||
const PITCH_COLOR: Record<string, string> = {
|
||||
FF: '#FF7A5A', FA: '#FF7A5A', // 4-seam / fastball
|
||||
SI: '#E0803D', FT: '#E0803D', FS: '#E0803D', FO: '#E0803D', // sinker / two-seam / splitter
|
||||
FC: '#E8A33D', // cutter
|
||||
SL: '#7C5CFF', ST: '#7C5CFF', SV: '#9B7CFF', // slider / sweeper / slurve
|
||||
CU: '#6C9CB0', KC: '#6C9CB0', CS: '#6C9CB0', // curveballs
|
||||
CH: '#4FB0A0', SC: '#4FB0A0', // change / screw
|
||||
KN: '#8888A0', EP: '#8888A0', // knuckle / eephus
|
||||
};
|
||||
function pitchColor(type: string) {
|
||||
return PITCH_COLOR[String(type || '').toUpperCase()] || '#6C9CB0';
|
||||
}
|
||||
|
||||
// Absent beats zero — a null velo/whiff/usage renders as an em-dash, never 0.
|
||||
function fmtVelo(v: number | null | undefined) {
|
||||
return v === null || v === undefined ? '—' : v.toFixed(1);
|
||||
}
|
||||
function fmtPct(v: number | null | undefined) {
|
||||
return v === null || v === undefined ? '—' : `${Math.round(v)}%`;
|
||||
}
|
||||
|
||||
const COL = '1fr 56px 46px 52px';
|
||||
|
||||
export default function PitcherArsenal({ arsenal, name, sport = 'mlb', pitcher, heading }: Props) {
|
||||
const [fetched, setFetched] = useState<PitcherArsenalData | null>(arsenal ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (arsenal !== undefined && arsenal !== null) { setFetched(arsenal); return; }
|
||||
if (!name) return;
|
||||
let alive = true;
|
||||
fetch(`/api/stats/pitcher/${encodeURIComponent(name)}/arsenal?sport=${encodeURIComponent(sport)}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => { if (alive) setFetched(d && typeof d === 'object' ? d : null); })
|
||||
.catch(() => { if (alive) setFetched(null); });
|
||||
return () => { alive = false; };
|
||||
}, [arsenal, name, sport]);
|
||||
|
||||
const pitches = fetched && fetched.found && Array.isArray(fetched.pitches) ? fetched.pitches : [];
|
||||
// SELF-HIDE: no real arsenal → render nothing (never an empty shell).
|
||||
if (pitches.length === 0) return null;
|
||||
|
||||
// Highlight the sharpest whiff pitch(es) green — the "what misses bats" read.
|
||||
const maxWhiff = Math.max(...pitches.map((p) => (p.whiffPct ?? -1)));
|
||||
const isSharp = (p: ArsenalPitch) => p.whiffPct !== null && p.whiffPct !== undefined && p.whiffPct >= 30 && p.whiffPct >= maxWhiff - 3;
|
||||
|
||||
const meta = pitcher || {};
|
||||
const monogram = (meta.team || (meta.name || '').slice(0, 3) || 'PIT').toString().slice(0, 3).toUpperCase();
|
||||
|
||||
const Card = (
|
||||
<div style={{ borderRadius: 18, background: 'linear-gradient(180deg,#0F0F17,#0A0A10)', border: '1px solid #2A2A38', overflow: 'hidden', boxShadow: '0 30px 70px -40px rgba(0,0,0,.9), inset 0 1px 0 rgba(255,255,255,.04)' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) minmax(0,1.35fr)', alignItems: 'stretch' }} className="parsenal-grid">
|
||||
{/* identity */}
|
||||
<div style={{ padding: '20px 22px', borderRight: '1px solid #14141E' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 8 }}>
|
||||
<div className="mono" style={{ width: 52, height: 52, flex: 'none', borderRadius: 12, background: 'linear-gradient(135deg,#2b2b2b,#c9a227)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 800, fontSize: 14, color: '#fff' }}>{monogram}</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontFamily: 'var(--sans)', fontWeight: 700, fontSize: 16 }}>{meta.name || 'Probable Pitcher'}</span>
|
||||
{meta.confirmed && (
|
||||
<span className="mono" style={{ display: 'inline-flex', alignItems: 'center', height: 16, padding: '0 6px', borderRadius: 4, background: 'color-mix(in srgb, var(--g-a) 12%, transparent)', border: '1px solid color-mix(in srgb, var(--g-a) 30%, transparent)', color: 'var(--g-a)', fontSize: 8.5, fontWeight: 700, letterSpacing: '.06em' }}>CONFIRMED</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 10.5, color: '#707080', marginTop: 3, letterSpacing: '.05em' }}>
|
||||
{[meta.hand, meta.number != null ? `#${meta.number}` : null, meta.team, meta.vs ? `vs ${meta.vs}` : null].filter(Boolean).join(' · ') || 'ARSENAL'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 14, padding: '11px 13px', borderRadius: 10, background: '#08080D', border: '1px solid #14141E' }}>
|
||||
<div className="mono" style={{ fontSize: 9, letterSpacing: '.2em', color: '#707080', marginBottom: 6 }}>THE ARSENAL READ</div>
|
||||
<p style={{ fontFamily: 'var(--sans)', fontSize: 11.5, lineHeight: 1.5, color: '#B8BCC8', margin: 0 }}>
|
||||
{(() => {
|
||||
const top = pitches[0];
|
||||
const sharp = pitches.find(isSharp);
|
||||
if (sharp && top && sharp.type !== top.type) {
|
||||
return `${top.name} sets it up; the ${sharp.name.toLowerCase()} is the swing-and-miss pitch (${fmtPct(sharp.whiffPct)} whiff).`;
|
||||
}
|
||||
if (top) return `${top.name}-led mix — ${fmtPct(top.usagePct)} usage. One identity, many props.`;
|
||||
return 'Pitch mix read. One identity, many props.';
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* arsenal table */}
|
||||
<div style={{ padding: '20px 22px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: COL, gap: 8, paddingBottom: 9, borderBottom: '1px solid #14141E' }}>
|
||||
<span className="mono" style={{ fontSize: 8.5, letterSpacing: '.14em', color: '#4a4a58' }}>PITCH</span>
|
||||
<span className="mono" style={{ fontSize: 8.5, letterSpacing: '.14em', color: '#4a4a58', textAlign: 'right' }}>VELO</span>
|
||||
<span className="mono" style={{ fontSize: 8.5, letterSpacing: '.14em', color: '#4a4a58', textAlign: 'right' }}>USE</span>
|
||||
<span className="mono" style={{ fontSize: 8.5, letterSpacing: '.14em', color: '#4a4a58', textAlign: 'right' }}>WHIFF</span>
|
||||
</div>
|
||||
{pitches.map((p, i) => {
|
||||
const sharp = isSharp(p);
|
||||
return (
|
||||
<div key={`${p.type}-${i}`} style={{ display: 'grid', gridTemplateColumns: COL, gap: 8, alignItems: 'center', padding: '8px 0', borderBottom: i < pitches.length - 1 ? '1px solid #101018' : 'none' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<span style={{ width: 6, height: 6, flex: 'none', borderRadius: 2, background: pitchColor(p.type) }} />
|
||||
<span style={{ fontFamily: 'var(--sans)', fontSize: 12.5, fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name}</span>
|
||||
</span>
|
||||
<span className="mono" style={{ fontSize: 12, color: '#F0F0F0', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>{fmtVelo(p.velo)}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: '#B8BCC8', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>{fmtPct(p.usagePct)}</span>
|
||||
<span className="mono" style={{ fontSize: sharp ? 12 : 11, fontWeight: sharp ? 700 : 400, color: sharp ? 'var(--g-a)' : '#B8BCC8', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>{fmtPct(p.whiffPct)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mono" style={{ fontSize: 8, letterSpacing: '.12em', color: '#4a4a58', marginTop: 12, textAlign: 'right' }}>SOURCE · BASEBALL SAVANT (STATCAST)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!heading) return Card;
|
||||
// Heading lives INSIDE the self-hide guard (past the early `return null`), so an
|
||||
// absent arsenal drops the label too — never an orphan header.
|
||||
return (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<span style={{ width: 6, height: 6, background: 'var(--g-a)', borderRadius: 1, flex: 'none' }} />
|
||||
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.1em', color: 'var(--g-a)' }}>PITCHER IDENTITY</span>
|
||||
<span className="mono" style={{ fontSize: 10, color: 'var(--text-2)' }}>ONE IDENTITY → MANY PROPS</span>
|
||||
</div>
|
||||
{Card}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export { default as ArchetypeBlend } from './ArchetypeBlend';
|
||||
export type { BlendSegment } from './ArchetypeBlend';
|
||||
export { default as StatStrip } from './StatStrip';
|
||||
export type { StatCell, StripProp, StripArchetype } from './StatStrip';
|
||||
export { default as PitcherArsenal } from './PitcherArsenal';
|
||||
export type { PitcherArsenalData, ArsenalPitch, PitcherMeta } from './PitcherArsenal';
|
||||
export { default as BookChip } from './BookChip';
|
||||
|
||||
/* DS0 (Design v2) — the Entity Layer: teams/players/books as themselves. */
|
||||
|
||||
Reference in New Issue
Block a user