9 Commits

15 changed files with 2462 additions and 2138 deletions
-39
View File
@@ -1,39 +0,0 @@
# Difference Equation Analyzer (DEA) - Architecture & Functionality
## Overview
DEA is a specialized tool for designing, visualizing, and validating discrete-time filters. It bridges the gap between theoretical filter design (floating-point) and hardware implementation (fixed-point/MCU).
## 🏗 System Architecture
### 1. Backend (Python 3.12 + FastAPI)
The backend is responsible for heavy mathematical computations and data processing.
- **API Entry (`dea_api.py`)**: Routes requests to appropriate modules and serves the Vue.js frontend from `static/`.
- **Computation Engine (`dea/`)**:
- `filter_design.py`: Uses `scipy.signal` to calculate coefficients for various filter topologies.
- `bode.py`: Computes magnitude and phase response. Supports comparing "Ideal" (float) vs "Fixed" (quantized) responses.
- `csv_processing.py`: Processes time-domain CSV data. It applies the current filter to the signal and downsamples the result for efficient visualization.
- `validation.py`: Protects against unstable filter designs and invalid inputs.
### 2. Frontend (Vue 3 + Vite + Tailwind)
A reactive "Single Page Application" (SPA) approach.
- **Reactive Logic (`src/app-options.js`)**:
- Manages the state of all coefficients.
- Implements "Fine-tuning" sliders that allow real-time adjustment of poles/zeros.
- Handles Fixed-Point conversion logic (Q-format shifting).
- **UI Components (`src/App.vue`)**:
- **Control Sidebar**: Interactive inputs for all filter parameters.
- **Visualization**: Dual-plot system using Plotly.js for Bode plots and Time-domain waveforms.
- **Hardware Bridge**: Uses the **Web Serial API** to send commands directly to connected MCUs (requires HTTPS).
## 🚀 Execution Flow
1. **Design**: User selects a filter type or enters coefficients manually.
2. **Quantize**: User adjusts Q-format bits to see how quantization affects the frequency response.
3. **Verify**: User uploads a CSV to see how the filter behaves with real-world signals.
4. **Deploy**: User clicks "Write to MCU" to send the coefficients to their hardware.
## 🛠 Tech Stack
- **Backend**: FastAPI, NumPy, SciPy, Pandas, Uvicorn.
- **Frontend**: Vue 3, Vite, Tailwind CSS, Plotly.js.
- **Security**: Restricted to LAN/Loopback; implemented security headers and HTTPS support.
-48
View File
@@ -1,48 +0,0 @@
# DSP Bode Plot 專案進度總結 (2026-05-15)
## 1. 核心功能優化 (DSP & 定點數運算)
- **細部微調 (Fine-tuning)**:實施了基於 $\delta$ (Delta) 與 $r$ (Radius) 的參數化微調。
- **定點數精確度修正**:採用「兩倍值整數運算」邏輯,解決了 $0.5$ 步階在浮點數圓整時產生的「按鍵沒反應」Bug。
- **防止漂移 (Oscillation Logic)**:針對數位微調,實施了奇偶震盪平衡邏輯。當調整 $\delta$ 導致 $r$ 必須隨動時,採用震盪補償防止 $r$ 產生單向累積誤差。
- **參數連動策略**:在 $0.5$ 步階時,採取「優先滿足目標變動」原則。若調整 $\delta$,則 $r$ 隨動;反之亦然。
## 2. UI/UX 介面調整
- **側邊欄優化**
- 將 $r$ 與 $\delta$ 的調整區域改為**垂直堆疊**,適應 21rem 的窄邊欄。
- 移除冗餘的 $a1, a2$ 預覽方塊,僅保留帶有數學公式標籤的 $r, \delta$ 預覽。
- **步階序列**:數位微調步階改為自訂序列:`[0.5, 1, 2, 4, 8, 32, 256, 1024, 4096, 16384, 65536]`
- **微調量顯示**:統一使用 `±` 符號並帶空格(例如 `± 0.5`)。
- **智能禁用**:當數位步階為 $0.5$ 時,自動禁用 $r$ 的控制項,確保以 $\delta$ 為主導。
## 3. 繪圖系統 (Bode Plot)
- **幅值圖 (Magnitude)**
- 初始範圍:基於 $systemGain \times 3$,涵蓋 $70 \text{ dB}$。
- 保留手動彈性:設定為 `fixedrange: false`,支援滑鼠縮放與 Auto Scale。
- 狀態保持:使用 `uirevision`,確保微調參數時不會跳回初始縮放。
- **相位圖 (Phase)**
- 絕對鎖死:固定範圍為 $[-180^\circ, 180^\circ]$。
- 禁止縮放:設定為 `fixedrange: true`,提供穩定的絕對參考。
- **座標軸連動**:幅值圖與相位圖的 **X 軸(頻率)已完全同步**。不論在哪一張圖進行橫向縮放,兩者都會連動。
## 4. 數學公式參考
- **理想空間**
- $a1 = -1 - r + \delta$
- $a2 = r + \delta$
- **定點數空間 (Q14 為例)**
- $a0 = 2^{14} = 16384$
- $a1 = -a0 - r + \delta$
- $a2 = r + \delta$
- $2\delta = a1 + a2 + a0$
- $2r = a2 - a1 - a0$
## 5. 目前 Git 狀態
- **當前分支**`dev-ui-fix-20260515` (已於 2026-05-15 17:46 推送至 GitLab)。
- **同步說明**:此版本已安全儲存在獨立分支,不影響 `main` 分支同事的使用。
- **同事試用指令**
```bash
git fetch
git checkout dev-ui-fix-20260515
```
---
*Last Updated: 2026-05-15 17:47*
Executable → Regular
+236 -122
View File
@@ -1,109 +1,256 @@
# Difference Equation Analyzer
Difference Equation Analyzer 是一個用來設計、檢視與驗證離散時間濾波器的工具。後端使用 FastAPI、NumPy、SciPy 與 Pandas;前端使用 Vue 3、Vite、Tailwind CSS 與 Plotly
Difference Equation Analyzer 是用來設計、檢視與驗證離散時間濾波器的工具。它同時支援浮點係數、fixed-point 係數、時域 CSV 驗證,以及透過 Web Serial 將係數寫入 MCU
主要功能:
Backend 使用 FastAPI、NumPy、SciPy 與 PandasFrontend 使用 Vue 3、Vite、Tailwind CSS 與 Plotly。
## Features
- 設計 Lowpass、Highpass、Bandpass、Notch、1P1Z、2P1Z、2P2Z、PID、SOGI 濾波器。
- 比較浮點係數fixed-point 係數的頻率響應。
- 手動微調 b/a 係數、a1/a2 組合(支援 δ/r 參數微調)、Q 格式位元數與 fixed-point 整數係數。
- 透過瀏覽器 Web Serial API 直接將 fixed-point 係數命令送到 MCU(需透過 HTTPS 連線)
- 上傳 CSV 時域資料,套用目前差分方程並繪製輸入/輸出波形
- 匯出包含濾波輸出欄位的 CSV
- 支援深色模式 (Dark Mode) 與響應式 UI。
- 比較 Ideal Float Fixed-Point 的 Bode 頻率響應。
- 手動微調 b/a 係數、a1/a2、δ/r、Q-format 位元數與 fixed-point 整數係數。
- 上傳 CSV 時域資料,繪製輸入、Ideal Float 輸出與 Mimic Integer 輸出
- 匯出包含理想浮點與定點整數模擬輸出的 CSV
- 使用瀏覽器 Web Serial API 將 fixed-point 係數寫入 MCU
- 支援 Dark Mode 與響應式 UI。
## 專案結構
## Quick Start
```bash
cd /home/wisetop/diff-eq-analyzer
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
npm install
npm run build
bash certs/generate_cert.sh
.venv/bin/uvicorn dea_api:app --host 0.0.0.0 --port 8000 --ssl-keyfile certs/key.pem --ssl-certfile certs/cert.pem
```
開啟:
```text
https://localhost:8000/ui/
```
若從其他主機連線,改用:
```text
https://<server-ip>:8000/ui/
```
自簽憑證會讓瀏覽器顯示安全性警告,需手動信任或選擇繼續前往。
## Project Layout
```text
dea_api.py FastAPI 入口與 HTTP route
dea/ 後端核心模組
bode.py 頻率響應計算
config.py 限制值與伺服器端 serial 設定
csv_processing.py CSV 取、驗證、濾波與匯出
csv_processing.py CSV 取、驗證、濾波與匯出
filter_design.py 濾波器設計
mcu.py MCU serial port 列表與命令寫入 (後端備用支援)
mcu.py MCU serial port 列表與命令寫入
schemas.py Pydantic request schema
security.py LAN 存取限制與安全標頭
validation.py 係數、頻率與有限值驗證
src/ Vue 3 前端原始碼
certs/ HTTPS 自簽憑證與產生腳本
static/ FastAPI 對外提供的靜態 UI
static/build/ Vite build 輸出
static/build/ Vite production build 輸出
tests/ unittest 測試
dea.service systemd service 範例
chirp_signal.csv CSV 上傳測試範例
```
## 安裝
## Architecture
在目標主機上建立 Python venv 並安裝依賴:
| Layer | Responsibility |
| --- | --- |
| FastAPI | 提供 API、serve `/ui/`、套用 LAN 存取限制與安全標頭 |
| `dea/` | 濾波器設計、Bode plot、CSV 處理、fixed-point simulation、驗證 |
| Vue UI | 管理係數、Q-format、CSV workflow、Plotly 圖表與 Web Serial 狀態 |
| Vite build | 將 `src/` 打包到 `static/build/`,由 FastAPI 在 production 提供 |
```bash
cd /home/wisetop/diff-eq-analyzer
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
CSV workflow
1. 前端選擇 CSV。
2. 前端呼叫 `POST /api/csv/upload`,後端將檔案暫存並回傳 `file_id`
3. 後續 `POST /api/filter``POST /api/filter/download` 使用 `file_id`,避免反覆傳大檔。
CSV 預設暫存在:
```text
/tmp/diff-eq-analyzer-csv/
```
安裝前端依賴並建置
可用環境變數覆寫
```bash
DEA_TEMP_CSV_DIR=/path/to/csv-cache
```
## Usage
### Filter Design
在左側控制面板選擇濾波器類型並調整參數。系統會更新 b/a 係數,並繪製 Ideal Float 與 Fixed-Point 的頻率響應。
支援濾波器:
```text
Lowpass, Highpass, Bandpass, Notch,
1P1Z, 2P1Z, 2P2Z,
PID, SOGI-Alpha, SOGI-Beta
```
### a1/a2 Fine Tuning
針對二階濾波器,UI 支援 δ/r 參數化微調。
| Mode | Formula |
| --- | --- |
| Float | `a1 = -1 - r`, `a2 = r + δ` |
| Fixed | `a1 = -a0 - r`, `a2 = r + δ` |
反推關係:
| Mode | δ | r |
| --- | --- | --- |
| Float | `a1 + a2 + 1` | `-1 - a1` |
| Fixed | `a1 + a2 + a0` | `-a0 - a1` |
桌機可用 Shift + 滑鼠滾輪微調;觸控裝置可按住數值面板上下拖曳。
### Fixed-Point Time Simulation
時域分析同時計算兩條路徑:
| Path | Description |
| --- | --- |
| Ideal Float | 使用 SciPy `signal.lfilter()` 作為理想浮點參考 |
| Mimic Integer | 將輸入與係數量化為整數,模擬 MCU / DSP fixed-point 差分方程 |
Q-format 參數:
| Parameter | Meaning |
| --- | --- |
| `shift_in` | 輸入訊號量化位元數 |
| `shift_out` | 輸出訊號量化位元數 |
| `shift_b` | 前饋 b 係數量化位元數 |
| `shift_a` | 回授 a 係數量化位元數 |
| `use_round` | 右移時使用 Floor (SRA) 或 Round (+0.5) |
整數模擬採高精度狀態變數架構:前饋項保留在 `Q_in+b` 精度,回授項除以 `A0` 對齊,再於最終輸出依 `Q_out` 縮放。
### CSV Workflow
限制與行為:
| Item | Value |
| --- | --- |
| 檔案大小上限 | 300 MB |
| 時域運算列數上限 | 1,200,000 rows |
| 圖表點數上限 | 5000 points |
| 格式要求 | 必須有標題列與至少一筆資料 |
若第一欄像 `time``t``sec``x``index`,前端會預設選第二欄作為訊號欄位。
範例檔案:
```text
chirp_signal.csv
```
欄位:
```text
time,value
```
### MCU Web Serial
Web Serial 是主要 MCU 寫入方式,伺服器不需要 serial 權限。
| Action | Description |
| --- | --- |
| 連線 | 點擊「選擇連接埠並連線」,在瀏覽器彈窗選擇 MCU |
| 寫值 | 點擊「寫值到 MCU」 |
| 限制 | Chrome / Edge,且必須透過 HTTPS 或 localhost |
寫入命令格式:
```text
bodeplot=b0,b1,b2,a1,a2
```
## API
| Method | Endpoint | Purpose |
| --- | --- | --- |
| `POST` | `/api/design` | 依濾波器參數產生 b/a 係數 |
| `POST` | `/api/bode` | 計算單組 b/a 頻率響應 |
| `POST` | `/api/bode/compare` | 比較 Ideal Float 與 Fixed-Point 頻率響應 |
| `POST` | `/api/csv/upload` | 上傳 CSV 並回傳 `file_id` |
| `POST` | `/api/filter` | 使用 `file_id` 回傳時域圖表資料 |
| `POST` | `/api/filter/download` | 使用 `file_id` 匯出結果 CSV |
| `GET` | `/api/mcu/ports` | 列出伺服器端 serial ports |
| `POST` | `/api/mcu/write` | 後端模式寫入 MCU 命令 |
## Development
### Frontend
```bash
npm install
npm run build
```
## HTTPS 設定
為了支援 Web Serial API(瀏覽器直接存取本機連接埠),後端必須透過 HTTPS 連線提供服務。
1. **產生自簽憑證**
```bash
bash certs/generate_cert.sh
```
這會在 `certs/` 目錄下產生 `key.pem` 與 `cert.pem`。
2. **信任憑證**
由於是自簽憑證,瀏覽器會顯示安全性警告。請在「進階」中選擇「繼續前往」或將其加入信任清單。
## 執行
### 手動啟動 (HTTPS)
開發模式:
```bash
npm run dev
```
目前 `vite.config.js` 保留 `main` 設定,不在 Vite 內設定 `/api` proxy。
### Backend
```bash
.venv/bin/pip install -r requirements.txt
.venv/bin/uvicorn dea_api:app --host 0.0.0.0 --port 8000 --ssl-keyfile certs/key.pem --ssl-certfile certs/cert.pem
```
瀏覽器開啟:`https://<server-ip>:8000/ui/` 或本機 `https://localhost:8000/ui/`
### HTTPS Certificate
根路徑 `/` 會自動導向 `/ui/`。
```bash
bash certs/generate_cert.sh
```
### 使用 systemd
會在 `certs/` 產生:
1. 檢查 `dea.service` 中的路徑是否正確(需確認 `ExecStart` 已加上 `--ssl-keyfile` 等參數)。
2. 啟用並啟動服務:
```bash
sudo cp dea.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable dea.service
sudo systemctl start dea.service
```
```text
cert.pem
key.pem
```
### systemd
```bash
sudo cp dea.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable dea.service
sudo systemctl start dea.service
```
查看狀態:
查看狀態與 Log
```bash
sudo systemctl status dea.service
sudo journalctl -u dea.service -f
```
## MCU 連接埠 (Web Serial)
### Backend Serial Fallback
本工具優先使用瀏覽器的 Web Serial API 直接與 MCU 通訊,無需伺服器端具備 serial 權限。
- **連線**:點擊 UI 上的「選擇連接埠並連線」,在瀏覽器彈出視窗中選擇正確的 MCU 裝置。
- **寫值**:點擊「寫值到 MCU」,系統會將目前定點數係數格式化為 `bodeplot=b0,b1,b2,a1,a2` 並送出。
- **限制**:需使用 Chrome 或 Edge 瀏覽器,且必須透過 HTTPS (或 localhost) 連線。
## 後端 MCU Serial 設定 (備用)
若無法使用 Web Serial,後端仍保留透過伺服器端實體連線寫入的 API。預設值在 `dea/config.py`,也可用環境變數覆蓋:
若無法使用 Web Serial,可改用後端 serial API。環境變數:
```bash
MCU_SERIAL_PORT=/dev/ttyUSB2
@@ -111,7 +258,7 @@ MCU_BAUD_RATE=115200
MCU_WRITE_TIMEOUT=1.0
```
若使用 systemd可在 `dea.service` 的 `[Service]` 區塊加入:
systemd 可在 `[Service]` 加入:
```ini
Environment="MCU_SERIAL_PORT=/dev/ttyUSB2"
@@ -119,70 +266,27 @@ Environment="MCU_BAUD_RATE=115200"
Environment="MCU_WRITE_TIMEOUT=1.0"
```
## API
## Testing
目前主要 API
- `POST /api/design`:依濾波器參數產生 b/a 係數。
- `POST /api/bode`:計算單組 b/a 係數的頻率響應。
- `POST /api/bode/compare`:一次計算浮點與 fixed-point 兩組頻率響應。
- `POST /api/filter`:上傳 CSV 並回傳圖表用的濾波結果。
- `POST /api/filter/download`:上傳 CSV 並下載包含 `<欄位>_filtered` 的結果 CSV。
- `GET /api/mcu/ports`:列出伺服器端可用 serial ports (後端模式)。
- `POST /api/mcu/write`:送出伺服器端 MCU 命令 (後端模式)。
MCU 寫入命令格式固定為:
```text
bodeplot=b0,b1,b2,a1,a2
```
## a1/a2 微調 (δ/r 參數)
針對二階濾波器 (2P1Z/2P2Z),UI 提供了進階的步階微調功能:
- **δ (Delta)**:控制極點的頻率/阻尼平衡點。
- **r**:控制極點的半徑/頻寬。
- 公式:`a1 = -a0 - r + δ``a2 = r + δ` (定點數模式) 或 `a1 = -1 - r + δ``a2 = r + δ` (浮點數模式)。
- 支援透過 Shift + 滑鼠滾輪或觸控拖曳進行高精度微調。
## CSV 上傳
限制與行為:
- CSV 檔案大小上限為 32 MB。
- CSV 必須有標題列與至少一筆資料。
- 可選擇要濾波的訊號欄位;若第一欄像 `time`、`t`、`sec`、`x`、`index`,前端會預設選第二欄。
- 圖表回應最多回傳 5000 點,避免大型 CSV 讓前端過重。
- 下載 CSV 會保留完整資料,不套用圖表降採樣。
專案根目錄的 `chirp_signal.csv` 是上傳測試範例,約 19 MB、500001 行,欄位為:
```text
time,value
```
## 測試與檢查
Python 語法檢查:
Python syntax check
```bash
.venv/bin/python -m py_compile dea_api.py tests/test_dea_api.py
```
後端測試使用 Python 內建 `unittest`,不需要安裝或執行 `pytest`
Backend tests 使用 Python 內建 `unittest`
```bash
.venv/bin/python -m unittest discover -s tests
```
前端建置檢查
Frontend build check
```bash
npm run build
```
建議完整驗證順序
建議完整驗證:
```bash
.venv/bin/pip install -r requirements.txt
@@ -191,29 +295,37 @@ npm run build
sudo systemctl restart dea.service
```
## 安全與限制
## Security And Limits
- API middleware 只允許 private / loopback IP 存取。
- 回應會加上 CSP、`Referrer-Policy`、`X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`。
- CSP 的 script 來源允許本機與 Plotly CDN。
- 係數數量上限為 64。
- CSV 上限為 32 MB。
- 頻率、係數、`a[0]` 與 CSV 訊號欄位都有後端驗證。
| Item | Value |
| --- | --- |
| Network access | 只允許 private / loopback IP |
| CSV size | 300 MB |
| Time-domain rows | 1,200,000 rows |
| Plot points | 5000 points |
| Coefficients | 最多 64 個 |
## 常見問題
HTTP responses 會加上 CSP、`Referrer-Policy``X-Content-Type-Options: nosniff``X-Frame-Options: DENY`
### 為什麼無法連線連接埠?
1. 確認是否使用 HTTPS 連線。
2. 確認瀏覽器是否支援 Web Serial API (推薦使用 Chrome/Edge)。
3. 檢查 USB 傳輸線是否連接正確。
## Troubleshooting
### 無法連線 MCU
1. 確認使用 HTTPS 或 localhost。
2. 確認瀏覽器支援 Web Serial API。
3. 使用 Chrome 或 Edge。
4. 檢查 USB 線與 MCU 裝置狀態。
### 頁面沒有更新
1. 瀏覽器按 `Ctrl+F5` 強制重新整理。
2. 執行 `npm run build` 重新建置前端
3. 執行 `sudo systemctl restart dea.service`。
1. 執行 `npm run build`
2. 重啟服務:`sudo systemctl restart dea.service`
3. 瀏覽器按 `Ctrl+F5` 強制重新整理。
### systemd 顯示 `status=203/EXEC`
通常是 `.venv``uvicorn` 路徑不正確。可重建 venv
```bash
rm -rf .venv
python3 -m venv .venv
@@ -222,7 +334,9 @@ sudo systemctl restart dea.service
```
### 找不到 pytest
本專案不使用 pytest。請改用:
```bash
.venv/bin/python -m unittest discover -s tests
```
+2 -1
View File
@@ -1,7 +1,8 @@
import os
MAX_COEFFICIENTS = 64
MAX_CSV_BYTES = 32 * 1024 * 1024
MAX_CSV_BYTES = 300 * 1024 * 1024
MAX_ROWS = 1200000
MAX_PLOT_POINTS = 5000
BODE_POINTS = 500
BODE_MAX_MULTIPLIER = 3.162
+137 -25
View File
@@ -1,22 +1,41 @@
import io
import os
import uuid
import re
import tempfile
import numpy as np
import pandas as pd
from fastapi import HTTPException
from scipy import signal
import math
from .config import MAX_CSV_BYTES, MAX_PLOT_POINTS
from .config import MAX_CSV_BYTES, MAX_PLOT_POINTS, MAX_ROWS
TEMP_CSV_DIR = os.environ.get(
"DEA_TEMP_CSV_DIR",
os.path.join(tempfile.gettempdir(), "diff-eq-analyzer-csv"),
)
os.makedirs(TEMP_CSV_DIR, exist_ok=True)
def get_cached_file_path(file_id):
if not re.match(r'^[0-9a-f\-]{36}$', file_id):
raise HTTPException(status_code=400, detail="無效的檔案ID")
file_path = os.path.join(TEMP_CSV_DIR, f"{file_id}.csv")
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="找不到快取的 CSV 檔案,請重新上傳")
return file_path
def downsample_for_plot(index, original, filtered):
def downsample_for_plot(index, original, filtered_float, filtered_fixed):
total = len(index)
if total <= MAX_PLOT_POINTS:
return index, original, filtered, 1
return index, original, filtered_float, filtered_fixed, 1
step = int(np.ceil(total / MAX_PLOT_POINTS))
return index[::step], original[::step], filtered[::step], step
return index[::step], original[::step], filtered_float[::step], filtered_fixed[::step], step
async def read_csv_upload(file):
async def save_csv_upload(file):
filename = (file.filename or "").lower()
if filename and not filename.endswith(".csv"):
raise HTTPException(status_code=400, detail="請上傳 CSV 檔案")
@@ -25,37 +44,115 @@ async def read_csv_upload(file):
raise HTTPException(status_code=413, detail=f"CSV 檔案不可超過 {MAX_CSV_BYTES // (1024 * 1024)}MB")
if not contents.strip():
raise HTTPException(status_code=400, detail="CSV 不可為空")
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
file_id = str(uuid.uuid4())
file_path = os.path.join(TEMP_CSV_DIR, f"{file_id}.csv")
with open(file_path, "wb") as f:
f.write(contents)
return file_id
def filtered_csv_data(df, b_vals, a_vals, col_idx):
if col_idx < 0 or len(df.columns) <= col_idx:
def integer_lfilter(b_int, a_int, x_float, shift_in, shift_out, shift_b, shift_a, use_round=False):
"""
全整數差分方程式模擬 (Mimic DSP hardware)
高精度狀態變數架構:前饋不位移,保留小數精度於狀態變數中。
支援硬體 Rounding (四捨五入) vs Floor (無條件捨去向下取整)。
"""
b_int = np.asarray(b_int, dtype=np.int64)
a_int = np.asarray(a_int, dtype=np.int64)
x_int = np.round(x_float * (2**shift_in)).astype(np.int64)
# y_hist 將保留在 Q_{in + b} 格式以降低 Truncation Error
y_hist = np.zeros(len(x_int), dtype=np.int64)
y_out = np.zeros(len(x_int), dtype=np.int64)
nb = len(b_int)
na = len(a_int)
A0 = int(a_int[0])
if A0 == 0: A0 = 1
# 輸出所需的總位移量
out_shift = shift_in + shift_b - shift_out
# 預先計算四捨五入的補償值 (+0.5)
# 韌體開發提示 (C Implementation / RISC-V):
# 1. 標準 RISC-V (RV32I/IMAC) 的 SRA 指令是純 Floor (無條件捨去),沒有硬體 Rounding shift。
# (除非具備 'P' DSP Extension 才可能有 1-cycle 的硬體 rounding shift)。
# 2. 演算法秘技:在 C 語言中要實現 1-clock 的四捨五入,不要呼叫 float 的 round()。
# 請使用 `y = (acc + (1 << (shift - 1))) >> shift`。
# 編譯器會將 (1 << (shift - 1)) 編譯為常數,整體只消耗 1 個 ADD 指令,極大消除了 DC Bias。
round_offset_a = (A0 >> 1) if use_round else 0
round_offset_out = (1 << (out_shift - 1)) if (use_round and out_shift > 0) else 0
for n in range(len(x_int)):
sum_b = 0
# Feedforward: 前饋完全不位移,結果為 Q_{in + b}
for i in range(nb):
if n - i >= 0:
sum_b += b_int[i] * x_int[n - i]
# Feedback: 歷史紀錄為 Q_{in + b},係數為 Q_a,乘積為 Q_{in + b + a}
sum_a = 0
for j in range(1, na):
if n - j >= 0:
sum_a += a_int[j] * y_hist[n - j]
# Feedback 縮放:前提 A0 = 1 (或者代表放大了 Q_a 倍的常數),除以 A0 (即 >> shift_a) 歸一化
# Python 的 // 等同於硬體的 SRA (Arithmetic Right Shift),會向負無窮大 Floor
sum_a_scaled = (sum_a + round_offset_a) // A0
acc = sum_b - sum_a_scaled
# 將超高精度的 acc 直接存入歷史變數
y_hist[n] = acc
# 最終輸出再針對 Q_out 進行位移縮放
if out_shift > 0:
y_out[n] = (acc + round_offset_out) >> out_shift
elif out_shift < 0:
y_out[n] = acc << (-out_shift)
else:
y_out[n] = acc
return y_out.astype(float) / (2**shift_out)
def filter_preview_response(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None, shift_in=14, shift_out=14, shift_b=14, shift_a=14, use_round=False):
path = get_cached_file_path(file_id)
# 預先讀取欄位名稱,避免用 usecols 讀取後找不到原始索引
cols = pd.read_csv(path, nrows=0).columns.tolist()
if col_idx < 0 or col_idx >= len(cols):
raise HTTPException(status_code=400, detail="欄位索引超出範圍")
col_to_filter = df.columns[col_idx]
col_to_filter = cols[col_idx]
# 精準讀取單一欄位,並加上筆數限制 (極大降低記憶體用量與時間)
df = pd.read_csv(path, usecols=[col_idx], nrows=MAX_ROWS)
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} 含有非數值資料")
x_values = x_signal.to_numpy(dtype=float)
if not np.isfinite(x_values).all():
raise HTTPException(status_code=400, detail=f"欄位 {col_to_filter} 含有非有限數值")
y_signal = signal.lfilter(b_vals, a_vals, x_values)
return col_to_filter, x_values, y_signal
# 路徑 1: 理想浮點數路徑
y_float = signal.lfilter(b_vals, a_vals, x_values)
def filter_preview_response(df, b_vals, a_vals, col_idx):
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)
# 路徑 2: 整數模擬路徑
if b_int is not None and a_int is not None:
y_fixed = integer_lfilter(b_int, a_int, x_values, shift_in, shift_out, shift_b, shift_a, use_round)
else:
y_fixed = y_float
index, original, filtered_float, filtered_fixed, step = downsample_for_plot(df.index.to_numpy(), x_signal, y_float, y_fixed)
return {
"index": index.tolist(),
"original": original.tolist(),
"filtered": filtered.tolist(),
"filtered": filtered_float.tolist(),
"filtered_fixed": filtered_fixed.tolist(),
"col_name": col_to_filter,
"total_points": int(len(df.index)),
"plot_points": int(len(index)),
@@ -63,10 +160,25 @@ def filter_preview_response(df, b_vals, a_vals, col_idx):
}
def filtered_csv_text(df, b_vals, a_vals, col_idx):
col_to_filter, _, y_signal = filtered_csv_data(df, b_vals, a_vals, col_idx)
df[f"{col_to_filter}_filtered"] = y_signal
def filtered_csv_text(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None, shift_in=14, shift_out=14, shift_b=14, shift_a=14, use_round=False):
path = get_cached_file_path(file_id)
# 匯出時需要原始所有欄位,但仍受限於 MAX_ROWS
df = pd.read_csv(path, nrows=MAX_ROWS)
if col_idx < 0 or col_idx >= len(df.columns):
raise HTTPException(status_code=400, detail="欄位索引超出範圍")
col_to_filter = df.columns[col_idx]
x_signal = pd.to_numeric(df[col_to_filter], errors="coerce")
x_values = x_signal.to_numpy(dtype=float)
y_float = signal.lfilter(b_vals, a_vals, x_values)
if b_int is not None and a_int is not None:
y_fixed = integer_lfilter(b_int, a_int, x_values, shift_in, shift_out, shift_b, shift_a, use_round)
else:
y_fixed = y_float
df[f"{col_to_filter}_filtered_ideal"] = y_float
df[f"{col_to_filter}_filtered_fixed"] = y_fixed
csv_buffer = io.StringIO()
df.to_csv(csv_buffer, index=False)
return csv_buffer.getvalue()
+13
View File
@@ -27,6 +27,19 @@ def parse_coefficients(raw, name):
return validate_coefficients(values, name)
def parse_int_coefficients(raw, name):
try:
# 先轉 float 再轉 int 以處理 1.0 這種字串
values = [int(float(x.strip())) for x in raw.replace(",", " ").split() if x.strip()]
except ValueError:
raise HTTPException(status_code=400, detail=f"{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}")
return values
def validate_coefficients(values, name):
if len(values) == 0:
raise HTTPException(status_code=400, detail=f"{name} 係數不可為空")
+55 -8
View File
@@ -14,9 +14,8 @@ from dea.config import (
from dea.csv_processing import (
downsample_for_plot,
filter_preview_response,
filtered_csv_data,
filtered_csv_text,
read_csv_upload,
save_csv_upload,
)
from dea.filter_design import design_response
from dea.mcu import list_mcu_ports_response, write_mcu_command_response
@@ -27,9 +26,11 @@ from dea.security import (
is_lan_or_loopback,
lan_denied_response,
)
import traceback
from dea.validation import (
normalize_coefficients,
parse_coefficients,
parse_int_coefficients,
require_finite,
validate_coefficients,
validate_frequency,
@@ -86,21 +87,50 @@ def calculate_bode_compare(params: BodeCompareParams):
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/csv/upload")
async def upload_csv(file: UploadFile = File(...)):
try:
file_id = await save_csv_upload(file)
return {"file_id": file_id}
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=400, detail=f"CSV上傳失敗: {str(e)}")
@app.post("/api/filter")
async def filter_csv(
file: UploadFile = File(...),
file_id: str = Form(...),
b: str = Form(...),
a: str = Form(...),
col_idx: int = Form(0),
b_int: str = Form(None),
a_int: str = Form(None),
shift_in: int = Form(14),
shift_out: int = Form(14),
shift_b: int = Form(14),
shift_a: int = Form(14),
use_round: bool = Form(False),
):
try:
b_vals = parse_coefficients(b, "b")
a_vals = parse_coefficients(a, "a")
df = await read_csv_upload(file)
return filter_preview_response(df, b_vals, a_vals, col_idx)
b_int_vals = parse_int_coefficients(b_int, "b_int") if b_int else None
a_int_vals = parse_int_coefficients(a_int, "a_int") if a_int else None
return filter_preview_response(
file_id, b_vals, a_vals, col_idx,
b_int=b_int_vals, a_int=a_int_vals,
shift_in=shift_in, shift_out=shift_out,
shift_b=shift_b, shift_a=shift_a,
use_round=use_round
)
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=400, detail=f"CSV處理失敗: {str(e)}")
@@ -126,16 +156,32 @@ def write_mcu_command(params: MCUWriteParams):
@app.post("/api/filter/download")
async def filter_csv_download(
file: UploadFile = File(...),
file_id: str = Form(...),
b: str = Form(...),
a: str = Form(...),
col_idx: int = Form(0),
b_int: str = Form(None),
a_int: str = Form(None),
shift_in: int = Form(14),
shift_out: int = Form(14),
shift_b: int = Form(14),
shift_a: int = Form(14),
use_round: bool = Form(False),
):
try:
b_vals = parse_coefficients(b, "b")
a_vals = parse_coefficients(a, "a")
df = await read_csv_upload(file)
csv_text = filtered_csv_text(df, b_vals, a_vals, col_idx)
b_int_vals = parse_int_coefficients(b_int, "b_int") if b_int else None
a_int_vals = parse_int_coefficients(a_int, "a_int") if a_int else None
csv_text = filtered_csv_text(
file_id, b_vals, a_vals, col_idx,
b_int=b_int_vals, a_int=a_int_vals,
shift_in=shift_in, shift_out=shift_out,
shift_b=shift_b, shift_a=shift_a,
use_round=use_round
)
return StreamingResponse(
iter([csv_text]),
@@ -145,4 +191,5 @@ async def filter_csv_download(
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=400, detail=f"CSV處理失敗: {str(e)}")
+7
View File
@@ -0,0 +1,7 @@
{
"folders": [
{
"path": "."
}
]
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "bodeplot",
"name": "diff-eq-analyzer",
"lockfileVersion": 3,
"requires": true,
"packages": {
+255 -187
View File
@@ -18,7 +18,7 @@
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">
<svg v-else class="w-5 h-5 text-slate-800" 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>
@@ -29,7 +29,7 @@
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 role-border-primary role-text-primary bg-transparent'
: 'text-slate-500 dark:text-gray-400 hover:text-slate-700 dark:hover:text-gray-200'"
: 'text-slate-600 dark:text-gray-400 hover:text-slate-800 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"
@@ -41,7 +41,7 @@
</button>
<button @click="mobileTab = 'chart'" :class="mobileTab === 'chart'
? 'border-b-2 role-border-primary role-text-primary bg-transparent'
: 'text-slate-500 dark:text-gray-400 hover:text-slate-700 dark:hover:text-gray-200'"
: 'text-slate-600 dark:text-gray-400 hover:text-slate-800 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"
@@ -55,12 +55,12 @@
<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-4 gap-3 custom-scrollbar transition-colors duration-300">
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-2 flex items-center justify-between">
class="text-sm font-semibold text-slate-600 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"
@@ -74,14 +74,14 @@
<div class="flex items-center gap-2">
<button @click="writeToMCU"
:disabled="writingMCU || !mcuConnected"
class="primary-action text-[10px] font-bold role-bg-primary role-hover-primary role-text-on-fill role-border-primary border px-2 py-1 rounded transition-colors uppercase disabled:opacity-50 disabled:cursor-not-allowed">
class="primary-action text-[10px] font-bold role-bg-primary role-hover-primary role-text-on-fill role-border-primary border px-3 py-1 rounded-full transition-colors uppercase disabled:opacity-50 disabled:cursor-not-allowed">
{{ writingMCU ? '寫入中' : '寫值到 MCU' }}
</button>
<button @click="resetFilterParams"
class="text-[10px] font-bold role-bg-secondary-soft role-border-secondary-soft role-text-secondary role-hover-secondary-soft border px-2 py-1 rounded transition-colors uppercase">重設設計</button>
class="text-[10px] font-bold border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 px-3 py-1 rounded-full transition-colors uppercase">重設設計</button>
</div>
</h2>
<div class="mb-2 space-y-1.5">
<div class="mb-3 space-y-2">
<div v-if="webSerialSupported" class="flex items-center gap-2">
<button v-if="!mcuConnected" @click="connectMCUPort"
class="flex-1 text-xs font-bold role-bg-primary role-hover-primary role-text-on-fill role-border-primary border px-3 py-2 rounded transition-colors uppercase">
@@ -93,7 +93,7 @@
MCU 已連線
</span>
<button @click="disconnectMCUPort"
class="text-[10px] font-bold bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-300 hover:bg-slate-300 dark:hover:bg-gray-700 px-2 py-2 rounded transition-colors uppercase">
class="text-[10px] font-bold bg-slate-200 dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-300 dark:hover:bg-gray-700 px-2 py-2 rounded transition-colors uppercase">
斷線
</button>
</template>
@@ -101,73 +101,73 @@
<p v-if="!webSerialSupported" class="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg px-3 py-2">
此瀏覽器不支援 Web SerialMCU 寫值功能需使用 Chrome Edge且透過 HTTPS 連線
</p>
<p v-if="mcuStatus" class="text-xs text-slate-500 dark:text-gray-400">{{ mcuStatus }}</p>
<p v-if="mcuStatus" class="text-xs text-slate-600 dark:text-gray-400">{{ mcuStatus }}</p>
</div>
<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-2 text-base role-focus-primary focus:ring-1 role-focus-ring-primary outline-none transition-all mb-2 text-slate-900 dark:text-gray-100 truncate">
class="w-full bg-slate-50 dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base role-focus-primary focus:ring-1 role-focus-ring-primary 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-2 bg-slate-100 dark:bg-gray-900 p-3 rounded-lg border border-slate-200 dark:border-gray-800 shadow-inner">
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>截止頻率
<label class="text-sm text-slate-600 dark:text-gray-400 flex justify-between mb-1"><span>截止頻率
f_c (Hz)</span><span class="role-text-primary">{{
formatK(params.fc) }}</span></label>
<input type="range" v-model.number="params.fc" @input="debouncedApply" :min="1" :max="fs/2"
class="w-full role-range-primary 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-2 text-base role-focus-primary outline-none text-slate-900 dark:text-gray-100">
<label class="text-sm text-slate-500 dark:text-gray-400 mt-1.5 mb-1 block">階數 Order</label>
<div class="selector-button-group flex gap-1">
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base role-focus-primary outline-none text-slate-900 dark:text-gray-100">
<label class="text-sm text-slate-600 dark:text-gray-400 mt-3 mb-2 block">階數 Order</label>
<div class="selector-button-group flex gap-2">
<button v-for="o in [1,2,3]" :key="o" @click="params.order = o; applyFilterDesign()"
:class="params.order === o ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
class="selector-button flex-1 rounded py-0.5 text-sm font-medium border transition-colors">{{ o
:class="params.order === o ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-700 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
class="selector-button 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>下截止頻率
<label class="text-sm text-slate-600 dark:text-gray-400 flex justify-between mb-1"><span>下截止頻率
f_low (Hz)</span><span class="role-text-primary">{{
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>上截止頻率
<label class="text-sm text-slate-600 dark:text-gray-400 flex justify-between mb-1"><span>上截止頻率
f_high (Hz)</span><span class="role-text-primary">{{
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-2 text-base mb-1 text-slate-900 dark:text-gray-100">
<label class="text-sm text-slate-500 dark:text-gray-400 mt-1.5 mb-1 block">階數 Order</label>
<div class="selector-button-group flex gap-1">
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-600 dark:text-gray-400 mt-2 mb-2 block">階數 Order</label>
<div class="selector-button-group flex gap-2">
<button v-for="o in [1,2,3]" :key="o" @click="params.bp_order = o; applyFilterDesign()"
:class="params.bp_order === o ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
class="selector-button flex-1 rounded py-0.5 text-sm border transition-colors">{{ o }}</button>
:class="params.bp_order === o ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-700 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
class="selector-button flex-1 rounded py-1.5 text-sm border transition-colors">{{ o }}</button>
</div>
</div>
<!-- Notch -->
<div v-if="filterType === 'Notch (陷波器)'" class="space-y-2">
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">中心頻率 f_0
<div v-if="filterType === 'Notch (陷波器)'" class="space-y-3">
<div><label class="text-sm text-slate-600 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-2 text-base text-slate-900 dark:text-gray-100">
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-1 block">品質因數
<div><label class="text-sm text-slate-600 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-2 text-base text-slate-900 dark:text-gray-100">
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
<div><label class="text-sm text-slate-600 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
<div><label class="text-sm text-slate-600 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">
@@ -175,17 +175,17 @@
</div>
<!-- 2P1Z -->
<div v-if="filterType === '2P1Z (二極一零)'" class="space-y-3">
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">零點頻率 Freq_z
<div><label class="text-sm text-slate-600 dark:text-gray-400 mb-2 block">零點頻率 Freq_z
(Hz)</label><input type="number" v-model.number="params.tp1z_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">極點頻率 1 Freq_p1
<div><label class="text-sm text-slate-600 dark:text-gray-400 mb-2 block">極點頻率 1 Freq_p1
(Hz)</label><input type="number" v-model.number="params.tp1z_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
<div><label class="text-sm text-slate-600 dark:text-gray-400 mb-2 block">極點頻率 2 Freq_p2
(Hz)</label><input type="number" v-model.number="params.tp1z_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">
@@ -193,22 +193,22 @@
</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
<div><label class="text-sm text-slate-600 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
<div><label class="text-sm text-slate-600 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
<div><label class="text-sm text-slate-600 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
<div><label class="text-sm text-slate-600 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">
@@ -218,7 +218,7 @@
<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">{{
<label class="text-sm font-medium text-slate-600 dark:text-gray-400 mb-1 block">{{
k.label
}}</label>
<input type="number" v-model.number="params[k.key]" @change="applyFilterDesign"
@@ -229,12 +229,12 @@
<!-- 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
<div><label class="text-xs text-slate-600 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">阻尼因數
<div><label class="text-xs text-slate-600 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">
@@ -247,7 +247,7 @@
<!-- 系統參數 -->
<section>
<h2 class="text-sm font-semibold text-slate-500 dark:text-gray-400 uppercase tracking-wider mb-2 flex items-center gap-2">
<h2 class="text-sm font-semibold text-slate-600 dark:text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z">
@@ -258,20 +258,20 @@
系統參數</h2>
<div class="space-y-3">
<div>
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>取樣頻率
<label class="text-sm text-slate-600 dark:text-gray-400 flex justify-between mb-1"><span>取樣頻率
fs
(Hz)</span><span class="role-text-primary">{{ 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-2 text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base role-focus-primary 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>系統增益
<label class="text-sm text-slate-600 dark:text-gray-400 flex justify-between mb-1"><span>系統增益
K (System Gain)</span><span class="role-text-primary">{{ 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-2 text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
</div>
</div>
</section>
@@ -281,7 +281,7 @@
<!-- 係數手動設定 -->
<section class="coeff-section">
<h2
class="text-sm font-semibold text-slate-500 dark:text-gray-400 uppercase tracking-wider mb-2 flex justify-between items-center">
class="text-sm font-semibold text-slate-600 dark:text-gray-400 uppercase tracking-wider mb-3 flex justify-between items-center">
<span 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"
@@ -290,41 +290,41 @@
濾波器係數設定</span>
<div class="flex items-center gap-2">
<button v-if="isManualDirty" @click="clearManualAdjustments"
class="text-[10px] font-bold role-bg-secondary-soft role-border-secondary-soft role-text-secondary role-hover-secondary-soft border px-2 py-1 rounded transition-colors uppercase mr-1">重設</button>
class="text-[10px] font-bold border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 px-3 py-1 rounded-full transition-colors uppercase mr-1">重設</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>
<label class="text-sm text-slate-600 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="role-bg-primary-soft px-1.5 py-0.5 rounded border role-border-primary-soft flex gap-1 items-center">
<span class="text-[9px] role-text-primary-muted font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono role-text-primary">{{ item.val
class="bg-slate-700 dark:bg-gray-300 px-1.5 py-0.5 rounded border border-transparent flex gap-1 items-center shadow-sm">
<span class="text-[9px] text-white dark:text-gray-900 font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono text-white dark:text-gray-900 font-bold">{{ 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-2 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-primary"></textarea>
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-primary"></textarea>
</div>
<!-- b 係數倍率微調 -->
<details :open="activeCoeffAdjustment === 'bScale'"
class="mb-3 bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-800 rounded-lg">
<summary @click.prevent="toggleCoeffAdjustment('bScale')"
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">
class="text-sm text-slate-600 dark:text-gray-400 px-3 py-3 cursor-pointer hover:text-slate-700 dark:hover:text-gray-300">
b 係數倍率微調</summary>
<div class="px-3 pb-3">
<div class="selector-button-group flex items-center gap-1 mb-2 mt-1">
<span class="text-xs text-slate-400 dark:text-gray-500">靈敏度:</span>
<div class="selector-button-group flex items-center gap-2 mb-2 mt-1">
<span class="text-xs text-slate-500 dark:text-gray-500">靈敏度:</span>
<button v-for="s in ['1%','2x','10x']" :key="s" @click="sense_b = s"
:class="sense_b === s ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-500 dark:text-gray-400'"
class="selector-button btn-small px-3 py-0.5 rounded text-[10px] font-medium border border-slate-300 dark:border-gray-700">{{
:class="sense_b === s ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400'"
class="selector-button btn-small px-3 py-1.5 rounded text-xs font-medium border border-slate-300 dark:border-gray-700">{{
s }}</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">
<label class="text-xs text-slate-500 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="role-text-warning font-bold animate-pulse">超出滑桿範圍</span>
@@ -337,44 +337,44 @@
</div>
</details>
<div class="mb-3">
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">回饋分母係數 a (a0=1, a1 ~
<label class="text-sm text-slate-600 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="role-bg-secondary-soft px-1.5 py-0.5 rounded border role-border-secondary-soft flex gap-1 items-center">
<span class="text-[9px] role-text-secondary-muted font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono role-text-secondary">{{ item.val
class="bg-slate-700 dark:bg-gray-300 px-1.5 py-0.5 rounded border border-transparent flex gap-1 items-center shadow-sm">
<span class="text-[9px] text-white dark:text-gray-900 font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono text-white dark:text-gray-900 font-bold">{{ 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-2 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-secondary"></textarea>
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-primary"></textarea>
</div>
<!-- a 係數倍率微調 -->
<details :open="activeCoeffAdjustment === 'aScale'"
class="bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-800 rounded-lg">
<summary @click.prevent="toggleCoeffAdjustment('aScale')"
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">
class="text-sm text-slate-600 dark:text-gray-400 px-3 py-3 cursor-pointer hover:text-slate-700 dark:hover:text-gray-300">
a 係數倍率微調</summary>
<div class="px-3 pb-3">
<div class="selector-button-group flex items-center gap-2 mb-2 mt-1">
<span class="text-xs text-slate-400 dark:text-gray-500">靈敏度:</span>
<span class="text-xs text-slate-500 dark:text-gray-500">靈敏度:</span>
<button v-for="s in ['1%','2x','10x']" :key="s" @click="sense_a = s"
:class="sense_a === s ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-500 dark:text-gray-400'"
class="selector-button px-3 py-0.5 rounded text-[10px] font-medium border border-slate-300 dark:border-gray-700">{{
:class="sense_a === s ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400'"
class="selector-button px-3 py-1.5 rounded text-xs font-medium border border-slate-300 dark:border-gray-700">{{
s }}</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">
<label class="text-xs text-slate-500 dark:text-gray-500 flex justify-between mt-1">
<span>a{{ i }} (基準: {{ baseA[i].toPrecision(6) }})</span>
<span v-if="outOfRangeA[i]" class="role-text-warning 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 role-range-secondary">
class="w-full role-range-primary">
</div>
</template>
</div>
@@ -384,86 +384,93 @@
<details :open="activeCoeffAdjustment === 'a1a2'"
class="mt-3 bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-800 rounded-lg">
<summary @click.prevent="toggleCoeffAdjustment('a1a2')"
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">
class="text-sm text-slate-600 dark:text-gray-400 px-3 py-3 cursor-pointer hover:text-slate-700 dark:hover:text-gray-300">
a1/a2 步階微調</summary>
<div class="px-3 pb-3">
<div class="mb-4">
<div class="grid grid-cols-[3.25rem_1fr_3.25rem] gap-2 mb-3">
<button type="button" @click="adjustAFineStep(-1)" title="調粗"
class="rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-600 dark:text-gray-300 py-1 text-xs font-bold transition-colors hover:bg-slate-100 dark:hover:bg-gray-700">
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-sm font-bold shadow-sm">
</button>
<div class="min-w-0 rounded-lg border role-border-secondary-soft role-bg-secondary-soft px-3 py-2 text-center">
<span class="block text-[9px] role-text-secondary-muted role-text-secondary font-bold">微調量</span>
<span class="block truncate font-mono text-sm font-bold role-text-secondary" :title="formatAFineDelta()">
± {{ formatAFineDelta() }}
<div class="min-w-0 rounded-lg border role-border-primary-soft role-bg-primary-soft px-3 h-[42px] flex flex-col justify-center text-center">
<span class="block text-[9px] role-text-primary-muted role-text-primary font-bold">微調量</span>
<span class="block truncate font-mono text-sm font-bold role-text-primary leading-none mt-0.5" :title="formatAFineDelta()">
+/-{{ formatAFineDelta() }}
</span>
</div>
<button type="button" @click="adjustAFineStep(1)" title="調細"
class="rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-600 dark:text-gray-300 py-1 text-xs font-bold transition-colors hover:bg-slate-100 dark:hover:bg-gray-700">
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-sm font-bold shadow-sm">
</button>
</div>
<div class="text-[11px] font-semibold text-slate-400 dark:text-gray-500 mb-2">
<div class="text-[11px] font-semibold text-slate-500 dark:text-gray-500 mb-2">
{{ isTouchInput ? '觸控拖曳微調' : '直接輸入 / shift + 滾輪微調' }}
</div>
<div class="flex flex-col gap-3">
<div class="grid grid-cols-2 gap-2">
<article v-if="!isTouchInput" class="block">
<span class="text-[9px] text-slate-400 block font-bold mb-1">δ</span>
<span class="text-[9px] text-slate-500 block font-bold mb-1">δ</span>
<div class="grid grid-cols-[2rem_1fr_2rem] gap-1">
<button type="button" @pointerdown.prevent="startA1A2Repeat('delta', -1)" @pointerup="stopA1A2Repeat" @pointerleave="stopA1A2Repeat" @pointercancel="stopA1A2Repeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
<input type="number" :value="formatFineCoeff(currentDelta)" :step="aFineStep"
@change="setAFineDirect('delta', $event.target.value)" @keydown="onlyNumberKey"
@wheel="handleAFineWheel('delta', $event)"
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-xs font-mono text-center role-text-secondary">
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg px-2 h-[42px] text-xs font-mono text-center role-text-primary">
<button type="button" @pointerdown.prevent="startA1A2Repeat('delta', 1)" @pointerup="stopA1A2Repeat" @pointerleave="stopA1A2Repeat" @pointercancel="stopA1A2Repeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
</div>
</article>
<button v-else type="button" @pointerdown.prevent="startAFineDrag('delta', $event)"
class="touch-none select-none w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-secondary active:ring-2 role-active-ring-secondary">
<span class="text-[9px] text-slate-400 block font-bold mb-1">δ</span>
<span class="block max-w-full truncate text-base font-mono font-bold role-text-secondary" :title="formatFineCoeff(currentDelta)">{{ formatFineCoeff(currentDelta) }}</span>
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-primary active:ring-2 role-active-ring-primary">
<span class="text-[9px] text-slate-500 block font-bold mb-1">δ</span>
<span class="block max-w-full truncate text-base font-mono font-bold role-text-primary" :title="formatFineCoeff(currentDelta)">{{ formatFineCoeff(currentDelta) }}</span>
</button>
<article v-if="!isTouchInput" class="block">
<span class="text-[9px] text-slate-400 block font-bold mb-1">r</span>
<span class="text-[9px] text-slate-500 block font-bold mb-1">r</span>
<div class="grid grid-cols-[2rem_1fr_2rem] gap-1">
<button type="button" @pointerdown.prevent="startA1A2Repeat('r', -1)" @pointerup="stopA1A2Repeat" @pointerleave="stopA1A2Repeat" @pointercancel="stopA1A2Repeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
<input type="number" :value="formatFineCoeff(currentR)" :step="aFineStep"
@change="setAFineDirect('r', $event.target.value)" @keydown="onlyNumberKey"
@wheel="handleAFineWheel('r', $event)"
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-xs font-mono text-center text-slate-700 dark:text-gray-300">
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg px-2 h-[42px] text-xs font-mono text-center role-text-primary">
<button type="button" @pointerdown.prevent="startA1A2Repeat('r', 1)" @pointerup="stopA1A2Repeat" @pointerleave="stopA1A2Repeat" @pointercancel="stopA1A2Repeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
</div>
</article>
<button v-else type="button" @pointerdown.prevent="startAFineDrag('r', $event)"
class="touch-none select-none w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center active:border-slate-400 active:ring-2 active:ring-slate-400/20">
<span class="text-[9px] text-slate-400 block font-bold mb-1">r</span>
<span class="block max-w-full truncate text-base font-mono font-bold text-slate-700 dark:text-gray-300" :title="formatFineCoeff(currentR)">{{ formatFineCoeff(currentR) }}</span>
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center active:border-slate-400 active:ring-2 active:ring-slate-400/20">
<span class="text-[9px] text-slate-500 block font-bold mb-1">r</span>
<span class="block max-w-full truncate text-base font-mono font-bold role-text-primary" :title="formatFineCoeff(currentR)">{{ formatFineCoeff(currentR) }}</span>
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-2 mb-4">
<div class="min-w-0 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 font-bold mb-1">r = -1 - a1</span>
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-secondary" :title="formatFineCoeff(currentR)">{{ formatFineCoeff(currentR) }}</span>
<span class="text-[9px] text-slate-500 block font-bold mb-1">a1 = -1 - r</span>
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-primary-muted" :title="formatFineCoeff(currentA1)">{{ formatFineCoeff(currentA1) }}</span>
</div>
<div class="min-w-0 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 font-bold mb-1">δ = a1 + a2 + 1</span>
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-secondary" :title="formatFineCoeff(currentDelta)">{{ formatFineCoeff(currentDelta) }}</span>
<span class="text-[9px] text-slate-500 block font-bold mb-1">a2 = r + δ</span>
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-primary-muted" :title="formatFineCoeff(currentA2)">{{ formatFineCoeff(currentA2) }}</span>
</div>
<div class="min-w-0 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-500 block font-bold mb-1">差值 a1-a2</span>
<span class="block max-w-full truncate text-xs font-mono text-slate-700 dark:text-gray-300" :title="formatFineCoeff(currentA1A2Diff)">{{ formatFineCoeff(currentA1A2Diff) }}</span>
</div>
<div class="min-w-0 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-500 block font-bold mb-1">總和 a1+a2</span>
<span class="block max-w-full truncate text-xs font-mono text-slate-700 dark:text-gray-300" :title="formatFineCoeff(currentA1A2Sum)">{{ formatFineCoeff(currentA1A2Sum) }}</span>
</div>
</div>
<div class="role-bg-warning-soft border role-border-warning-soft rounded-lg p-3">
<p class="text-[10px] role-text-warning leading-relaxed">
<span class="font-bold">運算說明</span><br>
a1 = -1 - r, a2 = r + δ更改數值會自動更新圖表桌機可用 ± shift + 滾輪觸控時按住數值面板上下拖曳
δ = a1 + a2 + 1r = -1 - a1更改數值會自動更新圖表桌機可用 +/- shift +
</p>
</div>
</div>
@@ -475,176 +482,183 @@
<!-- 定點數轉換 -->
<section class="fixed-section">
<h2
class="text-sm font-semibold text-slate-500 dark:text-gray-400 uppercase tracking-wider mb-2 flex justify-between items-center">
class="text-sm font-semibold text-slate-600 dark:text-gray-400 uppercase tracking-wider mb-3 flex justify-between items-center">
<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="M9 7h6M9 12h6M9 17h3M5 5a2 2 0 012-2h7l5 5v11a2 2 0 01-2 2H7a2 2 0 01-2-2V5z"></path>
</svg>
<span>定點數係數轉換</span>
<span
<span v-if="shiftBitsB > 0"
class="text-xs role-bg-primary-soft role-text-primary px-2 py-1 rounded">b Q{{
shiftBitsB }}</span>
<span
class="text-xs role-bg-secondary-soft role-text-secondary px-2 py-1 rounded">a Q{{
<span v-if="shiftBitsA > 0"
class="text-xs role-bg-primary-soft role-text-primary px-2 py-1 rounded">a Q{{
shiftBitsA }}</span>
</div>
<div class="flex items-center gap-2">
<button v-if="isFixedPointDirty" @click="resetFixedPointSettings"
class="text-[10px] font-bold role-bg-secondary-soft role-border-secondary-soft role-text-secondary role-hover-secondary-soft border px-2 py-1 rounded transition-colors uppercase mr-1">重設</button>
class="text-[10px] font-bold border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 px-3 py-1 rounded-full transition-colors uppercase mr-1">重設</button>
</div>
</h2>
<div class="mb-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">b 左移位元數 (Shift Bits)</label>
<label class="text-sm text-slate-600 dark:text-gray-400 mb-1 block">b 左移位元數 (Shift Bits)</label>
<div v-if="!isTouchInput" class="grid grid-cols-[2rem_1fr_2rem] gap-1">
<button type="button" @pointerdown.prevent="startShiftBitRepeat('b', -1)" @pointerup="stopShiftBitRepeat" @pointerleave="stopShiftBitRepeat" @pointercancel="stopShiftBitRepeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
<input type="number" :value="shiftBitsB" min="0"
@change="setShiftBits('b', $event.target.value)" @keydown="onlyNumberKey"
@wheel="handleShiftBitWheel('b', $event)"
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg px-2 h-[42px] text-center text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
<button type="button" @pointerdown.prevent="startShiftBitRepeat('b', 1)" @pointerup="stopShiftBitRepeat" @pointerleave="stopShiftBitRepeat" @pointercancel="stopShiftBitRepeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
</div>
<button v-else type="button" @pointerdown.prevent="startShiftBitDrag('b', $event)"
class="touch-none select-none block w-24 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-primary active:ring-2 role-active-ring-primary">
<span class="text-lg font-mono font-bold role-text-primary">{{ shiftBitsB }}</span>
</button>
<p class="text-[10px] text-slate-400 mt-1">b scaling: 2^{{ shiftBitsB }} ({{
<p class="text-[10px] text-slate-500 mt-1">b scaling: 2^{{ shiftBitsB }} ({{
Math.pow(2, shiftBitsB) }})</p>
</div>
<div>
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">a 左移位元數 (Shift Bits)</label>
<label class="text-sm text-slate-600 dark:text-gray-400 mb-1 block">a 左移位元數 (Shift Bits)</label>
<div v-if="!isTouchInput" class="grid grid-cols-[2rem_1fr_2rem] gap-1">
<button type="button" @pointerdown.prevent="startShiftBitRepeat('a', -1)" @pointerup="stopShiftBitRepeat" @pointerleave="stopShiftBitRepeat" @pointercancel="stopShiftBitRepeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
<input type="number" :value="shiftBitsA" min="0"
@change="setShiftBits('a', $event.target.value)" @keydown="onlyNumberKey"
@wheel="handleShiftBitWheel('a', $event)"
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg px-2 h-[42px] text-center text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
<button type="button" @pointerdown.prevent="startShiftBitRepeat('a', 1)" @pointerup="stopShiftBitRepeat" @pointerleave="stopShiftBitRepeat" @pointercancel="stopShiftBitRepeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
</div>
<button v-else type="button" @pointerdown.prevent="startShiftBitDrag('a', $event)"
class="touch-none select-none block w-24 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-secondary active:ring-2 role-active-ring-secondary">
<span class="text-lg font-mono font-bold role-text-secondary">{{ shiftBitsA }}</span>
class="touch-none select-none block w-24 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-primary active:ring-2 role-active-ring-primary">
<span class="text-lg font-mono font-bold role-text-primary">{{ shiftBitsA }}</span>
</button>
<p class="text-[10px] text-slate-400 mt-1">a scaling: 2^{{ shiftBitsA }} ({{
<p class="text-[10px] text-slate-500 mt-1">a scaling: 2^{{ shiftBitsA }} ({{
Math.pow(2, shiftBitsA) }})</p>
</div>
</div>
<div class="space-y-4">
<div>
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">定點分子係數 b
<label class="text-sm text-slate-600 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 ? 'role-border-primary ring-1 role-ring-primary' : 'role-border-primary-soft'"
class="role-bg-primary-soft px-1.5 py-0.5 rounded border flex items-center gap-1 transition-all">
<span class="text-[9px] role-text-primary font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono role-text-primary font-bold"
:class="item.isOverridden ? 'border-amber-400 dark:border-amber-500 ring-1 ring-amber-400 dark:ring-amber-500' : 'border-transparent'"
class="bg-slate-700 dark:bg-gray-300 px-1.5 py-0.5 rounded border flex items-center gap-1 transition-all shadow-sm">
<span class="text-[9px] text-white dark:text-gray-900 font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono text-white dark:text-gray-900 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-2 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-primary"></textarea>
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-primary"></textarea>
</div>
<div>
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">定點分母係數 a
<label class="text-sm text-slate-600 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 ? 'role-border-secondary ring-1 role-ring-secondary' : 'role-border-secondary-soft'"
class="role-bg-secondary-soft px-1.5 py-0.5 rounded border flex items-center gap-1 transition-all">
<span class="text-[9px] role-text-secondary-muted font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono role-text-secondary font-bold"
:class="item.isOverridden ? 'border-amber-400 dark:border-amber-500 ring-1 ring-amber-400 dark:ring-amber-500' : 'border-transparent'"
class="bg-slate-700 dark:bg-gray-300 px-1.5 py-0.5 rounded border flex items-center gap-1 transition-all shadow-sm">
<span class="text-[9px] text-white dark:text-gray-900 font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono text-white dark:text-gray-900 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-2 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-secondary"></textarea>
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-primary"></textarea>
</div>
<details class="mt-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">
<summary class="text-sm text-slate-600 dark:text-gray-400 px-3 py-3 cursor-pointer hover:text-slate-700 dark:hover:text-gray-300">
a1/a2 步階微調</summary>
<div class="px-3 pb-3">
<div class="mb-4">
<div class="grid grid-cols-[3.25rem_1fr_3.25rem] gap-2 mb-3">
<button type="button" @click="adjustFixedAFineStep(-1)" title="調粗"
class="rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-600 dark:text-gray-300 py-2 text-xs font-bold transition-colors hover:bg-slate-100 dark:hover:bg-gray-700"></button>
<div class="min-w-0 rounded-lg border role-border-secondary-soft role-bg-secondary-soft px-3 py-2 text-center">
<span class="block text-[9px] role-text-secondary-muted role-text-secondary font-bold">微調量</span>
<span class="block truncate font-mono text-sm font-bold role-text-secondary" :title="formatFixedAFineDelta()">± {{ formatFixedAFineDelta() }}</span>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-sm font-bold shadow-sm"></button>
<div class="min-w-0 rounded-lg border role-border-primary-soft role-bg-primary-soft px-3 h-[42px] flex flex-col justify-center text-center">
<span class="block text-[9px] role-text-primary-muted role-text-primary font-bold">微調量</span>
<span class="block truncate font-mono text-sm font-bold role-text-primary leading-none mt-0.5" :title="formatFixedAFineDelta()">+/-{{ formatFixedAFineDelta() }}</span>
</div>
<button type="button" @click="adjustFixedAFineStep(1)" title="調細"
class="rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-600 dark:text-gray-300 py-2 text-xs font-bold transition-colors hover:bg-slate-100 dark:hover:bg-gray-700"></button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-sm font-bold shadow-sm"></button>
</div>
<div class="text-[11px] font-semibold text-slate-400 dark:text-gray-500 mb-2">
<div class="text-[11px] font-semibold text-slate-500 dark:text-gray-500 mb-2">
{{ isTouchInput ? '觸控拖曳微調' : '直接輸入 / shift + 滾輪微調' }}
</div>
<div class="flex flex-col gap-3">
<div class="grid grid-cols-2 gap-2">
<article v-if="!isTouchInput" class="block">
<span class="text-[9px] text-slate-400 block font-bold mb-1">δ</span>
<span class="text-[9px] text-slate-500 block font-bold mb-1">δ</span>
<div class="grid grid-cols-[2rem_1fr_2rem] gap-1">
<button type="button" @pointerdown.prevent="startFixedA1A2Repeat('delta', -1)" @pointerup="stopFixedA1A2Repeat" @pointerleave="stopFixedA1A2Repeat" @pointercancel="stopFixedA1A2Repeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
<input type="number" :value="formatFixedFineCoeff(fixedCurrentDelta)" :step="fixedAFineStep"
@change="setFixedAFineDirect('delta', $event.target.value)" @keydown="onlyNumberKey"
@wheel="handleFixedAFineWheel('delta', $event)"
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono text-center role-text-secondary">
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg px-2 h-[42px] text-xs font-mono text-center role-text-primary">
<button type="button" @pointerdown.prevent="startFixedA1A2Repeat('delta', 1)" @pointerup="stopFixedA1A2Repeat" @pointerleave="stopFixedA1A2Repeat" @pointercancel="stopFixedA1A2Repeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
</div>
</article>
<button v-else type="button" @pointerdown.prevent="startFixedAFineDrag('delta', $event)"
class="touch-none select-none w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-secondary active:ring-2 role-active-ring-secondary">
<span class="text-[9px] text-slate-400 block font-bold mb-1">δ</span>
<span class="block max-w-full truncate text-base font-mono font-bold role-text-secondary" :title="formatFixedFineCoeff(fixedCurrentDelta)">{{ formatFixedFineCoeff(fixedCurrentDelta) }}</span>
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-primary active:ring-2 role-active-ring-primary">
<span class="text-[9px] text-slate-500 block font-bold mb-1">δ</span>
<span class="block max-w-full truncate text-base font-mono font-bold role-text-primary" :title="formatFixedFineCoeff(fixedCurrentDelta)">{{ formatFixedFineCoeff(fixedCurrentDelta) }}</span>
</button>
<article v-if="!isTouchInput" class="block">
<span class="text-[9px] text-slate-400 block font-bold mb-1">r</span>
<span class="text-[9px] text-slate-500 block font-bold mb-1">r</span>
<div class="grid grid-cols-[2rem_1fr_2rem] gap-1">
<button type="button" @pointerdown.prevent="startFixedA1A2Repeat('r', -1)" @pointerup="stopFixedA1A2Repeat" @pointerleave="stopFixedA1A2Repeat" @pointercancel="stopFixedA1A2Repeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
<input type="number" :value="formatFixedFineCoeff(fixedCurrentR)" :step="fixedAFineStep"
@change="setFixedAFineDirect('r', $event.target.value)" @keydown="onlyNumberKey"
@wheel="handleFixedAFineWheel('r', $event)"
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono text-center text-slate-700 dark:text-gray-300">
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono text-center role-text-primary">
<button type="button" @pointerdown.prevent="startFixedA1A2Repeat('r', 1)" @pointerup="stopFixedA1A2Repeat" @pointerleave="stopFixedA1A2Repeat" @pointercancel="stopFixedA1A2Repeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
</div>
</article>
<button v-else type="button" @pointerdown.prevent="startFixedAFineDrag('r', $event)"
class="touch-none select-none w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center active:border-slate-400 active:ring-2 active:ring-slate-400/20">
<span class="text-[9px] text-slate-400 block font-bold mb-1">r</span>
<span class="block max-w-full truncate text-base font-mono font-bold text-slate-700 dark:text-gray-300" :title="formatFixedFineCoeff(fixedCurrentR)">{{ formatFixedFineCoeff(fixedCurrentR) }}</span>
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center active:border-slate-400 active:ring-2 active:ring-slate-400/20">
<span class="text-[9px] text-slate-500 block font-bold mb-1">r</span>
<span class="block max-w-full truncate text-base font-mono font-bold role-text-primary" :title="formatFixedFineCoeff(fixedCurrentR)">{{ formatFixedFineCoeff(fixedCurrentR) }}</span>
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-2 mb-4">
<div class="min-w-0 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 font-bold mb-1">r = -a0 - a1</span>
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-secondary" :title="formatFixedFineCoeff(fixedCurrentR)">{{ formatFixedFineCoeff(fixedCurrentR) }}</span>
<span class="text-[9px] text-slate-500 block font-bold mb-1">a1 = -a0 - r</span>
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-primary-muted" :title="formatFixedFineCoeff(fixedCurrentA1)">{{ formatFixedFineCoeff(fixedCurrentA1) }}</span>
</div>
<div class="min-w-0 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 font-bold mb-1">δ = a1 + a2 + a0</span>
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-secondary" :title="formatFixedFineCoeff(fixedCurrentDelta)">{{ formatFixedFineCoeff(fixedCurrentDelta) }}</span>
<span class="text-[9px] text-slate-500 block font-bold mb-1">a2 = r + δ</span>
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-primary-muted" :title="formatFixedFineCoeff(fixedCurrentA2)">{{ formatFixedFineCoeff(fixedCurrentA2) }}</span>
</div>
<div class="min-w-0 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-500 block font-bold mb-1">差值 a1-a2</span>
<span class="block max-w-full truncate text-xs font-mono text-slate-700 dark:text-gray-300" :title="formatFixedFineCoeff(fixedCurrentA1A2Diff)">{{ formatFixedFineCoeff(fixedCurrentA1A2Diff) }}</span>
</div>
<div class="min-w-0 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-500 block font-bold mb-1">總和 a1+a2</span>
<span class="block max-w-full truncate text-xs font-mono text-slate-700 dark:text-gray-300" :title="formatFixedFineCoeff(fixedCurrentA1A2Sum)">{{ formatFixedFineCoeff(fixedCurrentA1A2Sum) }}</span>
</div>
</div>
<div class="role-bg-warning-soft border role-border-warning-soft rounded-lg p-3">
<p class="text-[10px] role-text-warning leading-relaxed">
<span class="font-bold">運算說明</span><br>
a0 = 2^{{ shiftBitsA }}a1 = -a0 - r, a2 = r + δ
a0 = 2^{{ shiftBitsA }}a1 = -a0 - ra2 = r + δ
</p>
</div>
</div>
</details>
</div>
<p class="text-[10px] text-slate-400 dark:text-gray-500 mt-3 italic">b 係數乘以 2^{{ shiftBitsB }}a
<p class="text-[10px] text-slate-500 dark:text-gray-500 mt-3 italic">b 係數乘以 2^{{ shiftBitsB }}a
係數乘以 2^{{ shiftBitsA }} 後四捨五入可用於定點數 DSP 實作</p>
</section>
@@ -704,10 +718,10 @@
時域訊號探討</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 class="text-base text-slate-600 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">
class="flex items-center flex-wrap gap-4 mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg border border-slate-100 dark:border-gray-700 shadow-sm">
<label
class="cursor-pointer role-bg-primary role-hover-primary role-text-on-fill px-5 py-3 rounded-lg text-base font-semibold transition-all shadow-md role-shadow-primary flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -721,7 +735,7 @@
<span v-if="csvFile" class="text-base role-text-primary font-mono">{{
csvFile.name }}</span>
<span v-if="csvInfo"
class="text-sm text-slate-500 dark:text-gray-400 font-mono">
class="text-sm text-slate-600 dark:text-gray-400 font-mono">
{{ csvInfo.rows }} / {{ csvInfo.columns }}
</span>
<span v-if="csvParseError"
@@ -733,7 +747,7 @@
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>
<span class="text-sm text-slate-500">訊號欄位:</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 role-focus-primary outline-none text-slate-900 dark:text-gray-100">
<option v-for="(col, idx) in csvColumns" :key="idx" :value="idx">{{ col }}</option>
@@ -756,30 +770,84 @@
</button>
</div>
<!-- 資料預覽 -->
<div v-if="csvPreview.length > 0" class="mb-6">
<!-- V2: 定點數時域設定 -->
<div
class="mb-6 p-4 bg-slate-50 dark:bg-gray-900/50 rounded-lg border border-slate-100 dark:border-gray-800">
<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>
class="text-sm font-semibold text-slate-600 dark:text-gray-400 mb-4 uppercase tracking-tight 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="M9 7h6M9 12h6M9 17h3M5 5a2 2 0 012-2h7l5 5v11a2 2 0 01-2 2H7a2 2 0 01-2-2V5z">
</path>
</svg>
定點數模擬設定 (Fixed-Point Simulation Settings)
</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 lg:gap-6">
<div>
<label
class="text-sm text-slate-600 dark:text-gray-400 mb-2 block font-bold">輸入訊號量化
(Q_in): 2^{{ shiftBitsIn }}</label>
<div v-if="!isTouchInput"
class="grid grid-cols-[2.5rem_1fr_2.5rem] gap-2 items-center">
<button type="button" @pointerdown.prevent="startShiftInRepeat(-1)"
@pointerup="stopShiftInRepeat" @pointerleave="stopShiftInRepeat"
@pointercancel="stopShiftInRepeat"
class="stepper-button h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
<input type="number" :value="shiftBitsIn"
@change="setShiftBitsIn($event.target.value)"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg px-2 h-[42px] text-center font-mono text-sm role-text-primary outline-none">
<button type="button" @pointerdown.prevent="startShiftInRepeat(1)"
@pointerup="stopShiftInRepeat" @pointerleave="stopShiftInRepeat"
@pointercancel="stopShiftInRepeat"
class="stepper-button h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
</div>
<button v-else type="button" @pointerdown.prevent="startShiftInDrag($event)"
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-primary active:ring-2 role-active-ring-primary">
<span class="text-base font-mono font-bold role-text-primary">{{ shiftBitsIn }}
bits</span>
</button>
</div>
<div>
<label
class="text-sm text-slate-600 dark:text-gray-400 mb-2 block font-bold">輸出訊號量化
(Q_out): 2^{{ shiftBitsOut }}</label>
<div v-if="!isTouchInput"
class="grid grid-cols-[2.5rem_1fr_2.5rem] gap-2 items-center">
<button type="button" @pointerdown.prevent="startShiftOutRepeat(-1)"
@pointerup="stopShiftOutRepeat" @pointerleave="stopShiftOutRepeat"
@pointercancel="stopShiftOutRepeat"
class="stepper-button h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
<input type="number" :value="shiftBitsOut"
@change="setShiftBitsOut($event.target.value)"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg px-2 h-[42px] text-center font-mono text-sm role-text-primary outline-none">
<button type="button" @pointerdown.prevent="startShiftOutRepeat(1)"
@pointerup="stopShiftOutRepeat" @pointerleave="stopShiftOutRepeat"
@pointercancel="stopShiftOutRepeat"
class="stepper-button h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
</div>
<button v-else type="button" @pointerdown.prevent="startShiftOutDrag($event)"
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-primary active:ring-2 role-active-ring-primary">
<span class="text-base font-mono font-bold role-text-primary">{{ shiftBitsOut }}
bits</span>
</button>
</div>
<div>
<label
class="text-sm text-slate-600 dark:text-gray-400 mb-2 block font-bold">位移捨去模式
(Truncation)</label>
<div class="selector-button-group flex gap-2 h-[42px]">
<button type="button" @click="setUseRound(false)"
:class="!useRound ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-700 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
class="selector-button flex-1 rounded py-2 text-sm font-medium border transition-colors">
Floor (SRA)
</button>
<button type="button" @click="setUseRound(true)"
:class="useRound ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-700 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
class="selector-button flex-1 rounded py-2 text-sm font-medium border transition-colors">
Round (+0.5)
</button>
</div>
</div>
</div>
</div>
@@ -793,7 +861,7 @@
</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">
class="h-[300px] flex flex-col items-center justify-center text-slate-500 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"
@@ -809,7 +877,7 @@
<!-- 全域置底版權宣告 -->
<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>
<p class="text-xs text-slate-500 dark:text-gray-600">© 2026 喆富創新科技股份有限公司. All rights reserved.</p>
</footer>
</div>
</template>
+263 -26
View File
@@ -43,11 +43,14 @@ export default {
sense_b: '2x', sense_a: '2x',
loadingBode: false, bodeTimeout: null, globalError: null,
csvFile: null, csvColumns: [], csvPreview: [], csvInfo: null, csvParseError: null,
csvFile: null, csvFileId: null, csvColumns: [], csvPreview: [], csvInfo: null, csvParseError: null,
isCsvPreviewExpanded: true,
selectedColumn: 0, loadingFilter: false, filterDone: false, timePlotData: null,
mobileTab: 'settings', // 手機版頁籤:'settings' | 'chart'
shiftBitsB: DEFAULT_SHIFT_BITS,
shiftBitsA: DEFAULT_SHIFT_BITS,
useRound: true, // 是否使用 Rounding 取代 Floor
sense_in: '2x', sense_out: '2x',
fixedOverrides: { a: {}, b: {} },
outOfRangeB: [false, false, false, false, false, false, false],
outOfRangeA: [false, false, false, false, false, false, false],
@@ -62,6 +65,14 @@ export default {
fixedAFineDrag: null,
fixedAFineRepeatDelayTimer: null,
fixedAFineRepeatTimer: null,
shiftBitsIn: 14,
shiftBitsOut: 14,
shiftInDrag: null,
shiftInRepeatDelayTimer: null,
shiftInRepeatTimer: null,
shiftOutDrag: null,
shiftOutRepeatDelayTimer: null,
shiftOutRepeatTimer: null,
isTouchInput: false,
activeCoeffAdjustment: null,
mcuSerialPort: null,
@@ -69,6 +80,7 @@ export default {
webSerialSupported: false,
writingMCU: false,
mcuStatus: '',
saveSettingsTimeout: null,
bodeMagRange: null, // [min, max] for Y-axis
}
},
@@ -210,6 +222,51 @@ export default {
return this.fixedCurrentA1 + this.fixedCurrentA2;
}
},
watch: {
fs: 'debouncedSaveSettings',
b_str() {
this.debouncedSaveSettings();
this.debouncedProcessFilter();
},
a_str() {
this.debouncedSaveSettings();
this.debouncedProcessFilter();
},
filterType: 'debouncedSaveSettings',
systemGain: 'debouncedSaveSettings',
params: { handler: 'debouncedSaveSettings', deep: true },
baseB: { handler: 'debouncedSaveSettings', deep: true },
baseA: { handler: 'debouncedSaveSettings', deep: true },
bSliders: { handler: 'debouncedSaveSettings', deep: true },
aSliders: { handler: 'debouncedSaveSettings', deep: true },
sense_b: 'debouncedSaveSettings',
sense_a: 'debouncedSaveSettings',
shiftBitsB() {
this.debouncedSaveSettings();
this.debouncedProcessFilter();
},
shiftBitsA() {
this.debouncedSaveSettings();
this.debouncedProcessFilter();
},
shiftBitsIn() {
this.debouncedSaveSettings();
this.debouncedProcessFilter();
},
shiftBitsOut() {
this.debouncedSaveSettings();
this.debouncedProcessFilter();
},
useRound() {
this.debouncedSaveSettings();
this.debouncedProcessFilter();
},
fixedOverrides: { handler: 'debouncedSaveSettings', deep: true },
aFineStep: 'debouncedSaveSettings',
fixedAFineStep: 'debouncedSaveSettings',
b_int_str() { this.debouncedProcessFilter(); },
a_int_str() { this.debouncedProcessFilter(); }
},
mounted() {
this.isTouchInput = window.matchMedia?.('(pointer: coarse)').matches || false;
window.addEventListener('pointerdown', this.rememberPointerType);
@@ -221,6 +278,9 @@ export default {
} else {
document.documentElement.classList.remove('dark');
}
this.loadSettings();
this.webSerialSupported = ('serial' in navigator);
if (!this.webSerialSupported) {
this.mcuStatus = '此瀏覽器不支援 Web Serial(需使用 Chrome 或 Edge,且透過 HTTPS 連線)';
@@ -236,8 +296,48 @@ export default {
this.stopShiftBitDrag();
this.stopFixedA1A2Repeat();
this.stopFixedAFineDrag();
this.stopShiftInRepeat();
this.stopShiftInDrag();
this.stopShiftOutRepeat();
this.stopShiftOutDrag();
},
methods: {
loadSettings() {
try {
const saved = localStorage.getItem('dea_settings');
if (saved) {
const parsed = JSON.parse(saved);
const keys = [
'fs', 'b_str', 'a_str', 'filterType', 'systemGain', 'params',
'baseB', 'baseA', 'bSliders', 'aSliders', 'sense_b', 'sense_a',
'shiftBitsB', 'shiftBitsA', 'shiftBitsIn', 'shiftBitsOut', 'useRound',
'fixedOverrides', 'aFineStep', 'fixedAFineStep'
];
keys.forEach(k => {
if (parsed[k] !== undefined) {
this[k] = parsed[k];
}
});
}
} catch (e) {
console.error('Failed to load settings:', e);
}
},
saveSettings() {
const keys = [
'fs', 'b_str', 'a_str', 'filterType', 'systemGain', 'params',
'baseB', 'baseA', 'bSliders', 'aSliders', 'sense_b', 'sense_a',
'shiftBitsB', 'shiftBitsA', 'shiftBitsIn', 'shiftBitsOut', 'useRound',
'fixedOverrides', 'aFineStep', 'fixedAFineStep'
];
const settings = {};
keys.forEach(k => settings[k] = this[k]);
localStorage.setItem('dea_settings', JSON.stringify(settings));
},
debouncedSaveSettings() {
clearTimeout(this.saveSettingsTimeout);
this.saveSettingsTimeout = setTimeout(() => this.saveSettings(), 500);
},
rememberPointerType(event) {
if (event.pointerType === 'touch') {
this.isTouchInput = true;
@@ -502,6 +602,9 @@ export default {
if (!Number.isFinite(nextValue)) return 0;
return Math.max(0, nextValue);
},
setUseRound(val) {
this.useRound = val;
},
setShiftBits(type, rawValue) {
const nextValue = this.normalizeShiftBits(rawValue);
if (type === 'b') {
@@ -566,6 +669,101 @@ export default {
window.removeEventListener('pointerup', this.stopShiftBitDrag);
window.removeEventListener('pointercancel', this.stopShiftBitDrag);
},
// 新增:處理輸入輸出位移
setShiftBitsIn(rawValue) {
this.shiftBitsIn = this.normalizeShiftBits(rawValue);
this.debouncedUpdateBode();
},
adjustShiftBitsIn(steps) {
this.setShiftBitsIn(this.shiftBitsIn + steps);
},
startShiftInRepeat(direction) {
this.stopShiftInRepeat();
this.adjustShiftBitsIn(direction);
this.shiftInRepeatDelayTimer = setTimeout(() => {
this.shiftInRepeatTimer = setInterval(() => {
this.adjustShiftBitsIn(direction);
}, 80);
}, 350);
},
stopShiftInRepeat() {
clearTimeout(this.shiftInRepeatDelayTimer);
clearInterval(this.shiftInRepeatTimer);
this.shiftInRepeatDelayTimer = null;
this.shiftInRepeatTimer = null;
},
startShiftInDrag(event) {
if (event.pointerType !== 'touch') return;
event.preventDefault();
this.shiftInDrag = { lastY: event.clientY, remainder: 0 };
window.addEventListener('pointermove', this.moveShiftInDrag, { passive: false });
window.addEventListener('pointerup', this.stopShiftInDrag);
window.addEventListener('pointercancel', this.stopShiftInDrag);
},
moveShiftInDrag(event) {
if (!this.shiftInDrag) return;
event.preventDefault();
const delta = this.shiftInDrag.lastY - event.clientY;
this.shiftInDrag.lastY = event.clientY;
this.shiftInDrag.remainder += delta;
const steps = Math.trunc(this.shiftInDrag.remainder / 18);
if (steps === 0) return;
this.shiftInDrag.remainder -= steps * 18;
this.adjustShiftBitsIn(steps);
},
stopShiftInDrag() {
this.shiftInDrag = null;
window.removeEventListener('pointermove', this.moveShiftInDrag);
window.removeEventListener('pointerup', this.stopShiftInDrag);
window.removeEventListener('pointercancel', this.stopShiftInDrag);
},
setShiftBitsOut(rawValue) {
this.shiftBitsOut = this.normalizeShiftBits(rawValue);
this.debouncedUpdateBode();
},
adjustShiftBitsOut(steps) {
this.setShiftBitsOut(this.shiftBitsOut + steps);
},
startShiftOutRepeat(direction) {
this.stopShiftOutRepeat();
this.adjustShiftBitsOut(direction);
this.shiftOutRepeatDelayTimer = setTimeout(() => {
this.shiftOutRepeatTimer = setInterval(() => {
this.adjustShiftBitsOut(direction);
}, 80);
}, 350);
},
stopShiftOutRepeat() {
clearTimeout(this.shiftOutRepeatDelayTimer);
clearInterval(this.shiftOutRepeatTimer);
this.shiftOutRepeatDelayTimer = null;
this.shiftOutRepeatTimer = null;
},
startShiftOutDrag(event) {
if (event.pointerType !== 'touch') return;
event.preventDefault();
this.shiftOutDrag = { lastY: event.clientY, remainder: 0 };
window.addEventListener('pointermove', this.moveShiftOutDrag, { passive: false });
window.addEventListener('pointerup', this.stopShiftOutDrag);
window.addEventListener('pointercancel', this.stopShiftOutDrag);
},
moveShiftOutDrag(event) {
if (!this.shiftOutDrag) return;
event.preventDefault();
const delta = this.shiftOutDrag.lastY - event.clientY;
this.shiftOutDrag.lastY = event.clientY;
this.shiftOutDrag.remainder += delta;
const steps = Math.trunc(this.shiftOutDrag.remainder / 18);
if (steps === 0) return;
this.shiftOutDrag.remainder -= steps * 18;
this.adjustShiftBitsOut(steps);
},
stopShiftOutDrag() {
this.shiftOutDrag = null;
window.removeEventListener('pointermove', this.moveShiftOutDrag);
window.removeEventListener('pointerup', this.stopShiftOutDrag);
window.removeEventListener('pointercancel', this.stopShiftOutDrag);
},
setA1A2(a1, a2) {
const a = this.padTo7(this.parseCoeffs(this.a_str));
a[0] = 1.0;
@@ -621,7 +819,7 @@ export default {
this.outOfRangeB[i] = false;
if (base !== 0 && val !== 0 && (val / (base * gain)) > 0) {
let logVal = Math.log10(val / (base * gain));
if (Math.abs(logVal) < 1e-12) logVal = 0; // 消除浮點數微小誤差
if (Math.abs(logVal) < 1e-8) logVal = 0; // 消除浮點數微小誤差
if (Math.abs(logVal) > this.maxLogB) this.outOfRangeB[i] = true;
this.bSliders[i] = Math.max(-this.maxLogB, Math.min(this.maxLogB, logVal));
} else {
@@ -636,7 +834,7 @@ export default {
this.outOfRangeA[i] = false;
if (base !== 0 && val !== 0 && (val / base) > 0) {
let logVal = Math.log10(val / base);
if (Math.abs(logVal) < 1e-12) logVal = 0; // 消除浮點數微小誤差
if (Math.abs(logVal) < 1e-8) logVal = 0; // 消除浮點數微小誤差
if (Math.abs(logVal) > this.maxLogA) this.outOfRangeA[i] = true;
this.aSliders[i] = Math.max(-this.maxLogA, Math.min(this.maxLogA, logVal));
} else {
@@ -954,30 +1152,30 @@ export default {
xaxis: { ...xAxisCommon, anchor: 'y' },
yaxis: {
title: { text: 'Mag (dB)', font: { size: isSmallScreen ? 12 : 14 } },
range: this.bodeMagRange || [-70, 0],
range: this.bodeMagRange || [-70, 0],
gridcolor: gridColor,
zerolinecolor: zeroLineColor,
tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 },
fixedrange: false // 允許使用者手動縮放或點擊 Auto Scale
},
xaxis2: {
type: 'log',
xaxis2: {
type: 'log',
title: { text: 'Freq (Hz)', font: { size: isSmallScreen ? 12 : 14 } },
tickvals: xTicks,
tickvals: xTicks,
ticktext: xTexts,
showgrid: true,
showgrid: true,
gridcolor: gridColor,
tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 },
nticks: isSmallScreen ? 6 : 10,
matches: 'x',
anchor: 'y2'
},
yaxis2: {
title: { text: 'Phase (°)', font: { size: isSmallScreen ? 12 : 14 } },
range: [-180, 180],
tickvals: [-180, -90, 0, 90, 180],
gridcolor: gridColor,
zerolinecolor: zeroLineColor,
matches: 'x',
anchor: 'y2'
},
yaxis2: {
title: { text: 'Phase (°)', font: { size: isSmallScreen ? 12 : 14 } },
range: [-180, 180],
tickvals: [-180, -90, 0, 90, 180],
gridcolor: gridColor,
zerolinecolor: zeroLineColor,
tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 },
fixedrange: true // 相位縱軸鎖死,不可縮放
},
@@ -1039,10 +1237,12 @@ export default {
resetCsvState() {
this.csvFile = null;
this.csvFileId = null;
this.csvColumns = [];
this.csvPreview = [];
this.csvInfo = null;
this.csvParseError = null;
this.isCsvPreviewExpanded = true;
this.selectedColumn = 0;
this.filterDone = false;
this.timePlotData = null;
@@ -1053,7 +1253,7 @@ export default {
this.resetCsvState();
if (!file) return;
const maxBytes = 32 * 1024 * 1024;
const maxBytes = 300 * 1024 * 1024;
const fileName = file.name || '';
if (!fileName.toLowerCase().endsWith('.csv')) {
this.csvParseError = '請選擇 .csv 檔案';
@@ -1061,13 +1261,13 @@ export default {
return;
}
if (file.size > maxBytes) {
this.csvParseError = 'CSV 檔案不可超過 32MB';
this.csvParseError = 'CSV 檔案不可超過 300MB';
event.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = (e) => {
reader.onload = async (e) => {
try {
const text = String(e.target.result || '').replace(/^\uFEFF/, '');
const { rows, totalRows } = this.parseCsvText(text, 6);
@@ -1098,11 +1298,21 @@ export default {
this.selectedColumn = 1;
}
}
// 背景上傳至暫存區
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/csv/upload', { method: 'POST', body: formData });
if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '背景上傳失敗'); }
const data = await res.json();
this.csvFileId = data.file_id;
} catch (err) {
this.csvParseError = err.message || 'CSV 解析失敗';
this.csvColumns = [];
this.csvPreview = [];
this.csvInfo = null;
this.csvFileId = null;
event.target.value = '';
}
};
@@ -1113,21 +1323,40 @@ export default {
reader.readAsText(file);
},
debouncedProcessFilter() {
if (!this.filterDone || !this.csvFileId) return;
if (this._filterTimeout) clearTimeout(this._filterTimeout);
this._filterTimeout = setTimeout(() => {
this.processFilter();
}, 500);
},
async processFilter() {
if (!this.csvFile) return;
if (!this.csvFileId) {
if (this.csvFile) this.globalError = "檔案還在上傳中,請稍候...";
return;
}
this.loadingFilter = true;
this.globalError = null;
const formData = new FormData();
formData.append('file', this.csvFile);
formData.append('file_id', this.csvFileId);
formData.append('b', this.b_str);
formData.append('a', this.a_str);
formData.append('col_idx', this.selectedColumn);
formData.append('b_int', this.b_int_str);
formData.append('a_int', this.a_int_str);
formData.append('shift_in', this.shiftBitsIn);
formData.append('shift_out', this.shiftBitsOut);
formData.append('shift_b', this.shiftBitsB);
formData.append('shift_a', this.shiftBitsA);
formData.append('use_round', this.useRound);
try {
const res = await fetch('/api/filter', { method: 'POST', body: formData });
if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '濾波處理失敗'); }
const data = await res.json();
this.timePlotData = data;
this.filterDone = true;
this.isCsvPreviewExpanded = false; // 運算成功後自動收合預覽
// 確保在可見狀態下繪圖,避免 Plotly 計算尺寸錯誤
this.$nextTick(() => {
this.drawTimePlot(data);
@@ -1153,18 +1382,26 @@ export default {
legend: { orientation: 'h', y: 1.02, yanchor: 'bottom', x: 1, xanchor: 'right', font: { color: textColor, size: isSmallScreen ? 9 : 10 } }
};
Plotly.react('timePlot', [
{ x: data.index, y: data.original, name: '原始輸入訊號 (Input)', type: 'scatter', line: { color: isDark ? '#c8cee0' : '#566075', width: 2.4 }, opacity: 0.9 },
{ x: data.index, y: data.filtered, name: '濾波後輸出 (Output)', type: 'scatter', line: { color: isDark ? '#64d2ff' : '#2d6f8f', width: 2.4 }, opacity: 0.95 }
{ x: data.index, y: data.original, name: '原始輸入 (Input)', type: 'scatter', line: { color: isDark ? '#c8cee0' : '#566075', width: 2.4 }, opacity: 0.5 },
{ x: data.index, y: data.filtered, name: '理想路徑 (Ideal Float)', type: 'scatter', line: { color: isDark ? '#64d2ff' : '#2d6f8f', width: 2.8 }, opacity: 0.95 },
{ x: data.index, y: data.filtered_fixed, name: '定點路徑 (Mimic Integer)', type: 'scatter', line: { color: isDark ? '#ff7c7c' : '#c0392b', width: 2.8, dash: 'dot' }, opacity: 0.95 }
], layout, { responsive: true });
},
async downloadCsv() {
if (!this.csvFile) return;
if (!this.csvFileId) return;
const formData = new FormData();
formData.append('file', this.csvFile);
formData.append('file_id', this.csvFileId);
formData.append('b', this.b_str);
formData.append('a', this.a_str);
formData.append('col_idx', this.selectedColumn);
formData.append('b_int', this.b_int_str);
formData.append('a_int', this.a_int_str);
formData.append('shift_in', this.shiftBitsIn);
formData.append('shift_out', this.shiftBitsOut);
formData.append('shift_b', this.shiftBitsB);
formData.append('shift_a', this.shiftBitsA);
formData.append('use_round', this.useRound);
try {
const res = await fetch('/api/filter/download', { method: 'POST', body: formData });
if (!res.ok) throw new Error('下載失敗');
+1489 -1668
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long
-9
View File
@@ -4,15 +4,6 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({
base: './',
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'https://127.0.0.1:8000',
changeOrigin: true,
secure: false, // 允許自簽憑證
},
},
},
build: {
outDir: 'static/build',
emptyOutDir: true,