Merge remote-tracking branch 'origin/dev-v2-integretimedomain' into integrate/dev-v2-into-main
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
+19
-19
@@ -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
@@ -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)}")
|
||||
|
||||
Generated
+151
-184
@@ -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
@@ -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) / 2,r = (a2 - a1 - 1) / 2。更改數值會自動更新圖表;桌機可用 +/- 或 shift + 滾輪,觸控時按住數值面板上下拖曳。
|
||||
δ = a1 + a2 + 1,r = -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) / 2,r = (a2 - a1 - a0) / 2。
|
||||
a0 = 2^{{ shiftBitsA }},a1 = -a0 - r,a2 = 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
@@ -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('下載失敗');
|
||||
|
||||
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user