2026-05-14 17:28:33 +08:00
|
|
|
|
const DEFAULT_FILTER_TYPE = "Lowpass (低通)";
|
2026-05-14 17:05:12 +08:00
|
|
|
|
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(),
|
|
|
|
|
|
// 模式切換
|
2026-05-14 13:59:45 +08:00
|
|
|
|
isDarkMode: true,
|
2026-05-14 17:05:12 +08:00
|
|
|
|
// 係數倍率微調
|
|
|
|
|
|
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,
|
2026-05-15 17:45:45 +08:00
|
|
|
|
fixedAFineStep: 0.5,
|
2026-05-14 17:05:12 +08:00
|
|
|
|
fixedAFineDrag: null,
|
|
|
|
|
|
fixedAFineRepeatDelayTimer: null,
|
2026-05-14 15:37:01 +08:00
|
|
|
|
fixedAFineRepeatTimer: null,
|
|
|
|
|
|
isTouchInput: false,
|
|
|
|
|
|
activeCoeffAdjustment: null,
|
2026-05-14 17:28:33 +08:00
|
|
|
|
mcuSerialPort: null,
|
|
|
|
|
|
mcuConnected: false,
|
|
|
|
|
|
webSerialSupported: false,
|
2026-05-14 15:37:01 +08:00
|
|
|
|
writingMCU: false,
|
|
|
|
|
|
mcuStatus: '',
|
2026-05-15 17:45:45 +08:00
|
|
|
|
bodeMagRange: null, // [min, max] for Y-axis
|
2026-05-14 15:37:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-05-14 17:05:12 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
mounted() {
|
|
|
|
|
|
this.isTouchInput = window.matchMedia?.('(pointer: coarse)').matches || false;
|
|
|
|
|
|
window.addEventListener('pointerdown', this.rememberPointerType);
|
|
|
|
|
|
|
|
|
|
|
|
const savedTheme = localStorage.getItem('theme');
|
2026-05-14 13:59:45 +08:00
|
|
|
|
this.isDarkMode = savedTheme ? savedTheme === 'dark' : true;
|
2026-05-14 15:37:01 +08:00
|
|
|
|
if (this.isDarkMode) {
|
|
|
|
|
|
document.documentElement.classList.add('dark');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
document.documentElement.classList.remove('dark');
|
|
|
|
|
|
}
|
2026-05-14 17:28:33 +08:00
|
|
|
|
this.webSerialSupported = ('serial' in navigator);
|
|
|
|
|
|
if (!this.webSerialSupported) {
|
|
|
|
|
|
this.mcuStatus = '此瀏覽器不支援 Web Serial(需使用 Chrome 或 Edge,且透過 HTTPS 連線)';
|
|
|
|
|
|
}
|
2026-05-15 17:45:45 +08:00
|
|
|
|
this.updateBodeMagRange();
|
2026-05-14 15:37:01 +08:00
|
|
|
|
this.applyFilterDesign();
|
|
|
|
|
|
},
|
2026-05-14 17:05:12 +08:00
|
|
|
|
beforeUnmount() {
|
|
|
|
|
|
window.removeEventListener('pointerdown', this.rememberPointerType);
|
|
|
|
|
|
this.stopA1A2Repeat();
|
|
|
|
|
|
this.stopAFineDrag();
|
|
|
|
|
|
this.stopShiftBitRepeat();
|
|
|
|
|
|
this.stopShiftBitDrag();
|
|
|
|
|
|
this.stopFixedA1A2Repeat();
|
|
|
|
|
|
this.stopFixedAFineDrag();
|
|
|
|
|
|
},
|
|
|
|
|
|
methods: {
|
|
|
|
|
|
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) {
|
2026-05-15 17:45:45 +08:00
|
|
|
|
const steps = [0.5, 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];
|
2026-05-14 17:05:12 +08:00
|
|
|
|
},
|
|
|
|
|
|
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) {
|
2026-05-15 17:45:45 +08:00
|
|
|
|
// 使用「兩倍值」空間進行整數運算
|
|
|
|
|
|
let S = Math.round(delta * 2);
|
|
|
|
|
|
let D = Math.round(r * 2);
|
|
|
|
|
|
const a0 = this.fixedA0Int;
|
|
|
|
|
|
|
|
|
|
|
|
// 核心邏輯:a1, a2 為整數的充要條件是 S 與 D 的奇偶性相同
|
|
|
|
|
|
// 這裡採用「優先滿足目標值」的策略:如果奇偶不同,則微調其中一個
|
|
|
|
|
|
// 由於此函數由 UI 直接調用,我們無法判斷使用者剛才改了誰
|
|
|
|
|
|
// 所以我們預設採用 Math.round 來尋找最接近的整數組合
|
|
|
|
|
|
const a2 = Math.round((S + D) / 2);
|
|
|
|
|
|
const a1 = a2 - D - a0;
|
|
|
|
|
|
|
|
|
|
|
|
this.fixedOverrides.a[1] = a1;
|
|
|
|
|
|
this.fixedOverrides.a[2] = a2;
|
2026-05-14 17:05:12 +08:00
|
|
|
|
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) {
|
2026-05-15 17:45:45 +08:00
|
|
|
|
let S = Math.round(this.fixedCurrentDelta * 2);
|
|
|
|
|
|
let D = Math.round(this.fixedCurrentR * 2);
|
|
|
|
|
|
const dVal = Math.round(steps * this.fixedAFineStep * 2);
|
|
|
|
|
|
const a0 = this.fixedA0Int;
|
|
|
|
|
|
|
2026-05-14 17:05:12 +08:00
|
|
|
|
if (target === 'delta') {
|
2026-05-15 17:45:45 +08:00
|
|
|
|
// 優先保證 delta 變動到位
|
|
|
|
|
|
S += dVal;
|
|
|
|
|
|
// 檢查奇偶性是否匹配
|
|
|
|
|
|
if ((S % 2 + 2) % 2 !== (D % 2 + 2) % 2) {
|
|
|
|
|
|
// 核心:震盪平衡邏輯。
|
|
|
|
|
|
// 如果 S 變為偶數,D 應該變為最近的偶數;如果 S 變為奇數,D 應該變為最近的奇數。
|
|
|
|
|
|
// 我們判斷目前 D 是在 .5 還是 .0,然後向反方向跳轉,防止單向漂移。
|
|
|
|
|
|
if (D % 2 !== 0) {
|
|
|
|
|
|
D -= 1; // 10.5 -> 10.0
|
|
|
|
|
|
} else {
|
|
|
|
|
|
D += 1; // 10.0 -> 10.5
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-14 17:05:12 +08:00
|
|
|
|
} else if (target === 'r') {
|
2026-05-15 17:45:45 +08:00
|
|
|
|
// 如果 r 沒被禁用,則優先保證 r 變動到位
|
|
|
|
|
|
D += dVal;
|
|
|
|
|
|
if ((S % 2 + 2) % 2 !== (D % 2 + 2) % 2) {
|
|
|
|
|
|
if (S % 2 !== 0) S -= 1;
|
|
|
|
|
|
else S += 1;
|
|
|
|
|
|
}
|
2026-05-14 17:05:12 +08:00
|
|
|
|
}
|
2026-05-15 17:45:45 +08:00
|
|
|
|
|
|
|
|
|
|
const a2 = (S + D) / 2;
|
|
|
|
|
|
const a1 = a2 - D - a0;
|
|
|
|
|
|
|
|
|
|
|
|
this.fixedOverrides.a[1] = a1;
|
|
|
|
|
|
this.fixedOverrides.a[2] = a2;
|
|
|
|
|
|
this.debouncedUpdateBode();
|
2026-05-14 17:05:12 +08:00
|
|
|
|
},
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-05-14 15:37:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
startA1A2Repeat(mode, direction) {
|
|
|
|
|
|
this.stopA1A2Repeat();
|
|
|
|
|
|
this.adjustAFineValue(mode, direction);
|
|
|
|
|
|
this.aFineRepeatDelayTimer = setTimeout(() => {
|
|
|
|
|
|
this.aFineRepeatTimer = setInterval(() => {
|
|
|
|
|
|
this.adjustAFineValue(mode, direction);
|
|
|
|
|
|
}, 80);
|
|
|
|
|
|
}, 350);
|
|
|
|
|
|
},
|
2026-05-14 17:05:12 +08:00
|
|
|
|
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-12) 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-12) 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() {
|
2026-05-15 17:45:45 +08:00
|
|
|
|
this.updateBodeMagRange();
|
2026-05-14 17:05:12 +08:00
|
|
|
|
this.updateFromControls();
|
|
|
|
|
|
},
|
2026-05-15 17:45:45 +08:00
|
|
|
|
updateBodeMagRange() {
|
|
|
|
|
|
const K = parseFloat(this.systemGain) || 1.0;
|
|
|
|
|
|
const yMax = 20 * Math.log10(Math.abs(K) * 3);
|
|
|
|
|
|
this.bodeMagRange = [yMax - 70, yMax];
|
|
|
|
|
|
},
|
2026-05-14 17:05:12 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-05-15 17:45:45 +08:00
|
|
|
|
this.updateBodeMagRange();
|
2026-05-14 17:05:12 +08:00
|
|
|
|
this.applyFilterDesign();
|
|
|
|
|
|
},
|
|
|
|
|
|
resetFilterParams() {
|
|
|
|
|
|
clearTimeout(this.bodeTimeout);
|
2026-05-15 11:34:13 +08:00
|
|
|
|
const isManual = this.filterType === MANUAL_FILTER_TYPE;
|
2026-05-14 17:05:12 +08:00
|
|
|
|
this.fs = DEFAULT_FS;
|
|
|
|
|
|
this.systemGain = 1.0;
|
|
|
|
|
|
this.params = cloneDefaultParams();
|
2026-05-15 11:34:13 +08:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
2026-05-14 17:05:12 +08:00
|
|
|
|
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;
|
2026-05-15 17:45:45 +08:00
|
|
|
|
this.updateBodeMagRange();
|
2026-05-15 11:34:13 +08:00
|
|
|
|
if (isManual) this.updateBodePlot({ switchToChart: false });
|
|
|
|
|
|
else this.applyFilterDesign();
|
2026-05-14 17:05:12 +08:00
|
|
|
|
},
|
|
|
|
|
|
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',
|
2026-05-14 13:59:45 +08:00
|
|
|
|
line: { color: isDark ? 'rgba(242,184,181,0.72)' : 'rgba(179,38,30,0.68)', width: 1.4, dash: 'dash' }
|
2026-05-14 17:05:12 +08:00
|
|
|
|
});
|
|
|
|
|
|
if (text) {
|
|
|
|
|
|
annotations.push({
|
|
|
|
|
|
x: Math.log10(xVal), y: 1, text: text,
|
|
|
|
|
|
xref: row === 1 ? 'x' : 'x2', yref: yref + ' domain',
|
2026-05-14 13:59:45 +08:00
|
|
|
|
showarrow: false, font: { color: isDark ? '#f2b8b5' : '#b3261e', size: isSmallScreen ? 10 : 12 },
|
2026-05-14 17:05:12 +08:00
|
|
|
|
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) {
|
2026-05-14 13:59:45 +08:00
|
|
|
|
const gridColor = isDark ? 'rgba(200,206,224,0.24)' : 'rgba(86,96,117,0.16)';
|
2026-05-14 17:05:12 +08:00
|
|
|
|
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 } }
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 13:59:45 +08:00
|
|
|
|
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';
|
2026-05-14 17:05:12 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-15 17:45:45 +08:00
|
|
|
|
uirevision: this.bodeMagRange ? this.bodeMagRange.join(',') : 'true', // 當範圍不變時,保留使用者縮放
|
2026-05-14 17:05:12 +08:00
|
|
|
|
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: [
|
2026-05-14 13:59:45 +08:00
|
|
|
|
{ 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' } },
|
2026-05-14 17:05:12 +08:00
|
|
|
|
|
|
|
|
|
|
// 手動 Magnitude 圖例 (位於上方圖表下方)
|
2026-05-14 13:59:45 +08:00
|
|
|
|
{ xref: 'paper', yref: 'paper', x: 0.35, y: isSmallScreen ? 0.52 : 0.54, text: '一一一', font: { color: isDark ? '#c8cee0' : '#566075', size: 10 }, showarrow: false },
|
2026-05-14 17:05:12 +08:00
|
|
|
|
{ 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' },
|
2026-05-14 13:59:45 +08:00
|
|
|
|
{ xref: 'paper', yref: 'paper', x: 0.58, y: isSmallScreen ? 0.52 : 0.54, text: '· · · ·', font: { color: isDark ? '#b9b2ff' : '#7a6689', size: 14 }, showarrow: false },
|
2026-05-14 17:05:12 +08:00
|
|
|
|
{ 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 圖例 (位於下方圖表下方)
|
2026-05-14 13:59:45 +08:00
|
|
|
|
{ xref: 'paper', yref: 'paper', x: 0.35, y: -0.15, text: '一一一', font: { color: isDark ? '#64d2ff' : '#2d6f8f', size: 10 }, showarrow: false },
|
2026-05-14 17:05:12 +08:00
|
|
|
|
{ xref: 'paper', yref: 'paper', x: 0.42, y: -0.15, text: 'Ideal Phase', font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' },
|
2026-05-14 13:59:45 +08:00
|
|
|
|
{ xref: 'paper', yref: 'paper', x: 0.58, y: -0.15, text: '· · · ·', font: { color: isDark ? '#ff9bd8' : '#96527b', size: 14 }, showarrow: false },
|
2026-05-14 17:05:12 +08:00
|
|
|
|
{ xref: 'paper', yref: 'paper', x: 0.63, y: -0.15, text: 'Fixed Phase', font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' },
|
|
|
|
|
|
|
|
|
|
|
|
...annotations
|
|
|
|
|
|
],
|
2026-05-15 17:45:45 +08:00
|
|
|
|
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 // 相位縱軸鎖死,不可縮放
|
|
|
|
|
|
},
|
2026-05-14 17:05:12 +08:00
|
|
|
|
shapes: shapes
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-14 13:59:45 +08:00
|
|
|
|
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' } };
|
2026-05-12 16:49:38 +08:00
|
|
|
|
|
2026-05-14 13:59:45 +08:00
|
|
|
|
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' };
|
2026-05-14 17:05:12 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-05-14 13:59:45 +08:00
|
|
|
|
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';
|
2026-05-14 17:05:12 +08:00
|
|
|
|
|
|
|
|
|
|
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', [
|
2026-05-14 13:59:45 +08:00
|
|
|
|
{ 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 }
|
2026-05-14 17:05:12 +08:00
|
|
|
|
], 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);
|
2026-05-14 15:37:01 +08:00
|
|
|
|
} catch (e) { this.globalError = e.message; }
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-05-14 17:28:33 +08:00
|
|
|
|
async connectMCUPort() {
|
|
|
|
|
|
if (!this.webSerialSupported) return;
|
2026-05-14 15:37:01 +08:00
|
|
|
|
this.mcuStatus = '';
|
|
|
|
|
|
try {
|
2026-05-14 17:28:33 +08:00
|
|
|
|
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}`;
|
2026-05-14 15:37:01 +08:00
|
|
|
|
}
|
2026-05-14 17:28:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-05-14 15:37:01 +08:00
|
|
|
|
|
2026-05-14 17:28:33 +08:00
|
|
|
|
async disconnectMCUPort() {
|
|
|
|
|
|
if (this.mcuSerialPort) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await this.mcuSerialPort.close();
|
|
|
|
|
|
} catch { /* ignore close errors */ }
|
|
|
|
|
|
this.mcuSerialPort = null;
|
2026-05-14 15:37:01 +08:00
|
|
|
|
}
|
2026-05-14 17:28:33 +08:00
|
|
|
|
this.mcuConnected = false;
|
|
|
|
|
|
this.mcuStatus = '已斷線';
|
2026-05-14 15:37:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
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() {
|
2026-05-14 17:28:33 +08:00
|
|
|
|
if (!this.mcuSerialPort || !this.mcuConnected) {
|
|
|
|
|
|
this.mcuStatus = '請先連線 MCU 連接埠';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-14 15:37:01 +08:00
|
|
|
|
this.writingMCU = true;
|
|
|
|
|
|
this.globalError = null;
|
|
|
|
|
|
this.mcuStatus = '';
|
|
|
|
|
|
const command = this.mcuCommandFromFixedCoeffs();
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-14 17:28:33 +08:00
|
|
|
|
const encoder = new TextEncoder();
|
|
|
|
|
|
const writer = this.mcuSerialPort.writable.getWriter();
|
|
|
|
|
|
try {
|
|
|
|
|
|
await writer.write(encoder.encode(command + '\r\n'));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
writer.releaseLock();
|
2026-05-14 15:37:01 +08:00
|
|
|
|
}
|
2026-05-14 17:28:33 +08:00
|
|
|
|
this.mcuStatus = `已送出 ${command}`;
|
2026-05-14 15:37:01 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
this.globalError = e.message;
|
2026-05-14 17:28:33 +08:00
|
|
|
|
this.mcuStatus = `寫入失敗: ${e.message}`;
|
2026-05-14 15:37:01 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
this.writingMCU = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|