diff --git a/frontend/src/framework/WorkbenchSettings.ts b/frontend/src/framework/WorkbenchSettings.ts index 3a45588c5..277b3b550 100644 --- a/frontend/src/framework/WorkbenchSettings.ts +++ b/frontend/src/framework/WorkbenchSettings.ts @@ -50,6 +50,27 @@ const defaultColorPalettes = [ ], id: "resinsight", }), + new ColorPalette({ + name: "Time Series", + colors: [ + "#1F77B4", + "#FF7F0E", + "#2CA02C", + "#D62728", + "#9467BD", + "#8C564B", + "#E377C2", + "#7F7F7F", + "#BCBD22", + "#17BECF", + ], + id: "time-series", + }), + new ColorPalette({ + name: "Dutch Field", + colors: ["#e60049", "#0bb4ff", "#50e991", "#e6d800", "#9b19f5", "#ffa300", "#dc0ab4", "#b3d4ff", "#00bfa0"], + id: "dutch-field", + }), ]; const defaultContinuousSequentialColorPalettes = [ diff --git a/frontend/src/framework/utils/ensembleUiHelpers.ts b/frontend/src/framework/utils/ensembleUiHelpers.ts index cafac15d2..864fb105d 100644 --- a/frontend/src/framework/utils/ensembleUiHelpers.ts +++ b/frontend/src/framework/utils/ensembleUiHelpers.ts @@ -20,9 +20,9 @@ export function maybeAssignFirstSyncedEnsemble( /** * Validates the the EnsembleIdent specified in currIdent against the contents of the * EnsembleSet and fixes the value if it isn't valid. - * + * * Returns null if an empty EnsembleSet is specified. - * + * * Note that if the specified EnsembleIdent is valid, this function will always return * a reference to the exact same object that was passed in currIdent. This means that * you can compare the references (fixedIdent !== currIdent) to detect any changes. @@ -43,3 +43,28 @@ export function fixupEnsembleIdent( return ensembleSet.getEnsembleArr()[0].getIdent(); } + +/** + * Validates the the EnsembleIdents specified in currIdents against the contents of the + * EnsembleSet and fixes the value if it isn't valid. + * + * Returns null if an empty EnsembleSet is specified. + * + * Note that if the specified EnsembleIdents are valid, this function will always return + * a reference to the exact same object that was passed in currIdent. This means that + * you can compare the references (fixedIdent !== currIdent) to detect any changes. + */ +export function fixupEnsembleIdents( + currIdents: EnsembleIdent[] | null, + ensembleSet: EnsembleSet | null +): EnsembleIdent[] | null { + if (!ensembleSet?.hasAnyEnsembles()) { + return null; + } + + if (currIdents === null || currIdents.length === 0) { + return [ensembleSet.getEnsembleArr()[0].getIdent()]; + } + + return currIdents.filter((currIdent) => ensembleSet.findEnsemble(currIdent)); +} diff --git a/frontend/src/lib/components/ApiStatesWrapper/apiStatesWrapper.tsx b/frontend/src/lib/components/ApiStatesWrapper/apiStatesWrapper.tsx new file mode 100644 index 000000000..7d88d7898 --- /dev/null +++ b/frontend/src/lib/components/ApiStatesWrapper/apiStatesWrapper.tsx @@ -0,0 +1,41 @@ +import React from "react"; + +import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import { QueryObserverResult } from "@tanstack/react-query"; + +export type ApiStatesWrapperProps = { + apiResults: QueryObserverResult[]; + loadingComponent: React.ReactNode; + errorComponent: React.ReactNode; + className?: string; + style?: React.CSSProperties; + children: React.ReactNode; +}; + +export const ApiStatesWrapper: React.FC = (props: ApiStatesWrapperProps) => { + return ( +
elm.isLoading) }, + { "outline outline-red-100 outline-offset-2": props.apiResults.some((elm) => elm.isError) }, + props.className ?? "" + )} + style={props.style} + > + {props.apiResults.some((elm) => elm.isLoading) && ( +
+ {props.loadingComponent} +
+ )} + {props.apiResults.some((elm) => elm.isError) && ( +
+ {props.errorComponent} +
+ )} + {props.children} +
+ ); +}; + +ApiStatesWrapper.displayName = "ApiStatesWrapper"; diff --git a/frontend/src/lib/components/ApiStatesWrapper/index.ts b/frontend/src/lib/components/ApiStatesWrapper/index.ts new file mode 100644 index 000000000..f81343045 --- /dev/null +++ b/frontend/src/lib/components/ApiStatesWrapper/index.ts @@ -0,0 +1 @@ +export { ApiStatesWrapper } from "./apiStatesWrapper"; diff --git a/frontend/src/lib/utils/vectorSelectorUtils.ts b/frontend/src/lib/utils/vectorSelectorUtils.ts new file mode 100644 index 000000000..45bb3cfc1 --- /dev/null +++ b/frontend/src/lib/utils/vectorSelectorUtils.ts @@ -0,0 +1,46 @@ +import { TreeDataNode } from "@lib/components/SmartNodeSelector"; + +export function addVectorToVectorSelectorData( + vectorSelectorData: TreeDataNode[], + vector: string, + description?: string, + descriptionAtLastNode = false +): void { + const nodes = vector.split(":"); + let currentChildList = vectorSelectorData; + + nodes.forEach((node, index) => { + let foundNode = false; + for (const child of currentChildList) { + if (child.name === node) { + foundNode = true; + currentChildList = child.children ?? []; + break; + } + } + if (!foundNode) { + const doAddDescription = + description !== undefined && + ((descriptionAtLastNode && index === nodes.length - 1) || (!descriptionAtLastNode && index === 0)); + + const nodeData: TreeDataNode = { + name: node, + children: index < nodes.length - 1 ? [] : undefined, + description: doAddDescription ? description : undefined, + }; + + currentChildList.push(nodeData); + currentChildList = nodeData.children ?? []; + } + }); +} + +export function createVectorSelectorDataFromVectors(vectors: string[]): TreeDataNode[] { + const vectorSelectorData: TreeDataNode[] = []; + + for (const vector of vectors) { + addVectorToVectorSelectorData(vectorSelectorData, vector); + } + + return vectorSelectorData; +} diff --git a/frontend/src/modules/SimulationTimeSeriesMatrix/loadModule.tsx b/frontend/src/modules/SimulationTimeSeriesMatrix/loadModule.tsx new file mode 100644 index 000000000..5faaf23d9 --- /dev/null +++ b/frontend/src/modules/SimulationTimeSeriesMatrix/loadModule.tsx @@ -0,0 +1,25 @@ +import { Frequency_api, StatisticFunction_api } from "@api"; +import { ModuleRegistry } from "@framework/ModuleRegistry"; + +import { settings } from "./settings"; +import { FanchartStatisticOption, GroupBy, State, VisualizationMode } from "./state"; +import { view } from "./view"; + +const defaultState: State = { + groupBy: GroupBy.TIME_SERIES, + visualizationMode: VisualizationMode.INDIVIDUAL_REALIZATIONS, + vectorSpecifications: [], + resamplingFrequency: Frequency_api.MONTHLY, + showObservations: true, + showHistorical: true, + statisticsSelection: { + IndividualStatisticsSelection: Object.values(StatisticFunction_api), + FanchartStatisticsSelection: Object.values(FanchartStatisticOption), + }, + realizationsToInclude: null, +}; + +const module = ModuleRegistry.initModule("SimulationTimeSeriesMatrix", defaultState); + +module.viewFC = view; +module.settingsFC = settings; diff --git a/frontend/src/modules/SimulationTimeSeriesMatrix/preview.tsx b/frontend/src/modules/SimulationTimeSeriesMatrix/preview.tsx new file mode 100644 index 000000000..d61301c73 --- /dev/null +++ b/frontend/src/modules/SimulationTimeSeriesMatrix/preview.tsx @@ -0,0 +1,48 @@ +import { DrawPreviewFunc } from "@framework/Preview"; + +export const preview: DrawPreviewFunc = function (width: number, height: number) { + const paths: { + x1: number; + y1: number; + x2: number; + y2: number; + x3: number; + y3: number; + xc: number; + yc: number; + color: string; + }[] = []; + const numPaths = 9; + for (let i = 0; i < numPaths; i++) { + const x1 = 0; + const y1 = height - (i / numPaths) * height; + const x2 = width / 2; + const y2 = height - ((i - 1) / numPaths) * height; + const x3 = width; + const y3 = height - (((i - 1) / numPaths) * height) / 1.2; + const xc = width / 4; + const yc = height - (i / numPaths) * height - height / 12; + + // Assign colors based on position + const color = i < 3 ? "green" : i < 6 ? "red" : "blue"; + + paths.push({ x1, y1, x2, y2, x3, y3, xc, yc, color }); + } + return ( + + + + {paths.map((path, index) => { + return ( + + ); + })} + + ); +}; diff --git a/frontend/src/modules/SimulationTimeSeriesMatrix/queryHooks.tsx b/frontend/src/modules/SimulationTimeSeriesMatrix/queryHooks.tsx new file mode 100644 index 000000000..32959b418 --- /dev/null +++ b/frontend/src/modules/SimulationTimeSeriesMatrix/queryHooks.tsx @@ -0,0 +1,143 @@ +import { Frequency_api, VectorDescription_api } from "@api"; +import { VectorHistoricalData_api, VectorRealizationData_api, VectorStatisticData_api } from "@api"; +import { apiService } from "@framework/ApiService"; +import { EnsembleIdent } from "@framework/EnsembleIdent"; +import { UseQueryResult, useQueries } from "@tanstack/react-query"; + +import { VectorSpec } from "./state"; + +const STALE_TIME = 60 * 1000; +const CACHE_TIME = 60 * 1000; + +export function useVectorListQueries( + caseUuidsAndEnsembleNames: EnsembleIdent[] | null +): UseQueryResult[] { + // Note: how to cancel queryFn if key is updated? + return useQueries({ + queries: (caseUuidsAndEnsembleNames ?? []).map((item) => { + return { + queryKey: ["getVectorList", item.getCaseUuid(), item.getEnsembleName()], + queryFn: () => + apiService.timeseries.getVectorList(item.getCaseUuid() ?? "", item.getEnsembleName() ?? ""), + staleTime: STALE_TIME, + cacheTime: CACHE_TIME, + enabled: item.getCaseUuid() && item.getEnsembleName() ? true : false, + }; + }), + }); +} + +export function useVectorDataQueries( + vectorSpecifications: VectorSpec[] | null, + resampleFrequency: Frequency_api | null, + realizationsToInclude: number[] | null, + allowEnable: boolean +): UseQueryResult[] { + // Note: how to cancel queryFn if key is updated? + return useQueries({ + queries: (vectorSpecifications ?? []).map((item) => { + return { + queryKey: [ + "getRealizationsVectorData", + item.ensembleIdent.getCaseUuid(), + item.ensembleIdent.getEnsembleName(), + item.vectorName, + resampleFrequency, + realizationsToInclude, + ], + queryFn: () => + apiService.timeseries.getRealizationsVectorData( + item.ensembleIdent.getCaseUuid() ?? "", + item.ensembleIdent.getEnsembleName() ?? "", + item.vectorName ?? "", + resampleFrequency ?? undefined, + realizationsToInclude ?? undefined + ), + staleTime: STALE_TIME, + cacheTime: CACHE_TIME, + enabled: !!( + allowEnable && + item.vectorName && + item.ensembleIdent.getCaseUuid() && + item.ensembleIdent.getEnsembleName() + ), + }; + }), + }); +} + +export function useStatisticalVectorDataQueries( + vectorSpecifications: VectorSpec[] | null, + resampleFrequency: Frequency_api | null, + realizationsToInclude: number[] | null, + allowEnable: boolean +): UseQueryResult[] { + return useQueries({ + queries: (vectorSpecifications ?? []).map((item) => { + return { + queryKey: [ + "getStatisticalVectorData", + item.ensembleIdent.getCaseUuid(), + item.ensembleIdent.getEnsembleName(), + item.vectorName, + resampleFrequency, + realizationsToInclude, + ], + queryFn: () => + apiService.timeseries.getStatisticalVectorData( + item.ensembleIdent.getCaseUuid() ?? "", + item.ensembleIdent.getEnsembleName() ?? "", + item.vectorName ?? "", + resampleFrequency ?? Frequency_api.MONTHLY, + undefined, + realizationsToInclude ?? undefined + ), + staleTime: STALE_TIME, + cacheTime: CACHE_TIME, + enabled: !!( + allowEnable && + item.vectorName && + item.ensembleIdent.getCaseUuid() && + item.ensembleIdent.getEnsembleName() && + resampleFrequency + ), + }; + }), + }); +} + +export function useHistoricalVectorDataQueries( + nonHistoricalVectorSpecifications: VectorSpec[] | null, + resampleFrequency: Frequency_api | null, + allowEnable: boolean +): UseQueryResult[] { + return useQueries({ + queries: (nonHistoricalVectorSpecifications ?? []).map((item) => { + return { + queryKey: [ + "getHistoricalVectorData", + item.ensembleIdent.getCaseUuid(), + item.ensembleIdent.getEnsembleName(), + item.vectorName, + resampleFrequency, + ], + queryFn: () => + apiService.timeseries.getHistoricalVectorData( + item.ensembleIdent.getCaseUuid() ?? "", + item.ensembleIdent.getEnsembleName() ?? "", + item.vectorName ?? "", + resampleFrequency ?? Frequency_api.MONTHLY + ), + staleTime: STALE_TIME, + cacheTime: CACHE_TIME, + enabled: !!( + allowEnable && + item.vectorName && + item.ensembleIdent.getCaseUuid() && + item.ensembleIdent.getEnsembleName() && + resampleFrequency + ), + }; + }), + }); +} diff --git a/frontend/src/modules/SimulationTimeSeriesMatrix/registerModule.ts b/frontend/src/modules/SimulationTimeSeriesMatrix/registerModule.ts new file mode 100644 index 000000000..f645ed1dc --- /dev/null +++ b/frontend/src/modules/SimulationTimeSeriesMatrix/registerModule.ts @@ -0,0 +1,14 @@ +import { ModuleRegistry } from "@framework/ModuleRegistry"; + +// import { SyncSettingKey } from "@framework/SyncSettings"; +// import { broadcastChannelsDef } from "./channelDefs"; +import { preview } from "./preview"; +import { State } from "./state"; + +ModuleRegistry.registerModule({ + moduleName: "SimulationTimeSeriesMatrix", + defaultTitle: "Simulation Time Series Matrix", + // syncableSettingKeys: [SyncSettingKey.ENSEMBLE, SyncSettingKey.TIME_SERIES], + // broadcastChannelsDef, + preview, +}); diff --git a/frontend/src/modules/SimulationTimeSeriesMatrix/settings.tsx b/frontend/src/modules/SimulationTimeSeriesMatrix/settings.tsx new file mode 100644 index 000000000..09ea1d165 --- /dev/null +++ b/frontend/src/modules/SimulationTimeSeriesMatrix/settings.tsx @@ -0,0 +1,300 @@ +import React from "react"; + +import { Frequency_api, StatisticFunction_api, VectorDescription_api } from "@api"; +import { EnsembleIdent } from "@framework/EnsembleIdent"; +import { EnsembleSet } from "@framework/EnsembleSet"; +import { ModuleFCProps } from "@framework/Module"; +import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { MultiEnsembleSelect } from "@framework/components/MultiEnsembleSelect"; +import { fixupEnsembleIdents } from "@framework/utils/ensembleUiHelpers"; +import { ApiStatesWrapper } from "@lib/components/ApiStatesWrapper"; +import { Checkbox } from "@lib/components/Checkbox"; +import { CircularProgress } from "@lib/components/CircularProgress"; +import { CollapsibleGroup } from "@lib/components/CollapsibleGroup"; +import { Dropdown } from "@lib/components/Dropdown"; +import { Label } from "@lib/components/Label"; +import { RadioGroup } from "@lib/components/RadioGroup"; +import { SmartNodeSelectorSelection, TreeDataNode } from "@lib/components/SmartNodeSelector"; +import { VectorSelector } from "@lib/components/VectorSelector"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import { createVectorSelectorDataFromVectors } from "@lib/utils/vectorSelectorUtils"; + +import { isEqual } from "lodash"; + +import { useVectorListQueries } from "./queryHooks"; +import { + FanchartStatisticOption, + FanchartStatisticOptionEnumToStringMapping, + FrequencyEnumToStringMapping, + GroupBy, + GroupByEnumToStringMapping, + State, + StatisticFunctionEnumToStringMapping, + VectorSpec, + VisualizationMode, + VisualizationModeEnumToStringMapping, +} from "./state"; +import { EnsembleVectorListsHelper } from "./utils/ensemblesVectorListHelper"; + +export function settings({ moduleContext, workbenchSession }: ModuleFCProps) { + const ensembleSet = useEnsembleSet(workbenchSession); + const [resampleFrequency, setResamplingFrequency] = moduleContext.useStoreState("resamplingFrequency"); + const [groupBy, setGroupBy] = moduleContext.useStoreState("groupBy"); + const [visualizationMode, setVisualizationMode] = moduleContext.useStoreState("visualizationMode"); + const [showHistorical, setShowHistorical] = moduleContext.useStoreState("showHistorical"); + const [showObservations, setShowObservations] = moduleContext.useStoreState("showObservations"); + const [statisticsSelection, setStatisticsSelection] = moduleContext.useStoreState("statisticsSelection"); + const setVectorSpecifications = moduleContext.useSetStoreValue("vectorSpecifications"); + + const [previousEnsembleSet, setPreviousEnsembleSet] = React.useState(ensembleSet); + const [selectedEnsembleIdents, setSelectedEnsembleIdents] = React.useState([]); + const [selectedVectorNames, setSelectedVectorNames] = React.useState([]); + const [vectorSelectorData, setVectorSelectorData] = React.useState([]); + + const [prevVisualizationMode, setPrevVisualizationMode] = React.useState(visualizationMode); + if (prevVisualizationMode !== visualizationMode) { + setPrevVisualizationMode(visualizationMode); + } + + const vectorListQueries = useVectorListQueries(selectedEnsembleIdents); + const ensembleVectorListsHelper = new EnsembleVectorListsHelper(selectedEnsembleIdents, vectorListQueries); + const vectorsUnion: VectorDescription_api[] = ensembleVectorListsHelper.vectorsUnion(); + + const selectedVectorNamesHasHistorical = ensembleVectorListsHelper.hasAnyHistoricalVector(selectedVectorNames); + const currentVectorSelectorData = createVectorSelectorDataFromVectors(vectorsUnion.map((vector) => vector.name)); + + // Only update if all vector lists are retrieved before updating vectorSelectorData has changed + const hasVectorListQueriesErrorOrLoading = vectorListQueries.some((query) => query.isLoading || query.isError); + if (!hasVectorListQueriesErrorOrLoading && !isEqual(currentVectorSelectorData, vectorSelectorData)) { + setVectorSelectorData(currentVectorSelectorData); + } + + if (!isEqual(ensembleSet, previousEnsembleSet)) { + const newSelectedEnsembleIdents = selectedEnsembleIdents.filter( + (ensemble) => ensembleSet.findEnsemble(ensemble) !== null + ); + const validatedEnsembleIdents = fixupEnsembleIdents(newSelectedEnsembleIdents, ensembleSet) ?? []; + if (!isEqual(selectedEnsembleIdents, validatedEnsembleIdents)) { + setSelectedEnsembleIdents(validatedEnsembleIdents); + } + + setPreviousEnsembleSet(ensembleSet); + } + + React.useEffect( + function propagateVectorSpecsToView() { + const newVectorSpecifications: VectorSpec[] = []; + for (const ensemble of selectedEnsembleIdents) { + for (const vector of selectedVectorNames) { + if (!ensembleVectorListsHelper.isVectorInEnsemble(ensemble, vector)) { + continue; + } + + newVectorSpecifications.push({ + ensembleIdent: ensemble, + vectorName: vector, + hasHistoricalVector: ensembleVectorListsHelper.hasHistoricalVector(ensemble, vector), + }); + } + } + + setVectorSpecifications(newVectorSpecifications); + }, + [selectedEnsembleIdents, selectedVectorNames, ensembleVectorListsHelper.numberOfQueriesWithData()] + ); + + function handleGroupByChange(event: React.ChangeEvent) { + setGroupBy(event.target.value as GroupBy); + } + + function handleEnsembleSelectChange(ensembleIdentArr: EnsembleIdent[]) { + setSelectedEnsembleIdents(ensembleIdentArr); + } + + function handleVectorSelectChange(selection: SmartNodeSelectorSelection) { + setSelectedVectorNames(selection.selectedTags); + } + + function handleFrequencySelectionChange(newFrequencyStr: string) { + const newFreq = newFrequencyStr !== "RAW" ? (newFrequencyStr as Frequency_api) : null; + setResamplingFrequency(newFreq); + } + + function handleShowHistorical(event: React.ChangeEvent) { + setShowHistorical(event.target.checked); + } + + function handleShowObservations(event: React.ChangeEvent) { + setShowObservations(event.target.checked); + } + + function handleVisualizationModeChange(event: React.ChangeEvent) { + setVisualizationMode(event.target.value as VisualizationMode); + } + + function handleFanchartStatisticsSelectionChange( + event: React.ChangeEvent, + statistic: FanchartStatisticOption + ) { + setStatisticsSelection((prev) => { + if (event.target.checked) { + return { + IndividualStatisticsSelection: prev.IndividualStatisticsSelection, + FanchartStatisticsSelection: prev.FanchartStatisticsSelection + ? [...prev.FanchartStatisticsSelection, statistic] + : [statistic], + }; + } else { + return { + IndividualStatisticsSelection: prev.IndividualStatisticsSelection, + FanchartStatisticsSelection: prev.FanchartStatisticsSelection + ? prev.FanchartStatisticsSelection.filter((item) => item !== statistic) + : [], + }; + } + }); + } + + function handleIndividualStatisticsSelectionChange( + event: React.ChangeEvent, + statistic: StatisticFunction_api + ) { + setStatisticsSelection((prev) => { + if (event.target.checked) { + return { + IndividualStatisticsSelection: prev.IndividualStatisticsSelection + ? [...prev.IndividualStatisticsSelection, statistic] + : [statistic], + FanchartStatisticsSelection: prev.FanchartStatisticsSelection, + }; + } else { + return { + IndividualStatisticsSelection: prev.IndividualStatisticsSelection + ? prev.IndividualStatisticsSelection.filter((item) => item !== statistic) + : [], + FanchartStatisticsSelection: prev.FanchartStatisticsSelection, + }; + } + }); + } + + return ( +
+ + { + return { value: val, label: GroupByEnumToStringMapping[val] }; + })} + onChange={handleGroupByChange} + /> + + + { + return { value: val, label: FrequencyEnumToStringMapping[val] }; + }), + ]} + value={resampleFrequency ?? Frequency_api.MONTHLY} + onChange={handleFrequencySelectionChange} + /> + + + + + + + +
query.isLoading), + })} + > + } + errorComponent={"Could not load the vectors for selected ensembles"} + > + + +
+
+ + { + return { value: val, label: VisualizationModeEnumToStringMapping[val] }; + })} + onChange={handleVisualizationModeChange} + /> +
+ +
+
+
+ ); +} diff --git a/frontend/src/modules/SimulationTimeSeriesMatrix/state.ts b/frontend/src/modules/SimulationTimeSeriesMatrix/state.ts new file mode 100644 index 000000000..5210fe6cb --- /dev/null +++ b/frontend/src/modules/SimulationTimeSeriesMatrix/state.ts @@ -0,0 +1,77 @@ +import { Frequency_api, StatisticFunction_api } from "@api"; +import { EnsembleIdent } from "@framework/EnsembleIdent"; + +export interface VectorSpec { + ensembleIdent: EnsembleIdent; + vectorName: string; + hasHistoricalVector: boolean; +} + +export enum VisualizationMode { + INDIVIDUAL_REALIZATIONS = "IndividualRealizations", + STATISTICAL_LINES = "StatisticalLines", + STATISTICAL_FANCHART = "StatisticalFanchart", + STATISTICS_AND_REALIZATIONS = "StatisticsAndRealizations", +} + +export const VisualizationModeEnumToStringMapping = { + [VisualizationMode.INDIVIDUAL_REALIZATIONS]: "Individual realizations", + [VisualizationMode.STATISTICAL_LINES]: "Statistical lines", + [VisualizationMode.STATISTICAL_FANCHART]: "Statistical fanchart", + [VisualizationMode.STATISTICS_AND_REALIZATIONS]: "Statistics + Realizations", +}; + +// NOTE: Add None as option? +export enum GroupBy { + ENSEMBLE = "ensemble", + TIME_SERIES = "timeSeries", +} + +// NOTE: Add None as option? +export const GroupByEnumToStringMapping = { + [GroupBy.ENSEMBLE]: "Ensemble", + [GroupBy.TIME_SERIES]: "Time Series", +}; + +export const StatisticFunctionEnumToStringMapping = { + [StatisticFunction_api.MEAN]: "Mean", + [StatisticFunction_api.MIN]: "Min", + [StatisticFunction_api.MAX]: "Max", + [StatisticFunction_api.P10]: "P10", + [StatisticFunction_api.P50]: "P50", + [StatisticFunction_api.P90]: "P90", +}; + +export enum FanchartStatisticOption { + MEAN = "mean", + MIN_MAX = "minMax", + P10_P90 = "p10p90", +} + +export const FanchartStatisticOptionEnumToStringMapping = { + [FanchartStatisticOption.MEAN]: "Mean", + [FanchartStatisticOption.MIN_MAX]: "Min/Max", + [FanchartStatisticOption.P10_P90]: "P10/P90", +}; + +export const FrequencyEnumToStringMapping = { + [Frequency_api.DAILY]: "Daily", + [Frequency_api.WEEKLY]: "Weekly", + [Frequency_api.MONTHLY]: "Monthly", + [Frequency_api.QUARTERLY]: "Quarterly", + [Frequency_api.YEARLY]: "Yearly", +}; + +export interface State { + groupBy: GroupBy; + visualizationMode: VisualizationMode; + vectorSpecifications: VectorSpec[] | null; + resamplingFrequency: Frequency_api | null; + showHistorical: boolean; + showObservations: boolean; + statisticsSelection: { + IndividualStatisticsSelection: StatisticFunction_api[]; + FanchartStatisticsSelection: FanchartStatisticOption[]; + }; + realizationsToInclude: number[] | null; +} diff --git a/frontend/src/modules/SimulationTimeSeriesMatrix/utils/PlotlyTraceUtils/createVectorTracesUtils.ts b/frontend/src/modules/SimulationTimeSeriesMatrix/utils/PlotlyTraceUtils/createVectorTracesUtils.ts new file mode 100644 index 000000000..90998dad1 --- /dev/null +++ b/frontend/src/modules/SimulationTimeSeriesMatrix/utils/PlotlyTraceUtils/createVectorTracesUtils.ts @@ -0,0 +1,232 @@ +import { + StatisticFunction_api, + VectorHistoricalData_api, + VectorRealizationData_api, + VectorStatisticData_api, +} from "@api"; + +import { FanchartData, FreeLineData, LowHighData, MinMaxData } from "./fanchartPlotting"; +import { createFanchartTraces } from "./fanchartPlotting"; +import { LineData, StatisticsData, createStatisticsTraces } from "./statisticsPlotting"; + +import { TimeSeriesPlotData } from "../timeSeriesPlotData"; + +/** + Get line shape - "vh" for rate data, "linear" for non-rate data + */ +export function getLineShape(isRate: boolean): "linear" | "vh" { + return isRate ? "vh" : "linear"; +} + +/** + Utility function for creating vector realization traces for an array of vector realization data + for given vector. + */ +export function createVectorRealizationTraces( + vectorRealizationsData: VectorRealizationData_api[], + ensembleName: string, + color: string, + legendGroup: string, + // lineShape: "linear" | "spline" | "hv" | "vh" | "hvh" | "vhv", + hoverTemplate: string, + showLegend = false, + yaxis = "y", + xaxis = "x" +): Partial[] { + // TODO: + // - type: "scattergl" or "scatter"? + // - vector name? + // - realization number? + // - lineShape - Each VectorRealizationData_api element has its own `is_rate` property. Should we + // use that to determine the line shape or provide a lineShape argument? + + return vectorRealizationsData.map((realization) => { + return { + x: realization.timestamps_utc_ms, + y: realization.values, + line: { width: 1, color: color, shape: getLineShape(realization.is_rate) }, + mode: "lines", + type: "scattergl", + hovertemplate: `${hoverTemplate}Realization: ${realization.realization}, Ensemble: ${ensembleName}`, + // realizationNumber: realization.realization, + name: legendGroup, + legendgroup: legendGroup, + showlegend: realization.realization === 0 && showLegend ? true : false, + yaxis: yaxis, + xaxis: xaxis, + } as Partial; + }); +} + +/** + Utility function for creating trace for historical vector data + */ +export function createHistoricalVectorTrace( + vectorHistoricalData: VectorHistoricalData_api, + color = "black", + yaxis = "y", + xaxis = "x", + showLegend = false, + // lineShape: "linear" | "spline" | "hv" | "vh" | "hvh" | "vhv", + vectorName?: string, + legendRank?: number +): Partial { + const hoverText = vectorName ? `History: ${vectorName}` : "History"; + return { + line: { shape: getLineShape(vectorHistoricalData.is_rate), color: color }, + mode: "lines", + type: "scatter", + x: vectorHistoricalData.timestamps_utc_ms, + y: vectorHistoricalData.values, + hovertext: hoverText, + hoverinfo: "y+x+text", + name: "History", + showlegend: showLegend, + legendgroup: "History", + legendrank: legendRank, + yaxis: yaxis, + xaxis: xaxis, + }; +} + +/** + Utility function for creating traces representing statistical fanchart for given statistics data. + + The function creates filled transparent area between P10 and P90, and between MIN and MAX, and a free line + for MEAN. + + NOTE: P10 and P90, and MIN and MAX are considered to work in pairs, therefore the pairs are neglected if + only one of the statistics in each pair is present in the data. I.e. P10/P90 is neglected if only P10 or P90 + is presented in the data. Similarly, MIN/MAX is neglected if only MIN or MAX is presented in the data. + */ +export function createVectorFanchartTraces( + vectorStatisticData: VectorStatisticData_api, + hexColor: string, + legendGroup: string, + yaxis = "y", + // lineShape: "vh" | "linear" | "spline" | "hv" | "hvh" | "vhv" = "linear", + hoverTemplate = "(%{x}, %{y})
", + showLegend = false, + legendRank?: number +): Partial[] { + const lowData = vectorStatisticData.value_objects.find((v) => v.statistic_function === StatisticFunction_api.P90); + const highData = vectorStatisticData.value_objects.find((v) => v.statistic_function === StatisticFunction_api.P10); + let lowHighData: LowHighData | undefined = undefined; + if (lowData && highData) { + lowHighData = { + highName: highData.statistic_function.toString(), + highData: highData.values, + lowName: lowData.statistic_function.toString(), + lowData: lowData.values, + }; + } + + const minData = vectorStatisticData.value_objects.find((v) => v.statistic_function === StatisticFunction_api.MIN); + const maxData = vectorStatisticData.value_objects.find((v) => v.statistic_function === StatisticFunction_api.MAX); + let minMaxData: MinMaxData | undefined = undefined; + if (minData && maxData) { + minMaxData = { + maximum: maxData.values, + minimum: minData.values, + }; + } + + const meanData = vectorStatisticData.value_objects.find((v) => v.statistic_function === StatisticFunction_api.MEAN); + let meanFreeLineData: FreeLineData | undefined = undefined; + if (meanData) { + meanFreeLineData = { + name: meanData.statistic_function.toString(), + data: meanData.values, + }; + } + + const fanchartData: FanchartData = { + samples: vectorStatisticData.timestamps_utc_ms, + lowHigh: lowHighData, + minimumMaximum: minMaxData, + freeLine: meanFreeLineData, + }; + + return createFanchartTraces({ + data: fanchartData, + hexColor: hexColor, + legendGroup: legendGroup, + lineShape: getLineShape(vectorStatisticData.is_rate), + showLegend: showLegend, + hoverTemplate: hoverTemplate, + legendRank: legendRank, + yaxis: yaxis, + }); +} + +/** + Utility function for creating traces for statistical lines for given statistics data. + + The function creates lines for P10, P50, P90, MIN, MAX, and MEAN. Solid line for MEAN, various + dashed lines for the remaining statistics. + */ +export function createVectorStatisticsTraces( + vectorStatisticData: VectorStatisticData_api, + color: string, + legendGroup: string, + yaxis = "y", + // lineShape: "vh" | "linear" | "spline" | "hv" | "hvh" | "vhv" = "linear", + lineWidth = 2, + hoverTemplate = "(%{x}, %{y})
", + showLegend = false, + legendRank?: number +): Partial[] { + const lowValueObject = vectorStatisticData.value_objects.find( + (v) => v.statistic_function === StatisticFunction_api.P90 + ); + const midValueObject = vectorStatisticData.value_objects.find( + (v) => v.statistic_function === StatisticFunction_api.P50 + ); + const highValueObject = vectorStatisticData.value_objects.find( + (v) => v.statistic_function === StatisticFunction_api.P10 + ); + const minValueObject = vectorStatisticData.value_objects.find( + (v) => v.statistic_function === StatisticFunction_api.MIN + ); + const maxValueObject = vectorStatisticData.value_objects.find( + (v) => v.statistic_function === StatisticFunction_api.MAX + ); + const meanValueObject = vectorStatisticData.value_objects.find( + (v) => v.statistic_function === StatisticFunction_api.MEAN + ); + + const lowData: LineData | undefined = lowValueObject + ? { data: lowValueObject.values, name: lowValueObject.statistic_function.toString() } + : undefined; + const midData: LineData | undefined = midValueObject + ? { data: midValueObject.values, name: midValueObject.statistic_function.toString() } + : undefined; + const highData: LineData | undefined = highValueObject + ? { data: highValueObject.values, name: highValueObject.statistic_function.toString() } + : undefined; + const meanData: LineData | undefined = meanValueObject + ? { data: meanValueObject.values, name: meanValueObject.statistic_function.toString() } + : undefined; + + const statisticsData: StatisticsData = { + samples: vectorStatisticData.timestamps_utc_ms, + freeLine: meanData, + minimum: minValueObject ? minValueObject.values : undefined, + maximum: maxValueObject ? maxValueObject.values : undefined, + lowPercentile: lowData, + highPercentile: highData, + midPercentile: midData, + }; + + return createStatisticsTraces({ + data: statisticsData, + color: color, + legendGroup: legendGroup, + lineShape: getLineShape(vectorStatisticData.is_rate), + lineWidth: lineWidth, + showLegend: showLegend, + hoverTemplate: hoverTemplate, + legendRank: legendRank, + yaxis: yaxis, + }); +} diff --git a/frontend/src/modules/SimulationTimeSeriesMatrix/utils/PlotlyTraceUtils/fanchartPlotting.ts b/frontend/src/modules/SimulationTimeSeriesMatrix/utils/PlotlyTraceUtils/fanchartPlotting.ts new file mode 100644 index 000000000..da29c6526 --- /dev/null +++ b/frontend/src/modules/SimulationTimeSeriesMatrix/utils/PlotlyTraceUtils/fanchartPlotting.ts @@ -0,0 +1,271 @@ +import { formatRgb, modeRgb, useMode } from "culori"; +import { ScatterLine } from "plotly.js"; + +import { TimeSeriesPlotData } from "../timeSeriesPlotData"; + +/** + Definition of statistics data for free line trace in fanchart + + * `name` - Name of statistics data (e.g. mean, median, etc.) + * `data` - List of statistics value data + */ +export type FreeLineData = { + name: string; + data: number[]; +}; + +/** + Defining paired low and high percentile data for fanchart plotting + + * `lowData` - List of low percentile data + * `lowName` - Name of low percentile data (e.g. 10th percentile) + * `highData` - List of high percentile data + * `highName` - Name of high percentile data (e.g. 90th percentile) + */ +export type LowHighData = { + lowData: number[]; + lowName: string; + highData: number[]; + highName: string; +}; + +/** + Definition of paired minimum and maximum data for fanchart plotting + + * `minimum` - List of minimum value data + * `maximum` - List of maximum value data + */ +export type MinMaxData = { + minimum: number[]; + maximum: number[]; +}; + +/** + Type defining fanchart data utilized in creation of statistical fanchart traces + + * `samples` - Common sample point list for each following value list. Can be list of strings or numbers + * `freeLine` - Optional statistics with name and value data for free line trace in fanchart (e.g. + mean, median, etc.) + * `minimumMaximum` - Paired optional minimum and maximum data for fanchart plotting + * `lowHigh` - Paired optional low and high percentile names and data for fanchart plotting + */ +export type FanchartData = { + samples: string[] | number[]; + freeLine?: FreeLineData; + minimumMaximum?: MinMaxData; + lowHigh?: LowHighData; +}; + +/** + Direction of traces in fanchart + */ +enum TraceDirection { + HORIZONTAL = "horizontal", + VERTICAL = "vertical", +} + +/** + Validation of fanchart data + + Ensure equal length of all statistical fanchart data lists and x-axis data list + + Throw error if lengths are unequal +*/ +function validateFanchartData(data: FanchartData): void { + const samplesLength = data.samples.length; + + if (samplesLength <= 0) { + throw new Error("Empty x-axis data list in FanchartData"); + } + + if (data.freeLine !== undefined && samplesLength !== data.freeLine.data.length) { + throw new Error("Invalid fanchart mean value data length. data.samples.length !== freeLine.data.length"); + } + + if (data.minimumMaximum !== undefined && samplesLength !== data.minimumMaximum.minimum.length) { + throw new Error( + "Invalid fanchart minimum value data length. data.samples.length !== data.minimumMaximum.minimum.length" + ); + } + + if (data.minimumMaximum !== undefined && samplesLength !== data.minimumMaximum.maximum.length) { + throw new Error( + "Invalid fanchart maximum value data length. data.samples.length !== data.minimumMaximum.maximum.length" + ); + } + + if (data.lowHigh !== undefined && samplesLength !== data.lowHigh.lowData.length) { + throw new Error( + "Invalid fanchart low percentile value data length. data.samples.length !== data.lowHigh.lowData.length" + ); + } + + if (data.lowHigh !== undefined && samplesLength !== data.lowHigh.highData.length) { + throw new Error( + "Invalid fanchart high percentile value data length. data.samples.length !== data.lowHigh.highData.length" + ); + } +} + +/** + Definition of options for creating statistical fanchart traces + + To be used as input to createFanchartTraces function with default values for optional arguments. + */ +export type CreateFanchartTracesOptions = { + data: FanchartData; + hexColor: string; + legendGroup: string; + lineShape?: ScatterLine["shape"]; + showLegend?: boolean; + hoverTemplate?: string; + legendRank?: number; + yaxis?: string; + xaxis?: string; + direction?: TraceDirection; + showHoverInfo?: boolean; + hoverText?: string; + legendName?: string; + // hovermode?: string, +}; + +/** + Utility function for creating statistical fanchart traces + + Takes `data` containing data for each statistical feature as input, and creates a list of traces + for each feature. Plotly plots traces from front to end of the list, thereby the last trace is + plotted on top. + + Note that min and max, and high and low percentile are paired optional statistics. This implies + that if minimum is provided, maximum must be provided as well, and vice versa. The same yields + for low and high percentile data. + + The function provides a list of traces: [trace0, tract1, ..., traceN] + + Fanchart is created by use of fill "tonexty" configuration for the traces. Fill "tonexty" is + misleading naming, as "tonexty" in trace1 fills to y in trace0, i.e y in previous trace. + + The order of traces are minimum, low, high, maximum and free line. Thus it is required that + values in minimum <= low, and low <= high, and high <= maximum. Fill is setting "tonexty" in + this function is set s.t. trace fillings are not stacked making colors in fills unchanged + when disabling trace statistics inputs (minimum and maximum or low and high). + + Free line is last trace and is plotted on top as a line - without filling to other traces. + + Note: + If hovertemplate is proved it overrides the hovertext + + Returns: + List of fanchart traces, one for each statistical feature in data input. + [trace0, tract1, ..., traceN]. + */ +export function createFanchartTraces({ + data, + hexColor, + legendGroup, + lineShape = "linear", + showLegend = true, + hoverTemplate = undefined, + legendRank = undefined, + yaxis = "y", + xaxis = "x", + direction = TraceDirection.HORIZONTAL, + showHoverInfo = true, + hoverText = "", + legendName = undefined, +}: CreateFanchartTracesOptions): Partial[] { + // NOTE: + // - hovermode? not exposed? + + // TODO: Remove unused default arguments? + + validateFanchartData(data); + + const convertRgb = useMode(modeRgb); + const rgb = convertRgb(hexColor); + if (rgb === undefined) { + throw new Error("Invalid conversion of hex color string: " + hexColor + " to rgb."); + } + const fillColorLight = formatRgb({ ...rgb, alpha: 0.3 }); + const fillColorDark = formatRgb({ ...rgb, alpha: 0.6 }); + const lineColor = formatRgb({ ...rgb, alpha: 1.0 }); + + function getDefaultTrace(statisticsName: string, values: number[]): Partial { + const trace: Partial = { + name: legendName ?? legendGroup, + x: direction === TraceDirection.HORIZONTAL ? data.samples : values, + y: direction === TraceDirection.HORIZONTAL ? values : data.samples, + xaxis: xaxis, + yaxis: yaxis, + mode: "lines", + type: "scatter", + line: { width: 0, color: lineColor, shape: lineShape }, + legendgroup: legendGroup, + showlegend: false, + }; + + if (legendRank !== undefined) { + trace.legendrank = legendRank; + } + if (!showHoverInfo) { + trace.hoverinfo = "skip"; + return trace; + } + if (hoverTemplate !== undefined) { + trace.hovertemplate = hoverTemplate + statisticsName; + } else { + trace.hovertext = statisticsName + " " + hoverText; + } + return trace; + } + + const traces: Partial[] = []; + + // Minimum + if (data.minimumMaximum !== undefined) { + traces.push(getDefaultTrace("Minimum", data.minimumMaximum.minimum)); + } + + // Low and high percentile + if (data.lowHigh !== undefined) { + const lowTrace = getDefaultTrace(data.lowHigh.lowName, data.lowHigh.lowData); + + // Add fill to previous trace + if (traces.length > 0) { + lowTrace.fill = "tonexty"; + lowTrace.fillcolor = fillColorLight; + } + traces.push(lowTrace); + + const highTrace = getDefaultTrace(data.lowHigh.highName, data.lowHigh.highData); + highTrace.fill = "tonexty"; + highTrace.fillcolor = fillColorDark; + traces.push(highTrace); + } + + // Maximum + if (data.minimumMaximum !== undefined) { + const maximumTrace = getDefaultTrace("Maximum", data.minimumMaximum.maximum); + + // Add fill to previous trace + if (traces.length > 0) { + maximumTrace.fill = "tonexty"; + maximumTrace.fillcolor = fillColorLight; + } + traces.push(maximumTrace); + } + + // Free line - solid line + if (data.freeLine !== undefined) { + const lineTrace = getDefaultTrace(data.freeLine.name, data.freeLine.data); + lineTrace.line = { color: lineColor, shape: lineShape }; + traces.push(lineTrace); + } + + // Set legend for last trace in list + if (traces.length > 0) { + traces[traces.length - 1].showlegend = showLegend; + } + + return traces; +} diff --git a/frontend/src/modules/SimulationTimeSeriesMatrix/utils/PlotlyTraceUtils/statisticsPlotting.ts b/frontend/src/modules/SimulationTimeSeriesMatrix/utils/PlotlyTraceUtils/statisticsPlotting.ts new file mode 100644 index 000000000..37befdcb3 --- /dev/null +++ b/frontend/src/modules/SimulationTimeSeriesMatrix/utils/PlotlyTraceUtils/statisticsPlotting.ts @@ -0,0 +1,238 @@ +import { ScatterLine } from "plotly.js"; + +import { TimeSeriesPlotData } from "../timeSeriesPlotData"; + +/** + Definition of line trace data for statistics plot + + * `data` - List of value data + * `name` - Name of line data + */ +export type LineData = { + data: number[]; + name: string; +}; + +/** + Definition of statistics data utilized in creation of statistical plot traces + + `Attributes:` + * `samples` - Common sample point list for each following value list. Can be list of strings or numbers + * `freeLine` - LineData with name and value data for free line trace in statistics plot + (e.g. mean, median, etc.) + * `minimum` - Optional list of minimum value data for statistics plot + * `maximum` - Optional list of maximum value data for statistics plot + * `lowPercentile` - Optional low percentile, name and data values for statistics plot + * `midPercentile` - Optional middle percentile, name and data values for statistics plot + * `highPercentile` - Optional high percentile, name and data values for statistics plot + */ +export type StatisticsData = { + samples: string[] | number[]; + freeLine?: LineData; + minimum?: number[]; + maximum?: number[]; + lowPercentile?: LineData; + highPercentile?: LineData; + midPercentile?: LineData; +}; + +/** + Validation of statistics data + + Ensure equal length of all statistical data lists and x-axis data list + + Throw error if lengths are unequal + */ +function validateStatisticsData(data: StatisticsData): void { + const samplesLength = data.samples.length; + + if (samplesLength <= 0) { + throw new Error("Empty x-axis data list in StatisticsData"); + } + + if (data.freeLine !== undefined && samplesLength !== data.freeLine.data.length) { + throw new Error( + `Invalid statistics mean value data length. data.samples.length (${samplesLength}) != data.freeLine.data.length (${data.freeLine.data.length})` + ); + } + + if (data.minimum !== undefined && samplesLength !== data.minimum.length) { + throw new Error( + `Invalid statistics minimum value data length. data.samples.length (${samplesLength}) != data.minimum.length (${data.minimum.length})` + ); + } + + if (data.maximum !== undefined && samplesLength !== data.maximum.length) { + throw new Error( + `Invalid statistics maximum value data length. data.samples.length (${samplesLength}) != data.maximum.length (${data.maximum.length})` + ); + } + + if (data.lowPercentile !== undefined && samplesLength !== data.lowPercentile.data.length) { + throw new Error( + `Invalid statistics low percentile value data length. data.samples.length (${samplesLength}) != data.lowPercentile.data.length (${data.lowPercentile.data.length})` + ); + } + + if (data.midPercentile !== undefined && samplesLength !== data.midPercentile.data.length) { + throw new Error( + `Invalid statistics middle percentile value data length. data.samples.length (${samplesLength}) != data.midPercentile.data.length (${data.midPercentile.data.length})` + ); + } + + if (data.highPercentile !== undefined && samplesLength !== data.highPercentile.data.length) { + throw new Error( + `Invalid statistics high percentile value data length. data.samples.length (${samplesLength}) != data.highPercentile.data.length (${data.highPercentile.data.length})` + ); + } +} + +/** + Definition of options for creating statistical plot trace + + To be used as input to createStatisticsTraces function with default values for optional arguments. + */ +export type CreateStatisticsTracesOptions = { + data: StatisticsData; + color: string; + legendGroup: string; + lineShape?: ScatterLine["shape"]; + showLegend?: boolean; + hoverTemplate?: string; + legendRank?: number; + xaxis?: string; + yaxis?: string; + lineWidth?: number; + showHoverInfo?: boolean; + hoverText?: string; + legendName?: string; + // hovermode?: string, +}; + +/** + Utility function for creating statistical plot traces + + Takes `data` containing data for each statistical feature as input, and creates a list of traces + for each feature. Plotly plots traces from front to end of the list, thereby the last trace is + plotted on top. + + Note that the data is optional, which implies that only wanted statistical features needs to be + provided for trace plot generation. + + The function provides a list of traces: [trace0, tract1, ..., traceN] + + Note: + If hovertemplate is proved it overrides the hovertext + + Returns: + List of statistical line traces, one for each statistical feature in data input. + [trace0, tract1, ..., traceN]. + */ +export function createStatisticsTraces({ + data, + color, + legendGroup, + legendName = undefined, + lineShape = "linear", + lineWidth = 2, + xaxis = "x", + yaxis = "y", + showLegend = true, + showHoverInfo = true, + hoverText = "", + hoverTemplate = undefined, + legendRank = undefined, +}: CreateStatisticsTracesOptions): Partial[] { + // NOTE: + // - hovermode? not exposed? + + validateStatisticsData(data); + + function getDefaultTrace(statisticsName: string, values: number[]): Partial { + const trace: Partial = { + name: legendName ?? legendGroup, + x: data.samples, + y: values, + xaxis: xaxis, + yaxis: yaxis, + mode: "lines", + type: "scatter", + line: { color: color, width: lineWidth, shape: lineShape }, + legendgroup: legendGroup, + showlegend: false, + }; + if (legendRank !== undefined) { + trace.legendrank = legendRank; + } + if (!showHoverInfo) { + trace.hoverinfo = "skip"; + return trace; + } + if (hoverTemplate !== undefined) { + trace.hovertemplate = hoverTemplate + statisticsName; + } else { + trace.hovertext = statisticsName + " " + hoverText; + } + return trace; + } + + const traces: Partial[] = []; + + // Minimum + if (data.minimum !== undefined) { + const minimumTrace = getDefaultTrace("Minimum", data.minimum); + if (minimumTrace.line) { + minimumTrace.line.dash = "longdash"; + } + traces.push(minimumTrace); + } + + // Low percentile + if (data.lowPercentile !== undefined) { + const lowPercentileTrace = getDefaultTrace(data.lowPercentile.name, data.lowPercentile.data); + if (lowPercentileTrace.line) { + lowPercentileTrace.line.dash = "dashdot"; + } + traces.push(lowPercentileTrace); + } + + // Mid percentile + if (data.midPercentile !== undefined) { + const midPercentileTrace = getDefaultTrace(data.midPercentile.name, data.midPercentile.data); + if (midPercentileTrace.line) { + midPercentileTrace.line.dash = "dot"; + } + traces.push(midPercentileTrace); + } + + // High percentile + if (data.highPercentile !== undefined) { + const highPercentileTrace = getDefaultTrace(data.highPercentile.name, data.highPercentile.data); + if (highPercentileTrace.line) { + highPercentileTrace.line.dash = "dashdot"; + } + traces.push(highPercentileTrace); + } + + // Maximum + if (data.maximum !== undefined) { + const maximumTrace = getDefaultTrace("Maximum", data.maximum); + if (maximumTrace.line) { + maximumTrace.line.dash = "longdash"; + } + traces.push(maximumTrace); + } + + // Free line + if (data.freeLine !== undefined) { + const freeLineTrace = getDefaultTrace(data.freeLine.name, data.freeLine.data); + traces.push(freeLineTrace); + } + + // Set legend for last trace in list + if (traces.length > 0) { + traces[traces.length - 1].showlegend = showLegend; + } + + return traces; +} diff --git a/frontend/src/modules/SimulationTimeSeriesMatrix/utils/colorUtils.ts b/frontend/src/modules/SimulationTimeSeriesMatrix/utils/colorUtils.ts new file mode 100644 index 000000000..c846794be --- /dev/null +++ b/frontend/src/modules/SimulationTimeSeriesMatrix/utils/colorUtils.ts @@ -0,0 +1,26 @@ +import { formatHex, formatHsl, modeHsl, useMode } from "culori"; + +/** + Converts the given hex color to hsl and adjusts the l-channel with the given scale. + + If conversion to hsl fails, the function returns undefined. + */ +export function scaleHexColorLightness( + hexColor: string, + scale: number, + minScale = 0.1, + maxScale = 1.5 +): string | undefined { + // Convert min and max to scalar 0-1 + const min = Math.max(0.0, minScale); + const max = Math.min(2.0, maxScale); + + const hslColor = useMode(modeHsl); + const result = hslColor(hexColor); + if (result) { + const adjustedHslColor = { ...result, l: Math.min(max, Math.max(min, result.l * scale)) }; + return formatHex(formatHsl(adjustedHslColor)) ?? hexColor; + } + + return undefined; +} diff --git a/frontend/src/modules/SimulationTimeSeriesMatrix/utils/ensemblesVectorListHelper.ts b/frontend/src/modules/SimulationTimeSeriesMatrix/utils/ensemblesVectorListHelper.ts new file mode 100644 index 000000000..7db9cb2c4 --- /dev/null +++ b/frontend/src/modules/SimulationTimeSeriesMatrix/utils/ensemblesVectorListHelper.ts @@ -0,0 +1,86 @@ +import { VectorDescription_api } from "@api"; +import { EnsembleIdent } from "@framework/EnsembleIdent"; +import { UseQueryResult } from "@tanstack/react-query"; + +/** + * Helper class for working with ensembles and corresponding vector list query results + */ +export class EnsembleVectorListsHelper { + private _ensembleIdents: EnsembleIdent[]; + private _queries: UseQueryResult[]; + + constructor(ensembles: EnsembleIdent[], vectorListQueryResults: UseQueryResult[]) { + if (ensembles.length !== vectorListQueryResults.length) { + throw new Error("Number of ensembles and vector list query results must be equal"); + } + + this._ensembleIdents = ensembles; + this._queries = vectorListQueryResults; + } + + /** + * + * @returns Number of queries with data results + */ + numberOfQueriesWithData(): number { + return this._queries.filter((query) => query.data).length; + } + + /** + * + * @returns Array of unique vector names, as union of all vectors in all queries + */ + vectorsUnion(): VectorDescription_api[] { + const vectorUnion: VectorDescription_api[] = []; + for (const query of this._queries) { + if (!query.data) continue; + + // Add vector if name is not already in vectorUnion + for (const vector of query.data) { + if (!vectorUnion.some((v) => v.name === vector.name)) { + vectorUnion.push(vector); + } + } + } + return vectorUnion; + } + + /** + * + * @param ensemble - EnsembleIdent to check + * @param vector - Vector name to look for + * @returns + */ + isVectorInEnsemble(ensemble: EnsembleIdent, vector: string): boolean { + const index = this._ensembleIdents.indexOf(ensemble); + + if (!this._queries[index].data) return false; + + return this._queries[index].data?.some((vec) => vec.name === vector) ?? false; + } + + /** + * + * @param ensemble - EnsembleIdent to check + * @param vector - Vector name to look for + * @returns + */ + hasHistoricalVector(ensemble: EnsembleIdent, vector: string): boolean { + if (!this.isVectorInEnsemble(ensemble, vector)) return false; + + const index = this._ensembleIdents.indexOf(ensemble); + + return this._queries[index].data?.some((vec) => vec.name === vector && vec.has_historical) ?? false; + } + + /** + * + * @param vectors - Array of vector names to look for + * @returns True if one or more of the vectors has historical vector in one or more of the ensembles, false otherwise + */ + hasAnyHistoricalVector(vectors: string[]): boolean { + return this._ensembleIdents.some((ensemble) => + vectors.some((vector) => this.hasHistoricalVector(ensemble, vector)) + ); + } +} diff --git a/frontend/src/modules/SimulationTimeSeriesMatrix/utils/subplotBuilder.ts b/frontend/src/modules/SimulationTimeSeriesMatrix/utils/subplotBuilder.ts new file mode 100644 index 000000000..e3cd6d986 --- /dev/null +++ b/frontend/src/modules/SimulationTimeSeriesMatrix/utils/subplotBuilder.ts @@ -0,0 +1,397 @@ +import { VectorHistoricalData_api, VectorRealizationData_api, VectorStatisticData_api } from "@api"; +import { ColorSet } from "@lib/utils/ColorSet"; + +// import { filterBrightness, formatHex, parseHex } from "culori"; +import { Annotations, Layout } from "plotly.js"; + +import { + createHistoricalVectorTrace, + createVectorFanchartTraces, + createVectorRealizationTraces, + createVectorStatisticsTraces, +} from "./PlotlyTraceUtils/createVectorTracesUtils"; +import { scaleHexColorLightness } from "./colorUtils"; +import { TimeSeriesPlotData } from "./timeSeriesPlotData"; + +import { VectorSpec } from "../state"; + +export type HexColorMap = { [name: string]: string }; + +export enum SubplotOwner { + VECTOR = "Vector", + ENSEMBLE = "Ensemble", +} + +/** + Helper class to build layout and corresponding plot data for plotly figure + with subplot per selected vector or per selected ensemble according to grouping selection. + + */ +export class SubplotBuilder { + private _selectedVectorSpecifications: VectorSpec[] = []; + private _plotData: Partial[] = []; + private _numberOfSubplots = 0; + private _subplotOwner: SubplotOwner; + + private _addedVectorsLegendTracker: string[] = []; + private _addedEnsemblesLegendTracker: string[] = []; + + private _uniqueEnsembleNames: string[] = []; + private _uniqueVectorNames: string[] = []; + + private _ensembleHexColors: HexColorMap = {}; + private _vectorHexColors: HexColorMap = {}; + // private _brighten = filterBrightness(1.3, "rgb"); + + private _hasHistoryTraces = false; + private _hasObservationTraces = false; + private _historyVectorColor = "black"; + private _observationColor = "black"; + + private _width = 0; + private _height = 0; + + constructor( + subplotOwner: SubplotOwner, + selectedVectorSpecifications: VectorSpec[], + colorSet: ColorSet, + width: number, + height: number + ) { + this._selectedVectorSpecifications = selectedVectorSpecifications; + this._width = width; + this._height = height; + + this._uniqueVectorNames = [...new Set(selectedVectorSpecifications.map((vec) => vec.vectorName))]; + this._uniqueEnsembleNames = [ + ...new Set(selectedVectorSpecifications.map((vec) => vec.ensembleIdent.getEnsembleName())), + ]; + + // Create map with color for each vector and ensemble + this._uniqueVectorNames.forEach((vectorName, index) => { + const color = index === 0 ? colorSet.getFirstColor() : colorSet.getNextColor(); + this._vectorHexColors[vectorName] = color; + }); + this._uniqueEnsembleNames.forEach((ensembleName, index) => { + const color = index === 0 ? colorSet.getFirstColor() : colorSet.getNextColor(); + this._ensembleHexColors[ensembleName] = color; + }); + + this._subplotOwner = subplotOwner; + this._numberOfSubplots = + this._subplotOwner === SubplotOwner.VECTOR + ? this._uniqueVectorNames.length + : this._uniqueEnsembleNames.length; + + // TODO: + // - Handle keep uirevision? + // - Assign same color to vector independent of order in vector list? + // - Determine which color brightness method to utilize + } + + createPlotData(): Partial[] { + this.createGraphLegends(); + return this._plotData; + } + + createPlotLayout(): Partial { + // NOTE: + // - Should one add xaxis: { type: "date" }, xaxis2: { type: "date" }, etc.? One for each xaxis? Seems to work with only xaxis: { type: "date" } + // - Annotations only way to create subplot titles? + return { + width: this._width, + height: this._height, + margin: { t: 30, r: 0, l: 40, b: 40 }, + xaxis: { type: "date" }, + grid: { rows: this._numberOfSubplots, columns: 1, pattern: "coupled" }, + annotations: this.subplotTitles(), + // uirevision: "true", // NOTE: Only works if vector data is cached, as Plot might receive empty data on rerender + }; + } + + subplotTitles(): Partial[] { + // NOTE: Annotations only way to create subplot titles? + // See: https://github.com/plotly/plotly.js/issues/2746 + const titles: Partial[] = []; + + const titleAnnotation = (title: string, yPosition: number): Partial => { + return { + xref: "paper", + yref: "paper", + x: 0.5, + y: yPosition, + xanchor: "center", + yanchor: "bottom", + text: title, + showarrow: false, + }; + }; + + if (this._subplotOwner === SubplotOwner.VECTOR) { + this._uniqueVectorNames.forEach((vec, index) => { + const yPosition = 1 - index / this._numberOfSubplots - 0.01; + titles.push(titleAnnotation(`Vector: "${vec}"`, yPosition)); + }); + } else if (this._subplotOwner === SubplotOwner.ENSEMBLE) { + this._uniqueEnsembleNames.forEach((ens, index) => { + const yPosition = 1 - index / this._numberOfSubplots - 0.01; + titles.push(titleAnnotation(`Ensemble: "${ens}"`, yPosition)); + }); + } + return titles; + } + + // Create legends + createGraphLegends(): void { + let currentLegendRank = 1; + + // Helper function to create legend trace + const subplotDataLegendTrace = (name: string, hexColor: string): Partial => { + return { + name: name, + x: [null], + y: [null], + legendgroup: name, + showlegend: true, + visible: true, + mode: "lines", + line: { color: hexColor }, + legendrank: currentLegendRank++, + yaxis: `y1`, + }; + }; + + // Add legend for each vector/ensemble on top + if (this._subplotOwner === SubplotOwner.ENSEMBLE) { + this._addedVectorsLegendTracker.forEach((vectorName) => { + this._plotData.push(subplotDataLegendTrace(vectorName, this._vectorHexColors[vectorName])); + }); + } else if (this._subplotOwner === SubplotOwner.VECTOR) { + this._addedEnsemblesLegendTracker.forEach((ensembleName) => { + this._plotData.push(subplotDataLegendTrace(ensembleName, this._ensembleHexColors[ensembleName])); + }); + } + + // Add legend for history trace with legendrank after vectors/ensembles + if (this._hasHistoryTraces) { + const historyLegendTrace: Partial = { + name: "History", + x: [null], + y: [null], + legendgroup: "History", + showlegend: true, + visible: true, + mode: "lines", + line: { color: this._historyVectorColor }, + legendrank: currentLegendRank++, + yaxis: `y1`, + }; + + this._plotData.push(historyLegendTrace); + } + + // Add legend for observation trace with legendrank after vectors/ensembles and history + if (this._hasObservationTraces) { + const observationLegendTrace: Partial = { + name: "Observation", + x: [null], + y: [null], + legendgroup: "Observation", + showlegend: true, + visible: true, + mode: "lines+markers", + marker: { color: this._observationColor }, + line: { color: this._observationColor }, + legendrank: currentLegendRank++, + yaxis: `y1`, + }; + + this._plotData.push(observationLegendTrace); + } + } + + addRealizationsTraces( + vectorsRealizationData: { vectorSpecification: VectorSpec; data: VectorRealizationData_api[] }[], + useIncreasedBrightness: boolean + ): void { + // Only allow selected vectors + const selectedVectorsRealizationData = vectorsRealizationData.filter((vec) => + this._selectedVectorSpecifications.some( + (selectedVec) => selectedVec.vectorName === vec.vectorSpecification.vectorName + ) + ); + + const addLegendForTraces = false; + const hoverTemplate = ""; // No template yet + + // Create traces for each vector + selectedVectorsRealizationData.forEach((elm) => { + const subplotIndex = this.getSubplotIndex(elm.vectorSpecification); + if (subplotIndex === -1) return; + + // Get legend group and color + const legendGroup = this.getLegendGroupAndUpdateTracker(elm.vectorSpecification); + let color = this.getHexColor(elm.vectorSpecification); + if (useIncreasedBrightness) { + // TODO: + // - Determine which solution is best: filterBrightness from culori vs adjust l-channel for hsl + + // Filter brightness using filterBrightness + // const rgbColor = parseHex(color); + // color = formatHex(this._brighten(rgbColor)) ?? color; + + // Adjust l-channel for hsl + color = scaleHexColorLightness(color, 1.3) ?? color; + } + + const vectorRealizationTraces = createVectorRealizationTraces( + elm.data, + elm.vectorSpecification.ensembleIdent.getEnsembleName(), + color, + legendGroup, + hoverTemplate, + addLegendForTraces, + `y${subplotIndex + 1}` + ); + + this._plotData.push(...vectorRealizationTraces); + }); + } + + addFanchartTraces( + vectorsStatisticData: { vectorSpecification: VectorSpec; data: VectorStatisticData_api }[] + ): void { + // Only allow selected vectors + const selectedVectorsStatisticData = vectorsStatisticData.filter((vec) => + this._selectedVectorSpecifications.some( + (selectedVec) => selectedVec.vectorName === vec.vectorSpecification.vectorName + ) + ); + + // Create traces for each vector + selectedVectorsStatisticData.forEach((elm) => { + const subplotIndex = this.getSubplotIndex(elm.vectorSpecification); + if (subplotIndex === -1) return; + + // Get legend group and color + const legendGroup = this.getLegendGroupAndUpdateTracker(elm.vectorSpecification); + const color = this.getHexColor(elm.vectorSpecification); + + const vectorFanchartTraces = createVectorFanchartTraces( + elm.data, + color, + legendGroup, + `y${subplotIndex + 1}` + ); + + this._plotData.push(...vectorFanchartTraces); + }); + } + + addStatisticsTraces( + vectorsStatisticData: { vectorSpecification: VectorSpec; data: VectorStatisticData_api }[], + highlightStatisticTraces: boolean + ): void { + // Only allow selected vectors + const selectedVectorsStatisticData = vectorsStatisticData.filter((vec) => + this._selectedVectorSpecifications.some( + (selectedVec) => selectedVec.vectorName === vec.vectorSpecification.vectorName + ) + ); + + const lineWidth = highlightStatisticTraces ? 3 : 2; + + // Create traces for each vector + selectedVectorsStatisticData.forEach((elm) => { + const subplotIndex = this.getSubplotIndex(elm.vectorSpecification); + if (subplotIndex === -1) return; + + // Get legend group and color + const legendGroup = this.getLegendGroupAndUpdateTracker(elm.vectorSpecification); + const color = this.getHexColor(elm.vectorSpecification); + + const vectorStatisticsTraces = createVectorStatisticsTraces( + elm.data, + color, + legendGroup, + `y${subplotIndex + 1}`, + lineWidth + ); + + this._plotData.push(...vectorStatisticsTraces); + }); + } + + addHistoryTraces( + vectorsHistoricalData: { + vectorSpecification: VectorSpec; + data: VectorHistoricalData_api; + }[] + ): void { + // Only allow selected vectors + const selectedVectorsHistoricalData = vectorsHistoricalData.filter((vec) => + this._selectedVectorSpecifications.some( + (selectedVec) => selectedVec.vectorName === vec.vectorSpecification.vectorName + ) + ); + + // Create traces for each vector + selectedVectorsHistoricalData.forEach((elm) => { + const subplotIndex = this.getSubplotIndex(elm.vectorSpecification); + if (subplotIndex === -1) return; + + this._hasHistoryTraces = true; + const vectorHistoryTrace = createHistoricalVectorTrace( + elm.data, + this._historyVectorColor, + `y${subplotIndex + 1}` + ); + this._plotData.push(vectorHistoryTrace); + }); + } + + addVectorObservations(): void { + throw new Error("Method not implemented."); + } + + private getSubplotIndex(vectorSpecification: VectorSpec) { + if (this._subplotOwner === SubplotOwner.VECTOR) { + return this._uniqueVectorNames.indexOf(vectorSpecification.vectorName); + } else if (this._subplotOwner === SubplotOwner.ENSEMBLE) { + return this._uniqueEnsembleNames.indexOf(vectorSpecification.ensembleIdent.getEnsembleName()); + } + return -1; + } + + private getLegendGroupAndUpdateTracker(vectorSpecification: VectorSpec): string { + // Subplot per vector, keep track of added ensembles + // Subplot per ensemble, keep track of added vectors + if (this._subplotOwner === SubplotOwner.VECTOR) { + const ensembleName = vectorSpecification.ensembleIdent.getEnsembleName(); + if (!this._addedEnsemblesLegendTracker.includes(ensembleName)) { + this._addedEnsemblesLegendTracker.push(ensembleName); + } + return ensembleName; + } else if (this._subplotOwner === SubplotOwner.ENSEMBLE) { + const vectorName = vectorSpecification.vectorName; + if (!this._addedVectorsLegendTracker.includes(vectorName)) { + this._addedVectorsLegendTracker.push(vectorName); + } + return vectorName; + } + return ""; + } + + private getHexColor(vectorSpecification: VectorSpec): string { + // Subplot per vector implies individual color per ensemble + // Subplot per ensemble implies individual color per vector + if (this._subplotOwner === SubplotOwner.VECTOR) { + return this._ensembleHexColors[vectorSpecification.ensembleIdent.getEnsembleName()]; + } else if (this._subplotOwner === SubplotOwner.ENSEMBLE) { + return this._vectorHexColors[vectorSpecification.vectorName]; + } + + // Black hex as fallback + return "#000000"; + } +} diff --git a/frontend/src/modules/SimulationTimeSeriesMatrix/utils/timeSeriesPlotData.ts b/frontend/src/modules/SimulationTimeSeriesMatrix/utils/timeSeriesPlotData.ts new file mode 100644 index 000000000..a0597a6dc --- /dev/null +++ b/frontend/src/modules/SimulationTimeSeriesMatrix/utils/timeSeriesPlotData.ts @@ -0,0 +1,9 @@ +import { PlotData } from "plotly.js"; + +export interface TimeSeriesPlotData extends Partial { + // TODO: Have realizationNumber? + //realizationNumber?: number | null; + + // Did they forget to expose this one + legendrank?: number; +} diff --git a/frontend/src/modules/SimulationTimeSeriesMatrix/utils/vectorSpecificationsAndQueriesUtils.ts b/frontend/src/modules/SimulationTimeSeriesMatrix/utils/vectorSpecificationsAndQueriesUtils.ts new file mode 100644 index 000000000..fb52cc661 --- /dev/null +++ b/frontend/src/modules/SimulationTimeSeriesMatrix/utils/vectorSpecificationsAndQueriesUtils.ts @@ -0,0 +1,83 @@ +import { StatisticFunction_api, VectorStatisticData_api } from "@api"; +import { UseQueryResult } from "@tanstack/react-query"; + +import { FanchartStatisticOption, VectorSpec } from "../state"; + +/** + Helper function to create an array with pair of vector specification and loaded query data + + If query data is not valid, the vector specification and corresponding query data will not + be included in the output array + */ +export function createLoadedVectorSpecificationAndDataArray( + vectorSpecifications: VectorSpec[], + queryResults: UseQueryResult[] +): { vectorSpecification: VectorSpec; data: T }[] { + if (vectorSpecifications.length !== queryResults.length) { + throw new Error( + "Number of vector specifications and query results must be equal. Got vector specifications: " + + vectorSpecifications.length + + " and query results: " + + queryResults.length + + "." + ); + } + + const output: { vectorSpecification: VectorSpec; data: T }[] = []; + for (let i = 0; i < queryResults.length; ++i) { + const result = queryResults[i]; + if (!result.data) continue; + + output.push({ vectorSpecification: vectorSpecifications[i], data: result.data }); + } + + return output; +} + +/** + Helper function to filter out the selected individual statistic options from the vector specification and statistics data array + */ +export function filterVectorSpecificationAndIndividualStatisticsDataArray( + vectorSpecificationAndStatisticsData: { vectorSpecification: VectorSpec; data: VectorStatisticData_api }[], + selectedIndividualStatisticOptions: StatisticFunction_api[] +): { vectorSpecification: VectorSpec; data: VectorStatisticData_api }[] { + if (selectedIndividualStatisticOptions.length === 0) return []; + + const output = vectorSpecificationAndStatisticsData.map((v) => { + const filteredValueObjects = v.data.value_objects.filter((vo) => { + return selectedIndividualStatisticOptions.includes(vo.statistic_function); + }); + return { vectorSpecification: v.vectorSpecification, data: { ...v.data, value_objects: filteredValueObjects } }; + }); + return output; +} + +/** + Helper function to filter out the selected fanchart statistic options from the vector specification and statistics data array + */ +export function filterVectorSpecificationAndFanchartStatisticsDataArray( + vectorSpecificationAndStatisticsData: { vectorSpecification: VectorSpec; data: VectorStatisticData_api }[], + selectedFanchartStatisticOptions: FanchartStatisticOption[] +): { vectorSpecification: VectorSpec; data: VectorStatisticData_api }[] { + const includeStatisticFunctions: StatisticFunction_api[] = []; + if (selectedFanchartStatisticOptions.includes(FanchartStatisticOption.MEAN)) + includeStatisticFunctions.push(StatisticFunction_api.MEAN); + if (selectedFanchartStatisticOptions.includes(FanchartStatisticOption.MIN_MAX)) { + includeStatisticFunctions.push(StatisticFunction_api.MIN); + includeStatisticFunctions.push(StatisticFunction_api.MAX); + } + if (selectedFanchartStatisticOptions.includes(FanchartStatisticOption.P10_P90)) { + includeStatisticFunctions.push(StatisticFunction_api.P10); + includeStatisticFunctions.push(StatisticFunction_api.P90); + } + + if (includeStatisticFunctions.length === 0) return []; + + const output = vectorSpecificationAndStatisticsData.map((v) => { + const filteredValueObjects = v.data.value_objects.filter((vo) => { + return includeStatisticFunctions.includes(vo.statistic_function); + }); + return { vectorSpecification: v.vectorSpecification, data: { ...v.data, value_objects: filteredValueObjects } }; + }); + return output; +} diff --git a/frontend/src/modules/SimulationTimeSeriesMatrix/view.tsx b/frontend/src/modules/SimulationTimeSeriesMatrix/view.tsx new file mode 100644 index 000000000..0b4bc4be9 --- /dev/null +++ b/frontend/src/modules/SimulationTimeSeriesMatrix/view.tsx @@ -0,0 +1,164 @@ +import React from "react"; +import Plot from "react-plotly.js"; + +import { ModuleFCProps } from "@framework/Module"; +import { useElementSize } from "@lib/hooks/useElementSize"; +// Note: Have for debug render count info +import { isDevMode } from "@lib/utils/devMode"; + +import { useHistoricalVectorDataQueries, useStatisticalVectorDataQueries, useVectorDataQueries } from "./queryHooks"; +import { GroupBy, State, VisualizationMode } from "./state"; +import { SubplotBuilder, SubplotOwner } from "./utils/subplotBuilder"; +import { + createLoadedVectorSpecificationAndDataArray, + filterVectorSpecificationAndFanchartStatisticsDataArray, + filterVectorSpecificationAndIndividualStatisticsDataArray, +} from "./utils/vectorSpecificationsAndQueriesUtils"; + +export const view = ({ moduleContext, workbenchSettings }: ModuleFCProps) => { + // Leave this in until we get a feeling for React18/Plotly + const renderCount = React.useRef(0); + React.useEffect(function incrementRenderCount() { + renderCount.current = renderCount.current + 1; + }); + + const wrapperDivRef = React.useRef(null); + const wrapperDivSize = useElementSize(wrapperDivRef); + + const colorSet = workbenchSettings.useColorSet(); + + // State + const vectorSpecifications = moduleContext.useStoreValue("vectorSpecifications"); + const groupBy = moduleContext.useStoreValue("groupBy"); + const resampleFrequency = moduleContext.useStoreValue("resamplingFrequency"); + const realizationsToInclude = moduleContext.useStoreValue("realizationsToInclude"); + const visualizationMode = moduleContext.useStoreValue("visualizationMode"); + const showHistorical = moduleContext.useStoreValue("showHistorical"); + const statisticsSelection = moduleContext.useStoreValue("statisticsSelection"); + + // Queries + const vectorDataQueries = useVectorDataQueries( + vectorSpecifications, + resampleFrequency, + realizationsToInclude, + true + ); + const vectorStatisticsQueries = useStatisticalVectorDataQueries( + vectorSpecifications, + resampleFrequency, + realizationsToInclude, + visualizationMode === VisualizationMode.STATISTICAL_FANCHART || + visualizationMode === VisualizationMode.STATISTICAL_LINES || + visualizationMode === VisualizationMode.STATISTICS_AND_REALIZATIONS + ); + + const vectorSpecificationsWithHistoricalData = vectorSpecifications?.filter((vec) => vec.hasHistoricalVector); + const historicalVectorDataQueries = useHistoricalVectorDataQueries( + vectorSpecificationsWithHistoricalData ?? null, + resampleFrequency, + vectorSpecificationsWithHistoricalData?.some((vec) => vec.hasHistoricalVector) ?? false + ); + + // Map vector specifications and queries with data + const loadedVectorSpecificationsAndRealizationData = vectorSpecifications + ? createLoadedVectorSpecificationAndDataArray(vectorSpecifications, vectorDataQueries) + : []; + const loadedVectorSpecificationsAndStatisticsData = vectorSpecifications + ? createLoadedVectorSpecificationAndDataArray(vectorSpecifications, vectorStatisticsQueries) + : []; + const loadedVectorSpecificationsAndHistoricalData = vectorSpecificationsWithHistoricalData + ? createLoadedVectorSpecificationAndDataArray( + vectorSpecificationsWithHistoricalData, + historicalVectorDataQueries + ) + : []; + + // TODO: + // - Add loading state if 1 or more queries are loading? + // - Can check for equal length of useQueries arrays and the loadedVectorSpecificationsAndData arrays? + + // Iterate over unique ensemble names and assign color from color palette + if (vectorSpecifications) { + const uniqueEnsembleNames: string[] = []; + vectorSpecifications.forEach((vectorSpec) => { + const ensembleName = vectorSpec.ensembleIdent.getEnsembleName(); + if (!uniqueEnsembleNames.includes(ensembleName)) { + uniqueEnsembleNames.push(vectorSpec.ensembleIdent.getEnsembleName()); + } + }); + } + + // Plot builder + // NOTE: useRef? + const subplotOwner = groupBy === GroupBy.TIME_SERIES ? SubplotOwner.VECTOR : SubplotOwner.ENSEMBLE; + const subplotBuilder = new SubplotBuilder( + subplotOwner, + vectorSpecifications ?? [], + colorSet, + wrapperDivSize.width, + wrapperDivSize.height + ); + + if (visualizationMode === VisualizationMode.INDIVIDUAL_REALIZATIONS) { + const useIncreasedBrightness = false; + subplotBuilder.addRealizationsTraces(loadedVectorSpecificationsAndRealizationData, useIncreasedBrightness); + } + if (visualizationMode === VisualizationMode.STATISTICAL_FANCHART) { + const selectedVectorsFanchartStatisticData = filterVectorSpecificationAndFanchartStatisticsDataArray( + loadedVectorSpecificationsAndStatisticsData, + statisticsSelection.FanchartStatisticsSelection + ); + subplotBuilder.addFanchartTraces(selectedVectorsFanchartStatisticData); + } + if (visualizationMode === VisualizationMode.STATISTICAL_LINES) { + const highlightStatistics = false; + const selectedVectorsIndividualStatisticData = filterVectorSpecificationAndIndividualStatisticsDataArray( + loadedVectorSpecificationsAndStatisticsData, + statisticsSelection.IndividualStatisticsSelection + ); + subplotBuilder.addStatisticsTraces(selectedVectorsIndividualStatisticData, highlightStatistics); + } + if (visualizationMode === VisualizationMode.STATISTICS_AND_REALIZATIONS) { + const useIncreasedBrightness = true; + const highlightStatistics = true; + const selectedVectorsIndividualStatisticData = filterVectorSpecificationAndIndividualStatisticsDataArray( + loadedVectorSpecificationsAndStatisticsData, + statisticsSelection.IndividualStatisticsSelection + ); + subplotBuilder.addRealizationsTraces(loadedVectorSpecificationsAndRealizationData, useIncreasedBrightness); + subplotBuilder.addStatisticsTraces(selectedVectorsIndividualStatisticData, highlightStatistics); + } + if (showHistorical) { + subplotBuilder.addHistoryTraces(loadedVectorSpecificationsAndHistoricalData); + } + + // Handler methods + function handleHover() { + return; + } + + function handleUnHover() { + return; + } + + const plotData = subplotBuilder.createPlotData(); + // TODO: Keep uirevision? + return ( +
+ + {isDevMode() && ( + <> +
(rc={renderCount.current})
+
Traces: {plotData.length}
+ + )} +
+ ); +}; diff --git a/frontend/src/modules/registerAllModules.ts b/frontend/src/modules/registerAllModules.ts index 692ca19fc..407540a7f 100644 --- a/frontend/src/modules/registerAllModules.ts +++ b/frontend/src/modules/registerAllModules.ts @@ -10,10 +10,11 @@ import "./Map/registerModule"; import "./Pvt/registerModule"; import "./Sensitivity/registerModule"; import "./SimulationTimeSeries/registerModule"; -import "./WellCompletion/registerModule" +import "./SimulationTimeSeriesMatrix/registerModule"; import "./SimulationTimeSeriesSensitivity/registerModule"; import "./TimeSeriesParameterDistribution/registerModule"; import "./TopographicMap/registerModule"; +import "./WellCompletion/registerModule"; if (isDevMode()) { await import("./MyModule/registerModule");