|
|
|
@@ -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>
|
|
|
|
|
);
|
|
|
|
|
}
|