From 41951d23608cd1b006519a611fa6f292f36c4fd6 Mon Sep 17 00:00:00 2001 From: Ruben Thoms Date: Fri, 9 Aug 2024 17:30:55 +0200 Subject: [PATCH] wip --- .../primary/routers/seismic/converters.py | 50 ++ .../primary/primary/routers/seismic/router.py | 49 +- .../primary/routers/seismic/schemas.py | 20 + .../2DViewer/settings/components/layers.tsx | 593 ----------------- .../modules/2DViewer/settings/settings.tsx | 48 +- frontend/src/modules/2DViewer/view/view.tsx | 41 +- .../settings/components/layers.tsx | 629 ------------------ .../Intersection/settings/settings.tsx | 83 ++- .../src/modules/Intersection/view/view.tsx | 6 +- .../_shared/components/Layers/index.ts | 1 + .../components/Layers/layerComponent.tsx | 250 +++++++ .../components/Layers/layerGroupComponent.tsx | 239 +++++++ .../_shared/components/Layers/layersPanel.tsx | 521 +++++++++++++++ .../src/modules/_shared/layers/LayerGroup.ts | 87 +++ .../modules/_shared/layers/LayerManager.ts | 144 +++- 15 files changed, 1504 insertions(+), 1257 deletions(-) create mode 100644 backend_py/primary/primary/routers/seismic/converters.py delete mode 100644 frontend/src/modules/2DViewer/settings/components/layers.tsx delete mode 100644 frontend/src/modules/Intersection/settings/components/layers.tsx create mode 100644 frontend/src/modules/_shared/components/Layers/index.ts create mode 100644 frontend/src/modules/_shared/components/Layers/layerComponent.tsx create mode 100644 frontend/src/modules/_shared/components/Layers/layerGroupComponent.tsx create mode 100644 frontend/src/modules/_shared/components/Layers/layersPanel.tsx create mode 100644 frontend/src/modules/_shared/layers/LayerGroup.ts diff --git a/backend_py/primary/primary/routers/seismic/converters.py b/backend_py/primary/primary/routers/seismic/converters.py new file mode 100644 index 000000000..375db0d71 --- /dev/null +++ b/backend_py/primary/primary/routers/seismic/converters.py @@ -0,0 +1,50 @@ +from typing import List + +import orjson +import numpy as np +import xtgeo + +from . import schemas + + +def surface_to_float32_array(values: np.ndarray) -> List[float]: + values = values.astype(np.float32) + values.fill_value = np.nan + values = np.ma.filled(values) + + # Rotate 90 deg left. + # This will cause the width of to run along the X axis + # and height of along Y axis (starting from bottom.) + values = np.rot90(values) + + return values.flatten().tolist() + + +def to_api_surface_data( + xtgeo_surf: xtgeo.RegularSurface, property_values: np.ndarray +) -> schemas.SurfaceMeshAndProperty: + """ + Create API SurfaceData from xtgeo regular surface + """ + float32_mesh = surface_to_float32_array(xtgeo_surf.values) + float32_property = surface_to_float32_array(property_values) + + return schemas.SurfaceMeshAndProperty( + x_ori=xtgeo_surf.xori, + y_ori=xtgeo_surf.yori, + x_count=xtgeo_surf.ncol, + y_count=xtgeo_surf.nrow, + x_inc=xtgeo_surf.xinc, + y_inc=xtgeo_surf.yinc, + x_min=xtgeo_surf.xmin, + x_max=xtgeo_surf.xmax, + y_min=xtgeo_surf.ymin, + y_max=xtgeo_surf.ymax, + mesh_value_min=xtgeo_surf.values.min(), + mesh_value_max=xtgeo_surf.values.max(), + property_value_min=property_values.min(), + property_value_max=property_values.max(), + rot_deg=xtgeo_surf.rotation, + mesh_data=orjson.dumps(float32_mesh), # pylint: disable=maybe-no-member + property_data=orjson.dumps(float32_property), # pylint: disable=maybe-no-member + ) diff --git a/backend_py/primary/primary/routers/seismic/router.py b/backend_py/primary/primary/routers/seismic/router.py index 9995f8071..e975e6495 100644 --- a/backend_py/primary/primary/routers/seismic/router.py +++ b/backend_py/primary/primary/routers/seismic/router.py @@ -5,13 +5,14 @@ from webviz_pkg.core_utils.b64 import b64_encode_float_array_as_float32 from primary.auth.auth_helper import AuthHelper +from primary.services.sumo_access.surface_access import SurfaceAccess from primary.services.sumo_access.seismic_access import SeismicAccess, VdsHandle from primary.services.utils.authenticated_user import AuthenticatedUser from primary.services.vds_access.request_types import VdsCoordinates, VdsCoordinateSystem from primary.services.vds_access.response_types import VdsMetadata from primary.services.vds_access.vds_access import VdsAccess -from . import schemas +from . import converters, schemas LOGGER = logging.getLogger(__name__) @@ -99,3 +100,49 @@ async def post_get_seismic_fence( min_fence_depth=depth_axis_meta.min, max_fence_depth=depth_axis_meta.max, ) + + +""" +@router.get("/get_seismic_attribute_near_surface/") +async def get_seismic_attribute_near_surface( + authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), + case_uuid: str = Query(description="Sumo case uuid"), + ensemble_name: str = Query(description="Ensemble name"), + realization_num: int = Query(description="Realization number"), + seismic_cube_attribute: str = Query(description="Seismic cube attribute"), + seismic_timestamp_or_timestep: str = Query(description="Timestamp or timestep"), + surface_name: str = Query(description="Surface name"), + surface_attribute: str = Query(description="Surface attribute"), +) -> schemas.SurfaceMeshAndProperty: + "" + Get a directory of surface names, attributes and time/interval strings for simulated dynamic surfaces. + "" + seismic_access = SeismicAccess(authenticated_user.get_sumo_access_token(), case_uuid, ensemble_name) + + timestamp = None + timestep = None + if "--" in seismic_timestamp_or_timestep: + timestep = seismic_timestamp_or_timestep + else: + timestamp = seismic_timestamp_or_timestep + + vds_handle: Optional[VdsHandle] = None + try: + vds_handle = await seismic_access.get_vds_handle_async( + realization=1, + iteration=ensemble_name, + cube_tagname=seismic_cube_attribute, + timestep=timestep, + timestamp=timestamp, + ) + except ValueError as err: + raise HTTPException(status_code=404, detail=str(err)) from err + + surface_access = SurfaceAccess(authenticated_user.get_sumo_access_token(), case_uuid, ensemble_name) + xtg_surf = surface_access.get_static_surf(real_num=1, name=surface_name, attribute=surface_attribute).copy() + + vdsaccess = VdsAccess(sas_token=vds_handle.sas_token, vds_url=vds_handle.vds_url) + seismic_values = vdsaccess.get_surface_values(xtgeo_surf=xtg_surf, above=5, below=5, attribute="mean") + + return converters.to_api_surface_data(xtg_surf, seismic_values) +""" diff --git a/backend_py/primary/primary/routers/seismic/schemas.py b/backend_py/primary/primary/routers/seismic/schemas.py index dbcf7430c..98c2d1813 100644 --- a/backend_py/primary/primary/routers/seismic/schemas.py +++ b/backend_py/primary/primary/routers/seismic/schemas.py @@ -58,3 +58,23 @@ class SeismicFenceData(BaseModel): num_samples_per_trace: int min_fence_depth: float max_fence_depth: float + + +class SurfaceMeshAndProperty(BaseModel): + x_ori: float + y_ori: float + x_count: int + y_count: int + x_inc: float + y_inc: float + x_min: float + x_max: float + y_min: float + y_max: float + mesh_value_min: float + mesh_value_max: float + property_value_min: float + property_value_max: float + rot_deg: float + mesh_data: str + property_data: str diff --git a/frontend/src/modules/2DViewer/settings/components/layers.tsx b/frontend/src/modules/2DViewer/settings/components/layers.tsx deleted file mode 100644 index 1f6532b52..000000000 --- a/frontend/src/modules/2DViewer/settings/components/layers.tsx +++ /dev/null @@ -1,593 +0,0 @@ -import React from "react"; - -import { EnsembleSet } from "@framework/EnsembleSet"; -import { StatusMessage } from "@framework/ModuleInstanceStatusController"; -import { WorkbenchSession } from "@framework/WorkbenchSession"; -import { WorkbenchSettings } from "@framework/WorkbenchSettings"; -import { CircularProgress } from "@lib/components/CircularProgress"; -import { Menu } from "@lib/components/Menu"; -import { MenuItem } from "@lib/components/MenuItem"; -import { useElementBoundingRect } from "@lib/hooks/useElementBoundingRect"; -import { createPortal } from "@lib/utils/createPortal"; -import { MANHATTAN_LENGTH, rectContainsPoint } from "@lib/utils/geometry"; -import { resolveClassNames } from "@lib/utils/resolveClassNames"; -import { Vec2, point2Distance } from "@lib/utils/vec2"; -import { LayerFactory } from "@modules/2DViewer/layers/LayerFactory"; -import { isSurfaceLayer } from "@modules/2DViewer/layers/SurfaceLayer"; -import { isWellboreLayer } from "@modules/2DViewer/layers/WellboreLayer"; -import { LAYER_TYPE_TO_STRING_MAPPING, LayerType } from "@modules/2DViewer/layers/types"; -import { - BaseLayer, - LayerStatus, - useIsLayerVisible, - useLayerName, - useLayerStatus, -} from "@modules/_shared/layers/BaseLayer"; -import { LayerManager, LayerManagerTopic, useLayerManagerTopicValue } from "@modules/_shared/layers/LayerManager"; -import { Dropdown, MenuButton } from "@mui/base"; -import { - Add, - ArrowDropDown, - Check, - Delete, - DragIndicator, - Error, - ExpandLess, - ExpandMore, - Settings, - Visibility, - VisibilityOff, -} from "@mui/icons-material"; - -import { isEqual } from "lodash"; - -import { SurfaceLayerSettingsComponent } from "./layerSettings/surfaceLayer"; -import { WellboreLayerSettingsComponent } from "./layerSettings/wellboreLayer"; - -export type LayersProps = { - ensembleSet: EnsembleSet; - layerManager: LayerManager; - workbenchSession: WorkbenchSession; - workbenchSettings: WorkbenchSettings; -}; - -export function Layers(props: LayersProps): React.ReactNode { - const layers = useLayerManagerTopicValue(props.layerManager, LayerManagerTopic.LAYERS_CHANGED); - - const [draggingLayerId, setDraggingLayerId] = React.useState(null); - const [isDragging, setIsDragging] = React.useState(false); - const [dragPosition, setDragPosition] = React.useState({ x: 0, y: 0 }); - const [prevLayers, setPrevLayers] = React.useState[]>(layers); - const [currentScrollPosition, setCurrentScrollPosition] = React.useState(0); - const [layerOrder, setLayerOrder] = React.useState(layers.map((layer) => layer.getId())); - - const parentDivRef = React.useRef(null); - const scrollDivRef = React.useRef(null); - const upperScrollDivRef = React.useRef(null); - const lowerScrollDivRef = React.useRef(null); - - if (!isEqual(prevLayers, layers)) { - setPrevLayers(layers); - setLayerOrder(layers.map((layer) => layer.getId())); - if (scrollDivRef.current) { - scrollDivRef.current.scrollTop = currentScrollPosition; - } - } - - function handleAddLayer(type: LayerType) { - props.layerManager.addLayer(LayerFactory.makeLayer(type)); - } - - function handleRemoveLayer(id: string) { - props.layerManager.removeLayer(id); - } - - React.useEffect( - function handleMount() { - if (parentDivRef.current === null) { - return; - } - - const currentParentDivRef = parentDivRef.current; - - let pointerDownPosition: Vec2 | null = null; - let pointerDownPositionRelativeToElement: Vec2 = { x: 0, y: 0 }; - let draggingActive: boolean = false; - let layerId: string | null = null; - let newLayerOrder: string[] = layers.map((layer) => layer.getId()); - - let scrollTimeout: ReturnType | null = null; - let doScroll: boolean = false; - let currentScrollTime = 100; - - function findLayerElement(element: HTMLElement): [HTMLElement | null, string | null] { - if (element?.parentElement && element.dataset.layerId) { - return [element.parentElement, element.dataset.layerId]; - } - return [null, null]; - } - - function handlePointerDown(e: PointerEvent) { - const [element, id] = findLayerElement(e.target as HTMLElement); - - if (!element || !id) { - return; - } - - draggingActive = false; - setIsDragging(true); - layerId = id; - pointerDownPosition = { x: e.clientX, y: e.clientY }; - pointerDownPositionRelativeToElement = { - x: e.clientX - element.getBoundingClientRect().left, - y: e.clientY - element.getBoundingClientRect().top, - }; - document.addEventListener("pointermove", handlePointerMove); - document.addEventListener("pointerup", handlePointerUp); - } - - function moveLayerToIndex(id: string, moveToIndex: number) { - const layer = layers.find((layer) => layer.getId() === id); - if (!layer) { - return; - } - - const index = newLayerOrder.indexOf(layer.getId()); - if (index === moveToIndex) { - return; - } - - if (moveToIndex <= 0) { - newLayerOrder = [id, ...newLayerOrder.filter((el) => el !== id)]; - } else if (moveToIndex >= layers.length - 1) { - newLayerOrder = [...newLayerOrder.filter((el) => el !== id), id]; - } else { - newLayerOrder = [...newLayerOrder]; - newLayerOrder.splice(index, 1); - newLayerOrder.splice(moveToIndex, 0, id); - } - - setLayerOrder(newLayerOrder); - } - - function handleElementDrag(id: string, position: Vec2) { - if (parentDivRef.current === null) { - return; - } - - let index = 0; - for (const child of parentDivRef.current.childNodes) { - if (child instanceof HTMLElement) { - const childBoundingRect = child.getBoundingClientRect(); - - if (!child.dataset.layerId) { - continue; - } - - if (child.dataset.layerId === id) { - continue; - } - - if (!rectContainsPoint(childBoundingRect, position)) { - index++; - continue; - } - - if (position.y <= childBoundingRect.y + childBoundingRect.height / 2) { - moveLayerToIndex(id, index); - } else { - moveLayerToIndex(id, index + 1); - } - index++; - } - } - } - - function maybeScroll(position: Vec2) { - if ( - upperScrollDivRef.current === null || - lowerScrollDivRef.current === null || - scrollDivRef.current === null - ) { - return; - } - - if (scrollTimeout) { - clearTimeout(scrollTimeout); - currentScrollTime = 100; - } - - if (rectContainsPoint(upperScrollDivRef.current.getBoundingClientRect(), position)) { - doScroll = true; - scrollTimeout = setTimeout(scrollUpRepeatedly, currentScrollTime); - } else if (rectContainsPoint(lowerScrollDivRef.current.getBoundingClientRect(), position)) { - doScroll = true; - scrollTimeout = setTimeout(scrollDownRepeatedly, currentScrollTime); - } else { - doScroll = false; - } - } - - function scrollUpRepeatedly() { - currentScrollTime = Math.max(10, currentScrollTime - 5); - if (scrollDivRef.current) { - scrollDivRef.current.scrollTop = Math.max(0, scrollDivRef.current.scrollTop - 10); - } - if (doScroll) { - scrollTimeout = setTimeout(scrollUpRepeatedly, currentScrollTime); - } - } - - function scrollDownRepeatedly() { - currentScrollTime = Math.max(10, currentScrollTime - 5); - if (scrollDivRef.current) { - scrollDivRef.current.scrollTop = Math.min( - scrollDivRef.current.scrollHeight, - scrollDivRef.current.scrollTop + 10 - ); - } - if (doScroll) { - scrollTimeout = setTimeout(scrollDownRepeatedly, currentScrollTime); - } - } - - function handlePointerMove(e: PointerEvent) { - if (!pointerDownPosition || !layerId) { - return; - } - - if ( - !draggingActive && - point2Distance(pointerDownPosition, { x: e.clientX, y: e.clientY }) > MANHATTAN_LENGTH - ) { - draggingActive = true; - setDraggingLayerId(layerId); - } - - if (!draggingActive) { - return; - } - - const dx = e.clientX - pointerDownPositionRelativeToElement.x; - const dy = e.clientY - pointerDownPositionRelativeToElement.y; - setDragPosition({ x: dx, y: dy }); - - const point: Vec2 = { x: e.clientX, y: e.clientY }; - - handleElementDrag(layerId, point); - - maybeScroll(point); - } - - function handlePointerUp() { - draggingActive = false; - pointerDownPosition = null; - layerId = null; - setIsDragging(false); - setDraggingLayerId(null); - document.removeEventListener("pointermove", handlePointerMove); - document.removeEventListener("pointerup", handlePointerUp); - props.layerManager.changeOrder(newLayerOrder); - } - - currentParentDivRef.addEventListener("pointerdown", handlePointerDown); - - return function handleUnmount() { - currentParentDivRef.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("pointermove", handlePointerMove); - document.removeEventListener("pointerup", handlePointerUp); - setIsDragging(false); - setDraggingLayerId(null); - }; - }, - [layers, props.layerManager] - ); - - function handleScroll(e: React.UIEvent) { - setCurrentScrollPosition(e.currentTarget.scrollTop); - } - - return ( -
-
-
Layers
- - -
- - Add layer - -
-
- - {Object.keys(LAYER_TYPE_TO_STRING_MAPPING).map((layerType, index) => { - return ( - handleAddLayer(layerType as LayerType)} - > - {LAYER_TYPE_TO_STRING_MAPPING[layerType as LayerType]} - - ); - })} - -
-
- {isDragging && - createPortal( -
- )} -
-
-
-
-
- {layerOrder - .map((id) => layers.find((el) => el.getId() === id)) - .map((layer) => { - if (!layer) { - return null; - } - return ( - - ); - })} -
- {layers.length === 0 && ( -
- Click on to add a layer. -
- )} -
-
-
- ); -} - -type LayerItemProps = { - layer: BaseLayer; - ensembleSet: EnsembleSet; - workbenchSession: WorkbenchSession; - workbenchSettings: WorkbenchSettings; - isDragging: boolean; - dragPosition: Vec2; - onRemoveLayer: (id: string) => void; -}; - -function LayerItem(props: LayerItemProps): React.ReactNode { - const [showSettings, setShowSettings] = React.useState(true); - - const dragIndicatorRef = React.useRef(null); - const divRef = React.useRef(null); - - const boundingClientRect = useElementBoundingRect(divRef); - - const isVisible = useIsLayerVisible(props.layer); - const status = useLayerStatus(props.layer); - - function handleRemoveLayer() { - props.onRemoveLayer(props.layer.getId()); - } - - function handleToggleLayerVisibility() { - props.layer.setIsVisible(!isVisible); - } - - function handleToggleSettingsVisibility() { - setShowSettings(!showSettings); - } - - function makeSettingsContainer(layer: BaseLayer): React.ReactNode { - if (isSurfaceLayer(layer)) { - return ( - - ); - } - if (isWellboreLayer(layer)) { - return ( - - ); - } - return null; - } - - function makeStatus(): React.ReactNode { - if (status === LayerStatus.LOADING) { - return ( -
- -
- ); - } - if (status === LayerStatus.ERROR) { - const error = props.layer.getError(); - if (typeof error === "string") { - return ( -
- -
- ); - } else { - const statusMessage = error as StatusMessage; - return ( -
- -
- ); - } - } - if (status === LayerStatus.SUCCESS) { - return ( -
- -
- ); - } - return null; - } - - function makeLayerElement(indicatorRef?: React.LegacyRef): React.ReactNode { - return ( - <> -
- -
-
- {isVisible ? : } -
- - {makeStatus()} -
- - {showSettings ? : } -
-
- -
- - ); - } - - return ( -
-
-
- {makeLayerElement(dragIndicatorRef)} -
- {props.isDragging && - createPortal( -
- {makeLayerElement()} -
- )} -
- {makeSettingsContainer(props.layer)} -
-
- ); -} - -type LayerNameProps = { - layer: BaseLayer; -}; - -function LayerName(props: LayerNameProps): React.ReactNode { - const layerName = useLayerName(props.layer); - const [editingName, setEditingName] = React.useState(false); - - function handleNameDoubleClick() { - setEditingName(true); - } - - function handleNameChange(e: React.ChangeEvent) { - props.layer.setName(e.target.value); - } - - function handleBlur() { - setEditingName(false); - } - - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === "Enter") { - setEditingName(false); - } - } - - return ( -
- {editingName ? ( - - ) : ( - layerName - )} -
- ); -} diff --git a/frontend/src/modules/2DViewer/settings/settings.tsx b/frontend/src/modules/2DViewer/settings/settings.tsx index 7f38bdc04..73f63e694 100644 --- a/frontend/src/modules/2DViewer/settings/settings.tsx +++ b/frontend/src/modules/2DViewer/settings/settings.tsx @@ -1,16 +1,25 @@ import React from "react"; +import { EnsembleSet } from "@framework/EnsembleSet"; import { ModuleSettingsProps } from "@framework/Module"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { WorkbenchSession, useEnsembleSet } from "@framework/WorkbenchSession"; +import { WorkbenchSettings } from "@framework/WorkbenchSettings"; import { FieldDropdown } from "@framework/components/FieldDropdown"; import { CollapsibleGroup } from "@lib/components/CollapsibleGroup"; +import { LayersPanel } from "@modules/_shared/components/Layers"; +import { BaseLayer } from "@modules/_shared/layers/BaseLayer"; import { useAtomValue, useSetAtom } from "jotai"; import { userSelectedFieldIdentifierAtom } from "./atoms/baseAtoms"; import { filteredEnsembleSetAtom, layerManagerAtom, selectedFieldIdentifierAtom } from "./atoms/derivedAtoms"; -import { Layers } from "./components/layers"; +import { SurfaceLayerSettingsComponent } from "./components/layerSettings/surfaceLayer"; +import { WellboreLayerSettingsComponent } from "./components/layerSettings/wellboreLayer"; +import { LayerFactory } from "../layers/LayerFactory"; +import { isSurfaceLayer } from "../layers/SurfaceLayer"; +import { isWellboreLayer } from "../layers/WellboreLayer"; +import { LAYER_TYPE_TO_STRING_MAPPING } from "../layers/types"; import { SettingsToViewInterface } from "../settingsToViewInterface"; import { State } from "../state"; @@ -38,13 +47,46 @@ export function Settings(props: ModuleSettingsProps
-
); } + +function makeSettingsContainer( + layer: BaseLayer, + ensembleSet: EnsembleSet, + workbenchSession: WorkbenchSession, + workbenchSettings: WorkbenchSettings +): React.ReactNode { + if (isSurfaceLayer(layer)) { + return ( + + ); + } + if (isWellboreLayer(layer)) { + return ( + + ); + } + return null; +} diff --git a/frontend/src/modules/2DViewer/view/view.tsx b/frontend/src/modules/2DViewer/view/view.tsx index 2a6a444ed..446c5d5f4 100644 --- a/frontend/src/modules/2DViewer/view/view.tsx +++ b/frontend/src/modules/2DViewer/view/view.tsx @@ -3,9 +3,10 @@ import React from "react"; import { Layer } from "@deck.gl/core/typed"; import { ModuleViewProps } from "@framework/Module"; import { useViewStatusWriter } from "@framework/StatusWriter"; -import { LayerStatus, useLayersStatuses } from "@modules/_shared/layers/BaseLayer"; +import { BaseLayer, LayerStatus, useLayersStatuses } from "@modules/_shared/layers/BaseLayer"; import { LayerManagerTopic, useLayerManagerTopicValue } from "@modules/_shared/layers/LayerManager"; -import SubsurfaceViewer from "@webviz/subsurface-viewer/dist/SubsurfaceViewer"; +import { ViewportType } from "@webviz/subsurface-viewer"; +import SubsurfaceViewer, { ViewsType } from "@webviz/subsurface-viewer/dist/SubsurfaceViewer"; import { MapLayer } from "@webviz/subsurface-viewer/dist/layers"; import { SurfaceLayer } from "../layers/SurfaceLayer"; @@ -15,19 +16,26 @@ import { State } from "../state"; export function View(props: ModuleViewProps): React.ReactNode { const statusWriter = useViewStatusWriter(props.viewContext); const layerManager = props.viewContext.useSettingsToViewInterfaceValue("layerManager"); - const layers = useLayerManagerTopicValue(layerManager, LayerManagerTopic.LAYERS_CHANGED); - const layersStatuses = useLayersStatuses(layers); + const items = useLayerManagerTopicValue(layerManager, LayerManagerTopic.ITEMS_CHANGED); + const layers = items.filter((item) => item instanceof BaseLayer) as BaseLayer[]; + const layersStatuses = useLayersStatuses(layers.filter((el) => el instanceof BaseLayer) as BaseLayer[]); statusWriter.setLoading(layersStatuses.some((status) => status.status === LayerStatus.LOADING)); - const viewerLayers: Layer[] = []; + const groupLayersMap: Map = new Map(); for (const layer of layers) { + const groupId = layerManager.getGroupOfLayer(layer.getId())?.getId() ?? "main"; + let layerArr = groupLayersMap.get(groupId); + if (!layerArr) { + layerArr = []; + groupLayersMap.set(groupId, layerArr); + } if (layer instanceof SurfaceLayer) { const data = layer.getData(); if (data) { for (const surfData of data) { - viewerLayers.push( + layerArr.push( new MapLayer({ id: layer.getId(), meshData: Array.from(surfData.valuesFloat32Arr), @@ -50,9 +58,28 @@ export function View(props: ModuleViewProps): Re } } + const numCols = Math.ceil(Math.sqrt(groupLayersMap.size)); + const numRows = Math.ceil(groupLayersMap.size / numCols); + + const viewports: ViewportType[] = []; + const viewerLayers: Layer[] = []; + for (const [group, layers] of groupLayersMap) { + viewports.push({ + id: group, + name: group, + layerIds: layers.map((layer) => (layer as unknown as Layer).id), + }); + viewerLayers.push(...layers); + } + + const views: ViewsType = { + layout: [numRows, numCols], + viewports: viewports, + }; + return (
- +
); } diff --git a/frontend/src/modules/Intersection/settings/components/layers.tsx b/frontend/src/modules/Intersection/settings/components/layers.tsx deleted file mode 100644 index 334844ea6..000000000 --- a/frontend/src/modules/Intersection/settings/components/layers.tsx +++ /dev/null @@ -1,629 +0,0 @@ -import React from "react"; - -import { EnsembleSet } from "@framework/EnsembleSet"; -import { StatusMessage } from "@framework/ModuleInstanceStatusController"; -import { WorkbenchSession } from "@framework/WorkbenchSession"; -import { WorkbenchSettings } from "@framework/WorkbenchSettings"; -import { CircularProgress } from "@lib/components/CircularProgress"; -import { Menu } from "@lib/components/Menu"; -import { MenuItem } from "@lib/components/MenuItem"; -import { useElementBoundingRect } from "@lib/hooks/useElementBoundingRect"; -import { createPortal } from "@lib/utils/createPortal"; -import { MANHATTAN_LENGTH, rectContainsPoint } from "@lib/utils/geometry"; -import { resolveClassNames } from "@lib/utils/resolveClassNames"; -import { Vec2, point2Distance } from "@lib/utils/vec2"; -import { isGridLayer } from "@modules/Intersection/utils/layers/GridLayer"; -import { LayerFactory } from "@modules/Intersection/utils/layers/LayerFactory"; -import { isSeismicLayer } from "@modules/Intersection/utils/layers/SeismicLayer"; -import { isSurfaceLayer } from "@modules/Intersection/utils/layers/SurfaceLayer"; -import { isSurfacesUncertaintyLayer } from "@modules/Intersection/utils/layers/SurfacesUncertaintyLayer"; -import { isWellpicksLayer } from "@modules/Intersection/utils/layers/WellpicksLayer"; -import { LAYER_TYPE_TO_STRING_MAPPING, LayerType } from "@modules/Intersection/utils/layers/types"; -import { - BaseLayer, - LayerStatus, - useIsLayerVisible, - useLayerName, - useLayerStatus, -} from "@modules/_shared/layers/BaseLayer"; -import { LayerManager, LayerManagerTopic, useLayerManagerTopicValue } from "@modules/_shared/layers/LayerManager"; -import { Dropdown, MenuButton } from "@mui/base"; -import { - Add, - ArrowDropDown, - Check, - Delete, - DragIndicator, - Error, - ExpandLess, - ExpandMore, - Settings, - Visibility, - VisibilityOff, -} from "@mui/icons-material"; - -import { isEqual } from "lodash"; - -import { GridLayerSettingsComponent } from "./layerSettings/gridLayer"; -import { SeismicLayerSettingsComponent } from "./layerSettings/seismicLayer"; -import { SurfaceLayerSettingsComponent } from "./layerSettings/surfaceLayer"; -import { SurfacesUncertaintyLayerSettingsComponent } from "./layerSettings/surfacesUncertaintyLayer"; -import { WellpicksLayerSettingsComponent } from "./layerSettings/wellpicksLayer"; - -export type LayersProps = { - ensembleSet: EnsembleSet; - layerManager: LayerManager; - workbenchSession: WorkbenchSession; - workbenchSettings: WorkbenchSettings; -}; - -export function Layers(props: LayersProps): React.ReactNode { - const layers = useLayerManagerTopicValue(props.layerManager, LayerManagerTopic.LAYERS_CHANGED); - - const [draggingLayerId, setDraggingLayerId] = React.useState(null); - const [isDragging, setIsDragging] = React.useState(false); - const [dragPosition, setDragPosition] = React.useState({ x: 0, y: 0 }); - const [prevLayers, setPrevLayers] = React.useState[]>(layers); - const [currentScrollPosition, setCurrentScrollPosition] = React.useState(0); - const [layerOrder, setLayerOrder] = React.useState(layers.map((layer) => layer.getId())); - - const parentDivRef = React.useRef(null); - const scrollDivRef = React.useRef(null); - const upperScrollDivRef = React.useRef(null); - const lowerScrollDivRef = React.useRef(null); - - if (!isEqual(prevLayers, layers)) { - setPrevLayers(layers); - setLayerOrder(layers.map((layer) => layer.getId())); - if (scrollDivRef.current) { - scrollDivRef.current.scrollTop = currentScrollPosition; - } - } - - function handleAddLayer(type: LayerType) { - props.layerManager.addLayer(LayerFactory.makeLayer(type)); - } - - function handleRemoveLayer(id: string) { - props.layerManager.removeLayer(id); - } - - React.useEffect( - function handleMount() { - if (parentDivRef.current === null) { - return; - } - - const currentParentDivRef = parentDivRef.current; - - let pointerDownPosition: Vec2 | null = null; - let pointerDownPositionRelativeToElement: Vec2 = { x: 0, y: 0 }; - let draggingActive: boolean = false; - let layerId: string | null = null; - let newLayerOrder: string[] = layers.map((layer) => layer.getId()); - - let scrollTimeout: ReturnType | null = null; - let doScroll: boolean = false; - let currentScrollTime = 100; - - function findLayerElement(element: HTMLElement): [HTMLElement | null, string | null] { - if (element?.parentElement && element.dataset.layerId) { - return [element.parentElement, element.dataset.layerId]; - } - return [null, null]; - } - - function handlePointerDown(e: PointerEvent) { - const [element, id] = findLayerElement(e.target as HTMLElement); - - if (!element || !id) { - return; - } - - draggingActive = false; - setIsDragging(true); - layerId = id; - pointerDownPosition = { x: e.clientX, y: e.clientY }; - pointerDownPositionRelativeToElement = { - x: e.clientX - element.getBoundingClientRect().left, - y: e.clientY - element.getBoundingClientRect().top, - }; - document.addEventListener("pointermove", handlePointerMove); - document.addEventListener("pointerup", handlePointerUp); - } - - function moveLayerToIndex(id: string, moveToIndex: number) { - const layer = layers.find((layer) => layer.getId() === id); - if (!layer) { - return; - } - - const index = newLayerOrder.indexOf(layer.getId()); - if (index === moveToIndex) { - return; - } - - if (moveToIndex <= 0) { - newLayerOrder = [id, ...newLayerOrder.filter((el) => el !== id)]; - } else if (moveToIndex >= layers.length - 1) { - newLayerOrder = [...newLayerOrder.filter((el) => el !== id), id]; - } else { - newLayerOrder = [...newLayerOrder]; - newLayerOrder.splice(index, 1); - newLayerOrder.splice(moveToIndex, 0, id); - } - - setLayerOrder(newLayerOrder); - } - - function handleElementDrag(id: string, position: Vec2) { - if (parentDivRef.current === null) { - return; - } - - let index = 0; - for (const child of parentDivRef.current.childNodes) { - if (child instanceof HTMLElement) { - const childBoundingRect = child.getBoundingClientRect(); - - if (!child.dataset.layerId) { - continue; - } - - if (child.dataset.layerId === id) { - continue; - } - - if (!rectContainsPoint(childBoundingRect, position)) { - index++; - continue; - } - - if (position.y <= childBoundingRect.y + childBoundingRect.height / 2) { - moveLayerToIndex(id, index); - } else { - moveLayerToIndex(id, index + 1); - } - index++; - } - } - } - - function maybeScroll(position: Vec2) { - if ( - upperScrollDivRef.current === null || - lowerScrollDivRef.current === null || - scrollDivRef.current === null - ) { - return; - } - - if (scrollTimeout) { - clearTimeout(scrollTimeout); - currentScrollTime = 100; - } - - if (rectContainsPoint(upperScrollDivRef.current.getBoundingClientRect(), position)) { - doScroll = true; - scrollTimeout = setTimeout(scrollUpRepeatedly, currentScrollTime); - } else if (rectContainsPoint(lowerScrollDivRef.current.getBoundingClientRect(), position)) { - doScroll = true; - scrollTimeout = setTimeout(scrollDownRepeatedly, currentScrollTime); - } else { - doScroll = false; - } - } - - function scrollUpRepeatedly() { - currentScrollTime = Math.max(10, currentScrollTime - 5); - if (scrollDivRef.current) { - scrollDivRef.current.scrollTop = Math.max(0, scrollDivRef.current.scrollTop - 10); - } - if (doScroll) { - scrollTimeout = setTimeout(scrollUpRepeatedly, currentScrollTime); - } - } - - function scrollDownRepeatedly() { - currentScrollTime = Math.max(10, currentScrollTime - 5); - if (scrollDivRef.current) { - scrollDivRef.current.scrollTop = Math.min( - scrollDivRef.current.scrollHeight, - scrollDivRef.current.scrollTop + 10 - ); - } - if (doScroll) { - scrollTimeout = setTimeout(scrollDownRepeatedly, currentScrollTime); - } - } - - function handlePointerMove(e: PointerEvent) { - if (!pointerDownPosition || !layerId) { - return; - } - - if ( - !draggingActive && - point2Distance(pointerDownPosition, { x: e.clientX, y: e.clientY }) > MANHATTAN_LENGTH - ) { - draggingActive = true; - setDraggingLayerId(layerId); - } - - if (!draggingActive) { - return; - } - - const dx = e.clientX - pointerDownPositionRelativeToElement.x; - const dy = e.clientY - pointerDownPositionRelativeToElement.y; - setDragPosition({ x: dx, y: dy }); - - const point: Vec2 = { x: e.clientX, y: e.clientY }; - - handleElementDrag(layerId, point); - - maybeScroll(point); - } - - function handlePointerUp() { - draggingActive = false; - pointerDownPosition = null; - layerId = null; - setIsDragging(false); - setDraggingLayerId(null); - document.removeEventListener("pointermove", handlePointerMove); - document.removeEventListener("pointerup", handlePointerUp); - props.layerManager.changeOrder(newLayerOrder); - } - - currentParentDivRef.addEventListener("pointerdown", handlePointerDown); - - return function handleUnmount() { - currentParentDivRef.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("pointermove", handlePointerMove); - document.removeEventListener("pointerup", handlePointerUp); - setIsDragging(false); - setDraggingLayerId(null); - }; - }, - [layers, props.layerManager] - ); - - function handleScroll(e: React.UIEvent) { - setCurrentScrollPosition(e.currentTarget.scrollTop); - } - - return ( -
-
-
Layers
- - -
- - Add layer - -
-
- - {Object.keys(LAYER_TYPE_TO_STRING_MAPPING).map((layerType, index) => { - return ( - handleAddLayer(layerType as LayerType)} - > - {LAYER_TYPE_TO_STRING_MAPPING[layerType as LayerType]} - - ); - })} - -
-
- {isDragging && - createPortal( -
- )} -
-
-
-
-
- {layerOrder - .map((id) => layers.find((el) => el.getId() === id)) - .map((layer) => { - if (!layer) { - return null; - } - return ( - - ); - })} -
- {layers.length === 0 && ( -
- Click on to add a layer. -
- )} -
-
-
- ); -} - -type LayerItemProps = { - layer: BaseLayer; - ensembleSet: EnsembleSet; - workbenchSession: WorkbenchSession; - workbenchSettings: WorkbenchSettings; - isDragging: boolean; - dragPosition: Vec2; - onRemoveLayer: (id: string) => void; -}; - -function LayerItem(props: LayerItemProps): React.ReactNode { - const [showSettings, setShowSettings] = React.useState(true); - - const dragIndicatorRef = React.useRef(null); - const divRef = React.useRef(null); - - const boundingClientRect = useElementBoundingRect(divRef); - - const isVisible = useIsLayerVisible(props.layer); - const status = useLayerStatus(props.layer); - - function handleRemoveLayer() { - props.onRemoveLayer(props.layer.getId()); - } - - function handleToggleLayerVisibility() { - props.layer.setIsVisible(!isVisible); - } - - function handleToggleSettingsVisibility() { - setShowSettings(!showSettings); - } - - function makeSettingsContainer(layer: BaseLayer): React.ReactNode { - if (isGridLayer(layer)) { - return ( - - ); - } - if (isSeismicLayer(layer)) { - return ( - - ); - } - if (isSurfaceLayer(layer)) { - return ( - - ); - } - if (isWellpicksLayer(layer)) { - return ( - - ); - } - if (isSurfacesUncertaintyLayer(layer)) { - return ( - - ); - } - return null; - } - - function makeStatus(): React.ReactNode { - if (status === LayerStatus.LOADING) { - return ( -
- -
- ); - } - if (status === LayerStatus.ERROR) { - const error = props.layer.getError(); - if (typeof error === "string") { - return ( -
- -
- ); - } else { - const statusMessage = error as StatusMessage; - return ( -
- -
- ); - } - } - if (status === LayerStatus.SUCCESS) { - return ( -
- -
- ); - } - return null; - } - - function makeLayerElement(indicatorRef?: React.LegacyRef): React.ReactNode { - return ( - <> -
- -
-
- {isVisible ? : } -
- - {makeStatus()} -
- - {showSettings ? : } -
-
- -
- - ); - } - - return ( -
-
-
- {makeLayerElement(dragIndicatorRef)} -
- {props.isDragging && - createPortal( -
- {makeLayerElement()} -
- )} -
- {makeSettingsContainer(props.layer)} -
-
- ); -} - -type LayerNameProps = { - layer: BaseLayer; -}; - -function LayerName(props: LayerNameProps): React.ReactNode { - const layerName = useLayerName(props.layer); - const [editingName, setEditingName] = React.useState(false); - - function handleNameDoubleClick() { - setEditingName(true); - } - - function handleNameChange(e: React.ChangeEvent) { - props.layer.setName(e.target.value); - } - - function handleBlur() { - setEditingName(false); - } - - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === "Enter") { - setEditingName(false); - } - } - - return ( -
- {editingName ? ( - - ) : ( - layerName - )} -
- ); -} diff --git a/frontend/src/modules/Intersection/settings/settings.tsx b/frontend/src/modules/Intersection/settings/settings.tsx index 65d1a6f57..fbcd616c3 100644 --- a/frontend/src/modules/Intersection/settings/settings.tsx +++ b/frontend/src/modules/Intersection/settings/settings.tsx @@ -1,10 +1,12 @@ import React from "react"; import { WellboreHeader_api } from "@api"; +import { EnsembleSet } from "@framework/EnsembleSet"; import { ModuleSettingsProps } from "@framework/Module"; import { useSettingsStatusWriter } from "@framework/StatusWriter"; import { SyncSettingKey, SyncSettingsHelper } from "@framework/SyncSettings"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { WorkbenchSession, useEnsembleSet } from "@framework/WorkbenchSession"; +import { WorkbenchSettings } from "@framework/WorkbenchSettings"; import { FieldDropdown } from "@framework/components/FieldDropdown"; import { Intersection, IntersectionType } from "@framework/types/intersection"; import { IntersectionPolyline } from "@framework/userCreatedItems/IntersectionPolylines"; @@ -15,7 +17,9 @@ import { PendingWrapper } from "@lib/components/PendingWrapper"; import { RadioGroup } from "@lib/components/RadioGroup"; import { Select, SelectOption } from "@lib/components/Select"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import { LayersPanel } from "@modules/_shared/components/Layers"; import { usePropagateApiErrorToStatusWriter } from "@modules/_shared/hooks/usePropagateApiErrorToStatusWriter"; +import { BaseLayer } from "@modules/_shared/layers/BaseLayer"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { isEqual } from "lodash"; @@ -36,10 +40,21 @@ import { selectedWellboreAtom, } from "./atoms/derivedAtoms"; import { drilledWellboreHeadersQueryAtom } from "./atoms/queryAtoms"; -import { Layers } from "./components/layers"; +import { GridLayerSettingsComponent } from "./components/layerSettings/gridLayer"; +import { SeismicLayerSettingsComponent } from "./components/layerSettings/seismicLayer"; +import { SurfaceLayerSettingsComponent } from "./components/layerSettings/surfaceLayer"; +import { SurfacesUncertaintyLayerSettingsComponent } from "./components/layerSettings/surfacesUncertaintyLayer"; +import { WellpicksLayerSettingsComponent } from "./components/layerSettings/wellpicksLayer"; import { SettingsToViewInterface } from "../settingsToViewInterface"; import { State } from "../state"; +import { isGridLayer } from "../utils/layers/GridLayer"; +import { LayerFactory } from "../utils/layers/LayerFactory"; +import { isSeismicLayer } from "../utils/layers/SeismicLayer"; +import { isSurfaceLayer } from "../utils/layers/SurfaceLayer"; +import { isSurfacesUncertaintyLayer } from "../utils/layers/SurfacesUncertaintyLayer"; +import { isWellpicksLayer } from "../utils/layers/WellpicksLayer"; +import { LAYER_TYPE_TO_STRING_MAPPING } from "../utils/layers/types"; import { ViewAtoms } from "../view/atoms/atomDefinitions"; export function Settings( @@ -193,17 +208,79 @@ export function Settings(
-
); } +function makeSettingsContainer( + layer: BaseLayer, + ensembleSet: EnsembleSet, + workbenchSession: WorkbenchSession, + workbenchSettings: WorkbenchSettings +): React.ReactNode { + if (isGridLayer(layer)) { + return ( + + ); + } + if (isSeismicLayer(layer)) { + return ( + + ); + } + if (isSurfaceLayer(layer)) { + return ( + + ); + } + if (isWellpicksLayer(layer)) { + return ( + + ); + } + if (isSurfacesUncertaintyLayer(layer)) { + return ( + + ); + } + return null; +} + function makeWellHeaderOptions(wellHeaders: WellboreHeader_api[]): SelectOption[] { return wellHeaders.map((wellHeader) => ({ value: wellHeader.wellboreUuid, diff --git a/frontend/src/modules/Intersection/view/view.tsx b/frontend/src/modules/Intersection/view/view.tsx index 3d68a8300..a1143d3d0 100644 --- a/frontend/src/modules/Intersection/view/view.tsx +++ b/frontend/src/modules/Intersection/view/view.tsx @@ -6,7 +6,7 @@ import { useEnsembleSet } from "@framework/WorkbenchSession"; import { IntersectionType } from "@framework/types/intersection"; import { CircularProgress } from "@lib/components/CircularProgress"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; -import { LayerStatus, useLayersStatuses } from "@modules_shared/layers/BaseLayer"; +import { BaseLayer, LayerStatus, useLayersStatuses } from "@modules_shared/layers/BaseLayer"; import { LayerManagerTopic, useLayerManagerTopicValue } from "@modules_shared/layers/LayerManager"; import { ViewAtoms } from "./atoms/atomDefinitions"; @@ -36,8 +36,8 @@ export function View( const wellbore = props.viewContext.useSettingsToViewInterfaceValue("wellboreHeader"); const layerManager = props.viewContext.useSettingsToViewInterfaceValue("layerManager"); - const layers = useLayerManagerTopicValue(layerManager, LayerManagerTopic.LAYERS_CHANGED); - const layersStatuses = useLayersStatuses(layers); + const layers = useLayerManagerTopicValue(layerManager, LayerManagerTopic.ITEMS_CHANGED); + const layersStatuses = useLayersStatuses(layers.filter((el) => el instanceof BaseLayer) as BaseLayer[]); const intersectionExtensionLength = props.viewContext.useSettingsToViewInterfaceValue("intersectionExtensionLength"); diff --git a/frontend/src/modules/_shared/components/Layers/index.ts b/frontend/src/modules/_shared/components/Layers/index.ts new file mode 100644 index 000000000..74ac83f4a --- /dev/null +++ b/frontend/src/modules/_shared/components/Layers/index.ts @@ -0,0 +1 @@ +export { LayersPanel } from "./layersPanel"; diff --git a/frontend/src/modules/_shared/components/Layers/layerComponent.tsx b/frontend/src/modules/_shared/components/Layers/layerComponent.tsx new file mode 100644 index 000000000..f16c684e0 --- /dev/null +++ b/frontend/src/modules/_shared/components/Layers/layerComponent.tsx @@ -0,0 +1,250 @@ +import React from "react"; + +import { EnsembleSet } from "@framework/EnsembleSet"; +import { StatusMessage } from "@framework/ModuleInstanceStatusController"; +import { WorkbenchSession } from "@framework/WorkbenchSession"; +import { WorkbenchSettings } from "@framework/WorkbenchSettings"; +import { CircularProgress } from "@lib/components/CircularProgress"; +import { useElementBoundingRect } from "@lib/hooks/useElementBoundingRect"; +import { createPortal } from "@lib/utils/createPortal"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import { Vec2 } from "@lib/utils/vec2"; +import { + BaseLayer, + LayerStatus, + useIsLayerVisible, + useLayerName, + useLayerStatus, +} from "@modules/_shared/layers/BaseLayer"; +import { + Check, + Delete, + DragIndicator, + Error, + ExpandLess, + ExpandMore, + Settings, + Visibility, + VisibilityOff, +} from "@mui/icons-material"; + +import { MakeSettingsContainerFunc } from "./layersPanel"; + +type LayerItemProps = { + layer: BaseLayer; + inGroup: boolean; + ensembleSet: EnsembleSet; + workbenchSession: WorkbenchSession; + workbenchSettings: WorkbenchSettings; + isDragging: boolean; + dragPosition: Vec2; + onRemoveLayer: (id: string) => void; + makeSettingsContainerFunc: MakeSettingsContainerFunc; +}; + +export function LayerComponent(props: LayerItemProps): React.ReactNode { + const [showSettings, setShowSettings] = React.useState(true); + + const dragIndicatorRef = React.useRef(null); + const divRef = React.useRef(null); + + const boundingClientRect = useElementBoundingRect(divRef); + + const isVisible = useIsLayerVisible(props.layer); + const status = useLayerStatus(props.layer); + + function handleRemoveLayer() { + props.onRemoveLayer(props.layer.getId()); + } + + function handleToggleLayerVisibility() { + props.layer.setIsVisible(!isVisible); + } + + function handleToggleSettingsVisibility() { + setShowSettings(!showSettings); + } + + function makeStatus(): React.ReactNode { + if (status === LayerStatus.LOADING) { + return ( +
+ +
+ ); + } + if (status === LayerStatus.ERROR) { + const error = props.layer.getError(); + if (typeof error === "string") { + return ( +
+ +
+ ); + } else { + const statusMessage = error as StatusMessage; + return ( +
+ +
+ ); + } + } + if (status === LayerStatus.SUCCESS) { + return ( +
+ +
+ ); + } + return null; + } + + function makeLayerElement(indicatorRef?: React.LegacyRef): React.ReactNode { + return ( + <> +
+ +
+
+ {isVisible ? : } +
+ + {makeStatus()} +
+ + {showSettings ? : } +
+
+ +
+ + ); + } + + return ( +
+
+
+ {makeLayerElement(dragIndicatorRef)} +
+ {props.isDragging && + createPortal( +
+ {makeLayerElement()} +
+ )} +
+ {props.makeSettingsContainerFunc( + props.layer, + props.ensembleSet, + props.workbenchSession, + props.workbenchSettings + )} +
+
+ ); +} + +type LayerNameProps = { + layer: BaseLayer; +}; + +function LayerName(props: LayerNameProps): React.ReactNode { + const layerName = useLayerName(props.layer); + const [editingName, setEditingName] = React.useState(false); + + function handleNameDoubleClick() { + setEditingName(true); + } + + function handleNameChange(e: React.ChangeEvent) { + props.layer.setName(e.target.value); + } + + function handleBlur() { + setEditingName(false); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") { + setEditingName(false); + } + } + + return ( +
+ {editingName ? ( + + ) : ( + layerName + )} +
+ ); +} diff --git a/frontend/src/modules/_shared/components/Layers/layerGroupComponent.tsx b/frontend/src/modules/_shared/components/Layers/layerGroupComponent.tsx new file mode 100644 index 000000000..3bce46e0a --- /dev/null +++ b/frontend/src/modules/_shared/components/Layers/layerGroupComponent.tsx @@ -0,0 +1,239 @@ +import React from "react"; + +import { EnsembleSet } from "@framework/EnsembleSet"; +import { WorkbenchSession } from "@framework/WorkbenchSession"; +import { WorkbenchSettings } from "@framework/WorkbenchSettings"; +import { Menu } from "@lib/components/Menu"; +import { MenuItem } from "@lib/components/MenuItem"; +import { useElementBoundingRect } from "@lib/hooks/useElementBoundingRect"; +import { createPortal } from "@lib/utils/createPortal"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import { Vec2 } from "@lib/utils/vec2"; +import { LayerGroup, LayerGroupTopic, useLayerGroupTopicValue } from "@modules/_shared/layers/LayerGroup"; +import { LayerManager } from "@modules/_shared/layers/LayerManager"; +import { Dropdown, MenuButton } from "@mui/base"; +import { + Add, + ArrowDropDown, + Delete, + DragIndicator, + ExpandLess, + ExpandMore, + Folder, + Visibility, + VisibilityOff, +} from "@mui/icons-material"; + +import { MakeSettingsContainerFunc } from "./layersPanel"; + +export type LayerGroupProps = { + group: LayerGroup; + ensembleSet: EnsembleSet; + workbenchSession: WorkbenchSession; + workbenchSettings: WorkbenchSettings; + layerManager: LayerManager; + layerTypeToStringMapping: Record; + isDragging: boolean; + dragPosition: Vec2; + draggingLayerId: string | null; + onRemoveGroup: (id: string) => void; + onAddLayer: (type: TLayerType, groupId: string) => void; + makeSettingsContainerFunc: MakeSettingsContainerFunc; + children: React.ReactNode[]; +}; + +export function LayerGroupComponent(props: LayerGroupProps): React.ReactNode { + const [showChildren, setShowChildren] = React.useState(true); + + const dragIndicatorRef = React.useRef(null); + const divRef = React.useRef(null); + + const boundingClientRect = useElementBoundingRect(divRef); + + const layers = props.layerManager.getLayersInGroup(props.group.getId()); + const isVisible = layers.every((layer) => layer.getIsVisible()); + + function handleRemoveGroup() { + props.onRemoveGroup(props.group.getId()); + } + + function handleAddLayer(type: TLayerType) { + props.onAddLayer(type, props.group.getId()); + } + + function handleToggleLayerVisibility() { + const layers = props.layerManager.getLayersInGroup(props.group.getId()); + const visible = layers.every((layer) => layer.getIsVisible()); + if (visible) { + layers.forEach((layer) => layer.setIsVisible(false)); + return; + } + layers.forEach((layer) => layer.setIsVisible(true)); + } + + function handleToggleChildrenVisibility() { + setShowChildren(!showChildren); + } + + function makeGroupElement(indicatorRef?: React.LegacyRef): React.ReactNode { + return ( + <> +
+ +
+
+ {showChildren ? : } +
+ +
+ {isVisible ? : } +
+ + + +
+ + Add layer + +
+
+ + {Object.keys(props.layerTypeToStringMapping).map((layerType, index) => { + return ( + handleAddLayer(layerType as TLayerType)} + > + {props.layerTypeToStringMapping[layerType as TLayerType]} + + ); + })} + +
+
+ +
+ + ); + } + + return ( +
+
+
+ {makeGroupElement(dragIndicatorRef)} +
+ {props.isDragging && + createPortal( +
+ {makeGroupElement()} +
+ )} +
+ {props.children} + {props.children.length === 0 && ( +
+ No layers +
+ )} +
+
+ ); +} + +type LayerNameProps = { + group: LayerGroup; +}; + +function GroupName(props: LayerNameProps): React.ReactNode { + const groupName = useLayerGroupTopicValue(props.group, LayerGroupTopic.NAME_CHANGED); + const [editingName, setEditingName] = React.useState(false); + + function handleNameDoubleClick() { + setEditingName(true); + } + + function handleNameChange(e: React.ChangeEvent) { + props.group.setName(e.target.value); + } + + function handleBlur() { + setEditingName(false); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") { + setEditingName(false); + } + } + + return ( +
+ {editingName ? ( + + ) : ( + groupName + )} +
+ ); +} diff --git a/frontend/src/modules/_shared/components/Layers/layersPanel.tsx b/frontend/src/modules/_shared/components/Layers/layersPanel.tsx new file mode 100644 index 000000000..c088c0af4 --- /dev/null +++ b/frontend/src/modules/_shared/components/Layers/layersPanel.tsx @@ -0,0 +1,521 @@ +import React from "react"; + +import { EnsembleSet } from "@framework/EnsembleSet"; +import { WorkbenchSession } from "@framework/WorkbenchSession"; +import { WorkbenchSettings } from "@framework/WorkbenchSettings"; +import { Menu } from "@lib/components/Menu"; +import { MenuItem } from "@lib/components/MenuItem"; +import { createPortal } from "@lib/utils/createPortal"; +import { MANHATTAN_LENGTH, rectContainsPoint } from "@lib/utils/geometry"; +import { Vec2, point2Distance } from "@lib/utils/vec2"; +import { BaseLayer } from "@modules/_shared/layers/BaseLayer"; +import { LayerGroup } from "@modules/_shared/layers/LayerGroup"; +import { + LayerManager, + LayerManagerItem, + LayerManagerTopic, + useLayerManagerTopicValue, +} from "@modules/_shared/layers/LayerManager"; +import { Dropdown, MenuButton } from "@mui/base"; +import { Add, ArrowDropDown, CreateNewFolder } from "@mui/icons-material"; + +import { isEqual } from "lodash"; + +import { LayerComponent } from "./layerComponent"; +import { LayerGroupComponent } from "./layerGroupComponent"; + +export interface LayerFactory { + makeLayer(layerType: TLayerType): BaseLayer; +} + +export interface MakeSettingsContainerFunc { + ( + layer: BaseLayer, + ensembleSet: EnsembleSet, + workbenchSession: WorkbenchSession, + workbenchSettings: WorkbenchSettings + ): React.ReactNode; +} + +export type LayersPanelProps = { + ensembleSet: EnsembleSet; + layerManager: LayerManager; + layerFactory: LayerFactory; + layerTypeToStringMapping: Record; + makeSettingsContainerFunc: MakeSettingsContainerFunc; + workbenchSession: WorkbenchSession; + workbenchSettings: WorkbenchSettings; + allowGroups?: boolean; +}; + +export function LayersPanel(props: LayersPanelProps): React.ReactNode { + const items = useLayerManagerTopicValue(props.layerManager, LayerManagerTopic.ITEMS_CHANGED); + + const [draggingLayerId, setDraggingLayerId] = React.useState(null); + const [isDragging, setIsDragging] = React.useState(false); + const [dragPosition, setDragPosition] = React.useState({ x: 0, y: 0 }); + const [prevItems, setPrevItems] = React.useState(items); + const [currentScrollPosition, setCurrentScrollPosition] = React.useState(0); + const [itemsOrder, setItemsOrder] = React.useState(items.map((item) => item.getId())); + + const parentDivRef = React.useRef(null); + const scrollDivRef = React.useRef(null); + const upperScrollDivRef = React.useRef(null); + const lowerScrollDivRef = React.useRef(null); + + if (!isEqual(prevItems, items)) { + setPrevItems(items); + setItemsOrder(items.map((layer) => layer.getId())); + if (scrollDivRef.current) { + scrollDivRef.current.scrollTop = currentScrollPosition; + } + } + + function handleAddLayer(type: TLayerType, groupId?: string) { + if (groupId) { + props.layerManager.addLayerToGroup(props.layerFactory.makeLayer(type), groupId); + return; + } + props.layerManager.addLayer(props.layerFactory.makeLayer(type)); + } + + function handleAddGroup() { + props.layerManager.addGroup("Group"); + } + + function handleRemoveGroup(id: string) { + props.layerManager.removeGroup(id); + } + + function handleRemoveItem(id: string) { + props.layerManager.removeLayer(id); + } + + React.useEffect( + function handleMount() { + if (parentDivRef.current === null) { + return; + } + + const currentParentDivRef = parentDivRef.current; + + let pointerDownPosition: Vec2 | null = null; + let pointerDownPositionRelativeToElement: Vec2 = { x: 0, y: 0 }; + let draggingActive: boolean = false; + let itemId: string | null = null; + let itemType: string | null = null; + let newLayerOrder: string[] = items.map((layer) => layer.getId()); + + let scrollTimeout: ReturnType | null = null; + let doScroll: boolean = false; + let currentScrollTime = 100; + + function findItemElement(element: HTMLElement): [HTMLElement | null, string | null, string | null] { + if (element?.parentElement && element.dataset.itemId && element.dataset.itemType) { + return [element.parentElement, element.dataset.itemId, element.dataset.itemType]; + } + return [null, null, null]; + } + + function handlePointerDown(e: PointerEvent) { + const [element, id, type] = findItemElement(e.target as HTMLElement); + + if (!element || !id || !type) { + return; + } + + draggingActive = false; + setIsDragging(true); + itemId = id; + itemType = type; + pointerDownPosition = { x: e.clientX, y: e.clientY }; + pointerDownPositionRelativeToElement = { + x: e.clientX - element.getBoundingClientRect().left, + y: e.clientY - element.getBoundingClientRect().top, + }; + document.addEventListener("pointermove", handlePointerMove); + document.addEventListener("pointerup", handlePointerUp); + } + + function moveLayerToIndex(id: string, moveToIndex: number, isLayer: boolean, groupId?: string) { + const layer = items.find((layer) => layer.getId() === id); + if (!layer) { + return; + } + + const index = newLayerOrder.indexOf(layer.getId()); + if (index === moveToIndex) { + if (!groupId && isLayer) { + props.layerManager.setLayerGroupId(id, undefined); + } + if (groupId && isLayer) { + props.layerManager.setLayerGroupId(id, groupId); + } + return; + } + + if (moveToIndex <= 0) { + newLayerOrder = [id, ...newLayerOrder.filter((el) => el !== id)]; + } else if (moveToIndex >= items.length - 1) { + newLayerOrder = [...newLayerOrder.filter((el) => el !== id), id]; + } else { + newLayerOrder = [...newLayerOrder]; + newLayerOrder.splice(index, 1); + newLayerOrder.splice(moveToIndex, 0, id); + } + + props.layerManager.setLayerGroupId(id, groupId); + + setItemsOrder(newLayerOrder); + } + + function handleElementDrag(id: string, position: Vec2) { + if (parentDivRef.current === null) { + return; + } + + let index = 0; + for (const child of parentDivRef.current.childNodes) { + if (child instanceof HTMLElement) { + const childBoundingRect = child.getBoundingClientRect(); + + if (child.dataset.itemType === "panel-placeholder") { + if (rectContainsPoint(childBoundingRect, position)) { + moveLayerToIndex(id, props.layerManager.getItems().length - 1, itemType === "layer"); + } + } + + if (!child.dataset.itemId) { + continue; + } + + if (child.dataset.itemId === id) { + continue; + } + + if (!rectContainsPoint(childBoundingRect, position)) { + index++; + continue; + } + + if (itemType === "layer") { + if (child.dataset.itemType === "group") { + const container = child.querySelector(".layer-container"); + if (!container) { + continue; + } + let numChildren = container.childNodes.length; + if ( + numChildren === 1 && + (container.childNodes[0] as HTMLElement)?.dataset.itemType === "group-placeholder" + ) { + numChildren = 0; + } + if (position.y <= childBoundingRect.y + 10) { + moveLayerToIndex(id, index, true); + } else if (position.y >= childBoundingRect.y + childBoundingRect.height - 10) { + moveLayerToIndex(id, index + numChildren + 1, true); + } else { + const groupId = child.dataset.itemId; + if (!groupId) { + continue; + } + let layerIndex = index + 1; + for (const innerChild of container.childNodes) { + if (innerChild instanceof HTMLElement) { + const innerChildBoundingRect = child.getBoundingClientRect(); + + if (innerChild.dataset.itemType === "group-placeholder") { + moveLayerToIndex(id, layerIndex, true, groupId); + } + + if (!innerChild.dataset.itemId) { + continue; + } + + if (innerChild.dataset.itemId === id) { + continue; + } + + if (!rectContainsPoint(innerChildBoundingRect, position)) { + layerIndex++; + continue; + } + + if ( + position.y <= + innerChildBoundingRect.y + innerChildBoundingRect.height / 2 + ) { + moveLayerToIndex(id, layerIndex, true, groupId); + } else { + moveLayerToIndex(id, layerIndex + 1, true, groupId); + } + layerIndex++; + } + } + } + index++; + continue; + } + } + + if (position.y <= childBoundingRect.y + childBoundingRect.height / 2) { + moveLayerToIndex(id, index, itemType === "layer"); + } else { + moveLayerToIndex(id, index + 1, itemType === "layer"); + } + index++; + } + } + } + + function maybeScroll(position: Vec2) { + if ( + upperScrollDivRef.current === null || + lowerScrollDivRef.current === null || + scrollDivRef.current === null + ) { + return; + } + + if (scrollTimeout) { + clearTimeout(scrollTimeout); + currentScrollTime = 100; + } + + if (rectContainsPoint(upperScrollDivRef.current.getBoundingClientRect(), position)) { + doScroll = true; + scrollTimeout = setTimeout(scrollUpRepeatedly, currentScrollTime); + } else if (rectContainsPoint(lowerScrollDivRef.current.getBoundingClientRect(), position)) { + doScroll = true; + scrollTimeout = setTimeout(scrollDownRepeatedly, currentScrollTime); + } else { + doScroll = false; + } + } + + function scrollUpRepeatedly() { + currentScrollTime = Math.max(10, currentScrollTime - 5); + if (scrollDivRef.current) { + scrollDivRef.current.scrollTop = Math.max(0, scrollDivRef.current.scrollTop - 10); + } + if (doScroll) { + scrollTimeout = setTimeout(scrollUpRepeatedly, currentScrollTime); + } + } + + function scrollDownRepeatedly() { + currentScrollTime = Math.max(10, currentScrollTime - 5); + if (scrollDivRef.current) { + scrollDivRef.current.scrollTop = Math.min( + scrollDivRef.current.scrollHeight, + scrollDivRef.current.scrollTop + 10 + ); + } + if (doScroll) { + scrollTimeout = setTimeout(scrollDownRepeatedly, currentScrollTime); + } + } + + function handlePointerMove(e: PointerEvent) { + if (!pointerDownPosition || !itemId || !itemType) { + return; + } + + if ( + !draggingActive && + point2Distance(pointerDownPosition, { x: e.clientX, y: e.clientY }) > MANHATTAN_LENGTH + ) { + draggingActive = true; + setDraggingLayerId(itemId); + } + + if (!draggingActive) { + return; + } + + const dx = e.clientX - pointerDownPositionRelativeToElement.x; + const dy = e.clientY - pointerDownPositionRelativeToElement.y; + setDragPosition({ x: dx, y: dy }); + + const point: Vec2 = { x: e.clientX, y: e.clientY }; + + handleElementDrag(itemId, point); + + maybeScroll(point); + } + + function handlePointerUp() { + draggingActive = false; + pointerDownPosition = null; + itemId = null; + setIsDragging(false); + setDraggingLayerId(null); + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("pointerup", handlePointerUp); + props.layerManager.changeOrder(newLayerOrder); + } + + currentParentDivRef.addEventListener("pointerdown", handlePointerDown); + + return function handleUnmount() { + currentParentDivRef.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("pointerup", handlePointerUp); + setIsDragging(false); + setDraggingLayerId(null); + }; + }, + [items, props.layerManager] + ); + + function handleScroll(e: React.UIEvent) { + setCurrentScrollPosition(e.currentTarget.scrollTop); + } + + function makeLayersAndGroupsContent(): React.ReactNode[] { + const nodes: React.ReactNode[] = []; + + const orderedItems = itemsOrder + .map((id) => items.find((el) => el.getId() === id)) + .filter((el) => el) as LayerManagerItem[]; + + for (let i = 0; i < orderedItems.length; i++) { + const item = orderedItems[i]; + + if (item instanceof LayerGroup) { + const layers: BaseLayer[] = []; + for (let j = i + 1; j < orderedItems.length; j++) { + const nextItem = orderedItems[j]; + if (nextItem instanceof LayerGroup) { + break; + } + const layerGroupId = props.layerManager.getLayerGroupId(nextItem.getId()); + if (layerGroupId !== item.getId()) { + break; + } + layers.push(nextItem as BaseLayer); + i++; + } + + nodes.push( + + {layers.map((layer) => ( + + ))} + + ); + } else { + nodes.push( + + ); + } + } + + return nodes; + } + + return ( +
+
+
Layers
+ {props.allowGroups && ( +
+ + Add group +
+ )} + + +
+ + Add layer + +
+
+ + {Object.keys(props.layerTypeToStringMapping).map((layerType, index) => { + return ( + handleAddLayer(layerType as TLayerType)} + > + {props.layerTypeToStringMapping[layerType as TLayerType]} + + ); + })} + +
+
+ {isDragging && + createPortal( +
+ )} +
+
+
+
+
+ {makeLayersAndGroupsContent()} +
+
+ {items.length === 0 && ( +
+ Click on to add a layer. +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/modules/_shared/layers/LayerGroup.ts b/frontend/src/modules/_shared/layers/LayerGroup.ts new file mode 100644 index 000000000..509fd270b --- /dev/null +++ b/frontend/src/modules/_shared/layers/LayerGroup.ts @@ -0,0 +1,87 @@ +import React from "react"; + +import { v4 } from "uuid"; + +export enum LayerGroupTopic { + NAME_CHANGED = "name-changed", + LAYER_IDS_CHANGED = "layer-ids-changed", +} + +export type LayerGroupTopicValueTypes = { + [LayerGroupTopic.LAYER_IDS_CHANGED]: string[]; + [LayerGroupTopic.NAME_CHANGED]: string; +}; + +export class LayerGroup { + private _id: string; + private _name: string; + private _subscribers: Map void>> = new Map(); + + constructor(name: string) { + this._id = v4(); + this._name = name; + } + + getId(): string { + return this._id; + } + + getName(): string { + return this._name; + } + + setName(name: string): void { + this._name = name; + this.notifySubscribers(LayerGroupTopic.NAME_CHANGED); + } + + subscribe(topic: LayerGroupTopic, subscriber: () => void): void { + const subscribers = this._subscribers.get(topic) ?? new Set(); + subscribers.add(subscriber); + this._subscribers.set(topic, subscribers); + } + + private notifySubscribers(topic: LayerGroupTopic): void { + const subscribers = this._subscribers.get(topic); + if (subscribers) { + subscribers.forEach((subscriber) => subscriber()); + } + } + + makeSubscriberFunction(topic: LayerGroupTopic): (onStoreChangeCallback: () => void) => () => void { + // Using arrow function in order to keep "this" in context + const subscriber = (onStoreChangeCallback: () => void): (() => void) => { + const subscribers = this._subscribers.get(topic) || new Set(); + subscribers.add(onStoreChangeCallback); + this._subscribers.set(topic, subscribers); + + return () => { + subscribers.delete(onStoreChangeCallback); + }; + }; + + return subscriber; + } + + makeSnapshotGetter(topic: T): () => LayerGroupTopicValueTypes[T] { + const snapshotGetter = (): any => { + if (topic === LayerGroupTopic.NAME_CHANGED) { + return this.getName(); + } + }; + + return snapshotGetter; + } +} + +export function useLayerGroupTopicValue( + layerGroup: LayerGroup, + topic: T +): LayerGroupTopicValueTypes[T] { + const value = React.useSyncExternalStore( + layerGroup.makeSubscriberFunction(topic), + layerGroup.makeSnapshotGetter(topic) + ); + + return value; +} diff --git a/frontend/src/modules/_shared/layers/LayerManager.ts b/frontend/src/modules/_shared/layers/LayerManager.ts index 1f6a238c6..c1a1e34da 100644 --- a/frontend/src/modules/_shared/layers/LayerManager.ts +++ b/frontend/src/modules/_shared/layers/LayerManager.ts @@ -3,52 +3,140 @@ import React from "react"; import { QueryClient } from "@tanstack/query-core"; import { BaseLayer } from "./BaseLayer"; +import { LayerGroup } from "./LayerGroup"; export enum LayerManagerTopic { - LAYERS_CHANGED = "layers-changed", + ITEMS_CHANGED = "items-changed", } export type LayerManagerTopicValueTypes = { - [LayerManagerTopic.LAYERS_CHANGED]: BaseLayer[]; + [LayerManagerTopic.ITEMS_CHANGED]: BaseLayer[]; }; +export type LayerManagerItem = BaseLayer | LayerGroup; + export class LayerManager { private _queryClient: QueryClient | null = null; - private _layers: BaseLayer[] = []; + private _layersGroupMap: Map = new Map(); private _subscribers: Map void>> = new Map(); + private _items: LayerManagerItem[] = []; setQueryClient(queryClient: QueryClient): void { this._queryClient = queryClient; } + getQueryClient(): QueryClient { + if (!this._queryClient) { + throw new Error("Query client not set"); + } + return this._queryClient; + } + addLayer(layer: BaseLayer): void { if (!this._queryClient) { throw new Error("Query client not set"); } layer.setName(this.makeUniqueLayerName(layer.getName())); layer.setQueryClient(this._queryClient); - this._layers = [layer, ...this._layers]; - this.notifySubscribers(LayerManagerTopic.LAYERS_CHANGED); + this._items = [layer, ...this._items]; + this.notifySubscribers(LayerManagerTopic.ITEMS_CHANGED); + } + + addLayerToGroup(layer: BaseLayer, groupId: string): void { + if (!this._queryClient) { + throw new Error("Query client not set"); + } + layer.setName(this.makeUniqueLayerName(layer.getName())); + layer.setQueryClient(this._queryClient); + const groupIndex = this._items.findIndex((item) => item.getId() === groupId); + if (groupIndex === -1) { + throw new Error("Group not found"); + } + this._items = [...this._items.slice(0, groupIndex + 1), layer, ...this._items.slice(groupIndex + 1)]; + this._layersGroupMap.set(layer.getId(), groupId); + this.notifySubscribers(LayerManagerTopic.ITEMS_CHANGED); + } + + addGroup(name: string): void { + const uniqueName = this.makeUniqueGroupName(name); + const group = new LayerGroup(uniqueName); + this._items = [group, ...this._items]; + this.notifySubscribers(LayerManagerTopic.ITEMS_CHANGED); } removeLayer(id: string): void { - this._layers = this._layers.filter((layer) => layer.getId() !== id); - this.notifySubscribers(LayerManagerTopic.LAYERS_CHANGED); + this._items = this._items.filter((item) => item.getId() !== id); + this._layersGroupMap.delete(id); + this.notifySubscribers(LayerManagerTopic.ITEMS_CHANGED); + } + + removeGroup(id: string): void { + const layerIdsInGroup: string[] = []; + for (const [layerId, groupId] of this._layersGroupMap) { + if (groupId === id) { + this._layersGroupMap.delete(layerId); + layerIdsInGroup.push(layerId); + } + } + this._items = this._items.filter((item) => item.getId() !== id && !layerIdsInGroup.includes(item.getId())); + this.notifySubscribers(LayerManagerTopic.ITEMS_CHANGED); + } + + getLayerGroupId(layerId: string): string | undefined { + return this._layersGroupMap.get(layerId); + } + + getGroupOfLayer(layerId: string): LayerGroup | undefined { + const groupId = this.getLayerGroupId(layerId); + if (!groupId) { + return undefined; + } + return this.getGroup(groupId); + } + + setLayerGroupId(layerId: string, groupId: string | undefined): void { + if (groupId) { + this._layersGroupMap.set(layerId, groupId); + } else { + this._layersGroupMap.delete(layerId); + } + this.notifySubscribers(LayerManagerTopic.ITEMS_CHANGED); + } + + getItem(id: string): LayerManagerItem | undefined { + return this._items.find((item) => item.getId() === id); } getLayer(id: string): BaseLayer | undefined { - return this._layers.find((layer) => layer.getId() === id); + const item = this.getItem(id); + if (item instanceof BaseLayer) { + return item; + } + return undefined; + } + + getLayersInGroup(groupId: string): BaseLayer[] { + return this._items.filter((item) => this.getLayerGroupId(item.getId()) === groupId) as BaseLayer[]; + } + + getGroup(id: string): LayerGroup | undefined { + const item = this.getItem(id); + if (item instanceof LayerGroup) { + return item; + } + return undefined; } - getLayers(): BaseLayer[] { - return this._layers; + getItems(): LayerManagerItem[] { + return this._items; } changeOrder(order: string[]): void { - this._layers = order - .map((id) => this._layers.find((layer) => layer.getId() === id)) - .filter(Boolean) as BaseLayer[]; - this.notifySubscribers(LayerManagerTopic.LAYERS_CHANGED); + this._items = order.map((id) => this._items.find((item) => item.getId() === id)).filter(Boolean) as BaseLayer< + any, + any + >[]; + this.notifySubscribers(LayerManagerTopic.ITEMS_CHANGED); } subscribe(topic: LayerManagerTopic, subscriber: () => void): void { @@ -64,10 +152,30 @@ export class LayerManager { } } - private makeUniqueLayerName(name: string): string { + private getAllLayers(): BaseLayer[] { + return this._items.filter((item) => item instanceof BaseLayer) as BaseLayer[]; + } + + private getAllGroups(): LayerGroup[] { + return this._items.filter((item) => item instanceof LayerGroup) as LayerGroup[]; + } + + makeUniqueLayerName(name: string): string { + let potentialName = name; + let i = 1; + const allLayers = this.getAllLayers(); + while (allLayers.some((layer) => layer.getName() === potentialName)) { + potentialName = `${name} (${i})`; + i++; + } + return potentialName; + } + + private makeUniqueGroupName(name: string): string { let potentialName = name; let i = 1; - while (this._layers.some((layer) => layer.getName() === potentialName)) { + const allGroups = this.getAllGroups(); + while (allGroups.some((group) => group.getName() === potentialName)) { potentialName = `${name} (${i})`; i++; } @@ -91,8 +199,8 @@ export class LayerManager { makeSnapshotGetter(topic: T): () => LayerManagerTopicValueTypes[T] { const snapshotGetter = (): any => { - if (topic === LayerManagerTopic.LAYERS_CHANGED) { - return this.getLayers(); + if (topic === LayerManagerTopic.ITEMS_CHANGED) { + return this.getItems(); } };