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
@@ -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. */