From 310471767f3cce51fb7fb6964dc2bd72a9ca3d65 Mon Sep 17 00:00:00 2001 From: Ruben Thoms Date: Mon, 18 Nov 2024 16:32:41 +0100 Subject: [PATCH] Implemented new 2d viewer module Co-authored-by: Hans Kallekleiv --- frontend/src/modules/2DViewer/interfaces.ts | 23 + .../src/modules/2DViewer/layers/ColorScale.ts | 59 +++ .../modules/2DViewer/layers/DeltaSurface.ts | 85 ++++ .../src/modules/2DViewer/layers/Dependency.ts | 210 +++++++++ .../2DViewer/layers/DeserializationFactory.ts | 70 +++ .../modules/2DViewer/layers/LayerManager.ts | 209 ++++++++ .../modules/2DViewer/layers/LayerRegistry.ts | 18 + .../2DViewer/layers/SettingRegistry.ts | 13 + .../modules/2DViewer/layers/SettingsGroup.ts | 36 ++ .../modules/2DViewer/layers/SharedSetting.ts | 118 +++++ frontend/src/modules/2DViewer/layers/View.ts | 38 ++ .../layers/components/ColorScaleComponent.tsx | 73 +++ .../components/DeltaSurfaceComponent.tsx | 78 +++ .../2DViewer/layers/components/EditName.tsx | 67 +++ .../layers/components/EmptyContent.tsx | 9 + .../components/ExpandCollapseAllButton.tsx | 37 ++ .../layers/components/LayerComponent.tsx | 167 +++++++ .../layers/components/LayersActions.tsx | 83 ++++ .../layers/components/RemoveButton.tsx | 30 ++ .../layers/components/SettingComponent.tsx | 113 +++++ .../components/SettingsGroupComponent.tsx | 73 +++ .../components/SharedSettingComponent.tsx | 89 ++++ .../layers/components/ViewComponent.tsx | 77 +++ .../layers/components/VisibilityToggle.tsx | 24 + .../2DViewer/layers/components/utils.tsx | 93 ++++ .../layers/delegates/GroupDelegate.ts | 249 ++++++++++ .../2DViewer/layers/delegates/ItemDelegate.ts | 153 ++++++ .../layers/delegates/LayerDelegate.ts | 330 +++++++++++++ .../delegates/PublishSubscribeDelegate.ts | 46 ++ .../layers/delegates/SettingDelegate.ts | 341 ++++++++++++++ .../delegates/SettingsContextDelegate.ts | 379 +++++++++++++++ .../delegates/UnsubscribeHandlerDelegate.ts | 30 ++ .../DrilledWellTrajectoriesContext.ts | 86 ++++ .../DrilledWellTrajectoriesLayer.ts | 125 +++++ .../DrilledWellTrajectoriesLayer/types.ts | 8 + .../DrilledWellborePicksContext.ts | 137 ++++++ .../DrilledWellborePicksLayer.ts | 126 +++++ .../layers/DrilledWellborePicksLayer/types.ts | 9 + .../ObservedSurfaceContext.ts | 144 ++++++ .../ObservedSurfaceLayer.ts | 124 +++++ .../layers/ObservedSurfaceLayer/types.ts | 9 + .../RealizationGridContext.ts | 180 +++++++ .../RealizationGridLayer.ts | 204 ++++++++ .../layers/RealizationGridLayer/types.ts | 12 + .../RealizationPolygonsContext.ts | 126 +++++ .../RealizationPolygonsLayer.ts | 124 +++++ .../layers/RealizationPolygonsLayer/types.ts | 10 + .../RealizationSurfaceContext.ts | 159 +++++++ .../RealizationSurfaceLayer.ts | 129 +++++ .../layers/RealizationSurfaceLayer/types.ts | 11 + .../StatisticalSurfaceContext.ts | 173 +++++++ .../StatisticalSurfaceLayer.ts | 155 ++++++ .../layers/StatisticalSurfaceLayer/types.ts | 14 + .../settings/DrilledWellbores.tsx | 135 ++++++ .../implementations/settings/Ensemble.tsx | 67 +++ .../settings/GridAttribute.tsx | 54 +++ .../implementations/settings/GridLayer.tsx | 102 ++++ .../implementations/settings/GridName.tsx | 54 +++ .../settings/PolygonsAttribute.tsx | 54 +++ .../implementations/settings/PolygonsName.tsx | 54 +++ .../implementations/settings/Realization.tsx | 56 +++ .../implementations/settings/Sensitivity.tsx | 139 ++++++ .../settings/ShowGridLines.tsx | 47 ++ .../settings/StatisticFunction.tsx | 60 +++ .../settings/SurfaceAttribute.tsx | 54 +++ .../implementations/settings/SurfaceName.tsx | 54 +++ .../settings/TimeOrInterval.tsx | 84 ++++ .../implementations/settings/settingsTypes.ts | 16 + .../src/modules/2DViewer/layers/interfaces.ts | 204 ++++++++ .../modules/2DViewer/layers/queryConstants.ts | 2 + frontend/src/modules/2DViewer/layers/utils.ts | 22 + frontend/src/modules/2DViewer/loadModule.tsx | 13 + frontend/src/modules/2DViewer/preview.tsx | 8 + frontend/src/modules/2DViewer/preview.webp | Bin 0 -> 38828 bytes .../src/modules/2DViewer/registerModule.ts | 24 + .../2DViewer/settings/atoms/baseAtoms.ts | 8 + .../2DViewer/settings/atoms/derivedAtoms.ts | 31 ++ .../components/layerManagerComponent.tsx | 445 ++++++++++++++++++ .../modules/2DViewer/settings/settings.tsx | 146 ++++++ frontend/src/modules/2DViewer/types.ts | 38 ++ .../view/components/LayersWrapper.tsx | 154 ++++++ .../view/components/ReadoutBoxWrapper.tsx | 117 +++++ .../view/components/ReadoutWrapper.tsx | 76 +++ .../2DViewer/view/components/Toolbar.tsx | 21 + .../customDeckGlLayers/AdvancedWellsLayer.ts | 67 +++ .../customDeckGlLayers/PlaceholderLayer.ts | 21 + .../customDeckGlLayers/WellborePicksLayer.ts | 136 ++++++ .../2DViewer/view/utils/layerFactory.ts | 363 ++++++++++++++ .../2DViewer/view/utils/makeViewsAndLayers.ts | 179 +++++++ frontend/src/modules/2DViewer/view/view.tsx | 24 + .../_shared/components/Toolbar/index.ts | 3 + .../_shared/components/Toolbar/toolbar.tsx | 16 + .../components/Toolbar/toolbarDivider.tsx | 3 + frontend/src/modules/registerAllModules.ts | 4 +- 94 files changed, 8638 insertions(+), 2 deletions(-) create mode 100644 frontend/src/modules/2DViewer/interfaces.ts create mode 100644 frontend/src/modules/2DViewer/layers/ColorScale.ts create mode 100644 frontend/src/modules/2DViewer/layers/DeltaSurface.ts create mode 100644 frontend/src/modules/2DViewer/layers/Dependency.ts create mode 100644 frontend/src/modules/2DViewer/layers/DeserializationFactory.ts create mode 100644 frontend/src/modules/2DViewer/layers/LayerManager.ts create mode 100644 frontend/src/modules/2DViewer/layers/LayerRegistry.ts create mode 100644 frontend/src/modules/2DViewer/layers/SettingRegistry.ts create mode 100644 frontend/src/modules/2DViewer/layers/SettingsGroup.ts create mode 100644 frontend/src/modules/2DViewer/layers/SharedSetting.ts create mode 100644 frontend/src/modules/2DViewer/layers/View.ts create mode 100644 frontend/src/modules/2DViewer/layers/components/ColorScaleComponent.tsx create mode 100644 frontend/src/modules/2DViewer/layers/components/DeltaSurfaceComponent.tsx create mode 100644 frontend/src/modules/2DViewer/layers/components/EditName.tsx create mode 100644 frontend/src/modules/2DViewer/layers/components/EmptyContent.tsx create mode 100644 frontend/src/modules/2DViewer/layers/components/ExpandCollapseAllButton.tsx create mode 100644 frontend/src/modules/2DViewer/layers/components/LayerComponent.tsx create mode 100644 frontend/src/modules/2DViewer/layers/components/LayersActions.tsx create mode 100644 frontend/src/modules/2DViewer/layers/components/RemoveButton.tsx create mode 100644 frontend/src/modules/2DViewer/layers/components/SettingComponent.tsx create mode 100644 frontend/src/modules/2DViewer/layers/components/SettingsGroupComponent.tsx create mode 100644 frontend/src/modules/2DViewer/layers/components/SharedSettingComponent.tsx create mode 100644 frontend/src/modules/2DViewer/layers/components/ViewComponent.tsx create mode 100644 frontend/src/modules/2DViewer/layers/components/VisibilityToggle.tsx create mode 100644 frontend/src/modules/2DViewer/layers/components/utils.tsx create mode 100644 frontend/src/modules/2DViewer/layers/delegates/GroupDelegate.ts create mode 100644 frontend/src/modules/2DViewer/layers/delegates/ItemDelegate.ts create mode 100644 frontend/src/modules/2DViewer/layers/delegates/LayerDelegate.ts create mode 100644 frontend/src/modules/2DViewer/layers/delegates/PublishSubscribeDelegate.ts create mode 100644 frontend/src/modules/2DViewer/layers/delegates/SettingDelegate.ts create mode 100644 frontend/src/modules/2DViewer/layers/delegates/SettingsContextDelegate.ts create mode 100644 frontend/src/modules/2DViewer/layers/delegates/UnsubscribeHandlerDelegate.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellTrajectoriesLayer/DrilledWellTrajectoriesContext.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellTrajectoriesLayer/DrilledWellTrajectoriesLayer.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellTrajectoriesLayer/types.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellborePicksLayer/DrilledWellborePicksContext.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellborePicksLayer/DrilledWellborePicksLayer.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellborePicksLayer/types.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/ObservedSurfaceLayer/ObservedSurfaceContext.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/ObservedSurfaceLayer/ObservedSurfaceLayer.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/ObservedSurfaceLayer/types.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/RealizationGridLayer/RealizationGridContext.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/RealizationGridLayer/RealizationGridLayer.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/RealizationGridLayer/types.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/RealizationPolygonsLayer/RealizationPolygonsContext.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/RealizationPolygonsLayer/RealizationPolygonsLayer.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/RealizationPolygonsLayer/types.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/RealizationSurfaceLayer/RealizationSurfaceContext.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/RealizationSurfaceLayer/RealizationSurfaceLayer.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/RealizationSurfaceLayer/types.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/StatisticalSurfaceLayer/StatisticalSurfaceContext.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/StatisticalSurfaceLayer/StatisticalSurfaceLayer.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/layers/StatisticalSurfaceLayer/types.ts create mode 100644 frontend/src/modules/2DViewer/layers/implementations/settings/DrilledWellbores.tsx create mode 100644 frontend/src/modules/2DViewer/layers/implementations/settings/Ensemble.tsx create mode 100644 frontend/src/modules/2DViewer/layers/implementations/settings/GridAttribute.tsx create mode 100644 frontend/src/modules/2DViewer/layers/implementations/settings/GridLayer.tsx create mode 100644 frontend/src/modules/2DViewer/layers/implementations/settings/GridName.tsx create mode 100644 frontend/src/modules/2DViewer/layers/implementations/settings/PolygonsAttribute.tsx create mode 100644 frontend/src/modules/2DViewer/layers/implementations/settings/PolygonsName.tsx create mode 100644 frontend/src/modules/2DViewer/layers/implementations/settings/Realization.tsx create mode 100644 frontend/src/modules/2DViewer/layers/implementations/settings/Sensitivity.tsx create mode 100644 frontend/src/modules/2DViewer/layers/implementations/settings/ShowGridLines.tsx create mode 100644 frontend/src/modules/2DViewer/layers/implementations/settings/StatisticFunction.tsx create mode 100644 frontend/src/modules/2DViewer/layers/implementations/settings/SurfaceAttribute.tsx create mode 100644 frontend/src/modules/2DViewer/layers/implementations/settings/SurfaceName.tsx create mode 100644 frontend/src/modules/2DViewer/layers/implementations/settings/TimeOrInterval.tsx create mode 100644 frontend/src/modules/2DViewer/layers/implementations/settings/settingsTypes.ts create mode 100644 frontend/src/modules/2DViewer/layers/interfaces.ts create mode 100644 frontend/src/modules/2DViewer/layers/queryConstants.ts create mode 100644 frontend/src/modules/2DViewer/layers/utils.ts create mode 100644 frontend/src/modules/2DViewer/loadModule.tsx create mode 100644 frontend/src/modules/2DViewer/preview.tsx create mode 100644 frontend/src/modules/2DViewer/preview.webp create mode 100644 frontend/src/modules/2DViewer/registerModule.ts create mode 100644 frontend/src/modules/2DViewer/settings/atoms/baseAtoms.ts create mode 100644 frontend/src/modules/2DViewer/settings/atoms/derivedAtoms.ts create mode 100644 frontend/src/modules/2DViewer/settings/components/layerManagerComponent.tsx create mode 100644 frontend/src/modules/2DViewer/settings/settings.tsx create mode 100644 frontend/src/modules/2DViewer/types.ts create mode 100644 frontend/src/modules/2DViewer/view/components/LayersWrapper.tsx create mode 100644 frontend/src/modules/2DViewer/view/components/ReadoutBoxWrapper.tsx create mode 100644 frontend/src/modules/2DViewer/view/components/ReadoutWrapper.tsx create mode 100644 frontend/src/modules/2DViewer/view/components/Toolbar.tsx create mode 100644 frontend/src/modules/2DViewer/view/customDeckGlLayers/AdvancedWellsLayer.ts create mode 100644 frontend/src/modules/2DViewer/view/customDeckGlLayers/PlaceholderLayer.ts create mode 100644 frontend/src/modules/2DViewer/view/customDeckGlLayers/WellborePicksLayer.ts create mode 100644 frontend/src/modules/2DViewer/view/utils/layerFactory.ts create mode 100644 frontend/src/modules/2DViewer/view/utils/makeViewsAndLayers.ts create mode 100644 frontend/src/modules/2DViewer/view/view.tsx create mode 100644 frontend/src/modules/_shared/components/Toolbar/index.ts create mode 100644 frontend/src/modules/_shared/components/Toolbar/toolbar.tsx create mode 100644 frontend/src/modules/_shared/components/Toolbar/toolbarDivider.tsx diff --git a/frontend/src/modules/2DViewer/interfaces.ts b/frontend/src/modules/2DViewer/interfaces.ts new file mode 100644 index 000000000..1c42c5ce3 --- /dev/null +++ b/frontend/src/modules/2DViewer/interfaces.ts @@ -0,0 +1,23 @@ +import { InterfaceInitialization } from "@framework/UniDirectionalModuleComponentsInterface"; + +import { LayerManager } from "./layers/LayerManager"; +import { layerManagerAtom, preferredViewLayoutAtom } from "./settings/atoms/baseAtoms"; +import { PreferredViewLayout } from "./types"; + +export type SettingsToViewInterface = { + layerManager: LayerManager | null; + preferredViewLayout: PreferredViewLayout; +}; + +export type Interfaces = { + settingsToView: SettingsToViewInterface; +}; + +export const settingsToViewInterfaceInitialization: InterfaceInitialization = { + layerManager: (get) => { + return get(layerManagerAtom); + }, + preferredViewLayout: (get) => { + return get(preferredViewLayoutAtom); + }, +}; diff --git a/frontend/src/modules/2DViewer/layers/ColorScale.ts b/frontend/src/modules/2DViewer/layers/ColorScale.ts new file mode 100644 index 000000000..6463ac651 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/ColorScale.ts @@ -0,0 +1,59 @@ +import { defaultContinuousSequentialColorPalettes } from "@framework/utils/colorPalettes"; +import { ColorScaleGradientType, ColorScaleType } from "@lib/utils/ColorScale"; +import { ColorScale as ColorScaleImpl } from "@lib/utils/ColorScale"; + +import { LayerManager, LayerManagerTopic } from "./LayerManager"; +import { ItemDelegate } from "./delegates/ItemDelegate"; +import { Item, SerializedColorScale } from "./interfaces"; + +export class ColorScale implements Item { + private _itemDelegate: ItemDelegate; + private _colorScale: ColorScaleImpl = new ColorScaleImpl({ + colorPalette: defaultContinuousSequentialColorPalettes[0], + gradientType: ColorScaleGradientType.Sequential, + type: ColorScaleType.Continuous, + steps: 10, + }); + private _areBoundariesUserDefined: boolean = false; + + constructor(name: string, layerManager: LayerManager) { + this._itemDelegate = new ItemDelegate(name, layerManager); + } + + getItemDelegate(): ItemDelegate { + return this._itemDelegate; + } + + getColorScale(): ColorScaleImpl { + return this._colorScale; + } + + setColorScale(colorScale: ColorScaleImpl): void { + this._colorScale = colorScale; + this.getItemDelegate().getLayerManager()?.publishTopic(LayerManagerTopic.LAYER_DATA_REVISION); + } + + getAreBoundariesUserDefined(): boolean { + return this._areBoundariesUserDefined; + } + + setAreBoundariesUserDefined(areBoundariesUserDefined: boolean): void { + this._areBoundariesUserDefined = areBoundariesUserDefined; + this.getItemDelegate().getLayerManager()?.publishTopic(LayerManagerTopic.LAYER_DATA_REVISION); + } + + serializeState(): SerializedColorScale { + return { + ...this._itemDelegate.serializeState(), + type: "color-scale", + colorScale: this._colorScale.serialize(), + userDefinedBoundaries: this._areBoundariesUserDefined, + }; + } + + deserializeState(serialized: SerializedColorScale): void { + this._itemDelegate.deserializeState(serialized); + this._colorScale = ColorScaleImpl.fromSerialized(serialized.colorScale); + this._areBoundariesUserDefined = serialized.userDefinedBoundaries; + } +} diff --git a/frontend/src/modules/2DViewer/layers/DeltaSurface.ts b/frontend/src/modules/2DViewer/layers/DeltaSurface.ts new file mode 100644 index 000000000..e7a9d5f8e --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/DeltaSurface.ts @@ -0,0 +1,85 @@ +import { LayerManager } from "./LayerManager"; +import { GroupDelegate, GroupDelegateTopic } from "./delegates/GroupDelegate"; +import { ItemDelegate } from "./delegates/ItemDelegate"; +import { LayerDelegate } from "./delegates/LayerDelegate"; +import { SettingsContextDelegateTopic } from "./delegates/SettingsContextDelegate"; +import { UnsubscribeHandlerDelegate } from "./delegates/UnsubscribeHandlerDelegate"; +import { Group, SerializedDeltaSurface, instanceofLayer } from "./interfaces"; + +export class DeltaSurface implements Group { + private _itemDelegate: ItemDelegate; + private _groupDelegate: GroupDelegate; + private _unsubscribeHandler: UnsubscribeHandlerDelegate = new UnsubscribeHandlerDelegate(); + private _childrenLayerDelegateSet: Set> = new Set(); + + constructor(name: string, layerManager: LayerManager) { + this._groupDelegate = new GroupDelegate(this); + + this._unsubscribeHandler.registerUnsubscribeFunction( + "children", + this._groupDelegate.getPublishSubscribeDelegate().makeSubscriberFunction(GroupDelegateTopic.CHILDREN)( + () => { + this.handleChildrenChange(); + } + ) + ); + + this._groupDelegate.setColor("rgb(220, 210, 180)"); + this._itemDelegate = new ItemDelegate(name, layerManager); + } + + private handleChildrenChange(): void { + this._unsubscribeHandler.unsubscribe("layer-delegates"); + + for (const layerDelegate of this._childrenLayerDelegateSet) { + layerDelegate.setIsSubordinated(false); + } + + this._childrenLayerDelegateSet.clear(); + + for (const child of this._groupDelegate.getChildren()) { + if (instanceofLayer(child)) { + child.getLayerDelegate().setIsSubordinated(true); + const layerDelegate = child.getLayerDelegate(); + this._childrenLayerDelegateSet.add(layerDelegate); + + this._unsubscribeHandler.registerUnsubscribeFunction( + "layer-delegates", + layerDelegate + .getSettingsContext() + .getDelegate() + .getPublishSubscribeDelegate() + .makeSubscriberFunction(SettingsContextDelegateTopic.SETTINGS_CHANGED)(() => { + this.handleSettingsChange(); + }) + ); + } + } + } + + private handleSettingsChange(): void { + console.debug("Settings changed - would refetch data"); + // Fetch data + } + + getItemDelegate(): ItemDelegate { + return this._itemDelegate; + } + + getGroupDelegate(): GroupDelegate { + return this._groupDelegate; + } + + deserializeState(serialized: SerializedDeltaSurface): void { + this._itemDelegate.deserializeState(serialized); + this._groupDelegate.deserializeChildren(serialized.children); + } + + serializeState(): SerializedDeltaSurface { + return { + ...this._itemDelegate.serializeState(), + type: "delta-surface", + children: this.getGroupDelegate().serializeChildren(), + }; + } +} diff --git a/frontend/src/modules/2DViewer/layers/Dependency.ts b/frontend/src/modules/2DViewer/layers/Dependency.ts new file mode 100644 index 000000000..af2904299 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/Dependency.ts @@ -0,0 +1,210 @@ +import { isCancelledError } from "@tanstack/react-query"; + +import { isEqual } from "lodash"; + +import { GlobalSettings } from "./LayerManager"; +import { SettingsContextDelegate } from "./delegates/SettingsContextDelegate"; +import { Settings, UpdateFunc } from "./interfaces"; + +export class Dependency { + private _updateFunc: UpdateFunc; + private _dependencies: Set<(value: Awaited | null) => void> = new Set(); + private _loadingDependencies: Set<(loading: boolean, hasDependencies: boolean) => void> = new Set(); + + private _contextDelegate: SettingsContextDelegate; + + private _makeSettingGetter: (key: K, handler: (value: TSettings[K]) => void) => void; + private _makeGlobalSettingGetter: ( + key: K, + handler: (value: GlobalSettings[K]) => void + ) => void; + private _cachedSettingsMap: Map = new Map(); + private _cachedGlobalSettingsMap: Map = new Map(); + private _cachedDependenciesMap: Map, any> = new Map(); + private _cachedValue: Awaited | null = null; + private _abortController: AbortController | null = null; + private _isInitialized = false; + private _numParentDependencies = 0; + private _numChildDependencies = 0; + + constructor( + contextDelegate: SettingsContextDelegate, + updateFunc: UpdateFunc, + makeSettingGetter: (key: K, handler: (value: TSettings[K]) => void) => void, + makeGlobalSettingGetter: ( + key: K, + handler: (value: GlobalSettings[K]) => void + ) => void + ) { + this._contextDelegate = contextDelegate; + this._updateFunc = updateFunc; + this._makeSettingGetter = makeSettingGetter; + this._makeGlobalSettingGetter = makeGlobalSettingGetter; + + this.getGlobalSetting = this.getGlobalSetting.bind(this); + this.getLocalSetting = this.getLocalSetting.bind(this); + this.getHelperDependency = this.getHelperDependency.bind(this); + } + + hasChildDependencies(): boolean { + return this._numChildDependencies > 0; + } + + getValue(): Awaited | null { + return this._cachedValue; + } + + subscribe(callback: (value: Awaited | null) => void, childDependency: boolean = false): () => void { + this._dependencies.add(callback); + + if (childDependency) { + this._numChildDependencies++; + } + + return () => { + this._dependencies.delete(callback); + if (childDependency) { + this._numChildDependencies--; + } + }; + } + + subscribeLoading(callback: (loading: boolean, hasDependencies: boolean) => void): () => void { + this._loadingDependencies.add(callback); + + return () => { + this._loadingDependencies.delete(callback); + }; + } + + private getLocalSetting(settingName: K): TSettings[K] { + if (!this._isInitialized) { + this._numParentDependencies++; + } + + if (this._cachedSettingsMap.has(settingName as string)) { + return this._cachedSettingsMap.get(settingName as string); + } + + this._makeSettingGetter(settingName, (value) => { + this._cachedSettingsMap.set(settingName as string, value); + this.callUpdateFunc(); + }); + + this._cachedSettingsMap.set( + settingName as string, + this._contextDelegate.getSettings()[settingName].getDelegate().getValue() + ); + return this._cachedSettingsMap.get(settingName as string); + } + + private setLoadingState(loading: boolean) { + for (const callback of this._loadingDependencies) { + callback(loading, this.hasChildDependencies()); + } + } + + private getGlobalSetting(settingName: K): GlobalSettings[K] { + if (this._cachedGlobalSettingsMap.has(settingName as string)) { + return this._cachedGlobalSettingsMap.get(settingName as string); + } + + this._makeGlobalSettingGetter(settingName, (value) => { + this._cachedGlobalSettingsMap.set(settingName as string, value); + this.callUpdateFunc(); + }); + + this._cachedGlobalSettingsMap.set( + settingName as string, + this._contextDelegate.getLayerManager().getGlobalSetting(settingName) + ); + return this._cachedGlobalSettingsMap.get(settingName as string); + } + + private getHelperDependency(dep: Dependency): Awaited | null { + if (!this._isInitialized) { + this._numParentDependencies++; + } + + if (this._cachedDependenciesMap.has(dep)) { + return this._cachedDependenciesMap.get(dep); + } + + const value = dep.getValue(); + this._cachedDependenciesMap.set(dep, value); + + dep.subscribe((newValue) => { + this._cachedDependenciesMap.set(dep, newValue); + this.callUpdateFunc(); + }, true); + + dep.subscribeLoading((loading) => { + if (loading) { + this.setLoadingState(true); + } + // Not subscribing to loading state false as it will + // be set when this dependency is updated + // #Waterfall + }); + + return value; + } + + async initialize() { + this._abortController = new AbortController(); + + // Establishing subscriptions + await this._updateFunc({ + getLocalSetting: this.getLocalSetting, + getGlobalSetting: this.getGlobalSetting, + getHelperDependency: this.getHelperDependency, + abortSignal: this._abortController.signal, + }); + + // If there are no dependencies, we can call the update function + if (this._numParentDependencies === 0) { + await this.callUpdateFunc(); + } + + this._isInitialized = true; + } + + async callUpdateFunc() { + if (this._abortController) { + this._abortController.abort(); + this._abortController = null; + } + + this._abortController = new AbortController(); + + this.setLoadingState(true); + + let newValue: Awaited | null = null; + try { + newValue = await this._updateFunc({ + getLocalSetting: this.getLocalSetting, + getGlobalSetting: this.getGlobalSetting, + getHelperDependency: this.getHelperDependency, + abortSignal: this._abortController.signal, + }); + } catch (e: any) { + if (!isCancelledError(e)) { + this.applyNewValue(null); + return; + } + return; + } + + this.applyNewValue(newValue); + } + + applyNewValue(newValue: Awaited | null) { + this.setLoadingState(false); + if (!isEqual(newValue, this._cachedValue) || newValue === null) { + this._cachedValue = newValue; + for (const callback of this._dependencies) { + callback(newValue); + } + } + } +} diff --git a/frontend/src/modules/2DViewer/layers/DeserializationFactory.ts b/frontend/src/modules/2DViewer/layers/DeserializationFactory.ts new file mode 100644 index 000000000..4f14b1477 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/DeserializationFactory.ts @@ -0,0 +1,70 @@ +import { ColorScale } from "./ColorScale"; +import { LayerManager } from "./LayerManager"; +import { LayerRegistry } from "./LayerRegistry"; +import { SettingRegistry } from "./SettingRegistry"; +import { SettingsGroup } from "./SettingsGroup"; +import { SharedSetting } from "./SharedSetting"; +import { View } from "./View"; +import { + Item, + SerializedColorScale, + SerializedItem, + SerializedLayer, + SerializedSettingsGroup, + SerializedSharedSetting, + SerializedView, +} from "./interfaces"; + +export class DeserializationFactory { + private _layerManager: LayerManager; + + constructor(layerManager: LayerManager) { + this._layerManager = layerManager; + } + + makeItem(serialized: SerializedItem): Item { + if (serialized.type === "layer") { + const serializedLayer = serialized as SerializedLayer; + const layer = LayerRegistry.makeLayer(serializedLayer.layerClass, this._layerManager); + layer.getLayerDelegate().deserializeState(serializedLayer); + layer.getItemDelegate().setId(serializedLayer.id); + layer.getItemDelegate().setName(serializedLayer.name); + return layer; + } + + if (serialized.type === "view") { + const serializedView = serialized as SerializedView; + const view = new View(serializedView.name, this._layerManager, serializedView.color); + view.deserializeState(serializedView); + return view; + } + + if (serialized.type === "settings-group") { + const serializedSettingsGroup = serialized as SerializedSettingsGroup; + const settingsGroup = new SettingsGroup(serializedSettingsGroup.name, this._layerManager); + settingsGroup.deserializeState(serializedSettingsGroup); + return settingsGroup; + } + + if (serialized.type === "color-scale") { + const serializedColorScale = serialized as SerializedColorScale; + const colorScale = new ColorScale(serializedColorScale.name, this._layerManager); + colorScale.deserializeState(serializedColorScale); + return colorScale; + } + + if (serialized.type === "delta-surface") { + throw new Error("DeltaSurface deserialization not implemented"); + } + + if (serialized.type === "shared-setting") { + const serializedSharedSetting = serialized as SerializedSharedSetting; + const wrappedSetting = SettingRegistry.makeSetting(serializedSharedSetting.wrappedSettingClass); + const setting = new SharedSetting(wrappedSetting, this._layerManager); + setting.deserializeState(serializedSharedSetting); + return setting; + } + + throw new Error(`Unknown serialized item type: ${serialized.type}`); + } +} diff --git a/frontend/src/modules/2DViewer/layers/LayerManager.ts b/frontend/src/modules/2DViewer/layers/LayerManager.ts new file mode 100644 index 000000000..dc9c7cfab --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/LayerManager.ts @@ -0,0 +1,209 @@ +import { Ensemble } from "@framework/Ensemble"; +import { + EnsembleRealizationFilterFunction, + WorkbenchSession, + WorkbenchSessionEvent, + createEnsembleRealizationFilterFuncForWorkbenchSession, +} from "@framework/WorkbenchSession"; +import { WorkbenchSettings } from "@framework/WorkbenchSettings"; +import { QueryClient } from "@tanstack/react-query"; + +import { isEqual } from "lodash"; + +import { GroupDelegate, GroupDelegateTopic } from "./delegates/GroupDelegate"; +import { ItemDelegate } from "./delegates/ItemDelegate"; +import { PublishSubscribe, PublishSubscribeDelegate } from "./delegates/PublishSubscribeDelegate"; +import { UnsubscribeHandlerDelegate } from "./delegates/UnsubscribeHandlerDelegate"; +import { Group, Item, SerializedLayerManager } from "./interfaces"; + +export enum LayerManagerTopic { + ITEMS_CHANGED = "ITEMS_CHANGED", + SETTINGS_CHANGED = "SETTINGS_CHANGED", + AVAILABLE_SETTINGS_CHANGED = "AVAILABLE_SETTINGS_CHANGED", + LAYER_DATA_REVISION = "LAYER_DATA_REVISION", + GLOBAL_SETTINGS_CHANGED = "GLOBAL_SETTINGS_CHANGED", + SHARED_SETTINGS_CHANGED = "SHARED_SETTINGS_CHANGED", +} + +export type LayerManagerTopicPayload = { + [LayerManagerTopic.ITEMS_CHANGED]: Item[]; + [LayerManagerTopic.SETTINGS_CHANGED]: void; + [LayerManagerTopic.AVAILABLE_SETTINGS_CHANGED]: void; + [LayerManagerTopic.LAYER_DATA_REVISION]: number; + [LayerManagerTopic.GLOBAL_SETTINGS_CHANGED]: void; + [LayerManagerTopic.SHARED_SETTINGS_CHANGED]: void; +}; + +export type GlobalSettings = { + fieldId: string | null; + ensembles: readonly Ensemble[]; + realizationFilterFunction: EnsembleRealizationFilterFunction; +}; + +export class LayerManager implements Group, PublishSubscribe { + private _workbenchSession: WorkbenchSession; + private _workbenchSettings: WorkbenchSettings; + private _groupDelegate: GroupDelegate; + private _queryClient: QueryClient; + private _publishSubscribeDelegate = new PublishSubscribeDelegate(); + private _itemDelegate: ItemDelegate; + private _layerDataRevision: number = 0; + private _globalSettings: GlobalSettings; + private _subscriptionsHandler = new UnsubscribeHandlerDelegate(); + private _deserializing = false; + + constructor(workbenchSession: WorkbenchSession, workbenchSettings: WorkbenchSettings, queryClient: QueryClient) { + this._workbenchSession = workbenchSession; + this._workbenchSettings = workbenchSettings; + this._queryClient = queryClient; + this._itemDelegate = new ItemDelegate("LayerManager", this); + this._groupDelegate = new GroupDelegate(this); + + this._globalSettings = this.initializeGlobalSettings(); + + this._subscriptionsHandler.registerUnsubscribeFunction( + "workbenchSession", + this._workbenchSession.subscribe( + WorkbenchSessionEvent.EnsembleSetChanged, + this.handleEnsembleSetChanged.bind(this) + ) + ); + this._subscriptionsHandler.registerUnsubscribeFunction( + "workbenchSession", + this._workbenchSession.subscribe( + WorkbenchSessionEvent.RealizationFilterSetChanged, + this.handleRealizationFilterSetChanged.bind(this) + ) + ); + this._subscriptionsHandler.registerUnsubscribeFunction( + "groupDelegate", + this._groupDelegate + .getPublishSubscribeDelegate() + .makeSubscriberFunction(GroupDelegateTopic.TREE_REVISION_NUMBER)(() => { + this.publishTopic(LayerManagerTopic.LAYER_DATA_REVISION); + this.publishTopic(LayerManagerTopic.ITEMS_CHANGED); + }) + ); + } + + getItemDelegate(): ItemDelegate { + return this._itemDelegate; + } + + getGroupDelegate(): GroupDelegate { + return this._groupDelegate; + } + + updateGlobalSetting(key: T, value: GlobalSettings[T]): void { + if (isEqual(this._globalSettings[key], value)) { + return; + } + + this._globalSettings[key] = value; + this.publishTopic(LayerManagerTopic.GLOBAL_SETTINGS_CHANGED); + } + + getGlobalSetting(key: T): GlobalSettings[T] { + return this._globalSettings[key]; + } + + publishTopic(topic: LayerManagerTopic): void { + if (this._deserializing) { + return; + } + + if (topic === LayerManagerTopic.LAYER_DATA_REVISION) { + this._layerDataRevision++; + } + + this._publishSubscribeDelegate.notifySubscribers(topic); + } + + getWorkbenchSession(): WorkbenchSession { + return this._workbenchSession; + } + + getQueryClient(): QueryClient { + return this._queryClient; + } + + getWorkbenchSettings(): WorkbenchSettings { + return this._workbenchSettings; + } + + makeSnapshotGetter(topic: T): () => LayerManagerTopicPayload[T] { + const snapshotGetter = (): any => { + if (topic === LayerManagerTopic.ITEMS_CHANGED) { + return this._groupDelegate.getChildren(); + } + if (topic === LayerManagerTopic.SETTINGS_CHANGED) { + return; + } + if (topic === LayerManagerTopic.AVAILABLE_SETTINGS_CHANGED) { + return; + } + if (topic === LayerManagerTopic.LAYER_DATA_REVISION) { + return this._layerDataRevision; + } + if (topic === LayerManagerTopic.GLOBAL_SETTINGS_CHANGED) { + return this._globalSettings; + } + if (topic === LayerManagerTopic.SHARED_SETTINGS_CHANGED) { + return; + } + }; + + return snapshotGetter; + } + + getPublishSubscribeDelegate(): PublishSubscribeDelegate { + return this._publishSubscribeDelegate; + } + + beforeDestroy() { + this._subscriptionsHandler.unsubscribeAll(); + } + + serializeState(): SerializedLayerManager { + const itemState = this._itemDelegate.serializeState(); + return { + ...itemState, + type: "layer-manager", + children: this._groupDelegate.serializeChildren(), + }; + } + + deserializeState(serializedState: SerializedLayerManager): void { + this._deserializing = true; + this._itemDelegate.deserializeState(serializedState); + this._groupDelegate.deserializeChildren(serializedState.children); + this._deserializing = false; + + this.publishTopic(LayerManagerTopic.ITEMS_CHANGED); + this.publishTopic(LayerManagerTopic.GLOBAL_SETTINGS_CHANGED); + } + + private initializeGlobalSettings(): GlobalSettings { + const ensembles = this._workbenchSession.getEnsembleSet().getEnsembleArr(); + return { + fieldId: null, + ensembles, + realizationFilterFunction: createEnsembleRealizationFilterFuncForWorkbenchSession(this._workbenchSession), + }; + } + + private handleRealizationFilterSetChanged() { + this._globalSettings.realizationFilterFunction = createEnsembleRealizationFilterFuncForWorkbenchSession( + this._workbenchSession + ); + + this.publishTopic(LayerManagerTopic.GLOBAL_SETTINGS_CHANGED); + } + + private handleEnsembleSetChanged() { + const ensembles = this._workbenchSession.getEnsembleSet().getEnsembleArr(); + this._globalSettings.ensembles = ensembles; + + this.publishTopic(LayerManagerTopic.GLOBAL_SETTINGS_CHANGED); + } +} diff --git a/frontend/src/modules/2DViewer/layers/LayerRegistry.ts b/frontend/src/modules/2DViewer/layers/LayerRegistry.ts new file mode 100644 index 000000000..cb1455e94 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/LayerRegistry.ts @@ -0,0 +1,18 @@ +import { LayerManager } from "./LayerManager"; +import { Layer } from "./interfaces"; + +export class LayerRegistry { + private static _registeredLayers: Map }> = new Map(); + + static registerLayer(ctor: { new (layerManager: LayerManager): Layer }): void { + this._registeredLayers.set(ctor.name, ctor); + } + + static makeLayer(layerName: string, layerManager: LayerManager): Layer { + const Layer = this._registeredLayers.get(layerName); + if (!Layer) { + throw new Error(`Layer ${layerName} not found`); + } + return new Layer(layerManager); + } +} diff --git a/frontend/src/modules/2DViewer/layers/SettingRegistry.ts b/frontend/src/modules/2DViewer/layers/SettingRegistry.ts new file mode 100644 index 000000000..b8e8b8a76 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/SettingRegistry.ts @@ -0,0 +1,13 @@ +import { Setting } from "./interfaces"; + +export class SettingRegistry { + private static _registeredSettings: Record }> = {}; + + static registerSetting(ctor: { new (): Setting }): void { + this._registeredSettings[ctor.name] = ctor; + } + + static makeSetting(settingName: string): Setting { + return new this._registeredSettings[settingName](); + } +} diff --git a/frontend/src/modules/2DViewer/layers/SettingsGroup.ts b/frontend/src/modules/2DViewer/layers/SettingsGroup.ts new file mode 100644 index 000000000..7ff819b2c --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/SettingsGroup.ts @@ -0,0 +1,36 @@ +import { LayerManager } from "./LayerManager"; +import { GroupDelegate } from "./delegates/GroupDelegate"; +import { ItemDelegate } from "./delegates/ItemDelegate"; +import { Group, SerializedSettingsGroup } from "./interfaces"; + +export class SettingsGroup implements Group { + private _itemDelegate: ItemDelegate; + private _groupDelegate: GroupDelegate; + + constructor(name: string, layerManager: LayerManager) { + this._groupDelegate = new GroupDelegate(this); + this._groupDelegate.setColor("rgb(196 181 253)"); + this._itemDelegate = new ItemDelegate(name, layerManager); + } + + getItemDelegate(): ItemDelegate { + return this._itemDelegate; + } + + getGroupDelegate(): GroupDelegate { + return this._groupDelegate; + } + + serializeState(): SerializedSettingsGroup { + return { + ...this._itemDelegate.serializeState(), + type: "settings-group", + children: this._groupDelegate.serializeChildren(), + }; + } + + deserializeState(serialized: SerializedSettingsGroup) { + this._itemDelegate.deserializeState(serialized); + this._groupDelegate.deserializeChildren(serialized.children); + } +} diff --git a/frontend/src/modules/2DViewer/layers/SharedSetting.ts b/frontend/src/modules/2DViewer/layers/SharedSetting.ts new file mode 100644 index 000000000..7446c0c42 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/SharedSetting.ts @@ -0,0 +1,118 @@ +import { LayerManager, LayerManagerTopic } from "./LayerManager"; +import { ItemDelegate } from "./delegates/ItemDelegate"; +import { SettingTopic } from "./delegates/SettingDelegate"; +import { UnsubscribeHandlerDelegate } from "./delegates/UnsubscribeHandlerDelegate"; +import { Item, Layer, SerializedSharedSetting, Setting, instanceofLayer } from "./interfaces"; + +export class SharedSetting implements Item { + private _wrappedSetting: Setting; + private _unsubscribeHandler: UnsubscribeHandlerDelegate = new UnsubscribeHandlerDelegate(); + private _itemDelegate: ItemDelegate; + + constructor(wrappedSetting: Setting, layerManager: LayerManager) { + this._wrappedSetting = wrappedSetting; + + this._unsubscribeHandler.registerUnsubscribeFunction( + "setting", + this._wrappedSetting + .getDelegate() + .getPublishSubscribeDelegate() + .makeSubscriberFunction(SettingTopic.VALUE_CHANGED)(() => { + this.publishValueChange(); + }) + ); + this._itemDelegate = new ItemDelegate(wrappedSetting.getLabel(), layerManager); + + this._unsubscribeHandler.registerUnsubscribeFunction( + "layer-manager", + layerManager.getPublishSubscribeDelegate().makeSubscriberFunction(LayerManagerTopic.ITEMS_CHANGED)(() => { + this.makeIntersectionOfAvailableValues(); + }) + ); + this._unsubscribeHandler.registerUnsubscribeFunction( + "layer-manager", + layerManager.getPublishSubscribeDelegate().makeSubscriberFunction(LayerManagerTopic.SETTINGS_CHANGED)( + () => { + this.makeIntersectionOfAvailableValues(); + } + ) + ); + this._unsubscribeHandler.registerUnsubscribeFunction( + "layer-manager", + layerManager + .getPublishSubscribeDelegate() + .makeSubscriberFunction(LayerManagerTopic.AVAILABLE_SETTINGS_CHANGED)(() => { + this.makeIntersectionOfAvailableValues(); + }) + ); + } + + getItemDelegate(): ItemDelegate { + return this._itemDelegate; + } + + publishValueChange(): void { + const layerManager = this._itemDelegate.getLayerManager(); + if (layerManager) { + layerManager.publishTopic(LayerManagerTopic.SHARED_SETTINGS_CHANGED); + } + } + + getWrappedSetting(): Setting { + return this._wrappedSetting; + } + + private makeIntersectionOfAvailableValues(): void { + const parentGroup = this._itemDelegate.getParentGroup(); + if (!parentGroup) { + return; + } + + const layers = parentGroup.getDescendantItems((item) => instanceofLayer(item)) as Layer[]; + let index = 0; + let availableValues: any[] = []; + for (const item of layers) { + const setting = item.getLayerDelegate().getSettingsContext().getDelegate().getSettings()[ + this._wrappedSetting.getType() + ]; + if (setting) { + if (setting.getDelegate().isLoading()) { + this._wrappedSetting.getDelegate().setLoading(true); + return; + } + if (index === 0) { + availableValues.push(...setting.getDelegate().getAvailableValues()); + } else { + availableValues = availableValues.filter((value) => + setting.getDelegate().getAvailableValues().includes(value) + ); + } + index++; + } + } + + this._wrappedSetting.getDelegate().setLoading(false); + + this._wrappedSetting.getDelegate().setAvailableValues(availableValues); + this.publishValueChange(); + } + + serializeState(): SerializedSharedSetting { + return { + ...this._itemDelegate.serializeState(), + type: "shared-setting", + wrappedSettingClass: this._wrappedSetting.constructor.name, + settingType: this._wrappedSetting.getType(), + value: this._wrappedSetting.getDelegate().serializeValue(), + }; + } + + deserializeState(serialized: SerializedSharedSetting): void { + this._itemDelegate.deserializeState(serialized); + this._wrappedSetting.getDelegate().deserializeValue(serialized.value); + } + + beforeDestroy(): void { + this._unsubscribeHandler.unsubscribeAll(); + } +} diff --git a/frontend/src/modules/2DViewer/layers/View.ts b/frontend/src/modules/2DViewer/layers/View.ts new file mode 100644 index 000000000..f39c308fa --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/View.ts @@ -0,0 +1,38 @@ +import { LayerManager } from "./LayerManager"; +import { GroupDelegate } from "./delegates/GroupDelegate"; +import { ItemDelegate } from "./delegates/ItemDelegate"; +import { Group, SerializedView } from "./interfaces"; + +export class View implements Group { + private _itemDelegate: ItemDelegate; + private _groupDelegate: GroupDelegate; + + constructor(name: string, layerManager: LayerManager, color: string | null = null) { + this._groupDelegate = new GroupDelegate(this); + this._groupDelegate.setColor(color); + this._itemDelegate = new ItemDelegate(name, layerManager); + } + + getItemDelegate(): ItemDelegate { + return this._itemDelegate; + } + + getGroupDelegate(): GroupDelegate { + return this._groupDelegate; + } + + serializeState(): SerializedView { + return { + ...this._itemDelegate.serializeState(), + type: "view", + color: this._groupDelegate.getColor() ?? "", + children: this._groupDelegate.serializeChildren(), + }; + } + + deserializeState(serialized: SerializedView) { + this._itemDelegate.deserializeState(serialized); + this._groupDelegate.setColor(serialized.color); + this._groupDelegate.deserializeChildren(serialized.children); + } +} diff --git a/frontend/src/modules/2DViewer/layers/components/ColorScaleComponent.tsx b/frontend/src/modules/2DViewer/layers/components/ColorScaleComponent.tsx new file mode 100644 index 000000000..91836a135 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/components/ColorScaleComponent.tsx @@ -0,0 +1,73 @@ +import React from "react"; + +import { Icon } from "@equinor/eds-core-react"; +import { color_palette } from "@equinor/eds-icons"; +import { DenseIconButton } from "@lib/components/DenseIconButton"; +import { SortableListItem } from "@lib/components/SortableList"; +import { ColorScale as ColorScaleImpl } from "@lib/utils/ColorScale"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import { ColorScaleSelector } from "@modules/_shared/components/ColorScaleSelector/colorScaleSelector"; +import { ExpandLess, ExpandMore } from "@mui/icons-material"; + +import { RemoveButton } from "./RemoveButton"; + +import { ColorScale } from "../ColorScale"; +import { ItemDelegateTopic } from "../delegates/ItemDelegate"; +import { usePublishSubscribeTopicValue } from "../delegates/PublishSubscribeDelegate"; + +export type ColorScaleComponentProps = { + colorScale: ColorScale; +}; + +export function ColorScaleComponent(props: ColorScaleComponentProps): React.ReactNode { + const workbenchSettings = props.colorScale.getItemDelegate().getLayerManager()?.getWorkbenchSettings(); + const isExpanded = usePublishSubscribeTopicValue(props.colorScale.getItemDelegate(), ItemDelegateTopic.EXPANDED); + + function handleColorScaleChange(newColorScale: ColorScaleImpl, areBoundariesUserDefined: boolean): void { + props.colorScale.setColorScale(newColorScale); + props.colorScale.setAreBoundariesUserDefined(areBoundariesUserDefined); + } + + function makeColorScaleSelector(): React.ReactNode { + if (!workbenchSettings) { + return "No layer manager set."; + } + + return ( + + ); + } + + function handleToggleExpanded(): void { + props.colorScale.getItemDelegate().setExpanded(!isExpanded); + } + + return ( + Color scale} + startAdornment={ +
+ + {isExpanded ? : } + + +
+ } + endAdornment={} + > +
+ {makeColorScaleSelector()} +
+
+ ); +} diff --git a/frontend/src/modules/2DViewer/layers/components/DeltaSurfaceComponent.tsx b/frontend/src/modules/2DViewer/layers/components/DeltaSurfaceComponent.tsx new file mode 100644 index 000000000..0dfa0eb61 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/components/DeltaSurfaceComponent.tsx @@ -0,0 +1,78 @@ +import { SortableListGroup } from "@lib/components/SortableList"; + +import { EditName } from "./EditName"; +import { EmptyContent } from "./EmptyContent"; +import { ExpandCollapseAllButton } from "./ExpandCollapseAllButton"; +import { LayersActionGroup, LayersActions } from "./LayersActions"; +import { RemoveButton } from "./RemoveButton"; +import { VisibilityToggle } from "./VisibilityToggle"; +import { makeComponent } from "./utils"; + +import { DeltaSurface } from "../DeltaSurface"; +import { GroupDelegateTopic } from "../delegates/GroupDelegate"; +import { ItemDelegateTopic } from "../delegates/ItemDelegate"; +import { usePublishSubscribeTopicValue } from "../delegates/PublishSubscribeDelegate"; +import { Group, Item, instanceofLayer } from "../interfaces"; + +export type DeltaSurfaceComponentProps = { + deltaSurface: DeltaSurface; + actions?: LayersActionGroup[]; + onActionClick?: (actionIdentifier: string, group: Group) => void; +}; + +export function DeltaSurfaceComponent(props: DeltaSurfaceComponentProps): React.ReactNode { + const children = usePublishSubscribeTopicValue(props.deltaSurface.getGroupDelegate(), GroupDelegateTopic.CHILDREN); + const isExpanded = usePublishSubscribeTopicValue(props.deltaSurface.getItemDelegate(), ItemDelegateTopic.EXPANDED); + const color = props.deltaSurface.getGroupDelegate().getColor(); + + function handleActionClick(actionIdentifier: string) { + if (props.onActionClick) { + props.onActionClick(actionIdentifier, props.deltaSurface); + } + } + + function makeEndAdornment() { + const adornment: React.ReactNode[] = []; + if ( + props.actions && + props.deltaSurface.getGroupDelegate().findChildren((item) => instanceofLayer(item)).length < 2 + ) { + adornment.push( + + ); + } + adornment.push(); + adornment.push(); + return adornment; + } + + return ( + } + contentStyle={{ + backgroundColor: color ?? undefined, + }} + headerStyle={{ + backgroundColor: color ?? undefined, + }} + startAdornment={ +
+ +
+ } + endAdornment={<>{makeEndAdornment()}} + contentWhenEmpty={ + Drag two surface layers inside to calculate the difference between them. + } + expanded={isExpanded} + > + {children.map((child: Item) => makeComponent(child, props.actions, props.onActionClick))} +
+ ); +} diff --git a/frontend/src/modules/2DViewer/layers/components/EditName.tsx b/frontend/src/modules/2DViewer/layers/components/EditName.tsx new file mode 100644 index 000000000..b4ec032ee --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/components/EditName.tsx @@ -0,0 +1,67 @@ +import React from "react"; + +import { Edit } from "@mui/icons-material"; + +import { ItemDelegateTopic } from "../delegates/ItemDelegate"; +import { usePublishSubscribeTopicValue } from "../delegates/PublishSubscribeDelegate"; +import { Item } from "../interfaces"; + +type EditItemNameProps = { + item: Item; +}; + +export function EditName(props: EditItemNameProps): React.ReactNode { + const itemName = usePublishSubscribeTopicValue(props.item.getItemDelegate(), ItemDelegateTopic.NAME); + + const [editingName, setEditingName] = React.useState(false); + const [currentName, setCurrentName] = React.useState(itemName); + + function handleNameDoubleClick() { + setEditingName(true); + } + + function handleNameChange(e: React.ChangeEvent) { + setCurrentName(e.target.value); + } + + function handleBlur() { + setEditingName(false); + props.item.getItemDelegate().setName(currentName); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") { + setEditingName(false); + props.item.getItemDelegate().setName(currentName); + } + } + + return ( +
+ {editingName ? ( + + ) : ( + <> +
{itemName}
+ + + )} +
+ ); +} diff --git a/frontend/src/modules/2DViewer/layers/components/EmptyContent.tsx b/frontend/src/modules/2DViewer/layers/components/EmptyContent.tsx new file mode 100644 index 000000000..ab8a3db27 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/components/EmptyContent.tsx @@ -0,0 +1,9 @@ +import React from "react"; + +export type EmptyContentProps = { + children?: React.ReactNode; +}; + +export function EmptyContent(props: EmptyContentProps): React.ReactNode { + return
{props.children}
; +} diff --git a/frontend/src/modules/2DViewer/layers/components/ExpandCollapseAllButton.tsx b/frontend/src/modules/2DViewer/layers/components/ExpandCollapseAllButton.tsx new file mode 100644 index 000000000..49ecbfb5f --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/components/ExpandCollapseAllButton.tsx @@ -0,0 +1,37 @@ +import React from "react"; + +import { DenseIconButton } from "@lib/components/DenseIconButton"; +import { UnfoldLessDouble, UnfoldMoreDouble } from "@mui/icons-material"; + +import { Group } from "../interfaces"; + +export type ExpandCollapseAllButtonProps = { + group: Group; +}; + +export function ExpandCollapseAllButton(props: ExpandCollapseAllButtonProps): React.ReactNode { + function expandAllChildren() { + const descendants = props.group.getGroupDelegate().getDescendantItems(() => true); + for (const child of descendants) { + child.getItemDelegate().setExpanded(true); + } + } + + function collapseAllChildren() { + const descendants = props.group.getGroupDelegate().getDescendantItems(() => true); + for (const child of descendants) { + child.getItemDelegate().setExpanded(false); + } + } + + return ( + <> + + + + + + + + ); +} diff --git a/frontend/src/modules/2DViewer/layers/components/LayerComponent.tsx b/frontend/src/modules/2DViewer/layers/components/LayerComponent.tsx new file mode 100644 index 000000000..05e3d3fdc --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/components/LayerComponent.tsx @@ -0,0 +1,167 @@ +import React from "react"; + +import { StatusMessage } from "@framework/ModuleInstanceStatusController"; +import { CircularProgress } from "@lib/components/CircularProgress"; +import { DenseIconButton } from "@lib/components/DenseIconButton"; +import { SortableListItem } from "@lib/components/SortableList"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import { Block, CheckCircle, Difference, Error, ExpandLess, ExpandMore } from "@mui/icons-material"; + +import { EditName } from "./EditName"; +import { RemoveButton } from "./RemoveButton"; +import { SettingComponent } from "./SettingComponent"; +import { VisibilityToggle } from "./VisibilityToggle"; + +import { ItemDelegateTopic } from "../delegates/ItemDelegate"; +import { LayerDelegateTopic, LayerStatus } from "../delegates/LayerDelegate"; +import { usePublishSubscribeTopicValue } from "../delegates/PublishSubscribeDelegate"; +import { SettingsContextDelegateTopic, SettingsContextLoadingState } from "../delegates/SettingsContextDelegate"; +import { Layer, Setting } from "../interfaces"; + +export type LayerComponentProps = { + layer: Layer; +}; + +export function LayerComponent(props: LayerComponentProps): React.ReactNode { + const isExpanded = usePublishSubscribeTopicValue(props.layer.getItemDelegate(), ItemDelegateTopic.EXPANDED); + + function makeSetting(setting: Setting) { + const manager = props.layer.getItemDelegate().getLayerManager(); + if (!manager) { + return null; + } + return ( + + ); + } + + function makeSettings(settings: Record>): React.ReactNode[] { + const settingNodes: React.ReactNode[] = []; + for (const key of Object.keys(settings)) { + settingNodes.push(makeSetting(settings[key])); + } + return settingNodes; + } + + return ( + } + startAdornment={} + endAdornment={} + > +
+ {makeSettings(props.layer.getLayerDelegate().getSettingsContext().getDelegate().getSettings())} +
+
+ ); +} + +type StartActionProps = { + layer: Layer; +}; + +function StartActions(props: StartActionProps): React.ReactNode { + const isExpanded = usePublishSubscribeTopicValue(props.layer.getItemDelegate(), ItemDelegateTopic.EXPANDED); + + function handleToggleExpanded() { + props.layer.getItemDelegate().setExpanded(!isExpanded); + } + return ( +
+ + {isExpanded ? : } + + +
+ ); +} + +type EndActionProps = { + layer: Layer; +}; + +function EndActions(props: EndActionProps): React.ReactNode { + const status = usePublishSubscribeTopicValue(props.layer.getLayerDelegate(), LayerDelegateTopic.STATUS); + const settingsStatus = usePublishSubscribeTopicValue( + props.layer.getLayerDelegate().getSettingsContext().getDelegate(), + SettingsContextDelegateTopic.LOADING_STATE_CHANGED + ); + const isSubordinated = usePublishSubscribeTopicValue( + props.layer.getLayerDelegate(), + LayerDelegateTopic.SUBORDINATED + ); + + function makeStatus(): React.ReactNode { + if (isSubordinated) { + return ( +
+ +
+ ); + } + if (status === LayerStatus.LOADING) { + return ( +
+ +
+ ); + } + if (status === LayerStatus.ERROR) { + const error = props.layer.getLayerDelegate().getError(); + if (typeof error === "string") { + return ( +
+ +
+ ); + } else { + const statusMessage = error as StatusMessage; + return ( +
+ +
+ ); + } + } + if (status === LayerStatus.SUCCESS) { + return ( +
+ +
+ ); + } + if (settingsStatus === SettingsContextLoadingState.FAILED) { + return ( +
+ +
+ ); + } + return null; + } + + return ( + <> + {makeStatus()} + + + ); +} diff --git a/frontend/src/modules/2DViewer/layers/components/LayersActions.tsx b/frontend/src/modules/2DViewer/layers/components/LayersActions.tsx new file mode 100644 index 000000000..f063c0a9a --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/components/LayersActions.tsx @@ -0,0 +1,83 @@ +import React from "react"; + +import { Menu } from "@lib/components/Menu"; +import { MenuButton } from "@lib/components/MenuButton/menuButton"; +import { MenuDivider } from "@lib/components/MenuDivider"; +import { MenuHeading } from "@lib/components/MenuHeading"; +import { MenuItem } from "@lib/components/MenuItem"; +import { Dropdown } from "@mui/base"; +import { Add, ArrowDropDown } from "@mui/icons-material"; + +export type LayersAction = { + identifier: string; + icon?: React.ReactNode; + label: string; +}; + +export type LayersActionGroup = { + icon?: React.ReactNode; + label: string; + children: (LayersAction | LayersActionGroup)[]; +}; + +function isLayersActionGroup(action: LayersAction | LayersActionGroup): action is LayersActionGroup { + return (action as LayersActionGroup).children !== undefined; +} + +export type LayersActionsProps = { + layersActionGroups: LayersActionGroup[]; + onActionClick: (actionIdentifier: string) => void; +}; + +export function LayersActions(props: LayersActionsProps): React.ReactNode { + function makeContent( + layersActionGroups: (LayersActionGroup | LayersAction)[], + indentLevel: number = 0 + ): React.ReactNode[] { + const content: React.ReactNode[] = []; + for (const [index, item] of layersActionGroups.entries()) { + if (isLayersActionGroup(item)) { + if (index > 0) { + content.push(); + } + content.push( + + {item.icon} + {item.label} + + ); + content.push(makeContent(item.children, indentLevel + 1)); + } else { + content.push( + props.onActionClick(item.identifier)} + > + {item.icon} + {item.label} + + ); + } + } + return content; + } + + return ( + + + + Add + + + + {makeContent(props.layersActionGroups)} + + + ); +} diff --git a/frontend/src/modules/2DViewer/layers/components/RemoveButton.tsx b/frontend/src/modules/2DViewer/layers/components/RemoveButton.tsx new file mode 100644 index 000000000..10a5a0caf --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/components/RemoveButton.tsx @@ -0,0 +1,30 @@ +import { DenseIconButton } from "@lib/components/DenseIconButton"; +import { DenseIconButtonColorScheme } from "@lib/components/DenseIconButton/denseIconButton"; +import { Delete } from "@mui/icons-material"; + +import { Item, instanceofLayer } from "../interfaces"; + +export type RemoveButtonProps = { + item: Item; +}; + +export function RemoveButton(props: RemoveButtonProps): React.ReactNode { + function handleRemove() { + const parentGroup = props.item.getItemDelegate().getParentGroup(); + if (parentGroup) { + parentGroup.removeChild(props.item); + } + + if (instanceofLayer(props.item)) { + props.item.getLayerDelegate().beforeDestroy(); + } + } + + return ( + <> + + + + + ); +} diff --git a/frontend/src/modules/2DViewer/layers/components/SettingComponent.tsx b/frontend/src/modules/2DViewer/layers/components/SettingComponent.tsx new file mode 100644 index 000000000..122fad595 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/components/SettingComponent.tsx @@ -0,0 +1,113 @@ +import React from "react"; + +import { PendingWrapper } from "@lib/components/PendingWrapper"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import { Link, Warning } from "@mui/icons-material"; + +import { LayerManager, LayerManagerTopic } from "../LayerManager"; +import { usePublishSubscribeTopicValue } from "../delegates/PublishSubscribeDelegate"; +import { SettingTopic } from "../delegates/SettingDelegate"; +import { Setting, SettingComponentProps as SettingComponentPropsInterface } from "../interfaces"; + +export type SettingComponentProps = { + setting: Setting; + manager: LayerManager; + sharedSetting: boolean; +}; + +export function SettingComponent(props: SettingComponentProps): React.ReactNode { + const componentRef = React.useRef<(props: SettingComponentPropsInterface) => React.ReactNode>( + props.setting.makeComponent() + ); + const value = usePublishSubscribeTopicValue(props.setting.getDelegate(), SettingTopic.VALUE_CHANGED); + const isValid = usePublishSubscribeTopicValue(props.setting.getDelegate(), SettingTopic.VALIDITY_CHANGED); + const isPersisted = usePublishSubscribeTopicValue( + props.setting.getDelegate(), + SettingTopic.PERSISTED_STATE_CHANGED + ); + const availableValues = usePublishSubscribeTopicValue( + props.setting.getDelegate(), + SettingTopic.AVAILABLE_VALUES_CHANGED + ); + const overriddenValue = usePublishSubscribeTopicValue(props.setting.getDelegate(), SettingTopic.OVERRIDDEN_CHANGED); + const isLoading = usePublishSubscribeTopicValue(props.setting.getDelegate(), SettingTopic.LOADING_STATE_CHANGED); + const isInitialized = usePublishSubscribeTopicValue(props.setting.getDelegate(), SettingTopic.INIT_STATE_CHANGED); + const globalSettings = usePublishSubscribeTopicValue(props.manager, LayerManagerTopic.GLOBAL_SETTINGS_CHANGED); + + let actuallyLoading = isLoading || !isInitialized; + if (!isLoading && isPersisted && !isValid) { + actuallyLoading = false; + } + + function handleValueChanged(newValue: TValue) { + props.setting.getDelegate().setValue(newValue); + } + + if (props.sharedSetting && availableValues.length === 0 && isInitialized) { + return ( + +
{props.setting.getLabel()}
+
Empty intersection
+
+ ); + } + + if (overriddenValue !== undefined) { + const valueAsString = props.setting + .getDelegate() + .valueToString(overriddenValue, props.manager.getWorkbenchSession(), props.manager.getWorkbenchSettings()); + return ( + +
+ {props.setting.getLabel()} + + + +
+
+ {isValid ? valueAsString : No valid shared setting value} +
+
+ ); + } + + return ( + +
{props.setting.getLabel()}
+
+ +
+
+ +
+ {isPersisted && !isLoading && isInitialized && ( + + + + Persisted value not valid. + + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/modules/2DViewer/layers/components/SettingsGroupComponent.tsx b/frontend/src/modules/2DViewer/layers/components/SettingsGroupComponent.tsx new file mode 100644 index 000000000..313ed2305 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/components/SettingsGroupComponent.tsx @@ -0,0 +1,73 @@ +import { SortableListGroup } from "@lib/components/SortableList"; +import { SettingsApplications } from "@mui/icons-material"; + +import { EmptyContent } from "./EmptyContent"; +import { ExpandCollapseAllButton } from "./ExpandCollapseAllButton"; +import { LayersActionGroup, LayersActions } from "./LayersActions"; +import { RemoveButton } from "./RemoveButton"; +import { makeComponent } from "./utils"; + +import { GroupDelegateTopic } from "../delegates/GroupDelegate"; +import { ItemDelegateTopic } from "../delegates/ItemDelegate"; +import { usePublishSubscribeTopicValue } from "../delegates/PublishSubscribeDelegate"; +import { Group, Item } from "../interfaces"; + +export type SettingsGroupComponentProps = { + group: Group; + actions?: LayersActionGroup[]; + onActionClick?: (actionIdentifier: string, group: Group) => void; +}; + +export function SettingsGroupComponent(props: SettingsGroupComponentProps): React.ReactNode { + const children = usePublishSubscribeTopicValue(props.group.getGroupDelegate(), GroupDelegateTopic.CHILDREN); + const isExpanded = usePublishSubscribeTopicValue(props.group.getItemDelegate(), ItemDelegateTopic.EXPANDED); + const color = props.group.getGroupDelegate().getColor(); + + function handleActionClick(actionIdentifier: string) { + if (props.onActionClick) { + props.onActionClick(actionIdentifier, props.group); + } + } + + function makeEndAdornment() { + const adornment: React.ReactNode[] = []; + if (props.actions) { + adornment.push( + + ); + } + adornment.push(); + adornment.push(); + return adornment; + } + + return ( + + {props.group.getItemDelegate().getName()} + + } + contentStyle={{ + backgroundColor: color ?? undefined, + }} + headerStyle={{ + backgroundColor: "rgb(196 181 253)", + }} + startAdornment={} + endAdornment={<>{makeEndAdornment()}} + contentWhenEmpty={ + Drag a layer or setting inside to add it to this settings group. + } + expanded={isExpanded} + > + {children.map((child: Item) => makeComponent(child, props.actions, props.onActionClick))} + + ); +} diff --git a/frontend/src/modules/2DViewer/layers/components/SharedSettingComponent.tsx b/frontend/src/modules/2DViewer/layers/components/SharedSettingComponent.tsx new file mode 100644 index 000000000..27523b6d5 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/components/SharedSettingComponent.tsx @@ -0,0 +1,89 @@ +import React from "react"; + +import { DenseIconButton } from "@lib/components/DenseIconButton"; +import { DenseIconButtonColorScheme } from "@lib/components/DenseIconButton/denseIconButton"; +import { SortableListItem } from "@lib/components/SortableList"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import { Delete, ExpandLess, ExpandMore, Link } from "@mui/icons-material"; + +import { SettingComponent } from "./SettingComponent"; + +import { SharedSetting } from "../SharedSetting"; +import { ItemDelegateTopic } from "../delegates/ItemDelegate"; +import { usePublishSubscribeTopicValue } from "../delegates/PublishSubscribeDelegate"; + +export type SharedSettingComponentProps = { + sharedSetting: SharedSetting; +}; + +export function SharedSettingComponent(props: SharedSettingComponentProps): React.ReactNode { + const isExpanded = usePublishSubscribeTopicValue(props.sharedSetting.getItemDelegate(), ItemDelegateTopic.EXPANDED); + + const manager = props.sharedSetting.getItemDelegate().getLayerManager(); + if (!manager) { + return null; + } + + function handleToggleExpanded() { + props.sharedSetting.getItemDelegate().setExpanded(!isExpanded); + } + + return ( + + {props.sharedSetting.getItemDelegate().getName()} + + } + startAdornment={ +
+ + {isExpanded ? : } + + +
+ } + endAdornment={} + headerClassNames="!bg-teal-200" + > +
+ +
+
+ ); +} + +type ActionProps = { + sharedSetting: SharedSetting; +}; + +function Actions(props: ActionProps): React.ReactNode { + function handleRemove() { + props.sharedSetting.beforeDestroy(); + const parentGroup = props.sharedSetting.getItemDelegate().getParentGroup(); + if (parentGroup) { + parentGroup.removeChild(props.sharedSetting); + } + } + + return ( + <> + + + + + ); +} diff --git a/frontend/src/modules/2DViewer/layers/components/ViewComponent.tsx b/frontend/src/modules/2DViewer/layers/components/ViewComponent.tsx new file mode 100644 index 000000000..d91ae8ec8 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/components/ViewComponent.tsx @@ -0,0 +1,77 @@ +import { SortableListGroup } from "@lib/components/SortableList"; + +import { EditName } from "./EditName"; +import { EmptyContent } from "./EmptyContent"; +import { ExpandCollapseAllButton } from "./ExpandCollapseAllButton"; +import { LayersActionGroup, LayersActions } from "./LayersActions"; +import { RemoveButton } from "./RemoveButton"; +import { VisibilityToggle } from "./VisibilityToggle"; +import { makeComponent } from "./utils"; + +import { GroupDelegateTopic } from "../delegates/GroupDelegate"; +import { ItemDelegateTopic } from "../delegates/ItemDelegate"; +import { usePublishSubscribeTopicValue } from "../delegates/PublishSubscribeDelegate"; +import { Group, Item } from "../interfaces"; + +export type ViewComponentProps = { + group: Group; + actions?: LayersActionGroup[]; + onActionClick?: (actionIdentifier: string, group: Group) => void; +}; + +export function ViewComponent(props: ViewComponentProps): React.ReactNode { + const children = usePublishSubscribeTopicValue(props.group.getGroupDelegate(), GroupDelegateTopic.CHILDREN); + const isExpanded = usePublishSubscribeTopicValue(props.group.getItemDelegate(), ItemDelegateTopic.EXPANDED); + const color = props.group.getGroupDelegate().getColor(); + + function handleActionClick(actionIdentifier: string) { + if (props.onActionClick) { + props.onActionClick(actionIdentifier, props.group); + } + } + + function makeEndAdornment() { + const adornments: React.ReactNode[] = []; + if (props.actions) { + adornments.push( + + ); + } + adornments.push(); + adornments.push(); + return adornments; + } + + return ( + +
+
+ +
+
+ } + contentStyle={{ + backgroundColor: color ?? undefined, + }} + expanded={isExpanded} + startAdornment={} + endAdornment={<>{makeEndAdornment()}} + contentWhenEmpty={Drag a layer inside to add it to this view.} + > + {children.map((child: Item) => makeComponent(child, props.actions, props.onActionClick))} +
+ ); +} diff --git a/frontend/src/modules/2DViewer/layers/components/VisibilityToggle.tsx b/frontend/src/modules/2DViewer/layers/components/VisibilityToggle.tsx new file mode 100644 index 000000000..366c17874 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/components/VisibilityToggle.tsx @@ -0,0 +1,24 @@ +import { DenseIconButton } from "@lib/components/DenseIconButton"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; + +import { ItemDelegateTopic } from "../delegates/ItemDelegate"; +import { usePublishSubscribeTopicValue } from "../delegates/PublishSubscribeDelegate"; +import { Item } from "../interfaces"; + +export type VisibilityToggleProps = { + item: Item; +}; + +export function VisibilityToggle(props: VisibilityToggleProps): React.ReactNode { + const isVisible = usePublishSubscribeTopicValue(props.item.getItemDelegate(), ItemDelegateTopic.VISIBILITY); + + function handleToggleLayerVisibility() { + props.item.getItemDelegate().setVisible(!isVisible); + } + + return ( + + {isVisible ? : } + + ); +} diff --git a/frontend/src/modules/2DViewer/layers/components/utils.tsx b/frontend/src/modules/2DViewer/layers/components/utils.tsx new file mode 100644 index 000000000..0f1f3f31b --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/components/utils.tsx @@ -0,0 +1,93 @@ +import { SortableListItemProps } from "@lib/components/SortableList"; + +import { ColorScaleComponent } from "./ColorScaleComponent"; +import { DeltaSurfaceComponent } from "./DeltaSurfaceComponent"; +import { LayerComponent } from "./LayerComponent"; +import { LayersActionGroup } from "./LayersActions"; +import { SettingsGroupComponent } from "./SettingsGroupComponent"; +import { SharedSettingComponent } from "./SharedSettingComponent"; +import { ViewComponent } from "./ViewComponent"; + +import { ColorScale } from "../ColorScale"; +import { DeltaSurface } from "../DeltaSurface"; +import { SettingsGroup } from "../SettingsGroup"; +import { SharedSetting } from "../SharedSetting"; +import { View } from "../View"; +import { Group, Item, instanceofGroup, instanceofLayer } from "../interfaces"; + +export function makeComponent( + item: Item, + layerActions?: LayersActionGroup[], + onActionClick?: (identifier: string, group: Group) => void +): React.ReactElement { + if (instanceofLayer(item)) { + return ; + } + if (instanceofGroup(item)) { + if (item instanceof SettingsGroup) { + return ( + + ); + } else if (item instanceof View) { + return ( + + ); + } else if (item instanceof DeltaSurface) { + return ( + + ); + } + } + if (item instanceof SharedSetting) { + return ; + } + if (item instanceof ColorScale) { + return ; + } + throw new Error("Not implemented"); +} + +function filterAwayViewActions(actions: LayersActionGroup[]): LayersActionGroup[] { + return actions.map((group) => ({ + ...group, + children: group.children.filter((child) => child.label !== "View"), + })); +} + +function filterAwayNonSurfaceActions(actions: LayersActionGroup[]): LayersActionGroup[] { + const result: LayersActionGroup[] = []; + + for (const group of actions) { + if (group.label === "Shared Settings") { + result.push(group); + continue; + } + if (group.label !== "Layers") { + continue; + } + const children = group.children.filter((child) => child.label.includes("Surface")); + if (children.length > 0) { + result.push({ + ...group, + children, + }); + } + } + + return result; +} diff --git a/frontend/src/modules/2DViewer/layers/delegates/GroupDelegate.ts b/frontend/src/modules/2DViewer/layers/delegates/GroupDelegate.ts new file mode 100644 index 000000000..613be3e8b --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/delegates/GroupDelegate.ts @@ -0,0 +1,249 @@ +import { ItemDelegateTopic } from "./ItemDelegate"; +import { PublishSubscribe, PublishSubscribeDelegate } from "./PublishSubscribeDelegate"; +import { UnsubscribeHandlerDelegate } from "./UnsubscribeHandlerDelegate"; + +import { DeserializationFactory } from "../DeserializationFactory"; +import { LayerManagerTopic } from "../LayerManager"; +import { SharedSetting } from "../SharedSetting"; +import { Item, SerializedItem, instanceofGroup, instanceofLayer } from "../interfaces"; + +export enum GroupDelegateTopic { + CHILDREN = "CHILDREN", + TREE_REVISION_NUMBER = "TREE_REVISION_NUMBER", + CHILDREN_EXPANSION_STATES = "CHILDREN_EXPANSION_STATES", +} + +export type GroupDelegateTopicPayloads = { + [GroupDelegateTopic.CHILDREN]: Item[]; + [GroupDelegateTopic.TREE_REVISION_NUMBER]: number; + [GroupDelegateTopic.CHILDREN_EXPANSION_STATES]: { [id: string]: boolean }; +}; + +export class GroupDelegate implements PublishSubscribe { + private _owner: Item | null; + private _color: string | null = null; + private _children: Item[] = []; + private _publishSubscribeDelegate = new PublishSubscribeDelegate(); + private _unsubscribeHandlerDelegate = new UnsubscribeHandlerDelegate(); + private _treeRevisionNumber: number = 0; + private _deserializing = false; + + constructor(owner: Item | null) { + this._owner = owner; + } + + getColor(): string | null { + return this._color; + } + + setColor(color: string | null) { + this._color = color; + } + + prependChild(child: Item) { + this._children = [child, ...this._children]; + this.takeOwnershipOfChild(child); + } + + appendChild(child: Item) { + this._children = [...this._children, child]; + this.takeOwnershipOfChild(child); + } + + insertChild(child: Item, index: number) { + this._children = [...this._children.slice(0, index), child, ...this._children.slice(index)]; + this.takeOwnershipOfChild(child); + } + + removeChild(child: Item) { + this._children = this._children.filter((c) => c !== child); + this.disposeOwnershipOfChild(child); + this.incrementTreeRevisionNumber(); + } + + clearChildren() { + for (const child of this._children) { + this.disposeOwnershipOfChild(child); + } + this._children = []; + this.publishTopic(GroupDelegateTopic.CHILDREN); + this.incrementTreeRevisionNumber(); + } + + moveChild(child: Item, index: number) { + const currentIndex = this._children.indexOf(child); + if (currentIndex === -1) { + throw new Error("Child not found"); + } + + this._children = [...this._children.slice(0, currentIndex), ...this._children.slice(currentIndex + 1)]; + + this._children = [...this._children.slice(0, index), child, ...this._children.slice(index)]; + this.publishTopic(GroupDelegateTopic.CHILDREN); + this.incrementTreeRevisionNumber(); + } + + getChildren() { + return this._children; + } + + findChildren(predicate: (item: Item) => boolean): Item[] { + return this._children.filter(predicate); + } + + findDescendantById(id: string): Item | undefined { + for (const child of this._children) { + if (child.getItemDelegate().getId() === id) { + return child; + } + + if (instanceofGroup(child)) { + const descendant = child.getGroupDelegate().findDescendantById(id); + if (descendant) { + return descendant; + } + } + } + + return undefined; + } + + getAncestorAndSiblingItems(predicate: (item: Item) => boolean): Item[] { + const items: Item[] = []; + for (const child of this._children) { + if (predicate(child)) { + items.push(child); + } + } + const parentGroup = this._owner?.getItemDelegate().getParentGroup(); + if (parentGroup) { + items.push(...parentGroup.getAncestorAndSiblingItems(predicate)); + } + + return items; + } + + getDescendantItems(predicate: (item: Item) => boolean): Item[] { + const items: Item[] = []; + for (const child of this._children) { + if (predicate(child)) { + items.push(child); + } + + if (instanceofGroup(child)) { + items.push(...child.getGroupDelegate().getDescendantItems(predicate)); + } + } + + return items; + } + + makeSnapshotGetter(topic: T): () => GroupDelegateTopicPayloads[T] { + const snapshotGetter = (): any => { + if (topic === GroupDelegateTopic.CHILDREN) { + return this._children; + } + if (topic === GroupDelegateTopic.TREE_REVISION_NUMBER) { + return this._treeRevisionNumber; + } + if (topic === GroupDelegateTopic.CHILDREN_EXPANSION_STATES) { + const expansionState: { [id: string]: boolean } = {}; + for (const child of this._children) { + if (instanceofGroup(child)) { + expansionState[child.getItemDelegate().getId()] = child.getItemDelegate().isExpanded(); + } + } + return expansionState; + } + }; + + return snapshotGetter; + } + + getPublishSubscribeDelegate(): PublishSubscribeDelegate { + return this._publishSubscribeDelegate; + } + + serializeChildren(): SerializedItem[] { + return this._children.map((child) => child.serializeState()); + } + + deserializeChildren(children: SerializedItem[]) { + if (!this._owner) { + throw new Error("Owner not set"); + } + + this._deserializing = true; + const factory = new DeserializationFactory(this._owner.getItemDelegate().getLayerManager()); + for (const child of children) { + const item = factory.makeItem(child); + this.appendChild(item); + } + this._deserializing = false; + } + + private incrementTreeRevisionNumber() { + this._treeRevisionNumber++; + this.publishTopic(GroupDelegateTopic.TREE_REVISION_NUMBER); + } + + private takeOwnershipOfChild(child: Item) { + child.getItemDelegate().setParentGroup(this); + + this._unsubscribeHandlerDelegate.unsubscribe(child.getItemDelegate().getId()); + + if (instanceofLayer(child)) { + this._unsubscribeHandlerDelegate.registerUnsubscribeFunction( + child.getItemDelegate().getId(), + child + .getItemDelegate() + .getPublishSubscribeDelegate() + .makeSubscriberFunction(ItemDelegateTopic.EXPANDED)(() => { + this.publishTopic(GroupDelegateTopic.CHILDREN_EXPANSION_STATES); + }) + ); + } + + if (instanceofGroup(child)) { + this._unsubscribeHandlerDelegate.registerUnsubscribeFunction( + child.getItemDelegate().getId(), + child + .getGroupDelegate() + .getPublishSubscribeDelegate() + .makeSubscriberFunction(GroupDelegateTopic.TREE_REVISION_NUMBER)(() => { + this.incrementTreeRevisionNumber(); + }) + ); + this._unsubscribeHandlerDelegate.registerUnsubscribeFunction( + child.getItemDelegate().getId(), + child + .getGroupDelegate() + .getPublishSubscribeDelegate() + .makeSubscriberFunction(GroupDelegateTopic.CHILDREN_EXPANSION_STATES)(() => { + this.publishTopic(GroupDelegateTopic.CHILDREN_EXPANSION_STATES); + }) + ); + } + + this.publishTopic(GroupDelegateTopic.CHILDREN); + this.incrementTreeRevisionNumber(); + } + + private publishTopic(topic: GroupDelegateTopic) { + if (this._deserializing) { + return; + } + this._publishSubscribeDelegate.notifySubscribers(topic); + } + + private disposeOwnershipOfChild(child: Item) { + this._unsubscribeHandlerDelegate.unsubscribe(child.getItemDelegate().getId()); + child.getItemDelegate().setParentGroup(null); + + if (child instanceof SharedSetting) { + this._owner?.getItemDelegate().getLayerManager().publishTopic(LayerManagerTopic.SETTINGS_CHANGED); + } + + this.publishTopic(GroupDelegateTopic.CHILDREN); + } +} diff --git a/frontend/src/modules/2DViewer/layers/delegates/ItemDelegate.ts b/frontend/src/modules/2DViewer/layers/delegates/ItemDelegate.ts new file mode 100644 index 000000000..0967d611e --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/delegates/ItemDelegate.ts @@ -0,0 +1,153 @@ +import { isEqual } from "lodash"; +import { v4 } from "uuid"; + +import { GroupDelegate } from "./GroupDelegate"; +import { PublishSubscribe, PublishSubscribeDelegate } from "./PublishSubscribeDelegate"; + +import { LayerManager, LayerManagerTopic } from "../LayerManager"; +import { SerializedItem } from "../interfaces"; + +export enum ItemDelegateTopic { + NAME = "NAME", + VISIBILITY = "VISIBILITY", + EXPANDED = "EXPANDED", +} + +export type ItemDelegatePayloads = { + [ItemDelegateTopic.NAME]: string; + [ItemDelegateTopic.VISIBILITY]: boolean; + [ItemDelegateTopic.EXPANDED]: boolean; +}; + +export class ItemDelegate implements PublishSubscribe { + private _id: string; + private _name: string; + private _visible: boolean = true; + private _expanded: boolean = true; + private _parentGroup: GroupDelegate | null = null; + private _layerManager: LayerManager; + private _publishSubscribeDelegate = new PublishSubscribeDelegate(); + + constructor(name: string, layerManager: LayerManager) { + this._id = v4(); + this._layerManager = layerManager; + this._name = this.makeUniqueName(name); + } + + setId(id: string): void { + this._id = id; + } + + getId(): string { + return this._id; + } + + getName(): string { + return this._name; + } + + setName(name: string): void { + if (isEqual(this._name, name)) { + return; + } + + this._name = name; + this._publishSubscribeDelegate.notifySubscribers(ItemDelegateTopic.NAME); + if (this._layerManager) { + this._layerManager.publishTopic(LayerManagerTopic.LAYER_DATA_REVISION); + } + } + + getParentGroup(): GroupDelegate | null { + return this._parentGroup; + } + + setParentGroup(parentGroup: GroupDelegate | null): void { + this._parentGroup = parentGroup; + } + + getLayerManager(): LayerManager { + return this._layerManager; + } + + isVisible(): boolean { + return this._visible; + } + + setVisible(visible: boolean): void { + if (isEqual(this._visible, visible)) { + return; + } + + this._visible = visible; + this._publishSubscribeDelegate.notifySubscribers(ItemDelegateTopic.VISIBILITY); + if (this._layerManager) { + this._layerManager.publishTopic(LayerManagerTopic.LAYER_DATA_REVISION); + } + } + + isExpanded(): boolean { + return this._expanded; + } + + setExpanded(expanded: boolean): void { + if (isEqual(this._expanded, expanded)) { + return; + } + + this._expanded = expanded; + this._publishSubscribeDelegate.notifySubscribers(ItemDelegateTopic.EXPANDED); + } + + makeSnapshotGetter(topic: T): () => ItemDelegatePayloads[T] { + const snapshotGetter = (): any => { + if (topic === ItemDelegateTopic.NAME) { + return this._name; + } + if (topic === ItemDelegateTopic.VISIBILITY) { + return this._visible; + } + if (topic === ItemDelegateTopic.EXPANDED) { + return this._expanded; + } + }; + return snapshotGetter; + } + + getPublishSubscribeDelegate(): PublishSubscribeDelegate { + return this._publishSubscribeDelegate; + } + + serializeState(): Omit { + return { + id: this._id, + name: this._name, + visible: this._visible, + expanded: this._expanded, + }; + } + + deserializeState(state: Omit): void { + this._id = state.id; + this._name = state.name; + this._visible = state.visible; + this._expanded = state.expanded; + } + + private makeUniqueName(candidate: string): string { + const groupDelegate = this._layerManager?.getGroupDelegate(); + if (!groupDelegate) { + return candidate; + } + const existingNames = groupDelegate + .getDescendantItems(() => true) + .map((item) => item.getItemDelegate().getName()); + let uniqueName = candidate; + let counter = 1; + while (existingNames.includes(uniqueName)) { + uniqueName = `${candidate} (${counter})`; + counter++; + } + return uniqueName; + } +} diff --git a/frontend/src/modules/2DViewer/layers/delegates/LayerDelegate.ts b/frontend/src/modules/2DViewer/layers/delegates/LayerDelegate.ts new file mode 100644 index 000000000..3d9dde990 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/delegates/LayerDelegate.ts @@ -0,0 +1,330 @@ +import { StatusMessage } from "@framework/ModuleInstanceStatusController"; +import { ApiErrorHelper } from "@framework/utils/ApiErrorHelper"; +import { isDevMode } from "@lib/utils/devMode"; +import { QueryClient, isCancelledError } from "@tanstack/react-query"; + +import { PublishSubscribe, PublishSubscribeDelegate } from "./PublishSubscribeDelegate"; +import { SettingsContextDelegateTopic } from "./SettingsContextDelegate"; +import { UnsubscribeHandlerDelegate } from "./UnsubscribeHandlerDelegate"; + +import { LayerManager, LayerManagerTopic } from "../LayerManager"; +import { SharedSetting } from "../SharedSetting"; +import { BoundingBox, Layer, SerializedLayer, Settings, SettingsContext } from "../interfaces"; + +export enum LayerDelegateTopic { + STATUS = "STATUS", + DATA = "DATA", + SUBORDINATED = "SUBORDINATED", +} + +export enum LayerColoringType { + NONE = "NONE", + COLORSCALE = "COLORSCALE", + COLORSET = "COLORSET", +} + +export enum LayerStatus { + IDLE = "IDLE", + LOADING = "LOADING", + ERROR = "ERROR", + SUCCESS = "SUCCESS", +} + +export type LayerDelegatePayloads = { + [LayerDelegateTopic.STATUS]: LayerStatus; + [LayerDelegateTopic.DATA]: TData; + [LayerDelegateTopic.SUBORDINATED]: boolean; +}; +export class LayerDelegate + implements PublishSubscribe> +{ + private _owner: Layer; + private _settingsContext: SettingsContext; + private _layerManager: LayerManager; + private _unsubscribeHandler: UnsubscribeHandlerDelegate = new UnsubscribeHandlerDelegate(); + private _cancellationPending: boolean = false; + private _publishSubscribeDelegate = new PublishSubscribeDelegate(); + private _queryKeys: unknown[][] = []; + private _status: LayerStatus = LayerStatus.IDLE; + private _data: TData | null = null; + private _error: StatusMessage | string | null = null; + private _boundingBox: BoundingBox | null = null; + private _valueRange: [number, number] | null = null; + private _coloringType: LayerColoringType; + private _isSubordinated: boolean = false; + + constructor( + owner: Layer, + layerManager: LayerManager, + settingsContext: SettingsContext, + coloringType: LayerColoringType + ) { + this._owner = owner; + this._layerManager = layerManager; + this._settingsContext = settingsContext; + this._coloringType = coloringType; + + this._unsubscribeHandler.registerUnsubscribeFunction( + "settings-context", + this._settingsContext + .getDelegate() + .getPublishSubscribeDelegate() + .makeSubscriberFunction(SettingsContextDelegateTopic.SETTINGS_CHANGED)(() => { + this.handleSettingsChange(); + }) + ); + + this._unsubscribeHandler.registerUnsubscribeFunction( + "layer-manager", + layerManager + .getPublishSubscribeDelegate() + .makeSubscriberFunction(LayerManagerTopic.SHARED_SETTINGS_CHANGED)(() => { + this.handleSharedSettingsChanged(); + }) + ); + + this._unsubscribeHandler.registerUnsubscribeFunction( + "layer-manager", + layerManager.getPublishSubscribeDelegate().makeSubscriberFunction(LayerManagerTopic.ITEMS_CHANGED)(() => { + this.handleSharedSettingsChanged(); + }) + ); + } + + handleSettingsChange(): void { + this._cancellationPending = true; + this.maybeCancelQuery().then(() => { + this.maybeRefetchData(); + }); + } + + registerQueryKey(queryKey: unknown[]): void { + this._queryKeys.push(queryKey); + } + + getStatus(): LayerStatus { + return this._status; + } + + getData(): TData | null { + return this._data; + } + + getSettingsContext(): SettingsContext { + return this._settingsContext; + } + + getBoundingBox(): BoundingBox | null { + return this._boundingBox; + } + + getColoringType(): LayerColoringType { + return this._coloringType; + } + + isSubordinated(): boolean { + return this._isSubordinated; + } + + setIsSubordinated(isSubordinated: boolean): void { + if (this._isSubordinated === isSubordinated) { + return; + } + this._isSubordinated = isSubordinated; + this._publishSubscribeDelegate.notifySubscribers(LayerDelegateTopic.SUBORDINATED); + } + + getValueRange(): [number, number] | null { + return this._valueRange; + } + + handleSharedSettingsChanged(): void { + const parentGroup = this._owner.getItemDelegate().getParentGroup(); + if (parentGroup) { + const sharedSettings: SharedSetting[] = parentGroup.getAncestorAndSiblingItems( + (item) => item instanceof SharedSetting + ) as SharedSetting[]; + const overriddenSettings: { [K in keyof TSettings]: TSettings[K] } = {} as { + [K in keyof TSettings]: TSettings[K]; + }; + for (const sharedSetting of sharedSettings) { + const type = sharedSetting.getWrappedSetting().getType(); + const setting = this._settingsContext.getDelegate().getSettings()[type]; + if (setting && overriddenSettings[type] === undefined) { + if ( + sharedSetting.getWrappedSetting().getDelegate().isInitialized() && + sharedSetting.getWrappedSetting().getDelegate().isValueValid() + ) { + overriddenSettings[type] = sharedSetting.getWrappedSetting().getDelegate().getValue(); + } else { + overriddenSettings[type] = null; + } + } + } + this._settingsContext.getDelegate().setOverriddenSettings(overriddenSettings); + } + } + + getLayerManager(): LayerManager { + return this._layerManager; + } + + makeSnapshotGetter(topic: T): () => LayerDelegatePayloads[T] { + const snapshotGetter = (): any => { + if (topic === LayerDelegateTopic.STATUS) { + return this._status; + } + if (topic === LayerDelegateTopic.DATA) { + return this._data; + } + if (topic === LayerDelegateTopic.SUBORDINATED) { + return this._isSubordinated; + } + }; + + return snapshotGetter; + } + + getPublishSubscribeDelegate(): PublishSubscribeDelegate { + return this._publishSubscribeDelegate; + } + + getError(): StatusMessage | string | null { + if (!this._error) { + return null; + } + + const name = this._owner.getItemDelegate().getName(); + + if (typeof this._error === "string") { + return `${name}: ${this._error}`; + } + + return { + ...this._error, + message: `${name}: ${this._error.message}`, + }; + } + + async maybeRefetchData(): Promise { + const queryClient = this.getQueryClient(); + + if (!queryClient) { + return; + } + + if (this._cancellationPending) { + return; + } + + if (this._isSubordinated) { + return; + } + + this.setStatus(LayerStatus.LOADING); + this.invalidateBoundingBox(); + this.invalidateValueRange(); + + try { + this._data = await this._owner.fechData(queryClient); + if (this._owner.makeBoundingBox) { + this._boundingBox = this._owner.makeBoundingBox(); + } + if (this._owner.makeValueRange) { + this._valueRange = this._owner.makeValueRange(); + } + if (this._queryKeys.length === null && isDevMode()) { + console.warn( + "Did you forget to use 'setQueryKeys' in your layer implementation of 'fetchData'? This will cause the queries to not be cancelled when settings change and might lead to undesired behaviour." + ); + } + this._queryKeys = []; + this._publishSubscribeDelegate.notifySubscribers(LayerDelegateTopic.DATA); + this._layerManager.publishTopic(LayerManagerTopic.LAYER_DATA_REVISION); + this.setStatus(LayerStatus.SUCCESS); + } catch (error: any) { + if (isCancelledError(error)) { + return; + } + const apiError = ApiErrorHelper.fromError(error); + if (apiError) { + this._error = apiError.makeStatusMessage(); + } else { + this._error = "An error occurred"; + } + this.setStatus(LayerStatus.ERROR); + } + } + + serializeState(): SerializedLayer { + const itemState = this._owner.getItemDelegate().serializeState(); + return { + ...itemState, + type: "layer", + layerClass: this._owner.constructor.name, + settings: this._settingsContext.getDelegate().serializeSettings(), + }; + } + + deserializeState(serializedLayer: SerializedLayer): void { + this._owner.getItemDelegate().deserializeState(serializedLayer); + this._settingsContext.getDelegate().deserializeSettings(serializedLayer.settings); + } + + beforeDestroy(): void { + this._settingsContext.getDelegate().beforeDestroy(); + this._unsubscribeHandler.unsubscribeAll(); + } + + private setStatus(status: LayerStatus): void { + if (this._status === status) { + return; + } + + this._status = status; + this._layerManager.publishTopic(LayerManagerTopic.LAYER_DATA_REVISION); + this._publishSubscribeDelegate.notifySubscribers(LayerDelegateTopic.STATUS); + } + + private getQueryClient(): QueryClient | null { + return this._layerManager?.getQueryClient() ?? null; + } + + private invalidateBoundingBox(): void { + this._boundingBox = null; + } + + private invalidateValueRange(): void { + this._valueRange = null; + } + + private async maybeCancelQuery(): Promise { + const queryClient = this.getQueryClient(); + + if (!queryClient) { + return; + } + + if (this._queryKeys.length > 0) { + for (const queryKey of this._queryKeys) { + await queryClient.cancelQueries( + { + queryKey, + exact: true, + fetchStatus: "fetching", + type: "active", + }, + { + silent: true, + revert: true, + } + ); + await queryClient.invalidateQueries({ queryKey }); + queryClient.removeQueries({ queryKey }); + } + this._queryKeys = []; + } + + this._cancellationPending = false; + } +} diff --git a/frontend/src/modules/2DViewer/layers/delegates/PublishSubscribeDelegate.ts b/frontend/src/modules/2DViewer/layers/delegates/PublishSubscribeDelegate.ts new file mode 100644 index 000000000..567fd3bfc --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/delegates/PublishSubscribeDelegate.ts @@ -0,0 +1,46 @@ +import React from "react"; + +export type TopicPayloads = Record; + +export interface PublishSubscribe> { + makeSnapshotGetter(topic: T): () => TTopicPayloads[T]; + getPublishSubscribeDelegate(): PublishSubscribeDelegate; +} + +export class PublishSubscribeDelegate { + private _subscribers = new Map void>>(); + + notifySubscribers(topic: TTopic): void { + const subscribers = this._subscribers.get(topic); + if (subscribers) { + subscribers.forEach((subscriber) => subscriber()); + } + } + + makeSubscriberFunction(topic: TTopic): (onStoreChangeCallback: () => void) => () => void { + // Using arrow function in order to keep "this" in context + const subscriber = (onStoreChangeCallback: () => void): (() => void) => { + const subscribers = this._subscribers.get(topic) || new Set(); + subscribers.add(onStoreChangeCallback); + this._subscribers.set(topic, subscribers); + + return () => { + subscribers.delete(onStoreChangeCallback); + }; + }; + + return subscriber; + } +} + +export function usePublishSubscribeTopicValue>( + publishSubscribe: PublishSubscribe, + topic: TTopic +): TTopicPayloads[TTopic] { + const value = React.useSyncExternalStore( + publishSubscribe.getPublishSubscribeDelegate().makeSubscriberFunction(topic), + publishSubscribe.makeSnapshotGetter(topic) + ); + + return value; +} diff --git a/frontend/src/modules/2DViewer/layers/delegates/SettingDelegate.ts b/frontend/src/modules/2DViewer/layers/delegates/SettingDelegate.ts new file mode 100644 index 000000000..e20285b14 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/delegates/SettingDelegate.ts @@ -0,0 +1,341 @@ +import { WorkbenchSession } from "@framework/WorkbenchSession"; +import { WorkbenchSettings } from "@framework/WorkbenchSettings"; + +import { isArray, isEqual } from "lodash"; +import { v4 } from "uuid"; + +import { PublishSubscribe, PublishSubscribeDelegate } from "./PublishSubscribeDelegate"; + +import { AvailableValuesType, Setting } from "../interfaces"; + +export enum SettingTopic { + VALUE_CHANGED = "VALUE_CHANGED", + VALIDITY_CHANGED = "VALIDITY_CHANGED", + AVAILABLE_VALUES_CHANGED = "AVAILABLE_VALUES_CHANGED", + OVERRIDDEN_CHANGED = "OVERRIDDEN_CHANGED", + LOADING_STATE_CHANGED = "LOADING_STATE_CHANGED", + INIT_STATE_CHANGED = "INIT_STATE_CHANGED", + PERSISTED_STATE_CHANGED = "PERSISTED_STATE_CHANGED", +} + +export type SettingTopicPayloads = { + [SettingTopic.VALUE_CHANGED]: TValue; + [SettingTopic.VALIDITY_CHANGED]: boolean; + [SettingTopic.AVAILABLE_VALUES_CHANGED]: Exclude[]; + [SettingTopic.OVERRIDDEN_CHANGED]: TValue | undefined; + [SettingTopic.LOADING_STATE_CHANGED]: boolean; + [SettingTopic.INIT_STATE_CHANGED]: boolean; + [SettingTopic.PERSISTED_STATE_CHANGED]: boolean; +}; + +export class SettingDelegate implements PublishSubscribe> { + private _id: string; + private _owner: Setting; + private _value: TValue; + private _isValueValid: boolean = false; + private _publishSubscribeDelegate = new PublishSubscribeDelegate(); + private _availableValues: AvailableValuesType = [] as unknown as AvailableValuesType; + private _overriddenValue: TValue | undefined = undefined; + private _loading: boolean = false; + private _initialized: boolean = false; + private _currentValueFromPersistence: TValue | null = null; + private _isStatic: boolean; + + constructor(value: TValue, owner: Setting, isStatic: boolean = false) { + this._id = v4(); + this._owner = owner; + this._value = value; + if (typeof value === "boolean") { + this._isValueValid = true; + } + this._isStatic = isStatic; + if (isStatic) { + this.setInitialized(); + } + } + + getId(): string { + return this._id; + } + + getValue(): TValue { + if (this._overriddenValue !== undefined) { + return this._overriddenValue; + } + + if (this._currentValueFromPersistence !== null) { + return this._currentValueFromPersistence; + } + + return this._value; + } + + isStatic(): boolean { + return this._isStatic; + } + + serializeValue(): string { + if (this._owner.serializeValue) { + return this._owner.serializeValue(this.getValue()); + } + + return JSON.stringify(this.getValue()); + } + + deserializeValue(serializedValue: string): void { + if (this._owner.deserializeValue) { + this._currentValueFromPersistence = this._owner.deserializeValue(serializedValue); + return; + } + + this._currentValueFromPersistence = JSON.parse(serializedValue); + } + + isValueValid(): boolean { + return this._isValueValid; + } + + isPersistedValue(): boolean { + return this._currentValueFromPersistence !== null; + } + + /* + * This method is used to set the value of the setting. + * It should only be called when a user is changing a setting. + */ + setValue(value: TValue): void { + if (isEqual(this._value, value)) { + return; + } + this._currentValueFromPersistence = null; + this._value = value; + + this.setValueValid(this.checkIfValueIsValid(this._value)); + + this._publishSubscribeDelegate.notifySubscribers(SettingTopic.VALUE_CHANGED); + } + + setValueValid(isValueValid: boolean): void { + if (this._isValueValid === isValueValid) { + return; + } + this._isValueValid = isValueValid; + this._publishSubscribeDelegate.notifySubscribers(SettingTopic.VALIDITY_CHANGED); + } + + setLoading(loading: boolean): void { + if (this._loading === loading) { + return; + } + this._loading = loading; + this._publishSubscribeDelegate.notifySubscribers(SettingTopic.LOADING_STATE_CHANGED); + } + + setInitialized(): void { + if (this._initialized) { + return; + } + this._initialized = true; + this._publishSubscribeDelegate.notifySubscribers(SettingTopic.INIT_STATE_CHANGED); + } + + isInitialized(): boolean { + return this._initialized; + } + + isLoading(): boolean { + return this._loading; + } + + valueToString(value: TValue, workbenchSession: WorkbenchSession, workbenchSettings: WorkbenchSettings): string { + if (this._owner.valueToString) { + return this._owner.valueToString({ value, workbenchSession, workbenchSettings }); + } + + if (typeof value === "boolean") { + return value ? "true" : "false"; + } + + if (typeof value === "string") { + return value; + } + + if (typeof value === "number") { + return value.toString(); + } + + return "Value has no string representation"; + } + + setOverriddenValue(overriddenValue: TValue | undefined): void { + if (isEqual(this._overriddenValue, overriddenValue)) { + return; + } + + const prevValue = this._overriddenValue; + this._overriddenValue = overriddenValue; + this._publishSubscribeDelegate.notifySubscribers(SettingTopic.OVERRIDDEN_CHANGED); + + if (overriddenValue === undefined) { + // Keep overridden value, if invalid fix it + if (prevValue !== undefined) { + this._value = prevValue; + } + this.maybeFixupValue(); + } + + this.setValueValid(this.checkIfValueIsValid(this.getValue())); + + if (prevValue === undefined && overriddenValue !== undefined && isEqual(this._value, overriddenValue)) { + return; + } + + if (prevValue !== undefined && overriddenValue === undefined && isEqual(this._value, prevValue)) { + return; + } + + this._publishSubscribeDelegate.notifySubscribers(SettingTopic.VALUE_CHANGED); + } + + makeSnapshotGetter(topic: T): () => SettingTopicPayloads[T] { + const snapshotGetter = (): any => { + if (topic === SettingTopic.VALUE_CHANGED) { + return this._value; + } + if (topic === SettingTopic.VALIDITY_CHANGED) { + return this._isValueValid; + } + if (topic === SettingTopic.AVAILABLE_VALUES_CHANGED) { + return this._availableValues; + } + if (topic === SettingTopic.OVERRIDDEN_CHANGED) { + return this._overriddenValue; + } + if (topic === SettingTopic.LOADING_STATE_CHANGED) { + return this._loading; + } + if (topic === SettingTopic.PERSISTED_STATE_CHANGED) { + return this.isPersistedValue(); + } + if (topic === SettingTopic.INIT_STATE_CHANGED) { + return this._initialized; + } + }; + + return snapshotGetter; + } + + getPublishSubscribeDelegate(): PublishSubscribeDelegate { + return this._publishSubscribeDelegate; + } + + getAvailableValues(): AvailableValuesType { + return this._availableValues; + } + + maybeResetPersistedValue(): boolean { + if (this._currentValueFromPersistence === null) { + return false; + } + + if (this._owner.isValueValid) { + if (this._owner.isValueValid(this._availableValues, this._currentValueFromPersistence)) { + this._value = this._currentValueFromPersistence; + this._currentValueFromPersistence = null; + return true; + } + return false; + } + + if (Array.isArray(this._currentValueFromPersistence)) { + const currentValueFromPersistence = this._currentValueFromPersistence as TValue[]; + if (currentValueFromPersistence.every((value) => this._availableValues.some((el) => isEqual(el, value)))) { + this._value = this._currentValueFromPersistence; + this._currentValueFromPersistence = null; + return true; + } + return false; + } + + if (this._availableValues.some((el) => isEqual(this._currentValueFromPersistence as TValue, el))) { + this._value = this._currentValueFromPersistence; + this._currentValueFromPersistence = null; + return true; + } + + return false; + } + + setAvailableValues(availableValues: AvailableValuesType): void { + if (isEqual(this._availableValues, availableValues) && this._initialized) { + return; + } + + this._availableValues = availableValues; + let valueChanged = false; + if ((!this.checkIfValueIsValid(this.getValue()) && this.maybeFixupValue()) || this.maybeResetPersistedValue()) { + valueChanged = true; + } + this.setValueValid(this.checkIfValueIsValid(this.getValue())); + this.setInitialized(); + const prevIsValid = this._isValueValid; + if (valueChanged || this._isValueValid !== prevIsValid) { + this._publishSubscribeDelegate.notifySubscribers(SettingTopic.VALUE_CHANGED); + } + this._publishSubscribeDelegate.notifySubscribers(SettingTopic.AVAILABLE_VALUES_CHANGED); + } + + private maybeFixupValue(): boolean { + if (this.checkIfValueIsValid(this._value)) { + return false; + } + + if (this.isPersistedValue()) { + return false; + } + + if (this._availableValues.length === 0) { + return false; + } + + if (this._availableValues.some((el) => isEqual(el, this._value))) { + return false; + } + + let candidate = this._value; + + if (this._owner.fixupValue) { + candidate = this._owner.fixupValue(this._availableValues, this._value); + } else if (Array.isArray(this._value)) { + candidate = [this._availableValues[0]] as TValue; + } else { + candidate = this._availableValues[0] as TValue; + } + + if (isEqual(candidate, this._value)) { + return false; + } + + this._value = candidate; + return true; + } + + private checkIfValueIsValid(value: TValue): boolean { + if (this._owner.isValueValid) { + return this._owner.isValueValid(this._availableValues, value); + } + if (typeof value === "boolean") { + return true; + } + if (this._availableValues.length === 0) { + return false; + } + if (this._availableValues.some((el) => isEqual(el, value))) { + return true; + } + if (isArray(value) && value.every((value) => this._availableValues.some((el) => isEqual(value, el)))) { + return true; + } + return false; + } +} diff --git a/frontend/src/modules/2DViewer/layers/delegates/SettingsContextDelegate.ts b/frontend/src/modules/2DViewer/layers/delegates/SettingsContextDelegate.ts new file mode 100644 index 000000000..5a0f21b15 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/delegates/SettingsContextDelegate.ts @@ -0,0 +1,379 @@ +import { PublishSubscribe, PublishSubscribeDelegate } from "./PublishSubscribeDelegate"; +import { SettingTopic } from "./SettingDelegate"; +import { UnsubscribeHandlerDelegate } from "./UnsubscribeHandlerDelegate"; + +import { Dependency } from "../Dependency"; +import { GlobalSettings, LayerManager, LayerManagerTopic } from "../LayerManager"; +import { + AvailableValuesType, + EachAvailableValuesType, + SerializedSettingsState, + Setting, + Settings, + SettingsContext, + UpdateFunc, +} from "../interfaces"; + +export enum SettingsContextLoadingState { + LOADING = "LOADING", + LOADED = "LOADED", + FAILED = "FAILED", +} + +export enum SettingsContextDelegateTopic { + SETTINGS_CHANGED = "SETTINGS_CHANGED", + LAYER_MANAGER_CHANGED = "LAYER_MANAGER_CHANGED", + LOADING_STATE_CHANGED = "LOADING_STATE_CHANGED", +} + +export type SettingsContextDelegatePayloads = { + [SettingsContextDelegateTopic.SETTINGS_CHANGED]: void; + [SettingsContextDelegateTopic.LAYER_MANAGER_CHANGED]: void; + [SettingsContextDelegateTopic.LOADING_STATE_CHANGED]: SettingsContextLoadingState; +}; + +export interface FetchDataFunction { + (oldValues: { [K in TKey]: TSettings[K] }, newValues: { [K in TKey]: TSettings[K] }): void; +} + +export type SettingsContextDelegateState = { + values: { [K in TKey]: TSettings[K] }; +}; + +export class SettingsContextDelegate + implements PublishSubscribe +{ + private _parentContext: SettingsContext; + private _layerManager: LayerManager; + private _settings: { [K in TKey]: Setting } = {} as { [K in TKey]: Setting }; + private _overriddenSettings: { [K in TKey]: TSettings[K] } = {} as { [K in TKey]: TSettings[K] }; + private _publishSubscribeDelegate = new PublishSubscribeDelegate(); + private _unsubscribeHandler: UnsubscribeHandlerDelegate = new UnsubscribeHandlerDelegate(); + private _loadingState: SettingsContextLoadingState = SettingsContextLoadingState.LOADING; + + constructor( + context: SettingsContext, + layerManager: LayerManager, + settings: { [K in TKey]: Setting } + ) { + this._parentContext = context; + this._layerManager = layerManager; + + for (const key in settings) { + this._unsubscribeHandler.registerUnsubscribeFunction( + "settings", + settings[key] + .getDelegate() + .getPublishSubscribeDelegate() + .makeSubscriberFunction(SettingTopic.VALUE_CHANGED)(() => { + this.handleSettingChanged(); + }) + ); + this._unsubscribeHandler.registerUnsubscribeFunction( + "settings", + settings[key] + .getDelegate() + .getPublishSubscribeDelegate() + .makeSubscriberFunction(SettingTopic.LOADING_STATE_CHANGED)(() => { + this.handleSettingsLoadingStateChanged(); + }) + ); + } + + this._settings = settings; + + this.createDependencies(); + } + + getLayerManager(): LayerManager { + return this._layerManager; + } + + getValues(): { [K in TKey]?: TSettings[K] } { + const settings: { [K in TKey]?: TSettings[K] } = {} as { [K in TKey]?: TSettings[K] }; + for (const key in this._settings) { + if (this._settings[key].getDelegate().isPersistedValue()) { + settings[key] = undefined; + continue; + } + settings[key] = this._settings[key].getDelegate().getValue(); + } + + return settings; + } + + setOverriddenSettings(overriddenSettings: { [K in TKey]: TSettings[K] }): void { + this._overriddenSettings = overriddenSettings; + for (const key in this._settings) { + if (Object.keys(this._overriddenSettings).includes(key)) { + this._settings[key].getDelegate().setOverriddenValue(this._overriddenSettings[key]); + } else { + this._settings[key].getDelegate().setOverriddenValue(undefined); + } + } + } + + areCurrentSettingsValid(): boolean { + for (const key in this._settings) { + if (!this._settings[key].getDelegate().isValueValid()) { + return false; + } + } + + if (!this._parentContext.areCurrentSettingsValid) { + return true; + } + + const settings: TSettings = {} as TSettings; + for (const key in this._settings) { + settings[key] = this._settings[key].getDelegate().getValue(); + } + + return this._parentContext.areCurrentSettingsValid(settings); + } + + areAllSettingsLoaded(): boolean { + for (const key in this._settings) { + if (this._settings[key].getDelegate().isLoading()) { + return false; + } + } + + return true; + } + + areAllSettingsInitialized(): boolean { + for (const key in this._settings) { + if ( + !this._settings[key].getDelegate().isInitialized() || + this._settings[key].getDelegate().isPersistedValue() + ) { + return false; + } + } + + return true; + } + + isSomePersistedSettingNotValid(): boolean { + for (const key in this._settings) { + if ( + !this._settings[key].getDelegate().isLoading() && + this._settings[key].getDelegate().isPersistedValue() && + !this._settings[key].getDelegate().isValueValid() && + this._settings[key].getDelegate().isInitialized() + ) { + return true; + } + } + + return false; + } + + getInvalidSettings(): string[] { + const invalidSettings: string[] = []; + for (const key in this._settings) { + if (!this._settings[key].getDelegate().isValueValid()) { + invalidSettings.push(this._settings[key].getLabel()); + } + } + + return invalidSettings; + } + + setAvailableValues(key: K, availableValues: AvailableValuesType): void { + const settingDelegate = this._settings[key].getDelegate(); + settingDelegate.setAvailableValues(availableValues); + + this.getLayerManager().publishTopic(LayerManagerTopic.AVAILABLE_SETTINGS_CHANGED); + } + + getSettings() { + return this._settings; + } + + makeSnapshotGetter(topic: T): () => SettingsContextDelegatePayloads[T] { + const snapshotGetter = (): any => { + if (topic === SettingsContextDelegateTopic.SETTINGS_CHANGED) { + return; + } + if (topic === SettingsContextDelegateTopic.LAYER_MANAGER_CHANGED) { + return; + } + if (topic === SettingsContextDelegateTopic.LOADING_STATE_CHANGED) { + return this._loadingState; + } + }; + + return snapshotGetter; + } + + getPublishSubscribeDelegate(): PublishSubscribeDelegate { + return this._publishSubscribeDelegate; + } + + serializeSettings(): SerializedSettingsState { + const serializedSettings: SerializedSettingsState = {} as SerializedSettingsState; + for (const key in this._settings) { + serializedSettings[key] = this._settings[key].getDelegate().serializeValue(); + } + return serializedSettings; + } + + deserializeSettings(serializedSettings: SerializedSettingsState): void { + for (const [key, value] of Object.entries(serializedSettings)) { + const settingDelegate = this._settings[key as TKey].getDelegate(); + settingDelegate.deserializeValue(value); + if (settingDelegate.isStatic()) { + settingDelegate.maybeResetPersistedValue(); + } + } + } + + createDependencies(): void { + this._unsubscribeHandler.unsubscribe("dependencies"); + + const makeSettingGetter = (key: K, handler: (value: TSettings[K]) => void) => { + const handleChange = (): void => { + handler(this._settings[key].getDelegate().getValue()); + }; + this._unsubscribeHandler.registerUnsubscribeFunction( + "dependencies", + this._settings[key] + .getDelegate() + .getPublishSubscribeDelegate() + .makeSubscriberFunction(SettingTopic.VALUE_CHANGED)(handleChange) + ); + + this._unsubscribeHandler.registerUnsubscribeFunction( + "dependencies", + this._settings[key] + .getDelegate() + .getPublishSubscribeDelegate() + .makeSubscriberFunction(SettingTopic.PERSISTED_STATE_CHANGED)(handleChange) + ); + + return handleChange; + }; + + const makeGlobalSettingGetter = ( + key: K, + handler: (value: GlobalSettings[K]) => void + ) => { + const handleChange = (): void => { + handler(this.getLayerManager.bind(this)().getGlobalSetting(key)); + }; + this._unsubscribeHandler.registerUnsubscribeFunction( + "dependencies", + this.getLayerManager() + .getPublishSubscribeDelegate() + .makeSubscriberFunction(LayerManagerTopic.GLOBAL_SETTINGS_CHANGED)(handleChange) + ); + + return handleChange; + }; + + const availableSettingsUpdater = ( + settingKey: K, + updateFunc: UpdateFunc, TSettings, K> + ): Dependency, TSettings, K> => { + const dependency = new Dependency, TSettings, K>( + this as unknown as SettingsContextDelegate, + updateFunc, + makeSettingGetter, + makeGlobalSettingGetter + ); + + dependency.subscribe((availableValues: AvailableValuesType | null) => { + if (availableValues === null) { + this.setAvailableValues(settingKey, [] as unknown as AvailableValuesType); + return; + } + this.setAvailableValues(settingKey, availableValues); + }); + + dependency.subscribeLoading((loading: boolean, hasDependencies: boolean) => { + this._settings[settingKey].getDelegate().setLoading(loading); + + if (!hasDependencies) { + this.handleSettingChanged(); + } + }); + + dependency.initialize(); + + return dependency; + }; + + const helperDependency = ( + update: (args: { + getLocalSetting: (settingName: T) => TSettings[T]; + getGlobalSetting: (settingName: T) => GlobalSettings[T]; + getHelperDependency: (dep: Dependency) => TDep | null; + abortSignal: AbortSignal; + }) => T + ) => { + const dependency = new Dependency( + this as unknown as SettingsContextDelegate, + update, + makeSettingGetter, + makeGlobalSettingGetter + ); + + dependency.initialize(); + + return dependency; + }; + + if (this._parentContext.defineDependencies) { + this._parentContext.defineDependencies({ + availableSettingsUpdater, + helperDependency, + workbenchSession: this.getLayerManager().getWorkbenchSession(), + workbenchSettings: this.getLayerManager().getWorkbenchSettings(), + queryClient: this.getLayerManager().getQueryClient(), + }); + } + } + + beforeDestroy(): void { + this._unsubscribeHandler.unsubscribeAll(); + } + + private setLoadingState(loadingState: SettingsContextLoadingState) { + if (this._loadingState === loadingState) { + return; + } + + this._loadingState = loadingState; + this._publishSubscribeDelegate.notifySubscribers(SettingsContextDelegateTopic.LOADING_STATE_CHANGED); + } + + private handleSettingChanged() { + // this.getLayerManager().publishTopic(LayerManagerTopic.SETTINGS_CHANGED); + + if (!this.areAllSettingsLoaded() || !this.areAllSettingsInitialized()) { + this.setLoadingState(SettingsContextLoadingState.LOADING); + return; + } + + if (this.isSomePersistedSettingNotValid() || !this.areCurrentSettingsValid()) { + this.setLoadingState(SettingsContextLoadingState.FAILED); + return; + } + + this.setLoadingState(SettingsContextLoadingState.LOADED); + this._publishSubscribeDelegate.notifySubscribers(SettingsContextDelegateTopic.SETTINGS_CHANGED); + } + + private handleSettingsLoadingStateChanged() { + for (const key in this._settings) { + if (this._settings[key].getDelegate().isLoading()) { + this.setLoadingState(SettingsContextLoadingState.LOADING); + return; + } + } + + this.setLoadingState(SettingsContextLoadingState.LOADED); + } +} diff --git a/frontend/src/modules/2DViewer/layers/delegates/UnsubscribeHandlerDelegate.ts b/frontend/src/modules/2DViewer/layers/delegates/UnsubscribeHandlerDelegate.ts new file mode 100644 index 000000000..067c8d830 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/delegates/UnsubscribeHandlerDelegate.ts @@ -0,0 +1,30 @@ +export class UnsubscribeHandlerDelegate { + private _subscriptions: Map void>> = new Map(); + + registerUnsubscribeFunction(topic: string, callback: () => void): void { + let subscriptionsSet = this._subscriptions.get(topic); + if (!subscriptionsSet) { + subscriptionsSet = new Set(); + this._subscriptions.set(topic, subscriptionsSet); + } + subscriptionsSet.add(callback); + } + + unsubscribe(topic: string): void { + const subscriptionsSet = this._subscriptions.get(topic); + if (subscriptionsSet) { + for (const unsubscribeFunc of subscriptionsSet) { + unsubscribeFunc(); + } + this._subscriptions.delete(topic); + } + } + + unsubscribeAll(): void { + for (const subscriptionsSet of this._subscriptions.values()) { + for (const unsubscribeFunc of subscriptionsSet) { + unsubscribeFunc(); + } + } + } +} diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellTrajectoriesLayer/DrilledWellTrajectoriesContext.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellTrajectoriesLayer/DrilledWellTrajectoriesContext.ts new file mode 100644 index 000000000..4b9e466de --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellTrajectoriesLayer/DrilledWellTrajectoriesContext.ts @@ -0,0 +1,86 @@ +import { apiService } from "@framework/ApiService"; +import { LayerManager } from "@modules/2DViewer/layers/LayerManager"; +import { SettingsContextDelegate } from "@modules/2DViewer/layers/delegates/SettingsContextDelegate"; +import { SettingType } from "@modules/2DViewer/layers/implementations/settings/settingsTypes"; +import { CACHE_TIME, STALE_TIME } from "@modules/2DViewer/layers/queryConstants"; +import { cancelPromiseOnAbort } from "@modules/2DViewer/layers/utils"; + +import { DrilledWellTrajectoriesSettings } from "./types"; + +import { DefineDependenciesArgs, SettingsContext } from "../../../interfaces"; +import { DrilledWellbores } from "../../settings/DrilledWellbores"; +import { Ensemble } from "../../settings/Ensemble"; + +export class DrilledWellTrajectoriesContext implements SettingsContext { + private _contextDelegate: SettingsContextDelegate; + + constructor(layerManager: LayerManager) { + this._contextDelegate = new SettingsContextDelegate< + DrilledWellTrajectoriesSettings, + keyof DrilledWellTrajectoriesSettings + >(this, layerManager, { + [SettingType.ENSEMBLE]: new Ensemble(), + [SettingType.SMDA_WELLBORE_HEADERS]: new DrilledWellbores(), + }); + } + + getDelegate(): SettingsContextDelegate { + return this._contextDelegate; + } + + getSettings() { + return this._contextDelegate.getSettings(); + } + + defineDependencies({ + helperDependency, + availableSettingsUpdater, + workbenchSession, + queryClient, + }: DefineDependenciesArgs) { + availableSettingsUpdater(SettingType.ENSEMBLE, ({ getGlobalSetting }) => { + const fieldIdentifier = getGlobalSetting("fieldId"); + const ensembles = getGlobalSetting("ensembles"); + + const ensembleIdents = ensembles + .filter((ensemble) => ensemble.getFieldIdentifier() === fieldIdentifier) + .map((ensemble) => ensemble.getIdent()); + + return ensembleIdents; + }); + + const wellboreHeadersDep = helperDependency(async function fetchData({ getLocalSetting, abortSignal }) { + const ensembleIdent = getLocalSetting(SettingType.ENSEMBLE); + + if (!ensembleIdent) { + return null; + } + + const ensembleSet = workbenchSession.getEnsembleSet(); + const ensemble = ensembleSet.findEnsemble(ensembleIdent); + + if (!ensemble) { + return null; + } + + const fieldIdentifier = ensemble.getFieldIdentifier(); + + return await queryClient.fetchQuery({ + queryKey: ["getDrilledWellboreHeaders", fieldIdentifier], + queryFn: () => + cancelPromiseOnAbort(apiService.well.getDrilledWellboreHeaders(fieldIdentifier), abortSignal), + staleTime: STALE_TIME, + gcTime: CACHE_TIME, + }); + }); + availableSettingsUpdater(SettingType.SMDA_WELLBORE_HEADERS, ({ getHelperDependency }) => { + const wellboreHeaders = getHelperDependency(wellboreHeadersDep); + + if (!wellboreHeaders) { + return []; + } + + return wellboreHeaders; + }); + } +} diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellTrajectoriesLayer/DrilledWellTrajectoriesLayer.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellTrajectoriesLayer/DrilledWellTrajectoriesLayer.ts new file mode 100644 index 000000000..961b1f4e1 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellTrajectoriesLayer/DrilledWellTrajectoriesLayer.ts @@ -0,0 +1,125 @@ +import { WellboreTrajectory_api } from "@api"; +import { apiService } from "@framework/ApiService"; +import { LayerManager } from "@modules/2DViewer/layers/LayerManager"; +import { LayerRegistry } from "@modules/2DViewer/layers/LayerRegistry"; +import { ItemDelegate } from "@modules/2DViewer/layers/delegates/ItemDelegate"; +import { SettingType } from "@modules/2DViewer/layers/implementations/settings/settingsTypes"; +import { QueryClient } from "@tanstack/react-query"; + +import { isEqual } from "lodash"; + +import { DrilledWellTrajectoriesContext } from "./DrilledWellTrajectoriesContext"; +import { DrilledWellTrajectoriesSettings } from "./types"; + +import { LayerColoringType, LayerDelegate } from "../../../delegates/LayerDelegate"; +import { BoundingBox, Layer, SerializedLayer } from "../../../interfaces"; + +export class DrilledWellTrajectoriesLayer implements Layer { + private _layerDelegate: LayerDelegate; + private _itemDelegate: ItemDelegate; + + constructor(layerManager: LayerManager) { + this._itemDelegate = new ItemDelegate("Drilled Wellbore trajectories", layerManager); + this._layerDelegate = new LayerDelegate( + this, + layerManager, + new DrilledWellTrajectoriesContext(layerManager), + LayerColoringType.NONE + ); + } + + getSettingsContext() { + return this._layerDelegate.getSettingsContext(); + } + + getItemDelegate(): ItemDelegate { + return this._itemDelegate; + } + + getLayerDelegate(): LayerDelegate { + return this._layerDelegate; + } + + doSettingsChangesRequireDataRefetch( + prevSettings: DrilledWellTrajectoriesSettings, + newSettings: DrilledWellTrajectoriesSettings + ): boolean { + return !isEqual(prevSettings, newSettings); + } + + makeBoundingBox(): BoundingBox | null { + const data = this._layerDelegate.getData(); + if (!data) { + return null; + } + + const bbox: BoundingBox = { + x: [Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER], + y: [Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER], + z: [Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER], + }; + + for (const trajectory of data) { + for (const point of trajectory.eastingArr) { + bbox.x[0] = Math.min(bbox.x[0], point); + bbox.x[1] = Math.max(bbox.x[1], point); + } + for (const point of trajectory.northingArr) { + bbox.y[0] = Math.min(bbox.y[0], point); + bbox.y[1] = Math.max(bbox.y[1], point); + } + for (const point of trajectory.tvdMslArr) { + bbox.z[0] = Math.min(bbox.z[0], point); + bbox.z[1] = Math.max(bbox.z[1], point); + } + } + + return bbox; + } + + fechData(queryClient: QueryClient): Promise { + const workbenchSession = this.getSettingsContext().getDelegate().getLayerManager().getWorkbenchSession(); + const ensembleSet = workbenchSession.getEnsembleSet(); + const settings = this.getSettingsContext().getDelegate().getSettings(); + const ensembleIdent = settings[SettingType.ENSEMBLE].getDelegate().getValue(); + const selectedWellboreHeaders = settings[SettingType.SMDA_WELLBORE_HEADERS].getDelegate().getValue(); + let selectedWellboreUuids: string[] = []; + if (selectedWellboreHeaders) { + selectedWellboreUuids = selectedWellboreHeaders.map((header) => header.wellboreUuid); + } + + let fieldIdentifier: string | null = null; + if (ensembleIdent) { + const ensemble = ensembleSet.findEnsemble(ensembleIdent); + if (ensemble) { + fieldIdentifier = ensemble.getFieldIdentifier(); + } + } + + const queryKey = ["getWellTrajectories", fieldIdentifier]; + this._layerDelegate.registerQueryKey(queryKey); + + const promise = queryClient + .fetchQuery({ + queryKey, + queryFn: () => apiService.well.getWellTrajectories(fieldIdentifier ?? ""), + staleTime: 1800000, // TODO + gcTime: 1800000, + }) + .then((response: WellboreTrajectory_api[]) => { + return response.filter((trajectory) => selectedWellboreUuids.includes(trajectory.wellboreUuid)); + }); + + return promise; + } + + serializeState(): SerializedLayer { + return this._layerDelegate.serializeState(); + } + + deserializeState(serializedState: SerializedLayer): void { + this._layerDelegate.deserializeState(serializedState); + } +} + +LayerRegistry.registerLayer(DrilledWellTrajectoriesLayer); diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellTrajectoriesLayer/types.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellTrajectoriesLayer/types.ts new file mode 100644 index 000000000..f4877d5b6 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellTrajectoriesLayer/types.ts @@ -0,0 +1,8 @@ +import { WellboreHeader_api } from "@api"; +import { EnsembleIdent } from "@framework/EnsembleIdent"; +import { SettingType } from "@modules/2DViewer/layers/implementations/settings/settingsTypes"; + +export type DrilledWellTrajectoriesSettings = { + [SettingType.ENSEMBLE]: EnsembleIdent | null; + [SettingType.SMDA_WELLBORE_HEADERS]: WellboreHeader_api[] | null; +}; diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellborePicksLayer/DrilledWellborePicksContext.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellborePicksLayer/DrilledWellborePicksContext.ts new file mode 100644 index 000000000..e2e7fb5ff --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellborePicksLayer/DrilledWellborePicksContext.ts @@ -0,0 +1,137 @@ +import { apiService } from "@framework/ApiService"; +import { LayerManager } from "@modules/2DViewer/layers/LayerManager"; +import { SettingsContextDelegate } from "@modules/2DViewer/layers/delegates/SettingsContextDelegate"; +import { SettingType } from "@modules/2DViewer/layers/implementations/settings/settingsTypes"; +import { CACHE_TIME, STALE_TIME } from "@modules/2DViewer/layers/queryConstants"; +import { cancelPromiseOnAbort } from "@modules/2DViewer/layers/utils"; + +import { DrilledWellborePicksSettings } from "./types"; + +import { DefineDependenciesArgs, SettingsContext } from "../../../interfaces"; +import { DrilledWellbores } from "../../settings/DrilledWellbores"; +import { Ensemble } from "../../settings/Ensemble"; +import { SurfaceName } from "../../settings/SurfaceName"; + +export class DrilledWellborePicksContext implements SettingsContext { + private _contextDelegate: SettingsContextDelegate; + + constructor(layerManager: LayerManager) { + this._contextDelegate = new SettingsContextDelegate< + DrilledWellborePicksSettings, + keyof DrilledWellborePicksSettings + >(this, layerManager, { + [SettingType.ENSEMBLE]: new Ensemble(), + [SettingType.SMDA_WELLBORE_HEADERS]: new DrilledWellbores(), + [SettingType.SURFACE_NAME]: new SurfaceName(), + }); + } + + getDelegate(): SettingsContextDelegate { + return this._contextDelegate; + } + + getSettings() { + return this._contextDelegate.getSettings(); + } + + areCurrentSettingsValid(settings: DrilledWellborePicksSettings): boolean { + return ( + settings[SettingType.ENSEMBLE] !== null && + settings[SettingType.SMDA_WELLBORE_HEADERS] !== null && + settings[SettingType.SMDA_WELLBORE_HEADERS].length > 0 && + settings[SettingType.SURFACE_NAME] !== null + ); + } + + defineDependencies({ + helperDependency, + availableSettingsUpdater, + workbenchSession, + queryClient, + }: DefineDependenciesArgs) { + availableSettingsUpdater(SettingType.ENSEMBLE, ({ getGlobalSetting }) => { + const fieldIdentifier = getGlobalSetting("fieldId"); + const ensembles = getGlobalSetting("ensembles"); + + const ensembleIdents = ensembles + .filter((ensemble) => ensemble.getFieldIdentifier() === fieldIdentifier) + .map((ensemble) => ensemble.getIdent()); + + return ensembleIdents; + }); + + const wellboreHeadersDep = helperDependency(async function fetchData({ getLocalSetting, abortSignal }) { + const ensembleIdent = getLocalSetting(SettingType.ENSEMBLE); + + if (!ensembleIdent) { + return null; + } + + const ensembleSet = workbenchSession.getEnsembleSet(); + const ensemble = ensembleSet.findEnsemble(ensembleIdent); + + if (!ensemble) { + return null; + } + + const fieldIdentifier = ensemble.getFieldIdentifier(); + + return await queryClient.fetchQuery({ + queryKey: ["getDrilledWellboreHeaders", fieldIdentifier], + queryFn: () => + cancelPromiseOnAbort(apiService.well.getDrilledWellboreHeaders(fieldIdentifier), abortSignal), + staleTime: STALE_TIME, + gcTime: CACHE_TIME, + }); + }); + + const pickIdentifiersDep = helperDependency(async function fetchData({ getLocalSetting, abortSignal }) { + const ensembleIdent = getLocalSetting(SettingType.ENSEMBLE); + + if (!ensembleIdent) { + return null; + } + + const ensembleSet = workbenchSession.getEnsembleSet(); + const ensemble = ensembleSet.findEnsemble(ensembleIdent); + + if (!ensemble) { + return null; + } + + const fieldIdentifier = ensemble.getFieldIdentifier(); + const stratColumnIdentifier = ensemble.getStratigraphicColumnIdentifier(); + + return await queryClient.fetchQuery({ + queryKey: ["getPickStratigraphy", fieldIdentifier, stratColumnIdentifier], + queryFn: () => + cancelPromiseOnAbort( + apiService.well.getWellborePickIdentifiers(fieldIdentifier, stratColumnIdentifier), + abortSignal + ), + staleTime: STALE_TIME, + gcTime: CACHE_TIME, + }); + }); + + availableSettingsUpdater(SettingType.SMDA_WELLBORE_HEADERS, ({ getHelperDependency }) => { + const wellboreHeaders = getHelperDependency(wellboreHeadersDep); + + if (!wellboreHeaders) { + return []; + } + + return wellboreHeaders; + }); + + availableSettingsUpdater(SettingType.SURFACE_NAME, ({ getHelperDependency }) => { + const pickIdentifiers = getHelperDependency(pickIdentifiersDep); + + if (!pickIdentifiers) { + return []; + } + + return pickIdentifiers; + }); + } +} diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellborePicksLayer/DrilledWellborePicksLayer.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellborePicksLayer/DrilledWellborePicksLayer.ts new file mode 100644 index 000000000..27fb63e4a --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellborePicksLayer/DrilledWellborePicksLayer.ts @@ -0,0 +1,126 @@ +import { WellborePick_api } from "@api"; +import { apiService } from "@framework/ApiService"; +import { LayerManager } from "@modules/2DViewer/layers/LayerManager"; +import { LayerRegistry } from "@modules/2DViewer/layers/LayerRegistry"; +import { ItemDelegate } from "@modules/2DViewer/layers/delegates/ItemDelegate"; +import { SettingType } from "@modules/2DViewer/layers/implementations/settings/settingsTypes"; +import { CACHE_TIME, STALE_TIME } from "@modules/2DViewer/layers/queryConstants"; +import { QueryClient } from "@tanstack/react-query"; + +import { isEqual } from "lodash"; + +import { DrilledWellborePicksContext } from "./DrilledWellborePicksContext"; +import { DrilledWellborePicksSettings } from "./types"; + +import { LayerColoringType, LayerDelegate } from "../../../delegates/LayerDelegate"; +import { BoundingBox, Layer, SerializedLayer } from "../../../interfaces"; + +export class DrilledWellborePicksLayer implements Layer { + private _layerDelegate: LayerDelegate; + private _itemDelegate: ItemDelegate; + + constructor(layerManager: LayerManager) { + this._itemDelegate = new ItemDelegate("Drilled Wellbore picks", layerManager); + this._layerDelegate = new LayerDelegate( + this, + layerManager, + new DrilledWellborePicksContext(layerManager), + LayerColoringType.NONE + ); + } + + getSettingsContext() { + return this._layerDelegate.getSettingsContext(); + } + + getItemDelegate(): ItemDelegate { + return this._itemDelegate; + } + + getLayerDelegate(): LayerDelegate { + return this._layerDelegate; + } + + doSettingsChangesRequireDataRefetch( + prevSettings: DrilledWellborePicksSettings, + newSettings: DrilledWellborePicksSettings + ): boolean { + return !isEqual(prevSettings, newSettings); + } + + makeBoundingBox(): BoundingBox | null { + const data = this._layerDelegate.getData(); + if (!data) { + return null; + } + + const bbox: BoundingBox = { + x: [Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER], + y: [Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER], + z: [Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER], + }; + + for (const trajectory of data) { + bbox.x[0] = Math.min(bbox.x[0], trajectory.easting); + bbox.x[1] = Math.max(bbox.x[1], trajectory.easting); + + bbox.y[0] = Math.min(bbox.y[0], trajectory.northing); + bbox.y[1] = Math.max(bbox.y[1], trajectory.northing); + + bbox.z[0] = Math.min(bbox.z[0], trajectory.tvdMsl); + bbox.z[1] = Math.max(bbox.z[1], trajectory.tvdMsl); + } + + return bbox; + } + + fechData(queryClient: QueryClient): Promise { + const workbenchSession = this.getSettingsContext().getDelegate().getLayerManager().getWorkbenchSession(); + const ensembleSet = workbenchSession.getEnsembleSet(); + const settings = this.getSettingsContext().getDelegate().getSettings(); + const ensembleIdent = settings[SettingType.ENSEMBLE].getDelegate().getValue(); + const selectedWellboreHeaders = settings[SettingType.SMDA_WELLBORE_HEADERS].getDelegate().getValue(); + let selectedWellboreUuids: string[] = []; + if (selectedWellboreHeaders) { + selectedWellboreUuids = selectedWellboreHeaders.map((header) => header.wellboreUuid); + } + const selectedPickIdentifier = settings[SettingType.SURFACE_NAME].getDelegate().getValue(); + let fieldIdentifier: string | null = null; + if (ensembleIdent) { + const ensemble = ensembleSet.findEnsemble(ensembleIdent); + if (ensemble) { + fieldIdentifier = ensemble.getFieldIdentifier(); + } + } + + const queryKey = ["getWellborePicksForPickIdentifier", fieldIdentifier, selectedPickIdentifier]; + this._layerDelegate.registerQueryKey(queryKey); + + const promise = queryClient + .fetchQuery({ + queryKey, + queryFn: () => + apiService.well.getWellborePicksForPickIdentifier( + fieldIdentifier ?? "", + selectedPickIdentifier ?? "" + ), + staleTime: STALE_TIME, + gcTime: CACHE_TIME, + }) + .then((response: WellborePick_api[]) => { + return response.filter((trajectory) => selectedWellboreUuids.includes(trajectory.wellboreUuid)); + }); + + return promise; + } + + serializeState(): SerializedLayer { + return this._layerDelegate.serializeState(); + } + + deserializeState(state: SerializedLayer): void { + this._layerDelegate.deserializeState(state); + } +} + +LayerRegistry.registerLayer(DrilledWellborePicksLayer); diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellborePicksLayer/types.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellborePicksLayer/types.ts new file mode 100644 index 000000000..ace87b9b8 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/DrilledWellborePicksLayer/types.ts @@ -0,0 +1,9 @@ +import { WellboreHeader_api } from "@api"; +import { EnsembleIdent } from "@framework/EnsembleIdent"; +import { SettingType } from "@modules/2DViewer/layers/implementations/settings/settingsTypes"; + +export type DrilledWellborePicksSettings = { + [SettingType.ENSEMBLE]: EnsembleIdent | null; + [SettingType.SMDA_WELLBORE_HEADERS]: WellboreHeader_api[] | null; + [SettingType.SURFACE_NAME]: string | null; +}; diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/ObservedSurfaceLayer/ObservedSurfaceContext.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/ObservedSurfaceLayer/ObservedSurfaceContext.ts new file mode 100644 index 000000000..8888954eb --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/ObservedSurfaceLayer/ObservedSurfaceContext.ts @@ -0,0 +1,144 @@ +import { SurfaceMetaSet_api, SurfaceTimeType_api } from "@api"; +import { apiService } from "@framework/ApiService"; +import { LayerManager } from "@modules/2DViewer/layers/LayerManager"; +import { SettingsContextDelegate } from "@modules/2DViewer/layers/delegates/SettingsContextDelegate"; +import { SettingType } from "@modules/2DViewer/layers/implementations/settings/settingsTypes"; +import { CACHE_TIME, STALE_TIME } from "@modules/2DViewer/layers/queryConstants"; +import { cancelPromiseOnAbort } from "@modules/2DViewer/layers/utils"; + +import { ObservedSurfaceSettings } from "./types"; + +import { DefineDependenciesArgs, SettingsContext } from "../../../interfaces"; +import { Ensemble } from "../../settings/Ensemble"; +import { SurfaceAttribute } from "../../settings/SurfaceAttribute"; +import { SurfaceName } from "../../settings/SurfaceName"; +import { TimeOrInterval } from "../../settings/TimeOrInterval"; + +export class ObservedSurfaceContext implements SettingsContext { + private _contextDelegate: SettingsContextDelegate; + private _fetchDataCache: SurfaceMetaSet_api | null = null; + + constructor(layerManager: LayerManager) { + this._contextDelegate = new SettingsContextDelegate( + this, + layerManager, + { + [SettingType.ENSEMBLE]: new Ensemble(), + [SettingType.SURFACE_ATTRIBUTE]: new SurfaceAttribute(), + [SettingType.SURFACE_NAME]: new SurfaceName(), + [SettingType.TIME_OR_INTERVAL]: new TimeOrInterval(), + } + ); + } + + getDelegate(): SettingsContextDelegate { + return this._contextDelegate; + } + + getSettings() { + return this._contextDelegate.getSettings(); + } + defineDependencies({ + helperDependency, + availableSettingsUpdater, + workbenchSession, + queryClient, + }: DefineDependenciesArgs) { + availableSettingsUpdater(SettingType.ENSEMBLE, ({ getGlobalSetting }) => { + const fieldIdentifier = getGlobalSetting("fieldId"); + const ensembleSet = workbenchSession.getEnsembleSet(); + + const ensembleIdents = ensembleSet + .getEnsembleArr() + .filter((ensemble) => ensemble.getFieldIdentifier() === fieldIdentifier) + .map((ensemble) => ensemble.getIdent()); + + return ensembleIdents; + }); + + const observedSurfaceMetadataDep = helperDependency(async ({ getLocalSetting, abortSignal }) => { + const ensembleIdent = getLocalSetting(SettingType.ENSEMBLE); + + if (!ensembleIdent) { + return null; + } + + return await queryClient.fetchQuery({ + queryKey: ["getObservedSurfacesMetadata", ensembleIdent.getCaseUuid()], + queryFn: () => + cancelPromiseOnAbort( + apiService.surface.getObservedSurfacesMetadata(ensembleIdent.getCaseUuid()), + abortSignal + ), + staleTime: STALE_TIME, + gcTime: CACHE_TIME, + }); + }); + + availableSettingsUpdater(SettingType.SURFACE_ATTRIBUTE, ({ getHelperDependency }) => { + const data = getHelperDependency(observedSurfaceMetadataDep); + + if (!data) { + return []; + } + + const availableAttributes = [ + ...Array.from(new Set(data.surfaces.map((surface) => surface.attribute_name))), + ]; + + return availableAttributes; + }); + + availableSettingsUpdater(SettingType.SURFACE_NAME, ({ getHelperDependency, getLocalSetting }) => { + const attribute = getLocalSetting(SettingType.SURFACE_ATTRIBUTE); + const data = getHelperDependency(observedSurfaceMetadataDep); + + if (!attribute || !data) { + return []; + } + + const availableSurfaceNames = [ + ...Array.from( + new Set( + data.surfaces.filter((surface) => surface.attribute_name === attribute).map((el) => el.name) + ) + ), + ]; + + return availableSurfaceNames; + }); + + availableSettingsUpdater(SettingType.TIME_OR_INTERVAL, ({ getLocalSetting, getHelperDependency }) => { + const attribute = getLocalSetting(SettingType.SURFACE_ATTRIBUTE); + const surfaceName = getLocalSetting(SettingType.SURFACE_NAME); + const data = getHelperDependency(observedSurfaceMetadataDep); + + if (!attribute || !surfaceName || !data) { + return []; + } + + const availableTimeOrIntervals: string[] = []; + const availableTimeTypes = [ + ...Array.from( + new Set( + data.surfaces + .filter((surface) => surface.attribute_name === attribute && surface.name === surfaceName) + .map((el) => el.time_type) + ) + ), + ]; + + if (availableTimeTypes.includes(SurfaceTimeType_api.NO_TIME)) { + availableTimeOrIntervals.push(SurfaceTimeType_api.NO_TIME); + } + if (availableTimeTypes.includes(SurfaceTimeType_api.TIME_POINT)) { + availableTimeOrIntervals.push(...data.time_points_iso_str); + } + if (availableTimeTypes.includes(SurfaceTimeType_api.INTERVAL)) { + availableTimeOrIntervals.push(...data.time_intervals_iso_str); + } + + return availableTimeOrIntervals; + }); + } +} diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/ObservedSurfaceLayer/ObservedSurfaceLayer.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/ObservedSurfaceLayer/ObservedSurfaceLayer.ts new file mode 100644 index 000000000..67ef98914 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/ObservedSurfaceLayer/ObservedSurfaceLayer.ts @@ -0,0 +1,124 @@ +import { SurfaceDataPng_api } from "@api"; +import { apiService } from "@framework/ApiService"; +import { LayerManager } from "@modules/2DViewer/layers/LayerManager"; +import { LayerRegistry } from "@modules/2DViewer/layers/LayerRegistry"; +import { ItemDelegate } from "@modules/2DViewer/layers/delegates/ItemDelegate"; +import { SettingType } from "@modules/2DViewer/layers/implementations/settings/settingsTypes"; +import { CACHE_TIME, STALE_TIME } from "@modules/2DViewer/layers/queryConstants"; +import { FullSurfaceAddress, SurfaceAddressBuilder } from "@modules/_shared/Surface"; +import { SurfaceDataFloat_trans, transformSurfaceData } from "@modules/_shared/Surface/queryDataTransforms"; +import { encodeSurfAddrStr } from "@modules/_shared/Surface/surfaceAddress"; +import { QueryClient } from "@tanstack/react-query"; + +import { isEqual } from "lodash"; + +import { ObservedSurfaceContext } from "./ObservedSurfaceContext"; +import { ObservedSurfaceSettings } from "./types"; + +import { LayerColoringType, LayerDelegate } from "../../../delegates/LayerDelegate"; +import { BoundingBox, Layer, SerializedLayer } from "../../../interfaces"; + +export class ObservedSurfaceLayer + implements Layer +{ + private _layerDelegate: LayerDelegate; + private _itemDelegate: ItemDelegate; + + constructor(layerManager: LayerManager) { + this._itemDelegate = new ItemDelegate("Observed Surface", layerManager); + this._layerDelegate = new LayerDelegate( + this, + layerManager, + new ObservedSurfaceContext(layerManager), + LayerColoringType.COLORSCALE + ); + } + + getSettingsContext() { + return this._layerDelegate.getSettingsContext(); + } + + getItemDelegate(): ItemDelegate { + return this._itemDelegate; + } + + getLayerDelegate(): LayerDelegate { + return this._layerDelegate; + } + + doSettingsChangesRequireDataRefetch( + prevSettings: ObservedSurfaceSettings, + newSettings: ObservedSurfaceSettings + ): boolean { + return !isEqual(prevSettings, newSettings); + } + + makeBoundingBox(): BoundingBox | null { + const data = this._layerDelegate.getData(); + if (!data) { + return null; + } + + return { + x: [data.transformed_bbox_utm.min_x, data.transformed_bbox_utm.max_x], + y: [data.transformed_bbox_utm.min_y, data.transformed_bbox_utm.max_y], + z: [0, 0], + }; + } + + makeValueRange(): [number, number] | null { + const data = this._layerDelegate.getData(); + if (!data) { + return null; + } + + return [data.value_min, data.value_max]; + } + + fechData(queryClient: QueryClient): Promise { + let surfaceAddress: FullSurfaceAddress | null = null; + const addrBuilder = new SurfaceAddressBuilder(); + + const settings = this.getSettingsContext().getDelegate().getSettings(); + const ensembleIdent = settings[SettingType.ENSEMBLE].getDelegate().getValue(); + const surfaceName = settings[SettingType.SURFACE_NAME].getDelegate().getValue(); + const attribute = settings[SettingType.SURFACE_ATTRIBUTE].getDelegate().getValue(); + const timeOrInterval = settings[SettingType.TIME_OR_INTERVAL].getDelegate().getValue(); + + if (ensembleIdent && surfaceName && attribute && timeOrInterval) { + addrBuilder.withEnsembleIdent(ensembleIdent); + addrBuilder.withName(surfaceName); + addrBuilder.withAttribute(attribute); + addrBuilder.withTimeOrInterval(timeOrInterval); + + surfaceAddress = addrBuilder.buildObservedAddress(); + } + + const surfAddrStr = surfaceAddress ? encodeSurfAddrStr(surfaceAddress) : null; + + const queryKey = ["getSurfaceData", surfAddrStr, null, "png"]; + + this._layerDelegate.registerQueryKey(queryKey); + + const promise = queryClient + .fetchQuery({ + queryKey, + queryFn: () => apiService.surface.getSurfaceData(surfAddrStr ?? "", "png", null), + staleTime: STALE_TIME, + gcTime: CACHE_TIME, + }) + .then((data) => transformSurfaceData(data)); + + return promise; + } + + serializeState(): SerializedLayer { + return this._layerDelegate.serializeState(); + } + + deserializeState(serializedState: SerializedLayer): void { + this._layerDelegate.deserializeState(serializedState); + } +} + +LayerRegistry.registerLayer(ObservedSurfaceLayer); diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/ObservedSurfaceLayer/types.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/ObservedSurfaceLayer/types.ts new file mode 100644 index 000000000..26d641e44 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/ObservedSurfaceLayer/types.ts @@ -0,0 +1,9 @@ +import { EnsembleIdent } from "@framework/EnsembleIdent"; +import { SettingType } from "@modules/2DViewer/layers/implementations/settings/settingsTypes"; + +export type ObservedSurfaceSettings = { + [SettingType.ENSEMBLE]: EnsembleIdent | null; + [SettingType.SURFACE_ATTRIBUTE]: string | null; + [SettingType.SURFACE_NAME]: string | null; + [SettingType.TIME_OR_INTERVAL]: string | null; +}; diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationGridLayer/RealizationGridContext.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationGridLayer/RealizationGridContext.ts new file mode 100644 index 000000000..e768409b5 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationGridLayer/RealizationGridContext.ts @@ -0,0 +1,180 @@ +import { apiService } from "@framework/ApiService"; +import { LayerManager } from "@modules/2DViewer/layers/LayerManager"; +import { SettingsContextDelegate } from "@modules/2DViewer/layers/delegates/SettingsContextDelegate"; +import { SettingType } from "@modules/2DViewer/layers/implementations/settings/settingsTypes"; +import { CACHE_TIME, STALE_TIME } from "@modules/2DViewer/layers/queryConstants"; +import { cancelQueryOnAbort } from "@modules/2DViewer/layers/utils"; + +import { RealizationGridSettings } from "./types"; + +import { DefineDependenciesArgs, SettingsContext } from "../../../interfaces"; +import { Ensemble } from "../../settings/Ensemble"; +import { GridAttribute } from "../../settings/GridAttribute"; +import { GridLayer } from "../../settings/GridLayer"; +import { GridName } from "../../settings/GridName"; +import { Realization } from "../../settings/Realization"; +import { ShowGridLines } from "../../settings/ShowGridLines"; +import { TimeOrInterval } from "../../settings/TimeOrInterval"; + +export class RealizationGridContext implements SettingsContext { + private _contextDelegate: SettingsContextDelegate; + + constructor(layerManager: LayerManager) { + this._contextDelegate = new SettingsContextDelegate( + this, + layerManager, + { + [SettingType.ENSEMBLE]: new Ensemble(), + [SettingType.REALIZATION]: new Realization(), + [SettingType.GRID_NAME]: new GridName(), + [SettingType.GRID_ATTRIBUTE]: new GridAttribute(), + [SettingType.GRID_LAYER]: new GridLayer(), + [SettingType.TIME_OR_INTERVAL]: new TimeOrInterval(), + [SettingType.SHOW_GRID_LINES]: new ShowGridLines(), + } + ); + } + + areCurrentSettingsValid(settings: RealizationGridSettings): boolean { + return ( + settings[SettingType.ENSEMBLE] !== null && + settings[SettingType.REALIZATION] !== null && + settings[SettingType.GRID_NAME] !== null && + settings[SettingType.GRID_ATTRIBUTE] !== null && + settings[SettingType.GRID_LAYER] !== null && + settings[SettingType.TIME_OR_INTERVAL] !== null + ); + } + + getDelegate(): SettingsContextDelegate { + return this._contextDelegate; + } + + getSettings() { + return this._contextDelegate.getSettings(); + } + defineDependencies({ + helperDependency, + availableSettingsUpdater, + workbenchSession, + queryClient, + }: DefineDependenciesArgs) { + availableSettingsUpdater(SettingType.ENSEMBLE, ({ getGlobalSetting }) => { + const fieldIdentifier = getGlobalSetting("fieldId"); + const ensembles = getGlobalSetting("ensembles"); + + const ensembleIdents = ensembles + .filter((ensemble) => ensemble.getFieldIdentifier() === fieldIdentifier) + .map((ensemble) => ensemble.getIdent()); + + return ensembleIdents; + }); + + availableSettingsUpdater(SettingType.REALIZATION, ({ getLocalSetting, getGlobalSetting }) => { + const ensembleIdent = getLocalSetting(SettingType.ENSEMBLE); + const realizationFilterFunc = getGlobalSetting("realizationFilterFunction"); + + if (!ensembleIdent) { + return []; + } + + const realizations = realizationFilterFunc(ensembleIdent); + + return [...realizations]; + }); + const realizationGridDataDep = helperDependency(async ({ getLocalSetting, abortSignal }) => { + const ensembleIdent = getLocalSetting(SettingType.ENSEMBLE); + const realization = getLocalSetting(SettingType.REALIZATION); + + if (!ensembleIdent || realization === null) { + return null; + } + + return await cancelQueryOnAbort(queryClient, abortSignal, { + queryKey: ["getRealizationGridMetadata", ensembleIdent, realization], + queryFn: () => + apiService.grid3D.getGridModelsInfo( + ensembleIdent.getCaseUuid(), + ensembleIdent.getEnsembleName(), + realization + ), + staleTime: STALE_TIME, + gcTime: CACHE_TIME, + }); + }); + + availableSettingsUpdater(SettingType.GRID_NAME, ({ getHelperDependency }) => { + const data = getHelperDependency(realizationGridDataDep); + + if (!data) { + return []; + } + + const availableGridNames = [...Array.from(new Set(data.map((gridModelInfo) => gridModelInfo.grid_name)))]; + + return availableGridNames; + }); + + availableSettingsUpdater(SettingType.GRID_ATTRIBUTE, ({ getLocalSetting, getHelperDependency }) => { + const gridName = getLocalSetting(SettingType.GRID_NAME); + const data = getHelperDependency(realizationGridDataDep); + + if (!gridName || !data) { + return []; + } + + const gridAttributeArr = + data.find((gridModel) => gridModel.grid_name === gridName)?.property_info_arr ?? []; + + const availableGridAttributes = [ + ...Array.from(new Set(gridAttributeArr.map((gridAttribute) => gridAttribute.property_name))), + ]; + + return availableGridAttributes; + }); + + availableSettingsUpdater(SettingType.GRID_LAYER, ({ getLocalSetting, getHelperDependency }) => { + const gridName = getLocalSetting(SettingType.GRID_NAME); + const data = getHelperDependency(realizationGridDataDep); + + if (!gridName || !data) { + return []; + } + + const gridDimensions = data.find((gridModel) => gridModel.grid_name === gridName)?.dimensions ?? null; + const availableGridLayers: number[] = []; + if (gridDimensions) { + availableGridLayers.push(gridDimensions.i_count); + availableGridLayers.push(gridDimensions.j_count); + availableGridLayers.push(gridDimensions.k_count); + } + + return availableGridLayers; + }); + + availableSettingsUpdater(SettingType.TIME_OR_INTERVAL, ({ getLocalSetting, getHelperDependency }) => { + const gridName = getLocalSetting(SettingType.GRID_NAME); + const gridAttribute = getLocalSetting(SettingType.GRID_ATTRIBUTE); + const data = getHelperDependency(realizationGridDataDep); + + if (!gridName || !gridAttribute || !data) { + return []; + } + + const gridAttributeArr = + data.find((gridModel) => gridModel.grid_name === gridName)?.property_info_arr ?? []; + + const availableTimeOrIntervals = [ + ...Array.from( + new Set( + gridAttributeArr + .filter((attr) => attr.property_name === gridAttribute) + .map((gridAttribute) => gridAttribute.iso_date_or_interval ?? "NO_TIME") + ) + ), + ]; + + return availableTimeOrIntervals; + }); + } +} diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationGridLayer/RealizationGridLayer.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationGridLayer/RealizationGridLayer.ts new file mode 100644 index 000000000..4ff662e3e --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationGridLayer/RealizationGridLayer.ts @@ -0,0 +1,204 @@ +import { apiService } from "@framework/ApiService"; +import { LayerManager } from "@modules/2DViewer/layers/LayerManager"; +import { LayerRegistry } from "@modules/2DViewer/layers/LayerRegistry"; +import { ItemDelegate } from "@modules/2DViewer/layers/delegates/ItemDelegate"; +import { SettingType } from "@modules/2DViewer/layers/implementations/settings/settingsTypes"; +import { CACHE_TIME, STALE_TIME } from "@modules/2DViewer/layers/queryConstants"; +import { + GridMappedProperty_trans, + GridSurface_trans, + transformGridMappedProperty, + transformGridSurface, +} from "@modules/3DViewer/view/queries/queryDataTransforms"; +import { QueryClient } from "@tanstack/react-query"; + +import { isEqual } from "lodash"; + +import { RealizationGridContext } from "./RealizationGridContext"; +import { RealizationGridSettings } from "./types"; + +import { LayerColoringType, LayerDelegate } from "../../../delegates/LayerDelegate"; +import { BoundingBox, Layer, SerializedLayer } from "../../../interfaces"; + +export class RealizationGridLayer + implements + Layer< + RealizationGridSettings, + { + gridSurfaceData: GridSurface_trans; + gridParameterData: GridMappedProperty_trans; + } + > +{ + private _layerDelegate: LayerDelegate< + RealizationGridSettings, + { + gridSurfaceData: GridSurface_trans; + gridParameterData: GridMappedProperty_trans; + } + >; + private _itemDelegate: ItemDelegate; + + constructor(layerManager: LayerManager) { + this._itemDelegate = new ItemDelegate("Realization Grid layer", layerManager); + this._layerDelegate = new LayerDelegate( + this, + layerManager, + new RealizationGridContext(layerManager), + LayerColoringType.COLORSCALE + ); + } + + getSettingsContext() { + return this._layerDelegate.getSettingsContext(); + } + + getItemDelegate(): ItemDelegate { + return this._itemDelegate; + } + + getLayerDelegate(): LayerDelegate< + RealizationGridSettings, + { + gridSurfaceData: GridSurface_trans; + gridParameterData: GridMappedProperty_trans; + } + > { + return this._layerDelegate; + } + + doSettingsChangesRequireDataRefetch( + prevSettings: RealizationGridSettings, + newSettings: RealizationGridSettings + ): boolean { + return !isEqual(prevSettings, newSettings); + } + + makeBoundingBox(): BoundingBox | null { + const data = this._layerDelegate.getData(); + if (!data) { + return null; + } + + return { + x: [ + data.gridSurfaceData.origin_utm_x + data.gridSurfaceData.xmin, + data.gridSurfaceData.origin_utm_x + data.gridSurfaceData.xmax, + ], + y: [ + data.gridSurfaceData.origin_utm_y + data.gridSurfaceData.ymin, + data.gridSurfaceData.origin_utm_y + data.gridSurfaceData.ymax, + ], + z: [data.gridSurfaceData.zmin, data.gridSurfaceData.zmax], + }; + } + + makeValueRange(): [number, number] | null { + const data = this._layerDelegate.getData(); + if (!data) { + return null; + } + + return [data.gridParameterData.min_grid_prop_value, data.gridParameterData.max_grid_prop_value]; + } + + fechData(queryClient: QueryClient): Promise<{ + gridSurfaceData: GridSurface_trans; + gridParameterData: GridMappedProperty_trans; + }> { + const settings = this.getSettingsContext().getDelegate().getSettings(); + const ensembleIdent = settings[SettingType.ENSEMBLE].getDelegate().getValue(); + const realizationNum = settings[SettingType.REALIZATION].getDelegate().getValue(); + const gridName = settings[SettingType.GRID_NAME].getDelegate().getValue(); + const attribute = settings[SettingType.GRID_ATTRIBUTE].getDelegate().getValue(); + let timeOrInterval = settings[SettingType.TIME_OR_INTERVAL].getDelegate().getValue(); + if (timeOrInterval === "NO_TIME") { + timeOrInterval = null; + } + let availableDimensions = settings[SettingType.GRID_LAYER].getDelegate().getAvailableValues(); + if (!availableDimensions.length || availableDimensions[0] === null) { + availableDimensions = [0, 0, 0]; + } + const layerIndex = settings[SettingType.GRID_LAYER].getDelegate().getValue(); + const iMin = 0; + const iMax = availableDimensions[0] || 0; + const jMin = 0; + const jMax = availableDimensions[1] || 0; + const kMin = layerIndex || 0; + const kMax = layerIndex || 0; + const queryKey = [ + "gridParameter", + ensembleIdent, + gridName, + attribute, + timeOrInterval, + realizationNum, + iMin, + iMax, + jMin, + jMax, + kMin, + kMax, + ]; + this._layerDelegate.registerQueryKey(queryKey); + + const gridParameterPromise = queryClient + .fetchQuery({ + queryKey, + queryFn: () => + apiService.grid3D.gridParameter( + ensembleIdent?.getCaseUuid() ?? "", + ensembleIdent?.getEnsembleName() ?? "", + gridName ?? "", + attribute ?? "", + realizationNum ?? 0, + timeOrInterval, + iMin, + iMax - 1, + jMin, + jMax - 1, + kMin, + kMax + ), + staleTime: STALE_TIME, + gcTime: CACHE_TIME, + }) + .then(transformGridMappedProperty); + + const gridSurfacePromise = queryClient + .fetchQuery({ + queryKey: ["getGridData", ensembleIdent, gridName, realizationNum, iMin, iMax, jMin, jMax, kMin, kMax], + queryFn: () => + apiService.grid3D.gridSurface( + ensembleIdent?.getCaseUuid() ?? "", + ensembleIdent?.getEnsembleName() ?? "", + gridName ?? "", + realizationNum ?? 0, + iMin, + iMax - 1, + jMin, + jMax - 1, + kMin, + kMax + ), + staleTime: STALE_TIME, + gcTime: CACHE_TIME, + }) + .then(transformGridSurface); + + return Promise.all([gridSurfacePromise, gridParameterPromise]).then(([gridSurfaceData, gridParameterData]) => ({ + gridSurfaceData, + gridParameterData, + })); + } + + serializeState(): SerializedLayer { + return this._layerDelegate.serializeState(); + } + + deserializeState(serializedState: SerializedLayer): void { + this._layerDelegate.deserializeState(serializedState); + } +} + +LayerRegistry.registerLayer(RealizationGridLayer); diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationGridLayer/types.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationGridLayer/types.ts new file mode 100644 index 000000000..8dbbeb749 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationGridLayer/types.ts @@ -0,0 +1,12 @@ +import { EnsembleIdent } from "@framework/EnsembleIdent"; +import { SettingType } from "@modules/2DViewer/layers/implementations/settings/settingsTypes"; + +export type RealizationGridSettings = { + [SettingType.ENSEMBLE]: EnsembleIdent | null; + [SettingType.REALIZATION]: number | null; + [SettingType.GRID_ATTRIBUTE]: string | null; + [SettingType.GRID_NAME]: string | null; + [SettingType.GRID_LAYER]: number | null; + [SettingType.TIME_OR_INTERVAL]: string | null; + [SettingType.SHOW_GRID_LINES]: boolean; +}; diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationPolygonsLayer/RealizationPolygonsContext.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationPolygonsLayer/RealizationPolygonsContext.ts new file mode 100644 index 000000000..d1a5c73ed --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationPolygonsLayer/RealizationPolygonsContext.ts @@ -0,0 +1,126 @@ +import { PolygonsMeta_api } from "@api"; +import { apiService } from "@framework/ApiService"; +import { LayerManager } from "@modules/2DViewer/layers/LayerManager"; +import { SettingsContextDelegate } from "@modules/2DViewer/layers/delegates/SettingsContextDelegate"; +import { SettingType } from "@modules/2DViewer/layers/implementations/settings/settingsTypes"; +import { CACHE_TIME, STALE_TIME } from "@modules/2DViewer/layers/queryConstants"; +import { cancelPromiseOnAbort } from "@modules/2DViewer/layers/utils"; + +import { RealizationPolygonsSettings } from "./types"; + +import { DefineDependenciesArgs, SettingsContext } from "../../../interfaces"; +import { Ensemble } from "../../settings/Ensemble"; +import { PolygonsAttribute } from "../../settings/PolygonsAttribute"; +import { PolygonsName } from "../../settings/PolygonsName"; +import { Realization } from "../../settings/Realization"; + +export class RealizationPolygonsContext implements SettingsContext { + private _contextDelegate: SettingsContextDelegate; + private _fetchDataCache: PolygonsMeta_api[] | null = null; + + constructor(layerManager: LayerManager) { + this._contextDelegate = new SettingsContextDelegate< + RealizationPolygonsSettings, + keyof RealizationPolygonsSettings + >(this, layerManager, { + [SettingType.ENSEMBLE]: new Ensemble(), + [SettingType.REALIZATION]: new Realization(), + [SettingType.POLYGONS_ATTRIBUTE]: new PolygonsAttribute(), + [SettingType.POLYGONS_NAME]: new PolygonsName(), + }); + } + + getDelegate(): SettingsContextDelegate { + return this._contextDelegate; + } + + getSettings() { + return this._contextDelegate.getSettings(); + } + + defineDependencies({ + helperDependency, + availableSettingsUpdater, + workbenchSession, + queryClient, + }: DefineDependenciesArgs) { + availableSettingsUpdater(SettingType.ENSEMBLE, ({ getGlobalSetting }) => { + const fieldIdentifier = getGlobalSetting("fieldId"); + const ensembles = getGlobalSetting("ensembles"); + + const ensembleIdents = ensembles + .filter((ensemble) => ensemble.getFieldIdentifier() === fieldIdentifier) + .map((ensemble) => ensemble.getIdent()); + + return ensembleIdents; + }); + + availableSettingsUpdater(SettingType.REALIZATION, ({ getLocalSetting, getGlobalSetting }) => { + const ensembleIdent = getLocalSetting(SettingType.ENSEMBLE); + const realizationFilterFunc = getGlobalSetting("realizationFilterFunction"); + + if (!ensembleIdent) { + return []; + } + + const realizations = realizationFilterFunc(ensembleIdent); + + return [...realizations]; + }); + + const realizationPolygonsMetadataDep = helperDependency(async ({ getLocalSetting, abortSignal }) => { + const ensembleIdent = getLocalSetting(SettingType.ENSEMBLE); + + if (!ensembleIdent) { + return null; + } + + return await queryClient.fetchQuery({ + queryKey: ["getRealizationPolygonsMetadata", ensembleIdent], + queryFn: () => + cancelPromiseOnAbort( + apiService.polygons.getPolygonsDirectory( + ensembleIdent.getCaseUuid(), + ensembleIdent.getEnsembleName() + ), + abortSignal + ), + staleTime: STALE_TIME, + gcTime: CACHE_TIME, + }); + }); + + availableSettingsUpdater(SettingType.POLYGONS_ATTRIBUTE, ({ getHelperDependency }) => { + const data = getHelperDependency(realizationPolygonsMetadataDep); + + if (!data) { + return []; + } + + const availableAttributes = [ + ...Array.from(new Set(data.map((polygonsMeta) => polygonsMeta.attribute_name))), + ]; + + return availableAttributes; + }); + + availableSettingsUpdater(SettingType.POLYGONS_NAME, ({ getHelperDependency, getLocalSetting }) => { + const attribute = getLocalSetting(SettingType.POLYGONS_ATTRIBUTE); + const data = getHelperDependency(realizationPolygonsMetadataDep); + + if (!attribute || !data) { + return []; + } + + const availableSurfaceNames = [ + ...Array.from( + new Set( + data.filter((polygonsMeta) => polygonsMeta.attribute_name === attribute).map((el) => el.name) + ) + ), + ]; + + return availableSurfaceNames; + }); + } +} diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationPolygonsLayer/RealizationPolygonsLayer.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationPolygonsLayer/RealizationPolygonsLayer.ts new file mode 100644 index 000000000..7cf7e6753 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationPolygonsLayer/RealizationPolygonsLayer.ts @@ -0,0 +1,124 @@ +import { PolygonData_api } from "@api"; +import { apiService } from "@framework/ApiService"; +import { LayerManager } from "@modules/2DViewer/layers/LayerManager"; +import { LayerRegistry } from "@modules/2DViewer/layers/LayerRegistry"; +import { ItemDelegate } from "@modules/2DViewer/layers/delegates/ItemDelegate"; +import { LayerColoringType, LayerDelegate } from "@modules/2DViewer/layers/delegates/LayerDelegate"; +import { SettingType } from "@modules/2DViewer/layers/implementations/settings/settingsTypes"; +import { CACHE_TIME, STALE_TIME } from "@modules/2DViewer/layers/queryConstants"; +import { QueryClient } from "@tanstack/react-query"; + +import { isEqual } from "lodash"; + +import { RealizationPolygonsContext } from "./RealizationPolygonsContext"; +import { RealizationPolygonsSettings } from "./types"; + +import { BoundingBox, Layer, SerializedLayer } from "../../../interfaces"; + +export class RealizationPolygonsLayer implements Layer { + private _layerDelegate: LayerDelegate; + private _itemDelegate: ItemDelegate; + + constructor(layerManager: LayerManager) { + this._itemDelegate = new ItemDelegate("Realization Polygons", layerManager); + this._layerDelegate = new LayerDelegate( + this, + layerManager, + new RealizationPolygonsContext(layerManager), + LayerColoringType.NONE + ); + } + + getSettingsContext() { + return this._layerDelegate.getSettingsContext(); + } + + getItemDelegate(): ItemDelegate { + return this._itemDelegate; + } + + getLayerDelegate(): LayerDelegate { + return this._layerDelegate; + } + + doSettingsChangesRequireDataRefetch( + prevSettings: RealizationPolygonsSettings, + newSettings: RealizationPolygonsSettings + ): boolean { + return !isEqual(prevSettings, newSettings); + } + + makeBoundingBox(): BoundingBox | null { + const data = this._layerDelegate.getData(); + if (!data) { + return null; + } + + const bbox: BoundingBox = { + x: [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY], + y: [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY], + z: [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY], + }; + + for (const polygon of data) { + for (const point of polygon.x_arr) { + bbox.x[0] = Math.min(bbox.x[0], point); + bbox.x[1] = Math.max(bbox.x[1], point); + } + for (const point of polygon.y_arr) { + bbox.y[0] = Math.min(bbox.y[0], point); + bbox.y[1] = Math.max(bbox.y[1], point); + } + for (const point of polygon.z_arr) { + bbox.z[0] = Math.min(bbox.z[0], point); + bbox.z[1] = Math.max(bbox.z[1], point); + } + } + + return bbox; + } + + fechData(queryClient: QueryClient): Promise { + const settings = this.getSettingsContext().getDelegate().getSettings(); + const ensembleIdent = settings[SettingType.ENSEMBLE].getDelegate().getValue(); + const realizationNum = settings[SettingType.REALIZATION].getDelegate().getValue(); + const polygonsName = settings[SettingType.POLYGONS_NAME].getDelegate().getValue(); + const polygonsAttribute = settings[SettingType.POLYGONS_ATTRIBUTE].getDelegate().getValue(); + + const queryKey = [ + "getPolygonsData", + ensembleIdent?.getCaseUuid() ?? "", + ensembleIdent?.getEnsembleName() ?? "", + realizationNum ?? 0, + polygonsName ?? "", + polygonsAttribute ?? "", + ]; + this._layerDelegate.registerQueryKey(queryKey); + + const promise = queryClient.fetchQuery({ + queryKey, + queryFn: () => + apiService.polygons.getPolygonsData( + ensembleIdent?.getCaseUuid() ?? "", + ensembleIdent?.getEnsembleName() ?? "", + realizationNum ?? 0, + polygonsName ?? "", + polygonsAttribute ?? "" + ), + staleTime: STALE_TIME, + gcTime: CACHE_TIME, + }); + + return promise; + } + + serializeState(): SerializedLayer { + return this._layerDelegate.serializeState(); + } + + deserializeState(serializedState: SerializedLayer): void { + this._layerDelegate.deserializeState(serializedState); + } +} + +LayerRegistry.registerLayer(RealizationPolygonsLayer); diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationPolygonsLayer/types.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationPolygonsLayer/types.ts new file mode 100644 index 000000000..cc5807a1e --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationPolygonsLayer/types.ts @@ -0,0 +1,10 @@ +import { EnsembleIdent } from "@framework/EnsembleIdent"; + +import { SettingType } from "../../settings/settingsTypes"; + +export type RealizationPolygonsSettings = { + [SettingType.ENSEMBLE]: EnsembleIdent | null; + [SettingType.REALIZATION]: number | null; + [SettingType.POLYGONS_ATTRIBUTE]: string | null; + [SettingType.POLYGONS_NAME]: string | null; +}; diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationSurfaceLayer/RealizationSurfaceContext.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationSurfaceLayer/RealizationSurfaceContext.ts new file mode 100644 index 000000000..19cbdc478 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationSurfaceLayer/RealizationSurfaceContext.ts @@ -0,0 +1,159 @@ +import { SurfaceTimeType_api } from "@api"; +import { apiService } from "@framework/ApiService"; +import { LayerManager } from "@modules/2DViewer/layers/LayerManager"; +import { SettingsContextDelegate } from "@modules/2DViewer/layers/delegates/SettingsContextDelegate"; +import { SettingType } from "@modules/2DViewer/layers/implementations/settings/settingsTypes"; +import { CACHE_TIME, STALE_TIME } from "@modules/2DViewer/layers/queryConstants"; +import { cancelPromiseOnAbort } from "@modules/2DViewer/layers/utils"; + +import { RealizationSurfaceSettings } from "./types"; + +import { DefineDependenciesArgs, SettingsContext } from "../../../interfaces"; +import { Ensemble } from "../../settings/Ensemble"; +import { Realization } from "../../settings/Realization"; +import { SurfaceAttribute } from "../../settings/SurfaceAttribute"; +import { SurfaceName } from "../../settings/SurfaceName"; +import { TimeOrInterval } from "../../settings/TimeOrInterval"; + +export class RealizationSurfaceContext implements SettingsContext { + private _contextDelegate: SettingsContextDelegate; + + constructor(layerManager: LayerManager) { + this._contextDelegate = new SettingsContextDelegate< + RealizationSurfaceSettings, + keyof RealizationSurfaceSettings + >(this, layerManager, { + [SettingType.ENSEMBLE]: new Ensemble(), + [SettingType.REALIZATION]: new Realization(), + [SettingType.SURFACE_ATTRIBUTE]: new SurfaceAttribute(), + [SettingType.SURFACE_NAME]: new SurfaceName(), + [SettingType.TIME_OR_INTERVAL]: new TimeOrInterval(), + }); + } + + getDelegate(): SettingsContextDelegate { + return this._contextDelegate; + } + + getSettings() { + return this._contextDelegate.getSettings(); + } + + defineDependencies({ + helperDependency, + availableSettingsUpdater, + queryClient, + }: DefineDependenciesArgs) { + availableSettingsUpdater(SettingType.ENSEMBLE, ({ getGlobalSetting }) => { + const fieldIdentifier = getGlobalSetting("fieldId"); + const ensembles = getGlobalSetting("ensembles"); + + const ensembleIdents = ensembles + .filter((ensemble) => ensemble.getFieldIdentifier() === fieldIdentifier) + .map((ensemble) => ensemble.getIdent()); + + return ensembleIdents; + }); + + availableSettingsUpdater(SettingType.REALIZATION, ({ getLocalSetting, getGlobalSetting }) => { + const ensembleIdent = getLocalSetting(SettingType.ENSEMBLE); + const realizationFilterFunc = getGlobalSetting("realizationFilterFunction"); + + if (!ensembleIdent) { + return []; + } + + const realizations = realizationFilterFunc(ensembleIdent); + + return [...realizations]; + }); + + const realizationSurfaceMetadataDep = helperDependency(async ({ getLocalSetting, abortSignal }) => { + const ensembleIdent = getLocalSetting(SettingType.ENSEMBLE); + + if (!ensembleIdent) { + return null; + } + + return await queryClient.fetchQuery({ + queryKey: ["getRealizationSurfacesMetadata", ensembleIdent], + queryFn: () => + cancelPromiseOnAbort( + apiService.surface.getRealizationSurfacesMetadata( + ensembleIdent.getCaseUuid(), + ensembleIdent.getEnsembleName() + ), + abortSignal + ), + staleTime: STALE_TIME, + gcTime: CACHE_TIME, + }); + }); + + availableSettingsUpdater(SettingType.SURFACE_ATTRIBUTE, ({ getHelperDependency }) => { + const data = getHelperDependency(realizationSurfaceMetadataDep); + + if (!data) { + return []; + } + + const availableAttributes = [ + ...Array.from(new Set(data.surfaces.map((surface) => surface.attribute_name))), + ]; + + return availableAttributes; + }); + + availableSettingsUpdater(SettingType.SURFACE_NAME, ({ getHelperDependency, getLocalSetting }) => { + const attribute = getLocalSetting(SettingType.SURFACE_ATTRIBUTE); + const data = getHelperDependency(realizationSurfaceMetadataDep); + + if (!attribute || !data) { + return []; + } + + const availableSurfaceNames = [ + ...Array.from( + new Set( + data.surfaces.filter((surface) => surface.attribute_name === attribute).map((el) => el.name) + ) + ), + ]; + + return availableSurfaceNames; + }); + + availableSettingsUpdater(SettingType.TIME_OR_INTERVAL, ({ getLocalSetting, getHelperDependency }) => { + const attribute = getLocalSetting(SettingType.SURFACE_ATTRIBUTE); + const surfaceName = getLocalSetting(SettingType.SURFACE_NAME); + const data = getHelperDependency(realizationSurfaceMetadataDep); + + if (!attribute || !surfaceName || !data) { + return []; + } + + const availableTimeOrIntervals: string[] = []; + const availableTimeTypes = [ + ...Array.from( + new Set( + data.surfaces + .filter((surface) => surface.attribute_name === attribute && surface.name === surfaceName) + .map((el) => el.time_type) + ) + ), + ]; + + if (availableTimeTypes.includes(SurfaceTimeType_api.NO_TIME)) { + availableTimeOrIntervals.push(SurfaceTimeType_api.NO_TIME); + } + if (availableTimeTypes.includes(SurfaceTimeType_api.TIME_POINT)) { + availableTimeOrIntervals.push(...data.time_points_iso_str); + } + if (availableTimeTypes.includes(SurfaceTimeType_api.INTERVAL)) { + availableTimeOrIntervals.push(...data.time_intervals_iso_str); + } + + return availableTimeOrIntervals; + }); + } +} diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationSurfaceLayer/RealizationSurfaceLayer.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationSurfaceLayer/RealizationSurfaceLayer.ts new file mode 100644 index 000000000..3dec51ed0 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationSurfaceLayer/RealizationSurfaceLayer.ts @@ -0,0 +1,129 @@ +import { SurfaceDataPng_api, SurfaceTimeType_api } from "@api"; +import { apiService } from "@framework/ApiService"; +import { LayerManager } from "@modules/2DViewer/layers/LayerManager"; +import { LayerRegistry } from "@modules/2DViewer/layers/LayerRegistry"; +import { ItemDelegate } from "@modules/2DViewer/layers/delegates/ItemDelegate"; +import { LayerColoringType, LayerDelegate } from "@modules/2DViewer/layers/delegates/LayerDelegate"; +import { SettingType } from "@modules/2DViewer/layers/implementations/settings/settingsTypes"; +import { CACHE_TIME, STALE_TIME } from "@modules/2DViewer/layers/queryConstants"; +import { FullSurfaceAddress, SurfaceAddressBuilder } from "@modules/_shared/Surface"; +import { SurfaceDataFloat_trans, transformSurfaceData } from "@modules/_shared/Surface/queryDataTransforms"; +import { encodeSurfAddrStr } from "@modules/_shared/Surface/surfaceAddress"; +import { QueryClient } from "@tanstack/react-query"; + +import { isEqual } from "lodash"; + +import { RealizationSurfaceContext } from "./RealizationSurfaceContext"; +import { RealizationSurfaceSettings } from "./types"; + +import { BoundingBox, Layer, SerializedLayer } from "../../../interfaces"; + +export class RealizationSurfaceLayer + implements Layer +{ + private _layerDelegate: LayerDelegate; + private _itemDelegate: ItemDelegate; + + constructor(layerManager: LayerManager) { + this._itemDelegate = new ItemDelegate("Realization Surface", layerManager); + this._layerDelegate = new LayerDelegate( + this, + layerManager, + new RealizationSurfaceContext(layerManager), + LayerColoringType.COLORSCALE + ); + } + + getSettingsContext() { + return this._layerDelegate.getSettingsContext(); + } + + getItemDelegate(): ItemDelegate { + return this._itemDelegate; + } + + getLayerDelegate(): LayerDelegate { + return this._layerDelegate; + } + + doSettingsChangesRequireDataRefetch( + prevSettings: RealizationSurfaceSettings, + newSettings: RealizationSurfaceSettings + ): boolean { + return !isEqual(prevSettings, newSettings); + } + + makeBoundingBox(): BoundingBox | null { + const data = this._layerDelegate.getData(); + if (!data) { + return null; + } + + return { + x: [data.transformed_bbox_utm.min_x, data.transformed_bbox_utm.max_x], + y: [data.transformed_bbox_utm.min_y, data.transformed_bbox_utm.max_y], + z: [0, 0], + }; + } + + makeValueRange(): [number, number] | null { + const data = this._layerDelegate.getData(); + if (!data) { + return null; + } + + return [data.value_min, data.value_max]; + } + + fechData(queryClient: QueryClient): Promise { + let surfaceAddress: FullSurfaceAddress | null = null; + const addrBuilder = new SurfaceAddressBuilder(); + + const settings = this.getSettingsContext().getDelegate().getSettings(); + const ensembleIdent = settings[SettingType.ENSEMBLE].getDelegate().getValue(); + const realizationNum = settings[SettingType.REALIZATION].getDelegate().getValue(); + const surfaceName = settings[SettingType.SURFACE_NAME].getDelegate().getValue(); + const attribute = settings[SettingType.SURFACE_ATTRIBUTE].getDelegate().getValue(); + const timeOrInterval = settings[SettingType.TIME_OR_INTERVAL].getDelegate().getValue(); + + if (ensembleIdent && surfaceName && attribute && realizationNum !== null) { + addrBuilder.withEnsembleIdent(ensembleIdent); + addrBuilder.withName(surfaceName); + addrBuilder.withAttribute(attribute); + addrBuilder.withRealization(realizationNum); + + if (timeOrInterval !== SurfaceTimeType_api.NO_TIME) { + addrBuilder.withTimeOrInterval(timeOrInterval); + } + + surfaceAddress = addrBuilder.buildRealizationAddress(); + } + + const surfAddrStr = surfaceAddress ? encodeSurfAddrStr(surfaceAddress) : null; + + const queryKey = ["getSurfaceData", surfAddrStr, null, "png"]; + + this._layerDelegate.registerQueryKey(queryKey); + + const promise = queryClient + .fetchQuery({ + queryKey, + queryFn: () => apiService.surface.getSurfaceData(surfAddrStr ?? "", "png", null), + staleTime: STALE_TIME, + gcTime: CACHE_TIME, + }) + .then((data) => transformSurfaceData(data)); + + return promise; + } + + serializeState(): SerializedLayer { + return this._layerDelegate.serializeState(); + } + + deserializeState(serializedState: SerializedLayer): void { + this._layerDelegate.deserializeState(serializedState); + } +} + +LayerRegistry.registerLayer(RealizationSurfaceLayer); diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationSurfaceLayer/types.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationSurfaceLayer/types.ts new file mode 100644 index 000000000..4eb3e2c98 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/RealizationSurfaceLayer/types.ts @@ -0,0 +1,11 @@ +import { EnsembleIdent } from "@framework/EnsembleIdent"; + +import { SettingType } from "../../settings/settingsTypes"; + +export type RealizationSurfaceSettings = { + [SettingType.ENSEMBLE]: EnsembleIdent | null; + [SettingType.REALIZATION]: number | null; + [SettingType.SURFACE_ATTRIBUTE]: string | null; + [SettingType.SURFACE_NAME]: string | null; + [SettingType.TIME_OR_INTERVAL]: string | null; +}; diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/StatisticalSurfaceLayer/StatisticalSurfaceContext.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/StatisticalSurfaceLayer/StatisticalSurfaceContext.ts new file mode 100644 index 000000000..22832de4b --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/StatisticalSurfaceLayer/StatisticalSurfaceContext.ts @@ -0,0 +1,173 @@ +import { SurfaceStatisticFunction_api, SurfaceTimeType_api } from "@api"; +import { apiService } from "@framework/ApiService"; +import { LayerManager } from "@modules/2DViewer/layers/LayerManager"; +import { CACHE_TIME, STALE_TIME } from "@modules/2DViewer/layers/queryConstants"; +import { cancelPromiseOnAbort } from "@modules/2DViewer/layers/utils"; + +import { StatisticalSurfaceSettings } from "./types"; + +import { SettingsContextDelegate } from "../../../delegates/SettingsContextDelegate"; +import { DefineDependenciesArgs, SettingsContext } from "../../../interfaces"; +import { Ensemble } from "../../settings/Ensemble"; +import { Sensitivity, SensitivityNameCasePair } from "../../settings/Sensitivity"; +import { StatisticFunction } from "../../settings/StatisticFunction"; +import { SurfaceAttribute } from "../../settings/SurfaceAttribute"; +import { SurfaceName } from "../../settings/SurfaceName"; +import { TimeOrInterval } from "../../settings/TimeOrInterval"; +import { SettingType } from "../../settings/settingsTypes"; + +export class StatisticalSurfaceContext implements SettingsContext { + private _contextDelegate: SettingsContextDelegate; + + constructor(layerManager: LayerManager) { + this._contextDelegate = new SettingsContextDelegate< + StatisticalSurfaceSettings, + keyof StatisticalSurfaceSettings + >(this, layerManager, { + [SettingType.ENSEMBLE]: new Ensemble(), + [SettingType.STATISTIC_FUNCTION]: new StatisticFunction(), + [SettingType.SENSITIVITY]: new Sensitivity(), + [SettingType.SURFACE_ATTRIBUTE]: new SurfaceAttribute(), + [SettingType.SURFACE_NAME]: new SurfaceName(), + [SettingType.TIME_OR_INTERVAL]: new TimeOrInterval(), + }); + } + + getDelegate(): SettingsContextDelegate { + return this._contextDelegate; + } + + getSettings() { + return this._contextDelegate.getSettings(); + } + + defineDependencies({ + helperDependency, + availableSettingsUpdater, + workbenchSession, + queryClient, + }: DefineDependenciesArgs) { + availableSettingsUpdater(SettingType.STATISTIC_FUNCTION, () => Object.values(SurfaceStatisticFunction_api)); + availableSettingsUpdater(SettingType.ENSEMBLE, ({ getGlobalSetting }) => { + const fieldIdentifier = getGlobalSetting("fieldId"); + const ensembles = getGlobalSetting("ensembles"); + + const ensembleIdents = ensembles + .filter((ensemble) => ensemble.getFieldIdentifier() === fieldIdentifier) + .map((ensemble) => ensemble.getIdent()); + + return ensembleIdents; + }); + availableSettingsUpdater(SettingType.SENSITIVITY, ({ getLocalSetting }) => { + const ensembleIdent = getLocalSetting(SettingType.ENSEMBLE); + + if (!ensembleIdent) { + return []; + } + + const ensembleSet = workbenchSession.getEnsembleSet(); + const currentEnsemble = ensembleSet.findEnsemble(ensembleIdent); + const sensitivities = currentEnsemble?.getSensitivities()?.getSensitivityArr() ?? []; + if (sensitivities.length === 0) { + return []; + } + const availableSensitivityPairs: SensitivityNameCasePair[] = []; + sensitivities.map((sensitivity) => + sensitivity.cases.map((sensitivityCase) => { + availableSensitivityPairs.push({ + sensitivityName: sensitivity.name, + sensitivityCase: sensitivityCase.name, + }); + }) + ); + return availableSensitivityPairs; + }); + + const surfaceMetadataDep = helperDependency(async ({ getLocalSetting, abortSignal }) => { + const ensembleIdent = getLocalSetting(SettingType.ENSEMBLE); + + if (!ensembleIdent) { + return null; + } + + return await queryClient.fetchQuery({ + queryKey: ["getRealizationSurfacesMetadata", ensembleIdent], + queryFn: () => + cancelPromiseOnAbort( + apiService.surface.getRealizationSurfacesMetadata( + ensembleIdent.getCaseUuid(), + ensembleIdent.getEnsembleName() + ), + abortSignal + ), + staleTime: STALE_TIME, + gcTime: CACHE_TIME, + }); + }); + + availableSettingsUpdater(SettingType.SURFACE_ATTRIBUTE, ({ getHelperDependency }) => { + const data = getHelperDependency(surfaceMetadataDep); + + if (!data) { + return []; + } + + const availableAttributes = [ + ...Array.from(new Set(data.surfaces.map((surface) => surface.attribute_name))), + ]; + + return availableAttributes; + }); + availableSettingsUpdater(SettingType.SURFACE_NAME, ({ getHelperDependency, getLocalSetting }) => { + const attribute = getLocalSetting(SettingType.SURFACE_ATTRIBUTE); + const data = getHelperDependency(surfaceMetadataDep); + + if (!attribute || !data) { + return []; + } + + const availableSurfaceNames = [ + ...Array.from( + new Set( + data.surfaces.filter((surface) => surface.attribute_name === attribute).map((el) => el.name) + ) + ), + ]; + + return availableSurfaceNames; + }); + + availableSettingsUpdater(SettingType.TIME_OR_INTERVAL, ({ getLocalSetting, getHelperDependency }) => { + const attribute = getLocalSetting(SettingType.SURFACE_ATTRIBUTE); + const surfaceName = getLocalSetting(SettingType.SURFACE_NAME); + const data = getHelperDependency(surfaceMetadataDep); + + if (!attribute || !surfaceName || !data) { + return []; + } + + const availableTimeOrIntervals: string[] = []; + const availableTimeTypes = [ + ...Array.from( + new Set( + data.surfaces + .filter((surface) => surface.attribute_name === attribute && surface.name === surfaceName) + .map((el) => el.time_type) + ) + ), + ]; + + if (availableTimeTypes.includes(SurfaceTimeType_api.NO_TIME)) { + availableTimeOrIntervals.push(SurfaceTimeType_api.NO_TIME); + } + if (availableTimeTypes.includes(SurfaceTimeType_api.TIME_POINT)) { + availableTimeOrIntervals.push(...data.time_points_iso_str); + } + if (availableTimeTypes.includes(SurfaceTimeType_api.INTERVAL)) { + availableTimeOrIntervals.push(...data.time_intervals_iso_str); + } + + return availableTimeOrIntervals; + }); + } +} diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/StatisticalSurfaceLayer/StatisticalSurfaceLayer.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/StatisticalSurfaceLayer/StatisticalSurfaceLayer.ts new file mode 100644 index 000000000..a06c7d16a --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/StatisticalSurfaceLayer/StatisticalSurfaceLayer.ts @@ -0,0 +1,155 @@ +import { SurfaceDataPng_api, SurfaceTimeType_api } from "@api"; +import { apiService } from "@framework/ApiService"; +import { LayerManager } from "@modules/2DViewer/layers/LayerManager"; +import { LayerRegistry } from "@modules/2DViewer/layers/LayerRegistry"; +import { ItemDelegate } from "@modules/2DViewer/layers/delegates/ItemDelegate"; +import { LayerColoringType, LayerDelegate } from "@modules/2DViewer/layers/delegates/LayerDelegate"; +import { SettingType } from "@modules/2DViewer/layers/implementations/settings/settingsTypes"; +import { CACHE_TIME, STALE_TIME } from "@modules/2DViewer/layers/queryConstants"; +import { FullSurfaceAddress, SurfaceAddressBuilder } from "@modules/_shared/Surface"; +import { SurfaceDataFloat_trans, transformSurfaceData } from "@modules/_shared/Surface/queryDataTransforms"; +import { encodeSurfAddrStr } from "@modules/_shared/Surface/surfaceAddress"; +import { QueryClient } from "@tanstack/react-query"; + +import { isEqual } from "lodash"; + +import { StatisticalSurfaceContext } from "./StatisticalSurfaceContext"; +import { StatisticalSurfaceSettings } from "./types"; + +import { BoundingBox, Layer, SerializedLayer } from "../../../interfaces"; + +export class StatisticalSurfaceLayer + implements Layer +{ + private _itemDelegate: ItemDelegate; + private _layerDelegate: LayerDelegate; + + constructor(layerManager: LayerManager) { + this._itemDelegate = new ItemDelegate("Statistical Surface", layerManager); + this._layerDelegate = new LayerDelegate( + this, + layerManager, + new StatisticalSurfaceContext(layerManager), + LayerColoringType.COLORSCALE + ); + } + + getSettingsContext() { + return this._layerDelegate.getSettingsContext(); + } + + getItemDelegate(): ItemDelegate { + return this._itemDelegate; + } + + getLayerDelegate(): LayerDelegate { + return this._layerDelegate; + } + + doSettingsChangesRequireDataRefetch( + prevSettings: StatisticalSurfaceSettings, + newSettings: StatisticalSurfaceSettings + ): boolean { + return !isEqual(prevSettings, newSettings); + } + + makeBoundingBox(): BoundingBox | null { + const data = this._layerDelegate.getData(); + if (!data) { + return null; + } + + return { + x: [data.transformed_bbox_utm.min_x, data.transformed_bbox_utm.max_x], + y: [data.transformed_bbox_utm.min_y, data.transformed_bbox_utm.max_y], + z: [0, 0], + }; + } + + makeValueRange(): [number, number] | null { + const data = this._layerDelegate.getData(); + if (!data) { + return null; + } + + return [data.value_min, data.value_max]; + } + + fechData(queryClient: QueryClient): Promise { + let surfaceAddress: FullSurfaceAddress | null = null; + const addrBuilder = new SurfaceAddressBuilder(); + const workbenchSession = this.getLayerDelegate().getLayerManager().getWorkbenchSession(); + const settings = this.getSettingsContext().getDelegate().getSettings(); + const ensembleIdent = settings[SettingType.ENSEMBLE].getDelegate().getValue(); + const surfaceName = settings[SettingType.SURFACE_NAME].getDelegate().getValue(); + const attribute = settings[SettingType.SURFACE_ATTRIBUTE].getDelegate().getValue(); + const timeOrInterval = settings[SettingType.TIME_OR_INTERVAL].getDelegate().getValue(); + const statisticFunction = settings[SettingType.STATISTIC_FUNCTION].getDelegate().getValue(); + const sensitivityNameCasePair = settings[SettingType.SENSITIVITY].getDelegate().getValue(); + + if (ensembleIdent && surfaceName && attribute) { + addrBuilder.withEnsembleIdent(ensembleIdent); + addrBuilder.withName(surfaceName); + addrBuilder.withAttribute(attribute); + + // Get filtered realizations from workbench + let filteredRealizations = workbenchSession + .getRealizationFilterSet() + .getRealizationFilterForEnsembleIdent(ensembleIdent) + .getFilteredRealizations(); + const currentEnsemble = workbenchSession.getEnsembleSet().findEnsemble(ensembleIdent); + + // If sensitivity is set, filter realizations further to only include the realizations that are in the sensitivity + if (sensitivityNameCasePair) { + const sensitivity = currentEnsemble + ?.getSensitivities() + ?.getCaseByName(sensitivityNameCasePair.sensitivityName, sensitivityNameCasePair.sensitivityCase); + + const sensitivityRealizations = sensitivity?.realizations ?? []; + + filteredRealizations = filteredRealizations.filter((realization) => + sensitivityRealizations.includes(realization) + ); + } + + // If realizations are filtered, update the address + const allRealizations = currentEnsemble?.getRealizations() ?? []; + if (!isEqual([...allRealizations], [...filteredRealizations])) { + addrBuilder.withStatisticRealizations([...filteredRealizations]); + } + + if (timeOrInterval !== SurfaceTimeType_api.NO_TIME) { + addrBuilder.withTimeOrInterval(timeOrInterval); + } + addrBuilder.withStatisticFunction(statisticFunction); + surfaceAddress = addrBuilder.buildStatisticalAddress(); + } + + const surfAddrStr = surfaceAddress ? encodeSurfAddrStr(surfaceAddress) : null; + + const queryKey = ["getSurfaceData", surfAddrStr, null, "png"]; + + this._layerDelegate.registerQueryKey(queryKey); + + const promise = queryClient + .fetchQuery({ + queryKey, + queryFn: () => apiService.surface.getSurfaceData(surfAddrStr ?? "", "png", null), + staleTime: STALE_TIME, + gcTime: CACHE_TIME, + }) + .then((data) => transformSurfaceData(data)); + + return promise; + } + + serializeState(): SerializedLayer { + return this._layerDelegate.serializeState(); + } + + deserializeState(serializedState: SerializedLayer): void { + this._layerDelegate.deserializeState(serializedState); + } +} + +LayerRegistry.registerLayer(StatisticalSurfaceLayer); diff --git a/frontend/src/modules/2DViewer/layers/implementations/layers/StatisticalSurfaceLayer/types.ts b/frontend/src/modules/2DViewer/layers/implementations/layers/StatisticalSurfaceLayer/types.ts new file mode 100644 index 000000000..22bb5154f --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/layers/StatisticalSurfaceLayer/types.ts @@ -0,0 +1,14 @@ +import { SurfaceStatisticFunction_api } from "@api"; +import { EnsembleIdent } from "@framework/EnsembleIdent"; +import { SettingType } from "@modules/2DViewer/layers/implementations/settings/settingsTypes"; + +import { SensitivityNameCasePair } from "../../settings/Sensitivity"; + +export type StatisticalSurfaceSettings = { + [SettingType.ENSEMBLE]: EnsembleIdent | null; + [SettingType.STATISTIC_FUNCTION]: SurfaceStatisticFunction_api; + [SettingType.SENSITIVITY]: SensitivityNameCasePair | null; + [SettingType.SURFACE_ATTRIBUTE]: string | null; + [SettingType.SURFACE_NAME]: string | null; + [SettingType.TIME_OR_INTERVAL]: string | null; +}; diff --git a/frontend/src/modules/2DViewer/layers/implementations/settings/DrilledWellbores.tsx b/frontend/src/modules/2DViewer/layers/implementations/settings/DrilledWellbores.tsx new file mode 100644 index 000000000..2f69789f5 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/settings/DrilledWellbores.tsx @@ -0,0 +1,135 @@ +import React from "react"; + +import { WellboreHeader_api } from "@api"; +import { DenseIconButton } from "@lib/components/DenseIconButton"; +import { Select, SelectOption } from "@lib/components/Select"; +import { Deselect, SelectAll } from "@mui/icons-material"; + +import { SettingType } from "./settingsTypes"; + +import { SettingRegistry } from "../../SettingRegistry"; +import { SettingDelegate } from "../../delegates/SettingDelegate"; +import { AvailableValuesType, Setting, SettingComponentProps } from "../../interfaces"; + +type ValueType = WellboreHeader_api[] | null; + +export class DrilledWellbores implements Setting { + private _delegate: SettingDelegate; + + constructor() { + this._delegate = new SettingDelegate(null, this); + } + + getType(): SettingType { + return SettingType.SMDA_WELLBORE_HEADERS; + } + + getLabel(): string { + return "Drilled wellbores"; + } + + getDelegate(): SettingDelegate { + return this._delegate; + } + + fixupValue(availableValues: AvailableValuesType, currentValue: ValueType): ValueType { + if (!currentValue) { + return availableValues; + } + + const matchingValues = currentValue.filter((value) => + availableValues.some((availableValue) => availableValue.wellboreUuid === value.wellboreUuid) + ); + if (matchingValues.length === 0) { + return availableValues; + } + return matchingValues; + } + + makeComponent(): (props: SettingComponentProps) => React.ReactNode { + return function DrilledWellbores(props: SettingComponentProps) { + const options: SelectOption[] = React.useMemo( + () => + props.availableValues.map((ident) => ({ + value: ident.wellboreUuid, + label: ident.uniqueWellboreIdentifier, + })), + [props.availableValues] + ); + + function handleChange(selectedUuids: string[]) { + const selectedWellbores = props.availableValues.filter((ident) => + selectedUuids.includes(ident.wellboreUuid) + ); + props.onValueChange(selectedWellbores); + } + + function selectAll() { + const allUuids = props.availableValues.map((ident) => ident.wellboreUuid); + handleChange(allUuids); + } + + function selectNone() { + handleChange([]); + } + + const selectedValues = React.useMemo( + () => props.value?.map((ident) => ident.wellboreUuid) ?? [], + [props.value] + ); + + return ( +
+
+ + + Select all + + + + Clear selection + +
+ + ); +} + +SettingRegistry.registerSetting(DrilledWellbores); diff --git a/frontend/src/modules/2DViewer/layers/implementations/settings/Ensemble.tsx b/frontend/src/modules/2DViewer/layers/implementations/settings/Ensemble.tsx new file mode 100644 index 000000000..404033a6c --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/settings/Ensemble.tsx @@ -0,0 +1,67 @@ +import React from "react"; + +import { EnsembleIdent } from "@framework/EnsembleIdent"; +import { EnsembleDropdown } from "@framework/components/EnsembleDropdown"; + +import { SettingType } from "./settingsTypes"; + +import { SettingRegistry } from "../../SettingRegistry"; +import { SettingDelegate } from "../../delegates/SettingDelegate"; +import { Setting, SettingComponentProps, ValueToStringArgs } from "../../interfaces"; + +export class Ensemble implements Setting { + private _delegate: SettingDelegate; + + constructor() { + this._delegate = new SettingDelegate(null, this); + } + + getType(): SettingType { + return SettingType.ENSEMBLE; + } + + getLabel(): string { + return "Ensemble"; + } + + getDelegate(): SettingDelegate { + return this._delegate; + } + + serializeValue(value: EnsembleIdent | null): string { + return value?.toString() ?? ""; + } + + deserializeValue(serializedValue: string): EnsembleIdent | null { + return serializedValue !== "" ? EnsembleIdent.fromString(serializedValue) : null; + } + + makeComponent(): (props: SettingComponentProps) => React.ReactNode { + return function Ensemble(props: SettingComponentProps) { + const ensembles = props.globalSettings.ensembles.filter((ensemble) => + props.availableValues.includes(ensemble.getIdent()) + ); + + return ( + + ); + }; + } + + valueToString(args: ValueToStringArgs): string { + const { value, workbenchSession } = args; + if (value === null) { + return "-"; + } + + return workbenchSession.getEnsembleSet().findEnsemble(value)?.getDisplayName() ?? "-"; + } +} + +SettingRegistry.registerSetting(Ensemble); diff --git a/frontend/src/modules/2DViewer/layers/implementations/settings/GridAttribute.tsx b/frontend/src/modules/2DViewer/layers/implementations/settings/GridAttribute.tsx new file mode 100644 index 000000000..9be760c67 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/settings/GridAttribute.tsx @@ -0,0 +1,54 @@ +import React from "react"; + +import { Dropdown, DropdownOption } from "@lib/components/Dropdown"; + +import { SettingType } from "./settingsTypes"; + +import { SettingRegistry } from "../../SettingRegistry"; +import { SettingDelegate } from "../../delegates/SettingDelegate"; +import { Setting, SettingComponentProps } from "../../interfaces"; + +type ValueType = string | null; + +export class GridAttribute implements Setting { + private _delegate: SettingDelegate; + + constructor() { + this._delegate = new SettingDelegate(null, this); + } + + getType(): SettingType { + return SettingType.GRID_ATTRIBUTE; + } + + getLabel(): string { + return "Grid attribute"; + } + + getDelegate(): SettingDelegate { + return this._delegate; + } + + makeComponent(): (props: SettingComponentProps) => React.ReactNode { + return function Ensemble(props: SettingComponentProps) { + const options: DropdownOption[] = props.availableValues.map((value) => { + return { + value: value.toString(), + label: value === null ? "None" : value.toString(), + }; + }); + + return ( + + ); + }; + } +} + +SettingRegistry.registerSetting(GridAttribute); diff --git a/frontend/src/modules/2DViewer/layers/implementations/settings/GridLayer.tsx b/frontend/src/modules/2DViewer/layers/implementations/settings/GridLayer.tsx new file mode 100644 index 000000000..7c05849d2 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/settings/GridLayer.tsx @@ -0,0 +1,102 @@ +import React from "react"; + +import { Dropdown, DropdownOption } from "@lib/components/Dropdown"; + +import { SettingType } from "./settingsTypes"; + +import { SettingRegistry } from "../../SettingRegistry"; +import { SettingDelegate } from "../../delegates/SettingDelegate"; +import { AvailableValuesType, Setting, SettingComponentProps } from "../../interfaces"; + +type ValueType = number | null; + +export class GridLayer implements Setting { + private _delegate: SettingDelegate; + + constructor() { + this._delegate = new SettingDelegate(null, this); + } + + getType(): SettingType { + return SettingType.GRID_LAYER; + } + + getLabel(): string { + return "Grid layer"; + } + + getDelegate(): SettingDelegate { + return this._delegate; + } + + isValueValid(availableValues: AvailableValuesType, value: ValueType): boolean { + if (value === null) { + return false; + } + + if (availableValues.length < 3) { + return false; + } + + const min = 0; + const max = availableValues[2]; + + if (max === null) { + return false; + } + + return value >= min && value <= max; + } + + fixupValue(availableValues: AvailableValuesType, currentValue: ValueType): ValueType { + if (availableValues.length < 3) { + return null; + } + + const min = 0; + const max = availableValues[2]; + + if (max === null) { + return null; + } + + if (currentValue === null) { + return min; + } + + if (currentValue < min) { + return min; + } + + if (currentValue > max) { + return max; + } + + return currentValue; + } + + makeComponent(): (props: SettingComponentProps) => React.ReactNode { + return function Ensemble(props: SettingComponentProps) { + const kRange = props.availableValues ? Array.from({ length: props.availableValues[2] }, (_, i) => i) : []; + + const options: DropdownOption[] = kRange.map((value) => { + return { + value: value.toString(), + label: value === null ? "None" : value.toString(), + }; + }); + + return ( + props.onValueChange(parseInt(val))} + disabled={props.isOverridden} + showArrows + /> + ); + }; + } +} + +SettingRegistry.registerSetting(GridLayer); diff --git a/frontend/src/modules/2DViewer/layers/implementations/settings/GridName.tsx b/frontend/src/modules/2DViewer/layers/implementations/settings/GridName.tsx new file mode 100644 index 000000000..160da8a10 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/settings/GridName.tsx @@ -0,0 +1,54 @@ +import React from "react"; + +import { Dropdown, DropdownOption } from "@lib/components/Dropdown"; + +import { SettingType } from "./settingsTypes"; + +import { SettingRegistry } from "../../SettingRegistry"; +import { SettingDelegate } from "../../delegates/SettingDelegate"; +import { Setting, SettingComponentProps } from "../../interfaces"; + +type ValueType = string | null; + +export class GridName implements Setting { + private _delegate: SettingDelegate; + + constructor() { + this._delegate = new SettingDelegate(null, this); + } + + getType(): SettingType { + return SettingType.GRID_NAME; + } + + getLabel(): string { + return "Grid name"; + } + + getDelegate(): SettingDelegate { + return this._delegate; + } + + makeComponent(): (props: SettingComponentProps) => React.ReactNode { + return function Ensemble(props: SettingComponentProps) { + const options: DropdownOption[] = props.availableValues.map((value) => { + return { + value: value.toString(), + label: value === null ? "None" : value.toString(), + }; + }); + + return ( + + ); + }; + } +} + +SettingRegistry.registerSetting(GridName); diff --git a/frontend/src/modules/2DViewer/layers/implementations/settings/PolygonsAttribute.tsx b/frontend/src/modules/2DViewer/layers/implementations/settings/PolygonsAttribute.tsx new file mode 100644 index 000000000..43c6617c6 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/settings/PolygonsAttribute.tsx @@ -0,0 +1,54 @@ +import React from "react"; + +import { Dropdown, DropdownOption } from "@lib/components/Dropdown"; + +import { SettingType } from "./settingsTypes"; + +import { SettingRegistry } from "../../SettingRegistry"; +import { SettingDelegate } from "../../delegates/SettingDelegate"; +import { Setting, SettingComponentProps } from "../../interfaces"; + +type ValueType = string | null; + +export class PolygonsAttribute implements Setting { + private _delegate: SettingDelegate; + + constructor() { + this._delegate = new SettingDelegate(null, this); + } + + getType(): SettingType { + return SettingType.POLYGONS_ATTRIBUTE; + } + + getLabel(): string { + return "Polygons attribute"; + } + + getDelegate(): SettingDelegate { + return this._delegate; + } + + makeComponent(): (props: SettingComponentProps) => React.ReactNode { + return function Ensemble(props: SettingComponentProps) { + const options: DropdownOption[] = props.availableValues.map((value) => { + return { + value: value.toString(), + label: value === null ? "None" : value.toString(), + }; + }); + + return ( + + ); + }; + } +} + +SettingRegistry.registerSetting(PolygonsAttribute); diff --git a/frontend/src/modules/2DViewer/layers/implementations/settings/PolygonsName.tsx b/frontend/src/modules/2DViewer/layers/implementations/settings/PolygonsName.tsx new file mode 100644 index 000000000..9bd3f6ff6 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/settings/PolygonsName.tsx @@ -0,0 +1,54 @@ +import React from "react"; + +import { Dropdown, DropdownOption } from "@lib/components/Dropdown"; + +import { SettingType } from "./settingsTypes"; + +import { SettingRegistry } from "../../SettingRegistry"; +import { SettingDelegate } from "../../delegates/SettingDelegate"; +import { Setting, SettingComponentProps } from "../../interfaces"; + +type ValueType = string | null; + +export class PolygonsName implements Setting { + private _delegate: SettingDelegate; + + constructor() { + this._delegate = new SettingDelegate(null, this); + } + + getType(): SettingType { + return SettingType.POLYGONS_NAME; + } + + getLabel(): string { + return "Polygons name"; + } + + getDelegate(): SettingDelegate { + return this._delegate; + } + + makeComponent(): (props: SettingComponentProps) => React.ReactNode { + return function FaultPolygons(props: SettingComponentProps) { + const options: DropdownOption[] = props.availableValues.map((value) => { + return { + value: value.toString(), + label: value === null ? "None" : value.toString(), + }; + }); + + return ( + + ); + }; + } +} + +SettingRegistry.registerSetting(PolygonsName); diff --git a/frontend/src/modules/2DViewer/layers/implementations/settings/Realization.tsx b/frontend/src/modules/2DViewer/layers/implementations/settings/Realization.tsx new file mode 100644 index 000000000..1decc851d --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/settings/Realization.tsx @@ -0,0 +1,56 @@ +import React from "react"; + +import { Dropdown, DropdownOption } from "@lib/components/Dropdown"; + +import { SettingType } from "./settingsTypes"; + +import { SettingRegistry } from "../../SettingRegistry"; +import { SettingDelegate } from "../../delegates/SettingDelegate"; +import { Setting, SettingComponentProps } from "../../interfaces"; + +export class Realization implements Setting { + private _delegate: SettingDelegate; + + constructor() { + this._delegate = new SettingDelegate(null, this); + } + + getType(): SettingType { + return SettingType.REALIZATION; + } + + getLabel(): string { + return "Realization"; + } + + getDelegate(): SettingDelegate { + return this._delegate; + } + + makeComponent(): (props: SettingComponentProps) => React.ReactNode { + return function Realization(props: SettingComponentProps) { + function handleSelectionChange(selectedValue: string) { + const newValue = parseInt(selectedValue); + props.onValueChange(newValue); + } + + const options: DropdownOption[] = props.availableValues.map((value) => { + return { + value: value.toString(), + label: value === null ? "None" : value.toString(), + }; + }); + return ( + + ); + }; + } +} + +SettingRegistry.registerSetting(Realization); diff --git a/frontend/src/modules/2DViewer/layers/implementations/settings/Sensitivity.tsx b/frontend/src/modules/2DViewer/layers/implementations/settings/Sensitivity.tsx new file mode 100644 index 000000000..c45071bac --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/settings/Sensitivity.tsx @@ -0,0 +1,139 @@ +import React from "react"; + +import { Dropdown } from "@lib/components/Dropdown"; + +import { SettingType } from "./settingsTypes"; + +import { SettingRegistry } from "../../SettingRegistry"; +import { SettingDelegate } from "../../delegates/SettingDelegate"; +import { AvailableValuesType, Setting, SettingComponentProps } from "../../interfaces"; + +export type SensitivityNameCasePair = { + sensitivityName: string; + sensitivityCase: string; +}; + +export class Sensitivity implements Setting { + private _delegate: SettingDelegate; + + constructor() { + this._delegate = new SettingDelegate(null, this); + } + + getType(): SettingType { + return SettingType.STATISTIC_FUNCTION; + } + + getLabel(): string { + return "Sensitivity"; + } + + getDelegate(): SettingDelegate { + return this._delegate; + } + + isValueValid( + availableValues: AvailableValuesType, + value: SensitivityNameCasePair | null + ): boolean { + if (availableValues.length === 0) { + return true; + } + if (!value) { + return false; + } + return availableValues + .filter((el) => el !== null) + .some( + (sensitivity) => + sensitivity?.sensitivityName === value.sensitivityName && + sensitivity?.sensitivityCase === value.sensitivityCase + ); + } + + makeComponent(): (props: SettingComponentProps) => React.ReactNode { + return function Sensitivity(props: SettingComponentProps) { + const availableSensitivityNames: string[] = [ + ...Array.from(new Set(props.availableValues.map((sensitivity) => sensitivity.sensitivityName))), + ]; + + const currentSensitivityName = props.value?.sensitivityName; + const availableSensitiveCases = props.availableValues + .filter((sensitivity) => sensitivity.sensitivityName === currentSensitivityName) + .map((sensitivity) => sensitivity.sensitivityCase); + + const currentSensitivityCase = fixupSensitivityCase( + props.value?.sensitivityCase || null, + availableSensitiveCases + ); + + const sensitivityNameOptions = availableSensitivityNames.map((sensitivityName) => ({ + value: sensitivityName, + label: sensitivityName, + })); + + const sensitivityCaseOptions = availableSensitiveCases.map((sensitivityCase) => ({ + value: sensitivityCase, + label: sensitivityCase, + })); + + if (!currentSensitivityName || !currentSensitivityCase) { + props.onValueChange(null); + } else if (currentSensitivityCase !== props.value?.sensitivityCase) { + props.onValueChange({ + sensitivityName: currentSensitivityName, + sensitivityCase: currentSensitivityCase, + }); + } + + function handleSensitivityNameChange(selectedValue: string) { + const availableSensitiveCases = props.availableValues + .filter((sensitivity) => sensitivity.sensitivityName === selectedValue) + .map((sensitivity) => sensitivity.sensitivityCase); + + const currentSensitivityCase = fixupSensitivityCase(null, availableSensitiveCases); + if (!currentSensitivityCase) { + props.onValueChange(null); + } else { + props.onValueChange({ + sensitivityName: selectedValue, + sensitivityCase: currentSensitivityCase, + }); + } + } + function handleSensitivityCaseChange(selectedValue: string) { + props.onValueChange({ + sensitivityName: props.value?.sensitivityName ?? "", + sensitivityCase: selectedValue, + }); + } + if (props.availableValues.length === 0) { + return "No sensitivities available"; + } + return ( +
+ + +
+ ); + }; + } +} + +function fixupSensitivityCase(currentSensitivityCase: string | null, availableSensitiveCases: string[]): string | null { + if (!currentSensitivityCase || !availableSensitiveCases.includes(currentSensitivityCase)) { + return availableSensitiveCases[0] ?? null; + } + + return currentSensitivityCase; +} + +SettingRegistry.registerSetting(Sensitivity as unknown as new () => Setting); diff --git a/frontend/src/modules/2DViewer/layers/implementations/settings/ShowGridLines.tsx b/frontend/src/modules/2DViewer/layers/implementations/settings/ShowGridLines.tsx new file mode 100644 index 000000000..17a9456f3 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/settings/ShowGridLines.tsx @@ -0,0 +1,47 @@ +import React, { ChangeEvent } from "react"; + +import { Switch } from "@lib/components/Switch"; + +import { SettingType } from "./settingsTypes"; + +import { SettingRegistry } from "../../SettingRegistry"; +import { SettingDelegate } from "../../delegates/SettingDelegate"; +import { Setting, SettingComponentProps } from "../../interfaces"; + +type ValueType = boolean; + +export class ShowGridLines implements Setting { + private _delegate: SettingDelegate; + + constructor() { + this._delegate = new SettingDelegate(false, this, true); + } + + getType(): SettingType { + return SettingType.ENSEMBLE; + } + + getLabel(): string { + return "Show grid lines"; + } + + isValueValid(): boolean { + return true; + } + + getDelegate(): SettingDelegate { + return this._delegate; + } + + makeComponent(): (props: SettingComponentProps) => React.ReactNode { + return function ShowGridLines(props: SettingComponentProps) { + function handleChange(e: ChangeEvent) { + props.onValueChange(e.target.checked); + } + + return ; + }; + } +} + +SettingRegistry.registerSetting(ShowGridLines); diff --git a/frontend/src/modules/2DViewer/layers/implementations/settings/StatisticFunction.tsx b/frontend/src/modules/2DViewer/layers/implementations/settings/StatisticFunction.tsx new file mode 100644 index 000000000..029e7544e --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/settings/StatisticFunction.tsx @@ -0,0 +1,60 @@ +import React from "react"; + +import { SurfaceStatisticFunction_api } from "@api"; +import { Dropdown, DropdownOption } from "@lib/components/Dropdown"; + +import { SettingType } from "./settingsTypes"; + +import { SettingRegistry } from "../../SettingRegistry"; +import { SettingDelegate } from "../../delegates/SettingDelegate"; +import { Setting, SettingComponentProps } from "../../interfaces"; + +export class StatisticFunction implements Setting { + private _delegate: SettingDelegate; + + constructor() { + this._delegate = new SettingDelegate(SurfaceStatisticFunction_api.MEAN, this); + } + + getType(): SettingType { + return SettingType.STATISTIC_FUNCTION; + } + + getLabel(): string { + return "Statistic function"; + } + + getDelegate(): SettingDelegate { + return this._delegate; + } + + isValueValid(): boolean { + return true; + } + + makeComponent(): (props: SettingComponentProps) => React.ReactNode { + const itemArr: DropdownOption[] = [ + { value: SurfaceStatisticFunction_api.MEAN, label: "Mean" }, + { value: SurfaceStatisticFunction_api.STD, label: "Std" }, + { value: SurfaceStatisticFunction_api.MIN, label: "Min" }, + { value: SurfaceStatisticFunction_api.MAX, label: "Max" }, + { value: SurfaceStatisticFunction_api.P10, label: "P10" }, + { value: SurfaceStatisticFunction_api.P90, label: "P90" }, + { value: SurfaceStatisticFunction_api.P50, label: "P50" }, + ]; + + return function StatisticFunction(props: SettingComponentProps) { + return ( + props.onValueChange(newVal as SurfaceStatisticFunction_api)} + disabled={props.isOverridden} + showArrows + /> + ); + }; + } +} + +SettingRegistry.registerSetting(StatisticFunction); diff --git a/frontend/src/modules/2DViewer/layers/implementations/settings/SurfaceAttribute.tsx b/frontend/src/modules/2DViewer/layers/implementations/settings/SurfaceAttribute.tsx new file mode 100644 index 000000000..2220606fd --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/settings/SurfaceAttribute.tsx @@ -0,0 +1,54 @@ +import React from "react"; + +import { Dropdown, DropdownOption } from "@lib/components/Dropdown"; + +import { SettingType } from "./settingsTypes"; + +import { SettingRegistry } from "../../SettingRegistry"; +import { SettingDelegate } from "../../delegates/SettingDelegate"; +import { Setting, SettingComponentProps } from "../../interfaces"; + +type ValueType = string | null; + +export class SurfaceAttribute implements Setting { + private _delegate: SettingDelegate; + + constructor() { + this._delegate = new SettingDelegate(null, this); + } + + getType(): SettingType { + return SettingType.SURFACE_ATTRIBUTE; + } + + getLabel(): string { + return "Surface attribute"; + } + + getDelegate(): SettingDelegate { + return this._delegate; + } + + makeComponent(): (props: SettingComponentProps) => React.ReactNode { + return function Ensemble(props: SettingComponentProps) { + const options: DropdownOption[] = props.availableValues.map((value) => { + return { + value: value.toString(), + label: value === null ? "None" : value.toString(), + }; + }); + + return ( + + ); + }; + } +} + +SettingRegistry.registerSetting(SurfaceAttribute); diff --git a/frontend/src/modules/2DViewer/layers/implementations/settings/SurfaceName.tsx b/frontend/src/modules/2DViewer/layers/implementations/settings/SurfaceName.tsx new file mode 100644 index 000000000..63e714a0e --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/settings/SurfaceName.tsx @@ -0,0 +1,54 @@ +import React from "react"; + +import { Dropdown, DropdownOption } from "@lib/components/Dropdown"; + +import { SettingType } from "./settingsTypes"; + +import { SettingRegistry } from "../../SettingRegistry"; +import { SettingDelegate } from "../../delegates/SettingDelegate"; +import { Setting, SettingComponentProps } from "../../interfaces"; + +type ValueType = string | null; + +export class SurfaceName implements Setting { + private _delegate: SettingDelegate; + + constructor() { + this._delegate = new SettingDelegate(null, this); + } + + getType(): SettingType { + return SettingType.SURFACE_NAME; + } + + getLabel(): string { + return "Surface name"; + } + + getDelegate(): SettingDelegate { + return this._delegate; + } + + makeComponent(): (props: SettingComponentProps) => React.ReactNode { + return function Ensemble(props: SettingComponentProps) { + const options: DropdownOption[] = props.availableValues.map((value) => { + return { + value: value.toString(), + label: value === null ? "None" : value.toString(), + }; + }); + + return ( + + ); + }; + } +} + +SettingRegistry.registerSetting(SurfaceName); diff --git a/frontend/src/modules/2DViewer/layers/implementations/settings/TimeOrInterval.tsx b/frontend/src/modules/2DViewer/layers/implementations/settings/TimeOrInterval.tsx new file mode 100644 index 000000000..5096481b2 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/settings/TimeOrInterval.tsx @@ -0,0 +1,84 @@ +import React from "react"; + +import { SurfaceTimeType_api } from "@api"; +import { Dropdown, DropdownOption } from "@lib/components/Dropdown"; + +import { SettingType } from "./settingsTypes"; + +import { SettingRegistry } from "../../SettingRegistry"; +import { SettingDelegate } from "../../delegates/SettingDelegate"; +import { Setting, SettingComponentProps, ValueToStringArgs } from "../../interfaces"; + +type ValueType = string | null; + +export class TimeOrInterval implements Setting { + private _delegate: SettingDelegate; + + constructor() { + this._delegate = new SettingDelegate(null, this); + } + + getType(): SettingType { + return SettingType.TIME_OR_INTERVAL; + } + + getLabel(): string { + return "Date"; + } + + getDelegate(): SettingDelegate { + return this._delegate; + } + + makeComponent(): (props: SettingComponentProps) => React.ReactNode { + return function Ensemble(props: SettingComponentProps) { + const options: DropdownOption[] = props.availableValues.map((value) => { + return { + value: value.toString(), + label: timeTypeToLabel(value), + }; + }); + + return ( + + ); + }; + } + + valueToString(args: ValueToStringArgs): string { + const { value } = args; + if (value === null) { + return "-"; + } + return timeTypeToLabel(value); + } +} + +function timeTypeToLabel(input: string): string { + if (input === SurfaceTimeType_api.NO_TIME) { + return "Initial / No date"; + } + const [start, end] = input.split("/"); + if (end) { + return isoIntervalStringToDateLabel(start, end); + } + return isoStringToDateLabel(start); +} +function isoStringToDateLabel(isoDatestring: string): string { + const date = isoDatestring.split("T")[0]; + return `${date}`; +} + +function isoIntervalStringToDateLabel(startIsoDateString: string, endIsoDateString: string): string { + const startDate = startIsoDateString.split("T")[0]; + const endDate = endIsoDateString.split("T")[0]; + return `${startDate}/${endDate}`; +} + +SettingRegistry.registerSetting(TimeOrInterval); diff --git a/frontend/src/modules/2DViewer/layers/implementations/settings/settingsTypes.ts b/frontend/src/modules/2DViewer/layers/implementations/settings/settingsTypes.ts new file mode 100644 index 000000000..f7430c181 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/implementations/settings/settingsTypes.ts @@ -0,0 +1,16 @@ +export enum SettingType { + ENSEMBLE = "ensemble", + REALIZATION = "realization", + STATISTIC_FUNCTION = "statisticFunction", + SENSITIVITY = "sensitivity", + SURFACE_NAME = "surfaceName", + SURFACE_ATTRIBUTE = "surfaceAttribute", + TIME_OR_INTERVAL = "timeOrInterval", + POLYGONS_ATTRIBUTE = "polygonsAttribute", + POLYGONS_NAME = "polygonsName", + SMDA_WELLBORE_HEADERS = "smdaWellboreHeaders", + GRID_NAME = "gridName", + GRID_ATTRIBUTE = "gridAttribute", + GRID_LAYER = "gridLayer", + SHOW_GRID_LINES = "showGridLines", +} diff --git a/frontend/src/modules/2DViewer/layers/interfaces.ts b/frontend/src/modules/2DViewer/layers/interfaces.ts new file mode 100644 index 000000000..1020cea33 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/interfaces.ts @@ -0,0 +1,204 @@ +import { WorkbenchSession } from "@framework/WorkbenchSession"; +import { WorkbenchSettings } from "@framework/WorkbenchSettings"; +import { ColorScaleSerialization } from "@lib/utils/ColorScale"; +import { QueryClient } from "@tanstack/react-query"; + +import { Dependency } from "./Dependency"; +import { GlobalSettings } from "./LayerManager"; +import { GroupDelegate } from "./delegates/GroupDelegate"; +import { ItemDelegate } from "./delegates/ItemDelegate"; +import { LayerDelegate } from "./delegates/LayerDelegate"; +import { SettingDelegate } from "./delegates/SettingDelegate"; +import { SettingsContextDelegate } from "./delegates/SettingsContextDelegate"; +import { SettingType } from "./implementations/settings/settingsTypes"; + +export type SerializedType = + | "layer-manager" + | "view" + | "layer" + | "settings-group" + | "color-scale" + | "delta-surface" + | "shared-setting"; + +export interface SerializedItem { + id: string; + type: SerializedType; + name: string; + expanded: boolean; + visible: boolean; +} + +export type SerializedSettingsState = Record; + +export interface SerializedLayer extends SerializedItem { + type: "layer"; + layerClass: string; + settings: SerializedSettingsState; +} + +export interface SerializedView extends SerializedItem { + type: "view"; + color: string; + children: SerializedItem[]; +} + +export interface SerializedSettingsGroup extends SerializedItem { + type: "settings-group"; + children: SerializedItem[]; +} + +export interface SerializedColorScale extends SerializedItem { + type: "color-scale"; + colorScale: ColorScaleSerialization; + userDefinedBoundaries: boolean; +} + +export interface SerializedSharedSetting extends SerializedItem { + type: "shared-setting"; + settingType: SettingType; + wrappedSettingClass: string; + value: string; +} + +export interface SerializedLayerManager extends SerializedItem { + type: "layer-manager"; + children: SerializedItem[]; +} + +export interface SerializedDeltaSurface extends SerializedItem { + type: "delta-surface"; + children: SerializedItem[]; +} + +export interface Item { + getItemDelegate(): ItemDelegate; + serializeState(): SerializedItem; + deserializeState(serialized: SerializedItem): void; +} + +export function instanceofItem(item: any): item is Item { + return (item as Item).getItemDelegate !== undefined; +} + +export interface Group extends Item { + getGroupDelegate(): GroupDelegate; +} + +export function instanceofGroup(item: Item): item is Group { + return (item as Group).getItemDelegate !== undefined && (item as Group).getGroupDelegate !== undefined; +} + +export type BoundingBox = { + x: [number, number]; + y: [number, number]; + z: [number, number]; +}; + +export enum FetchDataFunctionResult { + SUCCESS = "SUCCESS", + IN_PROGRESS = "IN_PROGRESS", + ERROR = "ERROR", + NO_CHANGE = "NO_CHANGE", +} +export interface FetchDataFunction { + ( + oldValues: { [K in TKey]?: TSettings[K] }, + newValues: { [K in TKey]?: TSettings[K] } + ): Promise; +} + +export interface Layer extends Item { + getLayerDelegate(): LayerDelegate; + doSettingsChangesRequireDataRefetch(prevSettings: TSettings, newSettings: TSettings): boolean; + fechData(queryClient: QueryClient): Promise; + makeBoundingBox?(): BoundingBox | null; + makeValueRange?(): [number, number] | null; +} + +export function instanceofLayer(item: Item): item is Layer { + return ( + (item as Layer).getItemDelegate !== undefined && + (item as Layer).doSettingsChangesRequireDataRefetch !== undefined && + (item as Layer).fechData !== undefined + ); +} + +export interface GetHelperDependency { + (dep: Dependency): Awaited | null; +} + +export interface UpdateFunc { + (args: { + getLocalSetting: (settingName: K) => TSettings[K]; + getGlobalSetting: (settingName: T) => GlobalSettings[T]; + getHelperDependency: GetHelperDependency; + abortSignal: AbortSignal; + }): TReturnValue; +} + +export interface DefineDependenciesArgs { + availableSettingsUpdater: ( + settingName: TKey, + update: UpdateFunc, TSettings, TKey> + ) => Dependency, TSettings, TKey>; + helperDependency: ( + update: (args: { + getLocalSetting: (settingName: T) => TSettings[T]; + getGlobalSetting: (settingName: T) => GlobalSettings[T]; + getHelperDependency: (helperDependency: Dependency) => TDep | null; + abortSignal: AbortSignal; + }) => T + ) => Dependency; + workbenchSession: WorkbenchSession; + workbenchSettings: WorkbenchSettings; + queryClient: QueryClient; +} + +export interface SettingsContext { + getDelegate(): SettingsContextDelegate; + areCurrentSettingsValid?: (settings: TSettings) => boolean; + defineDependencies(args: DefineDependenciesArgs): void; +} + +// Required when making "AvailableValuesType" for all settings in an object ("TSettings") +export type EachAvailableValuesType = T extends any ? AvailableValuesType : never; + +// Returns an array of "TValue" if the "TValue" itself is not already an array +export type AvailableValuesType = RemoveUnknownFromArray>; + +// "MakeArrayIfNotArray" yields "unknown[] | any[]" for "T = any" - we don't want "unknown[]" +type RemoveUnknownFromArray = T extends unknown[] | any[] ? any[] : T; +type MakeArrayIfNotArray = Exclude extends Array ? Array : Array>; + +export type SettingComponentProps = { + onValueChange: (newValue: TValue) => void; + value: TValue; + isValueValid: boolean; + overriddenValue: TValue | null; + isOverridden: boolean; + availableValues: AvailableValuesType; + workbenchSession: WorkbenchSession; + workbenchSettings: WorkbenchSettings; + globalSettings: GlobalSettings; +}; + +export type ValueToStringArgs = { + value: TValue; + workbenchSession: WorkbenchSession; + workbenchSettings: WorkbenchSettings; +}; + +export interface Setting { + getType(): SettingType; + getLabel(): string; + makeComponent(): (props: SettingComponentProps) => React.ReactNode; + getDelegate(): SettingDelegate; + fixupValue?: (availableValues: AvailableValuesType, currentValue: TValue) => TValue; + isValueValid?: (availableValues: AvailableValuesType, value: TValue) => boolean; + serializeValue?: (value: TValue) => string; + deserializeValue?: (serializedValue: string) => TValue; + valueToString?: (args: ValueToStringArgs) => string; +} + +export type Settings = { [key in SettingType]?: any }; diff --git a/frontend/src/modules/2DViewer/layers/queryConstants.ts b/frontend/src/modules/2DViewer/layers/queryConstants.ts new file mode 100644 index 000000000..3acac7d69 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/queryConstants.ts @@ -0,0 +1,2 @@ +export const STALE_TIME = 60 * 1000; +export const CACHE_TIME = 60 * 1000; diff --git a/frontend/src/modules/2DViewer/layers/utils.ts b/frontend/src/modules/2DViewer/layers/utils.ts new file mode 100644 index 000000000..d9ea853b6 --- /dev/null +++ b/frontend/src/modules/2DViewer/layers/utils.ts @@ -0,0 +1,22 @@ +import { CancelablePromise } from "@api"; +import { FetchQueryOptions, QueryClient } from "@tanstack/react-query"; + +export function cancelPromiseOnAbort(promise: CancelablePromise, abortSignal: AbortSignal): Promise { + abortSignal.addEventListener("abort", () => { + console.debug("Promise aborted"); + promise.cancel(); + }); + return promise; +} + +export async function cancelQueryOnAbort( + queryClient: QueryClient, + abortSignal: AbortSignal, + options: FetchQueryOptions +) { + abortSignal.addEventListener("abort", () => { + queryClient.cancelQueries({ queryKey: options.queryKey }); + }); + + return await queryClient.fetchQuery(options); +} diff --git a/frontend/src/modules/2DViewer/loadModule.tsx b/frontend/src/modules/2DViewer/loadModule.tsx new file mode 100644 index 000000000..dfcfa27de --- /dev/null +++ b/frontend/src/modules/2DViewer/loadModule.tsx @@ -0,0 +1,13 @@ +import { ModuleRegistry } from "@framework/ModuleRegistry"; + +import { Interfaces, settingsToViewInterfaceInitialization } from "./interfaces"; +import { MODULE_NAME } from "./registerModule"; +import { Settings } from "./settings/settings"; +import { View } from "./view/view"; + +const module = ModuleRegistry.initModule(MODULE_NAME, { + settingsToViewInterfaceInitialization, +}); + +module.settingsFC = Settings; +module.viewFC = View; diff --git a/frontend/src/modules/2DViewer/preview.tsx b/frontend/src/modules/2DViewer/preview.tsx new file mode 100644 index 000000000..2a9746ab7 --- /dev/null +++ b/frontend/src/modules/2DViewer/preview.tsx @@ -0,0 +1,8 @@ +import { DrawPreviewFunc } from "@framework/Preview"; +import previewImg from "./preview.webp"; + +export const preview: DrawPreviewFunc = function (width: number, height: number) { + return ( + + ); +}; diff --git a/frontend/src/modules/2DViewer/preview.webp b/frontend/src/modules/2DViewer/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..0b82963c8d6bbacf99350d170c51036c1c0e84ae GIT binary patch literal 38828 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 Xp{Yv8e@maqfBgIV-~aQc|MUL={RDJ4 literal 0 HcmV?d00001 diff --git a/frontend/src/modules/2DViewer/registerModule.ts b/frontend/src/modules/2DViewer/registerModule.ts new file mode 100644 index 000000000..2e29217a9 --- /dev/null +++ b/frontend/src/modules/2DViewer/registerModule.ts @@ -0,0 +1,24 @@ +import { ModuleCategory, ModuleDevState } from "@framework/Module"; +import { ModuleDataTagId } from "@framework/ModuleDataTags"; +import { ModuleRegistry } from "@framework/ModuleRegistry"; + +import { Interfaces } from "./interfaces"; +import { preview } from "./preview"; + +export const MODULE_NAME: string = "2DViewer"; + +ModuleRegistry.registerModule({ + moduleName: MODULE_NAME, + category: ModuleCategory.MAIN, + devState: ModuleDevState.DEV, + defaultTitle: "2D Viewer", + preview, + description: "Generic 2D viewer for co-visualization of spatial data.", + dataTagIds: [ + ModuleDataTagId.SURFACE, + ModuleDataTagId.DRILLED_WELLS, + ModuleDataTagId.SEISMIC, + ModuleDataTagId.GRID3D, + ModuleDataTagId.POLYGONS, + ], +}); diff --git a/frontend/src/modules/2DViewer/settings/atoms/baseAtoms.ts b/frontend/src/modules/2DViewer/settings/atoms/baseAtoms.ts new file mode 100644 index 000000000..c67693fd1 --- /dev/null +++ b/frontend/src/modules/2DViewer/settings/atoms/baseAtoms.ts @@ -0,0 +1,8 @@ +import { LayerManager } from "@modules/2DViewer/layers/LayerManager"; +import { PreferredViewLayout } from "@modules/2DViewer/types"; + +import { atom } from "jotai"; + +export const userSelectedFieldIdentifierAtom = atom(null); +export const layerManagerAtom = atom(null); +export const preferredViewLayoutAtom = atom(PreferredViewLayout.VERTICAL); diff --git a/frontend/src/modules/2DViewer/settings/atoms/derivedAtoms.ts b/frontend/src/modules/2DViewer/settings/atoms/derivedAtoms.ts new file mode 100644 index 000000000..7d4115816 --- /dev/null +++ b/frontend/src/modules/2DViewer/settings/atoms/derivedAtoms.ts @@ -0,0 +1,31 @@ +import { EnsembleSet } from "@framework/EnsembleSet"; +import { EnsembleSetAtom } from "@framework/GlobalAtoms"; + +import { atom } from "jotai"; + +import { userSelectedFieldIdentifierAtom } from "./baseAtoms"; + +export const selectedFieldIdentifierAtom = atom((get) => { + const ensembleSet = get(EnsembleSetAtom); + const userSelectedField = get(userSelectedFieldIdentifierAtom); + + if ( + !userSelectedField || + !ensembleSet.getEnsembleArr().some((ens) => ens.getFieldIdentifier() === userSelectedField) + ) { + return ensembleSet.getEnsembleArr().at(0)?.getFieldIdentifier() ?? null; + } + + return userSelectedField; +}); + +export const filteredEnsembleSetAtom = atom((get) => { + const ensembleSet = get(EnsembleSetAtom); + const fieldIdentifier = get(userSelectedFieldIdentifierAtom); + + if (fieldIdentifier === null) { + return ensembleSet; + } + + return new EnsembleSet(ensembleSet.getEnsembleArr().filter((el) => el.getFieldIdentifier() === fieldIdentifier)); +}); diff --git a/frontend/src/modules/2DViewer/settings/components/layerManagerComponent.tsx b/frontend/src/modules/2DViewer/settings/components/layerManagerComponent.tsx new file mode 100644 index 000000000..559bb309f --- /dev/null +++ b/frontend/src/modules/2DViewer/settings/components/layerManagerComponent.tsx @@ -0,0 +1,445 @@ +import React from "react"; + +import { Icon } from "@equinor/eds-core-react"; +import { color_palette, fault, grid_layer, settings, surface_layer, wellbore } from "@equinor/eds-icons"; +import { WorkbenchSession } from "@framework/WorkbenchSession"; +import { WorkbenchSettings } from "@framework/WorkbenchSettings"; +import { Menu } from "@lib/components/Menu"; +import { MenuButton } from "@lib/components/MenuButton"; +import { MenuHeading } from "@lib/components/MenuHeading"; +import { MenuItem } from "@lib/components/MenuItem"; +import { IsMoveAllowedArgs, SortableList } from "@lib/components/SortableList"; +import { useElementSize } from "@lib/hooks/useElementSize"; +import { convertRemToPixels } from "@lib/utils/screenUnitConversions"; +import { ColorScale } from "@modules/2DViewer/layers/ColorScale"; +import { DeltaSurface } from "@modules/2DViewer/layers/DeltaSurface"; +import { LayerManager } from "@modules/2DViewer/layers/LayerManager"; +import { SettingsGroup } from "@modules/2DViewer/layers/SettingsGroup"; +import { SharedSetting } from "@modules/2DViewer/layers/SharedSetting"; +import { View } from "@modules/2DViewer/layers/View"; +import { ExpandCollapseAllButton } from "@modules/2DViewer/layers/components/ExpandCollapseAllButton"; +import { LayersActionGroup, LayersActions } from "@modules/2DViewer/layers/components/LayersActions"; +import { makeComponent } from "@modules/2DViewer/layers/components/utils"; +import { GroupDelegateTopic } from "@modules/2DViewer/layers/delegates/GroupDelegate"; +import { usePublishSubscribeTopicValue } from "@modules/2DViewer/layers/delegates/PublishSubscribeDelegate"; +import { DrilledWellTrajectoriesLayer } from "@modules/2DViewer/layers/implementations/layers/DrilledWellTrajectoriesLayer/DrilledWellTrajectoriesLayer"; +import { DrilledWellborePicksLayer } from "@modules/2DViewer/layers/implementations/layers/DrilledWellborePicksLayer/DrilledWellborePicksLayer"; +import { ObservedSurfaceLayer } from "@modules/2DViewer/layers/implementations/layers/ObservedSurfaceLayer/ObservedSurfaceLayer"; +import { RealizationGridLayer } from "@modules/2DViewer/layers/implementations/layers/RealizationGridLayer/RealizationGridLayer"; +import { RealizationPolygonsLayer } from "@modules/2DViewer/layers/implementations/layers/RealizationPolygonsLayer/RealizationPolygonsLayer"; +import { RealizationSurfaceLayer } from "@modules/2DViewer/layers/implementations/layers/RealizationSurfaceLayer/RealizationSurfaceLayer"; +import { StatisticalSurfaceLayer } from "@modules/2DViewer/layers/implementations/layers/StatisticalSurfaceLayer/StatisticalSurfaceLayer"; +import { Ensemble } from "@modules/2DViewer/layers/implementations/settings/Ensemble"; +import { Realization } from "@modules/2DViewer/layers/implementations/settings/Realization"; +import { SurfaceAttribute } from "@modules/2DViewer/layers/implementations/settings/SurfaceAttribute"; +import { SurfaceName } from "@modules/2DViewer/layers/implementations/settings/SurfaceName"; +import { TimeOrInterval } from "@modules/2DViewer/layers/implementations/settings/TimeOrInterval"; +import { Group, Item, instanceofGroup, instanceofLayer } from "@modules/2DViewer/layers/interfaces"; +import { PreferredViewLayout } from "@modules/2DViewer/types"; +import { Dropdown } from "@mui/base"; +import { + Add, + Check, + Panorama, + SettingsApplications, + Settings as SettingsIcon, + TableRowsOutlined, + ViewColumnOutlined, +} from "@mui/icons-material"; + +import { useAtom } from "jotai"; + +import { preferredViewLayoutAtom } from "../atoms/baseAtoms"; + +export type LayerManagerComponentProps = { + layerManager: LayerManager; + workbenchSession: WorkbenchSession; + workbenchSettings: WorkbenchSettings; +}; + +export function LayerManagerComponent(props: LayerManagerComponentProps): React.ReactNode { + const layerListRef = React.useRef(null); + const colorSet = props.workbenchSettings.useColorSet(); + const layerListSize = useElementSize(layerListRef); + + const [preferredViewLayout, setPreferredViewLayout] = useAtom(preferredViewLayoutAtom); + + const groupDelegate = props.layerManager.getGroupDelegate(); + const items = usePublishSubscribeTopicValue(groupDelegate, GroupDelegateTopic.CHILDREN); + + function handleLayerAction(identifier: string, group?: Group) { + let groupDelegate = props.layerManager.getGroupDelegate(); + if (group) { + groupDelegate = group.getGroupDelegate(); + } + + const numSharedSettings = groupDelegate.findChildren((item) => { + return item instanceof SharedSetting; + }).length; + + const numViews = groupDelegate.getDescendantItems((item) => item instanceof View).length; + + switch (identifier) { + case "view": + groupDelegate.appendChild( + new View(numViews > 0 ? `View (${numViews})` : "View", props.layerManager, colorSet.getNextColor()) + ); + return; + case "delta-surface": + groupDelegate.insertChild(new DeltaSurface("Delta surface", props.layerManager), numSharedSettings); + return; + case "settings-group": + groupDelegate.insertChild(new SettingsGroup("Settings group", props.layerManager), numSharedSettings); + return; + case "color-scale": + groupDelegate.prependChild(new ColorScale("Color scale", props.layerManager)); + return; + case "observed-surface": + groupDelegate.insertChild(new ObservedSurfaceLayer(props.layerManager), numSharedSettings); + return; + case "statistical-surface": + groupDelegate.insertChild(new StatisticalSurfaceLayer(props.layerManager), numSharedSettings); + return; + case "realization-surface": + groupDelegate.insertChild(new RealizationSurfaceLayer(props.layerManager), numSharedSettings); + return; + case "realization-polygons": + groupDelegate.insertChild(new RealizationPolygonsLayer(props.layerManager), numSharedSettings); + return; + case "drilled-wellbore-trajectories": + groupDelegate.insertChild(new DrilledWellTrajectoriesLayer(props.layerManager), numSharedSettings); + return; + case "drilled-wellbore-picks": + groupDelegate.insertChild(new DrilledWellborePicksLayer(props.layerManager), numSharedSettings); + return; + case "realization-grid": + groupDelegate.insertChild(new RealizationGridLayer(props.layerManager), numSharedSettings); + return; + case "ensemble": + groupDelegate.prependChild(new SharedSetting(new Ensemble(), props.layerManager)); + return; + case "realization": + groupDelegate.prependChild(new SharedSetting(new Realization(), props.layerManager)); + return; + case "surface-name": + groupDelegate.prependChild(new SharedSetting(new SurfaceName(), props.layerManager)); + return; + case "surface-attribute": + groupDelegate.prependChild(new SharedSetting(new SurfaceAttribute(), props.layerManager)); + return; + case "Date": + groupDelegate.prependChild(new SharedSetting(new TimeOrInterval(), props.layerManager)); + return; + } + } + + function checkIfItemMoveAllowed(args: IsMoveAllowedArgs): boolean { + const movedItem = groupDelegate.findDescendantById(args.movedItemId); + if (!movedItem) { + return false; + } + + const destinationItem = args.destinationId + ? groupDelegate.findDescendantById(args.destinationId) + : props.layerManager; + + if (!destinationItem || !instanceofGroup(destinationItem)) { + return false; + } + + if (movedItem instanceof View && destinationItem instanceof View) { + return false; + } + + if (destinationItem instanceof DeltaSurface) { + if ( + instanceofLayer(movedItem) && + !( + movedItem instanceof RealizationSurfaceLayer || + movedItem instanceof StatisticalSurfaceLayer || + movedItem instanceof ObservedSurfaceLayer + ) + ) { + return false; + } + + if (instanceofGroup(movedItem)) { + return false; + } + + if (destinationItem.getGroupDelegate().findChildren((item) => instanceofLayer(item)).length >= 2) { + return false; + } + } + + const numSharedSettingsAndColorScales = + destinationItem.getGroupDelegate().findChildren((item) => { + return item instanceof SharedSetting || item instanceof ColorScale; + }).length ?? 0; + + if (!(movedItem instanceof SharedSetting || movedItem instanceof ColorScale)) { + if (args.position < numSharedSettingsAndColorScales) { + return false; + } + } else { + if (args.originId === args.destinationId) { + if (args.position >= numSharedSettingsAndColorScales) { + return false; + } + } else { + if (args.position > numSharedSettingsAndColorScales) { + return false; + } + } + } + + return true; + } + + function handleItemMoved( + movedItemId: string, + originId: string | null, + destinationId: string | null, + position: number + ) { + const movedItem = groupDelegate.findDescendantById(movedItemId); + if (!movedItem) { + return; + } + + let origin = props.layerManager.getGroupDelegate(); + if (originId) { + const candidate = groupDelegate.findDescendantById(originId); + if (candidate && instanceofGroup(candidate)) { + origin = candidate.getGroupDelegate(); + } + } + + let destination = props.layerManager.getGroupDelegate(); + if (destinationId) { + const candidate = groupDelegate.findDescendantById(destinationId); + if (candidate && instanceofGroup(candidate)) { + destination = candidate.getGroupDelegate(); + } + } + + if (origin === destination) { + origin.moveChild(movedItem, position); + return; + } + + origin.removeChild(movedItem); + destination.insertChild(movedItem, position); + } + + const hasView = groupDelegate.getDescendantItems((item) => item instanceof View).length > 0; + const adjustedLayerActions = hasView ? LAYER_ACTIONS : INITIAL_LAYER_ACTIONS; + + return ( +
+
+
+
Layers
+ + + + + + + + Preferred view layout + setPreferredViewLayout(PreferredViewLayout.HORIZONTAL)} + > + Horizontal + + setPreferredViewLayout(PreferredViewLayout.VERTICAL)} + > + Vertical + + + +
+
+ + Click on to add a layer. +
+ } + > + {items.map((item: Item) => makeComponent(item, LAYER_ACTIONS, handleLayerAction))} + +
+
+
+ ); +} + +type ViewLayoutMenuItemProps = { + checked: boolean; + onClick: () => void; + children: React.ReactNode; +}; + +function ViewLayoutMenuItem(props: ViewLayoutMenuItemProps): React.ReactNode { + return ( + +
+
{props.checked && }
+
{props.children}
+
+
+ ); +} + +const INITIAL_LAYER_ACTIONS: LayersActionGroup[] = [ + { + label: "Groups", + children: [ + { + identifier: "view", + icon: , + label: "View", + }, + { + identifier: "settings-group", + icon: , + label: "Settings group", + }, + ], + }, +]; + +const LAYER_ACTIONS: LayersActionGroup[] = [ + { + label: "Groups", + children: [ + { + identifier: "view", + icon: , + label: "View", + }, + { + identifier: "settings-group", + icon: , + label: "Settings group", + }, + /* + { + identifier: "delta-surface", + icon: , + label: "Delta Surface", + }, + */ + ], + }, + { + label: "Layers", + children: [ + { + label: "Surfaces", + children: [ + { + identifier: "observed-surface", + icon: , + label: "Observed Surface", + }, + { + identifier: "statistical-surface", + icon: , + label: "Statistical Surface", + }, + { + identifier: "realization-surface", + icon: , + label: "Realization Surface", + }, + ], + }, + { + label: "Wells", + children: [ + { + identifier: "drilled-wellbore-trajectories", + icon: , + label: "Drilled Wellbore Trajectories", + }, + { + identifier: "drilled-wellbore-picks", + icon: , + label: "Drilled Wellbore Picks", + }, + ], + }, + { + label: "Polygons", + children: [ + { + identifier: "realization-polygons", + icon: , + label: "Realization Polygons", + }, + ], + }, + { + label: "Others", + children: [ + { + identifier: "realization-grid", + icon: , + label: "Realization Grid", + }, + ], + }, + ], + }, + { + label: "Shared Settings", + children: [ + { + identifier: "ensemble", + icon: , + label: "Ensemble", + }, + { + identifier: "realization", + icon: , + label: "Realization", + }, + { + identifier: "surface-name", + icon: , + label: "Surface Name", + }, + { + identifier: "surface-attribute", + icon: , + label: "Surface Attribute", + }, + { + identifier: "Date", + icon: , + label: "Date", + }, + ], + }, + { + label: "Utilities", + children: [ + { + identifier: "color-scale", + icon: , + label: "Color scale", + }, + ], + }, +]; diff --git a/frontend/src/modules/2DViewer/settings/settings.tsx b/frontend/src/modules/2DViewer/settings/settings.tsx new file mode 100644 index 000000000..b2c17d128 --- /dev/null +++ b/frontend/src/modules/2DViewer/settings/settings.tsx @@ -0,0 +1,146 @@ +import React from "react"; + +import { ModuleSettingsProps } from "@framework/Module"; +import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { FieldDropdown } from "@framework/components/FieldDropdown"; +import { CollapsibleGroup } from "@lib/components/CollapsibleGroup"; +import { useQueryClient } from "@tanstack/react-query"; + +import { useAtom, useAtomValue, useSetAtom } from "jotai"; + +import { layerManagerAtom, preferredViewLayoutAtom, userSelectedFieldIdentifierAtom } from "./atoms/baseAtoms"; +import { selectedFieldIdentifierAtom } from "./atoms/derivedAtoms"; +import { LayerManagerComponent } from "./components/layerManagerComponent"; + +import { LayerManager, LayerManagerTopic } from "../layers/LayerManager"; +import { GroupDelegateTopic } from "../layers/delegates/GroupDelegate"; + +export function Settings(props: ModuleSettingsProps): React.ReactNode { + const ensembleSet = useEnsembleSet(props.workbenchSession); + const queryClient = useQueryClient(); + + const [layerManager, setLayerManager] = useAtom(layerManagerAtom); + + const fieldIdentifier = useAtomValue(selectedFieldIdentifierAtom); + const setFieldIdentifier = useSetAtom(userSelectedFieldIdentifierAtom); + const [preferredViewLayout, setPreferredViewLayout] = useAtom(preferredViewLayoutAtom); + + const persistState = React.useCallback( + function persistLayerManagerState() { + if (!layerManager) { + return; + } + + const serializedState = { + layerManager: layerManager.serializeState(), + fieldIdentifier, + preferredViewLayout, + }; + window.localStorage.setItem( + `${props.settingsContext.getInstanceIdString()}-settings`, + JSON.stringify(serializedState) + ); + }, + [layerManager, fieldIdentifier, preferredViewLayout, props.settingsContext] + ); + + const applyPersistedState = React.useCallback( + function applyPersistedState(layerManager: LayerManager) { + const serializedState = window.localStorage.getItem( + `${props.settingsContext.getInstanceIdString()}-settings` + ); + if (!serializedState) { + return; + } + + const parsedState = JSON.parse(serializedState); + if (parsedState.fieldIdentifier) { + setFieldIdentifier(parsedState.fieldIdentifier); + } + if (parsedState.preferredViewLayout) { + setPreferredViewLayout(parsedState.preferredViewLayout); + } + + if (parsedState.layerManager) { + if (!layerManager) { + return; + } + layerManager.deserializeState(parsedState.layerManager); + } + }, + [setFieldIdentifier, setPreferredViewLayout, props.settingsContext] + ); + + React.useEffect( + function onMountEffect() { + const newLayerManager = new LayerManager(props.workbenchSession, props.workbenchSettings, queryClient); + setLayerManager(newLayerManager); + + applyPersistedState(newLayerManager); + + return function onUnmountEffect() { + newLayerManager.beforeDestroy(); + }; + }, + [setLayerManager, props.workbenchSession, props.workbenchSettings, queryClient, applyPersistedState] + ); + + React.useEffect( + function onLayerManagerChangeEffect() { + if (!layerManager) { + return; + } + + persistState(); + + const unsubscribeDataRev = layerManager + .getPublishSubscribeDelegate() + .makeSubscriberFunction(LayerManagerTopic.LAYER_DATA_REVISION)(persistState); + + const unsubscribeExpands = layerManager + .getGroupDelegate() + .getPublishSubscribeDelegate() + .makeSubscriberFunction(GroupDelegateTopic.CHILDREN_EXPANSION_STATES)(persistState); + + return function onUnmountEffect() { + layerManager.beforeDestroy(); + unsubscribeDataRev(); + unsubscribeExpands(); + }; + }, + [layerManager, props.workbenchSession, props.workbenchSettings, persistState] + ); + + React.useEffect( + function onFieldIdentifierChangedEffect() { + if (!layerManager) { + return; + } + layerManager.updateGlobalSetting("fieldId", fieldIdentifier); + }, + [fieldIdentifier, layerManager] + ); + + function handleFieldChange(fieldId: string | null) { + setFieldIdentifier(fieldId); + if (!layerManager) { + return; + } + layerManager.updateGlobalSetting("fieldId", fieldId); + } + + return ( +
+ + + + {layerManager && ( + + )} +
+ ); +} diff --git a/frontend/src/modules/2DViewer/types.ts b/frontend/src/modules/2DViewer/types.ts new file mode 100644 index 000000000..4a9b727f7 --- /dev/null +++ b/frontend/src/modules/2DViewer/types.ts @@ -0,0 +1,38 @@ +export enum LayerType { + OBSERVED_SURFACE = "observedSurface", + STATISTICAL_SURFACE = "statisticalSurface", + REALIZATION_SURFACE = "realizationSurface", + REALIZATION_GRID = "realizationGrid", + REALIZATION_POLYGONS = "realizationPolygons", + DRILLED_WELLBORE_TRAJECTORIES = "drilledWellTrajectories", + DRILLED_WELLBORE_PICKS = "drilledWellPicks", +} + +export const LAYER_TYPE_TO_STRING_MAPPING: Record = { + [LayerType.OBSERVED_SURFACE]: "Observed Surface", + [LayerType.STATISTICAL_SURFACE]: "Statistical Surface", + [LayerType.REALIZATION_SURFACE]: "Realization Surface", + [LayerType.REALIZATION_GRID]: "Realization Grid Layer", + [LayerType.REALIZATION_POLYGONS]: "Realization Polygons", + [LayerType.DRILLED_WELLBORE_TRAJECTORIES]: "Drilled Well Trajectories", + [LayerType.DRILLED_WELLBORE_PICKS]: "Drilled Well Picks", +}; + +export enum SharedSettingType { + ENSEMBLE = "ensemble", + REALIZATION = "realization", + SURFACE_ATTRIBUTE = "surfaceAttribute", + SURFACE_NAME = "surfaceName", +} + +export const SHARED_SETTING_TYPE_TO_STRING_MAPPING: Record = { + [SharedSettingType.ENSEMBLE]: "Ensemble", + [SharedSettingType.REALIZATION]: "Realization", + [SharedSettingType.SURFACE_ATTRIBUTE]: "Surface Attribute", + [SharedSettingType.SURFACE_NAME]: "Surface Name", +}; + +export enum PreferredViewLayout { + HORIZONTAL = "horizontal", + VERTICAL = "vertical", +} diff --git a/frontend/src/modules/2DViewer/view/components/LayersWrapper.tsx b/frontend/src/modules/2DViewer/view/components/LayersWrapper.tsx new file mode 100644 index 000000000..ce4a63968 --- /dev/null +++ b/frontend/src/modules/2DViewer/view/components/LayersWrapper.tsx @@ -0,0 +1,154 @@ +import React from "react"; + +import { View as DeckGlView } from "@deck.gl/core"; +import { ViewContext } from "@framework/ModuleContext"; +import { useViewStatusWriter } from "@framework/StatusWriter"; +import { PendingWrapper } from "@lib/components/PendingWrapper"; +import { useElementSize } from "@lib/hooks/useElementSize"; +import { Rect2D, outerRectContainsInnerRect } from "@lib/utils/geometry"; +import { Interfaces } from "@modules/2DViewer/interfaces"; +import { LayerManager, LayerManagerTopic } from "@modules/2DViewer/layers/LayerManager"; +import { usePublishSubscribeTopicValue } from "@modules/2DViewer/layers/delegates/PublishSubscribeDelegate"; +import { BoundingBox } from "@modules/2DViewer/layers/interfaces"; +import { PreferredViewLayout } from "@modules/2DViewer/types"; +import { ColorLegendsContainer } from "@modules/_shared/components/ColorLegendsContainer"; +import { ColorScaleWithId } from "@modules/_shared/components/ColorLegendsContainer/colorLegendsContainer"; +import { ViewportType } from "@webviz/subsurface-viewer"; +import { ViewsType } from "@webviz/subsurface-viewer/dist/SubsurfaceViewer"; + +import { ReadoutWrapper } from "./ReadoutWrapper"; + +import { PlaceholderLayer } from "../customDeckGlLayers/PlaceholderLayer"; +import { DeckGlLayerWithPosition, recursivelyMakeViewsAndLayers } from "../utils/makeViewsAndLayers"; + +export type LayersWrapperProps = { + layerManager: LayerManager; + preferredViewLayout: PreferredViewLayout; + viewContext: ViewContext; +}; + +export function LayersWrapper(props: LayersWrapperProps): React.ReactNode { + const [prevBoundingBox, setPrevBoundingBox] = React.useState(null); + + const mainDivRef = React.useRef(null); + const mainDivSize = useElementSize(mainDivRef); + const statusWriter = useViewStatusWriter(props.viewContext); + + usePublishSubscribeTopicValue(props.layerManager, LayerManagerTopic.LAYER_DATA_REVISION); + + const viewports: ViewportType[] = []; + const viewerLayers: DeckGlLayerWithPosition[] = []; + const viewportAnnotations: React.ReactNode[] = []; + const globalColorScales: ColorScaleWithId[] = []; + + const views: ViewsType = { + layout: [1, 1], + viewports: viewports, + showLabel: false, + }; + + let numCols = 0; + let numRows = 0; + + let numLoadingLayers = 0; + + const viewsAndLayers = recursivelyMakeViewsAndLayers(props.layerManager); + + numCols = Math.ceil(Math.sqrt(viewsAndLayers.views.length)); + numRows = Math.ceil(viewsAndLayers.views.length / numCols); + + if (props.preferredViewLayout === PreferredViewLayout.HORIZONTAL) { + [numCols, numRows] = [numRows, numCols]; + } + + views.layout = [numCols, numRows]; + + viewerLayers.push(...viewsAndLayers.layers); + globalColorScales.push(...viewsAndLayers.colorScales); + const globalLayerIds = viewsAndLayers.layers.map((layer) => layer.layer.id); + + for (const view of viewsAndLayers.views) { + viewports.push({ + id: view.id, + name: view.name, + isSync: true, + layerIds: [...globalLayerIds, ...view.layers.map((layer) => layer.layer.id), "placeholder"], + }); + viewerLayers.push(...view.layers); + + viewportAnnotations.push( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + /* @ts-expect-error */ + + +
+
+
+
{view.name}
+
+
+ + ); + } + + if (viewsAndLayers.boundingBox !== null) { + if (prevBoundingBox !== null) { + const oldBoundingRect: Rect2D | null = { + x: prevBoundingBox.x[0], + y: prevBoundingBox.y[0], + width: prevBoundingBox.x[1] - prevBoundingBox.x[0], + height: prevBoundingBox.y[1] - prevBoundingBox.y[0], + }; + + const newBoundingRect: Rect2D = { + x: viewsAndLayers.boundingBox.x[0], + y: viewsAndLayers.boundingBox.y[0], + width: viewsAndLayers.boundingBox.x[1] - viewsAndLayers.boundingBox.x[0], + height: viewsAndLayers.boundingBox.y[1] - viewsAndLayers.boundingBox.y[0], + }; + + if (!outerRectContainsInnerRect(oldBoundingRect, newBoundingRect)) { + setPrevBoundingBox(viewsAndLayers.boundingBox); + } + } else { + setPrevBoundingBox(viewsAndLayers.boundingBox); + } + } + + numLoadingLayers = viewsAndLayers.numLoadingLayers; + statusWriter.setLoading(viewsAndLayers.numLoadingLayers > 0); + + for (const message of viewsAndLayers.errorMessages) { + statusWriter.addError(message); + } + + let bounds: [number, number, number, number] | undefined = undefined; + if (prevBoundingBox) { + bounds = [prevBoundingBox.x[0], prevBoundingBox.y[0], prevBoundingBox.x[1], prevBoundingBox.y[1]]; + } + + const layers = viewerLayers.toSorted((a, b) => b.position - a.position).map((layer) => layer.layer); + layers.push(new PlaceholderLayer({ id: "placeholder" })); + + return ( +
+ 0}> +
+ +
+
+
+ ); +} diff --git a/frontend/src/modules/2DViewer/view/components/ReadoutBoxWrapper.tsx b/frontend/src/modules/2DViewer/view/components/ReadoutBoxWrapper.tsx new file mode 100644 index 000000000..70a488c0b --- /dev/null +++ b/frontend/src/modules/2DViewer/view/components/ReadoutBoxWrapper.tsx @@ -0,0 +1,117 @@ +import React from "react"; + +import { ReadoutBox, ReadoutItem } from "@modules/_shared/components/ReadoutBox"; +import { ExtendedLayerProps, LayerPickInfo } from "@webviz/subsurface-viewer"; + +import { isEqual } from "lodash"; + +// Needs extra distance for the left side; this avoids overlapping with legend elements +const READOUT_EDGE_DISTANCE_REM = { left: 6 }; + +function makePositionReadout(layerPickInfo: LayerPickInfo): ReadoutItem | null { + if (layerPickInfo.coordinate === undefined || layerPickInfo.coordinate.length < 2) { + return null; + } + return { + label: "Position", + info: [ + { + name: "x", + value: layerPickInfo.coordinate[0], + unit: "m", + }, + { + name: "y", + value: layerPickInfo.coordinate[1], + unit: "m", + }, + ], + }; +} + +export type ReadoutBoxWrapperProps = { + layerPickInfo: LayerPickInfo[]; + maxNumItems?: number; + visible?: boolean; +}; + +export function ReadoutBoxWrapper(props: ReadoutBoxWrapperProps): React.ReactNode { + const [infoData, setInfoData] = React.useState([]); + const [prevLayerPickInfo, setPrevLayerPickInfo] = React.useState([]); + + if (!isEqual(props.layerPickInfo, prevLayerPickInfo)) { + setPrevLayerPickInfo(props.layerPickInfo); + const newReadoutItems: ReadoutItem[] = []; + + if (props.layerPickInfo.length === 0) { + setInfoData([]); + return; + } + + const positionReadout = makePositionReadout(props.layerPickInfo[0]); + if (!positionReadout) { + return; + } + newReadoutItems.push(positionReadout); + + for (const layerPickInfo of props.layerPickInfo) { + const layerName = (layerPickInfo.layer?.props as unknown as ExtendedLayerProps)?.name; + const layerProps = layerPickInfo.properties; + + // pick info can have 2 types of properties that can be displayed on the info card + // 1. defined as propertyValue, used for general layer info (now using for positional data) + // 2. Another defined as array of property object described by type PropertyDataType + + const layerReadout = newReadoutItems.find((item) => item.label === layerName); + + // collecting card data for 1st type + const zValue = (layerPickInfo as LayerPickInfo).propertyValue; + if (zValue !== undefined) { + if (layerReadout) { + layerReadout.info.push({ + name: "Property value", + value: zValue, + }); + } else { + newReadoutItems.push({ + label: layerName ?? "Unknown layer", + info: [ + { + name: "Property value", + value: zValue, + }, + ], + }); + } + } + + // collecting card data for 2nd type + if (!layerProps || layerProps.length === 0) { + continue; + } + if (layerReadout) { + layerProps?.forEach((prop) => { + const property = layerReadout.info?.find((item) => item.name === prop.name); + if (property) { + property.value = prop.value; + } else { + layerReadout.info.push(prop); + } + }); + } else { + newReadoutItems.push({ + label: layerName ?? "Unknown layer", + info: layerProps, + }); + } + } + + setInfoData(newReadoutItems); + } + + if (!props.visible) { + return null; + } + + return ; +} diff --git a/frontend/src/modules/2DViewer/view/components/ReadoutWrapper.tsx b/frontend/src/modules/2DViewer/view/components/ReadoutWrapper.tsx new file mode 100644 index 000000000..042573389 --- /dev/null +++ b/frontend/src/modules/2DViewer/view/components/ReadoutWrapper.tsx @@ -0,0 +1,76 @@ +import React from "react"; + +import { Layer as DeckGlLayer } from "@deck.gl/core"; +import { SubsurfaceViewerWithCameraState } from "@modules/_shared/components/SubsurfaceViewerWithCameraState"; +import { LayerPickInfo, MapMouseEvent, ViewStateType, ViewsType } from "@webviz/subsurface-viewer"; + +import { ReadoutBoxWrapper } from "./ReadoutBoxWrapper"; +import { Toolbar } from "./Toolbar"; + +export type ReadooutWrapperProps = { + views: ViewsType; + viewportAnnotations: React.ReactNode[]; + layers: DeckGlLayer[]; + bounds?: [number, number, number, number]; +}; + +export function ReadoutWrapper(props: ReadooutWrapperProps): React.ReactNode { + const id = React.useId(); + + const [cameraPositionSetByAction, setCameraPositionSetByAction] = React.useState(null); + const [triggerHomeCounter, setTriggerHomeCounter] = React.useState(0); + const [layerPickingInfo, setLayerPickingInfo] = React.useState([]); + + function handleFitInViewClick() { + setTriggerHomeCounter((prev) => prev + 1); + } + + function handleMouseHover(event: MapMouseEvent): void { + setLayerPickingInfo(event.infos); + } + + function handleMouseEvent(event: MapMouseEvent): void { + if (event.type === "hover") { + handleMouseHover(event); + } + } + + return ( + <> + + + setCameraPositionSetByAction(null)} + onMouseEvent={handleMouseEvent} + layers={props.layers} + scale={{ + visible: true, + incrementValue: 100, + widthPerUnit: 100, + cssStyle: { + right: 10, + top: 10, + }, + }} + coords={{ + visible: false, + multiPicking: true, + pickDepth: 2, + }} + triggerHome={triggerHomeCounter} + pickingRadius={5} + > + {props.viewportAnnotations} + + {props.views.viewports.length === 0 && ( +
+ Please add views and layers in the settings panel. +
+ )} + + ); +} diff --git a/frontend/src/modules/2DViewer/view/components/Toolbar.tsx b/frontend/src/modules/2DViewer/view/components/Toolbar.tsx new file mode 100644 index 000000000..08527e27d --- /dev/null +++ b/frontend/src/modules/2DViewer/view/components/Toolbar.tsx @@ -0,0 +1,21 @@ +import { Button } from "@lib/components/Button"; +import { Toolbar as GenericToolbar } from "@modules/_shared/components/Toolbar"; +import { FilterCenterFocus } from "@mui/icons-material"; + +export type ToolbarProps = { + onFitInView: () => void; +}; + +export function Toolbar(props: ToolbarProps): React.ReactNode { + function handleFitInViewClick() { + props.onFitInView(); + } + + return ( + + + + ); +} diff --git a/frontend/src/modules/2DViewer/view/customDeckGlLayers/AdvancedWellsLayer.ts b/frontend/src/modules/2DViewer/view/customDeckGlLayers/AdvancedWellsLayer.ts new file mode 100644 index 000000000..112c3363b --- /dev/null +++ b/frontend/src/modules/2DViewer/view/customDeckGlLayers/AdvancedWellsLayer.ts @@ -0,0 +1,67 @@ +import { FilterContext, Layer, LayersList } from "@deck.gl/core"; +import { GeoJsonLayer } from "@deck.gl/layers"; +import { WellsLayer } from "@webviz/subsurface-viewer/dist/layers"; + +export class AdvancedWellsLayer extends WellsLayer { + static layerName: string = "WellsLayer"; + + constructor(props: any) { + super(props); + } + + filterSubLayer(context: FilterContext): boolean { + if (context.layer.id.includes("names")) { + return context.viewport.zoom > -2; + } + + return true; + } + + renderLayers(): LayersList { + const layers = super.renderLayers(); + + if (!Array.isArray(layers)) { + return layers; + } + + const colorsLayer = layers.find((layer) => { + if (!(layer instanceof Layer)) { + return false; + } + + return layer.id.includes("colors"); + }); + + if (!(colorsLayer instanceof GeoJsonLayer)) { + return layers; + } + + const newColorsLayer = new GeoJsonLayer({ + data: colorsLayer.props.data, + pickable: true, + stroked: false, + positionFormat: colorsLayer.props.positionFormat, + pointRadiusUnits: "meters", + lineWidthUnits: "meters", + pointRadiusScale: this.props.pointRadiusScale, + lineWidthScale: this.props.lineWidthScale, + getLineWidth: colorsLayer.props.getLineWidth, + getPointRadius: colorsLayer.props.getPointRadius, + lineBillboard: true, + pointBillboard: true, + parameters: colorsLayer.props.parameters, + visible: colorsLayer.props.visible, + id: "colors", + lineWidthMinPixels: 1, + lineWidthMaxPixels: 5, + extensions: colorsLayer.props.extensions, + getDashArray: colorsLayer.props.getDashArray, + getLineColor: colorsLayer.props.getLineColor, + getFillColor: colorsLayer.props.getFillColor, + autoHighlight: true, + onHover: () => {}, + }); + + return [newColorsLayer, ...layers.filter((layer) => layer !== colorsLayer)]; + } +} diff --git a/frontend/src/modules/2DViewer/view/customDeckGlLayers/PlaceholderLayer.ts b/frontend/src/modules/2DViewer/view/customDeckGlLayers/PlaceholderLayer.ts new file mode 100644 index 000000000..b146c9fea --- /dev/null +++ b/frontend/src/modules/2DViewer/view/customDeckGlLayers/PlaceholderLayer.ts @@ -0,0 +1,21 @@ +import { Layer } from "@deck.gl/core"; + +type PlaceholderLayerProps = { + id: string; +}; + +export class PlaceholderLayer extends Layer { + static layerName: string = "PlaceholderLayer"; + + constructor(props: PlaceholderLayerProps) { + super(props); + } + + initializeState(): void { + return; + } + + render() { + return null; + } +} diff --git a/frontend/src/modules/2DViewer/view/customDeckGlLayers/WellborePicksLayer.ts b/frontend/src/modules/2DViewer/view/customDeckGlLayers/WellborePicksLayer.ts new file mode 100644 index 000000000..c58047a25 --- /dev/null +++ b/frontend/src/modules/2DViewer/view/customDeckGlLayers/WellborePicksLayer.ts @@ -0,0 +1,136 @@ +import { CompositeLayer, CompositeLayerProps, FilterContext, Layer, UpdateParameters } from "@deck.gl/core"; +import { GeoJsonLayer, TextLayer } from "@deck.gl/layers"; + +import type { Feature, FeatureCollection } from "geojson"; + +export type WellBorePickLayerData = { + easting: number; + northing: number; + wellBoreUwi: string; + tvdMsl: number; + md: number; + slotName: string; +}; + +type TextLayerData = { + coordinates: [number, number, number]; + name: string; +}; + +export type WellBorePicksLayerProps = { + id: string; + data: WellBorePickLayerData[]; +}; + +export class WellborePicksLayer extends CompositeLayer { + static layerName: string = "WellborePicksLayer"; + private _textData: TextLayerData[] = []; + private _pointsData: FeatureCollection | null = null; + + filterSubLayer(context: FilterContext): boolean { + if (context.layer.id.includes("text")) { + return context.viewport.zoom > -4; + } + + return true; + } + + updateState(params: UpdateParameters>>): void { + const features: Feature[] = params.props.data.map((wellPick) => { + return { + type: "Feature", + geometry: { + type: "Point", + coordinates: [wellPick.easting, wellPick.northing], + }, + properties: { + name: `${wellPick.wellBoreUwi}, TVD_MSL: ${wellPick.tvdMsl}, MD: ${wellPick.md}`, + color: [100, 100, 100, 100], + }, + }; + }); + + const pointsData: FeatureCollection = { + type: "FeatureCollection", + features: features, + }; + + const textData: TextLayerData[] = this.props.data.map((wellPick) => { + return { + coordinates: [wellPick.easting, wellPick.northing, wellPick.tvdMsl], + name: wellPick.wellBoreUwi, + }; + }); + + this._pointsData = pointsData; + this._textData = textData; + } + + renderLayers() { + const fontSize = 16; + const sizeMinPixels = 16; + const sizeMaxPixels = 16; + + return [ + new GeoJsonLayer( + this.getSubLayerProps({ + id: "points", + data: this._pointsData ?? undefined, + // pointType: 'circle+text', + filled: true, + lineWidthMinPixels: 5, + lineWidthMaxPixels: 5, + lineWidthUnits: "meters", + parameters: { + depthTest: false, + }, + getLineWidth: 1, + depthTest: false, + pickable: true, + getText: (d: Feature) => d.properties?.wellBoreUwi, + getLineColor: [50, 50, 50], + // extensions: [new CollisionFilterExtension()], + // collisionGroup: "wellbore-picks", + }) + ), + + new TextLayer( + this.getSubLayerProps({ + id: "text", + data: this._textData, + // depthTest: true, + pickable: true, + getColor: [255, 255, 255], + fontWeight: 800, + fontSettings: { + fontSize: fontSize * 2, + sdf: true, + }, + outlineColor: [0, 0, 0], + outlineWidth: 2, + getSize: 12, + sdf: true, + sizeScale: fontSize, + sizeUnits: "meters", + sizeMinPixels: sizeMinPixels, + sizeMaxPixels: sizeMaxPixels, + getAlignmentBaseline: "top", + getTextAnchor: "middle", + getPosition: (d: TextLayerData) => d.coordinates, + getText: (d: TextLayerData) => d.name, + // maxWidth: 64 * 12, + /* + // extensions: [new CollisionFilterExtension()], + collisionGroup: "wellbore-picks", + + collisionTestProps: { + sizeScale: fontSize, + sizeMaxPixels: sizeMaxPixels * 2, + sizeMinPixels: sizeMinPixels * 2, + }, + */ + }) + ), + ]; + } +} diff --git a/frontend/src/modules/2DViewer/view/utils/layerFactory.ts b/frontend/src/modules/2DViewer/view/utils/layerFactory.ts new file mode 100644 index 000000000..fc03f2de5 --- /dev/null +++ b/frontend/src/modules/2DViewer/view/utils/layerFactory.ts @@ -0,0 +1,363 @@ +import { PolygonData_api, SurfaceDataPng_api, SurfaceDef_api, WellborePick_api, WellboreTrajectory_api } from "@api"; +import { Layer } from "@deck.gl/core"; +import { GeoJsonLayer } from "@deck.gl/layers"; +import { defaultColorPalettes } from "@framework/utils/colorPalettes"; +import { ColorScaleGradientType, ColorScaleType } from "@lib/utils/ColorScale"; +import { Vec2, rotatePoint2Around } from "@lib/utils/vec2"; +import { GridMappedProperty_trans, GridSurface_trans } from "@modules/3DViewer/view/queries/queryDataTransforms"; +import { ColorScaleWithName } from "@modules/_shared/utils/ColorScaleWithName"; +import { ColormapLayer, Grid3DLayer, WellsLayer } from "@webviz/subsurface-viewer/dist/layers"; + +import { Rgb, parse } from "culori"; +import { Feature, FeatureCollection, GeoJsonProperties, Geometry } from "geojson"; + +import { DrilledWellTrajectoriesLayer } from "../../layers/implementations/layers/DrilledWellTrajectoriesLayer/DrilledWellTrajectoriesLayer"; +import { DrilledWellborePicksLayer } from "../../layers/implementations/layers/DrilledWellborePicksLayer/DrilledWellborePicksLayer"; +import { ObservedSurfaceLayer } from "../../layers/implementations/layers/ObservedSurfaceLayer/ObservedSurfaceLayer"; +import { RealizationGridLayer } from "../../layers/implementations/layers/RealizationGridLayer/RealizationGridLayer"; +import { RealizationPolygonsLayer } from "../../layers/implementations/layers/RealizationPolygonsLayer/RealizationPolygonsLayer"; +import { RealizationSurfaceLayer } from "../../layers/implementations/layers/RealizationSurfaceLayer/RealizationSurfaceLayer"; +import { StatisticalSurfaceLayer } from "../../layers/implementations/layers/StatisticalSurfaceLayer/StatisticalSurfaceLayer"; +import { Layer as LayerInterface } from "../../layers/interfaces"; +import { AdvancedWellsLayer } from "../customDeckGlLayers/AdvancedWellsLayer"; +import { WellBorePickLayerData, WellborePicksLayer } from "../customDeckGlLayers/WellborePicksLayer"; + +export function makeLayer(layer: LayerInterface, colorScale?: ColorScaleWithName): Layer | null { + const data = layer.getLayerDelegate().getData(); + + if (colorScale === undefined) { + colorScale = new ColorScaleWithName({ + colorPalette: defaultColorPalettes[0], + gradientType: ColorScaleGradientType.Sequential, + name: "Default", + type: ColorScaleType.Continuous, + steps: 10, + }); + } + + if (!data) { + return null; + } + if (layer instanceof ObservedSurfaceLayer) { + return createMapImageLayer( + data, + layer.getItemDelegate().getId(), + layer.getItemDelegate().getName(), + colorScale + ); + } + if (layer instanceof RealizationSurfaceLayer) { + return createMapImageLayer( + data, + layer.getItemDelegate().getId(), + layer.getItemDelegate().getName(), + colorScale + ); + } + if (layer instanceof StatisticalSurfaceLayer) { + return createMapImageLayer( + data, + layer.getItemDelegate().getId(), + layer.getItemDelegate().getName(), + colorScale + ); + } + if (layer instanceof RealizationPolygonsLayer) { + return createPolygonsLayer(data, layer.getItemDelegate().getId()); + } + if (layer instanceof DrilledWellTrajectoriesLayer) { + return makeWellsLayer(data, layer.getItemDelegate().getId(), null); + } + if (layer instanceof DrilledWellborePicksLayer) { + return createWellPicksLayer(data, layer.getItemDelegate().getId()); + } + if (layer instanceof RealizationGridLayer) { + return makeGrid3DLayer( + layer.getItemDelegate().getId(), + data.gridSurfaceData, + data.gridParameterData, + layer.getSettingsContext().getDelegate().getSettings().showGridLines.getDelegate().getValue(), + colorScale + ); + } + return null; +} +function createWellPicksLayer(wellPicksDataApi: WellborePick_api[], id: string): WellborePicksLayer { + const wellPicksData: WellBorePickLayerData[] = wellPicksDataApi.map((wellPick) => { + return { + easting: wellPick.easting, + northing: wellPick.northing, + wellBoreUwi: wellPick.uniqueWellboreIdentifier, + tvdMsl: wellPick.tvdMsl, + md: wellPick.md, + pickable: true, + slotName: "", + }; + }); + return new WellborePicksLayer({ + id: id, + data: wellPicksData, + pickable: true, + }); +} + +/* +function createMapFloatLayer(layerData: SurfaceDataFloat_trans, id: string): MapLayer { + return new MapLayer({ + id: id, + meshData: layerData.valuesFloat32Arr, + typedArraySupport: true, + frame: { + origin: [layerData.surface_def.origin_utm_x, layerData.surface_def.origin_utm_y], + count: [layerData.surface_def.npoints_x, layerData.surface_def.npoints_y], + increment: [layerData.surface_def.inc_x, layerData.surface_def.inc_y], + rotDeg: layerData.surface_def.rot_deg, + }, + contours: [0, 100], + isContoursDepth: true, + gridLines: false, + material: true, + smoothShading: true, + colorMapName: "Physics", + parameters: { + depthTest: false, + }, + depthTest: false, + }); +} +*/ + +function createMapImageLayer( + layerData: SurfaceDataPng_api, + id: string, + name: string, + colorScale?: ColorScaleWithName +): ColormapLayer { + return new ColormapLayer({ + id: id, + name: name, + image: `data:image/png;base64,${layerData.png_image_base64}`, + bounds: _calcBoundsForRotationAroundUpperLeftCorner(layerData.surface_def), + rotDeg: layerData.surface_def.rot_deg, + valueRange: [layerData.value_min, layerData.value_max], + colorMapRange: [layerData.value_min, layerData.value_max], + colorMapName: "Physics", + parameters: { + depthWriteEnabled: false, + }, + colorMapFunction: makeColorMapFunction(colorScale, layerData.value_min, layerData.value_max), + }); +} + +function _calcBoundsForRotationAroundUpperLeftCorner(surfDef: SurfaceDef_api): [number, number, number, number] { + const width = (surfDef.npoints_x - 1) * surfDef.inc_x; + const height = (surfDef.npoints_y - 1) * surfDef.inc_y; + const orgRotPoint: Vec2 = { x: surfDef.origin_utm_x, y: surfDef.origin_utm_y }; + const orgTopLeft: Vec2 = { x: surfDef.origin_utm_x, y: surfDef.origin_utm_y + height }; + + const transTopLeft: Vec2 = rotatePoint2Around(orgTopLeft, orgRotPoint, (surfDef.rot_deg * Math.PI) / 180); + const tLeft = transTopLeft.x; + const tBottom = transTopLeft.y - height; + const tRight = transTopLeft.x + width; + const tTop = transTopLeft.y; + + const bounds: [number, number, number, number] = [tLeft, tBottom, tRight, tTop]; + + return bounds; +} + +function createPolygonsLayer(polygonsData: PolygonData_api[], id: string): GeoJsonLayer { + const features: Feature[] = polygonsData.map((polygon) => { + return polygonsToGeojson(polygon); + }); + const data: FeatureCollection = { + type: "FeatureCollection", + features: features, + }; + return new GeoJsonLayer({ + id: id, + data: data, + // opacity: 0.5, + filled: false, + lineWidthMinPixels: 2, + parameters: { + depthTest: false, + }, + + pickable: true, + }); +} +function polygonsToGeojson(polygons: PolygonData_api): Feature { + const data: Feature = { + type: "Feature", + geometry: { + type: "Polygon", + coordinates: [zipCoords(polygons.x_arr, polygons.y_arr, polygons.z_arr)], + }, + properties: { name: polygons.poly_id, color: [0, 0, 0, 255] }, + }; + return data; +} + +export function makeWellsLayer( + fieldWellboreTrajectoriesData: WellboreTrajectory_api[], + id: string, + selectedWellboreUuid: string | null +): WellsLayer { + const tempWorkingWellsData = fieldWellboreTrajectoriesData.filter( + (el) => el.uniqueWellboreIdentifier !== "NO 34/4-K-3 AH" + ); + const wellLayerDataFeatures = tempWorkingWellsData.map((well) => + wellTrajectoryToGeojson(well, selectedWellboreUuid) + ); + + function getLineStyleWidth(object: Feature): number { + if (object.properties && "lineWidth" in object.properties) { + return object.properties.lineWidth as number; + } + return 2; + } + + function getWellHeadStyleWidth(object: Feature): number { + if (object.properties && "wellHeadSize" in object.properties) { + return object.properties.wellHeadSize as number; + } + return 1; + } + + function getColor(object: Feature): [number, number, number, number] { + if (object.properties && "color" in object.properties) { + return object.properties.color as [number, number, number, number]; + } + return [50, 50, 50, 100]; + } + + const wellsLayer = new AdvancedWellsLayer({ + id: id, + data: { + type: "FeatureCollection", + unit: "m", + features: wellLayerDataFeatures, + }, + refine: false, + lineStyle: { width: getLineStyleWidth, color: getColor }, + wellHeadStyle: { size: getWellHeadStyleWidth, color: getColor }, + wellNameVisible: true, + pickable: true, + ZIncreasingDownwards: false, + outline: false, + lineWidthScale: 2, + }); + + return wellsLayer; +} + +export function wellTrajectoryToGeojson( + wellTrajectory: WellboreTrajectory_api, + selectedWellboreUuid: string | null +): Record { + const point: Record = { + type: "Point", + coordinates: [wellTrajectory.eastingArr[0], wellTrajectory.northingArr[0], -wellTrajectory.tvdMslArr[0]], + }; + const coordinates: Record = { + type: "LineString", + coordinates: zipCoords(wellTrajectory.eastingArr, wellTrajectory.northingArr, wellTrajectory.tvdMslArr), + }; + + let color = [100, 100, 100]; + let lineWidth = 2; + let wellHeadSize = 1; + if (wellTrajectory.wellboreUuid === selectedWellboreUuid) { + color = [255, 0, 0]; + lineWidth = 5; + wellHeadSize = 10; + } + + const geometryCollection: Record = { + type: "Feature", + geometry: { + type: "GeometryCollection", + geometries: [point, coordinates], + }, + properties: { + uuid: wellTrajectory.wellboreUuid, + name: wellTrajectory.uniqueWellboreIdentifier, + uwi: wellTrajectory.uniqueWellboreIdentifier, + color, + md: [wellTrajectory.mdArr], + lineWidth, + wellHeadSize, + }, + }; + + return geometryCollection; +} + +function zipCoords(x_arr: number[], y_arr: number[], z_arr: number[]): number[][] { + const coords: number[][] = []; + for (let i = 0; i < x_arr.length; i++) { + coords.push([x_arr[i], y_arr[i], -z_arr[i]]); + } + + return coords; +} +type WorkingGrid3dLayer = { + pointsData: Float32Array; + polysData: Uint32Array; + propertiesData: Float32Array; + colorMapName: string; + ZIncreasingDownwards: boolean; +} & Layer; + +export function makeGrid3DLayer( + id: string, + gridSurfaceData: GridSurface_trans, + gridParameterData: GridMappedProperty_trans, + showGridLines: boolean, + colorScale?: ColorScaleWithName + // colorScale: ColorScale +): WorkingGrid3dLayer { + const offsetXyz = [gridSurfaceData.origin_utm_x, gridSurfaceData.origin_utm_y, 0]; + const pointsNumberArray = gridSurfaceData.pointsFloat32Arr.map((val, i) => val + offsetXyz[i % 3]); + const polysNumberArray = gridSurfaceData.polysUint32Arr; + const grid3dLayer = new Grid3DLayer({ + id: id, + pointsData: pointsNumberArray, + polysData: polysNumberArray, + propertiesData: gridParameterData.polyPropsFloat32Arr, + ZIncreasingDownwards: false, + gridLines: showGridLines, + material: { ambient: 0.4, diffuse: 0.7, shininess: 8, specularColor: [25, 25, 25] }, + pickable: true, + colorMapName: "Physics", + colorMapClampColor: true, + colorMapRange: [gridParameterData.min_grid_prop_value, gridParameterData.max_grid_prop_value], + colorMapFunction: makeColorMapFunction( + colorScale, + gridParameterData.min_grid_prop_value, + gridParameterData.max_grid_prop_value + ), + }); + return grid3dLayer as unknown as WorkingGrid3dLayer; +} + +function makeColorMapFunction( + colorScale: ColorScaleWithName | undefined, + valueMin: number, + valueMax: number +): ((value: number) => [number, number, number]) | undefined { + if (!colorScale) { + return undefined; + } + + return (value: number) => { + const nonNormalizedValue = value * (valueMax - valueMin) + valueMin; + const interpolatedColor = colorScale.getColorForValue(nonNormalizedValue); + const color = parse(interpolatedColor) as Rgb; + if (color === undefined) { + return [0, 0, 0]; + } + return [color.r * 255, color.g * 255, color.b * 255]; + }; +} diff --git a/frontend/src/modules/2DViewer/view/utils/makeViewsAndLayers.ts b/frontend/src/modules/2DViewer/view/utils/makeViewsAndLayers.ts new file mode 100644 index 000000000..d0e69341e --- /dev/null +++ b/frontend/src/modules/2DViewer/view/utils/makeViewsAndLayers.ts @@ -0,0 +1,179 @@ +import { Layer as DeckGlLayer } from "@deck.gl/core"; +import { StatusMessage } from "@framework/ModuleInstanceStatusController"; +import { defaultContinuousSequentialColorPalettes } from "@framework/utils/colorPalettes"; +import { ColorScaleGradientType, ColorScaleType } from "@lib/utils/ColorScale"; +import { ColorScale } from "@modules/2DViewer/layers/ColorScale"; +import { DeltaSurface } from "@modules/2DViewer/layers/DeltaSurface"; +import { View } from "@modules/2DViewer/layers/View"; +import { LayerStatus } from "@modules/2DViewer/layers/delegates/LayerDelegate"; +import { BoundingBox, Group, Layer, instanceofGroup, instanceofLayer } from "@modules/2DViewer/layers/interfaces"; +import { ColorScaleWithId } from "@modules/_shared/components/ColorLegendsContainer/colorLegendsContainer"; +import { ColorScaleWithName } from "@modules/_shared/utils/ColorScaleWithName"; + +import { makeLayer } from "./layerFactory"; + +export type DeckGlLayerWithPosition = { + layer: DeckGlLayer; + position: number; +}; + +export type DeckGlView = { + id: string; + color: string | null; + name: string; + layers: DeckGlLayerWithPosition[]; + colorScales: ColorScaleWithId[]; +}; + +export type DeckGlViewsAndLayers = { + views: DeckGlView[]; + layers: DeckGlLayerWithPosition[]; + errorMessages: (StatusMessage | string)[]; + boundingBox: BoundingBox | null; + colorScales: ColorScaleWithId[]; + numLoadingLayers: number; +}; + +export function recursivelyMakeViewsAndLayers(group: Group, numCollectedLayers: number = 0): DeckGlViewsAndLayers { + const collectedViews: DeckGlView[] = []; + const collectedLayers: DeckGlLayerWithPosition[] = []; + const collectedColorScales: ColorScaleWithId[] = []; + const collectedErrorMessages: (StatusMessage | string)[] = []; + let collectedNumLoadingLayers = 0; + let globalBoundingBox: BoundingBox | null = null; + + const children = group.getGroupDelegate().getChildren(); + + const maybeApplyBoundingBox = (boundingBox: BoundingBox | null) => { + if (boundingBox) { + globalBoundingBox = + globalBoundingBox === null ? boundingBox : makeNewBoundingBox(boundingBox, globalBoundingBox); + } + }; + + for (const child of children) { + if (!child.getItemDelegate().isVisible()) { + continue; + } + + if (instanceofGroup(child) && !(child instanceof DeltaSurface)) { + const { views, layers, boundingBox, colorScales, numLoadingLayers, errorMessages } = + recursivelyMakeViewsAndLayers(child, numCollectedLayers + collectedLayers.length); + + collectedErrorMessages.push(...errorMessages); + collectedNumLoadingLayers += numLoadingLayers; + maybeApplyBoundingBox(boundingBox); + + if (child instanceof View) { + const view: DeckGlView = { + id: child.getItemDelegate().getId(), + color: child.getGroupDelegate().getColor(), + name: child.getItemDelegate().getName(), + layers: layers, + colorScales, + }; + + collectedViews.push(view); + continue; + } + + collectedLayers.push(...layers); + collectedViews.push(...views); + } + + if (instanceofLayer(child)) { + if (child.getLayerDelegate().getStatus() === LayerStatus.LOADING) { + collectedNumLoadingLayers++; + } + + if (child.getLayerDelegate().getStatus() !== LayerStatus.SUCCESS) { + if (child.getLayerDelegate().getStatus() === LayerStatus.ERROR) { + const error = child.getLayerDelegate().getError(); + if (error) { + collectedErrorMessages.push(error); + } + } + continue; + } + + const colorScale = findColorScale(child); + + const layer = makeLayer(child, colorScale?.colorScale ?? undefined); + + if (!layer) { + continue; + } + + if (colorScale) { + collectedColorScales.push(colorScale); + } + + const boundingBox = child.getLayerDelegate().getBoundingBox(); + maybeApplyBoundingBox(boundingBox); + collectedLayers.push({ layer, position: numCollectedLayers + collectedLayers.length }); + } + } + + return { + views: collectedViews, + layers: collectedLayers, + errorMessages: collectedErrorMessages, + boundingBox: globalBoundingBox, + colorScales: collectedColorScales, + numLoadingLayers: collectedNumLoadingLayers, + }; +} + +function findColorScale(layer: Layer): { id: string; colorScale: ColorScaleWithName } | null { + if (layer.getLayerDelegate().getColoringType() !== "COLORSCALE") { + return null; + } + + let colorScaleWithName = new ColorScaleWithName({ + colorPalette: defaultContinuousSequentialColorPalettes[0], + gradientType: ColorScaleGradientType.Sequential, + name: layer.getItemDelegate().getName(), + type: ColorScaleType.Continuous, + steps: 10, + }); + + const range = layer.getLayerDelegate().getValueRange(); + if (range) { + colorScaleWithName.setRangeAndMidPoint(range[0], range[1], (range[0] + range[1]) / 2); + } + + const colorScaleItemArr = layer + .getItemDelegate() + .getParentGroup() + ?.getAncestorAndSiblingItems((item) => item instanceof ColorScale); + + if (colorScaleItemArr && colorScaleItemArr.length > 0) { + const colorScaleItem = colorScaleItemArr[0]; + if (colorScaleItem instanceof ColorScale) { + colorScaleWithName = ColorScaleWithName.fromColorScale( + colorScaleItem.getColorScale(), + layer.getItemDelegate().getName() + ); + + if (!colorScaleItem.getAreBoundariesUserDefined()) { + const range = layer.getLayerDelegate().getValueRange(); + if (range) { + colorScaleWithName.setRangeAndMidPoint(range[0], range[1], (range[0] + range[1]) / 2); + } + } + } + } + + return { + id: layer.getItemDelegate().getId(), + colorScale: colorScaleWithName, + }; +} + +function makeNewBoundingBox(newBoundingBox: BoundingBox, oldBoundingBox: BoundingBox): BoundingBox { + return { + x: [Math.min(newBoundingBox.x[0], oldBoundingBox.x[0]), Math.max(newBoundingBox.x[1], oldBoundingBox.x[1])], + y: [Math.min(newBoundingBox.y[0], oldBoundingBox.y[0]), Math.max(newBoundingBox.y[1], oldBoundingBox.y[1])], + z: [Math.min(newBoundingBox.z[0], oldBoundingBox.z[0]), Math.max(newBoundingBox.z[1], oldBoundingBox.z[1])], + }; +} diff --git a/frontend/src/modules/2DViewer/view/view.tsx b/frontend/src/modules/2DViewer/view/view.tsx new file mode 100644 index 000000000..bc4022d72 --- /dev/null +++ b/frontend/src/modules/2DViewer/view/view.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +import { ModuleViewProps } from "@framework/Module"; + +import { LayersWrapper } from "./components/LayersWrapper"; + +import { Interfaces } from "../interfaces"; + +export function View(props: ModuleViewProps): React.ReactNode { + const preferredViewLayout = props.viewContext.useSettingsToViewInterfaceValue("preferredViewLayout"); + const layerManager = props.viewContext.useSettingsToViewInterfaceValue("layerManager"); + + if (!layerManager) { + return null; + } + + return ( + + ); +} diff --git a/frontend/src/modules/_shared/components/Toolbar/index.ts b/frontend/src/modules/_shared/components/Toolbar/index.ts new file mode 100644 index 000000000..92e32c985 --- /dev/null +++ b/frontend/src/modules/_shared/components/Toolbar/index.ts @@ -0,0 +1,3 @@ +export { Toolbar } from "./toolbar"; +export { ToolBarDivider } from "./toolbarDivider"; +export type { ToolbarProps } from "./toolbar"; diff --git a/frontend/src/modules/_shared/components/Toolbar/toolbar.tsx b/frontend/src/modules/_shared/components/Toolbar/toolbar.tsx new file mode 100644 index 000000000..3ca04a388 --- /dev/null +++ b/frontend/src/modules/_shared/components/Toolbar/toolbar.tsx @@ -0,0 +1,16 @@ +export type ToolbarProps = { + hidden?: boolean; + children: React.ReactNode; +}; + +export function Toolbar(props: ToolbarProps): React.ReactNode { + if (props.hidden) { + return null; + } + + return ( +
+ {props.children} +
+ ); +} diff --git a/frontend/src/modules/_shared/components/Toolbar/toolbarDivider.tsx b/frontend/src/modules/_shared/components/Toolbar/toolbarDivider.tsx new file mode 100644 index 000000000..c3c141c7f --- /dev/null +++ b/frontend/src/modules/_shared/components/Toolbar/toolbarDivider.tsx @@ -0,0 +1,3 @@ +export function ToolBarDivider(): React.ReactNode { + return
; +} diff --git a/frontend/src/modules/registerAllModules.ts b/frontend/src/modules/registerAllModules.ts index f16f380d4..e4609ba35 100644 --- a/frontend/src/modules/registerAllModules.ts +++ b/frontend/src/modules/registerAllModules.ts @@ -1,5 +1,6 @@ import { isDevMode } from "@lib/utils/devMode"; +import "./2DViewer/registerModule"; import "./3DViewer/registerModule"; import "./DistributionPlot/registerModule"; import "./FlowNetwork/registerModule"; @@ -14,9 +15,8 @@ import "./SimulationTimeSeries/registerModule"; import "./SimulationTimeSeriesSensitivity/registerModule"; import "./SubsurfaceMap/registerModule"; import "./TornadoChart/registerModule"; -import "./WellCompletions/registerModule"; import "./Vfp/registerModule"; - +import "./WellCompletions/registerModule"; if (isDevMode()) { await import("./MyModule/registerModule");