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 18 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
50cd5d5
Experiment - Add throttled broadcast service for hover-events
Anders2303 Jan 28, 2025
5eef53f
Seperated HoverService from workbenchservices
Anders2303 Jan 30, 2025
1c327f8
Updated hoverservice to use React.useSyncExternalStore
Anders2303 Jan 30, 2025
2e89999
Merge branch 'main' into framework/hover-service
Anders2303 Mar 10, 2025
78004d9
Merge branch 'main' into framework/hover-service
Anders2303 Mar 11, 2025
0b793d9
Fixed import and refactored code in Hover Service
Anders2303 Mar 12, 2025
c2b995b
Fixed hover service not properly notifying current module
Anders2303 Mar 12, 2025
1b60ea3
Made log viewer module work better with hover system
Anders2303 Mar 12, 2025
15960af
Split hover service hook into publish, set, and mixed hooks
Anders2303 Mar 18, 2025
3502f48
Made it possible to get the latest hovered module from hover service
Anders2303 Mar 18, 2025
57d7a08
Specified some deck.gl types
Anders2303 Mar 18, 2025
c73fc4d
Added world position and wellbore hover sync to 2D and 3D viewers
Anders2303 Mar 18, 2025
477a82d
Made intersection module publish world position on hover
Anders2303 Mar 18, 2025
32548ef
Made 2d layer verify that world pos hover is within bounds before hig…
Anders2303 Mar 18, 2025
3312309
Merge branch 'main' into framework/hover-service
Anders2303 May 27, 2025
c24204b
Seperated multi-readout to better follow structure: crosshair def mov…
Anders2303 May 27, 2025
faf40f4
_waitingPollingRun
Anders2303 Jun 13, 2025
a58850c
Fixed imports
Anders2303 Jun 13, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ async def get_fields_async(self) -> List[FieldInfo]:
async def _get_case_info_async(self, search_context: SearchContext, case_uuid: str) -> CaseInfo:

case = await search_context.get_case_by_uuid_async(case_uuid)
print(await case.timestamps_async)
return CaseInfo(uuid=case.uuid, name=case.name, status=case.status, user=case.user)

async def get_cases_async(self, field_identifier: str) -> List[CaseInfo]:
Expand Down
151 changes: 151 additions & 0 deletions frontend/src/framework/HoverService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import React from "react";

import { throttle } from "lodash";

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

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 @@ -3,6 +3,7 @@ import type React from "react";
import type { Getter, Setter } from "jotai";

import type { ChannelDefinition, ChannelReceiverDefinition } from "./DataChannelTypes";
import type { HoverService } from "./HoverService";
import type { InitialSettings } from "./InitialSettings";
import type { SettingsContext, ViewContext } from "./ModuleContext";
import type { ModuleDataTagId } from "./ModuleDataTags";
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 { loadMetadataFromBackendAndCreateEnsembleSet } from "./internal/EnsembleSetLoader";
import { PrivateWorkbenchServices } from "./internal/PrivateWorkbenchServices";
Expand Down Expand Up @@ -60,6 +61,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 @@ -72,6 +74,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 @@ -104,6 +107,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 @@ -18,6 +18,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 @@ -109,6 +109,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
6 changes: 3 additions & 3 deletions frontend/src/lib/utils/bbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,12 @@ export function combine(box1: BBox, box2: BBox): BBox {
vec3.create(
Math.min(box1.min.x, box2.min.x),
Math.min(box1.min.y, box2.min.y),
Math.min(box1.min.z, box2.min.z)
Math.min(box1.min.z, box2.min.z),
),
vec3.create(
Math.max(box1.max.x, box2.max.x),
Math.max(box1.max.y, box2.max.y),
Math.max(box1.max.z, box2.max.z)
)
Math.max(box1.max.z, box2.max.z),
),
);
}
53 changes: 47 additions & 6 deletions frontend/src/modules/2DViewer/view/components/LayersWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import React from "react";

import type { Layer } from "@deck.gl/core";
import type { BoundingBox2D } from "@webviz/subsurface-viewer";
import { CrosshairLayer } from "@webviz/subsurface-viewer/dist/layers";
import _ from "lodash";

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 Down Expand Up @@ -49,10 +52,38 @@ import "../../DataProviderFramework/customDataProviderImplementations/registerAl

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

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

function bboxToBound2d(box: bbox.BBox | null): BoundingBox2D | undefined {
if (!box) return undefined;

return [box.min.x, box.min.y, box.max.x, box.max.y];
}

// TODO: Combine with DPF's hover-layer functionality
function useHighlightLayer(
boundingBox: bbox.BBox | null,
hoverService: HoverService,
instanceId: string,
): CrosshairLayer {
const { x, y } = useHoverValue(HoverTopic.WORLD_POS, hoverService, instanceId) ?? {};
const xInRange = boundingBox && x && _.inRange(x, boundingBox.min.x, boundingBox.max.x);
const yInRange = boundingBox && y && _.inRange(y, boundingBox.min.y, boundingBox.max.y);

return new CrosshairLayer({
id: HOVER_CROSSHAIR_LAYER_ID,
worldCoordinates: [x ?? 0, y ?? 0, 0],
sizePx: 40,
// Hide it crosshair with opacity to keep layer mounted
color: [255, 255, 255, xInRange && yInRange ? 225 : 0],
});
}

const VISUALIZATION_ASSEMBLER = new VisualizationAssembler<VisualizationTarget.DECK_GL>();

VISUALIZATION_ASSEMBLER.registerDataProviderTransformers(
Expand Down Expand Up @@ -128,7 +159,7 @@ export function LayersWrapper(props: LayersWrapperProps): React.ReactNode {
const viewports: ViewportTypeExtended[] = [];
const deckGlLayers: Layer<any>[] = [];
const globalColorScales = globalAnnotations.filter((el) => "colorScale" in el);
const globalLayerIds: string[] = ["placeholder"];
const globalLayerIds: string[] = ["placeholder", HOVER_CROSSHAIR_LAYER_ID];

for (const item of assemblerProduct.children) {
if (item.itemType === VisualizationItemType.GROUP && item.groupType === GroupType.VIEW) {
Expand Down Expand Up @@ -193,23 +224,33 @@ export function LayersWrapper(props: LayersWrapperProps): React.ReactNode {
}

const numLoadingLayers = assemblerProduct.numLoadingDataProviders;

statusWriter.setLoading(assemblerProduct.numLoadingDataProviders > 0);

for (const message of assemblerProduct.aggregatedErrorMessages) {
statusWriter.addError(message);
}

let bounds: BoundingBox2D | undefined = undefined;
if (prevBoundingBox) {
bounds = [prevBoundingBox.min.x, prevBoundingBox.min.y, prevBoundingBox.max.x, prevBoundingBox.max.y];
}
const highlightLayer = useHighlightLayer(
prevBoundingBox,
props.hoverService,
props.viewContext.getInstanceIdString(),
);

deckGlLayers.push(new PlaceholderLayer({ id: "placeholder" }));
deckGlLayers.reverse();
// We want this one to always be on top
deckGlLayers.push(highlightLayer);

return (
<PendingWrapper className="w-full h-full flex flex-col" isPending={numLoadingLayers > 0}>
<SubsurfaceViewerWrapper views={views} layers={deckGlLayers} bounds={bounds} />
<SubsurfaceViewerWrapper
instanceId={props.viewContext.getInstanceIdString()}
hoverService={props.hoverService}
views={views}
layers={deckGlLayers}
bounds={bboxToBound2d(prevBoundingBox)}
/>
</PendingWrapper>
);
}
Loading
Loading