Skip to content

Framework/hover service #891

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions frontend/src/framework/HoverService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import React from "react";

import { PublishSubscribeDelegate } from "@modules/_shared/utils/PublishSubscribeDelegate";

import _ from "lodash";

export enum HoverTopic {
MD = "hover.md",
WELLBORE = "hover.wellbore",
REALIZATION = "hover.realization",
TIMESTAMP = "hover.timestamp",
ZONE = "hover.zone",
REGION = "hover.region",
FACIES = "hover.facies",
WORLD_POS = "hover.world_pos",
}

export type HoverData = {
[HoverTopic.MD]: number | null;
[HoverTopic.WELLBORE]: string | null;
[HoverTopic.REALIZATION]: number | null;
[HoverTopic.TIMESTAMP]: number | null;
[HoverTopic.ZONE]: string | null;
[HoverTopic.REGION]: string | null;
[HoverTopic.FACIES]: string | null;
[HoverTopic.WORLD_POS]: { x?: number; y?: number; z?: number } | null;
};

// Possible future functionality
// - Some system to get derived "hovers"? A hovered wellbore implies a Well, which implies a field, and so on...

export type ThrottledPublishFunc = _.DebouncedFunc<
<T extends keyof HoverData>(topic: T, newValue: HoverData[T]) => void
>;

export class HoverService {
// Currently available hover-data. The two objects are updated at different rates, but will contain the same data
// when the system is not actively hovering
private _hoverData: Partial<HoverData> = {};
private _throttledHoverData: Partial<HoverData> = {};

private _lastHoveredModule: string | null = null;

// Throttling. Each topic is updated with its own throttle method.
private _topicThrottleMap = new Map<keyof HoverData, ThrottledPublishFunc>();
private _dataUpdateThrottleMs = 100;

// Delegate to handle update notifications
private _publishSubscribeDelegate = new PublishSubscribeDelegate<HoverData>();

private _getOrCreateTopicThrottleMethod(topic: keyof HoverData): ThrottledPublishFunc {
if (!this._topicThrottleMap.has(topic)) {
const throttledMethod = _.throttle(
this._doThrottledHoverDataUpdate.bind(this),
this._dataUpdateThrottleMs,
{
// These settings make it so notifications are only pushed *after* the throttle timer elapses
leading: false,
trailing: true,
},
);

this._topicThrottleMap.set(topic, throttledMethod);
}

// If-block above gurantees this is non-null
return this._topicThrottleMap.get(topic)!;
}

private _doThrottledHoverDataUpdate<T extends keyof HoverData>(topic: T, value: HoverData[T]): void {
this._throttledHoverData[topic] = value;
this.getPublishSubscribeDelegate().notifySubscribers(topic);
}

getPublishSubscribeDelegate(): PublishSubscribeDelegate<HoverData> {
return this._publishSubscribeDelegate;
}

makeSnapshotGetter<T extends keyof HoverData>(topic: T, moduleInstanceId: string): () => HoverData[T] | null {
return () => {
// ? Should this be an opt-in functionality?
// ! The module that is currently hovering will always see the data updated immedietally
if (this._lastHoveredModule && moduleInstanceId === this._lastHoveredModule) {
return this._hoverData[topic] ?? null;
} else {
return this._throttledHoverData[topic] ?? null;
}
};
}

updateHoverValue<T extends keyof HoverData>(topic: T, newValue: HoverData[T]): void {
this._hoverData[topic] = newValue;
this._getOrCreateTopicThrottleMethod(topic)(topic, newValue);

// Notify changes (do note that only the hovering module will see any changes at this point)
this.getPublishSubscribeDelegate().notifySubscribers(topic);
}

setLastHoveredModule(moduleInstanceId: string | null) {
if (this._lastHoveredModule === moduleInstanceId) return;

this._lastHoveredModule = moduleInstanceId;
}

getLastHoveredModule(): string | null {
return this._lastHoveredModule;
}

// ? Currently, the md and wellbore hovers are "cleared" when the module implementer sets them to null.
// ? Should there be a more explicit way to stop hovering?
// endHoverEffect(): void {
// this.#lastHoveredModule = null
// this.#hoverData = {}
// this.#throttledHoverData = {}
// // notify subscribers...
// }
}

export function useHoverValue<T extends keyof HoverData>(
topic: T,
hoverService: HoverService,
moduleInstanceId: string,
) {
const latestValue = React.useSyncExternalStore<HoverData[T]>(
hoverService.getPublishSubscribeDelegate().makeSubscriberFunction(topic),
hoverService.makeSnapshotGetter(topic, moduleInstanceId),
);

return latestValue;
}

export function usePublishHoverValue<T extends keyof HoverData>(
topic: T,
hoverService: HoverService,
moduleInstanceId: string,
): (v: HoverData[T]) => void {
return React.useCallback(
function updateHoverValue(newValue: HoverData[T]) {
hoverService.setLastHoveredModule(moduleInstanceId);
hoverService.updateHoverValue(topic, newValue);
},
[hoverService, moduleInstanceId, topic],
);
}

export function useHover<T extends keyof HoverData>(
topic: T,
hoverService: HoverService,
moduleInstanceId: string,
): [HoverData[T], (v: HoverData[T]) => void] {
const latestValue = useHoverValue(topic, hoverService, moduleInstanceId);
const updateValue = usePublishHoverValue(topic, hoverService, moduleInstanceId);

return [latestValue, updateValue];
}
2 changes: 2 additions & 0 deletions frontend/src/framework/Module.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Getter, Setter } from "jotai";

import type { ChannelDefinition, ChannelReceiverDefinition } from "./DataChannelTypes";
import type { InitialSettings } from "./InitialSettings";
import type { HoverService } from "./HoverService";
import type { SettingsContext, ViewContext } from "./ModuleContext";
import type { ModuleDataTagId } from "./ModuleDataTags";
import { ModuleInstance, ModuleInstanceTopic } from "./ModuleInstance";
Expand Down Expand Up @@ -65,6 +66,7 @@ export type ModuleViewProps<
viewContext: ViewContext<TInterfaceTypes>;
workbenchSession: WorkbenchSession;
workbenchServices: WorkbenchServices;
hoverService: HoverService;
workbenchSettings: WorkbenchSettings;
initialSettings?: InitialSettings;
};
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/framework/Workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { QueryClient } from "@tanstack/react-query";

import { AtomStoreMaster } from "./AtomStoreMaster";
import { GuiMessageBroker, GuiState } from "./GuiMessageBroker";
import { HoverService } from "./HoverService";
import { InitialSettings } from "./InitialSettings";
import { ImportState } from "./Module";
import type { ModuleInstance } from "./ModuleInstance";
Expand Down Expand Up @@ -57,6 +58,7 @@ export class Workbench {
private _moduleInstances: ModuleInstance<any>[];
private _workbenchSession: WorkbenchSessionPrivate;
private _workbenchServices: PrivateWorkbenchServices;
private _hoverService: HoverService;
private _workbenchSettings: PrivateWorkbenchSettings;
private _guiMessageBroker: GuiMessageBroker;
private _subscribersMap: { [key: string]: Set<() => void> };
Expand All @@ -69,6 +71,7 @@ export class Workbench {
this._atomStoreMaster = new AtomStoreMaster();
this._workbenchSession = new WorkbenchSessionPrivate(this._atomStoreMaster);
this._workbenchServices = new PrivateWorkbenchServices(this);
this._hoverService = new HoverService();
this._workbenchSettings = new PrivateWorkbenchSettings();
this._guiMessageBroker = new GuiMessageBroker();
this._subscribersMap = {};
Expand Down Expand Up @@ -101,6 +104,10 @@ export class Workbench {
return this._workbenchServices;
}

getHoverService(): HoverService {
return this._hoverService;
}

getWorkbenchSettings(): PrivateWorkbenchSettings {
return this._workbenchSettings;
}
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/framework/WorkbenchServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type GlobalTopicDefinitions = {
"global.infoMessage": string;
"global.hoverRealization": { realization: number } | null;
"global.hoverTimestamp": { timestampUtcMs: number } | null;
/** @deprecated use `global.hover.md` and `global.hover.wellbore` instead to delegate to hover-service */
"global.hoverMd": { wellboreUuid: string; md: number } | null;
"global.hoverZone": { zoneName: string } | null;
"global.hoverRegion": { regionName: string } | null;
Expand Down Expand Up @@ -88,7 +89,7 @@ export class WorkbenchServices {
};
}

publishGlobalData<T extends keyof GlobalTopicDefinitions>(
publishGlobalData<T extends keyof AllTopicDefinitions>(
topic: T,
value: TopicDefinitionsType<T>,
publisherId?: string,
Expand Down Expand Up @@ -116,6 +117,7 @@ export class WorkbenchServices {
if (!subscribersSet) {
return;
}

for (const { subscriberId, callbackFn } of subscribersSet) {
if (subscriberId === undefined || publisherId === undefined || subscriberId !== publisherId) {
callbackFn(value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export const ViewContent = React.memo((props: ViewContentProps) => {
workbenchSession={props.workbench.getWorkbenchSession()}
workbenchServices={props.workbench.getWorkbenchServices()}
workbenchSettings={props.workbench.getWorkbenchSettings()}
hoverService={props.workbench.getHoverService()}
initialSettings={props.moduleInstance.getInitialSettings() || undefined}
/>
</ApplyInterfaceEffectsToView>
Expand Down
53 changes: 52 additions & 1 deletion frontend/src/modules/2DViewer/view/components/LayersWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React from "react";

import { View as DeckGlView } from "@deck.gl/core";
import { GeoJsonLayer } from "@deck.gl/layers";
import { type HoverService, HoverTopic, useHoverValue } from "@framework/HoverService";
import type { ViewContext } from "@framework/ModuleContext";
import { useViewStatusWriter } from "@framework/StatusWriter";
import { PendingWrapper } from "@lib/components/PendingWrapper";
Expand All @@ -18,6 +20,9 @@ import { usePublishSubscribeTopicValue } from "@modules/_shared/utils/PublishSub
import type { BoundingBox2D, ViewportType } from "@webviz/subsurface-viewer";
import type { ViewsType } from "@webviz/subsurface-viewer/dist/SubsurfaceViewer";

import type { Feature } from "geojson";
import _ from "lodash";

import { ReadoutWrapper } from "./ReadoutWrapper";

import { PlaceholderLayer } from "../customDeckGlLayers/PlaceholderLayer";
Expand All @@ -26,10 +31,46 @@ import { recursivelyMakeViewsAndLayers } from "../utils/makeViewsAndLayers";

export type LayersWrapperProps = {
layerManager: LayerManager;
hoverService: HoverService;
preferredViewLayout: PreferredViewLayout;
viewContext: ViewContext<Interfaces>;
};

const HIGHLIGHT_LAYER_ID = "2d-hover-highlights";

function useHighlightLayer(
boundingBox: BoundingBox2D | undefined,
hoverService: HoverService,
instanceId: string,
): GeoJsonLayer {
const { x, y } = useHoverValue(HoverTopic.WORLD_POS, hoverService, instanceId) ?? {};

const highlightFeatures: Feature[] = [];

const xInRange = boundingBox && x && _.inRange(x, boundingBox[0], boundingBox[2]);
const yInRange = boundingBox && y && _.inRange(y, boundingBox[1], boundingBox[3]);

if (xInRange && yInRange) {
highlightFeatures.push({
type: "Feature",
properties: {},
geometry: { type: "Point", coordinates: [x, y] },
});
}

return new GeoJsonLayer({
id: HIGHLIGHT_LAYER_ID,
pointRadiusMinPixels: 5,
pointRadiusMaxPixels: 5,
getFillColor: [255, 0, 0, 170],
data: {
type: "FeatureCollection",
unit: "m",
features: highlightFeatures,
},
});
}

export function LayersWrapper(props: LayersWrapperProps): React.ReactNode {
const [prevBoundingBox, setPrevBoundingBox] = React.useState<BoundingBox | null>(null);

Expand Down Expand Up @@ -75,7 +116,12 @@ export function LayersWrapper(props: LayersWrapperProps): React.ReactNode {
id: view.id,
name: view.name,
isSync: true,
layerIds: [...globalLayerIds, ...view.layers.map((layer) => layer.layer.id), "placeholder"],
layerIds: [
...globalLayerIds,
...view.layers.map((layer) => layer.layer.id),
HIGHLIGHT_LAYER_ID,
"placeholder",
],
});
viewerLayers.push(...view.layers);

Expand Down Expand Up @@ -137,14 +183,19 @@ export function LayersWrapper(props: LayersWrapperProps): React.ReactNode {
bounds = [prevBoundingBox.x[0], prevBoundingBox.y[0], prevBoundingBox.x[1], prevBoundingBox.y[1]];
}

const highlightLayer = useHighlightLayer(bounds, props.hoverService, props.viewContext.getInstanceIdString());

const layers = viewerLayers.toSorted((a, b) => b.position - a.position).map((layer) => layer.layer);
layers.push(new PlaceholderLayer({ id: "placeholder" }));
layers.push(highlightLayer);

return (
<div ref={mainDivRef} className="relative w-full h-full flex flex-col">
<PendingWrapper isPending={numLoadingLayers > 0}>
<div style={{ height: mainDivSize.height, width: mainDivSize.width }}>
<ReadoutWrapper
instanceId={props.viewContext.getInstanceIdString()}
hoverService={props.hoverService}
views={views}
viewportAnnotations={viewportAnnotations}
layers={layers}
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/modules/2DViewer/view/components/ReadoutWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import React from "react";

import type { Layer as DeckGlLayer } from "@deck.gl/core";
import type { HoverService } from "@framework/HoverService";
import { HoverTopic, usePublishHoverValue } from "@framework/HoverService";
import { SubsurfaceViewerWithCameraState } from "@modules/_shared/components/SubsurfaceViewerWithCameraState";
import { getHoverTopicValuesInEvent } from "@modules/_shared/utils/subsurfaceViewerLayers";
import type { BoundingBox2D, LayerPickInfo, MapMouseEvent, ViewStateType, ViewsType } from "@webviz/subsurface-viewer";

import { ReadoutBoxWrapper } from "./ReadoutBoxWrapper";
import { Toolbar } from "./Toolbar";

export type ReadooutWrapperProps = {
hoverService: HoverService;
instanceId: string;
views: ViewsType;
viewportAnnotations: React.ReactNode[];
layers: DeckGlLayer[];
Expand All @@ -21,12 +26,22 @@ export function ReadoutWrapper(props: ReadooutWrapperProps): React.ReactNode {
const [triggerHomeCounter, setTriggerHomeCounter] = React.useState<number>(0);
const [layerPickingInfo, setLayerPickingInfo] = React.useState<LayerPickInfo[]>([]);

const setHoveredWorldPos = usePublishHoverValue(HoverTopic.WORLD_POS, props.hoverService, props.instanceId);
const setHoveredWellbore = usePublishHoverValue(HoverTopic.WELLBORE, props.hoverService, props.instanceId);
const setHoveredMd = usePublishHoverValue(HoverTopic.MD, props.hoverService, props.instanceId);

function handleFitInViewClick() {
setTriggerHomeCounter((prev) => prev + 1);
}

function handleMouseHover(event: MapMouseEvent): void {
setLayerPickingInfo(event.infos);

const hoverData = getHoverTopicValuesInEvent(event, HoverTopic.MD, HoverTopic.WELLBORE, HoverTopic.WORLD_POS);

setHoveredWorldPos(hoverData[HoverTopic.WORLD_POS]);
setHoveredWellbore(hoverData[HoverTopic.WELLBORE]);
setHoveredMd(hoverData[HoverTopic.MD]);
}

function handleMouseEvent(event: MapMouseEvent): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { FilterContext, LayersList } from "@deck.gl/core";
import { Layer } from "@deck.gl/core";
import { GeoJsonLayer } from "@deck.gl/layers";
import { WellsLayer } from "@webviz/subsurface-viewer/dist/layers";
import { WellsLayer, type WellsLayerProps } from "@webviz/subsurface-viewer/dist/layers";

export class AdvancedWellsLayer extends WellsLayer {
static layerName: string = "WellsLayer";

constructor(props: any) {
super(props);
constructor(...propObjects: Partial<WellsLayerProps>[]) {
super(...propObjects);
}

filterSubLayer(context: FilterContext): boolean {
Expand Down
Loading
Loading