diff --git a/apps/web/src/app/app.tsx b/apps/web/src/app/app.tsx index 1e6aee5f7d6212c03acefb7db3f308a9ce9b3148..2759ac28030569d1152be8f44211f984e282a8b6 100644 --- a/apps/web/src/app/app.tsx +++ b/apps/web/src/app/app.tsx @@ -12,7 +12,7 @@ import { } from '@graphpolaris/shared/lib/data-access'; import { WebSocketHandler } from '@graphpolaris/shared/lib/data-access/socket'; import Broker from '@graphpolaris/shared/lib/data-access/socket/broker'; -import { assignNewGraphQueryResult, useAppDispatch } from '@graphpolaris/shared/lib/data-access/store'; +import { assignNewGraphQueryResult, useAppDispatch, useML, useMLEnabledHash } from '@graphpolaris/shared/lib/data-access/store'; import { GraphQueryResultFromBackend, GraphQueryResultFromBackendPayload, @@ -26,6 +26,8 @@ import { VisualizationPanel } from './panels/Visualization'; import styles from './app.module.scss'; import { logout } from '@graphpolaris/shared/lib/data-access/store/authSlice'; import { SchemaFromBackend } from '@graphpolaris/shared/lib/schema'; +import { LinkPredictionInstance, setMLResult, allMLTypes } from '@graphpolaris/shared/lib/data-access/store/mlSlice'; +import { Resizable } from '@graphpolaris/shared/lib/components/Resizable'; export interface App {} @@ -41,6 +43,8 @@ export function App(props: App) { const queryHash = useQuerybuilderHash(); const ws = useRef(new WebSocketHandler(import.meta.env.VITE_BACKEND_WSS_URL)); const [authCheck, setAuthCheck] = useState(false); + const ml = useML(); + const mlHash = useMLEnabledHash(); // for testing purposes // useEffect(() => { @@ -50,12 +54,17 @@ export function App(props: App) { useEffect(() => { // Default Broker.instance().subscribe((data: SchemaFromBackend) => dispatch(readInSchemaFromBackend(data)), 'schema_result'); - Broker.instance().subscribe((data: GraphQueryResultFromBackendPayload) => dispatch(assignNewGraphQueryResult(data)), 'query_result'); + allMLTypes.forEach((mlType) => { + Broker.instance().subscribe((data: LinkPredictionInstance[]) => dispatch(setMLResult({ type: mlType, result: data })), mlType); + }); return () => { Broker.instance().unSubscribeAll('schema_result'); Broker.instance().unSubscribeAll('query_result'); + allMLTypes.forEach((mlType) => { + Broker.instance().unSubscribeAll(mlType); + }); }; }, []); @@ -85,14 +94,14 @@ export function App(props: App) { if (query.nodes.length === 0) { dispatch(resetGraphQueryResults()); } else { - api_query.execute(Query2BackendQuery(session.currentDatabase, query)); + api_query.execute(Query2BackendQuery(session.currentDatabase, query, ml)); } } }; useEffect(() => { runQuery(); - }, [queryHash]); + }, [queryHash, mlHash]); return ( <div className="h-screen w-screen"> @@ -101,25 +110,27 @@ export function App(props: App) { <aside className="h-[4rem]"> <Navbar /> </aside> - <main className="flex w-screen h-[calc(100%-4.2rem)] gap-0.5"> - <div className="h-full min-w-[35vw] max-w-[35vw] panel"> - <Schema auth={authCheck} /> - </div> - <div className="h-full min-w-[calc(65vw-0.125rem)] max-w-[calc(65vw-0.125rem)] flex-grow-0"> - <div className="w-full panel h-[50%]"> - <VisualizationPanel /> + <main className="flex w-screen h-[calc(100%-4.2rem)]"> + <Resizable divisorSize={3} horizontal={true} defaultProportion={0.33}> + <div className="h-full w-full panel"> + <Schema auth={authCheck} /> </div> - <div className="h-0.5"></div> - <div className="w-full panel h-[calc(50%-0.125rem)]"> - {/* <h1>Query Panel</h1> */} - <QueryBuilder - onRunQuery={() => { - console.log('Run Query'); - runQuery(); - }} - /> + <div className="h-full w-full"> + <Resizable divisorSize={3} horizontal={false}> + <div className="w-full h-full panel"> + <VisualizationPanel /> + </div> + <div className="w-full h-full panel"> + <QueryBuilder + onRunQuery={() => { + console.log('Run Query'); + runQuery(); + }} + /> + </div> + </Resizable> </div> - </div> + </Resizable> </main> </div> </div> diff --git a/libs/config/tailwind.config.js b/libs/config/tailwind.config.js index 553663a5b770180969d1b721dfa99480697b619c..fa3bfc85e6298c3a96ed5b6ae2d0e7bae8c54a5a 100644 --- a/libs/config/tailwind.config.js +++ b/libs/config/tailwind.config.js @@ -10,6 +10,20 @@ export default { inter: ['"Inter"', ...defaultTheme.fontFamily.sans], }, colors: tailwindColors, + animation: { + openmenu: 'openmenu 0.3s ease-out', + closemenu: 'closemenu 0.3s ease-out', + }, + keyframes: { + openmenu: { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, + closemenu: { + '0%': { opacity: '1' }, + '100%': { opacity: '0' }, + }, + }, }, }, plugins: [require('@tailwindcss/typography'), require('daisyui')], diff --git a/libs/shared/lib/components/Dialog.tsx b/libs/shared/lib/components/Dialog.tsx index c975753399caf237003d19386079c98dd5e443d9..1dbb2e7714bc7e96fe5b8c531e626e31f0d799c9 100644 --- a/libs/shared/lib/components/Dialog.tsx +++ b/libs/shared/lib/components/Dialog.tsx @@ -1,11 +1,12 @@ import { PropsWithChildren, useEffect, useRef } from 'react'; export type DialogProps = { - onClose(): void; + onClose: () => void; open: boolean; + children?: React.ReactNode; }; -export const Dialog = (props: PropsWithChildren<DialogProps>) => { +export const Dialog = (props: DialogProps) => { const ref = useRef<HTMLDialogElement>(null); useEffect(() => { diff --git a/libs/shared/lib/components/Popup.tsx b/libs/shared/lib/components/Popup.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a25f44e5451c931a8feabd58ec0ff74e24d367db --- /dev/null +++ b/libs/shared/lib/components/Popup.tsx @@ -0,0 +1,13 @@ +import { useState } from 'react'; + +export const Popup = (props: { children: React.ReactNode; open: boolean; vanchor: 'left' | 'right' }) => { + return ( + <> + {props.open && ( + <div className={`absolute ${props.vanchor}-20 z-10 max-w-[20rem] bg-offwhite-100 flex flex-col gap-2 animate-openmenu`}> + {props.children} + </div> + )} + </> + ); +}; diff --git a/libs/shared/lib/components/Resizable.tsx b/libs/shared/lib/components/Resizable.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fdf5aaa1391f853b86d3c12df21c9ebbab97023a --- /dev/null +++ b/libs/shared/lib/components/Resizable.tsx @@ -0,0 +1,95 @@ +import React, { useEffect, useRef } from 'react'; +import { s } from 'vitest/dist/env-afee91f0'; + +type Props = { + children: React.ReactNode; + className?: string; + style?: React.CSSProperties; + horizontal: boolean; + divisorSize: number; + defaultProportion?: number; +}; + +function convertRemToPixels(rem: number) { + return rem * parseFloat(getComputedStyle(document.documentElement).fontSize); +} + +export const Resizable = ({ children, className, style, horizontal, divisorSize, defaultProportion, ...props }: Props) => { + const ref = useRef<HTMLDivElement>(null); + const children2 = children as React.ReactElement[]; + const [firstSize, setFirstSize] = React.useState<number>(0); + const [secondSize, setSecondSize] = React.useState<number>(0); + const [dragging, setDragging] = React.useState<boolean>(false); + + useEffect(() => { + if (ref.current) { + const rect = ref.current.getBoundingClientRect(); + const _defaultProportion = defaultProportion || 0.5; + if (horizontal) { + setFirstSize(rect.width * _defaultProportion - divisorSize); + setSecondSize(rect.width * (1 / _defaultProportion) - divisorSize); + } else { + setFirstSize(rect.height * _defaultProportion - divisorSize); + setSecondSize(rect.height * (1 / _defaultProportion) - divisorSize); + } + } + }, [ref.current]); + + function onMouseDown(e: React.MouseEvent<HTMLDivElement, MouseEvent>) { + setDragging(true); + window.addEventListener('mouseup', onMouseUp); + } + const onMouseUp = () => { + setDragging(false); + window.removeEventListener('mouseup', onMouseUp); + }; + function onMouseMove(e: React.MouseEvent<HTMLDivElement, MouseEvent>) { + if (dragging) { + if (horizontal && ref.current) { + const rect = ref.current.getBoundingClientRect(); + setFirstSize(e.clientX); + setSecondSize(rect.width - e.clientX); + } else { + setFirstSize(e.clientY); + setSecondSize(window.innerHeight - e.clientY); + } + } + } + function onTouchStart(e: React.TouchEvent<HTMLDivElement>) { + setDragging(true); + } + function onTouchMove(e: React.TouchEvent<HTMLDivElement>) {} + function onTouchEnd(e: React.TouchEvent<HTMLDivElement>) { + setDragging(false); + } + function onTouchCancel(e: React.TouchEvent<HTMLDivElement>) { + setDragging(false); + } + + return ( + <> + {dragging && <div className="absolute top-0 left-0 w-screen h-screen z-10 cursor-grabbing" onMouseMove={onMouseMove}></div>} + <div className={` w-full h-full flex ${horizontal ? 'flex-row' : 'flex-col'} ${className}`} style={style} {...props} ref={ref}> + {firstSize > 0 && ( + <> + <div className="h-full w-full" style={horizontal ? { maxWidth: firstSize } : { maxHeight: firstSize }}> + {children2[0]} + </div> + <div + className={' ' + (horizontal ? 'cursor-col-resize' : 'cursor-row-resize')} + style={horizontal ? { minWidth: divisorSize } : { minHeight: divisorSize }} + onMouseDown={onMouseDown} + onTouchStart={onTouchStart} + onTouchMove={onTouchMove} + onTouchEnd={onTouchEnd} + onTouchCancel={onTouchCancel} + ></div> + <div className="h-full w-full" style={horizontal ? { maxWidth: secondSize } : { maxHeight: secondSize }}> + {children2[1]} + </div> + </> + )} + </div> + </> + ); +}; diff --git a/libs/shared/lib/data-access/store/graphQueryResultSlice.ts b/libs/shared/lib/data-access/store/graphQueryResultSlice.ts index a37a20e2fe5a26f85a5bec4e2de86f75e64905ee..85df8346437d6db9865ac7266b2da4ee10319465 100644 --- a/libs/shared/lib/data-access/store/graphQueryResultSlice.ts +++ b/libs/shared/lib/data-access/store/graphQueryResultSlice.ts @@ -48,7 +48,6 @@ export interface GraphQueryResult { // Describes what entities there are in this graph query result. nodeTypes: string[]; - mlEdges?: any; // FIXME } // Define the initial state using that type @@ -65,14 +64,16 @@ export const graphQueryResultSlice = createSlice({ reducers: { assignNewGraphQueryResult: (state, action: PayloadAction<GraphQueryResultFromBackendPayload>) => { const payload = action.payload.payload; - // console.log('!!!assignNewGraphQueryResult', action.payload.payload); - // Maybe do some data quality checking and parsing - // ... + // Only keep one node and one edge per id. This is also done in the backend, but we do it here as well to be sure. + const nodeIDs = new Set(payload.nodes.map((node) => node.id)); + const edgeIDs = new Set(payload.edges.map((edge) => edge.id)); + let nodes = [...nodeIDs].map((nodeID) => payload.nodes.find((node) => node.id === nodeID) as unknown as Node); + let edges = [...edgeIDs].map((edgeID) => payload.edges.find((edge) => edge.id === edgeID) as unknown as Edge); // Collect all the different nodetypes in the result const nodeTypes: string[] = []; - payload.nodes = payload.nodes.map((node) => { + nodes = nodes.map((node) => { let _node = { ...node }; let nodeType = node.id.split('/')[0]; let innerLabels = node?.attributes?.labels as string[]; @@ -84,7 +85,7 @@ export const graphQueryResultSlice = createSlice({ return _node; }); - payload.edges = payload.edges.map((edge) => { + edges = edges.map((edge) => { let _edge = { ...edge }; let edgeType = edge.id.split('/')[0]; if (!_edge.id.includes('/')) { @@ -95,8 +96,8 @@ export const graphQueryResultSlice = createSlice({ }); // Assign new state - state.nodes = payload.nodes as Node[]; - state.edges = payload.edges as Edge[]; + state.nodes = nodes; + state.edges = edges; state.nodeTypes = nodeTypes; }, resetGraphQueryResults: (state) => { diff --git a/libs/shared/lib/data-access/store/hooks.ts b/libs/shared/lib/data-access/store/hooks.ts index 7463e73c5ecea81390f9d1fdd3d4e98c87e6e04c..a65837dc8ee70792cd03648be7ff30dbecff6829 100644 --- a/libs/shared/lib/data-access/store/hooks.ts +++ b/libs/shared/lib/data-access/store/hooks.ts @@ -10,6 +10,7 @@ import { } from '@graphpolaris/shared/lib/data-access/store/querybuilderSlice'; import { sessionCacheState } from './sessionSlice'; import { authState } from './authSlice'; +import { allMLEnabled, selectML } from './mlSlice'; // Use throughout your app instead of plain `useDispatch` and `useSelector` export const useAppDispatch: () => AppDispatch = useDispatch; @@ -33,3 +34,7 @@ export const useQuerybuilderHash = () => useAppSelector(selectQuerybuilderHash); export const useConfig = () => useAppSelector(configState); export const useSessionCache = () => useAppSelector(sessionCacheState); export const useAuthorizationCache = () => useAppSelector(authState); + +// Machine Learning Slices +export const useML = () => useAppSelector(selectML); +export const useMLEnabledHash = () => useAppSelector(allMLEnabled); diff --git a/libs/shared/lib/data-access/store/index.ts b/libs/shared/lib/data-access/store/index.ts index 02dc9bb14ef580d5ed5baf6ccaf5d0be5deccff9..154aec4d518e733c223f90b20dbea750d072138a 100644 --- a/libs/shared/lib/data-access/store/index.ts +++ b/libs/shared/lib/data-access/store/index.ts @@ -11,6 +11,7 @@ export { resetGraphQueryResults, graphQueryResultSlice, } from './graphQueryResultSlice'; +export { mlSlice } from './mlSlice'; // Exported types export type { Node, Edge, GraphQueryResult } from './graphQueryResultSlice'; diff --git a/libs/shared/lib/data-access/store/mlSlice.ts b/libs/shared/lib/data-access/store/mlSlice.ts new file mode 100644 index 0000000000000000000000000000000000000000..9af8487a62e9378af9226303feedc8caf7cb0359 --- /dev/null +++ b/libs/shared/lib/data-access/store/mlSlice.ts @@ -0,0 +1,101 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import type { RootState } from './store'; +import { AllLayoutAlgorithms, CytoscapeLayoutAlgorithms } from '@graphpolaris/shared/lib/graph-layout'; +import { SchemaUtils } from '../../schema/schema-utils'; +import { SchemaFromBackend, SchemaGraph, SchemaGraphology } from '../../schema'; +import { s } from 'vitest/dist/env-afee91f0'; + +/**************************************************************** */ + +export type MLTypes = 'centrality' | 'linkPrediction' | 'communityDetection' | 'shortestPath'; +export const allMLTypes: MLTypes[] = ['centrality', 'linkPrediction', 'communityDetection', 'shortestPath']; +export enum MLTypesEnum { + CENTRALITY = 'centrality', + LINK_PREDICTION = 'linkPrediction', + COMMUNITY_DETECTION = 'communityDetection', + SHORTEST_PATH = 'shortestPath', +} + +export type LinkPredictionInstance = { + attributes: { jaccard_coefficient: number }; + from: string; + to: string; + id: string; +}; + +export type CommunityDetectionInstance = string[]; // set of ids + +export type MLInstance<T> = { + enabled: boolean; + result: T; +}; + +export type CommunityDetection = MLInstance<CommunityDetectionInstance[]> & { + jaccard_threshold: number; +}; + +export type ShortestPath = { + enabled: boolean; + result: any; + srcNode?: string; + trtNode?: string; +}; + +export type ML = { + [MLTypesEnum.LINK_PREDICTION]: MLInstance<LinkPredictionInstance[]>; + [MLTypesEnum.CENTRALITY]: MLInstance<Record<string, number>>; + [MLTypesEnum.COMMUNITY_DETECTION]: CommunityDetection; + [MLTypesEnum.SHORTEST_PATH]: ShortestPath; +}; + +// Define the initial state using that type +export const mlDefaultState: ML = { + linkPrediction: { enabled: false, result: [] }, + centrality: { enabled: false, result: {} }, + communityDetection: { enabled: false, result: [], jaccard_threshold: 0.5 }, + shortestPath: { enabled: false, result: [] }, +}; +export const mlSlice = createSlice({ + name: 'ml', + // `createSlice` will infer the state type from the `initialState` argument + initialState: mlDefaultState, + reducers: { + setLinkPredictionEnabled: (state, action: PayloadAction<boolean>) => { + state.linkPrediction.enabled = action.payload; + }, + setCommunityDetectionEnabled: (state, action: PayloadAction<boolean>) => { + state.communityDetection.enabled = action.payload; + }, + setCentralityEnabled: (state, action: PayloadAction<boolean>) => { + state.centrality.enabled = action.payload; + }, + setShortestPathEnabled: (state, action: PayloadAction<boolean>) => { + state.shortestPath.enabled = action.payload; + }, + setShortestPathSource: (state, action: PayloadAction<string | undefined>) => { + state.shortestPath.srcNode = action.payload; + }, + setShortestPathTarget: (state, action: PayloadAction<string | undefined>) => { + state.shortestPath.trtNode = action.payload; + }, + setMLResult: (state, action: PayloadAction<{ type: MLTypes; result: any[] }>) => { + state[action.payload.type].result = action.payload.result; + }, + }, +}); +export const { + setMLResult, + setLinkPredictionEnabled, + setCommunityDetectionEnabled, + setCentralityEnabled, + setShortestPathSource, + setShortestPathTarget, + setShortestPathEnabled, +} = mlSlice.actions; + +export const allMLEnabled = (state: RootState): string => { + return JSON.stringify(Object.values(state.ml).map((ml) => ml.enabled)); +}; + +export const selectML = (state: RootState) => state.ml; +export default mlSlice.reducer; diff --git a/libs/shared/lib/data-access/store/store.ts b/libs/shared/lib/data-access/store/store.ts index aa32a4d6aba58d3ae82bddf133279c784acfbd5f..713410cabd3acf056d6def96289b06c7fa3f322b 100644 --- a/libs/shared/lib/data-access/store/store.ts +++ b/libs/shared/lib/data-access/store/store.ts @@ -5,6 +5,7 @@ import schemaSlice from './schemaSlice'; import configSlice from './configSlice'; import sessionSlice from './sessionSlice'; import authSlice from './authSlice'; +import mlSlice from './mlSlice'; export const store = configureStore({ reducer: { @@ -14,6 +15,7 @@ export const store = configureStore({ sessionCache: sessionSlice, auth: authSlice, config: configSlice, + ml: mlSlice, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ diff --git a/libs/shared/lib/querybuilder/model/BackendQueryFormat.tsx b/libs/shared/lib/querybuilder/model/BackendQueryFormat.tsx index 06a26612530935cf68837302d4c8064e4ed0319e..5e514f6ea8aed3ab9a3c38c6eb52f9a188f88fe5 100644 --- a/libs/shared/lib/querybuilder/model/BackendQueryFormat.tsx +++ b/libs/shared/lib/querybuilder/model/BackendQueryFormat.tsx @@ -6,6 +6,7 @@ import { type } from 'os'; import { AllLogicStatement, AnyStatement, InputNodeType } from './logic/general'; +import { MLTypes } from '../../data-access/store/mlSlice'; /** JSON query format used to send a query to the backend. */ export interface BackendQueryResultFormat { @@ -34,7 +35,7 @@ export interface BackendQueryFormat { // entities: Entity[]; // relations: Relation[]; // groupBys: GroupBy[]; - // machineLearning: MachineLearning[]; + machineLearning: MachineLearning[]; // modifiers: ModifierStruct[]; // prefix: string; } @@ -168,11 +169,11 @@ export interface Constraint { // } // /** Interface for Machine Learning algorithm */ -// export interface MachineLearning { -// ID?: number; -// queuename: string; -// parameters: string[]; -// } +export interface MachineLearning { + ID?: number; + type: MLTypes; + parameters?: string[]; +} // /** Interface for what the JSON needs for link predicition */ // export interface LinkPrediction { diff --git a/libs/shared/lib/querybuilder/model/logic/graphFunctions.tsx b/libs/shared/lib/querybuilder/model/logic/graphFunctions.tsx deleted file mode 100644 index 9ba0343f8eb8cb6a4de3e81c8c12d003ead26c38..0000000000000000000000000000000000000000 --- a/libs/shared/lib/querybuilder/model/logic/graphFunctions.tsx +++ /dev/null @@ -1,146 +0,0 @@ -// TODO; move each to its own pill & logic (allowing for more complex and modular functions) -// /** -// * This program has been developed by students from the bachelor Computer Science at -// * Utrecht University within the Software Project course. -// * © Copyright Utrecht University (Department of Information and Computing Sciences) -// */ - -// /** What functions exist -// * Default is for the functions in the function bar that don't exist yet. -// */ -export enum GraphFunctionTypes { - // GroupBy = 'groupBy', - link = 'linkPrediction', - communityDetection = 'communityDetection', - centrality = 'centrality', - shortestPath = 'shortestPath', - default = 'default', -} - -// export enum FunctionArgTypes { -// group = 'group', -// by = 'by', -// relation = 'relation', -// modifier = 'modifier', -// constraints = 'constraints', -// result = 'result', -// ID1 = 'ID1', -// ID2 = 'ID2', -// } -// /** All arguments that groupby pill needs */ -// export const DefaultGroupByArgs: FunctionArgs = { -// group: { displayName: 'Group', connectable: true, value: '', visible: true }, -// by: { displayName: 'By', connectable: true, value: '_id', visible: true }, -// relation: { -// displayName: 'On', -// connectable: true, -// value: undefined, -// visible: true, -// }, -// modifier: { -// displayName: 'Modifier: ', -// connectable: false, -// value: '', -// visible: true, -// }, -// constraints: { -// displayName: 'Constraints: ', -// connectable: true, -// value: undefined, -// visible: true, -// }, -// result: { -// displayName: 'Result: ', -// connectable: true, -// value: undefined, -// visible: true, -// }, -// }; -// /** All arguments that linkprediction pill needs */ -// export const DefaultLinkPredictionArgs: FunctionArgs = { -// linkprediction: { -// //currently the querybuilder shows this name instead of the display name that needs to be changed. -// displayName: 'linkprediction', -// connectable: false, -// value: undefined, -// visible: true, -// }, -// }; - -// /** All arguments that CommunictyDetection pill needs */ -// export const DefaultCommunictyDetectionArgs: FunctionArgs = { -// CommunityDetection: { -// displayName: 'CommunityDetection', -// connectable: false, -// value: undefined, -// visible: true, -// }, -// }; - -// /** All arguments that centrality pill needs */ -// export const DefaultCentralityArgs: FunctionArgs = { -// centrality: { -// displayName: 'centrality', -// connectable: false, -// value: undefined, -// visible: true, -// }, -// }; - -// /** All arguments that centrality pill needs */ -// export const DefaultShortestPathArgs: FunctionArgs = { -// shortestPath: { -// displayName: 'shortestPath', -// connectable: false, -// value: undefined, -// visible: true, -// }, -// }; - -// // TODO: fix this to somehow make use of the enum -// /** Returns the correct arguments depending on the type */ -// export const DefaultFunctionArgs: { [type: string]: FunctionArgs } = { -// groupBy: DefaultGroupByArgs, -// linkPrediction: DefaultLinkPredictionArgs, -// communityDetection: DefaultCommunictyDetectionArgs, -// centrality: DefaultCentralityArgs, -// shortestPath: DefaultShortestPathArgs, -// }; - -/** Interface for what function descriptions need */ -export interface GraphFunctionDescription { - name: string; - type: GraphFunctionTypes; - description: string; -} - -/** All available functions in the function bar. */ -export const GraphFunctions: Record<string, GraphFunctionDescription> = { - centrality: { - name: 'centrality', - type: GraphFunctionTypes.centrality, - description: 'W.I.P. Shows the importance of nodes', - }, - communityDetection: { - name: 'Community Detection', - type: GraphFunctionTypes.communityDetection, - description: 'Group entities connected by a relation based on how interconnected they are.', - }, - // groupBy: { - // name: 'Group By', - // type: GraphFunctionTypes.GroupBy, - // description: - // 'W.I.P. Per entity of type A, generate aggregate statistics of an attribute of either all links of a relation, or all nodes of an entity of type B connected to the type A entity by a relation.', - // }, - link: { - name: 'Link Prediction', - type: GraphFunctionTypes.link, - description: - 'For each pair of entities from a given type, determine the overlap between nodes they are connect to by a given relation.', - }, - shortestPath: { - name: 'shortestPath', - type: GraphFunctionTypes.shortestPath, - description: 'W.I.P. shortest path. Shows the shortest path between nodes', - }, -}; diff --git a/libs/shared/lib/querybuilder/model/logic/index.ts b/libs/shared/lib/querybuilder/model/logic/index.ts index 01d4062551732c0ae3a205cdc488389445e92b48..eaaa301bb03bc691e2457e6a5ab64b1e9fa1b665 100644 --- a/libs/shared/lib/querybuilder/model/logic/index.ts +++ b/libs/shared/lib/querybuilder/model/logic/index.ts @@ -24,7 +24,6 @@ export const AllLogicMap: Record<string, AllLogicDescriptions> = { ...Object.fromEntries(Object.values(StringFunctions).map((x) => [x.key, x])), }; -export * from './graphFunctions'; export * from './numberFunctions'; export * from './numberFilters'; export * from './stringFunctions'; diff --git a/libs/shared/lib/querybuilder/panel/querybuilder.tsx b/libs/shared/lib/querybuilder/panel/querybuilder.tsx index d25ee62444aa69b0ac51ad06bf010d171100f954..912712627b3514c3e048538c22d497117f526300 100644 --- a/libs/shared/lib/querybuilder/panel/querybuilder.tsx +++ b/libs/shared/lib/querybuilder/panel/querybuilder.tsx @@ -42,14 +42,23 @@ import { InputNodeType } from '../model/logic/general'; import { ConnectionDragLine, ConnectionLine, EntityFlowElement, RelationPill } from '../pills'; import LogicPill from '../pills/customFlowPills/logicpill/logicpill'; import { dragPillStarted, movePillTo } from '../pills/dragging/dragPill'; -import { QueryBuilderModal } from './querypopup'; -import { QuerySidePanel, QueryBuilderProps } from './querysidepanel'; +import DifferenceIcon from '@mui/icons-material/Difference'; +import LightbulbIcon from '@mui/icons-material/Lightbulb'; +import { Dialog } from '../../components/Dialog'; +import { QueryBuilderLogicPillsPanel } from './querysidepanel/queryBuilderLogicPillsPanel'; +import { QueryBuilderMLPanel } from './querysidepanel/queryBuilderMLPanel'; +import { Popup } from '../../components/Popup'; + +export type QueryBuilderProps = { + onRunQuery?: () => void; +}; /** * This is the main querybuilder component. It is responsible for holding all pills and fire off the visual part of the querybuilder panel logic */ export const QueryBuilderInner = (props: QueryBuilderProps) => { - const [openPopup, setOpenPopup] = useState(false); + const [openLogicDialog, setOpenLogicDialog] = useState(false); + const [openMLDialog, setOpenMLDialog] = useState(false); const reactFlowWrapper = useRef<HTMLDivElement>(null); var nodeTypes = { @@ -193,8 +202,6 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { if (data.length == 0 || !reactFlow) return; const dragData = JSON.parse(data); - console.log(reactFlowWrapper); - const bounds = reactFlowWrapper?.current?.getBoundingClientRect() || { x: 0, y: 0 }; const position = reactFlow.project({ //TODO: this position should be centre of entity, rather than topleft @@ -229,17 +236,6 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { }, schema.getEdgeAttribute(dragData.label, 'attributes') ); - // const leftEntity = graphologyGraph.addPill2Graphology( - // { type: QueryElementTypes.Entity, ...RelationPosToFromEntityPos(position), name: dragData.from }, - // schema.getNodeAttribute(dragData.from, 'attributes') - // ); - // const rightEntity = graphologyGraph.addPill2Graphology( - // { type: QueryElementTypes.Entity, ...RelationPosToToEntityPos(position), name: dragData.to }, - // schema.getNodeAttribute(dragData.to, 'attributes') - // ); - - // graphologyGraph.addEdge2Graphology(leftEntity, relation); - // graphologyGraph.addEdge2Graphology(relation, rightEntity); if (config.autoSendQueries) { // sendQuery(); @@ -247,21 +243,11 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { dispatch(setQuerybuilderNodes(graphologyGraph.export())); break; - // Creates an attribute element with the correct dataType - // case QueryElementTypes.Attribute: - // createNodeFromSchema( - // QueryElementTypes.Attribute, - // position, - // dragData.name, - // dragData.datatype - // ); - // break; default: const logic = AllLogicMap[dragData.value.key]; const firstLeftLogicInput = logic.inputs?.[0]; if (!firstLeftLogicInput) return; - // logicAttributes[0].handles = [connectingNodeId.current.handleId]; const logicNode = graphologyGraph.addLogicPill2Graphology({ name: dragData.value.name, type: QueryElementTypes.Logic, @@ -310,7 +296,6 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { let node = graphologyGraph.getNodeAttributes(params.nodeId); const handleData = toHandleData(params.handleId); - // console.log(attributeName, attributeType, node.attributes?.filter((a) => a.name === attributeName)?.[0]); connectingNodeId.current = { params, @@ -347,47 +332,59 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { const position = reactFlow.project({ x: clientX, y: clientY }); if (connectingNodeId?.current) connectingNodeId.current.position = position; - setOpenPopup(true); + setOpenLogicDialog(true); } }, [reactFlow.project] ); const onNewNodeFromPopup = (value: AllLogicDescriptions) => { - setOpenPopup(false); - if (connectingNodeId.current === null || connectingNodeId.current?.params?.handleId == null) return; - const params = connectingNodeId.current.params; - const position = connectingNodeId.current.position; - - // console.log('onNewNodeFromPopup', value, type, params, position); + console.log('onNewNodeFromPopup', value); const logic = AllLogicMap[value.key]; const firstLeftLogicInput = logic.inputs?.[0]; if (!firstLeftLogicInput) return; - // logicAttributes[0].handles = [connectingNodeId.current.handleId]; - const logicNode = graphologyGraph.addLogicPill2Graphology({ - name: value.name, - type: QueryElementTypes.Logic, - x: position.x, - y: position.y, - logic: logic, - }); - - if (!logicNode?.id) throw new Error('Logic node has no id'); - if (!logicNode?.name) throw new Error('Logic node has no name'); - if (!params.handleId) throw new Error('Connection has no source or target'); - - const sourceHandleData = toHandleData(params.handleId); - graphologyGraph.addEdge2Graphology( - graphologyGraph.getNodeAttributes(params.nodeId), - graphologyGraph.getNodeAttributes(logicNode.id), - { type: 'connection' }, - { sourceHandleName: sourceHandleData.attributeName, targetHandleName: firstLeftLogicInput.name } - ); + if (connectingNodeId.current === null || connectingNodeId.current?.params?.handleId == null) { + const bounds = reactFlowWrapper?.current?.getBoundingClientRect() || { x: 0, y: 0, width: 0, height: 0 }; + + const logicNode = graphologyGraph.addLogicPill2Graphology({ + name: value.name, + type: QueryElementTypes.Logic, + x: bounds.width / 2, + y: bounds.height / 2, + logic: logic, + }); + } else { + const params = connectingNodeId.current.params; + const position = connectingNodeId.current.position; + + // console.log('onNewNodeFromPopup', value, type, params, position); + + const logicNode = graphologyGraph.addLogicPill2Graphology({ + name: value.name, + type: QueryElementTypes.Logic, + x: position.x, + y: position.y, + logic: logic, + }); + + if (!logicNode?.id) throw new Error('Logic node has no id'); + if (!logicNode?.name) throw new Error('Logic node has no name'); + if (!params.handleId) throw new Error('Connection has no source or target'); + + const sourceHandleData = toHandleData(params.handleId); + graphologyGraph.addEdge2Graphology( + graphologyGraph.getNodeAttributes(params.nodeId), + graphologyGraph.getNodeAttributes(logicNode.id), + { type: 'connection' }, + { sourceHandleName: sourceHandleData.attributeName, targetHandleName: firstLeftLogicInput.name } + ); + } dispatch(setQuerybuilderNodes(graphologyGraph.export())); - setOpenPopup(false); + setOpenLogicDialog(false); + connectingNodeId.current = null; }; const onEdgeUpdateStart = useCallback(() => { @@ -426,7 +423,6 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { graphologyGraph.dropEdge(edge.id); } dispatch(setQuerybuilderNodes(graphologyGraph.export())); - // setEdges((eds) => eds.filter((e) => e.id !== edge.id)); } isEdgeUpdating.current = false; }, @@ -435,21 +431,28 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { const onNodeContextMenu = (event: React.MouseEvent, node: Node) => { event.preventDefault(); - // console.log('context menu', node); graphologyGraph.dropNode(node.id); dispatch(setQuerybuilderNodes(graphologyGraph.export())); }; return ( <div ref={reactFlowWrapper} className="h-full w-full"> - <QueryBuilderModal - open={openPopup} - handle={connectingNodeId.current?.attribute.handleData} + <Dialog + open={openLogicDialog} onClose={() => { - setOpenPopup(false); + setOpenLogicDialog(false); }} - onClick={onNewNodeFromPopup} - /> + > + <QueryBuilderLogicPillsPanel + onClick={onNewNodeFromPopup} + title="Logic Pills usable by the node" + className="min-h-[75vh] max-h-[75vh]" + onDrag={() => {}} + /> + </Dialog> + <Popup open={openMLDialog} vanchor="right"> + <QueryBuilderMLPanel /> + </Popup> <ReactFlow edges={elements.edges} nodes={elements.nodes} @@ -492,10 +495,6 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { title={'Export querybuilder'} onClick={(event) => { event.stopPropagation(); - // this.setState({ - // ...this.state, - // exportMenuAnchor: event.currentTarget, - // }); }} > <ExportIcon /> @@ -505,17 +504,13 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { title={'Other settings'} onClick={(event) => { event.stopPropagation(); - // this.setState({ - // ...this.state, - // settingsMenuAnchor: event.currentTarget, - // }); }} > <SettingsIcon /> </ControlButton> <ControlButton className={styles.buttons} - title={'Run Query'} + title={'Re-Run Query'} onClick={(event) => { event.stopPropagation(); if (props.onRunQuery) props.onRunQuery(); @@ -523,6 +518,26 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { > <CachedIcon /> </ControlButton> + <ControlButton + className={styles.buttons + (openLogicDialog ? ' btn-active' : '')} + title={'Logic Pills'} + onClick={(event) => { + event.stopPropagation(); + setOpenLogicDialog(!openLogicDialog); + }} + > + <DifferenceIcon /> + </ControlButton> + <ControlButton + className={styles.buttons + (openMLDialog ? ' btn-active' : '')} + title={'Machine Learning'} + onClick={(event) => { + event.stopPropagation(); + setOpenMLDialog(!openMLDialog); + }} + > + <LightbulbIcon /> + </ControlButton> </Controls> </ReactFlow> </div> @@ -532,10 +547,10 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { export const QueryBuilder = (props: QueryBuilderProps) => { return ( <div className="flex w-full h-full"> - <QuerySidePanel title="Query Panel" draggable /> <ReactFlowProvider> <QueryBuilderInner {...props} /> </ReactFlowProvider> + {/* <QuerySidePanel title="Query Panel" draggable /> */} </div> ); }; diff --git a/libs/shared/lib/querybuilder/panel/querypopup.tsx b/libs/shared/lib/querybuilder/panel/querypopup.tsx deleted file mode 100644 index 1749d96977f42603dfc96eeb43aa9c0c122ceb99..0000000000000000000000000000000000000000 --- a/libs/shared/lib/querybuilder/panel/querypopup.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React, { useEffect } from 'react'; -import Draggable from 'react-draggable'; -import { AllLogicDescriptions, MathFilters, NumberFunctions, QueryGraphEdgeHandle, StringFilters, StringFunctions } from '../model'; -import { InputNodeType } from '../model/logic/general'; -import { QuerySidePanel } from './querysidepanel'; - -export function QueryBuilderModal(props: { - open: boolean; - handle: QueryGraphEdgeHandle | undefined; - onClose: () => void; - onClick: (item: AllLogicDescriptions) => void; -}) { - const modal = React.useRef<HTMLDialogElement>(null); - - useEffect(() => { - if (props.open) { - modal.current?.showModal(); - } else { - modal.current?.close(); - } - }, [props.open]); - - function onClose() { - props.onClose(); - } - - return ( - <dialog id="my_modal_1" className="modal " ref={modal} onClose={() => onClose()}> - <form method="dialog" className="modal-box h-auto"> - <QuerySidePanel title="Logic Pills usable by the node" filter={props.handle?.attributeType} onClick={props.onClick} /> - </form> - - <form method="dialog" className="modal-backdrop"> - <button>close</button> - </form> - </dialog> - ); -} diff --git a/libs/shared/lib/querybuilder/panel/querysidepanel.tsx b/libs/shared/lib/querybuilder/panel/querysidepanel/queryBuilderLogicPillsPanel.tsx similarity index 87% rename from libs/shared/lib/querybuilder/panel/querysidepanel.tsx rename to libs/shared/lib/querybuilder/panel/querysidepanel/queryBuilderLogicPillsPanel.tsx index 1ce5fc49b724d1810acc8ca591f356b46c402a6b..72a4aab296c666754a50ec1fb71c54d3250ccd15 100644 --- a/libs/shared/lib/querybuilder/panel/querysidepanel.tsx +++ b/libs/shared/lib/querybuilder/panel/querysidepanel/queryBuilderLogicPillsPanel.tsx @@ -1,16 +1,17 @@ -import React, { useState } from 'react'; -import { AllLogicDescriptions, AllLogicMap } from '../model'; import FilterAltIcon from '@mui/icons-material/FilterAlt'; import FunctionsIcon from '@mui/icons-material/Functions'; import GridOnIcon from '@mui/icons-material/GridOn'; import NumbersIcon from '@mui/icons-material/Numbers'; import AbcIcon from '@mui/icons-material/Abc'; +import { useState } from 'react'; +import { AllLogicDescriptions, AllLogicMap } from '../../model'; -export const QuerySidePanel = (props: { - title: string; +export const QueryBuilderLogicPillsPanel = (props: { + className?: string; + title?: string; filter?: string; - draggable?: boolean; - onClick?: (item: AllLogicDescriptions) => void; + onClick: (item: AllLogicDescriptions) => void; + onDrag?: (item: AllLogicDescriptions) => void; }) => { const dataOps = [ { @@ -41,7 +42,6 @@ export const QuerySidePanel = (props: { icon: <AbcIcon fontSize="small" />, }, ]; - const filter = props.filter === 'number' ? 'float' : props.filter; const [selectedOp, setSelectedOp] = useState(dataOps.findIndex((item) => item.title === filter) || -1); const [selectedType, setSelectedType] = useState(dataTypes.findIndex((item) => item.title === filter) || -1); @@ -51,11 +51,12 @@ export const QuerySidePanel = (props: { event.dataTransfer.setData('application/reactflow', JSON.stringify({ value })); event.dataTransfer.effectAllowed = 'move'; + if (props.onDrag) props.onDrag(value); }; return ( - <div className="bg-offwhite-100 h-flex flex flex-col gap-2"> - <h2 className="menu-title">{props.title}</h2> + <div className={props.className + ' card'}> + {props.title && <h1 className="card-title mb-7">{props.title}</h1>} <div className="btn-group w-full justify-center"> {dataOps.map((item, index) => ( <div key={item.title} data-tip={item.description} className="tooltip tooltip-top m-0 p-0"> @@ -85,8 +86,8 @@ export const QuerySidePanel = (props: { </div> ))} </div> - <div className="overflow-x-hidden flex-shrink flex-grow-0 w-full"> - <ul className="menu bg-base-200 p-0 [&_li>*]:rounded-none h-full w-full"> + <div className="overflow-x-hidden bg-base-200 h-full w-full mt-1"> + <ul className="menu bg-base-200 p-0 [&_li>*]:rounded-none w-full pb-10"> {Object.values(AllLogicMap) .filter((item) => !filter || item.key.toLowerCase().includes(filter === 'number' ? 'float' : 'string')) .filter((item) => selectedOp === -1 || item.key.toLowerCase().includes(dataOps?.[selectedOp].title)) @@ -97,9 +98,9 @@ export const QuerySidePanel = (props: { data-tip={item.description} className="flex before:w-[10rem] before:text-center tooltip tooltip-bottom text-start " onDragStart={(e) => onDragStart(e, item)} - draggable={props.draggable} + draggable={true} onClick={() => { - if (!props.draggable && props?.onClick) props.onClick(item); + props.onClick(item); }} > {item.icon && ( @@ -123,7 +124,3 @@ export const QuerySidePanel = (props: { </div> ); }; - -export type QueryBuilderProps = { - onRunQuery?: () => void; -}; diff --git a/libs/shared/lib/querybuilder/panel/querysidepanel/queryBuilderMLPanel.tsx b/libs/shared/lib/querybuilder/panel/querysidepanel/queryBuilderMLPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5ba2dfec5fd9efe754c3d212f430ab973e341aa3 --- /dev/null +++ b/libs/shared/lib/querybuilder/panel/querysidepanel/queryBuilderMLPanel.tsx @@ -0,0 +1,82 @@ +import { useAppDispatch, useML } from '@graphpolaris/shared/lib/data-access'; +import { + setCentralityEnabled, + setCommunityDetectionEnabled, + setLinkPredictionEnabled, + setShortestPathEnabled, +} from '@graphpolaris/shared/lib/data-access/store/mlSlice'; + +export const QueryBuilderMLPanel = () => { + const dispatch = useAppDispatch(); + const ml = useML(); + + return ( + <div className="card w-max"> + <div className="card-body p-5"> + <h2>Machine Learning Options</h2> + <div className="form-control"> + <label className="label cursor-pointer gap-2 w-fit"> + <input + type="checkbox" + checked={ml.linkPrediction.enabled} + className="checkbox checkbox-sm" + onChange={(e) => dispatch(setLinkPredictionEnabled(e.target.checked))} + /> + <span className="label-text">Link Prediction</span> + </label> + {ml.linkPrediction.enabled && ml.linkPrediction.result && <span># of predictions: {ml.linkPrediction.result.length}</span>} + {ml.linkPrediction.enabled && !ml.linkPrediction.result && <span>Loading...</span>} + </div> + <div className="form-control"> + <label className="label cursor-pointer gap-2 w-fit"> + <input + type="checkbox" + checked={ml.centrality.enabled} + className="checkbox checkbox-sm" + onChange={(e) => dispatch(setCentralityEnabled(e.target.checked))} + /> + <span className="label-text">Centrality</span> + </label> + {ml.centrality.enabled && Object.values(ml.centrality.result).length > 0 && ( + <span> + sum of centers: + {Object.values(ml.centrality.result) + .reduce((a, b) => b + a) + .toFixed(2)} + </span> + )} + {ml.centrality.enabled && Object.values(ml.centrality.result).length === 0 && <span>No Centers Found</span>} + </div> + <div className="form-control"> + <label className="label cursor-pointer gap-2 w-fit"> + <input + type="checkbox" + checked={ml.communityDetection.enabled} + className="checkbox checkbox-sm" + onChange={(e) => dispatch(setCommunityDetectionEnabled(e.target.checked))} + /> + <span className="label-text">Community Detection</span> + </label> + {ml.communityDetection.enabled && ml.communityDetection.result && ( + <span># of communities: {ml.communityDetection.result.length}</span> + )} + {ml.communityDetection.enabled && !ml.communityDetection.result && <span>Loading...</span>} + </div> + <div className="form-control"> + <label className="label cursor-pointer gap-2 w-fit"> + <input + type="checkbox" + checked={ml.shortestPath.enabled} + className="checkbox checkbox-sm" + onChange={(e) => dispatch(setShortestPathEnabled(e.target.checked))} + /> + <span className="label-text">Shortest Path</span> + </label> + {ml.shortestPath.enabled && ml.shortestPath.result?.length > 0 && <span># of hops: {ml.shortestPath.result.length}</span>} + {ml.shortestPath.enabled && !ml.shortestPath.srcNode && <span>Please select source node</span>} + {ml.shortestPath.enabled && ml.shortestPath.srcNode && !ml.shortestPath.trtNode && <span>Please select target node</span>} + </div> + </div> + </div> + ); +}; diff --git a/libs/shared/lib/querybuilder/query-utils/query2backend.spec.ts b/libs/shared/lib/querybuilder/query-utils/query2backend.spec.ts index c82c1f65581f799e376a79be6cad5f4cb8383c3e..3648e10babc7ef4889fc0bef4358d0a877c5a6bb 100644 --- a/libs/shared/lib/querybuilder/query-utils/query2backend.spec.ts +++ b/libs/shared/lib/querybuilder/query-utils/query2backend.spec.ts @@ -763,6 +763,7 @@ describe('QueryUtils with Logic', () => { const expected: BackendQueryFormat = { ...defaultQuery, logic: ['==', '@e1.age', 0], + machineLearning: [], query: [ { ID: 'path_0', @@ -830,6 +831,7 @@ describe('QueryUtils with Logic', () => { const expected: BackendQueryFormat = { ...defaultQuery, logic: ['==', ['+', '@e1.age', '@e2.age'], 0], + machineLearning: [], query: [ { ID: 'path_0', @@ -893,6 +895,7 @@ describe('QueryUtils with Logic', () => { const expected: BackendQueryFormat = { ...defaultQuery, logic: ['<', ['Avg', '@e1.age'], 0], + machineLearning: [], query: [ { ID: 'path_0', @@ -941,6 +944,7 @@ describe('QueryUtils with Logic', () => { const expected: BackendQueryFormat = { ...defaultQuery, logic: ['<', '@e1.age', 5], + machineLearning: [], query: [ { ID: 'path_0', @@ -958,3 +962,77 @@ describe('QueryUtils with Logic', () => { expect(ret).toMatchObject(expected); }); }); + +it('should no connections between entities and relations', () => { + const graph = new QueryMultiGraphology(); + + const e1 = graph.addPill2Graphology( + { + id: 'e1', + type: QueryElementTypes.Entity, + x: 100, + y: 100, + name: 'Airport 1', + }, + [{ name: 'age', type: 'string' }] + ); + + const e2 = graph.addPill2Graphology( + { + id: 'e2', + type: QueryElementTypes.Entity, + x: 100, + y: 100, + name: 'Airport 2', + }, + [{ name: 'age', type: 'string' }] + ); + const r1 = graph.addPill2Graphology( + { + id: 'r1', + type: QueryElementTypes.Relation, + x: 100, + y: 100, + name: 'Relation 1', + }, + [{ name: 'age', type: 'string' }] + ); + + const expected: BackendQueryFormat = { + ...defaultQuery, + logic: undefined, + machineLearning: [], + query: [ + { + ID: 'path_0', + node: { + ID: 'e1', + label: 'Airport 1', + relation: undefined, + }, + }, + { + ID: 'path_1', + node: { + ID: 'e2', + label: 'Airport 2', + relation: undefined, + }, + }, + { + ID: 'path_2', + node: { + relation: { + ID: 'r1', + label: 'Relation 1', + direction: 'TO', + node: {}, + }, + }, + }, + ], + }; + + let ret = Query2BackendQuery('database', graph.export()); + expect(ret).toMatchObject(expected); +}); diff --git a/libs/shared/lib/querybuilder/query-utils/query2backend.ts b/libs/shared/lib/querybuilder/query-utils/query2backend.ts index 50245ef1df77c10ba3e7ed5ec6a3f8b59132ae09..9e24d3f55a466cfd0da7b932ef082f7fa8bced3a 100644 --- a/libs/shared/lib/querybuilder/query-utils/query2backend.ts +++ b/libs/shared/lib/querybuilder/query-utils/query2backend.ts @@ -1,6 +1,6 @@ import { EntityNodeAttributes, LogicNodeAttributes, QueryGraphNodes, RelationNodeAttributes } from '../model/graphology/model'; import { QueryMultiGraph } from '../model/graphology/utils'; -import { BackendQueryFormat, NodeStruct, QueryStruct, RelationStruct } from '../model/BackendQueryFormat'; +import { BackendQueryFormat, MachineLearning, NodeStruct, QueryStruct, RelationStruct } from '../model/BackendQueryFormat'; import { Handles, QueryElementTypes, toHandleData } from '../model'; import { get } from 'http'; import { SerializedEdge, SerializedNode } from 'graphology-types'; @@ -9,6 +9,7 @@ import { hasCycle } from 'graphology-dag'; import Graph, { MultiGraph } from 'graphology'; import { allSimplePaths } from 'graphology-simple-path'; import { AllLogicStatement, ReferenceStatement } from '../model/logic/general'; +import { ML, MLTypes, mlDefaultState } from '../../data-access/store/mlSlice'; // export type QueryI { @@ -162,15 +163,23 @@ function queryLogicUnion(graphLogicChunks: AllLogicStatement[]): AllLogicStateme export function Query2BackendQuery( databaseName: string, graph: QueryMultiGraph, + ml: ML = mlDefaultState, options: Query2BackendQueryOptions = {} ): BackendQueryFormat { let query: BackendQueryFormat = { databaseName: databaseName, query: [], + machineLearning: [], limit: options.limit || 500, return: ['*'], // TODO }; + Object.keys(ml).forEach((mlType) => { + if (ml[mlType as MLTypes].enabled) { + query.machineLearning.push({ type: mlType as MLTypes }); + } + }); + let entities = graph.nodes.filter((n) => n?.attributes?.type === QueryElementTypes.Entity) as SerializedNode<EntityNodeAttributes>[]; let relations = graph.nodes.filter((n) => n?.attributes?.type === QueryElementTypes.Relation) as SerializedNode<RelationNodeAttributes>[]; @@ -210,10 +219,10 @@ export function Query2BackendQuery( // start with all entities that have no left handle, which means it "starts" a logic chunkOffset += traverseEntityRelationPaths(entity, graphSequenceChunks, i + chunkOffset, graph, entities, relations); }); - if (entitiesEmptyLeftHandle.length > 0) chunkOffset++; - relationsEmptyLeftHandle.map((entity, i) => { + if (entitiesEmptyLeftHandle.length > 0) chunkOffset += entitiesEmptyLeftHandle.length; + relationsEmptyLeftHandle.map((relation, i) => { // then, for all relations that have no left handle, since they weren't accounted by the loop above - chunkOffset += traverseEntityRelationPaths(entity, graphSequenceChunks, i + chunkOffset, graph, entities, relations); + chunkOffset += traverseEntityRelationPaths(relation, graphSequenceChunks, i + chunkOffset, graph, entities, relations); }); graphSequenceChunks.forEach((chunkSequence, i) => { chunkSequence.forEach((chunk, j) => { @@ -236,6 +245,8 @@ export function Query2BackendQuery( // Logic pathways extraction: now traverse the graph again though the logic components to construct the logic chunks // console.log('logics', logics); + // console.log('entitiesEmptyLeftHandle', entitiesEmptyLeftHandle); + // console.log('relationsEmptyLeftHandle', relationsEmptyLeftHandle); // console.log('graphSequenceChunks', graphSequenceChunks); // console.log('graphLogicChunks', graphLogicChunks); // console.log('logicsRightHandleConnectedOutside', logicsRightHandleConnectedOutside); diff --git a/libs/shared/lib/schema/panel/schema.tsx b/libs/shared/lib/schema/panel/schema.tsx index def690e907db2e986a0a7af772863c92bd2a031f..ac1f856e87c379db3bafba5d36d9731277cef2be 100644 --- a/libs/shared/lib/schema/panel/schema.tsx +++ b/libs/shared/lib/schema/panel/schema.tsx @@ -98,7 +98,6 @@ export const Schema = (props: Props) => { updateLayout(); const expandedSchema = schemaExpandRelation(schemaGraphology); layout.current?.layout(expandedSchema); - console.log(expandedSchema); const schemaFlow = schemaGraphology2Reactflow(expandedSchema); @@ -129,7 +128,6 @@ export const Schema = (props: Props) => { // console.log(nodes, edges); useEffect(() => { - console.log(settingsIconRef.current?.getBoundingClientRect()); if (dialogRef.current && settingsIconRef.current) { dialogRef.current.style.top = `${settingsIconRef.current.getBoundingClientRect().top}px`; dialogRef.current.style.left = `${settingsIconRef.current.getBoundingClientRect().left + 30}px`; diff --git a/libs/shared/lib/schema/panel/schemaDialog.tsx b/libs/shared/lib/schema/panel/schemaDialog.tsx index e2bb5ffd846143403d1c0de8ee2c0866bca16d92..1784d946058f05f47bba634d9e1b6af8930c6ee2 100644 --- a/libs/shared/lib/schema/panel/schemaDialog.tsx +++ b/libs/shared/lib/schema/panel/schemaDialog.tsx @@ -7,7 +7,7 @@ export const SchemaDialog = React.forwardRef<HTMLDivElement, DialogProps>((props return ( <> {props.open && ( - <div className="absolute top-0 left-10 opacity-100 transition-opacity group-hover:opacity-100 z-50 " ref={ref}> + <div className="absolute opacity-100 transition-opacity group-hover:opacity-100 z-50 " ref={ref}> <div className="card absolute card-bordered bg-white rounded-none"> <form className="card-body px-0 w-72 py-5" @@ -24,15 +24,15 @@ export const SchemaDialog = React.forwardRef<HTMLDivElement, DialogProps>((props <div className="divider m-0"></div> <div className="form-control px-5"> <label className="label cursor-pointer w-fit gap-2 px-0 py-1"> - <input type="checkbox" checked={true} className="checkbox checkbox-xs" /> + <input type="checkbox" checked={true} onChange={(e) => {}} className="checkbox checkbox-xs" /> <span className="label-text">Points</span> </label> <label className="label cursor-pointer w-fit gap-2 px-0 py-1"> - <input type="checkbox" checked={true} className="checkbox checkbox-xs" /> + <input type="checkbox" checked={true} onChange={(e) => {}} className="checkbox checkbox-xs" /> <span className="label-text">Line</span> </label> <label className="label cursor-pointer w-fit gap-2 px-0 py-1"> - <input type="checkbox" checked={true} className="checkbox checkbox-xs" /> + <input type="checkbox" checked={true} onChange={(e) => {}} className="checkbox checkbox-xs" /> <span className="label-text">Line</span> </label> </div> @@ -41,7 +41,7 @@ export const SchemaDialog = React.forwardRef<HTMLDivElement, DialogProps>((props <label className="label"> <span className="label-text">Opacity</span> </label> - <input type="range" min={0} max="100" value="40" className="range range-sm" /> + <input type="range" min={0} max="100" value="40" onChange={(e) => {}} className="range range-sm" /> </div> <div className="divider m-0"></div> <div className="form-control px-5"> diff --git a/libs/shared/lib/vis/nodelink/Types.tsx b/libs/shared/lib/vis/nodelink/Types.tsx index 8df3a97da2430966c7940f46ec997aecf017bc30..4103fa516745ce12b044603549e0b17d6ff89220 100644 --- a/libs/shared/lib/vis/nodelink/Types.tsx +++ b/libs/shared/lib/vis/nodelink/Types.tsx @@ -10,10 +10,10 @@ import * as PIXI from 'pixi.js'; export type GraphType = { nodes: NodeType[]; links: LinkType[]; - linkPrediction: boolean; - shortestPath: boolean; - communityDetection: boolean; - numberOfMlClusters?: number; + // linkPrediction?: boolean; + // shortestPath?: boolean; + // communityDetection?: boolean; + // numberOfMlClusters?: number; }; /** The interface for a node in the node-link diagram */ @@ -34,6 +34,8 @@ export interface NodeType extends d3.SimulationNodeDatum { gfxtext?: PIXI.Text; gfxAttributes?: PIXI.Graphics; selected?: boolean; + isShortestPathSource?: boolean; + isShortestPathTarget?: boolean; index?: number; // The text that will be shown on top of the node if selected. @@ -61,9 +63,12 @@ export interface NodeType extends d3.SimulationNodeDatum { /** The interface for a link in the node-link diagram */ export interface LinkType extends d3.SimulationLinkDatum<NodeType> { // The thickness of a line + id: string; value: number; // To check if an edge is calculated based on a ML algorithm mlEdge: boolean; + color: number; + alpha?: number; } /**collectionNode holds 1 entry per node kind (so for example a MockNode with name "parties" and all associated attributes,) */ diff --git a/libs/shared/lib/vis/nodelink/components/NLForce.tsx b/libs/shared/lib/vis/nodelink/components/NLForce.tsx index 54bdd14f7a0efa419993276651d2d8e04ebcf8c5..567e82cfbe5339967c836df519ff0bf9191cbca3 100644 --- a/libs/shared/lib/vis/nodelink/components/NLForce.tsx +++ b/libs/shared/lib/vis/nodelink/components/NLForce.tsx @@ -7,33 +7,20 @@ export const simulation = forceSimulation<NodeType, LinkType>(); /** StartSimulation starts the d3 forcelink simulation. This has to be called after the simulation is initialized. */ export function startSimulation(graph: GraphType, windowSize: { width: number; height: number }): void { simulation - .alpha(1.8) + .alpha(2) + .alphaDecay(0.02) .force( 'link', - forceLink() + forceLink<NodeType, LinkType>() .id((d: any) => d.id) - .strength(1) + .strength((link, i, links) => (link.mlEdge ? 0.001 : 1)) .distance(25) ) - .force('charge', forceManyBody().strength(-1)) - .force('center', forceCenter(windowSize.width / 2, windowSize.height / 2).strength(0.5)) - .force('collide', forceCollide().strength(1).radius(5).iterations(1)) // Force that avoids circle overlapping - .force('attract', forceRadial(50, windowSize.width / 2, windowSize.height / 2).strength(0.005)); + .force('charge', forceManyBody().strength(-4).distanceMax(200).distanceMin(0)) + .force('center', forceCenter(windowSize.width / 2, windowSize.height / 2).strength(0.02)) + .force('collide', forceCollide().strength(0.2).radius(15).iterations(1)) // Force that avoids circle overlapping + .force('attract', forceRadial(50, windowSize.width / 2, windowSize.height / 2).strength(0.002)); simulation.nodes(graph.nodes); simulation.force<d3.ForceLink<NodeType, LinkType>>('link')?.links(graph.links); simulation.alphaTarget(0).restart(); } - -// export const useNLForce = ({ graph, windowSize }: Props) => { -// useEffect(() => { -// if (graph && graph.nodes.length > 0 && graph.links.length > 0 && windowSize.width && windowSize.height) { -// startSimulation(graph); -// } -// }, [graph, windowSize.width, windowSize.height]); - -// function refresh(): void { -// simulation.restart(); -// } - -// return { simulation, refresh, startSimulation }; -// }; diff --git a/libs/shared/lib/vis/nodelink/components/NLMachineLearning.tsx b/libs/shared/lib/vis/nodelink/components/NLMachineLearning.tsx index a06ebc311d45fdbe3a00d7d02bd52b207303ac96..f6f46d863c1d2bdabf304f6e4b4929bee4d701c3 100644 --- a/libs/shared/lib/vis/nodelink/components/NLMachineLearning.tsx +++ b/libs/shared/lib/vis/nodelink/components/NLMachineLearning.tsx @@ -2,6 +2,63 @@ import { useState } from 'react'; import { AttributeData, NodeAttributeData } from '../../shared/InputDataTypes'; import { AttributeCategory } from '../../shared/Types'; import { GraphType, LinkType, NodeType } from '../Types'; +import { ML } from '@graphpolaris/shared/lib/data-access/store/mlSlice'; + +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)); + ml.linkPrediction.result.forEach((link) => { + if (allNodeIds.has(link.from) && allNodeIds.has(link.to)) { + const toAdd: LinkType = { + id: link.from + link.to, + source: link.from, + target: link.to, + value: link.attributes.jaccard_coefficient as number, + mlEdge: true, + color: 0x000000, + }; + graph.links.push(toAdd); + } + }); + } + return graph; +} + +export function processCommunityDetection(ml: ML, graph: GraphType): GraphType { + if (ml === undefined || ml.communityDetection === undefined) return graph; + + if (ml.communityDetection.enabled) { + let allNodeIdMap = new Map<string, number>(); + ml.communityDetection.result.forEach((idSet, i) => { + idSet.forEach((id) => { + allNodeIdMap.set(id, i); + }); + }); + + graph.nodes = graph.nodes.map((node, i) => { + if (allNodeIdMap.has(node.id)) { + node.cluster = allNodeIdMap.get(node.id); + } else { + node.cluster = -1; + } + return node; + }); + } else { + graph.nodes = graph.nodes.map((node, i) => { + node.cluster = undefined; + return node; + }); + } + return graph; +} + +export function processML(ml: ML, graph: GraphType): GraphType { + let ret = processLinkPrediction(ml, graph); + ret = processCommunityDetection(ml, ret); + return ret; +} export const useNLMachineLearning = (props: { graph: GraphType; @@ -114,7 +171,7 @@ export const useNLMachineLearning = (props: { /** * resetClusterOfNodes is a function that resets the cluster of the nodes that are being customised by the user, - * after a community detection algorithm, where the cluster of these node could have been changed. + * after a community detection algorithm, where the cluster of these nodes could have been changed. */ const resetClusterOfNodes = (type: number): void => { props.graph.nodes.forEach((node: NodeType) => { diff --git a/libs/shared/lib/vis/nodelink/components/NLPixi.tsx b/libs/shared/lib/vis/nodelink/components/NLPixi.tsx index 3ff52dc93ec380912d2a6ef8277e3c4124006d11..3f236696c4868bbac939348aa05114e2185bbdac 100644 --- a/libs/shared/lib/vis/nodelink/components/NLPixi.tsx +++ b/libs/shared/lib/vis/nodelink/components/NLPixi.tsx @@ -1,22 +1,22 @@ import { GraphType, LinkType, NodeType } from '../Types'; import { tailwindColors } from 'config'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { Application, Circle, Container, FederatedPointerEvent, Graphics, Point, Sprite, Texture, autoDetectRenderer } from 'pixi.js'; +import { ReactEventHandler, useEffect, useMemo, useRef, useState } from 'react'; +import { Application, Circle, Container, FederatedPointerEvent, Graphics, IPointData } from 'pixi.js'; import { binaryColor, nodeColor as nodeColor } from './utils'; import { select, zoom as d3zoom, drag as d3drag } from 'd3'; import * as force from './NLForce'; import { Viewport } from 'pixi-viewport'; -import ResultNodeLinkParserUseCase from '../../shared/ResultNodeLinkParserUseCase'; import { GraphQueryResult, GraphQueryResultFromBackendPayload } from '@graphpolaris/shared/lib/data-access/store/graphQueryResultSlice'; +import { useAppDispatch, useML } from '@graphpolaris/shared/lib/data-access'; +import { ML, setShortestPathSource, setShortestPathTarget } from '@graphpolaris/shared/lib/data-access/store/mlSlice'; +import { parseQueryResult } from './query2NL'; type Props = { - windowSize: { width: number; height: number }; onClick: (node: NodeType) => void; highlightNodes: NodeType[]; currentShortestPathEdges?: LinkType[]; highlightedLinks?: LinkType[]; - jaccardThreshold?: number; - myDiv: HTMLDivElement | null; + graph?: GraphType; }; const app = new Application({ background: 0xffffff, antialias: true, autoDensity: true, eventMode: 'auto' }); @@ -27,44 +27,40 @@ const links = new Container(); // MAIN COMPONENT ////////////////// -export const useNLPixi = (props: Props) => { - const [drag, setDrag] = useState({ x: 0, y: 0 }); +export const NLPixi = (props: Props) => { const nodeMap = useRef(new Map<string, Graphics>()); const linkMap = useRef(new Map<string, Graphics>()); - const graph = useRef<GraphType>(); const viewport = useRef<Viewport>(); + const ref = useRef<HTMLDivElement>(null); + const isSetup = useRef(false); + const ml = useML(); + const dragging = useRef<{ node: NodeType; gfx: Graphics } | null>(null); + const onlyClicked = useRef(false); + const dispatch = useAppDispatch(); - useEffect(() => { - app.renderer.resize(props.windowSize.width, props.windowSize.height); - app.render(); - }, [props.windowSize]); + // useEffect(() => { + // app.renderer.resize(props.windowSize.width, props.windowSize.height); + // app.render(); + // }, [props.windowSize]); useEffect(() => { - if (props.myDiv && props.myDiv.children.length === 0) props.myDiv.appendChild(app.view as HTMLCanvasElement); - }, [props.myDiv]); - - const updateNode = (node: NodeType) => { - // console.log(item); - const gfx = nodeMap.current.get(node.id); - gfx?.position.set(node.x, node.y); + if (!ref.current) return; + const resizeObserver = new ResizeObserver(() => { + app.renderer.resize(ref?.current?.clientWidth || 1000, ref?.current?.clientHeight || 1000); + app.render(); + }); + resizeObserver.observe(ref.current); + return () => resizeObserver.disconnect(); // clean up + }, []); - // if (!item.position) { - // item.position = new Point(node.x, node.y); - // } else { - // item.position.set(node.x, node.y); - // } - // Update attributes position if they exist - // if (node.gfxAttributes) { - // const x = node.x - node.gfxAttributes.width / 2; - // const y = node.y - node.gfxAttributes.height - 20; - // if (!node.gfxAttributes?.position) node.gfxAttributes.position = new Point(x, y); - // else { - // node.gfxAttributes.position.set(x, y); - // } - // } - }; + useEffect(() => { + if (ref.current && ref.current.children.length === 0) { + ref.current.appendChild(app.view as HTMLCanvasElement); + app.renderer.resize(ref?.current?.clientWidth || 1000, ref?.current?.clientHeight || 1000); + app.render(); + } + }, [ref]); - const dragging = useRef<{ node: NodeType; gfx: Graphics } | null>(null); function onDragStart(event: FederatedPointerEvent, node: NodeType, gfx: Graphics) { // store a reference to the data // the reason for this is because of multitouch @@ -72,6 +68,7 @@ export const useNLPixi = (props: Props) => { event.stopPropagation(); if (viewport.current) viewport.current.pause = true; dragging.current = { node, gfx }; + onlyClicked.current = true; } function onDragEnd(event: FederatedPointerEvent) { @@ -79,15 +76,19 @@ export const useNLPixi = (props: Props) => { event.stopPropagation(); dragging.current.node.fx = null; dragging.current.node.fy = null; - dragging.current = null; if (viewport.current) viewport.current.pause = false; + if (onlyClicked.current) { + onlyClicked.current = false; + props.onClick(dragging.current.node); + } + dragging.current = null; } } function onDragMove(event: FederatedPointerEvent) { if (dragging.current) { + onlyClicked.current = false; event.stopPropagation(); - console.log(viewport.current?.scaled); if (!dragging.current.node.fx) dragging.current.node.fx = dragging.current.node.x || 0; if (!dragging.current.node.fy) dragging.current.node.fy = dragging.current.node.y || 0; @@ -97,51 +98,82 @@ export const useNLPixi = (props: Props) => { } } + const updateNode = (node: NodeType) => { + const gfx = nodeMap.current.get(node.id); + if (!gfx) return; + + const lineColor = node.isShortestPathSource + ? tailwindColors.entity[950] + : node.isShortestPathTarget + ? tailwindColors.relation[950] + : node.selected + ? tailwindColors.entity[400] + : '#000000'; + const lineWidth = node.selected ? 3 : 1.5; + gfx.lineStyle(lineWidth, binaryColor(lineColor)); + + if (node?.cluster) { + gfx.beginFill(node.cluster >= 0 ? nodeColor(node.cluster) : 0x000000); + } else gfx.beginFill(nodeColor(node.type)); + + gfx.drawCircle(0, 0, Math.max(node.radius || 5, 5)); + gfx.endFill(); + + gfx.position.set(node.x, node.y); + + gfx.off('mousedown'); + gfx.off('mousemove'); + gfx.off('mouseup'); + gfx.on('mousedown', (e) => onDragStart(e, node, gfx)); + gfx.on('mousemove', onDragMove); + gfx.on('mouseup', onDragEnd); + + // if (!item.position) { + // item.position = new Point(node.x, node.y); + // } else { + // item.position.set(node.x, node.y); + // } + // Update attributes position if they exist + // if (node.gfxAttributes) { + // const x = node.x - node.gfxAttributes.width / 2; + // const y = node.y - node.gfxAttributes.height - 20; + // if (!node.gfxAttributes?.position) node.gfxAttributes.position = new Point(x, y); + // else { + // node.gfxAttributes.position.set(x, y); + // } + // } + }; + 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); } + // 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); + nodes.addChild(gfx); node.selected = selected; - const lineColor = selected ? tailwindColors.entity[400] : '#000000'; - const lineWidth = selected ? 3 : 1.5; - gfx.lineStyle(lineWidth, binaryColor(lineColor)); - //check if not undefined. - if (node.cluster) { - gfx.beginFill(nodeColor(node.cluster)); - } else { - //if cluster is undefined, use type to determine the color. - //check if not undefined. - if (node.type) { - gfx.beginFill(nodeColor(node.type)); - } - } - gfx.drawCircle(0, 0, Math.max(node.radius || 5, 5)); + updateNode(node); gfx.hitArea = new Circle(0, 0, 4); gfx.name = 'node_' + node.id; - gfx.position.set(node.x, node.y); gfx.eventMode = 'dynamic'; - gfx.on('mousedown', (e) => onDragStart(e, node, gfx)); - gfx.on('mousemove', onDragMove); - gfx.on('mouseup', onDragEnd); - nodes.addChild(gfx); - nodeMap.current.set(node.id, gfx); return gfx; }; - /** UpdateRadius works just like UpdateColors, but also applies radius*/ - const UpdateRadius = (graph: GraphType, radius: number) => { - // update for each node in graph - graph.nodes.forEach((node: NodeType) => { - createNode(node); - }); - }; + // /** UpdateRadius works just like UpdateColors, but also applies radius*/ + // const UpdateRadius = (graph: GraphType, radius: number) => { + // // update for each node in graph + // graph.nodes.forEach((node: NodeType) => { + // createNode(node); + // }); + // }; - const updateLink = (link: LinkType) => { - if (!graph.current) return; + const updateLink = (link: LinkType, gfx: Graphics) => { + if (!props.graph) return; const _source = link.source; const _target = link.target; @@ -168,88 +200,180 @@ export const useNLPixi = (props: Props) => { targetId = target.id; } if (!source || !target) { - console.log('source or target not found', source, target, sourceId, targetId); + console.error('source or target not found', source, target, sourceId, targetId); return; } - const id = sourceId + targetId; - const gfx = linkMap.current.get(id); - if (gfx) { + let color = link.color || 0x000000; + let style = 0.2; + let alpha = link.alpha || 1; + if (link.mlEdge) { + color = 0x0000ff; + if (link.value > ml.communityDetection.jaccard_threshold) { + style = link.value * 1.8; + } else { + style = 0; + alpha = 0.2; + } + } else if (props.highlightedLinks && props.highlightedLinks.includes(link)) { + if (link.mlEdge && ml.communityDetection.jaccard_threshold) { + if (link.value > ml.communityDetection.jaccard_threshold) { + color = 0xaa00ff; + style = link.value * 1.8; + } + } else { + color = 0xff0000; + style = 1.0; + } + } else if (props.currentShortestPathEdges && props.currentShortestPathEdges.includes(link)) { + color = 0x00ff00; + style = 3.0; + } + gfx.clear(); gfx.beginFill(); - // Check if link is a machine learning edge - // if (link.mlEdge && props.jaccardThreshold) { - // if (link.value > props.jaccardThreshold) { - // gfx.moveTo(source.x, source.y); - // gfx.lineTo(target.x, target.y); - // gfx.lineStyle(link.value * 1.8, 0x0000ff); - // } - // } else gfx.lineStyle(0.2, 0x000000); - // //Check if link is hightlighted - // if (props.highlightedLinks && props.highlightedLinks.includes(link)) { - // //If highlighted and ML edge make it purple - // if (link.mlEdge && props.jaccardThreshold) { - // if (link.value > props.jaccardThreshold) { - // gfx.lineStyle(link.value * 1.8, 0xaa00ff); - // } - // //If highlight and not ML edge make it red - // } else { - // gfx.lineStyle(1.0, 0xff0000); - // } - // } - // if (props.currentShortestPathEdges && props.currentShortestPathEdges.includes(link)) { - // gfx.lineStyle(3.0, 0x00ff00); - // } - // console.log('link', source.x); - - // gfx.position.set(source.x, source.y); - // gfx.moveTo(source.x || 0, source.y || 0); - // gfx.lineTo(target.x || 1000, target.y || 1000); - // gfx.position.set(source.x, source.y); gfx - .lineStyle(0.5, 0x000000) + .lineStyle(style, color, alpha) .moveTo(source.x || 0, source.y || 0) .lineTo(target.x || 0, target.y || 0); gfx.endFill(); + } else { + throw Error('Link not found'); } }; const createLink = (link: LinkType) => { - const sourceId = link.source as string; - const targetId = link.target as string; - const id = sourceId + targetId; - - if (linkMap.current.has(id)) { - linkMap.current.delete(id); + if (linkMap.current.has(link.id)) { + linkMap.current.delete(link.id); } const gfx = new Graphics(); - gfx.name = 'link_' + id; - linkMap.current.set(id, gfx); - updateLink(link); + gfx.name = 'link_' + link.id; + linkMap.current.set(link.id, gfx); + updateLink(link, gfx); links.addChild(gfx); return gfx; }; + useEffect(() => { + if (props.graph && ref.current && ref.current.children.length > 0) { + if (!isSetup.current) setup(); + else update(); + // setup(); + } + }, [props.graph]); + + const tick = (delta: number) => { + if (props.graph) { + props.graph.nodes.forEach((node: NodeType) => { + const gfx = nodeMap.current.get(node.id); + if (!gfx || node.x === undefined || node.y === undefined) return; + gfx.position.copyFrom(node as IPointData); + // const pos = gfx.position; + // let normal = { x: node.x - pos.x, y: node.y - pos.y }; + // const size = Math.sqrt(normal.x * normal.x + normal.y * normal.y); + // normal = { x: normal.x / size, y: normal.y / size }; + // // const mid: IPointData = { x: (pos.x + (node.x || 0)) / 2, y: (pos.y + (node.y || 0)) / 2 }; + // const vel = { x: pos.x + normal.x * delta * 0.1, y: pos.y + normal.y * delta * 0.1 }; + // gfx.position.copyFrom(vel as IPointData); + // console.log(normal); + + // gfx.position.set(node.x, node.y); + // if (node.x - pos.x + (node.y - pos.y) > 10000) gfx.position.set(node.x, node.y); + // else { + // const normal = { x: (node.x - pos.x) / (node.x + pos.x), y: (node.y - pos.y) / (node.y + pos.y) }; + // gfx.position.set(pos.x + (normal.x / delta) * 50, pos.y + (normal.y / delta) * 50); + // } + }); + + // Update forces of the links + props.graph.links.forEach((link: any) => { + if (linkMap.current && !!linkMap.current.has(link.id)) updateLink(link, linkMap.current.get(link.id) as Graphics); + }); + } + }; + + const update = (forceClear = false) => { + if (!props.graph || !ref.current) return; + + if (props.graph) { + if (forceClear) { + nodeMap.current.clear(); + linkMap.current.clear(); + nodes.removeChildren(); + links.removeChildren(); + } + + nodeMap.current.forEach((gfx, id) => { + if (!props.graph?.nodes?.find((node) => node.id === id)) { + nodes.removeChild(gfx); + gfx.destroy(); + nodeMap.current.delete(id); + } + }); + + linkMap.current.forEach((gfx, id) => { + if (!props.graph?.links?.find((link) => link.id === id)) { + links.removeChild(gfx); + gfx.destroy(); + linkMap.current.delete(id); + } + }); + + props.graph.nodes.forEach((node: NodeType) => { + 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); + } else { + createNode(node); + } + }); + props.graph.links.forEach((link: LinkType) => { + if (!forceClear && linkMap.current.has(link.id)) { + const gfx = linkMap.current.get(link.id); + if (gfx) updateLink(link, gfx); + } else createLink(link); + }); + + // // update text colour (written after nodes so that text appears on top of nodes) + // nodes.forEach((node: NodeType) => { + // if (node.gfxAttributes !== undefined) { + // const selected = node.selected === true; + // node.gfxAttributes.destroy(); + // createAttributes(node); + // if (selected) { + // showAttributes(node); + // } + // } + // }); + // console.log(nodes.children); + + force.startSimulation(props.graph, ref.current.getBoundingClientRect()); + force.simulation.on('tick', () => {}); + app.ticker.add(tick); + } + }; + /** * SetNodeGraphics is an initializing function. It attaches the nodes and links to the simulation. * It creates graphic objects and adds these to the PIXI containers. It also clears both of these of previous nodes and links. * @param graph The graph returned from the database and that is parsed into a nodelist and edgelist. - * @param radius The radius of the node. */ - const refresh = (graphQueryResult: GraphQueryResult) => { - const resultNodeLinkParserUseCase = new ResultNodeLinkParserUseCase(); - graph.current = resultNodeLinkParserUseCase.parseQueryResult(graphQueryResult); - + const setup = () => { nodes.removeChildren(); links.removeChildren(); app.stage.removeChildren(); + if (!props.graph) throw Error('Graph is undefined'); + + const size = ref.current?.getBoundingClientRect(); viewport.current = new Viewport({ - screenWidth: props.windowSize.width, - screenHeight: props.windowSize.height, - worldWidth: props.windowSize.width, - worldHeight: props.windowSize.height, + screenWidth: size?.width || 1000, + screenHeight: size?.height || 1000, + worldWidth: size?.width || 1000, + worldHeight: size?.height || 1000, stopPropagation: true, events: app.renderer.events, // the interaction module is important for wheel to work properly when renderer.view is placed or scaled }); @@ -265,68 +389,13 @@ export const useNLPixi = (props: Props) => { app.stage.on('mouseup', onDragEnd); app.stage.on('pointerup', onDragEnd); - app.ticker.add(function (delta) { - if (graph.current) { - graph.current.nodes.forEach((node: any) => { - const gfx = nodeMap.current.get(node.id); - if (!gfx) return; - const pos = gfx.position; - // gfx.position.set(pos.x + (node.x - pos.x) / delta, pos.y + (node.y - pos.y) / delta); - gfx.position.set(node.x, node.y); - }); - - // Update forces of the links - graph.current.links.forEach((link: any) => { - updateLink(link); - }); - } - }); - - // app.stage.onmousedown = (e) => { - // isPanning.current = true; - // console.log('mousedown'); - // }; - // app.stage.onmousemove = (e) => { - // if (isPanning.current) { - // setPan({ x: pan.x + e.movementX, y: pan.y + e.movementY }); - // app.stage.setTransform(pan.x + e.movementX, pan.y + e.movementY, scale, scale); - // } - // }; - // app.stage.onmouseup = (e) => { - // isPanning.current = false; - // console.log('mouseup'); - // }; - - // Create and initialise graphic objects for the nodes - if (graph && graph.current.nodes && graph.current.links) { - nodeMap.current.clear(); - graph.current.nodes.forEach((node: NodeType) => { - createNode(node); - }); - linkMap.current.clear(); - graph.current.links.forEach((link: LinkType) => { - createLink(link); - }); - - // // update text colour (written after nodes so that text appears on top of nodes) - // nodes.forEach((node: NodeType) => { - // if (node.gfxAttributes !== undefined) { - // const selected = node.selected === true; - // node.gfxAttributes.destroy(); - // createAttributes(node); - // if (selected) { - // showAttributes(node); - // } - // } - // }); - - // // refresh - // force.simulation.alphaTarget(0).restart(); + app.ticker.add(tick); - force.startSimulation(graph.current, props.windowSize); - force.simulation.on('tick', () => {}); - } + nodeMap.current.clear(); + linkMap.current.clear(); + update(); + isSetup.current = true; }; - return { renderer: app.renderer, stage: app.stage, links, refresh, graph: graph.current }; + return <div className="h-full w-full overflow-hidden" ref={ref}></div>; }; diff --git a/libs/shared/lib/vis/nodelink/components/query2NL.tsx b/libs/shared/lib/vis/nodelink/components/query2NL.tsx new file mode 100644 index 0000000000000000000000000000000000000000..57b39f3ba0172100a3f298d595aab53302aa7982 --- /dev/null +++ b/libs/shared/lib/vis/nodelink/components/query2NL.tsx @@ -0,0 +1,287 @@ +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ +import { GraphType, LinkType, NodeType } from '../Types'; +import { Edge, Node, GraphQueryResult } from '../../../data-access/store'; +import { ML } from '../../../data-access/store/mlSlice'; +import { processML } from './NLMachineLearning'; +/** ResultNodeLinkParserUseCase implements methods to parse and translate websocket messages from the backend into a GraphType. */ + +/** + * This program has been developed by students from the bachelor Computer Science at + * Utrecht University within the Software Project course. + * © Copyright Utrecht University (Department of Information and Computing Sciences) + */ +/** A node link data-type for a query result object from the backend. */ +// export type NodeLinkResultType = { DEPRECATED USE GraphQueryResult +// nodes: Node[]; +// edges: Link[]; +// mlEdges?: Link[]; +// }; + +/** Typing for nodes and links in the node-link result. Nodes and links should always have an id and attributes. */ +// export interface AxisType { +// id: string; +// attributes: Record<string, any>; +// mldata?: Record<string, string[]> | number; // This is shortest path data . This name is needs to be changed together with backend TODO: Change this. +// } + +/** Typing for a node in the node-link result */ +// export type Node = AxisType; + +/** Typing for a link in the node-link result */ +// export interface Link extends AxisType { +// from: string; +// to: string; +// } + +export type AxisType = Node | Edge; + +/** Gets the group to which the node/edge belongs */ +export function getGroupName(axisType: AxisType): string { + // FIXME: only works in arangodb + return axisType.label; +} + +/** Returns true if the given id belongs to the target group. */ +export function isNotInGroup(nodeOrEdge: AxisType, targetGroup: string): boolean { + return getGroupName(nodeOrEdge) != targetGroup; +} + +/** Checks if a query result form the backend contains valid NodeLinkResultType data. + * @param {any} jsonObject The query result object received from the frontend. + * @returns True and the jsonObject will be casted, false if the jsonObject did not contain all the data fields. + */ +export function isNodeLinkResult(jsonObject: any): jsonObject is GraphQueryResult { + if (typeof jsonObject === 'object' && jsonObject !== null && 'nodes' in jsonObject && 'edges' in jsonObject) { + if (!Array.isArray(jsonObject.nodes) || !Array.isArray(jsonObject.edges)) return false; + + const validNodes = jsonObject.nodes.every((node: any) => 'id' in node && 'attributes' in node); + const validEdges = jsonObject.edges.every((edge: any) => 'from' in edge && 'to' in edge); + + return validNodes && validEdges; + } else return false; +} + +/** Returns a record with a type of the nodes as key and a number that represents how many times this type is present in the nodeLinkResult as value. */ +export function getNodeTypes(nodeLinkResult: GraphQueryResult): Record<string, number> { + const types: Record<string, number> = {}; + + nodeLinkResult.nodes.forEach((node) => { + const type = getGroupName(node); + if (types[type] != undefined) types[type]++; + else types[type] = 0; + }); + + return types; +} + +export type UniqueEdge = { + from: string; + to: string; + count: number; + attributes: Record<string, any>; +}; + +/** + * Parse a message (containing query result) edges to unique edges. + * @param {Link[]} queryResultEdges Edges from a query result. + * @param {boolean} isLinkPredictionData True if parsing LinkPredictionData, false otherwise. + * @returns {UniqueEdge[]} Unique edges with a count property added. + */ +export function parseToUniqueEdges(queryResultEdges: Edge[], isLinkPredictionData: boolean): UniqueEdge[] { + // Edges to be returned + const edges: UniqueEdge[] = []; + + // Collect the edges in map, to only keep unique edges + // And count the number of same edges + const edgesMap = new Map<string, number>(); + const attriMap = new Map<string, Record<string, any>>(); + if (queryResultEdges != null) { + if (!isLinkPredictionData) { + for (let j = 0; j < queryResultEdges.length; j++) { + const newLink = queryResultEdges[j].from + ':' + queryResultEdges[j].to; + edgesMap.set(newLink, (edgesMap.get(newLink) || 0) + 1); + attriMap.set(newLink, queryResultEdges[j].attributes); + } + + edgesMap.forEach((count, key) => { + const fromTo = key.split(':'); + edges.push({ + from: fromTo[0], + to: fromTo[1], + count: count, + attributes: attriMap.get(key) ?? [], + }); + }); + } else { + for (let i = 0; i < queryResultEdges.length; i++) { + edges.push({ + from: queryResultEdges[i].from, + to: queryResultEdges[i].to, + count: queryResultEdges[i].attributes.jaccard_coefficient as number, + attributes: queryResultEdges[i].attributes, + }); + } + } + } + return edges; +} + +type OptionsI = { + defaultX?: number; + defaultY?: number; + defaultRadius?: number; +}; + +/** + * Parse a websocket message containing a query result into a node link GraphType. + * @param {any} queryResult An incoming query result from the websocket. + * @returns {GraphType} A node-link graph containing the nodes and links for the diagram. + */ +export function parseQueryResult(queryResult: GraphQueryResult, ml: ML, options: OptionsI = {}): GraphType { + const nodes: NodeType[] = []; + const typeDict: { [key: string]: number } = {}; + // Counter for the types + let counter = 1; + // Entry to keep track of the number of machine learning clusters + let numberOfMlClusters = 0; // TODO + + let communityDetectionInResult = false; + let shortestPathInResult = false; + let linkPredictionInResult = false; + + for (let i = 0; i < queryResult.nodes.length; i++) { + // Assigns a group to every entity type for color coding + const nodeId = queryResult.nodes[i].id; + const entityType = queryResult.nodes[i].label; + + // The preferred text to be shown on top of the node + let preferredText = nodeId; + let typeNumber = 1; + + // Check if entity is already seen by the dictionary + if (entityType in typeDict) typeNumber = typeDict[entityType]; + else { + typeDict[entityType] = counter; + typeNumber = counter; + counter++; + } + + // TODO: this should be a setting + // Check to see if node has a "naam" attribute and set prefText to it + if (queryResult.nodes[i].attributes.name !== undefined) preferredText = queryResult.nodes[i].attributes.name as string; + if (queryResult.nodes[i].attributes.label !== undefined) preferredText = queryResult.nodes[i].attributes.label as string; + if (queryResult.nodes[i].attributes.naam !== undefined) preferredText = queryResult.nodes[i].attributes.naam as string; + + let radius = options.defaultRadius || 5; + let data: NodeType = { + id: queryResult.nodes[i].id, + attributes: queryResult.nodes[i].attributes, + type: typeNumber, + displayInfo: preferredText, + radius: radius, + x: (options.defaultX || 0) + Math.random() * radius * 20 - radius * 10, + y: (options.defaultY || 0) + Math.random() * radius * 20 - radius * 10, + }; + + // let mlExtra = {}; + // if (queryResult.nodes[i].mldata && typeof queryResult.nodes[i].mldata != 'number') { // TODO FIXME: this is somewhere else now + // mlExtra = { + // shortestPathData: queryResult.nodes[i].mldata as Record<string, string[]>, + // }; + // shortestPathInResult = true; + // } else if (typeof queryResult.nodes[i].mldata == 'number') { + // // mldata + 1 so you dont get 0, which is interpreted as 'undefined' + // const numberOfCluster = (queryResult.nodes[i].mldata as number) + 1; + // mlExtra = { + // cluster: numberOfCluster, + // clusterAccoringToMLData: numberOfCluster, + // }; + // communityDetectionInResult = true; + // if (numberOfCluster > numberOfMlClusters) { + // numberOfMlClusters = numberOfCluster; + // } + // } + + // Add mlExtra to the node if necessary + // data = { ...data, ...mlExtra }; + nodes.push(data); + } + + // Filter unique edges and transform to LinkTypes + // List for all links + let links: LinkType[] = []; + let allNodeIds = new Set(nodes.map((n) => n.id)); + + // Parse ml edges + // if (ml != undefined) { + // ml?.linkPrediction?.forEach((link) => { + // if (allNodeIds.has(link.from) && allNodeIds.has(link.to)) { + // const toAdd: LinkType = { + // source: link.from, + // target: link.to, + // value: link.attributes.jaccard_coefficient as number, + // mlEdge: true, + // color: 0x000000, + // }; + // links.push(toAdd); + // } + // linkPredictionInResult = true; + // }); + // } + + // Parse normal edges + const uniqueEdges = parseToUniqueEdges(queryResult.edges, false); + for (let i = 0; i < uniqueEdges.length; i++) { + if (allNodeIds.has(uniqueEdges[i].from) && allNodeIds.has(uniqueEdges[i].to)) { + const toAdd: LinkType = { + id: uniqueEdges[i].from + ':' + uniqueEdges[i].to, + source: uniqueEdges[i].from, + target: uniqueEdges[i].to, + value: uniqueEdges[i].count, + mlEdge: false, + color: 0x000000, + }; + links.push(toAdd); + } + } + + //TODO: is this in use? + const maxCount = links.reduce( + (previousValue, currentValue) => (currentValue.value > previousValue ? currentValue.value : previousValue), + -1 + ); + //TODO: is this in use? + // Scale the value from 0 to 50 + const maxLineWidth = 50; + if (maxCount > maxLineWidth) { + links.forEach((link) => { + link.value = (link.value / maxCount) * maxLineWidth; + link.value = link.value < 1 ? 1 : link.value; + }); + } + + // Graph to be returned + let toBeReturned: GraphType = { + nodes: nodes, + links: links, + // linkPrediction: linkPredictionInResult, + // shortestPath: shortestPathInResult, + // communityDetection: communityDetectionInResult, + }; + + // If query with community detection; add number of clusters to the graph + // const numberOfClusters = { + // numberOfMlClusters: numberOfMlClusters, + // }; + // if (communityDetectionInResult) { + // toBeReturned = { ...toBeReturned, ...numberOfClusters }; + // } + + + // return toBeReturned; + return processML(ml, toBeReturned); +} diff --git a/libs/shared/lib/vis/nodelink/components/utils.tsx b/libs/shared/lib/vis/nodelink/components/utils.tsx index 84513035db3164c9c223119b23ee625e534ed120..20e348a3f753db21fbc0ff85ca7b1d848b6b5768 100644 --- a/libs/shared/lib/vis/nodelink/components/utils.tsx +++ b/libs/shared/lib/vis/nodelink/components/utils.tsx @@ -9,7 +9,7 @@ import { GraphType, LinkType, NodeType } from '../Types'; export function nodeColor(num: number) { // num = num % 4; // const col = '#000000'; - const col = tailwindColors.custom.nodes[num % tailwindColors.custom.nodes.length]; + const col = tailwindColors.custom.nodes[num % (tailwindColors.custom.nodes.length - 1)]; return binaryColor(col); } diff --git a/libs/shared/lib/vis/nodelink/nodelinkvis.tsx b/libs/shared/lib/vis/nodelink/nodelinkvis.tsx index 58d75ce677e2b245f8ede222f08590add3cdd36e..47adee5747c89d16b7e5676794cc227de84db0c7 100644 --- a/libs/shared/lib/vis/nodelink/nodelinkvis.tsx +++ b/libs/shared/lib/vis/nodelink/nodelinkvis.tsx @@ -1,10 +1,14 @@ -import { GraphQueryResult, useAppDispatch, useGraphQueryResult } from '../../data-access/store'; +import { GraphQueryResult, useAppDispatch, useGraphQueryResult, useML } from '../../data-access/store'; import React, { LegacyRef, useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; import * as PIXI from 'pixi.js'; import { GraphType, LinkType, NodeType } from './Types'; -import { useNLPixi } from './components/NLPixi'; +import { NLPixi } from './components/NLPixi'; import { getRelatedLinks } from './components/utils'; +import { parseQueryResult } from './components/query2NL'; +import { processML } from './components/NLMachineLearning'; +import { useImmer } from 'use-immer'; +import { ML, setShortestPathSource, setShortestPathTarget } from '../../data-access/store/mlSlice'; interface Props { loading?: boolean; @@ -37,69 +41,83 @@ export interface NodeLinkComponentState { } export const NodeLinkVis = React.memo((props: Props) => { - const jaccardThreshold = undefined; - const myRef = useRef<HTMLDivElement>(null); - const [graph, setGraph] = useState<GraphType>(); - const [windowSize, setWindowSize] = useState({ width: 1000, height: 1000 }); + const ref = useRef<HTMLDivElement>(null); + const [graph, setGraph] = useImmer<GraphType | undefined>(undefined); const [highlightNodes, setHighlightNodes] = useState<NodeType[]>([]); const [highlightedLinks, setHighlightedLinks] = useState<LinkType[]>([]); - const pixi = useNLPixi({ - windowSize: windowSize, - onClick: (node) => { - if (graph) { - //If node is already clicked we should remove it from the list. - if (highlightNodes.includes(node)) { - const index = highlightNodes.indexOf(node, 0); - setHighlightNodes(highlightNodes.splice(index, 1)); - } else { - //Else add it to the list. - setHighlightNodes(highlightNodes.concat(node)); - } - //Update the list of edges that need to be highlighted - setHighlightedLinks(getRelatedLinks(graph, highlightNodes, jaccardThreshold || -1)); - } - }, - highlightNodes: highlightNodes, - currentShortestPathEdges: [], - highlightedLinks: highlightedLinks, - jaccardThreshold: jaccardThreshold, - myDiv: myRef.current, - }); - const graphQueryResult = useGraphQueryResult(); + const ml = useML(); + const dispatch = useAppDispatch(); useEffect(() => { - console.debug('update nodelink useEffect', graphQueryResult); - pixi.refresh(graphQueryResult); - }, [graphQueryResult]); - - useEffect(() => { - console.log('loaded NodeLinkVis'); - window.addEventListener('resize', handleResize); - // nodeLinkViewModel.subscribeToQueryResult(); - // nodeLinkViewModel.setThisVisAsExportable(); - // nodeLinkViewModel.subscribeToAnalyticsData(); - - return () => { - console.log('unloaded NodeLinkVis'); - window.removeEventListener('resize', handleResize); - }; - }, []); - - useEffect(() => { - console.debug('loaded NodeLinkVis'); - setWindowSize({ width: myRef?.current?.clientWidth || 1000, height: myRef?.current?.clientHeight || 1000 }); - }, [myRef]); - - const loading = props.loading; + if (graphQueryResult) { + setGraph( + parseQueryResult(graphQueryResult, ml, { + defaultX: (ref.current?.clientWidth || 1000) / 2, + defaultY: (ref.current?.clientHeight || 1000) / 2, + }) + ); + } + }, [graphQueryResult, ml]); + + const onClickedNode = (node: NodeType, ml: ML) => { + console.log('shortestPath', graph, ml.shortestPath.enabled); + if (graph) { + // //If node is already clicked we should remove it from the list. + // if (highlightNodes.includes(node)) { + // const index = highlightNodes.indexOf(node, 0); + // setHighlightNodes(highlightNodes.splice(index, 1)); + // } else { + // //Else add it to the list. + // setHighlightNodes(highlightNodes.concat(node)); + // } + // //Update the list of edges that need to be highlighted + // setHighlightedLinks(getRelatedLinks(graph, highlightNodes, ml.communityDetection.jaccard_threshold || -1)); + + if (ml.shortestPath.enabled) { + console.log('shortestPath'); + + setGraph((draft) => { + 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) { + _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) { + _node.isShortestPathTarget = false; + dispatch(setShortestPathTarget(undefined)); + } else { + _node.isShortestPathSource = true; + _node.isShortestPathTarget = false; + dispatch(setShortestPathSource(node.id)); + dispatch(setShortestPathTarget(undefined)); + } + console.log('shortestPath', _node); + return draft; + }); + } + } + }; - // FUNCIONS FROM MODEL + // useEffect(() => { + // if (ml && graph) { + // // const g = processML(ml, graph as GraphType); + // // console.log(g); - /** When the screen gets resized, resize the node link canvas ViewModel. */ - const handleResize = () => { - setWindowSize({ width: window.innerWidth, height: window.innerHeight - 6 }); - }; + // setGraph((draft) => { + // const g = processML(ml, draft as GraphType); + // return g; + // }); + // } + // }, [ml]); return ( <> @@ -111,25 +129,28 @@ export const NodeLinkVis = React.memo((props: Props) => { name="head" value={theme.palette.primary.main} /> */} - {loading && ( - <div className="w-full h-full flex justify-center self-center items-center"> - <span className="loading loading-spinner loading-lg"></span> - </div> - )} - {!loading && ( - <div className="h-full overflow-hidden"> - <div className="h-full overflow-hidden" ref={myRef}></div> - {/* <VisConfigPanelComponent> */} - {/* <NodeLinkConfigPanelComponent + <div className="h-full w-full overflow-hidden" ref={ref}> + <NLPixi + graph={graph} + highlightNodes={highlightNodes} + highlightedLinks={highlightedLinks} + onClick={(node) => { + console.log(ml.shortestPath); + + onClickedNode(node, ml); + }} + /> + + {/* <VisConfigPanelComponent> */} + {/* <NodeLinkConfigPanelComponent graph={this.state.graph} nlViewModel={this.nodeLinkViewModel} /> */} - {/*</VisConfigPanelComponent>*/} - {/*<VisConfigPanelComponent isLeft>*/} - {/* <AttributesConfigPanel nodeLinkViewModel={this.nodeLinkViewModel} />*/} - {/* </VisConfigPanelComponent> */} - </div> - )} + {/*</VisConfigPanelComponent>*/} + {/*<VisConfigPanelComponent isLeft>*/} + {/* <AttributesConfigPanel nodeLinkViewModel={this.nodeLinkViewModel} />*/} + {/* </VisConfigPanelComponent> */} + </div> </> ); }); diff --git a/libs/shared/lib/vis/shared/ResultNodeLinkParserUseCase.tsx b/libs/shared/lib/vis/shared/ResultNodeLinkParserUseCase.tsx index 3ba91aa1346155239d6b391c6156a34607d562ca..22fcd08e2fd28d29349a4066865409049e9fa8e7 100644 --- a/libs/shared/lib/vis/shared/ResultNodeLinkParserUseCase.tsx +++ b/libs/shared/lib/vis/shared/ResultNodeLinkParserUseCase.tsx @@ -5,6 +5,7 @@ */ import { GraphType, LinkType, NodeType } from '../nodelink/Types'; import { Edge, Node, GraphQueryResult } from '../../data-access/store'; +import { ML } from '../../data-access/store/mlSlice'; /** ResultNodeLinkParserUseCase implements methods to parse and translate websocket messages from the backend into a GraphType. */ /** @@ -129,143 +130,3 @@ export class ParseToUniqueEdges { return edges; } } - -export default class ResultNodeLinkParserUseCase { - /** - * Parse a websocket message containing a query result into a node link GraphType. - * @param {any} queryResult An incoming query result from the websocket. - * @returns {GraphType} A node-link graph containing the nodes and links for the diagram. - */ - public parseQueryResult(queryResult: GraphQueryResult): GraphType { - const nodes: NodeType[] = []; - const typeDict: { [key: string]: number } = {}; - // Counter for the types - let counter = 1; - // Entry to keep track of the number of machine learning clusters - let numberOfMlClusters = 0; - - let communityDetectionInResult = false; - let shortestPathInResult = false; - let linkPredictionInResult = false; - - for (let i = 0; i < queryResult.nodes.length; i++) { - // Assigns a group to every entity type for color coding - const nodeId = queryResult.nodes[i].id + '/'; - const entityType = queryResult.nodes[i].label; - - // The preferred text to be shown on top of the node - let preferredText = nodeId; - let typeNumber = 1; - - // Check if entity is already seen by the dictionary - if (entityType in typeDict) typeNumber = typeDict[entityType]; - else { - typeDict[entityType] = counter; - typeNumber = counter; - counter++; - } - - // Check to see if node has a "naam" attribute and set prefText to it - if (queryResult.nodes[i].attributes.name != undefined) preferredText = queryResult.nodes[i].attributes.name as string; - if (queryResult.nodes[i].attributes.label != undefined) preferredText = queryResult.nodes[i].attributes.label as string; - if (queryResult.nodes[i].attributes.naam != undefined) preferredText = queryResult.nodes[i].attributes.naam as string; - - let data: NodeType = { - id: queryResult.nodes[i].id, - attributes: queryResult.nodes[i].attributes, - type: typeNumber, - displayInfo: preferredText, - radius: 5, - }; - - let mlExtra = {}; - if (queryResult.nodes[i].mldata && typeof queryResult.nodes[i].mldata != 'number') { - mlExtra = { - shortestPathData: queryResult.nodes[i].mldata as Record<string, string[]>, - }; - shortestPathInResult = true; - } else if (typeof queryResult.nodes[i].mldata == 'number') { - // mldata + 1 so you dont get 0, which is interpreted as 'undefined' - const numberOfCluster = (queryResult.nodes[i].mldata as number) + 1; - mlExtra = { - cluster: numberOfCluster, - clusterAccoringToMLData: numberOfCluster, - }; - communityDetectionInResult = true; - if (numberOfCluster > numberOfMlClusters) { - numberOfMlClusters = numberOfCluster; - } - } - - // Add mlExtra to the node if necessary - data = { ...data, ...mlExtra }; - nodes.push(data); - } - - // Filter unique edges and transform to LinkTypes - // List for all links - let links: LinkType[] = []; - let allNodeIds = new Set(nodes.map((n) => n.id)); - // Parse ml edges - if (queryResult.mlEdges != undefined) { - const uniqueMLEdges = ParseToUniqueEdges.parse(queryResult.mlEdges, true); - links = uniqueMLEdges.map((edge) => { - return { - source: edge.from, - target: edge.to, - value: edge.count, - mlEdge: true, - }; - }); - linkPredictionInResult = true; - } - - // Parse normal edges - const uniqueEdges = ParseToUniqueEdges.parse(queryResult.edges, false); - for (let i = 0; i < uniqueEdges.length; i++) { - if (allNodeIds.has(uniqueEdges[i].from) && allNodeIds.has(uniqueEdges[i].to)) { - const toAdd: LinkType = { - source: uniqueEdges[i].from, - target: uniqueEdges[i].to, - value: uniqueEdges[i].count, - mlEdge: false, - }; - links.push(toAdd); - } - } - - //TODO: is this in use? - const maxCount = links.reduce( - (previousValue, currentValue) => (currentValue.value > previousValue ? currentValue.value : previousValue), - -1 - ); - //TODO: is this in use? - // Scale the value from 0 to 50 - const maxLineWidth = 50; - if (maxCount > maxLineWidth) { - links.forEach((link) => { - link.value = (link.value / maxCount) * maxLineWidth; - link.value = link.value < 1 ? 1 : link.value; - }); - } - - // Graph to be returned - let toBeReturned = { - nodes: nodes, - links: links, - linkPrediction: linkPredictionInResult, - shortestPath: shortestPathInResult, - communityDetection: communityDetectionInResult, - }; - - // If query with community detection; add number of clusters to the graph - const numberOfClusters = { - numberOfMlClusters: numberOfMlClusters, - }; - if (communityDetectionInResult) { - toBeReturned = { ...toBeReturned, ...numberOfClusters }; - } - - return toBeReturned; - } -}