WPatrick81 commited on
Commit
00c0c19
·
verified ·
1 Parent(s): 474e54a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +145 -41
app.py CHANGED
@@ -37,9 +37,6 @@ LEAGUE_LABELS = {
37
  CACHE_DIR = "/tmp"
38
  MIN_YEAR = 2024
39
 
40
- # ★ 리그별 투수 이름 컬럼 매핑
41
- # KBO / Minor → NAME_pitcher (한국어)
42
- # NPB / AAA → pitname (영어)
43
  NAME_COL_MAP = {
44
  "KBO": "NAME_pitcher",
45
  "Minor": "NAME_pitcher",
@@ -48,7 +45,6 @@ NAME_COL_MAP = {
48
  }
49
 
50
  def get_name_col(league: str, df: pd.DataFrame) -> str:
51
- """리그에 맞는 이름 컬럼 반환. 없으면 pitname으로 fallback."""
52
  preferred = NAME_COL_MAP.get(league, "pitname")
53
  if preferred in df.columns:
54
  return preferred
@@ -115,7 +111,6 @@ def load_league_data(league_name, min_year=MIN_YEAR):
115
  print(f"파싱 실패: {e}")
116
  return pd.DataFrame()
117
 
118
- # 앱 시작 시 KBO 사전 로드
119
  league_cache: dict = {}
120
  league_cache["KBO"] = load_league_data("KBO")
121
 
@@ -131,12 +126,6 @@ def find_col(df, candidates):
131
  return None
132
 
133
  def build_pitcher_options(df: pd.DataFrame, league: str = "KBO"):
134
- """
135
- 리그에 따라 이름 컬럼을 다르게 사용하여 투수 드롭다운 옵션 생성.
136
- - KBO / Minor : NAME_pitcher (한국어)
137
- - NPB / AAA : pitname (영어)
138
- 드롭다운 value = 이름 컬럼 값 (한국어 or 영어)
139
- """
140
  name_col = get_name_col(league, df)
141
  throws_col = find_col(df, THROWS_CANDIDATES)
142
  team_col = "pitcherteam" if "pitcherteam" in df.columns else None
@@ -426,7 +415,7 @@ def make_runner_stats_section(df, latest_year, dcard_style, id_suffix=""):
426
  ])
427
 
428
  # ════════════════════════════════════════════════════════════
429
- # 7-D. 주자 상황별 구종 사용 비율 차트
430
  # ════════════════════════════════════════════════════════════
431
  def make_runner_usage_chart(df, pitcher_name):
432
  if "runner" not in df.columns:
@@ -480,6 +469,86 @@ def make_runner_usage_chart(df, pitcher_name):
480
  font=dict(color="tomato", size=13))
481
  return fig
482
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
  # ════════════════════════════════════════════════════════════
484
  # 8-A. 무브먼트 차트
485
  # ════════════════════════════════════════════════════════════
@@ -900,7 +969,6 @@ def make_location_fig(filtered_df, pitcher_name, year_filter=None):
900
  ), row=r_idx, col=c_idx)
901
  _add_zone_overlays(fig, r_idx, c_idx)
902
 
903
- # ── 축 설정
904
  for r_idx in range(1, n_rows + 1):
905
  for c_idx in range(1, n_cols + 1):
906
  ai = (r_idx - 1) * n_cols + c_idx
@@ -917,7 +985,6 @@ def make_location_fig(filtered_df, pitcher_name, year_filter=None):
917
  linecolor="rgba(108,122,137,0.9)", mirror=True),
918
  })
919
 
920
- # ── 좌/우타자 레이블
921
  fig.add_annotation(x=-0.02, y=0.78, text="우타자", showarrow=False,
922
  xref="paper", yref="paper", textangle=-90,
923
  font=dict(size=11, color="#2c3e50"))
@@ -938,6 +1005,8 @@ def make_location_fig(filtered_df, pitcher_name, year_filter=None):
938
  )
939
  for ann in fig.layout.annotations:
940
  ann.font.size = 10
 
 
941
  return fig
942
 
943
  # ── 9-B. 결과별 로케이션
@@ -974,12 +1043,12 @@ def make_result_location_chart(filtered_df, pitcher_name,
974
  swdf = pd.concat(frames, ignore_index=True)
975
 
976
  SWING_COLORS = {
977
- "스트라이크(콜)": "rgba(24,85,144,0.6)",
978
- "헛스윙": "rgba(210,160,0,0.9)",
979
- "볼": "rgba(108,122,137,0.7)",
980
- "파울": "rgba(241,106,227,0.5)",
981
- "강한타구(LSA4+)": "rgba(255,105,97,1)",
982
- "약한타구(LSA3-)": "rgba(140,86,75,0.6)",
983
  }
984
  SWING_ORDER = ["스트라이크(콜)","볼","파울","헛스윙","강한타구(LSA4+)","약한타구(LSA3-)"]
985
  avail_sw = [s for s in SWING_ORDER if s in swdf["swingmap"].unique()]
@@ -1023,7 +1092,6 @@ def make_result_location_chart(filtered_df, pitcher_name,
1023
  ), row=r_idx, col=c_idx)
1024
  _add_zone_overlays(fig, r_idx, c_idx)
1025
 
1026
- # ── 축 설정
1027
  for r_idx in range(1, n_rows + 1):
1028
  for c_idx in range(1, n_cols + 1):
1029
  ai = (r_idx - 1) * n_cols + c_idx
@@ -1040,7 +1108,6 @@ def make_result_location_chart(filtered_df, pitcher_name,
1040
  linecolor="rgba(108,122,137,0.9)", mirror=True),
1041
  })
1042
 
1043
- # ★ 추가: 우타자/좌타자 왼쪽 세로 레이블
1044
  fig.add_annotation(x=-0.02, y=0.78, text="우타자", showarrow=False,
1045
  xref="paper", yref="paper", textangle=-90,
1046
  font=dict(size=11, color="#2c3e50"))
@@ -1058,11 +1125,10 @@ def make_result_location_chart(filtered_df, pitcher_name,
1058
  x=0.5, font=dict(size=13)),
1059
  plot_bgcolor="white", paper_bgcolor="white", showlegend=False,
1060
  autosize=False, width=fig_w, height=fig_h,
1061
- margin=dict(l=60, r=20, t=80, b=40), # ★ l=40 → l=60 으로 변경
1062
  )
1063
  for ann in fig.layout.annotations:
1064
  ann.font.size = 9
1065
- # ★ 우타자/좌타자 레이블은 font.size=9 덮어쓰기 방지를 위해 마지막에 재설정
1066
  fig.layout.annotations[-2].font.size = 11
1067
  fig.layout.annotations[-1].font.size = 11
1068
  return fig
@@ -1238,13 +1304,13 @@ app.layout = html.Div([
1238
  html.Div(id="main-loading-banner", children=[],
1239
  style={"marginBottom":"8px"}),
1240
  dbc.Tabs(id="main-tabs", active_tab="tab-overview", children=[
1241
- dbc.Tab(label="[주요스탯]", tab_id="tab-overview"),
1242
- dbc.Tab(label="[무브먼트 차트]", tab_id="tab-movement"),
1243
- dbc.Tab(label="[구종사용비율(타자유형별)]", tab_id="tab-usage"),
1244
  dbc.Tab(label="[구종사용비율(카운트별)]", tab_id="tab-count"),
1245
- dbc.Tab(label="[구종별 로케이션]", tab_id="tab-location"),
1246
- dbc.Tab(label="[결과별 로케이션]", tab_id="tab-result-loc"),
1247
- dbc.Tab(label="[리그비교]", tab_id="tab-league"),
1248
  ]),
1249
  html.Div(id="tab-content", style={"marginTop":"16px"}),
1250
  ], id="page-content", style=CONTENT_STYLE),
@@ -1591,15 +1657,47 @@ def render_tab(active_tab, fkey, bkey,
1591
  return html.Div("선택된 조건에 해당하는 데이터가 없습니다.",
1592
  style={"color":"#888","padding":"40px"})
1593
 
1594
- orient = orientation if orientation in ("portrait","landscape") \
1595
- else "landscape"
1596
- pat = r"^\d{4}-\d{2}-\d{2}$"
1597
-
1598
  card_style = {"boxShadow":"0 1px 4px rgba(0,0,0,0.08)"}
1599
  dcard_style = {"boxShadow":"0 1px 4px rgba(0,0,0,0.06)"}
1600
 
1601
- # ── 공통: 주자 상황별 expander 블록
 
 
1602
  def _runner_expander(suffix, latest_year):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1603
  return html.Div([
1604
  html.Button(
1605
  "▼ 주자 상황별 스탯 보기",
@@ -1608,9 +1706,11 @@ def render_tab(active_tab, fkey, bkey,
1608
  ),
1609
  dbc.Collapse(
1610
  dbc.Card(dbc.CardBody(html.Div([
 
1611
  make_runner_stats_section(
1612
  filtered_df, latest_year,
1613
  dcard_style, id_suffix=f"-{suffix}"),
 
1614
  dbc.Card(dbc.CardBody([
1615
  html.H6("📊 무주자 vs 유주자 구종 사용 비율",
1616
  style={"fontWeight":"700","marginBottom":"8px"}),
@@ -1619,7 +1719,11 @@ def render_tab(active_tab, fkey, bkey,
1619
  filtered_df, pitcher_name),
1620
  config={"displayModeBar":False},
1621
  ),
1622
- ]), style=dcard_style),
 
 
 
 
1623
  ])), style={"marginBottom":"12px", **dcard_style}),
1624
  id=f"runner-collapse-{suffix}", is_open=False,
1625
  ),
@@ -1696,7 +1800,7 @@ def render_tab(active_tab, fkey, bkey,
1696
  "전체 기간 데이터 없음", style={"color":"#888","fontSize":"13px"})
1697
 
1698
  return html.Div([
1699
- html.H5(f"📋 {pitcher_name} — Overview ({latest_year})",
1700
  style={"fontWeight":"700","marginBottom":"12px"}),
1701
  dbc.Card(dbc.CardBody([
1702
  html.H6("🔧 트래킹 지표",
@@ -1749,12 +1853,12 @@ def render_tab(active_tab, fkey, bkey,
1749
  ])
1750
 
1751
  # ════════════════════════════════
1752
- # Usage
1753
  # ════════════════════════════════
1754
  elif active_tab == "tab-usage":
1755
  latest_year = filtered_df["game_year"].max()
1756
  return html.Div([
1757
- html.H5(f"📈 {pitcher_name} — 구종 사용 비율",
1758
  style={"fontWeight":"700","marginBottom":"12px"}),
1759
  dbc.Card(dbc.CardBody(
1760
  dcc.Graph(
@@ -1765,12 +1869,12 @@ def render_tab(active_tab, fkey, bkey,
1765
  ])
1766
 
1767
  # ════════════════════════════════
1768
- # Count
1769
  # ════════════════════════════════
1770
  elif active_tab == "tab-count":
1771
  latest_year = filtered_df["game_year"].max()
1772
  return html.Div([
1773
- html.H5(f"🔢 {pitcher_name} — 카운트별 구종 사용",
1774
  style={"fontWeight":"700","marginBottom":"12px"}),
1775
  dbc.Card(dbc.CardBody(
1776
  dcc.Graph(
 
37
  CACHE_DIR = "/tmp"
38
  MIN_YEAR = 2024
39
 
 
 
 
40
  NAME_COL_MAP = {
41
  "KBO": "NAME_pitcher",
42
  "Minor": "NAME_pitcher",
 
45
  }
46
 
47
  def get_name_col(league: str, df: pd.DataFrame) -> str:
 
48
  preferred = NAME_COL_MAP.get(league, "pitname")
49
  if preferred in df.columns:
50
  return preferred
 
111
  print(f"파싱 실패: {e}")
112
  return pd.DataFrame()
113
 
 
114
  league_cache: dict = {}
115
  league_cache["KBO"] = load_league_data("KBO")
116
 
 
126
  return None
127
 
128
  def build_pitcher_options(df: pd.DataFrame, league: str = "KBO"):
 
 
 
 
 
 
129
  name_col = get_name_col(league, df)
130
  throws_col = find_col(df, THROWS_CANDIDATES)
131
  team_col = "pitcherteam" if "pitcherteam" in df.columns else None
 
415
  ])
416
 
417
  # ════════════════════════════════════════════════════════════
418
+ # 7-D. 주자 상황별 구종 사용 비율 차트 (전체)
419
  # ════════════════════════════════════════════════════════════
420
  def make_runner_usage_chart(df, pitcher_name):
421
  if "runner" not in df.columns:
 
469
  font=dict(color="tomato", size=13))
470
  return fig
471
 
472
+ # ════════════════════════════════════════════════════════════
473
+ # ★ 7-E. 주자 상황별 구종 사용 비율 차트 (좌/우타자 분리) — 신규 추가
474
+ # ════════════════════════════════════════════════════════════
475
+ def make_runner_usage_by_stand_chart(df, pitcher_name, runner_label="무주자"):
476
+ """
477
+ 무주자 또는 유주자 데이터에서
478
+ 좌타자(L) / 우타자(R) 구종 사용 비율을 butterfly 차트로 표시.
479
+ runner_label: "무주자" or "유주자" (제목용)
480
+ """
481
+ if df.empty or "stand" not in df.columns:
482
+ return go.Figure()
483
+
484
+ latest_year = df["game_year"].max()
485
+
486
+ def get_stand_perc(src, stand):
487
+ sub = src[src["stand"] == stand]
488
+ if sub.empty: return pd.Series(dtype=float)
489
+ cnt = sub["pitch_name"].value_counts()
490
+ return (cnt / cnt.sum() * 100).round(1)
491
+
492
+ p_L = get_stand_perc(df, "L")
493
+ p_R = get_stand_perc(df, "R")
494
+
495
+ available = [p for p in PITCH_ORDER if p in set(p_L.index) | set(p_R.index)]
496
+ for p in available:
497
+ if p not in p_L.index: p_L[p] = 0.0
498
+ if p not in p_R.index: p_R[p] = 0.0
499
+
500
+ y_base = np.arange(len(available))
501
+ y_L = y_base + 0.10
502
+ y_R = y_base - 0.10
503
+
504
+ cnt_L = len(df[df["stand"] == "L"])
505
+ cnt_R = len(df[df["stand"] == "R"])
506
+
507
+ fig = go.Figure()
508
+ # 좌타자 (왼쪽 방향 음수)
509
+ fig.add_trace(go.Bar(
510
+ y=y_L, x=[-p_L.get(p, 0) for p in available],
511
+ orientation="h", name=f"좌타자 ({cnt_L}구)",
512
+ marker_color="skyblue", width=0.30,
513
+ text=[f"{p_L.get(p,0):.1f}%" if p_L.get(p,0) > 0 else "" for p in available],
514
+ textposition="outside",
515
+ ))
516
+ # 우타자 (오른쪽 방향 양수)
517
+ fig.add_trace(go.Bar(
518
+ y=y_R, x=[p_R.get(p, 0) for p in available],
519
+ orientation="h", name=f"우타자 ({cnt_R}구)",
520
+ marker_color="salmon", width=0.30,
521
+ text=[f"{p_R.get(p,0):.1f}%" if p_R.get(p,0) > 0 else "" for p in available],
522
+ textposition="outside",
523
+ ))
524
+ fig.update_layout(
525
+ title=dict(
526
+ text=f"{pitcher_name} {runner_label} — 좌/우타자별 구종 사용 비율 ({latest_year})",
527
+ x=0.5, font=dict(size=12)),
528
+ barmode="overlay", plot_bgcolor="white", autosize=True,
529
+ xaxis=dict(
530
+ tickmode="array", tickvals=[-60,-40,-20,0,20,40,60],
531
+ ticktext=["60%","40%","20%","0","20%","40%","60%"],
532
+ range=[-75,75], showgrid=True,
533
+ gridcolor="rgba(230,230,230,0.7)",
534
+ zeroline=True, zerolinecolor="gray", zerolinewidth=1,
535
+ ),
536
+ yaxis=dict(
537
+ tickmode="array", tickvals=y_base, ticktext=available,
538
+ autorange="reversed", range=[-0.7, len(available)-0.3],
539
+ ),
540
+ legend=dict(orientation="h", x=0.5, xanchor="center",
541
+ y=-0.15, yanchor="top"),
542
+ margin=dict(l=100, r=30, t=60, b=60), autosize=True,
543
+ )
544
+ fig.add_annotation(x=-10, y=1.06, text="[좌타자]", showarrow=False,
545
+ xref="x", yref="paper", xanchor="right", yanchor="bottom",
546
+ font=dict(color="skyblue", size=12))
547
+ fig.add_annotation(x=10, y=1.06, text="[우타자]", showarrow=False,
548
+ xref="x", yref="paper", xanchor="left", yanchor="bottom",
549
+ font=dict(color="salmon", size=12))
550
+ return fig
551
+
552
  # ════════════════════════════════════════════════════════════
553
  # 8-A. 무브먼트 차트
554
  # ════════════════════════════════════════════════════════════
 
969
  ), row=r_idx, col=c_idx)
970
  _add_zone_overlays(fig, r_idx, c_idx)
971
 
 
972
  for r_idx in range(1, n_rows + 1):
973
  for c_idx in range(1, n_cols + 1):
974
  ai = (r_idx - 1) * n_cols + c_idx
 
985
  linecolor="rgba(108,122,137,0.9)", mirror=True),
986
  })
987
 
 
988
  fig.add_annotation(x=-0.02, y=0.78, text="우타자", showarrow=False,
989
  xref="paper", yref="paper", textangle=-90,
990
  font=dict(size=11, color="#2c3e50"))
 
1005
  )
1006
  for ann in fig.layout.annotations:
1007
  ann.font.size = 10
1008
+ fig.layout.annotations[-2].font.size = 11
1009
+ fig.layout.annotations[-1].font.size = 11
1010
  return fig
1011
 
1012
  # ── 9-B. 결과별 로케이션
 
1043
  swdf = pd.concat(frames, ignore_index=True)
1044
 
1045
  SWING_COLORS = {
1046
+ "스트라이크(콜)": "rgba(24,85,144,0.6)",
1047
+ "헛스윙": "rgba(210,160,0,0.9)",
1048
+ "볼": "rgba(108,122,137,0.7)",
1049
+ "파울": "rgba(241,106,227,0.5)",
1050
+ "강한타구(LSA4+)": "rgba(255,105,97,1)",
1051
+ "약한타구(LSA3-)": "rgba(140,86,75,0.6)",
1052
  }
1053
  SWING_ORDER = ["스트라이크(콜)","볼","파울","헛스윙","강한타구(LSA4+)","약한타구(LSA3-)"]
1054
  avail_sw = [s for s in SWING_ORDER if s in swdf["swingmap"].unique()]
 
1092
  ), row=r_idx, col=c_idx)
1093
  _add_zone_overlays(fig, r_idx, c_idx)
1094
 
 
1095
  for r_idx in range(1, n_rows + 1):
1096
  for c_idx in range(1, n_cols + 1):
1097
  ai = (r_idx - 1) * n_cols + c_idx
 
1108
  linecolor="rgba(108,122,137,0.9)", mirror=True),
1109
  })
1110
 
 
1111
  fig.add_annotation(x=-0.02, y=0.78, text="우타자", showarrow=False,
1112
  xref="paper", yref="paper", textangle=-90,
1113
  font=dict(size=11, color="#2c3e50"))
 
1125
  x=0.5, font=dict(size=13)),
1126
  plot_bgcolor="white", paper_bgcolor="white", showlegend=False,
1127
  autosize=False, width=fig_w, height=fig_h,
1128
+ margin=dict(l=60, r=20, t=80, b=40),
1129
  )
1130
  for ann in fig.layout.annotations:
1131
  ann.font.size = 9
 
1132
  fig.layout.annotations[-2].font.size = 11
1133
  fig.layout.annotations[-1].font.size = 11
1134
  return fig
 
1304
  html.Div(id="main-loading-banner", children=[],
1305
  style={"marginBottom":"8px"}),
1306
  dbc.Tabs(id="main-tabs", active_tab="tab-overview", children=[
1307
+ dbc.Tab(label="[주요스탯]", tab_id="tab-overview"),
1308
+ dbc.Tab(label="[무브먼트 차트]", tab_id="tab-movement"),
1309
+ dbc.Tab(label="[구종사용비율(타자유형별)]", tab_id="tab-usage"),
1310
  dbc.Tab(label="[구종사용비율(카운트별)]", tab_id="tab-count"),
1311
+ dbc.Tab(label="[구종별 로케이션]", tab_id="tab-location"),
1312
+ dbc.Tab(label="[결과별 로케이션]", tab_id="tab-result-loc"),
1313
+ dbc.Tab(label="[리그비교]", tab_id="tab-league"),
1314
  ]),
1315
  html.Div(id="tab-content", style={"marginTop":"16px"}),
1316
  ], id="page-content", style=CONTENT_STYLE),
 
1657
  return html.Div("선택된 조건에 해당하는 데이터가 없습니다.",
1658
  style={"color":"#888","padding":"40px"})
1659
 
1660
+ orient = orientation if orientation in ("portrait","landscape") \
1661
+ else "landscape"
 
 
1662
  card_style = {"boxShadow":"0 1px 4px rgba(0,0,0,0.08)"}
1663
  dcard_style = {"boxShadow":"0 1px 4px rgba(0,0,0,0.06)"}
1664
 
1665
+ # ════════════════════════════════
1666
+ # ★ 공통: 주자 상황별 expander 블록 (무주자/유주자 구종사용비율 좌우타 추가)
1667
+ # ════════════════════════════════
1668
  def _runner_expander(suffix, latest_year):
1669
+ no_runner, yes_runner = split_by_runner(filtered_df) \
1670
+ if "runner" in filtered_df.columns \
1671
+ else (pd.DataFrame(), pd.DataFrame())
1672
+
1673
+ # ── 무주자 좌/우타자 구종사용비율 차트
1674
+ no_runner_stand_chart = html.Div()
1675
+ if not no_runner.empty:
1676
+ no_runner_stand_chart = dbc.Card(dbc.CardBody([
1677
+ html.H6("⬜ 무주자 — 좌/우타자별 구종 사용 비율",
1678
+ style={"fontWeight":"700","marginBottom":"8px",
1679
+ "color":"#2c7be5"}),
1680
+ dcc.Graph(
1681
+ figure=make_runner_usage_by_stand_chart(
1682
+ no_runner, pitcher_name, runner_label="무주자"),
1683
+ config={"displayModeBar":False},
1684
+ ),
1685
+ ]), className="mb-3", style=dcard_style)
1686
+
1687
+ # ── 유주자 좌/우타자 구종사용비율 차트
1688
+ yes_runner_stand_chart = html.Div()
1689
+ if not yes_runner.empty:
1690
+ yes_runner_stand_chart = dbc.Card(dbc.CardBody([
1691
+ html.H6("🟩 유주자 — 좌/우타자별 구종 사용 비율",
1692
+ style={"fontWeight":"700","marginBottom":"8px",
1693
+ "color":"#e55c2c"}),
1694
+ dcc.Graph(
1695
+ figure=make_runner_usage_by_stand_chart(
1696
+ yes_runner, pitcher_name, runner_label="유주자"),
1697
+ config={"displayModeBar":False},
1698
+ ),
1699
+ ]), className="mb-3", style=dcard_style)
1700
+
1701
  return html.Div([
1702
  html.Button(
1703
  "▼ 주자 상황별 스탯 보기",
 
1706
  ),
1707
  dbc.Collapse(
1708
  dbc.Card(dbc.CardBody(html.Div([
1709
+ # 기존: 무주자/유주자 스탯 테이블
1710
  make_runner_stats_section(
1711
  filtered_df, latest_year,
1712
  dcard_style, id_suffix=f"-{suffix}"),
1713
+ # 기존: 무주자 vs 유주자 전체 구종사용비율
1714
  dbc.Card(dbc.CardBody([
1715
  html.H6("📊 무주자 vs 유주자 구종 사용 비율",
1716
  style={"fontWeight":"700","marginBottom":"8px"}),
 
1719
  filtered_df, pitcher_name),
1720
  config={"displayModeBar":False},
1721
  ),
1722
+ ]), className="mb-3", style=dcard_style),
1723
+ # ★ 신규: 무주자 좌/우타자별 구종사용비율
1724
+ no_runner_stand_chart,
1725
+ # ★ 신규: 유주자 좌/우타자별 구종사용비율
1726
+ yes_runner_stand_chart,
1727
  ])), style={"marginBottom":"12px", **dcard_style}),
1728
  id=f"runner-collapse-{suffix}", is_open=False,
1729
  ),
 
1800
  "전체 기간 데이터 없음", style={"color":"#888","fontSize":"13px"})
1801
 
1802
  return html.Div([
1803
+ html.H5(f"📋 {pitcher_name} — 주요 스탯 ({latest_year})",
1804
  style={"fontWeight":"700","marginBottom":"12px"}),
1805
  dbc.Card(dbc.CardBody([
1806
  html.H6("🔧 트래킹 지표",
 
1853
  ])
1854
 
1855
  # ════════════════════════════════
1856
+ # Usage ★ expander에 좌/우타자 구종사용비율 차트 포함
1857
  # ════════════════════════════════
1858
  elif active_tab == "tab-usage":
1859
  latest_year = filtered_df["game_year"].max()
1860
  return html.Div([
1861
+ html.H5(f"📈 {pitcher_name} — 구종사용비율(타자유형별)",
1862
  style={"fontWeight":"700","marginBottom":"12px"}),
1863
  dbc.Card(dbc.CardBody(
1864
  dcc.Graph(
 
1869
  ])
1870
 
1871
  # ════════════════════════════════
1872
+ # Count ★ expander에 좌/우타자 구종사용비율 차트 포함
1873
  # ════════════════════════════════
1874
  elif active_tab == "tab-count":
1875
  latest_year = filtered_df["game_year"].max()
1876
  return html.Div([
1877
+ html.H5(f"🔢 {pitcher_name} — 구종사용비율(카운트별)",
1878
  style={"fontWeight":"700","marginBottom":"12px"}),
1879
  dbc.Card(dbc.CardBody(
1880
  dcc.Graph(