diff --git a/docs/source/users_manual/features/customization.rst b/docs/source/users_manual/features/customization.rst index 5cba350c..b6a06743 100644 --- a/docs/source/users_manual/features/customization.rst +++ b/docs/source/users_manual/features/customization.rst @@ -55,15 +55,33 @@ Applies a smoothing method to reduce noise while preserving the overall shape of :alt: Data smoothing section of the customization :align: center -Unary operations +Data operations -------------------- -Applies an ordered list of scalar operations to every data point of the plot: +Applies an ordered list of operations to every data point of the plot. Each row +has a **kind**, which decides what the plot is combined with: -.. image:: images/customization_unary_operations.png - :alt: Unary operations section of the customization +* **Constant**: a scalar value that you type in. Available operations are addition, + subtraction, multiplication, division, exponentiation and nth root. +* **Signal**: another plot of the same graph, combined point by point. Available + operations are addition, subtraction, multiplication and division. This kind + requires at least two plots in the graph, otherwise it stays disabled. + +.. image:: images/customization_data_operations.png + :alt: Data operations section of the customization :align: center +A few things to keep in mind when combining two signals: + +* All constant operations are applied before all signal operations, whatever the + order of the rows. +* Both signals must share the same shape and the same coordinates. When they do + not, either select an interpolation method in the *Interpolation* section or + the server reports which of the two has to be interpolated. +* An addition or a subtraction requires both signals to share the same unit, + while a multiplication or a division combines them (for example ``m`` and + ``s`` become ``m/s``). + Visual customization ---------------------------- diff --git a/docs/source/users_manual/features/images/customization_data_main.png b/docs/source/users_manual/features/images/customization_data_main.png index 3b976ad7..ab0b411c 100644 Binary files a/docs/source/users_manual/features/images/customization_data_main.png and b/docs/source/users_manual/features/images/customization_data_main.png differ diff --git a/docs/source/users_manual/features/images/customization_data_operations.png b/docs/source/users_manual/features/images/customization_data_operations.png new file mode 100644 index 00000000..ba871869 Binary files /dev/null and b/docs/source/users_manual/features/images/customization_data_operations.png differ diff --git a/docs/source/users_manual/features/images/customization_unary_operations.png b/docs/source/users_manual/features/images/customization_unary_operations.png deleted file mode 100644 index eddcebaa..00000000 Binary files a/docs/source/users_manual/features/images/customization_unary_operations.png and /dev/null differ diff --git a/frontend/src/renderer/components/grid/HoverButtons.tsx b/frontend/src/renderer/components/grid/HoverButtons.tsx index 157645bc..d841f996 100644 --- a/frontend/src/renderer/components/grid/HoverButtons.tsx +++ b/frontend/src/renderer/components/grid/HoverButtons.tsx @@ -19,7 +19,7 @@ import { IconTrash, IconTarget, } from '@tabler/icons-react'; -import { useHover } from '@mantine/hooks'; +import { useElementSize, useHover, useMergedRef } from '@mantine/hooks'; import { Configuration, CustomizedGridType, @@ -55,6 +55,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, ); @@ -196,7 +198,7 @@ export const HoverButtons = React.memo( }; return ( -
+
{is3DView || !data.coordinates.length || shouldDisplayMetadata ? ( diff --git a/frontend/src/renderer/components/plot/SimplePlotly.tsx b/frontend/src/renderer/components/plot/SimplePlotly.tsx index de95fe58..28e2d7fa 100644 --- a/frontend/src/renderer/components/plot/SimplePlotly.tsx +++ b/frontend/src/renderer/components/plot/SimplePlotly.tsx @@ -198,7 +198,7 @@ export const SimplePlotly = ({ title: { text: title }, })); - if (!itemDataGrid.isEditing) { + if (!itemDataGrid.isEditing || title === itemDataGrid.title) { return; } diff --git a/frontend/src/renderer/layout/MainLayout.tsx b/frontend/src/renderer/layout/MainLayout.tsx index 18d07a68..342d6756 100644 --- a/frontend/src/renderer/layout/MainLayout.tsx +++ b/frontend/src/renderer/layout/MainLayout.tsx @@ -17,6 +17,7 @@ import { PreferenceModal } from '../components/preferences/PreferenceModal'; import { updateIbexConfig, formatConfigBeforeLoadingURIs, + formatSignalOperandsToSave, plotNodeUriLoaded, updateCustomDataTree, readIbexConfig, @@ -144,6 +145,13 @@ export function MainLayout() { line: plot?.line || {}, customPreferences: plot?.customPreferences || {}, mode: plot?.mode || 'line', + smoothing: plot?.smoothing, + // Signal operands are stored the same way as nodeUri, so that they + // are remapped onto the selected data entries when reloading + operations: formatSignalOperandsToSave( + plot?.operations, + dataGrid.plot, + ), }; }), }), diff --git a/frontend/src/renderer/pages/visualization/DataplotCustomization.tsx b/frontend/src/renderer/pages/visualization/DataplotCustomization.tsx index 02541835..9e3e3375 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, + CustomizeDataOperations, CustomizeGeometry, } from './customizableElements'; import { IconGeometry, IconLink } from '@tabler/icons-react'; @@ -60,6 +62,9 @@ export const DataplotCustomization = () => { downsampled_method: customizedDataGrid?.downsampled_method, interpolated_method: customizedDataGrid.interpolated_method, plot: customizedDataGrid?.plot, + // A data operation can change a unit and move a plot to the second axis + yAxisData: customizedDataGrid?.yAxisData, + y2AxisData: customizedDataGrid?.y2AxisData, } as DataGridPlot; setDataGridLayout(updatedDataGridLayout); setSelectedPlot( @@ -481,6 +486,26 @@ const Customization = ({ /> ), }, + { + value: 'Data smoothing', + component: ( + + ), + }, + { + value: 'Data operations', + component: ( + + ), + }, ]; const items = ( diff --git a/frontend/src/renderer/pages/visualization/VisualizationMetaData.tsx b/frontend/src/renderer/pages/visualization/VisualizationMetaData.tsx index b8e8953a..c9c2ce13 100644 --- a/frontend/src/renderer/pages/visualization/VisualizationMetaData.tsx +++ b/frontend/src/renderer/pages/visualization/VisualizationMetaData.tsx @@ -265,10 +265,11 @@ export const VisualizationMetaData = () => { ); if (data) { setDataGridLayout(data); - setTabsValue(data.plot[0]?.name || null); - const findPlot = data.plot.find( - (item) => item.name === data.plot[0]?.name, - ); + const selectedName = data.plot.some((item) => item.name === tabsValue) + ? tabsValue + : data.plot[0]?.name || null; + setTabsValue(selectedName); + const findPlot = data.plot.find((item) => item.name === selectedName); if (findPlot) { setItemDataGrid({ ...data, diff --git a/frontend/src/renderer/pages/visualization/customizableElements/CustomizeDataOperations.tsx b/frontend/src/renderer/pages/visualization/customizableElements/CustomizeDataOperations.tsx new file mode 100644 index 00000000..af6adf1d --- /dev/null +++ b/frontend/src/renderer/pages/visualization/customizableElements/CustomizeDataOperations.tsx @@ -0,0 +1,504 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + DataGridPlot, + DataOperation, + DataPlotly, + OperationKind, + SignalOperation, + UnaryOperation, +} from '../../../types'; +import { + buildSmoothingRequest, + fetchDataPlot, + formatOperations, + formatSignalOperations, + getArrayValueFromDependance, + getFirstArrayValueFromShape, + getOperationKind, + getOperationMethods, + getSignalOperationMethods, + getUrisToInterpolate, + getVectorData, + isNotifiedError, + isSignalOperation, + normalizeIndices, + reapplyAxisOrder, + resolveYAxisForUnit, +} from '../../../utils'; +import { showNotification } from '@mantine/notifications'; +import { + ActionIcon, + Button, + Group, + NumberInput, + Select, + Stack, + Text, + Tooltip, +} from '@mantine/core'; +import { + IconCheck, + IconMinus, + IconPlus, + IconRestore, +} from '@tabler/icons-react'; + +const DEFAULT_UNARY_VALUE = 1; +const EMPTY_UNARY_OPERATION: UnaryOperation = { + kind: 'unary', + type: null, + value: DEFAULT_UNARY_VALUE, +}; +const EMPTY_SIGNAL_OPERATION: SignalOperation = { + kind: 'signal', + type: null, + value: null, +}; + +const SIGNAL_KIND_TOOLTIP = + 'At least two plots are required for a signal operation'; + +const OPERATION_KINDS: { value: OperationKind; label: string }[] = [ + { value: 'unary', label: 'Constant' }, + { value: 'signal', label: 'Signal' }, +]; + +interface CustomizeDataOperationsProps { + customizedDataGrid: DataGridPlot; + selectedPlot: DataPlotly | null; + setCustomizedDataGrid: React.Dispatch>; +} +export const CustomizeDataOperations = ({ + customizedDataGrid, + selectedPlot, + setCustomizedDataGrid, +}: CustomizeDataOperationsProps) => { + const [unaryMethods, setUnaryMethods] = useState< + { value: string; label: string }[] + >([]); + const [signalMethods, setSignalMethods] = 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_UNARY_OPERATION }]; + + // A signal operation can only target another plot of the same grid + const operandOptions = useMemo( + () => + (customizedDataGrid?.plot ?? []) + .filter( + (plot) => plot.nodeUri && plot.nodeUri !== selectedPlot?.nodeUri, + ) + .map((plot) => ({ + value: plot.nodeUri, + label: plot.name || plot.nodeUri, + })), + [customizedDataGrid?.plot, selectedPlot?.nodeUri], + ); + const isSignalKindDisabled = operandOptions.length === 0; + + /** + * Persist the operations rows onto the selected plot + */ + const updateOperations = (next: DataOperation[]) => { + if (!selectedPlot) return; + const updated = structuredClone(customizedDataGrid) as DataGridPlot; + const plot = updated.plot.find((p) => p.nodeUri === selectedPlot.nodeUri); + if (plot) { + plot.operations = next; + } + setCustomizedDataGrid({ ...customizedDataGrid, plot: updated.plot }); + }; + + const addOperation = () => { + // Keep the kind of the last row so that building several signal operations + // in a row does not require switching the kind every time + const lastOperation = operations[operations.length - 1]; + const addSignalOperation = + !isSignalKindDisabled && + lastOperation && + getOperationKind(lastOperation) === 'signal'; + updateOperations([ + ...operations, + addSignalOperation + ? { ...EMPTY_SIGNAL_OPERATION } + : { ...EMPTY_UNARY_OPERATION }, + ]); + }; + + const removeOperation = (index: number) => { + updateOperations(operations.filter((_, i) => i !== index)); + }; + + /** + * Switch a row between a constant and a signal operand. The row is replaced + * rather than merged because the value changes type along with the kind. + */ + const updateOperationKind = (index: number, kind: OperationKind) => { + updateOperations( + operations.map((operation, i) => { + if (i !== index || getOperationKind(operation) === kind) { + return operation; + } + // Keep the operation type only when the target kind supports it: + // add / sub / mul / div are shared, pow and root are constant only + const methods = kind === 'signal' ? signalMethods : unaryMethods; + const keptType = methods.some( + (method) => method.value === operation.type, + ) + ? operation.type + : null; + return kind === 'signal' + ? { ...EMPTY_SIGNAL_OPERATION, type: keptType } + : { ...EMPTY_UNARY_OPERATION, type: keptType }; + }), + ); + }; + + const updateOperationType = (index: number, type: string | null) => { + updateOperations( + operations.map((operation, i) => + i === index ? { ...operation, type } : operation, + ), + ); + }; + + const updateOperationValue = (index: number, value: string | number) => { + // NumberInput emits an empty string when the field is cleared + const numericValue = typeof value === 'number' ? value : Number(value); + updateOperations( + operations.map((operation, i) => + i === index && !isSignalOperation(operation) + ? { + ...operation, + value: Number.isFinite(numericValue) + ? numericValue + : DEFAULT_UNARY_VALUE, + } + : operation, + ), + ); + }; + + const updateOperationSignal = (index: number, value: string | null) => { + updateOperations( + operations.map((operation, i) => + i === index && isSignalOperation(operation) + ? { ...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[], + signalOperationsList?: string[], + ) => { + if (!selectedPlot) return; + try { + setLoadingAction(action); + const updatedDataPlot = structuredClone( + customizedDataGrid, + ) as DataGridPlot; + + const plot = updatedDataPlot.plot.find( + (p) => p.nodeUri === selectedPlot.nodeUri, + ); + 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, + signalOperationsList, + ); + + // 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); + // A multiplication or a division between signals changes the unit, which + // may require moving the plot to the secondary y axis + const newUnit = dataPlotOperated.data.unit; + if (!resolveYAxisForUnit(updatedDataPlot, plot, newUnit)) { + showNotification({ + title: 'Too many units', + message: `This operation produces data in "${newUnit}", but the graph already uses two y axes. Remove a signal from the graph or change the operation.`, + color: 'red', + }); + return; + } + + 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, + yAxisData: updatedDataPlot.yAxisData, + y2AxisData: updatedDataPlot.y2AxisData, + }); + } catch (error) { + console.error('Error getting plot data: ', error); + if (!isNotifiedError(error)) { + // The server already details why an operation could not be applied + 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); + const signalOperationsList = formatSignalOperations(operations); + + const hasIncompleteSignalOperation = operations.some( + (operation) => + isSignalOperation(operation) && operation.type && !operation.value, + ); + if (hasIncompleteSignalOperation) { + showNotification({ + title: 'Incomplete operation', + message: 'Please select a signal for every signal operation.', + color: 'red', + }); + return; + } + + if (!operationsList.length && !signalOperationsList.length) { + showNotification({ + title: 'No operation', + message: 'Please add at least one operation.', + color: 'red', + }); + return; + } + await updatePlotsData('apply', operationsList, signalOperationsList); + }; + + /** + * Restore the selected plot by re-fetching it without operations + */ + const restoreData = async () => { + await updatePlotsData('restore'); + }; + + /* + * Get operation methods to show in selects + */ + useEffect(() => { + const getOperationList = async () => { + const [unaryOptions, signalOptions] = await Promise.all([ + getOperationMethods(), + getSignalOperationMethods(), + ]); + setUnaryMethods(unaryOptions); + setSignalMethods(signalOptions); + }; + getOperationList(); + }, []); + + const hasMixedKinds = + operations.some(isSignalOperation) && + operations.some((operation) => !isSignalOperation(operation)); + + return ( + + {operations.map((operation, index) => ( + + updateOperationType(index, value)} + disabled={loadingAction !== null} + data-testid={`data-operation-type-${index}`} + w="30%" + maw={200} + /> + {isSignalOperation(operation) ? ( + meth.value)} + onChange={updateSmoothingMethod} + renderOption={(option) => { + const selectedOption = smoothingMethods.find( + (meth) => option.option.value === meth.value, + ); + return ( + + ); + }} + /> + + + {smoothingMethod === GAUSSIAN_FILTER && ( + + + updateSmoothingParam('gaussian_smoothing_sigma', value) + } + onValueChange={(payload, context) => { + const isSteppedChange = (context.source as string) !== 'event'; + if ( + isSteppedChange && + Number.isFinite(payload.floatValue) && + payload.floatValue < MIN_GAUSSIAN_SMOOTHING_SIGMA + ) { + updateSmoothingParam( + 'gaussian_smoothing_sigma', + MIN_GAUSSIAN_SMOOTHING_SIGMA, + ); + } + }} + error={isSigmaInvalid ? SIGMA_ERROR : undefined} + w="45%" + maw={200} + min={MIN_GAUSSIAN_SMOOTHING_SIGMA} + /> + + )} + + {smoothingMethod === SAVGOL_FILTER && ( + <> + + + updateSmoothingParam('savgol_smoothing_window_length', value) + } + w="45%" + maw={200} + min={1} + allowDecimal={false} + /> + + updateSmoothingParam('savgol_smoothing_polyorder', value) + } + w="45%" + maw={200} + min={0} + allowDecimal={false} + /> + + + + updateSmoothingParam('savgol_smoothing_deriv', value) + } + w="45%" + maw={200} + min={0} + allowDecimal={false} + /> + + updateSmoothingParam('savgol_smoothing_delta', value) + } + w="45%" + maw={200} + /> + + +