Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 345b4a789a | |||
| 8009a0dd78 | |||
| d9841fe7bc | |||
| c259f91380 | |||
| c4b4a4ced0 | |||
| e9dba45626 | |||
| 3c3bda7daa | |||
| 2762d0da86 | |||
| da2cbfe8da |
@@ -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.
|
||||
@@ -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*
|
||||
@@ -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 與 Pandas;Frontend 使用 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
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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)}")
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "bodeplot",
|
||||
"name": "diff-eq-analyzer",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
+255
-187
@@ -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 Serial。MCU 寫值功能需使用 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 + 1,r = -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 - r,a2 = 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
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user