feat: implement dynamic time-domain LOD zoom, locked axis multipliers, and clean 4-column CSV export
This commit is contained in:
+19
-9
@@ -59,15 +59,25 @@ A reactive "Single Page Application" (SPA) approach.
|
||||
- **API `/api/filter`**: 接收檔案 ID 與係數參數,返回兩組數列(Float / Fixed)。
|
||||
- **UI 圖表**: 同時繪製兩條曲線,讓開發者直觀判斷量化是否導致不穩定 (Instability) 或顯著失真。
|
||||
|
||||
### 3. 高效能資料處理架構 (High-Performance Data Processing)
|
||||
- **檔案快取與防呆**:
|
||||
- 支援高達 **300 MB** 的 CSV 檔案上傳。
|
||||
- 當檔案被選擇後,立即背景上傳至後端暫存區 (`temp_csv/`),避免重複傳輸。
|
||||
- 嚴格限制時域運算最多處理 **1,200,000 筆** 資料點,保護伺服器不因 O(N) 的純 Python 整數運算而死當。
|
||||
- **精準記憶體讀取**:
|
||||
- 利用 Pandas 的 `usecols` 參數,僅載入使用者選定的單一訊號欄位,極大幅度降低記憶體佔用。
|
||||
- **即時預覽 (Live Preview)**:
|
||||
- 透過前端的 Debounce 機制 (500ms),當使用者點擊 +/- 按鈕微調 Q-format 參數或係數時,系統會自動使用快取的檔案呼叫 API 並重繪圖表,達成無縫且直覺的實驗體驗。
|
||||
### 3. 高效能資料處理與區域高畫質重取樣架構 (Adaptive LOD & Resampling)
|
||||
- **區域高畫質重取樣 (LOD Zoom Resampling)**:
|
||||
- 當使用者在前端圖表進行 Zoom / Pan 縮放時,系統會捕捉可視的時序區間,轉換成數列 Index,向後端發送包含 `start_idx` 與 `end_idx` 的 Form 參數。
|
||||
- 後端保持**全時域 IIR 濾波器狀態暫存與記憶運算**(保證濾波響應的物理與數學狀態 100% 正確),但僅針對該可視區間進行切片 (Slice) 並降採樣至 5,000 點傳回,在大幅減少頻寬的同時,讓使用者在 Zoom In 後依然能看到最高精度的時域微小细節(如量化雜訊與過渡漣波)。
|
||||
- **時序比例鎖定 (Stable Time Scaling)**:
|
||||
- 將時間軸單位與 multiplier 在波形**首次全載入時進行計算與鎖定**(如 `ms`、`μs` 或 `s`),避免了 Plotly `uirevision` 狀態因 Zoom In 觸發單位動態改變所引發的座標軸錯位(Clashing)。
|
||||
- **重繪遞迴鎖定旗標 (Redraw Loop Lock)**:
|
||||
- 實作了 `isRedrawingTimePlot` 狀態鎖。在前端執行 `Plotly.react()` 佈局重繪時,自動遮蔽並忽略所引發的同步 `plotly_relayout` 佈局回呼,完全消除了繪圖重繪與 API 請求之間的 recursive 無窮迴圈,保證在 50 萬筆大資料下縮放依然流暢。
|
||||
|
||||
### 4. 限縮數據全新 CSV 匯出與多長度支援 (Restructured Export & NaN-Dropping)
|
||||
- **精簡 4 欄 CSV 結構**:
|
||||
- 匯出下載功能不再沿用並污染原本包含無數無關欄位的上傳 CSV 檔案,而是主動為使用者建構並匯出一個**全新、精簡的 4 欄式專屬 CSV 檔案**:
|
||||
1. `Time (s)`:由前端傳送取樣率 `fs`,後端根據實際數據點 Index 精確計算之秒數時間軸。
|
||||
2. `[原輸入欄位]`:去除無效欄位與尾部 NaN 的原始時域輸入。
|
||||
3. `[原輸入欄位]_filtered_ideal`:理想浮點數二階級聯濾波器輸出。
|
||||
4. `[原輸入欄位]_filtered_fixed`:定點數硬體級 Round/Floor 濾波器模擬輸出。
|
||||
- **不同訊號長度支援 (NaN-Dropping)**:
|
||||
- 後端 CSV 處理器支援每一訊號欄位擁有**獨立的實體有效長度**。當資料讀入時,後端會自動套用 `.dropna()` 拋棄尾部空白 `NaN`,並將系統點數 `total_points` 動態截斷為實際有效長度(例如 Step 預設訊號自動截斷為 `100 ms` 的 10,001 行,McDonald's Wave 自動截斷為 `200 ms` 的 20,001 行),極大提升了模擬運算速度與匯出精準度。
|
||||
- 同時實施了索引遮罩比對 `(numeric_nans & ~original_nans)`,能精準區分「因長度不足產生的尾部空白」與「因 CSV 資料損毀產生的非數值字串 (如 bad)」,確保上傳檔案的安全驗證防線不被破壞。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -143,3 +143,23 @@
|
||||
- **步階訊號腳本**:新增了 `generate_step_signal.py`,支援自動產生精確時間長度 (預設 30 ms) 與多種動態範圍 (0 to 1, 0.1 to 0.9, 0.2 to 0.8 等) 的步階函數測試 CSV 檔,方便快速驗證濾波器的步階響應 (Step Response) 與穩定時間 (Settling Time)。
|
||||
|
||||
*Last Updated: 2026-05-22 06:15*
|
||||
|
||||
---
|
||||
|
||||
# DSP Bode Plot 專案進度更新 (2026-05-26 - 時域動態高畫質縮放與全新 CSV 匯出架構)
|
||||
|
||||
## 16. 時域動態高畫質重取樣與時序縮放對齊 (Adaptive LOD & Scaling Lock)
|
||||
- **區域高畫質重取樣**:在時域圖表實作了動態重取樣。當使用者對圖表進行 Zoom / Pan 時,前端會抓取可視時間範圍,轉換為取樣點索引,向後端 `/api/filter` 發送動態 sliced 請求。後端在**保證完整濾波歷史狀態(IIR 記憶狀態)正確**的前提下,對該可視區域重新進行 5,000 點高畫質重取樣,讓微端細節(量化雜訊、步階過渡漣波)在 Zoom In 後纖毫畢現!
|
||||
- **座標軸比例對齊鎖定**:修正了 Plotly `uirevision` 保留舊秒數範圍但新資料縮放到毫秒導致的座標 clashing 錯誤。我們將時間單位與乘數在**首次全波形載入時進行鎖定**,不論使用者如何縮放,座標系皆保持絕對一致與對齊。
|
||||
- **無窮迴圈與 Lag 消除**:新增重繪鎖定旗標 `isRedrawingTimePlot`。在 `Plotly.react()` 執行佈局重繪時,自動忽略同步 layout 產生的 recursive 迴圈,CPU 佔用率大減,介面縮放達到極致流暢!
|
||||
|
||||
## 17. 限縮數據全新 CSV 匯出 (Restructured CSV Export)
|
||||
- **數據專屬匯出**:改寫了 `/api/filter/download` 匯出行為。取代以往冗餘附加到原始大檔案的作法,系統現在會產生一個**乾淨的全新 4 欄 CSV 檔案**:`Time (s)`、`[輸入欄位]`、`[輸入欄位]_filtered_ideal` 以及 `[輸入欄位]_filtered_fixed`。
|
||||
- **實體時間重構**:由前端將當前取樣率 `fs` 傳送至後端,後端根據實際有效點數的 Index 對應 `fs` 計算出精確的 `Time (s)` 列,確保匯出的時間軸與繪圖完全一致。
|
||||
- **支援不同欄位長度 (NaN-Dropping)**:後端支援對每一欄位獨立進行尾部 `NaN` 截斷,使 `Step (0 to 1)` 預設訊號自動限縮至 `100 ms`(10,001 行),`McDonald's Wave` 縮短至 `200 ms`,匯出的 CSV 點數與實際有效長度 100% 精準對齊,無任何多餘空白或零填充。
|
||||
|
||||
## 18. UI/UX 緊湊排版與側欄空間釋放
|
||||
- **側邊欄寬度縮小 17%**:將左側控制欄寬度從 `410px` 精簡至 `340px`,釋放出水平螢幕空間給右側響應圖表,使其在大螢幕與筆電上看起來更寬闊、視覺效果極為 premium。
|
||||
- **匯出按鈕移至預覽上方一列化**:為了最大化利用垂直空間,將「匯出串聯濾波 CSV」按鈕移到「級聯時域訊號模擬」的說明文字右側,以水平 Flex Row 對齊,並將說明文字更新為「可上傳時域輸入訊號的 CSV 檔案 。訊號將循序通過 (Cascade) 所有啟用 (Active) 的濾波器階段。」。
|
||||
|
||||
*Last Updated: 2026-05-26 14:43*
|
||||
|
||||
+58
-10
@@ -15,6 +15,11 @@ TEMP_CSV_DIR = "temp_csv"
|
||||
os.makedirs(TEMP_CSV_DIR, exist_ok=True)
|
||||
|
||||
def get_cached_file_path(file_id):
|
||||
if file_id == "00000000-0000-0000-0000-000000000000":
|
||||
preset_path = "static/preset_signals.csv"
|
||||
if os.path.exists(preset_path):
|
||||
return preset_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")
|
||||
@@ -114,7 +119,7 @@ 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, stages=None):
|
||||
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 讀取後找不到原始索引
|
||||
@@ -125,10 +130,19 @@ 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")
|
||||
|
||||
if x_signal.isna().any():
|
||||
# Check if there are non-numeric strings coerced to NaN
|
||||
original_nans = df[col_to_filter].isna()
|
||||
x_signal = pd.to_numeric(df[col_to_filter], errors="coerce")
|
||||
numeric_nans = x_signal.isna()
|
||||
if (numeric_nans & ~original_nans).any():
|
||||
raise HTTPException(status_code=400, detail=f"欄位 {col_to_filter} 含有非數值資料")
|
||||
|
||||
# Drop trailing/all NaN empty cells to support different sequence lengths natively
|
||||
x_signal = x_signal.dropna()
|
||||
if len(x_signal) == 0:
|
||||
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} 含有非有限數值")
|
||||
@@ -161,7 +175,24 @@ def filter_preview_response(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=
|
||||
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)
|
||||
total_len = len(x_values)
|
||||
|
||||
# Robust type checking to bypass FastAPI Form object default values in python unit tests
|
||||
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_len, int(end_idx_clean)) if end_idx_clean is not None else total_len
|
||||
if s_idx >= e_idx:
|
||||
s_idx = 0
|
||||
e_idx = total_len
|
||||
|
||||
x_sliced = x_values[s_idx:e_idx]
|
||||
y_float_sliced = y_float[s_idx:e_idx]
|
||||
y_fixed_sliced = y_fixed[s_idx:e_idx]
|
||||
idx_sliced = x_signal.index.to_numpy()[s_idx:e_idx]
|
||||
|
||||
index, original, filtered_float, filtered_fixed, step = downsample_for_plot(idx_sliced, x_sliced, y_float_sliced, y_fixed_sliced)
|
||||
|
||||
return {
|
||||
"index": index.tolist(),
|
||||
@@ -169,13 +200,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(len(x_signal)),
|
||||
"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, stages=None):
|
||||
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):
|
||||
path = get_cached_file_path(file_id)
|
||||
# 匯出時需要原始所有欄位,但仍受限於 MAX_ROWS
|
||||
df = pd.read_csv(path, nrows=MAX_ROWS)
|
||||
@@ -183,8 +214,17 @@ 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]
|
||||
|
||||
# Check if there are non-numeric strings coerced to NaN
|
||||
original_nans = df[col_to_filter].isna()
|
||||
x_signal = pd.to_numeric(df[col_to_filter], errors="coerce")
|
||||
x_values = x_signal.to_numpy(dtype=float)
|
||||
numeric_nans = x_signal.isna()
|
||||
if (numeric_nans & ~original_nans).any():
|
||||
raise HTTPException(status_code=400, detail=f"欄位 {col_to_filter} 含有非數值資料")
|
||||
|
||||
# Drop NaNs to process shorter signals properly
|
||||
x_signal_clean = x_signal.dropna()
|
||||
x_values = x_signal_clean.to_numpy(dtype=float)
|
||||
|
||||
if stages is not None:
|
||||
y_float = x_values
|
||||
@@ -210,10 +250,18 @@ def filtered_csv_text(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None,
|
||||
else:
|
||||
y_fixed = y_float
|
||||
|
||||
df[f"{col_to_filter}_filtered_ideal"] = y_float
|
||||
df[f"{col_to_filter}_filtered_fixed"] = y_fixed
|
||||
# Build a brand-new DataFrame containing exactly the 4 requested columns
|
||||
time_values = x_signal_clean.index.to_numpy(dtype=float) / fs
|
||||
|
||||
export_df = pd.DataFrame({
|
||||
"Time (s)": time_values,
|
||||
col_to_filter: x_values,
|
||||
f"{col_to_filter}_filtered_ideal": y_float,
|
||||
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()
|
||||
|
||||
|
||||
|
||||
+8
-2
@@ -146,6 +146,8 @@ async def filter_csv(
|
||||
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")
|
||||
@@ -162,7 +164,9 @@ async def filter_csv(
|
||||
shift_in=shift_in, shift_out=shift_out,
|
||||
shift_b=shift_b, shift_a=shift_a,
|
||||
use_round=use_round,
|
||||
stages=stages_list
|
||||
stages=stages_list,
|
||||
start_idx=start_idx,
|
||||
end_idx=end_idx
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -205,6 +209,7 @@ async def filter_csv_download(
|
||||
shift_a: int = Form(14),
|
||||
use_round: bool = Form(False),
|
||||
stages: str = Form(None),
|
||||
fs: float = Form(100000.0),
|
||||
):
|
||||
try:
|
||||
b_vals = parse_coefficients(b, "b")
|
||||
@@ -221,7 +226,8 @@ async def filter_csv_download(
|
||||
shift_in=shift_in, shift_out=shift_out,
|
||||
shift_b=shift_b, shift_a=shift_a,
|
||||
use_round=use_round,
|
||||
stages=stages_list
|
||||
stages=stages_list,
|
||||
fs=fs
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
|
||||
+26
-14
@@ -57,7 +57,7 @@
|
||||
<div class="flex flex-col lg:flex-row flex-1 overflow-hidden">
|
||||
<!-- 左側控制面板 -->
|
||||
<aside :class="mobileTab === 'settings' ? 'flex' : 'hidden'"
|
||||
class="lg:flex flex-col w-full lg:w-[410px] h-full bg-white dark:bg-dark border-b lg:border-b-0 lg:border-r border-slate-200 dark:border-gray-800 overflow-y-auto p-4 gap-4 custom-scrollbar transition-colors duration-300">
|
||||
class="lg:flex flex-col w-full lg:w-[340px] h-full bg-white dark:bg-dark border-b lg:border-b-0 lg:border-r border-slate-200 dark:border-gray-800 overflow-y-auto p-4 gap-4 custom-scrollbar transition-colors duration-300">
|
||||
|
||||
<!-- 系統級參數與 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">
|
||||
@@ -723,9 +723,20 @@
|
||||
級聯時域訊號模擬 (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-500 dark:text-gray-400 mb-4">
|
||||
上傳包含測試訊號的 CSV 檔案。訊號將<span class="font-bold role-text-primary underline">循序通過 (Cascade)</span> 所有啟用 (Active) 的濾波器階段。
|
||||
</p>
|
||||
<!-- Description & Export Button in the same row -->
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-4">
|
||||
<p class="text-base text-slate-500 dark:text-gray-400 flex-1 pr-4">
|
||||
可上傳時域輸入訊號的 CSV 檔案 。訊號將<span class="font-bold role-text-primary underline">循序通過 (Cascade)</span> 所有啟用 (Active) 的濾波器階段。
|
||||
</p>
|
||||
<button v-if="filterDone" @click="downloadCsv"
|
||||
class="bg-slate-700 hover:bg-slate-600 role-text-on-fill px-4 py-2 rounded-lg text-sm font-semibold transition-all flex items-center gap-1.5 shadow-sm active:scale-95 flex-shrink-0">
|
||||
<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>
|
||||
|
||||
<!-- CSV Preview -->
|
||||
<div v-if="csvPreview.length > 0" class="mb-6 border border-slate-100 dark:border-gray-800 rounded-xl overflow-hidden shadow-sm">
|
||||
@@ -773,7 +784,16 @@
|
||||
<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 || csvFileId === '00000000-0000-0000-0000-000000000000'" class="text-base role-text-primary font-mono">
|
||||
{{ csvFile ? csvFile.name : 'preset_signals.csv (系統預設波形)' }}
|
||||
</span>
|
||||
<button v-if="csvFile" @click="restorePresetWaveforms"
|
||||
class="text-sm font-semibold bg-slate-200 dark:bg-gray-800 hover:bg-slate-300 dark:hover:bg-gray-700 role-text-secondary px-3.5 py-2.5 rounded-lg border border-slate-300/60 dark:border-gray-700/60 transition-all flex items-center gap-1.5 shadow-sm active:scale-95">
|
||||
<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="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"></path>
|
||||
</svg>
|
||||
使用內建預設波形
|
||||
</button>
|
||||
<span v-if="csvInfo" class="text-sm text-slate-500 dark:text-gray-400 font-mono">
|
||||
{{ csvInfo.rows }} 筆 / {{ csvInfo.columns }} 欄
|
||||
</span>
|
||||
@@ -791,20 +811,12 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button v-if="csvFile" @click="processFilter"
|
||||
<button v-if="csvFile || csvFileId === '00000000-0000-0000-0000-000000000000'" @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 ? '串聯處理中...' : '⚡ 執行級聯模擬' }}
|
||||
</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>
|
||||
匯出串聯濾波 CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Cascading Simulation Settings -->
|
||||
|
||||
+136
-9
@@ -37,6 +37,7 @@ export default {
|
||||
csvFile: null, csvFileId: null, csvColumns: [], csvPreview: [], csvInfo: null, csvParseError: null,
|
||||
isCsvPreviewExpanded: true,
|
||||
selectedColumn: 0, loadingFilter: false, filterDone: false, timePlotData: null,
|
||||
zoomStartIdx: null, zoomEndIdx: null,
|
||||
mobileTab: 'settings', // 手機版頁籤:'settings' | 'chart'
|
||||
useRound: true, // 是否使用 Rounding 取代 Floor
|
||||
sense_in: '2x', sense_out: '2x',
|
||||
@@ -65,6 +66,9 @@ export default {
|
||||
writingMCU: false,
|
||||
mcuStatus: '',
|
||||
bodeMagRange: null, // [min, max] for Y-axis
|
||||
timePlotUnit: null,
|
||||
timePlotMultiplier: null,
|
||||
isRedrawingTimePlot: false,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -90,6 +94,7 @@ export default {
|
||||
|
||||
this.updateBodeMagRange(initialStage);
|
||||
this.applyFilterDesign(initialStage);
|
||||
this.loadPresetWaveforms();
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('pointerdown', this.rememberPointerType);
|
||||
@@ -1141,6 +1146,44 @@ export default {
|
||||
return { rows, totalRows };
|
||||
},
|
||||
|
||||
loadPresetWaveforms() {
|
||||
this.csvFile = null;
|
||||
this.csvFileId = "00000000-0000-0000-0000-000000000000";
|
||||
this.csvColumns = ['Time (s)', 'Chirp (Amp 1.0)', 'Chirp Small (Amp 0.01)', 'Step (0 to 1)', "McDonald's Wave"];
|
||||
this.csvPreview = [
|
||||
['0.0', '1.0', '0.01', '0.0', '0.0'],
|
||||
['1e-5', '0.9999999980', '0.0099999999', '0.0', '0.006283'],
|
||||
['2e-5', '0.9999999921', '0.0099999999', '0.0', '0.012566'],
|
||||
['3e-5', '0.9999999822', '0.0099999998', '0.0', '0.018848'],
|
||||
['4e-5', '0.9999999684', '0.0099999996', '0.0', '0.025130'],
|
||||
];
|
||||
this.csvInfo = {
|
||||
rows: 498999,
|
||||
columns: 5,
|
||||
size: 24365509,
|
||||
};
|
||||
this.selectedColumn = 1; // Default to Chirp (Amp 1.0)
|
||||
this.filterDone = true;
|
||||
this.timePlotData = null;
|
||||
this.zoomStartIdx = null;
|
||||
this.zoomEndIdx = null;
|
||||
this.timePlotUnit = null;
|
||||
this.timePlotMultiplier = null;
|
||||
this.isCsvPreviewExpanded = true;
|
||||
this.csvParseError = null;
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.processFilter();
|
||||
});
|
||||
},
|
||||
|
||||
restorePresetWaveforms() {
|
||||
if (this.$refs.fileInput) {
|
||||
this.$refs.fileInput.value = '';
|
||||
}
|
||||
this.loadPresetWaveforms();
|
||||
},
|
||||
|
||||
resetCsvState() {
|
||||
this.csvFile = null;
|
||||
this.csvFileId = null;
|
||||
@@ -1152,6 +1195,10 @@ export default {
|
||||
this.selectedColumn = 0;
|
||||
this.filterDone = false;
|
||||
this.timePlotData = null;
|
||||
this.zoomStartIdx = null;
|
||||
this.zoomEndIdx = null;
|
||||
this.timePlotUnit = null;
|
||||
this.timePlotMultiplier = null;
|
||||
},
|
||||
|
||||
handleFileUpload(event) {
|
||||
@@ -1241,6 +1288,10 @@ export default {
|
||||
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;
|
||||
|
||||
@@ -1269,6 +1320,10 @@ export default {
|
||||
formData.append('a', this.stages[0].a_str); // fallback
|
||||
formData.append('col_idx', this.selectedColumn);
|
||||
formData.append('stages', JSON.stringify(stagesPayload));
|
||||
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 });
|
||||
@@ -1292,32 +1347,94 @@ export default {
|
||||
const textColor = isDark ? '#b4b4bd' : '#525663';
|
||||
const plotBgColor = isDark ? '#090a0d' : '#ffffff';
|
||||
|
||||
const maxTimeS = (data.index.length > 0 ? Math.max(...data.index) : 0) / this.fs;
|
||||
let timeMultiplier = 1;
|
||||
let timeUnit = 's';
|
||||
if (maxTimeS > 0 && maxTimeS < 1e-3) {
|
||||
timeMultiplier = 1e6;
|
||||
timeUnit = 'μs';
|
||||
} else if (maxTimeS > 0 && maxTimeS < 1) {
|
||||
timeMultiplier = 1e3;
|
||||
timeUnit = 'ms';
|
||||
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: 'true',
|
||||
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: `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: 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', (eventData) => {
|
||||
this.handlePlotRelayout(eventData);
|
||||
});
|
||||
gd._hasRelayoutListener = true;
|
||||
}
|
||||
},
|
||||
|
||||
handlePlotRelayout(eventData) {
|
||||
if (this.isRedrawingTimePlot) return;
|
||||
if (!this.timePlotData || !this.timePlotData.total_points) return;
|
||||
|
||||
let isZoomed = false;
|
||||
let range0 = null;
|
||||
let range1 = null;
|
||||
|
||||
if (eventData['xaxis.range[0]'] !== undefined && eventData['xaxis.range[1]'] !== undefined) {
|
||||
range0 = eventData['xaxis.range[0]'];
|
||||
range1 = eventData['xaxis.range[1]'];
|
||||
isZoomed = true;
|
||||
} else if (eventData['xaxis.autorange'] === true) {
|
||||
isZoomed = false;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isZoomed) {
|
||||
const timeMultiplier = this.timePlotMultiplier || 1;
|
||||
const fs = this.fs;
|
||||
let startIdx = Math.max(0, Math.floor((range0 * fs) / timeMultiplier));
|
||||
let endIdx = Math.min(this.timePlotData.total_points, Math.ceil((range1 * fs) / timeMultiplier));
|
||||
|
||||
if (endIdx - startIdx > 10) {
|
||||
this.zoomStartIdx = startIdx;
|
||||
this.zoomEndIdx = endIdx;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.zoomStartIdx = null;
|
||||
this.zoomEndIdx = null;
|
||||
}
|
||||
|
||||
if (this._zoomTimeout) clearTimeout(this._zoomTimeout);
|
||||
this._zoomTimeout = setTimeout(() => {
|
||||
this.processFilter();
|
||||
}, 150);
|
||||
},
|
||||
|
||||
async downloadCsv() {
|
||||
@@ -1348,6 +1465,7 @@ export default {
|
||||
formData.append('a', this.stages[0].a_str); // fallback
|
||||
formData.append('col_idx', this.selectedColumn);
|
||||
formData.append('stages', JSON.stringify(stagesPayload));
|
||||
formData.append('fs', this.fs);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/filter/download', { method: 'POST', body: formData });
|
||||
@@ -1443,6 +1561,15 @@ export default {
|
||||
handler() {
|
||||
this.debouncedUpdateBode();
|
||||
}
|
||||
},
|
||||
selectedColumn(newVal, oldVal) {
|
||||
if (newVal !== oldVal) {
|
||||
this.zoomStartIdx = null;
|
||||
this.zoomEndIdx = null;
|
||||
this.timePlotUnit = null;
|
||||
this.timePlotMultiplier = null;
|
||||
this.processFilter();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+499000
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user