(
props.colorScaleSpecification?.colorScale.getGradientType() === ColorScaleGradientType.Diverging
? props.colorScaleSpecification?.colorScale.getColorPalette()
- : props.workbenchSettings.getColorPalettes()[ColorPaletteType.ContinuousDiverging][0] ?? ""
+ : (props.workbenchSettings.getColorPalettes()[ColorPaletteType.ContinuousDiverging][0] ?? ""),
);
if (!isEqual(props.colorScaleSpecification, prevColorScaleSpecification)) {
@@ -163,7 +163,7 @@ function ColorScaleSelectorDialog(props: ColorScaleSelectorProps): React.ReactNo
colorScaleSpecification.colorScale.getMax(),
colorScaleSpecification.colorScale.getNumSteps(),
colorScaleSpecification.colorScale.getDivMidPoint(),
- colorScaleSpecification.areBoundariesUserDefined
+ colorScaleSpecification.areBoundariesUserDefined,
);
}
@@ -181,7 +181,7 @@ function ColorScaleSelectorDialog(props: ColorScaleSelectorProps): React.ReactNo
colorScaleSpecification.colorScale.getMax(),
colorScaleSpecification.colorScale.getNumSteps(),
colorScaleSpecification.colorScale.getDivMidPoint(),
- colorScaleSpecification.areBoundariesUserDefined
+ colorScaleSpecification.areBoundariesUserDefined,
);
}
@@ -199,7 +199,7 @@ function ColorScaleSelectorDialog(props: ColorScaleSelectorProps): React.ReactNo
colorScaleSpecification.colorScale.getMax(),
colorScaleSpecification.colorScale.getNumSteps(),
colorScaleSpecification.colorScale.getDivMidPoint(),
- colorScaleSpecification.areBoundariesUserDefined
+ colorScaleSpecification.areBoundariesUserDefined,
);
}
@@ -212,7 +212,7 @@ function ColorScaleSelectorDialog(props: ColorScaleSelectorProps): React.ReactNo
colorScaleSpecification.colorScale.getMax(),
numSteps,
colorScaleSpecification.colorScale.getDivMidPoint(),
- colorScaleSpecification.areBoundariesUserDefined
+ colorScaleSpecification.areBoundariesUserDefined,
);
}
@@ -225,7 +225,7 @@ function ColorScaleSelectorDialog(props: ColorScaleSelectorProps): React.ReactNo
max: number,
numSteps: number,
divMid: number,
- areBoundariesUserDefined: boolean
+ areBoundariesUserDefined: boolean,
) {
const colorScale = new ColorScale({
colorPalette,
@@ -244,7 +244,7 @@ function ColorScaleSelectorDialog(props: ColorScaleSelectorProps): React.ReactNo
onChange(colorScaleSpecification);
}
},
- [onChange]
+ [onChange],
);
const handleMinMaxDivMidPointChange = React.useCallback(
@@ -257,10 +257,10 @@ function ColorScaleSelectorDialog(props: ColorScaleSelectorProps): React.ReactNo
max,
colorScaleSpecification.colorScale.getNumSteps(),
divMidPoint ?? colorScaleSpecification.colorScale.getDivMidPoint(),
- colorScaleSpecification.areBoundariesUserDefined
+ colorScaleSpecification.areBoundariesUserDefined,
);
},
- [colorScaleSpecification, makeAndPropagateColorScale]
+ [colorScaleSpecification, makeAndPropagateColorScale],
);
const handleAreBoundariesUserDefinedChange = React.useCallback(
@@ -273,10 +273,10 @@ function ColorScaleSelectorDialog(props: ColorScaleSelectorProps): React.ReactNo
colorScaleSpecification.colorScale.getMax(),
colorScaleSpecification.colorScale.getNumSteps(),
colorScaleSpecification.colorScale.getDivMidPoint(),
- areBoundariesUserDefined
+ areBoundariesUserDefined,
);
},
- [colorScaleSpecification, makeAndPropagateColorScale]
+ [colorScaleSpecification, makeAndPropagateColorScale],
);
return (
@@ -387,15 +387,14 @@ function ColorScaleSetter(props: ColorScaleSetterProps): React.ReactNode {
const handleMinMaxDivMidPointChange = React.useCallback(function handleMinMaxDivMidPointChange(
min: number,
max: number,
- divMidPoint?: number
+ divMidPoint?: number,
) {
setMin(min);
setMax(max);
if (divMidPoint !== undefined) {
setDivMidPoint(divMidPoint);
}
- },
- []);
+ }, []);
return (
@@ -439,10 +438,10 @@ function MinMaxDivMidPointSetter(props: MinMaxDivMidPointSetterProps): React.Rea
const [divMidPoint, setDivMidPoint] = React.useState(props.divMidPoint);
const [prevDivMidPoint, setPrevDivMidPoint] = React.useState(props.divMidPoint);
const [areBoundariesUserDefined, setAreBoundariesUserDefined] = React.useState(
- props.areBoundariesUserDefined
+ props.areBoundariesUserDefined,
);
const [prevAreBoundariesUserDefined, setPrevAreBoundariesUserDefined] = React.useState(
- props.areBoundariesUserDefined
+ props.areBoundariesUserDefined,
);
const [isDragging, setIsDragging] = React.useState(false);
@@ -520,7 +519,7 @@ function MinMaxDivMidPointSetter(props: MinMaxDivMidPointSetterProps): React.Rea
const newRelativeDivMidPoint = Math.min(
Math.max((dx + convertRemToPixels(0.75) - containerRect.left) / containerRect.width, 0),
- 1
+ 1,
);
newDivMidPoint = min + newRelativeDivMidPoint * (max - min);
@@ -550,7 +549,7 @@ function MinMaxDivMidPointSetter(props: MinMaxDivMidPointSetterProps): React.Rea
document.removeEventListener("pointerup", handlePointerUp);
};
},
- [onChange, onChangePreview, min, max]
+ [onChange, onChangePreview, min, max],
);
function handleMinChange(value: string) {
@@ -609,7 +608,7 @@ function MinMaxDivMidPointSetter(props: MinMaxDivMidPointSetterProps): React.Rea
"z-50": isDragging,
hidden:
props.gradientType === ColorScaleGradientType.Sequential || !areBoundariesUserDefined,
- }
+ },
)}
style={{ left: `${(Math.abs(divMidPoint - min) / Math.abs(max - min)) * 100}%` }}
ref={divMidPointRef}
@@ -677,7 +676,7 @@ const ColorScalePaletteSelector: React.FC = (pro
const [open, setOpen] = React.useState(false);
const [selectedColorPalette, setSelectedColorPalette] = React.useState(props.selectedColorPalette);
const [prevSelectedColorPalette, setPrevSelectedColorPalette] = React.useState(
- props.selectedColorPalette
+ props.selectedColorPalette,
);
if (prevSelectedColorPalette.getId() !== props.selectedColorPalette.getId()) {
@@ -769,7 +768,7 @@ const ColorScalePaletteSelector: React.FC = (pro
>
{renderColorPalettes()}
- >
+ >,
)}
);
diff --git a/frontend/src/framework/internal/components/Content/private-components/layout.tsx b/frontend/src/framework/internal/components/Content/private-components/layout.tsx
index 4b8748488..a2f059e3c 100644
--- a/frontend/src/framework/internal/components/Content/private-components/layout.tsx
+++ b/frontend/src/framework/internal/components/Content/private-components/layout.tsx
@@ -14,7 +14,7 @@ import type { Rect2D, Size2D } from "@lib/utils/geometry";
import { MANHATTAN_LENGTH, addMarginToRect, pointRelativeToDomRect, rectContainsPoint } from "@lib/utils/geometry";
import { convertRemToPixels } from "@lib/utils/screenUnitConversions";
import type { Vec2 } from "@lib/utils/vec2";
-import { multiplyVec2, point2Distance, scaleVec2NonUniform, subtractVec2, vec2FromPointerEvent } from "@lib/utils/vec2";
+import { multiplyElementWiseVec2, point2Distance, scaleVec2NonUniform, subtractVec2, vec2FromPointerEvent } from "@lib/utils/vec2";
import { ViewWrapper } from "./ViewWrapper";
import { ViewWrapperPlaceholder } from "./viewWrapperPlaceholder";
@@ -91,7 +91,7 @@ export const Layout: React.FC = (props) => {
setPosition(
subtractVec2(
relativePointerPosition,
- multiplyVec2(relativePointerToElementDiff, {
+ multiplyElementWiseVec2(relativePointerToElementDiff, {
x: draggedElementSize.width,
y: 1,
}),
@@ -193,7 +193,7 @@ export const Layout: React.FC = (props) => {
setPosition(
subtractVec2(
relativePointerPosition,
- multiplyVec2(relativePointerToElementDiff, {
+ multiplyElementWiseVec2(relativePointerToElementDiff, {
x: draggedElementSize.width,
y: 1,
}),
diff --git a/frontend/src/framework/userCreatedItems/IntersectionPolylines.ts b/frontend/src/framework/userCreatedItems/IntersectionPolylines.ts
index 7c02070ca..721add8dd 100644
--- a/frontend/src/framework/userCreatedItems/IntersectionPolylines.ts
+++ b/frontend/src/framework/userCreatedItems/IntersectionPolylines.ts
@@ -1,5 +1,5 @@
import { atom } from "jotai";
-import { cloneDeep } from "lodash";
+import { cloneDeep, isEqual } from "lodash";
import { v4 } from "uuid";
import type { AtomStoreMaster } from "@framework/AtomStoreMaster";
@@ -8,6 +8,7 @@ import type { UserCreatedItemSet } from "@framework/UserCreatedItems";
export type IntersectionPolyline = {
id: string;
name: string;
+ color: [number, number, number];
path: number[][];
fieldId: string;
};
@@ -55,7 +56,15 @@ export class IntersectionPolylines implements UserCreatedItemSet {
this.notifySubscribers(IntersectionPolylinesEvent.CHANGE);
}
- getPolylines(): IntersectionPolyline[] {
+ setPolylines(polylines: IntersectionPolyline[]): void {
+ if (isEqual(this._polylines, polylines)) {
+ return;
+ }
+ this._polylines = polylines;
+ this.notifySubscribers(IntersectionPolylinesEvent.CHANGE);
+ }
+
+ getPolylines(): readonly IntersectionPolyline[] {
return this._polylines;
}
diff --git a/frontend/src/lib/components/ToggleButton/toggleButton.tsx b/frontend/src/lib/components/ToggleButton/toggleButton.tsx
index 4215f501b..f7381b134 100644
--- a/frontend/src/lib/components/ToggleButton/toggleButton.tsx
+++ b/frontend/src/lib/components/ToggleButton/toggleButton.tsx
@@ -14,6 +14,12 @@ export type ToggleButtonProps = ButtonUnstyledProps & {
function ToggleButtonComponent(props: ToggleButtonProps, ref: React.ForwardedRef) {
const { active, onToggle, ...other } = props;
const [isActive, setIsActive] = React.useState(active);
+ const [prevActive, setPrevActive] = React.useState(active);
+
+ if (active !== prevActive) {
+ setIsActive(active);
+ setPrevActive(active);
+ }
const buttonRef = React.useRef(null);
React.useImperativeHandle(
diff --git a/frontend/src/lib/icons/icons/addPathPointIcon.tsx b/frontend/src/lib/icons/icons/addPathPointIcon.tsx
new file mode 100644
index 000000000..77d5da3be
--- /dev/null
+++ b/frontend/src/lib/icons/icons/addPathPointIcon.tsx
@@ -0,0 +1,11 @@
+import { createSvgIcon } from "@mui/material";
+
+export const AddPathPointIcon = createSvgIcon(
+ ,
+ "AddPathPoint"
+);
diff --git a/frontend/src/lib/icons/icons/pathIcon.tsx b/frontend/src/lib/icons/icons/pathIcon.tsx
new file mode 100644
index 000000000..39b6d38b0
--- /dev/null
+++ b/frontend/src/lib/icons/icons/pathIcon.tsx
@@ -0,0 +1,10 @@
+import { createSvgIcon } from "@mui/material";
+
+export const DrawPathIcon = createSvgIcon(
+ ,
+ "DrawPath"
+);
diff --git a/frontend/src/lib/icons/icons/removePathPointIcon.tsx b/frontend/src/lib/icons/icons/removePathPointIcon.tsx
new file mode 100644
index 000000000..c643ac9e6
--- /dev/null
+++ b/frontend/src/lib/icons/icons/removePathPointIcon.tsx
@@ -0,0 +1,11 @@
+import { createSvgIcon } from "@mui/material";
+
+export const RemovePathPointIcon = createSvgIcon(
+ ,
+ "RemovePathPoint"
+);
diff --git a/frontend/src/lib/icons/index.ts b/frontend/src/lib/icons/index.ts
new file mode 100644
index 000000000..efa0a00f2
--- /dev/null
+++ b/frontend/src/lib/icons/index.ts
@@ -0,0 +1,3 @@
+export { DrawPathIcon } from "./icons/pathIcon";
+export { AddPathPointIcon } from "./icons/addPathPointIcon";
+export { RemovePathPointIcon } from "./icons/removePathPointIcon";
diff --git a/frontend/src/lib/utils/boundingBox.ts b/frontend/src/lib/utils/boundingBox.ts
new file mode 100644
index 000000000..b71dddf23
--- /dev/null
+++ b/frontend/src/lib/utils/boundingBox.ts
@@ -0,0 +1,117 @@
+import { BoundingBox3D_api } from "@api";
+
+import * as vec3 from "./vec3";
+
+export type BBox = {
+ min: vec3.Vec3;
+ max: vec3.Vec3;
+};
+
+/**
+ * Creates a new bounding box.
+ * @param min The minimum point of the bounding box.
+ * @param max The maximum point of the bounding box.
+ * @returns A new bounding box.
+ */
+export function create(min: vec3.Vec3, max: vec3.Vec3): BBox {
+ return { min, max };
+}
+
+export function fromBoundingBox3DApi(boundingBox: BoundingBox3D_api): BBox {
+ return create(
+ vec3.create(boundingBox.xmin, boundingBox.ymin, boundingBox.zmin),
+ vec3.create(boundingBox.xmax, boundingBox.ymax, boundingBox.zmax)
+ );
+}
+
+export function fromBoundingBox2DApi(boundingBox: BoundingBox3D_api): BBox {
+ return create(
+ vec3.create(boundingBox.xmin, boundingBox.ymin, 0),
+ vec3.create(boundingBox.xmax, boundingBox.ymax, 0)
+ );
+}
+
+/**
+ Returns true if the bounding box contains the given point.
+ */
+export function containsPoint(box: BBox, point: vec3.Vec3): boolean {
+ return (
+ point.x >= box.min.x &&
+ point.x <= box.max.x &&
+ point.y >= box.min.y &&
+ point.y <= box.max.y &&
+ point.z >= box.min.z &&
+ point.z <= box.max.z
+ );
+}
+
+/**
+ Returns true if the two bounding boxes intersect.
+ */
+export function intersects(box1: BBox, box2: BBox): boolean {
+ return (
+ box1.min.x <= box2.max.x &&
+ box1.max.x >= box2.min.x &&
+ box1.min.y <= box2.max.y &&
+ box1.max.y >= box2.min.y &&
+ box1.min.z <= box2.max.z &&
+ box1.max.z >= box2.min.z
+ );
+}
+
+/**
+ * Returns true if outerBox contains innerBox.
+ */
+export function outerBoxcontainsInnerBox(outerBox: BBox, innerBox: BBox): boolean {
+ return (
+ outerBox.min.x <= innerBox.min.x &&
+ outerBox.min.y <= innerBox.min.y &&
+ outerBox.min.z <= innerBox.min.z &&
+ outerBox.max.x >= innerBox.max.x &&
+ outerBox.max.y >= innerBox.max.y &&
+ outerBox.max.z >= innerBox.max.z
+ );
+}
+
+/**
+ Converts a bounding box to an array of numbers.
+ The array contains the following numbers in the following order:
+ [min.x, min.y, min.z, max.x, max.y, max.z]
+ */
+export function toNumArray(box: BBox): [number, number, number, number, number, number] {
+ return [box.min.x, box.min.y, box.min.z, box.max.x, box.max.y, box.max.z];
+}
+
+/**
+ Converts an array of numbers to a bounding box.
+ The array should contain the following numbers in the following order:
+ [min.x, min.y, min.z, max.x, max.y, max.z]
+ */
+export function fromNumArray(array: [number, number, number, number, number, number]): BBox {
+ return create(vec3.fromArray(array.slice(0, 3)), vec3.fromArray(array.slice(3, 6)));
+}
+
+/**
+ * Clones the given bounding box.
+ */
+export function clone(box: BBox): BBox {
+ return create(vec3.clone(box.min), vec3.clone(box.max));
+}
+
+/**
+ * Combines the two bounding boxes into a new bounding box that contains both.
+ */
+export function combine(box1: BBox, box2: BBox): BBox {
+ return create(
+ vec3.create(
+ Math.min(box1.min.x, box2.min.x),
+ Math.min(box1.min.y, box2.min.y),
+ Math.min(box1.min.z, box2.min.z)
+ ),
+ vec3.create(
+ Math.max(box1.max.x, box2.max.x),
+ Math.max(box1.max.y, box2.max.y),
+ Math.max(box1.max.z, box2.max.z)
+ )
+ );
+}
diff --git a/frontend/src/lib/utils/colorConstants.ts b/frontend/src/lib/utils/colorConstants.ts
new file mode 100644
index 000000000..98f7748b9
--- /dev/null
+++ b/frontend/src/lib/utils/colorConstants.ts
@@ -0,0 +1,11 @@
+// This is used to set colors for different states throughout the application where CSS properties are not accessible (e.g. WebGL).
+// However, it should be in sync with what is set in Tailwind CSS.
+// The location of this file can still be decided upon.
+export type Colors = {
+ hover: [number, number, number];
+ selected: [number, number, number];
+};
+export const COLORS: Colors = {
+ hover: [191, 219, 254],
+ selected: [37, 99, 235],
+};
diff --git a/frontend/src/lib/utils/geometry.ts b/frontend/src/lib/utils/geometry.ts
index 7b1bd5613..97b981b50 100644
--- a/frontend/src/lib/utils/geometry.ts
+++ b/frontend/src/lib/utils/geometry.ts
@@ -1,10 +1,18 @@
+import { BBox } from "./boundingBox";
import type { Vec2 } from "./vec2";
+import { Vec3 } from "./vec3";
export type Size2D = {
width: number;
height: number;
};
+export type Size3D = {
+ width: number;
+ height: number;
+ depth: number;
+};
+
export type Rect2D = {
x: number;
y: number;
@@ -12,6 +20,36 @@ export type Rect2D = {
height: number;
};
+export type Rect3D = {
+ x: number;
+ y: number;
+ z: number;
+ width: number;
+ height: number;
+ depth: number;
+};
+
+export enum ShapeType {
+ BOX = "box",
+}
+
+export type Shape = {
+ type: ShapeType.BOX;
+ centerPoint: Vec3;
+ dimensions: Size3D;
+ normalizedEdgeVectors: {
+ // along width
+ u: Vec3;
+ // along height
+ v: Vec3;
+ };
+};
+
+export type Geometry = {
+ shapes: Shape[];
+ boundingBox: BBox;
+};
+
export const ORIGIN = Object.freeze({ x: 0, y: 0 });
export const MANHATTAN_LENGTH = 13.11;
@@ -78,7 +116,20 @@ export function addMarginToRect(rect: Rect2D, margin: number): Rect2D {
};
}
-export function outerRectContainsInnerRect(outerRect: Rect2D, innerRect: Rect2D): boolean {
+export function outerRectContainsInnerRect(outerRect: Rect3D, innerRect: Rect3D): boolean;
+export function outerRectContainsInnerRect(outerRect: Rect2D, innerRect: Rect2D): boolean;
+export function outerRectContainsInnerRect(outerRect: Rect2D | Rect3D, innerRect: Rect2D | Rect3D): boolean {
+ if ("depth" in outerRect && "depth" in innerRect) {
+ return (
+ outerRect.x <= innerRect.x &&
+ outerRect.y <= innerRect.y &&
+ outerRect.z <= innerRect.z &&
+ outerRect.x + outerRect.width >= innerRect.x + innerRect.width &&
+ outerRect.y + outerRect.height >= innerRect.y + innerRect.height &&
+ outerRect.z + outerRect.depth >= innerRect.z + innerRect.depth
+ );
+ }
+
return (
outerRect.x <= innerRect.x &&
outerRect.y <= innerRect.y &&
diff --git a/frontend/src/lib/utils/index.ts b/frontend/src/lib/utils/index.ts
new file mode 100644
index 000000000..a8c6b55be
--- /dev/null
+++ b/frontend/src/lib/utils/index.ts
@@ -0,0 +1 @@
+export * as bbox from "./boundingBox";
diff --git a/frontend/src/lib/utils/mat3.ts b/frontend/src/lib/utils/mat3.ts
new file mode 100644
index 000000000..6864161b7
--- /dev/null
+++ b/frontend/src/lib/utils/mat3.ts
@@ -0,0 +1,83 @@
+/**
+ * A 3x3 matrix.
+ */
+export type Mat3 = {
+ m00: number;
+ m01: number;
+ m02: number;
+ m10: number;
+ m11: number;
+ m12: number;
+ m20: number;
+ m21: number;
+ m22: number;
+};
+
+/**
+ * Creates a new 3x3 matrix with all elements set to 0.
+ *
+ * @returns A new 3x3 matrix.
+ */
+export function createEmpty(): Mat3 {
+ return {
+ m00: 0,
+ m01: 0,
+ m02: 0,
+ m10: 0,
+ m11: 0,
+ m12: 0,
+ m20: 0,
+ m21: 0,
+ m22: 0,
+ };
+}
+
+/**
+ * Creates a new 3x3 matrix with the given elements.
+ *
+ * @param m00 The element at row 0, column 0.
+ * @param m01 The element at row 0, column 1.
+ * @param m02 The element at row 0, column 2.
+ * @param m10 The element at row 1, column 0.
+ * @param m11 The element at row 1, column 1.
+ * @param m12 The element at row 1, column 2.
+ * @param m20 The element at row 2, column 0.
+ * @param m21 The element at row 2, column 1.
+ * @param m22 The element at row 2, column 2.
+ * @returns A new 3x3 matrix.
+ */
+export function create(
+ m00: number,
+ m01: number,
+ m02: number,
+ m10: number,
+ m11: number,
+ m12: number,
+ m20: number,
+ m21: number,
+ m22: number
+): Mat3 {
+ return { m00, m01, m02, m10, m11, m12, m20, m21, m22 };
+}
+
+/**
+ * Creates a new 3x3 matrix from the given array of numbers.
+ *
+ * @param array An array of numbers in the following order: [m00, m01, m02, m10, m11, m12, m20, m21, m22].
+ * @returns A new 3x3 matrix.
+ */
+export function fromArray(
+ array: ArrayLike | [number, number, number, number, number, number, number, number, number]
+): Mat3 {
+ return {
+ m00: array[0],
+ m01: array[1],
+ m02: array[2],
+ m10: array[3],
+ m11: array[4],
+ m12: array[5],
+ m20: array[6],
+ m21: array[7],
+ m22: array[8],
+ };
+}
\ No newline at end of file
diff --git a/frontend/src/lib/utils/orientedBoundingBox.ts b/frontend/src/lib/utils/orientedBoundingBox.ts
new file mode 100644
index 000000000..989d15608
--- /dev/null
+++ b/frontend/src/lib/utils/orientedBoundingBox.ts
@@ -0,0 +1,164 @@
+import * as bbox from "./boundingBox";
+import * as vec3 from "./vec3";
+
+export type OBBox = {
+ centerPoint: vec3.Vec3;
+ principalAxes: vec3.Vec3[];
+ halfExtents: number[];
+};
+
+/**
+ * Creates a new oriented bounding box.
+ */
+export function create(center: vec3.Vec3, principalAxes: vec3.Vec3[], halfExtents: number[]): OBBox {
+ return { centerPoint: center, principalAxes, halfExtents };
+}
+
+/*
+ * Returns true if the oriented bounding box contains the given point.
+ */
+export function containsPoint(box: OBBox, point: vec3.Vec3): boolean {
+ const diff = vec3.subtract(point, box.centerPoint);
+ return (
+ Math.abs(vec3.dot(diff, box.principalAxes[0])) <= box.halfExtents[0] &&
+ Math.abs(vec3.dot(diff, box.principalAxes[1])) <= box.halfExtents[1] &&
+ Math.abs(vec3.dot(diff, box.principalAxes[2])) <= box.halfExtents[2]
+ );
+}
+
+/**
+ * Converts an oriented bounding box to an axis-aligned bounding box.
+ */
+export function toAxisAlignedBoundingBox(box: OBBox): bbox.BBox {
+ const absAxisX = vec3.abs(box.principalAxes[0]);
+ const absAxisY = vec3.abs(box.principalAxes[1]);
+ const absAxisZ = vec3.abs(box.principalAxes[2]);
+
+ const halfSize: vec3.Vec3 = {
+ x: box.halfExtents[0] * absAxisX.x + box.halfExtents[1] * absAxisY.x + box.halfExtents[2] * absAxisZ.x,
+ y: box.halfExtents[0] * absAxisX.y + box.halfExtents[1] * absAxisY.y + box.halfExtents[2] * absAxisZ.y,
+ z: box.halfExtents[0] * absAxisX.z + box.halfExtents[1] * absAxisY.z + box.halfExtents[2] * absAxisZ.z,
+ };
+ return bbox.create(vec3.subtract(box.centerPoint, halfSize), vec3.add(box.centerPoint, halfSize));
+}
+
+export function fromAxisAlignedBoundingBox(box: bbox.BBox): OBBox {
+ const centerPoint = vec3.scale(vec3.add(box.min, box.max), 0.5);
+ const principalAxes = [vec3.create(1, 0, 0), vec3.create(0, 1, 0), vec3.create(0, 0, 1)];
+ const halfExtents = vec3.scale(vec3.subtract(box.max, box.min), 0.5);
+ return create(centerPoint, principalAxes, [halfExtents.x, halfExtents.y, halfExtents.z]);
+}
+
+/**
+ * Returns true if outerBox contains innerBox.
+ */
+export function containsBox(outerBox: OBBox, innerBox: OBBox): boolean {
+ const points = calcCornerPoints(innerBox);
+ for (const point of points) {
+ if (!containsPoint(outerBox, point)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Returns the corner points of the oriented bounding box.
+ *
+ * The points are returned in the following order for z-axis up coordinate system:
+ * 0: bottom front left
+ * 1: bottom front right
+ * 2: bottom back left
+ * 3: bottom back right
+ * 4: top front left
+ * 5: top front right
+ * 6: top back left
+ * 7: top back right
+ */
+
+export function calcCornerPoints(box: OBBox): vec3.Vec3[] {
+ const halfExtents = box.halfExtents;
+ const principalAxes = box.principalAxes;
+ const centerPoint = box.centerPoint;
+
+ const points: vec3.Vec3[] = [];
+ for (let i = 0; i < 8; i++) {
+ const x = (i & 1) === 0 ? -1 : 1;
+ const y = (i & 2) === 0 ? -1 : 1;
+ const z = (i & 4) === 0 ? -1 : 1;
+ const point = vec3.add(
+ centerPoint,
+ vec3.add(
+ vec3.scale(principalAxes[0], x * halfExtents[0]),
+ vec3.add(
+ vec3.scale(principalAxes[1], y * halfExtents[1]),
+ vec3.scale(principalAxes[2], z * halfExtents[2])
+ )
+ )
+ );
+ points.push(point);
+ }
+ return points;
+}
+
+/**
+ * Creates an oriented bounding box from the given corner points.
+ */
+export function fromCornerPoints(points: vec3.Vec3[]): OBBox {
+ const min = vec3.create(Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE);
+ const max = vec3.create(-Number.MAX_VALUE, -Number.MAX_VALUE, -Number.MAX_VALUE);
+ for (const point of points) {
+ min.x = Math.min(min.x, point.x);
+ min.y = Math.min(min.y, point.y);
+ min.z = Math.min(min.z, point.z);
+ max.x = Math.max(max.x, point.x);
+ max.y = Math.max(max.y, point.y);
+ max.z = Math.max(max.z, point.z);
+ }
+ const center = vec3.scale(vec3.add(min, max), 0.5);
+ const halfExtents = vec3.scale(vec3.subtract(max, min), 0.5);
+ const principalAxes = [
+ vec3.normalize(vec3.subtract(points[0], points[1])),
+ vec3.normalize(vec3.subtract(points[0], points[2])),
+ vec3.normalize(vec3.subtract(points[0], points[4])),
+ ];
+ return create(center, principalAxes, [halfExtents.x, halfExtents.y, halfExtents.z]);
+}
+
+/**
+ * Combines two oriented bounding boxes into a new oriented bounding box that contains both input boxes.
+ */
+export function combine(box1: OBBox, box2: OBBox): OBBox {
+ const points1 = calcCornerPoints(box1);
+ const points2 = calcCornerPoints(box2);
+ const allPoints = points1.concat(points2);
+ const min = vec3.create(Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE);
+ const max = vec3.create(-Number.MAX_VALUE, -Number.MAX_VALUE, -Number.MAX_VALUE);
+ for (const point of allPoints) {
+ min.x = Math.min(min.x, point.x);
+ min.y = Math.min(min.y, point.y);
+ min.z = Math.min(min.z, point.z);
+ max.x = Math.max(max.x, point.x);
+ max.y = Math.max(max.y, point.y);
+ max.z = Math.max(max.z, point.z);
+ }
+ const center = vec3.scale(vec3.add(min, max), 0.5);
+ const halfExtents = vec3.scale(vec3.subtract(max, min), 0.5);
+ const principalAxes = [
+ vec3.normalize(vec3.subtract(points1[0], points1[1])),
+ vec3.normalize(vec3.subtract(points1[0], points1[2])),
+ vec3.normalize(vec3.subtract(points1[0], points1[4])),
+ ];
+ return create(center, principalAxes, [halfExtents.x, halfExtents.y, halfExtents.z]);
+}
+
+/**
+ * Clones the given oriented bounding box.
+ */
+export function clone(box: OBBox): OBBox {
+ return create(
+ vec3.clone(box.centerPoint),
+ [vec3.clone(box.principalAxes[0]), vec3.clone(box.principalAxes[1]), vec3.clone(box.principalAxes[2])],
+ [...box.halfExtents]
+ );
+}
diff --git a/frontend/src/lib/utils/vec2.ts b/frontend/src/lib/utils/vec2.ts
index d07b0a03a..9d10e6db1 100644
--- a/frontend/src/lib/utils/vec2.ts
+++ b/frontend/src/lib/utils/vec2.ts
@@ -40,7 +40,7 @@ export function scaleVec2NonUniform(vector: Vec2, scalarX: number, scalarY: numb
return { x: vector.x * scalarX, y: vector.y * scalarY };
}
-export function multiplyVec2(vecA: Vec2, vecB: Vec2): Vec2 {
+export function multiplyElementWiseVec2(vecA: Vec2, vecB: Vec2): Vec2 {
return { x: vecA.x * vecB.x, y: vecA.y * vecB.y };
}
diff --git a/frontend/src/lib/utils/vec3.ts b/frontend/src/lib/utils/vec3.ts
index 7105315cd..41cbd8e37 100644
--- a/frontend/src/lib/utils/vec3.ts
+++ b/frontend/src/lib/utils/vec3.ts
@@ -1,3 +1,5 @@
+import { Mat3 } from "./mat3";
+
/**
* A 3D vector.
*/
@@ -51,3 +53,195 @@ export function toArray(vector: Vec3): [number, number, number] {
export function clone(vector: Vec3): Vec3 {
return { x: vector.x, y: vector.y, z: vector.z };
}
+
+/**
+ * Calculates the length of the given vector.
+ *
+ * @param vector The vector.
+ * @returns The length of the vector.
+ */
+export function length(vector: Vec3): number {
+ return Math.sqrt(vector.x ** 2 + vector.y ** 2 + vector.z ** 2);
+}
+
+/**
+ * Calculates the squared length of the given vector.
+ *
+ * @param vector The vector.
+ * @returns The squared length of the vector.
+ */
+export function squaredLength(vector: Vec3): number {
+ return vector.x ** 2 + vector.y ** 2 + vector.z ** 2;
+}
+
+/**
+ * Calculates the distance between two points.
+ *
+ * @param point1 The first point.
+ * @param point2 The second point.
+ * @returns The distance between the two points.
+ */
+export function distance(point1: Vec3, point2: Vec3): number {
+ return Math.sqrt((point1.x - point2.x) ** 2 + (point1.y - point2.y) ** 2 + (point1.z - point2.z) ** 2);
+}
+
+/**
+ * Calculates the squared distance between two points.
+ *
+ * @param point1 The first point.
+ * @param point2 The second point.
+ * @returns The squared distance between the two points.
+ */
+export function squaredDistance(point1: Vec3, point2: Vec3): number {
+ return (point1.x - point2.x) ** 2 + (point1.y - point2.y) ** 2 + (point1.z - point2.z) ** 2;
+}
+
+/**
+ * Subtracts the subtrahend from the minuend.
+ *
+ * @param minuend The minuend.
+ * @param subtrahend The subtrahend.
+ * @returns A new vector that is the result of the subtraction.
+ */
+export function subtract(minuend: Vec3, subtrahend: Vec3): Vec3 {
+ return { x: minuend.x - subtrahend.x, y: minuend.y - subtrahend.y, z: minuend.z - subtrahend.z };
+}
+
+/**
+ * Adds two vectors.
+ *
+ * @param vector1 The first vector.
+ * @param vector2 The second vector.
+ * @returns A new vector that is the result of the addition.
+ */
+export function add(vector1: Vec3, vector2: Vec3): Vec3 {
+ return { x: vector1.x + vector2.x, y: vector1.y + vector2.y, z: vector1.z + vector2.z };
+}
+
+/**
+ * Concatenates multiple vectors.
+ *
+ * @param vectors The vectors to concatenate.
+ * @returns A new vector that is the result of the concatenation.
+ */
+export function concat(...vectors: Vec3[]): Vec3 {
+ return vectors.reduce((acc, vector) => add(acc, vector), create(0, 0, 0));
+}
+
+/**
+ * Normalizes the given vector.
+ *
+ * @param vector The vector.
+ * @returns A new vector that is the normalized version of the given vector.
+ */
+export function normalize(vector: Vec3): Vec3 {
+ const len = length(vector);
+ return { x: vector.x / len, y: vector.y / len, z: vector.z / len };
+}
+
+/**
+ * Negates the given vector.
+ *
+ * @param vector The vector.
+ * @returns A new vector that is the negated version of the given vector.
+ */
+export function negate(vector: Vec3): Vec3 {
+ return { x: -vector.x, y: -vector.y, z: -vector.z };
+}
+
+/**
+ * Returns the absolute values of the components of the given vector.
+ *
+ * @param vector The vector.
+ * @returns A new vector that is the absolute version of the given vector.
+ */
+export function abs(vector: Vec3): Vec3 {
+ return { x: Math.abs(vector.x), y: Math.abs(vector.y), z: Math.abs(vector.z) };
+}
+
+/**
+ * Scales the given vector by the given scalar.
+ *
+ * @param vector The vector.
+ * @param scalar The scalar.
+ * @returns A new vector that is the result of the scaling.
+ */
+export function scale(vector: Vec3, scalar: number): Vec3 {
+ return { x: vector.x * scalar, y: vector.y * scalar, z: vector.z * scalar };
+}
+
+/**
+ * Scales the given vector components by the respective given scalar.
+ *
+ * @param vector The vector.
+ * @param scalarX The scalar for the x component.
+ * @param scalarY The scalar for the y component.
+ * @param scalarZ The scalar for the z component.
+ * @returns A new vector that is the result of the scaling.
+ */
+export function scaleNonUniform(vector: Vec3, scalarX: number, scalarY: number, scalarZ: number): Vec3 {
+ return { x: vector.x * scalarX, y: vector.y * scalarY, z: vector.z * scalarZ };
+}
+
+/**
+ * Multiplies two vectors element-wise.
+ *
+ * @param vecA The first vector.
+ * @param vecB The second vector.
+ * @returns A new vector that is the result of the element-wise multiplication.
+ */
+export function multiplyElementWise(vecA: Vec3, vecB: Vec3): Vec3 {
+ return { x: vecA.x * vecB.x, y: vecA.y * vecB.y, z: vecA.z * vecB.z };
+}
+
+/**
+ * Returns true if the two vectors are equal.
+ *
+ * @param vector1 The first vector.
+ * @param vector2 The second vector.
+ * @returns True if the two vectors are equal.
+ */
+export function equal(vector1: Vec3, vector2: Vec3): boolean {
+ return vector1.x === vector2.x && vector1.y === vector2.y && vector1.z === vector2.z;
+}
+
+/**
+ * Calculates the dot product of two vectors.
+ *
+ * @param vector1 The first vector.
+ * @param vector2 The second vector.
+ * @returns The dot product of the two vectors.
+ */
+export function dot(vector1: Vec3, vector2: Vec3): number {
+ return vector1.x * vector2.x + vector1.y * vector2.y + vector1.z * vector2.z;
+}
+
+/**
+ * Calculates the cross product of two vectors.
+ *
+ * @param vector1 The first vector.
+ * @param vector2 The second vector.
+ * @returns A new vector that is the cross product of the two vectors.
+ */
+export function cross(vector1: Vec3, vector2: Vec3): Vec3 {
+ return {
+ x: vector1.y * vector2.z - vector1.z * vector2.y,
+ y: vector1.z * vector2.x - vector1.x * vector2.z,
+ z: vector1.x * vector2.y - vector1.y * vector2.x,
+ };
+}
+
+/**
+ * Transforms the given vector by the given matrix.
+ *
+ * @param vector A 3D vector to transform.
+ * @param matrix A 3x3 matrix to transform the vector with.
+ * @returns A new 3D vector that is the result of the transformation.
+ */
+export function transform(vector: Vec3, matrix: Mat3): Vec3 {
+ return {
+ x: matrix.m00 * vector.x + matrix.m01 * vector.y + matrix.m02 * vector.z,
+ y: matrix.m10 * vector.x + matrix.m11 * vector.y + matrix.m12 * vector.z,
+ z: matrix.m20 * vector.x + matrix.m21 * vector.y + matrix.m22 * vector.z,
+ };
+}
diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/annotations/makeColorScaleAnnotation.ts b/frontend/src/modules/2DViewer/DataProviderFramework/annotations/makeColorScaleAnnotation.ts
index e43098908..a9f32545a 100644
--- a/frontend/src/modules/2DViewer/DataProviderFramework/annotations/makeColorScaleAnnotation.ts
+++ b/frontend/src/modules/2DViewer/DataProviderFramework/annotations/makeColorScaleAnnotation.ts
@@ -9,6 +9,7 @@ export function makeColorScaleAnnotation({
getSetting,
id,
name,
+ getValueRange,
}: TransformerArgs<[Setting.COLOR_SCALE], any>): Annotation[] {
const colorScale = getSetting(Setting.COLOR_SCALE)?.colorScale;
@@ -16,5 +17,13 @@ export function makeColorScaleAnnotation({
return [];
}
- return [{ id, colorScale: ColorScaleWithName.fromColorScale(colorScale, name) }];
+ const range = getValueRange();
+ if (!range) {
+ return [];
+ }
+
+ const localColorScale = colorScale.clone();
+ localColorScale.setRange(range[0], range[1]);
+
+ return [{ id, colorScale: ColorScaleWithName.fromColorScale(localColorScale, name) }];
}
diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/ObservedSurfaceProvider.ts b/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/ObservedSurfaceProvider.ts
index 91ec86689..093c271f5 100644
--- a/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/ObservedSurfaceProvider.ts
+++ b/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/ObservedSurfaceProvider.ts
@@ -16,7 +16,6 @@ import type { SurfaceDataFloat_trans } from "@modules/_shared/Surface/queryDataT
import { transformSurfaceData } from "@modules/_shared/Surface/queryDataTransforms";
import { encodeSurfAddrStr } from "@modules/_shared/Surface/surfaceAddress";
-
const observedSurfaceSettings = [
Setting.ENSEMBLE,
Setting.ATTRIBUTE,
@@ -199,7 +198,7 @@ export class ObservedSurfaceProvider
resample_to_def_str: null,
},
});
-
+
registerQueryKey(surfaceDataOptions.queryKey);
const promise = queryClient
diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationGridProvider.ts b/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationGridProvider.ts
index 3fd36e43a..63c531156 100644
--- a/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationGridProvider.ts
+++ b/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationGridProvider.ts
@@ -4,6 +4,7 @@ import { getGridModelsInfoOptions, getGridParameterOptions, getGridSurfaceOption
import type { GridMappedProperty_trans, GridSurface_trans } from "@modules/3DViewer/view/queries/queryDataTransforms";
import { transformGridMappedProperty, transformGridSurface } from "@modules/3DViewer/view/queries/queryDataTransforms";
import type {
+ AreSettingsValidArgs,
CustomDataProviderImplementation,
DataProviderInformationAccessors,
FetchDataParams,
@@ -12,7 +13,6 @@ import type { DefineDependenciesArgs } from "@modules/_shared/DataProviderFramew
import type { MakeSettingTypesMap } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions";
import { Setting } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions";
-
const realizationGridSettings = [
Setting.ENSEMBLE,
Setting.REALIZATION,
@@ -133,13 +133,9 @@ export class RealizationGridProvider
registerQueryKey(gridSurfaceOptions.queryKey);
- const gridParameterPromise = queryClient
- .fetchQuery(gridParameterOptions)
- .then(transformGridMappedProperty);
+ const gridParameterPromise = queryClient.fetchQuery(gridParameterOptions).then(transformGridMappedProperty);
- const gridSurfacePromise = queryClient
- .fetchQuery(gridSurfaceOptions)
- .then(transformGridSurface);
+ const gridSurfacePromise = queryClient.fetchQuery(gridSurfaceOptions).then(transformGridSurface);
return Promise.all([gridSurfacePromise, gridParameterPromise]).then(([gridSurfaceData, gridParameterData]) => ({
gridSurfaceData,
@@ -149,7 +145,7 @@ export class RealizationGridProvider
areCurrentSettingsValid({
getSetting,
- }: DataProviderInformationAccessors): boolean {
+ }: AreSettingsValidArgs): boolean {
return (
getSetting(Setting.ENSEMBLE) !== null &&
getSetting(Setting.REALIZATION) !== null &&
diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationPolygonsProvider.ts b/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationPolygonsProvider.ts
index 80ec813ea..81f27f05f 100644
--- a/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationPolygonsProvider.ts
+++ b/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationPolygonsProvider.ts
@@ -10,7 +10,6 @@ import type { DefineDependenciesArgs } from "@modules/_shared/DataProviderFramew
import type { MakeSettingTypesMap } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions";
import { Setting } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions";
-
const realizationPolygonsSettings = [
Setting.ENSEMBLE,
Setting.REALIZATION,
diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationSurfaceProvider.ts b/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationSurfaceProvider.ts
index 844d8fca6..af26f4ea3 100644
--- a/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationSurfaceProvider.ts
+++ b/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationSurfaceProvider.ts
@@ -16,7 +16,6 @@ import type { SurfaceDataFloat_trans } from "@modules/_shared/Surface/queryDataT
import { transformSurfaceData } from "@modules/_shared/Surface/queryDataTransforms";
import { encodeSurfAddrStr } from "@modules/_shared/Surface/surfaceAddress";
-
const realizationSurfaceSettings = [
Setting.ENSEMBLE,
Setting.REALIZATION,
@@ -247,9 +246,7 @@ export class RealizationSurfaceProvider
registerQueryKey(surfaceDataOptions.queryKey);
const promise = queryClient
- .fetchQuery(
- surfaceDataOptions,
- )
+ .fetchQuery(surfaceDataOptions)
.then((data) => ({ format: this._dataFormat, surfaceData: transformSurfaceData(data) }));
return promise as Promise;
diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/StatisticalSurfaceProvider.ts b/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/StatisticalSurfaceProvider.ts
index 081f8f28c..6d62f5ef9 100644
--- a/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/StatisticalSurfaceProvider.ts
+++ b/frontend/src/modules/2DViewer/DataProviderFramework/customDataProviderImplementations/StatisticalSurfaceProvider.ts
@@ -22,7 +22,6 @@ import type { SurfaceDataFloat_trans } from "@modules/_shared/Surface/queryDataT
import { transformSurfaceData } from "@modules/_shared/Surface/queryDataTransforms";
import { encodeSurfAddrStr } from "@modules/_shared/Surface/surfaceAddress";
-
const statisicalSurfaceSettings = [
Setting.ENSEMBLE,
Setting.STATISTIC_FUNCTION,
@@ -65,7 +64,10 @@ export class StatisticalSurfaceProvider
return "Statistical Surface";
}
- doSettingsChangesRequireDataRefetch(prevSettings: SettingsWithTypes, newSettings: SettingsWithTypes): boolean {
+ doSettingsChangesRequireDataRefetch(
+ prevSettings: SettingsWithTypes | null,
+ newSettings: SettingsWithTypes,
+ ): boolean {
return !isEqual(prevSettings, newSettings);
}
diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeRealizationGridLayer.ts b/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeRealizationGridLayer.ts
index c8097c353..bf02d1979 100644
--- a/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeRealizationGridLayer.ts
+++ b/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeRealizationGridLayer.ts
@@ -33,7 +33,7 @@ export function makeRealizationGridLayer({
polysData: polysNumberArray,
propertiesData: gridParameterData.polyPropsFloat32Arr,
ZIncreasingDownwards: false,
- gridLines: showGridLines,
+ gridLines: showGridLines ?? false,
material: { ambient: 0.4, diffuse: 0.7, shininess: 8, specularColor: [25, 25, 25] },
pickable: true,
colorMapName: "Physics",
diff --git a/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeRealizationPolygonsLayer.ts b/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeRealizationPolygonsLayer.ts
index 26bbc2ab2..7109fee24 100644
--- a/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeRealizationPolygonsLayer.ts
+++ b/frontend/src/modules/2DViewer/DataProviderFramework/visualization/makeRealizationPolygonsLayer.ts
@@ -4,13 +4,12 @@ import type { Feature, FeatureCollection, GeoJsonProperties, Geometry } from "ge
import type { PolygonData_api } from "@api";
import type { TransformerArgs } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler";
-
import type {
RealizationPolygonsData,
RealizationPolygonsSettings,
} from "../customDataProviderImplementations/RealizationPolygonsProvider";
-function zipCoords(xArr: number[], yArr: number[], zArr: number[]): number[][] {
+function zipCoords(xArr: readonly number[], yArr: readonly number[], zArr: readonly number[]): number[][] {
const coords: number[][] = [];
for (let i = 0; i < xArr.length; i++) {
coords.push([xArr[i], yArr[i], -zArr[i]]);
diff --git a/frontend/src/modules/2DViewer/settings/settings.tsx b/frontend/src/modules/2DViewer/settings/settings.tsx
index df5c4b31a..b5af7cbe4 100644
--- a/frontend/src/modules/2DViewer/settings/settings.tsx
+++ b/frontend/src/modules/2DViewer/settings/settings.tsx
@@ -9,7 +9,6 @@ import { useEnsembleSet } from "@framework/WorkbenchSession";
import { CollapsibleGroup } from "@lib/components/CollapsibleGroup";
import { GroupDelegateTopic } from "@modules/_shared/DataProviderFramework/delegates/GroupDelegate";
-
import {
DataProviderManager,
DataProviderManagerTopic,
@@ -19,7 +18,6 @@ import { dataProviderManagerAtom, preferredViewLayoutAtom, userSelectedFieldIden
import { selectedFieldIdentifierAtom } from "./atoms/derivedAtoms";
import { DataProviderManagerWrapper } from "./components/dataProviderManagerWrapper";
-
export function Settings(props: ModuleSettingsProps): React.ReactNode {
const ensembleSet = useEnsembleSet(props.workbenchSession);
const queryClient = useQueryClient();
@@ -112,7 +110,6 @@ export function Settings(props: ModuleSettingsProps): React.ReactNode {
.makeSubscriberFunction(GroupDelegateTopic.CHILDREN_EXPANSION_STATES)(persistState);
return function onUnmountEffect() {
- dataProviderManager.beforeDestroy();
unsubscribeDataRev();
unsubscribeExpands();
};
diff --git a/frontend/src/modules/3DViewer/preview.webp b/frontend/src/modules/3DViewer/preview.webp
deleted file mode 100644
index d180acb38..000000000
Binary files a/frontend/src/modules/3DViewer/preview.webp and /dev/null differ
diff --git a/frontend/src/modules/3DViewer/settings/settings.tsx b/frontend/src/modules/3DViewer/settings/settings.tsx
index 798ee02de..1b1f4ecfe 100644
--- a/frontend/src/modules/3DViewer/settings/settings.tsx
+++ b/frontend/src/modules/3DViewer/settings/settings.tsx
@@ -32,7 +32,6 @@ import { resolveClassNames } from "@lib/utils/resolveClassNames";
import { usePropagateApiErrorToStatusWriter } from "@modules/_shared/hooks/usePropagateApiErrorToStatusWriter";
import { isoIntervalStringToDateLabel, isoStringToDateLabel } from "@modules/_shared/utils/isoDatetimeStringFormatting";
-
import type { Interfaces } from "../interfaces";
import type { GridCellIndexRanges } from "../typesAndEnums";
@@ -71,7 +70,6 @@ import { drilledWellboreHeadersQueryAtom, gridModelInfosQueryAtom } from "./atom
import { GridCellIndexFilter } from "./components/gridCellIndexFilter";
import { WellboreSelector } from "./components/wellboreSelector";
-
export function Settings(props: ModuleSettingsProps): JSX.Element {
const ensembleSet = useEnsembleSet(props.workbenchSession);
const statusWriter = useSettingsStatusWriter(props.settingsContext);
@@ -515,7 +513,7 @@ function makeWellHeaderOptions(wellHeaders: WellboreHeader_api[]): SelectOption[
}
function makeCustomIntersectionPolylineOptions(
- polylines: IntersectionPolyline[],
+ polylines: readonly IntersectionPolyline[],
selectedId: string | null,
filter: string,
actions: React.ReactNode,
diff --git a/frontend/src/modules/3DViewer/view/components/PolylineEditingPanel.tsx b/frontend/src/modules/3DViewer/view/components/PolylineEditingPanel.tsx
index 9466c49db..bec1fcd29 100644
--- a/frontend/src/modules/3DViewer/view/components/PolylineEditingPanel.tsx
+++ b/frontend/src/modules/3DViewer/view/components/PolylineEditingPanel.tsx
@@ -15,7 +15,7 @@ export type PolylineEditingPanelProps = {
currentlyEditedPolylineName?: string;
selectedPolylineIndex: number | null;
hoveredPolylineIndex: number | null;
- intersectionPolylines: IntersectionPolyline[];
+ intersectionPolylines: readonly IntersectionPolyline[];
onPolylinePointSelectionChange: (index: number | null) => void;
onPolylineEditingModusChange: (active: boolean) => void;
onDeleteCurrentlySelectedPoint: () => void;
@@ -204,7 +204,7 @@ function makeSelectOptionsFromPoints(points: number[][]): SelectOption[] {
}));
}
-function makeUniquePolylineName(intersectionPolylines: IntersectionPolyline[]): string {
+function makeUniquePolylineName(intersectionPolylines: readonly IntersectionPolyline[]): string {
const names = intersectionPolylines.map((polyline) => polyline.name);
let i = 1;
while (names.includes(`Polyline ${i}`)) {
diff --git a/frontend/src/modules/3DViewer/view/components/SubsurfaceViewerWrapper.tsx b/frontend/src/modules/3DViewer/view/components/SubsurfaceViewerWrapper.tsx
index cc54a86ec..5ceafdad6 100644
--- a/frontend/src/modules/3DViewer/view/components/SubsurfaceViewerWrapper.tsx
+++ b/frontend/src/modules/3DViewer/view/components/SubsurfaceViewerWrapper.tsx
@@ -55,7 +55,7 @@ export type SubsurfaceViewerWrapperProps = {
onIntersectionPolylineEditCancel?: () => void;
onVerticalScaleChange?: (verticalScale: number) => void;
intersectionPolyline?: IntersectionPolyline;
- intersectionPolylines?: IntersectionPolyline[];
+ intersectionPolylines?: readonly IntersectionPolyline[];
};
type IntersectionZValues = {
@@ -372,6 +372,7 @@ export function SubsurfaceViewerWrapper(props: SubsurfaceViewerWrapperProps): Re
name,
fieldId: props.fieldId,
path: currentlyEditedPolyline,
+ color: [255, 0, 0],
});
}
handlePolylineEditingCancel();
diff --git a/frontend/src/modules/3DViewerNew/DataProviderFramework/customDataProviderImplementations/RealizationSeismicCrosslineProvider.ts b/frontend/src/modules/3DViewerNew/DataProviderFramework/customDataProviderImplementations/RealizationSeismicCrosslineProvider.ts
new file mode 100644
index 000000000..dfaf1ef2b
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/DataProviderFramework/customDataProviderImplementations/RealizationSeismicCrosslineProvider.ts
@@ -0,0 +1,214 @@
+import { type SeismicCubeMeta_api, getCrosslineSliceOptions, getSeismicCubeMetaListOptions } from "@api";
+import {
+ type SeismicSliceData_trans,
+ transformSeismicSlice,
+} from "@modules/3DViewerNew/settings/queries/queryDataTransforms";
+import type {
+ CustomDataProviderImplementation,
+ DataProviderInformationAccessors,
+ FetchDataParams,
+} from "@modules/_shared/DataProviderFramework/interfacesAndTypes/customDataProviderImplementation";
+import type { DefineDependenciesArgs } from "@modules/_shared/DataProviderFramework/interfacesAndTypes/customSettingsHandler";
+import { Setting, type MakeSettingTypesMap } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions";
+
+import { isEqual } from "lodash";
+
+const realizationSeismicCrosslineSettings = [
+ Setting.ENSEMBLE,
+ Setting.REALIZATION,
+ Setting.ATTRIBUTE,
+ Setting.TIME_OR_INTERVAL,
+ Setting.SEISMIC_CROSSLINE,
+ Setting.COLOR_SCALE,
+] as const;
+export type RealizationSeismicCrosslineSettings = typeof realizationSeismicCrosslineSettings;
+type SettingsWithTypes = MakeSettingTypesMap;
+
+export type RealizationSeismicCrosslineData = SeismicSliceData_trans;
+
+export type RealizationSeismicCrosslineStoredData = {
+ seismicCubeMeta: SeismicCubeMeta_api[];
+};
+
+export class RealizationSeismicCrosslineProvider
+ implements
+ CustomDataProviderImplementation<
+ RealizationSeismicCrosslineSettings,
+ RealizationSeismicCrosslineData,
+ RealizationSeismicCrosslineStoredData
+ >
+{
+ settings = realizationSeismicCrosslineSettings;
+
+ getDefaultName(): string {
+ return "Seismic Crossline (realization)";
+ }
+
+ doSettingsChangesRequireDataRefetch(prevSettings: SettingsWithTypes, newSettings: SettingsWithTypes): boolean {
+ return !isEqual(prevSettings, newSettings);
+ }
+
+ makeValueRange(
+ accessors: DataProviderInformationAccessors<
+ RealizationSeismicCrosslineSettings,
+ RealizationSeismicCrosslineData,
+ RealizationSeismicCrosslineStoredData
+ >,
+ ): [number, number] | null {
+ const data = accessors.getData();
+ if (!data) {
+ return null;
+ }
+ return [data.value_min, data.value_max];
+ }
+
+ fetchData({
+ getSetting,
+ registerQueryKey,
+ queryClient,
+ }: FetchDataParams<
+ RealizationSeismicCrosslineSettings,
+ RealizationSeismicCrosslineData
+ >): Promise {
+ const ensembleIdent = getSetting(Setting.ENSEMBLE);
+ const realizationNum = getSetting(Setting.REALIZATION);
+ const attribute = getSetting(Setting.ATTRIBUTE);
+ const timeOrInterval = getSetting(Setting.TIME_OR_INTERVAL);
+ const crosslineNumber = getSetting(Setting.SEISMIC_CROSSLINE);
+
+ const queryOptions = getCrosslineSliceOptions({
+ query: {
+ case_uuid: ensembleIdent?.getCaseUuid() ?? "",
+ ensemble_name: ensembleIdent?.getEnsembleName() ?? "",
+ realization_num: realizationNum ?? 0,
+ seismic_attribute: attribute ?? "",
+ time_or_interval_str: timeOrInterval ?? "",
+ observed: false,
+ crossline_no: crosslineNumber ?? 0,
+ },
+ });
+
+ registerQueryKey(queryOptions.queryKey);
+
+ return queryClient
+ .fetchQuery({
+ ...queryOptions,
+ })
+ .then((data) => transformSeismicSlice(data));
+ }
+
+ defineDependencies({
+ helperDependency,
+ availableSettingsUpdater,
+ storedDataUpdater,
+ queryClient,
+ }: DefineDependenciesArgs): void {
+ availableSettingsUpdater(Setting.ENSEMBLE, ({ getGlobalSetting }) => {
+ const fieldIdentifier = getGlobalSetting("fieldId");
+ const ensembles = getGlobalSetting("ensembles");
+
+ const ensembleIdents = ensembles
+ .filter((ensemble) => ensemble.getFieldIdentifier() === fieldIdentifier)
+ .map((ensemble) => ensemble.getIdent());
+
+ return ensembleIdents;
+ });
+
+ availableSettingsUpdater(Setting.REALIZATION, ({ getLocalSetting, getGlobalSetting }) => {
+ const ensembleIdent = getLocalSetting(Setting.ENSEMBLE);
+ const realizationFilterFunc = getGlobalSetting("realizationFilterFunction");
+
+ if (!ensembleIdent) {
+ return [];
+ }
+
+ const realizations = realizationFilterFunc(ensembleIdent);
+
+ return [...realizations];
+ });
+
+ const realizationSeismicCrosslineDataDep = helperDependency(async ({ getLocalSetting, abortSignal }) => {
+ const ensembleIdent = getLocalSetting(Setting.ENSEMBLE);
+ const realization = getLocalSetting(Setting.REALIZATION);
+
+ if (!ensembleIdent || realization === null) {
+ return null;
+ }
+
+ return await queryClient.fetchQuery({
+ ...getSeismicCubeMetaListOptions({
+ query: {
+ case_uuid: ensembleIdent.getCaseUuid(),
+ ensemble_name: ensembleIdent.getEnsembleName(),
+ },
+ signal: abortSignal,
+ }),
+ });
+ });
+
+ storedDataUpdater("seismicCubeMeta", ({ getHelperDependency }) => {
+ const data = getHelperDependency(realizationSeismicCrosslineDataDep);
+
+ if (!data) {
+ return null;
+ }
+
+ return data;
+ });
+
+ availableSettingsUpdater(Setting.ATTRIBUTE, ({ getHelperDependency }) => {
+ const data = getHelperDependency(realizationSeismicCrosslineDataDep);
+
+ if (!data) {
+ return [];
+ }
+
+ const availableSeismicAttributes = [
+ ...Array.from(new Set(data.map((seismicInfos) => seismicInfos.seismicAttribute))),
+ ];
+
+ return availableSeismicAttributes;
+ });
+
+ availableSettingsUpdater(Setting.TIME_OR_INTERVAL, ({ getLocalSetting, getHelperDependency }) => {
+ const seismicAttribute = getLocalSetting(Setting.ATTRIBUTE);
+
+ const data = getHelperDependency(realizationSeismicCrosslineDataDep);
+
+ if (!seismicAttribute || !data) {
+ return [];
+ }
+
+ const availableTimeOrIntervals = [
+ ...Array.from(
+ new Set(
+ data
+ .filter((surface) => surface.seismicAttribute === seismicAttribute)
+ .map((el) => el.isoDateOrInterval),
+ ),
+ ),
+ ];
+
+ return availableTimeOrIntervals;
+ });
+
+ availableSettingsUpdater(Setting.SEISMIC_CROSSLINE, ({ getLocalSetting, getHelperDependency }) => {
+ const seismicAttribute = getLocalSetting(Setting.ATTRIBUTE);
+ const timeOrInterval = getLocalSetting(Setting.TIME_OR_INTERVAL);
+ const data = getHelperDependency(realizationSeismicCrosslineDataDep);
+
+ if (!seismicAttribute || !timeOrInterval || !data) {
+ return [0, 0, 0];
+ }
+ const seismicInfo = data.filter(
+ (seismicInfos) =>
+ seismicInfos.seismicAttribute === seismicAttribute &&
+ seismicInfos.isoDateOrInterval === timeOrInterval,
+ )[0];
+ const jMin = 0;
+ const jMax = seismicInfo.spec.numRows - 1;
+
+ return [jMin, jMax, 1];
+ });
+ }
+}
diff --git a/frontend/src/modules/3DViewerNew/DataProviderFramework/customDataProviderImplementations/RealizationSeismicDepthProvider.ts b/frontend/src/modules/3DViewerNew/DataProviderFramework/customDataProviderImplementations/RealizationSeismicDepthProvider.ts
new file mode 100644
index 000000000..4c3685e31
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/DataProviderFramework/customDataProviderImplementations/RealizationSeismicDepthProvider.ts
@@ -0,0 +1,217 @@
+import { type SeismicCubeMeta_api, getDepthSliceOptions, getSeismicCubeMetaListOptions } from "@api";
+import {
+ type SeismicSliceData_trans,
+ transformSeismicSlice,
+} from "@modules/3DViewerNew/settings/queries/queryDataTransforms";
+import type {
+ CustomDataProviderImplementation,
+ DataProviderInformationAccessors,
+ FetchDataParams,
+} from "@modules/_shared/DataProviderFramework/interfacesAndTypes/customDataProviderImplementation";
+import type { DefineDependenciesArgs } from "@modules/_shared/DataProviderFramework/interfacesAndTypes/customSettingsHandler";
+import { Setting, type MakeSettingTypesMap } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions";
+
+import { isEqual } from "lodash";
+
+const realizationSeismicDepthSliceSettings = [
+ Setting.ENSEMBLE,
+ Setting.REALIZATION,
+ Setting.ATTRIBUTE,
+ Setting.TIME_OR_INTERVAL,
+ Setting.SEISMIC_DEPTH_SLICE,
+ Setting.COLOR_SCALE,
+] as const;
+export type RealizationSeismicDepthSliceSettings = typeof realizationSeismicDepthSliceSettings;
+type SettingsWithTypes = MakeSettingTypesMap;
+
+export type RealizationSeismicDepthSliceData = SeismicSliceData_trans;
+
+export type RealizationSeismicDepthSliceStoredData = {
+ seismicCubeMeta: SeismicCubeMeta_api[];
+};
+
+export class RealizationSeismicDepthSliceProvider
+ implements
+ CustomDataProviderImplementation<
+ RealizationSeismicDepthSliceSettings,
+ RealizationSeismicDepthSliceData,
+ RealizationSeismicDepthSliceStoredData
+ >
+{
+ settings = realizationSeismicDepthSliceSettings;
+
+ getDefaultName(): string {
+ return "Seismic Inline (realization)";
+ }
+
+ doSettingsChangesRequireDataRefetch(prevSettings: SettingsWithTypes, newSettings: SettingsWithTypes): boolean {
+ return !isEqual(prevSettings, newSettings);
+ }
+
+ makeValueRange(
+ accessors: DataProviderInformationAccessors<
+ RealizationSeismicDepthSliceSettings,
+ RealizationSeismicDepthSliceData,
+ RealizationSeismicDepthSliceStoredData
+ >,
+ ): [number, number] | null {
+ const data = accessors.getData();
+ if (!data) {
+ return null;
+ }
+ return [data.value_min, data.value_max];
+ }
+
+ fetchData({
+ getSetting,
+ registerQueryKey,
+ queryClient,
+ }: FetchDataParams<
+ RealizationSeismicDepthSliceSettings,
+ RealizationSeismicDepthSliceData
+ >): Promise {
+ const ensembleIdent = getSetting(Setting.ENSEMBLE);
+ const realizationNum = getSetting(Setting.REALIZATION);
+ const attribute = getSetting(Setting.ATTRIBUTE);
+ const timeOrInterval = getSetting(Setting.TIME_OR_INTERVAL);
+ const depthSlice = getSetting(Setting.SEISMIC_DEPTH_SLICE);
+
+ const queryOptions = getDepthSliceOptions({
+ query: {
+ case_uuid: ensembleIdent?.getCaseUuid() ?? "",
+ ensemble_name: ensembleIdent?.getEnsembleName() ?? "",
+ realization_num: realizationNum ?? 0,
+ seismic_attribute: attribute ?? "",
+ time_or_interval_str: timeOrInterval ?? "",
+ observed: false,
+ depth_slice_no: depthSlice ?? 0,
+ },
+ });
+
+ registerQueryKey(queryOptions.queryKey);
+
+ return queryClient
+ .fetchQuery({
+ ...queryOptions,
+ })
+ .then((data) => transformSeismicSlice(data));
+ }
+
+ defineDependencies({
+ helperDependency,
+ availableSettingsUpdater,
+ storedDataUpdater,
+ queryClient,
+ }: DefineDependenciesArgs): void {
+ availableSettingsUpdater(Setting.ENSEMBLE, ({ getGlobalSetting }) => {
+ const fieldIdentifier = getGlobalSetting("fieldId");
+ const ensembles = getGlobalSetting("ensembles");
+
+ const ensembleIdents = ensembles
+ .filter((ensemble) => ensemble.getFieldIdentifier() === fieldIdentifier)
+ .map((ensemble) => ensemble.getIdent());
+
+ return ensembleIdents;
+ });
+
+ availableSettingsUpdater(Setting.REALIZATION, ({ getLocalSetting, getGlobalSetting }) => {
+ const ensembleIdent = getLocalSetting(Setting.ENSEMBLE);
+ const realizationFilterFunc = getGlobalSetting("realizationFilterFunction");
+
+ if (!ensembleIdent) {
+ return [];
+ }
+
+ const realizations = realizationFilterFunc(ensembleIdent);
+
+ return [...realizations];
+ });
+
+ const realizationSeismicCrosslineDataDep = helperDependency(async ({ getLocalSetting, abortSignal }) => {
+ const ensembleIdent = getLocalSetting(Setting.ENSEMBLE);
+ const realization = getLocalSetting(Setting.REALIZATION);
+
+ if (!ensembleIdent || realization === null) {
+ return null;
+ }
+
+ return await queryClient.fetchQuery({
+ ...getSeismicCubeMetaListOptions({
+ query: {
+ case_uuid: ensembleIdent.getCaseUuid(),
+ ensemble_name: ensembleIdent.getEnsembleName(),
+ },
+ signal: abortSignal,
+ }),
+ });
+ });
+
+ storedDataUpdater("seismicCubeMeta", ({ getHelperDependency }) => {
+ const data = getHelperDependency(realizationSeismicCrosslineDataDep);
+
+ if (!data) {
+ return null;
+ }
+
+ return data;
+ });
+
+ availableSettingsUpdater(Setting.ATTRIBUTE, ({ getHelperDependency }) => {
+ const data = getHelperDependency(realizationSeismicCrosslineDataDep);
+
+ if (!data) {
+ return [];
+ }
+
+ const availableSeismicAttributes = [
+ ...Array.from(new Set(data.map((seismicInfos) => seismicInfos.seismicAttribute))),
+ ];
+
+ return availableSeismicAttributes;
+ });
+
+ availableSettingsUpdater(Setting.TIME_OR_INTERVAL, ({ getLocalSetting, getHelperDependency }) => {
+ const seismicAttribute = getLocalSetting(Setting.ATTRIBUTE);
+
+ const data = getHelperDependency(realizationSeismicCrosslineDataDep);
+
+ if (!seismicAttribute || !data) {
+ return [];
+ }
+
+ const availableTimeOrIntervals = [
+ ...Array.from(
+ new Set(
+ data
+ .filter((surface) => surface.seismicAttribute === seismicAttribute)
+ .map((el) => el.isoDateOrInterval),
+ ),
+ ),
+ ];
+
+ return availableTimeOrIntervals;
+ });
+
+ availableSettingsUpdater(Setting.SEISMIC_DEPTH_SLICE, ({ getLocalSetting, getHelperDependency }) => {
+ const seismicAttribute = getLocalSetting(Setting.ATTRIBUTE);
+ const timeOrInterval = getLocalSetting(Setting.TIME_OR_INTERVAL);
+ const data = getHelperDependency(realizationSeismicCrosslineDataDep);
+
+ if (!seismicAttribute || !timeOrInterval || !data) {
+ return [0, 0, 0];
+ }
+ const seismicInfo = data.filter(
+ (seismicInfos) =>
+ seismicInfos.seismicAttribute === seismicAttribute &&
+ seismicInfos.isoDateOrInterval === timeOrInterval,
+ )[0];
+ const zMin = seismicInfo.spec.zOrigin;
+ const zMax =
+ seismicInfo.spec.zOrigin +
+ seismicInfo.spec.zInc * seismicInfo.spec.zFlip * (seismicInfo.spec.numLayers - 1);
+ const zInc = seismicInfo.spec.zInc;
+
+ return [zMin, zMax, zInc];
+ });
+ }
+}
diff --git a/frontend/src/modules/3DViewerNew/DataProviderFramework/customDataProviderImplementations/RealizationSeismicInlineProvider.ts b/frontend/src/modules/3DViewerNew/DataProviderFramework/customDataProviderImplementations/RealizationSeismicInlineProvider.ts
new file mode 100644
index 000000000..26baf97ed
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/DataProviderFramework/customDataProviderImplementations/RealizationSeismicInlineProvider.ts
@@ -0,0 +1,214 @@
+import { type SeismicCubeMeta_api, getInlineSliceOptions, getSeismicCubeMetaListOptions } from "@api";
+import {
+ type SeismicSliceData_trans,
+ transformSeismicSlice,
+} from "@modules/3DViewerNew/settings/queries/queryDataTransforms";
+import type {
+ CustomDataProviderImplementation,
+ DataProviderInformationAccessors,
+ FetchDataParams,
+} from "@modules/_shared/DataProviderFramework/interfacesAndTypes/customDataProviderImplementation";
+import type { DefineDependenciesArgs } from "@modules/_shared/DataProviderFramework/interfacesAndTypes/customSettingsHandler";
+import { Setting, type MakeSettingTypesMap } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions";
+
+import { isEqual } from "lodash";
+
+const realizationSeismicInlineSettings = [
+ Setting.ENSEMBLE,
+ Setting.REALIZATION,
+ Setting.ATTRIBUTE,
+ Setting.TIME_OR_INTERVAL,
+ Setting.SEISMIC_INLINE,
+ Setting.COLOR_SCALE,
+] as const;
+export type RealizationSeismicInlineSettings = typeof realizationSeismicInlineSettings;
+type SettingsWithTypes = MakeSettingTypesMap;
+
+export type RealizationSeismicInlineData = SeismicSliceData_trans;
+
+export type RealizationSeismicInlineStoredData = {
+ seismicCubeMeta: SeismicCubeMeta_api[];
+};
+
+export class RealizationSeismicInlineProvider
+ implements
+ CustomDataProviderImplementation<
+ RealizationSeismicInlineSettings,
+ RealizationSeismicInlineData,
+ RealizationSeismicInlineStoredData
+ >
+{
+ settings = realizationSeismicInlineSettings;
+
+ getDefaultName(): string {
+ return "Seismic Inline (realization)";
+ }
+
+ doSettingsChangesRequireDataRefetch(prevSettings: SettingsWithTypes, newSettings: SettingsWithTypes): boolean {
+ return !isEqual(prevSettings, newSettings);
+ }
+
+ makeValueRange(
+ accessors: DataProviderInformationAccessors<
+ RealizationSeismicInlineSettings,
+ RealizationSeismicInlineData,
+ RealizationSeismicInlineStoredData
+ >,
+ ): [number, number] | null {
+ const data = accessors.getData();
+ if (!data) {
+ return null;
+ }
+ return [data.value_min, data.value_max];
+ }
+
+ fetchData({
+ getSetting,
+ registerQueryKey,
+ queryClient,
+ }: FetchDataParams<
+ RealizationSeismicInlineSettings,
+ RealizationSeismicInlineData
+ >): Promise {
+ const ensembleIdent = getSetting(Setting.ENSEMBLE);
+ const realizationNum = getSetting(Setting.REALIZATION);
+ const attribute = getSetting(Setting.ATTRIBUTE);
+ const timeOrInterval = getSetting(Setting.TIME_OR_INTERVAL);
+ const inlineNumber = getSetting(Setting.SEISMIC_INLINE);
+
+ const queryOptions = getInlineSliceOptions({
+ query: {
+ case_uuid: ensembleIdent?.getCaseUuid() ?? "",
+ ensemble_name: ensembleIdent?.getEnsembleName() ?? "",
+ realization_num: realizationNum ?? 0,
+ seismic_attribute: attribute ?? "",
+ time_or_interval_str: timeOrInterval ?? "",
+ observed: false,
+ inline_no: inlineNumber ?? 0,
+ },
+ });
+
+ registerQueryKey(queryOptions.queryKey);
+
+ return queryClient
+ .fetchQuery({
+ ...queryOptions,
+ })
+ .then((data) => transformSeismicSlice(data));
+ }
+
+ defineDependencies({
+ helperDependency,
+ availableSettingsUpdater,
+ storedDataUpdater,
+ queryClient,
+ }: DefineDependenciesArgs): void {
+ availableSettingsUpdater(Setting.ENSEMBLE, ({ getGlobalSetting }) => {
+ const fieldIdentifier = getGlobalSetting("fieldId");
+ const ensembles = getGlobalSetting("ensembles");
+
+ const ensembleIdents = ensembles
+ .filter((ensemble) => ensemble.getFieldIdentifier() === fieldIdentifier)
+ .map((ensemble) => ensemble.getIdent());
+
+ return ensembleIdents;
+ });
+
+ availableSettingsUpdater(Setting.REALIZATION, ({ getLocalSetting, getGlobalSetting }) => {
+ const ensembleIdent = getLocalSetting(Setting.ENSEMBLE);
+ const realizationFilterFunc = getGlobalSetting("realizationFilterFunction");
+
+ if (!ensembleIdent) {
+ return [];
+ }
+
+ const realizations = realizationFilterFunc(ensembleIdent);
+
+ return [...realizations];
+ });
+
+ const realizationSeismicCrosslineDataDep = helperDependency(async ({ getLocalSetting, abortSignal }) => {
+ const ensembleIdent = getLocalSetting(Setting.ENSEMBLE);
+ const realization = getLocalSetting(Setting.REALIZATION);
+
+ if (!ensembleIdent || realization === null) {
+ return null;
+ }
+
+ return await queryClient.fetchQuery({
+ ...getSeismicCubeMetaListOptions({
+ query: {
+ case_uuid: ensembleIdent.getCaseUuid(),
+ ensemble_name: ensembleIdent.getEnsembleName(),
+ },
+ signal: abortSignal,
+ }),
+ });
+ });
+
+ storedDataUpdater("seismicCubeMeta", ({ getHelperDependency }) => {
+ const data = getHelperDependency(realizationSeismicCrosslineDataDep);
+
+ if (!data) {
+ return null;
+ }
+
+ return data;
+ });
+
+ availableSettingsUpdater(Setting.ATTRIBUTE, ({ getHelperDependency }) => {
+ const data = getHelperDependency(realizationSeismicCrosslineDataDep);
+
+ if (!data) {
+ return [];
+ }
+
+ const availableSeismicAttributes = [
+ ...Array.from(new Set(data.map((seismicInfos) => seismicInfos.seismicAttribute))),
+ ];
+
+ return availableSeismicAttributes;
+ });
+
+ availableSettingsUpdater(Setting.TIME_OR_INTERVAL, ({ getLocalSetting, getHelperDependency }) => {
+ const seismicAttribute = getLocalSetting(Setting.ATTRIBUTE);
+
+ const data = getHelperDependency(realizationSeismicCrosslineDataDep);
+
+ if (!seismicAttribute || !data) {
+ return [];
+ }
+
+ const availableTimeOrIntervals = [
+ ...Array.from(
+ new Set(
+ data
+ .filter((surface) => surface.seismicAttribute === seismicAttribute)
+ .map((el) => el.isoDateOrInterval),
+ ),
+ ),
+ ];
+
+ return availableTimeOrIntervals;
+ });
+
+ availableSettingsUpdater(Setting.SEISMIC_INLINE, ({ getLocalSetting, getHelperDependency }) => {
+ const seismicAttribute = getLocalSetting(Setting.ATTRIBUTE);
+ const timeOrInterval = getLocalSetting(Setting.TIME_OR_INTERVAL);
+ const data = getHelperDependency(realizationSeismicCrosslineDataDep);
+
+ if (!seismicAttribute || !timeOrInterval || !data) {
+ return [0, 0, 0];
+ }
+ const seismicInfo = data.filter(
+ (seismicInfos) =>
+ seismicInfos.seismicAttribute === seismicAttribute &&
+ seismicInfos.isoDateOrInterval === timeOrInterval,
+ )[0];
+ const iMin = 0;
+ const iMax = seismicInfo.spec.numCols - 1;
+
+ return [iMin, iMax, 1];
+ });
+ }
+}
diff --git a/frontend/src/modules/3DViewerNew/DataProviderFramework/customDataProviderImplementations/dataProviderTypes.ts b/frontend/src/modules/3DViewerNew/DataProviderFramework/customDataProviderImplementations/dataProviderTypes.ts
new file mode 100644
index 000000000..53e1162e7
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/DataProviderFramework/customDataProviderImplementations/dataProviderTypes.ts
@@ -0,0 +1,5 @@
+export enum CustomDataProviderType {
+ REALIZATION_SEISMIC_CROSSLINE = "REALIZATION_SEISMIC_CROSSLINE",
+ REALIZATION_SEISMIC_INLINE = "REALIZATION_SEISMIC_INLINE",
+ REALIZATION_SEISMIC_DEPTH_SLICE = "REALIZATION_SEISMIC_DEPTH_SLICE",
+}
diff --git a/frontend/src/modules/3DViewerNew/DataProviderFramework/customDataProviderImplementations/registerAllDataProviders.ts b/frontend/src/modules/3DViewerNew/DataProviderFramework/customDataProviderImplementations/registerAllDataProviders.ts
new file mode 100644
index 000000000..117d54bb7
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/DataProviderFramework/customDataProviderImplementations/registerAllDataProviders.ts
@@ -0,0 +1,19 @@
+import { DataProviderRegistry } from "@modules/_shared/DataProviderFramework/dataProviders/DataProviderRegistry";
+
+import { CustomDataProviderType } from "./dataProviderTypes";
+import { RealizationSeismicCrosslineProvider } from "./RealizationSeismicCrosslineProvider";
+import { RealizationSeismicDepthSliceProvider } from "./RealizationSeismicDepthProvider";
+import { RealizationSeismicInlineProvider } from "./RealizationSeismicInlineProvider";
+
+DataProviderRegistry.registerDataProvider(
+ CustomDataProviderType.REALIZATION_SEISMIC_CROSSLINE,
+ RealizationSeismicCrosslineProvider,
+);
+DataProviderRegistry.registerDataProvider(
+ CustomDataProviderType.REALIZATION_SEISMIC_INLINE,
+ RealizationSeismicInlineProvider,
+);
+DataProviderRegistry.registerDataProvider(
+ CustomDataProviderType.REALIZATION_SEISMIC_DEPTH_SLICE,
+ RealizationSeismicDepthSliceProvider,
+);
diff --git a/frontend/src/modules/3DViewerNew/DataProviderFramework/visualization/makeDrilledWellTrajectoriesLayer.ts b/frontend/src/modules/3DViewerNew/DataProviderFramework/visualization/makeDrilledWellTrajectoriesLayer.ts
new file mode 100644
index 000000000..aed74af29
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/DataProviderFramework/visualization/makeDrilledWellTrajectoriesLayer.ts
@@ -0,0 +1,47 @@
+import type { WellboreTrajectory_api } from "@api";
+import * as bbox from "@lib/utils/bbox";
+import { WellsLayer } from "@modules/_shared/customDeckGlLayers/WellsLayer/WellsLayer";
+import type { WellsLayerData } from "@modules/_shared/customDeckGlLayers/WellsLayer/WellsLayer";
+import { makeDrilledWellTrajectoriesBoundingBox } from "@modules/_shared/DataProviderFramework/visualization/deckgl/boundingBoxes/makeDrilledWellTrajectoriesBoundingBox";
+import type { TransformerArgs } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler";
+
+export function makeDrilledWellTrajectoriesLayer(
+ args: TransformerArgs,
+): WellsLayer | null {
+ const { id, getData, name } = args;
+
+ const fieldWellboreTrajectoriesData = getData();
+
+ if (!fieldWellboreTrajectoriesData) {
+ return null;
+ }
+
+ const wellsLayerData: WellsLayerData = [];
+ for (const wellboreData of fieldWellboreTrajectoriesData) {
+ const properties = {
+ uuid: wellboreData.wellboreUuid,
+ name: wellboreData.uniqueWellboreIdentifier,
+ mdArray: wellboreData.mdArr,
+ };
+ const coordinates: [number, number, number][] = wellboreData.eastingArr.map((easting, index) => {
+ return [easting, wellboreData.northingArr[index], -wellboreData.tvdMslArr[index]];
+ });
+ wellsLayerData.push({ properties, coordinates });
+ }
+
+ const boundingBox = makeDrilledWellTrajectoriesBoundingBox(args);
+
+ if (!boundingBox) {
+ return null;
+ }
+
+ const wellsLayer = new WellsLayer({
+ id: id,
+ name,
+ data: wellsLayerData,
+ zIncreaseDownwards: true,
+ boundingBox: bbox.toNumArray(boundingBox),
+ });
+
+ return wellsLayer;
+}
diff --git a/frontend/src/modules/3DViewerNew/DataProviderFramework/visualization/makeIntersectionGrid3dLayer.ts b/frontend/src/modules/3DViewerNew/DataProviderFramework/visualization/makeIntersectionGrid3dLayer.ts
new file mode 100644
index 000000000..5d88a5b0d
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/DataProviderFramework/visualization/makeIntersectionGrid3dLayer.ts
@@ -0,0 +1,114 @@
+import type { IntersectionRealizationGridSettings } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/IntersectionRealizationGridProvider";
+import { Setting } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions";
+import { makeColorMapFunctionFromColorScale } from "@modules/_shared/DataProviderFramework/visualization/utils/colors";
+import type { TransformerArgs } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler";
+import { FenceMeshSection_trans, PolylineIntersection_trans } from "@modules/_shared/utils/wellbore";
+import { TGrid3DColoringMode } from "@webviz/subsurface-viewer";
+import { Grid3DLayer } from "@webviz/subsurface-viewer/dist/layers";
+
+interface PolyDataVtk {
+ points: Float32Array;
+ polys: Uint32Array;
+ props: Float32Array;
+}
+
+function buildVtkStylePolyDataFromFenceSections(fenceSections: FenceMeshSection_trans[]): PolyDataVtk {
+ // Calculate sizes of typed arrays
+ let totNumVertices = 0;
+ let totNumPolygons = 0;
+ let totNumConnectivities = 0;
+ for (const section of fenceSections) {
+ totNumVertices += section.verticesUzFloat32Arr.length / 2;
+ totNumPolygons += section.verticesPerPolyUintArr.length;
+ totNumConnectivities += section.polyIndicesUintArr.length;
+ }
+
+ const pointsFloat32Arr = new Float32Array(3 * totNumVertices);
+ const polysUint32Arr = new Uint32Array(totNumPolygons + totNumConnectivities);
+ const polyPropsFloat32Arr = new Float32Array(totNumPolygons);
+
+ let floatPointsDstIdx = 0;
+ let polysDstIdx = 0;
+ let propsDstIdx = 0;
+ for (const section of fenceSections) {
+ // uv to xyz
+ const directionX = section.end_utm_x - section.start_utm_x;
+ const directionY = section.end_utm_y - section.start_utm_y;
+ const magnitude = Math.sqrt(directionX ** 2 + directionY ** 2);
+ const unitDirectionX = directionX / magnitude;
+ const unitDirectionY = directionY / magnitude;
+
+ const connOffset = floatPointsDstIdx / 3;
+
+ for (let i = 0; i < section.verticesUzFloat32Arr.length; i += 2) {
+ const u = section.verticesUzFloat32Arr[i];
+ const z = section.verticesUzFloat32Arr[i + 1];
+ const x = u * unitDirectionX + section.start_utm_x;
+ const y = u * unitDirectionY + section.start_utm_y;
+
+ pointsFloat32Arr[floatPointsDstIdx++] = x;
+ pointsFloat32Arr[floatPointsDstIdx++] = y;
+ pointsFloat32Arr[floatPointsDstIdx++] = z;
+ }
+
+ // Fix poly indexes for each section
+ const numPolysInSection = section.verticesPerPolyUintArr.length;
+ let srcIdx = 0;
+ for (let i = 0; i < numPolysInSection; i++) {
+ const numVertsInPoly = section.verticesPerPolyUintArr[i];
+ polysUint32Arr[polysDstIdx++] = numVertsInPoly;
+
+ for (let j = 0; j < numVertsInPoly; j++) {
+ polysUint32Arr[polysDstIdx++] = section.polyIndicesUintArr[srcIdx++] + connOffset;
+ }
+ }
+
+ polyPropsFloat32Arr.set(section.polyPropsFloat32Arr, propsDstIdx);
+ propsDstIdx += numPolysInSection;
+ }
+
+ return {
+ points: pointsFloat32Arr,
+ polys: polysUint32Arr,
+ props: polyPropsFloat32Arr,
+ };
+}
+
+export function makeIntersectionLayer({
+ id,
+ name,
+ getData,
+ getSetting,
+}: TransformerArgs): Grid3DLayer | null {
+ const data = getData();
+ const colorScale = getSetting(Setting.COLOR_SCALE)?.colorScale;
+ const showGridLines = getSetting(Setting.SHOW_GRID_LINES);
+
+ if (!data) {
+ return null;
+ }
+ const polyData = buildVtkStylePolyDataFromFenceSections(data.fenceMeshSections);
+
+ const grid3dIntersectionLayer = new Grid3DLayer({
+ id,
+ name,
+ pointsData: polyData.points,
+ polysData: polyData.polys,
+ propertiesData: polyData.props,
+ colorMapName: "Continuous",
+ colorMapRange: [data.min_grid_prop_value, data.max_grid_prop_value],
+ colorMapClampColor: true,
+ coloringMode: TGrid3DColoringMode.Property,
+ colorMapFunction: makeColorMapFunctionFromColorScale(
+ colorScale,
+ data.min_grid_prop_value,
+ data.max_grid_prop_value,
+ ),
+ ZIncreasingDownwards: false,
+ gridLines: showGridLines ?? false,
+ material: { ambient: 0.4, diffuse: 0.7, shininess: 8, specularColor: [25, 25, 25] },
+ pickable: true,
+ });
+
+ return grid3dIntersectionLayer;
+}
diff --git a/frontend/src/modules/3DViewerNew/DataProviderFramework/visualization/makeRealizationSurfaceLayer.ts b/frontend/src/modules/3DViewerNew/DataProviderFramework/visualization/makeRealizationSurfaceLayer.ts
new file mode 100644
index 000000000..0d9dc5b41
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/DataProviderFramework/visualization/makeRealizationSurfaceLayer.ts
@@ -0,0 +1,69 @@
+import { SurfaceDataFormat } from "@modules/2DViewer/DataProviderFramework/customDataProviderImplementations/ObservedSurfaceProvider";
+import type {
+ RealizationSurfaceData,
+ RealizationSurfaceSettings,
+} from "@modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationSurfaceProvider";
+import { Setting } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions";
+import { makeColorMapFunctionFromColorScale } from "@modules/_shared/DataProviderFramework/visualization/utils/colors";
+import type { TransformerArgs } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler";
+import { MapLayer } from "@webviz/subsurface-viewer/dist/layers";
+
+export function makeRealizationSurfaceLayer({
+ id,
+ name,
+ getData,
+ getSetting,
+}: TransformerArgs): MapLayer | null {
+ const data = getData();
+ const colorScale = getSetting(Setting.COLOR_SCALE)?.colorScale;
+
+ if (!data) {
+ return null;
+ }
+
+ if (data.surfaceData.format === SurfaceDataFormat.FLOAT) {
+ return new MapLayer({
+ id,
+ name,
+ meshData: data.surfaceData.valuesFloat32Arr,
+ frame: {
+ origin: [data.surfaceData.surface_def.origin_utm_x, data.surfaceData.surface_def.origin_utm_y],
+ count: [data.surfaceData.surface_def.npoints_x, data.surfaceData.surface_def.npoints_y],
+ increment: [data.surfaceData.surface_def.inc_x, data.surfaceData.surface_def.inc_y],
+ rotDeg: data.surfaceData.surface_def.rot_deg,
+ },
+ valueRange: [data.surfaceData.value_min, data.surfaceData.value_max],
+ colorMapRange: [data.surfaceData.value_min, data.surfaceData.value_max],
+ colorMapFunction: makeColorMapFunctionFromColorScale(
+ colorScale,
+ data.surfaceData.value_min,
+ data.surfaceData.value_max,
+ ),
+ gridLines: false,
+ });
+ }
+
+ if (data.surfaceData.format === SurfaceDataFormat.PNG) {
+ return new MapLayer({
+ id,
+ name,
+ meshData: data.surfaceData.png_image_base64,
+ frame: {
+ origin: [data.surfaceData.surface_def.origin_utm_x, data.surfaceData.surface_def.origin_utm_y],
+ count: [data.surfaceData.surface_def.npoints_x, data.surfaceData.surface_def.npoints_y],
+ increment: [data.surfaceData.surface_def.inc_x, data.surfaceData.surface_def.inc_y],
+ rotDeg: data.surfaceData.surface_def.rot_deg,
+ },
+ valueRange: [data.surfaceData.value_min, data.surfaceData.value_max],
+ colorMapRange: [data.surfaceData.value_min, data.surfaceData.value_max],
+ colorMapFunction: makeColorMapFunctionFromColorScale(
+ colorScale,
+ data.surfaceData.value_min,
+ data.surfaceData.value_max,
+ ),
+ gridLines: false,
+ });
+ }
+
+ return null;
+}
diff --git a/frontend/src/modules/3DViewerNew/DataProviderFramework/visualization/makeSeismicFenceMeshLayer.ts b/frontend/src/modules/3DViewerNew/DataProviderFramework/visualization/makeSeismicFenceMeshLayer.ts
new file mode 100644
index 000000000..b0ca68656
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/DataProviderFramework/visualization/makeSeismicFenceMeshLayer.ts
@@ -0,0 +1,278 @@
+import { Layer } from "@deck.gl/core";
+import * as bbox from "@lib/utils/bbox";
+import { Geometry, ShapeType, degreesToRadians } from "@lib/utils/geometry";
+import { rotatePoint2Around } from "@lib/utils/vec2";
+import * as vec3 from "@lib/utils/vec3";
+import { SeismicSliceData_trans } from "@modules/3DViewerNew/settings/queries/queryDataTransforms";
+import { SeismicFenceMeshLayer } from "@modules/_shared/customDeckGlLayers/SeismicFenceMeshLayer/SeismicFenceMeshLayer";
+
+import type { RealizationSeismicDepthSliceStoredData } from "../customDataProviderImplementations/RealizationSeismicDepthProvider";
+import type { TransformerArgs } from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler";
+import { Setting } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions";
+import { makeColorMapFunctionFromColorScale } from "@modules/_shared/DataProviderFramework/visualization/utils/colors";
+
+export enum Plane {
+ CROSSLINE = "CROSSLINE",
+ INLINE = "INLINE",
+ DEPTH = "DEPTH",
+}
+
+function predictDepthSliceGeometry({
+ getSetting,
+ getStoredData,
+}: TransformerArgs): Geometry | null {
+ const attribute = getSetting(Setting.ATTRIBUTE);
+ const timeOrInterval = getSetting(Setting.TIME_OR_INTERVAL);
+ const seismicDepthSliceNumber = getSetting(Setting.SEISMIC_DEPTH_SLICE);
+ const seismicCubeMeta = getStoredData("seismicCubeMeta");
+
+ if (!seismicCubeMeta || seismicDepthSliceNumber === null) {
+ return null;
+ }
+
+ const meta = seismicCubeMeta.find(
+ (m) => m.seismicAttribute === attribute && m.isoDateOrInterval === timeOrInterval,
+ );
+
+ if (!meta) {
+ return null;
+ }
+
+ const xmin = meta.spec.xOrigin;
+ const xmax = meta.spec.xOrigin + meta.spec.xInc * (meta.spec.numCols - 1);
+
+ const ymin = meta.spec.yOrigin;
+ const ymax = meta.spec.yOrigin + meta.spec.yInc * meta.spec.yFlip * (meta.spec.numRows - 1);
+
+ const zmin = seismicDepthSliceNumber;
+ const zmax = zmin;
+
+ const maxXY = { x: xmax, y: ymax };
+ const maxX = { x: xmax, y: ymin };
+ const maxY = { x: xmin, y: ymax };
+ const origin = { x: meta.spec.xOrigin, y: meta.spec.yOrigin };
+
+ const rotatedMaxXY = rotatePoint2Around(maxXY, origin, degreesToRadians(meta.spec.rotationDeg));
+ const rotatedMaxX = rotatePoint2Around(maxX, origin, degreesToRadians(meta.spec.rotationDeg));
+ const rotatedMaxY = rotatePoint2Around(maxY, origin, degreesToRadians(meta.spec.rotationDeg));
+
+ const boundingBox = bbox.create(
+ vec3.create(Math.min(origin.x, rotatedMaxXY.x), Math.min(origin.y, rotatedMaxXY.y), zmin),
+ vec3.create(Math.max(origin.x, rotatedMaxXY.x), Math.max(origin.y, rotatedMaxXY.y), zmax),
+ );
+
+ const geometry: Geometry = {
+ shapes: [
+ {
+ type: ShapeType.BOX,
+ centerPoint: vec3.create((origin.x + rotatedMaxXY.x) / 2, (origin.y + rotatedMaxXY.y) / 2, zmin),
+ dimensions: {
+ width: Math.abs(rotatedMaxX.x - origin.x),
+ height: Math.abs(rotatedMaxY.y - origin.y),
+ depth: 0,
+ },
+ normalizedEdgeVectors: {
+ u: vec3.normalize(vec3.create(rotatedMaxX.x - origin.x, rotatedMaxX.y - origin.y, 0)),
+ v: vec3.normalize(vec3.create(rotatedMaxY.x - origin.x, rotatedMaxY.y - origin.y, 0)),
+ },
+ },
+ ],
+ boundingBox,
+ };
+
+ return geometry;
+}
+
+function predictCrosslineGeometry({
+ getSetting,
+ getStoredData,
+}: TransformerArgs): Geometry | null {
+ const attribute = getSetting(Setting.ATTRIBUTE);
+ const timeOrInterval = getSetting(Setting.TIME_OR_INTERVAL);
+ const seismicCrosslineNumber = getSetting(Setting.SEISMIC_CROSSLINE);
+ const seismicCubeMeta = getStoredData("seismicCubeMeta");
+
+ if (!seismicCubeMeta || seismicCrosslineNumber === null) {
+ return null;
+ }
+
+ const meta = seismicCubeMeta.find(
+ (m) => m.seismicAttribute === attribute && m.isoDateOrInterval === timeOrInterval,
+ );
+
+ if (!meta) {
+ return null;
+ }
+
+ const xmin = meta.spec.xOrigin;
+ const xmax = meta.spec.xOrigin + meta.spec.xInc * (meta.spec.numCols - 1);
+
+ const ymin = meta.spec.yOrigin + meta.spec.yInc * meta.spec.yFlip * seismicCrosslineNumber;
+ const ymax = ymin;
+
+ const zmin = meta.spec.zOrigin;
+ const zmax = meta.spec.zOrigin + meta.spec.zInc * meta.spec.zFlip * (meta.spec.numLayers - 1);
+
+ const maxXY = { x: xmax, y: ymax };
+ const minXY = { x: xmin, y: ymin };
+ const origin = { x: meta.spec.xOrigin, y: meta.spec.yOrigin };
+
+ const rotatedMinXY = rotatePoint2Around(minXY, origin, degreesToRadians(meta.spec.rotationDeg));
+ const rotatedMaxXY = rotatePoint2Around(maxXY, origin, degreesToRadians(meta.spec.rotationDeg));
+
+ const geometry: Geometry = {
+ shapes: [
+ {
+ type: ShapeType.BOX,
+ centerPoint: vec3.create(
+ (rotatedMinXY.x + rotatedMaxXY.x) / 2,
+ (rotatedMinXY.y + rotatedMaxXY.y) / 2,
+ (zmin + zmax) / 2,
+ ),
+ dimensions: {
+ width: vec3.length(
+ vec3.create(rotatedMaxXY.x - rotatedMinXY.x, rotatedMaxXY.y - rotatedMinXY.y, 0),
+ ),
+ height: Math.abs(zmax - zmin),
+ depth: 0,
+ },
+ normalizedEdgeVectors: {
+ u: vec3.normalize(vec3.create(rotatedMaxXY.x - rotatedMinXY.x, rotatedMaxXY.y - rotatedMinXY.y, 0)),
+ v: vec3.create(0, 0, 1),
+ },
+ },
+ ],
+ boundingBox: bbox.create(
+ vec3.create(Math.min(rotatedMinXY.x, rotatedMaxXY.x), Math.min(rotatedMinXY.y, rotatedMaxXY.y), zmin),
+ vec3.create(Math.max(rotatedMinXY.x, rotatedMaxXY.x), Math.max(rotatedMinXY.y, rotatedMaxXY.y), zmax),
+ ),
+ };
+
+ return geometry;
+}
+
+function predictInlineGeometry({
+ getSetting,
+ getStoredData,
+}: TransformerArgs): Geometry | null {
+ const attribute = getSetting(Setting.ATTRIBUTE);
+ const timeOrInterval = getSetting(Setting.TIME_OR_INTERVAL);
+ const seismicInlineNumber = getSetting(Setting.SEISMIC_INLINE);
+ const seismicCubeMeta = getStoredData("seismicCubeMeta");
+
+ if (!seismicCubeMeta || seismicInlineNumber === null) {
+ return null;
+ }
+
+ const meta = seismicCubeMeta.find(
+ (m) => m.seismicAttribute === attribute && m.isoDateOrInterval === timeOrInterval,
+ );
+
+ if (!meta) {
+ return null;
+ }
+
+ const xmin = meta.spec.xOrigin + meta.spec.yInc * seismicInlineNumber;
+ const xmax = xmin;
+
+ const ymin = meta.spec.yOrigin;
+ const ymax = meta.spec.yOrigin + meta.spec.yInc * meta.spec.yFlip * (meta.spec.numRows - 1);
+
+ const zmin = meta.spec.zOrigin;
+ const zmax = meta.spec.zOrigin + meta.spec.zInc * meta.spec.zFlip * (meta.spec.numLayers - 1);
+
+ const maxXY = { x: xmax, y: ymax };
+ const minXY = { x: xmin, y: ymin };
+ const origin = { x: meta.spec.xOrigin, y: meta.spec.yOrigin };
+
+ const rotatedMinXY = rotatePoint2Around(minXY, origin, (meta.spec.rotationDeg / 180.0) * Math.PI);
+ const rotatedMaxXY = rotatePoint2Around(maxXY, origin, (meta.spec.rotationDeg / 180.0) * Math.PI);
+
+ const geometry: Geometry = {
+ shapes: [
+ {
+ type: ShapeType.BOX,
+ centerPoint: vec3.create(
+ (rotatedMinXY.x + rotatedMaxXY.x) / 2,
+ (rotatedMinXY.y + rotatedMaxXY.y) / 2,
+ (zmin + zmax) / 2,
+ ),
+ dimensions: {
+ width: vec3.length(
+ vec3.create(rotatedMaxXY.x - rotatedMinXY.x, rotatedMaxXY.y - rotatedMinXY.y, 0),
+ ),
+ height: Math.abs(zmax - zmin),
+ depth: 0,
+ },
+ normalizedEdgeVectors: {
+ u: vec3.normalize(vec3.create(rotatedMaxXY.x - rotatedMinXY.x, rotatedMaxXY.y - rotatedMinXY.y, 0)),
+ v: vec3.create(0, 0, 1),
+ },
+ },
+ ],
+ boundingBox: bbox.create(
+ vec3.create(Math.min(rotatedMinXY.x, rotatedMaxXY.x), Math.min(rotatedMinXY.y, rotatedMaxXY.y), zmin),
+ vec3.create(Math.max(rotatedMinXY.x, rotatedMaxXY.x), Math.max(rotatedMinXY.y, rotatedMaxXY.y), zmax),
+ ),
+ };
+
+ return geometry;
+}
+
+export function makeSeismicFenceMeshLayerFunction(plane: Plane) {
+ return function makeSeismicFenceMeshLayer(
+ args: TransformerArgs,
+ ): Layer | null {
+ const { id, name, getData, getSetting, isLoading } = args;
+ const data = getData();
+ const colorScale = getSetting("colorScale")?.colorScale;
+
+ if (!data) {
+ return null;
+ }
+
+ let bbox: number[][] = [
+ [data.bbox_utm[0][0], data.bbox_utm[0][1], data.u_min],
+ [data.bbox_utm[1][0], data.bbox_utm[1][1], data.u_min],
+ [data.bbox_utm[0][0], data.bbox_utm[0][1], data.u_max],
+ [data.bbox_utm[1][0], data.bbox_utm[1][1], data.u_max],
+ ];
+
+ let predictedGeometry: Geometry | null = null;
+
+ if (plane === Plane.DEPTH) {
+ const seismicDepthSlice = getSetting(Setting.SEISMIC_DEPTH_SLICE);
+ bbox = [
+ [data.bbox_utm[0][0], data.bbox_utm[0][1], seismicDepthSlice],
+ [data.bbox_utm[3][0], data.bbox_utm[3][1], seismicDepthSlice],
+ [data.bbox_utm[1][0], data.bbox_utm[1][1], seismicDepthSlice],
+ [data.bbox_utm[2][0], data.bbox_utm[2][1], seismicDepthSlice],
+ ];
+
+ predictedGeometry = predictDepthSliceGeometry(args);
+ } else if (plane === Plane.CROSSLINE) {
+ predictedGeometry = predictCrosslineGeometry(args);
+ } else if (plane === Plane.INLINE) {
+ predictedGeometry = predictInlineGeometry(args);
+ }
+
+ return new SeismicFenceMeshLayer({
+ id,
+ name,
+ data: {
+ sections: [
+ {
+ boundingBox: bbox,
+ properties: data.dataFloat32Arr,
+ numSamplesU: data.u_num_samples,
+ numSamplesV: data.v_num_samples,
+ },
+ ],
+ },
+ colorMapFunction: makeColorMapFunctionFromColorScale(colorScale, data.value_min, data.value_max, false),
+ zIncreaseDownwards: true,
+ isLoading,
+ loadingGeometry: predictedGeometry ?? undefined,
+ });
+ };
+}
diff --git a/frontend/src/modules/3DViewerNew/interfaces.ts b/frontend/src/modules/3DViewerNew/interfaces.ts
new file mode 100644
index 000000000..64cc6ce89
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/interfaces.ts
@@ -0,0 +1,29 @@
+import { InterfaceInitialization } from "@framework/UniDirectionalModuleComponentsInterface";
+
+import { providerManagerAtom, preferredViewLayoutAtom } from "./settings/atoms/baseAtoms";
+import { PreferredViewLayout } from "./types";
+
+import { DataProviderManager } from "@modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager";
+import { selectedFieldIdentifierAtom } from "./settings/atoms/derivedAtoms";
+
+export type SettingsToViewInterface = {
+ layerManager: DataProviderManager | null;
+ fieldIdentifier: string | null;
+ preferredViewLayout: PreferredViewLayout;
+};
+
+export type Interfaces = {
+ settingsToView: SettingsToViewInterface;
+};
+
+export const settingsToViewInterfaceInitialization: InterfaceInitialization = {
+ layerManager: (get) => {
+ return get(providerManagerAtom);
+ },
+ fieldIdentifier: (get) => {
+ return get(selectedFieldIdentifierAtom);
+ },
+ preferredViewLayout: (get) => {
+ return get(preferredViewLayoutAtom);
+ },
+};
diff --git a/frontend/src/modules/3DViewerNew/loadModule.tsx b/frontend/src/modules/3DViewerNew/loadModule.tsx
new file mode 100644
index 000000000..8bb478e21
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/loadModule.tsx
@@ -0,0 +1,16 @@
+import { ModuleRegistry } from "@framework/ModuleRegistry";
+
+import type { Interfaces } from "./interfaces";
+import { settingsToViewInterfaceInitialization } from "./interfaces";
+import { MODULE_NAME } from "./registerModule";
+import { Settings } from "./settings/settings";
+import { View } from "./view/view";
+
+import "./DataProviderFramework/customDataProviderImplementations/registerAllDataProviders";
+
+const module = ModuleRegistry.initModule(MODULE_NAME, {
+ settingsToViewInterfaceInitialization,
+});
+
+module.settingsFC = Settings;
+module.viewFC = View;
diff --git a/frontend/src/modules/3DViewerNew/preview.jpg b/frontend/src/modules/3DViewerNew/preview.jpg
new file mode 100644
index 000000000..d1e2273e9
Binary files /dev/null and b/frontend/src/modules/3DViewerNew/preview.jpg differ
diff --git a/frontend/src/modules/3DViewerNew/preview.tsx b/frontend/src/modules/3DViewerNew/preview.tsx
new file mode 100644
index 000000000..831e956e2
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/preview.tsx
@@ -0,0 +1,6 @@
+import { DrawPreviewFunc } from "@framework/Preview";
+import previewImg from "./preview.jpg";
+
+export const preview: DrawPreviewFunc = function (width: number, height: number) {
+ return ;
+};
diff --git a/frontend/src/modules/3DViewerNew/registerModule.ts b/frontend/src/modules/3DViewerNew/registerModule.ts
new file mode 100644
index 000000000..cb06a94f5
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/registerModule.ts
@@ -0,0 +1,29 @@
+import { ModuleCategory, ModuleDevState } from "@framework/Module";
+import { ModuleDataTagId } from "@framework/ModuleDataTags";
+import { ModuleRegistry } from "@framework/ModuleRegistry";
+
+import { Interfaces } from "./interfaces";
+import { preview } from "./preview";
+
+export const MODULE_NAME: string = "3DViewerNew";
+
+const description = "Generic 3D viewer for grid, surfaces, and wells.";
+
+ModuleRegistry.registerModule({
+ moduleName: MODULE_NAME,
+ category: ModuleCategory.MAIN,
+ devState: ModuleDevState.DEV,
+ defaultTitle: "3D Viewer (new)",
+ preview,
+ description,
+ dataTagIds: [
+ ModuleDataTagId.SURFACE,
+ ModuleDataTagId.DRILLED_WELLS,
+ ModuleDataTagId.SEISMIC,
+ ModuleDataTagId.GRID3D,
+ ModuleDataTagId.POLYGONS,
+ ],
+ onInstanceUnload: (instanceId) => {
+ window.localStorage.removeItem(`${instanceId}-settings`);
+ },
+});
diff --git a/frontend/src/modules/3DViewerNew/settings/atoms/baseAtoms.ts b/frontend/src/modules/3DViewerNew/settings/atoms/baseAtoms.ts
new file mode 100644
index 000000000..6c7f4e9ed
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/settings/atoms/baseAtoms.ts
@@ -0,0 +1,8 @@
+import { PreferredViewLayout } from "@modules/2DViewer/types";
+import type { DataProviderManager } from "@modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager";
+
+import { atom } from "jotai";
+
+export const userSelectedFieldIdentifierAtom = atom(null);
+export const providerManagerAtom = atom(null);
+export const preferredViewLayoutAtom = atom(PreferredViewLayout.VERTICAL);
diff --git a/frontend/src/modules/3DViewerNew/settings/atoms/derivedAtoms.ts b/frontend/src/modules/3DViewerNew/settings/atoms/derivedAtoms.ts
new file mode 100644
index 000000000..ae64f8ba7
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/settings/atoms/derivedAtoms.ts
@@ -0,0 +1,19 @@
+import { EnsembleSetAtom } from "@framework/GlobalAtoms";
+
+import { atom } from "jotai";
+
+import { userSelectedFieldIdentifierAtom } from "./baseAtoms";
+
+export const selectedFieldIdentifierAtom = atom((get) => {
+ const ensembleSet = get(EnsembleSetAtom);
+ const userSelectedField = get(userSelectedFieldIdentifierAtom);
+
+ if (
+ !userSelectedField ||
+ !ensembleSet.getRegularEnsembleArray().some((ens) => ens.getFieldIdentifier() === userSelectedField)
+ ) {
+ return ensembleSet.getRegularEnsembleArray().at(0)?.getFieldIdentifier() ?? null;
+ }
+
+ return userSelectedField;
+});
diff --git a/frontend/src/modules/3DViewerNew/settings/components/dataProviderManagerComponentWrapper.tsx b/frontend/src/modules/3DViewerNew/settings/components/dataProviderManagerComponentWrapper.tsx
new file mode 100644
index 000000000..5cf3aaec3
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/settings/components/dataProviderManagerComponentWrapper.tsx
@@ -0,0 +1,420 @@
+import type React from "react";
+
+import { Icon } from "@equinor/eds-core-react";
+import { color_palette, fault, grid_layer, settings, surface_layer, wellbore } from "@equinor/eds-icons";
+import type { WorkbenchSession } from "@framework/WorkbenchSession";
+import type { WorkbenchSettings } from "@framework/WorkbenchSettings";
+import { Menu } from "@lib/components/Menu";
+import { MenuButton } from "@lib/components/MenuButton";
+import { MenuHeading } from "@lib/components/MenuHeading";
+import { MenuItem } from "@lib/components/MenuItem";
+import { PreferredViewLayout } from "@modules/2DViewer/types";
+import { GroupType } from "@modules/_shared/LayerFramework/groups/groupTypes";
+import { usePublishSubscribeTopicValue } from "@modules/_shared/utils/PublishSubscribeDelegate";
+import { Dropdown } from "@mui/base";
+import {
+ Check,
+ Panorama,
+ SettingsApplications,
+ Settings as SettingsIcon,
+ TableRowsOutlined,
+ ViewColumnOutlined,
+} from "@mui/icons-material";
+
+import { useAtom } from "jotai";
+
+import { preferredViewLayoutAtom } from "../atoms/baseAtoms";
+import type { DataProviderManager } from "@modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager";
+import { GroupDelegateTopic, type GroupDelegate } from "@modules/_shared/DataProviderFramework/delegates/GroupDelegate";
+import { GroupRegistry } from "@modules/_shared/DataProviderFramework/groups/GroupRegistry";
+import { DeltaSurface } from "@modules/_shared/DataProviderFramework/framework/DeltaSurface/DeltaSurface";
+import { SettingsGroup } from "@modules/_shared/DataProviderFramework/framework/SettingsGroup/SettingsGroup";
+import { SharedSetting } from "@modules/_shared/DataProviderFramework/framework/SharedSetting/SharedSetting";
+import { Setting } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions";
+import { DataProviderRegistry } from "@modules/_shared/DataProviderFramework/dataProviders/DataProviderRegistry";
+import { DataProviderType } from "@modules/_shared/DataProviderFramework/dataProviders/dataProviderTypes";
+import {
+ instanceofItemGroup,
+ type Item,
+ type ItemGroup,
+} from "@modules/_shared/DataProviderFramework/interfacesAndTypes/entities";
+import { DataProvider } from "@modules/_shared/DataProviderFramework/framework/DataProvider/DataProvider";
+import { RealizationSurfaceProvider } from "@modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationSurfaceProvider";
+import { StatisticalSurfaceProvider } from "@modules/2DViewer/DataProviderFramework/customDataProviderImplementations/StatisticalSurfaceProvider";
+import { ObservedSurfaceProvider } from "@modules/2DViewer/DataProviderFramework/customDataProviderImplementations/ObservedSurfaceProvider";
+import { Group } from "@modules/_shared/DataProviderFramework/framework/Group/Group";
+import { DataProviderManagerComponent } from "@modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManagerComponent";
+import type { ActionGroup } from "@modules/_shared/DataProviderFramework/Actions";
+
+export type LayerManagerComponentWrapperProps = {
+ layerManager: DataProviderManager;
+ workbenchSession: WorkbenchSession;
+ workbenchSettings: WorkbenchSettings;
+};
+
+export function LayerManagerComponentWrapper(props: LayerManagerComponentWrapperProps): React.ReactNode {
+ const colorSet = props.workbenchSettings.useColorSet();
+ const [preferredViewLayout, setPreferredViewLayout] = useAtom(preferredViewLayoutAtom);
+
+ const groupDelegate = props.layerManager.getGroupDelegate();
+ usePublishSubscribeTopicValue(groupDelegate, GroupDelegateTopic.CHILDREN);
+
+ function handleLayerAction(identifier: string, groupDelegate: GroupDelegate) {
+ switch (identifier) {
+ case "view":
+ groupDelegate.appendChild(
+ GroupRegistry.makeGroup(GroupType.VIEW, props.layerManager, colorSet.getNextColor()),
+ );
+ return;
+ case "delta-surface":
+ groupDelegate.appendChild(new DeltaSurface("Delta surface", props.layerManager));
+ return;
+ case "settings-group":
+ groupDelegate.appendChild(new SettingsGroup("Settings group", props.layerManager));
+ return;
+ case "color-scale":
+ groupDelegate.appendChild(new SharedSetting(Setting.COLOR_SCALE, null, props.layerManager));
+ return;
+ case "statistical-surface":
+ groupDelegate.prependChild(
+ DataProviderRegistry.makeDataProvider(DataProviderType.STATISTICAL_SURFACE_3D, props.layerManager),
+ );
+ return;
+ case "realization-surface":
+ groupDelegate.prependChild(
+ DataProviderRegistry.makeDataProvider(DataProviderType.REALIZATION_SURFACE_3D, props.layerManager),
+ );
+ return;
+ case "realization-polygons":
+ groupDelegate.prependChild(
+ DataProviderRegistry.makeDataProvider(DataProviderType.REALIZATION_POLYGONS, props.layerManager),
+ );
+ return;
+ case "drilled-wellbore-trajectories":
+ groupDelegate.prependChild(
+ DataProviderRegistry.makeDataProvider(
+ DataProviderType.DRILLED_WELL_TRAJECTORIES,
+ props.layerManager,
+ ),
+ );
+ return;
+ case "drilled-wellbore-picks":
+ groupDelegate.prependChild(
+ DataProviderRegistry.makeDataProvider(DataProviderType.DRILLED_WELLBORE_PICKS, props.layerManager),
+ );
+ return;
+ case "intersection-realization-grid":
+ groupDelegate.prependChild(
+ DataProviderRegistry.makeDataProvider(
+ DataProviderType.INTERSECTION_REALIZATION_GRID,
+ props.layerManager,
+ ),
+ );
+ return;
+ case "realization-grid":
+ groupDelegate.prependChild(
+ DataProviderRegistry.makeDataProvider(DataProviderType.REALIZATION_GRID, props.layerManager),
+ );
+ return;
+ case "realization-seismic-inline":
+ groupDelegate.prependChild(
+ DataProviderRegistry.makeDataProvider(
+ DataProviderType.REALIZATION_SEISMIC_INLINE,
+ props.layerManager,
+ ),
+ );
+ return;
+ case "realization-seismic-crossline":
+ groupDelegate.prependChild(
+ DataProviderRegistry.makeDataProvider(
+ DataProviderType.REALIZATION_SEISMIC_CROSSLINE,
+ props.layerManager,
+ ),
+ );
+ return;
+ case "realization-seismic-depth-slice":
+ groupDelegate.prependChild(
+ DataProviderRegistry.makeDataProvider(
+ DataProviderType.REALIZATION_SEISMIC_DEPTH_SLICE,
+ props.layerManager,
+ ),
+ );
+ return;
+ case "ensemble":
+ groupDelegate.appendChild(new SharedSetting(Setting.ENSEMBLE, null, props.layerManager));
+ return;
+ case "realization":
+ groupDelegate.appendChild(new SharedSetting(Setting.REALIZATION, null, props.layerManager));
+ return;
+ case "surface-name":
+ groupDelegate.appendChild(new SharedSetting(Setting.SURFACE_NAME, null, props.layerManager));
+ return;
+ case "attribute":
+ groupDelegate.appendChild(new SharedSetting(Setting.ATTRIBUTE, null, props.layerManager));
+ return;
+ case "Date":
+ groupDelegate.appendChild(new SharedSetting(Setting.TIME_OR_INTERVAL, null, props.layerManager));
+ return;
+ }
+ }
+
+ function checkIfItemMoveAllowed(movedItem: Item, destinationItem: ItemGroup): boolean {
+ if (destinationItem instanceof DeltaSurface) {
+ if (
+ movedItem instanceof DataProvider &&
+ !(
+ movedItem instanceof RealizationSurfaceProvider ||
+ movedItem instanceof StatisticalSurfaceProvider ||
+ movedItem instanceof ObservedSurfaceProvider
+ )
+ ) {
+ return false;
+ }
+
+ if (instanceofItemGroup(movedItem)) {
+ return false;
+ }
+
+ if (destinationItem.getGroupDelegate().findChildren((item) => item instanceof DataProvider).length >= 2) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ function makeActionsForGroup(group: ItemGroup): ActionGroup[] {
+ const hasView =
+ groupDelegate.getDescendantItems((item) => item instanceof Group && item.getGroupType() === GroupType.VIEW)
+ .length > 0;
+
+ const hasViewAncestor =
+ group
+ .getGroupDelegate()
+ .getAncestors((item) => item instanceof Group && item.getGroupType() === GroupType.VIEW).length > 0;
+ const actions: ActionGroup[] = [];
+
+ if (!hasView) {
+ return INITIAL_ACTIONS;
+ }
+
+ const groupActions: ActionGroup = {
+ label: "Groups",
+ children: [],
+ };
+
+ if (!hasViewAncestor) {
+ groupActions.children.push({
+ identifier: "view",
+ icon: ,
+ label: "View",
+ });
+ }
+
+ groupActions.children.push({
+ identifier: "settings-group",
+ icon: ,
+ label: "Settings group",
+ });
+
+ actions.push(groupActions);
+ actions.push(...ACTIONS);
+
+ return actions;
+ }
+
+ return (
+
+
+
+
+
+
+ }
+ groupActions={makeActionsForGroup}
+ onAction={handleLayerAction}
+ isMoveAllowed={checkIfItemMoveAllowed}
+ />
+ );
+}
+
+type ViewLayoutMenuItemProps = {
+ checked: boolean;
+ onClick: () => void;
+ children: React.ReactNode;
+};
+
+function ViewLayoutMenuItem(props: ViewLayoutMenuItemProps): React.ReactNode {
+ return (
+
+ );
+}
+
+const INITIAL_ACTIONS: ActionGroup[] = [
+ {
+ label: "Groups",
+ children: [
+ {
+ identifier: "view",
+ icon: ,
+ label: "View",
+ },
+ {
+ identifier: "settings-group",
+ icon: ,
+ label: "Settings group",
+ },
+ ],
+ },
+];
+
+const ACTIONS: ActionGroup[] = [
+ {
+ label: "Layers",
+ children: [
+ {
+ label: "Wells",
+ children: [
+ {
+ identifier: "drilled-wellbore-trajectories",
+ icon: ,
+ label: "Drilled Wellbore Trajectories",
+ },
+ {
+ identifier: "drilled-wellbore-picks",
+ icon: ,
+ label: "Drilled Wellbore Picks",
+ },
+ ],
+ },
+ {
+ label: "Intersection",
+ children: [
+ {
+ identifier: "intersection-realization-grid",
+ icon: ,
+ label: "Intersection Realization Grid",
+ },
+ ],
+ },
+ {
+ label: "Reservoir grid",
+ children: [
+ {
+ identifier: "realization-grid",
+ icon: ,
+ label: "Realization Grid",
+ },
+ ],
+ },
+ {
+ label: "Surface",
+ children: [
+ {
+ identifier: "statistical-surface",
+ icon: ,
+ label: "Statistical Surface",
+ },
+ {
+ identifier: "realization-surface",
+ icon: ,
+ label: "Realization Surface",
+ },
+ ],
+ },
+ {
+ label: "Polygons",
+ children: [
+ {
+ identifier: "realization-polygons",
+ icon: ,
+ label: "Realization Polygons",
+ },
+ ],
+ },
+ {
+ label: "Seismic",
+ children: [
+ {
+ label: "Synthetic",
+ children: [
+ {
+ identifier: "realization-seismic-inline",
+ icon: ,
+ label: "Realization Inline",
+ },
+ {
+ identifier: "realization-seismic-crossline",
+ icon: ,
+ label: "Realization Crossline",
+ },
+ {
+ identifier: "realization-seismic-depth-slice",
+ icon: ,
+ label: "Realization Depth Slice",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ label: "Shared Settings",
+ children: [
+ {
+ identifier: "ensemble",
+ icon: ,
+ label: "Ensemble",
+ },
+ {
+ identifier: "realization",
+ icon: ,
+ label: "Realization",
+ },
+ {
+ identifier: "attribute",
+ icon: ,
+ label: "Attribute",
+ },
+ {
+ identifier: "Date",
+ icon: ,
+ label: "Date",
+ },
+ ],
+ },
+ {
+ label: "Utilities",
+ children: [
+ {
+ identifier: "color-scale",
+ icon: ,
+ label: "Color scale",
+ },
+ ],
+ },
+];
diff --git a/frontend/src/modules/3DViewerNew/settings/queries/queryDataTransforms.ts b/frontend/src/modules/3DViewerNew/settings/queries/queryDataTransforms.ts
new file mode 100644
index 000000000..6dc734f2a
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/settings/queries/queryDataTransforms.ts
@@ -0,0 +1,20 @@
+import { SeismicSliceData_api } from "@api";
+import { b64DecodeFloatArrayToFloat32 } from "@modules/_shared/base64";
+
+export type SeismicSliceData_trans = Omit & {
+ dataFloat32Arr: Float32Array;
+};
+
+export function transformSeismicSlice(apiData: SeismicSliceData_api): SeismicSliceData_trans {
+ const startTS = performance.now();
+
+ const { slice_traces_b64arr, ...untransformedData } = apiData;
+ const dataFloat32Arr = b64DecodeFloatArrayToFloat32(slice_traces_b64arr);
+
+ console.debug(`transformSeismicSlice() took: ${(performance.now() - startTS).toFixed(1)}ms`);
+
+ return {
+ ...untransformedData,
+ dataFloat32Arr: dataFloat32Arr,
+ };
+}
diff --git a/frontend/src/modules/3DViewerNew/settings/settings.tsx b/frontend/src/modules/3DViewerNew/settings/settings.tsx
new file mode 100644
index 000000000..480497e8f
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/settings/settings.tsx
@@ -0,0 +1,152 @@
+import React from "react";
+
+import { ModuleSettingsProps } from "@framework/Module";
+import { useEnsembleSet } from "@framework/WorkbenchSession";
+import { FieldDropdown } from "@framework/components/FieldDropdown";
+import { CollapsibleGroup } from "@lib/components/CollapsibleGroup";
+import { useQueryClient } from "@tanstack/react-query";
+
+import { useAtom, useAtomValue, useSetAtom } from "jotai";
+
+import { providerManagerAtom, preferredViewLayoutAtom, userSelectedFieldIdentifierAtom } from "./atoms/baseAtoms";
+import { selectedFieldIdentifierAtom } from "./atoms/derivedAtoms";
+import { LayerManagerComponentWrapper } from "./components/dataProviderManagerComponentWrapper";
+import {
+ DataProviderManager,
+ DataProviderManagerTopic,
+} from "@modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager";
+import { GroupDelegateTopic } from "@modules/_shared/DataProviderFramework/delegates/GroupDelegate";
+
+export function Settings(props: ModuleSettingsProps): React.ReactNode {
+ const ensembleSet = useEnsembleSet(props.workbenchSession);
+ const queryClient = useQueryClient();
+
+ const [layerManager, setLayerManager] = useAtom(providerManagerAtom);
+
+ const fieldIdentifier = useAtomValue(selectedFieldIdentifierAtom);
+ const setFieldIdentifier = useSetAtom(userSelectedFieldIdentifierAtom);
+ const [preferredViewLayout, setPreferredViewLayout] = useAtom(preferredViewLayoutAtom);
+
+ const persistState = React.useCallback(
+ function persistLayerManagerState() {
+ if (!layerManager) {
+ return;
+ }
+
+ const serializedState = {
+ layerManager: layerManager.serializeState(),
+ fieldIdentifier,
+ preferredViewLayout,
+ };
+ window.localStorage.setItem(
+ `${props.settingsContext.getInstanceIdString()}-settings`,
+ JSON.stringify(serializedState),
+ );
+ },
+ [layerManager, fieldIdentifier, preferredViewLayout, props.settingsContext],
+ );
+
+ const applyPersistedState = React.useCallback(
+ function applyPersistedState(layerManager: DataProviderManager) {
+ const serializedState = window.localStorage.getItem(
+ `${props.settingsContext.getInstanceIdString()}-settings`,
+ );
+ if (!serializedState) {
+ return;
+ }
+
+ const parsedState = JSON.parse(serializedState);
+ if (parsedState.fieldIdentifier) {
+ setFieldIdentifier(parsedState.fieldIdentifier);
+ }
+ if (parsedState.preferredViewLayout) {
+ setPreferredViewLayout(parsedState.preferredViewLayout);
+ }
+
+ if (parsedState.layerManager) {
+ if (!layerManager) {
+ return;
+ }
+ layerManager.deserializeState(parsedState.layerManager);
+ }
+ },
+ [setFieldIdentifier, setPreferredViewLayout, props.settingsContext],
+ );
+
+ React.useEffect(
+ function onMountEffect() {
+ const newLayerManager = new DataProviderManager(
+ props.workbenchSession,
+ props.workbenchSettings,
+ queryClient,
+ );
+ setLayerManager(newLayerManager);
+
+ applyPersistedState(newLayerManager);
+
+ return function onUnmountEffect() {
+ newLayerManager.beforeDestroy();
+ };
+ },
+ [setLayerManager, props.workbenchSession, props.workbenchSettings, queryClient, applyPersistedState],
+ );
+
+ React.useEffect(
+ function onLayerManagerChangeEffect() {
+ if (!layerManager) {
+ return;
+ }
+
+ persistState();
+
+ const unsubscribeDataRev = layerManager
+ .getPublishSubscribeDelegate()
+ .makeSubscriberFunction(DataProviderManagerTopic.DATA_REVISION)(persistState);
+
+ const unsubscribeExpands = layerManager
+ .getGroupDelegate()
+ .getPublishSubscribeDelegate()
+ .makeSubscriberFunction(GroupDelegateTopic.CHILDREN_EXPANSION_STATES)(persistState);
+
+ return function onUnmountEffect() {
+ layerManager.beforeDestroy();
+ unsubscribeDataRev();
+ unsubscribeExpands();
+ };
+ },
+ [layerManager, props.workbenchSession, props.workbenchSettings, persistState],
+ );
+
+ React.useEffect(
+ function onFieldIdentifierChangedEffect() {
+ if (!layerManager) {
+ return;
+ }
+ layerManager.updateGlobalSetting("fieldId", fieldIdentifier);
+ },
+ [fieldIdentifier, layerManager],
+ );
+
+ function handleFieldChange(fieldId: string | null) {
+ setFieldIdentifier(fieldId);
+ if (!layerManager) {
+ return;
+ }
+ layerManager.updateGlobalSetting("fieldId", fieldId);
+ }
+
+ return (
+
+
+
+
+ {layerManager && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/modules/3DViewerNew/types.ts b/frontend/src/modules/3DViewerNew/types.ts
new file mode 100644
index 000000000..a457c6616
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/types.ts
@@ -0,0 +1,4 @@
+export enum PreferredViewLayout {
+ HORIZONTAL = "horizontal",
+ VERTICAL = "vertical",
+}
diff --git a/frontend/src/modules/3DViewerNew/view/components/ContextMenu.tsx b/frontend/src/modules/3DViewerNew/view/components/ContextMenu.tsx
new file mode 100644
index 000000000..2eea7315f
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/view/components/ContextMenu.tsx
@@ -0,0 +1,63 @@
+import React from "react";
+
+import { usePublishSubscribeTopicValue } from "@modules/_shared/utils/PublishSubscribeDelegate";
+
+import { isEqual } from "lodash";
+
+import {
+ ContextMenu as ContextMenuType,
+ DeckGlInstanceManager,
+ DeckGlInstanceManagerTopic,
+} from "../utils/DeckGlInstanceManager";
+
+export type ContextMenuProps = {
+ deckGlManager: DeckGlInstanceManager;
+};
+
+export function ContextMenu(props: ContextMenuProps): React.ReactNode {
+ const [visible, setVisible] = React.useState(false);
+ const [prevContextMenu, setPrevContextMenu] = React.useState(null);
+ const contextMenu = usePublishSubscribeTopicValue(props.deckGlManager, DeckGlInstanceManagerTopic.CONTEXT_MENU);
+
+ React.useEffect(function handleMount() {
+ function hideContextMenu() {
+ setVisible(false);
+ }
+
+ window.addEventListener("blur", hideContextMenu);
+
+ return function handleUnmount() {
+ window.removeEventListener("blur", hideContextMenu);
+ };
+ }, []);
+
+ if (!isEqual(prevContextMenu, contextMenu)) {
+ setPrevContextMenu(contextMenu);
+ setVisible(true);
+ }
+
+ if (!contextMenu || !visible || !contextMenu.items.length) {
+ return null;
+ }
+
+ return (
+
+ {contextMenu.items.map((item, index) => (
+
{
+ item.onClick();
+ setVisible(false);
+ }}
+ >
+ {item.icon ? React.cloneElement(item.icon, { fontSize: "small" }) : null}
+ {item.label}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/modules/3DViewerNew/view/components/InteractionWrapper.tsx b/frontend/src/modules/3DViewerNew/view/components/InteractionWrapper.tsx
new file mode 100644
index 000000000..6d77c64b1
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/view/components/InteractionWrapper.tsx
@@ -0,0 +1,163 @@
+import React from "react";
+
+import { Layer as DeckGlLayer } from "@deck.gl/core";
+import { DeckGLRef } from "@deck.gl/react";
+import { useIntersectionPolylines } from "@framework/UserCreatedItems";
+import { IntersectionPolylinesEvent } from "@framework/userCreatedItems/IntersectionPolylines";
+import { usePublishSubscribeTopicValue } from "@modules/_shared/utils/PublishSubscribeDelegate";
+import { AxesLayer } from "@webviz/subsurface-viewer/dist/layers";
+
+import { converter } from "culori";
+
+import { ContextMenu } from "./ContextMenu";
+import { ReadoutWrapperProps, ReadoutWrapper } from "./ReadoutWrapper";
+import { Toolbar } from "./Toolbar";
+
+import { DeckGlInstanceManager, DeckGlInstanceManagerTopic } from "../utils/DeckGlInstanceManager";
+import { Polyline, PolylinesPlugin, PolylinesPluginTopic } from "../utils/PolylinesPlugin";
+
+export type InteractionWrapperProps = Omit<
+ ReadoutWrapperProps,
+ "deckGlManager" | "triggerHome" | "verticalScale" | "deckGlRef"
+> & {
+ fieldIdentifier: string | null;
+};
+
+export function InteractionWrapper(props: InteractionWrapperProps): React.ReactNode {
+ const deckGlRef = React.useRef(null);
+ deckGlRef.current?.deck?.needsRedraw;
+ const [deckGlManager, setDeckGlManager] = React.useState(
+ new DeckGlInstanceManager(deckGlRef.current),
+ );
+ const [polylinesPlugin, setPolylinesPlugin] = React.useState(new PolylinesPlugin(deckGlManager));
+
+ usePublishSubscribeTopicValue(deckGlManager, DeckGlInstanceManagerTopic.REDRAW);
+
+ const intersectionPolylines = useIntersectionPolylines(props.workbenchSession);
+ const colorSet = props.workbenchSettings.useColorSet();
+
+ const colorGenerator = React.useCallback(
+ function* colorGenerator() {
+ const colors: [number, number, number][] = colorSet.getColorArray().map((c) => {
+ const rgb = converter("rgb")(c);
+ if (!rgb) {
+ return [0, 0, 0];
+ }
+ return [rgb.r * 255, rgb.g * 255, rgb.b * 255];
+ });
+ let i = 0;
+ while (true) {
+ yield colors[i % colors.length];
+ i++;
+ }
+ },
+ [colorSet],
+ );
+
+ React.useEffect(
+ function setupDeckGlManager() {
+ const manager = new DeckGlInstanceManager(deckGlRef.current);
+ setDeckGlManager(manager);
+
+ const polylinesPlugin = new PolylinesPlugin(manager, colorGenerator());
+ polylinesPlugin.setPolylines(intersectionPolylines.getPolylines());
+ manager.addPlugin(polylinesPlugin);
+ setPolylinesPlugin(polylinesPlugin);
+
+ const unsubscribeFromPolylinesPlugin = polylinesPlugin
+ .getPublishSubscribeDelegate()
+ .makeSubscriberFunction(PolylinesPluginTopic.EDITING_POLYLINE_ID)(() => {
+ if (polylinesPlugin.getCurrentEditingPolylineId() === null && props.fieldIdentifier !== null) {
+ intersectionPolylines.setPolylines(
+ polylinesPlugin.getPolylines().map((p) => ({ ...p, fieldId: props.fieldIdentifier! })),
+ );
+ }
+ });
+
+ const unsubscribeFromIntersectionPolylines = intersectionPolylines.subscribe(
+ IntersectionPolylinesEvent.CHANGE,
+ () => {
+ polylinesPlugin.setPolylines(intersectionPolylines.getPolylines());
+ },
+ );
+
+ return function cleanupDeckGlManager() {
+ manager.beforeDestroy();
+ unsubscribeFromPolylinesPlugin();
+ unsubscribeFromIntersectionPolylines();
+ };
+ },
+ [intersectionPolylines, colorGenerator],
+ );
+
+ const [triggerHomeCounter, setTriggerHomeCounter] = React.useState(0);
+ const [gridVisible, setGridVisible] = React.useState(false);
+ const [verticalScale, setVerticalScale] = React.useState(1);
+ const [polylines, setPolylines] = React.useState([]);
+
+ function handleFitInViewClick() {
+ setTriggerHomeCounter((prev) => prev + 1);
+ }
+
+ function handleGridVisibilityChange(visible: boolean) {
+ setGridVisible(visible);
+ }
+
+ function handleVerticalScaleChange(value: number) {
+ setVerticalScale(value);
+ }
+
+ const activePolylineId = polylinesPlugin.getCurrentEditingPolylineId();
+
+ const handlePolylineNameChange = React.useCallback(
+ function handlePolylineNameChange(name: string): void {
+ if (!activePolylineId) {
+ return;
+ }
+
+ setPolylines((prev) =>
+ prev.map((polyline) => {
+ if (polyline.id === activePolylineId) {
+ return {
+ ...polyline,
+ name,
+ };
+ }
+
+ return polyline;
+ }),
+ );
+ },
+ [activePolylineId],
+ );
+
+ let adjustedLayers: DeckGlLayer[] = [...props.layers];
+ if (!gridVisible) {
+ adjustedLayers = adjustedLayers.filter((layer) => !(layer instanceof AxesLayer));
+ }
+
+ return (
+ <>
+ p.id === activePolylineId)?.name}
+ />
+
+
+ >
+ );
+}
diff --git a/frontend/src/modules/3DViewerNew/view/components/LayersWrapper.tsx b/frontend/src/modules/3DViewerNew/view/components/LayersWrapper.tsx
new file mode 100644
index 000000000..a5c73b4f7
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/view/components/LayersWrapper.tsx
@@ -0,0 +1,260 @@
+import React from "react";
+
+import { View as DeckGlView, type Layer } from "@deck.gl/core";
+import type { ViewContext } from "@framework/ModuleContext";
+import { useViewStatusWriter } from "@framework/StatusWriter";
+import type { WorkbenchSession } from "@framework/WorkbenchSession";
+import type { WorkbenchSettings } from "@framework/WorkbenchSettings";
+import { useElementSize } from "@lib/hooks/useElementSize";
+import * as bbox from "@lib/utils/boundingBox";
+import type { Interfaces } from "@modules/2DViewer/interfaces";
+import { PreferredViewLayout } from "@modules/2DViewer/types";
+import { makeDrilledWellTrajectoriesLayer } from "@modules/3DViewerNew/DataProviderFramework/visualization/makeDrilledWellTrajectoriesLayer";
+import { makeIntersectionLayer } from "@modules/3DViewerNew/DataProviderFramework/visualization/makeIntersectionGrid3dLayer";
+import { makeRealizationSurfaceLayer } from "@modules/3DViewerNew/DataProviderFramework/visualization/makeRealizationSurfaceLayer";
+import {
+ Plane,
+ makeSeismicFenceMeshLayerFunction,
+} from "@modules/3DViewerNew/DataProviderFramework/visualization/makeSeismicFenceMeshLayer";
+import { ColorLegendsContainer } from "@modules/_shared/components/ColorLegendsContainer";
+import { usePublishSubscribeTopicValue } from "@modules/_shared/utils/PublishSubscribeDelegate";
+import type { ViewportType } from "@webviz/subsurface-viewer";
+import { AxesLayer } from "@webviz/subsurface-viewer/dist/layers";
+
+import { InteractionWrapper } from "./InteractionWrapper";
+
+import { PlaceholderLayer } from "../../../_shared/customDeckGlLayers/PlaceholderLayer";
+import {
+ VisualizationAssembler,
+ VisualizationItemType,
+ type VisualizationTarget,
+} from "@modules/_shared/DataProviderFramework/visualization/VisualizationAssembler";
+import { DataProviderType } from "@modules/_shared/DataProviderFramework/dataProviders/dataProviderTypes";
+import { RealizationSurfaceProvider } from "@modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationSurfaceProvider";
+import { makeColorScaleAnnotation } from "@modules/2DViewer/DataProviderFramework/annotations/makeColorScaleAnnotation";
+import { StatisticalSurfaceProvider } from "@modules/2DViewer/DataProviderFramework/customDataProviderImplementations/StatisticalSurfaceProvider";
+import { RealizationPolygonsProvider } from "@modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationPolygonsProvider";
+import { makeRealizationPolygonsLayer } from "@modules/2DViewer/DataProviderFramework/visualization/makeRealizationPolygonsLayer";
+import { IntersectionRealizationGridProvider } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/IntersectionRealizationGridProvider";
+import { RealizationGridProvider } from "@modules/2DViewer/DataProviderFramework/customDataProviderImplementations/RealizationGridProvider";
+import { makeRealizationGridLayer } from "@modules/2DViewer/DataProviderFramework/visualization/makeRealizationGridLayer";
+import { DrilledWellborePicksProvider } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/DrilledWellborePicksProvider";
+import { makeDrilledWellborePicksLayer } from "@modules/_shared/DataProviderFramework/visualization/deckgl/makeDrilledWellborePicksLayer";
+import { DrilledWellTrajectoriesProvider } from "@modules/_shared/DataProviderFramework/dataProviders/implementations/DrilledWellTrajectoriesProvider";
+import { RealizationSeismicDepthSliceProvider } from "@modules/3DViewerNew/DataProviderFramework/customDataProviderImplementations/RealizationSeismicDepthProvider";
+import { RealizationSeismicInlineProvider } from "@modules/3DViewerNew/DataProviderFramework/customDataProviderImplementations/RealizationSeismicInlineProvider";
+import { RealizationSeismicCrosslineProvider } from "@modules/3DViewerNew/DataProviderFramework/customDataProviderImplementations/RealizationSeismicCrosslineProvider";
+import {
+ DataProviderManagerTopic,
+ type DataProviderManager,
+} from "@modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager";
+import { GroupType } from "@modules/_shared/DataProviderFramework/groups/groupTypes";
+import { makeStatisticalSurfaceLayer } from "@modules/2DViewer/DataProviderFramework/visualization/makeStatisticalSurfaceLayer";
+
+const VISUALIZATION_ASSEMBLER = new VisualizationAssembler();
+
+VISUALIZATION_ASSEMBLER.registerDataProviderTransformers(
+ DataProviderType.REALIZATION_SURFACE_3D,
+ RealizationSurfaceProvider,
+ {
+ transformToVisualization: makeRealizationSurfaceLayer,
+ transformToAnnotations: makeColorScaleAnnotation,
+ },
+);
+VISUALIZATION_ASSEMBLER.registerDataProviderTransformers(
+ DataProviderType.STATISTICAL_SURFACE_3D,
+ StatisticalSurfaceProvider,
+ {
+ transformToVisualization: makeStatisticalSurfaceLayer,
+ transformToAnnotations: makeColorScaleAnnotation,
+ },
+);
+VISUALIZATION_ASSEMBLER.registerDataProviderTransformers(
+ DataProviderType.REALIZATION_POLYGONS,
+ RealizationPolygonsProvider,
+ {
+ transformToVisualization: makeRealizationPolygonsLayer,
+ },
+);
+VISUALIZATION_ASSEMBLER.registerDataProviderTransformers(
+ DataProviderType.INTERSECTION_REALIZATION_GRID,
+ IntersectionRealizationGridProvider,
+ {
+ transformToVisualization: makeIntersectionLayer,
+ transformToAnnotations: makeColorScaleAnnotation,
+ },
+);
+VISUALIZATION_ASSEMBLER.registerDataProviderTransformers(DataProviderType.REALIZATION_GRID, RealizationGridProvider, {
+ transformToVisualization: makeRealizationGridLayer,
+ transformToAnnotations: makeColorScaleAnnotation,
+});
+VISUALIZATION_ASSEMBLER.registerDataProviderTransformers(
+ DataProviderType.DRILLED_WELLBORE_PICKS,
+ DrilledWellborePicksProvider,
+ {
+ transformToVisualization: makeDrilledWellborePicksLayer,
+ },
+);
+VISUALIZATION_ASSEMBLER.registerDataProviderTransformers(
+ DataProviderType.DRILLED_WELL_TRAJECTORIES,
+ DrilledWellTrajectoriesProvider,
+ {
+ transformToVisualization: makeDrilledWellTrajectoriesLayer,
+ },
+);
+VISUALIZATION_ASSEMBLER.registerDataProviderTransformers(
+ DataProviderType.REALIZATION_SEISMIC_DEPTH_SLICE,
+ RealizationSeismicDepthSliceProvider,
+ {
+ transformToVisualization: makeSeismicFenceMeshLayerFunction(Plane.DEPTH),
+ },
+);
+VISUALIZATION_ASSEMBLER.registerDataProviderTransformers(
+ DataProviderType.REALIZATION_SEISMIC_INLINE,
+ RealizationSeismicInlineProvider,
+ {
+ transformToVisualization: makeSeismicFenceMeshLayerFunction(Plane.INLINE),
+ },
+);
+VISUALIZATION_ASSEMBLER.registerDataProviderTransformers(
+ DataProviderType.REALIZATION_SEISMIC_CROSSLINE,
+ RealizationSeismicCrosslineProvider,
+ {
+ transformToVisualization: makeSeismicFenceMeshLayerFunction(Plane.CROSSLINE),
+ },
+);
+
+export type LayersWrapperProps = {
+ fieldIdentifier: string | null;
+ layerManager: DataProviderManager;
+ preferredViewLayout: PreferredViewLayout;
+ viewContext: ViewContext;
+ workbenchSession: WorkbenchSession;
+ workbenchSettings: WorkbenchSettings;
+};
+
+export function LayersWrapper(props: LayersWrapperProps): React.ReactNode {
+ const [prevBoundingBox, setPrevBoundingBox] = React.useState(null);
+
+ const mainDivRef = React.useRef(null);
+ const mainDivSize = useElementSize(mainDivRef);
+ const statusWriter = useViewStatusWriter(props.viewContext);
+
+ usePublishSubscribeTopicValue(props.layerManager, DataProviderManagerTopic.DATA_REVISION);
+
+ const viewports: ViewportType[] = [];
+ const deckGlLayers: Layer[] = [];
+ const viewportAnnotations: React.ReactNode[] = [];
+ const globalLayerIds: string[] = ["placeholder"];
+
+ let numLoadingLayers = 0;
+
+ const assemblerProduct = VISUALIZATION_ASSEMBLER.make(props.layerManager);
+
+ const globalAnnotations = assemblerProduct.annotations;
+
+ const numViews = assemblerProduct.children.filter(
+ (item) => item.itemType === VisualizationItemType.GROUP && item.groupType === GroupType.VIEW,
+ ).length;
+
+ let numCols = Math.ceil(Math.sqrt(numViews));
+ let numRows = Math.ceil(numViews / numCols);
+
+ for (const item of assemblerProduct.children) {
+ if (item.itemType === VisualizationItemType.GROUP && item.groupType === GroupType.VIEW) {
+ const layerIds: string[] = [];
+ for (const child of item.children) {
+ if (child.itemType === VisualizationItemType.DATA_PROVIDER_VISUALIZATION) {
+ const layer = child.visualization;
+ layerIds.push(layer.id);
+ deckGlLayers.push(layer);
+ }
+ }
+ viewports.push({
+ id: item.id,
+ name: item.name,
+ isSync: true,
+ show3D: true,
+ layerIds,
+ });
+
+ viewportAnnotations.push(
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ /* @ts-expect-error */
+
+ "colorScale" in el), ...globalAnnotations]}
+ height={((mainDivSize.height / 3) * 2) / numCols - 20}
+ position="left"
+ />
+
+ ,
+ );
+ } else if (item.itemType === VisualizationItemType.DATA_PROVIDER_VISUALIZATION) {
+ deckGlLayers.push(item.visualization);
+ globalLayerIds.push(item.visualization.id);
+ }
+ }
+
+ if (props.preferredViewLayout === PreferredViewLayout.HORIZONTAL) {
+ [numCols, numRows] = [numRows, numCols];
+ }
+
+ if (assemblerProduct.combinedBoundingBox !== null) {
+ if (prevBoundingBox !== null) {
+ if (!bbox.outerBoxcontainsInnerBox(prevBoundingBox, assemblerProduct.combinedBoundingBox)) {
+ setPrevBoundingBox(assemblerProduct.combinedBoundingBox);
+ }
+ } else {
+ setPrevBoundingBox(assemblerProduct.combinedBoundingBox);
+ }
+ }
+
+ numLoadingLayers = assemblerProduct.numLoadingDataProviders;
+ statusWriter.setLoading(assemblerProduct.numLoadingDataProviders > 0);
+
+ for (const message of assemblerProduct.aggregatedErrorMessages) {
+ statusWriter.addError(message);
+ }
+
+ deckGlLayers.push(new PlaceholderLayer({ id: "placeholder" }));
+ deckGlLayers.push(
+ new AxesLayer({
+ id: "axes-layer",
+ visible: true,
+ ZIncreasingDownwards: true,
+ }),
+ );
+
+ deckGlLayers.reverse();
+
+ return (
+
+
+ ({
+ ...viewport,
+ layerIds: [...(viewport.layerIds ?? []), ...globalLayerIds],
+ })),
+ showLabel: false,
+ }}
+ viewportAnnotations={viewportAnnotations}
+ layers={deckGlLayers}
+ workbenchSession={props.workbenchSession}
+ workbenchSettings={props.workbenchSettings}
+ />
+
+
+ );
+}
diff --git a/frontend/src/modules/3DViewerNew/view/components/ReadoutBoxWrapper.tsx b/frontend/src/modules/3DViewerNew/view/components/ReadoutBoxWrapper.tsx
new file mode 100644
index 000000000..86734e4bf
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/view/components/ReadoutBoxWrapper.tsx
@@ -0,0 +1,126 @@
+import React from "react";
+
+import { ReadoutBox, ReadoutItem } from "@modules/_shared/components/ReadoutBox";
+import { ExtendedLayerProps, LayerPickInfo } from "@webviz/subsurface-viewer";
+
+import { isEqual } from "lodash";
+
+// Needs extra distance for the left side; this avoids overlapping with legend elements
+const READOUT_EDGE_DISTANCE_REM = { left: 6 };
+
+function makePositionReadout(layerPickInfo: LayerPickInfo): ReadoutItem | null {
+ if (layerPickInfo.coordinate === undefined || layerPickInfo.coordinate.length < 2) {
+ return null;
+ }
+ const readout = {
+ label: "Position",
+ info: [
+ {
+ name: "x",
+ value: layerPickInfo.coordinate[0],
+ unit: "m",
+ },
+ {
+ name: "y",
+ value: layerPickInfo.coordinate[1],
+ unit: "m",
+ },
+ ],
+ };
+ if (layerPickInfo.coordinate.length > 2) {
+ readout.info.push({
+ name: "z",
+ value: layerPickInfo.coordinate[2],
+ unit: "m",
+ });
+ }
+
+ return readout;
+}
+
+export type ReadoutBoxWrapperProps = {
+ layerPickInfo: LayerPickInfo[];
+ maxNumItems?: number;
+ visible?: boolean;
+};
+
+export function ReadoutBoxWrapper(props: ReadoutBoxWrapperProps): React.ReactNode {
+ const [infoData, setInfoData] = React.useState([]);
+ const [prevLayerPickInfo, setPrevLayerPickInfo] = React.useState([]);
+
+ if (!isEqual(props.layerPickInfo, prevLayerPickInfo)) {
+ setPrevLayerPickInfo(props.layerPickInfo);
+ const newReadoutItems: ReadoutItem[] = [];
+
+ if (props.layerPickInfo.length === 0) {
+ setInfoData([]);
+ return;
+ }
+
+ const positionReadout = makePositionReadout(props.layerPickInfo[0]);
+ if (!positionReadout) {
+ return;
+ }
+ newReadoutItems.push(positionReadout);
+
+ for (const layerPickInfo of props.layerPickInfo) {
+ const layerName = (layerPickInfo.layer?.props as unknown as ExtendedLayerProps)?.name;
+ const layerProps = layerPickInfo.properties;
+
+ // pick info can have 2 types of properties that can be displayed on the info card
+ // 1. defined as propertyValue, used for general layer info (now using for positional data)
+ // 2. Another defined as array of property object described by type PropertyDataType
+
+ const layerReadout = newReadoutItems.find((item) => item.label === layerName);
+
+ // collecting card data for 1st type
+ const zValue = (layerPickInfo as LayerPickInfo).propertyValue;
+ if (zValue !== undefined) {
+ if (layerReadout) {
+ layerReadout.info.push({
+ name: "Property value",
+ value: zValue,
+ });
+ } else {
+ newReadoutItems.push({
+ label: layerName ?? "Unknown layer",
+ info: [
+ {
+ name: "Property value",
+ value: zValue,
+ },
+ ],
+ });
+ }
+ }
+
+ // collecting card data for 2nd type
+ if (!layerProps || layerProps.length === 0) {
+ continue;
+ }
+ if (layerReadout) {
+ layerProps?.forEach((prop) => {
+ const property = layerReadout.info?.find((item) => item.name === prop.name);
+ if (property) {
+ property.value = prop.value;
+ } else {
+ layerReadout.info.push(prop);
+ }
+ });
+ } else {
+ newReadoutItems.push({
+ label: layerName ?? "Unknown layer",
+ info: layerProps,
+ });
+ }
+ }
+
+ setInfoData(newReadoutItems);
+ }
+
+ if (!props.visible) {
+ return null;
+ }
+
+ return ;
+}
diff --git a/frontend/src/modules/3DViewerNew/view/components/ReadoutWrapper.tsx b/frontend/src/modules/3DViewerNew/view/components/ReadoutWrapper.tsx
new file mode 100644
index 000000000..418f6cecf
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/view/components/ReadoutWrapper.tsx
@@ -0,0 +1,78 @@
+import React from "react";
+
+import { Layer as DeckGlLayer } from "@deck.gl/core";
+import { DeckGLRef } from "@deck.gl/react";
+import { WorkbenchSession } from "@framework/WorkbenchSession";
+import { WorkbenchSettings } from "@framework/WorkbenchSettings";
+import { SubsurfaceViewerWithCameraState } from "@modules/_shared/components/SubsurfaceViewerWithCameraState";
+import { BoundingBox3D, LayerPickInfo, MapMouseEvent, ViewsType } from "@webviz/subsurface-viewer";
+
+import { ReadoutBoxWrapper } from "./ReadoutBoxWrapper";
+
+import { DeckGlInstanceManager } from "../utils/DeckGlInstanceManager";
+
+export type ReadoutWrapperProps = {
+ views: ViewsType;
+ viewportAnnotations: React.ReactNode[];
+ layers: DeckGlLayer[];
+ bounds?: BoundingBox3D;
+ workbenchSession: WorkbenchSession;
+ workbenchSettings: WorkbenchSettings;
+ deckGlManager: DeckGlInstanceManager;
+ verticalScale: number;
+ triggerHome: number;
+ deckGlRef: React.RefObject;
+};
+
+export function ReadoutWrapper(props: ReadoutWrapperProps): React.ReactNode {
+ const id = React.useId();
+ const deckGlRef = React.useRef(null);
+
+ React.useImperativeHandle(props.deckGlRef, () => deckGlRef.current);
+
+ const [layerPickingInfo, setLayerPickingInfo] = React.useState([]);
+
+ function handleMouseEvent(event: MapMouseEvent) {
+ const pickingInfo = event.infos;
+ setLayerPickingInfo(pickingInfo);
+ }
+
+ return (
+ <>
+
+
+ {props.viewportAnnotations}
+
+ {props.views.viewports.length === 0 && (
+
+ Please add views and layers in the settings panel.
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/modules/3DViewerNew/view/components/Toolbar.tsx b/frontend/src/modules/3DViewerNew/view/components/Toolbar.tsx
new file mode 100644
index 000000000..1b0278b7b
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/view/components/Toolbar.tsx
@@ -0,0 +1,185 @@
+import React from "react";
+
+import { Button } from "@lib/components/Button";
+import { HoldPressedIntervalCallbackButton } from "@lib/components/HoldPressedIntervalCallbackButton/holdPressedIntervalCallbackButton";
+import { Input } from "@lib/components/Input";
+import { ToggleButton } from "@lib/components/ToggleButton";
+import { AddPathPointIcon, DrawPathIcon, RemovePathPointIcon } from "@lib/icons/";
+import { resolveClassNames } from "@lib/utils/resolveClassNames";
+import { Toolbar as GenericToolbar, ToolBarDivider } from "@modules/_shared/components/Toolbar";
+import { usePublishSubscribeTopicValue } from "@modules/_shared/utils/PublishSubscribeDelegate";
+import {
+ Add,
+ FilterCenterFocus,
+ GridOff,
+ GridOn,
+ KeyboardDoubleArrowLeft,
+ KeyboardDoubleArrowRight,
+ Polyline,
+ Remove,
+} from "@mui/icons-material";
+
+import { PolylineEditingMode } from "../hooks/editablePolylines/types";
+import { PolylinesPlugin, PolylinesPluginTopic } from "../utils/PolylinesPlugin";
+
+export type ToolbarProps = {
+ verticalScale: number;
+ hasActivePolyline: boolean;
+ activePolylineName?: string;
+ onFitInView: () => void;
+ polylinesPlugin: PolylinesPlugin;
+ onGridVisibilityChange: (visible: boolean) => void;
+ onVerticalScaleChange(value: number): void;
+ onPolylineNameChange(name: string): void;
+};
+
+export function Toolbar(props: ToolbarProps): React.ReactNode {
+ const [expanded, setExpanded] = React.useState(false);
+ const [gridVisible, setGridVisible] = React.useState(false);
+ const [polylineName, setPolylineName] = React.useState(null);
+ const [prevEditingPolylineId, setPrevEditingPolylineId] = React.useState(null);
+ const polylineEditingMode = usePublishSubscribeTopicValue(props.polylinesPlugin, PolylinesPluginTopic.EDITING_MODE);
+ const editingPolylineId = usePublishSubscribeTopicValue(
+ props.polylinesPlugin,
+ PolylinesPluginTopic.EDITING_POLYLINE_ID
+ );
+
+ if (editingPolylineId !== prevEditingPolylineId) {
+ setPrevEditingPolylineId(editingPolylineId);
+ const activePolyline = props.polylinesPlugin.getActivePolyline();
+ if (activePolyline) {
+ setPolylineName(activePolyline.name);
+ }
+ }
+
+ function handleFitInViewClick() {
+ props.onFitInView();
+ }
+
+ function handleGridToggle() {
+ props.onGridVisibilityChange(!gridVisible);
+ setGridVisible(!gridVisible);
+ }
+
+ function handleVerticalScaleIncrease() {
+ props.onVerticalScaleChange(props.verticalScale + 0.1);
+ }
+
+ function handleVerticalScaleDecrease() {
+ props.onVerticalScaleChange(props.verticalScale - 0.1);
+ }
+
+ function handleTogglePolylineEditing() {
+ if (polylineEditingMode !== PolylineEditingMode.NONE) {
+ props.polylinesPlugin.setEditingMode(PolylineEditingMode.NONE);
+ return;
+ }
+ props.polylinesPlugin.setEditingMode(PolylineEditingMode.IDLE);
+ }
+
+ function handlePolylineEditingModeChange(mode: PolylineEditingMode) {
+ props.polylinesPlugin.setEditingMode(mode);
+ }
+
+ function handlePolylineNameChange(event: React.ChangeEvent) {
+ props.polylinesPlugin.setActivePolylineName(event.target.value);
+ }
+
+ return (
+
+
+
+
+
+
+ {gridVisible ? : }
+
+
+
+
+
+
+
+
+
+
+ {props.verticalScale.toFixed(2)}
+
+
+
+
+
+
+
+
+ {polylineEditingMode !== PolylineEditingMode.NONE && expanded && (
+ <>
+
+
+ handlePolylineEditingModeChange(
+ active ? PolylineEditingMode.DRAW : PolylineEditingMode.IDLE
+ )
+ }
+ >
+
+
+
+ handlePolylineEditingModeChange(
+ active ? PolylineEditingMode.ADD_POINT : PolylineEditingMode.IDLE
+ )
+ }
+ >
+
+
+
+ handlePolylineEditingModeChange(
+ active ? PolylineEditingMode.REMOVE_POINT : PolylineEditingMode.IDLE
+ )
+ }
+ >
+
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/src/modules/3DViewerNew/view/hooks/editablePolylines/deckGlLayers/AnimatedPathLayer.ts b/frontend/src/modules/3DViewerNew/view/hooks/editablePolylines/deckGlLayers/AnimatedPathLayer.ts
new file mode 100644
index 000000000..53dc19ab1
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/view/hooks/editablePolylines/deckGlLayers/AnimatedPathLayer.ts
@@ -0,0 +1,51 @@
+import { UpdateParameters } from "@deck.gl/core";
+import { PathLayer } from "@deck.gl/layers";
+
+export class AnimatedPathLayer extends PathLayer {
+ static layerName = "AnimatedPathLayer";
+
+ private _dashStart: number = 0;
+ private _requestId: ReturnType | null = null;
+
+ initializeState() {
+ super.initializeState();
+ this.animate();
+ }
+
+ updateState(params: UpdateParameters): void {
+ super.updateState(params);
+ if (this._requestId) {
+ cancelAnimationFrame(this._requestId);
+ }
+ this.animate();
+ }
+
+ private animate() {
+ this._dashStart = (Date.now() / 50) % 1000;
+
+ this.setNeedsRedraw();
+ this._requestId = requestAnimationFrame(() => this.animate());
+ }
+
+ getShaders() {
+ const shaders = super.getShaders();
+ return {
+ ...shaders,
+ inject: {
+ ...shaders.inject,
+ "vs:#decl":
+ shaders.inject["vs:#decl"] +
+ `\
+ uniform float dashStart;`,
+ "vs:#main-end":
+ shaders.inject["vs:#main-end"] +
+ `\
+ vDashOffset += dashStart;`,
+ },
+ };
+ }
+
+ draw({ uniforms }: Record) {
+ super.draw({ uniforms: { ...uniforms, dashStart: this._dashStart } });
+ }
+}
diff --git a/frontend/src/modules/3DViewerNew/view/hooks/editablePolylines/deckGlLayers/EditablePolylineLayer.ts b/frontend/src/modules/3DViewerNew/view/hooks/editablePolylines/deckGlLayers/EditablePolylineLayer.ts
new file mode 100644
index 000000000..0ab850d83
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/view/hooks/editablePolylines/deckGlLayers/EditablePolylineLayer.ts
@@ -0,0 +1,294 @@
+import { CompositeLayer, GetPickingInfoParams, Layer, PickingInfo } from "@deck.gl/core";
+import { PathStyleExtension } from "@deck.gl/extensions";
+import { LineLayer, PathLayer, ScatterplotLayer } from "@deck.gl/layers";
+import { Polyline } from "@modules/3DViewerNew/view/utils/PolylinesPlugin";
+
+import { AnimatedPathLayer } from "./AnimatedPathLayer";
+
+export enum AllowHoveringOf {
+ NONE = "none",
+ LINES = "line",
+ POINTS = "point",
+ LINES_AND_POINTS = "lines-and-points",
+}
+
+export type EditablePolylineLayerProps = {
+ id: string;
+ polyline: Polyline;
+ mouseHoverPoint?: number[];
+ referencePathPointIndex?: number;
+ allowHoveringOf: AllowHoveringOf;
+};
+
+export type EditablePolylineLayerPickingInfo = PickingInfo & {
+ editableEntity?: {
+ type: "line" | "point";
+ index: number;
+ };
+};
+
+export function isEditablePolylineLayerPickingInfo(info: PickingInfo): info is EditablePolylineLayerPickingInfo {
+ return (
+ Object.keys(info).includes("editableEntity") &&
+ ((info as EditablePolylineLayerPickingInfo).editableEntity?.type === "line" ||
+ (info as EditablePolylineLayerPickingInfo).editableEntity?.type === "point")
+ );
+}
+
+export class EditablePolylineLayer extends CompositeLayer {
+ static layerName: string = "EditablePolylineLayer";
+
+ // @ts-expect-error - deck.gl types are wrong
+ state!: {
+ hoveredEntity: {
+ layer: "line" | "point";
+ index: number;
+ } | null;
+ dashStart: number;
+ };
+
+ initializeState(): void {
+ this.state = {
+ hoveredEntity: null,
+ dashStart: 0,
+ };
+ }
+
+ getPickingInfo({ info }: GetPickingInfoParams): EditablePolylineLayerPickingInfo {
+ if (info && info.sourceLayer && info.index !== undefined && info.index !== -1) {
+ let layer: "line" | "point" | null = null;
+ if (info.sourceLayer.id.includes("lines-selection")) {
+ layer = "line";
+ } else if (info.sourceLayer.id.includes("points")) {
+ layer = "point";
+ }
+ return {
+ ...info,
+ editableEntity: layer
+ ? {
+ type: layer,
+ index: info.index,
+ }
+ : undefined,
+ };
+ }
+
+ return info;
+ }
+
+ onHover(info: EditablePolylineLayerPickingInfo): boolean {
+ if (!info.editableEntity || this.props.allowHoveringOf === AllowHoveringOf.NONE) {
+ this.setState({
+ hoveredEntity: null,
+ });
+ return false;
+ }
+
+ if (this.props.allowHoveringOf === AllowHoveringOf.LINES && info.editableEntity.type === "point") {
+ return false;
+ }
+
+ if (this.props.allowHoveringOf === AllowHoveringOf.POINTS && info.editableEntity.type === "line") {
+ return false;
+ }
+
+ this.setState({
+ hoveredEntity: {
+ layer: info.editableEntity.type,
+ index: info.index,
+ },
+ });
+
+ return false;
+ }
+
+ renderLayers() {
+ const { polyline, mouseHoverPoint, referencePathPointIndex } = this.props;
+
+ const layers: Layer[] = [];
+
+ if (referencePathPointIndex !== undefined && mouseHoverPoint && this.state.hoveredEntity === null) {
+ layers.push(
+ new LineLayer({
+ id: "line",
+ data: [{ from: polyline.path[referencePathPointIndex], to: mouseHoverPoint }],
+ getSourcePosition: (d) => d.from,
+ getTargetPosition: (d) => d.to,
+ getColor: [polyline.color[0], polyline.color[1], polyline.color[2], 100],
+ getWidth: 10,
+ widthUnits: "meters",
+ widthMinPixels: 3,
+ parameters: {
+ depthTest: false,
+ },
+ }),
+ new ScatterplotLayer({
+ id: "hover-point",
+ data: [mouseHoverPoint],
+ getPosition: (d) => d,
+ getFillColor: [polyline.color[0], polyline.color[1], polyline.color[2], 100],
+ getRadius: 10,
+ radiusUnits: "pixels",
+ radiusMinPixels: 5,
+ radiusMaxPixels: 10,
+ pickable: false,
+ parameters: {
+ depthTest: false,
+ },
+ })
+ );
+ }
+
+ const polylinePathLayerData: number[][][] = [];
+ for (let i = 0; i < polyline.path.length - 1; i++) {
+ polylinePathLayerData.push([polyline.path[i], polyline.path[i + 1]]);
+ }
+
+ if (this.state.hoveredEntity && this.state.hoveredEntity.layer === "line") {
+ const hoveredLine = polylinePathLayerData[this.state.hoveredEntity.index];
+ layers.push(
+ new PathLayer({
+ id: "hovered-line",
+ data: [hoveredLine],
+ getPath: (d) => d,
+ getColor: [255, 255, 255, 50],
+ getWidth: 20,
+ widthUnits: "meters",
+ widthMinPixels: 6,
+ parameters: {
+ depthTest: false,
+ },
+ pickable: false,
+ })
+ );
+ }
+
+ layers.push(
+ new AnimatedPathLayer({
+ id: "lines",
+ data: polylinePathLayerData,
+ dashStart: 0,
+ getColor: polyline.color,
+ getPath: (d) => d,
+ getDashArray: [10, 10],
+ getWidth: 10,
+ billboard: true,
+ widthUnits: "meters",
+ widthMinPixels: 3,
+ widthMaxPixels: 10,
+ extensions: [new PathStyleExtension({ highPrecisionDash: true })],
+ parameters: {
+ // @ts-expect-error - deck.gl types are wrong
+ depthTest: false,
+ },
+ pickable: false,
+ depthTest: false,
+ }),
+ new PathLayer({
+ id: "lines-selection",
+ data: polylinePathLayerData,
+ getColor: [0, 0, 0, 0],
+ getPath: (d) => d,
+ getWidth: 50,
+ widthMinPixels: 10,
+ widthMaxPixels: 20,
+ billboard: false,
+ widthUnits: "meters",
+ parameters: {
+ depthTest: false,
+ },
+ pickable: true,
+ })
+ );
+ /*
+ layers.push(
+ new ScatterplotLayer({
+ id: "points-selection",
+ data: polyline.path,
+ getPosition: (d) => d,
+ getRadius: (d, context) => {
+ if (
+ this.state.hoveredEntity?.layer === "point" &&
+ context.index === this.state.hoveredEntity.index
+ ) {
+ return 10;
+ }
+ return 5;
+ },
+ getFillColor: [255, 255, 255, 1],
+ getLineColor: [0, 0, 0, 0],
+ getLineWidth: 30,
+ lineWidthMinPixels: 10,
+ radiusUnits: "pixels",
+ pickable: true,
+ parameters: {
+ depthTest: false,
+ },
+ updateTriggers: {
+ getRadius: [this.state.hoveredEntity, referencePathPointIndex],
+ },
+ })
+ );
+ */
+
+ layers.push(
+ new ScatterplotLayer({
+ id: "points",
+ data: polyline.path,
+ getPosition: (d) => d,
+ getFillColor: (_, context) => {
+ if (context.index === referencePathPointIndex) {
+ return [255, 255, 255, 255];
+ }
+ return polyline.color;
+ },
+ getLineColor: (_, context) => {
+ if (
+ this.state.hoveredEntity &&
+ this.state.hoveredEntity.layer === "point" &&
+ context.index === this.state.hoveredEntity.index
+ ) {
+ return [255, 255, 255, 255];
+ }
+ return [0, 0, 0, 0];
+ },
+ getLineWidth: (_, context) => {
+ if (
+ this.state.hoveredEntity &&
+ this.state.hoveredEntity.layer === "point" &&
+ context.index === this.state.hoveredEntity.index
+ ) {
+ return 5;
+ }
+ return 0;
+ },
+ getRadius: (_, context) => {
+ if (
+ this.state.hoveredEntity?.layer === "point" &&
+ context.index === this.state.hoveredEntity.index
+ ) {
+ return 12;
+ }
+ return 10;
+ },
+ stroked: true,
+ radiusUnits: "pixels",
+ lineWidthUnits: "meters",
+ lineWidthMinPixels: 3,
+ radiusMinPixels: 5,
+ pickable: true,
+ parameters: {
+ depthTest: false,
+ },
+ updateTriggers: {
+ getLineWidth: [this.state.hoveredEntity, referencePathPointIndex],
+ getLineColor: [this.state.hoveredEntity],
+ getFillColor: [referencePathPointIndex],
+ getRadius: [this.state.hoveredEntity, referencePathPointIndex],
+ },
+ })
+ );
+
+ return layers;
+ }
+}
diff --git a/frontend/src/modules/3DViewerNew/view/hooks/editablePolylines/deckGlLayers/HoverPointLayer.ts b/frontend/src/modules/3DViewerNew/view/hooks/editablePolylines/deckGlLayers/HoverPointLayer.ts
new file mode 100644
index 000000000..8bc04b19c
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/view/hooks/editablePolylines/deckGlLayers/HoverPointLayer.ts
@@ -0,0 +1,33 @@
+import { CompositeLayer, Layer, LayersList } from "@deck.gl/core";
+import { ColumnLayer } from "@deck.gl/layers";
+
+export type HoverPointLayerProps = {
+ point: number[] | null;
+ color: [number, number, number, number];
+};
+
+export class HoverPointLayer extends CompositeLayer {
+ static layerName: string = "HoverPointLayer";
+
+ renderLayers(): Layer | null | LayersList {
+ if (!this.props.point) {
+ return null;
+ }
+
+ return new ColumnLayer({
+ id: "hover-point",
+ data: [this.props.point],
+ diskResolution: 20,
+ getElevation: 1,
+ radiusUnits: "pixels",
+ radius: 20,
+ extruded: false,
+ pickable: false,
+ getPosition: (d) => d,
+ getFillColor: this.props.color,
+ parameters: {
+ depthTest: false,
+ },
+ });
+ }
+}
diff --git a/frontend/src/modules/3DViewerNew/view/hooks/editablePolylines/deckGlLayers/PolylinesLayer.ts b/frontend/src/modules/3DViewerNew/view/hooks/editablePolylines/deckGlLayers/PolylinesLayer.ts
new file mode 100644
index 000000000..1aa0d48a8
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/view/hooks/editablePolylines/deckGlLayers/PolylinesLayer.ts
@@ -0,0 +1,185 @@
+import { CompositeLayer, FilterContext, GetPickingInfoParams, Layer, PickingInfo } from "@deck.gl/core";
+import { PathLayer, TextLayer } from "@deck.gl/layers";
+import { Polyline } from "@modules/3DViewerNew/view/utils/PolylinesPlugin";
+
+export type PolylinesLayerProps = {
+ id: string;
+ polylines: Polyline[];
+ selectedPolylineId?: string;
+ hoverable?: boolean;
+};
+
+export type PolylinesLayerPickingInfo = PickingInfo & {
+ polylineId?: string;
+};
+
+export function isPolylinesLayerPickingInfo(info: PickingInfo): info is PolylinesLayerPickingInfo {
+ return Object.keys(info).includes("polylineId");
+}
+
+export class PolylinesLayer extends CompositeLayer {
+ static layerName: string = "PolylinesLayer";
+
+ // @ts-expect-error - deck.gl types are wrong
+ state!: {
+ hoveredPolylineIndex: number | null;
+ };
+
+ onHover(info: PickingInfo): boolean {
+ if (!this.props.hoverable) {
+ return false;
+ }
+ if (info.index === undefined) {
+ return false;
+ }
+
+ const hoveredPolylineIndex = info.index;
+ this.setState({ hoveredPolylineIndex });
+
+ return false;
+ }
+
+ getPickingInfo({ info }: GetPickingInfoParams): PolylinesLayerPickingInfo {
+ if (info && info.sourceLayer && info.object !== undefined) {
+ return {
+ ...info,
+ polylineId: info.object.id,
+ };
+ }
+
+ return info;
+ }
+
+ filterSubLayer(context: FilterContext): boolean {
+ if (context.layer.id.includes("labels")) {
+ return context.viewport.zoom > -4;
+ }
+
+ return true;
+ }
+
+ renderLayers(): Layer[] {
+ const { hoveredPolylineIndex } = this.state;
+
+ const layers: Layer[] = [];
+
+ if (this.props.selectedPolylineId) {
+ const selectedPolylineIndex = this.props.polylines.findIndex((p) => p.id === this.props.selectedPolylineId);
+ if (selectedPolylineIndex !== -1) {
+ layers.push(
+ new PathLayer({
+ id: `selected`,
+ data: [this.props.polylines[selectedPolylineIndex]],
+ getPath: (d) => d.path,
+ getColor: (d: Polyline) => [d.color[0], d.color[1], d.color[2], 200],
+ getWidth: 30,
+ widthUnits: "meters",
+ widthMinPixels: 5,
+ parameters: {
+ depthTest: false,
+ },
+ billboard: true,
+ })
+ );
+ }
+ }
+
+ if (hoveredPolylineIndex !== null && this.props.polylines[hoveredPolylineIndex] && this.props.hoverable) {
+ layers.push(
+ new PathLayer({
+ id: `hovered`,
+ data: [this.props.polylines[hoveredPolylineIndex]],
+ getPath: (d) => d.path,
+ getColor: (d: Polyline) => [d.color[0], d.color[1], d.color[2], 100],
+ getWidth: 30,
+ widthUnits: "meters",
+ widthMinPixels: 6,
+ parameters: {
+ depthTest: false,
+ },
+ billboard: true,
+ })
+ );
+ }
+
+ const polylineLabels: { label: string; position: number[]; angle: number; color: number[] }[] = [];
+ for (const polyline of this.props.polylines) {
+ const vector = [
+ polyline.path[1][0] - polyline.path[0][0],
+ polyline.path[1][1] - polyline.path[0][1],
+ polyline.path[1][2] - polyline.path[0][2],
+ ];
+ const length = Math.sqrt(vector[0] ** 2 + vector[1] ** 2);
+ const unitVector = [vector[0] / length, vector[1] / length, vector[2] / length];
+ let angle = Math.atan2(unitVector[1], unitVector[0]) * (180 / Math.PI);
+ if (angle > 90 || angle < -90) {
+ angle += 180;
+ }
+ polylineLabels.push({
+ label: polyline.name,
+ position: [
+ polyline.path[0][0] + (unitVector[0] * length) / 2,
+ polyline.path[0][1] + (unitVector[1] * length) / 2,
+ polyline.path[0][2] + (unitVector[2] * length) / 2,
+ ],
+ angle,
+ color: polyline.color,
+ });
+ }
+
+ layers.push(
+ new PathLayer({
+ id: `polylines`,
+ data: this.props.polylines,
+ getPath: (d) => d.path,
+ getColor: (d: Polyline) => d.color,
+ getWidth: 10,
+ widthUnits: "meters",
+ widthMinPixels: 3,
+ widthMaxPixels: 10,
+ pickable: false,
+ parameters: {
+ depthTest: false,
+ },
+ billboard: true,
+ }),
+ new TextLayer({
+ id: `polylines-labels`,
+ data: polylineLabels,
+ getPosition: (d) => d.position,
+ getText: (d) => d.label,
+ getSize: 12,
+ sizeUnits: "meters",
+ sizeMinPixels: 16,
+ getAngle: (d) => d.angle,
+ getColor: [0, 0, 0],
+ parameters: {
+ depthTest: false,
+ },
+ billboard: false,
+ getBackgroundColor: [255, 255, 255, 100],
+ getBackgroundPadding: [10, 10],
+ getBackgroundBorderColor: [0, 0, 0, 255],
+ getBackgroundBorderWidth: 2,
+ getBackgroundElevation: 1,
+ background: true,
+ }),
+ new PathLayer({
+ id: `polylines-hoverable`,
+ data: this.props.polylines,
+ getPath: (d) => d.path,
+ getColor: (d: Polyline) => [d.color[0], d.color[1], d.color[2], 1],
+ getWidth: 50,
+ widthUnits: "meters",
+ widthMinPixels: 10,
+ widthMaxPixels: 20,
+ pickable: true,
+ parameters: {
+ depthTest: false,
+ },
+ })
+ );
+
+ return layers;
+ }
+}
diff --git a/frontend/src/modules/3DViewerNew/view/hooks/editablePolylines/types.ts b/frontend/src/modules/3DViewerNew/view/hooks/editablePolylines/types.ts
new file mode 100644
index 000000000..58f16acde
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/view/hooks/editablePolylines/types.ts
@@ -0,0 +1,15 @@
+import React from "react";
+
+export enum PolylineEditingMode {
+ DRAW = "draw",
+ ADD_POINT = "add_point",
+ REMOVE_POINT = "remove_point",
+ NONE = "none",
+ IDLE = "idle",
+}
+
+export type ContextMenuItem = {
+ icon?: React.ReactNode;
+ label: string;
+ onClick: () => void;
+};
diff --git a/frontend/src/modules/3DViewerNew/view/utils/DeckGlInstanceManager.ts b/frontend/src/modules/3DViewerNew/view/utils/DeckGlInstanceManager.ts
new file mode 100644
index 000000000..560794f61
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/view/utils/DeckGlInstanceManager.ts
@@ -0,0 +1,321 @@
+/*
+This manager is responsible for managing plugins for DeckGL, forwarding events to them, and adding/adjusting layers based on the plugins' responses.
+*/
+import React from "react";
+
+import { Layer, PickingInfo } from "@deck.gl/core";
+import { DeckGLProps, DeckGLRef } from "@deck.gl/react";
+import { SubsurfaceViewerWithCameraStateProps } from "@modules/_shared/components/SubsurfaceViewerWithCameraState";
+import { PublishSubscribe, PublishSubscribeDelegate } from "@modules_shared/utils/PublishSubscribeDelegate";
+import { MapMouseEvent } from "@webviz/subsurface-viewer";
+
+export type ContextMenuItem = {
+ icon?: React.ReactElement;
+ label: string;
+ onClick: () => void;
+};
+
+export type ContextMenu = {
+ position: { x: number; y: number };
+ items: ContextMenuItem[];
+};
+
+export class DeckGlPlugin {
+ private _manager: DeckGlInstanceManager;
+
+ constructor(manager: DeckGlInstanceManager) {
+ this._manager = manager;
+ }
+
+ protected requireRedraw() {
+ this._manager.redraw();
+ }
+
+ protected requestDisablePanning() {
+ this._manager.disablePanning();
+ }
+
+ protected requestEnablePanning() {
+ this._manager.enablePanning();
+ }
+
+ protected getFirstLayerUnderCursorInfo(x: number, y: number): PickingInfo | undefined {
+ return this._manager.pickFirstLayerUnderCursorInfo(x, y);
+ }
+
+ handleDrag?(pickingInfo: PickingInfo): void;
+ handleLayerHover?(pickingInfo: PickingInfo): void;
+ handleLayerClick?(pickingInfo: PickingInfo): void;
+ handleClickAway?(): void;
+ handleGlobalMouseHover?(pickingInfo: PickingInfo): void;
+ handleGlobalMouseClick?(pickingInfo: PickingInfo): boolean;
+ handleKeyUpEvent?(key: string): void;
+ handleKeyDownEvent?(key: string): void;
+ getCursor?(pickingInfo: PickingInfo): string | null;
+ getLayers?(): Layer[];
+ getContextMenuItems?(pickingInfo: PickingInfo): ContextMenuItem[];
+}
+
+export enum DeckGlInstanceManagerTopic {
+ REDRAW = "REDRAW",
+ CONTEXT_MENU = "CONTEXT_MENU",
+}
+
+export type DeckGlInstanceManagerPayloads = {
+ [DeckGlInstanceManagerTopic.REDRAW]: number;
+ [DeckGlInstanceManagerTopic.CONTEXT_MENU]: ContextMenu | null;
+};
+
+type HoverPoint = {
+ worldCoordinates: number[];
+ screenCoordinates: [number, number];
+};
+
+type KeyboardEventListener = (event: KeyboardEvent) => void;
+
+export class DeckGlInstanceManager implements PublishSubscribe {
+ private _publishSubscribeDelegate = new PublishSubscribeDelegate();
+
+ private _ref: DeckGLRef | null;
+ private _hoverPoint: HoverPoint | null = null;
+ private _plugins: DeckGlPlugin[] = [];
+ private _layersIdPluginMap = new Map();
+ private _cursor: string = "auto";
+ private _redrawCycle: number = 0;
+ private _eventListeners: KeyboardEventListener[] = [];
+ private _contextMenu: ContextMenu | null = null;
+
+ constructor(ref: DeckGLRef | null) {
+ this._ref = ref;
+ this.addKeyboardEventListeners();
+ }
+
+ setRef(ref: DeckGLRef | null) {
+ this._ref = ref;
+ }
+
+ private addKeyboardEventListeners() {
+ const handleKeyDown = this.handleKeyDown.bind(this);
+ const handleKeyUp = this.handleKeyUp.bind(this);
+
+ this._eventListeners = [handleKeyDown, handleKeyUp];
+
+ document.addEventListener("keyup", handleKeyUp);
+ document.addEventListener("keydown", handleKeyDown);
+ }
+
+ private maybeRemoveKeyboardEventListeners() {
+ for (const listener of this._eventListeners) {
+ document.removeEventListener("keydown", listener);
+ }
+ }
+
+ private handleKeyDown(event: KeyboardEvent) {
+ for (const plugin of this._plugins) {
+ plugin.handleKeyDownEvent?.(event.key);
+ }
+ }
+
+ private handleKeyUp(event: KeyboardEvent) {
+ for (const plugin of this._plugins) {
+ plugin.handleKeyUpEvent?.(event.key);
+ }
+ }
+
+ addPlugin(plugin: DeckGlPlugin) {
+ this._plugins.push(plugin);
+ }
+
+ redraw() {
+ this._redrawCycle++;
+ this._publishSubscribeDelegate.notifySubscribers(DeckGlInstanceManagerTopic.REDRAW);
+ }
+
+ disablePanning() {
+ if (!this._ref) {
+ return;
+ }
+
+ this._ref.deck?.setProps({
+ controller: {
+ dragPan: false,
+ dragRotate: false,
+ },
+ });
+ }
+
+ enablePanning() {
+ if (!this._ref) {
+ return;
+ }
+
+ this._ref.deck?.setProps({
+ controller: {
+ dragRotate: true,
+ dragPan: true,
+ },
+ });
+ }
+
+ getPublishSubscribeDelegate(): PublishSubscribeDelegate {
+ return this._publishSubscribeDelegate;
+ }
+
+ makeSnapshotGetter(topic: T): () => DeckGlInstanceManagerPayloads[T] {
+ const snapshotGetter = (): any => {
+ if (topic === DeckGlInstanceManagerTopic.REDRAW) {
+ return this._redrawCycle;
+ }
+ if (topic === DeckGlInstanceManagerTopic.CONTEXT_MENU) {
+ return this._contextMenu;
+ }
+
+ throw new Error(`Unknown topic ${topic}`);
+ };
+
+ return snapshotGetter;
+ }
+
+ private getLayerIdFromPickingInfo(pickingInfo: PickingInfo): string | undefined {
+ return pickingInfo.layer?.id;
+ }
+
+ handleDrag(pickingInfo: PickingInfo): void {
+ const layerId = this.getLayerIdFromPickingInfo(pickingInfo);
+ if (!layerId) {
+ return;
+ }
+
+ const plugin = this._layersIdPluginMap.get(layerId);
+ if (!plugin) {
+ return;
+ }
+
+ plugin.handleDrag?.(pickingInfo);
+ }
+
+ handleDragStart(pickingInfo: PickingInfo) {
+ const layerId = this.getLayerIdFromPickingInfo(pickingInfo);
+ if (!layerId) {
+ return;
+ }
+
+ const plugin = this._layersIdPluginMap.get(layerId);
+ if (!plugin) {
+ return;
+ }
+ }
+
+ handleMouseEvent(event: MapMouseEvent) {
+ if (event.type !== "hover") {
+ this._contextMenu = null;
+ this._publishSubscribeDelegate.notifySubscribers(DeckGlInstanceManagerTopic.CONTEXT_MENU);
+ }
+
+ const firstLayerInfo = this.getFirstLayerUnderCursorInfo(event);
+ if (!firstLayerInfo || !firstLayerInfo.coordinate) {
+ this._hoverPoint = null;
+ return;
+ }
+
+ this._hoverPoint = {
+ worldCoordinates: firstLayerInfo.coordinate,
+ screenCoordinates: [firstLayerInfo.x, firstLayerInfo.y],
+ };
+
+ const layerId = this.getLayerIdFromPickingInfo(firstLayerInfo);
+ const plugin = this._layersIdPluginMap.get(layerId ?? "");
+ if (layerId && plugin) {
+ if (event.type === "hover") {
+ plugin.handleLayerHover?.(firstLayerInfo);
+ this._cursor = plugin.getCursor?.(firstLayerInfo) ?? "auto";
+ }
+
+ if (event.type === "click") {
+ plugin.handleLayerClick?.(firstLayerInfo);
+ }
+
+ if (event.type === "contextmenu") {
+ const contextMenuItems = plugin.getContextMenuItems?.(firstLayerInfo) ?? [];
+ this._contextMenu = {
+ position: { x: firstLayerInfo.x, y: firstLayerInfo.y },
+ items: contextMenuItems,
+ };
+ this._publishSubscribeDelegate.notifySubscribers(DeckGlInstanceManagerTopic.CONTEXT_MENU);
+ }
+ return;
+ }
+
+ const pluginsThatDidNotAcceptEvent: DeckGlPlugin[] = [];
+ for (const plugin of this._plugins) {
+ if (event.type === "hover") {
+ plugin.handleGlobalMouseHover?.(firstLayerInfo);
+ this._cursor = "auto";
+ } else if (event.type === "click") {
+ const accepted = plugin.handleGlobalMouseClick?.(firstLayerInfo);
+ if (!accepted) {
+ pluginsThatDidNotAcceptEvent.push(plugin);
+ }
+ }
+ }
+
+ if (event.type === "click") {
+ for (const plugin of pluginsThatDidNotAcceptEvent) {
+ plugin.handleClickAway?.();
+ }
+ }
+ }
+
+ pickFirstLayerUnderCursorInfo(x: number, y: number): PickingInfo | undefined {
+ if (!this._ref?.deck) {
+ return undefined;
+ }
+
+ const layer =
+ this._ref.deck.pickMultipleObjects({ x, y, radius: 10, depth: 1, unproject3D: true }) ?? undefined;
+ return layer[0];
+ }
+
+ private getFirstLayerUnderCursorInfo(event: MapMouseEvent): PickingInfo | undefined {
+ for (const info of event.infos) {
+ if (info.coordinate && info.x) {
+ return info;
+ }
+ }
+
+ return undefined;
+ }
+
+ getCursor(cursorState: Parameters>[0]): string {
+ if (cursorState.isDragging) {
+ return "grabbing";
+ }
+
+ return this._cursor;
+ }
+
+ makeDeckGlComponentProps(props: SubsurfaceViewerWithCameraStateProps): SubsurfaceViewerWithCameraStateProps {
+ const layers = [...(props.layers ?? [])];
+ for (const plugin of this._plugins) {
+ const pluginLayers = plugin.getLayers?.() ?? [];
+ layers.push(...pluginLayers);
+ for (const layer of pluginLayers) {
+ this._layersIdPluginMap.set(layer.id, plugin);
+ }
+ }
+ return {
+ ...props,
+ onDrag: this.handleDrag.bind(this),
+ onMouseEvent: (event) => {
+ this.handleMouseEvent(event);
+ props.onMouseEvent?.(event);
+ },
+ getCursor: (state) => this.getCursor(state),
+ layers,
+ };
+ }
+
+ beforeDestroy() {
+ this.maybeRemoveKeyboardEventListeners();
+ }
+}
diff --git a/frontend/src/modules/3DViewerNew/view/utils/MoveableLayerPlugin.tsx b/frontend/src/modules/3DViewerNew/view/utils/MoveableLayerPlugin.tsx
new file mode 100644
index 000000000..e94e98ddc
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/view/utils/MoveableLayerPlugin.tsx
@@ -0,0 +1,524 @@
+import addPathIcon from "@assets/add_path.svg";
+import continuePathIcon from "@assets/continue_path.svg";
+import removePathIcon from "@assets/remove_path.svg";
+import { Layer, PickingInfo } from "@deck.gl/core";
+import { PublishSubscribe, PublishSubscribeDelegate } from "@modules/_shared/utils/PublishSubscribeDelegate";
+import { Edit, Remove } from "@mui/icons-material";
+
+import { isEqual } from "lodash";
+import { v4 } from "uuid";
+
+import { ContextMenuItem, DeckGlInstanceManager, DeckGlPlugin } from "./DeckGlInstanceManager";
+
+import {
+ AllowHoveringOf,
+ EditablePolylineLayer,
+ isEditablePolylineLayerPickingInfo,
+} from "../hooks/editablePolylines/deckGlLayers/EditablePolylineLayer";
+import { PolylinesLayer, isPolylinesLayerPickingInfo } from "../hooks/editablePolylines/deckGlLayers/PolylinesLayer";
+
+export type Polyline = {
+ id: string;
+ name: string;
+ color: [number, number, number];
+ path: number[][];
+};
+
+export enum PolylineEditingMode {
+ DRAW = "draw",
+ ADD_POINT = "add_point",
+ REMOVE_POINT = "remove_point",
+ NONE = "none",
+ IDLE = "idle",
+}
+
+export enum PolylinesPluginTopic {
+ EDITING_POLYLINE_ID = "editing_polyline_id",
+ EDITING_MODE = "editing_mode",
+ POLYLINES = "polylines",
+}
+
+export type PolylinesPluginTopicPayloads = {
+ [PolylinesPluginTopic.EDITING_MODE]: PolylineEditingMode;
+ [PolylinesPluginTopic.EDITING_POLYLINE_ID]: string | null;
+ [PolylinesPluginTopic.POLYLINES]: Polyline[];
+};
+
+enum AppendToPathLocation {
+ START = "start",
+ END = "end",
+}
+
+function* defaultColorGenerator() {
+ const colors: [number, number, number][] = [
+ [255, 0, 0],
+ [0, 255, 0],
+ [0, 0, 255],
+ [255, 255, 0],
+ [255, 0, 255],
+ [0, 255, 255],
+ ];
+
+ let index = 0;
+ while (true) {
+ yield colors[index];
+ index = (index + 1) % colors.length;
+ }
+}
+
+export class PolylinesPlugin extends DeckGlPlugin implements PublishSubscribe {
+ private _currentEditingPolylineId: string | null = null;
+ private _currentEditingPolylinePathReferencePointIndex: number | null = null;
+ private _polylines: Polyline[] = [];
+ private _editingMode: PolylineEditingMode = PolylineEditingMode.NONE;
+ private _draggedPathPointIndex: number | null = null;
+ private _appendToPathLocation: AppendToPathLocation = AppendToPathLocation.END;
+ private _selectedPolylineId: string | null = null;
+ private _hoverPoint: number[] | null = null;
+ private _colorGenerator: Generator<[number, number, number]>;
+
+ private _publishSubscribeDelegate = new PublishSubscribeDelegate();
+
+ private setCurrentEditingPolylineId(id: string | null): void {
+ this._currentEditingPolylineId = id;
+ this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.EDITING_POLYLINE_ID);
+ }
+
+ constructor(manager: DeckGlInstanceManager, colorGenerator?: Generator<[number, number, number]>) {
+ super(manager);
+ this._colorGenerator = colorGenerator ?? defaultColorGenerator();
+ }
+
+ getActivePolyline(): Polyline | undefined {
+ return this._polylines.find((polyline) => polyline.id === this._currentEditingPolylineId);
+ }
+
+ getPolylines(): Polyline[] {
+ return this._polylines;
+ }
+
+ setPolylines(polylines: Polyline[]): void {
+ if (isEqual(this._polylines, polylines)) {
+ return;
+ }
+ this._polylines = polylines;
+ this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.POLYLINES);
+ this.requireRedraw();
+ }
+
+ setActivePolylineName(name: string): void {
+ const activePolyline = this.getActivePolyline();
+ if (!activePolyline) {
+ return;
+ }
+
+ this._polylines = this._polylines.map((polyline) => {
+ if (polyline.id === activePolyline.id) {
+ return {
+ ...polyline,
+ name,
+ };
+ }
+ return polyline;
+ });
+ this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.POLYLINES);
+ this.requireRedraw();
+ }
+
+ getPublishSubscribeDelegate(): PublishSubscribeDelegate {
+ return this._publishSubscribeDelegate;
+ }
+
+ setEditingMode(mode: PolylineEditingMode): void {
+ this._editingMode = mode;
+ this._hoverPoint = null;
+ this._currentEditingPolylinePathReferencePointIndex = null;
+ this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.EDITING_MODE);
+ this.requireRedraw();
+ }
+
+ getEditingMode(): PolylineEditingMode {
+ return this._editingMode;
+ }
+
+ getCurrentEditingPolylineId(): string | null {
+ return this._currentEditingPolylineId;
+ }
+
+ handleKeyUpEvent(key: string): void {
+ if (key === "Escape") {
+ if (this._editingMode === PolylineEditingMode.NONE) {
+ this._currentEditingPolylinePathReferencePointIndex = null;
+ this.requireRedraw();
+ return;
+ }
+ if (this._editingMode === PolylineEditingMode.IDLE) {
+ this._currentEditingPolylinePathReferencePointIndex = null;
+ this._hoverPoint = null;
+ this.requireRedraw();
+ return;
+ }
+
+ this._hoverPoint = null;
+ this.setEditingMode(PolylineEditingMode.IDLE);
+ this.requireRedraw();
+ return;
+ }
+ if (key === "Delete") {
+ if (this._editingMode === PolylineEditingMode.IDLE) {
+ if (this._selectedPolylineId) {
+ this._polylines = this._polylines.filter((polyline) => polyline.id !== this._selectedPolylineId);
+ this._selectedPolylineId = null;
+ this.requireRedraw();
+ }
+ return;
+ }
+ }
+ }
+
+ handleLayerClick(pickingInfo: PickingInfo): void {
+ if (this._editingMode === PolylineEditingMode.NONE || this._editingMode === PolylineEditingMode.IDLE) {
+ if (isPolylinesLayerPickingInfo(pickingInfo)) {
+ this._selectedPolylineId = pickingInfo.polylineId ?? null;
+ this.requireRedraw();
+ }
+ return;
+ }
+
+ if (!isEditablePolylineLayerPickingInfo(pickingInfo)) {
+ return;
+ }
+
+ const activePolyline = this.getActivePolyline();
+ if (!activePolyline) {
+ return;
+ }
+
+ if (pickingInfo.editableEntity?.type === "point") {
+ if (![PolylineEditingMode.DRAW, PolylineEditingMode.REMOVE_POINT].includes(this._editingMode)) {
+ return;
+ }
+
+ const index = pickingInfo.editableEntity.index;
+ if (this._editingMode === PolylineEditingMode.DRAW) {
+ if (
+ (index === 0 || index === activePolyline.path.length - 1) &&
+ this._currentEditingPolylinePathReferencePointIndex !== index
+ ) {
+ this._appendToPathLocation = index === 0 ? AppendToPathLocation.START : AppendToPathLocation.END;
+ this._currentEditingPolylinePathReferencePointIndex = index;
+ this.requireRedraw();
+ return;
+ }
+ }
+
+ const newPath = activePolyline.path.filter((_, i) => i !== index);
+ let newReferencePathPointIndex: number | null = null;
+ if (this._currentEditingPolylinePathReferencePointIndex !== null) {
+ newReferencePathPointIndex = Math.max(0, this._currentEditingPolylinePathReferencePointIndex - 1);
+ if (index > this._currentEditingPolylinePathReferencePointIndex) {
+ newReferencePathPointIndex = this._currentEditingPolylinePathReferencePointIndex;
+ }
+ if (activePolyline.path.length - 1 < 1) {
+ newReferencePathPointIndex = null;
+ }
+ }
+
+ this.updateActivePolylinePath(newPath);
+ this._currentEditingPolylinePathReferencePointIndex = newReferencePathPointIndex;
+ this.requireRedraw();
+ return;
+ }
+
+ if (pickingInfo.editableEntity?.type === "line") {
+ if (![PolylineEditingMode.DRAW, PolylineEditingMode.ADD_POINT].includes(this._editingMode)) {
+ return;
+ }
+
+ if (!pickingInfo.coordinate) {
+ return;
+ }
+
+ const index = pickingInfo.editableEntity.index;
+ const newPath = [...activePolyline.path];
+ newPath.splice(index + 1, 0, [...pickingInfo.coordinate]);
+ this.updateActivePolylinePath(newPath);
+
+ let newReferencePathPointIndex: number | null = null;
+ if (this._currentEditingPolylinePathReferencePointIndex !== null) {
+ newReferencePathPointIndex = this._currentEditingPolylinePathReferencePointIndex + 1;
+ if (index > this._currentEditingPolylinePathReferencePointIndex) {
+ newReferencePathPointIndex = this._currentEditingPolylinePathReferencePointIndex;
+ }
+ }
+
+ this._currentEditingPolylinePathReferencePointIndex = newReferencePathPointIndex;
+ this.requireRedraw();
+ }
+ }
+
+ private updateActivePolylinePath(newPath: number[][]): void {
+ const activePolyline = this.getActivePolyline();
+ if (!activePolyline) {
+ return;
+ }
+
+ this._polylines = this._polylines.map((polyline) => {
+ if (polyline.id === activePolyline.id) {
+ return {
+ ...polyline,
+ path: newPath,
+ };
+ }
+ return polyline;
+ });
+
+ this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.POLYLINES);
+ }
+
+ handleClickAway(): void {
+ this._selectedPolylineId = null;
+ this.requireRedraw();
+ }
+
+ handleGlobalMouseHover(pickingInfo: PickingInfo): void {
+ if (this._editingMode !== PolylineEditingMode.DRAW) {
+ return;
+ }
+
+ if (!pickingInfo.coordinate) {
+ return;
+ }
+
+ this._hoverPoint = pickingInfo.coordinate;
+ this.requireRedraw();
+ }
+
+ handleGlobalMouseClick(pickingInfo: PickingInfo): boolean {
+ if (this._editingMode === PolylineEditingMode.NONE) {
+ return false;
+ }
+
+ if (!pickingInfo.coordinate) {
+ return false;
+ }
+
+ const activePolyline = this.getActivePolyline();
+ if (!activePolyline && this._editingMode === PolylineEditingMode.DRAW) {
+ const id = v4();
+ this._polylines.push({
+ id,
+ name: "New polyline",
+ color: this._colorGenerator.next().value,
+ path: [[...pickingInfo.coordinate]],
+ });
+ this._polylines = [...this._polylines];
+ this._currentEditingPolylinePathReferencePointIndex = 0;
+ this.setCurrentEditingPolylineId(id);
+ this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.POLYLINES);
+ this.requireRedraw();
+ } else if (activePolyline) {
+ if (this._currentEditingPolylinePathReferencePointIndex === null) {
+ this.setCurrentEditingPolylineId(null);
+ this.setEditingMode(PolylineEditingMode.IDLE);
+ this.requireRedraw();
+ return true;
+ }
+
+ if (this._editingMode === PolylineEditingMode.DRAW) {
+ this.appendToActivePolylinePath(pickingInfo.coordinate);
+ this.requireRedraw();
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private appendToActivePolylinePath(point: number[]): void {
+ const activePolyline = this.getActivePolyline();
+ if (!activePolyline) {
+ return;
+ }
+
+ const newPath = [...activePolyline.path];
+ if (this._appendToPathLocation === AppendToPathLocation.START) {
+ newPath.unshift(point);
+ this._currentEditingPolylinePathReferencePointIndex = 0;
+ } else {
+ newPath.push(point);
+ this._currentEditingPolylinePathReferencePointIndex = newPath.length - 1;
+ }
+
+ this.updateActivePolylinePath(newPath);
+ }
+
+ handleDragStart(pickingInfo: PickingInfo): void {
+ if (!isEditablePolylineLayerPickingInfo(pickingInfo)) {
+ return;
+ }
+
+ if (pickingInfo.editableEntity?.type === "point") {
+ this._draggedPathPointIndex = pickingInfo.index;
+ this.requestDisablePanning();
+ }
+ }
+
+ handleDrag(pickingInfo: PickingInfo): void {
+ if (this._draggedPathPointIndex === null || !pickingInfo.coordinate) {
+ return;
+ }
+
+ const activePolyline = this.getActivePolyline();
+ if (!activePolyline) {
+ return;
+ }
+
+ // Take first layer under cursor to get coordinates for the polyline point
+ // An alternative would be to store a reference to the layer the polyline was first created upon
+ // and always try to use that layer to get the coordinates
+ const layerUnderCursor = this.getFirstLayerUnderCursorInfo(pickingInfo.x, pickingInfo.y);
+ if (!layerUnderCursor || !layerUnderCursor.coordinate) {
+ return;
+ }
+
+ const newPath = [...activePolyline.path];
+ newPath[this._draggedPathPointIndex] = [...layerUnderCursor.coordinate];
+ this.updateActivePolylinePath(newPath);
+ this.requireRedraw();
+ }
+
+ handleDragEnd(): void {
+ this._draggedPathPointIndex = null;
+ this.requestEnablePanning();
+ }
+
+ getCursor(pickingInfo: PickingInfo): string | null {
+ if (this._editingMode === PolylineEditingMode.NONE) {
+ return null;
+ }
+
+ const activePolyline = this.getActivePolyline();
+
+ if (isEditablePolylineLayerPickingInfo(pickingInfo) && pickingInfo.editableEntity) {
+ if (
+ [PolylineEditingMode.DRAW, PolylineEditingMode.ADD_POINT].includes(this._editingMode) &&
+ pickingInfo.editableEntity.type === "line"
+ ) {
+ return `url(${addPathIcon}) 4 2, crosshair`;
+ }
+
+ if (
+ activePolyline &&
+ [PolylineEditingMode.DRAW, PolylineEditingMode.REMOVE_POINT].includes(this._editingMode) &&
+ pickingInfo.editableEntity.type === "point"
+ ) {
+ const index = pickingInfo.index;
+ if (
+ (index === 0 || index === activePolyline.path.length - 1) &&
+ index !== this._currentEditingPolylinePathReferencePointIndex &&
+ this._editingMode === PolylineEditingMode.DRAW
+ ) {
+ return `url(${continuePathIcon}) 4 2, crosshair`;
+ }
+
+ return `url(${removePathIcon}) 4 2, crosshair`;
+ }
+
+ if (this._editingMode === PolylineEditingMode.IDLE && pickingInfo.editableEntity.type === "point") {
+ return "grab";
+ }
+ }
+
+ return "auto";
+ }
+
+ getContextMenuItems(pickingInfo: PickingInfo): ContextMenuItem[] {
+ if (this._editingMode !== PolylineEditingMode.IDLE) {
+ return [];
+ }
+
+ if (!isPolylinesLayerPickingInfo(pickingInfo) || !pickingInfo.polylineId) {
+ return [];
+ }
+
+ return [
+ {
+ icon: ,
+ label: "Edit",
+ onClick: () => {
+ this.setCurrentEditingPolylineId(pickingInfo.polylineId ?? null);
+ this.requireRedraw();
+ },
+ },
+ {
+ icon: ,
+ label: "Delete",
+ onClick: () => {
+ this._polylines = this._polylines.filter((polyline) => polyline.id !== pickingInfo.polylineId);
+ this.setCurrentEditingPolylineId(null);
+ this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.POLYLINES);
+ this.requireRedraw();
+ },
+ },
+ ];
+ }
+
+ getLayers(): Layer[] {
+ const layers: Layer[] = [
+ new PolylinesLayer({
+ id: "polylines-layer",
+ polylines: this._polylines.filter((polyline) => polyline.id !== this._currentEditingPolylineId),
+ selectedPolylineId:
+ this._editingMode === PolylineEditingMode.NONE ? undefined : this._selectedPolylineId ?? undefined,
+ hoverable: this._editingMode === PolylineEditingMode.IDLE,
+ }),
+ ];
+
+ let allowHoveringOf = AllowHoveringOf.NONE;
+ if ([PolylineEditingMode.DRAW, PolylineEditingMode.ADD_POINT].includes(this._editingMode)) {
+ allowHoveringOf = AllowHoveringOf.LINES_AND_POINTS;
+ }
+ if (this._editingMode === PolylineEditingMode.REMOVE_POINT) {
+ allowHoveringOf = AllowHoveringOf.POINTS;
+ }
+
+ const activePolyline = this.getActivePolyline();
+ if (activePolyline) {
+ layers.push(
+ new EditablePolylineLayer({
+ id: "editable-polylines-layer",
+ polyline: activePolyline,
+ mouseHoverPoint: this._hoverPoint ?? undefined,
+ referencePathPointIndex:
+ this._editingMode === PolylineEditingMode.DRAW
+ ? this._currentEditingPolylinePathReferencePointIndex ?? undefined
+ : undefined,
+ onDragStart: this.handleDragStart.bind(this),
+ onDragEnd: this.handleDragEnd.bind(this),
+ allowHoveringOf,
+ })
+ );
+ }
+
+ return layers;
+ }
+
+ makeSnapshotGetter(topic: T): () => PolylinesPluginTopicPayloads[T] {
+ const snapshotGetter = (): any => {
+ if (topic === PolylinesPluginTopic.EDITING_MODE) {
+ return this._editingMode;
+ }
+ if (topic === PolylinesPluginTopic.EDITING_POLYLINE_ID) {
+ return this._currentEditingPolylineId;
+ }
+ if (topic === PolylinesPluginTopic.POLYLINES) {
+ return this._polylines;
+ }
+
+ throw new Error(`Unknown topic ${topic}`);
+ };
+
+ return snapshotGetter;
+ }
+}
diff --git a/frontend/src/modules/3DViewerNew/view/utils/PolylinesPlugin.tsx b/frontend/src/modules/3DViewerNew/view/utils/PolylinesPlugin.tsx
new file mode 100644
index 000000000..65e45d7a6
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/view/utils/PolylinesPlugin.tsx
@@ -0,0 +1,526 @@
+import addPathIcon from "@assets/add_path.svg";
+import continuePathIcon from "@assets/continue_path.svg";
+import removePathIcon from "@assets/remove_path.svg";
+import { Layer, PickingInfo } from "@deck.gl/core";
+import { PublishSubscribe, PublishSubscribeDelegate } from "@modules/_shared/utils/PublishSubscribeDelegate";
+import { Edit, Remove } from "@mui/icons-material";
+
+import { isEqual } from "lodash";
+import { v4 } from "uuid";
+
+import { ContextMenuItem, DeckGlInstanceManager, DeckGlPlugin } from "./DeckGlInstanceManager";
+
+import {
+ AllowHoveringOf,
+ EditablePolylineLayer,
+ isEditablePolylineLayerPickingInfo,
+} from "../hooks/editablePolylines/deckGlLayers/EditablePolylineLayer";
+import { PolylinesLayer, isPolylinesLayerPickingInfo } from "../hooks/editablePolylines/deckGlLayers/PolylinesLayer";
+
+export type Polyline = {
+ id: string;
+ name: string;
+ color: [number, number, number];
+ path: number[][];
+};
+
+export enum PolylineEditingMode {
+ DRAW = "draw",
+ ADD_POINT = "add_point",
+ REMOVE_POINT = "remove_point",
+ NONE = "none",
+ IDLE = "idle",
+}
+
+export enum PolylinesPluginTopic {
+ EDITING_POLYLINE_ID = "editing_polyline_id",
+ EDITING_MODE = "editing_mode",
+ POLYLINES = "polylines",
+}
+
+export type PolylinesPluginTopicPayloads = {
+ [PolylinesPluginTopic.EDITING_MODE]: PolylineEditingMode;
+ [PolylinesPluginTopic.EDITING_POLYLINE_ID]: string | null;
+ [PolylinesPluginTopic.POLYLINES]: Polyline[];
+};
+
+enum AppendToPathLocation {
+ START = "start",
+ END = "end",
+}
+
+function* defaultColorGenerator() {
+ const colors: [number, number, number][] = [
+ [255, 0, 0],
+ [0, 255, 0],
+ [0, 0, 255],
+ [255, 255, 0],
+ [255, 0, 255],
+ [0, 255, 255],
+ ];
+
+ let index = 0;
+ while (true) {
+ yield colors[index];
+ index = (index + 1) % colors.length;
+ }
+}
+
+export class PolylinesPlugin extends DeckGlPlugin implements PublishSubscribe {
+ private _currentEditingPolylineId: string | null = null;
+ private _currentEditingPolylinePathReferencePointIndex: number | null = null;
+ private _polylines: Polyline[] = [];
+ private _editingMode: PolylineEditingMode = PolylineEditingMode.NONE;
+ private _draggedPathPointIndex: number | null = null;
+ private _appendToPathLocation: AppendToPathLocation = AppendToPathLocation.END;
+ private _selectedPolylineId: string | null = null;
+ private _hoverPoint: number[] | null = null;
+ private _colorGenerator: Generator<[number, number, number]>;
+
+ private _publishSubscribeDelegate = new PublishSubscribeDelegate();
+
+ private setCurrentEditingPolylineId(id: string | null): void {
+ this._currentEditingPolylineId = id;
+ this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.EDITING_POLYLINE_ID);
+ }
+
+ constructor(manager: DeckGlInstanceManager, colorGenerator?: Generator<[number, number, number]>) {
+ super(manager);
+ this._colorGenerator = colorGenerator ?? defaultColorGenerator();
+ }
+
+ getActivePolyline(): Polyline | undefined {
+ return this._polylines.find((polyline) => polyline.id === this._currentEditingPolylineId);
+ }
+
+ getPolylines(): Polyline[] {
+ return this._polylines;
+ }
+
+ setPolylines(polylines: readonly Polyline[]): void {
+ if (isEqual(this._polylines, polylines)) {
+ return;
+ }
+ this._polylines = [...polylines];
+ this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.POLYLINES);
+ this.requireRedraw();
+ }
+
+ setActivePolylineName(name: string): void {
+ const activePolyline = this.getActivePolyline();
+ if (!activePolyline) {
+ return;
+ }
+
+ this._polylines = this._polylines.map((polyline) => {
+ if (polyline.id === activePolyline.id) {
+ return {
+ ...polyline,
+ name,
+ };
+ }
+ return polyline;
+ });
+ this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.POLYLINES);
+ this.requireRedraw();
+ }
+
+ getPublishSubscribeDelegate(): PublishSubscribeDelegate {
+ return this._publishSubscribeDelegate;
+ }
+
+ setEditingMode(mode: PolylineEditingMode): void {
+ this._editingMode = mode;
+ this._hoverPoint = null;
+ this._currentEditingPolylinePathReferencePointIndex = null;
+ this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.EDITING_MODE);
+ this.requireRedraw();
+ }
+
+ getEditingMode(): PolylineEditingMode {
+ return this._editingMode;
+ }
+
+ getCurrentEditingPolylineId(): string | null {
+ return this._currentEditingPolylineId;
+ }
+
+ handleKeyUpEvent(key: string): void {
+ if (key === "Escape") {
+ if (this._editingMode === PolylineEditingMode.NONE) {
+ this._currentEditingPolylinePathReferencePointIndex = null;
+ this.requireRedraw();
+ return;
+ }
+ if (this._editingMode === PolylineEditingMode.IDLE) {
+ this._currentEditingPolylinePathReferencePointIndex = null;
+ this._hoverPoint = null;
+ this.requireRedraw();
+ return;
+ }
+
+ this._hoverPoint = null;
+ this.setEditingMode(PolylineEditingMode.IDLE);
+ this.requireRedraw();
+ return;
+ }
+ if (key === "Delete") {
+ if (this._editingMode === PolylineEditingMode.IDLE) {
+ if (this._selectedPolylineId) {
+ this._polylines = this._polylines.filter((polyline) => polyline.id !== this._selectedPolylineId);
+ this._selectedPolylineId = null;
+ this.requireRedraw();
+ }
+ return;
+ }
+ }
+ }
+
+ handleLayerClick(pickingInfo: PickingInfo): void {
+ if (this._editingMode === PolylineEditingMode.NONE || this._editingMode === PolylineEditingMode.IDLE) {
+ if (isPolylinesLayerPickingInfo(pickingInfo)) {
+ this._selectedPolylineId = pickingInfo.polylineId ?? null;
+ this.requireRedraw();
+ }
+ return;
+ }
+
+ if (!isEditablePolylineLayerPickingInfo(pickingInfo)) {
+ return;
+ }
+
+ const activePolyline = this.getActivePolyline();
+ if (!activePolyline) {
+ return;
+ }
+
+ if (pickingInfo.editableEntity?.type === "point") {
+ if (![PolylineEditingMode.DRAW, PolylineEditingMode.REMOVE_POINT].includes(this._editingMode)) {
+ return;
+ }
+
+ const index = pickingInfo.editableEntity.index;
+ if (this._editingMode === PolylineEditingMode.DRAW) {
+ if (
+ (index === 0 || index === activePolyline.path.length - 1) &&
+ this._currentEditingPolylinePathReferencePointIndex !== index
+ ) {
+ this._appendToPathLocation = index === 0 ? AppendToPathLocation.START : AppendToPathLocation.END;
+ this._currentEditingPolylinePathReferencePointIndex = index;
+ this.requireRedraw();
+ return;
+ }
+ }
+
+ const newPath = activePolyline.path.filter((_, i) => i !== index);
+ let newReferencePathPointIndex: number | null = null;
+ if (this._currentEditingPolylinePathReferencePointIndex !== null) {
+ newReferencePathPointIndex = Math.max(0, this._currentEditingPolylinePathReferencePointIndex - 1);
+ if (index > this._currentEditingPolylinePathReferencePointIndex) {
+ newReferencePathPointIndex = this._currentEditingPolylinePathReferencePointIndex;
+ }
+ if (activePolyline.path.length - 1 < 1) {
+ newReferencePathPointIndex = null;
+ }
+ }
+
+ this.updateActivePolylinePath(newPath);
+ this._currentEditingPolylinePathReferencePointIndex = newReferencePathPointIndex;
+ this.requireRedraw();
+ return;
+ }
+
+ if (pickingInfo.editableEntity?.type === "line") {
+ if (![PolylineEditingMode.DRAW, PolylineEditingMode.ADD_POINT].includes(this._editingMode)) {
+ return;
+ }
+
+ if (!pickingInfo.coordinate) {
+ return;
+ }
+
+ const index = pickingInfo.editableEntity.index;
+ const newPath = [...activePolyline.path];
+ newPath.splice(index + 1, 0, [...pickingInfo.coordinate]);
+ this.updateActivePolylinePath(newPath);
+
+ let newReferencePathPointIndex: number | null = null;
+ if (this._currentEditingPolylinePathReferencePointIndex !== null) {
+ newReferencePathPointIndex = this._currentEditingPolylinePathReferencePointIndex + 1;
+ if (index > this._currentEditingPolylinePathReferencePointIndex) {
+ newReferencePathPointIndex = this._currentEditingPolylinePathReferencePointIndex;
+ }
+ }
+
+ this._currentEditingPolylinePathReferencePointIndex = newReferencePathPointIndex;
+ this.requireRedraw();
+ }
+ }
+
+ private updateActivePolylinePath(newPath: number[][]): void {
+ const activePolyline = this.getActivePolyline();
+ if (!activePolyline) {
+ return;
+ }
+
+ this._polylines = this._polylines.map((polyline) => {
+ if (polyline.id === activePolyline.id) {
+ return {
+ ...polyline,
+ path: newPath,
+ };
+ }
+ return polyline;
+ });
+
+ this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.POLYLINES);
+ }
+
+ handleClickAway(): void {
+ this._selectedPolylineId = null;
+ this.requireRedraw();
+ }
+
+ handleGlobalMouseHover(pickingInfo: PickingInfo): void {
+ if (this._editingMode !== PolylineEditingMode.DRAW) {
+ return;
+ }
+
+ if (!pickingInfo.coordinate) {
+ return;
+ }
+
+ this._hoverPoint = pickingInfo.coordinate;
+ this.requireRedraw();
+ }
+
+ handleGlobalMouseClick(pickingInfo: PickingInfo): boolean {
+ if (this._editingMode === PolylineEditingMode.NONE) {
+ return false;
+ }
+
+ if (!pickingInfo.coordinate) {
+ return false;
+ }
+
+ const activePolyline = this.getActivePolyline();
+ if (!activePolyline && this._editingMode === PolylineEditingMode.DRAW) {
+ const id = v4();
+ this._polylines.push({
+ id,
+ name: "New polyline",
+ color: this._colorGenerator.next().value,
+ path: [[...pickingInfo.coordinate]],
+ });
+ this._polylines = [...this._polylines];
+ this._currentEditingPolylinePathReferencePointIndex = 0;
+ this.setCurrentEditingPolylineId(id);
+ this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.POLYLINES);
+ this.requireRedraw();
+ } else if (activePolyline) {
+ if (this._currentEditingPolylinePathReferencePointIndex === null) {
+ this.setCurrentEditingPolylineId(null);
+ this.setEditingMode(PolylineEditingMode.IDLE);
+ this.requireRedraw();
+ return true;
+ }
+
+ if (this._editingMode === PolylineEditingMode.DRAW) {
+ this.appendToActivePolylinePath(pickingInfo.coordinate);
+ this.requireRedraw();
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private appendToActivePolylinePath(point: number[]): void {
+ const activePolyline = this.getActivePolyline();
+ if (!activePolyline) {
+ return;
+ }
+
+ const newPath = [...activePolyline.path];
+ if (this._appendToPathLocation === AppendToPathLocation.START) {
+ newPath.unshift(point);
+ this._currentEditingPolylinePathReferencePointIndex = 0;
+ } else {
+ newPath.push(point);
+ this._currentEditingPolylinePathReferencePointIndex = newPath.length - 1;
+ }
+
+ this.updateActivePolylinePath(newPath);
+ }
+
+ handleDragStart(pickingInfo: PickingInfo): void {
+ if (!isEditablePolylineLayerPickingInfo(pickingInfo)) {
+ return;
+ }
+
+ if (pickingInfo.editableEntity?.type === "point") {
+ this._draggedPathPointIndex = pickingInfo.index;
+ this.requestDisablePanning();
+ }
+ }
+
+ handleDrag(pickingInfo: PickingInfo): void {
+ if (this._draggedPathPointIndex === null || !pickingInfo.coordinate) {
+ return;
+ }
+
+ const activePolyline = this.getActivePolyline();
+ if (!activePolyline) {
+ return;
+ }
+
+ // Take first layer under cursor to get coordinates for the polyline point
+ // An alternative would be to store a reference to the layer the polyline was first created upon
+ // and always try to use that layer to get the coordinates
+ const layerUnderCursor = this.getFirstLayerUnderCursorInfo(pickingInfo.x, pickingInfo.y);
+ if (!layerUnderCursor || !layerUnderCursor.coordinate) {
+ return;
+ }
+
+ const newPath = [...activePolyline.path];
+ newPath[this._draggedPathPointIndex] = [...layerUnderCursor.coordinate];
+ this.updateActivePolylinePath(newPath);
+ this.requireRedraw();
+ }
+
+ handleDragEnd(): void {
+ this._draggedPathPointIndex = null;
+ this.requestEnablePanning();
+ }
+
+ getCursor(pickingInfo: PickingInfo): string | null {
+ if (this._editingMode === PolylineEditingMode.NONE) {
+ return null;
+ }
+
+ const activePolyline = this.getActivePolyline();
+
+ if (isEditablePolylineLayerPickingInfo(pickingInfo) && pickingInfo.editableEntity) {
+ if (
+ [PolylineEditingMode.DRAW, PolylineEditingMode.ADD_POINT].includes(this._editingMode) &&
+ pickingInfo.editableEntity.type === "line"
+ ) {
+ return `url(${addPathIcon}) 4 2, crosshair`;
+ }
+
+ if (
+ activePolyline &&
+ [PolylineEditingMode.DRAW, PolylineEditingMode.REMOVE_POINT].includes(this._editingMode) &&
+ pickingInfo.editableEntity.type === "point"
+ ) {
+ const index = pickingInfo.index;
+ if (
+ (index === 0 || index === activePolyline.path.length - 1) &&
+ index !== this._currentEditingPolylinePathReferencePointIndex &&
+ this._editingMode === PolylineEditingMode.DRAW
+ ) {
+ return `url(${continuePathIcon}) 4 2, crosshair`;
+ }
+
+ return `url(${removePathIcon}) 4 2, crosshair`;
+ }
+
+ if (this._editingMode === PolylineEditingMode.IDLE && pickingInfo.editableEntity.type === "point") {
+ return "grab";
+ }
+ }
+
+ return "auto";
+ }
+
+ getContextMenuItems(pickingInfo: PickingInfo): ContextMenuItem[] {
+ if (this._editingMode !== PolylineEditingMode.IDLE) {
+ return [];
+ }
+
+ if (!isPolylinesLayerPickingInfo(pickingInfo) || !pickingInfo.polylineId) {
+ return [];
+ }
+
+ return [
+ {
+ icon: ,
+ label: "Edit",
+ onClick: () => {
+ this.setCurrentEditingPolylineId(pickingInfo.polylineId ?? null);
+ this.requireRedraw();
+ },
+ },
+ {
+ icon: ,
+ label: "Delete",
+ onClick: () => {
+ this._polylines = this._polylines.filter((polyline) => polyline.id !== pickingInfo.polylineId);
+ this.setCurrentEditingPolylineId(null);
+ this._publishSubscribeDelegate.notifySubscribers(PolylinesPluginTopic.POLYLINES);
+ this.requireRedraw();
+ },
+ },
+ ];
+ }
+
+ getLayers(): Layer[] {
+ const layers: Layer[] = [
+ new PolylinesLayer({
+ id: "polylines-layer",
+ polylines: this._polylines.filter((polyline) => polyline.id !== this._currentEditingPolylineId),
+ selectedPolylineId:
+ this._editingMode === PolylineEditingMode.NONE
+ ? undefined
+ : (this._selectedPolylineId ?? undefined),
+ hoverable: this._editingMode === PolylineEditingMode.IDLE,
+ }),
+ ];
+
+ let allowHoveringOf = AllowHoveringOf.NONE;
+ if ([PolylineEditingMode.DRAW, PolylineEditingMode.ADD_POINT].includes(this._editingMode)) {
+ allowHoveringOf = AllowHoveringOf.LINES_AND_POINTS;
+ }
+ if (this._editingMode === PolylineEditingMode.REMOVE_POINT) {
+ allowHoveringOf = AllowHoveringOf.POINTS;
+ }
+
+ const activePolyline = this.getActivePolyline();
+ if (activePolyline) {
+ layers.push(
+ new EditablePolylineLayer({
+ id: "editable-polylines-layer",
+ polyline: activePolyline,
+ mouseHoverPoint: this._hoverPoint ?? undefined,
+ referencePathPointIndex:
+ this._editingMode === PolylineEditingMode.DRAW
+ ? (this._currentEditingPolylinePathReferencePointIndex ?? undefined)
+ : undefined,
+ onDragStart: this.handleDragStart.bind(this),
+ onDragEnd: this.handleDragEnd.bind(this),
+ allowHoveringOf,
+ }),
+ );
+ }
+
+ return layers;
+ }
+
+ makeSnapshotGetter(topic: T): () => PolylinesPluginTopicPayloads[T] {
+ const snapshotGetter = (): any => {
+ if (topic === PolylinesPluginTopic.EDITING_MODE) {
+ return this._editingMode;
+ }
+ if (topic === PolylinesPluginTopic.EDITING_POLYLINE_ID) {
+ return this._currentEditingPolylineId;
+ }
+ if (topic === PolylinesPluginTopic.POLYLINES) {
+ return this._polylines;
+ }
+
+ throw new Error(`Unknown topic ${topic}`);
+ };
+
+ return snapshotGetter;
+ }
+}
diff --git a/frontend/src/modules/3DViewerNew/view/view.tsx b/frontend/src/modules/3DViewerNew/view/view.tsx
new file mode 100644
index 000000000..5ebcc729c
--- /dev/null
+++ b/frontend/src/modules/3DViewerNew/view/view.tsx
@@ -0,0 +1,28 @@
+import React from "react";
+
+import { ModuleViewProps } from "@framework/Module";
+
+import { LayersWrapper } from "./components/LayersWrapper";
+
+import { Interfaces } from "../interfaces";
+
+export function View(props: ModuleViewProps): React.ReactNode {
+ const preferredViewLayout = props.viewContext.useSettingsToViewInterfaceValue("preferredViewLayout");
+ const layerManager = props.viewContext.useSettingsToViewInterfaceValue("layerManager");
+ const fieldIdentifier = props.viewContext.useSettingsToViewInterfaceValue("fieldIdentifier");
+
+ if (!layerManager) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/modules/_shared/DataProviderFramework/dataProviders/dataProviderTypes.ts b/frontend/src/modules/_shared/DataProviderFramework/dataProviders/dataProviderTypes.ts
index 1c94c8063..6d1af01e2 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/dataProviders/dataProviderTypes.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/dataProviders/dataProviderTypes.ts
@@ -1,4 +1,15 @@
export enum DataProviderType {
DRILLED_WELLBORE_PICKS = "DRILLED_WELLBORE_PICKS",
DRILLED_WELL_TRAJECTORIES = "DRILLED_WELL_TRAJECTORIES",
+ REALIZATION_GRID = "REALIZATION_GRID",
+ REALIZATION_SURFACE_2D = "REALIZATION_SURFACE_2D",
+ REALIZATION_SURFACE_3D = "REALIZATION_SURFACE_3D",
+ REALIZATION_POLYGONS = "REALIZATION_POLYGONS",
+ REALIZATION_SEISMIC_DEPTH_SLICE = "REALIZATION_SEISMIC_DEPTH_SLICE",
+ REALIZATION_SEISMIC_INLINE = "REALIZATION_SEISMIC_INLINE",
+ REALIZATION_SEISMIC_CROSSLINE = "REALIZATION_SEISMIC_CROSSLINE",
+ OBSERVED_SURFACE_2D = "OBSERVED_SURFACE_2D",
+ INTERSECTION_REALIZATION_GRID = "INTERSECTION_REALIZATION_GRID",
+ STATISTICAL_SURFACE_2D = "STATISTICAL_SURFACE_2D",
+ STATISTICAL_SURFACE_3D = "STATISTICAL_SURFACE_3D",
}
diff --git a/frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/DrilledWellTrajectoriesProvider.ts b/frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/DrilledWellTrajectoriesProvider.ts
index 8d91ebda2..8cabbfa9a 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/DrilledWellTrajectoriesProvider.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/DrilledWellTrajectoriesProvider.ts
@@ -5,7 +5,6 @@ import { getDrilledWellboreHeadersOptions, getWellTrajectoriesOptions } from "@a
import type { MakeSettingTypesMap } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions";
import { Setting } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions";
-
import type {
CustomDataProviderImplementation,
FetchDataParams,
diff --git a/frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/DrilledWellborePicksProvider.ts b/frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/DrilledWellborePicksProvider.ts
index d7c01a733..b50c4b8b1 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/DrilledWellborePicksProvider.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/DrilledWellborePicksProvider.ts
@@ -9,7 +9,6 @@ import {
import type { MakeSettingTypesMap } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions";
import { Setting } from "@modules/_shared/DataProviderFramework/settings/settingsDefinitions";
-
import type {
CustomDataProviderImplementation,
DataProviderInformationAccessors,
@@ -60,11 +59,9 @@ export class DrilledWellborePicksProvider
registerQueryKey(queryOptions.queryKey);
- const promise = queryClient
- .fetchQuery(queryOptions)
- .then((response: WellborePick_api[]) => {
- return response.filter((trajectory) => selectedWellboreUuids.includes(trajectory.wellboreUuid));
- });
+ const promise = queryClient.fetchQuery(queryOptions).then((response: WellborePick_api[]) => {
+ return response.filter((trajectory) => selectedWellboreUuids.includes(trajectory.wellboreUuid));
+ });
return promise;
}
diff --git a/frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/IntersectionRealizationGridProvider.ts b/frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/IntersectionRealizationGridProvider.ts
index 5a6e24635..e8965121c 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/IntersectionRealizationGridProvider.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/dataProviders/implementations/IntersectionRealizationGridProvider.ts
@@ -32,8 +32,10 @@ const intersectionRealizationGridSettings = [
Setting.GRID_NAME,
Setting.TIME_OR_INTERVAL,
Setting.SHOW_GRID_LINES,
+ Setting.COLOR_SCALE,
+ Setting.SHOW_GRID_LINES,
] as const;
-type IntersectionRealizationGridSettings = typeof intersectionRealizationGridSettings;
+export type IntersectionRealizationGridSettings = typeof intersectionRealizationGridSettings;
type SettingsWithTypes = MakeSettingTypesMap;
export type IntersectionRealizationGridData = PolylineIntersection_trans;
diff --git a/frontend/src/modules/_shared/DataProviderFramework/delegates/GroupDelegate.ts b/frontend/src/modules/_shared/DataProviderFramework/delegates/GroupDelegate.ts
index e6ac1fe27..863b9a3c5 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/delegates/GroupDelegate.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/delegates/GroupDelegate.ts
@@ -1,4 +1,3 @@
-
import type { PublishSubscribe } from "../../utils/PublishSubscribeDelegate";
import { PublishSubscribeDelegate } from "../../utils/PublishSubscribeDelegate";
import { DataProvider } from "../framework/DataProvider/DataProvider";
@@ -315,4 +314,11 @@ export class GroupDelegate implements PublishSubscribe implements PublishSubscribe
{
- private _owner: DataProvider;
private _customSettingsHandler: CustomSettingsHandler<
TSettings,
TStoredData,
@@ -73,17 +64,19 @@ export class SettingsContextDelegate<
TStoredDataKey
>;
private _dataProviderManager: DataProviderManager;
- private _settings: { [K in TSettingKey]: SettingManager } = {} as {
- [K in TSettingKey]: SettingManager;
+ private _settings: { [K in TSettingKey]: SettingManager } = {} as {
+ [K in TSettingKey]: SettingManager;
};
private _publishSubscribeDelegate = new PublishSubscribeDelegate();
private _unsubscribeHandler: UnsubscribeHandlerDelegate = new UnsubscribeHandlerDelegate();
private _status: SettingsContextStatus = SettingsContextStatus.LOADING;
private _storedData: NullableStoredData = {} as NullableStoredData;
+ private _storedDataLoadingStatus: { [K in TStoredDataKey]: boolean } = {} as {
+ [K in TStoredDataKey]: boolean;
+ };
private _dependencies: Dependency[] = [];
constructor(
- owner: DataProvider,
customSettingsHandler: CustomSettingsHandler<
TSettings,
TStoredData,
@@ -94,44 +87,37 @@ export class SettingsContextDelegate<
dataProviderManager: DataProviderManager,
settings: { [K in TSettingKey]: SettingManager },
) {
- this._owner = owner;
this._customSettingsHandler = customSettingsHandler;
this._dataProviderManager = dataProviderManager;
- for (const key in settings) {
+ this._settings = settings;
+
+ this._unsubscribeHandler.registerUnsubscribeFunction(
+ "dependencies",
+ this.getDataProviderManager()
+ .getPublishSubscribeDelegate()
+ .makeSubscriberFunction(DataProviderManagerTopic.GLOBAL_SETTINGS)(() => {
+ this.handleSettingChanged();
+ }),
+ );
+
+ for (const key in this._settings) {
this._unsubscribeHandler.registerUnsubscribeFunction(
"settings",
- settings[key].getPublishSubscribeDelegate().makeSubscriberFunction(SettingTopic.VALUE)(() => {
+ this._settings[key].getPublishSubscribeDelegate().makeSubscriberFunction(SettingTopic.VALUE)(() => {
this.handleSettingChanged();
}),
);
this._unsubscribeHandler.registerUnsubscribeFunction(
"settings",
- settings[key].getPublishSubscribeDelegate().makeSubscriberFunction(SettingTopic.IS_LOADING)(() => {
- this.handleSettingsLoadingStateChanged();
- }),
- );
- this._unsubscribeHandler.registerUnsubscribeFunction(
- "data-provider-manager",
- dataProviderManager
- .getPublishSubscribeDelegate()
- .makeSubscriberFunction(DataProviderManagerTopic.SHARED_SETTINGS_CHANGED)(() => {
- this.handleSharedSettingsChanged();
- }),
- );
-
- this._unsubscribeHandler.registerUnsubscribeFunction(
- "data-provider-manager",
- dataProviderManager
- .getPublishSubscribeDelegate()
- .makeSubscriberFunction(DataProviderManagerTopic.ITEMS)(() => {
- this.handleSharedSettingsChanged();
- }),
+ this._settings[key].getPublishSubscribeDelegate().makeSubscriberFunction(SettingTopic.IS_LOADING)(
+ () => {
+ this.handleSettingsLoadingStateChanged();
+ },
+ ),
);
}
- this._settings = settings;
-
this.createDependencies();
}
@@ -158,26 +144,6 @@ export class SettingsContextDelegate<
return settings;
}
- handleSharedSettingsChanged() {
- const parentGroup = this._owner.getItemDelegate().getParentGroup();
- if (!parentGroup) {
- return;
- }
-
- const sharedSettingsProviders: SharedSettingsProvider[] = parentGroup.getAncestorAndSiblingItems(
- (item) => item instanceof SharedSetting,
- ) as unknown as SharedSettingsProvider[];
-
- const ancestorGroups: SharedSettingsProvider[] = parentGroup.getAncestors(
- (item) => item instanceof Group && instanceofSharedSettingsProvider(item),
- ) as unknown as SharedSettingsProvider[];
- sharedSettingsProviders.push(...ancestorGroups);
-
- for (const key in this._settings) {
- this._settings[key].checkForOverrides(sharedSettingsProviders);
- }
- }
-
areCurrentSettingsValid(): boolean {
for (const key in this._settings) {
if (!this._settings[key].isValueValid()) {
@@ -208,6 +174,16 @@ export class SettingsContextDelegate<
return true;
}
+ isAllStoredDataLoaded(): boolean {
+ for (const key in this._storedDataLoadingStatus) {
+ if (this._storedDataLoadingStatus[key]) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
areAllSettingsInitialized(): boolean {
for (const key in this._settings) {
if (!this._settings[key].isInitialized() || this._settings[key].isPersistedValue()) {
@@ -247,19 +223,13 @@ export class SettingsContextDelegate<
setAvailableValues(key: K, availableValues: AvailableValuesType): void {
const settingDelegate = this._settings[key];
settingDelegate.setAvailableValues(availableValues);
-
- this.getDataProviderManager().publishTopic(DataProviderManagerTopic.AVAILABLE_SETTINGS_CHANGED);
}
- setStoredData(key: K, data: TStoredData[K] | null): void {
+ setStoredData(key: K, data: TStoredData[K] | null): void {
this._storedData[key] = data;
+ this._storedDataLoadingStatus[key] = false;
- if (!this.areAllDependenciesLoaded()) {
- this.setStatus(SettingsContextStatus.LOADING);
- return;
- }
-
- this._publishSubscribeDelegate.notifySubscribers(SettingsContextDelegateTopic.STORED_DATA_CHANGED);
+ this.handleSettingChanged();
}
getSettings() {
@@ -276,10 +246,7 @@ export class SettingsContextDelegate<
makeSnapshotGetter(topic: T): () => SettingsContextDelegatePayloads[T] {
const snapshotGetter = (): any => {
- if (topic === SettingsContextDelegateTopic.SETTINGS_CHANGED) {
- return;
- }
- if (topic === SettingsContextDelegateTopic.STORED_DATA_CHANGED) {
+ if (topic === SettingsContextDelegateTopic.SETTINGS_AND_STORED_DATA_CHANGED) {
return;
}
if (topic === SettingsContextDelegateTopic.STATUS) {
@@ -323,7 +290,8 @@ export class SettingsContextDelegate<
const makeLocalSettingGetter = (key: K, handler: (value: TSettingTypes[K]) => void) => {
const handleChange = (): void => {
- handler(this._settings[key].getValue() as unknown as TSettingTypes[K]);
+ const setting = this._settings[key];
+ handler(setting.getValue() as unknown as TSettingTypes[K]);
};
this._unsubscribeHandler.registerUnsubscribeFunction(
"dependencies",
@@ -332,6 +300,17 @@ export class SettingsContextDelegate<
),
);
+ this._unsubscribeHandler.registerUnsubscribeFunction(
+ "dependencies",
+ this._settings[key].getPublishSubscribeDelegate().makeSubscriberFunction(SettingTopic.IS_LOADING)(
+ () => {
+ if (!this._settings[key].isLoading()) {
+ handleChange();
+ }
+ },
+ ),
+ );
+
this._unsubscribeHandler.registerUnsubscribeFunction(
"dependencies",
this._settings[key].getPublishSubscribeDelegate().makeSubscriberFunction(SettingTopic.IS_PERSISTED)(
@@ -359,14 +338,28 @@ export class SettingsContextDelegate<
return handleChange;
};
+ const loadingStateGetter = (settingKey: K): boolean => {
+ return this._settings[settingKey].isLoading();
+ };
+
+ const localSettingManagerGetter = (key: K): SettingManager => {
+ return this._settings[key];
+ };
+
+ const globalSettingGetter = (key: K): GlobalSettings[K] => {
+ return this.getDataProviderManager.bind(this)().getGlobalSetting(key);
+ };
+
const availableSettingsUpdater = (
settingKey: K,
updateFunc: UpdateFunc, TSettings, TSettingTypes, TSettingKey>,
): Dependency, TSettings, TSettingTypes, TSettingKey> => {
const dependency = new Dependency, TSettings, TSettingTypes, TSettingKey>(
- this,
+ localSettingManagerGetter,
+ globalSettingGetter,
updateFunc,
makeLocalSettingGetter,
+ loadingStateGetter,
makeGlobalSettingGetter,
);
this._dependencies.push(dependency);
@@ -377,14 +370,14 @@ export class SettingsContextDelegate<
return;
}
this.setAvailableValues(settingKey, availableValues);
+ this.handleSettingChanged();
});
- dependency.subscribeLoading((loading: boolean, hasDependencies: boolean) => {
- this._settings[settingKey].setLoading(loading);
-
- if (!hasDependencies && !loading) {
- this.handleSettingChanged();
+ dependency.subscribeLoading((loading: boolean) => {
+ if (loading) {
+ this._settings[settingKey].setLoading(loading);
}
+ this.handleSettingChanged();
});
dependency.initialize();
@@ -397,9 +390,11 @@ export class SettingsContextDelegate<
updateFunc: UpdateFunc, TSettings, TSettingTypes, TSettingKey>,
): Dependency, TSettings, TSettingTypes, TSettingKey> => {
const dependency = new Dependency, TSettings, TSettingTypes, TSettingKey>(
- this,
+ localSettingManagerGetter,
+ globalSettingGetter,
updateFunc,
makeLocalSettingGetter,
+ loadingStateGetter,
makeGlobalSettingGetter,
);
this._dependencies.push(dependency);
@@ -425,13 +420,28 @@ export class SettingsContextDelegate<
TSettings,
TSettingTypes,
TSettingKey
- >(this, updateFunc, makeLocalSettingGetter, makeGlobalSettingGetter);
+ >(
+ localSettingManagerGetter,
+ globalSettingGetter,
+ updateFunc,
+ makeLocalSettingGetter,
+ loadingStateGetter,
+ makeGlobalSettingGetter,
+ );
this._dependencies.push(dependency);
dependency.subscribe((storedData: TStoredData[K] | null) => {
this.setStoredData(key, storedData);
});
+ dependency.subscribeLoading((loading: boolean) => {
+ if (loading) {
+ this._storedData[key] = null;
+ this._storedDataLoadingStatus[key] = loading;
+ this.handleSettingChanged();
+ }
+ });
+
dependency.initialize();
return dependency;
@@ -448,13 +458,19 @@ export class SettingsContextDelegate<
}) => T,
) => {
const dependency = new Dependency(
- this,
+ localSettingManagerGetter,
+ globalSettingGetter,
update,
makeLocalSettingGetter,
+ loadingStateGetter,
makeGlobalSettingGetter,
);
this._dependencies.push(dependency);
+ dependency.subscribeLoading(() => {
+ this.handleSettingChanged();
+ });
+
dependency.initialize();
return dependency;
@@ -475,6 +491,14 @@ export class SettingsContextDelegate<
beforeDestroy(): void {
this._unsubscribeHandler.unsubscribeAll();
+ for (const dependency of this._dependencies) {
+ dependency.beforeDestroy();
+ }
+ this._dependencies = [];
+ for (const key in this._settings) {
+ this._settings[key].beforeDestroy();
+ }
+ this._settings = {} as { [K in TSettingKey]: SettingManager };
}
private setStatus(status: SettingsContextStatus) {
@@ -487,18 +511,22 @@ export class SettingsContextDelegate<
}
private handleSettingChanged() {
- if (!this.areAllSettingsLoaded() || !this.areAllDependenciesLoaded() || !this.areAllSettingsInitialized()) {
+ if (!this.areAllSettingsLoaded() || !this.areAllDependenciesLoaded() || !this.isAllStoredDataLoaded()) {
this.setStatus(SettingsContextStatus.LOADING);
return;
}
- if (this.isSomePersistedSettingNotValid() || !this.areCurrentSettingsValid()) {
+ if (
+ this.isSomePersistedSettingNotValid() ||
+ !this.areCurrentSettingsValid() ||
+ !this.areAllSettingsInitialized()
+ ) {
this.setStatus(SettingsContextStatus.INVALID_SETTINGS);
return;
}
this.setStatus(SettingsContextStatus.VALID_SETTINGS);
- this._publishSubscribeDelegate.notifySubscribers(SettingsContextDelegateTopic.SETTINGS_CHANGED);
+ this._publishSubscribeDelegate.notifySubscribers(SettingsContextDelegateTopic.SETTINGS_AND_STORED_DATA_CHANGED);
}
private handleSettingsLoadingStateChanged() {
diff --git a/frontend/src/modules/_shared/DataProviderFramework/delegates/SharedSettingsDelegate.ts b/frontend/src/modules/_shared/DataProviderFramework/delegates/SharedSettingsDelegate.ts
index c0e6458f3..96cca6d15 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/delegates/SharedSettingsDelegate.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/delegates/SharedSettingsDelegate.ts
@@ -1,142 +1,184 @@
-
-import { DataProvider } from "../framework/DataProvider/DataProvider";
-import { DataProviderManagerTopic } from "../framework/DataProviderManager/DataProviderManager";
-import { Group } from "../framework/Group/Group";
-import type { SettingManager } from "../framework/SettingManager/SettingManager";
-import { SettingTopic } from "../framework/SettingManager/SettingManager";
+import { DataProviderManagerTopic, type GlobalSettings } from "../framework/DataProviderManager/DataProviderManager";
+import { ExternalSettingController } from "../framework/ExternalSettingController/ExternalSettingController";
+import { SettingTopic, type SettingManager } from "../framework/SettingManager/SettingManager";
+import type {
+ DefineBasicDependenciesArgs,
+ SettingAttributes,
+ UpdateFunc,
+} from "../interfacesAndTypes/customSettingsHandler";
import type { Item } from "../interfacesAndTypes/entities";
-import type { AvailableValuesType, SettingsKeysFromTuple } from "../interfacesAndTypes/utils";
-import type { SettingTypes, Settings } from "../settings/settingsDefinitions";
-import { settingCategoryAvailableValuesIntersectionReducerMap } from "../settings/settingsDefinitions";
+import type { SettingsKeysFromTuple } from "../interfacesAndTypes/utils";
+import type { MakeSettingTypesMap, SettingTypes, Settings } from "../settings/settingsDefinitions";
+import { Dependency } from "./_utils/Dependency";
import { UnsubscribeHandlerDelegate } from "./UnsubscribeHandlerDelegate";
export class SharedSettingsDelegate<
TSettings extends Settings,
+ TSettingTypes extends MakeSettingTypesMap = MakeSettingTypesMap,
TSettingKey extends SettingsKeysFromTuple = SettingsKeysFromTuple,
> {
- private _parentItem: Item;
- private _wrappedSettings: { [K in TSettingKey]: SettingManager } = {} as {
- [K in TSettingKey]: SettingManager;
+ private _externalSettingControllers: { [K in TSettingKey]: ExternalSettingController } = {} as {
+ [K in TSettingKey]: ExternalSettingController;
+ };
+ private _wrappedSettings: { [K in TSettingKey]: SettingManager } = {} as {
+ [K in TSettingKey]: SettingManager;
};
private _unsubscribeHandler: UnsubscribeHandlerDelegate = new UnsubscribeHandlerDelegate();
-
- constructor(parentItem: Item, wrappedSettings: { [K in TSettingKey]: SettingManager }) {
+ private _dependencies: Dependency[] = [];
+ private _parentItem: Item;
+ private _customDependenciesDefinition:
+ | ((args: DefineBasicDependenciesArgs) => void)
+ | null = null;
+
+ constructor(
+ parentItem: Item,
+ wrappedSettings: { [K in TSettingKey]: SettingManager },
+ customDependenciesDefinition?: (
+ args: DefineBasicDependenciesArgs,
+ ) => void,
+ ) {
this._wrappedSettings = wrappedSettings;
this._parentItem = parentItem;
+ this._customDependenciesDefinition = customDependenciesDefinition ?? null;
+
+ for (const key in wrappedSettings) {
+ const setting = wrappedSettings[key];
+ const externalSettingController = new ExternalSettingController(parentItem, setting);
+ this._externalSettingControllers[key] = externalSettingController;
+ }
const dataProviderManager = parentItem.getItemDelegate().getDataProviderManager();
if (!dataProviderManager) {
throw new Error("SharedSettingDelegate must have a parent item with a data provider manager.");
}
- for (const key in wrappedSettings) {
- this._unsubscribeHandler.registerUnsubscribeFunction(
- "setting",
- wrappedSettings[key].getPublishSubscribeDelegate().makeSubscriberFunction(SettingTopic.VALUE)(() => {
- this.publishValueChange();
- }),
- );
- }
-
- this._unsubscribeHandler.registerUnsubscribeFunction(
- "data-provider-manager",
- dataProviderManager.getPublishSubscribeDelegate().makeSubscriberFunction(DataProviderManagerTopic.ITEMS)(
- () => {
- this.makeIntersectionOfAvailableValues();
- },
- ),
- );
- this._unsubscribeHandler.registerUnsubscribeFunction(
- "data-provider-manager",
- dataProviderManager
- .getPublishSubscribeDelegate()
- .makeSubscriberFunction(DataProviderManagerTopic.SETTINGS_CHANGED)(() => {
- this.makeIntersectionOfAvailableValues();
- }),
- );
- this._unsubscribeHandler.registerUnsubscribeFunction(
- "data-provider-manager",
- dataProviderManager
- .getPublishSubscribeDelegate()
- .makeSubscriberFunction(DataProviderManagerTopic.AVAILABLE_SETTINGS_CHANGED)(() => {
- this.makeIntersectionOfAvailableValues();
- }),
- );
+ this.createDependencies();
}
- getWrappedSettings(): { [K in TSettingKey]: SettingManager } {
+ getWrappedSettings(): { [K in TSettingKey]: SettingManager } {
return this._wrappedSettings;
}
- publishValueChange(): void {
- const dataProviderManager = this._parentItem.getItemDelegate().getDataProviderManager();
- if (dataProviderManager) {
- dataProviderManager.publishTopic(DataProviderManagerTopic.SHARED_SETTINGS_CHANGED);
- }
+ unsubscribeAll(): void {
+ this._unsubscribeHandler.unsubscribeAll();
}
- private makeIntersectionOfAvailableValues(): void {
- let parentGroup = this._parentItem.getItemDelegate().getParentGroup();
- if (this._parentItem instanceof Group) {
- parentGroup = this._parentItem.getGroupDelegate();
+ beforeDestroy(): void {
+ this._unsubscribeHandler.unsubscribeAll();
+ for (const key in this._externalSettingControllers) {
+ const externalSettingController = this._externalSettingControllers[key];
+ externalSettingController.beforeDestroy();
}
-
- if (!parentGroup) {
- return;
+ for (const key in this._wrappedSettings) {
+ const setting = this._wrappedSettings[key];
+ setting.beforeDestroy();
+ }
+ for (const dependency of this._dependencies) {
+ dependency.beforeDestroy();
}
+ this._dependencies = [];
+ }
- const providers = parentGroup.getDescendantItems((item) => item instanceof DataProvider) as DataProvider<
- any,
- any
- >[];
- const availableValuesMap: { [K in TSettingKey]: AvailableValuesType } = {} as {
- [K in TSettingKey]: AvailableValuesType;
- };
- const indices: { [K in TSettingKey]: number } = {} as { [K in TSettings[number]]: number };
-
- for (const provider of providers) {
- for (const key in this._wrappedSettings) {
- const wrappedSetting = this._wrappedSettings[key];
- const category = wrappedSetting.getCategory();
- const index = indices[key] ?? 0;
- const setting = provider.getSettingsContextDelegate().getSettings()[wrappedSetting.getType()];
- if (setting) {
- if (setting.isLoading()) {
- wrappedSetting.setLoading(true);
- continue;
- }
+ createDependencies(): void {
+ this._unsubscribeHandler.unsubscribe("dependencies");
- if (setting.getAvailableValues() === null) {
- continue;
- }
+ this._dependencies = [];
- const reducerDefinition = settingCategoryAvailableValuesIntersectionReducerMap[category];
- if (reducerDefinition) {
- const { reducer, startingValue } = reducerDefinition;
- if (index === 0) {
- availableValuesMap[key] = startingValue as AvailableValuesType;
- }
- availableValuesMap[key] = reducer(
- availableValuesMap[key] as any,
- setting.getAvailableValues(),
- index,
- ) as AvailableValuesType;
+ const makeLocalSettingGetter = (key: K, handler: (value: TSettingTypes[K]) => void) => {
+ const handleChange = (): void => {
+ const setting = this._wrappedSettings[key];
+ handler(setting.getValue() as unknown as TSettingTypes[K]);
+ };
+ this._unsubscribeHandler.registerUnsubscribeFunction(
+ "dependencies",
+ this._wrappedSettings[key].getPublishSubscribeDelegate().makeSubscriberFunction(SettingTopic.VALUE)(
+ handleChange,
+ ),
+ );
+
+ this._unsubscribeHandler.registerUnsubscribeFunction(
+ "dependencies",
+ this._wrappedSettings[key]
+ .getPublishSubscribeDelegate()
+ .makeSubscriberFunction(SettingTopic.IS_LOADING)(() => {
+ if (!this._wrappedSettings[key].isLoading()) {
+ handleChange();
}
- indices[key] = index + 1;
+ }),
+ );
+
+ this._unsubscribeHandler.registerUnsubscribeFunction(
+ "dependencies",
+ this._wrappedSettings[key]
+ .getPublishSubscribeDelegate()
+ .makeSubscriberFunction(SettingTopic.IS_PERSISTED)(handleChange),
+ );
+
+ return handleChange;
+ };
+
+ const makeGlobalSettingGetter = (
+ key: K,
+ handler: (value: GlobalSettings[K]) => void,
+ ) => {
+ const handleChange = (): void => {
+ handler(this._parentItem.getItemDelegate().getDataProviderManager().getGlobalSetting(key));
+ };
+ this._unsubscribeHandler.registerUnsubscribeFunction(
+ "dependencies",
+ this._parentItem
+ .getItemDelegate()
+ .getDataProviderManager()
+ .getPublishSubscribeDelegate()
+ .makeSubscriberFunction(DataProviderManagerTopic.GLOBAL_SETTINGS)(handleChange),
+ );
+
+ return handleChange.bind(this);
+ };
+
+ const loadingStateGetter = (settingKey: K): boolean => {
+ return this._wrappedSettings[settingKey].isLoading();
+ };
+
+ const localSettingManagerGetter = (key: K): SettingManager => {
+ return this._wrappedSettings[key];
+ };
+
+ const globalSettingGetter = (key: K): GlobalSettings[K] => {
+ return this._parentItem.getItemDelegate().getDataProviderManager().getGlobalSetting(key);
+ };
+
+ const settingAttributesUpdater = (
+ settingKey: K,
+ updateFunc: UpdateFunc, TSettings, TSettingTypes, TSettingKey>,
+ ): Dependency, TSettings, TSettingTypes, TSettingKey> => {
+ const dependency = new Dependency, TSettings, TSettingTypes, TSettingKey>(
+ localSettingManagerGetter.bind(this),
+ globalSettingGetter.bind(this),
+ updateFunc,
+ makeLocalSettingGetter,
+ loadingStateGetter,
+ makeGlobalSettingGetter,
+ );
+ this._dependencies.push(dependency);
+
+ dependency.subscribe((attributes: Partial | null) => {
+ if (attributes === null) {
+ return;
}
- }
- }
+ this._wrappedSettings[settingKey].updateAttributes(attributes);
+ });
- for (const key in this._wrappedSettings) {
- const wrappedSetting = this._wrappedSettings[key];
- wrappedSetting.setLoading(false);
- wrappedSetting.setAvailableValues(availableValuesMap[key] ?? []);
- this.publishValueChange();
- }
- }
+ dependency.initialize();
- unsubscribeAll(): void {
- this._unsubscribeHandler.unsubscribeAll();
+ return dependency;
+ };
+
+ if (this._customDependenciesDefinition) {
+ this._customDependenciesDefinition({
+ settingAttributesUpdater: settingAttributesUpdater.bind(this),
+ });
+ }
}
}
diff --git a/frontend/src/modules/_shared/DataProviderFramework/delegates/_utils/Dependency.ts b/frontend/src/modules/_shared/DataProviderFramework/delegates/_utils/Dependency.ts
index 56e520342..ca769ac98 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/delegates/_utils/Dependency.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/delegates/_utils/Dependency.ts
@@ -1,13 +1,12 @@
import { isCancelledError } from "@tanstack/react-query";
-import { isEqual } from "lodash";
import type { GlobalSettings } from "../../framework/DataProviderManager/DataProviderManager";
-import { SettingTopic } from "../../framework/SettingManager/SettingManager";
-import { CancelUpdate } from "../../interfacesAndTypes/customSettingsHandler";
+import { SettingTopic, type SettingManager } from "../../framework/SettingManager/SettingManager";
import type { UpdateFunc } from "../../interfacesAndTypes/customSettingsHandler";
import type { SettingsKeysFromTuple } from "../../interfacesAndTypes/utils";
import type { MakeSettingTypesMap, Settings } from "../../settings/settingsDefinitions";
-import type { SettingsContextDelegate } from "../SettingsContextDelegate";
+
+class DependencyLoadingError extends Error {}
/*
* Dependency class is used to represent a node in the dependency graph of a data provider settings context.
@@ -31,9 +30,11 @@ export class Dependency<
private _loadingDependencies: Set<(loading: boolean, hasDependencies: boolean) => void> = new Set();
private _isLoading = false;
- private _contextDelegate: SettingsContextDelegate;
+ private _localSettingManagerGetter: (key: K) => SettingManager;
+ private _globalSettingGetter: (key: K) => GlobalSettings[K];
private _makeLocalSettingGetter: (key: K, handler: (value: TSettingTypes[K]) => void) => void;
+ private _localSettingLoadingStateGetter: (key: K) => boolean;
private _makeGlobalSettingGetter: (
key: K,
handler: (value: GlobalSettings[K]) => void,
@@ -48,17 +49,21 @@ export class Dependency<
private _numChildDependencies = 0;
constructor(
- contextDelegate: SettingsContextDelegate,
+ localSettingManagerGetter: (key: K) => SettingManager,
+ globalSettingGetter: (key: K) => GlobalSettings[K],
updateFunc: UpdateFunc,
makeLocalSettingGetter: (key: K, handler: (value: TSettingTypes[K]) => void) => void,
+ localSettingLoadingStateGetter: (key: K) => boolean,
makeGlobalSettingGetter: (
key: K,
handler: (value: GlobalSettings[K]) => void,
) => void,
) {
- this._contextDelegate = contextDelegate;
+ this._localSettingManagerGetter = localSettingManagerGetter;
+ this._globalSettingGetter = globalSettingGetter;
this._updateFunc = updateFunc;
this._makeLocalSettingGetter = makeLocalSettingGetter;
+ this._localSettingLoadingStateGetter = localSettingLoadingStateGetter;
this._makeGlobalSettingGetter = makeGlobalSettingGetter;
this.getGlobalSetting = this.getGlobalSetting.bind(this);
@@ -66,6 +71,13 @@ export class Dependency<
this.getHelperDependency = this.getHelperDependency.bind(this);
}
+ beforeDestroy() {
+ this._abortController?.abort();
+ this._abortController = null;
+ this._dependencies.clear();
+ this._loadingDependencies.clear();
+ }
+
hasChildDependencies(): boolean {
return this._numChildDependencies > 0;
}
@@ -106,19 +118,23 @@ export class Dependency<
this._numParentDependencies++;
}
+ if (this._localSettingLoadingStateGetter(settingName)) {
+ throw new DependencyLoadingError("Setting is loading");
+ }
+
// If the dependency has already subscribed to this setting, return the cached value
// that is updated when the setting changes
if (this._cachedSettingsMap.has(settingName as string)) {
return this._cachedSettingsMap.get(settingName as string);
}
- const setting = this._contextDelegate.getSettings()[settingName];
+ const setting = this._localSettingManagerGetter(settingName);
const value = setting.getValue();
this._cachedSettingsMap.set(settingName as string, value);
this._makeLocalSettingGetter(settingName, (value) => {
this._cachedSettingsMap.set(settingName as string, value);
- this.callUpdateFunc();
+ this.invalidate();
});
setting.getPublishSubscribeDelegate().makeSubscriberFunction(SettingTopic.IS_LOADING)(() => {
@@ -157,18 +173,11 @@ export class Dependency<
}
this._makeGlobalSettingGetter(settingName, (value) => {
- const cachedValue = this._cachedGlobalSettingsMap.get(settingName as string);
- if (isEqual(value, cachedValue)) {
- return;
- }
this._cachedGlobalSettingsMap.set(settingName as string, value);
- this.callUpdateFunc();
+ this.invalidate();
});
- this._cachedGlobalSettingsMap.set(
- settingName as string,
- this._contextDelegate.getDataProviderManager().getGlobalSetting(settingName),
- );
+ this._cachedGlobalSettingsMap.set(settingName as string, this._globalSettingGetter(settingName));
return this._cachedGlobalSettingsMap.get(settingName as string);
}
@@ -177,6 +186,10 @@ export class Dependency<
this._numParentDependencies++;
}
+ if (dep.getIsLoading()) {
+ throw new DependencyLoadingError("Dependency is loading");
+ }
+
if (this._cachedDependenciesMap.has(dep)) {
return this._cachedDependenciesMap.get(dep);
}
@@ -186,7 +199,7 @@ export class Dependency<
dep.subscribe((newValue) => {
this._cachedDependenciesMap.set(dep, newValue);
- this.callUpdateFunc();
+ this.invalidate();
}, true);
dep.subscribeLoading((loading) => {
@@ -220,6 +233,13 @@ export class Dependency<
this._isInitialized = true;
}
+ private invalidate(): void {
+ if (!this._isLoading) {
+ this.setLoadingState(true);
+ }
+ this.callUpdateFunc();
+ }
+
private async callUpdateFunc() {
if (this._abortController) {
this._abortController.abort();
@@ -228,9 +248,7 @@ export class Dependency<
this._abortController = new AbortController();
- this.setLoadingState(true);
-
- let newValue: Awaited | null | typeof CancelUpdate = null;
+ let newValue: Awaited | null = null;
try {
newValue = await this._updateFunc({
getLocalSetting: this.getLocalSetting,
@@ -239,6 +257,10 @@ export class Dependency<
abortSignal: this._abortController.signal,
});
} catch (e: any) {
+ if (e instanceof DependencyLoadingError) {
+ return;
+ }
+
if (!isCancelledError(e)) {
this.applyNewValue(null);
return;
@@ -246,20 +268,14 @@ export class Dependency<
return;
}
- if (newValue === CancelUpdate) {
- return;
- }
-
this.applyNewValue(newValue);
}
private applyNewValue(newValue: Awaited | null) {
this.setLoadingState(false);
- if (!isEqual(newValue, this._cachedValue) || newValue === null) {
- this._cachedValue = newValue;
- for (const callback of this._dependencies) {
- callback(newValue);
- }
+ this._cachedValue = newValue;
+ for (const callback of this._dependencies) {
+ callback(newValue);
}
}
}
diff --git a/frontend/src/modules/_shared/DataProviderFramework/framework/DataProvider/DataProvider.ts b/frontend/src/modules/_shared/DataProviderFramework/framework/DataProvider/DataProvider.ts
index a7b1d51de..977da28a3 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/framework/DataProvider/DataProvider.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/framework/DataProvider/DataProvider.ts
@@ -31,6 +31,7 @@ export enum DataProviderTopic {
STATUS = "STATUS",
DATA = "DATA",
SUBORDINATED = "SUBORDINATED",
+ REVISION_NUMBER = "REVISION_NUMBER",
}
export enum DataProviderStatus {
@@ -45,6 +46,7 @@ export type DataProviderPayloads = {
[DataProviderTopic.STATUS]: DataProviderStatus;
[DataProviderTopic.DATA]: TData;
[DataProviderTopic.SUBORDINATED]: boolean;
+ [DataProviderTopic.REVISION_NUMBER]: number;
};
export function isDataProvider(obj: any): obj is DataProvider {
@@ -116,10 +118,13 @@ export class DataProvider<
private _status: DataProviderStatus = DataProviderStatus.IDLE;
private _data: TData | null = null;
private _error: StatusMessage | string | null = null;
- private _valueRange: [number, number] | null = null;
+ private _valueRange: readonly [number, number] | null = null;
private _isSubordinated: boolean = false;
private _prevSettings: TSettingTypes | null = null;
private _prevStoredData: NullableStoredData | null = null;
+ private _currentTransactionId: number = 0;
+ private _settingsErrorMessages: string[] = [];
+ private _revisionNumber: number = 0;
constructor(params: DataProviderParams) {
const {
@@ -131,7 +136,6 @@ export class DataProvider<
this._type = type;
this._dataProviderManager = dataProviderManager;
this._settingsContextDelegate = new SettingsContextDelegate(
- this,
customDataProviderImplementation,
dataProviderManager,
makeSettings(
@@ -150,16 +154,7 @@ export class DataProvider<
"settings-context",
this._settingsContextDelegate
.getPublishSubscribeDelegate()
- .makeSubscriberFunction(SettingsContextDelegateTopic.SETTINGS_CHANGED)(() => {
- this.handleSettingsAndStoredDataChange();
- }),
- );
-
- this._unsubscribeHandler.registerUnsubscribeFunction(
- "settings-context",
- this._settingsContextDelegate
- .getPublishSubscribeDelegate()
- .makeSubscriberFunction(SettingsContextDelegateTopic.STORED_DATA_CHANGED)(() => {
+ .makeSubscriberFunction(SettingsContextDelegateTopic.SETTINGS_AND_STORED_DATA_CHANGED)(() => {
this.handleSettingsAndStoredDataChange();
}),
);
@@ -172,15 +167,6 @@ export class DataProvider<
this.handleSettingsStatusChange();
}),
);
-
- this._unsubscribeHandler.registerUnsubscribeFunction(
- "data-provider-manager",
- dataProviderManager
- .getPublishSubscribeDelegate()
- .makeSubscriberFunction(DataProviderManagerTopic.GLOBAL_SETTINGS)(() => {
- this.handleSettingsAndStoredDataChange();
- }),
- );
}
areCurrentSettingsValid(): boolean {
@@ -188,10 +174,23 @@ export class DataProvider<
return true;
}
- return this._customDataProviderImpl.areCurrentSettingsValid(this.makeAccessors());
+ this._settingsErrorMessages = [];
+ const reportError = (message: string) => {
+ this._settingsErrorMessages.push(message);
+ };
+ return this._customDataProviderImpl.areCurrentSettingsValid({ ...this.makeAccessors(), reportError });
+ }
+
+ getSettingsErrorMessages(): string[] {
+ return this._settingsErrorMessages;
}
handleSettingsAndStoredDataChange(): void {
+ if (this._settingsContextDelegate.getStatus() === SettingsContextStatus.LOADING) {
+ this.setStatus(DataProviderStatus.LOADING);
+ return;
+ }
+
if (!this.areCurrentSettingsValid()) {
this._error = "Invalid settings";
this.setStatus(DataProviderStatus.INVALID_SETTINGS);
@@ -223,17 +222,27 @@ export class DataProvider<
}
if (!refetchRequired) {
- this._publishSubscribeDelegate.notifySubscribers(DataProviderTopic.DATA);
- this._dataProviderManager.publishTopic(DataProviderManagerTopic.DATA_REVISION);
+ // If the settings have changed but no refetch is required, it might be that the settings changes
+ // still require a rerender of the data provider.
+ if (this._status === DataProviderStatus.SUCCESS) {
+ this.incrementRevisionNumber();
+ return;
+ }
this.setStatus(DataProviderStatus.SUCCESS);
return;
}
this._cancellationPending = true;
- this._prevSettings = clone(this._settingsContextDelegate.getValues()) as TSettingTypes;
- this._prevStoredData = clone(this._settingsContextDelegate.getStoredDataRecord()) as TStoredData;
+
+ // It might be that we started a new transaction while the previous one was still running.
+ // In this case, we need to make sure that we only use the latest transaction and cancel the previous one.
+ this._currentTransactionId += 1;
+
this.maybeCancelQuery().then(() => {
- this.maybeRefetchData();
+ this.maybeRefetchData().then(() => {
+ this._prevSettings = clone(this._settingsContextDelegate.getValues()) as TSettingTypes;
+ this._prevStoredData = clone(this._settingsContextDelegate.getStoredDataRecord()) as TStoredData;
+ });
});
}
@@ -286,7 +295,7 @@ export class DataProvider<
this._publishSubscribeDelegate.notifySubscribers(DataProviderTopic.SUBORDINATED);
}
- getValueRange(): [number, number] | null {
+ getValueRange(): readonly [number, number] | null {
return this._valueRange;
}
@@ -305,6 +314,9 @@ export class DataProvider<
if (topic === DataProviderTopic.SUBORDINATED) {
return this._isSubordinated;
}
+ if (topic === DataProviderTopic.REVISION_NUMBER) {
+ return this._revisionNumber;
+ }
};
return snapshotGetter;
@@ -345,6 +357,8 @@ export class DataProvider<
}
async maybeRefetchData(): Promise {
+ const thisTransactionId = this._currentTransactionId;
+
const queryClient = this.getQueryClient();
if (!queryClient) {
@@ -371,6 +385,15 @@ export class DataProvider<
queryClient,
registerQueryKey: (key) => this.registerQueryKey(key),
});
+
+ // This is a security check to make sure that we are not using a stale transaction id.
+ // This can happen if the transaction id is incremented while the async fetch data function is still running.
+ // Queries are cancelled in the maybeCancelQuery function and should, hence, throw a cancelled error.
+ // However, there might me some operations following after the query execution that are not cancelled.
+ if (this._currentTransactionId !== thisTransactionId) {
+ return;
+ }
+
if (this._customDataProviderImpl.makeValueRange) {
this._valueRange = this._customDataProviderImpl.makeValueRange(accessors);
}
@@ -381,7 +404,6 @@ export class DataProvider<
}
this._queryKeys = [];
this._publishSubscribeDelegate.notifySubscribers(DataProviderTopic.DATA);
- this._dataProviderManager.publishTopic(DataProviderManagerTopic.DATA_REVISION);
this.setStatus(DataProviderStatus.SUCCESS);
} catch (error: any) {
if (isCancelledError(error)) {
@@ -391,7 +413,7 @@ export class DataProvider<
if (apiError) {
this._error = apiError.makeStatusMessage();
} else {
- this._error = "An error occurred";
+ this._error = error.message;
}
this.setStatus(DataProviderStatus.ERROR);
}
@@ -417,13 +439,19 @@ export class DataProvider<
this._unsubscribeHandler.unsubscribeAll();
}
+ private incrementRevisionNumber(): void {
+ this._revisionNumber += 1;
+ this._publishSubscribeDelegate.notifySubscribers(DataProviderTopic.REVISION_NUMBER);
+ this._dataProviderManager.publishTopic(DataProviderManagerTopic.DATA_REVISION);
+ }
+
private setStatus(status: DataProviderStatus): void {
if (this._status === status) {
return;
}
this._status = status;
- this._dataProviderManager.publishTopic(DataProviderManagerTopic.DATA_REVISION);
+ this.incrementRevisionNumber();
this._publishSubscribeDelegate.notifySubscribers(DataProviderTopic.STATUS);
}
diff --git a/frontend/src/modules/_shared/DataProviderFramework/framework/DataProvider/DataProviderComponent.tsx b/frontend/src/modules/_shared/DataProviderFramework/framework/DataProvider/DataProviderComponent.tsx
index 7a8ddd28e..577dada85 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/framework/DataProvider/DataProviderComponent.tsx
+++ b/frontend/src/modules/_shared/DataProviderFramework/framework/DataProvider/DataProviderComponent.tsx
@@ -8,11 +8,10 @@ import { DenseIconButton } from "@lib/components/DenseIconButton";
import { SortableListItem } from "@lib/components/SortableList";
import { resolveClassNames } from "@lib/utils/resolveClassNames";
-
import { usePublishSubscribeTopicValue } from "../../../utils/PublishSubscribeDelegate";
import { ItemDelegateTopic } from "../../delegates/ItemDelegate";
import type { SettingManager } from "../SettingManager/SettingManager";
-import { SettingComponent } from "../SettingManager/SettingManagerComponent";
+import { SettingManagerComponent } from "../SettingManager/SettingManagerComponent";
import { EditName } from "../utilityComponents/EditName";
import { RemoveItemButton } from "../utilityComponents/RemoveItemButton";
import { VisibilityToggle } from "../utilityComponents/VisibilityToggle";
@@ -32,7 +31,9 @@ export function DataProviderComponent(props: DataProviderComponentProps): React.
if (!manager) {
return null;
}
- return ;
+ return (
+
+ );
}
function makeSettings(settings: Record>): React.ReactNode[] {
@@ -123,13 +124,22 @@ function EndActions(props: EndActionProps): React.ReactNode {
}
}
if (status === DataProviderStatus.INVALID_SETTINGS) {
+ let errorMessage = "Invalid settings";
+ const invalidSettings = props.dataProvider.getSettingsContextDelegate().getInvalidSettings();
+
+ if (invalidSettings.length > 0) {
+ errorMessage += `: ${invalidSettings.join(", ")}`;
+ }
+ errorMessage += ".";
+
+ const customReportedErrors = props.dataProvider.getSettingsErrorMessages();
+ if (customReportedErrors.length > 0) {
+ errorMessage += `\n${customReportedErrors.join("\n")}`;
+ }
+ errorMessage += "\nPlease check the settings.";
+
return (
-
+
);
diff --git a/frontend/src/modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager.ts b/frontend/src/modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager.ts
index 60e46f7f8..3ac86855d 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager.ts
@@ -12,7 +12,6 @@ import {
import type { WorkbenchSettings } from "@framework/WorkbenchSettings";
import { ColorPaletteType } from "@framework/WorkbenchSettings";
-
import type { PublishSubscribe } from "../../../utils/PublishSubscribeDelegate";
import { PublishSubscribeDelegate } from "../../../utils/PublishSubscribeDelegate";
import { GroupDelegate, GroupDelegateTopic } from "../../delegates/GroupDelegate";
@@ -24,26 +23,22 @@ import { type SerializedDataProviderManager, SerializedType } from "../../interf
export enum DataProviderManagerTopic {
ITEMS = "ITEMS",
SETTINGS_CHANGED = "SETTINGS_CHANGED",
- AVAILABLE_SETTINGS_CHANGED = "AVAILABLE_SETTINGS_CHANGED",
DATA_REVISION = "DATA_REVISION",
GLOBAL_SETTINGS = "GLOBAL_SETTINGS",
- SHARED_SETTINGS_CHANGED = "SHARED_SETTINGS_CHANGED",
}
export type DataProviderManagerTopicPayload = {
[DataProviderManagerTopic.ITEMS]: Item[];
[DataProviderManagerTopic.SETTINGS_CHANGED]: void;
- [DataProviderManagerTopic.AVAILABLE_SETTINGS_CHANGED]: void;
[DataProviderManagerTopic.DATA_REVISION]: number;
[DataProviderManagerTopic.GLOBAL_SETTINGS]: GlobalSettings;
- [DataProviderManagerTopic.SHARED_SETTINGS_CHANGED]: void;
};
export type GlobalSettings = {
fieldId: string | null;
ensembles: readonly RegularEnsemble[];
realizationFilterFunction: EnsembleRealizationFilterFunction;
- intersectionPolylines: IntersectionPolyline[];
+ intersectionPolylines: readonly IntersectionPolyline[];
};
/*
@@ -164,18 +159,12 @@ export class DataProviderManager implements ItemGroup, PublishSubscribe
{
+ .makeSubscriberFunction(SettingsContextDelegateTopic.SETTINGS_AND_STORED_DATA_CHANGED)(() => {
this.handleSettingsChange();
}),
);
diff --git a/frontend/src/modules/_shared/DataProviderFramework/framework/ExternalSettingController/ExternalSettingController.ts b/frontend/src/modules/_shared/DataProviderFramework/framework/ExternalSettingController/ExternalSettingController.ts
new file mode 100644
index 000000000..afc2e342b
--- /dev/null
+++ b/frontend/src/modules/_shared/DataProviderFramework/framework/ExternalSettingController/ExternalSettingController.ts
@@ -0,0 +1,162 @@
+import { UnsubscribeHandlerDelegate } from "../../delegates/UnsubscribeHandlerDelegate";
+import type { Item } from "../../interfacesAndTypes/entities";
+import type { AvailableValuesType } from "../../interfacesAndTypes/utils";
+import {
+ type Setting,
+ type SettingCategories,
+ type SettingTypes,
+ settingCategoryAvailableValuesIntersectionReducerMap,
+} from "../../settings/settingsDefinitions";
+import { DataProvider } from "../DataProvider/DataProvider";
+import { DataProviderManagerTopic } from "../DataProviderManager/DataProviderManager";
+import { Group } from "../Group/Group";
+import type { SettingManager } from "../SettingManager/SettingManager";
+
+export class ExternalSettingController<
+ TSetting extends Setting,
+ TValue extends SettingTypes[TSetting] | null = SettingTypes[TSetting] | null,
+ TCategory extends SettingCategories[TSetting] = SettingCategories[TSetting],
+> {
+ private _parentItem: Item;
+ private _setting: SettingManager;
+ private _controlledSettings: Map> = new Map();
+ private _availableValuesMap: Map> = new Map();
+ private _unsubscribeHandler: UnsubscribeHandlerDelegate = new UnsubscribeHandlerDelegate();
+
+ constructor(parentItem: Item, setting: SettingManager) {
+ this._parentItem = parentItem;
+ this._setting = setting;
+
+ const dataProviderManager = parentItem.getItemDelegate().getDataProviderManager();
+ this._unsubscribeHandler.registerUnsubscribeFunction(
+ "data-provider-manager",
+ dataProviderManager.getPublishSubscribeDelegate().makeSubscriberFunction(DataProviderManagerTopic.ITEMS)(
+ () => {
+ this.updateControlledSettings();
+ },
+ ),
+ );
+ }
+
+ beforeDestroy(): void {
+ this._unsubscribeHandler.unsubscribeAll();
+ this.unregisterAllControlledSettings();
+ }
+
+ getParentItem(): Item {
+ return this._parentItem;
+ }
+
+ registerSetting(settingManager: SettingManager): void {
+ this._controlledSettings.set(settingManager.getId(), settingManager);
+ settingManager.registerExternalSettingController(this);
+ }
+
+ getSetting(): SettingManager {
+ return this._setting;
+ }
+
+ private updateControlledSettings(): void {
+ const oldControlledSettings = new Map(this._controlledSettings);
+ this._controlledSettings.clear();
+ this._availableValuesMap.clear();
+
+ let parentGroup = this._parentItem.getItemDelegate().getParentGroup();
+ if (this._parentItem instanceof Group) {
+ parentGroup = this._parentItem.getGroupDelegate();
+ }
+
+ if (!parentGroup) {
+ return;
+ }
+
+ const providers = parentGroup.getDescendantItems((item) => item instanceof DataProvider) as DataProvider<
+ any,
+ any
+ >[];
+
+ for (const provider of providers) {
+ const setting = provider.getSettingsContextDelegate().getSettings()[this._setting.getType()];
+ if (setting) {
+ this._controlledSettings.set(setting.getId(), setting);
+ this._availableValuesMap.set(setting.getId(), setting.getAvailableValues());
+ setting.registerExternalSettingController(this);
+ }
+ }
+
+ for (const settingId of oldControlledSettings.keys()) {
+ if (!this._controlledSettings.has(settingId)) {
+ const setting = oldControlledSettings.get(settingId);
+ if (setting) {
+ setting.unregisterExternalSettingController();
+ }
+ }
+ }
+
+ if (this._controlledSettings.size === 0) {
+ this._setting.setAvailableValues(null);
+ return;
+ }
+
+ this.makeIntersectionOfAvailableValues();
+ }
+
+ unregisterAllControlledSettings(): void {
+ for (const setting of this._controlledSettings.values()) {
+ setting.unregisterExternalSettingController();
+ }
+ this._controlledSettings.clear();
+ this._availableValuesMap.clear();
+ }
+
+ setAvailableValues(settingId: string, availableValues: AvailableValuesType | null): void {
+ if (availableValues) {
+ this._availableValuesMap.set(settingId, availableValues);
+ } else {
+ this._availableValuesMap.delete(settingId);
+ }
+
+ this.makeIntersectionOfAvailableValues();
+ }
+
+ makeIntersectionOfAvailableValues(): void {
+ for (const setting of this._controlledSettings.values()) {
+ if (!setting.isInitialized(true)) {
+ return;
+ }
+ }
+
+ const category = this._setting.getCategory();
+ const reducerDefinition = settingCategoryAvailableValuesIntersectionReducerMap[category];
+
+ if (this._setting.isStatic()) {
+ this._setting.maybeResetPersistedValue();
+ return;
+ }
+
+ if (!reducerDefinition) {
+ return;
+ }
+
+ const { reducer, startingValue, isValid } = reducerDefinition;
+ let availableValues: AvailableValuesType = startingValue as AvailableValuesType;
+ let index = 0;
+ let isInvalid = false;
+
+ for (const value of this._availableValuesMap.values()) {
+ if (value === null) {
+ isInvalid = true;
+ break;
+ }
+ availableValues = reducer(availableValues as any, value as any, index++) as AvailableValuesType;
+ }
+
+ if (!isValid(availableValues as any) || isInvalid) {
+ this._setting.setAvailableValues(null);
+ this._setting.setValue(null as any);
+ return;
+ }
+
+ this._setting.setAvailableValues(availableValues);
+ }
+}
diff --git a/frontend/src/modules/_shared/DataProviderFramework/framework/Group/Group.ts b/frontend/src/modules/_shared/DataProviderFramework/framework/Group/Group.ts
index 480ba9d0a..e872e97c9 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/framework/Group/Group.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/framework/Group/Group.ts
@@ -9,6 +9,7 @@ import type {
CustomGroupImplementationWithSettings,
} from "../../interfacesAndTypes/customGroupImplementation";
import { includesSettings } from "../../interfacesAndTypes/customGroupImplementation";
+import type { DefineBasicDependenciesArgs } from "../../interfacesAndTypes/customSettingsHandler";
import type { ItemGroup } from "../../interfacesAndTypes/entities";
import type { SerializedGroup } from "../../interfacesAndTypes/serialization";
import { SerializedType } from "../../interfacesAndTypes/serialization";
@@ -57,7 +58,7 @@ export class Group<
private _type: GroupType;
private _icon: React.ReactNode | null = null;
private _emptyContentMessage: string | null = null;
- private _sharedSettingsDelegate: SharedSettingsDelegate | null = null;
+ private _sharedSettingsDelegate: SharedSettingsDelegate | null = null;
constructor(params: GroupParams) {
const { dataProviderManager, customGroupImplementation, type } = params;
@@ -65,12 +66,15 @@ export class Group<
this._groupDelegate.setColor(dataProviderManager.makeGroupColor());
this._itemDelegate = new ItemDelegate(customGroupImplementation.getDefaultName(), 1, dataProviderManager);
if (includesSettings(customGroupImplementation)) {
- this._sharedSettingsDelegate = new SharedSettingsDelegate(
+ this._sharedSettingsDelegate = new SharedSettingsDelegate(
this,
makeSettings(
customGroupImplementation.settings as unknown as TSettings,
- customGroupImplementation.getDefaultSettingsValues() as unknown as TSettingTypes,
+ customGroupImplementation.getDefaultSettingsValues?.() ?? {},
),
+ customGroupImplementation.defineDependencies as unknown as
+ | ((args: DefineBasicDependenciesArgs) => void)
+ | undefined,
);
}
this._type = type;
@@ -95,11 +99,11 @@ export class Group<
return this._emptyContentMessage;
}
- getSharedSettingsDelegate(): SharedSettingsDelegate | null {
+ getSharedSettingsDelegate(): SharedSettingsDelegate | null {
return this._sharedSettingsDelegate;
}
- getWrappedSettings(): { [K in TSettingKey]: SettingManager } {
+ getWrappedSettings(): { [K in TSettingKey]: SettingManager } {
if (!this._sharedSettingsDelegate) {
throw new Error("Group does not have shared settings.");
}
@@ -125,4 +129,9 @@ export class Group<
this._groupDelegate.setColor(serialized.color);
this._groupDelegate.deserializeChildren(serialized.children);
}
+
+ beforeDestroy(): void {
+ this._groupDelegate.beforeDestroy();
+ this._sharedSettingsDelegate?.beforeDestroy();
+ }
}
diff --git a/frontend/src/modules/_shared/DataProviderFramework/framework/Group/GroupComponent.tsx b/frontend/src/modules/_shared/DataProviderFramework/framework/Group/GroupComponent.tsx
index a90ca492f..ac294c1d2 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/framework/Group/GroupComponent.tsx
+++ b/frontend/src/modules/_shared/DataProviderFramework/framework/Group/GroupComponent.tsx
@@ -3,7 +3,6 @@ import React from "react";
import { ColorSelect } from "@lib/components/ColorSelect";
import { SortableListGroup } from "@lib/components/SortableList";
-
import { usePublishSubscribeTopicValue } from "../../../utils/PublishSubscribeDelegate";
import type { ActionGroup } from "../../Actions";
import { Actions } from "../../Actions";
@@ -11,7 +10,7 @@ import { GroupDelegateTopic } from "../../delegates/GroupDelegate";
import { ItemDelegateTopic } from "../../delegates/ItemDelegate";
import type { Item, ItemGroup } from "../../interfacesAndTypes/entities";
import type { SettingManager } from "../SettingManager/SettingManager";
-import { SettingComponent } from "../SettingManager/SettingManagerComponent";
+import { SettingManagerComponent } from "../SettingManager/SettingManagerComponent";
import { EditName } from "../utilityComponents/EditName";
import { EmptyContent } from "../utilityComponents/EmptyContent";
import { ExpandCollapseAllButton } from "../utilityComponents/ExpandCollapseAllButton";
@@ -49,7 +48,9 @@ export function GroupComponent(props: GroupComponentProps): React.ReactNode {
if (!manager) {
return null;
}
- return ;
+ return (
+
+ );
}
function makeSettings(settings: SettingManager[]): React.ReactNode[] {
diff --git a/frontend/src/modules/_shared/DataProviderFramework/framework/SettingManager/SettingManager.ts b/frontend/src/modules/_shared/DataProviderFramework/framework/SettingManager/SettingManager.ts
index c3fbbdd3a..7b00cd042 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/framework/SettingManager/SettingManager.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/framework/SettingManager/SettingManager.ts
@@ -6,13 +6,13 @@ import type { WorkbenchSettings } from "@framework/WorkbenchSettings";
import type { PublishSubscribe } from "@modules/_shared/utils/PublishSubscribeDelegate";
import { PublishSubscribeDelegate } from "@modules/_shared/utils/PublishSubscribeDelegate";
-
+import { UnsubscribeHandlerDelegate } from "../../delegates/UnsubscribeHandlerDelegate";
import type { CustomSettingImplementation } from "../../interfacesAndTypes/customSettingImplementation";
import type { SettingAttributes } from "../../interfacesAndTypes/customSettingsHandler";
-import type { SharedSettingsProvider } from "../../interfacesAndTypes/entities";
import type { AvailableValuesType, MakeAvailableValuesTypeBasedOnCategory } from "../../interfacesAndTypes/utils";
import type { Setting, SettingCategories, SettingCategory, SettingTypes } from "../../settings/settingsDefinitions";
import { settingCategoryFixupMap, settingCategoryIsValueValidMap } from "../../settings/settingsDefinitions";
+import type { ExternalSettingController } from "../ExternalSettingController/ExternalSettingController";
import { Group } from "../Group/Group";
export enum SettingTopic {
@@ -20,8 +20,8 @@ export enum SettingTopic {
VALUE_ABOUT_TO_BE_CHANGED = "VALUE_ABOUT_TO_BE_CHANGED",
IS_VALID = "IS_VALID",
AVAILABLE_VALUES = "AVAILABLE_VALUES",
- OVERRIDDEN_VALUE = "OVERRIDDEN_VALUE",
- OVERRIDDEN_VALUE_PROVIDER = "OVERRIDDEN_VALUE_PROVIDER",
+ IS_EXTERNALLY_CONTROLLED = "IS_EXTERNALLY_CONTROLLED",
+ EXTERNAL_CONTROLLER_PROVIDER = "EXTERNAL_CONTROLLER_PROVIDER",
IS_LOADING = "IS_LOADING",
IS_INITIALIZED = "IS_INITIALIZED",
IS_PERSISTED = "IS_PERSISTED",
@@ -33,8 +33,8 @@ export type SettingTopicPayloads = {
[SettingTopic.VALUE_ABOUT_TO_BE_CHANGED]: void;
[SettingTopic.IS_VALID]: boolean;
[SettingTopic.AVAILABLE_VALUES]: MakeAvailableValuesTypeBasedOnCategory | null;
- [SettingTopic.OVERRIDDEN_VALUE]: TValue | undefined;
- [SettingTopic.OVERRIDDEN_VALUE_PROVIDER]: OverriddenValueProviderType | undefined;
+ [SettingTopic.IS_EXTERNALLY_CONTROLLED]: boolean;
+ [SettingTopic.EXTERNAL_CONTROLLER_PROVIDER]: OverriddenValueProviderType | undefined;
[SettingTopic.IS_LOADING]: boolean;
[SettingTopic.IS_INITIALIZED]: boolean;
[SettingTopic.IS_PERSISTED]: boolean;
@@ -66,7 +66,7 @@ export enum OverriddenValueProviderType {
*/
export class SettingManager<
TSetting extends Setting,
- TValue extends SettingTypes[TSetting] = SettingTypes[TSetting],
+ TValue extends SettingTypes[TSetting] | null = SettingTypes[TSetting] | null,
TCategory extends SettingCategories[TSetting] = SettingCategories[TSetting],
> implements PublishSubscribe>
{
@@ -78,9 +78,7 @@ export class SettingManager<
private _value: TValue;
private _isValueValid: boolean = false;
private _publishSubscribeDelegate = new PublishSubscribeDelegate>();
- private _availableValues: MakeAvailableValuesTypeBasedOnCategory | null = null;
- private _overriddenValue: TValue | undefined = undefined;
- private _overriddenValueProviderType: OverriddenValueProviderType | undefined = undefined;
+ private _availableValues: AvailableValuesType | null = null;
private _loading: boolean = false;
private _initialized: boolean = false;
private _currentValueFromPersistence: TValue | null = null;
@@ -89,6 +87,8 @@ export class SettingManager<
enabled: true,
visible: true,
};
+ private _externalController: ExternalSettingController | null = null;
+ private _unsubscribeHandler: UnsubscribeHandlerDelegate = new UnsubscribeHandlerDelegate();
constructor({
type,
@@ -109,6 +109,93 @@ export class SettingManager<
}
}
+ registerExternalSettingController(
+ externalController: ExternalSettingController,
+ ): void {
+ this._externalController = externalController;
+ this._unsubscribeHandler.registerUnsubscribeFunction(
+ "external-setting-controller",
+ externalController.getSetting().getPublishSubscribeDelegate().makeSubscriberFunction(SettingTopic.VALUE)(
+ () => {
+ this._publishSubscribeDelegate.notifySubscribers(SettingTopic.VALUE);
+ this._value = externalController.getSetting().getValue();
+ },
+ ),
+ );
+ this._unsubscribeHandler.registerUnsubscribeFunction(
+ "external-setting-controller",
+ externalController.getSetting().getPublishSubscribeDelegate().makeSubscriberFunction(SettingTopic.IS_VALID)(
+ () => {
+ this._publishSubscribeDelegate.notifySubscribers(SettingTopic.IS_VALID);
+ },
+ ),
+ );
+ this._unsubscribeHandler.registerUnsubscribeFunction(
+ "external-setting-controller",
+ externalController
+ .getSetting()
+ .getPublishSubscribeDelegate()
+ .makeSubscriberFunction(SettingTopic.IS_LOADING)(() => {
+ this._publishSubscribeDelegate.notifySubscribers(SettingTopic.IS_LOADING);
+ }),
+ );
+ this._unsubscribeHandler.registerUnsubscribeFunction(
+ "external-setting-controller",
+ externalController
+ .getSetting()
+ .getPublishSubscribeDelegate()
+ .makeSubscriberFunction(SettingTopic.ATTRIBUTES)(() => {
+ this._publishSubscribeDelegate.notifySubscribers(SettingTopic.ATTRIBUTES);
+ }),
+ );
+ this._unsubscribeHandler.registerUnsubscribeFunction(
+ "external-setting-controller",
+ externalController
+ .getSetting()
+ .getPublishSubscribeDelegate()
+ .makeSubscriberFunction(SettingTopic.VALUE_ABOUT_TO_BE_CHANGED)(() => {
+ this._publishSubscribeDelegate.notifySubscribers(SettingTopic.VALUE_ABOUT_TO_BE_CHANGED);
+ }),
+ );
+ this._unsubscribeHandler.registerUnsubscribeFunction(
+ "external-setting-controller",
+ externalController
+ .getSetting()
+ .getPublishSubscribeDelegate()
+ .makeSubscriberFunction(SettingTopic.IS_INITIALIZED)(() => {
+ this._publishSubscribeDelegate.notifySubscribers(SettingTopic.IS_INITIALIZED);
+ }),
+ );
+ this._unsubscribeHandler.registerUnsubscribeFunction(
+ "external-setting-controller",
+ externalController
+ .getSetting()
+ .getPublishSubscribeDelegate()
+ .makeSubscriberFunction(SettingTopic.IS_PERSISTED)(() => {
+ this._publishSubscribeDelegate.notifySubscribers(SettingTopic.IS_PERSISTED);
+ }),
+ );
+ this._unsubscribeHandler.registerUnsubscribeFunction(
+ "external-setting-controller",
+ externalController
+ .getSetting()
+ .getPublishSubscribeDelegate()
+ .makeSubscriberFunction(SettingTopic.AVAILABLE_VALUES)(() => {
+ this._publishSubscribeDelegate.notifySubscribers(SettingTopic.AVAILABLE_VALUES);
+ }),
+ );
+ }
+
+ unregisterExternalSettingController(): void {
+ this._externalController = null;
+ this._unsubscribeHandler.unsubscribe("external-setting-controller");
+ this.applyAvailableValues();
+ }
+
+ beforeDestroy(): void {
+ this._unsubscribeHandler.unsubscribeAll();
+ }
+
getId(): string {
return this._id;
}
@@ -134,14 +221,17 @@ export class SettingManager<
return;
}
- Object.assign(this._attributes, attributes);
+ this._attributes = {
+ ...this._attributes,
+ ...attributes,
+ };
this._publishSubscribeDelegate.notifySubscribers(SettingTopic.ATTRIBUTES);
}
getValue(): TValue {
- if (this._overriddenValue !== undefined) {
- return this._overriddenValue;
+ if (this._externalController) {
+ return this._externalController.getSetting().getValue();
}
if (this._currentValueFromPersistence !== null) {
@@ -173,10 +263,16 @@ export class SettingManager<
}
isValueValid(): boolean {
+ if (this._externalController) {
+ return this._externalController.getSetting().isValueValid();
+ }
return this._isValueValid;
}
isPersistedValue(): boolean {
+ if (this._externalController) {
+ return this._externalController.getSetting().isPersistedValue();
+ }
return this._currentValueFromPersistence !== null;
}
@@ -210,6 +306,11 @@ export class SettingManager<
if (this._loading === loading) {
return;
}
+
+ if (this._externalController) {
+ this._externalController.getSetting().setLoading(loading);
+ }
+
this._loading = loading;
this._publishSubscribeDelegate.notifySubscribers(SettingTopic.IS_LOADING);
}
@@ -219,14 +320,21 @@ export class SettingManager<
return;
}
this._initialized = true;
+
this._publishSubscribeDelegate.notifySubscribers(SettingTopic.IS_INITIALIZED);
}
- isInitialized(): boolean {
+ isInitialized(itself: boolean = false): boolean {
+ if (this._externalController && !itself) {
+ return this._externalController.getSetting().isInitialized();
+ }
return this._initialized || this._isStatic;
}
isLoading(): boolean {
+ if (this._externalController) {
+ return this._externalController.getSetting().isLoading();
+ }
return this._loading;
}
@@ -235,6 +343,12 @@ export class SettingManager<
workbenchSession: WorkbenchSession,
workbenchSettings: WorkbenchSettings,
): React.ReactNode {
+ if (this._externalController) {
+ return this._externalController
+ .getSetting()
+ .valueToRepresentation(value, workbenchSession, workbenchSettings);
+ }
+
if (this._customSettingImplementation.overriddenValueRepresentation) {
return this._customSettingImplementation.overriddenValueRepresentation({
value,
@@ -258,63 +372,22 @@ export class SettingManager<
return "Value has no string representation";
}
- checkForOverrides(sharedSettingsProviders: SharedSettingsProvider[]) {
- let overriddenValue: TValue | undefined;
- let overriddenValueProviderType: OverriddenValueProviderType | undefined;
-
- for (const provider of sharedSettingsProviders) {
- if (!provider.getSharedSettingsDelegate()) {
- continue;
- }
- for (const sharedSettingKey in provider.getSharedSettingsDelegate().getWrappedSettings()) {
- const sharedSetting = provider.getSharedSettingsDelegate().getWrappedSettings()[sharedSettingKey];
- if (sharedSetting.getType() === this._type) {
- overriddenValue = sharedSetting.getValue();
- overriddenValueProviderType = OverriddenValueProviderType.SHARED_SETTING;
- if (provider instanceof Group) {
- overriddenValueProviderType = OverriddenValueProviderType.GROUP;
- }
- break;
+ makeSnapshotGetter(topic: T): () => SettingTopicPayloads[T] {
+ const externalController = this._externalController;
+ if (externalController) {
+ return (): any => {
+ if (topic === SettingTopic.IS_EXTERNALLY_CONTROLLED) {
+ return true;
}
- }
- }
-
- this.setOverriddenValue(overriddenValue);
- this._overriddenValueProviderType = overriddenValueProviderType;
- this._publishSubscribeDelegate.notifySubscribers(SettingTopic.OVERRIDDEN_VALUE_PROVIDER);
- }
-
- setOverriddenValue(overriddenValue: TValue | undefined): void {
- if (isEqual(this._overriddenValue, overriddenValue)) {
- return;
- }
-
- const prevValue = this._overriddenValue;
- this._overriddenValue = overriddenValue;
- this._publishSubscribeDelegate.notifySubscribers(SettingTopic.OVERRIDDEN_VALUE);
-
- if (overriddenValue === undefined) {
- // Keep overridden value, if invalid fix it
- if (prevValue !== undefined) {
- this._value = prevValue;
- }
- this.maybeFixupValue();
- }
-
- this.setValueValid(this.checkIfValueIsValid(this.getValue()));
-
- if (prevValue === undefined && overriddenValue !== undefined && isEqual(this._value, overriddenValue)) {
- return;
- }
-
- if (prevValue !== undefined && overriddenValue === undefined && isEqual(this._value, prevValue)) {
- return;
+ if (topic === SettingTopic.EXTERNAL_CONTROLLER_PROVIDER) {
+ return externalController.getParentItem() instanceof Group
+ ? OverriddenValueProviderType.GROUP
+ : OverriddenValueProviderType.SHARED_SETTING;
+ }
+ return externalController.getSetting().makeSnapshotGetter(topic)();
+ };
}
- this._publishSubscribeDelegate.notifySubscribers(SettingTopic.VALUE);
- }
-
- makeSnapshotGetter(topic: T): () => SettingTopicPayloads[T] {
const snapshotGetter = (): any => {
switch (topic) {
case SettingTopic.VALUE:
@@ -325,10 +398,12 @@ export class SettingManager<
return this._isValueValid;
case SettingTopic.AVAILABLE_VALUES:
return this._availableValues;
- case SettingTopic.OVERRIDDEN_VALUE:
- return this._overriddenValue;
- case SettingTopic.OVERRIDDEN_VALUE_PROVIDER:
- return this._overriddenValueProviderType;
+ case SettingTopic.IS_EXTERNALLY_CONTROLLED:
+ return this._externalController !== null;
+ case SettingTopic.EXTERNAL_CONTROLLER_PROVIDER:
+ return this._externalController?.getParentItem() instanceof Group
+ ? OverriddenValueProviderType.GROUP
+ : OverriddenValueProviderType.SHARED_SETTING;
case SettingTopic.IS_LOADING:
return this.isLoading();
case SettingTopic.IS_PERSISTED:
@@ -350,7 +425,10 @@ export class SettingManager<
}
getAvailableValues(): AvailableValuesType | null {
- return this._availableValues;
+ if (this._externalController) {
+ return this._externalController.getSetting().getAvailableValues();
+ }
+ return this._availableValues as AvailableValuesType | null;
}
maybeResetPersistedValue(): boolean {
@@ -372,7 +450,7 @@ export class SettingManager<
if (customIsValueValidFunction) {
isPersistedValueValid = customIsValueValidFunction(
this._currentValueFromPersistence,
- this._availableValues,
+ this._availableValues as any,
);
} else {
isPersistedValueValid = settingCategoryIsValueValidMap[this._category](
@@ -393,22 +471,38 @@ export class SettingManager<
return false;
}
- setAvailableValues(availableValues: MakeAvailableValuesTypeBasedOnCategory): void {
- if (isEqual(this._availableValues, availableValues) && this._initialized) {
- return;
- }
-
- this._availableValues = availableValues;
+ private applyAvailableValues() {
let valueChanged = false;
- if ((!this.checkIfValueIsValid(this.getValue()) && this.maybeFixupValue()) || this.maybeResetPersistedValue()) {
+ const valueFixedUp = !this.checkIfValueIsValid(this.getValue()) && this.maybeFixupValue();
+ const persistedValueReset = this.maybeResetPersistedValue();
+ if (valueFixedUp || persistedValueReset) {
valueChanged = true;
}
const prevIsValid = this._isValueValid;
this.setValueValid(this.checkIfValueIsValid(this.getValue()));
this.initialize();
- if (valueChanged || this._isValueValid !== prevIsValid) {
+ this.setLoading(false);
+ if (valueChanged || this._isValueValid !== prevIsValid || this._value === null) {
this._publishSubscribeDelegate.notifySubscribers(SettingTopic.VALUE);
}
+ }
+
+ setAvailableValues(availableValues: AvailableValuesType | null): void {
+ if (this._externalController) {
+ this._availableValues = availableValues;
+ this.initialize();
+ this._externalController.setAvailableValues(this.getId(), availableValues);
+ return;
+ }
+
+ if (isEqual(this._availableValues, availableValues) && this._initialized) {
+ this.setLoading(false);
+ return;
+ }
+
+ this._availableValues = availableValues;
+
+ this.applyAvailableValues();
this._publishSubscribeDelegate.notifySubscribers(SettingTopic.AVAILABLE_VALUES);
}
@@ -431,7 +525,7 @@ export class SettingManager<
let candidate: TValue;
if (this._customSettingImplementation.fixupValue) {
- candidate = this._customSettingImplementation.fixupValue(this._value, this._availableValues);
+ candidate = this._customSettingImplementation.fixupValue(this._value, this._availableValues as any);
} else {
candidate = settingCategoryFixupMap[this._category](
this._value as any,
@@ -454,7 +548,7 @@ export class SettingManager<
return false;
}
if (this._customSettingImplementation.isValueValid) {
- return this._customSettingImplementation.isValueValid(value, this._availableValues);
+ return this._customSettingImplementation.isValueValid(value, this._availableValues as any);
} else {
return settingCategoryIsValueValidMap[this._category](value as any, this._availableValues as any);
}
diff --git a/frontend/src/modules/_shared/DataProviderFramework/framework/SettingManager/SettingManagerComponent.tsx b/frontend/src/modules/_shared/DataProviderFramework/framework/SettingManager/SettingManagerComponent.tsx
index d54c14179..79abd9dea 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/framework/SettingManager/SettingManagerComponent.tsx
+++ b/frontend/src/modules/_shared/DataProviderFramework/framework/SettingManager/SettingManagerComponent.tsx
@@ -5,7 +5,6 @@ import { Link, Warning } from "@mui/icons-material";
import { PendingWrapper } from "@lib/components/PendingWrapper";
import { resolveClassNames } from "@lib/utils/resolveClassNames";
-
import { usePublishSubscribeTopicValue } from "../../../utils/PublishSubscribeDelegate";
import type { SettingComponentProps as SettingComponentPropsInterface } from "../../interfacesAndTypes/customSettingImplementation";
import type { Setting, SettingCategories, SettingTypes } from "../../settings/settingsDefinitions";
@@ -24,7 +23,7 @@ export type SettingComponentProps<
sharedSetting: boolean;
};
-export function SettingComponent<
+export function SettingManagerComponent<
TSetting extends Setting,
TValue extends SettingTypes[TSetting] = SettingTypes[TSetting],
TCategory extends SettingCategories[TSetting] = SettingCategories[TSetting],
@@ -37,10 +36,10 @@ export function SettingComponent<
const isValid = usePublishSubscribeTopicValue(props.setting, SettingTopic.IS_VALID);
const isPersisted = usePublishSubscribeTopicValue(props.setting, SettingTopic.IS_PERSISTED);
const availableValues = usePublishSubscribeTopicValue(props.setting, SettingTopic.AVAILABLE_VALUES);
- const overriddenValue = usePublishSubscribeTopicValue(props.setting, SettingTopic.OVERRIDDEN_VALUE);
- const overriddenValueProvider = usePublishSubscribeTopicValue(
+ const isExternallyControlled = usePublishSubscribeTopicValue(props.setting, SettingTopic.IS_EXTERNALLY_CONTROLLED);
+ const externalControllerProvider = usePublishSubscribeTopicValue(
props.setting,
- SettingTopic.OVERRIDDEN_VALUE_PROVIDER,
+ SettingTopic.EXTERNAL_CONTROLLER_PROVIDER,
);
const isLoading = usePublishSubscribeTopicValue(props.setting, SettingTopic.IS_LOADING);
const isInitialized = usePublishSubscribeTopicValue(props.setting, SettingTopic.IS_INITIALIZED);
@@ -59,7 +58,7 @@ export function SettingComponent<
return null;
}
- if (props.sharedSetting && isInitialized && availableValues === null) {
+ if (props.sharedSetting && isInitialized && availableValues === null && !props.setting.isStatic()) {
return (
{props.setting.getLabel()}
@@ -68,12 +67,12 @@ export function SettingComponent<
);
}
- if (overriddenValue !== undefined) {
- if (overriddenValueProvider !== OverriddenValueProviderType.SHARED_SETTING) {
+ if (isExternallyControlled) {
+ if (externalControllerProvider !== OverriddenValueProviderType.SHARED_SETTING) {
return null;
}
const valueAsString = props.setting.valueToRepresentation(
- overriddenValue,
+ value,
props.manager.getWorkbenchSession(),
props.manager.getWorkbenchSettings(),
);
@@ -109,8 +108,8 @@ export function SettingComponent<
onValueChange={handleValueChanged}
value={value}
isValueValid={isValid}
- isOverridden={overriddenValue !== undefined}
- overriddenValue={overriddenValue ?? null}
+ isOverridden={isExternallyControlled}
+ overriddenValue={value}
availableValues={availableValues}
globalSettings={globalSettings}
workbenchSession={props.manager.getWorkbenchSession()}
diff --git a/frontend/src/modules/_shared/DataProviderFramework/framework/SettingsGroup/SettingsGroup.ts b/frontend/src/modules/_shared/DataProviderFramework/framework/SettingsGroup/SettingsGroup.ts
index 3921c9443..9e1c760c8 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/framework/SettingsGroup/SettingsGroup.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/framework/SettingsGroup/SettingsGroup.ts
@@ -53,4 +53,8 @@ export class SettingsGroup implements ItemGroup {
this._itemDelegate.deserializeState(serialized);
this._groupDelegate.deserializeChildren(serialized.children);
}
+
+ beforeDestroy?(): void {
+ this._groupDelegate.beforeDestroy();
+ }
}
diff --git a/frontend/src/modules/_shared/DataProviderFramework/framework/SharedSetting/SharedSetting.ts b/frontend/src/modules/_shared/DataProviderFramework/framework/SharedSetting/SharedSetting.ts
index fde3fc23a..8e28b567d 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/framework/SharedSetting/SharedSetting.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/framework/SharedSetting/SharedSetting.ts
@@ -7,7 +7,7 @@ import type { SerializedSharedSetting } from "../../interfacesAndTypes/serializa
import { SerializedType } from "../../interfacesAndTypes/serialization";
import { SettingRegistry } from "../../settings/SettingRegistry";
import type { Setting, SettingTypes } from "../../settings/settingsDefinitions";
-import { type DataProviderManager, DataProviderManagerTopic } from "../DataProviderManager/DataProviderManager";
+import { type DataProviderManager } from "../DataProviderManager/DataProviderManager";
import type { SettingManager } from "../SettingManager/SettingManager";
export function isSharedSetting(obj: any): obj is SharedSetting {
@@ -51,13 +51,6 @@ export class SharedSetting implements Item, SharedSett
return this._sharedSettingsDelegate;
}
- publishValueChange(): void {
- const dataProviderManager = this._itemDelegate.getDataProviderManager();
- if (dataProviderManager) {
- dataProviderManager.publishTopic(DataProviderManagerTopic.SHARED_SETTINGS_CHANGED);
- }
- }
-
getWrappedSetting(): SettingManager {
return Object.values(this._sharedSettingsDelegate.getWrappedSettings())[0] as SettingManager;
}
@@ -77,6 +70,7 @@ export class SharedSetting implements Item, SharedSett
}
beforeDestroy(): void {
+ this._sharedSettingsDelegate.beforeDestroy();
this._sharedSettingsDelegate.unsubscribeAll();
}
}
diff --git a/frontend/src/modules/_shared/DataProviderFramework/framework/SharedSetting/SharedSettingComponent.tsx b/frontend/src/modules/_shared/DataProviderFramework/framework/SharedSetting/SharedSettingComponent.tsx
index f8817db1b..ea78a60db 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/framework/SharedSetting/SharedSettingComponent.tsx
+++ b/frontend/src/modules/_shared/DataProviderFramework/framework/SharedSetting/SharedSettingComponent.tsx
@@ -9,7 +9,7 @@ import { resolveClassNames } from "@lib/utils/resolveClassNames";
import { usePublishSubscribeTopicValue } from "../../../utils/PublishSubscribeDelegate";
import { ItemDelegateTopic } from "../../delegates/ItemDelegate";
-import { SettingComponent } from "../SettingManager/SettingManagerComponent";
+import { SettingManagerComponent } from "../SettingManager/SettingManagerComponent";
import type { SharedSetting } from "./SharedSetting";
@@ -57,7 +57,11 @@ export function SharedSettingComponent(props: SharedSettingComponentProps): Reac
hidden: !isExpanded,
})}
>
-
+
);
diff --git a/frontend/src/modules/_shared/DataProviderFramework/framework/utilityComponents/RemoveItemButton.tsx b/frontend/src/modules/_shared/DataProviderFramework/framework/utilityComponents/RemoveItemButton.tsx
index 9909519d0..fe8c27bae 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/framework/utilityComponents/RemoveItemButton.tsx
+++ b/frontend/src/modules/_shared/DataProviderFramework/framework/utilityComponents/RemoveItemButton.tsx
@@ -4,7 +4,6 @@ import { DenseIconButton } from "@lib/components/DenseIconButton";
import { DenseIconButtonColorScheme } from "@lib/components/DenseIconButton/denseIconButton";
import type { Item } from "../../interfacesAndTypes/entities";
-import { DataProvider } from "../DataProvider/DataProvider";
export type RemoveItemButtonProps = {
item: Item;
@@ -17,9 +16,7 @@ export function RemoveItemButton(props: RemoveItemButtonProps): React.ReactNode
parentGroup.removeChild(props.item);
}
- if (props.item instanceof DataProvider) {
- props.item.beforeDestroy();
- }
+ props.item.beforeDestroy?.();
}
return (
diff --git a/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/customDataProviderImplementation.ts b/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/customDataProviderImplementation.ts
index 8bbbc057c..a44c0f6c9 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/customDataProviderImplementation.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/customDataProviderImplementation.ts
@@ -10,7 +10,6 @@ import type { CustomSettingsHandler } from "./customSettingsHandler";
import type { NullableStoredData, StoredData } from "./sharedTypes";
import type { AvailableValuesType, SettingsKeysFromTuple } from "./utils";
-
/**
* This type is used to pass parameters to the fetchData method of a CustomDataProviderImplementation.
* It contains accessors to the data and settings of the provider and other useful information.
@@ -38,7 +37,7 @@ export type DataProviderInformationAccessors<
* const value = getSetting("settingName");
* ```
*/
- getSetting: (settingName: K) => TSettingTypes[K];
+ getSetting: (settingName: K) => TSettingTypes[K] | null;
/**
* Access the available values of a setting.
@@ -91,6 +90,16 @@ export type DataProviderInformationAccessors<
getWorkbenchSettings: () => WorkbenchSettings;
};
+export type AreSettingsValidArgs<
+ TSettings extends Settings,
+ TData,
+ TStoredData extends StoredData = Record,
+ TSettingKey extends SettingsKeysFromTuple = SettingsKeysFromTuple,
+ TSettingTypes extends MakeSettingTypesMap = MakeSettingTypesMap,
+> = DataProviderInformationAccessors & {
+ reportError: (error: string) => void;
+};
+
/**
* This type is used to pass parameters to the fetchData method of a CustomDataProviderImplementation.
* It contains accessors to the data and settings of the provider and other useful information.
@@ -162,15 +171,16 @@ export interface CustomDataProviderImplementation<
*/
makeValueRange?(
accessors: DataProviderInformationAccessors,
- ): [number, number] | null;
+ ): readonly [number, number] | null;
/**
* This method is called to check if the current settings are valid. It should return true if the settings are valid
* and false if they are not.
* As long as the settings are not valid, the provider will not fetch data.
*
- * @param accessors Accessors to the data and settings of the provider.
- * @returns
+ * @param args Accessors to the data and settings of the provider plus a function that can be used to report an error if
+ * some settings are not valid. It can be called multiple times if multiple settings are not valid.
+ * @returns true if the settings are valid, false otherwise.
*/
- areCurrentSettingsValid?: (accessors: DataProviderInformationAccessors) => boolean;
+ areCurrentSettingsValid?: (args: AreSettingsValidArgs) => boolean;
}
diff --git a/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/customGroupImplementation.ts b/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/customGroupImplementation.ts
index 7b0354ef4..a6bdacdae 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/customGroupImplementation.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/customGroupImplementation.ts
@@ -1,5 +1,7 @@
import type { MakeSettingTypesMap, Settings } from "../settings/settingsDefinitions";
+import type { DefineBasicDependenciesArgs } from "./customSettingsHandler";
+
/**
* This interface is describing what methods and members a custom group must implement.
* A custom group can contain settings but it does not have to.
@@ -29,9 +31,11 @@ export interface CustomGroupImplementationWithSettings<
* A method that returns the default values of the settings.
* @returns The default values of the settings.
*/
- getDefaultSettingsValues(): TSettingTypes;
+ getDefaultSettingsValues?(): Partial;
+
+ defineDependencies?(args: DefineBasicDependenciesArgs): void;
}
export function includesSettings(obj: any): obj is CustomGroupImplementationWithSettings {
- return obj.settings !== undefined && obj.getDefaultSettingsValues !== undefined;
+ return obj.settings !== undefined;
}
diff --git a/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/customSettingsHandler.ts b/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/customSettingsHandler.ts
index 54ca7f99c..c2e28aa8a 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/customSettingsHandler.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/customSettingsHandler.ts
@@ -10,7 +10,6 @@ import type { MakeSettingTypesMap, Settings } from "../settings/settingsDefiniti
import type { NullableStoredData, StoredData } from "./sharedTypes";
import type { AvailableValuesType, SettingsKeysFromTuple } from "./utils";
-
export interface GetHelperDependency<
TSettings extends Settings,
TSettingTypes extends MakeSettingTypesMap,
@@ -24,8 +23,6 @@ export type SettingAttributes = {
enabled: boolean;
};
-export const CancelUpdate = Symbol("CancelUpdate");
-
export interface UpdateFunc<
TReturnValue,
TSettings extends Settings,
@@ -37,7 +34,18 @@ export interface UpdateFunc<
getGlobalSetting: (settingName: T) => GlobalSettings[T];
getHelperDependency: GetHelperDependency;
abortSignal: AbortSignal;
- }): TReturnValue | typeof CancelUpdate;
+ }): TReturnValue;
+}
+
+export interface DefineBasicDependenciesArgs<
+ TSettings extends Settings,
+ TSettingTypes extends MakeSettingTypesMap = MakeSettingTypesMap,
+ TKey extends SettingsKeysFromTuple = SettingsKeysFromTuple,
+> {
+ settingAttributesUpdater: (
+ settingKey: TSettingKey,
+ update: UpdateFunc, TSettings, TSettingTypes, TKey>,
+ ) => Dependency, TSettings, TSettingTypes, TKey>;
}
export interface DefineDependenciesArgs<
@@ -46,7 +54,7 @@ export interface DefineDependenciesArgs<
TSettingTypes extends MakeSettingTypesMap = MakeSettingTypesMap,
TKey extends SettingsKeysFromTuple = SettingsKeysFromTuple,
TStoredDataKey extends keyof TStoredData = keyof TStoredData,
-> {
+> extends DefineBasicDependenciesArgs {
availableSettingsUpdater: (
settingKey: TSettingKey,
update: UpdateFunc, TSettings, TSettingTypes, TKey>,
@@ -55,10 +63,6 @@ export interface DefineDependenciesArgs<
key: K,
update: UpdateFunc[TStoredDataKey], TSettings, TSettingTypes, TKey>,
) => Dependency[TStoredDataKey], TSettings, TSettingTypes, TKey>;
- settingAttributesUpdater: (
- settingKey: TSettingKey,
- update: UpdateFunc, TSettings, TSettingTypes, TKey>,
- ) => Dependency, TSettings, TSettingTypes, TKey>;
helperDependency: (
update: (args: {
getLocalSetting: (settingName: T) => TSettingTypes[T];
diff --git a/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/entities.ts b/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/entities.ts
index 7e17e7bd1..630c5b147 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/entities.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/entities.ts
@@ -1,4 +1,3 @@
-
import type { GroupDelegate } from "../delegates/GroupDelegate";
import type { ItemDelegate } from "../delegates/ItemDelegate";
import type { SharedSettingsDelegate } from "../delegates/SharedSettingsDelegate";
@@ -14,6 +13,7 @@ export interface Item {
getItemDelegate(): ItemDelegate;
serializeState(): SerializedItem;
deserializeState(serialized: SerializedItem): void;
+ beforeDestroy?(): void;
}
export function instanceofItem(item: any): item is Item {
diff --git a/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/DrilledWellboresSetting.tsx b/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/DrilledWellboresSetting.tsx
index 044d12803..755bb832e 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/DrilledWellboresSetting.tsx
+++ b/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/DrilledWellboresSetting.tsx
@@ -25,14 +25,14 @@ export class DrilledWellboresSetting implements CustomSettingImplementation
+ availableValues: MakeAvailableValuesTypeBasedOnCategory,
): ValueType {
if (!currentValue) {
return availableValues;
}
const matchingValues = currentValue.filter((value) =>
- availableValues.some((availableValue) => availableValue.wellboreUuid === value.wellboreUuid)
+ availableValues.some((availableValue) => availableValue.wellboreUuid === value.wellboreUuid),
);
if (matchingValues.length === 0) {
return availableValues;
@@ -65,7 +65,7 @@ export class DrilledWellboresSetting implements CustomSettingImplementation props.value?.map((ident) => ident.wellboreUuid) ?? [],
- [props.value]
+ [props.value],
);
return (
diff --git a/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/SensitivitySetting.tsx b/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/SensitivitySetting.tsx
index 6a59608e1..a59bb03cc 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/SensitivitySetting.tsx
+++ b/frontend/src/modules/_shared/DataProviderFramework/settings/implementations/SensitivitySetting.tsx
@@ -18,7 +18,7 @@ type ValueType = SensitivityNameCasePair | null;
export class SensitivitySetting implements CustomSettingImplementation {
isValueValid(
value: ValueType,
- availableValues: MakeAvailableValuesTypeBasedOnCategory
+ availableValues: MakeAvailableValuesTypeBasedOnCategory,
): boolean {
if (availableValues.length === 0) {
return true;
@@ -31,7 +31,7 @@ export class SensitivitySetting implements CustomSettingImplementation
sensitivity?.sensitivityName === value.sensitivityName &&
- sensitivity?.sensitivityCase === value.sensitivityCase
+ sensitivity?.sensitivityCase === value.sensitivityCase,
);
}
@@ -50,7 +50,7 @@ export class SensitivitySetting implements CustomSettingImplementation ({
diff --git a/frontend/src/modules/_shared/DataProviderFramework/settings/settingsDefinitions.ts b/frontend/src/modules/_shared/DataProviderFramework/settings/settingsDefinitions.ts
index 1907cf5f9..4ac6cb747 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/settings/settingsDefinitions.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/settings/settingsDefinitions.ts
@@ -5,13 +5,11 @@ import type { ColorScaleSpecification } from "@framework/components/ColorScaleSe
import type { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent";
import type { ColorSet } from "@lib/utils/ColorSet";
-
import type { AvailableValuesType } from "../interfacesAndTypes/utils";
import type { IntersectionSettingValue } from "./implementations/IntersectionSetting";
import type { SensitivityNameCasePair } from "./implementations/SensitivitySetting";
-
export enum SettingCategory {
SINGLE_SELECT = "singleSelect",
MULTI_SELECT = "multiSelect",
@@ -140,6 +138,7 @@ type SettingCategoryAvailableValuesIntersectionReducerMap = {
[K in SettingCategory]?: {
reducer: AvailableValuesIntersectionReducer;
startingValue: AvailableValuesType>;
+ isValid: (availableValues: AvailableValuesType>) => boolean;
};
};
@@ -291,6 +290,7 @@ export const settingCategoryAvailableValuesIntersectionReducerMap: SettingCatego
return accumulator.filter((value) => currentAvailableValues.some((av) => isEqual(av, value)));
},
startingValue: [],
+ isValid: (availableValues) => availableValues.length > 0,
},
[SettingCategory.MULTI_SELECT]: {
reducer: (accumulator, currentAvailableValues) => {
@@ -300,6 +300,7 @@ export const settingCategoryAvailableValuesIntersectionReducerMap: SettingCatego
return accumulator.filter((value) => currentAvailableValues.some((av) => isEqual(av, value)));
},
startingValue: [],
+ isValid: (availableValues) => availableValues.length > 0,
},
[SettingCategory.NUMBER]: {
reducer: (accumulator, currentAvailableValues) => {
@@ -308,7 +309,8 @@ export const settingCategoryAvailableValuesIntersectionReducerMap: SettingCatego
return [Math.max(min, currentMin), Math.min(max, currentMax)];
},
- startingValue: [Number.MIN_VALUE, Number.MAX_VALUE],
+ startingValue: [-Number.MAX_VALUE, Number.MAX_VALUE],
+ isValid: (availableValues) => availableValues[0] < availableValues[1],
},
[SettingCategory.RANGE]: {
reducer: (accumulator, currentAvailableValues) => {
@@ -317,7 +319,8 @@ export const settingCategoryAvailableValuesIntersectionReducerMap: SettingCatego
return [Math.max(min, currentMin), Math.min(max, currentMax)];
},
- startingValue: [Number.MIN_VALUE, Number.MAX_VALUE],
+ startingValue: [-Number.MAX_VALUE, Number.MAX_VALUE],
+ isValid: (availableValues) => availableValues[0] < availableValues[1],
},
};
diff --git a/frontend/src/modules/_shared/DataProviderFramework/visualization/VisualizationAssembler.ts b/frontend/src/modules/_shared/DataProviderFramework/visualization/VisualizationAssembler.ts
index 861854382..2df753173 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/visualization/VisualizationAssembler.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/visualization/VisualizationAssembler.ts
@@ -63,7 +63,7 @@ export type TransformerArgs<
name: string;
isLoading: boolean;
getInjectedData: () => TInjectedData;
- getValueRange: () => [number, number] | null;
+ getValueRange: () => Readonly<[number, number]> | null;
};
export interface HoverVisualizationsFunction {
@@ -302,15 +302,16 @@ export class VisualizationAssembler<
accumulatedData = product.accumulatedData;
aggregatedErrorMessages.push(...product.aggregatedErrorMessages);
hoverVisualizationFunctions.push(product.makeHoverVisualizationsFunction);
- annotations.push(...product.annotations);
numLoadingDataProviders += product.numLoadingDataProviders;
maybeApplyBoundingBox(product.combinedBoundingBox);
if (child instanceof Group) {
- const group = this.makeGroup(child, product.children, annotations);
+ const group = this.makeGroup(child, product.children, product.annotations);
children.push(group);
continue;
+ } else {
+ annotations.push(...product.annotations);
}
children.push(...product.children);
@@ -321,6 +322,10 @@ export class VisualizationAssembler<
numLoadingDataProviders++;
}
+ if (child.getStatus() === DataProviderStatus.INVALID_SETTINGS) {
+ continue;
+ }
+
if (child.getStatus() === DataProviderStatus.ERROR) {
const error = child.getError();
if (error) {
diff --git a/frontend/src/modules/_shared/DataProviderFramework/visualization/deckgl/makeDrilledWellTrajectoriesLayer.ts b/frontend/src/modules/_shared/DataProviderFramework/visualization/deckgl/makeDrilledWellTrajectoriesLayer.ts
index d9499569e..884467563 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/visualization/deckgl/makeDrilledWellTrajectoriesLayer.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/visualization/deckgl/makeDrilledWellTrajectoriesLayer.ts
@@ -53,7 +53,7 @@ function wellTrajectoryToGeojson(wellTrajectory: WellboreTrajectory_api): GeoWel
return geometryCollection;
}
-function zipCoords(xArr: number[], yArr: number[], zArr: number[]): number[][] {
+function zipCoords(xArr: readonly number[], yArr: readonly number[], zArr: readonly number[]): number[][] {
const coords: number[][] = [];
for (let i = 0; i < xArr.length; i++) {
coords.push([xArr[i], yArr[i], -zArr[i]]);
@@ -64,6 +64,7 @@ function zipCoords(xArr: number[], yArr: number[], zArr: number[]): number[][] {
export function makeDrilledWellTrajectoriesLayer({
id,
+ name,
getData,
}: TransformerArgs): WellsLayer | null {
const fieldWellboreTrajectoriesData = getData();
@@ -102,6 +103,7 @@ export function makeDrilledWellTrajectoriesLayer({
const wellsLayer = new AdvancedWellsLayer({
id: id,
+ name,
data: {
type: "FeatureCollection",
features: wellLayerDataFeatures,
diff --git a/frontend/src/modules/_shared/DataProviderFramework/visualization/utils/colors.ts b/frontend/src/modules/_shared/DataProviderFramework/visualization/utils/colors.ts
index 9b2a07c18..f5659d1bb 100644
--- a/frontend/src/modules/_shared/DataProviderFramework/visualization/utils/colors.ts
+++ b/frontend/src/modules/_shared/DataProviderFramework/visualization/utils/colors.ts
@@ -1,4 +1,3 @@
-
import type { Rgb } from "culori";
import { parse } from "culori";
@@ -27,3 +26,4 @@ export function makeColorMapFunctionFromColorScale(
return [color.r * 255, color.g * 255, color.b * 255];
};
}
+0;
diff --git a/frontend/src/modules/_shared/LayerFramework/groups/groupTypes.ts b/frontend/src/modules/_shared/LayerFramework/groups/groupTypes.ts
new file mode 100644
index 000000000..9a7ae4eb6
--- /dev/null
+++ b/frontend/src/modules/_shared/LayerFramework/groups/groupTypes.ts
@@ -0,0 +1,3 @@
+export enum GroupType {
+ VIEW = "VIEW",
+}
diff --git a/frontend/src/modules/_shared/components/ColorLegendsContainer/colorLegendsContainer.tsx b/frontend/src/modules/_shared/components/ColorLegendsContainer/colorLegendsContainer.tsx
index 9160a4c92..7ba05263a 100644
--- a/frontend/src/modules/_shared/components/ColorLegendsContainer/colorLegendsContainer.tsx
+++ b/frontend/src/modules/_shared/components/ColorLegendsContainer/colorLegendsContainer.tsx
@@ -1,4 +1,4 @@
-import type React from "react";
+import React from "react";
import type { ColorScale } from "@lib/utils/ColorScale";
import { ColorScaleGradientType, ColorScaleType } from "@lib/utils/ColorScale";
@@ -34,6 +34,7 @@ function makeMarkers(
sectionBottom: number,
left: number,
barHeight: number,
+ maxNumLog10: number = 4,
): React.ReactNode[] {
const sectionHeight = Math.abs(sectionBottom - sectionTop);
@@ -72,7 +73,7 @@ function makeMarkers(
fontSize="10"
style={TEXT_STYLE}
>
- {formatLegendValue(value)}
+ {formatLegendValue(value, maxNumLog10)}
,
);
@@ -81,7 +82,13 @@ function makeMarkers(
return markers;
}
-function makeDiscreteMarkers(colorScale: ColorScale, left: number, top: number, barHeight: number): React.ReactNode[] {
+function makeDiscreteMarkers(
+ colorScale: ColorScale,
+ left: number,
+ top: number,
+ barHeight: number,
+ maxNumLog10: number = 4,
+): React.ReactNode[] {
const minMarkerHeight = STYLE_CONSTANTS.fontSize + 2 * STYLE_CONSTANTS.textGap;
const numSteps = colorScale.getNumSteps();
@@ -123,7 +130,7 @@ function makeDiscreteMarkers(colorScale: ColorScale, left: number, top: number,
fontSize="10"
style={TEXT_STYLE}
>
- {formatLegendValue(value)}
+ {formatLegendValue(value, maxNumLog10)}
,
);
@@ -140,9 +147,11 @@ type ColorLegendProps = {
left: number;
totalHeight: number;
barWidth: number;
+ maxNumLog10?: number;
};
function ColorLegend(props: ColorLegendProps): React.ReactNode {
+ const clipPathId = React.useId();
const barHeight = props.totalHeight - STYLE_CONSTANTS.offset;
const barStartPosition = props.left + STYLE_CONSTANTS.nameLabelWidth + STYLE_CONSTANTS.textGap;
@@ -170,7 +179,7 @@ function ColorLegend(props: ColorLegendProps): React.ReactNode {
fontSize="10"
style={TEXT_STYLE}
>
- {formatLegendValue(props.colorScale.getMax())}
+ {formatLegendValue(props.colorScale.getMax(), props.maxNumLog10)}
,
);
@@ -274,13 +283,13 @@ function ColorLegend(props: ColorLegendProps): React.ReactNode {
fontSize="10"
style={TEXT_STYLE}
>
- {formatLegendValue(props.colorScale.getMin())}
+ {formatLegendValue(props.colorScale.getMin(), props.maxNumLog10)}
,
);
return (
-
+
{props.colorScale.getName()}
@@ -327,6 +336,7 @@ export type ColorLegendsContainerProps = {
colorScales: ColorScaleWithId[];
height: number;
position?: "left" | "right";
+ maxNumLog10?: number;
};
export function ColorLegendsContainer(props: ColorLegendsContainerProps): React.ReactNode {
@@ -369,6 +379,7 @@ export function ColorLegendsContainer(props: ColorLegendsContainerProps): React.
left={left}
totalHeight={height}
barWidth={width}
+ maxNumLog10={props.maxNumLog10}
/>,
);
}
@@ -443,9 +454,9 @@ function countDecimalPlaces(value: number): number {
return decimalIndex >= 0 ? value.toString().length - decimalIndex - 1 : 0;
}
-function formatLegendValue(value: number): string {
+function formatLegendValue(value: number, maxNumLog10: number = 4): string {
const numDecimalPlaces = countDecimalPlaces(value);
- if (Math.log10(Math.abs(value)) > 2) {
+ if (Math.log10(Math.abs(value)) > maxNumLog10) {
return value.toExponential(numDecimalPlaces > 2 ? 2 : numDecimalPlaces);
}
return value.toFixed(numDecimalPlaces > 2 ? 2 : numDecimalPlaces);
diff --git a/frontend/src/modules/_shared/customDeckGlLayers/PlaceholderLayer.ts~HEAD b/frontend/src/modules/_shared/customDeckGlLayers/PlaceholderLayer.ts~HEAD
new file mode 100644
index 000000000..b146c9fea
--- /dev/null
+++ b/frontend/src/modules/_shared/customDeckGlLayers/PlaceholderLayer.ts~HEAD
@@ -0,0 +1,21 @@
+import { Layer } from "@deck.gl/core";
+
+type PlaceholderLayerProps = {
+ id: string;
+};
+
+export class PlaceholderLayer extends Layer {
+ static layerName: string = "PlaceholderLayer";
+
+ constructor(props: PlaceholderLayerProps) {
+ super(props);
+ }
+
+ initializeState(): void {
+ return;
+ }
+
+ render() {
+ return null;
+ }
+}
diff --git a/frontend/src/modules/_shared/customDeckGlLayers/PlaceholderLayer.ts~main b/frontend/src/modules/_shared/customDeckGlLayers/PlaceholderLayer.ts~main
new file mode 100644
index 000000000..b146c9fea
--- /dev/null
+++ b/frontend/src/modules/_shared/customDeckGlLayers/PlaceholderLayer.ts~main
@@ -0,0 +1,21 @@
+import { Layer } from "@deck.gl/core";
+
+type PlaceholderLayerProps = {
+ id: string;
+};
+
+export class PlaceholderLayer extends Layer {
+ static layerName: string = "PlaceholderLayer";
+
+ constructor(props: PlaceholderLayerProps) {
+ super(props);
+ }
+
+ initializeState(): void {
+ return;
+ }
+
+ render() {
+ return null;
+ }
+}
diff --git a/frontend/src/modules/_shared/customDeckGlLayers/PreviewLayer/PreviewLayer.ts b/frontend/src/modules/_shared/customDeckGlLayers/PreviewLayer/PreviewLayer.ts
new file mode 100644
index 000000000..c4d958110
--- /dev/null
+++ b/frontend/src/modules/_shared/customDeckGlLayers/PreviewLayer/PreviewLayer.ts
@@ -0,0 +1,55 @@
+import { CompositeLayer, Layer, LayersList } from "@deck.gl/core";
+import { Geometry, ShapeType } from "@lib/utils/geometry";
+import * as vec3 from "@lib/utils/vec3";
+
+import { BoxLayer } from "./_private/BoxLayer";
+
+export type PreviewLayerProps = {
+ id: string;
+ data: {
+ geometry: Geometry;
+ };
+ zIncreaseDownwards?: boolean;
+};
+
+export class PreviewLayer extends CompositeLayer {
+ static layerName = "PreviewLayer";
+
+ renderLayers(): LayersList {
+ const { data } = this.props;
+
+ const layers: Layer[] = [];
+
+ const zFactor = this.props.zIncreaseDownwards ? -1 : 1;
+
+ for (const [idx, shape] of data.geometry.shapes.entries()) {
+ if (shape.type === ShapeType.BOX) {
+ layers.push(
+ new BoxLayer({
+ id: `${idx}`,
+ data: {
+ centerPoint: vec3.toArray(
+ vec3.multiplyElementWise(shape.centerPoint, vec3.create(1, 1, zFactor)),
+ ),
+ dimensions: [
+ shape.dimensions.width,
+ shape.dimensions.height,
+ shape.dimensions.depth * zFactor,
+ ],
+ normalizedEdgeVectors: [
+ vec3.toArray(
+ vec3.multiplyElementWise(shape.normalizedEdgeVectors.u, vec3.create(1, 1, zFactor)),
+ ),
+ vec3.toArray(
+ vec3.multiplyElementWise(shape.normalizedEdgeVectors.v, vec3.create(1, 1, zFactor)),
+ ),
+ ],
+ },
+ }),
+ );
+ }
+ }
+
+ return layers;
+ }
+}
diff --git a/frontend/src/modules/_shared/customDeckGlLayers/PreviewLayer/_private/BoxLayer.ts b/frontend/src/modules/_shared/customDeckGlLayers/PreviewLayer/_private/BoxLayer.ts
new file mode 100644
index 000000000..37302a313
--- /dev/null
+++ b/frontend/src/modules/_shared/customDeckGlLayers/PreviewLayer/_private/BoxLayer.ts
@@ -0,0 +1,198 @@
+import { CompositeLayer, CompositeLayerProps, Layer, UpdateParameters } from "@deck.gl/core";
+import { SimpleMeshLayer } from "@deck.gl/mesh-layers";
+import * as vec3 from "@lib/utils/vec3";
+import { Geometry } from "@luma.gl/engine";
+
+export type RectangleLayerData = {
+ centerPoint: [number, number, number];
+ dimensions: [number, number, number];
+ normalizedEdgeVectors: [[number, number, number], [number, number, number]];
+};
+
+export type RectangleLayerProps = {
+ id: string;
+ data: RectangleLayerData;
+};
+
+export class BoxLayer extends CompositeLayer {
+ static layerName = "BoxLayer";
+
+ // @ts-expect-error - private
+ state!: {
+ geometry: Geometry;
+ };
+
+ private makeGeometry(): Geometry {
+ const { data } = this.props;
+
+ const vertices: Float32Array = new Float32Array(8 * 3);
+ const indices: Uint16Array = new Uint16Array(3 * 2 * 6);
+
+ const [centerX, centerY, centerZ] = data.centerPoint;
+ const [width, height, depth] = data.dimensions;
+ const [[uX, uY, uZ], [vX, vY, vZ]] = data.normalizedEdgeVectors;
+
+ const halfWidth = width / 2;
+ const halfHeight = height / 2;
+ const halfDepth = depth / 2;
+
+ const center = vec3.fromArray([centerX, centerY, centerZ]);
+ const vecU = vec3.fromArray([uX, uY, uZ]);
+ const vecV = vec3.fromArray([vX, vY, vZ]);
+
+ // Make normal vector from u and v
+ const vecW = vec3.cross(vecU, vecV);
+
+ // Make vertices wrt to vectors
+ vertices.set(
+ vec3.toArray(
+ vec3.concat(
+ center,
+ vec3.scale(vecU, halfWidth),
+ vec3.scale(vecV, halfHeight),
+ vec3.scale(vecW, halfDepth),
+ ),
+ ),
+ 0,
+ );
+ vertices.set(
+ vec3.toArray(
+ vec3.concat(
+ center,
+ vec3.scale(vecU, halfWidth),
+ vec3.scale(vecV, -halfHeight),
+ vec3.scale(vecW, halfDepth),
+ ),
+ ),
+ 3,
+ );
+ vertices.set(
+ vec3.toArray(
+ vec3.concat(
+ center,
+ vec3.scale(vecU, -halfWidth),
+ vec3.scale(vecV, -halfHeight),
+ vec3.scale(vecW, halfDepth),
+ ),
+ ),
+ 6,
+ );
+ vertices.set(
+ vec3.toArray(
+ vec3.concat(
+ center,
+ vec3.scale(vecU, -halfWidth),
+ vec3.scale(vecV, halfHeight),
+ vec3.scale(vecW, halfDepth),
+ ),
+ ),
+ 9,
+ );
+ vertices.set(
+ vec3.toArray(
+ vec3.concat(
+ center,
+ vec3.scale(vecU, halfWidth),
+ vec3.scale(vecV, halfHeight),
+ vec3.scale(vecW, -halfDepth),
+ ),
+ ),
+ 12,
+ );
+ vertices.set(
+ vec3.toArray(
+ vec3.concat(
+ center,
+ vec3.scale(vecU, halfWidth),
+ vec3.scale(vecV, -halfHeight),
+ vec3.scale(vecW, -halfDepth),
+ ),
+ ),
+ 15,
+ );
+ vertices.set(
+ vec3.toArray(
+ vec3.concat(
+ center,
+ vec3.scale(vecU, -halfWidth),
+ vec3.scale(vecV, -halfHeight),
+ vec3.scale(vecW, -halfDepth),
+ ),
+ ),
+ 18,
+ );
+ vertices.set(
+ vec3.toArray(
+ vec3.concat(
+ center,
+ vec3.scale(vecU, -halfWidth),
+ vec3.scale(vecV, halfHeight),
+ vec3.scale(vecW, -halfDepth),
+ ),
+ ),
+ 21,
+ );
+
+ // Front
+ indices.set([0, 1, 2], 0);
+ indices.set([0, 2, 3], 3);
+
+ // Back
+ indices.set([4, 6, 5], 6);
+ indices.set([4, 7, 6], 9);
+
+ // Left
+ indices.set([0, 7, 4], 12);
+ indices.set([0, 3, 7], 15);
+
+ // Right
+ indices.set([1, 5, 6], 18);
+ indices.set([1, 6, 2], 21);
+
+ // Top
+ indices.set([3, 2, 6], 24);
+ indices.set([3, 6, 7], 27);
+
+ // Bottom
+ indices.set([0, 4, 5], 30);
+ indices.set([0, 5, 1], 33);
+
+ return new Geometry({
+ topology: "triangle-list",
+ attributes: {
+ positions: vertices,
+ },
+ indices,
+ });
+ }
+
+ initializeState(): void {
+ this.setState({
+ ...this.state,
+ isHovered: false,
+ isLoaded: false,
+ });
+ }
+
+ updateState({ changeFlags }: UpdateParameters>>) {
+ if (changeFlags.dataChanged) {
+ this.setState({
+ geometry: this.makeGeometry(),
+ });
+ }
+ }
+
+ renderLayers() {
+ return [
+ new SimpleMeshLayer({
+ id: "mesh",
+ data: [0],
+ mesh: this.state.geometry,
+ getPosition: (d) => [0, 0, 0],
+ getColor: [100, 100, 100, 100],
+ material: { ambient: 0.95, diffuse: 1, shininess: 0, specularColor: [0, 0, 0] },
+ pickable: false,
+ }),
+ ];
+ }
+}
diff --git a/frontend/src/modules/_shared/customDeckGlLayers/SeismicFenceMeshLayer/SeismicFenceMeshLayer.ts b/frontend/src/modules/_shared/customDeckGlLayers/SeismicFenceMeshLayer/SeismicFenceMeshLayer.ts
new file mode 100644
index 000000000..7ec67e274
--- /dev/null
+++ b/frontend/src/modules/_shared/customDeckGlLayers/SeismicFenceMeshLayer/SeismicFenceMeshLayer.ts
@@ -0,0 +1,393 @@
+import {
+ CompositeLayer,
+ CompositeLayerProps,
+ GetPickingInfoParams,
+ Layer,
+ PickingInfo,
+ UpdateParameters,
+} from "@deck.gl/core";
+import { Geometry as LoadingGeometry } from "@lib/utils/geometry";
+import { Geometry } from "@luma.gl/engine";
+import { ExtendedLayerProps } from "@webviz/subsurface-viewer";
+import { BoundingBox3D, ReportBoundingBoxAction } from "@webviz/subsurface-viewer/dist/components/Map";
+
+import { isEqual } from "lodash";
+import workerpool from "workerpool";
+
+import { ExtendedSimpleMeshLayer } from "./_private/ExtendedSimpleMeshLayer";
+import { WebworkerParameters, makeMesh } from "./_private/worker";
+
+import { PreviewLayer } from "../PreviewLayer/PreviewLayer";
+
+export type SeismicFenceMeshLayerPickingInfo = {
+ properties?: { name: string; value: number }[];
+} & PickingInfo;
+
+export type SeismicFenceSection = {
+ numSamplesU: number;
+ numSamplesV: number;
+ properties: Float32Array;
+ boundingBox: number[][]; // [minX, minY, minZ, maxX, maxY, maxZ]
+};
+
+export interface SeismicFenceMeshLayerProps extends ExtendedLayerProps {
+ data: {
+ sections: SeismicFenceSection[];
+ };
+ colorMapFunction: (value: number) => [number, number, number];
+ hoverable?: boolean;
+ zIncreaseDownwards?: boolean;
+ isLoading?: boolean;
+ loadingGeometry?: LoadingGeometry;
+
+ // Non public properties:
+ reportBoundingBox?: React.Dispatch;
+}
+
+function assert(condition: any, msg?: string): asserts condition {
+ if (!condition) {
+ throw new Error(msg);
+ }
+}
+
+export class SeismicFenceMeshLayer extends CompositeLayer {
+ static layerName: string = "SeismicFenceMeshLayer";
+
+ private _pool = workerpool.pool({
+ workerType: "web",
+ maxWorkers: 10,
+ workerOpts: {
+ // By default, Vite uses a module worker in dev mode, which can cause your application to fail.
+ // Therefore, we need to use a module worker in dev mode and a classic worker in prod mode.
+ type: import.meta.env.PROD ? undefined : "module",
+ },
+ });
+ private _numTasks = 0;
+ private _numTasksCompleted = 0;
+ private _numTasksFailed = 0;
+ private _sharedVerticesBuffer: SharedArrayBuffer | null = null;
+ private _sharedIndicesBuffer: SharedArrayBuffer | null = null;
+ private _colorsArray: Float32Array = new Float32Array();
+
+ // @ts-expect-error - This is how deck.gl expects the state to be defined
+ state!: {
+ geometry: Geometry;
+ isHovered: boolean;
+ meshCreated: boolean;
+ colorsArrayCreated: boolean;
+ };
+
+ initializeState(): void {
+ this.setState({
+ isHovered: false,
+ meshCreated: false,
+ colorsArrayCreated: false,
+ geometry: new Geometry({
+ attributes: {
+ positions: new Float32Array(),
+ },
+ topology: "triangle-list",
+ }),
+ });
+
+ this.rebuildMesh();
+ this.recolorMesh();
+ }
+
+ updateState({
+ props,
+ oldProps,
+ }: UpdateParameters>>) {
+ const meshRecomputationRequired =
+ !isEqual(oldProps.data?.sections.length, props.data?.sections.length) ||
+ !isEqual(oldProps.data?.sections, props.data?.sections) ||
+ !isEqual(oldProps.zIncreaseDownwards, props.zIncreaseDownwards);
+
+ const colorMapFunctionChanged = !isEqual(oldProps.colorMapFunction, props.colorMapFunction);
+
+ if (!meshRecomputationRequired && !colorMapFunctionChanged) {
+ return;
+ }
+
+ if (props.isLoading) {
+ return;
+ }
+
+ if (meshRecomputationRequired) {
+ this.rebuildMesh();
+ }
+
+ if (colorMapFunctionChanged) {
+ this.recolorMesh();
+ }
+ }
+
+ private calcNumVerticesForSection(section: SeismicFenceSection): number {
+ return section.numSamplesU * section.numSamplesV * 3;
+ }
+
+ private calcNumIndicesForSection(section: SeismicFenceSection): number {
+ return (section.numSamplesU - 1) * (section.numSamplesV - 1) * 6;
+ }
+
+ private initSharedBuffers() {
+ const { data } = this.props;
+
+ let totalNumVertices = 0;
+ let totalNumIndices = 0;
+
+ for (const section of data.sections) {
+ totalNumVertices += this.calcNumVerticesForSection(section);
+ totalNumIndices += this.calcNumIndicesForSection(section);
+ }
+ this._sharedVerticesBuffer = new SharedArrayBuffer(totalNumVertices * Float32Array.BYTES_PER_ELEMENT);
+ this._sharedIndicesBuffer = new SharedArrayBuffer(totalNumIndices * Uint32Array.BYTES_PER_ELEMENT);
+ }
+
+ private calcBoundingBox(): BoundingBox3D {
+ let xmin = Number.MAX_VALUE;
+ let ymin = Number.MAX_VALUE;
+ let zmin = Number.MAX_VALUE;
+ let xmax = Number.MIN_VALUE;
+ let ymax = Number.MIN_VALUE;
+ let zmax = Number.MIN_VALUE;
+
+ const zFactor = this.props.zIncreaseDownwards ? -1 : 1;
+
+ for (const section of this.props.data.sections) {
+ for (const point of section.boundingBox) {
+ xmin = Math.min(xmin, point[0]);
+ ymin = Math.min(ymin, point[1]);
+ zmin = Math.min(zmin, zFactor * point[2]);
+ xmax = Math.max(xmax, point[0]);
+ ymax = Math.max(ymax, point[1]);
+ zmax = Math.max(zmax, zFactor * point[2]);
+ }
+ }
+
+ return [xmin, ymin, zmin, xmax, ymax, zmax];
+ }
+
+ private maybeUpdateGeometry() {
+ const { geometry } = this.state;
+ if (this._numTasks === this._numTasksCompleted) {
+ const verticesArr = new Float32Array(this._sharedVerticesBuffer!);
+ const indicesArr = new Uint32Array(this._sharedIndicesBuffer!);
+
+ this.setState({
+ ...this.state,
+ geometry: new Geometry({
+ attributes: {
+ ...geometry.attributes,
+ positions: verticesArr,
+ },
+ topology: "triangle-list",
+ indices: indicesArr,
+ }),
+ meshCreated: true,
+ });
+
+ this.props.reportBoundingBox?.({
+ layerBoundingBox: this.calcBoundingBox(),
+ });
+ }
+ }
+
+ private calcOrigin(): [number, number, number] {
+ const { data, zIncreaseDownwards } = this.props;
+
+ if (data.sections.length === 0) {
+ return [0, 0, 0];
+ }
+
+ const firstSection = data.sections[0];
+
+ return [
+ firstSection.boundingBox[0][0],
+ firstSection.boundingBox[0][1],
+ (zIncreaseDownwards ? -1 : 1) * firstSection.boundingBox[0][2],
+ ];
+ }
+
+ private rebuildMesh() {
+ const { zIncreaseDownwards } = this.props;
+
+ this.setState({ ...this.state, meshCreated: false });
+
+ this.initSharedBuffers();
+
+ assert(this._sharedVerticesBuffer !== null, "Shared vertices buffer is null");
+ assert(this._sharedIndicesBuffer !== null, "Shared indices buffer is null");
+
+ const origin = this.calcOrigin();
+
+ let verticesIndex = 0;
+ let indicesIndex = 0;
+ for (const section of this.props.data.sections) {
+ this._numTasks++;
+
+ const offset: [number, number, number] = [
+ section.boundingBox[0][0] - origin[0],
+ section.boundingBox[0][1] - origin[1],
+ (zIncreaseDownwards ? -1 : 1) * section.boundingBox[0][2] - origin[2],
+ ];
+
+ const params: WebworkerParameters = {
+ offset,
+ numSamplesU: section.numSamplesU,
+ numSamplesV: section.numSamplesV,
+ boundingBox: section.boundingBox,
+ startVerticesIndex: verticesIndex,
+ startIndicesIndex: indicesIndex,
+ sharedVerticesBuffer: this._sharedVerticesBuffer,
+ sharedIndicesBuffer: this._sharedIndicesBuffer,
+ zIncreasingDownwards: this.props.zIncreaseDownwards ?? false,
+ };
+
+ verticesIndex += this.calcNumVerticesForSection(section);
+ indicesIndex += this.calcNumIndicesForSection(section);
+
+ this._pool
+ .exec(makeMesh, [{ ...params }])
+ .then(() => {
+ this._numTasksCompleted++;
+ this.maybeUpdateGeometry();
+ })
+ .catch(() => {
+ this._numTasksFailed++;
+ });
+ }
+ }
+
+ private recolorMesh() {
+ const { geometry } = this.state;
+
+ this.setState({ ...this.state, colorsArrayCreated: false });
+
+ this.makeColorsArray().then(() => {
+ this.setState({
+ ...this.state,
+ geometry: new Geometry({
+ attributes: {
+ ...geometry.attributes,
+ colors: {
+ value: this._colorsArray,
+ size: 4,
+ },
+ },
+ topology: "triangle-list",
+ indices: geometry.indices,
+ }),
+ colorsArrayCreated: true,
+ });
+ });
+ }
+
+ private async makeColorsArray() {
+ const { data, colorMapFunction } = this.props;
+
+ this._colorsArray = new Float32Array(
+ data.sections.reduce((acc, section) => acc + section.properties.length * 4, 0),
+ );
+
+ let colorIndex = 0;
+ for (const section of data.sections) {
+ for (let i = 0; i < section.properties.length; i++) {
+ const [r, g, b] = colorMapFunction(section.properties[i]);
+ this._colorsArray[colorIndex * 4 + 0] = r / 255;
+ this._colorsArray[colorIndex * 4 + 1] = g / 255;
+ this._colorsArray[colorIndex * 4 + 2] = b / 255;
+ this._colorsArray[colorIndex * 4 + 3] = 1;
+ colorIndex++;
+ }
+ }
+ }
+
+ private getProperty(vertexIndex: number): number {
+ const { data } = this.props;
+
+ let offset = 0;
+ for (const section of data.sections) {
+ if (vertexIndex < offset + section.properties.length) {
+ return section.properties[vertexIndex - offset];
+ }
+ offset += section.properties.length;
+ }
+
+ return 0;
+ }
+
+ getPickingInfo({ info }: GetPickingInfoParams): SeismicFenceMeshLayerPickingInfo {
+ const { zIncreaseDownwards } = this.props;
+ if (!info.color) {
+ return info;
+ }
+
+ const r = info.color[0];
+ const g = info.color[1];
+ const b = info.color[2];
+
+ const vertexIndex = r * 256 * 256 + g * 256 + b;
+
+ const property = this.getProperty(vertexIndex);
+
+ if (property === undefined) {
+ return info;
+ }
+
+ const properties: { name: string; value: number }[] = [];
+ properties.push({ name: "Property", value: property });
+ if (info.coordinate?.length === 3) {
+ properties.push({ name: "Depth", value: (zIncreaseDownwards ? -1 : 1) * info.coordinate[2] });
+ }
+
+ return {
+ ...info,
+ properties,
+ };
+ }
+
+ onHover(pickingInfo: PickingInfo): boolean {
+ this.setState({ ...this.state, isHovered: pickingInfo.index !== -1 });
+ return false;
+ }
+
+ renderLayers() {
+ const { isLoading, zIncreaseDownwards, loadingGeometry } = this.props;
+ const { geometry, meshCreated, colorsArrayCreated } = this.state;
+
+ const origin = this.calcOrigin();
+
+ const layers: Layer[] = [];
+
+ if ((isLoading || !meshCreated || !colorsArrayCreated) && loadingGeometry) {
+ layers.push(
+ new PreviewLayer({
+ id: "seismic-fence-mesh-layer-loading",
+ data: {
+ geometry: loadingGeometry,
+ },
+ zIncreaseDownwards,
+ }),
+ );
+ } else {
+ layers.push(
+ new ExtendedSimpleMeshLayer({
+ id: "seismic-fence-mesh-layer",
+ data: [0],
+ mesh: geometry,
+ getPosition: origin,
+ getColor: [255, 255, 255, 255],
+ material: { ambient: 0.95, diffuse: 1, shininess: 0, specularColor: [0, 0, 0] },
+ pickable: true,
+ }),
+ );
+ }
+
+ return layers;
+ }
+
+ finalize() {
+ this._pool.terminate();
+ }
+}
diff --git a/frontend/src/modules/_shared/customDeckGlLayers/SeismicFenceMeshLayer/_private/ExtendedSimpleMeshLayer.ts b/frontend/src/modules/_shared/customDeckGlLayers/SeismicFenceMeshLayer/_private/ExtendedSimpleMeshLayer.ts
new file mode 100644
index 000000000..19593b53f
--- /dev/null
+++ b/frontend/src/modules/_shared/customDeckGlLayers/SeismicFenceMeshLayer/_private/ExtendedSimpleMeshLayer.ts
@@ -0,0 +1,46 @@
+import { SimpleMeshLayer } from "@deck.gl/mesh-layers";
+
+export class ExtendedSimpleMeshLayer extends SimpleMeshLayer {
+ static name = "ExtendedSimpleMeshLayer";
+ static componentName = "ExtendedSimpleMeshLayer";
+
+ getShaders() {
+ return {
+ ...super.getShaders(),
+ inject: {
+ "vs:#decl": `
+ flat out int vertexIndex;`,
+ "vs:#main-end": `
+ vertexIndex = gl_VertexID;`,
+ "fs:#decl": `
+ flat in int vertexIndex;
+
+ vec4 encodeVertexIndexToRGB (int vertexIndex) {
+ float r = 0.0;
+ float g = 0.0;
+ float b = 0.0;
+
+ if (vertexIndex >= (256 * 256) - 1) {
+ r = floor(float(vertexIndex) / (256.0 * 256.0));
+ vertexIndex -= int(r * (256.0 * 256.0));
+ }
+
+ if (vertexIndex >= 256 - 1) {
+ g = floor(float(vertexIndex) / 256.0);
+ vertexIndex -= int(g * 256.0);
+ }
+
+ b = float(vertexIndex);
+
+ return vec4(r / 255.0, g / 255.0, b / 255.0, 1.0);
+ }
+ `,
+ "fs:#main-start": `
+ if (picking.isActive > 0.5 && !(picking.isAttribute > 0.5)) {
+ fragColor = encodeVertexIndexToRGB(vertexIndex);
+ return;
+ }`,
+ },
+ };
+ }
+}
diff --git a/frontend/src/modules/_shared/customDeckGlLayers/SeismicFenceMeshLayer/_private/worker.ts b/frontend/src/modules/_shared/customDeckGlLayers/SeismicFenceMeshLayer/_private/worker.ts
new file mode 100644
index 000000000..e1e6c4470
--- /dev/null
+++ b/frontend/src/modules/_shared/customDeckGlLayers/SeismicFenceMeshLayer/_private/worker.ts
@@ -0,0 +1,67 @@
+export type WebworkerParameters = {
+ offset: [number, number, number];
+ sharedVerticesBuffer: SharedArrayBuffer;
+ sharedIndicesBuffer: SharedArrayBuffer;
+ startVerticesIndex: number;
+ startIndicesIndex: number;
+ numSamplesU: number;
+ numSamplesV: number;
+ boundingBox: number[][];
+ zIncreasingDownwards: boolean;
+};
+
+/*
+Generates a mesh for a seismic fence.
+@param parameters The parameters for generating the mesh
+- offset: The offset of the mesh
+- sharedVerticesBuffer: The shared vertices buffer
+- sharedIndicesBuffer: The shared indices buffer
+- startVerticesIndex: The start index of the vertices in the shared memory buffer
+- startIndicesIndex: The start index of the indices in the shared memory buffer
+- numSamplesU: The number of samples in the U direction
+- numSamplesV: The number of samples in the V direction
+- boundingBox: The bounding box of the mesh - used to transform UV coordinates to XYZ coordinates
+- zIncreasingDownwards: Whether the Z axis increases downwards
+
+*/
+export function makeMesh(parameters: WebworkerParameters) {
+ const bbox = parameters.boundingBox;
+
+ const vectorV = [bbox[1][0] - bbox[0][0], bbox[1][1] - bbox[0][1], bbox[1][2] - bbox[0][2]];
+ const vectorU = [bbox[2][0] - bbox[0][0], bbox[2][1] - bbox[0][1], bbox[2][2] - bbox[0][2]];
+
+ function transformUVToXYZ(u: number, v: number): [number, number, number] {
+ const x = parameters.offset[0] + u * vectorU[0] + v * vectorV[0];
+ const y = parameters.offset[1] + u * vectorU[1] + v * vectorV[1];
+ const z = parameters.offset[2] + (parameters.zIncreasingDownwards ? -1 : 1) * (v * vectorV[2] + u * vectorU[2]);
+ return [x, y, z];
+ }
+
+ const verticesArray = new Float32Array(parameters.sharedVerticesBuffer);
+ const indicesArray = new Uint32Array(parameters.sharedIndicesBuffer);
+
+ const stepU = 1.0 / (parameters.numSamplesU - 1);
+ const stepV = 1.0 / (parameters.numSamplesV - 1);
+
+ let verticesIndex = parameters.startVerticesIndex;
+ let indicesIndex = parameters.startIndicesIndex;
+
+ for (let v = 0; v < parameters.numSamplesV; v++) {
+ for (let u = 0; u < parameters.numSamplesU; u++) {
+ const [x, y, z] = transformUVToXYZ(u * stepU, v * stepV);
+ verticesArray[verticesIndex++] = x;
+ verticesArray[verticesIndex++] = y;
+ verticesArray[verticesIndex++] = z;
+
+ if (u > 0 && v > 0) {
+ indicesArray[indicesIndex++] = (v - 1) * parameters.numSamplesU + u - 1;
+ indicesArray[indicesIndex++] = (v - 1) * parameters.numSamplesU + u;
+ indicesArray[indicesIndex++] = v * parameters.numSamplesU + u - 1;
+
+ indicesArray[indicesIndex++] = v * parameters.numSamplesU + u - 1;
+ indicesArray[indicesIndex++] = (v - 1) * parameters.numSamplesU + u;
+ indicesArray[indicesIndex++] = v * parameters.numSamplesU + u;
+ }
+ }
+ }
+}
diff --git a/frontend/src/modules/_shared/customDeckGlLayers/WellborePicksLayer.ts b/frontend/src/modules/_shared/customDeckGlLayers/WellborePicksLayer.ts
index 1bb9993c7..9ef1b6692 100644
--- a/frontend/src/modules/_shared/customDeckGlLayers/WellborePicksLayer.ts
+++ b/frontend/src/modules/_shared/customDeckGlLayers/WellborePicksLayer.ts
@@ -1,7 +1,18 @@
-import type { CompositeLayerProps, FilterContext, Layer, UpdateParameters } from "@deck.gl/core";
-import { CompositeLayer } from "@deck.gl/core";
-import { GeoJsonLayer, TextLayer } from "@deck.gl/layers";
-import type { Feature, FeatureCollection } from "geojson";
+import {
+ CompositeLayer,
+ CompositeLayerProps,
+ FilterContext,
+ Layer,
+ LayerContext,
+ PickingInfo,
+ UpdateParameters,
+} from "@deck.gl/core";
+import { PointCloudLayer } from "@deck.gl/layers";
+import { COLORS } from "@lib/utils/colorConstants";
+import { ExtendedLayerProps } from "@webviz/subsurface-viewer";
+import { BoundingBox3D, ReportBoundingBoxAction } from "@webviz/subsurface-viewer/dist/components/Map";
+
+import { isEqual } from "lodash";
export type WellborePicksLayerData = {
easting: number;
@@ -12,20 +23,37 @@ export type WellborePicksLayerData = {
slotName: string;
};
-type TextLayerData = {
- coordinates: [number, number, number];
- name: string;
-};
-
-export type WellBorePicksLayerProps = {
+export interface WellBorePicksLayerProps extends ExtendedLayerProps {
id: string;
data: WellborePicksLayerData[];
-};
+ zIncreaseDownwards?: boolean;
+
+ // Non public properties:
+ reportBoundingBox?: React.Dispatch;
+}
+
+// properties.name is required to trigger tooltip in Map.tsx component in subsurface-viewer
+type PointsData = { coordinates: [number, number, number]; properties: { name: string } };
export class WellborePicksLayer extends CompositeLayer {
static layerName: string = "WellborePicksLayer";
- private _textData: TextLayerData[] = [];
- private _pointsData: FeatureCollection | null = null;
+
+ // @ts-expect-error - This is how deck.gl expects the state to be defined
+ // For instance, see her:
+ // https://github.com/visgl/deck.gl/blob/master/modules/layers/src/point-cloud-layer/point-cloud-layer.ts#L123
+ state!: {
+ pointsData: PointsData[];
+ hoveredIndex: number | null;
+ };
+
+ initializeState(context: LayerContext): void {
+ super.initializeState(context);
+
+ this.state = {
+ pointsData: [],
+ hoveredIndex: null,
+ };
+ }
filterSubLayer(context: FilterContext): boolean {
if (context.layer.id.includes("text")) {
@@ -35,87 +63,94 @@ export class WellborePicksLayer extends CompositeLayer
return true;
}
- updateState(params: UpdateParameters>>): void {
- const features: Feature[] = params.props.data.map((wellPick) => {
+ private calcBoundingBox(): BoundingBox3D {
+ const { data } = this.props;
+
+ let minX = Number.MAX_VALUE;
+ let minY = Number.MAX_VALUE;
+ let minZ = Number.MAX_VALUE;
+ let maxX = Number.MIN_VALUE;
+ let maxY = Number.MIN_VALUE;
+ let maxZ = Number.MIN_VALUE;
+
+ for (const wellPick of data) {
+ minX = Math.min(minX, wellPick.easting);
+ minY = Math.min(minY, wellPick.northing);
+ minZ = Math.min(minZ, wellPick.tvdMsl);
+ maxX = Math.max(maxX, wellPick.easting);
+ maxY = Math.max(maxY, wellPick.northing);
+ maxZ = Math.max(maxZ, wellPick.tvdMsl);
+ }
+
+ return [minX, minY, minZ, maxX, maxY, maxZ];
+ }
+
+ updateState({
+ changeFlags,
+ props,
+ oldProps,
+ }: UpdateParameters>>): void {
+ if (!changeFlags.dataChanged) {
+ return;
+ }
+
+ if (isEqual(props.data, oldProps.data)) {
+ return;
+ }
+
+ const pointsData: PointsData[] = props.data.map((wellPick) => {
return {
- type: "Feature",
- geometry: {
- type: "Point",
- coordinates: [wellPick.easting, wellPick.northing],
- },
+ coordinates: [wellPick.easting, wellPick.northing, wellPick.tvdMsl],
properties: {
name: `${wellPick.wellBoreUwi}, TVD_MSL: ${wellPick.tvdMsl}, MD: ${wellPick.md}`,
- color: [100, 100, 100, 100],
},
};
});
- const pointsData: FeatureCollection = {
- type: "FeatureCollection",
- features: features,
- };
+ this.setState({
+ pointsData,
+ });
- const textData: TextLayerData[] = this.props.data.map((wellPick) => {
- return {
- coordinates: [wellPick.easting, wellPick.northing, wellPick.tvdMsl],
- name: wellPick.wellBoreUwi,
- };
+ this.props.reportBoundingBox?.({
+ layerBoundingBox: this.calcBoundingBox(),
});
+ }
+
+ onHover(info: PickingInfo): boolean {
+ const { index } = info;
+ this.setState({ hoveredIndex: index });
- this._pointsData = pointsData;
- this._textData = textData;
+ return false;
}
renderLayers() {
- const fontSize = 16;
- const sizeMinPixels = 16;
- const sizeMaxPixels = 16;
+ const { zIncreaseDownwards } = this.props;
+ const { pointsData, hoveredIndex } = this.state;
return [
- new GeoJsonLayer(
- this.getSubLayerProps({
- id: "points",
- data: this._pointsData ?? undefined,
- filled: true,
- lineWidthMinPixels: 5,
- lineWidthMaxPixels: 5,
- lineWidthUnits: "meters",
- parameters: {
- depthTest: false,
- },
- getLineWidth: 1,
- depthTest: false,
- pickable: true,
- getText: (d: Feature) => d.properties?.wellBoreUwi,
- getLineColor: [50, 50, 50],
- }),
- ),
-
- new TextLayer(
- this.getSubLayerProps({
- id: "text",
- data: this._textData,
- pickable: true,
- getColor: [255, 255, 255],
- fontWeight: 800,
- fontSettings: {
- fontSize: fontSize * 2,
- sdf: true,
- },
- outlineColor: [0, 0, 0],
- outlineWidth: 2,
- getSize: 12,
- sdf: true,
- sizeScale: fontSize,
- sizeUnits: "meters",
- sizeMinPixels: sizeMinPixels,
- sizeMaxPixels: sizeMaxPixels,
- getAlignmentBaseline: "top",
- getTextAnchor: "middle",
- getPosition: (d: TextLayerData) => d.coordinates,
- getText: (d: TextLayerData) => d.name,
- }),
- ),
+ new PointCloudLayer({
+ id: `${this.props.id}-points`,
+ data: pointsData,
+ pickable: true,
+ getPosition: (d) => {
+ const zFactor = zIncreaseDownwards ? -1 : 1;
+ return [d.coordinates[0], d.coordinates[1], d.coordinates[2] * zFactor];
+ },
+ getColor: (_, ctx) => {
+ if (ctx.index === hoveredIndex) {
+ return COLORS.hover;
+ }
+
+ return [100, 100, 100];
+ },
+ pointSize: 15,
+ sizeUnits: "meters",
+ material: { ambient: 0.75, diffuse: 0.4, shininess: 0, specularColor: [0, 0, 0] },
+ updateTriggers: {
+ getColor: [hoveredIndex],
+ getPosition: [zIncreaseDownwards],
+ },
+ }),
];
}
}
diff --git a/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/WellsLayer.ts b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/WellsLayer.ts
new file mode 100644
index 000000000..8e8d5ba47
--- /dev/null
+++ b/frontend/src/modules/_shared/customDeckGlLayers/WellsLayer/WellsLayer.ts
@@ -0,0 +1,142 @@
+import {
+ CompositeLayer,
+ GetPickingInfoParams,
+ LayersList,
+ type Material,
+ PickingInfo,
+ type UpdateParameters,
+} from "@deck.gl/core";
+import * as vec3 from "@lib/utils/vec3";
+import { ExtendedLayerProps, LayerPickInfo } from "@webviz/subsurface-viewer";
+import { BoundingBox3D, ReportBoundingBoxAction } from "@webviz/subsurface-viewer/dist/components/Map";
+
+import { type PipeLayerProps, PipesLayer } from "./_private/PipeLayer";
+import { getMd } from "./_private/wellTrajectoryUtils";
+
+export type WellsLayerData = {
+ coordinates: [number, number, number][];
+ properties: { uuid: string; name: string; mdArray: number[] };
+}[];
+
+export interface WellsLayerProps extends ExtendedLayerProps {
+ id: string;
+ data: WellsLayerData;
+ zIncreaseDownwards?: boolean;
+
+ boundingBox: BoundingBox3D;
+
+ // Non public properties:
+ reportBoundingBox?: React.Dispatch;
+}
+
+const MATERIAL: Material = {
+ ambient: 0.2,
+ diffuse: 0.6,
+ shininess: 132,
+ specularColor: [255, 255, 255],
+};
+
+export class WellsLayer extends CompositeLayer