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

1246 lines
57 KiB
JavaScript
Raw Normal View History

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];
export default {
data() {
return {
fs: DEFAULT_FS,
b_str: DEFAULT_B_STR,
a_str: DEFAULT_A_STR,
filterType: DEFAULT_FILTER_TYPE,
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, csvColumns: [], csvPreview: [], csvInfo: null, csvParseError: null,
selectedColumn: 0, loadingFilter: false, filterDone: false, timePlotData: null,
mobileTab: 'settings', // 手機版頁籤:'settings' | 'chart'
shiftBitsB: DEFAULT_SHIFT_BITS,
shiftBitsA: DEFAULT_SHIFT_BITS,
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,
isTouchInput: false,
activeCoeffAdjustment: null,
mcuSerialPort: null,
mcuConnected: false,
webSerialSupported: false,
writingMCU: false,
mcuStatus: '',
2026-05-15 16:12:47 +08:00
saveSettingsTimeout: null,
}
},
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() {
return (this.currentA1 + this.currentA2 + 1) / 2;
},
currentR() {
return (this.currentA2 - this.currentA1 - 1) / 2;
},
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() {
return (this.fixedCurrentA1 + this.fixedCurrentA2 + this.fixedA0Int) / 2;
},
fixedCurrentR() {
return (this.fixedCurrentA2 - this.fixedCurrentA1 - this.fixedA0Int) / 2;
},
fixedCurrentA1A2Diff() {
return this.fixedCurrentA1 - this.fixedCurrentA2;
},
fixedCurrentA1A2Sum() {
return this.fixedCurrentA1 + this.fixedCurrentA2;
}
},
2026-05-15 16:12:47 +08:00
watch: {
fs: 'debouncedSaveSettings',
b_str: 'debouncedSaveSettings',
a_str: 'debouncedSaveSettings',
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: 'debouncedSaveSettings',
shiftBitsA: 'debouncedSaveSettings',
fixedOverrides: { handler: 'debouncedSaveSettings', deep: true },
aFineStep: 'debouncedSaveSettings',
fixedAFineStep: 'debouncedSaveSettings'
},
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');
}
2026-05-15 16:12:47 +08:00
this.loadSettings();
this.webSerialSupported = ('serial' in navigator);
if (!this.webSerialSupported) {
this.mcuStatus = '此瀏覽器不支援 Web Serial(需使用 Chrome 或 Edge,且透過 HTTPS 連線)';
}
this.applyFilterDesign();
},
beforeUnmount() {
window.removeEventListener('pointerdown', this.rememberPointerType);
this.stopA1A2Repeat();
this.stopAFineDrag();
this.stopShiftBitRepeat();
this.stopShiftBitDrag();
this.stopFixedA1A2Repeat();
this.stopFixedAFineDrag();
},
methods: {
2026-05-15 16:12:47 +08:00
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', 'fixedOverrides', 'aFineStep', 'fixedAFineStep'
];
keys.forEach(k => {
if (parsed[k] !== undefined) {
this[k] = parsed[k];
}
});
}
} catch (e) {
console.error('Failed to load settings:', e);
}
},
saveSettings() {
const keys = [
'fs', 'b_str', 'a_str', 'filterType', 'systemGain', 'params',
'baseB', 'baseA', 'bSliders', 'aSliders', 'sense_b', 'sense_a',
'shiftBitsB', 'shiftBitsA', 'fixedOverrides', 'aFineStep', 'fixedAFineStep'
];
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 nextStep = this.fixedAFineStep * (direction > 0 ? 0.1 : 10);
this.fixedAFineStep = Math.max(1, Math.min(1000000000, Math.round(nextStep)));
},
setDeltaR(delta, r) {
const nextDelta = Number(delta);
const nextR = Number(r);
if (!Number.isFinite(nextDelta) || !Number.isFinite(nextR)) return;
this.setA1A2(-1 - nextR + nextDelta, 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 = Number(delta);
const nextR = Number(r);
if (!Number.isFinite(nextDelta) || !Number.isFinite(nextR)) return;
this.fixedOverrides.a[1] = Math.round(-this.fixedA0Int - nextR + nextDelta);
this.fixedOverrides.a[2] = Math.round(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 delta = steps * this.fixedAFineStep;
if (target === 'delta') {
this.setFixedDeltaR(this.fixedCurrentDelta + delta, this.fixedCurrentR);
} else if (target === 'r') {
this.setFixedDeltaR(this.fixedCurrentDelta, this.fixedCurrentR + delta);
}
},
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);
},
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);
},
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));
2026-05-15 16:12:47 +08:00
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);
2026-05-15 16:12:47 +08:00
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.updateFromControls();
},
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.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;
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 res = await fetch('/api/bode/compare', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ideal: { b: b_ideal, a: a_ideal, fs: this.fs },
fixed: { b: b_fixed, a: a_fixed, fs: this.fs }
})
});
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,
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 (Float)', font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' },
{ xref: 'paper', yref: 'paper', x: 0.58, y: isSmallScreen ? 0.52 : 0.54, text: '· · · ·', font: { color: isDark ? '#b9b2ff' : '#7a6689', size: 14 }, showarrow: false },
{ xref: 'paper', yref: 'paper', x: 0.63, y: isSmallScreen ? 0.52 : 0.54, text: `Fixed (b Q${this.shiftBitsB}, a Q${this.shiftBitsA})`, font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' },
// 手動 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 },
yaxis: { title: { text: 'Mag (dB)', font: { size: isSmallScreen ? 12 : 14 } }, range: [-60, 0], gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 } },
xaxis2: { ...xAxisCommon },
yaxis2: { title: { text: 'Phase (°)', font: { size: isSmallScreen ? 12 : 14 } }, range: [-180, 180], tickvals: [-180, -90, 0, 90, 180], gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 } },
shapes: shapes
};
const traceMagIdeal = { x: freq, y: magIdeal, name: 'Ideal (Float)', type: 'scatter', line: { color: isDark ? '#c8cee0' : '#566075', width: 2.8 } };
const traceMagFixed = { x: freq, y: magFixed, name: `Fixed (b Q${this.shiftBitsB}, a Q${this.shiftBitsA})`, type: 'scatter', line: { color: isDark ? '#b9b2ff' : '#7a6689', width: 2.4, dash: 'dot' } };
const 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.csvColumns = [];
this.csvPreview = [];
this.csvInfo = null;
this.csvParseError = null;
this.selectedColumn = 0;
this.filterDone = false;
this.timePlotData = null;
},
handleFileUpload(event) {
const file = event.target.files[0];
this.resetCsvState();
if (!file) return;
const maxBytes = 32 * 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 檔案不可超過 32MB';
event.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = (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),
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;
}
}
} catch (err) {
this.csvParseError = err.message || 'CSV 解析失敗';
this.csvColumns = [];
this.csvPreview = [];
this.csvInfo = null;
event.target.value = '';
}
};
reader.onerror = () => {
this.csvParseError = 'CSV 讀取失敗';
event.target.value = '';
};
reader.readAsText(file);
},
async processFilter() {
if (!this.csvFile) return;
this.loadingFilter = true;
this.globalError = null;
const formData = new FormData();
formData.append('file', this.csvFile);
formData.append('b', this.b_str);
formData.append('a', this.a_str);
formData.append('col_idx', this.selectedColumn);
try {
const res = await fetch('/api/filter', { method: 'POST', body: formData });
if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '濾波處理失敗'); }
const data = await res.json();
this.timePlotData = data;
this.filterDone = true;
// 確保在可見狀態下繪圖,避免 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';
const layout = {
paper_bgcolor: plotBgColor, plot_bgcolor: plotBgColor,
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: 'Sample Index', font: { size: isSmallScreen ? 10 : 11 } }, gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 9 : 11 } },
yaxis: { title: { text: 'Amplitude', font: { size: isSmallScreen ? 10 : 11 } }, gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 9 : 11 } },
legend: { orientation: 'h', y: 1.02, yanchor: 'bottom', x: 1, xanchor: 'right', font: { color: textColor, size: isSmallScreen ? 9 : 10 } }
};
Plotly.react('timePlot', [
{ x: data.index, y: data.original, name: '原始輸入訊號 (Input)', type: 'scatter', line: { color: isDark ? '#c8cee0' : '#566075', width: 2.4 }, opacity: 0.9 },
{ x: data.index, y: data.filtered, name: '濾波後輸出 (Output)', type: 'scatter', line: { color: isDark ? '#64d2ff' : '#2d6f8f', width: 2.4 }, opacity: 0.95 }
], layout, { responsive: true });
},
async downloadCsv() {
if (!this.csvFile) return;
const formData = new FormData();
formData.append('file', this.csvFile);
formData.append('b', this.b_str);
formData.append('a', this.a_str);
formData.append('col_idx', this.selectedColumn);
try {
const res = await fetch('/api/filter/download', { method: 'POST', body: formData });
if (!res.ok) throw new Error('下載失敗');
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'filtered_output.csv';
document.body.appendChild(a); a.click();
window.URL.revokeObjectURL(url);
} catch (e) { this.globalError = e.message; }
},
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() {
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 = `已送出 ${command}`;
} catch (e) {
this.globalError = e.message;
this.mcuStatus = `寫入失敗: ${e.message}`;
} finally {
this.writingMCU = false;
}
}
}
}