Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a36446ce92 | |||
| 750bea70ec | |||
| 7991f1fcba | |||
| b99af5f734 | |||
| 5413062eff | |||
| 6766df8f5e | |||
| b8ec600673 | |||
| 63382f6cf5 | |||
| 3edc4702f3 | |||
| 8caa8918d0 | |||
| 0140b0495c | |||
| 9182220c1d | |||
| 075f20dd81 | |||
| 2f392b13c6 |
+11
@@ -37,8 +37,19 @@ Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Data and Reports
|
||||
*.csv
|
||||
*.png
|
||||
!static/logo.png
|
||||
*.docx
|
||||
|
||||
# Agent Skills
|
||||
.agents/
|
||||
.agents
|
||||
|
||||
# Local Tracker & AI Workspace
|
||||
.scratch/
|
||||
.gemini/
|
||||
|
||||
# SSL certs (private keys)
|
||||
certs/*.pem
|
||||
|
||||
@@ -8,10 +8,12 @@ Backend 使用 FastAPI、NumPy、SciPy 與 Pandas;Frontend 使用 Vue 3、Vite
|
||||
|
||||
- 設計 Lowpass、Highpass、Bandpass、Notch、1P1Z、2P1Z、2P2Z、PID、SOGI 濾波器。
|
||||
- 比較 Ideal Float 與 Fixed-Point 的 Bode 頻率響應。
|
||||
- 支援多個 cascade stages 的頻率響應與時域模擬,並可個別啟用或 bypass。
|
||||
- 手動微調 b/a 係數、a1/a2、δ/r、Q-format 位元數與 fixed-point 整數係數。
|
||||
- 上傳 CSV 時域資料,繪製輸入、Ideal Float 輸出與 Mimic Integer 輸出。
|
||||
- 匯出包含理想浮點與定點整數模擬輸出的 CSV。
|
||||
- 匯出包含理想浮點與定點整數模擬輸出的 CSV;cascade workflow 可匯出乾淨的 4 欄 CSV。
|
||||
- 使用瀏覽器 Web Serial API 將 fixed-point 係數寫入 MCU。
|
||||
- 時域圖表支援 zoom 後以 `start_idx` / `end_idx` 向後端重取可視區間資料,保留 IIR 完整歷史狀態。
|
||||
- 支援 Dark Mode 與響應式 UI。
|
||||
|
||||
## Quick Start
|
||||
@@ -29,16 +31,12 @@ bash certs/generate_cert.sh
|
||||
開啟:
|
||||
|
||||
```text
|
||||
https://localhost:8000/ui/
|
||||
https://192.168.2.58:8000/ui/
|
||||
```
|
||||
|
||||
若從其他主機連線,改用:
|
||||
本專案固定從 FastAPI 提供的 HTTPS UI 使用。不要用 Vite dev server URL 測試 CSV 或 MCU 功能,否則 `/api/...` 可能會回 `Not Found`。
|
||||
|
||||
```text
|
||||
https://<server-ip>:8000/ui/
|
||||
```
|
||||
|
||||
自簽憑證會讓瀏覽器顯示安全性警告,需手動信任或選擇繼續前往。
|
||||
自簽憑證會讓瀏覽器顯示安全性警告,第一次開啟時需手動信任或選擇繼續前往。
|
||||
|
||||
## Project Layout
|
||||
|
||||
@@ -47,7 +45,7 @@ dea_api.py FastAPI 入口與 HTTP route
|
||||
dea/ 後端核心模組
|
||||
bode.py 頻率響應計算
|
||||
config.py 限制值與伺服器端 serial 設定
|
||||
csv_processing.py CSV 快取、驗證、濾波與匯出
|
||||
csv_processing.py CSV 快取、驗證、單濾波器/cascade 濾波與匯出
|
||||
filter_design.py 濾波器設計
|
||||
mcu.py MCU serial port 列表與命令寫入
|
||||
schemas.py Pydantic request schema
|
||||
@@ -66,15 +64,17 @@ chirp_signal.csv CSV 上傳測試範例
|
||||
| Layer | Responsibility |
|
||||
| --- | --- |
|
||||
| FastAPI | 提供 API、serve `/ui/`、套用 LAN 存取限制與安全標頭 |
|
||||
| `dea/` | 濾波器設計、Bode plot、CSV 處理、fixed-point simulation、驗證 |
|
||||
| `dea/` | 濾波器設計、單濾波器/cascade Bode plot、CSV 處理、fixed-point simulation、驗證 |
|
||||
| Vue UI | 管理係數、Q-format、CSV workflow、Plotly 圖表與 Web Serial 狀態 |
|
||||
| Vite build | 將 `src/` 打包到 `static/build/`,由 FastAPI 在 production 提供 |
|
||||
|
||||
CSV workflow:
|
||||
|
||||
1. 前端選擇 CSV。
|
||||
2. 前端呼叫 `POST /api/csv/upload`,後端將檔案暫存並回傳 `file_id`。
|
||||
3. 後續 `POST /api/filter` 與 `POST /api/filter/download` 使用 `file_id`,避免反覆傳大檔。
|
||||
2. 前端只讀取檔案前段內容建立欄位與 preview,避免為了 preview 把大型 CSV 全部載入瀏覽器記憶體。
|
||||
3. 前端呼叫 `POST /api/csv/upload`,後端將檔案暫存並回傳 `file_id`。
|
||||
4. 上傳完成前,filter 按鈕會保持 disabled,避免尚未取得 `file_id` 就送出處理。
|
||||
5. 後續 `POST /api/filter` 與 `POST /api/filter/download` 使用 `file_id`,避免反覆傳大檔。
|
||||
|
||||
CSV 預設暫存在:
|
||||
|
||||
@@ -88,6 +88,12 @@ CSV 預設暫存在:
|
||||
DEA_TEMP_CSV_DIR=/path/to/csv-cache
|
||||
```
|
||||
|
||||
暫存 CSV 預設保留 24 小時。可用環境變數調整,設為 `0` 可停用清理:
|
||||
|
||||
```bash
|
||||
DEA_TEMP_CSV_TTL_SECONDS=86400
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Filter Design
|
||||
@@ -120,6 +126,24 @@ PID, SOGI-Alpha, SOGI-Beta
|
||||
|
||||
桌機可用 Shift + 滑鼠滾輪微調;觸控裝置可按住數值面板上下拖曳。
|
||||
|
||||
|
||||
### Cascade Stages
|
||||
|
||||
UI 以 cascade stage accordion 管理多段濾波器。每個 stage 顯示序號、濾波器型態、Active/Bypass 狀態、係數摘要與 Q-format 摘要;可用上移/下移調整串接順序。
|
||||
|
||||
完整的濾波器設計、浮點係數、fixed-point 係數與 MCU 寫值控制只會出現在目前選取的 active stage accordion 內。同時間只有一個 active stage 承載完整 editor,避免多份表單同時修改同一組全域 active-stage state。展開非 active stage 時,UI 會先切換該 stage 為 active stage。
|
||||
|
||||
送出 Bode 或 CSV 時,前端會先同步目前 active stage,再把所有 stages 依畫面順序打包送到後端。單一 stage 時仍等同 legacy 單濾波器 workflow。
|
||||
|
||||
後端相容兩種路徑:
|
||||
|
||||
| Mode | API Behavior |
|
||||
| --- | --- |
|
||||
| Legacy single filter | `/api/bode`、`/api/bode/compare`、`/api/filter` 仍接受原本的 `b/a/b_int/a_int` 參數 |
|
||||
| Cascade | `/api/bode/compare_cascade` 接受 stages;`/api/filter` 與 `/api/filter/download` 可接受 `stages` form payload |
|
||||
|
||||
Cascade 設計刻意不直接 merge `origin/cascadedfilters` 的整包檔案,而是在 `main` 上重作功能,避免帶入大型 CSV、暫存檔、log、舊文件與 build noise。
|
||||
|
||||
### Fixed-Point Time Simulation
|
||||
|
||||
時域分析同時計算兩條路徑:
|
||||
@@ -141,6 +165,13 @@ Q-format 參數:
|
||||
|
||||
整數模擬採高精度狀態變數架構:前饋項保留在 `Q_in+b` 精度,回授項除以 `A0` 對齊,再於最終輸出依 `Q_out` 縮放。
|
||||
|
||||
|
||||
### Time-Domain LOD Zoom
|
||||
|
||||
時域圖表使用 Plotly zoom / pan 時,前端會把目前可視時間範圍換算成 sample index,透過 `/api/filter` 傳送 `start_idx` 與 `end_idx`。後端仍先用完整訊號計算 IIR 輸出,保留濾波器歷史狀態正確性,再只切出可視區間並 downsample 回傳,避免 zoom in 後只能看到粗略的全域降採樣資料。
|
||||
|
||||
時間軸單位會在首次完整載入時鎖定為 `s`、`ms` 或 `μs`,避免 zoom 後重新換單位造成 Plotly `uirevision` 座標錯位。前端也使用 `isRedrawingTimePlot` 避免 `Plotly.react()` 觸發 relayout callback 造成重繪迴圈。
|
||||
|
||||
### CSV Workflow
|
||||
|
||||
限制與行為:
|
||||
@@ -154,18 +185,50 @@ Q-format 參數:
|
||||
|
||||
若第一欄像 `time`、`t`、`sec`、`x`、`index`,前端會預設選第二欄作為訊號欄位。
|
||||
|
||||
範例檔案:
|
||||
首頁會自動嘗試載入 `.scratch/examples/preset_signals.csv` 作為 preset waveform;若檔案不存在,UI 仍可正常手動上傳 CSV。preset 路徑可用環境變數覆寫:
|
||||
|
||||
```text
|
||||
chirp_signal.csv
|
||||
```bash
|
||||
DEA_PRESET_CSV_PATH=/path/to/preset_signals.csv
|
||||
```
|
||||
|
||||
欄位:
|
||||
範例 CSV 不再放在 Git 追蹤路徑。大型範例請放在本機 ignored 目錄:
|
||||
|
||||
```text
|
||||
.scratch/examples/
|
||||
```
|
||||
|
||||
常用範例欄位格式:
|
||||
|
||||
```text
|
||||
time,value
|
||||
```
|
||||
|
||||
可從歷史 commit 匯出範例到本機 ignored 路徑使用:
|
||||
|
||||
```bash
|
||||
mkdir -p .scratch/examples
|
||||
git show 075f20d:chirp_signal.csv > .scratch/examples/chirp_signal.csv
|
||||
git show 0140b049:chirp_signal_small_s.csv > .scratch/examples/chirp_signal_small_s.csv
|
||||
git show 0140b049:step_signal.csv > .scratch/examples/step_signal.csv
|
||||
git show 8caa8918:static/preset_signals.csv > .scratch/examples/preset_signals.csv
|
||||
```
|
||||
|
||||
`.scratch/` 已被 `.gitignore` 忽略,適合放大型 CSV、手動測試資料與暫存分析檔。
|
||||
|
||||
|
||||
### CSV Export
|
||||
|
||||
`/api/filter/download` 預設保留 legacy 行為:輸出原始 CSV 欄位,並新增 `[欄位]_filtered_ideal` 與 `[欄位]_filtered_fixed`。
|
||||
|
||||
若前端傳送 `compact=true`,後端會輸出專供分析用的 4 欄 CSV:
|
||||
|
||||
| Column | Meaning |
|
||||
| --- | --- |
|
||||
| `Time (s)` | 由 sample index 與前端傳入的 `fs` 計算 |
|
||||
| 原輸入欄位 | 選定訊號欄位原始值 |
|
||||
| `[欄位]_filtered_ideal` | Ideal Float 輸出 |
|
||||
| `[欄位]_filtered_fixed` | Mimic Integer / fixed-point 輸出 |
|
||||
|
||||
### MCU Web Serial
|
||||
|
||||
Web Serial 是主要 MCU 寫入方式,伺服器不需要 serial 權限。
|
||||
@@ -188,10 +251,12 @@ bodeplot=b0,b1,b2,a1,a2
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/design` | 依濾波器參數產生 b/a 係數 |
|
||||
| `POST` | `/api/bode` | 計算單組 b/a 頻率響應 |
|
||||
| `POST` | `/api/bode/compare` | 比較 Ideal Float 與 Fixed-Point 頻率響應 |
|
||||
| `POST` | `/api/bode/compare` | 比較單一濾波器 Ideal Float 與 Fixed-Point 頻率響應 |
|
||||
| `POST` | `/api/bode/compare_cascade` | 比較 cascade stages 的 Ideal Float 與 Fixed-Point 頻率響應 |
|
||||
| `GET` | `/api/csv/preset` | 讀取本機 ignored preset CSV metadata,供首頁自動載入 |
|
||||
| `POST` | `/api/csv/upload` | 上傳 CSV 並回傳 `file_id` |
|
||||
| `POST` | `/api/filter` | 使用 `file_id` 回傳時域圖表資料 |
|
||||
| `POST` | `/api/filter/download` | 使用 `file_id` 匯出結果 CSV |
|
||||
| `POST` | `/api/filter` | 使用 `file_id` 回傳單濾波器或 cascade 時域圖表資料;可傳 `start_idx/end_idx` 做 LOD zoom |
|
||||
| `POST` | `/api/filter/download` | 使用 `file_id` 匯出結果 CSV;可傳 `compact=true` 匯出 4 欄 CSV |
|
||||
| `GET` | `/api/mcu/ports` | 列出伺服器端 serial ports |
|
||||
| `POST` | `/api/mcu/write` | 後端模式寫入 MCU 命令 |
|
||||
|
||||
@@ -201,6 +266,7 @@ bodeplot=b0,b1,b2,a1,a2
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run test:frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
@@ -210,7 +276,11 @@ npm run build
|
||||
npm run dev
|
||||
```
|
||||
|
||||
目前 `vite.config.js` 保留 `main` 設定,不在 Vite 內設定 `/api` proxy。
|
||||
CSV 與 API 流程保留 `dev-v2` 的兩段式設計:先 upload 取得 `file_id`,再用 `file_id` filter 或 download。`vite.config.js` 仍保留 `main` 的 HTTPS deployment 設定,不在 Vite 內設定 `/api` proxy。因此 `npm run dev` 只適合檢查前端畫面;CSV upload、filter API、MCU API 請固定使用:
|
||||
|
||||
```text
|
||||
https://192.168.2.58:8000/ui/
|
||||
```
|
||||
|
||||
### Backend
|
||||
|
||||
@@ -309,9 +379,19 @@ HTTP responses 會加上 CSP、`Referrer-Policy`、`X-Content-Type-Options: nosn
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### CSV 上傳顯示 `Not Found`
|
||||
|
||||
請確認目前網址是:
|
||||
|
||||
```text
|
||||
https://192.168.2.58:8000/ui/
|
||||
```
|
||||
|
||||
如果從 `http://localhost:5173` 或其他 Vite dev server URL 開啟,`/api/csv/upload` 會送到前端 dev server,而不是 FastAPI,因此會回 `Not Found`。
|
||||
|
||||
### 無法連線 MCU
|
||||
|
||||
1. 確認使用 HTTPS 或 localhost。
|
||||
1. 確認使用 `https://192.168.2.58:8000/ui/`。
|
||||
2. 確認瀏覽器支援 Web Serial API。
|
||||
3. 使用 Chrome 或 Edge。
|
||||
4. 檢查 USB 線與 MCU 裝置狀態。
|
||||
|
||||
-500001
File diff suppressed because it is too large
Load Diff
+37
-1
@@ -3,7 +3,7 @@ from fastapi import HTTPException
|
||||
from scipy import signal
|
||||
|
||||
from .config import BODE_MAX_MULTIPLIER, BODE_POINTS
|
||||
from .schemas import BodeCompareParams, BodeParams
|
||||
from .schemas import BodeCascadeParams, BodeCompareParams, BodeParams
|
||||
from .validation import require_finite, validate_coefficients
|
||||
|
||||
|
||||
@@ -47,3 +47,39 @@ def calculate_bode_compare_response(params: BodeCompareParams):
|
||||
"ideal": ideal,
|
||||
"fixed": fixed,
|
||||
}
|
||||
|
||||
|
||||
def calculate_bode_cascade_response(params: BodeCascadeParams):
|
||||
fs_val, f_eval = _frequency_axis(params.fs)
|
||||
h_total_ideal = np.ones(len(f_eval), dtype=complex)
|
||||
h_total_fixed = np.ones(len(f_eval), dtype=complex)
|
||||
any_active = False
|
||||
|
||||
for i, stage in enumerate(params.stages):
|
||||
if not stage.isActive:
|
||||
continue
|
||||
any_active = True
|
||||
b_vals = validate_coefficients(stage.b, f"stages[{i}].b")
|
||||
a_vals = validate_coefficients(stage.a, f"stages[{i}].a")
|
||||
b_fixed_vals = validate_coefficients(stage.b_fixed, f"stages[{i}].b_fixed")
|
||||
a_fixed_vals = validate_coefficients(stage.a_fixed, f"stages[{i}].a_fixed")
|
||||
_, h_ideal = signal.freqz(b_vals, a_vals, worN=f_eval, fs=fs_val)
|
||||
_, h_fixed = signal.freqz(b_fixed_vals, a_fixed_vals, worN=f_eval, fs=fs_val)
|
||||
h_total_ideal *= h_ideal
|
||||
h_total_fixed *= h_fixed
|
||||
|
||||
if not any_active:
|
||||
h_total_ideal = np.ones(len(f_eval), dtype=complex)
|
||||
h_total_fixed = np.ones(len(f_eval), dtype=complex)
|
||||
|
||||
return {
|
||||
"freq": f_eval.tolist(),
|
||||
"ideal": {
|
||||
"mag": (20 * np.log10(np.abs(h_total_ideal) + 1e-12)).tolist(),
|
||||
"phase": np.angle(h_total_ideal, deg=True).tolist(),
|
||||
},
|
||||
"fixed": {
|
||||
"mag": (20 * np.log10(np.abs(h_total_fixed) + 1e-12)).tolist(),
|
||||
"phase": np.angle(h_total_fixed, deg=True).tolist(),
|
||||
},
|
||||
}
|
||||
|
||||
+144
-27
@@ -3,6 +3,7 @@ import os
|
||||
import uuid
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
@@ -16,9 +17,33 @@ TEMP_CSV_DIR = os.environ.get(
|
||||
"DEA_TEMP_CSV_DIR",
|
||||
os.path.join(tempfile.gettempdir(), "diff-eq-analyzer-csv"),
|
||||
)
|
||||
TEMP_CSV_TTL_SECONDS = int(os.environ.get("DEA_TEMP_CSV_TTL_SECONDS", str(24 * 60 * 60)))
|
||||
PRESET_FILE_ID = "00000000-0000-0000-0000-000000000000"
|
||||
PRESET_CSV_PATH = os.environ.get("DEA_PRESET_CSV_PATH", ".scratch/examples/preset_signals.csv")
|
||||
os.makedirs(TEMP_CSV_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def prune_expired_csv_uploads(now=None):
|
||||
if TEMP_CSV_TTL_SECONDS <= 0:
|
||||
return
|
||||
os.makedirs(TEMP_CSV_DIR, exist_ok=True)
|
||||
cutoff = (now or time.time()) - TEMP_CSV_TTL_SECONDS
|
||||
for filename in os.listdir(TEMP_CSV_DIR):
|
||||
if not filename.endswith(".csv"):
|
||||
continue
|
||||
path = os.path.join(TEMP_CSV_DIR, filename)
|
||||
try:
|
||||
if os.path.isfile(path) and os.path.getmtime(path) < cutoff:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def get_cached_file_path(file_id):
|
||||
if file_id == PRESET_FILE_ID:
|
||||
if not os.path.exists(PRESET_CSV_PATH):
|
||||
raise HTTPException(status_code=404, detail="找不到 preset CSV 檔案")
|
||||
return PRESET_CSV_PATH
|
||||
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")
|
||||
@@ -27,6 +52,34 @@ def get_cached_file_path(file_id):
|
||||
return file_path
|
||||
|
||||
|
||||
def preset_csv_metadata(preview_rows=5):
|
||||
if not os.path.exists(PRESET_CSV_PATH):
|
||||
return {"available": False}
|
||||
|
||||
preview_df = pd.read_csv(PRESET_CSV_PATH, nrows=preview_rows)
|
||||
columns = preview_df.columns.tolist()
|
||||
with open(PRESET_CSV_PATH, "rb") as f:
|
||||
rows = max(sum(1 for _ in f) - 1, 0)
|
||||
|
||||
default_col_idx = 0
|
||||
if len(columns) > 1:
|
||||
first = columns[0].lower()
|
||||
if any(kw in first for kw in ("time", "t", "sec", "x", "index", "unnamed")):
|
||||
default_col_idx = 1
|
||||
|
||||
return {
|
||||
"available": True,
|
||||
"file_id": PRESET_FILE_ID,
|
||||
"name": os.path.basename(PRESET_CSV_PATH),
|
||||
"columns": columns,
|
||||
"preview": preview_df.fillna("").astype(str).values.tolist(),
|
||||
"rows": rows,
|
||||
"columns_count": len(columns),
|
||||
"size": os.path.getsize(PRESET_CSV_PATH),
|
||||
"default_col_idx": default_col_idx,
|
||||
}
|
||||
|
||||
|
||||
def downsample_for_plot(index, original, filtered_float, filtered_fixed):
|
||||
total = len(index)
|
||||
if total <= MAX_PLOT_POINTS:
|
||||
@@ -35,7 +88,22 @@ def downsample_for_plot(index, original, filtered_float, filtered_fixed):
|
||||
return index[::step], original[::step], filtered_float[::step], filtered_fixed[::step], step
|
||||
|
||||
|
||||
def _numeric_signal(series, col_name):
|
||||
original_missing = series.isna()
|
||||
numeric_signal = pd.to_numeric(series, errors="coerce")
|
||||
if (numeric_signal.isna() & ~original_missing).any():
|
||||
raise HTTPException(status_code=400, detail=f"欄位 {col_name} 含有非數值資料")
|
||||
numeric_signal = numeric_signal.dropna()
|
||||
if numeric_signal.empty:
|
||||
raise HTTPException(status_code=400, detail=f"欄位 {col_name} 沒有可用的數值資料")
|
||||
x_values = numeric_signal.to_numpy(dtype=float)
|
||||
if not np.isfinite(x_values).all():
|
||||
raise HTTPException(status_code=400, detail=f"欄位 {col_name} 含有非有限數值")
|
||||
return numeric_signal, x_values
|
||||
|
||||
|
||||
async def save_csv_upload(file):
|
||||
prune_expired_csv_uploads()
|
||||
filename = (file.filename or "").lower()
|
||||
if filename and not filename.endswith(".csv"):
|
||||
raise HTTPException(status_code=400, detail="請上傳 CSV 檔案")
|
||||
@@ -118,7 +186,43 @@ def integer_lfilter(b_int, a_int, x_float, shift_in, shift_out, shift_b, shift_a
|
||||
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):
|
||||
def _run_filter_paths(x_values, b_vals, a_vals, b_int, a_int, shift_in, shift_out, shift_b, shift_a, use_round, stages=None):
|
||||
if stages is None:
|
||||
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
|
||||
return y_float, y_fixed
|
||||
|
||||
y_float = x_values
|
||||
y_fixed = x_values
|
||||
any_active = False
|
||||
for stage in stages:
|
||||
if not stage.get("isActive", True):
|
||||
continue
|
||||
any_active = True
|
||||
y_float = signal.lfilter(stage["b"], stage["a"], y_float)
|
||||
if stage.get("b_int") is not None and stage.get("a_int") is not None:
|
||||
y_fixed = integer_lfilter(
|
||||
stage["b_int"],
|
||||
stage["a_int"],
|
||||
y_fixed,
|
||||
stage["shift_in"],
|
||||
stage["shift_out"],
|
||||
stage["shift_b"],
|
||||
stage["shift_a"],
|
||||
stage["use_round"],
|
||||
)
|
||||
else:
|
||||
y_fixed = y_float
|
||||
|
||||
if not any_active:
|
||||
return x_values, x_values
|
||||
return y_float, y_fixed
|
||||
|
||||
|
||||
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, stages=None, start_idx=None, end_idx=None):
|
||||
path = get_cached_file_path(file_id)
|
||||
|
||||
# 預先讀取欄位名稱,避免用 usecols 讀取後找不到原始索引
|
||||
@@ -129,24 +233,29 @@ def filter_preview_response(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=
|
||||
|
||||
# 精準讀取單一欄位,並加上筆數限制 (極大降低記憶體用量與時間)
|
||||
df = pd.read_csv(path, usecols=[col_idx], nrows=MAX_ROWS)
|
||||
x_signal = pd.to_numeric(df[col_to_filter], errors="coerce")
|
||||
x_signal, x_values = _numeric_signal(df[col_to_filter], col_to_filter)
|
||||
|
||||
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_float, y_fixed = _run_filter_paths(
|
||||
x_values, b_vals, a_vals, b_int, a_int,
|
||||
shift_in, shift_out, shift_b, shift_a, use_round, stages=stages,
|
||||
)
|
||||
|
||||
# 路徑 1: 理想浮點數路徑
|
||||
y_float = signal.lfilter(b_vals, a_vals, x_values)
|
||||
total_points = len(x_signal)
|
||||
start_idx_clean = start_idx if isinstance(start_idx, (int, float, str)) else None
|
||||
end_idx_clean = end_idx if isinstance(end_idx, (int, float, str)) else None
|
||||
s_idx = max(0, int(start_idx_clean)) if start_idx_clean is not None else 0
|
||||
e_idx = min(total_points, int(end_idx_clean)) if end_idx_clean is not None else total_points
|
||||
if s_idx >= e_idx:
|
||||
s_idx = 0
|
||||
e_idx = total_points
|
||||
|
||||
# 路徑 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)
|
||||
full_index = x_signal.index.to_numpy()
|
||||
index, original, filtered_float, filtered_fixed, step = downsample_for_plot(
|
||||
full_index[s_idx:e_idx],
|
||||
x_values[s_idx:e_idx],
|
||||
y_float[s_idx:e_idx],
|
||||
y_fixed[s_idx:e_idx],
|
||||
)
|
||||
|
||||
return {
|
||||
"index": index.tolist(),
|
||||
@@ -154,13 +263,13 @@ def filter_preview_response(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=
|
||||
"filtered": filtered_float.tolist(),
|
||||
"filtered_fixed": filtered_fixed.tolist(),
|
||||
"col_name": col_to_filter,
|
||||
"total_points": int(len(df.index)),
|
||||
"total_points": int(total_points),
|
||||
"plot_points": int(len(index)),
|
||||
"downsample_step": int(step),
|
||||
}
|
||||
|
||||
|
||||
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):
|
||||
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, stages=None, fs=100000.0, compact=False):
|
||||
path = get_cached_file_path(file_id)
|
||||
# 匯出時需要原始所有欄位,但仍受限於 MAX_ROWS
|
||||
df = pd.read_csv(path, nrows=MAX_ROWS)
|
||||
@@ -168,17 +277,25 @@ def filtered_csv_text(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None,
|
||||
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)
|
||||
x_signal, x_values = _numeric_signal(df[col_to_filter], col_to_filter)
|
||||
|
||||
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)
|
||||
y_float, y_fixed = _run_filter_paths(
|
||||
x_values, b_vals, a_vals, b_int, a_int,
|
||||
shift_in, shift_out, shift_b, shift_a, use_round, stages=stages,
|
||||
)
|
||||
|
||||
if compact:
|
||||
export_df = pd.DataFrame({
|
||||
"Time (s)": x_signal.index.to_numpy(dtype=float) / fs,
|
||||
col_to_filter: x_values,
|
||||
f"{col_to_filter}_filtered_ideal": y_float,
|
||||
f"{col_to_filter}_filtered_fixed": y_fixed,
|
||||
})
|
||||
else:
|
||||
y_fixed = y_float
|
||||
export_df = df.copy()
|
||||
export_df[f"{col_to_filter}_filtered_ideal"] = pd.Series(y_float, index=x_signal.index)
|
||||
export_df[f"{col_to_filter}_filtered_fixed"] = pd.Series(y_fixed, index=x_signal.index)
|
||||
|
||||
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)
|
||||
export_df.to_csv(csv_buffer, index=False)
|
||||
return csv_buffer.getvalue()
|
||||
|
||||
@@ -47,3 +47,16 @@ class BodeCompareParams(BaseModel):
|
||||
class MCUWriteParams(BaseModel):
|
||||
command: str = Field(min_length=1, max_length=128)
|
||||
port: Optional[str] = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
class CascadeStageParams(BaseModel):
|
||||
b: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
|
||||
a: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
|
||||
b_fixed: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
|
||||
a_fixed: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
|
||||
isActive: bool = True
|
||||
|
||||
|
||||
class BodeCascadeParams(BaseModel):
|
||||
stages: List[CascadeStageParams] = Field(min_length=1)
|
||||
fs: float = Field(gt=0)
|
||||
|
||||
+73
-8
@@ -2,7 +2,7 @@ from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import RedirectResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from dea.bode import calculate_bode_compare_response, calculate_bode_response
|
||||
from dea.bode import calculate_bode_cascade_response, calculate_bode_compare_response, calculate_bode_response
|
||||
from dea.config import (
|
||||
BODE_MAX_MULTIPLIER,
|
||||
BODE_POINTS,
|
||||
@@ -15,11 +15,12 @@ from dea.csv_processing import (
|
||||
downsample_for_plot,
|
||||
filter_preview_response,
|
||||
filtered_csv_text,
|
||||
preset_csv_metadata,
|
||||
save_csv_upload,
|
||||
)
|
||||
from dea.filter_design import design_response
|
||||
from dea.mcu import list_mcu_ports_response, write_mcu_command_response
|
||||
from dea.schemas import BodeCompareParams, BodeParams, DesignParams, MCUWriteParams
|
||||
from dea.schemas import BodeCascadeParams, BodeCompareParams, BodeParams, DesignParams, MCUWriteParams
|
||||
from dea.security import (
|
||||
SECURITY_HEADERS,
|
||||
add_security_headers,
|
||||
@@ -50,6 +51,7 @@ async def restrict_to_lan(request: Request, call_next):
|
||||
|
||||
# 掛載靜態網頁檔案,將預設首頁導向到 /ui/
|
||||
app.mount("/ui", StaticFiles(directory="static", html=True), name="static")
|
||||
app.mount("/uart_terminal", StaticFiles(directory="uart_example", html=True), name="uart_terminal")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@@ -57,6 +59,11 @@ def redirect_to_ui():
|
||||
return RedirectResponse(url="/ui/")
|
||||
|
||||
|
||||
@app.get("/uart_terminal")
|
||||
def redirect_to_uart_terminal():
|
||||
return RedirectResponse(url="/uart_terminal/")
|
||||
|
||||
|
||||
@app.post("/api/design")
|
||||
def design_filter(params: DesignParams):
|
||||
try:
|
||||
@@ -87,6 +94,27 @@ def calculate_bode_compare(params: BodeCompareParams):
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/bode/compare_cascade")
|
||||
def calculate_bode_compare_cascade(params: BodeCascadeParams):
|
||||
try:
|
||||
return calculate_bode_cascade_response(params)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/csv/preset")
|
||||
def preset_csv():
|
||||
try:
|
||||
return preset_csv_metadata()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=400, detail=f"preset CSV 讀取失敗: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/api/csv/upload")
|
||||
async def upload_csv(file: UploadFile = File(...)):
|
||||
try:
|
||||
@@ -99,6 +127,28 @@ async def upload_csv(file: UploadFile = File(...)):
|
||||
raise HTTPException(status_code=400, detail=f"CSV上傳失敗: {str(e)}")
|
||||
|
||||
|
||||
def parse_stages_list(stages_str: str):
|
||||
if not isinstance(stages_str, str) or not stages_str:
|
||||
return None
|
||||
import json
|
||||
stages_raw = json.loads(stages_str)
|
||||
stages_list = []
|
||||
for i, stage in enumerate(stages_raw):
|
||||
stages_list.append({
|
||||
"b": parse_coefficients(stage.get("b_str", ""), f"stages[{i}].b"),
|
||||
"a": parse_coefficients(stage.get("a_str", ""), f"stages[{i}].a"),
|
||||
"b_int": parse_int_coefficients(stage.get("b_int_str", ""), f"stages[{i}].b_int") if stage.get("b_int_str") else None,
|
||||
"a_int": parse_int_coefficients(stage.get("a_int_str", ""), f"stages[{i}].a_int") if stage.get("a_int_str") else None,
|
||||
"shift_in": int(stage.get("shift_in", 14)),
|
||||
"shift_out": int(stage.get("shift_out", 14)),
|
||||
"shift_b": int(stage.get("shift_b", 14)),
|
||||
"shift_a": int(stage.get("shift_a", 14)),
|
||||
"use_round": bool(stage.get("use_round", False)),
|
||||
"isActive": bool(stage.get("isActive", True)),
|
||||
})
|
||||
return stages_list
|
||||
|
||||
|
||||
@app.post("/api/filter")
|
||||
async def filter_csv(
|
||||
file_id: str = Form(...),
|
||||
@@ -112,20 +162,27 @@ async def filter_csv(
|
||||
shift_b: int = Form(14),
|
||||
shift_a: int = Form(14),
|
||||
use_round: bool = Form(False),
|
||||
stages: str = Form(None),
|
||||
start_idx: int = Form(None),
|
||||
end_idx: int = Form(None),
|
||||
):
|
||||
try:
|
||||
b_vals = parse_coefficients(b, "b")
|
||||
a_vals = parse_coefficients(a, "a")
|
||||
|
||||
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
|
||||
b_int_vals = parse_int_coefficients(b_int, "b_int") if isinstance(b_int, str) and b_int else None
|
||||
a_int_vals = parse_int_coefficients(a_int, "a_int") if isinstance(a_int, str) and a_int else None
|
||||
stages_list = parse_stages_list(stages)
|
||||
|
||||
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
|
||||
use_round=use_round,
|
||||
stages=stages_list,
|
||||
start_idx=start_idx,
|
||||
end_idx=end_idx
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -167,20 +224,28 @@ async def filter_csv_download(
|
||||
shift_b: int = Form(14),
|
||||
shift_a: int = Form(14),
|
||||
use_round: bool = Form(False),
|
||||
stages: str = Form(None),
|
||||
fs: float = Form(100000.0),
|
||||
compact: bool = Form(False),
|
||||
):
|
||||
try:
|
||||
b_vals = parse_coefficients(b, "b")
|
||||
a_vals = parse_coefficients(a, "a")
|
||||
|
||||
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
|
||||
b_int_vals = parse_int_coefficients(b_int, "b_int") if isinstance(b_int, str) and b_int else None
|
||||
a_int_vals = parse_int_coefficients(a_int, "a_int") if isinstance(a_int, str) and a_int else None
|
||||
stages_list = parse_stages_list(stages)
|
||||
compact_value = compact if isinstance(compact, bool) else False
|
||||
|
||||
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
|
||||
use_round=use_round,
|
||||
stages=stages_list,
|
||||
fs=fs,
|
||||
compact=compact_value
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>差分方程式分析 (Difference Equation Analyzer)</title>
|
||||
<title>級聯差分方程式分析 (Cascade Difference Equation Analyzer)</title>
|
||||
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
|
||||
<link rel="icon" type="image/png" href="/ui/logo.png">
|
||||
</head>
|
||||
|
||||
+2
-1
@@ -2,7 +2,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite --host 0.0.0.0"
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"test:frontend": "node tests/frontend/cascade-stage-smoke.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
|
||||
+239
-105
@@ -1,35 +1,11 @@
|
||||
<template>
|
||||
<div id="app" class="flex flex-col h-screen overflow-hidden">
|
||||
<!-- 頂部導覽列 -->
|
||||
<header
|
||||
class="bg-white dark:bg-dark border-b border-slate-200 dark:border-gray-800 p-4 shadow-md flex justify-between items-center z-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<img :src="'/ui/logo.png'" alt="Logo" class="h-10 w-10 object-contain rounded-lg shadow-sm">
|
||||
<h1 class="text-xl font-bold text-slate-900 dark:text-gray-100">
|
||||
差分方程式分析 (Difference Equation Analyzer)</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 模式切換按鈕 -->
|
||||
<button @click="toggleDarkMode"
|
||||
class="p-2 rounded-lg bg-slate-100 dark:bg-gray-800 hover:bg-slate-200 dark:hover:bg-gray-700 transition-colors shadow-sm">
|
||||
<svg v-if="isDarkMode" class="w-5 h-5 role-theme-icon" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0 1 1 0 002 0zM2 10a1 1 0 011-1h1a1 1 0 110 2H3a1 1 0 01-1-1zm14 0a1 1 0 011-1h1a1 1 0 110 2h-1a1 1 0 01-1-1zM5.05 5.05a1 1 0 011.414 0l.707.707a1 1 0 11-1.414 1.414l-.707-.707a1 1 0 010-1.414zm9.9 9.9a1 1 0 011.414 0l.707.707a1 1 0 11-1.414 1.414l-.707-.707a1 1 0 010-1.414zM5.05 14.95a1 1 0 010-1.414l.707-.707a1 1 0 011.414 1.414l-.707.707a1 1 0 01-1.414 0zm9.9-9.9a1 1 0 010-1.414l.707-.707a1 1 0 011.414 1.414l-.707.707a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5 text-slate-800" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- 手機版頁籤列 (桌面版隱藏) -->
|
||||
<div
|
||||
class="lg:hidden flex border-b border-slate-200 dark:border-gray-800 bg-white dark:bg-dark z-10 flex-shrink-0">
|
||||
<button @click="mobileTab = 'settings'" :class="mobileTab === 'settings'
|
||||
? 'border-b-2 role-border-primary role-text-primary bg-transparent'
|
||||
: 'text-slate-600 dark:text-gray-400 hover:text-slate-800 dark:hover:text-gray-200'"
|
||||
: 'text-slate-500 dark:text-gray-400 hover:text-slate-700 dark:hover:text-gray-200'"
|
||||
class="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
@@ -37,25 +13,95 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
設定
|
||||
串聯設定
|
||||
</button>
|
||||
<button @click="mobileTab = 'chart'" :class="mobileTab === 'chart'
|
||||
? 'border-b-2 role-border-primary role-text-primary bg-transparent'
|
||||
: 'text-slate-600 dark:text-gray-400 hover:text-slate-800 dark:hover:text-gray-200'"
|
||||
: 'text-slate-500 dark:text-gray-400 hover:text-slate-700 dark:hover:text-gray-200'"
|
||||
class="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z">
|
||||
</path>
|
||||
</svg>
|
||||
圖表
|
||||
串聯響應
|
||||
<span v-if="loadingBode" class="w-2 h-2 rounded-full role-dot-primary animate-ping"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col lg:flex-row flex-1 overflow-hidden">
|
||||
<!-- 左側控制面板 (RWD: 手機版=設定頁籤全螢幕,桌面版=左側邊欄) -->
|
||||
<aside :class="mobileTab === 'settings' ? 'flex' : 'hidden'"
|
||||
class="lg:flex flex-col w-full lg:w-96 h-full bg-white dark:bg-dark border-b lg:border-b-0 lg:border-r border-slate-200 dark:border-gray-800 overflow-y-auto p-5 gap-5 custom-scrollbar transition-colors duration-300">
|
||||
<div class="flex flex-col lg:flex-row flex-1 overflow-hidden relative">
|
||||
<!-- 浮動展開側邊欄按鈕 (僅在桌面版且收折時顯示) -->
|
||||
<button v-show="isSidebarCollapsed" @click="toggleSidebar"
|
||||
class="hidden lg:flex absolute top-6 left-6 z-30 p-2 rounded-lg bg-white dark:bg-dark border border-slate-200/80 dark:border-gray-800 shadow-md text-slate-500 hover:text-slate-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-slate-100 dark:hover:bg-gray-800 transition-all flex-shrink-0"
|
||||
title="展開側邊欄">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<aside :class="[mobileTab === 'settings' ? 'flex' : 'hidden', isSidebarCollapsed ? 'sidebar-collapsed' : '']"
|
||||
class="lg:flex flex-col w-full lg:w-96 h-full bg-white dark:bg-dark border-b lg:border-b-0 lg:border-r border-slate-200 dark:border-gray-800 transition-all duration-300 ease-in-out select-none flex-shrink-0 overflow-y-auto p-5 lg:p-4 gap-5 lg:gap-4 custom-scrollbar">
|
||||
|
||||
<!-- 側邊欄頂部 Logo 與標題及收折按鈕 -->
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-200 dark:border-gray-800/80 flex-shrink-0">
|
||||
<div class="flex items-center gap-2 overflow-hidden">
|
||||
<img :src="'/ui/logo.png'" alt="Logo" style="width: 24px; height: 24px;" class="object-contain rounded-md shadow-sm flex-shrink-0">
|
||||
<h1 class="text-sm font-extrabold text-slate-800 dark:text-gray-200 tracking-wide truncate">
|
||||
級聯差分方程式分析
|
||||
</h1>
|
||||
</div>
|
||||
<button @click="toggleSidebar"
|
||||
class="p-1.5 rounded-lg text-slate-500 hover:text-slate-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-slate-100 dark:hover:bg-gray-800/60 transition-colors flex-shrink-0"
|
||||
title="收折側邊欄">
|
||||
<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="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 系統級參數與 MCU 連線 -->
|
||||
<section class="bg-slate-50 dark:bg-gray-900/40 p-3.5 rounded-xl border border-slate-200 dark:border-gray-800 space-y-3 shadow-sm">
|
||||
<h2 class="text-sm font-semibold text-slate-600 dark:text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z">
|
||||
</path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
全域系統設定
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 dark:text-gray-400 flex justify-between mb-1">
|
||||
<span>系統取樣頻率 fs</span>
|
||||
<span class="role-text-primary font-mono font-bold">{{ formatK(fs) }}</span>
|
||||
</label>
|
||||
<input type="number" v-model.number="fs" @input="debouncedApply" @keydown="onlyNumberKey"
|
||||
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-sm outline-none transition-all text-slate-900 dark:text-gray-100 font-mono">
|
||||
</div>
|
||||
<div class="flex flex-col justify-end">
|
||||
<div v-if="webSerialSupported" class="w-full">
|
||||
<button v-if="!mcuConnected" @click="connectMCUPort"
|
||||
class="w-full text-xs font-bold bg-slate-200 hover:bg-slate-300 dark:bg-gray-800 dark:hover:bg-gray-700 text-slate-700 dark:text-gray-200 px-3 py-2 rounded-lg transition-colors flex items-center justify-center gap-1.5 border border-slate-300 dark:border-gray-700 shadow-sm">
|
||||
連線硬體 MCU
|
||||
</button>
|
||||
<div v-else class="flex items-center justify-between bg-emerald-500/10 dark:bg-emerald-500/5 border border-emerald-500/20 px-3 py-2 rounded-lg">
|
||||
<span class="flex items-center gap-1.5 text-xs font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
MCU 已連線
|
||||
</span>
|
||||
<button @click="disconnectMCUPort" class="text-[10px] font-bold text-red-500 hover:underline">
|
||||
中斷
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-[10px] text-amber-600 dark:text-amber-400 leading-relaxed">
|
||||
此瀏覽器不支援 Web Serial。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="mcuStatus" class="text-xs text-slate-600 dark:text-gray-400">{{ mcuStatus }}</p>
|
||||
</section>
|
||||
|
||||
<!-- 濾波器設計工具 -->
|
||||
<section>
|
||||
@@ -69,40 +115,74 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
濾波器設計
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="writeToMCU"
|
||||
:disabled="writingMCU || !mcuConnected"
|
||||
class="primary-action text-[10px] font-bold role-bg-primary role-hover-primary role-text-on-fill role-border-primary border px-3 py-1 rounded-full transition-colors uppercase disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{{ writingMCU ? '寫入中' : '寫值到 MCU' }}
|
||||
</button>
|
||||
<button @click="resetFilterParams"
|
||||
class="text-[10px] font-bold border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 px-3 py-1 rounded-full transition-colors uppercase">重設設計</button>
|
||||
串聯濾波器設計
|
||||
</div>
|
||||
<div class="flex items-center gap-2"></div>
|
||||
</h2>
|
||||
<div class="mb-3 space-y-2">
|
||||
<div v-if="webSerialSupported" class="flex items-center gap-2">
|
||||
<button v-if="!mcuConnected" @click="connectMCUPort"
|
||||
class="flex-1 text-xs font-bold role-bg-primary role-hover-primary role-text-on-fill role-border-primary border px-3 py-2 rounded transition-colors uppercase">
|
||||
選擇連接埠並連線
|
||||
<div class="mb-3 p-3 rounded-lg border border-slate-200 dark:border-gray-800 bg-slate-50 dark:bg-gray-900/50 space-y-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs font-semibold text-slate-600 dark:text-gray-400 uppercase tracking-wide">Cascade Stages</span>
|
||||
<button @click="addCascadeStage"
|
||||
class="text-[10px] font-bold role-bg-primary role-hover-primary role-text-on-fill px-3 py-1.5 rounded-full transition-colors">
|
||||
新增 Stage
|
||||
</button>
|
||||
<template v-else>
|
||||
<span class="flex items-center gap-1.5 flex-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
MCU 已連線
|
||||
</span>
|
||||
<button @click="disconnectMCUPort"
|
||||
class="text-[10px] font-bold bg-slate-200 dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-300 dark:hover:bg-gray-700 px-2 py-2 rounded transition-colors uppercase">
|
||||
斷線
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="!webSerialSupported" class="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg px-3 py-2">
|
||||
⚠ 此瀏覽器不支援 Web Serial。MCU 寫值功能需使用 Chrome 或 Edge,且透過 HTTPS 連線。
|
||||
</p>
|
||||
<p v-if="mcuStatus" class="text-xs text-slate-600 dark:text-gray-400">{{ mcuStatus }}</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(stage, idx) in cascadeStages" :key="stage.id"
|
||||
:data-cascade-stage-index="idx"
|
||||
:style="cascadeStageDragStyle(idx)"
|
||||
:class="{
|
||||
'stage-dragging': draggingCascadeStageIndex === idx,
|
||||
'stage-drag-target': dragOverCascadeStageIndex === idx && draggingCascadeStageIndex !== null && draggingCascadeStageIndex !== idx,
|
||||
}"
|
||||
class="rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden transition-all">
|
||||
<div class="flex items-center gap-2 px-2 py-2">
|
||||
<button type="button" @pointerdown.stop.prevent="startCascadeStageDrag(idx, $event)"
|
||||
title="拖曳排序"
|
||||
class="stage-drag-handle px-2 py-1 rounded bg-slate-200 dark:bg-gray-700 text-slate-700 dark:text-gray-300 text-xs touch-none select-none cursor-grab active:cursor-grabbing">
|
||||
↕
|
||||
</button>
|
||||
<span @click.stop="toggleCascadeStage(idx)"
|
||||
class="h-[2.25rem] rounded text-[11px] font-bold transition-all duration-200 border cursor-pointer inline-flex items-center justify-center w-16 flex-shrink-0 select-none"
|
||||
:class="stage.isActive
|
||||
? 'z-10'
|
||||
: 'bg-slate-200 dark:bg-gray-800 text-slate-400 dark:text-gray-500 border-slate-300 dark:border-gray-700 shadow-inner'"
|
||||
:style="stage.isActive ? 'color: #052e16 !important; background-color: #4ade80 !important; border-color: #86efac !important;' : ''"
|
||||
:title="stage.isActive ? '設為 Bypass' : '設為 Active'">
|
||||
{{ stage.isActive ? 'Active' : 'Bypass' }}
|
||||
</span>
|
||||
<button type="button" @click="selectCascadeStage(idx)"
|
||||
:class="idx === activeCascadeStageIndex ? 'role-bg-primary role-text-on-fill' : 'text-slate-700 dark:text-gray-300'"
|
||||
class="flex-1 min-w-0 px-3 py-1.5 rounded text-left text-xs font-semibold transition-colors">
|
||||
<span class="block truncate">{{ idx + 1 }}. {{ stageTitle(idx) }}</span>
|
||||
</button>
|
||||
<button type="button" @click="toggleCascadeStageExpanded(idx)"
|
||||
class="px-2 py-1 rounded bg-slate-200 dark:bg-gray-700 text-slate-700 dark:text-gray-300 text-xs">
|
||||
{{ stage.isExpanded ? '收合' : '展開' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="stage.isExpanded && draggingCascadeStageIndex === null" class="px-3 pb-3 text-xs text-slate-600 dark:text-gray-400 border-t border-slate-100 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between gap-2 pt-2">
|
||||
<span class="truncate">{{ cascadeStageSummary(stage) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 pt-2">
|
||||
<button v-if="idx === activeCascadeStageIndex" type="button" @click="writeToMCU"
|
||||
:disabled="writingMCU || !mcuConnected"
|
||||
class="px-3 py-1 rounded-full role-bg-primary role-hover-primary role-text-on-fill disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{{ writingMCU ? '寫入中' : `寫入 Stage ${idx + 1}` }}
|
||||
</button>
|
||||
<button v-if="idx === activeCascadeStageIndex" type="button" @click="resetFilterParams"
|
||||
class="px-3 py-1 rounded-full bg-slate-200 dark:bg-gray-700 text-slate-700 dark:text-gray-300">
|
||||
重設
|
||||
</button>
|
||||
<button type="button" @click="removeCascadeStage(idx)" :disabled="cascadeStages.length <= 1"
|
||||
class="px-3 py-1 rounded-full bg-slate-200 dark:bg-gray-700 text-slate-700 dark:text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
刪除
|
||||
</button>
|
||||
</div>
|
||||
<StageEditor v-if="idx === activeCascadeStageIndex" :stage="stage" :stage-index="idx"
|
||||
class="mt-3 pt-3 border-t border-slate-100 dark:border-gray-700">
|
||||
|
||||
<select v-model="filterType" @change="onFilterTypeChange"
|
||||
class="w-full bg-slate-50 dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base role-focus-primary focus:ring-1 role-focus-ring-primary outline-none transition-all mb-3 text-slate-900 dark:text-gray-100 truncate">
|
||||
<option v-for="opt in filterOptions" :key="opt" :value="opt">{{ opt }}</option>
|
||||
@@ -110,7 +190,7 @@
|
||||
|
||||
<!-- 動態參數區 -->
|
||||
<div v-if="filterType !== '(無) 手動自訂'"
|
||||
class="space-y-3 bg-slate-100 dark:bg-gray-900 p-4 rounded-lg border border-slate-200 dark:border-gray-800 shadow-inner">
|
||||
class="stage-design-params space-y-3 mb-4">
|
||||
<!-- Lowpass / Highpass -->
|
||||
<div v-if="['Lowpass (低通)', 'Highpass (高通)'].includes(filterType)">
|
||||
<label class="text-sm text-slate-600 dark:text-gray-400 flex justify-between mb-1"><span>截止頻率
|
||||
@@ -241,11 +321,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr class="border-slate-200 dark:border-gray-800">
|
||||
<hr class="my-4 border-slate-200 dark:border-gray-800">
|
||||
|
||||
<!-- 系統參數 -->
|
||||
<!-- Stage 增益 -->
|
||||
<section>
|
||||
<h2 class="text-sm font-semibold text-slate-600 dark:text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -255,16 +334,8 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
系統參數</h2>
|
||||
Stage 增益</h2>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-sm text-slate-600 dark:text-gray-400 flex justify-between mb-1"><span>取樣頻率
|
||||
fs
|
||||
(Hz)</span><span class="role-text-primary">{{ formatK(fs)
|
||||
}}</span></label>
|
||||
<input type="number" v-model.number="fs" @input="debouncedApply" @keydown="onlyNumberKey"
|
||||
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-slate-600 dark:text-gray-400 flex justify-between mb-1"><span>系統增益
|
||||
K (System Gain)</span><span class="role-text-primary">{{ systemGain
|
||||
@@ -276,7 +347,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr class="border-slate-200 dark:border-gray-800">
|
||||
<hr class="my-4 border-slate-200 dark:border-gray-800">
|
||||
|
||||
<!-- 係數手動設定 -->
|
||||
<section class="coeff-section">
|
||||
@@ -477,7 +548,7 @@
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<hr class="border-slate-200 dark:border-gray-800">
|
||||
<hr class="my-4 border-slate-200 dark:border-gray-800">
|
||||
|
||||
<!-- 定點數轉換 -->
|
||||
<section class="fixed-section">
|
||||
@@ -660,13 +731,19 @@
|
||||
</div>
|
||||
<p class="text-[10px] text-slate-500 dark:text-gray-500 mt-3 italic">註:b 係數乘以 2^{{ shiftBitsB }},a
|
||||
係數乘以 2^{{ shiftBitsA }} 後四捨五入,可用於定點數 DSP 實作。</p>
|
||||
</section>
|
||||
</StageEditor>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</aside>
|
||||
|
||||
<!-- 右側主視窗 (RWD: 手機版=圖表頁籤全螢幕,桌面版=右側主區) -->
|
||||
<main :class="mobileTab === 'chart' ? 'flex' : 'hidden'"
|
||||
class="lg:flex flex-1 flex-col overflow-y-auto bg-slate-50 dark:bg-[#0f0f0f] lg:p-6 gap-0 lg:gap-6 custom-scrollbar relative transition-colors duration-300">
|
||||
class="lg:flex flex-1 flex-col overflow-y-auto bg-slate-50 dark:bg-[#0f0f0f] lg:py-6 lg:px-20 gap-0 lg:gap-6 custom-scrollbar relative transition-colors duration-300">
|
||||
<div v-if="globalError"
|
||||
class="absolute top-4 right-6 role-bg-error-soft border role-border-error role-text-error role-text-on-fill px-4 py-3 rounded-lg shadow-lg text-base z-50 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -691,7 +768,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 19V5m0 14h16M7 16l3-4 3 2 4-7"></path>
|
||||
</svg>
|
||||
頻率響應 (Frequency Response)</h3>
|
||||
串聯濾波器總頻率響應 (Cascade Bode Plot)</h3>
|
||||
<div v-if="loadingBode"
|
||||
class="text-sm role-text-primary role-bg-primary-soft px-3 py-1.5 rounded border role-border-primary-soft flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full role-dot-primary animate-ping"></span>
|
||||
@@ -709,17 +786,44 @@
|
||||
<div
|
||||
class="chart-panel bg-white dark:bg-dark lg:rounded-xl border-b lg:border border-slate-100 lg:border-slate-200 dark:border-gray-100 lg:dark:border-gray-800 lg:shadow-md p-0 lg:p-1 flex-shrink-0 relative transition-colors duration-300">
|
||||
<div
|
||||
class="chart-panel-header px-5 py-4 border-b border-slate-100 dark:border-gray-800 bg-white dark:bg-dark backdrop-blur-sm z-10 relative">
|
||||
class="chart-panel-header px-5 py-4 border-b border-slate-100 dark:border-gray-800 bg-white dark:bg-dark backdrop-blur-sm z-10 relative flex justify-between items-center">
|
||||
<h3 class="font-bold text-slate-800 dark:text-gray-200 tracking-wide flex items-center gap-2">
|
||||
<svg class="w-5 h-5 role-text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 12h4l2-6 4 12 2-6h4"></path>
|
||||
</svg>
|
||||
時域訊號探討</h3>
|
||||
級聯時域訊號模擬 (Multi-Stage Time-Domain Simulation)</h3>
|
||||
</div>
|
||||
<div class="p-6 z-10 relative bg-white dark:bg-dark transition-colors duration-300">
|
||||
<p class="text-base text-slate-600 dark:text-gray-400 mb-4">請上傳包含時域訊號的 CSV 檔案,系統會使用上述設定的差分濾波器。
|
||||
<p class="text-base text-slate-600 dark:text-gray-400 mb-4">可上傳時域輸入訊號的 CSV 檔案。訊號會依序通過所有啟用的 cascade stages,並比較原始輸入、Ideal Float 與 Mimic Integer 輸出。
|
||||
</p>
|
||||
<div v-if="csvPreview.length > 0"
|
||||
class="csv-preview-panel mb-6 rounded-lg border border-slate-100 dark:border-gray-800 bg-slate-50 dark:bg-gray-900/50 overflow-hidden">
|
||||
<button type="button" @click="isCsvPreviewExpanded = !isCsvPreviewExpanded"
|
||||
class="csv-preview-toggle w-full flex items-center justify-between gap-3 px-4 py-3 text-left text-sm font-semibold text-slate-700 dark:text-gray-300 bg-transparent hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors">
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 role-text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
|
||||
</svg>
|
||||
CSV Preview
|
||||
</span>
|
||||
<span class="text-xs text-slate-500 dark:text-gray-400">{{ isCsvPreviewExpanded ? '收合' : '展開' }}</span>
|
||||
</button>
|
||||
<div v-show="isCsvPreviewExpanded" class="overflow-x-auto border-t border-slate-100 dark:border-gray-800">
|
||||
<table class="min-w-full text-xs text-left">
|
||||
<thead class="bg-white dark:bg-gray-800 text-slate-500 dark:text-gray-400">
|
||||
<tr>
|
||||
<th v-for="(col, idx) in csvColumns" :key="idx" class="px-3 py-2 font-semibold whitespace-nowrap">{{ col }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-gray-800 text-slate-700 dark:text-gray-300 font-mono">
|
||||
<tr v-for="(row, rowIdx) in csvPreview" :key="rowIdx">
|
||||
<td v-for="(cell, cellIdx) in row" :key="cellIdx" class="px-3 py-2 whitespace-nowrap">{{ cell }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center flex-wrap gap-4 mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg border border-slate-100 dark:border-gray-700 shadow-sm">
|
||||
<label
|
||||
@@ -732,11 +836,15 @@
|
||||
<input type="file" ref="fileInput" accept=".csv" @change="handleFileUpload"
|
||||
class="hidden">
|
||||
</label>
|
||||
<span v-if="csvFile" class="text-base role-text-primary font-mono">{{
|
||||
csvFile.name }}</span>
|
||||
<span v-if="csvFile || usingPresetCsv" class="text-base role-text-primary font-mono">{{
|
||||
csvFile ? csvFile.name : presetCsvName }}</span>
|
||||
<span v-if="csvInfo"
|
||||
class="text-sm text-slate-600 dark:text-gray-400 font-mono">
|
||||
{{ csvInfo.rows }} 筆 / {{ csvInfo.columns }} 欄
|
||||
{{ csvInfo.rowsLabel || csvInfo.rows }} 筆 / {{ csvInfo.columns }} 欄
|
||||
</span>
|
||||
<span v-if="csvUploading"
|
||||
class="text-sm role-text-primary font-semibold">
|
||||
上傳中...
|
||||
</span>
|
||||
<span v-if="csvParseError"
|
||||
class="text-sm role-text-error font-semibold">
|
||||
@@ -754,19 +862,15 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button v-if="csvFile" @click="processFilter"
|
||||
<button v-if="csvFile || usingPresetCsv" @click="processFilter"
|
||||
class="role-bg-primary role-hover-primary role-text-on-fill px-6 py-3 rounded-lg text-base font-semibold transition-all shadow-md role-shadow-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="loadingFilter || csvColumns.length === 0">
|
||||
{{ loadingFilter ? '處理中...' : '執行當前濾波器' }}
|
||||
:disabled="csvUploading || loadingFilter || csvColumns.length === 0">
|
||||
{{ csvUploading ? '上傳中...' : (loadingFilter ? '處理中...' : '執行當前濾波器') }}
|
||||
</button>
|
||||
|
||||
<button v-if="filterDone" @click="downloadCsv"
|
||||
class="ml-auto bg-slate-700 hover:bg-slate-600 role-text-on-fill px-5 py-3 rounded-lg text-base font-semibold transition-all flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
匯出結果
|
||||
<button v-if="csvFile && presetCsvAvailable" @click="restorePresetWaveforms"
|
||||
class="bg-slate-200 dark:bg-gray-700 hover:bg-slate-300 dark:hover:bg-gray-600 text-slate-700 dark:text-gray-200 px-4 py-3 rounded-lg text-base font-semibold transition-all">
|
||||
使用 preset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -780,7 +884,7 @@
|
||||
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)
|
||||
級聯量化與捨去設定 (Cascade Simulation Settings)
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 lg:gap-6">
|
||||
<div>
|
||||
@@ -852,12 +956,23 @@
|
||||
</div>
|
||||
|
||||
<!-- 波形比較圖 -->
|
||||
<h4 v-if="filterDone" class="text-base font-bold text-slate-800 dark:text-gray-200 mb-3">波形比較圖
|
||||
</h4>
|
||||
<!-- 只有圖表繪圖區可水平滾動,卡片外框不動 -->
|
||||
<div class="overflow-x-auto bg-white dark:bg-dark">
|
||||
<div id="timePlot" class="min-w-[580px] w-full h-[400px] bg-white dark:bg-dark"
|
||||
v-show="filterDone"></div>
|
||||
<h4 v-if="filterDone" class="text-base font-bold text-slate-800 dark:text-gray-200 mb-3">波形比較圖</h4>
|
||||
<div class="relative" v-if="filterDone">
|
||||
<!-- 只有圖表繪圖區可水平滾動,卡片外框不動 -->
|
||||
<div class="overflow-x-auto bg-white dark:bg-dark rounded-lg">
|
||||
<div id="timePlot" class="min-w-[580px] w-full h-[400px] bg-white dark:bg-dark"></div>
|
||||
</div>
|
||||
|
||||
<!-- 匯出 CSV 按鈕 -->
|
||||
<button @click="downloadCsv"
|
||||
class="absolute bottom-4 right-4 z-20 bg-slate-700 hover:bg-slate-600 active:scale-95 text-white px-4 py-2 rounded-lg text-sm font-semibold transition-all flex items-center gap-2 shadow-md border border-slate-600/50"
|
||||
title="匯出 CSV">
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
匯出 CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!filterDone && !loadingFilter && csvPreview.length === 0"
|
||||
@@ -868,7 +983,7 @@
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z">
|
||||
</path>
|
||||
</svg>
|
||||
<p>請上傳包含時間序列的 CSV 檔案進行測試</p>
|
||||
<p>請上傳包含時間序列的 CSV 檔案以測試時域級聯濾波效果</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -876,8 +991,27 @@
|
||||
</div>
|
||||
<!-- 全域置底版權宣告 -->
|
||||
<footer
|
||||
class="bg-white dark:bg-dark border-t border-slate-200 dark:border-gray-800 py-2 text-center z-20 transition-colors duration-300">
|
||||
<p class="text-xs text-slate-500 dark:text-gray-600">© 2026 喆富創新科技股份有限公司. All rights reserved.</p>
|
||||
class="bg-white dark:bg-dark border-t border-slate-200 dark:border-gray-800 py-2 px-4 z-20 transition-colors duration-300 flex items-center justify-between relative min-h-[44px]">
|
||||
<button @click="toggleDarkMode"
|
||||
class="p-2 rounded-lg bg-slate-100 hover:bg-slate-200 dark:bg-gray-800 dark:hover:bg-gray-700 text-slate-700 dark:text-gray-200 transition-colors shadow-sm border border-slate-200/50 dark:border-gray-700/50 flex items-center gap-1.5"
|
||||
:title="isDarkMode ? '切換淺色模式' : '切換深色模式'">
|
||||
<svg v-if="isDarkMode" class="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0 1 1 0 002 0zM2 10a1 1 0 011-1h1a1 1 0 110 2H3a1 1 0 01-1-1zm14 0a1 1 0 011-1h1a1 1 0 110 2h-1a1 1 0 01-1-1zM5.05 5.05a1 1 0 011.414 0l.707.707a1 1 0 11-1.414 1.414l-.707-.707a1 1 0 010-1.414zm9.9 9.9a1 1 0 011.414 0l.707.707a1 1 0 11-1.414 1.414l-.707-.707a1 1 0 010-1.414zM5.05 14.95a1 1 0 010-1.414l.707-.707a1 1 0 011.414 1.414l-.707.707a1 1 0 01-1.414 0zm9.9-9.9a1 1 0 010-1.414l.707-.707a1 1 0 011.414 1.414l-.707.707a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4 text-slate-700" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
||||
</svg>
|
||||
<span class="text-xs font-semibold hidden md:inline">{{ isDarkMode ? '切換淺色模式' : '切換深色模式' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- 置中的版權宣告 -->
|
||||
<p class="text-xs text-slate-500 dark:text-gray-600 absolute left-1/2 transform -translate-x-1/2 pointer-events-none">
|
||||
© 2026 喆富創新科技股份有限公司. All rights reserved.
|
||||
</p>
|
||||
|
||||
<!-- 右側佔位 -->
|
||||
<div class="w-[32px] md:w-[120px]"></div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+498
-21
@@ -1,3 +1,5 @@
|
||||
import StageEditor from './components/StageEditor.vue';
|
||||
import { computeStageFixedCoeffs, createCascadeFilterPayload, normalizeCascadeStage } from './cascade-stage.js';
|
||||
const DEFAULT_FILTER_TYPE = "Lowpass (低通)";
|
||||
const MANUAL_FILTER_TYPE = "(無) 手動自訂";
|
||||
const DEFAULT_FS = 100000.0;
|
||||
@@ -18,14 +20,32 @@ const DEFAULT_PARAMS = {
|
||||
|
||||
const cloneDefaultParams = () => ({ ...DEFAULT_PARAMS });
|
||||
const zero7 = () => [...ZERO_7];
|
||||
const createStageId = () => `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
StageEditor,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
fs: DEFAULT_FS,
|
||||
b_str: DEFAULT_B_STR,
|
||||
a_str: DEFAULT_A_STR,
|
||||
filterType: DEFAULT_FILTER_TYPE,
|
||||
cascadeStages: [],
|
||||
activeCascadeStageIndex: 0,
|
||||
draggingCascadeStageIndex: null,
|
||||
dragOverCascadeStageIndex: null,
|
||||
stageDragPointerId: null,
|
||||
draggedCascadeStageId: null,
|
||||
stageDragStartX: 0,
|
||||
stageDragStartY: 0,
|
||||
stageDragCurrentX: 0,
|
||||
stageDragCurrentY: 0,
|
||||
zoomStartIdx: null, zoomEndIdx: null,
|
||||
timePlotUnit: null,
|
||||
timePlotMultiplier: null,
|
||||
isRedrawingTimePlot: false,
|
||||
filterOptions: [
|
||||
"Lowpass (低通)", "Highpass (高通)", "Bandpass (帶通)",
|
||||
"Notch (陷波器)", "1P1Z (一極一零)", "2P1Z (二極一零)", "2P2Z (二極二零)",
|
||||
@@ -44,6 +64,7 @@ export default {
|
||||
|
||||
loadingBode: false, bodeTimeout: null, globalError: null,
|
||||
csvFile: null, csvFileId: null, csvColumns: [], csvPreview: [], csvInfo: null, csvParseError: null,
|
||||
csvUploading: false, presetCsvAvailable: false, presetCsvName: '', usingPresetCsv: false,
|
||||
isCsvPreviewExpanded: true,
|
||||
selectedColumn: 0, loadingFilter: false, filterDone: false, timePlotData: null,
|
||||
mobileTab: 'settings', // 手機版頁籤:'settings' | 'chart'
|
||||
@@ -81,6 +102,7 @@ export default {
|
||||
writingMCU: false,
|
||||
mcuStatus: '',
|
||||
saveSettingsTimeout: null,
|
||||
isSidebarCollapsed: localStorage.getItem('dea_sidebar_collapsed') === 'true',
|
||||
bodeMagRange: null, // [min, max] for Y-axis
|
||||
}
|
||||
},
|
||||
@@ -265,7 +287,16 @@ export default {
|
||||
aFineStep: 'debouncedSaveSettings',
|
||||
fixedAFineStep: 'debouncedSaveSettings',
|
||||
b_int_str() { this.debouncedProcessFilter(); },
|
||||
a_int_str() { this.debouncedProcessFilter(); }
|
||||
a_int_str() { this.debouncedProcessFilter(); },
|
||||
selectedColumn(newVal, oldVal) {
|
||||
if (newVal !== oldVal) {
|
||||
this.zoomStartIdx = null;
|
||||
this.zoomEndIdx = null;
|
||||
this.timePlotUnit = null;
|
||||
this.timePlotMultiplier = null;
|
||||
this.debouncedProcessFilter();
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.isTouchInput = window.matchMedia?.('(pointer: coarse)').matches || false;
|
||||
@@ -285,8 +316,10 @@ export default {
|
||||
if (!this.webSerialSupported) {
|
||||
this.mcuStatus = '此瀏覽器不支援 Web Serial(需使用 Chrome 或 Edge,且透過 HTTPS 連線)';
|
||||
}
|
||||
this.ensureCascadeStages();
|
||||
this.updateBodeMagRange();
|
||||
this.applyFilterDesign();
|
||||
this.loadPresetWaveforms();
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('pointerdown', this.rememberPointerType);
|
||||
@@ -302,6 +335,325 @@ export default {
|
||||
this.stopShiftOutDrag();
|
||||
},
|
||||
methods: {
|
||||
toggleSidebar() {
|
||||
this.isSidebarCollapsed = !this.isSidebarCollapsed;
|
||||
localStorage.setItem('dea_sidebar_collapsed', this.isSidebarCollapsed ? 'true' : 'false');
|
||||
const startTime = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
if (Date.now() - startTime > 400) {
|
||||
clearInterval(interval);
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
this.resizePlots();
|
||||
}
|
||||
}, 30);
|
||||
},
|
||||
resizePlots() {
|
||||
const bode = document.getElementById('bodePlot');
|
||||
const time = document.getElementById('timePlot');
|
||||
if (bode && window.Plotly) window.Plotly.Plots.resize(bode);
|
||||
if (time && window.Plotly) window.Plotly.Plots.resize(time);
|
||||
},
|
||||
stageTitle(index) {
|
||||
const stage = this.cascadeStages[index];
|
||||
return stage?.filterType || `Stage ${index + 1}`;
|
||||
},
|
||||
cloneStage(stage) {
|
||||
return JSON.parse(JSON.stringify(stage));
|
||||
},
|
||||
captureCurrentStage() {
|
||||
return {
|
||||
id: this.cascadeStages[this.activeCascadeStageIndex]?.id || createStageId(),
|
||||
isActive: this.cascadeStages[this.activeCascadeStageIndex]?.isActive ?? true,
|
||||
isExpanded: this.cascadeStages[this.activeCascadeStageIndex]?.isExpanded ?? true,
|
||||
filterType: this.filterType,
|
||||
b_str: this.b_str,
|
||||
a_str: this.a_str,
|
||||
systemGain: this.systemGain,
|
||||
params: { ...this.params },
|
||||
baseB: [...this.baseB],
|
||||
baseA: [...this.baseA],
|
||||
bSliders: [...this.bSliders],
|
||||
aSliders: [...this.aSliders],
|
||||
shiftBitsB: this.shiftBitsB,
|
||||
shiftBitsA: this.shiftBitsA,
|
||||
fixedOverrides: {
|
||||
a: { ...this.fixedOverrides.a },
|
||||
b: { ...this.fixedOverrides.b },
|
||||
},
|
||||
outOfRangeB: [...this.outOfRangeB],
|
||||
outOfRangeA: [...this.outOfRangeA],
|
||||
aFineStep: this.aFineStep,
|
||||
fixedAFineStep: this.fixedAFineStep,
|
||||
activeCoeffAdjustment: this.activeCoeffAdjustment,
|
||||
};
|
||||
},
|
||||
applyStageToControls(stage) {
|
||||
stage = normalizeCascadeStage(stage);
|
||||
this.filterType = stage.filterType;
|
||||
this.b_str = stage.b_str;
|
||||
this.a_str = stage.a_str;
|
||||
this.systemGain = stage.systemGain;
|
||||
this.params = { ...stage.params };
|
||||
this.baseB = [...stage.baseB];
|
||||
this.baseA = [...stage.baseA];
|
||||
this.bSliders = [...stage.bSliders];
|
||||
this.aSliders = [...stage.aSliders];
|
||||
this.shiftBitsB = stage.shiftBitsB;
|
||||
this.shiftBitsA = stage.shiftBitsA;
|
||||
this.fixedOverrides = {
|
||||
a: { ...stage.fixedOverrides.a },
|
||||
b: { ...stage.fixedOverrides.b },
|
||||
};
|
||||
this.outOfRangeB = [...stage.outOfRangeB];
|
||||
this.outOfRangeA = [...stage.outOfRangeA];
|
||||
this.aFineStep = stage.aFineStep;
|
||||
this.fixedAFineStep = stage.fixedAFineStep;
|
||||
this.activeCoeffAdjustment = stage.activeCoeffAdjustment;
|
||||
},
|
||||
ensureCascadeStages() {
|
||||
if (!Array.isArray(this.cascadeStages)) this.cascadeStages = [];
|
||||
if (this.cascadeStages.length === 0) {
|
||||
this.cascadeStages = [this.captureCurrentStage()];
|
||||
this.activeCascadeStageIndex = 0;
|
||||
}
|
||||
this.cascadeStages = this.cascadeStages.map((stage, index) => normalizeCascadeStage(stage, {
|
||||
isExpanded: index === this.activeCascadeStageIndex,
|
||||
filterType: this.filterType,
|
||||
b_str: this.b_str,
|
||||
a_str: this.a_str,
|
||||
systemGain: this.systemGain,
|
||||
params: this.params,
|
||||
baseB: this.baseB,
|
||||
baseA: this.baseA,
|
||||
bSliders: this.bSliders,
|
||||
aSliders: this.aSliders,
|
||||
shiftBitsB: this.shiftBitsB,
|
||||
shiftBitsA: this.shiftBitsA,
|
||||
fixedOverrides: this.fixedOverrides,
|
||||
outOfRangeB: this.outOfRangeB,
|
||||
outOfRangeA: this.outOfRangeA,
|
||||
aFineStep: this.aFineStep,
|
||||
fixedAFineStep: this.fixedAFineStep,
|
||||
activeCoeffAdjustment: this.activeCoeffAdjustment,
|
||||
}));
|
||||
if (this.activeCascadeStageIndex < 0 || this.activeCascadeStageIndex >= this.cascadeStages.length) {
|
||||
this.activeCascadeStageIndex = 0;
|
||||
}
|
||||
},
|
||||
saveActiveCascadeStage() {
|
||||
this.ensureCascadeStages();
|
||||
this.cascadeStages.splice(this.activeCascadeStageIndex, 1, this.captureCurrentStage());
|
||||
},
|
||||
selectCascadeStage(index) {
|
||||
if (index < 0 || index >= this.cascadeStages.length || index === this.activeCascadeStageIndex) return;
|
||||
this.saveActiveCascadeStage();
|
||||
this.activeCascadeStageIndex = index;
|
||||
this.cascadeStages.forEach((stage, stageIndex) => {
|
||||
stage.isExpanded = stageIndex === index;
|
||||
});
|
||||
this.applyStageToControls(this.cloneStage(this.cascadeStages[index]));
|
||||
this.updateBodeMagRange();
|
||||
this.updateBodePlot({ switchToChart: false });
|
||||
},
|
||||
addCascadeStage() {
|
||||
this.saveActiveCascadeStage();
|
||||
const newStage = this.cloneStage(this.cascadeStages[this.activeCascadeStageIndex]);
|
||||
newStage.id = createStageId();
|
||||
newStage.isActive = true;
|
||||
newStage.isExpanded = true;
|
||||
this.cascadeStages.push(newStage);
|
||||
this.selectCascadeStage(this.cascadeStages.length - 1);
|
||||
},
|
||||
removeCascadeStage(index) {
|
||||
if (this.cascadeStages.length <= 1) {
|
||||
this.globalError = '至少需要保留一個濾波器階段';
|
||||
return;
|
||||
}
|
||||
this.saveActiveCascadeStage();
|
||||
this.cascadeStages.splice(index, 1);
|
||||
this.activeCascadeStageIndex = Math.min(this.activeCascadeStageIndex, this.cascadeStages.length - 1);
|
||||
this.cascadeStages[this.activeCascadeStageIndex].isExpanded = true;
|
||||
this.applyStageToControls(this.cloneStage(this.cascadeStages[this.activeCascadeStageIndex]));
|
||||
this.updateBodePlot({ switchToChart: false });
|
||||
},
|
||||
toggleCascadeStage(index) {
|
||||
this.saveActiveCascadeStage();
|
||||
const stage = this.cascadeStages[index];
|
||||
stage.isActive = !stage.isActive;
|
||||
this.updateBodePlot({ switchToChart: false });
|
||||
this.debouncedProcessFilter();
|
||||
},
|
||||
toggleCascadeStageExpanded(index) {
|
||||
if (index < 0 || index >= this.cascadeStages.length) return;
|
||||
if (index !== this.activeCascadeStageIndex) {
|
||||
this.selectCascadeStage(index);
|
||||
return;
|
||||
}
|
||||
this.saveActiveCascadeStage();
|
||||
this.cascadeStages.forEach((stage, stageIndex) => {
|
||||
stage.isExpanded = stageIndex === index ? !stage.isExpanded : false;
|
||||
});
|
||||
},
|
||||
cascadeStageSummary(stage) {
|
||||
const bCount = this.parseCoeffs(stage.b_str || '').length;
|
||||
const aCount = this.parseCoeffs(stage.a_str || '').length;
|
||||
return `${bCount}b / ${aCount}a, Qb${stage.shiftBitsB}, Qa${stage.shiftBitsA}`;
|
||||
},
|
||||
moveCascadeStageUp(index) {
|
||||
this.moveCascadeStage(index, index - 1);
|
||||
},
|
||||
moveCascadeStageDown(index) {
|
||||
this.moveCascadeStage(index, index + 1);
|
||||
},
|
||||
startCascadeStageDrag(index, event) {
|
||||
if (index < 0 || index >= this.cascadeStages.length) return;
|
||||
this.saveActiveCascadeStage();
|
||||
this.draggingCascadeStageIndex = index;
|
||||
this.dragOverCascadeStageIndex = index;
|
||||
this.stageDragPointerId = event.pointerId;
|
||||
this.draggedCascadeStageId = this.cascadeStages[index]?.id || null;
|
||||
this.stageDragStartX = event.clientX;
|
||||
this.stageDragStartY = event.clientY;
|
||||
this.stageDragCurrentX = event.clientX;
|
||||
this.stageDragCurrentY = event.clientY;
|
||||
event.currentTarget?.setPointerCapture?.(event.pointerId);
|
||||
window.addEventListener('pointermove', this.moveCascadeStageDrag, { passive: false });
|
||||
window.addEventListener('pointerup', this.endCascadeStageDrag);
|
||||
window.addEventListener('pointercancel', this.cancelCascadeStageDrag);
|
||||
},
|
||||
cascadeStageDragStyle(index) {
|
||||
if (this.draggingCascadeStageIndex !== index) return null;
|
||||
const dx = this.stageDragCurrentX - this.stageDragStartX;
|
||||
const dy = this.stageDragCurrentY - this.stageDragStartY;
|
||||
return {
|
||||
transform: `translate3d(${dx}px, ${dy}px, 0) scale(1.015)`,
|
||||
};
|
||||
},
|
||||
moveCascadeStageDrag(event) {
|
||||
if (this.draggingCascadeStageIndex === null) return;
|
||||
event.preventDefault();
|
||||
this.stageDragCurrentX = event.clientX;
|
||||
this.stageDragCurrentY = event.clientY;
|
||||
const target = document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.find(element => {
|
||||
const stageEl = element.closest?.('[data-cascade-stage-index]');
|
||||
return stageEl && Number(stageEl.dataset.cascadeStageIndex) !== this.draggingCascadeStageIndex;
|
||||
})
|
||||
?.closest?.('[data-cascade-stage-index]');
|
||||
if (!target) return;
|
||||
const index = Number(target.dataset.cascadeStageIndex);
|
||||
if (Number.isInteger(index) && index >= 0 && index < this.cascadeStages.length) {
|
||||
this.dragOverCascadeStageIndex = index;
|
||||
this.reorderCascadeStageDuringDrag(index);
|
||||
}
|
||||
},
|
||||
reorderCascadeStageDuringDrag(toIndex) {
|
||||
const fromIndex = this.draggingCascadeStageIndex;
|
||||
if (fromIndex === null || fromIndex === toIndex || !this.draggedCascadeStageId) return;
|
||||
const draggedEl = document.querySelector(`[data-cascade-stage-index="${fromIndex}"]`);
|
||||
const beforeRect = draggedEl?.getBoundingClientRect();
|
||||
const activeStageId = this.cascadeStages[this.activeCascadeStageIndex]?.id;
|
||||
const [stage] = this.cascadeStages.splice(fromIndex, 1);
|
||||
this.cascadeStages.splice(toIndex, 0, stage);
|
||||
this.draggingCascadeStageIndex = toIndex;
|
||||
this.dragOverCascadeStageIndex = toIndex;
|
||||
this.activeCascadeStageIndex = Math.max(0, this.cascadeStages.findIndex(item => item.id === activeStageId));
|
||||
this.$nextTick(() => {
|
||||
const nextEl = document.querySelector(`[data-cascade-stage-index="${toIndex}"]`);
|
||||
const afterRect = nextEl?.getBoundingClientRect();
|
||||
if (!beforeRect || !afterRect) return;
|
||||
this.stageDragStartX += afterRect.left - beforeRect.left;
|
||||
this.stageDragStartY += afterRect.top - beforeRect.top;
|
||||
});
|
||||
},
|
||||
endCascadeStageDrag() {
|
||||
const activeStageId = this.cascadeStages[this.activeCascadeStageIndex]?.id;
|
||||
this.clearCascadeStageDrag();
|
||||
this.collapseCascadeStages({ skipSave: true });
|
||||
this.activeCascadeStageIndex = Math.max(0, this.cascadeStages.findIndex(item => item.id === activeStageId));
|
||||
this.applyStageToControls(this.cloneStage(this.cascadeStages[this.activeCascadeStageIndex]));
|
||||
this.updateBodePlot({ switchToChart: false });
|
||||
this.debouncedProcessFilter();
|
||||
},
|
||||
cancelCascadeStageDrag() {
|
||||
this.clearCascadeStageDrag();
|
||||
this.collapseCascadeStages();
|
||||
},
|
||||
clearCascadeStageDrag() {
|
||||
window.removeEventListener('pointermove', this.moveCascadeStageDrag);
|
||||
window.removeEventListener('pointerup', this.endCascadeStageDrag);
|
||||
window.removeEventListener('pointercancel', this.cancelCascadeStageDrag);
|
||||
this.draggingCascadeStageIndex = null;
|
||||
this.dragOverCascadeStageIndex = null;
|
||||
this.stageDragPointerId = null;
|
||||
this.draggedCascadeStageId = null;
|
||||
this.stageDragStartX = 0;
|
||||
this.stageDragStartY = 0;
|
||||
this.stageDragCurrentX = 0;
|
||||
this.stageDragCurrentY = 0;
|
||||
},
|
||||
collapseCascadeStages({ skipSave = false } = {}) {
|
||||
if (!skipSave) this.saveActiveCascadeStage();
|
||||
this.cascadeStages.forEach(stage => {
|
||||
stage.isExpanded = false;
|
||||
});
|
||||
},
|
||||
moveCascadeStage(fromIndex, toIndex, { preserveExpansion = false, collapseAll = false } = {}) {
|
||||
if (fromIndex < 0 || fromIndex >= this.cascadeStages.length || toIndex < 0 || toIndex >= this.cascadeStages.length) return;
|
||||
this.saveActiveCascadeStage();
|
||||
const activeStageId = this.cascadeStages[this.activeCascadeStageIndex]?.id;
|
||||
const expansionById = new Map(this.cascadeStages.map(stage => [stage.id, !!stage.isExpanded]));
|
||||
const [stage] = this.cascadeStages.splice(fromIndex, 1);
|
||||
this.cascadeStages.splice(toIndex, 0, stage);
|
||||
this.activeCascadeStageIndex = Math.max(0, this.cascadeStages.findIndex(item => item.id === activeStageId));
|
||||
this.cascadeStages.forEach((item, index) => {
|
||||
if (collapseAll) {
|
||||
item.isExpanded = false;
|
||||
} else {
|
||||
item.isExpanded = preserveExpansion ? !!expansionById.get(item.id) : index === this.activeCascadeStageIndex;
|
||||
}
|
||||
});
|
||||
this.applyStageToControls(this.cloneStage(this.cascadeStages[this.activeCascadeStageIndex]));
|
||||
this.updateBodePlot({ switchToChart: false });
|
||||
this.debouncedProcessFilter();
|
||||
},
|
||||
getStageFixedCoeffs(stage) {
|
||||
return computeStageFixedCoeffs(stage, this.parseCoeffs);
|
||||
},
|
||||
getCascadeStagesSnapshot() {
|
||||
this.saveActiveCascadeStage();
|
||||
return this.cascadeStages.map(stage => this.cloneStage(stage));
|
||||
},
|
||||
getCascadeBodePayload() {
|
||||
return {
|
||||
fs: this.fs,
|
||||
stages: this.getCascadeStagesSnapshot().map(stage => {
|
||||
const fixed = this.getStageFixedCoeffs(stage);
|
||||
const scaleB = Math.pow(2, stage.shiftBitsB);
|
||||
const scaleA = Math.pow(2, stage.shiftBitsA);
|
||||
return {
|
||||
b: this.parseCoeffs(stage.b_str),
|
||||
a: this.parseCoeffs(stage.a_str),
|
||||
b_fixed: fixed.b.map(v => v / scaleB),
|
||||
a_fixed: fixed.a.map(v => v / scaleA),
|
||||
isActive: stage.isActive,
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
getCascadeFilterPayload() {
|
||||
return createCascadeFilterPayload({
|
||||
stages: this.getCascadeStagesSnapshot(),
|
||||
fs: this.fs,
|
||||
shiftBitsIn: this.shiftBitsIn,
|
||||
shiftBitsOut: this.shiftBitsOut,
|
||||
useRound: this.useRound,
|
||||
parseCoeffs: this.parseCoeffs,
|
||||
getStageFixedCoeffs: this.getStageFixedCoeffs,
|
||||
});
|
||||
},
|
||||
loadSettings() {
|
||||
try {
|
||||
const saved = localStorage.getItem('dea_settings');
|
||||
@@ -311,7 +663,7 @@ export default {
|
||||
'fs', 'b_str', 'a_str', 'filterType', 'systemGain', 'params',
|
||||
'baseB', 'baseA', 'bSliders', 'aSliders', 'sense_b', 'sense_a',
|
||||
'shiftBitsB', 'shiftBitsA', 'shiftBitsIn', 'shiftBitsOut', 'useRound',
|
||||
'fixedOverrides', 'aFineStep', 'fixedAFineStep'
|
||||
'fixedOverrides', 'aFineStep', 'fixedAFineStep', 'cascadeStages', 'activeCascadeStageIndex'
|
||||
];
|
||||
keys.forEach(k => {
|
||||
if (parsed[k] !== undefined) {
|
||||
@@ -324,11 +676,12 @@ export default {
|
||||
}
|
||||
},
|
||||
saveSettings() {
|
||||
this.saveActiveCascadeStage();
|
||||
const keys = [
|
||||
'fs', 'b_str', 'a_str', 'filterType', 'systemGain', 'params',
|
||||
'baseB', 'baseA', 'bSliders', 'aSliders', 'sense_b', 'sense_a',
|
||||
'shiftBitsB', 'shiftBitsA', 'shiftBitsIn', 'shiftBitsOut', 'useRound',
|
||||
'fixedOverrides', 'aFineStep', 'fixedAFineStep'
|
||||
'fixedOverrides', 'aFineStep', 'fixedAFineStep', 'cascadeStages', 'activeCascadeStageIndex'
|
||||
];
|
||||
const settings = {};
|
||||
keys.forEach(k => settings[k] = this[k]);
|
||||
@@ -1005,13 +1358,11 @@ export default {
|
||||
return intVal / scaleA;
|
||||
});
|
||||
|
||||
const res = await fetch('/api/bode/compare', {
|
||||
const cascadePayload = this.getCascadeBodePayload();
|
||||
const res = await fetch('/api/bode/compare_cascade', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ideal: { b: b_ideal, a: a_ideal, fs: this.fs },
|
||||
fixed: { b: b_fixed, a: a_fixed, fs: this.fs }
|
||||
})
|
||||
body: JSON.stringify(cascadePayload)
|
||||
});
|
||||
|
||||
let data;
|
||||
@@ -1137,9 +1488,9 @@ export default {
|
||||
|
||||
// 手動 Magnitude 圖例 (位於上方圖表下方)
|
||||
{ xref: 'paper', yref: 'paper', x: 0.35, y: isSmallScreen ? 0.52 : 0.54, text: '一一一', font: { color: isDark ? '#c8cee0' : '#566075', size: 10 }, showarrow: false },
|
||||
{ xref: 'paper', yref: 'paper', x: 0.42, y: isSmallScreen ? 0.52 : 0.54, text: 'Ideal (Float)', font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' },
|
||||
{ xref: 'paper', yref: 'paper', x: 0.42, y: isSmallScreen ? 0.52 : 0.54, text: 'Ideal Cascade (Float)', font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' },
|
||||
{ xref: 'paper', yref: 'paper', x: 0.58, y: isSmallScreen ? 0.52 : 0.54, text: '· · · ·', font: { color: isDark ? '#b9b2ff' : '#7a6689', size: 14 }, showarrow: false },
|
||||
{ xref: 'paper', yref: 'paper', x: 0.63, y: isSmallScreen ? 0.52 : 0.54, text: `Fixed (b Q${this.shiftBitsB}, a Q${this.shiftBitsA})`, font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' },
|
||||
{ xref: 'paper', yref: 'paper', x: 0.63, y: isSmallScreen ? 0.52 : 0.54, text: 'Fixed Cascade (Mimic)', font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' },
|
||||
|
||||
// 手動 Phase 圖例 (位於下方圖表下方)
|
||||
{ xref: 'paper', yref: 'paper', x: 0.35, y: -0.15, text: '一一一', font: { color: isDark ? '#64d2ff' : '#2d6f8f', size: 10 }, showarrow: false },
|
||||
@@ -1182,8 +1533,8 @@ export default {
|
||||
shapes: shapes
|
||||
};
|
||||
|
||||
const traceMagIdeal = { x: freq, y: magIdeal, name: 'Ideal (Float)', type: 'scatter', line: { color: isDark ? '#c8cee0' : '#566075', width: 2.8 } };
|
||||
const traceMagFixed = { x: freq, y: magFixed, name: `Fixed (b Q${this.shiftBitsB}, a Q${this.shiftBitsA})`, type: 'scatter', line: { color: isDark ? '#b9b2ff' : '#7a6689', width: 2.4, dash: 'dot' } };
|
||||
const traceMagIdeal = { x: freq, y: magIdeal, name: 'Ideal Cascade (Float)', type: 'scatter', line: { color: isDark ? '#c8cee0' : '#566075', width: 2.8 } };
|
||||
const traceMagFixed = { x: freq, y: magFixed, name: 'Fixed Cascade (Mimic)', type: 'scatter', line: { color: isDark ? '#b9b2ff' : '#7a6689', width: 2.4, dash: 'dot' } };
|
||||
|
||||
const tracePhaseIdeal = { x: freq, y: phaseIdeal, name: 'Ideal Phase', type: 'scatter', line: { color: isDark ? '#64d2ff' : '#2d6f8f', width: 2.8 }, xaxis: 'x2', yaxis: 'y2' };
|
||||
const tracePhaseFixed = { x: freq, y: phaseFixed, name: 'Fixed Phase', type: 'scatter', line: { color: isDark ? '#ff9bd8' : '#96527b', width: 2.4, dash: 'dot' }, xaxis: 'x2', yaxis: 'y2' };
|
||||
@@ -1242,10 +1593,56 @@ export default {
|
||||
this.csvPreview = [];
|
||||
this.csvInfo = null;
|
||||
this.csvParseError = null;
|
||||
this.csvUploading = false;
|
||||
this.usingPresetCsv = false;
|
||||
this.isCsvPreviewExpanded = true;
|
||||
this.selectedColumn = 0;
|
||||
this.filterDone = false;
|
||||
this.timePlotData = null;
|
||||
this.zoomStartIdx = null;
|
||||
this.zoomEndIdx = null;
|
||||
this.timePlotUnit = null;
|
||||
this.timePlotMultiplier = null;
|
||||
},
|
||||
|
||||
async loadPresetWaveforms() {
|
||||
try {
|
||||
const res = await fetch('/api/csv/preset');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
this.presetCsvAvailable = !!data.available;
|
||||
if (!data.available) return;
|
||||
|
||||
this.csvFile = null;
|
||||
this.csvFileId = data.file_id;
|
||||
this.csvColumns = data.columns || [];
|
||||
this.csvPreview = data.preview || [];
|
||||
this.csvInfo = {
|
||||
rows: data.rows || 0,
|
||||
columns: data.columns_count || (data.columns || []).length,
|
||||
size: data.size || 0,
|
||||
};
|
||||
this.csvParseError = null;
|
||||
this.csvUploading = false;
|
||||
this.presetCsvName = data.name || 'preset_signals.csv';
|
||||
this.usingPresetCsv = true;
|
||||
this.selectedColumn = Number.isInteger(data.default_col_idx) ? data.default_col_idx : 0;
|
||||
this.filterDone = false;
|
||||
this.timePlotData = null;
|
||||
this.zoomStartIdx = null;
|
||||
this.zoomEndIdx = null;
|
||||
this.timePlotUnit = null;
|
||||
this.timePlotMultiplier = null;
|
||||
this.isCsvPreviewExpanded = true;
|
||||
this.$nextTick(() => this.processFilter());
|
||||
} catch (_) {
|
||||
this.presetCsvAvailable = false;
|
||||
}
|
||||
},
|
||||
|
||||
restorePresetWaveforms() {
|
||||
if (this.$refs.fileInput) this.$refs.fileInput.value = '';
|
||||
this.loadPresetWaveforms();
|
||||
},
|
||||
|
||||
handleFileUpload(event) {
|
||||
@@ -1266,6 +1663,7 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewBytes = Math.min(file.size, 1024 * 1024);
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
@@ -1288,6 +1686,7 @@ export default {
|
||||
});
|
||||
this.csvInfo = {
|
||||
rows: Math.max(totalRows - 1, 0),
|
||||
rowsLabel: file.size > previewBytes ? `預覽 ${Math.max(rows.length - 1, 0)}+` : `${Math.max(totalRows - 1, 0)}`,
|
||||
columns: columns.length,
|
||||
size: file.size,
|
||||
};
|
||||
@@ -1300,10 +1699,18 @@ export default {
|
||||
}
|
||||
|
||||
// 背景上傳至暫存區
|
||||
this.csvUploading = true;
|
||||
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 || '背景上傳失敗'); }
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
let detail = errText || '背景上傳失敗';
|
||||
try {
|
||||
detail = JSON.parse(errText).detail || detail;
|
||||
} catch (_) {}
|
||||
throw new Error(detail);
|
||||
}
|
||||
const data = await res.json();
|
||||
this.csvFileId = data.file_id;
|
||||
|
||||
@@ -1314,13 +1721,16 @@ export default {
|
||||
this.csvInfo = null;
|
||||
this.csvFileId = null;
|
||||
event.target.value = '';
|
||||
} finally {
|
||||
this.csvUploading = false;
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
this.csvParseError = 'CSV 讀取失敗';
|
||||
this.csvUploading = false;
|
||||
event.target.value = '';
|
||||
};
|
||||
reader.readAsText(file);
|
||||
reader.readAsText(file.slice(0, previewBytes));
|
||||
},
|
||||
|
||||
debouncedProcessFilter() {
|
||||
@@ -1332,10 +1742,18 @@ export default {
|
||||
},
|
||||
|
||||
async processFilter() {
|
||||
if (this.csvUploading) {
|
||||
this.globalError = "CSV 還在上傳中,請稍候...";
|
||||
return;
|
||||
}
|
||||
if (!this.csvFileId) {
|
||||
if (this.csvFile) this.globalError = "檔案還在上傳中,請稍候...";
|
||||
return;
|
||||
}
|
||||
if (this.zoomStartIdx === null && this.zoomEndIdx === null) {
|
||||
this.timePlotUnit = null;
|
||||
this.timePlotMultiplier = null;
|
||||
}
|
||||
this.loadingFilter = true;
|
||||
this.globalError = null;
|
||||
const formData = new FormData();
|
||||
@@ -1350,6 +1768,11 @@ export default {
|
||||
formData.append('shift_b', this.shiftBitsB);
|
||||
formData.append('shift_a', this.shiftBitsA);
|
||||
formData.append('use_round', this.useRound);
|
||||
formData.append('stages', JSON.stringify(this.getCascadeFilterPayload()));
|
||||
if (this.zoomStartIdx !== null && this.zoomEndIdx !== null) {
|
||||
formData.append('start_idx', this.zoomStartIdx);
|
||||
formData.append('end_idx', this.zoomEndIdx);
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/filter', { method: 'POST', body: formData });
|
||||
if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '濾波處理失敗'); }
|
||||
@@ -1373,19 +1796,66 @@ export default {
|
||||
const textColor = isDark ? '#b4b4bd' : '#525663';
|
||||
const plotBgColor = isDark ? '#090a0d' : '#ffffff';
|
||||
|
||||
if (!this.timePlotUnit) {
|
||||
const totalDurationS = (data.total_points || (data.index.length > 0 ? Math.max(...data.index) : 0)) / this.fs;
|
||||
if (totalDurationS > 0 && totalDurationS < 1e-3) {
|
||||
this.timePlotMultiplier = 1e6;
|
||||
this.timePlotUnit = 'μs';
|
||||
} else if (totalDurationS > 0 && totalDurationS < 1) {
|
||||
this.timePlotMultiplier = 1e3;
|
||||
this.timePlotUnit = 'ms';
|
||||
} else {
|
||||
this.timePlotMultiplier = 1;
|
||||
this.timePlotUnit = 's';
|
||||
}
|
||||
}
|
||||
const timeMultiplier = this.timePlotMultiplier || 1;
|
||||
const timeUnit = this.timePlotUnit || 's';
|
||||
const xData = data.index.map(i => (i / this.fs) * timeMultiplier);
|
||||
|
||||
const layout = {
|
||||
paper_bgcolor: plotBgColor, plot_bgcolor: plotBgColor,
|
||||
uirevision: 'time-domain-lod',
|
||||
margin: isSmallScreen ? { t: 60, b: 60, l: 45, r: 15 } : { t: 40, b: 55, l: 60, r: 20 },
|
||||
font: { color: textColor, family: 'Google Sans, Roboto, sans-serif' },
|
||||
xaxis: { title: { text: 'Sample Index', font: { size: isSmallScreen ? 10 : 11 } }, gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 9 : 11 } },
|
||||
xaxis: { title: { text: `Time (${timeUnit})`, font: { size: isSmallScreen ? 10 : 11 } }, gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 9 : 11 } },
|
||||
yaxis: { title: { text: 'Amplitude', font: { size: isSmallScreen ? 10 : 11 } }, gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 9 : 11 } },
|
||||
legend: { orientation: 'h', y: 1.02, yanchor: 'bottom', x: 1, xanchor: 'right', font: { color: textColor, size: isSmallScreen ? 9 : 10 } }
|
||||
};
|
||||
this.isRedrawingTimePlot = true;
|
||||
Plotly.react('timePlot', [
|
||||
{ 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 }
|
||||
{ x: xData, y: data.original, name: '原始輸入 (Input)', type: 'scatter', line: { color: isDark ? '#c8cee0' : '#566075', width: 2.4 }, opacity: 0.5 },
|
||||
{ x: xData, y: data.filtered, name: '理想串聯路徑 (Cascade Ideal)', type: 'scatter', line: { color: isDark ? '#64d2ff' : '#2d6f8f', width: 2.8 }, opacity: 0.95 },
|
||||
{ x: xData, y: data.filtered_fixed, name: '定點串聯路徑 (Cascade Fixed)', type: 'scatter', line: { color: isDark ? '#ff7c7c' : '#c0392b', width: 2.8, dash: 'dot' }, opacity: 0.95 }
|
||||
], layout, { responsive: true });
|
||||
this.$nextTick(() => setTimeout(() => { this.isRedrawingTimePlot = false; }, 50));
|
||||
const gd = document.getElementById('timePlot');
|
||||
if (gd && !gd._hasRelayoutListener) {
|
||||
gd.on('plotly_relayout', this.handleTimePlotRelayout);
|
||||
gd._hasRelayoutListener = true;
|
||||
}
|
||||
},
|
||||
|
||||
handleTimePlotRelayout(eventData) {
|
||||
if (this.isRedrawingTimePlot) return;
|
||||
if (!this.timePlotData || !this.timePlotData.total_points) return;
|
||||
let startIdx = null;
|
||||
let endIdx = null;
|
||||
if (eventData['xaxis.range[0]'] !== undefined && eventData['xaxis.range[1]'] !== undefined) {
|
||||
const timeMultiplier = this.timePlotMultiplier || 1;
|
||||
startIdx = Math.max(0, Math.floor((eventData['xaxis.range[0]'] * this.fs) / timeMultiplier));
|
||||
endIdx = Math.min(this.timePlotData.total_points, Math.ceil((eventData['xaxis.range[1]'] * this.fs) / timeMultiplier));
|
||||
if (endIdx - startIdx <= 10) return;
|
||||
} else if (eventData['xaxis.autorange'] === true) {
|
||||
startIdx = null;
|
||||
endIdx = null;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
this.zoomStartIdx = startIdx;
|
||||
this.zoomEndIdx = endIdx;
|
||||
if (this._zoomTimeout) clearTimeout(this._zoomTimeout);
|
||||
this._zoomTimeout = setTimeout(() => this.processFilter(), 150);
|
||||
},
|
||||
|
||||
async downloadCsv() {
|
||||
@@ -1402,13 +1872,19 @@ export default {
|
||||
formData.append('shift_b', this.shiftBitsB);
|
||||
formData.append('shift_a', this.shiftBitsA);
|
||||
formData.append('use_round', this.useRound);
|
||||
formData.append('stages', JSON.stringify(this.getCascadeFilterPayload()));
|
||||
formData.append('compact', 'true');
|
||||
if (this.zoomStartIdx !== null && this.zoomEndIdx !== null) {
|
||||
formData.append('start_idx', this.zoomStartIdx);
|
||||
formData.append('end_idx', this.zoomEndIdx);
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/filter/download', { method: 'POST', body: formData });
|
||||
if (!res.ok) throw new Error('下載失敗');
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = 'filtered_output.csv';
|
||||
a.href = url; a.download = 'cascade_filtered_output.csv';
|
||||
document.body.appendChild(a); a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (e) { this.globalError = e.message; }
|
||||
@@ -1457,6 +1933,7 @@ export default {
|
||||
},
|
||||
|
||||
async writeToMCU() {
|
||||
const stageNumber = this.activeCascadeStageIndex + 1;
|
||||
if (!this.mcuSerialPort || !this.mcuConnected) {
|
||||
this.mcuStatus = '請先連線 MCU 連接埠';
|
||||
return;
|
||||
@@ -1474,10 +1951,10 @@ export default {
|
||||
} finally {
|
||||
writer.releaseLock();
|
||||
}
|
||||
this.mcuStatus = `已送出 ${command}`;
|
||||
this.mcuStatus = `已送出 Stage ${stageNumber}: ${command}`;
|
||||
} catch (e) {
|
||||
this.globalError = e.message;
|
||||
this.mcuStatus = `寫入失敗: ${e.message}`;
|
||||
this.mcuStatus = `Stage ${stageNumber} 寫入失敗: ${e.message}`;
|
||||
} finally {
|
||||
this.writingMCU = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
const DEFAULT_FILTER_TYPE = "Lowpass (低通)";
|
||||
const DEFAULT_B_STR = "0.5, 0.5, 0.0, 0.0";
|
||||
const DEFAULT_A_STR = "1.0, 0.0, 0.0, 0.0";
|
||||
const DEFAULT_SHIFT_BITS = 14;
|
||||
const ZERO_7 = [0, 0, 0, 0, 0, 0, 0];
|
||||
const DEFAULT_PARAMS = {
|
||||
fc: 1000.0, order: 1,
|
||||
bp_f_low: 500.0, bp_f_high: 2000.0, bp_order: 1,
|
||||
notch_f0: 120.0, notch_q: 1.0,
|
||||
opoz_fz: 15000.0, opoz_fp: 10.0,
|
||||
tp1z_fz: 200.0, tp1z_fp1: 10.0, tp1z_fp2: 5000.0,
|
||||
tptz_fz1: 200.0, tptz_fz2: 25000.0, tptz_fp1: 10.0, tptz_fp2: 5000.0,
|
||||
kp: 0.003, ki: 10.0, kd: 0.000016,
|
||||
sogi_f0: 60.0, sogi_k: 1.414,
|
||||
};
|
||||
|
||||
const cloneDefaultParams = () => ({ ...DEFAULT_PARAMS });
|
||||
const zero7 = () => [...ZERO_7];
|
||||
const createStageId = () => `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
export const normalizeCascadeStage = (stage = {}, fallback = {}) => ({
|
||||
id: stage.id ?? fallback.id ?? createStageId(),
|
||||
isActive: stage.isActive ?? fallback.isActive ?? true,
|
||||
isExpanded: stage.isExpanded ?? fallback.isExpanded ?? false,
|
||||
filterType: stage.filterType ?? fallback.filterType ?? DEFAULT_FILTER_TYPE,
|
||||
b_str: stage.b_str ?? fallback.b_str ?? DEFAULT_B_STR,
|
||||
a_str: stage.a_str ?? fallback.a_str ?? DEFAULT_A_STR,
|
||||
systemGain: stage.systemGain ?? fallback.systemGain ?? 1.0,
|
||||
params: { ...cloneDefaultParams(), ...(fallback.params || {}), ...(stage.params || {}) },
|
||||
baseB: [...(stage.baseB || fallback.baseB || [0.5, 0.5, 0, 0, 0, 0, 0])],
|
||||
baseA: [...(stage.baseA || fallback.baseA || [1, 0, 0, 0, 0, 0, 0])],
|
||||
bSliders: [...(stage.bSliders || fallback.bSliders || zero7())],
|
||||
aSliders: [...(stage.aSliders || fallback.aSliders || zero7())],
|
||||
shiftBitsB: stage.shiftBitsB ?? fallback.shiftBitsB ?? DEFAULT_SHIFT_BITS,
|
||||
shiftBitsA: stage.shiftBitsA ?? fallback.shiftBitsA ?? DEFAULT_SHIFT_BITS,
|
||||
fixedOverrides: {
|
||||
a: { ...((fallback.fixedOverrides || {}).a || {}), ...((stage.fixedOverrides || {}).a || {}) },
|
||||
b: { ...((fallback.fixedOverrides || {}).b || {}), ...((stage.fixedOverrides || {}).b || {}) },
|
||||
},
|
||||
outOfRangeB: [...(stage.outOfRangeB || fallback.outOfRangeB || [false, false, false, false, false, false, false])],
|
||||
outOfRangeA: [...(stage.outOfRangeA || fallback.outOfRangeA || [false, false, false, false, false, false, false])],
|
||||
aFineStep: stage.aFineStep ?? fallback.aFineStep ?? 0.01,
|
||||
fixedAFineStep: stage.fixedAFineStep ?? fallback.fixedAFineStep ?? 1,
|
||||
activeCoeffAdjustment: stage.activeCoeffAdjustment ?? fallback.activeCoeffAdjustment ?? null,
|
||||
});
|
||||
|
||||
export const computeStageFixedCoeffs = (stage, parseCoeffs) => {
|
||||
const normalized = normalizeCascadeStage(stage);
|
||||
const scaleB = Math.pow(2, normalized.shiftBitsB);
|
||||
const scaleA = Math.pow(2, normalized.shiftBitsA);
|
||||
const bRaw = parseCoeffs(normalized.b_str);
|
||||
const aRaw = parseCoeffs(normalized.a_str);
|
||||
return {
|
||||
b: bRaw.map((x, i) => ((normalized.fixedOverrides.b[i] !== undefined) ? normalized.fixedOverrides.b[i] : Math.round(x * scaleB))),
|
||||
a: aRaw.map((x, i) => ((normalized.fixedOverrides.a[i] !== undefined) ? normalized.fixedOverrides.a[i] : Math.round(x * scaleA))),
|
||||
};
|
||||
};
|
||||
|
||||
export const createCascadeFilterPayload = ({ stages, fs, shiftBitsIn, shiftBitsOut, useRound, parseCoeffs, getStageFixedCoeffs }) => (
|
||||
stages.map((stage, index) => {
|
||||
const fixed = getStageFixedCoeffs(stage);
|
||||
return {
|
||||
b_str: stage.b_str,
|
||||
a_str: stage.a_str,
|
||||
b_int_str: fixed.b.join(', '),
|
||||
a_int_str: fixed.a.join(', '),
|
||||
shift_in: index === 0 ? shiftBitsIn : stages[index - 1].shiftBitsB,
|
||||
shift_out: index === stages.length - 1 ? shiftBitsOut : stage.shiftBitsB,
|
||||
shift_b: stage.shiftBitsB,
|
||||
shift_a: stage.shiftBitsA,
|
||||
use_round: useRound,
|
||||
isActive: stage.isActive,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="stage-editor" :data-stage-index="stageIndex" :data-stage-id="stage?.id || ''">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'StageEditor',
|
||||
props: {
|
||||
stage: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
stageIndex: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
+207
-108
@@ -123,7 +123,7 @@ body {
|
||||
header,
|
||||
aside,
|
||||
footer,
|
||||
main > div,
|
||||
main>div,
|
||||
section,
|
||||
details,
|
||||
.js-plotly-plot,
|
||||
@@ -142,17 +142,17 @@ footer.dark\:bg-dark {
|
||||
}
|
||||
|
||||
header {
|
||||
min-height: 72px;
|
||||
min-height: 56px;
|
||||
padding-inline: clamp(1rem, 2vw, 1.75rem) !important;
|
||||
}
|
||||
|
||||
header img {
|
||||
background: var(--m3-surface-container-high);
|
||||
border-radius: 18px !important;
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: clamp(1.35rem, 1.8vw, 2rem) !important;
|
||||
font-size: clamp(1.05rem, 1.4vw, 1.5rem) !important;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ details,
|
||||
}
|
||||
|
||||
aside section,
|
||||
main > div {
|
||||
main>div {
|
||||
background: transparent !important;
|
||||
border-color: transparent !important;
|
||||
}
|
||||
@@ -239,20 +239,29 @@ aside section {
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
aside.lg\:w-96 {
|
||||
width: 28rem !important;
|
||||
width: 26.25rem !important;
|
||||
transition: width 0.3s ease-in-out, padding 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.2s ease-in-out !important;
|
||||
}
|
||||
aside.lg\:w-96.sidebar-collapsed {
|
||||
width: 0 !important;
|
||||
padding: 0 !important;
|
||||
border-right-color: transparent !important;
|
||||
overflow: hidden !important;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
main > div {
|
||||
main>div {
|
||||
border-radius: 30px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
main > div > div:first-child {
|
||||
main>div>div:first-child {
|
||||
border-radius: 30px 30px 0 0;
|
||||
}
|
||||
|
||||
main > div > div:last-child,
|
||||
main>div>div:last-child,
|
||||
main .overflow-x-auto:has(#bodePlot),
|
||||
main .overflow-x-auto:has(#timePlot),
|
||||
#bodePlot,
|
||||
@@ -280,7 +289,7 @@ main .overflow-x-auto:has(#timePlot) {
|
||||
.border-l,
|
||||
.border-r,
|
||||
.lg\:border,
|
||||
.divide-y > :not([hidden]) ~ :not([hidden]) {
|
||||
.divide-y> :not([hidden])~ :not([hidden]) {
|
||||
border-color: var(--m3-divider) !important;
|
||||
}
|
||||
|
||||
@@ -724,14 +733,12 @@ label.role-bg-primary {
|
||||
}
|
||||
|
||||
.role-surface-gradient {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--m3-primary) 7%, transparent),
|
||||
color-mix(in srgb, var(--m3-tertiary) 6%, transparent)
|
||||
) !important;
|
||||
background-image: linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--m3-primary) 7%, transparent),
|
||||
color-mix(in srgb, var(--m3-tertiary) 6%, transparent)) !important;
|
||||
}
|
||||
|
||||
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div,
|
||||
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>div,
|
||||
details .grid.grid-cols-\[2rem_1fr_2rem\] input,
|
||||
details button.touch-none {
|
||||
background: var(--m3-surface-container-lowest) !important;
|
||||
@@ -743,8 +750,8 @@ details .grid.grid-cols-\[2rem_1fr_2rem\] button {
|
||||
color: var(--m3-on-secondary-container) !important;
|
||||
}
|
||||
|
||||
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > button,
|
||||
details .grid.grid-cols-\[2rem_1fr_2rem\] > button,
|
||||
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>button,
|
||||
details .grid.grid-cols-\[2rem_1fr_2rem\]>button,
|
||||
details button.touch-none {
|
||||
background: var(--m3-secondary-container) !important;
|
||||
border: 1px solid color-mix(in srgb, var(--m3-secondary) 28%, var(--m3-outline-variant)) !important;
|
||||
@@ -753,29 +760,29 @@ details button.touch-none {
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > button:hover,
|
||||
details .grid.grid-cols-\[2rem_1fr_2rem\] > button:hover,
|
||||
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>button:hover,
|
||||
details .grid.grid-cols-\[2rem_1fr_2rem\]>button:hover,
|
||||
details button.touch-none:hover {
|
||||
background: color-mix(in srgb, var(--m3-secondary-container) 82%, var(--m3-primary) 18%) !important;
|
||||
color: var(--m3-on-secondary-container) !important;
|
||||
}
|
||||
|
||||
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div,
|
||||
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>div,
|
||||
details .grid.grid-cols-\[2rem_1fr_2rem\] input,
|
||||
details button.touch-none,
|
||||
details .grid.grid-cols-2.gap-2 > div {
|
||||
details .grid.grid-cols-2.gap-2>div {
|
||||
background: var(--m3-surface-container-lowest) !important;
|
||||
border-color: var(--m3-soft-outline) !important;
|
||||
}
|
||||
|
||||
details .grid.grid-cols-\[2rem_1fr_2rem\] input,
|
||||
details button.touch-none span.font-mono,
|
||||
details .grid.grid-cols-2.gap-2 > div span.font-mono {
|
||||
details .grid.grid-cols-2.gap-2>div span.font-mono {
|
||||
color: var(--m3-on-surface) !important;
|
||||
-webkit-text-fill-color: var(--m3-on-surface);
|
||||
}
|
||||
|
||||
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div span.font-mono {
|
||||
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>div span.font-mono {
|
||||
color: var(--m3-on-secondary-container) !important;
|
||||
-webkit-text-fill-color: var(--m3-on-secondary-container);
|
||||
}
|
||||
@@ -785,8 +792,8 @@ details .grid.grid-cols-\[2rem_1fr_2rem\] input:focus {
|
||||
box-shadow: 0 0 0 3px var(--m3-primary-state) !important;
|
||||
}
|
||||
|
||||
.grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > button,
|
||||
.grid.grid-cols-\[2rem_1fr_2rem\] > button,
|
||||
.grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>button,
|
||||
.grid.grid-cols-\[2rem_1fr_2rem\]>button,
|
||||
button.touch-none {
|
||||
background: var(--m3-secondary-container) !important;
|
||||
border: 1px solid color-mix(in srgb, var(--m3-secondary) 28%, var(--m3-outline-variant)) !important;
|
||||
@@ -798,21 +805,21 @@ button.touch-none {
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > button:hover,
|
||||
.grid.grid-cols-\[2rem_1fr_2rem\] > button:hover,
|
||||
.grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>button:hover,
|
||||
.grid.grid-cols-\[2rem_1fr_2rem\]>button:hover,
|
||||
button.touch-none:hover {
|
||||
background: color-mix(in srgb, var(--m3-secondary-container) 82%, var(--m3-primary) 18%) !important;
|
||||
color: var(--m3-on-secondary-container) !important;
|
||||
}
|
||||
|
||||
.grid.grid-cols-\[2rem_1fr_2rem\] > input,
|
||||
.grid.grid-cols-\[2rem_1fr_2rem\]>input,
|
||||
button.touch-none,
|
||||
.grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div {
|
||||
.grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>div {
|
||||
background: var(--m3-surface-container-lowest) !important;
|
||||
border-color: var(--m3-soft-outline) !important;
|
||||
}
|
||||
|
||||
.grid.grid-cols-\[2rem_1fr_2rem\] > input,
|
||||
.grid.grid-cols-\[2rem_1fr_2rem\]>input,
|
||||
button.touch-none span.font-mono,
|
||||
.grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] span.font-mono,
|
||||
.grid.grid-cols-2.gap-2 span.font-mono {
|
||||
@@ -826,13 +833,13 @@ button.touch-none span.font-mono,
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
.grid.grid-cols-\[2rem_1fr_2rem\] > input {
|
||||
.grid.grid-cols-\[2rem_1fr_2rem\]>input {
|
||||
text-align: center;
|
||||
font-size: 0.95rem !important;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.grid.grid-cols-\[2rem_1fr_2rem\] > input:focus {
|
||||
.grid.grid-cols-\[2rem_1fr_2rem\]>input:focus {
|
||||
border-color: var(--m3-primary) !important;
|
||||
box-shadow: 0 0 0 3px var(--m3-primary-state) !important;
|
||||
}
|
||||
@@ -851,18 +858,18 @@ button.touch-none span.font-mono,
|
||||
letter-spacing: 0 !important;
|
||||
}
|
||||
|
||||
#app section .flex.flex-wrap.gap-1.mb-2 > div,
|
||||
#app section .grid.grid-cols-2.gap-2 > div,
|
||||
#app section .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div,
|
||||
#app section .flex.flex-wrap.gap-1.mb-2>div,
|
||||
#app section .grid.grid-cols-2.gap-2>div,
|
||||
#app section .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>div,
|
||||
#app section button.touch-none {
|
||||
background: var(--m3-surface-container-lowest) !important;
|
||||
border-color: color-mix(in srgb, var(--m3-outline) 34%, var(--m3-outline-variant)) !important;
|
||||
color: var(--m3-on-surface) !important;
|
||||
}
|
||||
|
||||
#app section .flex.flex-wrap.gap-1.mb-2 > div span,
|
||||
#app section .grid.grid-cols-2.gap-2 > div span,
|
||||
#app section .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div span,
|
||||
#app section .flex.flex-wrap.gap-1.mb-2>div span,
|
||||
#app section .grid.grid-cols-2.gap-2>div span,
|
||||
#app section .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>div span,
|
||||
#app section button.touch-none span {
|
||||
color: var(--m3-on-surface) !important;
|
||||
-webkit-text-fill-color: var(--m3-on-surface);
|
||||
@@ -871,43 +878,43 @@ button.touch-none span.font-mono,
|
||||
line-height: 1.25 !important;
|
||||
}
|
||||
|
||||
#app section .flex.flex-wrap.gap-1.mb-2 > div span:first-child,
|
||||
#app section .grid.grid-cols-2.gap-2 > div span:first-child,
|
||||
#app section .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div span:first-child,
|
||||
#app section .flex.flex-wrap.gap-1.mb-2>div span:first-child,
|
||||
#app section .grid.grid-cols-2.gap-2>div span:first-child,
|
||||
#app section .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>div span:first-child,
|
||||
#app section button.touch-none span:first-child {
|
||||
color: var(--m3-on-surface-variant) !important;
|
||||
-webkit-text-fill-color: var(--m3-on-surface-variant);
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
#app section .flex.flex-wrap.gap-1.mb-2 > div {
|
||||
#app section .flex.flex-wrap.gap-1.mb-2>div {
|
||||
gap: 0.3rem !important;
|
||||
min-height: 1.85rem;
|
||||
padding: 0.25rem 0.45rem !important;
|
||||
}
|
||||
|
||||
#app section .flex.flex-wrap.gap-1.mb-2 > div span:first-child {
|
||||
#app section .flex.flex-wrap.gap-1.mb-2>div span:first-child {
|
||||
font-size: 0.68rem !important;
|
||||
}
|
||||
|
||||
#app section .flex.flex-wrap.gap-1.mb-2 > div span.font-mono {
|
||||
#app section .flex.flex-wrap.gap-1.mb-2>div span.font-mono {
|
||||
font-size: 0.78rem !important;
|
||||
}
|
||||
|
||||
#app section .grid.grid-cols-2.gap-2 > div {
|
||||
#app section .grid.grid-cols-2.gap-2>div {
|
||||
padding: 0.7rem 0.65rem !important;
|
||||
}
|
||||
|
||||
#app section .grid.grid-cols-2.gap-2 > div span.font-mono {
|
||||
#app section .grid.grid-cols-2.gap-2>div span.font-mono {
|
||||
font-size: clamp(0.92rem, 1vw, 1.08rem) !important;
|
||||
}
|
||||
|
||||
#app section .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div span.font-mono,
|
||||
#app section .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>div span.font-mono,
|
||||
#app section button.touch-none span.font-mono {
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
|
||||
#app section .grid.grid-cols-\[2rem_1fr_2rem\] > input {
|
||||
#app section .grid.grid-cols-\[2rem_1fr_2rem\]>input {
|
||||
color: var(--m3-on-surface) !important;
|
||||
font-size: 1rem !important;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -945,8 +952,8 @@ button.touch-none span.font-mono,
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#app aside label.flex > span:first-child,
|
||||
#app aside .flex.justify-between > span:first-child {
|
||||
#app aside label.flex>span:first-child,
|
||||
#app aside .flex.justify-between>span:first-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -960,38 +967,38 @@ button.touch-none span.font-mono,
|
||||
}
|
||||
|
||||
#app aside .space-y-3,
|
||||
#app aside details > div,
|
||||
#app aside details>div,
|
||||
#app aside section {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
#app aside .coeff-section > div.mb-3,
|
||||
#app aside .fixed-section > div.mb-4,
|
||||
#app aside .fixed-section > .space-y-4 > div {
|
||||
#app aside .coeff-section>div.mb-3,
|
||||
#app aside .fixed-section>div.mb-4,
|
||||
#app aside .fixed-section>.space-y-4>div {
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
#app aside .coeff-section > details,
|
||||
#app aside .coeff-section>details,
|
||||
#app aside .fixed-section details {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app aside .coeff-section > details > summary,
|
||||
#app aside .fixed-section details > summary {
|
||||
#app aside .coeff-section>details>summary,
|
||||
#app aside .fixed-section details>summary {
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid transparent;
|
||||
padding: 0.95rem 1rem !important;
|
||||
}
|
||||
|
||||
#app aside .coeff-section > details[open] > summary,
|
||||
#app aside .fixed-section details[open] > summary {
|
||||
#app aside .coeff-section>details[open]>summary,
|
||||
#app aside .fixed-section details[open]>summary {
|
||||
border-bottom-color: var(--m3-divider);
|
||||
}
|
||||
|
||||
#app aside .coeff-section > details > div,
|
||||
#app aside .fixed-section details > div {
|
||||
#app aside .coeff-section>details>div,
|
||||
#app aside .fixed-section details>div {
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
@@ -1007,10 +1014,10 @@ button.touch-none span.font-mono,
|
||||
|
||||
|
||||
|
||||
.dark #app aside .coeff-section .flex.flex-wrap.gap-1.mb-2 > div,
|
||||
.dark #app aside .fixed-section .flex.flex-wrap.gap-1.mb-2 > div,
|
||||
.dark #app aside .grid.grid-cols-2.gap-2 > div,
|
||||
.dark #app aside .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div,
|
||||
.dark #app aside .coeff-section .flex.flex-wrap.gap-1.mb-2>div,
|
||||
.dark #app aside .fixed-section .flex.flex-wrap.gap-1.mb-2>div,
|
||||
.dark #app aside .grid.grid-cols-2.gap-2>div,
|
||||
.dark #app aside .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>div,
|
||||
.dark #app aside button.touch-none {
|
||||
background: #292a2d !important;
|
||||
border-color: color-mix(in srgb, var(--m3-outline-variant) 78%, transparent) !important;
|
||||
@@ -1072,43 +1079,43 @@ button.touch-none span.font-mono,
|
||||
-webkit-text-fill-color: var(--m3-on-surface);
|
||||
}
|
||||
|
||||
#app aside .fixed-section > div.mb-4 {
|
||||
#app aside .fixed-section>div.mb-4 {
|
||||
border-radius: 24px 24px 4px 4px !important;
|
||||
margin: 0 0 2px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app aside .fixed-section > .space-y-4 {
|
||||
#app aside .fixed-section>.space-y-4 {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
gap: 0 !important;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
#app aside .fixed-section > .space-y-4 > :not([hidden]) ~ :not([hidden]) {
|
||||
#app aside .fixed-section>.space-y-4> :not([hidden])~ :not([hidden]) {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
#app aside .fixed-section > .space-y-4 > :is(div, details) {
|
||||
#app aside .fixed-section>.space-y-4> :is(div, details) {
|
||||
border-radius: 4px !important;
|
||||
margin: 0 0 2px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app aside .fixed-section > .space-y-4 > :is(div, details):first-child {
|
||||
#app aside .fixed-section>.space-y-4> :is(div, details):first-child {
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
#app aside .fixed-section > .space-y-4 > :is(div, details):last-child {
|
||||
#app aside .fixed-section>.space-y-4> :is(div, details):last-child {
|
||||
border-radius: 4px 4px 24px 24px !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
#app aside .fixed-section > .space-y-4 > :is(div, details):first-child:last-child {
|
||||
#app aside .fixed-section>.space-y-4> :is(div, details):first-child:last-child {
|
||||
border-radius: 4px 4px 24px 24px !important;
|
||||
}
|
||||
|
||||
#app aside .fixed-section > div.mb-4 + .space-y-4 {
|
||||
#app aside .fixed-section>div.mb-4+.space-y-4 {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
@@ -1186,7 +1193,7 @@ button.touch-none span.font-mono,
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#app aside .selector-button-group > span {
|
||||
#app aside .selector-button-group>span {
|
||||
flex: 0 0 auto;
|
||||
color: var(--m3-on-surface-variant) !important;
|
||||
}
|
||||
@@ -1249,7 +1256,7 @@ button.touch-none span.font-mono,
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
#app main .chart-panel > .role-surface-gradient {
|
||||
#app main .chart-panel>.role-surface-gradient {
|
||||
border-radius: inherit !important;
|
||||
}
|
||||
|
||||
@@ -1272,14 +1279,14 @@ button.touch-none span.font-mono,
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
#app > header + .lg\:hidden {
|
||||
#app>header+.lg\:hidden {
|
||||
gap: 0.25rem !important;
|
||||
padding: 0.35rem 0.75rem 0.45rem !important;
|
||||
border-bottom: 0 !important;
|
||||
background: var(--m3-background) !important;
|
||||
}
|
||||
|
||||
#app > header + .lg\:hidden > button {
|
||||
#app>header+.lg\:hidden>button {
|
||||
position: relative;
|
||||
min-height: 2.75rem;
|
||||
border: 0 !important;
|
||||
@@ -1289,12 +1296,12 @@ button.touch-none span.font-mono,
|
||||
color: var(--m3-on-surface-variant) !important;
|
||||
}
|
||||
|
||||
#app > header + .lg\:hidden > button.role-text-primary {
|
||||
#app>header+.lg\:hidden>button.role-text-primary {
|
||||
color: var(--m3-primary) !important;
|
||||
-webkit-text-fill-color: var(--m3-primary);
|
||||
}
|
||||
|
||||
#app > header + .lg\:hidden > button::after {
|
||||
#app>header+.lg\:hidden>button::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
@@ -1308,7 +1315,7 @@ button.touch-none span.font-mono,
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
#app > header + .lg\:hidden > button.role-text-primary::after {
|
||||
#app>header+.lg\:hidden>button.role-text-primary::after {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) scaleX(1);
|
||||
}
|
||||
@@ -1323,25 +1330,25 @@ button.touch-none span.font-mono,
|
||||
}
|
||||
}
|
||||
|
||||
.dark #app aside .fixed-section > div.mb-4 {
|
||||
.dark #app aside .fixed-section>div.mb-4 {
|
||||
border-radius: 24px 24px 4px 4px !important;
|
||||
margin: 0 0 2px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dark #app aside .fixed-section > div.mb-4 + .space-y-4,
|
||||
.dark #app aside .fixed-section > .space-y-4 {
|
||||
.dark #app aside .fixed-section>div.mb-4+.space-y-4,
|
||||
.dark #app aside .fixed-section>.space-y-4 {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
gap: 0 !important;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.dark #app aside .fixed-section > .space-y-4 > :not([hidden]) ~ :not([hidden]) {
|
||||
.dark #app aside .fixed-section>.space-y-4> :not([hidden])~ :not([hidden]) {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.dark #app aside .fixed-section > .space-y-4 > :is(div, details) {
|
||||
.dark #app aside .fixed-section>.space-y-4> :is(div, details) {
|
||||
background: var(--m3-surface-container-high) !important;
|
||||
border: 0 !important;
|
||||
border-radius: 4px !important;
|
||||
@@ -1349,20 +1356,20 @@ button.touch-none span.font-mono,
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dark #app aside .fixed-section > .space-y-4 > :is(div, details):first-child {
|
||||
.dark #app aside .fixed-section>.space-y-4> :is(div, details):first-child {
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.dark #app aside .fixed-section > .space-y-4 > :is(div, details):last-child {
|
||||
.dark #app aside .fixed-section>.space-y-4> :is(div, details):last-child {
|
||||
border-radius: 4px 4px 24px 24px !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.dark #app aside .fixed-section > .space-y-4 > :is(div, details):first-child:last-child {
|
||||
.dark #app aside .fixed-section>.space-y-4> :is(div, details):first-child:last-child {
|
||||
border-radius: 4px 4px 24px 24px !important;
|
||||
}
|
||||
|
||||
.dark #app aside .fixed-section > .space-y-4 > details.mt-3 {
|
||||
.dark #app aside .fixed-section>.space-y-4>details.mt-3 {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
@@ -1373,11 +1380,11 @@ button.touch-none span.font-mono,
|
||||
border-color: var(--m3-outline-variant) !important;
|
||||
}
|
||||
|
||||
#app aside .coeff-section > div.mb-3,
|
||||
#app aside .coeff-section > details,
|
||||
#app aside .fixed-section > div.mb-4,
|
||||
#app aside .fixed-section > .space-y-4 > :is(div, details),
|
||||
#app aside section > .space-y-3.bg-slate-100 {
|
||||
#app aside .coeff-section>div.mb-3,
|
||||
#app aside .coeff-section>details,
|
||||
#app aside .fixed-section>div.mb-4,
|
||||
#app aside .fixed-section>.space-y-4> :is(div, details),
|
||||
#app aside section>.space-y-3.bg-slate-100 {
|
||||
background: var(--m3-surface-container-high) !important;
|
||||
border: 0 !important;
|
||||
border-radius: 4px !important;
|
||||
@@ -1386,38 +1393,38 @@ button.touch-none span.font-mono,
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app aside section > .space-y-3.bg-slate-100 {
|
||||
#app aside section>.space-y-3.bg-slate-100 {
|
||||
border-radius: 24px !important;
|
||||
}
|
||||
|
||||
#app aside .fixed-section > .space-y-4 {
|
||||
#app aside .fixed-section>.space-y-4 {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
gap: 0 !important;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
#app aside .fixed-section > .space-y-4 > :not([hidden]) ~ :not([hidden]) {
|
||||
#app aside .fixed-section>.space-y-4> :not([hidden])~ :not([hidden]) {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
#app aside .coeff-section > div.mb-3:first-of-type,
|
||||
#app aside .fixed-section > div.mb-4 {
|
||||
#app aside .coeff-section>div.mb-3:first-of-type,
|
||||
#app aside .fixed-section>div.mb-4 {
|
||||
border-radius: 24px 24px 4px 4px !important;
|
||||
}
|
||||
|
||||
#app aside .coeff-section > details:last-of-type,
|
||||
#app aside .fixed-section > .space-y-4 > :is(div, details):last-child {
|
||||
#app aside .coeff-section>details:last-of-type,
|
||||
#app aside .fixed-section>.space-y-4> :is(div, details):last-child {
|
||||
border-radius: 4px 4px 24px 24px !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
#app aside .fixed-section > .space-y-4 > :is(div, details):first-child {
|
||||
#app aside .fixed-section>.space-y-4> :is(div, details):first-child {
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
#app aside .coeff-section > details.mt-3,
|
||||
#app aside .fixed-section > .space-y-4 > details.mt-3 {
|
||||
#app aside .coeff-section>details.mt-3,
|
||||
#app aside .fixed-section>.space-y-4>details.mt-3 {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
@@ -1451,25 +1458,117 @@ button.touch-none span.font-mono,
|
||||
border-radius: 999px !important;
|
||||
}
|
||||
|
||||
button.touch-none, details button.touch-none {
|
||||
button.touch-none,
|
||||
details button.touch-none {
|
||||
background: var(--m3-secondary-container) !important;
|
||||
border: 1px solid color-mix(in srgb, var(--m3-secondary) 28%, var(--m3-outline-variant)) !important;
|
||||
color: var(--m3-on-secondary-container) !important;
|
||||
border-radius: 999px !important;
|
||||
min-height: 42px;
|
||||
}
|
||||
button.touch-none:hover, details button.touch-none:hover {
|
||||
|
||||
button.touch-none:hover,
|
||||
details button.touch-none:hover {
|
||||
background: color-mix(in srgb, var(--m3-secondary-container) 82%, var(--m3-primary) 18%) !important;
|
||||
color: var(--m3-on-secondary-container) !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
#app .stage-drag-handle {
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
min-width: 2.25rem;
|
||||
}
|
||||
|
||||
#app .stage-dragging {
|
||||
opacity: 0.96;
|
||||
border-color: var(--m3-primary) !important;
|
||||
box-shadow: var(--m3-elevated-shadow) !important;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
transition: box-shadow 140ms ease, opacity 140ms ease;
|
||||
}
|
||||
|
||||
#app .stage-drag-target {
|
||||
border-color: var(--m3-primary) !important;
|
||||
box-shadow: 0 0 0 2px var(--m3-primary-state) inset !important;
|
||||
transform: translateY(2px);
|
||||
transition: transform 140ms ease, box-shadow 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
|
||||
/* Control geometry consistency: keep buttons and input panes visually aligned. */
|
||||
#app {
|
||||
--control-radius: 12px;
|
||||
--control-height-sm: 2.25rem;
|
||||
}
|
||||
|
||||
#app aside input:not([type="range"]),
|
||||
#app aside select,
|
||||
#app aside textarea,
|
||||
#app main input:not([type="range"]),
|
||||
#app main select,
|
||||
#app main textarea {
|
||||
border-radius: var(--control-radius) !important;
|
||||
}
|
||||
|
||||
|
||||
#app aside input[type="range"],
|
||||
#app main input[type="range"] {
|
||||
display: block;
|
||||
width: calc(100% - 28px) !important;
|
||||
max-width: calc(100% - 28px);
|
||||
margin-inline: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#app aside button:not(.modebar-btn),
|
||||
#app main button:not(.modebar-btn),
|
||||
#app aside [role="button"],
|
||||
#app main [role="button"],
|
||||
#app aside label.cursor-pointer,
|
||||
#app main label.cursor-pointer {
|
||||
border-radius: var(--control-radius) !important;
|
||||
min-height: var(--control-height-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
#app aside .selector-button,
|
||||
#app aside .stepper-button,
|
||||
#app main .stepper-button,
|
||||
#app aside button.touch-none,
|
||||
#app main button.touch-none {
|
||||
border-radius: var(--control-radius) !important;
|
||||
}
|
||||
|
||||
|
||||
#app main button.csv-preview-toggle {
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
#app main button.csv-preview-toggle:hover {
|
||||
background: var(--m3-surface-container-low) !important;
|
||||
}
|
||||
|
||||
.dark #app main button.csv-preview-toggle:hover {
|
||||
background: var(--m3-surface-container-high) !important;
|
||||
}
|
||||
|
||||
/* Fix Plotly Modebar Layout under Tailwind Preflight */
|
||||
.modebar-container {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
right: 0 !important;
|
||||
}
|
||||
|
||||
.modebar-container svg {
|
||||
display: inline-block !important;
|
||||
}
|
||||
@@ -1486,4 +1585,4 @@ button.touch-none:hover, details button.touch-none:hover {
|
||||
background: transparent !important;
|
||||
border-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>差分方程式分析 (Difference Equation Analyzer)</title>
|
||||
<title>級聯差分方程式分析 (Cascade Difference Equation Analyzer)</title>
|
||||
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
|
||||
<link rel="icon" type="image/png" href="/ui/logo.png">
|
||||
<script type="module" crossorigin src="./app.js"></script>
|
||||
|
||||
+3
-3
@@ -4,15 +4,15 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>差分方程式分析 (Difference Equation Analyzer)</title>
|
||||
<title>級聯差分方程式分析 (Cascade Difference Equation Analyzer)</title>
|
||||
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
|
||||
<link rel="stylesheet" href="build/app.css">
|
||||
<link rel="stylesheet" href="build/app.css?v=cascade_ui_low_risk">
|
||||
<link rel="icon" type="image/png" href="logo.png">
|
||||
</head>
|
||||
|
||||
<body class="bg-slate-50 dark:bg-darker text-slate-900 dark:text-gray-200 min-h-screen transition-colors duration-300">
|
||||
<div id="dea-root"></div>
|
||||
<script type="module" src="build/app.js"></script>
|
||||
<script type="module" src="build/app.js?v=cascade_ui_low_risk"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { computeStageFixedCoeffs, createCascadeFilterPayload, normalizeCascadeStage } from '../../src/cascade-stage.js';
|
||||
|
||||
const legacyStage = normalizeCascadeStage({
|
||||
id: 'legacy-1',
|
||||
filterType: 'Lowpass (低通)',
|
||||
b_str: '1, 0',
|
||||
a_str: '1',
|
||||
});
|
||||
|
||||
assert.equal(legacyStage.id, 'legacy-1');
|
||||
assert.equal(legacyStage.isActive, true);
|
||||
assert.equal(legacyStage.isExpanded, false);
|
||||
assert.equal(legacyStage.shiftBitsB, 14);
|
||||
assert.deepEqual(Object.keys(legacyStage.fixedOverrides), ['a', 'b']);
|
||||
|
||||
const fixedStage = normalizeCascadeStage({
|
||||
b_str: '0.5, -0.25',
|
||||
a_str: '1, -0.5',
|
||||
shiftBitsB: 4,
|
||||
shiftBitsA: 5,
|
||||
fixedOverrides: { b: { 1: -9 }, a: {} },
|
||||
});
|
||||
const fixed = computeStageFixedCoeffs(fixedStage, (value) => value.split(',').map(Number));
|
||||
assert.deepEqual(fixed.b, [8, -9]);
|
||||
assert.deepEqual(fixed.a, [32, -16]);
|
||||
|
||||
const stages = [
|
||||
normalizeCascadeStage({ id: 'a', b_str: '1', a_str: '1', shiftBitsB: 12, shiftBitsA: 13, isActive: true }),
|
||||
normalizeCascadeStage({ id: 'b', b_str: '2', a_str: '1', shiftBitsB: 10, shiftBitsA: 11, isActive: false }),
|
||||
];
|
||||
|
||||
const payload = createCascadeFilterPayload({
|
||||
stages,
|
||||
fs: 100000,
|
||||
shiftBitsIn: 9,
|
||||
shiftBitsOut: 8,
|
||||
useRound: true,
|
||||
parseCoeffs: (value) => value.split(',').map(Number),
|
||||
getStageFixedCoeffs: (stage) => ({
|
||||
b: stage.b_str.split(',').map(Number),
|
||||
a: stage.a_str.split(',').map(Number),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(payload.length, 2);
|
||||
assert.equal(payload[0].b_str, '1');
|
||||
assert.equal(payload[1].b_str, '2');
|
||||
assert.equal(payload[0].shift_in, 9);
|
||||
assert.equal(payload[0].shift_out, 12);
|
||||
assert.equal(payload[1].shift_in, 12);
|
||||
assert.equal(payload[1].shift_out, 8);
|
||||
assert.equal(payload[1].isActive, false);
|
||||
assert.equal(payload[0].use_round, true);
|
||||
+206
-20
@@ -1,11 +1,17 @@
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import dea.csv_processing as csv_processing
|
||||
from dea.csv_processing import PRESET_FILE_ID
|
||||
from dea_api import (
|
||||
BodeCascadeParams,
|
||||
BodeCompareParams,
|
||||
BodeParams,
|
||||
DesignParams,
|
||||
@@ -13,13 +19,47 @@ from dea_api import (
|
||||
add_security_headers,
|
||||
calculate_bode,
|
||||
calculate_bode_compare,
|
||||
calculate_bode_compare_cascade,
|
||||
design_filter,
|
||||
filter_csv,
|
||||
filter_csv_download,
|
||||
preset_csv,
|
||||
upload_csv,
|
||||
write_mcu_command,
|
||||
)
|
||||
|
||||
|
||||
class InMemoryUpload:
|
||||
def __init__(self, content, filename):
|
||||
self.content = content
|
||||
self.filename = filename
|
||||
|
||||
async def read(self, _size=None):
|
||||
return self.content
|
||||
|
||||
|
||||
class DeaApiTest(unittest.TestCase):
|
||||
async def upload_and_filter_csv(self, upload, b="1", a="1", col_idx=0, **kwargs):
|
||||
upload_body = await upload_csv(upload)
|
||||
self.assertIn("file_id", upload_body)
|
||||
params = {
|
||||
"b_int": None,
|
||||
"a_int": None,
|
||||
"shift_in": 14,
|
||||
"shift_out": 14,
|
||||
"shift_b": 14,
|
||||
"shift_a": 14,
|
||||
"use_round": False,
|
||||
}
|
||||
params.update(kwargs)
|
||||
return await filter_csv(
|
||||
file_id=upload_body["file_id"],
|
||||
b=b,
|
||||
a=a,
|
||||
col_idx=col_idx,
|
||||
**params,
|
||||
)
|
||||
|
||||
def test_design_returns_normalized_coefficients_for_default_lowpass(self):
|
||||
body = design_filter(
|
||||
DesignParams(
|
||||
@@ -88,6 +128,33 @@ class DeaApiTest(unittest.TestCase):
|
||||
self.assertEqual(len(body["freq"]), len(body["fixed"]["mag"]))
|
||||
self.assertEqual(len(body["ideal"]["phase"]), len(body["fixed"]["phase"]))
|
||||
|
||||
def test_bode_compare_cascade_combines_active_stages(self):
|
||||
body = calculate_bode_compare_cascade(
|
||||
BodeCascadeParams(
|
||||
fs=1000,
|
||||
stages=[
|
||||
{
|
||||
"b": [1.0],
|
||||
"a": [1.0],
|
||||
"b_fixed": [1.0],
|
||||
"a_fixed": [1.0],
|
||||
"isActive": True,
|
||||
},
|
||||
{
|
||||
"b": [0.5, 0.5],
|
||||
"a": [1.0],
|
||||
"b_fixed": [0.5, 0.5],
|
||||
"a_fixed": [1.0],
|
||||
"isActive": True,
|
||||
},
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(len(body["freq"]), len(body["ideal"]["mag"]))
|
||||
self.assertEqual(len(body["freq"]), len(body["fixed"]["mag"]))
|
||||
self.assertEqual(len(body["ideal"]["phase"]), len(body["fixed"]["phase"]))
|
||||
|
||||
def test_mcu_write_rejects_invalid_command_format(self):
|
||||
try:
|
||||
write_mcu_command(MCUWriteParams(command="hello"))
|
||||
@@ -98,14 +165,52 @@ class DeaApiTest(unittest.TestCase):
|
||||
raise AssertionError("Expected invalid MCU command validation to fail")
|
||||
|
||||
|
||||
|
||||
def test_preset_csv_endpoint_returns_local_ignored_file_metadata(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
preset_path = Path(tmp) / "preset_signals.csv"
|
||||
preset_path.write_text("time,value\n0,1\n1,2\n", encoding="utf-8")
|
||||
|
||||
with patch.object(csv_processing, "PRESET_CSV_PATH", str(preset_path)):
|
||||
body = preset_csv()
|
||||
|
||||
self.assertTrue(body["available"])
|
||||
self.assertEqual(body["file_id"], PRESET_FILE_ID)
|
||||
self.assertEqual(body["columns"], ["time", "value"])
|
||||
self.assertEqual(body["default_col_idx"], 1)
|
||||
self.assertEqual(body["rows"], 2)
|
||||
|
||||
def test_filter_can_use_preset_file_id_and_skip_blank_signal_cells(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
preset_path = Path(tmp) / "preset_signals.csv"
|
||||
preset_path.write_text("time,value\n0,1\n1,\n2,3\n", encoding="utf-8")
|
||||
|
||||
with patch.object(csv_processing, "PRESET_CSV_PATH", str(preset_path)):
|
||||
body = asyncio.run(
|
||||
filter_csv(
|
||||
file_id=PRESET_FILE_ID,
|
||||
b="1",
|
||||
a="1",
|
||||
col_idx=1,
|
||||
b_int=None,
|
||||
a_int=None,
|
||||
shift_in=14,
|
||||
shift_out=14,
|
||||
shift_b=14,
|
||||
shift_a=14,
|
||||
use_round=False,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(body["total_points"], 2)
|
||||
self.assertEqual(body["index"], [0, 2])
|
||||
self.assertEqual(body["original"], [1.0, 3.0])
|
||||
|
||||
def test_filter_downsamples_plot_response_for_large_csv(self):
|
||||
rows = ["value"] + [str(i) for i in range(6001)]
|
||||
upload = UploadFile(
|
||||
io.BytesIO(("\n".join(rows) + "\n").encode("utf-8")),
|
||||
filename="input.csv",
|
||||
)
|
||||
upload = InMemoryUpload(("\n".join(rows) + "\n").encode("utf-8"), "input.csv")
|
||||
|
||||
body = asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
|
||||
body = asyncio.run(self.upload_and_filter_csv(upload, b="1", a="1", col_idx=0))
|
||||
|
||||
self.assertEqual(body["total_points"], 6001)
|
||||
self.assertLessEqual(body["plot_points"], 5000)
|
||||
@@ -113,10 +218,10 @@ class DeaApiTest(unittest.TestCase):
|
||||
|
||||
|
||||
def test_filter_rejects_non_numeric_signal_column(self):
|
||||
upload = UploadFile(io.BytesIO(b"value\n1\nbad\n3\n"), filename="input.csv")
|
||||
upload = InMemoryUpload(b"value\n1\nbad\n3\n", "input.csv")
|
||||
|
||||
try:
|
||||
asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
|
||||
asyncio.run(self.upload_and_filter_csv(upload, b="1", a="1", col_idx=0))
|
||||
except HTTPException as exc:
|
||||
self.assertEqual(exc.status_code, 400)
|
||||
self.assertIn("非數值", exc.detail)
|
||||
@@ -124,22 +229,103 @@ class DeaApiTest(unittest.TestCase):
|
||||
raise AssertionError("Expected non-numeric column validation to fail")
|
||||
|
||||
def test_filter_accepts_quoted_csv_fields_and_filters_selected_column(self):
|
||||
upload = UploadFile(
|
||||
io.BytesIO('label,value\n"a,1",1\n"a,2",2\n'.encode("utf-8")),
|
||||
filename="input.csv",
|
||||
)
|
||||
upload = InMemoryUpload('label,value\n"a,1",1\n"a,2",2\n'.encode("utf-8"), "input.csv")
|
||||
|
||||
body = asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=1))
|
||||
body = asyncio.run(self.upload_and_filter_csv(upload, b="1", a="1", col_idx=1))
|
||||
|
||||
self.assertEqual(body["col_name"], "value")
|
||||
self.assertEqual(body["original"], [1.0, 2.0])
|
||||
self.assertEqual(body["filtered"], [1.0, 2.0])
|
||||
|
||||
def test_filter_preview_accepts_index_window_for_lod_zoom(self):
|
||||
upload = InMemoryUpload(b"value\n0\n1\n2\n3\n4\n", "input.csv")
|
||||
|
||||
body = asyncio.run(
|
||||
self.upload_and_filter_csv(
|
||||
upload,
|
||||
b="1",
|
||||
a="1",
|
||||
col_idx=0,
|
||||
start_idx=1,
|
||||
end_idx=4,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(body["total_points"], 5)
|
||||
self.assertEqual(body["index"], [1, 2, 3])
|
||||
self.assertEqual(body["original"], [1.0, 2.0, 3.0])
|
||||
|
||||
def test_filter_accepts_cascade_stages_payload(self):
|
||||
upload = InMemoryUpload(b"value\n1\n2\n3\n", "input.csv")
|
||||
stages = [
|
||||
{
|
||||
"b_str": "1",
|
||||
"a_str": "1",
|
||||
"b_int_str": "16384",
|
||||
"a_int_str": "16384",
|
||||
"shift_in": 14,
|
||||
"shift_out": 14,
|
||||
"shift_b": 14,
|
||||
"shift_a": 14,
|
||||
"use_round": False,
|
||||
"isActive": True,
|
||||
},
|
||||
{
|
||||
"b_str": "1",
|
||||
"a_str": "1",
|
||||
"b_int_str": "16384",
|
||||
"a_int_str": "16384",
|
||||
"shift_in": 14,
|
||||
"shift_out": 14,
|
||||
"shift_b": 14,
|
||||
"shift_a": 14,
|
||||
"use_round": False,
|
||||
"isActive": True,
|
||||
},
|
||||
]
|
||||
|
||||
body = asyncio.run(
|
||||
self.upload_and_filter_csv(
|
||||
upload,
|
||||
b="1",
|
||||
a="1",
|
||||
col_idx=0,
|
||||
stages=json.dumps(stages),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(body["original"], [1.0, 2.0, 3.0])
|
||||
self.assertEqual(body["filtered"], [1.0, 2.0, 3.0])
|
||||
self.assertEqual(body["filtered_fixed"], [1.0, 2.0, 3.0])
|
||||
|
||||
def test_filter_download_can_export_compact_four_column_csv(self):
|
||||
async def run_case():
|
||||
upload = InMemoryUpload(b"value\n1\n2\n", "input.csv")
|
||||
upload_body = await upload_csv(upload)
|
||||
response = await filter_csv_download(
|
||||
file_id=upload_body["file_id"],
|
||||
b="1",
|
||||
a="1",
|
||||
col_idx=0,
|
||||
fs=1000.0,
|
||||
compact=True,
|
||||
)
|
||||
chunks = []
|
||||
async for chunk in response.body_iterator:
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunk if isinstance(chunk, bytes) else chunk.encode() for chunk in chunks).decode()
|
||||
|
||||
csv_text = asyncio.run(run_case())
|
||||
|
||||
self.assertTrue(csv_text.startswith("Time (s),value,value_filtered_ideal,value_filtered_fixed\n"))
|
||||
self.assertIn("0.0,1.0,1.0,1.0", csv_text)
|
||||
self.assertIn("0.001,2.0,2.0,2.0", csv_text)
|
||||
|
||||
def test_filter_rejects_non_csv_filename(self):
|
||||
upload = UploadFile(io.BytesIO(b"value\n1\n"), filename="input.txt")
|
||||
upload = InMemoryUpload(b"value\n1\n", "input.txt")
|
||||
|
||||
try:
|
||||
asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
|
||||
asyncio.run(upload_csv(upload))
|
||||
except HTTPException as exc:
|
||||
self.assertEqual(exc.status_code, 400)
|
||||
self.assertIn("CSV", exc.detail)
|
||||
@@ -147,10 +333,10 @@ class DeaApiTest(unittest.TestCase):
|
||||
raise AssertionError("Expected non-CSV upload validation to fail")
|
||||
|
||||
def test_filter_rejects_empty_csv_upload(self):
|
||||
upload = UploadFile(io.BytesIO(b" \n"), filename="input.csv")
|
||||
upload = InMemoryUpload(b" \n", "input.csv")
|
||||
|
||||
try:
|
||||
asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
|
||||
asyncio.run(upload_csv(upload))
|
||||
except HTTPException as exc:
|
||||
self.assertEqual(exc.status_code, 400)
|
||||
self.assertIn("CSV", exc.detail)
|
||||
@@ -158,10 +344,10 @@ class DeaApiTest(unittest.TestCase):
|
||||
raise AssertionError("Expected empty CSV validation to fail")
|
||||
|
||||
def test_filter_rejects_infinite_signal_values(self):
|
||||
upload = UploadFile(io.BytesIO(b"value\n1\ninf\n3\n"), filename="input.csv")
|
||||
upload = InMemoryUpload(b"value\n1\ninf\n3\n", "input.csv")
|
||||
|
||||
try:
|
||||
asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
|
||||
asyncio.run(self.upload_and_filter_csv(upload, b="1", a="1", col_idx=0))
|
||||
except HTTPException as exc:
|
||||
self.assertEqual(exc.status_code, 400)
|
||||
self.assertIn("有限", exc.detail)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Web UART Terminal
|
||||
|
||||
A sleek, premium, self-contained web application that enables serial port communication directly from your browser using the **Web Serial API**. It supports standard UART configuration controls (Baud rate, data bits, stop bits, parity), ASCII/Hex display, Hex data sending, log exporting, and a responsive dark/light themed user interface.
|
||||
|
||||

|
||||
|
||||
## Prerequisites & Compatibility
|
||||
|
||||
1. **Browser Support**: The Web Serial API is supported in modern Chromium-based browsers, including:
|
||||
- Google Chrome (version 89+)
|
||||
- Microsoft Edge (version 89+)
|
||||
- Opera (version 75+)
|
||||
- *(Note: Firefox, Safari, and iOS browsers are not supported at this time.)*
|
||||
2. **Secure Context**: The browser only permits Web Serial API calls in a secure context (`https://`) or from `localhost` (which Vite handles automatically).
|
||||
|
||||
## Quick Start
|
||||
|
||||
To launch the development server locally:
|
||||
|
||||
1. **Install dependencies**:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Start the local server**:
|
||||
```bash
|
||||
cd ui/uart_terminal
|
||||
# If not create the virtual environment
|
||||
python3 -m venv .venv
|
||||
# Activate it
|
||||
source .venv/bin/activate
|
||||
# start server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. **Open the browser**:
|
||||
Navigate to the URL printed in the terminal (usually `http://localhost:3000`).
|
||||
|
||||
## Features & Usage
|
||||
|
||||
- **Connect / Disconnect**: Click the circular green play icon in the top left to request a COM/USB serial port and open connection. The button changes to a red stop icon when connected.
|
||||
- **Save Logs**: Click the blue download button next to the connection status to export the accumulated console output log as a `.txt` file.
|
||||
- **Scroll Modes**: Switch between "Auto-scroll" and "No scroll" to control terminal navigation.
|
||||
- **Display Formats**:
|
||||
- `Auto`: Prints incoming data as decoded UTF-8 text string (default).
|
||||
- `Hex`: Displays incoming bytes as space-separated hexadecimal numbers (e.g., `55 AA 01 02`).
|
||||
- **Data Transmission**:
|
||||
- Enter text in the send input at the bottom and click the Send button or press `Enter` to transmit.
|
||||
- Switch the send format select dropdown between `ASCII` and `Hex` to format data packages.
|
||||
- **Hex Parsing Rules**: When in Hex mode, you can input hex characters with or without spaces (e.g., `55 AA FF` or `55aaff`). The string must contain an even number of characters and valid hexadecimal characters only.
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
- **Send Data**: Press `Enter` (or `Ctrl+Enter`) inside the input bar to send.
|
||||
- **Insert Tabs**: Press `Ctrl+Tab` in the input bar to insert a literal tab character (`\t`) instead of losing input focus.
|
||||
@@ -0,0 +1,761 @@
|
||||
// DOM Elements Selection
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const connectBtn = document.getElementById('connect-btn');
|
||||
const downloadBtn = document.getElementById('download-btn');
|
||||
const statusBanner = document.getElementById('status-banner');
|
||||
const statusText = document.getElementById('status-text');
|
||||
|
||||
// Settings Dropdowns
|
||||
const baudRateSelect = document.getElementById('baud-rate');
|
||||
const dataBitsSelect = document.getElementById('data-bits');
|
||||
const stopBitsSelect = document.getElementById('stop-bits');
|
||||
const paritySelect = document.getElementById('parity');
|
||||
|
||||
// Controls & Console
|
||||
const scrollModeSelect = document.getElementById('scroll-mode');
|
||||
const displayFormatSelect = document.getElementById('display-format');
|
||||
const terminalContainer = document.getElementById('terminal-container');
|
||||
|
||||
// Sending Inputs
|
||||
const sendInput = document.getElementById('send-input');
|
||||
const sendFormatSelect = document.getElementById('send-format');
|
||||
const sendEolSelect = document.getElementById('send-eol');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
const clearBtn = document.getElementById('clear-btn');
|
||||
const addSessionBtn = document.getElementById('add-session-btn');
|
||||
const sessionListContainer = document.getElementById('session-list');
|
||||
const downloadTypeSelect = document.getElementById('download-type');
|
||||
|
||||
// PortSession Class definition
|
||||
class PortSession {
|
||||
constructor(id, name) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
|
||||
// Web Serial State
|
||||
this.port = null;
|
||||
this.reader = null;
|
||||
this.writer = null;
|
||||
this.keepReading = false;
|
||||
this.rxLogBuffer = [];
|
||||
this.txLogBuffer = [];
|
||||
this.combinedLogBuffer = [];
|
||||
this.downloadType = 'rx_tx';
|
||||
this.commandHistory = [];
|
||||
this.historyIndex = -1;
|
||||
this.isTerminalEmpty = true;
|
||||
this.deviceInfo = '';
|
||||
|
||||
// Settings state
|
||||
this.baudRate = '115200';
|
||||
this.dataBits = '8';
|
||||
this.stopBits = '1';
|
||||
this.parity = 'none';
|
||||
|
||||
// Console controls
|
||||
this.scrollMode = 'auto';
|
||||
this.displayFormat = 'auto';
|
||||
this.sendFormat = 'ascii';
|
||||
this.sendEol = 'crlf';
|
||||
this.sendText = '';
|
||||
|
||||
// Dedicated Terminal Element
|
||||
this.terminalOutputElement = document.createElement('div');
|
||||
this.terminalOutputElement.id = `terminal-output-${this.id}`;
|
||||
this.terminalOutputElement.className = 'terminal-output empty';
|
||||
this.terminalOutputElement.tabIndex = 0;
|
||||
this.terminalOutputElement.textContent = 'No data received yet...';
|
||||
}
|
||||
}
|
||||
|
||||
// Global Application State
|
||||
let sessions = [];
|
||||
let activeSession = null;
|
||||
|
||||
// Initialize theme and browser support on load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initTheme();
|
||||
checkWebSerialSupport();
|
||||
|
||||
// Wire up main event listeners
|
||||
addSessionBtn.addEventListener('click', addSession);
|
||||
|
||||
baudRateSelect.addEventListener('change', () => {
|
||||
if (activeSession) {
|
||||
activeSession.baudRate = baudRateSelect.value;
|
||||
renderSessionsList();
|
||||
}
|
||||
});
|
||||
|
||||
dataBitsSelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.dataBits = dataBitsSelect.value;
|
||||
});
|
||||
|
||||
stopBitsSelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.stopBits = stopBitsSelect.value;
|
||||
});
|
||||
|
||||
paritySelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.parity = paritySelect.value;
|
||||
});
|
||||
|
||||
scrollModeSelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.scrollMode = scrollModeSelect.value;
|
||||
});
|
||||
|
||||
displayFormatSelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.displayFormat = displayFormatSelect.value;
|
||||
});
|
||||
|
||||
sendFormatSelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.sendFormat = sendFormatSelect.value;
|
||||
});
|
||||
|
||||
sendEolSelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.sendEol = sendEolSelect.value;
|
||||
});
|
||||
|
||||
sendInput.addEventListener('input', () => {
|
||||
if (activeSession) activeSession.sendText = sendInput.value;
|
||||
});
|
||||
|
||||
downloadTypeSelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.downloadType = downloadTypeSelect.value;
|
||||
});
|
||||
|
||||
// Initialize with a default session
|
||||
addSession();
|
||||
});
|
||||
|
||||
// Toast notification helper
|
||||
function showToast(message, type = 'success') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast-msg show toast-${type}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 1. Theme Configuration (Dark / Light Mode)
|
||||
// -------------------------------------------------------------
|
||||
function initTheme() {
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
if (savedTheme === 'light') {
|
||||
document.body.classList.add('light-theme');
|
||||
} else {
|
||||
document.body.classList.remove('light-theme');
|
||||
}
|
||||
}
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
document.body.classList.toggle('light-theme');
|
||||
const activeTheme = document.body.classList.contains('light-theme') ? 'light' : 'dark';
|
||||
localStorage.setItem('theme', activeTheme);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 2. Web Serial API Checks & State Updates
|
||||
// -------------------------------------------------------------
|
||||
function checkWebSerialSupport() {
|
||||
if (!('serial' in navigator)) {
|
||||
statusBanner.className = 'status-banner error';
|
||||
statusText.innerHTML = '<strong>Web Serial API not supported.</strong> Please use a compatible Chromium-based browser (Chrome, Edge, Opera).';
|
||||
connectBtn.disabled = true;
|
||||
connectBtn.style.opacity = '0.5';
|
||||
connectBtn.style.cursor = 'not-allowed';
|
||||
addSessionBtn.disabled = true;
|
||||
addSessionBtn.style.opacity = '0.5';
|
||||
addSessionBtn.style.cursor = 'not-allowed';
|
||||
showToast('Web Serial API is not supported in this browser.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateConnectionUI(connected, errorMsg = '') {
|
||||
if (!activeSession) return;
|
||||
|
||||
if (connected && activeSession.port) {
|
||||
connectBtn.className = 'connect-btn connected';
|
||||
connectBtn.title = 'Disconnect from serial port';
|
||||
statusBanner.className = 'status-banner connected';
|
||||
|
||||
const info = activeSession.port.getInfo();
|
||||
const portLabel = info.usbVendorId
|
||||
? `USB Device (VID: 0x${info.usbVendorId.toString(16).toUpperCase()}, PID: 0x${info.usbProductId.toString(16).toUpperCase()})`
|
||||
: 'Serial Device';
|
||||
|
||||
statusText.innerHTML = `Connected to <strong>${portLabel}</strong> at ${activeSession.baudRate} bps.`;
|
||||
} else {
|
||||
connectBtn.className = 'connect-btn disconnected';
|
||||
connectBtn.title = 'Connect to serial port';
|
||||
|
||||
if (errorMsg) {
|
||||
statusBanner.className = 'status-banner error';
|
||||
statusText.textContent = errorMsg;
|
||||
} else {
|
||||
statusBanner.className = 'status-banner warn';
|
||||
statusText.textContent = 'Not connected. Click the connect button to select a port.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 3. Serial Session Management
|
||||
// -------------------------------------------------------------
|
||||
function addSession() {
|
||||
const id = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
|
||||
const name = `Session ${sessions.length + 1}`;
|
||||
const newSession = new PortSession(id, name);
|
||||
|
||||
sessions.push(newSession);
|
||||
terminalContainer.appendChild(newSession.terminalOutputElement);
|
||||
|
||||
selectSession(newSession);
|
||||
showToast(`${name} created.`, 'success');
|
||||
}
|
||||
|
||||
function selectSession(session) {
|
||||
if (activeSession) {
|
||||
activeSession.sendText = sendInput.value;
|
||||
}
|
||||
|
||||
activeSession = session;
|
||||
renderSessionsList();
|
||||
|
||||
// Show only active session's terminal DOM element
|
||||
const terminals = terminalContainer.querySelectorAll('.terminal-output');
|
||||
terminals.forEach(el => {
|
||||
if (el.id === activeSession.terminalOutputElement.id) {
|
||||
el.classList.remove('hidden');
|
||||
} else {
|
||||
el.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Sync dropdown UI values
|
||||
baudRateSelect.value = activeSession.baudRate;
|
||||
dataBitsSelect.value = activeSession.dataBits;
|
||||
stopBitsSelect.value = activeSession.stopBits;
|
||||
paritySelect.value = activeSession.parity;
|
||||
|
||||
scrollModeSelect.value = activeSession.scrollMode;
|
||||
displayFormatSelect.value = activeSession.displayFormat;
|
||||
sendFormatSelect.value = activeSession.sendFormat;
|
||||
sendEolSelect.value = activeSession.sendEol;
|
||||
sendInput.value = activeSession.sendText;
|
||||
downloadTypeSelect.value = activeSession.downloadType;
|
||||
|
||||
downloadBtn.disabled = (activeSession.rxLogBuffer.length === 0 && activeSession.txLogBuffer.length === 0);
|
||||
|
||||
// Sync status banner and buttons
|
||||
const isConnected = activeSession.port !== null && activeSession.port.readable;
|
||||
updateConnectionUI(isConnected);
|
||||
}
|
||||
|
||||
async function deleteSession(session, event) {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (sessions.length <= 1) {
|
||||
showToast('Cannot delete the last remaining session.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Disconnect if connected
|
||||
if (session.port) {
|
||||
await disconnectSession(session);
|
||||
}
|
||||
|
||||
// Remove terminal from DOM
|
||||
if (session.terminalOutputElement && session.terminalOutputElement.parentNode) {
|
||||
session.terminalOutputElement.parentNode.removeChild(session.terminalOutputElement);
|
||||
}
|
||||
|
||||
const index = sessions.indexOf(session);
|
||||
if (index > -1) {
|
||||
sessions.splice(index, 1);
|
||||
}
|
||||
|
||||
if (activeSession === session) {
|
||||
const nextActive = sessions[Math.max(0, index - 1)];
|
||||
selectSession(nextActive);
|
||||
} else {
|
||||
renderSessionsList();
|
||||
}
|
||||
|
||||
showToast(`${session.name} deleted.`, 'success');
|
||||
}
|
||||
|
||||
function renderSessionsList() {
|
||||
sessionListContainer.innerHTML = '';
|
||||
|
||||
sessions.forEach(session => {
|
||||
const item = document.createElement('div');
|
||||
item.className = `session-item ${session === activeSession ? 'active' : ''}`;
|
||||
item.addEventListener('click', () => selectSession(session));
|
||||
|
||||
const leftGroup = document.createElement('div');
|
||||
leftGroup.className = 'session-item-left';
|
||||
|
||||
// Status dot
|
||||
const dot = document.createElement('span');
|
||||
const isConnected = session.port !== null && session.port.readable;
|
||||
dot.className = `status-dot ${isConnected ? 'connected' : 'disconnected'}`;
|
||||
leftGroup.appendChild(dot);
|
||||
|
||||
// Session labels
|
||||
const info = document.createElement('div');
|
||||
info.className = 'session-info';
|
||||
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'session-name';
|
||||
nameEl.textContent = session.name;
|
||||
|
||||
// Double click to rename
|
||||
nameEl.addEventListener('dblclick', (e) => {
|
||||
e.stopPropagation();
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = session.name;
|
||||
input.className = 'session-rename-input';
|
||||
|
||||
const saveRename = () => {
|
||||
const val = input.value.trim();
|
||||
if (val) {
|
||||
session.name = val;
|
||||
}
|
||||
renderSessionsList();
|
||||
};
|
||||
|
||||
input.addEventListener('keydown', (evt) => {
|
||||
if (evt.key === 'Enter') {
|
||||
saveRename();
|
||||
} else if (evt.key === 'Escape') {
|
||||
renderSessionsList();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur', saveRename);
|
||||
|
||||
info.innerHTML = '';
|
||||
info.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
|
||||
const detailEl = document.createElement('span');
|
||||
detailEl.className = 'session-detail';
|
||||
if (isConnected) {
|
||||
detailEl.textContent = `${session.baudRate} bps | ${session.deviceInfo || 'Connected'}`;
|
||||
} else {
|
||||
detailEl.textContent = `${session.baudRate} bps | Disconnected`;
|
||||
}
|
||||
|
||||
info.appendChild(nameEl);
|
||||
info.appendChild(detailEl);
|
||||
leftGroup.appendChild(info);
|
||||
item.appendChild(leftGroup);
|
||||
|
||||
// Close / Delete Session button
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'session-delete-btn';
|
||||
deleteBtn.title = 'Delete Session';
|
||||
deleteBtn.innerHTML = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
`;
|
||||
deleteBtn.addEventListener('click', (e) => deleteSession(session, e));
|
||||
item.appendChild(deleteBtn);
|
||||
|
||||
sessionListContainer.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 4. Serial Port Connection & Reader Loop
|
||||
// -------------------------------------------------------------
|
||||
async function connectSession(session) {
|
||||
try {
|
||||
session.port = await navigator.serial.requestPort();
|
||||
|
||||
const options = {
|
||||
baudRate: parseInt(session.baudRate, 10),
|
||||
dataBits: parseInt(session.dataBits, 10),
|
||||
stopBits: parseInt(session.stopBits, 10),
|
||||
parity: session.parity
|
||||
};
|
||||
|
||||
await session.port.open(options);
|
||||
|
||||
const info = session.port.getInfo();
|
||||
if (info.usbVendorId) {
|
||||
session.deviceInfo = `USB VID:0x${info.usbVendorId.toString(16).toUpperCase()}`;
|
||||
} else {
|
||||
session.deviceInfo = 'Serial Device';
|
||||
}
|
||||
|
||||
if (session === activeSession) {
|
||||
updateConnectionUI(true);
|
||||
}
|
||||
renderSessionsList();
|
||||
|
||||
session.keepReading = true;
|
||||
startSessionReadingLoop(session);
|
||||
|
||||
showToast(`${session.name} connected.`, 'success');
|
||||
} catch (err) {
|
||||
console.error('Connection failed:', err);
|
||||
session.port = null;
|
||||
if (session === activeSession) {
|
||||
updateConnectionUI(false, `Connection failed: ${err.message}`);
|
||||
}
|
||||
showToast(`Failed to open port for ${session.name}.`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectSession(session) {
|
||||
session.keepReading = false;
|
||||
|
||||
if (session.reader) {
|
||||
try {
|
||||
await session.reader.cancel();
|
||||
} catch (e) {
|
||||
console.error('Error cancelling reader:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(async () => {
|
||||
if (session.port) {
|
||||
try {
|
||||
await session.port.close();
|
||||
} catch (e) {
|
||||
console.error('Error closing port:', e);
|
||||
}
|
||||
session.port = null;
|
||||
}
|
||||
session.deviceInfo = '';
|
||||
if (session === activeSession) {
|
||||
updateConnectionUI(false);
|
||||
}
|
||||
renderSessionsList();
|
||||
showToast(`${session.name} disconnected.`, 'success');
|
||||
resolve();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
async function startSessionReadingLoop(session) {
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (session.port && session.port.readable && session.keepReading) {
|
||||
try {
|
||||
session.reader = session.port.readable.getReader();
|
||||
|
||||
while (session.keepReading) {
|
||||
const { value, done } = await session.reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
handleIncomingSessionData(session, value, decoder);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Read error:', err);
|
||||
if (session === activeSession) {
|
||||
updateConnectionUI(false, `Port disconnected: ${err.message}`);
|
||||
}
|
||||
showToast(`${session.name} disconnected unexpectedly.`, 'error');
|
||||
session.port = null;
|
||||
renderSessionsList();
|
||||
break;
|
||||
} finally {
|
||||
if (session.reader) {
|
||||
session.reader.releaseLock();
|
||||
session.reader = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleIncomingSessionData(session, uint8Array, decoder) {
|
||||
const format = session.displayFormat;
|
||||
let formattedText = '';
|
||||
|
||||
if (format === 'hex') {
|
||||
formattedText = Array.from(uint8Array)
|
||||
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
|
||||
.join(' ') + ' ';
|
||||
} else {
|
||||
formattedText = decoder.decode(uint8Array);
|
||||
}
|
||||
|
||||
session.rxLogBuffer.push(formattedText);
|
||||
session.combinedLogBuffer.push(formattedText);
|
||||
appendSessionTerminalText(session, formattedText, 'received');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 5. Writing Data (Transmission)
|
||||
// -------------------------------------------------------------
|
||||
async function sendSessionData() {
|
||||
if (!activeSession) return;
|
||||
const session = activeSession;
|
||||
|
||||
if (!session.port || !session.port.writable) {
|
||||
showToast(`Cannot send. ${session.name} is not connected.`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const rawInput = sendInput.value;
|
||||
if (!rawInput) return;
|
||||
|
||||
// Add command to history (maximum 10 items)
|
||||
const trimmed = rawInput.trim();
|
||||
if (trimmed) {
|
||||
const history = session.commandHistory;
|
||||
if (history.length === 0 || history[history.length - 1] !== trimmed) {
|
||||
history.push(trimmed);
|
||||
if (history.length > 10) {
|
||||
history.shift();
|
||||
}
|
||||
}
|
||||
session.historyIndex = history.length;
|
||||
}
|
||||
|
||||
const format = session.sendFormat;
|
||||
const eol = session.sendEol;
|
||||
let bytesToSend;
|
||||
|
||||
let eolText = '';
|
||||
let eolBytes = new Uint8Array(0);
|
||||
if (eol === 'lf') {
|
||||
eolText = '\n';
|
||||
eolBytes = new Uint8Array([10]);
|
||||
} else if (eol === 'cr') {
|
||||
eolText = '\r';
|
||||
eolBytes = new Uint8Array([13]);
|
||||
} else if (eol === 'crlf') {
|
||||
eolText = '\r\n';
|
||||
eolBytes = new Uint8Array([13, 10]);
|
||||
}
|
||||
|
||||
try {
|
||||
if (format === 'hex') {
|
||||
const parsedBytes = parseHexString(rawInput);
|
||||
bytesToSend = new Uint8Array(parsedBytes.length + eolBytes.length);
|
||||
bytesToSend.set(parsedBytes);
|
||||
bytesToSend.set(eolBytes, parsedBytes.length);
|
||||
} else {
|
||||
bytesToSend = new TextEncoder().encode(rawInput + eolText);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
session.writer = session.port.writable.getWriter();
|
||||
await session.writer.write(bytesToSend);
|
||||
|
||||
let displaySent = '';
|
||||
if (format === 'hex') {
|
||||
displaySent = Array.from(bytesToSend)
|
||||
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
|
||||
.join(' ') + ' ';
|
||||
} else {
|
||||
displaySent = rawInput;
|
||||
if (eol === 'lf') displaySent += '\\n';
|
||||
else if (eol === 'cr') displaySent += '\\r';
|
||||
else if (eol === 'crlf') displaySent += '\\r\\n';
|
||||
}
|
||||
|
||||
appendSessionTerminalText(session, `\n[Sent] ${displaySent}\n`, 'sent');
|
||||
|
||||
// Save sent log
|
||||
session.txLogBuffer.push(`[Sent] ${displaySent}\n`);
|
||||
session.combinedLogBuffer.push(`\n[Sent] ${displaySent}\n`);
|
||||
|
||||
sendInput.value = '';
|
||||
session.sendText = '';
|
||||
|
||||
} catch (err) {
|
||||
console.error('Send error:', err);
|
||||
showToast(`Failed to send: ${err.message}`, 'error');
|
||||
} finally {
|
||||
if (session.writer) {
|
||||
session.writer.releaseLock();
|
||||
session.writer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseHexString(hexString) {
|
||||
const clean = hexString.replace(/\s+/g, '');
|
||||
|
||||
if (!/^[0-9a-fA-F]*$/.test(clean)) {
|
||||
throw new Error('Hex contains invalid characters (0-9, A-F only)');
|
||||
}
|
||||
|
||||
if (clean.length % 2 !== 0) {
|
||||
throw new Error('Hex string must have an even number of characters');
|
||||
}
|
||||
|
||||
const result = new Uint8Array(clean.length / 2);
|
||||
for (let i = 0; i < clean.length; i += 2) {
|
||||
result[i / 2] = parseInt(clean.substring(i, i + 2), 16);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 6. Terminal Console Operations
|
||||
// -------------------------------------------------------------
|
||||
function appendSessionTerminalText(session, text, className = '') {
|
||||
if (session.isTerminalEmpty) {
|
||||
session.terminalOutputElement.textContent = '';
|
||||
session.terminalOutputElement.classList.remove('empty');
|
||||
session.isTerminalEmpty = false;
|
||||
if (session === activeSession) {
|
||||
downloadBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
const span = document.createElement('span');
|
||||
if (className) {
|
||||
span.className = `terminal-line ${className}`;
|
||||
}
|
||||
span.textContent = text;
|
||||
session.terminalOutputElement.appendChild(span);
|
||||
|
||||
if (session.scrollMode === 'auto') {
|
||||
session.terminalOutputElement.scrollTop = session.terminalOutputElement.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function clearTerminal() {
|
||||
if (!activeSession) return;
|
||||
activeSession.terminalOutputElement.innerHTML = 'No data received yet...';
|
||||
activeSession.terminalOutputElement.classList.add('empty');
|
||||
activeSession.isTerminalEmpty = true;
|
||||
activeSession.rxLogBuffer = [];
|
||||
activeSession.txLogBuffer = [];
|
||||
activeSession.combinedLogBuffer = [];
|
||||
downloadBtn.disabled = true;
|
||||
showToast('Terminal cleared.', 'success');
|
||||
}
|
||||
|
||||
function downloadLog() {
|
||||
if (!activeSession) return;
|
||||
|
||||
let content = '';
|
||||
let filenameSuffix = '';
|
||||
|
||||
if (activeSession.downloadType === 'rx') {
|
||||
content = activeSession.rxLogBuffer.join('');
|
||||
filenameSuffix = 'rx';
|
||||
} else if (activeSession.downloadType === 'tx') {
|
||||
content = activeSession.txLogBuffer.join('');
|
||||
filenameSuffix = 'tx';
|
||||
} else {
|
||||
content = activeSession.combinedLogBuffer.join('');
|
||||
filenameSuffix = 'combined';
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
showToast('No logs to download for the selected type.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const dateStr = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `uart_log_${activeSession.name.replace(/\s+/g, '_')}_${filenameSuffix}_${dateStr}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
|
||||
showToast('Download started.', 'success');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 7. Event Listeners & Keyboard Shortcuts
|
||||
// -------------------------------------------------------------
|
||||
connectBtn.addEventListener('click', () => {
|
||||
if (activeSession) {
|
||||
if (activeSession.port) {
|
||||
disconnectSession(activeSession);
|
||||
} else {
|
||||
connectSession(activeSession);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sendBtn.addEventListener('click', sendSessionData);
|
||||
clearBtn.addEventListener('click', clearTerminal);
|
||||
downloadBtn.addEventListener('click', downloadLog);
|
||||
|
||||
sendInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
sendSessionData();
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (activeSession && activeSession.commandHistory.length > 0) {
|
||||
if (activeSession.historyIndex > 0) {
|
||||
activeSession.historyIndex--;
|
||||
sendInput.value = activeSession.commandHistory[activeSession.historyIndex];
|
||||
activeSession.sendText = sendInput.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (activeSession && activeSession.commandHistory.length > 0) {
|
||||
if (activeSession.historyIndex < activeSession.commandHistory.length - 1) {
|
||||
activeSession.historyIndex++;
|
||||
sendInput.value = activeSession.commandHistory[activeSession.historyIndex];
|
||||
activeSession.sendText = sendInput.value;
|
||||
} else {
|
||||
activeSession.historyIndex = activeSession.commandHistory.length;
|
||||
sendInput.value = '';
|
||||
activeSession.sendText = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (e.key === 'Tab' && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
const start = sendInput.selectionStart;
|
||||
const end = sendInput.selectionEnd;
|
||||
|
||||
sendInput.value = sendInput.value.substring(0, start) + '\t' + sendInput.value.substring(end);
|
||||
sendInput.selectionStart = sendInput.selectionEnd = start + 1;
|
||||
if (activeSession) activeSession.sendText = sendInput.value;
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-detect browser USB disconnect events globally
|
||||
if ('serial' in navigator) {
|
||||
navigator.serial.addEventListener('disconnect', async (event) => {
|
||||
const disconnectedSession = sessions.find(s => s.port === event.target);
|
||||
if (disconnectedSession) {
|
||||
await disconnectSession(disconnectedSession);
|
||||
showToast(`${disconnectedSession.name} device disconnected.`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
# Implementation Plan - Web UART Terminal
|
||||
|
||||
This plan details the implementation of a modern, responsive, and feature-rich Web UART Terminal application. The design will match the dark-themed UI provided in the user's reference image and support the Web Serial API for real-time serial communication with hardware.
|
||||
|
||||
## User Review Required
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Browser Compatibility**: The Web Serial API is supported in Chromium-based browsers (Chrome, Edge, Opera). It is not supported by default in Firefox or Safari. A friendly warning banner will be displayed to users accessing the page from unsupported browsers.
|
||||
> **Local Server Requirement**: The Web Serial API requires a secure context (HTTPS) or localhost to work. To facilitate this, we will configure a simple dev environment using **Vite** so you can run it locally with `npm run dev` (serving at `http://localhost:5173`).
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
We will create a self-contained, lightweight, and high-performance Vanilla HTML/CSS/JS application within the `/home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal` directory.
|
||||
|
||||
### UI & Dev Environment Configuration
|
||||
|
||||
---
|
||||
|
||||
#### [NEW] [package.json](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/package.json)
|
||||
- Define a project package with `vite` as a dev dependency.
|
||||
- Include scripts for local development (`npm run dev`) and building (`npm run build`).
|
||||
|
||||
#### [NEW] [vite.config.js](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/vite.config.js)
|
||||
- A basic configuration file for Vite to ensure it runs correctly and resolves static assets.
|
||||
|
||||
#### [NEW] [index.html](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/index.html)
|
||||
- Create the HTML skeleton of the UART Terminal.
|
||||
- Match the layout in the screenshot:
|
||||
- Header: App Title, Theme Toggle button.
|
||||
- Action Control: Connect/Disconnect button (circle with play/stop icon), Download button (square download icon).
|
||||
- Info Banner: "Not connected" warning banner, dynamically changing based on serial connection status.
|
||||
- Serial Settings Grid: Dropdowns for Baud Rate, Data Bits, Stop Bits, Parity.
|
||||
- Received Data Section: Section title, controls (Scroll: Auto-scroll/None, Display: Auto/Hex/Text), console output textarea/container.
|
||||
- Send Data Footer: Form input with keyboard shortcut tooltip, Send Format dropdown (ASCII/Hex), Send button, and Clear Terminal (trash) button.
|
||||
- Embed all required SVG icons inline for zero external dependencies and fast load times.
|
||||
- Include a Google Font link (e.g., "Inter" for UI typography, "JetBrains Mono" for the terminal console).
|
||||
|
||||
#### [NEW] [index.css](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/index.css)
|
||||
- Implement a premium design system using CSS custom properties (variables) for HSL colors.
|
||||
- Support Dark Mode (default) and Light Mode.
|
||||
- Incorporate subtle animations (transitions on hover/active, pulsing connection status, smooth modal fades).
|
||||
- Implement responsive grids and flex layouts, ensuring pixel-perfect alignment with the reference image.
|
||||
- Design custom scrollbars for the terminal output.
|
||||
|
||||
#### [NEW] [app.js](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/app.js)
|
||||
- Implement core UART logic via Web Serial API:
|
||||
- Request serial port and open connection using selected settings (Baud Rate, Data Bits, Stop Bits, Parity).
|
||||
- Background reading loop with cancellation support.
|
||||
- Writing loop supporting ASCII/Hex conversion.
|
||||
- Implement helper utilities:
|
||||
- Hex parser (validating and converting hex string inputs like `AA BB CC` or `AABBCC` to Uint8Array).
|
||||
- Hex formatter (representing received bytes as spaced hex strings e.g. `00 12 AF`).
|
||||
- Implement UI interactions:
|
||||
- Terminal text appending with Auto-scroll capability.
|
||||
- Download log: converts terminal content to a `.txt` blob and triggers a browser download.
|
||||
- Clear log: clears the screen and reset states.
|
||||
- Keyboard shortcuts (e.g. `Ctrl+Enter` to send, `Ctrl+Tab` to insert tab in input, etc.).
|
||||
- Theme toggling with `localStorage` persistence.
|
||||
|
||||
#### [NEW] [README.md](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/README.md)
|
||||
- Instructions on installing dependencies (`npm install`) and launching the development server (`npm run dev`).
|
||||
- Quick notes on Web Serial browser compatibility and security requirements.
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### Manual Verification
|
||||
1. Launch the local dev server using `npm run dev`.
|
||||
2. Access the app via Chrome or Edge browser.
|
||||
3. Verify that the UI matches the reference screenshot (dark theme, layouts, styling, icons, font sizes).
|
||||
4. Verify the theme toggle (switching from dark to light mode and back).
|
||||
5. Verify inputs:
|
||||
- Check if serial configuration dropdowns have correct options.
|
||||
- Verify that clicking "Connect" triggers the browser's Serial Port prompt.
|
||||
- Verify Hex transmission (e.g., inputting `48 45 4C 4C 4F` in Hex mode sends `HELLO`).
|
||||
- Verify Text transmission.
|
||||
- Test "Clear" (trash bin) and "Download" buttons.
|
||||
- Test auto-scroll behaviour with mock data.
|
||||
@@ -0,0 +1,61 @@
|
||||
# UART Terminal Multi-Port Implementation Plan
|
||||
|
||||
The goal is to modify the UART Terminal to support multiple concurrent serial port sessions. We will introduce a sidebar panel on the left to display all sessions (e.g., active/inactive state, baud rate, and custom names) and a main workspace on the right to manage the configuration and terminal log of the currently selected session.
|
||||
|
||||
## User Review Required
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - **Default Session**: On app load, a default "Session 1" will be created and selected. This ensures that users who just want to use a single port don't have to perform any additional clicks.
|
||||
> - **Concurrent Connections**: Each session will maintain its own active connection, serial reader loop, terminal logs, and configurations. This allows the user to have multiple serial devices connected and communicating simultaneously, switching between them at will without losing context or scroll history.
|
||||
> - **Session Renaming**: Users can double-click a session name in the sidebar to rename it to something descriptive (e.g., "MCU 1", "GPS Module").
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### UI / Layout Configuration
|
||||
|
||||
#### [MODIFY] [index.html](file:///home/roy/zData/WTPCode/bodeplot/uart_example/index.html)
|
||||
- Wrap the `<main class="app-main">` and a new `<aside class="app-sidebar">` inside a container `<div class="app-layout">`.
|
||||
- Adjust container structure:
|
||||
- Increase `.app-container` max-width to `1200px` to comfortably host the sidebar and terminal split.
|
||||
- Implement dynamic placeholders for the terminal output elements. Instead of a single `#terminal-output` div, we'll make `#terminal-container` host dynamically created terminal-output elements for each session, toggling their visibility based on the active session.
|
||||
- Add an "Add Session" (`#add-session-btn`) button to the sidebar.
|
||||
|
||||
#### [MODIFY] [index.css](file:///home/roy/zData/WTPCode/bodeplot/uart_example/index.css)
|
||||
- Increase `.app-container` `max-width` to `1200px`.
|
||||
- Add rules for `.app-layout` (CSS Grid or Flexbox layout, split `260px` and `1fr`).
|
||||
- Add styles for the sidebar (`.app-sidebar`):
|
||||
- `.sidebar-header` (flex row, title, add-session button).
|
||||
- `.session-list` (flex column, scrolling).
|
||||
- `.session-item` (states: hover, active, status-dots, title/subtitle, hover delete button).
|
||||
- `.session-rename-input` (input box replacing title on double-click).
|
||||
- Add support for displaying/hiding terminal sessions via class `.hidden { display: none !important; }`.
|
||||
|
||||
### State & Logic Configuration
|
||||
|
||||
#### [MODIFY] [app.js](file:///home/roy/zData/WTPCode/bodeplot/uart_example/app.js)
|
||||
- Define a `PortSession` class to encapsulate all states of a single UART connection:
|
||||
- ID, name, connection state, terminal DOM element, log buffers, input text cache.
|
||||
- Port options: `baudRate`, `dataBits`, `stopBits`, `parity`.
|
||||
- Terminal configs: `scrollMode`, `displayFormat`, `sendFormat`, `sendEol`.
|
||||
- Maintain global variables:
|
||||
- `sessions` array.
|
||||
- `activeSession` pointer.
|
||||
- Refactor connection functions (`connectSerial`, `disconnectSerial`, `startReadingLoop`, `sendData`) to execute against the active session's parameters.
|
||||
- Re-route UI change listeners (Baud Rate selector, display formats, send input, etc.) to update properties on `activeSession` when changed.
|
||||
- Build functions to:
|
||||
- Add a session (`addSession()`).
|
||||
- Select/Activate a session (`activateSession(session)`).
|
||||
- Delete/Remove a session (`deleteSession(session)`).
|
||||
- Sync UI selectors with active session states.
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### Automated Tests
|
||||
We will run `npm run dev` or a local static files server to review the layout, and use our understanding of the DOM state to ensure multiple concurrent sessions behave correctly.
|
||||
|
||||
### Manual Verification
|
||||
1. **Multiple Tabs**: Add 3 ports in the sidebar. Verify they are named Session 1, Session 2, Session 3.
|
||||
2. **Settings Persistence**: Change Baud Rate in Session 1 to `9600` and Session 2 to `115200`. Switch between them and verify that the dropdown value updates to reflect the active session's settings.
|
||||
3. **Session Renaming**: Double-click "Session 1" name in the sidebar, type "Test Port", and press Enter. Verify the label changes.
|
||||
4. **Delete Session**: Select a session and click the `x` delete icon. Verify it gets removed. If connected, it should disconnect safely first.
|
||||
5. **Console logs isolation**: Verify that sending data or logs in one session does not bleed into another session's terminal panel.
|
||||
@@ -0,0 +1,952 @@
|
||||
/* CSS Variables for Premium Design System */
|
||||
:root {
|
||||
/* Dark Theme Variables (Default) */
|
||||
--bg-app: #0b0f19;
|
||||
--bg-panel: #111827;
|
||||
--bg-input: #1f2937;
|
||||
--border-color: #374151;
|
||||
--border-hover: #4b5563;
|
||||
--border-focus: #6366f1;
|
||||
--text-main: #f9fafb;
|
||||
--text-muted: #9ca3af;
|
||||
--terminal-bg: #090d16;
|
||||
--terminal-text: #e5e7eb;
|
||||
--terminal-border: #1f2937;
|
||||
|
||||
/* Accent Colors */
|
||||
--accent-green: #10b981;
|
||||
--accent-green-hover: #059669;
|
||||
--accent-blue: #3b82f6;
|
||||
--accent-blue-hover: #2563eb;
|
||||
--accent-purple: #6366f1;
|
||||
--accent-purple-hover: #4f46e5;
|
||||
--accent-warn-bg: #fef3c7;
|
||||
--accent-warn-text: #b45309;
|
||||
--accent-warn-border: #f59e0b;
|
||||
--accent-danger: #ef4444;
|
||||
--accent-danger-hover: #dc2626;
|
||||
|
||||
/* Layout and Styling Details */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 16px;
|
||||
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', source-code-pro, Menlo, Monaco, Consolas, monospace;
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.15);
|
||||
--transition-fast: 0.15s ease;
|
||||
--transition-normal: 0.25s ease;
|
||||
}
|
||||
|
||||
/* Light Theme Variables */
|
||||
body.light-theme {
|
||||
--bg-app: #f3f4f6;
|
||||
--bg-panel: #ffffff;
|
||||
--bg-input: #f9fafb;
|
||||
--border-color: #d1d5db;
|
||||
--border-hover: #9ca3af;
|
||||
--border-focus: #4f46e5;
|
||||
--text-main: #111827;
|
||||
--text-muted: #6b7280;
|
||||
--terminal-bg: #f8fafc;
|
||||
--terminal-text: #0f172a;
|
||||
--terminal-border: #e2e8f0;
|
||||
|
||||
--accent-warn-bg: #fffbeb;
|
||||
--accent-warn-text: #b45309;
|
||||
--accent-warn-border: #f59e0b;
|
||||
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
/* Base resets & styling */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-app);
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-ui);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
transition: background-color var(--transition-normal), color var(--transition-normal);
|
||||
}
|
||||
|
||||
/* App container */
|
||||
.app-container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
padding: 2.5rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
color: var(--text-main);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#theme-toggle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-main);
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
transition: border-color var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
#theme-toggle:hover {
|
||||
border-color: var(--border-hover);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
body.light-theme #theme-toggle:hover {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
/* Toggle Sun/Moon icons depending on active class */
|
||||
.sun-icon {
|
||||
display: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.moon-icon {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
body.light-theme .sun-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
body.light-theme .moon-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Main Content Area */
|
||||
.app-main {
|
||||
background-color: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
transition: background-color var(--transition-normal), border-color var(--transition-normal);
|
||||
}
|
||||
|
||||
/* Action Controls & Banner Row */
|
||||
.control-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Circular Connect Button */
|
||||
.connect-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
transition: transform var(--transition-fast), background-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.connect-btn:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.connect-btn.disconnected {
|
||||
background-color: var(--accent-green);
|
||||
}
|
||||
|
||||
.connect-btn.disconnected:hover {
|
||||
background-color: var(--accent-green-hover);
|
||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.connect-btn.connected {
|
||||
background-color: var(--accent-danger);
|
||||
}
|
||||
|
||||
.connect-btn.connected:hover {
|
||||
background-color: var(--accent-danger-hover);
|
||||
box-shadow: 0 0 12px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.connect-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Play/Stop SVG displays */
|
||||
.connect-btn.disconnected .stop-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.connect-btn.disconnected .play-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.connect-btn.connected .stop-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.connect-btn.connected .play-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Download Button */
|
||||
.download-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background-color: var(--accent-blue);
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform var(--transition-fast), background-color var(--transition-fast), opacity var(--transition-fast);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.download-btn:hover:not(:disabled) {
|
||||
background-color: var(--accent-blue-hover);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.download-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.download-btn svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* Status Banner */
|
||||
.status-banner {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 1.25rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
border: 1px solid transparent;
|
||||
transition: background-color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Status Banner Themes */
|
||||
.status-banner.warn {
|
||||
background-color: rgba(245, 158, 11, 0.1);
|
||||
border-color: rgba(245, 158, 11, 0.25);
|
||||
color: var(--accent-warn-border);
|
||||
}
|
||||
|
||||
body.light-theme .status-banner.warn {
|
||||
background-color: var(--accent-warn-bg);
|
||||
border-color: var(--accent-warn-border);
|
||||
color: var(--accent-warn-text);
|
||||
}
|
||||
|
||||
.status-banner.connected {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
body.light-theme .status-banner.connected {
|
||||
background-color: #ecfdf5;
|
||||
border-color: var(--accent-green);
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.status-banner.error {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.25);
|
||||
color: var(--accent-danger);
|
||||
}
|
||||
|
||||
body.light-theme .status-banner.error {
|
||||
background-color: #fef2f2;
|
||||
border-color: var(--accent-danger);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* Port Config Dropdowns Grid */
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.control-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.action-buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.settings-group label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Custom Styled Select Dropdown */
|
||||
.select-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.select-wrapper select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-main);
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.select-wrapper select:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.select-wrapper select:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
/* Select arrow icon custom styling */
|
||||
.select-wrapper::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 1.25rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 10px;
|
||||
height: 6px;
|
||||
background-color: var(--text-muted);
|
||||
clip-path: polygon(100% 0, 0 0, 50% 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Small Select Dropdowns in UI Controls */
|
||||
.select-wrapper-sm select {
|
||||
padding: 0.4rem 2rem 0.4rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.select-wrapper-sm::after {
|
||||
right: 0.75rem;
|
||||
width: 8px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
/* Received Data Terminal Area */
|
||||
.received-data-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.received-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.received-header h2 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.received-controls {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.control-select-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.control-select-group label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.terminal-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.terminal-output {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
background-color: var(--terminal-bg);
|
||||
border: 1px solid var(--terminal-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.25rem;
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
color: var(--terminal-text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast), background-color var(--transition-normal), color var(--transition-normal);
|
||||
}
|
||||
|
||||
.terminal-output:focus {
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
.terminal-output.empty {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for Terminal Output */
|
||||
.terminal-output::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.terminal-output::-webkit-scrollbar-track {
|
||||
background: var(--terminal-bg);
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
}
|
||||
|
||||
.terminal-output::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 5px;
|
||||
border: 2px solid var(--terminal-bg);
|
||||
}
|
||||
|
||||
.terminal-output::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-hover);
|
||||
}
|
||||
|
||||
/* Send Data Controls Footer */
|
||||
.send-data-section {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.send-bar {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-container input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-main);
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.input-container input:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.input-container input:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.input-container input::placeholder {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.send-format-wrapper {
|
||||
width: 90px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.send-eol-wrapper {
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Button UI */
|
||||
.send-btn, .clear-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
transition: transform var(--transition-fast), background-color var(--transition-fast);
|
||||
box-shadow: var(--shadow-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
background-color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.send-btn:hover {
|
||||
background-color: var(--accent-purple-hover);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.send-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
border-color: var(--accent-danger);
|
||||
color: var(--accent-danger);
|
||||
background-color: rgba(239, 68, 68, 0.05);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
body.light-theme .clear-btn:hover {
|
||||
background-color: #fef2f2;
|
||||
}
|
||||
|
||||
.clear-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.send-btn svg, .clear-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Terminal Line Highlights & Formatting */
|
||||
.terminal-line {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.terminal-line.sent {
|
||||
color: var(--accent-purple);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
body.light-theme .terminal-line.sent {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.terminal-line.error {
|
||||
color: var(--accent-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.terminal-line.system {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Toast Notification Utility */
|
||||
.toast-msg {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
background-color: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transform: translateY(100px);
|
||||
opacity: 0;
|
||||
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.3s ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.toast-msg.show {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
border-color: var(--accent-danger);
|
||||
color: var(--accent-danger);
|
||||
}
|
||||
|
||||
/* Sidebar and Split Layout */
|
||||
.app-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.app-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
background-color: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
transition: background-color var(--transition-normal), border-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.add-session-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background-color: var(--accent-purple);
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform var(--transition-fast), background-color var(--transition-fast);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.add-session-btn:hover {
|
||||
background-color: var(--accent-purple-hover);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.add-session-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for Session List */
|
||||
.session-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.session-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.session-list::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.session-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-hover);
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast);
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.session-item:hover {
|
||||
border-color: var(--border-hover);
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
body.light-theme .session-item:hover {
|
||||
background-color: rgba(0, 0, 0, 0.01);
|
||||
}
|
||||
|
||||
.session-item.active {
|
||||
border-color: var(--border-focus);
|
||||
background-color: rgba(99, 102, 241, 0.08);
|
||||
box-shadow: 0 0 0 1px var(--border-focus);
|
||||
}
|
||||
|
||||
body.light-theme .session-item.active {
|
||||
background-color: rgba(99, 102, 241, 0.05);
|
||||
}
|
||||
|
||||
.session-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
transition: background-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background-color: var(--accent-green);
|
||||
box-shadow: 0 0 8px var(--accent-green);
|
||||
}
|
||||
|
||||
.session-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-detail {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-delete-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color var(--transition-fast), background-color var(--transition-fast);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.session-item:hover .session-delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.session-delete-btn:hover {
|
||||
color: var(--accent-danger);
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.session-rename-input {
|
||||
background-color: var(--bg-panel);
|
||||
border: 1px solid var(--border-focus);
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Download Group Control Styling */
|
||||
.download-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 3px 6px 3px 3px;
|
||||
height: 48px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: border-color var(--transition-fast), background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.download-group:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.download-group .download-btn {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border-radius: 6px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.download-group .download-btn:hover:not(:disabled) {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.download-select-wrapper {
|
||||
width: 105px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.download-select-wrapper select {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
padding: 0.4rem 1.75rem 0.4rem 0.5rem !important;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-main);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.download-select-wrapper select option {
|
||||
background-color: var(--bg-panel);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.download-select-wrapper::after {
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>UART Terminal</title>
|
||||
<!-- Google Fonts for premium design: Inter for UI, JetBrains Mono for monospace data -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<link rel="stylesheet" href="./index.css">
|
||||
</head>
|
||||
|
||||
<body class="dark-theme">
|
||||
<div class="app-container">
|
||||
<!-- Header -->
|
||||
<header class="app-header">
|
||||
<h1>UART Terminal</h1>
|
||||
<button id="theme-toggle" class="icon-btn" aria-label="Toggle Theme">
|
||||
<!-- Sun Icon -->
|
||||
<svg class="sun-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
<!-- Moon Icon -->
|
||||
<svg class="moon-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- App Layout Split -->
|
||||
<div class="app-layout">
|
||||
<!-- Sidebar Panel -->
|
||||
<aside class="app-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>Sessions</h2>
|
||||
<button id="add-session-btn" class="add-session-btn" title="Add new session">
|
||||
<!-- Plus Icon -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="session-list" class="session-list">
|
||||
<!-- Session items dynamically injected -->
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Workspace -->
|
||||
<main class="app-main">
|
||||
|
||||
<!-- Connection Controls & Status -->
|
||||
<section class="control-row">
|
||||
<div class="action-buttons">
|
||||
<button id="connect-btn" class="connect-btn disconnected" title="Connect to serial port">
|
||||
<!-- Play Icon -->
|
||||
<svg class="play-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="6,4 20,12 6,20"></polygon>
|
||||
</svg>
|
||||
<!-- Stop Icon -->
|
||||
<svg class="stop-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="5" y="5" width="14" height="14" rx="2"></rect>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="download-group">
|
||||
<button id="download-btn" class="download-btn" title="Download log" disabled>
|
||||
<!-- Download Icon -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="select-wrapper select-wrapper-sm download-select-wrapper">
|
||||
<select id="download-type" title="Select download content">
|
||||
<option value="rx_tx" selected>RX & TX</option>
|
||||
<option value="rx">RX Only</option>
|
||||
<option value="tx">TX Only</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection Status Banner -->
|
||||
<div id="status-banner" class="status-banner warn">
|
||||
<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
<span id="status-text">Not connected. Click the connect button to select a port.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Settings Form Grid -->
|
||||
<section class="settings-grid">
|
||||
<div class="settings-group">
|
||||
<label for="baud-rate">Baud Rate</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="baud-rate">
|
||||
<option value="300">300</option>
|
||||
<option value="1200">1200</option>
|
||||
<option value="2400">2400</option>
|
||||
<option value="4800">4800</option>
|
||||
<option value="9600">9600</option>
|
||||
<option value="19200">19200</option>
|
||||
<option value="38400">38400</option>
|
||||
<option value="57600">57600</option>
|
||||
<option value="74880">74880</option>
|
||||
<option value="115200" selected>115200</option>
|
||||
<option value="230400">230400</option>
|
||||
<option value="250000">250000</option>
|
||||
<option value="500000">500000</option>
|
||||
<option value="1000000">1000000</option>
|
||||
<option value="2000000">2000000</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label for="data-bits">Data Bits</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="data-bits">
|
||||
<option value="8" selected>8</option>
|
||||
<option value="7">7</option>
|
||||
<option value="6">6</option>
|
||||
<option value="5">5</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label for="stop-bits">Stop Bits</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="stop-bits">
|
||||
<option value="1" selected>1</option>
|
||||
<option value="2">2</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label for="parity">Parity</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="parity">
|
||||
<option value="none" selected>none</option>
|
||||
<option value="even">even</option>
|
||||
<option value="odd">odd</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Received Data Section -->
|
||||
<section class="received-data-section">
|
||||
<div class="received-header">
|
||||
<h2>Received Data</h2>
|
||||
<div class="received-controls">
|
||||
<div class="control-select-group">
|
||||
<label for="scroll-mode">Scroll</label>
|
||||
<div class="select-wrapper select-wrapper-sm">
|
||||
<select id="scroll-mode">
|
||||
<option value="auto" selected>Auto-scroll</option>
|
||||
<option value="none">No scroll</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-select-group">
|
||||
<label for="display-format">Display Format</label>
|
||||
<div class="select-wrapper select-wrapper-sm">
|
||||
<select id="display-format">
|
||||
<option value="auto" selected>Auto</option>
|
||||
<option value="text">Text</option>
|
||||
<option value="hex">Hex</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Console -->
|
||||
<div id="terminal-container" class="terminal-wrapper">
|
||||
<!-- Terminal elements will be dynamically appended here per session -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Send Data Input Area -->
|
||||
<section class="send-data-section">
|
||||
<div class="send-bar">
|
||||
<div class="input-container">
|
||||
<input type="text" id="send-input" placeholder="Enter data to send..." autocomplete="off">
|
||||
</div>
|
||||
<div class="select-wrapper select-wrapper-sm send-format-wrapper">
|
||||
<select id="send-format">
|
||||
<option value="ascii" selected>ASCII</option>
|
||||
<option value="hex">Hex</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="select-wrapper select-wrapper-sm send-eol-wrapper">
|
||||
<select id="send-eol">
|
||||
<option value="none">None</option>
|
||||
<option value="lf">LF (\n)</option>
|
||||
<option value="cr">CR (\r)</option>
|
||||
<option value="crlf" selected>CRLF (\r\n)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="send-btn" class="send-btn" title="Send Data (Ctrl+Enter)">
|
||||
<!-- Send / Paper Airplane Icon -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="clear-btn" class="clear-btn" title="Clear Terminal Console">
|
||||
<!-- Trash Icon -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
<line x1="10" y1="11" x2="10" y2="17"></line>
|
||||
<line x1="14" y1="11" x2="14" y2="17"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Generated
+1026
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "web-uart-terminal",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^5.2.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
open: false, // Don't auto open browser in command-line environments to avoid errors
|
||||
},
|
||||
base: './',
|
||||
});
|
||||
Reference in New Issue
Block a user