Initial commit

This commit is contained in:
ws50529
2026-05-05 16:16:34 +08:00
commit 5568651cc3
10 changed files with 2067 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# Python
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
ENV/
env/
.env
# Distribution / packaging
dist/
build/
*.egg-info/
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
# Jupyter Notebook
.ipynb_checkpoints
# OS generated files
.DS_Store
.DS_Store?
._*
Thumbs.db
ehthumbs.db
Desktop.ini
# Agent Skills
.agent/
+71
View File
@@ -0,0 +1,71 @@
# Difference Equation Analyzer 專案維護指引
本文件提供 `diff-eq-analyzer` 專案的日常維護、環境更新與故障排除指南。
## 1. 系統服務管理 (Systemd)
本專案的後端 API (FastAPI) 以 systemd 服務 (`dea.service`) 的形式在背景執行。
### 常用指令
* **查看服務狀態:**
```bash
sudo systemctl status dea.service
```
* **啟動服務:**
```bash
sudo systemctl start dea.service
```
* **停止服務:**
```bash
sudo systemctl stop dea.service
```
* **重新啟動服務(更新程式碼後必備):**
```bash
sudo systemctl restart dea.service
```
* **查看即時日誌 (Logs)**
```bash
sudo journalctl -u dea.service -f
```
## 2. 虛擬環境與套件管理
專案使用 Python 虛擬環境 (`.venv`) 隔離依賴套件。若您需要更新套件或是因移動專案路徑導致環境異常,請依下列步驟重建虛擬環境:
1. **進入專案目錄:**
```bash
cd /home/wisetop/diff-eq-analyzer
```
2. **刪除舊的虛擬環境並重建:**
```bash
rm -rf .venv
python3 -m venv .venv
```
3. **安裝必要套件:**
```bash
.venv/bin/pip install -r requirements.txt
```
4. **重新啟動系統服務以套用新環境:**
```bash
sudo systemctl restart dea.service
```
## 3. 常見問題排除
### 3.1 服務無法啟動 (status=203/EXEC)
* **原因:** 通常是因為 Python 虛擬環境的路徑錯誤,或是啟動腳本 (`uvicorn`) 內部寫死的絕對路徑與實際位置不符(例如專案資料夾被重新命名)。
* **解法:** 參考上方的「虛擬環境與套件管理」,將 `.venv` 刪除並重新安裝,然後重啟 `dea.service` 即可。
### 3.2 網頁無法連線
* **檢查後端狀態:** 執行 `sudo systemctl status dea.service` 確認 FastAPI 伺服器是否正常運作中(應顯示 `Active: active (running)`)。
* **檢查防火牆設定:** 預設使用的 Port 為 `8000`,請確認防火牆或安全群組允許 `8000` 埠的入站連線。
* **檢查綁定 IP:** 服務預設綁定於 `0.0.0.0`,代表接受所有網路介面的連線。如需限制,請修改 `dea.service` 內的 `ExecStart` 參數。
## 4. 變更專案設定檔
若您修改了 `dea.service`(位於專案目錄內或 `/etc/systemd/system/dea.service`),請務必執行下列指令讓系統重新載入設定:
```bash
sudo systemctl daemon-reload
sudo systemctl restart dea.service
```
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=Difference Equation Analyzer FastAPI Server
After=network.target
[Service]
User=wisetop
WorkingDirectory=/home/wisetop/diff-eq-analyzer
Environment="PATH=/home/wisetop/diff-eq-analyzer/.venv/bin"
ExecStart=/home/wisetop/diff-eq-analyzer/.venv/bin/uvicorn dea_api:app --host 0.0.0.0 --port 8000
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
Executable
+203
View File
@@ -0,0 +1,203 @@
import numpy as np
import pandas as pd
from scipy import signal
from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Request
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from typing import List, Optional
import io
from fastapi.responses import StreamingResponse, RedirectResponse, JSONResponse
import ipaddress
app = FastAPI(title="Difference Equation Analyzer API")
@app.middleware("http")
async def restrict_to_lan(request: Request, call_next):
client_ip = request.client.host
if client_ip:
try:
ip_obj = ipaddress.ip_address(client_ip)
if not (ip_obj.is_private or ip_obj.is_loopback):
return JSONResponse(status_code=403, content={"detail": "Access Denied: Only LAN connections are allowed."})
except ValueError:
pass
return await call_next(request)
# 掛載靜態網頁檔案,將預設首頁導向到 /ui/
app.mount("/ui", StaticFiles(directory="static", html=True), name="static")
@app.get("/")
def redirect_to_ui():
return RedirectResponse(url="/ui/")
class DesignParams(BaseModel):
filter_type: str
fs: float
lp_fc: float = 1000.0
lp_order: int = 1
hp_fc: float = 1000.0
hp_order: int = 1
bp_f_low: float = 500.0
bp_f_high: float = 2000.0
bp_order: int = 1
notch_f0: float = 120.0
notch_q: float = 1.0
opoz_fz: float = 15000.0
opoz_fp: float = 10.0
tptz_fz1: float = 200.0
tptz_fz2: float = 25000.0
tptz_fp1: float = 10.0
tptz_fp2: float = 5000.0
pid_kp: float = 0.003
pid_ki: float = 10.0
pid_kd: float = 0.000016
sogi_f0: float = 60.0
sogi_k: float = 1.414
class BodeParams(BaseModel):
b: List[float]
a: List[float]
fs: float
@app.post("/api/design")
def design_filter(params: DesignParams):
fs_val = params.fs
f_type = params.filter_type
b_new, a_new = [], []
try:
if f_type == "Lowpass (低通)":
b_new, a_new = signal.butter(params.lp_order, params.lp_fc, btype='low', fs=fs_val)
elif f_type == "Highpass (高通)":
b_new, a_new = signal.butter(params.hp_order, params.hp_fc, btype='high', fs=fs_val)
elif f_type == "Bandpass (帶通)":
f_low, f_high = params.bp_f_low, params.bp_f_high
if f_low >= f_high: f_low = f_high * 0.99
b_new, a_new = signal.butter(params.bp_order, [f_low, f_high], btype='bandpass', fs=fs_val)
elif f_type == "Notch (陷波器)":
b_new, a_new = signal.iirnotch(params.notch_f0, params.notch_q, fs=fs_val)
elif f_type == "1P1Z (一極一零)":
b_s = [1.0, 2 * np.pi * params.opoz_fz]
a_s = [1.0, 2 * np.pi * params.opoz_fp]
b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val)
dc_gain = np.sum(b_new) / np.sum(a_new)
b_new = b_new / dc_gain
elif f_type == "2P2Z (二極二零)":
w_p1, w_p2 = 2 * np.pi * params.tptz_fp1, 2 * np.pi * params.tptz_fp2
w_z1, w_z2 = 2 * np.pi * params.tptz_fz1, 2 * np.pi * params.tptz_fz2
b_s = [1.0, w_z1 + w_z2, w_z1 * w_z2]
a_s = [1.0, w_p1 + w_p2, w_p1 * w_p2]
b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val)
dc_gain = np.sum(b_new) / np.sum(a_new)
b_new = b_new / dc_gain
elif f_type == "PID 控制器":
ki_term = params.pid_ki / (2.0 * fs_val)
kd_term = params.pid_kd * fs_val
b0 = params.pid_kp + ki_term + kd_term
b1 = -params.pid_kp + ki_term - 2.0 * kd_term
b2 = kd_term
b_new = [b0, b1, b2]
a_new = [1.0, -1.0, 0.0]
elif f_type == "SOGI-Alpha (帶通)":
w0 = 2 * np.pi * params.sogi_f0
b_s = [params.sogi_k * w0, 0.0]
a_s = [1.0, params.sogi_k * w0, w0**2]
b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val)
elif f_type == "SOGI-Beta (低通)":
w0 = 2 * np.pi * params.sogi_f0
b_s = [params.sogi_k * (w0**2)]
a_s = [1.0, params.sogi_k * w0, w0**2]
b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val)
return {"b": b_new.tolist() if isinstance(b_new, np.ndarray) else b_new,
"a": a_new.tolist() if isinstance(a_new, np.ndarray) else a_new}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/bode")
def calculate_bode(params: BodeParams):
try:
f_min = params.fs / 50000.0
f_max = params.fs * 3.162
f_eval = np.logspace(np.log10(f_min), np.log10(f_max), 500)
# WorN determines frequencies to evaluate at
w, h = signal.freqz(params.b, params.a, worN=f_eval, fs=params.fs)
mag = 20 * np.log10(np.abs(h) + 1e-12)
phase = np.angle(h, deg=True)
return {
"freq": f_eval.tolist(),
"mag": mag.tolist(),
"phase": phase.tolist()
}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/filter")
async def filter_csv(
file: UploadFile = File(...),
b: str = Form(...),
a: str = Form(...),
col_idx: int = Form(0)
):
try:
b_vals = [float(x.strip()) for x in b.split(',') if x.strip()]
a_vals = [float(x.strip()) for x in a.split(',') if x.strip()]
contents = await file.read()
df = pd.read_csv(io.BytesIO(contents))
if len(df.columns) <= col_idx:
raise HTTPException(status_code=400, detail="欄位索引超出範圍")
col_to_filter = df.columns[col_idx]
x_signal = df[col_to_filter].values
# 套用濾波
y_signal = signal.lfilter(b_vals, a_vals, x_signal)
# 準備資料回傳前端進行繪圖 (為了防止過大,可以在此決定是否 downsample,但目前保留原貌)
# 前端收到 JSON 後可自行繪製並產生 CSV
return {
"index": df.index.tolist(),
"original": x_signal.tolist(),
"filtered": y_signal.tolist(),
"col_name": col_to_filter
}
except Exception as e:
raise HTTPException(status_code=400, detail=f"CSV處理失敗: {str(e)}")
@app.post("/api/filter/download")
async def filter_csv_download(
file: UploadFile = File(...),
b: str = Form(...),
a: str = Form(...),
col_idx: int = Form(0)
):
# 此端點專為產生包含輸出結果的 CSV 檔供下載
try:
b_vals = [float(x.strip()) for x in b.split(',') if x.strip()]
a_vals = [float(x.strip()) for x in a.split(',') if x.strip()]
contents = await file.read()
df = pd.read_csv(io.BytesIO(contents))
col_to_filter = df.columns[col_idx]
x_signal = df[col_to_filter].values
y_signal = signal.lfilter(b_vals, a_vals, x_signal)
output_col_name = f"{col_to_filter}_filtered"
df[output_col_name] = y_signal
csv_buffer = io.StringIO()
df.to_csv(csv_buffer, index=False)
return StreamingResponse(
iter([csv_buffer.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=filtered_output.csv"}
)
except Exception as e:
raise HTTPException(status_code=400, detail=f"CSV處理失敗: {str(e)}")
Executable
+691
View File
@@ -0,0 +1,691 @@
import streamlit as st
import numpy as np
import pandas as pd
from scipy import signal
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import io
# 設定網頁標題與佈局
st.set_page_config(page_title="Difference Equation Analyzer", layout="wide")
# 透過 CSS 縮小頂部留白與標題大小
st.markdown("""
<style>
.block-container {
padding-top: 3.5rem !important;
padding-bottom: 1rem !important;
min-height: 250vh; /* 增加到 250vh 避免長圖表重繪時跳回頂部 */
}
.main-title {
font-size: 22px !important;
font-weight: bold;
margin-bottom: 10px !important;
}
/* 縮小 st.header (h2) st.subheader (h3) 的字體大小 */
h2 {
font-size: 20px !important;
padding-top: 0.5rem !important;
padding-bottom: 0.5rem !important;
}
h3 {
font-size: 16px !important;
}
/* 壓縮全域元件間距 (預設約為 1rem~1.5rem) */
[data-testid="stVerticalBlock"] {
gap: 0.5rem !important;
}
/* 壓縮 Sidebar 內部的元件間距變得更緊湊 */
[data-testid="stSidebar"] [data-testid="stVerticalBlock"] {
gap: 0.2rem !important;
}
/* 壓縮分隔線 */
hr {
margin-top: 0.5em !important;
margin-bottom: 0.5em !important;
}
/* 壓縮所有段落與標籤底部留白 */
.stMarkdown p {
margin-bottom: 0px !important;
}
/* 壓縮原生標籤與輸入框的距離 */
div[data-testid="stWidgetLabel"] {
margin-bottom: 0px !important;
padding-bottom: 0px !important;
}
/* 為按鈕增加上下留白避免跟標題或下方輸入框黏在一起 */
div[data-testid="stButton"] {
margin-top: 7px !important;
margin-bottom: 5px !important;
}
/* 暴力壓扁所有文字輸入框數字框下拉選單的物理高度 */
[data-baseweb="input"],
[data-baseweb="base-input"],
[data-baseweb="select"],
div[data-testid="stNumberInputContainer"] {
min-height: 32px !important;
height: 32px !important;
}
/* 抽乾內部 Padding 確保文字置中不被切斷 */
input[type="text"],
input[type="number"],
[data-baseweb="select"] span {
padding-top: 0px !important;
padding-bottom: 0px !important;
min-height: 32px !important;
line-height: 32px !important;
}
/* 同樣暴力壓扁折疊選單 (Expander) 的標題列 */
details summary {
min-height: 32px !important;
height: 32px !important;
padding-top: 0px !important;
padding-bottom: 0px !important;
}
details summary p {
font-size: 0.95em !important;
line-height: 32px !important;
}
/* 針對拉桿特別施加負邊距強制吃掉原本用來顯示浮動數值所預留的幽靈空白 */
.stSlider div[data-testid="stWidgetLabel"] {
margin-bottom: -15px !important;
}
.stSlider [data-baseweb="slider"] {
padding-top: 0px !important;
margin-top: 0px !important;
}
/* 徹底隱藏拉桿的所有紅字浮動數值與底部刻度 */
.stSlider [data-baseweb="slider"] * {
color: transparent !important;
font-size: 0px !important;
}
/* 防禦性隱藏 (針對不同版本的 Streamlit) */
[data-testid="stThumbValue"], [data-testid="stTickBar"], .stSliderValue {
display: none !important;
}
/* 縮小面板內的重置按鈕達到水平與垂直縮減 30% 的視覺效果 */
[data-testid="stExpanderDetails"] button[kind="secondary"] {
padding-top: 0px !important;
padding-bottom: 0px !important;
padding-left: 5px !important;
padding-right: 5px !important;
min-height: 26px !important;
height: 26px !important;
font-size: 0.8em !important;
margin-top: 2px !important;
white-space: nowrap !important;
}
/* 壓縮側邊欄內水平 Radio 按鈕 (靈敏度) 的間距並強制不換行 */
[data-testid="stSidebar"] div[role="radiogroup"] {
gap: 0.65rem !important;
flex-wrap: nowrap !important;
}
[data-testid="stSidebar"] div[role="radiogroup"] label {
white-space: nowrap !important;
margin-right: 0px !important;
}
</style>
""", unsafe_allow_html=True)
st.markdown('<div class="main-title">差分方程式 (Difference Equation) 試算與分析環境</div>', unsafe_allow_html=True)
# --- 左側欄:濾波器參數設定 ---
st.sidebar.header("濾波器係數設定")
st.sidebar.markdown("<div style='margin-bottom: 5px;'></div>", unsafe_allow_html=True)
# st.sidebar.markdown("請輸入差分方程式的係數,以逗號分隔。")
# 1. 初始化 Session State 與邊界條件
if 'b_str' not in st.session_state:
st.session_state.b_str = "0.5, 0.5, 0.0, 0.0"
if 'a_str' not in st.session_state:
st.session_state.a_str = "1.0, 0.0, 0.0, 0.0"
def apply_constraints(val):
# 放寬限制,回歸純數學運算的 Float64 本質
# 真正的 Dynamic Range 與 Quantization Error 會在 v1 的 fixed-point 模組中處理
return val
def parse_to_padded_list(coeff_str, length=4):
try:
vals = [float(x.strip()) for x in coeff_str.split(',') if x.strip()]
except ValueError:
vals = []
# 套用限制
vals = [apply_constraints(v) for v in vals]
while len(vals) < length:
vals.append(0.0)
return vals[:length]
def format_exact_val(x):
# 用於還原文字框或濾波器設計,提供 10 位有效數字確保數學精度
return f"{x:.10g}"
def format_slider_val(x):
# 用於拉桿微調時,只需顯示 6 位有效數字避免畫面過度冗長
return f"{x:g}"
def update_base_b():
b_vals = parse_to_padded_list(st.session_state.b_str, length=7)
st.session_state.base_b = b_vals
for i in range(7):
st.session_state[f'b_slider_{i}'] = 0.0
st.session_state.b_str = ", ".join(map(format_exact_val, b_vals))
def update_base_a():
a_vals = parse_to_padded_list(st.session_state.a_str, length=7)
a_vals[0] = 1.0
st.session_state.base_a = a_vals
for i in range(1, 7):
st.session_state[f'a_slider_{i}'] = 0.0
st.session_state.a_str = ", ".join(map(format_exact_val, a_vals))
def update_base_from_text():
update_base_b()
update_base_a()
def reset_sliders_b():
for i in range(7):
st.session_state[f'b_slider_{i}'] = 0.0
st.session_state.b_str = ", ".join(map(format_exact_val, st.session_state.base_b))
def reset_sliders_a():
for i in range(1, 7):
st.session_state[f'a_slider_{i}'] = 0.0
st.session_state.a_str = ", ".join(map(format_exact_val, st.session_state.base_a))
def update_text_from_sliders():
current_b = []
current_a = [1.0] # a0 固定為 1.0
for i in range(7):
log_val = st.session_state.get(f'b_slider_{i}', 0.0)
m_b = 10.0 ** log_val
val_b = apply_constraints(st.session_state.base_b[i] * m_b)
current_b.append(val_b)
for i in range(1, 7):
log_val = st.session_state.get(f'a_slider_{i}', 0.0)
m_a = 10.0 ** log_val
val_a = apply_constraints(st.session_state.base_a[i] * m_a)
current_a.append(val_a)
st.session_state.b_str = ", ".join(map(format_slider_val, current_b))
st.session_state.a_str = ", ".join(map(format_slider_val, current_a))
def apply_filter_design():
f_type = st.session_state.get('filter_type', "(無) 手動自訂")
if f_type == "(無) 手動自訂":
return
fs_val = st.session_state.get('sys_fs', 100000.0)
b_new, a_new = [], []
try:
if f_type == "Lowpass (低通)":
order = st.session_state.get('lp_order', 1)
fc = st.session_state.get('lp_fc', 1000.0)
b_new, a_new = signal.butter(order, fc, btype='low', fs=fs_val)
elif f_type == "Highpass (高通)":
order = st.session_state.get('hp_order', 1)
fc = st.session_state.get('hp_fc', 1000.0)
b_new, a_new = signal.butter(order, fc, btype='high', fs=fs_val)
elif f_type == "Bandpass (帶通)":
order = st.session_state.get('bp_order', 1)
f_low = st.session_state.get('bp_f_low', 500.0)
f_high = st.session_state.get('bp_f_high', 2000.0)
# 防呆:確保 f_low 必定小於 f_high
if f_low >= f_high:
f_low = f_high * 0.99
b_new, a_new = signal.butter(order, [f_low, f_high], btype='bandpass', fs=fs_val)
elif f_type == "Notch (陷波器)":
f0 = st.session_state.get('notch_f0', 120.0)
q = st.session_state.get('notch_q', 1.0)
b_new, a_new = signal.iirnotch(f0, q, fs=fs_val)
elif f_type == "1P1Z (一極一零)":
fp = st.session_state.get('opoz_fp', 10.0)
fz = st.session_state.get('opoz_fz', 15000.0)
# s-domain: H(s) = (s + 2*pi*fz) / (s + 2*pi*fp)
b_s = [1.0, 2 * np.pi * fz]
a_s = [1.0, 2 * np.pi * fp]
b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val)
# 歸一化 DC Gain
dc_gain = np.sum(b_new) / np.sum(a_new)
b_new = b_new / dc_gain
elif f_type == "2P2Z (二極二零)":
fp1 = st.session_state.get('tptz_fp1', 10.0)
fp2 = st.session_state.get('tptz_fp2', 5000.0)
fz1 = st.session_state.get('tptz_fz1', 200.0)
fz2 = st.session_state.get('tptz_fz2', 25000.0)
w_p1, w_p2 = 2 * np.pi * fp1, 2 * np.pi * fp2
w_z1, w_z2 = 2 * np.pi * fz1, 2 * np.pi * fz2
b_s = [1.0, w_z1 + w_z2, w_z1 * w_z2]
a_s = [1.0, w_p1 + w_p2, w_p1 * w_p2]
b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val)
# 歸一化 DC Gain
dc_gain = np.sum(b_new) / np.sum(a_new)
b_new = b_new / dc_gain
elif f_type == "PID 控制器":
kp = st.session_state.get('pid_kp', 0.003)
ki = st.session_state.get('pid_ki', 10.0)
kd = st.session_state.get('pid_kd', 0.000016)
# 工業界標準數位 PID 實作 (Position Form)
# 積分項採用 Tustin (Bilinear)Ki * (Ts/2) * (z+1)/(z-1)
# 微分項採用 後向歐拉 (Backward Euler) 以避免 Nyquist 震盪:Kd * (1/Ts) * (z-1)/z
# 為了得到整齊的差分方程式 y[n] = y[n-1] + ...,我們將分母通分為 (z-1)
# H(z) = Kp + Ki*(Ts/2)*(1+z^-1)/(1-z^-1) + Kd*fs*(1-z^-1)
ki_term = ki / (2.0 * fs_val)
kd_term = kd * fs_val
b0 = kp + ki_term + kd_term
b1 = -kp + ki_term - 2.0 * kd_term
b2 = kd_term
b_new = [b0, b1, b2]
a_new = [1.0, -1.0, 0.0] # 分母為 1 - z^-1 (標準積分器 y[n] = y[n-1] + ...)
elif f_type == "SOGI-Alpha (帶通)":
f0 = st.session_state.get('sogi_f0', 60.0)
k = st.session_state.get('sogi_k', 1.414)
w0 = 2 * np.pi * f0
b_s = [k * w0, 0.0]
a_s = [1.0, k * w0, w0**2]
b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val)
elif f_type == "SOGI-Beta (低通)":
f0 = st.session_state.get('sogi_f0', 60.0)
k = st.session_state.get('sogi_k', 1.414)
w0 = 2 * np.pi * f0
b_s = [k * (w0**2)]
a_s = [1.0, k * w0, w0**2]
b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val)
if len(b_new) > 0 and len(a_new) > 0:
st.session_state.b_str = ", ".join([f"{x:.10g}" for x in b_new])
st.session_state.a_str = ", ".join([f"{x:.10g}" for x in a_new])
update_base_from_text()
except Exception as e:
pass
if 'base_b' not in st.session_state or len(st.session_state.base_b) < 7:
update_base_from_text()
# b 係數區塊
st.sidebar.text_input("前饋分子係數對應輸入訊號 (b0 ~ b6)", key='b_str', on_change=update_base_b)
with st.sidebar.expander("b 係數倍率微調", expanded=False):
col1, col2 = st.columns([3, 1])
with col1:
sense_b = st.radio("靈敏度", options=["1%", "2x", "10x"], index=1, horizontal=True, label_visibility="collapsed", key="sense_b", on_change=update_base_b)
with col2:
st.button("重置", on_click=reset_sliders_b, key="btn_reset_b", use_container_width=True)
max_log_b = np.log10(1.01) if sense_b == "1%" else (np.log10(2.0) if sense_b == "2x" else 1.0)
first_b = True
for i in range(7):
if st.session_state.base_b[i] == 0.0:
continue
label = f"b{i} (基準: {format_exact_val(st.session_state.base_b[i])})"
margin_top = "0px" if first_b else "15px"
first_b = False
st.markdown(f"<div style='margin-top: {margin_top};'><span style='font-size: 0.9em; font-weight: 600;'>{label}</span></div>", unsafe_allow_html=True)
st.slider(
f"_{label}",
min_value=float(-max_log_b), max_value=float(max_log_b), step=float(max_log_b / 100.0),
key=f'b_slider_{i}',
on_change=update_text_from_sliders,
label_visibility="collapsed"
)
st.sidebar.markdown("---")
# a 係數區塊
st.sidebar.text_input("回饋分母係數對應輸出訊號 (a0=1, a1 ~ a6)", key='a_str', on_change=update_base_a)
with st.sidebar.expander("a 係數倍率微調", expanded=False):
col1, col2 = st.columns([3, 1])
with col1:
sense_a = st.radio("靈敏度", options=["1%", "2x", "10x"], index=1, horizontal=True, label_visibility="collapsed", key="sense_a", on_change=update_base_a)
with col2:
st.button("重置", on_click=reset_sliders_a, key="btn_reset_a", use_container_width=True)
max_log_a = np.log10(1.01) if sense_a == "1%" else (np.log10(2.0) if sense_a == "2x" else 1.0)
first_a = True
for i in range(1, 7):
if st.session_state.base_a[i] == 0.0:
continue
label = f"a{i} (基準: {format_exact_val(st.session_state.base_a[i])})"
margin_top = "0px" if first_a else "15px"
first_a = False
st.markdown(f"<div style='margin-top: {margin_top};'><span style='font-size: 0.9em; font-weight: 600;'>{label}</span></div>", unsafe_allow_html=True)
st.slider(
f"_{label}",
min_value=float(-max_log_a), max_value=float(max_log_a), step=float(max_log_a / 100.0),
key=f'a_slider_{i}',
on_change=update_text_from_sliders,
label_visibility="collapsed"
)
# --- 輔助函式:動態轉換為 k 單位標示 ---
def format_k_notation(val):
if val >= 1000:
k_val = val / 1000.0
# 保留一位小數,若是 .0 則安全去除 (如 100.0k -> 100k)
formatted = f"{k_val:.1f}".rstrip('0').rstrip('.')
return f" <span style='color: #4CAF50; font-size: 0.9em;'>(~{formatted}k)</span>"
return ""
# --- 左側欄:系統參數 ---
st.sidebar.markdown("---")
st.sidebar.header("系統參數")
fs_val = st.session_state.get('sys_fs', 100000.0)
st.sidebar.markdown(f"<div style='margin-bottom: 15px;'><span style='font-size: 0.9em; font-weight: 600;'>取樣頻率 fs (Hz){format_k_notation(fs_val)}</span></div>", unsafe_allow_html=True)
fs = st.sidebar.number_input("_fs", min_value=1.0, value=100000.0, step=1000.0, format="%g", key='sys_fs', on_change=apply_filter_design, label_visibility="collapsed")
# --- 輔助函式:對數滑桿 + 數值輸入框 (微調模式) ---
# 拉桿作為微調工具,範圍固定為當前錨點值的 1/2x ~ 2x (對數等距)
# 滿右 = 錨點 × 2, 中央 = 錨點 × 1, 滿左 = 錨點 × 0.5
# 拉桿滑動 → 數字框即時更新;數字框 Enter → 拉桿歸中並更新錨點
def log_slider_input(label, min_val, max_val, default_val, key, on_change=None):
if key not in st.session_state:
st.session_state[key] = default_val
# 邊界保護
current_val = st.session_state[key]
if current_val < min_val: current_val = min_val
if current_val > max_val: current_val = max_val
st.session_state[key] = current_val
# base_log:拉桿中心錨點,只在數字框確認時更新,拉桿滑動時維持不動
base_log_key = key + '_base_log'
if base_log_key not in st.session_state:
st.session_state[base_log_key] = float(np.log10(current_val))
base_log = st.session_state[base_log_key]
# 固定微調範圍 ±log10(2):右滿 = ×2, 左滿 = ×0.5
FINE_RANGE = np.log10(2.0) # ≈ 0.30103
slider_min = base_log - FINE_RANGE
slider_max = base_log + FINE_RANGE
slider_key = key + '_slider'
if slider_key not in st.session_state:
st.session_state[slider_key] = base_log
# 夾回邊界(base_log 更新後保護)
sv = st.session_state[slider_key]
if sv < slider_min: st.session_state[slider_key] = slider_min
if sv > slider_max: st.session_state[slider_key] = slider_max
def update_from_slider():
raw_val = 10.0 ** st.session_state[slider_key]
raw_val = max(min_val, min(max_val, raw_val))
st.session_state[key] = float(f"{raw_val:.6g}")
# 拉桿滑動時不更新 base_log,讓拉桿能真實偏離中心
if on_change: on_change()
def update_from_num():
val = st.session_state[key + '_num']
if val < min_val: val = min_val
if val > max_val: val = max_val
st.session_state[key] = val
# 數字框確認後:更新錨點,拉桿歸中
new_log = float(np.log10(val)) if val > 0 else base_log
st.session_state[base_log_key] = new_log
st.session_state[slider_key] = new_log
if on_change: on_change()
dynamic_step = 10.0 ** (np.floor(np.log10(current_val)) - 1) if current_val > 0 else 0.1
k_label = format_k_notation(current_val)
st.sidebar.markdown(f"<div style='margin-top: 8px; margin-bottom: 5px;'><span style='font-size: 0.9em; font-weight: 600;'>{label}{k_label}</span></div>", unsafe_allow_html=True)
col1, col2 = st.sidebar.columns([5, 3])
with col1:
st.slider(
f"_{label}_slider",
min_value=float(slider_min), max_value=float(slider_max),
step=float(FINE_RANGE / 100.0),
key=slider_key, on_change=update_from_slider,
label_visibility="collapsed"
)
with col2:
st.number_input(
f"_{label}_num",
min_value=float(min_val), max_value=float(max_val), value=float(current_val),
step=float(dynamic_step),
format="%g", key=key + '_num', on_change=update_from_num,
label_visibility="collapsed"
)
return st.session_state[key]
# --- 濾波器設計工具 ---
st.sidebar.markdown("---")
st.sidebar.header("濾波器設計工具")
st.sidebar.selectbox("選擇設計範本", ["(無) 手動自訂", "Lowpass (低通)", "Highpass (高通)", "Bandpass (帶通)", "Notch (陷波器)", "1P1Z (一極一零)", "2P2Z (二極二零)", "PID 控制器", "SOGI-Alpha (帶通)", "SOGI-Beta (低通)"], key='filter_type', on_change=apply_filter_design)
f_type = st.session_state.get('filter_type', "(無) 手動自訂")
nyq = fs / 2.0
f_min_limit = max(1e-6, fs * 1e-6)
nyq_limit = nyq * 0.999
if 'lp_order' not in st.session_state: st.session_state['lp_order'] = 1
if 'hp_order' not in st.session_state: st.session_state['hp_order'] = 1
if 'bp_order' not in st.session_state: st.session_state['bp_order'] = 1
if f_type == "Lowpass (低通)":
log_slider_input("截止頻率 f_c (Hz)", min_val=f_min_limit, max_val=nyq_limit, default_val=min(1000.0, nyq_limit), key='lp_fc', on_change=apply_filter_design)
st.sidebar.radio("階數 Order", options=[1, 2, 3], horizontal=True, key='lp_order', on_change=apply_filter_design)
elif f_type == "Highpass (高通)":
log_slider_input("截止頻率 f_c (Hz)", min_val=f_min_limit, max_val=nyq_limit, default_val=min(1000.0, nyq_limit), key='hp_fc', on_change=apply_filter_design)
st.sidebar.radio("階數 Order", options=[1, 2, 3], horizontal=True, key='hp_order', on_change=apply_filter_design)
elif f_type == "Bandpass (帶通)":
log_slider_input("下截止頻率 f_low (Hz)", min_val=f_min_limit, max_val=nyq_limit, default_val=min(500.0, nyq_limit), key='bp_f_low', on_change=apply_filter_design)
log_slider_input("上截止頻率 f_high (Hz)", min_val=f_min_limit, max_val=nyq_limit, default_val=min(2000.0, nyq_limit), key='bp_f_high', on_change=apply_filter_design)
st.sidebar.radio("階數 Order", options=[1, 2, 3], horizontal=True, key='bp_order', on_change=apply_filter_design)
elif f_type == "Notch (陷波器)":
log_slider_input("中心頻率 f_0 (Hz)", min_val=f_min_limit, max_val=nyq_limit, default_val=min(120.0, nyq_limit), key='notch_f0', on_change=apply_filter_design)
log_slider_input("品質因數 Q", min_val=0.01, max_val=100.0, default_val=1.0, key='notch_q', on_change=apply_filter_design)
elif f_type == "1P1Z (一極一零)":
log_slider_input("零點頻率 Freq_z (Hz)", min_val=f_min_limit, max_val=fs, default_val=min(15000.0, fs), key='opoz_fz', on_change=apply_filter_design)
log_slider_input("極點頻率 Freq_p (Hz)", min_val=f_min_limit, max_val=fs, default_val=min(10.0, fs), key='opoz_fp', on_change=apply_filter_design)
elif f_type == "2P2Z (二極二零)":
log_slider_input("零點頻率 1 Freq_z1 (Hz)", min_val=f_min_limit, max_val=fs, default_val=min(200.0, fs), key='tptz_fz1', on_change=apply_filter_design)
log_slider_input("零點頻率 2 Freq_z2 (Hz)", min_val=f_min_limit, max_val=fs, default_val=min(25000.0, fs), key='tptz_fz2', on_change=apply_filter_design)
log_slider_input("極點頻率 1 Freq_p1 (Hz)", min_val=f_min_limit, max_val=fs, default_val=min(10.0, fs), key='tptz_fp1', on_change=apply_filter_design)
log_slider_input("極點頻率 2 Freq_p2 (Hz)", min_val=f_min_limit, max_val=fs, default_val=min(5000.0, fs), key='tptz_fp2', on_change=apply_filter_design)
elif f_type == "PID 控制器":
log_slider_input("比例增益 K_p", min_val=1e-6, max_val=1e3, default_val=0.003, key='pid_kp', on_change=apply_filter_design)
log_slider_input("積分增益 K_i", min_val=1e-6, max_val=1e3, default_val=10.0, key='pid_ki', on_change=apply_filter_design)
log_slider_input("微分增益 K_d", min_val=1e-6, max_val=1e3, default_val=0.000016, key='pid_kd', on_change=apply_filter_design)
elif f_type in ["SOGI-Alpha (帶通)", "SOGI-Beta (低通)"]:
log_slider_input("中心頻率 f_0 (Hz)", min_val=f_min_limit, max_val=nyq_limit, default_val=min(60.0, nyq_limit), key='sogi_f0', on_change=apply_filter_design)
log_slider_input("阻尼因數 k", min_val=0.01, max_val=10.0, default_val=1.414, key='sogi_k', on_change=apply_filter_design)
if f_type != "(無) 手動自訂":
st.sidebar.info("💡 調整上方參數會自動覆寫 a, b 係數。你也可以隨時上去手動修改係數拉桿進行微調!")
# 解析文字框內最後確認的數值供後續計算使用
def parse_coeffs(coeff_str, force_a0_one=False):
try:
vals = [float(x.strip()) for x in coeff_str.split(',') if x.strip()]
vals = [apply_constraints(v) for v in vals]
if force_a0_one and len(vals) > 0:
vals[0] = 1.0
return vals
except ValueError:
return []
b = parse_coeffs(st.session_state.b_str)
a = parse_coeffs(st.session_state.a_str, force_a0_one=True)
if not b:
b = [1.0]
if not a:
a = [1.0]
# --- 主畫面區塊一:Bode Plot ---
st.header("1. 頻率響應 (Frequency Response)")
if b and a:
try:
# 依據取樣頻率動態決定 X 軸範圍 (下限: fs/50000, 上限: fs*3.162)
f_min = fs / 50000.0
f_max = fs * 3.162
f_eval = np.logspace(np.log10(f_min), np.log10(f_max), 500)
# 計算頻率響應 (直接給定頻率點 f_eval 與 fs)
w, h = signal.freqz(b, a, worN=f_eval, fs=fs)
mag = 20 * np.log10(np.abs(h) + 1e-12) # 轉換為 dB
phase = np.angle(h, deg=True) # 計算相位 (度),自動 Wrap 至 [-180, 180]
# 使用 Plotly 繪製可互動的圖表 (取消 shared_xaxes,讓上下圖都標示 X 軸)
fig_bode = make_subplots(rows=2, cols=1, shared_xaxes=False,
vertical_spacing=0.15,
subplot_titles=("Magnitude Response (大小響應)", "Phase Response (相位響應)"))
fig_bode.add_trace(go.Scatter(x=f_eval, y=mag, name='Magnitude (dB)', line=dict(color='#1f77b4')), row=1, col=1)
fig_bode.add_trace(go.Scatter(x=f_eval, y=phase, name='Phase (deg)', line=dict(color='#ff7f0e')), row=2, col=1)
fig_bode.update_layout(height=650, showlegend=False, template="plotly_dark")
# 產生 1, 2, 5, 10 序列的刻度,動態適應 f_min 和 f_max
x_ticks = []
x_texts = []
p_start = int(np.floor(np.log10(f_min)))
p_end = int(np.ceil(np.log10(f_max)))
for p in range(p_start, p_end + 1):
for m in [1, 2, 5]:
val = m * (10**p)
if val < f_min or val > f_max:
continue
x_ticks.append(val)
# 格式化標籤文字
if val < 1:
x_texts.append(f"{val:.3g}")
elif val < 1000:
x_texts.append(f"{val:g}")
elif val < 1000000:
x_texts.append(f"{val/1000:g}k")
else:
x_texts.append(f"{val/1000000:g}M")
# 設定 X 軸為對數座標與 1-2-5 刻度線
for row in [1, 2]:
fig_bode.update_xaxes(
title_text="Frequency (Hz)",
type="log",
range=[np.log10(f_min), np.log10(f_max)],
tickvals=x_ticks,
ticktext=x_texts,
showgrid=True,
gridcolor="rgba(128,128,128,0.2)",
row=row, col=1
)
# 強化 10 的倍數 (1, 10, 100...) 刻度線顏色 (不加太寬以維持美觀)
for p in range(p_start, p_end + 1):
v_line = 10**p
if f_min <= v_line <= f_max:
fig_bode.add_vline(x=v_line, line_width=1.5, line_color="rgba(128,128,128,0.5)", layer="below", row=row, col=1)
# 標示 Nyquist 頻率和 fs,方便觀察混疊現象
fig_bode.add_vline(x=fs/2, line_dash="dash", line_color="rgba(255,0,0,0.5)", annotation_text="Nyquist", row=1, col=1)
fig_bode.add_vline(x=fs, line_dash="dash", line_color="rgba(255,0,0,0.5)", annotation_text="fs", row=1, col=1)
fig_bode.add_vline(x=fs/2, line_dash="dash", line_color="rgba(255,0,0,0.5)", row=2, col=1)
fig_bode.add_vline(x=fs, line_dash="dash", line_color="rgba(255,0,0,0.5)", row=2, col=1)
# 設定 Y 軸 (固定幅度範圍為 -80dB ~ 0dB)
fig_bode.update_yaxes(title_text="Magnitude (dB)", range=[-60, 0], row=1, col=1)
fig_bode.update_yaxes(title_text="Phase (Degrees)", range=[-180, 180], tickvals=[-180, -90, 0, 90, 180], row=2, col=1)
st.plotly_chart(fig_bode, use_container_width=True)
except Exception as e:
st.error(f"計算 Bode Plot 時發生錯誤,請檢查係數設定。錯誤訊息: {e}")
# --- 主畫面區塊二:時域訊號探討 ---
st.header("2. 時域訊號探討")
st.markdown("請上傳包含時域訊號的 CSV 檔案,系統會套用上述設定的差分濾波器。")
uploaded_file = st.file_uploader("上傳輸入訊號 (CSV)", type=["csv"])
if uploaded_file is not None:
try:
# 讀取 CSV 檔案
df = pd.read_csv(uploaded_file)
st.write("資料預覽 (前五筆):")
st.dataframe(df.head())
# 讓使用者選擇要濾波的欄位 (智慧預設:若第一欄看起來像時間戳記,則預設選第二欄)
default_idx = 0
if len(df.columns) > 1:
first_col = str(df.columns[0]).lower()
if any(kw in first_col for kw in ['time', 't', 'sec', 'x', 'index', 'Unnamed']):
default_idx = 1
col_to_filter = st.selectbox("請選擇要套用濾波器的訊號欄位:", df.columns, index=default_idx)
x_signal = df[col_to_filter].values
# 執行差分方程式濾波
y_signal = signal.lfilter(b, a, x_signal)
# 將結果合併回 DataFrame 以便下載
df_out = df.copy()
output_col_name = f"{col_to_filter}_filtered"
df_out[output_col_name] = y_signal
# 繪製時域訊號比較圖
st.subheader("波形比較圖")
fig_time = go.Figure()
x_axis = df.index
fig_time.add_trace(go.Scatter(x=x_axis, y=x_signal, name='原始輸入訊號 (Input)', opacity=0.7, line=dict(color='#00cc96')))
fig_time.add_trace(go.Scatter(x=x_axis, y=y_signal, name='濾波後輸出 (Output)', opacity=0.9, line=dict(color='#ef553b')))
fig_time.update_layout(
height=500,
xaxis_title="Sample Index",
yaxis_title="Amplitude",
template="plotly_dark",
legend=dict(
orientation="h", # 水平排列
yanchor="bottom",
y=1.02, # 置於圖表正上方
xanchor="right",
x=1
)
)
st.plotly_chart(fig_time, use_container_width=True)
# 提供下載按鈕
st.subheader("匯出結果")
csv_buffer = io.StringIO()
df_out.to_csv(csv_buffer, index=False)
st.download_button(
label="下載包含輸出訊號的 CSV",
data=csv_buffer.getvalue(),
file_name="filtered_output.csv",
mime="text/csv"
)
except Exception as e:
st.error(f"處理 CSV 檔案時發生錯誤: {e}")
st.sidebar.markdown("<div style='margin-top: 20px;'></div>", unsafe_allow_html=True)
st.sidebar.markdown("---")
st.sidebar.markdown("<div style='margin-bottom: 20px;'></div>", unsafe_allow_html=True)
st.sidebar.image("WISETOP LOGO-FIN.png", width=150)
st.sidebar.caption("© 2026 喆富創新科技(股). All rights reserved.")
+6
View File
@@ -0,0 +1,6 @@
fastapi>=0.100.0
uvicorn>=0.23.0
python-multipart>=0.0.6
numpy>=1.24.0
pandas>=2.0.0
scipy>=1.10.0
+392
View File
@@ -0,0 +1,392 @@
const { createApp } = Vue;
createApp({
data() {
return {
fs: 100000.0,
b_str: "0.5, 0.5, 0.0, 0.0",
a_str: "1.0, 0.0, 0.0, 0.0",
filterType: "(無) 手動自訂",
filterOptions: [
"(無) 手動自訂", "Lowpass (低通)", "Highpass (高通)",
"Bandpass (帶通)", "Notch (陷波器)", "1P1Z (一極一零)",
"2P2Z (二極二零)", "PID 控制器", "SOGI-Alpha (帶通)", "SOGI-Beta (低通)"
],
params: {
fc: 1000.0, order: 1,
bp_f_low: 500.0, bp_f_high: 2000.0, bp_order: 1,
notch_f0: 120.0, notch_q: 1.0,
opoz_fz: 15000.0, opoz_fp: 10.0,
tptz_fz1: 200.0, tptz_fz2: 25000.0, tptz_fp1: 10.0, tptz_fp2: 5000.0,
kp: 0.003, ki: 10.0, kd: 0.000016,
sogi_f0: 60.0, sogi_k: 1.414,
},
// 模式切換
isDarkMode: true,
// 係數倍率微調
baseB: [0.5, 0.5, 0, 0, 0, 0, 0],
baseA: [1, 0, 0, 0, 0, 0, 0],
bSliders: [0, 0, 0, 0, 0, 0, 0],
aSliders: [0, 0, 0, 0, 0, 0], // a1~a6 (index 0 unused for display but maps to a[1]~a[6])
sense_b: '2x', sense_a: '2x',
loadingBode: false, bodeTimeout: null, globalError: null,
csvFile: null, csvColumns: [], csvPreview: [],
selectedColumn: 0, loadingFilter: false, filterDone: false, timePlotData: null,
mobileTab: 'settings', // 手機版頁籤:'settings' | 'chart'
shiftBits: 12,
}
},
computed: {
maxLogB() {
if (this.sense_b === '1%') return Math.log10(1.01);
if (this.sense_b === '2x') return Math.log10(2.0);
return 1.0;
},
maxLogA() {
if (this.sense_a === '1%') return Math.log10(1.01);
if (this.sense_a === '2x') return Math.log10(2.0);
return 1.0;
},
fixedPointCoeffs() {
const scale = Math.pow(2, this.shiftBits);
const b_raw = this.parseCoeffs(this.b_str);
const a_raw = this.parseCoeffs(this.a_str);
const b = b_raw.map((x, i) => ({ label: `b${i}`, val: Math.round(x * scale) }));
const a = a_raw.map((x, i) => ({ label: `a${i}`, val: Math.round(x * scale) }));
return { b, a };
},
floatingCoeffs() {
const b = this.parseCoeffs(this.b_str).map((x, i) => ({ label: `b${i}`, val: x }));
const a = this.parseCoeffs(this.a_str).map((x, i) => ({ label: `a${i}`, val: x }));
return { b, a };
}
},
mounted() {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
this.isDarkMode = savedTheme === 'dark';
}
if (this.isDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
this.syncBaseFromText();
this.updateBodePlot();
},
methods: {
toggleDarkMode() {
this.isDarkMode = !this.isDarkMode;
localStorage.setItem('theme', this.isDarkMode ? 'dark' : 'light');
if (this.isDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
this.updateBodePlot();
if (this.timePlotData) {
this.drawTimePlot(this.timePlotData);
}
},
formatK(val) {
if (val >= 1000000) return `(~${(val / 1000000).toFixed(1).replace('.0', '')}M)`;
if (val >= 1000) return `(~${(val / 1000).toFixed(1).replace('.0', '')}k)`;
return "";
},
parseCoeffs(str) {
return str.split(',').map(x => parseFloat(x.trim())).filter(x => !isNaN(x));
},
padTo7(arr) {
const r = arr.slice(0, 7);
while (r.length < 7) r.push(0);
return r;
},
syncBaseFromText() {
this.baseB = this.padTo7(this.parseCoeffs(this.b_str));
this.baseA = this.padTo7(this.parseCoeffs(this.a_str));
this.baseA[0] = 1.0;
this.bSliders = [0, 0, 0, 0, 0, 0, 0];
this.aSliders = [0, 0, 0, 0, 0, 0];
},
resetSlidersB() {
this.bSliders = [0, 0, 0, 0, 0, 0, 0];
this.b_str = this.baseB.map(x => parseFloat(x.toPrecision(10))).join(', ');
this.updateBodePlot();
},
resetSlidersA() {
this.aSliders = [0, 0, 0, 0, 0, 0];
this.a_str = this.baseA.map(x => parseFloat(x.toPrecision(10))).join(', ');
this.updateBodePlot();
},
updateFromSliders() {
const currentB = this.baseB.map((base, i) => base * Math.pow(10, this.bSliders[i] || 0));
const currentA = [1.0];
for (let i = 1; i < 7; i++) {
currentA.push(this.baseA[i] * Math.pow(10, this.aSliders[i] || 0));
}
this.b_str = currentB.map(x => parseFloat(x.toPrecision(6))).join(', ');
this.a_str = currentA.map(x => parseFloat(x.toPrecision(6))).join(', ');
this.debouncedUpdateBode();
},
updateFromText() {
this.syncBaseFromText();
this.updateBodePlot();
},
onFsChange() {
if (this.filterType !== "(無) 手動自訂") {
this.applyFilterDesign();
} else {
this.updateBodePlot();
}
},
debouncedApply() {
clearTimeout(this.bodeTimeout);
this.bodeTimeout = setTimeout(() => {
if (this.filterType !== "(無) 手動自訂") this.applyFilterDesign();
else this.updateBodePlot();
}, 300);
},
debouncedUpdateBode() {
clearTimeout(this.bodeTimeout);
this.bodeTimeout = setTimeout(() => this.updateBodePlot(), 150);
},
async applyFilterDesign() {
if (this.filterType === "(無) 手動自訂") return;
this.globalError = null;
try {
const payload = {
filter_type: this.filterType, fs: this.fs,
lp_fc: this.params.fc, lp_order: this.params.order,
hp_fc: this.params.fc, hp_order: this.params.order,
bp_f_low: this.params.bp_f_low, bp_f_high: this.params.bp_f_high, bp_order: this.params.bp_order,
notch_f0: this.params.notch_f0, notch_q: this.params.notch_q,
opoz_fz: this.params.opoz_fz, opoz_fp: this.params.opoz_fp,
tptz_fz1: this.params.tptz_fz1, tptz_fz2: this.params.tptz_fz2,
tptz_fp1: this.params.tptz_fp1, tptz_fp2: this.params.tptz_fp2,
pid_kp: this.params.kp, pid_ki: this.params.ki, pid_kd: this.params.kd,
sogi_f0: this.params.sogi_f0, sogi_k: this.params.sogi_k
};
const res = await fetch('/api/design', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '設計濾波器失敗'); }
const data = await res.json();
this.b_str = data.b.map(x => parseFloat(x.toPrecision(10))).join(', ');
this.a_str = data.a.map(x => parseFloat(x.toPrecision(10))).join(', ');
this.syncBaseFromText();
this.updateBodePlot();
} catch (e) { this.globalError = e.message; }
},
async updateBodePlot() {
this.loadingBode = true;
this.globalError = null;
try {
let b = this.parseCoeffs(this.b_str);
let a = this.parseCoeffs(this.a_str);
if (b.length === 0) b = [1.0];
if (a.length === 0) a = [1.0];
const res = await fetch('/api/bode', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ b, a, fs: this.fs })
});
if (!res.ok) throw new Error('無法計算頻率響應');
const data = await res.json();
this.drawBodePlot(data.freq, data.mag, data.phase);
// 手機版:計算完成後自動切換到圖表頁籤
if (window.innerWidth < 1024) this.mobileTab = 'chart';
} catch (e) { this.globalError = e.message; }
finally { this.loadingBode = false; }
},
drawBodePlot(freq, mag, phase) {
const fs = this.fs;
const fMin = freq[0], fMax = freq[freq.length - 1];
const isDark = this.isDarkMode;
const isSmallScreen = window.innerWidth < 768;
// 產生 1-2-5 刻度
const xTicks = [], xTexts = [];
const pStart = Math.floor(Math.log10(fMin));
const pEnd = Math.ceil(Math.log10(fMax));
for (let p = pStart; p <= pEnd; p++) {
for (const m of [1, 2, 5]) {
const val = m * Math.pow(10, p);
if (val < fMin || val > fMax) continue;
xTicks.push(val);
if (val < 1) xTexts.push(val.toPrecision(3));
else if (val < 1000) xTexts.push(String(val));
else if (val < 1000000) xTexts.push((val / 1000) + 'k');
else xTexts.push((val / 1000000) + 'M');
}
}
// Nyquist & fs 標線 (shapes)
const shapes = [];
const annotations = [];
const addVLine = (xVal, text, row) => {
const yref = row === 1 ? 'y' : 'y2';
shapes.push({
type: 'line', x0: xVal, x1: xVal, y0: 0, y1: 1,
xref: row === 1 ? 'x' : 'x2', yref: yref + ' domain',
line: { color: isDark ? 'rgba(255,0,0,0.5)' : 'rgba(220,0,0,0.6)', width: 1.5, dash: 'dash' }
});
if (text) {
annotations.push({
x: Math.log10(xVal), y: 1, text: text,
xref: row === 1 ? 'x' : 'x2', yref: yref + ' domain',
showarrow: false, font: { color: isDark ? 'rgba(255,100,100,0.8)' : 'rgba(180,50,50,1)', size: isSmallScreen ? 10 : 12 },
yshift: isSmallScreen ? 15 : 12,
xanchor: 'center',
yanchor: isSmallScreen ? 'middle' : 'bottom',
textangle: isSmallScreen ? -90 : 0
});
}
};
addVLine(fs / 2, 'Nyquist', 1);
addVLine(fs, 'fs', 1);
addVLine(fs / 2, null, 2);
addVLine(fs, null, 2);
// 10 的倍數強化線
for (let p = pStart; p <= pEnd; p++) {
const v = Math.pow(10, p);
if (v >= fMin && v <= fMax) {
const gridColor = isDark ? 'rgba(128,128,128,0.5)' : 'rgba(100,100,100,0.3)';
shapes.push(
{ type: 'line', x0: v, x1: v, y0: 0, y1: 1, xref: 'x', yref: 'y domain', line: { color: gridColor, width: 1.5 } },
{ type: 'line', x0: v, x1: v, y0: 0, y1: 1, xref: 'x2', yref: 'y2 domain', line: { color: gridColor, width: 1.5 } }
);
}
}
const gridColor = isDark ? 'rgba(128,128,128,0.2)' : 'rgba(0,0,0,0.1)';
const zeroLineColor = isDark ? '#4b5563' : '#cbd5e1';
const textColor = isDark ? '#9ca3af' : '#475569';
const titleColor = isDark ? '#d1d5db' : '#1e293b';
const xAxisCommon = {
type: 'log', title: { text: 'Freq (Hz)', font: { size: isSmallScreen ? 12 : 14 } },
range: [Math.log10(fMin), Math.log10(fMax)],
tickvals: xTicks, ticktext: xTexts,
showgrid: true, gridcolor: gridColor,
tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 },
nticks: isSmallScreen ? 6 : 10
};
const layout = {
paper_bgcolor: 'transparent', plot_bgcolor: 'transparent',
margin: isSmallScreen ? { t: 80, b: 70, l: 55, r: 15 } : { t: 70, b: 60, l: 70, r: 30 },
showlegend: false,
grid: { rows: 2, columns: 1, pattern: 'independent', roworder: 'top to bottom', ygap: isSmallScreen ? 0.35 : 0.35 },
font: { color: textColor, family: 'Inter, sans-serif' },
annotations: [
{ text: 'Magnitude Response (dB)', xref: 'x domain', yref: 'y domain', x: 0.5, y: isSmallScreen ? 1.22 : 1.18, showarrow: false, font: { size: isSmallScreen ? 14 : 16, color: titleColor, weight: 'bold' } },
{ text: 'Phase Response (Deg)', xref: 'x2 domain', yref: 'y2 domain', x: 0.5, y: isSmallScreen ? 1.22 : 1.18, showarrow: false, font: { size: isSmallScreen ? 14 : 16, color: titleColor, weight: 'bold' } },
...annotations
],
xaxis: { ...xAxisCommon },
yaxis: { title: { text: 'Mag (dB)', font: { size: isSmallScreen ? 12 : 14 } }, range: [-60, 0], gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 } },
xaxis2: { ...xAxisCommon },
yaxis2: { title: { text: 'Phase (°)', font: { size: isSmallScreen ? 12 : 14 } }, range: [-180, 180], tickvals: [-180, -90, 0, 90, 180], gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 } },
shapes: shapes
};
const traceMag = { x: freq, y: mag, name: 'Magnitude (dB)', type: 'scatter', line: { color: isDark ? '#3b82f6' : '#2563eb', width: 2.5 } };
const tracePhase = { x: freq, y: phase, name: 'Phase (deg)', type: 'scatter', line: { color: isDark ? '#f97316' : '#ea580c', width: 2.5 }, xaxis: 'x2', yaxis: 'y2' };
Plotly.react('bodePlot', [traceMag, tracePhase], layout, { responsive: true });
},
handleFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
this.csvFile = file;
this.filterDone = false;
this.csvPreview = [];
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target.result;
const lines = text.split('\n').filter(l => l.trim());
if (lines.length === 0) return;
this.csvColumns = lines[0].split(',').map(s => s.trim());
this.selectedColumn = 0;
if (this.csvColumns.length > 1) {
const first = this.csvColumns[0].toLowerCase();
if (['time', 't', 'sec', 'x', 'index', 'unnamed'].some(kw => first.includes(kw))) {
this.selectedColumn = 1;
}
}
const preview = [];
for (let i = 1; i < Math.min(lines.length, 6); i++) {
preview.push(lines[i].split(',').map(s => s.trim()));
}
this.csvPreview = preview;
};
reader.readAsText(file);
},
async processFilter() {
if (!this.csvFile) return;
this.loadingFilter = true;
this.globalError = null;
const formData = new FormData();
formData.append('file', this.csvFile);
formData.append('b', this.b_str);
formData.append('a', this.a_str);
formData.append('col_idx', this.selectedColumn);
try {
const res = await fetch('/api/filter', { method: 'POST', body: formData });
if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '濾波處理失敗'); }
const data = await res.json();
this.timePlotData = data;
this.drawTimePlot(data);
this.filterDone = true;
} catch (e) { this.globalError = e.message; }
finally { this.loadingFilter = false; }
},
drawTimePlot(data) {
const isDark = this.isDarkMode;
const isSmallScreen = window.innerWidth < 768;
const gridColor = isDark ? 'rgba(128,128,128,0.2)' : 'rgba(0,0,0,0.1)';
const zeroLineColor = isDark ? '#4b5563' : '#cbd5e1';
const textColor = isDark ? '#9ca3af' : '#475569';
const layout = {
paper_bgcolor: 'transparent', plot_bgcolor: 'transparent',
margin: isSmallScreen ? { t: 60, b: 60, l: 45, r: 15 } : { t: 40, b: 55, l: 60, r: 20 },
font: { color: textColor, family: 'Inter, sans-serif' },
xaxis: { title: { text: 'Sample Index', font: { size: isSmallScreen ? 10 : 11 } }, gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 9 : 11 } },
yaxis: { title: { text: 'Amplitude', font: { size: isSmallScreen ? 10 : 11 } }, gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 9 : 11 } },
legend: { orientation: 'h', y: 1.02, yanchor: 'bottom', x: 1, xanchor: 'right', font: { color: textColor, size: isSmallScreen ? 9 : 10 } }
};
Plotly.react('timePlot', [
{ x: data.index, y: data.original, name: '原始輸入訊號 (Input)', type: 'scatter', line: { color: isDark ? '#00cc96' : '#059669' }, opacity: 0.7 },
{ x: data.index, y: data.filtered, name: '濾波後輸出 (Output)', type: 'scatter', line: { color: isDark ? '#ef553b' : '#dc2626' }, opacity: 0.9 }
], layout, { responsive: true });
},
async downloadCsv() {
if (!this.csvFile) return;
const formData = new FormData();
formData.append('file', this.csvFile);
formData.append('b', this.b_str);
formData.append('a', this.a_str);
formData.append('col_idx', this.selectedColumn);
try {
const res = await fetch('/api/filter/download', { method: 'POST', body: formData });
if (!res.ok) throw new Error('下載失敗');
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'filtered_output.csv';
document.body.appendChild(a); a.click();
window.URL.revokeObjectURL(url);
} catch (e) { this.globalError = e.message; }
}
}
}).mount('#app')
+503
View File
@@ -0,0 +1,503 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>差分方程式分析 (Difference Equation Analyzer)</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: { colors: { dark: '#121212', darker: '#0a0a0a', primary: '#1f77b4', secondary: '#ff7f0e' } } }
}
</script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<link rel="stylesheet" href="style.css">
</head>
<body class="bg-slate-50 dark:bg-darker text-slate-900 dark:text-gray-200 min-h-screen transition-colors duration-300">
<div id="app" class="flex flex-col h-screen overflow-hidden">
<!-- 頂部導覽列 -->
<header
class="bg-white dark:bg-dark border-b border-slate-200 dark:border-gray-800 p-4 shadow-md flex justify-between items-center z-10">
<h1
class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-emerald-600 dark:from-blue-400 dark:to-emerald-400">
差分方程式分析 (Difference Equation Analyzer)</h1>
<div class="flex items-center gap-4">
<!-- 模式切換按鈕 -->
<button @click="toggleDarkMode"
class="p-2 rounded-lg bg-slate-100 dark:bg-gray-800 hover:bg-slate-200 dark:hover:bg-gray-700 transition-colors shadow-sm">
<svg v-if="isDarkMode" class="w-5 h-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0 1 1 0 002 0zM2 10a1 1 0 011-1h1a1 1 0 110 2H3a1 1 0 01-1-1zm14 0a1 1 0 011-1h1a1 1 0 110 2h-1a1 1 0 01-1-1zM5.05 5.05a1 1 0 011.414 0l.707.707a1 1 0 11-1.414 1.414l-.707-.707a1 1 0 010-1.414zm9.9 9.9a1 1 0 011.414 0l.707.707a1 1 0 11-1.414 1.414l-.707-.707a1 1 0 010-1.414zM5.05 14.95a1 1 0 010-1.414l.707-.707a1 1 0 011.414 1.414l-.707.707a1 1 0 01-1.414 0zm9.9-9.9a1 1 0 010-1.414l.707-.707a1 1 0 011.414 1.414l-.707.707a1 1 0 01-1.414 0z"
clip-rule="evenodd" />
</svg>
<svg v-else class="w-5 h-5 text-slate-700" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
</button>
</div>
</header>
<!-- 手機版頁籤列 (桌面版隱藏) -->
<div
class="lg:hidden flex border-b border-slate-200 dark:border-gray-800 bg-white dark:bg-dark z-10 flex-shrink-0">
<button @click="mobileTab = 'settings'" :class="mobileTab === 'settings'
? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20'
: 'text-slate-500 dark:text-gray-400 hover:text-slate-700 dark:hover:text-gray-200'"
class="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z">
</path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
設定
</button>
<button @click="mobileTab = 'chart'" :class="mobileTab === 'chart'
? 'border-b-2 border-emerald-500 text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20'
: 'text-slate-500 dark:text-gray-400 hover:text-slate-700 dark:hover:text-gray-200'"
class="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z">
</path>
</svg>
圖表
<span v-if="loadingBode" class="w-2 h-2 rounded-full bg-blue-500 animate-ping"></span>
</button>
</div>
<div class="flex flex-col lg:flex-row flex-1 overflow-hidden">
<!-- 左側控制面板 (RWD: 手機版=設定頁籤全螢幕,桌面版=左側邊欄) -->
<aside :class="mobileTab === 'settings' ? 'flex' : 'hidden'"
class="lg:flex flex-col w-full lg:w-96 h-full bg-white dark:bg-dark border-b lg:border-b-0 lg:border-r border-slate-200 dark:border-gray-800 overflow-y-auto p-5 gap-5 custom-scrollbar transition-colors duration-300">
<!-- 濾波器設計工具 -->
<section>
<h2
class="text-sm font-semibold text-slate-500 dark:text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z">
</path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
濾波器設計
</h2>
<select v-model="filterType" @change="applyFilterDesign"
class="w-full bg-slate-50 dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none transition-all mb-3 text-slate-900 dark:text-gray-100 truncate">
<option v-for="opt in filterOptions" :key="opt" :value="opt">{{ opt }}</option>
</select>
<!-- 動態參數區 -->
<div v-if="filterType !== '(無) 手動自訂'"
class="space-y-3 bg-slate-100 dark:bg-gray-900 p-4 rounded-lg border border-slate-200 dark:border-gray-800 shadow-inner">
<!-- Lowpass / Highpass -->
<div v-if="['Lowpass (低通)', 'Highpass (高通)'].includes(filterType)">
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>截止頻率
f_c (Hz)</span><span class="text-emerald-600 dark:text-emerald-400">{{
formatK(params.fc) }}</span></label>
<input type="range" v-model.number="params.fc" @input="debouncedApply" :min="1" :max="fs/2"
class="w-full accent-blue-500 mb-2">
<input type="number" v-model.number="params.fc" @change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base focus:border-blue-500 outline-none text-slate-900 dark:text-gray-100">
<label class="text-sm text-slate-500 dark:text-gray-400 mt-3 mb-2 block">階數 Order</label>
<div class="flex gap-2">
<button v-for="o in [1,2,3]" :key="o" @click="params.order = o; applyFilterDesign()"
:class="params.order === o ? 'bg-blue-600 text-white border-blue-500' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
class="flex-1 rounded py-2.5 text-base font-medium border transition-colors">{{ o
}}</button>
</div>
</div>
<!-- Bandpass -->
<div v-if="filterType === 'Bandpass (帶通)'">
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>下截止頻率
f_low (Hz)</span><span class="text-emerald-600 dark:text-emerald-400">{{
formatK(params.bp_f_low) }}</span></label>
<input type="number" v-model.number="params.bp_f_low" @change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base mb-2 text-slate-900 dark:text-gray-100">
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>上截止頻率
f_high (Hz)</span><span class="text-emerald-600 dark:text-emerald-400">{{
formatK(params.bp_f_high) }}</span></label>
<input type="number" v-model.number="params.bp_f_high" @change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base mb-2 text-slate-900 dark:text-gray-100">
<label class="text-sm text-slate-500 dark:text-gray-400 mt-2 mb-2 block">階數 Order</label>
<div class="flex gap-2">
<button v-for="o in [1,2,3]" :key="o" @click="params.bp_order = o; applyFilterDesign()"
:class="params.bp_order === o ? 'bg-blue-600 text-white border-blue-500' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
class="flex-1 rounded py-1.5 text-sm border transition-colors">{{ o }}</button>
</div>
</div>
<!-- Notch -->
<div v-if="filterType === 'Notch (陷波器)'" class="space-y-3">
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">中心頻率 f_0
(Hz)</label><input type="number" v-model.number="params.notch_f0"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">品質因數
Q</label><input type="number" v-model.number="params.notch_q"
@change="applyFilterDesign" step="0.1"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
</div>
<!-- 1P1Z -->
<div v-if="filterType === '1P1Z (一極一零)'" class="space-y-3">
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">零點頻率 Freq_z
(Hz)</label><input type="number" v-model.number="params.opoz_fz"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">極點頻率 Freq_p
(Hz)</label><input type="number" v-model.number="params.opoz_fp"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
</div>
<!-- 2P2Z -->
<div v-if="filterType === '2P2Z (二極二零)'" class="space-y-3">
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">零點頻率 1 Freq_z1
(Hz)</label><input type="number" v-model.number="params.tptz_fz1"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">零點頻率 2 Freq_z2
(Hz)</label><input type="number" v-model.number="params.tptz_fz2"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">極點頻率 1 Freq_p1
(Hz)</label><input type="number" v-model.number="params.tptz_fp1"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">極點頻率 2 Freq_p2
(Hz)</label><input type="number" v-model.number="params.tptz_fp2"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
</div>
<!-- PID -->
<div v-if="filterType === 'PID 控制器'" class="space-y-3">
<div v-for="k in [{key:'kp',label:'比例增益 Kp'},{key:'ki',label:'積分增益 Ki'},{key:'kd',label:'微分增益 Kd'}]"
:key="k.key">
<label class="text-sm font-medium text-slate-500 dark:text-gray-400 mb-1 block">{{
k.label
}}</label>
<input type="number" v-model.number="params[k.key]" @change="applyFilterDesign"
step="0.0001"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base focus:border-blue-500 outline-none text-slate-900 dark:text-gray-100">
</div>
</div>
<!-- SOGI -->
<div v-if="filterType === 'SOGI-Alpha (帶通)' || filterType === 'SOGI-Beta (低通)'"
class="space-y-3">
<div><label class="text-xs text-slate-500 dark:text-gray-400 mb-1 block">中心頻率 f_0
(Hz)</label><input type="number" v-model.number="params.sogi_f0"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-2 text-sm text-slate-900 dark:text-gray-100">
</div>
<div><label class="text-xs text-slate-500 dark:text-gray-400 mb-1 block">阻尼因數
k</label><input type="number" v-model.number="params.sogi_k"
@change="applyFilterDesign" step="0.001"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-2 text-sm text-slate-900 dark:text-gray-100">
</div>
</div>
<!-- 提示資訊 -->
<div
class="bg-blue-100 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800/50 rounded-lg p-3 text-sm text-blue-700 dark:text-blue-300 mt-2">
💡 調整上方參數會自動覆寫 a, b 係數。你也可以隨時手動修改下方係數進行微調!
</div>
</div>
</section>
<hr class="border-slate-200 dark:border-gray-800">
<!-- 系統參數 -->
<section>
<h2 class="text-sm font-semibold text-slate-500 dark:text-gray-400 uppercase tracking-wider mb-3">
系統參數</h2>
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>取樣頻率 fs
(Hz)</span><span class="text-emerald-600 dark:text-emerald-400">{{ formatK(fs)
}}</span></label>
<input type="number" v-model.number="fs" @change="onFsChange"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base focus:border-blue-500 outline-none transition-all text-slate-900 dark:text-gray-100">
</section>
<hr class="border-slate-200 dark:border-gray-800">
<!-- 係數手動設定 -->
<section>
<h2
class="text-sm font-semibold text-slate-500 dark:text-gray-400 uppercase tracking-wider mb-3 flex justify-between items-center">
<span>濾波器係數設定</span>
<button @click="updateFromText"
class="text-xs font-semibold bg-slate-200 dark:bg-gray-800 hover:bg-slate-300 dark:hover:bg-gray-700 px-3 py-1.5 rounded text-slate-600 dark:text-gray-300 transition-colors shadow-sm">套用</button>
</h2>
<div class="mb-3">
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">前饋分子係數 b (b0 ~ b6)</label>
<!-- b 係數標記顯示 -->
<div class="flex flex-wrap gap-1 mb-2">
<div v-for="item in floatingCoeffs.b" :key="item.label" class="bg-blue-50 dark:bg-blue-900/30 px-1.5 py-0.5 rounded border border-blue-100 dark:border-blue-800/20 flex gap-1 items-center">
<span class="text-[9px] text-blue-500 font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono text-blue-800 dark:text-blue-300">{{ item.val }}</span>
</div>
</div>
<textarea v-model="b_str" @change="updateFromText" rows="2"
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm focus:border-blue-500 outline-none font-mono resize-none leading-relaxed text-blue-700 dark:text-blue-300"></textarea>
</div>
<!-- b 係數倍率微調 -->
<details
class="mb-3 bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-800 rounded-lg">
<summary
class="text-sm text-slate-500 dark:text-gray-400 px-3 py-3 cursor-pointer hover:text-slate-600 dark:hover:text-gray-300">
b 係數倍率微調</summary>
<div class="px-3 pb-3">
<div class="flex items-center gap-2 mb-2 mt-1">
<span class="text-xs text-slate-400 dark:text-gray-500">靈敏度:</span>
<button v-for="s in ['1%','2x','10x']" :key="s" @click="sense_b = s"
:class="sense_b === s ? 'bg-blue-600 text-white' : 'bg-slate-200 dark:bg-gray-800 text-slate-500 dark:text-gray-400'"
class="btn-small px-3 py-1.5 rounded text-xs font-medium border border-slate-300 dark:border-gray-700">{{
s }}</button>
<button @click="resetSlidersB"
class="btn-small ml-auto px-3 py-1.5 rounded text-xs font-medium bg-slate-200 dark:bg-gray-800 text-slate-500 dark:text-gray-400 border border-slate-300 dark:border-gray-700 hover:bg-slate-300 dark:hover:bg-gray-700">重置</button>
</div>
<template v-for="i in 7" :key="'bs'+i">
<div v-if="baseB[i-1] !== 0">
<label class="text-xs text-slate-400 dark:text-gray-500 block mt-1">b{{ i-1 }} (基準:
{{ baseB[i-1].toPrecision(6) }})</label>
<input type="range" v-model.number="bSliders[i-1]" @input="updateFromSliders"
:min="-maxLogB" :max="maxLogB" :step="maxLogB/100" class="w-full accent-blue-500">
</div>
</template>
</div>
</details>
<div class="mb-3">
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">回饋分母係數 a (a0=1, a1 ~ a6)</label>
<!-- a 係數標記顯示 -->
<div class="flex flex-wrap gap-1 mb-2">
<div v-for="item in floatingCoeffs.a" :key="item.label" class="bg-orange-50 dark:bg-orange-900/30 px-1.5 py-0.5 rounded border border-orange-100 dark:border-orange-800/20 flex gap-1 items-center">
<span class="text-[9px] text-orange-500 font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono text-orange-800 dark:text-orange-300">{{ item.val }}</span>
</div>
</div>
<textarea v-model="a_str" @change="updateFromText" rows="2"
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm focus:border-blue-500 outline-none font-mono resize-none leading-relaxed text-orange-700 dark:text-orange-300"></textarea>
</div>
<!-- a 係數倍率微調 -->
<details
class="bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-800 rounded-lg">
<summary
class="text-sm text-slate-500 dark:text-gray-400 px-3 py-3 cursor-pointer hover:text-slate-600 dark:hover:text-gray-300">
a 係數倍率微調</summary>
<div class="px-3 pb-3">
<div class="flex items-center gap-2 mb-2 mt-1">
<span class="text-xs text-slate-400 dark:text-gray-500">靈敏度:</span>
<button v-for="s in ['1%','2x','10x']" :key="s" @click="sense_a = s"
:class="sense_a === s ? 'bg-blue-600 text-white' : 'bg-slate-200 dark:bg-gray-800 text-slate-500 dark:text-gray-400'"
class="px-3 py-1.5 rounded text-xs font-medium border border-slate-300 dark:border-gray-700">{{
s }}</button>
<button @click="resetSlidersA"
class="btn-small ml-auto px-3 py-1.5 rounded text-xs font-medium bg-slate-200 dark:bg-gray-800 text-slate-500 dark:text-gray-400 border border-slate-300 dark:border-gray-700 hover:bg-slate-300 dark:hover:bg-gray-700">重置</button>
</div>
<template v-for="i in 6" :key="'as'+i">
<div v-if="baseA[i] !== 0">
<label class="text-xs text-slate-400 dark:text-gray-500 block mt-1">a{{ i }} (基準: {{
baseA[i].toPrecision(6) }})</label>
<input type="range" v-model.number="aSliders[i]" @input="updateFromSliders"
:min="-maxLogA" :max="maxLogA" :step="maxLogA/100" class="w-full accent-orange-500">
</div>
</template>
</div>
</details>
<!-- 定點數轉換 -->
<div class="mt-5 pt-5 border-t border-slate-200 dark:border-gray-800">
<h3 class="text-sm font-semibold text-slate-500 dark:text-gray-400 uppercase tracking-wider mb-3 flex justify-between items-center">
<span>定點數係數轉換</span>
<span class="text-xs bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 px-2 py-1 rounded">Q{{ shiftBits }}</span>
</h3>
<div class="mb-3">
<label class="text-xs text-slate-500 dark:text-gray-400 mb-1 block">左移位元數 (Shift Bits: 0~14)</label>
<div class="flex items-center gap-2">
<input type="number" v-model.number="shiftBits" min="0" max="14" class="w-20 bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-2 text-sm outline-none text-slate-900 dark:text-gray-100">
<span class="text-[10px] text-slate-400">Scaling: 2^{{ shiftBits }} ({{ Math.pow(2, shiftBits) }})</span>
</div>
</div>
<div class="space-y-3">
<div>
<label class="text-[10px] text-slate-500 dark:text-gray-400 mb-1 block uppercase font-bold tracking-tighter">b (Integers)</label>
<div class="bg-slate-100 dark:bg-gray-900/80 p-2 rounded-lg border border-slate-200 dark:border-gray-800 shadow-inner flex flex-wrap gap-2">
<div v-for="item in fixedPointCoeffs.b" :key="item.label" class="bg-blue-50 dark:bg-blue-900/20 px-2 py-1 rounded border border-blue-100 dark:border-blue-800/30 flex gap-1">
<span class="text-[10px] text-blue-400 font-bold">{{ item.label }}:</span>
<span class="text-xs font-mono text-blue-700 dark:text-blue-300">{{ item.val }}</span>
</div>
</div>
</div>
<div>
<label class="text-[10px] text-slate-500 dark:text-gray-400 mb-1 block uppercase font-bold tracking-tighter">a (Integers)</label>
<div class="bg-slate-100 dark:bg-gray-900/80 p-2 rounded-lg border border-slate-200 dark:border-gray-800 shadow-inner flex flex-wrap gap-2">
<div v-for="item in fixedPointCoeffs.a" :key="item.label" class="bg-orange-50 dark:bg-orange-900/20 px-2 py-1 rounded border border-orange-100 dark:border-orange-800/30 flex gap-1">
<span class="text-[10px] text-orange-400 font-bold">{{ item.label }}:</span>
<span class="text-xs font-mono text-orange-700 dark:text-orange-300">{{ item.val }}</span>
</div>
</div>
</div>
</div>
<p class="text-[10px] text-slate-400 dark:text-gray-500 mt-2 italic">註:此數值為原始係數乘以 2^{{ shiftBits }} 後四捨五入之結果,可用於定點數 DSP 實作。</p>
</div>
</section>
</aside>
<!-- 右側主視窗 (RWD: 手機版=圖表頁籤全螢幕,桌面版=右側主區) -->
<main :class="mobileTab === 'chart' ? 'flex bg-white dark:bg-[#0f0f0f] !p-0' : 'hidden'"
class="lg:flex flex-1 flex-col overflow-y-auto bg-slate-50 dark:bg-[#0f0f0f] lg:p-6 gap-0 lg:gap-6 custom-scrollbar relative transition-colors duration-300">
<div v-if="globalError"
class="absolute top-4 right-6 bg-red-100 dark:bg-red-900 border border-red-200 dark:border-red-700 text-red-700 dark:text-white px-4 py-3 rounded-lg shadow-lg text-base z-50 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{{ globalError }}
<button @click="globalError = null"
class="ml-2 hover:text-red-900 dark:hover:text-red-200">&times;</button>
</div>
<!-- Bode Plot -->
<div
class="bg-white dark:bg-dark lg:rounded-xl border-b lg:border border-slate-100 lg:border-slate-200 dark:border-gray-800 lg:dark:border-gray-800 lg:shadow-md p-0 lg:p-1 flex-shrink-0 relative transition-colors duration-300">
<div class="hidden lg:block absolute inset-0 bg-gradient-to-br from-blue-500/5 to-purple-500/5 pointer-events-none">
</div>
<div
class="px-5 py-4 border-b border-slate-100 dark:border-gray-800 flex justify-between items-center bg-white dark:bg-dark backdrop-blur-sm z-10 relative">
<h3 class="font-bold text-slate-800 dark:text-gray-200 tracking-wide">1. 頻率響應 (Frequency
Response)</h3>
<div v-if="loadingBode"
class="text-sm text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 px-3 py-1.5 rounded border border-blue-100 dark:border-blue-800/50 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-blue-500 dark:bg-blue-400 animate-ping"></span>
重新計算中...
</div>
</div>
<!-- 只有圖表繪圖區可水平滾動,卡片外框不動 -->
<div class="overflow-x-auto bg-white dark:bg-dark">
<div id="bodePlot" class="min-w-[580px] w-full h-[800px] z-10 relative bg-white dark:bg-dark">
</div>
</div>
</div>
<!-- 時域分析 -->
<div
class="bg-white dark:bg-white lg:dark:bg-dark lg:rounded-xl border-b lg:border border-slate-100 lg:border-slate-200 dark:border-gray-100 lg:dark:border-gray-800 lg:shadow-md p-0 lg:p-1 flex-shrink-0 relative transition-colors duration-300">
<div
class="px-5 py-4 border-b border-slate-100 dark:border-gray-800 bg-white dark:bg-dark backdrop-blur-sm z-10 relative">
<h3 class="font-bold text-slate-800 dark:text-gray-200 tracking-wide">2. 時域訊號探討</h3>
</div>
<div class="p-6 z-10 relative bg-white dark:bg-dark transition-colors duration-300">
<p class="text-base text-slate-500 dark:text-gray-400 mb-4">請上傳包含時域訊號的 CSV 檔案,系統會套用上述設定的差分濾波器。
</p>
<div
class="flex items-center flex-wrap gap-4 mb-6 p-4 bg-slate-50 dark:bg-gray-900/50 rounded-lg border border-slate-100 dark:border-gray-800">
<label
class="cursor-pointer bg-blue-600 hover:bg-blue-500 text-white px-5 py-3 rounded-lg text-base font-semibold transition-all shadow-md hover:shadow-blue-500/20 flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
</svg>
上傳輸入訊號 (CSV)
<input type="file" ref="fileInput" accept=".csv" @change="handleFileUpload"
class="hidden">
</label>
<span v-if="csvFile" class="text-base text-emerald-600 dark:text-emerald-400 font-mono">{{
csvFile.name }}</span>
<div v-if="csvColumns.length > 0"
class="h-8 border-l border-slate-200 dark:border-gray-700 mx-2"></div>
<div v-if="csvColumns.length > 0" class="flex items-center gap-2">
<span class="text-sm text-slate-400">訊號欄位:</span>
<select v-model="selectedColumn"
class="bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base focus:border-blue-500 outline-none text-slate-900 dark:text-gray-100">
<option v-for="(col, idx) in csvColumns" :key="idx" :value="idx">{{ col }}</option>
</select>
</div>
<button v-if="csvFile" @click="processFilter"
class="bg-emerald-600 hover:bg-emerald-500 text-white px-6 py-3 rounded-lg text-base font-semibold transition-all shadow-md hover:shadow-emerald-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loadingFilter">
{{ loadingFilter ? '處理中...' : '套用當前濾波器' }}
</button>
<button v-if="filterDone" @click="downloadCsv"
class="ml-auto bg-slate-700 hover:bg-slate-600 text-white px-5 py-3 rounded-lg text-base font-semibold transition-all flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
匯出結果
</button>
</div>
<!-- 資料預覽 -->
<div v-if="csvPreview.length > 0" class="mb-6">
<h4
class="text-sm font-semibold text-slate-500 dark:text-gray-400 mb-2 uppercase tracking-tight">
資料預覽 (前五筆):</h4>
<div
class="overflow-x-auto rounded-xl border border-slate-100 dark:border-gray-800 shadow-inner">
<table class="w-full text-sm text-left">
<thead>
<tr class="bg-slate-50 dark:bg-gray-800/50">
<th v-for="col in csvColumns" :key="col"
class="px-4 py-3 font-semibold text-slate-700 dark:text-gray-300">{{ col
}}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-gray-800">
<tr v-for="(row, ri) in csvPreview" :key="ri"
class="hover:bg-slate-50/50 dark:hover:bg-gray-800/30 transition-colors">
<td v-for="(val, ci) in row" :key="ci"
class="px-4 py-2 font-mono text-slate-600 dark:text-gray-400">{{ val }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 波形比較圖 -->
<h4 v-if="filterDone" class="text-base font-bold text-slate-800 dark:text-gray-200 mb-3">波形比較圖
</h4>
<!-- 只有圖表繪圖區可水平滾動,卡片外框不動 -->
<div class="overflow-x-auto bg-white dark:bg-dark">
<div id="timePlot" class="min-w-[580px] w-full h-[400px] bg-white dark:bg-dark"
v-show="filterDone"></div>
</div>
<div v-if="!filterDone && !loadingFilter && csvPreview.length === 0"
class="h-[300px] flex flex-col items-center justify-center text-slate-400 dark:text-gray-500 border-2 border-dashed border-slate-200 dark:border-gray-800 rounded-xl bg-slate-50/50 dark:bg-gray-900/30">
<svg class="w-12 h-12 mb-3 opacity-50" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z">
</path>
</svg>
<p>請上傳包含時間序列的 CSV 檔案進行測試</p>
</div>
</div>
</div>
</main>
</div>
<!-- 全域置底版權宣告 -->
<footer
class="bg-white dark:bg-dark border-t border-slate-200 dark:border-gray-800 py-2 text-center z-20 transition-colors duration-300">
<p class="text-xs text-slate-400 dark:text-gray-600">© 2026 喆富創新科技股份有限公司. All rights reserved.</p>
</footer>
</div>
<script src="app.js"></script>
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

+149
View File
@@ -0,0 +1,149 @@
/* Custom scrollbar for webkit */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
/* Light mode thumb */
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
/* Dark mode thumb */
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background: #374151;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #4b5563;
}
/* Base styles */
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-size: 16px; /* 提高基礎字體大小 */
line-height: 1.6; /* 增加行高,提升閱讀舒適度 */
}
/* Range input styling overrides - Touch Optimized */
input[type=range] {
-webkit-appearance: none;
appearance: none; /* 標準屬性 */
background: #e2e8f0;
height: 8px;
border-radius: 4px;
transition: background 0.3s;
cursor: pointer;
}
.dark input[type=range] {
background: #374151;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
height: 24px; /* 加大滑桿頭,方便手指觸控 */
width: 24px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
transition: background .15s ease-in-out, transform .1s;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
border: 2px solid white;
}
.dark input[type=range]::-webkit-slider-thumb {
border: 2px solid #1f2937;
}
input[type=range]::-webkit-slider-thumb:hover {
background: #2563eb;
transform: scale(1.1);
}
/* 加大點擊區域與間距 */
button, select, input[type=number], textarea {
min-height: 44px; /* 符合行動裝置建議的最小點擊高度 */
}
/* 小型功能按鈕(靈敏度、重置)排除最小高度限制 */
.btn-small {
min-height: unset;
}
/* Hide number input arrows */
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
appearance: none; /* 標準屬性 */
margin: 0;
}
input[type=number] {
-moz-appearance: textfield;
appearance: textfield;
}
/* 強制 Plotly modebar 容器定位於圖表外上方 */
.js-plotly-plot .plotly .modebar-container {
top: -45px !important;
right: 0 !important;
left: 0 !important;
width: 100% !important;
display: block !important;
}
/* 強制 modebar 內部按鈕橫向排列且靠右 */
.js-plotly-plot .plotly .modebar {
position: static !important;
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
justify-content: flex-end !important;
background: transparent !important;
padding-right: 10px;
}
/* 確保按鈕群組也是橫向 */
.js-plotly-plot .plotly .modebar-group {
display: flex !important;
flex-direction: row !important;
}
/* 增加圖表上方間距給 modebar 使用 */
#bodePlot, #timePlot {
margin-top: 50px;
}
/* RWD 調整:在窄螢幕下微調 modebar 位置與間距 */
@media (max-width: 1024px) {
.js-plotly-plot .plotly .modebar-container {
top: -40px !important;
}
}
/* details 展開動畫 */
details > summary {
list-style: none;
user-select: none;
}
details > summary::-webkit-details-marker {
display: none;
}
details > div {
animation: slideDown 0.2s ease-out;
overflow: hidden;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}