diff --git a/backend_py/primary/primary/routers/well/converters.py b/backend_py/primary/primary/routers/well/converters.py index f6d01f078..d00be3646 100644 --- a/backend_py/primary/primary/routers/well/converters.py +++ b/backend_py/primary/primary/routers/well/converters.py @@ -120,6 +120,9 @@ def convert_wellbore_perforation_to_schema( def convert_wellbore_log_curve_header_to_schema( wellbore_log_curve_header: WellboreLogCurveHeader, ) -> schemas.WellboreLogCurveHeader: + if wellbore_log_curve_header.log_name is None: + raise AttributeError("Missing log name is not allowed") + return schemas.WellboreLogCurveHeader( logName=wellbore_log_curve_header.log_name, curveName=wellbore_log_curve_header.curve_name, @@ -131,6 +134,9 @@ def convert_wellbore_log_curve_data_to_schema( wellbore_log_curve_data: WellboreLogCurveData, ) -> schemas.WellboreLogCurveData: return schemas.WellboreLogCurveData( + name=wellbore_log_curve_data.name, + unit=wellbore_log_curve_data.unit, + curveUnitDesc=wellbore_log_curve_data.curve_unit_desc, indexMin=wellbore_log_curve_data.index_min, indexMax=wellbore_log_curve_data.index_max, minCurveValue=wellbore_log_curve_data.min_curve_value, diff --git a/backend_py/primary/primary/routers/well/router.py b/backend_py/primary/primary/routers/well/router.py index 9d8e6c415..fdc35af6a 100644 --- a/backend_py/primary/primary/routers/well/router.py +++ b/backend_py/primary/primary/routers/well/router.py @@ -216,6 +216,8 @@ async def get_wellbore_log_curve_headers( return [ converters.convert_wellbore_log_curve_header_to_schema(wellbore_log_curve_header) for wellbore_log_curve_header in wellbore_log_curve_headers + # Missing log name implies garbage data, so we simply drop them + if wellbore_log_curve_header.log_name is not None ] diff --git a/backend_py/primary/primary/routers/well/schemas.py b/backend_py/primary/primary/routers/well/schemas.py index 8427292d0..783a1ece4 100644 --- a/backend_py/primary/primary/routers/well/schemas.py +++ b/backend_py/primary/primary/routers/well/schemas.py @@ -103,16 +103,19 @@ class WellborePerforation(BaseModel): class WellboreLogCurveHeader(BaseModel): logName: str curveName: str - curveUnit: str + curveUnit: str | None class WellboreLogCurveData(BaseModel): + name: str indexMin: float indexMax: float minCurveValue: float maxCurveValue: float - dataPoints: list[list[float | None]] - curveAlias: str - curveDescription: str + curveAlias: str | None + curveDescription: str | None indexUnit: str - noDataValue: float + noDataValue: float | None + unit: str + curveUnitDesc: str | None + dataPoints: list[list[float | None]] diff --git a/backend_py/primary/primary/services/ssdl_access/types.py b/backend_py/primary/primary/services/ssdl_access/types.py index 7e0660d94..84f07a0cf 100644 --- a/backend_py/primary/primary/services/ssdl_access/types.py +++ b/backend_py/primary/primary/services/ssdl_access/types.py @@ -1,3 +1,4 @@ +from typing import Any from pydantic import BaseModel @@ -34,18 +35,33 @@ class WellborePerforation(BaseModel): class WellboreLogCurveHeader(BaseModel): - log_name: str + log_name: str | None curve_name: str - curve_unit: str + curve_unit: str | None + + # Defining a hash-function to facilitate usage in Sets + def __hash__(self) -> int: + # No globally unique field, but curve-name should be unique (per wellbore) + return hash(self.curve_name + (self.log_name or "N/A")) + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, WellboreLogCurveHeader): + # delegate to the other item in the comparison + return NotImplemented + + return (self.curve_name, self.log_name) == (other.curve_name, other.log_name) class WellboreLogCurveData(BaseModel): + name: str index_min: float index_max: float min_curve_value: float max_curve_value: float - DataPoints: list[list[float | None]] - curve_alias: str - curve_description: str + curve_alias: str | None + curve_description: str | None index_unit: str - no_data_value: float + no_data_value: float | None + unit: str + curve_unit_desc: str | None + DataPoints: list[list[float | None]] diff --git a/backend_py/primary/primary/services/ssdl_access/well_access.py b/backend_py/primary/primary/services/ssdl_access/well_access.py index acdb4542f..da17dc28e 100644 --- a/backend_py/primary/primary/services/ssdl_access/well_access.py +++ b/backend_py/primary/primary/services/ssdl_access/well_access.py @@ -51,10 +51,12 @@ async def get_log_curve_headers_for_wellbore(self, wellbore_uuid: str) -> List[t endpoint = f"WellLog/{wellbore_uuid}" ssdl_data = await fetch_from_ssdl(access_token=self._ssdl_token, endpoint=endpoint, params=None) try: - result = [types.WellboreLogCurveHeader.model_validate(log_curve) for log_curve in ssdl_data] + # This endpoint is a bit weird, and MIGHT return duplicates which, as far as I can tell, are the exact same. Using a set to drop duplicates. See data model for comparator + result_set = {types.WellboreLogCurveHeader.model_validate(log_curve) for log_curve in ssdl_data} + except ValidationError as error: raise InvalidDataError(f"Invalid log curve headers for wellbore {wellbore_uuid}", Service.SSDL) from error - return result + return list(result_set) async def get_log_curve_headers_for_field(self, field_uuid: str) -> List[types.WellboreLogCurveHeader]: endpoint = f"WellLog/field/{field_uuid}" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4d40595bf..eabd00690 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "@webviz/group-tree-plot": "^1.1.14", "@webviz/subsurface-viewer": "^0.25.2", "@webviz/well-completions-plot": "^1.4.1", + "@webviz/well-log-viewer": "^1.12.7", "animate.css": "^4.1.1", "axios": "^1.6.5", "culori": "^3.2.0", @@ -890,6 +891,19 @@ "@equinor/videx-linear-algebra": "^1.0.7" } }, + "node_modules/@equinor/videx-wellog": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@equinor/videx-wellog/-/videx-wellog-0.10.3.tgz", + "integrity": "sha512-twcQXeXDLcl5szLJ3MDDBbR2ehg0uTgHmK4FTqDzfAGP6y6jVowxoz2V3vOzS5nsk63QsySiEuWNxYMpLwQJmQ==", + "dependencies": { + "@equinor/videx-math": "^1.1.0", + "d3-array": "^3.2.0", + "d3-scale": "^4.0.2", + "d3-selection": "^3.0.0", + "d3-shape": "^3.1.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz", @@ -5067,6 +5081,23 @@ "react-dom": "^18.0.0" } }, + "node_modules/@webviz/well-log-viewer": { + "version": "1.12.7", + "resolved": "https://registry.npmjs.org/@webviz/well-log-viewer/-/well-log-viewer-1.12.7.tgz", + "integrity": "sha512-evCrmtRRIBeQfHDSQmlQERBllCzXXR9mAVnK+sKwy4IH0i5enOEf6BpoVLmoA0eHLlum30H+tj3rEha2xT5ZFg==", + "dependencies": { + "@emerson-eps/color-tables": "^0.4.71", + "@equinor/videx-wellog": "^0.10.0", + "@webviz/wsc-common": "*", + "convert-units": "^2.3.4", + "d3": "^7.8.2" + }, + "peerDependencies": { + "@mui/material": "^5.11", + "react": "^17 || ^18", + "react-dom": "^17 || ^18" + } + }, "node_modules/@webviz/wsc-common": { "version": "0.0.1-alpha.1", "resolved": "https://registry.npmjs.org/@webviz/wsc-common/-/wsc-common-0.0.1-alpha.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index c817cbfa3..a4bc0ac9a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "@webviz/group-tree-plot": "^1.1.14", "@webviz/subsurface-viewer": "^0.25.2", "@webviz/well-completions-plot": "^1.4.1", + "@webviz/well-log-viewer": "^1.12.7", "animate.css": "^4.1.1", "axios": "^1.6.5", "culori": "^3.2.0", diff --git a/frontend/src/api/models/WellboreLogCurveData.ts b/frontend/src/api/models/WellboreLogCurveData.ts index d8d952bd4..81f8170df 100644 --- a/frontend/src/api/models/WellboreLogCurveData.ts +++ b/frontend/src/api/models/WellboreLogCurveData.ts @@ -3,14 +3,17 @@ /* tslint:disable */ /* eslint-disable */ export type WellboreLogCurveData = { + name: string; indexMin: number; indexMax: number; minCurveValue: number; maxCurveValue: number; - dataPoints: Array>; - curveAlias: string; - curveDescription: string; + curveAlias: (string | null); + curveDescription: (string | null); indexUnit: string; - noDataValue: number; + noDataValue: (number | null); + unit: string; + curveUnitDesc: (string | null); + dataPoints: Array>; }; diff --git a/frontend/src/api/models/WellboreLogCurveHeader.ts b/frontend/src/api/models/WellboreLogCurveHeader.ts index 12049f97e..3d804b915 100644 --- a/frontend/src/api/models/WellboreLogCurveHeader.ts +++ b/frontend/src/api/models/WellboreLogCurveHeader.ts @@ -5,6 +5,6 @@ export type WellboreLogCurveHeader = { logName: string; curveName: string; - curveUnit: string; + curveUnit: (string | null); }; diff --git a/frontend/src/framework/AtomStoreMaster.ts b/frontend/src/framework/AtomStoreMaster.ts index 43356331a..7f8a40932 100644 --- a/frontend/src/framework/AtomStoreMaster.ts +++ b/frontend/src/framework/AtomStoreMaster.ts @@ -1,5 +1,7 @@ import { WritableAtom, createStore } from "jotai"; +import { CurrentModuleInstanceIdAtom } from "./GlobalAtoms"; + export type AtomStore = ReturnType; export class AtomStoreMaster { @@ -16,6 +18,8 @@ export class AtomStoreMaster { makeAtomStoreForModuleInstance(moduleInstanceId: string) { const atomStore = createStore(); + // Make the module's own id available within each module's store + atomStore.set(CurrentModuleInstanceIdAtom, moduleInstanceId); this._atomStores.set(moduleInstanceId, atomStore); const atomStates = Array.from(this._atomStates.entries()); diff --git a/frontend/src/framework/GlobalAtoms.ts b/frontend/src/framework/GlobalAtoms.ts index 421bd4320..443e02abc 100644 --- a/frontend/src/framework/GlobalAtoms.ts +++ b/frontend/src/framework/GlobalAtoms.ts @@ -8,6 +8,10 @@ import { RealizationFilterSet } from "./RealizationFilterSet"; import { EnsembleRealizationFilterFunction } from "./WorkbenchSession"; import { atomWithCompare } from "./utils/atomUtils"; +/** A module's instance-id. Available in the jotai-store of each module, otherwise null */ +// ? Should this one be moved to `AtomStoreMaster.ts`? +export const CurrentModuleInstanceIdAtom = atom(null); + export const EnsembleSetAtom = atomWithCompare(new EnsembleSet([]), isEqual); /** diff --git a/frontend/src/framework/Module.tsx b/frontend/src/framework/Module.tsx index dc81aaee0..46bfd1888 100644 --- a/frontend/src/framework/Module.tsx +++ b/frontend/src/framework/Module.tsx @@ -15,6 +15,8 @@ import { WorkbenchServices } from "./WorkbenchServices"; import { WorkbenchSession } from "./WorkbenchSession"; import { WorkbenchSettings } from "./WorkbenchSettings"; +export type OnInstanceUnloadFunc = (instanceId: string) => void; + export enum ModuleCategory { MAIN = "main", SUB = "sub", @@ -105,6 +107,7 @@ export interface ModuleOptions { description?: string; channelDefinitions?: ChannelDefinition[]; channelReceiverDefinitions?: ChannelReceiverDefinition[]; + onUnloadInstanceFunc?: OnInstanceUnloadFunc; } export class Module { @@ -127,6 +130,7 @@ export class Module { private _workbench: Workbench | null = null; private _syncableSettingKeys: SyncSettingKey[]; private _drawPreviewFunc: DrawPreviewFunc | null; + private _onUnloadInstanceFunc: OnInstanceUnloadFunc | null; private _description: string | null; private _channelDefinitions: ChannelDefinition[] | null; private _channelReceiverDefinitions: ChannelReceiverDefinition[] | null; @@ -143,6 +147,7 @@ export class Module { this.settingsFC = () =>
Not defined
; this._syncableSettingKeys = options.syncableSettingKeys ?? []; this._drawPreviewFunc = options.drawPreviewFunc ?? null; + this._onUnloadInstanceFunc = options.onUnloadInstanceFunc ?? null; this._description = options.description ?? null; this._channelDefinitions = options.channelDefinitions ?? null; this._channelReceiverDefinitions = options.channelReceiverDefinitions ?? null; @@ -242,6 +247,10 @@ export class Module { return instance; } + onInstanceUnload(instanceId: string) { + this._onUnloadInstanceFunc?.(instanceId); + } + private setImportState(state: ImportState): void { this._importState = state; this._moduleInstances.forEach((instance) => { diff --git a/frontend/src/framework/ModuleInstance.ts b/frontend/src/framework/ModuleInstance.ts index 968e56d2f..f531aa8ac 100644 --- a/frontend/src/framework/ModuleInstance.ts +++ b/frontend/src/framework/ModuleInstance.ts @@ -347,6 +347,10 @@ export class ModuleInstance { getInitialSettings(): InitialSettings | null { return this._initialSettings; } + + unload() { + this._module.onInstanceUnload(this._id); + } } export function useModuleInstanceTopicValue( diff --git a/frontend/src/framework/ModuleRegistry.ts b/frontend/src/framework/ModuleRegistry.ts index c6fdc7766..e3898c270 100644 --- a/frontend/src/framework/ModuleRegistry.ts +++ b/frontend/src/framework/ModuleRegistry.ts @@ -1,5 +1,12 @@ import { ChannelDefinition, ChannelReceiverDefinition } from "./DataChannelTypes"; -import { InterfaceEffects, Module, ModuleCategory, ModuleDevState, ModuleInterfaceTypes } from "./Module"; +import { + InterfaceEffects, + Module, + ModuleCategory, + ModuleDevState, + ModuleInterfaceTypes, + OnInstanceUnloadFunc, +} from "./Module"; import { ModuleDataTagId } from "./ModuleDataTags"; import { DrawPreviewFunc } from "./Preview"; import { SyncSettingKey } from "./SyncSettings"; @@ -17,6 +24,7 @@ export type RegisterModuleOptions = { channelReceiverDefinitions?: ChannelReceiverDefinition[]; preview?: DrawPreviewFunc; description?: string; + onUnloadInstance?: OnInstanceUnloadFunc; }; export class ModuleNotFoundError extends Error { @@ -49,6 +57,7 @@ export class ModuleRegistry { channelDefinitions: options.channelDefinitions, channelReceiverDefinitions: options.channelReceiverDefinitions, drawPreviewFunc: options.preview, + onUnloadInstanceFunc: options.onUnloadInstance, description: options.description, }); this._registeredModules[options.moduleName] = module; diff --git a/frontend/src/framework/Workbench.ts b/frontend/src/framework/Workbench.ts index 71cbfe105..424813de4 100644 --- a/frontend/src/framework/Workbench.ts +++ b/frontend/src/framework/Workbench.ts @@ -182,8 +182,12 @@ export class Workbench { } removeModuleInstance(moduleInstanceId: string): void { - const manager = this.getModuleInstance(moduleInstanceId)?.getChannelManager(); - if (manager) { + const moduleInstance = this.getModuleInstance(moduleInstanceId); + + if (moduleInstance) { + const manager = moduleInstance.getChannelManager(); + + moduleInstance.unload(); manager.unregisterAllChannels(); manager.unregisterAllReceivers(); } diff --git a/frontend/src/framework/utils/arrays.ts b/frontend/src/framework/utils/arrays.ts new file mode 100644 index 000000000..1ee291e9a --- /dev/null +++ b/frontend/src/framework/utils/arrays.ts @@ -0,0 +1,14 @@ +/** + * Util method to do an immutable item move in an array + * @param array The array to move items in + * @param from The index of the first item being moved + * @param to The index the item(s) should be moved to + * @param moveAmt The amount of items (from the start-index) that should be moved + * @returns A copy of the original array, with it's items moved accordingly + */ +export function arrayMove(array: t[], from: number, to: number, moveAmt = 1): t[] { + const newArrray = [...array]; + const movedItems = newArrray.splice(from, moveAmt); + + return newArrray.toSpliced(to, 0, ...movedItems); +} diff --git a/frontend/src/framework/utils/atomUtils.ts b/frontend/src/framework/utils/atomUtils.ts index bfbf784ae..247fbd572 100644 --- a/frontend/src/framework/utils/atomUtils.ts +++ b/frontend/src/framework/utils/atomUtils.ts @@ -1,3 +1,4 @@ +import { CurrentModuleInstanceIdAtom } from "@framework/GlobalAtoms"; import { DefaultError, QueryClient, QueryKey, QueryObserverOptions, QueryObserverResult } from "@tanstack/query-core"; import { Atom, Getter, atom } from "jotai"; @@ -57,3 +58,54 @@ export function atomWithQueries< return results as TCombinedResult; }); } + +/** + * Creates an writeable atom that persists it's value in localstorage, but keeps it unique per module instance. **Note: must only be used within a module instances own store** + * @param storageKey Prefix for the key. Should be unique among a module's atoms. Actual storage key will be: `:` + * @param initialValue Initial atom value + * @returns A get/set atom + */ +export function atomWithModuleInstanceStorage(storageKey: string, initialValue: T) { + // simple atom, needed to trigger new lookups + const instanceValueAtom = atom(null); + + return atom( + (get) => { + const instanceId = getIntanceId(get); + const fullStorageKey = makeInstanceStorageKey(instanceId, storageKey); + const instanceValue = get(instanceValueAtom); + const storedValue = localStorage.getItem(fullStorageKey); + + if (instanceValue !== null) return instanceValue; + else if (storedValue !== null) return JSON.parse(storedValue); + else return initialValue; + }, + (get, set, newVal: T) => { + const instanceId = getIntanceId(get); + const fullStorageKey = makeInstanceStorageKey(instanceId, storageKey); + + localStorage.setItem(fullStorageKey, JSON.stringify(newVal)); + set(instanceValueAtom, newVal); + } + ); +} + +export function clearModuleInstanceStorage(instanceId: string, key: string) { + const fullKey = makeInstanceStorageKey(instanceId, key); + localStorage.removeItem(fullKey); +} + +function makeInstanceStorageKey(instanceId: string, key: string): string { + // ! Be mindful about changing this; existing stored values will dissapear! + return `${key}:${instanceId}`; +} + +function getIntanceId(get: Getter): string { + const id = get(CurrentModuleInstanceIdAtom); + + if (id === null) { + throw new Error("Module instance not set. Make sure this atom is only used within a module storage"); + } + + return id; +} diff --git a/frontend/src/lib/components/Dropdown/dropdown.tsx b/frontend/src/lib/components/Dropdown/dropdown.tsx index 441b6342c..ef98f6c70 100644 --- a/frontend/src/lib/components/Dropdown/dropdown.tsx +++ b/frontend/src/lib/components/Dropdown/dropdown.tsx @@ -20,6 +20,7 @@ export type DropdownOption = { adornment?: React.ReactNode; hoverText?: string; disabled?: boolean; + group?: string; }; export type DropdownProps = { @@ -32,10 +33,11 @@ export type DropdownProps = { width?: string | number; showArrows?: boolean; debounceTimeMs?: number; + placeholder?: string; } & BaseComponentProps; -const minHeight = 200; -const optionHeight = 32; +const MIN_HEIGHT = 200; +const OPTION_HEIGHT = 32; type DropdownRect = { left?: number; @@ -46,9 +48,36 @@ type DropdownRect = { minWidth: number; }; +type OptionListItem = + | { + type: "option"; + actualIndex: number; + content: DropdownOption; + } + | { + type: "separator"; + actualIndex: never; + content: string; + }; + const noMatchingOptionsText = "No matching options"; const noOptionsText = "No options"; +function makeOptionListItems(options: DropdownOption[]): OptionListItem[] { + const optionsWithSeperators: OptionListItem[] = options.flatMap((option, index) => { + const optionItem = { type: "option", actualIndex: index, content: option } as OptionListItem; + const seperatorItem = { type: "separator", content: option.group } as OptionListItem; + + if (option.group && option.group !== options[index - 1]?.group) { + return [seperatorItem, optionItem]; + } else { + return [optionItem]; + } + }); + + return optionsWithSeperators; +} + export function Dropdown(props: DropdownProps) { const { onChange } = props; @@ -63,9 +92,13 @@ export function Dropdown(props: DropdownProps) { const [filter, setFilter] = React.useState(null); const [selection, setSelection] = React.useState(props.value ?? null); const [prevValue, setPrevValue] = React.useState(props.value ?? null); - const [prevFilteredOptions, setPrevFilteredOptions] = React.useState[]>(props.options); + const [prevFilteredOptionsWithSeparators, setPrevFilteredOptionsWithSeparators] = React.useState< + OptionListItem[] + >(makeOptionListItems(props.options)); const [selectionIndex, setSelectionIndex] = React.useState(-1); - const [filteredOptions, setFilteredOptions] = React.useState[]>(props.options); + const [filteredOptionsWithSeparators, setFilteredOptionsWithSeparators] = React.useState[]>( + makeOptionListItems(props.options) + ); const [optionIndexWithFocus, setOptionIndexWithFocus] = React.useState(-1); const [startIndex, setStartIndex] = React.useState(0); const [keyboardFocus, setKeyboardFocus] = React.useState(false); @@ -78,11 +111,13 @@ export function Dropdown(props: DropdownProps) { const setOptionIndexWithFocusToCurrentSelection = React.useCallback( function handleFilteredOptionsChange() { - const index = filteredOptions.findIndex((option) => isEqual(option.value, selection)); + const index = filteredOptionsWithSeparators.findIndex( + (option) => option.type === "option" && isEqual(option.content.value, selection) + ); setSelectionIndex(index); setOptionIndexWithFocus(index); }, - [filteredOptions, selection] + [filteredOptionsWithSeparators, selection] ); if (!isEqual(prevValue, valueWithDefault)) { @@ -91,12 +126,12 @@ export function Dropdown(props: DropdownProps) { setPrevValue(valueWithDefault); } - if (!isEqual(prevFilteredOptions, filteredOptions)) { + if (!isEqual(prevFilteredOptionsWithSeparators, filteredOptionsWithSeparators)) { setOptionIndexWithFocusToCurrentSelection(); - setPrevFilteredOptions(filteredOptions); + setPrevFilteredOptionsWithSeparators(filteredOptionsWithSeparators); } - React.useEffect(function handleMount() { + React.useEffect(function mountEffect() { return function handleUnmount() { if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); @@ -105,7 +140,7 @@ export function Dropdown(props: DropdownProps) { }, []); React.useEffect( - function handleOptionsChange() { + function handleOptionsChangeEffect() { function handleMouseDown(event: MouseEvent) { if ( dropdownRef.current && @@ -115,7 +150,7 @@ export function Dropdown(props: DropdownProps) { ) { setDropdownVisible(false); setFilter(null); - setFilteredOptions(props.options); + setFilteredOptionsWithSeparators(makeOptionListItems(props.options)); setOptionIndexWithFocus(-1); } } @@ -130,7 +165,7 @@ export function Dropdown(props: DropdownProps) { ); React.useEffect( - function updateDropdownRectWidth() { + function updateDropdownRectWidthEffect() { let longestOptionWidth = props.options.reduce((prev, current) => { const labelWidth = getTextWidthWithFont(current.label, "Equinor", 1); const adornmentWidth = current.adornment ? convertRemToPixels((5 + 2) / 4) : 0; @@ -151,33 +186,38 @@ export function Dropdown(props: DropdownProps) { setDropdownRect((prev) => ({ ...prev, width: longestOptionWidth + 32 })); const newFilteredOptions = props.options.filter((option) => option.label.includes(filter || "")); - setFilteredOptions(newFilteredOptions); + setFilteredOptionsWithSeparators(makeOptionListItems(newFilteredOptions)); }, [props.options, filter] ); React.useEffect( - function computeDropdownRect() { + function computeDropdownRectEffect() { if (dropdownVisible) { const inputClientBoundingRect = inputRef.current?.getBoundingClientRect(); const bodyClientBoundingRect = document.body.getBoundingClientRect(); - const height = Math.min(minHeight, Math.max(filteredOptions.length * optionHeight, optionHeight)) + 2; + const preferredHeight = + Math.min( + MIN_HEIGHT, + Math.max(filteredOptionsWithSeparators.length * OPTION_HEIGHT, OPTION_HEIGHT) + ) + 2; if (inputClientBoundingRect && bodyClientBoundingRect) { const newDropdownRect: DropdownRect = { minWidth: inputBoundingRect.width, width: dropdownRect.width, - height: height, + height: preferredHeight, }; - if (inputClientBoundingRect.y + inputBoundingRect.height + height > window.innerHeight) { + if (inputClientBoundingRect.y + inputBoundingRect.height + preferredHeight > window.innerHeight) { + const height = Math.min(inputClientBoundingRect.y, preferredHeight); newDropdownRect.top = inputClientBoundingRect.y - height; - newDropdownRect.height = Math.min(height, inputClientBoundingRect.y); + newDropdownRect.height = height; } else { newDropdownRect.top = inputClientBoundingRect.y + inputBoundingRect.height; newDropdownRect.height = Math.min( - height, + preferredHeight, window.innerHeight - inputClientBoundingRect.y - inputBoundingRect.height ); } @@ -194,8 +234,10 @@ export function Dropdown(props: DropdownProps) { Math.max( 0, Math.round( - (filteredOptions.findIndex((option) => option.value === selection) || 0) - - height / optionHeight / 2 + (filteredOptionsWithSeparators.findIndex( + (option) => option.type === "option" && option.content.value === selection + ) || 0) - + preferredHeight / OPTION_HEIGHT / 2 ) ) ); @@ -206,7 +248,7 @@ export function Dropdown(props: DropdownProps) { [ inputBoundingRect, dropdownVisible, - filteredOptions, + filteredOptionsWithSeparators, selection, dropdownRect.width, props.options, @@ -242,7 +284,7 @@ export function Dropdown(props: DropdownProps) { setSelectionIndex(props.options.findIndex((option) => isEqual(option.value, value))); setDropdownVisible(false); setFilter(null); - setFilteredOptions(props.options); + setFilteredOptionsWithSeparators(makeOptionListItems(props.options)); setOptionIndexWithFocus(-1); handleOnChange(value); @@ -253,14 +295,14 @@ export function Dropdown(props: DropdownProps) { setSelectionIndex, setDropdownVisible, setFilter, - setFilteredOptions, + setFilteredOptionsWithSeparators, setSelection, handleOnChange, ] ); React.useEffect( - function addKeyDownEventHandler() { + function addKeyDownEventHandlerEffect() { function handleKeyDown(e: KeyboardEvent) { if (e.key === "Escape") { setDropdownVisible(false); @@ -269,35 +311,63 @@ export function Dropdown(props: DropdownProps) { inputRef.current?.blur(); } if (dropdownRef.current) { - const currentStartIndex = Math.round(dropdownRef.current?.scrollTop / optionHeight); + const currentStartIndex = Math.round(dropdownRef.current?.scrollTop / OPTION_HEIGHT); if (dropdownVisible) { if (e.key === "ArrowUp") { e.preventDefault(); - const adjustedOptionIndexWithFocus = + let adjustedOptionIndexWithFocus = optionIndexWithFocus === -1 ? selectionIndex : optionIndexWithFocus; - const newIndex = Math.max(0, adjustedOptionIndexWithFocus - 1); + adjustedOptionIndexWithFocus--; + + // Make sure we are focusing on an option and not a separator + const item = filteredOptionsWithSeparators[adjustedOptionIndexWithFocus]; + let scrollToTop = false; + if (item && item.type !== "option") { + if (adjustedOptionIndexWithFocus === 0) { + adjustedOptionIndexWithFocus = 1; + scrollToTop = true; + } else { + adjustedOptionIndexWithFocus--; + } + } + + const newIndex = Math.max(0, adjustedOptionIndexWithFocus); setOptionIndexWithFocus(newIndex); - if (newIndex < currentStartIndex) { - setStartIndex(newIndex); + const newStartIndex = newIndex - (scrollToTop ? 1 : 0); + if (newStartIndex < currentStartIndex) { + setStartIndex(newStartIndex); } setKeyboardFocus(true); } if (e.key === "ArrowDown") { e.preventDefault(); - const adjustedOptionIndexWithFocus = + let adjustedOptionIndexWithFocus = optionIndexWithFocus === -1 ? selectionIndex : optionIndexWithFocus; - const newIndex = Math.min(filteredOptions.length - 1, adjustedOptionIndexWithFocus + 1); + adjustedOptionIndexWithFocus++; + + // Make sure we are focusing on an option and not a separator + const item = filteredOptionsWithSeparators[adjustedOptionIndexWithFocus]; + if (item && item.type !== "option") { + adjustedOptionIndexWithFocus++; + } + + const newIndex = Math.min( + filteredOptionsWithSeparators.length - 1, + adjustedOptionIndexWithFocus + ); + setOptionIndexWithFocus(newIndex); - if (newIndex >= currentStartIndex + minHeight / optionHeight - 1) { - setStartIndex(Math.max(0, newIndex - minHeight / optionHeight + 1)); + if (newIndex >= currentStartIndex + MIN_HEIGHT / OPTION_HEIGHT - 1) { + setStartIndex(Math.max(0, newIndex - MIN_HEIGHT / OPTION_HEIGHT + 1)); } setKeyboardFocus(true); } if (e.key === "Enter") { e.preventDefault(); - const option = filteredOptions[keyboardFocus ? optionIndexWithFocus : selectionIndex]; - if (option && !option.disabled) { - handleOptionClick(option.value); + const option = + filteredOptionsWithSeparators[keyboardFocus ? optionIndexWithFocus : selectionIndex]; + if (option && option.type === "option" && !option.content.disabled) { + handleOptionClick(option.content.value); } } } @@ -312,7 +382,7 @@ export function Dropdown(props: DropdownProps) { }, [ selection, - filteredOptions, + filteredOptionsWithSeparators, dropdownVisible, startIndex, handleOptionClick, @@ -330,7 +400,7 @@ export function Dropdown(props: DropdownProps) { function handleInputChange(event: React.ChangeEvent) { setFilter(event.target.value); const newFilteredOptions = props.options.filter((option) => option.label.includes(event.target.value)); - setFilteredOptions(newFilteredOptions); + setFilteredOptionsWithSeparators(makeOptionListItems(newFilteredOptions)); setSelectionIndex(newFilteredOptions.findIndex((option) => isEqual(option.value, selection))); }, [props.options, selection] @@ -357,8 +427,20 @@ export function Dropdown(props: DropdownProps) { } function handleSelectPreviousOption() { - const newIndex = Math.max(0, selectionIndex - 1); - const newValue = filteredOptions[newIndex].value; + let newIndex = Math.max(0, selectionIndex - 1); + let item = filteredOptionsWithSeparators[newIndex]; + if (item && item.type === "separator") { + if (newIndex === 0) { + newIndex = 1; + } else { + newIndex--; + } + item = filteredOptionsWithSeparators[newIndex]; + } + if (!item || item.type !== "option") { + throw new Error("Every separator should be followed by an option"); + } + const newValue = item.content.value; setSelectionIndex(newIndex); setSelection(newValue); handleOnChange(newValue); @@ -366,14 +448,39 @@ export function Dropdown(props: DropdownProps) { } function handleSelectNextOption() { - const newIndex = Math.min(filteredOptions.length - 1, selectionIndex + 1); - const newValue = filteredOptions[newIndex].value; + let newIndex = Math.min(filteredOptionsWithSeparators.length - 1, selectionIndex + 1); + let item = filteredOptionsWithSeparators[newIndex]; + if (item && item.type === "separator") { + newIndex++; + item = filteredOptionsWithSeparators[newIndex]; + } + if (newIndex >= filteredOptionsWithSeparators.length - 1 || !item || item.type !== "option") { + throw new Error("Every separator should be followed by an option"); + } + const newValue = item.content.value; setSelectionIndex(newIndex); setSelection(newValue); handleOnChange(newValue); setOptionIndexWithFocus(-1); } + function renderItem(item: OptionListItem, index: number) { + if (item.type === "separator") { + return ; + } else { + return ( + handlePointerOver(index)} + /> + ); + } + } + return (
@@ -400,6 +507,7 @@ export function Dropdown(props: DropdownProps) { onChange={handleInputChange} value={makeInputValue()} rounded={props.showArrows ? "left" : "all"} + placeholder={props.placeholder} />
{props.showArrows && ( @@ -420,8 +528,8 @@ export function Dropdown(props: DropdownProps) { className={resolveClassNames( "border border-gray-300 hover:bg-blue-100 rounded-tr cursor-pointer", { - "pointer-events-none": selectionIndex >= filteredOptions.length - 1, - "text-gray-400": selectionIndex >= filteredOptions.length - 1, + "pointer-events-none": selectionIndex >= filteredOptionsWithSeparators.length - 1, + "text-gray-400": selectionIndex >= filteredOptionsWithSeparators.length - 1, } )} onClick={handleSelectNextOption} @@ -437,63 +545,23 @@ export function Dropdown(props: DropdownProps) { style={{ ...dropdownRect }} ref={dropdownRef} > - {filteredOptions.length === 0 && ( + {filteredOptionsWithSeparators.length === 0 && (
{props.options.length === 0 || filter === "" ? noOptionsText : noMatchingOptionsText}
)} - ( -
{ - if (option.disabled) { - return; - } - handleOptionClick(option.value); - }} - style={{ height: optionHeight }} - onPointerMove={() => handlePointerOver(index)} - title={option.hoverText ?? option.label} - > - - {option.adornment && ( - - {option.adornment} - - )} - {option.label} - -
- )} - /> +
+ +
)} @@ -501,4 +569,49 @@ export function Dropdown(props: DropdownProps) { ); } +type OptionProps = DropdownOption & { + isSelected: boolean; + isFocused: boolean; + onSelect: (value: TValue) => void; + onPointerOver: (value: TValue) => void; +}; + +function OptionItem(props: OptionProps): React.ReactNode { + return ( +
props.onPointerOver(props.value)} + onClick={() => !props.disabled && props.onSelect(props.value)} + > + + {props.adornment && {props.adornment}} + {props.label} + +
+ ); +} + +function SeparatorItem(props: { text: string }): React.ReactNode { + return ( +
+ {props.text} +
+ ); +} + Dropdown.displayName = "Dropdown"; diff --git a/frontend/src/lib/components/Virtualization/virtualization.tsx b/frontend/src/lib/components/Virtualization/virtualization.tsx index b293e2dea..4c770287d 100644 --- a/frontend/src/lib/components/Virtualization/virtualization.tsx +++ b/frontend/src/lib/components/Virtualization/virtualization.tsx @@ -97,81 +97,87 @@ export const Virtualization = withDefaults()(defaultProps, } } - React.useEffect(() => { - if (props.containerRef.current && initialScrollPositions) { - setIsProgrammaticScroll(true); - props.containerRef.current.scrollTop = initialScrollPositions.top; - props.containerRef.current.scrollLeft = initialScrollPositions.left; - } - }, [props.containerRef, initialScrollPositions]); - - React.useEffect(() => { - let lastScrollPosition = -1; - function handleScroll() { - if (isProgrammaticScroll) { - setIsProgrammaticScroll(false); - return; + React.useEffect( + function applyInitialScrollPositionEffect() { + if (props.containerRef.current && initialScrollPositions) { + setIsProgrammaticScroll(true); + props.containerRef.current.scrollTop = initialScrollPositions.top; + props.containerRef.current.scrollLeft = initialScrollPositions.left; } - if (props.containerRef.current) { - const scrollPosition = - props.direction === "vertical" - ? props.containerRef.current.scrollTop - : props.containerRef.current.scrollLeft; + }, + [props.containerRef, initialScrollPositions] + ); - if (scrollPosition === lastScrollPosition) { + React.useEffect( + function mountScrollEffect() { + let lastScrollPosition = -1; + function handleScroll() { + if (isProgrammaticScroll) { + setIsProgrammaticScroll(false); return; } - - lastScrollPosition = scrollPosition; - - const size = props.direction === "vertical" ? containerSize.height : containerSize.width; - - const startIndex = Math.max(0, Math.floor(scrollPosition / props.itemSize) - 1); - const endIndex = Math.min( - props.items.length - 1, - Math.ceil((scrollPosition + size) / props.itemSize) + 1 - ); - - setRange({ start: startIndex, end: endIndex }); - setPlaceholderSizes({ - start: startIndex * props.itemSize, - end: (props.items.length - 1 - endIndex) * props.itemSize, - }); - - if (onScroll) { - onScroll(startIndex); + if (props.containerRef.current) { + const scrollPosition = + props.direction === "vertical" + ? props.containerRef.current.scrollTop + : props.containerRef.current.scrollLeft; + + if (scrollPosition === lastScrollPosition) { + return; + } + + lastScrollPosition = scrollPosition; + + const size = props.direction === "vertical" ? containerSize.height : containerSize.width; + + const startIndex = Math.max(0, Math.floor(scrollPosition / props.itemSize) - 1); + const endIndex = Math.min( + props.items.length - 1, + Math.ceil((scrollPosition + size) / props.itemSize) + 1 + ); + + setRange({ start: startIndex, end: endIndex }); + setPlaceholderSizes({ + start: startIndex * props.itemSize, + end: (props.items.length - 1 - endIndex) * props.itemSize, + }); + + if (onScroll) { + onScroll(startIndex); + } } } - } - if (props.containerRef.current) { - props.containerRef.current.addEventListener("scroll", handleScroll); - } - handleScroll(); - - return () => { if (props.containerRef.current) { - props.containerRef.current.removeEventListener("scroll", handleScroll); + props.containerRef.current.addEventListener("scroll", handleScroll); } - }; - }, [ - props.containerRef, - props.direction, - props.items, - props.itemSize, - containerSize.height, - containerSize.width, - onScroll, - isProgrammaticScroll, - ]); - - const makeStyle = (size: number) => { + handleScroll(); + + return function unmountScrollEffect() { + if (props.containerRef.current) { + props.containerRef.current.removeEventListener("scroll", handleScroll); + } + }; + }, + [ + props.containerRef, + props.direction, + props.items, + props.itemSize, + containerSize.height, + containerSize.width, + onScroll, + isProgrammaticScroll, + ] + ); + + function makeStyle(size: number) { if (props.direction === "vertical") { return { height: size }; } else { return { width: size }; } - }; + } return ( <> diff --git a/frontend/src/modules/3DViewer/settings/atoms/baseAtoms.ts b/frontend/src/modules/3DViewer/settings/atoms/baseAtoms.ts index 72c62537b..f7a756ef5 100644 --- a/frontend/src/modules/3DViewer/settings/atoms/baseAtoms.ts +++ b/frontend/src/modules/3DViewer/settings/atoms/baseAtoms.ts @@ -10,6 +10,8 @@ export const showIntersectionAtom = atom(false); export const gridLayerAtom = atom(1); export const intersectionExtensionLengthAtom = atom(1000); export const colorScaleAtom = atom(null); +export const viewerHorizontalAtom = atom(false); + export const useCustomBoundsAtom = atom(false); export const intersectionTypeAtom = atom(IntersectionType.WELLBORE); export const addCustomIntersectionPolylineEditModeActiveAtom = atom(false); diff --git a/frontend/src/modules/MyModule2/atoms.ts b/frontend/src/modules/MyModule2/atoms.ts index fb658d0b0..3a5af1c1e 100644 --- a/frontend/src/modules/MyModule2/atoms.ts +++ b/frontend/src/modules/MyModule2/atoms.ts @@ -1,6 +1,7 @@ import { apiService } from "@framework/ApiService"; import { EnsembleIdent } from "@framework/EnsembleIdent"; import { EnsembleSetAtom } from "@framework/GlobalAtoms"; +import { atomWithModuleInstanceStorage, clearModuleInstanceStorage } from "@framework/utils/atomUtils"; import { atom } from "jotai"; import { atomWithQuery } from "jotai-tanstack-query"; @@ -35,3 +36,8 @@ export const ensembleSetDependentAtom = atom((get) => { const firstEnsemble = ensembleSet.getEnsembleArr()[0]; return firstEnsemble?.getIdent() ?? null; }); + +export const persistentTextSettingAtom = atomWithModuleInstanceStorage("myPersistentValue", ""); +export function cleanUpInstanceAtomStorage(instanceId: string) { + clearModuleInstanceStorage(instanceId, "myPersistentValue"); +} diff --git a/frontend/src/modules/MyModule2/interfaces.ts b/frontend/src/modules/MyModule2/interfaces.ts index c74218dd6..4b29f99d9 100644 --- a/frontend/src/modules/MyModule2/interfaces.ts +++ b/frontend/src/modules/MyModule2/interfaces.ts @@ -1,10 +1,11 @@ import { InterfaceInitialization } from "@framework/UniDirectionalModuleComponentsInterface"; -import { textAtom } from "./atoms"; +import { persistentTextSettingAtom, textAtom } from "./atoms"; type SettingsToViewInterface = { text: string; derivedText: string; + persistentText: string; }; export type Interfaces = { @@ -13,5 +14,6 @@ export type Interfaces = { export const settingsToViewInterfaceInitialization: InterfaceInitialization = { text: (get) => get(textAtom), + persistentText: (get) => get(persistentTextSettingAtom), derivedText: (get) => get(textAtom).toUpperCase(), }; diff --git a/frontend/src/modules/MyModule2/registerModule.ts b/frontend/src/modules/MyModule2/registerModule.ts index 0bfcc8169..76dbc30b5 100644 --- a/frontend/src/modules/MyModule2/registerModule.ts +++ b/frontend/src/modules/MyModule2/registerModule.ts @@ -1,6 +1,7 @@ import { ModuleCategory, ModuleDevState } from "@framework/Module"; import { ModuleRegistry } from "@framework/ModuleRegistry"; +import { cleanUpInstanceAtomStorage } from "./atoms"; import { Interfaces } from "./interfaces"; ModuleRegistry.registerModule({ @@ -8,4 +9,11 @@ ModuleRegistry.registerModule({ defaultTitle: "My Module 2", category: ModuleCategory.DEBUG, devState: ModuleDevState.DEV, + + // Runs whenever you cross out a module + onUnloadInstance(instanceId: string) { + // eslint-disable-next-line no-console + console.log(`Moduel instance ${instanceId} is unloading...`); + cleanUpInstanceAtomStorage(instanceId); + }, }); diff --git a/frontend/src/modules/WellLogViewer/interfaces.ts b/frontend/src/modules/WellLogViewer/interfaces.ts new file mode 100644 index 000000000..da0c8e06f --- /dev/null +++ b/frontend/src/modules/WellLogViewer/interfaces.ts @@ -0,0 +1,37 @@ +import { WellboreHeader_api } from "@api"; +import { InterfaceInitialization } from "@framework/UniDirectionalModuleComponentsInterface"; +import { WellPicksLayerData } from "@modules/Intersection/utils/layers/WellpicksLayer"; +import { TemplateTrack } from "@webviz/well-log-viewer/dist/components/WellLogTemplateTypes"; + +import { + allSelectedWellLogCurvesAtom, + selectedFieldIdentifierAtom, + selectedWellboreAtom, + selectedWellborePicksAtom, + wellLogTemplateTracks, +} from "./settings/atoms/derivedAtoms"; +import { padDataWithEmptyRowsAtom, viewerHorizontalAtom } from "./settings/atoms/persistedAtoms"; + +export type InterfaceTypes = { + settingsToView: SettingsToViewInterface; +}; + +export type SettingsToViewInterface = { + selectedField: string | null; + wellboreHeader: WellboreHeader_api | null; + requiredDataCurves: string[]; + templateTracks: TemplateTrack[]; + viewerHorizontal: boolean; + padDataWithEmptyRows: boolean; + selectedWellborePicks: WellPicksLayerData; +}; + +export const settingsToViewInterfaceInitialization: InterfaceInitialization = { + selectedField: (get) => get(selectedFieldIdentifierAtom), + wellboreHeader: (get) => get(selectedWellboreAtom), + templateTracks: (get) => get(wellLogTemplateTracks), + requiredDataCurves: (get) => get(allSelectedWellLogCurvesAtom), + viewerHorizontal: (get) => get(viewerHorizontalAtom), + padDataWithEmptyRows: (get) => get(padDataWithEmptyRowsAtom), + selectedWellborePicks: (get) => get(selectedWellborePicksAtom), +}; diff --git a/frontend/src/modules/WellLogViewer/loadModule.tsx b/frontend/src/modules/WellLogViewer/loadModule.tsx new file mode 100644 index 000000000..3381cca53 --- /dev/null +++ b/frontend/src/modules/WellLogViewer/loadModule.tsx @@ -0,0 +1,13 @@ +import { ModuleRegistry } from "@framework/ModuleRegistry"; + +import { InterfaceTypes, 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.viewFC = View; +module.settingsFC = Settings; diff --git a/frontend/src/modules/WellLogViewer/registerModule.ts b/frontend/src/modules/WellLogViewer/registerModule.ts new file mode 100644 index 000000000..1d1d8f88d --- /dev/null +++ b/frontend/src/modules/WellLogViewer/registerModule.ts @@ -0,0 +1,31 @@ +/** + * Well log viewer module. + * @author Anders Rantala Hunderi + * @since 08.14.2024 + */ +import { ModuleCategory, ModuleDevState } from "@framework/Module"; +import { ModuleRegistry } from "@framework/ModuleRegistry"; +import { SyncSettingKey } from "@framework/SyncSettings"; + +import { InterfaceTypes } from "./interfaces"; +import { clearStorageForInstance } from "./settings/atoms/persistedAtoms"; + +export const MODULE_NAME = "WellLogViewer"; +const MODULE_TITLE = "Well log Viewer"; +// TODO: Better description +const MODULE_DESCRIPTION = "Well log Viewer"; +// TODO: preview Icon + +ModuleRegistry.registerModule({ + moduleName: MODULE_NAME, + defaultTitle: MODULE_TITLE, + description: MODULE_DESCRIPTION, + + category: ModuleCategory.MAIN, + devState: ModuleDevState.DEV, + + syncableSettingKeys: [SyncSettingKey.INTERSECTION, SyncSettingKey.VERTICAL_SCALE], + onUnloadInstance(instanceId) { + clearStorageForInstance(instanceId); + }, +}); diff --git a/frontend/src/modules/WellLogViewer/settings/atoms/baseAtoms.ts b/frontend/src/modules/WellLogViewer/settings/atoms/baseAtoms.ts new file mode 100644 index 000000000..5e0f79f75 --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/atoms/baseAtoms.ts @@ -0,0 +1,6 @@ +import { atom } from "jotai"; + +export const userSelectedFieldIdentifierAtom = atom(null); +export const userSelectedWellboreUuidAtom = atom(null); +export const userSelectedUnitWellpicksAtom = atom([]); +export const userSelectedNonUnitWellpicksAtom = atom([]); diff --git a/frontend/src/modules/WellLogViewer/settings/atoms/derivedAtoms.ts b/frontend/src/modules/WellLogViewer/settings/atoms/derivedAtoms.ts new file mode 100644 index 000000000..95e5393eb --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/atoms/derivedAtoms.ts @@ -0,0 +1,107 @@ +import { WellboreHeader_api, WellboreLogCurveHeader_api } from "@api"; +import { EnsembleSetAtom } from "@framework/GlobalAtoms"; +import { WellPicksLayerData } from "@modules/Intersection/utils/layers/WellpicksLayer"; +import { TemplatePlot, TemplateTrack } from "@webviz/well-log-viewer/dist/components/WellLogTemplateTypes"; + +import { atom } from "jotai"; +import _, { Dictionary } from "lodash"; + +import { + userSelectedFieldIdentifierAtom, + userSelectedNonUnitWellpicksAtom, + userSelectedUnitWellpicksAtom, + userSelectedWellboreUuidAtom, +} from "./baseAtoms"; +import { logViewerTrackConfigs } from "./persistedAtoms"; +import { + drilledWellboreHeadersQueryAtom, + wellLogCurveHeadersQueryAtom, + wellborePicksAndStratigraphyQueryAtom, +} from "./queryAtoms"; + +export const selectedEnsembleSetAtom = atom((get) => { + const ensembleSetArr = get(EnsembleSetAtom).getEnsembleArr(); + const selectedFieldId = get(userSelectedFieldIdentifierAtom); + + if (ensembleSetArr.length < 1) { + return null; + } else { + const selectedEnsemble = ensembleSetArr.find((e) => e.getFieldIdentifier() === selectedFieldId); + + return selectedEnsemble ?? ensembleSetArr[0]; + } +}); + +export const selectedFieldIdentifierAtom = atom((get) => { + return get(selectedEnsembleSetAtom)?.getFieldIdentifier() ?? null; +}); + +export const selectedWellboreAtom = atom((get) => { + const availableWellboreHeaders = get(drilledWellboreHeadersQueryAtom)?.data; + const selectedWellboreId = get(userSelectedWellboreUuidAtom); + + return getSelectedWellboreHeader(selectedWellboreId, availableWellboreHeaders); +}); + +export const selectedWellborePicksAtom = atom((get) => { + const wellborePicks = get(wellborePicksAndStratigraphyQueryAtom)?.data; + const selectedUnitPicks = get(userSelectedUnitWellpicksAtom); + const selectedNonUnitPicks = get(userSelectedNonUnitWellpicksAtom); + + if (!wellborePicks) return { unitPicks: [], nonUnitPicks: [] }; + else { + const unitPicks = wellborePicks.unitPicks.filter((pick) => selectedUnitPicks.includes(pick.name)); + const nonUnitPicks = wellborePicks.nonUnitPicks.filter((pick) => + selectedNonUnitPicks.includes(pick.identifier) + ); + + return { unitPicks, nonUnitPicks }; + } +}); + +export const groupedCurveHeadersAtom = atom>((get) => { + const logCurveHeaders = get(wellLogCurveHeadersQueryAtom)?.data ?? []; + + return _.groupBy(logCurveHeaders, "logName"); +}); + +export const wellLogTemplateTracks = atom((get) => { + const templateTrackConfigs = get(logViewerTrackConfigs); + + return templateTrackConfigs.map((config): TemplateTrack => { + return { + ...config, + plots: config.plots.filter(({ _isValid }) => _isValid) as TemplatePlot[], + }; + }); +}); + +export const allSelectedWellLogCurvesAtom = atom((get) => { + const templateTracks = get(wellLogTemplateTracks); + + const curveNames = templateTracks.reduce((acc, trackCfg) => { + const usedCurves = _.flatMap(trackCfg.plots, ({ name, name2 }) => { + if (name2) return [name, name2]; + else return [name]; + }); + + return _.uniq([...acc, ...usedCurves]); + }, []); + + return curveNames; +}); + +function getSelectedWellboreHeader( + currentId: string | null, + wellboreHeaderSet: WellboreHeader_api[] | null | undefined +): WellboreHeader_api | null { + if (!wellboreHeaderSet || wellboreHeaderSet.length < 1) { + return null; + } + + if (!currentId) { + return wellboreHeaderSet[0]; + } + + return wellboreHeaderSet.find((wh) => wh.wellboreUuid === currentId) ?? wellboreHeaderSet[0]; +} diff --git a/frontend/src/modules/WellLogViewer/settings/atoms/persistedAtoms.ts b/frontend/src/modules/WellLogViewer/settings/atoms/persistedAtoms.ts new file mode 100644 index 000000000..56124755b --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/atoms/persistedAtoms.ts @@ -0,0 +1,55 @@ +import { atomWithModuleInstanceStorage, clearModuleInstanceStorage } from "@framework/utils/atomUtils"; +import { TemplatePlot, TemplateTrack } from "@webviz/well-log-viewer/dist/components/WellLogTemplateTypes"; + +import { Getter, Setter, atom } from "jotai"; +import { Dictionary } from "lodash"; + +/** + * Extension of the template track type with additional fields used while editing + */ +export type TemplatePlotConfig = Partial & { + // Used for state updates + _id: string; + // Wether the config has all required fields for it's curve-type + _isValid: boolean; + // This is used as the value for dropdowns. Even if the curvename is supposed to be unique, In some rare cases, the curvename is duplicated across different well-logs. + _logAndName: `${string}::${string}`; +}; +export type TemplateTrackConfig = Omit & { + // ID used to allow the settings-menu to drag-sort them + _id: string; + plots: TemplatePlotConfig[]; +}; + +const STORAGE_KEY = "moduleSettings"; +const moduleSettingsAtom = atomWithModuleInstanceStorage>(STORAGE_KEY, {}); + +function getPersistentModuleField(get: Getter, valueKey: string, defaultValue: any): typeof defaultValue { + return get(moduleSettingsAtom)[valueKey] ?? defaultValue; +} + +function setPersistentModuleField(get: Getter, set: Setter, valueKey: string, newValue: any) { + const storageCopy = { ...get(moduleSettingsAtom) }; + storageCopy[valueKey] = newValue; + + set(moduleSettingsAtom, storageCopy); +} + +export const logViewerTrackConfigs = atom( + (get) => getPersistentModuleField(get, "logViewerTrackConfigs", []), + (get, set, newVal) => setPersistentModuleField(get, set, "logViewerTrackConfigs", newVal) +); + +export const viewerHorizontalAtom = atom( + (get) => getPersistentModuleField(get, "viewerHorizontal", false), + (get, set, newVal) => setPersistentModuleField(get, set, "viewerHorizontal", newVal) +); + +export const padDataWithEmptyRowsAtom = atom( + (get) => getPersistentModuleField(get, "padDataWithEmptyRows", true), + (get, set, newVal) => setPersistentModuleField(get, set, "padDataWithEmptyRows", newVal) +); + +export function clearStorageForInstance(instanceId: string) { + clearModuleInstanceStorage(instanceId, STORAGE_KEY); +} diff --git a/frontend/src/modules/WellLogViewer/settings/atoms/queryAtoms.ts b/frontend/src/modules/WellLogViewer/settings/atoms/queryAtoms.ts new file mode 100644 index 000000000..809f7b0cc --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/atoms/queryAtoms.ts @@ -0,0 +1,66 @@ +import { transformFormationData } from "@equinor/esv-intersection"; +import { apiService } from "@framework/ApiService"; +import { WellPicksLayerData } from "@modules/Intersection/utils/layers/WellpicksLayer"; + +import { atomWithQuery } from "jotai-tanstack-query"; +import _ from "lodash"; + +import { selectedEnsembleSetAtom, selectedFieldIdentifierAtom, selectedWellboreAtom } from "./derivedAtoms"; + +const STALE_TIME = 60 * 1000; +const CACHE_TIME = 60 * 1000; + +const SHARED_QUERY_OPTS = { + staleTime: STALE_TIME, + gcTime: CACHE_TIME, +}; + +export const drilledWellboreHeadersQueryAtom = atomWithQuery((get) => { + const fieldId = get(selectedFieldIdentifierAtom) ?? ""; + + return { + queryKey: ["getDrilledWellboreHeader", fieldId], + queryFn: () => apiService.well.getDrilledWellboreHeaders(fieldId), + enabled: Boolean(fieldId), + ...SHARED_QUERY_OPTS, + }; +}); + +/* ! Note + No logs are returned for any of the Drogon wells, afaik. Found a working set using in one of the TROLL ones. Some of them are still on the old system, so just click around until you find a working one + +*/ +export const wellLogCurveHeadersQueryAtom = atomWithQuery((get) => { + const wellboreId = get(selectedWellboreAtom)?.wellboreUuid; + + return { + queryKey: ["getWellboreLogCurveHeaders", wellboreId], + queryFn: () => apiService.well.getWellboreLogCurveHeaders(wellboreId ?? ""), + enabled: Boolean(wellboreId), + ...SHARED_QUERY_OPTS, + }; +}); + +export const wellborePicksAndStratigraphyQueryAtom = atomWithQuery((get) => { + const selectedEnsemble = get(selectedEnsembleSetAtom); + + const wellboreId = get(selectedWellboreAtom)?.wellboreUuid ?? ""; + const caseId = selectedEnsemble?.getIdent()?.getCaseUuid() ?? ""; + + return { + queryKey: ["getWellborePicksAndStratigraphicUnits", wellboreId, caseId], + enabled: Boolean(caseId && wellboreId), + queryFn: async () => { + const data = await apiService.well.getWellborePicksAndStratigraphicUnits(caseId, wellboreId); + + const transformedData = transformFormationData(data.wellbore_picks, data.stratigraphic_units as any); + + // ! Sometimes the transformation data returns duplicate entries, filtering them out + return { + nonUnitPicks: _.uniqBy(transformedData.nonUnitPicks, "identifier"), + unitPicks: _.uniqBy(transformedData.unitPicks, "name"), + }; + }, + ...SHARED_QUERY_OPTS, + }; +}); diff --git a/frontend/src/modules/WellLogViewer/settings/components/AddItemButton.tsx b/frontend/src/modules/WellLogViewer/settings/components/AddItemButton.tsx new file mode 100644 index 000000000..00b42001e --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/components/AddItemButton.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +import { Menu } from "@lib/components/Menu"; +import { MenuItem } from "@lib/components/MenuItem"; +import { SelectOption } from "@lib/components/Select"; +import { Button, Dropdown, MenuButton } from "@mui/base"; +import { Add, ArrowDropDown } from "@mui/icons-material"; + +export type AddItemButtonProps = { + buttonText: string; + options?: SelectOption[]; + onAddClicked?: () => void; + onOptionClicked?: (value: SelectOption["value"]) => void; +}; + +/** + * Generic add-button, for the top of sortable-lists. Uses a dropdown if there's more than 1 available options + */ +export function AddItemButton(props: AddItemButtonProps): React.ReactNode { + const { onOptionClicked, onAddClicked } = props; + + const handleOptionClicked = React.useCallback( + function handleOptionClicked(item: SelectOption) { + if (onOptionClicked) onOptionClicked(item.value); + }, + [onOptionClicked] + ); + + if (!props.options) { + return ( + + ); + } + + return ( + + + + + + + {props.options.map((entry) => ( + handleOptionClicked(entry)}> + {entry.label} + + ))} + + + ); +} + +function ButtonContent(props: { text: string; multiple?: boolean }) { + return ( +
+ + {props.text} + {props.multiple && } +
+ ); +} diff --git a/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/SortablePlotList.tsx b/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/SortablePlotList.tsx new file mode 100644 index 000000000..2fdf1252b --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/SortablePlotList.tsx @@ -0,0 +1,220 @@ +import React from "react"; + +import { WellboreLogCurveHeader_api } from "@api"; +import { arrayMove } from "@framework/utils/arrays"; +import { Dropdown, DropdownOption } from "@lib/components/Dropdown"; +import { Label } from "@lib/components/Label"; +import { SortableList, SortableListItem } from "@lib/components/SortableList"; +import { ColorSet } from "@lib/utils/ColorSet"; +import { CURVE_COLOR_PALETTE } from "@modules/WellLogViewer/utils/logViewerColors"; +import { PLOT_TYPE_OPTIONS, makeTrackPlot } from "@modules/WellLogViewer/utils/logViewerTemplate"; +import { Delete, SwapHoriz, Warning } from "@mui/icons-material"; +import { TemplatePlotTypes } from "@webviz/well-log-viewer/dist/components/WellLogTemplateTypes"; + +import { useAtomValue } from "jotai"; +import _ from "lodash"; + +import { allSelectedWellLogCurvesAtom } from "../../atoms/derivedAtoms"; +import { TemplatePlotConfig } from "../../atoms/persistedAtoms"; +import { AddItemButton } from "../AddItemButton"; + +export type SortablePlotListProps = { + availableCurveHeaders: WellboreLogCurveHeader_api[]; + plots: TemplatePlotConfig[]; + onUpdatePlots: (plots: TemplatePlotConfig[]) => void; +}; + +export function SortablePlotList(props: SortablePlotListProps): React.ReactNode { + const allSelectedWellLogCurves = useAtomValue(allSelectedWellLogCurvesAtom); + + const { onUpdatePlots } = props; + + const curveHeaderOptions = makeCurveNameOptions(props.availableCurveHeaders); + + // If the current selection does not exist, keep it in the selection, with a warning. This can happen when the user is importing a config, or swapping between wellbores + allSelectedWellLogCurves.forEach((curveName) => { + if (!curveHeaderOptions.some(({ value }) => value.endsWith("::" + curveName))) { + curveHeaderOptions.push(makeMissingCurveOption(curveName)); + } + }); + + // TODO, do an offsett or something, so they dont always start on the same color? + const colorSet = React.useRef(new ColorSet(CURVE_COLOR_PALETTE)); + + const addPlot = React.useCallback( + function addPlot(plotType: string) { + const plotConfig: TemplatePlotConfig = makeTrackPlot({ + color: colorSet.current.getNextColor(), + type: plotType as TemplatePlotTypes, + }); + + onUpdatePlots([...props.plots, plotConfig]); + }, + [onUpdatePlots, props.plots] + ); + + const removePlot = React.useCallback( + function removePlot(plot: TemplatePlotConfig) { + onUpdatePlots(props.plots.filter((p) => p._id !== plot._id)); + }, + [onUpdatePlots, props.plots] + ); + + const handlePlotUpdate = React.useCallback( + function handlePlotUpdate(newPlot: TemplatePlotConfig) { + const newPlots = props.plots.map((p) => (p._id === newPlot._id ? newPlot : p)); + + onUpdatePlots(newPlots); + }, + [onUpdatePlots, props.plots] + ); + + const handleTrackMove = React.useCallback( + function handleTrackMove( + movedItemId: string, + originId: string | null, + destinationId: string | null, + newPosition: number + ) { + // Skip update if the item was moved above or below itself, as this means no actual move happened + // TODO: This should probably be checked inside SortableList + const currentPosition = props.plots.findIndex((p) => p.name === movedItemId); + if (currentPosition === newPosition || currentPosition + 1 === newPosition) return; + + const newTrackCfg = arrayMove(props.plots, currentPosition, newPosition); + + onUpdatePlots(newTrackCfg); + }, + [onUpdatePlots, props.plots] + ); + + return ( +
+ + + + {props.plots.map((plot) => ( + + ))} + +
+ ); +} + +type SortablePlotItemProps = { + plot: TemplatePlotConfig; + curveHeaderOptions: DropdownOption[]; + onPlotUpdate: (plot: TemplatePlotConfig) => void; + onDeletePlot: (plot: TemplatePlotConfig) => void; +}; + +function SortablePlotItem(props: SortablePlotItemProps) { + const { onPlotUpdate } = props; + const secondCurveNeeded = props.plot.type === "differential"; + + const handlePlotChange = React.useCallback( + function handlePlotChange(changes: Partial) { + const newPlot = makeTrackPlot({ + ...props.plot, + ...changes, + }); + + onPlotUpdate(newPlot); + }, + [props.plot, onPlotUpdate] + ); + + const title = ( + <> + handlePlotChange({ _logAndName: v, name: v.split("::")[1] })} + /> + + ); + + const endAdornment = ( + <> + {secondCurveNeeded && ( + <> + + handlePlotChange({ name2: v })} + /> + + )} +
+ handlePlotChange({ type: v as TemplatePlotTypes })} + /> +
+ + + + ); + + return ; +} +function sortStatLogsToTop(o: WellboreLogCurveHeader_api) { + if (o.logName.startsWith("STAT_")) return 0; + else return 1; +} + +function makeCurveNameOptions(curveHeaders: WellboreLogCurveHeader_api[]): DropdownOption[] { + // It's my understanding that the STAT logs are the main curves users' would care about, so sorting them to the top first + return _.chain(curveHeaders) + .sortBy([sortStatLogsToTop, "logName", "curveName"]) + .map((curveHeader): DropdownOption => { + return { + // ... surely they wont have log-names with :: in them, RIGHT? + value: `${curveHeader.logName}::${curveHeader.curveName}`, + label: curveHeader.curveName, + group: curveHeader.logName, + }; + }) + .value(); +} + +// Helper method to show a missing curve as a disabled option +function makeMissingCurveOption(curveAndLogName: string): DropdownOption { + return { + label: curveAndLogName.split("::")[0], + value: curveAndLogName, + group: "Unavailable curves!", + disabled: true, + adornment: ( + + + + ), + }; +} diff --git a/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/SortableTrackItem.tsx b/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/SortableTrackItem.tsx new file mode 100644 index 000000000..31bb5a01e --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/SortableTrackItem.tsx @@ -0,0 +1,72 @@ +import React from "react"; + +import { SettingsStatusWriter } from "@framework/StatusWriter"; +import { SortableListItem } from "@lib/components/SortableList"; +import { Delete, ExpandLess, ExpandMore, Settings, Warning } from "@mui/icons-material"; + +import { TrackSettings } from "./TrackSettings"; + +import { TemplateTrackConfig } from "../../atoms/persistedAtoms"; + +export type CurveTrackItemProps = { + trackConfig: TemplateTrackConfig; + statusWriter: SettingsStatusWriter; + onUpdateTrack: (newTrack: TemplateTrackConfig) => void; + onDeleteTrack: (track: TemplateTrackConfig) => void; +}; + +export function SortableTrackItem(props: CurveTrackItemProps) { + const [isExpanded, setIsExpanded] = React.useState(true); + + const itemEndAdornment = ( + props.onDeleteTrack(props.trackConfig)} + toggleExpanded={() => setIsExpanded(!isExpanded)} + /> + ); + + return ( + + + + ); +} + +type ListItemEndAdornmentProps = { + track: TemplateTrackConfig; + isExpanded: boolean; + onDeleteTrack?: () => void; + toggleExpanded?: () => void; +}; + +function ListItemEndAdornment(props: ListItemEndAdornmentProps) { + return ( + <> + {props.track.plots.length < 1 && ( + + + + )} + + + + + ); +} diff --git a/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/TrackSettings.tsx b/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/TrackSettings.tsx new file mode 100644 index 000000000..81d30881b --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/TrackSettings.tsx @@ -0,0 +1,83 @@ +import React from "react"; + +import { Dropdown } from "@lib/components/Dropdown"; +import { Input } from "@lib/components/Input"; +import { PendingWrapper } from "@lib/components/PendingWrapper"; +import { PLOT_SCALE_OPTIONS } from "@modules/WellLogViewer/utils/logViewerTemplate"; +import { usePropagateApiErrorToStatusWriter } from "@modules/_shared/hooks/usePropagateApiErrorToStatusWriter"; +import { TemplatePlotScaleTypes } from "@webviz/well-log-viewer/dist/components/WellLogTemplateTypes"; + +import { useAtomValue } from "jotai"; + +import { SortablePlotList } from "./SortablePlotList"; +import { CurveTrackItemProps } from "./SortableTrackItem"; + +import { TemplateTrackConfig } from "../../atoms/persistedAtoms"; +import { wellLogCurveHeadersQueryAtom } from "../../atoms/queryAtoms"; + +export type TrackSettingsProps = CurveTrackItemProps; +type ConfigChanges = Pick, "width" | "plots" | "scale" | "title">; + +const INPUT_DEBOUNCE_TIME = 500; + +export function TrackSettings(props: TrackSettingsProps): React.ReactNode { + const { onUpdateTrack } = props; + + const curveHeadersQuery = useAtomValue(wellLogCurveHeadersQueryAtom); + const curveHeadersErrorStatus = usePropagateApiErrorToStatusWriter(curveHeadersQuery, props.statusWriter) ?? ""; + + const updateTrackConfig = React.useCallback( + function updateTrackConfig(configChanges: ConfigChanges) { + onUpdateTrack({ ...props.trackConfig, ...configChanges }); + }, + [props.trackConfig, onUpdateTrack] + ); + + return ( +
+ + updateTrackConfig({ title: val })} + /> + + + updateTrackConfig({ width: Number(val) })} + /> + + + { + if (!val) updateTrackConfig({ scale: undefined }); + else updateTrackConfig({ scale: val as TemplatePlotScaleTypes }); + }} + /> + +
+ + updateTrackConfig({ plots: plots })} + /> + +
+
+ ); +} diff --git a/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/index.tsx b/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/index.tsx new file mode 100644 index 000000000..68723c319 --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/index.tsx @@ -0,0 +1,181 @@ +import React from "react"; + +import { SettingsStatusWriter } from "@framework/StatusWriter"; +import { arrayMove } from "@framework/utils/arrays"; +import { Menu } from "@lib/components/Menu"; +import { MenuItem } from "@lib/components/MenuItem"; +// import { MenuItem } from "@lib/components/MenuItem"; +import { SortableList } from "@lib/components/SortableList"; +import { transformToTrackConfigs } from "@modules/WellLogViewer/utils/logViewerTemplate"; +import { Dropdown, MenuButton } from "@mui/base"; +import { FileDownload, FileUpload, MoreVert } from "@mui/icons-material"; + +import { useAtom } from "jotai"; +import { v4 } from "uuid"; + +import { SortableTrackItem } from "./SortableTrackItem"; + +import { TemplateTrackConfig, logViewerTrackConfigs } from "../../atoms/persistedAtoms"; +import { AddItemButton } from "../AddItemButton"; + +interface TemplateTrackSettingsProps { + statusWriter: SettingsStatusWriter; +} + +export function TemplateTrackSettings(props: TemplateTrackSettingsProps): React.ReactNode { + const [trackConfigs, setTrackConfigs] = useAtom(logViewerTrackConfigs); + const jsonImportInputRef = React.useRef(null); + + const handleNewPlotTrack = React.useCallback( + function handleNewPlotTrack() { + const newConfig = createNewConfig(`Plot track #${trackConfigs.length + 1}`); + + setTrackConfigs([...trackConfigs, newConfig]); + }, + [setTrackConfigs, trackConfigs] + ); + + const handleDeleteTrack = React.useCallback( + function handleDeleteTrack(track: TemplateTrackConfig) { + setTrackConfigs(trackConfigs.filter((configs) => configs._id !== track._id)); + }, + [setTrackConfigs, trackConfigs] + ); + + const handleEditTrack = React.useCallback( + function handleEditTrack(updatedItem: TemplateTrackConfig) { + const newConfigs = trackConfigs.map((tc) => (tc._id === updatedItem._id ? updatedItem : tc)); + + setTrackConfigs(newConfigs); + }, + [setTrackConfigs, trackConfigs] + ); + + const handleTrackMove = React.useCallback( + function handleTrackMove( + movedItemId: string, + originId: string | null, + destinationId: string | null, + newPosition: number + ) { + // Skip update if the item was moved above or below itself, as this means no actual move happened + // TODO: This should probably be checked inside SortableList + const currentPosition = trackConfigs.findIndex((cfg) => cfg._id === movedItemId); + if (currentPosition === newPosition || currentPosition + 1 === newPosition) return; + + const newTrackCfg = arrayMove(trackConfigs, currentPosition, newPosition); + + setTrackConfigs(newTrackCfg); + }, + [setTrackConfigs, trackConfigs] + ); + + const encodedConfigJsonUrl = React.useMemo( + function generateConfigJsonDataString() { + if (trackConfigs.length === 0) return null; + + const configJSON = JSON.stringify(trackConfigs); + return `data:text/json;charset=utf-8,${encodeURIComponent(configJSON)}`; + }, + [trackConfigs] + ); + + const handleConfigJsonImport = React.useCallback( + async function readUploadedFile(evt: React.ChangeEvent) { + const file = evt.target.files?.item(0); + + if (!file) return console.warn("No file given"); + if (file.type !== "application/json") return console.warn("Invalid file extension"); + + try { + const fileData = await file.text(); + + const parsedData = JSON.parse(fileData); + const newConfig = transformToTrackConfigs(parsedData); + + setTrackConfigs(newConfig); + } catch (error) { + console.error(error); + console.warn("Invalid JSON content"); + } + }, + [setTrackConfigs] + ); + + return ( +
+
+ + +
Plot Tracks
+ + + + + + + + {/* No idea why this wouldnt play along with Typescript + + Export JSON + + */} + + + + Export JSON + + + jsonImportInputRef.current?.click()}> + Import JSON + + + +
+ + + {trackConfigs.map((config) => ( + + ))} + +
+ ); +} + +function createNewConfig(title: string): TemplateTrackConfig { + return { + _id: v4(), + plots: [], + scale: "linear", + width: 3, + title, + }; +} diff --git a/frontend/src/modules/WellLogViewer/settings/components/ViewerSettings/index.tsx b/frontend/src/modules/WellLogViewer/settings/components/ViewerSettings/index.tsx new file mode 100644 index 000000000..2d2099953 --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/components/ViewerSettings/index.tsx @@ -0,0 +1,57 @@ +import React from "react"; + +import { SettingsStatusWriter } from "@framework/StatusWriter"; +import { Checkbox } from "@lib/components/Checkbox"; +import { Label } from "@lib/components/Label"; +import { PendingWrapper } from "@lib/components/PendingWrapper"; +import { usePropagateApiErrorToStatusWriter } from "@modules/_shared/hooks/usePropagateApiErrorToStatusWriter"; + +import { useAtom, useAtomValue } from "jotai"; + +import { userSelectedNonUnitWellpicksAtom, userSelectedUnitWellpicksAtom } from "../../atoms/baseAtoms"; +import { padDataWithEmptyRowsAtom, viewerHorizontalAtom } from "../../atoms/persistedAtoms"; +import { wellborePicksAndStratigraphyQueryAtom } from "../../atoms/queryAtoms"; +import { WellpickSelect } from "../WellpickSelect"; + +export type ViewerSettingsProps = { + statusWriter: SettingsStatusWriter; +}; + +export function ViewerSettings(props: ViewerSettingsProps): React.ReactNode { + // Well log selection + const [horizontal, setHorizontal] = useAtom(viewerHorizontalAtom); + const [padWithEmptyRows, setPadWithEmptyRows] = useAtom(padDataWithEmptyRowsAtom); + + // Wellpick selection + const borePicksAndStratQuery = useAtomValue(wellborePicksAndStratigraphyQueryAtom); + const availableWellPicks = borePicksAndStratQuery.data ?? { nonUnitPicks: [], unitPicks: [] }; + const wellpickErrorMsg = usePropagateApiErrorToStatusWriter(borePicksAndStratQuery, props.statusWriter) ?? ""; + + const [selectedNonUnitPicks, setSelectedNonUnitPicks] = useAtom(userSelectedNonUnitWellpicksAtom); + const [selectedUnitPicks, setSelectedUnitPicks] = useAtom(userSelectedUnitWellpicksAtom); + + return ( +
+ {/* TODO: Other settings, like, color, max cols, etc */} + + + + + +
+ ); +} diff --git a/frontend/src/modules/WellLogViewer/settings/components/WellpickSelect.tsx b/frontend/src/modules/WellLogViewer/settings/components/WellpickSelect.tsx new file mode 100644 index 000000000..d2f0d083c --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/components/WellpickSelect.tsx @@ -0,0 +1,92 @@ +import React from "react"; + +import { Select, SelectOption, SelectProps } from "@lib/components/Select"; +import { WellPicksLayerData } from "@modules/Intersection/utils/layers/WellpicksLayer"; + +import _ from "lodash"; + +export type WellpickSelectProps = { + availableWellpicks: WellPicksLayerData; + selectedUnitPicks: string[]; + selectedNonUnitPicks: string[]; + onNonUnitPicksChange?: (value: string[]) => void; + onUnitPicksChange?: (value: string[]) => void; +} & Omit; + +export function WellpickSelect(props: WellpickSelectProps): React.ReactNode { + const { onNonUnitPicksChange, onUnitPicksChange } = props; + const groupedOptions = createWellpickOptions(props.availableWellpicks); + + const handleChangeUnitPicks = React.useCallback( + function handleChangeUnitPicks(value: string[]) { + if (!onUnitPicksChange) return; + + return _.isEqual(value, props.selectedUnitPicks) ? onUnitPicksChange([]) : onUnitPicksChange(value); + }, + [onUnitPicksChange, props.selectedUnitPicks] + ); + + const handleChangeNonUnitPicks = React.useCallback( + function handleChangeNonUnitPicks(value: string[]) { + if (!onNonUnitPicksChange) return; + + return _.isEqual(value, props.selectedNonUnitPicks) + ? onNonUnitPicksChange([]) + : onNonUnitPicksChange(value); + }, + [onNonUnitPicksChange, props.selectedNonUnitPicks] + ); + + return ( +
+ Unit picks + + +
+ ); +} + +type UnitPicks = WellPicksLayerData["unitPicks"]; +type NonUnitPicks = WellPicksLayerData["nonUnitPicks"]; + +type WellpickOptions = { + unitPicks: SelectOption[]; + nonUnitPicks: SelectOption[]; +}; + +function createWellpickOptions(groupedWellpicks: WellPicksLayerData): WellpickOptions { + return { + unitPicks: unitPickToSelectOptions(groupedWellpicks.unitPicks), + nonUnitPicks: nonUnitPickToSelectOptions(groupedWellpicks.nonUnitPicks), + }; +} + +function nonUnitPickToSelectOptions(picks: NonUnitPicks): SelectOption[] { + return picks.map((pick) => ({ + label: pick.identifier, + value: pick.identifier, + })); +} + +function unitPickToSelectOptions(picks: UnitPicks): SelectOption[] { + return picks.map((pick) => ({ + label: pick.name, + value: pick.name, + })); +} diff --git a/frontend/src/modules/WellLogViewer/settings/settings.tsx b/frontend/src/modules/WellLogViewer/settings/settings.tsx new file mode 100644 index 000000000..cc3826d40 --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/settings.tsx @@ -0,0 +1,120 @@ +import React from "react"; + +import { WellboreHeader_api } from "@api"; +import { ModuleSettingsProps } from "@framework/Module"; +import { useSettingsStatusWriter } from "@framework/StatusWriter"; +import { SyncSettingKey, SyncSettingsHelper } from "@framework/SyncSettings"; +import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { FieldDropdown } from "@framework/components/FieldDropdown"; +import { IntersectionType } from "@framework/types/intersection"; +import { CollapsibleGroup } from "@lib/components/CollapsibleGroup"; +import { Label } from "@lib/components/Label"; +import { PendingWrapper } from "@lib/components/PendingWrapper"; +import { Select, SelectOption } from "@lib/components/Select"; +import { usePropagateApiErrorToStatusWriter } from "@modules/_shared/hooks/usePropagateApiErrorToStatusWriter"; + +import { useAtomValue, useSetAtom } from "jotai"; + +import { userSelectedFieldIdentifierAtom, userSelectedWellboreUuidAtom } from "./atoms/baseAtoms"; +import { selectedFieldIdentifierAtom, selectedWellboreAtom } from "./atoms/derivedAtoms"; +import { drilledWellboreHeadersQueryAtom } from "./atoms/queryAtoms"; +import { TemplateTrackSettings } from "./components/TemplateTrackSettings"; +import { ViewerSettings } from "./components/ViewerSettings"; + +import { InterfaceTypes } from "../interfaces"; +import { useTrackedGlobalValue } from "../utils/hooks"; + +function useSyncedWellboreSetting( + syncHelper: SyncSettingsHelper +): [typeof selectedWellboreHeader, typeof setSelectedWellboreHeader] { + const localSetSelectedWellboreHeader = useSetAtom(userSelectedWellboreUuidAtom); + // Global syncronization + const globalIntersection = syncHelper.useValue(SyncSettingKey.INTERSECTION, "global.syncValue.intersection"); + useTrackedGlobalValue(globalIntersection, () => { + if (globalIntersection?.type === IntersectionType.WELLBORE) { + localSetSelectedWellboreHeader(globalIntersection.uuid); + } + }); + + function setSelectedWellboreHeader(wellboreUuid: string | null) { + localSetSelectedWellboreHeader(wellboreUuid); + + syncHelper.publishValue(SyncSettingKey.INTERSECTION, "global.syncValue.intersection", { + type: IntersectionType.WELLBORE, + uuid: wellboreUuid ?? "", + }); + } + // Leave AFTER checking global, othwise the select menu will highlight the wrong value + const selectedWellboreHeader = useAtomValue(selectedWellboreAtom); + + return [selectedWellboreHeader, setSelectedWellboreHeader]; +} + +export function Settings(props: ModuleSettingsProps) { + // Utilities + const syncedSettingKeys = props.settingsContext.useSyncedSettingKeys(); + const syncHelper = new SyncSettingsHelper(syncedSettingKeys, props.workbenchServices); + + // Ensemble selections + const ensembleSet = useEnsembleSet(props.workbenchSession); + + const selectedField = useAtomValue(selectedFieldIdentifierAtom); + const setSelectedField = useSetAtom(userSelectedFieldIdentifierAtom); + + // Wellbore selection + const wellboreHeaders = useAtomValue(drilledWellboreHeadersQueryAtom); + const [selectedWellboreHeader, setSelectedWellboreHeader] = useSyncedWellboreSetting(syncHelper); + + const handleWellboreSelectionChange = React.useCallback( + function handleWellboreSelectionChange(uuids: string[]) { + setSelectedWellboreHeader(uuids[0] ?? null); + }, + [setSelectedWellboreHeader] + ); + + // Error messages + const statusWriter = useSettingsStatusWriter(props.settingsContext); + const wellboreHeadersErrorStatus = usePropagateApiErrorToStatusWriter(wellboreHeaders, statusWriter) ?? ""; + + return ( +
+ + + +