diff --git a/frontend/src/modules/InplaceVolumetricsConvergence/view/plotBuilder.ts b/frontend/src/modules/InplaceVolumetricsConvergence/view/plotBuilder.ts index 737155d21..9d90c5e74 100644 --- a/frontend/src/modules/InplaceVolumetricsConvergence/view/plotBuilder.ts +++ b/frontend/src/modules/InplaceVolumetricsConvergence/view/plotBuilder.ts @@ -3,6 +3,7 @@ import { EnsembleSet } from "@framework/EnsembleSet"; import { ColorSet } from "@lib/utils/ColorSet"; import { Figure, makeSubplots } from "@modules/_shared/Figure"; import { InplaceVolumetricsTablesDataAccessor } from "@modules/_shared/InplaceVolumetrics/InplaceVolumetricsDataAccessor"; +import { Table } from "@modules/_shared/InplaceVolumetrics/Table"; import { makeDistinguishableEnsembleDisplayName } from "@modules/_shared/ensembleNameUtils"; import { PlotData } from "plotly.js"; @@ -21,7 +22,7 @@ export type TableData = { export class InplaceVolumetricsPlotBuilder { private _dataAccessor: InplaceVolumetricsTablesDataAccessor; - private _plottingFunction: ((data: TableData) => Partial[]) | null = null; + private _plottingFunction: ((table: Table) => Partial[]) | null = null; private _subplotByInfo: SubplotByInfo = { subplotBy: SubplotBy.TABLE_NAME }; private _colorBy: SubplotByInfo = { subplotBy: SubplotBy.ENSEMBLE }; private _ensembleSet: EnsembleSet; @@ -41,7 +42,7 @@ export class InplaceVolumetricsPlotBuilder { this._colorBy = colorBy; } - setPlottingFunction(plottingFunction: (data: TableData) => Partial[]) { + setPlottingFunction(plottingFunction: (data: Table) => Partial[]) { this._plottingFunction = plottingFunction; } @@ -93,8 +94,6 @@ export class InplaceVolumetricsPlotBuilder { return figure; } - private makeColor(); - private makeSubplotTables(): TablesData[] { const tablesData: TablesData[] = []; if (this._subplotByInfo.subplotBy === SubplotBy.ENSEMBLE) { diff --git a/frontend/src/modules/InplaceVolumetricsConvergence/view/view.tsx b/frontend/src/modules/InplaceVolumetricsConvergence/view/view.tsx index 85197b098..0dd5a1d7f 100644 --- a/frontend/src/modules/InplaceVolumetricsConvergence/view/view.tsx +++ b/frontend/src/modules/InplaceVolumetricsConvergence/view/view.tsx @@ -11,10 +11,13 @@ import { useElementBoundingRect } from "@lib/hooks/useElementBoundingRect"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; import { makeSubplots } from "@modules/_shared/Figure"; import { InplaceVolumetricsTablesDataAccessor } from "@modules/_shared/InplaceVolumetrics/InplaceVolumetricsDataAccessor"; +import { PlotBuilder } from "@modules/_shared/InplaceVolumetrics/PlotBuilder"; +import { Table } from "@modules/_shared/InplaceVolumetrics/Table"; import { EnsembleIdentWithRealizations, useGetAggregatedTableDataQueries, } from "@modules/_shared/InplaceVolumetrics/queryHooks"; +import { makeTableFromApiData } from "@modules/_shared/InplaceVolumetrics/tableUtils"; import { makeDistinguishableEnsembleDisplayName } from "@modules/_shared/ensembleNameUtils"; import { Layout, PlotData } from "plotly.js"; @@ -52,7 +55,7 @@ export function View(props: ModuleViewProps, SettingsToVie filter.tableNames, resultName ? [resultName] : [], filter.fluidZones, - subplotBy?.subplotBy === SubplotBy.IDENTIFIER ? [subplotBy.identifier] : [], + [InplaceVolumetricsIdentifier_api.ZONE], //subplotBy?.subplotBy === SubplotBy.IDENTIFIER ? [subplotBy.identifier] : [], subplotBy?.subplotBy !== SubplotBy.FLUID_ZONE, false, filter.identifiersValues @@ -69,28 +72,27 @@ export function View(props: ModuleViewProps, SettingsToVie } } - const tablesDataAccessor = new InplaceVolumetricsTablesDataAccessor(aggregatedTableDataQueries.tablesData); + let plots: React.ReactNode | null = null; - let title = `Convergence plot of mean/p10/p90`; - if (resultName) { - title += ` for ${resultName}`; - } - if (subplotBy.subplotBy !== SubplotBy.ENSEMBLE && tablesDataAccessor.getTables().length === 1) { - const subTable = tablesDataAccessor.getTables()[0]; - title += ` - ${makeDistinguishableEnsembleDisplayName( - subTable.getEnsembleIdent(), - ensembleSet.getEnsembleArr() - )} - ${subTable.getTableName()}`; - } - props.viewContext.setInstanceTitle(title); + if (aggregatedTableDataQueries.tablesData.length > 0) { + const table = makeTableFromApiData(aggregatedTableDataQueries.tablesData); - const plotbuilder = new InplaceVolumetricsPlotBuilder(tablesDataAccessor, ensembleSet, colorSet); + let title = `Convergence plot of mean/p10/p90`; + if (resultName) { + title += ` for ${resultName}`; + } + props.viewContext.setInstanceTitle(title); - plotbuilder.setSubplotBy(subplotBy); - plotbuilder.setPlottingFunction(makePlotData(resultName ?? "")); - const figure = plotbuilder.build(divBoundingRect.height, divBoundingRect.width); + const plotbuilder = new PlotBuilder(table, makePlotData(resultName ?? "")); - const plotComponent = figure?.makePlot(); + plotbuilder.setSubplotByColumn("ZONE"); + plots = plotbuilder.build(divBoundingRect.height, divBoundingRect.width, { + horizontalSpacing: 0.075, + verticalSpacing: 0.075, + showGrid: true, + margin: { t: 50, b: 20, l: 50, r: 20 }, + }); + } /* const subplots: { title?: string; plotData: Partial[] }[] = []; @@ -230,68 +232,72 @@ export function View(props: ModuleViewProps, SettingsToVie
{makeMessage()}
- {plotComponent} + {plots} ); } -function makePlotData(resultName: string): (tableData: TableData[]) => Partial[] { - return (tableData: TableData[]): Partial[] => { +function makePlotData(resultName: string): (table: Table) => Partial[] { + return (table: Table): Partial[] => { const data: Partial[] = []; - for (const table of tableData) { - const realizationAndResultArray: RealizationAndResult[] = []; - const reals = table.columns["realization"]; - const results = table.columns[resultName]; - for (let i = 0; i < reals.length; i++) { - realizationAndResultArray.push({ - realization: reals[i] as number, - resultValue: results[i] as number, - }); - } + const realizationAndResultArray: RealizationAndResult[] = []; + const reals = table.getColumn("REAL"); + const results = table.getColumn(resultName); + if (!reals) { + throw new Error("REAL column not found"); + } + if (!results) { + throw new Error(`Column not found: ${resultName}`); + } + for (let i = 0; i < reals.getNumRows(); i++) { + realizationAndResultArray.push({ + realization: reals.getRowValue(i) as number, + resultValue: results.getRowValue(i) as number, + }); + } - const convergenceArr = calcConvergenceArray(realizationAndResultArray); - - data.push( - { - x: convergenceArr.map((el) => el.realization), - y: convergenceArr.map((el) => el.mean), - name: "Mean", - type: "scatter", - line: { - color: "black", - width: 1, - }, + const convergenceArr = calcConvergenceArray(realizationAndResultArray); + + data.push( + { + x: convergenceArr.map((el) => el.realization), + y: convergenceArr.map((el) => el.mean), + name: "Mean", + type: "scatter", + line: { + color: "black", + width: 1, }, - { - x: convergenceArr.map((el) => el.realization), - y: convergenceArr.map((el) => el.p10), - name: "P10", - type: "scatter", - line: { - color: "red", - width: 1, - dash: "dash", - }, + }, + { + x: convergenceArr.map((el) => el.realization), + y: convergenceArr.map((el) => el.p10), + name: "P10", + type: "scatter", + line: { + color: "red", + width: 1, + dash: "dash", }, - { - x: convergenceArr.map((el) => el.realization), - y: convergenceArr.map((el) => el.p90), - name: "P90", - type: "scatter", - line: { - color: "blue", - width: 1, - dash: "dashdot", - }, - } - ); - } + }, + { + x: convergenceArr.map((el) => el.realization), + y: convergenceArr.map((el) => el.p90), + name: "P90", + type: "scatter", + line: { + color: "blue", + width: 1, + dash: "dashdot", + }, + } + ); return data; }; diff --git a/frontend/src/modules/InplaceVolumetricsTable/view/view.tsx b/frontend/src/modules/InplaceVolumetricsTable/view/view.tsx index b907f2504..625012106 100644 --- a/frontend/src/modules/InplaceVolumetricsTable/view/view.tsx +++ b/frontend/src/modules/InplaceVolumetricsTable/view/view.tsx @@ -12,15 +12,12 @@ import { Table } from "@lib/components/Table"; import { TableHeading, TableRow } from "@lib/components/Table/table"; import { useElementBoundingRect } from "@lib/hooks/useElementBoundingRect"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; -import { - Column, - ColumnType, - InplaceVolumetricsTablesDataAccessor, -} from "@modules/_shared/InplaceVolumetrics/InplaceVolumetricsDataAccessor"; +import { Column, ColumnType } from "@modules/_shared/InplaceVolumetrics/Table"; import { EnsembleIdentWithRealizations, useGetAggregatedTableDataQueries, } from "@modules/_shared/InplaceVolumetrics/queryHooks"; +import { makeTableFromApiData } from "@modules/_shared/InplaceVolumetrics/tableUtils"; import { makeDistinguishableEnsembleDisplayName } from "@modules/_shared/ensembleNameUtils"; import { SettingsToViewInterface } from "../settingsToViewInterface"; @@ -72,20 +69,19 @@ export function View(props: ModuleViewProps, SettingsToVie const headings: TableHeading = {}; - const tablesDataAccessor = new InplaceVolumetricsTablesDataAccessor(aggregatedTableDataQueries.tablesData); + const dataTable = makeTableFromApiData(aggregatedTableDataQueries.tablesData); - for (const column of tablesDataAccessor.getColumnsUnion()) { - headings[column.name] = { - label: column.name, - sizeInPercent: 100 / tablesDataAccessor.getColumnsUnionCount(), + for (const column of dataTable.getColumns()) { + headings[column.getName()] = { + label: column.getName(), + sizeInPercent: 100 / dataTable.getNumColumns(), formatValue: makeValueFormattingFunc(column, ensembleSet), formatStyle: makeStyleFormattingFunc(column), }; } const tableRows: TableRow[] = []; - - for (const row of tablesDataAccessor.getRowsUnion()) { + for (const row of dataTable.getRows()) { tableRows.push(row); } @@ -105,7 +101,7 @@ export function View(props: ModuleViewProps, SettingsToVie } function makeStyleFormattingFunc(column: Column): ((value: number | string | null) => React.CSSProperties) | undefined { - if (column.type === ColumnType.FLUID_ZONE) { + if (column.getType() === ColumnType.FLUID_ZONE) { return (value: number | string | null) => { const style: React.CSSProperties = { textAlign: "right", fontWeight: "bold" }; @@ -123,7 +119,7 @@ function makeStyleFormattingFunc(column: Column): ((value: number | string | nul }; } - if (column.type === ColumnType.ENSEMBLE) { + if (column.getType() === ColumnType.ENSEMBLE) { return undefined; } @@ -134,10 +130,10 @@ function makeValueFormattingFunc( column: Column, ensembleSet: EnsembleSet ): ((value: number | string | null) => string) | undefined { - if (column.type === ColumnType.ENSEMBLE) { + if (column.getType() === ColumnType.ENSEMBLE) { return (value: number | string | null) => formatEnsembleIdent(value, ensembleSet); } - if (column.type === ColumnType.RESULT) { + if (column.getType() === ColumnType.RESULT) { return formatResultValue; } diff --git a/frontend/src/modules/_shared/InplaceVolumetrics/PlotBuilder.tsx b/frontend/src/modules/_shared/InplaceVolumetrics/PlotBuilder.tsx new file mode 100644 index 000000000..41b98d62f --- /dev/null +++ b/frontend/src/modules/_shared/InplaceVolumetrics/PlotBuilder.tsx @@ -0,0 +1,124 @@ +import React from "react"; + +import { PlotData } from "plotly.js"; + +import { Table } from "./Table"; + +import { Figure, MakeSubplotOptions, makeSubplots } from "../Figure"; + +export class PlotBuilder { + private _table: Table; + private _plottingFunction: (table: Table) => Partial[]; + private _groupByColumn: string | null = null; + private _subplotByColumn: string | null = null; + + constructor(table: Table, plottingFunction: (table: Table) => Partial[]) { + this._table = table; + this._plottingFunction = plottingFunction; + } + + setGroupByColumn(columnName: string): void { + if (!this._table.getColumn(columnName)) { + throw new Error(`Column not found: ${columnName}`); + } + this._groupByColumn = columnName; + } + + setSubplotByColumn(columnName: string): void { + if (!this._table.getColumn(columnName)) { + throw new Error(`Column not found: ${columnName}`); + } + this._subplotByColumn = columnName; + } + + build( + height: number, + width: number, + options?: Pick + ): React.ReactNode { + if (!this._groupByColumn) { + const figure = this.buildSubplots(this._table, height, width, options ?? {}); + return figure.makePlot(); + } + + const components: React.ReactNode[] = []; + const tableCollection = this._table.splitByColumn(this._groupByColumn); + const numTables = tableCollection.getNumTables(); + const collectionMap = tableCollection.getCollectionMap(); + + for (const [key, table] of collectionMap) { + const figure = this.buildSubplots(table, height / numTables, width, options ?? {}); + components.push(

{key}

); + components.push(figure.makePlot()); + } + + return <>{components}; + } + + private buildSubplots( + table: Table, + height: number, + width: number, + options: Pick + ): Figure { + if (!this._subplotByColumn) { + const figure = makeSubplots({ + numRows: 1, + numCols: 1, + height, + width, + ...options, + }); + + const traces = this._plottingFunction(table); + for (const trace of traces) { + figure.addTrace(trace); + } + return figure; + } + + const tableCollection = table.splitByColumn(this._subplotByColumn); + const numTables = tableCollection.getNumTables(); + const numRows = Math.ceil(Math.sqrt(numTables)); + const numCols = Math.ceil(numTables / numRows); + + const tables = tableCollection.getTables(); + const keys = tableCollection.getKeys(); + + const traces: { row: number; col: number; trace: Partial }[] = []; + const subplotTitles: string[] = Array(numRows * numCols).fill(""); + + for (let row = 1; row <= numRows; row++) { + for (let col = 1; col <= numCols; col++) { + const index = (numRows - 1 - (row - 1)) * numCols + (col - 1); + if (!keys[index]) { + continue; + } + const label = keys[index].toString(); + subplotTitles[(row - 1) * numCols + col - 1] = label; + + const table = tables[index]; + + const plotDataArr = this._plottingFunction(table); + for (const plotData of plotDataArr) { + traces.push({ row, col, trace: plotData }); + } + } + } + + const figure = makeSubplots({ + numRows, + numCols, + height, + width, + subplotTitles, + ...options, + }); + + for (const { row, col, trace } of traces) { + figure.addTrace(trace, row, col); + } + + return figure; + } +} diff --git a/frontend/src/modules/_shared/InplaceVolumetrics/Table.ts b/frontend/src/modules/_shared/InplaceVolumetrics/Table.ts new file mode 100644 index 000000000..300b923f0 --- /dev/null +++ b/frontend/src/modules/_shared/InplaceVolumetrics/Table.ts @@ -0,0 +1,181 @@ +import { EnsembleIdent } from "@framework/EnsembleIdent"; + +import { TableCollection } from "./TableCollection"; + +export enum ColumnType { + ENSEMBLE = "ensemble", + TABLE = "table", + FLUID_ZONE = "fluidZone", + REAL = "real", + IDENTIFIER = "identifier", + RESULT = "result", +} + +export class Column { + private _name: string; + private _type: ColumnType; + private _uniqueValues: TValue[] = []; + private _indices: number[] = []; + + constructor(name: string, type: ColumnType); + constructor(name: string, type: ColumnType, uniqueValues: TValue[], indices: number[]); + constructor(name: string, type: ColumnType, uniqueValues: TValue[] = [], indices: number[] = []) { + this._name = name; + this._type = type; + this._uniqueValues = uniqueValues; + this._indices = indices; + } + + getName(): string { + return this._name; + } + + getType(): ColumnType { + return this._type; + } + + getUniqueValues(): TValue[] { + return this._uniqueValues; + } + + getRowsWhere(predicate: (value: TValue) => boolean): { index: number; value: TValue }[] { + const rows: { index: number; value: TValue }[] = []; + for (let i = 0; i < this._indices.length; i++) { + const value = this._uniqueValues[this._indices[i]]; + if (predicate(value)) { + rows.push({ index: i, value }); + } + } + return rows; + } + + getNumRows(): number { + return this._indices.length; + } + + addRowValue(value: TValue): void { + const index = this._uniqueValues.indexOf(value); + if (index === -1) { + this._uniqueValues.push(value); + this._indices.push(this._uniqueValues.length - 1); + return; + } + this._indices.push(index); + } + + getRowValue(rowIndex: number): TValue { + if (rowIndex < 0 || rowIndex >= this._indices.length) { + throw new Error(`Invalid index: ${rowIndex}`); + } + + return this._uniqueValues[this._indices[rowIndex]]; + } + + cloneEmpty(): Column { + return new Column(this._name, this._type); + } +} + +export interface Row { + [columnName: string]: string | number; +} + +export class Table { + private _columns: Column[]; + + constructor(columns: Column[]) { + this._columns = columns; + this.assertColumnLengthsMatch(); + } + + private assertColumnLengthsMatch(): void { + const numRows = this._columns[0].getNumRows(); + for (const column of this._columns) { + if (column.getNumRows() !== numRows) { + throw new Error("Column lengths do not match"); + } + } + } + + getNumColumns(): number { + return this._columns.length; + } + + getNumRows(): number { + return this._columns[0].getNumRows(); + } + + getColumns(): Column[] { + return this._columns; + } + + getColumn(columnName: string): Column | undefined { + return this._columns.find((c) => c.getName() === columnName); + } + + getRows(): Row[] { + const rows: Row[] = []; + for (let i = 0; i < this.getNumRows(); i++) { + rows.push(this.getRow(i)); + } + return rows; + } + + getRow(rowIndex: number): Row { + if (rowIndex < 0 || rowIndex >= this.getNumRows()) { + throw new Error(`Invalid row index: ${rowIndex}`); + } + + const row: Row = {}; + for (const column of this._columns) { + row[column.getName()] = column.getRowValue(rowIndex); + } + + return row; + } + + filterRowsByColumn(columnName: string, predicate: (value: string | number | EnsembleIdent) => boolean): Row[] { + const columnIndex = this._columns.findIndex((column) => column.getName() === columnName); + + if (columnIndex === -1) { + throw new Error(`Column not found: ${columnName}`); + } + + const column = this._columns[columnIndex]; + const rows = column.getRowsWhere(predicate); + + return rows.map((row) => this.getRow(row.index)); + } + + splitByColumn(columnName: string): TableCollection { + const columnIndex = this._columns.findIndex((column) => column.getName() === columnName); + + if (columnIndex === -1) { + throw new Error(`Column not found: ${columnName}`); + } + + const column = this._columns[columnIndex]; + const uniqueValues = column.getUniqueValues(); + const numCols = this.getNumColumns(); + + const tables: Table[] = []; + for (const value of uniqueValues) { + const rows = this.filterRowsByColumn(columnName, (v) => v === value); + const columns: Column[] = []; + for (let i = 0; i < numCols; i++) { + if (i === columnIndex) { + continue; + } + + const newColumn = this._columns[i].cloneEmpty(); + for (const row of rows) { + newColumn.addRowValue(row[newColumn.getName()]); + } + columns.push(newColumn); + } + tables.push(new Table(columns)); + } + + return new TableCollection(columnName, uniqueValues, tables); + } +} diff --git a/frontend/src/modules/_shared/InplaceVolumetrics/TableCollection.ts b/frontend/src/modules/_shared/InplaceVolumetrics/TableCollection.ts new file mode 100644 index 000000000..40ace2155 --- /dev/null +++ b/frontend/src/modules/_shared/InplaceVolumetrics/TableCollection.ts @@ -0,0 +1,49 @@ +import { Table } from "./Table"; + +export class TableCollection { + private _collectedBy: string; + private _collection: Map; + + constructor(collectedBy: string, values: (string | number)[], tables: Table[]) { + this._collectedBy = collectedBy; + this._collection = new Map(); + + if (values.length !== tables.length) { + throw new Error("Values and tables length do not match"); + } + + for (let i = 0; i < values.length; i++) { + this._collection.set(values[i], tables[i]); + } + } + + getCollectedBy(): string { + return this._collectedBy; + } + + getCollectionMap(): Map { + return this._collection; + } + + getNumTables(): number { + return this._collection.size; + } + + getKeys(): (string | number)[] { + return Array.from(this._collection.keys()); + } + + getTables(): Table[] { + return Array.from(this._collection.values()); + } + + getTable(key: string | number): Table { + const item = this._collection.get(key); + + if (!item) { + throw new Error(`Item not found for key: ${key}`); + } + + return item; + } +} diff --git a/frontend/src/modules/_shared/InplaceVolumetrics/tableUtils.ts b/frontend/src/modules/_shared/InplaceVolumetrics/tableUtils.ts new file mode 100644 index 000000000..f5ddeebf7 --- /dev/null +++ b/frontend/src/modules/_shared/InplaceVolumetrics/tableUtils.ts @@ -0,0 +1,54 @@ +import { Column, ColumnType, Table } from "./Table"; +import { InplaceVolumetricsTableData } from "./types"; + +export function makeTableFromApiData(data: InplaceVolumetricsTableData[]): Table { + const columns: Map> = new Map(); + columns.set("ensemble", new Column("Ensemble", ColumnType.ENSEMBLE)); + columns.set("table", new Column("Table", ColumnType.TABLE)); + columns.set("fluid-zone", new Column("Fluid Zone", ColumnType.FLUID_ZONE)); + + for (const tableSet of data) { + for (const fluidZoneTable of tableSet.data.tablePerFluidSelection) { + let mainColumnsAdded = false; + for (const selectorColumn of fluidZoneTable.selectorColumns) { + if (!columns.has(selectorColumn.columnName)) { + let type = ColumnType.IDENTIFIER; + if (selectorColumn.columnName === "REAL") { + type = ColumnType.REAL; + } + columns.set( + selectorColumn.columnName, + new Column(selectorColumn.columnName, type, selectorColumn.uniqueValues, selectorColumn.indices) + ); + + if (!mainColumnsAdded) { + mainColumnsAdded = true; + for (let i = 0; i < selectorColumn.indices.length; i++) { + columns.get("ensemble")?.addRowValue(tableSet.ensembleIdent); + columns.get("table")?.addRowValue(tableSet.tableName); + columns.get("fluid-zone")?.addRowValue(fluidZoneTable.fluidSelectionName); + } + } + } + } + for (const resultColumn of fluidZoneTable.resultColumns) { + if (!columns.has(resultColumn.columnName)) { + columns.set(resultColumn.columnName, new Column(resultColumn.columnName, ColumnType.RESULT)); + } + for (const value of resultColumn.columnValues) { + columns.get(resultColumn.columnName)?.addRowValue(value); + } + if (!mainColumnsAdded) { + mainColumnsAdded = true; + for (let i = 0; i < resultColumn.columnValues.length; i++) { + columns.get("ensemble")?.addRowValue(tableSet.ensembleIdent); + columns.get("table")?.addRowValue(tableSet.tableName); + columns.get("fluid-zone")?.addRowValue(fluidZoneTable.fluidSelectionName); + } + } + } + } + } + + return new Table(Array.from(columns.values())); +}