From 44ba1d91051f1b21e475d6232707f4ca3307ac7a Mon Sep 17 00:00:00 2001 From: Anders Rantala Hunderi Date: Wed, 4 Dec 2024 15:44:48 +0100 Subject: [PATCH] Implement Well-log viewer module MVP (#794) --- .../primary/routers/well/converters.py | 6 + .../primary/primary/routers/well/router.py | 2 + .../primary/primary/routers/well/schemas.py | 13 +- .../primary/services/ssdl_access/types.py | 35 +- .../services/ssdl_access/well_access.py | 6 +- frontend/package-lock.json | 31 ++ frontend/package.json | 1 + .../src/api/models/WellboreLogCurveData.ts | 11 +- .../src/api/models/WellboreLogCurveHeader.ts | 2 +- .../src/lib/components/Dropdown/dropdown.tsx | 305 ++++++++---- .../Virtualization/virtualization.tsx | 128 ++--- frontend/src/lib/utils/arrays.ts | 14 + .../src/modules/WellLogViewer/interfaces.ts | 37 ++ .../src/modules/WellLogViewer/loadModule.tsx | 15 + .../src/modules/WellLogViewer/preview.svg | 66 +++ .../src/modules/WellLogViewer/preview.tsx | 7 + .../modules/WellLogViewer/registerModule.ts | 32 ++ .../WellLogViewer/settings/atoms/baseAtoms.ts | 6 + .../settings/atoms/derivedAtoms.ts | 138 ++++++ .../settings/atoms/persistedAtoms.ts | 38 ++ .../settings/atoms/queryAtoms.ts | 69 +++ .../settings/components/AddItemButton.tsx | 66 +++ .../components/TemplateTrackSettings/index.ts | 1 + .../private-components/SortablePlotList.tsx | 223 +++++++++ .../private-components/SortableTrackItem.tsx | 70 +++ .../private-components/TrackSettings.tsx | 86 ++++ .../private-components/plotTypeOptions.tsx | 15 + .../templateTrackSettings.tsx | 152 ++++++ .../settings/components/ViewerSettings.tsx | 72 +++ .../settings/components/WellpickSelect.tsx | 94 ++++ .../WellLogViewer/settings/settings.tsx | 119 +++++ frontend/src/modules/WellLogViewer/types.ts | 23 + .../src/modules/WellLogViewer/utils/atoms.ts | 54 +++ .../WellLogViewer/utils/logViewerColors.ts | 165 +++++++ .../WellLogViewer/utils/logViewerTemplate.ts | 100 ++++ .../WellLogViewer/utils/queryDataTransform.ts | 213 +++++++++ .../WellLogViewer/utils/settingsImport.ts | 60 +++ .../WellLogViewer/view/atoms/baseAtoms.ts | 6 + .../WellLogViewer/view/atoms/derivedAtoms.ts | 28 ++ .../view/atoms/interfaceEffects.ts | 15 + .../WellLogViewer/view/atoms/queryAtoms.ts | 22 + .../view/components/ReadoutWrapper.tsx | 68 +++ .../components/SubsurfaceLogViewerWrapper.tsx | 286 +++++++++++ .../WellLogViewer/view/queries/shared.ts | 7 + .../view/queries/wellLogQueries.ts | 19 + .../src/modules/WellLogViewer/view/view.tsx | 93 ++++ .../usePropagateApiErrorToStatusWriter.ts | 16 +- frontend/src/modules/registerAllModules.ts | 1 + .../WellLogViewer/logViewerTemplate.test.ts | 443 ++++++++++++++++++ .../WellLogViewer/queryDataTransform.test.ts | 255 ++++++++++ 50 files changed, 3558 insertions(+), 176 deletions(-) create mode 100644 frontend/src/lib/utils/arrays.ts create mode 100644 frontend/src/modules/WellLogViewer/interfaces.ts create mode 100644 frontend/src/modules/WellLogViewer/loadModule.tsx create mode 100644 frontend/src/modules/WellLogViewer/preview.svg create mode 100644 frontend/src/modules/WellLogViewer/preview.tsx create mode 100644 frontend/src/modules/WellLogViewer/registerModule.ts create mode 100644 frontend/src/modules/WellLogViewer/settings/atoms/baseAtoms.ts create mode 100644 frontend/src/modules/WellLogViewer/settings/atoms/derivedAtoms.ts create mode 100644 frontend/src/modules/WellLogViewer/settings/atoms/persistedAtoms.ts create mode 100644 frontend/src/modules/WellLogViewer/settings/atoms/queryAtoms.ts create mode 100644 frontend/src/modules/WellLogViewer/settings/components/AddItemButton.tsx create mode 100644 frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/index.ts create mode 100644 frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/private-components/SortablePlotList.tsx create mode 100644 frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/private-components/SortableTrackItem.tsx create mode 100644 frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/private-components/TrackSettings.tsx create mode 100644 frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/private-components/plotTypeOptions.tsx create mode 100644 frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/templateTrackSettings.tsx create mode 100644 frontend/src/modules/WellLogViewer/settings/components/ViewerSettings.tsx create mode 100644 frontend/src/modules/WellLogViewer/settings/components/WellpickSelect.tsx create mode 100644 frontend/src/modules/WellLogViewer/settings/settings.tsx create mode 100644 frontend/src/modules/WellLogViewer/types.ts create mode 100644 frontend/src/modules/WellLogViewer/utils/atoms.ts create mode 100644 frontend/src/modules/WellLogViewer/utils/logViewerColors.ts create mode 100644 frontend/src/modules/WellLogViewer/utils/logViewerTemplate.ts create mode 100644 frontend/src/modules/WellLogViewer/utils/queryDataTransform.ts create mode 100644 frontend/src/modules/WellLogViewer/utils/settingsImport.ts create mode 100644 frontend/src/modules/WellLogViewer/view/atoms/baseAtoms.ts create mode 100644 frontend/src/modules/WellLogViewer/view/atoms/derivedAtoms.ts create mode 100644 frontend/src/modules/WellLogViewer/view/atoms/interfaceEffects.ts create mode 100644 frontend/src/modules/WellLogViewer/view/atoms/queryAtoms.ts create mode 100644 frontend/src/modules/WellLogViewer/view/components/ReadoutWrapper.tsx create mode 100644 frontend/src/modules/WellLogViewer/view/components/SubsurfaceLogViewerWrapper.tsx create mode 100644 frontend/src/modules/WellLogViewer/view/queries/shared.ts create mode 100644 frontend/src/modules/WellLogViewer/view/queries/wellLogQueries.ts create mode 100644 frontend/src/modules/WellLogViewer/view/view.tsx create mode 100644 frontend/tests/unit/WellLogViewer/logViewerTemplate.test.ts create mode 100644 frontend/tests/unit/WellLogViewer/queryDataTransform.test.ts diff --git a/backend_py/primary/primary/routers/well/converters.py b/backend_py/primary/primary/routers/well/converters.py index fb12f9d40..ab40d5a80 100644 --- a/backend_py/primary/primary/routers/well/converters.py +++ b/backend_py/primary/primary/routers/well/converters.py @@ -123,6 +123,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, @@ -134,6 +137,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 2ab5baa46..b4433c184 100644 --- a/backend_py/primary/primary/routers/well/router.py +++ b/backend_py/primary/primary/routers/well/router.py @@ -212,6 +212,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 5e47a71f8..cbb606e1c 100644 --- a/backend_py/primary/primary/routers/well/schemas.py +++ b/backend_py/primary/primary/routers/well/schemas.py @@ -106,16 +106,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..d02914d46 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,40 @@ 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 + + # This field has weird casing. This is just how SSDL has decided to return this object, so we leave it as is to make model validation + DataPoints: list[list[float | None]] + + @property + def data_points(self) -> list[list[float | None]]: + # Utility property to "DataPoint" with proper casing + return self.DataPoints 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 90ff4c45a..0c015d7ca 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 ssdl_get_request(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 2835c0856..1490ae23c 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": "^1.1.1", "@webviz/well-completions-plot": "^1.5.11", + "@webviz/well-log-viewer": "^1.12.7", "animate.css": "^4.1.1", "axios": "^1.6.5", "culori": "^3.2.0", @@ -940,6 +941,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", @@ -5234,6 +5248,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": "1.0.6", "resolved": "https://registry.npmjs.org/@webviz/wsc-common/-/wsc-common-1.0.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8b28d305c..8836d81c5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ "@tanstack/react-query-devtools": "^5.4.2", "@types/geojson": "^7946.0.14", "@webviz/group-tree-plot": "^1.1.14", + "@webviz/well-log-viewer": "^1.12.7", "@webviz/subsurface-viewer": "^1.1.1", "@webviz/well-completions-plot": "^1.5.11", "animate.css": "^4.1.1", 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/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/lib/utils/arrays.ts b/frontend/src/lib/utils/arrays.ts new file mode 100644 index 000000000..4892060f8 --- /dev/null +++ b/frontend/src/lib/utils/arrays.ts @@ -0,0 +1,14 @@ +/** + * Util method for moving items in an array, by index number. Does not mutate the original 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 shallow copy of the original array, with its 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/modules/WellLogViewer/interfaces.ts b/frontend/src/modules/WellLogViewer/interfaces.ts new file mode 100644 index 000000000..728b95795 --- /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, + selectedWellboreHeaderAtom, + 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(selectedWellboreHeaderAtom), + 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..36cc21ef0 --- /dev/null +++ b/frontend/src/modules/WellLogViewer/loadModule.tsx @@ -0,0 +1,15 @@ +import { ModuleRegistry } from "@framework/ModuleRegistry"; + +import { InterfaceTypes, settingsToViewInterfaceInitialization } from "./interfaces"; +import { MODULE_NAME } from "./registerModule"; +import { Settings } from "./settings/settings"; +import { settingsToViewInterfaceEffects } from "./view/atoms/interfaceEffects"; +import { View } from "./view/view"; + +const module = ModuleRegistry.initModule(MODULE_NAME, { + settingsToViewInterfaceInitialization, + settingsToViewInterfaceEffects, +}); + +module.viewFC = View; +module.settingsFC = Settings; diff --git a/frontend/src/modules/WellLogViewer/preview.svg b/frontend/src/modules/WellLogViewer/preview.svg new file mode 100644 index 000000000..a84e8ab26 --- /dev/null +++ b/frontend/src/modules/WellLogViewer/preview.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/modules/WellLogViewer/preview.tsx b/frontend/src/modules/WellLogViewer/preview.tsx new file mode 100644 index 000000000..6ba8d9cf8 --- /dev/null +++ b/frontend/src/modules/WellLogViewer/preview.tsx @@ -0,0 +1,7 @@ +import { DrawPreviewFunc } from "@framework/Preview"; + +import previewImg from "./preview.svg"; + +export const preview: DrawPreviewFunc = function (width: number, height: number) { + return ; +}; diff --git a/frontend/src/modules/WellLogViewer/registerModule.ts b/frontend/src/modules/WellLogViewer/registerModule.ts new file mode 100644 index 000000000..dbeda8145 --- /dev/null +++ b/frontend/src/modules/WellLogViewer/registerModule.ts @@ -0,0 +1,32 @@ +/** + * 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 { preview } from "./preview"; +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"; + +ModuleRegistry.registerModule({ + moduleName: MODULE_NAME, + defaultTitle: MODULE_TITLE, + description: MODULE_DESCRIPTION, + preview, + + category: ModuleCategory.MAIN, + devState: ModuleDevState.DEV, + + syncableSettingKeys: [SyncSettingKey.INTERSECTION, SyncSettingKey.VERTICAL_SCALE], + onInstanceUnload(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..77d14b942 --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/atoms/derivedAtoms.ts @@ -0,0 +1,138 @@ +import { WellboreHeader_api, WellboreLogCurveHeader_api } from "@api"; +import { transformFormationData } from "@equinor/esv-intersection"; +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 _ from "lodash"; + +import { + userSelectedFieldIdentifierAtom, + userSelectedNonUnitWellpicksAtom, + userSelectedUnitWellpicksAtom, + userSelectedWellboreUuidAtom, +} from "./baseAtoms"; +import { logViewerTrackConfigs } from "./persistedAtoms"; +import { + drilledWellboreHeadersQueryAtom, + wellLogCurveHeadersQueryAtom, + wellborePicksQueryAtom, + wellboreStratigraphicUnitsQueryAtom, +} from "./queryAtoms"; + +/** + * Exposing the return type of esv-intersection's transformFormationData, since they don't export that anywhere + */ +export type TransformFormationDataResult = ReturnType; + +export const firstEnsembleInSelectedFieldAtom = atom((get) => { + const selectedFieldId = get(userSelectedFieldIdentifierAtom); + const ensembleSetArr = get(EnsembleSetAtom).getEnsembleArr(); + + if (!ensembleSetArr.length) { + return null; + } + + const selectedEnsemble = ensembleSetArr.find((e) => e.getFieldIdentifier() === selectedFieldId); + + return selectedEnsemble ?? ensembleSetArr[0]; +}); + +export const selectedFieldIdentifierAtom = atom((get) => { + return get(firstEnsembleInSelectedFieldAtom)?.getFieldIdentifier() ?? null; +}); + +export const selectedWellboreHeaderAtom = atom((get) => { + const availableWellboreHeaders = get(drilledWellboreHeadersQueryAtom)?.data; + const selectedWellboreId = get(userSelectedWellboreUuidAtom); + + if (!availableWellboreHeaders?.length) { + return null; + } + + return availableWellboreHeaders.find((wh) => wh.wellboreUuid === selectedWellboreId) ?? availableWellboreHeaders[0]; +}); + +export const availableWellPicksAtom = atom((get) => { + const wellborePicks = get(wellborePicksQueryAtom).data; + const wellboreStratUnits = get(wellboreStratigraphicUnitsQueryAtom).data; + + if (!wellborePicks || !wellboreStratUnits) return { nonUnitPicks: [], unitPicks: [] }; + + const transformedPickData = transformFormationData(wellborePicks, wellboreStratUnits as any); + + return { + nonUnitPicks: _.uniqBy(transformedPickData.nonUnitPicks, "identifier"), + unitPicks: _.uniqBy(transformedPickData.unitPicks, "name"), + }; +}); + +export const selectedWellborePicksAtom = atom((get) => { + const wellborePicks = get(availableWellPicksAtom); + 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; +}); + +/** + * Returns a list of all user-selected curves that have no available curve-header + */ +export const missingCurvesAtom = atom((get) => { + const allSelectedWellLogCurves = get(allSelectedWellLogCurvesAtom); + const curveHeadersQuery = get(wellLogCurveHeadersQueryAtom); + + // While loading, assume all curves are "available" (since they *most likely* are) + if (!curveHeadersQuery.data) return []; + + const missingNames: string[] = []; + + allSelectedWellLogCurves.forEach((selectedName) => { + if (!curveHeadersQuery.data.some(({ curveName }) => curveName === selectedName)) { + missingNames.push(selectedName); + } + }); + + return missingNames; +}); 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..a60ce49aa --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/atoms/persistedAtoms.ts @@ -0,0 +1,38 @@ +import { TemplateTrackConfig } from "@modules/WellLogViewer/types"; +import { atomWithModuleInstanceStorage, clearModuleInstanceStorage } from "@modules/WellLogViewer/utils/atoms"; + +import { Getter, Setter, atom } from "jotai"; +import { Dictionary } from "lodash"; + +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..667b586d2 --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/atoms/queryAtoms.ts @@ -0,0 +1,69 @@ +import { apiService } from "@framework/ApiService"; + +import { atomWithQuery } from "jotai-tanstack-query"; + +import { + firstEnsembleInSelectedFieldAtom, + selectedFieldIdentifierAtom, + selectedWellboreHeaderAtom, +} 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(selectedWellboreHeaderAtom)?.wellboreUuid; + + return { + queryKey: ["getWellboreLogCurveHeaders", wellboreId], + queryFn: () => apiService.well.getWellboreLogCurveHeaders(wellboreId ?? ""), + enabled: Boolean(wellboreId), + ...SHARED_QUERY_OPTS, + }; +}); + +export const wellborePicksQueryAtom = atomWithQuery((get) => { + const selectedFieldIdent = get(selectedFieldIdentifierAtom) ?? ""; + const selectedWellboreUuid = get(selectedWellboreHeaderAtom)?.wellboreUuid ?? ""; + + return { + queryKey: ["getWellborePicksForWellbore", selectedFieldIdent, selectedWellboreUuid], + enabled: Boolean(selectedFieldIdent && selectedWellboreUuid), + queryFn: () => apiService.well.getWellborePicksForWellbore(selectedFieldIdent, selectedWellboreUuid), + ...SHARED_QUERY_OPTS, + }; +}); + +export const wellboreStratigraphicUnitsQueryAtom = atomWithQuery((get) => { + // Stratigraphic column will be computed based on the case uuid + // TODO: Should make it a user-selected ensemble instead, at some point + const selectedEnsemble = get(firstEnsembleInSelectedFieldAtom); + const caseUuid = selectedEnsemble?.getCaseUuid() ?? ""; + + return { + queryKey: ["getWellborePicksForWellbore", caseUuid], + enabled: Boolean(caseUuid), + queryFn: () => apiService.surface.getStratigraphicUnits(caseUuid), + ...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..b98c13ccc --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/components/AddItemButton.tsx @@ -0,0 +1,66 @@ +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: TValue) => 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/index.ts b/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/index.ts new file mode 100644 index 000000000..4229630d3 --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/index.ts @@ -0,0 +1 @@ +export { TemplateTrackSettings } from "./templateTrackSettings"; diff --git a/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/private-components/SortablePlotList.tsx b/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/private-components/SortablePlotList.tsx new file mode 100644 index 000000000..69cf40618 --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/private-components/SortablePlotList.tsx @@ -0,0 +1,223 @@ +import React from "react"; + +import { WellboreLogCurveHeader_api } from "@api"; +import { DenseIconButton } from "@lib/components/DenseIconButton"; +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 { arrayMove } from "@lib/utils/arrays"; +import { TemplatePlotConfig } from "@modules/WellLogViewer/types"; +import { CURVE_COLOR_PALETTE } from "@modules/WellLogViewer/utils/logViewerColors"; +import { 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 { PLOT_TYPE_OPTIONS } from "./plotTypeOptions"; + +import { missingCurvesAtom } from "../../../atoms/derivedAtoms"; +import { AddItemButton } from "../../AddItemButton"; + +export type SortablePlotListProps = { + availableCurveHeaders: WellboreLogCurveHeader_api[]; + plots: TemplatePlotConfig[]; + onUpdatePlots: (plots: TemplatePlotConfig[]) => void; +}; + +// TODO, do an offsett or something, so they dont always start on the same color? +const colorSet = new ColorSet(CURVE_COLOR_PALETTE); + +export function SortablePlotList(props: SortablePlotListProps): React.ReactNode { + const { onUpdatePlots } = props; + + const curveHeaderOptions = makeCurveNameOptions(props.availableCurveHeaders); + const missingCurves = useAtomValue(missingCurvesAtom); + + missingCurves.forEach((curveName) => { + // If the current selection does not exist, keep it in the dropdown options, but with a warning. + // This can happen when the user is importing a config, or swapping between wellbores. + curveHeaderOptions.push(makeMissingCurveOption(curveName)); + }); + + const addPlot = React.useCallback( + function addPlot(plotType: TemplatePlotTypes) { + const plotConfig: TemplatePlotConfig = makeTrackPlot({ + color: colorSet.getNextColor(), + type: plotType, + }); + + 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 + ) { + const currentPosition = props.plots.findIndex((p) => p.name === movedItemId); + const newTrackCfg = arrayMove(props.plots, currentPosition, newPosition); + + onUpdatePlots(newTrackCfg); + }, + [onUpdatePlots, props.plots] + ); + + return ( +
+ + + + {props.plots.map((plot) => ( + + ))} + +
+ ); +} + +type SortablePlotItemProps = { + plot: TemplatePlotConfig; + curveHeaderOptions: CurveDropdownOption[]; + onPlotUpdate: (plot: TemplatePlotConfig) => void; + onDeletePlot: (plot: TemplatePlotConfig) => void; +}; + +function SortablePlotItem(props: SortablePlotItemProps) { + const { onPlotUpdate } = props; + const secondCurveNeeded = props.plot.type === "differential"; + + function handlePlotChange(changes: Partial) { + const newPlot = makeTrackPlot({ + ...props.plot, + ...changes, + }); + + onPlotUpdate(newPlot); + } + + const title = ( + <> + handlePlotChange({ _logAndName: v, name: v.split("::")[1] })} + /> + + ); + + const endAdornment = ( + <> + {secondCurveNeeded && ( + <> + + handlePlotChange({ + _logAndName: props.plot._logAndName2, + _logAndName2: props.plot._logAndName, + name: props.plot.name2, + name2: props.plot.name, + }) + } + > + + + + handlePlotChange({ _logAndName2: v, name2: v.split("::")[1] })} + /> + + )} +
+ handlePlotChange({ type: v })} + /> +
+ + + + ); + + return ; +} + +// It's my understanding that the STAT logs are the main curves users' would care about, so sorting them to the top first +function sortStatLogsToTop(o: WellboreLogCurveHeader_api) { + if (o.logName.startsWith("STAT_")) return 0; + else return 1; +} + +// The select value string needs a specific pattern +type CurveDropdownOption = DropdownOption; + +function makeCurveNameOptions(curveHeaders: WellboreLogCurveHeader_api[]): CurveDropdownOption[] { + return _.chain(curveHeaders) + .sortBy([sortStatLogsToTop, "logName", "curveName"]) + .map((curveHeader) => { + 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(curveName: string): CurveDropdownOption { + return { + label: curveName, + value: `${curveName}::n/a`, + group: "Unavailable curves!", + disabled: true, + adornment: ( + + + + ), + }; +} diff --git a/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/private-components/SortableTrackItem.tsx b/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/private-components/SortableTrackItem.tsx new file mode 100644 index 000000000..ab94b5e52 --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/private-components/SortableTrackItem.tsx @@ -0,0 +1,70 @@ +import React from "react"; + +import { SettingsStatusWriter } from "@framework/StatusWriter"; +import { DenseIconButton } from "@lib/components/DenseIconButton"; +import { DenseIconButtonColorScheme } from "@lib/components/DenseIconButton/denseIconButton"; +import { SortableListItem } from "@lib/components/SortableList"; +import { TemplateTrackConfig } from "@modules/WellLogViewer/types"; +import { Delete, ExpandLess, ExpandMore, Settings, Warning } from "@mui/icons-material"; + +import { TrackSettings } from "./TrackSettings"; + +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 && ( + + + + )} + + + + {props.isExpanded ? : } + + + + + + + ); +} diff --git a/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/private-components/TrackSettings.tsx b/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/private-components/TrackSettings.tsx new file mode 100644 index 000000000..c0c7c0f01 --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/private-components/TrackSettings.tsx @@ -0,0 +1,86 @@ +import React from "react"; + +import { Dropdown, DropdownOption } from "@lib/components/Dropdown"; +import { Input } from "@lib/components/Input"; +import { PendingWrapper } from "@lib/components/PendingWrapper"; +import { TemplateTrackConfig } from "@modules/WellLogViewer/types"; +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 { wellLogCurveHeadersQueryAtom } from "../../../atoms/queryAtoms"; + +export type TrackSettingsProps = CurveTrackItemProps; + +type ConfigChanges = Partial>; +type TemplatePlotScaleOption = DropdownOption; + +const PLOT_SCALE_OPTIONS: TemplatePlotScaleOption[] = [ + { label: "Linear", value: "linear" }, + { label: "Logaritmic", value: "log" }, +]; + +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) ?? ""; + + function updateTrackConfig(configChanges: ConfigChanges) { + onUpdateTrack({ ...props.trackConfig, ...configChanges }); + } + + return ( +
+ + updateTrackConfig({ title: val })} + /> + + + updateTrackConfig({ width: Number(val) })} + /> + + + + id="trackScale" + value={props.trackConfig.scale} + options={PLOT_SCALE_OPTIONS} + filter={false} + onChange={(val) => { + if (!val) updateTrackConfig({ scale: undefined }); + else updateTrackConfig({ scale: val }); + }} + /> + +
+ + updateTrackConfig({ plots: plots })} + /> + +
+
+ ); +} diff --git a/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/private-components/plotTypeOptions.tsx b/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/private-components/plotTypeOptions.tsx new file mode 100644 index 000000000..826e60070 --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/private-components/plotTypeOptions.tsx @@ -0,0 +1,15 @@ +import { DropdownOption } from "@lib/components/Dropdown"; +import { TemplatePlotTypes } from "@webviz/well-log-viewer/dist/components/WellLogTemplateTypes"; + +type PlotDropdownOption = DropdownOption; +export const PLOT_TYPE_OPTIONS: PlotDropdownOption[] = [ + { value: "line", label: "Line" }, + { value: "linestep", label: "Linestep" }, + { value: "dot", label: "Dot" }, + { value: "area", label: "Area" }, + { value: "gradientfill", label: "Gradientfill" }, + // TODO: Type requires two named curves, ensure the flow for that is good + { value: "differential", label: "Differential" }, + // This one is completely different; requires "discrete" metadata + // { value: "stacked", label: "Stacked" }, +]; diff --git a/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/templateTrackSettings.tsx b/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/templateTrackSettings.tsx new file mode 100644 index 000000000..6cf2d234c --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/components/TemplateTrackSettings/templateTrackSettings.tsx @@ -0,0 +1,152 @@ +import React from "react"; + +import { SettingsStatusWriter } from "@framework/StatusWriter"; +import { Menu } from "@lib/components/Menu"; +import { MenuItem } from "@lib/components/MenuItem"; +import { SortableList } from "@lib/components/SortableList"; +import { arrayMove } from "@lib/utils/arrays"; +import { TemplateTrackConfig } from "@modules/WellLogViewer/types"; +import { configToJsonDataBlob, jsonFileToTrackConfigs } from "@modules/WellLogViewer/utils/settingsImport"; +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 "./private-components/SortableTrackItem"; + +import { 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 + ) { + const currentPosition = trackConfigs.findIndex((cfg) => cfg._id === movedItemId); + const newTrackCfg = arrayMove(trackConfigs, currentPosition, newPosition); + + setTrackConfigs(newTrackCfg); + }, + [setTrackConfigs, trackConfigs] + ); + + const encodedConfigJsonUrl = React.useMemo(() => configToJsonDataBlob(trackConfigs), [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"); + + try { + const newConfig = await jsonFileToTrackConfigs(file); + + setTrackConfigs(newConfig); + } catch (error) { + console.warn("Invalid JSON content"); + console.error(error); + let errorMsg = "Unkown error"; + if (typeof error === "string") errorMsg = "error"; + if (error instanceof Error) errorMsg = error.message; + + window.alert("Invalid JSON content\n\n" + errorMsg); + } + }, + [setTrackConfigs] + ); + + return ( +
+
+ + +
Plot Tracks
+ + + + + + + + + + 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.tsx b/frontend/src/modules/WellLogViewer/settings/components/ViewerSettings.tsx new file mode 100644 index 000000000..8f3e5b15d --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/components/ViewerSettings.tsx @@ -0,0 +1,72 @@ +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 { WellpickSelect } from "./WellpickSelect"; + +import { userSelectedNonUnitWellpicksAtom, userSelectedUnitWellpicksAtom } from "../atoms/baseAtoms"; +import { availableWellPicksAtom } from "../atoms/derivedAtoms"; +import { padDataWithEmptyRowsAtom, viewerHorizontalAtom } from "../atoms/persistedAtoms"; +import { wellborePicksQueryAtom, wellboreStratigraphicUnitsQueryAtom } from "../atoms/queryAtoms"; + +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 availableWellPicks = useAtomValue(availableWellPicksAtom); + const wellPickQueryState = useGetWellpickQueryState(props.statusWriter); + + const [selectedNonUnitPicks, setSelectedNonUnitPicks] = useAtom(userSelectedNonUnitWellpicksAtom); + const [selectedUnitPicks, setSelectedUnitPicks] = useAtom(userSelectedUnitWellpicksAtom); + + return ( +
+ {/* TODO: Other settings, like, color, max cols, etc */} + + + + + +
+ ); +} + +// As `availableWellpicks` is computed based on two separate queries, we need to check loading states and error messages of both. +function useGetWellpickQueryState(statusWriter: SettingsStatusWriter): { anyLoading: boolean; errorMsg: string } { + const wellpicksQuery = useAtomValue(wellborePicksQueryAtom); + const stratUnitQuery = useAtomValue(wellboreStratigraphicUnitsQueryAtom); + + const pickQueryError = usePropagateApiErrorToStatusWriter(wellpicksQuery, statusWriter); + const unitQueryError = usePropagateApiErrorToStatusWriter(stratUnitQuery, statusWriter); + + return { + anyLoading: wellpicksQuery.isLoading || stratUnitQuery.isLoading, + errorMsg: pickQueryError ?? unitQueryError ?? "", + }; +} 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..d15ca47ba --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/components/WellpickSelect.tsx @@ -0,0 +1,94 @@ +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; + + // Allow the user to de-select if they click the already chosen value + const newVal = _.isEqual(value, props.selectedUnitPicks) ? [] : value; + onUnitPicksChange(newVal); + }, + [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..3eb32a90f --- /dev/null +++ b/frontend/src/modules/WellLogViewer/settings/settings.tsx @@ -0,0 +1,119 @@ +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 { Intersection, 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 _ from "lodash"; + +import { userSelectedFieldIdentifierAtom, userSelectedWellboreUuidAtom } from "./atoms/baseAtoms"; +import { selectedFieldIdentifierAtom, selectedWellboreHeaderAtom } from "./atoms/derivedAtoms"; +import { drilledWellboreHeadersQueryAtom } from "./atoms/queryAtoms"; +import { TemplateTrackSettings } from "./components/TemplateTrackSettings"; +import { ViewerSettings } from "./components/ViewerSettings"; + +import { InterfaceTypes } from "../interfaces"; + +function useSyncedWellboreSetting( + syncHelper: SyncSettingsHelper +): [typeof selectedWellboreHeader, typeof setSelectedWellboreHeader] { + const localSetSelectedWellboreHeader = useSetAtom(userSelectedWellboreUuidAtom); + // Global syncronization + const globalIntersection = syncHelper.useValue(SyncSettingKey.INTERSECTION, "global.syncValue.intersection"); + const [prevGlobalIntersection, setPrevGlobalIntersection] = React.useState(null); + + if (!_.isEqual(prevGlobalIntersection, globalIntersection)) { + setPrevGlobalIntersection(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(selectedWellboreHeaderAtom); + + 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 ( +
+ + + +