diff --git a/backend_py/primary/primary/routers/seismic/router.py b/backend_py/primary/primary/routers/seismic/router.py index b5ce11d72d..c35807906a 100644 --- a/backend_py/primary/primary/routers/seismic/router.py +++ b/backend_py/primary/primary/routers/seismic/router.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import List, Optional, Tuple from fastapi import APIRouter, Body, Depends, HTTPException, Query from webviz_pkg.core_utils.b64 import b64_encode_float_array_as_float32 @@ -41,7 +41,7 @@ async def get_inline_slice( seismic_attribute: str = Query(description="Seismic cube attribute"), time_or_interval_str: str = Query(description="Timestamp or timestep"), observed: bool = Query(description="Observed or simulated"), - inline_no: int = Query(description="Inline number"), + inline_number: int = Query(description="Inline number"), ) -> schemas.SeismicSliceData: """Get a seismic inline from a seismic cube.""" seismic_access = SeismicAccess.from_ensemble_name( @@ -64,7 +64,7 @@ async def get_inline_slice( vds_access = VdsAccess(sas_token=vds_handle.sas_token, vds_url=vds_handle.vds_url) - flattened_slice_traces_array, metadata = await vds_access.get_inline_slice_async(line_no=inline_no) + flattened_slice_traces_array, metadata = await vds_access.get_inline_slice_async(line_no=inline_number) return converters.to_api_vds_slice_data( flattened_slice_traces_array=flattened_slice_traces_array, metadata=metadata @@ -80,7 +80,7 @@ async def get_crossline_slice( seismic_attribute: str = Query(description="Seismic cube attribute"), time_or_interval_str: str = Query(description="Timestamp or timestep"), observed: bool = Query(description="Observed or simulated"), - crossline_no: int = Query(description="Crossline number"), + crossline_num: int = Query(description="Crossline number"), ) -> schemas.SeismicSliceData: """Get a seismic crossline from a seismic cube.""" seismic_access = SeismicAccess.from_ensemble_name( @@ -103,7 +103,7 @@ async def get_crossline_slice( vds_access = VdsAccess(sas_token=vds_handle.sas_token, vds_url=vds_handle.vds_url) - flattened_slice_traces_array, metadata = await vds_access.get_crossline_slice_async(line_no=crossline_no) + flattened_slice_traces_array, metadata = await vds_access.get_crossline_slice_async(line_no=crossline_num) return converters.to_api_vds_slice_data( flattened_slice_traces_array=flattened_slice_traces_array, metadata=metadata @@ -119,7 +119,7 @@ async def get_depth_slice( seismic_attribute: str = Query(description="Seismic cube attribute"), time_or_interval_str: str = Query(description="Timestamp or timestep"), observed: bool = Query(description="Observed or simulated"), - depth_slice_no: int = Query(description="Depth slice no"), + depth_slice_num: int = Query(description="Depth slice number"), ) -> schemas.SeismicSliceData: """Get a seismic depth slice from a seismic cube.""" seismic_access = SeismicAccess.from_ensemble_name( @@ -142,13 +142,61 @@ async def get_depth_slice( vds_access = VdsAccess(sas_token=vds_handle.sas_token, vds_url=vds_handle.vds_url) - flattened_slice_traces_array, metadata = await vds_access.get_depth_slice_async(depth_slice_no=depth_slice_no) + flattened_slice_traces_array, metadata = await vds_access.get_depth_slice_async(depth_slice_no=depth_slice_num) return converters.to_api_vds_slice_data( flattened_slice_traces_array=flattened_slice_traces_array, metadata=metadata ) +@router.get("/get_seismic_slices/") +# pylint: disable=too-many-arguments +async def get_seismic_slices( + authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), + case_uuid: str = Query(description="Sumo case uuid"), + ensemble_name: str = Query(description="Ensemble name"), + realization_num: int = Query(description="Realization number"), + seismic_attribute: str = Query(description="Seismic cube attribute"), + time_or_interval_str: str = Query(description="Timestamp or timestep"), + observed: bool = Query(description="Observed or simulated"), + inline_number: int = Query(description="Inline number"), + crossline_number: int = Query(description="Crossline number"), + depth_slice_number: int = Query(description="Depth slice number"), +) -> Tuple[schemas.SeismicSliceData, schemas.SeismicSliceData, schemas.SeismicSliceData]: + """Get a seismic depth slice from a seismic cube.""" + seismic_access = SeismicAccess.from_ensemble_name( + authenticated_user.get_sumo_access_token(), case_uuid, ensemble_name + ) + + vds_handle: Optional[VdsHandle] = None + try: + vds_handle = await seismic_access.get_vds_handle_async( + realization=realization_num, + seismic_attribute=seismic_attribute, + time_or_interval_str=time_or_interval_str, + observed=observed, + ) + except ValueError as err: + raise HTTPException(status_code=404, detail=str(err)) from err + + if vds_handle is None: + raise HTTPException(status_code=404, detail="Vds handle not found") + + vds_access = VdsAccess(sas_token=vds_handle.sas_token, vds_url=vds_handle.vds_url) + + inline_tuple = await vds_access.get_inline_slice_async(line_no=inline_number) + crossline_tuple = await vds_access.get_crossline_slice_async(line_no=crossline_number) + depth_slice_tuple = await vds_access.get_depth_slice_async(depth_slice_no=depth_slice_number) + + return ( + converters.to_api_vds_slice_data(flattened_slice_traces_array=inline_tuple[0], metadata=inline_tuple[1]), + converters.to_api_vds_slice_data(flattened_slice_traces_array=crossline_tuple[0], metadata=crossline_tuple[1]), + converters.to_api_vds_slice_data( + flattened_slice_traces_array=depth_slice_tuple[0], metadata=depth_slice_tuple[1] + ), + ) + + @router.post("/get_seismic_fence/") async def post_get_seismic_fence( authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), diff --git a/backend_py/primary/primary/services/vds_access/vds_access.py b/backend_py/primary/primary/services/vds_access/vds_access.py index 0073c350ee..0039d182e4 100644 --- a/backend_py/primary/primary/services/vds_access/vds_access.py +++ b/backend_py/primary/primary/services/vds_access/vds_access.py @@ -11,6 +11,7 @@ from primary import config from primary.services.utils.httpx_async_client_wrapper import HTTPX_ASYNC_CLIENT_WRAPPER +from primary.services.service_exceptions import ServiceRequestError from .response_types import VdsArray, VdsAxis, VdsMetadata, VdsFenceMetadata, VdsSliceMetadata from .request_types import ( @@ -60,16 +61,21 @@ def __init__( @staticmethod async def _query_async(endpoint: str, request: VdsRequestedResource) -> httpx.Response: """Query the service""" + try: + response = await HTTPX_ASYNC_CLIENT_WRAPPER.client.post( + f"{config.VDS_HOST_ADDRESS}/{endpoint}", + headers={"Content-Type": "application/json"}, + content=json.dumps(request.request_parameters()), + timeout=60, + ) - response = await HTTPX_ASYNC_CLIENT_WRAPPER.client.post( - f"{config.VDS_HOST_ADDRESS}/{endpoint}", - headers={"Content-Type": "application/json"}, - content=json.dumps(request.request_parameters()), - timeout=60, - ) + if response.is_error: + raise ServiceRequestError( + f"({str(response.status_code)})-{response.reason_phrase}-{response.text}", service=Service.VDS + ) - if response.is_error: - raise RuntimeError(f"({str(response.status_code)})-{response.reason_phrase}-{response.text}") + except httpx.RequestError as error: + raise ServiceRequestError(f"{error}", service=Service.VDS) from error return response @@ -94,8 +100,14 @@ async def get_inline_slice_async(self, line_no: int) -> Tuple[NDArray[np.float32 response = await self._query_async(endpoint, slice_request) parts = self._extract_and_validate_body_parts_from_response(response) - - metadata = VdsSliceMetadata(**json.loads(parts[0].content)) + response_metadata = json.loads(parts[0].content) + metadata = VdsSliceMetadata( + format=response_metadata["format"], + shape=response_metadata["shape"], + x_axis=VdsAxis(**response_metadata["x"]), + y_axis=VdsAxis(**response_metadata["y"]), + geospatial=response_metadata["geospatial"], + ) self._assert_valid_metadata_format_and_shape(metadata) byte_array = parts[1].content @@ -254,7 +266,6 @@ async def get_flattened_fence_traces_array_and_metadata_async( # Convert every value of `hard_coded_fill_value` to np.nan flattened_fence_traces_float32_array[flattened_fence_traces_float32_array == hard_coded_fill_value] = np.nan - return (flattened_fence_traces_float32_array, num_traces, num_samples_per_trace) def _extract_and_validate_body_parts_from_response(self, response: httpx.Response) -> Tuple[BodyPart, BodyPart]: diff --git a/frontend/.dependency-cruiser.cjs b/frontend/.dependency-cruiser.cjs index 14c2d6a2bd..95b211c708 100644 --- a/frontend/.dependency-cruiser.cjs +++ b/frontend/.dependency-cruiser.cjs @@ -47,6 +47,21 @@ module.exports = { path: "^src/(?!$1/).*(/_[^/]+/)", }, }, + { + name: "no-cross-module-imports", + severity: "error", + from: { + path: "^src/modules/([^/]+)/", + }, + to: { + path: "^src/modules/([^/]+)/", + pathNot: [ + "^src/modules/_shared/", + // This should allow same-module imports + "($1)", + ], + }, + }, ], options: { /* conditions specifying which files not to follow further when encountered: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e5def90fec..9ac84b5a7b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,11 +18,12 @@ "@tanstack/react-query": "^5.63", "@tanstack/react-query-devtools": "^5.63", "@webviz/group-tree-plot": "^1.5.3", - "@webviz/subsurface-viewer": "^1.11.1", + "@webviz/subsurface-viewer": "^1.11.3", "@webviz/well-completions-plot": "^1.7.3", "@webviz/well-log-viewer": "^2.4.4", "animate.css": "^4.1.1", "axios": "^1.8.3", + "comlink": "^4.4.2", "culori": "^3.2.0", "geojson": "^0.5.0", "jotai": "^2.6.2", @@ -65,11 +66,13 @@ "glob": "^10.3.3", "globals": "^16.0.0", "prettier": "^3.5.2", + "prettier-plugin-glsl": "^0.2.2", "tailwindcss": "^4.0.9", "typescript": "^5.3.3", "typescript-eslint": "^8.25.0", "vite": "^6.3.5", "vite-plugin-checker": "^0.9.0", + "vite-plugin-glsl": "^1.3.1", "vitest": "^3.0.7" } }, @@ -366,6 +369,43 @@ "node": ">=6.9.0" } }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz", + "integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/gast": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz", + "integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/types": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz", + "integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz", + "integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@choojs/findup": { "version": "0.2.1", "license": "MIT", @@ -2345,6 +2385,18 @@ "react": "^17.0.0 || ^18.0.0" } }, + "node_modules/@netflix/nerror": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@netflix/nerror/-/nerror-1.1.3.tgz", + "integrity": "sha512-b+MGNyP9/LXkapreJzNUzcvuzZslj/RGgdVVJ16P2wSlYatfLycPObImqVJSmNAdyeShvNeM/pl3sVZsObFueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "extsprintf": "^1.4.0", + "lodash": "^4.17.15" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -6683,6 +6735,16 @@ "util": "^0.12.5" } }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -7202,6 +7264,21 @@ "node": ">= 16" } }, + "node_modules/chevrotain": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz", + "integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "10.5.0", + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "@chevrotain/utils": "10.5.0", + "lodash": "4.17.21", + "regexp-to-ast": "0.5.0" + } + }, "node_modules/chokidar": { "version": "3.6.0", "license": "MIT", @@ -7378,6 +7455,12 @@ "node": ">= 0.8" } }, + "node_modules/comlink": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.2.tgz", + "integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==", + "license": "Apache-2.0" + }, "node_modules/commander": { "version": "2.20.3", "license": "MIT" @@ -9672,6 +9755,16 @@ "type": "^2.7.2" } }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, "node_modules/falafel": { "version": "2.2.5", "license": "MIT", @@ -13971,6 +14064,21 @@ "node": ">=6.0.0" } }, + "node_modules/prettier-plugin-glsl": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-glsl/-/prettier-plugin-glsl-0.2.2.tgz", + "integrity": "sha512-VD4A59LtF6Eyw7FS46tOh6ZhVbIrdk3ikEPiFkyOAr8RgnuiL2Ad5EnuDqxYYzBqEPO3xDfhejieF3Q/AL5+7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@netflix/nerror": "^1.1.3", + "chevrotain": "^10.5.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "prettier": "^3.0.0" + } + }, "node_modules/probe-image-size": { "version": "7.2.3", "license": "MIT", @@ -14368,6 +14476,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regexp-to-ast": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", + "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==", + "dev": true, + "license": "MIT" + }, "node_modules/regexp-tree": { "version": "0.1.27", "dev": true, @@ -16582,6 +16697,23 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/vite-plugin-glsl": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/vite-plugin-glsl/-/vite-plugin-glsl-1.3.1.tgz", + "integrity": "sha512-iClII8Idb9X0m6nS0YI2cWWXbBuT5EKKw5kXSAuRu4RJsNe4oypxKXE7jx0XMoyqij2s8WL0ZLfou801mpkREg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">= 16.15.1", + "npm": ">= 8.11.0" + }, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" + } + }, "node_modules/vite-plugin-node-polyfills": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/vite-plugin-node-polyfills/-/vite-plugin-node-polyfills-0.24.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index aaa021d0c7..2aa1da05b8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,11 +34,12 @@ "@tanstack/react-query": "^5.63", "@tanstack/react-query-devtools": "^5.63", "@webviz/group-tree-plot": "^1.5.3", - "@webviz/subsurface-viewer": "^1.11.1", + "@webviz/subsurface-viewer": "^1.11.3", "@webviz/well-completions-plot": "^1.7.3", "@webviz/well-log-viewer": "^2.4.4", "animate.css": "^4.1.1", "axios": "^1.8.3", + "comlink": "^4.4.2", "culori": "^3.2.0", "geojson": "^0.5.0", "jotai": "^2.6.2", @@ -81,11 +82,13 @@ "glob": "^10.3.3", "globals": "^16.0.0", "prettier": "^3.5.2", + "prettier-plugin-glsl": "^0.2.2", "tailwindcss": "^4.0.9", "typescript": "^5.3.3", "typescript-eslint": "^8.25.0", "vite": "^6.3.5", "vite-plugin-checker": "^0.9.0", + "vite-plugin-glsl": "^1.3.1", "vitest": "^3.0.7" }, "overrides": { diff --git a/frontend/src/api/autogen/@tanstack/react-query.gen.ts b/frontend/src/api/autogen/@tanstack/react-query.gen.ts index 4d1d7ed287..86c9f4a1f8 100644 --- a/frontend/src/api/autogen/@tanstack/react-query.gen.ts +++ b/frontend/src/api/autogen/@tanstack/react-query.gen.ts @@ -60,6 +60,7 @@ import { getInlineSlice, getCrosslineSlice, getDepthSlice, + getSeismicSlices, postGetSeismicFence, getPolygonsDirectory, getPolygonsData, @@ -146,6 +147,7 @@ import type { GetInlineSliceData_api, GetCrosslineSliceData_api, GetDepthSliceData_api, + GetSeismicSlicesData_api, PostGetSeismicFenceData_api, PostGetSeismicFenceError_api, PostGetSeismicFenceResponse_api, @@ -1364,6 +1366,25 @@ export const getDepthSliceOptions = (options: Options) => }); }; +export const getSeismicSlicesQueryKey = (options: Options) => [ + createQueryKey("getSeismicSlices", options), +]; + +export const getSeismicSlicesOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getSeismicSlices({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getSeismicSlicesQueryKey(options), + }); +}; + export const postGetSeismicFenceQueryKey = (options: Options) => [ createQueryKey("postGetSeismicFence", options), ]; diff --git a/frontend/src/api/autogen/sdk.gen.ts b/frontend/src/api/autogen/sdk.gen.ts index e3602b509b..90cbafdb66 100644 --- a/frontend/src/api/autogen/sdk.gen.ts +++ b/frontend/src/api/autogen/sdk.gen.ts @@ -167,6 +167,9 @@ import type { GetDepthSliceData_api, GetDepthSliceResponse_api, GetDepthSliceError_api, + GetSeismicSlicesData_api, + GetSeismicSlicesResponse_api, + GetSeismicSlicesError_api, PostGetSeismicFenceData_api, PostGetSeismicFenceResponse_api, PostGetSeismicFenceError_api, @@ -1098,6 +1101,19 @@ export const getDepthSlice = ( }); }; +/** + * Get Seismic Slices + * Get a seismic depth slice from a seismic cube. + */ +export const getSeismicSlices = ( + options: Options, +) => { + return (options?.client ?? client).get({ + ...options, + url: "/seismic/get_seismic_slices/", + }); +}; + /** * Post Get Seismic Fence * Get a fence of seismic data from a polyline defined by a set of (x, y) coordinates in domain coordinate system. diff --git a/frontend/src/api/autogen/types.gen.ts b/frontend/src/api/autogen/types.gen.ts index 6d6ca30aee..d57087e1c0 100644 --- a/frontend/src/api/autogen/types.gen.ts +++ b/frontend/src/api/autogen/types.gen.ts @@ -3331,7 +3331,7 @@ export type GetInlineSliceData_api = { /** * Inline number */ - inline_no: number; + inline_number: number; t?: number; }; url: "/seismic/get_inline_slice/"; @@ -3386,7 +3386,7 @@ export type GetCrosslineSliceData_api = { /** * Crossline number */ - crossline_no: number; + crossline_num: number; t?: number; }; url: "/seismic/get_crossline_slice/"; @@ -3439,9 +3439,9 @@ export type GetDepthSliceData_api = { */ observed: boolean; /** - * Depth slice no + * Depth slice number */ - depth_slice_no: number; + depth_slice_num: number; t?: number; }; url: "/seismic/get_depth_slice/"; @@ -3465,6 +3465,69 @@ export type GetDepthSliceResponses_api = { export type GetDepthSliceResponse_api = GetDepthSliceResponses_api[keyof GetDepthSliceResponses_api]; +export type GetSeismicSlicesData_api = { + body?: never; + path?: never; + query: { + /** + * Sumo case uuid + */ + case_uuid: string; + /** + * Ensemble name + */ + ensemble_name: string; + /** + * Realization number + */ + realization_num: number; + /** + * Seismic cube attribute + */ + seismic_attribute: string; + /** + * Timestamp or timestep + */ + time_or_interval_str: string; + /** + * Observed or simulated + */ + observed: boolean; + /** + * Inline number + */ + inline_number: number; + /** + * Crossline number + */ + crossline_number: number; + /** + * Depth slice number + */ + depth_slice_number: number; + t?: number; + }; + url: "/seismic/get_seismic_slices/"; +}; + +export type GetSeismicSlicesErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type GetSeismicSlicesError_api = GetSeismicSlicesErrors_api[keyof GetSeismicSlicesErrors_api]; + +export type GetSeismicSlicesResponses_api = { + /** + * Successful Response + */ + 200: [SeismicSliceData_api, SeismicSliceData_api, SeismicSliceData_api]; +}; + +export type GetSeismicSlicesResponse_api = GetSeismicSlicesResponses_api[keyof GetSeismicSlicesResponses_api]; + export type PostGetSeismicFenceData_api = { body: BodyPostGetSeismicFence_api; path?: never; diff --git a/frontend/src/assets/add_path.cur b/frontend/src/assets/add_path.cur new file mode 100644 index 0000000000..ed11f1a24c Binary files /dev/null and b/frontend/src/assets/add_path.cur differ diff --git a/frontend/src/assets/add_path.svg b/frontend/src/assets/add_path.svg new file mode 100644 index 0000000000..7d1d609bb5 --- /dev/null +++ b/frontend/src/assets/add_path.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/frontend/src/assets/add_path_point.svg b/frontend/src/assets/add_path_point.svg new file mode 100644 index 0000000000..d7b020df4e --- /dev/null +++ b/frontend/src/assets/add_path_point.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/continue_path.cur b/frontend/src/assets/continue_path.cur new file mode 100644 index 0000000000..dd4db09937 Binary files /dev/null and b/frontend/src/assets/continue_path.cur differ diff --git a/frontend/src/assets/continue_path.svg b/frontend/src/assets/continue_path.svg new file mode 100644 index 0000000000..4a5dd3f202 --- /dev/null +++ b/frontend/src/assets/continue_path.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/frontend/src/assets/path.svg b/frontend/src/assets/path.svg new file mode 100644 index 0000000000..2e17d9e11b --- /dev/null +++ b/frontend/src/assets/path.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/remove_path.cur b/frontend/src/assets/remove_path.cur new file mode 100644 index 0000000000..3dc90c9cea Binary files /dev/null and b/frontend/src/assets/remove_path.cur differ diff --git a/frontend/src/assets/remove_path.svg b/frontend/src/assets/remove_path.svg new file mode 100644 index 0000000000..de114f858e --- /dev/null +++ b/frontend/src/assets/remove_path.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/frontend/src/assets/remove_path_point.svg b/frontend/src/assets/remove_path_point.svg new file mode 100644 index 0000000000..0ec59f3162 --- /dev/null +++ b/frontend/src/assets/remove_path_point.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/set_path_point.cur b/frontend/src/assets/set_path_point.cur new file mode 100644 index 0000000000..db871dbf80 Binary files /dev/null and b/frontend/src/assets/set_path_point.cur differ diff --git a/frontend/src/assets/set_path_point.svg b/frontend/src/assets/set_path_point.svg new file mode 100644 index 0000000000..077308cbfa --- /dev/null +++ b/frontend/src/assets/set_path_point.svg @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/frontend/src/framework/internal/components/Content/private-components/layout.tsx b/frontend/src/framework/internal/components/Content/private-components/layout.tsx index 4b8748488f..048f79c658 100644 --- a/frontend/src/framework/internal/components/Content/private-components/layout.tsx +++ b/frontend/src/framework/internal/components/Content/private-components/layout.tsx @@ -14,7 +14,13 @@ import type { Rect2D, Size2D } from "@lib/utils/geometry"; import { MANHATTAN_LENGTH, addMarginToRect, pointRelativeToDomRect, rectContainsPoint } from "@lib/utils/geometry"; import { convertRemToPixels } from "@lib/utils/screenUnitConversions"; import type { Vec2 } from "@lib/utils/vec2"; -import { multiplyVec2, point2Distance, scaleVec2NonUniform, subtractVec2, vec2FromPointerEvent } from "@lib/utils/vec2"; +import { + multiplyElementwiseVec2, + point2Distance, + scaleVec2NonUniform, + subtractVec2, + vec2FromPointerEvent, +} from "@lib/utils/vec2"; import { ViewWrapper } from "./ViewWrapper"; import { ViewWrapperPlaceholder } from "./viewWrapperPlaceholder"; @@ -91,7 +97,7 @@ export const Layout: React.FC = (props) => { setPosition( subtractVec2( relativePointerPosition, - multiplyVec2(relativePointerToElementDiff, { + multiplyElementwiseVec2(relativePointerToElementDiff, { x: draggedElementSize.width, y: 1, }), @@ -193,7 +199,7 @@ export const Layout: React.FC = (props) => { setPosition( subtractVec2( relativePointerPosition, - multiplyVec2(relativePointerToElementDiff, { + multiplyElementwiseVec2(relativePointerToElementDiff, { x: draggedElementSize.width, y: 1, }), diff --git a/frontend/src/framework/userCreatedItems/IntersectionPolylines.ts b/frontend/src/framework/userCreatedItems/IntersectionPolylines.ts index 1a4ece1a93..a9f13e1db5 100644 --- a/frontend/src/framework/userCreatedItems/IntersectionPolylines.ts +++ b/frontend/src/framework/userCreatedItems/IntersectionPolylines.ts @@ -1,5 +1,5 @@ import { atom } from "jotai"; -import { cloneDeep } from "lodash"; +import { cloneDeep, isEqual } from "lodash"; import { v4 } from "uuid"; import type { AtomStoreMaster } from "@framework/AtomStoreMaster"; @@ -8,6 +8,7 @@ import type { UserCreatedItemSet } from "@framework/UserCreatedItems"; export type IntersectionPolyline = { id: string; name: string; + color: [number, number, number]; path: number[][]; fieldId: string; }; @@ -55,6 +56,14 @@ export class IntersectionPolylines implements UserCreatedItemSet { this.notifySubscribers(IntersectionPolylinesEvent.CHANGE); } + setPolylines(polylines: IntersectionPolyline[]): void { + if (isEqual(this._polylines, polylines)) { + return; + } + this._polylines = [...polylines]; + this.notifySubscribers(IntersectionPolylinesEvent.CHANGE); + } + getPolylines(): readonly IntersectionPolyline[] { return this._polylines; } diff --git a/frontend/src/glsl.d.ts b/frontend/src/glsl.d.ts new file mode 100644 index 0000000000..37d2635a9d --- /dev/null +++ b/frontend/src/glsl.d.ts @@ -0,0 +1,4 @@ +declare module "*.glsl" { + const value: string; + export default value; +} diff --git a/frontend/src/lib/components/ColorTile/colorTile.tsx b/frontend/src/lib/components/ColorTile/colorTile.tsx index 8baa2ef852..74adce9d81 100644 --- a/frontend/src/lib/components/ColorTile/colorTile.tsx +++ b/frontend/src/lib/components/ColorTile/colorTile.tsx @@ -11,7 +11,7 @@ export type ColorTileProps = { export const ColorTile: React.FC = (props) => { return (
= { + outlined: ["border", "bg-transparent"], + contained: ["border", "border-transparent", "text-white"], + text: ["bg-transparent"], +}; + +const variantColorClasses: Record> = { + outlined: { + primary: ["border-indigo-600", "text-indigo-600", "hover:bg-indigo-50"], + danger: ["border-red-600", "text-red-600", "hover:bg-red-50"], + success: ["border-green-600", "text-green-600", "hover:bg-green-50"], + secondary: ["border-slate-500", "text-slate-600", "hover:bg-slate-50"], + }, + contained: { + primary: ["bg-indigo-600", "hover:bg-indigo-700"], + danger: ["bg-red-600", "hover:bg-red-700"], + success: ["bg-green-600", "hover:bg-green-700"], + secondary: ["bg-slate-500", "hover:bg-slate-600"], + }, + text: { + primary: ["text-indigo-600", "hover:bg-indigo-100"], + danger: ["text-red-600", "hover:bg-red-100"], + success: ["text-green-600", "hover:bg-green-100"], + secondary: ["text-slate-600", "hover:bg-slate-100"], + }, +}; + +const activeOverrides: Partial>>> = { + outlined: { + primary: ["bg-indigo-600", "text-white", "hover:bg-indigo-500"], + danger: ["bg-red-600", "text-white", "hover:bg-red-500"], + success: ["bg-green-600", "text-white", "hover:bg-green-500"], + secondary: ["bg-slate-500", "text-white", "hover:bg-slate-400"], + }, + contained: { + primary: ["bg-indigo-700", "text-white", "hover:bg-indigo-600"], + danger: ["bg-red-700", "text-white", "hover:bg-red-600"], + success: ["bg-green-700", "text-white", "hover:bg-green-600"], + secondary: ["bg-slate-600", "text-white", "hover:bg-slate-500"], + }, + text: { + primary: ["bg-indigo-500", "text-white", "hover:bg-indigo-400"], + danger: ["bg-red-500", "text-white", "hover:bg-red-400"], + success: ["bg-green-500", "text-white", "hover:bg-green-400"], + secondary: ["bg-slate-500", "text-white", "hover:bg-slate-400"], + }, +}; + +function getClassNames(variant: Variant, color: Color = "primary", active: boolean): string[] { + let classes = [...baseClasses[variant], ...variantColorClasses[variant][color]]; + + if (active) { + // Remove background and hover background classes + classes = classes.filter((c) => !/^bg-|^hover:bg-/.test(c)); + + const override = activeOverrides[variant]?.[color]; + if (override) { + classes.push(...override); + } + } + + return classes; +} + export type ToggleButtonProps = ButtonUnstyledProps & { active: boolean; + size?: "small" | "medium" | "large"; + variant?: Variant; + color?: Color; onToggle: (active: boolean) => void; buttonRef?: React.Ref; }; function ToggleButtonComponent(props: ToggleButtonProps, ref: React.ForwardedRef) { - const { active, onToggle, ...other } = props; - const [isActive, setIsActive] = React.useState(active); - const [prevIsActive, setPrevIsActive] = React.useState(active); + const { active, onToggle, color, variant, ...other } = props; const buttonRef = React.useRef(null); + React.useImperativeHandle( props.buttonRef, () => buttonRef.current, ); - if (active !== prevIsActive) { - setIsActive(active); - setPrevIsActive(isActive); - } - const handleClick = React.useCallback(() => { - setIsActive(!isActive); onToggle(!active); - }, [active, onToggle, isActive]); + }, [active, onToggle]); + + const classNames = [ + "inline-flex", + "items-center", + ...(props.size === "medium" + ? ["px-2", "py-1"] + : props.size === "small" + ? ["px-1", "py-0.5"] + : ["px-4", "py-2"]), + "font-medium", + "rounded-md", + ...getClassNames(variant ?? "text", color ?? "primary", active), + ]; return ( @@ -40,9 +118,7 @@ function ToggleButtonComponent(props: ToggleButtonProps, ref: React.ForwardedRef ref={buttonRef} slotProps={{ root: { - className: `inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500${ - isActive ? " bg-indigo-400 hover:bg-indigo-500 text-white" : " bg-white hover:bg-slate-100" - }`, + className: resolveClassNames(...classNames), }, }} /> diff --git a/frontend/src/lib/icons/addPathPointIcon.tsx b/frontend/src/lib/icons/addPathPointIcon.tsx new file mode 100644 index 0000000000..77d5da3be9 --- /dev/null +++ b/frontend/src/lib/icons/addPathPointIcon.tsx @@ -0,0 +1,11 @@ +import { createSvgIcon } from "@mui/material"; + +export const AddPathPointIcon = createSvgIcon( + + + + + + , + "AddPathPoint" +); diff --git a/frontend/src/lib/icons/index.ts b/frontend/src/lib/icons/index.ts new file mode 100644 index 0000000000..a1ce8b3ad6 --- /dev/null +++ b/frontend/src/lib/icons/index.ts @@ -0,0 +1,3 @@ +export { DrawPathIcon } from "./pathIcon"; +export { AddPathPointIcon } from "./addPathPointIcon"; +export { RemovePathPointIcon } from "./removePathPointIcon"; diff --git a/frontend/src/lib/icons/pathIcon.tsx b/frontend/src/lib/icons/pathIcon.tsx new file mode 100644 index 0000000000..39b6d38b04 --- /dev/null +++ b/frontend/src/lib/icons/pathIcon.tsx @@ -0,0 +1,10 @@ +import { createSvgIcon } from "@mui/material"; + +export const DrawPathIcon = createSvgIcon( + + + + + , + "DrawPath" +); diff --git a/frontend/src/lib/icons/removePathPointIcon.tsx b/frontend/src/lib/icons/removePathPointIcon.tsx new file mode 100644 index 0000000000..c643ac9e62 --- /dev/null +++ b/frontend/src/lib/icons/removePathPointIcon.tsx @@ -0,0 +1,11 @@ +import { createSvgIcon } from "@mui/material"; + +export const RemovePathPointIcon = createSvgIcon( + + + + + + , + "RemovePathPoint" +); diff --git a/frontend/src/lib/utils/assertNonNull.ts b/frontend/src/lib/utils/assertNonNull.ts index 3663d1c242..4fb7f678d8 100644 --- a/frontend/src/lib/utils/assertNonNull.ts +++ b/frontend/src/lib/utils/assertNonNull.ts @@ -1,6 +1,6 @@ -export function assertNonNull(value: T | null | undefined, message?: string): T { +export function assertNonNull(value: T | null | undefined, message?: string): NonNullable { if (value === null || value === undefined) { - message = message || "Value cannot be null or undefined"; + message = message ?? "Value cannot be null or undefined"; throw new Error(message); } return value; diff --git a/frontend/src/lib/utils/bbox.ts b/frontend/src/lib/utils/bbox.ts index 6d143efc10..b9a83d3cf9 100644 --- a/frontend/src/lib/utils/bbox.ts +++ b/frontend/src/lib/utils/bbox.ts @@ -116,12 +116,12 @@ export function combine(box1: BBox, box2: BBox): BBox { vec3.create( Math.min(box1.min.x, box2.min.x), Math.min(box1.min.y, box2.min.y), - Math.min(box1.min.z, box2.min.z) + Math.min(box1.min.z, box2.min.z), ), vec3.create( Math.max(box1.max.x, box2.max.x), Math.max(box1.max.y, box2.max.y), - Math.max(box1.max.z, box2.max.z) - ) + Math.max(box1.max.z, box2.max.z), + ), ); } diff --git a/frontend/src/lib/utils/colorConstants.ts b/frontend/src/lib/utils/colorConstants.ts new file mode 100644 index 0000000000..43d40cb778 --- /dev/null +++ b/frontend/src/lib/utils/colorConstants.ts @@ -0,0 +1,12 @@ +// This is used to set colors for different states throughout the application where CSS properties are not accessible (e.g. WebGL). +// However, it should be in sync with what is set in Tailwind CSS. +// The location of this file can still be decided upon. + +export type Colors = { + hover: [number, number, number]; + selected: [number, number, number]; +}; +export const COLORS: Colors = { + hover: [191, 219, 254], + selected: [37, 99, 235], +}; diff --git a/frontend/src/lib/utils/geometry.ts b/frontend/src/lib/utils/geometry.ts index 7b1bd56136..36601c6584 100644 --- a/frontend/src/lib/utils/geometry.ts +++ b/frontend/src/lib/utils/geometry.ts @@ -1,10 +1,18 @@ +import type { BBox } from "./bbox"; import type { Vec2 } from "./vec2"; +import type { Vec3 } from "./vec3"; export type Size2D = { width: number; height: number; }; +export type Size3D = { + width: number; + height: number; + depth: number; +}; + export type Rect2D = { x: number; y: number; @@ -12,6 +20,36 @@ export type Rect2D = { height: number; }; +export type Rect3D = { + x: number; + y: number; + z: number; + width: number; + height: number; + depth: number; +}; + +export enum ShapeType { + BOX = "box", +} + +export type Shape = { + type: ShapeType.BOX; + centerPoint: Vec3; + dimensions: Size3D; + normalizedEdgeVectors: { + // along width + u: Vec3; + // along height + v: Vec3; + }; +}; + +export type Geometry = { + shapes: Shape[]; + boundingBox: BBox; +}; + export const ORIGIN = Object.freeze({ x: 0, y: 0 }); export const MANHATTAN_LENGTH = 13.11; @@ -78,7 +116,20 @@ export function addMarginToRect(rect: Rect2D, margin: number): Rect2D { }; } -export function outerRectContainsInnerRect(outerRect: Rect2D, innerRect: Rect2D): boolean { +export function outerRectContainsInnerRect(outerRect: Rect3D, innerRect: Rect3D): boolean; +export function outerRectContainsInnerRect(outerRect: Rect2D, innerRect: Rect2D): boolean; +export function outerRectContainsInnerRect(outerRect: Rect2D | Rect3D, innerRect: Rect2D | Rect3D): boolean { + if ("depth" in outerRect && "depth" in innerRect) { + return ( + outerRect.x <= innerRect.x && + outerRect.y <= innerRect.y && + outerRect.z <= innerRect.z && + outerRect.x + outerRect.width >= innerRect.x + innerRect.width && + outerRect.y + outerRect.height >= innerRect.y + innerRect.height && + outerRect.z + outerRect.depth >= innerRect.z + innerRect.depth + ); + } + return ( outerRect.x <= innerRect.x && outerRect.y <= innerRect.y && diff --git a/frontend/src/lib/utils/mat3.ts b/frontend/src/lib/utils/mat3.ts new file mode 100644 index 0000000000..2e844e2f1d --- /dev/null +++ b/frontend/src/lib/utils/mat3.ts @@ -0,0 +1,83 @@ +/** + * A 3x3 matrix. + */ +export type Mat3 = { + m00: number; + m01: number; + m02: number; + m10: number; + m11: number; + m12: number; + m20: number; + m21: number; + m22: number; +}; + +/** + * Creates a new 3x3 matrix with all elements set to 0. + * + * @returns A new 3x3 matrix. + */ +export function createEmpty(): Mat3 { + return { + m00: 0, + m01: 0, + m02: 0, + m10: 0, + m11: 0, + m12: 0, + m20: 0, + m21: 0, + m22: 0, + }; +} + +/** + * Creates a new 3x3 matrix with the given elements. + * + * @param m00 The element at row 0, column 0. + * @param m01 The element at row 0, column 1. + * @param m02 The element at row 0, column 2. + * @param m10 The element at row 1, column 0. + * @param m11 The element at row 1, column 1. + * @param m12 The element at row 1, column 2. + * @param m20 The element at row 2, column 0. + * @param m21 The element at row 2, column 1. + * @param m22 The element at row 2, column 2. + * @returns A new 3x3 matrix. + */ +export function create( + m00: number, + m01: number, + m02: number, + m10: number, + m11: number, + m12: number, + m20: number, + m21: number, + m22: number, +): Mat3 { + return { m00, m01, m02, m10, m11, m12, m20, m21, m22 }; +} + +/** + * Creates a new 3x3 matrix from the given array of numbers. + * + * @param array An array of numbers in the following order: [m00, m01, m02, m10, m11, m12, m20, m21, m22]. + * @returns A new 3x3 matrix. + */ +export function fromArray( + array: ArrayLike | [number, number, number, number, number, number, number, number, number], +): Mat3 { + return { + m00: array[0], + m01: array[1], + m02: array[2], + m10: array[3], + m11: array[4], + m12: array[5], + m20: array[6], + m21: array[7], + m22: array[8], + }; +} diff --git a/frontend/src/lib/utils/orientedBoundingBox.ts b/frontend/src/lib/utils/orientedBoundingBox.ts new file mode 100644 index 0000000000..c2c8e35849 --- /dev/null +++ b/frontend/src/lib/utils/orientedBoundingBox.ts @@ -0,0 +1,169 @@ +import * as bbox from "./bbox"; +import * as vec3 from "./vec3"; + +export type OBBox = { + centerPoint: vec3.Vec3; + principalAxes: vec3.Vec3[]; + halfExtents: number[]; +}; + +/** + * Creates a new oriented bounding box. + */ +export function create(center: vec3.Vec3, principalAxes: vec3.Vec3[], halfExtents: number[]): OBBox { + return { centerPoint: center, principalAxes, halfExtents }; +} + +/* + * Returns true if the oriented bounding box contains the given point. + */ +export function containsPoint(box: OBBox, point: vec3.Vec3): boolean { + const diff = vec3.subtract(point, box.centerPoint); + return ( + Math.abs(vec3.dot(diff, box.principalAxes[0])) <= box.halfExtents[0] && + Math.abs(vec3.dot(diff, box.principalAxes[1])) <= box.halfExtents[1] && + Math.abs(vec3.dot(diff, box.principalAxes[2])) <= box.halfExtents[2] + ); +} + +/** + * Converts an oriented bounding box to an axis-aligned bounding box. + */ +export function toAxisAlignedBoundingBox(box: OBBox): bbox.BBox { + const absAxisX = vec3.abs(box.principalAxes[0]); + const absAxisY = vec3.abs(box.principalAxes[1]); + const absAxisZ = vec3.abs(box.principalAxes[2]); + + const halfSize: vec3.Vec3 = { + x: box.halfExtents[0] * absAxisX.x + box.halfExtents[1] * absAxisY.x + box.halfExtents[2] * absAxisZ.x, + y: box.halfExtents[0] * absAxisX.y + box.halfExtents[1] * absAxisY.y + box.halfExtents[2] * absAxisZ.y, + z: box.halfExtents[0] * absAxisX.z + box.halfExtents[1] * absAxisY.z + box.halfExtents[2] * absAxisZ.z, + }; + return bbox.create(vec3.subtract(box.centerPoint, halfSize), vec3.add(box.centerPoint, halfSize)); +} + +/* + * Creates an oriented bounding box from an axis-aligned bounding box. + * + * The principal axes are set to the standard basis vectors (x, y, z). + */ +export function fromAxisAlignedBoundingBox(box: bbox.BBox): OBBox { + const centerPoint = vec3.scale(vec3.add(box.min, box.max), 0.5); + const principalAxes = [vec3.create(1, 0, 0), vec3.create(0, 1, 0), vec3.create(0, 0, 1)]; + const halfExtents = vec3.scale(vec3.subtract(box.max, box.min), 0.5); + return create(centerPoint, principalAxes, [halfExtents.x, halfExtents.y, halfExtents.z]); +} + +/** + * Returns true if outerBox contains innerBox. + */ +export function containsBox(outerBox: OBBox, innerBox: OBBox): boolean { + const points = calcCornerPoints(innerBox); + for (const point of points) { + if (!containsPoint(outerBox, point)) { + return false; + } + } + return true; +} + +/** + * Returns the corner points of the oriented bounding box. + * + * The points are returned in the following order for z-axis up coordinate system: + * 0: bottom front left + * 1: bottom front right + * 2: bottom back left + * 3: bottom back right + * 4: top front left + * 5: top front right + * 6: top back left + * 7: top back right + */ + +export function calcCornerPoints(box: OBBox): vec3.Vec3[] { + const halfExtents = box.halfExtents; + const principalAxes = box.principalAxes; + const centerPoint = box.centerPoint; + + const points: vec3.Vec3[] = []; + for (let i = 0; i < 8; i++) { + const x = (i & 1) === 0 ? -1 : 1; + const y = (i & 2) === 0 ? -1 : 1; + const z = (i & 4) === 0 ? -1 : 1; + const point = vec3.add( + centerPoint, + vec3.add( + vec3.scale(principalAxes[0], x * halfExtents[0]), + vec3.add( + vec3.scale(principalAxes[1], y * halfExtents[1]), + vec3.scale(principalAxes[2], z * halfExtents[2]), + ), + ), + ); + points.push(point); + } + return points; +} + +/** + * Creates an oriented bounding box from the given corner points. + */ +export function fromCornerPoints(points: vec3.Vec3[]): OBBox { + const min = vec3.create(Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE); + const max = vec3.create(-Number.MAX_VALUE, -Number.MAX_VALUE, -Number.MAX_VALUE); + for (const point of points) { + min.x = Math.min(min.x, point.x); + min.y = Math.min(min.y, point.y); + min.z = Math.min(min.z, point.z); + max.x = Math.max(max.x, point.x); + max.y = Math.max(max.y, point.y); + max.z = Math.max(max.z, point.z); + } + const center = vec3.scale(vec3.add(min, max), 0.5); + const halfExtents = vec3.scale(vec3.subtract(max, min), 0.5); + const principalAxes = [ + vec3.normalize(vec3.subtract(points[0], points[1])), + vec3.normalize(vec3.subtract(points[0], points[2])), + vec3.normalize(vec3.subtract(points[0], points[4])), + ]; + return create(center, principalAxes, [halfExtents.x, halfExtents.y, halfExtents.z]); +} + +/** + * Combines two oriented bounding boxes into a new oriented bounding box that contains both input boxes. + */ +export function combine(box1: OBBox, box2: OBBox): OBBox { + const points1 = calcCornerPoints(box1); + const points2 = calcCornerPoints(box2); + const allPoints = points1.concat(points2); + const min = vec3.create(Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE); + const max = vec3.create(-Number.MAX_VALUE, -Number.MAX_VALUE, -Number.MAX_VALUE); + for (const point of allPoints) { + min.x = Math.min(min.x, point.x); + min.y = Math.min(min.y, point.y); + min.z = Math.min(min.z, point.z); + max.x = Math.max(max.x, point.x); + max.y = Math.max(max.y, point.y); + max.z = Math.max(max.z, point.z); + } + const center = vec3.scale(vec3.add(min, max), 0.5); + const halfExtents = vec3.scale(vec3.subtract(max, min), 0.5); + const principalAxes = [ + vec3.normalize(vec3.subtract(points1[0], points1[1])), + vec3.normalize(vec3.subtract(points1[0], points1[2])), + vec3.normalize(vec3.subtract(points1[0], points1[4])), + ]; + return create(center, principalAxes, [halfExtents.x, halfExtents.y, halfExtents.z]); +} + +/** + * Clones the given oriented bounding box. + */ +export function clone(box: OBBox): OBBox { + return create( + vec3.clone(box.centerPoint), + [vec3.clone(box.principalAxes[0]), vec3.clone(box.principalAxes[1]), vec3.clone(box.principalAxes[2])], + [...box.halfExtents], + ); +} diff --git a/frontend/src/lib/utils/vec2.ts b/frontend/src/lib/utils/vec2.ts index d07b0a03a5..1ceac35906 100644 --- a/frontend/src/lib/utils/vec2.ts +++ b/frontend/src/lib/utils/vec2.ts @@ -40,7 +40,7 @@ export function scaleVec2NonUniform(vector: Vec2, scalarX: number, scalarY: numb return { x: vector.x * scalarX, y: vector.y * scalarY }; } -export function multiplyVec2(vecA: Vec2, vecB: Vec2): Vec2 { +export function multiplyElementwiseVec2(vecA: Vec2, vecB: Vec2): Vec2 { return { x: vecA.x * vecB.x, y: vecA.y * vecB.y }; } diff --git a/frontend/src/lib/utils/vec3.ts b/frontend/src/lib/utils/vec3.ts index 7105315cde..90f676aeda 100644 --- a/frontend/src/lib/utils/vec3.ts +++ b/frontend/src/lib/utils/vec3.ts @@ -1,3 +1,5 @@ +import type { Mat3 } from "./mat3"; + /** * A 3D vector. */ @@ -51,3 +53,213 @@ export function toArray(vector: Vec3): [number, number, number] { export function clone(vector: Vec3): Vec3 { return { x: vector.x, y: vector.y, z: vector.z }; } + +/** + * Calculates the length of the given vector. + * + * @param vector The vector. + * @returns The length of the vector. + */ +export function length(vector: Vec3): number { + return Math.sqrt(vector.x ** 2 + vector.y ** 2 + vector.z ** 2); +} + +/** + * Calculates the squared length of the given vector. + * + * @param vector The vector. + * @returns The squared length of the vector. + */ +export function squaredLength(vector: Vec3): number { + return vector.x ** 2 + vector.y ** 2 + vector.z ** 2; +} + +/** + * Calculates the distance between two points. + * + * @param point1 The first point. + * @param point2 The second point. + * @returns The distance between the two points. + */ +export function distance(point1: Vec3, point2: Vec3): number { + return Math.sqrt((point1.x - point2.x) ** 2 + (point1.y - point2.y) ** 2 + (point1.z - point2.z) ** 2); +} + +/** + * Calculates the squared distance between two points. + * + * @param point1 The first point. + * @param point2 The second point. + * @returns The squared distance between the two points. + */ +export function squaredDistance(point1: Vec3, point2: Vec3): number { + return (point1.x - point2.x) ** 2 + (point1.y - point2.y) ** 2 + (point1.z - point2.z) ** 2; +} + +/** + * Subtracts the subtrahend from the minuend. + * + * @param minuend The minuend. + * @param subtrahend The subtrahend. + * @returns A new vector that is the result of the subtraction. + */ +export function subtract(minuend: Vec3, subtrahend: Vec3): Vec3 { + return { x: minuend.x - subtrahend.x, y: minuend.y - subtrahend.y, z: minuend.z - subtrahend.z }; +} + +/** + * Adds two or more vectors. + * + * @param vector1 The first vector. + * @param vectors The other vectors. + * @returns A new vector that is the result of the addition. + */ +export function add(vector1: Vec3, ...vectors: Vec3[]): Vec3 { + if (vectors.length === 0) { + return clone(vector1); + } + + return vectors.reduce((acc, vector2) => { + if (!vector2) { + throw new Error("Cannot add undefined or null vector."); + } + if (typeof vector2.x !== "number" || typeof vector2.y !== "number" || typeof vector2.z !== "number") { + throw new Error("Invalid vector: must have numeric x, y, and z properties."); + } + + if (typeof acc.x !== "number" || typeof acc.y !== "number" || typeof acc.z !== "number") { + throw new Error("Invalid accumulator vector: must have numeric x, y, and z properties."); + } + + return { x: acc.x + vector2.x, y: acc.y + vector2.y, z: acc.z + vector2.z }; + }, vector1); +} + +/** + * Concatenates multiple vectors. + * + * @param vectors The vectors to concatenate. + * @returns A new vector that is the result of the concatenation. + */ +export function concat(...vectors: Vec3[]): Vec3 { + return vectors.reduce((acc, vector) => add(acc, vector), create(0, 0, 0)); +} + +/** + * Normalizes the given vector. + * + * @param vector The vector. + * @returns A new vector that is the normalized version of the given vector. + */ +export function normalize(vector: Vec3): Vec3 { + const len = length(vector); + if (len === 0) return { x: 0, y: 0, z: 0 }; + return { x: vector.x / len, y: vector.y / len, z: vector.z / len }; +} + +/** + * Negates the given vector. + * + * @param vector The vector. + * @returns A new vector that is the negated version of the given vector. + */ +export function negate(vector: Vec3): Vec3 { + return { x: -vector.x, y: -vector.y, z: -vector.z }; +} + +/** + * Returns the absolute values of the components of the given vector. + * + * @param vector The vector. + * @returns A new vector that is the absolute version of the given vector. + */ +export function abs(vector: Vec3): Vec3 { + return { x: Math.abs(vector.x), y: Math.abs(vector.y), z: Math.abs(vector.z) }; +} + +/** + * Scales the given vector by the given scalar. + * + * @param vector The vector. + * @param scalar The scalar. + * @returns A new vector that is the result of the scaling. + */ +export function scale(vector: Vec3, scalar: number): Vec3 { + return { x: vector.x * scalar, y: vector.y * scalar, z: vector.z * scalar }; +} + +/** + * Scales the given vector components by the respective given scalar. + * + * @param vector The vector. + * @param scalarX The scalar for the x component. + * @param scalarY The scalar for the y component. + * @param scalarZ The scalar for the z component. + * @returns A new vector that is the result of the scaling. + */ +export function scaleNonUniform(vector: Vec3, scalarX: number, scalarY: number, scalarZ: number): Vec3 { + return { x: vector.x * scalarX, y: vector.y * scalarY, z: vector.z * scalarZ }; +} + +/** + * Multiplies two vectors element-wise. + * + * @param vecA The first vector. + * @param vecB The second vector. + * @returns A new vector that is the result of the element-wise multiplication. + */ +export function multiplyElementWise(vecA: Vec3, vecB: Vec3): Vec3 { + return { x: vecA.x * vecB.x, y: vecA.y * vecB.y, z: vecA.z * vecB.z }; +} + +/** + * Returns true if the two vectors are equal. + * + * @param vector1 The first vector. + * @param vector2 The second vector. + * @returns True if the two vectors are equal. + */ +export function equal(vector1: Vec3, vector2: Vec3): boolean { + return vector1.x === vector2.x && vector1.y === vector2.y && vector1.z === vector2.z; +} + +/** + * Calculates the dot product of two vectors. + * + * @param vector1 The first vector. + * @param vector2 The second vector. + * @returns The dot product of the two vectors. + */ +export function dot(vector1: Vec3, vector2: Vec3): number { + return vector1.x * vector2.x + vector1.y * vector2.y + vector1.z * vector2.z; +} + +/** + * Calculates the cross product of two vectors. + * + * @param vector1 The first vector. + * @param vector2 The second vector. + * @returns A new vector that is the cross product of the two vectors. + */ +export function cross(vector1: Vec3, vector2: Vec3): Vec3 { + return { + x: vector1.y * vector2.z - vector1.z * vector2.y, + y: vector1.z * vector2.x - vector1.x * vector2.z, + z: vector1.x * vector2.y - vector1.y * vector2.x, + }; +} + +/** + * Transforms the given vector by the given matrix. + * + * @param vector A 3D vector to transform. + * @param matrix A 3x3 matrix to transform the vector with. + * @returns A new 3D vector that is the result of the transformation. + */ +export function transform(vector: Vec3, matrix: Mat3): Vec3 { + return { + x: matrix.m00 * vector.x + matrix.m01 * vector.y + matrix.m02 * vector.z, + y: matrix.m10 * vector.x + matrix.m11 * vector.y + matrix.m12 * vector.z, + z: matrix.m20 * vector.x + matrix.m21 * vector.y + matrix.m22 * vector.z, + }; +} diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationGridProvider.ts b/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationGridProvider.ts index 6ab9e91101..5dcfa3fb84 100644 --- a/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationGridProvider.ts +++ b/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationGridProvider.ts @@ -1,8 +1,6 @@ import { isEqual } from "lodash"; import { getGridModelsInfoOptions, getGridParameterOptions, getGridSurfaceOptions } from "@api"; -import type { GridMappedProperty_trans, GridSurface_trans } from "@modules/3DViewer/view/queries/queryDataTransforms"; -import { transformGridMappedProperty, transformGridSurface } from "@modules/3DViewer/view/queries/queryDataTransforms"; import type { AreSettingsValidArgs, CustomDataProviderImplementation, @@ -12,6 +10,13 @@ import type { import type { DefineDependenciesArgs } from "@modules/_shared/DataProviderFramework/interfacesAndTypes/customSettingsHandler"; import type { MakeSettingTypesMap } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions"; import { Setting } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions"; +import type { RealizationGridData } from "@modules/_shared/DataProviderFramework/visualization/utils/types"; +import { + transformGridMappedProperty, + transformGridSurface, + type GridMappedProperty_trans, + type GridSurface_trans, +} from "@modules/_shared/utils/queryDataTransforms"; const realizationGridSettings = [ Setting.ENSEMBLE, @@ -26,11 +31,6 @@ const realizationGridSettings = [ export type RealizationGridSettings = typeof realizationGridSettings; type SettingsWithTypes = MakeSettingTypesMap; -export type RealizationGridData = { - gridSurfaceData: GridSurface_trans; - gridParameterData: GridMappedProperty_trans; -}; - type StoredData = { availableGridDimensions: { i: number; diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/dataProviderTypes.ts b/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/dataProviderTypes.ts index 0798a98ea3..f3bdb2f4fa 100644 --- a/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/dataProviderTypes.ts +++ b/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/dataProviderTypes.ts @@ -1,7 +1,4 @@ export enum CustomDataProviderType { OBSERVED_SURFACE = "OBSERVED_SURFACE", - REALIZATION_SURFACE = "REALIZATION_SURFACE", - STATISTICAL_SURFACE = "STATISTICAL_SURFACE", - REALIZATION_POLYGONS = "REALIZATION_POLYGONS", - REALIZATION_GRID = "REALIZATION_GRID", + REALIZATION_GRID_2D = "REALIZATION_GRID_2D", } diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/registerAllDataProviders.ts b/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/registerAllDataProviders.ts index 9dbf2ac3bd..a691c8ba10 100644 --- a/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/registerAllDataProviders.ts +++ b/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/registerAllDataProviders.ts @@ -3,12 +3,6 @@ import { DataProviderRegistry } from "@modules/_shared/DataProviderFramework/dat import { CustomDataProviderType } from "./dataProviderTypes"; import { ObservedSurfaceProvider } from "./ObservedSurfaceProvider"; import { RealizationGridProvider } from "./RealizationGridProvider"; -import { RealizationPolygonsProvider } from "./RealizationPolygonsProvider"; -import { RealizationSurfaceProvider } from "./RealizationSurfaceProvider"; -import { StatisticalSurfaceProvider } from "./StatisticalSurfaceProvider"; DataProviderRegistry.registerDataProvider(CustomDataProviderType.OBSERVED_SURFACE, ObservedSurfaceProvider); -DataProviderRegistry.registerDataProvider(CustomDataProviderType.REALIZATION_GRID, RealizationGridProvider); -DataProviderRegistry.registerDataProvider(CustomDataProviderType.REALIZATION_POLYGONS, RealizationPolygonsProvider); -DataProviderRegistry.registerDataProvider(CustomDataProviderType.REALIZATION_SURFACE, RealizationSurfaceProvider); -DataProviderRegistry.registerDataProvider(CustomDataProviderType.STATISTICAL_SURFACE, StatisticalSurfaceProvider); +DataProviderRegistry.registerDataProvider(CustomDataProviderType.REALIZATION_GRID_2D, RealizationGridProvider); diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeObservedSurfaceLayer.ts b/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeObservedSurfaceLayer.ts index 97e782e056..52112528bc 100644 --- a/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeObservedSurfaceLayer.ts +++ b/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeObservedSurfaceLayer.ts @@ -38,7 +38,7 @@ export function makeObservedSurfaceLayer({ getSetting, }: TransformerArgs): ColormapLayer | Grid3DLayer | null { const data = getData(); - const colorScale = getSetting(Setting.COLOR_SCALE)?.colorScale; + const colorScaleSpec = getSetting(Setting.COLOR_SCALE); if (!data) { return null; @@ -52,11 +52,11 @@ export function makeObservedSurfaceLayer({ parameters: { depthWriteEnabled: false, }, - colorMapFunction: makeColorMapFunctionFromColorScale( - colorScale, - data.surfaceData.value_min, - data.surfaceData.value_max, - ), + colorMapFunction: makeColorMapFunctionFromColorScale(colorScaleSpec, { + valueMin: data.surfaceData.value_min, + valueMax: data.surfaceData.value_max, + denormalize: true, + }), }); } @@ -72,10 +72,10 @@ export function makeObservedSurfaceLayer({ parameters: { depthWriteEnabled: false, }, - colorMapFunction: makeColorMapFunctionFromColorScale( - colorScale, - data.surfaceData.value_min, - data.surfaceData.value_max, - ), + colorMapFunction: makeColorMapFunctionFromColorScale(colorScaleSpec, { + valueMin: data.surfaceData.value_min, + valueMax: data.surfaceData.value_max, + denormalize: true, + }), }); } diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeRealizationGridLayer.ts b/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeRealizationGridLayer.ts deleted file mode 100644 index bf02d19794..0000000000 --- a/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeRealizationGridLayer.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Grid3DLayer } from "@webviz/subsurface-viewer/dist/layers"; - -import { Setting } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions"; -import { makeColorMapFunctionFromColorScale } from "@modules/_shared/DataProviderFramework/visualization/utils/colors"; -import type { TransformerArgs } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; - -import type { - RealizationGridData, - RealizationGridSettings, -} from "../customDataProviderImplementations/RealizationGridProvider"; - -export function makeRealizationGridLayer({ - id, - getData, - getSetting, -}: TransformerArgs): Grid3DLayer | null { - const data = getData(); - const colorScale = getSetting(Setting.COLOR_SCALE)?.colorScale; - - if (!data) { - return null; - } - - const { gridSurfaceData, gridParameterData } = data; - const showGridLines = getSetting(Setting.SHOW_GRID_LINES); - - const offsetXyz = [gridSurfaceData.origin_utm_x, gridSurfaceData.origin_utm_y, 0]; - const pointsNumberArray = gridSurfaceData.pointsFloat32Arr.map((val, i) => val + offsetXyz[i % 3]); - const polysNumberArray = gridSurfaceData.polysUint32Arr; - const grid3dLayer = new Grid3DLayer({ - id: id, - pointsData: pointsNumberArray, - polysData: polysNumberArray, - propertiesData: gridParameterData.polyPropsFloat32Arr, - ZIncreasingDownwards: false, - gridLines: showGridLines ?? false, - material: { ambient: 0.4, diffuse: 0.7, shininess: 8, specularColor: [25, 25, 25] }, - pickable: true, - colorMapName: "Physics", - colorMapClampColor: true, - colorMapRange: [gridParameterData.min_grid_prop_value, gridParameterData.max_grid_prop_value], - colorMapFunction: makeColorMapFunctionFromColorScale( - colorScale, - gridParameterData.min_grid_prop_value, - gridParameterData.max_grid_prop_value, - ), - }); - return grid3dLayer; -} diff --git a/frontend/src/modules/2DViewer/settings/components/dataProviderManagerWrapper.tsx b/frontend/src/modules/2DViewer/settings/components/dataProviderManagerWrapper.tsx index 866e44da87..e8be1a2fab 100644 --- a/frontend/src/modules/2DViewer/settings/components/dataProviderManagerWrapper.tsx +++ b/frontend/src/modules/2DViewer/settings/components/dataProviderManagerWrapper.tsx @@ -22,12 +22,12 @@ import { MenuItem } from "@lib/components/MenuItem"; import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { CustomDataProviderType } from "@modules/2DViewer/DataProviderFramework/customDataProviderImplementations/dataProviderTypes"; import { ObservedSurfaceProvider } from "@modules/2DViewer/DataProviderFramework/customDataProviderImplementations/ObservedSurfaceProvider"; -import { RealizationSurfaceProvider } from "@modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationSurfaceProvider"; -import { StatisticalSurfaceProvider } from "@modules/2DViewer/DataProviderFramework/customDataProviderImplementations/StatisticalSurfaceProvider"; import { PreferredViewLayout } from "@modules/2DViewer/types"; import type { ActionGroup } from "@modules/_shared/DataProviderFramework/Actions"; import { DataProviderRegistry } from "@modules/_shared/DataProviderFramework/dataProviders/DataProviderRegistry"; import { DataProviderType } from "@modules/_shared/DataProviderFramework/dataProviders/dataProviderTypes"; +import { RealizationSurfaceProvider } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/RealizationSurfaceProvider"; +import { StatisticalSurfaceProvider } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/StatisticalSurfaceProvider"; import type { GroupDelegate } from "@modules/_shared/DataProviderFramework/delegates/GroupDelegate"; import { GroupDelegateTopic } from "@modules/_shared/DataProviderFramework/delegates/GroupDelegate"; import { DataProvider } from "@modules/_shared/DataProviderFramework/framework/DataProvider/DataProvider"; @@ -85,7 +85,7 @@ export function DataProviderManagerWrapper(props: LayerManagerComponentWrapperPr case "statistical-surface": groupDelegate.prependChild( DataProviderRegistry.makeDataProvider( - CustomDataProviderType.STATISTICAL_SURFACE, + DataProviderType.STATISTICAL_SURFACE, props.dataProviderManager, ), ); @@ -93,7 +93,7 @@ export function DataProviderManagerWrapper(props: LayerManagerComponentWrapperPr case "realization-surface": groupDelegate.prependChild( DataProviderRegistry.makeDataProvider( - CustomDataProviderType.REALIZATION_SURFACE, + DataProviderType.REALIZATION_SURFACE, props.dataProviderManager, ), ); @@ -101,7 +101,7 @@ export function DataProviderManagerWrapper(props: LayerManagerComponentWrapperPr case "realization-polygons": groupDelegate.prependChild( DataProviderRegistry.makeDataProvider( - CustomDataProviderType.REALIZATION_POLYGONS, + DataProviderType.REALIZATION_POLYGONS, props.dataProviderManager, ), ); @@ -125,7 +125,7 @@ export function DataProviderManagerWrapper(props: LayerManagerComponentWrapperPr case "realization-grid": groupDelegate.prependChild( DataProviderRegistry.makeDataProvider( - CustomDataProviderType.REALIZATION_GRID, + CustomDataProviderType.REALIZATION_GRID_2D, props.dataProviderManager, ), ); @@ -370,11 +370,6 @@ const ACTIONS: ActionGroup[] = [ icon: , label: "Date", }, - ], - }, - { - label: "Utilities", - children: [ { identifier: "color-scale", icon: , diff --git a/frontend/src/modules/2DViewer/view/components/LayersWrapper.tsx b/frontend/src/modules/2DViewer/view/components/LayersWrapper.tsx index 72b9f8aaf9..ec7dc2eb2c 100644 --- a/frontend/src/modules/2DViewer/view/components/LayersWrapper.tsx +++ b/frontend/src/modules/2DViewer/view/components/LayersWrapper.tsx @@ -7,33 +7,33 @@ import type { ViewContext } from "@framework/ModuleContext"; import { useViewStatusWriter } from "@framework/StatusWriter"; import { PendingWrapper } from "@lib/components/PendingWrapper"; import * as bbox from "@lib/utils/bbox"; -import { makeColorScaleAnnotation } from "@modules/2DViewer/DataProviderFramework/annotations/makeColorScaleAnnotation"; -import { makePolygonDataBoundingBox } from "@modules/2DViewer/DataProviderFramework/boundingBoxes/makePolygonDataBoundingBox"; -import { makeRealizationGridBoundingBox } from "@modules/2DViewer/DataProviderFramework/boundingBoxes/makeRealizationGridBoundingBox"; -import { makeSurfaceLayerBoundingBox } from "@modules/2DViewer/DataProviderFramework/boundingBoxes/makeSurfaceLayerBoundingBox"; import { CustomDataProviderType } from "@modules/2DViewer/DataProviderFramework/customDataProviderImplementations/dataProviderTypes"; import { ObservedSurfaceProvider } from "@modules/2DViewer/DataProviderFramework/customDataProviderImplementations/ObservedSurfaceProvider"; import { RealizationGridProvider } from "@modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationGridProvider"; -import { RealizationPolygonsProvider } from "@modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationPolygonsProvider"; -import { RealizationSurfaceProvider } from "@modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationSurfaceProvider"; -import { StatisticalSurfaceProvider } from "@modules/2DViewer/DataProviderFramework/customDataProviderImplementations/StatisticalSurfaceProvider"; import { makeObservedSurfaceLayer } from "@modules/2DViewer/DataProviderFramework/visualization/makeObservedSurfaceLayer"; -import { makeRealizationGridLayer } from "@modules/2DViewer/DataProviderFramework/visualization/makeRealizationGridLayer"; -import { makeRealizationPolygonsLayer } from "@modules/2DViewer/DataProviderFramework/visualization/makeRealizationPolygonsLayer"; -import { makeRealizationSurfaceLayer } from "@modules/2DViewer/DataProviderFramework/visualization/makeRealizationSurfaceLayer"; -import { makeStatisticalSurfaceLayer } from "@modules/2DViewer/DataProviderFramework/visualization/makeStatisticalSurfaceLayer"; import type { Interfaces } from "@modules/2DViewer/interfaces"; import { PreferredViewLayout } from "@modules/2DViewer/types"; import { DataProviderType } from "@modules/_shared/DataProviderFramework/dataProviders/dataProviderTypes"; import { DrilledWellborePicksProvider } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/DrilledWellborePicksProvider"; import { DrilledWellTrajectoriesProvider } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/DrilledWellTrajectoriesProvider"; +import { RealizationPolygonsProvider } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/RealizationPolygonsProvider"; +import { RealizationSurfaceProvider } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/RealizationSurfaceProvider"; +import { StatisticalSurfaceProvider } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/StatisticalSurfaceProvider"; import type { DataProviderManager } from "@modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager"; import { GroupType } from "@modules/_shared/DataProviderFramework/groups/groupTypes"; import { useVisualizationAssemblerProduct } from "@modules/_shared/DataProviderFramework/hooks/useVisualizationProduct"; +import { makeColorScaleAnnotation } from "@modules/_shared/DataProviderFramework/visualization/annotations/makeColorScaleAnnotation"; +import { makePolygonDataBoundingBox } from "@modules/_shared/DataProviderFramework/visualization/boundingBoxes/makePolygonDataBoundingBox"; +import { makeRealizationGridBoundingBox } from "@modules/_shared/DataProviderFramework/visualization/boundingBoxes/makeRealizationGridBoundingBox"; +import { makeSurfaceLayerBoundingBox } from "@modules/_shared/DataProviderFramework/visualization/boundingBoxes/makeSurfaceLayerBoundingBox"; import { makeDrilledWellborePicksBoundingBox } from "@modules/_shared/DataProviderFramework/visualization/deckgl/boundingBoxes/makeDrilledWellborePicksBoundingBox"; import { makeDrilledWellTrajectoriesBoundingBox } from "@modules/_shared/DataProviderFramework/visualization/deckgl/boundingBoxes/makeDrilledWellTrajectoriesBoundingBox"; import { makeDrilledWellborePicksLayer } from "@modules/_shared/DataProviderFramework/visualization/deckgl/makeDrilledWellborePicksLayer"; import { makeDrilledWellTrajectoriesLayer } from "@modules/_shared/DataProviderFramework/visualization/deckgl/makeDrilledWellTrajectoriesLayer"; +import { makeRealizationGridLayer } from "@modules/_shared/DataProviderFramework/visualization/deckgl/makeRealizationGridLayer"; +import { makeRealizationPolygonsLayer } from "@modules/_shared/DataProviderFramework/visualization/deckgl/makeRealizationPolygonsLayer"; +import { makeRealizationSurfaceLayer } from "@modules/_shared/DataProviderFramework/visualization/deckgl/makeRealizationSurfaceLayer"; +import { makeStatisticalSurfaceLayer } from "@modules/_shared/DataProviderFramework/visualization/deckgl/makeStatisticalSurfaceLayer"; import type { VisualizationTarget } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; import { VisualizationAssembler, @@ -65,7 +65,7 @@ VISUALIZATION_ASSEMBLER.registerDataProviderTransformers( }, ); VISUALIZATION_ASSEMBLER.registerDataProviderTransformers( - CustomDataProviderType.REALIZATION_SURFACE, + DataProviderType.REALIZATION_SURFACE, RealizationSurfaceProvider, { transformToVisualization: makeRealizationSurfaceLayer, @@ -74,7 +74,7 @@ VISUALIZATION_ASSEMBLER.registerDataProviderTransformers( }, ); VISUALIZATION_ASSEMBLER.registerDataProviderTransformers( - CustomDataProviderType.STATISTICAL_SURFACE, + DataProviderType.STATISTICAL_SURFACE, StatisticalSurfaceProvider, { transformToVisualization: makeStatisticalSurfaceLayer, @@ -83,7 +83,7 @@ VISUALIZATION_ASSEMBLER.registerDataProviderTransformers( }, ); VISUALIZATION_ASSEMBLER.registerDataProviderTransformers( - CustomDataProviderType.REALIZATION_POLYGONS, + DataProviderType.REALIZATION_POLYGONS, RealizationPolygonsProvider, { transformToVisualization: makeRealizationPolygonsLayer, @@ -91,7 +91,7 @@ VISUALIZATION_ASSEMBLER.registerDataProviderTransformers( }, ); VISUALIZATION_ASSEMBLER.registerDataProviderTransformers( - CustomDataProviderType.REALIZATION_GRID, + CustomDataProviderType.REALIZATION_GRID_2D, RealizationGridProvider, { transformToVisualization: makeRealizationGridLayer, diff --git a/frontend/src/modules/3DViewer/DataProviderFramework/accumulators/polylineIdsAccumulator.ts b/frontend/src/modules/3DViewer/DataProviderFramework/accumulators/polylineIdsAccumulator.ts new file mode 100644 index 0000000000..9568886f83 --- /dev/null +++ b/frontend/src/modules/3DViewer/DataProviderFramework/accumulators/polylineIdsAccumulator.ts @@ -0,0 +1,32 @@ +import { IntersectionType } from "@framework/types/intersection"; +import type { IntersectionRealizationGridSettings } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/IntersectionRealizationGridProvider"; +import { Setting } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions"; +import type { TransformerArgs } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; +import type { PolylineIntersection_trans } from "@modules/_shared/Intersection/gridIntersectionTransform"; + +export type AccumulatedData = { + polylineIds: string[]; +}; + +export function accumulatePolylineIds( + accumulatedData: AccumulatedData, + { getSetting }: TransformerArgs, +): AccumulatedData { + const intersection = getSetting(Setting.INTERSECTION); + + if (!intersection) { + return accumulatedData; + } + + if (intersection.type !== IntersectionType.CUSTOM_POLYLINE) { + return accumulatedData; + } + + const polylineIdsSet = new Set(accumulatedData.polylineIds); + polylineIdsSet.add(intersection.uuid); + + return { + ...accumulatedData, + polylineIds: Array.from(polylineIdsSet), + }; +} diff --git a/frontend/src/modules/3DViewer/DataProviderFramework/boundingBoxes/makeIntersectionRealizationGridBoundingBox.ts b/frontend/src/modules/3DViewer/DataProviderFramework/boundingBoxes/makeIntersectionRealizationGridBoundingBox.ts new file mode 100644 index 0000000000..4cdbec9ff3 --- /dev/null +++ b/frontend/src/modules/3DViewer/DataProviderFramework/boundingBoxes/makeIntersectionRealizationGridBoundingBox.ts @@ -0,0 +1,45 @@ +import type { BBox } from "@lib/utils/bbox"; +import type { IntersectionRealizationGridData } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/IntersectionRealizationGridProvider"; +import type { TransformerArgs } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; + +export function makeIntersectionRealizationGridBoundingBox({ + getData, +}: TransformerArgs): BBox | null { + const data = getData(); + if (!data) { + return null; + } + + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let minZ = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + let maxZ = Number.NEGATIVE_INFINITY; + + for (const section of data.fenceMeshSections) { + minX = Math.min(minX, section.start_utm_x, section.end_utm_x); + minY = Math.min(minY, section.start_utm_y, section.end_utm_y); + maxX = Math.max(maxX, section.start_utm_x, section.end_utm_x); + maxY = Math.max(maxY, section.start_utm_y, section.end_utm_y); + + for (let i = 0; i < section.verticesUzFloat32Arr.length; i += 2) { + const z = section.verticesUzFloat32Arr[i + 1]; + minZ = Math.min(minZ, z); + maxZ = Math.max(maxZ, z); + } + } + + return { + min: { + x: minX, + y: minY, + z: -maxZ, + }, + max: { + x: maxX, + y: maxY, + z: -minZ, + }, + }; +} diff --git a/frontend/src/modules/3DViewer/DataProviderFramework/boundingBoxes/makeIntersectionRealizationSeismicBoundingBox.ts b/frontend/src/modules/3DViewer/DataProviderFramework/boundingBoxes/makeIntersectionRealizationSeismicBoundingBox.ts new file mode 100644 index 0000000000..a08dd8d237 --- /dev/null +++ b/frontend/src/modules/3DViewer/DataProviderFramework/boundingBoxes/makeIntersectionRealizationSeismicBoundingBox.ts @@ -0,0 +1,47 @@ +import type { BBox } from "@lib/utils/bbox"; +import type { + IntersectionRealizationSeismicData, + IntersectionRealizationSeismicStoredData, +} from "@modules/_shared/DataProviderFramework/dataProviders/implementations/IntersectionRealizationSeismicProvider"; +import type { TransformerArgs } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; + +export function makeIntersectionRealizationSeismicBoundingBox({ + getData, + getStoredData, +}: TransformerArgs): BBox | null { + const data = getData(); + const polyline = getStoredData("seismicFencePolylineWithSectionLengths"); + if (!polyline || !data) { + return null; + } + + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + const minZ = data.min_fence_depth; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + const maxZ = data.max_fence_depth; + + for (let i = 0; i < polyline.polylineUtmXy.length; i += 2) { + const x = polyline.polylineUtmXy[i]; + const y = polyline.polylineUtmXy[i + 1]; + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + + return { + min: { + x: minX, + y: minY, + z: minZ, + }, + max: { + x: maxX, + y: maxY, + z: maxZ, + }, + }; +} diff --git a/frontend/src/modules/3DViewer/DataProviderFramework/boundingBoxes/makeRealizationSeismicSlicesBoundingBox.ts b/frontend/src/modules/3DViewer/DataProviderFramework/boundingBoxes/makeRealizationSeismicSlicesBoundingBox.ts new file mode 100644 index 0000000000..8023c914be --- /dev/null +++ b/frontend/src/modules/3DViewer/DataProviderFramework/boundingBoxes/makeRealizationSeismicSlicesBoundingBox.ts @@ -0,0 +1,29 @@ +import type { BBox } from "@lib/utils/bbox"; +import type { TransformerArgs } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; + +import type { + RealizationSeismicSlicesData, + RealizationSeismicSlicesStoredData, +} from "../customDataProviderImplementations/RealizationSeismicSlicesProvider"; + +export function makeRealizationSeismicSlicesBoundingBox({ + getStoredData, +}: TransformerArgs): BBox | null { + const seismicCubeMeta = getStoredData("seismicCubeMeta"); + if (!seismicCubeMeta) { + return null; + } + + return { + min: { + x: seismicCubeMeta.bbox.xmin, + y: seismicCubeMeta.bbox.ymin, + z: seismicCubeMeta.bbox.zmin, + }, + max: { + x: seismicCubeMeta.bbox.xmax, + y: seismicCubeMeta.bbox.ymax, + z: seismicCubeMeta.bbox.zmax, + }, + }; +} diff --git a/frontend/src/modules/3DViewer/DataProviderFramework/customDataProviderImplementations/RealizationGridProvider.ts b/frontend/src/modules/3DViewer/DataProviderFramework/customDataProviderImplementations/RealizationGridProvider.ts new file mode 100644 index 0000000000..e9a8ea328a --- /dev/null +++ b/frontend/src/modules/3DViewer/DataProviderFramework/customDataProviderImplementations/RealizationGridProvider.ts @@ -0,0 +1,277 @@ +import { getGridModelsInfoOptions, getGridParameterOptions, getGridSurfaceOptions } from "@api"; +import { NO_UPDATE } from "@modules/_shared/DataProviderFramework/delegates/_utils/Dependency"; +import type { + AreSettingsValidArgs, + CustomDataProviderImplementation, + DataProviderInformationAccessors, + FetchDataParams, +} from "@modules/_shared/DataProviderFramework/interfacesAndTypes/customDataProviderImplementation"; +import type { DefineDependenciesArgs } from "@modules/_shared/DataProviderFramework/interfacesAndTypes/customSettingsHandler"; +import type { MakeSettingTypesMap } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions"; +import { Setting } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions"; +import type { RealizationGridData } from "@modules/_shared/DataProviderFramework/visualization/utils/types"; +import { + transformGridMappedProperty, + transformGridSurface, + type GridMappedProperty_trans, + type GridSurface_trans, +} from "@modules/_shared/utils/queryDataTransforms"; + +const realizationGridSettings = [ + Setting.ENSEMBLE, + Setting.REALIZATION, + Setting.GRID_NAME, + Setting.ATTRIBUTE, + Setting.GRID_LAYER_RANGE, + Setting.TIME_OR_INTERVAL, + Setting.SHOW_GRID_LINES, + Setting.COLOR_SCALE, + Setting.OPACITY_PERCENT, +] as const; +export type RealizationGridSettings = typeof realizationGridSettings; +type SettingsWithTypes = MakeSettingTypesMap; + +export class RealizationGridProvider + implements CustomDataProviderImplementation +{ + settings = realizationGridSettings; + + getDefaultSettingsValues() { + return { + [Setting.SHOW_GRID_LINES]: false, + [Setting.OPACITY_PERCENT]: 100, + }; + } + + getDefaultName() { + return "Realization Grid"; + } + + doSettingsChangesRequireDataRefetch( + prevSettings: SettingsWithTypes | null, + newSettings: SettingsWithTypes, + ): boolean { + if (prevSettings === null) { + return true; + } + if ( + prevSettings[Setting.ENSEMBLE] !== newSettings[Setting.ENSEMBLE] || + prevSettings[Setting.REALIZATION] !== newSettings[Setting.REALIZATION] || + prevSettings[Setting.GRID_NAME] !== newSettings[Setting.GRID_NAME] || + prevSettings[Setting.ATTRIBUTE] !== newSettings[Setting.ATTRIBUTE] || + prevSettings[Setting.TIME_OR_INTERVAL] !== newSettings[Setting.TIME_OR_INTERVAL] || + prevSettings[Setting.GRID_LAYER_RANGE] !== newSettings[Setting.GRID_LAYER_RANGE] + ) { + return true; + } + return false; + } + + makeValueRange({ + getData, + }: DataProviderInformationAccessors): [number, number] | null { + const data = getData(); + if (!data) { + return null; + } + + return [data.gridParameterData.min_grid_prop_value, data.gridParameterData.max_grid_prop_value]; + } + + fetchData({ getSetting, fetchQuery }: FetchDataParams): Promise<{ + gridSurfaceData: GridSurface_trans; + gridParameterData: GridMappedProperty_trans; + }> { + const ensembleIdent = getSetting(Setting.ENSEMBLE); + const realizationNum = getSetting(Setting.REALIZATION); + const gridName = getSetting(Setting.GRID_NAME); + const attribute = getSetting(Setting.ATTRIBUTE); + let timeOrInterval = getSetting(Setting.TIME_OR_INTERVAL); + if (timeOrInterval === "NO_TIME") { + timeOrInterval = null; + } + const range = getSetting(Setting.GRID_LAYER_RANGE); + + if (range === null) { + throw new Error("Grid ranges are not set"); + } + + const gridParameterOptions = getGridParameterOptions({ + query: { + case_uuid: ensembleIdent?.getCaseUuid() ?? "", + ensemble_name: ensembleIdent?.getEnsembleName() ?? "", + grid_name: gridName ?? "", + parameter_name: attribute ?? "", + parameter_time_or_interval_str: timeOrInterval, + realization_num: realizationNum ?? 0, + i_min: range[0][0], + i_max: range[0][1], + j_min: range[1][0], + j_max: range[1][1], + k_min: range[2][0], + k_max: range[2][1], + }, + }); + + const gridSurfaceOptions = getGridSurfaceOptions({ + query: { + case_uuid: ensembleIdent?.getCaseUuid() ?? "", + ensemble_name: ensembleIdent?.getEnsembleName() ?? "", + grid_name: gridName ?? "", + realization_num: realizationNum ?? 0, + i_min: range[0][0], + i_max: range[0][1], + j_min: range[1][0], + j_max: range[1][1], + k_min: range[2][0], + k_max: range[2][1], + }, + }); + + const gridParameterPromise = fetchQuery(gridParameterOptions).then(transformGridMappedProperty); + + const gridSurfacePromise = fetchQuery(gridSurfaceOptions).then(transformGridSurface); + + return Promise.all([gridSurfacePromise, gridParameterPromise]).then(([gridSurfaceData, gridParameterData]) => ({ + gridSurfaceData, + gridParameterData, + })); + } + + areCurrentSettingsValid({ + getSetting, + }: AreSettingsValidArgs): boolean { + return ( + getSetting(Setting.ENSEMBLE) !== null && + getSetting(Setting.REALIZATION) !== null && + getSetting(Setting.GRID_NAME) !== null && + getSetting(Setting.ATTRIBUTE) !== null && + getSetting(Setting.GRID_LAYER_RANGE) !== null && + getSetting(Setting.TIME_OR_INTERVAL) !== null + ); + } + + defineDependencies({ + helperDependency, + availableSettingsUpdater, + queryClient, + }: DefineDependenciesArgs) { + availableSettingsUpdater(Setting.ENSEMBLE, ({ getGlobalSetting }) => { + const fieldIdentifier = getGlobalSetting("fieldId"); + const ensembles = getGlobalSetting("ensembles"); + + const ensembleIdents = ensembles + .filter((ensemble) => ensemble.getFieldIdentifier() === fieldIdentifier) + .map((ensemble) => ensemble.getIdent()); + + return ensembleIdents; + }); + + availableSettingsUpdater(Setting.REALIZATION, ({ getLocalSetting, getGlobalSetting }) => { + const ensembleIdent = getLocalSetting(Setting.ENSEMBLE); + const realizationFilterFunc = getGlobalSetting("realizationFilterFunction"); + + if (!ensembleIdent) { + return []; + } + + const realizations = realizationFilterFunc(ensembleIdent); + + return [...realizations]; + }); + const realizationGridDataDep = helperDependency(async ({ getLocalSetting, abortSignal }) => { + const ensembleIdent = getLocalSetting(Setting.ENSEMBLE); + const realization = getLocalSetting(Setting.REALIZATION); + + if (!ensembleIdent || realization === null) { + return null; + } + + return await queryClient.fetchQuery({ + ...getGridModelsInfoOptions({ + query: { + case_uuid: ensembleIdent.getCaseUuid(), + ensemble_name: ensembleIdent.getEnsembleName(), + realization_num: realization, + }, + signal: abortSignal, + }), + }); + }); + + availableSettingsUpdater(Setting.GRID_NAME, ({ getHelperDependency }) => { + const data = getHelperDependency(realizationGridDataDep); + + if (!data) { + return []; + } + + const availableGridNames = [...Array.from(new Set(data.map((gridModelInfo) => gridModelInfo.grid_name)))]; + + return availableGridNames; + }); + + availableSettingsUpdater(Setting.ATTRIBUTE, ({ getLocalSetting, getHelperDependency }) => { + const gridName = getLocalSetting(Setting.GRID_NAME); + const data = getHelperDependency(realizationGridDataDep); + + if (!gridName || !data) { + return []; + } + + const gridAttributeArr = + data.find((gridModel) => gridModel.grid_name === gridName)?.property_info_arr ?? []; + + const availableGridAttributes = [ + ...Array.from(new Set(gridAttributeArr.map((gridAttribute) => gridAttribute.property_name))), + ]; + + return availableGridAttributes; + }); + + availableSettingsUpdater(Setting.GRID_LAYER_RANGE, ({ getLocalSetting, getHelperDependency }) => { + const gridName = getLocalSetting(Setting.GRID_NAME); + const data = getHelperDependency(realizationGridDataDep); + + if (!gridName || !data) { + return NO_UPDATE; + } + + const gridDimensions = data.find((gridModel) => gridModel.grid_name === gridName)?.dimensions ?? null; + if (!gridDimensions) { + return NO_UPDATE; + } + + return [ + [0, gridDimensions.i_count - 1, 1], + [0, gridDimensions.j_count - 1, 1], + [0, gridDimensions.k_count - 1, 1], + ]; + }); + + availableSettingsUpdater(Setting.TIME_OR_INTERVAL, ({ getLocalSetting, getHelperDependency }) => { + const gridName = getLocalSetting(Setting.GRID_NAME); + const gridAttribute = getLocalSetting(Setting.ATTRIBUTE); + const data = getHelperDependency(realizationGridDataDep); + + if (!gridName || !gridAttribute || !data) { + return []; + } + + const gridAttributeArr = + data.find((gridModel) => gridModel.grid_name === gridName)?.property_info_arr ?? []; + + const availableTimeOrIntervals = [ + ...Array.from( + new Set( + gridAttributeArr + .filter((attr) => attr.property_name === gridAttribute) + .map((gridAttribute) => gridAttribute.iso_date_or_interval ?? "NO_TIME"), + ), + ), + ]; + + return availableTimeOrIntervals; + }); + } +} diff --git a/frontend/src/modules/3DViewer/DataProviderFramework/customDataProviderImplementations/RealizationSeismicSlicesProvider.ts b/frontend/src/modules/3DViewer/DataProviderFramework/customDataProviderImplementations/RealizationSeismicSlicesProvider.ts new file mode 100644 index 0000000000..2b9443e17b --- /dev/null +++ b/frontend/src/modules/3DViewer/DataProviderFramework/customDataProviderImplementations/RealizationSeismicSlicesProvider.ts @@ -0,0 +1,326 @@ +import { isEqual } from "lodash"; + +import { type SeismicCubeMeta_api, getSeismicCubeMetaListOptions, getSeismicSlicesOptions } from "@api"; +import { defaultContinuousDivergingColorPalettes } from "@framework/utils/colorPalettes"; +import { ColorScale, ColorScaleGradientType, ColorScaleType } from "@lib/utils/ColorScale"; +import { NO_UPDATE } from "@modules/_shared/DataProviderFramework/delegates/_utils/Dependency"; +import type { + AreSettingsValidArgs, + CustomDataProviderImplementation, + DataProviderInformationAccessors, + FetchDataParams, +} from "@modules/_shared/DataProviderFramework/interfacesAndTypes/customDataProviderImplementation"; +import type { DefineDependenciesArgs } from "@modules/_shared/DataProviderFramework/interfacesAndTypes/customSettingsHandler"; +import type { NullableStoredData } from "@modules/_shared/DataProviderFramework/interfacesAndTypes/sharedTypes"; +import { type MakeSettingTypesMap, Setting } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions"; + +import { type SeismicSliceData_trans, transformSeismicSlice } from "../utils/transformSeismicSlice"; + +const realizationSeismicSlicesSettings = [ + Setting.ENSEMBLE, + Setting.REALIZATION, + Setting.ATTRIBUTE, + Setting.TIME_OR_INTERVAL, + Setting.SEISMIC_SLICES, + Setting.COLOR_SCALE, + Setting.OPACITY_PERCENT, +] as const; +export type RealizationSeismicSlicesSettings = typeof realizationSeismicSlicesSettings; +type SettingsWithTypes = MakeSettingTypesMap; + +export type RealizationSeismicSlicesData = { + inline: SeismicSliceData_trans; + crossline: SeismicSliceData_trans; + depthSlice: SeismicSliceData_trans; +}; + +export type RealizationSeismicSlicesStoredData = { + seismicCubeMeta: SeismicCubeMeta_api; + seismicSlices: [number, number, number]; +}; + +export class RealizationSeismicSlicesProvider + implements + CustomDataProviderImplementation< + RealizationSeismicSlicesSettings, + RealizationSeismicSlicesData, + RealizationSeismicSlicesStoredData + > +{ + settings = realizationSeismicSlicesSettings; + + getDefaultSettingsValues() { + const defaultColorPalette = + defaultContinuousDivergingColorPalettes.find((elm) => elm.getId() === "red-to-blue") ?? + defaultContinuousDivergingColorPalettes[0]; + + const defaultColorScale = new ColorScale({ + colorPalette: defaultColorPalette, + gradientType: ColorScaleGradientType.Diverging, + type: ColorScaleType.Continuous, + steps: 10, + }); + + return { + [Setting.COLOR_SCALE]: { + colorScale: defaultColorScale, + areBoundariesUserDefined: false, + }, + [Setting.OPACITY_PERCENT]: 100, + }; + } + + getDefaultName(): string { + return "Seismic Slices (realization)"; + } + + doSettingsChangesRequireDataRefetch( + prevSettings: SettingsWithTypes | null, + newSettings: SettingsWithTypes, + ): boolean { + return ( + !prevSettings || + !isEqual(prevSettings[Setting.ENSEMBLE], newSettings[Setting.ENSEMBLE]) || + !isEqual(prevSettings[Setting.REALIZATION], newSettings[Setting.REALIZATION]) || + !isEqual(prevSettings[Setting.ATTRIBUTE], newSettings[Setting.ATTRIBUTE]) || + !isEqual(prevSettings[Setting.TIME_OR_INTERVAL], newSettings[Setting.TIME_OR_INTERVAL]) + ); + } + + areCurrentSettingsValid({ + getStoredData, + getSetting, + }: AreSettingsValidArgs< + RealizationSeismicSlicesSettings, + RealizationSeismicSlicesData, + RealizationSeismicSlicesStoredData + >): boolean { + return ( + getSetting(Setting.ENSEMBLE) !== null && + getSetting(Setting.REALIZATION) !== null && + getSetting(Setting.ATTRIBUTE) !== null && + getSetting(Setting.TIME_OR_INTERVAL) !== null && + getStoredData("seismicSlices") !== null && + getStoredData("seismicCubeMeta") !== null + ); + } + + doStoredDataChangesRequireDataRefetch( + prevStoredData: NullableStoredData | null, + newStoredData: NullableStoredData, + ): boolean { + return !prevStoredData || !isEqual(prevStoredData.seismicSlices, newStoredData.seismicSlices); + } + + makeValueRange( + accessors: DataProviderInformationAccessors< + RealizationSeismicSlicesSettings, + RealizationSeismicSlicesData, + RealizationSeismicSlicesStoredData + >, + ): [number, number] | null { + const data = accessors.getData(); + if (!data) { + return null; + } + + return [ + Math.min(data.inline.value_min, data.crossline.value_min, data.depthSlice.value_min), + Math.max(data.inline.value_max, data.crossline.value_max, data.depthSlice.value_max), + ]; + } + + fetchData({ + getSetting, + getStoredData, + fetchQuery, + }: FetchDataParams< + RealizationSeismicSlicesSettings, + RealizationSeismicSlicesData + >): Promise { + const ensembleIdent = getSetting(Setting.ENSEMBLE); + const realizationNum = getSetting(Setting.REALIZATION); + const attribute = getSetting(Setting.ATTRIBUTE); + const timeOrInterval = getSetting(Setting.TIME_OR_INTERVAL); + const slices = getStoredData("seismicSlices"); + + const queryOptions = getSeismicSlicesOptions({ + query: { + case_uuid: ensembleIdent?.getCaseUuid() ?? "", + ensemble_name: ensembleIdent?.getEnsembleName() ?? "", + realization_num: realizationNum ?? 0, + seismic_attribute: attribute ?? "", + time_or_interval_str: timeOrInterval ?? "", + observed: false, + inline_number: slices?.[0] ?? 0, + crossline_number: slices?.[1] ?? 0, + depth_slice_number: slices?.[2] ?? 0, + }, + }); + + return fetchQuery({ + ...queryOptions, + }).then((data) => ({ + inline: transformSeismicSlice(data[0]), + crossline: transformSeismicSlice(data[1]), + depthSlice: transformSeismicSlice(data[2]), + })); + } + + defineDependencies({ + helperDependency, + availableSettingsUpdater, + storedDataUpdater, + queryClient, + }: DefineDependenciesArgs): void { + availableSettingsUpdater(Setting.ENSEMBLE, ({ getGlobalSetting }) => { + const fieldIdentifier = getGlobalSetting("fieldId"); + const ensembles = getGlobalSetting("ensembles"); + + const ensembleIdents = ensembles + .filter((ensemble) => ensemble.getFieldIdentifier() === fieldIdentifier) + .map((ensemble) => ensemble.getIdent()); + + return ensembleIdents; + }); + + availableSettingsUpdater(Setting.REALIZATION, ({ getLocalSetting, getGlobalSetting }) => { + const ensembleIdent = getLocalSetting(Setting.ENSEMBLE); + const realizationFilterFunc = getGlobalSetting("realizationFilterFunction"); + + if (!ensembleIdent) { + return []; + } + + const realizations = realizationFilterFunc(ensembleIdent); + + return [...realizations]; + }); + + const realizationSeismicCrosslineDataDep = helperDependency(async ({ getLocalSetting, abortSignal }) => { + const ensembleIdent = getLocalSetting(Setting.ENSEMBLE); + const realization = getLocalSetting(Setting.REALIZATION); + + if (!ensembleIdent || realization === null) { + return null; + } + + return await queryClient.fetchQuery({ + ...getSeismicCubeMetaListOptions({ + query: { + case_uuid: ensembleIdent.getCaseUuid(), + ensemble_name: ensembleIdent.getEnsembleName(), + }, + signal: abortSignal, + }), + }); + }); + + storedDataUpdater("seismicCubeMeta", ({ getHelperDependency, getLocalSetting }) => { + const data = getHelperDependency(realizationSeismicCrosslineDataDep); + const attribute = getLocalSetting(Setting.ATTRIBUTE); + const timeOrInterval = getLocalSetting(Setting.TIME_OR_INTERVAL); + + if (!data || !attribute || !timeOrInterval) { + return null; + } + + return ( + data.find( + (seismicCubeMeta) => + seismicCubeMeta.seismicAttribute === attribute && + seismicCubeMeta.isoDateOrInterval === timeOrInterval, + ) ?? null + ); + }); + + availableSettingsUpdater(Setting.ATTRIBUTE, ({ getHelperDependency }) => { + const data = getHelperDependency(realizationSeismicCrosslineDataDep); + + if (!data) { + return []; + } + + const availableSeismicAttributes = Array.from( + new Set(data.filter((el) => el.isDepth).map((el) => el.seismicAttribute)), + ).sort(); + + return availableSeismicAttributes; + }); + + availableSettingsUpdater(Setting.TIME_OR_INTERVAL, ({ getLocalSetting, getHelperDependency }) => { + const seismicAttribute = getLocalSetting(Setting.ATTRIBUTE); + + const data = getHelperDependency(realizationSeismicCrosslineDataDep); + + if (!seismicAttribute || !data) { + return []; + } + + const availableTimeOrIntervals = [ + ...Array.from( + new Set( + data + .filter((surface) => surface.seismicAttribute === seismicAttribute) + .map((el) => el.isoDateOrInterval), + ), + ), + ]; + + return availableTimeOrIntervals; + }); + + availableSettingsUpdater(Setting.SEISMIC_SLICES, ({ getLocalSetting, getHelperDependency }) => { + const seismicAttribute = getLocalSetting(Setting.ATTRIBUTE); + const timeOrInterval = getLocalSetting(Setting.TIME_OR_INTERVAL); + const data = getHelperDependency(realizationSeismicCrosslineDataDep); + + if (!seismicAttribute || !timeOrInterval || !data) { + return [ + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + ]; + } + const seismicInfo = data.filter( + (seismicInfos) => + seismicInfos.seismicAttribute === seismicAttribute && + seismicInfos.isoDateOrInterval === timeOrInterval, + )[0]; + + const xMin = 0; + const xMax = seismicInfo.spec.numCols - 1; + const xInc = 1; + + const yMin = 0; + const yMax = seismicInfo.spec.numRows - 1; + const yInc = 1; + + const zMin = seismicInfo.spec.zOrigin; + const zMax = + seismicInfo.spec.zOrigin + + seismicInfo.spec.zInc * seismicInfo.spec.zFlip * (seismicInfo.spec.numLayers - 1); + const zInc = seismicInfo.spec.zInc; + + return [ + [xMin, xMax, xInc], + [yMin, yMax, yInc], + [zMin, zMax, zInc], + ]; + }); + + storedDataUpdater("seismicSlices", ({ getLocalSetting }) => { + const slices = getLocalSetting(Setting.SEISMIC_SLICES); + + if (!slices) { + return null; + } + + if (slices.applied) { + return slices.value; + } + + return NO_UPDATE; + }); + } +} diff --git a/frontend/src/modules/3DViewer/DataProviderFramework/customDataProviderTypes.ts b/frontend/src/modules/3DViewer/DataProviderFramework/customDataProviderTypes.ts new file mode 100644 index 0000000000..d0c67d4f92 --- /dev/null +++ b/frontend/src/modules/3DViewer/DataProviderFramework/customDataProviderTypes.ts @@ -0,0 +1,5 @@ +export enum CustomDataProviderType { + REALIZATION_SEISMIC_SLICES = "REALIZATION_SEISMIC_SLICES", + REALIZATION_GRID_3D = "REALIZATION_GRID_3D", + POLYLINES = "POLYLINES", +} diff --git a/frontend/src/modules/3DViewer/DataProviderFramework/registerAllDataProviders.ts b/frontend/src/modules/3DViewer/DataProviderFramework/registerAllDataProviders.ts new file mode 100644 index 0000000000..0a91de118f --- /dev/null +++ b/frontend/src/modules/3DViewer/DataProviderFramework/registerAllDataProviders.ts @@ -0,0 +1,11 @@ +import { DataProviderRegistry } from "@modules/_shared/DataProviderFramework/dataProviders/DataProviderRegistry"; + +import { RealizationGridProvider } from "./customDataProviderImplementations/RealizationGridProvider"; +import { RealizationSeismicSlicesProvider } from "./customDataProviderImplementations/RealizationSeismicSlicesProvider"; +import { CustomDataProviderType } from "./customDataProviderTypes"; + +DataProviderRegistry.registerDataProvider( + CustomDataProviderType.REALIZATION_SEISMIC_SLICES, + RealizationSeismicSlicesProvider, +); +DataProviderRegistry.registerDataProvider(CustomDataProviderType.REALIZATION_GRID_3D, RealizationGridProvider); diff --git a/frontend/src/modules/3DViewer/DataProviderFramework/utils/transformSeismicSlice.ts b/frontend/src/modules/3DViewer/DataProviderFramework/utils/transformSeismicSlice.ts new file mode 100644 index 0000000000..6670d69413 --- /dev/null +++ b/frontend/src/modules/3DViewer/DataProviderFramework/utils/transformSeismicSlice.ts @@ -0,0 +1,20 @@ +import type { SeismicSliceData_api } from "@api"; +import { b64DecodeFloatArrayToFloat32 } from "@modules/_shared/base64"; + +export type SeismicSliceData_trans = Omit & { + dataFloat32Arr: Float32Array; +}; + +export function transformSeismicSlice(apiData: SeismicSliceData_api): SeismicSliceData_trans { + const startTS = performance.now(); + + const { slice_traces_b64arr, ...untransformedData } = apiData; + const dataFloat32Arr = b64DecodeFloatArrayToFloat32(slice_traces_b64arr); + + console.debug(`transformSeismicSlice() took: ${(performance.now() - startTS).toFixed(1)}ms`); + + return { + ...untransformedData, + dataFloat32Arr: dataFloat32Arr, + }; +} diff --git a/frontend/src/modules/3DViewer/DataProviderFramework/visualization/makeDrilledWellTrajectoriesHoverVisualizationFunctions.ts b/frontend/src/modules/3DViewer/DataProviderFramework/visualization/makeDrilledWellTrajectoriesHoverVisualizationFunctions.ts new file mode 100644 index 0000000000..6cadb8bbbd --- /dev/null +++ b/frontend/src/modules/3DViewer/DataProviderFramework/visualization/makeDrilledWellTrajectoriesHoverVisualizationFunctions.ts @@ -0,0 +1,108 @@ +import { WellsLayer } from "@webviz/subsurface-viewer/dist/layers"; + +import type { WellboreTrajectory_api } from "@api"; +import type { GlobalTopicDefinitions } from "@framework/WorkbenchServices"; +import { BiconeLayer } from "@modules/3DViewer/customDeckGlLayers/BiconeLayer"; +import type { GeoWellFeature } from "@modules/_shared/DataProviderFramework/visualization/deckgl/makeDrilledWellTrajectoriesLayer"; +import type { + HoverVisualizationFunctions, + TransformerArgs, + VisualizationTarget, +} from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; +import { wellTrajectoryToGeojson } from "@modules/_shared/utils/wellbore"; + +export function makeDrilledWellTrajectoriesHoverVisualizationFunctions( + args: TransformerArgs, +): HoverVisualizationFunctions { + const { id, getData } = args; + + const wellboreTrajectories = getData(); + + if (!wellboreTrajectories) { + return {}; + } + + return { + "global.hoverMd": (hoveredMd: GlobalTopicDefinitions["global.hoverMd"] | null) => { + const wellboreTrajectory = wellboreTrajectories.find( + (wellTrajectory) => wellTrajectory.wellboreUuid === hoveredMd?.wellboreUuid, + ); + + let hoveredMdPoint3d: [number, number, number] = [0, 0, 0]; + let normal: [number, number, number] = [0, 0, 1]; + const wellLayerDataFeatures: GeoWellFeature[] = []; + + const visible = hoveredMd !== null && wellboreTrajectory !== undefined; + + if (visible) { + for (const [index, point] of wellboreTrajectory.mdArr.entries()) { + if (point >= hoveredMd.md) { + // Interpolate the coordinates + const prevPoint = wellboreTrajectory.mdArr[index - 1]; + const thisPoint = wellboreTrajectory.mdArr[index]; + + const prevX = wellboreTrajectory.eastingArr[index - 1]; + const prevY = wellboreTrajectory.northingArr[index - 1]; + const prevZ = wellboreTrajectory.tvdMslArr[index - 1]; + const thisX = wellboreTrajectory.eastingArr[index]; + const thisY = wellboreTrajectory.northingArr[index]; + const thisZ = wellboreTrajectory.tvdMslArr[index]; + + const ratio = (hoveredMd.md - prevPoint) / (thisPoint - prevPoint); + const x = prevX + ratio * (thisX - prevX); + const y = prevY + ratio * (thisY - prevY); + const z = prevZ + ratio * (thisZ - prevZ); + hoveredMdPoint3d = [x, y, -z]; + + const dx = thisX - prevX; + const dy = thisY - prevY; + const dz = thisZ - prevZ; + + const length = Math.sqrt(dx * dx + dy * dy + dz * dz); + + normal = length === 0 ? [0, 0, 1] : [dx / length, dy / length, -dz / length]; + + break; + } + } + + wellLayerDataFeatures.push(wellTrajectoryToGeojson(wellboreTrajectory)); + } + + return [ + new WellsLayer({ + id: `${id}-hovered-well`, + data: { + type: "FeatureCollection", + features: wellLayerDataFeatures, + }, + refine: false, + lineStyle: { width: 3, color: [255, 0, 0] }, + wellHeadStyle: { + size: 0, + }, + pickable: false, + wellNameVisible: false, + ZIncreasingDownwards: true, + visible: visible, + depthTest: false, + }), + new BiconeLayer({ + id: `${id}-hovered-md-point`, + centerPoint: hoveredMdPoint3d, + radius: 20, + height: 10, + normalVector: normal, + numberOfSegments: 32, + color: [255, 0, 0], + opacity: 1, + visible: visible, + sizeUnits: "pixels", + minSizeInMeters: 0, + maxSizeInMeters: 200, + depthTest: false, + }), + ]; + }, + }; +} diff --git a/frontend/src/modules/3DViewer/DataProviderFramework/visualization/makeDrilledWellTrajectoriesLayer.ts b/frontend/src/modules/3DViewer/DataProviderFramework/visualization/makeDrilledWellTrajectoriesLayer.ts new file mode 100644 index 0000000000..92c6d7eaaf --- /dev/null +++ b/frontend/src/modules/3DViewer/DataProviderFramework/visualization/makeDrilledWellTrajectoriesLayer.ts @@ -0,0 +1,69 @@ +import type { Feature } from "geojson"; + +import type { WellboreTrajectory_api } from "@api"; +import { AdjustedWellsLayer } from "@modules/_shared/customDeckGlLayers/AdjustedWellsLayer"; +import { makeDrilledWellTrajectoriesBoundingBox } from "@modules/_shared/DataProviderFramework/visualization/deckgl/boundingBoxes/makeDrilledWellTrajectoriesBoundingBox"; +import type { TransformerArgs } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; +import { wellTrajectoryToGeojson } from "@modules/_shared/utils/wellbore"; + +export function makeDrilledWellTrajectoriesLayer( + args: TransformerArgs, +): AdjustedWellsLayer | null { + const { id, getData, name } = args; + + const fieldWellboreTrajectoriesData = getData(); + + if (!fieldWellboreTrajectoriesData) { + return null; + } + + const tempWorkingWellsData = fieldWellboreTrajectoriesData.filter( + (el) => el.uniqueWellboreIdentifier !== "NO 34/4-K-3 AH", + ); + + const wellLayerDataFeatures = tempWorkingWellsData.map((well) => wellTrajectoryToGeojson(well)); + + function getLineStyleWidth(object: Feature): number { + if (object.properties && "lineWidth" in object.properties) { + return object.properties.lineWidth as number; + } + return 2; + } + + function getWellHeadStyleWidth(object: Feature): number { + if (object.properties && "wellHeadSize" in object.properties) { + return object.properties.wellHeadSize as number; + } + return 1; + } + + function getColor(object: Feature): [number, number, number, number] { + if (object.properties && "color" in object.properties) { + return object.properties.color as [number, number, number, number]; + } + return [50, 50, 50, 100]; + } + + const boundingBox = makeDrilledWellTrajectoriesBoundingBox(args); + + if (!boundingBox) { + return null; + } + + const wellsLayer = new AdjustedWellsLayer({ + id, + data: { + type: "FeatureCollection", + features: wellLayerDataFeatures, + }, + name, + refine: false, + lineStyle: { width: getLineStyleWidth, color: getColor }, + wellHeadStyle: { size: getWellHeadStyleWidth, color: getColor }, + pickable: true, + wellNameVisible: true, + ZIncreasingDownwards: true, + }); + + return wellsLayer; +} diff --git a/frontend/src/modules/3DViewer/DataProviderFramework/visualization/makeIntersectionRealizationGridLayer.ts b/frontend/src/modules/3DViewer/DataProviderFramework/visualization/makeIntersectionRealizationGridLayer.ts new file mode 100644 index 0000000000..fb97ce68dd --- /dev/null +++ b/frontend/src/modules/3DViewer/DataProviderFramework/visualization/makeIntersectionRealizationGridLayer.ts @@ -0,0 +1,120 @@ +import { TGrid3DColoringMode } from "@webviz/subsurface-viewer"; +import { Grid3DLayer } from "@webviz/subsurface-viewer/dist/layers"; + +import type { IntersectionRealizationGridSettings } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/IntersectionRealizationGridProvider"; +import { Setting } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions"; +import { makeColorMapFunctionFromColorScale } from "@modules/_shared/DataProviderFramework/visualization/utils/colors"; +import type { TransformerArgs } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; +import type { + FenceMeshSection_trans, + PolylineIntersection_trans, +} from "@modules/_shared/Intersection/gridIntersectionTransform"; + +interface PolyDataVtk { + points: Float32Array; + polys: Uint32Array; + props: Float32Array; +} + +function buildVtkStylePolyDataFromFenceSections(fenceSections: FenceMeshSection_trans[]): PolyDataVtk { + // Calculate sizes of typed arrays + let totNumVertices = 0; + let totNumPolygons = 0; + let totNumConnectivities = 0; + for (const section of fenceSections) { + totNumVertices += section.verticesUzFloat32Arr.length / 2; + totNumPolygons += section.verticesPerPolyUintArr.length; + totNumConnectivities += section.polyIndicesUintArr.length; + } + + const pointsFloat32Arr = new Float32Array(3 * totNumVertices); + const polysUint32Arr = new Uint32Array(totNumPolygons + totNumConnectivities); + const polyPropsFloat32Arr = new Float32Array(totNumPolygons); + + let floatPointsDstIdx = 0; + let polysDstIdx = 0; + let propsDstIdx = 0; + for (const section of fenceSections) { + // uv to xyz + const directionX = section.end_utm_x - section.start_utm_x; + const directionY = section.end_utm_y - section.start_utm_y; + const magnitude = Math.sqrt(directionX ** 2 + directionY ** 2); + const unitDirectionX = directionX / magnitude; + const unitDirectionY = directionY / magnitude; + + const connOffset = floatPointsDstIdx / 3; + + for (let i = 0; i < section.verticesUzFloat32Arr.length; i += 2) { + const u = section.verticesUzFloat32Arr[i]; + const z = section.verticesUzFloat32Arr[i + 1]; + const x = u * unitDirectionX + section.start_utm_x; + const y = u * unitDirectionY + section.start_utm_y; + + pointsFloat32Arr[floatPointsDstIdx++] = x; + pointsFloat32Arr[floatPointsDstIdx++] = y; + pointsFloat32Arr[floatPointsDstIdx++] = z; + } + + // Fix poly indexes for each section + const numPolysInSection = section.verticesPerPolyUintArr.length; + let srcIdx = 0; + for (let i = 0; i < numPolysInSection; i++) { + const numVertsInPoly = section.verticesPerPolyUintArr[i]; + polysUint32Arr[polysDstIdx++] = numVertsInPoly; + + for (let j = 0; j < numVertsInPoly; j++) { + polysUint32Arr[polysDstIdx++] = section.polyIndicesUintArr[srcIdx++] + connOffset; + } + } + + polyPropsFloat32Arr.set(section.polyPropsFloat32Arr, propsDstIdx); + propsDstIdx += numPolysInSection; + } + + return { + points: pointsFloat32Arr, + polys: polysUint32Arr, + props: polyPropsFloat32Arr, + }; +} + +export function makeIntersectionRealizationGridLayer({ + id, + name, + getData, + getSetting, +}: TransformerArgs): Grid3DLayer | null { + const data = getData(); + const colorScaleSpec = getSetting(Setting.COLOR_SCALE); + const showGridLines = getSetting(Setting.SHOW_GRID_LINES); + const opacity = getSetting(Setting.OPACITY_PERCENT) ?? 100; + + if (!data) { + return null; + } + const polyData = buildVtkStylePolyDataFromFenceSections(data.fenceMeshSections); + + const grid3dIntersectionLayer = new Grid3DLayer({ + id, + name, + pointsData: polyData.points, + polysData: polyData.polys, + propertiesData: polyData.props, + colorMapName: "Continuous", + colorMapRange: [data.min_grid_prop_value, data.max_grid_prop_value], + colorMapClampColor: true, + coloringMode: TGrid3DColoringMode.Property, + colorMapFunction: makeColorMapFunctionFromColorScale(colorScaleSpec, { + valueMin: data.min_grid_prop_value, + valueMax: data.max_grid_prop_value, + denormalize: true, + }), + ZIncreasingDownwards: false, + gridLines: showGridLines ?? false, + material: { ambient: 0.4, diffuse: 0.7, shininess: 8, specularColor: [25, 25, 25] }, + pickable: true, + opacity: opacity / 100, + }); + + return grid3dIntersectionLayer; +} diff --git a/frontend/src/modules/3DViewer/DataProviderFramework/visualization/makeRealizationSurfaceLayer.ts b/frontend/src/modules/3DViewer/DataProviderFramework/visualization/makeRealizationSurfaceLayer.ts new file mode 100644 index 0000000000..b36c7a1e52 --- /dev/null +++ b/frontend/src/modules/3DViewer/DataProviderFramework/visualization/makeRealizationSurfaceLayer.ts @@ -0,0 +1,66 @@ +import { MapLayer } from "@webviz/subsurface-viewer/dist/layers"; + +import { + type RealizationSurfaceData, + type RealizationSurfaceSettings, + SurfaceDataFormat, +} from "@modules/_shared/DataProviderFramework/dataProviders/implementations/RealizationSurfaceProvider"; +import { Setting } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions"; +import { makeColorMapFunctionFromColorScale } from "@modules/_shared/DataProviderFramework/visualization/utils/colors"; +import type { TransformerArgs } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; + +export function makeRealizationSurfaceLayer({ + id, + name, + getData, + getSetting, +}: TransformerArgs): MapLayer | null { + const data = getData(); + const colorScaleSpec = getSetting(Setting.COLOR_SCALE); + + if (!data) { + return null; + } + + if (data.format === SurfaceDataFormat.FLOAT) { + return new MapLayer({ + id, + name, + meshData: data.surfaceData.valuesFloat32Arr, + frame: { + origin: [data.surfaceData.surface_def.origin_utm_x, data.surfaceData.surface_def.origin_utm_y], + count: [data.surfaceData.surface_def.npoints_x, data.surfaceData.surface_def.npoints_y], + increment: [data.surfaceData.surface_def.inc_x, data.surfaceData.surface_def.inc_y], + rotDeg: data.surfaceData.surface_def.rot_deg, + }, + valueRange: [data.surfaceData.value_min, data.surfaceData.value_max], + colorMapRange: [data.surfaceData.value_min, data.surfaceData.value_max], + colorMapFunction: makeColorMapFunctionFromColorScale(colorScaleSpec, { + valueMin: data.surfaceData.value_min, + valueMax: data.surfaceData.value_max, + denormalize: true, + }), + gridLines: false, + }); + } + + return new MapLayer({ + id, + name, + meshData: data.surfaceData.png_image_base64, + frame: { + origin: [data.surfaceData.surface_def.origin_utm_x, data.surfaceData.surface_def.origin_utm_y], + count: [data.surfaceData.surface_def.npoints_x, data.surfaceData.surface_def.npoints_y], + increment: [data.surfaceData.surface_def.inc_x, data.surfaceData.surface_def.inc_y], + rotDeg: data.surfaceData.surface_def.rot_deg, + }, + valueRange: [data.surfaceData.value_min, data.surfaceData.value_max], + colorMapRange: [data.surfaceData.value_min, data.surfaceData.value_max], + colorMapFunction: makeColorMapFunctionFromColorScale(colorScaleSpec, { + valueMin: data.surfaceData.value_min, + valueMax: data.surfaceData.value_max, + denormalize: true, + }), + gridLines: false, + }); +} diff --git a/frontend/src/modules/3DViewer/DataProviderFramework/visualization/makeSeismicIntersectionMeshLayer.ts b/frontend/src/modules/3DViewer/DataProviderFramework/visualization/makeSeismicIntersectionMeshLayer.ts new file mode 100644 index 0000000000..f6b5b088b0 --- /dev/null +++ b/frontend/src/modules/3DViewer/DataProviderFramework/visualization/makeSeismicIntersectionMeshLayer.ts @@ -0,0 +1,76 @@ +import type { Layer } from "@deck.gl/core"; + +import { + SeismicFenceMeshLayer, + type SeismicFence, +} from "@modules/3DViewer/customDeckGlLayers/SeismicFenceMeshLayer/SeismicFenceMeshLayer"; +import type { + IntersectionRealizationSeismicData, + IntersectionRealizationSeismicSettings, + IntersectionRealizationSeismicStoredData, +} from "@modules/_shared/DataProviderFramework/dataProviders/implementations/IntersectionRealizationSeismicProvider"; +import { Setting } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions"; +import { makeColorMapFunctionFromColorScale } from "@modules/_shared/DataProviderFramework/visualization/utils/colors"; +import type { TransformerArgs } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; + +function makeTraceXYZPointsArrayFromPolyline(polylineUtmXy: number[], z: number): Float32Array { + if (polylineUtmXy.length % 2 !== 0) { + throw new Error("Polyline UTM XY coordinates must be in pairs (x, y)."); + } + const traceXYZPointsArray = new Float32Array((polylineUtmXy.length / 2) * 3); + for (let i = 0; i < polylineUtmXy.length; i += 2) { + const index = (i / 2) * 3; + traceXYZPointsArray[index] = polylineUtmXy[i]; // x + traceXYZPointsArray[index + 1] = polylineUtmXy[i + 1]; // y + traceXYZPointsArray[index + 2] = z; // z, set to 0 as we don't have depth info here + } + return traceXYZPointsArray; +} + +export function makeSeismicIntersectionMeshLayer( + args: TransformerArgs< + IntersectionRealizationSeismicSettings, + IntersectionRealizationSeismicData, + IntersectionRealizationSeismicStoredData + >, +): Layer | null { + const { id, name, getData, getSetting, getStoredData, getValueRange } = args; + const fenceData = getData(); + const colorScaleSpec = getSetting(Setting.COLOR_SCALE); + const opacityPercent = (getSetting(Setting.OPACITY_PERCENT) ?? 100) / 100; + const valueRange = getValueRange(); + const polyline = getStoredData("seismicFencePolylineWithSectionLengths"); + + if (!fenceData || !polyline) { + return null; + } + + // Ensure consistency between fetched data and requested polyline + if (fenceData.num_traces !== polyline.polylineUtmXy.length / 2) { + throw new Error( + `Number of traces (${fenceData.num_traces}) does not match number of polyline points (${polyline.polylineUtmXy.length / 2}) for requested polyline`, + ); + } + + const fence: SeismicFence = { + traceXYZPointsArray: new Float32Array( + makeTraceXYZPointsArrayFromPolyline(polyline.polylineUtmXy, fenceData.min_fence_depth), + ), + vVector: [0, 0, fenceData.max_fence_depth - fenceData.min_fence_depth], + numSamples: fenceData.num_samples_per_trace, + properties: fenceData.fenceTracesFloat32Arr, + }; + + return new SeismicFenceMeshLayer({ + id, + name, + data: fence, + colorMapFunction: makeColorMapFunctionFromColorScale(colorScaleSpec, { + valueMin: valueRange?.[0] ?? 0, + valueMax: valueRange?.[1] ?? 0, + midPoint: 0, + }), + zIncreaseDownwards: true, + opacity: opacityPercent, + }); +} diff --git a/frontend/src/modules/3DViewer/DataProviderFramework/visualization/makeSeismicSlicesLayer.ts b/frontend/src/modules/3DViewer/DataProviderFramework/visualization/makeSeismicSlicesLayer.ts new file mode 100644 index 0000000000..ad21408363 --- /dev/null +++ b/frontend/src/modules/3DViewer/DataProviderFramework/visualization/makeSeismicSlicesLayer.ts @@ -0,0 +1,335 @@ +import type { Layer } from "@deck.gl/core"; + +import type { SeismicCubeMeta_api } from "@api"; +import * as bbox from "@lib/utils/bbox"; +import { degreesToRadians, ShapeType, type Geometry } from "@lib/utils/geometry"; +import { rotatePoint2Around } from "@lib/utils/vec2"; +import * as vec3 from "@lib/utils/vec3"; +import { SeismicSlicesLayer, type SeismicFenceWithId } from "@modules/3DViewer/customDeckGlLayers/SeismicSlicesLayer"; +import { Setting } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions"; +import { makeColorMapFunctionFromColorScale } from "@modules/_shared/DataProviderFramework/visualization/utils/colors"; +import type { TransformerArgs } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; + +import type { + RealizationSeismicSlicesData, + RealizationSeismicSlicesSettings, + RealizationSeismicSlicesStoredData, +} from "../customDataProviderImplementations/RealizationSeismicSlicesProvider"; + +function predictDepthSliceGeometry( + seismicCubeMeta: SeismicCubeMeta_api, + seismicDepthSliceNumber: number | null, +): Geometry | null { + if (!seismicCubeMeta || seismicDepthSliceNumber === null) { + return null; + } + + const xmin = seismicCubeMeta.spec.xOrigin; + const xmax = seismicCubeMeta.spec.xOrigin + seismicCubeMeta.spec.xInc * (seismicCubeMeta.spec.numCols - 1); + + const ymin = seismicCubeMeta.spec.yOrigin; + const ymax = + seismicCubeMeta.spec.yOrigin + + seismicCubeMeta.spec.yInc * seismicCubeMeta.spec.yFlip * (seismicCubeMeta.spec.numRows - 1); + + const zmin = seismicDepthSliceNumber; + const zmax = zmin; + + const maxXY = { x: xmax, y: ymax }; + const maxX = { x: xmax, y: ymin }; + const maxY = { x: xmin, y: ymax }; + const origin = { x: seismicCubeMeta.spec.xOrigin, y: seismicCubeMeta.spec.yOrigin }; + + const rotatedMaxXY = rotatePoint2Around(maxXY, origin, degreesToRadians(seismicCubeMeta.spec.rotationDeg)); + const rotatedMaxX = rotatePoint2Around(maxX, origin, degreesToRadians(seismicCubeMeta.spec.rotationDeg)); + const rotatedMaxY = rotatePoint2Around(maxY, origin, degreesToRadians(seismicCubeMeta.spec.rotationDeg)); + + const boundingBox = bbox.create( + vec3.create(Math.min(origin.x, rotatedMaxXY.x), Math.min(origin.y, rotatedMaxXY.y), zmin), + vec3.create(Math.max(origin.x, rotatedMaxXY.x), Math.max(origin.y, rotatedMaxXY.y), zmax), + ); + + const vecU = vec3.create(rotatedMaxX.x - origin.x, rotatedMaxX.y - origin.y, 0); + const vecV = vec3.create(rotatedMaxY.x - origin.x, rotatedMaxY.y - origin.y, 0); + + const width = vec3.length(vecU); + const height = vec3.length(vecV); + + const geometry: Geometry = { + shapes: [ + { + type: ShapeType.BOX, + centerPoint: vec3.create((origin.x + rotatedMaxXY.x) / 2, (origin.y + rotatedMaxXY.y) / 2, zmin), + dimensions: { + width, + height, + depth: 0, + }, + normalizedEdgeVectors: { + u: vec3.normalize(vec3.create(rotatedMaxX.x - origin.x, rotatedMaxX.y - origin.y, 0)), + v: vec3.normalize(vec3.create(rotatedMaxY.x - origin.x, rotatedMaxY.y - origin.y, 0)), + }, + }, + ], + boundingBox, + }; + + return geometry; +} + +function predictCrosslineGeometry( + seismicCubeMeta: SeismicCubeMeta_api, + seismicCrosslineNumber: number | null, +): Geometry | null { + if (!seismicCubeMeta || seismicCrosslineNumber === null) { + return null; + } + + const xmin = seismicCubeMeta.spec.xOrigin; + const xmax = seismicCubeMeta.spec.xOrigin + seismicCubeMeta.spec.xInc * (seismicCubeMeta.spec.numCols - 1); + + const ymin = + seismicCubeMeta.spec.yOrigin + seismicCubeMeta.spec.yInc * seismicCubeMeta.spec.yFlip * seismicCrosslineNumber; + const ymax = ymin; + + const zmin = seismicCubeMeta.spec.zOrigin; + const zmax = + seismicCubeMeta.spec.zOrigin + + seismicCubeMeta.spec.zInc * seismicCubeMeta.spec.zFlip * (seismicCubeMeta.spec.numLayers - 1); + + const maxXY = { x: xmax, y: ymax }; + const minXY = { x: xmin, y: ymin }; + const origin = { x: seismicCubeMeta.spec.xOrigin, y: seismicCubeMeta.spec.yOrigin }; + + const rotatedMinXY = rotatePoint2Around(minXY, origin, degreesToRadians(seismicCubeMeta.spec.rotationDeg)); + const rotatedMaxXY = rotatePoint2Around(maxXY, origin, degreesToRadians(seismicCubeMeta.spec.rotationDeg)); + + const geometry: Geometry = { + shapes: [ + { + type: ShapeType.BOX, + centerPoint: vec3.create( + (rotatedMinXY.x + rotatedMaxXY.x) / 2, + (rotatedMinXY.y + rotatedMaxXY.y) / 2, + (zmin + zmax) / 2, + ), + dimensions: { + width: vec3.length( + vec3.create(rotatedMaxXY.x - rotatedMinXY.x, rotatedMaxXY.y - rotatedMinXY.y, 0), + ), + height: Math.abs(zmax - zmin), + depth: 0, + }, + normalizedEdgeVectors: { + u: vec3.normalize(vec3.create(rotatedMaxXY.x - rotatedMinXY.x, rotatedMaxXY.y - rotatedMinXY.y, 0)), + v: vec3.create(0, 0, 1), + }, + }, + ], + boundingBox: bbox.create( + vec3.create(Math.min(rotatedMinXY.x, rotatedMaxXY.x), Math.min(rotatedMinXY.y, rotatedMaxXY.y), zmin), + vec3.create(Math.max(rotatedMinXY.x, rotatedMaxXY.x), Math.max(rotatedMinXY.y, rotatedMaxXY.y), zmax), + ), + }; + + return geometry; +} + +function predictInlineGeometry( + seismicCubeMeta: SeismicCubeMeta_api, + seismicInlineNumber: number | null, +): Geometry | null { + if (!seismicCubeMeta || seismicInlineNumber === null) { + return null; + } + + const xmin = seismicCubeMeta.spec.xOrigin + seismicCubeMeta.spec.yInc * seismicInlineNumber; + const xmax = xmin; + + const ymin = seismicCubeMeta.spec.yOrigin; + const ymax = + seismicCubeMeta.spec.yOrigin + + seismicCubeMeta.spec.yInc * seismicCubeMeta.spec.yFlip * (seismicCubeMeta.spec.numRows - 1); + + const zmin = seismicCubeMeta.spec.zOrigin; + const zmax = + seismicCubeMeta.spec.zOrigin + + seismicCubeMeta.spec.zInc * seismicCubeMeta.spec.zFlip * (seismicCubeMeta.spec.numLayers - 1); + + const maxXY = { x: xmax, y: ymax }; + const minXY = { x: xmin, y: ymin }; + const origin = { x: seismicCubeMeta.spec.xOrigin, y: seismicCubeMeta.spec.yOrigin }; + + const rotatedMinXY = rotatePoint2Around(minXY, origin, (seismicCubeMeta.spec.rotationDeg / 180.0) * Math.PI); + const rotatedMaxXY = rotatePoint2Around(maxXY, origin, (seismicCubeMeta.spec.rotationDeg / 180.0) * Math.PI); + + const geometry: Geometry = { + shapes: [ + { + type: ShapeType.BOX, + centerPoint: vec3.create( + (rotatedMinXY.x + rotatedMaxXY.x) / 2, + (rotatedMinXY.y + rotatedMaxXY.y) / 2, + (zmin + zmax) / 2, + ), + dimensions: { + width: vec3.length( + vec3.create(rotatedMaxXY.x - rotatedMinXY.x, rotatedMaxXY.y - rotatedMinXY.y, 0), + ), + height: Math.abs(zmax - zmin), + depth: 0, + }, + normalizedEdgeVectors: { + u: vec3.normalize(vec3.create(rotatedMaxXY.x - rotatedMinXY.x, rotatedMaxXY.y - rotatedMinXY.y, 0)), + v: vec3.create(0, 0, 1), + }, + }, + ], + boundingBox: bbox.create( + vec3.create(Math.min(rotatedMinXY.x, rotatedMaxXY.x), Math.min(rotatedMinXY.y, rotatedMaxXY.y), zmin), + vec3.create(Math.max(rotatedMinXY.x, rotatedMaxXY.x), Math.max(rotatedMinXY.y, rotatedMaxXY.y), zmax), + ), + }; + + return geometry; +} + +function interpolateTrace( + start: [number, number, number], + end: [number, number, number], + numTraces: number, +): Float32Array { + const result = new Float32Array(numTraces * 3); + for (let i = 0; i < numTraces; i++) { + const t = i / (numTraces - 1); + result[i * 3] = start[0] + t * (end[0] - start[0]); + result[i * 3 + 1] = start[1] + t * (end[1] - start[1]); + result[i * 3 + 2] = start[2] + t * (end[2] - start[2]); + } + return result; +} + +export function makeSeismicSlicesLayer( + args: TransformerArgs< + RealizationSeismicSlicesSettings, + RealizationSeismicSlicesData, + RealizationSeismicSlicesStoredData + >, +): Layer | null { + const { id, name, getData, getSetting, getStoredData, isLoading, getValueRange } = args; + const data = getData(); + const colorScaleSpec = getSetting(Setting.COLOR_SCALE); + const slicesSettings = getSetting(Setting.SEISMIC_SLICES); + const slices = getStoredData("seismicSlices"); + const seismicCubeMeta = getStoredData("seismicCubeMeta"); + const opacityPercent = getSetting(Setting.OPACITY_PERCENT) ?? 100; + const valueRange = getValueRange(); + + if (!seismicCubeMeta || !slicesSettings) { + return null; + } + + const previewOrLoading = !slicesSettings.applied || isLoading; + + const sections: SeismicFenceWithId[] = []; + + // Inline slice + if (slicesSettings.visible[0]) { + const inlinePreviewGeometry = predictInlineGeometry(seismicCubeMeta, slicesSettings.value[0]); + + if (slices && data) { + sections.push({ + id: "inline-slice", + fence: { + traceXYZPointsArray: interpolateTrace( + [data.inline.bbox_utm[0][0], data.inline.bbox_utm[0][1], data.inline.u_min], + [data.inline.bbox_utm[1][0], data.inline.bbox_utm[1][1], data.inline.u_min], + data.inline.v_num_samples, + ), + vVector: [0, 0, data.inline.u_max - data.inline.u_min], + numSamples: data.inline.u_num_samples, + properties: data.inline.dataFloat32Arr, + }, + loadingGeometry: inlinePreviewGeometry ?? undefined, + }); + } else if (inlinePreviewGeometry) { + sections.push({ + id: "inline-slice", + loadingGeometry: inlinePreviewGeometry, + }); + } + } + + // Crossline slice + if (slicesSettings.visible[1]) { + const crosslinePreviewGeometry = predictCrosslineGeometry(seismicCubeMeta, slicesSettings.value[1]); + + if (slices && data) { + sections.push({ + id: "crossline-slice", + fence: { + traceXYZPointsArray: interpolateTrace( + [data.crossline.bbox_utm[0][0], data.crossline.bbox_utm[0][1], data.crossline.u_min], + [data.crossline.bbox_utm[1][0], data.crossline.bbox_utm[1][1], data.crossline.u_min], + data.crossline.v_num_samples, + ), + vVector: [0, 0, data.crossline.u_max - data.crossline.u_min], + numSamples: data.crossline.u_num_samples, + properties: data.crossline.dataFloat32Arr, + }, + loadingGeometry: crosslinePreviewGeometry ?? undefined, + }); + } else if (crosslinePreviewGeometry) { + sections.push({ + id: "crossline-slice", + loadingGeometry: crosslinePreviewGeometry, + }); + } + } + + // Depth slice + if (slicesSettings.visible[2]) { + const depthPreviewGeometry = predictDepthSliceGeometry(seismicCubeMeta, slicesSettings.value[2] ?? null); + + if (slices && data) { + sections.push({ + id: "depth-slice", + fence: { + traceXYZPointsArray: interpolateTrace( + [data.depthSlice.bbox_utm[2][0], data.depthSlice.bbox_utm[2][1], slices[2]], + [data.depthSlice.bbox_utm[3][0], data.depthSlice.bbox_utm[3][1], slices[2]], + data.depthSlice.v_num_samples, + ), + vVector: vec3.toArray( + vec3.subtract( + vec3.fromArray([...data.depthSlice.bbox_utm[0], 0]), + vec3.fromArray([...data.depthSlice.bbox_utm[3], 0]), + ), + ), + numSamples: data.depthSlice.u_num_samples, + properties: data.depthSlice.dataFloat32Arr.toReversed(), + }, + loadingGeometry: depthPreviewGeometry ?? undefined, + }); + } else if (depthPreviewGeometry) { + sections.push({ + id: "depth-slice", + loadingGeometry: depthPreviewGeometry, + }); + } + } + + return new SeismicSlicesLayer({ + id, + name, + data: sections, + colorMapFunction: makeColorMapFunctionFromColorScale(colorScaleSpec, { + valueMin: valueRange?.[0] ?? 0, + valueMax: valueRange?.[1] ?? 0, + midPoint: 0, + }), + zIncreaseDownwards: true, + isLoading: previewOrLoading, + opacity: opacityPercent / 100, + }); +} diff --git a/frontend/src/modules/3DViewer/customDeckGlLayers/AnimatedPathLayer.ts b/frontend/src/modules/3DViewer/customDeckGlLayers/AnimatedPathLayer.ts new file mode 100644 index 0000000000..c1d9245b83 --- /dev/null +++ b/frontend/src/modules/3DViewer/customDeckGlLayers/AnimatedPathLayer.ts @@ -0,0 +1,76 @@ +import { CompositeLayer, type Color } from "@deck.gl/core"; +import { PathStyleExtension, type PathStyleExtensionProps } from "@deck.gl/extensions"; +import { PathLayer, type PathLayerProps } from "@deck.gl/layers"; + +type AnimatedPathLayerProps = PathLayerProps & PathStyleExtensionProps; + +export class AnimatedPathLayer extends CompositeLayer { + static layerName = "AnimatedPathCompositeLayer"; + + // @ts-expect-error - this is how deck.gl defines state + state!: { + dashOffset: number; + }; + + private _animationFrame: number | null = null; + + initializeState() { + this.state = { dashOffset: 0 }; + this._startAnimation(); + } + + finalizeState() { + this._stopAnimation(); + } + + private _startAnimation() { + const animate = () => { + this.setState({ dashOffset: (Date.now() / 10) % 1000 }); + this.setNeedsUpdate(); + this._animationFrame = requestAnimationFrame(animate); + }; + animate(); + } + + private _stopAnimation() { + if (this._animationFrame) { + cancelAnimationFrame(this._animationFrame); + this._animationFrame = null; + } + } + + renderLayers() { + const { data, getPath, getColor = () => [255, 0, 0] as Color, getWidth = () => 5 } = this.props; + const { dashOffset } = this.state; + + return new PathLayer( + super.getSubLayerProps({ + id: `path`, + data, + getPath, + getColor, + getWidth, + getDashArray: () => { + const base = 10; + const gap = 5; + + const phase = (Math.sin(dashOffset / 100) + 1) / 2; // 0 to 1 + const scale = 0.5 + 0.5 * phase; // avoid 0 + + return [base * scale, gap * scale]; + }, + billboard: true, + widthUnits: "meters", + widthMinPixels: 3, + widthMaxPixels: 10, + extensions: [new PathStyleExtension({ highPrecisionDash: true, dash: true, offset: true })], + updateTriggers: { + getDashArray: { dashOffset }, + }, + parameters: { + depthTest: false, + }, + }), + ); + } +} diff --git a/frontend/src/modules/3DViewer/customDeckGlLayers/BiconeLayer.ts b/frontend/src/modules/3DViewer/customDeckGlLayers/BiconeLayer.ts new file mode 100644 index 0000000000..41de406546 --- /dev/null +++ b/frontend/src/modules/3DViewer/customDeckGlLayers/BiconeLayer.ts @@ -0,0 +1,239 @@ +import { CompositeLayer, type CompositeLayerProps, type Layer, type UpdateParameters } from "@deck.gl/core"; +import { SimpleMeshLayer } from "@deck.gl/mesh-layers"; +import { Geometry } from "@luma.gl/engine"; + +export type DiscLayerProps = { + id: string; + centerPoint: [number, number, number]; + radius: number; + height: number; + normalVector: [number, number, number]; + numberOfSegments?: number; + color?: [number, number, number]; + opacity?: number; + sizeUnits?: "meters" | "pixels"; + maxSizeInMeters?: number; + minSizeInMeters?: number; + depthTest?: boolean; +}; + +export class BiconeLayer extends CompositeLayer { + static layerName = "BiconeLayer"; + + // @ts-expect-error - this is how deck.gl expects state to be defined + state!: { + geometry: Geometry; + }; + + private makeGeometry(): Geometry { + const segments = this.props.numberOfSegments ?? 32; + const radius = 1; + const height = this.props.height / this.props.radius; // Normalize height to radius + + const halfHeight = height / 2; + + const ringVertexCount = segments; + const vertexCount = ringVertexCount * 2 + 2; + const triangleCount = segments * 2; + const positions = new Float32Array(vertexCount * 3); + const normals = new Float32Array(vertexCount * 3); + const indices = new Uint16Array(triangleCount * 3); + + const slope = halfHeight; + + // Top cone ring + for (let i = 0; i < segments; i++) { + const theta = (i / segments) * 2 * Math.PI; + const x = radius * Math.cos(theta); + const y = radius * Math.sin(theta); + const z = 0; + + const len = Math.sqrt(x * x + y * y + slope * slope); + const nx = x / len; + const ny = y / len; + const nz = slope / len; + + const vi = i; + positions[vi * 3 + 0] = x; + positions[vi * 3 + 1] = y; + positions[vi * 3 + 2] = z; + + normals[vi * 3 + 0] = nx; + normals[vi * 3 + 1] = ny; + normals[vi * 3 + 2] = nz; + } + + // Bottom cone ring + for (let i = 0; i < segments; i++) { + const theta = (i / segments) * 2 * Math.PI; + const x = radius * Math.cos(theta); + const y = radius * Math.sin(theta); + const z = 0; + + const len = Math.sqrt(x * x + y * y + slope * slope); + const nx = x / len; + const ny = y / len; + const nz = -slope / len; + + const vi = segments + i; + positions[vi * 3 + 0] = x; + positions[vi * 3 + 1] = y; + positions[vi * 3 + 2] = z; + + normals[vi * 3 + 0] = nx; + normals[vi * 3 + 1] = ny; + normals[vi * 3 + 2] = nz; + } + + // Top tip + const topIndex = segments * 2; + positions[topIndex * 3 + 0] = 0; + positions[topIndex * 3 + 1] = 0; + positions[topIndex * 3 + 2] = halfHeight; + normals[topIndex * 3 + 0] = 0; + normals[topIndex * 3 + 1] = 0; + normals[topIndex * 3 + 2] = 1; + + // Bottom tip + const bottomIndex = segments * 2 + 1; + positions[bottomIndex * 3 + 0] = 0; + positions[bottomIndex * 3 + 1] = 0; + positions[bottomIndex * 3 + 2] = -halfHeight; + normals[bottomIndex * 3 + 0] = 0; + normals[bottomIndex * 3 + 1] = 0; + normals[bottomIndex * 3 + 2] = -1; + + // Top cone triangles + for (let i = 0; i < segments; i++) { + const i0 = i; + const i1 = (i + 1) % segments; + const ti = i * 3; + indices[ti + 0] = topIndex; + indices[ti + 1] = i0; + indices[ti + 2] = i1; + } + + // Bottom cone triangles (reverse winding) + for (let i = 0; i < segments; i++) { + const i0 = segments + i; + const i1 = segments + ((i + 1) % segments); + const ti = segments * 3 + i * 3; + indices[ti + 0] = bottomIndex; + indices[ti + 1] = i1; + indices[ti + 2] = i0; + } + + return new Geometry({ + topology: "triangle-list", + attributes: { + positions, + normals: { + value: normals, + size: 3, + }, + }, + indices, + }); + } + + initializeState(): void { + this.setState({ + geometry: this.makeGeometry(), + }); + } + + updateState({ oldProps, props }: UpdateParameters>>) { + if ( + props.radius !== oldProps.radius || + props.height !== oldProps.height || + props.numberOfSegments !== oldProps.numberOfSegments + ) { + this.setState({ geometry: this.makeGeometry() }); + } + } + + renderLayers() { + const { color, opacity, centerPoint, normalVector, sizeUnits, radius, depthTest } = this.props; + const { viewport } = this.context; + + let sizeScale = this.props.radius; + if (sizeUnits === "pixels") { + sizeScale = radius * viewport.metersPerPixel; + } + sizeScale = Math.max( + Math.min(sizeScale, this.props.maxSizeInMeters ?? Infinity), + this.props.minSizeInMeters ?? 0, + ); + + if (this.props.modelMatrix) { + // Apply scaling from model matrix to center point and reset scaling in model matrix + // We don't want to scale the bicone, just adjust its position + centerPoint[2] *= this.props.modelMatrix[10]; + this.props.modelMatrix[10] = 1; + } + + return [ + new SimpleMeshLayer( + super.getSubLayerProps({ + id: `mesh`, + data: [0], + mesh: this.state.geometry, + getPosition: () => centerPoint, + getColor: () => color ?? [255, 255, 255], + getOrientation: () => normalToOrientation(normalVector), + opacity: opacity ?? 1, + material: true, + pickable: false, + sizeScale, + parameters: { + depthTest, + }, + }), + ), + ]; + } +} + +function normalToOrientation(normal: [number, number, number]): [number, number, number] { + const [nx, ny, nz] = normalize(normal); + + // Get rotation matrix that aligns [0, 0, 1] to [nx, ny, nz] + const z: [number, number, number] = [nx, ny, nz]; + + // Construct orthonormal basis (Y and X axes) + const up: [number, number, number] = Math.abs(nz) < 0.999 ? [0, 0, 1] : [1, 0, 0]; // handle pole case + const x = normalize(cross(up, z)); + const y = cross(z, x); + + const rot = [x[0], y[0], z[0], x[1], y[1], z[1], x[2], y[2], z[2]]; + + return rotationMatrixToEulerXYZ(rot); +} + +function rotationMatrixToEulerXYZ(m: number[]): [number, number, number] { + const [m00, m01, , m10, m11, , m20, m21, m22] = m; + + let pitch, yaw, roll; + + if (Math.abs(m20) < 0.9999) { + pitch = Math.asin(-m20); + yaw = Math.atan2(m10, m00); + roll = Math.atan2(m21, m22); + } else { + // Gimbal lock + pitch = Math.asin(-m20); + yaw = 0; + roll = Math.atan2(-m01, m11); + } + + return [(pitch * 180) / Math.PI, (yaw * 180) / Math.PI, (roll * 180) / Math.PI]; +} + +function normalize([x, y, z]: [number, number, number]): [number, number, number] { + const len = Math.hypot(x, y, z); + return len === 0 ? [0, 0, 1] : [x / len, y / len, z / len]; +} + +function cross(a: [number, number, number], b: [number, number, number]): [number, number, number] { + return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +} diff --git a/frontend/src/modules/3DViewer/customDeckGlLayers/EditablePolylineLayer.ts b/frontend/src/modules/3DViewer/customDeckGlLayers/EditablePolylineLayer.ts new file mode 100644 index 0000000000..3f540f0441 --- /dev/null +++ b/frontend/src/modules/3DViewer/customDeckGlLayers/EditablePolylineLayer.ts @@ -0,0 +1,302 @@ +import { + CompositeLayer, + type CompositeLayerProps, + type GetPickingInfoParams, + type Layer, + type PickingInfo, + type UpdateParameters, +} from "@deck.gl/core"; +import { LineLayer, PathLayer, ScatterplotLayer } from "@deck.gl/layers"; + +import type { Polyline } from "@modules/3DViewer/view/utils/PolylinesPlugin"; + +import { AnimatedPathLayer } from "./AnimatedPathLayer"; + +export enum AllowHoveringOf { + NONE = "none", + LINES = "line", + POINTS = "point", + LINES_AND_POINTS = "lines-and-points", +} + +export type EditablePolylineLayerProps = { + id: string; + polyline: Polyline; + polylineVersion: number; + mouseHoverPoint?: number[]; + referencePathPointIndex?: number; + allowHoveringOf: AllowHoveringOf; +}; + +export type EditablePolylineLayerPickingInfo = PickingInfo & { + editableEntity?: { + type: "line" | "point"; + index: number; + }; +}; + +export function isEditablePolylineLayerPickingInfo(info: PickingInfo): info is EditablePolylineLayerPickingInfo { + return ( + Object.keys(info).includes("editableEntity") && + ((info as EditablePolylineLayerPickingInfo).editableEntity?.type === "line" || + (info as EditablePolylineLayerPickingInfo).editableEntity?.type === "point") + ); +} + +export class EditablePolylineLayer extends CompositeLayer { + static layerName: string = "EditablePolylineLayer"; + + // @ts-expect-error - deck.gl types are wrong + state!: { + hoveredEntity: { + layer: "line" | "point"; + index: number; + } | null; + }; + + initializeState(): void { + this.state = { + hoveredEntity: null, + }; + } + + getPickingInfo({ info }: GetPickingInfoParams): EditablePolylineLayerPickingInfo { + if (info && info.sourceLayer && info.index !== undefined && info.index !== -1) { + let layer: "line" | "point" | null = null; + if (info.sourceLayer.id.includes("lines-selection")) { + layer = "line"; + } else if (info.sourceLayer.id.includes("points")) { + layer = "point"; + } + return { + ...info, + editableEntity: layer + ? { + type: layer, + index: info.index, + } + : undefined, + }; + } + + return info; + } + + onHover(info: EditablePolylineLayerPickingInfo): boolean { + if (!info.editableEntity || this.props.allowHoveringOf === AllowHoveringOf.NONE) { + this.setState({ + hoveredEntity: null, + }); + return false; + } + + if (this.props.allowHoveringOf === AllowHoveringOf.LINES && info.editableEntity.type === "point") { + return false; + } + + if (this.props.allowHoveringOf === AllowHoveringOf.POINTS && info.editableEntity.type === "line") { + return false; + } + + this.setState({ + hoveredEntity: { + layer: info.editableEntity.type, + index: info.index, + }, + }); + + return false; + } + + shouldUpdateState( + params: UpdateParameters>>, + ): boolean { + if (params.props.polylineVersion !== params.oldProps.polylineVersion) { + return true; + } + + return super.shouldUpdateState(params); + } + + renderLayers() { + const { polyline, mouseHoverPoint, referencePathPointIndex, visible } = this.props; + + if (!visible || !polyline) { + return []; + } + + const layers: Layer[] = []; + + if (referencePathPointIndex !== undefined && mouseHoverPoint && this.state.hoveredEntity === null) { + layers.push( + new LineLayer({ + ...this.getSubLayerProps({ + id: "line", + data: [{ from: polyline.path[referencePathPointIndex], to: mouseHoverPoint }], + getSourcePosition: (d: any) => d.from, + getTargetPosition: (d: any) => d.to, + getColor: [polyline.color[0], polyline.color[1], polyline.color[2], 100], + getWidth: 10, + widthUnits: "meters", + widthMinPixels: 3, + parameters: { + depthTest: false, + }, + }), + }), + new ScatterplotLayer({ + ...this.getSubLayerProps({ + id: "hover-point", + data: [mouseHoverPoint], + getPosition: (d: any) => d, + getFillColor: [polyline.color[0], polyline.color[1], polyline.color[2], 100], + getRadius: 10, + radiusUnits: "pixels", + radiusMinPixels: 5, + radiusMaxPixels: 10, + pickable: false, + parameters: { + depthTest: false, + }, + }), + }), + ); + } + + const polylinePathLayerData: number[][][] = []; + for (let i = 0; i < polyline.path.length - 1; i++) { + polylinePathLayerData.push([polyline.path[i], polyline.path[i + 1]]); + } + + if (this.state.hoveredEntity && this.state.hoveredEntity.layer === "line") { + const hoveredLine = polylinePathLayerData[this.state.hoveredEntity.index]; + layers.push( + new PathLayer({ + ...this.getSubLayerProps({ + id: "hovered-line", + data: [hoveredLine], + getPath: (d: any) => d, + getColor: [255, 255, 255, 50], + getWidth: 20, + widthUnits: "meters", + widthMinPixels: 6, + parameters: { + depthTest: false, + }, + pickable: false, + }), + }), + ); + } + + layers.push( + new AnimatedPathLayer({ + ...this.getSubLayerProps({ + id: "lines", + data: polylinePathLayerData, + getColor: polyline.color, + getPath: (d: any) => d, + getDashArray: [10, 10], + dashJustified: true, + getWidth: 10, + billboard: true, + widthUnits: "meters", + widthMinPixels: 3, + widthMaxPixels: 10, + parameters: { + depthTest: false, + }, + pickable: false, + depthTest: false, + updateTriggers: { + getPath: [polyline.version], + getPosition: [polyline.version], + getRadius: [polyline.version], + getLineColor: [polyline.version], + getFillColor: [polyline.version], + getLineWidth: [polyline.version], + }, + }), + }), + new PathLayer({ + ...this.getSubLayerProps({ + id: "lines-selection", + data: polylinePathLayerData, + getColor: [0, 0, 0, 0], + getPath: (d: any) => d, + getWidth: 50, + widthMinPixels: 10, + widthMaxPixels: 20, + billboard: false, + widthUnits: "meters", + parameters: { + depthTest: false, + }, + pickable: true, + }), + }), + ); + + layers.push( + new ScatterplotLayer({ + ...this.getSubLayerProps({ + id: "points", + data: polyline.path, + getPosition: (d: any) => d, + getFillColor: (_: any, context: any) => { + if (context.index === referencePathPointIndex) { + return [255, 255, 255, 255]; + } + return polyline.color; + }, + getLineColor: (_: any, context: any) => { + if ( + this.state.hoveredEntity && + this.state.hoveredEntity.layer === "point" && + context.index === this.state.hoveredEntity.index + ) { + return [255, 255, 255, 255]; + } + return [0, 0, 0, 0]; + }, + getLineWidth: (_: any, context: any) => { + if ( + this.state.hoveredEntity && + this.state.hoveredEntity.layer === "point" && + context.index === this.state.hoveredEntity.index + ) { + return 5; + } + return 0; + }, + getRadius: (_: any, context: any) => { + if ( + this.state.hoveredEntity?.layer === "point" && + context.index === this.state.hoveredEntity.index + ) { + return 12; + } + return 10; + }, + stroked: true, + radiusUnits: "pixels", + lineWidthUnits: "meters", + lineWidthMinPixels: 3, + radiusMinPixels: 5, + pickable: true, + parameters: { + depthTest: false, + }, + updateTriggers: { + getLineWidth: [this.state.hoveredEntity, referencePathPointIndex], + getLineColor: [this.state.hoveredEntity], + getFillColor: [referencePathPointIndex], + getRadius: [this.state.hoveredEntity, referencePathPointIndex], + }, + }), + }), + ); + + return layers; + } +} diff --git a/frontend/src/modules/3DViewer/customDeckGlLayers/HoverPointLayer.ts b/frontend/src/modules/3DViewer/customDeckGlLayers/HoverPointLayer.ts new file mode 100644 index 0000000000..c15dc92a8f --- /dev/null +++ b/frontend/src/modules/3DViewer/customDeckGlLayers/HoverPointLayer.ts @@ -0,0 +1,33 @@ +import { CompositeLayer, type Layer, type LayersList } from "@deck.gl/core"; +import { ColumnLayer } from "@deck.gl/layers"; + +export type HoverPointLayerProps = { + point: number[] | null; + color: [number, number, number, number]; +}; + +export class HoverPointLayer extends CompositeLayer { + static layerName: string = "HoverPointLayer"; + + renderLayers(): Layer | null | LayersList { + if (!this.props.point) { + return null; + } + + return new ColumnLayer({ + id: "hover-point", + data: [this.props.point], + diskResolution: 20, + getElevation: 1, + radiusUnits: "pixels", + radius: 20, + extruded: false, + pickable: false, + getPosition: (d) => d, + getFillColor: this.props.color, + parameters: { + depthTest: false, + }, + }); + } +} diff --git a/frontend/src/modules/3DViewer/customDeckGlLayers/PolylinesLayer.ts b/frontend/src/modules/3DViewer/customDeckGlLayers/PolylinesLayer.ts new file mode 100644 index 0000000000..d1f550358a --- /dev/null +++ b/frontend/src/modules/3DViewer/customDeckGlLayers/PolylinesLayer.ts @@ -0,0 +1,216 @@ +import { + CompositeLayer, + type FilterContext, + type GetPickingInfoParams, + type Layer, + type LayerContext, + type PickingInfo, +} from "@deck.gl/core"; +import { PathLayer, TextLayer } from "@deck.gl/layers"; +import type { ExtendedLayerProps } from "@webviz/subsurface-viewer"; + +import type { Polyline } from "@modules/3DViewer/view/utils/PolylinesPlugin"; + +export type PolylinesLayerProps = ExtendedLayerProps & { + id: string; + polylines: Polyline[]; + selectedPolylineId?: string; + hoverable?: boolean; +}; + +export type PolylinesLayerPickingInfo = PickingInfo & { + polylineId?: string; + name?: string; +}; + +export function isPolylinesLayerPickingInfo(info: PickingInfo): info is PolylinesLayerPickingInfo { + return Object.keys(info).includes("polylineId"); +} + +export class PolylinesLayer extends CompositeLayer { + static layerName: string = "PolylinesLayer"; + + // @ts-expect-error - deck.gl types are wrong + state!: { + hoveredPolylineIndex: number | null; + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + initializeState(_: LayerContext): void { + this.setState({ + hoveredPolylineIndex: null, + }); + } + + onHover(info: PickingInfo): boolean { + if (!this.props.hoverable) { + return false; + } + if (info.index === undefined) { + return false; + } + + const hoveredPolylineIndex = info.index; + this.setState({ hoveredPolylineIndex }); + + return false; + } + + getPickingInfo({ info }: GetPickingInfoParams): PolylinesLayerPickingInfo { + if (info && info.sourceLayer && info.object !== undefined) { + return { + ...info, + name: info.object.name, + polylineId: info.object.id, + }; + } + + return info; + } + + filterSubLayer(context: FilterContext): boolean { + if (context.layer.id.includes("labels")) { + return context.viewport.zoom > -5; + } + + return true; + } + + renderLayers(): Layer[] { + const { hoveredPolylineIndex } = this.state; + + const layers: Layer[] = []; + + if (this.props.selectedPolylineId) { + const selectedPolylineIndex = this.props.polylines.findIndex((p) => p.id === this.props.selectedPolylineId); + if (selectedPolylineIndex !== -1) { + layers.push( + new PathLayer({ + ...this.getSubLayerProps({ + id: `selected`, + data: [this.props.polylines[selectedPolylineIndex]], + getPath: (d: Polyline) => d.path, + getColor: (d: Polyline) => [d.color[0], d.color[1], d.color[2], 200], + getWidth: 30, + widthUnits: "meters", + widthMinPixels: 5, + parameters: { + depthTest: false, + }, + billboard: true, + }), + }), + ); + } + } + + if (hoveredPolylineIndex !== null && this.props.polylines[hoveredPolylineIndex] && this.props.hoverable) { + layers.push( + new PathLayer({ + ...this.getSubLayerProps({ + id: `hovered`, + data: [this.props.polylines[hoveredPolylineIndex]], + getPath: (d: Polyline) => d.path, + getColor: (d: Polyline) => [d.color[0], d.color[1], d.color[2], 100], + getWidth: 30, + widthUnits: "meters", + widthMinPixels: 6, + parameters: { + depthTest: false, + }, + billboard: true, + }), + }), + ); + } + + const polylineLabels: { label: string; position: number[]; angle: number; color: number[] }[] = []; + for (const polyline of this.props.polylines) { + if (polyline.path.length < 2) { + continue; + } + const vector = [ + polyline.path[1][0] - polyline.path[0][0], + polyline.path[1][1] - polyline.path[0][1], + polyline.path[1][2] - polyline.path[0][2], + ]; + const length = Math.sqrt(vector[0] ** 2 + vector[1] ** 2); + const unitVector = [vector[0] / length, vector[1] / length, vector[2] / length]; + let angle = Math.atan2(unitVector[1], unitVector[0]) * (180 / Math.PI); + if (angle > 90 || angle < -90) { + angle += 180; + } + polylineLabels.push({ + label: polyline.name, + position: [ + polyline.path[0][0] + (unitVector[0] * length) / 2, + polyline.path[0][1] + (unitVector[1] * length) / 2, + polyline.path[0][2] + (unitVector[2] * length) / 2, + ], + angle, + color: polyline.color, + }); + } + + layers.push( + new PathLayer({ + ...this.getSubLayerProps({ + id: `polylines`, + data: this.props.polylines, + getPath: (d: Polyline) => d.path, + getColor: (d: Polyline) => d.color, + getWidth: 10, + widthUnits: "meters", + widthMinPixels: 3, + widthMaxPixels: 10, + pickable: false, + parameters: { + depthTest: false, + }, + billboard: true, + }), + }), + new TextLayer({ + ...this.getSubLayerProps({ + id: `polylines-labels`, + data: polylineLabels, + getPosition: (d: any) => d.position, + getText: (d: any) => d.label, + getSize: 12, + sizeUnits: "meters", + sizeMinPixels: 16, + getAngle: (d: any) => d.angle, + getColor: [0, 0, 0], + parameters: { + depthTest: false, + }, + billboard: false, + getBackgroundColor: [255, 255, 255, 100], + getBackgroundPadding: [10, 10], + getBackgroundBorderColor: [0, 0, 0, 255], + getBackgroundBorderWidth: 2, + getBackgroundElevation: 1, + background: true, + }), + }), + new PathLayer({ + ...this.getSubLayerProps({ + id: `polylines-hoverable`, + data: this.props.polylines, + getPath: (d: Polyline) => d.path, + getColor: (d: Polyline) => [d.color[0], d.color[1], d.color[2], 1], + getWidth: 50, + widthUnits: "meters", + widthMinPixels: 10, + widthMaxPixels: 20, + pickable: true, + parameters: { + depthTest: false, + }, + }), + }), + ); + + return layers; + } +} diff --git a/frontend/src/modules/3DViewer/customDeckGlLayers/PreviewLayer/PreviewLayer.ts b/frontend/src/modules/3DViewer/customDeckGlLayers/PreviewLayer/PreviewLayer.ts new file mode 100644 index 0000000000..c28febb7a2 --- /dev/null +++ b/frontend/src/modules/3DViewer/customDeckGlLayers/PreviewLayer/PreviewLayer.ts @@ -0,0 +1,128 @@ +import { CompositeLayer, type Layer, type LayersList } from "@deck.gl/core"; +import type { BoundingBox3D, ExtendedLayerProps } from "@webviz/subsurface-viewer"; + +import { type Geometry, ShapeType } from "@lib/utils/geometry"; +import * as vec3 from "@lib/utils/vec3"; + +import { BoxLayer } from "./_private/BoxLayer"; + +export type PreviewLayerProps = ExtendedLayerProps & { + data: { + geometry: Geometry; + }; + zIncreaseDownwards?: boolean; + + // Non-public property: + reportBoundingBox?: React.Dispatch<{ + layerBoundingBox: BoundingBox3D; + }>; +}; + +export class PreviewLayer extends CompositeLayer { + static layerName = "PreviewLayer"; + + initializeState(): void { + if (this.props.reportBoundingBox) { + this.props.reportBoundingBox({ + layerBoundingBox: this.calcBoundingBox(), + }); + } + } + + updateState({ changeFlags, props }: { changeFlags: any; props: PreviewLayerProps }): void { + if (props.reportBoundingBox && changeFlags.dataChanged) { + props.reportBoundingBox({ + layerBoundingBox: this.calcBoundingBox(), + }); + } + } + + private calcBoundingBox(): BoundingBox3D { + const { data, zIncreaseDownwards } = this.props; + + let xmin = Number.POSITIVE_INFINITY; + let ymin = Number.POSITIVE_INFINITY; + let zmin = Number.POSITIVE_INFINITY; + let xmax = Number.NEGATIVE_INFINITY; + let ymax = Number.NEGATIVE_INFINITY; + let zmax = Number.NEGATIVE_INFINITY; + + for (const shape of data.geometry.shapes) { + if (shape.type === ShapeType.BOX) { + const { centerPoint, dimensions } = shape; + const halfDimensions = vec3.create(dimensions.width / 2, dimensions.height / 2, dimensions.depth / 2); + + const vecU = vec3.scale(shape.normalizedEdgeVectors.u, halfDimensions.x); + const vecV = vec3.scale(shape.normalizedEdgeVectors.v, halfDimensions.y); + const vecW = vec3.cross(vecU, vecV); + const corners = [ + vec3.add(centerPoint, vecU, vecV, vecW), + vec3.add(centerPoint, vecU, vecV, vec3.negate(vecW)), + vec3.add(centerPoint, vecU, vec3.negate(vecV), vecW), + vec3.add(centerPoint, vecU, vec3.negate(vecV), vec3.negate(vecW)), + vec3.add(centerPoint, vec3.negate(vecU), vecV, vecW), + vec3.add(centerPoint, vec3.negate(vecU), vecV, vec3.negate(vecW)), + vec3.add(centerPoint, vec3.negate(vecU), vec3.negate(vecV), vecW), + vec3.add(centerPoint, vec3.negate(vecU), vec3.negate(vecV), vec3.negate(vecW)), + ]; + + for (const corner of corners) { + xmin = Math.min(xmin, corner.x); + ymin = Math.min(ymin, corner.y); + zmin = Math.min(zmin, corner.z * (zIncreaseDownwards ? -1 : 1)); + xmax = Math.max(xmax, corner.x); + ymax = Math.max(ymax, corner.y); + zmax = Math.max(zmax, corner.z * (zIncreaseDownwards ? -1 : 1)); + } + } + } + + return [xmin, ymin, zmin, xmax, ymax, zmax]; + } + + renderLayers(): LayersList { + const { data } = this.props; + + const layers: Layer[] = []; + + const zFactor = this.props.zIncreaseDownwards ? -1 : 1; + + for (const [idx, shape] of data.geometry.shapes.entries()) { + if (shape.type === ShapeType.BOX) { + layers.push( + new BoxLayer( + this.getSubLayerProps({ + id: `${idx}`, + data: { + centerPoint: vec3.toArray( + vec3.multiplyElementWise(shape.centerPoint, vec3.create(1, 1, zFactor)), + ), + dimensions: [ + shape.dimensions.width, + shape.dimensions.height, + shape.dimensions.depth * zFactor, + ], + normalizedEdgeVectors: [ + vec3.toArray( + vec3.multiplyElementWise( + shape.normalizedEdgeVectors.u, + vec3.create(1, 1, zFactor), + ), + ), + vec3.toArray( + vec3.multiplyElementWise( + shape.normalizedEdgeVectors.v, + vec3.create(1, 1, zFactor), + ), + ), + ], + }, + }), + ), + ); + } + } + + return layers; + } +} diff --git a/frontend/src/modules/3DViewer/customDeckGlLayers/PreviewLayer/_private/BoxLayer.ts b/frontend/src/modules/3DViewer/customDeckGlLayers/PreviewLayer/_private/BoxLayer.ts new file mode 100644 index 0000000000..8036ee1d5d --- /dev/null +++ b/frontend/src/modules/3DViewer/customDeckGlLayers/PreviewLayer/_private/BoxLayer.ts @@ -0,0 +1,189 @@ +import { CompositeLayer, type CompositeLayerProps, type Layer, type UpdateParameters } from "@deck.gl/core"; +import { SimpleMeshLayer } from "@deck.gl/mesh-layers"; +import { Geometry } from "@luma.gl/engine"; +import type { ExtendedLayerProps } from "@webviz/subsurface-viewer"; + +import { fuzzyCompare } from "@lib/utils/fuzzyCompare"; +import * as vec3 from "@lib/utils/vec3"; + +export type RectangleLayerData = { + centerPoint: [number, number, number]; + dimensions: [number, number, number]; + normalizedEdgeVectors: [[number, number, number], [number, number, number]]; +}; + +export type BoxLayerProps = ExtendedLayerProps & { + data: RectangleLayerData; +}; + +export class BoxLayer extends CompositeLayer { + static layerName = "BoxLayer"; + + // @ts-expect-error - private + state!: { + geometry: Geometry; + }; + + private makeGeometry(): Geometry { + const { data } = this.props; + + const [centerX, centerY, centerZ] = [0, 0, 0]; // Default center point + const [[uX, uY, uZ], [vX, vY, vZ]] = data.normalizedEdgeVectors; + const [clampedWidth, clampedHeight, clampedDepth] = data.dimensions.map((dim) => Math.max(dim, 0.5)); + + const halfWidth = clampedWidth / 2; + const halfHeight = clampedHeight / 2; + const halfDepth = clampedDepth / 2; + + const u = vec3.create(uX, uY, uZ); + const v = vec3.create(vX, vY, vZ); + const w = vec3.normalize(vec3.cross(u, v)); // orthogonal vector + + // Precompute 8 box corners + const offsets: [number, number, number][] = [ + [+1, +1, +1], + [-1, +1, +1], + [-1, -1, +1], + [+1, -1, +1], // front (+w) + [+1, +1, -1], + [-1, +1, -1], + [-1, -1, -1], + [+1, -1, -1], // back (-w) + ]; + + const corners = offsets.map(([su, sv, sw]) => + vec3.add( + { x: centerX, y: centerY, z: centerZ }, + vec3.scale(u, halfWidth * su), + vec3.scale(v, halfHeight * sv), + vec3.scale(w, halfDepth * sw), + ), + ); + + // Define each face as two triangles (6 faces × 2 triangles = 12 total) + const faces: { indices: [number, number, number]; normal: vec3.Vec3 }[] = []; + + function addFace(i0: number, i1: number, i2: number, i3: number, normal: vec3.Vec3) { + // Triangle 1: i0, i1, i2 + faces.push({ indices: [i0, i1, i2], normal }); + // Triangle 2: i0, i2, i3 + faces.push({ indices: [i0, i2, i3], normal }); + } + + // Front (+w) + addFace(0, 1, 2, 3, w); + // Back (-w) + addFace(5, 4, 7, 6, vec3.negate(w)); + // Left (-u) + addFace(1, 5, 6, 2, vec3.negate(u)); + // Right (+u) + addFace(4, 0, 3, 7, u); + // Top (+v) + addFace(4, 5, 1, 0, v); + // Bottom (-v) + addFace(3, 2, 6, 7, vec3.negate(v)); + + const positions = new Float32Array(faces.length * 3 * 3); + const normals = new Float32Array(faces.length * 3 * 3); + const indices = new Uint32Array(faces.length * 3); + + let p = 0; + let i = 0; + for (let f = 0; f < faces.length; f++) { + const { indices: tri, normal } = faces[f]; + for (const idx of tri) { + const corner = corners[idx]; + positions.set([corner.x, corner.y, corner.z], p); + normals.set([normal.x, normal.y, normal.z], p); + p += 3; + } + indices.set([i, i + 1, i + 2], i); + i += 3; + } + + return new Geometry({ + topology: "triangle-list", + attributes: { + positions, + normals: { value: normals, size: 3 }, + }, + indices, + }); + } + + initializeState(): void { + this.setState({ + ...this.state, + isHovered: false, + isLoaded: false, + }); + } + + updateState({ + changeFlags, + props, + oldProps, + }: UpdateParameters>>) { + if (changeFlags.dataChanged && props.data) { + let changeDetected = false; + if (!fuzzyCompareArrays(props.data?.dimensions, oldProps.data?.dimensions, 0.0001)) { + changeDetected = true; + } + if ( + !fuzzyCompareArrays( + props.data?.normalizedEdgeVectors[0], + oldProps.data?.normalizedEdgeVectors[0], + 0.0001, + ) + ) { + changeDetected = true; + } + if ( + !fuzzyCompareArrays( + props.data?.normalizedEdgeVectors[1], + oldProps.data?.normalizedEdgeVectors[1], + 0.0001, + ) + ) { + changeDetected = true; + } + + if (!changeDetected) { + return; + } + + this.setState({ + geometry: this.makeGeometry(), + }); + } + } + + renderLayers() { + const { centerPoint } = this.props.data; + return [ + new SimpleMeshLayer( + super.getSubLayerProps({ + id: `${this.props.id}-mesh`, + data: [0], + mesh: this.state.geometry, + getPosition: () => centerPoint, + getColor: [255, 255, 255, 255], + material: true, + pickable: false, + }), + ), + ]; + } +} + +function fuzzyCompareArrays(arr1?: number[], arr2?: number[], tolerance = 0.01): boolean { + if (!arr1 || !arr2 || arr1.length !== arr2.length) { + return false; + } + for (let i = 0; i < arr1.length; i++) { + if (!fuzzyCompare(arr1[i], arr2[i], tolerance)) { + return false; + } + } + return true; +} diff --git a/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicFenceMeshLayer/SeismicFenceMeshLayer.ts b/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicFenceMeshLayer/SeismicFenceMeshLayer.ts new file mode 100644 index 0000000000..9bd018d291 --- /dev/null +++ b/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicFenceMeshLayer/SeismicFenceMeshLayer.ts @@ -0,0 +1,389 @@ +import { + CompositeLayer, + type CompositeLayerProps, + type GetPickingInfoParams, + type Layer, + type PickingInfo, + type UpdateParameters, +} from "@deck.gl/core"; +import { Geometry } from "@luma.gl/engine"; +import type { ExtendedLayerProps } from "@webviz/subsurface-viewer"; +import type { BoundingBox3D, ReportBoundingBoxAction } from "@webviz/subsurface-viewer/dist/components/Map"; +import { transfer, wrap } from "comlink"; +import { isEqual } from "lodash"; + +import { assertNonNull } from "@lib/utils/assertNonNull"; +import type { Geometry as LoadingGeometry } from "@lib/utils/geometry"; + +import { PreviewLayer } from "../PreviewLayer/PreviewLayer"; + +import { ExtendedSimpleMeshLayer } from "./_private/ExtendedSimpleMeshLayer"; +// eslint-disable-next-line import/default +import MeshWorker from "./_private/webworker/makeMesh.worker?worker"; +import { type WebWorkerParameters, type WebworkerResult } from "./_private/webworker/types"; + +export type SeismicFenceMeshLayerPickingInfo = { + properties?: { name: string; value: number }[]; +} & PickingInfo; + +export type SeismicFence = { + numSamples: number; + properties: Float32Array; + traceXYZPointsArray: Float32Array; + vVector: [number, number, number]; +}; + +export interface SeismicFenceMeshLayerProps extends ExtendedLayerProps { + data: SeismicFence; + colorMapFunction: (value: number) => [number, number, number, number]; + hoverable?: boolean; + zIncreaseDownwards?: boolean; + isLoading?: boolean; + loadingGeometry?: LoadingGeometry; + + // Non public properties: + reportBoundingBox?: React.Dispatch; +} + +function encodePropertyToColor(property: number, min: number, max: number): [number, number, number] { + const normalized = (property - min) / (max - min); + const safeNormalized = Math.max(1 / 16777215, normalized); // avoid zero + const colorIndex = Math.floor(safeNormalized * 16777215); + const r = (colorIndex >> 16) & 255; + const g = (colorIndex >> 8) & 255; + const b = colorIndex & 255; + return [r, g, b]; +} + +function decodeColorToProperty(r: number, g: number, b: number, min: number, max: number): number { + const colorIndex = r * 256 * 256 + g * 256 + b; + const normalized = colorIndex / 16777215; + return normalized * (max - min) + min; +} + +export class SeismicFenceMeshLayer extends CompositeLayer { + static layerName: string = "SeismicFenceSectionMeshLayer"; + + private _verticesArray: Float32Array | null = null; + private _indicesArray: Uint32Array | null = null; + private _colorsArray: Float32Array | null = null; + private _pickingColorsArray: Uint8ClampedArray | null = null; + + // @ts-expect-error - This is how deck.gl expects the state to be defined + state!: { + geometry: Geometry; + meshCreated: boolean; + colorsArrayCreated: boolean; + minProperty: number; + maxProperty: number; + }; + + initializeState(): void { + this.setState({ + meshCreated: false, + colorsArrayCreated: false, + geometry: new Geometry({ + attributes: { + positions: new Float32Array(), + }, + topology: "triangle-list", + }), + }); + } + + shouldUpdateState( + params: UpdateParameters>>, + ): boolean { + const { changeFlags } = params; + + if (changeFlags.propsOrDataChanged) { + return true; + } + + return false; + } + + updateState({ + props, + oldProps, + changeFlags, + }: UpdateParameters>>) { + const meshRecomputationRequired = + !isEqual(oldProps.data, props.data) || !isEqual(oldProps.zIncreaseDownwards, props.zIncreaseDownwards); + + const colorMapFunctionChanged = !isEqual(oldProps.colorMapFunction, props.colorMapFunction); + + if (props.reportBoundingBox && changeFlags.dataChanged) { + props.reportBoundingBox({ + layerBoundingBox: this.calcBoundingBox(), + }); + } + + if ( + !meshRecomputationRequired && + !colorMapFunctionChanged && + this.state.meshCreated && + this.state.colorsArrayCreated + ) { + return; + } + + if (props.isLoading) { + return; + } + + if (meshRecomputationRequired || !this.state.meshCreated) { + this.rebuildMesh(); + return; + } + + if (colorMapFunctionChanged || !this.state.colorsArrayCreated) { + this.recolorMesh(); + } + } + + private calcBoundingBox(): BoundingBox3D { + const { traceXYZPointsArray, vVector } = this.props.data; + const zFactor = this.props.zIncreaseDownwards ? -1 : 1; + + let xmin = Number.POSITIVE_INFINITY; + let ymin = Number.POSITIVE_INFINITY; + let zmin = Number.POSITIVE_INFINITY; + let xmax = Number.NEGATIVE_INFINITY; + let ymax = Number.NEGATIVE_INFINITY; + let zmax = Number.NEGATIVE_INFINITY; + + for (let i = 0; i < traceXYZPointsArray.length; i += 3) { + const x = traceXYZPointsArray[i] + vVector[0]; + const y = traceXYZPointsArray[i + 1] + vVector[1]; + const z = (traceXYZPointsArray[i + 2] + vVector[2]) * zFactor; + xmin = Math.min(xmin, x); + ymin = Math.min(ymin, y); + zmin = Math.min(zmin, z); + xmax = Math.max(xmax, x); + ymax = Math.max(ymax, y); + zmax = Math.max(zmax, z); + } + + return [xmin, ymin, Math.min(zmin, zmax), xmax, ymax, Math.max(zmin, zmax)]; + } + + private initArrayBuffers() { + const { data } = this.props; + + const numTraces = data.traceXYZPointsArray.length / 3; + const totalNumVertices = numTraces * data.numSamples; + const totalNumIndices = (numTraces - 1) * (data.numSamples - 1) * 6; + this._verticesArray = new Float32Array(totalNumVertices * 3); + this._indicesArray = new Uint32Array(totalNumIndices); + } + + private initColorsArray() { + const { data } = this.props; + + this._colorsArray = new Float32Array(data.properties.length * 4); + this._pickingColorsArray = new Uint8ClampedArray(data.properties.length * 3); + } + + private maybeUpdateGeometry() { + const { geometry } = this.state; + const verticesArr = this._verticesArray; + const indicesArr = this._indicesArray; + if (verticesArr === null || indicesArr === null) { + return; + } + + this.setState({ + ...this.state, + geometry: new Geometry({ + attributes: { + ...geometry.attributes, + positions: verticesArr, + }, + topology: "triangle-list", + indices: indicesArr, + }), + meshCreated: true, + }); + } + + private async rebuildMesh() { + const { data, zIncreaseDownwards } = this.props; + + this.setState({ ...this.state, meshCreated: false }); + + this.initArrayBuffers(); + + const verticesArray = assertNonNull(this._verticesArray, "Vertices array is null"); + const indicesArray = assertNonNull(this._indicesArray, "Indices array is null"); + + const params: WebWorkerParameters = { + numSamples: data.numSamples, + traceXYZPointsArray: data.traceXYZPointsArray, + vVector: data.vVector, + verticesArray, + indicesArray, + zIncreasingDownwards: zIncreaseDownwards ?? false, + }; + + const workerInstance = new MeshWorker(); + + try { + const meshWorker = wrap<{ + makeMesh(params: WebWorkerParameters): Promise; + }>(workerInstance); + + const result = await transfer(meshWorker.makeMesh(params), [verticesArray.buffer, indicesArray.buffer]); + this._verticesArray = result.verticesArray; + this._indicesArray = result.indicesArray; + + this.maybeUpdateGeometry(); + this.recolorMesh(); + + this.props.reportBoundingBox?.({ + layerBoundingBox: this.calcBoundingBox(), + }); + } catch (error) { + console.error("Error in worker:", error); + } + + workerInstance.terminate(); + } + + private recolorMesh() { + const { geometry } = this.state; + + this.setState({ ...this.state, colorsArrayCreated: false }); + this.initColorsArray(); + const colorsArray = assertNonNull(this._colorsArray, "Colors array is null"); + const pickingColorsArray = assertNonNull(this._pickingColorsArray, "Picking colors array is null"); + + this.makeColorsArray().then(() => { + this.setState({ + ...this.state, + geometry: new Geometry({ + attributes: { + ...geometry.attributes, + colors: { + value: colorsArray, + size: 4, + }, + pickingColors: { + value: pickingColorsArray, + type: "uint8", + size: 3, + normalized: true, + }, + }, + topology: "triangle-list", + indices: geometry.indices, + }), + colorsArrayCreated: true, + }); + }); + } + + private async makeColorsArray() { + const { data, colorMapFunction } = this.props; + + const colorsArray = assertNonNull(this._colorsArray, "Colors array is null"); + const pickingColorsArray = assertNonNull(this._pickingColorsArray, "Picking colors array is null"); + + let minProperty = Number.MAX_VALUE; + let maxProperty = -Number.MAX_VALUE; + for (let i = 0; i < data.properties.length; i++) { + minProperty = Math.min(minProperty, data.properties[i]); + maxProperty = Math.max(maxProperty, data.properties[i]); + } + + this.setState({ + ...this.state, + minProperty, + maxProperty, + }); + + let colorIndex = 0; + for (let i = 0; i < data.properties.length; i++) { + const property = data.properties[i]; + const [r, g, b, a] = colorMapFunction(property); + colorsArray[colorIndex * 4 + 0] = r / 255; + colorsArray[colorIndex * 4 + 1] = g / 255; + colorsArray[colorIndex * 4 + 2] = b / 255; + colorsArray[colorIndex * 4 + 3] = a / 255; + + const [r2, g2, b2] = encodePropertyToColor(property, minProperty, maxProperty); + pickingColorsArray[i * 3 + 0] = r2; + pickingColorsArray[i * 3 + 1] = g2; + pickingColorsArray[i * 3 + 2] = b2; + colorIndex++; + } + } + + getPickingInfo({ info }: GetPickingInfoParams): SeismicFenceMeshLayerPickingInfo { + if (!info.color) return info; + + const [r, g, b] = info.color; // Convert from [0, 1] to [0, 255] + const { minProperty, maxProperty } = this.state; + + const property = decodeColorToProperty(r, g, b, minProperty, maxProperty); + + const properties: { name: string; value: number }[] = [{ name: "Property", value: property }]; + + if (info.coordinate?.length === 3) { + const depth = (this.props.zIncreaseDownwards ? -1 : 1) * info.coordinate[2]; + properties.push({ name: "Depth", value: depth }); + } + + return { + ...info, + properties, + }; + } + + onHover(pickingInfo: PickingInfo): boolean { + this.setState({ ...this.state, isHovered: pickingInfo.index !== -1 }); + return false; + } + + renderLayers() { + const { isLoading, zIncreaseDownwards, loadingGeometry, opacity } = this.props; + const { geometry, meshCreated, colorsArrayCreated } = this.state; + + const layers: Layer[] = []; + + if ((isLoading || !meshCreated || !colorsArrayCreated) && loadingGeometry) { + layers.push( + new PreviewLayer( + super.getSubLayerProps({ + id: "loading", + data: { + geometry: loadingGeometry, + }, + zIncreaseDownwards, + }), + ), + ); + } else { + layers.push( + new ExtendedSimpleMeshLayer( + super.getSubLayerProps({ + id: "mesh", + data: [0], + mesh: geometry, + getPosition: [0, 0, 0], + getColor: [255, 255, 255, 255], + material: { ambient: 0.9, diffuse: 0.1, shininess: 0, specularColor: [0, 0, 0] }, + pickable: true, + _instanced: false, + opacity, + parameters: { + blend: true, + }, + }), + ), + ); + } + + return layers; + } +} diff --git a/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicFenceMeshLayer/_private/ExtendedSimpleMeshLayer.ts b/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicFenceMeshLayer/_private/ExtendedSimpleMeshLayer.ts new file mode 100644 index 0000000000..c462ecef34 --- /dev/null +++ b/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicFenceMeshLayer/_private/ExtendedSimpleMeshLayer.ts @@ -0,0 +1,45 @@ +import { SimpleMeshLayer } from "@deck.gl/mesh-layers"; +import { Model } from "@luma.gl/engine"; + +import fs from "./shaders/fragmentShader.glsl"; +import vs from "./shaders/vertexShader.glsl"; +export class ExtendedSimpleMeshLayer extends SimpleMeshLayer { + static layerName = "ExtendedSimpleMeshLayer"; + + initializeState(): void { + super.initializeState(); + const attributeManager = this.getAttributeManager()!; + // Removing this as we are not interested in instance picking colors (we have no instances). + // Otherwise, it will be added to the attribute manager and will be used in the shader occupying space + // in the fragment color output which we want to use for our own picking color. + + attributeManager.remove(["instancePickingColors"]); + } + + protected getModel(mesh: any): Model { + const model = new Model(this.context.device, { + ...this.getShaders(), + id: this.props.id, + bufferLayout: this.getAttributeManager()!.getBufferLayouts(), + geometry: mesh, + isInstanced: false, + }); + + const { texture } = this.props; + const { emptyTexture } = this.state; + const simpleMeshProps: any = { + sampler: (texture as any) || emptyTexture, + hasTexture: Boolean(texture), + }; + model.shaderInputs.setProps({ simpleMesh: simpleMeshProps }); + return model; + } + + getShaders() { + return { + ...super.getShaders(), + vs, + fs, + }; + } +} diff --git a/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicFenceMeshLayer/_private/shaders/fragmentShader.glsl b/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicFenceMeshLayer/_private/shaders/fragmentShader.glsl new file mode 100644 index 0000000000..500d271a07 --- /dev/null +++ b/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicFenceMeshLayer/_private/shaders/fragmentShader.glsl @@ -0,0 +1,41 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +#version 300 es +#define SHADER_NAME simple-mesh-layer-fs + +precision highp float; + +uniform sampler2D sampler; + +in vec2 vTexCoord; +in vec3 cameraPosition; +in vec3 normals_commonspace; +in vec4 position_commonspace; +in vec4 vColor; +in vec3 vPickingColor; + +out vec4 fragColor; + +void main(void) { + geometry.uv = vTexCoord; + + if(picking.isActive > 0.5 && !(picking.isAttribute > 0.5)) { + fragColor = vec4(vPickingColor, 1.0); + return; + } + + vec3 normal; + if(simpleMesh.flatShading) { + normal = normalize(cross(dFdx(position_commonspace.xyz), dFdy(position_commonspace.xyz))); + } else { + normal = normals_commonspace; + } + + vec4 color = simpleMesh.hasTexture ? texture(sampler, vTexCoord) : vColor; + DECKGL_FILTER_COLOR(color, geometry); + + vec3 lightColor = lighting_getLightColor(color.rgb, cameraPosition, position_commonspace.xyz, normal); + fragColor = vec4(lightColor, color.a * layer.opacity); +} \ No newline at end of file diff --git a/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicFenceMeshLayer/_private/shaders/vertexShader.glsl b/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicFenceMeshLayer/_private/shaders/vertexShader.glsl new file mode 100644 index 0000000000..2c4bad81f1 --- /dev/null +++ b/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicFenceMeshLayer/_private/shaders/vertexShader.glsl @@ -0,0 +1,66 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +#version 300 es +#define SHADER_NAME simple-mesh-layer-vs + +// Primitive attributes +in vec3 positions; +in vec3 normals; +in vec4 colors; +in vec2 texCoords; +in vec3 pickingColors; + +// Instance attributes +in vec3 instancePositions; +in vec3 instancePositions64Low; +in vec4 instanceColors; +in vec3 instanceModelMatrixCol0; +in vec3 instanceModelMatrixCol1; +in vec3 instanceModelMatrixCol2; +in vec3 instanceTranslation; + +// Outputs to fragment shader +out vec2 vTexCoord; +out vec3 cameraPosition; +out vec3 normals_commonspace; +out vec4 position_commonspace; +out vec4 vColor; +out vec3 vPickingColor; + +void main(void) { + geometry.worldPosition = instancePositions; + geometry.uv = texCoords; + geometry.pickingColor = pickingColors; + vPickingColor = pickingColors; + + vTexCoord = texCoords; + cameraPosition = project.cameraPosition; + vColor = colors; + + mat3 instanceModelMatrix = mat3(instanceModelMatrixCol0, instanceModelMatrixCol1, instanceModelMatrixCol2); + vec3 pos = (instanceModelMatrix * positions) * simpleMesh.sizeScale + instanceTranslation; + + if(simpleMesh.composeModelMatrix) { + DECKGL_FILTER_SIZE(pos, geometry); + // using instancePositions as world coordinates + // when using globe mode, this branch does not re-orient the model to align with the surface of the earth + // call project_normal before setting position to avoid rotation + normals_commonspace = project_normal(instanceModelMatrix * normals); + geometry.worldPosition += pos; + gl_Position = project_position_to_clipspace(pos + instancePositions, instancePositions64Low, vec3(0.0), position_commonspace); + geometry.position = position_commonspace; + } else { + pos = project_size(pos); + DECKGL_FILTER_SIZE(pos, geometry); + gl_Position = project_position_to_clipspace(instancePositions, instancePositions64Low, pos, position_commonspace); + geometry.position = position_commonspace; + normals_commonspace = project_normal(instanceModelMatrix * normals); + } + + geometry.normal = normals_commonspace; + DECKGL_FILTER_GL_POSITION(gl_Position, geometry); + + DECKGL_FILTER_COLOR(vColor, geometry); +} \ No newline at end of file diff --git a/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicFenceMeshLayer/_private/webworker/makeMesh.worker.ts b/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicFenceMeshLayer/_private/webworker/makeMesh.worker.ts new file mode 100644 index 0000000000..763451b20f --- /dev/null +++ b/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicFenceMeshLayer/_private/webworker/makeMesh.worker.ts @@ -0,0 +1,70 @@ +import { expose } from "comlink"; + +import * as vec3 from "@lib/utils/vec3"; + +import type { WebWorkerParameters } from "./types"; + +/** + * Generates a mesh for a seismic fence based on an array of XY points and a vertical sampling range. + * @param parameters The parameters for generating the mesh: + * - verticesArray: The Float32Array to be filled with vertex positions (x, y, z) + * - indicesArray: The Uint32Array to be filled with triangle indices + * - numSamples: The number of vertical samples (depth levels) + * - origin: The origin point in 3D space (x, y, z) + * - vVector: A 3D vector representing the vertical direction + * - traceXYPointsArray: A flat Float32Array of XY coordinate pairs representing the fence trace + * - zIncreasingDownwards: Whether the Z axis increases downwards (true for depth-based systems) + */ +function makeMesh(parameters: WebWorkerParameters) { + const { verticesArray, indicesArray, numSamples, vVector, traceXYZPointsArray, zIncreasingDownwards } = parameters; + + const numTraces = traceXYZPointsArray.length / 3; + + if (!Number.isInteger(numTraces)) { + throw new Error("traceXYZPointsArray must contain a multiple of 3 elements ([x, y, z] triplets)."); + } + + const zSign = zIncreasingDownwards ? -1 : 1; + const stepV = 1.0 / (numSamples - 1); + + let vertexIndex = 0; + let indexIndex = 0; + + for (let u = 0; u < numTraces; u++) { + for (let v = 0; v < numSamples; v++) { + const vec = vec3.scale(vec3.fromArray(vVector), stepV * v); + + const index = u * 3; + const x = traceXYZPointsArray[index] + vec.x; + const y = traceXYZPointsArray[index + 1] + vec.y; + const z = (traceXYZPointsArray[index + 2] + vec.z) * zSign; + + verticesArray[vertexIndex++] = x; + verticesArray[vertexIndex++] = y; + verticesArray[vertexIndex++] = z; + + if (u > 0 && v > 0) { + const rowStride = numSamples; + const i00 = (u - 1) * rowStride + (v - 1); + const i01 = u * rowStride + (v - 1); + const i10 = (u - 1) * rowStride + v; + const i11 = u * rowStride + v; + + indicesArray[indexIndex++] = i00; + indicesArray[indexIndex++] = i01; + indicesArray[indexIndex++] = i10; + + indicesArray[indexIndex++] = i10; + indicesArray[indexIndex++] = i01; + indicesArray[indexIndex++] = i11; + } + } + } + + return { + verticesArray, + indicesArray, + }; +} + +expose({ makeMesh }); diff --git a/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicFenceMeshLayer/_private/webworker/types.ts b/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicFenceMeshLayer/_private/webworker/types.ts new file mode 100644 index 0000000000..fb32624db5 --- /dev/null +++ b/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicFenceMeshLayer/_private/webworker/types.ts @@ -0,0 +1,13 @@ +export type WebWorkerParameters = { + verticesArray: Float32Array; + indicesArray: Uint32Array; + numSamples: number; + vVector: [number, number, number]; + traceXYZPointsArray: Float32Array; + zIncreasingDownwards: boolean; +}; + +export type WebworkerResult = { + verticesArray: Float32Array; + indicesArray: Uint32Array; +}; diff --git a/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicSlicesLayer.ts b/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicSlicesLayer.ts new file mode 100644 index 0000000000..dcde52f8eb --- /dev/null +++ b/frontend/src/modules/3DViewer/customDeckGlLayers/SeismicSlicesLayer.ts @@ -0,0 +1,102 @@ +import { CompositeLayer, type Layer, type UpdateParameters } from "@deck.gl/core"; +import type { BoundingBox3D, ReportBoundingBoxAction } from "@webviz/subsurface-viewer/dist/components/Map"; +import type { ExtendedLayerProps } from "@webviz/subsurface-viewer/dist/layers/utils/layerTools"; + +import type { Geometry } from "@lib/utils/geometry"; + +import { + SeismicFenceMeshLayer, + type SeismicFence, + type SeismicFenceMeshLayerProps, +} from "./SeismicFenceMeshLayer/SeismicFenceMeshLayer"; + +export type SeismicFenceWithId = { + id: string; + fence?: SeismicFence; + loadingGeometry?: Geometry; +}; + +export type SeismicSlicesLayerProps = ExtendedLayerProps & { + data: Array; + colorMapFunction: SeismicFenceMeshLayerProps["colorMapFunction"]; + zIncreaseDownwards?: boolean; + isLoading?: boolean; + + // Non-public property: + reportBoundingBox?: React.Dispatch; +}; + +export class SeismicSlicesLayer extends CompositeLayer { + static layerName = "SeismicSlicesLayer"; + + updateState({ changeFlags, props }: UpdateParameters): void { + if (props.reportBoundingBox && changeFlags.propsOrDataChanged) { + props.reportBoundingBox({ + layerBoundingBox: this.calcBoundingBox(), + }); + } + } + + renderLayers(): Layer[] { + const { data: sections, colorMapFunction, zIncreaseDownwards, isLoading } = this.props; + return sections.map((section) => { + return new SeismicFenceMeshLayer( + this.getSubLayerProps({ + id: section.id, + data: section.fence, + loadingGeometry: section.loadingGeometry, + colorMapFunction, + zIncreaseDownwards, + isLoading, + }), + ); + }); + } + + private calcBoundingBox(): BoundingBox3D { + const { data: sections, zIncreaseDownwards } = this.props; + + let xmin = Number.POSITIVE_INFINITY; + let ymin = Number.POSITIVE_INFINITY; + let zmin = Number.POSITIVE_INFINITY; + let xmax = Number.NEGATIVE_INFINITY; + let ymax = Number.NEGATIVE_INFINITY; + let zmax = Number.NEGATIVE_INFINITY; + + const zSign = zIncreaseDownwards ? -1 : 1; + + for (const section of sections) { + if (!section.fence || !section.fence.traceXYZPointsArray) { + if (section.loadingGeometry) { + // If the fence is not loaded, we can use the loading geometry to calculate the bounding box. + const { boundingBox } = section.loadingGeometry; + xmin = Math.min(xmin, boundingBox.min.x); + ymin = Math.min(ymin, boundingBox.min.y); + zmin = Math.min(zmin, boundingBox.min.z * zSign); + xmax = Math.max(xmax, boundingBox.max.x); + ymax = Math.max(ymax, boundingBox.max.y); + zmax = Math.max(zmax, boundingBox.max.z * zSign); + } + continue; + } + const { traceXYZPointsArray } = section.fence; + + for (let i = 0; i < traceXYZPointsArray.length; i += 3) { + const sectionXMin = traceXYZPointsArray[i]; + const sectionXMax = sectionXMin + section.fence.vVector[0]; + const sectionYMin = traceXYZPointsArray[i + 1]; + const sectionYMax = traceXYZPointsArray[i + 1] + section.fence.vVector[1]; + const sectionZMin = zSign * traceXYZPointsArray[i + 2]; + const sectionZMax = zSign * traceXYZPointsArray[i + 2] + section.fence.vVector[2]; + xmin = Math.min(xmin, sectionXMin, sectionXMax); + ymin = Math.min(ymin, sectionYMin, sectionYMax); + zmin = Math.min(zmin, sectionZMin, sectionZMax); + xmax = Math.max(xmax, sectionXMin, sectionXMax); + ymax = Math.max(ymax, sectionYMin, sectionYMax); + zmax = Math.max(zmax, sectionZMin, sectionZMax); + } + } + + return [xmin, ymin, zmin, xmax, ymax, zmax]; + } +} diff --git a/frontend/src/modules/3DViewer/customDeckGlLayers/WellborePicksLayer.ts b/frontend/src/modules/3DViewer/customDeckGlLayers/WellborePicksLayer.ts new file mode 100644 index 0000000000..1bb9993c7b --- /dev/null +++ b/frontend/src/modules/3DViewer/customDeckGlLayers/WellborePicksLayer.ts @@ -0,0 +1,121 @@ +import type { CompositeLayerProps, FilterContext, Layer, UpdateParameters } from "@deck.gl/core"; +import { CompositeLayer } from "@deck.gl/core"; +import { GeoJsonLayer, TextLayer } from "@deck.gl/layers"; +import type { Feature, FeatureCollection } from "geojson"; + +export type WellborePicksLayerData = { + easting: number; + northing: number; + wellBoreUwi: string; + tvdMsl: number; + md: number; + slotName: string; +}; + +type TextLayerData = { + coordinates: [number, number, number]; + name: string; +}; + +export type WellBorePicksLayerProps = { + id: string; + data: WellborePicksLayerData[]; +}; + +export class WellborePicksLayer extends CompositeLayer { + static layerName: string = "WellborePicksLayer"; + private _textData: TextLayerData[] = []; + private _pointsData: FeatureCollection | null = null; + + filterSubLayer(context: FilterContext): boolean { + if (context.layer.id.includes("text")) { + return context.viewport.zoom > -4; + } + + return true; + } + + updateState(params: UpdateParameters>>): void { + const features: Feature[] = params.props.data.map((wellPick) => { + return { + type: "Feature", + geometry: { + type: "Point", + coordinates: [wellPick.easting, wellPick.northing], + }, + properties: { + name: `${wellPick.wellBoreUwi}, TVD_MSL: ${wellPick.tvdMsl}, MD: ${wellPick.md}`, + color: [100, 100, 100, 100], + }, + }; + }); + + const pointsData: FeatureCollection = { + type: "FeatureCollection", + features: features, + }; + + const textData: TextLayerData[] = this.props.data.map((wellPick) => { + return { + coordinates: [wellPick.easting, wellPick.northing, wellPick.tvdMsl], + name: wellPick.wellBoreUwi, + }; + }); + + this._pointsData = pointsData; + this._textData = textData; + } + + renderLayers() { + const fontSize = 16; + const sizeMinPixels = 16; + const sizeMaxPixels = 16; + + return [ + new GeoJsonLayer( + this.getSubLayerProps({ + id: "points", + data: this._pointsData ?? undefined, + filled: true, + lineWidthMinPixels: 5, + lineWidthMaxPixels: 5, + lineWidthUnits: "meters", + parameters: { + depthTest: false, + }, + getLineWidth: 1, + depthTest: false, + pickable: true, + getText: (d: Feature) => d.properties?.wellBoreUwi, + getLineColor: [50, 50, 50], + }), + ), + + new TextLayer( + this.getSubLayerProps({ + id: "text", + data: this._textData, + pickable: true, + getColor: [255, 255, 255], + fontWeight: 800, + fontSettings: { + fontSize: fontSize * 2, + sdf: true, + }, + outlineColor: [0, 0, 0], + outlineWidth: 2, + getSize: 12, + sdf: true, + sizeScale: fontSize, + sizeUnits: "meters", + sizeMinPixels: sizeMinPixels, + sizeMaxPixels: sizeMaxPixels, + getAlignmentBaseline: "top", + getTextAnchor: "middle", + getPosition: (d: TextLayerData) => d.coordinates, + getText: (d: TextLayerData) => d.name, + }), + ), + ]; + } +} diff --git a/frontend/src/modules/3DViewer/interfaces.ts b/frontend/src/modules/3DViewer/interfaces.ts index 018fa18ae0..69638dac1e 100644 --- a/frontend/src/modules/3DViewer/interfaces.ts +++ b/frontend/src/modules/3DViewer/interfaces.ts @@ -1,135 +1,29 @@ -import type { BoundingBox3D_api } from "@api"; -import type { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; -import type { IntersectionType } from "@framework/types/intersection"; import type { InterfaceInitialization } from "@framework/UniDirectionalModuleComponentsInterface"; -import type { ColorScale } from "@lib/utils/ColorScale"; -import { - addCustomIntersectionPolylineEditModeActiveAtom, - colorScaleAtom, - editCustomIntersectionPolylineEditModeActiveAtom, - gridLayerAtom, - intersectionExtensionLengthAtom, - intersectionTypeAtom, - showGridlinesAtom, - showIntersectionAtom, - useCustomBoundsAtom, -} from "./settings/atoms/baseAtoms"; -import { - selectedCustomIntersectionPolylineIdAtom, - selectedEnsembleIdentAtom, - selectedGridCellIndexRangesAtom, - selectedGridModelBoundingBox3dAtom, - selectedGridModelNameAtom, - selectedGridModelParameterDateOrIntervalAtom, - selectedGridModelParameterNameAtom, - selectedHighlightedWellboreUuidAtom, - selectedRealizationAtom, - selectedWellboreUuidsAtom, -} from "./settings/atoms/derivedAtoms"; -import type { GridCellIndexRanges } from "./typesAndEnums"; -import { - editCustomIntersectionPolylineEditModeActiveAtom as viewEditCustomIntersectionPolylineEditModeActiveAtom, - intersectionTypeAtom as viewIntersectionTypeAtom, -} from "./view/atoms/baseAtoms"; +import type { DataProviderManager } from "../_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager"; -export type SettingsToViewInterface = { - ensembleIdent: RegularEnsembleIdent | null; - highlightedWellboreUuid: string | null; - customIntersectionPolylineId: string | null; - intersectionType: IntersectionType; - addCustomIntersectionPolylineEditModeActive: boolean; - editCustomIntersectionPolylineEditModeActive: boolean; - showGridlines: boolean; - showIntersection: boolean; - gridLayer: number; - intersectionExtensionLength: number; - colorScale: ColorScale | null; - useCustomBounds: boolean; - realization: number | null; - wellboreUuids: string[]; - gridModelName: string | null; - gridModelBoundingBox3d: BoundingBox3D_api | null; - gridModelParameterName: string | null; - gridModelParameterDateOrInterval: string | null; - gridCellIndexRanges: GridCellIndexRanges; -}; +import { dataProviderManagerAtom, preferredViewLayoutAtom } from "./settings/atoms/baseAtoms"; +import { selectedFieldIdentifierAtom } from "./settings/atoms/derivedAtoms"; +import type { PreferredViewLayout } from "./types"; -export type ViewToSettingsInterface = { - editCustomIntersectionPolylineEditModeActive: boolean; - intersectionType: IntersectionType; +export type SettingsToViewInterface = { + fieldId: string | null; + layerManager: DataProviderManager | null; + preferredViewLayout: PreferredViewLayout; }; export type Interfaces = { settingsToView: SettingsToViewInterface; - viewToSettings: ViewToSettingsInterface; }; export const settingsToViewInterfaceInitialization: InterfaceInitialization = { - ensembleIdent: (get) => { - return get(selectedEnsembleIdentAtom); - }, - highlightedWellboreUuid: (get) => { - return get(selectedHighlightedWellboreUuidAtom); - }, - customIntersectionPolylineId: (get) => { - return get(selectedCustomIntersectionPolylineIdAtom); - }, - intersectionType: (get) => { - return get(intersectionTypeAtom); - }, - addCustomIntersectionPolylineEditModeActive: (get) => { - return get(addCustomIntersectionPolylineEditModeActiveAtom); - }, - editCustomIntersectionPolylineEditModeActive: (get) => { - return get(editCustomIntersectionPolylineEditModeActiveAtom); - }, - showGridlines: (get) => { - return get(showGridlinesAtom); - }, - showIntersection: (get) => { - return get(showIntersectionAtom); - }, - gridLayer: (get) => { - return get(gridLayerAtom); - }, - intersectionExtensionLength: (get) => { - return get(intersectionExtensionLengthAtom); + fieldId: (get) => { + return get(selectedFieldIdentifierAtom); }, - colorScale: (get) => { - return get(colorScaleAtom); - }, - useCustomBounds: (get) => { - return get(useCustomBoundsAtom); - }, - realization: (get) => { - return get(selectedRealizationAtom); - }, - wellboreUuids: (get) => { - return get(selectedWellboreUuidsAtom); - }, - gridModelName: (get) => { - return get(selectedGridModelNameAtom); - }, - gridModelBoundingBox3d: (get) => { - return get(selectedGridModelBoundingBox3dAtom); - }, - gridModelParameterName: (get) => { - return get(selectedGridModelParameterNameAtom); - }, - gridModelParameterDateOrInterval: (get) => { - return get(selectedGridModelParameterDateOrIntervalAtom); - }, - gridCellIndexRanges: (get) => { - return get(selectedGridCellIndexRangesAtom); - }, -}; - -export const viewToSettingsInterfaceInitialization: InterfaceInitialization = { - editCustomIntersectionPolylineEditModeActive: (get) => { - return get(viewEditCustomIntersectionPolylineEditModeActiveAtom); + layerManager: (get) => { + return get(dataProviderManagerAtom); }, - intersectionType: (get) => { - return get(viewIntersectionTypeAtom); + preferredViewLayout: (get) => { + return get(preferredViewLayoutAtom); }, }; diff --git a/frontend/src/modules/3DViewer/loadModule.tsx b/frontend/src/modules/3DViewer/loadModule.tsx index 3d1b4e6146..f176a05357 100644 --- a/frontend/src/modules/3DViewer/loadModule.tsx +++ b/frontend/src/modules/3DViewer/loadModule.tsx @@ -1,18 +1,13 @@ import { ModuleRegistry } from "@framework/ModuleRegistry"; import type { Interfaces } from "./interfaces"; -import { settingsToViewInterfaceInitialization, viewToSettingsInterfaceInitialization } from "./interfaces"; +import { settingsToViewInterfaceInitialization } from "./interfaces"; import { MODULE_NAME } from "./registerModule"; -import { viewToSettingsInterfaceEffects } from "./settings/atoms/interfaceEffects"; import { Settings } from "./settings/settings"; -import { settingsToViewInterfaceEffects } from "./view/atoms/interfaceEffects"; import { View } from "./view/view"; const module = ModuleRegistry.initModule(MODULE_NAME, { settingsToViewInterfaceInitialization, - viewToSettingsInterfaceInitialization, - viewToSettingsInterfaceEffects, - settingsToViewInterfaceEffects, }); module.viewFC = View; diff --git a/frontend/src/modules/3DViewer/preview.webp b/frontend/src/modules/3DViewer/preview.webp deleted file mode 100644 index d180acb38d..0000000000 Binary files a/frontend/src/modules/3DViewer/preview.webp and /dev/null differ diff --git a/frontend/src/modules/3DViewer/registerModule.ts b/frontend/src/modules/3DViewer/registerModule.ts index e5c395c979..aad4ff3d6d 100644 --- a/frontend/src/modules/3DViewer/registerModule.ts +++ b/frontend/src/modules/3DViewer/registerModule.ts @@ -6,6 +6,8 @@ import { SyncSettingKey } from "@framework/SyncSettings"; import type { Interfaces } from "./interfaces"; import { preview } from "./preview"; +import "./DataProviderFramework/registerAllDataProviders"; + export const MODULE_NAME = "3DViewer"; const description = "Generic 3D viewer for grid, surfaces, and wells."; @@ -15,8 +17,17 @@ ModuleRegistry.registerModule({ defaultTitle: "3D Viewer", category: ModuleCategory.MAIN, devState: ModuleDevState.DEV, - dataTagIds: [ModuleDataTagId.GRID3D, ModuleDataTagId.DRILLED_WELLS], description, preview, + dataTagIds: [ + ModuleDataTagId.SURFACE, + ModuleDataTagId.DRILLED_WELLS, + ModuleDataTagId.SEISMIC, + ModuleDataTagId.GRID3D, + ModuleDataTagId.POLYGONS, + ], syncableSettingKeys: [SyncSettingKey.ENSEMBLE, SyncSettingKey.INTERSECTION, SyncSettingKey.VERTICAL_SCALE], + onInstanceUnload: (instanceId) => { + window.localStorage.removeItem(`${instanceId}-settings`); + }, }); diff --git a/frontend/src/modules/3DViewer/settings/atoms/baseAtoms.ts b/frontend/src/modules/3DViewer/settings/atoms/baseAtoms.ts index c41afe31d3..dc0cd6c73d 100644 --- a/frontend/src/modules/3DViewer/settings/atoms/baseAtoms.ts +++ b/frontend/src/modules/3DViewer/settings/atoms/baseAtoms.ts @@ -1,28 +1,8 @@ import { atom } from "jotai"; -import type { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; -import { IntersectionType } from "@framework/types/intersection"; -import type { ColorScale } from "@lib/utils/ColorScale"; -import type { GridCellIndexRanges } from "@modules/3DViewer/typesAndEnums"; +import { PreferredViewLayout } from "@modules/3DViewer/types"; +import type { DataProviderManager } from "@modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager"; - -export const showGridlinesAtom = atom(false); -export const showIntersectionAtom = atom(false); -export const gridLayerAtom = atom(1); -export const intersectionExtensionLengthAtom = atom(1000); -export const colorScaleAtom = atom(null); -export const useCustomBoundsAtom = atom(false); -export const intersectionTypeAtom = atom(IntersectionType.WELLBORE); -export const addCustomIntersectionPolylineEditModeActiveAtom = atom(false); -export const editCustomIntersectionPolylineEditModeActiveAtom = atom(false); -export const currentCustomIntersectionPolylineAtom = atom([]); - -export const userSelectedEnsembleIdentAtom = atom(null); -export const userSelectedRealizationAtom = atom(null); -export const userSelectedGridModelNameAtom = atom(null); -export const userSelectedGridModelParameterNameAtom = atom(null); -export const userSelectedGridModelParameterDateOrIntervalAtom = atom(null); -export const userSelectedWellboreUuidsAtom = atom([]); -export const userSelectedHighlightedWellboreUuidAtom = atom(null); -export const userSelectedCustomIntersectionPolylineIdAtom = atom(null); -export const userSelectedGridCellIndexRangesAtom = atom(null); +export const userSelectedFieldIdentifierAtom = atom(null); +export const dataProviderManagerAtom = atom(null); +export const preferredViewLayoutAtom = atom(PreferredViewLayout.VERTICAL); diff --git a/frontend/src/modules/3DViewer/settings/atoms/derivedAtoms.ts b/frontend/src/modules/3DViewer/settings/atoms/derivedAtoms.ts index ded80d0bce..cc8874acaa 100644 --- a/frontend/src/modules/3DViewer/settings/atoms/derivedAtoms.ts +++ b/frontend/src/modules/3DViewer/settings/atoms/derivedAtoms.ts @@ -1,287 +1,20 @@ import { atom } from "jotai"; -import type { Grid3dDimensions_api } from "@api"; -import { EnsembleSetAtom, ValidEnsembleRealizationsFunctionAtom } from "@framework/GlobalAtoms"; -import type { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; -import { IntersectionPolylinesAtom } from "@framework/userCreatedItems/IntersectionPolylines"; -import type { GridCellIndexRanges } from "@modules/3DViewer/typesAndEnums"; +import { EnsembleSetAtom } from "@framework/GlobalAtoms"; -import { - userSelectedCustomIntersectionPolylineIdAtom, - userSelectedEnsembleIdentAtom, - userSelectedGridCellIndexRangesAtom, - userSelectedGridModelNameAtom, - userSelectedGridModelParameterDateOrIntervalAtom, - userSelectedGridModelParameterNameAtom, - userSelectedHighlightedWellboreUuidAtom, - userSelectedRealizationAtom, - userSelectedWellboreUuidsAtom, -} from "./baseAtoms"; -import { drilledWellboreHeadersQueryAtom, gridModelInfosQueryAtom } from "./queryAtoms"; +import { userSelectedFieldIdentifierAtom } from "./baseAtoms"; -export const selectedEnsembleIdentAtom = atom((get) => { +export const selectedFieldIdentifierAtom = atom((get) => { const ensembleSet = get(EnsembleSetAtom); - const userSelectedEnsembleIdent = get(userSelectedEnsembleIdentAtom); - - if (userSelectedEnsembleIdent === null || !ensembleSet.hasEnsemble(userSelectedEnsembleIdent)) { - return ensembleSet.getRegularEnsembleArray()[0]?.getIdent() || null; - } - - return userSelectedEnsembleIdent; -}); - -export const selectedHighlightedWellboreUuidAtom = atom((get) => { - const userSelectedHighlightedWellboreUuid = get(userSelectedHighlightedWellboreUuidAtom); - const wellboreHeaders = get(drilledWellboreHeadersQueryAtom); - - if (!wellboreHeaders.data) { - return null; - } - - if ( - !userSelectedHighlightedWellboreUuid || - !wellboreHeaders.data.some((el) => el.wellboreUuid === userSelectedHighlightedWellboreUuid) - ) { - return wellboreHeaders.data[0]?.wellboreUuid ?? null; - } - - return userSelectedHighlightedWellboreUuid; -}); - -export const selectedCustomIntersectionPolylineIdAtom = atom((get) => { - const userSelectedCustomIntersectionPolylineId = get(userSelectedCustomIntersectionPolylineIdAtom); - const customIntersectionPolylines = get(IntersectionPolylinesAtom); - - if (!customIntersectionPolylines.length) { - return null; - } - - if ( - !userSelectedCustomIntersectionPolylineId || - !customIntersectionPolylines.some((el) => el.id === userSelectedCustomIntersectionPolylineId) - ) { - return customIntersectionPolylines[0].id; - } - - return userSelectedCustomIntersectionPolylineId; -}); - -export const availableRealizationsAtom = atom((get) => { - const selectedEnsembleIdent = get(selectedEnsembleIdentAtom); - - if (selectedEnsembleIdent === null) { - return []; - } - - const validEnsembleRealizationsFunction = get(ValidEnsembleRealizationsFunctionAtom); - return validEnsembleRealizationsFunction(selectedEnsembleIdent); -}); - -export const selectedRealizationAtom = atom((get) => { - const realizations = get(availableRealizationsAtom); - const userSelectedRealization = get(userSelectedRealizationAtom); - - if (userSelectedRealization === null || !realizations.includes(userSelectedRealization)) { - return realizations.at(0) ?? null; - } - - return userSelectedRealization; -}); - -export const selectedWellboreUuidsAtom = atom((get) => { - const userSelectedWellboreUuids = get(userSelectedWellboreUuidsAtom); - const wellboreHeaders = get(drilledWellboreHeadersQueryAtom); - - if (!wellboreHeaders.data) { - return []; - } - - return userSelectedWellboreUuids.filter((uuid) => wellboreHeaders.data.some((el) => el.wellboreUuid === uuid)); -}); -export const selectedGridModelNameAtom = atom((get) => { - const gridModelInfos = get(gridModelInfosQueryAtom); - const userSelectedGridModelName = get(userSelectedGridModelNameAtom); - - if (!gridModelInfos.data) { - return null; - } - - if ( - userSelectedGridModelName === null || - !gridModelInfos.data.map((gridModelInfo) => gridModelInfo.grid_name).includes(userSelectedGridModelName) - ) { - return gridModelInfos.data[0]?.grid_name || null; - } - - return userSelectedGridModelName; -}); - -export const gridModelDimensionsAtom = atom((get) => { - const gridModelInfos = get(gridModelInfosQueryAtom); - const selectedGridModelName = get(selectedGridModelNameAtom); - - if (!gridModelInfos.data) { - return null; - } - - if (!selectedGridModelName) { - return null; - } - - return ( - gridModelInfos.data.find((gridModelInfo) => gridModelInfo.grid_name === selectedGridModelName)?.dimensions ?? - null - ); -}); - -export const selectedGridModelBoundingBox3dAtom = atom((get) => { - const gridModelInfos = get(gridModelInfosQueryAtom); - const selectedGridModelName = get(selectedGridModelNameAtom); - - if (!gridModelInfos.data) { - return null; - } - - if (!selectedGridModelName) { - return null; - } - - return gridModelInfos.data.find((gridModelInfo) => gridModelInfo.grid_name === selectedGridModelName)?.bbox ?? null; -}); - -export const selectedGridModelParameterNameAtom = atom((get) => { - const gridModelInfos = get(gridModelInfosQueryAtom); - const userSelectedGridModelParameterName = get(userSelectedGridModelParameterNameAtom); - const selectedGridModelName = get(selectedGridModelNameAtom); - - if (!gridModelInfos.data) { - return null; - } - - if (!selectedGridModelName) { - return null; - } - - if ( - userSelectedGridModelParameterName === null || - !gridModelInfos.data - .find((gridModelInfo) => gridModelInfo.grid_name === selectedGridModelName) - ?.property_info_arr.some( - (propertyInfo) => propertyInfo.property_name === userSelectedGridModelParameterName, - ) - ) { - return ( - gridModelInfos.data.find((gridModelInfo) => gridModelInfo.grid_name === selectedGridModelName) - ?.property_info_arr[0]?.property_name || null - ); - } - - return userSelectedGridModelParameterName; -}); - -export const selectedGridModelParameterDateOrIntervalAtom = atom((get) => { - const gridModelInfos = get(gridModelInfosQueryAtom); - const selectedGridModelParameterName = get(selectedGridModelParameterNameAtom); - const selectedGridModelName = get(selectedGridModelNameAtom); - const userSelectedGridModelParameterDateOrInterval = get(userSelectedGridModelParameterDateOrIntervalAtom); - - if (!gridModelInfos.data) { - return null; - } - - if (!selectedGridModelName || !selectedGridModelParameterName) { - return null; - } + const userSelectedField = get(userSelectedFieldIdentifierAtom); if ( - userSelectedGridModelParameterDateOrInterval === null || - !gridModelInfos.data - .find((gridModelInfo) => gridModelInfo.grid_name === selectedGridModelName) - ?.property_info_arr.some( - (propertyInfo) => - propertyInfo.property_name === selectedGridModelParameterName && - propertyInfo.iso_date_or_interval === userSelectedGridModelParameterDateOrInterval, - ) + !userSelectedField || + !ensembleSet.getRegularEnsembleArray().some((ens) => ens.getFieldIdentifier() === userSelectedField) ) { - return ( - gridModelInfos.data - .find((gridModelInfo) => gridModelInfo.grid_name === selectedGridModelName) - ?.property_info_arr.find( - (propertyInfo) => propertyInfo.property_name === selectedGridModelParameterName, - )?.iso_date_or_interval || null - ); + return ensembleSet.getRegularEnsembleArray().at(0)?.getFieldIdentifier() ?? null; } - return userSelectedGridModelParameterDateOrInterval; + return userSelectedField; }); - -export const availableUserCreatedIntersectionPolylinesAtom = atom((get) => { - const intersectionPolylines = get(IntersectionPolylinesAtom); - return intersectionPolylines; -}); - -export const selectedGridCellIndexRangesAtom = atom((get) => { - const userSelectedGridCellIndexRanges = get(userSelectedGridCellIndexRangesAtom); - const gridModelDimensions = get(gridModelDimensionsAtom); - - if (!gridModelDimensions && !userSelectedGridCellIndexRanges) { - return { - i: [0, 1], - j: [0, 1], - k: [0, 1], - }; - } - - if (!gridModelDimensions && userSelectedGridCellIndexRanges) { - return userSelectedGridCellIndexRanges; - } - - if (gridModelDimensions && !userSelectedGridCellIndexRanges) { - return { - i: [0, gridModelDimensions.i_count], - j: [0, gridModelDimensions.j_count], - k: [0, gridModelDimensions.k_count], - }; - } - - return assertGridDimensionRangesContainedInGridDimensions( - userSelectedGridCellIndexRanges as GridCellIndexRanges, - gridModelDimensions as Grid3dDimensions_api, - ); -}); - -function assertGridDimensionRangesContainedInGridDimensions( - cellIndexRanges: GridCellIndexRanges, - other: Grid3dDimensions_api, -): GridCellIndexRanges { - const assertedGridDimensionRanges: GridCellIndexRanges = { - ...cellIndexRanges, - }; - - if (other.i_count < cellIndexRanges.i[1]) { - assertedGridDimensionRanges.i[1] = other.i_count; - } - - if (cellIndexRanges.i[0] > assertedGridDimensionRanges.i[1]) { - assertedGridDimensionRanges.i[0] = assertedGridDimensionRanges.i[1]; - } - - if (other.j_count < cellIndexRanges.j[1]) { - assertedGridDimensionRanges.j[1] = other.j_count; - } - - if (cellIndexRanges.j[0] > assertedGridDimensionRanges.j[1]) { - assertedGridDimensionRanges.j[0] = assertedGridDimensionRanges.j[1]; - } - - if (other.k_count < cellIndexRanges.k[1]) { - assertedGridDimensionRanges.k[1] = other.k_count; - } - - if (cellIndexRanges.k[0] > assertedGridDimensionRanges.k[1]) { - assertedGridDimensionRanges.k[0] = assertedGridDimensionRanges.k[1]; - } - - return assertedGridDimensionRanges; -} diff --git a/frontend/src/modules/3DViewer/settings/atoms/interfaceEffects.ts b/frontend/src/modules/3DViewer/settings/atoms/interfaceEffects.ts deleted file mode 100644 index 748aa87ea1..0000000000 --- a/frontend/src/modules/3DViewer/settings/atoms/interfaceEffects.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { InterfaceEffects } from "@framework/Module"; -import type { ViewToSettingsInterface } from "@modules/3DViewer/interfaces"; - -import { editCustomIntersectionPolylineEditModeActiveAtom, intersectionTypeAtom } from "./baseAtoms"; - -export const viewToSettingsInterfaceEffects: InterfaceEffects = [ - (getInterfaceValue, setAtomValue) => { - const editCustomIntersectionPolylineEditModeActive = getInterfaceValue( - "editCustomIntersectionPolylineEditModeActive", - ); - setAtomValue(editCustomIntersectionPolylineEditModeActiveAtom, editCustomIntersectionPolylineEditModeActive); - }, - (getInterfaceValue, setAtomValue) => { - const viewIntersectionType = getInterfaceValue("intersectionType"); - setAtomValue(intersectionTypeAtom, viewIntersectionType); - }, -]; diff --git a/frontend/src/modules/3DViewer/settings/atoms/queryAtoms.ts b/frontend/src/modules/3DViewer/settings/atoms/queryAtoms.ts deleted file mode 100644 index c04b193e0f..0000000000 --- a/frontend/src/modules/3DViewer/settings/atoms/queryAtoms.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { atomWithQuery } from "jotai-tanstack-query"; - -import { getDrilledWellboreHeadersOptions, getGridModelsInfoOptions } from "@api"; -import { EnsembleSetAtom } from "@framework/GlobalAtoms"; - - -import { selectedEnsembleIdentAtom, selectedRealizationAtom } from "./derivedAtoms"; - -export const gridModelInfosQueryAtom = atomWithQuery((get) => { - const ensembleIdent = get(selectedEnsembleIdentAtom); - const realizationNumber = get(selectedRealizationAtom); - - const caseUuid = ensembleIdent?.getCaseUuid() ?? ""; - const ensembleName = ensembleIdent?.getEnsembleName() ?? ""; - - return { - ...getGridModelsInfoOptions({ - query: { - case_uuid: caseUuid, - ensemble_name: ensembleName, - realization_num: realizationNumber ?? 0, - }, - }), - enabled: Boolean(caseUuid && ensembleName && realizationNumber !== null), - }; -}); - -export const drilledWellboreHeadersQueryAtom = atomWithQuery((get) => { - const ensembleIdent = get(selectedEnsembleIdentAtom); - const ensembleSet = get(EnsembleSetAtom); - - let fieldIdentifier: string | null = null; - if (ensembleIdent) { - const ensemble = ensembleSet.findEnsemble(ensembleIdent); - if (ensemble) { - fieldIdentifier = ensemble.getFieldIdentifier(); - } - } - - return { - ...getDrilledWellboreHeadersOptions({ - query: { - field_identifier: fieldIdentifier ?? "", - }, - }), - enabled: Boolean(fieldIdentifier), - }; -}); diff --git a/frontend/src/modules/3DViewer/settings/components/dataProviderManagerWrapper.tsx b/frontend/src/modules/3DViewer/settings/components/dataProviderManagerWrapper.tsx new file mode 100644 index 0000000000..8e1cb5ca45 --- /dev/null +++ b/frontend/src/modules/3DViewer/settings/components/dataProviderManagerWrapper.tsx @@ -0,0 +1,414 @@ +import type React from "react"; + +import { Icon } from "@equinor/eds-core-react"; +import { color_palette, fault, grid_layer, settings, surface_layer, timeline, wellbore } from "@equinor/eds-icons"; +import { Dropdown } from "@mui/base"; +import { + Check, + Panorama, + SettingsApplications, + Settings as SettingsIcon, + TableRowsOutlined, + ViewColumnOutlined, +} from "@mui/icons-material"; +import { useAtom } from "jotai"; + +import type { WorkbenchSession } from "@framework/WorkbenchSession"; +import type { WorkbenchSettings } from "@framework/WorkbenchSettings"; +import { Menu } from "@lib/components/Menu"; +import { MenuButton } from "@lib/components/MenuButton"; +import { MenuHeading } from "@lib/components/MenuHeading"; +import { MenuItem } from "@lib/components/MenuItem"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; +import { CustomDataProviderType } from "@modules/3DViewer/DataProviderFramework/customDataProviderTypes"; +import { PreferredViewLayout } from "@modules/3DViewer/types"; +import type { ActionGroup } from "@modules/_shared/DataProviderFramework/Actions"; +import { DataProviderRegistry } from "@modules/_shared/DataProviderFramework/dataProviders/DataProviderRegistry"; +import { DataProviderType } from "@modules/_shared/DataProviderFramework/dataProviders/dataProviderTypes"; +import { RealizationSurfaceProvider } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/RealizationSurfaceProvider"; +import { StatisticalSurfaceProvider } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/StatisticalSurfaceProvider"; +import type { GroupDelegate } from "@modules/_shared/DataProviderFramework/delegates/GroupDelegate"; +import { GroupDelegateTopic } from "@modules/_shared/DataProviderFramework/delegates/GroupDelegate"; +import { DataProvider } from "@modules/_shared/DataProviderFramework/framework/DataProvider/DataProvider"; +import type { DataProviderManager } from "@modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager"; +import { DataProviderManagerComponent } from "@modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManagerComponent"; +import { DeltaSurface } from "@modules/_shared/DataProviderFramework/framework/DeltaSurface/DeltaSurface"; +import { Group } from "@modules/_shared/DataProviderFramework/framework/Group/Group"; +import { SettingsGroup } from "@modules/_shared/DataProviderFramework/framework/SettingsGroup/SettingsGroup"; +import { SharedSetting } from "@modules/_shared/DataProviderFramework/framework/SharedSetting/SharedSetting"; +import { GroupRegistry } from "@modules/_shared/DataProviderFramework/groups/GroupRegistry"; +import { GroupType } from "@modules/_shared/DataProviderFramework/groups/groupTypes"; +import type { Item, ItemGroup } from "@modules/_shared/DataProviderFramework/interfacesAndTypes/entities"; +import { instanceofItemGroup } from "@modules/_shared/DataProviderFramework/interfacesAndTypes/entities"; +import { Setting } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions"; + +import { preferredViewLayoutAtom } from "../atoms/baseAtoms"; + +export type LayerManagerComponentWrapperProps = { + dataProviderManager: DataProviderManager; + workbenchSession: WorkbenchSession; + workbenchSettings: WorkbenchSettings; +}; + +export function DataProviderManagerWrapper(props: LayerManagerComponentWrapperProps): React.ReactNode { + const colorSet = props.workbenchSettings.useColorSet(); + const [preferredViewLayout, setPreferredViewLayout] = useAtom(preferredViewLayoutAtom); + + const groupDelegate = props.dataProviderManager.getGroupDelegate(); + usePublishSubscribeTopicValue(groupDelegate, GroupDelegateTopic.CHILDREN); + + function handleLayerAction(identifier: string, groupDelegate: GroupDelegate) { + switch (identifier) { + case "view": + groupDelegate.appendChild( + GroupRegistry.makeGroup(GroupType.VIEW, props.dataProviderManager, colorSet.getNextColor()), + ); + return; + case "delta-surface": + groupDelegate.prependChild(new DeltaSurface("Delta surface", props.dataProviderManager)); + return; + case "settings-group": + groupDelegate.prependChild(new SettingsGroup("Settings group", props.dataProviderManager)); + return; + case "color-scale": + groupDelegate.prependChild(new SharedSetting(Setting.COLOR_SCALE, null, props.dataProviderManager)); + return; + case "realization-surface": + groupDelegate.prependChild( + DataProviderRegistry.makeDataProvider( + DataProviderType.REALIZATION_SURFACE_3D, + props.dataProviderManager, + ), + ); + return; + case "realization-polygons": + groupDelegate.prependChild( + DataProviderRegistry.makeDataProvider( + DataProviderType.REALIZATION_POLYGONS, + props.dataProviderManager, + ), + ); + return; + case "drilled-wellbore-trajectories": + groupDelegate.prependChild( + DataProviderRegistry.makeDataProvider( + DataProviderType.DRILLED_WELL_TRAJECTORIES, + props.dataProviderManager, + ), + ); + return; + case "drilled-wellbore-picks": + groupDelegate.prependChild( + DataProviderRegistry.makeDataProvider( + DataProviderType.DRILLED_WELLBORE_PICKS, + props.dataProviderManager, + ), + ); + return; + case "realization-grid": + groupDelegate.prependChild( + DataProviderRegistry.makeDataProvider( + CustomDataProviderType.REALIZATION_GRID_3D, + props.dataProviderManager, + ), + ); + return; + case "realization-seismic-slices": + groupDelegate.prependChild( + DataProviderRegistry.makeDataProvider( + CustomDataProviderType.REALIZATION_SEISMIC_SLICES, + props.dataProviderManager, + ), + ); + return; + case "simulated-seismic-fence": + groupDelegate.prependChild( + DataProviderRegistry.makeDataProvider( + DataProviderType.INTERSECTION_REALIZATION_SIMULATED_SEISMIC, + props.dataProviderManager, + "Seismic fence (simulated)", + ), + ); + return; + case "observed-seismic-fence": + groupDelegate.prependChild( + DataProviderRegistry.makeDataProvider( + DataProviderType.INTERSECTION_REALIZATION_OBSERVED_SEISMIC, + props.dataProviderManager, + "Seismic fence (observed)", + ), + ); + return; + case "intersection-realization-grid": + groupDelegate.prependChild( + DataProviderRegistry.makeDataProvider( + DataProviderType.INTERSECTION_WITH_WELLBORE_EXTENSION_REALIZATION_GRID, + props.dataProviderManager, + "Intersection Grid", + ), + ); + return; + case "ensemble": + groupDelegate.appendChild(new SharedSetting(Setting.ENSEMBLE, null, props.dataProviderManager)); + return; + case "realization": + groupDelegate.appendChild(new SharedSetting(Setting.REALIZATION, null, props.dataProviderManager)); + return; + case "surface-name": + groupDelegate.appendChild(new SharedSetting(Setting.SURFACE_NAME, null, props.dataProviderManager)); + return; + case "attribute": + groupDelegate.appendChild(new SharedSetting(Setting.ATTRIBUTE, null, props.dataProviderManager)); + return; + case "Date": + groupDelegate.appendChild(new SharedSetting(Setting.TIME_OR_INTERVAL, null, props.dataProviderManager)); + return; + } + } + + function checkIfItemMoveAllowed(movedItem: Item, destinationItem: ItemGroup): boolean { + if (destinationItem instanceof DeltaSurface) { + if ( + movedItem instanceof DataProvider && + !(movedItem instanceof RealizationSurfaceProvider || movedItem instanceof StatisticalSurfaceProvider) + ) { + return false; + } + + if (instanceofItemGroup(movedItem)) { + return false; + } + + if (destinationItem.getGroupDelegate().findChildren((item) => item instanceof DataProvider).length >= 2) { + return false; + } + } + + return true; + } + + function makeActionsForGroup(group: ItemGroup): ActionGroup[] { + const hasView = + groupDelegate.getDescendantItems((item) => item instanceof Group && item.getGroupType() === GroupType.VIEW) + .length > 0; + + const hasViewAncestor = + group + .getGroupDelegate() + .getAncestors((item) => item instanceof Group && item.getGroupType() === GroupType.VIEW).length > 0; + const actions: ActionGroup[] = []; + + if (!hasView) { + return INITIAL_ACTIONS; + } + + const groupActions: ActionGroup = { + label: "Groups", + children: [], + }; + + if (!hasViewAncestor) { + groupActions.children.push({ + identifier: "view", + icon: , + label: "View", + }); + } + + groupActions.children.push({ + identifier: "settings-group", + icon: , + label: "Settings group", + }); + + actions.push(groupActions); + actions.push(...ACTIONS); + + return actions; + } + + return ( + + + + + + Preferred view layout + setPreferredViewLayout(PreferredViewLayout.HORIZONTAL)} + > + Horizontal + + setPreferredViewLayout(PreferredViewLayout.VERTICAL)} + > + Vertical + + + + } + groupActions={makeActionsForGroup} + onAction={handleLayerAction} + isMoveAllowed={checkIfItemMoveAllowed} + /> + ); +} + +type ViewLayoutMenuItemProps = { + checked: boolean; + onClick: () => void; + children: React.ReactNode; +}; + +function ViewLayoutMenuItem(props: ViewLayoutMenuItemProps): React.ReactNode { + return ( + +
+
{props.checked && }
+
{props.children}
+
+
+ ); +} + +const INITIAL_ACTIONS: ActionGroup[] = [ + { + label: "Groups", + children: [ + { + identifier: "view", + icon: , + label: "View", + }, + { + identifier: "settings-group", + icon: , + label: "Settings group", + }, + ], + }, +]; + +const ACTIONS: ActionGroup[] = [ + { + label: "Layers", + children: [ + { + label: "Reservoir grid", + children: [ + { + identifier: "realization-grid", + icon: , + label: "Realization Grid", + }, + ], + }, + { + label: "Surfaces", + children: [ + { + identifier: "realization-surface", + icon: , + label: "Realization Surface", + }, + ], + }, + { + label: "Wells", + children: [ + { + identifier: "drilled-wellbore-trajectories", + icon: , + label: "Drilled Wellbore Trajectories", + }, + { + identifier: "drilled-wellbore-picks", + icon: , + label: "Drilled Wellbore Picks", + }, + ], + }, + { + label: "Intersection", + children: [ + { + identifier: "intersection-realization-grid", + icon: , + label: "Intersection Realization Grid", + }, + { + identifier: "simulated-seismic-fence", + icon: , + label: "Seismic Fence (Simulated)", + }, + { + identifier: "observed-seismic-fence", + icon: , + label: "Seismic Fence (Observed)", + }, + ], + }, + { + label: "Polygons", + children: [ + { + identifier: "realization-polygons", + icon: , + label: "Realization Polygons", + }, + ], + }, + { + label: "Seismic", + children: [ + { + identifier: "realization-seismic-slices", + icon: , + label: "Realization Seismic Slices", + }, + ], + }, + ], + }, + { + label: "Shared Settings", + children: [ + { + identifier: "ensemble", + icon: , + label: "Ensemble", + }, + { + identifier: "realization", + icon: , + label: "Realization", + }, + { + identifier: "surface-name", + icon: , + label: "Surface Name", + }, + { + identifier: "attribute", + icon: , + label: "Attribute", + }, + { + identifier: "Date", + icon: , + label: "Date", + }, + { + identifier: "color-scale", + icon: , + label: "Color scale", + }, + ], + }, +]; diff --git a/frontend/src/modules/3DViewer/settings/components/gridCellIndexFilter.tsx b/frontend/src/modules/3DViewer/settings/components/gridCellIndexFilter.tsx deleted file mode 100644 index b080ad3cd1..0000000000 --- a/frontend/src/modules/3DViewer/settings/components/gridCellIndexFilter.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import type React from "react"; - -import { Checkbox } from "@lib/components/Checkbox"; -import { Input } from "@lib/components/Input"; -import { Slider } from "@lib/components/Slider"; - -export type GridCellIndexFilterProps = { - labelTitle: string; - pickSingle: boolean; - range: [number, number]; - max: number; - onPickSingleChange: (pickSingle: boolean) => void; - onChange: (range: [number, number]) => void; -}; - -export function GridCellIndexFilter(props: GridCellIndexFilterProps): React.ReactNode { - function handleSliderChange(_: any, value: number[] | number) { - if (typeof value === "number") { - props.onChange([value, value]); - return; - } - if (!props.pickSingle) { - props.onChange(value as [number, number]); - return; - } - } - - function handleRangeMinChange(e: React.ChangeEvent) { - if (props.pickSingle) { - props.onChange([parseInt(e.target.value), parseInt(e.target.value)]); - return; - } - props.onChange([parseInt(e.target.value), props.range[1]]); - } - - function handleRangeMaxChange(e: React.ChangeEvent) { - if (props.pickSingle) { - props.onChange([parseInt(e.target.value), parseInt(e.target.value)]); - return; - } - props.onChange([props.range[0], parseInt(e.target.value)]); - } - - function handlePickSingleChange(_: React.ChangeEvent, checked: boolean) { - props.onPickSingleChange(checked); - props.onChange([props.range[0], props.range[0]]); - } - - return ( -
-
- {props.labelTitle} - -
-
- -
- -
- -
-
- ); -} diff --git a/frontend/src/modules/3DViewer/settings/components/wellboreSelector.tsx b/frontend/src/modules/3DViewer/settings/components/wellboreSelector.tsx deleted file mode 100644 index 52e18c56b6..0000000000 --- a/frontend/src/modules/3DViewer/settings/components/wellboreSelector.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import React from "react"; - -import { Deselect, SelectAll } from "@mui/icons-material"; - -import type { WellboreHeader_api } from "@api"; -import { Button } from "@lib/components/Button"; -import { CollapsibleGroup } from "@lib/components/CollapsibleGroup"; -import { Label } from "@lib/components/Label"; -import type { SelectOption } from "@lib/components/Select"; -import { Select } from "@lib/components/Select"; -import { useValidArrayState } from "@lib/hooks/useValidArrayState"; - -export type WellboreSelectorProps = { - wellboreHeaders: WellboreHeader_api[]; - selectedWellboreUuids: string[]; - onSelectedWellboreUuidsChange: (wellboreUuids: string[]) => void; -}; - -export function WellboreSelector(props: WellboreSelectorProps): React.ReactNode { - const { onSelectedWellboreUuidsChange } = props; - const availableWellboreStatuses = Array.from(new Set(props.wellboreHeaders.map((header) => header.wellboreStatus))); - const availableWellborePurposes = Array.from( - new Set(props.wellboreHeaders.map((header) => header.wellborePurpose)), - ); - const [selectedWellboreStatuses, setSelectedWellboreStatuses] = useValidArrayState({ - initialState: availableWellboreStatuses, - validStateArray: availableWellboreStatuses, - }); - const [selectedWellborePurposes, setSelectedWellborePurposes] = useValidArrayState({ - initialState: availableWellborePurposes, - validStateArray: availableWellborePurposes, - }); - React.useEffect(() => { - onSelectedWellboreUuidsChange( - filterWellboreHeaders(props.wellboreHeaders, selectedWellboreStatuses, selectedWellborePurposes).map( - (header) => header.wellboreUuid, - ), - ); - }, [selectedWellborePurposes, selectedWellboreStatuses, onSelectedWellboreUuidsChange, props.wellboreHeaders]); - - function handleSelectAll() { - props.onSelectedWellboreUuidsChange( - filterWellboreHeaders(props.wellboreHeaders, selectedWellboreStatuses, selectedWellborePurposes).map( - (header) => header.wellboreUuid, - ), - ); - } - function handleUnselectAll() { - props.onSelectedWellboreUuidsChange([]); - } - function makeWellHeadersOptions(): SelectOption[] { - return filterWellboreHeaders(props.wellboreHeaders, selectedWellboreStatuses, selectedWellborePurposes).map( - (wellHeader) => ({ - label: wellHeader.uniqueWellboreIdentifier, - value: wellHeader.wellboreUuid, - }), - ); - } - - return ( - <> - -
- -
-
- - <> -
- - -
- - - - - -
- - -
- handleGridCellIndexRangesChange("i", range)} - /> - handleGridCellIndexRangesChange("j", range)} - /> - handleGridCellIndexRangesChange("k", range)} - /> -
-
- - -
- -
- - - -
- -
-
- -
-
, - )} - value={selectedCustomIntersectionPolylineId ? [selectedCustomIntersectionPolylineId] : []} - headerLabels={["Polyline name", "Actions"]} - onChange={handleCustomPolylineSelectionChange} - size={5} - columnSizesInPercent={[80, 20]} - debounceTimeMs={600} - disabled={intersectionType !== IntersectionType.CUSTOM_POLYLINE || polylineAddModeActive} - /> -
- - -
- - +
+ + + {dataProviderManager && ( + + )}
); } - -function makeRealizationOptions(realizations: readonly number[]): SelectOption[] { - return realizations.map((realization) => ({ label: realization.toString(), value: realization.toString() })); -} - -function makeGridModelOptions(gridModelsInfo: Grid3dInfo_api[]): SelectOption[] { - return gridModelsInfo.map((gridModel) => ({ label: gridModel.grid_name, value: gridModel.grid_name })); -} - -function makeGridParameterNameOptions(gridModelInfo: Grid3dInfo_api | null): SelectOption[] { - if (!gridModelInfo) { - return []; - } - const reduced = gridModelInfo.property_info_arr.reduce((acc, info) => { - if (!acc.includes(info.property_name)) { - acc.push(info.property_name); - } - return acc; - }, [] as string[]); - - return reduced.map((info) => ({ - label: info, - value: info, - })); -} - -function makeGridParameterDateOrIntervalOptions(datesOrIntervals: (string | null)[]): SelectOption[] { - const reduced = datesOrIntervals.sort().reduce((acc, info) => { - if (info === null) { - return acc; - } else if (!acc.map((el) => el.value).includes(info)) { - acc.push({ - label: info.includes("/") ? isoIntervalStringToDateLabel(info) : isoStringToDateLabel(info), - value: info, - }); - } - return acc; - }, [] as SelectOption[]); - - return reduced; -} - -function makeWellHeaderOptions(wellHeaders: WellboreHeader_api[]): SelectOption[] { - return wellHeaders.map((wellHeader) => ({ - value: wellHeader.wellboreUuid, - label: wellHeader.uniqueWellboreIdentifier, - })); -} - -function makeCustomIntersectionPolylineOptions( - polylines: readonly IntersectionPolyline[], - selectedId: string | null, - filter: string, - actions: React.ReactNode, -): TableSelectOption[] { - return polylines - .filter((polyline) => polyline.name.includes(filter)) - .map((polyline) => ({ - id: polyline.id, - values: [ - { label: polyline.name }, - { label: "", adornment: selectedId === polyline.id ? actions : undefined }, - ], - })); -} diff --git a/frontend/src/modules/3DViewer/types.ts b/frontend/src/modules/3DViewer/types.ts new file mode 100644 index 0000000000..a457c6616b --- /dev/null +++ b/frontend/src/modules/3DViewer/types.ts @@ -0,0 +1,4 @@ +export enum PreferredViewLayout { + HORIZONTAL = "horizontal", + VERTICAL = "vertical", +} diff --git a/frontend/src/modules/3DViewer/typesAndEnums.ts b/frontend/src/modules/3DViewer/typesAndEnums.ts deleted file mode 100644 index 57a5768348..0000000000 --- a/frontend/src/modules/3DViewer/typesAndEnums.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { - WellFeature as BaseWellFeature, - GeoJsonWellProperties as BaseWellProperties, -} from "@webviz/subsurface-viewer/dist/layers/wells/types"; - -export type GridCellIndexRanges = { - i: [number, number]; - j: [number, number]; - k: [number, number]; -}; - -export type GeoWellProperties = BaseWellProperties & { - uuid: string; - uwi: string; - lineWidth: number; - wellHeadSize: number; -}; -export type GeoWellFeature = BaseWellFeature & { properties: GeoWellProperties }; diff --git a/frontend/src/modules/3DViewer/view/atoms/baseAtoms.ts b/frontend/src/modules/3DViewer/view/atoms/baseAtoms.ts deleted file mode 100644 index aae78509e3..0000000000 --- a/frontend/src/modules/3DViewer/view/atoms/baseAtoms.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { atom } from "jotai"; - -import type { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; -import { IntersectionType } from "@framework/types/intersection"; - - -export const intersectionTypeAtom = atom(IntersectionType.WELLBORE); -export const editCustomIntersectionPolylineEditModeActiveAtom = atom(false); - -export const ensembleIdentAtom = atom(null); -export const highlightedWellboreUuidAtom = atom(null); -export const customIntersectionPolylineIdAtom = atom(null); diff --git a/frontend/src/modules/3DViewer/view/atoms/derivedAtoms.ts b/frontend/src/modules/3DViewer/view/atoms/derivedAtoms.ts deleted file mode 100644 index 123f0a962d..0000000000 --- a/frontend/src/modules/3DViewer/view/atoms/derivedAtoms.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { IntersectionReferenceSystem } from "@equinor/esv-intersection"; -import { atom } from "jotai"; - -import { IntersectionType } from "@framework/types/intersection"; -import { IntersectionPolylinesAtom } from "@framework/userCreatedItems/IntersectionPolylines"; - - -import { customIntersectionPolylineIdAtom, highlightedWellboreUuidAtom, intersectionTypeAtom } from "./baseAtoms"; -import { fieldWellboreTrajectoriesQueryAtom } from "./queryAtoms"; - -export const intersectionReferenceSystemAtom = atom((get) => { - const fieldWellboreTrajectories = get(fieldWellboreTrajectoriesQueryAtom); - const wellboreUuid = get(highlightedWellboreUuidAtom); - const customIntersectionPolylines = get(IntersectionPolylinesAtom); - const customIntersectionPolylineId = get(customIntersectionPolylineIdAtom); - - const customIntersectionPolyline = customIntersectionPolylines.find((el) => el.id === customIntersectionPolylineId); - - const intersectionType = get(intersectionTypeAtom); - - if (intersectionType === IntersectionType.WELLBORE) { - if (!fieldWellboreTrajectories.data || !wellboreUuid) { - return null; - } - - const wellboreTrajectory = fieldWellboreTrajectories.data.find( - (wellbore) => wellbore.wellboreUuid === wellboreUuid, - ); - - if (wellboreTrajectory) { - const path: number[][] = []; - for (const [index, northing] of wellboreTrajectory.northingArr.entries()) { - const easting = wellboreTrajectory.eastingArr[index]; - const tvd_msl = wellboreTrajectory.tvdMslArr[index]; - - path.push([easting, northing, tvd_msl]); - } - const offset = wellboreTrajectory.tvdMslArr[0]; - - const referenceSystem = new IntersectionReferenceSystem(path); - referenceSystem.offset = offset; - - return referenceSystem; - } - } else if (intersectionType === IntersectionType.CUSTOM_POLYLINE && customIntersectionPolyline) { - if (customIntersectionPolyline.path.length < 2) { - return null; - } - const referenceSystem = new IntersectionReferenceSystem( - customIntersectionPolyline.path.map((point) => [point[0], point[1], 0]), - ); - referenceSystem.offset = 0; - - return referenceSystem; - } - - return null; -}); - -export const selectedCustomIntersectionPolylineAtom = atom((get) => { - const customIntersectionPolylineId = get(customIntersectionPolylineIdAtom); - const customIntersectionPolylines = get(IntersectionPolylinesAtom); - - return customIntersectionPolylines.find((el) => el.id === customIntersectionPolylineId) ?? null; -}); diff --git a/frontend/src/modules/3DViewer/view/atoms/interfaceEffects.ts b/frontend/src/modules/3DViewer/view/atoms/interfaceEffects.ts deleted file mode 100644 index eecc3b9a53..0000000000 --- a/frontend/src/modules/3DViewer/view/atoms/interfaceEffects.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { InterfaceEffects } from "@framework/Module"; -import type { SettingsToViewInterface } from "@modules/3DViewer/interfaces"; - -import { - customIntersectionPolylineIdAtom, - ensembleIdentAtom, - highlightedWellboreUuidAtom, - intersectionTypeAtom, -} from "./baseAtoms"; - -export const settingsToViewInterfaceEffects: InterfaceEffects = [ - (getInterfaceValue, setAtomValue) => { - const ensembleIdent = getInterfaceValue("ensembleIdent"); - setAtomValue(ensembleIdentAtom, ensembleIdent); - }, - (getInterfaceValue, setAtomValue) => { - const highlightedWellboreUuid = getInterfaceValue("highlightedWellboreUuid"); - setAtomValue(highlightedWellboreUuidAtom, highlightedWellboreUuid); - }, - (getInterfaceValue, setAtomValue) => { - const customIntersectionPolylineId = getInterfaceValue("customIntersectionPolylineId"); - setAtomValue(customIntersectionPolylineIdAtom, customIntersectionPolylineId); - }, - (getInterfaceValue, setAtomValue) => { - const intersectionType = getInterfaceValue("intersectionType"); - setAtomValue(intersectionTypeAtom, intersectionType); - }, -]; diff --git a/frontend/src/modules/3DViewer/view/atoms/queryAtoms.ts b/frontend/src/modules/3DViewer/view/atoms/queryAtoms.ts deleted file mode 100644 index d4b12c2388..0000000000 --- a/frontend/src/modules/3DViewer/view/atoms/queryAtoms.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { atomWithQuery } from "jotai-tanstack-query"; - -import { getWellTrajectoriesOptions } from "@api"; -import { EnsembleSetAtom } from "@framework/GlobalAtoms"; - - -import { ensembleIdentAtom } from "./baseAtoms"; - -export const fieldWellboreTrajectoriesQueryAtom = atomWithQuery((get) => { - const ensembleIdent = get(ensembleIdentAtom); - const ensembleSet = get(EnsembleSetAtom); - - let fieldIdentifier: string | null = null; - if (ensembleIdent) { - const ensemble = ensembleSet.findEnsemble(ensembleIdent); - if (ensemble) { - fieldIdentifier = ensemble.getFieldIdentifier(); - } - } - - return { - ...getWellTrajectoriesOptions({ - query: { - field_identifier: fieldIdentifier ?? "", - }, - }), - enabled: Boolean(fieldIdentifier), - }; -}); diff --git a/frontend/src/modules/3DViewer/view/components/ContextMenu.tsx b/frontend/src/modules/3DViewer/view/components/ContextMenu.tsx new file mode 100644 index 0000000000..efc08dac53 --- /dev/null +++ b/frontend/src/modules/3DViewer/view/components/ContextMenu.tsx @@ -0,0 +1,63 @@ +import React from "react"; + +import { isEqual } from "lodash"; + +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; + +import { + type ContextMenu as ContextMenuType, + type DeckGlInstanceManager, + DeckGlInstanceManagerTopic, +} from "../utils/DeckGlInstanceManager"; + +export type ContextMenuProps = { + deckGlManager: DeckGlInstanceManager; +}; + +export function ContextMenu(props: ContextMenuProps): React.ReactNode { + const [visible, setVisible] = React.useState(false); + const [prevContextMenu, setPrevContextMenu] = React.useState(null); + const contextMenu = usePublishSubscribeTopicValue(props.deckGlManager, DeckGlInstanceManagerTopic.CONTEXT_MENU); + + React.useEffect(function handleMount() { + function hideContextMenu() { + setVisible(false); + } + + window.addEventListener("blur", hideContextMenu); + + return function handleUnmount() { + window.removeEventListener("blur", hideContextMenu); + }; + }, []); + + if (!isEqual(prevContextMenu, contextMenu)) { + setPrevContextMenu(contextMenu); + setVisible(true); + } + + if (!contextMenu || !visible || !contextMenu.items.length) { + return null; + } + + return ( +
+ {contextMenu.items.map((item, index) => ( +
{ + item.onClick(); + setVisible(false); + }} + > + {item.icon ? React.cloneElement(item.icon, { fontSize: "small" }) : null} + {item.label} +
+ ))} +
+ ); +} diff --git a/frontend/src/modules/3DViewer/view/components/ControlsInfoBox.tsx b/frontend/src/modules/3DViewer/view/components/ControlsInfoBox.tsx new file mode 100644 index 0000000000..e56679689f --- /dev/null +++ b/frontend/src/modules/3DViewer/view/components/ControlsInfoBox.tsx @@ -0,0 +1,42 @@ +import React from "react"; + +import { Info, Mouse } from "@mui/icons-material"; + +import { resolveClassNames } from "@lib/utils/resolveClassNames"; + +export function ControlsInfoBox() { + const [expanded, setExpanded] = React.useState(false); + + function toggleExpanded() { + setExpanded((prev) => !prev); + } + + return ( +
+ + + {expanded && ( +
+

Mouse controls:

+
    +
  • Click and drag to rotate the view
  • +
  • Scroll to zoom in and out
  • +
  • Right-click and drag to pan the view
  • +
  • Double-click on an object to set focus
  • +
+
+ )} +
+ ); +} diff --git a/frontend/src/modules/3DViewer/view/components/DataProvidersWrapper.tsx b/frontend/src/modules/3DViewer/view/components/DataProvidersWrapper.tsx new file mode 100644 index 0000000000..20e212a473 --- /dev/null +++ b/frontend/src/modules/3DViewer/view/components/DataProvidersWrapper.tsx @@ -0,0 +1,301 @@ +import React from "react"; + +import { type Layer } from "@deck.gl/core"; +import type { BoundingBox3D } from "@webviz/subsurface-viewer"; +import { AxesLayer } from "@webviz/subsurface-viewer/dist/layers"; + +import type { ViewContext } from "@framework/ModuleContext"; +import { useViewStatusWriter } from "@framework/StatusWriter"; +import type { WorkbenchServices } from "@framework/WorkbenchServices"; +import type { WorkbenchSession } from "@framework/WorkbenchSession"; +import type { WorkbenchSettings } from "@framework/WorkbenchSettings"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; +import { + accumulatePolylineIds, + type AccumulatedData, +} from "@modules/3DViewer/DataProviderFramework/accumulators/polylineIdsAccumulator"; +import { makeIntersectionRealizationGridBoundingBox } from "@modules/3DViewer/DataProviderFramework/boundingBoxes/makeIntersectionRealizationGridBoundingBox"; +import { makeIntersectionRealizationSeismicBoundingBox } from "@modules/3DViewer/DataProviderFramework/boundingBoxes/makeIntersectionRealizationSeismicBoundingBox"; +import { makeRealizationSeismicSlicesBoundingBox } from "@modules/3DViewer/DataProviderFramework/boundingBoxes/makeRealizationSeismicSlicesBoundingBox"; +import { RealizationGridProvider } from "@modules/3DViewer/DataProviderFramework/customDataProviderImplementations/RealizationGridProvider"; +import { RealizationSeismicSlicesProvider } from "@modules/3DViewer/DataProviderFramework/customDataProviderImplementations/RealizationSeismicSlicesProvider"; +import { CustomDataProviderType } from "@modules/3DViewer/DataProviderFramework/customDataProviderTypes"; +import { makeDrilledWellTrajectoriesHoverVisualizationFunctions } from "@modules/3DViewer/DataProviderFramework/visualization/makeDrilledWellTrajectoriesHoverVisualizationFunctions"; +import { makeDrilledWellTrajectoriesLayer } from "@modules/3DViewer/DataProviderFramework/visualization/makeDrilledWellTrajectoriesLayer"; +import { makeIntersectionRealizationGridLayer } from "@modules/3DViewer/DataProviderFramework/visualization/makeIntersectionRealizationGridLayer"; +import { makeRealizationSurfaceLayer } from "@modules/3DViewer/DataProviderFramework/visualization/makeRealizationSurfaceLayer"; +import { makeSeismicIntersectionMeshLayer } from "@modules/3DViewer/DataProviderFramework/visualization/makeSeismicIntersectionMeshLayer"; +import { makeSeismicSlicesLayer } from "@modules/3DViewer/DataProviderFramework/visualization/makeSeismicSlicesLayer"; +import type { Interfaces } from "@modules/3DViewer/interfaces"; +import { DataProviderType } from "@modules/_shared/DataProviderFramework/dataProviders/dataProviderTypes"; +import { DrilledWellborePicksProvider } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/DrilledWellborePicksProvider"; +import { DrilledWellTrajectoriesProvider } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/DrilledWellTrajectoriesProvider"; +import { IntersectionRealizationGridProvider } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/IntersectionRealizationGridProvider"; +import { IntersectionRealizationSeismicProvider } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/IntersectionRealizationSeismicProvider"; +import { RealizationPolygonsProvider } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/RealizationPolygonsProvider"; +import { RealizationSurfaceProvider } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/RealizationSurfaceProvider"; +import { StatisticalSurfaceProvider } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/StatisticalSurfaceProvider"; +import type { DataProviderManager } from "@modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager"; +import { DataProviderManagerTopic } from "@modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager"; +import { GroupType } from "@modules/_shared/DataProviderFramework/groups/groupTypes"; +import { makeColorScaleAnnotation } from "@modules/_shared/DataProviderFramework/visualization/annotations/makeColorScaleAnnotation"; +import { makePolygonDataBoundingBox } from "@modules/_shared/DataProviderFramework/visualization/boundingBoxes/makePolygonDataBoundingBox"; +import { makeRealizationGridBoundingBox } from "@modules/_shared/DataProviderFramework/visualization/boundingBoxes/makeRealizationGridBoundingBox"; +import { makeSurfaceLayerBoundingBox } from "@modules/_shared/DataProviderFramework/visualization/boundingBoxes/makeSurfaceLayerBoundingBox"; +import { makeDrilledWellborePicksBoundingBox } from "@modules/_shared/DataProviderFramework/visualization/deckgl/boundingBoxes/makeDrilledWellborePicksBoundingBox"; +import { makeDrilledWellTrajectoriesBoundingBox } from "@modules/_shared/DataProviderFramework/visualization/deckgl/boundingBoxes/makeDrilledWellTrajectoriesBoundingBox"; +import { makeDrilledWellborePicksLayer } from "@modules/_shared/DataProviderFramework/visualization/deckgl/makeDrilledWellborePicksLayer"; +import { makeRealizationGridLayer } from "@modules/_shared/DataProviderFramework/visualization/deckgl/makeRealizationGridLayer"; +import { makeRealizationPolygonsLayer } from "@modules/_shared/DataProviderFramework/visualization/deckgl/makeRealizationPolygonsLayer"; +import { makeStatisticalSurfaceLayer } from "@modules/_shared/DataProviderFramework/visualization/deckgl/makeStatisticalSurfaceLayer"; +import type { VisualizationTarget } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; +import { + VisualizationAssembler, + VisualizationItemType, +} from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; +import type { ViewportTypeExtended, ViewsTypeExtended } from "@modules/_shared/types/deckgl"; + +import { PlaceholderLayer } from "../../../_shared/customDeckGlLayers/PlaceholderLayer"; +import { PreferredViewLayout } from "../typesAndEnums"; + +import { InteractionWrapper } from "./InteractionWrapper"; + +const VISUALIZATION_ASSEMBLER = new VisualizationAssembler< + VisualizationTarget.DECK_GL, + Record, + Record, + AccumulatedData +>(); + +VISUALIZATION_ASSEMBLER.registerDataProviderTransformers( + DataProviderType.REALIZATION_SURFACE_3D, + RealizationSurfaceProvider, + { + transformToVisualization: makeRealizationSurfaceLayer, + transformToBoundingBox: makeSurfaceLayerBoundingBox, + transformToAnnotations: makeColorScaleAnnotation, + }, +); +VISUALIZATION_ASSEMBLER.registerDataProviderTransformers( + DataProviderType.STATISTICAL_SURFACE, + StatisticalSurfaceProvider, + { + transformToVisualization: makeStatisticalSurfaceLayer, + transformToBoundingBox: makeSurfaceLayerBoundingBox, + transformToAnnotations: makeColorScaleAnnotation, + }, +); +VISUALIZATION_ASSEMBLER.registerDataProviderTransformers( + DataProviderType.REALIZATION_POLYGONS, + RealizationPolygonsProvider, + { + transformToVisualization: makeRealizationPolygonsLayer, + transformToBoundingBox: makePolygonDataBoundingBox, + }, +); +VISUALIZATION_ASSEMBLER.registerDataProviderTransformers( + CustomDataProviderType.REALIZATION_GRID_3D, + RealizationGridProvider, + { + transformToVisualization: makeRealizationGridLayer, + transformToBoundingBox: makeRealizationGridBoundingBox, + transformToAnnotations: makeColorScaleAnnotation, + }, +); +VISUALIZATION_ASSEMBLER.registerDataProviderTransformers( + DataProviderType.DRILLED_WELLBORE_PICKS, + DrilledWellborePicksProvider, + { + transformToVisualization: makeDrilledWellborePicksLayer, + transformToBoundingBox: makeDrilledWellborePicksBoundingBox, + }, +); +VISUALIZATION_ASSEMBLER.registerDataProviderTransformers( + DataProviderType.DRILLED_WELL_TRAJECTORIES, + DrilledWellTrajectoriesProvider, + { + transformToVisualization: makeDrilledWellTrajectoriesLayer, + transformToBoundingBox: makeDrilledWellTrajectoriesBoundingBox, + transformToHoverVisualization: makeDrilledWellTrajectoriesHoverVisualizationFunctions, + }, +); +VISUALIZATION_ASSEMBLER.registerDataProviderTransformers( + CustomDataProviderType.REALIZATION_SEISMIC_SLICES, + RealizationSeismicSlicesProvider, + { + transformToVisualization: makeSeismicSlicesLayer, + transformToAnnotations: makeColorScaleAnnotation, + transformToBoundingBox: makeRealizationSeismicSlicesBoundingBox, + }, +); +VISUALIZATION_ASSEMBLER.registerDataProviderTransformers( + DataProviderType.INTERSECTION_WITH_WELLBORE_EXTENSION_REALIZATION_GRID, + IntersectionRealizationGridProvider, + { + transformToVisualization: makeIntersectionRealizationGridLayer, + transformToAnnotations: makeColorScaleAnnotation, + reduceAccumulatedData: accumulatePolylineIds, + transformToBoundingBox: makeIntersectionRealizationGridBoundingBox, + }, +); +VISUALIZATION_ASSEMBLER.registerDataProviderTransformers( + DataProviderType.INTERSECTION_REALIZATION_OBSERVED_SEISMIC, + IntersectionRealizationSeismicProvider, + { + transformToVisualization: makeSeismicIntersectionMeshLayer, + transformToAnnotations: makeColorScaleAnnotation, + transformToBoundingBox: makeIntersectionRealizationSeismicBoundingBox, + }, +); +VISUALIZATION_ASSEMBLER.registerDataProviderTransformers( + DataProviderType.INTERSECTION_REALIZATION_SIMULATED_SEISMIC, + IntersectionRealizationSeismicProvider, + { + transformToVisualization: makeSeismicIntersectionMeshLayer, + transformToAnnotations: makeColorScaleAnnotation, + transformToBoundingBox: makeIntersectionRealizationSeismicBoundingBox, + }, +); + +export type LayersWrapperProps = { + fieldId: string; + layerManager: DataProviderManager; + preferredViewLayout: PreferredViewLayout; + viewContext: ViewContext; + workbenchSession: WorkbenchSession; + workbenchSettings: WorkbenchSettings; + workbenchServices: WorkbenchServices; +}; + +export function DataProvidersWrapper(props: LayersWrapperProps): React.ReactNode { + const [changingFields, setChangingFields] = React.useState(false); + const [prevFieldId, setPrevFieldId] = React.useState(null); + const statusWriter = useViewStatusWriter(props.viewContext); + + usePublishSubscribeTopicValue(props.layerManager, DataProviderManagerTopic.DATA_REVISION); + + const assemblerProduct = VISUALIZATION_ASSEMBLER.make(props.layerManager, { + initialAccumulatedData: { polylineIds: [] }, + }); + + const viewports: ViewportTypeExtended[] = []; + const deckGlLayers: Layer[] = []; + const globalAnnotations = assemblerProduct.annotations; + const globalColorScales = globalAnnotations.filter((el) => "colorScale" in el); + const globalLayerIds: string[] = ["placeholder", "axes"]; + const usedPolylineIds = assemblerProduct.accumulatedData.polylineIds; + + for (const item of assemblerProduct.children) { + if (item.itemType === VisualizationItemType.GROUP && item.groupType === GroupType.VIEW) { + const colorScales = item.annotations.filter((el) => "colorScale" in el); + const layerIds: string[] = []; + + for (const child of item.children) { + if (child.itemType === VisualizationItemType.DATA_PROVIDER_VISUALIZATION) { + const layer = child.visualization; + layerIds.push(layer.id); + deckGlLayers.push(layer); + } + } + viewports.push({ + id: item.id, + name: item.name, + color: item.color, + isSync: true, + show3D: true, + layerIds, + colorScales, + }); + } else if (item.itemType === VisualizationItemType.DATA_PROVIDER_VISUALIZATION) { + deckGlLayers.push(item.visualization); + globalLayerIds.push(item.visualization.id); + } + } + + const views: ViewsTypeExtended = { + layout: [0, 0], + showLabel: false, + viewports: viewports.map((viewport) => ({ + ...viewport, + layerIds: [...globalLayerIds, ...viewport.layerIds!], + colorScales: [...globalColorScales, ...viewport.colorScales!], + })), + }; + + const numViews = assemblerProduct.children.filter( + (item) => item.itemType === VisualizationItemType.GROUP && item.groupType === GroupType.VIEW, + ).length; + + if (numViews) { + const numCols = Math.ceil(Math.sqrt(numViews)); + const numRows = Math.ceil(numViews / numCols); + views.layout = [numCols, numRows]; + } + + if (props.preferredViewLayout === PreferredViewLayout.HORIZONTAL) { + views.layout = [views.layout[1], views.layout[0]]; + } + + statusWriter.setLoading(assemblerProduct.numLoadingDataProviders > 0); + + for (const message of assemblerProduct.aggregatedErrorMessages) { + statusWriter.addError(message); + } + + let bounds: BoundingBox3D | undefined = undefined; + if (assemblerProduct.combinedBoundingBox) { + bounds = [ + assemblerProduct.combinedBoundingBox.min.x, + assemblerProduct.combinedBoundingBox.min.y, + assemblerProduct.combinedBoundingBox.min.z, + assemblerProduct.combinedBoundingBox.max.x, + assemblerProduct.combinedBoundingBox.max.y, + assemblerProduct.combinedBoundingBox.max.z, + ]; + } + + deckGlLayers.push( + new PlaceholderLayer({ id: "placeholder" }), + new AxesLayer({ id: "axes", bounds, ZIncreasingDownwards: true }), + ); + + deckGlLayers.reverse(); + + // We are using this pattern (emptying the layers list + setting a new key for the InteractionWrapper) + // as a workaround due to subsurface-viewer's bounding box model not respecting the removal of layers. + // In case of a field change, the total accumulated bounding box would become very large and homing wouldn't work properly. + // + // This is a temporary solution until the subsurface-viewer is updated to handle + // bounding boxes more correctly. + // + // See: https://github.com/equinor/webviz-subsurface-components/pull/2573 + if (prevFieldId !== props.fieldId) { + setChangingFields(true); + setPrevFieldId(props.fieldId); + } + + const finalLayers: Layer[] = []; + if (changingFields && assemblerProduct.numLoadingDataProviders === 0) { + setChangingFields(false); + } + + if (!changingFields) { + finalLayers.push(...deckGlLayers); + } + + // ----------------------------------------------------------------------------- + + return ( + + ); +} diff --git a/frontend/src/modules/3DViewer/view/components/HoverUpdateWrapper.tsx b/frontend/src/modules/3DViewer/view/components/HoverUpdateWrapper.tsx deleted file mode 100644 index 00c927b42f..0000000000 --- a/frontend/src/modules/3DViewer/view/components/HoverUpdateWrapper.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React from "react"; - -import { GeoJsonLayer } from "@deck.gl/layers"; -import type { IntersectionReferenceSystem } from "@equinor/esv-intersection"; -import { isEqual } from "lodash"; - -import type { ViewContext } from "@framework/ModuleContext"; -import type { GlobalTopicDefinitions, WorkbenchServices } from "@framework/WorkbenchServices"; -import { useSubscribedValue } from "@framework/WorkbenchServices"; - - -import type { SubsurfaceViewerWrapperProps } from "./SubsurfaceViewerWrapper"; -import { SubsurfaceViewerWrapper } from "./SubsurfaceViewerWrapper"; - -export type HoverUpdateWrapperProps = { - wellboreUuid: string | null; - intersectionReferenceSystem?: IntersectionReferenceSystem; - workbenchServices: WorkbenchServices; - viewContext: ViewContext; -} & SubsurfaceViewerWrapperProps; - -export function HoverUpdateWrapper(props: HoverUpdateWrapperProps): React.ReactNode { - const [mdLayer, setMdLayer] = React.useState([]); - - const [prevHoveredMd, setPrevHoveredMd] = React.useState(null); - const syncedHoveredMd = useSubscribedValue( - "global.hoverMd", - props.workbenchServices, - props.viewContext.getInstanceIdString(), - ); - - if (!isEqual(syncedHoveredMd, prevHoveredMd)) { - setPrevHoveredMd(syncedHoveredMd); - if (syncedHoveredMd && props.intersectionReferenceSystem) { - const [x, y] = props.intersectionReferenceSystem.getPosition(syncedHoveredMd.md); - const [, z] = props.intersectionReferenceSystem.project(syncedHoveredMd.md); - const hoveredMdPoint3d = [x, y, -z]; - setMdLayer([ - new GeoJsonLayer({ - id: "hovered-md-point", - data: { - type: "FeatureCollection", - features: [ - { - type: "Feature", - geometry: { - type: "Point", - coordinates: hoveredMdPoint3d, - }, - properties: { - color: [255, 0, 0], // Custom property to use in styling (optional) - }, - }, - ], - }, - pickable: false, - getPosition: (d: number[]) => d, - getRadius: 10, - pointRadiusUnits: "pixels", - getFillColor: [255, 0, 0], - getLineColor: [255, 0, 0], - getLineWidth: 2, - }), - ]); - } else { - setMdLayer([]); - } - } - - return ; -} diff --git a/frontend/src/modules/3DViewer/view/components/HoverVisualizationWrapper.tsx b/frontend/src/modules/3DViewer/view/components/HoverVisualizationWrapper.tsx new file mode 100644 index 0000000000..241ecbb991 --- /dev/null +++ b/frontend/src/modules/3DViewer/view/components/HoverVisualizationWrapper.tsx @@ -0,0 +1,43 @@ +import type React from "react"; + +import { cloneDeep } from "lodash"; + +import { useSubscribedProviderHoverVisualizations } from "@modules/_shared/DataProviderFramework/visualization/hooks/useSubscribedProviderHoverVisualizations"; +import type { VisualizationTarget } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; + +import { ReadoutWrapper, type ReadoutWrapperProps } from "./ReadoutWrapper"; + +export type HoverVisualizationWrapperProps = ReadoutWrapperProps; + +export function HoverVisualizationWrapper(props: HoverVisualizationWrapperProps): React.ReactNode { + const hoverVisualizations = useSubscribedProviderHoverVisualizations( + props.assemblerProduct, + props.workbenchServices, + ); + + const adjustedLayersWithHoverVisualizations = [...(props.layers ?? [])]; + const adjustedViewportsWithHoverVisualizations = cloneDeep(props.views?.viewports ?? []); + + for (const hoverVisualization of hoverVisualizations) { + for (const viewport of adjustedViewportsWithHoverVisualizations) { + if (hoverVisualization.groupId === viewport.id) { + viewport.layerIds = [ + ...(viewport.layerIds ?? []), + ...hoverVisualization.hoverVisualizations.map((v) => v.id), + ]; + adjustedLayersWithHoverVisualizations.push(...hoverVisualization.hoverVisualizations); + } + } + } + + return ( + + ); +} diff --git a/frontend/src/modules/3DViewer/view/components/InteractionWrapper.tsx b/frontend/src/modules/3DViewer/view/components/InteractionWrapper.tsx new file mode 100644 index 0000000000..8cad3c14f7 --- /dev/null +++ b/frontend/src/modules/3DViewer/view/components/InteractionWrapper.tsx @@ -0,0 +1,179 @@ +import React from "react"; + +import type { Layer as DeckGlLayer } from "@deck.gl/core"; +import type { DeckGLRef } from "@deck.gl/react"; +import { AxesLayer } from "@webviz/subsurface-viewer/dist/layers"; +import { converter } from "culori"; + +import { useIntersectionPolylines } from "@framework/UserCreatedItems"; +import type { IntersectionPolyline } from "@framework/userCreatedItems/IntersectionPolylines"; +import { IntersectionPolylinesEvent } from "@framework/userCreatedItems/IntersectionPolylines"; + +import { DeckGlInstanceManager } from "../utils/DeckGlInstanceManager"; +import { type Polyline, PolylinesPlugin, PolylinesPluginTopic } from "../utils/PolylinesPlugin"; + +import { ContextMenu } from "./ContextMenu"; +import { ControlsInfoBox } from "./ControlsInfoBox"; +import { HoverVisualizationWrapper } from "./HoverVisualizationWrapper"; +import { type ReadoutWrapperProps } from "./ReadoutWrapper"; +import { Toolbar } from "./Toolbar"; + +export type InteractionWrapperProps = Omit< + ReadoutWrapperProps, + "deckGlManager" | "triggerHome" | "verticalScale" | "deckGlRef" +> & { + fieldId: string; + usedPolylineIds: string[]; +}; + +function convertPolylines(polylines: Polyline[], fieldId: string): IntersectionPolyline[] { + return polylines.map((polyline) => ({ + id: polyline.id, + name: polyline.name, + color: polyline.color, + path: polyline.path, + fieldId, + })); +} + +export function InteractionWrapper(props: InteractionWrapperProps): React.ReactNode { + const deckGlRef = React.useRef(null); + const intersectionPolylines = useIntersectionPolylines(props.workbenchSession); + + const [triggerHomeCounter, setTriggerHomeCounter] = React.useState(0); + const [gridVisible, setGridVisible] = React.useState(false); + const [verticalScale, setVerticalScale] = React.useState(10); + const [activePolylineName, setActivePolylineName] = React.useState(undefined); + + const deckGlManagerRef = React.useRef(new DeckGlInstanceManager(deckGlRef.current)); + const polylinesPluginRef = React.useRef(new PolylinesPlugin(deckGlManagerRef.current)); + + const colorSet = props.workbenchSettings.useColorSet(); + + const colorArray = React.useMemo((): [number, number, number][] => { + return colorSet.getColorArray().map((c) => { + const rgb = converter("rgb")(c); + return rgb ? [rgb.r * 255, rgb.g * 255, rgb.b * 255] : [0, 0, 0]; + }); + }, [colorSet]); + + const colorGenerator = React.useMemo( + () => + function* () { + let i = 0; + while (true) { + yield colorArray[i % colorArray.length]; + i++; + } + }, + [colorArray], + ); + + React.useEffect( + function updateVisiblePolylines() { + if (polylinesPluginRef.current) { + polylinesPluginRef.current.setVisiblePolylineIds(props.usedPolylineIds); + } + }, + [props.usedPolylineIds], + ); + + React.useLayoutEffect( + function setupDeckGlManager() { + // Imperative Deck.gl plugin setup — must happen before paint and before useEffect runs + // to avoid visual artifacts or plugin timing issues. + const manager = new DeckGlInstanceManager(deckGlRef.current); + deckGlManagerRef.current = manager; + + const polylinesPlugin = new PolylinesPlugin(manager, colorGenerator()); + polylinesPlugin.setPolylines([...intersectionPolylines.getPolylines()]); + manager.addPlugin(polylinesPlugin); + polylinesPluginRef.current = polylinesPlugin; + + const unsubscribeFromPolylinesPlugin = polylinesPlugin + .getPublishSubscribeDelegate() + .makeSubscriberFunction(PolylinesPluginTopic.EDITING_POLYLINE_ID)(() => { + const editingId = polylinesPlugin.getCurrentEditingPolylineId(); + if (editingId == null) { + intersectionPolylines.setPolylines(convertPolylines(polylinesPlugin.getPolylines(), props.fieldId)); + } else { + const current = polylinesPlugin.getPolylines().find((p) => p.id === editingId); + setActivePolylineName(current?.name); + } + }); + + const unsubscribeFromIntersectionPolylines = intersectionPolylines.subscribe( + IntersectionPolylinesEvent.CHANGE, + () => { + polylinesPlugin.setPolylines([...intersectionPolylines.getPolylines()]); + }, + ); + + return function cleanupDeckGlManager() { + manager.beforeDestroy(); + unsubscribeFromPolylinesPlugin(); + unsubscribeFromIntersectionPolylines(); + }; + }, + [intersectionPolylines, colorGenerator, props.fieldId], + ); + + function handleFitInViewClick() { + setTriggerHomeCounter((prev) => prev + 1); + } + + function handleGridVisibilityChange(visible: boolean) { + setGridVisible(visible); + } + + function handleVerticalScaleChange(value: number) { + setVerticalScale(value); + } + + const handlePolylineNameChange = React.useCallback((name: string) => { + const plugin = polylinesPluginRef.current; + const editingId = plugin?.getCurrentEditingPolylineId(); + if (!plugin || !editingId) return; + + const updated = plugin + .getPolylines() + .map((polyline) => (polyline.id === editingId ? { ...polyline, name } : polyline)); + plugin.setPolylines(updated); + setActivePolylineName(name); + }, []); + + let adjustedLayers: DeckGlLayer[] = [...props.layers]; + let adjustedViewports = [...props.views.viewports]; + if (!gridVisible) { + adjustedLayers = adjustedLayers.filter((layer) => !(layer instanceof AxesLayer)); + adjustedViewports = adjustedViewports.map((viewport) => ({ + ...viewport, + layerIds: viewport.layerIds?.filter((layerId) => layerId !== "axes"), + })); + } + + return ( + + + + + + ); +} diff --git a/frontend/src/modules/3DViewer/view/components/PolylineEditingPanel.tsx b/frontend/src/modules/3DViewer/view/components/PolylineEditingPanel.tsx deleted file mode 100644 index bec1fcd29c..0000000000 --- a/frontend/src/modules/3DViewer/view/components/PolylineEditingPanel.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import React from "react"; - -import { ArrowBack, ArrowForward, Delete, Save } from "@mui/icons-material"; - -import type { IntersectionPolyline } from "@framework/userCreatedItems/IntersectionPolylines"; -import { Button } from "@lib/components/Button"; -import { IconButton } from "@lib/components/IconButton"; -import { Input } from "@lib/components/Input"; -import { Label } from "@lib/components/Label"; -import type { SelectOption } from "@lib/components/Select"; -import { Select } from "@lib/components/Select"; - -export type PolylineEditingPanelProps = { - currentlyEditedPolyline: number[][]; - currentlyEditedPolylineName?: string; - selectedPolylineIndex: number | null; - hoveredPolylineIndex: number | null; - intersectionPolylines: readonly IntersectionPolyline[]; - onPolylinePointSelectionChange: (index: number | null) => void; - onPolylineEditingModusChange: (active: boolean) => void; - onDeleteCurrentlySelectedPoint: () => void; - onChangeCurrentlySelectedPoint: (index: number, value: number) => void; - onEditingFinish: (name: string) => void; - onEditingCancel: () => void; -}; - -export function PolylineEditingPanel(props: PolylineEditingPanelProps): React.ReactNode { - const [pointEditingFinished, setPointEditingFinished] = React.useState(false); - const [polylineName, setPolylineName] = React.useState( - props.currentlyEditedPolylineName ?? makeUniquePolylineName(props.intersectionPolylines), - ); - - function handlePolylinePointSelectionChange(values: string[]): void { - if (values.length === 0) { - props.onPolylinePointSelectionChange(null); - } else { - props.onPolylinePointSelectionChange(parseInt(values[0], 10)); - } - } - - function handleFinishEditingClick(): void { - setPointEditingFinished(true); - props.onPolylineEditingModusChange(false); - } - - function handleSaveClick(): void { - setPointEditingFinished(false); - props.onEditingFinish(polylineName); - setPolylineName(""); - } - - function handleCancelClick(): void { - setPointEditingFinished(false); - setPolylineName(""); - props.onEditingCancel(); - } - - function handleBackClick(): void { - setPointEditingFinished(false); - props.onPolylineEditingModusChange(true); - } - - function handleNameChange(event: React.ChangeEvent): void { - setPolylineName(event.target.value); - } - - function handleDeleteCurrentlySelectedPoint(): void { - if (props.selectedPolylineIndex !== null) { - props.onDeleteCurrentlySelectedPoint(); - } - } - - function handlePolylineCoordinateChange(index: number, value: number): void { - if (props.selectedPolylineIndex !== null) { - props.onChangeCurrentlySelectedPoint(index, value); - } - } - - function handleKeyDown(event: React.KeyboardEvent): void { - event.stopPropagation(); - } - - function makeContent() { - if (pointEditingFinished) { - return ( - - ); - } - return ( - <> -
-
- - handlePolylineCoordinateChange(0, parseFloat(event.target.value)) - } - onKeyDown={handleKeyDown} - /> - - -
-
-
- - - -
- - - ); - } - - function makeButtons() { - if (pointEditingFinished) { - return ( - <> - - - - - ); - } - return ( - <> - - - - ); - } - - return ( -
-
Polyline editing
-
{makeContent()}
-
{makeButtons()}
-
- ); -} - -function makeStringFromPoint(point: number[]): string { - return `${point[0].toFixed(2)}, ${point[1].toFixed(2)}`; -} - -function makeSelectOptionsFromPoints(points: number[][]): SelectOption[] { - return points.map((point, index) => ({ - value: index.toString(), - label: makeStringFromPoint(point), - })); -} - -function makeUniquePolylineName(intersectionPolylines: readonly IntersectionPolyline[]): string { - const names = intersectionPolylines.map((polyline) => polyline.name); - let i = 1; - while (names.includes(`Polyline ${i}`)) { - i++; - } - return `Polyline ${i}`; -} diff --git a/frontend/src/modules/3DViewer/view/components/ReadoutBoxWrapper.tsx b/frontend/src/modules/3DViewer/view/components/ReadoutBoxWrapper.tsx index 7599e0e934..ad03dd212f 100644 --- a/frontend/src/modules/3DViewer/view/components/ReadoutBoxWrapper.tsx +++ b/frontend/src/modules/3DViewer/view/components/ReadoutBoxWrapper.tsx @@ -1,117 +1,96 @@ import React from "react"; -import type { ExtendedLayerProps, LayerPickInfo } from "@webviz/subsurface-viewer"; +import type { PickingInfoPerView } from "@webviz/subsurface-viewer/dist/hooks/useMultiViewPicking"; import { isEqual } from "lodash"; -import type { InfoItem, ReadoutItem } from "@modules/_shared/components/ReadoutBox"; -import { ReadoutBox } from "@modules/_shared/components/ReadoutBox"; - +import { ReadoutBox, type ReadoutItem } from "@modules/_shared/components/ReadoutBox"; // Needs extra distance for the left side; this avoids overlapping with legend elements const READOUT_EDGE_DISTANCE_REM = { left: 6 }; -function makePositionReadout(layerPickInfo: LayerPickInfo): ReadoutItem | null { - if (layerPickInfo.coordinate === undefined || layerPickInfo.coordinate.length < 2) { +function makePositionReadout(coordinates: number[], verticalScale: number = 1): ReadoutItem | null { + if (coordinates === undefined || coordinates.length < 2) { return null; } - return { + + const readout = { label: "Position", info: [ { name: "x", - value: layerPickInfo.coordinate[0], + value: coordinates[0], unit: "m", }, { name: "y", - value: layerPickInfo.coordinate[1], + value: coordinates[1], unit: "m", }, ], }; -} - -// depth readout from SubsurfaceViewer is not properly working -function fixLayerDepth(item: InfoItem, verticalScale: number) { - const { name, value } = item; - - if (name === "Depth") { - item.value = (typeof value === "string" ? parseFloat(value) : (value as number)) / verticalScale; + if (coordinates.length > 2) { + readout.info.push({ + name: "z", + value: coordinates[2] / verticalScale, + unit: "m", + }); } + + return readout; } +export type ViewportPickingInfo = PickingInfoPerView extends Record ? V : never; + export type ReadoutBoxWrapperProps = { - layerPickInfo: LayerPickInfo[]; + viewportPickInfo: ViewportPickingInfo; + verticalScale?: number; maxNumItems?: number; visible?: boolean; - // Required as long as the SubsurfaceViewer is not providing correct depth readout - verticalScale: number; + compact?: boolean; }; export function ReadoutBoxWrapper(props: ReadoutBoxWrapperProps): React.ReactNode { const [infoData, setInfoData] = React.useState([]); - const [prevLayerPickInfo, setPrevLayerPickInfo] = React.useState([]); + const [prevLayerPickInfo, setPrevLayerPickInfo] = React.useState(null); + + if (!props.visible) { + return null; + } - if (!isEqual(props.layerPickInfo, prevLayerPickInfo)) { - setPrevLayerPickInfo(props.layerPickInfo); + if (!isEqual(props.viewportPickInfo, prevLayerPickInfo)) { + setPrevLayerPickInfo(props.viewportPickInfo); const newReadoutItems: ReadoutItem[] = []; - if (props.layerPickInfo.length === 0) { + const coordinates = props.viewportPickInfo.coordinates; + const layerPickInfoArray = props.viewportPickInfo.layerPickingInfo; + + if (!coordinates || coordinates.length < 2) { setInfoData([]); return; } - const positionReadout = makePositionReadout(props.layerPickInfo[0]); + const positionReadout = makePositionReadout(coordinates, props.verticalScale); if (!positionReadout) { return; } newReadoutItems.push(positionReadout); - for (const layerPickInfo of props.layerPickInfo) { - const layerName = (layerPickInfo.layer?.props as unknown as ExtendedLayerProps)?.name; + for (const layerPickInfo of layerPickInfoArray) { + const layerName = layerPickInfo.layerName; const layerProps = layerPickInfo.properties; - // pick info can have 2 types of properties that can be displayed on the info card - // 1. defined as propertyValue, used for general layer info (now using for positional data) - // 2. Another defined as array of property object described by type PropertyDataType - - // collecting card data for 1st type - const zValue = (layerPickInfo as LayerPickInfo).propertyValue; - if (zValue !== undefined) { - const property = positionReadout.info?.find((item) => item.name === layerName); - if (property) { - property.value = zValue; - } else { - positionReadout.info.push({ - name: layerName, - value: zValue, - }); - } - } + let layerReadout = newReadoutItems.find((item) => item.label === layerName); - // collecting card data for 2nd type - const layerReadout = newReadoutItems.find((item) => item.label === layerName); - if (!layerProps || layerProps.length === 0) { - continue; - } - if (layerReadout) { - layerProps?.forEach((prop) => { - const property = layerReadout.info?.find((item) => item.name === prop.name); - if (property) { - property.value = prop.value; - } else { - layerReadout.info.push(prop); - } - }); - } else { - newReadoutItems.push({ - label: layerName ?? "Unknown layer", - info: layerProps, - }); + if (!layerReadout) { + layerReadout = { label: layerName, info: [] }; + newReadoutItems.push(layerReadout); } - } - newReadoutItems.forEach(({ info }) => info.forEach((i) => fixLayerDepth(i, props.verticalScale))); + layerReadout.info = layerProps.map((p) => ({ + name: p.name, + value: p.value, + })); + } setInfoData(newReadoutItems); } @@ -120,5 +99,12 @@ export function ReadoutBoxWrapper(props: ReadoutBoxWrapperProps): React.ReactNod return null; } - return ; + return ( + + ); } diff --git a/frontend/src/modules/3DViewer/view/components/ReadoutWrapper.tsx b/frontend/src/modules/3DViewer/view/components/ReadoutWrapper.tsx new file mode 100644 index 0000000000..861e0152c8 --- /dev/null +++ b/frontend/src/modules/3DViewer/view/components/ReadoutWrapper.tsx @@ -0,0 +1,185 @@ +import React from "react"; + +import type { Layer as DeckGlLayer, PickingInfo } from "@deck.gl/core"; +import { View as DeckGlView } from "@deck.gl/core"; +import type { DeckGLRef } from "@deck.gl/react"; +import type { LayerPickInfo, MapMouseEvent } from "@webviz/subsurface-viewer"; +import { useMultiViewCursorTracking } from "@webviz/subsurface-viewer/dist/hooks/useMultiViewCursorTracking"; +import { useMultiViewPicking } from "@webviz/subsurface-viewer/dist/hooks/useMultiViewPicking"; +import { WellLabelLayer } from "@webviz/subsurface-viewer/dist/layers/wells/layers/wellLabelLayer"; +import type { WellsPickInfo } from "@webviz/subsurface-viewer/dist/layers/wells/types"; +import type { Feature } from "geojson"; +import { isEqual } from "lodash"; + +import type { WorkbenchServices } from "@framework/WorkbenchServices"; +import type { WorkbenchSession } from "@framework/WorkbenchSession"; +import type { WorkbenchSettings } from "@framework/WorkbenchSettings"; +import { useElementSize } from "@lib/hooks/useElementSize"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; +import { PolylinesLayer } from "@modules/3DViewer/customDeckGlLayers/PolylinesLayer"; +import { ColorLegendsContainer } from "@modules/_shared/components/ColorLegendsContainer/colorLegendsContainer"; +import { + SubsurfaceViewerWithCameraState, + type SubsurfaceViewerWithCameraStateProps, +} from "@modules/_shared/components/SubsurfaceViewerWithCameraState"; +import { ViewportLabel } from "@modules/_shared/components/ViewportLabel"; +import type { + AssemblerProduct, + VisualizationTarget, +} from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; +import type { ViewsTypeExtended } from "@modules/_shared/types/deckgl"; + +import { DeckGlInstanceManagerTopic, type DeckGlInstanceManager } from "../utils/DeckGlInstanceManager"; + +import { ReadoutBoxWrapper } from "./ReadoutBoxWrapper"; + +export type ReadoutWrapperProps = { + views: ViewsTypeExtended; + layers: DeckGlLayer[]; + workbenchSession: WorkbenchSession; + workbenchSettings: WorkbenchSettings; + workbenchServices: WorkbenchServices; + deckGlManager: DeckGlInstanceManager; + verticalScale: number; + triggerHome: number; + deckGlRef: React.RefObject; + children?: React.ReactNode; + assemblerProduct: AssemblerProduct; +}; + +export function ReadoutWrapper(props: ReadoutWrapperProps): React.ReactNode { + const id = React.useId(); + const [hideReadout, setHideReadout] = React.useState(false); + const [storedDeckGlViews, setStoredDeckGlViews] = + React.useState(undefined); + + const mainDivRef = React.useRef(null); + const mainDivSize = useElementSize(mainDivRef); + const deckGlRef = React.useRef(null); + + React.useImperativeHandle(props.deckGlRef, () => deckGlRef.current); + usePublishSubscribeTopicValue(props.deckGlManager, DeckGlInstanceManagerTopic.REDRAW); + + const [numRows] = props.views.layout; + + const viewports = props.views?.viewports ?? []; + const layers = props.layers ?? []; + + const { pickingInfoPerView, activeViewportId, getPickingInfo } = useMultiViewPicking({ + deckGlRef, + pickDepth: 3, + multiPicking: true, + }); + + const { viewports: adjustedViewports, layers: adjustedLayers } = useMultiViewCursorTracking({ + activeViewportId, + viewports, + layers, + worldCoordinates: pickingInfoPerView[activeViewportId]?.coordinates ?? null, + crosshairProps: { + // ! We hide the crosshair by opacity since toggling "visible" causes a full asset load/unload + color: [255, 255, 255, hideReadout ? 0 : 255], + sizePx: 32, + }, + }); + + function handleMouseHover(event: MapMouseEvent): void { + getPickingInfo(event); + } + + function handleMouseEvent(event: MapMouseEvent): void { + if (event.type === "hover") { + handleMouseHover(event); + } + } + + function tooltip(info: PickingInfo): string { + if ( + (info.layer?.constructor === WellLabelLayer || info.sourceLayer?.constructor === WellLabelLayer) && + info.object?.wellLabels + ) { + return info.object.wellLabels?.join("\n"); + } else if ((info as WellsPickInfo)?.logName) { + return (info as WellsPickInfo)?.logName; + } else if (info.layer?.id === "drawing-layer") { + return (info as LayerPickInfo).propertyValue?.toFixed(2) ?? ""; + } else if (info.layer?.constructor === PolylinesLayer) { + return info?.object?.name; + } + const feat = info.object as Feature; + return feat?.properties?.["name"]; + } + + const deckGlProps = props.deckGlManager.makeDeckGlComponentProps({ + deckGlRef, + id: `subsurface-viewer-${id}`, + views: { + ...props.views, + viewports: adjustedViewports, + layout: props.views?.layout ?? [1, 1], + }, + verticalScale: props.verticalScale, + scale: { + visible: true, + incrementValue: 100, + widthPerUnit: 100, + cssStyle: { + right: 10, + top: 10, + }, + }, + coords: { + visible: false, + multiPicking: true, + pickDepth: 2, + }, + triggerHome: props.triggerHome, + pickingRadius: 5, + layers: adjustedLayers, + onMouseEvent: handleMouseEvent, + getTooltip: tooltip, + }); + + if (!isEqual(deckGlProps.views, storedDeckGlViews)) { + setStoredDeckGlViews(deckGlProps.views); + } + + const handleMainDivLeave = React.useCallback(() => setHideReadout(true), []); + const handleMainDivEnter = React.useCallback(() => setHideReadout(false), []); + + return ( +
+ {props.children} + + {props.views.viewports.map((viewport) => ( + // @ts-expect-error -- This class is marked as abstract, but seems to just work as is + + + + + 1} + viewportPickInfo={pickingInfoPerView[viewport.id]} + visible={!hideReadout && !!pickingInfoPerView[viewport.id]} + verticalScale={props.verticalScale} + /> + + ))} + + {props.views.viewports.length === 0 && ( +
+ Please add views and layers in the settings panel. +
+ )} +
+ ); +} diff --git a/frontend/src/modules/3DViewer/view/components/SubsurfaceViewerWrapper.tsx b/frontend/src/modules/3DViewer/view/components/SubsurfaceViewerWrapper.tsx deleted file mode 100644 index 20c4eec55a..0000000000 --- a/frontend/src/modules/3DViewer/view/components/SubsurfaceViewerWrapper.tsx +++ /dev/null @@ -1,819 +0,0 @@ -import React from "react"; - -import type { Layer, PickingInfo } from "@deck.gl/core"; -import { ColumnLayer, SolidPolygonLayer } from "@deck.gl/layers"; -import { Add, FilterCenterFocus, Polyline, Remove } from "@mui/icons-material"; -import type { LayerPickInfo, ViewStateType } from "@webviz/subsurface-viewer"; -import type { WellsPickInfo } from "@webviz/subsurface-viewer/dist/layers/wells/types"; -import type { MapMouseEvent } from "@webviz/subsurface-viewer/dist/SubsurfaceViewer"; -import type { Feature } from "geojson"; -import { isEqual } from "lodash"; - -import type { - IntersectionPolyline, - IntersectionPolylineWithoutId, -} from "@framework/userCreatedItems/IntersectionPolylines"; -import { Button } from "@lib/components/Button"; -import { HoldPressedIntervalCallbackButton } from "@lib/components/HoldPressedIntervalCallbackButton/holdPressedIntervalCallbackButton"; -import { useElementSize } from "@lib/hooks/useElementSize"; -import { ColorLegendsContainer } from "@modules/_shared/components/ColorLegendsContainer"; -import { SubsurfaceViewerWithCameraState } from "@modules/_shared/components/SubsurfaceViewerWithCameraState"; -import type { ColorScaleWithName } from "@modules/_shared/utils/ColorScaleWithName"; - -import { createContinuousColorScaleForMap } from "../utils/colorTables"; - -import { PolylineEditingPanel } from "./PolylineEditingPanel"; -import { ReadoutBoxWrapper } from "./ReadoutBoxWrapper"; - -export type BoundingBox3D = { - xmin: number; - ymin: number; - zmin: number; - xmax: number; - ymax: number; - zmax: number; -}; - -export type BoundingBox2D = { - xmin: number; - ymin: number; - xmax: number; - ymax: number; -}; - -export type SubsurfaceViewerWrapperProps = { - ref?: React.ForwardedRef; - fieldId: string; - boundingBox: BoundingBox2D | BoundingBox3D; - layers: Layer[]; - show3D?: boolean; - verticalScale?: number; - colorScale: ColorScaleWithName; - enableIntersectionPolylineEditing?: boolean; - onAddIntersectionPolyline?: (intersectionPolyline: IntersectionPolylineWithoutId) => void; - onIntersectionPolylineChange?: (intersectionPolyline: IntersectionPolyline) => void; - onIntersectionPolylineEditCancel?: () => void; - onVerticalScaleChange?: (verticalScale: number) => void; - intersectionPolyline?: IntersectionPolyline; - intersectionPolylines?: readonly IntersectionPolyline[]; -}; - -type IntersectionZValues = { - zMid: number; - zExtension: number; -}; - -export function SubsurfaceViewerWrapper(props: SubsurfaceViewerWrapperProps): React.ReactNode { - const { onVerticalScaleChange } = props; - - const subsurfaceViewerId = React.useId(); - - const [intersectionZValues, setIntersectionZValues] = React.useState(undefined); - const [polylineEditPointsModusActive, setPolylineEditPointsModusActive] = React.useState(false); - const [polylineEditingActive, setPolylineEditingActive] = React.useState(false); - const [currentlyEditedPolyline, setCurrentlyEditedPolyline] = React.useState([]); - const [selectedPolylinePointIndex, setSelectedPolylinePointIndex] = React.useState(null); - const [hoveredPolylinePointIndex, setHoveredPolylinePointIndex] = React.useState(null); - const [userCameraInteractionActive, setUserCameraInteractionActive] = React.useState(true); - const [hoverPreviewPoint, setHoverPreviewPoint] = React.useState(null); - const [cameraPositionSetByAction, setCameraPositionSetByAction] = React.useState(null); - const [isDragging, setIsDragging] = React.useState(false); - const [layerPickingInfo, setLayerPickingInfo] = React.useState([]); - const [pointerOver, setPointerOver] = React.useState(false); - - const [verticalScale, setVerticalScale] = React.useState(1); - const [prevVerticalScale, setPrevVerticalScale] = React.useState(props.verticalScale); - - if (props.verticalScale !== prevVerticalScale) { - setPrevVerticalScale(props.verticalScale); - if (props.verticalScale !== undefined) { - setVerticalScale(props.verticalScale ?? 1); - } - } - - const [prevBoundingBox, setPrevBoundingBox] = React.useState(undefined); - const [prevIntersectionPolyline, setPrevIntersectionPolyline] = React.useState( - undefined, - ); - - const internalRef = React.useRef(null); - const divSize = useElementSize(internalRef); - - React.useImperativeHandle(props.ref, () => internalRef.current); - - if (!isEqual(props.boundingBox, prevBoundingBox)) { - setPrevBoundingBox(props.boundingBox); - let zMid = 0; - let zExtension = 10; - if ("zmin" in props.boundingBox && "zmax" in props.boundingBox) { - zMid = -(props.boundingBox.zmin + (props.boundingBox.zmax - props.boundingBox.zmin) / 2); - zExtension = Math.abs(props.boundingBox.zmax - props.boundingBox.zmin) + 100; - } - setIntersectionZValues({ - zMid, - zExtension, - }); - } - - if (!isEqual(props.intersectionPolyline, prevIntersectionPolyline)) { - setPrevIntersectionPolyline(props.intersectionPolyline); - if (props.intersectionPolyline) { - setCurrentlyEditedPolyline(props.intersectionPolyline.path); - setPolylineEditingActive(true); - setPolylineEditPointsModusActive(true); - setSelectedPolylinePointIndex(0); - } else { - setPolylineEditingActive(false); - setPolylineEditPointsModusActive(false); - setCurrentlyEditedPolyline([]); - setSelectedPolylinePointIndex(null); - } - } - - const layers: Layer[] = []; - const layerIds: string[] = []; - - if (props.layers) { - for (const layer of props.layers) { - layers.push(layer); - layerIds.push(layer.id); - } - } - - function handleHover(pickingInfo: PickingInfo): void { - if (!polylineEditPointsModusActive) { - return; - } - if (pickingInfo.object && pickingInfo.object.index < currentlyEditedPolyline.length) { - setHoveredPolylinePointIndex(pickingInfo.object.index); - } else { - setHoveredPolylinePointIndex(null); - } - } - - function handleClick(pickingInfo: PickingInfo, event: any): void { - if (!polylineEditPointsModusActive) { - return; - } - if (pickingInfo.object && pickingInfo.object.index < currentlyEditedPolyline.length) { - setHoverPreviewPoint(null); - setSelectedPolylinePointIndex(pickingInfo.object.index); - event.stopPropagation(); - event.handled = true; - } else { - setSelectedPolylinePointIndex(null); - } - } - - function handleDragStart(): void { - setHoverPreviewPoint(null); - setIsDragging(true); - if (!polylineEditPointsModusActive) { - return; - } - setUserCameraInteractionActive(false); - } - - function handleDragEnd(): void { - setIsDragging(false); - setUserCameraInteractionActive(true); - } - - function handleDrag(pickingInfo: PickingInfo): void { - if (!polylineEditPointsModusActive) { - return; - } - if (pickingInfo.object) { - const index = pickingInfo.object.index; - if (!pickingInfo.coordinate) { - return; - } - setCurrentlyEditedPolyline((prev) => { - const newPolyline = prev.reduce((acc, point, i) => { - if (i === index && pickingInfo.coordinate) { - return [...acc, [pickingInfo.coordinate[0], pickingInfo.coordinate[1]]]; - } - return [...acc, point]; - }, [] as number[][]); - - if (props.onIntersectionPolylineChange) { - // props.onIntersectionPolylineChange(newPolyline); - } - return newPolyline; - }); - } - } - - if (props.enableIntersectionPolylineEditing && polylineEditingActive) { - const zMid = intersectionZValues?.zMid || 0; - const zExtension = intersectionZValues?.zExtension || 10; - - const currentlyEditedPolylineData = makePolylineData( - currentlyEditedPolyline, - zMid, - zExtension, - polylineEditPointsModusActive ? selectedPolylinePointIndex : -1, - hoveredPolylinePointIndex, - [255, 255, 255, 255], - ); - - const userPolylinePolygonsData = currentlyEditedPolylineData.polygonData; - const userPolylineColumnsData = currentlyEditedPolylineData.columnData; - - const userPolylineLineLayer = new SolidPolygonLayer({ - id: "user-polyline-line-layer", - data: userPolylinePolygonsData, - getPolygon: (d) => d.polygon, - getFillColor: (d) => d.color, - getElevation: zExtension, - getLineColor: [255, 255, 255], - getLineWidth: 20, - lineWidthMinPixels: 1, - extruded: true, - }); - layers.push(userPolylineLineLayer); - layerIds.push(userPolylineLineLayer.id); - - const userPolylinePointLayer = new ColumnLayer({ - id: "user-polyline-point-layer", - data: userPolylineColumnsData, - getElevation: zExtension, - getPosition: (d) => d.centroid, - getFillColor: (d) => d.color, - extruded: true, - radius: 50, - radiusUnits: "pixels", - pickable: true, - onHover: handleHover, - onClick: handleClick, - onDragStart: handleDragStart, - onDragEnd: handleDragEnd, - onDrag: handleDrag, - }); - layers.push(userPolylinePointLayer); - layerIds.push(userPolylinePointLayer.id); - - const previewData: { centroid: number[]; color: [number, number, number, number] }[] = []; - if (hoverPreviewPoint) { - previewData.push({ - centroid: hoverPreviewPoint, - color: [255, 255, 255, 100], - }); - } - - const userPolylineHoverPointLayer = new ColumnLayer({ - id: "user-polyline-hover-point-layer", - data: previewData, - getElevation: zExtension, - getPosition: (d) => d.centroid, - getFillColor: (d) => d.color, - extruded: true, - radius: 50, - radiusUnits: "pixels", - pickable: true, - }); - layers.push(userPolylineHoverPointLayer); - layerIds.push(userPolylineHoverPointLayer.id); - } - - function handleMouseClick(event: MapMouseEvent): void { - if (!polylineEditPointsModusActive) { - return; - } - - if (!event.x || !event.y) { - return; - } - - // Do not create new polyline point when clicking on an already existing point - for (const info of event.infos) { - if ("layer" in info && info.layer?.id === "user-polyline-point-layer") { - if (info.picked) { - return; - } - } - } - - const newPoint = [event.x, event.y]; - setCurrentlyEditedPolyline((prev) => { - let newPolyline: number[][] = []; - if (selectedPolylinePointIndex === null || selectedPolylinePointIndex === prev.length - 1) { - newPolyline = [...prev, newPoint]; - setSelectedPolylinePointIndex(prev.length); - } else if (selectedPolylinePointIndex === 0) { - newPolyline = [newPoint, ...prev]; - setSelectedPolylinePointIndex(0); - } else { - newPolyline = prev; - } - return newPolyline; - }); - - setHoverPreviewPoint(null); - } - - function handleMouseHover(event: MapMouseEvent): void { - if (!polylineEditPointsModusActive) { - setLayerPickingInfo(event.infos); - setHoverPreviewPoint(null); - return; - } - - if (event.x === undefined || event.y === undefined) { - setHoverPreviewPoint(null); - return; - } - - if ( - selectedPolylinePointIndex !== null && - selectedPolylinePointIndex !== 0 && - selectedPolylinePointIndex !== currentlyEditedPolyline.length - 1 - ) { - setHoverPreviewPoint(null); - return; - } - - setHoverPreviewPoint([event.x, event.y, intersectionZValues?.zMid ?? 0]); - } - - function handleMouseEvent(event: MapMouseEvent): void { - if (event.type === "click") { - handleMouseClick(event); - return; - } - if (event.type === "hover") { - handleMouseHover(event); - return; - } - } - - function handlePolylineEditingCancel(): void { - setPolylineEditingActive(false); - setPolylineEditPointsModusActive(false); - setCurrentlyEditedPolyline([]); - setSelectedPolylinePointIndex(null); - if (props.onIntersectionPolylineEditCancel) { - props.onIntersectionPolylineEditCancel(); - } - } - - function handlePolylineEditingFinish(name: string): void { - if (props.intersectionPolyline) { - if (props.onIntersectionPolylineChange && currentlyEditedPolyline.length > 1) { - props.onIntersectionPolylineChange({ - ...props.intersectionPolyline, - name, - path: currentlyEditedPolyline, - }); - } - } else { - if (props.onAddIntersectionPolyline && currentlyEditedPolyline.length > 1) { - props.onAddIntersectionPolyline({ - name, - fieldId: props.fieldId, - path: currentlyEditedPolyline, - }); - } - handlePolylineEditingCancel(); - } - } - - const handleDeleteCurrentlySelectedPoint = React.useCallback( - function handleDeleteCurrentlySelectedPoint() { - if (selectedPolylinePointIndex !== null) { - setSelectedPolylinePointIndex((prev) => (prev === null || prev === 0 ? null : prev - 1)); - setCurrentlyEditedPolyline((prev) => { - const newPolyline = prev.filter((_, i) => i !== selectedPolylinePointIndex); - return newPolyline; - }); - } - }, - [selectedPolylinePointIndex], - ); - - const handleCurrentlySelectedPointChange = React.useCallback( - function handleCurrentlySelectedPointChange(index: number, value: number) { - if (selectedPolylinePointIndex !== null) { - setCurrentlyEditedPolyline((prev) => { - const newPolyline = prev.map((point, i) => { - if (i === selectedPolylinePointIndex) { - const newPoint = [...point]; - newPoint[index] = value; - return newPoint; - } - return point; - }); - return newPolyline; - }); - } - }, - [selectedPolylinePointIndex], - ); - - React.useEffect(() => { - function handleKeyboardEvent(event: KeyboardEvent) { - if (!polylineEditPointsModusActive) { - return; - } - if (event.key === "Delete" && selectedPolylinePointIndex !== null) { - handleDeleteCurrentlySelectedPoint(); - } - } - - document.addEventListener("keydown", handleKeyboardEvent); - - return () => { - document.removeEventListener("keydown", handleKeyboardEvent); - }; - }, [selectedPolylinePointIndex, polylineEditPointsModusActive, handleDeleteCurrentlySelectedPoint]); - - function handleAddPolyline(): void { - setPolylineEditingActive(true); - handleFocusTopViewClick(); - setPolylineEditPointsModusActive(true); - setCurrentlyEditedPolyline([]); - setSelectedPolylinePointIndex(null); - } - - function handlePolylineEditingModusChange(active: boolean): void { - setPolylineEditPointsModusActive(active); - } - - function handleFocusTopViewClick(): void { - const targetX = (props.boundingBox.xmin + props.boundingBox.xmax) / 2; - const targetY = (props.boundingBox.ymin + props.boundingBox.ymax) / 2; - const targetZ = intersectionZValues?.zMid ?? 0; - - setCameraPositionSetByAction({ - rotationOrbit: 0, - rotationX: 90, - target: [targetX, targetY, targetZ], - zoom: NaN, - }); - } - - const handleVerticalScaleIncrease = React.useCallback( - function handleVerticalScaleIncrease(): void { - setVerticalScale((prev) => { - const newVerticalScale = prev + 0.1; - if (onVerticalScaleChange) { - onVerticalScaleChange(newVerticalScale); - } - return newVerticalScale; - }); - }, - [onVerticalScaleChange], - ); - - const handleVerticalScaleDecrease = React.useCallback( - function handleVerticalScaleIncrease(): void { - setVerticalScale((prev) => { - const newVerticalScale = Math.max(0.1, prev - 0.1); - if (onVerticalScaleChange) { - onVerticalScaleChange(newVerticalScale); - } - return newVerticalScale; - }); - }, - [onVerticalScaleChange], - ); - - function makeTooltip(info: PickingInfo): string | null { - if (!polylineEditPointsModusActive) { - if ((info as WellsPickInfo)?.logName) { - return (info as WellsPickInfo)?.logName; - } else if (info.layer?.id === "drawing-layer") { - return (info as LayerPickInfo).propertyValue?.toFixed(2) ?? null; - } - const feat = info.object as Feature; - return feat?.properties?.["name"]; - } - - if (isDragging) { - return null; - } - - if ( - selectedPolylinePointIndex !== null && - selectedPolylinePointIndex !== 0 && - selectedPolylinePointIndex !== currentlyEditedPolyline.length - 1 - ) { - return null; - } - - if (!info.coordinate) { - return null; - } - - return `x: ${info.coordinate[0].toFixed(2)}, y: ${info.coordinate[1].toFixed(2)}`; - } - - function makeHelperText(): React.ReactNode { - if (!props.enableIntersectionPolylineEditing) { - return null; - } - - if (!polylineEditPointsModusActive) { - return null; - } - - const nodes: React.ReactNode[] = []; - - if (selectedPolylinePointIndex === null) { - nodes.push("Click on map to add first point to polyline"); - } else if (selectedPolylinePointIndex === currentlyEditedPolyline.length - 1) { - nodes.push(
Click on map to add new point to end of polyline
); - nodes.push( -
- Press to remove selected point -
, - ); - } else if (selectedPolylinePointIndex === 0) { - nodes.push(
Click on map to add new point to start of polyline
); - nodes.push( -
- Press to remove selected point -
, - ); - } else { - nodes.push(
Select either end of polyline to add new point
); - nodes.push( -
- Press to remove selected point -
, - ); - } - - return nodes; - } - - React.useEffect(function handleMount() { - if (!internalRef.current) { - return; - } - - const internalRefCurrent = internalRef.current; - - function handlePointerEnter() { - setPointerOver(true); - } - - function handlePointerLeave() { - setPointerOver(false); - } - - internalRefCurrent.addEventListener("pointerenter", handlePointerEnter); - internalRefCurrent.addEventListener("pointerleave", handlePointerLeave); - - return function handleUnmount() { - internalRefCurrent.removeEventListener("pointerenter", handlePointerEnter); - internalRefCurrent.removeEventListener("pointerleave", handlePointerLeave); - }; - }); - - const colorTables = createContinuousColorScaleForMap(props.colorScale); - const colorScaleWithName = { id: "grid3d", colorScale: props.colorScale }; - - return ( -
- - - - {props.enableIntersectionPolylineEditing && polylineEditingActive && ( - - )} - setCameraPositionSetByAction(null)} - views={{ - layout: [1, 1], - showLabel: false, - viewports: [ - { - id: "main", - isSync: true, - show3D: props.show3D, - layerIds, - }, - ], - }} - getTooltip={makeTooltip} - verticalScale={verticalScale} - pickingRadius={10} - /> -
{makeHelperText()}
-
- ); -} - -type KeyboardButtonProps = { - text: string; -}; - -function KeyboardButton(props: KeyboardButtonProps): React.ReactNode { - return ( - - {props.text} - - ); -} - -type SubsurfaceViewerToolbarProps = { - visible: boolean; - zFactor: number; - onAddPolyline: () => void; - onFocusTopView: () => void; - onVerticalScaleIncrease: () => void; - onVerticalScaleDecrease: () => void; -}; - -function SubsurfaceViewerToolbar(props: SubsurfaceViewerToolbarProps): React.ReactNode { - function handleAddPolylineClick() { - props.onAddPolyline(); - } - - function handleFocusTopViewClick() { - props.onFocusTopView(); - } - - function handleVerticalScaleIncrease() { - props.onVerticalScaleIncrease(); - } - - function handleVerticalScaleDecrease() { - props.onVerticalScaleDecrease(); - } - - if (!props.visible) { - return null; - } - - return ( -
- - - - - - - {props.zFactor.toFixed(2)} - - - -
- ); -} - -function ToolBarDivider(): React.ReactNode { - return
; -} - -function makePolylineData( - polyline: number[][], - zMid: number, - zExtension: number, - selectedPolylineIndex: number | null, - hoveredPolylineIndex: number | null, - color: [number, number, number, number], -): { - polygonData: { polygon: number[][]; color: number[] }[]; - columnData: { index: number; centroid: number[]; color: number[] }[]; -} { - const polygonData: { - polygon: number[][]; - color: number[]; - }[] = []; - - const columnData: { - index: number; - centroid: number[]; - color: number[]; - }[] = []; - - const width = 10; - for (let i = 0; i < polyline.length; i++) { - const startPoint = polyline[i]; - const endPoint = polyline[i + 1]; - - if (i < polyline.length - 1) { - const lineVector = [endPoint[0] - startPoint[0], endPoint[1] - startPoint[1], 0]; - const zVector = [0, 0, 1]; - const normalVector = [ - lineVector[1] * zVector[2] - lineVector[2] * zVector[1], - lineVector[2] * zVector[0] - lineVector[0] * zVector[2], - lineVector[0] * zVector[1] - lineVector[1] * zVector[0], - ]; - const normalizedNormalVector = [ - normalVector[0] / Math.sqrt(normalVector[0] ** 2 + normalVector[1] ** 2 + normalVector[2] ** 2), - normalVector[1] / Math.sqrt(normalVector[0] ** 2 + normalVector[1] ** 2 + normalVector[2] ** 2), - ]; - - const point1 = [ - startPoint[0] - (normalizedNormalVector[0] * width) / 2, - startPoint[1] - (normalizedNormalVector[1] * width) / 2, - zMid - zExtension / 2, - ]; - - const point2 = [ - endPoint[0] - (normalizedNormalVector[0] * width) / 2, - endPoint[1] - (normalizedNormalVector[1] * width) / 2, - zMid - zExtension / 2, - ]; - - const point3 = [ - endPoint[0] + (normalizedNormalVector[0] * width) / 2, - endPoint[1] + (normalizedNormalVector[1] * width) / 2, - zMid - zExtension / 2, - ]; - - const point4 = [ - startPoint[0] + (normalizedNormalVector[0] * width) / 2, - startPoint[1] + (normalizedNormalVector[1] * width) / 2, - zMid - zExtension / 2, - ]; - - const polygon: number[][] = [point1, point2, point3, point4]; - polygonData.push({ polygon, color: [color[0], color[1], color[2], color[3] / 2] }); - } - - let adjustedColor = color; - if (i === selectedPolylineIndex) { - if (i === 0 || i === polyline.length - 1) { - adjustedColor = [0, 255, 0, color[3]]; - if (i === hoveredPolylineIndex) { - adjustedColor = [200, 255, 200, color[3]]; - } - } else { - adjustedColor = [60, 60, 255, color[3]]; - if (i === hoveredPolylineIndex) { - adjustedColor = [120, 120, 255, color[3]]; - } - } - } else if (i === hoveredPolylineIndex) { - adjustedColor = [120, 120, 255, color[3]]; - } - columnData.push({ - index: i, - centroid: [startPoint[0], startPoint[1], zMid - zExtension / 2], - color: adjustedColor, - }); - } - - return { polygonData, columnData }; -} diff --git a/frontend/src/modules/3DViewer/view/components/SyncedSettingsUpdateWrapper.tsx b/frontend/src/modules/3DViewer/view/components/SyncedSettingsUpdateWrapper.tsx deleted file mode 100644 index 992db22245..0000000000 --- a/frontend/src/modules/3DViewer/view/components/SyncedSettingsUpdateWrapper.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from "react"; - -import type { ViewContext } from "@framework/ModuleContext"; -import { SyncSettingKey, SyncSettingsHelper } from "@framework/SyncSettings"; -import type { WorkbenchServices } from "@framework/WorkbenchServices"; - -import type { HoverUpdateWrapperProps } from "./HoverUpdateWrapper"; -import { HoverUpdateWrapper } from "./HoverUpdateWrapper"; - -export type SyncedSettingsUpdateWrapperProps = { - workbenchServices: WorkbenchServices; - viewContext: ViewContext; -} & HoverUpdateWrapperProps; - -export function SyncedSettingsUpdateWrapper(props: SyncedSettingsUpdateWrapperProps): React.ReactNode { - const syncedSettingKeys = props.viewContext.useSyncedSettingKeys(); - - const syncHelper = React.useMemo(() => { - return new SyncSettingsHelper(syncedSettingKeys, props.workbenchServices, props.viewContext); - }, [props.workbenchServices, syncedSettingKeys, props.viewContext]); - - const syncedVerticalScale = syncHelper.useValue(SyncSettingKey.VERTICAL_SCALE, "global.syncValue.verticalScale"); - - const handleVerticalScaleChange = React.useCallback( - function handleVerticalScaleChange(verticalScale: number) { - syncHelper.publishValue(SyncSettingKey.VERTICAL_SCALE, "global.syncValue.verticalScale", verticalScale); - }, - [syncHelper], - ); - - return ( - - ); -} diff --git a/frontend/src/modules/3DViewer/view/components/Toolbar.tsx b/frontend/src/modules/3DViewer/view/components/Toolbar.tsx new file mode 100644 index 0000000000..cf4a02301a --- /dev/null +++ b/frontend/src/modules/3DViewer/view/components/Toolbar.tsx @@ -0,0 +1,204 @@ +import React from "react"; + +import { + Add, + Check, + FilterCenterFocus, + GridOn, + KeyboardDoubleArrowLeft, + KeyboardDoubleArrowRight, + Polyline, + Remove, +} from "@mui/icons-material"; + +import { Button } from "@lib/components/Button"; +import { HoldPressedIntervalCallbackButton } from "@lib/components/HoldPressedIntervalCallbackButton/holdPressedIntervalCallbackButton"; +import { Input } from "@lib/components/Input"; +import { ToggleButton } from "@lib/components/ToggleButton"; +import { AddPathPointIcon, DrawPathIcon, RemovePathPointIcon } from "@lib/icons/"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import { Toolbar as GenericToolbar, ToolBarDivider } from "@modules/_shared/components/Toolbar"; + +import { PolylineEditingMode } from "../typesAndEnums"; +import { type PolylinesPlugin, PolylinesPluginTopic } from "../utils/PolylinesPlugin"; + +export type ToolbarProps = { + verticalScale: number; + hasActivePolyline: boolean; + activePolylineName?: string; + onFitInView: () => void; + polylinesPlugin: PolylinesPlugin; + onGridVisibilityChange: (visible: boolean) => void; + onVerticalScaleChange(value: number): void; + onPolylineNameChange(name: string): void; +}; + +export function Toolbar(props: ToolbarProps): React.ReactNode { + const [expanded, setExpanded] = React.useState(false); + const [gridVisible, setGridVisible] = React.useState(false); + const [polylineName, setPolylineName] = React.useState(null); + const [prevEditingPolylineId, setPrevEditingPolylineId] = React.useState(null); + const polylineEditingMode = usePublishSubscribeTopicValue(props.polylinesPlugin, PolylinesPluginTopic.EDITING_MODE); + const editingPolylineId = usePublishSubscribeTopicValue( + props.polylinesPlugin, + PolylinesPluginTopic.EDITING_POLYLINE_ID, + ); + + if (editingPolylineId !== prevEditingPolylineId) { + setPrevEditingPolylineId(editingPolylineId); + const activePolyline = props.polylinesPlugin.getActivePolyline(); + if (activePolyline) { + setPolylineName(activePolyline.name); + } + } + + function handleFitInViewClick() { + props.onFitInView(); + } + + function handleGridToggle() { + props.onGridVisibilityChange(!gridVisible); + setGridVisible(!gridVisible); + } + + function handleVerticalScaleIncrease() { + props.onVerticalScaleChange(props.verticalScale + 1); + } + + function handleVerticalScaleDecrease() { + props.onVerticalScaleChange(props.verticalScale - 1); + } + + function handleTogglePolylineEditing() { + if (polylineEditingMode !== PolylineEditingMode.NONE) { + props.polylinesPlugin.setEditingMode(PolylineEditingMode.NONE); + return; + } + props.polylinesPlugin.setEditingMode(PolylineEditingMode.IDLE); + } + + function handlePolylineEditingModeChange(mode: PolylineEditingMode) { + props.polylinesPlugin.setEditingMode(mode); + } + + function handlePolylineNameChange(event: React.ChangeEvent) { + setPolylineName(event.target.value); + } + + function handleSavePolylineClick() { + if (!polylineName) { + return; + } + props.polylinesPlugin.setEditingMode(PolylineEditingMode.IDLE); + props.onPolylineNameChange(polylineName); + props.polylinesPlugin.handleClickAway(); + } + + return ( + +
+
+ +
+ + + + + + + + + + + + + {props.verticalScale} + + + + +
+ + +
+ {polylineEditingMode !== PolylineEditingMode.NONE && expanded && ( + <> +
+ + handlePolylineEditingModeChange( + active ? PolylineEditingMode.DRAW : PolylineEditingMode.IDLE, + ) + } + > + + + + handlePolylineEditingModeChange( + active ? PolylineEditingMode.ADD_POINT : PolylineEditingMode.IDLE, + ) + } + > + + + + handlePolylineEditingModeChange( + active ? PolylineEditingMode.REMOVE_POINT : PolylineEditingMode.IDLE, + ) + } + > + + + + +
+ + )} +
+
+ ); +} diff --git a/frontend/src/modules/3DViewer/view/queries/gridQueries.ts b/frontend/src/modules/3DViewer/view/queries/gridQueries.ts deleted file mode 100644 index c608120006..0000000000 --- a/frontend/src/modules/3DViewer/view/queries/gridQueries.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { UseQueryResult } from "@tanstack/react-query"; -import { useQuery } from "@tanstack/react-query"; - -import { getGridParameterOptions, getGridSurfaceOptions } from "@api"; - -import type { GridMappedProperty_trans, GridSurface_trans } from "./queryDataTransforms"; -import { transformGridMappedProperty, transformGridSurface } from "./queryDataTransforms"; - -export function useGridSurfaceQuery(options: { - caseUuid: string | null; - ensembleName: string | null; - gridName: string | null; - realizationNum: number | null; - iMin?: number; - iMax?: number; - jMin?: number; - jMax?: number; - kMin?: number; - kMax?: number; -}): UseQueryResult { - return useQuery({ - ...getGridSurfaceOptions({ - query: { - case_uuid: options.caseUuid ?? "", - ensemble_name: options.ensembleName ?? "", - grid_name: options.gridName ?? "", - realization_num: options.realizationNum ?? 0, - i_min: options.iMin, - i_max: options.iMax, - j_min: options.jMin, - j_max: options.jMax, - k_min: options.kMin, - k_max: options.kMax, - }, - }), - select: transformGridSurface, - enabled: Boolean( - options.caseUuid && options.ensembleName && options.gridName && options.realizationNum !== null, - ), - }); -} - -export function useGridParameterQuery(options: { - caseUuid: string | null; - ensembleName: string | null; - gridName: string | null; - parameterName: string | null; - realizationNum: number | null; - parameterTimeOrIntervalString?: string | null; - iMin?: number; - iMax?: number; - jMin?: number; - jMax?: number; - kMin?: number; - kMax?: number; -}): UseQueryResult { - return useQuery({ - ...getGridParameterOptions({ - query: { - case_uuid: options.caseUuid ?? "", - ensemble_name: options.ensembleName ?? "", - grid_name: options.gridName ?? "", - parameter_name: options.parameterName ?? "", - realization_num: options.realizationNum ?? 0, - parameter_time_or_interval_str: options.parameterTimeOrIntervalString, - i_min: options.iMin, - i_max: options.iMax, - j_min: options.jMin, - j_max: options.jMax, - k_min: options.kMin, - k_max: options.kMax, - }, - }), - select: transformGridMappedProperty, - enabled: Boolean( - options.caseUuid && - options.ensembleName && - options.gridName && - options.parameterName && - options.realizationNum !== null, - ), - }); -} diff --git a/frontend/src/modules/3DViewer/view/queries/polylineIntersection.ts b/frontend/src/modules/3DViewer/view/queries/polylineIntersection.ts deleted file mode 100644 index 4cbb135691..0000000000 --- a/frontend/src/modules/3DViewer/view/queries/polylineIntersection.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { UseQueryResult } from "@tanstack/react-query"; -import { useQuery } from "@tanstack/react-query"; - -import { postGetPolylineIntersectionOptions } from "@api"; -import type { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; -import type { PolylineIntersection_trans } from "@modules/3DViewer/view/queries/queryDataTransforms"; -import { transformPolylineIntersection } from "@modules/3DViewer/view/queries/queryDataTransforms"; - -export function useGridPolylineIntersection( - ensembleIdent: RegularEnsembleIdent | null, - gridModelName: string | null, - gridModelParameterName: string | null, - gridModelDateOrInterval: string | null, - realizationNum: number | null, - polyline_utm_xy: number[], - enabled: boolean, -): UseQueryResult { - return useQuery({ - ...postGetPolylineIntersectionOptions({ - query: { - case_uuid: ensembleIdent?.getCaseUuid() ?? "", - ensemble_name: ensembleIdent?.getEnsembleName() ?? "", - grid_name: gridModelName ?? "", - parameter_name: gridModelParameterName ?? "", - realization_num: realizationNum ?? 0, - parameter_time_or_interval_str: gridModelDateOrInterval, - }, - body: { polyline_utm_xy }, - }), - select: transformPolylineIntersection, - enabled: Boolean( - ensembleIdent && gridModelName && realizationNum !== null && polyline_utm_xy.length && enabled, - ), - }); -} diff --git a/frontend/src/modules/3DViewer/view/queries/wellboreSchematicsQueries.ts b/frontend/src/modules/3DViewer/view/queries/wellboreSchematicsQueries.ts deleted file mode 100644 index a5457a72d6..0000000000 --- a/frontend/src/modules/3DViewer/view/queries/wellboreSchematicsQueries.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { UseQueryResult } from "@tanstack/react-query"; -import { useQuery } from "@tanstack/react-query"; - -import type { WellboreCasing_api, WellboreCompletion_api, WellborePerforation_api } from "@api"; -import { getWellboreCasingsOptions, getWellboreCompletionsOptions, getWellborePerforationsOptions } from "@api"; - -export function useWellboreCasingsQuery(wellboreUuid: string | undefined): UseQueryResult { - return useQuery({ - ...getWellboreCasingsOptions({ - query: { - wellbore_uuid: wellboreUuid ?? "", - }, - }), - enabled: Boolean(wellboreUuid), - }); -} - -export function useWellborePerforationsQuery( - wellboreUuid: string | undefined, -): UseQueryResult { - return useQuery({ - ...getWellborePerforationsOptions({ - query: { - wellbore_uuid: wellboreUuid ?? "", - }, - }), - enabled: Boolean(wellboreUuid), - }); -} - -export function useWellboreCompletionsQuery( - wellboreUuid: string | undefined, -): UseQueryResult { - return useQuery({ - ...getWellboreCompletionsOptions({ - query: { - wellbore_uuid: wellboreUuid ?? "", - }, - }), - enabled: Boolean(wellboreUuid), - }); -} diff --git a/frontend/src/modules/3DViewer/view/typesAndEnums.ts b/frontend/src/modules/3DViewer/view/typesAndEnums.ts new file mode 100644 index 0000000000..446c669fe4 --- /dev/null +++ b/frontend/src/modules/3DViewer/view/typesAndEnums.ts @@ -0,0 +1,18 @@ +export enum PolylineEditingMode { + DRAW = "draw", + ADD_POINT = "add_point", + REMOVE_POINT = "remove_point", + NONE = "none", + IDLE = "idle", +} + +export type ContextMenuItem = { + icon?: React.ReactNode; + label: string; + onClick: () => void; +}; + +export enum PreferredViewLayout { + HORIZONTAL = "horizontal", + VERTICAL = "vertical", +} diff --git a/frontend/src/modules/3DViewer/view/utils/DeckGlInstanceManager.ts b/frontend/src/modules/3DViewer/view/utils/DeckGlInstanceManager.ts new file mode 100644 index 0000000000..e92ff3e50c --- /dev/null +++ b/frontend/src/modules/3DViewer/view/utils/DeckGlInstanceManager.ts @@ -0,0 +1,362 @@ +/* +This manager is responsible for managing plugins for DeckGL, forwarding events to them, and adding/adjusting layers based on the plugins' responses. +*/ +import type { Layer, PickingInfo } from "@deck.gl/core"; +import type { DeckGLProps, DeckGLRef } from "@deck.gl/react"; +import type { MapMouseEvent } from "@webviz/subsurface-viewer"; +import { v4 } from "uuid"; + +import { PublishSubscribeDelegate, type PublishSubscribe } from "@lib/utils/PublishSubscribeDelegate"; +import type { SubsurfaceViewerWithCameraStateProps } from "@modules/_shared/components/SubsurfaceViewerWithCameraState"; + +export type ContextMenuItem = { + icon?: React.ReactElement; + label: string; + onClick: () => void; +}; + +export type ContextMenu = { + position: { x: number; y: number }; + items: ContextMenuItem[]; +}; + +export class DeckGlPlugin { + private _manager: DeckGlInstanceManager; + private _id: string; + + constructor(manager: DeckGlInstanceManager) { + this._manager = manager; + this._id = v4(); + } + + protected requireRedraw() { + this._manager.redraw(); + } + + protected requestDisablePanning() { + this._manager.disablePanning(); + } + + protected requestEnablePanning() { + this._manager.enablePanning(); + } + + protected getFirstLayerUnderCursorInfo(x: number, y: number): PickingInfo | undefined { + return this._manager.pickFirstLayerUnderCursorInfo(x, y); + } + + protected setDragStart() { + this._manager.setDragStart(); + } + + protected setDragEnd() { + this._manager.setDragEnd(); + } + + protected makeLayerId(layerId: string): string { + return `${this._id}-${layerId}`; + } + + handleDrag?(pickingInfo: PickingInfo): void; + handleLayerHover?(pickingInfo: PickingInfo): void; + handleLayerClick?(pickingInfo: PickingInfo): void; + handleClickAway?(): void; + handleGlobalMouseHover?(pickingInfo: PickingInfo): void; + handleGlobalMouseClick?(pickingInfo: PickingInfo): boolean; + handleKeyUpEvent?(key: string): void; + handleKeyDownEvent?(key: string): void; + getCursor?(pickingInfo: PickingInfo): string | null; + getLayers?(): Layer[]; + getContextMenuItems?(pickingInfo: PickingInfo): ContextMenuItem[]; +} + +export enum DeckGlInstanceManagerTopic { + REDRAW = "REDRAW", + CONTEXT_MENU = "CONTEXT_MENU", +} + +export type DeckGlInstanceManagerPayloads = { + [DeckGlInstanceManagerTopic.REDRAW]: number; + [DeckGlInstanceManagerTopic.CONTEXT_MENU]: ContextMenu | null; +}; + +type KeyboardEventListener = (event: KeyboardEvent) => void; + +export class DeckGlInstanceManager implements PublishSubscribe { + private _publishSubscribeDelegate = new PublishSubscribeDelegate(); + + private _isDragging: boolean = false; + private _ref: DeckGLRef | null; + private _plugins: DeckGlPlugin[] = []; + private _layersIdPluginMap = new Map(); + private _cursor: string = "auto"; + private _redrawCycle: number = 0; + private _eventListeners: KeyboardEventListener[] = []; + private _contextMenu: ContextMenu | null = null; + private _verticalScale: number = 1; + + constructor(ref: DeckGLRef | null) { + this._ref = ref; + this.addKeyboardEventListeners(); + } + + setRef(ref: DeckGLRef | null) { + this._ref = ref; + } + + private addKeyboardEventListeners() { + const handleKeyDown = this.handleKeyDown.bind(this); + const handleKeyUp = this.handleKeyUp.bind(this); + + this._eventListeners = [handleKeyDown, handleKeyUp]; + + document.addEventListener("keyup", handleKeyUp); + document.addEventListener("keydown", handleKeyDown); + } + + private maybeRemoveKeyboardEventListeners() { + for (const listener of this._eventListeners) { + document.removeEventListener("keydown", listener); + } + } + + private handleKeyDown(event: KeyboardEvent) { + for (const plugin of this._plugins) { + plugin.handleKeyDownEvent?.(event.key); + } + } + + private handleKeyUp(event: KeyboardEvent) { + for (const plugin of this._plugins) { + plugin.handleKeyUpEvent?.(event.key); + } + } + + addPlugin(plugin: DeckGlPlugin) { + this._plugins.push(plugin); + } + + redraw() { + this._redrawCycle++; + this._publishSubscribeDelegate.notifySubscribers(DeckGlInstanceManagerTopic.REDRAW); + } + + disablePanning() { + if (!this._ref) { + return; + } + + this._ref.deck?.setProps({ + controller: { + dragPan: false, + dragRotate: false, + }, + }); + } + + enablePanning() { + if (!this._ref) { + return; + } + + this._ref.deck?.setProps({ + controller: { + dragRotate: true, + dragPan: true, + }, + }); + } + + getPublishSubscribeDelegate(): PublishSubscribeDelegate { + return this._publishSubscribeDelegate; + } + + makeSnapshotGetter(topic: T): () => DeckGlInstanceManagerPayloads[T] { + const snapshotGetter = (): any => { + if (topic === DeckGlInstanceManagerTopic.REDRAW) { + return this._redrawCycle; + } + if (topic === DeckGlInstanceManagerTopic.CONTEXT_MENU) { + return this._contextMenu; + } + + throw new Error(`Unknown topic ${topic}`); + }; + + return snapshotGetter; + } + + private getLayerIdFromPickingInfo(pickingInfo: PickingInfo): string | undefined { + return pickingInfo.layer?.id; + } + + setDragStart() { + this._isDragging = true; + } + + setDragEnd() { + this._isDragging = false; + } + + handleDrag(pickingInfo: PickingInfo): void { + const layerId = this.getLayerIdFromPickingInfo(pickingInfo); + if (!layerId) { + return; + } + + const plugin = this._layersIdPluginMap.get(layerId); + if (!plugin) { + return; + } + + plugin.handleDrag?.(pickingInfo); + } + + handleMouseEvent(event: MapMouseEvent) { + if (this._isDragging) { + return; + } + + if (event.type !== "hover") { + this._contextMenu = null; + this._publishSubscribeDelegate.notifySubscribers(DeckGlInstanceManagerTopic.CONTEXT_MENU); + } + + const firstLayerInfo = this.getFirstLayerUnderCursorInfo(event); + if (!firstLayerInfo || !firstLayerInfo.coordinate) { + return; + } + + const layerId = this.getLayerIdFromPickingInfo(firstLayerInfo); + const plugin = this._layersIdPluginMap.get(layerId ?? ""); + if (layerId && plugin) { + if (event.type === "hover") { + plugin.handleLayerHover?.(firstLayerInfo); + this._cursor = plugin.getCursor?.(firstLayerInfo) ?? "auto"; + } + + if (event.type === "click") { + plugin.handleLayerClick?.(firstLayerInfo); + } + + if (event.type === "contextmenu") { + const contextMenuItems = plugin.getContextMenuItems?.(firstLayerInfo) ?? []; + this._contextMenu = { + position: { x: firstLayerInfo.x, y: firstLayerInfo.y }, + items: contextMenuItems, + }; + this._publishSubscribeDelegate.notifySubscribers(DeckGlInstanceManagerTopic.CONTEXT_MENU); + } + return; + } + + const pluginsThatDidNotAcceptEvent: DeckGlPlugin[] = []; + for (const plugin of this._plugins) { + if (event.type === "hover") { + plugin.handleGlobalMouseHover?.(firstLayerInfo); + this._cursor = "auto"; + } else if (event.type === "click") { + const accepted = plugin.handleGlobalMouseClick?.(firstLayerInfo); + if (!accepted) { + pluginsThatDidNotAcceptEvent.push(plugin); + } + } + } + + if (event.type === "click") { + for (const plugin of pluginsThatDidNotAcceptEvent) { + plugin.handleClickAway?.(); + } + } + } + + pickFirstLayerUnderCursorInfo(x: number, y: number): PickingInfo | undefined { + if (!this._ref?.deck) { + return undefined; + } + + const layer = + this._ref.deck.pickMultipleObjects({ x, y, radius: 10, depth: 1, unproject3D: true }) ?? undefined; + + if (!layer || !layer.length) { + return undefined; + } + + const firstLayerInfo = layer[0]; + if (!firstLayerInfo.coordinate) { + return undefined; + } + + firstLayerInfo.coordinate = [ + firstLayerInfo.coordinate[0], + firstLayerInfo.coordinate[1], + firstLayerInfo.coordinate[2] / this._verticalScale, + ]; + + return firstLayerInfo; + } + + private getFirstLayerUnderCursorInfo(event: MapMouseEvent): PickingInfo | undefined { + for (const info of event.infos) { + if (info.coordinate && info.x) { + return info; + } + } + + return undefined; + } + + getCursor(cursorState: Parameters>[0]): string { + if (cursorState.isDragging) { + return "grabbing"; + } + + return this._cursor; + } + + makeDeckGlComponentProps(props: SubsurfaceViewerWithCameraStateProps): SubsurfaceViewerWithCameraStateProps { + const pluginLayerIds: string[] = []; + const layers = [...(props.layers ?? [])]; + for (const plugin of this._plugins) { + const pluginLayers = plugin.getLayers?.() ?? []; + layers.push(...pluginLayers); + for (const layer of pluginLayers) { + if (pluginLayerIds.includes(layer.id)) { + throw new Error( + `Layer with id ${layer.id} is already registered by another plugin. This may lead to unexpected behavior. Make sure to use the makeLayerId method to create unique layer ids in your plugins.`, + ); + } + this._layersIdPluginMap.set(layer.id, plugin); + pluginLayerIds.push(layer.id); + } + } + + if ("verticalScale" in props) { + this._verticalScale = props.verticalScale ?? 1; + } + + return { + ...props, + onDrag: this.handleDrag.bind(this), + onMouseEvent: (event) => { + this.handleMouseEvent(event); + props.onMouseEvent?.(event); + }, + getCursor: (state) => this.getCursor(state), + layers, + views: { + ...props.views, + viewports: (props.views?.viewports ?? []).map((viewport) => ({ + ...viewport, + layerIds: [...(viewport.layerIds ?? []), ...pluginLayerIds], + })), + layout: props.views?.layout ?? [1, 1], + }, + }; + } + + beforeDestroy() { + this.enablePanning(); + this.maybeRemoveKeyboardEventListeners(); + } +} diff --git a/frontend/src/modules/3DViewer/view/utils/PolylinesPlugin.tsx b/frontend/src/modules/3DViewer/view/utils/PolylinesPlugin.tsx new file mode 100644 index 0000000000..bb7e3da9f8 --- /dev/null +++ b/frontend/src/modules/3DViewer/view/utils/PolylinesPlugin.tsx @@ -0,0 +1,591 @@ +import type { Layer, PickingInfo } from "@deck.gl/core"; +import { Edit, Remove } from "@mui/icons-material"; +import { isEqual } from "lodash"; +import { v4 } from "uuid"; + +import addPathIcon from "@assets/add_path.cur?url"; +import continuePathIcon from "@assets/continue_path.cur?url"; +import removePathIcon from "@assets/remove_path.cur?url"; + +import { type PublishSubscribe, PublishSubscribeDelegate } from "@lib/utils/PublishSubscribeDelegate"; + +import { + AllowHoveringOf, + EditablePolylineLayer, + isEditablePolylineLayerPickingInfo, +} from "../../customDeckGlLayers/EditablePolylineLayer"; +import { PolylinesLayer, isPolylinesLayerPickingInfo } from "../../customDeckGlLayers/PolylinesLayer"; + +import { type ContextMenuItem, type DeckGlInstanceManager, DeckGlPlugin } from "./DeckGlInstanceManager"; + +export type Polyline = { + id: string; + name: string; + color: [number, number, number]; + path: number[][]; + version?: number; +}; + +export enum PolylineEditingMode { + DRAW = "draw", + ADD_POINT = "add_point", + REMOVE_POINT = "remove_point", + NONE = "none", + IDLE = "idle", +} + +export enum PolylinesPluginTopic { + EDITING_POLYLINE_ID = "editing_polyline_id", + EDITING_MODE = "editing_mode", + POLYLINES = "polylines", +} + +export type PolylinesPluginTopicPayloads = { + [PolylinesPluginTopic.EDITING_MODE]: PolylineEditingMode; + [PolylinesPluginTopic.EDITING_POLYLINE_ID]: string | null; + [PolylinesPluginTopic.POLYLINES]: Polyline[]; +}; + +enum AppendToPathLocation { + START = "start", + END = "end", +} + +function* defaultColorGenerator() { + const colors: [number, number, number][] = [ + [255, 0, 0], + [0, 255, 0], + [0, 0, 255], + [255, 255, 0], + [255, 0, 255], + [0, 255, 255], + ]; + + let index = 0; + while (true) { + yield colors[index]; + index = (index + 1) % colors.length; + } +} + +export class PolylinesPlugin extends DeckGlPlugin implements PublishSubscribe { + private _currentEditingPolylineId: string | null = null; + private _currentEditingPolylinePathReferencePointIndex: number | null = null; + private _polylines: Polyline[] = []; + private _editingMode: PolylineEditingMode = PolylineEditingMode.NONE; + private _draggedPathPointIndex: number | null = null; + private _appendToPathLocation: AppendToPathLocation = AppendToPathLocation.END; + private _selectedPolylineId: string | null = null; + private _hoverPoint: number[] | null = null; + private _visiblePolylineIds: string[] = []; + private _colorGenerator: Generator<[number, number, number]>; + + private _publishSubscribeDelegate = new PublishSubscribeDelegate(); + + private setCurrentEditingPolylineId(id: string | null, shouldRedraw = false): void { + this._currentEditingPolylineId = id; + this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.EDITING_POLYLINE_ID); + if (shouldRedraw) { + this.requireRedraw(); + } + } + + constructor(manager: DeckGlInstanceManager, colorGenerator?: Generator<[number, number, number]>) { + super(manager); + this._colorGenerator = colorGenerator ?? defaultColorGenerator(); + } + + setVisiblePolylineIds(visiblePolylineIds: string[]): void { + this._visiblePolylineIds = visiblePolylineIds; + } + + getActivePolyline(): Polyline | undefined { + return this._polylines.find((polyline) => polyline.id === this._currentEditingPolylineId); + } + + getPolylines(): Polyline[] { + return this._polylines; + } + + setPolylines(polylines: Polyline[]): void { + if (isEqual(this._polylines, polylines)) { + return; + } + this._polylines = polylines; + this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.POLYLINES); + this.requireRedraw(); + } + + setActivePolylineName(name: string): void { + const activePolyline = this.getActivePolyline(); + if (!activePolyline) { + return; + } + + this._polylines = this._polylines.map((polyline) => { + if (polyline.id === activePolyline.id) { + return { + ...polyline, + name, + }; + } + return polyline; + }); + this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.POLYLINES); + this.requireRedraw(); + } + + getPublishSubscribeDelegate(): PublishSubscribeDelegate { + return this._publishSubscribeDelegate; + } + + setEditingMode(mode: PolylineEditingMode): void { + this._editingMode = mode; + this._hoverPoint = null; + if (mode === PolylineEditingMode.NONE) { + this._currentEditingPolylinePathReferencePointIndex = null; + this.setCurrentEditingPolylineId(null); + } + this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.EDITING_MODE); + if (mode === PolylineEditingMode.NONE) { + this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.POLYLINES); + } + this.requireRedraw(); + } + + getEditingMode(): PolylineEditingMode { + return this._editingMode; + } + + getCurrentEditingPolylineId(): string | null { + return this._currentEditingPolylineId; + } + + handleKeyUpEvent(key: string): void { + if (key === "Escape") { + if (this._editingMode === PolylineEditingMode.NONE) { + this._currentEditingPolylinePathReferencePointIndex = null; + this.requireRedraw(); + return; + } + if (this._editingMode === PolylineEditingMode.IDLE) { + this._currentEditingPolylinePathReferencePointIndex = null; + this._hoverPoint = null; + this.requireRedraw(); + return; + } + + this._hoverPoint = null; + this.setEditingMode(PolylineEditingMode.IDLE); + return; + } + if (key === "Delete") { + if (this._editingMode === PolylineEditingMode.IDLE) { + if (this._selectedPolylineId) { + this._polylines = this._polylines.filter((polyline) => polyline.id !== this._selectedPolylineId); + this._selectedPolylineId = null; + this.requireRedraw(); + } + return; + } + } + } + + handleLayerClick(pickingInfo: PickingInfo): void { + if (this._editingMode === PolylineEditingMode.NONE || this._editingMode === PolylineEditingMode.IDLE) { + if (isPolylinesLayerPickingInfo(pickingInfo)) { + this._selectedPolylineId = pickingInfo.polylineId ?? null; + this.requireRedraw(); + } + return; + } + + if (!isEditablePolylineLayerPickingInfo(pickingInfo)) { + return; + } + + const activePolyline = this.getActivePolyline(); + if (!activePolyline) { + return; + } + + if (pickingInfo.editableEntity?.type === "point") { + if (![PolylineEditingMode.DRAW, PolylineEditingMode.REMOVE_POINT].includes(this._editingMode)) { + return; + } + + const index = pickingInfo.editableEntity.index; + if (this._editingMode === PolylineEditingMode.DRAW) { + if ( + (index === 0 || index === activePolyline.path.length - 1) && + this._currentEditingPolylinePathReferencePointIndex !== index + ) { + this._appendToPathLocation = index === 0 ? AppendToPathLocation.START : AppendToPathLocation.END; + this._currentEditingPolylinePathReferencePointIndex = index; + this.requireRedraw(); + return; + } + } + + const newPath = activePolyline.path.filter((_, i) => i !== index); + let newReferencePathPointIndex: number | null = null; + if (this._currentEditingPolylinePathReferencePointIndex !== null) { + newReferencePathPointIndex = Math.max(0, this._currentEditingPolylinePathReferencePointIndex - 1); + if (index > this._currentEditingPolylinePathReferencePointIndex) { + newReferencePathPointIndex = this._currentEditingPolylinePathReferencePointIndex; + } + if (activePolyline.path.length - 1 < 1) { + newReferencePathPointIndex = null; + } + } + + if (newPath.length === 0) { + this._polylines = this._polylines.filter((polyline) => polyline.id !== activePolyline.id); + this.setCurrentEditingPolylineId(null); + this._currentEditingPolylinePathReferencePointIndex = null; + this.setEditingMode(PolylineEditingMode.IDLE); + this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.POLYLINES); + return; + } + this.updateActivePolylinePath(newPath); + this._currentEditingPolylinePathReferencePointIndex = newReferencePathPointIndex; + this.requireRedraw(); + return; + } + + if (pickingInfo.editableEntity?.type === "line") { + if (![PolylineEditingMode.DRAW, PolylineEditingMode.ADD_POINT].includes(this._editingMode)) { + return; + } + + if (!pickingInfo.coordinate) { + return; + } + + const index = pickingInfo.editableEntity.index; + const newPath = [...activePolyline.path]; + newPath.splice(index + 1, 0, [...pickingInfo.coordinate]); + this.updateActivePolylinePath(newPath); + + let newReferencePathPointIndex: number | null = null; + if ( + this._currentEditingPolylinePathReferencePointIndex !== null && + this._appendToPathLocation !== AppendToPathLocation.START + ) { + newReferencePathPointIndex = this._currentEditingPolylinePathReferencePointIndex + 1; + if (index > this._currentEditingPolylinePathReferencePointIndex) { + newReferencePathPointIndex = this._currentEditingPolylinePathReferencePointIndex; + } + } + + this._currentEditingPolylinePathReferencePointIndex = newReferencePathPointIndex; + this.requireRedraw(); + } + } + + private updateActivePolylinePath(newPath: number[][]): void { + const activePolyline = this.getActivePolyline(); + if (!activePolyline) { + return; + } + + if (isEqual(activePolyline.path, newPath)) { + return; + } + + this._polylines = this._polylines.map((polyline) => { + if (polyline.id === activePolyline.id) { + return { + ...polyline, + path: newPath, + version: (polyline.version ?? 0) + 1, + }; + } + return polyline; + }); + + this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.POLYLINES); + } + + handleClickAway(): void { + if (this._editingMode === PolylineEditingMode.NONE) { + return; + } + this._selectedPolylineId = null; + if (this._editingMode !== PolylineEditingMode.DRAW) { + this.setCurrentEditingPolylineId(null); + this.setEditingMode(PolylineEditingMode.IDLE); + } else { + this.requireRedraw(); + } + } + + handleGlobalMouseHover(pickingInfo: PickingInfo): void { + if (this._editingMode !== PolylineEditingMode.DRAW) { + return; + } + + if (!pickingInfo.coordinate) { + return; + } + + this._hoverPoint = pickingInfo.coordinate; + this.requireRedraw(); + } + + private makeNewPolylineName(): string { + const base = "New polyline"; + const existingNames = new Set(this._polylines.map((p) => p.name)); + + if (!existingNames.has(base)) { + return base; + } + + for (let i = 1; i < 10000; i++) { + const name = `${base} (${i})`; + if (!existingNames.has(name)) { + return name; + } + } + + throw new Error("Unable to generate unique polyline name"); + } + + handleGlobalMouseClick(pickingInfo: PickingInfo): boolean { + if (this._editingMode === PolylineEditingMode.NONE) { + return false; + } + + if (!pickingInfo.coordinate) { + return false; + } + + const activePolyline = this.getActivePolyline(); + if (!activePolyline && this._editingMode === PolylineEditingMode.DRAW) { + const id = v4(); + this._polylines.push({ + id, + name: this.makeNewPolylineName(), + color: this._colorGenerator.next().value, + path: [[...pickingInfo.coordinate]], + version: 0, + }); + this._polylines = [...this._polylines]; + this._currentEditingPolylinePathReferencePointIndex = 0; + this.setCurrentEditingPolylineId(id, true); + this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.POLYLINES); + } else if (activePolyline) { + if (this._currentEditingPolylinePathReferencePointIndex === null) { + this.setCurrentEditingPolylineId(null); + this.setEditingMode(PolylineEditingMode.IDLE); + return true; + } + + if (this._editingMode === PolylineEditingMode.DRAW) { + this.appendToActivePolylinePath(pickingInfo.coordinate); + this.requireRedraw(); + return true; + } + } + + return false; + } + + private appendToActivePolylinePath(point: number[]): void { + const activePolyline = this.getActivePolyline(); + if (!activePolyline) { + return; + } + + const newPath = [...activePolyline.path]; + if (this._appendToPathLocation === AppendToPathLocation.START) { + newPath.unshift(point); + this._currentEditingPolylinePathReferencePointIndex = 0; + } else { + newPath.push(point); + this._currentEditingPolylinePathReferencePointIndex = newPath.length - 1; + } + + this.updateActivePolylinePath(newPath); + } + + handleDragStart(pickingInfo: PickingInfo): void { + if (!isEditablePolylineLayerPickingInfo(pickingInfo)) { + return; + } + + if (pickingInfo.editableEntity?.type === "point") { + this._draggedPathPointIndex = pickingInfo.editableEntity.index; + this.requestDisablePanning(); + this.setDragStart(); + } + } + + handleDrag(pickingInfo: PickingInfo): void { + if (this._draggedPathPointIndex === null || !pickingInfo.coordinate) { + return; + } + + const activePolyline = this.getActivePolyline(); + if (!activePolyline) { + return; + } + + // Take first layer under cursor to get coordinates for the polyline point + // An alternative would be to store a reference to the layer the polyline was first created upon + // and always try to use that layer to get the coordinates + const layerUnderCursor = this.getFirstLayerUnderCursorInfo(pickingInfo.x, pickingInfo.y); + if (!layerUnderCursor || !layerUnderCursor.coordinate) { + return; + } + + const newPath = [...activePolyline.path]; + newPath[this._draggedPathPointIndex] = [...layerUnderCursor.coordinate]; + this.updateActivePolylinePath(newPath); + this.requireRedraw(); + } + + handleDragEnd(): void { + this._draggedPathPointIndex = null; + this.requestEnablePanning(); + this.setDragEnd(); + } + + getCursor(pickingInfo: PickingInfo): string | null { + if (this._editingMode === PolylineEditingMode.NONE) { + return null; + } + + const activePolyline = this.getActivePolyline(); + + if (isEditablePolylineLayerPickingInfo(pickingInfo) && pickingInfo.editableEntity) { + if ( + [PolylineEditingMode.DRAW, PolylineEditingMode.ADD_POINT].includes(this._editingMode) && + pickingInfo.editableEntity.type === "line" + ) { + return `url("${addPathIcon}"), crosshair`; + } + + if ( + activePolyline && + [PolylineEditingMode.DRAW, PolylineEditingMode.REMOVE_POINT].includes(this._editingMode) && + pickingInfo.editableEntity.type === "point" + ) { + const index = pickingInfo.index; + if ( + (index === 0 || index === activePolyline.path.length - 1) && + index !== this._currentEditingPolylinePathReferencePointIndex && + this._editingMode === PolylineEditingMode.DRAW + ) { + return `url("${continuePathIcon}"), crosshair`; + } + + return `url("${removePathIcon}"), crosshair`; + } + + if (this._editingMode === PolylineEditingMode.IDLE && pickingInfo.editableEntity.type === "point") { + return "grab"; + } + } + + return "auto"; + } + + getContextMenuItems(pickingInfo: PickingInfo): ContextMenuItem[] { + if (this._editingMode !== PolylineEditingMode.IDLE) { + return []; + } + + if (!isPolylinesLayerPickingInfo(pickingInfo) || !pickingInfo.polylineId) { + return []; + } + + return [ + { + icon: , + label: "Edit", + onClick: () => { + this.setCurrentEditingPolylineId(pickingInfo.polylineId ?? null, true); + }, + }, + { + icon: , + label: "Delete", + onClick: () => { + this._polylines = this._polylines.filter((polyline) => polyline.id !== pickingInfo.polylineId); + this.setCurrentEditingPolylineId(null, true); + this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.POLYLINES); + }, + }, + ]; + } + + getLayers(): Layer[] { + const layers: Layer[] = [ + new PolylinesLayer({ + id: super.makeLayerId("polylines-layer"), + polylines: this._polylines.filter( + (polyline) => + polyline.id !== this._currentEditingPolylineId && + (this._visiblePolylineIds.includes(polyline.id) || + this._editingMode !== PolylineEditingMode.NONE), + ), + selectedPolylineId: + this._editingMode === PolylineEditingMode.NONE + ? undefined + : (this._selectedPolylineId ?? undefined), + hoverable: this._editingMode === PolylineEditingMode.IDLE, + visible: this._editingMode !== PolylineEditingMode.NONE, + }), + ]; + + let allowHoveringOf = AllowHoveringOf.NONE; + if ([PolylineEditingMode.DRAW, PolylineEditingMode.ADD_POINT].includes(this._editingMode)) { + allowHoveringOf = AllowHoveringOf.LINES_AND_POINTS; + } + if (this._editingMode === PolylineEditingMode.REMOVE_POINT) { + allowHoveringOf = AllowHoveringOf.POINTS; + } + + const activePolyline = this.getActivePolyline(); + layers.push( + new EditablePolylineLayer({ + id: super.makeLayerId("editable-polyline-layer"), + polyline: activePolyline, + polylineVersion: activePolyline?.version ?? 0, + mouseHoverPoint: this._hoverPoint ?? undefined, + referencePathPointIndex: + this._editingMode === PolylineEditingMode.DRAW + ? (this._currentEditingPolylinePathReferencePointIndex ?? undefined) + : undefined, + onDragStart: this.handleDragStart.bind(this), + onDragEnd: this.handleDragEnd.bind(this), + allowHoveringOf, + visible: activePolyline !== undefined, + updateTriggers: { + renderLayers: [this._hoverPoint?.join(",") ?? ""], + }, + }), + ); + + return layers; + } + + makeSnapshotGetter(topic: T): () => PolylinesPluginTopicPayloads[T] { + const snapshotGetter = (): any => { + if (topic === PolylinesPluginTopic.EDITING_MODE) { + return this._editingMode; + } + if (topic === PolylinesPluginTopic.EDITING_POLYLINE_ID) { + return this._currentEditingPolylineId; + } + if (topic === PolylinesPluginTopic.POLYLINES) { + return this._polylines; + } + + throw new Error(`Unknown topic ${topic}`); + }; + + return snapshotGetter; + } +} diff --git a/frontend/src/modules/3DViewer/view/utils/colorTables.ts b/frontend/src/modules/3DViewer/view/utils/colorTables.ts deleted file mode 100644 index 95fd459b33..0000000000 --- a/frontend/src/modules/3DViewer/view/utils/colorTables.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { colorTablesObj } from "@emerson-eps/color-tables"; -import type { Color, Rgb } from "culori"; -import { parse } from "culori"; - -import type { ColorScale } from "@lib/utils/ColorScale"; - -export function createContinuousColorScaleForMap(colorScale: ColorScale): colorTablesObj[] { - const hexColors = colorScale.getPlotlyColorScale(); - const rgbArr: [number, number, number, number][] = []; - hexColors.forEach((hexColor) => { - const color: Color | undefined = parse(hexColor[1]); // Returns object with r, g, b items for hex strings - - if (color && "r" in color && "g" in color && "b" in color) { - const rgbColor = color as Rgb; - rgbArr.push([hexColor[0], rgbColor.r * 255, rgbColor.g * 255, rgbColor.b * 255]); - } - }); - - return [{ name: "Continuous", discrete: false, colors: rgbArr, colorNaN: [255, 255, 255] }]; -} diff --git a/frontend/src/modules/3DViewer/view/utils/layers.ts b/frontend/src/modules/3DViewer/view/utils/layers.ts deleted file mode 100644 index 92d9c368ac..0000000000 --- a/frontend/src/modules/3DViewer/view/utils/layers.ts +++ /dev/null @@ -1,270 +0,0 @@ -import type { Color, Layer } from "@deck.gl/core"; -import { TGrid3DColoringMode } from "@webviz/subsurface-viewer"; -import { AxesLayer, Grid3DLayer, WellsLayer } from "@webviz/subsurface-viewer/dist/layers"; -import type { Feature, LineString, Point } from "geojson"; - -import type { BoundingBox3D_api, WellboreTrajectory_api } from "@api"; -import type { ColorScale } from "@lib/utils/ColorScale"; -import type { GeoWellFeature } from "@modules/3DViewer/typesAndEnums"; - -import type { - FenceMeshSection_trans, - GridMappedProperty_trans, - GridSurface_trans, - PolylineIntersection_trans, -} from "../queries/queryDataTransforms"; - -export function makeAxesLayer(gridModelBoundingBox3d: BoundingBox3D_api): AxesLayer { - const axesBounds = gridModelBoundingBox3d - ? [ - gridModelBoundingBox3d.xmin, - gridModelBoundingBox3d.ymin, - gridModelBoundingBox3d.zmin, - gridModelBoundingBox3d.xmax, - gridModelBoundingBox3d.ymax, - gridModelBoundingBox3d.zmax, - ] - : [0, 0, 0, 100, 100, 100]; - - return new AxesLayer({ - id: "axes-layer", - bounds: axesBounds as [number, number, number, number, number, number], - visible: true, - ZIncreasingDownwards: true, - }); -} - -type WorkingGrid3dLayer = { - pointsData: Float32Array; - polysData: Uint32Array; - propertiesData: Float32Array; - colorMapName: string; - ZIncreasingDownwards: boolean; -} & Layer; - -export function makeGrid3DLayer( - gridSurfaceData: GridSurface_trans, - gridParameterData: GridMappedProperty_trans, - showGridLines: boolean, - colorScale: ColorScale, -): WorkingGrid3dLayer { - const offsetXyz = [gridSurfaceData.origin_utm_x, gridSurfaceData.origin_utm_y, 0]; - const pointsNumberArray = gridSurfaceData.pointsFloat32Arr.map((val, i) => val + offsetXyz[i % 3]); - const polysNumberArray = gridSurfaceData.polysUint32Arr; - const grid3dLayer = new Grid3DLayer({ - id: "grid3d-layer", - pointsData: pointsNumberArray, - polysData: polysNumberArray, - propertiesData: gridParameterData.polyPropsFloat32Arr, - ZIncreasingDownwards: false, - gridLines: showGridLines, - material: { ambient: 0.4, diffuse: 0.7, shininess: 8, specularColor: [25, 25, 25] }, - pickable: true, - colorMapName: "Continuous", - colorMapClampColor: true, - colorMapRange: [colorScale.getMin(), colorScale.getMax()], - /* - colorMapFunction: (value: number) => { - const interpolatedColor = colorScale.getColorPalette().getInterpolatedColor(value); - // const nonNormalizedValue = value * (colorScale.getMax() - colorScale.getMin()) + colorScale.getMin(); - const color = parse(interpolatedColor) as Rgb; // colorScale.getColorForValue(nonNormalizedValue)) as Rgb; - if (color === undefined) { - return [0, 0, 0]; - } - return [color.r * 255, color.g * 255, color.b * 255]; - }, - */ - }); - return grid3dLayer as unknown as WorkingGrid3dLayer; -} - -export function makeIntersectionLayer( - polylineIntersectionData: PolylineIntersection_trans, - showGridLines: boolean, - colorScale: ColorScale, -): WorkingGrid3dLayer { - const polyData = buildVtkStylePolyDataFromFenceSections(polylineIntersectionData.fenceMeshSections); - const grid3dIntersectionLayer = new Grid3DLayer({ - id: "grid-3d-intersection-layer", - pointsData: polyData.points as unknown as number[], - polysData: polyData.polys as unknown as number[], - propertiesData: polyData.props as unknown as number[], - colorMapName: "Continuous", - colorMapRange: [colorScale.getMin(), colorScale.getMax()], - colorMapClampColor: true, - coloringMode: TGrid3DColoringMode.Property, - ZIncreasingDownwards: false, - gridLines: showGridLines, - material: { ambient: 0.4, diffuse: 0.7, shininess: 8, specularColor: [25, 25, 25] }, - pickable: false, - }); - return grid3dIntersectionLayer as unknown as WorkingGrid3dLayer; -} - -export function makeWellsLayer( - fieldWellboreTrajectoriesData: WellboreTrajectory_api[], - selectedWellboreUuid: string | null, -): WellsLayer { - const tempWorkingWellsData = fieldWellboreTrajectoriesData.filter( - (el) => el.uniqueWellboreIdentifier !== "NO 34/4-K-3 AH", - ); - const wellLayerDataFeatures = tempWorkingWellsData.map((well) => - wellTrajectoryToGeojson(well, selectedWellboreUuid), - ); - - function getLineStyleWidth(object: Feature): number { - if (object.properties && "lineWidth" in object.properties) { - return object.properties.lineWidth as number; - } - return 2; - } - - function getWellHeadStyleWidth(object: Feature): number { - if (object.properties && "wellHeadSize" in object.properties) { - return object.properties.wellHeadSize as number; - } - return 1; - } - - function getColor(object: Feature): [number, number, number, number] { - if (object.properties && "color" in object.properties) { - return object.properties.color as [number, number, number, number]; - } - return [50, 50, 50, 100]; - } - - const wellsLayer = new WellsLayer({ - id: "wells-layer", - data: { - type: "FeatureCollection", - features: wellLayerDataFeatures, - }, - refine: false, - lineStyle: { width: getLineStyleWidth, color: getColor }, - wellHeadStyle: { size: getWellHeadStyleWidth, color: getColor }, - pickable: true, - wellNameVisible: true, - ZIncreasingDownwards: false, - }); - - return wellsLayer; -} - -export function wellTrajectoryToGeojson( - wellTrajectory: WellboreTrajectory_api, - selectedWellboreUuid: string | null, -): GeoWellFeature { - const wellHeadPoint: Point = { - type: "Point", - coordinates: [wellTrajectory.eastingArr[0], wellTrajectory.northingArr[0], -wellTrajectory.tvdMslArr[0]], - }; - const trajectoryLineString: LineString = { - type: "LineString", - coordinates: zipCoords(wellTrajectory.eastingArr, wellTrajectory.northingArr, wellTrajectory.tvdMslArr), - }; - - let color = [150, 150, 150] as Color; - let lineWidth = 2; - let wellHeadSize = 1; - if (wellTrajectory.wellboreUuid === selectedWellboreUuid) { - color = [255, 0, 0]; - lineWidth = 5; - wellHeadSize = 10; - } - - const geometryCollection: GeoWellFeature = { - type: "Feature", - geometry: { - type: "GeometryCollection", - geometries: [wellHeadPoint, trajectoryLineString], - }, - properties: { - uuid: wellTrajectory.wellboreUuid, - uwi: wellTrajectory.uniqueWellboreIdentifier, - lineWidth, - wellHeadSize, - name: wellTrajectory.uniqueWellboreIdentifier, - color, - md: [wellTrajectory.mdArr], - }, - }; - - return geometryCollection; -} - -function zipCoords(x_arr: number[], y_arr: number[], z_arr: number[]): number[][] { - const coords: number[][] = []; - for (let i = 0; i < x_arr.length; i++) { - coords.push([x_arr[i], y_arr[i], -z_arr[i]]); - } - - return coords; -} - -interface PolyDataVtk { - points: Float32Array; - polys: Uint32Array; - props: Float32Array; -} - -function buildVtkStylePolyDataFromFenceSections(fenceSections: FenceMeshSection_trans[]): PolyDataVtk { - // Calculate sizes of typed arrays - let totNumVertices = 0; - let totNumPolygons = 0; - let totNumConnectivities = 0; - for (const section of fenceSections) { - totNumVertices += section.verticesUzFloat32Arr.length / 2; - totNumPolygons += section.verticesPerPolyUintArr.length; - totNumConnectivities += section.polyIndicesUintArr.length; - } - - const pointsFloat32Arr = new Float32Array(3 * totNumVertices); - const polysUint32Arr = new Uint32Array(totNumPolygons + totNumConnectivities); - const polyPropsFloat32Arr = new Float32Array(totNumPolygons); - - let floatPointsDstIdx = 0; - let polysDstIdx = 0; - let propsDstIdx = 0; - for (const section of fenceSections) { - // uv to xyz - const directionX = section.end_utm_x - section.start_utm_x; - const directionY = section.end_utm_y - section.start_utm_y; - const magnitude = Math.sqrt(directionX ** 2 + directionY ** 2); - const unitDirectionX = directionX / magnitude; - const unitDirectionY = directionY / magnitude; - - const connOffset = floatPointsDstIdx / 3; - - for (let i = 0; i < section.verticesUzFloat32Arr.length; i += 2) { - const u = section.verticesUzFloat32Arr[i]; - const z = section.verticesUzFloat32Arr[i + 1]; - const x = u * unitDirectionX + section.start_utm_x; - const y = u * unitDirectionY + section.start_utm_y; - - pointsFloat32Arr[floatPointsDstIdx++] = x; - pointsFloat32Arr[floatPointsDstIdx++] = y; - pointsFloat32Arr[floatPointsDstIdx++] = z; - } - - // Fix poly indexes for each section - const numPolysInSection = section.verticesPerPolyUintArr.length; - let srcIdx = 0; - for (let i = 0; i < numPolysInSection; i++) { - const numVertsInPoly = section.verticesPerPolyUintArr[i]; - polysUint32Arr[polysDstIdx++] = numVertsInPoly; - - for (let j = 0; j < numVertsInPoly; j++) { - polysUint32Arr[polysDstIdx++] = section.polyIndicesUintArr[srcIdx++] + connOffset; - } - } - - polyPropsFloat32Arr.set(section.polyPropsFloat32Arr, propsDstIdx); - propsDstIdx += numPolysInSection; - } - - return { - points: pointsFloat32Arr, - polys: polysUint32Arr, - props: polyPropsFloat32Arr, - }; -} diff --git a/frontend/src/modules/3DViewer/view/view.tsx b/frontend/src/modules/3DViewer/view/view.tsx index 2572cfbeaa..064e78b442 100644 --- a/frontend/src/modules/3DViewer/view/view.tsx +++ b/frontend/src/modules/3DViewer/view/view.tsx @@ -1,313 +1,33 @@ -import React from "react"; - -import type { Layer } from "@deck.gl/core"; -import { IntersectionReferenceSystem } from "@equinor/esv-intersection"; -import { NorthArrow3DLayer } from "@webviz/subsurface-viewer/dist/layers"; -import { useAtom, useSetAtom } from "jotai"; +import type React from "react"; import type { ModuleViewProps } from "@framework/Module"; -import { useViewStatusWriter } from "@framework/StatusWriter"; -import { SyncSettingKey, SyncSettingsHelper } from "@framework/SyncSettings"; -import type { Intersection } from "@framework/types/intersection"; -import { IntersectionType } from "@framework/types/intersection"; -import { useIntersectionPolylines } from "@framework/UserCreatedItems"; -import type { - IntersectionPolyline, - IntersectionPolylineWithoutId, -} from "@framework/userCreatedItems/IntersectionPolylines"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; -import { ColorScaleGradientType } from "@lib/utils/ColorScale"; -import { usePropagateApiErrorToStatusWriter } from "@modules/_shared/hooks/usePropagateApiErrorToStatusWriter"; -import { ColorScaleWithName } from "@modules/_shared/utils/ColorScaleWithName"; -import { calcExtendedSimplifiedWellboreTrajectoryInXYPlane } from "@modules/_shared/utils/wellbore"; -import { useFieldWellboreTrajectoriesQuery } from "@modules/_shared/WellBore/queryHooks"; import type { Interfaces } from "../interfaces"; -import { userSelectedCustomIntersectionPolylineIdAtom } from "../settings/atoms/baseAtoms"; -import { editCustomIntersectionPolylineEditModeActiveAtom, intersectionTypeAtom } from "./atoms/baseAtoms"; -import { SyncedSettingsUpdateWrapper } from "./components/SyncedSettingsUpdateWrapper"; -import { useGridParameterQuery, useGridSurfaceQuery } from "./queries/gridQueries"; -import { useGridPolylineIntersection as useGridPolylineIntersectionQuery } from "./queries/polylineIntersection"; -import { useWellboreCasingsQuery } from "./queries/wellboreSchematicsQueries"; -import { makeAxesLayer, makeGrid3DLayer, makeIntersectionLayer, makeWellsLayer } from "./utils/layers"; +import { DataProvidersWrapper } from "./components/DataProvidersWrapper"; export function View(props: ModuleViewProps): React.ReactNode { - const statusWriter = useViewStatusWriter(props.viewContext); - const syncedSettingKeys = props.viewContext.useSyncedSettingKeys(); - const syncHelper = new SyncSettingsHelper(syncedSettingKeys, props.workbenchServices); - - let colorScale = props.viewContext.useSettingsToViewInterfaceValue("colorScale"); - const defaultColorScale = props.workbenchSettings.useContinuousColorScale({ - gradientType: ColorScaleGradientType.Sequential, - }); - if (!colorScale) { - colorScale = defaultColorScale; - } - - const useCustomBounds = props.viewContext.useSettingsToViewInterfaceValue("useCustomBounds"); - - const intersectionPolylines = useIntersectionPolylines(props.workbenchSession); - - const ensembleIdent = props.viewContext.useSettingsToViewInterfaceValue("ensembleIdent"); - const highlightedWellboreUuid = props.viewContext.useSettingsToViewInterfaceValue("highlightedWellboreUuid"); - const realization = props.viewContext.useSettingsToViewInterfaceValue("realization"); - const wellboreUuids = props.viewContext.useSettingsToViewInterfaceValue("wellboreUuids"); - const gridModelName = props.viewContext.useSettingsToViewInterfaceValue("gridModelName"); - const gridModelBoundingBox3d = props.viewContext.useSettingsToViewInterfaceValue("gridModelBoundingBox3d"); - const gridModelParameterName = props.viewContext.useSettingsToViewInterfaceValue("gridModelParameterName"); - const gridModelParameterDateOrInterval = props.viewContext.useSettingsToViewInterfaceValue( - "gridModelParameterDateOrInterval", - ); - - const editPolylineModeActive = props.viewContext.useSettingsToViewInterfaceValue( - "editCustomIntersectionPolylineEditModeActive", - ); - const setEditPolylineModeActive = useSetAtom(editCustomIntersectionPolylineEditModeActiveAtom); - - const intersectionType = props.viewContext.useSettingsToViewInterfaceValue("intersectionType"); - const setIntersectionType = useSetAtom(intersectionTypeAtom); - - const ensembleSet = useEnsembleSet(props.workbenchSession); - - const fieldId = ensembleIdent ? (ensembleSet.getEnsemble(ensembleIdent)?.getFieldIdentifier() ?? null) : null; - - React.useEffect( - function handleTitleChange() { - let ensembleName = ""; - if (ensembleIdent) { - const ensemble = ensembleSet.findEnsemble(ensembleIdent); - ensembleName = ensemble?.getDisplayName() ?? ""; - } - - props.viewContext.setInstanceTitle( - `${ensembleName}, R=${realization} -- ${gridModelName} / ${gridModelParameterName}`, - ); - }, - [ensembleSet, ensembleIdent, gridModelName, gridModelParameterName, realization, props.viewContext], - ); - - const gridCellIndexRanges = props.viewContext.useSettingsToViewInterfaceValue("gridCellIndexRanges"); - const showGridLines = props.viewContext.useSettingsToViewInterfaceValue("showGridlines"); - const showIntersection = props.viewContext.useSettingsToViewInterfaceValue("showIntersection"); - - const intersectionExtensionLength = - props.viewContext.useSettingsToViewInterfaceValue("intersectionExtensionLength"); - - const [selectedCustomIntersectionPolylineId, setSelectedCustomIntersectionPolylineId] = useAtom( - userSelectedCustomIntersectionPolylineIdAtom, - ); - - const fieldIdentifier = ensembleIdent - ? (ensembleSet.findEnsemble(ensembleIdent)?.getFieldIdentifier() ?? null) - : null; - const fieldWellboreTrajectoriesQuery = useFieldWellboreTrajectoriesQuery(fieldIdentifier ?? undefined); - - usePropagateApiErrorToStatusWriter(fieldWellboreTrajectoriesQuery, statusWriter); - - const displayedWellboreUuid = [...wellboreUuids]; - if (highlightedWellboreUuid && !displayedWellboreUuid.includes(highlightedWellboreUuid)) { - displayedWellboreUuid.push(highlightedWellboreUuid); - } - const filteredFieldWellBoreTrajectories = fieldWellboreTrajectoriesQuery.data?.filter((wellbore) => - displayedWellboreUuid.includes(wellbore.wellboreUuid), - ); - - const polylineUtmXy: number[] = []; - const oldPolylineUtmXy: number[] = []; - - let intersectionReferenceSystem: IntersectionReferenceSystem | null = null; - const customIntersectionPolyline = intersectionPolylines.getPolyline(selectedCustomIntersectionPolylineId ?? ""); - - if (intersectionType === IntersectionType.WELLBORE) { - if (filteredFieldWellBoreTrajectories && highlightedWellboreUuid) { - const wellboreTrajectory = filteredFieldWellBoreTrajectories.find( - (wellbore) => wellbore.wellboreUuid === highlightedWellboreUuid, - ); - if (wellboreTrajectory) { - const path: number[][] = []; - for (const [index, northing] of wellboreTrajectory.northingArr.entries()) { - const easting = wellboreTrajectory.eastingArr[index]; - const tvd_msl = wellboreTrajectory.tvdMslArr[index]; - - path.push([easting, northing, tvd_msl]); - } - const offset = wellboreTrajectory.tvdMslArr[0]; + const preferredViewLayout = props.viewContext.useSettingsToViewInterfaceValue("preferredViewLayout"); + const layerManager = props.viewContext.useSettingsToViewInterfaceValue("layerManager"); + const fieldId = props.viewContext.useSettingsToViewInterfaceValue("fieldId"); - intersectionReferenceSystem = new IntersectionReferenceSystem(path); - intersectionReferenceSystem.offset = offset; - - polylineUtmXy.push( - ...calcExtendedSimplifiedWellboreTrajectoryInXYPlane( - path, - intersectionExtensionLength, - 5, - ).simplifiedWellboreTrajectoryXy.flat(), - ); - - const extendedTrajectory = intersectionReferenceSystem.getExtendedTrajectory( - 100, - intersectionExtensionLength, - intersectionExtensionLength, - ); - - oldPolylineUtmXy.push(...extendedTrajectory.points.flat()); - } - } - } else if (intersectionType === IntersectionType.CUSTOM_POLYLINE) { - if (customIntersectionPolyline && customIntersectionPolyline.path.length >= 2) { - intersectionReferenceSystem = new IntersectionReferenceSystem( - customIntersectionPolyline.path.map((point) => [point[0], point[1], 0]), - ); - intersectionReferenceSystem.offset = 0; - if (!customIntersectionPolyline) { - statusWriter.addError("Custom intersection polyline not found"); - } else { - for (const point of customIntersectionPolyline.path) { - polylineUtmXy.push(point[0], point[1]); - } - } - } - } - - // Polyline intersection query - const polylineIntersectionQuery = useGridPolylineIntersectionQuery( - ensembleIdent ?? null, - gridModelName, - gridModelParameterName, - gridModelParameterDateOrInterval, - realization, - polylineUtmXy, - showIntersection, - ); - - // Wellbore casing query - const wellboreCasingQuery = useWellboreCasingsQuery(highlightedWellboreUuid ?? undefined); - - // Grid surface query - const gridSurfaceQuery = useGridSurfaceQuery({ - caseUuid: ensembleIdent?.getCaseUuid() ?? null, - ensembleName: ensembleIdent?.getEnsembleName() ?? null, - gridName: gridModelName, - realizationNum: realization, - iMin: gridCellIndexRanges.i[0], - iMax: gridCellIndexRanges.i[1], - jMin: gridCellIndexRanges.j[0], - jMax: gridCellIndexRanges.j[1], - kMin: gridCellIndexRanges.k[0], - kMax: gridCellIndexRanges.k[1], - }); - - // Grid parameter query - const gridParameterQuery = useGridParameterQuery({ - caseUuid: ensembleIdent?.getCaseUuid() ?? null, - ensembleName: ensembleIdent?.getEnsembleName() ?? null, - gridName: gridModelName, - parameterName: gridModelParameterName, - realizationNum: realization, - iMin: gridCellIndexRanges.i[0], - iMax: gridCellIndexRanges.i[1], - jMin: gridCellIndexRanges.j[0], - jMax: gridCellIndexRanges.j[1], - kMin: gridCellIndexRanges.k[0], - kMax: gridCellIndexRanges.k[1], - }); - - usePropagateApiErrorToStatusWriter(polylineIntersectionQuery, statusWriter); - usePropagateApiErrorToStatusWriter(wellboreCasingQuery, statusWriter); - usePropagateApiErrorToStatusWriter(gridSurfaceQuery, statusWriter); - usePropagateApiErrorToStatusWriter(gridParameterQuery, statusWriter); - - // Set loading status - statusWriter.setLoading( - polylineIntersectionQuery.isFetching || - fieldWellboreTrajectoriesQuery.isFetching || - wellboreCasingQuery.isFetching || - gridSurfaceQuery.isFetching || - gridParameterQuery.isFetching, - ); - - function handleAddPolyline(polyline: IntersectionPolylineWithoutId) { - const id = intersectionPolylines.add(polyline); - setSelectedCustomIntersectionPolylineId(id); - setIntersectionType(IntersectionType.CUSTOM_POLYLINE); - const intersection: Intersection = { - type: IntersectionType.CUSTOM_POLYLINE, - uuid: id ?? "", - }; - syncHelper.publishValue(SyncSettingKey.INTERSECTION, "global.syncValue.intersection", intersection); - } - - function handlePolylineChange(polyline: IntersectionPolyline) { - const { id, ...rest } = polyline; - intersectionPolylines.updatePolyline(id, rest); - setEditPolylineModeActive(false); - } - - function handleEditPolylineCancel() { - setEditPolylineModeActive(false); - } - - if (!gridModelBoundingBox3d) { + if (!layerManager) { return null; } - const northArrowLayer = new NorthArrow3DLayer({ - id: "north-arrow-layer", - visible: true, - }); - - const axesLayer = makeAxesLayer(gridModelBoundingBox3d); - - const layers: Layer[] = [northArrowLayer, axesLayer]; - const colorScaleClone = colorScale.clone(); - - if (gridSurfaceQuery.data && gridParameterQuery.data) { - const minPropValue = gridParameterQuery.data.min_grid_prop_value; - const maxPropValue = gridParameterQuery.data.max_grid_prop_value; - if (!useCustomBounds) { - colorScaleClone.setRangeAndMidPoint( - minPropValue, - maxPropValue, - minPropValue + (maxPropValue - minPropValue) / 2, - ); - } - layers.push(makeGrid3DLayer(gridSurfaceQuery.data, gridParameterQuery.data, showGridLines, colorScaleClone)); - - if (polylineIntersectionQuery.data && showIntersection) { - layers.push(makeIntersectionLayer(polylineIntersectionQuery.data, showGridLines, colorScaleClone)); - } - } - - if (filteredFieldWellBoreTrajectories) { - const maybeWellboreUuid = intersectionType === IntersectionType.WELLBORE ? highlightedWellboreUuid : null; - layers.push(makeWellsLayer(filteredFieldWellBoreTrajectories, maybeWellboreUuid)); + if (!fieldId) { + return null; } - const colorScaleWithName = ColorScaleWithName.fromColorScale( - colorScaleClone, - gridModelParameterName ?? "Grid model", - ); - return ( -
- -
+ ); } diff --git a/frontend/src/modules/Intersection/view/utils/createLayerItemsUtils.ts b/frontend/src/modules/Intersection/view/utils/createLayerItemsUtils.ts index 7eaca686a7..27314519e0 100644 --- a/frontend/src/modules/Intersection/view/utils/createLayerItemsUtils.ts +++ b/frontend/src/modules/Intersection/view/utils/createLayerItemsUtils.ts @@ -10,7 +10,7 @@ import { type VisualizationGroup, type VisualizationTarget, } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; -import type { IntersectionInjectedData } from "@modules/Intersection/DataProviderFramework/injectedDataType"; + import type { TargetViewReturnTypes } from "../components/DataProvidersWrapper"; @@ -24,7 +24,7 @@ import { createWellboreLayerItems } from "./createWellboreLayerItems"; * in an array. The items are assigned order based on the order of the providers in the view. */ export function makeViewProvidersVisualizationLayerItems( - view: VisualizationGroup, + view: VisualizationGroup, GroupType>, intersectionReferenceSystem: IntersectionReferenceSystem, ): LayerItem[] { // Make LayerItems per provider, using maker function diff --git a/frontend/src/modules/SubsurfaceMap/_utils/subsurfaceMap.ts b/frontend/src/modules/SubsurfaceMap/_utils/subsurfaceMap.ts index 0e8ebb1a87..6ecf88aff2 100644 --- a/frontend/src/modules/SubsurfaceMap/_utils/subsurfaceMap.ts +++ b/frontend/src/modules/SubsurfaceMap/_utils/subsurfaceMap.ts @@ -1,4 +1,5 @@ import type { PolygonData_api, SurfaceDef_api, WellboreTrajectory_api } from "@api"; +import { wellTrajectoryToGeojson, zipCoords } from "@modules/_shared/utils/wellbore"; export type SurfaceMeshLayerSettings = { contours?: boolean | number[]; @@ -96,7 +97,7 @@ function surfacePolygonsToGeojson(surfacePolygon: PolygonData_api): Record { - const features: Record[] = wellTrajectories.map((wellTrajectory) => { + const features = wellTrajectories.map((wellTrajectory) => { return wellTrajectoryToGeojson(wellTrajectory); }); const data: Record = { @@ -114,33 +115,7 @@ export function createWellboreTrajectoryLayer(wellTrajectories: WellboreTrajecto pickable: true, }; } -export function wellTrajectoryToGeojson(wellTrajectory: WellboreTrajectory_api): Record { - const point: Record = { - type: "Point", - coordinates: [wellTrajectory.eastingArr[0], wellTrajectory.northingArr[0], -wellTrajectory.tvdMslArr[0]], - }; - const coordinates: Record = { - type: "LineString", - coordinates: zipCoords(wellTrajectory.eastingArr, wellTrajectory.northingArr, wellTrajectory.tvdMslArr), - }; - const geometryCollection: Record = { - type: "Feature", - geometry: { - type: "GeometryCollection", - geometries: [point, coordinates], - }, - properties: { - uuid: wellTrajectory.wellboreUuid, - name: wellTrajectory.uniqueWellboreIdentifier, - uwi: wellTrajectory.uniqueWellboreIdentifier, - - color: [0, 0, 0, 100], - md: [wellTrajectory.mdArr], - }, - }; - return geometryCollection; -} export function createWellBoreHeaderLayer(wellTrajectories: WellboreTrajectory_api[]): Record { const data: Record[] = wellTrajectories.map((wellTrajectory) => { return wellHeaderMarkerToGeojson( @@ -173,14 +148,6 @@ function wellHeaderMarkerToGeojson( uwi: string, uuid: string, ): Record { - // let data: Record = { - // type: "Feature", - // geometry: { - // type: "Point", - // coordinates: [x, y, z], - // }, - // properties: { name: label }, - // }; const data: Record = { coordinates: [x, y, z], uuid: uuid, @@ -188,12 +155,3 @@ function wellHeaderMarkerToGeojson( }; return data; } - -function zipCoords(x_arr: number[], y_arr: number[], z_arr: number[]): number[][] { - const coords: number[][] = []; - for (let i = 0; i < x_arr.length; i++) { - coords.push([x_arr[i], y_arr[i], -z_arr[i]]); - } - - return coords; -} diff --git a/frontend/src/modules/WellLogViewer/DataProviderFramework/visualizations/plots.ts b/frontend/src/modules/WellLogViewer/DataProviderFramework/visualizations/plots.ts index f344dd7fde..5c7ada3741 100644 --- a/frontend/src/modules/WellLogViewer/DataProviderFramework/visualizations/plots.ts +++ b/frontend/src/modules/WellLogViewer/DataProviderFramework/visualizations/plots.ts @@ -13,7 +13,7 @@ import type { VisualizationTarget, } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; import { VisualizationItemType } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; -import type { TemplatePlot } from "@modules/WellLogViewer/types"; +import type { TemplatePlot } from "@modules/_shared/types/wellLogTemplates"; import { isNumericalDataPoints } from "@modules/WellLogViewer/utils/queryDataTransform"; import type { AreaPlotSettingTypes } from "../dataProviders/plots/AreaPlotProvider"; @@ -152,7 +152,13 @@ export function plotDataAccumulator( colorMapFuncDefs.push({ name: colorFuncName(args), - func: makeColorMapFunctionFromColorScale(colorScale, minValue, maxValue)!, + func: (value: number) => { + const colorWithAlpha = makeColorMapFunctionFromColorScale( + { colorScale, areBoundariesUserDefined: false }, + { valueMin: minValue, valueMax: maxValue }, + )!(value); + return [colorWithAlpha[0], colorWithAlpha[1], colorWithAlpha[2]]; + }, }); } } diff --git a/frontend/src/modules/WellLogViewer/DataProviderFramework/visualizations/tracks.ts b/frontend/src/modules/WellLogViewer/DataProviderFramework/visualizations/tracks.ts index cfbd8ad8c7..7bfaf02198 100644 --- a/frontend/src/modules/WellLogViewer/DataProviderFramework/visualizations/tracks.ts +++ b/frontend/src/modules/WellLogViewer/DataProviderFramework/visualizations/tracks.ts @@ -9,7 +9,7 @@ import type { VisualizationTarget, } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; import { VisualizationItemType } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; -import type { TemplateTrack } from "@modules/WellLogViewer/types"; +import type { TemplateTrack } from "@modules/_shared/types/wellLogTemplates"; import type { baseSettings } from "../groups/_shared"; import type { ContinuousTrackSettings } from "../groups/ContinuousLogTrack"; diff --git a/frontend/src/modules/WellLogViewer/DataProviderFramework/visualizations/wellpicks.ts b/frontend/src/modules/WellLogViewer/DataProviderFramework/visualizations/wellpicks.ts index a87d6b5ca9..c47a79b704 100644 --- a/frontend/src/modules/WellLogViewer/DataProviderFramework/visualizations/wellpicks.ts +++ b/frontend/src/modules/WellLogViewer/DataProviderFramework/visualizations/wellpicks.ts @@ -7,19 +7,13 @@ import { VisualizationItemType, type VisualizationTarget, } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; +import type { WellPickDataCollection } from "@modules/_shared/types/wellpicks"; import type { WellPickSettingTypes } from "../dataProviders/wellpicks/WellPicksProvider"; import { CustomDataProviderType } from "../dataProviderTypes"; type WellpickTransformerArgs = TransformerArgs; -export type WellPickDataCollection = { - picks: WellborePick_api[]; - // We currently don't use these fields anywhere, but I'm leaving them here so they're available in the future - stratColumn: string; - interpreter: string; -}; - export function makeWellPickCollections(args: WellpickTransformerArgs): WellPickDataCollection | null { const data = args.getData(); const stratColumn = args.getSetting(Setting.STRAT_COLUMN); diff --git a/frontend/src/modules/WellLogViewer/utils/queryDataTransform.ts b/frontend/src/modules/WellLogViewer/utils/queryDataTransform.ts index f0fa6a4f18..b6efb4bbbe 100644 --- a/frontend/src/modules/WellLogViewer/utils/queryDataTransform.ts +++ b/frontend/src/modules/WellLogViewer/utils/queryDataTransform.ts @@ -14,11 +14,12 @@ import { chain, clone, round, set } from "lodash"; import { WellLogCurveSourceEnum_api } from "@api"; import type { WellboreLogCurveData_api, WellborePick_api, WellboreTrajectory_api } from "@api"; +import type { WellPickDataCollection } from "../../_shared/types/wellpicks"; +import { getUniqueCurveNameForCurveData } from "../../_shared/utils/wellLog"; import { MAIN_AXIS_CURVE, SECONDARY_AXIS_CURVE } from "../constants"; -import type { WellPickDataCollection } from "../DataProviderFramework/visualizations/wellpicks"; import { COLOR_TABLES } from "./logViewerColors"; -import { getUniqueCurveNameForCurveData } from "./strings"; + type DataRowAccumulatorMap = Record; diff --git a/frontend/src/modules/WellLogViewer/view/components/ProviderVisualizationWrapper.tsx b/frontend/src/modules/WellLogViewer/view/components/ProviderVisualizationWrapper.tsx index 99206d05c0..b070638cf6 100644 --- a/frontend/src/modules/WellLogViewer/view/components/ProviderVisualizationWrapper.tsx +++ b/frontend/src/modules/WellLogViewer/view/components/ProviderVisualizationWrapper.tsx @@ -7,6 +7,8 @@ import type { ColorMapFunction } from "@webviz/well-log-viewer/dist/utils/color- import type { WellboreTrajectory_api } from "@api"; import type { DataProviderManager } from "@modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager"; +import type { Template, TemplatePlot, TemplateTrack } from "@modules/_shared/types/wellLogTemplates"; +import { getUniqueCurveNameForPlotConfig } from "@modules/_shared/utils/wellLog"; import { MAIN_AXIS_CURVE } from "@modules/WellLogViewer/constants"; import type { DiffVisualizationGroup } from "@modules/WellLogViewer/DataProviderFramework/visualizations/plots"; import { @@ -23,9 +25,7 @@ import { import { isWellPickVisualization } from "@modules/WellLogViewer/DataProviderFramework/visualizations/wellpicks"; import type { WellLogFactoryProduct } from "@modules/WellLogViewer/hooks/useLogViewerVisualizationProduct"; import { useLogViewerVisualizationProduct } from "@modules/WellLogViewer/hooks/useLogViewerVisualizationProduct"; -import type { Template, TemplatePlot, TemplateTrack } from "@modules/WellLogViewer/types"; import { createLogViewerWellPicks, createWellLogSets } from "@modules/WellLogViewer/utils/queryDataTransform"; -import { getUniqueCurveNameForPlotConfig } from "@modules/WellLogViewer/utils/strings"; import { trajectoryToIntersectionReference } from "@modules/WellLogViewer/utils/trajectory"; import type { SubsurfaceLogViewerWrapperProps } from "./SubsurfaceLogViewerWrapper"; diff --git a/frontend/src/modules/WellLogViewer/view/components/ReadoutWrapper.tsx b/frontend/src/modules/WellLogViewer/view/components/ReadoutWrapper.tsx index f5e3d2c5a6..161fb8a74c 100644 --- a/frontend/src/modules/WellLogViewer/view/components/ReadoutWrapper.tsx +++ b/frontend/src/modules/WellLogViewer/view/components/ReadoutWrapper.tsx @@ -5,7 +5,7 @@ import { chain, maxBy } from "lodash"; import type { InfoItem, ReadoutItem } from "@modules/_shared/components/ReadoutBox"; import { ReadoutBox } from "@modules/_shared/components/ReadoutBox"; -import type { TemplateTrack } from "@modules/WellLogViewer/types"; +import type { TemplateTrack } from "@modules/_shared/types/wellLogTemplates"; const DEFAULT_MAX_READOUT_ITEMS = 6; diff --git a/frontend/src/modules/WellLogViewer/view/components/SubsurfaceLogViewerWrapper.tsx b/frontend/src/modules/WellLogViewer/view/components/SubsurfaceLogViewerWrapper.tsx index a98c0c072e..76371e4c4a 100644 --- a/frontend/src/modules/WellLogViewer/view/components/SubsurfaceLogViewerWrapper.tsx +++ b/frontend/src/modules/WellLogViewer/view/components/SubsurfaceLogViewerWrapper.tsx @@ -15,7 +15,7 @@ import type { WellboreHeader_api } from "@api"; import type { ModuleViewProps } from "@framework/Module"; import { SyncSettingKey } from "@framework/SyncSettings"; import type { GlobalTopicDefinitions, WorkbenchServices } from "@framework/WorkbenchServices"; -import type { Template } from "@modules/WellLogViewer/types"; +import type { Template } from "@modules/_shared/types/wellLogTemplates"; import type { InterfaceTypes } from "../../interfaces"; diff --git a/frontend/src/modules/_shared/DataProviderFramework/dataProviders/DataProviderRegistry/_registerAllSharedDataProviders.ts b/frontend/src/modules/_shared/DataProviderFramework/dataProviders/DataProviderRegistry/_registerAllSharedDataProviders.ts index 9cbd30e7c8..16e48fb750 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/dataProviders/DataProviderRegistry/_registerAllSharedDataProviders.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/dataProviders/DataProviderRegistry/_registerAllSharedDataProviders.ts @@ -6,6 +6,13 @@ import { IntersectionRealizationSeismicProvider, SeismicDataSource, } from "../implementations/IntersectionRealizationSeismicProvider"; +import { RealizationPolygonsProvider } from "../implementations/RealizationPolygonsProvider"; +import { + RealizationSurfaceProvider, + SurfaceDataFormat, + VisualizationSpace, +} from "../implementations/RealizationSurfaceProvider"; +import { StatisticalSurfaceProvider } from "../implementations/StatisticalSurfaceProvider"; import { DataProviderRegistry } from "./_DataProviderRegistry"; @@ -26,3 +33,14 @@ DataProviderRegistry.registerDataProvider( IntersectionRealizationSeismicProvider, [SeismicDataSource.SIMULATED], ); +DataProviderRegistry.registerDataProvider(DataProviderType.REALIZATION_SURFACE_3D, RealizationSurfaceProvider, [ + SurfaceDataFormat.FLOAT, + VisualizationSpace.SPACE_3D, +]); +DataProviderRegistry.registerDataProvider(DataProviderType.REALIZATION_POLYGONS, RealizationPolygonsProvider); +DataProviderRegistry.registerDataProvider(DataProviderType.REALIZATION_SURFACE, RealizationSurfaceProvider); +DataProviderRegistry.registerDataProvider(DataProviderType.STATISTICAL_SURFACE, StatisticalSurfaceProvider); +DataProviderRegistry.registerDataProvider( + DataProviderType.INTERSECTION_REALIZATION_GRID, + IntersectionRealizationGridProvider, +); diff --git a/frontend/src/modules/_shared/DataProviderFramework/dataProviders/dataProviderTypes.ts b/frontend/src/modules/_shared/DataProviderFramework/dataProviders/dataProviderTypes.ts index 201bcd7ce7..ac943f49c6 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/dataProviders/dataProviderTypes.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/dataProviders/dataProviderTypes.ts @@ -4,4 +4,9 @@ export enum DataProviderType { INTERSECTION_WITH_WELLBORE_EXTENSION_REALIZATION_GRID = "INTERSECTION_WITH_WELLBORE_EXTENSION_REALIZATION_GRID", INTERSECTION_REALIZATION_OBSERVED_SEISMIC = "INTERSECTION_REALIZATION_OBSERVED_SEISMIC", INTERSECTION_REALIZATION_SIMULATED_SEISMIC = "INTERSECTION_REALIZATION_SIMULATED_SEISMIC", + INTERSECTION_REALIZATION_GRID = "INTERSECTION_REALIZATION_GRID", + REALIZATION_POLYGONS = "REALIZATION_POLYGONS", + REALIZATION_SURFACE = "REALIZATION_SURFACE", + REALIZATION_SURFACE_3D = "REALIZATION_SURFACE_3D", + STATISTICAL_SURFACE = "STATISTICAL_SURFACE", } diff --git a/frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/IntersectionRealizationSeismicProvider.ts b/frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/IntersectionRealizationSeismicProvider.ts index 303588e073..808890bcdc 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/IntersectionRealizationSeismicProvider.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/IntersectionRealizationSeismicProvider.ts @@ -89,7 +89,7 @@ export class IntersectionRealizationSeismicProvider return { [Setting.WELLBORE_EXTENSION_LENGTH]: 500.0, - [Setting.SAMPLE_RESOLUTION_IN_METERS]: 1.0, + [Setting.SAMPLE_RESOLUTION_IN_METERS]: 25.0, [Setting.COLOR_SCALE]: { colorScale: defaultColorScale, areBoundariesUserDefined: false, diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationPolygonsProvider.ts b/frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/RealizationPolygonsProvider.ts similarity index 100% rename from frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationPolygonsProvider.ts rename to frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/RealizationPolygonsProvider.ts diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationSurfaceProvider.ts b/frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/RealizationSurfaceProvider.ts similarity index 90% rename from frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationSurfaceProvider.ts rename to frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/RealizationSurfaceProvider.ts index 145db5b9b4..be2bdac74d 100644 --- a/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationSurfaceProvider.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/RealizationSurfaceProvider.ts @@ -1,7 +1,12 @@ import { isEqual } from "lodash"; import type { SurfaceDataPng_api } from "@api"; -import { SurfaceTimeType_api, getRealizationSurfacesMetadataOptions, getSurfaceDataOptions } from "@api"; +import { + SurfaceAttributeType_api, + SurfaceTimeType_api, + getRealizationSurfacesMetadataOptions, + getSurfaceDataOptions, +} from "@api"; import type { CustomDataProviderImplementation, DataProviderInformationAccessors, @@ -32,6 +37,11 @@ export enum SurfaceDataFormat { PNG = "png", } +export enum VisualizationSpace { + SPACE_2D = "2D", + SPACE_3D = "3D", +} + export type RealizationSurfaceData = | { format: SurfaceDataFormat.FLOAT; surfaceData: SurfaceDataFloat_trans } | { format: SurfaceDataFormat.PNG; surfaceData: SurfaceDataPng_api }; @@ -42,9 +52,11 @@ export class RealizationSurfaceProvider settings = realizationSurfaceSettings; private _dataFormat: SurfaceDataFormat; + private _visualizationSpace: VisualizationSpace; - constructor(dataFormat?: SurfaceDataFormat) { + constructor(dataFormat?: SurfaceDataFormat, visualizationSpace?: VisualizationSpace) { this._dataFormat = dataFormat ?? SurfaceDataFormat.PNG; + this._visualizationSpace = visualizationSpace ?? VisualizationSpace.SPACE_2D; } getDefaultName(): string { @@ -145,7 +157,17 @@ export class RealizationSurfaceProvider } const availableAttributes = [ - ...Array.from(new Set(data.surfaces.map((surface) => surface.attribute_name))), + ...Array.from( + new Set( + data.surfaces + .filter( + (surface) => + surface.attribute_type === SurfaceAttributeType_api.DEPTH || + this._visualizationSpace === VisualizationSpace.SPACE_2D, + ) + .map((surface) => surface.attribute_name), + ), + ), ]; return availableAttributes; diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/StatisticalSurfaceProvider.ts b/frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/StatisticalSurfaceProvider.ts similarity index 100% rename from frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/StatisticalSurfaceProvider.ts rename to frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/StatisticalSurfaceProvider.ts diff --git a/frontend/src/modules/_shared/DataProviderFramework/delegates/_utils/Dependency.ts b/frontend/src/modules/_shared/DataProviderFramework/delegates/_utils/Dependency.ts index 86034b3a92..3efd9a3f87 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/delegates/_utils/Dependency.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/delegates/_utils/Dependency.ts @@ -8,6 +8,9 @@ import type { MakeSettingTypesMap, Settings } from "../../settings/settingsDefin class DependencyLoadingError extends Error {} +export const NO_UPDATE = Symbol("NO_UPDATE"); +export type NoUpdate = typeof NO_UPDATE; + /* * Dependency class is used to represent a node in the dependency graph of a data provider settings context. * It can be compared to an atom in Jotai. @@ -256,7 +259,7 @@ export class Dependency< this._abortController = new AbortController(); - let newValue: Awaited | null = null; + let newValue: Awaited | null | NoUpdate = null; try { newValue = await this._updateFunc({ getLocalSetting: this.getLocalSetting, @@ -284,6 +287,10 @@ export class Dependency< return; } + if (newValue === NO_UPDATE) { + newValue = this._cachedValue; + } + this.applyNewValue(newValue); } diff --git a/frontend/src/modules/_shared/DataProviderFramework/framework/DataProvider/DataProvider.ts b/frontend/src/modules/_shared/DataProviderFramework/framework/DataProvider/DataProvider.ts index 091b08a5d3..acbb11ee5e 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/framework/DataProvider/DataProvider.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/framework/DataProvider/DataProvider.ts @@ -174,6 +174,10 @@ export class DataProvider< ); } + getRevisionNumber(): number { + return this._revisionNumber; + } + areCurrentSettingsValid(): boolean { if (!this._customDataProviderImpl.areCurrentSettingsValid) { return true; diff --git a/frontend/src/modules/_shared/DataProviderFramework/hooks/useVisualizationProduct.ts b/frontend/src/modules/_shared/DataProviderFramework/hooks/useVisualizationProduct.ts index f498308daa..4b90b5c488 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/hooks/useVisualizationProduct.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/hooks/useVisualizationProduct.ts @@ -15,9 +15,10 @@ export function useVisualizationAssemblerProduct< TTarget extends VisualizationTarget, TCustomGroupProps extends CustomGroupPropsMap, TAccumulatedData extends Record, + TInjectedData extends Record, >( dataProviderManager: DataProviderManager, - visualizationAssembler: VisualizationAssembler, + visualizationAssembler: VisualizationAssembler, ): AssemblerProduct { const latestRevision = React.useSyncExternalStore( dataProviderManager diff --git a/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/customSettingsHandler.ts b/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/customSettingsHandler.ts index c2e28aa8aa..01c1bdec3b 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/customSettingsHandler.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/customSettingsHandler.ts @@ -3,7 +3,7 @@ import type { QueryClient } from "@tanstack/query-core"; import type { WorkbenchSession } from "@framework/WorkbenchSession"; import type { WorkbenchSettings } from "@framework/WorkbenchSettings"; -import type { Dependency } from "../delegates/_utils/Dependency"; +import type { Dependency, NoUpdate } from "../delegates/_utils/Dependency"; import type { GlobalSettings } from "../framework/DataProviderManager/DataProviderManager"; import type { MakeSettingTypesMap, Settings } from "../settings/settingsDefinitions"; @@ -34,7 +34,7 @@ export interface UpdateFunc< getGlobalSetting: (settingName: T) => GlobalSettings[T]; getHelperDependency: GetHelperDependency; abortSignal: AbortSignal; - }): TReturnValue; + }): TReturnValue | NoUpdate; } export interface DefineBasicDependenciesArgs< diff --git a/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/utils.ts b/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/utils.ts index 400737f148..69bd7a7dff 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/utils.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/utils.ts @@ -21,11 +21,13 @@ export type MakeAvailableValuesTypeBasedOnCategory> : TCategory extends SettingCategory.NUMBER ? [Exclude, Exclude] - : TCategory extends SettingCategory.NUMBER_WITH_STEP - ? [Exclude, Exclude, Exclude] - : TCategory extends SettingCategory.RANGE - ? Exclude - : never; + : TCategory extends SettingCategory.XYZ_RANGE + ? [[number, number, number], [number, number, number], [number, number, number]] + : TCategory extends SettingCategory.NUMBER_WITH_STEP + ? [number, number, number] + : TCategory extends SettingCategory.XYZ_VALUES_WITH_VISIBILITY + ? [[number, number, number], [number, number, number], [number, number, number]] + : never; export type TupleIndices = Extract; export type SettingsKeysFromTuple = TSettings[TupleIndices]; diff --git a/frontend/src/modules/_shared/DataProviderFramework/settings/SettingRegistry/_registerAllSettings.ts b/frontend/src/modules/_shared/DataProviderFramework/settings/SettingRegistry/_registerAllSettings.ts index 550ccc7510..5f7b3719bf 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/settings/SettingRegistry/_registerAllSettings.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/settings/SettingRegistry/_registerAllSettings.ts @@ -6,17 +6,17 @@ import { DrilledWellboresSetting } from "../implementations/DrilledWellboresSett import { DropdownNumberSetting } from "../implementations/DropdownNumberSetting"; import { DropdownStringSetting } from "../implementations/DropdownStringSetting"; import { EnsembleSetting } from "../implementations/EnsembleSetting"; -import { Direction as GridLayerRangeDirection, GridLayerRangeSetting } from "../implementations/GridLayerRangeSetting"; +import { GridLayerRangeSetting } from "../implementations/GridLayerRangeSetting"; import { Direction as GridLayerDirection, GridLayerSetting } from "../implementations/GridLayerSetting"; import { InputNumberSetting } from "../implementations/InputNumberSetting"; import { IntersectionSetting } from "../implementations/IntersectionSetting"; import { LogCurveSetting } from "../implementations/LogCurveSetting"; -import { SeismicSliceDirection, SeismicSliceSetting } from "../implementations/SeismicSliceSetting"; +import { SeismicSliceSetting } from "../implementations/SeismicSliceSetting"; import { SelectNumberSetting } from "../implementations/SelectNumberSetting"; import { SelectStringSetting } from "../implementations/SelectStringSetting"; import { SensitivitySetting } from "../implementations/SensitivitySetting"; import { SingleColorSetting } from "../implementations/SingleColorSetting"; -import { SliderNumberSetting } from "../implementations/SliderNumberSettig"; +import { SliderNumberSetting } from "../implementations/SliderNumberSetting"; import { StaticRotationSetting } from "../implementations/StaticRotationSetting"; import { StatisticFunctionSetting } from "../implementations/StatisticFunctionSetting"; import { TimeOrIntervalSetting } from "../implementations/TimeOrIntervalSetting"; @@ -56,15 +56,7 @@ SettingRegistry.registerSetting(Setting.COLOR_SET, "Color Set", ColorSetSetting) SettingRegistry.registerSetting(Setting.GRID_LAYER_K, "Grid Layer K", GridLayerSetting, { customConstructorParameters: [GridLayerDirection.K], }); -SettingRegistry.registerSetting(Setting.GRID_LAYER_K_RANGE, "Grid Layer K Range", GridLayerRangeSetting, { - customConstructorParameters: [GridLayerRangeDirection.I], -}); -SettingRegistry.registerSetting(Setting.GRID_LAYER_I_RANGE, "Grid Layer I Range", GridLayerRangeSetting, { - customConstructorParameters: [GridLayerRangeDirection.J], -}); -SettingRegistry.registerSetting(Setting.GRID_LAYER_J_RANGE, "Grid Layer J Range", GridLayerRangeSetting, { - customConstructorParameters: [GridLayerRangeDirection.K], -}); +SettingRegistry.registerSetting(Setting.GRID_LAYER_RANGE, "Grid Ranges", GridLayerRangeSetting); SettingRegistry.registerSetting(Setting.GRID_NAME, "Grid Name", DropdownStringSetting); SettingRegistry.registerSetting(Setting.INTERSECTION, "Intersection", IntersectionSetting); SettingRegistry.registerSetting(Setting.OPACITY_PERCENT, "Color Opacity [%]", SliderNumberSetting, { @@ -82,15 +74,7 @@ SettingRegistry.registerSetting( customConstructorParameters: [{ min: 1.0, max: 50.0 }], }, ); -SettingRegistry.registerSetting(Setting.SEISMIC_CROSSLINE, "Seismic Crossline", SeismicSliceSetting, { - customConstructorParameters: [SeismicSliceDirection.CROSSLINE], -}); -SettingRegistry.registerSetting(Setting.SEISMIC_DEPTH_SLICE, "Seismic Depth Slice", SeismicSliceSetting, { - customConstructorParameters: [SeismicSliceDirection.DEPTH], -}); -SettingRegistry.registerSetting(Setting.SEISMIC_INLINE, "Seismic Inline", SeismicSliceSetting, { - customConstructorParameters: [SeismicSliceDirection.INLINE], -}); +SettingRegistry.registerSetting(Setting.SEISMIC_SLICES, "Seismic Slices", SeismicSliceSetting); SettingRegistry.registerSetting(Setting.SENSITIVITY, "Sensitivity", SensitivitySetting); SettingRegistry.registerSetting(Setting.SHOW_GRID_LINES, "Show Grid Lines", BooleanSetting); SettingRegistry.registerSetting(Setting.SMDA_INTERPRETER, "SMDA Interpreter", DropdownStringSetting); diff --git a/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/GridLayerRangeSetting.tsx b/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/GridLayerRangeSetting.tsx index 87b5898c33..c46f8c1a92 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/GridLayerRangeSetting.tsx +++ b/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/GridLayerRangeSetting.tsx @@ -1,6 +1,12 @@ -import type React from "react"; +import React from "react"; +import { cloneDeep, isEqual } from "lodash"; + +import { Button } from "@lib/components/Button"; +import { Input } from "@lib/components/Input"; import { Slider } from "@lib/components/Slider"; +import { useElementSize } from "@lib/hooks/useElementSize"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; import type { CustomSettingImplementation, @@ -8,53 +14,130 @@ import type { } from "../../interfacesAndTypes/customSettingImplementation"; import type { SettingCategory } from "../settingsDefinitions"; -type ValueType = [number, number] | null; - -export enum Direction { - I, - J, - K, -} +type ValueType = [[number, number], [number, number], [number, number]] | null; +type Category = SettingCategory.XYZ_RANGE; -export class GridLayerRangeSetting implements CustomSettingImplementation { +export class GridLayerRangeSetting implements CustomSettingImplementation { defaultValue: ValueType = null; - private _direction: Direction; - - constructor(direction: Direction) { - this._direction = direction; - } - getLabel(): string { - switch (this._direction) { - case Direction.I: - return "Grid layer I"; - case Direction.J: - return "Grid layer J"; - case Direction.K: - return "Grid layer K"; - } + return "Grid ranges"; } - makeComponent(): (props: SettingComponentProps) => React.ReactNode { - return function RangeSlider(props: SettingComponentProps) { - function handleChange(_: any, value: number | number[]) { - if (!Array.isArray(value)) { - return; - } + makeComponent(): (props: SettingComponentProps) => React.ReactNode { + return function RangeSlider(props: SettingComponentProps) { + const divRef = React.useRef(null); + const divSize = useElementSize(divRef); + + const availableValues = props.availableValues ?? [ + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + ]; - props.onValueChange([value[0], value[1]]); + const [internalValue, setInternalValue] = React.useState< + [[number, number], [number, number], [number, number]] | null + >(cloneDeep(props.value)); + const [prevValue, setPrevValue] = React.useState(cloneDeep(props.value)); + + if (!isEqual(props.value, prevValue)) { + setInternalValue(cloneDeep(props.value)); + setPrevValue(cloneDeep(props.value)); + } + + function handleSliderChange(index: number, val: number[]) { + const newValue: [[number, number], [number, number], [number, number]] = [ + ...(internalValue ?? [ + [0, 0], + [0, 0], + [0, 0], + ]), + ]; + newValue[index] = val as [number, number]; + setInternalValue(newValue); + } + + function handleInputChange(outerIndex: number, innerIndex: number, val: number) { + const min = availableValues[outerIndex][0]; + const max = availableValues[outerIndex][1]; + const step = availableValues[outerIndex][2]; + const allowedValues = Array.from( + { length: Math.floor((max - min) / step) + 1 }, + (_, i) => min + i * step, + ); + const newVal = allowedValues.reduce((prev, curr) => + Math.abs(curr - val) < Math.abs(prev - val) ? curr : prev, + ); + + const newValue: [[number, number], [number, number], [number, number]] = [ + ...(internalValue ?? [ + [0, 0], + [0, 0], + [0, 0], + ]), + ]; + newValue[outerIndex][innerIndex] = newVal; + setInternalValue(newValue); + } + + const labels: string[] = ["I", "J", "K"]; + const hasChanges = !isEqual(internalValue, props.value); + const MIN_SIZE = 250; + let inputsVisible = true; + if (divSize.width < MIN_SIZE) { + inputsVisible = false; + } + + function handleApplyChanges() { + if (internalValue && !isEqual(internalValue, props.value)) { + props.onValueChange(internalValue); + } } return ( - + <> +
+ {labels.map((label, index) => ( +
+
{label}
+
+ handleInputChange(index, 0, parseInt(e.target.value))} + /> +
+
+ handleSliderChange(index, value as [number, number])} + value={ + internalValue?.[index] ?? [ + availableValues[index][0], + availableValues[index][1], + ] + } + valueLabelDisplay="auto" + step={availableValues[index][2]} + /> +
+
+ handleInputChange(index, 1, parseInt(e.target.value))} + /> +
+
+ ))} +
+
+ +
+ ); }; } diff --git a/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/LogCurveSetting.tsx b/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/LogCurveSetting.tsx index b6c6aea37b..5dffb1ea56 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/LogCurveSetting.tsx +++ b/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/LogCurveSetting.tsx @@ -5,7 +5,7 @@ import { chain, sortBy } from "lodash"; import type { WellboreLogCurveHeader_api } from "@api"; import type { DropdownOption, DropdownOptionGroup } from "@lib/components/Dropdown"; import { Dropdown } from "@lib/components/Dropdown"; -import { makeSelectValueForCurveHeader } from "@modules/WellLogViewer/utils/strings"; +import { makeSelectValueForCurveHeader } from "@modules/_shared/utils/wellLog"; import type { CustomSettingImplementation, diff --git a/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/SeismicSliceSetting.tsx b/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/SeismicSliceSetting.tsx index 6c430e0b58..79840aaab7 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/SeismicSliceSetting.tsx +++ b/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/SeismicSliceSetting.tsx @@ -1,101 +1,173 @@ -import type React from "react"; +import React from "react"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; +import { isEqual } from "lodash"; + +import { Button } from "@lib/components/Button"; +import { DenseIconButton } from "@lib/components/DenseIconButton"; import { Input } from "@lib/components/Input"; import { Slider } from "@lib/components/Slider"; +import { useElementSize } from "@lib/hooks/useElementSize"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; import type { CustomSettingImplementation, SettingComponentProps, } from "../../interfacesAndTypes/customSettingImplementation"; -import type { MakeAvailableValuesTypeBasedOnCategory } from "../../interfacesAndTypes/utils"; import type { SettingCategory } from "../settingsDefinitions"; -type ValueType = number | null; - -export enum SeismicSliceDirection { - INLINE, - CROSSLINE, - DEPTH, -} -export class SeismicSliceSetting implements CustomSettingImplementation { - private _direction: SeismicSliceDirection; - - constructor(direction: SeismicSliceDirection) { - this._direction = direction; - } - +type ValueType = { value: [number, number, number]; visible: [boolean, boolean, boolean]; applied: boolean } | null; +type Category = SettingCategory.XYZ_VALUES_WITH_VISIBILITY; +export class SeismicSliceSetting implements CustomSettingImplementation { fixupValue( currentValue: ValueType, - availableValues: MakeAvailableValuesTypeBasedOnCategory, + availableValues: [[number, number, number], [number, number, number], [number, number, number]], ): ValueType { - if (availableValues.length < 2) { - return null; + if (!currentValue || !Array.isArray(currentValue.value) || currentValue.value.length !== 3) { + return { + value: [availableValues[0][0], availableValues[1][0], availableValues[2][0]], + visible: [true, true, true], + applied: true, + }; } - const min = availableValues[0]; - const max = availableValues[1]; + const fixedValue: [number, number, number] = currentValue.value.map((val, index) => { + const [min, max, step] = availableValues[index]; + return Math.max(min, Math.min(max, Math.round(val / step) * step)); + }) as [number, number, number]; - if (max === null) { - return null; - } + return { value: fixedValue, visible: [true, true, true], applied: currentValue.applied }; + } - if (currentValue === null) { - return min; + isValueValid( + value: ValueType, + availableValues: [[number, number, number], [number, number, number], [number, number, number]], + ): boolean { + if (!value || !Array.isArray(value.value) || value.value.length !== 3) { + return false; } - - return Math.min(Math.max(currentValue, min), max); + return value.value.every((val, index) => { + const [min, max, step] = availableValues[index]; + return val >= min && val <= max && (val - min) % step === 0; + }); } - makeComponent(): (props: SettingComponentProps) => React.ReactNode { - const direction = this._direction; - return function RangeSlider(props: SettingComponentProps) { - const availableValues = props.availableValues ?? [0, 0, 0]; + makeComponent(): (props: SettingComponentProps) => React.ReactNode { + return function RangeSlider(props: SettingComponentProps) { + const divRef = React.useRef(null); + const divSize = useElementSize(divRef); - function handleSliderChange(_: any, value: number | number[]) { - if (Array.isArray(value)) { - return value[0]; - } + const availableValues = props.availableValues ?? [ + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + ]; - props.onValueChange(value); + const [internalValue, setInternalValue] = React.useState<[number, number, number] | null>( + props.value?.value ?? null, + ); + const [prevValue, setPrevValue] = React.useState<[number, number, number] | null>(null); + + if (!isEqual(prevValue, props.value?.value ?? null)) { + setInternalValue(props.value?.value ?? [0, 0, 0]); + setPrevValue(props.value?.value ?? null); + } + + const [visible, setVisible] = React.useState<[boolean, boolean, boolean]>( + props.value?.visible ?? [true, true, true], + ); + + function handleSliderChange(index: number, val: number) { + const newValue: [number, number, number] = [...(internalValue ?? [0, 0, 0])]; + newValue[index] = val; + setInternalValue(newValue); + props.onValueChange({ value: newValue, visible, applied: false }); } - function handleInputChange(event: React.ChangeEvent) { - let value = Number(event.target.value); - - if (direction === SeismicSliceDirection.DEPTH) { - // Check if value is allowed (in increments of availableValues[2], if not return closest allowed value) - const min = availableValues[0]; - const max = availableValues[1]; - const step = availableValues[2]; - const allowedValues = Array.from( - { length: Math.floor((max - min) / step) + 1 }, - (_, i) => min + i * step, - ); - value = allowedValues.reduce((prev, curr) => - Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev, - ); + function handleInputChange(index: number, val: number) { + const min = availableValues[index][0]; + const max = availableValues[index][1]; + const step = availableValues[index][2]; + const allowedValues = Array.from( + { length: Math.floor((max - min) / step) + 1 }, + (_, i) => min + i * step, + ); + const newVal = allowedValues.reduce((prev, curr) => + Math.abs(curr - val) < Math.abs(prev - val) ? curr : prev, + ); + + const newValue: [number, number, number] = [...(internalValue ?? [0, 0, 0])]; + newValue[index] = newVal; + setInternalValue(newValue); + props.onValueChange({ value: newValue, visible, applied: false }); + } + + function handleVisibleChange(index: number) { + const newVisible: [boolean, boolean, boolean] = [...visible]; + newVisible[index] = !newVisible[index]; + setVisible(newVisible); + props.onValueChange({ value: internalValue ?? [0, 0, 0], visible: newVisible, applied: false }); + } + + function handleApplyChanges() { + if (internalValue) { + props.onValueChange({ value: internalValue, visible, applied: true }); } + } - props.onValueChange(value); + const labels: string[] = ["Col", "Row", "Depth"]; + const hasChanges = props.value === null || props.value.applied === false; + const MIN_SIZE = 250; + let inputsVisible = true; + if (divSize.width < MIN_SIZE) { + inputsVisible = false; } return ( -
-
- + <> +
+ {labels.map((label, index) => ( +
+
{label}
+
+ handleVisibleChange(index)} + > + {visible[index] ? ( + + ) : ( + + )} + +
+
+ handleSliderChange(index, value as number)} + value={props.value?.value[index] ?? availableValues[index][0]} + valueLabelDisplay="auto" + step={availableValues[index][2]} + track={false} + /> +
+
+ handleInputChange(index, parseInt(e.target.value))} + /> +
+
+ ))}
-
- +
+
-
+ ); }; } diff --git a/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/SingleColorSetting.tsx b/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/SingleColorSetting.tsx index 3898264141..2d5c2c0a82 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/SingleColorSetting.tsx +++ b/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/SingleColorSetting.tsx @@ -1,4 +1,4 @@ -import type React from "react"; +import React from "react"; import { defaultColorPalettes } from "@framework/utils/colorPalettes"; import { ColorSelect } from "@lib/components/ColorSelect"; @@ -59,15 +59,16 @@ export class SingleColorSetting implements CustomSettingImplementation) => React.ReactNode { return function SingleColorSettingComponent(props: SettingComponentProps) { + const initialColor = props.value?.slice(0, 7) ?? "#000000"; + + const [color, setColor] = React.useState(initialColor); + function handleColorChange(color: string) { + setColor(color); props.onValueChange(color); } - return ( -
- -
- ); + return ; }; } diff --git a/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/SliderNumberSettig.tsx b/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/SliderNumberSetting.tsx similarity index 100% rename from frontend/src/modules/_shared/DataProviderFramework/settings/implementations/SliderNumberSettig.tsx rename to frontend/src/modules/_shared/DataProviderFramework/settings/implementations/SliderNumberSetting.tsx diff --git a/frontend/src/modules/_shared/DataProviderFramework/settings/settingsDefinitions.ts b/frontend/src/modules/_shared/DataProviderFramework/settings/settingsDefinitions.ts index 0d72ff6721..048498dad0 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/settings/settingsDefinitions.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/settings/settingsDefinitions.ts @@ -20,10 +20,12 @@ export enum SettingCategory { SINGLE_SELECT = "singleSelect", MULTI_SELECT = "multiSelect", NUMBER = "number", - RANGE = "range", NUMBER_WITH_STEP = "numberWithStep", + // XYZ_NUMBER = "xyzNumber", + XYZ_RANGE = "xyzRange", BOOLEAN = "boolean", STATIC = "static", + XYZ_VALUES_WITH_VISIBILITY = "rangesWithVisibility", } export enum Setting { @@ -35,17 +37,15 @@ export enum Setting { SCALE = "scale", LOG_CURVE = "logCurve", - PLOT_VARIANT = "plotType", + PLOT_VARIANT = "plotVariant", ATTRIBUTE = "attribute", ENSEMBLE = "ensemble", COLOR_SCALE = "colorScale", COLOR_SET = "colorSet", COLOR = "color", - GRID_LAYER_I_RANGE = "gridLayerIRange", - GRID_LAYER_J_RANGE = "gridLayerJRange", + GRID_LAYER_RANGE = "gridLayerRange", GRID_LAYER_K = "gridLayerK", - GRID_LAYER_K_RANGE = "gridLayerKRange", GRID_NAME = "gridName", INTERSECTION = "intersection", OPACITY_PERCENT = "opacityPercent", @@ -55,9 +55,7 @@ export enum Setting { STRAT_COLUMN = "stratColumn", REALIZATIONS = "realizations", SAMPLE_RESOLUTION_IN_METERS = "sampleResolutionInMeters", - SEISMIC_CROSSLINE = "seismicCrossline", - SEISMIC_DEPTH_SLICE = "seismicDepthSlice", - SEISMIC_INLINE = "seismicInline", + SEISMIC_SLICES = "seismicSlices", SENSITIVITY = "sensitivity", SHOW_GRID_LINES = "showGridLines", SMDA_INTERPRETER = "smdaInterpreter", @@ -68,6 +66,7 @@ export enum Setting { TIME_OR_INTERVAL = "timeOrInterval", WELLBORE_EXTENSION_LENGTH = "wellboreExtensionLength", WELLBORE_PICKS = "wellborePicks", + WELLBORE_PICK_IDENTIFIER = "wellborePickIdentifier", } export const settingCategories = { @@ -83,10 +82,8 @@ export const settingCategories = { [Setting.COLOR_SCALE]: SettingCategory.STATIC, [Setting.COLOR_SET]: SettingCategory.STATIC, [Setting.COLOR]: SettingCategory.STATIC, - [Setting.GRID_LAYER_I_RANGE]: SettingCategory.RANGE, - [Setting.GRID_LAYER_J_RANGE]: SettingCategory.RANGE, + [Setting.GRID_LAYER_RANGE]: SettingCategory.XYZ_RANGE, [Setting.GRID_LAYER_K]: SettingCategory.NUMBER, - [Setting.GRID_LAYER_K_RANGE]: SettingCategory.RANGE, [Setting.GRID_NAME]: SettingCategory.SINGLE_SELECT, [Setting.INTERSECTION]: SettingCategory.SINGLE_SELECT, [Setting.OPACITY_PERCENT]: SettingCategory.NUMBER_WITH_STEP, @@ -95,9 +92,7 @@ export const settingCategories = { [Setting.REALIZATION]: SettingCategory.SINGLE_SELECT, [Setting.REALIZATIONS]: SettingCategory.MULTI_SELECT, [Setting.SAMPLE_RESOLUTION_IN_METERS]: SettingCategory.NUMBER, - [Setting.SEISMIC_CROSSLINE]: SettingCategory.NUMBER_WITH_STEP, - [Setting.SEISMIC_DEPTH_SLICE]: SettingCategory.NUMBER_WITH_STEP, - [Setting.SEISMIC_INLINE]: SettingCategory.NUMBER_WITH_STEP, + [Setting.SEISMIC_SLICES]: SettingCategory.XYZ_VALUES_WITH_VISIBILITY, [Setting.SENSITIVITY]: SettingCategory.SINGLE_SELECT, [Setting.SHOW_GRID_LINES]: SettingCategory.BOOLEAN, [Setting.SMDA_INTERPRETER]: SettingCategory.SINGLE_SELECT, @@ -109,6 +104,7 @@ export const settingCategories = { [Setting.TIME_OR_INTERVAL]: SettingCategory.SINGLE_SELECT, [Setting.WELLBORE_EXTENSION_LENGTH]: SettingCategory.NUMBER, [Setting.WELLBORE_PICKS]: SettingCategory.MULTI_SELECT, + [Setting.WELLBORE_PICK_IDENTIFIER]: SettingCategory.SINGLE_SELECT, } as const; export type SettingCategories = typeof settingCategories; @@ -126,10 +122,8 @@ export type SettingTypes = { [Setting.COLOR_SCALE]: ColorScaleSpecification | null; [Setting.COLOR_SET]: ColorSet | null; [Setting.COLOR]: string | null; - [Setting.GRID_LAYER_I_RANGE]: [number, number] | null; - [Setting.GRID_LAYER_J_RANGE]: [number, number] | null; + [Setting.GRID_LAYER_RANGE]: [[number, number], [number, number], [number, number]] | null; [Setting.GRID_LAYER_K]: number | null; - [Setting.GRID_LAYER_K_RANGE]: [number, number] | null; [Setting.GRID_NAME]: string | null; [Setting.INTERSECTION]: IntersectionSettingValue | null; [Setting.OPACITY_PERCENT]: number | null; @@ -138,9 +132,11 @@ export type SettingTypes = { [Setting.REALIZATION]: number | null; [Setting.REALIZATIONS]: number[] | null; [Setting.SAMPLE_RESOLUTION_IN_METERS]: number | null; - [Setting.SEISMIC_CROSSLINE]: number | null; - [Setting.SEISMIC_DEPTH_SLICE]: number | null; - [Setting.SEISMIC_INLINE]: number | null; + [Setting.SEISMIC_SLICES]: { + value: [number, number, number]; + visible: [boolean, boolean, boolean]; + applied: boolean; + } | null; [Setting.SENSITIVITY]: SensitivityNameCasePair | null; [Setting.SHOW_GRID_LINES]: boolean; [Setting.SMDA_INTERPRETER]: string | null; @@ -152,6 +148,7 @@ export type SettingTypes = { [Setting.TIME_OR_INTERVAL]: string | null; [Setting.WELLBORE_EXTENSION_LENGTH]: number | null; [Setting.WELLBORE_PICKS]: WellborePick_api[] | null; + [Setting.WELLBORE_PICK_IDENTIFIER]: string | null; }; export type PossibleSettingsForCategory = { @@ -272,25 +269,52 @@ export const settingCategoryFixupMap: SettingCategoryFixupMap = { const steps = Math.round((value - min) / step); return min + steps * step; }, - [SettingCategory.RANGE]: >( + [SettingCategory.XYZ_RANGE]: >( value: SettingTypes[TSetting], availableValues: AvailableValuesType, ) => { if (value === null) { - return availableValues; + return [ + [availableValues[0][0], availableValues[0][1]], + [availableValues[1][0], availableValues[1][1]], + [availableValues[2][0], availableValues[2][1]], + ]; } - const [min, max] = availableValues; - - if (value[0] < min) { - value[0] = min; - } + const [xRange, yRange, zRange] = availableValues; - if (value[1] > max) { - value[1] = max; + const newValue: SettingTypes[TSetting] = [ + [Math.max(xRange[0], value[0][0]), Math.min(xRange[1], value[0][1])], + [Math.max(yRange[0], value[1][0]), Math.min(yRange[1], value[1][1])], + [Math.max(zRange[0], value[2][0]), Math.min(zRange[1], value[2][1])], + ]; + return newValue; + }, + [SettingCategory.XYZ_VALUES_WITH_VISIBILITY]: < + TSetting extends PossibleSettingsForCategory, + >( + value: SettingTypes[TSetting], + availableValues: AvailableValuesType, + ) => { + if (value === null) { + return { + value: [availableValues[0][0], availableValues[1][0], availableValues[2][0]], + visible: [true, true, true], + applied: false, + }; } - return value; + const [xRange, yRange, zRange] = availableValues; + + const newValue: SettingTypes[TSetting] = { + ...value, + value: [ + Math.max(xRange[0], Math.min(xRange[1], value.value[0])), + Math.max(yRange[0], Math.min(yRange[1], value.value[1])), + Math.max(zRange[0], Math.min(zRange[1], value.value[2])), + ], + }; + return newValue; }, [SettingCategory.STATIC]: (value) => value, [SettingCategory.BOOLEAN]: (value) => value, @@ -323,12 +347,39 @@ export const settingCategoryIsValueValidMap: SettingCategoryIsValueValidMap = { const [min, max, step] = availableValues; return value >= min && value <= max && (value - min) % step === 0; }, - [SettingCategory.RANGE]: (value, availableValues) => { + [SettingCategory.XYZ_VALUES_WITH_VISIBILITY]: (value, availableValues) => { if (value === null) { return false; } - const [min, max] = availableValues; - return value[0] >= min && value[1] <= max; + const [xRange, yRange, zRange] = availableValues; + return ( + value.value[0] >= xRange[0] && + value.value[0] <= xRange[1] && + value.value[1] >= yRange[0] && + value.value[1] <= yRange[1] && + value.value[2] >= zRange[0] && + value.value[2] <= zRange[1] + ); + }, + [SettingCategory.XYZ_RANGE]: (value, availableValues) => { + if (value === null) { + return false; + } + const [xRange, yRange, zRange] = availableValues; + return ( + value[0][0] >= xRange[0] && + value[0][0] <= xRange[1] && + value[0][1] >= xRange[0] && + value[0][1] <= xRange[1] && + value[1][0] >= yRange[0] && + value[1][0] <= yRange[1] && + value[1][1] >= yRange[0] && + value[1][1] <= yRange[1] && + value[2][0] >= zRange[0] && + value[2][0] <= zRange[1] && + value[2][1] >= zRange[0] && + value[2][1] <= zRange[1] + ); }, [SettingCategory.STATIC]: () => true, [SettingCategory.BOOLEAN]: () => true, @@ -366,16 +417,6 @@ export const settingCategoryAvailableValuesIntersectionReducerMap: SettingCatego startingValue: [-Number.MAX_VALUE, Number.MAX_VALUE], isValid: (availableValues) => availableValues[0] < availableValues[1], }, - [SettingCategory.RANGE]: { - reducer: (accumulator, currentAvailableValues) => { - const [min, max] = accumulator; - const [currentMin, currentMax] = currentAvailableValues; - - return [Math.max(min, currentMin), Math.min(max, currentMax)]; - }, - startingValue: [-Number.MAX_VALUE, Number.MAX_VALUE], - isValid: (availableValues) => availableValues[0] < availableValues[1], - }, }; // From: https://stackoverflow.com/a/50375286/62076 diff --git a/frontend/src/modules/_shared/DataProviderFramework/visualization/VisualizationAssembler.ts b/frontend/src/modules/_shared/DataProviderFramework/visualization/VisualizationAssembler.ts index 09a399ab70..5c7bf2bd9e 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/visualization/VisualizationAssembler.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/visualization/VisualizationAssembler.ts @@ -6,8 +6,9 @@ import type { GlobalTopicDefinitions } from "@framework/WorkbenchServices"; import * as bbox from "@lib/utils/bbox"; import type { ColorScaleWithId } from "@modules/_shared/components/ColorLegendsContainer/colorScaleWithId"; import type { LayerItem } from "@modules/_shared/components/EsvIntersection"; -import type { WellPickDataCollection } from "@modules/WellLogViewer/DataProviderFramework/visualizations/wellpicks"; -import type { TemplatePlot } from "@modules/WellLogViewer/types"; +import type { HighlightItem } from "@modules/_shared/components/EsvIntersection/types"; +import type { TemplatePlot } from "@modules/_shared/types/wellLogTemplates"; +import type { WellPickDataCollection } from "@modules/_shared/types/wellpicks"; import type { GroupDelegate } from "../delegates/GroupDelegate"; import { DataProvider, DataProviderStatus } from "../framework/DataProvider/DataProvider"; @@ -37,7 +38,6 @@ export enum VisualizationTarget { DECK_GL = "deck_gl", ESV = "esv", WSC_WELL_LOG = "wsc_well_log", - // VIDEX = "videx", } export interface EsvLayerItemsMaker { @@ -51,6 +51,12 @@ export type DataProviderVisualizationTargetTypes = { [VisualizationTarget.WSC_WELL_LOG]: TemplatePlot | WellPickDataCollection; }; +export type DataProviderHoverVisualizationTargetTypes = { + [VisualizationTarget.DECK_GL]: DeckGlLayer; + [VisualizationTarget.ESV]: HighlightItem; + [VisualizationTarget.WSC_WELL_LOG]: null; +}; + export type DataProviderVisualization< TTarget extends VisualizationTarget, TVisualization extends @@ -67,7 +73,7 @@ export type TransformerArgs< TSettings extends Settings, TData, TStoredData extends StoredData = Record, - TInjectedData extends Record = never, + TInjectedData extends Record = Record, > = DataProviderInformationAccessors & { id: string; name: string; @@ -76,12 +82,6 @@ export type TransformerArgs< getValueRange: () => Readonly<[number, number]> | null; }; -export interface HoverVisualizationsFunction { - ( - hoverInfo: GlobalTopicDefinitions[FilterHoverKeys], - ): DataProviderVisualizationTargetTypes[TTarget][]; -} - export type VisualizationGroupMetadata = { itemType: VisualizationItemType.GROUP; id: string; @@ -93,7 +93,7 @@ export type VisualizationGroupMetadata = { export type VisualizationGroup< TTarget extends VisualizationTarget, TCustomGroupProps extends CustomGroupPropsMap = Record, - TAccumulatedData extends Record = never, + TAccumulatedData extends Record = Record, TGroupType extends GroupType = GroupType, > = VisualizationGroupMetadata & { children: (VisualizationGroup | DataProviderVisualization)[]; @@ -101,8 +101,9 @@ export type VisualizationGroup< aggregatedErrorMessages: (StatusMessage | string)[]; combinedBoundingBox: bbox.BBox | null; numLoadingDataProviders: number; + numDataProviders: number; accumulatedData: TAccumulatedData; - makeHoverVisualizationsFunction: HoverVisualizationsFunction; + hoverVisualizationFunctions: HoverVisualizationFunctions; customProps: TCustomGroupProps[TGroupType]; }; @@ -131,8 +132,8 @@ export type DataProviderTransformers< TData, TTarget extends VisualizationTarget, TStoredData extends StoredData = Record, - TInjectedData extends Record = never, - TAccumulatedData extends Record = never, + TInjectedData extends Record = Record, + TAccumulatedData extends Record = Record, > = { transformToVisualization: VisualizationTransformer; transformToBoundingBox?: BoundingBoxTransformer; @@ -153,12 +154,28 @@ export type DataProviderTransformers< >; }; +type KeysMatching = { + [K in keyof T]: K extends Pattern ? K : never; +}[keyof T]; + +type PickMatching = { + [K in KeysMatching]: T[K]; +}; + +export type HoverTopicDefinitions = PickMatching; + +export type HoverVisualizationFunctions = Partial<{ + [K in keyof HoverTopicDefinitions]: ( + hoverInfo: HoverTopicDefinitions[K], + ) => DataProviderHoverVisualizationTargetTypes[TTarget][]; +}>; + export type VisualizationTransformer< TSettings extends Settings, TData, TTarget extends VisualizationTarget, TStoredData extends StoredData = Record, - TInjectedData extends Record = never, + TInjectedData extends Record = Record, > = ( args: TransformerArgs, ) => DataProviderVisualizationTargetTypes[TTarget] | null; @@ -169,24 +186,21 @@ export type HoverVisualizationTransformer< TData, TTarget extends VisualizationTarget, TStoredData extends StoredData = Record, - TInjectedData extends Record = never, -> = ( - args: TransformerArgs, - hoverInfo: GlobalTopicDefinitions[FilterHoverKeys], -) => DataProviderVisualizationTargetTypes[TTarget][]; + TInjectedData extends Record = Record, +> = (args: TransformerArgs) => HoverVisualizationFunctions; export type BoundingBoxTransformer< TSettings extends Settings, TData, TStoredData extends StoredData = Record, - TInjectedData extends Record = never, + TInjectedData extends Record = Record, > = (args: TransformerArgs) => bbox.BBox | null; export type AnnotationsTransformer< TSettings extends Settings, TData, TStoredData extends StoredData = Record, - TInjectedData extends Record = never, + TInjectedData extends Record = Record, > = (args: TransformerArgs) => Annotation[]; export type ReduceAccumulatedDataFunction< @@ -194,29 +208,33 @@ export type ReduceAccumulatedDataFunction< TData, TAccumulatedData, TStoredData extends StoredData = Record, - TInjectedData extends Record = never, + TInjectedData extends Record = Record, > = ( accumulatedData: TAccumulatedData, args: TransformerArgs, ) => TAccumulatedData; -type FilterHoverKeys = { - [K in keyof T]: K extends `global.hover${string}` ? K : never; -}[keyof T]; - export type AssemblerProduct< TTarget extends VisualizationTarget, TCustomGroupProps extends CustomGroupPropsMap = Record, - TAccumulatedData extends Record = never, + TAccumulatedData extends Record = Record, > = Omit, keyof VisualizationGroupMetadata>; export type CustomGroupPropsMap = Partial>>; +type DataProviderObjects> = { + visualization: DataProviderVisualization | null; + hoverVisualizationFunctions: HoverVisualizationFunctions; + annotations: Annotation[]; + boundingBox: bbox.BBox | null; + accumulatedData: TAccumulatedData | null; +}; + export class VisualizationAssembler< TTarget extends VisualizationTarget, TCustomGroupProps extends CustomGroupPropsMap = Record, - TInjectedData extends Record = never, - TAccumulatedData extends Record = never, + TInjectedData extends Record = Record, + TAccumulatedData extends Record = Record, > { private _dataProviderTransformers: Map< string, @@ -228,6 +246,14 @@ export class VisualizationAssembler< GroupCustomPropsCollector > = new Map(); + private _cachedDataProviderVisualizationsMap: Map< + string, + { + revisionNumber: number; + objects: DataProviderObjects; + } + > = new Map(); + registerDataProviderTransformers< TSettings extends Settings, TData, @@ -283,10 +309,9 @@ export class VisualizationAssembler< )[] = []; const annotations: Annotation[] = []; const aggregatedErrorMessages: (StatusMessage | string)[] = []; - const hoverVisualizationFunctions: (( - hoverInfo: GlobalTopicDefinitions[FilterHoverKeys], - ) => DataProviderVisualizationTargetTypes[TTarget][])[] = []; + let hoverVisualizationFunctions: HoverVisualizationFunctions = {}; let numLoadingDataProviders = 0; + let numDataProviders = 0; let combinedBoundingBox: bbox.BBox | null = null; const maybeApplyBoundingBox = (boundingBox: bbox.BBox | null) => { @@ -311,8 +336,12 @@ export class VisualizationAssembler< accumulatedData = product.accumulatedData; aggregatedErrorMessages.push(...product.aggregatedErrorMessages); - hoverVisualizationFunctions.push(product.makeHoverVisualizationsFunction); + hoverVisualizationFunctions = this.mergeHoverVisualizationFunctions( + hoverVisualizationFunctions, + product.hoverVisualizationFunctions, + ); numLoadingDataProviders += product.numLoadingDataProviders; + numDataProviders += product.numDataProviders; maybeApplyBoundingBox(product.combinedBoundingBox); if (child instanceof Group) { @@ -328,6 +357,8 @@ export class VisualizationAssembler< } if (child instanceof DataProvider) { + numDataProviders++; + if (child.getStatus() === DataProviderStatus.LOADING) { numLoadingDataProviders++; } @@ -348,18 +379,20 @@ export class VisualizationAssembler< continue; } - const dataProviderVisualization = this.makeDataProviderVisualization(child, injectedData); + const dataProviderObjects = this.makeDataProviderObjects(child, injectedData); - if (!dataProviderVisualization) { + if (!dataProviderObjects.visualization) { continue; } - const providerBoundingBox = this.makeDataProviderBoundingBox(child); - maybeApplyBoundingBox(providerBoundingBox); - children.push(dataProviderVisualization); - annotations.push(...this.makeDataProviderAnnotations(child)); - hoverVisualizationFunctions.push(this.makeDataProviderHoverVisualizationsFunction(child, injectedData)); - accumulatedData = this.accumulateDataProviderData(child, accumulatedData) ?? accumulatedData; + maybeApplyBoundingBox(dataProviderObjects.boundingBox); + children.push(dataProviderObjects.visualization); + annotations.push(...dataProviderObjects.annotations); + hoverVisualizationFunctions = this.mergeHoverVisualizationFunctions( + hoverVisualizationFunctions, + dataProviderObjects.hoverVisualizationFunctions, + ); + accumulatedData = dataProviderObjects.accumulatedData ?? accumulatedData; } } @@ -373,21 +406,50 @@ export class VisualizationAssembler< aggregatedErrorMessages: aggregatedErrorMessages, combinedBoundingBox: combinedBoundingBox, annotations: annotations, - numLoadingDataProviders: numLoadingDataProviders, + numLoadingDataProviders, + numDataProviders, accumulatedData, - makeHoverVisualizationsFunction: ( - hoverInfo: GlobalTopicDefinitions[FilterHoverKeys], - ) => { - const collectedHoverVisualizations: DataProviderVisualizationTargetTypes[TTarget][] = []; - for (const makeHoverVisualizationFunction of hoverVisualizationFunctions) { - collectedHoverVisualizations.push(...makeHoverVisualizationFunction(hoverInfo)); - } - return collectedHoverVisualizations; - }, + hoverVisualizationFunctions: hoverVisualizationFunctions, customProps: {} as TCustomGroupProps, }; } + private makeDataProviderObjects( + dataProvider: DataProvider, + injectedData?: TInjectedData, + ): DataProviderObjects { + if (this._cachedDataProviderVisualizationsMap.has(dataProvider.getItemDelegate().getId())) { + const cached = this._cachedDataProviderVisualizationsMap.get(dataProvider.getItemDelegate().getId()); + if (cached && cached.revisionNumber === dataProvider.getRevisionNumber()) { + return cached.objects; + } + } + + const visualization = this.makeDataProviderVisualization(dataProvider, injectedData); + const hoverVisualizationFunctions = this.makeDataProviderHoverVisualizationFunctions( + dataProvider, + injectedData, + ); + const annotations = this.makeDataProviderAnnotations(dataProvider, injectedData); + const boundingBox = this.makeDataProviderBoundingBox(dataProvider); + const accumulatedData = this.accumulateDataProviderData(dataProvider, {} as TAccumulatedData, injectedData); + + const objects: DataProviderObjects = { + visualization, + hoverVisualizationFunctions, + annotations, + boundingBox, + accumulatedData, + }; + + this._cachedDataProviderVisualizationsMap.set(dataProvider.getItemDelegate().getId(), { + revisionNumber: dataProvider.getRevisionNumber(), + objects, + }); + + return objects; + } + private makeGroup< TSettings extends Settings, TSettingKey extends SettingsKeysFromTuple = SettingsKeysFromTuple, @@ -408,14 +470,15 @@ export class VisualizationAssembler< aggregatedErrorMessages: product.aggregatedErrorMessages, combinedBoundingBox: product.combinedBoundingBox, numLoadingDataProviders: product.numLoadingDataProviders, + numDataProviders: product.numDataProviders, accumulatedData: product.accumulatedData, - makeHoverVisualizationsFunction: product.makeHoverVisualizationsFunction, + hoverVisualizationFunctions: product.hoverVisualizationFunctions, customProps: func?.({ id: group.getItemDelegate().getId(), name: group.getItemDelegate().getName(), getSetting: (setting: TKey) => - group.getSharedSettingsDelegate()?.getWrappedSettings()[setting].getValue(), + group.getSharedSettingsDelegate()?.getWrappedSettings()[setting].getValue() ?? null, }) ?? ({} as TCustomGroupProps), }; } @@ -459,27 +522,27 @@ export class VisualizationAssembler< return null; } - return { + const visualizationObj: DataProviderVisualization = { itemType: VisualizationItemType.DATA_PROVIDER_VISUALIZATION, id: dataProvider.getItemDelegate().getId(), name: dataProvider.getItemDelegate().getName(), type: dataProvider.getType(), visualization, }; + + return visualizationObj; } - private makeDataProviderHoverVisualizationsFunction( + private makeDataProviderHoverVisualizationFunctions( dataProvider: DataProvider, injectedData?: TInjectedData, - ): HoverVisualizationsFunction { + ): HoverVisualizationFunctions { const func = this._dataProviderTransformers.get(dataProvider.getType())?.transformToHoverVisualization; if (!func) { - return () => []; + return {}; } - return (hoverInfo: GlobalTopicDefinitions[FilterHoverKeys]) => { - return func({ ...this.makeFactoryFunctionArgs.bind(this)(dataProvider, injectedData) }, hoverInfo); - }; + return func(this.makeFactoryFunctionArgs(dataProvider, injectedData)); } private makeDataProviderBoundingBox( @@ -518,4 +581,29 @@ export class VisualizationAssembler< return func(accumulatedData, this.makeFactoryFunctionArgs(dataProvider, injectedData)); } + + private mergeHoverVisualizationFunctions( + base: HoverVisualizationFunctions, + additional: HoverVisualizationFunctions, + ): HoverVisualizationFunctions { + const merged: HoverVisualizationFunctions = { ...base }; + + for (const key in additional) { + const typedKey = key as keyof HoverTopicDefinitions; + const baseFn = base[typedKey]; + const additionalFn = additional[typedKey]; + + if (baseFn && additionalFn) { + // TypeScript can't narrow K per key in a dynamic loop; we assert here intentionally + merged[typedKey] = ((hoverInfo: any) => [ + ...(baseFn as any)(hoverInfo), + ...(additionalFn as any)(hoverInfo), + ]) as any; + } else if (additionalFn) { + merged[typedKey] = additionalFn as any; + } + } + + return merged; + } } diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/annotations/makeColorScaleAnnotation.ts b/frontend/src/modules/_shared/DataProviderFramework/visualization/annotations/makeColorScaleAnnotation.ts similarity index 100% rename from frontend/src/modules/2DViewer/DataProviderFramework/annotations/makeColorScaleAnnotation.ts rename to frontend/src/modules/_shared/DataProviderFramework/visualization/annotations/makeColorScaleAnnotation.ts diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/boundingBoxes/makePolygonDataBoundingBox.ts b/frontend/src/modules/_shared/DataProviderFramework/visualization/boundingBoxes/makePolygonDataBoundingBox.ts similarity index 100% rename from frontend/src/modules/2DViewer/DataProviderFramework/boundingBoxes/makePolygonDataBoundingBox.ts rename to frontend/src/modules/_shared/DataProviderFramework/visualization/boundingBoxes/makePolygonDataBoundingBox.ts diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/boundingBoxes/makeRealizationGridBoundingBox.ts b/frontend/src/modules/_shared/DataProviderFramework/visualization/boundingBoxes/makeRealizationGridBoundingBox.ts similarity index 79% rename from frontend/src/modules/2DViewer/DataProviderFramework/boundingBoxes/makeRealizationGridBoundingBox.ts rename to frontend/src/modules/_shared/DataProviderFramework/visualization/boundingBoxes/makeRealizationGridBoundingBox.ts index 450647838b..d7be1428e8 100644 --- a/frontend/src/modules/2DViewer/DataProviderFramework/boundingBoxes/makeRealizationGridBoundingBox.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/visualization/boundingBoxes/makeRealizationGridBoundingBox.ts @@ -1,7 +1,7 @@ import type { BBox } from "@lib/utils/bbox"; import type { TransformerArgs } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler"; -import type { RealizationGridData } from "../customDataProviderImplementations/RealizationGridProvider"; +import type { RealizationGridData } from "../utils/types"; export function makeRealizationGridBoundingBox({ getData }: TransformerArgs): BBox | null { const data = getData(); @@ -13,12 +13,12 @@ export function makeRealizationGridBoundingBox({ getData }: TransformerArgs): BBox | null { const data = getData(); @@ -13,12 +13,12 @@ export function makeSurfaceLayerBoundingBox({ getData }: TransformerArgs, +): Grid3DLayer | null { + const { id, getData, getSetting, isLoading } = args; + const data = getData(); + let colorScaleSpec = getSetting(Setting.COLOR_SCALE); + const showGridLines = getSetting(Setting.SHOW_GRID_LINES) ?? false; + const opacityPercent = getSetting(Setting.OPACITY_PERCENT) ?? 100; + + if (!data || !colorScaleSpec) { + return null; + } + + colorScaleSpec = { + colorScale: colorScaleSpec.colorScale, + areBoundariesUserDefined: colorScaleSpec.areBoundariesUserDefined, + }; + + if (isLoading) { + colorScaleSpec.colorScale = new ColorScale({ + colorPalette: new ColorPalette({ + name: "Loading", + colors: ["#EEEEEE", "#EFEFEF"], + id: "black-white", + }), + gradientType: ColorScaleGradientType.Sequential, + type: ColorScaleType.Continuous, + steps: 100, + }); + } + + const { gridSurfaceData, gridParameterData } = data; + + const offsetXyz = [gridSurfaceData.origin_utm_x, gridSurfaceData.origin_utm_y, 0]; + const pointsNumberArray = gridSurfaceData.pointsFloat32Arr.map((val, i) => val + offsetXyz[i % 3]); + const polysNumberArray = gridSurfaceData.polysUint32Arr; + const grid3dLayer = new Grid3DLayer({ + id: id, + pointsData: pointsNumberArray, + polysData: polysNumberArray, + propertiesData: gridParameterData.polyPropsFloat32Arr, + ZIncreasingDownwards: false, + gridLines: showGridLines, + opacity: opacityPercent / 100, + material: { ambient: 0.4, diffuse: 0.7, shininess: 8, specularColor: [25, 25, 25] }, + pickable: true, + colorMapName: "Physics", + colorMapClampColor: true, + colorMapRange: [gridParameterData.min_grid_prop_value, gridParameterData.max_grid_prop_value], + colorMapFunction: makeColorMapFunctionFromColorScale(colorScaleSpec, { + valueMin: gridParameterData.min_grid_prop_value, + valueMax: gridParameterData.max_grid_prop_value, + denormalize: true, + }), + }); + return grid3dLayer; +} diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeRealizationPolygonsLayer.ts b/frontend/src/modules/_shared/DataProviderFramework/visualization/deckgl/makeRealizationPolygonsLayer.ts similarity index 96% rename from frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeRealizationPolygonsLayer.ts rename to frontend/src/modules/_shared/DataProviderFramework/visualization/deckgl/makeRealizationPolygonsLayer.ts index 7109fee243..d676052402 100644 --- a/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeRealizationPolygonsLayer.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/visualization/deckgl/makeRealizationPolygonsLayer.ts @@ -7,7 +7,7 @@ import type { TransformerArgs } from "@modules/_shared/DataProviderFramework/vis import type { RealizationPolygonsData, RealizationPolygonsSettings, -} from "../customDataProviderImplementations/RealizationPolygonsProvider"; +} from "../../dataProviders/implementations/RealizationPolygonsProvider"; function zipCoords(xArr: readonly number[], yArr: readonly number[], zArr: readonly number[]): number[][] { const coords: number[][] = []; diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeRealizationSurfaceLayer.ts b/frontend/src/modules/_shared/DataProviderFramework/visualization/deckgl/makeRealizationSurfaceLayer.ts similarity index 83% rename from frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeRealizationSurfaceLayer.ts rename to frontend/src/modules/_shared/DataProviderFramework/visualization/deckgl/makeRealizationSurfaceLayer.ts index 564c419c47..f228e3d827 100644 --- a/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeRealizationSurfaceLayer.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/visualization/deckgl/makeRealizationSurfaceLayer.ts @@ -12,7 +12,7 @@ import { type RealizationSurfaceData, type RealizationSurfaceSettings, SurfaceDataFormat, -} from "../customDataProviderImplementations/RealizationSurfaceProvider"; +} from "../../dataProviders/implementations/RealizationSurfaceProvider"; function calcBoundsForRotationAroundUpperLeftCorner(surfDef: SurfaceDef_api): [number, number, number, number] { const width = (surfDef.npoints_x - 1) * surfDef.inc_x; @@ -38,7 +38,7 @@ export function makeRealizationSurfaceLayer({ getSetting, }: TransformerArgs): ColormapLayer | Grid3DLayer | null { const data = getData(); - const colorScale = getSetting(Setting.COLOR_SCALE)?.colorScale; + const colorScaleSpec = getSetting(Setting.COLOR_SCALE); if (!data) { return null; @@ -52,11 +52,11 @@ export function makeRealizationSurfaceLayer({ parameters: { depthWriteEnabled: false, }, - colorMapFunction: makeColorMapFunctionFromColorScale( - colorScale, - data.surfaceData.value_min, - data.surfaceData.value_max, - ), + colorMapFunction: makeColorMapFunctionFromColorScale(colorScaleSpec, { + valueMin: data.surfaceData.value_min, + valueMax: data.surfaceData.value_max, + denormalize: true, + }), }); } @@ -72,10 +72,10 @@ export function makeRealizationSurfaceLayer({ parameters: { depthWriteEnabled: false, }, - colorMapFunction: makeColorMapFunctionFromColorScale( - colorScale, - data.surfaceData.value_min, - data.surfaceData.value_max, - ), + colorMapFunction: makeColorMapFunctionFromColorScale(colorScaleSpec, { + valueMin: data.surfaceData.value_min, + valueMax: data.surfaceData.value_max, + denormalize: true, + }), }); } diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeStatisticalSurfaceLayer.ts b/frontend/src/modules/_shared/DataProviderFramework/visualization/deckgl/makeStatisticalSurfaceLayer.ts similarity index 83% rename from frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeStatisticalSurfaceLayer.ts rename to frontend/src/modules/_shared/DataProviderFramework/visualization/deckgl/makeStatisticalSurfaceLayer.ts index 7954d1d7a1..d93531960b 100644 --- a/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeStatisticalSurfaceLayer.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/visualization/deckgl/makeStatisticalSurfaceLayer.ts @@ -13,7 +13,7 @@ import { type StatisticalSurfaceSettings, type StatisticalSurfaceStoredData, SurfaceDataFormat, -} from "../customDataProviderImplementations/StatisticalSurfaceProvider"; +} from "../../dataProviders/implementations/StatisticalSurfaceProvider"; function calcBoundsForRotationAroundUpperLeftCorner(surfDef: SurfaceDef_api): [number, number, number, number] { const width = (surfDef.npoints_x - 1) * surfDef.inc_x; @@ -42,7 +42,7 @@ export function makeStatisticalSurfaceLayer({ | Grid3DLayer | null { const data = getData(); - const colorScale = getSetting(Setting.COLOR_SCALE)?.colorScale; + const colorScaleSpec = getSetting(Setting.COLOR_SCALE); if (!data) { return null; @@ -56,11 +56,11 @@ export function makeStatisticalSurfaceLayer({ parameters: { depthWriteEnabled: false, }, - colorMapFunction: makeColorMapFunctionFromColorScale( - colorScale, - data.surfaceData.value_min, - data.surfaceData.value_max, - ), + colorMapFunction: makeColorMapFunctionFromColorScale(colorScaleSpec, { + valueMin: data.surfaceData.value_min, + valueMax: data.surfaceData.value_max, + denormalize: true, + }), }); } @@ -76,10 +76,10 @@ export function makeStatisticalSurfaceLayer({ parameters: { depthWriteEnabled: false, }, - colorMapFunction: makeColorMapFunctionFromColorScale( - colorScale, - data.surfaceData.value_min, - data.surfaceData.value_max, - ), + colorMapFunction: makeColorMapFunctionFromColorScale(colorScaleSpec, { + valueMin: data.surfaceData.value_min, + valueMax: data.surfaceData.value_max, + denormalize: true, + }), }); } diff --git a/frontend/src/modules/_shared/DataProviderFramework/visualization/hooks/useSubscribedProviderHoverVisualizations.ts b/frontend/src/modules/_shared/DataProviderFramework/visualization/hooks/useSubscribedProviderHoverVisualizations.ts new file mode 100644 index 0000000000..68f56c8d7e --- /dev/null +++ b/frontend/src/modules/_shared/DataProviderFramework/visualization/hooks/useSubscribedProviderHoverVisualizations.ts @@ -0,0 +1,113 @@ +import React from "react"; + +import type { WorkbenchServices } from "@framework/WorkbenchServices"; + +import { + VisualizationItemType, + type AssemblerProduct, + type DataProviderHoverVisualizationTargetTypes, + type HoverTopicDefinitions, + type HoverVisualizationFunctions, + type VisualizationGroup, + type VisualizationTarget, +} from "../VisualizationAssembler"; + +export type AssemblerProviderHoverVisualizations = { + groupId?: string; + hoverVisualizations: DataProviderHoverVisualizationTargetTypes[TTarget][]; +}; + +type InternalAssemblerProviderHoverVisualizations = { + groupId?: string; + hoverVisualizations: Partial< + Record + >; +}; + +type InternalAssemblerProviderHoverVisualizationFunctions = { + groupId?: string; + hoverVisualizationFunctions: HoverVisualizationFunctions; +}; + +export function useSubscribedProviderHoverVisualizations( + visualizationAssemblerProduct: AssemblerProduct, + workbenchServices: WorkbenchServices, +): AssemblerProviderHoverVisualizations[] { + const [visualizations, setVisualizations] = React.useState[]>( + [], + ); + + React.useEffect( + function subscribeToWorkbenchServices() { + const hoverVisualizationFunctions = + flattenVisualizationFunctionsRecursively(visualizationAssemblerProduct); + const unsubscribeFunctions: (() => void)[] = []; + + let visualizationsArray: InternalAssemblerProviderHoverVisualizations[] = + hoverVisualizationFunctions.map((hoverVisualizationFunction) => ({ + groupId: hoverVisualizationFunction.groupId, + hoverVisualizations: {}, + })); + setVisualizations(visualizationsArray); + + for (const hoverVisualizationFunction of hoverVisualizationFunctions) { + for (const [topic, hoverFunction] of Object.entries( + hoverVisualizationFunction.hoverVisualizationFunctions, + )) { + const typedKey = topic as keyof HoverTopicDefinitions; + unsubscribeFunctions.push( + workbenchServices.subscribe(typedKey, (value) => { + const newVisualizations = [...visualizationsArray]; + + for (const visualization of newVisualizations) { + if (visualization.groupId === hoverVisualizationFunction.groupId) { + visualization.hoverVisualizations[typedKey] = hoverFunction(value as any); + } + } + + visualizationsArray = newVisualizations; + setVisualizations(newVisualizations); + }), + ); + } + } + + return function unsubscribeFromWorkbenchServices() { + for (const unsubscribeFunction of unsubscribeFunctions) { + unsubscribeFunction(); + } + setVisualizations([]); + }; + }, + [visualizationAssemblerProduct, workbenchServices], + ); + + return visualizations.map((visualization) => ({ + groupId: visualization.groupId, + hoverVisualizations: Object.values(visualization.hoverVisualizations).flat(), + })); +} + +function flattenVisualizationFunctionsRecursively( + visualizationGroup: VisualizationGroup | AssemblerProduct, +): InternalAssemblerProviderHoverVisualizationFunctions[] { + const visualizationFunctions: InternalAssemblerProviderHoverVisualizationFunctions[] = []; + if (visualizationGroup.hoverVisualizationFunctions) { + visualizationFunctions.push({ + groupId: Object.hasOwn(visualizationGroup, "id") + ? (visualizationGroup as VisualizationGroup).id + : undefined, + hoverVisualizationFunctions: visualizationGroup.hoverVisualizationFunctions, + }); + } + + if (visualizationGroup.children) { + for (const child of visualizationGroup.children) { + if (child.itemType === VisualizationItemType.GROUP) { + visualizationFunctions.push(...flattenVisualizationFunctionsRecursively(child)); + } + } + } + + return visualizationFunctions; +} diff --git a/frontend/src/modules/_shared/DataProviderFramework/visualization/utils/colors.ts b/frontend/src/modules/_shared/DataProviderFramework/visualization/utils/colors.ts index 90e0e2bbbe..2e73ae0e1b 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/visualization/utils/colors.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/visualization/utils/colors.ts @@ -1,28 +1,72 @@ -import type { Rgb } from "culori"; -import { parse } from "culori"; +import type { colorTablesObj } from "@emerson-eps/color-tables"; +import { parse, type Color, type Rgb } from "culori"; +import type { ColorScaleSpecification } from "@framework/components/ColorScaleSelector/colorScaleSelector"; import type { ColorScale } from "@lib/utils/ColorScale"; export function makeColorMapFunctionFromColorScale( - colorScale: ColorScale | undefined, - valueMin: number, - valueMax: number, - unnormalize = true, -): ((value: number) => [number, number, number]) | undefined { - if (!colorScale) { - return undefined; + colorScaleSpec: ColorScaleSpecification | null, + options?: { + valueMin: number; + valueMax: number; + midPoint?: number; + denormalize?: boolean; + specialColor?: { + color: string; + range: [number, number]; + }; + }, +): ((value: number) => [number, number, number, number]) | undefined { + if (!colorScaleSpec) return undefined; + + const localColorScale = colorScaleSpec.colorScale.clone(); + + if (options && !colorScaleSpec.areBoundariesUserDefined) { + if (options.midPoint === undefined) { + localColorScale.setRange(options.valueMin, options.valueMax); + } else { + localColorScale.setRangeAndMidPoint(options.valueMin, options.valueMax, options.midPoint); + } } - const localColorScale = colorScale.clone(); - localColorScale.setRange(valueMin, valueMax); + const valueMin = localColorScale.getMin(); + const valueMax = localColorScale.getMax(); + const specialColor = options?.specialColor; return (value: number) => { - const nonNormalizedValue = unnormalize ? value * (valueMax - valueMin) + valueMin : value; - const interpolatedColor = localColorScale.getColorForValue(nonNormalizedValue); - const color = parse(interpolatedColor) as Rgb; - if (color === undefined) { - return [0, 0, 0]; + const nonNormalizedValue = options?.denormalize ? value * (valueMax - valueMin) + valueMin : value; + let interpolatedColor = localColorScale.getColorForValue(nonNormalizedValue); + + if ( + specialColor !== null && + specialColor !== undefined && + value >= specialColor.range[0] && + value <= specialColor.range[1] + ) { + interpolatedColor = specialColor.color; + } + + const parsed = parse(interpolatedColor); + + if (!parsed || parsed.mode !== "rgb") { + return [0, 0, 0, 1]; // fallback } - return [color.r * 255, color.g * 255, color.b * 255]; + + return [parsed.r * 255, parsed.g * 255, parsed.b * 255, (parsed.alpha ?? 1) * 255]; }; } + +export function createContinuousColorScaleForMap(colorScale: ColorScale): colorTablesObj[] { + const hexColors = colorScale.getPlotlyColorScale(); + const rgbArr: [number, number, number, number][] = []; + hexColors.forEach((hexColor) => { + const color: Color | undefined = parse(hexColor[1]); // Returns object with r, g, b items for hex strings + + if (color && "r" in color && "g" in color && "b" in color) { + const rgbColor = color as Rgb; + rgbArr.push([hexColor[0], rgbColor.r * 255, rgbColor.g * 255, rgbColor.b * 255]); + } + }); + + return [{ name: "Continuous", discrete: false, colors: rgbArr }]; +} diff --git a/frontend/src/modules/_shared/DataProviderFramework/visualization/utils/types.ts b/frontend/src/modules/_shared/DataProviderFramework/visualization/utils/types.ts new file mode 100644 index 0000000000..000ded914a --- /dev/null +++ b/frontend/src/modules/_shared/DataProviderFramework/visualization/utils/types.ts @@ -0,0 +1,6 @@ +import type { GridMappedProperty_trans, GridSurface_trans } from "@modules/_shared/utils/queryDataTransforms"; + +export type RealizationGridData = { + gridSurfaceData: GridSurface_trans; + gridParameterData: GridMappedProperty_trans; +}; diff --git a/frontend/src/modules/_shared/components/ColorLegendsContainer/colorLegendsContainer.tsx b/frontend/src/modules/_shared/components/ColorLegendsContainer/colorLegendsContainer.tsx index 0aa4836d41..04a5aeceb9 100644 --- a/frontend/src/modules/_shared/components/ColorLegendsContainer/colorLegendsContainer.tsx +++ b/frontend/src/modules/_shared/components/ColorLegendsContainer/colorLegendsContainer.tsx @@ -3,6 +3,7 @@ import React from "react"; import type { ColorScale } from "@lib/utils/ColorScale"; import { ColorScaleGradientType, ColorScaleType } from "@lib/utils/ColorScale"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import { formatNumber } from "@modules/_shared/utils/numberFormatting"; import type { ColorScaleWithName } from "@modules_shared/utils/ColorScaleWithName"; import type { ColorScaleWithId } from "./colorScaleWithId"; @@ -74,7 +75,7 @@ function makeMarkers( fontSize="10" style={TEXT_STYLE} > - {formatLegendValue(value)} + {formatNumber(value)} , ); @@ -125,7 +126,7 @@ function makeDiscreteMarkers(colorScale: ColorScale, left: number, top: number, fontSize="10" style={TEXT_STYLE} > - {formatLegendValue(value)} + {formatNumber(value)} , ); @@ -173,7 +174,7 @@ function ColorLegend(props: ColorLegendProps): React.ReactNode { fontSize="10" style={TEXT_STYLE} > - {formatLegendValue(props.colorScale.getMax())} + {formatNumber(props.colorScale.getMax())} , ); @@ -230,7 +231,7 @@ function ColorLegend(props: ColorLegendProps): React.ReactNode { fontSize="10" style={TEXT_STYLE} > - {formatLegendValue(props.colorScale.getDivMidPoint())} + {formatNumber(props.colorScale.getDivMidPoint())} , ); @@ -277,7 +278,7 @@ function ColorLegend(props: ColorLegendProps): React.ReactNode { fontSize="10" style={TEXT_STYLE} > - {formatLegendValue(props.colorScale.getMin())} + {formatNumber(props.colorScale.getMin())} , ); @@ -435,16 +436,3 @@ function GradientDef(props: GradientDefProps): React.ReactNode { function makeGradientId(id: string): string { return `color-legend-gradient-${id}`; } - -function countDecimalPlaces(value: number): number { - const decimalIndex = value.toString().indexOf("."); - return decimalIndex >= 0 ? value.toString().length - decimalIndex - 1 : 0; -} - -function formatLegendValue(value: number): string { - const numDecimalPlaces = countDecimalPlaces(value); - if (Math.log10(Math.abs(value)) > 2) { - return value.toExponential(numDecimalPlaces > 2 ? 2 : numDecimalPlaces); - } - return value.toFixed(numDecimalPlaces > 2 ? 2 : numDecimalPlaces); -} diff --git a/frontend/src/modules/_shared/components/ReadoutBox.tsx b/frontend/src/modules/_shared/components/ReadoutBox.tsx index d5f4e62047..1ec8c4df97 100644 --- a/frontend/src/modules/_shared/components/ReadoutBox.tsx +++ b/frontend/src/modules/_shared/components/ReadoutBox.tsx @@ -6,6 +6,8 @@ import { useStableProp } from "@lib/hooks/useStableProp"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; import { convertRemToPixels } from "@lib/utils/screenUnitConversions"; +import { formatNumber } from "../utils/numberFormatting"; + export type ReadoutItem = { label: string; info: InfoItem[]; @@ -182,7 +184,7 @@ function makeFormattedInfoValue(value: string | number | boolean | number[]): st function formatValue(value: number | string | boolean): string { if (typeof value === "number") { - return (+value.toFixed(2)).toString(); + return formatNumber(value); } return value.toString(); } diff --git a/frontend/src/modules/_shared/components/ViewportLabel/ViewportLabel.tsx b/frontend/src/modules/_shared/components/ViewportLabel/ViewportLabel.tsx new file mode 100644 index 0000000000..d646c0eca9 --- /dev/null +++ b/frontend/src/modules/_shared/components/ViewportLabel/ViewportLabel.tsx @@ -0,0 +1,21 @@ +import type React from "react"; + +import type { ViewportTypeExtended } from "@modules/_shared/types/deckgl"; + +export type ViewportLabelProps = { + viewport: ViewportTypeExtended; +}; + +export function ViewportLabel(props: ViewportLabelProps): React.ReactNode { + return ( +
+
+
+
{props.viewport.name}
+
+
+ ); +} diff --git a/frontend/src/modules/_shared/components/ViewportLabel/index.ts b/frontend/src/modules/_shared/components/ViewportLabel/index.ts new file mode 100644 index 0000000000..11981843ad --- /dev/null +++ b/frontend/src/modules/_shared/components/ViewportLabel/index.ts @@ -0,0 +1,2 @@ +export { ViewportLabel } from "./ViewportLabel"; +export type { ViewportLabelProps } from "./ViewportLabel"; diff --git a/frontend/src/modules/_shared/customDeckGlLayers/AdjustedWellsLayer.ts b/frontend/src/modules/_shared/customDeckGlLayers/AdjustedWellsLayer.ts new file mode 100644 index 0000000000..0309281d8a --- /dev/null +++ b/frontend/src/modules/_shared/customDeckGlLayers/AdjustedWellsLayer.ts @@ -0,0 +1,80 @@ +import type { FilterContext, LayersList, UpdateParameters } from "@deck.gl/core"; +import { Layer } from "@deck.gl/core"; +import { GeoJsonLayer } from "@deck.gl/layers"; +import type { BoundingBox3D } from "@webviz/subsurface-viewer"; +import { WellsLayer } from "@webviz/subsurface-viewer/dist/layers"; +import { GetBoundingBox } from "@webviz/subsurface-viewer/dist/layers/wells/utils/spline"; + +export class AdjustedWellsLayer extends WellsLayer { + static layerName: string = "AdjustedWellsLayer"; + + filterSubLayer(context: FilterContext): boolean { + if (context.layer.id.includes("names")) { + return context.viewport.zoom > -2; + } + + return true; + } + + updateState(params: UpdateParameters): void { + super.updateState(params); + const { props, changeFlags } = params; + if (props.reportBoundingBox && changeFlags.dataChanged) { + props.reportBoundingBox({ + layerBoundingBox: this.calcBoundingBox(), + }); + } + } + + private calcBoundingBox(): BoundingBox3D { + if (!this.state.data) { + return [0, 0, 0, 0, 0, 0]; + } + + const bbox = GetBoundingBox(this.state.data); + console.debug("AdjustedWellsLayer bounding box", bbox); + return bbox; + } + + renderLayers(): LayersList { + const layers = super.renderLayers(); + + if (!Array.isArray(layers)) { + return layers; + } + + const colorsLayer = layers.find((layer) => { + if (!(layer instanceof Layer)) { + return false; + } + + return layer.id.includes("colors"); + }); + + if (!(colorsLayer instanceof GeoJsonLayer)) { + return layers; + } + + const newColorsLayer = new GeoJsonLayer( + super.getSubLayerProps({ + ...colorsLayer.props, + data: colorsLayer.props.data, + pickable: true, + stroked: false, + pointRadiusUnits: "meters", + lineWidthUnits: "meters", + pointRadiusScale: this.props.pointRadiusScale, + lineWidthScale: this.props.lineWidthScale, + lineBillboard: true, + pointBillboard: true, + id: "colors", + lineWidthMinPixels: 1, + lineWidthMaxPixels: 5, + autoHighlight: true, + onHover: () => {}, + }), + ); + + return [newColorsLayer, ...layers.filter((layer) => layer !== colorsLayer)]; + } +} diff --git a/frontend/src/modules/_shared/customDeckGlLayers/AdvancedWellsLayer.ts b/frontend/src/modules/_shared/customDeckGlLayers/AdvancedWellsLayer.ts deleted file mode 100644 index 7fb7475992..0000000000 --- a/frontend/src/modules/_shared/customDeckGlLayers/AdvancedWellsLayer.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { FilterContext, LayersList } from "@deck.gl/core"; -import { Layer } from "@deck.gl/core"; -import { GeoJsonLayer } from "@deck.gl/layers"; -import { WellsLayer } from "@webviz/subsurface-viewer/dist/layers"; - -export class AdvancedWellsLayer extends WellsLayer { - static layerName: string = "WellsLayer"; - - filterSubLayer(context: FilterContext): boolean { - if (context.layer.id.includes("names")) { - return context.viewport.zoom > -2; - } - - return true; - } - - renderLayers(): LayersList { - const layers = super.renderLayers(); - - if (!Array.isArray(layers)) { - return layers; - } - - const colorsLayer = layers.find((layer) => { - if (!(layer instanceof Layer)) { - return false; - } - - return layer.id.includes("colors"); - }); - - if (!(colorsLayer instanceof GeoJsonLayer)) { - return layers; - } - - const newColorsLayer = new GeoJsonLayer({ - data: colorsLayer.props.data, - pickable: true, - stroked: false, - positionFormat: colorsLayer.props.positionFormat, - pointRadiusUnits: "meters", - lineWidthUnits: "meters", - pointRadiusScale: this.props.pointRadiusScale, - lineWidthScale: this.props.lineWidthScale, - getLineWidth: colorsLayer.props.getLineWidth, - getPointRadius: colorsLayer.props.getPointRadius, - lineBillboard: true, - pointBillboard: true, - parameters: colorsLayer.props.parameters, - visible: colorsLayer.props.visible, - id: "colors", - lineWidthMinPixels: 1, - lineWidthMaxPixels: 5, - extensions: colorsLayer.props.extensions, - getDashArray: colorsLayer.props.getDashArray, - getLineColor: colorsLayer.props.getLineColor, - getFillColor: colorsLayer.props.getFillColor, - autoHighlight: true, - onHover: () => {}, - }); - - return [newColorsLayer, ...layers.filter((layer) => layer !== colorsLayer)]; - } -} diff --git a/frontend/src/modules/_shared/customDeckGlLayers/WellborePicksLayer.ts b/frontend/src/modules/_shared/customDeckGlLayers/WellborePicksLayer.ts index 1bb9993c7b..440ba1fa2e 100644 --- a/frontend/src/modules/_shared/customDeckGlLayers/WellborePicksLayer.ts +++ b/frontend/src/modules/_shared/customDeckGlLayers/WellborePicksLayer.ts @@ -1,6 +1,7 @@ import type { CompositeLayerProps, FilterContext, Layer, UpdateParameters } from "@deck.gl/core"; import { CompositeLayer } from "@deck.gl/core"; import { GeoJsonLayer, TextLayer } from "@deck.gl/layers"; +import type { BoundingBox3D, ReportBoundingBoxAction } from "@webviz/subsurface-viewer/dist/components/Map"; import type { Feature, FeatureCollection } from "geojson"; export type WellborePicksLayerData = { @@ -20,6 +21,9 @@ type TextLayerData = { export type WellBorePicksLayerProps = { id: string; data: WellborePicksLayerData[]; + + // Non-public property: + reportBoundingBox?: React.Dispatch; }; export class WellborePicksLayer extends CompositeLayer { @@ -35,13 +39,22 @@ export class WellborePicksLayer extends CompositeLayer return true; } - updateState(params: UpdateParameters>>): void { - const features: Feature[] = params.props.data.map((wellPick) => { + updateState({ + props, + changeFlags, + }: UpdateParameters>>): void { + if (props.reportBoundingBox && changeFlags.dataChanged) { + props.reportBoundingBox({ + layerBoundingBox: this.calcBoundingBox(), + }); + } + + const features: Feature[] = props.data.map((wellPick) => { return { type: "Feature", geometry: { type: "Point", - coordinates: [wellPick.easting, wellPick.northing], + coordinates: [wellPick.easting, wellPick.northing, -wellPick.tvdMsl], }, properties: { name: `${wellPick.wellBoreUwi}, TVD_MSL: ${wellPick.tvdMsl}, MD: ${wellPick.md}`, @@ -57,7 +70,7 @@ export class WellborePicksLayer extends CompositeLayer const textData: TextLayerData[] = this.props.data.map((wellPick) => { return { - coordinates: [wellPick.easting, wellPick.northing, wellPick.tvdMsl], + coordinates: [wellPick.easting, wellPick.northing, -wellPick.tvdMsl], name: wellPick.wellBoreUwi, }; }); @@ -66,6 +79,30 @@ export class WellborePicksLayer extends CompositeLayer this._textData = textData; } + private calcBoundingBox(): BoundingBox3D { + let xmin = Number.POSITIVE_INFINITY; + let ymin = Number.POSITIVE_INFINITY; + let zmin = Number.POSITIVE_INFINITY; + let xmax = Number.NEGATIVE_INFINITY; + let ymax = Number.NEGATIVE_INFINITY; + let zmax = Number.NEGATIVE_INFINITY; + + for (const wellPick of this.props.data) { + const easting = wellPick.easting; + const northing = wellPick.northing; + const tvdMsl = -wellPick.tvdMsl; // Invert Z for depth + + xmin = Math.min(xmin, easting); + ymin = Math.min(ymin, northing); + zmin = Math.min(zmin, tvdMsl); + xmax = Math.max(xmax, easting); + ymax = Math.max(ymax, northing); + zmax = Math.max(zmax, tvdMsl); + } + + return [xmin, ymin, zmin, xmax, ymax, zmax]; + } + renderLayers() { const fontSize = 16; const sizeMinPixels = 16; diff --git a/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/WellsLayer.ts b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/WellsLayer.ts new file mode 100644 index 0000000000..01dc1bb679 --- /dev/null +++ b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/WellsLayer.ts @@ -0,0 +1,143 @@ +import { + CompositeLayer, + type GetPickingInfoParams, + type LayersList, + type Material, + type PickingInfo, + type UpdateParameters, +} from "@deck.gl/core"; +import type { ExtendedLayerProps, LayerPickInfo } from "@webviz/subsurface-viewer"; +import type { BoundingBox3D, ReportBoundingBoxAction } from "@webviz/subsurface-viewer/dist/components/Map"; + +import * as vec3 from "@lib/utils/vec3"; + +import { type PipeLayerProps, PipesLayer } from "./_private/PipeLayer"; +import { getMd } from "./_private/wellTrajectoryUtils"; + +export type WellsLayerData = { + coordinates: [number, number, number][]; + properties: { uuid: string; name: string; mdArray: number[] }; +}[]; + +export interface WellsLayerProps extends ExtendedLayerProps { + id: string; + data: WellsLayerData; + zIncreaseDownwards?: boolean; + + boundingBox: BoundingBox3D; + + // Non public properties: + reportBoundingBox?: React.Dispatch; +} + +const MATERIAL: Material = { + ambient: 0.2, + diffuse: 0.6, + shininess: 132, + specularColor: [255, 255, 255], +}; + +export class WellsLayer extends CompositeLayer { + static layerName: string = "WellsLayer"; + + // @ts-expect-error - This is how deck.gl expects the state to be defined + state!: { + hoveredPipeIndex: number | null; + pipesLayerData: PipeLayerProps["data"]; + }; + + initializeState(): void { + this.setState({ + hoveredPipeIndex: null, + pipesLayerData: [], + }); + } + + shouldUpdateState({ changeFlags }: UpdateParameters): boolean { + return changeFlags.dataChanged !== false; + } + + updateState(params: UpdateParameters): void { + super.updateState(params); + + const { boundingBox } = this.props; + + if (params.changeFlags.dataChanged) { + const pipesLayerData = this.props.data.map((well) => { + return { + id: well.properties.uuid, + centerLinePath: well.coordinates.map((coord) => { + return { x: coord[0], y: coord[1], z: coord[2] }; + }), + }; + }); + + this.setState({ pipesLayerData }); + } + + this.props.reportBoundingBox?.({ + layerBoundingBox: boundingBox, + }); + } + + getPickingInfo({ info }: GetPickingInfoParams): LayerPickInfo { + if (!info.sourceLayer?.id.includes("pipes-layer")) { + return info; + } + + const wellbore = this.props.data[info.index]; + if (!wellbore) { + return info; + } + info.object = this.props.data[info.index]; + + const coordinate = info.coordinate ?? [0, 0, 0]; + + const trajectory = wellbore.coordinates.map((coord) => vec3.fromArray(coord)); + + const md = getMd(vec3.fromArray(coordinate), wellbore.properties.mdArray, trajectory); + + if (md !== null) { + return { + ...info, + properties: [ + { + name: `MD ${wellbore.properties.name}`, + value: md, + }, + ], + }; + } + + return info; + } + + onHover(info: PickingInfo): boolean { + if (!info.sourceLayer) { + return false; + } + + const { sourceLayer } = info; + if (sourceLayer.id !== "hover-path-layer") { + return false; + } + + const { index } = info; + this.setState({ hoveredPipeIndex: index }); + return false; + } + + renderLayers(): LayersList { + const { pipesLayerData } = this.state; + return [ + new PipesLayer({ + id: "pipes-layer", + data: pipesLayerData, + material: MATERIAL, + pickable: true, + // @ts-expect-error - This is how deck.gl expects the state to be defined + parameters: { depthTest: true }, + }), + ]; + } +} diff --git a/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/Line.ts b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/Line.ts new file mode 100644 index 0000000000..3231cf035c --- /dev/null +++ b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/Line.ts @@ -0,0 +1,32 @@ +import * as vec3 from "@lib/utils/vec3"; + +export type Line = { + point: vec3.Vec3; + direction: vec3.Vec3; +}; + +export function fromPoints(p1: vec3.Vec3, p2: vec3.Vec3): Line { + return { + point: p1, + direction: vec3.subtract(p2, p1), + }; +} + +export function fromPointAndDirection(point: vec3.Vec3, direction: vec3.Vec3): Line { + return { + point, + direction, + }; +} + +export function intersect(line1: Line, line2: Line): vec3.Vec3 | null { + const cross = vec3.cross(line1.direction, line2.direction); + const crossLength = vec3.length(cross); + if (crossLength < 1e-6) { + return null; + } + + const line1ToLine2 = vec3.subtract(line2.point, line1.point); + const t = vec3.dot(vec3.cross(line1ToLine2, line2.direction), cross) / crossLength ** 2; + return vec3.add(line1.point, vec3.scale(line1.direction, t)); +} diff --git a/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/Mat3.ts b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/Mat3.ts new file mode 100644 index 0000000000..e0310d58cb --- /dev/null +++ b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/Mat3.ts @@ -0,0 +1,113 @@ +/** + * A 3x3 matrix. + * + * The matrix is stored in column-major order. + */ +export type Mat3 = { + m00: number; + m01: number; + m02: number; + m10: number; + m11: number; + m12: number; + m20: number; + m21: number; + m22: number; +}; + +export function setRow(matrix: Mat3, rowIndex: number, x: number, y: number, z: number): Mat3 { + switch (rowIndex) { + case 0: + return { + ...matrix, + m00: x, + m01: y, + m02: z, + }; + case 1: + return { + ...matrix, + m10: x, + m11: y, + m12: z, + }; + case 2: + return { + ...matrix, + m20: x, + m21: y, + m22: z, + }; + default: + throw new Error(`Invalid row index: ${rowIndex}`); + } +} + +export function setColumn(matrix: Mat3, columnIndex: number, x: number, y: number, z: number): Mat3 { + switch (columnIndex) { + case 0: + return { + ...matrix, + m00: x, + m10: y, + m20: z, + }; + case 1: + return { + ...matrix, + m01: x, + m11: y, + m21: z, + }; + case 2: + return { + ...matrix, + m02: x, + m12: y, + m22: z, + }; + default: + throw new Error(`Invalid column index: ${columnIndex}`); + } +} + +export function transpose(matrix: Mat3) { + matrix.m01 = matrix.m10; + matrix.m02 = matrix.m20; + matrix.m10 = matrix.m01; + matrix.m12 = matrix.m21; + matrix.m20 = matrix.m02; + matrix.m21 = matrix.m12; +} + +export function invert(matrix: Mat3) { + const tmp: number[] = []; + + tmp[0] = matrix.m11 * matrix.m22 - matrix.m12 * matrix.m21; + tmp[1] = matrix.m21 * matrix.m02 - matrix.m22 * matrix.m01; + tmp[2] = matrix.m01 * matrix.m12 - matrix.m02 * matrix.m11; + tmp[3] = matrix.m12 * matrix.m20 - matrix.m10 * matrix.m22; + tmp[4] = matrix.m22 * matrix.m00 - matrix.m20 * matrix.m02; + tmp[5] = matrix.m02 * matrix.m10 - matrix.m00 * matrix.m12; + tmp[6] = matrix.m10 * matrix.m21 - matrix.m11 * matrix.m20; + tmp[7] = matrix.m20 * matrix.m01 - matrix.m21 * matrix.m00; + tmp[8] = matrix.m00 * matrix.m11 - matrix.m01 * matrix.m10; + + const det = matrix.m00 * tmp[0] + matrix.m01 * tmp[3] + matrix.m02 * tmp[6]; + + if (det === 0) { + throw new Error("Matrix is not invertible"); + } + + const invDet = 1.0 / det; + + matrix.m00 = tmp[0] * invDet; + matrix.m01 = tmp[1] * invDet; + matrix.m02 = tmp[2] * invDet; + matrix.m10 = tmp[3] * invDet; + matrix.m11 = tmp[4] * invDet; + matrix.m12 = tmp[5] * invDet; + matrix.m20 = tmp[6] * invDet; + matrix.m21 = tmp[7] * invDet; + matrix.m22 = tmp[8] * invDet; +} diff --git a/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/Mat4.ts b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/Mat4.ts new file mode 100644 index 0000000000..6fa08f76e2 --- /dev/null +++ b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/Mat4.ts @@ -0,0 +1,465 @@ +import * as vec3 from "@lib/utils/vec3"; + +import * as mat3 from "./Mat3"; + +const EPSILON = 0.00001; + +/** + * A 4x4 matrix. + * + * The matrix is stored in column-major order. + * + */ +export type Mat4 = { + m00: number; + m01: number; + m02: number; + m03: number; + m10: number; + m11: number; + m12: number; + m13: number; + m20: number; + m21: number; + m22: number; + m23: number; + m30: number; + m31: number; + m32: number; + m33: number; +}; + +export function identity(): Mat4 { + return { + m00: 1, + m01: 0, + m02: 0, + m03: 0, + m10: 0, + m11: 1, + m12: 0, + m13: 0, + m20: 0, + m21: 0, + m22: 1, + m23: 0, + m30: 0, + m31: 0, + m32: 0, + m33: 1, + }; +} + +export function setRow(matrix: Mat4, rowIndex: number, x: number, y: number, z: number, w: number): Mat4 { + switch (rowIndex) { + case 0: + return { + ...matrix, + m00: x, + m01: y, + m02: z, + m03: w, + }; + case 1: + return { + ...matrix, + m10: x, + m11: y, + m12: z, + m13: w, + }; + case 2: + return { + ...matrix, + m20: x, + m21: y, + m22: z, + m23: w, + }; + case 3: + return { + ...matrix, + m30: x, + m31: y, + m32: z, + m33: w, + }; + default: + return matrix; + } +} + +export function setColumn(matrix: Mat4, columnIndex: number, x: number, y: number, z: number, w: number): Mat4 { + switch (columnIndex) { + case 0: + return { + ...matrix, + m00: x, + m10: y, + m20: z, + m30: w, + }; + case 1: + return { + ...matrix, + m01: x, + m11: y, + m21: z, + m31: w, + }; + case 2: + return { + ...matrix, + m02: x, + m12: y, + m22: z, + m32: w, + }; + case 3: + return { + ...matrix, + m03: x, + m13: y, + m23: z, + m33: w, + }; + default: + return matrix; + } +} + +export function transpose(matrix: Mat4) { + matrix.m01 = matrix.m10; + matrix.m02 = matrix.m20; + matrix.m03 = matrix.m30; + matrix.m10 = matrix.m01; + matrix.m12 = matrix.m21; + matrix.m13 = matrix.m31; + matrix.m20 = matrix.m02; + matrix.m21 = matrix.m12; + matrix.m23 = matrix.m32; + matrix.m30 = matrix.m03; + matrix.m31 = matrix.m13; + matrix.m32 = matrix.m23; +} + +export function invert(matrix: Mat4) { + if (matrix.m03 == 0 && matrix.m13 == 0 && matrix.m23 == 0 && matrix.m33 == 1) { + invertAffine(matrix); + return; + } +} + +export function invertAffine(matrix: Mat4) { + const r: mat3.Mat3 = { + m00: matrix.m00, + m01: matrix.m01, + m02: matrix.m02, + m10: matrix.m10, + m11: matrix.m11, + m12: matrix.m12, + m20: matrix.m20, + m21: matrix.m21, + m22: matrix.m22, + }; + + mat3.invert(r); + + matrix.m00 = r.m00; + matrix.m01 = r.m01; + matrix.m02 = r.m02; + matrix.m10 = r.m10; + matrix.m11 = r.m11; + matrix.m12 = r.m12; + matrix.m20 = r.m20; + matrix.m21 = r.m21; + matrix.m22 = r.m22; + + const x = matrix.m30; + const y = matrix.m31; + const z = matrix.m32; + + matrix.m30 = -(r.m00 * x + r.m10 * y + r.m20 * z); + matrix.m31 = -(r.m01 * x + r.m11 * y + r.m21 * z); + matrix.m32 = -(r.m02 * x + r.m12 * y + r.m22 * z); +} + +export function invertGeneral(matrix: Mat4) { + const cofactor0 = getCofactor( + matrix.m11, + matrix.m12, + matrix.m13, + matrix.m21, + matrix.m22, + matrix.m23, + matrix.m31, + matrix.m32, + matrix.m33 + ); + const cofactor1 = getCofactor( + matrix.m10, + matrix.m12, + matrix.m13, + matrix.m20, + matrix.m22, + matrix.m23, + matrix.m30, + matrix.m32, + matrix.m33 + ); + const cofactor2 = getCofactor( + matrix.m10, + matrix.m11, + matrix.m13, + matrix.m20, + matrix.m21, + matrix.m23, + matrix.m30, + matrix.m31, + matrix.m33 + ); + const cofactor3 = getCofactor( + matrix.m10, + matrix.m11, + matrix.m12, + matrix.m20, + matrix.m21, + matrix.m22, + matrix.m30, + matrix.m31, + matrix.m32 + ); + + const determinant = + matrix.m00 * cofactor0 - matrix.m01 * cofactor1 + matrix.m02 * cofactor2 - matrix.m03 * cofactor3; + if (Math.abs(determinant) <= EPSILON) { + matrix = identity(); + } + + const cofactor4 = getCofactor( + matrix.m01, + matrix.m02, + matrix.m03, + matrix.m21, + matrix.m22, + matrix.m23, + matrix.m31, + matrix.m32, + matrix.m33 + ); + const cofactor5 = getCofactor( + matrix.m00, + matrix.m02, + matrix.m03, + matrix.m20, + matrix.m22, + matrix.m23, + matrix.m30, + matrix.m32, + matrix.m33 + ); + const cofactor6 = getCofactor( + matrix.m00, + matrix.m01, + matrix.m03, + matrix.m20, + matrix.m21, + matrix.m23, + matrix.m30, + matrix.m31, + matrix.m33 + ); + const cofactor7 = getCofactor( + matrix.m00, + matrix.m01, + matrix.m02, + matrix.m20, + matrix.m21, + matrix.m22, + matrix.m30, + matrix.m31, + matrix.m32 + ); + + const cofactor8 = getCofactor( + matrix.m01, + matrix.m02, + matrix.m03, + matrix.m11, + matrix.m12, + matrix.m13, + matrix.m31, + matrix.m32, + matrix.m33 + ); + const cofactor9 = getCofactor( + matrix.m00, + matrix.m02, + matrix.m03, + matrix.m10, + matrix.m12, + matrix.m13, + matrix.m30, + matrix.m32, + matrix.m33 + ); + const cofactor10 = getCofactor( + matrix.m00, + matrix.m01, + matrix.m03, + matrix.m10, + matrix.m11, + matrix.m13, + matrix.m30, + matrix.m31, + matrix.m33 + ); + const cofactor11 = getCofactor( + matrix.m00, + matrix.m01, + matrix.m02, + matrix.m10, + matrix.m11, + matrix.m12, + matrix.m30, + matrix.m31, + matrix.m32 + ); + + const cofactor12 = getCofactor( + matrix.m01, + matrix.m02, + matrix.m03, + matrix.m11, + matrix.m12, + matrix.m13, + matrix.m21, + matrix.m22, + matrix.m23 + ); + const cofactor13 = getCofactor( + matrix.m00, + matrix.m02, + matrix.m03, + matrix.m10, + matrix.m12, + matrix.m13, + matrix.m20, + matrix.m22, + matrix.m23 + ); + const cofactor14 = getCofactor( + matrix.m00, + matrix.m01, + matrix.m03, + matrix.m10, + matrix.m11, + matrix.m13, + matrix.m20, + matrix.m21, + matrix.m23 + ); + const cofactor15 = getCofactor( + matrix.m00, + matrix.m01, + matrix.m02, + matrix.m10, + matrix.m11, + matrix.m12, + matrix.m20, + matrix.m21, + matrix.m22 + ); + + const invDeterminant = 1 / determinant; + + matrix.m00 = cofactor0 * invDeterminant; + matrix.m01 = -cofactor4 * invDeterminant; + matrix.m02 = cofactor8 * invDeterminant; + matrix.m03 = -cofactor12 * invDeterminant; + + matrix.m10 = -cofactor1 * invDeterminant; + matrix.m11 = cofactor5 * invDeterminant; + matrix.m12 = -cofactor9 * invDeterminant; + matrix.m13 = cofactor13 * invDeterminant; + + matrix.m20 = cofactor2 * invDeterminant; + matrix.m21 = -cofactor6 * invDeterminant; + matrix.m22 = cofactor10 * invDeterminant; + matrix.m23 = -cofactor14 * invDeterminant; + + matrix.m30 = -cofactor3 * invDeterminant; + matrix.m31 = cofactor7 * invDeterminant; + matrix.m32 = -cofactor11 * invDeterminant; + matrix.m33 = cofactor15 * invDeterminant; +} + +function getCofactor( + m0: number, + m1: number, + m2: number, + m3: number, + m4: number, + m5: number, + m6: number, + m7: number, + m8: number +): number { + return m0 * (m4 * m8 - m5 * m7) - m1 * (m3 * m8 - m5 * m6) + m2 * (m3 * m7 - m4 * m6); +} + +export function lookAt(matrix: Mat4, target: vec3.Vec3) { + const position = { x: matrix.m30, y: matrix.m31, z: matrix.m32 }; + const forward = vec3.normalize(vec3.subtract(target, position)); + let up: vec3.Vec3 = { x: 0, y: 0, z: 1 }; + let left: vec3.Vec3 = { x: 0, y: 1, z: 0 }; + + if (Math.abs(forward.x) < EPSILON && Math.abs(forward.z) < EPSILON) { + if (forward.y > 0) { + up = { x: 0, y: 0, z: -1 }; + } + } else { + up = { x: 0, y: 1, z: 0 }; + } + + left = vec3.normalize(vec3.cross(up, forward)); + up = vec3.cross(forward, left); + + matrix.m00 = left.x; + matrix.m01 = left.y; + matrix.m02 = left.z; + + matrix.m10 = up.x; + matrix.m11 = up.y; + matrix.m12 = up.z; + + matrix.m20 = forward.x; + matrix.m21 = forward.y; + matrix.m22 = forward.z; +} + +export function translate(matrix: Mat4, v: vec3.Vec3) { + matrix.m00 += matrix.m03 * v.x; + matrix.m01 += matrix.m03 * v.y; + matrix.m02 += matrix.m03 * v.z; + + matrix.m10 += matrix.m13 * v.x; + matrix.m11 += matrix.m13 * v.y; + matrix.m12 += matrix.m13 * v.z; + + matrix.m20 += matrix.m23 * v.x; + matrix.m21 += matrix.m23 * v.y; + matrix.m22 += matrix.m23 * v.z; + + matrix.m30 += matrix.m33 * v.x; + matrix.m31 += matrix.m33 * v.y; + matrix.m32 += matrix.m33 * v.z; +} + +export function multiply(matrix: Mat4, vector: vec3.Vec3): vec3.Vec3 { + return { + x: matrix.m00 * vector.x + matrix.m10 * vector.y + matrix.m20 * vector.z + matrix.m30, + y: matrix.m01 * vector.x + matrix.m11 * vector.y + matrix.m21 * vector.z + matrix.m31, + z: matrix.m02 * vector.x + matrix.m12 * vector.y + matrix.m22 * vector.z + matrix.m32, + }; +} diff --git a/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/Pipe.ts b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/Pipe.ts new file mode 100644 index 0000000000..7b70fddaa0 --- /dev/null +++ b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/Pipe.ts @@ -0,0 +1,106 @@ +import { cloneDeep } from "lodash"; + +import * as vec3 from "@lib/utils/vec3"; + + +import * as mat4 from "./Mat4"; +import * as plane from "./Plane"; + +export class Pipe { + private _path: vec3.Vec3[] = []; + private _contour: vec3.Vec3[] = []; + private _contours: vec3.Vec3[][] = []; + private _normals: vec3.Vec3[][] = []; + + constructor(pathPoints: vec3.Vec3[], contourPoints: vec3.Vec3[]) { + this._path = pathPoints; + this._contour = cloneDeep(contourPoints); + + this.generateContours(); + } + + getPath(): vec3.Vec3[] { + return this._path; + } + + getContours(): vec3.Vec3[][] { + return this._contours; + } + + getNormals(): vec3.Vec3[][] { + return this._normals; + } + + getContourCount(): number { + return this._contours.length; + } + + private generateContours() { + this._contours = []; + this._normals = []; + + if (this._path.length < 1) { + return; + } + + this.transformFirstContour(); + + this._contours.push(this._contour); + this._normals.push(this.computeContourNormal(0)); + + for (let i = 1; i < this._path.length; ++i) { + this._contours.push(this.projectContour(i - 1, i)); + this._normals.push(this.computeContourNormal(i)); + } + } + + private projectContour(fromIndex: number, toIndex: number): vec3.Vec3[] { + const dir1 = vec3.subtract(this._path[toIndex], this._path[fromIndex]); + + let dir2 = dir1; + if (toIndex < this._path.length - 1) { + dir2 = vec3.subtract(this._path[toIndex + 1], this._path[toIndex]); + } + + const normal = vec3.add(dir1, dir2); + const pl = plane.fromNormalAndPoint(normal, this._path[toIndex]); + + const fromContour = this._contours[fromIndex]; + const toContour: vec3.Vec3[] = []; + + for (let i = 0; i < fromContour.length; ++i) { + toContour.push(plane.intersectLine(pl, { point: fromContour[i], direction: dir1 })!); + } + + return toContour; + } + + private transformFirstContour() { + const matrix: mat4.Mat4 = mat4.identity(); + + if (this._path.length > 0) { + if (this._path.length > 1) { + mat4.lookAt(matrix, vec3.subtract(this._path[1], this._path[0])); + } + + mat4.translate(matrix, this._path[0]); + + for (let i = 0; i < this._contour.length; ++i) { + this._contour[i] = mat4.multiply(matrix, this._contour[i]); + } + } + } + + private computeContourNormal(pathIndex: number): vec3.Vec3[] { + const contour = this._contours[pathIndex]; + const center = this._path[pathIndex]; + + const contourNormal: vec3.Vec3[] = []; + for (let i = 0; i < contour.length; ++i) { + const normal = vec3.normalize(vec3.subtract(contour[i], center)); + contourNormal.push(normal); + } + + return contourNormal; + } +} diff --git a/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/PipeLayer.ts b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/PipeLayer.ts new file mode 100644 index 0000000000..20fa8d413a --- /dev/null +++ b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/PipeLayer.ts @@ -0,0 +1,288 @@ +import { + type GetPickingInfoParams, + Layer, + type LayerContext, + type LayerProps, + type Material, + type PickingInfo, + type UpdateParameters, + picking, + project32, +} from "@deck.gl/core"; +import { Geometry, Model } from "@luma.gl/engine"; +import { phongLighting } from "@luma.gl/shadertools"; +import { isEqual } from "lodash"; + +import * as vec3 from "@lib/utils/vec3"; +import type { Vec3 } from "@lib/utils/vec3"; + +import { Pipe } from "./Pipe"; +import fragmentShader from "./shaders/fragmentShader.glsl?raw"; +import { type PipeProps, pipeUniforms } from "./shaders/uniforms"; +import vertexShader from "./shaders/vertexShader.glsl?raw"; + +export type PipeLayerProps = { + id: string; + data: { + id: string; + centerLinePath: Vec3[]; + }[]; + color: [number, number, number, number]; + hoverColor: [number, number, number, number]; + selectedColor: [number, number, number, number]; + hoveredPipeIndex: number | null; + selectedPipeIndex: number | null; + material?: Material; +} & LayerProps; + +export class PipesLayer extends Layer { + static layerName: string = "PipeLayer"; + + // @ts-expect-error - This is how deck.gl expects the state to be defined + state!: { + models: Model[]; + hoveredPipeIndex: number | null; + }; + + initializeState(context: LayerContext): void { + this.setState({ + models: this.makeModels(context), + hoveredPipeIndex: null, + }); + } + + getPickingInfo(params: GetPickingInfoParams): PickingInfo { + const info = super.getPickingInfo(params); + + if (!info.color) { + this.setState({ hoveredPipeIndex: null }); + return info; + } + + const r = info.color[0]; + const g = info.color[1]; + const b = info.color[2]; + + const pipeIndex = r * 256 * 256 + g * 256 + b; + + this.setState({ hoveredPipeIndex: pipeIndex }); + + return { + ...info, + picked: true, + index: pipeIndex, + object: this.props.data[pipeIndex], + }; + } + + draw(): void { + const { models, hoveredPipeIndex } = this.state; + for (const [idx, model] of models.entries()) { + model.shaderInputs.setProps({ + pipe: { + isHovered: hoveredPipeIndex === idx, + }, + }); + model.draw(this.context.renderPass); + } + } + + shouldUpdateState({ changeFlags }: UpdateParameters): boolean { + return changeFlags.dataChanged !== false; + } + + updateState(params: UpdateParameters>): void { + super.updateState(params); + + if (!params.changeFlags.dataChanged) { + return; + } + + if (!isEqual(params.props.data, params.oldProps.data)) { + this.setState({ + models: this.makeModels(params.context), + }); + } + } + + makeModels(context: LayerContext): Model[] { + const pipes = this.makePipes(); + const meshes = this.makeMeshes(pipes); + + const models: Model[] = []; + for (const [idx, mesh] of meshes.entries()) { + const pipeProps: PipeProps = { + pipeIndex: idx, + isHovered: false, + }; + const model = new Model(context.device, { + id: `${this.id}-mesh-${idx}`, + geometry: mesh, + modules: [project32, phongLighting, picking, pipeUniforms], + vs: vertexShader, + fs: fragmentShader, + }); + model.shaderInputs.setProps({ pipe: pipeProps }); + + models.push(model); + } + + return models; + } + + private makePipes(): Pipe[] { + const { data } = this.props; + const circle = this.makeCircle(10, 16); + + const pipes: Pipe[] = []; + for (const pipeData of data) { + pipes.push(new Pipe(pipeData.centerLinePath, circle)); + } + + return pipes; + } + + private makeCircle(radius: number, segments: number): Vec3[] { + const points: Vec3[] = []; + if (segments < 2) { + return points; + } + + const pi2 = Math.PI * 2; + for (let i = 0; i < segments; i++) { + const angle = (i / segments) * pi2; + points.push({ x: Math.cos(angle) * radius, y: Math.sin(angle) * radius, z: 0 }); + } + + return points; + } + + private makeMeshes(pipes: Pipe[]): Geometry[] { + const { data } = this.props; + + const geometries: Geometry[] = []; + for (const [idx, pipe] of pipes.entries()) { + const numContours = pipe.getContourCount(); + const numVerticesPerContour = pipe.getContours()[0].length; + + const vertices = new Float32Array((numContours + 2) * numVerticesPerContour * 3 + 2 * 3); + const indices = new Uint32Array( + (numContours - 1) * numVerticesPerContour * 6 + numVerticesPerContour * 2 * 3, + ); + const normals = new Float32Array((numContours + 2) * numVerticesPerContour * 3 + 2 * 3); + + let verticesIndex: number = 0; + let indicesIndex: number = 0; + let normalsIndex: number = 0; + + const startVertex = pipe.getPath()[0]; + vertices[verticesIndex++] = startVertex.x; + vertices[verticesIndex++] = startVertex.y; + vertices[verticesIndex++] = startVertex.z; + + const normal = vec3.normalize(vec3.cross(pipe.getNormals()[0][1], pipe.getNormals()[0][0])); + normals[normalsIndex++] = normal.x; + normals[normalsIndex++] = normal.y; + normals[normalsIndex++] = normal.z; + + for (let j = 0; j < numVerticesPerContour; j++) { + const vertex = pipe.getContours()[0][j]; + vertices[verticesIndex++] = vertex.x; + vertices[verticesIndex++] = vertex.y; + vertices[verticesIndex++] = vertex.z; + + normals[normalsIndex++] = normal.x; + normals[normalsIndex++] = normal.y; + normals[normalsIndex++] = normal.z; + + indices[indicesIndex++] = 0; + indices[indicesIndex++] = j + 1; + indices[indicesIndex++] = j + 2 > numVerticesPerContour ? 1 : j + 2; + } + + for (let i = 0; i < numContours; i++) { + const contour = pipe.getContours()[i]; + for (let j = 0; j < numVerticesPerContour; j++) { + const vertex = contour[j]; + vertices[verticesIndex++] = vertex.x; + vertices[verticesIndex++] = vertex.y; + vertices[verticesIndex++] = vertex.z; + + const normal = pipe.getNormals()[i][j]; + normals[normalsIndex++] = normal.x; + normals[normalsIndex++] = normal.y; + normals[normalsIndex++] = normal.z; + + if (i > 0) { + const index = 1 + (i + 1) * numVerticesPerContour + j; + if (j === numVerticesPerContour - 1) { + indices[indicesIndex++] = index - numVerticesPerContour; + indices[indicesIndex++] = index; + indices[indicesIndex++] = index - 2 * numVerticesPerContour + 1; + indices[indicesIndex++] = index - 2 * numVerticesPerContour + 1; + indices[indicesIndex++] = index; + indices[indicesIndex++] = (i + 1) * numVerticesPerContour + 1; + } else { + indices[indicesIndex++] = index - numVerticesPerContour; + indices[indicesIndex++] = index; + indices[indicesIndex++] = index - numVerticesPerContour + 1; + indices[indicesIndex++] = index - numVerticesPerContour + 1; + indices[indicesIndex++] = index; + indices[indicesIndex++] = index + 1; + } + } + } + } + + const endNormal = vec3.normalize( + vec3.cross( + pipe.getNormals()[pipe.getNormals().length - 1][0], + pipe.getNormals()[pipe.getNormals().length - 1][1], + ), + ); + for (let j = 0; j < numVerticesPerContour; j++) { + const vertex = pipe.getContours()[numContours - 1][j]; + vertices[verticesIndex++] = vertex.x; + vertices[verticesIndex++] = vertex.y; + vertices[verticesIndex++] = vertex.z; + + normals[normalsIndex++] = endNormal.x; + normals[normalsIndex++] = endNormal.y; + normals[normalsIndex++] = endNormal.z; + } + + const endVertex = pipe.getPath()[pipe.getPath().length - 1]; + vertices[verticesIndex++] = endVertex.x; + vertices[verticesIndex++] = endVertex.y; + vertices[verticesIndex++] = endVertex.z; + + normals[normalsIndex++] = endNormal.x; + normals[normalsIndex++] = endNormal.y; + normals[normalsIndex++] = endNormal.z; + + for (let j = 0; j < numVerticesPerContour; j++) { + const index = numContours * numVerticesPerContour + 1; + indices[indicesIndex++] = verticesIndex / 3 - 1; + indices[indicesIndex++] = index + j; + indices[indicesIndex++] = j + 1 >= numVerticesPerContour ? index : index + j + 1; + } + + geometries.push( + new Geometry({ + topology: "triangle-list", + attributes: { + positions: vertices, + normals: { + size: 3, + value: normals, + }, + }, + indices: indices, + id: data[idx].id, + }), + ); + } + + return geometries; + } +} diff --git a/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/Plane.ts b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/Plane.ts new file mode 100644 index 0000000000..0a17da799e --- /dev/null +++ b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/Plane.ts @@ -0,0 +1,64 @@ +import * as vec3 from "@lib/utils/vec3"; + +import type { Line } from "./Line"; + +export type Plane = { + normal: vec3.Vec3; + point: vec3.Vec3; + d: number; // Coefficient of constant term: d = -(a*x0 + b*y0 + c*z0) + normalLength: number; + distanceToOrigin: number; +}; + +export function fromNormalAndPoint(normal: vec3.Vec3, point: vec3.Vec3): Plane { + const d = -vec3.dot(normal, point); + const normalLength = vec3.length(normal); + const distanceToOrigin = -d / normalLength; + return { normal, point, d, normalLength, distanceToOrigin }; +} + +export function getDistance(plane: Plane, point: vec3.Vec3): number { + const dot = vec3.dot(plane.normal, point); + return (dot + plane.d) / plane.normalLength; +} + +export function normalize(plane: Plane): Plane { + const inversedLength = 1.0 / plane.normalLength; + const normal = vec3.normalize(plane.normal); + const d = plane.d * inversedLength; + return { + normal, + point: plane.point, + d, + normalLength: 1.0, + distanceToOrigin: plane.distanceToOrigin * inversedLength, + }; +} + +export function intersectLine(plane: Plane, line: Line): vec3.Vec3 | null { + const dot1 = vec3.dot(plane.normal, line.point); + const dot2 = vec3.dot(plane.normal, line.direction); + + if (dot2 === 0) { + return null; + } + + const t = -(dot1 + plane.d) / dot2; + + return vec3.add(line.point, vec3.scale(line.direction, t)); +} + +export function intersectPlane(plane1: Plane, plane2: Plane): Line | null { + const v = vec3.cross(plane1.normal, plane2.normal); + + if (v.x === 0 && v.y === 0 && v.z === 0) { + return null; + } + + const dot = vec3.dot(v, v); + const n1 = vec3.scale(plane2.normal, plane1.d); + const n2 = vec3.scale(plane1.normal, -plane2.d); + const p = vec3.scale(vec3.cross(vec3.add(n1, n2), v), 1 / dot); + + return { point: p, direction: v }; +} diff --git a/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/shaders/fragmentShader.glsl b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/shaders/fragmentShader.glsl new file mode 100644 index 0000000000..52dae0fce2 --- /dev/null +++ b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/shaders/fragmentShader.glsl @@ -0,0 +1,58 @@ +#version 300 es +#define SHADER_NAME wells-layer-fragment-shader + +precision highp float; + +in vec3 cameraPosition; +in vec4 position_commonspace; +flat in vec3 normal; +in float pointingTowardsCamera; + +out vec4 fragColor; + +vec4 encodeVertexAndPipeIndexToRGB(float pipeIndex) { + int index = int(pipeIndex); + float r = 0.0f; + float g = 0.0f; + float b = 0.0f; + + if(index >= (256 * 256) - 1) { + r = floor(float(index) / (256.0f * 256.0f)); + index -= int(r * (256.0f * 256.0f)); + } + + if(index >= 256 - 1) { + g = floor(float(index) / 256.0f); + index -= int(g * 256.0f); + } + + b = float(index); + + return vec4(r / 255.0f, g / 255.0f, b / 255.0f, 1.0f); +} + +void main(void) { + if(picking.isActive > 0.5f && !(picking.isAttribute > 0.5f)) { + float pipeIndex = pipe.pipeIndex; + fragColor = encodeVertexAndPipeIndexToRGB(pipeIndex); + return; + } + + vec4 color = vec4(0.85f, 0.85f, 0.85f, 1.0f); + + if(pipe.isHovered) { + color = vec4(0.42f, 0.47f, 0.88f, 1.0f); + /* + This could be a nice effect, but it's not used in the current implementation + if(pointingTowardsCamera > 0.0f) { + color.a = 0.2f; + } + */ + } + + geometry.uv = vec2(0.f); + + DECKGL_FILTER_COLOR(color, geometry); + vec3 lightColor = lighting_getLightColor(color.rgb, cameraPosition, position_commonspace.xyz, normal); + fragColor = vec4(lightColor, color.a); +} \ No newline at end of file diff --git a/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/shaders/uniforms.ts b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/shaders/uniforms.ts new file mode 100644 index 0000000000..94333492a2 --- /dev/null +++ b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/shaders/uniforms.ts @@ -0,0 +1,23 @@ +import type { ShaderModule } from "@luma.gl/shadertools"; + +const uniformBlock = `\ +uniform pipeUniforms { + float pipeIndex; + bool isHovered; +} pipe; + `; + +export type PipeProps = { + pipeIndex?: number; + isHovered?: boolean; +}; + +export const pipeUniforms = { + name: "pipe", + vs: uniformBlock, + fs: uniformBlock, + uniformTypes: { + pipeIndex: "f32", + isHovered: "f32", + }, +} as const satisfies ShaderModule; diff --git a/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/shaders/vertexShader.glsl b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/shaders/vertexShader.glsl new file mode 100644 index 0000000000..8cc7cb0c1a --- /dev/null +++ b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/shaders/vertexShader.glsl @@ -0,0 +1,35 @@ +#version 300 es +#define SHADER_NAME wells-layer-vertex-shader + +precision highp float; + +in vec3 positions; +in vec3 normals; + +flat out vec3 normal; +out vec3 cameraPosition; +out vec4 position_commonspace; +out float pointingTowardsCamera; + +const vec3 pickingColor = vec3(1.0, 1.0, 0.0); + +void main(void) { + cameraPosition = project_uCameraPosition; + + normal = normals; + + vec3 cameraPositionToPosition = normalize(cameraPosition - positions); + pointingTowardsCamera = dot(cameraPositionToPosition, normals); + + position_commonspace = vec4(project_position(positions), 0.0f); + + geometry.position = position_commonspace; + geometry.pickingColor = pickingColor; + + gl_Position = project_common_position_to_clipspace(position_commonspace); + + DECKGL_FILTER_GL_POSITION(gl_Position, geometry); + + vec4 color = vec4(0.0f); + DECKGL_FILTER_COLOR(color, geometry); +} \ No newline at end of file diff --git a/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/wellTrajectoryUtils.ts b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/wellTrajectoryUtils.ts new file mode 100644 index 0000000000..dbe581e6d2 --- /dev/null +++ b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/_private/wellTrajectoryUtils.ts @@ -0,0 +1,110 @@ +import * as vec3 from "@lib/utils/vec3"; + +function squared_distance(a: vec3.Vec3, b: vec3.Vec3): number { + const dx = a.x - b.x; + const dy = a.y - b.y; + const dz = a.z - b.z; + return dx * dx + dy * dy + dz * dz; +} + +function distPointToSegmentSquared(segment: { v: vec3.Vec3; w: vec3.Vec3 }, point: vec3.Vec3): number { + const l2 = squared_distance(segment.v, segment.w); + if (l2 === 0) return squared_distance(point, segment.v); + let t = + ((point.x - segment.v.x) * (segment.w.x - segment.v.x) + + (point.y - segment.v.y) * (segment.w.y - segment.v.y) + + (point.z - segment.v.z) * (segment.w.z - segment.v.z)) / + l2; + t = Math.max(0, Math.min(1, t)); + return squared_distance(point, { + x: segment.v.x + t * (segment.w.x - segment.v.x), + y: segment.v.y + t * (segment.w.y - segment.v.y), + z: segment.v.z + t * (segment.w.z - segment.v.z), + }); +} + +function getSegmentIndex(coord: vec3.Vec3, path: vec3.Vec3[]): number { + let minD = Number.MAX_VALUE; + let segmentIndex = 0; + for (let i = 0; i < path.length - 1; i++) { + const d = distPointToSegmentSquared({ v: path[i], w: path[i + 1] }, coord); + if (d > minD) continue; + + segmentIndex = i; + minD = d; + } + return segmentIndex; +} + +function interpolateDataOnTrajectory(coord: vec3.Vec3, data: number[], trajectory: vec3.Vec3[]): number { + // if number of data points in less than 1 or + // length of data and trajectory are different we cannot interpolate. + if (data.length <= 1 || data.length != trajectory.length) return -1; + + // Identify closest well path leg to coord. + const segmentIndex = getSegmentIndex(coord, trajectory); + + const index0 = segmentIndex; + const index1 = index0 + 1; + + // Get the nearest data. + const data0 = data[index0]; + const data1 = data[index1]; + + // Get the nearest survey points. + const survey0 = trajectory[index0]; + const survey1 = trajectory[index1]; + + const dv = vec3.distance(survey0, survey1) as number; + if (dv === 0) { + return -1; + } + + // Calculate the scalar projection onto segment. + const v0 = vec3.subtract(coord, survey0); + const v1 = vec3.subtract(survey1, survey0); + + // scalar_projection in interval [0,1] + const scalar_projection: number = vec3.dot(v0, v1) / (dv * dv); + + // Interpolate data. + return data0 * (1.0 - scalar_projection) + data1 * scalar_projection; +} + +export function getMd(coord: vec3.Vec3, mdArray: number[], trajectory: vec3.Vec3[]): number | null { + return interpolateDataOnTrajectory(coord, mdArray, trajectory); +} + +export function getCoordinateForMd(md: number, mdArray: number[], trajectory: vec3.Vec3[]): vec3.Vec3 | null { + const numPoints = mdArray.length; + if (numPoints < 2) { + return null; + } + + let segmentIndex = 0; + for (let i = 0; i < numPoints - 1; i++) { + if (mdArray[i] <= md && md <= mdArray[i + 1]) { + segmentIndex = i; + break; + } + } + + const md0 = mdArray[segmentIndex]; + const md1 = mdArray[segmentIndex + 1]; + + const survey0 = trajectory[segmentIndex]; + const survey1 = trajectory[segmentIndex + 1]; + + const dv = vec3.distance(survey0, survey1) as number; + if (dv === 0) { + return null; + } + + const scalar_projection = (md - md0) / (md1 - md0); + + return { + x: survey0.x + scalar_projection * (survey1.x - survey0.x), + y: survey0.y + scalar_projection * (survey1.y - survey0.y), + z: survey0.z + scalar_projection * (survey1.z - survey0.z), + }; +} diff --git a/frontend/src/modules/_shared/types/deckgl.ts b/frontend/src/modules/_shared/types/deckgl.ts new file mode 100644 index 0000000000..a859a22a47 --- /dev/null +++ b/frontend/src/modules/_shared/types/deckgl.ts @@ -0,0 +1,12 @@ +import type { ViewportType, ViewsType } from "@webviz/subsurface-viewer"; + +import type { ColorScaleWithId } from "../components/ColorLegendsContainer/colorScaleWithId"; + +export interface ViewportTypeExtended extends ViewportType { + color: string | null; + colorScales: ColorScaleWithId[]; +} + +export interface ViewsTypeExtended extends ViewsType { + viewports: ViewportTypeExtended[]; +} diff --git a/frontend/src/modules/WellLogViewer/types.ts b/frontend/src/modules/_shared/types/wellLogTemplates.ts similarity index 100% rename from frontend/src/modules/WellLogViewer/types.ts rename to frontend/src/modules/_shared/types/wellLogTemplates.ts diff --git a/frontend/src/modules/_shared/types/wellpicks.ts b/frontend/src/modules/_shared/types/wellpicks.ts new file mode 100644 index 0000000000..d98429fc1c --- /dev/null +++ b/frontend/src/modules/_shared/types/wellpicks.ts @@ -0,0 +1,8 @@ +import type { WellborePick_api } from "@api"; + +export type WellPickDataCollection = { + picks: WellborePick_api[]; + // We currently don't use these fields anywhere, but I'm leaving them here so they're available in the future + stratColumn: string; + interpreter: string; +}; diff --git a/frontend/src/modules/_shared/utils/numberFormatting.ts b/frontend/src/modules/_shared/utils/numberFormatting.ts new file mode 100644 index 0000000000..5767000546 --- /dev/null +++ b/frontend/src/modules/_shared/utils/numberFormatting.ts @@ -0,0 +1,37 @@ +/** + * Formats a number to a string with a maximum number of decimal places. + * Uses suffixes (K, M, B, T) for large numbers, and exponential notation for very small ones. + * @param value The number to format. + * @param maxNumDecimalPlaces The maximum number of decimal places to include in the formatted string. + * @returns The formatted string representation of the number. + */ +export function formatNumber(value: number, maxNumDecimalPlaces: number = 3): string { + if (!isFinite(value)) return value.toString(); + if (value === 0) return "0"; + + const absValue = Math.abs(value); + + // Suffixes for large numbers + const suffixes: [number, string][] = [ + [1e12, "T"], + [1e9, "B"], + [1e6, "M"], + [1e3, "K"], + ]; + + for (const [threshold, suffix] of suffixes) { + if (absValue >= threshold && absValue >= 10000) { + const scaled = value / threshold; + return Number.isInteger(scaled) ? `${scaled}${suffix}` : `${scaled.toFixed(maxNumDecimalPlaces)}${suffix}`; + } + } + + // Exponential for small values too small to represent with fixed decimals + const fixed = value.toFixed(maxNumDecimalPlaces); + if (parseFloat(fixed) === 0 && absValue < 1) { + return value.toExponential(maxNumDecimalPlaces); + } + + // Omit decimals for integers + return Number.isInteger(value) ? value.toString() : fixed; +} diff --git a/frontend/src/modules/3DViewer/view/queries/queryDataTransforms.ts b/frontend/src/modules/_shared/utils/queryDataTransforms.ts similarity index 100% rename from frontend/src/modules/3DViewer/view/queries/queryDataTransforms.ts rename to frontend/src/modules/_shared/utils/queryDataTransforms.ts diff --git a/frontend/src/modules/WellLogViewer/utils/strings.ts b/frontend/src/modules/_shared/utils/wellLog.ts similarity index 97% rename from frontend/src/modules/WellLogViewer/utils/strings.ts rename to frontend/src/modules/_shared/utils/wellLog.ts index 801007a542..e4470de5ea 100644 --- a/frontend/src/modules/WellLogViewer/utils/strings.ts +++ b/frontend/src/modules/_shared/utils/wellLog.ts @@ -1,7 +1,7 @@ import type { WellboreLogCurveData_api, WellboreLogCurveHeader_api } from "@api"; import { WellLogCurveSourceEnum_api } from "@api"; -import type { TemplatePlot } from "../types"; +import type { TemplatePlot } from "../types/wellLogTemplates"; /** * Translates a well log curve data source to a more readable string diff --git a/frontend/src/modules/_shared/utils/wellbore.ts b/frontend/src/modules/_shared/utils/wellbore.ts index e177f8b76f..989c1c583d 100644 --- a/frontend/src/modules/_shared/utils/wellbore.ts +++ b/frontend/src/modules/_shared/utils/wellbore.ts @@ -1,7 +1,12 @@ +import type { Color } from "@deck.gl/core"; +import type { LineString, Point } from "geojson"; import simplify from "simplify-js"; +import type { WellboreTrajectory_api } from "@api"; import { point2Distance, vec2FromArray } from "@lib/utils/vec2"; +import type { GeoWellFeature } from "../DataProviderFramework/visualization/deckgl/makeDrilledWellTrajectoriesLayer"; + function normalizeVector(vector: number[]): number[] { const vectorLength = Math.sqrt(vector[0] ** 2 + vector[1] ** 2); return [vector[0] / vectorLength, vector[1] / vectorLength]; @@ -118,3 +123,54 @@ export function calcExtendedSimplifiedWellboreTrajectoryInXYPlane( actualSectionLengths, }; } + +export function zipCoords(x_arr: number[], y_arr: number[], z_arr: number[]): number[][] { + const coords: number[][] = []; + for (let i = 0; i < x_arr.length; i++) { + coords.push([x_arr[i], y_arr[i], z_arr[i]]); + } + + return coords; +} + +export function wellTrajectoryToGeojson( + wellTrajectory: WellboreTrajectory_api, + selectedWellboreUuid?: string, +): GeoWellFeature { + const wellHeadPoint: Point = { + type: "Point", + coordinates: [wellTrajectory.eastingArr[0], wellTrajectory.northingArr[0], wellTrajectory.tvdMslArr[0]], + }; + const trajectoryLineString: LineString = { + type: "LineString", + coordinates: zipCoords(wellTrajectory.eastingArr, wellTrajectory.northingArr, wellTrajectory.tvdMslArr), + }; + + let color = [150, 150, 150] as Color; + let lineWidth = 2; + let wellHeadSize = 1; + if (wellTrajectory.wellboreUuid === selectedWellboreUuid) { + color = [255, 0, 0]; + lineWidth = 5; + wellHeadSize = 10; + } + + const geometryCollection: GeoWellFeature = { + type: "Feature", + geometry: { + type: "GeometryCollection", + geometries: [wellHeadPoint, trajectoryLineString], + }, + properties: { + uuid: wellTrajectory.wellboreUuid, + uwi: wellTrajectory.uniqueWellboreIdentifier, + lineWidth, + wellHeadSize, + name: wellTrajectory.uniqueWellboreIdentifier, + color, + md: [wellTrajectory.mdArr], + }, + }; + + return geometryCollection; +} diff --git a/frontend/tests/unit/WellLogViewer/queryDataTransform.test.ts b/frontend/tests/unit/WellLogViewer/queryDataTransform.test.ts index 74ccfe7019..d81a752361 100644 --- a/frontend/tests/unit/WellLogViewer/queryDataTransform.test.ts +++ b/frontend/tests/unit/WellLogViewer/queryDataTransform.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it } from "vitest"; import type { WellboreLogCurveData_api, WellborePick_api, WellboreTrajectory_api } from "@api"; import { WellLogCurveSourceEnum_api } from "@api"; +import type { WellPickDataCollection } from "@modules/_shared/types/wellpicks"; import { MAIN_AXIS_CURVE, SECONDARY_AXIS_CURVE } from "@modules/WellLogViewer/constants"; -import type { WellPickDataCollection } from "@modules/WellLogViewer/DataProviderFramework/visualizations/wellpicks"; import { createLogViewerWellPicks, createWellLogSets } from "@modules/WellLogViewer/utils/queryDataTransform"; describe("QueryDataTransform", () => { diff --git a/frontend/tests/unit/WellLogViewer/strings.test.ts b/frontend/tests/unit/WellLogViewer/strings.test.ts index f66926f9ee..166138ab75 100644 --- a/frontend/tests/unit/WellLogViewer/strings.test.ts +++ b/frontend/tests/unit/WellLogViewer/strings.test.ts @@ -1,13 +1,8 @@ import { describe, expect, it } from "vitest"; import { WellLogCurveSourceEnum_api } from "@api"; -import type { TemplatePlot } from "@modules/WellLogViewer/types"; -import { - curveSourceToText, - getUniqueCurveNameForPlotConfig, - simplifyLogName, -} from "@modules/WellLogViewer/utils/strings"; - +import type { TemplatePlot } from "@modules/_shared/types/wellLogTemplates"; +import { curveSourceToText, getUniqueCurveNameForPlotConfig, simplifyLogName } from "@modules/_shared/utils/wellLog"; describe("curveSourceToText", () => { it("should return 'Geology' for SMDA_GEOLOGY", () => { diff --git a/frontend/tests/unit/colorMapFromColorScale.test.ts b/frontend/tests/unit/colorMapFromColorScale.test.ts index 5f951d014a..101eb95ca5 100644 --- a/frontend/tests/unit/colorMapFromColorScale.test.ts +++ b/frontend/tests/unit/colorMapFromColorScale.test.ts @@ -2,61 +2,74 @@ import type { Rgb } from "culori"; import { parse } from "culori"; import { describe, expect, test } from "vitest"; +import type { ColorScaleSpecification } from "@framework/components/ColorScaleSelector/colorScaleSelector"; import { ColorPalette } from "@lib/utils/ColorPalette"; import { ColorScale, ColorScaleGradientType, ColorScaleType } from "@lib/utils/ColorScale"; import { makeColorMapFunctionFromColorScale } from "@modules/_shared/DataProviderFramework/visualization/utils/colors"; - -const COLOR_SCALE = new ColorScale({ - colorPalette: new ColorPalette({ - id: "test", - name: "test", - colors: ["#000000", "#111111", "#222222", "#333333", "#444444"], +const COLOR_SCALE_SPEC: ColorScaleSpecification = { + colorScale: new ColorScale({ + colorPalette: new ColorPalette({ + id: "test", + name: "test", + colors: ["#000000", "#111111", "#222222", "#333333", "#444444"], + }), + gradientType: ColorScaleGradientType.Sequential, + type: ColorScaleType.Continuous, + steps: 5, + min: 0, + max: 100, }), - gradientType: ColorScaleGradientType.Sequential, - type: ColorScaleType.Continuous, - steps: 5, - min: 0, - max: 100, -}); + areBoundariesUserDefined: false, +}; describe("makeColorMapFunctionFromColorScale", () => { - test("Maps to expected colors when values are normalized", () => { - const colorMapFunc = makeColorMapFunctionFromColorScale(COLOR_SCALE, 0, 100, true); + test("Maps to expected colors when values are unnormalized (raw values)", () => { + const colorMapFunc = makeColorMapFunctionFromColorScale(COLOR_SCALE_SPEC, { + valueMin: 0, + valueMax: 100, + denormalize: false, // use raw values as-is + }); expect(colorMapFunc).toBeInstanceOf(Function); - if (!colorMapFunc) { - throw new Error("Color map function not created"); - } + if (!colorMapFunc) throw new Error("Color map function not created"); + + const expectedHexes = ["#000000", "#111111", "#222222", "#333333", "#444444"]; for (let i = 0; i < 5; i++) { - const normalizedValue = i / 4; - const expectedColorHex = `#${i}${i}${i}${i}${i}${i}`; - const expectedColorRgb = parse(expectedColorHex) as Rgb; - expect(colorMapFunc(normalizedValue)).toStrictEqual([ - (expectedColorRgb?.r ?? 0) * 255, - (expectedColorRgb?.g ?? 0) * 255, - (expectedColorRgb?.b ?? 0) * 255, + const value = i * 25; // 0, 25, 50, 75, 100 + const expectedColorRgb = parse(expectedHexes[i]) as Rgb; + + expect(colorMapFunc(value)).toStrictEqual([ + (expectedColorRgb.r ?? 0) * 255, + (expectedColorRgb.g ?? 0) * 255, + (expectedColorRgb.b ?? 0) * 255, + (expectedColorRgb.alpha ?? 1) * 255, ]); } }); - test("Maps to the expected colors when values are not normalized", () => { - const colorMapFunc = makeColorMapFunctionFromColorScale(COLOR_SCALE, 0, 100, false); + test("Maps to expected colors when values are normalized", () => { + const colorMapFunc = makeColorMapFunctionFromColorScale(COLOR_SCALE_SPEC, { + valueMin: 0, + valueMax: 100, + denormalize: true, // normalized inputs + }); expect(colorMapFunc).toBeInstanceOf(Function); - if (!colorMapFunc) { - throw new Error("Color map function not created"); - } + if (!colorMapFunc) throw new Error("Color map function not created"); + + const expectedHexes = ["#000000", "#111111", "#222222", "#333333", "#444444"]; for (let i = 0; i < 5; i++) { - const nonNormalizedValue = (i / 4) * 100; - const expectedColorHex = `#${i}${i}${i}${i}${i}${i}`; - const expectedColorRgb = parse(expectedColorHex) as Rgb; - expect(colorMapFunc(nonNormalizedValue)).toStrictEqual([ - (expectedColorRgb?.r ?? 0) * 255, - (expectedColorRgb?.g ?? 0) * 255, - (expectedColorRgb?.b ?? 0) * 255, + const normalizedValue = i / 4; // 0, 0.25, ..., 1 + const expectedColorRgb = parse(expectedHexes[i]) as Rgb; + + expect(colorMapFunc(normalizedValue)).toStrictEqual([ + (expectedColorRgb.r ?? 0) * 255, + (expectedColorRgb.g ?? 0) * 255, + (expectedColorRgb.b ?? 0) * 255, + (expectedColorRgb.alpha ?? 1) * 255, ]); } }); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 7cc5b00224..39083179ac 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -7,6 +7,7 @@ import jotaiReactRefresh from "jotai/babel/plugin-react-refresh"; import { defineConfig } from "vite"; import vitePluginChecker from "vite-plugin-checker"; import { nodePolyfills } from "vite-plugin-node-polyfills"; +import glsl from "vite-plugin-glsl"; import aliases from "./aliases.json"; @@ -17,15 +18,15 @@ const paths = { }; // https://vitejs.dev/config/ -export default defineConfig(({ mode }) => { +export default defineConfig(({ mode, command }) => { const define = { "process.env": {}, }; // In order to polyfill "global" for older packages // Only in dev since "@loaders.gl" is already exporting "window" and would cause a duplicate export - if (mode === "development") { - define["global"] = "window"; + if (mode === "development" && command === "serve") { + define["global"] = "globalThis"; } return { @@ -45,6 +46,10 @@ export default defineConfig(({ mode }) => { exclude: ["crypto"], globals: { Buffer: true }, }), + glsl({ + include: "**/*.glsl", + defaultExtension: "glsl", + }), ], build: { rollupOptions: {