Spaces:
Runtime error
Runtime error
Update app.py
Browse files
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 |
-
"스트라이크(콜)":
|
| 978 |
-
"헛스윙":
|
| 979 |
-
"볼":
|
| 980 |
-
"파울":
|
| 981 |
-
"강한타구(LSA4+)":
|
| 982 |
-
"약한타구(LSA3-)":
|
| 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),
|
| 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="[주요스탯]",
|
| 1242 |
-
dbc.Tab(label="[무브먼트 차트]",
|
| 1243 |
-
dbc.Tab(label="[구종사용비율(타자유형별)]",
|
| 1244 |
dbc.Tab(label="[구종사용비율(카운트별)]", tab_id="tab-count"),
|
| 1245 |
-
dbc.Tab(label="[구종별 로케이션]",
|
| 1246 |
-
dbc.Tab(label="[결과별 로케이션]",
|
| 1247 |
-
dbc.Tab(label="[리그비교]",
|
| 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
|
| 1595 |
-
|
| 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 |
-
#
|
|
|
|
|
|
|
| 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} —
|
| 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(
|