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:
Kev
2026-07-13 16:04:06 -04:00
9 changed files with 681 additions and 1 deletions
+22 -1
View File
@@ -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) => {
+277
View File
@@ -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
});
});
+100
View File
@@ -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 });
}
}
+3
View File
@@ -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
View File
@@ -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 && (
<>
+175
View File
@@ -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>
);
}
+2
View File
@@ -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. */