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