From 4df552e3ae8f799795035fe4f258ff8f7eff2cc9 Mon Sep 17 00:00:00 2001
From: Milho001 <l.milhomemfrancochristino@uu.nl>
Date: Tue, 19 Sep 2023 09:59:02 +0000
Subject: [PATCH] feat(nodelink): popup on mouse hover with node attributes

---
 .../lib/vis/nodelink/components/NLPixi.tsx    |  42 ++--
 .../lib/vis/nodelink/components/NLPopup.tsx   | 207 ++++--------------
 libs/shared/lib/vis/nodelink/nodelinkvis.tsx  |   7 +-
 3 files changed, 69 insertions(+), 187 deletions(-)

diff --git a/libs/shared/lib/vis/nodelink/components/NLPixi.tsx b/libs/shared/lib/vis/nodelink/components/NLPixi.tsx
index a2f058832..79202fad0 100644
--- a/libs/shared/lib/vis/nodelink/components/NLPixi.tsx
+++ b/libs/shared/lib/vis/nodelink/components/NLPixi.tsx
@@ -13,6 +13,7 @@ import { parseQueryResult } from './query2NL';
 
 type Props = {
   onClick: (node: NodeType) => void;
+  onHover: (data: { node: NodeType; pos: IPointData } | undefined) => void;
   highlightNodes: NodeType[];
   currentShortestPathEdges?: LinkType[];
   highlightedLinks?: LinkType[];
@@ -77,11 +78,26 @@ export const NLPixi = (props: Props) => {
     // the reason for this is because of multitouch
     // we want to track the movement of this particular touch
     event.stopPropagation();
+    console.log('dragstart', node);
+    onHover(event, undefined);
+
     if (viewport.current) viewport.current.pause = true;
     dragging.current = { node, gfx };
     onlyClicked.current = true;
   }
 
+  function onHover(event: FederatedPointerEvent, node: NodeType | undefined) {
+    event.stopPropagation();
+    if (viewport?.current && !viewport?.current?.pause && node) {
+      const rect = ref.current?.getBoundingClientRect();
+      const x = (rect?.x || 0) + (node.x || 0);
+      const y = (rect?.y || 0) + (node.y || 0);
+      props.onHover({ node, pos: viewport.current.toScreen(x, y) });
+    } else {
+      props.onHover(undefined);
+    }
+  }
+
   function onDragEnd(event: FederatedPointerEvent) {
     if (dragging.current) {
       event.stopPropagation();
@@ -92,6 +108,7 @@ export const NLPixi = (props: Props) => {
         onlyClicked.current = false;
         props.onClick(dragging.current.node);
       }
+      onHover(event, dragging.current.node);
       dragging.current = null;
     }
   }
@@ -132,12 +149,11 @@ export const NLPixi = (props: Props) => {
 
     gfx.position.set(node.x, node.y);
 
+    gfx.off('mouseover');
     gfx.off('mousedown');
-    gfx.off('mousemove');
-    gfx.off('mouseup');
+    gfx.on('mouseover', (e) => onHover(e, node));
+    gfx.on('mouseout', (e) => onHover(e, undefined));
     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);
@@ -168,7 +184,6 @@ export const NLPixi = (props: Props) => {
     node.selected = selected;
 
     updateNode(node);
-    gfx.hitArea = new Circle(0, 0, 4);
     gfx.name = 'node_' + node.id;
     gfx.eventMode = 'dynamic';
 
@@ -280,21 +295,6 @@ export const NLPixi = (props: Props) => {
         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
@@ -399,6 +399,8 @@ export const NLPixi = (props: Props) => {
     app.stage.eventMode = 'dynamic';
     app.stage.on('mouseup', onDragEnd);
     app.stage.on('pointerup', onDragEnd);
+    app.stage.on('mousemove', onDragMove);
+    app.stage.on('mouseup', onDragEnd);
 
     app.ticker.add(tick);
 
diff --git a/libs/shared/lib/vis/nodelink/components/NLPopup.tsx b/libs/shared/lib/vis/nodelink/components/NLPopup.tsx
index d6c016daf..850099430 100644
--- a/libs/shared/lib/vis/nodelink/components/NLPopup.tsx
+++ b/libs/shared/lib/vis/nodelink/components/NLPopup.tsx
@@ -1,168 +1,43 @@
-/**
- * Creates a list of attributes from a given node, that can be visualized when the node is clicked
- * @param node The node from the graph which attributes need to be visualized
- */
-
+import { IPointData } from 'pixi.js';
 import { NodeType } from '../Types';
 
-export const useNLPopup = (node: NodeType) => {};
-//   private createAttributes = (node: NodeType) => {
-//     node.gfxAttributes = new PIXI.Graphics();
-//     node.gfxAttributes.beginFill(0xeeeeee);
-//     node.gfxAttributes.name = 'attributes_' + node.id;
-//     node.gfxAttributes.alpha = 0;
-//     node.gfxAttributes.eventMode = 'auto';
-//     // add title
-//     const gfxName = new PIXI.Text();
-//     if (node.displayInfo != undefined) {
-//       gfxName.text = node.displayInfo;
-//     } else {
-//       gfxName.text = node.id;
-//     }
-//     gfxName.style.fontSize = 16;
-//     gfxName.style.fontFamily = 'Poppins, arial, sans-serif';
-//     gfxName.style.fontWeight = 'bold';
-//     gfxName.x = 10;
-//     gfxName.y = 10;
-//     gfxName.name = 'header';
-//     node.gfxAttributes.addChild(gfxName);
-
-//     // add attributes container
-//     const container = new PIXI.Container();
-//     node.gfxAttributes.addChild(container);
-//     // add container for keys
-//     const keys = new PIXI.Container();
-//     container.addChild(keys);
-//     // add container for values
-//     const values = new PIXI.Container();
-//     container.addChild(values);
-
-//     // add attributes
-//     if (node.attributes) {
-//       let index = 0;
-//       for (const key in node.attributes) {
-//         const attributes = this.visibleAttributes[node?.label || node.id];
-//         const attributeHidden = attributes ? attributes[key] === false : false;
-//         if (attributeHidden) continue; // if attribute should be hidden, then skip
-//         this.addAttribute(keys, values, key, String(node.attributes[key]), index);
-//         index++;
-//       }
-//     }
-
-//     // position attribute containers
-//     container.x = 10;
-//     container.y = 45;
-//     values.x = keys.width + 10;
-
-//     // calculate width
-//     const width = this.calcAttrWidth(node.gfxAttributes) + 40;
-//     const height = container.height + 55;
-//     node.gfxAttributes.drawRoundedRect(0, 0, width, height, 10);
-
-//     // add stripe
-//     const gfxLine = new PIXI.Graphics();
-//     gfxLine.beginFill(0xbbbbbb);
-//     gfxLine.drawRect(10, 35, width - 20, 1);
-//     node.gfxAttributes.addChild(gfxLine);
-
-//     this.stage.addChild(node.gfxAttributes);
-//   };
-
-//   /**
-//    * Helper function of createAttributes: adds an attribute to the new attributes graphic
-//    */
-//   private addAttribute(keys: PIXI.Container, values: PIXI.Container, key: string, value: string, index: number): void {
-//     // add text for key
-//     const keyText = new PIXI.Text();
-//     keyText.style.fontSize = 14;
-//     keyText.style.fontFamily = 'Poppins, arial, sans-serif';
-//     keyText.style.fontWeight = 'bold';
-//     keyText.name = 'key';
-//     keyText.text = key + ':';
-//     keyText.y = index * 20;
-//     keys.addChild(keyText);
-//     // add text for value
-//     const valueText = new PIXI.Text();
-//     valueText.style.fontSize = 14;
-//     valueText.style.fontFamily = 'Poppins, arial, sans-serif';
-//     valueText.name = 'value';
-//     valueText.text = value;
-//     valueText.y = index * 20;
-//     values.addChild(valueText);
-//   }
-
-//   /**
-//    * Helper function of createAttributes: calculates the width of the attributes graphic
-//    */
-//   private calcAttrWidth(gfxAttributes: PIXI.Graphics): number {
-//     let width = 0;
-//     gfxAttributes.children.forEach((child) => {
-//       width = this.calcObjectWidth(child as PIXI.Text | PIXI.Graphics, width);
-//     });
-
-//     return width;
-//   }
-
-//   /**
-//    * Helper function of calcAttrWidth: calculates the width of a child element of the attributes graphic
-//    */
-//   private calcObjectWidth(object: PIXI.Text | PIXI.Graphics, minWidth?: number): number {
-//     const newWidth = object.width;
-//     return minWidth ? Math.max(newWidth, minWidth) : newWidth;
-// }
-
-//   /**
-//    * Calls for the Display that pops up and highlights the node and the links
-//    * if ShortestPath turns on, it turns off the highlightedlinks!
-//    * @param node The node clicked in the event
-//    */
-//   public ToggleInformationOnNode(node: NodeType) {
-//     this.simulation.alphaTarget(0).restart(); // renderer will not always update without this line
-//     this.showAttributes(node);
-//     // this.highlightNode(node);
-//     // this.highlightLinks(node);
-//     // this.showShortestPath();
-// }
-
-//   /**
-//    * When clicking on a node, toggle the select, making the text visible/invisible.
-//    * @param node The node which text should be toggled.
-//    */
-//   public showAttributes = (node: NodeType) => {
-//     if (node.selected) {
-//       if (node.gfxAttributes) {
-//         node.gfxAttributes.alpha = 0;
-//         node.selected = false;
-//       }
-//     } else {
-//       if (node.gfxAttributes) node.gfxAttributes.destroy();
-//       this.createAttributes(node);
-//       if (node.gfxAttributes) {
-//         node.gfxAttributes.scale.x = 1 / this.scalexy + 0.001 * this.scalexy;
-//         node.gfxAttributes.scale.y = 1 / this.scalexy + 0.001 * this.scalexy;
-//         node.gfxAttributes.alpha = 1;
-//         node.selected = true;
-//       }
-//     }
-//   };
-
-//   /**
-//    * Refreshes the attributes. This function is called when the attribute filter has been changed
-//    * @param graph the graph of which its nodes should be checked for attributes
-//    */
-//   const UpdateAttributes = (graph: GraphType) => {
-//     this.simulation.alphaTarget(0).restart(); // required so that the visualization updates
-//     graph.nodes.forEach((node: NodeType) => {
-//       if (node.gfxAttributes !== undefined) {
-//         // destroy and add the attributes pop-up again
-//         node.gfxAttributes.destroy();
-//         this.createAttributes(node);
-//         node.gfxAttributes.scale.x = 1 / this.scalexy + 0.001 * this.scalexy;
-//         node.gfxAttributes.scale.y = 1 / this.scalexy + 0.001 * this.scalexy;
-//         // make the pop-up visible if it was visible before
-//         if (node.selected) {
-//           node.gfxAttributes.alpha = 1;
-//         }
-//       }
-//     });
-//   };
+export type NodelinkPopupProps = {
+  data: { node: NodeType; pos: IPointData };
+  onClose: () => void;
+};
+
+export const NLPopup = (props: NodelinkPopupProps) => {
+  const node = props.data.node;
+
+  return (
+    <div
+      className="absolute card card-bordered bg-white rounded-none text-[0.9rem] min-w-[10rem]"
+      style={{ top: props.data.pos.y + 10, left: props.data.pos.x + 10 }}
+    >
+      <div className="card-body p-0">
+        <span className="px-2.5 pt-2">
+          <span>Node</span>
+          <span className="float-right">{node.id}</span>
+        </span>
+        <div className="h-[1px] w-full bg-offwhite-300"></div>
+        <div className="px-2.5 text-[0.8rem]">
+          {node.attributes &&
+            Object.entries(node.attributes).map(([k, v], i) => {
+              return (
+                <div key={k} className="flex flex-row gap-3">
+                  <span className="">{k}: </span>
+                  <span className="ml-auto flex-wrap max-w-[10rem] text-right">{v}</span>
+                </div>
+              );
+            })}
+          {node.cluster && (
+            <p>
+              Cluster: <span className="float-right">{node.cluster}</span>
+            </p>
+          )}
+        </div>
+        <div className="h-[1px] w-full"></div>
+      </div>
+    </div>
+  );
+};
diff --git a/libs/shared/lib/vis/nodelink/nodelinkvis.tsx b/libs/shared/lib/vis/nodelink/nodelinkvis.tsx
index 065f5d26d..74baf7208 100644
--- a/libs/shared/lib/vis/nodelink/nodelinkvis.tsx
+++ b/libs/shared/lib/vis/nodelink/nodelinkvis.tsx
@@ -9,6 +9,7 @@ import { parseQueryResult } from './components/query2NL';
 import { processML } from './components/NLMachineLearning';
 import { useImmer } from 'use-immer';
 import { ML, setShortestPathSource, setShortestPathTarget } from '../../data-access/store/mlSlice';
+import { NLPopup } from './components/NLPopup';
 
 interface Props {
   loading?: boolean;
@@ -45,6 +46,7 @@ export const NodeLinkVis = React.memo((props: Props) => {
   const [graph, setGraph] = useImmer<GraphType | undefined>(undefined);
   const [highlightNodes, setHighlightNodes] = useState<NodeType[]>([]);
   const [highlightedLinks, setHighlightedLinks] = useState<LinkType[]>([]);
+  const [popup, setPopup] = useState<undefined | { node: NodeType; pos: PIXI.IPointData }>(undefined);
 
   const graphQueryResult = useGraphQueryResult();
   const ml = useML();
@@ -101,15 +103,18 @@ export const NodeLinkVis = React.memo((props: Props) => {
   return (
     <>
       <div className="h-full w-full overflow-hidden" ref={ref}>
+        {!!popup && <NLPopup onClose={() => setPopup(undefined)} data={popup} />}
         <NLPixi
           graph={graph}
           highlightNodes={highlightNodes}
           highlightedLinks={highlightedLinks}
           onClick={(node) => {
             console.log(ml.shortestPath);
-
             onClickedNode(node, ml);
           }}
+          onHover={(data) => {
+            setPopup(data);
+          }}
         />
 
         {/* <VisConfigPanelComponent> */}
-- 
GitLab