Initial commit
This commit is contained in:
+38
@@ -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/
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.")
|
||||
@@ -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
@@ -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')
|
||||
@@ -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">×</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>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
@@ -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); }
|
||||
}
|
||||
Reference in New Issue
Block a user