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 000000000..0b82963c8 Binary files /dev/null and b/frontend/src/modules/2DViewer/preview.webp differ 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");