diff --git a/src/component/context/processings_mutations_context.api.ts b/src/component/context/processings_mutations_context.api.ts index 8771b1d3e..ade06e5ca 100644 --- a/src/component/context/processings_mutations_context.api.ts +++ b/src/component/context/processings_mutations_context.api.ts @@ -23,7 +23,7 @@ import { initializeContoursLevels } from '../../data/data2d/Spectrum2D/contours. import type { LiveEdit, State } from '../reducer/Reducer.ts'; import { setDomain, setMode } from '../reducer/actions/DomainActions.ts'; import { updateView } from '../reducer/actions/FiltersActions.ts'; -import { restoreLastZoomDomain } from '../reducer/helper/ZoomHistoryManager.ts'; +import { restoreLastZoomUnlessDomainChanged } from '../reducer/helper/ZoomHistoryManager.ts'; import { getActiveSpectrum } from '../reducer/helper/getActiveSpectrum.ts'; import { useChartData } from './ChartContext.tsx'; @@ -207,6 +207,10 @@ export function useProcessingsMutationsAPI() { const operation = spectrum.processings[operationIndex]; operation.enabled = !operation.enabled; + // `processSpectrum` mutates the spectrum in place, so capture the current + // FID/FT state before processing to detect a FID <-> FT transition. + const wasFt = spectrum.info.isFt; + resetSelectedTool(); await submit(spectrum, indexSpectrum, (draft, processedSpectrum) => { if (isSpectrum2D(processedSpectrum) && processedSpectrum.info.isFt) { @@ -216,7 +220,7 @@ export function useProcessingsMutationsAPI() { setDomain(draft); setMode(draft); - restoreLastZoomDomain(draft); + restoreLastZoomUnlessDomainChanged(draft, wasFt); }); }); diff --git a/src/component/reducer/actions/FiltersActions.ts b/src/component/reducer/actions/FiltersActions.ts index fa2bba38c..c57ca01e6 100644 --- a/src/component/reducer/actions/FiltersActions.ts +++ b/src/component/reducer/actions/FiltersActions.ts @@ -52,7 +52,7 @@ import { getDefaultTwoDimensionsPhaseCorrectionTraceOptions, getInitialState, } from '../Reducer.js'; -import { restoreLastZoomDomain } from '../helper/ZoomHistoryManager.js'; +import { restoreLastZoomUnlessDomainChanged } from '../helper/ZoomHistoryManager.js'; import { findStrongestPeak } from '../helper/findStrongestPeak.js'; import { getActiveSpectrum } from '../helper/getActiveSpectrum.js'; import getRange from '../helper/getRange.js'; @@ -1658,6 +1658,10 @@ export function handleEnableFilter( const { id, enabled } = action.payload; const datum = draft.data[activeSpectrum.index]; + // `enableFilter` reprocesses the spectrum in place, so capture the current + // FID/FT state before to detect a FID <-> FT transition. + const wasFt = datum.info.isFt; + //apply filter into the spectrum if (isSpectrum1D(datum)) { Filters1DManager.enableFilter(datum, { id, enabled }); @@ -1674,7 +1678,7 @@ export function handleEnableFilter( resetSelectedTool(draft); setDomain(draft); setMode(draft); - restoreLastZoomDomain(draft); + restoreLastZoomUnlessDomainChanged(draft, wasFt); } function deleteFilter(datum: Spectrum, id?: string) { diff --git a/src/component/reducer/helper/ZoomHistoryManager.ts b/src/component/reducer/helper/ZoomHistoryManager.ts index 01cb2d930..55f13ca0b 100644 --- a/src/component/reducer/helper/ZoomHistoryManager.ts +++ b/src/component/reducer/helper/ZoomHistoryManager.ts @@ -3,6 +3,8 @@ import type { Draft } from 'immer'; import type { BrushAxis } from '../../EventsTrackers/brush_tracker.types.ts'; import type { State } from '../Reducer.js'; +import { getSpectrum } from './getSpectrum.js'; + interface HistoryItem { xDomain: number[]; yDomain: number[]; @@ -117,11 +119,31 @@ export function addToBrushHistory( } } -export function restoreLastZoomDomain(draft: Draft) { +interface RestoreLastZoomDomainOptions { + /** + * Set to true when the displayed spectrum switched between FID and FT. + * A FID <-> FT transition regenerates the x axis, so any zoom domain stored + * for the previous state is expressed in a different scale and must be + * discarded instead of restored (otherwise the previous zoom is applied to + * the new axis, e.g. FFT keeps the FID zoom range and only flips the scale). + */ + isFtStateChanged?: boolean; +} + +export function restoreLastZoomDomain( + draft: Draft, + options: RestoreLastZoomDomainOptions = {}, +) { const zoomHistory = zoomHistoryManager( draft.zoom.history, draft.view.spectra.activeTab, ); + + if (options.isFtStateChanged) { + zoomHistory.clear(); + return false; + } + const zoomValue = zoomHistory.getLast(); if (!zoomValue) { @@ -133,3 +155,21 @@ export function restoreLastZoomDomain(draft: Draft) { return true; } + +/** + * Restore the last zoom domain, unless the active spectrum switched between FID + * and FT since `previousIsFt` was captured. This is the shared criterion for + * every action that re-processes a spectrum (processings panel, legacy filter + * panel, FFT): a FID <-> FT transition regenerates the x axis, so the stored + * zoom is stale and the freshly computed full domain must be kept instead. + */ +export function restoreLastZoomUnlessDomainChanged( + draft: Draft, + previousIsFt: boolean, +) { + const currentIsFt = getSpectrum(draft)?.info.isFt ?? previousIsFt; + + return restoreLastZoomDomain(draft, { + isFtStateChanged: previousIsFt !== currentIsFt, + }); +} diff --git a/src/component/reducer/helper/__tests__/ZoomHistoryManager.test.ts b/src/component/reducer/helper/__tests__/ZoomHistoryManager.test.ts new file mode 100644 index 000000000..df4286bfb --- /dev/null +++ b/src/component/reducer/helper/__tests__/ZoomHistoryManager.test.ts @@ -0,0 +1,90 @@ +import type { Draft } from 'immer'; +import { expect, test } from 'vitest'; + +import type { State } from '../../Reducer.ts'; +import { + addToBrushHistory, + restoreLastZoomDomain, + restoreLastZoomUnlessDomainChanged, +} from '../ZoomHistoryManager.ts'; + +/** + * Minimal draft exposing only what `addToBrushHistory`, + * `restoreLastZoomDomain` and `restoreLastZoomUnlessDomainChanged` read from + * the reducer state. + */ +function createDraft(isFt: boolean): Draft { + return { + displayerMode: '1D', + xDomain: [0, 100], + yDomain: [0, 1], + yDomains: {}, + data: [{ info: { isFt } }], + view: { + spectra: { + activeTab: '1H', + activeSpectra: { '1H': [{ index: 0, selected: true }] }, + }, + }, + zoom: { history: {} }, + } as unknown as Draft; +} + +test('restores the last zoom domain when no FID/FT transition happened', () => { + const draft = createDraft(false); + + // user brushes a zoom on the current (FID) spectrum + addToBrushHistory(draft, { axis: 'X', xDomain: [20, 40], yDomain: [0, 1] }); + expect(draft.xDomain).toStrictEqual([20, 40]); + + // toggling a processing that does not change the FID/FT state resets the + // domain and then restores the user zoom + draft.xDomain = [-10, 10]; + const restored = restoreLastZoomUnlessDomainChanged(draft, false); + + expect(restored).toBe(true); + expect(draft.xDomain).toStrictEqual([20, 40]); +}); + +test('discards the stale zoom domain on a FID -> FT transition (FFT enabled)', () => { + const draft = createDraft(false); + + // user brushes a zoom while the spectrum is in the FID state + addToBrushHistory(draft, { axis: 'X', xDomain: [20, 40], yDomain: [0, 1] }); + + // enabling FFT switches to FT and recomputes the full domain + draft.data[0].info.isFt = true; + draft.xDomain = [-10, 10]; + const restored = restoreLastZoomUnlessDomainChanged(draft, false); + + // the FID zoom must not be applied on the regenerated FT axis + expect(restored).toBe(false); + expect(draft.xDomain).toStrictEqual([-10, 10]); + expect(draft.zoom.history['1H']).toStrictEqual([]); +}); + +test('discards the stale zoom domain on a FT -> FID transition (FFT disabled)', () => { + const draft = createDraft(true); + + // user brushes a zoom while the spectrum is in the FT state + addToBrushHistory(draft, { axis: 'X', xDomain: [2, 4], yDomain: [0, 1] }); + + // disabling FFT switches back to FID and recomputes the full domain + draft.data[0].info.isFt = false; + draft.xDomain = [0, 100]; + const restored = restoreLastZoomUnlessDomainChanged(draft, true); + + expect(restored).toBe(false); + expect(draft.xDomain).toStrictEqual([0, 100]); + expect(draft.zoom.history['1H']).toStrictEqual([]); +}); + +test('restoreLastZoomDomain keeps its default behaviour (restore when possible)', () => { + const draft = createDraft(false); + + addToBrushHistory(draft, { axis: 'X', xDomain: [20, 40], yDomain: [0, 1] }); + draft.xDomain = [-10, 10]; + + expect(restoreLastZoomDomain(draft)).toBe(true); + expect(draft.xDomain).toStrictEqual([20, 40]); +});