/* ─── 히트존 매핑 상세 화면 ────────────────────────────────────────────── */ /* 5×5 zone, 안쪽 3×3이 스트라이크 존. 각 셀에 더미 (avg, slg, n). */ const HZ_PLAYERS = { "lee-jh": { ko:"이정후", team:"SF", lg:"MLB", color:"#FD5A1E", role:"좌타자" }, "kim-hys": { ko:"김혜성", team:"LAD", lg:"MLB", color:"#005A9C", role:"좌타자" }, "kim-hs": { ko:"김하성", team:"TB", lg:"MLB", color:"#092C5C", role:"우타자" }, "kim-dy": { ko:"김도영", team:"KIA", lg:"KBO", color:"#EA0029", role:"좌타자" }, "austin": { ko:"오스틴", team:"LG", lg:"KBO", color:"#C30452", role:"우타자" }, }; /* 5×5 matrix of {avg, slg, n}. Inner 3×3 are strike zone. */ function makeZone(seed=1) { // simple deterministic-ish dummy zone for each player+hand+season combo const rng = (a,b,k)=> { const x = Math.sin((a+1)*(b+2)*seed + k)*10000; return x - Math.floor(x); }; const grid = []; for (let r=0; r<5; r++){ const row = []; for (let c=0; c<5; c++){ const strike = r>=1 && r<=3 && c>=1 && c<=3; const base = strike ? 0.25 : 0.18; const center = (r===2 && c===2) ? 0.12 : (r===2||c===2) && strike ? 0.07 : 0; const noise = rng(r,c,0)*0.10; const avg = Math.max(0.12, Math.min(0.52, base + center + noise)); const slg = Math.max(0.18, Math.min(0.92, avg + 0.18 + rng(r,c,1)*0.22)); const n = strike ? 28 + Math.floor(rng(r,c,2)*42) : 8 + Math.floor(rng(r,c,2)*16); row.push({avg,slg,n,strike}); } grid.push(row); } return grid; } const ZONE_NAMES = [ ["하이 아웃","하이 인","하이 센터","하이 가까이","하이 몸쪽"], ["바깥 높음","아웃 하이","상단","인 하이","몸쪽 높음"], ["바깥","아웃","센터","인","몸쪽"], ["바깥 낮음","아웃 로우","하단","인 로우","몸쪽 낮음"], ["로우 아웃","로우 인","로우 센터","로우 가까이","로우 몸쪽"], ]; function ToolHeatzone({ onClose }) { const [pid, setPid] = React.useState("lee-jh"); const [hand, setHand] = React.useState("R"); // 상대 투수 손 const [season, setSeason] = React.useState("2026"); const [hover, setHover] = React.useState(null); const player = HZ_PLAYERS[pid]; const seedKey = pid.length + (hand==="R"?2:5) + season.charCodeAt(3); const zone = React.useMemo(() => makeZone(seedKey), [pid,hand,season]); const flat = []; zone.forEach((row,r)=>row.forEach((c,i)=>flat.push({r,c:i,...c,name:ZONE_NAMES[r][i]}))); const strikes = flat.filter(c=>c.strike).sort((a,b)=>b.avg-a.avg); const top3 = strikes.slice(0,3); const bot3 = [...strikes].sort((a,b)=>a.avg-b.avg).slice(0,3); const maxAvg = Math.max(...flat.map(c=>c.avg)); return (
e.stopPropagation()}> {/* banner */}
인터랙티브 도구

히트존 매핑 — 9구역 타율 시각화

{/* LEFT: controls */}
선수 선택
{Object.entries(HZ_PLAYERS).map(([id,p])=>(
setPid(id)}>
{p.ko[0]}
{p.ko}
{p.lg} · {p.team} · {p.role}
))}
상대 투수
{[["R","우완"],["L","좌완"]].map(([k,l])=>( setHand(k)}>{l} ))}
시즌
{["2024","2025","2026"].map(s=>( setSeason(s)}>{s} ))}
{/* CENTER: zone */}
{player.ko}{season} · {hand==="R"?"우완":"좌완"} 상대
표본 {flat.reduce((s,c)=>s+c.n,0)}타석
{zone.flatMap((row,r)=>row.map((cell,c)=>{ const heat = (cell.avg / maxAvg); const bg = `color-mix(in oklab, var(--mlb) ${Math.round(heat*92)}%, transparent)`; const isHov = hover && hover.r===r && hover.c===c; return (
setHover({r,c,...cell})} onMouseLeave={()=>setHover(null)}> {isHov && (
{ZONE_NAMES[r][c]}
.{Math.round(cell.avg*1000).toString().padStart(3,"0")}
SLG .{Math.round(cell.slg*1000).toString().padStart(3,"0")} n {cell.n}
)}
); }))}
차가움
뜨거움
{/* opponent pitcher type bar */}
상대 투수 구종 분포
{[ {k:"포심 패스트볼", p:42, c:"var(--mlb)"}, {k:"슬라이더", p:24, c:"var(--kbo)"}, {k:"체인지업", p:18, c:"var(--warn)"}, {k:"커브", p:11, c:"var(--pos)"}, {k:"기타", p: 5, c:"var(--text-3)"}, ].map((r,i)=>(
{r.k}
{r.p}%
))}
{/* RIGHT: strengths / weaknesses */}
강점 코스 TOP 3
{top3.map((c,i)=>(
{i+1}
{c.name}
SLG .{Math.round(c.slg*1000).toString().padStart(3,"0")} · n {c.n}
.{Math.round(c.avg*1000).toString().padStart(3,"0")}
))}
약점 코스 TOP 3
{bot3.map((c,i)=>(
{i+1}
{c.name}
SLG .{Math.round(c.slg*1000).toString().padStart(3,"0")} · n {c.n}
.{Math.round(c.avg*1000).toString().padStart(3,"0")}
))}
※ 표본 30타석 이하 코스는 참고용 데이터 분석 자료로만 활용하세요.
); } /* 3×3 mini zone with one cell highlighted */ function ZoneMini({ r, ci, cold }) { // map 5×5 strike cells (r in [1..3], ci in [1..3]) to 3×3 inner cells const rr = r-1, cc = ci-1; return (
{Array.from({length:9}).map((_,k)=>{ const x = k%3, y = Math.floor(k/3); const on = (x===cc && y===rr); return ; })}
); } window.ToolHeatzone = ToolHeatzone;