From f0f8e3e318fec175592bde54c71023cc045fae99 Mon Sep 17 00:00:00 2001
From: Dennis Collaris <d.a.c.collaris@uu.nl>
Date: Thu, 4 Jul 2024 12:47:29 +0000
Subject: [PATCH] feat(nl): use floating ui for node link tooltips

---
 apps/web/public/assets/sprite_selected.png    | Bin 646 -> 697 bytes
 .../public/assets/sprite_selected_square.png  | Bin 100 -> 96 bytes
 .../shared/lib/components/tooltip/Tooltip.tsx |  79 ++++++-
 .../nodelinkvis/components/NLPixi.tsx         | 199 +++++++++---------
 .../nodelinkvis/components/NLPopup.tsx        |   4 +-
 5 files changed, 171 insertions(+), 111 deletions(-)

diff --git a/apps/web/public/assets/sprite_selected.png b/apps/web/public/assets/sprite_selected.png
index bd57dcc236de77693873365fdf8d007f2349d2ba..1717d0ce9b6570151ce5f68bacb12864bc542e00 100644
GIT binary patch
literal 697
zcmV;q0!ICbP)<h;3K|Lk000e1NJLTq002w?002w~0{{R3@JXQ=0001cP)t-s0000_
zS!zyLYfe~dO;>79S8GmKYED>bO<8GGRc20CYED>cPFQMASZYpJYED>cPFZSCS7}XH
zYEM~ePgrYET4+^OW=>dY|NsBd)Y-4Jylr)aXK;Y__V@Dh^y}>I=;`d|>Fd+g+Qi4r
zwYtHTo2GMnid$rLeua`MI{i`r000+sQchC<FZt%Rl6^iO2k-3P+0x9xtd3_<HV=D)
z(C`2N0oF-GK~z}7oYe`AgD?yQ(6E<bDN9=$C_9wi|AZrTMk0X_;=Th!w*0@slzcDG
z>^KZeGYDfl%RQfc>Fy>bY?z6AXJi*Qf&-CzVH8FRN~xj$lntkBz2_bdXmK9w)Xf4-
z){Uw84iGrL@`hI+xhn2zSU_YQ-og6;vLEl@Efc<AdeR-Gb3Ny&=o-YPE2jA=olUao
z#e$uxY*Emb73^5MEBO06z6O*SUi+JF6XNXl8}Y!n+t%6S9yUr;C}HC!^w9#rQlL_?
zfWUejFIn|?qOT4YH9+vL@l^c=!I}?SoU29+wK$tCA8hGSOTS1*OMzFQrUH2BfA&?G
z&`-!;4_qOuNFb!w{X7-{XGDS#IML^?75a!ILQn78Ee9kSwtFUg#E^&&Tf&%c%1=o@
zsOd8gI4u&j!0}kF$OSPSIm=)*BiRhAOepz;WRp<#SW!!2L1Pxeszb64Rv`;u)gV~|
ztAOz_!!Phb`)$QOXaInLAcpa0Z4xF>srq$EeXM@77NE99ZQ43cb<pbys>^Y%?Rrq^
zshdZ*#)BGMW~8cNZ^qCXkY{wSP%&|5f>=ew39l2YC-6@dF(t{=H&ZM{<uzqo)`VYR
fezp3gZ`IKNWhbEIJ}Luq00000NkvXXu0mjf;+Z>d

literal 646
zcmeAS@N?(olHy`uVBq!ia0vp^ZXnFT3?e@WZ39v{0X`wFK-w=N3xUF+?4anZpy+JB
zh)l2)kdf^dp6wf%;~k#i>6!cg|9_zPpFe+IynJ){>aF!#_7qljq!%=N`}Y0Q=P$3{
zyt{Du#*yP^x9&PPb@sBHlIG}?(z@13^3zZD0__tj3GxeOSpD|Sk4p(2Qv4SVO=*h=
z)?s&Nb9l$Vz_{Jh#W5t}@RTd3r!^}Gumm_3F6!)j$tHSV?f?J&;xt8$$Ibh+&5O32
zGO`L>#(Fw=o{G)6$cfVgW2YJQ$(_5Tv*<@6=R2<({vxk5KeSY|*?RVfKYFdc%aG?l
z-o=&T#})pF%2wMO+<06Y@ky5R-AzXwCfllcY*#&Ru1uU|T6HA%yU6;5%ibNlc)j=f
zjdPYW3;0(ixIdLs*;N11YUc|<zMfkrkDtH!a>cX)k86JM_r5n>Tiz6Wm3{4WVSV@H
zzye3zj1ReIczW}>r{B_-%GvB@GEIzW3%69+@{MO*9R*T9cb@&v+RMchnz81;eB$i3
zgl**y)fcO>Oun`3@l_M1gD<8{RZVkuNcuMQkFWOE&r6rj$-BRjaZ%duusiEJ<HY(G
z35DAVeBGgyYa(@G3-9s>{d9%d*Q>c_e_%<OeAXgfpIt%vT6OX6Jl2#`Ywmoz%NUt1
zZ5}+qLG7$eUGzp_DTV}vw!}Y?vs*ZQZe91DHO+Ijm-`$&8{7R~Brjd}aV$*IZq4ZW
zVv=xfhxg&{3mljxH+yp4;&W14U|Ug}V88OX;KIeJ?n3>W<Qny?>QA)>l}>r2dfj3F
c6faT9^1EVNHYho!03(>e)78&qol`;+0MfrA!vFvP

diff --git a/apps/web/public/assets/sprite_selected_square.png b/apps/web/public/assets/sprite_selected_square.png
index eb7a57e4f70d60c5096d07f0cd53803454285099..8e6a75992f913fa454c18c4138c8a3883791f98f 100644
GIT binary patch
literal 96
zcmeAS@N?(olHy`uVBq!ia0vp^P9V(43?y&PnzR~7u?6^qxcWt8{r~^}OmX@epopla
ti(`n!`Q!!M8~*b%Enf5Rz#>})2E{iYa@)L}HGm2jJYD@<);T3K0RXyd9TNZm

literal 100
zcmeAS@N?(olHy`uVBq!ia0vp^wjj*N3?x<7d|m~l*aCb)T>t<7?-!Bv;jqp+popZW
wi(`n!`Q#t`jP@F71`Q0qHIf4iR|xN9C|rJH`@fU+{6Iwvp00i_>zopr01F8n;Q#;t

diff --git a/libs/shared/lib/components/tooltip/Tooltip.tsx b/libs/shared/lib/components/tooltip/Tooltip.tsx
index 19ca660cf..3541d2516 100644
--- a/libs/shared/lib/components/tooltip/Tooltip.tsx
+++ b/libs/shared/lib/components/tooltip/Tooltip.tsx
@@ -5,6 +5,8 @@ import {
   offset,
   flip,
   shift,
+  hide,
+  arrow,
   useHover,
   useFocus,
   useDismiss,
@@ -12,6 +14,7 @@ import {
   useInteractions,
   useMergeRefs,
   FloatingPortal,
+  FloatingArrow
 } from '@floating-ui/react';
 import type { Placement } from '@floating-ui/react';
 import { FloatingDelayGroup } from '@floating-ui/react';
@@ -21,6 +24,8 @@ interface TooltipOptions {
   placement?: Placement;
   open?: boolean;
   onOpenChange?: (open: boolean) => void;
+  boundaryElement?: React.RefObject<HTMLElement> | null;
+  showArrow?: boolean;
 }
 
 export function useTooltip({
@@ -28,6 +33,8 @@ export function useTooltip({
   placement = 'top',
   open: controlledOpen,
   onOpenChange: setControlledOpen,
+  boundaryElement = null,
+  showArrow = false
 }: TooltipOptions = {}): {
   open: boolean;
   setOpen: (open: boolean) => void;
@@ -38,8 +45,9 @@ export function useTooltip({
 
   const open = controlledOpen ?? uncontrolledOpen;
   const setOpen = setControlledOpen ?? setUncontrolledOpen;
+  const arrowRef = React.useRef<SVGSVGElement | null>(null);
 
-  const data = useFloating({
+  let config = {
     placement,
     open,
     onOpenChange: setOpen,
@@ -49,11 +57,25 @@ export function useTooltip({
       flip({
         crossAxis: placement.includes('-'),
         fallbackAxisSideDirection: 'start',
-        padding: 5,
+        padding: 5
       }),
       shift({ padding: 5 }),
     ],
-  });
+  }
+
+  if (boundaryElement != null) {
+    const boundary = boundaryElement?.current ?? undefined;
+    config.middleware.find(x => x.name == 'flip')!.options[0].boundary = boundary;
+    config.middleware.find(x => x.name == 'shift')!.options[0].boundary = boundary;
+    config.middleware.push(hide({ boundary }));
+  }
+
+  if (showArrow) {
+    config.middleware.push(arrow({ element: arrowRef }));
+  }
+
+  const data = useFloating(config);
+  (data.refs as any).arrow = arrowRef;
 
   const context = data.context;
 
@@ -98,17 +120,48 @@ export function Tooltip({ children, ...options }: { children: React.ReactNode }
   // This can accept any props as options, e.g. `placement`,
   // or other positioning options.
   const tooltip = useTooltip(options);
-  return <TooltipContext.Provider value={tooltip}>{children}</TooltipContext.Provider>;
+  
+  return <TooltipContext.Provider value={tooltip}>
+    {children}
+  </TooltipContext.Provider>;
 }
 
-export const TooltipTrigger = React.forwardRef<HTMLElement, React.HTMLProps<HTMLElement> & { asChild?: boolean }>(function TooltipTrigger(
-  { children, asChild = false, ...props },
+export const TooltipTrigger = React.forwardRef<HTMLElement, React.HTMLProps<HTMLElement> & { asChild?: boolean, x?: number, y?: number }>(function TooltipTrigger(
+  { children, asChild = false, x = null, y = null, ...props },
   propRef,
 ) {
   const context = useTooltipContext();
-  const childrenRef = (children as any).ref;
+  const childrenRef = React.useMemo(() => {
+    if (children == null) {
+      return null;
+    } else {
+      return (children as any).ref;
+    }
+  }, [children]);
+
   const ref = useMergeRefs([context.data.refs.setReference, propRef, childrenRef]);
 
+  React.useEffect(() => {
+    if (x && y && context.data.refs.reference.current != null) {
+      const element = context.data.refs.reference.current as HTMLElement;
+      element.style.position = 'absolute';
+      const {x: offsetX, y: offsetY} = element.getBoundingClientRect();
+      element.getBoundingClientRect = () => {
+        return {
+          width: 0,
+          height: 0,
+          x: offsetX,
+          y: offsetY,
+          top: y + offsetY,
+          left: x + offsetX,
+          right: x + offsetX,
+          bottom: y + offsetY,
+        } as DOMRect
+      }
+      context.data.update();
+    }
+  }, [x, y]);
+
   // `asChild` allows the user to pass any element as the anchor
   if (asChild && React.isValidElement(children)) {
     return React.cloneElement(
@@ -149,13 +202,21 @@ export const TooltipContent = React.forwardRef<
     <FloatingPortal>
       <div
         ref={ref}
-        className={`z-50 max-w-64 overflow-hidden rounded bg-light px-2 py-1 shadow text-xs border border-secondary-200 text-dark animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2${className ? ` ${className}` : ''}`}
+        className={`z-50 max-w-64 rounded bg-light px-2 py-1 shadow text-xs border border-secondary-200 text-dark animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2${className ? ` ${className}` : ''}`}
         style={{
           ...context.data.floatingStyles,
           ...style,
+          display: context.data.middlewareData.hide?.referenceHidden ? 'none' : 'block',
         }}
         {...context.interactions.getFloatingProps(props)}
-      />
+      >
+        { props.children }
+        { context.data.middlewareData.arrow ? <FloatingArrow 
+          ref={(context.data.refs as any).arrow} 
+          context={context.data.context} 
+          style={{fill: 'white'}}
+        /> : null }
+      </div>
     </FloatingPortal>
   );
 });
diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
index 6357b96bd..9d59adebd 100644
--- a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
+++ b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
@@ -1,6 +1,6 @@
 import { GraphType, GraphTypeD3, LinkType, LinkTypeD3, NodeType, NodeTypeD3 } from '../types';
 import { dataColors, visualizationColors } from 'config';
-import { ReactEventHandler, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
+import { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
 import {
   Application,
   AssetsBundle,
@@ -21,6 +21,9 @@ import { CytoscapeLayout, GraphologyLayout, LayoutFactory, Layouts } from '../..
 import { MultiGraph } from 'graphology';
 import { Viewport } from 'pixi-viewport';
 import { NodelinkVisProps } from '../nodelinkvis';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@graphpolaris/shared/lib/components/tooltip';
+import { MovedEvent } from 'pixi-viewport/dist/types';
+import { ConstructionOutlined } from '@mui/icons-material';
 
 type Props = {
   onClick: (event?: { node: NodeTypeD3; pos: IPointData }) => void;
@@ -43,7 +46,7 @@ type LayoutState = 'reset' | 'running' | 'paused';
 
 export const NLPixi = (props: Props) => {
   const [quickPopup, setQuickPopup] = useState<{ node: NodeType; pos: IPointData } | undefined>();
-  const [popups, setPopups] = useState<{ node: NodeType; pos: IPointData }[]>([]);
+  const [popups, setPopups] = useState<{ node: NodeTypeD3; pos: IPointData }[]>([]);
   const [assetsLoaded, setAssetsLoaded] = useState(false);
 
   const app = useMemo(
@@ -68,8 +71,6 @@ export const NLPixi = (props: Props) => {
   const mouseInCanvas = useRef<boolean>(false);
   const isSetup = useRef(false);
   const ml = useML();
-  const dragging = useRef<{ node: NodeTypeD3; gfx: Sprite } | null>(null);
-  const onlyClicked = useRef(false);
   const searchResults = useSearchResultData();
   const graph = useRef<GraphTypeD3>({ nodes: [], links: [] });
 
@@ -122,61 +123,64 @@ export const NLPixi = (props: Props) => {
 
   const imperative = useRef<any>(null);
 
+  const mouseClickThreshold = 200; // Time between mouse up and down events that is considered a click, and not a drag.
+
   useImperativeHandle(imperative, () => ({
-    onDragStart(node: NodeTypeD3, gfx: Sprite) {
-      dragging.current = { node, gfx };
-      onlyClicked.current = true;
-
-      // todo: graphology does not support fixed nodes
-      // todo: after vis-settings panel is there, we should to also support the original d3 force to allow interactivity if needed
-      if (props.layoutAlgorithm === Layouts.FORCEATLAS2WEBWORKER) return;
-      if (viewport.current) viewport.current.pause = true;
+    onMouseDown(event: FederatedPointerEvent) {
+      if (props.configuration.showPopUpOnHover) return;
+
+      (event as any).mouseDownTimeStamp = event.timeStamp;
     },
 
-    onDragMove(movementX: number, movementY: number) {
-      if (props.layoutAlgorithm === Layouts.FORCEATLAS2WEBWORKER) return;
-      if (dragging.current) {
-        onlyClicked.current = false;
-        if (quickPopup) setQuickPopup(undefined);
-        const idx = popups.findIndex((p) => p.node._id === dragging.current?.node._id);
-        if (idx >= 0) {
-          const p = popups[idx];
-          p.pos.x += movementX / (viewport.current?.scaled || 1);
-          p.pos.y += movementY / (viewport.current?.scaled || 1);
-          popups[idx] = p;
-          setPopups([...popups]);
-        }
+    onMouseUpNode(event: FederatedPointerEvent) {
+      if (props.configuration.showPopUpOnHover) return;
+
+      // If its a short click (not a drag) on the stage but not on a node: clear the selection and remove all popups.
+      const holdDownTime = event.timeStamp - (event as any).mouseDownTimeStamp;
+      if (holdDownTime > mouseClickThreshold) {
+        return;
+      }
+
+      const sprite = event.target as Sprite;
+      const node = (sprite as any).node as NodeTypeD3;
 
-        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;
-        dragging.current.node.fx += movementX / (viewport.current?.scaled || 1);
-        dragging.current.node.fy += movementY / (viewport.current?.scaled || 1);
-        // force.simulation.alpha(0.1).restart();
+      if (event.shiftKey) {
+        setPopups([...popups, { node: node, pos: toGlobal(node) }]);
+      } else {
+        setPopups([{ node: node, pos: toGlobal(node) }]);
+        for (const popup of popups) {
+          const sprite = nodeMap.current.get(popup.node._id) as Sprite;
+          sprite.texture = Assets.get(textureId(false));
+        }
       }
+
+      sprite.texture = Assets.get(textureId(true));
+
+      props.onClick({ node: node, pos: toGlobal(node) });
+
+      event.stopPropagation();
     },
 
-    onDragEnd() {
-      if (dragging.current) {
-        // dragging.current.node.fx = null;
-        // dragging.current.node.fy = null;
-        if (viewport.current) viewport.current.pause = false;
-        if (onlyClicked.current) {
-          onlyClicked.current = false;
-
-          if (popups.filter((d) => d.node._id === dragging.current?.node._id).length > 0) {
-            setPopups(popups.filter((p) => p.node._id !== dragging.current?.node._id));
-            props.onClick();
-          } else {
-            setPopups([...popups, { node: props.graph.nodes[dragging.current.node._id], pos: toGlobal(dragging.current.node) }]);
-            props.onClick({ node: dragging.current.node, pos: toGlobal(dragging.current.node) });
-          }
+    onMouseUpStage(event: FederatedPointerEvent) {
+      if (props.configuration.showPopUpOnHover) return;
+      
+      // If its a short click (not a drag) on the stage but not on a node: clear the selection and remove all popups.
+      const holdDownTime = event.timeStamp - (event as any).mouseDownTimeStamp;
+      if (holdDownTime < mouseClickThreshold) {
+        for (const popup of popups) {
+          const sprite = nodeMap.current.get(popup.node._id) as Sprite;
+          sprite.texture = Assets.get(textureId(false));
         }
-        this.onHover(dragging.current.node);
-        dragging.current = null;
-      } else {
+        setPopups([]);
+        props.onClick();
       }
     },
-    onHover(node: NodeTypeD3) {
+
+    onHover(event: FederatedPointerEvent) {
+      if (!props.configuration.showPopUpOnHover) return;
+
+      const sprite = event.target as Sprite;
+      const node = (sprite as any).node as NodeTypeD3;
       if (
         mouseInCanvas.current &&
         viewport?.current &&
@@ -188,11 +192,19 @@ export const NLPixi = (props: Props) => {
       }
     },
     onUnHover() {
+      if (!props.configuration.showPopUpOnHover) return;
+
       setQuickPopup(undefined);
     },
-    onPan() {
-      setPopups([]);
-      props.onClick();
+    onMoved(event: MovedEvent) {
+      if (props.configuration.showPopUpOnHover) return;
+      
+      for (const popup of popups) {
+        if (popup.node.x == null || popup.node.y == null) continue;
+        popup.pos.x = event.viewport.transform.position.x + popup.node.x * event.viewport.scale.x;
+        popup.pos.y = event.viewport.transform.position.y + popup.node.y * event.viewport.scale.y;
+      }
+      setPopups([...popups]);
     },
   }));
 
@@ -234,21 +246,6 @@ export const NLPixi = (props: Props) => {
     } else return { x: 0, y: 0 };
   }
 
-  function onDragStart(event: FederatedPointerEvent, node: NodeTypeD3, gfx: Sprite) {
-    event.stopPropagation();
-    if (imperative.current) imperative.current.onDragStart(node, gfx);
-  }
-
-  function onDragMove(event: FederatedPointerEvent) {
-    event.stopPropagation();
-    if (imperative.current) imperative.current.onDragMove(event.movementX, event.movementY);
-  }
-
-  function onDragEnd(event: FederatedPointerEvent) {
-    event.stopPropagation();
-    if (imperative.current) imperative.current.onDragEnd();
-  }
-
   const updateNode = (node: NodeTypeD3) => {
     const gfx = nodeMap.current.get(node._id);
     if (!gfx) return;
@@ -267,20 +264,6 @@ export const NLPixi = (props: Props) => {
 
     gfx.position.set(node.x, node.y);
 
-    gfx.off('mouseover');
-    gfx.off('mousedown');
-    gfx.on('mouseover', (e) => {
-      e.stopPropagation();
-      e.preventDefault();
-      if (imperative.current) imperative.current.onHover(node);
-    });
-    gfx.on('mouseout', (e) => {
-      e.stopPropagation();
-      e.preventDefault();
-      if (imperative.current) imperative.current.onUnHover();
-    });
-    gfx.on('mousedown', (e) => onDragStart(e, node, gfx));
-
     // if (!item.position) {
     //   item.position = new Point(node.x, node.y);
     // } else {
@@ -307,23 +290,28 @@ export const NLPixi = (props: Props) => {
     // Do not draw node if it has no position
     if (node.x === undefined || node.y === undefined) return;
 
-    let gfx: Sprite;
+    let sprite: Sprite;
     const texture = Assets.get(textureId());
-    gfx = new Sprite(texture);
+    sprite = new Sprite(texture);
 
-    gfx.tint = nodeColor(nodeMeta.type);
+    sprite.tint = nodeColor(nodeMeta.type);
     const scale = (Math.max(nodeMeta.radius || 5, 5) / 70) * 2;
-    gfx.scale.set(scale, scale);
-    gfx.anchor.set(0.5, 0.5);
+    sprite.scale.set(scale, scale);
+    sprite.anchor.set(0.5, 0.5);
 
-    nodeMap.current.set(node._id, gfx);
-    nodeLayer.addChild(gfx);
+    sprite.eventMode = 'static';
+    sprite.on('mousedown', (e) => imperative.current.onMouseDown(e));
+    sprite.on('mouseup', (e) => imperative.current.onMouseUpNode(e));
+    sprite.on('mouseover', (e) => imperative.current.onHover(e));
+    sprite.on('mouseout', (e) => imperative.current.onUnHover(e));
+
+    nodeMap.current.set(node._id, sprite);
+    nodeLayer.addChild(sprite);
 
     updateNode(node);
-    gfx.name = 'node_' + node._id;
-    gfx.eventMode = 'dynamic';
+    (sprite as any).node = node;
 
-    return gfx;
+    return sprite;
   };
 
   // /** UpdateRadius works just like UpdateColors, but also applies radius*/
@@ -512,7 +500,6 @@ export const NLPixi = (props: Props) => {
   };
 
   const update = (forceClear = false) => {
-    setPopups([]);
     if (!props.graph || !ref.current) return;
 
     if (props.graph) {
@@ -601,14 +588,13 @@ export const NLPixi = (props: Props) => {
 
     viewport.current.addChild(linkGfx);
     viewport.current.addChild(nodeLayer);
-    viewport.current.on('drag-start', (event) => {
-      imperative.current.onPan();
+    viewport.current.on('moved', (event) => {
+      imperative.current.onMoved(event);
     });
 
     app.stage.eventMode = 'dynamic';
-    app.stage.on('pointerup', onDragEnd);
-    app.stage.on('mousemove', onDragMove);
-    app.stage.on('mouseup', onDragEnd);
+    app.stage.on('mousedown', (e) => imperative.current.onMouseDown(e));
+    app.stage.on('mouseup', (e) => imperative.current.onMouseUpStage(e));
 
     nodeMap.current.clear();
     linkGfx.clear();
@@ -639,10 +625,25 @@ export const NLPixi = (props: Props) => {
     }
     layoutAlgorithm.current.layout(graphologyGraph, boundingBox);
   };
+
   return (
     <>
-      {mouseInCanvas.current && popups.map((popup) => <NLPopup onClose={() => {}} data={popup} key={popup.node._id} />)}
-      {props.showPopupsOnHover && quickPopup && <NLPopup onClose={() => {}} data={quickPopup} />}
+      {popups.map((popup) => (
+        <Tooltip key={popup.node._id} open={true} boundaryElement={ref} showArrow={true}>
+          <TooltipTrigger x={popup.pos.x} y={popup.pos.y} />
+          <TooltipContent>
+            <NLPopup onClose={() => {}} data={{node: props.graph.nodes[popup.node._id], pos: popup.pos}} key={popup.node._id} />
+          </TooltipContent>
+        </Tooltip>
+      ))}
+      {quickPopup != null && 
+        <Tooltip key={quickPopup.node._id} open={true} boundaryElement={ref} showArrow={true}>
+          <TooltipTrigger x={quickPopup.pos.x} y={quickPopup.pos.y} />
+          <TooltipContent>
+            <NLPopup onClose={() => {}} data={{node: props.graph.nodes[quickPopup.node._id], pos: quickPopup.pos}} key={quickPopup.node._id} />
+          </TooltipContent>
+        </Tooltip>
+      }
       <div
         className="h-full w-full overflow-hidden"
         ref={ref}
diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx
index 2ff3cd5f6..db7f37d0d 100644
--- a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx
+++ b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx
@@ -11,9 +11,7 @@ export const NLPopup = (props: NodelinkPopupProps) => {
 
   return (
     <div
-      className="absolute card card-bordered z-50 bg-light rounded-none text-[0.9rem] min-w-[10rem] pointer-events-none"
-      // style={{ top: props.data.pos.y + 10, left: props.data.pos.x + 10 }}
-      style={{ transform: 'translate(' + (props.data.pos.x + 20) + 'px, ' + (props.data.pos.y + 10) + 'px)' }}
+      className="text-[0.9rem] min-w-[10rem]"
     >
       <div className="card-body p-0">
         <span className="px-2.5 pt-2">
-- 
GitLab