feat: implement drag-and-drop reordering for cascade stages and update UI plot labels
This commit is contained in:
+87
-64
@@ -2,9 +2,9 @@
|
||||
<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">
|
||||
class="bg-white dark:bg-dark border-b border-slate-200 dark:border-gray-800 py-1.5 px-4 shadow-md flex justify-between items-center z-10 flex-shrink-0">
|
||||
<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">
|
||||
<img :src="'/ui/logo.png'" alt="Logo" style="width: 28px; height: 28px;" class="object-contain rounded-md shadow-sm">
|
||||
<h1 class="text-xl font-bold text-slate-900 dark:text-gray-100">
|
||||
級聯差分方程式分析 (Cascade Difference Equation Analyzer)</h1>
|
||||
</div>
|
||||
@@ -12,13 +12,13 @@
|
||||
<!-- 模式切換按鈕 -->
|
||||
<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">
|
||||
<svg v-if="isDarkMode" class="w-5 h-5 role-theme-icon 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-5 h-5 text-slate-800" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg v-else class="w-5 h-5 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>
|
||||
</button>
|
||||
@@ -29,7 +29,7 @@
|
||||
class="lg:hidden flex border-b border-slate-200 dark:border-gray-800 bg-white dark:bg-dark z-10 flex-shrink-0">
|
||||
<button @click="mobileTab = 'settings'" :class="mobileTab === 'settings'
|
||||
? 'border-b-2 role-border-primary role-text-primary bg-transparent'
|
||||
: 'text-slate-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,18 +37,18 @@
|
||||
<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>
|
||||
@@ -57,6 +57,51 @@
|
||||
<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">
|
||||
|
||||
<!-- 系統級參數與 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>
|
||||
<h2
|
||||
@@ -69,40 +114,10 @@
|
||||
<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">
|
||||
選擇連接埠並連線
|
||||
</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="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>
|
||||
@@ -113,36 +128,52 @@
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(stage, idx) in cascadeStages" :key="stage.id"
|
||||
class="rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden">
|
||||
: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>
|
||||
<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="moveCascadeStageUp(idx)" :disabled="idx === 0"
|
||||
class="px-2 py-1 rounded bg-slate-200 dark:bg-gray-700 text-slate-700 dark:text-gray-300 disabled:opacity-40 text-xs">↑</button>
|
||||
<button type="button" @click="moveCascadeStageDown(idx)" :disabled="idx === cascadeStages.length - 1"
|
||||
class="px-2 py-1 rounded bg-slate-200 dark:bg-gray-700 text-slate-700 dark:text-gray-300 disabled:opacity-40 text-xs">↓</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" class="px-3 pb-3 text-xs text-slate-600 dark:text-gray-400 border-t border-slate-100 dark:border-gray-700">
|
||||
<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>
|
||||
<span :class="stage.isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-500 dark:text-gray-500'">
|
||||
{{ stage.isActive ? 'Active' : 'Bypass' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pt-2">
|
||||
<div class="flex flex-wrap items-center gap-2 pt-2">
|
||||
<button type="button" @click="toggleCascadeStage(idx)"
|
||||
class="px-3 py-1 rounded-full bg-slate-200 dark:bg-gray-700 text-slate-700 dark:text-gray-300">
|
||||
{{ stage.isActive ? '設為 Bypass' : '設為 Active' }}
|
||||
</button>
|
||||
<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">
|
||||
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>
|
||||
@@ -156,7 +187,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>截止頻率
|
||||
@@ -288,9 +319,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
@@ -300,16 +331,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
|
||||
@@ -321,7 +344,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">
|
||||
@@ -522,7 +545,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">
|
||||
@@ -780,9 +803,9 @@
|
||||
<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="mb-6 rounded-lg border border-slate-100 dark:border-gray-800 bg-slate-50 dark:bg-gray-900/50 overflow-hidden">
|
||||
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="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">
|
||||
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>
|
||||
|
||||
+120
-11
@@ -34,6 +34,14 @@ export default {
|
||||
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,
|
||||
@@ -478,15 +486,114 @@ export default {
|
||||
moveCascadeStageDown(index) {
|
||||
this.moveCascadeStage(index, index + 1);
|
||||
},
|
||||
moveCascadeStage(fromIndex, toIndex) {
|
||||
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) => {
|
||||
item.isExpanded = index === this.activeCascadeStageIndex;
|
||||
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 });
|
||||
@@ -1361,9 +1468,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 },
|
||||
@@ -1406,8 +1513,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' };
|
||||
@@ -1698,8 +1805,8 @@ export default {
|
||||
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: '理想路徑 (Ideal Float)', type: 'scatter', line: { color: isDark ? '#64d2ff' : '#2d6f8f', width: 2.8 }, opacity: 0.95 },
|
||||
{ x: xData, 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.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');
|
||||
@@ -1746,6 +1853,7 @@ export default {
|
||||
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);
|
||||
@@ -1756,7 +1864,7 @@ export default {
|
||||
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; }
|
||||
@@ -1805,6 +1913,7 @@ export default {
|
||||
},
|
||||
|
||||
async writeToMCU() {
|
||||
const stageNumber = this.activeCascadeStageIndex + 1;
|
||||
if (!this.mcuSerialPort || !this.mcuConnected) {
|
||||
this.mcuStatus = '請先連線 MCU 連接埠';
|
||||
return;
|
||||
@@ -1822,10 +1931,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;
|
||||
}
|
||||
|
||||
+91
-3
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1464,6 +1464,94 @@ button.touch-none:hover, details button.touch-none:hover {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
#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;
|
||||
|
||||
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user