Files
tcad-bodeplot/static/app.js
T
2026-05-08 16:31:10 +08:00

612 lines
30 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], // 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],
}
},
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;
}
},
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) {
return str.split(',').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];
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];
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(', ');
},
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();
},
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) {
const logVal = Math.log10(val / (base * gain));
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) {
const logVal = Math.log10(val / base);
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();
},
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];
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];
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.drawTimePlot(data);
this.filterDone = true;
} 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')