From 04a156bb4085655150afc84d4ad87dfdb41b94ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Fiaudrin?= Date: Wed, 8 Jul 2026 08:45:10 +0200 Subject: [PATCH 01/14] add data smoothing feature --- .../visualization/DataplotCustomization.tsx | 10 + .../CustomizeSmoothing.tsx | 304 ++++++++++++++++++ .../customizableElements/index.ts | 1 + frontend/src/renderer/types/data.ts | 11 + frontend/src/renderer/utils/fetchData.ts | 34 +- 5 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 frontend/src/renderer/pages/visualization/customizableElements/CustomizeSmoothing.tsx diff --git a/frontend/src/renderer/pages/visualization/DataplotCustomization.tsx b/frontend/src/renderer/pages/visualization/DataplotCustomization.tsx index 9247ce6a..9af2e4c1 100644 --- a/frontend/src/renderer/pages/visualization/DataplotCustomization.tsx +++ b/frontend/src/renderer/pages/visualization/DataplotCustomization.tsx @@ -28,6 +28,7 @@ import { CustomizeDataRange, CustomizeSynchronization, CustomizeInterpolation, + CustomizeSmoothing, } from './customizableElements'; import { IconLink } from '@tabler/icons-react'; import { initPlotColors } from '../../utils'; @@ -454,6 +455,15 @@ const Customization = ({ /> ), }, + { + value: 'Data smoothing', + component: ( + + ), + }, ]; const items = ( diff --git a/frontend/src/renderer/pages/visualization/customizableElements/CustomizeSmoothing.tsx b/frontend/src/renderer/pages/visualization/customizableElements/CustomizeSmoothing.tsx new file mode 100644 index 00000000..3670bacf --- /dev/null +++ b/frontend/src/renderer/pages/visualization/customizableElements/CustomizeSmoothing.tsx @@ -0,0 +1,304 @@ +import { useEffect, useState } from 'react'; +import { DataGridPlot, SmoothingParams } from '../../../types'; +import { + fetchDataPlot, + getArrayValueFromDependance, + getFirstArrayValueFromShape, + getSmoothingMethods, + getUrisToInterpolate, + getVectorData, + normalizeIndices, +} from '../../../utils'; +import { showNotification } from '@mantine/notifications'; +import { Button, Group, NumberInput, Select, Stack } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { OptionWithTooltip } from '../../../types/components/select'; +import { RenderSelectOption } from '../../../components/select'; + +const GAUSSIAN_FILTER = 'gaussian_filter'; +const SAVGOL_FILTER = 'savitzky-golay_filter'; +const SAVGOL_MODES = ['mirror', 'constant', 'nearest', 'wrap', 'interp']; + +interface CustomizeSmoothingProps { + customizedDataGrid: DataGridPlot; + setCustomizedDataGrid: React.Dispatch>; +} +export const CustomizeSmoothing = ({ + customizedDataGrid, + setCustomizedDataGrid, +}: CustomizeSmoothingProps) => { + const [smoothingMethods, setSmoothingMethods] = useState( + [], + ); + const [smoothingMethod, setSmoothingMethod] = useState(null); + + // Gaussian filter parameter + const [gaussianSigma, setGaussianSigma] = useState(1); + + // Savitzky-Golay filter parameters + const [savgolWindowLength, setSavgolWindowLength] = useState(5); + const [savgolPolyorder, setSavgolPolyorder] = useState(2); + const [savgolDeriv, setSavgolDeriv] = useState(0); + const [savgolDelta, setSavgolDelta] = useState(1.0); + const [savgolMode, setSavgolMode] = useState('interp'); + const [savgolCval, setSavgolCval] = useState(0.0); + + const [loading, { open, close }] = useDisclosure(); + + /** + * Build the smoothing params for the selected method + */ + const buildSmoothingParams = (): SmoothingParams | undefined => { + if (smoothingMethod === GAUSSIAN_FILTER) { + return { + smoothing_method: smoothingMethod, + gaussian_smoothing_sigma: gaussianSigma, + }; + } + if (smoothingMethod === SAVGOL_FILTER) { + return { + smoothing_method: smoothingMethod, + savgol_smoothing_window_length: savgolWindowLength, + savgol_smoothing_polyorder: savgolPolyorder, + savgol_smoothing_deriv: savgolDeriv, + savgol_smoothing_delta: savgolDelta, + savgol_smoothing_mode: savgolMode, + savgol_smoothing_cval: savgolCval, + }; + } + return undefined; + }; + + /** + * Update configuration with smoothed data (changes coordinates & plots) + */ + const getSmoothedData = async () => { + const smoothingParams = buildSmoothingParams(); + if (!smoothingParams) { + showNotification({ + title: 'No smoothing method', + message: 'Please select a smoothing method.', + color: 'red', + }); + return; + } + try { + open(); + const updatedDataPlot = structuredClone( + customizedDataGrid, + ) as DataGridPlot; + + let plotIndex = 0; + for (const plot of updatedDataPlot.plot) { + // Smooth data + const urisToInterpolate = getUrisToInterpolate( + plot.nodeUri, + updatedDataPlot.plot, + ); + const dataPlotSmoothed = await fetchDataPlot( + normalizeIndices(plot.nodeUri), + customizedDataGrid?.downsampled_method, + customizedDataGrid?.downsampled_size, + updatedDataPlot?.dataType, + urisToInterpolate, + customizedDataGrid?.interpolated_method, + smoothingParams, + ); + + if (plotIndex === 0) { + // Update coordinates with smoothed data only once because each plots have same coordinates + let coordinateIndex = 0; + for (const coordinate of updatedDataPlot.coordinates) { + // Apply new shape + coordinate.shape = + dataPlotSmoothed.data.coordinates[ + coordinateIndex + ].downsampled_shape; + // Apply new data + coordinate.data = + dataPlotSmoothed.data.coordinates[coordinateIndex].value; + coordinateIndex++; + // Apply new range + coordinate.range = [ + 0, + coordinate.shape[coordinate.shape.length - 1] - 1, + ]; + const firstArrayValueFromCoord = getFirstArrayValueFromShape( + coordinate.data, + coordinate.shape, + ); + + coordinate.rangeValues = [ + firstArrayValueFromCoord[0], + firstArrayValueFromCoord[firstArrayValueFromCoord.length - 1], + ]; + } + } + + // Update plot with smoothed data + plot.shape = dataPlotSmoothed.data.downsampled_shape; + // Get x axis switch coordinates dependances + plot.x = getArrayValueFromDependance(updatedDataPlot.coordinates, 0); + plot.yData = dataPlotSmoothed.data.value; + // Get y axis + const vectorData = getVectorData( + updatedDataPlot.coordinates, + plot.yData, + ); + plot.y = vectorData; + + plotIndex++; + } + + // Save new configuration with smoothed data + setCustomizedDataGrid({ + ...customizedDataGrid, + coordinates: updatedDataPlot.coordinates, + plot: updatedDataPlot.plot, + }); + } catch (error) { + console.error('Error getting smoothed data: ', error); + showNotification({ + title: 'Error', + message: `Unable to get smoothed data.`, + color: 'red', + }); + } finally { + close(); + } + }; + + /* + * Get smoothing methods to show in select + */ + useEffect(() => { + const getSmoothingList = async () => { + const options = await getSmoothingMethods(); + setSmoothingMethods(options); + }; + getSmoothingList(); + }, []); + + return ( + + + setSavgolMode(value || savgolMode)} + w="45%" + maw={200} + /> + setSavgolCval(value)} + w="45%" + maw={200} + /> + + + )} + + + + + + ); +}; diff --git a/frontend/src/renderer/pages/visualization/customizableElements/index.ts b/frontend/src/renderer/pages/visualization/customizableElements/index.ts index f82459d6..3ebbcb62 100644 --- a/frontend/src/renderer/pages/visualization/customizableElements/index.ts +++ b/frontend/src/renderer/pages/visualization/customizableElements/index.ts @@ -1,6 +1,7 @@ export * from './CustomizeGlobal'; export * from './CustomizeDownsampling'; export * from './CustomizeInterpolation'; +export * from './CustomizeSmoothing'; export * from './CustomizeHeatmap'; export * from './Customize1DPlot'; export * from './CustomizeDataRange'; diff --git a/frontend/src/renderer/types/data.ts b/frontend/src/renderer/types/data.ts index c8c7b06e..690af2cd 100644 --- a/frontend/src/renderer/types/data.ts +++ b/frontend/src/renderer/types/data.ts @@ -33,6 +33,17 @@ export type FieldValueResponse = { value: AxisData; }; +export type SmoothingParams = { + smoothing_method: string; + gaussian_smoothing_sigma?: number; + savgol_smoothing_window_length?: number; + savgol_smoothing_polyorder?: number; + savgol_smoothing_deriv?: number; + savgol_smoothing_delta?: number; + savgol_smoothing_mode?: string; + savgol_smoothing_cval?: number; +}; + export type DownsamplingMethodsResponse = { downsampling_methods: [ { diff --git a/frontend/src/renderer/utils/fetchData.ts b/frontend/src/renderer/utils/fetchData.ts index ecbf150e..8f077c98 100644 --- a/frontend/src/renderer/utils/fetchData.ts +++ b/frontend/src/renderer/utils/fetchData.ts @@ -12,6 +12,7 @@ import { NodeInfoTypeEnum, PlotDataResponse, SearchNodeResponse, + SmoothingParams, URDataEntriesResponse, URIExistsResponse, URIFromPathResponse, @@ -202,6 +203,22 @@ export const getInterpolationMethods = async (): Promise< ); }; +export const getSmoothingMethods = async (): Promise => { + const methodsRes = await fetchDataManipulationMethods(); + const smoothing = methodsRes.data_manipulation_methods.find( + (m) => m.name === 'Data smoothing/denoising', + ); + const param = smoothing?.method_parameters.find( + (p) => p.name === 'smoothing_method', + ); + return ( + param?.possible_values?.map((item) => ({ + value: item.value, + tooltip: item.description, + })) ?? [] + ); +}; + /** * Retrieves plot data for a given URI. */ @@ -212,6 +229,7 @@ export const fetchDataPlot = async ( type?: NodeInfoTypeEnum, interpolateOver?: string[], interpolationMethod?: string, + smoothing?: SmoothingParams, ) => { const downsampled_size = downsamplingSize || 1000; let response: PlotDataResponse; @@ -240,16 +258,26 @@ export const fetchDataPlot = async ( encodedInterpolateOver += `&interpolation_method=${encodeURIComponent(interpolationMethod)}`; } + // Provide smoothing params if needed + let encodedSmoothing: string = ''; + if (smoothing?.smoothing_method) { + for (const [key, value] of Object.entries(smoothing)) { + if (value != null) { + encodedSmoothing += `&${key}=${encodeURIComponent(value)}`; + } + } + } + if (downsamplingMethod) { // Get downsampled data plot response = await fetchFromApi( - `/data/plot_data?uri=${encodeURIComponent(uri)}&downsampling_method=${encodeURIComponent(downsamplingMethod)}&downsampled_size=${encodeURIComponent(downsampled_size)}${encodedInterpolateOver}`, + `/data/plot_data?uri=${encodeURIComponent(uri)}&downsampling_method=${encodeURIComponent(downsamplingMethod)}&downsampled_size=${encodeURIComponent(downsampled_size)}${encodedInterpolateOver}${encodedSmoothing}`, ); } else { try { // Try to fetch data without downsampling in according timeout response = await fetchFromApi( - `/data/plot_data?uri=${encodeURIComponent(uri)}${encodedInterpolateOver}`, + `/data/plot_data?uri=${encodeURIComponent(uri)}${encodedInterpolateOver}${encodedSmoothing}`, 5000, ); } catch (error) { @@ -274,7 +302,7 @@ export const fetchDataPlot = async ( (meth) => meth.name === 'M4', )?.name || downsampledMethods?.downsampling_methods.slice(0)[1].name; response = await fetchFromApi( - `/data/plot_data?uri=${encodeURIComponent(uri)}&downsampling_method=${encodeURIComponent(firstDownsampledMethod)}&downsampled_size=${encodeURIComponent(downsampled_size)}${encodedInterpolateOver}`, + `/data/plot_data?uri=${encodeURIComponent(uri)}&downsampling_method=${encodeURIComponent(firstDownsampledMethod)}&downsampled_size=${encodeURIComponent(downsampled_size)}${encodedInterpolateOver}${encodedSmoothing}`, ); } } From ab1f21d1c71032c5ce59bc22ddf8d9425d275a45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Fiaudrin?= Date: Wed, 8 Jul 2026 09:48:45 +0200 Subject: [PATCH 02/14] allow the user to restore data once smoothed --- .../CustomizeSmoothing.tsx | 75 +++++++++++++------ 1 file changed, 52 insertions(+), 23 deletions(-) diff --git a/frontend/src/renderer/pages/visualization/customizableElements/CustomizeSmoothing.tsx b/frontend/src/renderer/pages/visualization/customizableElements/CustomizeSmoothing.tsx index 3670bacf..2cd41189 100644 --- a/frontend/src/renderer/pages/visualization/customizableElements/CustomizeSmoothing.tsx +++ b/frontend/src/renderer/pages/visualization/customizableElements/CustomizeSmoothing.tsx @@ -11,7 +11,7 @@ import { } from '../../../utils'; import { showNotification } from '@mantine/notifications'; import { Button, Group, NumberInput, Select, Stack } from '@mantine/core'; -import { useDisclosure } from '@mantine/hooks'; +import { IconRestore } from '@tabler/icons-react'; import { OptionWithTooltip } from '../../../types/components/select'; import { RenderSelectOption } from '../../../components/select'; @@ -43,7 +43,9 @@ export const CustomizeSmoothing = ({ const [savgolMode, setSavgolMode] = useState('interp'); const [savgolCval, setSavgolCval] = useState(0.0); - const [loading, { open, close }] = useDisclosure(); + const [loadingAction, setLoadingAction] = useState< + 'apply' | 'restore' | null + >(null); /** * Build the smoothing params for the selected method @@ -70,27 +72,21 @@ export const CustomizeSmoothing = ({ }; /** - * Update configuration with smoothed data (changes coordinates & plots) + * Re-fetch plot data (optionally with smoothing) and update coordinates & plots. + * Called with smoothing params to apply smoothing, or without to restore raw data. */ - const getSmoothedData = async () => { - const smoothingParams = buildSmoothingParams(); - if (!smoothingParams) { - showNotification({ - title: 'No smoothing method', - message: 'Please select a smoothing method.', - color: 'red', - }); - return; - } + const updatePlotsData = async ( + action: 'apply' | 'restore', + smoothingParams?: SmoothingParams, + ) => { try { - open(); + setLoadingAction(action); const updatedDataPlot = structuredClone( customizedDataGrid, ) as DataGridPlot; let plotIndex = 0; for (const plot of updatedDataPlot.plot) { - // Smooth data const urisToInterpolate = getUrisToInterpolate( plot.nodeUri, updatedDataPlot.plot, @@ -106,7 +102,7 @@ export const CustomizeSmoothing = ({ ); if (plotIndex === 0) { - // Update coordinates with smoothed data only once because each plots have same coordinates + // Update coordinates only once because each plots have same coordinates let coordinateIndex = 0; for (const coordinate of updatedDataPlot.coordinates) { // Apply new shape @@ -135,7 +131,7 @@ export const CustomizeSmoothing = ({ } } - // Update plot with smoothed data + // Update plot with new data plot.shape = dataPlotSmoothed.data.downsampled_shape; // Get x axis switch coordinates dependances plot.x = getArrayValueFromDependance(updatedDataPlot.coordinates, 0); @@ -150,24 +146,48 @@ export const CustomizeSmoothing = ({ plotIndex++; } - // Save new configuration with smoothed data + // Save new configuration with updated data setCustomizedDataGrid({ ...customizedDataGrid, coordinates: updatedDataPlot.coordinates, plot: updatedDataPlot.plot, }); } catch (error) { - console.error('Error getting smoothed data: ', error); + console.error('Error getting plot data: ', error); showNotification({ title: 'Error', - message: `Unable to get smoothed data.`, + message: `Unable to get plot data.`, color: 'red', }); } finally { - close(); + setLoadingAction(null); } }; + /** + * Apply the selected smoothing method to the plot data + */ + const getSmoothedData = async () => { + const smoothingParams = buildSmoothingParams(); + if (!smoothingParams) { + showNotification({ + title: 'No smoothing method', + message: 'Please select a smoothing method.', + color: 'red', + }); + return; + } + await updatePlotsData('apply', smoothingParams); + }; + + /** + * Restore the plot data by re-fetching it without smoothing + */ + const restoreData = async () => { + await updatePlotsData('restore'); + setSmoothingMethod(null); + }; + /* * Get smoothing methods to show in select */ @@ -293,11 +313,20 @@ export const CustomizeSmoothing = ({ + ); From 34055b16f312300422f433f24d3594da339eda95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Fiaudrin?= Date: Wed, 8 Jul 2026 11:27:47 +0200 Subject: [PATCH 03/14] add unary operations --- .../visualization/DataplotCustomization.tsx | 10 + .../CustomizeUnaryOperations.tsx | 286 ++++++++++++++++++ .../customizableElements/index.ts | 1 + frontend/src/renderer/types/data.ts | 12 +- frontend/src/renderer/utils/fetchData.ts | 34 ++- 5 files changed, 339 insertions(+), 4 deletions(-) create mode 100644 frontend/src/renderer/pages/visualization/customizableElements/CustomizeUnaryOperations.tsx diff --git a/frontend/src/renderer/pages/visualization/DataplotCustomization.tsx b/frontend/src/renderer/pages/visualization/DataplotCustomization.tsx index 9af2e4c1..9090e855 100644 --- a/frontend/src/renderer/pages/visualization/DataplotCustomization.tsx +++ b/frontend/src/renderer/pages/visualization/DataplotCustomization.tsx @@ -29,6 +29,7 @@ import { CustomizeSynchronization, CustomizeInterpolation, CustomizeSmoothing, + CustomizeUnaryOperations, } from './customizableElements'; import { IconLink } from '@tabler/icons-react'; import { initPlotColors } from '../../utils'; @@ -464,6 +465,15 @@ const Customization = ({ /> ), }, + { + value: 'Unary operations', + component: ( + + ), + }, ]; const items = ( diff --git a/frontend/src/renderer/pages/visualization/customizableElements/CustomizeUnaryOperations.tsx b/frontend/src/renderer/pages/visualization/customizableElements/CustomizeUnaryOperations.tsx new file mode 100644 index 00000000..22ee763e --- /dev/null +++ b/frontend/src/renderer/pages/visualization/customizableElements/CustomizeUnaryOperations.tsx @@ -0,0 +1,286 @@ +import { useEffect, useState } from 'react'; +import { DataGridPlot, UnaryOperation } from '../../../types'; +import { + fetchDataPlot, + getArrayValueFromDependance, + getFirstArrayValueFromShape, + getOperationMethods, + getUrisToInterpolate, + getVectorData, + normalizeIndices, +} from '../../../utils'; +import { showNotification } from '@mantine/notifications'; +import { + ActionIcon, + Button, + Group, + NumberInput, + Select, + Stack, +} from '@mantine/core'; +import { IconMinus, IconPlus, IconRestore } from '@tabler/icons-react'; +import { OptionWithTooltip } from '../../../types/components/select'; +import { RenderSelectOption } from '../../../components/select'; + +const EMPTY_OPERATION: UnaryOperation = { type: null, value: 1 }; + +interface CustomizeUnaryOperationsProps { + customizedDataGrid: DataGridPlot; + setCustomizedDataGrid: React.Dispatch>; +} +export const CustomizeUnaryOperations = ({ + customizedDataGrid, + setCustomizedDataGrid, +}: CustomizeUnaryOperationsProps) => { + const [operationMethods, setOperationMethods] = useState( + [], + ); + const [operations, setOperations] = useState([ + { ...EMPTY_OPERATION }, + ]); + const [loadingAction, setLoadingAction] = useState< + 'apply' | 'restore' | null + >(null); + + const addOperation = () => { + setOperations((prev) => [...prev, { ...EMPTY_OPERATION }]); + }; + + const removeOperation = (index: number) => { + setOperations((prev) => prev.filter((_, i) => i !== index)); + }; + + const updateOperationType = (index: number, type: string | null) => { + setOperations((prev) => + prev.map((operation, i) => + i === index ? { ...operation, type } : operation, + ), + ); + }; + + const updateOperationValue = (index: number, value: number) => { + setOperations((prev) => + prev.map((operation, i) => + i === index ? { ...operation, value } : operation, + ), + ); + }; + + /** + * Build the ordered list of "type:value" operations for the query param + */ + const buildOperations = (): string[] => { + return operations + .filter((operation) => operation.type) + .map((operation) => `${operation.type}:${operation.value}`); + }; + + /** + * Re-fetch plot data (optionally with operations) and update coordinates & plots. + * Called with operations to apply them, or without to restore raw data. + */ + const updatePlotsData = async ( + action: 'apply' | 'restore', + operationsList?: string[], + ) => { + try { + setLoadingAction(action); + const updatedDataPlot = structuredClone( + customizedDataGrid, + ) as DataGridPlot; + + let plotIndex = 0; + for (const plot of updatedDataPlot.plot) { + const urisToInterpolate = getUrisToInterpolate( + plot.nodeUri, + updatedDataPlot.plot, + ); + const dataPlotOperated = await fetchDataPlot( + normalizeIndices(plot.nodeUri), + customizedDataGrid?.downsampled_method, + customizedDataGrid?.downsampled_size, + updatedDataPlot?.dataType, + urisToInterpolate, + customizedDataGrid?.interpolated_method, + undefined, + operationsList, + ); + + if (plotIndex === 0) { + // Update coordinates only once because each plots have same coordinates + let coordinateIndex = 0; + for (const coordinate of updatedDataPlot.coordinates) { + // Apply new shape + coordinate.shape = + dataPlotOperated.data.coordinates[ + coordinateIndex + ].downsampled_shape; + // Apply new data + coordinate.data = + dataPlotOperated.data.coordinates[coordinateIndex].value; + coordinateIndex++; + // Apply new range + coordinate.range = [ + 0, + coordinate.shape[coordinate.shape.length - 1] - 1, + ]; + const firstArrayValueFromCoord = getFirstArrayValueFromShape( + coordinate.data, + coordinate.shape, + ); + + coordinate.rangeValues = [ + firstArrayValueFromCoord[0], + firstArrayValueFromCoord[firstArrayValueFromCoord.length - 1], + ]; + } + } + + // Update plot with new data + plot.shape = dataPlotOperated.data.downsampled_shape; + // Get x axis switch coordinates dependances + plot.x = getArrayValueFromDependance(updatedDataPlot.coordinates, 0); + plot.yData = dataPlotOperated.data.value; + // Get y axis + const vectorData = getVectorData( + updatedDataPlot.coordinates, + plot.yData, + ); + plot.y = vectorData; + + plotIndex++; + } + + // Save new configuration with updated data + setCustomizedDataGrid({ + ...customizedDataGrid, + coordinates: updatedDataPlot.coordinates, + plot: updatedDataPlot.plot, + }); + } catch (error) { + console.error('Error getting plot data: ', error); + showNotification({ + title: 'Error', + message: `Unable to get plot data.`, + color: 'red', + }); + } finally { + setLoadingAction(null); + } + }; + + /** + * Apply the selected operations to the plot data + */ + const applyOperations = async () => { + const operationsList = buildOperations(); + if (!operationsList.length) { + showNotification({ + title: 'No operation', + message: 'Please add at least one operation.', + color: 'red', + }); + return; + } + await updatePlotsData('apply', operationsList); + }; + + /** + * Restore the plot data by re-fetching it without operations + */ + const restoreData = async () => { + await updatePlotsData('restore'); + setOperations([{ ...EMPTY_OPERATION }]); + }; + + /* + * Get operation methods to show in select + */ + useEffect(() => { + const getOperationList = async () => { + const options = await getOperationMethods(); + setOperationMethods(options); + }; + getOperationList(); + }, []); + + return ( + + {operations.map((operation, index) => ( + + ({ + ...kind, + disabled: kind.value === 'signal' && isSignalKindDisabled, + }))} + onChange={(value) => + updateOperationKind(index, value as OperationKind) + } + allowDeselect={false} + disabled={loadingAction !== null} + renderOption={({ option, checked }) => ( + + + {checked && } + {option.label} + + + )} + data-testid={`data-operation-kind-${index}`} + w="25%" + maw={130} + /> + updateOperationSignal(index, value)} + error={ + operation.value && + !operandOptions.some( + (option) => option.value === operation.value, + ) + ? 'This plot is no longer in the grid' + : undefined + } + disabled={loadingAction !== null} + searchable + data-testid={`data-operation-signal-${index}`} + w="30%" + maw={200} + /> + ) : ( + updateOperationValue(index, value)} + disabled={loadingAction !== null} + data-testid={`data-operation-value-${index}`} + w="30%" + maw={200} + /> + )} + {operations.length !== 1 && ( + removeOperation(index)} + aria-label="Remove operation" + size={36} + > + + + )} + + ))} + + {hasMixedKinds && ( + + Constant operations are always applied before signal operations, + whatever the order of the rows. + + )} + + + + + + + + + + + ); +}; diff --git a/frontend/src/renderer/pages/visualization/customizableElements/CustomizeSmoothing.tsx b/frontend/src/renderer/pages/visualization/customizableElements/CustomizeSmoothing.tsx index 1c7d8f6f..08bd5046 100644 --- a/frontend/src/renderer/pages/visualization/customizableElements/CustomizeSmoothing.tsx +++ b/frontend/src/renderer/pages/visualization/customizableElements/CustomizeSmoothing.tsx @@ -11,6 +11,7 @@ import { DEFAULT_SAVGOL_WINDOW_LENGTH, fetchDataPlot, formatOperations, + formatSignalOperations, GAUSSIAN_FILTER, getArrayValueFromDependance, getFirstArrayValueFromShape, @@ -110,6 +111,7 @@ export const CustomizeSmoothing = ({ customizedDataGrid?.interpolated_method, smoothingParams, formatOperations(plot.operations), + formatSignalOperations(plot.operations), ); // Realign coordinates with the returned data (a no-op when smoothing diff --git a/frontend/src/renderer/pages/visualization/customizableElements/CustomizeUnaryOperations.tsx b/frontend/src/renderer/pages/visualization/customizableElements/CustomizeUnaryOperations.tsx deleted file mode 100644 index 5e5ab8f0..00000000 --- a/frontend/src/renderer/pages/visualization/customizableElements/CustomizeUnaryOperations.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import { useEffect, useState } from 'react'; -import { DataGridPlot, DataPlotly, UnaryOperation } from '../../../types'; -import { - buildSmoothingRequest, - fetchDataPlot, - formatOperations, - getArrayValueFromDependance, - getFirstArrayValueFromShape, - getOperationMethods, - getUrisToInterpolate, - getVectorData, - normalizeIndices, - reapplyAxisOrder, -} from '../../../utils'; -import { showNotification } from '@mantine/notifications'; -import { - ActionIcon, - Button, - Group, - NumberInput, - Select, - Stack, -} from '@mantine/core'; -import { IconMinus, IconPlus, IconRestore } from '@tabler/icons-react'; - -const EMPTY_OPERATION: UnaryOperation = { type: null, value: 1 }; - -interface CustomizeUnaryOperationsProps { - customizedDataGrid: DataGridPlot; - selectedPlot: DataPlotly | null; - setCustomizedDataGrid: React.Dispatch>; -} -export const CustomizeUnaryOperations = ({ - customizedDataGrid, - selectedPlot, - setCustomizedDataGrid, -}: CustomizeUnaryOperationsProps) => { - const [operationMethods, setOperationMethods] = useState< - { value: string; label: string }[] - >([]); - const [loadingAction, setLoadingAction] = useState< - 'apply' | 'restore' | null - >(null); - - // Source of truth is the selected plot's own operations (persisted on the grid) - const operations = selectedPlot?.operations ?? [{ ...EMPTY_OPERATION }]; - - /** - * Persist the operations rows onto the selected plot - */ - const updateOperations = (next: UnaryOperation[]) => { - if (!selectedPlot) return; - const updated = structuredClone(customizedDataGrid) as DataGridPlot; - const plot = updated.plot.find((p) => p.name === selectedPlot.name); - if (plot) { - plot.operations = next; - } - setCustomizedDataGrid({ ...customizedDataGrid, plot: updated.plot }); - }; - - const addOperation = () => { - updateOperations([...operations, { ...EMPTY_OPERATION }]); - }; - - const removeOperation = (index: number) => { - updateOperations(operations.filter((_, i) => i !== index)); - }; - - const updateOperationType = (index: number, type: string | null) => { - updateOperations( - operations.map((operation, i) => - i === index ? { ...operation, type } : operation, - ), - ); - }; - - const updateOperationValue = (index: number, value: number) => { - updateOperations( - operations.map((operation, i) => - i === index ? { ...operation, value } : operation, - ), - ); - }; - - /** - * Re-fetch the selected plot's data (optionally with operations) and update it. - * Called with operations to apply them, or without to restore raw data. - */ - const updatePlotsData = async ( - action: 'apply' | 'restore', - operationsList?: string[], - ) => { - if (!selectedPlot) return; - try { - setLoadingAction(action); - const updatedDataPlot = structuredClone( - customizedDataGrid, - ) as DataGridPlot; - - const plot = updatedDataPlot.plot.find( - (p) => p.name === selectedPlot.name, - ); - if (!plot) return; - - const urisToInterpolate = getUrisToInterpolate( - plot.nodeUri, - updatedDataPlot.plot, - ); - const dataPlotOperated = await fetchDataPlot( - normalizeIndices(plot.nodeUri), - customizedDataGrid?.downsampled_method, - customizedDataGrid?.downsampled_size, - updatedDataPlot?.dataType, - urisToInterpolate, - customizedDataGrid?.interpolated_method, - buildSmoothingRequest(plot.smoothing), - operationsList, - ); - - // Realign coordinates with the returned data (a no-op when the operations - // preserve the shape; needed if the fetch auto-downsampled the data) - let coordinateIndex = 0; - for (const coordinate of updatedDataPlot.coordinates) { - coordinate.shape = - dataPlotOperated.data.coordinates[coordinateIndex].downsampled_shape; - coordinate.data = - dataPlotOperated.data.coordinates[coordinateIndex].value; - coordinateIndex++; - coordinate.range = [ - 0, - coordinate.shape[coordinate.shape.length - 1] - 1, - ]; - const firstArrayValueFromCoord = getFirstArrayValueFromShape( - coordinate.data, - coordinate.shape, - ); - coordinate.rangeValues = [ - firstArrayValueFromCoord[0], - firstArrayValueFromCoord[firstArrayValueFromCoord.length - 1], - ]; - } - - // Update only the selected plot with the new data - plot.shape = dataPlotOperated.data.downsampled_shape; - plot.x = getArrayValueFromDependance(updatedDataPlot.coordinates, 0); - plot.yData = dataPlotOperated.data.value; - plot.y = getVectorData(updatedDataPlot.coordinates, plot.yData); - if (action === 'restore') { - plot.operations = undefined; - } - - // Re-apply axis transposition on the re-fetched plot only: the back-end - // returns data in default axis order, so restore the user's transposition - const wantedAxeIndexOrder = customizedDataGrid.coordinates.map( - (coord) => coord.axeIndex, - ); - await reapplyAxisOrder(updatedDataPlot, wantedAxeIndexOrder, plot); - - setCustomizedDataGrid({ - ...customizedDataGrid, - coordinates: updatedDataPlot.coordinates, - plot: updatedDataPlot.plot, - }); - } catch (error) { - console.error('Error getting plot data: ', error); - showNotification({ - title: 'Error', - message: `Unable to get plot data.`, - color: 'red', - }); - } finally { - setLoadingAction(null); - } - }; - - /** - * Apply the operations to the selected plot - */ - const applyOperations = async () => { - const operationsList = formatOperations(operations); - if (!operationsList.length) { - showNotification({ - title: 'No operation', - message: 'Please add at least one operation.', - color: 'red', - }); - return; - } - await updatePlotsData('apply', operationsList); - }; - - /** - * Restore the selected plot by re-fetching it without operations - */ - const restoreData = async () => { - await updatePlotsData('restore'); - }; - - /* - * Get operation methods to show in select - */ - useEffect(() => { - const getOperationList = async () => { - const options = await getOperationMethods(); - setOperationMethods(options); - }; - getOperationList(); - }, []); - - return ( - - {operations.map((operation, index) => ( - -