From 94c0c68c484670cc26c9ab16632fd4b7e1569c8b Mon Sep 17 00:00:00 2001 From: bug-sentinel Date: Mon, 4 Nov 2024 09:58:48 +0100 Subject: [PATCH 1/7] docs: fix typo in README.md (#789) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dbeddafe4..d89e9fba6 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ in the following folders are changed: ./backend/src ``` -If other files are changed through the host operativey system, +If other files are changed through the host operating system, e.g. typically when a new dependency is added, the relevant component needs to be rebuilt. I.e. `docker-compose up --build frontend` or `docker-compose up --build backend`. From cc98040cdf10b6dd1f2003da76df0b91f64f4f3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8rgen=20Herje?= <82032112+jorgenherje@users.noreply.github.com> Date: Mon, 4 Nov 2024 14:13:58 +0100 Subject: [PATCH 2/7] Upgrade realization filtering (#741) Co-authored-by: Ruben Thoms - Improved/upgraded realization filter - Adjustments to SmartNodeSelector --- frontend/src/framework/RealizationFilter.ts | 333 ++++++++++-- .../parameterListFilter.tsx | 2 +- .../internal/components/Drawer/drawer.tsx | 2 +- .../ensembleRealizationFilter.tsx | 327 ++++++++++++ .../EnsembleRealizationFilter/index.ts | 2 + .../private-assets/folder.svg | 3 + .../private-assets/misc.svg | 1 + .../byParameterValueFilter.tsx | 473 ++++++++++++++++++ .../byRealizationNumberFilter.tsx | 135 +++++ .../realizationNumberDisplay.tsx | 114 +++++ .../private-utils/conversionUtils.ts | 69 +++ .../private-utils/realizationPickerUtils.ts | 48 ++ .../private-utils/sliderUtils.ts | 32 ++ .../private-utils/smartNodeSelectorUtils.ts | 108 ++++ .../ModuleInstanceLog/moduleInstanceLog.tsx | 5 +- .../realizationFilterSettings.tsx | 444 +++++++++------- .../utils/dataTypeConversion.ts | 48 -- .../framework/types/realizationFilterTypes.ts | 24 + frontend/src/framework/utils/arrayUtils.ts | 27 + .../utils/realizationFilterTypesUtils.ts | 113 +++++ .../private-utils/treeNodeSelection.ts | 6 + .../SmartNodeSelector/smartNodeSelector.tsx | 39 +- .../settings/settings.tsx | 2 +- .../settings/settings.tsx | 2 +- ...leRealizationFilterConversionUtils.test.ts | 58 +++ ...zationFilterRealizationPickerUtils.test.ts | 155 ++++++ ...zationFilterSmartNodeSelectorUtils.test.ts | 310 ++++++++++++ frontend/tests/unit/RealizationFilter.test.ts | 422 +++++++++++++++- frontend/tests/unit/arrayUtils.test.ts | 58 +++ .../unit/realizationFilterTypesUtils.test.ts | 214 ++++++++ 30 files changed, 3249 insertions(+), 327 deletions(-) create mode 100644 frontend/src/framework/internal/components/EnsembleRealizationFilter/ensembleRealizationFilter.tsx create mode 100644 frontend/src/framework/internal/components/EnsembleRealizationFilter/index.ts create mode 100644 frontend/src/framework/internal/components/EnsembleRealizationFilter/private-assets/folder.svg create mode 100644 frontend/src/framework/internal/components/EnsembleRealizationFilter/private-assets/misc.svg create mode 100644 frontend/src/framework/internal/components/EnsembleRealizationFilter/private-components/byParameterValueFilter.tsx create mode 100644 frontend/src/framework/internal/components/EnsembleRealizationFilter/private-components/byRealizationNumberFilter.tsx create mode 100644 frontend/src/framework/internal/components/EnsembleRealizationFilter/private-components/realizationNumberDisplay.tsx create mode 100644 frontend/src/framework/internal/components/EnsembleRealizationFilter/private-utils/conversionUtils.ts create mode 100644 frontend/src/framework/internal/components/EnsembleRealizationFilter/private-utils/realizationPickerUtils.ts create mode 100644 frontend/src/framework/internal/components/EnsembleRealizationFilter/private-utils/sliderUtils.ts create mode 100644 frontend/src/framework/internal/components/EnsembleRealizationFilter/private-utils/smartNodeSelectorUtils.ts delete mode 100644 frontend/src/framework/internal/components/RightSettingsPanel/private-components/RealizationFilterSettings/utils/dataTypeConversion.ts create mode 100644 frontend/src/framework/types/realizationFilterTypes.ts create mode 100644 frontend/src/framework/utils/arrayUtils.ts create mode 100644 frontend/src/framework/utils/realizationFilterTypesUtils.ts create mode 100644 frontend/tests/unit/EnsembleRealizationFilterConversionUtils.test.ts create mode 100644 frontend/tests/unit/EnsembleRealizationFilterRealizationPickerUtils.test.ts create mode 100644 frontend/tests/unit/EnsembleRealizationFilterSmartNodeSelectorUtils.test.ts create mode 100644 frontend/tests/unit/arrayUtils.test.ts create mode 100644 frontend/tests/unit/realizationFilterTypesUtils.test.ts diff --git a/frontend/src/framework/RealizationFilter.ts b/frontend/src/framework/RealizationFilter.ts index 4849ca598..63c29e9a3 100644 --- a/frontend/src/framework/RealizationFilter.ts +++ b/frontend/src/framework/RealizationFilter.ts @@ -2,32 +2,51 @@ import { isEqual } from "lodash"; import { Ensemble } from "./Ensemble"; import { EnsembleIdent } from "./EnsembleIdent"; +import { + ContinuousParameter, + DiscreteParameter, + EnsembleParameters, + Parameter, + ParameterIdent, + ParameterType, +} from "./EnsembleParameters"; +import { + DiscreteParameterValueSelection, + IncludeExcludeFilter, + NumberRange, + ParameterValueSelection, + RealizationFilterType, + RealizationNumberSelection, +} from "./types/realizationFilterTypes"; +import { isArrayOfNumbers, isArrayOfStrings } from "./utils/arrayUtils"; +import { + isValueSelectionAnArrayOfNumber, + isValueSelectionAnArrayOfString, + makeRealizationNumberArrayFromSelections, +} from "./utils/realizationFilterTypesUtils"; -export enum RealizationFilterType { - REALIZATION_INDEX = "realizationIndex", -} -export const RealizationFilterTypeStringMapping = { - [RealizationFilterType.REALIZATION_INDEX]: "Realization index", -}; - -export enum IncludeExcludeFilter { - INCLUDE_FILTER = "includeFilter", - EXCLUDE_FILTER = "excludeFilter", -} -export const IncludeExcludeFilterEnumToStringMapping = { - [IncludeExcludeFilter.INCLUDE_FILTER]: "Include Filter", - [IncludeExcludeFilter.EXCLUDE_FILTER]: "Exclude Filter", -}; - -export type IndexRange = { start: number; end: number }; -export type RealizationIndexSelection = IndexRange | number; - +/** + * Class for filtering realizations based on realization number or parameter values. + * + * The class is designed to be used in conjunction with the Ensemble class. + * + * The class is designed to keep track of the filtering state and provide the filtered realizations + * for an ensemble. + * + * Should not provide interface to get the Ensemble object itself, but can provide access to information about the ensemble, + * such as the ensemble ident and realization numbers. + */ export class RealizationFilter { private _assignedEnsemble: Ensemble; private _includeExcludeFilter: IncludeExcludeFilter; private _filterType: RealizationFilterType; - private _realizationIndexSelections: readonly RealizationIndexSelection[] | null; + private _realizationNumberSelections: readonly RealizationNumberSelection[] | null; + + // Map of parameterIdent string to value selection (Both continuous and discrete parameters) + // - Map vs object: { [parameterIdentString: string]: ParameterValueSelection } - object? + // - Consider array of pairs: [ParameterIdent, NumberRange] where ParameterIdents must be unique + private _parameterIdentStringToValueSelectionMap: ReadonlyMap | null; // Internal array for ref stability private _filteredRealizations: readonly number[]; @@ -35,38 +54,62 @@ export class RealizationFilter { constructor( assignedEnsemble: Ensemble, initialIncludeExcludeFilter = IncludeExcludeFilter.INCLUDE_FILTER, - initialFilterType = RealizationFilterType.REALIZATION_INDEX + initialFilterType = RealizationFilterType.BY_REALIZATION_NUMBER ) { this._assignedEnsemble = assignedEnsemble; this._includeExcludeFilter = initialIncludeExcludeFilter; this._filterType = initialFilterType; this._filteredRealizations = assignedEnsemble.getRealizations(); - this._realizationIndexSelections = null; + this._realizationNumberSelections = null; + this._parameterIdentStringToValueSelectionMap = null; } getAssignedEnsembleIdent(): EnsembleIdent { return this._assignedEnsemble.getIdent(); } - setRealizationIndexSelections(selections: readonly RealizationIndexSelection[] | null): void { - this._realizationIndexSelections = selections; + setRealizationNumberSelections(selections: readonly RealizationNumberSelection[] | null): void { + this._realizationNumberSelections = selections; + } + + setParameterIdentStringToValueSelectionReadonlyMap( + newMap: ReadonlyMap | null + ): void { + // Validate parameterIdent strings + if (newMap !== null) { + for (const [parameterIdentStr, valueSelection] of newMap) { + const parameterIdent = ParameterIdent.fromString(parameterIdentStr); + const parameter = this._assignedEnsemble.getParameters().findParameter(parameterIdent); + if (!parameter) { + throw new Error( + `Invalid parameterIdent string "${parameterIdentStr}" for ensemble ${this._assignedEnsemble.getIdent()}` + ); + } - // Update internal array if resulting realizations has changed - if (this._filterType === RealizationFilterType.REALIZATION_INDEX) { - this.runSelectedRealizationIndexFiltering(); + RealizationFilter.validateParameterAndValueSelection(parameter, valueSelection); + } } + + this._parameterIdentStringToValueSelectionMap = newMap; + } + + getRealizationNumberSelections(): readonly RealizationNumberSelection[] | null { + return this._realizationNumberSelections; } - getRealizationIndexSelections(): readonly RealizationIndexSelection[] | null { - return this._realizationIndexSelections; + getParameterIdentStringToValueSelectionReadonlyMap(): ReadonlyMap | null { + if (this._parameterIdentStringToValueSelectionMap === null) { + return null; + } + + return this._parameterIdentStringToValueSelectionMap; } setFilterType(filterType: RealizationFilterType): void { if (filterType === this._filterType) return; this._filterType = filterType; - this.runFiltering(); } getFilterType(): RealizationFilterType { @@ -75,7 +118,6 @@ export class RealizationFilter { setIncludeOrExcludeFilter(value: IncludeExcludeFilter): void { this._includeExcludeFilter = value; - this.runFiltering(); } getIncludeOrExcludeFilter(): IncludeExcludeFilter { @@ -86,44 +128,229 @@ export class RealizationFilter { return this._filteredRealizations; } - private runFiltering(): void { - if (this._filterType !== RealizationFilterType.REALIZATION_INDEX) return; + runFiltering(): void { + if ( + this._filterType !== RealizationFilterType.BY_REALIZATION_NUMBER && + this._filterType !== RealizationFilterType.BY_PARAMETER_VALUES + ) { + throw new Error(`Invalid filter type ${this._filterType}`); + } - this.runSelectedRealizationIndexFiltering(); + if (this._filterType === RealizationFilterType.BY_REALIZATION_NUMBER) { + this.runRealizationNumberSelectionFiltering(); + return; + } + this.runParameterValueSelectionsFiltering(); + } + + static createFilteredRealizationsFromRealizationNumberSelection( + realizationNumberSelections: readonly RealizationNumberSelection[] | null, + validRealizations: readonly number[], + includeOrExclude: IncludeExcludeFilter + ): readonly number[] { + let newFilteredRealizations = validRealizations; + + // If realization number selection is provided, filter the realizations + if (realizationNumberSelections !== null) { + // Create array from realization number selection + const selectedRealizationNumbers: number[] = + makeRealizationNumberArrayFromSelections(realizationNumberSelections); + + newFilteredRealizations = RealizationFilter.createIncludeOrExcludeFilteredRealizationsArray( + includeOrExclude, + selectedRealizationNumbers, + validRealizations + ); + } + return newFilteredRealizations; + } + + static createIncludeOrExcludeFilteredRealizationsArray( + includeOrExclude: IncludeExcludeFilter, + selectedRealizations: readonly number[], + validRealizations: readonly number[] + ): readonly number[] { + if (includeOrExclude === IncludeExcludeFilter.INCLUDE_FILTER) { + return selectedRealizations.filter((elm) => validRealizations.includes(elm)); + } + + // Corrected to exclude values existing in sourceRealizations + return validRealizations.filter((elm) => !selectedRealizations.includes(elm)); + } + + private runRealizationNumberSelectionFiltering(): void { + const newFilteredRealizations = RealizationFilter.createFilteredRealizationsFromRealizationNumberSelection( + this._realizationNumberSelections, + this._assignedEnsemble.getRealizations(), + this._includeExcludeFilter + ); + + if (!isEqual(newFilteredRealizations, this._filteredRealizations)) { + this._filteredRealizations = newFilteredRealizations; + } } - private runSelectedRealizationIndexFiltering(): void { - let newFilteredRealizations = this._assignedEnsemble.getRealizations(); + static createFilteredRealizationsFromParameterValueSelections( + parameterIdentStringToValueSelectionMap: ReadonlyMap | null, + validParameters: EnsembleParameters, + validRealizations: readonly number[] + ): readonly number[] { + let newFilteredRealizations = validRealizations; + + if (parameterIdentStringToValueSelectionMap !== null) { + const parameters = validParameters; + + // Apply value selection filter per parameter with AND logic + for (const [parameterIdentString, valueSelection] of parameterIdentStringToValueSelectionMap) { + const parameterIdent = ParameterIdent.fromString(parameterIdentString); + const parameter = parameters.findParameter(parameterIdent); + if (!parameter) { + continue; + } - // If realization index selection is provided, filter the realizations - if (this._realizationIndexSelections !== null) { - // Create index array from realization index selection - const realizationIndexArray: number[] = []; - this._realizationIndexSelections.forEach((elm) => { - if (typeof elm === "number") { - realizationIndexArray.push(elm); - } else { - realizationIndexArray.push( - ...Array.from({ length: elm.end - elm.start + 1 }, (_, i) => elm.start + i) + // Validation of parameters and value selections are performed in setter, + // thus invalid selections are ignored + const isValueSelectionArray = + isValueSelectionAnArrayOfString(valueSelection) || isValueSelectionAnArrayOfNumber(valueSelection); + let realizationsFromValueSelection: number[] | null = null; + if (parameter.type === ParameterType.DISCRETE && isValueSelectionArray) { + // Run discrete parameter filtering + realizationsFromValueSelection = this.getRealizationNumbersFromParameterValueArray( + parameter, + valueSelection ); + } else if (parameter.type === ParameterType.CONTINUOUS && !isValueSelectionArray) { + // Run continuous parameter filtering + realizationsFromValueSelection = this.getRealizationNumbersFromParameterValueRange( + parameter, + valueSelection + ); + } + + if (realizationsFromValueSelection === null) { + continue; } - }); - newFilteredRealizations = this.createIncludeOrExcludeFilteredRealizationsArray(realizationIndexArray); + // Intersect with new filtered realization array + newFilteredRealizations = newFilteredRealizations.filter((elm) => { + if (realizationsFromValueSelection === null) { + throw new Error(`realizationsFromValueSelection is null`); + } + return realizationsFromValueSelection.includes(elm); + }); + } } + return newFilteredRealizations; + } + + private runParameterValueSelectionsFiltering(): void { + const newFilteredRealizations = RealizationFilter.createFilteredRealizationsFromParameterValueSelections( + this._parameterIdentStringToValueSelectionMap, + this._assignedEnsemble.getParameters(), + this._assignedEnsemble.getRealizations() + ); + if (!isEqual(newFilteredRealizations, this._filteredRealizations)) { this._filteredRealizations = newFilteredRealizations; } } - private createIncludeOrExcludeFilteredRealizationsArray(sourceRealizations: readonly number[]): readonly number[] { - const validRealizations = this._assignedEnsemble.getRealizations(); + static getRealizationNumbersFromParameterValueRange( + parameter: ContinuousParameter, + valueRange: Readonly + ): number[] { + // Get indices of values within range + const valueIndicesWithinRange: number[] = []; + for (const [index, value] of parameter.values.entries()) { + if (value >= valueRange.start && value <= valueRange.end) { + valueIndicesWithinRange.push(index); + } + } + + // Find the realization numbers at indices + // - Assuming realizations and values to be same length + return valueIndicesWithinRange.map((index) => parameter.realizations[index]); + } + + static getRealizationNumbersFromParameterValueArray( + parameter: DiscreteParameter, + selectedValueArray: DiscreteParameterValueSelection + ): number[] { + if (selectedValueArray.length === 0 || parameter.values.length === 0) { + return []; + } - if (this._includeExcludeFilter === IncludeExcludeFilter.INCLUDE_FILTER) { - return sourceRealizations.filter((elm) => validRealizations.includes(elm)); + const isStringValueSelection = isArrayOfStrings(selectedValueArray); + const isNumberValues = isArrayOfNumbers(parameter.values); + if (isStringValueSelection && isNumberValues) { + throw new Error( + `Parameter ${parameter.name} is discrete with number values, but value selection is string` + ); } - return validRealizations.filter((elm) => !sourceRealizations.includes(elm)); + const isNumberValueSelection = isArrayOfNumbers(selectedValueArray); + const isStringValues = isArrayOfStrings(parameter.values); + if (isNumberValueSelection && isStringValues) { + throw new Error( + `Parameter ${parameter.name} is discrete with string values, but value selection is number` + ); + } + + const valueIndices: number[] = []; + + // Find indices of string values + if (isStringValueSelection && isStringValues) { + for (const [index, value] of parameter.values.entries()) { + if (selectedValueArray.includes(value)) { + valueIndices.push(index); + } + } + return valueIndices.map((index) => parameter.realizations[index]); + } + + // Find indices of number values + if (isNumberValueSelection && isNumberValues) { + for (const [index, value] of parameter.values.entries()) { + if (selectedValueArray.includes(value)) { + valueIndices.push(index); + } + } + return valueIndices.map((index) => parameter.realizations[index]); + } + + throw new Error(`Parameter ${parameter.name} is discrete with mixed string and number values`); + } + + static validateParameterAndValueSelection(parameter: Parameter, valueSelection: ParameterValueSelection) { + if (parameter.type === ParameterType.CONTINUOUS && Array.isArray(valueSelection)) { + throw new Error(`Parameter ${parameter.name} is continuous, but value selection is not a NumberRange`); + } + if (parameter.type === ParameterType.DISCRETE && !Array.isArray(valueSelection)) { + throw new Error(`Parameter ${parameter.name} is discrete, but value selection is not an array`); + } + + if ( + parameter.type === ParameterType.DISCRETE && + isArrayOfNumbers(parameter.values) && + !isValueSelectionAnArrayOfNumber(valueSelection) + ) { + // Using !isValueSelectionAnArrayOfNumber, as isValueSelectionAnArrayOfString(valueSelection) is true + // for empty array + throw new Error( + `Parameter ${parameter.name} is discrete with number values, but value selection is strings` + ); + } + if ( + parameter.type === ParameterType.DISCRETE && + isArrayOfStrings(parameter.values) && + !isValueSelectionAnArrayOfString(valueSelection) + ) { + // Using !isValueSelectionAnArrayOfString, as isValueSelectionAnArrayOfNumber(valueSelection) is true + // for empty array + throw new Error( + `Parameter ${parameter.name} is discrete with string values, but value selection is numbers` + ); + } } } diff --git a/frontend/src/framework/components/ParameterListFilter/parameterListFilter.tsx b/frontend/src/framework/components/ParameterListFilter/parameterListFilter.tsx index 39c546be8..2c2f3abca 100644 --- a/frontend/src/framework/components/ParameterListFilter/parameterListFilter.tsx +++ b/frontend/src/framework/components/ParameterListFilter/parameterListFilter.tsx @@ -74,7 +74,7 @@ export const ParameterListFilter: React.FC = (props: P ); function handleSmartNodeSelectorChange(selection: SmartNodeSelectorSelection) { - setSelectedTags(selection.selectedTags); + setSelectedTags(selection.selectedTags.map((tag) => tag.text)); setSelectedNodes(selection.selectedNodes); } diff --git a/frontend/src/framework/internal/components/Drawer/drawer.tsx b/frontend/src/framework/internal/components/Drawer/drawer.tsx index e586a2b2f..974f4eae1 100644 --- a/frontend/src/framework/internal/components/Drawer/drawer.tsx +++ b/frontend/src/framework/internal/components/Drawer/drawer.tsx @@ -29,7 +29,7 @@ export const Drawer: React.FC = (props) => { )} -
+
{props.showFilter && (
| null; // For ByParameterValueFilter + filterType: RealizationFilterType; + includeOrExcludeFilter: IncludeExcludeFilter; +}; + +export type EnsembleRealizationFilterProps = { + selections: EnsembleRealizationFilterSelections; + hasUnsavedSelections: boolean; + ensembleName: string; + availableEnsembleRealizations: readonly number[]; + ensembleParameters: EnsembleParameters; + isActive: boolean; + isAnotherFilterActive: boolean; + onClick?: () => void; + onHeaderClick?: () => void; + onFilterChange?: (newSelections: EnsembleRealizationFilterSelections) => void; + onApplyClick?: () => void; + onDiscardClick?: () => void; +}; + +/** + * Component for visualizing and handling of realization filtering for an Ensemble. + * + * Realization filter is used to filter ensemble realizations based on selected realization number or parameter values. + * The selection creates a valid subset of realization numbers for the ensemble throughout the application. + */ +export const EnsembleRealizationFilter: React.FC = (props) => { + const { onClick, onHeaderClick, onFilterChange, onApplyClick, onDiscardClick } = props; + + // States for handling initial realization number selections and smart node selector tags + // - When undefined, the initial value will be calculated on next render + const [initialRealizationNumberSelections, setInitialRealizationNumberSelections] = React.useState< + readonly RealizationNumberSelection[] | null | undefined + >(props.selections.realizationNumberSelections); + + // Update initial realization number selection due to conditional rendering + let actualInitialRealizationNumberSelections = initialRealizationNumberSelections; + + // Reset the initial number selections to the current realization number selections when set to undefined + if (actualInitialRealizationNumberSelections === undefined) { + setInitialRealizationNumberSelections(props.selections.realizationNumberSelections); + actualInitialRealizationNumberSelections = props.selections.realizationNumberSelections; + } + + function handleRealizationNumberFilterChanged(selection: ByRealizationNumberFilterSelection) { + if (!onFilterChange) { + return; + } + + // Create realization number array to display based on current selection + const realizationNumberArray = RealizationFilter.createFilteredRealizationsFromRealizationNumberSelection( + selection.realizationNumberSelections, + props.availableEnsembleRealizations, + selection.includeOrExcludeFilter + ); + + onFilterChange({ + ...props.selections, + displayRealizationNumbers: realizationNumberArray, + realizationNumberSelections: selection.realizationNumberSelections, + includeOrExcludeFilter: selection.includeOrExcludeFilter, + }); + } + + function handleParameterValueFilterChanged( + newParameterIdentStringToValueSelectionMap: ReadonlyMap | null + ) { + if (!onFilterChange) { + return; + } + + // Create realization number array to display based on current selection + const realizationNumberArray = RealizationFilter.createFilteredRealizationsFromParameterValueSelections( + newParameterIdentStringToValueSelectionMap, + props.ensembleParameters, + props.availableEnsembleRealizations + ); + + onFilterChange({ + ...props.selections, + displayRealizationNumbers: realizationNumberArray, + parameterIdentStringToValueSelectionReadonlyMap: newParameterIdentStringToValueSelectionMap, + }); + } + + function handleActiveFilterTypeChange(newFilterType: RealizationFilterType) { + if (!onFilterChange) { + return; + } + + // Create realization number array to display based on current selection + let realizationNumberArray: readonly number[] = []; + if (newFilterType === RealizationFilterType.BY_REALIZATION_NUMBER) { + // Reset initial value to be calculated next render to ensure correct visualization when + // mounting realization number filter component + setInitialRealizationNumberSelections(undefined); + + // Update realization numbers based on current selection + realizationNumberArray = RealizationFilter.createFilteredRealizationsFromRealizationNumberSelection( + props.selections.realizationNumberSelections, + props.availableEnsembleRealizations, + props.selections.includeOrExcludeFilter + ); + } else if (newFilterType === RealizationFilterType.BY_PARAMETER_VALUES) { + // Create realization number array to display based on current parameters + realizationNumberArray = RealizationFilter.createFilteredRealizationsFromParameterValueSelections( + props.selections.parameterIdentStringToValueSelectionReadonlyMap, + props.ensembleParameters, + props.availableEnsembleRealizations + ); + } + + onFilterChange({ + ...props.selections, + filterType: newFilterType, + displayRealizationNumbers: realizationNumberArray, + }); + } + + function handleRealizationNumberDisplayClick(displayRealizationNumbers: readonly number[]) { + if (!onFilterChange) { + return; + } + + // Create number selection based on the current display realization numbers + let candidateSelectedRealizationNumbers = displayRealizationNumbers; + if (props.selections.includeOrExcludeFilter === IncludeExcludeFilter.EXCLUDE_FILTER) { + // Invert selection for exclude filter + candidateSelectedRealizationNumbers = props.availableEnsembleRealizations.filter( + (realization) => !displayRealizationNumbers.includes(realization) + ); + } + + // Create realization number selections based on the current selection and available realization numbers + const newRealizationNumberSelections = createBestSuggestedRealizationNumberSelections( + candidateSelectedRealizationNumbers, + props.availableEnsembleRealizations + ); + + onFilterChange({ + ...props.selections, + displayRealizationNumbers: displayRealizationNumbers, + realizationNumberSelections: newRealizationNumberSelections, + }); + } + + function handleBodyOnClickCapture(e: React.MouseEvent) { + // Capture click event on the body to prevent drilling down to child elements when filter is inactive + if (props.isActive) { + return; + } + + e.stopPropagation(); + if (onClick) { + onClick(); + } + } + + function handleApplyClick() { + // Reset states for initialization on next render + setInitialRealizationNumberSelections(undefined); + + if (onApplyClick) { + onApplyClick(); + } + } + + function handleDiscardClick() { + // Reset states for initialization on next render + setInitialRealizationNumberSelections(undefined); + + if (onDiscardClick) { + onDiscardClick(); + } + } + + function handleHeaderOnClick() { + if (props.isActive && onHeaderClick) { + onHeaderClick(); + } + if (!props.isActive && onClick) { + onClick(); + } + } + + return ( +
+
+
+ {props.ensembleName} +
+
+
+
+
+
+
+ +
+
+
+ +
+ +
+
+ +
+
+
+
+
+
+ ); +}; diff --git a/frontend/src/framework/internal/components/EnsembleRealizationFilter/index.ts b/frontend/src/framework/internal/components/EnsembleRealizationFilter/index.ts new file mode 100644 index 000000000..d5742f4ca --- /dev/null +++ b/frontend/src/framework/internal/components/EnsembleRealizationFilter/index.ts @@ -0,0 +1,2 @@ +export { EnsembleRealizationFilter } from "./ensembleRealizationFilter"; +export type { EnsembleRealizationFilterSelections } from "./ensembleRealizationFilter"; diff --git a/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-assets/folder.svg b/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-assets/folder.svg new file mode 100644 index 000000000..772d01bfd --- /dev/null +++ b/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-assets/folder.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-assets/misc.svg b/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-assets/misc.svg new file mode 100644 index 000000000..68aad48c9 --- /dev/null +++ b/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-assets/misc.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-components/byParameterValueFilter.tsx b/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-components/byParameterValueFilter.tsx new file mode 100644 index 000000000..67f71e982 --- /dev/null +++ b/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-components/byParameterValueFilter.tsx @@ -0,0 +1,473 @@ +import React from "react"; + +import { EnsembleParameters, ParameterIdent, ParameterType } from "@framework/EnsembleParameters"; +import { + DiscreteParameterValueSelection, + NumberRange, + ParameterValueSelection, +} from "@framework/types/realizationFilterTypes"; +import { isArrayOfNumbers, isArrayOfStrings } from "@framework/utils/arrayUtils"; +import { + isValueSelectionAnArrayOfNumber, + isValueSelectionAnArrayOfString, +} from "@framework/utils/realizationFilterTypesUtils"; +import { Button } from "@lib/components/Button"; +import { DenseIconButton } from "@lib/components/DenseIconButton"; +import { DenseIconButtonColorScheme } from "@lib/components/DenseIconButton/denseIconButton"; +import { Label } from "@lib/components/Label"; +import { Slider } from "@lib/components/Slider"; +import { SmartNodeSelector, SmartNodeSelectorSelection, TreeDataNode } from "@lib/components/SmartNodeSelector"; +import { SmartNodeSelectorTag } from "@lib/components/SmartNodeSelector/smartNodeSelector"; +import { TagPicker } from "@lib/components/TagPicker"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import { AddCircle, Delete, Report } from "@mui/icons-material"; + +import { createContinuousValueSliderStep } from "../private-utils/sliderUtils"; +import { + createSmartNodeSelectorTagTextFromParameterIdentString, + createSmartNodeSelectorTagTextListFromParameterIdentStrings, + createTreeDataNodeListFromParameters, +} from "../private-utils/smartNodeSelectorUtils"; + +export type ByParameterValueFilterProps = { + ensembleParameters: EnsembleParameters; // Should be stable object - both content and reference + parameterIdentStringToValueSelectionReadonlyMap: ReadonlyMap | null; + disabled: boolean; + onFilterChange: ( + newParameterIdentStringToValueSelectionMap: ReadonlyMap | null + ) => void; +}; + +export const ByParameterValueFilter: React.FC = (props) => { + const { ensembleParameters, parameterIdentStringToValueSelectionReadonlyMap, onFilterChange } = props; + + const [smartNodeSelectorSelection, setSmartNodeSelectorSelection] = React.useState({ + selectedIds: [], + selectedNodes: [], + selectedTags: [], + }); + + // Compare by reference - ensembleParameters should be stable object + const smartNodeSelectorTreeDataNodes = React.useMemo(() => { + const includeConstantParameters = false; + const includeNodeDescription = false; // Node description and name seems to be the same, i.e. duplicate information + return createTreeDataNodeListFromParameters( + ensembleParameters.getParameterArr(), + includeConstantParameters, + includeNodeDescription + ); + }, [ensembleParameters]); + + const handleParameterNameSelectionChanged = React.useCallback( + function handleParameterNameSelectionChanged(selection: SmartNodeSelectorSelection) { + setSmartNodeSelectorSelection(selection); + }, + [setSmartNodeSelectorSelection] + ); + + const handleAddSelectedParametersClick = React.useCallback( + function handleAddSelectedParametersClick() { + // Find new parameter ident strings that are not in the current map + // NOTE: This is not a deep copy + const newMap = new Map(parameterIdentStringToValueSelectionReadonlyMap); + + // Get selected parameter ident strings + const selectedParameterIdentStrings = smartNodeSelectorSelection.selectedIds; + + // Find parameter ident strings not in the current map + const newParameterIdentStrings = selectedParameterIdentStrings.filter((elm) => !newMap.has(elm)); + + // Add new selected parameter ident strings + const newDiscreteValueSelection: Readonly = []; + for (const parameterIdentString of newParameterIdentStrings) { + const parameter = ensembleParameters.findParameter(ParameterIdent.fromString(parameterIdentString)); + if (!parameter) { + continue; + } + + let newParameterValueSelection: ParameterValueSelection = newDiscreteValueSelection; + if (parameter.type === ParameterType.CONTINUOUS) { + const max = Math.max(...parameter.values); + const min = Math.min(...parameter.values); + const numberRange: Readonly = { start: min, end: max }; + newParameterValueSelection = numberRange; + } + + // Update value selection with .set() + // - Do not use .get() and modify by reference, as .get() will return reference to source, + // i.e. parameterIdentStringToValueSelectionReadonlyMap. Thus modifying the value + // will modify the source, which is not allowed. + newMap.set(parameterIdentString, newParameterValueSelection); + } + + const nonEmptyMap = newMap.size > 0 ? (newMap as ReadonlyMap) : null; + + // Trigger filter change + onFilterChange(nonEmptyMap); + + // Clear SmartNodeSelector selection + setSmartNodeSelectorSelection({ + selectedIds: [], + selectedNodes: [], + selectedTags: [], + }); + }, + [ + ensembleParameters, + parameterIdentStringToValueSelectionReadonlyMap, + smartNodeSelectorSelection, + setSmartNodeSelectorSelection, + onFilterChange, + ] + ); + + const setNewParameterValueSelectionAndTriggerOnChange = React.useCallback( + function setNewParameterValueSelectionAndTriggerOnChange( + parameterIdentString: string, + valueSelection: ParameterValueSelection + ) { + // Update existing map + // NOTE: This is not a deep copy + const updatedMap = new Map(parameterIdentStringToValueSelectionReadonlyMap); + if (!updatedMap.has(parameterIdentString)) { + throw new Error(`Edited Parameter ident string ${parameterIdentString} not found in map`); + } + + // Update value selection with .set() + // - Do not use .get() and modify by reference, as .get() will return reference to source, + // i.e. parameterIdentStringToValueSelectionReadonlyMap. Thus modifying the value + // will modify the source, which is not allowed. + updatedMap.set(parameterIdentString, valueSelection); + + // Trigger filter change + onFilterChange(updatedMap as ReadonlyMap); + }, + [parameterIdentStringToValueSelectionReadonlyMap, onFilterChange] + ); + + const handleContinuousParameterValueRangeChange = React.useCallback( + function handleContinuousParameterValueRangeChange(parameterIdentString: string, valueSelection: number[]) { + if (valueSelection.length !== 2) { + throw new Error(`Value selection must have 2 values`); + } + + const parameter = ensembleParameters.findParameter(ParameterIdent.fromString(parameterIdentString)); + if (!parameter) { + throw new Error(`Parameter ${parameterIdentString} not found`); + } + if (parameter.type !== ParameterType.CONTINUOUS) { + throw new Error(`Parameter ${parameterIdentString} is not of type continuous`); + } + if ( + parameterIdentStringToValueSelectionReadonlyMap && + !parameterIdentStringToValueSelectionReadonlyMap.has(parameterIdentString) + ) { + throw new Error(`Edited Parameter ident string ${parameterIdentString} not found in map`); + } + + const newRangeSelection: Readonly = { start: valueSelection[0], end: valueSelection[1] }; + + setNewParameterValueSelectionAndTriggerOnChange(parameterIdentString, newRangeSelection); + }, + [ + ensembleParameters, + parameterIdentStringToValueSelectionReadonlyMap, + setNewParameterValueSelectionAndTriggerOnChange, + ] + ); + + const handleDiscreteParameterValueSelectionChange = React.useCallback( + function handleDiscreteParameterValueSelectionChange( + parameterIdentString: string, + valueSelection: string[] | number[] + ) { + const parameter = ensembleParameters.findParameter(ParameterIdent.fromString(parameterIdentString)); + if (!parameter) { + throw new Error(`Parameter ${parameterIdentString} not found`); + } + if (parameter.type !== ParameterType.DISCRETE) { + throw new Error(`Parameter ${parameterIdentString} is not of type discrete`); + } + if ( + parameterIdentStringToValueSelectionReadonlyMap && + !parameterIdentStringToValueSelectionReadonlyMap.has(parameterIdentString) + ) { + throw new Error(`Edited Parameter ident string ${parameterIdentString} not found in map`); + } + + const newDiscreteValueSelection: Readonly = valueSelection; + + setNewParameterValueSelectionAndTriggerOnChange(parameterIdentString, newDiscreteValueSelection); + }, + [ + ensembleParameters, + parameterIdentStringToValueSelectionReadonlyMap, + setNewParameterValueSelectionAndTriggerOnChange, + ] + ); + + const handleRemoveButtonClick = React.useCallback( + function handleRemoveButtonClick(parameterIdentString: string) { + if ( + parameterIdentStringToValueSelectionReadonlyMap && + !parameterIdentStringToValueSelectionReadonlyMap.has(parameterIdentString) + ) { + throw new Error(`Parameter ${parameterIdentString} not found`); + } + + // Create a new map by selecting keys from the original map, excluding the specified key + // NOTE: This is not a deep copy + const newMap = new Map(parameterIdentStringToValueSelectionReadonlyMap); + newMap.delete(parameterIdentString); + + const nonEmptyMap = newMap.size > 0 ? (newMap as ReadonlyMap) : null; + + // Trigger filter change + onFilterChange(nonEmptyMap); + }, + [parameterIdentStringToValueSelectionReadonlyMap, onFilterChange] + ); + + function createContinuousParameterValueRangeRow( + parameterIdentString: string, + valueSelection: Readonly + ): React.ReactNode { + const parameterIdent = ParameterIdent.fromString(parameterIdentString); + const parameterMinMax = ensembleParameters.getContinuousParameterMinMax(parameterIdent); + + return ( + + handleContinuousParameterValueRangeChange(parameterIdentString, newValue as number[]) + } + /> + ); + } + + function createDiscreteParameterValueSelectionRow( + parameterIdentString: string, + valueSelection: DiscreteParameterValueSelection + ): React.ReactNode { + const parameterIdent = ParameterIdent.fromString(parameterIdentString); + const parameter = ensembleParameters.getParameter(parameterIdent); + if (!parameter) { + throw new Error(`Parameter ${parameterIdentString} not found`); + } + + if (isArrayOfStrings(valueSelection) && isArrayOfStrings(parameter.values)) { + const uniqueValues = Array.from(new Set([...parameter.values])); + return ( + + value={[...valueSelection]} + tags={uniqueValues.map((elm) => { + return { label: elm, value: elm }; + })} + onChange={(value) => handleDiscreteParameterValueSelectionChange(parameterIdentString, value)} + /> + ); + } + + if (isArrayOfNumbers(valueSelection) && isArrayOfNumbers(parameter.values)) { + const uniqueValues = Array.from(new Set([...parameter.values])); + return ( + + value={valueSelection.map((elm) => elm)} + tags={uniqueValues.map((elm) => { + return { label: elm.toString(), value: elm }; + })} + onChange={(value) => handleDiscreteParameterValueSelectionChange(parameterIdentString, value)} + /> + ); + } + + throw new Error( + `Invalid value selection type. Selection is ${valueSelection} and parameter values is ${parameter.values}` + ); + } + + function createParameterValueSelectionRow( + parameterIdentString: string, + valueSelection: ParameterValueSelection + ): React.ReactNode { + const displayParameterName = createSmartNodeSelectorTagTextFromParameterIdentString(parameterIdentString); + + return ( +
+
+
+
+ {displayParameterName} +
+ handleRemoveButtonClick(parameterIdentString)} + > + + +
+
+
+ {isValueSelectionAnArrayOfString(valueSelection) || + isValueSelectionAnArrayOfNumber(valueSelection) + ? createDiscreteParameterValueSelectionRow(parameterIdentString, valueSelection) + : createContinuousParameterValueRangeRow(parameterIdentString, valueSelection)} +
+
+
+
+ ); + } + + // Create info text and enable/disable states for icon and button + const invalidTags = smartNodeSelectorSelection.selectedTags.filter((tag) => !tag.isValid); + const existingParameterIdentStrings = Array.from(parameterIdentStringToValueSelectionReadonlyMap?.keys() ?? []); + + // Text and disabled state for "Add button" + const { text: addButtonText, isDisabled: isAddButtonDisabled } = createAddButtonTextAndDisableState( + existingParameterIdentStrings, + smartNodeSelectorSelection.selectedIds, + invalidTags + ); + + // Text and visibility state for report/warning icon + const { text: reportIconText, isVisible: isReportIconVisible } = createReportIconTextAndVisibleState( + existingParameterIdentStrings, + smartNodeSelectorSelection.selectedIds + ); + + return ( +
+
+
+
+ {"Select parameters to add"} +
+
+ +
+
+
+
+ tag.text)} + onChange={handleParameterNameSelectionChanged} + placeholder="Add parameter..." + caseInsensitiveMatching={true} + /> +
+
+ +
+
+
+ {parameterIdentStringToValueSelectionReadonlyMap && ( + + )} +
+ ); +}; + +/** + * Text and disabled state for add parameter button + * + * The button is disabled if: + * - There are invalid tags + * - There are no selected parameters + * - All selected parameters are already added + */ +function createAddButtonTextAndDisableState( + existingParameterIdentStrings: string[], + selectedParameterIdentStrings: string[], + invalidTags: SmartNodeSelectorTag[] +): { text: string | null; isDisabled: boolean } { + if (invalidTags.length === 1) { + return { text: "Invalid parameter selected", isDisabled: true }; + } + if (invalidTags.length > 1) { + return { text: "Invalid parameters selected", isDisabled: true }; + } + if (selectedParameterIdentStrings.length === 0) { + return { text: "No parameter to add", isDisabled: true }; + } + + const newParameterIdentStrings = selectedParameterIdentStrings.filter( + (selectedId) => !existingParameterIdentStrings.includes(selectedId) + ); + if (newParameterIdentStrings.length === 0 && selectedParameterIdentStrings.length === 1) { + return { text: "Parameter already added", isDisabled: true }; + } + if (newParameterIdentStrings.length === 0 && selectedParameterIdentStrings.length > 1) { + return { text: "Parameters already added", isDisabled: true }; + } + if (newParameterIdentStrings.length === selectedParameterIdentStrings.length) { + const text = newParameterIdentStrings.length === 1 ? "Add parameter" : "Add parameters"; + return { text, isDisabled: false }; + } + + // Some selected parameters are already added + const newParameterTags = createSmartNodeSelectorTagTextListFromParameterIdentStrings(newParameterIdentStrings); + if (newParameterTags.length === 1) { + return { text: "Add parameter:\n" + newParameterTags[0], isDisabled: false }; + } + return { text: "Add parameters:\n" + newParameterTags.join("\n"), isDisabled: false }; +} + +/** + * Text and visible state for report icon + * + * The icon is visible if one or more selected parameters are already added + */ +function createReportIconTextAndVisibleState( + existingParameterIdentStrings: string[], + selectedParameterIdentStrings: string[] +): { text: string | null; isVisible: boolean } { + const alreadySelectedParameterIdentStrings = selectedParameterIdentStrings.filter((selectedId) => + existingParameterIdentStrings.includes(selectedId) + ); + const alreadySelectedParameterTagTexts = createSmartNodeSelectorTagTextListFromParameterIdentStrings( + alreadySelectedParameterIdentStrings + ); + if (alreadySelectedParameterTagTexts.length === 1 && selectedParameterIdentStrings.length >= 1) { + return { text: `Parameter already added:\n${alreadySelectedParameterTagTexts[0]}`, isVisible: true }; + } + if (alreadySelectedParameterTagTexts.length > 1) { + return { text: `Parameters already added:\n${alreadySelectedParameterTagTexts.join("\n")}`, isVisible: true }; + } + return { text: null, isVisible: false }; +} diff --git a/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-components/byRealizationNumberFilter.tsx b/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-components/byRealizationNumberFilter.tsx new file mode 100644 index 000000000..908b48967 --- /dev/null +++ b/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-components/byRealizationNumberFilter.tsx @@ -0,0 +1,135 @@ +import React from "react"; + +import { RealizationPicker, RealizationPickerSelection } from "@framework/components/RealizationPicker"; +import { IncludeExcludeFilter, RealizationNumberSelection } from "@framework/types/realizationFilterTypes"; +import { Label } from "@lib/components/Label"; +import { RadioGroup } from "@lib/components/RadioGroup"; + +import { isEqual } from "lodash"; + +import { + makeRealizationNumberSelectionsFromRealizationPickerTags, + makeRealizationPickerTagsFromRealizationNumberSelections, +} from "../private-utils/realizationPickerUtils"; + +export interface ByRealizationNumberFilterSelection { + realizationNumberSelections: RealizationNumberSelection[] | null; + includeOrExcludeFilter: IncludeExcludeFilter; +} + +export type ByRealizationNumberFilterProps = { + disabled: boolean; + initialRealizationNumberSelections?: readonly RealizationNumberSelection[] | null; + realizationNumberSelections: readonly RealizationNumberSelection[] | null; + availableRealizationNumbers: readonly number[]; + selectedIncludeOrExcludeFilter: IncludeExcludeFilter; + onFilterChange: (selection: ByRealizationNumberFilterSelection) => void; +}; + +export const ByRealizationNumberFilter: React.FC = (props) => { + const { onFilterChange } = props; + + const [prevInitialRealizationNumberSelections, setPrevInitialRealizationNumberSelections] = React.useState< + readonly RealizationNumberSelection[] | null + >(props.initialRealizationNumberSelections ?? null); + const [prevRealizationNumberSelections, setPrevRealizationNumberSelections] = React.useState< + readonly RealizationNumberSelection[] | null + >(props.realizationNumberSelections); + + const [initialRangeTags, setInitialRangeTags] = React.useState( + props.initialRealizationNumberSelections + ? makeRealizationPickerTagsFromRealizationNumberSelections(props.initialRealizationNumberSelections) + : [] + ); + const [selectedRangeTags, setSelectedRangeTags] = React.useState( + props.realizationNumberSelections + ? makeRealizationPickerTagsFromRealizationNumberSelections(props.realizationNumberSelections) + : [] + ); + + if (!isEqual(props.initialRealizationNumberSelections, prevInitialRealizationNumberSelections)) { + if (!props.initialRealizationNumberSelections) { + setInitialRangeTags([]); + setPrevInitialRealizationNumberSelections(null); + } else { + setInitialRangeTags( + makeRealizationPickerTagsFromRealizationNumberSelections(props.initialRealizationNumberSelections) + ); + setPrevInitialRealizationNumberSelections(props.initialRealizationNumberSelections); + } + } + + if (!isEqual(props.realizationNumberSelections, prevRealizationNumberSelections)) { + if (!props.realizationNumberSelections) { + setSelectedRangeTags([]); + } else { + setSelectedRangeTags( + makeRealizationPickerTagsFromRealizationNumberSelections(props.realizationNumberSelections) + ); + } + setPrevRealizationNumberSelections(props.realizationNumberSelections); + } + + const handleIncludeExcludeFilterChange = React.useCallback( + function handleIncludeExcludeFilterChange(newFilter: IncludeExcludeFilter) { + // Make selections from tags to ensure consistency with user interface + const newRealizationNumberSelections = + selectedRangeTags.length === 0 + ? null + : makeRealizationNumberSelectionsFromRealizationPickerTags(selectedRangeTags); + + onFilterChange({ + realizationNumberSelections: newRealizationNumberSelections, + includeOrExcludeFilter: newFilter, + }); + }, + [onFilterChange, selectedRangeTags] + ); + + const handleRealizationPickChange = React.useCallback( + function handleRealizationPickChange(newSelection: RealizationPickerSelection) { + const newRealizationNumberSelections = + newSelection.selectedRangeTags.length === 0 + ? null + : makeRealizationNumberSelectionsFromRealizationPickerTags(newSelection.selectedRangeTags); + + onFilterChange({ + realizationNumberSelections: newRealizationNumberSelections, + includeOrExcludeFilter: props.selectedIncludeOrExcludeFilter, + }); + }, + [onFilterChange, props.selectedIncludeOrExcludeFilter] + ); + + return ( +
+ + +
+ ); +}; diff --git a/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-components/realizationNumberDisplay.tsx b/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-components/realizationNumberDisplay.tsx new file mode 100644 index 000000000..8439ab57b --- /dev/null +++ b/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-components/realizationNumberDisplay.tsx @@ -0,0 +1,114 @@ +import React from "react"; + +import { useElementSize } from "@lib/hooks/useElementSize"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; + +import { isEqual } from "lodash"; + +export type RealizationNumberDisplayProps = { + selectedRealizations: readonly number[]; + availableRealizations: readonly number[]; + showAsCompact?: boolean; + disableOnClick: boolean; + onRealizationNumberClick: (selectedRealizations: readonly number[]) => void; +}; +export const RealizationNumberDisplay: React.FC = (props) => { + const divRef = React.useRef(null); + const divSize = useElementSize(divRef); + + const [prevSelectedRealizations, setPrevSelectedRealizations] = React.useState(); + const [allRealizationsInRange, setAllRealizationsInRange] = React.useState( + Array.from({ length: Math.max(...props.availableRealizations) + 1 }, (_, i) => i) + ); + + if (!isEqual(props.selectedRealizations, prevSelectedRealizations)) { + setPrevSelectedRealizations(props.selectedRealizations); + setAllRealizationsInRange(Array.from({ length: Math.max(...props.availableRealizations) + 1 }, (_, i) => i)); + } + + function handleRealizationElementClick(realization: number) { + if (props.disableOnClick) { + return; + } + if (!props.selectedRealizations.includes(realization)) { + // Add the realization to the selected realizations + props.onRealizationNumberClick([...props.selectedRealizations, realization]); + return; + } + // Remove the realization from the selected realizations + const newRealizationNumberSelections = props.selectedRealizations.filter( + (selectedRealization) => selectedRealization !== realization + ); + props.onRealizationNumberClick(newRealizationNumberSelections); + } + + function createRealizationNumberVisualization(isCompact: boolean, numRealizationPerRow: number): React.ReactNode { + const mainDivElements: JSX.Element[] = []; + + // Compact/non-compact div size and gap class definitions + const gapClass = isCompact ? "gap-[3px]" : "gap-[4px]"; + const realizationDivSizeClass = isCompact ? "w-[9px] h-[9px]" : "w-[12px] h-[12px]"; + + let rowElmCounter = 0; + let rowCounter = 0; + let rowElements: JSX.Element[] = []; + for (const [index, realization] of allRealizationsInRange.entries()) { + const isCurrentRealizationAvailable = props.availableRealizations.includes(realization); + const isRealizationSelected = props.selectedRealizations.includes(realization); + const isClickDisabled = props.disableOnClick || !isCurrentRealizationAvailable; + if (rowElmCounter === 0) { + rowElements = []; + } + const realizationDiv = ( +
handleRealizationElementClick(realization)} + /> + ); + rowElements.push(realizationDiv); + + // If the row is full (or last realization), add it to the main div elements and reset counter + const isLastRealization = index === allRealizationsInRange.length - 1; + if (++rowElmCounter === numRealizationPerRow || isLastRealization) { + const rowDiv = ( +
+ {[...rowElements]} +
+ ); + mainDivElements.push(rowDiv); + rowElmCounter = 0; + rowCounter++; + } + } + return
{mainDivElements}
; + } + + // Compact and non-compact element width and gap (Must be in sync with the CSS in createRealizationNumberVisualization() function) + const nonCompactGapPx = 4; + const nonCompactWidthAndHeightPx = 12; + + // Find the number of realizations that can fit in a row based on non-compact size, as factor of 5 + const candidateNumberOfRealizationsPerRow = Math.floor( + divSize.width / (nonCompactWidthAndHeightPx + nonCompactGapPx) + ); + const remainder = candidateNumberOfRealizationsPerRow % 5; + const newNumberOfRealizationsPerRow = + remainder === 0 ? candidateNumberOfRealizationsPerRow : candidateNumberOfRealizationsPerRow - remainder; + + return ( +
+ {createRealizationNumberVisualization(props.showAsCompact ?? false, newNumberOfRealizationsPerRow)} +
+ ); +}; diff --git a/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-utils/conversionUtils.ts b/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-utils/conversionUtils.ts new file mode 100644 index 000000000..4eec941e7 --- /dev/null +++ b/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-utils/conversionUtils.ts @@ -0,0 +1,69 @@ +import { RealizationNumberSelection } from "@framework/types/realizationFilterTypes"; + +import { isEqual } from "lodash"; + +/** + * Create the best suggested realization number selections from an array of realization numbers and an array of valid realization numbers. + * + * Sequences of valid realization numbers are combined into range. Separate realization numbers are kept as is. This implies that the realization + * numbers are combined into range, based on continuous sequences within the valid realization numbers array. + */ +export function createBestSuggestedRealizationNumberSelections( + selectedRealizationNumbers: readonly number[], + validRealizationNumbers: readonly number[] +): readonly RealizationNumberSelection[] | null { + // Sort arrays and remove duplicates + const validRealizations = [...new Set(validRealizationNumbers)].sort((a, b) => a - b); + const selectedRealizations = [...new Set(selectedRealizationNumbers)] + .filter((num) => validRealizations.includes(num)) + .sort((a, b) => a - b); + + if (selectedRealizations.length === 0) { + return []; + } + if (selectedRealizations.length === 1) { + return [selectedRealizations[0]]; + } + if (isEqual(selectedRealizations, validRealizations)) { + return null; + } + + // Create realization number selections, if the realization numbers creates a continuous sequence within the valid realization numbers + // it should be defined as a range. + // Example: + // - const selectedRealizations = [1, 2, 4, 6, 7, 8, 10, 12, 14]; + // - const validRealizations = [1, 2, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16]; + // - Results in: [{ start: 1, end: 4 },{ start: 6, end: 8 }, 10, 12, 14] + const realizationNumberSelections: RealizationNumberSelection[] = []; + let rangeStart: number | null = null; + let rangeEnd: number | null = null; + for (let i = 0; i < selectedRealizations.length; i++) { + const currentNumber = selectedRealizations[i]; + const nextNumber = selectedRealizations[i + 1]; // undefined if last number + + // Check if the currentNumber is a valid start of a range + if (validRealizations.includes(currentNumber)) { + if (rangeStart === null) { + rangeStart = currentNumber; + } + rangeEnd = currentNumber; + + // Check if the nextNumber is a valid continuation of the range in validNumbers + if ( + nextNumber === undefined || + validRealizations.indexOf(nextNumber) !== validRealizations.indexOf(currentNumber) + 1 + ) { + // If not, finish the current range + if (rangeStart !== rangeEnd) { + realizationNumberSelections.push({ start: rangeStart, end: rangeEnd }); + } else { + realizationNumberSelections.push(rangeStart); // Single number, no range + } + rangeStart = null; + rangeEnd = null; + } + } + } + + return realizationNumberSelections; +} diff --git a/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-utils/realizationPickerUtils.ts b/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-utils/realizationPickerUtils.ts new file mode 100644 index 000000000..1c4b99a11 --- /dev/null +++ b/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-utils/realizationPickerUtils.ts @@ -0,0 +1,48 @@ +import { RealizationNumberSelection } from "@framework/types/realizationFilterTypes"; + +/** + * Convert a realization number selection to a string tag for a realization picker. + * + * The string tag is in the format "start-end" or "number". + */ +export function makeRealizationPickerTagFromRealizationNumberSelection(selection: RealizationNumberSelection): string { + if (typeof selection === "number") { + return `${selection}`; + } + return `${selection.start}-${selection.end}`; +} + +/** + * Convert realization number selections to string tags for a realization picker. + * + * The string tags are in the format "start-end" or "number". + * + * The selection can be be null, in which case an empty array is returned. + */ +export function makeRealizationPickerTagsFromRealizationNumberSelections( + selections: readonly RealizationNumberSelection[] | null +): string[] { + if (!selections) return []; + + return selections.map(makeRealizationPickerTagFromRealizationNumberSelection); +} + +/** + * Convert a string tag from a realization picker to a realization number selection. + * + * The string tag is expected to be in the format "start-end" or "number". + */ +export function makeRealizationNumberSelectionFromRealizationPickerTag(tag: string): RealizationNumberSelection { + const split = tag.split("-"); + if (split.length === 1) { + return parseInt(split[0]); + } + return { start: parseInt(split[0]), end: parseInt(split[1]) }; +} + +/** + * Convert string tags from a realization picker to realization number selections. + */ +export function makeRealizationNumberSelectionsFromRealizationPickerTags(tags: string[]): RealizationNumberSelection[] { + return tags.map(makeRealizationNumberSelectionFromRealizationPickerTag); +} diff --git a/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-utils/sliderUtils.ts b/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-utils/sliderUtils.ts new file mode 100644 index 000000000..e1ad1ffc6 --- /dev/null +++ b/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-utils/sliderUtils.ts @@ -0,0 +1,32 @@ +/** + * Create a step size for a continuous value slider based on the min and max values. + * + * The step size is computed as a fraction of the range, and then rounded to a magnitude-adjusted value. + */ +export function createContinuousValueSliderStep(min: number, max: number): number { + const range = Math.abs(max - min); + + // Determine the number of steps based on the magnitude of the range + const magnitude = Math.floor(Math.log10(range)); + + let numberOfSteps = 100; + let digitPrecision = 3; + if (magnitude < 1) { + numberOfSteps = 100; + digitPrecision = 4; + } else if (magnitude < 2) { + numberOfSteps = 100; + } else if (magnitude < 3) { + numberOfSteps = 1000; + } else { + numberOfSteps = 10000; + } + + // Calculate the step size based on the number of steps + let stepSize = range / numberOfSteps; + + // Reduce number of significant digits + stepSize = parseFloat(stepSize.toPrecision(digitPrecision)); + + return stepSize; +} diff --git a/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-utils/smartNodeSelectorUtils.ts b/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-utils/smartNodeSelectorUtils.ts new file mode 100644 index 000000000..70ada6a41 --- /dev/null +++ b/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-utils/smartNodeSelectorUtils.ts @@ -0,0 +1,108 @@ +import { Parameter, ParameterIdent } from "@framework/EnsembleParameters"; +import { TreeDataNode } from "@lib/components/SmartNodeSelector"; + +import folderIcon from "../private-assets/folder.svg"; +import miscIcon from "../private-assets/misc.svg"; + +const NON_GROUPED_PARENT_NODE = "Generic"; + +/** + * Add a parameter node to a tree data node list under a requested group node. + */ +export function addParameterNodeToTreeDataNodeList( + treeDataNodeList: TreeDataNode[], + parameterNode: TreeDataNode, + groupNodeName: string +) { + const groupNode = treeDataNodeList.find((node) => node.id === groupNodeName); + + if (!groupNode) { + const icon = groupNodeName === NON_GROUPED_PARENT_NODE ? miscIcon : folderIcon; + const newGroupNode: TreeDataNode = { + id: groupNodeName, + name: groupNodeName, + icon: icon, + children: [parameterNode], + }; + treeDataNodeList.push(newGroupNode); + } else { + if (!groupNode.children) { + groupNode.children = [parameterNode]; + } else { + groupNode.children.push(parameterNode); + } + } +} + +/** + * Create a tree data node list for the SmartNodeSelector component form list of parameters. + * + * The parameter ident string is used as the node id. + * + * The parent nodes are created based on existing groups. + * Parameters with no group are added to the parent node with name NON_GROUPED_PARENT_NODE. + */ +export function createTreeDataNodeListFromParameters( + parameters: readonly Parameter[], + includeConstantParameters: boolean, + includeNodeDescription: boolean +): TreeDataNode[] { + if (parameters.length === 0) { + return []; + } + + const validParameters = includeConstantParameters + ? parameters + : parameters.filter((parameter) => !parameter.isConstant); + + const treeDataNodeList: TreeDataNode[] = []; + for (const parameter of validParameters) { + const parameterIdentString = ParameterIdent.fromNameAndGroup(parameter.name, parameter.groupName).toString(); + const newNode: TreeDataNode = { + id: parameterIdentString, + name: parameter.name, + description: includeNodeDescription ? parameter.description ?? undefined : undefined, + children: [], + }; + + const parentNodeName = parameter.groupName ?? NON_GROUPED_PARENT_NODE; + addParameterNodeToTreeDataNodeList(treeDataNodeList, newNode, parentNodeName); + } + + return treeDataNodeList; +} + +/** + * Create a tree data node list for the SmartNodeSelector component from a list of parameters. + */ +export function createSmartNodeSelectorTagListFromParameterList(parameters: Parameter[]): string[] { + const tags: string[] = []; + + for (const parameter of parameters) { + if (!parameter.groupName) { + tags.push(`${NON_GROUPED_PARENT_NODE}:${parameter.name}`); + } else { + tags.push(`${parameter.groupName}:${parameter.name}`); + } + } + + return tags; +} + +/** + * Create a tree date node for the SmartNodeSelector component from a parameter ident string. + */ +export function createSmartNodeSelectorTagTextFromParameterIdentString(parameterIdentString: string): string { + const parameterIdent = ParameterIdent.fromString(parameterIdentString); + if (!parameterIdent.groupName) { + return `${NON_GROUPED_PARENT_NODE}:${parameterIdent.name}`; + } + return `${parameterIdent.groupName}:${parameterIdent.name}`; +} + +/** + * Create a tree data node list for the SmartNodeSelector component from a list of parameter ident strings. + */ +export function createSmartNodeSelectorTagTextListFromParameterIdentStrings(parameterIdentStrings: string[]): string[] { + return parameterIdentStrings.map((elm) => createSmartNodeSelectorTagTextFromParameterIdentString(elm)); +} diff --git a/frontend/src/framework/internal/components/RightSettingsPanel/private-components/ModuleInstanceLog/moduleInstanceLog.tsx b/frontend/src/framework/internal/components/RightSettingsPanel/private-components/ModuleInstanceLog/moduleInstanceLog.tsx index effb85ea3..7427dd9d7 100644 --- a/frontend/src/framework/internal/components/RightSettingsPanel/private-components/ModuleInstanceLog/moduleInstanceLog.tsx +++ b/frontend/src/framework/internal/components/RightSettingsPanel/private-components/ModuleInstanceLog/moduleInstanceLog.tsx @@ -123,7 +123,10 @@ export function ModuleInstanceLog(props: ModuleInstanceLogProps): React.ReactNod } return ( -
+
} diff --git a/frontend/src/framework/internal/components/RightSettingsPanel/private-components/RealizationFilterSettings/realizationFilterSettings.tsx b/frontend/src/framework/internal/components/RightSettingsPanel/private-components/RealizationFilterSettings/realizationFilterSettings.tsx index a78221ba0..27752f348 100644 --- a/frontend/src/framework/internal/components/RightSettingsPanel/private-components/RealizationFilterSettings/realizationFilterSettings.tsx +++ b/frontend/src/framework/internal/components/RightSettingsPanel/private-components/RealizationFilterSettings/realizationFilterSettings.tsx @@ -2,246 +2,302 @@ import React from "react"; import { EnsembleIdent } from "@framework/EnsembleIdent"; import { GuiState, RightDrawerContent, useGuiValue } from "@framework/GuiMessageBroker"; -import { - IncludeExcludeFilter, - IncludeExcludeFilterEnumToStringMapping, - RealizationFilter, - RealizationFilterType, - RealizationFilterTypeStringMapping, - RealizationIndexSelection, -} from "@framework/RealizationFilter"; import { Workbench } from "@framework/Workbench"; import { useEnsembleSet } from "@framework/WorkbenchSession"; -import { RealizationPicker, RealizationPickerSelection } from "@framework/components/RealizationPicker"; +import { Drawer } from "@framework/internal/components/Drawer"; +import { + EnsembleRealizationFilter, + EnsembleRealizationFilterSelections, +} from "@framework/internal/components/EnsembleRealizationFilter"; +import { areParameterIdentStringToValueSelectionMapCandidatesEqual } from "@framework/utils/realizationFilterTypesUtils"; import { Button } from "@lib/components/Button"; import { Dialog } from "@lib/components/Dialog"; -import { Dropdown } from "@lib/components/Dropdown"; -import { Label } from "@lib/components/Label"; -import { RadioGroup } from "@lib/components/RadioGroup"; -import { Check, FilterAlt as FilterIcon } from "@mui/icons-material"; +import { FilterAlt } from "@mui/icons-material"; import { isEqual } from "lodash"; -import { - makeRealizationIndexSelectionsFromRealizationPickerTags, - makeRealizationPickerTagsFromRealizationIndexSelections, -} from "./utils/dataTypeConversion"; - -import { Drawer } from "../../../Drawer"; - -type RealizationFilterSettingsProps = { workbench: Workbench; onClose: () => void }; +export type RealizationFilterSettingsProps = { workbench: Workbench; onClose: () => void }; export const RealizationFilterSettings: React.FC = (props) => { const drawerContent = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.RightDrawerContent); + const ensembleSet = useEnsembleSet(props.workbench.getWorkbenchSession()); + const realizationFilterSet = props.workbench.getWorkbenchSession().getRealizationFilterSet(); - const [, forceUpdate] = React.useReducer((x) => x + 1, 0); - const [candidateEnsembleIdent, setCandidateEnsembleIdent] = React.useState(null); const [dialogOpen, setDialogOpen] = React.useState(false); - const [realizationIndexSelections, setRealizationIndexSelections] = React.useState< - readonly RealizationIndexSelection[] | null - >(null); - const [selectedRealizationFilter, setSelectedRealizationFilter] = React.useState(null); - const [selectedRangeTags, setSelectedRangeTags] = React.useState([]); - const [selectedIncludeOrExcludeFiltering, setSelectedIncludeOrExcludeFiltering] = - React.useState(IncludeExcludeFilter.INCLUDE_FILTER); - const [selectedFilterType, setSelectedFilterType] = React.useState( - RealizationFilterType.REALIZATION_INDEX - ); + const [activeFilterEnsembleIdent, setActiveFilterEnsembleIdent] = React.useState(null); - const ensembleSet = useEnsembleSet(props.workbench.getWorkbenchSession()); - const realizationFilterSet = props.workbench.getWorkbenchSession().getRealizationFilterSet(); + // Maps for keeping track of unsaved changes and filter selections + const [ensembleIdentStringHasUnsavedChangesMap, setEnsembleIdentStringHasUnsavedChangesMap] = React.useState<{ + [ensembleIdentString: string]: boolean; + }>({}); + const [ + ensembleIdentStringToRealizationFilterSelectionsMap, + setEnsembleIdentStringToRealizationFilterSelectionsMap, + ] = React.useState<{ + [ensembleIdentString: string]: EnsembleRealizationFilterSelections; + }>({}); + + // Create new maps if ensembles are added or removed + const ensembleIdentStrings = ensembleSet.getEnsembleArr().map((ensemble) => ensemble.getIdent().toString()); + if (!isEqual(ensembleIdentStrings, Object.keys(ensembleIdentStringToRealizationFilterSelectionsMap))) { + // Create new maps with the new ensemble ident strings + const updatedHasUnsavedChangesMap: { [ensembleIdentString: string]: boolean } = { + ...ensembleIdentStringHasUnsavedChangesMap, + }; + const updatedSelectionsMap: { [ensembleIdentString: string]: EnsembleRealizationFilterSelections } = { + ...ensembleIdentStringToRealizationFilterSelectionsMap, + }; - const hasUnsavedChanges = !selectedRealizationFilter - ? false - : !isEqual(realizationIndexSelections, selectedRealizationFilter.getRealizationIndexSelections()) || - selectedFilterType !== selectedRealizationFilter.getFilterType() || - selectedIncludeOrExcludeFiltering !== selectedRealizationFilter.getIncludeOrExcludeFilter(); - - function setStatesFromEnsembleIdent(ensembleIdent: EnsembleIdent | null) { - if (ensembleIdent === null) { - setSelectedRealizationFilter(null); - setRealizationIndexSelections(null); - setSelectedRangeTags([]); - setSelectedFilterType(RealizationFilterType.REALIZATION_INDEX); - return; + // Delete non-existing ensemble ident strings + for (const ensembleIdentString of Object.keys(ensembleIdentStringToRealizationFilterSelectionsMap)) { + if (!ensembleIdentStrings.includes(ensembleIdentString)) { + delete updatedHasUnsavedChangesMap[ensembleIdentString]; + delete updatedSelectionsMap[ensembleIdentString]; + } } - const realizationFilter = realizationFilterSet.getRealizationFilterForEnsembleIdent(ensembleIdent); - const realizationIndexSelection = realizationFilter.getRealizationIndexSelections(); + for (const ensembleIdentString of ensembleIdentStrings) { + if (ensembleIdentString in updatedSelectionsMap) { + // Skip if already exists + continue; + } - setSelectedRealizationFilter(realizationFilter); - setRealizationIndexSelections(realizationIndexSelection); - setSelectedRangeTags(makeRealizationPickerTagsFromRealizationIndexSelections(realizationIndexSelection)); - setSelectedFilterType(realizationFilter.getFilterType()); - setSelectedIncludeOrExcludeFiltering(realizationFilter.getIncludeOrExcludeFilter()); + const ensembleIdent = EnsembleIdent.fromString(ensembleIdentString); + const realizationFilter = realizationFilterSet.getRealizationFilterForEnsembleIdent(ensembleIdent); + + updatedHasUnsavedChangesMap[ensembleIdentString] = false; + updatedSelectionsMap[ensembleIdentString] = { + displayRealizationNumbers: realizationFilter.getFilteredRealizations(), + realizationNumberSelections: realizationFilter.getRealizationNumberSelections(), + parameterIdentStringToValueSelectionReadonlyMap: + realizationFilter.getParameterIdentStringToValueSelectionReadonlyMap(), + filterType: realizationFilter.getFilterType(), + includeOrExcludeFilter: realizationFilter.getIncludeOrExcludeFilter(), + }; + } + setEnsembleIdentStringHasUnsavedChangesMap(updatedHasUnsavedChangesMap); + setEnsembleIdentStringToRealizationFilterSelectionsMap(updatedSelectionsMap); } - function handleSelectedEnsembleChange(newValue: string | undefined) { - const ensembleIdent = newValue ? EnsembleIdent.fromString(newValue) : null; - setCandidateEnsembleIdent(ensembleIdent); + function handleFilterSettingsClose() { + // Check if there are unsaved changes + const hasUnsavedChanges = Object.values(ensembleIdentStringHasUnsavedChangesMap).some( + (hasUnsavedChanges) => hasUnsavedChanges + ); if (hasUnsavedChanges) { setDialogOpen(true); - return; + } else { + props.onClose(); + setActiveFilterEnsembleIdent(null); } - - setStatesFromEnsembleIdent(ensembleIdent); } - function handleRealizationPickChange(newSelection: RealizationPickerSelection) { - const realizationIndexSelection = - newSelection.selectedRangeTags.length === 0 - ? null - : makeRealizationIndexSelectionsFromRealizationPickerTags(newSelection.selectedRangeTags); + function handleApplyClick(ensembleIdent: EnsembleIdent) { + const ensembleIdentString = ensembleIdent.toString(); + const realizationFilter = realizationFilterSet.getRealizationFilterForEnsembleIdent(ensembleIdent); + const selections = ensembleIdentStringToRealizationFilterSelectionsMap[ensembleIdentString]; - setSelectedRangeTags(newSelection.selectedRangeTags); - setRealizationIndexSelections(realizationIndexSelection); - } + // Apply the filter changes + realizationFilter.setFilterType(selections.filterType); + realizationFilter.setIncludeOrExcludeFilter(selections.includeOrExcludeFilter); + realizationFilter.setRealizationNumberSelections(selections.realizationNumberSelections); + realizationFilter.setParameterIdentStringToValueSelectionReadonlyMap( + selections.parameterIdentStringToValueSelectionReadonlyMap + ); - function handleDiscardChangesClick() { - if (!selectedRealizationFilter) return; + // Run filtering + realizationFilter.runFiltering(); - const realizationIndexSelections = selectedRealizationFilter.getRealizationIndexSelections(); - setRealizationIndexSelections(realizationIndexSelections); - setSelectedRangeTags(makeRealizationPickerTagsFromRealizationIndexSelections(realizationIndexSelections)); - setSelectedFilterType(selectedRealizationFilter.getFilterType()); - setSelectedIncludeOrExcludeFiltering(selectedRealizationFilter.getIncludeOrExcludeFilter()); + // Reset the unsaved changes state + setEnsembleIdentStringHasUnsavedChangesMap({ + ...ensembleIdentStringHasUnsavedChangesMap, + [ensembleIdentString]: false, + }); + + // Notify subscribers of change. + props.workbench.getWorkbenchSession().notifyAboutEnsembleRealizationFilterChange(); } - function handleApplyButtonClick() { - saveSelectionsToSelectedFilterAndNotifySubscribers(); + function handleApplyAllClick() { + // Apply all the unsaved changes state and reset the unsaved changes state + const resetHasUnsavedChangesMap: { [ensembleIdentString: string]: boolean } = {}; + for (const ensembleIdentString of Object.keys(ensembleIdentStringToRealizationFilterSelectionsMap)) { + const ensembleIdent = EnsembleIdent.fromString(ensembleIdentString); + const realizationFilter = realizationFilterSet.getRealizationFilterForEnsembleIdent(ensembleIdent); + const selections = ensembleIdentStringToRealizationFilterSelectionsMap[ensembleIdent.toString()]; - // Force update to reflect changes in UI, as states are not updated. - forceUpdate(); - } + // Apply the filter changes + realizationFilter.setFilterType(selections.filterType); + realizationFilter.setIncludeOrExcludeFilter(selections.includeOrExcludeFilter); + realizationFilter.setRealizationNumberSelections(selections.realizationNumberSelections); + realizationFilter.setParameterIdentStringToValueSelectionReadonlyMap( + selections.parameterIdentStringToValueSelectionReadonlyMap + ); + + // Run filtering + realizationFilter.runFiltering(); - function handleDoNotSaveClick() { - setStatesFromEnsembleIdent(candidateEnsembleIdent); + // Reset the unsaved changes state + resetHasUnsavedChangesMap[ensembleIdentString] = false; + } + + setEnsembleIdentStringHasUnsavedChangesMap(resetHasUnsavedChangesMap); setDialogOpen(false); + props.onClose(); } - function handleDoSaveClick() { - saveSelectionsToSelectedFilterAndNotifySubscribers(); + function handleDiscardClick(ensembleIdent: EnsembleIdent) { + const ensembleIdentString = ensembleIdent.toString(); + const realizationFilter = realizationFilterSet.getRealizationFilterForEnsembleIdent(ensembleIdent); + setEnsembleIdentStringToRealizationFilterSelectionsMap({ + ...ensembleIdentStringToRealizationFilterSelectionsMap, + [ensembleIdentString]: { + displayRealizationNumbers: realizationFilter.getFilteredRealizations(), + realizationNumberSelections: realizationFilter.getRealizationNumberSelections(), + parameterIdentStringToValueSelectionReadonlyMap: + realizationFilter.getParameterIdentStringToValueSelectionReadonlyMap(), + filterType: realizationFilter.getFilterType(), + includeOrExcludeFilter: realizationFilter.getIncludeOrExcludeFilter(), + }, + }); + + // Reset the unsaved changes state + setEnsembleIdentStringHasUnsavedChangesMap({ + ...ensembleIdentStringHasUnsavedChangesMap, + [ensembleIdentString]: false, + }); + } + + function handleDiscardAllClick() { + // Discard all filter changes - i.e. reset the unsaved changes state + const resetSelectionsMap: { [ensembleIdentString: string]: EnsembleRealizationFilterSelections } = {}; + const resetHasUnsavedChangesMap: { [ensembleIdentString: string]: boolean } = {}; + for (const ensembleIdentString of Object.keys(ensembleIdentStringToRealizationFilterSelectionsMap)) { + const ensembleIdent = EnsembleIdent.fromString(ensembleIdentString); + const realizationFilter = realizationFilterSet.getRealizationFilterForEnsembleIdent(ensembleIdent); + + resetSelectionsMap[ensembleIdentString] = { + displayRealizationNumbers: realizationFilter.getFilteredRealizations(), + realizationNumberSelections: realizationFilter.getRealizationNumberSelections(), + parameterIdentStringToValueSelectionReadonlyMap: + realizationFilter.getParameterIdentStringToValueSelectionReadonlyMap(), + filterType: realizationFilter.getFilterType(), + includeOrExcludeFilter: realizationFilter.getIncludeOrExcludeFilter(), + }; + resetHasUnsavedChangesMap[ensembleIdentString] = false; + } + + setEnsembleIdentStringToRealizationFilterSelectionsMap(resetSelectionsMap); + setEnsembleIdentStringHasUnsavedChangesMap(resetHasUnsavedChangesMap); - setStatesFromEnsembleIdent(candidateEnsembleIdent); setDialogOpen(false); + props.onClose(); } - function saveSelectionsToSelectedFilterAndNotifySubscribers() { - if (!selectedRealizationFilter || !hasUnsavedChanges) return; + function handleFilterChange(ensembleIdent: EnsembleIdent, selections: EnsembleRealizationFilterSelections) { + const ensembleIdentString = ensembleIdent.toString(); - selectedRealizationFilter.setFilterType(selectedFilterType); - selectedRealizationFilter.setIncludeOrExcludeFilter(selectedIncludeOrExcludeFiltering); - selectedRealizationFilter.setRealizationIndexSelections(realizationIndexSelections); + // Register the filter changes in the map + setEnsembleIdentStringToRealizationFilterSelectionsMap({ + ...ensembleIdentStringToRealizationFilterSelectionsMap, + [ensembleIdentString]: selections, + }); - // Notify subscribers of change. - props.workbench.getWorkbenchSession().notifyAboutEnsembleRealizationFilterChange(); + // Check if the filter changes are different from the original filter + const realizationFilter = realizationFilterSet.getRealizationFilterForEnsembleIdent(ensembleIdent); + const hasUnsavedChanges = + !isEqual(selections.realizationNumberSelections, realizationFilter.getRealizationNumberSelections()) || + !areParameterIdentStringToValueSelectionMapCandidatesEqual( + selections.parameterIdentStringToValueSelectionReadonlyMap, + realizationFilter.getParameterIdentStringToValueSelectionReadonlyMap() + ) || + selections.filterType !== realizationFilter.getFilterType() || + selections.includeOrExcludeFilter !== realizationFilter.getIncludeOrExcludeFilter(); + + // Update the unsaved changes state + setEnsembleIdentStringHasUnsavedChangesMap({ + ...ensembleIdentStringHasUnsavedChangesMap, + [ensembleIdentString]: hasUnsavedChanges, + }); } - function handleFilterSettingsClose() { - props.onClose(); + function handleSetActiveEnsembleRealizationFilter(ensembleIdent: EnsembleIdent) { + setActiveFilterEnsembleIdent(ensembleIdent); + } + + function handleOnEnsembleRealizationFilterHeaderClick(ensembleIdent: EnsembleIdent) { + if (activeFilterEnsembleIdent?.equals(ensembleIdent)) { + setActiveFilterEnsembleIdent(null); + } } return ( - } - visible={drawerContent === RightDrawerContent.RealizationFilterSettings} - onClose={handleFilterSettingsClose} - > -
-