From d3cafcc594d21ee4f484b968146b00098d44bf1d Mon Sep 17 00:00:00 2001
From: Dennis Collaris <d.a.c.collaris@uu.nl>
Date: Tue, 9 Jul 2024 15:58:02 +0000
Subject: [PATCH] feat(Vis): implement edge labels for the node-link
 visualization

---
 .../nodelinkvis/components/NLPixi.tsx         | 228 ++++++++++++++----
 .../nodelinkvis/components/query2NL.tsx       |   1 +
 .../vis/visualizations/nodelinkvis/types.ts   |   1 +
 3 files changed, 184 insertions(+), 46 deletions(-)

diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
index ba9d39d82..0cd2b32b9 100644
--- a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
+++ b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
@@ -11,6 +11,7 @@ import {
   IPointData,
   Sprite,
   Assets,
+  Text,
   Texture,
   Resource,
 } from 'pixi.js';
@@ -56,14 +57,21 @@ export const NLPixi = (props: Props) => {
         antialias: true,
         autoDensity: true,
         eventMode: 'auto',
-        resolution: window.devicePixelRatio || 1,
+        resolution: window.devicePixelRatio || 2,
       }),
     [],
   );
   const nodeLayer = useMemo(() => new Container(), []);
+  const labelLayer = useMemo(() => {
+    const container = new Container();
+    container.alpha = 0;
+    container.renderable = false;
+    return container;
+  }, []);
 
   const nodeMap = useRef(new Map<string, Sprite>());
   const linkGfx = new Graphics();
+  const labelMap = useRef(new Map<string, Text>());
   const viewport = useRef<Viewport>();
   const layoutState = useRef<LayoutState>('reset');
   const layoutStoppedCount = useRef(0);
@@ -90,6 +98,8 @@ export const NLPixi = (props: Props) => {
     width: 1000,
     height: 1000,
 
+    LABEL_MAX_NODES: 1000,
+
     LAYOUT_ALGORITHM: Layouts.FORCEATLAS2WEBWORKER,
 
     NODE_RADIUS: 5,
@@ -208,6 +218,29 @@ export const NLPixi = (props: Props) => {
       }
       setPopups([...popups]);
     },
+    onZoom(event: FederatedPointerEvent) {
+      const scale = viewport.current!.transform.scale.x;
+      
+      if (graph.current.nodes.length < config.LABEL_MAX_NODES) {
+        labelLayer.alpha = (scale > 2) ? Math.min(1, (scale - 2) * 3) : 0;
+
+        if (labelLayer.alpha > 0) {
+          labelLayer.renderable = true;
+          
+          const scale  = 1 / viewport.current!.scale.x;  // starts from 0.5 down to 0.
+
+          // Only change the fontSize for specific intervals, continuous change has too big of an impact on performance
+          const fontSize = (scale < 0.1) ? 30 : (scale < 0.2) ? 40 : (scale < 0.3) ? 50 : 60;
+          const strokeWidth = fontSize / 2;
+          labelMap.current.forEach((text) => {
+            text.style.fontSize = fontSize;
+            text.style.strokeThickness = strokeWidth;
+          });
+        } else {
+          labelLayer.renderable = false;
+        }
+      }
+    }
   }));
 
   function resize() {
@@ -300,6 +333,7 @@ export const NLPixi = (props: Props) => {
     const scale = (Math.max(nodeMeta.radius || 5, 5) / 70) * 2;
     sprite.scale.set(scale, scale);
     sprite.anchor.set(0.5, 0.5);
+    sprite.cullable = true;
 
     sprite.eventMode = 'static';
     sprite.on('mousedown', (e) => imperative.current.onMouseDown(e));
@@ -316,13 +350,30 @@ export const NLPixi = (props: Props) => {
     return sprite;
   };
 
-  // /** 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 createLinkLabel = (link: LinkTypeD3) => {
+    // check if link is already drawn, and if so, delete it
+    if (link && link?._id && labelMap.current.has(link._id)) {
+      labelMap.current.delete(link._id);
+    }
+
+    const linkMeta = props.graph.links[link._id];
+
+    const text = new Text(linkMeta.name, { 
+      fontSize: 60,
+      fill: config.LINE_COLOR_DEFAULT,
+      stroke: 0xffffff,
+      strokeThickness: 30,
+    });
+    text.cullable = true;
+    text.anchor.set(0.5, 0.5);
+    text.scale.set(0.1, .1);
+    labelMap.current.set(link._id, text);
+    labelLayer.addChild(text);
+
+    updateLinkLabel(link);
+
+    return text;
+  };
 
   const updateLink = (link: LinkTypeD3) => {
     if (!props.graph || nodeMap.current.size === 0) return;
@@ -356,52 +407,110 @@ export const NLPixi = (props: Props) => {
       return;
     }
 
-    if (linkGfx) {
-      // let color = link.color || 0x000000;
-      let color = config.LINE_COLOR_DEFAULT;
-      let style = config.LINE_WIDTH_DEFAULT;
-      let alpha = linkMeta.alpha || 1;
-      if (linkMeta.mlEdge) {
-        color = config.LINE_COLOR_ML;
+    // let color = link.color || 0x000000;
+    let color = config.LINE_COLOR_DEFAULT;
+    let style = config.LINE_WIDTH_DEFAULT;
+    let alpha = linkMeta.alpha || 1;
+    if (linkMeta.mlEdge) {
+      color = config.LINE_COLOR_ML;
+      if (linkMeta.value > ml.communityDetection.jaccard_threshold) {
+        style = linkMeta.value * 1.8;
+      } else {
+        style = 0;
+        alpha = 0.2;
+      }
+    } else if (props.highlightedLinks && props.highlightedLinks.includes(linkMeta)) {
+      if (linkMeta.mlEdge && ml.communityDetection.jaccard_threshold) {
         if (linkMeta.value > ml.communityDetection.jaccard_threshold) {
+          color = dataColors.magenta[50];
+          // 0xaa00ff;
           style = linkMeta.value * 1.8;
-        } else {
-          style = 0;
-          alpha = 0.2;
         }
-      } else if (props.highlightedLinks && props.highlightedLinks.includes(linkMeta)) {
-        if (linkMeta.mlEdge && ml.communityDetection.jaccard_threshold) {
-          if (linkMeta.value > ml.communityDetection.jaccard_threshold) {
-            color = dataColors.magenta[50];
-            // 0xaa00ff;
-            style = linkMeta.value * 1.8;
-          }
-        } else {
-          color = dataColors.red[70];
-          // color = 0xff0000;
-          style = 1.0;
-        }
-      } else if (props.currentShortestPathEdges && props.currentShortestPathEdges.includes(linkMeta)) {
-        color = dataColors.green[50];
-        // color = 0x00ff00;
-        style = 3.0;
+      } else {
+        color = dataColors.red[70];
+        // color = 0xff0000;
+        style = 1.0;
       }
+    } else if (props.currentShortestPathEdges && props.currentShortestPathEdges.includes(linkMeta)) {
+      color = dataColors.green[50];
+      // color = 0x00ff00;
+      style = 3.0;
+    }
 
-      // Conditional alpha for search results
-      if (searchResults.nodes.length > 0 || searchResults.edges.length > 0) {
-        // FIXME: searchResults.edges should be a hashmap to improve performance.
-        const isLinkInSearchResults = searchResults.edges.some((resultEdge) => resultEdge.id === link._id);
-        alpha = isLinkInSearchResults ? 1 : 0.05;
-      }
+    // Conditional alpha for search results
+    if (searchResults.nodes.length > 0 || searchResults.edges.length > 0) {
+      // FIXME: searchResults.edges should be a hashmap to improve performance.
+      const isLinkInSearchResults = searchResults.edges.some((resultEdge) => resultEdge.id === link._id);
+      alpha = isLinkInSearchResults ? 1 : 0.05;
+    }
+
+    linkGfx
+      .lineStyle(style, hslStringToHex(color), alpha)
+      .moveTo(source.x || 0, source.y || 0)
+      .lineTo(target.x || 0, target.y || 0);
+  };
+
+  const updateLinkLabel = (link: LinkTypeD3) => {
+    const text = labelMap.current.get(link._id);
+    if (!text) return;
+
+    const _source = link.source;
+    const _target = link.target;
+
+    if (!_source || !_target) {
+      return;
+    }
+
+    const source = nodeMap.current.get(link.source as string) as Sprite;
+    const target = nodeMap.current.get(link.target as string) as Sprite;
+
+    text.x = (source.x + target.x) / 2;
+    text.y = (source.y + target.y) / 2;
+    
+    const length = Math.hypot(target.x - source.x, target.y - source.y);
 
-      linkGfx
-        .lineStyle(style, hslStringToHex(color), alpha)
-        .moveTo(source.x || 0, source.y || 0)
-        .lineTo(target.x || 0, target.y || 0);
+    // Skip rendering labels on very short edges
+    if (length < text.width + 10) { // 10 to account for size of node
+      text.alpha = 0;
+      return;
     } else {
-      throw Error('Link not found');
+      text.alpha = 1;
     }
-  };
+
+    const rads = Math.atan2(target.y - source.y, target.x - source.x);
+    text.rotation = rads
+
+    const degrees = Math.abs(text.angle % 360);
+
+    // Rotate edge labels to always be legible
+    if (degrees > 90 && degrees < 270) {
+      text.rotation = rads + Math.PI;
+    } else {
+      text.rotation = rads;
+    }
+  }
+
+
+  // const text = labelMap.current.get(link._id);
+  //   if (!text) return;
+
+  //   const source = link.source as NodeTypeD3;
+  //   const target = link.target as NodeTypeD3;
+
+  //   if (source.x == null || source.y == null || target.x == null || target.y == null) return;
+
+  //   text.x = (source.x + target.x) / 2;
+  //   text.y = (source.y + target.y) / 2;
+    
+  //   const rads = Math.atan2(target.y - source.y, target.x - source.x);
+  //   const degrees = Math.abs(text.angle % 360);
+
+  //   // Rotate edge labels to always be legible
+  //   if (degrees > 90 && degrees < 270) {
+  //     text.rotation = rads + Math.PI;
+  //   } else {
+  //     text.rotation = rads;
+  //   }
 
   async function loadAssets() {
     if (!Assets.cache.has('texture')) {
@@ -420,8 +529,10 @@ export const NLPixi = (props: Props) => {
     loadAssets();
     return () => {
       nodeMap.current.clear();
+      labelMap.current.clear();
       linkGfx.clear();
       nodeLayer.removeChildren();
+      labelLayer.removeChildren();
     };
   }, []);
 
@@ -496,6 +607,7 @@ export const NLPixi = (props: Props) => {
       linkGfx.beginFill();
       graph.current.links.forEach((link: any) => {
         updateLink(link);
+        updateLinkLabel(link);
       });
       linkGfx.endFill();
     }
@@ -509,6 +621,7 @@ export const NLPixi = (props: Props) => {
         nodeMap.current.clear();
         linkGfx.clear();
         nodeLayer.removeChildren();
+        labelLayer.removeChildren();
       }
 
       nodeMap.current.forEach((gfx, id) => {
@@ -519,6 +632,14 @@ export const NLPixi = (props: Props) => {
         }
       });
 
+      labelMap.current.forEach((text, id) => {
+        if (!graph.current.links.find((link) => link._id === id)) {
+          labelLayer.removeChild(text);
+          text.destroy();
+          labelMap.current.delete(id);
+        }
+      });
+
       linkGfx.clear();
 
       graph.current.nodes.forEach((node) => {
@@ -533,6 +654,16 @@ export const NLPixi = (props: Props) => {
         }
       });
 
+      if (graph.current.nodes.length < config.LABEL_MAX_NODES) {
+        graph.current.links.forEach((link) => {
+          if (!forceClear && labelMap.current.has(link._id)) {
+            updateLinkLabel(link);
+          } else {
+            createLinkLabel(link);
+          }
+        });
+      }
+
       // // update text colour (written after nodes so that text appears on top of nodes)
       //   nodes.forEach((node: NodeType) => {
       //   if (node.gfxAttributes !== undefined) {
@@ -561,6 +692,7 @@ export const NLPixi = (props: Props) => {
    */
   const setup = async () => {
     nodeLayer.removeChildren();
+    labelLayer.removeChildren();
     app.stage.removeChildren();
 
     if (!props.graph) throw Error('Graph is undefined');
@@ -589,6 +721,7 @@ export const NLPixi = (props: Props) => {
     viewport.current.drag().pinch().wheel({ smooth: 2 }).animate({}).decelerate({ friction: 0.75 });
 
     viewport.current.addChild(linkGfx);
+    viewport.current.addChild(labelLayer);
     viewport.current.addChild(nodeLayer);
     viewport.current.on('moved', (event) => {
       imperative.current.onMoved(event);
@@ -596,6 +729,9 @@ export const NLPixi = (props: Props) => {
     viewport.current.on('drag-end', (event) => {
       setDragging(false);
     });
+    viewport.current.on('zoomed', (event) => {
+      imperative.current.onZoom(event);
+    });
 
     app.stage.eventMode = 'dynamic';
     app.stage.on('mousedown', (e) => imperative.current.onMouseDown(e));
diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx
index 952d6bc4e..ef0aba7b5 100644
--- a/libs/shared/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx
+++ b/libs/shared/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx
@@ -247,6 +247,7 @@ export function parseQueryResult(queryResult: GraphQueryResult, ml: ML, options:
         source: uniqueEdges[i].from,
         target: uniqueEdges[i].to,
         value: uniqueEdges[i].count,
+        name: uniqueEdges[i].attributes.Type,
         mlEdge: false,
         color: 0x000000,
       };
diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/types.ts b/libs/shared/lib/vis/visualizations/nodelinkvis/types.ts
index 321f20198..4d039f916 100644
--- a/libs/shared/lib/vis/visualizations/nodelinkvis/types.ts
+++ b/libs/shared/lib/vis/visualizations/nodelinkvis/types.ts
@@ -57,6 +57,7 @@ export interface LinkType {
   // The thickness of a line
   id: string;
   value: number;
+  name: string;
   // To check if an edge is calculated based on a ML algorithm
   mlEdge: boolean;
   color: number;
-- 
GitLab