const { createApp } = Vue; createApp({ data() { return { fs: 100000.0, b_str: "0.5, 0.5, 0.0, 0.0", a_str: "1.0, 0.0, 0.0, 0.0", filterType: "Lowpass (低通)", filterOptions: [ "Lowpass (低通)", "Highpass (高通)", "Bandpass (帶通)", "Notch (陷波器)", "1P1Z (一極一零)", "2P2Z (二極二零)", "PID 控制器", "SOGI-Alpha (帶通)", "SOGI-Beta (低通)", "(無) 手動自訂" ], systemGain: 1.0, 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, 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, }, // 模式切換 isDarkMode: true, // 係數倍率微調 baseB: [0.5, 0.5, 0, 0, 0, 0, 0], baseA: [1, 0, 0, 0, 0, 0, 0], bSliders: [0, 0, 0, 0, 0, 0, 0], aSliders: [0, 0, 0, 0, 0, 0], // a1~a6 (index 0 unused for display but maps to a[1]~a[6]) sense_b: '2x', sense_a: '2x', loadingBode: false, bodeTimeout: null, globalError: null, csvFile: null, csvColumns: [], csvPreview: [], selectedColumn: 0, loadingFilter: false, filterDone: false, timePlotData: null, mobileTab: 'settings', // 手機版頁籤:'settings' | 'chart' shiftBits: 14, fixedOverrides: { a: {}, b: {} }, outOfRangeB: [false, false, false, false, false, false, false], outOfRangeA: [false, false, false, false, false, false, false], } }, computed: { maxLogB() { if (this.sense_b === '1%') return Math.log10(1.01); if (this.sense_b === '2x') return Math.log10(2.0); return 1.0; }, maxLogA() { if (this.sense_a === '1%') return Math.log10(1.01); if (this.sense_a === '2x') return Math.log10(2.0); if (this.sense_a === '10x') return Math.log10(10.0); return Math.log10(2.0); }, b_int_str: { get() { return this.fixedPointCoeffs.b.map(item => item.val).join(', '); }, set(val) { const cleaned = val.replace(/[^0-9,\-\s]/g, ''); const nums = this.parseCoeffs(cleaned); nums.forEach((n, i) => { this.fixedOverrides.b[i] = Math.round(n); }); } }, a_int_str: { get() { return this.fixedPointCoeffs.a.map(item => item.val).join(', '); }, set(val) { const cleaned = val.replace(/[^0-9,\-\s]/g, ''); const nums = this.parseCoeffs(cleaned); nums.forEach((n, i) => { this.fixedOverrides.a[i] = Math.round(n); }); } }, fixedPointCoeffs() { const scale = Math.pow(2, this.shiftBits); const b_raw = this.parseCoeffs(this.b_str); const a_raw = this.parseCoeffs(this.a_str); const b = b_raw.map((x, i) => { const autoVal = Math.round(x * scale); const val = (this.fixedOverrides.b[i] !== undefined) ? this.fixedOverrides.b[i] : autoVal; return { label: `b${i}`, val, isOverridden: this.fixedOverrides.b[i] !== undefined }; }); const a = a_raw.map((x, i) => { const autoVal = Math.round(x * scale); const val = (this.fixedOverrides.a[i] !== undefined) ? this.fixedOverrides.a[i] : autoVal; return { label: `a${i}`, val, isOverridden: this.fixedOverrides.a[i] !== undefined }; }); return { b, a }; }, floatingCoeffs() { const b = this.parseCoeffs(this.b_str).map((x, i) => ({ label: `b${i}`, val: parseFloat(x.toPrecision(6)) })); const a = this.parseCoeffs(this.a_str).map((x, i) => ({ label: `a${i}`, val: parseFloat(x.toPrecision(6)) })); return { b, a }; }, isManualDirty() { if (this.bSliders.some(v => v !== 0) || this.aSliders.some(v => v !== 0)) return true; const currentB = this.padTo7(this.parseCoeffs(this.b_str)); const currentA = this.padTo7(this.parseCoeffs(this.a_str)); const targetB = this.baseB.map(x => x * (this.systemGain || 1.0)); const targetA = this.baseA; for (let i = 0; i < 7; i++) { const bVal = currentB[i] || 0; const bTarget = targetB[i] || 0; // 使用相對誤差,避免數值較大時 (如 K > 10000) 產生的精確度誤判 const epsB = Math.max(1e-8, Math.abs(bTarget) * 1e-10); if (Math.abs(bVal - bTarget) > epsB) return true; const aVal = currentA[i] || 0; const aTarget = targetA[i] || 0; const epsA = Math.max(1e-8, Math.abs(aTarget) * 1e-10); if (Math.abs(aVal - aTarget) > epsA) return true; } return false; } }, mounted() { const savedTheme = localStorage.getItem('theme'); if (savedTheme) { this.isDarkMode = savedTheme === 'dark'; } if (this.isDarkMode) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } this.applyFilterDesign(); }, methods: { toggleDarkMode() { this.isDarkMode = !this.isDarkMode; localStorage.setItem('theme', this.isDarkMode ? 'dark' : 'light'); if (this.isDarkMode) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } this.updateBodePlot(); if (this.timePlotData) { this.drawTimePlot(this.timePlotData); } }, formatK(val) { if (val >= 1000000) return `(~${(val / 1000000).toFixed(1).replace('.0', '')}M)`; if (val >= 1000) return `(~${(val / 1000).toFixed(1).replace('.0', '')}k)`; return ""; }, parseCoeffs(str) { return str.split(',').map(x => parseFloat(x.trim())).filter(x => !isNaN(x)); }, padTo7(arr) { const r = arr.slice(0, 7); while (r.length < 7) r.push(0); return r; }, syncBaseFromText() { const gain = this.systemGain || 1.0; this.baseB = this.padTo7(this.parseCoeffs(this.b_str)).map(x => x / gain); this.baseA = this.padTo7(this.parseCoeffs(this.a_str)); this.baseA[0] = 1.0; this.fixedOverrides = { a: {}, b: {} }; this.bSliders = [0, 0, 0, 0, 0, 0, 0]; this.aSliders = [0, 0, 0, 0, 0, 0]; this.outOfRangeB = [false, false, false, false, false, false, false]; this.outOfRangeA = [false, false, false, false, false, false, false]; }, resetSlidersB() { this.bSliders = [0, 0, 0, 0, 0, 0, 0]; this.b_str = this.baseB.map(x => parseFloat(x.toPrecision(10))).join(', '); }, resetSlidersA() { this.aSliders = [0, 0, 0, 0, 0, 0]; this.a_str = this.baseA.map(x => parseFloat(x.toPrecision(10))).join(', '); }, clearManualAdjustments() { this.bSliders = [0, 0, 0, 0, 0, 0, 0]; this.aSliders = [0, 0, 0, 0, 0, 0]; const gain = this.systemGain || 1.0; this.b_str = this.baseB.map(x => parseFloat((x * gain).toPrecision(10))).join(', '); this.a_str = this.baseA.map(x => parseFloat(x.toPrecision(10))).join(', '); }, updateFromControls() { const currentB = this.baseB.map((base, i) => base * (this.systemGain || 1.0) * Math.pow(10, this.bSliders[i] || 0)); const currentA = [1.0]; for (let i = 1; i < 7; i++) { currentA.push(this.baseA[i] * Math.pow(10, this.aSliders[i] || 0)); } this.b_str = currentB.map(x => parseFloat(x.toPrecision(10))).join(', '); this.a_str = currentA.map(x => parseFloat(x.toPrecision(10))).join(', '); }, updateFromText() { // 不再呼叫 syncBaseFromText(),保留基礎設計 (baseB/baseA) 作為參考點 // 只更新圖表,讓滑桿維持在相對應的偏差位置 this.updateBodePlot(); }, calcSlidersFromText() { const gain = this.systemGain || 1.0; const currentB = this.parseCoeffs(this.b_str); const currentA = this.parseCoeffs(this.a_str); // 計算 b 係數滑桿 for (let i = 0; i < 7; i++) { const val = currentB[i] || 0; const base = this.baseB[i] || 0; this.outOfRangeB[i] = false; if (base !== 0 && val !== 0 && (val/(base*gain)) > 0) { const logVal = Math.log10(val / (base * gain)); if (Math.abs(logVal) > this.maxLogB) this.outOfRangeB[i] = true; this.bSliders[i] = Math.max(-this.maxLogB, Math.min(this.maxLogB, logVal)); } else { this.bSliders[i] = 0; if (val !== 0 && base !== 0) this.outOfRangeB[i] = true; // 符號不同或極端 } } // 計算 a 係數滑桿 (a0 恆為 1, 從 a1 開始) for (let i = 1; i < 7; i++) { const val = currentA[i] || 0; const base = this.baseA[i] || 0; this.outOfRangeA[i] = false; if (base !== 0 && val !== 0 && (val/base) > 0) { const logVal = Math.log10(val / base); if (Math.abs(logVal) > this.maxLogA) this.outOfRangeA[i] = true; this.aSliders[i] = Math.max(-this.maxLogA, Math.min(this.maxLogA, logVal)); } else { this.aSliders[i] = 0; if (val !== 0 && base !== 0) this.outOfRangeA[i] = true; } } }, debouncedUpdateFromText(type) { if (type === 'b') this.b_str = this.b_str.replace(/[^0-9,.\-\s]/g, ''); if (type === 'a') this.a_str = this.a_str.replace(/[^0-9,.\-\s]/g, ''); // 當文字改變時,反向計算滑桿位置 this.calcSlidersFromText(); }, onlyNumberKey(e, allowDot = true) { // 允許的控制鍵 const allowedKeys = ['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab', 'Enter', 'Home', 'End']; if (allowedKeys.includes(e.key) || e.ctrlKey || e.metaKey) return; // 檢查字元是否合法 const charRegex = allowDot ? /[0-9,.\-\s]/ : /[0-9,\-\s]/; if (!charRegex.test(e.key)) { e.preventDefault(); } }, updateFromFixed(idx, type, val) { const intVal = parseInt(val); if (isNaN(intVal)) return; this.fixedOverrides[type][idx] = intVal; this.updateBodePlot(); }, clearFixedOverrides() { this.fixedOverrides = { a: {}, b: {} }; }, onFsChange() { if (this.filterType !== "(無) 手動自訂") { this.applyFilterDesign(); } else { this.updateBodePlot(); } }, debouncedApply() { clearTimeout(this.bodeTimeout); this.bodeTimeout = setTimeout(() => { if (this.filterType !== "(無) 手動自訂") this.applyFilterDesign(); else this.updateBodePlot(); }, 300); }, debouncedUpdateBode() { clearTimeout(this.bodeTimeout); this.bodeTimeout = setTimeout(() => this.updateBodePlot(), 150); }, onGainChange() { this.updateFromControls(); this.debouncedUpdateBode(); }, onFilterTypeChange() { this.systemGain = 1.0; // 這裡可以選擇是否重設所有設計參數 (fc, Q 等) // 根據使用者要求「載入預設值」,我們呼叫重設邏輯 if (this.filterType !== "(無) 手動自訂") { this.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, 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, }; } this.applyFilterDesign(); }, resetFilterParams() { this.systemGain = 1.0; this.fixedOverrides = { a: {}, b: {} }; this.bSliders = [0, 0, 0, 0, 0, 0, 0]; this.aSliders = [0, 0, 0, 0, 0, 0]; this.outOfRangeB = [false, false, false, false, false, false, false]; this.outOfRangeA = [false, false, false, false, false, false, false]; if (this.filterType === "(無) 手動自訂") { this.b_str = "1.0, 0.0, 0.0, 0.0"; this.a_str = "1.0, 0.0, 0.0, 0.0"; this.updateBodePlot(); } else { this.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, 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, }; this.applyFilterDesign(); } }, async applyFilterDesign() { if (this.filterType === "(無) 手動自訂") return; this.globalError = null; try { const payload = { filter_type: this.filterType, fs: this.fs, lp_fc: this.params.fc, lp_order: this.params.order, hp_fc: this.params.fc, hp_order: this.params.order, bp_f_low: this.params.bp_f_low, bp_f_high: this.params.bp_f_high, bp_order: this.params.bp_order, notch_f0: this.params.notch_f0, notch_q: this.params.notch_q, opoz_fz: this.params.opoz_fz, opoz_fp: this.params.opoz_fp, tptz_fz1: this.params.tptz_fz1, tptz_fz2: this.params.tptz_fz2, tptz_fp1: this.params.tptz_fp1, tptz_fp2: this.params.tptz_fp2, pid_kp: this.params.kp, pid_ki: this.params.ki, pid_kd: this.params.kd, sogi_f0: this.params.sogi_f0, sogi_k: this.params.sogi_k }; const res = await fetch('/api/design', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '設計濾波器失敗'); } const data = await res.json(); // 1. 將後端傳回的單位增益係數存入 baseB this.baseB = this.padTo7(data.b); this.baseA = this.padTo7(data.a); this.baseA[0] = 1.0; // 2. 重新計算含增益的文字框顯示與滑桿位置 this.bSliders = [0, 0, 0, 0, 0, 0, 0]; this.aSliders = [0, 0, 0, 0, 0, 0]; this.updateFromControls(); // 3. 更新圖表 this.updateBodePlot(); } catch (e) { this.globalError = e.message; } }, async updateBodePlot() { this.loadingBode = true; this.globalError = null; try { let b_ideal = this.parseCoeffs(this.b_str); let a_ideal = this.parseCoeffs(this.a_str); if (b_ideal.length === 0) b_ideal = [1.0]; if (a_ideal.length === 0) a_ideal = [1.0]; const scale = Math.pow(2, this.shiftBits); const b_fixed = b_ideal.map((x, i) => { const intVal = (this.fixedOverrides.b[i] !== undefined) ? this.fixedOverrides.b[i] : Math.round(x * scale); return intVal / scale; }); const a_fixed = a_ideal.map((x, i) => { const intVal = (this.fixedOverrides.a[i] !== undefined) ? this.fixedOverrides.a[i] : Math.round(x * scale); return intVal / scale; }); const [resIdeal, resFixed] = await Promise.all([ fetch('/api/bode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ b: b_ideal, a: a_ideal, fs: this.fs }) }), fetch('/api/bode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ b: b_fixed, a: a_fixed, fs: this.fs }) }) ]); if (!resIdeal.ok || !resFixed.ok) throw new Error('無法計算頻率響應'); const dataIdeal = await resIdeal.json(); const dataFixed = await resFixed.json(); this.drawBodePlot(dataIdeal.freq, dataIdeal.mag, dataIdeal.phase, dataFixed.mag, dataFixed.phase); // 手機版:計算完成後自動切換到圖表頁籤 if (window.innerWidth < 1024) this.mobileTab = 'chart'; } catch (e) { this.globalError = e.message; } finally { this.loadingBode = false; } }, drawBodePlot(freq, magIdeal, phaseIdeal, magFixed, phaseFixed) { const fs = this.fs; const fMin = freq[0], fMax = freq[freq.length - 1]; const isDark = this.isDarkMode; const isSmallScreen = window.innerWidth < 768; // 產生 1-2-5 刻度 const xTicks = [], xTexts = []; const pStart = Math.floor(Math.log10(fMin)); const pEnd = Math.ceil(Math.log10(fMax)); for (let p = pStart; p <= pEnd; p++) { for (const m of [1, 2, 5]) { const val = m * Math.pow(10, p); if (val < fMin || val > fMax) continue; xTicks.push(val); if (val < 1) xTexts.push(val.toPrecision(3)); else if (val < 1000) xTexts.push(String(val)); else if (val < 1000000) xTexts.push((val / 1000) + 'k'); else xTexts.push((val / 1000000) + 'M'); } } // Nyquist & fs 標線 (shapes) const shapes = []; const annotations = []; const addVLine = (xVal, text, row) => { const yref = row === 1 ? 'y' : 'y2'; shapes.push({ type: 'line', x0: xVal, x1: xVal, y0: 0, y1: 1, xref: row === 1 ? 'x' : 'x2', yref: yref + ' domain', line: { color: isDark ? 'rgba(255,0,0,0.5)' : 'rgba(220,0,0,0.6)', width: 1.5, dash: 'dash' } }); if (text) { annotations.push({ x: Math.log10(xVal), y: 1, text: text, xref: row === 1 ? 'x' : 'x2', yref: yref + ' domain', showarrow: false, font: { color: isDark ? 'rgba(255,100,100,0.8)' : 'rgba(180,50,50,1)', size: isSmallScreen ? 10 : 12 }, yshift: isSmallScreen ? 15 : 12, xanchor: 'center', yanchor: isSmallScreen ? 'middle' : 'bottom', textangle: isSmallScreen ? -90 : 0 }); } }; addVLine(fs / 2, 'Nyquist', 1); addVLine(fs, 'fs', 1); addVLine(fs / 2, null, 2); addVLine(fs, null, 2); // 10 的倍數強化線 for (let p = pStart; p <= pEnd; p++) { const v = Math.pow(10, p); if (v >= fMin && v <= fMax) { const gridColor = isDark ? 'rgba(128,128,128,0.5)' : 'rgba(100,100,100,0.3)'; shapes.push( { type: 'line', x0: v, x1: v, y0: 0, y1: 1, xref: 'x', yref: 'y domain', line: { color: gridColor, width: 1.5 } }, { type: 'line', x0: v, x1: v, y0: 0, y1: 1, xref: 'x2', yref: 'y2 domain', line: { color: gridColor, width: 1.5 } } ); } } const gridColor = isDark ? 'rgba(128,128,128,0.2)' : 'rgba(0,0,0,0.1)'; const zeroLineColor = isDark ? '#4b5563' : '#cbd5e1'; const textColor = isDark ? '#9ca3af' : '#475569'; const titleColor = isDark ? '#d1d5db' : '#1e293b'; const xAxisCommon = { type: 'log', title: { text: 'Freq (Hz)', font: { size: isSmallScreen ? 12 : 14 } }, range: [Math.log10(fMin), Math.log10(fMax)], tickvals: xTicks, ticktext: xTexts, showgrid: true, gridcolor: gridColor, tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 }, nticks: isSmallScreen ? 6 : 10 }; const layout = { paper_bgcolor: 'transparent', plot_bgcolor: 'transparent', margin: isSmallScreen ? { t: 80, b: 120, l: 55, r: 15 } : { t: 70, b: 120, l: 70, r: 30 }, showlegend: false, grid: { rows: 2, columns: 1, pattern: 'independent', roworder: 'top to bottom', ygap: isSmallScreen ? 0.35 : 0.3 }, font: { color: textColor, family: 'Inter, sans-serif' }, annotations: [ { text: 'Magnitude Response (dB)', xref: 'x domain', yref: 'y domain', x: 0.5, y: isSmallScreen ? 1.25 : 1.15, showarrow: false, font: { size: isSmallScreen ? 14 : 16, color: titleColor, weight: 'bold' } }, { text: 'Phase Response (Deg)', xref: 'x2 domain', yref: 'y2 domain', x: 0.5, y: isSmallScreen ? 1.25 : 1.15, showarrow: false, font: { size: isSmallScreen ? 14 : 16, color: titleColor, weight: 'bold' } }, // 手動 Magnitude 圖例 (位於上方圖表下方) { xref: 'paper', yref: 'paper', x: 0.35, y: isSmallScreen ? 0.52 : 0.54, text: '一一一', font: { color: isDark ? '#3b82f6' : '#2563eb', 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.58, y: isSmallScreen ? 0.52 : 0.54, text: '· · · ·', font: { color: isDark ? '#10b981' : '#059669', size: 14 }, showarrow: false }, { xref: 'paper', yref: 'paper', x: 0.63, y: isSmallScreen ? 0.52 : 0.54, text: `Fixed (Q${this.shiftBits})`, font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' }, // 手動 Phase 圖例 (位於下方圖表下方) { xref: 'paper', yref: 'paper', x: 0.35, y: -0.15, text: '一一一', font: { color: isDark ? '#f97316' : '#ea580c', size: 10 }, showarrow: false }, { xref: 'paper', yref: 'paper', x: 0.42, y: -0.15, text: 'Ideal Phase', font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' }, { xref: 'paper', yref: 'paper', x: 0.58, y: -0.15, text: '· · · ·', font: { color: isDark ? '#f43f5e' : '#e11d48', size: 14 }, showarrow: false }, { xref: 'paper', yref: 'paper', x: 0.63, y: -0.15, text: 'Fixed Phase', font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' }, ...annotations ], xaxis: { ...xAxisCommon }, yaxis: { title: { text: 'Mag (dB)', font: { size: isSmallScreen ? 12 : 14 } }, range: [-60, 0], gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 } }, xaxis2: { ...xAxisCommon }, yaxis2: { title: { text: 'Phase (°)', font: { size: isSmallScreen ? 12 : 14 } }, range: [-180, 180], tickvals: [-180, -90, 0, 90, 180], gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 } }, shapes: shapes }; const traceMagIdeal = { x: freq, y: magIdeal, name: 'Ideal (Float)', type: 'scatter', line: { color: isDark ? '#3b82f6' : '#2563eb', width: 2.5 } }; const traceMagFixed = { x: freq, y: magFixed, name: `Fixed (Q${this.shiftBits})`, type: 'scatter', line: { color: isDark ? '#10b981' : '#059669', width: 2, dash: 'dot' } }; const tracePhaseIdeal = { x: freq, y: phaseIdeal, name: 'Ideal Phase', type: 'scatter', line: { color: isDark ? '#f97316' : '#ea580c', width: 2.5 }, xaxis: 'x2', yaxis: 'y2' }; const tracePhaseFixed = { x: freq, y: phaseFixed, name: 'Fixed Phase', type: 'scatter', line: { color: isDark ? '#f43f5e' : '#e11d48', width: 2, dash: 'dot' }, xaxis: 'x2', yaxis: 'y2' }; Plotly.react('bodePlot', [traceMagIdeal, traceMagFixed, tracePhaseIdeal, tracePhaseFixed], layout, { responsive: true }); }, handleFileUpload(event) { const file = event.target.files[0]; if (!file) return; this.csvFile = file; this.filterDone = false; this.csvPreview = []; const reader = new FileReader(); reader.onload = (e) => { const text = e.target.result; const lines = text.split('\n').filter(l => l.trim()); if (lines.length === 0) return; this.csvColumns = lines[0].split(',').map(s => s.trim()); this.selectedColumn = 0; if (this.csvColumns.length > 1) { const first = this.csvColumns[0].toLowerCase(); if (['time', 't', 'sec', 'x', 'index', 'unnamed'].some(kw => first.includes(kw))) { this.selectedColumn = 1; } } const preview = []; for (let i = 1; i < Math.min(lines.length, 6); i++) { preview.push(lines[i].split(',').map(s => s.trim())); } this.csvPreview = preview; }; reader.readAsText(file); }, async processFilter() { if (!this.csvFile) return; this.loadingFilter = true; this.globalError = null; const formData = new FormData(); formData.append('file', this.csvFile); formData.append('b', this.b_str); formData.append('a', this.a_str); formData.append('col_idx', this.selectedColumn); try { const res = await fetch('/api/filter', { method: 'POST', body: formData }); if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '濾波處理失敗'); } const data = await res.json(); this.timePlotData = data; this.drawTimePlot(data); this.filterDone = true; } catch (e) { this.globalError = e.message; } finally { this.loadingFilter = false; } }, drawTimePlot(data) { const isDark = this.isDarkMode; const isSmallScreen = window.innerWidth < 768; const gridColor = isDark ? 'rgba(128,128,128,0.2)' : 'rgba(0,0,0,0.1)'; const zeroLineColor = isDark ? '#4b5563' : '#cbd5e1'; const textColor = isDark ? '#9ca3af' : '#475569'; const layout = { paper_bgcolor: 'transparent', plot_bgcolor: 'transparent', margin: isSmallScreen ? { t: 60, b: 60, l: 45, r: 15 } : { t: 40, b: 55, l: 60, r: 20 }, font: { color: textColor, family: 'Inter, sans-serif' }, xaxis: { title: { text: 'Sample Index', 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 } } }; Plotly.react('timePlot', [ { x: data.index, y: data.original, name: '原始輸入訊號 (Input)', type: 'scatter', line: { color: isDark ? '#00cc96' : '#059669' }, opacity: 0.7 }, { x: data.index, y: data.filtered, name: '濾波後輸出 (Output)', type: 'scatter', line: { color: isDark ? '#ef553b' : '#dc2626' }, opacity: 0.9 } ], layout, { responsive: true }); }, async downloadCsv() { if (!this.csvFile) return; const formData = new FormData(); formData.append('file', this.csvFile); formData.append('b', this.b_str); formData.append('a', this.a_str); formData.append('col_idx', this.selectedColumn); 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'; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); } catch (e) { this.globalError = e.message; } } } }).mount('#app')