From 9ac4cce63435b73d7a3b0c4d8a3081d963b30425 Mon Sep 17 00:00:00 2001 From: Christophe Winkler Date: Fri, 8 Dec 2023 09:06:50 +0100 Subject: [PATCH 1/8] Refactor Map component to remove numerous useEffect Main motivation is to fix wrong view state computed when first rendering is done with hidden flag. First reproduced by application with tabbed layout where all the tabs are rendered using hidden flag. This use case has been added to the storybook to demonstrate the issue, along with a 'triggerHome' control (in a previous PR). The current PR brings: - onResize() function to handle resizing - use a React reducer to handle Z scaling Add support for PageUp/PageDown and Shift modifier - use a React reducer to compute the global 3D bounding box from the reportBoundingBox callback of the layers - introduce a ViewController class to generate the DeckGL views and viewState This brings all the computations in one place/one step by providing state change allowing to fine-tune the computations - use bounding box from the 'cameraPosition' (if set as zoom field) instead of the data bounding box Fixes: - initial viewState is correct, even when rendered with 'hidden' flag set to true - triggerHome now respects current zScale In the end, the final implementation drops down to 6 React useEffect, from the 16 initial ones ! --- .../subsurface-viewer/src/components/Map.tsx | 1463 ++++++++++------- .../src/utils/BoundingBox3D.test.ts | 37 + .../src/utils/BoundingBox3D.ts | 53 + 3 files changed, 926 insertions(+), 627 deletions(-) create mode 100644 typescript/packages/subsurface-viewer/src/utils/BoundingBox3D.test.ts create mode 100644 typescript/packages/subsurface-viewer/src/utils/BoundingBox3D.ts diff --git a/typescript/packages/subsurface-viewer/src/components/Map.tsx b/typescript/packages/subsurface-viewer/src/components/Map.tsx index f26820071f..f12a123ee3 100644 --- a/typescript/packages/subsurface-viewer/src/components/Map.tsx +++ b/typescript/packages/subsurface-viewer/src/components/Map.tsx @@ -1,3 +1,15 @@ +import React, { useEffect, useState, useCallback, useMemo } from "react"; + +import type { Feature, FeatureCollection } from "geojson"; +import { cloneDeep, isEmpty } from "lodash"; + +import type { + MjolnirEvent, + MjolnirGestureEvent, + MjolnirKeyEvent, + MjolnirPointerEvent, +} from "mjolnir.js"; + import { JSONConfiguration, JSONConverter } from "@deck.gl/json/typed"; import type { DeckGLRef } from "@deck.gl/react/typed"; import DeckGL from "@deck.gl/react/typed"; @@ -11,47 +23,58 @@ import type { Viewport, PickingInfo, } from "@deck.gl/core/typed"; -import { OrthographicView, OrbitView, PointLight } from "@deck.gl/core/typed"; -import type { Feature, FeatureCollection } from "geojson"; -import React, { useEffect, useState, useCallback, useMemo } from "react"; +import { + _CameraLight as CameraLight, + AmbientLight, + DirectionalLight, + LightingEffect, + OrbitController, + OrbitView, + OrthographicController, + OrthographicView, + PointLight, +} from "@deck.gl/core/typed"; +import { LineLayer } from "@deck.gl/layers/typed"; + +import { Matrix4 } from "@math.gl/core"; +import { fovyToAltitude } from "@math.gl/web-mercator"; + +import { colorTables } from "@emerson-eps/color-tables"; +import type { colorTablesArray } from "@emerson-eps/color-tables/"; + +import type { BoundingBox3D } from "../utils/BoundingBox3D"; +import { boxCenter, boxUnion } from "../utils/BoundingBox3D"; import JSON_CONVERTER_CONFIG from "../utils/configuration"; import type { WellsPickInfo } from "../layers/wells/wellsLayer"; import InfoCard from "./InfoCard"; import DistanceScale from "./DistanceScale"; import StatusIndicator from "./StatusIndicator"; -import type { colorTablesArray } from "@emerson-eps/color-tables/"; import fitBounds from "../utils/fit-bounds"; import { validateColorTables, validateLayers } from "@webviz/wsc-common"; import type { LayerPickInfo } from "../layers/utils/layerTools"; -import { getLayersByType } from "../layers/utils/layerTools"; -import { getWellLayerByTypeAndSelectedWells } from "../layers/utils/layerTools"; +import { + getModelMatrixScale, + getLayersByType, + getWellLayerByTypeAndSelectedWells, +} from "../layers/utils/layerTools"; import { WellsLayer, Axes2DLayer, NorthArrow3DLayer } from "../layers"; -import { isEmpty, isEqual } from "lodash"; -import { cloneDeep } from "lodash"; - -import { colorTables } from "@emerson-eps/color-tables"; -import { getModelMatrixScale } from "../layers/utils/layerTools"; -import { OrbitController, OrthographicController } from "@deck.gl/core/typed"; -import type { MjolnirEvent, MjolnirPointerEvent } from "mjolnir.js"; import IntersectionView from "../views/intersectionView"; import type { Unit } from "convert-units"; import type { LightsType } from "../SubsurfaceViewer"; -import { - _CameraLight as CameraLight, - AmbientLight, - DirectionalLight, -} from "@deck.gl/core/typed"; -import { LightingEffect } from "@deck.gl/core/typed"; -import { LineLayer } from "@deck.gl/layers/typed"; -import { Matrix4 } from "@math.gl/core"; -import { fovyToAltitude } from "@math.gl/web-mercator"; -import type { MjolnirGestureEvent } from "mjolnir.js"; /** * 3D bounding box defined as [xmin, ymin, zmin, xmax, ymax, zmax]. */ -export type BoundingBox3D = [number, number, number, number, number, number]; +export type { BoundingBox3D }; +/** + * 2D bounding box defined as [left, bottom, right, top] + */ +export type BoundingBox2D = [number, number, number, number]; +/** + * Type of the function returning coordinate boundary for the view defined as [left, bottom, right, top]. + */ +export type BoundsAccessor = () => BoundingBox2D; type Size = { width: number; @@ -63,45 +86,78 @@ const maxZoom3D = 12; const minZoom2D = -12; const maxZoom2D = 4; -class ZScaleOrbitController extends OrbitController { - static setZScaleUp: React.Dispatch> | null = - null; - static setZScaleDown: React.Dispatch> | null = - null; +// https://developer.mozilla.org/docs/Web/API/KeyboardEvent +type ArrowEvent = { + key: "ArrowUp" | "ArrowDown" | "PageUp" | "PageDown"; + shiftModifier: boolean; + // altModifier: boolean; + // ctrlModifier: boolean; +}; - static setZScaleUpReference( - setZScaleUp: React.Dispatch> - ) { - ZScaleOrbitController.setZScaleUp = setZScaleUp; +function updateZScaleReducer(zScale: number, action: ArrowEvent): number { + return zScale * getZScaleModifier(action); +} + +function getZScaleModifier(arrowEvent: ArrowEvent): number { + let scaleFactor = 0; + switch (arrowEvent.key) { + case "ArrowUp": + scaleFactor = 0.05; + break; + case "ArrowDown": + scaleFactor = -0.05; + break; + case "PageUp": + scaleFactor = 0.25; + break; + case "PageDown": + scaleFactor = -0.25; + break; + default: + break; } + if (arrowEvent.shiftModifier) { + scaleFactor /= 5; + } + return 1 + scaleFactor; +} + +function convertToArrowEvent(event: MjolnirEvent): ArrowEvent | null { + if (event.type === "keydown") { + const keyEvent = event as MjolnirKeyEvent; + switch (keyEvent.key) { + case "ArrowUp": + case "ArrowDown": + case "PageUp": + case "PageDown": + return { + key: keyEvent.key, + shiftModifier: keyEvent.srcEvent.shiftKey, + }; + default: + return null; + } + } + return null; +} + +class ZScaleOrbitController extends OrbitController { + static updateZScaleAction: React.Dispatch | null = null; - static setZScaleDownReference( - setZScaleDown: React.Dispatch> + static setUpdateZScaleAction( + updateZScaleAction: React.Dispatch ) { - ZScaleOrbitController.setZScaleDown = setZScaleDown; + ZScaleOrbitController.updateZScaleAction = updateZScaleAction; } handleEvent(event: MjolnirEvent): boolean { - if (ZScaleOrbitController.setZScaleUp === null) { - return super.handleEvent(event); - } - - if ( - ZScaleOrbitController.setZScaleUp && - event.type === "keydown" && - event.key === "ArrowUp" - ) { - ZScaleOrbitController.setZScaleUp(Math.random()); - return true; - } else if ( - ZScaleOrbitController.setZScaleDown && - event.type === "keydown" && - event.key === "ArrowDown" - ) { - ZScaleOrbitController.setZScaleDown(Math.random()); - return true; + if (ZScaleOrbitController.updateZScaleAction) { + const arrowEvent = convertToArrowEvent(event); + if (arrowEvent) { + ZScaleOrbitController.updateZScaleAction(arrowEvent); + return true; + } } - return super.handleEvent(event); } } @@ -113,7 +169,7 @@ class ZScaleOrbitView extends OrbitView { } function parseLights(lights?: LightsType): LightingEffect[] | undefined { - if (typeof lights === "undefined") { + if (!lights) { return undefined; } @@ -136,7 +192,7 @@ function parseLights(lights?: LightsType): LightingEffect[] | undefined { lightsObj = { ...lightsObj, ambientLight }; } - if (typeof lights.pointLights !== "undefined") { + if (lights.pointLights) { for (const light of lights.pointLights) { const pointLight = new PointLight({ ...light, @@ -146,7 +202,7 @@ function parseLights(lights?: LightsType): LightingEffect[] | undefined { } } - if (typeof lights.directionalLights !== "undefined") { + if (lights.directionalLights) { for (const light of lights.directionalLights) { const directionalLight = new DirectionalLight({ ...light, @@ -162,53 +218,20 @@ function parseLights(lights?: LightsType): LightingEffect[] | undefined { return effects; } -function addBoundingBoxes(b1: BoundingBox3D, b2: BoundingBox3D): BoundingBox3D { - const boxDefault: BoundingBox3D = [0, 0, 0, 1, 1, 1]; - - if (typeof b1 === "undefined" || typeof b2 === "undefined") { - return boxDefault; - } - - if (isEqual(b1, boxDefault)) { - return b2; - } - - const xmin = Math.min(b1[0], b2[0]); - const ymin = Math.min(b1[1], b2[1]); - const zmin = Math.min(b1[2], b2[2]); - - const xmax = Math.max(b1[3], b2[3]); - const ymax = Math.max(b1[4], b2[4]); - const zmax = Math.max(b1[5], b2[5]); - return [xmin, ymin, zmin, xmax, ymax, zmax]; -} - export type ReportBoundingBoxAction = { layerBoundingBox: BoundingBox3D }; function mapBoundingBoxReducer( - mapBoundingBox: BoundingBox3D, + mapBoundingBox: BoundingBox3D | undefined, action: ReportBoundingBoxAction -): BoundingBox3D { - return addBoundingBoxes(mapBoundingBox, action.layerBoundingBox); +): BoundingBox3D | undefined { + return boxUnion(mapBoundingBox, action.layerBoundingBox); } -function boundingBoxCenter(box: BoundingBox3D): [number, number, number] { - const xmin = box[0]; - const ymin = box[1]; - const zmin = box[2]; - - const xmax = box[3]; - const ymax = box[4]; - const zmax = box[5]; - return [ - xmin + 0.5 * (xmax - xmin), - ymin + 0.5 * (ymax - ymin), - zmin + 0.5 * (zmax - zmin), - ]; -} // Exclude "layerIds" when monitoring changes to "view" prop as we do not // want to recalculate views when the layers change. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error function compareViewsProp(views: ViewsType | undefined): string | undefined { - if (typeof views === "undefined" || Object.keys(views).length === 0) { + if (views === undefined) { return undefined; } @@ -221,11 +244,6 @@ function compareViewsProp(views: ViewsType | undefined): string | undefined { return JSON.stringify(copy); } -/** - * Type of the function returning coordinate boundary for the view defined as [left, bottom, right, top]. - */ -export type BoundsAccessor = () => [number, number, number, number]; - export type TooltipCallback = ( info: PickingInfo ) => string | Record | null; @@ -293,7 +311,7 @@ export interface ViewportType { */ export interface ViewStateType { target: number[]; - zoom: number | BoundingBox3D; + zoom: number | BoundingBox3D | undefined; rotationX: number; rotationOrbit: number; minZoom?: number; @@ -315,8 +333,29 @@ export interface DeckGLLayerContext extends LayerContext { }; } +export interface MapMouseEvent { + type: "click" | "hover" | "contextmenu"; + infos: PickingInfo[]; + // some frequently used values extracted from infos[]: + x?: number; + y?: number; + // Only for one well. Full information is available in infos[] + wellname?: string; + wellcolor?: Color; // well color + md?: number; + tvd?: number; +} + export type EventCallback = (event: MapMouseEvent) => void; +export function useHoverInfo(): [PickingInfo[], EventCallback] { + const [hoverInfo, setHoverInfo] = useState([]); + const callback = useCallback((pickEvent: MapMouseEvent) => { + setHoverInfo(pickEvent.infos); + }, []); + return [hoverInfo, callback]; +} + export interface MapProps { /** * The ID of this component, used to identify dash components @@ -344,10 +383,10 @@ export interface MapProps { * Coordinate boundary for the view defined as [left, bottom, right, top]. * Should be used for 2D view only. */ - bounds?: [number, number, number, number] | BoundsAccessor; + bounds?: BoundingBox2D | BoundsAccessor; /** - * Camera state for the view defined as [left, bottom, right, top]. + * Camera state for the view defined as a ViewStateType. * Should be used for 3D view only. * If the zoom is given as a 3D bounding box, the camera state is computed to * display the full box. @@ -441,27 +480,6 @@ export interface MapProps { getTooltip?: TooltipCallback; } -export interface MapMouseEvent { - type: "click" | "hover" | "contextmenu"; - infos: PickingInfo[]; - // some frequently used values extracted from infos[]: - x?: number; - y?: number; - // Only for one well. Full information is available in infos[] - wellname?: string; - wellcolor?: Color; // well color - md?: number; - tvd?: number; -} - -export function useHoverInfo(): [PickingInfo[], EventCallback] { - const [hoverInfo, setHoverInfo] = useState([]); - const callback = useCallback((pickEvent: MapMouseEvent) => { - setHoverInfo(pickEvent.infos); - }, []); - return [hoverInfo, callback]; -} - function defaultTooltip(info: PickingInfo) { if ((info as WellsPickInfo)?.logName) { return (info as WellsPickInfo)?.logName; @@ -472,114 +490,6 @@ function defaultTooltip(info: PickingInfo) { return feat?.properties?.["name"]; } -function adjustCameraTarget( - viewStates: Record, - scale: number, - newScale: number -): Record { - const vs = cloneDeep(viewStates); - for (const key in vs) { - if (typeof vs[key].target !== "undefined") { - const t = vs[key].target; - const z = newScale * (t[2] / scale); - vs[key].target = [t[0], t[1], z]; - } - } - return vs; -} - -function calculateZoomFromBBox3D( - camera: ViewStateType | undefined, - size: Size -): ViewStateType | undefined { - const DEGREES_TO_RADIANS = Math.PI / 180; - const RADIANS_TO_DEGREES = 180 / Math.PI; - const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; - - const camera_ = cloneDeep(camera); - - if (typeof camera_ === "undefined" || !Array.isArray(camera_.zoom)) { - return camera; - } - - if (size.width === 0 || size.height === 0) { - camera_.zoom = 0; - camera_.target = [0, 0, 0]; - return camera_; - } - - // camera fov eye position. see deck.gl file orbit-viewports.ts - const fovy = 50; // default in deck.gl. May also be set construction OrbitView - const fD = fovyToAltitude(fovy); - - const bbox = camera_.zoom; - - const xMin = bbox[0]; - const yMin = bbox[1]; - const zMin = bbox[2]; - - const xMax = bbox[3]; - const yMax = bbox[4]; - const zMax = bbox[5]; - - const target = [ - xMin + (xMax - xMin) / 2, - yMin + (yMax - yMin) / 2, - zMin + (zMax - zMin) / 2, - ]; - - const cameraFovVertical = 50; - const angle_ver = (cameraFovVertical / 2) * DEGREES_TO_RADIANS; - const L = size.height / 2 / Math.sin(angle_ver); - const r = L * Math.cos(angle_ver); - const cameraFov = 2 * Math.atan(size.width / 2 / r) * RADIANS_TO_DEGREES; - const angle_hor = (cameraFov / 2) * DEGREES_TO_RADIANS; - - const points: [number, number, number][] = []; - points.push([xMin, yMin, zMin]); - points.push([xMin, yMax, zMin]); - points.push([xMax, yMax, zMin]); - points.push([xMax, yMin, zMin]); - points.push([xMin, yMin, zMax]); - points.push([xMin, yMax, zMax]); - points.push([xMax, yMax, zMax]); - points.push([xMax, yMin, zMax]); - - let zoom = 999; - for (const point of points) { - const x_ = (point[0] - target[0]) / size.height; - const y_ = (point[1] - target[1]) / size.height; - const z_ = (point[2] - target[2]) / size.height; - - const m = new Matrix4(IDENTITY); - m.rotateX(camera_.rotationX * DEGREES_TO_RADIANS); - m.rotateZ(camera_.rotationOrbit * DEGREES_TO_RADIANS); - - const [x, y, z] = m.transformAsVector([x_, y_, z_]); - if (y >= 0) { - // These points will actually appear further away when zooming in. - continue; - } - - const fwX = fD * Math.tan(angle_hor); - let y_new = fwX / (Math.abs(x) / y - fwX / fD); - const zoom_x = Math.log2(y_new / y); - - const fwY = fD * Math.tan(angle_ver); - y_new = fwY / (Math.abs(z) / y - fwY / fD); - const zoom_z = Math.log2(y_new / y); - - // it needs to be inside view volume in both directions. - zoom = zoom_x < zoom ? zoom_x : zoom; - zoom = zoom_z < zoom ? zoom_z : zoom; - } - - camera_.zoom = zoom; - camera_.target = target; - - return camera_; -} - const Map: React.FC = ({ id, layers, @@ -604,147 +514,72 @@ const Map: React.FC = ({ lights, triggerResetMultipleWells, }: MapProps) => { + // From react doc, ref should not be read nor modified during rendering. const deckRef = React.useRef(null); - // From react doc, ref should not be read nor modified during rendering. - // Extract the needed size in an effect to respect this rule (which proved true) + const [applyViewController, forceUpdate] = React.useReducer( + (x) => x + 1, + 0 + ); + const [viewController, _] = useState(() => new ViewController(forceUpdate)); + + // Extract the needed size from onResize function const [deckSize, setDeckSize] = useState({ width: 0, height: 0 }); - useEffect(() => { + const onResize = useCallback((size: { width: number; height: number }) => { + // exclude {0, 0} size (when rendered hidden pages) if ( - deckRef.current?.deck?.width && - deckRef.current?.deck?.height && - deckRef.current.deck.width !== deckSize.width && - deckRef.current.deck.height !== deckSize.height + size.width > 0 && + size.height > 0 && + (size.width !== deckSize.width || size.height !== deckSize.height) ) { - setDeckSize({ - width: deckRef.current.deck.width, - height: deckRef.current.deck.height, - }); + setDeckSize(size); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [deckRef.current?.deck?.width, deckRef.current?.deck?.height]); - - // Deck.gl View's and viewStates as input to Deck.gl - const [deckGLViews, setDeckGLViews] = useState([]); - const [viewStates, setViewStates] = useState>( - {} - ); + }, []); + // 3d bounding box computed from the layers const [dataBoundingBox3d, dispatchBoundingBox] = React.useReducer( mapBoundingBoxReducer, - [0, 0, 0, 1, 1, 1] + undefined ); - const [viewStateChanged, setViewStateChanged] = useState(false); - - const camera = useMemo(() => { - return calculateZoomFromBBox3D(cameraPosition, deckSize); - }, [cameraPosition, deckSize]); - // Used for scaling in z direction using arrow keys. - const [scaleZ, setScaleZ] = useState(1); - const [scaleZUp, setScaleZUp] = useState(Number.MAX_VALUE); - const [scaleZDown, setScaleZDown] = useState(Number.MAX_VALUE); - + const [zScale, updateZScale] = React.useReducer(updateZScaleReducer, 1); React.useEffect(() => { - ZScaleOrbitController.setZScaleUpReference(setScaleZUp); - }, [setScaleZUp]); - - React.useEffect(() => { - ZScaleOrbitController.setZScaleDownReference(setScaleZDown); - }, [setScaleZDown]); - - useEffect(() => { - const [Views, viewStates] = createViewsAndViewStates( - views, - viewPortMargins, - bounds, - undefined, // Use bounds not cameraPosition, - dataBoundingBox3d, - deckSize - ); + ZScaleOrbitController.setUpdateZScaleAction(updateZScale); + }, [updateZScale]); - setDeckGLViews(Views); - setViewStates(viewStates); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [triggerHome]); - - useEffect(() => { - const isBoundsDefined = typeof bounds !== "undefined"; - const isCameraPositionDefined = - typeof cameraPosition !== "undefined" && - Object.keys(cameraPosition).length !== 0; - - if (viewStateChanged || isBoundsDefined || isCameraPositionDefined) { - // User has changed viewState or camera is defined, do not recalculate. - return; + // compute the viewport margins + const viewPortMargins = React.useMemo(() => { + if (!layers?.length) { + return { + left: 0, + right: 0, + top: 0, + bottom: 0, + }; } - const [Views, viewStates] = createViewsAndViewStates( - views, - viewPortMargins, - bounds, - camera, - dataBoundingBox3d, - deckSize - ); + // Margins on the viewport are extracted from a potential axes2D layer. + const axes2DLayer = layers?.find((e) => { + return e?.constructor === Axes2DLayer; + }) as Axes2DLayer; - setDeckGLViews(Views); - setViewStates(viewStates); - setViewStateChanged(false); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dataBoundingBox3d]); + const axes2DProps = axes2DLayer?.props; + return { + left: axes2DProps?.isLeftRuler ? axes2DProps.marginH : 0, + right: axes2DProps?.isRightRuler ? axes2DProps.marginH : 0, + top: axes2DProps?.isTopRuler ? axes2DProps.marginV : 0, + bottom: axes2DProps?.isBottomRuler ? axes2DProps.marginV : 0, + }; + }, [layers]); + // selection useEffect(() => { - const [Views, viewStates] = createViewsAndViewStates( - views, - viewPortMargins, - bounds, - camera, - dataBoundingBox3d, - deckSize - ); - - setDeckGLViews(Views); - setViewStates(viewStates); - setViewStateChanged(false); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - bounds, - camera, - deckSize, - // eslint-disable-next-line react-hooks/exhaustive-deps - compareViewsProp(views), - ]); - - useEffect(() => { - if (scaleZUp !== Number.MAX_VALUE) { - const newScaleZ = scaleZ * 1.05; - setScaleZ(newScaleZ); - // Make camera target follow the scaling. - const vs = adjustCameraTarget(viewStates, scaleZ, newScaleZ); - setViewStates(vs); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [scaleZUp]); - - useEffect(() => { - if (scaleZUp !== Number.MAX_VALUE) { - const newScaleZ = scaleZ * 0.95; - setScaleZ(newScaleZ); - // Make camera target follow the scaling. - const vs = adjustCameraTarget(viewStates, scaleZ, newScaleZ); - setViewStates(vs); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [scaleZDown]); - - useEffect(() => { - const layers = deckRef.current?.deck?.props.layers; - if (layers) { - const wellslayer = getLayersByType( - layers, - WellsLayer.name - )?.[0] as WellsLayer; + const layers = deckRef.current?.deck?.props.layers; + if (layers) { + const wellslayer = getLayersByType( + layers, + WellsLayer.name + )?.[0] as WellsLayer; wellslayer?.setSelection(selection?.well, selection?.selection); } @@ -857,18 +692,11 @@ const Map: React.FC = ({ event.tapCount == 2 // Note. Detect double click. ) { // Left button click identifies new camera rotation anchor. - const viewstateKeys = Object.keys(viewStates); - if (infos.length >= 1 && viewstateKeys.length === 1) { - const info = infos[0]; - if (info.coordinate) { - const x = info.coordinate[0]; - const y = info.coordinate[1]; - const z = info.coordinate[2]; - - const vs = cloneDeep(viewStates); - vs[viewstateKeys[0]].target = [x, y, z]; - vs[viewstateKeys[0]].transitionDuration = 1000; - setViewStates(vs); + if (infos.length >= 1) { + if (infos[0].coordinate) { + viewController.setTarget( + infos[0].coordinate as [number, number, number] + ); } } } @@ -877,7 +705,7 @@ const Map: React.FC = ({ const ev = handleMouseEvent(type, infos, event); onMouseEvent(ev); }, - [onMouseEvent, viewStates] + [onMouseEvent, viewController] ); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -899,32 +727,8 @@ const Map: React.FC = ({ [callOnMouseEvent, getPickingInfos] ); - // compute the viewport margins - const viewPortMargins = React.useMemo(() => { - if (typeof layers === "undefined") { - return { - left: 0, - right: 0, - top: 0, - bottom: 0, - }; - } - // Margins on the viewport are extracted from a potential axes2D layer. - const axes2DLayer = layers?.find((e) => { - return e?.constructor === Axes2DLayer; - }) as Axes2DLayer; - - const axes2DProps = axes2DLayer?.props; - return { - left: axes2DProps?.isLeftRuler ? axes2DProps.marginH : 0, - right: axes2DProps?.isRightRuler ? axes2DProps.marginH : 0, - top: axes2DProps?.isTopRuler ? axes2DProps.marginV : 0, - bottom: axes2DProps?.isBottomRuler ? axes2DProps.marginV : 0, - }; - }, [layers]); - const deckGLLayers = React.useMemo(() => { - if (typeof layers === "undefined") { + if (!layers) { return []; } if (layers.length === 0) { @@ -937,7 +741,7 @@ const Map: React.FC = ({ layers.push(dummy_layer); } - const m = getModelMatrixScale(scaleZ); + const m = getModelMatrixScale(zScale); return layers.map((item) => { if (item?.constructor.name === NorthArrow3DLayer.name) { @@ -953,7 +757,7 @@ const Map: React.FC = ({ modelMatrix: m, }); }); - }, [layers, scaleZ]); + }, [layers, zScale]); const [isLoaded, setIsLoaded] = useState(false); const onAfterRender = useCallback(() => { @@ -970,7 +774,7 @@ const Map: React.FC = ({ "webviz_internal_dummy_layer"; setIsLoaded(loadedState || emptyLayers); - if (typeof isRenderedCallback !== "undefined") { + if (isRenderedCallback) { isRenderedCallback(loadedState); } } @@ -999,7 +803,9 @@ const Map: React.FC = ({ const layerFilter = useCallback( (args: { layer: Layer; viewport: Viewport }): boolean => { // display all the layers if views are not specified correctly - if (!views || !views.viewports || !views.layout) return true; + if (!views?.viewports || !views?.layout) { + return true; + } const cur_view = views.viewports.find( ({ id }) => args.viewport.id && id === args.viewport.id @@ -1020,47 +826,48 @@ const Map: React.FC = ({ const onViewStateChange = useCallback( // eslint-disable-next-line @typescript-eslint/no-explicit-any ({ viewId, viewState }: { viewId: string; viewState: any }) => { - const viewports = views?.viewports || []; - if (viewState.target.length === 2) { - // In orthographic mode viewState.target contains only x and y. Add existing z value. - viewState.target.push(viewStates[viewId].target[2]); - } - const isSyncIds = viewports - .filter((item) => item.isSync) - .map((item) => item.id); - if (isSyncIds?.includes(viewId)) { - const viewStateTable = views?.viewports - .filter((item) => item.isSync) - .map((item) => [item.id, viewState]); - const tempViewStates = Object.fromEntries(viewStateTable ?? []); - setViewStates((currentViewStates) => ({ - ...currentViewStates, - ...tempViewStates, - })); - } else { - setViewStates((currentViewStates) => ({ - ...currentViewStates, - [viewId]: viewState, - })); - } + viewController.onViewStateChange(viewId, viewState); if (getCameraPosition) { getCameraPosition(viewState); } - setViewStateChanged(true); }, - [getCameraPosition, viewStates, views?.viewports] + [getCameraPosition, viewController] ); const effects = parseLights(lights); - if (!deckGLViews || isEmpty(deckGLViews) || isEmpty(deckGLLayers)) + const [deckGlViews, deckGlViewState] = useMemo(() => { + const state = { + triggerHome, + camera: cameraPosition, + bounds, + boundingBox3d: dataBoundingBox3d, + viewPortMargins, + deckSize, + zScale, + }; + return viewController.getViews(views, state); + }, [ + triggerHome, + cameraPosition, + bounds, + dataBoundingBox3d, + viewPortMargins, + deckSize, + views, + zScale, + applyViewController, + viewController, + ]); + + if (!deckGlViews || isEmpty(deckGlViews) || isEmpty(deckGLLayers)) return null; return (
event.preventDefault()}> = ({ effects={effects} onDragStart={onDragStart} onDragEnd={onDragEnd} + onResize={onResize} > {children} @@ -1116,7 +924,7 @@ const Map: React.FC = ({ Object.keys(value).length !== 0 - ); + const filtered_data = data.filter((value) => !isEmpty(value)); return jsonConverter.convert(filtered_data); } +/////////////////////////////////////////////////////////////////////////////////////////// +// View Controller +// Implements the algorithms to compute the views and the view state +type ViewControllerState = { + // Explicit state + triggerHome: number | undefined; + camera: ViewStateType | undefined; + bounds: BoundingBox2D | BoundsAccessor | undefined; + boundingBox3d: BoundingBox3D | undefined; + deckSize: Size; + zScale: number; + viewPortMargins: MarginsType; +}; +type ViewControllerDerivedState = { + // Derived state + target: [number, number, number] | undefined; + viewStateChanged: boolean; +}; +type ViewControllerFullState = ViewControllerState & ViewControllerDerivedState; +class ViewController { + private rerender_: React.DispatchWithoutAction; + + private state_: ViewControllerFullState = { + triggerHome: undefined, + camera: undefined, + bounds: undefined, + boundingBox3d: undefined, + deckSize: { width: 0, height: 0 }, + zScale: 1, + viewPortMargins: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + // Derived state + target: undefined, + viewStateChanged: false, + }; + + private derivedState_: ViewControllerDerivedState = { + target: undefined, + viewStateChanged: false, + }; + + private views_: ViewsType | undefined = undefined; + private result_: { + views: View[]; + viewState: Record; + } = { + views: [], + viewState: {}, + }; + + public constructor(rerender: React.DispatchWithoutAction) { + this.rerender_ = rerender; + } + + public readonly setTarget = (target: [number, number, number]) => { + this.derivedState_.target = [target[0], target[1], target[2]]; + this.rerender_(); + }; + + public readonly getViews = ( + views: ViewsType | undefined, + state: ViewControllerState + ): [View[], Record] => { + const fullState = this.consolidateState(state); + const newViews = this.getDeckGlViews(views, fullState); + const newViewState = this.getDeckGlViewState(views, fullState); + + if ( + this.result_.views !== newViews || + this.result_.viewState !== newViewState + ) { + const viewsMsg = this.result_.views !== newViews ? " views" : ""; + const stateMsg = + this.result_.viewState !== newViewState ? " state" : ""; + const linkMsg = viewsMsg && stateMsg ? " and" : ""; + + console.log( + `ViewController returns new${viewsMsg}${linkMsg}${stateMsg}` + ); + } + + this.state_ = fullState; + this.views_ = views; + this.result_.views = newViews; + this.result_.viewState = newViewState; + return [newViews, newViewState]; + }; + + // consolidate "controlled" state (ie. set by parent) with "uncontrolled" state + private readonly consolidateState = ( + state: ViewControllerState + ): ViewControllerFullState => { + return { ...state, ...this.derivedState_ }; + }; + + // returns the DeckGL views (ie. view position and viewport) + private readonly getDeckGlViews = ( + views: ViewsType | undefined, + state: ViewControllerFullState + ) => { + const needUpdate = + views != this.views_ || state.deckSize != this.state_.deckSize; + if (!needUpdate) { + return this.result_.views; + } + return buildDeckGlViews(views, state.deckSize); + }; + + // returns the DeckGL views state(s) (ie. camera settings applied to individual views) + private readonly getDeckGlViewState = ( + views: ViewsType | undefined, + state: ViewControllerFullState + ): Record => { + const viewsChanged = views != this.views_; + const triggerHome = state.triggerHome !== this.state_.triggerHome; + const updateTarget = + (viewsChanged || state.target !== this.state_?.target) && + state.target !== undefined; + const updateZScale = + viewsChanged || state.zScale !== this.state_?.zScale || triggerHome; + const updateViewState = + viewsChanged || + triggerHome || + (!state.viewStateChanged && + state.boundingBox3d !== this.state_.boundingBox3d); + const needUpdate = updateZScale || updateTarget || updateViewState; + + const isCacheEmpty = isEmpty(this.result_.viewState); + if (!isCacheEmpty && !needUpdate) { + return this.result_.viewState; + } + + let viewStateCloned = false; + + // initialize with last result + let viewState = this.result_.viewState; + + if (updateViewState || isCacheEmpty) { + viewState = buildDeckGlViewStates( + views, + state.viewPortMargins, + state.camera, + state.boundingBox3d, + state.bounds, + state.deckSize + ); + viewStateCloned = true; + // reset state + this.derivedState_.viewStateChanged = false; + } + + // check if view state could be computed + if (isEmpty(viewState)) { + return viewState; + } + + const viewStateKeys = Object.keys(viewState); + if ( + updateTarget && + this.derivedState_.target && + viewStateKeys?.length === 1 + ) { + // force copy + if (!viewStateCloned) { + viewState = cloneDeep(viewState); + viewStateCloned = true; + } + // update target + viewState[viewStateKeys[0]].target = this.derivedState_.target; + viewState[viewStateKeys[0]].transitionDuration = 1000; + // reset + this.derivedState_.target = undefined; + } + if (updateZScale) { + // force copy + if (!viewStateCloned) { + viewState = cloneDeep(viewState); + viewStateCloned = true; + } + // Z scale to apply to target. + // - if triggerHome: the target was recomputed from the input data (ie. without any scale applied) + // - otherwise: previous scale (ie. this.state_.zScale) was already applied, and must be "reverted" + const targetScale = + state.zScale / (triggerHome ? 1 : this.state_.zScale); + // update target + for (const key in viewState) { + const t = viewState[key].target; + if (t) { + viewState[key].target = [t[0], t[1], t[2] * targetScale]; + } + } + } + return viewState; + }; + + public readonly onViewStateChange = ( + viewId: string, + viewState: ViewStateType + ): void => { + const viewports = this.views_?.viewports ?? []; + if (viewState.target.length === 2) { + // In orthographic mode viewState.target contains only x and y. Add existing z value. + viewState.target.push(this.result_.viewState[viewId].target[2]); + } + const isSyncIds = viewports + .filter((item) => item.isSync) + .map((item) => item.id); + if (isSyncIds?.includes(viewId)) { + const viewStateTable = this.views_?.viewports + .filter((item) => item.isSync) + .map((item) => [item.id, viewState]); + const tempViewStates = Object.fromEntries(viewStateTable ?? []); + this.result_.viewState = { + ...this.result_.viewState, + ...tempViewStates, + }; + } else { + this.result_.viewState = { + ...this.result_.viewState, + [viewId]: viewState, + }; + } + this.derivedState_.viewStateChanged = true; + this.rerender_(); + }; +} + +/** + * Returns the zoom factor allowing to view the complete boundingBox. + * @param camera camera defining the view orientation. + * @param boundingBox 3D bounding box to visualize. + * @param fov field of view (see deck.gl file orbit-viewports.ts). + */ +function computeCameraZoom( + camera: ViewStateType, + boundingBox: BoundingBox3D, + size: Size, + fovy = 50 +): number { + const DEGREES_TO_RADIANS = Math.PI / 180; + const RADIANS_TO_DEGREES = 180 / Math.PI; + const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + + const fD = fovyToAltitude(fovy); + + const xMin = boundingBox[0]; + const yMin = boundingBox[1]; + const zMin = boundingBox[2]; + + const xMax = boundingBox[3]; + const yMax = boundingBox[4]; + const zMax = boundingBox[5]; + + const target = [ + xMin + (xMax - xMin) / 2, + yMin + (yMax - yMin) / 2, + zMin + (zMax - zMin) / 2, + ]; + + const cameraFovVertical = 50; + const angle_ver = (cameraFovVertical / 2) * DEGREES_TO_RADIANS; + const L = size.height / 2 / Math.sin(angle_ver); + const r = L * Math.cos(angle_ver); + const cameraFov = 2 * Math.atan(size.width / 2 / r) * RADIANS_TO_DEGREES; + const angle_hor = (cameraFov / 2) * DEGREES_TO_RADIANS; + + const points: [number, number, number][] = []; + points.push([xMin, yMin, zMin]); + points.push([xMin, yMax, zMin]); + points.push([xMax, yMax, zMin]); + points.push([xMax, yMin, zMin]); + points.push([xMin, yMin, zMax]); + points.push([xMin, yMax, zMax]); + points.push([xMax, yMax, zMax]); + points.push([xMax, yMin, zMax]); + + let zoom = 999; + for (const point of points) { + const x_ = (point[0] - target[0]) / size.height; + const y_ = (point[1] - target[1]) / size.height; + const z_ = (point[2] - target[2]) / size.height; + + const m = new Matrix4(IDENTITY); + m.rotateX(camera.rotationX * DEGREES_TO_RADIANS); + m.rotateZ(camera.rotationOrbit * DEGREES_TO_RADIANS); + + const [x, y, z] = m.transformAsVector([x_, y_, z_]); + if (y >= 0) { + // These points will actually appear further away when zooming in. + continue; + } + + const fwX = fD * Math.tan(angle_hor); + let y_new = fwX / (Math.abs(x) / y - fwX / fD); + const zoom_x = Math.log2(y_new / y); + + const fwY = fD * Math.tan(angle_ver); + y_new = fwY / (Math.abs(z) / y - fwY / fD); + const zoom_z = Math.log2(y_new / y); + + // it needs to be inside view volume in both directions. + zoom = zoom_x < zoom ? zoom_x : zoom; + zoom = zoom_z < zoom ? zoom_z : zoom; + } + return zoom; +} + +/////////////////////////////////////////////////////////////////////////////////////////// // return viewstate with computed bounds to fit the data in viewport -function getViewState( +function getViewStateFromBounds( viewPortMargins: MarginsType, - bounds_accessor: [number, number, number, number] | BoundsAccessor, - centerOfData: [number, number, number], + bounds_accessor: BoundingBox2D | BoundsAccessor, + target: [number, number, number], views: ViewsType | undefined, viewPort: ViewportType, size: Size -): ViewStateType { - let bounds = [0, 0, 1, 1]; - if (typeof bounds_accessor == "function") { - bounds = bounds_accessor(); - } else { - bounds = bounds_accessor; - } +): ViewStateType | undefined { + const bounds = + typeof bounds_accessor == "function" + ? bounds_accessor() + : bounds_accessor; let w = bounds[2] - bounds[0]; // right - left let h = bounds[3] - bounds[1]; // top - bottom - const z = centerOfData[2]; + const z = target[2]; const fb = fitBounds({ width: w, height: h, bounds }); let fb_target = [fb.x, fb.y, z]; @@ -1240,8 +1356,8 @@ function getViewState( h = size.height - marginV; // Special case if matrix views. - // Use width and heigt for a subview instead of full viewport. - if (typeof views?.layout !== "undefined") { + // Use width and height for a sub-view instead of full viewport. + if (views?.layout) { const [nY, nX] = views.layout; const isMatrixViews = nX !== 1 || nY !== 1; if (isMatrixViews) { @@ -1290,105 +1406,85 @@ function getViewState( fb_zoom = fb.zoom; } - const target = viewPort.target; - const zoom = viewPort.zoom; - - const target_ = target ?? fb_target; - const zoom_ = zoom ?? fb_zoom; - - const minZoom = minZoom3D; - const maxZoom = viewPort.show3D ? maxZoom3D : maxZoom2D; - const view_state: ViewStateType = { - target: target_, - zoom: zoom_, + target: viewPort.target ?? fb_target, + zoom: viewPort.zoom ?? fb_zoom, rotationX: 90, // look down z -axis rotationOrbit: 0, - minZoom, - maxZoom, + minZoom: viewPort.show3D ? minZoom3D : minZoom2D, + maxZoom: viewPort.show3D ? maxZoom3D : maxZoom2D, }; return view_state; } /////////////////////////////////////////////////////////////////////////////////////////// -// return viewstate with computed bounds to fit the data in viewport -function getViewState3D( - is3D: boolean, - bounds: BoundingBox3D, - zoom: number | undefined, - size: Size -): ViewStateType { - const xMin = bounds[0]; - const yMin = bounds[1]; - const zMin = bounds[2]; - - const xMax = bounds[3]; - const yMax = bounds[4]; - const zMax = bounds[5]; - - let width = xMax - xMin; - let height = yMax - yMin; - if (size.width > 0 && size.height > 0) { - width = size.width; - height = size.height; +// build views +type ViewTypeType = + | typeof ZScaleOrbitView + | typeof IntersectionView + | typeof OrthographicView; +function getVT( + viewport: ViewportType +): [ + ViewType: ViewTypeType, + Controller: typeof ZScaleOrbitController | typeof OrthographicController, +] { + if (viewport.show3D) { + return [ZScaleOrbitView, ZScaleOrbitController]; } - - const target = [ - xMin + (xMax - xMin) / 2, - yMin + (yMax - yMin) / 2, - is3D ? zMin + (zMax - zMin) / 2 : 0, + return [ + viewport.id === "intersection_view" + ? IntersectionView + : OrthographicView, + OrthographicController, ]; - const bounds2D = [xMin, yMin, xMax, yMax]; - const fitted_bound = fitBounds({ - width, - height, - bounds: bounds2D, - }); - - const view_state: ViewStateType = { - target, - zoom: zoom ?? fitted_bound.zoom * 1.2, - rotationX: 45, // look down z -axis at 45 degrees - rotationOrbit: 0, - minZoom: minZoom3D, - maxZoom: maxZoom3D, - }; - return view_state; } -// construct views and viewStates for DeckGL component -function createViewsAndViewStates( - views: ViewsType | undefined, - viewPortMargins: MarginsType, - bounds: [number, number, number, number] | BoundsAccessor | undefined, - cameraPosition: ViewStateType | undefined, - boundingBox: BoundingBox3D, - size: Size -): [View[], Record] { - const deckgl_views: View[] = []; - let viewStates: Record = {} as Record< - string, - ViewStateType - >; - - const centerOfData = boundingBoxCenter(boundingBox); +function areViewsValid(views: ViewsType | undefined, size: Size): boolean { + const isInvalid: boolean = + views?.viewports === undefined || + views?.layout === undefined || + !views?.layout?.[0] || + !views?.layout?.[1] || + !size.width || + !size.height; + return !isInvalid; +} - const widthViewPort = size.width; - const heightViewPort = size.height; +/** returns a new View instance. */ +function newView( + viewport: ViewportType, + x: number | string, + y: number | string, + width: number | string, + height: number | string +): View { + const far = 9999; + const near = viewport.show3D ? 0.1 : -9999; + + const [ViewType, Controller] = getVT(viewport); + return new ViewType({ + id: viewport.id, + controller: { + type: Controller, + doubleClickZoom: false, + }, - const mPixels = views?.marginPixels ?? 0; + x, + y, + width, + height, - const isOk: boolean = - views?.layout !== undefined && - views?.layout?.[0] >= 1 && - views?.layout?.[1] >= 1 && - widthViewPort > 0 && - heightViewPort > 0; + flipY: false, + far, + near, + }); +} - // if props for multiple viewport are not proper, or deck size is not yet initialized, return 2d view - // add redundant check on views to please lint +function buildDeckGlViews(views: ViewsType | undefined, size: Size): View[] { + const isOk = areViewsValid(views, size); if (!views || !isOk) { - deckgl_views.push( + return [ new OrthographicView({ id: "main", controller: { doubleClickZoom: false }, @@ -1399,121 +1495,234 @@ function createViewsAndViewStates( flipY: false, far: +99999, near: -99999, - }) - ); - viewStates["dummy"] = - cameraPosition ?? - ({ - target: [0, 0], - zoom: 0, - rotationX: 0, - rotationOrbit: 0, - minZoom: minZoom2D, - maxZoom: maxZoom2D, - } as ViewStateType); - } else { - let yPos = 0; - const [nY, nX] = views.layout; - const w = 99.5 / nX; // Using 99.5% of viewport to avoid flickering of deckgl canvas - const h = 99.5 / nY; - - const singleView = nX === 1 && nY === 1; - - const marginHorPercentage = singleView // percentage of sub view - ? 0 - : 100 * 100 * (mPixels / (w * widthViewPort)); - const marginVerPercentage = singleView - ? 0 - : 100 * 100 * (mPixels / (h * heightViewPort)); - - for (let y = 1; y <= nY; y++) { - let xPos = 0; - for (let x = 1; x <= nX; x++) { - if ( - views.viewports == undefined || - deckgl_views.length >= views.viewports.length - ) { - return [deckgl_views, viewStates]; - } + }), + ]; + } - const currentViewport: ViewportType = - views.viewports[deckgl_views.length]; - - let ViewType: - | typeof ZScaleOrbitView - | typeof IntersectionView - | typeof OrthographicView = ZScaleOrbitView; - if (!currentViewport.show3D) { - ViewType = - currentViewport.id === "intersection_view" - ? IntersectionView - : OrthographicView; - } + // compute - const far = 9999; - const near = currentViewport.show3D ? 0.1 : -9999; + const [nY, nX] = views.layout; + // compute for single view (code is more readable) + const singleView = nX === 1 && nY === 1; + if (singleView) { + // Using 99.5% of viewport to avoid flickering of deckgl canvas + return [newView(views.viewports[0], 0, 0, "95%", "95%")]; + } - const Controller = currentViewport.show3D - ? ZScaleOrbitController - : OrthographicController; + // compute for matrix + const result: View[] = []; + const w = 99.5 / nX; // Using 99.5% of viewport to avoid flickering of deckgl canvas + const h = 99.5 / nY; + const marginPixels = views.marginPixels ?? 0; + const marginHorPercentage = 100 * 100 * (marginPixels / (w * size.width)); + const marginVerPercentage = 100 * 100 * (marginPixels / (h * size.height)); + let yPos = 0; + for (let y = 1; y <= nY; y++) { + let xPos = 0; + for (let x = 1; x <= nX; x++) { + if (result.length >= views.viewports.length) { + // stop when all the viewports are filled + return result; + } - const controller = { - type: Controller, - doubleClickZoom: false, - }; + const currentViewport: ViewportType = + views.viewports[result.length]; - deckgl_views.push( - new ViewType({ - id: currentViewport.id, - controller: controller, + const viewX = xPos + marginHorPercentage / nX + "%"; + const viewY = yPos + marginVerPercentage / nY + "%"; + const viewWidth = w * (1 - 2 * (marginHorPercentage / 100)) + "%"; + const viewHeight = h * (1 - 2 * (marginVerPercentage / 100)) + "%"; - x: xPos + marginHorPercentage / nX + "%", - y: yPos + marginVerPercentage / nY + "%", + result.push( + newView(currentViewport, viewX, viewY, viewWidth, viewHeight) + ); + xPos = xPos + w; + } + yPos = yPos + h; + } + return result; +} - width: w * (1 - 2 * (marginHorPercentage / 100)) + "%", - height: h * (1 - 2 * (marginVerPercentage / 100)) + "%", +/** + * Returns the camera if it is fully specified (ie. the zoom is a valid number), otherwise computes + * the zoom to visualize the complete camera boundingBox if set, the provided boundingBox otherwise. + * @param camera input camera + * @param boundingBox fallback bounding box, if the camera zoom is not zoom a value nor a bounding box + */ +function updateViewState( + camera: ViewStateType, + boundingBox: BoundingBox3D | undefined, + size: Size +): ViewStateType { + if (typeof camera.zoom === "number" && !Number.isNaN(camera.zoom)) { + return camera; + } - flipY: false, - far, - near, - }) - ); + // update the camera to see the whole boundingBox + if (Array.isArray(camera.zoom)) { + boundingBox = camera.zoom as BoundingBox3D; + } - const isBoundsDefined = typeof bounds !== "undefined"; - const isCameraPositionDefined = - typeof cameraPosition !== "undefined" && - Object.keys(cameraPosition).length !== 0; - - let viewState = cameraPosition; - if (!isCameraPositionDefined) { - viewState = isBoundsDefined - ? getViewState( - viewPortMargins, - bounds ?? [0, 0, 1, 1], - centerOfData, - views, - currentViewport, - size - ) - : getViewState3D( - currentViewport.show3D ?? false, - boundingBox, - currentViewport.zoom, - size - ); - } + // return the camera if the bounding box is undefined + if (boundingBox === undefined) { + return camera; + } - viewStates = { - ...viewStates, - [currentViewport.id]: viewState as ViewStateType, - }; + // clone the camera in case of triggerHome + const camera_ = cloneDeep(camera); + camera_.zoom = computeCameraZoom(camera, boundingBox, size); + camera_.target = boxCenter(boundingBox); + camera_.minZoom = camera_.minZoom ?? minZoom3D; + camera_.maxZoom = camera_.maxZoom ?? maxZoom3D; + return camera_; +} - xPos = xPos + w; +/** + * + * @returns Computes the view state + */ +function computeViewState( + viewPort: ViewportType, + cameraPosition: ViewStateType | undefined, + boundingBox: BoundingBox3D | undefined, + bounds: BoundingBox2D | BoundsAccessor | undefined, + viewportMargins: MarginsType, + views: ViewsType | undefined, + size: Size +): ViewStateType | undefined { + // If the camera is defined, use it + const isCameraPositionDefined = cameraPosition !== undefined; + const isBoundsDefined = bounds !== undefined; + + if (viewPort.show3D ?? false) { + // If the camera is defined, use it + if (isCameraPositionDefined) { + return updateViewState(cameraPosition, boundingBox, size); + } + + // deprecated in 3D, kept for backward compatibility + if (isBoundsDefined) { + const centerOfData: [number, number, number] = boundingBox + ? boxCenter(boundingBox) + : [0, 0, 0]; + return getViewStateFromBounds( + viewportMargins, + bounds, + centerOfData, + views, + viewPort, + size + ); + } + const defaultCamera = { + target: [], + zoom: NaN, + rotationX: 45, // look down z -axis at 45 degrees + rotationOrbit: 0, + }; + return updateViewState(defaultCamera, boundingBox, size); + } else { + const centerOfData: [number, number, number] = boundingBox + ? boxCenter(boundingBox) + : [0, 0, 0]; + // if bounds are defined, use them + if (isBoundsDefined) { + return getViewStateFromBounds( + viewportMargins, + bounds, + centerOfData, + views, + viewPort, + size + ); + } + + // deprecated in 2D, kept for backward compatibility + if (isCameraPositionDefined) { + return cameraPosition; + } + + return boundingBox + ? getViewStateFromBounds( + viewportMargins, + // use the bounding box to extract the 2D bounds + [ + boundingBox[0], + boundingBox[1], + boundingBox[3], + boundingBox[4], + ], + centerOfData, + views, + viewPort, + size + ) + : undefined; + } +} + +function buildDeckGlViewStates( + views: ViewsType | undefined, + viewPortMargins: MarginsType, + cameraPosition: ViewStateType | undefined, + boundingBox: BoundingBox3D | undefined, + bounds: BoundingBox2D | BoundsAccessor | undefined, + size: Size +): Record { + const isOk = areViewsValid(views, size); + if (!views || !isOk) { + return {}; + } + + // compute + + const [nY, nX] = views.layout; + // compute for single view (code is more readable) + const singleView = nX === 1 && nY === 1; + if (singleView) { + const viewState = computeViewState( + views.viewports[0], + cameraPosition, + boundingBox, + bounds, + viewPortMargins, + views, + size + ); + return viewState ? { [views.viewports[0].id]: viewState } : {}; + } + + // compute for matrix + let result: Record = {} as Record< + string, + ViewStateType + >; + for (let y = 1; y <= nY; y++) { + for (let x = 1; x <= nX; x++) { + const resultLength = Object.keys(result).length; + if (resultLength >= views.viewports.length) { + // stop when all the viewports are filled + return result; + } + const currentViewport: ViewportType = views.viewports[resultLength]; + const currentViewState = computeViewState( + currentViewport, + cameraPosition, + boundingBox, + bounds, + viewPortMargins, + views, + size + ); + if (currentViewState) { + result = { + ...result, + [currentViewport.id]: currentViewState, + }; } - yPos = yPos + h; } } - return [deckgl_views, viewStates]; + return result; } function handleMouseEvent( diff --git a/typescript/packages/subsurface-viewer/src/utils/BoundingBox3D.test.ts b/typescript/packages/subsurface-viewer/src/utils/BoundingBox3D.test.ts new file mode 100644 index 0000000000..93f11dc1f8 --- /dev/null +++ b/typescript/packages/subsurface-viewer/src/utils/BoundingBox3D.test.ts @@ -0,0 +1,37 @@ +import "jest"; + +import type { BoundingBox3D } from "./BoundingBox3D"; +import { boxCenter, boxUnion } from "./BoundingBox3D"; + +describe("Test BoundingBox3D", () => { + it("boxUnion default box", () => { + const unitBox: BoundingBox3D = [0, 0, 0, 1, 1, 1]; + const defaultBox: BoundingBox3D = [-1, -1, -1, 1, 1, 1]; + const box1: BoundingBox3D = [0, 1, 2, 5, 6, 7]; + const box2: BoundingBox3D = [1, 2, 3, 4, 5, 6]; + expect(boxUnion(undefined, undefined)).toEqual(unitBox); + expect(boxUnion(undefined, undefined, defaultBox)).toEqual(defaultBox); + expect(boxUnion(box1, undefined, defaultBox)).toEqual(box1); + expect(boxUnion(undefined, box2, defaultBox)).toEqual(box2); + expect(boxUnion(box1, box2, defaultBox)).toEqual(box1); + }); + + it("boxUnion without default box", () => { + const box1: BoundingBox3D = [0, 1, 2, 5, 6, 7]; + const box2: BoundingBox3D = [1, 2, 3, 4, 5, 6]; + const box3: BoundingBox3D = [1, 2, 3, 6, 7, 8]; + expect(boxUnion(box1, undefined)).toEqual(box1); + expect(boxUnion(undefined, box2)).toEqual(box2); + expect(boxUnion(box1, box2)).toEqual(box1); + expect(boxUnion(box1, box3)).toEqual([0, 1, 2, 6, 7, 8]); + }); + + it("boxCenter", () => { + const box1: BoundingBox3D = [0, 1, 2, 5, 6, 7]; + const box2: BoundingBox3D = [1, 2, 3, 4, 5, 6]; + const box3: BoundingBox3D = [1, 2, 3, 6, 7, 8]; + expect(boxCenter(box1)).toEqual([2.5, 3.5, 4.5]); + expect(boxCenter(box2)).toEqual([2.5, 3.5, 4.5]); + expect(boxCenter(box3)).toEqual([3.5, 4.5, 5.5]); + }); +}); diff --git a/typescript/packages/subsurface-viewer/src/utils/BoundingBox3D.ts b/typescript/packages/subsurface-viewer/src/utils/BoundingBox3D.ts new file mode 100644 index 0000000000..b9ba05c9ac --- /dev/null +++ b/typescript/packages/subsurface-viewer/src/utils/BoundingBox3D.ts @@ -0,0 +1,53 @@ +/** + * 3D bounding box defined as [xmin, ymin, zmin, xmax, ymax, zmax]. + */ +export type BoundingBox3D = [number, number, number, number, number, number]; + +/** + * Returns the bounding box encompassing both boxes. + * @param box1 first box. + * @param box2 second box. + * @param defaultBox in case both boxes are undefined. + * @returns the bounding box encompassing both boxes. + */ +export const boxUnion = ( + box1: BoundingBox3D | undefined, + box2: BoundingBox3D | undefined, + defaultBox: BoundingBox3D = [0, 0, 0, 1, 1, 1] +): BoundingBox3D => { + if (box1 === undefined) { + return box2 ?? defaultBox; + } + if (box2 === undefined) { + return box1 ?? defaultBox; + } + + const xmin = Math.min(box1[0], box2[0]); + const ymin = Math.min(box1[1], box2[1]); + const zmin = Math.min(box1[2], box2[2]); + + const xmax = Math.max(box1[3], box2[3]); + const ymax = Math.max(box1[4], box2[4]); + const zmax = Math.max(box1[5], box2[5]); + return [xmin, ymin, zmin, xmax, ymax, zmax]; +}; + +/** + * Returns the center of the bounding box. + * @param box1 bounding box. + * @returns the center of the bounding box. + */ +export const boxCenter = (box: BoundingBox3D): [number, number, number] => { + const xmin = box[0]; + const ymin = box[1]; + const zmin = box[2]; + + const xmax = box[3]; + const ymax = box[4]; + const zmax = box[5]; + return [ + xmin + 0.5 * (xmax - xmin), + ymin + 0.5 * (ymax - ymin), + zmin + 0.5 * (zmax - zmin), + ]; +}; From da2c97a526c4d24399896eeb8521b3a903b9937c Mon Sep 17 00:00:00 2001 From: Christophe Winkler Date: Fri, 15 Dec 2023 09:47:57 +0100 Subject: [PATCH 2/8] Fix triggerHome, fix prettier and compilation Update story --- .../subsurface-viewer/src/components/Map.tsx | 3613 +++++++++-------- .../src/layers/map/mapLayer.stories.tsx | 43 +- 2 files changed, 1825 insertions(+), 1831 deletions(-) diff --git a/typescript/packages/subsurface-viewer/src/components/Map.tsx b/typescript/packages/subsurface-viewer/src/components/Map.tsx index f12a123ee3..5d3860f138 100644 --- a/typescript/packages/subsurface-viewer/src/components/Map.tsx +++ b/typescript/packages/subsurface-viewer/src/components/Map.tsx @@ -1,1806 +1,1807 @@ -import React, { useEffect, useState, useCallback, useMemo } from "react"; - -import type { Feature, FeatureCollection } from "geojson"; -import { cloneDeep, isEmpty } from "lodash"; - -import type { - MjolnirEvent, - MjolnirGestureEvent, - MjolnirKeyEvent, - MjolnirPointerEvent, -} from "mjolnir.js"; - -import { JSONConfiguration, JSONConverter } from "@deck.gl/json/typed"; -import type { DeckGLRef } from "@deck.gl/react/typed"; -import DeckGL from "@deck.gl/react/typed"; -import type { - Color, - Layer, - LayersList, - LayerProps, - LayerContext, - View, - Viewport, - PickingInfo, -} from "@deck.gl/core/typed"; -import { - _CameraLight as CameraLight, - AmbientLight, - DirectionalLight, - LightingEffect, - OrbitController, - OrbitView, - OrthographicController, - OrthographicView, - PointLight, -} from "@deck.gl/core/typed"; -import { LineLayer } from "@deck.gl/layers/typed"; - -import { Matrix4 } from "@math.gl/core"; -import { fovyToAltitude } from "@math.gl/web-mercator"; - -import { colorTables } from "@emerson-eps/color-tables"; -import type { colorTablesArray } from "@emerson-eps/color-tables/"; - -import type { BoundingBox3D } from "../utils/BoundingBox3D"; -import { boxCenter, boxUnion } from "../utils/BoundingBox3D"; -import JSON_CONVERTER_CONFIG from "../utils/configuration"; -import type { WellsPickInfo } from "../layers/wells/wellsLayer"; -import InfoCard from "./InfoCard"; -import DistanceScale from "./DistanceScale"; -import StatusIndicator from "./StatusIndicator"; -import fitBounds from "../utils/fit-bounds"; -import { validateColorTables, validateLayers } from "@webviz/wsc-common"; -import type { LayerPickInfo } from "../layers/utils/layerTools"; -import { - getModelMatrixScale, - getLayersByType, - getWellLayerByTypeAndSelectedWells, -} from "../layers/utils/layerTools"; -import { WellsLayer, Axes2DLayer, NorthArrow3DLayer } from "../layers"; - -import IntersectionView from "../views/intersectionView"; -import type { Unit } from "convert-units"; -import type { LightsType } from "../SubsurfaceViewer"; - -/** - * 3D bounding box defined as [xmin, ymin, zmin, xmax, ymax, zmax]. - */ -export type { BoundingBox3D }; -/** - * 2D bounding box defined as [left, bottom, right, top] - */ -export type BoundingBox2D = [number, number, number, number]; -/** - * Type of the function returning coordinate boundary for the view defined as [left, bottom, right, top]. - */ -export type BoundsAccessor = () => BoundingBox2D; - -type Size = { - width: number; - height: number; -}; - -const minZoom3D = -12; -const maxZoom3D = 12; -const minZoom2D = -12; -const maxZoom2D = 4; - -// https://developer.mozilla.org/docs/Web/API/KeyboardEvent -type ArrowEvent = { - key: "ArrowUp" | "ArrowDown" | "PageUp" | "PageDown"; - shiftModifier: boolean; - // altModifier: boolean; - // ctrlModifier: boolean; -}; - -function updateZScaleReducer(zScale: number, action: ArrowEvent): number { - return zScale * getZScaleModifier(action); -} - -function getZScaleModifier(arrowEvent: ArrowEvent): number { - let scaleFactor = 0; - switch (arrowEvent.key) { - case "ArrowUp": - scaleFactor = 0.05; - break; - case "ArrowDown": - scaleFactor = -0.05; - break; - case "PageUp": - scaleFactor = 0.25; - break; - case "PageDown": - scaleFactor = -0.25; - break; - default: - break; - } - if (arrowEvent.shiftModifier) { - scaleFactor /= 5; - } - return 1 + scaleFactor; -} - -function convertToArrowEvent(event: MjolnirEvent): ArrowEvent | null { - if (event.type === "keydown") { - const keyEvent = event as MjolnirKeyEvent; - switch (keyEvent.key) { - case "ArrowUp": - case "ArrowDown": - case "PageUp": - case "PageDown": - return { - key: keyEvent.key, - shiftModifier: keyEvent.srcEvent.shiftKey, - }; - default: - return null; - } - } - return null; -} - -class ZScaleOrbitController extends OrbitController { - static updateZScaleAction: React.Dispatch | null = null; - - static setUpdateZScaleAction( - updateZScaleAction: React.Dispatch - ) { - ZScaleOrbitController.updateZScaleAction = updateZScaleAction; - } - - handleEvent(event: MjolnirEvent): boolean { - if (ZScaleOrbitController.updateZScaleAction) { - const arrowEvent = convertToArrowEvent(event); - if (arrowEvent) { - ZScaleOrbitController.updateZScaleAction(arrowEvent); - return true; - } - } - return super.handleEvent(event); - } -} - -class ZScaleOrbitView extends OrbitView { - get ControllerType(): typeof OrbitController { - return ZScaleOrbitController; - } -} - -function parseLights(lights?: LightsType): LightingEffect[] | undefined { - if (!lights) { - return undefined; - } - - const effects = []; - let lightsObj = {}; - - if (lights.headLight) { - const headLight = new CameraLight({ - intensity: lights.headLight.intensity, - color: lights.headLight.color ?? [255, 255, 255], - }); - lightsObj = { ...lightsObj, headLight }; - } - - if (lights.ambientLight) { - const ambientLight = new AmbientLight({ - intensity: lights.ambientLight.intensity, - color: lights.ambientLight.color ?? [255, 255, 255], - }); - lightsObj = { ...lightsObj, ambientLight }; - } - - if (lights.pointLights) { - for (const light of lights.pointLights) { - const pointLight = new PointLight({ - ...light, - color: light.color ?? [255, 255, 255], - }); - lightsObj = { ...lightsObj, pointLight }; - } - } - - if (lights.directionalLights) { - for (const light of lights.directionalLights) { - const directionalLight = new DirectionalLight({ - ...light, - color: light.color ?? [255, 255, 255], - }); - lightsObj = { ...lightsObj, directionalLight }; - } - } - - const lightingEffect = new LightingEffect(lightsObj); - effects.push(lightingEffect); - - return effects; -} - -export type ReportBoundingBoxAction = { layerBoundingBox: BoundingBox3D }; -function mapBoundingBoxReducer( - mapBoundingBox: BoundingBox3D | undefined, - action: ReportBoundingBoxAction -): BoundingBox3D | undefined { - return boxUnion(mapBoundingBox, action.layerBoundingBox); -} - -// Exclude "layerIds" when monitoring changes to "view" prop as we do not -// want to recalculate views when the layers change. -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-expect-error -function compareViewsProp(views: ViewsType | undefined): string | undefined { - if (views === undefined) { - return undefined; - } - - const copy = cloneDeep(views); - const viewports = copy.viewports.map((e) => { - delete e.layerIds; - return e; - }); - copy.viewports = viewports; - return JSON.stringify(copy); -} - -export type TooltipCallback = ( - info: PickingInfo -) => string | Record | null; - -/** - * Views - */ -export interface ViewsType { - /** - * Layout for viewport in specified as [row, column]. - */ - layout: [number, number]; - - /** - * Number of pixels used for the margin in matrix mode. - * Defaults to 0. - */ - marginPixels?: number; - - /** - * Show views label. - */ - showLabel?: boolean; - - /** - * Layers configuration for multiple viewports. - */ - viewports: ViewportType[]; -} - -/** - * Viewport type. - */ -export interface ViewportType { - /** - * Viewport id - */ - id: string; - - /** - * Viewport name - */ - name?: string; - - /** - * If true, displays map in 3D view, default is 2D view (false) - */ - show3D?: boolean; - - /** - * Layers to be displayed on viewport - */ - layerIds?: string[]; - - target?: [number, number]; - zoom?: number; - rotationX?: number; - rotationOrbit?: number; - - isSync?: boolean; -} - -/** - * Camera view state. - */ -export interface ViewStateType { - target: number[]; - zoom: number | BoundingBox3D | undefined; - rotationX: number; - rotationOrbit: number; - minZoom?: number; - maxZoom?: number; - transitionDuration?: number; -} - -interface MarginsType { - left: number; - right: number; - top: number; - bottom: number; -} - -export interface DeckGLLayerContext extends LayerContext { - userData: { - setEditedData: (data: Record) => void; - colorTables: colorTablesArray; - }; -} - -export interface MapMouseEvent { - type: "click" | "hover" | "contextmenu"; - infos: PickingInfo[]; - // some frequently used values extracted from infos[]: - x?: number; - y?: number; - // Only for one well. Full information is available in infos[] - wellname?: string; - wellcolor?: Color; // well color - md?: number; - tvd?: number; -} - -export type EventCallback = (event: MapMouseEvent) => void; - -export function useHoverInfo(): [PickingInfo[], EventCallback] { - const [hoverInfo, setHoverInfo] = useState([]); - const callback = useCallback((pickEvent: MapMouseEvent) => { - setHoverInfo(pickEvent.infos); - }, []); - return [hoverInfo, callback]; -} - -export interface MapProps { - /** - * The ID of this component, used to identify dash components - * in callbacks. The ID needs to be unique across all of the - * components in an app. - */ - id: string; - - /** - * Resource dictionary made available in the DeckGL specification as an enum. - * The values can be accessed like this: `"@@#resources.resourceId"`, where - * `resourceId` is the key in the `resources` dict. For more information, - * see the DeckGL documentation on enums in the json spec: - * https://deck.gl/docs/api-reference/json/conversion-reference#enumerations-and-using-the--prefix - */ - resources?: Record; - - /* List of JSON object containing layer specific data. - * Each JSON object will consist of layer type with key as "@@type" and - * layer specific data, if any. - */ - layers?: LayersList; - - /** - * Coordinate boundary for the view defined as [left, bottom, right, top]. - * Should be used for 2D view only. - */ - bounds?: BoundingBox2D | BoundsAccessor; - - /** - * Camera state for the view defined as a ViewStateType. - * Should be used for 3D view only. - * If the zoom is given as a 3D bounding box, the camera state is computed to - * display the full box. - */ - cameraPosition?: ViewStateType; - - /** - * If changed will reset view settings (bounds or camera) to default position. - */ - triggerHome?: number; - - /** - * Views configuration for map. If not specified, all the layers will be - * displayed in a single 2D viewport - */ - views?: ViewsType; - - /** - * Parameters for the InfoCard component - */ - coords?: { - visible?: boolean | null; - multiPicking?: boolean | null; - pickDepth?: number | null; - }; - - /** - * Parameters for the Distance Scale component - */ - scale?: { - visible?: boolean | null; - incrementValue?: number | null; - widthPerUnit?: number | null; - cssStyle?: Record | null; - }; - - coordinateUnit?: Unit; - - /** - * Parameters to control toolbar - */ - toolbar?: { - visible?: boolean | null; - }; - - /** - * Prop containing color table data - */ - colorTables?: colorTablesArray; - - /** - * Prop containing edited data from layers - */ - editedData?: Record; - - /** - * For reacting to prop changes - */ - setEditedData?: (data: Record) => void; - - /** - * Validate JSON datafile against schema - */ - checkDatafileSchema?: boolean; - - /** - * For get mouse events - */ - onMouseEvent?: EventCallback; - - getCameraPosition?: (input: ViewStateType) => void; - - /** - * Will be called after all layers have rendered data. - */ - isRenderedCallback?: (arg: boolean) => void; - - onDragStart?: (info: PickingInfo, event: MjolnirGestureEvent) => void; - onDragEnd?: (info: PickingInfo, event: MjolnirGestureEvent) => void; - - triggerResetMultipleWells?: number; - selection?: { - well: string | undefined; - selection: [number | undefined, number | undefined] | undefined; - }; - - lights?: LightsType; - - children?: React.ReactNode; - - getTooltip?: TooltipCallback; -} - -function defaultTooltip(info: PickingInfo) { - if ((info as WellsPickInfo)?.logName) { - return (info as WellsPickInfo)?.logName; - } else if (info.layer?.id === "drawing-layer") { - return (info as LayerPickInfo).propertyValue?.toFixed(2); - } - const feat = info.object as Feature; - return feat?.properties?.["name"]; -} - -const Map: React.FC = ({ - id, - layers, - bounds, - cameraPosition, - triggerHome, - views, - coords, - scale, - coordinateUnit, - colorTables, - setEditedData, - checkDatafileSchema, - onMouseEvent, - selection, - children, - getTooltip = defaultTooltip, - getCameraPosition, - isRenderedCallback, - onDragStart, - onDragEnd, - lights, - triggerResetMultipleWells, -}: MapProps) => { - // From react doc, ref should not be read nor modified during rendering. - const deckRef = React.useRef(null); - - const [applyViewController, forceUpdate] = React.useReducer( - (x) => x + 1, - 0 - ); - const [viewController, _] = useState(() => new ViewController(forceUpdate)); - - // Extract the needed size from onResize function - const [deckSize, setDeckSize] = useState({ width: 0, height: 0 }); - const onResize = useCallback((size: { width: number; height: number }) => { - // exclude {0, 0} size (when rendered hidden pages) - if ( - size.width > 0 && - size.height > 0 && - (size.width !== deckSize.width || size.height !== deckSize.height) - ) { - setDeckSize(size); - } - }, []); - - // 3d bounding box computed from the layers - const [dataBoundingBox3d, dispatchBoundingBox] = React.useReducer( - mapBoundingBoxReducer, - undefined - ); - - // Used for scaling in z direction using arrow keys. - const [zScale, updateZScale] = React.useReducer(updateZScaleReducer, 1); - React.useEffect(() => { - ZScaleOrbitController.setUpdateZScaleAction(updateZScale); - }, [updateZScale]); - - // compute the viewport margins - const viewPortMargins = React.useMemo(() => { - if (!layers?.length) { - return { - left: 0, - right: 0, - top: 0, - bottom: 0, - }; - } - // Margins on the viewport are extracted from a potential axes2D layer. - const axes2DLayer = layers?.find((e) => { - return e?.constructor === Axes2DLayer; - }) as Axes2DLayer; - - const axes2DProps = axes2DLayer?.props; - return { - left: axes2DProps?.isLeftRuler ? axes2DProps.marginH : 0, - right: axes2DProps?.isRightRuler ? axes2DProps.marginH : 0, - top: axes2DProps?.isTopRuler ? axes2DProps.marginV : 0, - bottom: axes2DProps?.isBottomRuler ? axes2DProps.marginV : 0, - }; - }, [layers]); - - // selection - useEffect(() => { - const layers = deckRef.current?.deck?.props.layers; - if (layers) { - const wellslayer = getLayersByType( - layers, - WellsLayer.name - )?.[0] as WellsLayer; - - wellslayer?.setSelection(selection?.well, selection?.selection); - } - }, [selection]); - - // multiple well layers - const [multipleWells, setMultipleWells] = useState([]); - const [selectedWell, setSelectedWell] = useState(""); - const [shiftHeld, setShiftHeld] = useState(false); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function downHandler({ key }: any) { - if (key === "Shift") { - setShiftHeld(true); - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function upHandler({ key }: any) { - if (key === "Shift") { - setShiftHeld(false); - } - } - - useEffect(() => { - window.addEventListener("keydown", downHandler); - window.addEventListener("keyup", upHandler); - return () => { - window.removeEventListener("keydown", downHandler); - window.removeEventListener("keyup", upHandler); - }; - }, []); - - useEffect(() => { - const layers = deckRef.current?.deck?.props.layers; - if (layers) { - const wellslayer = getWellLayerByTypeAndSelectedWells( - layers, - "WellsLayer", - selectedWell - )?.[0] as WellsLayer; - wellslayer?.setMultiSelection(multipleWells); - } - }, [multipleWells, selectedWell]); - - useEffect(() => { - if (typeof triggerResetMultipleWells !== "undefined") { - setMultipleWells([]); - } - }, [triggerResetMultipleWells]); - - const getPickingInfos = useCallback( - ( - pickInfo: PickingInfo, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - event: any - ): PickingInfo[] => { - if (coords?.multiPicking && pickInfo.layer?.context.deck) { - const pickInfos = - pickInfo.layer.context.deck.pickMultipleObjects({ - x: event.offsetCenter.x, - y: event.offsetCenter.y, - depth: coords.pickDepth ? coords.pickDepth : undefined, - unproject3D: true, - }) as LayerPickInfo[]; - pickInfos.forEach((item) => { - if (item.properties) { - let unit = ( - item.sourceLayer?.props - .data as unknown as FeatureCollection & { - unit: string; - } - )?.unit; - if (unit == undefined) unit = " "; - item.properties.forEach((element) => { - if ( - element.name.includes("MD") || - element.name.includes("TVD") - ) { - element.value = - Number(element.value) - .toFixed(2) - .toString() + - " " + - unit; - } - }); - } - }); - return pickInfos; - } - return [pickInfo]; - }, - [coords?.multiPicking, coords?.pickDepth] - ); - - /** - * call onMouseEvent callback - */ - const callOnMouseEvent = useCallback( - ( - type: "click" | "hover", - infos: PickingInfo[], - event: MjolnirEvent - ): void => { - if ( - (event as MjolnirPointerEvent).leftButton && - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - event.tapCount == 2 // Note. Detect double click. - ) { - // Left button click identifies new camera rotation anchor. - if (infos.length >= 1) { - if (infos[0].coordinate) { - viewController.setTarget( - infos[0].coordinate as [number, number, number] - ); - } - } - } - - if (!onMouseEvent) return; - const ev = handleMouseEvent(type, infos, event); - onMouseEvent(ev); - }, - [onMouseEvent, viewController] - ); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const [hoverInfo, setHoverInfo] = useState([]); - const onHover = useCallback( - (pickInfo: PickingInfo, event: MjolnirEvent) => { - const infos = getPickingInfos(pickInfo, event); - setHoverInfo(infos); // for InfoCard pickInfos - callOnMouseEvent?.("hover", infos, event); - }, - [callOnMouseEvent, getPickingInfos] - ); - - const onClick = useCallback( - (pickInfo: PickingInfo, event: MjolnirEvent) => { - const infos = getPickingInfos(pickInfo, event); - callOnMouseEvent?.("click", infos, event); - }, - [callOnMouseEvent, getPickingInfos] - ); - - const deckGLLayers = React.useMemo(() => { - if (!layers) { - return []; - } - if (layers.length === 0) { - // Empty layers array makes deck.gl set deckRef to undefined (no OpenGL context). - // Hence insert dummy layer. - const dummy_layer = new LineLayer({ - id: "webviz_internal_dummy_layer", - visible: false, - }); - layers.push(dummy_layer); - } - - const m = getModelMatrixScale(zScale); - - return layers.map((item) => { - if (item?.constructor.name === NorthArrow3DLayer.name) { - return item; - } - - return (item as Layer).clone({ - // Inject "dispatchBoundingBox" function into layer for it to report back its respective bounding box. - // eslint-disable-next-line - // @ts-ignore - reportBoundingBox: dispatchBoundingBox, - // Set "modelLayer" matrix to reflect correct z scaling. - modelMatrix: m, - }); - }); - }, [layers, zScale]); - - const [isLoaded, setIsLoaded] = useState(false); - const onAfterRender = useCallback(() => { - if (deckGLLayers) { - const loadedState = deckGLLayers.every((layer) => { - return ( - (layer as Layer).isLoaded || !(layer as Layer).props.visible - ); - }); - - const emptyLayers = // There will always be a dummy layer. Deck.gl does not like empty array of layers. - deckGLLayers.length == 1 && - (deckGLLayers[0] as LineLayer).id === - "webviz_internal_dummy_layer"; - - setIsLoaded(loadedState || emptyLayers); - if (isRenderedCallback) { - isRenderedCallback(loadedState); - } - } - }, [deckGLLayers, isRenderedCallback]); - - // validate layers data - const [errorText, setErrorText] = useState(); - useEffect(() => { - const layers = deckRef.current?.deck?.props.layers as Layer[]; - // this ensures to validate the schemas only once - if (checkDatafileSchema && layers && isLoaded) { - try { - validateLayers(layers); - colorTables && validateColorTables(colorTables); - } catch (e) { - setErrorText(String(e)); - } - } else setErrorText(undefined); - }, [ - checkDatafileSchema, - colorTables, - deckRef?.current?.deck?.props.layers, - isLoaded, - ]); - - const layerFilter = useCallback( - (args: { layer: Layer; viewport: Viewport }): boolean => { - // display all the layers if views are not specified correctly - if (!views?.viewports || !views?.layout) { - return true; - } - - const cur_view = views.viewports.find( - ({ id }) => args.viewport.id && id === args.viewport.id - ); - if (cur_view?.layerIds && cur_view.layerIds.length > 0) { - const layer_ids = cur_view.layerIds; - return layer_ids.some((layer_id) => { - const t = layer_id === args.layer.id; - return t; - }); - } else { - return true; - } - }, - [views] - ); - - const onViewStateChange = useCallback( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ({ viewId, viewState }: { viewId: string; viewState: any }) => { - viewController.onViewStateChange(viewId, viewState); - if (getCameraPosition) { - getCameraPosition(viewState); - } - }, - [getCameraPosition, viewController] - ); - - const effects = parseLights(lights); - - const [deckGlViews, deckGlViewState] = useMemo(() => { - const state = { - triggerHome, - camera: cameraPosition, - bounds, - boundingBox3d: dataBoundingBox3d, - viewPortMargins, - deckSize, - zScale, - }; - return viewController.getViews(views, state); - }, [ - triggerHome, - cameraPosition, - bounds, - dataBoundingBox3d, - viewPortMargins, - deckSize, - views, - zScale, - applyViewController, - viewController, - ]); - - if (!deckGlViews || isEmpty(deckGlViews) || isEmpty(deckGLLayers)) - return null; - return ( -
event.preventDefault()}> - ) => { - setSelectedWell(updated_prop["selectedWell"] as string); - if ( - Object.keys(updated_prop).includes("selectedWell") - ) { - if (shiftHeld) { - if ( - multipleWells.includes( - updated_prop["selectedWell"] as string - ) - ) { - const temp = multipleWells.filter( - (item) => - item !== - updated_prop["selectedWell"] - ); - setMultipleWells(temp); - } else { - const temp = multipleWells.concat( - updated_prop["selectedWell"] as string - ); - setMultipleWells(temp); - } - } else { - setMultipleWells([]); - } - } - setEditedData?.(updated_prop); - }, - colorTables: colorTables, - }} - getCursor={({ isDragging }): string => - isDragging ? "grabbing" : "default" - } - getTooltip={getTooltip} - ref={deckRef} - onViewStateChange={onViewStateChange} - onHover={onHover} - onClick={onClick} - onAfterRender={onAfterRender} - effects={effects} - onDragStart={onDragStart} - onDragEnd={onDragEnd} - onResize={onResize} - > - {children} - - {scale?.visible ? ( - - ) : null} - - {coords?.visible ? : null} - {errorText && ( -
-                    {errorText}
-                
- )} -
- ); -}; - -Map.defaultProps = { - coords: { - visible: true, - multiPicking: true, - pickDepth: 10, - }, - scale: { - visible: true, - incrementValue: 100, - widthPerUnit: 100, - cssStyle: { top: 10, left: 10 }, - }, - toolbar: { - visible: false, - }, - coordinateUnit: "m", - views: { - layout: [1, 1], - showLabel: false, - viewports: [{ id: "main-view", show3D: false, layerIds: [] }], - }, - colorTables: colorTables, - checkDatafileSchema: false, -}; - -export default Map; - -// ------------- Helper functions ---------- // - -// Add the resources as an enum in the Json Configuration and then convert the spec to actual objects. -// See https://deck.gl/docs/api-reference/json/overview for more details. -export function jsonToObject( - data: Record[] | LayerProps[], - enums: Record[] | undefined = undefined -): LayersList | View[] { - if (!data) return []; - - const configuration = new JSONConfiguration(JSON_CONVERTER_CONFIG); - enums?.forEach((enumeration) => { - if (enumeration) { - configuration.merge({ - enumerations: { - ...enumeration, - }, - }); - } - }); - const jsonConverter = new JSONConverter({ configuration }); - - // remove empty data/layer object - const filtered_data = data.filter((value) => !isEmpty(value)); - return jsonConverter.convert(filtered_data); -} - -/////////////////////////////////////////////////////////////////////////////////////////// -// View Controller -// Implements the algorithms to compute the views and the view state -type ViewControllerState = { - // Explicit state - triggerHome: number | undefined; - camera: ViewStateType | undefined; - bounds: BoundingBox2D | BoundsAccessor | undefined; - boundingBox3d: BoundingBox3D | undefined; - deckSize: Size; - zScale: number; - viewPortMargins: MarginsType; -}; -type ViewControllerDerivedState = { - // Derived state - target: [number, number, number] | undefined; - viewStateChanged: boolean; -}; -type ViewControllerFullState = ViewControllerState & ViewControllerDerivedState; -class ViewController { - private rerender_: React.DispatchWithoutAction; - - private state_: ViewControllerFullState = { - triggerHome: undefined, - camera: undefined, - bounds: undefined, - boundingBox3d: undefined, - deckSize: { width: 0, height: 0 }, - zScale: 1, - viewPortMargins: { - left: 0, - right: 0, - top: 0, - bottom: 0, - }, - // Derived state - target: undefined, - viewStateChanged: false, - }; - - private derivedState_: ViewControllerDerivedState = { - target: undefined, - viewStateChanged: false, - }; - - private views_: ViewsType | undefined = undefined; - private result_: { - views: View[]; - viewState: Record; - } = { - views: [], - viewState: {}, - }; - - public constructor(rerender: React.DispatchWithoutAction) { - this.rerender_ = rerender; - } - - public readonly setTarget = (target: [number, number, number]) => { - this.derivedState_.target = [target[0], target[1], target[2]]; - this.rerender_(); - }; - - public readonly getViews = ( - views: ViewsType | undefined, - state: ViewControllerState - ): [View[], Record] => { - const fullState = this.consolidateState(state); - const newViews = this.getDeckGlViews(views, fullState); - const newViewState = this.getDeckGlViewState(views, fullState); - - if ( - this.result_.views !== newViews || - this.result_.viewState !== newViewState - ) { - const viewsMsg = this.result_.views !== newViews ? " views" : ""; - const stateMsg = - this.result_.viewState !== newViewState ? " state" : ""; - const linkMsg = viewsMsg && stateMsg ? " and" : ""; - - console.log( - `ViewController returns new${viewsMsg}${linkMsg}${stateMsg}` - ); - } - - this.state_ = fullState; - this.views_ = views; - this.result_.views = newViews; - this.result_.viewState = newViewState; - return [newViews, newViewState]; - }; - - // consolidate "controlled" state (ie. set by parent) with "uncontrolled" state - private readonly consolidateState = ( - state: ViewControllerState - ): ViewControllerFullState => { - return { ...state, ...this.derivedState_ }; - }; - - // returns the DeckGL views (ie. view position and viewport) - private readonly getDeckGlViews = ( - views: ViewsType | undefined, - state: ViewControllerFullState - ) => { - const needUpdate = - views != this.views_ || state.deckSize != this.state_.deckSize; - if (!needUpdate) { - return this.result_.views; - } - return buildDeckGlViews(views, state.deckSize); - }; - - // returns the DeckGL views state(s) (ie. camera settings applied to individual views) - private readonly getDeckGlViewState = ( - views: ViewsType | undefined, - state: ViewControllerFullState - ): Record => { - const viewsChanged = views != this.views_; - const triggerHome = state.triggerHome !== this.state_.triggerHome; - const updateTarget = - (viewsChanged || state.target !== this.state_?.target) && - state.target !== undefined; - const updateZScale = - viewsChanged || state.zScale !== this.state_?.zScale || triggerHome; - const updateViewState = - viewsChanged || - triggerHome || - (!state.viewStateChanged && - state.boundingBox3d !== this.state_.boundingBox3d); - const needUpdate = updateZScale || updateTarget || updateViewState; - - const isCacheEmpty = isEmpty(this.result_.viewState); - if (!isCacheEmpty && !needUpdate) { - return this.result_.viewState; - } - - let viewStateCloned = false; - - // initialize with last result - let viewState = this.result_.viewState; - - if (updateViewState || isCacheEmpty) { - viewState = buildDeckGlViewStates( - views, - state.viewPortMargins, - state.camera, - state.boundingBox3d, - state.bounds, - state.deckSize - ); - viewStateCloned = true; - // reset state - this.derivedState_.viewStateChanged = false; - } - - // check if view state could be computed - if (isEmpty(viewState)) { - return viewState; - } - - const viewStateKeys = Object.keys(viewState); - if ( - updateTarget && - this.derivedState_.target && - viewStateKeys?.length === 1 - ) { - // force copy - if (!viewStateCloned) { - viewState = cloneDeep(viewState); - viewStateCloned = true; - } - // update target - viewState[viewStateKeys[0]].target = this.derivedState_.target; - viewState[viewStateKeys[0]].transitionDuration = 1000; - // reset - this.derivedState_.target = undefined; - } - if (updateZScale) { - // force copy - if (!viewStateCloned) { - viewState = cloneDeep(viewState); - viewStateCloned = true; - } - // Z scale to apply to target. - // - if triggerHome: the target was recomputed from the input data (ie. without any scale applied) - // - otherwise: previous scale (ie. this.state_.zScale) was already applied, and must be "reverted" - const targetScale = - state.zScale / (triggerHome ? 1 : this.state_.zScale); - // update target - for (const key in viewState) { - const t = viewState[key].target; - if (t) { - viewState[key].target = [t[0], t[1], t[2] * targetScale]; - } - } - } - return viewState; - }; - - public readonly onViewStateChange = ( - viewId: string, - viewState: ViewStateType - ): void => { - const viewports = this.views_?.viewports ?? []; - if (viewState.target.length === 2) { - // In orthographic mode viewState.target contains only x and y. Add existing z value. - viewState.target.push(this.result_.viewState[viewId].target[2]); - } - const isSyncIds = viewports - .filter((item) => item.isSync) - .map((item) => item.id); - if (isSyncIds?.includes(viewId)) { - const viewStateTable = this.views_?.viewports - .filter((item) => item.isSync) - .map((item) => [item.id, viewState]); - const tempViewStates = Object.fromEntries(viewStateTable ?? []); - this.result_.viewState = { - ...this.result_.viewState, - ...tempViewStates, - }; - } else { - this.result_.viewState = { - ...this.result_.viewState, - [viewId]: viewState, - }; - } - this.derivedState_.viewStateChanged = true; - this.rerender_(); - }; -} - -/** - * Returns the zoom factor allowing to view the complete boundingBox. - * @param camera camera defining the view orientation. - * @param boundingBox 3D bounding box to visualize. - * @param fov field of view (see deck.gl file orbit-viewports.ts). - */ -function computeCameraZoom( - camera: ViewStateType, - boundingBox: BoundingBox3D, - size: Size, - fovy = 50 -): number { - const DEGREES_TO_RADIANS = Math.PI / 180; - const RADIANS_TO_DEGREES = 180 / Math.PI; - const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; - - const fD = fovyToAltitude(fovy); - - const xMin = boundingBox[0]; - const yMin = boundingBox[1]; - const zMin = boundingBox[2]; - - const xMax = boundingBox[3]; - const yMax = boundingBox[4]; - const zMax = boundingBox[5]; - - const target = [ - xMin + (xMax - xMin) / 2, - yMin + (yMax - yMin) / 2, - zMin + (zMax - zMin) / 2, - ]; - - const cameraFovVertical = 50; - const angle_ver = (cameraFovVertical / 2) * DEGREES_TO_RADIANS; - const L = size.height / 2 / Math.sin(angle_ver); - const r = L * Math.cos(angle_ver); - const cameraFov = 2 * Math.atan(size.width / 2 / r) * RADIANS_TO_DEGREES; - const angle_hor = (cameraFov / 2) * DEGREES_TO_RADIANS; - - const points: [number, number, number][] = []; - points.push([xMin, yMin, zMin]); - points.push([xMin, yMax, zMin]); - points.push([xMax, yMax, zMin]); - points.push([xMax, yMin, zMin]); - points.push([xMin, yMin, zMax]); - points.push([xMin, yMax, zMax]); - points.push([xMax, yMax, zMax]); - points.push([xMax, yMin, zMax]); - - let zoom = 999; - for (const point of points) { - const x_ = (point[0] - target[0]) / size.height; - const y_ = (point[1] - target[1]) / size.height; - const z_ = (point[2] - target[2]) / size.height; - - const m = new Matrix4(IDENTITY); - m.rotateX(camera.rotationX * DEGREES_TO_RADIANS); - m.rotateZ(camera.rotationOrbit * DEGREES_TO_RADIANS); - - const [x, y, z] = m.transformAsVector([x_, y_, z_]); - if (y >= 0) { - // These points will actually appear further away when zooming in. - continue; - } - - const fwX = fD * Math.tan(angle_hor); - let y_new = fwX / (Math.abs(x) / y - fwX / fD); - const zoom_x = Math.log2(y_new / y); - - const fwY = fD * Math.tan(angle_ver); - y_new = fwY / (Math.abs(z) / y - fwY / fD); - const zoom_z = Math.log2(y_new / y); - - // it needs to be inside view volume in both directions. - zoom = zoom_x < zoom ? zoom_x : zoom; - zoom = zoom_z < zoom ? zoom_z : zoom; - } - return zoom; -} - -/////////////////////////////////////////////////////////////////////////////////////////// -// return viewstate with computed bounds to fit the data in viewport -function getViewStateFromBounds( - viewPortMargins: MarginsType, - bounds_accessor: BoundingBox2D | BoundsAccessor, - target: [number, number, number], - views: ViewsType | undefined, - viewPort: ViewportType, - size: Size -): ViewStateType | undefined { - const bounds = - typeof bounds_accessor == "function" - ? bounds_accessor() - : bounds_accessor; - - let w = bounds[2] - bounds[0]; // right - left - let h = bounds[3] - bounds[1]; // top - bottom - - const z = target[2]; - - const fb = fitBounds({ width: w, height: h, bounds }); - let fb_target = [fb.x, fb.y, z]; - let fb_zoom = fb.zoom; - - if (size.width > 0 && size.height > 0) { - // If there are margins/rulers in the viewport (axes2DLayer) we have to account for that. - // Camera target should be in the middle of viewport minus the rulers. - const w_bounds = w; - const h_bounds = h; - - const ml = viewPortMargins.left; - const mr = viewPortMargins.right; - const mb = viewPortMargins.bottom; - const mt = viewPortMargins.top; - - // Subtract margins. - const marginH = (ml > 0 ? ml : 0) + (mr > 0 ? mr : 0); - const marginV = (mb > 0 ? mb : 0) + (mt > 0 ? mt : 0); - - w = size.width - marginH; // width of the viewport minus margin. - h = size.height - marginV; - - // Special case if matrix views. - // Use width and height for a sub-view instead of full viewport. - if (views?.layout) { - const [nY, nX] = views.layout; - const isMatrixViews = nX !== 1 || nY !== 1; - if (isMatrixViews) { - const mPixels = views?.marginPixels ?? 0; - - const w_ = 99.5 / nX; // Using 99.5% of viewport to avoid flickering of deckgl canvas - const h_ = 99.5 / nY; - - const marginHorPercentage = - 100 * 100 * (mPixels / (w_ * size.width)); //percentage of sub view - const marginVerPercentage = - 100 * 100 * (mPixels / (h_ * size.height)); - - const sub_w = (w_ / 100) * size.width; - const sub_h = (h_ / 100) * size.height; - - w = sub_w * (1 - 2 * (marginHorPercentage / 100)) - marginH; - h = sub_h * (1 - 2 * (marginVerPercentage / 100)) - marginV; - } - } - - const port_aspect = h / w; - const bounds_aspect = h_bounds / w_bounds; - - const m_pr_pixel = - bounds_aspect > port_aspect ? h_bounds / h : w_bounds / w; - - let translate_x = 0; - if (ml > 0 && mr === 0) { - // left margin and no right margin - translate_x = 0.5 * ml * m_pr_pixel; - } else if (ml === 0 && mr > 0) { - // no left margin but right margin - translate_x = -0.5 * mr * m_pr_pixel; - } - - let translate_y = 0; - if (mb > 0 && mt === 0) { - translate_y = 0.5 * mb * m_pr_pixel; - } else if (mb === 0 && mt > 0) { - translate_y = -0.5 * mt * m_pr_pixel; - } - - const fb = fitBounds({ width: w, height: h, bounds }); - fb_target = [fb.x - translate_x, fb.y - translate_y, z]; - fb_zoom = fb.zoom; - } - - const view_state: ViewStateType = { - target: viewPort.target ?? fb_target, - zoom: viewPort.zoom ?? fb_zoom, - rotationX: 90, // look down z -axis - rotationOrbit: 0, - minZoom: viewPort.show3D ? minZoom3D : minZoom2D, - maxZoom: viewPort.show3D ? maxZoom3D : maxZoom2D, - }; - return view_state; -} - -/////////////////////////////////////////////////////////////////////////////////////////// -// build views -type ViewTypeType = - | typeof ZScaleOrbitView - | typeof IntersectionView - | typeof OrthographicView; -function getVT( - viewport: ViewportType -): [ - ViewType: ViewTypeType, - Controller: typeof ZScaleOrbitController | typeof OrthographicController, -] { - if (viewport.show3D) { - return [ZScaleOrbitView, ZScaleOrbitController]; - } - return [ - viewport.id === "intersection_view" - ? IntersectionView - : OrthographicView, - OrthographicController, - ]; -} - -function areViewsValid(views: ViewsType | undefined, size: Size): boolean { - const isInvalid: boolean = - views?.viewports === undefined || - views?.layout === undefined || - !views?.layout?.[0] || - !views?.layout?.[1] || - !size.width || - !size.height; - return !isInvalid; -} - -/** returns a new View instance. */ -function newView( - viewport: ViewportType, - x: number | string, - y: number | string, - width: number | string, - height: number | string -): View { - const far = 9999; - const near = viewport.show3D ? 0.1 : -9999; - - const [ViewType, Controller] = getVT(viewport); - return new ViewType({ - id: viewport.id, - controller: { - type: Controller, - doubleClickZoom: false, - }, - - x, - y, - width, - height, - - flipY: false, - far, - near, - }); -} - -function buildDeckGlViews(views: ViewsType | undefined, size: Size): View[] { - const isOk = areViewsValid(views, size); - if (!views || !isOk) { - return [ - new OrthographicView({ - id: "main", - controller: { doubleClickZoom: false }, - x: "0%", - y: "0%", - width: "100%", - height: "100%", - flipY: false, - far: +99999, - near: -99999, - }), - ]; - } - - // compute - - const [nY, nX] = views.layout; - // compute for single view (code is more readable) - const singleView = nX === 1 && nY === 1; - if (singleView) { - // Using 99.5% of viewport to avoid flickering of deckgl canvas - return [newView(views.viewports[0], 0, 0, "95%", "95%")]; - } - - // compute for matrix - const result: View[] = []; - const w = 99.5 / nX; // Using 99.5% of viewport to avoid flickering of deckgl canvas - const h = 99.5 / nY; - const marginPixels = views.marginPixels ?? 0; - const marginHorPercentage = 100 * 100 * (marginPixels / (w * size.width)); - const marginVerPercentage = 100 * 100 * (marginPixels / (h * size.height)); - let yPos = 0; - for (let y = 1; y <= nY; y++) { - let xPos = 0; - for (let x = 1; x <= nX; x++) { - if (result.length >= views.viewports.length) { - // stop when all the viewports are filled - return result; - } - - const currentViewport: ViewportType = - views.viewports[result.length]; - - const viewX = xPos + marginHorPercentage / nX + "%"; - const viewY = yPos + marginVerPercentage / nY + "%"; - const viewWidth = w * (1 - 2 * (marginHorPercentage / 100)) + "%"; - const viewHeight = h * (1 - 2 * (marginVerPercentage / 100)) + "%"; - - result.push( - newView(currentViewport, viewX, viewY, viewWidth, viewHeight) - ); - xPos = xPos + w; - } - yPos = yPos + h; - } - return result; -} - -/** - * Returns the camera if it is fully specified (ie. the zoom is a valid number), otherwise computes - * the zoom to visualize the complete camera boundingBox if set, the provided boundingBox otherwise. - * @param camera input camera - * @param boundingBox fallback bounding box, if the camera zoom is not zoom a value nor a bounding box - */ -function updateViewState( - camera: ViewStateType, - boundingBox: BoundingBox3D | undefined, - size: Size -): ViewStateType { - if (typeof camera.zoom === "number" && !Number.isNaN(camera.zoom)) { - return camera; - } - - // update the camera to see the whole boundingBox - if (Array.isArray(camera.zoom)) { - boundingBox = camera.zoom as BoundingBox3D; - } - - // return the camera if the bounding box is undefined - if (boundingBox === undefined) { - return camera; - } - - // clone the camera in case of triggerHome - const camera_ = cloneDeep(camera); - camera_.zoom = computeCameraZoom(camera, boundingBox, size); - camera_.target = boxCenter(boundingBox); - camera_.minZoom = camera_.minZoom ?? minZoom3D; - camera_.maxZoom = camera_.maxZoom ?? maxZoom3D; - return camera_; -} - -/** - * - * @returns Computes the view state - */ -function computeViewState( - viewPort: ViewportType, - cameraPosition: ViewStateType | undefined, - boundingBox: BoundingBox3D | undefined, - bounds: BoundingBox2D | BoundsAccessor | undefined, - viewportMargins: MarginsType, - views: ViewsType | undefined, - size: Size -): ViewStateType | undefined { - // If the camera is defined, use it - const isCameraPositionDefined = cameraPosition !== undefined; - const isBoundsDefined = bounds !== undefined; - - if (viewPort.show3D ?? false) { - // If the camera is defined, use it - if (isCameraPositionDefined) { - return updateViewState(cameraPosition, boundingBox, size); - } - - // deprecated in 3D, kept for backward compatibility - if (isBoundsDefined) { - const centerOfData: [number, number, number] = boundingBox - ? boxCenter(boundingBox) - : [0, 0, 0]; - return getViewStateFromBounds( - viewportMargins, - bounds, - centerOfData, - views, - viewPort, - size - ); - } - const defaultCamera = { - target: [], - zoom: NaN, - rotationX: 45, // look down z -axis at 45 degrees - rotationOrbit: 0, - }; - return updateViewState(defaultCamera, boundingBox, size); - } else { - const centerOfData: [number, number, number] = boundingBox - ? boxCenter(boundingBox) - : [0, 0, 0]; - // if bounds are defined, use them - if (isBoundsDefined) { - return getViewStateFromBounds( - viewportMargins, - bounds, - centerOfData, - views, - viewPort, - size - ); - } - - // deprecated in 2D, kept for backward compatibility - if (isCameraPositionDefined) { - return cameraPosition; - } - - return boundingBox - ? getViewStateFromBounds( - viewportMargins, - // use the bounding box to extract the 2D bounds - [ - boundingBox[0], - boundingBox[1], - boundingBox[3], - boundingBox[4], - ], - centerOfData, - views, - viewPort, - size - ) - : undefined; - } -} - -function buildDeckGlViewStates( - views: ViewsType | undefined, - viewPortMargins: MarginsType, - cameraPosition: ViewStateType | undefined, - boundingBox: BoundingBox3D | undefined, - bounds: BoundingBox2D | BoundsAccessor | undefined, - size: Size -): Record { - const isOk = areViewsValid(views, size); - if (!views || !isOk) { - return {}; - } - - // compute - - const [nY, nX] = views.layout; - // compute for single view (code is more readable) - const singleView = nX === 1 && nY === 1; - if (singleView) { - const viewState = computeViewState( - views.viewports[0], - cameraPosition, - boundingBox, - bounds, - viewPortMargins, - views, - size - ); - return viewState ? { [views.viewports[0].id]: viewState } : {}; - } - - // compute for matrix - let result: Record = {} as Record< - string, - ViewStateType - >; - for (let y = 1; y <= nY; y++) { - for (let x = 1; x <= nX; x++) { - const resultLength = Object.keys(result).length; - if (resultLength >= views.viewports.length) { - // stop when all the viewports are filled - return result; - } - const currentViewport: ViewportType = views.viewports[resultLength]; - const currentViewState = computeViewState( - currentViewport, - cameraPosition, - boundingBox, - bounds, - viewPortMargins, - views, - size - ); - if (currentViewState) { - result = { - ...result, - [currentViewport.id]: currentViewState, - }; - } - } - } - return result; -} - -function handleMouseEvent( - type: "click" | "hover", - infos: PickingInfo[], - event: MjolnirEvent -) { - const ev: MapMouseEvent = { - type: type, - infos: infos, - }; - if (ev.type === "click") { - if ((event as MjolnirPointerEvent).rightButton) ev.type = "contextmenu"; - } - for (const info of infos as LayerPickInfo[]) { - if (info.coordinate) { - ev.x = info.coordinate[0]; - ev.y = info.coordinate[1]; - } - if (info.layer && info.layer.id === "wells-layer") { - // info.object is Feature or WellLog; - { - // try to use Object info (see DeckGL getToolTip callback) - const feat = info.object as Feature; - const properties = feat?.properties; - if (properties) { - ev.wellname = properties["name"]; - ev.wellcolor = properties["color"]; - } - } - - if (!ev.wellname) - if (info.object) { - ev.wellname = info.object.header?.["well"]; // object is WellLog - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if (info.properties) { - for (const property of info.properties) { - if (!ev.wellcolor) ev.wellcolor = property.color; - let propname = property.name; - if (propname) { - const sep = propname.indexOf(" "); - if (sep >= 0) { - if (!ev.wellname) { - ev.wellname = propname.substring(sep + 1); - } - propname = propname.substring(0, sep); - } - } - const names_md = [ - "DEPTH", - "DEPT", - "MD" /*Measured Depth*/, - "TDEP" /*"Tool DEPth"*/, - "MD_RKB" /*Rotary Relly Bushing*/, - ]; // aliases for MD - const names_tvd = [ - "TVD" /*True Vertical Depth*/, - "TVDSS" /*SubSea*/, - "DVER" /*"VERtical Depth"*/, - "TVD_MSL" /*below Mean Sea Level*/, - ]; // aliases for MD - - if (names_md.find((name) => name == propname)) - ev.md = parseFloat(property.value as string); - else if (names_tvd.find((name) => name == propname)) - ev.tvd = parseFloat(property.value as string); - - if ( - ev.md !== undefined && - ev.tvd !== undefined && - ev.wellname !== undefined - ) - break; - } - } - break; - } - } - return ev; -} +import React, { useEffect, useState, useCallback, useMemo } from "react"; + +import type { Feature, FeatureCollection } from "geojson"; +import { cloneDeep, isEmpty } from "lodash"; + +import type { + MjolnirEvent, + MjolnirGestureEvent, + MjolnirKeyEvent, + MjolnirPointerEvent, +} from "mjolnir.js"; + +import { JSONConfiguration, JSONConverter } from "@deck.gl/json/typed"; +import type { DeckGLRef } from "@deck.gl/react/typed"; +import DeckGL from "@deck.gl/react/typed"; +import type { + Color, + Layer, + LayersList, + LayerProps, + LayerContext, + View, + Viewport, + PickingInfo, +} from "@deck.gl/core/typed"; +import { + _CameraLight as CameraLight, + AmbientLight, + DirectionalLight, + LightingEffect, + OrbitController, + OrbitView, + OrthographicController, + OrthographicView, + PointLight, +} from "@deck.gl/core/typed"; +import { LineLayer } from "@deck.gl/layers/typed"; + +import { Matrix4 } from "@math.gl/core"; +import { fovyToAltitude } from "@math.gl/web-mercator"; + +import { colorTables } from "@emerson-eps/color-tables"; +import type { colorTablesArray } from "@emerson-eps/color-tables/"; + +import type { BoundingBox3D } from "../utils/BoundingBox3D"; +import { boxCenter, boxUnion } from "../utils/BoundingBox3D"; +import JSON_CONVERTER_CONFIG from "../utils/configuration"; +import type { WellsPickInfo } from "../layers/wells/wellsLayer"; +import InfoCard from "./InfoCard"; +import DistanceScale from "./DistanceScale"; +import StatusIndicator from "./StatusIndicator"; +import fitBounds from "../utils/fit-bounds"; +import { validateColorTables, validateLayers } from "@webviz/wsc-common"; +import type { LayerPickInfo } from "../layers/utils/layerTools"; +import { + getModelMatrixScale, + getLayersByType, + getWellLayerByTypeAndSelectedWells, +} from "../layers/utils/layerTools"; +import { WellsLayer, Axes2DLayer, NorthArrow3DLayer } from "../layers"; + +import IntersectionView from "../views/intersectionView"; +import type { Unit } from "convert-units"; +import type { LightsType } from "../SubsurfaceViewer"; + +/** + * 3D bounding box defined as [xmin, ymin, zmin, xmax, ymax, zmax]. + */ +export type { BoundingBox3D }; +/** + * 2D bounding box defined as [left, bottom, right, top] + */ +export type BoundingBox2D = [number, number, number, number]; +/** + * Type of the function returning coordinate boundary for the view defined as [left, bottom, right, top]. + */ +export type BoundsAccessor = () => BoundingBox2D; + +type Size = { + width: number; + height: number; +}; + +const minZoom3D = -12; +const maxZoom3D = 12; +const minZoom2D = -12; +const maxZoom2D = 4; + +// https://developer.mozilla.org/docs/Web/API/KeyboardEvent +type ArrowEvent = { + key: "ArrowUp" | "ArrowDown" | "PageUp" | "PageDown"; + shiftModifier: boolean; + // altModifier: boolean; + // ctrlModifier: boolean; +}; + +function updateZScaleReducer(zScale: number, action: ArrowEvent): number { + return zScale * getZScaleModifier(action); +} + +function getZScaleModifier(arrowEvent: ArrowEvent): number { + let scaleFactor = 0; + switch (arrowEvent.key) { + case "ArrowUp": + scaleFactor = 0.05; + break; + case "ArrowDown": + scaleFactor = -0.05; + break; + case "PageUp": + scaleFactor = 0.25; + break; + case "PageDown": + scaleFactor = -0.25; + break; + default: + break; + } + if (arrowEvent.shiftModifier) { + scaleFactor /= 5; + } + return 1 + scaleFactor; +} + +function convertToArrowEvent(event: MjolnirEvent): ArrowEvent | null { + if (event.type === "keydown") { + const keyEvent = event as MjolnirKeyEvent; + switch (keyEvent.key) { + case "ArrowUp": + case "ArrowDown": + case "PageUp": + case "PageDown": + return { + key: keyEvent.key, + shiftModifier: keyEvent.srcEvent.shiftKey, + }; + default: + return null; + } + } + return null; +} + +class ZScaleOrbitController extends OrbitController { + static updateZScaleAction: React.Dispatch | null = null; + + static setUpdateZScaleAction( + updateZScaleAction: React.Dispatch + ) { + ZScaleOrbitController.updateZScaleAction = updateZScaleAction; + } + + handleEvent(event: MjolnirEvent): boolean { + if (ZScaleOrbitController.updateZScaleAction) { + const arrowEvent = convertToArrowEvent(event); + if (arrowEvent) { + ZScaleOrbitController.updateZScaleAction(arrowEvent); + return true; + } + } + return super.handleEvent(event); + } +} + +class ZScaleOrbitView extends OrbitView { + get ControllerType(): typeof OrbitController { + return ZScaleOrbitController; + } +} + +function parseLights(lights?: LightsType): LightingEffect[] | undefined { + if (!lights) { + return undefined; + } + + const effects = []; + let lightsObj = {}; + + if (lights.headLight) { + const headLight = new CameraLight({ + intensity: lights.headLight.intensity, + color: lights.headLight.color ?? [255, 255, 255], + }); + lightsObj = { ...lightsObj, headLight }; + } + + if (lights.ambientLight) { + const ambientLight = new AmbientLight({ + intensity: lights.ambientLight.intensity, + color: lights.ambientLight.color ?? [255, 255, 255], + }); + lightsObj = { ...lightsObj, ambientLight }; + } + + if (lights.pointLights) { + for (const light of lights.pointLights) { + const pointLight = new PointLight({ + ...light, + color: light.color ?? [255, 255, 255], + }); + lightsObj = { ...lightsObj, pointLight }; + } + } + + if (lights.directionalLights) { + for (const light of lights.directionalLights) { + const directionalLight = new DirectionalLight({ + ...light, + color: light.color ?? [255, 255, 255], + }); + lightsObj = { ...lightsObj, directionalLight }; + } + } + + const lightingEffect = new LightingEffect(lightsObj); + effects.push(lightingEffect); + + return effects; +} + +export type ReportBoundingBoxAction = { layerBoundingBox: BoundingBox3D }; +function mapBoundingBoxReducer( + mapBoundingBox: BoundingBox3D | undefined, + action: ReportBoundingBoxAction +): BoundingBox3D | undefined { + return boxUnion(mapBoundingBox, action.layerBoundingBox); +} + +// Exclude "layerIds" when monitoring changes to "view" prop as we do not +// want to recalculate views when the layers change. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error +function compareViewsProp(views: ViewsType | undefined): string | undefined { + if (views === undefined) { + return undefined; + } + + const copy = cloneDeep(views); + const viewports = copy.viewports.map((e) => { + delete e.layerIds; + return e; + }); + copy.viewports = viewports; + return JSON.stringify(copy); +} + +export type TooltipCallback = ( + info: PickingInfo +) => string | Record | null; + +/** + * Views + */ +export interface ViewsType { + /** + * Layout for viewport in specified as [row, column]. + */ + layout: [number, number]; + + /** + * Number of pixels used for the margin in matrix mode. + * Defaults to 0. + */ + marginPixels?: number; + + /** + * Show views label. + */ + showLabel?: boolean; + + /** + * Layers configuration for multiple viewports. + */ + viewports: ViewportType[]; +} + +/** + * Viewport type. + */ +export interface ViewportType { + /** + * Viewport id + */ + id: string; + + /** + * Viewport name + */ + name?: string; + + /** + * If true, displays map in 3D view, default is 2D view (false) + */ + show3D?: boolean; + + /** + * Layers to be displayed on viewport + */ + layerIds?: string[]; + + target?: [number, number]; + zoom?: number; + rotationX?: number; + rotationOrbit?: number; + + isSync?: boolean; +} + +/** + * Camera view state. + */ +export interface ViewStateType { + target: number[]; + zoom: number | BoundingBox3D | undefined; + rotationX: number; + rotationOrbit: number; + minZoom?: number; + maxZoom?: number; + transitionDuration?: number; +} + +interface MarginsType { + left: number; + right: number; + top: number; + bottom: number; +} + +export interface DeckGLLayerContext extends LayerContext { + userData: { + setEditedData: (data: Record) => void; + colorTables: colorTablesArray; + }; +} + +export interface MapMouseEvent { + type: "click" | "hover" | "contextmenu"; + infos: PickingInfo[]; + // some frequently used values extracted from infos[]: + x?: number; + y?: number; + // Only for one well. Full information is available in infos[] + wellname?: string; + wellcolor?: Color; // well color + md?: number; + tvd?: number; +} + +export type EventCallback = (event: MapMouseEvent) => void; + +export function useHoverInfo(): [PickingInfo[], EventCallback] { + const [hoverInfo, setHoverInfo] = useState([]); + const callback = useCallback((pickEvent: MapMouseEvent) => { + setHoverInfo(pickEvent.infos); + }, []); + return [hoverInfo, callback]; +} + +export interface MapProps { + /** + * The ID of this component, used to identify dash components + * in callbacks. The ID needs to be unique across all of the + * components in an app. + */ + id: string; + + /** + * Resource dictionary made available in the DeckGL specification as an enum. + * The values can be accessed like this: `"@@#resources.resourceId"`, where + * `resourceId` is the key in the `resources` dict. For more information, + * see the DeckGL documentation on enums in the json spec: + * https://deck.gl/docs/api-reference/json/conversion-reference#enumerations-and-using-the--prefix + */ + resources?: Record; + + /* List of JSON object containing layer specific data. + * Each JSON object will consist of layer type with key as "@@type" and + * layer specific data, if any. + */ + layers?: LayersList; + + /** + * Coordinate boundary for the view defined as [left, bottom, right, top]. + * Should be used for 2D view only. + */ + bounds?: BoundingBox2D | BoundsAccessor; + + /** + * Camera state for the view defined as a ViewStateType. + * Should be used for 3D view only. + * If the zoom is given as a 3D bounding box, the camera state is computed to + * display the full box. + */ + cameraPosition?: ViewStateType; + + /** + * If changed will reset view settings (bounds or camera) to default position. + */ + triggerHome?: number; + + /** + * Views configuration for map. If not specified, all the layers will be + * displayed in a single 2D viewport + */ + views?: ViewsType; + + /** + * Parameters for the InfoCard component + */ + coords?: { + visible?: boolean | null; + multiPicking?: boolean | null; + pickDepth?: number | null; + }; + + /** + * Parameters for the Distance Scale component + */ + scale?: { + visible?: boolean | null; + incrementValue?: number | null; + widthPerUnit?: number | null; + cssStyle?: Record | null; + }; + + coordinateUnit?: Unit; + + /** + * Parameters to control toolbar + */ + toolbar?: { + visible?: boolean | null; + }; + + /** + * Prop containing color table data + */ + colorTables?: colorTablesArray; + + /** + * Prop containing edited data from layers + */ + editedData?: Record; + + /** + * For reacting to prop changes + */ + setEditedData?: (data: Record) => void; + + /** + * Validate JSON datafile against schema + */ + checkDatafileSchema?: boolean; + + /** + * For get mouse events + */ + onMouseEvent?: EventCallback; + + getCameraPosition?: (input: ViewStateType) => void; + + /** + * Will be called after all layers have rendered data. + */ + isRenderedCallback?: (arg: boolean) => void; + + onDragStart?: (info: PickingInfo, event: MjolnirGestureEvent) => void; + onDragEnd?: (info: PickingInfo, event: MjolnirGestureEvent) => void; + + triggerResetMultipleWells?: number; + selection?: { + well: string | undefined; + selection: [number | undefined, number | undefined] | undefined; + }; + + lights?: LightsType; + + children?: React.ReactNode; + + getTooltip?: TooltipCallback; +} + +function defaultTooltip(info: PickingInfo) { + if ((info as WellsPickInfo)?.logName) { + return (info as WellsPickInfo)?.logName; + } else if (info.layer?.id === "drawing-layer") { + return (info as LayerPickInfo).propertyValue?.toFixed(2); + } + const feat = info.object as Feature; + return feat?.properties?.["name"]; +} + +const Map: React.FC = ({ + id, + layers, + bounds, + cameraPosition, + triggerHome, + views, + coords, + scale, + coordinateUnit, + colorTables, + setEditedData, + checkDatafileSchema, + onMouseEvent, + selection, + children, + getTooltip = defaultTooltip, + getCameraPosition, + isRenderedCallback, + onDragStart, + onDragEnd, + lights, + triggerResetMultipleWells, +}: MapProps) => { + // From react doc, ref should not be read nor modified during rendering. + const deckRef = React.useRef(null); + + const [applyViewController, forceUpdate] = React.useReducer( + (x) => x + 1, + 0 + ); + const viewController = useMemo(() => new ViewController(forceUpdate), []); + + // Extract the needed size from onResize function + const [deckSize, setDeckSize] = useState({ width: 0, height: 0 }); + const onResize = useCallback((size: Size) => { + // exclude {0, 0} size (when rendered hidden pages) + if (size.width > 0 && size.height > 0) { + setDeckSize((prevSize: Size) => { + if ( + prevSize?.width !== size.width || + prevSize?.height !== size.height + ) { + return size; + } + return prevSize; + }); + } + }, []); + + // 3d bounding box computed from the layers + const [dataBoundingBox3d, dispatchBoundingBox] = React.useReducer( + mapBoundingBoxReducer, + undefined + ); + + // Used for scaling in z direction using arrow keys. + const [zScale, updateZScale] = React.useReducer(updateZScaleReducer, 1); + React.useEffect(() => { + ZScaleOrbitController.setUpdateZScaleAction(updateZScale); + }, [updateZScale]); + + // compute the viewport margins + const viewPortMargins = React.useMemo(() => { + if (!layers?.length) { + return { + left: 0, + right: 0, + top: 0, + bottom: 0, + }; + } + // Margins on the viewport are extracted from a potential axes2D layer. + const axes2DLayer = layers?.find((e) => { + return e?.constructor === Axes2DLayer; + }) as Axes2DLayer; + + const axes2DProps = axes2DLayer?.props; + return { + left: axes2DProps?.isLeftRuler ? axes2DProps.marginH : 0, + right: axes2DProps?.isRightRuler ? axes2DProps.marginH : 0, + top: axes2DProps?.isTopRuler ? axes2DProps.marginV : 0, + bottom: axes2DProps?.isBottomRuler ? axes2DProps.marginV : 0, + }; + }, [layers]); + + // selection + useEffect(() => { + const layers = deckRef.current?.deck?.props.layers; + if (layers) { + const wellslayer = getLayersByType( + layers, + WellsLayer.name + )?.[0] as WellsLayer; + + wellslayer?.setSelection(selection?.well, selection?.selection); + } + }, [selection]); + + // multiple well layers + const [multipleWells, setMultipleWells] = useState([]); + const [selectedWell, setSelectedWell] = useState(""); + const [shiftHeld, setShiftHeld] = useState(false); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function downHandler({ key }: any) { + if (key === "Shift") { + setShiftHeld(true); + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function upHandler({ key }: any) { + if (key === "Shift") { + setShiftHeld(false); + } + } + + useEffect(() => { + window.addEventListener("keydown", downHandler); + window.addEventListener("keyup", upHandler); + return () => { + window.removeEventListener("keydown", downHandler); + window.removeEventListener("keyup", upHandler); + }; + }, []); + + useEffect(() => { + const layers = deckRef.current?.deck?.props.layers; + if (layers) { + const wellslayer = getWellLayerByTypeAndSelectedWells( + layers, + "WellsLayer", + selectedWell + )?.[0] as WellsLayer; + wellslayer?.setMultiSelection(multipleWells); + } + }, [multipleWells, selectedWell]); + + useEffect(() => { + if (typeof triggerResetMultipleWells !== "undefined") { + setMultipleWells([]); + } + }, [triggerResetMultipleWells]); + + const getPickingInfos = useCallback( + ( + pickInfo: PickingInfo, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + event: any + ): PickingInfo[] => { + if (coords?.multiPicking && pickInfo.layer?.context.deck) { + const pickInfos = + pickInfo.layer.context.deck.pickMultipleObjects({ + x: event.offsetCenter.x, + y: event.offsetCenter.y, + depth: coords.pickDepth ? coords.pickDepth : undefined, + unproject3D: true, + }) as LayerPickInfo[]; + pickInfos.forEach((item) => { + if (item.properties) { + let unit = ( + item.sourceLayer?.props + .data as unknown as FeatureCollection & { + unit: string; + } + )?.unit; + if (unit == undefined) unit = " "; + item.properties.forEach((element) => { + if ( + element.name.includes("MD") || + element.name.includes("TVD") + ) { + element.value = + Number(element.value) + .toFixed(2) + .toString() + + " " + + unit; + } + }); + } + }); + return pickInfos; + } + return [pickInfo]; + }, + [coords?.multiPicking, coords?.pickDepth] + ); + + /** + * call onMouseEvent callback + */ + const callOnMouseEvent = useCallback( + ( + type: "click" | "hover", + infos: PickingInfo[], + event: MjolnirEvent + ): void => { + if ( + (event as MjolnirPointerEvent).leftButton && + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + event.tapCount == 2 // Note. Detect double click. + ) { + // Left button click identifies new camera rotation anchor. + if (infos.length >= 1) { + if (infos[0].coordinate) { + viewController.setTarget( + infos[0].coordinate as [number, number, number] + ); + } + } + } + + if (!onMouseEvent) return; + const ev = handleMouseEvent(type, infos, event); + onMouseEvent(ev); + }, + [onMouseEvent, viewController] + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [hoverInfo, setHoverInfo] = useState([]); + const onHover = useCallback( + (pickInfo: PickingInfo, event: MjolnirEvent) => { + const infos = getPickingInfos(pickInfo, event); + setHoverInfo(infos); // for InfoCard pickInfos + callOnMouseEvent?.("hover", infos, event); + }, + [callOnMouseEvent, getPickingInfos] + ); + + const onClick = useCallback( + (pickInfo: PickingInfo, event: MjolnirEvent) => { + const infos = getPickingInfos(pickInfo, event); + callOnMouseEvent?.("click", infos, event); + }, + [callOnMouseEvent, getPickingInfos] + ); + + const deckGLLayers = React.useMemo(() => { + if (!layers) { + return []; + } + if (layers.length === 0) { + // Empty layers array makes deck.gl set deckRef to undefined (no OpenGL context). + // Hence insert dummy layer. + const dummy_layer = new LineLayer({ + id: "webviz_internal_dummy_layer", + visible: false, + }); + layers.push(dummy_layer); + } + + const m = getModelMatrixScale(zScale); + + return layers.map((item) => { + if (item?.constructor.name === NorthArrow3DLayer.name) { + return item; + } + + return (item as Layer).clone({ + // Inject "dispatchBoundingBox" function into layer for it to report back its respective bounding box. + // eslint-disable-next-line + // @ts-ignore + reportBoundingBox: dispatchBoundingBox, + // Set "modelLayer" matrix to reflect correct z scaling. + modelMatrix: m, + }); + }); + }, [layers, zScale]); + + const [isLoaded, setIsLoaded] = useState(false); + const onAfterRender = useCallback(() => { + if (deckGLLayers) { + const loadedState = deckGLLayers.every((layer) => { + return ( + (layer as Layer).isLoaded || !(layer as Layer).props.visible + ); + }); + + const emptyLayers = // There will always be a dummy layer. Deck.gl does not like empty array of layers. + deckGLLayers.length == 1 && + (deckGLLayers[0] as LineLayer).id === + "webviz_internal_dummy_layer"; + + setIsLoaded(loadedState || emptyLayers); + if (isRenderedCallback) { + isRenderedCallback(loadedState); + } + } + }, [deckGLLayers, isRenderedCallback]); + + // validate layers data + const [errorText, setErrorText] = useState(); + useEffect(() => { + const layers = deckRef.current?.deck?.props.layers as Layer[]; + // this ensures to validate the schemas only once + if (checkDatafileSchema && layers && isLoaded) { + try { + validateLayers(layers); + colorTables && validateColorTables(colorTables); + } catch (e) { + setErrorText(String(e)); + } + } else setErrorText(undefined); + }, [ + checkDatafileSchema, + colorTables, + deckRef?.current?.deck?.props.layers, + isLoaded, + ]); + + const layerFilter = useCallback( + (args: { layer: Layer; viewport: Viewport }): boolean => { + // display all the layers if views are not specified correctly + if (!views?.viewports || !views?.layout) { + return true; + } + + const cur_view = views.viewports.find( + ({ id }) => args.viewport.id && id === args.viewport.id + ); + if (cur_view?.layerIds && cur_view.layerIds.length > 0) { + const layer_ids = cur_view.layerIds; + return layer_ids.some((layer_id) => { + const t = layer_id === args.layer.id; + return t; + }); + } else { + return true; + } + }, + [views] + ); + + const onViewStateChange = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ({ viewId, viewState }: { viewId: string; viewState: any }) => { + viewController.onViewStateChange(viewId, viewState); + if (getCameraPosition) { + getCameraPosition(viewState); + } + }, + [getCameraPosition, viewController] + ); + + const effects = parseLights(lights); + + const [deckGlViews, deckGlViewState] = useMemo(() => { + const state = { + triggerHome, + camera: cameraPosition, + bounds, + boundingBox3d: dataBoundingBox3d, + viewPortMargins, + deckSize, + zScale, + }; + return viewController.getViews(views, state); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + triggerHome, + cameraPosition, + bounds, + dataBoundingBox3d, + viewPortMargins, + deckSize, + views, + zScale, + applyViewController, + viewController, + ]); + + if (!deckGlViews || isEmpty(deckGlViews) || isEmpty(deckGLLayers)) + return null; + return ( +
event.preventDefault()}> + ) => { + setSelectedWell(updated_prop["selectedWell"] as string); + if ( + Object.keys(updated_prop).includes("selectedWell") + ) { + if (shiftHeld) { + if ( + multipleWells.includes( + updated_prop["selectedWell"] as string + ) + ) { + const temp = multipleWells.filter( + (item) => + item !== + updated_prop["selectedWell"] + ); + setMultipleWells(temp); + } else { + const temp = multipleWells.concat( + updated_prop["selectedWell"] as string + ); + setMultipleWells(temp); + } + } else { + setMultipleWells([]); + } + } + setEditedData?.(updated_prop); + }, + colorTables: colorTables, + }} + getCursor={({ isDragging }): string => + isDragging ? "grabbing" : "default" + } + getTooltip={getTooltip} + ref={deckRef} + onViewStateChange={onViewStateChange} + onHover={onHover} + onClick={onClick} + onAfterRender={onAfterRender} + effects={effects} + onDragStart={onDragStart} + onDragEnd={onDragEnd} + onResize={onResize} + > + {children} + + {scale?.visible ? ( + + ) : null} + + {coords?.visible ? : null} + {errorText && ( +
+                    {errorText}
+                
+ )} +
+ ); +}; + +Map.defaultProps = { + coords: { + visible: true, + multiPicking: true, + pickDepth: 10, + }, + scale: { + visible: true, + incrementValue: 100, + widthPerUnit: 100, + cssStyle: { top: 10, left: 10 }, + }, + toolbar: { + visible: false, + }, + coordinateUnit: "m", + views: { + layout: [1, 1], + showLabel: false, + viewports: [{ id: "main-view", show3D: false, layerIds: [] }], + }, + colorTables: colorTables, + checkDatafileSchema: false, +}; + +export default Map; + +// ------------- Helper functions ---------- // + +// Add the resources as an enum in the Json Configuration and then convert the spec to actual objects. +// See https://deck.gl/docs/api-reference/json/overview for more details. +export function jsonToObject( + data: Record[] | LayerProps[], + enums: Record[] | undefined = undefined +): LayersList | View[] { + if (!data) return []; + + const configuration = new JSONConfiguration(JSON_CONVERTER_CONFIG); + enums?.forEach((enumeration) => { + if (enumeration) { + configuration.merge({ + enumerations: { + ...enumeration, + }, + }); + } + }); + const jsonConverter = new JSONConverter({ configuration }); + + // remove empty data/layer object + const filtered_data = data.filter((value) => !isEmpty(value)); + return jsonConverter.convert(filtered_data); +} + +/////////////////////////////////////////////////////////////////////////////////////////// +// View Controller +// Implements the algorithms to compute the views and the view state +type ViewControllerState = { + // Explicit state + triggerHome: number | undefined; + camera: ViewStateType | undefined; + bounds: BoundingBox2D | BoundsAccessor | undefined; + boundingBox3d: BoundingBox3D | undefined; + deckSize: Size; + zScale: number; + viewPortMargins: MarginsType; +}; +type ViewControllerDerivedState = { + // Derived state + target: [number, number, number] | undefined; + viewStateChanged: boolean; +}; +type ViewControllerFullState = ViewControllerState & ViewControllerDerivedState; +class ViewController { + private rerender_: React.DispatchWithoutAction; + + private state_: ViewControllerFullState = { + triggerHome: undefined, + camera: undefined, + bounds: undefined, + boundingBox3d: undefined, + deckSize: { width: 0, height: 0 }, + zScale: 1, + viewPortMargins: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + // Derived state + target: undefined, + viewStateChanged: false, + }; + + private derivedState_: ViewControllerDerivedState = { + target: undefined, + viewStateChanged: false, + }; + + private views_: ViewsType | undefined = undefined; + private result_: { + views: View[]; + viewState: Record; + } = { + views: [], + viewState: {}, + }; + + public constructor(rerender: React.DispatchWithoutAction) { + this.rerender_ = rerender; + } + + public readonly setTarget = (target: [number, number, number]) => { + this.derivedState_.target = [target[0], target[1], target[2]]; + this.rerender_(); + }; + + public readonly getViews = ( + views: ViewsType | undefined, + state: ViewControllerState + ): [View[], Record] => { + const fullState = this.consolidateState(state); + const newViews = this.getDeckGlViews(views, fullState); + const newViewState = this.getDeckGlViewState(views, fullState); + + if ( + this.result_.views !== newViews || + this.result_.viewState !== newViewState + ) { + const viewsMsg = this.result_.views !== newViews ? " views" : ""; + const stateMsg = + this.result_.viewState !== newViewState ? " state" : ""; + const linkMsg = viewsMsg && stateMsg ? " and" : ""; + + console.log( + `ViewController returns new${viewsMsg}${linkMsg}${stateMsg}` + ); + } + + this.state_ = fullState; + this.views_ = views; + this.result_.views = newViews; + this.result_.viewState = newViewState; + return [newViews, newViewState]; + }; + + // consolidate "controlled" state (ie. set by parent) with "uncontrolled" state + private readonly consolidateState = ( + state: ViewControllerState + ): ViewControllerFullState => { + return { ...state, ...this.derivedState_ }; + }; + + // returns the DeckGL views (ie. view position and viewport) + private readonly getDeckGlViews = ( + views: ViewsType | undefined, + state: ViewControllerFullState + ) => { + const needUpdate = + views != this.views_ || state.deckSize != this.state_.deckSize; + if (!needUpdate) { + return this.result_.views; + } + return buildDeckGlViews(views, state.deckSize); + }; + + // returns the DeckGL views state(s) (ie. camera settings applied to individual views) + private readonly getDeckGlViewState = ( + views: ViewsType | undefined, + state: ViewControllerFullState + ): Record => { + const viewsChanged = views != this.views_; + const triggerHome = state.triggerHome !== this.state_.triggerHome; + const updateTarget = + (viewsChanged || state.target !== this.state_?.target) && + state.target !== undefined; + const updateZScale = + viewsChanged || state.zScale !== this.state_?.zScale || triggerHome; + const updateViewState = + viewsChanged || + triggerHome || + (!state.viewStateChanged && + state.boundingBox3d !== this.state_.boundingBox3d); + const needUpdate = updateZScale || updateTarget || updateViewState; + + const isCacheEmpty = isEmpty(this.result_.viewState); + if (!isCacheEmpty && !needUpdate) { + return this.result_.viewState; + } + + // initialize with last result + const prevViewState = this.result_.viewState; + let viewState = prevViewState; + + if (updateViewState || isCacheEmpty) { + viewState = buildDeckGlViewStates( + views, + state.viewPortMargins, + state.camera, + state.boundingBox3d, + state.bounds, + state.deckSize + ); + // reset state + this.derivedState_.viewStateChanged = false; + } + + // check if view state could be computed + if (isEmpty(viewState)) { + return viewState; + } + + const viewStateKeys = Object.keys(viewState); + if ( + updateTarget && + this.derivedState_.target && + viewStateKeys?.length === 1 + ) { + // deep clone to notify change (memo checks object address) + if (viewState === prevViewState) { + viewState = cloneDeep(prevViewState); + } + // update target + viewState[viewStateKeys[0]].target = this.derivedState_.target; + viewState[viewStateKeys[0]].transitionDuration = 1000; + // reset + this.derivedState_.target = undefined; + } + if (updateZScale) { + // deep clone to notify change (memo checks object address) + if (viewState === prevViewState) { + viewState = cloneDeep(prevViewState); + } + // Z scale to apply to target. + // - if triggerHome: the target was recomputed from the input data (ie. without any scale applied) + // - otherwise: previous scale (ie. this.state_.zScale) was already applied, and must be "reverted" + const targetScale = + state.zScale / (triggerHome ? 1 : this.state_.zScale); + // update target + for (const key in viewState) { + const t = viewState[key].target; + if (t) { + viewState[key].target = [t[0], t[1], t[2] * targetScale]; + } + } + } + return viewState; + }; + + public readonly onViewStateChange = ( + viewId: string, + viewState: ViewStateType + ): void => { + const viewports = this.views_?.viewports ?? []; + if (viewState.target.length === 2) { + // In orthographic mode viewState.target contains only x and y. Add existing z value. + viewState.target.push(this.result_.viewState[viewId].target[2]); + } + const isSyncIds = viewports + .filter((item) => item.isSync) + .map((item) => item.id); + if (isSyncIds?.includes(viewId)) { + const viewStateTable = this.views_?.viewports + .filter((item) => item.isSync) + .map((item) => [item.id, viewState]); + const tempViewStates = Object.fromEntries(viewStateTable ?? []); + this.result_.viewState = { + ...this.result_.viewState, + ...tempViewStates, + }; + } else { + this.result_.viewState = { + ...this.result_.viewState, + [viewId]: viewState, + }; + } + this.derivedState_.viewStateChanged = true; + this.rerender_(); + }; +} + +/** + * Returns the zoom factor allowing to view the complete boundingBox. + * @param camera camera defining the view orientation. + * @param boundingBox 3D bounding box to visualize. + * @param fov field of view (see deck.gl file orbit-viewports.ts). + */ +function computeCameraZoom( + camera: ViewStateType, + boundingBox: BoundingBox3D, + size: Size, + fovy = 50 +): number { + const DEGREES_TO_RADIANS = Math.PI / 180; + const RADIANS_TO_DEGREES = 180 / Math.PI; + const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + + const fD = fovyToAltitude(fovy); + + const xMin = boundingBox[0]; + const yMin = boundingBox[1]; + const zMin = boundingBox[2]; + + const xMax = boundingBox[3]; + const yMax = boundingBox[4]; + const zMax = boundingBox[5]; + + const target = [ + xMin + (xMax - xMin) / 2, + yMin + (yMax - yMin) / 2, + zMin + (zMax - zMin) / 2, + ]; + + const cameraFovVertical = 50; + const angle_ver = (cameraFovVertical / 2) * DEGREES_TO_RADIANS; + const L = size.height / 2 / Math.sin(angle_ver); + const r = L * Math.cos(angle_ver); + const cameraFov = 2 * Math.atan(size.width / 2 / r) * RADIANS_TO_DEGREES; + const angle_hor = (cameraFov / 2) * DEGREES_TO_RADIANS; + + const points: [number, number, number][] = []; + points.push([xMin, yMin, zMin]); + points.push([xMin, yMax, zMin]); + points.push([xMax, yMax, zMin]); + points.push([xMax, yMin, zMin]); + points.push([xMin, yMin, zMax]); + points.push([xMin, yMax, zMax]); + points.push([xMax, yMax, zMax]); + points.push([xMax, yMin, zMax]); + + let zoom = 999; + for (const point of points) { + const x_ = (point[0] - target[0]) / size.height; + const y_ = (point[1] - target[1]) / size.height; + const z_ = (point[2] - target[2]) / size.height; + + const m = new Matrix4(IDENTITY); + m.rotateX(camera.rotationX * DEGREES_TO_RADIANS); + m.rotateZ(camera.rotationOrbit * DEGREES_TO_RADIANS); + + const [x, y, z] = m.transformAsVector([x_, y_, z_]); + if (y >= 0) { + // These points will actually appear further away when zooming in. + continue; + } + + const fwX = fD * Math.tan(angle_hor); + let y_new = fwX / (Math.abs(x) / y - fwX / fD); + const zoom_x = Math.log2(y_new / y); + + const fwY = fD * Math.tan(angle_ver); + y_new = fwY / (Math.abs(z) / y - fwY / fD); + const zoom_z = Math.log2(y_new / y); + + // it needs to be inside view volume in both directions. + zoom = zoom_x < zoom ? zoom_x : zoom; + zoom = zoom_z < zoom ? zoom_z : zoom; + } + return zoom; +} + +/////////////////////////////////////////////////////////////////////////////////////////// +// return viewstate with computed bounds to fit the data in viewport +function getViewStateFromBounds( + viewPortMargins: MarginsType, + bounds_accessor: BoundingBox2D | BoundsAccessor, + target: [number, number, number], + views: ViewsType | undefined, + viewPort: ViewportType, + size: Size +): ViewStateType | undefined { + const bounds = + typeof bounds_accessor == "function" + ? bounds_accessor() + : bounds_accessor; + + let w = bounds[2] - bounds[0]; // right - left + let h = bounds[3] - bounds[1]; // top - bottom + + const z = target[2]; + + const fb = fitBounds({ width: w, height: h, bounds }); + let fb_target = [fb.x, fb.y, z]; + let fb_zoom = fb.zoom; + + if (size.width > 0 && size.height > 0) { + // If there are margins/rulers in the viewport (axes2DLayer) we have to account for that. + // Camera target should be in the middle of viewport minus the rulers. + const w_bounds = w; + const h_bounds = h; + + const ml = viewPortMargins.left; + const mr = viewPortMargins.right; + const mb = viewPortMargins.bottom; + const mt = viewPortMargins.top; + + // Subtract margins. + const marginH = (ml > 0 ? ml : 0) + (mr > 0 ? mr : 0); + const marginV = (mb > 0 ? mb : 0) + (mt > 0 ? mt : 0); + + w = size.width - marginH; // width of the viewport minus margin. + h = size.height - marginV; + + // Special case if matrix views. + // Use width and height for a sub-view instead of full viewport. + if (views?.layout) { + const [nY, nX] = views.layout; + const isMatrixViews = nX !== 1 || nY !== 1; + if (isMatrixViews) { + const mPixels = views?.marginPixels ?? 0; + + const w_ = 99.5 / nX; // Using 99.5% of viewport to avoid flickering of deckgl canvas + const h_ = 99.5 / nY; + + const marginHorPercentage = + 100 * 100 * (mPixels / (w_ * size.width)); //percentage of sub view + const marginVerPercentage = + 100 * 100 * (mPixels / (h_ * size.height)); + + const sub_w = (w_ / 100) * size.width; + const sub_h = (h_ / 100) * size.height; + + w = sub_w * (1 - 2 * (marginHorPercentage / 100)) - marginH; + h = sub_h * (1 - 2 * (marginVerPercentage / 100)) - marginV; + } + } + + const port_aspect = h / w; + const bounds_aspect = h_bounds / w_bounds; + + const m_pr_pixel = + bounds_aspect > port_aspect ? h_bounds / h : w_bounds / w; + + let translate_x = 0; + if (ml > 0 && mr === 0) { + // left margin and no right margin + translate_x = 0.5 * ml * m_pr_pixel; + } else if (ml === 0 && mr > 0) { + // no left margin but right margin + translate_x = -0.5 * mr * m_pr_pixel; + } + + let translate_y = 0; + if (mb > 0 && mt === 0) { + translate_y = 0.5 * mb * m_pr_pixel; + } else if (mb === 0 && mt > 0) { + translate_y = -0.5 * mt * m_pr_pixel; + } + + const fb = fitBounds({ width: w, height: h, bounds }); + fb_target = [fb.x - translate_x, fb.y - translate_y, z]; + fb_zoom = fb.zoom; + } + + const view_state: ViewStateType = { + target: viewPort.target ?? fb_target, + zoom: viewPort.zoom ?? fb_zoom, + rotationX: 90, // look down z -axis + rotationOrbit: 0, + minZoom: viewPort.show3D ? minZoom3D : minZoom2D, + maxZoom: viewPort.show3D ? maxZoom3D : maxZoom2D, + }; + return view_state; +} + +/////////////////////////////////////////////////////////////////////////////////////////// +// build views +type ViewTypeType = + | typeof ZScaleOrbitView + | typeof IntersectionView + | typeof OrthographicView; +function getVT( + viewport: ViewportType +): [ + ViewType: ViewTypeType, + Controller: typeof ZScaleOrbitController | typeof OrthographicController, +] { + if (viewport.show3D) { + return [ZScaleOrbitView, ZScaleOrbitController]; + } + return [ + viewport.id === "intersection_view" + ? IntersectionView + : OrthographicView, + OrthographicController, + ]; +} + +function areViewsValid(views: ViewsType | undefined, size: Size): boolean { + const isInvalid: boolean = + views?.viewports === undefined || + views?.layout === undefined || + !views?.layout?.[0] || + !views?.layout?.[1] || + !size.width || + !size.height; + return !isInvalid; +} + +/** returns a new View instance. */ +function newView( + viewport: ViewportType, + x: number | string, + y: number | string, + width: number | string, + height: number | string +): View { + const far = 9999; + const near = viewport.show3D ? 0.1 : -9999; + + const [ViewType, Controller] = getVT(viewport); + return new ViewType({ + id: viewport.id, + controller: { + type: Controller, + doubleClickZoom: false, + }, + + x, + y, + width, + height, + + flipY: false, + far, + near, + }); +} + +function buildDeckGlViews(views: ViewsType | undefined, size: Size): View[] { + const isOk = areViewsValid(views, size); + if (!views || !isOk) { + return [ + new OrthographicView({ + id: "main", + controller: { doubleClickZoom: false }, + x: "0%", + y: "0%", + width: "100%", + height: "100%", + flipY: false, + far: +99999, + near: -99999, + }), + ]; + } + + // compute + + const [nY, nX] = views.layout; + // compute for single view (code is more readable) + const singleView = nX === 1 && nY === 1; + if (singleView) { + // Using 99.5% of viewport to avoid flickering of deckgl canvas + return [newView(views.viewports[0], 0, 0, "95%", "95%")]; + } + + // compute for matrix + const result: View[] = []; + const w = 99.5 / nX; // Using 99.5% of viewport to avoid flickering of deckgl canvas + const h = 99.5 / nY; + const marginPixels = views.marginPixels ?? 0; + const marginHorPercentage = 100 * 100 * (marginPixels / (w * size.width)); + const marginVerPercentage = 100 * 100 * (marginPixels / (h * size.height)); + let yPos = 0; + for (let y = 1; y <= nY; y++) { + let xPos = 0; + for (let x = 1; x <= nX; x++) { + if (result.length >= views.viewports.length) { + // stop when all the viewports are filled + return result; + } + + const currentViewport: ViewportType = + views.viewports[result.length]; + + const viewX = xPos + marginHorPercentage / nX + "%"; + const viewY = yPos + marginVerPercentage / nY + "%"; + const viewWidth = w * (1 - 2 * (marginHorPercentage / 100)) + "%"; + const viewHeight = h * (1 - 2 * (marginVerPercentage / 100)) + "%"; + + result.push( + newView(currentViewport, viewX, viewY, viewWidth, viewHeight) + ); + xPos = xPos + w; + } + yPos = yPos + h; + } + return result; +} + +/** + * Returns the camera if it is fully specified (ie. the zoom is a valid number), otherwise computes + * the zoom to visualize the complete camera boundingBox if set, the provided boundingBox otherwise. + * @param camera input camera + * @param boundingBox fallback bounding box, if the camera zoom is not zoom a value nor a bounding box + */ +function updateViewState( + camera: ViewStateType, + boundingBox: BoundingBox3D | undefined, + size: Size +): ViewStateType { + if (typeof camera.zoom === "number" && !Number.isNaN(camera.zoom)) { + return camera; + } + + // update the camera to see the whole boundingBox + if (Array.isArray(camera.zoom)) { + boundingBox = camera.zoom as BoundingBox3D; + } + + // return the camera if the bounding box is undefined + if (boundingBox === undefined) { + return camera; + } + + // clone the camera in case of triggerHome + const camera_ = cloneDeep(camera); + camera_.zoom = computeCameraZoom(camera, boundingBox, size); + camera_.target = boxCenter(boundingBox); + camera_.minZoom = camera_.minZoom ?? minZoom3D; + camera_.maxZoom = camera_.maxZoom ?? maxZoom3D; + return camera_; +} + +/** + * + * @returns Computes the view state + */ +function computeViewState( + viewPort: ViewportType, + cameraPosition: ViewStateType | undefined, + boundingBox: BoundingBox3D | undefined, + bounds: BoundingBox2D | BoundsAccessor | undefined, + viewportMargins: MarginsType, + views: ViewsType | undefined, + size: Size +): ViewStateType | undefined { + // If the camera is defined, use it + const isCameraPositionDefined = cameraPosition !== undefined; + const isBoundsDefined = bounds !== undefined; + + if (viewPort.show3D ?? false) { + // If the camera is defined, use it + if (isCameraPositionDefined) { + return updateViewState(cameraPosition, boundingBox, size); + } + + // deprecated in 3D, kept for backward compatibility + if (isBoundsDefined) { + const centerOfData: [number, number, number] = boundingBox + ? boxCenter(boundingBox) + : [0, 0, 0]; + return getViewStateFromBounds( + viewportMargins, + bounds, + centerOfData, + views, + viewPort, + size + ); + } + const defaultCamera = { + target: [], + zoom: NaN, + rotationX: 45, // look down z -axis at 45 degrees + rotationOrbit: 0, + }; + return updateViewState(defaultCamera, boundingBox, size); + } else { + const centerOfData: [number, number, number] = boundingBox + ? boxCenter(boundingBox) + : [0, 0, 0]; + // if bounds are defined, use them + if (isBoundsDefined) { + return getViewStateFromBounds( + viewportMargins, + bounds, + centerOfData, + views, + viewPort, + size + ); + } + + // deprecated in 2D, kept for backward compatibility + if (isCameraPositionDefined) { + return cameraPosition; + } + + return boundingBox + ? getViewStateFromBounds( + viewportMargins, + // use the bounding box to extract the 2D bounds + [ + boundingBox[0], + boundingBox[1], + boundingBox[3], + boundingBox[4], + ], + centerOfData, + views, + viewPort, + size + ) + : undefined; + } +} + +function buildDeckGlViewStates( + views: ViewsType | undefined, + viewPortMargins: MarginsType, + cameraPosition: ViewStateType | undefined, + boundingBox: BoundingBox3D | undefined, + bounds: BoundingBox2D | BoundsAccessor | undefined, + size: Size +): Record { + const isOk = areViewsValid(views, size); + if (!views || !isOk) { + return {}; + } + + // compute + + const [nY, nX] = views.layout; + // compute for single view (code is more readable) + const singleView = nX === 1 && nY === 1; + if (singleView) { + const viewState = computeViewState( + views.viewports[0], + cameraPosition, + boundingBox, + bounds, + viewPortMargins, + views, + size + ); + return viewState ? { [views.viewports[0].id]: viewState } : {}; + } + + // compute for matrix + let result: Record = {} as Record< + string, + ViewStateType + >; + for (let y = 1; y <= nY; y++) { + for (let x = 1; x <= nX; x++) { + const resultLength = Object.keys(result).length; + if (resultLength >= views.viewports.length) { + // stop when all the viewports are filled + return result; + } + const currentViewport: ViewportType = views.viewports[resultLength]; + const currentViewState = computeViewState( + currentViewport, + cameraPosition, + boundingBox, + bounds, + viewPortMargins, + views, + size + ); + if (currentViewState) { + result = { + ...result, + [currentViewport.id]: currentViewState, + }; + } + } + } + return result; +} + +function handleMouseEvent( + type: "click" | "hover", + infos: PickingInfo[], + event: MjolnirEvent +) { + const ev: MapMouseEvent = { + type: type, + infos: infos, + }; + if (ev.type === "click") { + if ((event as MjolnirPointerEvent).rightButton) ev.type = "contextmenu"; + } + for (const info of infos as LayerPickInfo[]) { + if (info.coordinate) { + ev.x = info.coordinate[0]; + ev.y = info.coordinate[1]; + } + if (info.layer && info.layer.id === "wells-layer") { + // info.object is Feature or WellLog; + { + // try to use Object info (see DeckGL getToolTip callback) + const feat = info.object as Feature; + const properties = feat?.properties; + if (properties) { + ev.wellname = properties["name"]; + ev.wellcolor = properties["color"]; + } + } + + if (!ev.wellname) + if (info.object) { + ev.wellname = info.object.header?.["well"]; // object is WellLog + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (info.properties) { + for (const property of info.properties) { + if (!ev.wellcolor) ev.wellcolor = property.color; + let propname = property.name; + if (propname) { + const sep = propname.indexOf(" "); + if (sep >= 0) { + if (!ev.wellname) { + ev.wellname = propname.substring(sep + 1); + } + propname = propname.substring(0, sep); + } + } + const names_md = [ + "DEPTH", + "DEPT", + "MD" /*Measured Depth*/, + "TDEP" /*"Tool DEPth"*/, + "MD_RKB" /*Rotary Relly Bushing*/, + ]; // aliases for MD + const names_tvd = [ + "TVD" /*True Vertical Depth*/, + "TVDSS" /*SubSea*/, + "DVER" /*"VERtical Depth"*/, + "TVD_MSL" /*below Mean Sea Level*/, + ]; // aliases for MD + + if (names_md.find((name) => name == propname)) + ev.md = parseFloat(property.value as string); + else if (names_tvd.find((name) => name == propname)) + ev.tvd = parseFloat(property.value as string); + + if ( + ev.md !== undefined && + ev.tvd !== undefined && + ev.wellname !== undefined + ) + break; + } + } + break; + } + } + return ev; +} diff --git a/typescript/packages/subsurface-viewer/src/layers/map/mapLayer.stories.tsx b/typescript/packages/subsurface-viewer/src/layers/map/mapLayer.stories.tsx index b4d401c521..8cbc234643 100644 --- a/typescript/packages/subsurface-viewer/src/layers/map/mapLayer.stories.tsx +++ b/typescript/packages/subsurface-viewer/src/layers/map/mapLayer.stories.tsx @@ -462,29 +462,30 @@ ScaleZ.parameters = { }, }; +const ResetCameraPropertyDefaultCameraPosition = { + rotationOrbit: 0, + rotationX: 45, + target: [435775, 6478650, -2750], + zoom: -3.8, +}; + export const ResetCameraProperty: ComponentStory = ( args ) => { - const [home, setHome] = React.useState(0); - const [camera, setCamera] = React.useState({ - rotationOrbit: 0, - rotationX: 45, - target: [435775, 6477650, -1750], - zoom: -3.8, - }); - - const handleChange1 = () => { - setHome(home + 1); - }; + const [camera, setCamera] = React.useState( + () => args.cameraPosition ?? ResetCameraPropertyDefaultCameraPosition + ); - const handleChange2 = () => { - setCamera({ ...camera, rotationOrbit: camera.rotationOrbit + 5 }); + const handleChange = () => { + setCamera({ + ...camera, + rotationOrbit: camera.rotationOrbit + 5, + }); }; const props = { ...args, cameraPosition: camera, - triggerHome: home, }; return ( @@ -492,8 +493,7 @@ export const ResetCameraProperty: ComponentStory = (
- - + ); }; @@ -503,12 +503,7 @@ ResetCameraProperty.args = { layers: [axes_hugin, meshMapLayerPng, north_arrow_layer], bounds: [432150, 6475800, 439400, 6481500] as NumberQuad, - cameraPosition: { - rotationOrbit: 0, - rotationX: 80, - target: [435775, 6478650, -1750], - zoom: -3.5109619192773796, - }, + cameraPosition: ResetCameraPropertyDefaultCameraPosition, views: DEFAULT_VIEWS, }; @@ -516,9 +511,7 @@ ResetCameraProperty.parameters = { docs: { ...defaultParameters.docs, description: { - story: `Example using optional 'triggerHome' property. - When this property is changed camera will reset to home position. - Using the button the property will change its value.`, + story: `Pressing the button 'Change Camera' does rotate it.`, }, }, }; From 5d5aa6e13865c3870a15530cf3a6e4f2edda18cb Mon Sep 17 00:00:00 2001 From: Christophe Winkler Date: Fri, 15 Dec 2023 17:39:35 +0100 Subject: [PATCH 3/8] Fix lint, tentative to fix prettier --- typescript/packages/subsurface-viewer/src/components/Map.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/typescript/packages/subsurface-viewer/src/components/Map.tsx b/typescript/packages/subsurface-viewer/src/components/Map.tsx index 5d3860f138..67c3e10e8e 100644 --- a/typescript/packages/subsurface-viewer/src/components/Map.tsx +++ b/typescript/packages/subsurface-viewer/src/components/Map.tsx @@ -228,8 +228,7 @@ function mapBoundingBoxReducer( // Exclude "layerIds" when monitoring changes to "view" prop as we do not // want to recalculate views when the layers change. -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-expect-error +// eslint-disable-next-line @typescript-eslint/no-unused-vars function compareViewsProp(views: ViewsType | undefined): string | undefined { if (views === undefined) { return undefined; From d7c90d9488041a960413cbdca89753f63d80cd43 Mon Sep 17 00:00:00 2001 From: Christophe Winkler Date: Fri, 15 Dec 2023 17:44:21 +0100 Subject: [PATCH 4/8] Fix lint --- .../subsurface-viewer/src/components/Map.tsx | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/typescript/packages/subsurface-viewer/src/components/Map.tsx b/typescript/packages/subsurface-viewer/src/components/Map.tsx index 67c3e10e8e..b12d264b77 100644 --- a/typescript/packages/subsurface-viewer/src/components/Map.tsx +++ b/typescript/packages/subsurface-viewer/src/components/Map.tsx @@ -226,27 +226,6 @@ function mapBoundingBoxReducer( return boxUnion(mapBoundingBox, action.layerBoundingBox); } -// Exclude "layerIds" when monitoring changes to "view" prop as we do not -// want to recalculate views when the layers change. -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function compareViewsProp(views: ViewsType | undefined): string | undefined { - if (views === undefined) { - return undefined; - } - - const copy = cloneDeep(views); - const viewports = copy.viewports.map((e) => { - delete e.layerIds; - return e; - }); - copy.viewports = viewports; - return JSON.stringify(copy); -} - -export type TooltipCallback = ( - info: PickingInfo -) => string | Record | null; - /** * Views */ From b9a997e5d2de05ddb468f6a14618da4ee4b6e413 Mon Sep 17 00:00:00 2001 From: Christophe Winkler Date: Mon, 18 Dec 2023 08:40:57 +0100 Subject: [PATCH 5/8] Fix compilation --- typescript/packages/subsurface-viewer/src/components/Map.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/typescript/packages/subsurface-viewer/src/components/Map.tsx b/typescript/packages/subsurface-viewer/src/components/Map.tsx index b12d264b77..26ac7f6cec 100644 --- a/typescript/packages/subsurface-viewer/src/components/Map.tsx +++ b/typescript/packages/subsurface-viewer/src/components/Map.tsx @@ -226,6 +226,10 @@ function mapBoundingBoxReducer( return boxUnion(mapBoundingBox, action.layerBoundingBox); } +export type TooltipCallback = ( + info: PickingInfo +) => string | Record | null; + /** * Views */ From 4f3308e2fc0d10b0f51b209810e1906ed479a100 Mon Sep 17 00:00:00 2001 From: Christophe Winkler Date: Mon, 18 Dec 2023 10:00:03 +0100 Subject: [PATCH 6/8] Fix eol --- .../subsurface-viewer/src/components/Map.tsx | 3576 ++++++++--------- 1 file changed, 1788 insertions(+), 1788 deletions(-) diff --git a/typescript/packages/subsurface-viewer/src/components/Map.tsx b/typescript/packages/subsurface-viewer/src/components/Map.tsx index 26ac7f6cec..254f876546 100644 --- a/typescript/packages/subsurface-viewer/src/components/Map.tsx +++ b/typescript/packages/subsurface-viewer/src/components/Map.tsx @@ -1,1789 +1,1789 @@ -import React, { useEffect, useState, useCallback, useMemo } from "react"; - -import type { Feature, FeatureCollection } from "geojson"; -import { cloneDeep, isEmpty } from "lodash"; - -import type { - MjolnirEvent, - MjolnirGestureEvent, - MjolnirKeyEvent, - MjolnirPointerEvent, -} from "mjolnir.js"; - -import { JSONConfiguration, JSONConverter } from "@deck.gl/json/typed"; -import type { DeckGLRef } from "@deck.gl/react/typed"; -import DeckGL from "@deck.gl/react/typed"; -import type { - Color, - Layer, - LayersList, - LayerProps, - LayerContext, - View, - Viewport, - PickingInfo, -} from "@deck.gl/core/typed"; -import { - _CameraLight as CameraLight, - AmbientLight, - DirectionalLight, - LightingEffect, - OrbitController, - OrbitView, - OrthographicController, - OrthographicView, - PointLight, -} from "@deck.gl/core/typed"; -import { LineLayer } from "@deck.gl/layers/typed"; - -import { Matrix4 } from "@math.gl/core"; -import { fovyToAltitude } from "@math.gl/web-mercator"; - -import { colorTables } from "@emerson-eps/color-tables"; -import type { colorTablesArray } from "@emerson-eps/color-tables/"; - -import type { BoundingBox3D } from "../utils/BoundingBox3D"; -import { boxCenter, boxUnion } from "../utils/BoundingBox3D"; -import JSON_CONVERTER_CONFIG from "../utils/configuration"; -import type { WellsPickInfo } from "../layers/wells/wellsLayer"; -import InfoCard from "./InfoCard"; -import DistanceScale from "./DistanceScale"; -import StatusIndicator from "./StatusIndicator"; -import fitBounds from "../utils/fit-bounds"; -import { validateColorTables, validateLayers } from "@webviz/wsc-common"; -import type { LayerPickInfo } from "../layers/utils/layerTools"; -import { - getModelMatrixScale, - getLayersByType, - getWellLayerByTypeAndSelectedWells, -} from "../layers/utils/layerTools"; -import { WellsLayer, Axes2DLayer, NorthArrow3DLayer } from "../layers"; - -import IntersectionView from "../views/intersectionView"; -import type { Unit } from "convert-units"; -import type { LightsType } from "../SubsurfaceViewer"; - -/** - * 3D bounding box defined as [xmin, ymin, zmin, xmax, ymax, zmax]. - */ -export type { BoundingBox3D }; -/** - * 2D bounding box defined as [left, bottom, right, top] - */ -export type BoundingBox2D = [number, number, number, number]; -/** - * Type of the function returning coordinate boundary for the view defined as [left, bottom, right, top]. - */ -export type BoundsAccessor = () => BoundingBox2D; - -type Size = { - width: number; - height: number; -}; - -const minZoom3D = -12; -const maxZoom3D = 12; -const minZoom2D = -12; -const maxZoom2D = 4; - -// https://developer.mozilla.org/docs/Web/API/KeyboardEvent -type ArrowEvent = { - key: "ArrowUp" | "ArrowDown" | "PageUp" | "PageDown"; - shiftModifier: boolean; - // altModifier: boolean; - // ctrlModifier: boolean; -}; - -function updateZScaleReducer(zScale: number, action: ArrowEvent): number { - return zScale * getZScaleModifier(action); -} - -function getZScaleModifier(arrowEvent: ArrowEvent): number { - let scaleFactor = 0; - switch (arrowEvent.key) { - case "ArrowUp": - scaleFactor = 0.05; - break; - case "ArrowDown": - scaleFactor = -0.05; - break; - case "PageUp": - scaleFactor = 0.25; - break; - case "PageDown": - scaleFactor = -0.25; - break; - default: - break; - } - if (arrowEvent.shiftModifier) { - scaleFactor /= 5; - } - return 1 + scaleFactor; -} - -function convertToArrowEvent(event: MjolnirEvent): ArrowEvent | null { - if (event.type === "keydown") { - const keyEvent = event as MjolnirKeyEvent; - switch (keyEvent.key) { - case "ArrowUp": - case "ArrowDown": - case "PageUp": - case "PageDown": - return { - key: keyEvent.key, - shiftModifier: keyEvent.srcEvent.shiftKey, - }; - default: - return null; - } - } - return null; -} - -class ZScaleOrbitController extends OrbitController { - static updateZScaleAction: React.Dispatch | null = null; - - static setUpdateZScaleAction( - updateZScaleAction: React.Dispatch - ) { - ZScaleOrbitController.updateZScaleAction = updateZScaleAction; - } - - handleEvent(event: MjolnirEvent): boolean { - if (ZScaleOrbitController.updateZScaleAction) { - const arrowEvent = convertToArrowEvent(event); - if (arrowEvent) { - ZScaleOrbitController.updateZScaleAction(arrowEvent); - return true; - } - } - return super.handleEvent(event); - } -} - -class ZScaleOrbitView extends OrbitView { - get ControllerType(): typeof OrbitController { - return ZScaleOrbitController; - } -} - -function parseLights(lights?: LightsType): LightingEffect[] | undefined { - if (!lights) { - return undefined; - } - - const effects = []; - let lightsObj = {}; - - if (lights.headLight) { - const headLight = new CameraLight({ - intensity: lights.headLight.intensity, - color: lights.headLight.color ?? [255, 255, 255], - }); - lightsObj = { ...lightsObj, headLight }; - } - - if (lights.ambientLight) { - const ambientLight = new AmbientLight({ - intensity: lights.ambientLight.intensity, - color: lights.ambientLight.color ?? [255, 255, 255], - }); - lightsObj = { ...lightsObj, ambientLight }; - } - - if (lights.pointLights) { - for (const light of lights.pointLights) { - const pointLight = new PointLight({ - ...light, - color: light.color ?? [255, 255, 255], - }); - lightsObj = { ...lightsObj, pointLight }; - } - } - - if (lights.directionalLights) { - for (const light of lights.directionalLights) { - const directionalLight = new DirectionalLight({ - ...light, - color: light.color ?? [255, 255, 255], - }); - lightsObj = { ...lightsObj, directionalLight }; - } - } - - const lightingEffect = new LightingEffect(lightsObj); - effects.push(lightingEffect); - - return effects; -} - -export type ReportBoundingBoxAction = { layerBoundingBox: BoundingBox3D }; -function mapBoundingBoxReducer( - mapBoundingBox: BoundingBox3D | undefined, - action: ReportBoundingBoxAction -): BoundingBox3D | undefined { - return boxUnion(mapBoundingBox, action.layerBoundingBox); -} - -export type TooltipCallback = ( - info: PickingInfo -) => string | Record | null; - -/** - * Views - */ -export interface ViewsType { - /** - * Layout for viewport in specified as [row, column]. - */ - layout: [number, number]; - - /** - * Number of pixels used for the margin in matrix mode. - * Defaults to 0. - */ - marginPixels?: number; - - /** - * Show views label. - */ - showLabel?: boolean; - - /** - * Layers configuration for multiple viewports. - */ - viewports: ViewportType[]; -} - -/** - * Viewport type. - */ -export interface ViewportType { - /** - * Viewport id - */ - id: string; - - /** - * Viewport name - */ - name?: string; - - /** - * If true, displays map in 3D view, default is 2D view (false) - */ - show3D?: boolean; - - /** - * Layers to be displayed on viewport - */ - layerIds?: string[]; - - target?: [number, number]; - zoom?: number; - rotationX?: number; - rotationOrbit?: number; - - isSync?: boolean; -} - -/** - * Camera view state. - */ -export interface ViewStateType { - target: number[]; - zoom: number | BoundingBox3D | undefined; - rotationX: number; - rotationOrbit: number; - minZoom?: number; - maxZoom?: number; - transitionDuration?: number; -} - -interface MarginsType { - left: number; - right: number; - top: number; - bottom: number; -} - -export interface DeckGLLayerContext extends LayerContext { - userData: { - setEditedData: (data: Record) => void; - colorTables: colorTablesArray; - }; -} - -export interface MapMouseEvent { - type: "click" | "hover" | "contextmenu"; - infos: PickingInfo[]; - // some frequently used values extracted from infos[]: - x?: number; - y?: number; - // Only for one well. Full information is available in infos[] - wellname?: string; - wellcolor?: Color; // well color - md?: number; - tvd?: number; -} - -export type EventCallback = (event: MapMouseEvent) => void; - -export function useHoverInfo(): [PickingInfo[], EventCallback] { - const [hoverInfo, setHoverInfo] = useState([]); - const callback = useCallback((pickEvent: MapMouseEvent) => { - setHoverInfo(pickEvent.infos); - }, []); - return [hoverInfo, callback]; -} - -export interface MapProps { - /** - * The ID of this component, used to identify dash components - * in callbacks. The ID needs to be unique across all of the - * components in an app. - */ - id: string; - - /** - * Resource dictionary made available in the DeckGL specification as an enum. - * The values can be accessed like this: `"@@#resources.resourceId"`, where - * `resourceId` is the key in the `resources` dict. For more information, - * see the DeckGL documentation on enums in the json spec: - * https://deck.gl/docs/api-reference/json/conversion-reference#enumerations-and-using-the--prefix - */ - resources?: Record; - - /* List of JSON object containing layer specific data. - * Each JSON object will consist of layer type with key as "@@type" and - * layer specific data, if any. - */ - layers?: LayersList; - - /** - * Coordinate boundary for the view defined as [left, bottom, right, top]. - * Should be used for 2D view only. - */ - bounds?: BoundingBox2D | BoundsAccessor; - - /** - * Camera state for the view defined as a ViewStateType. - * Should be used for 3D view only. - * If the zoom is given as a 3D bounding box, the camera state is computed to - * display the full box. - */ - cameraPosition?: ViewStateType; - - /** - * If changed will reset view settings (bounds or camera) to default position. - */ - triggerHome?: number; - - /** - * Views configuration for map. If not specified, all the layers will be - * displayed in a single 2D viewport - */ - views?: ViewsType; - - /** - * Parameters for the InfoCard component - */ - coords?: { - visible?: boolean | null; - multiPicking?: boolean | null; - pickDepth?: number | null; - }; - - /** - * Parameters for the Distance Scale component - */ - scale?: { - visible?: boolean | null; - incrementValue?: number | null; - widthPerUnit?: number | null; - cssStyle?: Record | null; - }; - - coordinateUnit?: Unit; - - /** - * Parameters to control toolbar - */ - toolbar?: { - visible?: boolean | null; - }; - - /** - * Prop containing color table data - */ - colorTables?: colorTablesArray; - - /** - * Prop containing edited data from layers - */ - editedData?: Record; - - /** - * For reacting to prop changes - */ - setEditedData?: (data: Record) => void; - - /** - * Validate JSON datafile against schema - */ - checkDatafileSchema?: boolean; - - /** - * For get mouse events - */ - onMouseEvent?: EventCallback; - - getCameraPosition?: (input: ViewStateType) => void; - - /** - * Will be called after all layers have rendered data. - */ - isRenderedCallback?: (arg: boolean) => void; - - onDragStart?: (info: PickingInfo, event: MjolnirGestureEvent) => void; - onDragEnd?: (info: PickingInfo, event: MjolnirGestureEvent) => void; - - triggerResetMultipleWells?: number; - selection?: { - well: string | undefined; - selection: [number | undefined, number | undefined] | undefined; - }; - - lights?: LightsType; - - children?: React.ReactNode; - - getTooltip?: TooltipCallback; -} - -function defaultTooltip(info: PickingInfo) { - if ((info as WellsPickInfo)?.logName) { - return (info as WellsPickInfo)?.logName; - } else if (info.layer?.id === "drawing-layer") { - return (info as LayerPickInfo).propertyValue?.toFixed(2); - } - const feat = info.object as Feature; - return feat?.properties?.["name"]; -} - -const Map: React.FC = ({ - id, - layers, - bounds, - cameraPosition, - triggerHome, - views, - coords, - scale, - coordinateUnit, - colorTables, - setEditedData, - checkDatafileSchema, - onMouseEvent, - selection, - children, - getTooltip = defaultTooltip, - getCameraPosition, - isRenderedCallback, - onDragStart, - onDragEnd, - lights, - triggerResetMultipleWells, -}: MapProps) => { - // From react doc, ref should not be read nor modified during rendering. - const deckRef = React.useRef(null); - - const [applyViewController, forceUpdate] = React.useReducer( - (x) => x + 1, - 0 - ); - const viewController = useMemo(() => new ViewController(forceUpdate), []); - - // Extract the needed size from onResize function - const [deckSize, setDeckSize] = useState({ width: 0, height: 0 }); - const onResize = useCallback((size: Size) => { - // exclude {0, 0} size (when rendered hidden pages) - if (size.width > 0 && size.height > 0) { - setDeckSize((prevSize: Size) => { - if ( - prevSize?.width !== size.width || - prevSize?.height !== size.height - ) { - return size; - } - return prevSize; - }); - } - }, []); - - // 3d bounding box computed from the layers - const [dataBoundingBox3d, dispatchBoundingBox] = React.useReducer( - mapBoundingBoxReducer, - undefined - ); - - // Used for scaling in z direction using arrow keys. - const [zScale, updateZScale] = React.useReducer(updateZScaleReducer, 1); - React.useEffect(() => { - ZScaleOrbitController.setUpdateZScaleAction(updateZScale); - }, [updateZScale]); - - // compute the viewport margins - const viewPortMargins = React.useMemo(() => { - if (!layers?.length) { - return { - left: 0, - right: 0, - top: 0, - bottom: 0, - }; - } - // Margins on the viewport are extracted from a potential axes2D layer. - const axes2DLayer = layers?.find((e) => { - return e?.constructor === Axes2DLayer; - }) as Axes2DLayer; - - const axes2DProps = axes2DLayer?.props; - return { - left: axes2DProps?.isLeftRuler ? axes2DProps.marginH : 0, - right: axes2DProps?.isRightRuler ? axes2DProps.marginH : 0, - top: axes2DProps?.isTopRuler ? axes2DProps.marginV : 0, - bottom: axes2DProps?.isBottomRuler ? axes2DProps.marginV : 0, - }; - }, [layers]); - - // selection - useEffect(() => { - const layers = deckRef.current?.deck?.props.layers; - if (layers) { - const wellslayer = getLayersByType( - layers, - WellsLayer.name - )?.[0] as WellsLayer; - - wellslayer?.setSelection(selection?.well, selection?.selection); - } - }, [selection]); - - // multiple well layers - const [multipleWells, setMultipleWells] = useState([]); - const [selectedWell, setSelectedWell] = useState(""); - const [shiftHeld, setShiftHeld] = useState(false); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function downHandler({ key }: any) { - if (key === "Shift") { - setShiftHeld(true); - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function upHandler({ key }: any) { - if (key === "Shift") { - setShiftHeld(false); - } - } - - useEffect(() => { - window.addEventListener("keydown", downHandler); - window.addEventListener("keyup", upHandler); - return () => { - window.removeEventListener("keydown", downHandler); - window.removeEventListener("keyup", upHandler); - }; - }, []); - - useEffect(() => { - const layers = deckRef.current?.deck?.props.layers; - if (layers) { - const wellslayer = getWellLayerByTypeAndSelectedWells( - layers, - "WellsLayer", - selectedWell - )?.[0] as WellsLayer; - wellslayer?.setMultiSelection(multipleWells); - } - }, [multipleWells, selectedWell]); - - useEffect(() => { - if (typeof triggerResetMultipleWells !== "undefined") { - setMultipleWells([]); - } - }, [triggerResetMultipleWells]); - - const getPickingInfos = useCallback( - ( - pickInfo: PickingInfo, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - event: any - ): PickingInfo[] => { - if (coords?.multiPicking && pickInfo.layer?.context.deck) { - const pickInfos = - pickInfo.layer.context.deck.pickMultipleObjects({ - x: event.offsetCenter.x, - y: event.offsetCenter.y, - depth: coords.pickDepth ? coords.pickDepth : undefined, - unproject3D: true, - }) as LayerPickInfo[]; - pickInfos.forEach((item) => { - if (item.properties) { - let unit = ( - item.sourceLayer?.props - .data as unknown as FeatureCollection & { - unit: string; - } - )?.unit; - if (unit == undefined) unit = " "; - item.properties.forEach((element) => { - if ( - element.name.includes("MD") || - element.name.includes("TVD") - ) { - element.value = - Number(element.value) - .toFixed(2) - .toString() + - " " + - unit; - } - }); - } - }); - return pickInfos; - } - return [pickInfo]; - }, - [coords?.multiPicking, coords?.pickDepth] - ); - - /** - * call onMouseEvent callback - */ - const callOnMouseEvent = useCallback( - ( - type: "click" | "hover", - infos: PickingInfo[], - event: MjolnirEvent - ): void => { - if ( - (event as MjolnirPointerEvent).leftButton && - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - event.tapCount == 2 // Note. Detect double click. - ) { - // Left button click identifies new camera rotation anchor. - if (infos.length >= 1) { - if (infos[0].coordinate) { - viewController.setTarget( - infos[0].coordinate as [number, number, number] - ); - } - } - } - - if (!onMouseEvent) return; - const ev = handleMouseEvent(type, infos, event); - onMouseEvent(ev); - }, - [onMouseEvent, viewController] - ); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const [hoverInfo, setHoverInfo] = useState([]); - const onHover = useCallback( - (pickInfo: PickingInfo, event: MjolnirEvent) => { - const infos = getPickingInfos(pickInfo, event); - setHoverInfo(infos); // for InfoCard pickInfos - callOnMouseEvent?.("hover", infos, event); - }, - [callOnMouseEvent, getPickingInfos] - ); - - const onClick = useCallback( - (pickInfo: PickingInfo, event: MjolnirEvent) => { - const infos = getPickingInfos(pickInfo, event); - callOnMouseEvent?.("click", infos, event); - }, - [callOnMouseEvent, getPickingInfos] - ); - - const deckGLLayers = React.useMemo(() => { - if (!layers) { - return []; - } - if (layers.length === 0) { - // Empty layers array makes deck.gl set deckRef to undefined (no OpenGL context). - // Hence insert dummy layer. - const dummy_layer = new LineLayer({ - id: "webviz_internal_dummy_layer", - visible: false, - }); - layers.push(dummy_layer); - } - - const m = getModelMatrixScale(zScale); - - return layers.map((item) => { - if (item?.constructor.name === NorthArrow3DLayer.name) { - return item; - } - - return (item as Layer).clone({ - // Inject "dispatchBoundingBox" function into layer for it to report back its respective bounding box. - // eslint-disable-next-line +import React, { useEffect, useState, useCallback, useMemo } from "react"; + +import type { Feature, FeatureCollection } from "geojson"; +import { cloneDeep, isEmpty } from "lodash"; + +import type { + MjolnirEvent, + MjolnirGestureEvent, + MjolnirKeyEvent, + MjolnirPointerEvent, +} from "mjolnir.js"; + +import { JSONConfiguration, JSONConverter } from "@deck.gl/json/typed"; +import type { DeckGLRef } from "@deck.gl/react/typed"; +import DeckGL from "@deck.gl/react/typed"; +import type { + Color, + Layer, + LayersList, + LayerProps, + LayerContext, + View, + Viewport, + PickingInfo, +} from "@deck.gl/core/typed"; +import { + _CameraLight as CameraLight, + AmbientLight, + DirectionalLight, + LightingEffect, + OrbitController, + OrbitView, + OrthographicController, + OrthographicView, + PointLight, +} from "@deck.gl/core/typed"; +import { LineLayer } from "@deck.gl/layers/typed"; + +import { Matrix4 } from "@math.gl/core"; +import { fovyToAltitude } from "@math.gl/web-mercator"; + +import { colorTables } from "@emerson-eps/color-tables"; +import type { colorTablesArray } from "@emerson-eps/color-tables/"; + +import type { BoundingBox3D } from "../utils/BoundingBox3D"; +import { boxCenter, boxUnion } from "../utils/BoundingBox3D"; +import JSON_CONVERTER_CONFIG from "../utils/configuration"; +import type { WellsPickInfo } from "../layers/wells/wellsLayer"; +import InfoCard from "./InfoCard"; +import DistanceScale from "./DistanceScale"; +import StatusIndicator from "./StatusIndicator"; +import fitBounds from "../utils/fit-bounds"; +import { validateColorTables, validateLayers } from "@webviz/wsc-common"; +import type { LayerPickInfo } from "../layers/utils/layerTools"; +import { + getModelMatrixScale, + getLayersByType, + getWellLayerByTypeAndSelectedWells, +} from "../layers/utils/layerTools"; +import { WellsLayer, Axes2DLayer, NorthArrow3DLayer } from "../layers"; + +import IntersectionView from "../views/intersectionView"; +import type { Unit } from "convert-units"; +import type { LightsType } from "../SubsurfaceViewer"; + +/** + * 3D bounding box defined as [xmin, ymin, zmin, xmax, ymax, zmax]. + */ +export type { BoundingBox3D }; +/** + * 2D bounding box defined as [left, bottom, right, top] + */ +export type BoundingBox2D = [number, number, number, number]; +/** + * Type of the function returning coordinate boundary for the view defined as [left, bottom, right, top]. + */ +export type BoundsAccessor = () => BoundingBox2D; + +type Size = { + width: number; + height: number; +}; + +const minZoom3D = -12; +const maxZoom3D = 12; +const minZoom2D = -12; +const maxZoom2D = 4; + +// https://developer.mozilla.org/docs/Web/API/KeyboardEvent +type ArrowEvent = { + key: "ArrowUp" | "ArrowDown" | "PageUp" | "PageDown"; + shiftModifier: boolean; + // altModifier: boolean; + // ctrlModifier: boolean; +}; + +function updateZScaleReducer(zScale: number, action: ArrowEvent): number { + return zScale * getZScaleModifier(action); +} + +function getZScaleModifier(arrowEvent: ArrowEvent): number { + let scaleFactor = 0; + switch (arrowEvent.key) { + case "ArrowUp": + scaleFactor = 0.05; + break; + case "ArrowDown": + scaleFactor = -0.05; + break; + case "PageUp": + scaleFactor = 0.25; + break; + case "PageDown": + scaleFactor = -0.25; + break; + default: + break; + } + if (arrowEvent.shiftModifier) { + scaleFactor /= 5; + } + return 1 + scaleFactor; +} + +function convertToArrowEvent(event: MjolnirEvent): ArrowEvent | null { + if (event.type === "keydown") { + const keyEvent = event as MjolnirKeyEvent; + switch (keyEvent.key) { + case "ArrowUp": + case "ArrowDown": + case "PageUp": + case "PageDown": + return { + key: keyEvent.key, + shiftModifier: keyEvent.srcEvent.shiftKey, + }; + default: + return null; + } + } + return null; +} + +class ZScaleOrbitController extends OrbitController { + static updateZScaleAction: React.Dispatch | null = null; + + static setUpdateZScaleAction( + updateZScaleAction: React.Dispatch + ) { + ZScaleOrbitController.updateZScaleAction = updateZScaleAction; + } + + handleEvent(event: MjolnirEvent): boolean { + if (ZScaleOrbitController.updateZScaleAction) { + const arrowEvent = convertToArrowEvent(event); + if (arrowEvent) { + ZScaleOrbitController.updateZScaleAction(arrowEvent); + return true; + } + } + return super.handleEvent(event); + } +} + +class ZScaleOrbitView extends OrbitView { + get ControllerType(): typeof OrbitController { + return ZScaleOrbitController; + } +} + +function parseLights(lights?: LightsType): LightingEffect[] | undefined { + if (!lights) { + return undefined; + } + + const effects = []; + let lightsObj = {}; + + if (lights.headLight) { + const headLight = new CameraLight({ + intensity: lights.headLight.intensity, + color: lights.headLight.color ?? [255, 255, 255], + }); + lightsObj = { ...lightsObj, headLight }; + } + + if (lights.ambientLight) { + const ambientLight = new AmbientLight({ + intensity: lights.ambientLight.intensity, + color: lights.ambientLight.color ?? [255, 255, 255], + }); + lightsObj = { ...lightsObj, ambientLight }; + } + + if (lights.pointLights) { + for (const light of lights.pointLights) { + const pointLight = new PointLight({ + ...light, + color: light.color ?? [255, 255, 255], + }); + lightsObj = { ...lightsObj, pointLight }; + } + } + + if (lights.directionalLights) { + for (const light of lights.directionalLights) { + const directionalLight = new DirectionalLight({ + ...light, + color: light.color ?? [255, 255, 255], + }); + lightsObj = { ...lightsObj, directionalLight }; + } + } + + const lightingEffect = new LightingEffect(lightsObj); + effects.push(lightingEffect); + + return effects; +} + +export type ReportBoundingBoxAction = { layerBoundingBox: BoundingBox3D }; +function mapBoundingBoxReducer( + mapBoundingBox: BoundingBox3D | undefined, + action: ReportBoundingBoxAction +): BoundingBox3D | undefined { + return boxUnion(mapBoundingBox, action.layerBoundingBox); +} + +export type TooltipCallback = ( + info: PickingInfo +) => string | Record | null; + +/** + * Views + */ +export interface ViewsType { + /** + * Layout for viewport in specified as [row, column]. + */ + layout: [number, number]; + + /** + * Number of pixels used for the margin in matrix mode. + * Defaults to 0. + */ + marginPixels?: number; + + /** + * Show views label. + */ + showLabel?: boolean; + + /** + * Layers configuration for multiple viewports. + */ + viewports: ViewportType[]; +} + +/** + * Viewport type. + */ +export interface ViewportType { + /** + * Viewport id + */ + id: string; + + /** + * Viewport name + */ + name?: string; + + /** + * If true, displays map in 3D view, default is 2D view (false) + */ + show3D?: boolean; + + /** + * Layers to be displayed on viewport + */ + layerIds?: string[]; + + target?: [number, number]; + zoom?: number; + rotationX?: number; + rotationOrbit?: number; + + isSync?: boolean; +} + +/** + * Camera view state. + */ +export interface ViewStateType { + target: number[]; + zoom: number | BoundingBox3D | undefined; + rotationX: number; + rotationOrbit: number; + minZoom?: number; + maxZoom?: number; + transitionDuration?: number; +} + +interface MarginsType { + left: number; + right: number; + top: number; + bottom: number; +} + +export interface DeckGLLayerContext extends LayerContext { + userData: { + setEditedData: (data: Record) => void; + colorTables: colorTablesArray; + }; +} + +export interface MapMouseEvent { + type: "click" | "hover" | "contextmenu"; + infos: PickingInfo[]; + // some frequently used values extracted from infos[]: + x?: number; + y?: number; + // Only for one well. Full information is available in infos[] + wellname?: string; + wellcolor?: Color; // well color + md?: number; + tvd?: number; +} + +export type EventCallback = (event: MapMouseEvent) => void; + +export function useHoverInfo(): [PickingInfo[], EventCallback] { + const [hoverInfo, setHoverInfo] = useState([]); + const callback = useCallback((pickEvent: MapMouseEvent) => { + setHoverInfo(pickEvent.infos); + }, []); + return [hoverInfo, callback]; +} + +export interface MapProps { + /** + * The ID of this component, used to identify dash components + * in callbacks. The ID needs to be unique across all of the + * components in an app. + */ + id: string; + + /** + * Resource dictionary made available in the DeckGL specification as an enum. + * The values can be accessed like this: `"@@#resources.resourceId"`, where + * `resourceId` is the key in the `resources` dict. For more information, + * see the DeckGL documentation on enums in the json spec: + * https://deck.gl/docs/api-reference/json/conversion-reference#enumerations-and-using-the--prefix + */ + resources?: Record; + + /* List of JSON object containing layer specific data. + * Each JSON object will consist of layer type with key as "@@type" and + * layer specific data, if any. + */ + layers?: LayersList; + + /** + * Coordinate boundary for the view defined as [left, bottom, right, top]. + * Should be used for 2D view only. + */ + bounds?: BoundingBox2D | BoundsAccessor; + + /** + * Camera state for the view defined as a ViewStateType. + * Should be used for 3D view only. + * If the zoom is given as a 3D bounding box, the camera state is computed to + * display the full box. + */ + cameraPosition?: ViewStateType; + + /** + * If changed will reset view settings (bounds or camera) to default position. + */ + triggerHome?: number; + + /** + * Views configuration for map. If not specified, all the layers will be + * displayed in a single 2D viewport + */ + views?: ViewsType; + + /** + * Parameters for the InfoCard component + */ + coords?: { + visible?: boolean | null; + multiPicking?: boolean | null; + pickDepth?: number | null; + }; + + /** + * Parameters for the Distance Scale component + */ + scale?: { + visible?: boolean | null; + incrementValue?: number | null; + widthPerUnit?: number | null; + cssStyle?: Record | null; + }; + + coordinateUnit?: Unit; + + /** + * Parameters to control toolbar + */ + toolbar?: { + visible?: boolean | null; + }; + + /** + * Prop containing color table data + */ + colorTables?: colorTablesArray; + + /** + * Prop containing edited data from layers + */ + editedData?: Record; + + /** + * For reacting to prop changes + */ + setEditedData?: (data: Record) => void; + + /** + * Validate JSON datafile against schema + */ + checkDatafileSchema?: boolean; + + /** + * For get mouse events + */ + onMouseEvent?: EventCallback; + + getCameraPosition?: (input: ViewStateType) => void; + + /** + * Will be called after all layers have rendered data. + */ + isRenderedCallback?: (arg: boolean) => void; + + onDragStart?: (info: PickingInfo, event: MjolnirGestureEvent) => void; + onDragEnd?: (info: PickingInfo, event: MjolnirGestureEvent) => void; + + triggerResetMultipleWells?: number; + selection?: { + well: string | undefined; + selection: [number | undefined, number | undefined] | undefined; + }; + + lights?: LightsType; + + children?: React.ReactNode; + + getTooltip?: TooltipCallback; +} + +function defaultTooltip(info: PickingInfo) { + if ((info as WellsPickInfo)?.logName) { + return (info as WellsPickInfo)?.logName; + } else if (info.layer?.id === "drawing-layer") { + return (info as LayerPickInfo).propertyValue?.toFixed(2); + } + const feat = info.object as Feature; + return feat?.properties?.["name"]; +} + +const Map: React.FC = ({ + id, + layers, + bounds, + cameraPosition, + triggerHome, + views, + coords, + scale, + coordinateUnit, + colorTables, + setEditedData, + checkDatafileSchema, + onMouseEvent, + selection, + children, + getTooltip = defaultTooltip, + getCameraPosition, + isRenderedCallback, + onDragStart, + onDragEnd, + lights, + triggerResetMultipleWells, +}: MapProps) => { + // From react doc, ref should not be read nor modified during rendering. + const deckRef = React.useRef(null); + + const [applyViewController, forceUpdate] = React.useReducer( + (x) => x + 1, + 0 + ); + const viewController = useMemo(() => new ViewController(forceUpdate), []); + + // Extract the needed size from onResize function + const [deckSize, setDeckSize] = useState({ width: 0, height: 0 }); + const onResize = useCallback((size: Size) => { + // exclude {0, 0} size (when rendered hidden pages) + if (size.width > 0 && size.height > 0) { + setDeckSize((prevSize: Size) => { + if ( + prevSize?.width !== size.width || + prevSize?.height !== size.height + ) { + return size; + } + return prevSize; + }); + } + }, []); + + // 3d bounding box computed from the layers + const [dataBoundingBox3d, dispatchBoundingBox] = React.useReducer( + mapBoundingBoxReducer, + undefined + ); + + // Used for scaling in z direction using arrow keys. + const [zScale, updateZScale] = React.useReducer(updateZScaleReducer, 1); + React.useEffect(() => { + ZScaleOrbitController.setUpdateZScaleAction(updateZScale); + }, [updateZScale]); + + // compute the viewport margins + const viewPortMargins = React.useMemo(() => { + if (!layers?.length) { + return { + left: 0, + right: 0, + top: 0, + bottom: 0, + }; + } + // Margins on the viewport are extracted from a potential axes2D layer. + const axes2DLayer = layers?.find((e) => { + return e?.constructor === Axes2DLayer; + }) as Axes2DLayer; + + const axes2DProps = axes2DLayer?.props; + return { + left: axes2DProps?.isLeftRuler ? axes2DProps.marginH : 0, + right: axes2DProps?.isRightRuler ? axes2DProps.marginH : 0, + top: axes2DProps?.isTopRuler ? axes2DProps.marginV : 0, + bottom: axes2DProps?.isBottomRuler ? axes2DProps.marginV : 0, + }; + }, [layers]); + + // selection + useEffect(() => { + const layers = deckRef.current?.deck?.props.layers; + if (layers) { + const wellslayer = getLayersByType( + layers, + WellsLayer.name + )?.[0] as WellsLayer; + + wellslayer?.setSelection(selection?.well, selection?.selection); + } + }, [selection]); + + // multiple well layers + const [multipleWells, setMultipleWells] = useState([]); + const [selectedWell, setSelectedWell] = useState(""); + const [shiftHeld, setShiftHeld] = useState(false); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function downHandler({ key }: any) { + if (key === "Shift") { + setShiftHeld(true); + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function upHandler({ key }: any) { + if (key === "Shift") { + setShiftHeld(false); + } + } + + useEffect(() => { + window.addEventListener("keydown", downHandler); + window.addEventListener("keyup", upHandler); + return () => { + window.removeEventListener("keydown", downHandler); + window.removeEventListener("keyup", upHandler); + }; + }, []); + + useEffect(() => { + const layers = deckRef.current?.deck?.props.layers; + if (layers) { + const wellslayer = getWellLayerByTypeAndSelectedWells( + layers, + "WellsLayer", + selectedWell + )?.[0] as WellsLayer; + wellslayer?.setMultiSelection(multipleWells); + } + }, [multipleWells, selectedWell]); + + useEffect(() => { + if (typeof triggerResetMultipleWells !== "undefined") { + setMultipleWells([]); + } + }, [triggerResetMultipleWells]); + + const getPickingInfos = useCallback( + ( + pickInfo: PickingInfo, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + event: any + ): PickingInfo[] => { + if (coords?.multiPicking && pickInfo.layer?.context.deck) { + const pickInfos = + pickInfo.layer.context.deck.pickMultipleObjects({ + x: event.offsetCenter.x, + y: event.offsetCenter.y, + depth: coords.pickDepth ? coords.pickDepth : undefined, + unproject3D: true, + }) as LayerPickInfo[]; + pickInfos.forEach((item) => { + if (item.properties) { + let unit = ( + item.sourceLayer?.props + .data as unknown as FeatureCollection & { + unit: string; + } + )?.unit; + if (unit == undefined) unit = " "; + item.properties.forEach((element) => { + if ( + element.name.includes("MD") || + element.name.includes("TVD") + ) { + element.value = + Number(element.value) + .toFixed(2) + .toString() + + " " + + unit; + } + }); + } + }); + return pickInfos; + } + return [pickInfo]; + }, + [coords?.multiPicking, coords?.pickDepth] + ); + + /** + * call onMouseEvent callback + */ + const callOnMouseEvent = useCallback( + ( + type: "click" | "hover", + infos: PickingInfo[], + event: MjolnirEvent + ): void => { + if ( + (event as MjolnirPointerEvent).leftButton && + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + event.tapCount == 2 // Note. Detect double click. + ) { + // Left button click identifies new camera rotation anchor. + if (infos.length >= 1) { + if (infos[0].coordinate) { + viewController.setTarget( + infos[0].coordinate as [number, number, number] + ); + } + } + } + + if (!onMouseEvent) return; + const ev = handleMouseEvent(type, infos, event); + onMouseEvent(ev); + }, + [onMouseEvent, viewController] + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [hoverInfo, setHoverInfo] = useState([]); + const onHover = useCallback( + (pickInfo: PickingInfo, event: MjolnirEvent) => { + const infos = getPickingInfos(pickInfo, event); + setHoverInfo(infos); // for InfoCard pickInfos + callOnMouseEvent?.("hover", infos, event); + }, + [callOnMouseEvent, getPickingInfos] + ); + + const onClick = useCallback( + (pickInfo: PickingInfo, event: MjolnirEvent) => { + const infos = getPickingInfos(pickInfo, event); + callOnMouseEvent?.("click", infos, event); + }, + [callOnMouseEvent, getPickingInfos] + ); + + const deckGLLayers = React.useMemo(() => { + if (!layers) { + return []; + } + if (layers.length === 0) { + // Empty layers array makes deck.gl set deckRef to undefined (no OpenGL context). + // Hence insert dummy layer. + const dummy_layer = new LineLayer({ + id: "webviz_internal_dummy_layer", + visible: false, + }); + layers.push(dummy_layer); + } + + const m = getModelMatrixScale(zScale); + + return layers.map((item) => { + if (item?.constructor.name === NorthArrow3DLayer.name) { + return item; + } + + return (item as Layer).clone({ + // Inject "dispatchBoundingBox" function into layer for it to report back its respective bounding box. + // eslint-disable-next-line // @ts-ignore - reportBoundingBox: dispatchBoundingBox, - // Set "modelLayer" matrix to reflect correct z scaling. - modelMatrix: m, - }); - }); - }, [layers, zScale]); - - const [isLoaded, setIsLoaded] = useState(false); - const onAfterRender = useCallback(() => { - if (deckGLLayers) { - const loadedState = deckGLLayers.every((layer) => { - return ( - (layer as Layer).isLoaded || !(layer as Layer).props.visible - ); - }); - - const emptyLayers = // There will always be a dummy layer. Deck.gl does not like empty array of layers. - deckGLLayers.length == 1 && - (deckGLLayers[0] as LineLayer).id === - "webviz_internal_dummy_layer"; - - setIsLoaded(loadedState || emptyLayers); - if (isRenderedCallback) { - isRenderedCallback(loadedState); - } - } - }, [deckGLLayers, isRenderedCallback]); - - // validate layers data - const [errorText, setErrorText] = useState(); - useEffect(() => { - const layers = deckRef.current?.deck?.props.layers as Layer[]; - // this ensures to validate the schemas only once - if (checkDatafileSchema && layers && isLoaded) { - try { - validateLayers(layers); - colorTables && validateColorTables(colorTables); - } catch (e) { - setErrorText(String(e)); - } - } else setErrorText(undefined); - }, [ - checkDatafileSchema, - colorTables, - deckRef?.current?.deck?.props.layers, - isLoaded, - ]); - - const layerFilter = useCallback( - (args: { layer: Layer; viewport: Viewport }): boolean => { - // display all the layers if views are not specified correctly - if (!views?.viewports || !views?.layout) { - return true; - } - - const cur_view = views.viewports.find( - ({ id }) => args.viewport.id && id === args.viewport.id - ); - if (cur_view?.layerIds && cur_view.layerIds.length > 0) { - const layer_ids = cur_view.layerIds; - return layer_ids.some((layer_id) => { - const t = layer_id === args.layer.id; - return t; - }); - } else { - return true; - } - }, - [views] - ); - - const onViewStateChange = useCallback( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ({ viewId, viewState }: { viewId: string; viewState: any }) => { - viewController.onViewStateChange(viewId, viewState); - if (getCameraPosition) { - getCameraPosition(viewState); - } - }, - [getCameraPosition, viewController] - ); - - const effects = parseLights(lights); - - const [deckGlViews, deckGlViewState] = useMemo(() => { - const state = { - triggerHome, - camera: cameraPosition, - bounds, - boundingBox3d: dataBoundingBox3d, - viewPortMargins, - deckSize, - zScale, - }; - return viewController.getViews(views, state); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - triggerHome, - cameraPosition, - bounds, - dataBoundingBox3d, - viewPortMargins, - deckSize, - views, - zScale, - applyViewController, - viewController, - ]); - - if (!deckGlViews || isEmpty(deckGlViews) || isEmpty(deckGLLayers)) - return null; - return ( -
event.preventDefault()}> - ) => { - setSelectedWell(updated_prop["selectedWell"] as string); - if ( - Object.keys(updated_prop).includes("selectedWell") - ) { - if (shiftHeld) { - if ( - multipleWells.includes( - updated_prop["selectedWell"] as string - ) - ) { - const temp = multipleWells.filter( - (item) => - item !== - updated_prop["selectedWell"] - ); - setMultipleWells(temp); - } else { - const temp = multipleWells.concat( - updated_prop["selectedWell"] as string - ); - setMultipleWells(temp); - } - } else { - setMultipleWells([]); - } - } - setEditedData?.(updated_prop); - }, - colorTables: colorTables, - }} - getCursor={({ isDragging }): string => - isDragging ? "grabbing" : "default" - } - getTooltip={getTooltip} - ref={deckRef} - onViewStateChange={onViewStateChange} - onHover={onHover} - onClick={onClick} - onAfterRender={onAfterRender} - effects={effects} - onDragStart={onDragStart} - onDragEnd={onDragEnd} - onResize={onResize} - > - {children} - - {scale?.visible ? ( - - ) : null} - - {coords?.visible ? : null} - {errorText && ( -
-                    {errorText}
-                
- )} -
- ); -}; - -Map.defaultProps = { - coords: { - visible: true, - multiPicking: true, - pickDepth: 10, - }, - scale: { - visible: true, - incrementValue: 100, - widthPerUnit: 100, - cssStyle: { top: 10, left: 10 }, - }, - toolbar: { - visible: false, - }, - coordinateUnit: "m", - views: { - layout: [1, 1], - showLabel: false, - viewports: [{ id: "main-view", show3D: false, layerIds: [] }], - }, - colorTables: colorTables, - checkDatafileSchema: false, -}; - -export default Map; - -// ------------- Helper functions ---------- // - -// Add the resources as an enum in the Json Configuration and then convert the spec to actual objects. -// See https://deck.gl/docs/api-reference/json/overview for more details. -export function jsonToObject( - data: Record[] | LayerProps[], - enums: Record[] | undefined = undefined -): LayersList | View[] { - if (!data) return []; - - const configuration = new JSONConfiguration(JSON_CONVERTER_CONFIG); - enums?.forEach((enumeration) => { - if (enumeration) { - configuration.merge({ - enumerations: { - ...enumeration, - }, - }); - } - }); - const jsonConverter = new JSONConverter({ configuration }); - - // remove empty data/layer object - const filtered_data = data.filter((value) => !isEmpty(value)); - return jsonConverter.convert(filtered_data); -} - -/////////////////////////////////////////////////////////////////////////////////////////// -// View Controller -// Implements the algorithms to compute the views and the view state -type ViewControllerState = { - // Explicit state - triggerHome: number | undefined; - camera: ViewStateType | undefined; - bounds: BoundingBox2D | BoundsAccessor | undefined; - boundingBox3d: BoundingBox3D | undefined; - deckSize: Size; - zScale: number; - viewPortMargins: MarginsType; -}; -type ViewControllerDerivedState = { - // Derived state - target: [number, number, number] | undefined; - viewStateChanged: boolean; -}; -type ViewControllerFullState = ViewControllerState & ViewControllerDerivedState; -class ViewController { - private rerender_: React.DispatchWithoutAction; - - private state_: ViewControllerFullState = { - triggerHome: undefined, - camera: undefined, - bounds: undefined, - boundingBox3d: undefined, - deckSize: { width: 0, height: 0 }, - zScale: 1, - viewPortMargins: { - left: 0, - right: 0, - top: 0, - bottom: 0, - }, - // Derived state - target: undefined, - viewStateChanged: false, - }; - - private derivedState_: ViewControllerDerivedState = { - target: undefined, - viewStateChanged: false, - }; - - private views_: ViewsType | undefined = undefined; - private result_: { - views: View[]; - viewState: Record; - } = { - views: [], - viewState: {}, - }; - - public constructor(rerender: React.DispatchWithoutAction) { - this.rerender_ = rerender; - } - - public readonly setTarget = (target: [number, number, number]) => { - this.derivedState_.target = [target[0], target[1], target[2]]; - this.rerender_(); - }; - - public readonly getViews = ( - views: ViewsType | undefined, - state: ViewControllerState - ): [View[], Record] => { - const fullState = this.consolidateState(state); - const newViews = this.getDeckGlViews(views, fullState); - const newViewState = this.getDeckGlViewState(views, fullState); - - if ( - this.result_.views !== newViews || - this.result_.viewState !== newViewState - ) { - const viewsMsg = this.result_.views !== newViews ? " views" : ""; - const stateMsg = - this.result_.viewState !== newViewState ? " state" : ""; - const linkMsg = viewsMsg && stateMsg ? " and" : ""; - - console.log( - `ViewController returns new${viewsMsg}${linkMsg}${stateMsg}` - ); - } - - this.state_ = fullState; - this.views_ = views; - this.result_.views = newViews; - this.result_.viewState = newViewState; - return [newViews, newViewState]; - }; - - // consolidate "controlled" state (ie. set by parent) with "uncontrolled" state - private readonly consolidateState = ( - state: ViewControllerState - ): ViewControllerFullState => { - return { ...state, ...this.derivedState_ }; - }; - - // returns the DeckGL views (ie. view position and viewport) - private readonly getDeckGlViews = ( - views: ViewsType | undefined, - state: ViewControllerFullState - ) => { - const needUpdate = - views != this.views_ || state.deckSize != this.state_.deckSize; - if (!needUpdate) { - return this.result_.views; - } - return buildDeckGlViews(views, state.deckSize); - }; - - // returns the DeckGL views state(s) (ie. camera settings applied to individual views) - private readonly getDeckGlViewState = ( - views: ViewsType | undefined, - state: ViewControllerFullState - ): Record => { - const viewsChanged = views != this.views_; - const triggerHome = state.triggerHome !== this.state_.triggerHome; - const updateTarget = - (viewsChanged || state.target !== this.state_?.target) && - state.target !== undefined; - const updateZScale = - viewsChanged || state.zScale !== this.state_?.zScale || triggerHome; - const updateViewState = - viewsChanged || - triggerHome || - (!state.viewStateChanged && - state.boundingBox3d !== this.state_.boundingBox3d); - const needUpdate = updateZScale || updateTarget || updateViewState; - - const isCacheEmpty = isEmpty(this.result_.viewState); - if (!isCacheEmpty && !needUpdate) { - return this.result_.viewState; - } - - // initialize with last result - const prevViewState = this.result_.viewState; - let viewState = prevViewState; - - if (updateViewState || isCacheEmpty) { - viewState = buildDeckGlViewStates( - views, - state.viewPortMargins, - state.camera, - state.boundingBox3d, - state.bounds, - state.deckSize - ); - // reset state - this.derivedState_.viewStateChanged = false; - } - - // check if view state could be computed - if (isEmpty(viewState)) { - return viewState; - } - - const viewStateKeys = Object.keys(viewState); - if ( - updateTarget && - this.derivedState_.target && - viewStateKeys?.length === 1 - ) { - // deep clone to notify change (memo checks object address) - if (viewState === prevViewState) { - viewState = cloneDeep(prevViewState); - } - // update target - viewState[viewStateKeys[0]].target = this.derivedState_.target; - viewState[viewStateKeys[0]].transitionDuration = 1000; - // reset - this.derivedState_.target = undefined; - } - if (updateZScale) { - // deep clone to notify change (memo checks object address) - if (viewState === prevViewState) { - viewState = cloneDeep(prevViewState); - } - // Z scale to apply to target. - // - if triggerHome: the target was recomputed from the input data (ie. without any scale applied) - // - otherwise: previous scale (ie. this.state_.zScale) was already applied, and must be "reverted" - const targetScale = - state.zScale / (triggerHome ? 1 : this.state_.zScale); - // update target - for (const key in viewState) { - const t = viewState[key].target; - if (t) { - viewState[key].target = [t[0], t[1], t[2] * targetScale]; - } - } - } - return viewState; - }; - - public readonly onViewStateChange = ( - viewId: string, - viewState: ViewStateType - ): void => { - const viewports = this.views_?.viewports ?? []; - if (viewState.target.length === 2) { - // In orthographic mode viewState.target contains only x and y. Add existing z value. - viewState.target.push(this.result_.viewState[viewId].target[2]); - } - const isSyncIds = viewports - .filter((item) => item.isSync) - .map((item) => item.id); - if (isSyncIds?.includes(viewId)) { - const viewStateTable = this.views_?.viewports - .filter((item) => item.isSync) - .map((item) => [item.id, viewState]); - const tempViewStates = Object.fromEntries(viewStateTable ?? []); - this.result_.viewState = { - ...this.result_.viewState, - ...tempViewStates, - }; - } else { - this.result_.viewState = { - ...this.result_.viewState, - [viewId]: viewState, - }; - } - this.derivedState_.viewStateChanged = true; - this.rerender_(); - }; -} - -/** - * Returns the zoom factor allowing to view the complete boundingBox. - * @param camera camera defining the view orientation. - * @param boundingBox 3D bounding box to visualize. - * @param fov field of view (see deck.gl file orbit-viewports.ts). - */ -function computeCameraZoom( - camera: ViewStateType, - boundingBox: BoundingBox3D, - size: Size, - fovy = 50 -): number { - const DEGREES_TO_RADIANS = Math.PI / 180; - const RADIANS_TO_DEGREES = 180 / Math.PI; - const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; - - const fD = fovyToAltitude(fovy); - - const xMin = boundingBox[0]; - const yMin = boundingBox[1]; - const zMin = boundingBox[2]; - - const xMax = boundingBox[3]; - const yMax = boundingBox[4]; - const zMax = boundingBox[5]; - - const target = [ - xMin + (xMax - xMin) / 2, - yMin + (yMax - yMin) / 2, - zMin + (zMax - zMin) / 2, - ]; - - const cameraFovVertical = 50; - const angle_ver = (cameraFovVertical / 2) * DEGREES_TO_RADIANS; - const L = size.height / 2 / Math.sin(angle_ver); - const r = L * Math.cos(angle_ver); - const cameraFov = 2 * Math.atan(size.width / 2 / r) * RADIANS_TO_DEGREES; - const angle_hor = (cameraFov / 2) * DEGREES_TO_RADIANS; - - const points: [number, number, number][] = []; - points.push([xMin, yMin, zMin]); - points.push([xMin, yMax, zMin]); - points.push([xMax, yMax, zMin]); - points.push([xMax, yMin, zMin]); - points.push([xMin, yMin, zMax]); - points.push([xMin, yMax, zMax]); - points.push([xMax, yMax, zMax]); - points.push([xMax, yMin, zMax]); - - let zoom = 999; - for (const point of points) { - const x_ = (point[0] - target[0]) / size.height; - const y_ = (point[1] - target[1]) / size.height; - const z_ = (point[2] - target[2]) / size.height; - - const m = new Matrix4(IDENTITY); - m.rotateX(camera.rotationX * DEGREES_TO_RADIANS); - m.rotateZ(camera.rotationOrbit * DEGREES_TO_RADIANS); - - const [x, y, z] = m.transformAsVector([x_, y_, z_]); - if (y >= 0) { - // These points will actually appear further away when zooming in. - continue; - } - - const fwX = fD * Math.tan(angle_hor); - let y_new = fwX / (Math.abs(x) / y - fwX / fD); - const zoom_x = Math.log2(y_new / y); - - const fwY = fD * Math.tan(angle_ver); - y_new = fwY / (Math.abs(z) / y - fwY / fD); - const zoom_z = Math.log2(y_new / y); - - // it needs to be inside view volume in both directions. - zoom = zoom_x < zoom ? zoom_x : zoom; - zoom = zoom_z < zoom ? zoom_z : zoom; - } - return zoom; -} - -/////////////////////////////////////////////////////////////////////////////////////////// -// return viewstate with computed bounds to fit the data in viewport -function getViewStateFromBounds( - viewPortMargins: MarginsType, - bounds_accessor: BoundingBox2D | BoundsAccessor, - target: [number, number, number], - views: ViewsType | undefined, - viewPort: ViewportType, - size: Size -): ViewStateType | undefined { - const bounds = - typeof bounds_accessor == "function" - ? bounds_accessor() - : bounds_accessor; - - let w = bounds[2] - bounds[0]; // right - left - let h = bounds[3] - bounds[1]; // top - bottom - - const z = target[2]; - - const fb = fitBounds({ width: w, height: h, bounds }); - let fb_target = [fb.x, fb.y, z]; - let fb_zoom = fb.zoom; - - if (size.width > 0 && size.height > 0) { - // If there are margins/rulers in the viewport (axes2DLayer) we have to account for that. - // Camera target should be in the middle of viewport minus the rulers. - const w_bounds = w; - const h_bounds = h; - - const ml = viewPortMargins.left; - const mr = viewPortMargins.right; - const mb = viewPortMargins.bottom; - const mt = viewPortMargins.top; - - // Subtract margins. - const marginH = (ml > 0 ? ml : 0) + (mr > 0 ? mr : 0); - const marginV = (mb > 0 ? mb : 0) + (mt > 0 ? mt : 0); - - w = size.width - marginH; // width of the viewport minus margin. - h = size.height - marginV; - - // Special case if matrix views. - // Use width and height for a sub-view instead of full viewport. - if (views?.layout) { - const [nY, nX] = views.layout; - const isMatrixViews = nX !== 1 || nY !== 1; - if (isMatrixViews) { - const mPixels = views?.marginPixels ?? 0; - - const w_ = 99.5 / nX; // Using 99.5% of viewport to avoid flickering of deckgl canvas - const h_ = 99.5 / nY; - - const marginHorPercentage = - 100 * 100 * (mPixels / (w_ * size.width)); //percentage of sub view - const marginVerPercentage = - 100 * 100 * (mPixels / (h_ * size.height)); - - const sub_w = (w_ / 100) * size.width; - const sub_h = (h_ / 100) * size.height; - - w = sub_w * (1 - 2 * (marginHorPercentage / 100)) - marginH; - h = sub_h * (1 - 2 * (marginVerPercentage / 100)) - marginV; - } - } - - const port_aspect = h / w; - const bounds_aspect = h_bounds / w_bounds; - - const m_pr_pixel = - bounds_aspect > port_aspect ? h_bounds / h : w_bounds / w; - - let translate_x = 0; - if (ml > 0 && mr === 0) { - // left margin and no right margin - translate_x = 0.5 * ml * m_pr_pixel; - } else if (ml === 0 && mr > 0) { - // no left margin but right margin - translate_x = -0.5 * mr * m_pr_pixel; - } - - let translate_y = 0; - if (mb > 0 && mt === 0) { - translate_y = 0.5 * mb * m_pr_pixel; - } else if (mb === 0 && mt > 0) { - translate_y = -0.5 * mt * m_pr_pixel; - } - - const fb = fitBounds({ width: w, height: h, bounds }); - fb_target = [fb.x - translate_x, fb.y - translate_y, z]; - fb_zoom = fb.zoom; - } - - const view_state: ViewStateType = { - target: viewPort.target ?? fb_target, - zoom: viewPort.zoom ?? fb_zoom, - rotationX: 90, // look down z -axis - rotationOrbit: 0, - minZoom: viewPort.show3D ? minZoom3D : minZoom2D, - maxZoom: viewPort.show3D ? maxZoom3D : maxZoom2D, - }; - return view_state; -} - -/////////////////////////////////////////////////////////////////////////////////////////// -// build views -type ViewTypeType = - | typeof ZScaleOrbitView - | typeof IntersectionView - | typeof OrthographicView; -function getVT( - viewport: ViewportType -): [ - ViewType: ViewTypeType, - Controller: typeof ZScaleOrbitController | typeof OrthographicController, -] { - if (viewport.show3D) { - return [ZScaleOrbitView, ZScaleOrbitController]; - } - return [ - viewport.id === "intersection_view" - ? IntersectionView - : OrthographicView, - OrthographicController, - ]; -} - -function areViewsValid(views: ViewsType | undefined, size: Size): boolean { - const isInvalid: boolean = - views?.viewports === undefined || - views?.layout === undefined || - !views?.layout?.[0] || - !views?.layout?.[1] || - !size.width || - !size.height; - return !isInvalid; -} - -/** returns a new View instance. */ -function newView( - viewport: ViewportType, - x: number | string, - y: number | string, - width: number | string, - height: number | string -): View { - const far = 9999; - const near = viewport.show3D ? 0.1 : -9999; - - const [ViewType, Controller] = getVT(viewport); - return new ViewType({ - id: viewport.id, - controller: { - type: Controller, - doubleClickZoom: false, - }, - - x, - y, - width, - height, - - flipY: false, - far, - near, - }); -} - -function buildDeckGlViews(views: ViewsType | undefined, size: Size): View[] { - const isOk = areViewsValid(views, size); - if (!views || !isOk) { - return [ - new OrthographicView({ - id: "main", - controller: { doubleClickZoom: false }, - x: "0%", - y: "0%", - width: "100%", - height: "100%", - flipY: false, - far: +99999, - near: -99999, - }), - ]; - } - - // compute - - const [nY, nX] = views.layout; - // compute for single view (code is more readable) - const singleView = nX === 1 && nY === 1; - if (singleView) { - // Using 99.5% of viewport to avoid flickering of deckgl canvas - return [newView(views.viewports[0], 0, 0, "95%", "95%")]; - } - - // compute for matrix - const result: View[] = []; - const w = 99.5 / nX; // Using 99.5% of viewport to avoid flickering of deckgl canvas - const h = 99.5 / nY; - const marginPixels = views.marginPixels ?? 0; - const marginHorPercentage = 100 * 100 * (marginPixels / (w * size.width)); - const marginVerPercentage = 100 * 100 * (marginPixels / (h * size.height)); - let yPos = 0; - for (let y = 1; y <= nY; y++) { - let xPos = 0; - for (let x = 1; x <= nX; x++) { - if (result.length >= views.viewports.length) { - // stop when all the viewports are filled - return result; - } - - const currentViewport: ViewportType = - views.viewports[result.length]; - - const viewX = xPos + marginHorPercentage / nX + "%"; - const viewY = yPos + marginVerPercentage / nY + "%"; - const viewWidth = w * (1 - 2 * (marginHorPercentage / 100)) + "%"; - const viewHeight = h * (1 - 2 * (marginVerPercentage / 100)) + "%"; - - result.push( - newView(currentViewport, viewX, viewY, viewWidth, viewHeight) - ); - xPos = xPos + w; - } - yPos = yPos + h; - } - return result; -} - -/** - * Returns the camera if it is fully specified (ie. the zoom is a valid number), otherwise computes - * the zoom to visualize the complete camera boundingBox if set, the provided boundingBox otherwise. - * @param camera input camera - * @param boundingBox fallback bounding box, if the camera zoom is not zoom a value nor a bounding box - */ -function updateViewState( - camera: ViewStateType, - boundingBox: BoundingBox3D | undefined, - size: Size -): ViewStateType { - if (typeof camera.zoom === "number" && !Number.isNaN(camera.zoom)) { - return camera; - } - - // update the camera to see the whole boundingBox - if (Array.isArray(camera.zoom)) { - boundingBox = camera.zoom as BoundingBox3D; - } - - // return the camera if the bounding box is undefined - if (boundingBox === undefined) { - return camera; - } - - // clone the camera in case of triggerHome - const camera_ = cloneDeep(camera); - camera_.zoom = computeCameraZoom(camera, boundingBox, size); - camera_.target = boxCenter(boundingBox); - camera_.minZoom = camera_.minZoom ?? minZoom3D; - camera_.maxZoom = camera_.maxZoom ?? maxZoom3D; - return camera_; -} - -/** - * - * @returns Computes the view state - */ -function computeViewState( - viewPort: ViewportType, - cameraPosition: ViewStateType | undefined, - boundingBox: BoundingBox3D | undefined, - bounds: BoundingBox2D | BoundsAccessor | undefined, - viewportMargins: MarginsType, - views: ViewsType | undefined, - size: Size -): ViewStateType | undefined { - // If the camera is defined, use it - const isCameraPositionDefined = cameraPosition !== undefined; - const isBoundsDefined = bounds !== undefined; - - if (viewPort.show3D ?? false) { - // If the camera is defined, use it - if (isCameraPositionDefined) { - return updateViewState(cameraPosition, boundingBox, size); - } - - // deprecated in 3D, kept for backward compatibility - if (isBoundsDefined) { - const centerOfData: [number, number, number] = boundingBox - ? boxCenter(boundingBox) - : [0, 0, 0]; - return getViewStateFromBounds( - viewportMargins, - bounds, - centerOfData, - views, - viewPort, - size - ); - } - const defaultCamera = { - target: [], - zoom: NaN, - rotationX: 45, // look down z -axis at 45 degrees - rotationOrbit: 0, - }; - return updateViewState(defaultCamera, boundingBox, size); - } else { - const centerOfData: [number, number, number] = boundingBox - ? boxCenter(boundingBox) - : [0, 0, 0]; - // if bounds are defined, use them - if (isBoundsDefined) { - return getViewStateFromBounds( - viewportMargins, - bounds, - centerOfData, - views, - viewPort, - size - ); - } - - // deprecated in 2D, kept for backward compatibility - if (isCameraPositionDefined) { - return cameraPosition; - } - - return boundingBox - ? getViewStateFromBounds( - viewportMargins, - // use the bounding box to extract the 2D bounds - [ - boundingBox[0], - boundingBox[1], - boundingBox[3], - boundingBox[4], - ], - centerOfData, - views, - viewPort, - size - ) - : undefined; - } -} - -function buildDeckGlViewStates( - views: ViewsType | undefined, - viewPortMargins: MarginsType, - cameraPosition: ViewStateType | undefined, - boundingBox: BoundingBox3D | undefined, - bounds: BoundingBox2D | BoundsAccessor | undefined, - size: Size -): Record { - const isOk = areViewsValid(views, size); - if (!views || !isOk) { - return {}; - } - - // compute - - const [nY, nX] = views.layout; - // compute for single view (code is more readable) - const singleView = nX === 1 && nY === 1; - if (singleView) { - const viewState = computeViewState( - views.viewports[0], - cameraPosition, - boundingBox, - bounds, - viewPortMargins, - views, - size - ); - return viewState ? { [views.viewports[0].id]: viewState } : {}; - } - - // compute for matrix - let result: Record = {} as Record< - string, - ViewStateType - >; - for (let y = 1; y <= nY; y++) { - for (let x = 1; x <= nX; x++) { - const resultLength = Object.keys(result).length; - if (resultLength >= views.viewports.length) { - // stop when all the viewports are filled - return result; - } - const currentViewport: ViewportType = views.viewports[resultLength]; - const currentViewState = computeViewState( - currentViewport, - cameraPosition, - boundingBox, - bounds, - viewPortMargins, - views, - size - ); - if (currentViewState) { - result = { - ...result, - [currentViewport.id]: currentViewState, - }; - } - } - } - return result; -} - -function handleMouseEvent( - type: "click" | "hover", - infos: PickingInfo[], - event: MjolnirEvent -) { - const ev: MapMouseEvent = { - type: type, - infos: infos, - }; - if (ev.type === "click") { - if ((event as MjolnirPointerEvent).rightButton) ev.type = "contextmenu"; - } - for (const info of infos as LayerPickInfo[]) { - if (info.coordinate) { - ev.x = info.coordinate[0]; - ev.y = info.coordinate[1]; - } - if (info.layer && info.layer.id === "wells-layer") { - // info.object is Feature or WellLog; - { - // try to use Object info (see DeckGL getToolTip callback) - const feat = info.object as Feature; - const properties = feat?.properties; - if (properties) { - ev.wellname = properties["name"]; - ev.wellcolor = properties["color"]; - } - } - - if (!ev.wellname) - if (info.object) { - ev.wellname = info.object.header?.["well"]; // object is WellLog - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if (info.properties) { - for (const property of info.properties) { - if (!ev.wellcolor) ev.wellcolor = property.color; - let propname = property.name; - if (propname) { - const sep = propname.indexOf(" "); - if (sep >= 0) { - if (!ev.wellname) { - ev.wellname = propname.substring(sep + 1); - } - propname = propname.substring(0, sep); - } - } - const names_md = [ - "DEPTH", - "DEPT", - "MD" /*Measured Depth*/, - "TDEP" /*"Tool DEPth"*/, - "MD_RKB" /*Rotary Relly Bushing*/, - ]; // aliases for MD - const names_tvd = [ - "TVD" /*True Vertical Depth*/, - "TVDSS" /*SubSea*/, - "DVER" /*"VERtical Depth"*/, - "TVD_MSL" /*below Mean Sea Level*/, - ]; // aliases for MD - - if (names_md.find((name) => name == propname)) - ev.md = parseFloat(property.value as string); - else if (names_tvd.find((name) => name == propname)) - ev.tvd = parseFloat(property.value as string); - - if ( - ev.md !== undefined && - ev.tvd !== undefined && - ev.wellname !== undefined - ) - break; - } - } - break; - } - } - return ev; -} + reportBoundingBox: dispatchBoundingBox, + // Set "modelLayer" matrix to reflect correct z scaling. + modelMatrix: m, + }); + }); + }, [layers, zScale]); + + const [isLoaded, setIsLoaded] = useState(false); + const onAfterRender = useCallback(() => { + if (deckGLLayers) { + const loadedState = deckGLLayers.every((layer) => { + return ( + (layer as Layer).isLoaded || !(layer as Layer).props.visible + ); + }); + + const emptyLayers = // There will always be a dummy layer. Deck.gl does not like empty array of layers. + deckGLLayers.length == 1 && + (deckGLLayers[0] as LineLayer).id === + "webviz_internal_dummy_layer"; + + setIsLoaded(loadedState || emptyLayers); + if (isRenderedCallback) { + isRenderedCallback(loadedState); + } + } + }, [deckGLLayers, isRenderedCallback]); + + // validate layers data + const [errorText, setErrorText] = useState(); + useEffect(() => { + const layers = deckRef.current?.deck?.props.layers as Layer[]; + // this ensures to validate the schemas only once + if (checkDatafileSchema && layers && isLoaded) { + try { + validateLayers(layers); + colorTables && validateColorTables(colorTables); + } catch (e) { + setErrorText(String(e)); + } + } else setErrorText(undefined); + }, [ + checkDatafileSchema, + colorTables, + deckRef?.current?.deck?.props.layers, + isLoaded, + ]); + + const layerFilter = useCallback( + (args: { layer: Layer; viewport: Viewport }): boolean => { + // display all the layers if views are not specified correctly + if (!views?.viewports || !views?.layout) { + return true; + } + + const cur_view = views.viewports.find( + ({ id }) => args.viewport.id && id === args.viewport.id + ); + if (cur_view?.layerIds && cur_view.layerIds.length > 0) { + const layer_ids = cur_view.layerIds; + return layer_ids.some((layer_id) => { + const t = layer_id === args.layer.id; + return t; + }); + } else { + return true; + } + }, + [views] + ); + + const onViewStateChange = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ({ viewId, viewState }: { viewId: string; viewState: any }) => { + viewController.onViewStateChange(viewId, viewState); + if (getCameraPosition) { + getCameraPosition(viewState); + } + }, + [getCameraPosition, viewController] + ); + + const effects = parseLights(lights); + + const [deckGlViews, deckGlViewState] = useMemo(() => { + const state = { + triggerHome, + camera: cameraPosition, + bounds, + boundingBox3d: dataBoundingBox3d, + viewPortMargins, + deckSize, + zScale, + }; + return viewController.getViews(views, state); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + triggerHome, + cameraPosition, + bounds, + dataBoundingBox3d, + viewPortMargins, + deckSize, + views, + zScale, + applyViewController, + viewController, + ]); + + if (!deckGlViews || isEmpty(deckGlViews) || isEmpty(deckGLLayers)) + return null; + return ( +
event.preventDefault()}> + ) => { + setSelectedWell(updated_prop["selectedWell"] as string); + if ( + Object.keys(updated_prop).includes("selectedWell") + ) { + if (shiftHeld) { + if ( + multipleWells.includes( + updated_prop["selectedWell"] as string + ) + ) { + const temp = multipleWells.filter( + (item) => + item !== + updated_prop["selectedWell"] + ); + setMultipleWells(temp); + } else { + const temp = multipleWells.concat( + updated_prop["selectedWell"] as string + ); + setMultipleWells(temp); + } + } else { + setMultipleWells([]); + } + } + setEditedData?.(updated_prop); + }, + colorTables: colorTables, + }} + getCursor={({ isDragging }): string => + isDragging ? "grabbing" : "default" + } + getTooltip={getTooltip} + ref={deckRef} + onViewStateChange={onViewStateChange} + onHover={onHover} + onClick={onClick} + onAfterRender={onAfterRender} + effects={effects} + onDragStart={onDragStart} + onDragEnd={onDragEnd} + onResize={onResize} + > + {children} + + {scale?.visible ? ( + + ) : null} + + {coords?.visible ? : null} + {errorText && ( +
+                    {errorText}
+                
+ )} +
+ ); +}; + +Map.defaultProps = { + coords: { + visible: true, + multiPicking: true, + pickDepth: 10, + }, + scale: { + visible: true, + incrementValue: 100, + widthPerUnit: 100, + cssStyle: { top: 10, left: 10 }, + }, + toolbar: { + visible: false, + }, + coordinateUnit: "m", + views: { + layout: [1, 1], + showLabel: false, + viewports: [{ id: "main-view", show3D: false, layerIds: [] }], + }, + colorTables: colorTables, + checkDatafileSchema: false, +}; + +export default Map; + +// ------------- Helper functions ---------- // + +// Add the resources as an enum in the Json Configuration and then convert the spec to actual objects. +// See https://deck.gl/docs/api-reference/json/overview for more details. +export function jsonToObject( + data: Record[] | LayerProps[], + enums: Record[] | undefined = undefined +): LayersList | View[] { + if (!data) return []; + + const configuration = new JSONConfiguration(JSON_CONVERTER_CONFIG); + enums?.forEach((enumeration) => { + if (enumeration) { + configuration.merge({ + enumerations: { + ...enumeration, + }, + }); + } + }); + const jsonConverter = new JSONConverter({ configuration }); + + // remove empty data/layer object + const filtered_data = data.filter((value) => !isEmpty(value)); + return jsonConverter.convert(filtered_data); +} + +/////////////////////////////////////////////////////////////////////////////////////////// +// View Controller +// Implements the algorithms to compute the views and the view state +type ViewControllerState = { + // Explicit state + triggerHome: number | undefined; + camera: ViewStateType | undefined; + bounds: BoundingBox2D | BoundsAccessor | undefined; + boundingBox3d: BoundingBox3D | undefined; + deckSize: Size; + zScale: number; + viewPortMargins: MarginsType; +}; +type ViewControllerDerivedState = { + // Derived state + target: [number, number, number] | undefined; + viewStateChanged: boolean; +}; +type ViewControllerFullState = ViewControllerState & ViewControllerDerivedState; +class ViewController { + private rerender_: React.DispatchWithoutAction; + + private state_: ViewControllerFullState = { + triggerHome: undefined, + camera: undefined, + bounds: undefined, + boundingBox3d: undefined, + deckSize: { width: 0, height: 0 }, + zScale: 1, + viewPortMargins: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + // Derived state + target: undefined, + viewStateChanged: false, + }; + + private derivedState_: ViewControllerDerivedState = { + target: undefined, + viewStateChanged: false, + }; + + private views_: ViewsType | undefined = undefined; + private result_: { + views: View[]; + viewState: Record; + } = { + views: [], + viewState: {}, + }; + + public constructor(rerender: React.DispatchWithoutAction) { + this.rerender_ = rerender; + } + + public readonly setTarget = (target: [number, number, number]) => { + this.derivedState_.target = [target[0], target[1], target[2]]; + this.rerender_(); + }; + + public readonly getViews = ( + views: ViewsType | undefined, + state: ViewControllerState + ): [View[], Record] => { + const fullState = this.consolidateState(state); + const newViews = this.getDeckGlViews(views, fullState); + const newViewState = this.getDeckGlViewState(views, fullState); + + if ( + this.result_.views !== newViews || + this.result_.viewState !== newViewState + ) { + const viewsMsg = this.result_.views !== newViews ? " views" : ""; + const stateMsg = + this.result_.viewState !== newViewState ? " state" : ""; + const linkMsg = viewsMsg && stateMsg ? " and" : ""; + + console.log( + `ViewController returns new${viewsMsg}${linkMsg}${stateMsg}` + ); + } + + this.state_ = fullState; + this.views_ = views; + this.result_.views = newViews; + this.result_.viewState = newViewState; + return [newViews, newViewState]; + }; + + // consolidate "controlled" state (ie. set by parent) with "uncontrolled" state + private readonly consolidateState = ( + state: ViewControllerState + ): ViewControllerFullState => { + return { ...state, ...this.derivedState_ }; + }; + + // returns the DeckGL views (ie. view position and viewport) + private readonly getDeckGlViews = ( + views: ViewsType | undefined, + state: ViewControllerFullState + ) => { + const needUpdate = + views != this.views_ || state.deckSize != this.state_.deckSize; + if (!needUpdate) { + return this.result_.views; + } + return buildDeckGlViews(views, state.deckSize); + }; + + // returns the DeckGL views state(s) (ie. camera settings applied to individual views) + private readonly getDeckGlViewState = ( + views: ViewsType | undefined, + state: ViewControllerFullState + ): Record => { + const viewsChanged = views != this.views_; + const triggerHome = state.triggerHome !== this.state_.triggerHome; + const updateTarget = + (viewsChanged || state.target !== this.state_?.target) && + state.target !== undefined; + const updateZScale = + viewsChanged || state.zScale !== this.state_?.zScale || triggerHome; + const updateViewState = + viewsChanged || + triggerHome || + (!state.viewStateChanged && + state.boundingBox3d !== this.state_.boundingBox3d); + const needUpdate = updateZScale || updateTarget || updateViewState; + + const isCacheEmpty = isEmpty(this.result_.viewState); + if (!isCacheEmpty && !needUpdate) { + return this.result_.viewState; + } + + // initialize with last result + const prevViewState = this.result_.viewState; + let viewState = prevViewState; + + if (updateViewState || isCacheEmpty) { + viewState = buildDeckGlViewStates( + views, + state.viewPortMargins, + state.camera, + state.boundingBox3d, + state.bounds, + state.deckSize + ); + // reset state + this.derivedState_.viewStateChanged = false; + } + + // check if view state could be computed + if (isEmpty(viewState)) { + return viewState; + } + + const viewStateKeys = Object.keys(viewState); + if ( + updateTarget && + this.derivedState_.target && + viewStateKeys?.length === 1 + ) { + // deep clone to notify change (memo checks object address) + if (viewState === prevViewState) { + viewState = cloneDeep(prevViewState); + } + // update target + viewState[viewStateKeys[0]].target = this.derivedState_.target; + viewState[viewStateKeys[0]].transitionDuration = 1000; + // reset + this.derivedState_.target = undefined; + } + if (updateZScale) { + // deep clone to notify change (memo checks object address) + if (viewState === prevViewState) { + viewState = cloneDeep(prevViewState); + } + // Z scale to apply to target. + // - if triggerHome: the target was recomputed from the input data (ie. without any scale applied) + // - otherwise: previous scale (ie. this.state_.zScale) was already applied, and must be "reverted" + const targetScale = + state.zScale / (triggerHome ? 1 : this.state_.zScale); + // update target + for (const key in viewState) { + const t = viewState[key].target; + if (t) { + viewState[key].target = [t[0], t[1], t[2] * targetScale]; + } + } + } + return viewState; + }; + + public readonly onViewStateChange = ( + viewId: string, + viewState: ViewStateType + ): void => { + const viewports = this.views_?.viewports ?? []; + if (viewState.target.length === 2) { + // In orthographic mode viewState.target contains only x and y. Add existing z value. + viewState.target.push(this.result_.viewState[viewId].target[2]); + } + const isSyncIds = viewports + .filter((item) => item.isSync) + .map((item) => item.id); + if (isSyncIds?.includes(viewId)) { + const viewStateTable = this.views_?.viewports + .filter((item) => item.isSync) + .map((item) => [item.id, viewState]); + const tempViewStates = Object.fromEntries(viewStateTable ?? []); + this.result_.viewState = { + ...this.result_.viewState, + ...tempViewStates, + }; + } else { + this.result_.viewState = { + ...this.result_.viewState, + [viewId]: viewState, + }; + } + this.derivedState_.viewStateChanged = true; + this.rerender_(); + }; +} + +/** + * Returns the zoom factor allowing to view the complete boundingBox. + * @param camera camera defining the view orientation. + * @param boundingBox 3D bounding box to visualize. + * @param fov field of view (see deck.gl file orbit-viewports.ts). + */ +function computeCameraZoom( + camera: ViewStateType, + boundingBox: BoundingBox3D, + size: Size, + fovy = 50 +): number { + const DEGREES_TO_RADIANS = Math.PI / 180; + const RADIANS_TO_DEGREES = 180 / Math.PI; + const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + + const fD = fovyToAltitude(fovy); + + const xMin = boundingBox[0]; + const yMin = boundingBox[1]; + const zMin = boundingBox[2]; + + const xMax = boundingBox[3]; + const yMax = boundingBox[4]; + const zMax = boundingBox[5]; + + const target = [ + xMin + (xMax - xMin) / 2, + yMin + (yMax - yMin) / 2, + zMin + (zMax - zMin) / 2, + ]; + + const cameraFovVertical = 50; + const angle_ver = (cameraFovVertical / 2) * DEGREES_TO_RADIANS; + const L = size.height / 2 / Math.sin(angle_ver); + const r = L * Math.cos(angle_ver); + const cameraFov = 2 * Math.atan(size.width / 2 / r) * RADIANS_TO_DEGREES; + const angle_hor = (cameraFov / 2) * DEGREES_TO_RADIANS; + + const points: [number, number, number][] = []; + points.push([xMin, yMin, zMin]); + points.push([xMin, yMax, zMin]); + points.push([xMax, yMax, zMin]); + points.push([xMax, yMin, zMin]); + points.push([xMin, yMin, zMax]); + points.push([xMin, yMax, zMax]); + points.push([xMax, yMax, zMax]); + points.push([xMax, yMin, zMax]); + + let zoom = 999; + for (const point of points) { + const x_ = (point[0] - target[0]) / size.height; + const y_ = (point[1] - target[1]) / size.height; + const z_ = (point[2] - target[2]) / size.height; + + const m = new Matrix4(IDENTITY); + m.rotateX(camera.rotationX * DEGREES_TO_RADIANS); + m.rotateZ(camera.rotationOrbit * DEGREES_TO_RADIANS); + + const [x, y, z] = m.transformAsVector([x_, y_, z_]); + if (y >= 0) { + // These points will actually appear further away when zooming in. + continue; + } + + const fwX = fD * Math.tan(angle_hor); + let y_new = fwX / (Math.abs(x) / y - fwX / fD); + const zoom_x = Math.log2(y_new / y); + + const fwY = fD * Math.tan(angle_ver); + y_new = fwY / (Math.abs(z) / y - fwY / fD); + const zoom_z = Math.log2(y_new / y); + + // it needs to be inside view volume in both directions. + zoom = zoom_x < zoom ? zoom_x : zoom; + zoom = zoom_z < zoom ? zoom_z : zoom; + } + return zoom; +} + +/////////////////////////////////////////////////////////////////////////////////////////// +// return viewstate with computed bounds to fit the data in viewport +function getViewStateFromBounds( + viewPortMargins: MarginsType, + bounds_accessor: BoundingBox2D | BoundsAccessor, + target: [number, number, number], + views: ViewsType | undefined, + viewPort: ViewportType, + size: Size +): ViewStateType | undefined { + const bounds = + typeof bounds_accessor == "function" + ? bounds_accessor() + : bounds_accessor; + + let w = bounds[2] - bounds[0]; // right - left + let h = bounds[3] - bounds[1]; // top - bottom + + const z = target[2]; + + const fb = fitBounds({ width: w, height: h, bounds }); + let fb_target = [fb.x, fb.y, z]; + let fb_zoom = fb.zoom; + + if (size.width > 0 && size.height > 0) { + // If there are margins/rulers in the viewport (axes2DLayer) we have to account for that. + // Camera target should be in the middle of viewport minus the rulers. + const w_bounds = w; + const h_bounds = h; + + const ml = viewPortMargins.left; + const mr = viewPortMargins.right; + const mb = viewPortMargins.bottom; + const mt = viewPortMargins.top; + + // Subtract margins. + const marginH = (ml > 0 ? ml : 0) + (mr > 0 ? mr : 0); + const marginV = (mb > 0 ? mb : 0) + (mt > 0 ? mt : 0); + + w = size.width - marginH; // width of the viewport minus margin. + h = size.height - marginV; + + // Special case if matrix views. + // Use width and height for a sub-view instead of full viewport. + if (views?.layout) { + const [nY, nX] = views.layout; + const isMatrixViews = nX !== 1 || nY !== 1; + if (isMatrixViews) { + const mPixels = views?.marginPixels ?? 0; + + const w_ = 99.5 / nX; // Using 99.5% of viewport to avoid flickering of deckgl canvas + const h_ = 99.5 / nY; + + const marginHorPercentage = + 100 * 100 * (mPixels / (w_ * size.width)); //percentage of sub view + const marginVerPercentage = + 100 * 100 * (mPixels / (h_ * size.height)); + + const sub_w = (w_ / 100) * size.width; + const sub_h = (h_ / 100) * size.height; + + w = sub_w * (1 - 2 * (marginHorPercentage / 100)) - marginH; + h = sub_h * (1 - 2 * (marginVerPercentage / 100)) - marginV; + } + } + + const port_aspect = h / w; + const bounds_aspect = h_bounds / w_bounds; + + const m_pr_pixel = + bounds_aspect > port_aspect ? h_bounds / h : w_bounds / w; + + let translate_x = 0; + if (ml > 0 && mr === 0) { + // left margin and no right margin + translate_x = 0.5 * ml * m_pr_pixel; + } else if (ml === 0 && mr > 0) { + // no left margin but right margin + translate_x = -0.5 * mr * m_pr_pixel; + } + + let translate_y = 0; + if (mb > 0 && mt === 0) { + translate_y = 0.5 * mb * m_pr_pixel; + } else if (mb === 0 && mt > 0) { + translate_y = -0.5 * mt * m_pr_pixel; + } + + const fb = fitBounds({ width: w, height: h, bounds }); + fb_target = [fb.x - translate_x, fb.y - translate_y, z]; + fb_zoom = fb.zoom; + } + + const view_state: ViewStateType = { + target: viewPort.target ?? fb_target, + zoom: viewPort.zoom ?? fb_zoom, + rotationX: 90, // look down z -axis + rotationOrbit: 0, + minZoom: viewPort.show3D ? minZoom3D : minZoom2D, + maxZoom: viewPort.show3D ? maxZoom3D : maxZoom2D, + }; + return view_state; +} + +/////////////////////////////////////////////////////////////////////////////////////////// +// build views +type ViewTypeType = + | typeof ZScaleOrbitView + | typeof IntersectionView + | typeof OrthographicView; +function getVT( + viewport: ViewportType +): [ + ViewType: ViewTypeType, + Controller: typeof ZScaleOrbitController | typeof OrthographicController, +] { + if (viewport.show3D) { + return [ZScaleOrbitView, ZScaleOrbitController]; + } + return [ + viewport.id === "intersection_view" + ? IntersectionView + : OrthographicView, + OrthographicController, + ]; +} + +function areViewsValid(views: ViewsType | undefined, size: Size): boolean { + const isInvalid: boolean = + views?.viewports === undefined || + views?.layout === undefined || + !views?.layout?.[0] || + !views?.layout?.[1] || + !size.width || + !size.height; + return !isInvalid; +} + +/** returns a new View instance. */ +function newView( + viewport: ViewportType, + x: number | string, + y: number | string, + width: number | string, + height: number | string +): View { + const far = 9999; + const near = viewport.show3D ? 0.1 : -9999; + + const [ViewType, Controller] = getVT(viewport); + return new ViewType({ + id: viewport.id, + controller: { + type: Controller, + doubleClickZoom: false, + }, + + x, + y, + width, + height, + + flipY: false, + far, + near, + }); +} + +function buildDeckGlViews(views: ViewsType | undefined, size: Size): View[] { + const isOk = areViewsValid(views, size); + if (!views || !isOk) { + return [ + new OrthographicView({ + id: "main", + controller: { doubleClickZoom: false }, + x: "0%", + y: "0%", + width: "100%", + height: "100%", + flipY: false, + far: +99999, + near: -99999, + }), + ]; + } + + // compute + + const [nY, nX] = views.layout; + // compute for single view (code is more readable) + const singleView = nX === 1 && nY === 1; + if (singleView) { + // Using 99.5% of viewport to avoid flickering of deckgl canvas + return [newView(views.viewports[0], 0, 0, "95%", "95%")]; + } + + // compute for matrix + const result: View[] = []; + const w = 99.5 / nX; // Using 99.5% of viewport to avoid flickering of deckgl canvas + const h = 99.5 / nY; + const marginPixels = views.marginPixels ?? 0; + const marginHorPercentage = 100 * 100 * (marginPixels / (w * size.width)); + const marginVerPercentage = 100 * 100 * (marginPixels / (h * size.height)); + let yPos = 0; + for (let y = 1; y <= nY; y++) { + let xPos = 0; + for (let x = 1; x <= nX; x++) { + if (result.length >= views.viewports.length) { + // stop when all the viewports are filled + return result; + } + + const currentViewport: ViewportType = + views.viewports[result.length]; + + const viewX = xPos + marginHorPercentage / nX + "%"; + const viewY = yPos + marginVerPercentage / nY + "%"; + const viewWidth = w * (1 - 2 * (marginHorPercentage / 100)) + "%"; + const viewHeight = h * (1 - 2 * (marginVerPercentage / 100)) + "%"; + + result.push( + newView(currentViewport, viewX, viewY, viewWidth, viewHeight) + ); + xPos = xPos + w; + } + yPos = yPos + h; + } + return result; +} + +/** + * Returns the camera if it is fully specified (ie. the zoom is a valid number), otherwise computes + * the zoom to visualize the complete camera boundingBox if set, the provided boundingBox otherwise. + * @param camera input camera + * @param boundingBox fallback bounding box, if the camera zoom is not zoom a value nor a bounding box + */ +function updateViewState( + camera: ViewStateType, + boundingBox: BoundingBox3D | undefined, + size: Size +): ViewStateType { + if (typeof camera.zoom === "number" && !Number.isNaN(camera.zoom)) { + return camera; + } + + // update the camera to see the whole boundingBox + if (Array.isArray(camera.zoom)) { + boundingBox = camera.zoom as BoundingBox3D; + } + + // return the camera if the bounding box is undefined + if (boundingBox === undefined) { + return camera; + } + + // clone the camera in case of triggerHome + const camera_ = cloneDeep(camera); + camera_.zoom = computeCameraZoom(camera, boundingBox, size); + camera_.target = boxCenter(boundingBox); + camera_.minZoom = camera_.minZoom ?? minZoom3D; + camera_.maxZoom = camera_.maxZoom ?? maxZoom3D; + return camera_; +} + +/** + * + * @returns Computes the view state + */ +function computeViewState( + viewPort: ViewportType, + cameraPosition: ViewStateType | undefined, + boundingBox: BoundingBox3D | undefined, + bounds: BoundingBox2D | BoundsAccessor | undefined, + viewportMargins: MarginsType, + views: ViewsType | undefined, + size: Size +): ViewStateType | undefined { + // If the camera is defined, use it + const isCameraPositionDefined = cameraPosition !== undefined; + const isBoundsDefined = bounds !== undefined; + + if (viewPort.show3D ?? false) { + // If the camera is defined, use it + if (isCameraPositionDefined) { + return updateViewState(cameraPosition, boundingBox, size); + } + + // deprecated in 3D, kept for backward compatibility + if (isBoundsDefined) { + const centerOfData: [number, number, number] = boundingBox + ? boxCenter(boundingBox) + : [0, 0, 0]; + return getViewStateFromBounds( + viewportMargins, + bounds, + centerOfData, + views, + viewPort, + size + ); + } + const defaultCamera = { + target: [], + zoom: NaN, + rotationX: 45, // look down z -axis at 45 degrees + rotationOrbit: 0, + }; + return updateViewState(defaultCamera, boundingBox, size); + } else { + const centerOfData: [number, number, number] = boundingBox + ? boxCenter(boundingBox) + : [0, 0, 0]; + // if bounds are defined, use them + if (isBoundsDefined) { + return getViewStateFromBounds( + viewportMargins, + bounds, + centerOfData, + views, + viewPort, + size + ); + } + + // deprecated in 2D, kept for backward compatibility + if (isCameraPositionDefined) { + return cameraPosition; + } + + return boundingBox + ? getViewStateFromBounds( + viewportMargins, + // use the bounding box to extract the 2D bounds + [ + boundingBox[0], + boundingBox[1], + boundingBox[3], + boundingBox[4], + ], + centerOfData, + views, + viewPort, + size + ) + : undefined; + } +} + +function buildDeckGlViewStates( + views: ViewsType | undefined, + viewPortMargins: MarginsType, + cameraPosition: ViewStateType | undefined, + boundingBox: BoundingBox3D | undefined, + bounds: BoundingBox2D | BoundsAccessor | undefined, + size: Size +): Record { + const isOk = areViewsValid(views, size); + if (!views || !isOk) { + return {}; + } + + // compute + + const [nY, nX] = views.layout; + // compute for single view (code is more readable) + const singleView = nX === 1 && nY === 1; + if (singleView) { + const viewState = computeViewState( + views.viewports[0], + cameraPosition, + boundingBox, + bounds, + viewPortMargins, + views, + size + ); + return viewState ? { [views.viewports[0].id]: viewState } : {}; + } + + // compute for matrix + let result: Record = {} as Record< + string, + ViewStateType + >; + for (let y = 1; y <= nY; y++) { + for (let x = 1; x <= nX; x++) { + const resultLength = Object.keys(result).length; + if (resultLength >= views.viewports.length) { + // stop when all the viewports are filled + return result; + } + const currentViewport: ViewportType = views.viewports[resultLength]; + const currentViewState = computeViewState( + currentViewport, + cameraPosition, + boundingBox, + bounds, + viewPortMargins, + views, + size + ); + if (currentViewState) { + result = { + ...result, + [currentViewport.id]: currentViewState, + }; + } + } + } + return result; +} + +function handleMouseEvent( + type: "click" | "hover", + infos: PickingInfo[], + event: MjolnirEvent +) { + const ev: MapMouseEvent = { + type: type, + infos: infos, + }; + if (ev.type === "click") { + if ((event as MjolnirPointerEvent).rightButton) ev.type = "contextmenu"; + } + for (const info of infos as LayerPickInfo[]) { + if (info.coordinate) { + ev.x = info.coordinate[0]; + ev.y = info.coordinate[1]; + } + if (info.layer && info.layer.id === "wells-layer") { + // info.object is Feature or WellLog; + { + // try to use Object info (see DeckGL getToolTip callback) + const feat = info.object as Feature; + const properties = feat?.properties; + if (properties) { + ev.wellname = properties["name"]; + ev.wellcolor = properties["color"]; + } + } + + if (!ev.wellname) + if (info.object) { + ev.wellname = info.object.header?.["well"]; // object is WellLog + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (info.properties) { + for (const property of info.properties) { + if (!ev.wellcolor) ev.wellcolor = property.color; + let propname = property.name; + if (propname) { + const sep = propname.indexOf(" "); + if (sep >= 0) { + if (!ev.wellname) { + ev.wellname = propname.substring(sep + 1); + } + propname = propname.substring(0, sep); + } + } + const names_md = [ + "DEPTH", + "DEPT", + "MD" /*Measured Depth*/, + "TDEP" /*"Tool DEPth"*/, + "MD_RKB" /*Rotary Relly Bushing*/, + ]; // aliases for MD + const names_tvd = [ + "TVD" /*True Vertical Depth*/, + "TVDSS" /*SubSea*/, + "DVER" /*"VERtical Depth"*/, + "TVD_MSL" /*below Mean Sea Level*/, + ]; // aliases for MD + + if (names_md.find((name) => name == propname)) + ev.md = parseFloat(property.value as string); + else if (names_tvd.find((name) => name == propname)) + ev.tvd = parseFloat(property.value as string); + + if ( + ev.md !== undefined && + ev.tvd !== undefined && + ev.wellname !== undefined + ) + break; + } + } + break; + } + } + return ev; +} From d631f95ed1a2e0c702ecc77f6cc9494c711c8860 Mon Sep 17 00:00:00 2001 From: Christophe Winkler Date: Mon, 18 Dec 2023 10:21:38 +0100 Subject: [PATCH 7/8] Fix test snapshots --- .../SubsurfaceViewer.test.tsx.snap | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/typescript/packages/subsurface-viewer/src/__snapshots__/SubsurfaceViewer.test.tsx.snap b/typescript/packages/subsurface-viewer/src/__snapshots__/SubsurfaceViewer.test.tsx.snap index 31cc3acb01..a85fe4b110 100644 --- a/typescript/packages/subsurface-viewer/src/__snapshots__/SubsurfaceViewer.test.tsx.snap +++ b/typescript/packages/subsurface-viewer/src/__snapshots__/SubsurfaceViewer.test.tsx.snap @@ -31,6 +31,19 @@ exports[`Test Map component snapshot test 1`] = ` style="left: 0px; top: 0px; width: 100%; position: absolute; height: 100%;" />
+
+ +
+
+
+ +
+
+
+ +
+
Date: Mon, 18 Dec 2023 11:39:44 +0100 Subject: [PATCH 8/8] Fix Map tests (both crash and snapshots). Add script to debug jest tests --- .../packages/subsurface-viewer/package.json | 1 + .../src/components/Map.test.tsx | 3 ++- .../__snapshots__/Map.test.tsx.snap | 26 +++++++++++++++++++ .../src/layers/utils/layerTools.ts | 6 +++-- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/typescript/packages/subsurface-viewer/package.json b/typescript/packages/subsurface-viewer/package.json index 7f6dd272a2..5bac076a8e 100644 --- a/typescript/packages/subsurface-viewer/package.json +++ b/typescript/packages/subsurface-viewer/package.json @@ -16,6 +16,7 @@ "test": "jest --coverage", "test:update": "npm test -- --u", "test:watch": "npm test -- --watch", + "test:debug": "node --inspect-brk ../../node_modules/jest/bin/jest.js --coverage=false --runInBand", "doc": "git clean -xdff docs && typedoc src" }, "author": "Equinor ", diff --git a/typescript/packages/subsurface-viewer/src/components/Map.test.tsx b/typescript/packages/subsurface-viewer/src/components/Map.test.tsx index e3bd5a93db..c9d21a36a1 100644 --- a/typescript/packages/subsurface-viewer/src/components/Map.test.tsx +++ b/typescript/packages/subsurface-viewer/src/components/Map.test.tsx @@ -8,6 +8,7 @@ import type { LayersList } from "@deck.gl/core/typed"; import Map from "./Map"; import { EmptyWrapper } from "../test/TestWrapper"; import { colorTables } from "@emerson-eps/color-tables"; +import type { colorTablesArray } from "@emerson-eps/color-tables/"; import { ColormapLayer, DrawingLayer, @@ -21,7 +22,7 @@ import { import mapData from "../../../../../example-data/deckgl-map.json"; import type { Unit } from "convert-units"; -const colorTablesData = colorTables; +const colorTablesData = colorTables as colorTablesArray; const testBounds = [432205, 6475078, 437720, 6481113] as [ number, number, diff --git a/typescript/packages/subsurface-viewer/src/components/__snapshots__/Map.test.tsx.snap b/typescript/packages/subsurface-viewer/src/components/__snapshots__/Map.test.tsx.snap index 49c3f4e0e2..f1677d18f8 100644 --- a/typescript/packages/subsurface-viewer/src/components/__snapshots__/Map.test.tsx.snap +++ b/typescript/packages/subsurface-viewer/src/components/__snapshots__/Map.test.tsx.snap @@ -31,6 +31,19 @@ exports[`Test Map component snapshot test 1`] = ` style="left: 0px; top: 0px; width: 100%; position: absolute; height: 100%;" />
+
+ +
+
+
+ +
+
{ return ( l?.constructor.name === type && - (l as NewLayersList).props.data.features.find( + (l as NewLayersList).props.data?.features?.find( (item) => item.properties.name === selectedWell ) );