Merge remote-tracking branch 'origin/dev-v2-integretimedomain' into integrate/dev-v2-into-main

This commit is contained in:
ws50529
2026-05-20 13:44:31 +08:00
11 changed files with 976 additions and 427 deletions
Executable → Regular
+236 -122
View File
@@ -1,109 +1,256 @@
# Difference Equation Analyzer
Difference Equation Analyzer 是一個用來設計、檢視與驗證離散時間濾波器的工具。後端使用 FastAPI、NumPy、SciPy 與 Pandas;前端使用 Vue 3、Vite、Tailwind CSS 與 Plotly
Difference Equation Analyzer 是用來設計、檢視與驗證離散時間濾波器的工具。它同時支援浮點係數、fixed-point 係數、時域 CSV 驗證,以及透過 Web Serial 將係數寫入 MCU
主要功能:
Backend 使用 FastAPI、NumPy、SciPy 與 PandasFrontend 使用 Vue 3、Vite、Tailwind CSS 與 Plotly。
## Features
- 設計 Lowpass、Highpass、Bandpass、Notch、1P1Z、2P1Z、2P2Z、PID、SOGI 濾波器。
- 比較浮點係數fixed-point 係數的頻率響應。
- 手動微調 b/a 係數、a1/a2 組合(支援 δ/r 參數微調)、Q 格式位元數與 fixed-point 整數係數。
- 透過瀏覽器 Web Serial API 直接將 fixed-point 係數命令送到 MCU(需透過 HTTPS 連線)
- 上傳 CSV 時域資料,套用目前差分方程並繪製輸入/輸出波形
- 匯出包含濾波輸出欄位的 CSV
- 支援深色模式 (Dark Mode) 與響應式 UI。
- 比較 Ideal Float Fixed-Point 的 Bode 頻率響應。
- 手動微調 b/a 係數、a1/a2、δ/r、Q-format 位元數與 fixed-point 整數係數。
- 上傳 CSV 時域資料,繪製輸入、Ideal Float 輸出與 Mimic Integer 輸出
- 匯出包含理想浮點與定點整數模擬輸出的 CSV
- 使用瀏覽器 Web Serial API 將 fixed-point 係數寫入 MCU
- 支援 Dark Mode 與響應式 UI。
## 專案結構
## Quick Start
```bash
cd /home/wisetop/diff-eq-analyzer
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
npm install
npm run build
bash certs/generate_cert.sh
.venv/bin/uvicorn dea_api:app --host 0.0.0.0 --port 8000 --ssl-keyfile certs/key.pem --ssl-certfile certs/cert.pem
```
開啟:
```text
https://localhost:8000/ui/
```
若從其他主機連線,改用:
```text
https://<server-ip>:8000/ui/
```
自簽憑證會讓瀏覽器顯示安全性警告,需手動信任或選擇繼續前往。
## Project Layout
```text
dea_api.py FastAPI 入口與 HTTP route
dea/ 後端核心模組
bode.py 頻率響應計算
config.py 限制值與伺服器端 serial 設定
csv_processing.py CSV 取、驗證、濾波與匯出
csv_processing.py CSV 取、驗證、濾波與匯出
filter_design.py 濾波器設計
mcu.py MCU serial port 列表與命令寫入 (後端備用支援)
mcu.py MCU serial port 列表與命令寫入
schemas.py Pydantic request schema
security.py LAN 存取限制與安全標頭
validation.py 係數、頻率與有限值驗證
src/ Vue 3 前端原始碼
certs/ HTTPS 自簽憑證與產生腳本
static/ FastAPI 對外提供的靜態 UI
static/build/ Vite build 輸出
static/build/ Vite production build 輸出
tests/ unittest 測試
dea.service systemd service 範例
chirp_signal.csv CSV 上傳測試範例
```
## 安裝
## Architecture
在目標主機上建立 Python venv 並安裝依賴:
| Layer | Responsibility |
| --- | --- |
| FastAPI | 提供 API、serve `/ui/`、套用 LAN 存取限制與安全標頭 |
| `dea/` | 濾波器設計、Bode plot、CSV 處理、fixed-point simulation、驗證 |
| Vue UI | 管理係數、Q-format、CSV workflow、Plotly 圖表與 Web Serial 狀態 |
| Vite build | 將 `src/` 打包到 `static/build/`,由 FastAPI 在 production 提供 |
```bash
cd /home/wisetop/diff-eq-analyzer
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
CSV workflow
1. 前端選擇 CSV。
2. 前端呼叫 `POST /api/csv/upload`,後端將檔案暫存並回傳 `file_id`
3. 後續 `POST /api/filter``POST /api/filter/download` 使用 `file_id`,避免反覆傳大檔。
CSV 預設暫存在:
```text
/tmp/diff-eq-analyzer-csv/
```
安裝前端依賴並建置
可用環境變數覆寫
```bash
DEA_TEMP_CSV_DIR=/path/to/csv-cache
```
## Usage
### Filter Design
在左側控制面板選擇濾波器類型並調整參數。系統會更新 b/a 係數,並繪製 Ideal Float 與 Fixed-Point 的頻率響應。
支援濾波器:
```text
Lowpass, Highpass, Bandpass, Notch,
1P1Z, 2P1Z, 2P2Z,
PID, SOGI-Alpha, SOGI-Beta
```
### a1/a2 Fine Tuning
針對二階濾波器,UI 支援 δ/r 參數化微調。
| Mode | Formula |
| --- | --- |
| Float | `a1 = -1 - r`, `a2 = r + δ` |
| Fixed | `a1 = -a0 - r`, `a2 = r + δ` |
反推關係:
| Mode | δ | r |
| --- | --- | --- |
| Float | `a1 + a2 + 1` | `-1 - a1` |
| Fixed | `a1 + a2 + a0` | `-a0 - a1` |
桌機可用 Shift + 滑鼠滾輪微調;觸控裝置可按住數值面板上下拖曳。
### Fixed-Point Time Simulation
時域分析同時計算兩條路徑:
| Path | Description |
| --- | --- |
| Ideal Float | 使用 SciPy `signal.lfilter()` 作為理想浮點參考 |
| Mimic Integer | 將輸入與係數量化為整數,模擬 MCU / DSP fixed-point 差分方程 |
Q-format 參數:
| Parameter | Meaning |
| --- | --- |
| `shift_in` | 輸入訊號量化位元數 |
| `shift_out` | 輸出訊號量化位元數 |
| `shift_b` | 前饋 b 係數量化位元數 |
| `shift_a` | 回授 a 係數量化位元數 |
| `use_round` | 右移時使用 Floor (SRA) 或 Round (+0.5) |
整數模擬採高精度狀態變數架構:前饋項保留在 `Q_in+b` 精度,回授項除以 `A0` 對齊,再於最終輸出依 `Q_out` 縮放。
### CSV Workflow
限制與行為:
| Item | Value |
| --- | --- |
| 檔案大小上限 | 300 MB |
| 時域運算列數上限 | 1,200,000 rows |
| 圖表點數上限 | 5000 points |
| 格式要求 | 必須有標題列與至少一筆資料 |
若第一欄像 `time``t``sec``x``index`,前端會預設選第二欄作為訊號欄位。
範例檔案:
```text
chirp_signal.csv
```
欄位:
```text
time,value
```
### MCU Web Serial
Web Serial 是主要 MCU 寫入方式,伺服器不需要 serial 權限。
| Action | Description |
| --- | --- |
| 連線 | 點擊「選擇連接埠並連線」,在瀏覽器彈窗選擇 MCU |
| 寫值 | 點擊「寫值到 MCU」 |
| 限制 | Chrome / Edge,且必須透過 HTTPS 或 localhost |
寫入命令格式:
```text
bodeplot=b0,b1,b2,a1,a2
```
## API
| Method | Endpoint | Purpose |
| --- | --- | --- |
| `POST` | `/api/design` | 依濾波器參數產生 b/a 係數 |
| `POST` | `/api/bode` | 計算單組 b/a 頻率響應 |
| `POST` | `/api/bode/compare` | 比較 Ideal Float 與 Fixed-Point 頻率響應 |
| `POST` | `/api/csv/upload` | 上傳 CSV 並回傳 `file_id` |
| `POST` | `/api/filter` | 使用 `file_id` 回傳時域圖表資料 |
| `POST` | `/api/filter/download` | 使用 `file_id` 匯出結果 CSV |
| `GET` | `/api/mcu/ports` | 列出伺服器端 serial ports |
| `POST` | `/api/mcu/write` | 後端模式寫入 MCU 命令 |
## Development
### Frontend
```bash
npm install
npm run build
```
## HTTPS 設定
為了支援 Web Serial API(瀏覽器直接存取本機連接埠),後端必須透過 HTTPS 連線提供服務。
1. **產生自簽憑證**
```bash
bash certs/generate_cert.sh
```
這會在 `certs/` 目錄下產生 `key.pem` 與 `cert.pem`。
2. **信任憑證**
由於是自簽憑證,瀏覽器會顯示安全性警告。請在「進階」中選擇「繼續前往」或將其加入信任清單。
## 執行
### 手動啟動 (HTTPS)
開發模式:
```bash
npm run dev
```
目前 `vite.config.js` 保留 `main` 設定,不在 Vite 內設定 `/api` proxy。
### Backend
```bash
.venv/bin/pip install -r requirements.txt
.venv/bin/uvicorn dea_api:app --host 0.0.0.0 --port 8000 --ssl-keyfile certs/key.pem --ssl-certfile certs/cert.pem
```
瀏覽器開啟:`https://<server-ip>:8000/ui/` 或本機 `https://localhost:8000/ui/`
### HTTPS Certificate
根路徑 `/` 會自動導向 `/ui/`。
```bash
bash certs/generate_cert.sh
```
### 使用 systemd
會在 `certs/` 產生:
1. 檢查 `dea.service` 中的路徑是否正確(需確認 `ExecStart` 已加上 `--ssl-keyfile` 等參數)。
2. 啟用並啟動服務:
```bash
sudo cp dea.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable dea.service
sudo systemctl start dea.service
```
```text
cert.pem
key.pem
```
### systemd
```bash
sudo cp dea.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable dea.service
sudo systemctl start dea.service
```
查看狀態:
查看狀態與 Log
```bash
sudo systemctl status dea.service
sudo journalctl -u dea.service -f
```
## MCU 連接埠 (Web Serial)
### Backend Serial Fallback
本工具優先使用瀏覽器的 Web Serial API 直接與 MCU 通訊,無需伺服器端具備 serial 權限。
- **連線**:點擊 UI 上的「選擇連接埠並連線」,在瀏覽器彈出視窗中選擇正確的 MCU 裝置。
- **寫值**:點擊「寫值到 MCU」,系統會將目前定點數係數格式化為 `bodeplot=b0,b1,b2,a1,a2` 並送出。
- **限制**:需使用 Chrome 或 Edge 瀏覽器,且必須透過 HTTPS (或 localhost) 連線。
## 後端 MCU Serial 設定 (備用)
若無法使用 Web Serial,後端仍保留透過伺服器端實體連線寫入的 API。預設值在 `dea/config.py`,也可用環境變數覆蓋:
若無法使用 Web Serial,可改用後端 serial API。環境變數:
```bash
MCU_SERIAL_PORT=/dev/ttyUSB2
@@ -111,7 +258,7 @@ MCU_BAUD_RATE=115200
MCU_WRITE_TIMEOUT=1.0
```
若使用 systemd可在 `dea.service` 的 `[Service]` 區塊加入:
systemd 可在 `[Service]` 加入:
```ini
Environment="MCU_SERIAL_PORT=/dev/ttyUSB2"
@@ -119,70 +266,27 @@ Environment="MCU_BAUD_RATE=115200"
Environment="MCU_WRITE_TIMEOUT=1.0"
```
## API
## Testing
目前主要 API
- `POST /api/design`:依濾波器參數產生 b/a 係數。
- `POST /api/bode`:計算單組 b/a 係數的頻率響應。
- `POST /api/bode/compare`:一次計算浮點與 fixed-point 兩組頻率響應。
- `POST /api/filter`:上傳 CSV 並回傳圖表用的濾波結果。
- `POST /api/filter/download`:上傳 CSV 並下載包含 `<欄位>_filtered` 的結果 CSV。
- `GET /api/mcu/ports`:列出伺服器端可用 serial ports (後端模式)。
- `POST /api/mcu/write`:送出伺服器端 MCU 命令 (後端模式)。
MCU 寫入命令格式固定為:
```text
bodeplot=b0,b1,b2,a1,a2
```
## a1/a2 微調 (δ/r 參數)
針對二階濾波器 (2P1Z/2P2Z),UI 提供了進階的步階微調功能:
- **δ (Delta)**:控制極點的頻率/阻尼平衡點。
- **r**:控制極點的半徑/頻寬。
- 公式:`a1 = -a0 - r + δ``a2 = r + δ` (定點數模式) 或 `a1 = -1 - r + δ``a2 = r + δ` (浮點數模式)。
- 支援透過 Shift + 滑鼠滾輪或觸控拖曳進行高精度微調。
## CSV 上傳
限制與行為:
- CSV 檔案大小上限為 32 MB。
- CSV 必須有標題列與至少一筆資料。
- 可選擇要濾波的訊號欄位;若第一欄像 `time`、`t`、`sec`、`x`、`index`,前端會預設選第二欄。
- 圖表回應最多回傳 5000 點,避免大型 CSV 讓前端過重。
- 下載 CSV 會保留完整資料,不套用圖表降採樣。
專案根目錄的 `chirp_signal.csv` 是上傳測試範例,約 19 MB、500001 行,欄位為:
```text
time,value
```
## 測試與檢查
Python 語法檢查:
Python syntax check
```bash
.venv/bin/python -m py_compile dea_api.py tests/test_dea_api.py
```
後端測試使用 Python 內建 `unittest`,不需要安裝或執行 `pytest`
Backend tests 使用 Python 內建 `unittest`
```bash
.venv/bin/python -m unittest discover -s tests
```
前端建置檢查
Frontend build check
```bash
npm run build
```
建議完整驗證順序
建議完整驗證:
```bash
.venv/bin/pip install -r requirements.txt
@@ -191,29 +295,37 @@ npm run build
sudo systemctl restart dea.service
```
## 安全與限制
## Security And Limits
- API middleware 只允許 private / loopback IP 存取。
- 回應會加上 CSP、`Referrer-Policy`、`X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`。
- CSP 的 script 來源允許本機與 Plotly CDN。
- 係數數量上限為 64。
- CSV 上限為 32 MB。
- 頻率、係數、`a[0]` 與 CSV 訊號欄位都有後端驗證。
| Item | Value |
| --- | --- |
| Network access | 只允許 private / loopback IP |
| CSV size | 300 MB |
| Time-domain rows | 1,200,000 rows |
| Plot points | 5000 points |
| Coefficients | 最多 64 個 |
## 常見問題
HTTP responses 會加上 CSP、`Referrer-Policy``X-Content-Type-Options: nosniff``X-Frame-Options: DENY`
### 為什麼無法連線連接埠?
1. 確認是否使用 HTTPS 連線。
2. 確認瀏覽器是否支援 Web Serial API (推薦使用 Chrome/Edge)。
3. 檢查 USB 傳輸線是否連接正確。
## Troubleshooting
### 無法連線 MCU
1. 確認使用 HTTPS 或 localhost。
2. 確認瀏覽器支援 Web Serial API。
3. 使用 Chrome 或 Edge。
4. 檢查 USB 線與 MCU 裝置狀態。
### 頁面沒有更新
1. 瀏覽器按 `Ctrl+F5` 強制重新整理。
2. 執行 `npm run build` 重新建置前端
3. 執行 `sudo systemctl restart dea.service`。
1. 執行 `npm run build`
2. 重啟服務:`sudo systemctl restart dea.service`
3. 瀏覽器按 `Ctrl+F5` 強制重新整理。
### systemd 顯示 `status=203/EXEC`
通常是 `.venv``uvicorn` 路徑不正確。可重建 venv
```bash
rm -rf .venv
python3 -m venv .venv
@@ -222,7 +334,9 @@ sudo systemctl restart dea.service
```
### 找不到 pytest
本專案不使用 pytest。請改用:
```bash
.venv/bin/python -m unittest discover -s tests
```
+19 -19
View File
@@ -1,27 +1,27 @@
#!/usr/bin/env bash
# 產生自簽 SSL 憑證供 Uvicorn HTTPS 使用
# 用法: bash certs/generate_cert.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CERT_FILE="${SCRIPT_DIR}/cert.pem"
KEY_FILE="${SCRIPT_DIR}/key.pem"
#!/usr/bin/env bash
# 產生自簽 SSL 憑證供 Uvicorn HTTPS 使用
# 用法: bash certs/generate_cert.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CERT_FILE="${SCRIPT_DIR}/cert.pem"
KEY_FILE="${SCRIPT_DIR}/key.pem"
DAYS=3650
if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ]; then
echo "憑證已存在: ${CERT_FILE}, ${KEY_FILE}"
echo "如需重新產生,請先刪除後再執行。"
exit 0
fi
openssl req -x509 -newkey rsa:2048 -nodes \
echo "憑證已存在: ${CERT_FILE}, ${KEY_FILE}"
echo "如需重新產生,請先刪除後再執行。"
exit 0
fi
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "$KEY_FILE" \
-out "$CERT_FILE" \
-days "$DAYS" \
-subj "/CN=$(hostname)" \
-addext "subjectAltName=DNS:$(hostname),IP:0.0.0.0"
echo "已產生自簽憑證(有效 ${DAYS} 天):"
echo " cert: ${CERT_FILE}"
echo " key: ${KEY_FILE}"
echo "已產生自簽憑證(有效 ${DAYS} 天):"
echo " cert: ${CERT_FILE}"
echo " key: ${KEY_FILE}"
+2 -1
View File
@@ -1,7 +1,8 @@
import os
MAX_COEFFICIENTS = 64
MAX_CSV_BYTES = 32 * 1024 * 1024
MAX_CSV_BYTES = 300 * 1024 * 1024
MAX_ROWS = 1200000
MAX_PLOT_POINTS = 5000
BODE_POINTS = 500
BODE_MAX_MULTIPLIER = 3.162
+137 -25
View File
@@ -1,22 +1,41 @@
import io
import os
import uuid
import re
import tempfile
import numpy as np
import pandas as pd
from fastapi import HTTPException
from scipy import signal
import math
from .config import MAX_CSV_BYTES, MAX_PLOT_POINTS
from .config import MAX_CSV_BYTES, MAX_PLOT_POINTS, MAX_ROWS
TEMP_CSV_DIR = os.environ.get(
"DEA_TEMP_CSV_DIR",
os.path.join(tempfile.gettempdir(), "diff-eq-analyzer-csv"),
)
os.makedirs(TEMP_CSV_DIR, exist_ok=True)
def get_cached_file_path(file_id):
if not re.match(r'^[0-9a-f\-]{36}$', file_id):
raise HTTPException(status_code=400, detail="無效的檔案ID")
file_path = os.path.join(TEMP_CSV_DIR, f"{file_id}.csv")
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="找不到快取的 CSV 檔案,請重新上傳")
return file_path
def downsample_for_plot(index, original, filtered):
def downsample_for_plot(index, original, filtered_float, filtered_fixed):
total = len(index)
if total <= MAX_PLOT_POINTS:
return index, original, filtered, 1
return index, original, filtered_float, filtered_fixed, 1
step = int(np.ceil(total / MAX_PLOT_POINTS))
return index[::step], original[::step], filtered[::step], step
return index[::step], original[::step], filtered_float[::step], filtered_fixed[::step], step
async def read_csv_upload(file):
async def save_csv_upload(file):
filename = (file.filename or "").lower()
if filename and not filename.endswith(".csv"):
raise HTTPException(status_code=400, detail="請上傳 CSV 檔案")
@@ -25,37 +44,115 @@ async def read_csv_upload(file):
raise HTTPException(status_code=413, detail=f"CSV 檔案不可超過 {MAX_CSV_BYTES // (1024 * 1024)}MB")
if not contents.strip():
raise HTTPException(status_code=400, detail="CSV 不可為空")
try:
df = pd.read_csv(io.BytesIO(contents))
except Exception as e:
raise HTTPException(status_code=400, detail=f"CSV 讀取失敗: {str(e)}")
if df.empty:
raise HTTPException(status_code=400, detail="CSV 不可為空")
return df
file_id = str(uuid.uuid4())
file_path = os.path.join(TEMP_CSV_DIR, f"{file_id}.csv")
with open(file_path, "wb") as f:
f.write(contents)
return file_id
def filtered_csv_data(df, b_vals, a_vals, col_idx):
if col_idx < 0 or len(df.columns) <= col_idx:
def integer_lfilter(b_int, a_int, x_float, shift_in, shift_out, shift_b, shift_a, use_round=False):
"""
全整數差分方程式模擬 (Mimic DSP hardware)
高精度狀態變數架構:前饋不位移,保留小數精度於狀態變數中。
支援硬體 Rounding (四捨五入) vs Floor (無條件捨去向下取整)。
"""
b_int = np.asarray(b_int, dtype=np.int64)
a_int = np.asarray(a_int, dtype=np.int64)
x_int = np.round(x_float * (2**shift_in)).astype(np.int64)
# y_hist 將保留在 Q_{in + b} 格式以降低 Truncation Error
y_hist = np.zeros(len(x_int), dtype=np.int64)
y_out = np.zeros(len(x_int), dtype=np.int64)
nb = len(b_int)
na = len(a_int)
A0 = int(a_int[0])
if A0 == 0: A0 = 1
# 輸出所需的總位移量
out_shift = shift_in + shift_b - shift_out
# 預先計算四捨五入的補償值 (+0.5)
# 韌體開發提示 (C Implementation / RISC-V):
# 1. 標準 RISC-V (RV32I/IMAC) 的 SRA 指令是純 Floor (無條件捨去),沒有硬體 Rounding shift。
# (除非具備 'P' DSP Extension 才可能有 1-cycle 的硬體 rounding shift)。
# 2. 演算法秘技:在 C 語言中要實現 1-clock 的四捨五入,不要呼叫 float 的 round()。
# 請使用 `y = (acc + (1 << (shift - 1))) >> shift`。
# 編譯器會將 (1 << (shift - 1)) 編譯為常數,整體只消耗 1 個 ADD 指令,極大消除了 DC Bias。
round_offset_a = (A0 >> 1) if use_round else 0
round_offset_out = (1 << (out_shift - 1)) if (use_round and out_shift > 0) else 0
for n in range(len(x_int)):
sum_b = 0
# Feedforward: 前饋完全不位移,結果為 Q_{in + b}
for i in range(nb):
if n - i >= 0:
sum_b += b_int[i] * x_int[n - i]
# Feedback: 歷史紀錄為 Q_{in + b},係數為 Q_a,乘積為 Q_{in + b + a}
sum_a = 0
for j in range(1, na):
if n - j >= 0:
sum_a += a_int[j] * y_hist[n - j]
# Feedback 縮放:前提 A0 = 1 (或者代表放大了 Q_a 倍的常數),除以 A0 (即 >> shift_a) 歸一化
# Python 的 // 等同於硬體的 SRA (Arithmetic Right Shift),會向負無窮大 Floor
sum_a_scaled = (sum_a + round_offset_a) // A0
acc = sum_b - sum_a_scaled
# 將超高精度的 acc 直接存入歷史變數
y_hist[n] = acc
# 最終輸出再針對 Q_out 進行位移縮放
if out_shift > 0:
y_out[n] = (acc + round_offset_out) >> out_shift
elif out_shift < 0:
y_out[n] = acc << (-out_shift)
else:
y_out[n] = acc
return y_out.astype(float) / (2**shift_out)
def filter_preview_response(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None, shift_in=14, shift_out=14, shift_b=14, shift_a=14, use_round=False):
path = get_cached_file_path(file_id)
# 預先讀取欄位名稱,避免用 usecols 讀取後找不到原始索引
cols = pd.read_csv(path, nrows=0).columns.tolist()
if col_idx < 0 or col_idx >= len(cols):
raise HTTPException(status_code=400, detail="欄位索引超出範圍")
col_to_filter = df.columns[col_idx]
col_to_filter = cols[col_idx]
# 精準讀取單一欄位,並加上筆數限制 (極大降低記憶體用量與時間)
df = pd.read_csv(path, usecols=[col_idx], nrows=MAX_ROWS)
x_signal = pd.to_numeric(df[col_to_filter], errors="coerce")
if x_signal.isna().any():
raise HTTPException(status_code=400, detail=f"欄位 {col_to_filter} 含有非數值資料")
x_values = x_signal.to_numpy(dtype=float)
if not np.isfinite(x_values).all():
raise HTTPException(status_code=400, detail=f"欄位 {col_to_filter} 含有非有限數值")
y_signal = signal.lfilter(b_vals, a_vals, x_values)
return col_to_filter, x_values, y_signal
# 路徑 1: 理想浮點數路徑
y_float = signal.lfilter(b_vals, a_vals, x_values)
def filter_preview_response(df, b_vals, a_vals, col_idx):
col_to_filter, x_signal, y_signal = filtered_csv_data(df, b_vals, a_vals, col_idx)
index, original, filtered, step = downsample_for_plot(df.index.to_numpy(), x_signal, y_signal)
# 路徑 2: 整數模擬路徑
if b_int is not None and a_int is not None:
y_fixed = integer_lfilter(b_int, a_int, x_values, shift_in, shift_out, shift_b, shift_a, use_round)
else:
y_fixed = y_float
index, original, filtered_float, filtered_fixed, step = downsample_for_plot(df.index.to_numpy(), x_signal, y_float, y_fixed)
return {
"index": index.tolist(),
"original": original.tolist(),
"filtered": filtered.tolist(),
"filtered": filtered_float.tolist(),
"filtered_fixed": filtered_fixed.tolist(),
"col_name": col_to_filter,
"total_points": int(len(df.index)),
"plot_points": int(len(index)),
@@ -63,10 +160,25 @@ def filter_preview_response(df, b_vals, a_vals, col_idx):
}
def filtered_csv_text(df, b_vals, a_vals, col_idx):
col_to_filter, _, y_signal = filtered_csv_data(df, b_vals, a_vals, col_idx)
df[f"{col_to_filter}_filtered"] = y_signal
def filtered_csv_text(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None, shift_in=14, shift_out=14, shift_b=14, shift_a=14, use_round=False):
path = get_cached_file_path(file_id)
# 匯出時需要原始所有欄位,但仍受限於 MAX_ROWS
df = pd.read_csv(path, nrows=MAX_ROWS)
if col_idx < 0 or col_idx >= len(df.columns):
raise HTTPException(status_code=400, detail="欄位索引超出範圍")
col_to_filter = df.columns[col_idx]
x_signal = pd.to_numeric(df[col_to_filter], errors="coerce")
x_values = x_signal.to_numpy(dtype=float)
y_float = signal.lfilter(b_vals, a_vals, x_values)
if b_int is not None and a_int is not None:
y_fixed = integer_lfilter(b_int, a_int, x_values, shift_in, shift_out, shift_b, shift_a, use_round)
else:
y_fixed = y_float
df[f"{col_to_filter}_filtered_ideal"] = y_float
df[f"{col_to_filter}_filtered_fixed"] = y_fixed
csv_buffer = io.StringIO()
df.to_csv(csv_buffer, index=False)
return csv_buffer.getvalue()
+13
View File
@@ -27,6 +27,19 @@ def parse_coefficients(raw, name):
return validate_coefficients(values, name)
def parse_int_coefficients(raw, name):
try:
# 先轉 float 再轉 int 以處理 1.0 這種字串
values = [int(float(x.strip())) for x in raw.replace(",", " ").split() if x.strip()]
except ValueError:
raise HTTPException(status_code=400, detail=f"{name} 係數格式錯誤 (預期為整數)")
if len(values) == 0:
raise HTTPException(status_code=400, detail=f"{name} 係數不可為空")
if len(values) > MAX_COEFFICIENTS:
raise HTTPException(status_code=400, detail=f"{name} 係數最多 {MAX_COEFFICIENTS}")
return values
def validate_coefficients(values, name):
if len(values) == 0:
raise HTTPException(status_code=400, detail=f"{name} 係數不可為空")
+55 -8
View File
@@ -14,9 +14,8 @@ from dea.config import (
from dea.csv_processing import (
downsample_for_plot,
filter_preview_response,
filtered_csv_data,
filtered_csv_text,
read_csv_upload,
save_csv_upload,
)
from dea.filter_design import design_response
from dea.mcu import list_mcu_ports_response, write_mcu_command_response
@@ -27,9 +26,11 @@ from dea.security import (
is_lan_or_loopback,
lan_denied_response,
)
import traceback
from dea.validation import (
normalize_coefficients,
parse_coefficients,
parse_int_coefficients,
require_finite,
validate_coefficients,
validate_frequency,
@@ -86,21 +87,50 @@ def calculate_bode_compare(params: BodeCompareParams):
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/csv/upload")
async def upload_csv(file: UploadFile = File(...)):
try:
file_id = await save_csv_upload(file)
return {"file_id": file_id}
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=400, detail=f"CSV上傳失敗: {str(e)}")
@app.post("/api/filter")
async def filter_csv(
file: UploadFile = File(...),
file_id: str = Form(...),
b: str = Form(...),
a: str = Form(...),
col_idx: int = Form(0),
b_int: str = Form(None),
a_int: str = Form(None),
shift_in: int = Form(14),
shift_out: int = Form(14),
shift_b: int = Form(14),
shift_a: int = Form(14),
use_round: bool = Form(False),
):
try:
b_vals = parse_coefficients(b, "b")
a_vals = parse_coefficients(a, "a")
df = await read_csv_upload(file)
return filter_preview_response(df, b_vals, a_vals, col_idx)
b_int_vals = parse_int_coefficients(b_int, "b_int") if b_int else None
a_int_vals = parse_int_coefficients(a_int, "a_int") if a_int else None
return filter_preview_response(
file_id, b_vals, a_vals, col_idx,
b_int=b_int_vals, a_int=a_int_vals,
shift_in=shift_in, shift_out=shift_out,
shift_b=shift_b, shift_a=shift_a,
use_round=use_round
)
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=400, detail=f"CSV處理失敗: {str(e)}")
@@ -126,16 +156,32 @@ def write_mcu_command(params: MCUWriteParams):
@app.post("/api/filter/download")
async def filter_csv_download(
file: UploadFile = File(...),
file_id: str = Form(...),
b: str = Form(...),
a: str = Form(...),
col_idx: int = Form(0),
b_int: str = Form(None),
a_int: str = Form(None),
shift_in: int = Form(14),
shift_out: int = Form(14),
shift_b: int = Form(14),
shift_a: int = Form(14),
use_round: bool = Form(False),
):
try:
b_vals = parse_coefficients(b, "b")
a_vals = parse_coefficients(a, "a")
df = await read_csv_upload(file)
csv_text = filtered_csv_text(df, b_vals, a_vals, col_idx)
b_int_vals = parse_int_coefficients(b_int, "b_int") if b_int else None
a_int_vals = parse_int_coefficients(a_int, "a_int") if a_int else None
csv_text = filtered_csv_text(
file_id, b_vals, a_vals, col_idx,
b_int=b_int_vals, a_int=a_int_vals,
shift_in=shift_in, shift_out=shift_out,
shift_b=shift_b, shift_a=shift_a,
use_round=use_round
)
return StreamingResponse(
iter([csv_text]),
@@ -145,4 +191,5 @@ async def filter_csv_download(
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=400, detail=f"CSV處理失敗: {str(e)}")
+151 -184
View File
@@ -175,17 +175,17 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.128.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz",
"integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==",
"version": "0.130.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz",
"integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
"cpu": [
"arm64"
],
@@ -198,9 +198,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz",
"integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
"cpu": [
"arm64"
],
@@ -213,9 +213,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz",
"integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
"cpu": [
"x64"
],
@@ -228,9 +228,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz",
"integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
"cpu": [
"x64"
],
@@ -243,9 +243,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz",
"integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
"cpu": [
"arm"
],
@@ -258,9 +258,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz",
"integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
"cpu": [
"arm64"
],
@@ -273,9 +273,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz",
"integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
"cpu": [
"arm64"
],
@@ -288,9 +288,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz",
"integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
"cpu": [
"ppc64"
],
@@ -303,9 +303,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz",
"integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
"cpu": [
"s390x"
],
@@ -318,9 +318,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz",
"integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
"cpu": [
"x64"
],
@@ -333,9 +333,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz",
"integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
"cpu": [
"x64"
],
@@ -348,9 +348,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz",
"integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
"cpu": [
"arm64"
],
@@ -363,9 +363,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz",
"integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
"cpu": [
"wasm32"
],
@@ -380,9 +380,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz",
"integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
"cpu": [
"arm64"
],
@@ -395,9 +395,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz",
"integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
"cpu": [
"x64"
],
@@ -410,9 +410,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA=="
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
@@ -424,11 +424,11 @@
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.6",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
"integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==",
"version": "6.0.7",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
"integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==",
"dependencies": {
"@rolldown/pluginutils": "1.0.0-rc.13"
"@rolldown/pluginutils": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@@ -546,17 +546,6 @@
"node": ">= 8"
}
},
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -766,9 +755,9 @@
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="
},
"node_modules/electron-to-chromium": {
"version": "1.5.353",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz",
"integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w=="
"version": "1.5.356",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz",
"integrity": "sha512-9NgFd7m5t5MCJ5rUSjJITUXAH9mEGlrlofnMf4YEr+pz6JlP7cWmTAH+JFmbPnaSW8koVTkuW7pacORWAnA5Yw=="
},
"node_modules/entities": {
"version": "7.0.1",
@@ -836,22 +825,6 @@
"reusify": "^1.0.4"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -971,13 +944,11 @@
}
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"optional": true,
"peer": true,
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"bin": {
"jiti": "lib/jiti-cli.mjs"
"jiti": "bin/jiti.js"
}
},
"node_modules/lightningcss": {
@@ -1261,17 +1232,6 @@
"node": ">=8.6"
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -1300,9 +1260,9 @@
}
},
"node_modules/node-releases": {
"version": "2.0.38",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="
"version": "2.0.44",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
"integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ=="
},
"node_modules/normalize-path": {
"version": "3.0.0",
@@ -1339,11 +1299,11 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"engines": {
"node": ">=12"
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
@@ -1433,9 +1393,9 @@
}
},
"node_modules/postcss-load-config": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
"integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
"funding": [
{
"type": "opencollective",
@@ -1447,21 +1407,28 @@
}
],
"dependencies": {
"lilconfig": "^3.0.0",
"yaml": "^2.3.4"
"lilconfig": "^3.1.1"
},
"engines": {
"node": ">= 14"
"node": ">= 18"
},
"peerDependencies": {
"jiti": ">=1.21.0",
"postcss": ">=8.0.9",
"ts-node": ">=9.0.0"
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"jiti": {
"optional": true
},
"postcss": {
"optional": true
},
"ts-node": {
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
@@ -1545,17 +1512,6 @@
"node": ">=8.10.0"
}
},
"node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -1586,12 +1542,12 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz",
"integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
"dependencies": {
"@oxc-project/types": "=0.128.0",
"@rolldown/pluginutils": "1.0.0-rc.18"
"@oxc-project/types": "=0.130.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -1600,28 +1556,23 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.18",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.18",
"@rolldown/binding-darwin-x64": "1.0.0-rc.18",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.18",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.18",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.18",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.18",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18"
"@rolldown/binding-android-arm64": "1.0.1",
"@rolldown/binding-darwin-arm64": "1.0.1",
"@rolldown/binding-darwin-x64": "1.0.1",
"@rolldown/binding-freebsd-x64": "1.0.1",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
"@rolldown/binding-linux-arm64-gnu": "1.0.1",
"@rolldown/binding-linux-arm64-musl": "1.0.1",
"@rolldown/binding-linux-ppc64-gnu": "1.0.1",
"@rolldown/binding-linux-s390x-gnu": "1.0.1",
"@rolldown/binding-linux-x64-gnu": "1.0.1",
"@rolldown/binding-linux-x64-musl": "1.0.1",
"@rolldown/binding-openharmony-arm64": "1.0.1",
"@rolldown/binding-wasm32-wasi": "1.0.1",
"@rolldown/binding-win32-arm64-msvc": "1.0.1",
"@rolldown/binding-win32-x64-msvc": "1.0.1"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz",
"integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw=="
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -1685,9 +1636,9 @@
}
},
"node_modules/tailwindcss": {
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -1697,7 +1648,7 @@
"fast-glob": "^3.3.2",
"glob-parent": "^6.0.2",
"is-glob": "^4.0.3",
"jiti": "^1.21.6",
"jiti": "^1.21.7",
"lilconfig": "^3.1.3",
"micromatch": "^4.0.8",
"normalize-path": "^3.0.0",
@@ -1706,7 +1657,7 @@
"postcss": "^8.4.47",
"postcss-import": "^15.1.0",
"postcss-js": "^4.0.1",
"postcss-load-config": "^4.0.2",
"postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
"postcss-nested": "^6.2.0",
"postcss-selector-parser": "^6.1.2",
"resolve": "^1.22.8",
@@ -1720,14 +1671,6 @@
"node": ">=14.0.0"
}
},
"node_modules/tailwindcss/node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"bin": {
"jiti": "bin/jiti.js"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -1762,6 +1705,33 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -1819,14 +1789,14 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/vite": {
"version": "8.0.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
"integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==",
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.14",
"rolldown": "1.0.0-rc.18",
"rolldown": "1.0.1",
"tinyglobby": "^0.2.16"
},
"bin": {
@@ -1894,6 +1864,17 @@
}
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vue": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz",
@@ -1913,20 +1894,6 @@
"optional": true
}
}
},
"node_modules/yaml": {
"version": "2.8.4",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}
+81 -27
View File
@@ -450,7 +450,7 @@
<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-500 block font-bold mb-1">a1 = -1 - r + δ</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">
@@ -470,7 +470,7 @@
<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 + a2 + 1) / 2r = (a2 - a1 - 1) / 2更改數值會自動更新圖表桌機可用 +/- shift +
δ = a1 + a2 + 1r = -1 - a1更改數值會自動更新圖表桌機可用 +/- shift +
</p>
</div>
</div>
@@ -633,7 +633,7 @@
</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-500 block font-bold mb-1">a1 = -a0 - r + δ</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">
@@ -652,7 +652,7 @@
<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 + a2 + a0) / 2r = (a2 - a1 - a0) / 2
a0 = 2^{{ shiftBitsA }}a1 = -a0 - ra2 = r + δ
</p>
</div>
</div>
@@ -770,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-600 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-800 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-700 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>
+278 -37
View File
@@ -43,11 +43,14 @@ export default {
sense_b: '2x', sense_a: '2x',
loadingBode: false, bodeTimeout: null, globalError: null,
csvFile: null, csvColumns: [], csvPreview: [], csvInfo: null, csvParseError: null,
csvFile: null, csvFileId: null, csvColumns: [], csvPreview: [], csvInfo: null, csvParseError: null,
isCsvPreviewExpanded: true,
selectedColumn: 0, loadingFilter: false, filterDone: false, timePlotData: null,
mobileTab: 'settings', // 手機版頁籤:'settings' | 'chart'
shiftBitsB: DEFAULT_SHIFT_BITS,
shiftBitsA: DEFAULT_SHIFT_BITS,
useRound: true, // 是否使用 Rounding 取代 Floor
sense_in: '2x', sense_out: '2x',
fixedOverrides: { a: {}, b: {} },
outOfRangeB: [false, false, false, false, false, false, false],
outOfRangeA: [false, false, false, false, false, false, false],
@@ -62,6 +65,14 @@ export default {
fixedAFineDrag: null,
fixedAFineRepeatDelayTimer: null,
fixedAFineRepeatTimer: null,
shiftBitsIn: 14,
shiftBitsOut: 14,
shiftInDrag: null,
shiftInRepeatDelayTimer: null,
shiftInRepeatTimer: null,
shiftOutDrag: null,
shiftOutRepeatDelayTimer: null,
shiftOutRepeatTimer: null,
isTouchInput: false,
activeCoeffAdjustment: null,
mcuSerialPort: null,
@@ -70,6 +81,7 @@ export default {
writingMCU: false,
mcuStatus: '',
saveSettingsTimeout: null,
bodeMagRange: null, // [min, max] for Y-axis
}
},
@@ -171,10 +183,13 @@ export default {
return a[2] || 0;
},
currentDelta() {
return (this.currentA1 + this.currentA2 + 1) / 2;
// New Model: a1 = -1 - r, a2 = r + delta
// => delta = a1 + a2 + 1
return this.currentA1 + this.currentA2 + 1;
},
currentR() {
return (this.currentA2 - this.currentA1 - 1) / 2;
// New Model: a1 = -1 - r => r = -1 - a1
return -1 - this.currentA1;
},
currentA1A2Diff() {
return this.currentA1 - this.currentA2;
@@ -192,10 +207,13 @@ export default {
return this.fixedPointCoeffs.a[2]?.val || 0;
},
fixedCurrentDelta() {
return (this.fixedCurrentA1 + this.fixedCurrentA2 + this.fixedA0Int) / 2;
// New Model: a1 = -a0 - r, a2 = r + delta
// => delta = a1 + a2 + a0
return this.fixedCurrentA1 + this.fixedCurrentA2 + this.fixedA0Int;
},
fixedCurrentR() {
return (this.fixedCurrentA2 - this.fixedCurrentA1 - this.fixedA0Int) / 2;
// New Model: a1 = -a0 - r => r = -a0 - a1
return -this.fixedA0Int - this.fixedCurrentA1;
},
fixedCurrentA1A2Diff() {
return this.fixedCurrentA1 - this.fixedCurrentA2;
@@ -206,8 +224,14 @@ export default {
},
watch: {
fs: 'debouncedSaveSettings',
b_str: 'debouncedSaveSettings',
a_str: 'debouncedSaveSettings',
b_str() {
this.debouncedSaveSettings();
this.debouncedProcessFilter();
},
a_str() {
this.debouncedSaveSettings();
this.debouncedProcessFilter();
},
filterType: 'debouncedSaveSettings',
systemGain: 'debouncedSaveSettings',
params: { handler: 'debouncedSaveSettings', deep: true },
@@ -217,11 +241,31 @@ export default {
aSliders: { handler: 'debouncedSaveSettings', deep: true },
sense_b: 'debouncedSaveSettings',
sense_a: 'debouncedSaveSettings',
shiftBitsB: 'debouncedSaveSettings',
shiftBitsA: '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'
fixedAFineStep: 'debouncedSaveSettings',
b_int_str() { this.debouncedProcessFilter(); },
a_int_str() { this.debouncedProcessFilter(); }
},
mounted() {
this.isTouchInput = window.matchMedia?.('(pointer: coarse)').matches || false;
@@ -234,13 +278,14 @@ 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 連線)';
}
this.updateBodeMagRange();
this.applyFilterDesign();
},
beforeUnmount() {
@@ -251,6 +296,10 @@ export default {
this.stopShiftBitDrag();
this.stopFixedA1A2Repeat();
this.stopFixedAFineDrag();
this.stopShiftInRepeat();
this.stopShiftInDrag();
this.stopShiftOutRepeat();
this.stopShiftOutDrag();
},
methods: {
loadSettings() {
@@ -261,7 +310,8 @@ export default {
const keys = [
'fs', 'b_str', 'a_str', 'filterType', 'systemGain', 'params',
'baseB', 'baseA', 'bSliders', 'aSliders', 'sense_b', 'sense_a',
'shiftBitsB', 'shiftBitsA', 'fixedOverrides', 'aFineStep', 'fixedAFineStep'
'shiftBitsB', 'shiftBitsA', 'shiftBitsIn', 'shiftBitsOut', 'useRound',
'fixedOverrides', 'aFineStep', 'fixedAFineStep'
];
keys.forEach(k => {
if (parsed[k] !== undefined) {
@@ -277,7 +327,8 @@ export default {
const keys = [
'fs', 'b_str', 'a_str', 'filterType', 'systemGain', 'params',
'baseB', 'baseA', 'bSliders', 'aSliders', 'sense_b', 'sense_a',
'shiftBitsB', 'shiftBitsA', 'fixedOverrides', 'aFineStep', 'fixedAFineStep'
'shiftBitsB', 'shiftBitsA', 'shiftBitsIn', 'shiftBitsOut', 'useRound',
'fixedOverrides', 'aFineStep', 'fixedAFineStep'
];
const settings = {};
keys.forEach(k => settings[k] = this[k]);
@@ -392,14 +443,29 @@ export default {
this.aFineStep = Number(clampedStep.toPrecision(12));
},
adjustFixedAFineStep(direction) {
const nextStep = this.fixedAFineStep * (direction > 0 ? 0.1 : 10);
this.fixedAFineStep = Math.max(1, Math.min(1000000000, Math.round(nextStep)));
const steps = [1, 2, 4, 8, 32, 256, 1024, 4096, 16384, 65536];
let currentIdx = steps.indexOf(this.fixedAFineStep);
// 如果當前值不在數列中,找最接近的一個
if (currentIdx === -1) {
currentIdx = steps.reduce((prevIdx, curr, idx) => {
return Math.abs(curr - this.fixedAFineStep) < Math.abs(steps[prevIdx] - this.fixedAFineStep) ? idx : prevIdx;
}, 0);
}
if (direction > 0) { // 細 -> 往小調
currentIdx = Math.max(0, currentIdx - 1);
} else { // 粗 -> 往大調
currentIdx = Math.min(steps.length - 1, currentIdx + 1);
}
this.fixedAFineStep = steps[currentIdx];
},
setDeltaR(delta, r) {
const nextDelta = Number(delta);
const nextR = Number(r);
if (!Number.isFinite(nextDelta) || !Number.isFinite(nextR)) return;
this.setA1A2(-1 - nextR + nextDelta, nextR + nextDelta);
// New Model: a1 = -1 - r, a2 = r + delta
this.setA1A2(-1 - nextR, nextR + nextDelta);
},
setAFineDirect(target, rawValue) {
const nextValue = parseFloat(rawValue);
@@ -454,11 +520,14 @@ export default {
window.removeEventListener('pointercancel', this.stopAFineDrag);
},
setFixedDeltaR(delta, r) {
const nextDelta = Number(delta);
const nextR = Number(r);
if (!Number.isFinite(nextDelta) || !Number.isFinite(nextR)) return;
this.fixedOverrides.a[1] = Math.round(-this.fixedA0Int - nextR + nextDelta);
this.fixedOverrides.a[2] = Math.round(nextR + nextDelta);
const nextDelta = Math.round(delta);
const nextR = Math.round(r);
const a0 = this.fixedA0Int;
// New Model: a1 = -a0 - r, a2 = r + delta
// If r and delta are integers, a1 and a2 are guaranteed to be integers.
this.fixedOverrides.a[1] = -a0 - nextR;
this.fixedOverrides.a[2] = nextR + nextDelta;
this.debouncedUpdateBode();
},
setFixedAFineDirect(target, rawValue) {
@@ -471,11 +540,11 @@ export default {
}
},
adjustFixedAFineValue(target, steps) {
const delta = steps * this.fixedAFineStep;
const dVal = Math.round(steps * this.fixedAFineStep);
if (target === 'delta') {
this.setFixedDeltaR(this.fixedCurrentDelta + delta, this.fixedCurrentR);
this.setFixedDeltaR(this.fixedCurrentDelta + dVal, this.fixedCurrentR);
} else if (target === 'r') {
this.setFixedDeltaR(this.fixedCurrentDelta, this.fixedCurrentR + delta);
this.setFixedDeltaR(this.fixedCurrentDelta, this.fixedCurrentR + dVal);
}
},
handleFixedAFineWheel(target, event) {
@@ -533,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') {
@@ -597,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;
@@ -721,8 +888,14 @@ export default {
this.bodeTimeout = setTimeout(() => this.updateBodePlot({ switchToChart: false }), 150);
},
onGainChange() {
this.updateBodeMagRange();
this.updateFromControls();
},
updateBodeMagRange() {
const K = parseFloat(this.systemGain) || 1.0;
const yMax = 20 * Math.log10(Math.abs(K) * 3);
this.bodeMagRange = [yMax - 70, yMax];
},
onFilterTypeChange() {
this.systemGain = 1.0;
// 這裡可以選擇是否重設所有設計參數 (fc, Q 等)
@@ -736,6 +909,7 @@ export default {
if (this.filterType !== MANUAL_FILTER_TYPE) {
this.params = cloneDefaultParams();
}
this.updateBodeMagRange();
this.applyFilterDesign();
},
resetFilterParams() {
@@ -769,6 +943,7 @@ export default {
this.stopShiftBitDrag();
this.activeCoeffAdjustment = null;
this.globalError = null;
this.updateBodeMagRange();
if (isManual) this.updateBodePlot({ switchToChart: false });
else this.applyFilterDesign();
},
@@ -951,6 +1126,7 @@ export default {
const layout = {
paper_bgcolor: plotBgColor, plot_bgcolor: plotBgColor,
uirevision: this.bodeMagRange ? this.bodeMagRange.join(',') : 'true', // 當範圍不變時,保留使用者縮放
margin: isSmallScreen ? { t: 80, b: 120, l: 55, r: 15 } : { t: 70, b: 120, l: 70, r: 30 },
showlegend: false,
grid: { rows: 2, columns: 1, pattern: 'independent', roworder: 'top to bottom', ygap: isSmallScreen ? 0.35 : 0.3 },
@@ -973,10 +1149,36 @@ export default {
...annotations
],
xaxis: { ...xAxisCommon },
yaxis: { title: { text: 'Mag (dB)', font: { size: isSmallScreen ? 12 : 14 } }, range: [-60, 0], gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 } },
xaxis2: { ...xAxisCommon },
yaxis2: { title: { text: 'Phase (°)', font: { size: isSmallScreen ? 12 : 14 } }, range: [-180, 180], tickvals: [-180, -90, 0, 90, 180], gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 } },
xaxis: { ...xAxisCommon, anchor: 'y' },
yaxis: {
title: { text: 'Mag (dB)', font: { size: isSmallScreen ? 12 : 14 } },
range: this.bodeMagRange || [-70, 0],
gridcolor: gridColor,
zerolinecolor: zeroLineColor,
tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 },
fixedrange: false // 允許使用者手動縮放或點擊 Auto Scale
},
xaxis2: {
type: 'log',
title: { text: 'Freq (Hz)', font: { size: isSmallScreen ? 12 : 14 } },
tickvals: xTicks,
ticktext: xTexts,
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,
tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 },
fixedrange: true // 相位縱軸鎖死,不可縮放
},
shapes: shapes
};
@@ -1035,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;
@@ -1049,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 檔案';
@@ -1057,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);
@@ -1094,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 = '';
}
};
@@ -1109,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);
@@ -1149,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('下載失敗');
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long