diff --git a/libs/shared/lib/data-access/api/eventBus.tsx b/libs/shared/lib/data-access/api/eventBus.tsx index f49d266e0dca4a1f25fda85e6567e6cb97b78927..01c72fb6aafc19fc0b7e0b355905bf98b6e6ce0d 100644 --- a/libs/shared/lib/data-access/api/eventBus.tsx +++ b/libs/shared/lib/data-access/api/eventBus.tsx @@ -50,6 +50,7 @@ import { setVisualizationState } from '../store/visualizationSlice'; import { isEqual } from 'lodash-es'; import { addSchemaAttributeDimensions } from '../store/schemaSlice'; import { addError } from '@graphpolaris/shared/lib/data-access/store/configSlice'; +import { unSelect } from '../store/interactionSlice'; export const EventBus = (props: { onRunQuery: Function; onAuthorized: Function }) => { const { login } = useAuth(); @@ -88,6 +89,7 @@ export const EventBus = (props: { onRunQuery: Function; onAuthorized: Function } wsQuerySubscription((data) => { dispatch(setNewGraphQueryResult(data)); dispatch(addInfo('Query Executed!')); + dispatch(unSelect()); }), ); diff --git a/libs/shared/lib/data-access/store/graphQueryResultSlice.ts b/libs/shared/lib/data-access/store/graphQueryResultSlice.ts index ada9c3028bf4de258e49b00ff1f75260b3d026b6..48968c579985a1812012773a260111f91961f944 100755 --- a/libs/shared/lib/data-access/store/graphQueryResultSlice.ts +++ b/libs/shared/lib/data-access/store/graphQueryResultSlice.ts @@ -31,29 +31,29 @@ export interface GraphQueryResultFromBackend { // TODO: The backend should send all the different entitytypes and relationtypes in the result } -export interface Node { +export type Node = { _id: string; label: string; attributes: NodeAttributes; mldata?: any; // FIXME /* type: string[]; */ -} -export interface Edge { +}; +export type Edge = { attributes: NodeAttributes; from: string; to: string; _id: string; label: string; /* type: string; */ -} +}; // Define a type for the slice state -export interface GraphQueryResult { +export type GraphQueryResult = { metaData: GraphMetadata; nodes: Node[]; edges: Edge[]; queryingBackend: boolean; -} +}; // Define the initial state using that type export const initialState: GraphQueryResult = { diff --git a/libs/shared/lib/data-access/store/hooks.ts b/libs/shared/lib/data-access/store/hooks.ts index 729be1655e70ab958b3dd12fbbb785986c4265c9..247785872841be2af1eea649f6fbb9bb353b1e65 100644 --- a/libs/shared/lib/data-access/store/hooks.ts +++ b/libs/shared/lib/data-access/store/hooks.ts @@ -29,6 +29,7 @@ import { AllLayoutAlgorithms } from '../../graph-layout'; import { QueryGraphEdgeHandle, QueryMultiGraph } from '../../querybuilder'; import { SchemaGraph } from '../../schema'; import { GraphMetadata } from '../statistics'; +import { SelectionStateI, selectionState } from './interactionSlice'; // Use throughout your app instead of plain `useDispatch` and `useSelector` export const useAppDispatch: () => AppDispatch = useDispatch; @@ -69,3 +70,6 @@ export const useRecentSearches: () => string[] = () => useAppSelector(recentSear // Visualization Slices export const useVisualization: () => VisState = () => useAppSelector(visualizationState); + +// Interaction Slices +export const useSelection: () => SelectionStateI | undefined = () => useAppSelector(selectionState); diff --git a/libs/shared/lib/data-access/store/interactionSlice.ts b/libs/shared/lib/data-access/store/interactionSlice.ts index 89d18d41050d1f9f66c00566d1f21c93b5b338f6..4a7434768c787722e3ee4a712e7c167e24b7ff90 100644 --- a/libs/shared/lib/data-access/store/interactionSlice.ts +++ b/libs/shared/lib/data-access/store/interactionSlice.ts @@ -1,19 +1,31 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import type { RootState } from './store'; +import { Edge, Node } from './graphQueryResultSlice'; -export type HoverType = { [id: string]: any }; +export type HoverStateI = { [id: string]: any }; -export type SelectType = { [id: string]: any }; +export type SelectionStateI = + | { + selectionType: 'node'; + contentType: 'data'; + content: Node[]; + } + | { + // TODO: other selection types + selectionType: 'relation'; + contentType: 'data'; + content: Edge[]; + }; // Define the initial state using that type export type InteractionsType = { - hover: HoverType | undefined; - select: SelectType | undefined; + hover?: HoverStateI; + selection?: SelectionStateI; }; export const initialState: InteractionsType = { - hover: {}, - select: {}, + hover: undefined, + selection: undefined, }; export const interactionSlice = createSlice({ @@ -21,15 +33,29 @@ export const interactionSlice = createSlice({ // `createSlice` will infer the state type from the `initialState` argument initialState, reducers: { - addHover: (state, action: PayloadAction<HoverType | undefined>) => { + addHover: (state, action: PayloadAction<HoverStateI | undefined>) => { state.hover = action.payload; }, - addSelect: (state, action: PayloadAction<SelectType | undefined>) => { - state.select = action.payload; + unSelect: (state) => { + state.selection = undefined; + }, + resultSetSelection: (state, action: PayloadAction<Node[]>) => { + if (action.payload.length === 0) { + state.selection = undefined; + } else { + state.selection = { + selectionType: 'node', + contentType: 'data', + content: action.payload, + }; + } }, }, }); -export const { addHover, addSelect } = interactionSlice.actions; +export const { addHover, unSelect, resultSetSelection } = interactionSlice.actions; + +export const interactionState = (state: RootState) => state.interaction; +export const selectionState = (state: RootState) => state.interaction.selection; export default interactionSlice.reducer; diff --git a/libs/shared/lib/data-access/store/store.ts b/libs/shared/lib/data-access/store/store.ts index af20b8beff50401d69e4fc6f369e1cf3f4e46014..ec6bce79354fbba967ebb3a03339350434617e9a 100644 --- a/libs/shared/lib/data-access/store/store.ts +++ b/libs/shared/lib/data-access/store/store.ts @@ -8,6 +8,7 @@ import authSlice from './authSlice'; import mlSlice from './mlSlice'; import searchResultSlice from './searchResultSlice'; import visualizationSlice from './visualizationSlice'; +import interactionSlice from './interactionSlice'; export const store = configureStore({ reducer: { @@ -19,6 +20,7 @@ export const store = configureStore({ config: configSlice, ml: mlSlice, searchResults: searchResultSlice, + interaction: interactionSlice, visualize: visualizationSlice, }, middleware: (getDefaultMiddleware) => diff --git a/libs/shared/lib/schema/model/reactflow.tsx b/libs/shared/lib/schema/model/reactflow.tsx index f6fbbb3db4cb7e2bcc15acf9193ea927c759a969..39a1211063ae736ce9ecf0bda5c041c8717a9a1e 100644 --- a/libs/shared/lib/schema/model/reactflow.tsx +++ b/libs/shared/lib/schema/model/reactflow.tsx @@ -20,16 +20,7 @@ export enum AttributeCategory { undefined = 'undefined', } -// /** -// * List of schema elements for react flow -// */ -// export type SchemaElements = { -// nodes: Node[]; -// edges: Edge[]; -// selfEdges: Edge[]; -// }; - -export interface SchemaReactflowData { +export type SchemaReactflowData = { name: string; attributes: SchemaGraphologyNode[]; nodeCount: number; @@ -37,37 +28,37 @@ export interface SchemaReactflowData { label: string; type: string; hovered: boolean; -} +}; -export interface SchemaReactflowEntity extends SchemaReactflowData { +export type SchemaReactflowEntity = SchemaReactflowData & { // handles: string[]; connectedRatio: number; name: string; -} +}; -export interface SchemaReactflowRelation extends SchemaReactflowData { +export type SchemaReactflowRelation = SchemaReactflowData & { from: string; to: string; collection: string; fromRatio: number; toRatio: number; -} +}; -export interface SchemaReactflowNodeWithFunctions extends SchemaReactflowEntity { +export type SchemaReactflowNodeWithFunctions = SchemaReactflowEntity & { toggleNodeQualityPopup: (id: string) => void; toggleAttributeAnalyticsPopupMenu: (id: string) => void; -} +}; -export interface SchemaReactflowRelationWithFunctions extends SchemaReactflowRelation { +export type SchemaReactflowRelationWithFunctions = SchemaReactflowRelation & { toggleNodeQualityPopup: (id: string) => void; toggleAttributeAnalyticsPopupMenu: (id: string) => void; -} +}; /** * Typing for the Node Quality data of an entity. * It is used for the Node quality analytics and will be displayed in the corresponding popup. */ -export interface NodeQualityDataForEntities { +export type NodeQualityDataForEntities = { nodeCount: number; attributeNullCount: number; notConnectedNodeCount: number; @@ -76,13 +67,13 @@ export interface NodeQualityDataForEntities { // for user interactions onClickCloseButton: () => void; -} +}; /** * Typing for the Node Quality data of a relation. * It is used for the Node quality analytics and will be displayed in the corresponding popup. */ -export interface NodeQualityDataForRelations { +export type NodeQualityDataForRelations = { nodeCount: number; attributeNullCount: number; // from-entity node --relation--> to-entity node @@ -93,20 +84,20 @@ export interface NodeQualityDataForRelations { // for user interactions onClickCloseButton: () => void; -} +}; /** * Typing for the Node Quality popup of an entity or relation node. */ -export interface NodeQualityPopupNode extends Node { +export type NodeQualityPopupNode = Node & { data: NodeQualityDataForEntities | NodeQualityDataForRelations; nodeID: string; //ID of the node for which the popup is -} +}; /** * Typing for the attribute analytics popup menu data of an entity or relation. */ -export interface AttributeAnalyticsData { +export type AttributeAnalyticsData = { nodeType: NodeType; nodeID: string; attributes: AttributeWithData[]; @@ -120,15 +111,15 @@ export interface AttributeAnalyticsData { searchForAttributes: (id: string, searchbarValue: string) => void; resetAttributeFilters: (id: string) => void; applyAttributeFilters: (id: string, category: AttributeCategory, predicate: string, percentage: number) => void; -} +}; /** * Typing for the attribute analytics popup menu of entity or relation nodes */ -export interface AttributeAnalyticsPopupMenuNode extends Node { +export type AttributeAnalyticsPopupMenuNode = Node & { nodeID: string; //ID of the node for which the popup is data: AttributeAnalyticsData; -} +}; /** Typing of the attributes which are stored in the popup menu's */ export type AttributeWithData = { diff --git a/libs/shared/lib/vis/common/types.ts b/libs/shared/lib/vis/common/types.ts index 37a3ea76ed809029f781c56787363b0e4bfe9b0b..2d8f8bf3d798f2747e962439792fdd60bffb44a9 100644 --- a/libs/shared/lib/vis/common/types.ts +++ b/libs/shared/lib/vis/common/types.ts @@ -4,8 +4,8 @@ import { SchemaGraph } from '../../schema'; import type { AppDispatch } from '../../data-access'; import { FC } from 'react'; import { Visualizations } from '../manager'; -import { Edge, Node } from 'reactflow'; import { GraphMetadata } from '../../data-access/statistics'; +import { Node, Edge } from '../../data-access/store/graphQueryResultSlice'; export type VisualizationConfiguration = { [id: string]: any }; @@ -25,7 +25,7 @@ export type VisualizationPropTypes = { graphMetadata: GraphMetadata; updateSettings: (newSettings: any) => void; handleHover: (val: any) => void; - handleSelect: (val: any) => void; + handleSelect: (selection?: { nodes?: Node[]; edges?: Edge[] }) => void; }; export type SchemaElements = { @@ -34,39 +34,39 @@ export type SchemaElements = { selfEdges: Edge[]; }; -export interface Point { +export type Point = { x: number; y: number; -} +}; -export interface BoundingBox { +export type BoundingBox = { topLeft: Point; bottomRight: Point; -} +}; -export interface NodeQualityDataForEntities { +export type NodeQualityDataForEntities = { nodeCount: number; attributeNullCount: number; notConnectedNodeCount: number; isAttributeDataIn: boolean; // is true when the data to display has arrived onClickCloseButton: () => void; -} +}; -export interface NodeQualityDataForRelations { +export type NodeQualityDataForRelations = { nodeCount: number; attributeNullCount: number; fromRatio: number; // the ratio of from-entity nodes to nodes that have this relation toRatio: number; // the ratio of to-entity nodes to nodes that have this relation isAttributeDataIn: boolean; // is true when the data to display has arrived onClickCloseButton: () => void; -} +}; -export interface NodeQualityPopupNode extends Node { +export type NodeQualityPopupNode = Node & { data: NodeQualityDataForEntities | NodeQualityDataForRelations; nodeID: string; //ID of the node for which the popup is -} +}; -export interface AttributeAnalyticsData { +export type AttributeAnalyticsData = { nodeType: NodeType; nodeID: string; attributes: AttributeWithData[]; @@ -76,7 +76,7 @@ export interface AttributeAnalyticsData { searchForAttributes: (id: string, searchbarValue: string) => void; resetAttributeFilters: (id: string) => void; applyAttributeFilters: (id: string, category: AttributeCategory, predicate: string, percentage: number) => void; -} +}; export enum AttributeCategory { categorical = 'Categorical', @@ -90,10 +90,10 @@ export enum NodeType { relation = 'relation', } -export interface AttributeAnalyticsPopupMenuNode extends Node { +export type AttributeAnalyticsPopupMenuNode = Node & { nodeID: string; //ID of the node for which the popup is data: AttributeAnalyticsData; -} +}; export type AttributeWithData = { attribute: any; diff --git a/libs/shared/lib/vis/components/config/ActiveVisualizationConfig.tsx b/libs/shared/lib/vis/components/config/ActiveVisualizationConfig.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fa36ceed168fc9e210965eab2bf9aad84491fcbd --- /dev/null +++ b/libs/shared/lib/vis/components/config/ActiveVisualizationConfig.tsx @@ -0,0 +1,45 @@ +import { Delete } from '@mui/icons-material'; +import { Button, Input } from '../../..'; +import { VisualizationManagerType, VISUALIZATION_TYPES } from '../../manager'; +import { SettingsHeader } from './components'; + +type Props = { + manager: VisualizationManagerType; +}; + +export const ActiveVisualizationConfig = ({ manager }: Props) => { + return ( + <> + <div className="border-b py-2"> + <div className="flex justify-between items-center px-4 py-2"> + <span className="text-xs font-bold">Visualization</span> + <Button + type="secondary" + variant="ghost" + size="xs" + iconComponent={<Delete />} + onClick={() => { + if (manager.activeVisualization) manager.deleteVisualization(manager.activeVisualization); + }} + /> + </div> + <div className="flex justify-between items-center px-4 py-1"> + <span className="text-xs font-normal">Type</span> + <div className="w-36"> + <Input type="dropdown" size="xs" options={VISUALIZATION_TYPES} value={manager.activeVisualization} onChange={() => {}} /> + </div> + </div> + <div className="flex justify-between items-center px-4 py-1"> + <span className="text-xs font-normal">Name</span> + <input type="text" className="border rouded text-xs w-36" value={manager.activeVisualization} onChange={() => {}} /> + </div> + </div> + {manager.activeVisualization && ( + <div className="border-b p-4 w-full"> + <SettingsHeader name="Configuration" /> + {manager.renderSettings()} + </div> + )} + </> + ); +}; diff --git a/libs/shared/lib/vis/components/config/SelectionConfig.tsx b/libs/shared/lib/vis/components/config/SelectionConfig.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d790d651f713193df09b685592dc11c51f340b6f --- /dev/null +++ b/libs/shared/lib/vis/components/config/SelectionConfig.tsx @@ -0,0 +1,48 @@ +import { SelectionStateI, unSelect } from '@graphpolaris/shared/lib/data-access/store/interactionSlice'; +import { Delete } from '@mui/icons-material'; +import { useDispatch } from 'react-redux'; +import { Button, EntityPill } from '../../..'; +import { VISUALIZATION_TYPES } from '../../manager'; +import { SettingsHeader } from './components'; + +export const SelectionConfig = (props: { selection: SelectionStateI }) => { + const dispatch = useDispatch(); + + return ( + <div className="border-b py-2"> + <div className="flex justify-between items-center px-4 py-2"> + <span className="text-xs font-bold">Selection</span> + <Button + type="secondary" + variant="ghost" + size="xs" + iconComponent={<Delete />} + onClick={() => { + dispatch(unSelect()); + }} + /> + </div> + {props.selection.content.map((item, index) => ( + <> + <div key={index + 'id'} className="flex justify-between items-center px-4 py-1 gap-1"> + <span className="text-xs font-normal">ID</span> + <span className="text-xs">{item._id}</span> + </div> + <div key={index + 'label'} className="flex justify-between items-center px-4 py-1 gap-1"> + <span className="text-xs font-normal">Label</span> + <EntityPill title={item.attributes['labels'] as string}></EntityPill> + </div> + {Object.entries(item.attributes).map(([key, value]) => { + if (key === 'labels' || key === '_id') return null; + return ( + <div key={index + key} className="flex justify-between items-center px-4 py-1 gap-1"> + <span className="text-xs font-normal break-all max-w-[6rem]">{key}</span> + <span className="text-xs break-all">{value as string}</span> + </div> + ); + })} + </> + ))} + </div> + ); +}; diff --git a/libs/shared/lib/vis/components/config/panel.tsx b/libs/shared/lib/vis/components/config/panel.tsx index 3e7d04c4e4c8dec5309b2343d468f0b564898425..22adadfbb9357e3f8615fe528d0c4136d571cad4 100644 --- a/libs/shared/lib/vis/components/config/panel.tsx +++ b/libs/shared/lib/vis/components/config/panel.tsx @@ -1,11 +1,9 @@ import React from 'react'; -import { Button, Icon } from '../../../components'; -import { Delete, Person } from '@mui/icons-material'; -import { Input } from '../../../components/inputs'; -import { VISUALIZATION_TYPES } from '../../manager'; +import { Button } from '../../../components'; import { VisualizationManagerType } from '../../manager'; -import { SettingsHeader } from './components'; -import { useSessionCache } from '../../../data-access'; +import { useSelection, useSessionCache } from '../../../data-access'; +import { SelectionConfig } from './SelectionConfig'; +import { ActiveVisualizationConfig } from './ActiveVisualizationConfig'; type Props = { manager: VisualizationManagerType; @@ -13,45 +11,15 @@ type Props = { export function ConfigPanel({ manager }: Props) { const session = useSessionCache(); + const selection = useSelection(); const buildInfo = import.meta.env.GRAPHPOLARIS_VERSION; return ( <div className="flex flex-col w-full"> - {manager.activeVisualization ? ( - <> - <div className="border-b py-2"> - <div className="flex justify-between items-center px-4 py-2"> - <span className="text-xs font-bold">Visualization</span> - <Button - type="secondary" - variant="ghost" - size="xs" - iconComponent={<Delete />} - onClick={() => { - if (manager.activeVisualization) manager.deleteVisualization(manager.activeVisualization); - }} - /> - </div> - <div className="flex justify-between items-center px-4 py-1"> - <span className="text-xs font-normal">Type</span> - <div className="w-36"> - <Input type="dropdown" size="xs" options={VISUALIZATION_TYPES} value={manager.activeVisualization} onChange={() => {}} /> - </div> - </div> - <div className="flex justify-between items-center px-4 py-1"> - <span className="text-xs font-normal">Name</span> - <input type="text" className="border rouded text-xs w-36" value={manager.activeVisualization} onChange={() => {}} /> - </div> - </div> - {manager.activeVisualization && ( - <div className="border-b p-4 w-full"> - <SettingsHeader name="Configuration" /> - {manager.renderSettings()} - </div> - )} - </> - ) : ( + {!!selection && <SelectionConfig selection={selection} />} + {!selection && manager.activeVisualization && <ActiveVisualizationConfig manager={manager} />} + {!selection && !manager.activeVisualization && ( <div> {session && session.currentSaveState && ( <div className="flex flex-col p-4 border-b"> diff --git a/libs/shared/lib/vis/manager/VisualizationManager.tsx b/libs/shared/lib/vis/manager/VisualizationManager.tsx index 06add912d53d7b366575c945439e61974fdc5295..3118611fd3acee18204c29be15267e4f4df0b48e 100644 --- a/libs/shared/lib/vis/manager/VisualizationManager.tsx +++ b/libs/shared/lib/vis/manager/VisualizationManager.tsx @@ -16,7 +16,8 @@ import { useVisualization, } from '../../data-access'; import { VisualizationManagerType } from '.'; -import { SelectType, addSelect } from '../../data-access/store/interactionSlice'; +import { Node, Edge } from '../../data-access/store/graphQueryResultSlice'; +import { SelectionStateI, resultSetSelection, unSelect } from '../../data-access/store/interactionSlice'; export const Visualizations: Record<string, Function> = { TableVis: () => import('../visualizations/tablevis/tablevis'), @@ -100,8 +101,9 @@ export const VisualizationManager = (): VisualizationManagerType => { } }; - const handleSelect = (item: SelectType | undefined) => { - dispatch(addSelect(item)); + const handleSelect = (selection?: { nodes?: Node[]; edges?: Edge[] }) => { + if (selection?.nodes && selection.nodes.length > 0) dispatch(resultSetSelection(selection.nodes)); + else dispatch(unSelect()); }; const updateSettings = (newSettings: Record<string, any>) => { diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLMachineLearning.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLMachineLearning.tsx index 6ac50ebe2f9a0021e1b73f8b772b5aa66223464e..78d36400c8efd19494d706d1e84a56ad86c1d62d 100644 --- a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLMachineLearning.tsx +++ b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLMachineLearning.tsx @@ -6,7 +6,7 @@ export function processLinkPrediction(ml: ML, graph: GraphType): GraphType { if (ml === undefined || ml.linkPrediction === undefined) return graph; if (ml.linkPrediction.enabled) { - let allNodeIds = new Set(graph.nodes.map((n) => n.id)); + let allNodeIds = new Set(graph.nodes.map((n) => n._id)); ml.linkPrediction.result.forEach((link) => { if (allNodeIds.has(link.from) && allNodeIds.has(link.to)) { const toAdd: LinkType = { @@ -36,8 +36,8 @@ export function processCommunityDetection(ml: ML, graph: GraphType): GraphType { }); graph.nodes = graph.nodes.map((node, i) => { - if (allNodeIdMap.has(node.id)) { - node.cluster = allNodeIdMap.get(node.id); + if (allNodeIdMap.has(node._id)) { + node.cluster = allNodeIdMap.get(node._id); } else { node.cluster = -1; } @@ -89,7 +89,7 @@ export const useNLMachineLearning = (props: { if (shortestPathData === undefined) { console.warn('Something went wrong with shortest path calculation'); } else { - const path: string[] = shortestPathData[shortestPathNodes[index + 1].id]; + const path: string[] = shortestPathData[shortestPathNodes[index + 1]._id]; allPaths = allPaths.concat(getShortestPathEdges(path)); } index++; diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx index 20b07dc4230b2b27a8a0823162c079d595d36deb..340dcfa7e3f43b8b2ff6072104698dfd05372ef5 100644 --- a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx +++ b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx @@ -9,9 +9,10 @@ import { CytoscapeLayout, GraphologyLayout, LayoutFactory, Layouts } from '../.. import { MultiGraph } from 'graphology'; import { VisualizationConfiguration } from '../../../common'; import { Viewport } from 'pixi-viewport'; +import { c } from 'vite/dist/node/types.d-aGj9QkWt'; type Props = { - onClick: (node: NodeType, pos: IPointData) => void; + onClick: (event?: { node: NodeType; pos: IPointData }) => void; // onHover: (data: { node: NodeType; pos: IPointData }) => void; // onUnHover: (data: { node: NodeType; pos: IPointData }) => void; highlightNodes: NodeType[]; @@ -53,7 +54,6 @@ export const NLPixi = (props: Props) => { const ml = useML(); const dragging = useRef<{ node: NodeType; gfx: Graphics } | null>(null); const onlyClicked = useRef(false); - const dispatch = useAppDispatch(); const searchResults = useSearchResultData(); const layoutAlgorithm = useRef<CytoscapeLayout | GraphologyLayout>(new LayoutFactory().createLayout(Layouts.DAGRE)); @@ -92,19 +92,21 @@ export const NLPixi = (props: Props) => { useImperativeHandle(imperative, () => ({ onDragStart(node: NodeType, gfx: Graphics) { + dragging.current = { node, gfx }; + onlyClicked.current = true; + // todo: graphology does not support fixed nodes // todo: after vis-settings panel is there, we should to also support the original d3 force to allow interactivity if needed if (props.layoutAlgorithm === Layouts.FORCEATLAS2WEBWORKER) return; if (viewport.current) viewport.current.pause = true; - dragging.current = { node, gfx }; - onlyClicked.current = true; }, onDragMove(movementX: number, movementY: number) { + if (props.layoutAlgorithm === Layouts.FORCEATLAS2WEBWORKER) return; if (dragging.current) { onlyClicked.current = false; if (quickPopup) setQuickPopup(undefined); - const idx = popups.findIndex((p) => p.node.id === dragging.current?.node.id); + const idx = popups.findIndex((p) => p.node._id === dragging.current?.node._id); if (idx >= 0) { const p = popups[idx]; p.pos.x += movementX / (viewport.current?.scaled || 1); @@ -129,17 +131,17 @@ export const NLPixi = (props: Props) => { if (onlyClicked.current) { onlyClicked.current = false; - if (popups.filter((d) => d.node.id === dragging.current?.node.id).length > 0) { - setPopups(popups.filter((p) => p.node.id !== dragging.current?.node.id)); + if (popups.filter((d) => d.node._id === dragging.current?.node._id).length > 0) { + setPopups(popups.filter((p) => p.node._id !== dragging.current?.node._id)); + props.onClick(); } else { - console.log('clicked', popups); setPopups([...popups, { node: dragging.current.node, pos: toGlobal(dragging.current.node) }]); + props.onClick({ node: dragging.current.node, pos: toGlobal(dragging.current.node) }); } - - props.onClick(dragging.current.node, toGlobal(dragging.current.node)); } this.onHover(dragging.current.node); dragging.current = null; + } else { } }, onHover(node: NodeType) { @@ -148,7 +150,7 @@ export const NLPixi = (props: Props) => { viewport?.current && !viewport?.current?.pause && node && - popups.filter((p) => p.node.id === node.id).length === 0 + popups.filter((p) => p.node._id === node._id).length === 0 ) { setQuickPopup({ node, pos: toGlobal(node) }); } @@ -158,6 +160,7 @@ export const NLPixi = (props: Props) => { }, onPan() { setPopups([]); + props.onClick(); }, })); @@ -216,7 +219,7 @@ export const NLPixi = (props: Props) => { } const updateNode = (node: NodeType) => { - const gfx = nodeMap.current.get(node.id); + const gfx = nodeMap.current.get(node._id); if (!gfx) return; const lineColor = node.isShortestPathSource @@ -270,18 +273,18 @@ export const NLPixi = (props: Props) => { const createNode = (node: NodeType, selected?: boolean) => { // check if node is already drawn, and if so, delete it - if (node && node?.id && nodeMap.current?.has(node.id)) { - nodeMap.current.delete(node.id); + if (node && node?._id && nodeMap.current?.has(node._id)) { + nodeMap.current.delete(node._id); } // Do not draw node if it has no position if (node.x === undefined || node.y === undefined) return; const gfx = new Graphics(); - nodeMap.current.set(node.id, gfx); + nodeMap.current.set(node._id, gfx); nodeLayer.addChild(gfx); node.selected = selected; updateNode(node); - gfx.name = 'node_' + node.id; + gfx.name = 'node_' + node._id; gfx.eventMode = 'dynamic'; return gfx; @@ -319,8 +322,8 @@ export const NLPixi = (props: Props) => { } else { source = link.source as NodeType; target = link.target as NodeType; - sourceId = source.id; - targetId = target.id; + sourceId = source._id; + targetId = target._id; } if (!source || !target) { console.error('source or target not found', source, target, sourceId, targetId); @@ -401,9 +404,9 @@ export const NLPixi = (props: Props) => { useEffect(() => { if (props.graph) { props.graph.nodes.forEach((node: NodeType) => { - const gfx = nodeMap.current.get(node.id); + const gfx = nodeMap.current.get(node._id); if (!gfx) return; - const isNodeInSearchResults = searchResults.nodes.some((resultNode) => resultNode.id === node.id); + const isNodeInSearchResults = searchResults.nodes.some((resultNode) => resultNode.id === node._id); gfx.alpha = isNodeInSearchResults || searchResults.nodes.length === 0 ? 1 : 0.05; }); @@ -426,22 +429,30 @@ export const NLPixi = (props: Props) => { let stopped = 0; props.graph.nodes.forEach((node: NodeType, i) => { if (!layoutAlgorithm.current) return; - const gfx = nodeMap.current.get(node.id); + const gfx = nodeMap.current.get(node._id); if (!gfx || node.x === undefined || node.y === undefined) return; - const position = layoutAlgorithm.current.getNodePosition(node.id); + const position = layoutAlgorithm.current.getNodePosition(node._id); - if (Math.abs(node.x - position.x - app.renderer.width / 2) + Math.abs(node.y - position.y - app.renderer.height / 2) < 1) { + if ( + !position || + Math.abs(node.x - position.x - app.renderer.width / 2) + Math.abs(node.y - position.y - app.renderer.height / 2) < 1 + ) { stopped += 1; return; } - if (layoutAlgorithm.current.provider === 'Graphology') { - // this is a dirty hack to fix the graphology layout being out of bounds - node.x = position.x + app.renderer.width / 2; - node.y = position.y + app.renderer.height / 2; - } else { - node.x = position.x; - node.y = position.y; + try { + if (layoutAlgorithm.current.provider === 'Graphology') { + // this is a dirty hack to fix the graphology layout being out of bounds + node.x = position.x + app.renderer.width / 2; + node.y = position.y + app.renderer.height / 2; + } else { + node.x = position.x; + node.y = position.y; + } + } catch (e) { + // node.x and .y become read-only when some layout algorithms are finished + layoutState.current = 'paused'; } if (layoutState.current === 'running') { @@ -489,7 +500,7 @@ export const NLPixi = (props: Props) => { } nodeMap.current.forEach((gfx, id) => { - if (!props.graph?.nodes?.find((node) => node.id === id)) { + if (!props.graph?.nodes?.find((node) => node._id === id)) { nodeLayer.removeChild(gfx); gfx.destroy(); nodeMap.current.delete(id); @@ -505,8 +516,8 @@ export const NLPixi = (props: Props) => { }); props.graph.nodes.forEach((node: NodeType) => { - if (!forceClear && nodeMap.current.has(node.id)) { - const old = nodeMap.current.get(node.id); + if (!forceClear && nodeMap.current.has(node._id)) { + const old = nodeMap.current.get(node._id); node.x = old?.x || node.x; node.y = old?.y || node.y; updateNode(node); @@ -574,7 +585,6 @@ export const NLPixi = (props: Props) => { }); app.stage.eventMode = 'dynamic'; - app.stage.on('mouseup', onDragEnd); app.stage.on('pointerup', onDragEnd); app.stage.on('mousemove', onDragMove); app.stage.on('mouseup', onDragEnd); @@ -593,8 +603,8 @@ export const NLPixi = (props: Props) => { const graphologyGraph = new MultiGraph(); props.graph?.nodes.forEach((node) => { - if (forceClear) graphologyGraph.addNode(node.id, { size: node.radius || 5 }); - else graphologyGraph.addNode(node.id, { size: node.radius || 5, x: node.x || 0, y: node.y || 0 }); + if (forceClear) graphologyGraph.addNode(node._id, { size: node.radius || 5 }); + else graphologyGraph.addNode(node._id, { size: node.radius || 5, x: node.x || 0, y: node.y || 0 }); }); props.graph?.links.forEach((link) => { @@ -610,7 +620,7 @@ export const NLPixi = (props: Props) => { }; return ( <> - {mouseInCanvas.current && popups.map((popup) => <NLPopup onClose={() => {}} data={popup} key={popup.node.id} />)} + {mouseInCanvas.current && popups.map((popup) => <NLPopup onClose={() => {}} data={popup} key={popup.node._id} />)} {quickPopup && <NLPopup onClose={() => {}} data={quickPopup} />} <div className="h-full w-full overflow-hidden" diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx index 7e07fbe0805bce9f2e02b24d79f9d7814caea571..679486e82c93eee1faab5aa5c73a965607838d3a 100644 --- a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx +++ b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx @@ -18,7 +18,7 @@ export const NLPopup = (props: NodelinkPopupProps) => { <div className="card-body p-0"> <span className="px-2.5 pt-2"> <span>Node</span> - <span className="float-right">{node.id}</span> + <span className="float-right">{node._id}</span> </span> <div className="h-[1px] w-full bg-secondary-200"></div> <div className="px-2.5 text-[0.8rem]"> diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx index 33ea7097b6d3cade3b4c21a18f4bd61254d16c38..f5b7381b4fb3b47e57a3b9f5490654f9eac412b6 100644 --- a/libs/shared/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx +++ b/libs/shared/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx @@ -178,7 +178,7 @@ export function parseQueryResult(queryResult: GraphQueryResult, ml: ML, options: let radius = options.defaultRadius || 5; let data: NodeType = { - id: queryResult.nodes[i]._id, + _id: queryResult.nodes[i]._id, attributes: queryResult.nodes[i].attributes, type: typeNumber, displayInfo: preferredText, @@ -214,7 +214,7 @@ export function parseQueryResult(queryResult: GraphQueryResult, ml: ML, options: // Filter unique edges and transform to LinkTypes // List for all links let links: LinkType[] = []; - let allNodeIds = new Set(nodes.map((n) => n.id)); + let allNodeIds = new Set(nodes.map((n) => n._id)); // Parse ml edges // if (ml != undefined) { diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/components/utils.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/components/utils.tsx index b8929f4f8ad2ef50f9ef14ab81b92ea86be93915..36f17720a1c891c1dbbc9f8743dd51f7cb800ce9 100644 --- a/libs/shared/lib/vis/visualizations/nodelinkvis/components/utils.tsx +++ b/libs/shared/lib/vis/visualizations/nodelinkvis/components/utils.tsx @@ -72,7 +72,7 @@ export const getRelatedLinks = (graph: GraphType, nodes: NodeType[], jaccardThre const { source, target } = link; if (isLinkVisible(link, jaccardThreshold)) { nodes.forEach((node: NodeType) => { - if (source == node || target == node || source == node.id || target == node.id) { + if (source == node || target == node || source == node._id || target == node._id) { relatedLinks.push(link); } }); diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx index ca8bc5d5d60157eb03f78fe7a676d8110730bc3e..c6ba5d9bd6cb6ac5a207df16a08abaea6d7e408c 100644 --- a/libs/shared/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx +++ b/libs/shared/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx @@ -11,6 +11,8 @@ import { SettingsContainer, SettingsHeader } from '@graphpolaris/shared/lib/vis/ import { VISComponentType, VisualizationPropTypes } from '../../common'; import { EntityPill } from '@graphpolaris/shared/lib/components/pills/Pill'; import { nodeColorHex } from './components/utils'; +import { Node } from '@graphpolaris/shared/lib/data-access/store/graphQueryResultSlice'; +import { IPointData } from 'pixi.js'; export interface NodelinkVisProps { layout: string; @@ -43,7 +45,7 @@ const configuration: NodelinkVisProps = { nodeList: [], }; -export const NodeLinkVis = React.memo(({ data, ml, dispatch, configuration }: VisualizationPropTypes) => { +export const NodeLinkVis = React.memo(({ data, ml, dispatch, configuration, handleSelect }: VisualizationPropTypes) => { const ref = useRef<HTMLDivElement>(null); const [graph, setGraph] = useImmer<GraphType | undefined>(undefined); const [highlightNodes, setHighlightNodes] = useState<NodeType[]>([]); @@ -60,29 +62,37 @@ export const NodeLinkVis = React.memo(({ data, ml, dispatch, configuration }: Vi } }, [data, ml]); - const onClickedNode = (node: NodeType, ml: ML) => { + const onClickedNode = (event?: { node: NodeType; pos: IPointData }, ml?: ML) => { if (graph) { - if (ml.shortestPath.enabled) { + if (!event?.node) { + handleSelect(); + return; + } + + const node = event.node; + handleSelect({ nodes: [node as Node] }); + + if (ml && ml.shortestPath.enabled) { setGraph((draft) => { - let _node = draft?.nodes.find((n) => n.id === node.id); + let _node = draft?.nodes.find((n) => n._id === node._id); if (!_node) return draft; if (!ml.shortestPath.srcNode) { _node.isShortestPathSource = true; - dispatch(setShortestPathSource(node.id)); - } else if (ml.shortestPath.srcNode === node.id) { + dispatch(setShortestPathSource(node._id)); + } else if (ml.shortestPath.srcNode === node._id) { _node.isShortestPathSource = false; dispatch(setShortestPathSource(undefined)); } else if (!ml.shortestPath.trtNode) { _node.isShortestPathTarget = true; - dispatch(setShortestPathTarget(node.id)); - } else if (ml.shortestPath.trtNode === node.id) { + dispatch(setShortestPathTarget(node._id)); + } else if (ml.shortestPath.trtNode === node._id) { _node.isShortestPathTarget = false; dispatch(setShortestPathTarget(undefined)); } else { _node.isShortestPathSource = true; _node.isShortestPathTarget = false; - dispatch(setShortestPathSource(node.id)); + dispatch(setShortestPathSource(node._id)); dispatch(setShortestPathTarget(undefined)); } return draft; @@ -97,8 +107,8 @@ export const NodeLinkVis = React.memo(({ data, ml, dispatch, configuration }: Vi configuration={configuration} highlightNodes={highlightNodes} highlightedLinks={highlightedLinks} - onClick={(node, pos) => { - onClickedNode(node, ml); + onClick={(event) => { + onClickedNode(event, ml); }} layoutAlgorithm={configuration.layout} /> @@ -127,7 +137,7 @@ const NodelinkSettings = ({ <div className="m-1 flex flex-col space-y-4"> <h1>Nodes Labels:</h1> {configuration.nodeList.map((item, index) => ( - <div className="flex m-1 items-center"> + <div className="flex m-1 items-center" key={item}> <div className="w-3/4 mr-6"> <EntityPill title={item} /> </div> diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/types.ts b/libs/shared/lib/vis/visualizations/nodelinkvis/types.ts index c8f6bec1d6baa52b7d150c8e8a00ceceb4c37ca3..7374b774b60577320b3a832c1fe096b94fd8c287 100644 --- a/libs/shared/lib/vis/visualizations/nodelinkvis/types.ts +++ b/libs/shared/lib/vis/visualizations/nodelinkvis/types.ts @@ -5,6 +5,7 @@ */ import * as PIXI from 'pixi.js'; +import { Node } from '@graphpolaris/shared/lib/data-access/store/graphQueryResultSlice'; /** Types for the nodes and links in the node-link diagram. */ export type GraphType = { @@ -17,13 +18,13 @@ export type GraphType = { }; /** The interface for a node in the node-link diagram */ -export interface NodeType extends d3.SimulationNodeDatum { - id: string; +export interface NodeType extends d3.SimulationNodeDatum, Node { + _id: string; // Number to determine the color of the node - label?: string; + label: string; type: number; - attributes?: Record<string, any>; + attributes: Record<string, any>; cluster?: number; clusterAccoringToMLData?: number; shortestPathData?: Record<string, string[]>;