2026-05-14 17:28:33 +08:00
const DEFAULT_FILTER_TYPE = "Lowpass (低通)" ;
2026-05-14 17:05:12 +08:00
const MANUAL_FILTER_TYPE = "(無) 手動自訂" ;
const DEFAULT_FS = 100000.0 ;
const DEFAULT_B_STR = "0.5, 0.5, 0.0, 0.0" ;
const DEFAULT_A_STR = "1.0, 0.0, 0.0, 0.0" ;
const DEFAULT_SHIFT_BITS = 14 ;
const ZERO_7 = [ 0 , 0 , 0 , 0 , 0 , 0 , 0 ];
const DEFAULT_PARAMS = {
fc : 1000.0 , order : 1 ,
bp_f_low : 500.0 , bp_f_high : 2000.0 , bp_order : 1 ,
notch_f0 : 120.0 , notch_q : 1.0 ,
opoz_fz : 15000.0 , opoz_fp : 10.0 ,
tp1z_fz : 200.0 , tp1z_fp1 : 10.0 , tp1z_fp2 : 5000.0 ,
tptz_fz1 : 200.0 , tptz_fz2 : 25000.0 , tptz_fp1 : 10.0 , tptz_fp2 : 5000.0 ,
kp : 0.003 , ki : 10.0 , kd : 0.000016 ,
sogi_f0 : 60.0 , sogi_k : 1.414 ,
};
const cloneDefaultParams = () => ({ ... DEFAULT_PARAMS });
const zero7 = () => [... ZERO_7 ];
export default {
data () {
return {
fs : DEFAULT_FS ,
b_str : DEFAULT_B_STR ,
a_str : DEFAULT_A_STR ,
filterType : DEFAULT_FILTER_TYPE ,
filterOptions : [
"Lowpass (低通)" , "Highpass (高通)" , "Bandpass (帶通)" ,
"Notch (陷波器)" , "1P1Z (一極一零)" , "2P1Z (二極一零)" , "2P2Z (二極二零)" ,
"PID 控制器" , "SOGI-Alpha (帶通)" , "SOGI-Beta (低通)" , "(無) 手動自訂"
],
systemGain : 1.0 ,
params : cloneDefaultParams (),
// 模式切換
2026-05-14 13:59:45 +08:00
isDarkMode : true ,
2026-05-14 17:05:12 +08:00
// 係數倍率微調
baseB : [ 0.5 , 0.5 , 0 , 0 , 0 , 0 , 0 ],
baseA : [ 1 , 0 , 0 , 0 , 0 , 0 , 0 ],
bSliders : zero7 (),
aSliders : zero7 (), // a1~a6 (index 0 unused for display but maps to a[1]~a[6])
sense_b : '2x' , sense_a : '2x' ,
loadingBode : false , bodeTimeout : null , globalError : null ,
csvFile : null , csvColumns : [], csvPreview : [], csvInfo : null , csvParseError : null ,
selectedColumn : 0 , loadingFilter : false , filterDone : false , timePlotData : null ,
mobileTab : 'settings' , // 手機版頁籤:'settings' | 'chart'
shiftBitsB : DEFAULT_SHIFT_BITS ,
shiftBitsA : DEFAULT_SHIFT_BITS ,
fixedOverrides : { a : {}, b : {} },
outOfRangeB : [ false , false , false , false , false , false , false ],
outOfRangeA : [ false , false , false , false , false , false , false ],
aFineStep : 0.01 ,
aFineRepeatDelayTimer : null ,
aFineRepeatTimer : null ,
aFineDrag : null ,
shiftBitRepeatDelayTimer : null ,
shiftBitRepeatTimer : null ,
shiftBitDrag : null ,
fixedAFineStep : 1 ,
fixedAFineDrag : null ,
fixedAFineRepeatDelayTimer : null ,
2026-05-14 15:37:01 +08:00
fixedAFineRepeatTimer : null ,
isTouchInput : false ,
activeCoeffAdjustment : null ,
2026-05-14 17:28:33 +08:00
mcuSerialPort : null ,
mcuConnected : false ,
webSerialSupported : false ,
2026-05-14 15:37:01 +08:00
writingMCU : false ,
mcuStatus : '' ,
}
},
2026-05-14 17:05:12 +08:00
computed : {
maxLogB () {
if ( this . sense_b === '1%' ) return Math . log10 ( 1.01 );
if ( this . sense_b === '2x' ) return Math . log10 ( 2.0 );
return 1.0 ;
},
maxLogA () {
if ( this . sense_a === '1%' ) return Math . log10 ( 1.01 );
if ( this . sense_a === '2x' ) return Math . log10 ( 2.0 );
if ( this . sense_a === '10x' ) return Math . log10 ( 10.0 );
return Math . log10 ( 2.0 );
},
b_int_str : {
get () {
return this . fixedPointCoeffs . b . map ( item => item . val ). join ( ', ' );
},
set ( val ) {
const cleaned = val . replace ( /[^0-9,\-\s]/g , '' );
const nums = this . parseCoeffs ( cleaned );
nums . forEach (( n , i ) => {
this . fixedOverrides . b [ i ] = Math . round ( n );
});
this . debouncedUpdateBode ();
}
},
a_int_str : {
get () {
return this . fixedPointCoeffs . a . map ( item => item . val ). join ( ', ' );
},
set ( val ) {
const cleaned = val . replace ( /[^0-9,\-\s]/g , '' );
const nums = this . parseCoeffs ( cleaned );
nums . forEach (( n , i ) => {
this . fixedOverrides . a [ i ] = Math . round ( n );
});
this . debouncedUpdateBode ();
}
},
fixedPointCoeffs () {
const scaleB = Math . pow ( 2 , this . shiftBitsB );
const scaleA = Math . pow ( 2 , this . shiftBitsA );
const b_raw = this . parseCoeffs ( this . b_str );
const a_raw = this . parseCoeffs ( this . a_str );
const b = b_raw . map (( x , i ) => {
const autoVal = Math . round ( x * scaleB );
const val = ( this . fixedOverrides . b [ i ] !== undefined ) ? this . fixedOverrides . b [ i ] : autoVal ;
return { label : `b ${ i } ` , val , isOverridden : this . fixedOverrides . b [ i ] !== undefined };
});
const a = a_raw . map (( x , i ) => {
const autoVal = Math . round ( x * scaleA );
const val = ( this . fixedOverrides . a [ i ] !== undefined ) ? this . fixedOverrides . a [ i ] : autoVal ;
return { label : `a ${ i } ` , val , isOverridden : this . fixedOverrides . a [ i ] !== undefined };
});
return { b , a };
},
floatingCoeffs () {
const b = this . parseCoeffs ( this . b_str ). map (( x , i ) => ({ label : `b ${ i } ` , val : parseFloat ( x . toPrecision ( 6 )) }));
const a = this . parseCoeffs ( this . a_str ). map (( x , i ) => ({ label : `a ${ i } ` , val : parseFloat ( x . toPrecision ( 6 )) }));
return { b , a };
},
isManualDirty () {
if ( this . bSliders . some ( v => v !== 0 ) || this . aSliders . some ( v => v !== 0 )) return true ;
const currentB = this . padTo7 ( this . parseCoeffs ( this . b_str ));
const currentA = this . padTo7 ( this . parseCoeffs ( this . a_str ));
const targetB = this . baseB . map ( x => x * ( this . systemGain || 1.0 ));
const targetA = this . baseA ;
for ( let i = 0 ; i < 7 ; i ++ ) {
const bVal = currentB [ i ] || 0 ;
const bTarget = targetB [ i ] || 0 ;
// 使用相對誤差,避免數值較大時 (如 K > 10000) 產生的精確度誤判
const epsB = Math . max ( 1e-8 , Math . abs ( bTarget ) * 1e-10 );
if ( Math . abs ( bVal - bTarget ) > epsB ) return true ;
const aVal = currentA [ i ] || 0 ;
const aTarget = targetA [ i ] || 0 ;
const epsA = Math . max ( 1e-8 , Math . abs ( aTarget ) * 1e-10 );
if ( Math . abs ( aVal - aTarget ) > epsA ) return true ;
}
return false ;
},
isFixedPointDirty () {
return this . shiftBitsB !== DEFAULT_SHIFT_BITS
|| this . shiftBitsA !== DEFAULT_SHIFT_BITS
|| Object . keys ( this . fixedOverrides . b ). length > 0
|| Object . keys ( this . fixedOverrides . a ). length > 0 ;
},
currentA1 () {
const a = this . parseCoeffs ( this . a_str );
return a [ 1 ] || 0 ;
},
currentA2 () {
const a = this . parseCoeffs ( this . a_str );
return a [ 2 ] || 0 ;
},
currentDelta () {
return ( this . currentA1 + this . currentA2 + 1 ) / 2 ;
},
currentR () {
return ( this . currentA2 - this . currentA1 - 1 ) / 2 ;
},
currentA1A2Diff () {
return this . currentA1 - this . currentA2 ;
},
currentA1A2Sum () {
return this . currentA1 + this . currentA2 ;
},
fixedA0Int () {
return Math . pow ( 2 , this . shiftBitsA );
},
fixedCurrentA1 () {
return this . fixedPointCoeffs . a [ 1 ] ? . val || 0 ;
},
fixedCurrentA2 () {
return this . fixedPointCoeffs . a [ 2 ] ? . val || 0 ;
},
fixedCurrentDelta () {
return ( this . fixedCurrentA1 + this . fixedCurrentA2 + this . fixedA0Int ) / 2 ;
},
fixedCurrentR () {
return ( this . fixedCurrentA2 - this . fixedCurrentA1 - this . fixedA0Int ) / 2 ;
},
fixedCurrentA1A2Diff () {
return this . fixedCurrentA1 - this . fixedCurrentA2 ;
},
fixedCurrentA1A2Sum () {
return this . fixedCurrentA1 + this . fixedCurrentA2 ;
}
},
mounted () {
this . isTouchInput = window . matchMedia ? .( '(pointer: coarse)' ). matches || false ;
window . addEventListener ( 'pointerdown' , this . rememberPointerType );
const savedTheme = localStorage . getItem ( 'theme' );
2026-05-14 13:59:45 +08:00
this . isDarkMode = savedTheme ? savedTheme === 'dark' : true ;
2026-05-14 15:37:01 +08:00
if ( this . isDarkMode ) {
document . documentElement . classList . add ( 'dark' );
} else {
document . documentElement . classList . remove ( 'dark' );
}
2026-05-14 17:28:33 +08:00
this . webSerialSupported = ( 'serial' in navigator );
if ( ! this . webSerialSupported ) {
this . mcuStatus = '此瀏覽器不支援 Web Serial(需使用 Chrome 或 Edge,且透過 HTTPS 連線)' ;
}
2026-05-14 15:37:01 +08:00
this . applyFilterDesign ();
},
2026-05-14 17:05:12 +08:00
beforeUnmount () {
window . removeEventListener ( 'pointerdown' , this . rememberPointerType );
this . stopA1A2Repeat ();
this . stopAFineDrag ();
this . stopShiftBitRepeat ();
this . stopShiftBitDrag ();
this . stopFixedA1A2Repeat ();
this . stopFixedAFineDrag ();
},
methods : {
rememberPointerType ( event ) {
if ( event . pointerType === 'touch' ) {
this . isTouchInput = true ;
} else if ( event . pointerType === 'mouse' || event . pointerType === 'pen' ) {
this . isTouchInput = false ;
}
},
toggleDarkMode () {
this . isDarkMode = ! this . isDarkMode ;
localStorage . setItem ( 'theme' , this . isDarkMode ? 'dark' : 'light' );
if ( this . isDarkMode ) {
document . documentElement . classList . add ( 'dark' );
} else {
document . documentElement . classList . remove ( 'dark' );
}
this . updateBodePlot ({ switchToChart : false });
if ( this . timePlotData ) {
this . drawTimePlot ( this . timePlotData );
}
},
formatK ( val ) {
if ( val >= 1000000 ) return `(~ ${ ( val / 1000000 ). toFixed ( 1 ). replace ( '.0' , '' ) } M)` ;
if ( val >= 1000 ) return `(~ ${ ( val / 1000 ). toFixed ( 1 ). replace ( '.0' , '' ) } k)` ;
return "" ;
},
parseCoeffs ( str ) {
if ( ! str ) return [];
// 支援逗號、空格、換行等分隔符號
return str . split ( /[,\s]+/ ). map ( x => parseFloat ( x . trim ())). filter ( x => ! isNaN ( x ));
},
padTo7 ( arr ) {
const r = arr . slice ( 0 , 7 );
while ( r . length < 7 ) r . push ( 0 );
return r ;
},
syncBaseFromText () {
const gain = this . systemGain || 1.0 ;
this . baseB = this . padTo7 ( this . parseCoeffs ( this . b_str )). map ( x => x / gain );
this . baseA = this . padTo7 ( this . parseCoeffs ( this . a_str ));
this . baseA [ 0 ] = 1.0 ;
this . fixedOverrides = { a : {}, b : {} };
this . bSliders = [ 0 , 0 , 0 , 0 , 0 , 0 , 0 ];
this . aSliders = [ 0 , 0 , 0 , 0 , 0 , 0 , 0 ];
this . outOfRangeB = [ false , false , false , false , false , false , false ];
this . outOfRangeA = [ false , false , false , false , false , false , false ];
},
resetSlidersB () {
this . bSliders = [ 0 , 0 , 0 , 0 , 0 , 0 , 0 ];
this . b_str = this . baseB . map ( x => parseFloat ( x . toPrecision ( 10 ))). join ( ', ' );
this . debouncedUpdateBode ();
},
resetSlidersA () {
this . aSliders = [ 0 , 0 , 0 , 0 , 0 , 0 ];
this . a_str = this . baseA . map ( x => parseFloat ( x . toPrecision ( 10 ))). join ( ', ' );
this . debouncedUpdateBode ();
},
clearManualAdjustments () {
this . bSliders = [ 0 , 0 , 0 , 0 , 0 , 0 , 0 ];
this . aSliders = [ 0 , 0 , 0 , 0 , 0 , 0 , 0 ];
const gain = this . systemGain || 1.0 ;
this . b_str = this . baseB . map ( x => parseFloat (( x * gain ). toPrecision ( 10 ))). join ( ', ' );
this . a_str = this . baseA . map ( x => parseFloat ( x . toPrecision ( 10 ))). join ( ', ' );
this . activeCoeffAdjustment = null ;
this . calcSlidersFromText ();
this . updateBodePlot ({ switchToChart : false });
},
updateFromControls ({ updateBode = true } = {}) {
const currentB = this . baseB . map (( base , i ) => base * ( this . systemGain || 1.0 ) * Math . pow ( 10 , this . bSliders [ i ] || 0 ));
const currentA = [ 1.0 ];
for ( let i = 1 ; i < 7 ; i ++ ) {
currentA . push ( this . baseA [ i ] * Math . pow ( 10 , this . aSliders [ i ] || 0 ));
}
this . b_str = currentB . map ( x => parseFloat ( x . toPrecision ( 10 ))). join ( ', ' );
this . a_str = currentA . map ( x => parseFloat ( x . toPrecision ( 10 ))). join ( ', ' );
if ( updateBode ) this . debouncedUpdateBode ();
},
stepDecimals ( step ) {
const text = String ( step );
if ( text . includes ( 'e-' )) return Number ( text . split ( 'e-' )[ 1 ]);
return text . includes ( '.' ) ? text . split ( '.' )[ 1 ]. length : 0 ;
},
roundToStep ( value , step = this . aFineStep ) {
const scale = 1 / step ;
return Math . round ( value * scale ) / scale ;
},
formatFineCoeff ( value ) {
if ( ! Number . isFinite ( value )) return '0' ;
return Number ( value ). toString ();
},
formatAFineDelta ( step = this . aFineStep ) {
return this . roundToStep ( step , step ). toFixed ( this . stepDecimals ( step ));
},
formatFixedFineCoeff ( value ) {
if ( ! Number . isFinite ( value )) return '0' ;
return Number . isInteger ( value ) ? String ( value ) : String ( value );
},
formatFixedAFineDelta ( step = this . fixedAFineStep ) {
return String ( step );
},
adjustAFineStep ( direction ) {
const nextStep = this . aFineStep * ( direction > 0 ? 0.1 : 10 );
const clampedStep = Math . min ( 1 , Math . max ( 1e-9 , nextStep ));
this . aFineStep = Number ( clampedStep . toPrecision ( 12 ));
},
adjustFixedAFineStep ( direction ) {
const nextStep = this . fixedAFineStep * ( direction > 0 ? 0.1 : 10 );
this . fixedAFineStep = Math . max ( 1 , Math . min ( 1000000000 , Math . round ( nextStep )));
},
setDeltaR ( delta , r ) {
const nextDelta = Number ( delta );
const nextR = Number ( r );
if ( ! Number . isFinite ( nextDelta ) || ! Number . isFinite ( nextR )) return ;
this . setA1A2 ( - 1 - nextR + nextDelta , nextR + nextDelta );
},
setAFineDirect ( target , rawValue ) {
const nextValue = parseFloat ( rawValue );
if ( ! Number . isFinite ( nextValue )) return ;
if ( target === 'delta' ) {
this . setDeltaR ( nextValue , this . currentR );
} else if ( target === 'r' ) {
this . setDeltaR ( this . currentDelta , nextValue );
}
},
adjustAFineValue ( target , steps ) {
const delta = steps * this . aFineStep ;
if ( target === 'delta' ) {
this . setDeltaR ( this . currentDelta + delta , this . currentR );
} else if ( target === 'r' ) {
this . setDeltaR ( this . currentDelta , this . currentR + delta );
}
},
handleAFineWheel ( target , event ) {
if ( ! event . shiftKey ) {
event . currentTarget ? . blur ? .();
return ;
}
event . preventDefault ();
const direction = event . deltaY < 0 ? 1 : - 1 ;
const steps = Math . max ( 1 , Math . round ( Math . abs ( event . deltaY ) / 80 ));
this . adjustAFineValue ( target , direction * steps );
},
startAFineDrag ( target , event ) {
if ( event . pointerType !== 'touch' ) return ;
event . preventDefault ();
this . aFineDrag = { target , lastY : event . clientY , remainder : 0 };
window . addEventListener ( 'pointermove' , this . moveAFineDrag , { passive : false });
window . addEventListener ( 'pointerup' , this . stopAFineDrag );
window . addEventListener ( 'pointercancel' , this . stopAFineDrag );
},
moveAFineDrag ( event ) {
if ( ! this . aFineDrag ) return ;
event . preventDefault ();
const delta = this . aFineDrag . lastY - event . clientY ;
this . aFineDrag . lastY = event . clientY ;
this . aFineDrag . remainder += delta ;
const steps = Math . trunc ( this . aFineDrag . remainder / 18 );
if ( steps === 0 ) return ;
this . aFineDrag . remainder -= steps * 18 ;
this . adjustAFineValue ( this . aFineDrag . target , steps );
},
stopAFineDrag () {
this . aFineDrag = null ;
window . removeEventListener ( 'pointermove' , this . moveAFineDrag );
window . removeEventListener ( 'pointerup' , this . stopAFineDrag );
window . removeEventListener ( 'pointercancel' , this . stopAFineDrag );
},
setFixedDeltaR ( delta , r ) {
const nextDelta = Number ( delta );
const nextR = Number ( r );
if ( ! Number . isFinite ( nextDelta ) || ! Number . isFinite ( nextR )) return ;
this . fixedOverrides . a [ 1 ] = Math . round ( - this . fixedA0Int - nextR + nextDelta );
this . fixedOverrides . a [ 2 ] = Math . round ( nextR + nextDelta );
this . debouncedUpdateBode ();
},
setFixedAFineDirect ( target , rawValue ) {
const nextValue = parseFloat ( rawValue );
if ( ! Number . isFinite ( nextValue )) return ;
if ( target === 'delta' ) {
this . setFixedDeltaR ( nextValue , this . fixedCurrentR );
} else if ( target === 'r' ) {
this . setFixedDeltaR ( this . fixedCurrentDelta , nextValue );
}
},
adjustFixedAFineValue ( target , steps ) {
const delta = steps * this . fixedAFineStep ;
if ( target === 'delta' ) {
this . setFixedDeltaR ( this . fixedCurrentDelta + delta , this . fixedCurrentR );
} else if ( target === 'r' ) {
this . setFixedDeltaR ( this . fixedCurrentDelta , this . fixedCurrentR + delta );
}
},
handleFixedAFineWheel ( target , event ) {
if ( ! event . shiftKey ) {
event . currentTarget ? . blur ? .();
return ;
}
event . preventDefault ();
const direction = event . deltaY < 0 ? 1 : - 1 ;
const steps = Math . max ( 1 , Math . round ( Math . abs ( event . deltaY ) / 80 ));
this . adjustFixedAFineValue ( target , direction * steps );
},
startFixedA1A2Repeat ( target , direction ) {
this . stopFixedA1A2Repeat ();
this . adjustFixedAFineValue ( target , direction );
this . fixedAFineRepeatDelayTimer = setTimeout (() => {
this . fixedAFineRepeatTimer = setInterval (() => {
this . adjustFixedAFineValue ( target , direction );
}, 80 );
}, 350 );
},
stopFixedA1A2Repeat () {
clearTimeout ( this . fixedAFineRepeatDelayTimer );
clearInterval ( this . fixedAFineRepeatTimer );
this . fixedAFineRepeatDelayTimer = null ;
this . fixedAFineRepeatTimer = null ;
},
startFixedAFineDrag ( target , event ) {
if ( event . pointerType !== 'touch' ) return ;
event . preventDefault ();
this . fixedAFineDrag = { target , lastY : event . clientY , remainder : 0 };
window . addEventListener ( 'pointermove' , this . moveFixedAFineDrag , { passive : false });
window . addEventListener ( 'pointerup' , this . stopFixedAFineDrag );
window . addEventListener ( 'pointercancel' , this . stopFixedAFineDrag );
},
moveFixedAFineDrag ( event ) {
if ( ! this . fixedAFineDrag ) return ;
event . preventDefault ();
const delta = this . fixedAFineDrag . lastY - event . clientY ;
this . fixedAFineDrag . lastY = event . clientY ;
this . fixedAFineDrag . remainder += delta ;
const steps = Math . trunc ( this . fixedAFineDrag . remainder / 18 );
if ( steps === 0 ) return ;
this . fixedAFineDrag . remainder -= steps * 18 ;
this . adjustFixedAFineValue ( this . fixedAFineDrag . target , steps );
},
stopFixedAFineDrag () {
this . fixedAFineDrag = null ;
window . removeEventListener ( 'pointermove' , this . moveFixedAFineDrag );
window . removeEventListener ( 'pointerup' , this . stopFixedAFineDrag );
window . removeEventListener ( 'pointercancel' , this . stopFixedAFineDrag );
},
normalizeShiftBits ( rawValue ) {
const nextValue = parseInt ( rawValue , 10 );
if ( ! Number . isFinite ( nextValue )) return 0 ;
return Math . max ( 0 , nextValue );
},
setShiftBits ( type , rawValue ) {
const nextValue = this . normalizeShiftBits ( rawValue );
if ( type === 'b' ) {
this . shiftBitsB = nextValue ;
} else {
this . shiftBitsA = nextValue ;
}
this . clearFixedOverrides ( type );
this . debouncedUpdateBode ();
},
adjustShiftBits ( type , steps ) {
const current = type === 'b' ? this . shiftBitsB : this . shiftBitsA ;
this . setShiftBits ( type , current + steps );
},
handleShiftBitWheel ( type , event ) {
if ( ! event . shiftKey ) {
event . currentTarget ? . blur ? .();
return ;
}
event . preventDefault ();
const direction = event . deltaY < 0 ? 1 : - 1 ;
const steps = Math . max ( 1 , Math . round ( Math . abs ( event . deltaY ) / 80 ));
this . adjustShiftBits ( type , direction * steps );
},
startShiftBitRepeat ( type , direction ) {
this . stopShiftBitRepeat ();
this . adjustShiftBits ( type , direction );
this . shiftBitRepeatDelayTimer = setTimeout (() => {
this . shiftBitRepeatTimer = setInterval (() => {
this . adjustShiftBits ( type , direction );
}, 80 );
}, 350 );
},
stopShiftBitRepeat () {
clearTimeout ( this . shiftBitRepeatDelayTimer );
clearInterval ( this . shiftBitRepeatTimer );
this . shiftBitRepeatDelayTimer = null ;
this . shiftBitRepeatTimer = null ;
},
startShiftBitDrag ( type , event ) {
if ( event . pointerType !== 'touch' ) return ;
event . preventDefault ();
this . shiftBitDrag = { type , lastY : event . clientY , remainder : 0 };
window . addEventListener ( 'pointermove' , this . moveShiftBitDrag , { passive : false });
window . addEventListener ( 'pointerup' , this . stopShiftBitDrag );
window . addEventListener ( 'pointercancel' , this . stopShiftBitDrag );
},
moveShiftBitDrag ( event ) {
if ( ! this . shiftBitDrag ) return ;
event . preventDefault ();
const delta = this . shiftBitDrag . lastY - event . clientY ;
this . shiftBitDrag . lastY = event . clientY ;
this . shiftBitDrag . remainder += delta ;
const steps = Math . trunc ( this . shiftBitDrag . remainder / 18 );
if ( steps === 0 ) return ;
this . shiftBitDrag . remainder -= steps * 18 ;
this . adjustShiftBits ( this . shiftBitDrag . type , steps );
},
stopShiftBitDrag () {
this . shiftBitDrag = null ;
window . removeEventListener ( 'pointermove' , this . moveShiftBitDrag );
window . removeEventListener ( 'pointerup' , this . stopShiftBitDrag );
window . removeEventListener ( 'pointercancel' , this . stopShiftBitDrag );
},
setA1A2 ( a1 , a2 ) {
const a = this . padTo7 ( this . parseCoeffs ( this . a_str ));
a [ 0 ] = 1.0 ;
a [ 1 ] = Number ( a1 );
a [ 2 ] = Number ( a2 );
this . a_str = a . map ( x => Number . isFinite ( x ) ? Number ( x ). toString () : '0' ). join ( ', ' );
this . calcSlidersFromText ();
this . debouncedUpdateBode ();
},
adjustA1A2 ( mode , direction ) {
const step = this . aFineStep * direction ;
if ( mode === 'delta' ) {
this . setDeltaR ( this . currentDelta + step , this . currentR );
} else if ( mode === 'r' ) {
this . setDeltaR ( this . currentDelta , this . currentR + step );
}
2026-05-14 15:37:01 +08:00
},
startA1A2Repeat ( mode , direction ) {
this . stopA1A2Repeat ();
this . adjustAFineValue ( mode , direction );
this . aFineRepeatDelayTimer = setTimeout (() => {
this . aFineRepeatTimer = setInterval (() => {
this . adjustAFineValue ( mode , direction );
}, 80 );
}, 350 );
},
2026-05-14 17:05:12 +08:00
stopA1A2Repeat () {
clearTimeout ( this . aFineRepeatDelayTimer );
clearInterval ( this . aFineRepeatTimer );
this . aFineRepeatDelayTimer = null ;
this . aFineRepeatTimer = null ;
},
toggleCoeffAdjustment ( panel ) {
if ( this . activeCoeffAdjustment === panel ) {
this . activeCoeffAdjustment = null ;
return ;
}
this . activeCoeffAdjustment = panel ;
if ( panel === 'a1a2' ) {
this . calcSlidersFromText ();
}
},
calcSlidersFromText () {
const gain = this . systemGain || 1.0 ;
const currentB = this . parseCoeffs ( this . b_str );
const currentA = this . parseCoeffs ( this . a_str );
// 計算 b 係數滑桿
for ( let i = 0 ; i < 7 ; i ++ ) {
const val = currentB [ i ] || 0 ;
const base = this . baseB [ i ] || 0 ;
this . outOfRangeB [ i ] = false ;
if ( base !== 0 && val !== 0 && ( val / ( base * gain )) > 0 ) {
let logVal = Math . log10 ( val / ( base * gain ));
if ( Math . abs ( logVal ) < 1e-12 ) logVal = 0 ; // 消除浮點數微小誤差
if ( Math . abs ( logVal ) > this . maxLogB ) this . outOfRangeB [ i ] = true ;
this . bSliders [ i ] = Math . max ( - this . maxLogB , Math . min ( this . maxLogB , logVal ));
} else {
this . bSliders [ i ] = 0 ;
if ( val !== 0 && base !== 0 ) this . outOfRangeB [ i ] = true ; // 符號不同或極端
}
}
// 計算 a 係數滑桿 (a0 恆為 1, 從 a1 開始)
for ( let i = 1 ; i < 7 ; i ++ ) {
const val = currentA [ i ] || 0 ;
const base = this . baseA [ i ] || 0 ;
this . outOfRangeA [ i ] = false ;
if ( base !== 0 && val !== 0 && ( val / base ) > 0 ) {
let logVal = Math . log10 ( val / base );
if ( Math . abs ( logVal ) < 1e-12 ) logVal = 0 ; // 消除浮點數微小誤差
if ( Math . abs ( logVal ) > this . maxLogA ) this . outOfRangeA [ i ] = true ;
this . aSliders [ i ] = Math . max ( - this . maxLogA , Math . min ( this . maxLogA , logVal ));
} else {
this . aSliders [ i ] = 0 ;
if ( val !== 0 && base !== 0 ) this . outOfRangeA [ i ] = true ;
}
}
},
debouncedUpdateFromText ( type ) {
if ( type === 'b' ) this . b_str = this . b_str . replace ( /[^0-9,.\-\s]/g , '' );
if ( type === 'a' ) this . a_str = this . a_str . replace ( /[^0-9,.\-\s]/g , '' );
// 當文字改變時,反向計算滑桿位置
this . calcSlidersFromText ();
this . debouncedUpdateBode ();
},
onlyNumberKey ( e , allowDot = true ) {
// 允許的控制鍵
const allowedKeys = [ 'Backspace' , 'Delete' , 'ArrowLeft' , 'ArrowRight' , 'Tab' , 'Enter' , 'Home' , 'End' ];
if ( allowedKeys . includes ( e . key ) || e . ctrlKey || e . metaKey ) return ;
// 檢查字元是否合法
const charRegex = allowDot ? /[0-9,.\-\s]/ : /[0-9,\-\s]/ ;
if ( ! charRegex . test ( e . key )) {
e . preventDefault ();
}
},
clearFixedOverrides ( type = null ) {
if ( type === 'a' || type === 'b' ) {
this . fixedOverrides [ type ] = {};
return ;
}
this . fixedOverrides = { a : {}, b : {} };
},
resetFixedPointSettings () {
this . shiftBitsB = DEFAULT_SHIFT_BITS ;
this . shiftBitsA = DEFAULT_SHIFT_BITS ;
this . fixedAFineStep = 1 ;
this . clearFixedOverrides ();
this . updateBodePlot ({ switchToChart : false });
},
debouncedApply () {
clearTimeout ( this . bodeTimeout );
this . bodeTimeout = setTimeout (() => {
if ( this . filterType !== MANUAL_FILTER_TYPE ) this . applyFilterDesign ();
else this . updateBodePlot ({ switchToChart : false });
}, 300 );
},
debouncedUpdateBode () {
clearTimeout ( this . bodeTimeout );
this . bodeTimeout = setTimeout (() => this . updateBodePlot ({ switchToChart : false }), 150 );
},
onGainChange () {
this . updateFromControls ();
},
onFilterTypeChange () {
this . systemGain = 1.0 ;
// 這裡可以選擇是否重設所有設計參數 (fc, Q 等)
// 根據使用者要求「載入預設值」,我們呼叫重設邏輯
this . clearFixedOverrides ();
this . bSliders = zero7 ();
this . aSliders = zero7 ();
this . outOfRangeB = [ false , false , false , false , false , false , false ];
this . outOfRangeA = [ false , false , false , false , false , false , false ];
this . activeCoeffAdjustment = null ;
if ( this . filterType !== MANUAL_FILTER_TYPE ) {
this . params = cloneDefaultParams ();
}
this . applyFilterDesign ();
},
resetFilterParams () {
clearTimeout ( this . bodeTimeout );
this . fs = DEFAULT_FS ;
this . filterType = DEFAULT_FILTER_TYPE ;
this . systemGain = 1.0 ;
this . params = cloneDefaultParams ();
this . b_str = DEFAULT_B_STR ;
this . a_str = DEFAULT_A_STR ;
this . baseB = this . padTo7 ( this . parseCoeffs ( DEFAULT_B_STR ));
this . baseA = this . padTo7 ( this . parseCoeffs ( DEFAULT_A_STR ));
this . clearFixedOverrides ();
this . bSliders = zero7 ();
this . aSliders = zero7 ();
this . outOfRangeB = [ false , false , false , false , false , false , false ];
this . outOfRangeA = [ false , false , false , false , false , false , false ];
this . sense_b = '2x' ;
this . sense_a = '2x' ;
this . shiftBitsB = DEFAULT_SHIFT_BITS ;
this . shiftBitsA = DEFAULT_SHIFT_BITS ;
this . aFineStep = 0.01 ;
this . fixedAFineStep = 1 ;
this . stopA1A2Repeat ();
this . stopAFineDrag ();
this . stopFixedA1A2Repeat ();
this . stopFixedAFineDrag ();
this . stopShiftBitRepeat ();
this . stopShiftBitDrag ();
this . activeCoeffAdjustment = null ;
this . globalError = null ;
this . applyFilterDesign ();
},
async applyFilterDesign () {
if ( this . filterType === MANUAL_FILTER_TYPE ) return ;
this . globalError = null ;
try {
const payload = {
filter_type : this . filterType , fs : this . fs ,
lp_fc : this . params . fc , lp_order : this . params . order ,
hp_fc : this . params . fc , hp_order : this . params . order ,
bp_f_low : this . params . bp_f_low , bp_f_high : this . params . bp_f_high , bp_order : this . params . bp_order ,
notch_f0 : this . params . notch_f0 , notch_q : this . params . notch_q ,
opoz_fz : this . params . opoz_fz , opoz_fp : this . params . opoz_fp ,
tp1z_fz : this . params . tp1z_fz , tp1z_fp1 : this . params . tp1z_fp1 , tp1z_fp2 : this . params . tp1z_fp2 ,
tptz_fz1 : this . params . tptz_fz1 , tptz_fz2 : this . params . tptz_fz2 ,
tptz_fp1 : this . params . tptz_fp1 , tptz_fp2 : this . params . tptz_fp2 ,
pid_kp : this . params . kp , pid_ki : this . params . ki , pid_kd : this . params . kd ,
sogi_f0 : this . params . sogi_f0 , sogi_k : this . params . sogi_k
};
const res = await fetch ( '/api/design' , {
method : 'POST' , headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ( payload )
});
if ( ! res . ok ) { const err = await res . json (); throw new Error ( err . detail || '設計濾波器失敗' ); }
const data = await res . json ();
// 1. 將後端傳回的單位增益係數存入 baseB
this . baseB = this . padTo7 ( data . b );
this . baseA = this . padTo7 ( data . a );
this . baseA [ 0 ] = 1.0 ;
// 2. 重新計算含增益的文字框顯示與滑桿位置
this . bSliders = [ 0 , 0 , 0 , 0 , 0 , 0 , 0 ];
this . aSliders = [ 0 , 0 , 0 , 0 , 0 , 0 , 0 ];
this . activeCoeffAdjustment = null ;
this . updateFromControls ({ updateBode : false });
// 3. 更新圖表
await this . updateBodePlot ({ switchToChart : false });
} catch ( e ) { this . globalError = e . message ; }
},
async updateBodePlot ({ switchToChart = true } = {}) {
this . loadingBode = true ;
this . globalError = null ;
try {
let b_ideal = this . parseCoeffs ( this . b_str );
let a_ideal = this . parseCoeffs ( this . a_str );
if ( b_ideal . length === 0 ) b_ideal = [ 1.0 ];
if ( a_ideal . length === 0 ) a_ideal = [ 1.0 ];
const scaleB = Math . pow ( 2 , this . shiftBitsB );
const scaleA = Math . pow ( 2 , this . shiftBitsA );
const b_fixed = b_ideal . map (( x , i ) => {
const intVal = ( this . fixedOverrides . b [ i ] !== undefined ) ? this . fixedOverrides . b [ i ] : Math . round ( x * scaleB );
return intVal / scaleB ;
});
const a_fixed = a_ideal . map (( x , i ) => {
const intVal = ( this . fixedOverrides . a [ i ] !== undefined ) ? this . fixedOverrides . a [ i ] : Math . round ( x * scaleA );
return intVal / scaleA ;
});
const res = await fetch ( '/api/bode/compare' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({
ideal : { b : b_ideal , a : a_ideal , fs : this . fs },
fixed : { b : b_fixed , a : a_fixed , fs : this . fs }
})
});
let data ;
if ( res . status === 404 ) {
const [ resIdeal , resFixed ] = await Promise . all ([
fetch ( '/api/bode' , {
method : 'POST' , headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ b : b_ideal , a : a_ideal , fs : this . fs })
}),
fetch ( '/api/bode' , {
method : 'POST' , headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ b : b_fixed , a : a_fixed , fs : this . fs })
})
]);
if ( ! resIdeal . ok || ! resFixed . ok ) throw new Error ( '無法計算頻率響應' );
const dataIdeal = await resIdeal . json ();
const dataFixed = await resFixed . json ();
data = {
freq : dataIdeal . freq ,
ideal : { mag : dataIdeal . mag , phase : dataIdeal . phase },
fixed : { mag : dataFixed . mag , phase : dataFixed . phase }
};
} else if ( ! res . ok ) {
const err = await res . json ();
throw new Error ( err . detail || '無法計算頻率響應' );
} else {
data = await res . json ();
}
this . drawBodePlot ( data . freq , data . ideal . mag , data . ideal . phase , data . fixed . mag , data . fixed . phase );
// 手機版:計算完成後自動切換到圖表頁籤
if ( switchToChart && window . innerWidth < 1024 ) this . mobileTab = 'chart' ;
} catch ( e ) { this . globalError = e . message ; }
finally { this . loadingBode = false ; }
},
drawBodePlot ( freq , magIdeal , phaseIdeal , magFixed , phaseFixed ) {
const fs = this . fs ;
const fMin = freq [ 0 ], fMax = freq [ freq . length - 1 ];
const isDark = this . isDarkMode ;
const isSmallScreen = window . innerWidth < 768 ;
// 產生 1-2-5 刻度
const xTicks = [], xTexts = [];
const pStart = Math . floor ( Math . log10 ( fMin ));
const pEnd = Math . ceil ( Math . log10 ( fMax ));
for ( let p = pStart ; p <= pEnd ; p ++ ) {
for ( const m of [ 1 , 2 , 5 ]) {
const val = m * Math . pow ( 10 , p );
if ( val < fMin || val > fMax ) continue ;
xTicks . push ( val );
if ( val < 1 ) xTexts . push ( val . toPrecision ( 3 ));
else if ( val < 1000 ) xTexts . push ( String ( val ));
else if ( val < 1000000 ) xTexts . push (( val / 1000 ) + 'k' );
else xTexts . push (( val / 1000000 ) + 'M' );
}
}
// Nyquist & fs 標線 (shapes)
const shapes = [];
const annotations = [];
const addVLine = ( xVal , text , row ) => {
const yref = row === 1 ? 'y' : 'y2' ;
shapes . push ({
type : 'line' , x0 : xVal , x1 : xVal , y0 : 0 , y1 : 1 ,
xref : row === 1 ? 'x' : 'x2' , yref : yref + ' domain' ,
2026-05-14 13:59:45 +08:00
line : { color : isDark ? 'rgba(242,184,181,0.72)' : 'rgba(179,38,30,0.68)' , width : 1.4 , dash : 'dash' }
2026-05-14 17:05:12 +08:00
});
if ( text ) {
annotations . push ({
x : Math . log10 ( xVal ), y : 1 , text : text ,
xref : row === 1 ? 'x' : 'x2' , yref : yref + ' domain' ,
2026-05-14 13:59:45 +08:00
showarrow : false , font : { color : isDark ? '#f2b8b5' : '#b3261e' , size : isSmallScreen ? 10 : 12 },
2026-05-14 17:05:12 +08:00
yshift : isSmallScreen ? 15 : 12 ,
xanchor : 'center' ,
yanchor : isSmallScreen ? 'middle' : 'bottom' ,
textangle : isSmallScreen ? - 90 : 0
});
}
};
addVLine ( fs / 2 , 'Nyquist' , 1 );
addVLine ( fs , 'fs' , 1 );
addVLine ( fs / 2 , null , 2 );
addVLine ( fs , null , 2 );
// 10 的倍數強化線
for ( let p = pStart ; p <= pEnd ; p ++ ) {
const v = Math . pow ( 10 , p );
if ( v >= fMin && v <= fMax ) {
2026-05-14 13:59:45 +08:00
const gridColor = isDark ? 'rgba(200,206,224,0.24)' : 'rgba(86,96,117,0.16)' ;
2026-05-14 17:05:12 +08:00
shapes . push (
{ type : 'line' , x0 : v , x1 : v , y0 : 0 , y1 : 1 , xref : 'x' , yref : 'y domain' , line : { color : gridColor , width : 1.5 } },
{ type : 'line' , x0 : v , x1 : v , y0 : 0 , y1 : 1 , xref : 'x2' , yref : 'y2 domain' , line : { color : gridColor , width : 1.5 } }
);
}
}
2026-05-14 13:59:45 +08:00
const gridColor = isDark ? 'rgba(200,206,224,0.14)' : 'rgba(86,96,117,0.09)' ;
const zeroLineColor = isDark ? 'rgba(200,206,224,0.24)' : 'rgba(82,86,99,0.20)' ;
const textColor = isDark ? '#b4b4bd' : '#525663' ;
const titleColor = isDark ? '#f1f1f4' : '#17191f' ;
const plotBgColor = isDark ? '#090a0d' : '#ffffff' ;
2026-05-14 17:05:12 +08:00
const xAxisCommon = {
type : 'log' , title : { text : 'Freq (Hz)' , font : { size : isSmallScreen ? 12 : 14 } },
range : [ Math . log10 ( fMin ), Math . log10 ( fMax )],
tickvals : xTicks , ticktext : xTexts ,
showgrid : true , gridcolor : gridColor ,
tickfont : { color : textColor , size : isSmallScreen ? 11 : 12 },
nticks : isSmallScreen ? 6 : 10
};
const layout = {
paper_bgcolor : plotBgColor , plot_bgcolor : plotBgColor ,
margin : isSmallScreen ? { t : 80 , b : 120 , l : 55 , r : 15 } : { t : 70 , b : 120 , l : 70 , r : 30 },
showlegend : false ,
grid : { rows : 2 , columns : 1 , pattern : 'independent' , roworder : 'top to bottom' , ygap : isSmallScreen ? 0.35 : 0.3 },
font : { color : textColor , family : 'Google Sans, Roboto, sans-serif' },
annotations : [
2026-05-14 13:59:45 +08:00
{ text : 'Magnitude Response (dB)' , xref : 'x domain' , yref : 'y domain' , x : 0.5 , y : isSmallScreen ? 1.25 : 1.15 , showarrow : false , font : { size : isSmallScreen ? 14 : 16 , color : titleColor , weight : 'normal' } },
{ text : 'Phase Response (Deg)' , xref : 'x2 domain' , yref : 'y2 domain' , x : 0.5 , y : isSmallScreen ? 1.25 : 1.15 , showarrow : false , font : { size : isSmallScreen ? 14 : 16 , color : titleColor , weight : 'normal' } },
2026-05-14 17:05:12 +08:00
// 手動 Magnitude 圖例 (位於上方圖表下方)
2026-05-14 13:59:45 +08:00
{ xref : 'paper' , yref : 'paper' , x : 0.35 , y : isSmallScreen ? 0.52 : 0.54 , text : '一一一' , font : { color : isDark ? '#c8cee0' : '#566075' , size : 10 }, showarrow : false },
2026-05-14 17:05:12 +08:00
{ xref : 'paper' , yref : 'paper' , x : 0.42 , y : isSmallScreen ? 0.52 : 0.54 , text : 'Ideal (Float)' , font : { color : textColor , size : 11 }, showarrow : false , xanchor : 'left' },
2026-05-14 13:59:45 +08:00
{ xref : 'paper' , yref : 'paper' , x : 0.58 , y : isSmallScreen ? 0.52 : 0.54 , text : '· · · ·' , font : { color : isDark ? '#b9b2ff' : '#7a6689' , size : 14 }, showarrow : false },
2026-05-14 17:05:12 +08:00
{ xref : 'paper' , yref : 'paper' , x : 0.63 , y : isSmallScreen ? 0.52 : 0.54 , text : `Fixed (b Q ${ this . shiftBitsB } , a Q ${ this . shiftBitsA } )` , font : { color : textColor , size : 11 }, showarrow : false , xanchor : 'left' },
// 手動 Phase 圖例 (位於下方圖表下方)
2026-05-14 13:59:45 +08:00
{ xref : 'paper' , yref : 'paper' , x : 0.35 , y : - 0.15 , text : '一一一' , font : { color : isDark ? '#64d2ff' : '#2d6f8f' , size : 10 }, showarrow : false },
2026-05-14 17:05:12 +08:00
{ xref : 'paper' , yref : 'paper' , x : 0.42 , y : - 0.15 , text : 'Ideal Phase' , font : { color : textColor , size : 11 }, showarrow : false , xanchor : 'left' },
2026-05-14 13:59:45 +08:00
{ xref : 'paper' , yref : 'paper' , x : 0.58 , y : - 0.15 , text : '· · · ·' , font : { color : isDark ? '#ff9bd8' : '#96527b' , size : 14 }, showarrow : false },
2026-05-14 17:05:12 +08:00
{ xref : 'paper' , yref : 'paper' , x : 0.63 , y : - 0.15 , text : 'Fixed Phase' , font : { color : textColor , size : 11 }, showarrow : false , xanchor : 'left' },
... annotations
],
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
};
2026-05-14 13:59:45 +08:00
const traceMagIdeal = { x : freq , y : magIdeal , name : 'Ideal (Float)' , type : 'scatter' , line : { color : isDark ? '#c8cee0' : '#566075' , width : 2.8 } };
const traceMagFixed = { x : freq , y : magFixed , name : `Fixed (b Q ${ this . shiftBitsB } , a Q ${ this . shiftBitsA } )` , type : 'scatter' , line : { color : isDark ? '#b9b2ff' : '#7a6689' , width : 2.4 , dash : 'dot' } };
2026-05-12 16:49:38 +08:00
2026-05-14 13:59:45 +08:00
const tracePhaseIdeal = { x : freq , y : phaseIdeal , name : 'Ideal Phase' , type : 'scatter' , line : { color : isDark ? '#64d2ff' : '#2d6f8f' , width : 2.8 }, xaxis : 'x2' , yaxis : 'y2' };
const tracePhaseFixed = { x : freq , y : phaseFixed , name : 'Fixed Phase' , type : 'scatter' , line : { color : isDark ? '#ff9bd8' : '#96527b' , width : 2.4 , dash : 'dot' }, xaxis : 'x2' , yaxis : 'y2' };
2026-05-14 17:05:12 +08:00
Plotly . react ( 'bodePlot' , [ traceMagIdeal , traceMagFixed , tracePhaseIdeal , tracePhaseFixed ], layout , { responsive : true });
},
parseCsvText ( text , maxRows = Infinity ) {
const rows = [];
let row = [];
let field = '' ;
let inQuotes = false ;
let totalRows = 0 ;
const finishRow = () => {
row . push ( field );
if ( row . some ( value => value . trim () !== '' )) {
totalRows ++ ;
if ( rows . length < maxRows ) rows . push ( row );
}
row = [];
field = '' ;
};
for ( let i = 0 ; i < text . length ; i ++ ) {
const char = text [ i ];
const next = text [ i + 1 ];
if ( char === '"' ) {
if ( inQuotes && next === '"' ) {
field += '"' ;
i ++ ;
} else {
inQuotes = ! inQuotes ;
}
} else if ( char === ',' && ! inQuotes ) {
row . push ( field );
field = '' ;
} else if (( char === '\n' || char === '\r' ) && ! inQuotes ) {
if ( char === '\r' && next === '\n' ) i ++ ;
finishRow ();
} else {
field += char ;
}
}
if ( inQuotes ) throw new Error ( 'CSV 引號未正確結束' );
finishRow ();
return { rows , totalRows };
},
resetCsvState () {
this . csvFile = null ;
this . csvColumns = [];
this . csvPreview = [];
this . csvInfo = null ;
this . csvParseError = null ;
this . selectedColumn = 0 ;
this . filterDone = false ;
this . timePlotData = null ;
},
handleFileUpload ( event ) {
const file = event . target . files [ 0 ];
this . resetCsvState ();
if ( ! file ) return ;
const maxBytes = 32 * 1024 * 1024 ;
const fileName = file . name || '' ;
if ( ! fileName . toLowerCase (). endsWith ( '.csv' )) {
this . csvParseError = '請選擇 .csv 檔案' ;
event . target . value = '' ;
return ;
}
if ( file . size > maxBytes ) {
this . csvParseError = 'CSV 檔案不可超過 32MB' ;
event . target . value = '' ;
return ;
}
const reader = new FileReader ();
reader . onload = ( e ) => {
try {
const text = String ( e . target . result || '' ). replace ( /^\uFEFF/ , '' );
const { rows , totalRows } = this . parseCsvText ( text , 6 );
if ( totalRows < 2 || rows . length < 2 ) throw new Error ( 'CSV 需包含標題列與至少一筆資料' );
const columns = rows [ 0 ]. map (( value , index ) => {
const label = value . trim ();
return label || `Column ${ index + 1 } ` ;
});
if ( columns . length === 0 ) throw new Error ( 'CSV 標題列不可為空' );
this . csvFile = file ;
this . csvColumns = columns ;
this . csvPreview = rows . slice ( 1 , 6 ). map ( row => {
const padded = row . slice ( 0 , columns . length );
while ( padded . length < columns . length ) padded . push ( '' );
return padded . map ( value => value . trim ());
});
this . csvInfo = {
rows : Math . max ( totalRows - 1 , 0 ),
columns : columns . length ,
size : file . size ,
};
this . selectedColumn = 0 ;
if ( columns . length > 1 ) {
const first = columns [ 0 ]. toLowerCase ();
if ([ 'time' , 't' , 'sec' , 'x' , 'index' , 'unnamed' ]. some ( kw => first . includes ( kw ))) {
this . selectedColumn = 1 ;
}
}
} catch ( err ) {
this . csvParseError = err . message || 'CSV 解析失敗' ;
this . csvColumns = [];
this . csvPreview = [];
this . csvInfo = null ;
event . target . value = '' ;
}
};
reader . onerror = () => {
this . csvParseError = 'CSV 讀取失敗' ;
event . target . value = '' ;
};
reader . readAsText ( file );
},
async processFilter () {
if ( ! this . csvFile ) return ;
this . loadingFilter = true ;
this . globalError = null ;
const formData = new FormData ();
formData . append ( 'file' , this . csvFile );
formData . append ( 'b' , this . b_str );
formData . append ( 'a' , this . a_str );
formData . append ( 'col_idx' , this . selectedColumn );
try {
const res = await fetch ( '/api/filter' , { method : 'POST' , body : formData });
if ( ! res . ok ) { const err = await res . json (); throw new Error ( err . detail || '濾波處理失敗' ); }
const data = await res . json ();
this . timePlotData = data ;
this . filterDone = true ;
// 確保在可見狀態下繪圖,避免 Plotly 計算尺寸錯誤
this . $nextTick (() => {
this . drawTimePlot ( data );
});
} catch ( e ) { this . globalError = e . message ; }
finally { this . loadingFilter = false ; }
},
drawTimePlot ( data ) {
const isDark = this . isDarkMode ;
const isSmallScreen = window . innerWidth < 768 ;
2026-05-14 13:59:45 +08:00
const gridColor = isDark ? 'rgba(200,206,224,0.14)' : 'rgba(86,96,117,0.09)' ;
const zeroLineColor = isDark ? 'rgba(200,206,224,0.24)' : 'rgba(82,86,99,0.20)' ;
const textColor = isDark ? '#b4b4bd' : '#525663' ;
const plotBgColor = isDark ? '#090a0d' : '#ffffff' ;
2026-05-14 17:05:12 +08:00
const layout = {
paper_bgcolor : plotBgColor , plot_bgcolor : plotBgColor ,
margin : isSmallScreen ? { t : 60 , b : 60 , l : 45 , r : 15 } : { t : 40 , b : 55 , l : 60 , r : 20 },
font : { color : textColor , family : 'Google Sans, Roboto, sans-serif' },
xaxis : { title : { text : 'Sample Index' , font : { size : isSmallScreen ? 10 : 11 } }, gridcolor : gridColor , zerolinecolor : zeroLineColor , tickfont : { color : textColor , size : isSmallScreen ? 9 : 11 } },
yaxis : { title : { text : 'Amplitude' , font : { size : isSmallScreen ? 10 : 11 } }, gridcolor : gridColor , zerolinecolor : zeroLineColor , tickfont : { color : textColor , size : isSmallScreen ? 9 : 11 } },
legend : { orientation : 'h' , y : 1.02 , yanchor : 'bottom' , x : 1 , xanchor : 'right' , font : { color : textColor , size : isSmallScreen ? 9 : 10 } }
};
Plotly . react ( 'timePlot' , [
2026-05-14 13:59:45 +08:00
{ x : data . index , y : data . original , name : '原始輸入訊號 (Input)' , type : 'scatter' , line : { color : isDark ? '#c8cee0' : '#566075' , width : 2.4 }, opacity : 0.9 },
{ x : data . index , y : data . filtered , name : '濾波後輸出 (Output)' , type : 'scatter' , line : { color : isDark ? '#64d2ff' : '#2d6f8f' , width : 2.4 }, opacity : 0.95 }
2026-05-14 17:05:12 +08:00
], layout , { responsive : true });
},
async downloadCsv () {
if ( ! this . csvFile ) return ;
const formData = new FormData ();
formData . append ( 'file' , this . csvFile );
formData . append ( 'b' , this . b_str );
formData . append ( 'a' , this . a_str );
formData . append ( 'col_idx' , this . selectedColumn );
try {
const res = await fetch ( '/api/filter/download' , { method : 'POST' , body : formData });
if ( ! res . ok ) throw new Error ( '下載失敗' );
const blob = await res . blob ();
const url = window . URL . createObjectURL ( blob );
const a = document . createElement ( 'a' );
a . href = url ; a . download = 'filtered_output.csv' ;
document . body . appendChild ( a ); a . click ();
window . URL . revokeObjectURL ( url );
2026-05-14 15:37:01 +08:00
} catch ( e ) { this . globalError = e . message ; }
},
2026-05-14 17:28:33 +08:00
async connectMCUPort () {
if ( ! this . webSerialSupported ) return ;
2026-05-14 15:37:01 +08:00
this . mcuStatus = '' ;
try {
2026-05-14 17:28:33 +08:00
const port = await navigator . serial . requestPort ();
await port . open ({ baudRate : 115200 });
this . mcuSerialPort = port ;
this . mcuConnected = true ;
const info = port . getInfo ();
const vid = info . usbVendorId ? `VID:0x ${ info . usbVendorId . toString ( 16 ) } ` : '' ;
this . mcuStatus = `已連線 ${ vid ? ' (' + vid + ')' : '' } ` ;
} catch ( e ) {
if ( e . name === 'NotFoundError' ) {
this . mcuStatus = '未選擇連接埠' ;
} else {
this . mcuStatus = `連線失敗: ${ e . message } ` ;
2026-05-14 15:37:01 +08:00
}
2026-05-14 17:28:33 +08:00
}
},
2026-05-14 15:37:01 +08:00
2026-05-14 17:28:33 +08:00
async disconnectMCUPort () {
if ( this . mcuSerialPort ) {
try {
await this . mcuSerialPort . close ();
} catch { /* ignore close errors */ }
this . mcuSerialPort = null ;
2026-05-14 15:37:01 +08:00
}
2026-05-14 17:28:33 +08:00
this . mcuConnected = false ;
this . mcuStatus = '已斷線' ;
2026-05-14 15:37:01 +08:00
},
mcuCommandFromFixedCoeffs () {
const bVals = this . fixedPointCoeffs . b . map ( item => item . val );
const aVals = this . fixedPointCoeffs . a . map ( item => item . val );
const b0 = bVals [ 0 ] ?? 0 ;
const b1 = bVals [ 1 ] ?? 0 ;
const b2 = bVals [ 2 ] ?? 0 ;
const a1 = aVals [ 1 ] ?? 0 ;
const a2 = aVals [ 2 ] ?? 0 ;
return `bodeplot= ${ b0 } , ${ b1 } , ${ b2 } , ${ a1 } , ${ a2 } ` ;
},
async writeToMCU () {
2026-05-14 17:28:33 +08:00
if ( ! this . mcuSerialPort || ! this . mcuConnected ) {
this . mcuStatus = '請先連線 MCU 連接埠' ;
return ;
}
2026-05-14 15:37:01 +08:00
this . writingMCU = true ;
this . globalError = null ;
this . mcuStatus = '' ;
const command = this . mcuCommandFromFixedCoeffs ();
try {
2026-05-14 17:28:33 +08:00
const encoder = new TextEncoder ();
const writer = this . mcuSerialPort . writable . getWriter ();
try {
await writer . write ( encoder . encode ( command + '\r\n' ));
} finally {
writer . releaseLock ();
2026-05-14 15:37:01 +08:00
}
2026-05-14 17:28:33 +08:00
this . mcuStatus = `已送出 ${ command } ` ;
2026-05-14 15:37:01 +08:00
} catch ( e ) {
this . globalError = e . message ;
2026-05-14 17:28:33 +08:00
this . mcuStatus = `寫入失敗: ${ e . message } ` ;
2026-05-14 15:37:01 +08:00
} finally {
this . writingMCU = false ;
}
}
}
}