2026-05-27 08:38:40 +08:00
import StageEditor from './components/StageEditor.vue' ;
import { computeStageFixedCoeffs , createCascadeFilterPayload , normalizeCascadeStage } from './cascade-stage.js' ;
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 ];
2026-05-27 08:38:40 +08:00
const createStageId = () => ` ${ Date . now () } - ${ Math . random (). toString ( 36 ). slice ( 2 ) } ` ;
2026-05-14 17:05:12 +08:00
export default {
2026-05-27 08:38:40 +08:00
components : {
StageEditor ,
},
2026-05-14 17:05:12 +08:00
data () {
return {
fs : DEFAULT_FS ,
b_str : DEFAULT_B_STR ,
a_str : DEFAULT_A_STR ,
filterType : DEFAULT_FILTER_TYPE ,
2026-05-26 15:34:24 +08:00
cascadeStages : [],
activeCascadeStageIndex : 0 ,
zoomStartIdx : null , zoomEndIdx : null ,
timePlotUnit : null ,
timePlotMultiplier : null ,
isRedrawingTimePlot : false ,
2026-05-14 17:05:12 +08:00
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 ,
2026-05-17 00:58:19 +08:00
csvFile : null , csvFileId : null , csvColumns : [], csvPreview : [], csvInfo : null , csvParseError : null ,
2026-05-27 08:38:40 +08:00
csvUploading : false , presetCsvAvailable : false , presetCsvName : '' , usingPresetCsv : false ,
2026-05-17 00:58:19 +08:00
isCsvPreviewExpanded : true ,
2026-05-14 17:05:12 +08:00
selectedColumn : 0 , loadingFilter : false , filterDone : false , timePlotData : null ,
mobileTab : 'settings' , // 手機版頁籤:'settings' | 'chart'
shiftBitsB : DEFAULT_SHIFT_BITS ,
shiftBitsA : DEFAULT_SHIFT_BITS ,
2026-05-17 00:58:19 +08:00
useRound : true , // 是否使用 Rounding 取代 Floor
sense_in : '2x' , sense_out : '2x' ,
2026-05-14 17:05:12 +08:00
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 ,
2026-05-17 00:58:19 +08:00
shiftBitsIn : 14 ,
shiftBitsOut : 14 ,
shiftInDrag : null ,
shiftInRepeatDelayTimer : null ,
shiftInRepeatTimer : null ,
shiftOutDrag : null ,
shiftOutRepeatDelayTimer : null ,
shiftOutRepeatTimer : null ,
2026-05-14 15:37:01 +08:00
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-15 16:12:47 +08:00
saveSettingsTimeout : null ,
2026-05-15 17:45:45 +08:00
bodeMagRange : null , // [min, max] for Y-axis
2026-05-14 15:37:01 +08:00
}
},
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 () {
2026-05-16 18:17:13 +08:00
// New Model: a1 = -1 - r, a2 = r + delta
// => delta = a1 + a2 + 1
return this . currentA1 + this . currentA2 + 1 ;
2026-05-14 17:05:12 +08:00
},
currentR () {
2026-05-16 18:17:13 +08:00
// New Model: a1 = -1 - r => r = -1 - a1
return - 1 - this . currentA1 ;
2026-05-14 17:05:12 +08:00
},
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 () {
2026-05-16 18:17:13 +08:00
// New Model: a1 = -a0 - r, a2 = r + delta
// => delta = a1 + a2 + a0
return this . fixedCurrentA1 + this . fixedCurrentA2 + this . fixedA0Int ;
2026-05-14 17:05:12 +08:00
},
fixedCurrentR () {
2026-05-16 18:17:13 +08:00
// New Model: a1 = -a0 - r => r = -a0 - a1
return - this . fixedA0Int - this . fixedCurrentA1 ;
2026-05-14 17:05:12 +08:00
},
fixedCurrentA1A2Diff () {
return this . fixedCurrentA1 - this . fixedCurrentA2 ;
},
fixedCurrentA1A2Sum () {
return this . fixedCurrentA1 + this . fixedCurrentA2 ;
}
},
2026-05-15 16:12:47 +08:00
watch : {
fs : 'debouncedSaveSettings' ,
2026-05-20 13:44:31 +08:00
b_str () {
this . debouncedSaveSettings ();
this . debouncedProcessFilter ();
},
a_str () {
this . debouncedSaveSettings ();
this . debouncedProcessFilter ();
},
2026-05-15 16:12:47 +08:00
filterType : 'debouncedSaveSettings' ,
systemGain : 'debouncedSaveSettings' ,
params : { handler : 'debouncedSaveSettings' , deep : true },
baseB : { handler : 'debouncedSaveSettings' , deep : true },
baseA : { handler : 'debouncedSaveSettings' , deep : true },
bSliders : { handler : 'debouncedSaveSettings' , deep : true },
aSliders : { handler : 'debouncedSaveSettings' , deep : true },
sense_b : 'debouncedSaveSettings' ,
sense_a : 'debouncedSaveSettings' ,
2026-05-20 13:44:31 +08:00
shiftBitsB () {
this . debouncedSaveSettings ();
this . debouncedProcessFilter ();
},
shiftBitsA () {
this . debouncedSaveSettings ();
this . debouncedProcessFilter ();
},
shiftBitsIn () {
this . debouncedSaveSettings ();
this . debouncedProcessFilter ();
},
shiftBitsOut () {
this . debouncedSaveSettings ();
this . debouncedProcessFilter ();
},
useRound () {
this . debouncedSaveSettings ();
this . debouncedProcessFilter ();
},
2026-05-15 16:12:47 +08:00
fixedOverrides : { handler : 'debouncedSaveSettings' , deep : true },
aFineStep : 'debouncedSaveSettings' ,
2026-05-20 13:44:31 +08:00
fixedAFineStep : 'debouncedSaveSettings' ,
b_int_str () { this . debouncedProcessFilter (); },
2026-05-26 15:34:24 +08:00
a_int_str () { this . debouncedProcessFilter (); },
selectedColumn ( newVal , oldVal ) {
if ( newVal !== oldVal ) {
this . zoomStartIdx = null ;
this . zoomEndIdx = null ;
this . timePlotUnit = null ;
this . timePlotMultiplier = null ;
this . debouncedProcessFilter ();
}
}
2026-05-15 16:12:47 +08:00
},
2026-05-14 17:05:12 +08:00
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-20 13:44:31 +08:00
2026-05-15 16:12:47 +08:00
this . loadSettings ();
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-26 15:34:24 +08:00
this . ensureCascadeStages ();
2026-05-15 17:45:45 +08:00
this . updateBodeMagRange ();
2026-05-14 15:37:01 +08:00
this . applyFilterDesign ();
2026-05-27 08:38:40 +08:00
this . loadPresetWaveforms ();
2026-05-14 15:37:01 +08:00
},
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 ();
2026-05-17 00:58:19 +08:00
this . stopShiftInRepeat ();
this . stopShiftInDrag ();
this . stopShiftOutRepeat ();
this . stopShiftOutDrag ();
2026-05-14 17:05:12 +08:00
},
methods : {
2026-05-26 15:34:24 +08:00
stageTitle ( index ) {
const stage = this . cascadeStages [ index ];
return stage ? . filterType || `Stage ${ index + 1 } ` ;
},
cloneStage ( stage ) {
return JSON . parse ( JSON . stringify ( stage ));
},
captureCurrentStage () {
return {
2026-05-27 08:38:40 +08:00
id : this . cascadeStages [ this . activeCascadeStageIndex ] ? . id || createStageId (),
2026-05-26 15:34:24 +08:00
isActive : this . cascadeStages [ this . activeCascadeStageIndex ] ? . isActive ?? true ,
2026-05-27 08:38:40 +08:00
isExpanded : this . cascadeStages [ this . activeCascadeStageIndex ] ? . isExpanded ?? true ,
2026-05-26 15:34:24 +08:00
filterType : this . filterType ,
b_str : this . b_str ,
a_str : this . a_str ,
systemGain : this . systemGain ,
params : { ... this . params },
baseB : [... this . baseB ],
baseA : [... this . baseA ],
bSliders : [... this . bSliders ],
aSliders : [... this . aSliders ],
shiftBitsB : this . shiftBitsB ,
shiftBitsA : this . shiftBitsA ,
fixedOverrides : {
a : { ... this . fixedOverrides . a },
b : { ... this . fixedOverrides . b },
},
outOfRangeB : [... this . outOfRangeB ],
outOfRangeA : [... this . outOfRangeA ],
aFineStep : this . aFineStep ,
fixedAFineStep : this . fixedAFineStep ,
activeCoeffAdjustment : this . activeCoeffAdjustment ,
};
},
applyStageToControls ( stage ) {
2026-05-27 08:38:40 +08:00
stage = normalizeCascadeStage ( stage );
2026-05-26 15:34:24 +08:00
this . filterType = stage . filterType ;
this . b_str = stage . b_str ;
this . a_str = stage . a_str ;
this . systemGain = stage . systemGain ;
this . params = { ... stage . params };
this . baseB = [... stage . baseB ];
this . baseA = [... stage . baseA ];
this . bSliders = [... stage . bSliders ];
this . aSliders = [... stage . aSliders ];
this . shiftBitsB = stage . shiftBitsB ;
this . shiftBitsA = stage . shiftBitsA ;
this . fixedOverrides = {
a : { ... stage . fixedOverrides . a },
b : { ... stage . fixedOverrides . b },
};
this . outOfRangeB = [... stage . outOfRangeB ];
this . outOfRangeA = [... stage . outOfRangeA ];
this . aFineStep = stage . aFineStep ;
this . fixedAFineStep = stage . fixedAFineStep ;
this . activeCoeffAdjustment = stage . activeCoeffAdjustment ;
},
ensureCascadeStages () {
2026-05-27 08:38:40 +08:00
if ( ! Array . isArray ( this . cascadeStages )) this . cascadeStages = [];
2026-05-26 15:34:24 +08:00
if ( this . cascadeStages . length === 0 ) {
this . cascadeStages = [ this . captureCurrentStage ()];
this . activeCascadeStageIndex = 0 ;
}
2026-05-27 08:38:40 +08:00
this . cascadeStages = this . cascadeStages . map (( stage , index ) => normalizeCascadeStage ( stage , {
isExpanded : index === this . activeCascadeStageIndex ,
filterType : this . filterType ,
b_str : this . b_str ,
a_str : this . a_str ,
systemGain : this . systemGain ,
params : this . params ,
baseB : this . baseB ,
baseA : this . baseA ,
bSliders : this . bSliders ,
aSliders : this . aSliders ,
shiftBitsB : this . shiftBitsB ,
shiftBitsA : this . shiftBitsA ,
fixedOverrides : this . fixedOverrides ,
outOfRangeB : this . outOfRangeB ,
outOfRangeA : this . outOfRangeA ,
aFineStep : this . aFineStep ,
fixedAFineStep : this . fixedAFineStep ,
activeCoeffAdjustment : this . activeCoeffAdjustment ,
}));
if ( this . activeCascadeStageIndex < 0 || this . activeCascadeStageIndex >= this . cascadeStages . length ) {
this . activeCascadeStageIndex = 0 ;
}
2026-05-26 15:34:24 +08:00
},
saveActiveCascadeStage () {
this . ensureCascadeStages ();
this . cascadeStages . splice ( this . activeCascadeStageIndex , 1 , this . captureCurrentStage ());
},
selectCascadeStage ( index ) {
if ( index < 0 || index >= this . cascadeStages . length || index === this . activeCascadeStageIndex ) return ;
this . saveActiveCascadeStage ();
this . activeCascadeStageIndex = index ;
2026-05-27 08:38:40 +08:00
this . cascadeStages . forEach (( stage , stageIndex ) => {
stage . isExpanded = stageIndex === index ;
});
2026-05-26 15:34:24 +08:00
this . applyStageToControls ( this . cloneStage ( this . cascadeStages [ index ]));
this . updateBodeMagRange ();
this . updateBodePlot ({ switchToChart : false });
},
addCascadeStage () {
this . saveActiveCascadeStage ();
const newStage = this . cloneStage ( this . cascadeStages [ this . activeCascadeStageIndex ]);
2026-05-27 08:38:40 +08:00
newStage . id = createStageId ();
2026-05-26 15:34:24 +08:00
newStage . isActive = true ;
2026-05-27 08:38:40 +08:00
newStage . isExpanded = true ;
2026-05-26 15:34:24 +08:00
this . cascadeStages . push ( newStage );
this . selectCascadeStage ( this . cascadeStages . length - 1 );
},
removeCascadeStage ( index ) {
if ( this . cascadeStages . length <= 1 ) {
this . globalError = '至少需要保留一個濾波器階段' ;
return ;
}
this . saveActiveCascadeStage ();
this . cascadeStages . splice ( index , 1 );
this . activeCascadeStageIndex = Math . min ( this . activeCascadeStageIndex , this . cascadeStages . length - 1 );
2026-05-27 08:38:40 +08:00
this . cascadeStages [ this . activeCascadeStageIndex ]. isExpanded = true ;
2026-05-26 15:34:24 +08:00
this . applyStageToControls ( this . cloneStage ( this . cascadeStages [ this . activeCascadeStageIndex ]));
this . updateBodePlot ({ switchToChart : false });
},
toggleCascadeStage ( index ) {
this . saveActiveCascadeStage ();
const stage = this . cascadeStages [ index ];
stage . isActive = ! stage . isActive ;
this . updateBodePlot ({ switchToChart : false });
this . debouncedProcessFilter ();
},
2026-05-27 08:38:40 +08:00
toggleCascadeStageExpanded ( index ) {
if ( index < 0 || index >= this . cascadeStages . length ) return ;
if ( index !== this . activeCascadeStageIndex ) {
this . selectCascadeStage ( index );
return ;
}
this . saveActiveCascadeStage ();
this . cascadeStages . forEach (( stage , stageIndex ) => {
stage . isExpanded = stageIndex === index ? ! stage . isExpanded : false ;
});
},
cascadeStageSummary ( stage ) {
const bCount = this . parseCoeffs ( stage . b_str || '' ). length ;
const aCount = this . parseCoeffs ( stage . a_str || '' ). length ;
return ` ${ bCount } b / ${ aCount } a, Qb ${ stage . shiftBitsB } , Qa ${ stage . shiftBitsA } ` ;
},
moveCascadeStageUp ( index ) {
this . moveCascadeStage ( index , index - 1 );
},
moveCascadeStageDown ( index ) {
this . moveCascadeStage ( index , index + 1 );
},
moveCascadeStage ( fromIndex , toIndex ) {
if ( fromIndex < 0 || fromIndex >= this . cascadeStages . length || toIndex < 0 || toIndex >= this . cascadeStages . length ) return ;
this . saveActiveCascadeStage ();
const activeStageId = this . cascadeStages [ this . activeCascadeStageIndex ] ? . id ;
const [ stage ] = this . cascadeStages . splice ( fromIndex , 1 );
this . cascadeStages . splice ( toIndex , 0 , stage );
this . activeCascadeStageIndex = Math . max ( 0 , this . cascadeStages . findIndex ( item => item . id === activeStageId ));
this . cascadeStages . forEach (( item , index ) => {
item . isExpanded = index === this . activeCascadeStageIndex ;
});
this . applyStageToControls ( this . cloneStage ( this . cascadeStages [ this . activeCascadeStageIndex ]));
this . updateBodePlot ({ switchToChart : false });
this . debouncedProcessFilter ();
},
2026-05-26 15:34:24 +08:00
getStageFixedCoeffs ( stage ) {
2026-05-27 08:38:40 +08:00
return computeStageFixedCoeffs ( stage , this . parseCoeffs );
2026-05-26 15:34:24 +08:00
},
getCascadeStagesSnapshot () {
this . saveActiveCascadeStage ();
return this . cascadeStages . map ( stage => this . cloneStage ( stage ));
},
getCascadeBodePayload () {
return {
fs : this . fs ,
stages : this . getCascadeStagesSnapshot (). map ( stage => {
const fixed = this . getStageFixedCoeffs ( stage );
const scaleB = Math . pow ( 2 , stage . shiftBitsB );
const scaleA = Math . pow ( 2 , stage . shiftBitsA );
return {
b : this . parseCoeffs ( stage . b_str ),
a : this . parseCoeffs ( stage . a_str ),
b_fixed : fixed . b . map ( v => v / scaleB ),
a_fixed : fixed . a . map ( v => v / scaleA ),
isActive : stage . isActive ,
};
}),
};
},
getCascadeFilterPayload () {
2026-05-27 08:38:40 +08:00
return createCascadeFilterPayload ({
stages : this . getCascadeStagesSnapshot (),
fs : this . fs ,
shiftBitsIn : this . shiftBitsIn ,
shiftBitsOut : this . shiftBitsOut ,
useRound : this . useRound ,
parseCoeffs : this . parseCoeffs ,
getStageFixedCoeffs : this . getStageFixedCoeffs ,
2026-05-26 15:34:24 +08:00
});
},
2026-05-15 16:12:47 +08:00
loadSettings () {
try {
const saved = localStorage . getItem ( 'dea_settings' );
if ( saved ) {
const parsed = JSON . parse ( saved );
const keys = [
'fs' , 'b_str' , 'a_str' , 'filterType' , 'systemGain' , 'params' ,
'baseB' , 'baseA' , 'bSliders' , 'aSliders' , 'sense_b' , 'sense_a' ,
2026-05-20 13:44:31 +08:00
'shiftBitsB' , 'shiftBitsA' , 'shiftBitsIn' , 'shiftBitsOut' , 'useRound' ,
2026-05-27 08:38:40 +08:00
'fixedOverrides' , 'aFineStep' , 'fixedAFineStep' , 'cascadeStages' , 'activeCascadeStageIndex'
2026-05-15 16:12:47 +08:00
];
keys . forEach ( k => {
if ( parsed [ k ] !== undefined ) {
this [ k ] = parsed [ k ];
}
});
}
} catch ( e ) {
console . error ( 'Failed to load settings:' , e );
}
},
saveSettings () {
2026-05-27 08:38:40 +08:00
this . saveActiveCascadeStage ();
2026-05-15 16:12:47 +08:00
const keys = [
'fs' , 'b_str' , 'a_str' , 'filterType' , 'systemGain' , 'params' ,
'baseB' , 'baseA' , 'bSliders' , 'aSliders' , 'sense_b' , 'sense_a' ,
2026-05-20 13:44:31 +08:00
'shiftBitsB' , 'shiftBitsA' , 'shiftBitsIn' , 'shiftBitsOut' , 'useRound' ,
2026-05-27 08:38:40 +08:00
'fixedOverrides' , 'aFineStep' , 'fixedAFineStep' , 'cascadeStages' , 'activeCascadeStageIndex'
2026-05-15 16:12:47 +08:00
];
const settings = {};
keys . forEach ( k => settings [ k ] = this [ k ]);
localStorage . setItem ( 'dea_settings' , JSON . stringify ( settings ));
},
debouncedSaveSettings () {
clearTimeout ( this . saveSettingsTimeout );
this . saveSettingsTimeout = setTimeout (() => this . saveSettings (), 500 );
},
2026-05-14 17:05:12 +08:00
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 ) {
2026-05-16 18:17:13 +08:00
const steps = [ 1 , 2 , 4 , 8 , 32 , 256 , 1024 , 4096 , 16384 , 65536 ];
2026-05-15 17:45:45 +08:00
let currentIdx = steps . indexOf ( this . fixedAFineStep );
// 如果當前值不在數列中,找最接近的一個
if ( currentIdx === - 1 ) {
currentIdx = steps . reduce (( prevIdx , curr , idx ) => {
return Math . abs ( curr - this . fixedAFineStep ) < Math . abs ( steps [ prevIdx ] - this . fixedAFineStep ) ? idx : prevIdx ;
}, 0 );
}
if ( direction > 0 ) { // 細 -> 往小調
currentIdx = Math . max ( 0 , currentIdx - 1 );
} else { // 粗 -> 往大調
currentIdx = Math . min ( steps . length - 1 , currentIdx + 1 );
}
this . fixedAFineStep = steps [ currentIdx ];
2026-05-14 17:05:12 +08:00
},
setDeltaR ( delta , r ) {
const nextDelta = Number ( delta );
const nextR = Number ( r );
if ( ! Number . isFinite ( nextDelta ) || ! Number . isFinite ( nextR )) return ;
2026-05-16 18:17:13 +08:00
// New Model: a1 = -1 - r, a2 = r + delta
this . setA1A2 ( - 1 - nextR , nextR + nextDelta );
2026-05-14 17:05:12 +08:00
},
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 ) {
2026-05-16 18:17:13 +08:00
const nextDelta = Math . round ( delta );
const nextR = Math . round ( r );
2026-05-15 17:45:45 +08:00
const a0 = this . fixedA0Int ;
2026-05-16 18:17:13 +08:00
// New Model: a1 = -a0 - r, a2 = r + delta
// If r and delta are integers, a1 and a2 are guaranteed to be integers.
this . fixedOverrides . a [ 1 ] = - a0 - nextR ;
this . fixedOverrides . a [ 2 ] = nextR + nextDelta ;
2026-05-14 17:05:12 +08:00
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 ) {
2026-05-16 18:17:13 +08:00
const dVal = Math . round ( steps * this . fixedAFineStep );
2026-05-14 17:05:12 +08:00
if ( target === 'delta' ) {
2026-05-16 18:17:13 +08:00
this . setFixedDeltaR ( this . fixedCurrentDelta + dVal , this . fixedCurrentR );
2026-05-14 17:05:12 +08:00
} else if ( target === 'r' ) {
2026-05-16 18:17:13 +08:00
this . setFixedDeltaR ( this . fixedCurrentDelta , this . fixedCurrentR + dVal );
2026-05-14 17:05:12 +08:00
}
},
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 );
},
2026-05-17 00:58:19 +08:00
setUseRound ( val ) {
this . useRound = val ;
},
2026-05-14 17:05:12 +08:00
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 );
},
2026-05-17 00:58:19 +08:00
// 新增:處理輸入輸出位移
setShiftBitsIn ( rawValue ) {
this . shiftBitsIn = this . normalizeShiftBits ( rawValue );
this . debouncedUpdateBode ();
},
adjustShiftBitsIn ( steps ) {
this . setShiftBitsIn ( this . shiftBitsIn + steps );
},
startShiftInRepeat ( direction ) {
this . stopShiftInRepeat ();
this . adjustShiftBitsIn ( direction );
this . shiftInRepeatDelayTimer = setTimeout (() => {
this . shiftInRepeatTimer = setInterval (() => {
this . adjustShiftBitsIn ( direction );
}, 80 );
}, 350 );
},
stopShiftInRepeat () {
clearTimeout ( this . shiftInRepeatDelayTimer );
clearInterval ( this . shiftInRepeatTimer );
this . shiftInRepeatDelayTimer = null ;
this . shiftInRepeatTimer = null ;
},
startShiftInDrag ( event ) {
if ( event . pointerType !== 'touch' ) return ;
event . preventDefault ();
this . shiftInDrag = { lastY : event . clientY , remainder : 0 };
window . addEventListener ( 'pointermove' , this . moveShiftInDrag , { passive : false });
window . addEventListener ( 'pointerup' , this . stopShiftInDrag );
window . addEventListener ( 'pointercancel' , this . stopShiftInDrag );
},
moveShiftInDrag ( event ) {
if ( ! this . shiftInDrag ) return ;
event . preventDefault ();
const delta = this . shiftInDrag . lastY - event . clientY ;
this . shiftInDrag . lastY = event . clientY ;
this . shiftInDrag . remainder += delta ;
const steps = Math . trunc ( this . shiftInDrag . remainder / 18 );
if ( steps === 0 ) return ;
this . shiftInDrag . remainder -= steps * 18 ;
this . adjustShiftBitsIn ( steps );
},
stopShiftInDrag () {
this . shiftInDrag = null ;
window . removeEventListener ( 'pointermove' , this . moveShiftInDrag );
window . removeEventListener ( 'pointerup' , this . stopShiftInDrag );
window . removeEventListener ( 'pointercancel' , this . stopShiftInDrag );
},
setShiftBitsOut ( rawValue ) {
this . shiftBitsOut = this . normalizeShiftBits ( rawValue );
this . debouncedUpdateBode ();
},
adjustShiftBitsOut ( steps ) {
this . setShiftBitsOut ( this . shiftBitsOut + steps );
},
startShiftOutRepeat ( direction ) {
this . stopShiftOutRepeat ();
this . adjustShiftBitsOut ( direction );
this . shiftOutRepeatDelayTimer = setTimeout (() => {
this . shiftOutRepeatTimer = setInterval (() => {
this . adjustShiftBitsOut ( direction );
}, 80 );
}, 350 );
},
stopShiftOutRepeat () {
clearTimeout ( this . shiftOutRepeatDelayTimer );
clearInterval ( this . shiftOutRepeatTimer );
this . shiftOutRepeatDelayTimer = null ;
this . shiftOutRepeatTimer = null ;
},
startShiftOutDrag ( event ) {
if ( event . pointerType !== 'touch' ) return ;
event . preventDefault ();
this . shiftOutDrag = { lastY : event . clientY , remainder : 0 };
window . addEventListener ( 'pointermove' , this . moveShiftOutDrag , { passive : false });
window . addEventListener ( 'pointerup' , this . stopShiftOutDrag );
window . addEventListener ( 'pointercancel' , this . stopShiftOutDrag );
},
moveShiftOutDrag ( event ) {
if ( ! this . shiftOutDrag ) return ;
event . preventDefault ();
const delta = this . shiftOutDrag . lastY - event . clientY ;
this . shiftOutDrag . lastY = event . clientY ;
this . shiftOutDrag . remainder += delta ;
const steps = Math . trunc ( this . shiftOutDrag . remainder / 18 );
if ( steps === 0 ) return ;
this . shiftOutDrag . remainder -= steps * 18 ;
this . adjustShiftBitsOut ( steps );
},
stopShiftOutDrag () {
this . shiftOutDrag = null ;
window . removeEventListener ( 'pointermove' , this . moveShiftOutDrag );
window . removeEventListener ( 'pointerup' , this . stopShiftOutDrag );
window . removeEventListener ( 'pointercancel' , this . stopShiftOutDrag );
},
2026-05-14 17:05:12 +08:00
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 ));
2026-05-15 16:12:47 +08:00
if ( Math . abs ( logVal ) < 1e-8 ) logVal = 0 ; // 消除浮點數微小誤差
2026-05-14 17:05:12 +08:00
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 );
2026-05-15 16:12:47 +08:00
if ( Math . abs ( logVal ) < 1e-8 ) logVal = 0 ; // 消除浮點數微小誤差
2026-05-14 17:05:12 +08:00
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 () {
2026-05-15 17:45:45 +08:00
this . updateBodeMagRange ();
2026-05-14 17:05:12 +08:00
this . updateFromControls ();
},
2026-05-15 17:45:45 +08:00
updateBodeMagRange () {
const K = parseFloat ( this . systemGain ) || 1.0 ;
const yMax = 20 * Math . log10 ( Math . abs ( K ) * 3 );
this . bodeMagRange = [ yMax - 70 , yMax ];
},
2026-05-14 17:05:12 +08:00
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 ();
}
2026-05-15 17:45:45 +08:00
this . updateBodeMagRange ();
2026-05-14 17:05:12 +08:00
this . applyFilterDesign ();
},
resetFilterParams () {
clearTimeout ( this . bodeTimeout );
2026-05-15 11:34:13 +08:00
const isManual = this . filterType === MANUAL_FILTER_TYPE ;
2026-05-14 17:05:12 +08:00
this . fs = DEFAULT_FS ;
this . systemGain = 1.0 ;
this . params = cloneDefaultParams ();
2026-05-15 11:34:13 +08:00
if ( isManual ) {
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 ));
}
2026-05-14 17:05:12 +08:00
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 ;
2026-05-15 17:45:45 +08:00
this . updateBodeMagRange ();
2026-05-15 11:34:13 +08:00
if ( isManual ) this . updateBodePlot ({ switchToChart : false });
else this . applyFilterDesign ();
2026-05-14 17:05:12 +08:00
},
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 ;
});
2026-05-26 15:34:24 +08:00
const cascadePayload = this . getCascadeBodePayload ();
const res = await fetch ( '/api/bode/compare_cascade' , {
2026-05-14 17:05:12 +08:00
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
2026-05-26 15:34:24 +08:00
body : JSON . stringify ( cascadePayload )
2026-05-14 17:05:12 +08:00
});
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 ,
2026-05-15 17:45:45 +08:00
uirevision : this . bodeMagRange ? this . bodeMagRange . join ( ',' ) : 'true' , // 當範圍不變時,保留使用者縮放
2026-05-14 17:05:12 +08:00
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
],
2026-05-15 17:45:45 +08:00
xaxis : { ... xAxisCommon , anchor : 'y' },
yaxis : {
title : { text : 'Mag (dB)' , font : { size : isSmallScreen ? 12 : 14 } },
2026-05-20 13:44:31 +08:00
range : this . bodeMagRange || [ - 70 , 0 ],
2026-05-15 17:45:45 +08:00
gridcolor : gridColor ,
zerolinecolor : zeroLineColor ,
tickfont : { color : textColor , size : isSmallScreen ? 11 : 12 },
fixedrange : false // 允許使用者手動縮放或點擊 Auto Scale
},
2026-05-20 13:44:31 +08:00
xaxis2 : {
type : 'log' ,
2026-05-15 17:45:45 +08:00
title : { text : 'Freq (Hz)' , font : { size : isSmallScreen ? 12 : 14 } },
2026-05-20 13:44:31 +08:00
tickvals : xTicks ,
2026-05-15 17:45:45 +08:00
ticktext : xTexts ,
2026-05-20 13:44:31 +08:00
showgrid : true ,
2026-05-15 17:45:45 +08:00
gridcolor : gridColor ,
tickfont : { color : textColor , size : isSmallScreen ? 11 : 12 },
nticks : isSmallScreen ? 6 : 10 ,
2026-05-20 13:44:31 +08:00
matches : 'x' ,
anchor : 'y2'
},
yaxis2 : {
title : { text : 'Phase (°)' , font : { size : isSmallScreen ? 12 : 14 } },
range : [ - 180 , 180 ],
tickvals : [ - 180 , - 90 , 0 , 90 , 180 ],
gridcolor : gridColor ,
zerolinecolor : zeroLineColor ,
2026-05-15 17:45:45 +08:00
tickfont : { color : textColor , size : isSmallScreen ? 11 : 12 },
fixedrange : true // 相位縱軸鎖死,不可縮放
},
2026-05-14 17:05:12 +08:00
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 ;
2026-05-17 00:58:19 +08:00
this . csvFileId = null ;
2026-05-14 17:05:12 +08:00
this . csvColumns = [];
this . csvPreview = [];
this . csvInfo = null ;
this . csvParseError = null ;
2026-05-20 17:35:10 +08:00
this . csvUploading = false ;
2026-05-27 08:38:40 +08:00
this . usingPresetCsv = false ;
2026-05-17 00:58:19 +08:00
this . isCsvPreviewExpanded = true ;
2026-05-14 17:05:12 +08:00
this . selectedColumn = 0 ;
this . filterDone = false ;
this . timePlotData = null ;
2026-05-26 15:34:24 +08:00
this . zoomStartIdx = null ;
this . zoomEndIdx = null ;
this . timePlotUnit = null ;
this . timePlotMultiplier = null ;
2026-05-14 17:05:12 +08:00
},
2026-05-27 08:38:40 +08:00
async loadPresetWaveforms () {
try {
const res = await fetch ( '/api/csv/preset' );
if ( ! res . ok ) return ;
const data = await res . json ();
this . presetCsvAvailable = !! data . available ;
if ( ! data . available ) return ;
this . csvFile = null ;
this . csvFileId = data . file_id ;
this . csvColumns = data . columns || [];
this . csvPreview = data . preview || [];
this . csvInfo = {
rows : data . rows || 0 ,
columns : data . columns_count || ( data . columns || []). length ,
size : data . size || 0 ,
};
this . csvParseError = null ;
this . csvUploading = false ;
this . presetCsvName = data . name || 'preset_signals.csv' ;
this . usingPresetCsv = true ;
this . selectedColumn = Number . isInteger ( data . default_col_idx ) ? data . default_col_idx : 0 ;
this . filterDone = false ;
this . timePlotData = null ;
this . zoomStartIdx = null ;
this . zoomEndIdx = null ;
this . timePlotUnit = null ;
this . timePlotMultiplier = null ;
this . isCsvPreviewExpanded = true ;
this . $nextTick (() => this . processFilter ());
} catch ( _ ) {
this . presetCsvAvailable = false ;
}
},
restorePresetWaveforms () {
if ( this . $refs . fileInput ) this . $refs . fileInput . value = '' ;
this . loadPresetWaveforms ();
},
2026-05-14 17:05:12 +08:00
handleFileUpload ( event ) {
const file = event . target . files [ 0 ];
this . resetCsvState ();
if ( ! file ) return ;
2026-05-17 00:58:19 +08:00
const maxBytes = 300 * 1024 * 1024 ;
2026-05-14 17:05:12 +08:00
const fileName = file . name || '' ;
if ( ! fileName . toLowerCase (). endsWith ( '.csv' )) {
this . csvParseError = '請選擇 .csv 檔案' ;
event . target . value = '' ;
return ;
}
if ( file . size > maxBytes ) {
2026-05-17 00:58:19 +08:00
this . csvParseError = 'CSV 檔案不可超過 300MB' ;
2026-05-14 17:05:12 +08:00
event . target . value = '' ;
return ;
}
2026-05-20 17:35:10 +08:00
const previewBytes = Math . min ( file . size , 1024 * 1024 );
2026-05-14 17:05:12 +08:00
const reader = new FileReader ();
2026-05-17 00:58:19 +08:00
reader . onload = async ( e ) => {
2026-05-14 17:05:12 +08:00
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 ),
2026-05-20 17:35:10 +08:00
rowsLabel : file . size > previewBytes ? `預覽 ${ Math . max ( rows . length - 1 , 0 ) } +` : ` ${ Math . max ( totalRows - 1 , 0 ) } ` ,
2026-05-14 17:05:12 +08:00
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 ;
}
}
2026-05-20 13:44:31 +08:00
2026-05-17 00:58:19 +08:00
// 背景上傳至暫存區
2026-05-20 17:35:10 +08:00
this . csvUploading = true ;
2026-05-17 00:58:19 +08:00
const formData = new FormData ();
formData . append ( 'file' , file );
const res = await fetch ( '/api/csv/upload' , { method : 'POST' , body : formData });
2026-05-20 17:35:10 +08:00
if ( ! res . ok ) {
const errText = await res . text ();
let detail = errText || '背景上傳失敗' ;
try {
detail = JSON . parse ( errText ). detail || detail ;
} catch ( _ ) {}
throw new Error ( detail );
}
2026-05-17 00:58:19 +08:00
const data = await res . json ();
this . csvFileId = data . file_id ;
2026-05-20 13:44:31 +08:00
2026-05-14 17:05:12 +08:00
} catch ( err ) {
this . csvParseError = err . message || 'CSV 解析失敗' ;
this . csvColumns = [];
this . csvPreview = [];
this . csvInfo = null ;
2026-05-17 00:58:19 +08:00
this . csvFileId = null ;
2026-05-14 17:05:12 +08:00
event . target . value = '' ;
2026-05-20 17:35:10 +08:00
} finally {
this . csvUploading = false ;
2026-05-14 17:05:12 +08:00
}
};
reader . onerror = () => {
this . csvParseError = 'CSV 讀取失敗' ;
2026-05-20 17:35:10 +08:00
this . csvUploading = false ;
2026-05-14 17:05:12 +08:00
event . target . value = '' ;
};
2026-05-20 17:35:10 +08:00
reader . readAsText ( file . slice ( 0 , previewBytes ));
2026-05-14 17:05:12 +08:00
},
2026-05-17 00:58:19 +08:00
debouncedProcessFilter () {
if ( ! this . filterDone || ! this . csvFileId ) return ;
if ( this . _filterTimeout ) clearTimeout ( this . _filterTimeout );
this . _filterTimeout = setTimeout (() => {
this . processFilter ();
}, 500 );
},
2026-05-14 17:05:12 +08:00
async processFilter () {
2026-05-20 17:35:10 +08:00
if ( this . csvUploading ) {
this . globalError = "CSV 還在上傳中,請稍候..." ;
return ;
}
2026-05-17 00:58:19 +08:00
if ( ! this . csvFileId ) {
if ( this . csvFile ) this . globalError = "檔案還在上傳中,請稍候..." ;
return ;
}
2026-05-26 15:34:24 +08:00
if ( this . zoomStartIdx === null && this . zoomEndIdx === null ) {
this . timePlotUnit = null ;
this . timePlotMultiplier = null ;
}
2026-05-14 17:05:12 +08:00
this . loadingFilter = true ;
this . globalError = null ;
const formData = new FormData ();
2026-05-17 00:58:19 +08:00
formData . append ( 'file_id' , this . csvFileId );
2026-05-14 17:05:12 +08:00
formData . append ( 'b' , this . b_str );
formData . append ( 'a' , this . a_str );
formData . append ( 'col_idx' , this . selectedColumn );
2026-05-17 00:58:19 +08:00
formData . append ( 'b_int' , this . b_int_str );
formData . append ( 'a_int' , this . a_int_str );
formData . append ( 'shift_in' , this . shiftBitsIn );
formData . append ( 'shift_out' , this . shiftBitsOut );
formData . append ( 'shift_b' , this . shiftBitsB );
formData . append ( 'shift_a' , this . shiftBitsA );
formData . append ( 'use_round' , this . useRound );
2026-05-26 15:34:24 +08:00
formData . append ( 'stages' , JSON . stringify ( this . getCascadeFilterPayload ()));
if ( this . zoomStartIdx !== null && this . zoomEndIdx !== null ) {
formData . append ( 'start_idx' , this . zoomStartIdx );
formData . append ( 'end_idx' , this . zoomEndIdx );
}
2026-05-14 17:05:12 +08:00
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 ;
2026-05-17 00:58:19 +08:00
this . isCsvPreviewExpanded = false ; // 運算成功後自動收合預覽
2026-05-14 17:05:12 +08:00
// 確保在可見狀態下繪圖,避免 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
2026-05-26 15:34:24 +08:00
if ( ! this . timePlotUnit ) {
const totalDurationS = ( data . total_points || ( data . index . length > 0 ? Math . max (... data . index ) : 0 )) / this . fs ;
if ( totalDurationS > 0 && totalDurationS < 1e-3 ) {
this . timePlotMultiplier = 1e6 ;
this . timePlotUnit = 'μs' ;
} else if ( totalDurationS > 0 && totalDurationS < 1 ) {
this . timePlotMultiplier = 1e3 ;
this . timePlotUnit = 'ms' ;
} else {
this . timePlotMultiplier = 1 ;
this . timePlotUnit = 's' ;
}
}
const timeMultiplier = this . timePlotMultiplier || 1 ;
const timeUnit = this . timePlotUnit || 's' ;
const xData = data . index . map ( i => ( i / this . fs ) * timeMultiplier );
2026-05-14 17:05:12 +08:00
const layout = {
paper_bgcolor : plotBgColor , plot_bgcolor : plotBgColor ,
2026-05-26 15:34:24 +08:00
uirevision : 'time-domain-lod' ,
2026-05-14 17:05:12 +08:00
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' },
2026-05-26 15:34:24 +08:00
xaxis : { title : { text : `Time ( ${ timeUnit } )` , font : { size : isSmallScreen ? 10 : 11 } }, gridcolor : gridColor , zerolinecolor : zeroLineColor , tickfont : { color : textColor , size : isSmallScreen ? 9 : 11 } },
2026-05-14 17:05:12 +08:00
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 } }
};
2026-05-26 15:34:24 +08:00
this . isRedrawingTimePlot = true ;
2026-05-14 17:05:12 +08:00
Plotly . react ( 'timePlot' , [
2026-05-26 15:34:24 +08:00
{ x : xData , y : data . original , name : '原始輸入 (Input)' , type : 'scatter' , line : { color : isDark ? '#c8cee0' : '#566075' , width : 2.4 }, opacity : 0.5 },
{ x : xData , y : data . filtered , name : '理想路徑 (Ideal Float)' , type : 'scatter' , line : { color : isDark ? '#64d2ff' : '#2d6f8f' , width : 2.8 }, opacity : 0.95 },
{ x : xData , y : data . filtered_fixed , name : '定點路徑 (Mimic Integer)' , type : 'scatter' , line : { color : isDark ? '#ff7c7c' : '#c0392b' , width : 2.8 , dash : 'dot' }, opacity : 0.95 }
2026-05-14 17:05:12 +08:00
], layout , { responsive : true });
2026-05-26 15:34:24 +08:00
this . $nextTick (() => setTimeout (() => { this . isRedrawingTimePlot = false ; }, 50 ));
const gd = document . getElementById ( 'timePlot' );
if ( gd && ! gd . _hasRelayoutListener ) {
gd . on ( 'plotly_relayout' , this . handleTimePlotRelayout );
gd . _hasRelayoutListener = true ;
}
},
handleTimePlotRelayout ( eventData ) {
if ( this . isRedrawingTimePlot ) return ;
if ( ! this . timePlotData || ! this . timePlotData . total_points ) return ;
let startIdx = null ;
let endIdx = null ;
if ( eventData [ 'xaxis.range[0]' ] !== undefined && eventData [ 'xaxis.range[1]' ] !== undefined ) {
const timeMultiplier = this . timePlotMultiplier || 1 ;
startIdx = Math . max ( 0 , Math . floor (( eventData [ 'xaxis.range[0]' ] * this . fs ) / timeMultiplier ));
endIdx = Math . min ( this . timePlotData . total_points , Math . ceil (( eventData [ 'xaxis.range[1]' ] * this . fs ) / timeMultiplier ));
if ( endIdx - startIdx <= 10 ) return ;
} else if ( eventData [ 'xaxis.autorange' ] === true ) {
startIdx = null ;
endIdx = null ;
} else {
return ;
}
this . zoomStartIdx = startIdx ;
this . zoomEndIdx = endIdx ;
if ( this . _zoomTimeout ) clearTimeout ( this . _zoomTimeout );
this . _zoomTimeout = setTimeout (() => this . processFilter (), 150 );
2026-05-14 17:05:12 +08:00
},
async downloadCsv () {
2026-05-17 00:58:19 +08:00
if ( ! this . csvFileId ) return ;
2026-05-14 17:05:12 +08:00
const formData = new FormData ();
2026-05-17 00:58:19 +08:00
formData . append ( 'file_id' , this . csvFileId );
2026-05-14 17:05:12 +08:00
formData . append ( 'b' , this . b_str );
formData . append ( 'a' , this . a_str );
formData . append ( 'col_idx' , this . selectedColumn );
2026-05-17 00:58:19 +08:00
formData . append ( 'b_int' , this . b_int_str );
formData . append ( 'a_int' , this . a_int_str );
formData . append ( 'shift_in' , this . shiftBitsIn );
formData . append ( 'shift_out' , this . shiftBitsOut );
formData . append ( 'shift_b' , this . shiftBitsB );
formData . append ( 'shift_a' , this . shiftBitsA );
formData . append ( 'use_round' , this . useRound );
2026-05-26 15:34:24 +08:00
formData . append ( 'stages' , JSON . stringify ( this . getCascadeFilterPayload ()));
if ( this . zoomStartIdx !== null && this . zoomEndIdx !== null ) {
formData . append ( 'start_idx' , this . zoomStartIdx );
formData . append ( 'end_idx' , this . zoomEndIdx );
}
2026-05-14 17:05:12 +08:00
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 ;
}
}
}
}