diff --git a/frontend/src/renderer/components/grid/HoverButtons.tsx b/frontend/src/renderer/components/grid/HoverButtons.tsx index 8aba4dd9..bd11bf3d 100644 --- a/frontend/src/renderer/components/grid/HoverButtons.tsx +++ b/frontend/src/renderer/components/grid/HoverButtons.tsx @@ -17,7 +17,7 @@ import { IconEyeEdit, IconTrash, } from '@tabler/icons-react'; -import { useHover } from '@mantine/hooks'; +import { useElementSize, useHover, useMergedRef } from '@mantine/hooks'; import { Configuration, CustomizedGridType, DataGridPlot } from '../../types'; import { applyRange, @@ -52,6 +52,8 @@ export const HoverButtons = React.memo( }: HoverButtonsProps) => { const { active, updatedConfiguration } = useIbexStore(); const { hovered, ref: hoverRef } = useHover(); + const { ref: sizeRef, width: containerWidth } = useElementSize(); + const containerRef = useMergedRef(hoverRef, sizeRef); const previousValueDisplayErrorBands = useRef( undefined, ); @@ -185,7 +187,7 @@ export const HoverButtons = React.memo( }; return ( -
+
{is3DView || !data.coordinates.length || shouldDisplayMetadata ? ( diff --git a/frontend/src/renderer/layout/MainLayout.tsx b/frontend/src/renderer/layout/MainLayout.tsx index b45ee6a8..9eb2ad24 100644 --- a/frontend/src/renderer/layout/MainLayout.tsx +++ b/frontend/src/renderer/layout/MainLayout.tsx @@ -138,6 +138,8 @@ export function MainLayout() { line: plot?.line || {}, customPreferences: plot?.customPreferences || {}, mode: plot?.mode || 'line', + smoothing: plot?.smoothing, + operations: plot?.operations, }; }), }), diff --git a/frontend/src/renderer/pages/visualization/DataplotCustomization.tsx b/frontend/src/renderer/pages/visualization/DataplotCustomization.tsx index 9247ce6a..5ba3c576 100644 --- a/frontend/src/renderer/pages/visualization/DataplotCustomization.tsx +++ b/frontend/src/renderer/pages/visualization/DataplotCustomization.tsx @@ -28,6 +28,8 @@ import { CustomizeDataRange, CustomizeSynchronization, CustomizeInterpolation, + CustomizeSmoothing, + CustomizeUnaryOperations, } from './customizableElements'; import { IconLink } from '@tabler/icons-react'; import { initPlotColors } from '../../utils'; @@ -454,6 +456,26 @@ const Customization = ({ /> ), }, + { + value: 'Data smoothing', + component: ( + + ), + }, + { + value: 'Unary operations', + component: ( + + ), + }, ]; const items = ( diff --git a/frontend/src/renderer/pages/visualization/customizableElements/CustomizeDownsampling.tsx b/frontend/src/renderer/pages/visualization/customizableElements/CustomizeDownsampling.tsx index 8e5149ef..bebdbe54 100644 --- a/frontend/src/renderer/pages/visualization/customizableElements/CustomizeDownsampling.tsx +++ b/frontend/src/renderer/pages/visualization/customizableElements/CustomizeDownsampling.tsx @@ -9,6 +9,7 @@ import { getUrisToInterpolate, getVectorData, normalizeIndices, + reapplyAxisOrder, } from '../../../utils'; import { showNotification } from '@mantine/notifications'; import { Button, Group, NumberInput, Select, Stack } from '@mantine/core'; @@ -118,6 +119,13 @@ export const CustomizeDownsampling = ({ plotIndex++; } + // Re-apply axis transposition: the back-end returns data in default axis + // order, so restore the user's transposition after the fetch + const wantedAxeIndexOrder = customizedDataGrid.coordinates.map( + (coord) => coord.axeIndex, + ); + await reapplyAxisOrder(updatedDataPlot, wantedAxeIndexOrder); + // Save new configuration with downsampled data setCustomizedDataGrid({ ...customizedDataGrid, diff --git a/frontend/src/renderer/pages/visualization/customizableElements/CustomizeInterpolation.tsx b/frontend/src/renderer/pages/visualization/customizableElements/CustomizeInterpolation.tsx index 2a307c5c..03c29bb1 100644 --- a/frontend/src/renderer/pages/visualization/customizableElements/CustomizeInterpolation.tsx +++ b/frontend/src/renderer/pages/visualization/customizableElements/CustomizeInterpolation.tsx @@ -9,6 +9,7 @@ import { normalizeIndices, getInterpolationMethods, getUrisToInterpolate, + reapplyAxisOrder, } from '../../../utils'; import { showNotification } from '@mantine/notifications'; import { Group, Loader, Select, Stack } from '@mantine/core'; @@ -118,6 +119,13 @@ export const CustomizeInterpolation = ({ plotIndex++; } + // Re-apply axis transposition: the back-end returns data in default axis + // order, so restore the user's transposition after the fetch + const wantedAxeIndexOrder = customizedDataGrid.coordinates.map( + (coord) => coord.axeIndex, + ); + await reapplyAxisOrder(updatedDataPlot, wantedAxeIndexOrder); + // Save new configuration with interpolated data setCustomizedDataGrid({ ...customizedDataGrid, 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..1c7d8f6f --- /dev/null +++ b/frontend/src/renderer/pages/visualization/customizableElements/CustomizeSmoothing.tsx @@ -0,0 +1,364 @@ +import { useEffect, useState } from 'react'; +import { DataGridPlot, DataPlotly, SmoothingParams } from '../../../types'; +import { + buildSmoothingRequest, + DEFAULT_GAUSSIAN_SMOOTHING_SIGMA, + DEFAULT_SAVGOL_CVAL, + DEFAULT_SAVGOL_DELTA, + DEFAULT_SAVGOL_DERIV, + DEFAULT_SAVGOL_MODE, + DEFAULT_SAVGOL_POLYORDER, + DEFAULT_SAVGOL_WINDOW_LENGTH, + fetchDataPlot, + formatOperations, + GAUSSIAN_FILTER, + getArrayValueFromDependance, + getFirstArrayValueFromShape, + getSmoothingMethods, + getUrisToInterpolate, + getVectorData, + normalizeIndices, + reapplyAxisOrder, + SAVGOL_FILTER, +} from '../../../utils'; +import { showNotification } from '@mantine/notifications'; +import { Button, Group, NumberInput, Select, Stack } from '@mantine/core'; +import { IconRestore } from '@tabler/icons-react'; +import { OptionWithTooltip } from '../../../types/components/select'; +import { RenderSelectOption } from '../../../components/select'; + +const SAVGOL_MODES = ['mirror', 'constant', 'nearest', 'wrap', 'interp']; + +interface CustomizeSmoothingProps { + customizedDataGrid: DataGridPlot; + selectedPlot: DataPlotly | null; + setCustomizedDataGrid: React.Dispatch>; +} +export const CustomizeSmoothing = ({ + customizedDataGrid, + selectedPlot, + setCustomizedDataGrid, +}: CustomizeSmoothingProps) => { + const [smoothingMethods, setSmoothingMethods] = useState( + [], + ); + const [loadingAction, setLoadingAction] = useState< + 'apply' | 'restore' | null + >(null); + + // Source of truth is the selected plot's own smoothing config (persisted on the grid) + const smoothing = selectedPlot?.smoothing; + const smoothingMethod = smoothing?.smoothing_method ?? null; + + /** + * Persist the smoothing config onto the selected plot + */ + const updateSmoothing = (next: SmoothingParams | undefined) => { + if (!selectedPlot) return; + const updated = structuredClone(customizedDataGrid) as DataGridPlot; + const plot = updated.plot.find((p) => p.name === selectedPlot.name); + if (plot) { + plot.smoothing = next; + } + setCustomizedDataGrid({ ...customizedDataGrid, plot: updated.plot }); + }; + + const updateSmoothingMethod = (method: string | null) => { + updateSmoothing( + method ? { ...smoothing, smoothing_method: method } : undefined, + ); + }; + + const updateSmoothingParam = ( + param: keyof SmoothingParams, + value: number | string, + ) => { + if (!smoothing) return; + updateSmoothing({ ...smoothing, [param]: value }); + }; + + /** + * Re-fetch the selected plot's data (optionally with smoothing) and update it. + * Called with smoothing params to apply smoothing, or without to restore raw data. + */ + const updatePlotsData = async ( + action: 'apply' | 'restore', + smoothingParams?: SmoothingParams, + ) => { + 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 dataPlotSmoothed = await fetchDataPlot( + normalizeIndices(plot.nodeUri), + customizedDataGrid?.downsampled_method, + customizedDataGrid?.downsampled_size, + updatedDataPlot?.dataType, + urisToInterpolate, + customizedDataGrid?.interpolated_method, + smoothingParams, + formatOperations(plot.operations), + ); + + // Realign coordinates with the returned data (a no-op when smoothing + // preserves the shape; needed if the fetch auto-downsampled the data) + let coordinateIndex = 0; + for (const coordinate of updatedDataPlot.coordinates) { + coordinate.shape = + dataPlotSmoothed.data.coordinates[coordinateIndex].downsampled_shape; + coordinate.data = + dataPlotSmoothed.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 = dataPlotSmoothed.data.downsampled_shape; + plot.x = getArrayValueFromDependance(updatedDataPlot.coordinates, 0); + plot.yData = dataPlotSmoothed.data.value; + plot.y = getVectorData(updatedDataPlot.coordinates, plot.yData); + if (action === 'restore') { + plot.smoothing = 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 selected smoothing method to the selected plot + */ + const getSmoothedData = async () => { + const smoothingParams = buildSmoothingRequest(smoothing); + if (!smoothingParams) { + showNotification({ + title: 'No smoothing method', + message: 'Please select a smoothing method.', + color: 'red', + }); + return; + } + await updatePlotsData('apply', smoothingParams); + }; + + /** + * Restore the selected plot by re-fetching it without smoothing + */ + const restoreData = async () => { + await updatePlotsData('restore'); + }; + + /* + * Get smoothing methods to show in select + */ + useEffect(() => { + const getSmoothingList = async () => { + const options = await getSmoothingMethods(); + setSmoothingMethods(options); + }; + getSmoothingList(); + }, []); + + return ( + + + + updateSmoothingParam( + 'savgol_smoothing_mode', + value ?? DEFAULT_SAVGOL_MODE, + ) + } + w="45%" + maw={200} + /> + + updateSmoothingParam('savgol_smoothing_cval', value) + } + w="45%" + maw={200} + /> + + + )} + + + + + + + ); +}; 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..5e5ab8f0 --- /dev/null +++ b/frontend/src/renderer/pages/visualization/customizableElements/CustomizeUnaryOperations.tsx @@ -0,0 +1,277 @@ +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) => ( + +