refactor: add fixed-point coefficient overrides and enhance manual adjustment tracking

This commit is contained in:
ws50529
2026-05-08 16:10:19 +08:00
parent 48eeb11d26
commit 9c535432fe
2 changed files with 321 additions and 102 deletions
+230 -49
View File
@@ -6,13 +6,13 @@ createApp({
fs: 100000.0,
b_str: "0.5, 0.5, 0.0, 0.0",
a_str: "1.0, 0.0, 0.0, 0.0",
filterType: "(無) 手動自訂",
filterType: "Lowpass (低通)",
filterOptions: [
"(無) 手動自訂", "Lowpass (低通)", "Highpass (高通)",
"Bandpass (帶通)", "Notch (陷波器)", "1P1Z (一極一零)",
"2P2Z (二極二零)", "PID 控制器", "SOGI-Alpha (帶通)", "SOGI-Beta (低通)"
"Lowpass (低通)", "Highpass (高通)", "Bandpass (帶通)",
"Notch (陷波器)", "1P1Z (一極一零)", "2P2Z (二極二零)",
"PID 控制器", "SOGI-Alpha (帶通)", "SOGI-Beta (低通)", "(無) 手動自訂"
],
systemGainDB: 0.0,
systemGain: 1.0,
params: {
fc: 1000.0, order: 1,
bp_f_low: 500.0, bp_f_high: 2000.0, bp_order: 1,
@@ -35,13 +35,14 @@ createApp({
csvFile: null, csvColumns: [], csvPreview: [],
selectedColumn: 0, loadingFilter: false, filterDone: false, timePlotData: null,
mobileTab: 'settings', // 手機版頁籤:'settings' | 'chart'
shiftBits: 12,
shiftBits: 14,
fixedOverrides: { a: {}, b: {} },
outOfRangeB: [false, false, false, false, false, false, false],
outOfRangeA: [false, false, false, false, false, false, false],
}
},
computed: {
linearSystemGain() {
return Math.pow(10, this.systemGainDB / 20.0);
},
maxLogB() {
if (this.sense_b === '1%') return Math.log10(1.01);
if (this.sense_b === '2x') return Math.log10(2.0);
@@ -50,22 +51,68 @@ createApp({
maxLogA() {
if (this.sense_a === '1%') return Math.log10(1.01);
if (this.sense_a === '2x') return Math.log10(2.0);
return 1.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).map(x => x * this.linearSystemGain);
const b_raw = this.parseCoeffs(this.b_str);
const a_raw = this.parseCoeffs(this.a_str);
const b = b_raw.map((x, i) => ({ label: `b${i}`, val: Math.round(x * scale) }));
const a = a_raw.map((x, i) => ({ label: `a${i}`, val: Math.round(x * scale) }));
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 * this.linearSystemGain).toPrecision(6)) }));
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++) {
if (Math.abs((currentB[i] || 0) - (targetB[i] || 0)) > 1e-8) return true;
if (Math.abs((currentA[i] || 0) - (targetA[i] || 0)) > 1e-8) return true;
}
return false;
}
},
mounted() {
@@ -78,8 +125,7 @@ createApp({
} else {
document.documentElement.classList.remove('dark');
}
this.syncBaseFromText();
this.updateBodePlot();
this.applyFilterDesign();
},
methods: {
toggleDarkMode() {
@@ -109,36 +155,95 @@ createApp({
return r;
},
syncBaseFromText() {
this.baseB = this.padTo7(this.parseCoeffs(this.b_str));
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(', ');
this.updateBodePlot();
},
resetSlidersA() {
this.aSliders = [0, 0, 0, 0, 0, 0];
this.a_str = this.baseA.map(x => parseFloat(x.toPrecision(10))).join(', ');
this.updateBodePlot();
},
updateFromSliders() {
const currentB = this.baseB.map((base, i) => base * Math.pow(10, this.bSliders[i] || 0));
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(6))).join(', ');
this.a_str = currentA.map(x => parseFloat(x.toPrecision(6))).join(', ');
this.debouncedUpdateBode();
this.b_str = currentB.map(x => parseFloat(x.toPrecision(10))).join(', ');
this.a_str = currentA.map(x => parseFloat(x.toPrecision(10))).join(', ');
},
updateFromText() {
this.syncBaseFromText();
// 不再呼叫 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();
},
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();
@@ -157,6 +262,45 @@ createApp({
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() {
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.updateFromText();
} 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;
@@ -179,9 +323,17 @@ createApp({
});
if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '設計濾波器失敗'); }
const data = await res.json();
this.b_str = data.b.map(x => parseFloat(x.toPrecision(10))).join(', ');
this.a_str = data.a.map(x => parseFloat(x.toPrecision(10))).join(', ');
this.syncBaseFromText();
// 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; }
},
@@ -189,23 +341,44 @@ createApp({
this.loadingBode = true;
this.globalError = null;
try {
let b = this.parseCoeffs(this.b_str).map(val => val * this.linearSystemGain);
let a = this.parseCoeffs(this.a_str);
if (b.length === 0) b = [1.0];
if (a.length === 0) a = [1.0];
const res = await fetch('/api/bode', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ b, a, fs: this.fs })
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;
});
if (!res.ok) throw new Error('無法計算頻率響應');
const data = await res.json();
this.drawBodePlot(data.freq, data.mag, data.phase);
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, mag, phase) {
drawBodePlot(freq, magIdeal, phaseIdeal, magFixed, phaseFixed) {
const fs = this.fs;
const fMin = freq[0], fMax = freq[freq.length - 1];
const isDark = this.isDarkMode;
@@ -283,12 +456,19 @@ createApp({
const layout = {
paper_bgcolor: 'transparent', plot_bgcolor: 'transparent',
margin: isSmallScreen ? { t: 80, b: 70, l: 55, r: 15 } : { t: 70, b: 60, l: 70, r: 30 },
showlegend: false,
showlegend: true,
legend: {
orientation: 'h',
y: 1.1,
x: 0.5,
xanchor: 'center',
font: { size: isSmallScreen ? 10 : 12, color: textColor }
},
grid: { rows: 2, columns: 1, pattern: 'independent', roworder: 'top to bottom', ygap: isSmallScreen ? 0.35 : 0.35 },
font: { color: textColor, family: 'Inter, sans-serif' },
annotations: [
{ text: 'Magnitude Response (dB)', xref: 'x domain', yref: 'y domain', x: 0.5, y: isSmallScreen ? 1.22 : 1.18, 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.22 : 1.18, showarrow: false, font: { size: isSmallScreen ? 14 : 16, color: titleColor, weight: 'bold' } },
{ text: 'Magnitude Response (dB)', xref: 'x domain', yref: 'y domain', x: 0.5, y: isSmallScreen ? 1.25 : 1.2, 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.2, showarrow: false, font: { size: isSmallScreen ? 14 : 16, color: titleColor, weight: 'bold' } },
...annotations
],
xaxis: { ...xAxisCommon },
@@ -298,10 +478,13 @@ createApp({
shapes: shapes
};
const traceMag = { x: freq, y: mag, name: 'Magnitude (dB)', type: 'scatter', line: { color: isDark ? '#3b82f6' : '#2563eb', width: 2.5 } };
const tracePhase = { x: freq, y: phase, name: 'Phase (deg)', type: 'scatter', line: { color: isDark ? '#f97316' : '#ea580c', width: 2.5 }, xaxis: 'x2', yaxis: 'y2' };
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', showlegend: false };
const tracePhaseFixed = { x: freq, y: phaseFixed, name: 'Fixed Phase', type: 'scatter', line: { color: isDark ? '#f43f5e' : '#e11d48', width: 2, dash: 'dot' }, xaxis: 'x2', yaxis: 'y2', showlegend: false };
Plotly.react('bodePlot', [traceMag, tracePhase], layout, { responsive: true });
Plotly.react('bodePlot', [traceMagIdeal, traceMagFixed, tracePhaseIdeal, tracePhaseFixed], layout, { responsive: true });
},
handleFileUpload(event) {
@@ -338,9 +521,8 @@ createApp({
this.loadingFilter = true;
this.globalError = null;
const formData = new FormData();
const final_b_str = this.parseCoeffs(this.b_str).map(val => val * this.linearSystemGain).join(', ');
formData.append('file', this.csvFile);
formData.append('b', final_b_str);
formData.append('b', this.b_str);
formData.append('a', this.a_str);
formData.append('col_idx', this.selectedColumn);
try {
@@ -378,9 +560,8 @@ createApp({
async downloadCsv() {
if (!this.csvFile) return;
const formData = new FormData();
const final_b_str = this.parseCoeffs(this.b_str).map(val => val * this.linearSystemGain).join(', ');
formData.append('file', this.csvFile);
formData.append('b', final_b_str);
formData.append('b', this.b_str);
formData.append('a', this.a_str);
formData.append('col_idx', this.selectedColumn);
try {
+91 -53
View File
@@ -82,17 +82,21 @@
<!-- 濾波器設計工具 -->
<section>
<h2
class="text-sm font-semibold text-slate-500 dark:text-gray-400 uppercase tracking-wider mb-3 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.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>
濾波器設計
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="applyFilterDesign"
<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>
@@ -230,12 +234,12 @@
<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" @change="onFsChange"
<input type="number" v-model.number="fs" @input="debouncedApply"
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>系統整體增益 (System Gain)</span><span class="text-blue-600 dark:text-blue-400">{{ systemGainDB }} dB (線性倍率: {{ linearSystemGain.toFixed(4) }}x)</span></label>
<input type="number" v-model.number="systemGainDB" @change="updateBodePlot(); if(filterDone) processFilter();" step="1"
<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"
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>
@@ -248,8 +252,13 @@
<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>
<button @click="updateFromText"
class="text-xs font-semibold bg-slate-200 dark:bg-gray-800 hover:bg-slate-300 dark:hover:bg-gray-700 px-3 py-1.5 rounded text-slate-600 dark:text-gray-300 transition-colors shadow-sm">套用</button>
<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>
@@ -260,7 +269,7 @@
<span class="text-[10px] font-mono text-blue-800 dark:text-blue-300">{{ item.val }}</span>
</div>
</div>
<textarea v-model="b_str" @change="updateFromText" rows="2"
<textarea v-model="b_str" @input="debouncedUpdateFromText('b')" 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 係數倍率微調 -->
@@ -281,9 +290,11 @@
</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 block mt-1">b{{ i-1 }} (基準:
{{ baseB[i-1].toPrecision(6) }})</label>
<input type="range" v-model.number="bSliders[i-1]" @input="updateFromSliders"
<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>
@@ -298,7 +309,7 @@
<span class="text-[10px] font-mono text-orange-800 dark:text-orange-300">{{ item.val }}</span>
</div>
</div>
<textarea v-model="a_str" @change="updateFromText" rows="2"
<textarea v-model="a_str" @input="debouncedUpdateFromText('a')" 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 係數倍率微調 -->
@@ -319,49 +330,76 @@
</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 block mt-1">a{{ i }} (基準: {{
baseA[i].toPrecision(6) }})</label>
<input type="range" v-model.number="aSliders[i]" @input="updateFromSliders"
<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>
<!-- 定點數轉換 -->
<div class="mt-5 pt-5 border-t border-slate-200 dark:border-gray-800">
<h3 class="text-sm font-semibold text-slate-500 dark:text-gray-400 uppercase tracking-wider mb-3 flex justify-between items-center">
</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>
</h3>
<div class="mb-3">
<label class="text-xs 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" class="w-20 bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-2 text-sm outline-none text-slate-900 dark:text-gray-100">
<span class="text-[10px] text-slate-400">Scaling: 2^{{ shiftBits }} ({{ Math.pow(2, shiftBits) }})</span>
</div>
</div>
<div class="space-y-3">
<div>
<label class="text-[10px] text-slate-500 dark:text-gray-400 mb-1 block uppercase font-bold tracking-tighter">b (Integers)</label>
<div class="bg-slate-100 dark:bg-gray-900/80 p-2 rounded-lg border border-slate-200 dark:border-gray-800 shadow-inner flex flex-wrap gap-2">
<div v-for="item in fixedPointCoeffs.b" :key="item.label" class="bg-blue-50 dark:bg-blue-900/20 px-2 py-1 rounded border border-blue-100 dark:border-blue-800/30 flex gap-1">
<span class="text-[10px] text-blue-400 font-bold">{{ item.label }}:</span>
<span class="text-xs font-mono text-blue-700 dark:text-blue-300">{{ item.val }}</span>
</div>
</div>
</div>
<div>
<label class="text-[10px] text-slate-500 dark:text-gray-400 mb-1 block uppercase font-bold tracking-tighter">a (Integers)</label>
<div class="bg-slate-100 dark:bg-gray-900/80 p-2 rounded-lg border border-slate-200 dark:border-gray-800 shadow-inner flex flex-wrap gap-2">
<div v-for="item in fixedPointCoeffs.a" :key="item.label" class="bg-orange-50 dark:bg-orange-900/20 px-2 py-1 rounded border border-orange-100 dark:border-orange-800/30 flex gap-1">
<span class="text-[10px] text-orange-400 font-bold">{{ item.label }}:</span>
<span class="text-xs font-mono text-orange-700 dark:text-orange-300">{{ item.val }}</span>
</div>
</div>
</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>
<p class="text-[10px] text-slate-400 dark:text-gray-500 mt-2 italic">註:此數值為原始係數乘以 2^{{ shiftBits }} 後四捨五入之結果,可用於定點數 DSP 實作。</p>
</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"
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" 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" 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>