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; 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 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 (二極二零)", "PID 控制器", "SOGI-Alpha (帶通)", "SOGI-Beta (低通)", "(無) 手動自訂" ], systemGain: 1.0, params: cloneDefaultParams(), // 模式切換 isDarkMode: true, // 係數倍率微調 baseB: [0.5, 0.5, 0, 0, 0, 0, 0], baseA: [1, 0, 0, 0, 0, 0, 0], bSliders: zero7(), aSliders: zero7(), // 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, 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' shiftBitsB: DEFAULT_SHIFT_BITS, shiftBitsA: DEFAULT_SHIFT_BITS, useRound: true, // 是否使用 Rounding 取代 Floor sense_in: '2x', sense_out: '2x', fixedOverrides: { a: {}, b: {} }, outOfRangeB: [false, false, false, false, false, false, false], outOfRangeA: [false, false, false, false, false, false, false], aFineStep: 0.01, aFineRepeatDelayTimer: null, aFineRepeatTimer: null, aFineDrag: null, shiftBitRepeatDelayTimer: null, shiftBitRepeatTimer: null, shiftBitDrag: null, fixedAFineStep: 1, fixedAFineDrag: null, fixedAFineRepeatDelayTimer: null, fixedAFineRepeatTimer: null, shiftBitsIn: 14, shiftBitsOut: 14, shiftInDrag: null, shiftInRepeatDelayTimer: null, shiftInRepeatTimer: null, shiftOutDrag: null, shiftOutRepeatDelayTimer: null, shiftOutRepeatTimer: null, isTouchInput: false, activeCoeffAdjustment: null, mcuSerialPort: null, mcuConnected: false, webSerialSupported: false, writingMCU: false, mcuStatus: '', saveSettingsTimeout: null, bodeMagRange: null, // [min, max] for Y-axis } }, 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); }); this.debouncedUpdateBode(); } }, 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); }); this.debouncedUpdateBode(); } }, fixedPointCoeffs() { const scaleB = Math.pow(2, this.shiftBitsB); const scaleA = Math.pow(2, this.shiftBitsA); 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 * scaleB); 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 * scaleA); 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; }, isFixedPointDirty() { return this.shiftBitsB !== DEFAULT_SHIFT_BITS || this.shiftBitsA !== DEFAULT_SHIFT_BITS || Object.keys(this.fixedOverrides.b).length > 0 || Object.keys(this.fixedOverrides.a).length > 0; }, currentA1() { const a = this.parseCoeffs(this.a_str); return a[1] || 0; }, currentA2() { const a = this.parseCoeffs(this.a_str); return a[2] || 0; }, currentDelta() { // New Model: a1 = -1 - r, a2 = r + delta // => delta = a1 + a2 + 1 return this.currentA1 + this.currentA2 + 1; }, currentR() { // New Model: a1 = -1 - r => r = -1 - a1 return -1 - this.currentA1; }, currentA1A2Diff() { return this.currentA1 - this.currentA2; }, currentA1A2Sum() { return this.currentA1 + this.currentA2; }, fixedA0Int() { return Math.pow(2, this.shiftBitsA); }, fixedCurrentA1() { return this.fixedPointCoeffs.a[1]?.val || 0; }, fixedCurrentA2() { return this.fixedPointCoeffs.a[2]?.val || 0; }, fixedCurrentDelta() { // New Model: a1 = -a0 - r, a2 = r + delta // => delta = a1 + a2 + a0 return this.fixedCurrentA1 + this.fixedCurrentA2 + this.fixedA0Int; }, fixedCurrentR() { // New Model: a1 = -a0 - r => r = -a0 - a1 return -this.fixedA0Int - this.fixedCurrentA1; }, fixedCurrentA1A2Diff() { return this.fixedCurrentA1 - this.fixedCurrentA2; }, fixedCurrentA1A2Sum() { return this.fixedCurrentA1 + this.fixedCurrentA2; } }, watch: { fs: 'debouncedSaveSettings', b_str() { this.debouncedSaveSettings(); this.debouncedProcessFilter(); }, a_str() { this.debouncedSaveSettings(); this.debouncedProcessFilter(); }, filterType: 'debouncedSaveSettings', systemGain: 'debouncedSaveSettings', params: { handler: 'debouncedSaveSettings', deep: true }, baseB: { handler: 'debouncedSaveSettings', deep: true }, baseA: { handler: 'debouncedSaveSettings', deep: true }, bSliders: { handler: 'debouncedSaveSettings', deep: true }, aSliders: { handler: 'debouncedSaveSettings', deep: true }, sense_b: 'debouncedSaveSettings', sense_a: 'debouncedSaveSettings', shiftBitsB() { this.debouncedSaveSettings(); this.debouncedProcessFilter(); }, shiftBitsA() { this.debouncedSaveSettings(); this.debouncedProcessFilter(); }, shiftBitsIn() { this.debouncedSaveSettings(); this.debouncedProcessFilter(); }, shiftBitsOut() { this.debouncedSaveSettings(); this.debouncedProcessFilter(); }, useRound() { this.debouncedSaveSettings(); this.debouncedProcessFilter(); }, fixedOverrides: { handler: 'debouncedSaveSettings', deep: true }, aFineStep: 'debouncedSaveSettings', fixedAFineStep: 'debouncedSaveSettings', b_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; window.addEventListener('pointerdown', this.rememberPointerType); const savedTheme = localStorage.getItem('theme'); this.isDarkMode = savedTheme ? savedTheme === 'dark' : true; if (this.isDarkMode) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } this.loadSettings(); this.webSerialSupported = ('serial' in navigator); if (!this.webSerialSupported) { this.mcuStatus = '此瀏覽器不支援 Web Serial(需使用 Chrome 或 Edge,且透過 HTTPS 連線)'; } this.ensureCascadeStages(); this.updateBodeMagRange(); this.applyFilterDesign(); this.loadPresetWaveforms(); }, beforeUnmount() { window.removeEventListener('pointerdown', this.rememberPointerType); this.stopA1A2Repeat(); this.stopAFineDrag(); this.stopShiftBitRepeat(); this.stopShiftBitDrag(); this.stopFixedA1A2Repeat(); this.stopFixedAFineDrag(); this.stopShiftInRepeat(); this.stopShiftInDrag(); this.stopShiftOutRepeat(); this.stopShiftOutDrag(); }, methods: { 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'); if (saved) { const parsed = JSON.parse(saved); 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', 'cascadeStages', 'activeCascadeStageIndex' ]; keys.forEach(k => { if (parsed[k] !== undefined) { this[k] = parsed[k]; } }); } } catch (e) { console.error('Failed to load settings:', e); } }, 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', 'cascadeStages', 'activeCascadeStageIndex' ]; const settings = {}; keys.forEach(k => settings[k] = this[k]); localStorage.setItem('dea_settings', JSON.stringify(settings)); }, debouncedSaveSettings() { clearTimeout(this.saveSettingsTimeout); this.saveSettingsTimeout = setTimeout(() => this.saveSettings(), 500); }, rememberPointerType(event) { if (event.pointerType === 'touch') { this.isTouchInput = true; } else if (event.pointerType === 'mouse' || event.pointerType === 'pen') { this.isTouchInput = false; } }, 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({ switchToChart: false }); 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) { if (!str) return []; // 支援逗號、空格、換行等分隔符號 return str.split(/[,\s]+/).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, 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(', '); this.debouncedUpdateBode(); }, resetSlidersA() { this.aSliders = [0, 0, 0, 0, 0, 0]; this.a_str = this.baseA.map(x => parseFloat(x.toPrecision(10))).join(', '); this.debouncedUpdateBode(); }, clearManualAdjustments() { this.bSliders = [0, 0, 0, 0, 0, 0, 0]; this.aSliders = [0, 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(', '); this.activeCoeffAdjustment = null; this.calcSlidersFromText(); this.updateBodePlot({ switchToChart: false }); }, updateFromControls({ updateBode = true } = {}) { 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(', '); if (updateBode) this.debouncedUpdateBode(); }, stepDecimals(step) { const text = String(step); if (text.includes('e-')) return Number(text.split('e-')[1]); return text.includes('.') ? text.split('.')[1].length : 0; }, roundToStep(value, step = this.aFineStep) { const scale = 1 / step; return Math.round(value * scale) / scale; }, formatFineCoeff(value) { if (!Number.isFinite(value)) return '0'; return Number(value).toString(); }, formatAFineDelta(step = this.aFineStep) { return this.roundToStep(step, step).toFixed(this.stepDecimals(step)); }, formatFixedFineCoeff(value) { if (!Number.isFinite(value)) return '0'; return Number.isInteger(value) ? String(value) : String(value); }, formatFixedAFineDelta(step = this.fixedAFineStep) { return String(step); }, adjustAFineStep(direction) { const nextStep = this.aFineStep * (direction > 0 ? 0.1 : 10); const clampedStep = Math.min(1, Math.max(1e-9, nextStep)); this.aFineStep = Number(clampedStep.toPrecision(12)); }, adjustFixedAFineStep(direction) { const steps = [1, 2, 4, 8, 32, 256, 1024, 4096, 16384, 65536]; let currentIdx = steps.indexOf(this.fixedAFineStep); // 如果當前值不在數列中,找最接近的一個 if (currentIdx === -1) { currentIdx = steps.reduce((prevIdx, curr, idx) => { return Math.abs(curr - this.fixedAFineStep) < Math.abs(steps[prevIdx] - this.fixedAFineStep) ? idx : prevIdx; }, 0); } if (direction > 0) { // 細 -> 往小調 currentIdx = Math.max(0, currentIdx - 1); } else { // 粗 -> 往大調 currentIdx = Math.min(steps.length - 1, currentIdx + 1); } this.fixedAFineStep = steps[currentIdx]; }, setDeltaR(delta, r) { const nextDelta = Number(delta); const nextR = Number(r); if (!Number.isFinite(nextDelta) || !Number.isFinite(nextR)) return; // New Model: a1 = -1 - r, a2 = r + delta this.setA1A2(-1 - nextR, nextR + nextDelta); }, setAFineDirect(target, rawValue) { const nextValue = parseFloat(rawValue); if (!Number.isFinite(nextValue)) return; if (target === 'delta') { this.setDeltaR(nextValue, this.currentR); } else if (target === 'r') { this.setDeltaR(this.currentDelta, nextValue); } }, adjustAFineValue(target, steps) { const delta = steps * this.aFineStep; if (target === 'delta') { this.setDeltaR(this.currentDelta + delta, this.currentR); } else if (target === 'r') { this.setDeltaR(this.currentDelta, this.currentR + delta); } }, handleAFineWheel(target, event) { if (!event.shiftKey) { event.currentTarget?.blur?.(); return; } event.preventDefault(); const direction = event.deltaY < 0 ? 1 : -1; const steps = Math.max(1, Math.round(Math.abs(event.deltaY) / 80)); this.adjustAFineValue(target, direction * steps); }, startAFineDrag(target, event) { if (event.pointerType !== 'touch') return; event.preventDefault(); this.aFineDrag = { target, lastY: event.clientY, remainder: 0 }; window.addEventListener('pointermove', this.moveAFineDrag, { passive: false }); window.addEventListener('pointerup', this.stopAFineDrag); window.addEventListener('pointercancel', this.stopAFineDrag); }, moveAFineDrag(event) { if (!this.aFineDrag) return; event.preventDefault(); const delta = this.aFineDrag.lastY - event.clientY; this.aFineDrag.lastY = event.clientY; this.aFineDrag.remainder += delta; const steps = Math.trunc(this.aFineDrag.remainder / 18); if (steps === 0) return; this.aFineDrag.remainder -= steps * 18; this.adjustAFineValue(this.aFineDrag.target, steps); }, stopAFineDrag() { this.aFineDrag = null; window.removeEventListener('pointermove', this.moveAFineDrag); window.removeEventListener('pointerup', this.stopAFineDrag); window.removeEventListener('pointercancel', this.stopAFineDrag); }, setFixedDeltaR(delta, r) { const nextDelta = Math.round(delta); const nextR = Math.round(r); const a0 = this.fixedA0Int; // New Model: a1 = -a0 - r, a2 = r + delta // If r and delta are integers, a1 and a2 are guaranteed to be integers. this.fixedOverrides.a[1] = -a0 - nextR; this.fixedOverrides.a[2] = nextR + nextDelta; this.debouncedUpdateBode(); }, setFixedAFineDirect(target, rawValue) { const nextValue = parseFloat(rawValue); if (!Number.isFinite(nextValue)) return; if (target === 'delta') { this.setFixedDeltaR(nextValue, this.fixedCurrentR); } else if (target === 'r') { this.setFixedDeltaR(this.fixedCurrentDelta, nextValue); } }, adjustFixedAFineValue(target, steps) { const dVal = Math.round(steps * this.fixedAFineStep); if (target === 'delta') { this.setFixedDeltaR(this.fixedCurrentDelta + dVal, this.fixedCurrentR); } else if (target === 'r') { this.setFixedDeltaR(this.fixedCurrentDelta, this.fixedCurrentR + dVal); } }, handleFixedAFineWheel(target, event) { if (!event.shiftKey) { event.currentTarget?.blur?.(); return; } event.preventDefault(); const direction = event.deltaY < 0 ? 1 : -1; const steps = Math.max(1, Math.round(Math.abs(event.deltaY) / 80)); this.adjustFixedAFineValue(target, direction * steps); }, startFixedA1A2Repeat(target, direction) { this.stopFixedA1A2Repeat(); this.adjustFixedAFineValue(target, direction); this.fixedAFineRepeatDelayTimer = setTimeout(() => { this.fixedAFineRepeatTimer = setInterval(() => { this.adjustFixedAFineValue(target, direction); }, 80); }, 350); }, stopFixedA1A2Repeat() { clearTimeout(this.fixedAFineRepeatDelayTimer); clearInterval(this.fixedAFineRepeatTimer); this.fixedAFineRepeatDelayTimer = null; this.fixedAFineRepeatTimer = null; }, startFixedAFineDrag(target, event) { if (event.pointerType !== 'touch') return; event.preventDefault(); this.fixedAFineDrag = { target, lastY: event.clientY, remainder: 0 }; window.addEventListener('pointermove', this.moveFixedAFineDrag, { passive: false }); window.addEventListener('pointerup', this.stopFixedAFineDrag); window.addEventListener('pointercancel', this.stopFixedAFineDrag); }, moveFixedAFineDrag(event) { if (!this.fixedAFineDrag) return; event.preventDefault(); const delta = this.fixedAFineDrag.lastY - event.clientY; this.fixedAFineDrag.lastY = event.clientY; this.fixedAFineDrag.remainder += delta; const steps = Math.trunc(this.fixedAFineDrag.remainder / 18); if (steps === 0) return; this.fixedAFineDrag.remainder -= steps * 18; this.adjustFixedAFineValue(this.fixedAFineDrag.target, steps); }, stopFixedAFineDrag() { this.fixedAFineDrag = null; window.removeEventListener('pointermove', this.moveFixedAFineDrag); window.removeEventListener('pointerup', this.stopFixedAFineDrag); window.removeEventListener('pointercancel', this.stopFixedAFineDrag); }, normalizeShiftBits(rawValue) { const nextValue = parseInt(rawValue, 10); if (!Number.isFinite(nextValue)) return 0; return Math.max(0, nextValue); }, setUseRound(val) { this.useRound = val; }, setShiftBits(type, rawValue) { const nextValue = this.normalizeShiftBits(rawValue); if (type === 'b') { this.shiftBitsB = nextValue; } else { this.shiftBitsA = nextValue; } this.clearFixedOverrides(type); this.debouncedUpdateBode(); }, adjustShiftBits(type, steps) { const current = type === 'b' ? this.shiftBitsB : this.shiftBitsA; this.setShiftBits(type, current + steps); }, handleShiftBitWheel(type, event) { if (!event.shiftKey) { event.currentTarget?.blur?.(); return; } event.preventDefault(); const direction = event.deltaY < 0 ? 1 : -1; const steps = Math.max(1, Math.round(Math.abs(event.deltaY) / 80)); this.adjustShiftBits(type, direction * steps); }, startShiftBitRepeat(type, direction) { this.stopShiftBitRepeat(); this.adjustShiftBits(type, direction); this.shiftBitRepeatDelayTimer = setTimeout(() => { this.shiftBitRepeatTimer = setInterval(() => { this.adjustShiftBits(type, direction); }, 80); }, 350); }, stopShiftBitRepeat() { clearTimeout(this.shiftBitRepeatDelayTimer); clearInterval(this.shiftBitRepeatTimer); this.shiftBitRepeatDelayTimer = null; this.shiftBitRepeatTimer = null; }, startShiftBitDrag(type, event) { if (event.pointerType !== 'touch') return; event.preventDefault(); this.shiftBitDrag = { type, lastY: event.clientY, remainder: 0 }; window.addEventListener('pointermove', this.moveShiftBitDrag, { passive: false }); window.addEventListener('pointerup', this.stopShiftBitDrag); window.addEventListener('pointercancel', this.stopShiftBitDrag); }, moveShiftBitDrag(event) { if (!this.shiftBitDrag) return; event.preventDefault(); const delta = this.shiftBitDrag.lastY - event.clientY; this.shiftBitDrag.lastY = event.clientY; this.shiftBitDrag.remainder += delta; const steps = Math.trunc(this.shiftBitDrag.remainder / 18); if (steps === 0) return; this.shiftBitDrag.remainder -= steps * 18; this.adjustShiftBits(this.shiftBitDrag.type, steps); }, stopShiftBitDrag() { this.shiftBitDrag = null; window.removeEventListener('pointermove', this.moveShiftBitDrag); window.removeEventListener('pointerup', this.stopShiftBitDrag); window.removeEventListener('pointercancel', this.stopShiftBitDrag); }, // 新增:處理輸入輸出位移 setShiftBitsIn(rawValue) { this.shiftBitsIn = this.normalizeShiftBits(rawValue); this.debouncedUpdateBode(); }, adjustShiftBitsIn(steps) { this.setShiftBitsIn(this.shiftBitsIn + steps); }, startShiftInRepeat(direction) { this.stopShiftInRepeat(); this.adjustShiftBitsIn(direction); this.shiftInRepeatDelayTimer = setTimeout(() => { this.shiftInRepeatTimer = setInterval(() => { this.adjustShiftBitsIn(direction); }, 80); }, 350); }, stopShiftInRepeat() { clearTimeout(this.shiftInRepeatDelayTimer); clearInterval(this.shiftInRepeatTimer); this.shiftInRepeatDelayTimer = null; this.shiftInRepeatTimer = null; }, startShiftInDrag(event) { if (event.pointerType !== 'touch') return; event.preventDefault(); this.shiftInDrag = { lastY: event.clientY, remainder: 0 }; window.addEventListener('pointermove', this.moveShiftInDrag, { passive: false }); window.addEventListener('pointerup', this.stopShiftInDrag); window.addEventListener('pointercancel', this.stopShiftInDrag); }, moveShiftInDrag(event) { if (!this.shiftInDrag) return; event.preventDefault(); const delta = this.shiftInDrag.lastY - event.clientY; this.shiftInDrag.lastY = event.clientY; this.shiftInDrag.remainder += delta; const steps = Math.trunc(this.shiftInDrag.remainder / 18); if (steps === 0) return; this.shiftInDrag.remainder -= steps * 18; this.adjustShiftBitsIn(steps); }, stopShiftInDrag() { this.shiftInDrag = null; window.removeEventListener('pointermove', this.moveShiftInDrag); window.removeEventListener('pointerup', this.stopShiftInDrag); window.removeEventListener('pointercancel', this.stopShiftInDrag); }, setShiftBitsOut(rawValue) { this.shiftBitsOut = this.normalizeShiftBits(rawValue); this.debouncedUpdateBode(); }, adjustShiftBitsOut(steps) { this.setShiftBitsOut(this.shiftBitsOut + steps); }, startShiftOutRepeat(direction) { this.stopShiftOutRepeat(); this.adjustShiftBitsOut(direction); this.shiftOutRepeatDelayTimer = setTimeout(() => { this.shiftOutRepeatTimer = setInterval(() => { this.adjustShiftBitsOut(direction); }, 80); }, 350); }, stopShiftOutRepeat() { clearTimeout(this.shiftOutRepeatDelayTimer); clearInterval(this.shiftOutRepeatTimer); this.shiftOutRepeatDelayTimer = null; this.shiftOutRepeatTimer = null; }, startShiftOutDrag(event) { if (event.pointerType !== 'touch') return; event.preventDefault(); this.shiftOutDrag = { lastY: event.clientY, remainder: 0 }; window.addEventListener('pointermove', this.moveShiftOutDrag, { passive: false }); window.addEventListener('pointerup', this.stopShiftOutDrag); window.addEventListener('pointercancel', this.stopShiftOutDrag); }, moveShiftOutDrag(event) { if (!this.shiftOutDrag) return; event.preventDefault(); const delta = this.shiftOutDrag.lastY - event.clientY; this.shiftOutDrag.lastY = event.clientY; this.shiftOutDrag.remainder += delta; const steps = Math.trunc(this.shiftOutDrag.remainder / 18); if (steps === 0) return; this.shiftOutDrag.remainder -= steps * 18; this.adjustShiftBitsOut(steps); }, stopShiftOutDrag() { this.shiftOutDrag = null; window.removeEventListener('pointermove', this.moveShiftOutDrag); window.removeEventListener('pointerup', this.stopShiftOutDrag); window.removeEventListener('pointercancel', this.stopShiftOutDrag); }, setA1A2(a1, a2) { const a = this.padTo7(this.parseCoeffs(this.a_str)); a[0] = 1.0; a[1] = Number(a1); a[2] = Number(a2); this.a_str = a.map(x => Number.isFinite(x) ? Number(x).toString() : '0').join(', '); this.calcSlidersFromText(); this.debouncedUpdateBode(); }, adjustA1A2(mode, direction) { const step = this.aFineStep * direction; if (mode === 'delta') { this.setDeltaR(this.currentDelta + step, this.currentR); } else if (mode === 'r') { this.setDeltaR(this.currentDelta, this.currentR + step); } }, startA1A2Repeat(mode, direction) { this.stopA1A2Repeat(); this.adjustAFineValue(mode, direction); this.aFineRepeatDelayTimer = setTimeout(() => { this.aFineRepeatTimer = setInterval(() => { this.adjustAFineValue(mode, direction); }, 80); }, 350); }, stopA1A2Repeat() { clearTimeout(this.aFineRepeatDelayTimer); clearInterval(this.aFineRepeatTimer); this.aFineRepeatDelayTimer = null; this.aFineRepeatTimer = null; }, toggleCoeffAdjustment(panel) { if (this.activeCoeffAdjustment === panel) { this.activeCoeffAdjustment = null; return; } this.activeCoeffAdjustment = panel; if (panel === 'a1a2') { this.calcSlidersFromText(); } }, 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) { let logVal = Math.log10(val / (base * gain)); if (Math.abs(logVal) < 1e-8) logVal = 0; // 消除浮點數微小誤差 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) { let logVal = Math.log10(val / base); if (Math.abs(logVal) < 1e-8) logVal = 0; // 消除浮點數微小誤差 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(); this.debouncedUpdateBode(); }, 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(); } }, clearFixedOverrides(type = null) { if (type === 'a' || type === 'b') { this.fixedOverrides[type] = {}; return; } this.fixedOverrides = { a: {}, b: {} }; }, resetFixedPointSettings() { this.shiftBitsB = DEFAULT_SHIFT_BITS; this.shiftBitsA = DEFAULT_SHIFT_BITS; this.fixedAFineStep = 1; this.clearFixedOverrides(); this.updateBodePlot({ switchToChart: false }); }, debouncedApply() { clearTimeout(this.bodeTimeout); this.bodeTimeout = setTimeout(() => { if (this.filterType !== MANUAL_FILTER_TYPE) this.applyFilterDesign(); else this.updateBodePlot({ switchToChart: false }); }, 300); }, debouncedUpdateBode() { clearTimeout(this.bodeTimeout); this.bodeTimeout = setTimeout(() => this.updateBodePlot({ switchToChart: false }), 150); }, onGainChange() { this.updateBodeMagRange(); this.updateFromControls(); }, updateBodeMagRange() { const K = parseFloat(this.systemGain) || 1.0; const yMax = 20 * Math.log10(Math.abs(K) * 3); this.bodeMagRange = [yMax - 70, yMax]; }, onFilterTypeChange() { this.systemGain = 1.0; // 這裡可以選擇是否重設所有設計參數 (fc, Q 等) // 根據使用者要求「載入預設值」,我們呼叫重設邏輯 this.clearFixedOverrides(); this.bSliders = zero7(); this.aSliders = zero7(); this.outOfRangeB = [false, false, false, false, false, false, false]; this.outOfRangeA = [false, false, false, false, false, false, false]; this.activeCoeffAdjustment = null; if (this.filterType !== MANUAL_FILTER_TYPE) { this.params = cloneDefaultParams(); } this.updateBodeMagRange(); this.applyFilterDesign(); }, resetFilterParams() { clearTimeout(this.bodeTimeout); const isManual = this.filterType === MANUAL_FILTER_TYPE; this.fs = DEFAULT_FS; this.systemGain = 1.0; this.params = cloneDefaultParams(); if (isManual) { this.b_str = DEFAULT_B_STR; this.a_str = DEFAULT_A_STR; this.baseB = this.padTo7(this.parseCoeffs(DEFAULT_B_STR)); this.baseA = this.padTo7(this.parseCoeffs(DEFAULT_A_STR)); } this.clearFixedOverrides(); this.bSliders = zero7(); this.aSliders = zero7(); this.outOfRangeB = [false, false, false, false, false, false, false]; this.outOfRangeA = [false, false, false, false, false, false, false]; this.sense_b = '2x'; this.sense_a = '2x'; this.shiftBitsB = DEFAULT_SHIFT_BITS; this.shiftBitsA = DEFAULT_SHIFT_BITS; this.aFineStep = 0.01; this.fixedAFineStep = 1; this.stopA1A2Repeat(); this.stopAFineDrag(); this.stopFixedA1A2Repeat(); this.stopFixedAFineDrag(); this.stopShiftBitRepeat(); this.stopShiftBitDrag(); this.activeCoeffAdjustment = null; this.globalError = null; this.updateBodeMagRange(); if (isManual) this.updateBodePlot({ switchToChart: false }); else this.applyFilterDesign(); }, async applyFilterDesign() { if (this.filterType === MANUAL_FILTER_TYPE) 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, tp1z_fz: this.params.tp1z_fz, tp1z_fp1: this.params.tp1z_fp1, tp1z_fp2: this.params.tp1z_fp2, 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, 0]; this.activeCoeffAdjustment = null; this.updateFromControls({ updateBode: false }); // 3. 更新圖表 await this.updateBodePlot({ switchToChart: false }); } catch (e) { this.globalError = e.message; } }, async updateBodePlot({ switchToChart = true } = {}) { 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 scaleB = Math.pow(2, this.shiftBitsB); const scaleA = Math.pow(2, this.shiftBitsA); const b_fixed = b_ideal.map((x, i) => { const intVal = (this.fixedOverrides.b[i] !== undefined) ? this.fixedOverrides.b[i] : Math.round(x * scaleB); return intVal / scaleB; }); const a_fixed = a_ideal.map((x, i) => { const intVal = (this.fixedOverrides.a[i] !== undefined) ? this.fixedOverrides.a[i] : Math.round(x * scaleA); return intVal / scaleA; }); const cascadePayload = this.getCascadeBodePayload(); const res = await fetch('/api/bode/compare_cascade', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cascadePayload) }); let data; if (res.status === 404) { 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(); data = { freq: dataIdeal.freq, ideal: { mag: dataIdeal.mag, phase: dataIdeal.phase }, fixed: { mag: dataFixed.mag, phase: dataFixed.phase } }; } else if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '無法計算頻率響應'); } else { data = await res.json(); } this.drawBodePlot(data.freq, data.ideal.mag, data.ideal.phase, data.fixed.mag, data.fixed.phase); // 手機版:計算完成後自動切換到圖表頁籤 if (switchToChart && 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(242,184,181,0.72)' : 'rgba(179,38,30,0.68)', width: 1.4, 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 ? '#f2b8b5' : '#b3261e', 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(200,206,224,0.24)' : 'rgba(86,96,117,0.16)'; 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(200,206,224,0.14)' : 'rgba(86,96,117,0.09)'; const zeroLineColor = isDark ? 'rgba(200,206,224,0.24)' : 'rgba(82,86,99,0.20)'; const textColor = isDark ? '#b4b4bd' : '#525663'; const titleColor = isDark ? '#f1f1f4' : '#17191f'; const plotBgColor = isDark ? '#090a0d' : '#ffffff'; 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: plotBgColor, plot_bgcolor: plotBgColor, uirevision: this.bodeMagRange ? this.bodeMagRange.join(',') : 'true', // 當範圍不變時,保留使用者縮放 margin: isSmallScreen ? { t: 80, b: 120, l: 55, r: 15 } : { t: 70, b: 120, l: 70, r: 30 }, showlegend: false, grid: { rows: 2, columns: 1, pattern: 'independent', roworder: 'top to bottom', ygap: isSmallScreen ? 0.35 : 0.3 }, font: { color: textColor, family: 'Google Sans, Roboto, 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: 'normal' } }, { 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: 'normal' } }, // 手動 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 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 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 }, { 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 ? '#ff9bd8' : '#96527b', 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, anchor: 'y' }, yaxis: { title: { text: 'Mag (dB)', font: { size: isSmallScreen ? 12 : 14 } }, range: this.bodeMagRange || [-70, 0], gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 }, fixedrange: false // 允許使用者手動縮放或點擊 Auto Scale }, xaxis2: { type: 'log', title: { text: 'Freq (Hz)', font: { size: isSmallScreen ? 12 : 14 } }, tickvals: xTicks, ticktext: xTexts, showgrid: true, gridcolor: gridColor, tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 }, nticks: isSmallScreen ? 6 : 10, matches: 'x', anchor: 'y2' }, yaxis2: { title: { text: 'Phase (°)', font: { size: isSmallScreen ? 12 : 14 } }, range: [-180, 180], tickvals: [-180, -90, 0, 90, 180], gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 }, fixedrange: true // 相位縱軸鎖死,不可縮放 }, shapes: shapes }; 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' }; Plotly.react('bodePlot', [traceMagIdeal, traceMagFixed, tracePhaseIdeal, tracePhaseFixed], layout, { responsive: true }); }, parseCsvText(text, maxRows = Infinity) { const rows = []; let row = []; let field = ''; let inQuotes = false; let totalRows = 0; const finishRow = () => { row.push(field); if (row.some(value => value.trim() !== '')) { totalRows++; if (rows.length < maxRows) rows.push(row); } row = []; field = ''; }; for (let i = 0; i < text.length; i++) { const char = text[i]; const next = text[i + 1]; if (char === '"') { if (inQuotes && next === '"') { field += '"'; i++; } else { inQuotes = !inQuotes; } } else if (char === ',' && !inQuotes) { row.push(field); field = ''; } else if ((char === '\n' || char === '\r') && !inQuotes) { if (char === '\r' && next === '\n') i++; finishRow(); } else { field += char; } } if (inQuotes) throw new Error('CSV 引號未正確結束'); finishRow(); return { rows, totalRows }; }, resetCsvState() { this.csvFile = null; this.csvFileId = null; this.csvColumns = []; 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) { const file = event.target.files[0]; this.resetCsvState(); if (!file) return; const maxBytes = 300 * 1024 * 1024; const fileName = file.name || ''; if (!fileName.toLowerCase().endsWith('.csv')) { this.csvParseError = '請選擇 .csv 檔案'; event.target.value = ''; return; } if (file.size > maxBytes) { this.csvParseError = 'CSV 檔案不可超過 300MB'; event.target.value = ''; return; } const previewBytes = Math.min(file.size, 1024 * 1024); const reader = new FileReader(); reader.onload = async (e) => { try { const text = String(e.target.result || '').replace(/^\uFEFF/, ''); const { rows, totalRows } = this.parseCsvText(text, 6); if (totalRows < 2 || rows.length < 2) throw new Error('CSV 需包含標題列與至少一筆資料'); const columns = rows[0].map((value, index) => { const label = value.trim(); return label || `Column ${index + 1}`; }); if (columns.length === 0) throw new Error('CSV 標題列不可為空'); this.csvFile = file; this.csvColumns = columns; this.csvPreview = rows.slice(1, 6).map(row => { const padded = row.slice(0, columns.length); while (padded.length < columns.length) padded.push(''); return padded.map(value => value.trim()); }); 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, }; this.selectedColumn = 0; if (columns.length > 1) { const first = columns[0].toLowerCase(); if (['time', 't', 'sec', 'x', 'index', 'unnamed'].some(kw => first.includes(kw))) { this.selectedColumn = 1; } } // 背景上傳至暫存區 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 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; } catch (err) { this.csvParseError = err.message || 'CSV 解析失敗'; this.csvColumns = []; this.csvPreview = []; 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.slice(0, previewBytes)); }, debouncedProcessFilter() { if (!this.filterDone || !this.csvFileId) return; if (this._filterTimeout) clearTimeout(this._filterTimeout); this._filterTimeout = setTimeout(() => { this.processFilter(); }, 500); }, 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(); formData.append('file_id', this.csvFileId); formData.append('b', this.b_str); formData.append('a', this.a_str); formData.append('col_idx', this.selectedColumn); formData.append('b_int', this.b_int_str); formData.append('a_int', this.a_int_str); formData.append('shift_in', this.shiftBitsIn); formData.append('shift_out', this.shiftBitsOut); formData.append('shift_b', this.shiftBitsB); formData.append('shift_a', this.shiftBitsA); formData.append('use_round', this.useRound); 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 || '濾波處理失敗'); } const data = await res.json(); this.timePlotData = data; this.filterDone = true; this.isCsvPreviewExpanded = false; // 運算成功後自動收合預覽 // 確保在可見狀態下繪圖,避免 Plotly 計算尺寸錯誤 this.$nextTick(() => { this.drawTimePlot(data); }); } 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(200,206,224,0.14)' : 'rgba(86,96,117,0.09)'; const zeroLineColor = isDark ? 'rgba(200,206,224,0.24)' : 'rgba(82,86,99,0.20)'; 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: `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', 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() { if (!this.csvFileId) return; const formData = new FormData(); formData.append('file_id', this.csvFileId); formData.append('b', this.b_str); formData.append('a', this.a_str); formData.append('col_idx', this.selectedColumn); formData.append('b_int', this.b_int_str); formData.append('a_int', this.a_int_str); formData.append('shift_in', this.shiftBitsIn); formData.append('shift_out', this.shiftBitsOut); formData.append('shift_b', this.shiftBitsB); formData.append('shift_a', this.shiftBitsA); formData.append('use_round', this.useRound); 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 = 'cascade_filtered_output.csv'; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); } catch (e) { this.globalError = e.message; } }, async connectMCUPort() { if (!this.webSerialSupported) return; this.mcuStatus = ''; try { const port = await navigator.serial.requestPort(); await port.open({ baudRate: 115200 }); this.mcuSerialPort = port; this.mcuConnected = true; const info = port.getInfo(); const vid = info.usbVendorId ? `VID:0x${info.usbVendorId.toString(16)}` : ''; this.mcuStatus = `已連線${vid ? ' (' + vid + ')' : ''}`; } catch (e) { if (e.name === 'NotFoundError') { this.mcuStatus = '未選擇連接埠'; } else { this.mcuStatus = `連線失敗: ${e.message}`; } } }, async disconnectMCUPort() { if (this.mcuSerialPort) { try { await this.mcuSerialPort.close(); } catch { /* ignore close errors */ } this.mcuSerialPort = null; } this.mcuConnected = false; this.mcuStatus = '已斷線'; }, mcuCommandFromFixedCoeffs() { const bVals = this.fixedPointCoeffs.b.map(item => item.val); const aVals = this.fixedPointCoeffs.a.map(item => item.val); const b0 = bVals[0] ?? 0; const b1 = bVals[1] ?? 0; const b2 = bVals[2] ?? 0; const a1 = aVals[1] ?? 0; const a2 = aVals[2] ?? 0; return `bodeplot=${b0},${b1},${b2},${a1},${a2}`; }, async writeToMCU() { const stageNumber = this.activeCascadeStageIndex + 1; if (!this.mcuSerialPort || !this.mcuConnected) { this.mcuStatus = '請先連線 MCU 連接埠'; return; } this.writingMCU = true; this.globalError = null; this.mcuStatus = ''; const command = this.mcuCommandFromFixedCoeffs(); try { const encoder = new TextEncoder(); const writer = this.mcuSerialPort.writable.getWriter(); try { await writer.write(encoder.encode(command + '\r\n')); } finally { writer.releaseLock(); } this.mcuStatus = `已送出 Stage ${stageNumber}: ${command}`; } catch (e) { this.globalError = e.message; this.mcuStatus = `Stage ${stageNumber} 寫入失敗: ${e.message}`; } finally { this.writingMCU = false; } } } }