import streamlit as st import pandas as pd import plotly.graph_objects as go import plotly.io as pio from zipfile import ZipFile from io import BytesIO import matplotlib.pyplot as plt import os import matplotlib.font_manager as fm # ============================ # 載入本地字型檔與設定 # ============================ font_path = "NotoSansTC-Regular.ttf" # 確認此字型檔路徑正確 if os.path.exists(font_path): fm.fontManager.addfont(font_path) font_prop = fm.FontProperties(fname=font_path) CHINESE_FONT = font_prop.get_name() # 例如 "Noto Sans TC" else: st.error(f"找不到字型檔: {font_path}") CHINESE_FONT = "Noto Sans TC" # fallback # 配置 Kaleido 的預設參數與額外字型設定 pio.kaleido.scope.default_font = CHINESE_FONT pio.kaleido.scope.default_format = "png" pio.kaleido.scope.default_width = 1200 pio.kaleido.scope.default_height = 900 pio.kaleido.scope.default_scale = 2 pio.kaleido.scope.extra_fonts = {CHINESE_FONT: font_path} # ============================ # 系統字型檢查與安裝 # ============================ def setup_chinese_font(): """檢查系統中文字型是否可用,若無則嘗試安裝""" try: test_fig, ax = plt.subplots() ax.text(0.5, 0.5, "中文測試", fontfamily=CHINESE_FONT) test_fig.savefig("font_test.png") plt.close(test_fig) except Exception as e: st.warning("正在安裝中文字型...") os.system('apt-get update && apt-get install -y --force-yes fonts-noto-cjk') st.experimental_rerun() # ============================ # 資料讀取 # ============================ def load_data(uploaded_file): """從 CSV 檔案中讀取資料""" try: df = pd.read_csv(uploaded_file, encoding='utf-8').dropna(how='all') if '姓名' not in df.columns: raise ValueError("CSV檔案中缺少必要欄位:姓名") numeric_columns = [] potential_numeric = ['平均', '總分', '國文', '英文', '數學', '自科', '社會', '地理', '歷史', '公民', '物理', '化學', '生物'] for col in potential_numeric: if col in df.columns: try: df[col] = pd.to_numeric(df[col], errors='coerce') numeric_columns.append(col) except: pass if not numeric_columns: raise ValueError("CSV檔案中未找到有效的數值欄位") return df, numeric_columns except Exception as e: st.error(f"數據加載錯誤:{str(e)}") return None, None # ============================ # 生成雷達圖 # ============================ def create_radar_chart(df, selected_rows, selected_columns, student_name=""): """生成雷達圖 (包含圖例、極座標軸等設定)""" fig = go.Figure() line_styles = ['solid', 'dot', 'dash', 'longdash', 'dashdot'] colors = ['#1F77B4', '#FF7F0E', '#2CA02C', '#D62728', '#9467BD'] for i, row_name in enumerate(selected_rows): row_data = df[df['姓名'] == row_name][selected_columns].iloc[0] fig.add_trace(go.Scatterpolar( r=row_data.values, theta=selected_columns, fill='toself', name=row_name, line=dict( color=colors[i % len(colors)], dash=line_styles[i % len(line_styles)], width=2 ), marker=dict(opacity=0.5) )) max_value = df[selected_columns].max().max() * 1.1 if selected_columns else 100 fig.update_layout( polar=dict( radialaxis=dict( visible=True, range=[0, max_value], tickfont=dict(size=14, color='black', family=CHINESE_FONT) ), angularaxis=dict( tickfont=dict(size=16, color='black', family=CHINESE_FONT), rotation=20 ) ), showlegend=True, legend=dict( font=dict(size=14, color='black', family=CHINESE_FONT), orientation="v", yanchor="middle", xanchor="left", x=1.2, y=0.5 ), title=dict( text=f'{student_name} 成績比較圖' if student_name else '學生成績雷達圖', font=dict(size=24, family=CHINESE_FONT), x=0.5, xanchor='center' ), margin=dict(t=80, b=120, r=200), plot_bgcolor='white', paper_bgcolor='white', font=dict(family=CHINESE_FONT, size=14) ) return fig # ============================ # 生成圖片 # ============================ def generate_radar_image(fig): """生成圖片 (重設 Kaleido 配置與更新字型設定)""" try: # 重設 kaleido 配置(含 extra_fonts) pio.kaleido.scope.default_font = CHINESE_FONT pio.kaleido.scope.default_format = "png" pio.kaleido.scope.default_width = 1200 pio.kaleido.scope.default_height = 900 pio.kaleido.scope.default_scale = 2 pio.kaleido.scope.extra_fonts = {CHINESE_FONT: font_path} # 強制更新圖表內所有文字字型設定 fig.update_layout( font=dict( family=CHINESE_FONT, size=14 ), title=dict( font=dict( family=CHINESE_FONT, size=24 ) ), legend=dict( font=dict( family=CHINESE_FONT, size=14 ) ), polar=dict( radialaxis=dict( tickfont=dict( family=CHINESE_FONT, size=14 ) ), angularaxis=dict( tickfont=dict( family=CHINESE_FONT, size=16 ) ) ) ) return pio.to_image(fig, format="png", engine="kaleido") except Exception as e: st.error(f"圖片生成失敗: {str(e)}") return None # ============================ # 主程式 # ============================ def main(): st.title('學生成績雷達圖產生器') st.markdown(""" """, unsafe_allow_html=True) setup_chinese_font() # 檢查中文字型 uploaded_file = st.file_uploader("上傳CSV檔案", type=['csv']) if uploaded_file is not None: df, numeric_columns = load_data(uploaded_file) if df is not None and numeric_columns: st.write("### 選擇要比較的欄位") preset_columns = ['平均', '國文', '英文', '數學', '自科', '社會'] use_preset = st.checkbox("使用五科分數與平均比較") available_preset = [col for col in preset_columns if col in numeric_columns] selected_columns = st.multiselect( '選擇科目', numeric_columns, default=available_preset if use_preset else numeric_columns[:5] ) st.write("### 選擇要比較的對象") all_students = df['姓名'].tolist() selected_rows = st.multiselect('選擇學生', all_students) if selected_columns and selected_rows: try: fig = create_radar_chart(df, selected_rows, selected_columns) st.plotly_chart(fig, use_container_width=True) except Exception as e: st.error(f"生成雷達圖錯誤:{str(e)}") st.write("### 批次下載全班學生雷達圖") comparison_items = st.multiselect("選擇比較基準", all_students) if comparison_items and selected_columns: progress_bar = st.progress(0) image_bytes = {} current_columns = selected_columns.copy() current_comparison = comparison_items.copy() target_students = [s for s in all_students if s not in current_comparison] for idx, student in enumerate(target_students): try: # 動態組合比較組(比較基準加上當前學生) fig = create_radar_chart( df, current_comparison + [student], current_columns, student_name=student ) img = generate_radar_image(fig) if img: image_bytes[student] = img except Exception as e: st.error(f"生成 {student} 圖表失敗:{str(e)}") progress_bar.progress((idx + 1) / len(target_students)) if image_bytes: st.write("### 圖表預覽") cols = st.columns(3) for i, (student, img) in enumerate(image_bytes.items()): with cols[i % 3]: st.image(img, use_container_width=True) st.caption(f"{student} 比較圖") zip_buffer = BytesIO() with ZipFile(zip_buffer, "w") as zip_file: for student, img in image_bytes.items(): zip_file.writestr(f"{student}_成績比較圖.png", img) zip_buffer.seek(0) st.download_button( label="⬇️ 下載全部圖表 (ZIP)", data=zip_buffer, file_name="學生成績比較圖.zip", mime="application/zip", use_container_width=True ) if __name__ == "__main__": main()