refactor: migrate project structure to Vue 3 with Vite and Tailwind CSS integration
This commit is contained in:
+4
-1
@@ -7,10 +7,13 @@ venv/
|
||||
ENV/
|
||||
env/
|
||||
.env
|
||||
node_modules/
|
||||
|
||||
# Distribution / packaging
|
||||
dist/
|
||||
build/
|
||||
!static/build/
|
||||
!static/build/**
|
||||
*.egg-info/
|
||||
|
||||
# Unit test / coverage reports
|
||||
@@ -35,4 +38,4 @@ ehthumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Agent Skills
|
||||
.agents/
|
||||
.agents/
|
||||
|
||||
@@ -1,71 +1,172 @@
|
||||
# Difference Equation Analyzer 專案維護指引
|
||||
# Difference Equation Analyzer
|
||||
|
||||
本文件提供 `diff-eq-analyzer` 專案的日常維護、環境更新與故障排除指南。
|
||||
差分方程式與數位濾波器分析工具。後端使用 FastAPI,前端使用 Vue 3 + Vite 建置後由 FastAPI 以靜態檔案服務。
|
||||
|
||||
## 1. 系統服務管理 (Systemd)
|
||||
## 專案結構
|
||||
|
||||
本專案的後端 API (FastAPI) 以 systemd 服務 (`dea.service`) 的形式在背景執行。
|
||||
- `dea_api.py`:FastAPI 後端、API 端點、LAN 存取限制、安全標頭。
|
||||
- `src/`:Vue 前端原始碼。
|
||||
- `static/index.html`:正式 UI 入口,載入建置後的前端資源。
|
||||
- `static/build/app.js`、`static/build/app.css`:前端建置產物,正式服務會載入這兩個檔案。
|
||||
- `static/style.css`:既有自訂 CSS,由 `src/style.css` 引入。
|
||||
- `tests/`:後端回歸測試。
|
||||
- `dea.service`:systemd 服務設定範本。
|
||||
- `dea_legacy.py`:舊 Streamlit 版本,僅供參考。
|
||||
|
||||
### 常用指令
|
||||
## 使用網址
|
||||
|
||||
* **查看服務狀態:**
|
||||
```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. 虛擬環境與套件管理
|
||||
```bash
|
||||
http://localhost:8000/ui/
|
||||
```
|
||||
|
||||
專案使用 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
|
||||
```
|
||||
```bash
|
||||
http://192.168.2.58:8000/ui/
|
||||
```
|
||||
|
||||
## 3. 常見問題排除
|
||||
根路徑 `/` 會自動導向 `/ui/`。
|
||||
|
||||
### 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` 參數。
|
||||
建立或重建 Python 虛擬環境:
|
||||
|
||||
## 4. 變更專案設定檔
|
||||
若您修改了 `dea.service`(位於專案目錄內或 `/etc/systemd/system/dea.service`),請務必執行下列指令讓系統重新載入設定:
|
||||
```bash
|
||||
cd /home/wisetop/diff-eq-analyzer
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
```
|
||||
|
||||
本機開發啟動:
|
||||
|
||||
```bash
|
||||
.venv/bin/uvicorn dea_api:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
## 前端建置
|
||||
|
||||
前端依賴由 `package.json` / `package-lock.json` 管理。首次安裝:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
重新建置正式前端:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
建置後會更新:
|
||||
|
||||
```text
|
||||
static/build/app.js
|
||||
static/build/app.css
|
||||
```
|
||||
|
||||
注意:正式頁面不再載入 Vue CDN 或 Tailwind CDN;Vue 會被打包到 `static/build/app.js`。目前仍使用 Plotly CDN。
|
||||
|
||||
## systemd 服務
|
||||
|
||||
查看狀態:
|
||||
|
||||
```bash
|
||||
sudo systemctl status dea.service
|
||||
```
|
||||
|
||||
啟動、停止、重啟:
|
||||
|
||||
```bash
|
||||
sudo systemctl start dea.service
|
||||
sudo systemctl stop dea.service
|
||||
sudo systemctl restart dea.service
|
||||
```
|
||||
|
||||
查看即時日誌:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u dea.service -f
|
||||
```
|
||||
|
||||
修改 `dea.service` 或 `/etc/systemd/system/dea.service` 後:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart dea.service
|
||||
```
|
||||
|
||||
## 測試與檢查
|
||||
|
||||
Python 語法檢查:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m py_compile dea_api.py tests/test_dea_api.py
|
||||
```
|
||||
|
||||
後端測試:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m unittest discover -s tests
|
||||
```
|
||||
|
||||
前端建置檢查:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 安全與效能設定
|
||||
|
||||
後端目前包含:
|
||||
|
||||
- 僅允許 private / loopback IP 存取。
|
||||
- CSP、安全回應標頭、`X-Frame-Options: DENY`、`nosniff`。
|
||||
- CSV 上傳大小限制:10 MB。
|
||||
- 圖表用 CSV 回傳最多 5000 點,下載 CSV 仍保留完整資料。
|
||||
- 係數數量上限:64。
|
||||
- 濾波器頻率、係數有限值、`a[0]` 等輸入驗證。
|
||||
|
||||
目前 CSP 的 `script-src` 僅允許:
|
||||
|
||||
```text
|
||||
'self' https://cdn.plot.ly
|
||||
```
|
||||
|
||||
因此不再需要 `'unsafe-eval'`。
|
||||
|
||||
## 常見問題
|
||||
|
||||
### 網頁空白
|
||||
|
||||
1. 強制刷新瀏覽器:`Ctrl+F5`。
|
||||
2. 確認服務狀態:
|
||||
|
||||
```bash
|
||||
sudo systemctl status dea.service
|
||||
```
|
||||
|
||||
3. 確認靜態檔案存在:
|
||||
|
||||
```bash
|
||||
ls static/build/app.js static/build/app.css
|
||||
```
|
||||
|
||||
4. 若剛改前端,請重新建置並重啟:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
sudo systemctl restart dea.service
|
||||
```
|
||||
|
||||
### 服務無法啟動,出現 status=203/EXEC
|
||||
|
||||
通常是 `.venv` 路徑或 `uvicorn` 腳本路徑失效。重建虛擬環境後再重啟:
|
||||
|
||||
```bash
|
||||
rm -rf .venv
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
sudo systemctl restart dea.service
|
||||
```
|
||||
|
||||
+174
-70
@@ -3,14 +3,42 @@ 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
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List
|
||||
import io
|
||||
from fastapi.responses import StreamingResponse, RedirectResponse, JSONResponse
|
||||
import ipaddress
|
||||
|
||||
app = FastAPI(title="Difference Equation Analyzer API")
|
||||
|
||||
MAX_COEFFICIENTS = 64
|
||||
MAX_CSV_BYTES = 10 * 1024 * 1024
|
||||
MAX_PLOT_POINTS = 5000
|
||||
BODE_POINTS = 500
|
||||
BODE_MAX_MULTIPLIER = 3.162
|
||||
NYQUIST_MARGIN = 0.999999
|
||||
SECURITY_HEADERS = {
|
||||
"Content-Security-Policy": (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' https://cdn.plot.ly; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; "
|
||||
"connect-src 'self'; "
|
||||
"font-src 'self' data:; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"frame-ancestors 'none'"
|
||||
),
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
}
|
||||
|
||||
def add_security_headers(response):
|
||||
for key, value in SECURITY_HEADERS.items():
|
||||
response.headers[key] = value
|
||||
return response
|
||||
|
||||
@app.middleware("http")
|
||||
async def restrict_to_lan(request: Request, call_next):
|
||||
client_ip = request.client.host
|
||||
@@ -18,10 +46,11 @@ async def restrict_to_lan(request: Request, call_next):
|
||||
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."})
|
||||
return add_security_headers(JSONResponse(status_code=403, content={"detail": "Access Denied: Only LAN connections are allowed."}))
|
||||
except ValueError:
|
||||
pass
|
||||
return await call_next(request)
|
||||
response = await call_next(request)
|
||||
return add_security_headers(response)
|
||||
|
||||
# 掛載靜態網頁檔案,將預設首頁導向到 /ui/
|
||||
app.mount("/ui", StaticFiles(directory="static", html=True), name="static")
|
||||
@@ -32,57 +61,137 @@ def redirect_to_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
|
||||
fs: float = Field(gt=0)
|
||||
lp_fc: float = Field(default=1000.0, gt=0)
|
||||
lp_order: int = Field(default=1, ge=1, le=8)
|
||||
hp_fc: float = Field(default=1000.0, gt=0)
|
||||
hp_order: int = Field(default=1, ge=1, le=8)
|
||||
bp_f_low: float = Field(default=500.0, gt=0)
|
||||
bp_f_high: float = Field(default=2000.0, gt=0)
|
||||
bp_order: int = Field(default=1, ge=1, le=8)
|
||||
notch_f0: float = Field(default=120.0, gt=0)
|
||||
notch_q: float = Field(default=1.0, gt=0)
|
||||
opoz_fz: float = Field(default=15000.0, gt=0)
|
||||
opoz_fp: float = Field(default=10.0, gt=0)
|
||||
tptz_fz1: float = Field(default=200.0, gt=0)
|
||||
tptz_fz2: float = Field(default=25000.0, gt=0)
|
||||
tptz_fp1: float = Field(default=10.0, gt=0)
|
||||
tptz_fp2: float = Field(default=5000.0, gt=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
|
||||
sogi_f0: float = Field(default=60.0, gt=0)
|
||||
sogi_k: float = Field(default=1.414, gt=0)
|
||||
|
||||
class BodeParams(BaseModel):
|
||||
b: List[float]
|
||||
a: List[float]
|
||||
fs: float
|
||||
b: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
|
||||
a: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
|
||||
fs: float = Field(gt=0)
|
||||
|
||||
def require_finite(value, name):
|
||||
if not np.isfinite(value):
|
||||
raise HTTPException(status_code=400, detail=f"{name} 必須是有限數值")
|
||||
return float(value)
|
||||
|
||||
def validate_frequency(name, value, fs, *, below_nyquist=True):
|
||||
value = require_finite(value, name)
|
||||
if value <= 0:
|
||||
raise HTTPException(status_code=400, detail=f"{name} 必須大於 0")
|
||||
if below_nyquist and value >= (fs / 2) * NYQUIST_MARGIN:
|
||||
raise HTTPException(status_code=400, detail=f"{name} 必須小於 Nyquist 頻率 ({fs / 2:g} Hz)")
|
||||
return value
|
||||
|
||||
def parse_coefficients(raw, name):
|
||||
try:
|
||||
values = [float(x.strip()) for x in raw.replace(',', ' ').split() if x.strip()]
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"{name} 係數格式錯誤")
|
||||
return validate_coefficients(values, name)
|
||||
|
||||
def validate_coefficients(values, name):
|
||||
if len(values) == 0:
|
||||
raise HTTPException(status_code=400, detail=f"{name} 係數不可為空")
|
||||
if len(values) > MAX_COEFFICIENTS:
|
||||
raise HTTPException(status_code=400, detail=f"{name} 係數最多 {MAX_COEFFICIENTS} 個")
|
||||
coeffs = np.asarray(values, dtype=float)
|
||||
if not np.all(np.isfinite(coeffs)):
|
||||
raise HTTPException(status_code=400, detail=f"{name} 係數必須都是有限數值")
|
||||
if name == "a" and np.isclose(coeffs[0], 0.0):
|
||||
raise HTTPException(status_code=400, detail="a[0] 不可為 0")
|
||||
return coeffs
|
||||
|
||||
def normalize_coefficients(b_values, a_values):
|
||||
b_arr = validate_coefficients(b_values, "b")
|
||||
a_arr = validate_coefficients(a_values, "a")
|
||||
if not np.isclose(a_arr[0], 1.0):
|
||||
b_arr = b_arr / a_arr[0]
|
||||
a_arr = a_arr / a_arr[0]
|
||||
return b_arr, a_arr
|
||||
|
||||
def downsample_for_plot(index, original, filtered):
|
||||
total = len(index)
|
||||
if total <= MAX_PLOT_POINTS:
|
||||
return index, original, filtered, 1
|
||||
step = int(np.ceil(total / MAX_PLOT_POINTS))
|
||||
return index[::step], original[::step], filtered[::step], step
|
||||
|
||||
async def read_csv_upload(file):
|
||||
contents = await file.read(MAX_CSV_BYTES + 1)
|
||||
if len(contents) > MAX_CSV_BYTES:
|
||||
raise HTTPException(status_code=413, detail=f"CSV 檔案不可超過 {MAX_CSV_BYTES // (1024 * 1024)}MB")
|
||||
try:
|
||||
df = pd.read_csv(io.BytesIO(contents))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"CSV 讀取失敗: {str(e)}")
|
||||
if df.empty:
|
||||
raise HTTPException(status_code=400, detail="CSV 不可為空")
|
||||
return df
|
||||
|
||||
def filtered_csv_data(df, b_vals, a_vals, col_idx):
|
||||
if col_idx < 0 or len(df.columns) <= col_idx:
|
||||
raise HTTPException(status_code=400, detail="欄位索引超出範圍")
|
||||
col_to_filter = df.columns[col_idx]
|
||||
x_signal = pd.to_numeric(df[col_to_filter], errors="coerce")
|
||||
if x_signal.isna().any():
|
||||
raise HTTPException(status_code=400, detail=f"欄位 {col_to_filter} 含有非數值資料")
|
||||
y_signal = signal.lfilter(b_vals, a_vals, x_signal.to_numpy(dtype=float))
|
||||
return col_to_filter, x_signal.to_numpy(dtype=float), y_signal
|
||||
|
||||
@app.post("/api/design")
|
||||
def design_filter(params: DesignParams):
|
||||
fs_val = params.fs
|
||||
fs_val = require_finite(params.fs, "fs")
|
||||
f_type = params.filter_type
|
||||
b_new, a_new = [], []
|
||||
|
||||
try:
|
||||
if f_type == "Lowpass (低通)":
|
||||
validate_frequency("低通截止頻率", params.lp_fc, fs_val)
|
||||
b_new, a_new = signal.butter(params.lp_order, params.lp_fc, btype='low', fs=fs_val)
|
||||
elif f_type == "Highpass (高通)":
|
||||
validate_frequency("高通截止頻率", params.hp_fc, fs_val)
|
||||
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
|
||||
validate_frequency("帶通下截止頻率", f_low, fs_val)
|
||||
validate_frequency("帶通上截止頻率", f_high, fs_val)
|
||||
if f_low >= f_high:
|
||||
raise HTTPException(status_code=400, detail="帶通下截止頻率必須小於上截止頻率")
|
||||
b_new, a_new = signal.butter(params.bp_order, [f_low, f_high], btype='bandpass', fs=fs_val)
|
||||
elif f_type == "Notch (陷波器)":
|
||||
validate_frequency("陷波中心頻率", params.notch_f0, fs_val)
|
||||
b_new, a_new = signal.iirnotch(params.notch_f0, params.notch_q, fs=fs_val)
|
||||
elif f_type == "1P1Z (一極一零)":
|
||||
validate_frequency("1P1Z 零點頻率", params.opoz_fz, fs_val, below_nyquist=False)
|
||||
validate_frequency("1P1Z 極點頻率", params.opoz_fp, fs_val, below_nyquist=False)
|
||||
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 (二極二零)":
|
||||
validate_frequency("2P2Z 極點頻率 1", params.tptz_fp1, fs_val, below_nyquist=False)
|
||||
validate_frequency("2P2Z 極點頻率 2", params.tptz_fp2, fs_val, below_nyquist=False)
|
||||
validate_frequency("2P2Z 零點頻率 1", params.tptz_fz1, fs_val, below_nyquist=False)
|
||||
validate_frequency("2P2Z 零點頻率 2", params.tptz_fz2, fs_val, below_nyquist=False)
|
||||
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]
|
||||
@@ -99,36 +208,40 @@ def design_filter(params: DesignParams):
|
||||
b_new = [b0, b1, b2]
|
||||
a_new = [1.0, -1.0, 0.0]
|
||||
elif f_type == "SOGI-Alpha (帶通)":
|
||||
validate_frequency("SOGI 中心頻率", params.sogi_f0, fs_val)
|
||||
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 (低通)":
|
||||
validate_frequency("SOGI 中心頻率", params.sogi_f0, fs_val)
|
||||
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)
|
||||
|
||||
# 強制進行 a[0] 正規化
|
||||
a_arr = np.array(a_new)
|
||||
b_arr = np.array(b_new)
|
||||
if a_arr[0] != 0 and a_arr[0] != 1.0:
|
||||
b_arr = b_arr / a_arr[0]
|
||||
a_arr = a_arr / a_arr[0]
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="未知的濾波器類型")
|
||||
|
||||
b_arr, a_arr = normalize_coefficients(b_new, a_new)
|
||||
|
||||
return {"b": b_arr.tolist(), "a": a_arr.tolist()}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@app.post("/api/bode")
|
||||
def calculate_bode(params: BodeParams):
|
||||
try:
|
||||
fs_val = require_finite(params.fs, "fs")
|
||||
b_vals = validate_coefficients(params.b, "b")
|
||||
a_vals = validate_coefficients(params.a, "a")
|
||||
f_min = params.fs / 50000.0
|
||||
f_max = params.fs # 數位濾波器通常顯示至 fs 或 Nyquist (fs/2) 即可
|
||||
f_eval = np.logspace(np.log10(f_min), np.log10(f_max), 500)
|
||||
f_max = params.fs * BODE_MAX_MULTIPLIER # 修復為 Legacy 版本的顯示範圍上限 (原為 params.fs)
|
||||
f_eval = np.logspace(np.log10(f_min), np.log10(f_max), BODE_POINTS)
|
||||
|
||||
# WorN determines frequencies to evaluate at
|
||||
w, h = signal.freqz(params.b, params.a, worN=f_eval, fs=params.fs)
|
||||
w, h = signal.freqz(b_vals, a_vals, worN=f_eval, fs=fs_val)
|
||||
|
||||
mag = 20 * np.log10(np.abs(h) + 1e-12)
|
||||
phase = np.angle(h, deg=True)
|
||||
@@ -138,6 +251,8 @@ def calculate_bode(params: BodeParams):
|
||||
"mag": mag.tolist(),
|
||||
"phase": phase.tolist()
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -149,30 +264,23 @@ async def filter_csv(
|
||||
col_idx: int = Form(0)
|
||||
):
|
||||
try:
|
||||
# 支援逗號、空格等多種分隔符號
|
||||
b_vals = [float(x.strip()) for x in b.replace(',', ' ').split() if x.strip()]
|
||||
a_vals = [float(x.strip()) for x in a.replace(',', ' ').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
|
||||
b_vals = parse_coefficients(b, "b")
|
||||
a_vals = parse_coefficients(a, "a")
|
||||
df = await read_csv_upload(file)
|
||||
col_to_filter, x_signal, y_signal = filtered_csv_data(df, b_vals, a_vals, col_idx)
|
||||
index, original, filtered, step = downsample_for_plot(df.index.to_numpy(), x_signal, y_signal)
|
||||
|
||||
return {
|
||||
"index": df.index.tolist(),
|
||||
"original": x_signal.tolist(),
|
||||
"filtered": y_signal.tolist(),
|
||||
"col_name": col_to_filter
|
||||
"index": index.tolist(),
|
||||
"original": original.tolist(),
|
||||
"filtered": filtered.tolist(),
|
||||
"col_name": col_to_filter,
|
||||
"total_points": int(len(df.index)),
|
||||
"plot_points": int(len(index)),
|
||||
"downsample_step": int(step)
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"CSV處理失敗: {str(e)}")
|
||||
|
||||
@@ -185,16 +293,10 @@ async def filter_csv_download(
|
||||
):
|
||||
# 此端點專為產生包含輸出結果的 CSV 檔供下載
|
||||
try:
|
||||
# 支援逗號、空格等多種分隔符號
|
||||
b_vals = [float(x.strip()) for x in b.replace(',', ' ').split() if x.strip()]
|
||||
a_vals = [float(x.strip()) for x in a.replace(',', ' ').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)
|
||||
b_vals = parse_coefficients(b, "b")
|
||||
a_vals = parse_coefficients(a, "a")
|
||||
df = await read_csv_upload(file)
|
||||
col_to_filter, x_signal, y_signal = filtered_csv_data(df, b_vals, a_vals, col_idx)
|
||||
|
||||
output_col_name = f"{col_to_filter}_filtered"
|
||||
df[output_col_name] = y_signal
|
||||
@@ -207,5 +309,7 @@ async def filter_csv_download(
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=filtered_output.csv"}
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"CSV處理失敗: {str(e)}")
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<!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.plot.ly/plotly-2.27.0.min.js"></script>
|
||||
<link rel="icon" type="image/png" href="/ui/logo.png">
|
||||
</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="dea-root"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1932
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^8.0.11",
|
||||
"vue": "^3.5.34"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
fastapi>=0.100.0
|
||||
uvicorn>=0.23.0
|
||||
python-multipart>=0.0.6
|
||||
pydantic>=2.0.0
|
||||
numpy>=1.24.0
|
||||
pandas>=2.0.0
|
||||
scipy>=1.10.0
|
||||
|
||||
+628
@@ -0,0 +1,628 @@
|
||||
<template>
|
||||
<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">
|
||||
<div class="flex items-center gap-3">
|
||||
<img :src="'/ui/logo.png'" alt="Logo" class="h-10 w-10 object-contain rounded-lg shadow-sm">
|
||||
<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>
|
||||
<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 justify-between">
|
||||
<div class="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.066-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>
|
||||
濾波器設計
|
||||
</div>
|
||||
<button @click="resetFilterParams"
|
||||
class="text-[10px] font-bold bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 hover:bg-amber-200 dark:hover:bg-amber-800/50 px-2 py-1 rounded transition-colors uppercase">重設設計</button>
|
||||
</h2>
|
||||
<select v-model="filterType" @change="onFilterTypeChange"
|
||||
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>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<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" @input="debouncedApply" @keydown="onlyNumberKey"
|
||||
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">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>系統增益
|
||||
K (System Gain)</span><span class="text-blue-600 dark:text-blue-400">{{ systemGain
|
||||
}}x</span></label>
|
||||
<input type="number" v-model.number="systemGain" @input="onGainChange" step="0.1"
|
||||
@keydown="onlyNumberKey"
|
||||
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">
|
||||
</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 flex justify-between items-center">
|
||||
<span>濾波器係數設定</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button v-if="isManualDirty" @click="clearManualAdjustments"
|
||||
class="text-[10px] font-bold bg-slate-200 dark:bg-gray-800 hover:bg-slate-300 dark:hover:bg-gray-700 px-2 py-1 rounded text-slate-500 transition-colors uppercase mr-1">重設</button>
|
||||
<button @click="updateFromText"
|
||||
class="text-[10px] font-bold bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded transition-colors uppercase shadow-sm">套用</button>
|
||||
</div>
|
||||
</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" @input="debouncedUpdateFromText('b')" @keydown="onlyNumberKey"
|
||||
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 flex justify-between mt-1">
|
||||
<span>b{{ i-1 }} (基準: {{ (baseB[i-1] * (systemGain || 1.0)).toPrecision(6)
|
||||
}})</span>
|
||||
<span v-if="outOfRangeB[i-1]" class="text-amber-500 font-bold animate-pulse">⚠️
|
||||
超出滑桿範圍</span>
|
||||
</label>
|
||||
<input type="range" v-model.number="bSliders[i-1]" @input="updateFromControls"
|
||||
: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" @input="debouncedUpdateFromText('a')" @keydown="onlyNumberKey"
|
||||
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 flex justify-between mt-1">
|
||||
<span>a{{ i }} (基準: {{ baseA[i].toPrecision(6) }})</span>
|
||||
<span v-if="outOfRangeA[i]" class="text-amber-500 font-bold animate-pulse">⚠️
|
||||
超出滑桿範圍</span>
|
||||
</label>
|
||||
<input type="range" v-model.number="aSliders[i]" @input="updateFromControls"
|
||||
:min="-maxLogA" :max="maxLogA" :step="maxLogA/100"
|
||||
class="w-full accent-orange-500">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 進階係數關聯調整 (X = a1 - a2) -->
|
||||
<div class="mt-4">
|
||||
<button v-if="!showAdvancedAdjustment" @click="enableAdvancedAdjustment"
|
||||
class="w-full py-3 border-2 border-dashed border-slate-200 dark:border-gray-800 rounded-xl text-slate-400 dark:text-gray-500 text-xs font-bold hover:bg-slate-50 dark:hover:bg-gray-800/50 hover:border-indigo-300 dark:hover:border-indigo-900 transition-all flex items-center justify-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 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"></path>
|
||||
</svg>
|
||||
開啟進階係數關聯調整 (a1, a2)
|
||||
</button>
|
||||
|
||||
<div v-if="showAdvancedAdjustment" class="p-4 bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-800 rounded-xl shadow-inner">
|
||||
<h3 class="text-xs font-bold text-slate-500 dark:text-gray-400 uppercase tracking-widest mb-4 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="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
進階係數關聯調整
|
||||
</h3>
|
||||
|
||||
<!-- 差距調整 (X) -->
|
||||
<div class="mb-4">
|
||||
<label class="text-[11px] font-semibold text-slate-400 dark:text-gray-500 flex justify-between mb-2">
|
||||
<span>差距 (a1 - a2)</span>
|
||||
<span class="text-indigo-600 dark:text-indigo-400 font-mono bg-indigo-50 dark:bg-indigo-900/30 px-2 py-0.5 rounded">{{ a1a2Diff.toFixed(3) }}</span>
|
||||
</label>
|
||||
<input type="range" v-model.number="a1a2Diff" min="-4" max="4" step="0.001"
|
||||
@input="updateA1A2FromRelationship"
|
||||
class="w-full accent-indigo-500 h-1.5 bg-slate-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer">
|
||||
</div>
|
||||
|
||||
<!-- 平移調整 (S) -->
|
||||
<div class="mb-4">
|
||||
<label class="text-[11px] font-semibold text-slate-400 dark:text-gray-500 flex justify-between mb-2">
|
||||
<span>平移 (a1 + a2)</span>
|
||||
<span class="text-emerald-600 dark:text-emerald-400 font-mono bg-emerald-50 dark:bg-emerald-900/30 px-2 py-0.5 rounded">{{ a1a2Sum.toFixed(3) }}</span>
|
||||
</label>
|
||||
<input type="range" v-model.number="a1a2Sum" min="-4" max="4" step="0.001"
|
||||
@input="updateA1A2FromRelationship"
|
||||
class="w-full accent-emerald-500 h-1.5 bg-slate-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer">
|
||||
</div>
|
||||
|
||||
<!-- 預覽結果 -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
<div class="flex-1 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
|
||||
<span class="text-[9px] text-slate-400 block uppercase font-bold mb-1">a1 變化預覽</span>
|
||||
<div class="flex items-center justify-center gap-1.5">
|
||||
<span class="text-[10px] font-mono text-slate-400">{{ currentA1.toFixed(3) }}</span>
|
||||
<svg class="w-2.5 h-2.5 text-slate-300" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
|
||||
<span class="text-xs font-mono font-bold text-indigo-600 dark:text-indigo-400">{{ previewA1.toFixed(3) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
|
||||
<span class="text-[9px] text-slate-400 block uppercase font-bold mb-1">a2 變化預覽</span>
|
||||
<div class="flex items-center justify-center gap-1.5">
|
||||
<span class="text-[10px] font-mono text-slate-400">{{ currentA2.toFixed(3) }}</span>
|
||||
<svg class="w-2.5 h-2.5 text-slate-300" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
|
||||
<span class="text-xs font-mono font-bold text-emerald-600 dark:text-emerald-400">{{ previewA2.toFixed(3) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-100 dark:border-amber-800/30 rounded-lg p-3">
|
||||
<p class="text-[10px] text-amber-700 dark:text-amber-400 leading-relaxed">
|
||||
<span class="font-bold">運算說明:</span><br>
|
||||
調整時,系統會先將現有係數四捨五入至三位小數再計算基準值。調整結果會自動將 a1, a2 存回為三位小數精度。
|
||||
</p>
|
||||
</div>
|
||||
</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 flex justify-between items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="Object.keys(fixedOverrides.a).length > 0 || Object.keys(fixedOverrides.b).length > 0"
|
||||
@click="clearFixedOverrides"
|
||||
class="text-[10px] font-bold bg-slate-200 dark:bg-gray-800 hover:bg-slate-300 dark:hover:bg-gray-700 px-2 py-1 rounded text-slate-500 transition-colors uppercase mr-1">重設</button>
|
||||
<button @click="updateBodePlot"
|
||||
class="text-[10px] font-bold bg-emerald-600 hover:bg-emerald-500 text-white px-3 py-1 rounded transition-colors uppercase shadow-sm">套用</button>
|
||||
</div>
|
||||
</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="text-sm 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"
|
||||
@change="clearFixedOverrides" @keydown="onlyNumberKey"
|
||||
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">
|
||||
</div>
|
||||
<p class="text-[10px] text-slate-400 mt-1">Scaling: 2^{{ shiftBits }} ({{ Math.pow(2, shiftBits)
|
||||
}})</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">定點分子係數 b
|
||||
(Integers)</label>
|
||||
<div class="flex flex-wrap gap-1 mb-2">
|
||||
<div v-for="(item, idx) in fixedPointCoeffs.b" :key="item.label"
|
||||
:class="item.isOverridden ? 'border-blue-400 ring-1 ring-blue-400/20' : 'border-blue-100 dark:border-blue-800/30'"
|
||||
class="bg-blue-50 dark:bg-blue-900/20 px-1.5 py-0.5 rounded border flex items-center gap-1 transition-all">
|
||||
<span class="text-[9px] text-blue-400 font-bold">{{ item.label }}:</span>
|
||||
<span class="text-[10px] font-mono text-blue-700 dark:text-blue-300 font-bold"
|
||||
:class="{ 'italic': !item.isOverridden }">{{ item.val }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<textarea v-model="b_int_str" @keydown="onlyNumberKey($event, false)" 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>
|
||||
<div>
|
||||
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">定點分母係數 a
|
||||
(Integers)</label>
|
||||
<div class="flex flex-wrap gap-1 mb-2">
|
||||
<div v-for="(item, idx) in fixedPointCoeffs.a" :key="item.label"
|
||||
:class="item.isOverridden ? 'border-orange-400 ring-1 ring-orange-400/20' : 'border-orange-100 dark:border-orange-800/30'"
|
||||
class="bg-orange-50 dark:bg-orange-900/20 px-1.5 py-0.5 rounded border flex items-center gap-1 transition-all">
|
||||
<span class="text-[9px] text-orange-400 font-bold">{{ item.label }}:</span>
|
||||
<span class="text-[10px] font-mono text-orange-700 dark:text-orange-300 font-bold"
|
||||
:class="{ 'italic': !item.isOverridden }">{{ item.val }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<textarea v-model="a_int_str" @keydown="onlyNumberKey($event, false)" 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>
|
||||
</div>
|
||||
<p class="text-[10px] text-slate-400 dark:text-gray-500 mt-3 italic">註:此數值為原始係數乘以 2^{{ shiftBits }}
|
||||
後四捨五入之結果,可用於定點數 DSP 實作。</p>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script src="./app-options.js"></script>
|
||||
@@ -1,6 +1,4 @@
|
||||
const { createApp } = Vue;
|
||||
|
||||
createApp({
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
fs: 100000.0,
|
||||
@@ -662,4 +660,4 @@ createApp({
|
||||
} catch (e) { this.globalError = e.message; }
|
||||
}
|
||||
}
|
||||
}).mount('#app')
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#dea-root')
|
||||
@@ -0,0 +1,5 @@
|
||||
@import '../static/style.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
|
||||
<!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.plot.ly/plotly-2.27.0.min.js"></script>
|
||||
<link rel="icon" type="image/png" href="/ui/logo.png">
|
||||
<script type="module" crossorigin src="./app.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./app.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="dea-root"></div>
|
||||
</body>
|
||||
</html>
|
||||
+4
-635
@@ -5,645 +5,14 @@
|
||||
<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">
|
||||
<link rel="stylesheet" href="build/app.css">
|
||||
<link rel="icon" type="image/png" href="logo.png">
|
||||
</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">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="logo.png" alt="Logo" class="h-10 w-10 object-contain rounded-lg shadow-sm">
|
||||
<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>
|
||||
<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 justify-between">
|
||||
<div class="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.066-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>
|
||||
濾波器設計
|
||||
</div>
|
||||
<button @click="resetFilterParams"
|
||||
class="text-[10px] font-bold bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 hover:bg-amber-200 dark:hover:bg-amber-800/50 px-2 py-1 rounded transition-colors uppercase">重設設計</button>
|
||||
</h2>
|
||||
<select v-model="filterType" @change="onFilterTypeChange"
|
||||
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>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<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" @input="debouncedApply" @keydown="onlyNumberKey"
|
||||
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">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>系統增益
|
||||
K (System Gain)</span><span class="text-blue-600 dark:text-blue-400">{{ systemGain
|
||||
}}x</span></label>
|
||||
<input type="number" v-model.number="systemGain" @input="onGainChange" step="0.1"
|
||||
@keydown="onlyNumberKey"
|
||||
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">
|
||||
</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 flex justify-between items-center">
|
||||
<span>濾波器係數設定</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button v-if="isManualDirty" @click="clearManualAdjustments"
|
||||
class="text-[10px] font-bold bg-slate-200 dark:bg-gray-800 hover:bg-slate-300 dark:hover:bg-gray-700 px-2 py-1 rounded text-slate-500 transition-colors uppercase mr-1">重設</button>
|
||||
<button @click="updateFromText"
|
||||
class="text-[10px] font-bold bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded transition-colors uppercase shadow-sm">套用</button>
|
||||
</div>
|
||||
</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" @input="debouncedUpdateFromText('b')" @keydown="onlyNumberKey"
|
||||
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 flex justify-between mt-1">
|
||||
<span>b{{ i-1 }} (基準: {{ (baseB[i-1] * (systemGain || 1.0)).toPrecision(6)
|
||||
}})</span>
|
||||
<span v-if="outOfRangeB[i-1]" class="text-amber-500 font-bold animate-pulse">⚠️
|
||||
超出滑桿範圍</span>
|
||||
</label>
|
||||
<input type="range" v-model.number="bSliders[i-1]" @input="updateFromControls"
|
||||
: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" @input="debouncedUpdateFromText('a')" @keydown="onlyNumberKey"
|
||||
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 flex justify-between mt-1">
|
||||
<span>a{{ i }} (基準: {{ baseA[i].toPrecision(6) }})</span>
|
||||
<span v-if="outOfRangeA[i]" class="text-amber-500 font-bold animate-pulse">⚠️
|
||||
超出滑桿範圍</span>
|
||||
</label>
|
||||
<input type="range" v-model.number="aSliders[i]" @input="updateFromControls"
|
||||
:min="-maxLogA" :max="maxLogA" :step="maxLogA/100"
|
||||
class="w-full accent-orange-500">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 進階係數關聯調整 (X = a1 - a2) -->
|
||||
<div class="mt-4">
|
||||
<button v-if="!showAdvancedAdjustment" @click="enableAdvancedAdjustment"
|
||||
class="w-full py-3 border-2 border-dashed border-slate-200 dark:border-gray-800 rounded-xl text-slate-400 dark:text-gray-500 text-xs font-bold hover:bg-slate-50 dark:hover:bg-gray-800/50 hover:border-indigo-300 dark:hover:border-indigo-900 transition-all flex items-center justify-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 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"></path>
|
||||
</svg>
|
||||
開啟進階係數關聯調整 (a1, a2)
|
||||
</button>
|
||||
|
||||
<div v-if="showAdvancedAdjustment" class="p-4 bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-800 rounded-xl shadow-inner">
|
||||
<h3 class="text-xs font-bold text-slate-500 dark:text-gray-400 uppercase tracking-widest mb-4 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="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
進階係數關聯調整
|
||||
</h3>
|
||||
|
||||
<!-- 差距調整 (X) -->
|
||||
<div class="mb-4">
|
||||
<label class="text-[11px] font-semibold text-slate-400 dark:text-gray-500 flex justify-between mb-2">
|
||||
<span>差距 (a1 - a2)</span>
|
||||
<span class="text-indigo-600 dark:text-indigo-400 font-mono bg-indigo-50 dark:bg-indigo-900/30 px-2 py-0.5 rounded">{{ a1a2Diff.toFixed(3) }}</span>
|
||||
</label>
|
||||
<input type="range" v-model.number="a1a2Diff" min="-4" max="4" step="0.001"
|
||||
@input="updateA1A2FromRelationship"
|
||||
class="w-full accent-indigo-500 h-1.5 bg-slate-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer">
|
||||
</div>
|
||||
|
||||
<!-- 平移調整 (S) -->
|
||||
<div class="mb-4">
|
||||
<label class="text-[11px] font-semibold text-slate-400 dark:text-gray-500 flex justify-between mb-2">
|
||||
<span>平移 (a1 + a2)</span>
|
||||
<span class="text-emerald-600 dark:text-emerald-400 font-mono bg-emerald-50 dark:bg-emerald-900/30 px-2 py-0.5 rounded">{{ a1a2Sum.toFixed(3) }}</span>
|
||||
</label>
|
||||
<input type="range" v-model.number="a1a2Sum" min="-4" max="4" step="0.001"
|
||||
@input="updateA1A2FromRelationship"
|
||||
class="w-full accent-emerald-500 h-1.5 bg-slate-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer">
|
||||
</div>
|
||||
|
||||
<!-- 預覽結果 -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
<div class="flex-1 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
|
||||
<span class="text-[9px] text-slate-400 block uppercase font-bold mb-1">a1 變化預覽</span>
|
||||
<div class="flex items-center justify-center gap-1.5">
|
||||
<span class="text-[10px] font-mono text-slate-400">{{ currentA1.toFixed(3) }}</span>
|
||||
<svg class="w-2.5 h-2.5 text-slate-300" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
|
||||
<span class="text-xs font-mono font-bold text-indigo-600 dark:text-indigo-400">{{ previewA1.toFixed(3) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
|
||||
<span class="text-[9px] text-slate-400 block uppercase font-bold mb-1">a2 變化預覽</span>
|
||||
<div class="flex items-center justify-center gap-1.5">
|
||||
<span class="text-[10px] font-mono text-slate-400">{{ currentA2.toFixed(3) }}</span>
|
||||
<svg class="w-2.5 h-2.5 text-slate-300" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
|
||||
<span class="text-xs font-mono font-bold text-emerald-600 dark:text-emerald-400">{{ previewA2.toFixed(3) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-100 dark:border-amber-800/30 rounded-lg p-3">
|
||||
<p class="text-[10px] text-amber-700 dark:text-amber-400 leading-relaxed">
|
||||
<span class="font-bold">運算說明:</span><br>
|
||||
調整時,系統會先將現有係數四捨五入至三位小數再計算基準值。調整結果會自動將 a1, a2 存回為三位小數精度。
|
||||
</p>
|
||||
</div>
|
||||
</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 flex justify-between items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="Object.keys(fixedOverrides.a).length > 0 || Object.keys(fixedOverrides.b).length > 0"
|
||||
@click="clearFixedOverrides"
|
||||
class="text-[10px] font-bold bg-slate-200 dark:bg-gray-800 hover:bg-slate-300 dark:hover:bg-gray-700 px-2 py-1 rounded text-slate-500 transition-colors uppercase mr-1">重設</button>
|
||||
<button @click="updateBodePlot"
|
||||
class="text-[10px] font-bold bg-emerald-600 hover:bg-emerald-500 text-white px-3 py-1 rounded transition-colors uppercase shadow-sm">套用</button>
|
||||
</div>
|
||||
</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="text-sm 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"
|
||||
@change="clearFixedOverrides" @keydown="onlyNumberKey"
|
||||
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">
|
||||
</div>
|
||||
<p class="text-[10px] text-slate-400 mt-1">Scaling: 2^{{ shiftBits }} ({{ Math.pow(2, shiftBits)
|
||||
}})</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">定點分子係數 b
|
||||
(Integers)</label>
|
||||
<div class="flex flex-wrap gap-1 mb-2">
|
||||
<div v-for="(item, idx) in fixedPointCoeffs.b" :key="item.label"
|
||||
:class="item.isOverridden ? 'border-blue-400 ring-1 ring-blue-400/20' : 'border-blue-100 dark:border-blue-800/30'"
|
||||
class="bg-blue-50 dark:bg-blue-900/20 px-1.5 py-0.5 rounded border flex items-center gap-1 transition-all">
|
||||
<span class="text-[9px] text-blue-400 font-bold">{{ item.label }}:</span>
|
||||
<span class="text-[10px] font-mono text-blue-700 dark:text-blue-300 font-bold"
|
||||
:class="{ 'italic': !item.isOverridden }">{{ item.val }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<textarea v-model="b_int_str" @keydown="onlyNumberKey($event, false)" 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>
|
||||
<div>
|
||||
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">定點分母係數 a
|
||||
(Integers)</label>
|
||||
<div class="flex flex-wrap gap-1 mb-2">
|
||||
<div v-for="(item, idx) in fixedPointCoeffs.a" :key="item.label"
|
||||
:class="item.isOverridden ? 'border-orange-400 ring-1 ring-orange-400/20' : 'border-orange-100 dark:border-orange-800/30'"
|
||||
class="bg-orange-50 dark:bg-orange-900/20 px-1.5 py-0.5 rounded border flex items-center gap-1 transition-all">
|
||||
<span class="text-[9px] text-orange-400 font-bold">{{ item.label }}:</span>
|
||||
<span class="text-[10px] font-mono text-orange-700 dark:text-orange-300 font-bold"
|
||||
:class="{ 'italic': !item.isOverridden }">{{ item.val }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<textarea v-model="a_int_str" @keydown="onlyNumberKey($event, false)" 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>
|
||||
</div>
|
||||
<p class="text-[10px] text-slate-400 dark:text-gray-500 mt-3 italic">註:此數值為原始係數乘以 2^{{ shiftBits }}
|
||||
後四捨五入之結果,可用於定點數 DSP 實作。</p>
|
||||
</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>
|
||||
<div id="dea-root"></div>
|
||||
<script type="module" src="build/app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: ['./index.html', './src/**/*.{vue,js}', './static/index.html'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
dark: '#121212',
|
||||
darker: '#0a0a0a',
|
||||
primary: '#1f77b4',
|
||||
secondary: '#ff7f0e',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import asyncio
|
||||
import io
|
||||
import unittest
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from dea_api import BodeParams, DesignParams, add_security_headers, calculate_bode, design_filter, filter_csv
|
||||
|
||||
|
||||
class DeaApiTest(unittest.TestCase):
|
||||
def test_design_returns_normalized_coefficients_for_default_lowpass(self):
|
||||
body = design_filter(
|
||||
DesignParams(
|
||||
filter_type="Lowpass (低通)",
|
||||
fs=100000,
|
||||
lp_fc=1000,
|
||||
lp_order=1,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertGreater(len(body["b"]), 0)
|
||||
self.assertGreater(len(body["a"]), 0)
|
||||
self.assertAlmostEqual(body["a"][0], 1.0)
|
||||
|
||||
def test_design_rejects_cutoff_at_or_above_nyquist(self):
|
||||
try:
|
||||
design_filter(
|
||||
DesignParams(
|
||||
filter_type="Lowpass (低通)",
|
||||
fs=1000,
|
||||
lp_fc=500,
|
||||
lp_order=1,
|
||||
)
|
||||
)
|
||||
except HTTPException as exc:
|
||||
self.assertEqual(exc.status_code, 400)
|
||||
self.assertIn("Nyquist", exc.detail)
|
||||
else:
|
||||
raise AssertionError("Expected Nyquist validation to fail")
|
||||
|
||||
|
||||
def test_bode_rejects_zero_a0(self):
|
||||
try:
|
||||
calculate_bode(BodeParams(fs=1000, b=[1.0], a=[0.0, 1.0]))
|
||||
except HTTPException as exc:
|
||||
self.assertEqual(exc.status_code, 400)
|
||||
self.assertIn("a[0]", exc.detail)
|
||||
else:
|
||||
raise AssertionError("Expected a[0] validation to fail")
|
||||
|
||||
|
||||
def test_filter_downsamples_plot_response_for_large_csv(self):
|
||||
rows = ["value"] + [str(i) for i in range(6001)]
|
||||
upload = UploadFile(
|
||||
io.BytesIO(("\n".join(rows) + "\n").encode("utf-8")),
|
||||
filename="input.csv",
|
||||
)
|
||||
|
||||
body = asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
|
||||
|
||||
self.assertEqual(body["total_points"], 6001)
|
||||
self.assertLessEqual(body["plot_points"], 5000)
|
||||
self.assertGreater(body["downsample_step"], 1)
|
||||
|
||||
|
||||
def test_filter_rejects_non_numeric_signal_column(self):
|
||||
upload = UploadFile(io.BytesIO(b"value\n1\nbad\n3\n"), filename="input.csv")
|
||||
|
||||
try:
|
||||
asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
|
||||
except HTTPException as exc:
|
||||
self.assertEqual(exc.status_code, 400)
|
||||
self.assertIn("非數值", exc.detail)
|
||||
else:
|
||||
raise AssertionError("Expected non-numeric column validation to fail")
|
||||
|
||||
def test_security_headers_are_applied(self):
|
||||
response = add_security_headers(JSONResponse({"ok": True}))
|
||||
|
||||
self.assertEqual(response.headers["X-Content-Type-Options"], "nosniff")
|
||||
self.assertEqual(response.headers["X-Frame-Options"], "DENY")
|
||||
self.assertIn("frame-ancestors 'none'", response.headers["Content-Security-Policy"])
|
||||
self.assertNotIn("unsafe-eval", response.headers["Content-Security-Policy"])
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
outDir: 'static/build',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: 'app.js',
|
||||
chunkFileNames: 'chunks/[name]-[hash].js',
|
||||
assetFileNames: (assetInfo) => {
|
||||
if (assetInfo.name && assetInfo.name.endsWith('.css')) return 'app.css'
|
||||
return 'assets/[name]-[hash][extname]'
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user