Files
tcad-bodeplot/src/app-options.js
T

1944 lines
89 KiB
JavaScript
Executable File

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;
}
}
}
}