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 1/9] 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 2/9] 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 3/9] 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) => ( + +