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`. diff --git a/backend_py/primary/primary/main.py b/backend_py/primary/primary/main.py index af023774d..d2e9fa22e 100644 --- a/backend_py/primary/primary/main.py +++ b/backend_py/primary/primary/main.py @@ -12,7 +12,6 @@ from primary.auth.auth_helper import AuthHelper from primary.auth.enforce_logged_in_middleware import EnforceLoggedInMiddleware from primary.middleware.add_process_time_to_server_timing_middleware import AddProcessTimeToServerTimingMiddleware -from primary.routers.correlations.router import router as correlations_router from primary.routers.dev.router import router as dev_router from primary.routers.explore import router as explore_router from primary.routers.general import router as general_router @@ -77,7 +76,6 @@ def custom_generate_unique_id(route: APIRoute) -> str: app.include_router(inplace_volumetrics_router, prefix="/inplace_volumetrics", tags=["inplace_volumetrics"]) app.include_router(surface_router, prefix="/surface", tags=["surface"]) app.include_router(parameters_router, prefix="/parameters", tags=["parameters"]) -app.include_router(correlations_router, prefix="/correlations", tags=["correlations"]) app.include_router(grid3d_router, prefix="/grid3d", tags=["grid3d"]) app.include_router(group_tree_router, prefix="/group_tree", tags=["group_tree"]) app.include_router(pvt_router, prefix="/pvt", tags=["pvt"]) diff --git a/backend_py/primary/primary/routers/correlations/__init__.py b/backend_py/primary/primary/routers/correlations/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend_py/primary/primary/routers/correlations/router.py b/backend_py/primary/primary/routers/correlations/router.py deleted file mode 100644 index f474495b2..000000000 --- a/backend_py/primary/primary/routers/correlations/router.py +++ /dev/null @@ -1,65 +0,0 @@ -import logging - -from fastapi import APIRouter - -LOGGER = logging.getLogger(__name__) - -router = APIRouter() - - -# @router.get("/correlate_parameters_with_timeseries/") -# def correlate_parameters_with_timeseries( -# # fmt:off -# authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), -# case_uuid: str = Query(description="Sumo case uuid"), -# ensemble_name: str = Query(description="Ensemble name"), -# vector_name: str = Query(description="Name of the vector"), -# timestep: datetime.datetime = Query(description= "Timestep"), -# # realizations: Optional[Sequence[int]] = Query(None, description="Optional list of realizations to include. If not specified, all realizations will be returned."), -# # parameter_names: Optional[List[str]] = Query(None, description="Optional subset of parameters to correlate. Default are all parameters.") -# # fmt:on -# ) -> EnsembleCorrelations: -# """Get parameter correlations for a timeseries at a given timestep""" - -# summary_access = SummaryAccess(authenticated_user.get_sumo_access_token(), case_uuid, ensemble_name) -# parameter_access = ParameterAccess( -# authenticated_user.get_sumo_access_token(), -# case_uuid=case_uuid, -# iteration_name=ensemble_name, -# ) - -# ensemble_response = summary_access.get_vector_values_at_timestep( -# vector_name=vector_name, timestep=timestep, realizations=None -# ) -# parameters = parameter_access.get_parameters_and_sensitivities() - -# return correlate_parameters_with_response(parameters.parameters, ensemble_response) - - -# @router.get("/correlate_parameters_with_inplace_volumes/") -# def correlate_parameters_with_inplace_volumes( -# # fmt:off -# authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), -# case_uuid: str = Query(description="Sumo case uuid"), -# ensemble_name: str = Query(description="Ensemble name"), -# table_name: str = Query(description="Table name"), -# response_name:str = Query(description="Response name"), -# # categorical_filter:Optional[List[InplaceVolumetricsCategoricalMetaData]] = None, -# # realizations: Optional[Sequence[int]] = Query(None, description="Optional list of realizations to include. If not specified, all realizations will be returned."), -# # parameter_names: Optional[List[str]] = Query(None, description="Optional subset of parameters to correlate. Default are all parameters.") -# # fmt:on -# ) -> EnsembleCorrelations: -# """Get parameter correlations for an inplace volumetrics response""" - -# inplace_access = InplaceVolumetricsAccess(authenticated_user.get_sumo_access_token(), case_uuid, ensemble_name) -# parameter_access = ParameterAccess( -# authenticated_user.get_sumo_access_token(), -# case_uuid=case_uuid, -# iteration_name=ensemble_name, -# ) -# ensemble_response = inplace_access.get_response( -# table_name, response_name, categorical_filters=None, realizations=None -# ) -# parameters = parameter_access.get_parameters_and_sensitivities() - -# return correlate_parameters_with_response(parameters.parameters, ensemble_response) diff --git a/backend_py/primary/primary/routers/parameters/router.py b/backend_py/primary/primary/routers/parameters/router.py index 71c3882e1..8f180c17e 100644 --- a/backend_py/primary/primary/routers/parameters/router.py +++ b/backend_py/primary/primary/routers/parameters/router.py @@ -41,7 +41,7 @@ async def get_parameter_names_and_description( name=parameter.name, descriptive_name=parameter.descriptive_name, group_name=parameter.group_name, - is_numerical=parameter.is_numerical, + is_discrete=parameter.is_discrete, ) for parameter in parameters ] diff --git a/backend_py/primary/primary/routers/parameters/schemas.py b/backend_py/primary/primary/routers/parameters/schemas.py index 20ed66de0..e799b9f00 100644 --- a/backend_py/primary/primary/routers/parameters/schemas.py +++ b/backend_py/primary/primary/routers/parameters/schemas.py @@ -7,4 +7,4 @@ class EnsembleParameterDescription(BaseModel): name: str group_name: Optional[str] = None descriptive_name: Optional[str] = None - is_numerical: bool + is_discrete: bool diff --git a/backend_py/primary/primary/routers/rft/converters.py b/backend_py/primary/primary/routers/rft/converters.py new file mode 100644 index 000000000..11d4c036f --- /dev/null +++ b/backend_py/primary/primary/routers/rft/converters.py @@ -0,0 +1,19 @@ +from primary.services.sumo_access.rft_types import RftTableDefinition + +from . import schemas + + +def to_api_table_definition( + table_definition: RftTableDefinition, +) -> schemas.RftTableDefinition: + """Converts the table definitions from the sumo service to the API format""" + return schemas.RftTableDefinition( + response_names=table_definition.response_names, + well_infos=[ + schemas.RftWellInfo( + well_name=well_info.well_name, + timestamps_utc_ms=well_info.timestamps_utc_ms, + ) + for well_info in table_definition.well_infos + ], + ) diff --git a/backend_py/primary/primary/routers/rft/router.py b/backend_py/primary/primary/routers/rft/router.py index ae728dbcb..905851df6 100644 --- a/backend_py/primary/primary/routers/rft/router.py +++ b/backend_py/primary/primary/routers/rft/router.py @@ -8,26 +8,23 @@ from primary.services.utils.authenticated_user import AuthenticatedUser from . import schemas +from . import converters LOGGER = logging.getLogger(__name__) router = APIRouter() -@router.get("/rft_info") -async def get_rft_info( +@router.get("/table_definition") +async def get_table_definition( authenticated_user: Annotated[AuthenticatedUser, Depends(AuthHelper.get_authenticated_user)], case_uuid: Annotated[str, Query(description="Sumo case uuid")], ensemble_name: Annotated[str, Query(description="Ensemble name")], -) -> list[schemas.RftInfo]: +) -> schemas.RftTableDefinition: access = await RftAccess.from_case_uuid_async(authenticated_user.get_sumo_access_token(), case_uuid, ensemble_name) - rft_well_list = await access.get_rft_info() + rft_table_def = await access.get_rft_info() - ret_rft_well_list: list[schemas.RftInfo] = [] - for rftinfo in rft_well_list: - ret_rft_well_list.append(schemas.RftInfo.model_validate(rftinfo.model_dump())) - - return ret_rft_well_list + return converters.to_api_table_definition(rft_table_def) @router.get("/realization_data") diff --git a/backend_py/primary/primary/routers/rft/schemas.py b/backend_py/primary/primary/routers/rft/schemas.py index 14a021457..610eedbbb 100644 --- a/backend_py/primary/primary/routers/rft/schemas.py +++ b/backend_py/primary/primary/routers/rft/schemas.py @@ -1,11 +1,16 @@ from pydantic import BaseModel -class RftInfo(BaseModel): +class RftWellInfo(BaseModel): well_name: str timestamps_utc_ms: list[int] +class RftTableDefinition(BaseModel): + response_names: list[str] + well_infos: list[RftWellInfo] + + class RftRealizationData(BaseModel): well_name: str realization: int diff --git a/backend_py/primary/primary/services/parameter_correlations.py b/backend_py/primary/primary/services/parameter_correlations.py deleted file mode 100644 index ba5894e73..000000000 --- a/backend_py/primary/primary/services/parameter_correlations.py +++ /dev/null @@ -1,62 +0,0 @@ -from typing import List - -import pandas as pd - -from primary.services.sumo_access.parameter_access import EnsembleParameter -from primary.services.sumo_access.generic_types import EnsembleScalarResponse, EnsembleCorrelations - - -def correlate_parameters_with_response( - ensemble_parameters: List[EnsembleParameter], response: EnsembleScalarResponse -) -> EnsembleCorrelations: - """Correlates ensemble parameters values with an ensemble response""" - parameter_dframe = _numerical_parameters_to_pandas_table(ensemble_parameters) - response_dframe = _ensemble_scalar_response_to_pandas_table(response) - - # Merge dframes on real for consistency - dframe = pd.merge(parameter_dframe, response_dframe, on="realization") - dframe.set_index("realization", inplace=True) - - # Separate response out as a series and correlate - response_series = dframe["response"] - dframe = dframe.drop(columns=["response"]) - corr_series = dframe.corrwith(response_series) - - # Sort correlations in descending order - sorted_corr_series = corr_series.reindex(corr_series.abs().sort_values().index) - - return EnsembleCorrelations(names=sorted_corr_series.index.to_list(), values=sorted_corr_series.to_list()) - - -def _numerical_parameters_to_pandas_table( - ensemble_parameters: List[EnsembleParameter], -) -> pd.DataFrame: - """Convert a list of ensemble parameters to a pandas dataframe""" - data = [] - for parameter in ensemble_parameters: - # Skip non-numerical parameters - if not parameter.is_numerical: - continue - # Skip parameters where all values are equal - if all(value == parameter.values[0] for value in parameter.values[1:]): - continue - for real, value in zip(parameter.realizations, parameter.values): - data.append({"name": parameter.name, "realization": real, "value": value}) - - # Convert the list of dictionaries to a pandas DataFrame - df = pd.DataFrame(data) - - # Pivot name column to individual columns per parameter - pivot_df = df.pivot(index="realization", columns="name", values="value").reset_index() - return pivot_df - - -def _ensemble_scalar_response_to_pandas_table( - ensemble_response: EnsembleScalarResponse, -) -> pd.DataFrame: - """Convert a ensemble scalar response to a pandas dataframe""" - data = [] - for real, value in zip(ensemble_response.realizations, ensemble_response.values): - data.append({"realization": real, "response": value}) - df = pd.DataFrame(data) - return df diff --git a/backend_py/primary/primary/services/sumo_access/parameter_access.py b/backend_py/primary/primary/services/sumo_access/parameter_access.py index 71b3d7c1f..29b62bd56 100644 --- a/backend_py/primary/primary/services/sumo_access/parameter_access.py +++ b/backend_py/primary/primary/services/sumo_access/parameter_access.py @@ -140,7 +140,7 @@ def parameter_table_to_ensemble_parameters(parameter_table: pa.Table) -> List[En name=parameter_name, group_name=f"LOG10_{group_name}" if is_logarithmic else group_name, is_logarithmic=is_logarithmic, - is_numerical=parameter_table.schema.field(table_column_name).type != pa.string, + is_discrete=_is_discrete_column(parameter_table.schema.field(table_column_name).type), is_constant=len(set(parameter_table[table_column_name])) == 1, descriptive_name=parameter_name, values=parameter_table[table_column_name].to_numpy().tolist(), @@ -150,6 +150,20 @@ def parameter_table_to_ensemble_parameters(parameter_table: pa.Table) -> List[En return ensemble_parameters +def _is_discrete_column(column_type: pa.DataType) -> bool: + """Check if a column is discrete + + Discrete parameter is defined as a parameter that is either a string or an integer + """ + return ( + column_type == pa.string() + or column_type == pa.int64() + or column_type == pa.int32() + or column_type == pa.int16() + or column_type == pa.int8() + ) + + def _parameter_name_and_group_name_to_parameter_str(parameter_name: str, group_name: Optional[str]) -> str: """Convert a parameter name and group name to a parameter string""" return f"{group_name}:{parameter_name}" if group_name else parameter_name diff --git a/backend_py/primary/primary/services/sumo_access/parameter_types.py b/backend_py/primary/primary/services/sumo_access/parameter_types.py index 707866a2f..3a89aaec6 100644 --- a/backend_py/primary/primary/services/sumo_access/parameter_types.py +++ b/backend_py/primary/primary/services/sumo_access/parameter_types.py @@ -9,7 +9,7 @@ class EnsembleParameter(BaseModel): name: str is_logarithmic: bool - is_numerical: bool + is_discrete: bool # values are string or integer is_constant: bool # all values are equal group_name: Optional[str] = None descriptive_name: Optional[str] = None diff --git a/backend_py/primary/primary/services/sumo_access/rft_access.py b/backend_py/primary/primary/services/sumo_access/rft_access.py index 41487e3bc..63920ee0c 100644 --- a/backend_py/primary/primary/services/sumo_access/rft_access.py +++ b/backend_py/primary/primary/services/sumo_access/rft_access.py @@ -9,12 +9,22 @@ from fmu.sumo.explorer.objects import Case, TableCollection from webviz_pkg.core_utils.perf_timer import PerfTimer +from primary.services.service_exceptions import ( + Service, + NoDataError, + InvalidDataError, + MultipleDataMatchesError, +) + from ._helpers import create_sumo_client, create_sumo_case_async -from .rft_types import RftInfo, RftRealizationData +from .rft_types import RftTableDefinition, RftWellInfo, RftRealizationData LOGGER = logging.getLogger(__name__) +ALLOWED_RFT_RESPONSE_NAMES = ["PRESSURE", "SGAS", "SWAT", "SOIL"] + + class RftAccess: def __init__(self, case: Case, iteration_name: str): self._case: Case = case @@ -26,18 +36,24 @@ async def from_case_uuid_async(cls, access_token: str, case_uuid: str, iteration case: Case = await create_sumo_case_async(client=sumo_client, case_uuid=case_uuid, want_keepalive_pit=False) return RftAccess(case=case, iteration_name=iteration_name) - async def get_rft_info(self) -> list[RftInfo]: - table = await get_concatenated_rft_table(self._case, self._iteration_name, column_names=["PRESSURE"]) - rft_well_infos: list[RftInfo] = [] + async def get_rft_info(self) -> RftTableDefinition: + rft_table_collection = await get_rft_table_collection(self._case, self._iteration_name, column_name=None) + + columns = await rft_table_collection.columns_async + available_response_names = [col for col in columns if col in ALLOWED_RFT_RESPONSE_NAMES] + table = await get_concatenated_rft_table( + self._case, self._iteration_name, column_names=available_response_names + ) + rft_well_infos: list[RftWellInfo] = [] well_names = table["WELL"].unique().tolist() for well_name in well_names: well_table = table.filter(pc.equal(table["WELL"], well_name)) timestamps_utc_ms = sorted(list(set(well_table["DATE"].to_numpy().astype(int).tolist()))) - rft_well_infos.append(RftInfo(well_name=well_name, timestamps_utc_ms=timestamps_utc_ms)) + rft_well_infos.append(RftWellInfo(well_name=well_name, timestamps_utc_ms=timestamps_utc_ms)) - return rft_well_infos + return RftTableDefinition(response_names=available_response_names, well_infos=rft_well_infos) async def get_rft_well_realization_data( self, @@ -112,7 +128,10 @@ async def _load_arrow_table_for_from_sumo(case: Case, iteration_name: str, colum if await rft_table_collection.length_async() == 0: return None if await rft_table_collection.length_async() > 1: - raise ValueError(f"Multiple tables found for vector {column_name=}") + raise MultipleDataMatchesError( + f"Multiple rft tables found in case={case}, iteration={iteration_name}: {column_name=}", + Service.SUMO, + ) sumo_table = await rft_table_collection.getitem_async(0) # print(f"{sumo_table.format=}") @@ -129,26 +148,29 @@ async def _load_arrow_table_for_from_sumo(case: Case, iteration_name: str, colum # Verify that we got the expected columns if not "DATE" in table.column_names: - raise ValueError("Table does not contain a DATE column") + raise InvalidDataError("Table does not contain a DATE column", Service.SUMO) + if not "REAL" in table.column_names: - raise ValueError("Table does not contain a REAL column") + raise InvalidDataError("Table does not contain a REAL column", Service.SUMO) if not column_name in table.column_names: - raise ValueError(f"Table does not contain a {column_name} column") + raise InvalidDataError(f"Table does not contain a {column_name} column", Service.SUMO) if table.num_columns != 4: - raise ValueError("Table should contain exactly 4 columns") + raise InvalidDataError("Table should contain exactly 4 columns", Service.SUMO) # Verify that we got the expected columns if sorted(table.column_names) != sorted(["DATE", "REAL", "WELL", column_name]): - raise ValueError(f"Unexpected columns in table {table.column_names=}") + raise InvalidDataError(f"Unexpected columns in table {table.column_names=}", Service.SUMO) # Verify that the column datatypes are as we expect schema = table.schema if schema.field("DATE").type != pa.timestamp("ms"): - raise ValueError(f"Unexpected type for DATE column {schema.field('DATE').type=}") + raise InvalidDataError(f"Unexpected type for DATE column {schema.field('DATE').type=}", Service.SUMO) if schema.field("REAL").type != pa.int16(): - raise ValueError(f"Unexpected type for REAL column {schema.field('REAL').type=}") + raise InvalidDataError(f"Unexpected type for REAL column {schema.field('REAL').type=}", Service.SUMO) if schema.field(column_name).type != pa.float32(): - raise ValueError(f"Unexpected type for {column_name} column {schema.field(column_name).type=}") + raise InvalidDataError( + f"Unexpected type for {column_name} column {schema.field(column_name).type=}", Service.SUMO + ) LOGGER.debug( f"Loaded arrow table from Sumo in: {timer.elapsed_ms()}ms (" @@ -170,7 +192,6 @@ async def get_rft_table_collection( iteration=iteration_name, ) table_names = await rft_table_collection.names_async - print(table_names) rft_table_collection = case.tables.filter( aggregation="collection", tagname="rft", @@ -179,8 +200,13 @@ async def get_rft_table_collection( ) table_names = await rft_table_collection.names_async if len(table_names) == 0: - raise ValueError("No rft table collections found") + raise NoDataError( + f"No rft table collections found in case={case.uuid}, iteration={iteration_name}", Service.SUMO + ) if len(table_names) == 1: return rft_table_collection - raise ValueError(f"Multiple rft table collections found: {table_names}. Expected only one.") + raise MultipleDataMatchesError( + f"Multiple rft table collections found in case={case.uuid}, iteration={iteration_name}: {table_names=}", + Service.SUMO, + ) diff --git a/backend_py/primary/primary/services/sumo_access/rft_types.py b/backend_py/primary/primary/services/sumo_access/rft_types.py index 6d319a9f2..25ccc8491 100644 --- a/backend_py/primary/primary/services/sumo_access/rft_types.py +++ b/backend_py/primary/primary/services/sumo_access/rft_types.py @@ -14,11 +14,16 @@ class RftSumoTableSchema(BaseModel): column_names: list[str] -class RftInfo(BaseModel): +class RftWellInfo(BaseModel): well_name: str timestamps_utc_ms: list[int] +class RftTableDefinition(BaseModel): + response_names: list[str] + well_infos: list[RftWellInfo] + + class RftRealizationData(BaseModel): well_name: str realization: int diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 05a520905..fd7cf0531 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -66,10 +66,11 @@ export type { PolygonsMeta as PolygonsMeta_api } from './models/PolygonsMeta'; export type { PolylineIntersection as PolylineIntersection_api } from './models/PolylineIntersection'; export type { PvtData as PvtData_api } from './models/PvtData'; export type { RepeatedTableColumnData as RepeatedTableColumnData_api } from './models/RepeatedTableColumnData'; -export type { RftInfo as RftInfo_api } from './models/RftInfo'; export type { RftObservation as RftObservation_api } from './models/RftObservation'; export type { RftObservations as RftObservations_api } from './models/RftObservations'; export type { RftRealizationData as RftRealizationData_api } from './models/RftRealizationData'; +export type { RftTableDefinition as RftTableDefinition_api } from './models/RftTableDefinition'; +export type { RftWellInfo as RftWellInfo_api } from './models/RftWellInfo'; export type { SeismicCubeMeta as SeismicCubeMeta_api } from './models/SeismicCubeMeta'; export type { SeismicFenceData as SeismicFenceData_api } from './models/SeismicFenceData'; export type { SeismicFencePolyline as SeismicFencePolyline_api } from './models/SeismicFencePolyline'; diff --git a/frontend/src/api/models/EnsembleParameter.ts b/frontend/src/api/models/EnsembleParameter.ts index 684b518a5..ab76eb01f 100644 --- a/frontend/src/api/models/EnsembleParameter.ts +++ b/frontend/src/api/models/EnsembleParameter.ts @@ -8,7 +8,7 @@ export type EnsembleParameter = { name: string; is_logarithmic: boolean; - is_numerical: boolean; + is_discrete: boolean; is_constant: boolean; group_name: (string | null); descriptive_name: (string | null); diff --git a/frontend/src/api/models/EnsembleParameterDescription.ts b/frontend/src/api/models/EnsembleParameterDescription.ts index eae024cc4..38340af95 100644 --- a/frontend/src/api/models/EnsembleParameterDescription.ts +++ b/frontend/src/api/models/EnsembleParameterDescription.ts @@ -6,6 +6,6 @@ export type EnsembleParameterDescription = { name: string; group_name: (string | null); descriptive_name: (string | null); - is_numerical: boolean; + is_discrete: boolean; }; diff --git a/frontend/src/api/models/RftTableDefinition.ts b/frontend/src/api/models/RftTableDefinition.ts new file mode 100644 index 000000000..0b8fd0a13 --- /dev/null +++ b/frontend/src/api/models/RftTableDefinition.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { RftWellInfo } from './RftWellInfo'; +export type RftTableDefinition = { + response_names: Array; + well_infos: Array; +}; + diff --git a/frontend/src/api/models/RftInfo.ts b/frontend/src/api/models/RftWellInfo.ts similarity index 87% rename from frontend/src/api/models/RftInfo.ts rename to frontend/src/api/models/RftWellInfo.ts index 5fa3cc3d8..48000c3f1 100644 --- a/frontend/src/api/models/RftInfo.ts +++ b/frontend/src/api/models/RftWellInfo.ts @@ -2,7 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type RftInfo = { +export type RftWellInfo = { well_name: string; timestamps_utc_ms: Array; }; diff --git a/frontend/src/api/services/RftService.ts b/frontend/src/api/services/RftService.ts index 845ea6c55..252a5933a 100644 --- a/frontend/src/api/services/RftService.ts +++ b/frontend/src/api/services/RftService.ts @@ -2,26 +2,26 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import type { RftInfo } from '../models/RftInfo'; import type { RftRealizationData } from '../models/RftRealizationData'; +import type { RftTableDefinition } from '../models/RftTableDefinition'; import type { CancelablePromise } from '../core/CancelablePromise'; import type { BaseHttpRequest } from '../core/BaseHttpRequest'; export class RftService { constructor(public readonly httpRequest: BaseHttpRequest) {} /** - * Get Rft Info + * Get Table Definition * @param caseUuid Sumo case uuid * @param ensembleName Ensemble name - * @returns RftInfo Successful Response + * @returns RftTableDefinition Successful Response * @throws ApiError */ - public getRftInfo( + public getTableDefinition( caseUuid: string, ensembleName: string, - ): CancelablePromise> { + ): CancelablePromise { return this.httpRequest.request({ method: 'GET', - url: '/rft/rft_info', + url: '/rft/table_definition', query: { 'case_uuid': caseUuid, 'ensemble_name': ensembleName, diff --git a/frontend/src/framework/AtomStoreMaster.ts b/frontend/src/framework/AtomStoreMaster.ts index 43356331a..7f8a40932 100644 --- a/frontend/src/framework/AtomStoreMaster.ts +++ b/frontend/src/framework/AtomStoreMaster.ts @@ -1,5 +1,7 @@ import { WritableAtom, createStore } from "jotai"; +import { CurrentModuleInstanceIdAtom } from "./GlobalAtoms"; + export type AtomStore = ReturnType; export class AtomStoreMaster { @@ -16,6 +18,8 @@ export class AtomStoreMaster { makeAtomStoreForModuleInstance(moduleInstanceId: string) { const atomStore = createStore(); + // Make the module's own id available within each module's store + atomStore.set(CurrentModuleInstanceIdAtom, moduleInstanceId); this._atomStores.set(moduleInstanceId, atomStore); const atomStates = Array.from(this._atomStates.entries()); diff --git a/frontend/src/framework/GlobalAtoms.ts b/frontend/src/framework/GlobalAtoms.ts index 421bd4320..443e02abc 100644 --- a/frontend/src/framework/GlobalAtoms.ts +++ b/frontend/src/framework/GlobalAtoms.ts @@ -8,6 +8,10 @@ import { RealizationFilterSet } from "./RealizationFilterSet"; import { EnsembleRealizationFilterFunction } from "./WorkbenchSession"; import { atomWithCompare } from "./utils/atomUtils"; +/** A module's instance-id. Available in the jotai-store of each module, otherwise null */ +// ? Should this one be moved to `AtomStoreMaster.ts`? +export const CurrentModuleInstanceIdAtom = atom(null); + export const EnsembleSetAtom = atomWithCompare(new EnsembleSet([]), isEqual); /** diff --git a/frontend/src/framework/GuiMessageBroker.ts b/frontend/src/framework/GuiMessageBroker.ts index 4c9242a70..0824807dc 100644 --- a/frontend/src/framework/GuiMessageBroker.ts +++ b/frontend/src/framework/GuiMessageBroker.ts @@ -4,6 +4,8 @@ import { isDevMode } from "@lib/utils/devMode"; import { Size2D } from "@lib/utils/geometry"; import { Vec2 } from "@lib/utils/vec2"; +import { UnsavedChangesAction } from "./types/unsavedChangesAction"; + export enum LeftDrawerContent { ModuleSettings = "ModuleSettings", ModulesList = "ModulesList", @@ -27,6 +29,7 @@ export enum GuiState { EditDataChannelConnections = "editDataChannelConnections", RightSettingsPanelWidthInPercent = "rightSettingsPanelWidthInPercent", AppInitialized = "appInitialized", + NumberOfUnsavedRealizationFilters = "numberOfUnsavedRealizationFilters", } export enum GuiEvent { @@ -43,6 +46,7 @@ export enum GuiEvent { DataChannelConnectionsChange = "dataChannelConnectionsChange", DataChannelNodeHover = "dataChannelNodeHover", DataChannelNodeUnhover = "dataChannelNodeUnhover", + UnsavedRealizationFilterSettingsAction = "unsavedRealizationFilterSettingsAction", } export type GuiEventPayloads = { @@ -75,6 +79,9 @@ export type GuiEventPayloads = { [GuiEvent.DataChannelNodeHover]: { connectionAllowed: boolean; }; + [GuiEvent.UnsavedRealizationFilterSettingsAction]: { + action: UnsavedChangesAction; + }; }; type GuiStateValueTypes = { @@ -87,6 +94,7 @@ type GuiStateValueTypes = { [GuiState.EditDataChannelConnections]: boolean; [GuiState.RightSettingsPanelWidthInPercent]: number; [GuiState.AppInitialized]: boolean; + [GuiState.NumberOfUnsavedRealizationFilters]: number; }; const defaultStates: Map = new Map(); @@ -98,12 +106,14 @@ defaultStates.set(GuiState.DataChannelConnectionLayerVisible, false); defaultStates.set(GuiState.DevToolsVisible, isDevMode()); defaultStates.set(GuiState.RightSettingsPanelWidthInPercent, 0); defaultStates.set(GuiState.AppInitialized, false); +defaultStates.set(GuiState.NumberOfUnsavedRealizationFilters, 0); const persistentStates: GuiState[] = [ GuiState.LeftSettingsPanelWidthInPercent, GuiState.DevToolsVisible, GuiState.RightSettingsPanelWidthInPercent, GuiState.RightDrawerContent, + GuiState.NumberOfUnsavedRealizationFilters, ]; export class GuiMessageBroker { diff --git a/frontend/src/framework/Module.tsx b/frontend/src/framework/Module.tsx index dc81aaee0..15ec05a53 100644 --- a/frontend/src/framework/Module.tsx +++ b/frontend/src/framework/Module.tsx @@ -15,6 +15,8 @@ import { WorkbenchServices } from "./WorkbenchServices"; import { WorkbenchSession } from "./WorkbenchSession"; import { WorkbenchSettings } from "./WorkbenchSettings"; +export type OnInstanceUnloadFunc = (instanceId: string) => void; + export enum ModuleCategory { MAIN = "main", SUB = "sub", @@ -105,6 +107,7 @@ export interface ModuleOptions { description?: string; channelDefinitions?: ChannelDefinition[]; channelReceiverDefinitions?: ChannelReceiverDefinition[]; + onInstanceUnloadFunc?: OnInstanceUnloadFunc; } export class Module { @@ -127,6 +130,7 @@ export class Module { private _workbench: Workbench | null = null; private _syncableSettingKeys: SyncSettingKey[]; private _drawPreviewFunc: DrawPreviewFunc | null; + private _onInstanceUnloadFunc: OnInstanceUnloadFunc | null; private _description: string | null; private _channelDefinitions: ChannelDefinition[] | null; private _channelReceiverDefinitions: ChannelReceiverDefinition[] | null; @@ -143,6 +147,7 @@ export class Module { this.settingsFC = () =>
Not defined
; this._syncableSettingKeys = options.syncableSettingKeys ?? []; this._drawPreviewFunc = options.drawPreviewFunc ?? null; + this._onInstanceUnloadFunc = options.onInstanceUnloadFunc ?? null; this._description = options.description ?? null; this._channelDefinitions = options.channelDefinitions ?? null; this._channelReceiverDefinitions = options.channelReceiverDefinitions ?? null; @@ -242,6 +247,10 @@ export class Module { return instance; } + onInstanceUnload(instanceId: string) { + this._onInstanceUnloadFunc?.(instanceId); + } + private setImportState(state: ImportState): void { this._importState = state; this._moduleInstances.forEach((instance) => { diff --git a/frontend/src/framework/ModuleInstance.ts b/frontend/src/framework/ModuleInstance.ts index 968e56d2f..4a2e927ad 100644 --- a/frontend/src/framework/ModuleInstance.ts +++ b/frontend/src/framework/ModuleInstance.ts @@ -335,6 +335,8 @@ export class ModuleInstance { this.setModuleInstanceState(ModuleInstanceState.RESETTING); return new Promise((resolve) => { + this._module.onInstanceUnload(this._id); + this.initialize(); resolve(); }); @@ -347,6 +349,10 @@ export class ModuleInstance { getInitialSettings(): InitialSettings | null { return this._initialSettings; } + + unload() { + this._module.onInstanceUnload(this._id); + } } export function useModuleInstanceTopicValue( diff --git a/frontend/src/framework/ModuleRegistry.ts b/frontend/src/framework/ModuleRegistry.ts index c6fdc7766..0ad55dc39 100644 --- a/frontend/src/framework/ModuleRegistry.ts +++ b/frontend/src/framework/ModuleRegistry.ts @@ -1,5 +1,12 @@ import { ChannelDefinition, ChannelReceiverDefinition } from "./DataChannelTypes"; -import { InterfaceEffects, Module, ModuleCategory, ModuleDevState, ModuleInterfaceTypes } from "./Module"; +import { + InterfaceEffects, + Module, + ModuleCategory, + ModuleDevState, + ModuleInterfaceTypes, + OnInstanceUnloadFunc, +} from "./Module"; import { ModuleDataTagId } from "./ModuleDataTags"; import { DrawPreviewFunc } from "./Preview"; import { SyncSettingKey } from "./SyncSettings"; @@ -17,6 +24,7 @@ export type RegisterModuleOptions = { channelReceiverDefinitions?: ChannelReceiverDefinition[]; preview?: DrawPreviewFunc; description?: string; + onInstanceUnload?: OnInstanceUnloadFunc; }; export class ModuleNotFoundError extends Error { @@ -49,6 +57,7 @@ export class ModuleRegistry { channelDefinitions: options.channelDefinitions, channelReceiverDefinitions: options.channelReceiverDefinitions, drawPreviewFunc: options.preview, + onInstanceUnloadFunc: options.onInstanceUnload, description: options.description, }); this._registeredModules[options.moduleName] = module; 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/Workbench.ts b/frontend/src/framework/Workbench.ts index 71cbfe105..424813de4 100644 --- a/frontend/src/framework/Workbench.ts +++ b/frontend/src/framework/Workbench.ts @@ -182,8 +182,12 @@ export class Workbench { } removeModuleInstance(moduleInstanceId: string): void { - const manager = this.getModuleInstance(moduleInstanceId)?.getChannelManager(); - if (manager) { + const moduleInstance = this.getModuleInstance(moduleInstanceId); + + if (moduleInstance) { + const manager = moduleInstance.getChannelManager(); + + moduleInstance.unload(); manager.unregisterAllChannels(); manager.unregisterAllReceivers(); } 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/EnsembleSetLoader.ts b/frontend/src/framework/internal/EnsembleSetLoader.ts index 8a756fa2b..9e1faf451 100644 --- a/frontend/src/framework/internal/EnsembleSetLoader.ts +++ b/frontend/src/framework/internal/EnsembleSetLoader.ts @@ -136,27 +136,27 @@ function buildParameterArrFromApiResponse(apiParameterArr: EnsembleParameter_api const retParameterArr: Parameter[] = []; for (const apiPar of apiParameterArr) { - if (apiPar.is_numerical) { - const retPar: ContinuousParameter = { - type: ParameterType.CONTINUOUS, + if (apiPar.is_discrete) { + const retPar: DiscreteParameter = { + type: ParameterType.DISCRETE, name: apiPar.name, groupName: apiPar.group_name, description: apiPar.descriptive_name, isConstant: apiPar.is_constant, - isLogarithmic: apiPar.is_logarithmic, realizations: apiPar.realizations, - values: apiPar.values as number[], + values: apiPar.values, }; retParameterArr.push(retPar); } else { - const retPar: DiscreteParameter = { - type: ParameterType.DISCRETE, + const retPar: ContinuousParameter = { + type: ParameterType.CONTINUOUS, name: apiPar.name, groupName: apiPar.group_name, description: apiPar.descriptive_name, isConstant: apiPar.is_constant, + isLogarithmic: apiPar.is_logarithmic, realizations: apiPar.realizations, - values: apiPar.values, + values: apiPar.values as number[], }; retParameterArr.push(retPar); } 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(); + } + } + + const activeStyleClasses = { + "ring ring-opacity-100 shadow-lg": true, + "ring-blue-400 shadow-blue-400": !props.hasUnsavedSelections, + "ring-orange-400 shadow-orange-400": props.hasUnsavedSelections, + }; + const inactiveStyleClasses = { + "cursor-pointer ring-2": true, + "ring-opacity-100": !props.isAnotherFilterActive, + "ring-opacity-50 group hover:shadow-md hover:ring-opacity-75 transition-opacity": props.isAnotherFilterActive, + "ring-gray-300 shadow-gray-300 ": !props.hasUnsavedSelections, + "ring-orange-400 shadow-orange-400": props.hasUnsavedSelections, + "hover:shadow-blue-400 hover:shadow-lg shadow-md": !props.isAnotherFilterActive && props.hasUnsavedSelections, + "hover:ring-blue-400 hover:shadow-blue-400 hover:shadow-md": + !props.isAnotherFilterActive && !props.hasUnsavedSelections, + }; + const mainDivStyleClasses = props.isActive ? activeStyleClasses : inactiveStyleClasses; + + 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..5d4bdc6ed --- /dev/null +++ b/frontend/src/framework/internal/components/EnsembleRealizationFilter/private-components/realizationNumberDisplay.tsx @@ -0,0 +1,115 @@ +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.max( + 5, + 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/NavBar/rightNavBar.tsx b/frontend/src/framework/internal/components/NavBar/rightNavBar.tsx index eb3845813..358ecd53a 100644 --- a/frontend/src/framework/internal/components/NavBar/rightNavBar.tsx +++ b/frontend/src/framework/internal/components/NavBar/rightNavBar.tsx @@ -2,6 +2,7 @@ import React from "react"; import { GuiState, RightDrawerContent, useGuiState } from "@framework/GuiMessageBroker"; import { Workbench } from "@framework/Workbench"; +import { Badge } from "@lib/components/Badge"; import { Button } from "@lib/components/Button"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; import { FilterAlt, History } from "@mui/icons-material"; @@ -11,27 +12,24 @@ type RightNavBarProps = { }; export const RightNavBar: React.FC = (props) => { - const [drawerContent, setDrawerContent] = useGuiState( - props.workbench.getGuiMessageBroker(), - GuiState.RightDrawerContent + const guiMessageBroker = props.workbench.getGuiMessageBroker(); + const [drawerContent, setDrawerContent] = useGuiState(guiMessageBroker, GuiState.RightDrawerContent); + const [numberOfUnsavedRealizationFilters] = useGuiState( + guiMessageBroker, + GuiState.NumberOfUnsavedRealizationFilters ); - const [rightSettingsPanelWidth, setRightSettingsPanelWidth] = useGuiState( - props.workbench.getGuiMessageBroker(), + guiMessageBroker, GuiState.RightSettingsPanelWidthInPercent ); function ensureSettingsPanelIsVisible() { if (rightSettingsPanelWidth <= 5) { - setRightSettingsPanelWidth(15); + setRightSettingsPanelWidth(30); } } function handleRealizationFilterClick() { - if (rightSettingsPanelWidth > 0 && drawerContent === RightDrawerContent.RealizationFilterSettings) { - setRightSettingsPanelWidth(0); - return; - } ensureSettingsPanelIsVisible(); setDrawerContent(RightDrawerContent.RealizationFilterSettings); } @@ -49,7 +47,9 @@ export const RightNavBar: React.FC = (props) => { >
); 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..5c2c0cfe1 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 @@ -1,247 +1,324 @@ 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"; + GuiEvent, + GuiEventPayloads, + GuiState, + RightDrawerContent, + useGuiState, + useGuiValue, +} from "@framework/GuiMessageBroker"; import { Workbench } from "@framework/Workbench"; import { useEnsembleSet } from "@framework/WorkbenchSession"; -import { RealizationPicker, RealizationPickerSelection } from "@framework/components/RealizationPicker"; -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 { isEqual } from "lodash"; - +import { Drawer } from "@framework/internal/components/Drawer"; import { - makeRealizationIndexSelectionsFromRealizationPickerTags, - makeRealizationPickerTagsFromRealizationIndexSelections, -} from "./utils/dataTypeConversion"; + EnsembleRealizationFilter, + EnsembleRealizationFilterSelections, +} from "@framework/internal/components/EnsembleRealizationFilter"; +import { UnsavedChangesAction } from "@framework/types/unsavedChangesAction"; +import { countTrueValues } from "@framework/utils/objectUtils"; +import { areParameterIdentStringToValueSelectionMapCandidatesEqual } from "@framework/utils/realizationFilterTypesUtils"; +import { FilterAlt } from "@mui/icons-material"; -import { Drawer } from "../../../Drawer"; +import { isEqual } from "lodash"; -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 [, 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 guiMessageBroker = props.workbench.getGuiMessageBroker(); + const drawerContent = useGuiValue(guiMessageBroker, GuiState.RightDrawerContent); + const rightSettingsPanelWidth = useGuiValue(guiMessageBroker, GuiState.RightSettingsPanelWidthInPercent); const ensembleSet = useEnsembleSet(props.workbench.getWorkbenchSession()); const realizationFilterSet = props.workbench.getWorkbenchSession().getRealizationFilterSet(); + const [, setNumberOfUnsavedRealizationFilters] = useGuiState( + guiMessageBroker, + GuiState.NumberOfUnsavedRealizationFilters + ); - 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; - } + const [activeFilterEnsembleIdent, setActiveFilterEnsembleIdent] = React.useState(null); - const realizationFilter = realizationFilterSet.getRealizationFilterForEnsembleIdent(ensembleIdent); - const realizationIndexSelection = realizationFilter.getRealizationIndexSelections(); + // 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; + }>({}); - setSelectedRealizationFilter(realizationFilter); - setRealizationIndexSelections(realizationIndexSelection); - setSelectedRangeTags(makeRealizationPickerTagsFromRealizationIndexSelections(realizationIndexSelection)); - setSelectedFilterType(realizationFilter.getFilterType()); - setSelectedIncludeOrExcludeFiltering(realizationFilter.getIncludeOrExcludeFilter()); + // Set no active filter if the settings panel is closed + if (rightSettingsPanelWidth < 5 && activeFilterEnsembleIdent !== null) { + setActiveFilterEnsembleIdent(null); } - function handleSelectedEnsembleChange(newValue: string | undefined) { - const ensembleIdent = newValue ? EnsembleIdent.fromString(newValue) : null; - setCandidateEnsembleIdent(ensembleIdent); - if (hasUnsavedChanges) { - setDialogOpen(true); - return; + // 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, + }; + + // Delete non-existing ensemble ident strings + for (const ensembleIdentString of Object.keys(ensembleIdentStringToRealizationFilterSelectionsMap)) { + if (!ensembleIdentStrings.includes(ensembleIdentString)) { + delete updatedHasUnsavedChangesMap[ensembleIdentString]; + delete updatedSelectionsMap[ensembleIdentString]; + } } - setStatesFromEnsembleIdent(ensembleIdent); - } + for (const ensembleIdentString of ensembleIdentStrings) { + if (ensembleIdentString in updatedSelectionsMap) { + // Skip if already exists + continue; + } - function handleRealizationPickChange(newSelection: RealizationPickerSelection) { - const realizationIndexSelection = - newSelection.selectedRangeTags.length === 0 - ? null - : makeRealizationIndexSelectionsFromRealizationPickerTags(newSelection.selectedRangeTags); + const ensembleIdent = EnsembleIdent.fromString(ensembleIdentString); + const realizationFilter = realizationFilterSet.getRealizationFilterForEnsembleIdent(ensembleIdent); - setSelectedRangeTags(newSelection.selectedRangeTags); - setRealizationIndexSelections(realizationIndexSelection); + updatedHasUnsavedChangesMap[ensembleIdentString] = false; + updatedSelectionsMap[ensembleIdentString] = { + displayRealizationNumbers: realizationFilter.getFilteredRealizations(), + realizationNumberSelections: realizationFilter.getRealizationNumberSelections(), + parameterIdentStringToValueSelectionReadonlyMap: + realizationFilter.getParameterIdentStringToValueSelectionReadonlyMap(), + filterType: realizationFilter.getFilterType(), + includeOrExcludeFilter: realizationFilter.getIncludeOrExcludeFilter(), + }; + } + setEnsembleIdentStringHasUnsavedChangesMap(updatedHasUnsavedChangesMap); + setEnsembleIdentStringToRealizationFilterSelectionsMap(updatedSelectionsMap); + setNumberOfUnsavedRealizationFilters(countTrueValues(updatedHasUnsavedChangesMap)); } - function handleDiscardChangesClick() { - if (!selectedRealizationFilter) return; + const handleApplyAllClick = React.useCallback( + function handleApplyAllClick() { + // Apply all the unsaved changes state and reset the unsaved changes state + const resetHasUnsavedChangesMap: { [ensembleIdentString: string]: boolean } = {}; + for (const ensembleIdentString in ensembleIdentStringToRealizationFilterSelectionsMap) { + const ensembleIdent = EnsembleIdent.fromString(ensembleIdentString); + const realizationFilter = realizationFilterSet.getRealizationFilterForEnsembleIdent(ensembleIdent); + const selections = ensembleIdentStringToRealizationFilterSelectionsMap[ensembleIdent.toString()]; - const realizationIndexSelections = selectedRealizationFilter.getRealizationIndexSelections(); - setRealizationIndexSelections(realizationIndexSelections); - setSelectedRangeTags(makeRealizationPickerTagsFromRealizationIndexSelections(realizationIndexSelections)); - setSelectedFilterType(selectedRealizationFilter.getFilterType()); - setSelectedIncludeOrExcludeFiltering(selectedRealizationFilter.getIncludeOrExcludeFilter()); - } + // Apply the filter changes + realizationFilter.setFilterType(selections.filterType); + realizationFilter.setIncludeOrExcludeFilter(selections.includeOrExcludeFilter); + realizationFilter.setRealizationNumberSelections(selections.realizationNumberSelections); + realizationFilter.setParameterIdentStringToValueSelectionReadonlyMap( + selections.parameterIdentStringToValueSelectionReadonlyMap + ); - function handleApplyButtonClick() { - saveSelectionsToSelectedFilterAndNotifySubscribers(); + // Run filtering + realizationFilter.runFiltering(); - // Force update to reflect changes in UI, as states are not updated. - forceUpdate(); - } + // Reset the unsaved changes state + resetHasUnsavedChangesMap[ensembleIdentString] = false; + } - function handleDoNotSaveClick() { - setStatesFromEnsembleIdent(candidateEnsembleIdent); - setDialogOpen(false); - } + setEnsembleIdentStringHasUnsavedChangesMap(resetHasUnsavedChangesMap); + setNumberOfUnsavedRealizationFilters(0); + }, + [ + ensembleIdentStringToRealizationFilterSelectionsMap, + realizationFilterSet, + setNumberOfUnsavedRealizationFilters, + ] + ); + + const handleDiscardAllClick = React.useCallback( + 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 in 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); + setNumberOfUnsavedRealizationFilters(0); + }, + [ + ensembleIdentStringToRealizationFilterSelectionsMap, + realizationFilterSet, + setNumberOfUnsavedRealizationFilters, + ] + ); + + React.useEffect(() => { + function handleUnsavedChangesAction( + payload: GuiEventPayloads[GuiEvent.UnsavedRealizationFilterSettingsAction] + ) { + if (payload.action === UnsavedChangesAction.Save) { + handleApplyAllClick(); + setActiveFilterEnsembleIdent(null); + } else if (payload.action === UnsavedChangesAction.Discard) { + handleDiscardAllClick(); + setActiveFilterEnsembleIdent(null); + } + } + + const removeUnsavedChangesActionHandler = guiMessageBroker.subscribeToEvent( + GuiEvent.UnsavedRealizationFilterSettingsAction, + handleUnsavedChangesAction + ); - function handleDoSaveClick() { - saveSelectionsToSelectedFilterAndNotifySubscribers(); + return () => { + removeUnsavedChangesActionHandler(); + }; + }, [guiMessageBroker, handleApplyAllClick, handleDiscardAllClick]); - setStatesFromEnsembleIdent(candidateEnsembleIdent); - setDialogOpen(false); + function handleFilterSettingsClose() { + props.onClose(); } - function saveSelectionsToSelectedFilterAndNotifySubscribers() { - if (!selectedRealizationFilter || !hasUnsavedChanges) return; + function handleApplyClick(ensembleIdent: EnsembleIdent) { + const ensembleIdentString = ensembleIdent.toString(); + const realizationFilter = realizationFilterSet.getRealizationFilterForEnsembleIdent(ensembleIdent); + const selections = ensembleIdentStringToRealizationFilterSelectionsMap[ensembleIdentString]; + + // Apply the filter changes + realizationFilter.setFilterType(selections.filterType); + realizationFilter.setIncludeOrExcludeFilter(selections.includeOrExcludeFilter); + realizationFilter.setRealizationNumberSelections(selections.realizationNumberSelections); + realizationFilter.setParameterIdentStringToValueSelectionReadonlyMap( + selections.parameterIdentStringToValueSelectionReadonlyMap + ); + + // Run filtering + realizationFilter.runFiltering(); - selectedRealizationFilter.setFilterType(selectedFilterType); - selectedRealizationFilter.setIncludeOrExcludeFilter(selectedIncludeOrExcludeFiltering); - selectedRealizationFilter.setRealizationIndexSelections(realizationIndexSelections); + // Reset the unsaved changes state + const newHasUnsavedChangesMap = { ...ensembleIdentStringHasUnsavedChangesMap, [ensembleIdentString]: false }; + setEnsembleIdentStringHasUnsavedChangesMap(newHasUnsavedChangesMap); + setNumberOfUnsavedRealizationFilters(countTrueValues(newHasUnsavedChangesMap)); // Notify subscribers of change. props.workbench.getWorkbenchSession().notifyAboutEnsembleRealizationFilterChange(); } - function handleFilterSettingsClose() { - props.onClose(); + 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 + const newHasUnsavedChangesMap = { ...ensembleIdentStringHasUnsavedChangesMap, [ensembleIdentString]: false }; + setEnsembleIdentStringHasUnsavedChangesMap(newHasUnsavedChangesMap); + setNumberOfUnsavedRealizationFilters(countTrueValues(newHasUnsavedChangesMap)); + } + + function handleFilterChange(ensembleIdent: EnsembleIdent, selections: EnsembleRealizationFilterSelections) { + const ensembleIdentString = ensembleIdent.toString(); + + // Register the filter changes in the map + setEnsembleIdentStringToRealizationFilterSelectionsMap({ + ...ensembleIdentStringToRealizationFilterSelectionsMap, + [ensembleIdentString]: selections, + }); + + // 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 + const newHasUnsavedChangesMap = { + ...ensembleIdentStringHasUnsavedChangesMap, + [ensembleIdentString]: hasUnsavedChanges, + }; + setEnsembleIdentStringHasUnsavedChangesMap(newHasUnsavedChangesMap); + setNumberOfUnsavedRealizationFilters(countTrueValues(newHasUnsavedChangesMap)); + } + + function handleSetActiveEnsembleRealizationFilter(ensembleIdent: EnsembleIdent) { + setActiveFilterEnsembleIdent(ensembleIdent); + } + + function handleOnEnsembleRealizationFilterHeaderClick(ensembleIdent: EnsembleIdent) { + if (activeFilterEnsembleIdent?.equals(ensembleIdent)) { + setActiveFilterEnsembleIdent(null); + } } return ( - } - visible={drawerContent === RightDrawerContent.RealizationFilterSettings} - onClose={handleFilterSettingsClose} - > -
-
); }; diff --git a/frontend/src/framework/internal/components/SettingsContentPanels/settingsContentPanels.tsx b/frontend/src/framework/internal/components/SettingsContentPanels/settingsContentPanels.tsx index 746bfef1b..b9d4f004d 100644 --- a/frontend/src/framework/internal/components/SettingsContentPanels/settingsContentPanels.tsx +++ b/frontend/src/framework/internal/components/SettingsContentPanels/settingsContentPanels.tsx @@ -39,7 +39,7 @@ export const SettingsContentPanels: React.FC = (prop 100 - leftSettingsPanelWidth - rightSettingsPanelWidth, rightSettingsPanelWidth, ]} - minSizes={[300, 0, 300]} + minSizes={[300, 0, 400]} onSizesChange={handleResizablePanelsChange} > diff --git a/frontend/src/framework/types/realizationFilterTypes.ts b/frontend/src/framework/types/realizationFilterTypes.ts new file mode 100644 index 000000000..3197bd51f --- /dev/null +++ b/frontend/src/framework/types/realizationFilterTypes.ts @@ -0,0 +1,24 @@ +export enum RealizationFilterType { + BY_REALIZATION_NUMBER = "byRealizationNumber", + BY_PARAMETER_VALUES = "byParameterValues", +} +export const RealizationFilterTypeStringMapping = { + [RealizationFilterType.BY_REALIZATION_NUMBER]: "By Realization Number", + [RealizationFilterType.BY_PARAMETER_VALUES]: "By Parameter Values", +}; + +export enum IncludeExcludeFilter { + INCLUDE_FILTER = "includeFilter", + EXCLUDE_FILTER = "excludeFilter", +} +export const IncludeExcludeFilterEnumToStringMapping = { + [IncludeExcludeFilter.INCLUDE_FILTER]: "Include Filter", + [IncludeExcludeFilter.EXCLUDE_FILTER]: "Exclude Filter", +}; + +export type NumberRange = { start: number; end: number }; +export type RealizationNumberSelection = NumberRange | number; + +export type ContinuousParameterValueSelection = Readonly; +export type DiscreteParameterValueSelection = readonly string[] | readonly number[]; +export type ParameterValueSelection = ContinuousParameterValueSelection | DiscreteParameterValueSelection; diff --git a/frontend/src/framework/types/unsavedChangesAction.ts b/frontend/src/framework/types/unsavedChangesAction.ts new file mode 100644 index 000000000..99d3f3448 --- /dev/null +++ b/frontend/src/framework/types/unsavedChangesAction.ts @@ -0,0 +1,5 @@ +export enum UnsavedChangesAction { + Save = "Save", + Discard = "Discard", + Cancel = "Cancel", +} diff --git a/frontend/src/framework/utils/arrayUtils.ts b/frontend/src/framework/utils/arrayUtils.ts new file mode 100644 index 000000000..c0670a032 --- /dev/null +++ b/frontend/src/framework/utils/arrayUtils.ts @@ -0,0 +1,27 @@ +/** + * Check if array of values is an array of strings. + * + * For arrays where each element is of same type. + */ +export function isArrayOfStrings(values: readonly number[] | readonly string[]): values is readonly string[] { + if (values.length === 0) { + return true; + } + + // Check first element only for efficiency, as input is string[] | number[] + return typeof values[0] === "string"; +} + +/** + * Check if array of values is an array of numbers. + * + * For arrays where each element is of same type. + */ +export function isArrayOfNumbers(values: readonly number[] | readonly string[]): values is readonly number[] { + if (values.length === 0) { + return true; + } + + // Check first element only for efficiency, as input is string[] | number[] + return typeof values[0] === "number"; +} diff --git a/frontend/src/framework/utils/objectUtils.ts b/frontend/src/framework/utils/objectUtils.ts new file mode 100644 index 000000000..e4abed1d0 --- /dev/null +++ b/frontend/src/framework/utils/objectUtils.ts @@ -0,0 +1,8 @@ +/** + * Check number of boolean values equal to true in object of string keys and boolean values + * + * Returns number of true values in object + */ +export function countTrueValues(obj: { [key: string]: boolean }): number { + return Object.values(obj).filter((value) => value).length; +} diff --git a/frontend/src/framework/utils/realizationFilterTypesUtils.ts b/frontend/src/framework/utils/realizationFilterTypesUtils.ts new file mode 100644 index 000000000..045bfa339 --- /dev/null +++ b/frontend/src/framework/utils/realizationFilterTypesUtils.ts @@ -0,0 +1,113 @@ +import { + NumberRange, + ParameterValueSelection, + RealizationNumberSelection, +} from "@framework/types/realizationFilterTypes"; + +import { isEqual, range } from "lodash"; + +import { isArrayOfNumbers, isArrayOfStrings } from "./arrayUtils"; + +/** + * Check if value selection is a number range + */ +export function isValueSelectionANumberRange( + valueSelection: ParameterValueSelection +): valueSelection is Readonly { + if (typeof valueSelection === "object" && valueSelection !== null) { + return "start" in valueSelection && "end" in valueSelection; + } + return false; +} + +/** + * Check if parameter value selection is an array of strings. + */ +export function isValueSelectionAnArrayOfString( + valueSelection: ParameterValueSelection +): valueSelection is readonly string[] { + if (!isValueSelectionANumberRange(valueSelection) && isArrayOfStrings(valueSelection)) { + return true; + } + return false; +} + +/** + * Check if parameter value selection is an array of numbers. + */ +export function isValueSelectionAnArrayOfNumber( + valueSelection: ParameterValueSelection +): valueSelection is readonly number[] { + if (Array.isArray(valueSelection) && isArrayOfNumbers(valueSelection)) { + return true; + } + return false; +} + +/** + * Check if content of two readonly maps are equal + * + * Compare two maps of parameter ident strings to value selections for equality. + */ +export function areParameterIdentStringToValueSelectionReadonlyMapsEqual( + firstMap: ReadonlyMap, + secondMap: ReadonlyMap +): boolean { + // Must have same amount of parameters + if (firstMap.size !== secondMap.size) { + return false; + } + + // Ensure both maps have same keys and selections are equal + for (const [paramIdentString, valueSelection] of firstMap.entries()) { + const otherValueSelection = secondMap.get(paramIdentString); + if (!otherValueSelection || !isEqual(valueSelection, otherValueSelection)) { + return false; + } + } + + return true; +} + +/** + * Check if two parameter ident string to value selection maps are equal. + * + * Allowing null for both maps. + * + * If both maps are null, they are considered equal. + */ +export function areParameterIdentStringToValueSelectionMapCandidatesEqual( + firstMap: ReadonlyMap | null, + secondMap: ReadonlyMap | null +): boolean { + if (firstMap === null && secondMap === null) { + return true; + } + + if (firstMap === null || secondMap === null) { + return false; + } + + return areParameterIdentStringToValueSelectionReadonlyMapsEqual(firstMap, secondMap); +} + +/** + * Convert realization number selections to an array of realization numbers. + */ +export function makeRealizationNumberArrayFromSelections( + selections: readonly RealizationNumberSelection[] | null +): number[] { + if (!selections) return []; + + const realizationNumbers: number[] = []; + for (const selection of selections) { + if (typeof selection === "number") { + realizationNumbers.push(selection); + } else { + const realizationNumbersInRange = range(selection.start, selection.end + 1); + realizationNumbers.push(...realizationNumbersInRange); + } + } + + return realizationNumbers; +} diff --git a/frontend/src/lib/components/DenseIconButton/denseIconButton.tsx b/frontend/src/lib/components/DenseIconButton/denseIconButton.tsx index e2a9ccc8b..90272ceda 100644 --- a/frontend/src/lib/components/DenseIconButton/denseIconButton.tsx +++ b/frontend/src/lib/components/DenseIconButton/denseIconButton.tsx @@ -19,6 +19,7 @@ export type DenseIconButtonProps = { colorScheme?: DenseIconButtonColorScheme; children: React.ReactNode; title?: string; + disabled?: boolean; }; export function DenseIconButton(props: DenseIconButtonProps): React.ReactNode { @@ -32,10 +33,11 @@ export function DenseIconButton(props: DenseIconButtonProps): React.ReactNode { return (