From 09407b32ef2aeb466c35cf857d2f68ce3ecd091f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8rgen=20Herje?= <82032112+jorgenherje@users.noreply.github.com> Date: Tue, 10 Oct 2023 16:39:17 +0200 Subject: [PATCH] Parameter list filtering component (#404) Add component for filter a list of Parameter objects. To shorten the list of parameters based on wanted property values/flags --- frontend/src/framework/EnsembleParameters.ts | 2 +- .../components/ParameterListFilter/index.ts | 1 + .../parameterListFilter.tsx | 97 ++++++++++ .../private-assets/check.svg | 1 + .../private-assets/segment.svg | 1 + .../private-utils/smartNodeSelectorUtils.ts | 177 ++++++++++++++++++ .../SimulationTimeSeriesMatrix/settings.tsx | 85 ++++++--- .../ParameterListFilterUtils.test.ts | 150 +++++++++++++++ 8 files changed, 492 insertions(+), 22 deletions(-) create mode 100644 frontend/src/framework/components/ParameterListFilter/index.ts create mode 100644 frontend/src/framework/components/ParameterListFilter/parameterListFilter.tsx create mode 100644 frontend/src/framework/components/ParameterListFilter/private-assets/check.svg create mode 100644 frontend/src/framework/components/ParameterListFilter/private-assets/segment.svg create mode 100644 frontend/src/framework/components/ParameterListFilter/private-utils/smartNodeSelectorUtils.ts create mode 100644 frontend/tests/unit-tests/ParameterListFilterUtils.test.ts diff --git a/frontend/src/framework/EnsembleParameters.ts b/frontend/src/framework/EnsembleParameters.ts index 27fdabf0e..9c0c3abfb 100644 --- a/frontend/src/framework/EnsembleParameters.ts +++ b/frontend/src/framework/EnsembleParameters.ts @@ -48,7 +48,7 @@ export class ParameterIdent { } if (parts.length === 2) { return new ParameterIdent(parts[0], parts[1]); - } + } throw new Error(`Invalid parameter ident string: ${paramIdentString}`); } diff --git a/frontend/src/framework/components/ParameterListFilter/index.ts b/frontend/src/framework/components/ParameterListFilter/index.ts new file mode 100644 index 000000000..3c183244b --- /dev/null +++ b/frontend/src/framework/components/ParameterListFilter/index.ts @@ -0,0 +1 @@ +export { ParameterListFilter } from "./parameterListFilter"; diff --git a/frontend/src/framework/components/ParameterListFilter/parameterListFilter.tsx b/frontend/src/framework/components/ParameterListFilter/parameterListFilter.tsx new file mode 100644 index 000000000..8019d2fde --- /dev/null +++ b/frontend/src/framework/components/ParameterListFilter/parameterListFilter.tsx @@ -0,0 +1,97 @@ +import React from "react"; + +import { Parameter } from "@framework/EnsembleParameters"; +import { SmartNodeSelector, SmartNodeSelectorSelection } from "@lib/components/SmartNodeSelector"; +import { TreeDataNode } from "@lib/components/SmartNodeSelector"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; + +import { isEqual } from "lodash"; + +// Icons placed here due to limitation of jest for testing utils (cannot import svg) +import checkIcon from "./private-assets/check.svg"; +import segmentIcon from "./private-assets/segment.svg"; +import { + ParameterParentNodeNames, + createTreeDataNodeListFromParameters, + getParametersMatchingSelectedNodes, +} from "./private-utils/smartNodeSelectorUtils"; + +export type InitialParameterFilter = Extract< + (typeof ParameterParentNodeNames)[keyof typeof ParameterParentNodeNames], + "Continuous" | "Discrete" | "Constant" | "Nonconstant" +>; + +export type ParameterListFilterProps = { + parameters: Parameter[]; + initialFilters?: InitialParameterFilter[]; + showTitle?: boolean; + onChange?: (filteredParameters: Parameter[]) => void; +}; + +export const ParameterListFilter: React.FC = (props: ParameterListFilterProps) => { + const smartNodeSelectorId = React.useId(); + const [selectedTags, setSelectedTags] = React.useState( + props.initialFilters ?? [ParameterParentNodeNames.IS_NONCONSTANT] + ); + const [selectedNodes, setSelectedNodes] = React.useState([]); + const [numberOfMatchingParameters, setNumberOfMatchingParameters] = React.useState(0); + const [parameters, setParameters] = React.useState(null); + const [previousTreeDataNodeList, setPreviousTreeDataNodeList] = React.useState([]); + const smartNodeSelectorDelimiter = ":"; + + let newTreeDataNodeList: TreeDataNode[] | null = null; + if (parameters === null || !isEqual(props.parameters, parameters)) { + newTreeDataNodeList = createTreeDataNodeListFromParameters(props.parameters, checkIcon, segmentIcon); + setParameters(props.parameters); + setPreviousTreeDataNodeList(newTreeDataNodeList); + } + const treeDataNodeList = newTreeDataNodeList ? newTreeDataNodeList : previousTreeDataNodeList; + + // Utilizing useEffect to prevent re-render of parent component during rendering of this component + React.useEffect( + function createFilterParameters() { + if (parameters === null || parameters.length === 0) { + setNumberOfMatchingParameters(0); + if (props.onChange) { + props.onChange([]); + } + return; + } + + const filteredParameters = getParametersMatchingSelectedNodes( + parameters, + selectedNodes, + smartNodeSelectorDelimiter + ); + setNumberOfMatchingParameters(filteredParameters.length); + if (props.onChange) { + props.onChange(filteredParameters); + } + }, + [selectedNodes, parameters, props.onChange] + ); + + function handleSmartNodeSelectorChange(selection: SmartNodeSelectorSelection) { + setSelectedTags(selection.selectedTags); + setSelectedNodes(selection.selectedNodes); + } + + return ( +
+ <> + +
+ Number of matches: {numberOfMatchingParameters} +
+ +
+ ); +}; diff --git a/frontend/src/framework/components/ParameterListFilter/private-assets/check.svg b/frontend/src/framework/components/ParameterListFilter/private-assets/check.svg new file mode 100644 index 000000000..e6a00ab62 --- /dev/null +++ b/frontend/src/framework/components/ParameterListFilter/private-assets/check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/framework/components/ParameterListFilter/private-assets/segment.svg b/frontend/src/framework/components/ParameterListFilter/private-assets/segment.svg new file mode 100644 index 000000000..ee6522bac --- /dev/null +++ b/frontend/src/framework/components/ParameterListFilter/private-assets/segment.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/framework/components/ParameterListFilter/private-utils/smartNodeSelectorUtils.ts b/frontend/src/framework/components/ParameterListFilter/private-utils/smartNodeSelectorUtils.ts new file mode 100644 index 000000000..b6c849170 --- /dev/null +++ b/frontend/src/framework/components/ParameterListFilter/private-utils/smartNodeSelectorUtils.ts @@ -0,0 +1,177 @@ +import { Parameter, ParameterIdent, ParameterType } from "@framework/EnsembleParameters"; +import { TreeDataNode } from "@lib/components/SmartNodeSelector"; + +export const ParameterParentNodeNames = { + NAME: "Name", + GROUP: "Group", + CONTINUOUS: "Continuous", // For Parameter.type === ParameterType.CONTINUOUS + DISCRETE: "Discrete", // For Parameter.type === ParameterType.DISCRETE + IS_CONSTANT: "Constant", // For Parameter.isConstant === true + IS_NONCONSTANT: "Nonconstant", // For Parameter.isConstant === false + IS_LOGARITHMIC: "Logarithmic", // For Parameter.isLogarithmic === true + IS_LINEAR: "Linear", // For Parameter.isLogarithmic === false +} as const; + +export function createAndAddNode(treeNodeDataList: TreeDataNode[], nodeName: string, icon?: string): TreeDataNode { + const newNode: TreeDataNode = { name: nodeName, description: "", icon: icon }; + treeNodeDataList.push(newNode); + return newNode; +} + +export function createTreeDataNodeListFromParameters( + parameters: Parameter[], + checkIcon?: string, + parentIcon?: string +): TreeDataNode[] { + if (parameters.length === 0) { + return []; + } + + const treeDataNodeList: TreeDataNode[] = []; + + const hasContinuousParameter = parameters.some((parameter) => parameter.type === ParameterType.CONTINUOUS); + + // Node for boolean/state properties on top level + createAndAddNode(treeDataNodeList, ParameterParentNodeNames.CONTINUOUS, checkIcon); + createAndAddNode(treeDataNodeList, ParameterParentNodeNames.DISCRETE, checkIcon); + createAndAddNode(treeDataNodeList, ParameterParentNodeNames.IS_CONSTANT, checkIcon); + createAndAddNode(treeDataNodeList, ParameterParentNodeNames.IS_NONCONSTANT, checkIcon); + if (hasContinuousParameter) { + createAndAddNode(treeDataNodeList, ParameterParentNodeNames.IS_LOGARITHMIC, checkIcon); + createAndAddNode(treeDataNodeList, ParameterParentNodeNames.IS_LINEAR, checkIcon); + } + + // Add parameter name and check for group name + const parameterNameSet = new Set(); + const groupNameSet = new Set(); + for (const parameter of parameters) { + parameterNameSet.add(parameter.name); + + if (parameter.groupName) { + groupNameSet.add(parameter.groupName); + } + } + + // Add parameter names + if (parameterNameSet.size !== 0) { + const nameParentNode = createAndAddNode(treeDataNodeList, ParameterParentNodeNames.NAME, parentIcon); + nameParentNode.children = []; + for (const parameterName of parameterNameSet) { + createAndAddNode(nameParentNode.children, parameterName); + } + } + + // Add parameter groups + if (groupNameSet.size !== 0) { + const groupParentNode = createAndAddNode(treeDataNodeList, ParameterParentNodeNames.GROUP, parentIcon); + groupParentNode.children = []; + for (const groupName of groupNameSet) { + createAndAddNode(groupParentNode.children, groupName); + } + } + + return treeDataNodeList; +} + +export function getChildNodeNamesFromParentNodeName( + nodes: string[], + parentNodeName: string, + delimiter = ":" +): string[] { + return nodes + .filter((node) => node.split(delimiter, 1)[0] === parentNodeName) + .map((node) => node.split(delimiter, 2)[1]); +} + +export function getParametersMatchingSelectedNodes( + parameters: Parameter[], + selectedNodes: string[], + delimiter = ":" +): Parameter[] { + // No selection implies no filtering + if (selectedNodes.length === 0) { + return parameters; + } + + const isContinuousSelected = selectedNodes.includes(ParameterParentNodeNames.CONTINUOUS); + const isDiscreteSelected = selectedNodes.includes(ParameterParentNodeNames.DISCRETE); + const isConstantSelected = selectedNodes.includes(ParameterParentNodeNames.IS_CONSTANT); + const isNonConstantSelected = selectedNodes.includes(ParameterParentNodeNames.IS_NONCONSTANT); + const isLogarithmicSelected = selectedNodes.includes(ParameterParentNodeNames.IS_LOGARITHMIC); + const isLinearSelected = selectedNodes.includes(ParameterParentNodeNames.IS_LINEAR); + + // Intersection filtering, i.e. parameter cannot be both continuous and discrete, constant and non-constant, logarithmic and linear + if (isContinuousSelected && isDiscreteSelected) return []; + if (isConstantSelected && isNonConstantSelected) return []; + if (isLogarithmicSelected && isLinearSelected) return []; + + const selectedParameterNames = getChildNodeNamesFromParentNodeName( + selectedNodes, + ParameterParentNodeNames.NAME, + delimiter + ); + const selectedParameterGroups = getChildNodeNamesFromParentNodeName( + selectedNodes, + ParameterParentNodeNames.GROUP, + delimiter + ); + + const isNoParameterPropertyAmongSelectedNodes = + !isContinuousSelected && + !isDiscreteSelected && + !isConstantSelected && + !isNonConstantSelected && + !isLogarithmicSelected && + !isLinearSelected && + selectedParameterNames.length === 0 && + selectedParameterGroups.length === 0; + if (isNoParameterPropertyAmongSelectedNodes) { + return []; + } + + const selectedEnsembleParameters: Parameter[] = []; + for (const parameter of parameters) { + // Filter by parameter name + if (selectedParameterNames.length !== 0 && !selectedParameterNames.includes(parameter.name)) { + continue; + } + + // Filter by parameter group + if (selectedParameterGroups.length !== 0 && parameter.groupName === null) { + continue; + } + if ( + selectedParameterGroups.length !== 0 && + parameter.groupName !== null && + !selectedParameterGroups.includes(parameter.groupName) + ) { + continue; + } + + // Intersection filter by parameter type (continuous/discrete) + if (isContinuousSelected && parameter.type !== ParameterType.CONTINUOUS) continue; + if (isDiscreteSelected && parameter.type !== ParameterType.DISCRETE) continue; + + // Intersection filter by parameter is constant/non-constant + if (isConstantSelected && !parameter.isConstant) continue; + if (isNonConstantSelected && parameter.isConstant) continue; + + // Filter by parameter is logarithmic/linear (only for continuous parameters) + if (isLogarithmicSelected && parameter.type === ParameterType.CONTINUOUS && !parameter.isLogarithmic) continue; + if (isLinearSelected && parameter.type === ParameterType.CONTINUOUS && parameter.isLogarithmic) continue; + + // Prevent duplicates + const parameterIdent = ParameterIdent.fromNameAndGroup(parameter.name, parameter.groupName); + if ( + selectedEnsembleParameters.some((elm) => + ParameterIdent.fromNameAndGroup(elm.name, elm.groupName).equals(parameterIdent) + ) + ) { + continue; + } + + selectedEnsembleParameters.push(parameter); + } + + return selectedEnsembleParameters; +} diff --git a/frontend/src/modules/SimulationTimeSeriesMatrix/settings.tsx b/frontend/src/modules/SimulationTimeSeriesMatrix/settings.tsx index 427519d6e..255ffebb7 100644 --- a/frontend/src/modules/SimulationTimeSeriesMatrix/settings.tsx +++ b/frontend/src/modules/SimulationTimeSeriesMatrix/settings.tsx @@ -2,11 +2,12 @@ import React from "react"; import { Frequency_api, StatisticFunction_api } from "@api"; import { EnsembleIdent } from "@framework/EnsembleIdent"; -import { ParameterIdent, ParameterType } from "@framework/EnsembleParameters"; +import { Parameter, ParameterIdent, ParameterType } from "@framework/EnsembleParameters"; import { EnsembleSet } from "@framework/EnsembleSet"; import { ModuleFCProps } from "@framework/Module"; import { useEnsembleSet } from "@framework/WorkbenchSession"; import { MultiEnsembleSelect } from "@framework/components/MultiEnsembleSelect"; +import { ParameterListFilter } from "@framework/components/ParameterListFilter"; import { VectorSelector, createVectorSelectorDataFromVectors } from "@framework/components/VectorSelector"; import { fixupEnsembleIdents } from "@framework/utils/ensembleUiHelpers"; import { ApiStatesWrapper } from "@lib/components/ApiStatesWrapper"; @@ -16,9 +17,11 @@ import { CollapsibleGroup } from "@lib/components/CollapsibleGroup"; import { Dropdown } from "@lib/components/Dropdown"; import { Label } from "@lib/components/Label"; import { RadioGroup } from "@lib/components/RadioGroup"; +import { Select } from "@lib/components/Select"; import { SmartNodeSelectorSelection, TreeDataNode } from "@lib/components/SmartNodeSelector"; import { useValidState } from "@lib/hooks/useValidState"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import { FilterAlt } from "@mui/icons-material"; import { isEqual } from "lodash"; @@ -63,6 +66,7 @@ export function settings({ moduleContext, workbenchSession }: ModuleFCProps([]); const [vectorSelectorData, setVectorSelectorData] = React.useState([]); const [statisticsType, setStatisticsType] = React.useState(StatisticsType.INDIVIDUAL); + const [filteredParameterIdentList, setFilteredParameterIdentList] = React.useState([]); if (!isEqual(ensembleSet, previousEnsembleSet)) { const newSelectedEnsembleIdents = selectedEnsembleIdents.filter((ensemble) => @@ -76,26 +80,36 @@ export function settings({ moduleContext, workbenchSession }: ModuleFCProps param.equals(parameter))) continue; - if (ensemble.getParameters().getParameter(parameter).isConstant) continue; + const continuousAndNonConstantParameters = ensemble + .getParameters() + .getParameterArr() + .filter((parameter) => parameter.type === ParameterType.CONTINUOUS && !parameter.isConstant); + // Add non-duplicate parameters to list - verified by ParameterIdent + for (const parameter of continuousAndNonConstantParameters) { + const parameterIdent = ParameterIdent.fromNameAndGroup(parameter.name, parameter.groupName); + const isParameterInUnion = continuousAndNonConstantParametersUnion.some((elm) => + parameterIdent.equals(ParameterIdent.fromNameAndGroup(elm.name, elm.groupName)) + ); + + if (isParameterInUnion) continue; continuousAndNonConstantParametersUnion.push(parameter); } } + + const vectorListQueries = useVectorListQueries(selectedEnsembleIdents); + const ensembleVectorListsHelper = new EnsembleVectorListsHelper(selectedEnsembleIdents, vectorListQueries); + const selectedVectorNamesHasHistorical = ensembleVectorListsHelper.hasAnyHistoricalVector(selectedVectorNames); + const currentVectorSelectorData = createVectorSelectorDataFromVectors(ensembleVectorListsHelper.vectorsUnion()); + const [selectedParameterIdentStr, setSelectedParameterIdentStr] = useValidState(null, [ - continuousAndNonConstantParametersUnion, + filteredParameterIdentList, (item: ParameterIdent) => item.toString(), ]); @@ -142,10 +156,10 @@ export function settings({ moduleContext, workbenchSession }: ModuleFCProps + const isParameterAmongFiltered = filteredParameterIdentList.some((parameter) => parameter.equals(newParameterIdent) ); - if (isParameterInUnion) { + if (isParameterAmongFiltered) { setParameterIdent(newParameterIdent); } else { setParameterIdent(null); @@ -154,15 +168,19 @@ export function settings({ moduleContext, workbenchSession }: ModuleFCProps) { setGroupBy(event.target.value as GroupBy); } - function handleColorByParameterChange(parameterIdentStr: string) { - setSelectedParameterIdentStr(parameterIdentStr); + function handleColorByParameterChange(parameterIdentStrings: string[]) { + if (parameterIdentStrings.length !== 0) { + setSelectedParameterIdentStr(parameterIdentStrings[0]); + return; + } + setSelectedParameterIdentStr(null); } function handleEnsembleSelectChange(ensembleIdentArr: EnsembleIdent[]) { @@ -213,6 +231,17 @@ export function settings({ moduleContext, workbenchSession }: ModuleFCProps + ParameterIdent.fromNameAndGroup(elm.name, elm.groupName) + ); + + setFilteredParameterIdentList(filteredParamIdents); + }, + [setFilteredParameterIdentList] + ); + function handleIndividualStatisticsSelectionChange( event: React.ChangeEvent, statistic: StatisticFunction_api @@ -344,20 +373,34 @@ export function settings({ moduleContext, workbenchSession }: ModuleFCProps
- { +
+ } + > + + +
+