refactor: migrate project structure to Vue 3 with Vite and Tailwind CSS integration

This commit is contained in:
ws50529
2026-05-11 09:56:16 +08:00
parent a45569c94c
commit e6f2bf29e9
19 changed files with 3083 additions and 764 deletions
-665
View File
@@ -1,665 +0,0 @@
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')
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>差分方程式分析 (Difference Equation Analyzer)</title>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<link rel="icon" type="image/png" href="/ui/logo.png">
<script type="module" crossorigin src="./app.js"></script>
<link rel="stylesheet" crossorigin href="./app.css">
</head>
<body class="bg-slate-50 dark:bg-darker text-slate-900 dark:text-gray-200 min-h-screen transition-colors duration-300">
<div id="dea-root"></div>
</body>
</html>
+4 -635
View File
@@ -5,645 +5,14 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>差分方程式分析 (Difference Equation Analyzer)</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: { colors: { dark: '#121212', darker: '#0a0a0a', primary: '#1f77b4', secondary: '#ff7f0e' } } }
}
</script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="build/app.css">
<link rel="icon" type="image/png" href="logo.png">
</head>
<body class="bg-slate-50 dark:bg-darker text-slate-900 dark:text-gray-200 min-h-screen transition-colors duration-300">
<div id="app" class="flex flex-col h-screen overflow-hidden">
<!-- 頂部導覽列 -->
<header
class="bg-white dark:bg-dark border-b border-slate-200 dark:border-gray-800 p-4 shadow-md flex justify-between items-center z-10">
<div class="flex items-center gap-3">
<img src="logo.png" alt="Logo" class="h-10 w-10 object-contain rounded-lg shadow-sm">
<h1
class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-emerald-600 dark:from-blue-400 dark:to-emerald-400">
差分方程式分析 (Difference Equation Analyzer)</h1>
</div>
<div class="flex items-center gap-4">
<!-- 模式切換按鈕 -->
<button @click="toggleDarkMode"
class="p-2 rounded-lg bg-slate-100 dark:bg-gray-800 hover:bg-slate-200 dark:hover:bg-gray-700 transition-colors shadow-sm">
<svg v-if="isDarkMode" class="w-5 h-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0 1 1 0 002 0zM2 10a1 1 0 011-1h1a1 1 0 110 2H3a1 1 0 01-1-1zm14 0a1 1 0 011-1h1a1 1 0 110 2h-1a1 1 0 01-1-1zM5.05 5.05a1 1 0 011.414 0l.707.707a1 1 0 11-1.414 1.414l-.707-.707a1 1 0 010-1.414zm9.9 9.9a1 1 0 011.414 0l.707.707a1 1 0 11-1.414 1.414l-.707-.707a1 1 0 010-1.414zM5.05 14.95a1 1 0 010-1.414l.707-.707a1 1 0 011.414 1.414l-.707.707a1 1 0 01-1.414 0zm9.9-9.9a1 1 0 010-1.414l.707-.707a1 1 0 011.414 1.414l-.707.707a1 1 0 01-1.414 0z"
clip-rule="evenodd" />
</svg>
<svg v-else class="w-5 h-5 text-slate-700" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
</button>
</div>
</header>
<!-- 手機版頁籤列 (桌面版隱藏) -->
<div
class="lg:hidden flex border-b border-slate-200 dark:border-gray-800 bg-white dark:bg-dark z-10 flex-shrink-0">
<button @click="mobileTab = 'settings'" :class="mobileTab === 'settings'
? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20'
: 'text-slate-500 dark:text-gray-400 hover:text-slate-700 dark:hover:text-gray-200'"
class="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z">
</path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
設定
</button>
<button @click="mobileTab = 'chart'" :class="mobileTab === 'chart'
? 'border-b-2 border-emerald-500 text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20'
: 'text-slate-500 dark:text-gray-400 hover:text-slate-700 dark:hover:text-gray-200'"
class="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z">
</path>
</svg>
圖表
<span v-if="loadingBode" class="w-2 h-2 rounded-full bg-blue-500 animate-ping"></span>
</button>
</div>
<div class="flex flex-col lg:flex-row flex-1 overflow-hidden">
<!-- 左側控制面板 (RWD: 手機版=設定頁籤全螢幕,桌面版=左側邊欄) -->
<aside :class="mobileTab === 'settings' ? 'flex' : 'hidden'"
class="lg:flex flex-col w-full lg:w-96 h-full bg-white dark:bg-dark border-b lg:border-b-0 lg:border-r border-slate-200 dark:border-gray-800 overflow-y-auto p-5 gap-5 custom-scrollbar transition-colors duration-300">
<!-- 濾波器設計工具 -->
<section>
<h2
class="text-sm font-semibold text-slate-500 dark:text-gray-400 uppercase tracking-wider mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z">
</path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
濾波器設計
</div>
<button @click="resetFilterParams"
class="text-[10px] font-bold bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 hover:bg-amber-200 dark:hover:bg-amber-800/50 px-2 py-1 rounded transition-colors uppercase">重設設計</button>
</h2>
<select v-model="filterType" @change="onFilterTypeChange"
class="w-full bg-slate-50 dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none transition-all mb-3 text-slate-900 dark:text-gray-100 truncate">
<option v-for="opt in filterOptions" :key="opt" :value="opt">{{ opt }}</option>
</select>
<!-- 動態參數區 -->
<div v-if="filterType !== '(無) 手動自訂'"
class="space-y-3 bg-slate-100 dark:bg-gray-900 p-4 rounded-lg border border-slate-200 dark:border-gray-800 shadow-inner">
<!-- Lowpass / Highpass -->
<div v-if="['Lowpass (低通)', 'Highpass (高通)'].includes(filterType)">
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>截止頻率
f_c (Hz)</span><span class="text-emerald-600 dark:text-emerald-400">{{
formatK(params.fc) }}</span></label>
<input type="range" v-model.number="params.fc" @input="debouncedApply" :min="1" :max="fs/2"
class="w-full accent-blue-500 mb-2">
<input type="number" v-model.number="params.fc" @change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base focus:border-blue-500 outline-none text-slate-900 dark:text-gray-100">
<label class="text-sm text-slate-500 dark:text-gray-400 mt-3 mb-2 block">階數 Order</label>
<div class="flex gap-2">
<button v-for="o in [1,2,3]" :key="o" @click="params.order = o; applyFilterDesign()"
:class="params.order === o ? 'bg-blue-600 text-white border-blue-500' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
class="flex-1 rounded py-2.5 text-base font-medium border transition-colors">{{ o
}}</button>
</div>
</div>
<!-- Bandpass -->
<div v-if="filterType === 'Bandpass (帶通)'">
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>下截止頻率
f_low (Hz)</span><span class="text-emerald-600 dark:text-emerald-400">{{
formatK(params.bp_f_low) }}</span></label>
<input type="number" v-model.number="params.bp_f_low" @change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base mb-2 text-slate-900 dark:text-gray-100">
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>上截止頻率
f_high (Hz)</span><span class="text-emerald-600 dark:text-emerald-400">{{
formatK(params.bp_f_high) }}</span></label>
<input type="number" v-model.number="params.bp_f_high" @change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base mb-2 text-slate-900 dark:text-gray-100">
<label class="text-sm text-slate-500 dark:text-gray-400 mt-2 mb-2 block">階數 Order</label>
<div class="flex gap-2">
<button v-for="o in [1,2,3]" :key="o" @click="params.bp_order = o; applyFilterDesign()"
:class="params.bp_order === o ? 'bg-blue-600 text-white border-blue-500' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
class="flex-1 rounded py-1.5 text-sm border transition-colors">{{ o }}</button>
</div>
</div>
<!-- Notch -->
<div v-if="filterType === 'Notch (陷波器)'" class="space-y-3">
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">中心頻率 f_0
(Hz)</label><input type="number" v-model.number="params.notch_f0"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">品質因數
Q</label><input type="number" v-model.number="params.notch_q"
@change="applyFilterDesign" step="0.1"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
</div>
<!-- 1P1Z -->
<div v-if="filterType === '1P1Z (一極一零)'" class="space-y-3">
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">零點頻率 Freq_z
(Hz)</label><input type="number" v-model.number="params.opoz_fz"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">極點頻率 Freq_p
(Hz)</label><input type="number" v-model.number="params.opoz_fp"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
</div>
<!-- 2P2Z -->
<div v-if="filterType === '2P2Z (二極二零)'" class="space-y-3">
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">零點頻率 1 Freq_z1
(Hz)</label><input type="number" v-model.number="params.tptz_fz1"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">零點頻率 2 Freq_z2
(Hz)</label><input type="number" v-model.number="params.tptz_fz2"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">極點頻率 1 Freq_p1
(Hz)</label><input type="number" v-model.number="params.tptz_fp1"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">極點頻率 2 Freq_p2
(Hz)</label><input type="number" v-model.number="params.tptz_fp2"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
</div>
<!-- PID -->
<div v-if="filterType === 'PID 控制器'" class="space-y-3">
<div v-for="k in [{key:'kp',label:'比例增益 Kp'},{key:'ki',label:'積分增益 Ki'},{key:'kd',label:'微分增益 Kd'}]"
:key="k.key">
<label class="text-sm font-medium text-slate-500 dark:text-gray-400 mb-1 block">{{
k.label
}}</label>
<input type="number" v-model.number="params[k.key]" @change="applyFilterDesign"
step="0.0001"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base focus:border-blue-500 outline-none text-slate-900 dark:text-gray-100">
</div>
</div>
<!-- SOGI -->
<div v-if="filterType === 'SOGI-Alpha (帶通)' || filterType === 'SOGI-Beta (低通)'"
class="space-y-3">
<div><label class="text-xs text-slate-500 dark:text-gray-400 mb-1 block">中心頻率 f_0
(Hz)</label><input type="number" v-model.number="params.sogi_f0"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-2 text-sm text-slate-900 dark:text-gray-100">
</div>
<div><label class="text-xs text-slate-500 dark:text-gray-400 mb-1 block">阻尼因數
k</label><input type="number" v-model.number="params.sogi_k"
@change="applyFilterDesign" step="0.001"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-2 text-sm text-slate-900 dark:text-gray-100">
</div>
</div>
<!-- 提示資訊 -->
<div
class="bg-blue-100 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800/50 rounded-lg p-3 text-sm text-blue-700 dark:text-blue-300 mt-2">
💡 調整上方參數會自動覆寫 a, b 係數。你也可以隨時手動修改下方係數進行微調!
</div>
</div>
</section>
<hr class="border-slate-200 dark:border-gray-800">
<!-- 系統參數 -->
<section>
<h2 class="text-sm font-semibold text-slate-500 dark:text-gray-400 uppercase tracking-wider mb-3">
系統參數</h2>
<div class="space-y-3">
<div>
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>取樣頻率
fs
(Hz)</span><span class="text-emerald-600 dark:text-emerald-400">{{ formatK(fs)
}}</span></label>
<input type="number" v-model.number="fs" @input="debouncedApply" @keydown="onlyNumberKey"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base focus:border-blue-500 outline-none transition-all text-slate-900 dark:text-gray-100">
</div>
<div>
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>系統增益
K (System Gain)</span><span class="text-blue-600 dark:text-blue-400">{{ systemGain
}}x</span></label>
<input type="number" v-model.number="systemGain" @input="onGainChange" step="0.1"
@keydown="onlyNumberKey"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base focus:border-blue-500 outline-none transition-all text-slate-900 dark:text-gray-100">
</div>
</div>
</section>
<hr class="border-slate-200 dark:border-gray-800">
<!-- 係數手動設定 -->
<section>
<h2
class="text-sm font-semibold text-slate-500 dark:text-gray-400 uppercase tracking-wider mb-3 flex justify-between items-center">
<span>濾波器係數設定</span>
<div class="flex items-center gap-2">
<button v-if="isManualDirty" @click="clearManualAdjustments"
class="text-[10px] font-bold bg-slate-200 dark:bg-gray-800 hover:bg-slate-300 dark:hover:bg-gray-700 px-2 py-1 rounded text-slate-500 transition-colors uppercase mr-1">重設</button>
<button @click="updateFromText"
class="text-[10px] font-bold bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded transition-colors uppercase shadow-sm">套用</button>
</div>
</h2>
<div class="mb-3">
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">前饋分子係數 b (b0 ~ b6)</label>
<!-- b 係數標記顯示 -->
<div class="flex flex-wrap gap-1 mb-2">
<div v-for="item in floatingCoeffs.b" :key="item.label"
class="bg-blue-50 dark:bg-blue-900/30 px-1.5 py-0.5 rounded border border-blue-100 dark:border-blue-800/20 flex gap-1 items-center">
<span class="text-[9px] text-blue-500 font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono text-blue-800 dark:text-blue-300">{{ item.val
}}</span>
</div>
</div>
<textarea v-model="b_str" @input="debouncedUpdateFromText('b')" @keydown="onlyNumberKey"
rows="2"
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm focus:border-blue-500 outline-none font-mono resize-none leading-relaxed text-blue-700 dark:text-blue-300"></textarea>
</div>
<!-- b 係數倍率微調 -->
<details
class="mb-3 bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-800 rounded-lg">
<summary
class="text-sm text-slate-500 dark:text-gray-400 px-3 py-3 cursor-pointer hover:text-slate-600 dark:hover:text-gray-300">
b 係數倍率微調</summary>
<div class="px-3 pb-3">
<div class="flex items-center gap-2 mb-2 mt-1">
<span class="text-xs text-slate-400 dark:text-gray-500">靈敏度:</span>
<button v-for="s in ['1%','2x','10x']" :key="s" @click="sense_b = s"
:class="sense_b === s ? 'bg-blue-600 text-white' : 'bg-slate-200 dark:bg-gray-800 text-slate-500 dark:text-gray-400'"
class="btn-small px-3 py-1.5 rounded text-xs font-medium border border-slate-300 dark:border-gray-700">{{
s }}</button>
<button @click="resetSlidersB"
class="btn-small ml-auto px-3 py-1.5 rounded text-xs font-medium bg-slate-200 dark:bg-gray-800 text-slate-500 dark:text-gray-400 border border-slate-300 dark:border-gray-700 hover:bg-slate-300 dark:hover:bg-gray-700">重置</button>
</div>
<template v-for="i in 7" :key="'bs'+i">
<div v-if="baseB[i-1] !== 0">
<label class="text-xs text-slate-400 dark:text-gray-500 flex justify-between mt-1">
<span>b{{ i-1 }} (基準: {{ (baseB[i-1] * (systemGain || 1.0)).toPrecision(6)
}})</span>
<span v-if="outOfRangeB[i-1]" class="text-amber-500 font-bold animate-pulse">⚠️
超出滑桿範圍</span>
</label>
<input type="range" v-model.number="bSliders[i-1]" @input="updateFromControls"
:min="-maxLogB" :max="maxLogB" :step="maxLogB/100"
class="w-full accent-blue-500">
</div>
</template>
</div>
</details>
<div class="mb-3">
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">回饋分母係數 a (a0=1, a1 ~
a6)</label>
<!-- a 係數標記顯示 -->
<div class="flex flex-wrap gap-1 mb-2">
<div v-for="item in floatingCoeffs.a" :key="item.label"
class="bg-orange-50 dark:bg-orange-900/30 px-1.5 py-0.5 rounded border border-orange-100 dark:border-orange-800/20 flex gap-1 items-center">
<span class="text-[9px] text-orange-500 font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono text-orange-800 dark:text-orange-300">{{ item.val
}}</span>
</div>
</div>
<textarea v-model="a_str" @input="debouncedUpdateFromText('a')" @keydown="onlyNumberKey"
rows="2"
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm focus:border-blue-500 outline-none font-mono resize-none leading-relaxed text-orange-700 dark:text-orange-300"></textarea>
</div>
<!-- a 係數倍率微調 -->
<details
class="bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-800 rounded-lg">
<summary
class="text-sm text-slate-500 dark:text-gray-400 px-3 py-3 cursor-pointer hover:text-slate-600 dark:hover:text-gray-300">
a 係數倍率微調</summary>
<div class="px-3 pb-3">
<div class="flex items-center gap-2 mb-2 mt-1">
<span class="text-xs text-slate-400 dark:text-gray-500">靈敏度:</span>
<button v-for="s in ['1%','2x','10x']" :key="s" @click="sense_a = s"
:class="sense_a === s ? 'bg-blue-600 text-white' : 'bg-slate-200 dark:bg-gray-800 text-slate-500 dark:text-gray-400'"
class="px-3 py-1.5 rounded text-xs font-medium border border-slate-300 dark:border-gray-700">{{
s }}</button>
<button @click="resetSlidersA"
class="btn-small ml-auto px-3 py-1.5 rounded text-xs font-medium bg-slate-200 dark:bg-gray-800 text-slate-500 dark:text-gray-400 border border-slate-300 dark:border-gray-700 hover:bg-slate-300 dark:hover:bg-gray-700">重置</button>
</div>
<template v-for="i in 6" :key="'as'+i">
<div v-if="baseA[i] !== 0">
<label class="text-xs text-slate-400 dark:text-gray-500 flex justify-between mt-1">
<span>a{{ i }} (基準: {{ baseA[i].toPrecision(6) }})</span>
<span v-if="outOfRangeA[i]" class="text-amber-500 font-bold animate-pulse">⚠️
超出滑桿範圍</span>
</label>
<input type="range" v-model.number="aSliders[i]" @input="updateFromControls"
:min="-maxLogA" :max="maxLogA" :step="maxLogA/100"
class="w-full accent-orange-500">
</div>
</template>
</div>
</details>
<!-- 進階係數關聯調整 (X = a1 - a2) -->
<div class="mt-4">
<button v-if="!showAdvancedAdjustment" @click="enableAdvancedAdjustment"
class="w-full py-3 border-2 border-dashed border-slate-200 dark:border-gray-800 rounded-xl text-slate-400 dark:text-gray-500 text-xs font-bold hover:bg-slate-50 dark:hover:bg-gray-800/50 hover:border-indigo-300 dark:hover:border-indigo-900 transition-all flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"></path>
</svg>
開啟進階係數關聯調整 (a1, a2)
</button>
<div v-if="showAdvancedAdjustment" class="p-4 bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-800 rounded-xl shadow-inner">
<h3 class="text-xs font-bold text-slate-500 dark:text-gray-400 uppercase tracking-widest mb-4 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
進階係數關聯調整
</h3>
<!-- 差距調整 (X) -->
<div class="mb-4">
<label class="text-[11px] font-semibold text-slate-400 dark:text-gray-500 flex justify-between mb-2">
<span>差距 (a1 - a2)</span>
<span class="text-indigo-600 dark:text-indigo-400 font-mono bg-indigo-50 dark:bg-indigo-900/30 px-2 py-0.5 rounded">{{ a1a2Diff.toFixed(3) }}</span>
</label>
<input type="range" v-model.number="a1a2Diff" min="-4" max="4" step="0.001"
@input="updateA1A2FromRelationship"
class="w-full accent-indigo-500 h-1.5 bg-slate-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer">
</div>
<!-- 平移調整 (S) -->
<div class="mb-4">
<label class="text-[11px] font-semibold text-slate-400 dark:text-gray-500 flex justify-between mb-2">
<span>平移 (a1 + a2)</span>
<span class="text-emerald-600 dark:text-emerald-400 font-mono bg-emerald-50 dark:bg-emerald-900/30 px-2 py-0.5 rounded">{{ a1a2Sum.toFixed(3) }}</span>
</label>
<input type="range" v-model.number="a1a2Sum" min="-4" max="4" step="0.001"
@input="updateA1A2FromRelationship"
class="w-full accent-emerald-500 h-1.5 bg-slate-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer">
</div>
<!-- 預覽結果 -->
<div class="flex gap-2 mb-4">
<div class="flex-1 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
<span class="text-[9px] text-slate-400 block uppercase font-bold mb-1">a1 變化預覽</span>
<div class="flex items-center justify-center gap-1.5">
<span class="text-[10px] font-mono text-slate-400">{{ currentA1.toFixed(3) }}</span>
<svg class="w-2.5 h-2.5 text-slate-300" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
<span class="text-xs font-mono font-bold text-indigo-600 dark:text-indigo-400">{{ previewA1.toFixed(3) }}</span>
</div>
</div>
<div class="flex-1 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
<span class="text-[9px] text-slate-400 block uppercase font-bold mb-1">a2 變化預覽</span>
<div class="flex items-center justify-center gap-1.5">
<span class="text-[10px] font-mono text-slate-400">{{ currentA2.toFixed(3) }}</span>
<svg class="w-2.5 h-2.5 text-slate-300" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
<span class="text-xs font-mono font-bold text-emerald-600 dark:text-emerald-400">{{ previewA2.toFixed(3) }}</span>
</div>
</div>
</div>
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-100 dark:border-amber-800/30 rounded-lg p-3">
<p class="text-[10px] text-amber-700 dark:text-amber-400 leading-relaxed">
<span class="font-bold">運算說明:</span><br>
調整時,系統會先將現有係數四捨五入至三位小數再計算基準值。調整結果會自動將 a1, a2 存回為三位小數精度。
</p>
</div>
</div>
</div>
</section>
<hr class="border-slate-200 dark:border-gray-800">
<!-- 定點數轉換 -->
<section>
<h2
class="text-sm font-semibold text-slate-500 dark:text-gray-400 uppercase tracking-wider mb-3 flex justify-between items-center">
<div class="flex items-center gap-2">
<span>定點數係數轉換</span>
<span
class="text-xs bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 px-2 py-1 rounded">Q{{
shiftBits }}</span>
</div>
<div class="flex items-center gap-2">
<button
v-if="Object.keys(fixedOverrides.a).length > 0 || Object.keys(fixedOverrides.b).length > 0"
@click="clearFixedOverrides"
class="text-[10px] font-bold bg-slate-200 dark:bg-gray-800 hover:bg-slate-300 dark:hover:bg-gray-700 px-2 py-1 rounded text-slate-500 transition-colors uppercase mr-1">重設</button>
<button @click="updateBodePlot"
class="text-[10px] font-bold bg-emerald-600 hover:bg-emerald-500 text-white px-3 py-1 rounded transition-colors uppercase shadow-sm">套用</button>
</div>
</h2>
<div class="mb-4">
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">左移位元數 (Shift Bits:
0~14)</label>
<div class="flex items-center gap-2">
<input type="number" v-model.number="shiftBits" min="0" max="14"
@change="clearFixedOverrides" @keydown="onlyNumberKey"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base focus:border-blue-500 outline-none transition-all text-slate-900 dark:text-gray-100">
</div>
<p class="text-[10px] text-slate-400 mt-1">Scaling: 2^{{ shiftBits }} ({{ Math.pow(2, shiftBits)
}})</p>
</div>
<div class="space-y-4">
<div>
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">定點分子係數 b
(Integers)</label>
<div class="flex flex-wrap gap-1 mb-2">
<div v-for="(item, idx) in fixedPointCoeffs.b" :key="item.label"
:class="item.isOverridden ? 'border-blue-400 ring-1 ring-blue-400/20' : 'border-blue-100 dark:border-blue-800/30'"
class="bg-blue-50 dark:bg-blue-900/20 px-1.5 py-0.5 rounded border flex items-center gap-1 transition-all">
<span class="text-[9px] text-blue-400 font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono text-blue-700 dark:text-blue-300 font-bold"
:class="{ 'italic': !item.isOverridden }">{{ item.val }}</span>
</div>
</div>
<textarea v-model="b_int_str" @keydown="onlyNumberKey($event, false)" rows="2"
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm focus:border-blue-500 outline-none font-mono resize-none leading-relaxed text-blue-700 dark:text-blue-300"></textarea>
</div>
<div>
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">定點分母係數 a
(Integers)</label>
<div class="flex flex-wrap gap-1 mb-2">
<div v-for="(item, idx) in fixedPointCoeffs.a" :key="item.label"
:class="item.isOverridden ? 'border-orange-400 ring-1 ring-orange-400/20' : 'border-orange-100 dark:border-orange-800/30'"
class="bg-orange-50 dark:bg-orange-900/20 px-1.5 py-0.5 rounded border flex items-center gap-1 transition-all">
<span class="text-[9px] text-orange-400 font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono text-orange-700 dark:text-orange-300 font-bold"
:class="{ 'italic': !item.isOverridden }">{{ item.val }}</span>
</div>
</div>
<textarea v-model="a_int_str" @keydown="onlyNumberKey($event, false)" rows="2"
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm focus:border-blue-500 outline-none font-mono resize-none leading-relaxed text-orange-700 dark:text-orange-300"></textarea>
</div>
</div>
<p class="text-[10px] text-slate-400 dark:text-gray-500 mt-3 italic">註:此數值為原始係數乘以 2^{{ shiftBits }}
後四捨五入之結果,可用於定點數 DSP 實作。</p>
</section>
</aside>
<!-- 右側主視窗 (RWD: 手機版=圖表頁籤全螢幕,桌面版=右側主區) -->
<main :class="mobileTab === 'chart' ? 'flex bg-white dark:bg-[#0f0f0f] !p-0' : 'hidden'"
class="lg:flex flex-1 flex-col overflow-y-auto bg-slate-50 dark:bg-[#0f0f0f] lg:p-6 gap-0 lg:gap-6 custom-scrollbar relative transition-colors duration-300">
<div v-if="globalError"
class="absolute top-4 right-6 bg-red-100 dark:bg-red-900 border border-red-200 dark:border-red-700 text-red-700 dark:text-white px-4 py-3 rounded-lg shadow-lg text-base z-50 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{{ globalError }}
<button @click="globalError = null"
class="ml-2 hover:text-red-900 dark:hover:text-red-200">&times;</button>
</div>
<!-- Bode Plot -->
<div
class="bg-white dark:bg-dark lg:rounded-xl border-b lg:border border-slate-100 lg:border-slate-200 dark:border-gray-800 lg:dark:border-gray-800 lg:shadow-md p-0 lg:p-1 flex-shrink-0 relative transition-colors duration-300">
<div
class="hidden lg:block absolute inset-0 bg-gradient-to-br from-blue-500/5 to-purple-500/5 pointer-events-none">
</div>
<div
class="px-5 py-4 border-b border-slate-100 dark:border-gray-800 flex justify-between items-center bg-white dark:bg-dark backdrop-blur-sm z-10 relative">
<h3 class="font-bold text-slate-800 dark:text-gray-200 tracking-wide">1. 頻率響應 (Frequency
Response)</h3>
<div v-if="loadingBode"
class="text-sm text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 px-3 py-1.5 rounded border border-blue-100 dark:border-blue-800/50 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-blue-500 dark:bg-blue-400 animate-ping"></span>
重新計算中...
</div>
</div>
<!-- 只有圖表繪圖區可水平滾動,卡片外框不動 -->
<div class="overflow-x-auto bg-white dark:bg-dark">
<div id="bodePlot" class="min-w-[580px] w-full h-[800px] z-10 relative bg-white dark:bg-dark">
</div>
</div>
</div>
<!-- 時域分析 -->
<div
class="bg-white dark:bg-white lg:dark:bg-dark lg:rounded-xl border-b lg:border border-slate-100 lg:border-slate-200 dark:border-gray-100 lg:dark:border-gray-800 lg:shadow-md p-0 lg:p-1 flex-shrink-0 relative transition-colors duration-300">
<div
class="px-5 py-4 border-b border-slate-100 dark:border-gray-800 bg-white dark:bg-dark backdrop-blur-sm z-10 relative">
<h3 class="font-bold text-slate-800 dark:text-gray-200 tracking-wide">2. 時域訊號探討</h3>
</div>
<div class="p-6 z-10 relative bg-white dark:bg-dark transition-colors duration-300">
<p class="text-base text-slate-500 dark:text-gray-400 mb-4">請上傳包含時域訊號的 CSV 檔案,系統會套用上述設定的差分濾波器。
</p>
<div
class="flex items-center flex-wrap gap-4 mb-6 p-4 bg-slate-50 dark:bg-gray-900/50 rounded-lg border border-slate-100 dark:border-gray-800">
<label
class="cursor-pointer bg-blue-600 hover:bg-blue-500 text-white px-5 py-3 rounded-lg text-base font-semibold transition-all shadow-md hover:shadow-blue-500/20 flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
</svg>
上傳輸入訊號 (CSV)
<input type="file" ref="fileInput" accept=".csv" @change="handleFileUpload"
class="hidden">
</label>
<span v-if="csvFile" class="text-base text-emerald-600 dark:text-emerald-400 font-mono">{{
csvFile.name }}</span>
<div v-if="csvColumns.length > 0"
class="h-8 border-l border-slate-200 dark:border-gray-700 mx-2"></div>
<div v-if="csvColumns.length > 0" class="flex items-center gap-2">
<span class="text-sm text-slate-400">訊號欄位:</span>
<select v-model="selectedColumn"
class="bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base focus:border-blue-500 outline-none text-slate-900 dark:text-gray-100">
<option v-for="(col, idx) in csvColumns" :key="idx" :value="idx">{{ col }}</option>
</select>
</div>
<button v-if="csvFile" @click="processFilter"
class="bg-emerald-600 hover:bg-emerald-500 text-white px-6 py-3 rounded-lg text-base font-semibold transition-all shadow-md hover:shadow-emerald-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loadingFilter">
{{ loadingFilter ? '處理中...' : '套用當前濾波器' }}
</button>
<button v-if="filterDone" @click="downloadCsv"
class="ml-auto bg-slate-700 hover:bg-slate-600 text-white px-5 py-3 rounded-lg text-base font-semibold transition-all flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
匯出結果
</button>
</div>
<!-- 資料預覽 -->
<div v-if="csvPreview.length > 0" class="mb-6">
<h4
class="text-sm font-semibold text-slate-500 dark:text-gray-400 mb-2 uppercase tracking-tight">
資料預覽 (前五筆):</h4>
<div
class="overflow-x-auto rounded-xl border border-slate-100 dark:border-gray-800 shadow-inner">
<table class="w-full text-sm text-left">
<thead>
<tr class="bg-slate-50 dark:bg-gray-800/50">
<th v-for="col in csvColumns" :key="col"
class="px-4 py-3 font-semibold text-slate-700 dark:text-gray-300">{{ col
}}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-gray-800">
<tr v-for="(row, ri) in csvPreview" :key="ri"
class="hover:bg-slate-50/50 dark:hover:bg-gray-800/30 transition-colors">
<td v-for="(val, ci) in row" :key="ci"
class="px-4 py-2 font-mono text-slate-600 dark:text-gray-400">{{ val }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 波形比較圖 -->
<h4 v-if="filterDone" class="text-base font-bold text-slate-800 dark:text-gray-200 mb-3">波形比較圖
</h4>
<!-- 只有圖表繪圖區可水平滾動,卡片外框不動 -->
<div class="overflow-x-auto bg-white dark:bg-dark">
<div id="timePlot" class="min-w-[580px] w-full h-[400px] bg-white dark:bg-dark"
v-show="filterDone"></div>
</div>
<div v-if="!filterDone && !loadingFilter && csvPreview.length === 0"
class="h-[300px] flex flex-col items-center justify-center text-slate-400 dark:text-gray-500 border-2 border-dashed border-slate-200 dark:border-gray-800 rounded-xl bg-slate-50/50 dark:bg-gray-900/30">
<svg class="w-12 h-12 mb-3 opacity-50" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z">
</path>
</svg>
<p>請上傳包含時間序列的 CSV 檔案進行測試</p>
</div>
</div>
</div>
</main>
</div>
<!-- 全域置底版權宣告 -->
<footer
class="bg-white dark:bg-dark border-t border-slate-200 dark:border-gray-800 py-2 text-center z-20 transition-colors duration-300">
<p class="text-xs text-slate-400 dark:text-gray-600">© 2026 喆富創新科技股份有限公司. All rights reserved.</p>
</footer>
</div>
<script src="app.js"></script>
<div id="dea-root"></div>
<script type="module" src="build/app.js"></script>
</body>
</html>
</html>