diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8eca54689..c3a165d2d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,7 +15,7 @@ function App() { const workbench = React.useRef(new Workbench()); const queryClient = useQueryClient(); - React.useEffect(() => { + React.useEffect(function handleMount() { if (!workbench.current.loadLayoutFromLocalStorage()) { workbench.current.makeLayout(layout); } @@ -32,8 +32,9 @@ function App() { }); } - return function () { + return function handleUnmount() { workbench.current.clearLayout(); + workbench.current.resetModuleInstanceNumbers(); }; }, []); diff --git a/frontend/src/framework/Broadcaster.ts b/frontend/src/framework/Broadcaster.ts index c3fdb82a7..ff62536fe 100644 --- a/frontend/src/framework/Broadcaster.ts +++ b/frontend/src/framework/Broadcaster.ts @@ -76,11 +76,17 @@ export type BroadcastChannelData = { value: number | string; }; +export type InputBroadcastChannelDef = { + name: string; + displayName: string; + keyCategories?: BroadcastChannelKeyCategory[]; +}; + export function checkChannelCompatibility( - channelDef1: BroadcastChannelDef, + channelDef: BroadcastChannelDef, channelKeyCategory: BroadcastChannelKeyCategory ): boolean { - if (channelDef1.key !== channelKeyCategory) { + if (channelDef.key !== channelKeyCategory) { return false; } @@ -89,6 +95,7 @@ export function checkChannelCompatibility( export class BroadcastChannel { private _name: string; + private _displayName: string; private _metaData: BroadcastChannelMeta | null; private _moduleInstanceId: string; private _subscribers: Set<(data: BroadcastChannelData[], metaData: BroadcastChannelMeta) => void>; @@ -96,8 +103,9 @@ export class BroadcastChannel { private _dataDef: BroadcastChannelDef; private _dataGenerator: (() => BroadcastChannelData[]) | null; - constructor(name: string, def: BroadcastChannelDef, moduleInstanceId: string) { + constructor(name: string, displayName: string, def: BroadcastChannelDef, moduleInstanceId: string) { this._name = name; + this._displayName = displayName; this._subscribers = new Set(); this._cachedData = null; this._dataDef = def; @@ -142,6 +150,10 @@ export class BroadcastChannel { return this._name; } + getDisplayName(): string { + return this._displayName; + } + getDataDef(): BroadcastChannelDef { return this._dataDef; } @@ -173,7 +185,7 @@ export class BroadcastChannel { } subscribe( - callbackChannelDataChanged: (data: BroadcastChannelData[], metaData: BroadcastChannelMeta) => void + callbackChannelDataChanged: (data: BroadcastChannelData[] | null, metaData: BroadcastChannelMeta | null) => void ): () => void { this._subscribers.add(callbackChannelDataChanged); @@ -181,9 +193,7 @@ export class BroadcastChannel { this._cachedData = this.generateAndVerifyData(this._dataGenerator); } - if (this._cachedData && this._metaData) { - callbackChannelDataChanged(this._cachedData, this._metaData); - } + callbackChannelDataChanged(this._cachedData ?? null, this._metaData ?? null); return () => { this._subscribers.delete(callbackChannelDataChanged); @@ -202,10 +212,11 @@ export class Broadcaster { registerChannel( channelName: string, + displayName: string, channelDef: BroadcastChannelDef, moduleInstanceId: string ): BroadcastChannel { - const channel = new BroadcastChannel(channelName, channelDef, moduleInstanceId); + const channel = new BroadcastChannel(channelName, displayName, channelDef, moduleInstanceId); this._channels.push(channel); this.notifySubscribersAboutChannelsChanges(); return channel; @@ -216,6 +227,10 @@ export class Broadcaster { this.notifySubscribersAboutChannelsChanges(); } + getChannelsForModuleInstance(moduleInstanceId: string): BroadcastChannel[] { + return this._channels.filter((c) => c.getModuleInstanceId() === moduleInstanceId); + } + getChannel(channelName: string): BroadcastChannel | null { const channel = this._channels.find((c) => c.getName() === channelName); if (!channel) { diff --git a/frontend/src/framework/GuiMessageBroker.ts b/frontend/src/framework/GuiMessageBroker.ts index 44f0e89e4..6e3920c64 100644 --- a/frontend/src/framework/GuiMessageBroker.ts +++ b/frontend/src/framework/GuiMessageBroker.ts @@ -2,6 +2,8 @@ import React from "react"; import { Point } from "@lib/utils/geometry"; +import { GlobalCursor } from "./internal/GlobalCursor"; + export enum DrawerContent { ModuleSettings = "ModuleSettings", ModulesList = "ModulesList", @@ -15,12 +17,23 @@ export enum GuiState { SettingsPanelWidthInPercent = "settingsPanelWidthInPercent", LoadingEnsembleSet = "loadingEnsembleSet", ActiveModuleInstanceId = "activeModuleInstanceId", + DataChannelConnectionLayerVisible = "dataChannelConnectionLayerVisible", } export enum GuiEvent { ModuleHeaderPointerDown = "moduleHeaderPointerDown", NewModulePointerDown = "newModulePointerDown", RemoveModuleInstanceRequest = "removeModuleInstanceRequest", + EditDataChannelConnectionsForModuleInstanceRequest = "editDataChannelConnectionsForModuleInstanceRequest", + ShowDataChannelConnectionsRequest = "showDataChannelConnectionsRequest", + HideDataChannelConnectionsRequest = "hideDataChannelConnectionsRequest", + HighlightDataChannelConnectionRequest = "highlightDataChannelConnectionRequest", + UnhighlightDataChannelConnectionRequest = "unhighlightDataChannelConnectionRequest", + DataChannelPointerUp = "dataChannelPointerUp", + DataChannelOriginPointerDown = "dataChannelOriginPointerDown", + DataChannelConnectionsChange = "dataChannelConnectionsChange", + DataChannelNodeHover = "dataChannelNodeHover", + DataChannelNodeUnhover = "dataChannelNodeUnhover", } export type GuiEventPayloads = { @@ -37,6 +50,20 @@ export type GuiEventPayloads = { [GuiEvent.RemoveModuleInstanceRequest]: { moduleInstanceId: string; }; + [GuiEvent.EditDataChannelConnectionsForModuleInstanceRequest]: { + moduleInstanceId: string; + }; + [GuiEvent.HighlightDataChannelConnectionRequest]: { + moduleInstanceId: string; + dataChannelName: string; + }; + [GuiEvent.DataChannelOriginPointerDown]: { + moduleInstanceId: string; + originElement: HTMLElement; + }; + [GuiEvent.DataChannelNodeHover]: { + connectionAllowed: boolean; + }; }; type GuiStateValueTypes = { @@ -44,6 +71,7 @@ type GuiStateValueTypes = { [GuiState.SettingsPanelWidthInPercent]: number; [GuiState.LoadingEnsembleSet]: boolean; [GuiState.ActiveModuleInstanceId]: string; + [GuiState.DataChannelConnectionLayerVisible]: boolean; }; const defaultStates: Map = new Map(); @@ -58,15 +86,21 @@ export class GuiMessageBroker { private _eventListeners: Map void>>; private _stateSubscribers: Map void>>; private _storedValues: Map; + private _globalCursor: GlobalCursor; constructor() { this._eventListeners = new Map(); this._stateSubscribers = new Map(); this._storedValues = defaultStates; + this._globalCursor = new GlobalCursor(); this.loadPersistentStates(); } + getGlobalCursor(): GlobalCursor { + return this._globalCursor; + } + private loadPersistentStates() { persistentStates.forEach((state) => { const value = localStorage.getItem(state); @@ -84,7 +118,12 @@ export class GuiMessageBroker { } } - subscribeToEvent(event: T, callback: (payload: GuiEventPayloads[T]) => void) { + subscribeToEvent>(event: T, callback: () => void): () => void; + subscribeToEvent( + event: T, + callback: (payload: GuiEventPayloads[T]) => void + ): () => void; + subscribeToEvent(event: T, callback: (payload?: any) => void): () => void { const eventListeners = this._eventListeners.get(event) || new Set(); eventListeners.add(callback); this._eventListeners.set(event, eventListeners); @@ -94,7 +133,9 @@ export class GuiMessageBroker { }; } - publishEvent(event: T, payload: GuiEventPayloads[T]) { + publishEvent>(event: T): void; + publishEvent(event: T, payload: GuiEventPayloads[T]): void; + publishEvent(event: T, payload?: any): void { const eventListeners = this._eventListeners.get(event); if (eventListeners) { eventListeners.forEach((callback) => callback({ ...payload })); diff --git a/frontend/src/framework/Module.tsx b/frontend/src/framework/Module.tsx index 9d825f3b1..a1ea34fe5 100644 --- a/frontend/src/framework/Module.tsx +++ b/frontend/src/framework/Module.tsx @@ -2,7 +2,7 @@ import React from "react"; import { cloneDeep } from "lodash"; -import { BroadcastChannelsDef } from "./Broadcaster"; +import { BroadcastChannelsDef, InputBroadcastChannelDef } from "./Broadcaster"; import { InitialSettings } from "./InitialSettings"; import { ModuleContext } from "./ModuleContext"; import { ModuleInstance } from "./ModuleInstance"; @@ -45,12 +45,14 @@ export class Module { private _channelsDef: BroadcastChannelsDef; private _drawPreviewFunc: DrawPreviewFunc | null; private _description: string | null; + private _inputChannelDefs: InputBroadcastChannelDef[]; constructor( name: string, defaultTitle: string, syncableSettingKeys: SyncSettingKey[] = [], broadcastChannelsDef: BroadcastChannelsDef = {}, + inputChannelDefs: InputBroadcastChannelDef[] = [], drawPreviewFunc: DrawPreviewFunc | null = null, description: string | null = null ) { @@ -64,6 +66,7 @@ export class Module { this._workbench = null; this._syncableSettingKeys = syncableSettingKeys; this._channelsDef = broadcastChannelsDef; + this._inputChannelDefs = inputChannelDefs; this._drawPreviewFunc = drawPreviewFunc; this._description = description; } @@ -115,7 +118,13 @@ export class Module { throw new Error("Module must be added to a workbench before making an instance"); } - const instance = new ModuleInstance(this, instanceNumber, this._channelsDef, this._workbench); + const instance = new ModuleInstance( + this, + instanceNumber, + this._channelsDef, + this._workbench, + this._inputChannelDefs + ); this._moduleInstances.push(instance); this.maybeImportSelf(); return instance; diff --git a/frontend/src/framework/ModuleContext.ts b/frontend/src/framework/ModuleContext.ts index 6bc70e309..60889635a 100644 --- a/frontend/src/framework/ModuleContext.ts +++ b/frontend/src/framework/ModuleContext.ts @@ -1,6 +1,7 @@ import React from "react"; -import { BroadcastChannel } from "./Broadcaster"; +import { BroadcastChannel, InputBroadcastChannelDef } from "./Broadcaster"; +import { InitialSettings } from "./InitialSettings"; import { ModuleInstance } from "./ModuleInstance"; import { ModuleInstanceStatusController } from "./ModuleInstanceStatusController"; import { StateBaseType, StateStore, useSetStoreValue, useStoreState, useStoreValue } from "./StateStore"; @@ -50,10 +51,6 @@ export class ModuleContext { return keyArr; } - getChannel(channelName: string): BroadcastChannel { - return this._moduleInstance.getBroadcastChannel(channelName); - } - setInstanceTitle(title: string): void { this._moduleInstance.setTitle(title); } @@ -61,4 +58,44 @@ export class ModuleContext { getStatusController(): ModuleInstanceStatusController { return this._moduleInstance.getStatusController(); } + + getChannel(channelName: string): BroadcastChannel { + return this._moduleInstance.getBroadcastChannel(channelName); + } + + getInputChannel(name: string): BroadcastChannel | null { + return this._moduleInstance.getInputChannel(name); + } + + setInputChannel(inputName: string, channelName: string): void { + this._moduleInstance.setInputChannel(inputName, channelName); + } + + getInputChannelDef(name: string): InputBroadcastChannelDef | undefined { + return this._moduleInstance.getInputChannelDefs().find((channelDef) => channelDef.name === name); + } + + useInputChannel(name: string, initialSettings?: InitialSettings): BroadcastChannel | null { + const [channel, setChannel] = React.useState(null); + + React.useEffect(() => { + if (initialSettings) { + const setting = initialSettings.get(name, "string"); + if (setting) { + this._moduleInstance.setInputChannel(name, setting); + } + } + }, [initialSettings]); + + React.useEffect(() => { + function handleNewChannel(newChannel: BroadcastChannel | null) { + setChannel(newChannel); + } + + const unsubscribeFunc = this._moduleInstance.subscribeToInputChannelChange(name, handleNewChannel); + return unsubscribeFunc; + }, [name]); + + return channel; + } } diff --git a/frontend/src/framework/ModuleInstance.ts b/frontend/src/framework/ModuleInstance.ts index d94dd1d65..17335af2e 100644 --- a/frontend/src/framework/ModuleInstance.ts +++ b/frontend/src/framework/ModuleInstance.ts @@ -2,7 +2,7 @@ import { ErrorInfo } from "react"; import { cloneDeep } from "lodash"; -import { BroadcastChannel, BroadcastChannelsDef } from "./Broadcaster"; +import { BroadcastChannel, BroadcastChannelsDef, InputBroadcastChannelDef } from "./Broadcaster"; import { InitialSettings } from "./InitialSettings"; import { ImportState, Module, ModuleFC } from "./Module"; import { ModuleContext } from "./ModuleContext"; @@ -31,18 +31,24 @@ export class ModuleInstance { private _importStateSubscribers: Set<() => void>; private _moduleInstanceStateSubscribers: Set<(moduleInstanceState: ModuleInstanceState) => void>; private _syncedSettingsSubscribers: Set<(syncedSettings: SyncSettingKey[]) => void>; + private _inputChannelSubscribers: Record void>>; + private _inputChannelsSubscribers: Set<() => void>; private _titleChangeSubscribers: Set<(title: string) => void>; private _broadcastChannels: Record; private _cachedDefaultState: StateType | null; private _cachedStateStoreOptions?: StateOptions; private _initialSettings: InitialSettings | null; private _statusController: ModuleInstanceStatusControllerInternal; + private _inputChannelDefs: InputBroadcastChannelDef[]; + private _inputChannels: Record = {}; + private _workbench: Workbench; constructor( module: Module, instanceNumber: number, broadcastChannelsDef: BroadcastChannelsDef, - workbench: Workbench + workbench: Workbench, + inputChannelDefs: InputBroadcastChannelDef[] ) { this._id = `${module.getName()}-${instanceNumber}`; this._title = module.getDefaultTitle(); @@ -55,11 +61,16 @@ export class ModuleInstance { this._syncedSettingsSubscribers = new Set(); this._moduleInstanceStateSubscribers = new Set(); this._titleChangeSubscribers = new Set(); + this._inputChannelSubscribers = {}; + this._inputChannelsSubscribers = new Set(); this._moduleInstanceState = ModuleInstanceState.INITIALIZING; this._fatalError = null; this._cachedDefaultState = null; this._initialSettings = null; this._statusController = new ModuleInstanceStatusControllerInternal(); + this._inputChannelDefs = inputChannelDefs; + this._inputChannels = {}; + this._workbench = workbench; this._broadcastChannels = {} as Record; @@ -70,11 +81,47 @@ export class ModuleInstance { const enrichedChannelName = `${this._id} - ${channelName as string}`; this._broadcastChannels[channelName] = workbench .getBroadcaster() - .registerChannel(enrichedChannelName, broadcastChannelsDef[channelName as string], this._id); + .registerChannel( + enrichedChannelName, + channelName, + broadcastChannelsDef[channelName as string], + this._id + ); }); } } + getInputChannelDefs(): InputBroadcastChannelDef[] { + return this._inputChannelDefs; + } + + setInputChannel(inputName: string, channelName: string): void { + const channel = this._workbench.getBroadcaster().getChannel(channelName); + if (!channel) { + throw new Error(`Channel '${channelName}' does not exist on module '${this._title}'`); + } + this._inputChannels[inputName] = channel; + this.notifySubscribersAboutInputChannelChange(inputName); + this.notifySubscribersAboutInputChannelsChange(); + } + + removeInputChannel(inputName: string): void { + delete this._inputChannels[inputName]; + this.notifySubscribersAboutInputChannelChange(inputName); + this.notifySubscribersAboutInputChannelsChange(); + } + + getInputChannel(inputName: string): BroadcastChannel | null { + if (!this._inputChannels[inputName]) { + return null; + } + return this._inputChannels[inputName]; + } + + getInputChannels(): Record { + return this._inputChannels; + } + getBroadcastChannel(channelName: string): BroadcastChannel { if (!this._broadcastChannels[channelName]) { throw new Error(`Channel '${channelName}' does not exist on module '${this._title}'`); @@ -83,6 +130,14 @@ export class ModuleInstance { return this._broadcastChannels[channelName]; } + getBroadcastChannels(): Record { + return this._broadcastChannels; + } + + hasBroadcastChannels(): boolean { + return Object.keys(this._broadcastChannels).length > 0; + } + setDefaultState(defaultState: StateType, options?: StateOptions): void { if (this._cachedDefaultState === null) { this._cachedDefaultState = defaultState; @@ -124,6 +179,31 @@ export class ModuleInstance { }; } + subscribeToInputChannelsChange(cb: () => void): () => void { + this._inputChannelsSubscribers.add(cb); + + // Trigger callback immediately with our current set of keys + cb(); + + return () => { + this._inputChannelsSubscribers.delete(cb); + }; + } + + subscribeToInputChannelChange(inputName: string, cb: (channel: BroadcastChannel | null) => void): () => void { + if (!this._inputChannelSubscribers[inputName]) { + this._inputChannelSubscribers[inputName] = new Set(); + } + + this._inputChannelSubscribers[inputName].add(cb); + + cb(this.getInputChannel(inputName)); + + return () => { + this._inputChannelSubscribers[inputName].delete(cb); + }; + } + isInitialised(): boolean { return this._initialised; } @@ -204,6 +284,21 @@ export class ModuleInstance { }); } + notifySubscribersAboutInputChannelsChange(): void { + this._inputChannelsSubscribers.forEach((subscriber) => { + subscriber(); + }); + } + + notifySubscribersAboutInputChannelChange(inputName: string): void { + if (!this._inputChannelSubscribers[inputName]) { + return; + } + this._inputChannelSubscribers[inputName].forEach((subscriber) => { + subscriber(this.getInputChannel(inputName)); + }); + } + subscribeToModuleInstanceStateChange(cb: () => void): () => void { this._moduleInstanceStateSubscribers.add(cb); return () => { diff --git a/frontend/src/framework/ModuleRegistry.ts b/frontend/src/framework/ModuleRegistry.ts index 92e3edc22..8cf91041a 100644 --- a/frontend/src/framework/ModuleRegistry.ts +++ b/frontend/src/framework/ModuleRegistry.ts @@ -1,4 +1,4 @@ -import { BroadcastChannelsDef } from "./Broadcaster"; +import { BroadcastChannelsDef, InputBroadcastChannelDef } from "./Broadcaster"; import { Module } from "./Module"; import { DrawPreviewFunc } from "./Preview"; import { StateBaseType, StateOptions } from "./StateStore"; @@ -10,6 +10,7 @@ export type RegisterModuleOptions = { defaultTitle: string; syncableSettingKeys?: SyncSettingKey[]; broadcastChannelsDef?: BroadcastChannelsDef; + inputChannelDefs?: InputBroadcastChannelDef[]; preview?: DrawPreviewFunc; description?: string; }; @@ -39,6 +40,7 @@ export class ModuleRegistry { options.defaultTitle, options.syncableSettingKeys, options.broadcastChannelsDef, + options.inputChannelDefs, options.preview ?? null, options.description ?? null ); diff --git a/frontend/src/framework/StatusWriter.ts b/frontend/src/framework/StatusWriter.ts index a71aa4266..6611ab1f5 100644 --- a/frontend/src/framework/StatusWriter.ts +++ b/frontend/src/framework/StatusWriter.ts @@ -62,10 +62,10 @@ export function useViewStatusWriter(moduleContext: ModuleContext): ViewStat return statusWriter.current; } -export function useSettingsStatusWriter(moduleContext: ModuleContext): ViewStatusWriter { +export function useSettingsStatusWriter(moduleContext: ModuleContext): SettingsStatusWriter { const statusController = moduleContext.getStatusController(); - const statusWriter = React.useRef(new ViewStatusWriter(statusController)); + const statusWriter = React.useRef(new SettingsStatusWriter(statusController)); statusController.clearMessages(StatusSource.Settings); statusController.incrementReportedComponentRenderCount(StatusSource.Settings); diff --git a/frontend/src/framework/Workbench.ts b/frontend/src/framework/Workbench.ts index ac44d0b55..9a31da9b2 100644 --- a/frontend/src/framework/Workbench.ts +++ b/frontend/src/framework/Workbench.ts @@ -136,13 +136,16 @@ export class Workbench { }); } + resetModuleInstanceNumbers(): void { + this._perModuleRunningInstanceNumber = {}; + } + clearLayout(): void { for (const moduleInstance of this._moduleInstances) { this._broadcaster.unregisterAllChannelsForModuleInstance(moduleInstance.getId()); } this._moduleInstances = []; this._layout = []; - this._perModuleRunningInstanceNumber = {}; this.notifySubscribers(WorkbenchEvents.ModuleInstancesChanged); } @@ -164,6 +167,18 @@ export class Workbench { } removeModuleInstance(moduleInstanceId: string): void { + const channels = this._broadcaster.getChannelsForModuleInstance(moduleInstanceId); + + for (const channel of channels) { + for (const moduleInstance of this._moduleInstances) { + for (const [inputChannelName, inputChannel] of Object.entries(moduleInstance.getInputChannels())) { + if (inputChannel === channel) { + moduleInstance.removeInputChannel(inputChannelName); + } + } + } + } + this._broadcaster.unregisterAllChannelsForModuleInstance(moduleInstanceId); this._moduleInstances = this._moduleInstances.filter((el) => el.getId() !== moduleInstanceId); diff --git a/frontend/src/framework/components/ChannelSelect/channelSelect.tsx b/frontend/src/framework/components/ChannelSelect/channelSelect.tsx index fff6d2adc..ec04c072f 100644 --- a/frontend/src/framework/components/ChannelSelect/channelSelect.tsx +++ b/frontend/src/framework/components/ChannelSelect/channelSelect.tsx @@ -1,70 +1,53 @@ import React from "react"; -import { - BroadcastChannel, - BroadcastChannelKeyCategory, - Broadcaster, - checkChannelCompatibility, -} from "@framework/Broadcaster"; +import { BroadcastChannel, Broadcaster } from "@framework/Broadcaster"; +import { ModuleContext } from "@framework/ModuleContext"; import { BaseComponentProps } from "@lib/components/BaseComponent"; import { Dropdown } from "@lib/components/Dropdown"; export type ChannelSelectProps = { - initialChannel?: string; - channelKeyCategory?: BroadcastChannelKeyCategory; - onChange?: (channel: string) => void; + moduleContext: ModuleContext; + channelName: string; className?: string; broadcaster: Broadcaster; } & BaseComponentProps; export const ChannelSelect: React.FC = (props) => { - const { channelKeyCategory, onChange, broadcaster, ...rest } = props; - const [channel, setChannel] = React.useState(props.initialChannel ?? ""); - const [prevInitialChannel, setPrevInitialChannel] = React.useState(props.initialChannel); - const [channels, setChannels] = React.useState([]); + const { moduleContext, broadcaster, ...rest } = props; - if (prevInitialChannel !== props.initialChannel) { - setPrevInitialChannel(props.initialChannel); - setChannel(props.initialChannel ?? ""); - } + const channel = moduleContext.useInputChannel(props.channelName); + const [channels, setChannels] = React.useState([]); React.useEffect(() => { const handleChannelsChanged = (channels: BroadcastChannel[]) => { - setChannels( - channels - .filter( - (el) => - !props.channelKeyCategory || - checkChannelCompatibility(el.getDataDef(), props.channelKeyCategory) - ) - .map((el) => el.getName()) - ); + const inputChannel = moduleContext.getInputChannel(props.channelName); - if (channels.length === 0 || !channels.find((el) => el.getName() === channel)) { - setChannel(""); + const acceptedKeys = moduleContext.getInputChannelDef(props.channelName)?.keyCategories; - if (onChange) { - onChange(""); + const validChannels = Object.values(channels).filter((channel) => { + if (!acceptedKeys || acceptedKeys.some((key) => channel.getDataDef().key === key)) { + if (!inputChannel || inputChannel.getDataDef().key === channel.getDataDef().key) { + return true; + } + return false; } - } + }); + setChannels(validChannels.map((el) => el.getName())); }; const unsubscribeFunc = broadcaster.subscribeToChannelsChanges(handleChannelsChanged); return unsubscribeFunc; - }, [channelKeyCategory, onChange, broadcaster, channel]); + }, [moduleContext, broadcaster, props.channelName]); - const handleChannelsChanged = (channel: string) => { - setChannel(channel); - if (onChange) { - onChange(channel); - } + const handleChannelsChanged = (channelName: string) => { + moduleContext.setInputChannel(props.channelName, channelName); }; return ( ({ label: el, value: el }))} - value={channel} + value={channel?.getName()} onChange={handleChannelsChanged} {...rest} /> diff --git a/frontend/src/framework/internal/GlobalCursor.ts b/frontend/src/framework/internal/GlobalCursor.ts new file mode 100644 index 000000000..056310505 --- /dev/null +++ b/frontend/src/framework/internal/GlobalCursor.ts @@ -0,0 +1,106 @@ +export enum GlobalCursorType { + Default = "default", + Copy = "copy", + Pointer = "pointer", + Grab = "grab", + Grabbing = "grabbing", + Crosshair = "crosshair", + Move = "move", + Text = "text", + Wait = "wait", + Help = "help", + Progress = "progress", + Cell = "cell", + VerticalText = "vertical-text", + Alias = "alias", + ContextMenu = "context-menu", + NoDrop = "no-drop", + NotAllowed = "not-allowed", +} + +export class GlobalCursor { + private _cursorStack: GlobalCursorType[]; + private _lastCursor: GlobalCursorType = GlobalCursorType.Default; + + constructor() { + this._cursorStack = []; + } + + private getCursorClassName(cursorType: GlobalCursorType) { + switch (cursorType) { + case GlobalCursorType.Default: + return "cursor-default"; + case GlobalCursorType.Copy: + return "cursor-copy"; + case GlobalCursorType.Pointer: + return "cursor-pointer"; + case GlobalCursorType.Grab: + return "cursor-grab"; + case GlobalCursorType.Grabbing: + return "cursor-grabbing"; + case GlobalCursorType.Crosshair: + return "cursor-crosshair"; + case GlobalCursorType.Move: + return "cursor-move"; + case GlobalCursorType.Text: + return "cursor-text"; + case GlobalCursorType.Wait: + return "cursor-wait"; + case GlobalCursorType.Help: + return "cursor-help"; + case GlobalCursorType.Progress: + return "cursor-progress"; + case GlobalCursorType.Cell: + return "cursor-cell"; + case GlobalCursorType.VerticalText: + return "cursor-vertical-text"; + case GlobalCursorType.Alias: + return "cursor-alias"; + case GlobalCursorType.ContextMenu: + return "cursor-context-menu"; + case GlobalCursorType.NoDrop: + return "cursor-no-drop"; + case GlobalCursorType.NotAllowed: + return "cursor-not-allowed"; + default: + return "cursor-default"; + } + } + + private updateBodyCursor() { + if (this._lastCursor !== GlobalCursorType.Default) { + const oldCursorClass = this.getCursorClassName(this._lastCursor); + document.body.classList.remove(oldCursorClass); + } + + if (this._cursorStack.length === 0) { + this._lastCursor = GlobalCursorType.Default; + return; + } + + const newCursor = this._cursorStack[this._cursorStack.length - 1]; + const newCursorClass = this.getCursorClassName(newCursor); + document.body.classList.add(newCursorClass); + this._lastCursor = newCursor; + } + + setOverrideCursor(cursorType: GlobalCursorType) { + this._cursorStack.push(cursorType); + this.updateBodyCursor(); + } + + restoreOverrideCursor() { + if (this._cursorStack.length > 0) { + this._cursorStack.pop(); + this.updateBodyCursor(); + } + } + + changeOverrideCursor(cursorType: GlobalCursorType) { + if (this._cursorStack.length > 0) { + this._cursorStack.pop(); + } + this._cursorStack.push(cursorType); + this.updateBodyCursor(); + } +} diff --git a/frontend/src/framework/internal/ModuleNotFoundPlaceholder.tsx b/frontend/src/framework/internal/ModuleNotFoundPlaceholder.tsx index a79251ba8..ed17f48fc 100644 --- a/frontend/src/framework/internal/ModuleNotFoundPlaceholder.tsx +++ b/frontend/src/framework/internal/ModuleNotFoundPlaceholder.tsx @@ -6,7 +6,7 @@ import { BugReport, Forum, WebAssetOff } from "@mui/icons-material"; export class ModuleNotFoundPlaceholder extends Module> { constructor(moduleName: string) { - super(moduleName, moduleName, [], {}, null); + super(moduleName, moduleName, [], {}, [], null); this._importState = ImportState.Imported; } diff --git a/frontend/src/framework/internal/components/Content/content.tsx b/frontend/src/framework/internal/components/Content/content.tsx index d83ead2eb..5480e2a38 100644 --- a/frontend/src/framework/internal/components/Content/content.tsx +++ b/frontend/src/framework/internal/components/Content/content.tsx @@ -3,6 +3,7 @@ import React from "react"; import { GuiState, useGuiValue } from "@framework/GuiMessageBroker"; import { Workbench } from "@framework/Workbench"; +import { DataChannelVisualizationLayer } from "./private-components/DataChannelVisualizationLayer"; import { Layout } from "./private-components/layout"; type ContentProps = { @@ -12,8 +13,11 @@ type ContentProps = { export const Content: React.FC = (props) => { const activeModuleInstanceId = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.ActiveModuleInstanceId); return ( -
- -
+ <> + +
+ +
+ ); }; diff --git a/frontend/src/framework/internal/components/Content/private-components/DataChannelVisualizationLayer/dataChannelVisualizationLayer.tsx b/frontend/src/framework/internal/components/Content/private-components/DataChannelVisualizationLayer/dataChannelVisualizationLayer.tsx new file mode 100644 index 000000000..f3fe43127 --- /dev/null +++ b/frontend/src/framework/internal/components/Content/private-components/DataChannelVisualizationLayer/dataChannelVisualizationLayer.tsx @@ -0,0 +1,450 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import { GuiEvent, GuiEventPayloads, GuiState, useGuiState } from "@framework/GuiMessageBroker"; +import { Workbench } from "@framework/Workbench"; +import { GlobalCursorType } from "@framework/internal/GlobalCursor"; +import { useElementBoundingRect } from "@lib/hooks/useElementBoundingRect"; +import { Point } from "@lib/utils/geometry"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; + +export type DataChannelVisualizationProps = { + workbench: Workbench; +}; + +type DataChannelPath = { + key: string; + origin: Point; + midPoint1: Point; + midPoint2: Point; + destination: Point; + description: string; + descriptionCenterPoint: Point; + highlighted: boolean; +}; + +export const DataChannelVisualizationLayer: React.FC = (props) => { + const ref = React.useRef(null); + const [visible, setVisible] = React.useState(false); + const [originPoint, setOriginPoint] = React.useState({ x: 0, y: 0 }); + const [currentPointerPosition, setCurrentPointerPosition] = React.useState({ x: 0, y: 0 }); + const [currentChannelName, setCurrentChannelName] = React.useState(null); + const [showDataChannelConnections, setShowDataChannelConnections] = useGuiState( + props.workbench.getGuiMessageBroker(), + GuiState.DataChannelConnectionLayerVisible + ); + const [highlightedDataChannelConnection, setHighlightedDataChannelConnection] = React.useState<{ + moduleInstanceId: string; + dataChannelName: string; + } | null>(null); + const [editDataChannelConnectionsForModuleInstanceId, setEditDataChannelConnectionsForModuleInstanceId] = + React.useState(null); + + // When data channels are changed within a module, we need to force a rerender to update the drawn arrows + const forceRerender = React.useReducer((x) => x + 1, 0)[1]; + + const timeoutRef = React.useRef | null>(null); + + const boundingRect = useElementBoundingRect(ref); + + const guiMessageBroker = props.workbench.getGuiMessageBroker(); + + const [, setDataChannelConnectionsLayerVisible] = useGuiState( + guiMessageBroker, + GuiState.DataChannelConnectionLayerVisible + ); + + React.useEffect(() => { + let localMousePressed = false; + let localCurrentOriginPoint: Point = { x: 0, y: 0 }; + let localEditDataChannelConnections = false; + + function handleDataChannelOriginPointerDown(payload: GuiEventPayloads[GuiEvent.DataChannelOriginPointerDown]) { + const clientRect = payload.originElement.getBoundingClientRect(); + localCurrentOriginPoint = { + x: clientRect.left + clientRect.width / 2, + y: clientRect.top + clientRect.height / 2, + }; + setVisible(true); + setOriginPoint(localCurrentOriginPoint); + guiMessageBroker.getGlobalCursor().setOverrideCursor(GlobalCursorType.Crosshair); + localMousePressed = true; + setCurrentPointerPosition(localCurrentOriginPoint); + setCurrentChannelName(null); + setDataChannelConnectionsLayerVisible(true); + + const moduleInstance = props.workbench.getModuleInstance(payload.moduleInstanceId); + if (!moduleInstance) { + return; + } + + const availableChannels = moduleInstance.getBroadcastChannels(); + if (Object.keys(availableChannels).length === 1) { + setCurrentChannelName(Object.values(availableChannels)[0].getDisplayName()); + return; + } + } + + function handlePointerUp() { + localMousePressed = false; + + if (localEditDataChannelConnections) { + return; + } + setShowDataChannelConnections(false); + setEditDataChannelConnectionsForModuleInstanceId(null); + } + + function handleDataChannelDone() { + localEditDataChannelConnections = false; + setVisible(false); + setEditDataChannelConnectionsForModuleInstanceId(null); + setShowDataChannelConnections(false); + guiMessageBroker.getGlobalCursor().restoreOverrideCursor(); + setDataChannelConnectionsLayerVisible(false); + } + + function handlePointerMove(e: PointerEvent) { + if (!localMousePressed) { + return; + } + const hoveredElement = document.elementFromPoint(e.clientX, e.clientY); + if ( + hoveredElement && + hoveredElement instanceof HTMLElement && + hoveredElement.hasAttribute("data-channelconnector") + ) { + const boundingRect = hoveredElement.getBoundingClientRect(); + setCurrentPointerPosition({ + x: boundingRect.left + boundingRect.width / 2, + y: localCurrentOriginPoint.y > boundingRect.top ? boundingRect.bottom : boundingRect.top, + }); + return; + } + setCurrentPointerPosition({ x: e.clientX, y: e.clientY }); + } + + function handleConnectionChange() { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + forceRerender(); + }, 100); + } + + function handleNodeHover(payload: GuiEventPayloads[GuiEvent.DataChannelNodeHover]) { + if (!localEditDataChannelConnections) { + if (payload.connectionAllowed) { + guiMessageBroker.getGlobalCursor().changeOverrideCursor(GlobalCursorType.Copy); + } else { + guiMessageBroker.getGlobalCursor().changeOverrideCursor(GlobalCursorType.NotAllowed); + } + } + } + + function handleNodeUnhover() { + guiMessageBroker.getGlobalCursor().restoreOverrideCursor(); + } + + function handleEditDataChannelConnectionsRequest( + payload: GuiEventPayloads[GuiEvent.EditDataChannelConnectionsForModuleInstanceRequest] + ) { + localEditDataChannelConnections = true; + setEditDataChannelConnectionsForModuleInstanceId(payload.moduleInstanceId); + setShowDataChannelConnections(true); + } + + function handleHighlightDataChannelConnectionRequest( + payload: GuiEventPayloads[GuiEvent.HighlightDataChannelConnectionRequest] + ) { + setHighlightedDataChannelConnection({ + moduleInstanceId: payload.moduleInstanceId, + dataChannelName: payload.dataChannelName, + }); + } + + function handleUnhighlightDataChannelConnectionRequest() { + setHighlightedDataChannelConnection(null); + } + + const removeHighlightDataChannelConnectionRequestHandler = guiMessageBroker.subscribeToEvent( + GuiEvent.HighlightDataChannelConnectionRequest, + handleHighlightDataChannelConnectionRequest + ); + + const removeUnhighlightDataChannelConnectionRequestHandler = guiMessageBroker.subscribeToEvent( + GuiEvent.UnhighlightDataChannelConnectionRequest, + handleUnhighlightDataChannelConnectionRequest + ); + + const removeEditDataChannelConnectionsRequestHandler = guiMessageBroker.subscribeToEvent( + GuiEvent.EditDataChannelConnectionsForModuleInstanceRequest, + handleEditDataChannelConnectionsRequest + ); + + const removeDataChannelOriginPointerDownHandler = guiMessageBroker.subscribeToEvent( + GuiEvent.DataChannelOriginPointerDown, + handleDataChannelOriginPointerDown + ); + const removeDataChannelPointerUpHandler = guiMessageBroker.subscribeToEvent( + GuiEvent.DataChannelPointerUp, + handlePointerUp + ); + const removeDataChannelDoneHandler = guiMessageBroker.subscribeToEvent( + GuiEvent.HideDataChannelConnectionsRequest, + handleDataChannelDone + ); + const removeConnectionChangeHandler = guiMessageBroker.subscribeToEvent( + GuiEvent.DataChannelConnectionsChange, + handleConnectionChange + ); + const removeNodeHoverHandler = guiMessageBroker.subscribeToEvent( + GuiEvent.DataChannelNodeHover, + handleNodeHover + ); + const removeNodeUnhoverHandler = guiMessageBroker.subscribeToEvent( + GuiEvent.DataChannelNodeUnhover, + handleNodeUnhover + ); + + document.addEventListener("pointerup", handlePointerUp); + document.addEventListener("pointermove", handlePointerMove); + document.addEventListener("resize", handleConnectionChange); + + return () => { + removeEditDataChannelConnectionsRequestHandler(); + removeHighlightDataChannelConnectionRequestHandler(); + removeUnhighlightDataChannelConnectionRequestHandler(); + removeDataChannelOriginPointerDownHandler(); + removeDataChannelPointerUpHandler(); + removeDataChannelDoneHandler(); + removeConnectionChangeHandler(); + removeNodeHoverHandler(); + removeNodeUnhoverHandler(); + + document.removeEventListener("pointerup", handlePointerUp); + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("resize", handleConnectionChange); + }; + }, []); + let midPointY = (originPoint.y + currentPointerPosition.y) / 2; + + if (currentPointerPosition.y < originPoint.y + 40 && currentPointerPosition.y > originPoint.y) { + midPointY = originPoint.y - 20; + } else if (currentPointerPosition.y > originPoint.y - 40 && currentPointerPosition.y < originPoint.y) { + midPointY = originPoint.y + 20; + } + + const midPoint1: Point = { + x: originPoint.x, + y: midPointY, + }; + + const midPoint2: Point = { + x: currentPointerPosition.x, + y: midPointY, + }; + + function makeDataChannelPaths() { + const dataChannelPaths: DataChannelPath[] = []; + for (const moduleInstance of props.workbench.getModuleInstances()) { + if ( + editDataChannelConnectionsForModuleInstanceId && + moduleInstance.getId() !== editDataChannelConnectionsForModuleInstanceId + ) { + continue; + } + const inputChannels = moduleInstance.getInputChannels(); + if (!inputChannels) { + continue; + } + + for (const inputChannelName in inputChannels) { + const inputChannel = inputChannels[inputChannelName]; + const originModuleInstanceId = inputChannel.getModuleInstanceId(); + const originModuleInstance = props.workbench.getModuleInstance(originModuleInstanceId); + if (!originModuleInstance) { + continue; + } + + const originElement = document.getElementById( + `moduleinstance-${originModuleInstanceId}-data-channel-origin` + ); + const destinationElement = document.getElementById( + `channel-connector-${moduleInstance.getId()}-${inputChannelName}` + ); + if (!originElement || !destinationElement) { + continue; + } + + const originRect = originElement.getBoundingClientRect(); + const destinationRect = destinationElement.getBoundingClientRect(); + + const originPoint: Point = { + x: originRect.left + originRect.width / 2, + y: originRect.top + originRect.height / 2, + }; + + const destinationPoint: Point = { + x: destinationRect.left + destinationRect.width / 2, + y: destinationRect.top < originPoint.y ? destinationRect.bottom + 20 : destinationRect.top - 20, + }; + + const midPoint1: Point = { + x: originPoint.x, + y: (originPoint.y + destinationPoint.y) / 2, + }; + + const midPoint2: Point = { + x: destinationPoint.x, + y: (originPoint.y + destinationPoint.y) / 2, + }; + + const descriptionCenterPoint: Point = { + x: (originPoint.x + destinationPoint.x) / 2, + y: (originPoint.y + destinationPoint.y) / 2, + }; + + const highlighted = + highlightedDataChannelConnection?.dataChannelName === inputChannelName && + highlightedDataChannelConnection?.moduleInstanceId === moduleInstance.getId(); + + dataChannelPaths.push({ + key: `${originModuleInstanceId}-${moduleInstance.getId()}-${inputChannelName}-${JSON.stringify( + boundingRect + )}`, + origin: originPoint, + midPoint1: midPoint1, + midPoint2: midPoint2, + destination: destinationPoint, + description: inputChannel.getDisplayName(), + descriptionCenterPoint: descriptionCenterPoint, + highlighted: highlighted, + }); + } + } + return dataChannelPaths; + } + + if (!visible && !showDataChannelConnections) { + return null; + } + + const dataChannelPaths = makeDataChannelPaths(); + + return ReactDOM.createPortal( + + + + + + + + + + + + + + + + {visible && ( + + {originPoint.x < currentPointerPosition.x ? ( + originPoint.y ? -20 : 20) + }`} + stroke="black" + fill="transparent" + className={resolveClassNames({ invisible: !visible })} + markerEnd="url(#arrowhead-right)" + /> + ) : ( + originPoint.y ? -20 : 20) + } C ${midPoint2.x} ${midPoint2.y} ${midPoint1.x} ${midPoint1.y} ${originPoint.x} ${ + originPoint.y + }`} + stroke="black" + fill="transparent" + className={resolveClassNames({ invisible: !visible })} + markerStart="url(#arrowhead-left)" + /> + )} + {currentChannelName && ( + + + {currentChannelName} + + + )} + + )} + {dataChannelPaths.map((dataChannelPath) => ( + + {dataChannelPath.origin.x < dataChannelPath.destination.x ? ( + + ) : ( + + )} + + + {dataChannelPath.description} + + + + ))} + , + document.body + ); +}; diff --git a/frontend/src/framework/internal/components/Content/private-components/DataChannelVisualizationLayer/index.ts b/frontend/src/framework/internal/components/Content/private-components/DataChannelVisualizationLayer/index.ts new file mode 100644 index 000000000..67bb8310e --- /dev/null +++ b/frontend/src/framework/internal/components/Content/private-components/DataChannelVisualizationLayer/index.ts @@ -0,0 +1 @@ +export { DataChannelVisualizationLayer } from "./dataChannelVisualizationLayer"; diff --git a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/channelSelector.tsx b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/channelSelector.tsx new file mode 100644 index 000000000..2aeeb50bf --- /dev/null +++ b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/channelSelector.tsx @@ -0,0 +1,73 @@ +import React from "react"; +import { createPortal } from "react-dom"; + +import { Overlay } from "@lib/components/Overlay"; +import { Point } from "@lib/utils/geometry"; +import { Close } from "@mui/icons-material"; + +export type ChannelSelectorProps = { + channelNames: string[]; + position: Point; + onSelectChannel: (channelName: string) => void; + onCancel: () => void; +}; + +export const ChannelSelector: React.FC = (props) => { + React.useEffect(() => { + const handleClickOutside = (e: PointerEvent) => { + const target = e.target as HTMLElement; + if (target.closest("#channel-selector-header")) { + e.preventDefault(); + e.stopPropagation(); + return; + } + + if (target.closest("#channel-selector")) { + return; + } + props.onCancel(); + }; + + document.addEventListener("pointerdown", handleClickOutside); + + return () => { + document.removeEventListener("pointerdown", handleClickOutside); + }; + }, [props.onCancel]); + + return createPortal( + <> + +
window.innerWidth / 2 ? window.innerWidth - props.position.x : undefined, + bottom: + props.position.y > window.innerHeight / 2 ? window.innerHeight - props.position.y : undefined, + }} + > +
+
Select a channel
+
+ +
+
+ {props.channelNames.map((channelName) => { + return ( +
props.onSelectChannel(channelName)} + > + {channelName} +
+ ); + })} +
+ , + document.body + ); +}; diff --git a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/header.tsx b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/header.tsx index 4c4960f2a..767060d31 100644 --- a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/header.tsx +++ b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/header.tsx @@ -1,6 +1,7 @@ import React from "react"; import ReactDOM from "react-dom"; +import { GuiEvent, GuiMessageBroker } from "@framework/GuiMessageBroker"; import { ModuleInstance } from "@framework/ModuleInstance"; import { StatusMessageType } from "@framework/ModuleInstanceStatusController"; import { SyncSettingKey, SyncSettingsMeta } from "@framework/SyncSettings"; @@ -10,16 +11,19 @@ import { CircularProgress } from "@lib/components/CircularProgress"; import { useElementBoundingRect } from "@lib/hooks/useElementBoundingRect"; import { isDevMode } from "@lib/utils/devMode"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; -import { Close, Error, Warning } from "@mui/icons-material"; +import { Close, Error, Input, Output, Warning } from "@mui/icons-material"; export type HeaderProps = { moduleInstance: ModuleInstance; isDragged: boolean; onPointerDown: (event: React.PointerEvent) => void; onRemoveClick: (event: React.PointerEvent) => void; + onInputChannelsClick: (event: React.PointerEvent) => void; + guiMessageBroker: GuiMessageBroker; }; export const Header: React.FC = (props) => { + const dataChannelOriginRef = React.useRef(null); const [syncedSettings, setSyncedSettings] = React.useState( props.moduleInstance.getSyncedSettingKeys() ); @@ -55,6 +59,26 @@ export const Header: React.FC = (props) => { setStatusMessagesVisible(false); } + function handleDataChannelOriginPointerDown(e: React.PointerEvent) { + if (!dataChannelOriginRef.current) { + return; + } + props.guiMessageBroker.publishEvent(GuiEvent.DataChannelOriginPointerDown, { + moduleInstanceId: props.moduleInstance.getId(), + originElement: dataChannelOriginRef.current, + }); + e.stopPropagation(); + e.preventDefault(); + } + + function handleInputChannelsPointerUp(e: React.PointerEvent) { + props.onInputChannelsClick(e); + } + + function handleInputChannelsPointerDown(e: React.PointerEvent) { + e.stopPropagation(); + } + function handlePointerUp(e: React.PointerEvent) { e.stopPropagation(); } @@ -116,9 +140,8 @@ export const Header: React.FC = (props) => { return (
- + {stateIndicators} -
); } @@ -187,8 +210,33 @@ export const Header: React.FC = (props) => { {makeStatusIndicator()} + {(props.moduleInstance.hasBroadcastChannels() || props.moduleInstance.getInputChannelDefs().length > 0) && ( + + )} + {props.moduleInstance.hasBroadcastChannels() && ( +
+ +
+ )} + {props.moduleInstance.getInputChannelDefs().length > 0 && ( +
+ +
+ )} +
void; + onChannelConnectionDisconnect: (inputName: string) => void; + workbench: Workbench; +}; + +export const InputChannelNode: React.FC = (props) => { + const ref = React.useRef(null); + const removeButtonRef = React.useRef(null); + const [connectable, setConnectable] = React.useState(false); + const [hovered, setHovered] = React.useState(false); + const [hasConnection, setHasConnection] = React.useState(false); + const [editDataChannelConnections, setEditDataChannelConnections] = React.useState(false); + + const guiMessageBroker = props.workbench.getGuiMessageBroker(); + + React.useEffect(() => { + let localHovered = false; + let localConnectable = false; + let localModuleInstanceId = ""; + let localEditDataChannelConnections = false; + + const moduleInstance = props.workbench.getModuleInstance(props.moduleInstanceId); + + function handleDataChannelOriginPointerDown(payload: GuiEventPayloads[GuiEvent.DataChannelOriginPointerDown]) { + localConnectable = false; + setConnectable(false); + localModuleInstanceId = ""; + + const originModuleInstance = props.workbench.getModuleInstance(payload.moduleInstanceId); + if (!originModuleInstance) { + return; + } + const dataChannels = originModuleInstance.getBroadcastChannels(); + const channelKeyCategories: BroadcastChannelKeyCategory[] = []; + for (const dataChannelName in dataChannels) { + channelKeyCategories.push(dataChannels[dataChannelName].getDataDef().key); + } + + if ( + props.channelKeyCategories && + !channelKeyCategories.some( + (channelKeyCategory) => + props.channelKeyCategories && props.channelKeyCategories.includes(channelKeyCategory) + ) + ) { + return; + } + + if (!moduleInstance) { + return; + } + + const alreadySetInputChannels = moduleInstance.getInputChannels(); + + const alreadySetInputKeys: BroadcastChannelKeyCategory[] = []; + + for (const channelName in alreadySetInputChannels) { + alreadySetInputKeys.push(alreadySetInputChannels[channelName].getDataDef().key); + } + + if ( + alreadySetInputKeys.length > 0 && + !alreadySetInputKeys.some((key) => channelKeyCategories.includes(key)) + ) { + return; + } + + setConnectable(true); + localConnectable = true; + localModuleInstanceId = payload.moduleInstanceId; + guiMessageBroker.publishEvent(GuiEvent.DataChannelConnectionsChange); + } + + function handlePointerUp(e: PointerEvent) { + if (localHovered) { + if (removeButtonRef.current && removeButtonRef.current.contains(e.target as Node)) { + props.onChannelConnectionDisconnect(props.inputName); + setHovered(false); + localHovered = false; + } else if (localConnectable) { + props.onChannelConnect(props.inputName, localModuleInstanceId, pointerEventToPoint(e)); + setHovered(false); + localHovered = false; + } else if (!localConnectable && !localEditDataChannelConnections) { + setHovered(false); + localHovered = false; + guiMessageBroker.publishEvent(GuiEvent.HideDataChannelConnectionsRequest); + } + } + guiMessageBroker.publishEvent(GuiEvent.DataChannelPointerUp); + e.stopPropagation(); + } + + function handleEditDataChannelConnectionsRequest() { + setEditDataChannelConnections(true); + localEditDataChannelConnections = true; + } + + function handleDataChannelDone() { + localConnectable = false; + setConnectable(false); + setHovered(false); + localHovered = false; + setEditDataChannelConnections(false); + localEditDataChannelConnections = false; + } + + function handlePointerMove(e: PointerEvent) { + const boundingRect = ref.current?.getBoundingClientRect(); + if (boundingRect && rectContainsPoint(boundingRect, pointerEventToPoint(e))) { + setHovered(true); + localHovered = true; + return; + } + if (localHovered) { + setHovered(false); + localHovered = false; + } + } + + function handleResize() { + guiMessageBroker.publishEvent(GuiEvent.DataChannelConnectionsChange); + } + + function checkIfConnection() { + const moduleInstance = props.workbench.getModuleInstance(props.moduleInstanceId); + if (!moduleInstance) { + return; + } + + const inputChannels = moduleInstance.getInputChannels(); + const hasConnection = props.inputName in inputChannels; + setHasConnection(hasConnection); + } + + const removeDataChannelOriginPointerDownHandler = guiMessageBroker.subscribeToEvent( + GuiEvent.DataChannelOriginPointerDown, + handleDataChannelOriginPointerDown + ); + const removeDataChannelDoneHandler = guiMessageBroker.subscribeToEvent( + GuiEvent.HideDataChannelConnectionsRequest, + handleDataChannelDone + ); + + const removeShowDataChannelConnectionsRequestHandler = guiMessageBroker.subscribeToEvent( + GuiEvent.EditDataChannelConnectionsForModuleInstanceRequest, + handleEditDataChannelConnectionsRequest + ); + + ref.current?.addEventListener("pointerup", handlePointerUp, true); + document.addEventListener("pointermove", handlePointerMove); + window.addEventListener("resize", handleResize); + + const resizeObserver = new ResizeObserver(handleResize); + + if (ref.current) { + handleResize(); + resizeObserver.observe(ref.current); + } + + const unsubscribeFunc = moduleInstance?.subscribeToInputChannelsChange(checkIfConnection); + + return () => { + removeDataChannelDoneHandler(); + removeDataChannelOriginPointerDownHandler(); + removeShowDataChannelConnectionsRequestHandler(); + + ref.current?.removeEventListener("pointerup", handlePointerUp); + document.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("resize", handleResize); + + resizeObserver.disconnect(); + + if (unsubscribeFunc) { + unsubscribeFunc(); + } + }; + }, [props.onChannelConnect, props.workbench, props.moduleInstanceId, props.inputName, props.channelKeyCategories]); + + function handlePointerEnter() { + guiMessageBroker.publishEvent(GuiEvent.HighlightDataChannelConnectionRequest, { + moduleInstanceId: props.moduleInstanceId, + dataChannelName: props.inputName, + }); + + guiMessageBroker.publishEvent(GuiEvent.DataChannelNodeHover, { + connectionAllowed: connectable, + }); + } + + function handlePointerLeave() { + guiMessageBroker.publishEvent(GuiEvent.UnhighlightDataChannelConnectionRequest); + guiMessageBroker.publishEvent(GuiEvent.DataChannelNodeUnhover); + } + + return ( +
+ {props.displayName} + + + +
+ ); +}; diff --git a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/inputChannelNodeWrapper.tsx b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/inputChannelNodeWrapper.tsx new file mode 100644 index 000000000..6d94aa081 --- /dev/null +++ b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/inputChannelNodeWrapper.tsx @@ -0,0 +1,200 @@ +import React from "react"; +import { createPortal } from "react-dom"; + +import { GuiEvent, GuiEventPayloads } from "@framework/GuiMessageBroker"; +import { ModuleInstance } from "@framework/ModuleInstance"; +import { Workbench } from "@framework/Workbench"; +import { useElementBoundingRect } from "@lib/hooks/useElementBoundingRect"; +import { Point } from "@lib/utils/geometry"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; + +import { ChannelSelector } from "./channelSelector"; +import { InputChannelNode } from "./inputChannelNode"; + +export type InputChannelNodesProps = { + forwardedRef: React.RefObject; + moduleInstance: ModuleInstance; + workbench: Workbench; +}; + +export const InputChannelNodes: React.FC = (props) => { + const [visible, setVisible] = React.useState(false); + const [currentInputName, setCurrentInputName] = React.useState(null); + const [channelSelectorCenterPoint, setChannelSelectorCenterPoint] = React.useState(null); + const [selectableChannels, setSelectableChannels] = React.useState([]); + + const elementRect = useElementBoundingRect(props.forwardedRef); + + const guiMessageBroker = props.workbench.getGuiMessageBroker(); + + React.useEffect(() => { + let localVisible = false; + + function handleDataChannelOriginPointerDown() { + setVisible(true); + localVisible = true; + } + + function handleDataChannelDone() { + setVisible(false); + localVisible = false; + } + + function handlePointerUp(e: PointerEvent) { + if (!localVisible) { + return; + } + if ( + (!e.target || !(e.target as Element).hasAttribute("data-channelconnector")) && + !(e.target as Element).closest("#channel-selector-header") + ) { + guiMessageBroker.publishEvent(GuiEvent.HideDataChannelConnectionsRequest); + setVisible(false); + } + e.stopPropagation(); + } + + function handleEditDataChannelConnectionsRequest( + payload: GuiEventPayloads[GuiEvent.EditDataChannelConnectionsForModuleInstanceRequest] + ) { + if (payload.moduleInstanceId !== props.moduleInstance.getId()) { + return; + } + setVisible(true); + localVisible = true; + } + + const removeEditDataChannelConnectionsRequestHandler = guiMessageBroker.subscribeToEvent( + GuiEvent.EditDataChannelConnectionsForModuleInstanceRequest, + handleEditDataChannelConnectionsRequest + ); + + const removeDataChannelOriginPointerDownHandler = guiMessageBroker.subscribeToEvent( + GuiEvent.DataChannelOriginPointerDown, + handleDataChannelOriginPointerDown + ); + + const removeDataChannelDoneHandler = guiMessageBroker.subscribeToEvent( + GuiEvent.HideDataChannelConnectionsRequest, + handleDataChannelDone + ); + + document.addEventListener("pointerup", handlePointerUp); + + return () => { + removeEditDataChannelConnectionsRequestHandler(); + removeDataChannelDoneHandler(); + removeDataChannelOriginPointerDownHandler(); + document.removeEventListener("pointerup", handlePointerUp); + }; + }, [props.moduleInstance, guiMessageBroker]); + + const handleChannelConnect = React.useCallback( + function handleChannelConnect(inputName: string, moduleInstanceId: string, destinationPoint: Point) { + const originModuleInstance = props.workbench.getModuleInstance(moduleInstanceId); + + if (!originModuleInstance) { + guiMessageBroker.publishEvent(GuiEvent.HideDataChannelConnectionsRequest); + return; + } + + const acceptedKeys = props.moduleInstance + .getInputChannelDefs() + .find((channelDef) => channelDef.name === inputName)?.keyCategories; + + const channels = Object.values(originModuleInstance.getBroadcastChannels()).filter((channel) => { + if (!acceptedKeys || acceptedKeys.some((key) => channel.getDataDef().key === key)) { + return Object.values(props.moduleInstance.getInputChannels()).every((inputChannel) => { + if (inputChannel.getDataDef().key === channel.getDataDef().key) { + return true; + } + return false; + }); + } + return false; + }); + + if (channels.length === 0) { + guiMessageBroker.publishEvent(GuiEvent.HideDataChannelConnectionsRequest); + return; + } + + if (channels.length > 1) { + setChannelSelectorCenterPoint(destinationPoint); + setSelectableChannels(Object.values(channels).map((channel) => channel.getName())); + setCurrentInputName(inputName); + return; + } + + const channelName = Object.values(channels)[0].getName(); + + props.moduleInstance.setInputChannel(inputName, channelName); + guiMessageBroker.publishEvent(GuiEvent.HideDataChannelConnectionsRequest); + }, + [props.moduleInstance, props.workbench] + ); + + const handleChannelDisconnect = React.useCallback( + function handleChannelDisconnect(inputName: string) { + props.moduleInstance.removeInputChannel(inputName); + guiMessageBroker.publishEvent(GuiEvent.DataChannelConnectionsChange); + }, + [props.moduleInstance] + ); + + function handleCancelChannelSelection() { + setChannelSelectorCenterPoint(null); + setSelectableChannels([]); + guiMessageBroker.publishEvent(GuiEvent.HideDataChannelConnectionsRequest); + } + + function handleChannelSelection(channelName: string) { + guiMessageBroker.publishEvent(GuiEvent.HideDataChannelConnectionsRequest); + + if (!currentInputName) { + return; + } + setChannelSelectorCenterPoint(null); + setSelectableChannels([]); + + props.moduleInstance.setInputChannel(currentInputName, channelName); + } + + return createPortal( +
+ {props.moduleInstance.getInputChannelDefs().map((channelDef) => { + return ( + + ); + })} + {channelSelectorCenterPoint && ( + + )} +
, + document.body + ); +}; diff --git a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/viewWrapper.tsx b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/viewWrapper.tsx index 542caabcc..b3f0f40b2 100644 --- a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/viewWrapper.tsx +++ b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/viewWrapper.tsx @@ -1,11 +1,12 @@ import React from "react"; -import { DrawerContent, GuiEvent, GuiState, useGuiState } from "@framework/GuiMessageBroker"; +import { DrawerContent, GuiEvent, GuiState, useGuiState, useGuiValue } from "@framework/GuiMessageBroker"; import { ModuleInstance } from "@framework/ModuleInstance"; import { Workbench } from "@framework/Workbench"; import { Point, pointDifference, pointRelativeToDomRect, pointerEventToPoint } from "@lib/utils/geometry"; import { Header } from "./private-components/header"; +import { InputChannelNodes } from "./private-components/inputChannelNodeWrapper"; import { ViewContent } from "./private-components/viewContent"; import { ViewWrapperPlaceholder } from "../viewWrapperPlaceholder"; @@ -35,6 +36,11 @@ export const ViewWrapper: React.FC = (props) => { const guiMessageBroker = props.workbench.getGuiMessageBroker(); + const dataChannelConnectionsLayerVisible = useGuiValue( + guiMessageBroker, + GuiState.DataChannelConnectionLayerVisible + ); + const timeRef = React.useRef(null); const handleHeaderPointerDown = React.useCallback( @@ -64,6 +70,9 @@ export const ViewWrapper: React.FC = (props) => { ); function handleModuleClick() { + if (dataChannelConnectionsLayerVisible) { + return; + } if (settingsPanelWidth <= 5) { setSettingsPanelWidth(20); } @@ -88,6 +97,13 @@ export const ViewWrapper: React.FC = (props) => { handleModuleClick(); } + function handleInputChannelsClick(e: React.PointerEvent): void { + guiMessageBroker.publishEvent(GuiEvent.EditDataChannelConnectionsForModuleInstanceRequest, { + moduleInstanceId: props.moduleInstance.getId(), + }); + e.stopPropagation(); + } + const showAsActive = props.isActive && [DrawerContent.ModuleSettings, DrawerContent.SyncSettings].includes(drawerContent); @@ -122,9 +138,16 @@ export const ViewWrapper: React.FC = (props) => { isDragged={props.isDragged} onPointerDown={handleHeaderPointerDown} onRemoveClick={handleRemoveClick} + onInputChannelsClick={handleInputChannelsClick} + guiMessageBroker={guiMessageBroker} />
+
diff --git a/frontend/src/lib/components/Slider/slider.tsx b/frontend/src/lib/components/Slider/slider.tsx index 6e9d48b46..51ad7cb2c 100644 --- a/frontend/src/lib/components/Slider/slider.tsx +++ b/frontend/src/lib/components/Slider/slider.tsx @@ -221,7 +221,7 @@ export const Slider = React.forwardRef((props: SliderProps, ref: React.Forwarded "h-5", "block", "bg-blue-600", - "z-50", + "z-30", "shadow-sm", "rounded-full", "transform", diff --git a/frontend/src/lib/hooks/useElementBoundingRect.ts b/frontend/src/lib/hooks/useElementBoundingRect.ts index 916e13b60..6ff7ec3de 100644 --- a/frontend/src/lib/hooks/useElementBoundingRect.ts +++ b/frontend/src/lib/hooks/useElementBoundingRect.ts @@ -1,9 +1,9 @@ import React from "react"; -export function useElementBoundingRect(ref: React.RefObject): DOMRect { +export function useElementBoundingRect(ref: React.RefObject): DOMRect { const [rect, setRect] = React.useState(new DOMRect(0, 0, 0, 0)); - React.useLayoutEffect(() => { + React.useEffect(() => { const handleResize = (): void => { if (ref.current) { const boundingClientRect = ref.current.getBoundingClientRect(); @@ -12,16 +12,21 @@ export function useElementBoundingRect(ref: React.RefObject): DOMRe }; const resizeObserver = new ResizeObserver(handleResize); + const mutationObserver = new MutationObserver(handleResize); window.addEventListener("resize", handleResize); window.addEventListener("scroll", handleResize, true); if (ref.current) { handleResize(); resizeObserver.observe(ref.current); + if (ref.current.parentElement) { + mutationObserver.observe(ref.current.parentElement, { childList: true, subtree: true }); + } } return () => { resizeObserver.disconnect(); + mutationObserver.disconnect(); window.removeEventListener("resize", handleResize); window.removeEventListener("scroll", handleResize, true); }; diff --git a/frontend/src/modules/DistributionPlot/loadModule.tsx b/frontend/src/modules/DistributionPlot/loadModule.tsx index d514063de..1ac13c883 100644 --- a/frontend/src/modules/DistributionPlot/loadModule.tsx +++ b/frontend/src/modules/DistributionPlot/loadModule.tsx @@ -1,14 +1,11 @@ import { ModuleRegistry } from "@framework/ModuleRegistry"; import { settings } from "./settings"; -import { State } from "./state"; +import { PlotType, State } from "./state"; import { view } from "./view"; const defaultState: State = { - channelNameX: null, - channelNameY: null, - channelNameZ: null, - plotType: null, + plotType: PlotType.Histogram, numBins: 10, orientation: "h", }; diff --git a/frontend/src/modules/DistributionPlot/registerModule.ts b/frontend/src/modules/DistributionPlot/registerModule.ts index 0f115f500..495f7a028 100644 --- a/frontend/src/modules/DistributionPlot/registerModule.ts +++ b/frontend/src/modules/DistributionPlot/registerModule.ts @@ -1,12 +1,32 @@ +import { BroadcastChannelKeyCategory, InputBroadcastChannelDef } from "@framework/Broadcaster"; import { ModuleRegistry } from "@framework/ModuleRegistry"; import { SyncSettingKey } from "@framework/SyncSettings"; import { preview } from "./preview"; import { State } from "./state"; +const inputChannelDefs: InputBroadcastChannelDef[] = [ + { + name: "channelX", + displayName: "X axis", + keyCategories: [BroadcastChannelKeyCategory.Realization], + }, + { + name: "channelY", + displayName: "Y axis", + keyCategories: [BroadcastChannelKeyCategory.Realization], + }, + { + name: "channelColor", + displayName: "Color mapping", + keyCategories: [BroadcastChannelKeyCategory.Realization], + }, +]; + ModuleRegistry.registerModule({ moduleName: "DistributionPlot", defaultTitle: "Distribution plot", syncableSettingKeys: [SyncSettingKey.ENSEMBLE, SyncSettingKey.TIME_SERIES], - preview + inputChannelDefs, + preview, }); diff --git a/frontend/src/modules/DistributionPlot/settings.tsx b/frontend/src/modules/DistributionPlot/settings.tsx index 9455813e0..a365f74ae 100644 --- a/frontend/src/modules/DistributionPlot/settings.tsx +++ b/frontend/src/modules/DistributionPlot/settings.tsx @@ -55,33 +55,38 @@ const crossPlottingTypes = [ //----------------------------------------------------------------------------------------------------------- export function settings({ moduleContext, workbenchServices, initialSettings }: ModuleFCProps) { - const [channelNameX, setChannelNameX] = moduleContext.useStoreState("channelNameX"); - const [channelNameY, setChannelNameY] = moduleContext.useStoreState("channelNameY"); - const [channelNameZ, setChannelNameZ] = moduleContext.useStoreState("channelNameZ"); + const [prevChannelXName, setPrevChannelXName] = React.useState(null); + const [prevChannelYName, setPrevChannelYName] = React.useState(null); + const [prevChannelColorName, setPrevChannelColorName] = React.useState(null); + const [plotType, setPlotType] = moduleContext.useStoreState("plotType"); const [numBins, setNumBins] = moduleContext.useStoreState("numBins"); const [orientation, setOrientation] = moduleContext.useStoreState("orientation"); const [crossPlottingType, setCrossPlottingType] = React.useState(null); - applyInitialSettingsToState(initialSettings, "channelNameX", "string", setChannelNameX); - applyInitialSettingsToState(initialSettings, "channelNameY", "string", setChannelNameY); - applyInitialSettingsToState(initialSettings, "channelNameZ", "string", setChannelNameZ); applyInitialSettingsToState(initialSettings, "plotType", "string", setPlotType); applyInitialSettingsToState(initialSettings, "numBins", "number", setNumBins); applyInitialSettingsToState(initialSettings, "orientation", "string", setOrientation); applyInitialSettingsToState(initialSettings, "crossPlottingType", "string", setCrossPlottingType); - const handleChannelXChanged = (channelName: string) => { - setChannelNameX(channelName); - }; + const channelX = moduleContext.useInputChannel("channelX", initialSettings); + const channelY = moduleContext.useInputChannel("channelY", initialSettings); + const channelColor = moduleContext.useInputChannel("channelColor", initialSettings); - const handleChannelYChanged = (channelName: string) => { - setChannelNameY(channelName); - }; + if (channelX && channelX.getName() !== prevChannelXName && crossPlottingType === null) { + setPrevChannelXName(channelX?.getName() ?? null); + setCrossPlottingType(channelX?.getDataDef().key ?? null); + } - const handleChannelZChanged = (channelName: string) => { - setChannelNameZ(channelName); - }; + if (channelY && channelY?.getName() !== prevChannelYName && crossPlottingType === null) { + setPrevChannelYName(channelY?.getName() ?? null); + setCrossPlottingType(channelY?.getDataDef().key ?? null); + } + + if (channelColor && channelColor?.getName() !== prevChannelColorName && crossPlottingType === null) { + setPrevChannelColorName(channelColor?.getName() ?? null); + setCrossPlottingType(channelColor?.getDataDef().key ?? null); + } const handlePlotTypeChanged = (value: string) => { setPlotType(value as PlotType); @@ -111,9 +116,8 @@ export function settings({ moduleContext, workbenchServices, initialSettings }: content.push( @@ -123,9 +127,8 @@ export function settings({ moduleContext, workbenchServices, initialSettings }: content.push( @@ -136,9 +139,8 @@ export function settings({ moduleContext, workbenchServices, initialSettings }: content.push( diff --git a/frontend/src/modules/DistributionPlot/state.ts b/frontend/src/modules/DistributionPlot/state.ts index 8c4b83004..5f108da5d 100644 --- a/frontend/src/modules/DistributionPlot/state.ts +++ b/frontend/src/modules/DistributionPlot/state.ts @@ -6,9 +6,6 @@ export enum PlotType { } export interface State { - channelNameX: string | null; - channelNameY: string | null; - channelNameZ: string | null; plotType: PlotType | null; numBins: number; orientation: "v" | "h"; diff --git a/frontend/src/modules/DistributionPlot/view.tsx b/frontend/src/modules/DistributionPlot/view.tsx index aaf1f8f8d..ea1f6fce1 100644 --- a/frontend/src/modules/DistributionPlot/view.tsx +++ b/frontend/src/modules/DistributionPlot/view.tsx @@ -32,25 +32,27 @@ function nFormatter(num: number, digits: number): string { return item ? (num / item.value).toFixed(digits).replace(rx, "$1") + item.symbol : "0"; } -export const view = ({ moduleContext, workbenchServices, workbenchSettings }: ModuleFCProps) => { - const plotType = moduleContext.useStoreValue("plotType"); - const channelNameX = moduleContext.useStoreValue("channelNameX"); - const channelNameY = moduleContext.useStoreValue("channelNameY"); - const channelNameZ = moduleContext.useStoreValue("channelNameZ"); +export const view = ({ + moduleContext, + workbenchServices, + initialSettings, + workbenchSettings, +}: ModuleFCProps) => { + const [plotType, setPlotType] = moduleContext.useStoreState("plotType"); const numBins = moduleContext.useStoreValue("numBins"); const orientation = moduleContext.useStoreValue("orientation"); const [highlightedKey, setHighlightedKey] = React.useState(null); const [dataX, setDataX] = React.useState(null); const [dataY, setDataY] = React.useState(null); - const [dataZ, setDataZ] = React.useState(null); + const [dataColor, setDataColor] = React.useState(null); const [metaDataX, setMetaDataX] = React.useState(null); const [metaDataY, setMetaDataY] = React.useState(null); - const [metaDataZ, setMetaDataZ] = React.useState(null); + const [metaDataColor, setMetaDataColor] = React.useState(null); - const channelX = workbenchServices.getBroadcaster().getChannel(channelNameX ?? ""); - const channelY = workbenchServices.getBroadcaster().getChannel(channelNameY ?? ""); - const channelZ = workbenchServices.getBroadcaster().getChannel(channelNameZ ?? ""); + const channelX = moduleContext.useInputChannel("channelX", initialSettings); + const channelY = moduleContext.useInputChannel("channelY", initialSettings); + const channelColor = moduleContext.useInputChannel("channelColor", initialSettings); const colorSet = workbenchSettings.useColorSet(); const seqColorScale = workbenchSettings.useContinuousColorScale({ @@ -67,7 +69,7 @@ export const view = ({ moduleContext, workbenchServices, workbenchSettings }: Mo return; } - const handleChannelXChanged = (data: any, metaData: BroadcastChannelMeta) => { + const handleChannelXChanged = (data: any | null, metaData: BroadcastChannelMeta | null) => { setDataX(data); setMetaDataX(metaData); }; @@ -84,7 +86,11 @@ export const view = ({ moduleContext, workbenchServices, workbenchSettings }: Mo return; } - const handleChannelYChanged = (data: any, metaData: BroadcastChannelMeta) => { + if (plotType !== PlotType.ScatterWithColorMapping && plotType !== PlotType.Scatter) { + setPlotType(PlotType.Scatter); + } + + const handleChannelYChanged = (data: any | null, metaData: BroadcastChannelMeta | null) => { setDataY(data); setMetaDataY(metaData); }; @@ -95,21 +101,25 @@ export const view = ({ moduleContext, workbenchServices, workbenchSettings }: Mo }, [channelY]); React.useEffect(() => { - if (!channelZ) { - setDataZ(null); - setMetaDataZ(null); + if (!channelColor) { + setDataColor(null); + setMetaDataColor(null); return; } - const handleChannelZChanged = (data: any, metaData: BroadcastChannelMeta) => { - setDataZ(data); - setMetaDataZ(metaData); + if (plotType !== PlotType.ScatterWithColorMapping) { + setPlotType(PlotType.ScatterWithColorMapping); + } + + const handleChannelColorChanged = (data: any | null, metaData: BroadcastChannelMeta | null) => { + setDataColor(data); + setMetaDataColor(metaData); }; - const unsubscribeFunc = channelZ.subscribe(handleChannelZChanged); + const unsubscribeFunc = channelColor.subscribe(handleChannelColorChanged); return unsubscribeFunc; - }, [channelZ]); + }, [channelColor]); React.useEffect(() => { if (channelX?.getDataDef().key === BroadcastChannelKeyCategory.Realization) { @@ -133,13 +143,13 @@ export const view = ({ moduleContext, workbenchServices, workbenchSettings }: Mo } if (plotType === PlotType.Histogram) { - if (channelNameX === "" || channelNameX === null) { + if (!channelX) { return "Please select a channel for the x-axis."; } if (dataX === null) { return ( <> - No data on channel yet. + No data on channel yet. ); } @@ -174,13 +184,13 @@ export const view = ({ moduleContext, workbenchServices, workbenchSettings }: Mo } if (plotType === PlotType.BarChart) { - if (channelNameX === "" || channelNameX === null) { + if (!channelX) { return "Please select a channel for the x-axis."; } if (dataX === null) { return ( <> - No data on channel yet. + No data on channel yet. ); } @@ -210,25 +220,25 @@ export const view = ({ moduleContext, workbenchServices, workbenchSettings }: Mo } if (plotType === PlotType.Scatter) { - if (channelNameX === "" || channelNameX === null) { + if (!channelX) { return "Please select a channel for the x-axis."; } - if (channelNameY === "" || channelNameY === null) { + if (!channelY) { return "Please select a channel for the y-axis."; } if (dataX === null) { return ( <> - No data on channel yet. + No data on channel yet. ); } if (dataY === null) { return ( <> - No data on channel yet. + No data on channel yet. ); } @@ -264,36 +274,36 @@ export const view = ({ moduleContext, workbenchServices, workbenchSettings }: Mo } if (plotType === PlotType.ScatterWithColorMapping) { - if (channelNameX === "" || channelNameX === null) { + if (!channelX) { return "Please select a channel for the x-axis."; } - if (channelNameY === "" || channelNameY === null) { + if (!channelY) { return "Please select a channel for the y-axis."; } - if (channelNameZ === "" || channelNameZ === null) { - return "Please select a channel for the z-axis."; + if (!channelColor) { + return "Please select a channel for the color mapping."; } if (dataX === null) { return ( <> - No data on channel yet. + No data on channel yet. ); } if (dataY === null) { return ( <> - No data on channel yet. + No data on channel yet. ); } - if (dataZ === null) { + if (dataColor === null) { return ( <> - No data on channel yet. + No data on channel yet. ); } @@ -304,7 +314,7 @@ export const view = ({ moduleContext, workbenchServices, workbenchSettings }: Mo const keysX = dataX.map((el: any) => el.key); const keysY = dataY.map((el: any) => el.key); - const keysZ = dataZ.map((el: any) => el.key); + const keysZ = dataColor.map((el: any) => el.key); if ( keysX.length === keysY.length && keysY.length === keysZ.length && @@ -314,7 +324,7 @@ export const view = ({ moduleContext, workbenchServices, workbenchSettings }: Mo keysX.forEach((key) => { const dataPointX = dataX.find((el: any) => el.key === key); const dataPointY = dataY.find((el: any) => el.key === key); - const dataPointZ = dataZ.find((el: any) => el.key === key); + const dataPointZ = dataColor.find((el: any) => el.key === key); xValues.push(dataPointX.value); yValues.push(dataPointY.value); zValues.push(dataPointZ.value); @@ -331,7 +341,7 @@ export const view = ({ moduleContext, workbenchServices, workbenchSettings }: Mo highlightedKey={highlightedKey ?? undefined} xAxisTitle={`${metaDataX?.description ?? ""} [${metaDataX?.unit ?? ""}]`} yAxisTitle={`${metaDataY?.description ?? ""} [${metaDataY?.unit ?? ""}]`} - zAxisTitle={`${metaDataZ?.description ?? ""} [${metaDataZ?.unit ?? ""}]`} + zAxisTitle={`${metaDataColor?.description ?? ""} [${metaDataColor?.unit ?? ""}]`} width={wrapperDivSize.width} onHoverData={handleHoverChanged} height={wrapperDivSize.height} @@ -339,6 +349,63 @@ export const view = ({ moduleContext, workbenchServices, workbenchSettings }: Mo /> ); } + + if (dataX) { + if (plotType === PlotType.Histogram) { + const xValues = dataX.map((el: any) => el.value); + const xMin = Math.min(...xValues); + const xMax = Math.max(...xValues); + const binSize = (xMax - xMin) / numBins; + const bins: { from: number; to: number }[] = Array.from({ length: numBins }, (_, i) => ({ + from: xMin + i * binSize, + to: xMin + (i + 1) * binSize, + })); + bins[bins.length - 1].to = xMax + 1e-6; // make sure the last bin includes the max value + const binValues: number[] = bins.map( + (range) => xValues.filter((el) => el >= range.from && el < range.to).length + ); + + const binStrings = bins.map((range) => `${nFormatter(range.from, 2)}-${nFormatter(range.to, 2)}`); + + return ( + + ); + } + + if (plotType === PlotType.BarChart) { + const keyData = dataX.map((el: any) => el.key); + const valueData = dataX.map((el: any) => el.value); + + const keyTitle = channelX?.getDataDef().key ?? ""; + const valueTitle = `${metaDataX?.description ?? ""} [${metaDataX?.unit ?? ""}]`; + + return ( + + ); + } + } } return ( diff --git a/frontend/src/modules/MyModule/registerModule.ts b/frontend/src/modules/MyModule/registerModule.ts index a55be4116..f013c4d8e 100644 --- a/frontend/src/modules/MyModule/registerModule.ts +++ b/frontend/src/modules/MyModule/registerModule.ts @@ -7,3 +7,5 @@ ModuleRegistry.registerModule({ defaultTitle: "My Module", description: "My module description", }); + +ModuleRegistry.registerModule({ moduleName: "MyModule", defaultTitle: "My Module" }); diff --git a/frontend/src/modules/TornadoChart/registerModule.tsx b/frontend/src/modules/TornadoChart/registerModule.ts similarity index 55% rename from frontend/src/modules/TornadoChart/registerModule.tsx rename to frontend/src/modules/TornadoChart/registerModule.ts index 477d7a1c6..c036d3205 100644 --- a/frontend/src/modules/TornadoChart/registerModule.tsx +++ b/frontend/src/modules/TornadoChart/registerModule.ts @@ -1,12 +1,22 @@ +import { BroadcastChannelKeyCategory, InputBroadcastChannelDef } from "@framework/Broadcaster"; import { ModuleRegistry } from "@framework/ModuleRegistry"; import { SyncSettingKey } from "@framework/SyncSettings"; import { preview } from "./preview"; import { State } from "./state"; +const inputChannelDefs: InputBroadcastChannelDef[] = [ + { + name: "response", + displayName: "Response", + keyCategories: [BroadcastChannelKeyCategory.Realization], + }, +]; + ModuleRegistry.registerModule({ moduleName: "TornadoChart", defaultTitle: "Tornado Chart", syncableSettingKeys: [SyncSettingKey.ENSEMBLE, SyncSettingKey.TIME_SERIES], + inputChannelDefs, preview, }); diff --git a/frontend/src/modules/TornadoChart/settings.tsx b/frontend/src/modules/TornadoChart/settings.tsx index 0e69b5357..6a03f10c0 100644 --- a/frontend/src/modules/TornadoChart/settings.tsx +++ b/frontend/src/modules/TornadoChart/settings.tsx @@ -1,27 +1,16 @@ -import { BroadcastChannelKeyCategory } from "@framework/Broadcaster"; -import { applyInitialSettingsToState } from "@framework/InitialSettings"; import { ModuleFCProps } from "@framework/Module"; import { ChannelSelect } from "@framework/components/ChannelSelect"; import { Label } from "@lib/components/Label"; import { State } from "./state"; -export function settings({ moduleContext, workbenchServices, initialSettings }: ModuleFCProps) { - const [responseChannelName, setResponseChannelName] = moduleContext.useStoreState("responseChannelName"); - - applyInitialSettingsToState(initialSettings, "responseChannelName", "string", setResponseChannelName); - - function handleResponseChannelNameChange(channelName: string) { - setResponseChannelName(channelName); - } - +export function settings({ moduleContext, workbenchServices }: ModuleFCProps) { return ( <> diff --git a/frontend/src/modules/TornadoChart/state.ts b/frontend/src/modules/TornadoChart/state.ts index f4b11ec2c..e5a2de069 100644 --- a/frontend/src/modules/TornadoChart/state.ts +++ b/frontend/src/modules/TornadoChart/state.ts @@ -7,6 +7,7 @@ export type SelectedSensitivity = { selectedSensitivity: string; selectedSensitivityCase: string | null; }; + export interface State { plotType: PlotType; selectedSensitivity: SelectedSensitivity | null; diff --git a/frontend/src/modules/TornadoChart/view.tsx b/frontend/src/modules/TornadoChart/view.tsx index b73eafb03..c43b47c88 100644 --- a/frontend/src/modules/TornadoChart/view.tsx +++ b/frontend/src/modules/TornadoChart/view.tsx @@ -18,12 +18,7 @@ import { PlotType, State } from "./state"; import { createSensitivityColorMap } from "../_shared/sensitivityColors"; -export const view = ({ - moduleContext, - workbenchSession, - workbenchSettings, - workbenchServices, -}: ModuleFCProps) => { +export const view = ({ moduleContext, workbenchSession, workbenchSettings, initialSettings }: ModuleFCProps) => { // Leave this in until we get a feeling for React18/Plotly const renderCount = React.useRef(0); React.useEffect(function incrementRenderCount() { @@ -34,10 +29,11 @@ export const view = ({ const wrapperDivSize = useElementSize(wrapperDivRef); const ensembleSet = useEnsembleSet(workbenchSession); const [plotType, setPlotType] = moduleContext.useStoreState("plotType"); - const responseChannelName = moduleContext.useStoreValue("responseChannelName"); const [channelEnsemble, setChannelEnsemble] = React.useState(null); const [channelResponseData, setChannelResponseData] = React.useState(null); + const responseChannel = moduleContext.useInputChannel("response", initialSettings); + const [showLabels, setShowLabels] = React.useState(true); const [hideZeroY, setHideZeroY] = React.useState(false); const [showRealizationPoints, setShowRealizationPoints] = React.useState(false); @@ -54,7 +50,6 @@ export const view = ({ } }; - const responseChannel = workbenchServices.getBroadcaster().getChannel(responseChannelName ?? ""); React.useEffect(() => { if (!responseChannel) { setChannelEnsemble(null); @@ -62,7 +57,13 @@ export const view = ({ return; } - function handleChannelDataChanged(data: BroadcastChannelData[], metaData: BroadcastChannelMeta) { + function handleChannelDataChanged(data: BroadcastChannelData[] | null, metaData: BroadcastChannelMeta | null) { + if (!data || !metaData) { + setChannelEnsemble(null); + setChannelResponseData(null); + return; + } + if (data.length === 0) { setChannelEnsemble(null); setChannelResponseData(null); diff --git a/frontend/src/templates/oatTimeSeries.ts b/frontend/src/templates/oatTimeSeries.ts index bea629a22..7702e8888 100644 --- a/frontend/src/templates/oatTimeSeries.ts +++ b/frontend/src/templates/oatTimeSeries.ts @@ -30,7 +30,7 @@ const template: Template = { }, syncedSettings: [SyncSettingKey.ENSEMBLE], dataChannelsToInitialSettingsMapping: { - responseChannelName: { + response: { listensToInstanceRef: "MainTimeSeriesSensitivityInstance", keyCategory: BroadcastChannelKeyCategory.Realization, channelName: BroadcastChannelNames.Realization_Value, @@ -48,7 +48,7 @@ const template: Template = { }, syncedSettings: [SyncSettingKey.ENSEMBLE], dataChannelsToInitialSettingsMapping: { - channelNameX: { + channelX: { listensToInstanceRef: "MainTimeSeriesSensitivityInstance", keyCategory: BroadcastChannelKeyCategory.Realization, channelName: BroadcastChannelNames.Realization_Value,