From aa8db19e49696fd7eeae5793104923e2be04d332 Mon Sep 17 00:00:00 2001
From: Leonardo Christino <lchristino@graphpolaris.com>
Date: Mon, 31 Mar 2025 13:49:29 +0200
Subject: [PATCH 01/12] chore: refactor nl

now the calculation logic lives outside NLPixi, so that it doesnt care about it anymore and do not duplicate data unecessarily
---
 .../nodelinkvis/components/NLPixi.tsx         | 432 ++++++------------
 .../nodelinkvis/components/query2NL.tsx       | 293 ++++++++++--
 .../nodelinkvis/components/utils.tsx          |  41 +-
 .../nodelinkvis/nodelinkvis.tsx               | 180 +-------
 .../vis/visualizations/nodelinkvis/types.ts   | 102 +----
 5 files changed, 422 insertions(+), 626 deletions(-)

diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
index 8227b72c7..a78ab9887 100644
--- a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
@@ -1,7 +1,5 @@
 import { dataColors, visualizationColors } from '@/config';
 import { canViewFeature } from '@/lib/components/featureFlags';
-import { NodeDetails } from '@/lib/components/nodeDetails';
-import { Popover, PopoverContent, PopoverTrigger } from '@/lib/components/popover';
 import { useConfig } from '@/lib/data-access/store';
 import { Theme } from '@/lib/data-access/store/configSlice';
 import { useAsyncMemo } from '@/utils';
@@ -19,12 +17,12 @@ import {
   Texture,
   type StrokeStyle,
 } from 'pixi.js';
-import { forwardRef, RefObject, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
+import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
 import { AllLayoutAlgorithms } from 'ts-common/src/model/layouts';
 import { useML, useSearchResultData } from '../../../../data-access';
 import { GraphologyForceAtlas2Webworker, LayoutFactory, Layouts, LayoutTypes } from '../../../../graph-layout';
 import { NodelinkVisProps } from '../nodelinkvis';
-import { EdgeType, EdgeTypeD3, GraphType, GraphTypeD3, NodeType, NodeTypeD3 } from '../types';
+import { EdgeType, GraphType, NodeType } from '../types';
 import { ForceEdgeBundling, type Point } from './edgeBundling';
 import { NLPopUp } from './NLPopup';
 import { nodeColor, nodeColorHex } from './utils';
@@ -32,7 +30,7 @@ import { nodeColor, nodeColorHex } from './utils';
 const PERF_EDGE_THRESHOLD = 2500;
 
 type Props = {
-  onClick: (event?: { node: NodeTypeD3; pos: PointData }) => void;
+  onClick: (event?: { node: NodeType; pos: PointData }) => void;
   // onHover: (data: { node: NodeType; pos: PointData }) => void;
   // onUnHover: (data: { node: NodeType; pos: PointData }) => void;
   highlightNodes: NodeType[];
@@ -51,10 +49,9 @@ type LayoutState = 'reset' | 'running' | 'paused';
 // MAIN COMPONENT
 //////////////////
 
-let metaEdges: Record<string, EdgeType> | null = null;
 export const NLPixi = forwardRef((props: Props, refExternal) => {
   const [quickPopup, setQuickPopup] = useState<{ node: NodeType; pos: PointData } | undefined>();
-  const [popups, setPopups] = useState<{ node: NodeTypeD3; pos: PointData }[]>([]);
+  const [popups, setPopups] = useState<{ node: NodeType; pos: PointData }[]>([]);
 
   const globalConfig = useConfig();
 
@@ -114,7 +111,6 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
   const isSetup = useRef(false);
   const ml = useML();
   const searchResults = useSearchResultData();
-  const graph = useRef<GraphTypeD3>({ nodes: [], edges: [] });
 
   const layoutAlgorithm = useRef(new LayoutFactory().createLayout<AllLayoutAlgorithms>(Layouts.DAGRE));
 
@@ -157,21 +153,20 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
   useEffect(() => {
     if (nodeMap.current.size === 0) return;
 
-    graph.current.nodes.forEach(node => {
-      const sprite = nodeMap.current.get(node._id) as Sprite;
-      const nodeMeta = props.graph.nodes[node._id];
+    Object.values(props.graph.nodes).forEach(node => {
+      const sprite = nodeMap.current.get(node.id) as Sprite;
       sprite.texture = (sprite as any).selected ? selectedTexture : glyphTexture;
 
       // To calculate the scale, we:
       // 1) Determine the node radius, with a minimum of 5. If not available, we default to 5.
       // 2) Get the ratio with respect to the typical size of the node (divide by NODE_RADIUS).
       // 3) Scale this ratio by the current scale factor.
-      let scale = (Math.max(nodeMeta.radius || 5, 5) / config.NODE_RADIUS) * 2;
+      let scale = (Math.max(node.radius || 5, 5) / config.NODE_RADIUS) * 2;
       scale *= responsiveScale;
       sprite.scale.set(scale, scale);
     });
 
-    if (graph.current.nodes.length > config.LABEL_MAX_NODES) return;
+    if (Object.keys(props.graph.nodes).length > config.LABEL_MAX_NODES) return;
 
     // Change font size at specific scale intervals
     const fontSize =
@@ -189,7 +184,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       text.resolution = Math.ceil(1 / responsiveScale);
     });
 
-    graph.current.nodes.forEach((node: any) => {
+    Object.values(props.graph.nodes).forEach((node: any) => {
       updateNodeLabel(node);
     });
   }, [responsiveScale, props.configuration.nodes?.shape?.type]);
@@ -233,27 +228,27 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
     });
   }, [props.layoutAlgorithm, props.configuration, props.configuration.edgeBundlingEnabled]);
 
-  useEffect(() => {
-    if (nodeMap.current.size == 0 || props.graph.edges == null) {
-      metaEdges = null;
-      return;
-    }
+  // useEffect(() => {
+  //   if (nodeMap.current.size == 0 || props.graph.edges == null) {
+  //     metaEdges = null;
+  //     return;
+  //   }
 
-    const edgesCopy = JSON.parse(JSON.stringify(props.graph.edges)) as Record<string, EdgeType>;
-    metaEdges = Object.fromEntries(
-      Object.entries(edgesCopy).map(([key, edge]) => {
-        const sourceId = edge.source as string;
-        const targetId = edge.target as string;
-        const source = nodeMap.current.get(sourceId) as NodeTypeD3 | undefined;
-        const target = nodeMap.current.get(targetId) as NodeTypeD3 | undefined;
+  //   const edgesCopy = JSON.parse(JSON.stringify(props.graph.edges)) as Record<string, EdgeType>;
+  //   metaEdges = Object.fromEntries(
+  //     Object.entries(edgesCopy).map(([key, edge]) => {
+  //       const sourceId = edge.source as string;
+  //       const targetId = edge.target as string;
+  //       const source = nodeMap.current.get(sourceId) as NodeType | undefined;
+  //       const target = nodeMap.current.get(targetId) as NodeType | undefined;
 
-        edge._source = source;
-        edge._target = target;
+  //       edge._source = source;
+  //       edge._target = target;
 
-        return [key, edge];
-      }),
-    );
-  }, [props.graph.edges, nodeMap.current.size]);
+  //       return [key, edge];
+  //     }),
+  //   );
+  // }, [props.graph.edges, nodeMap.current.size]);
 
   const imperative = useRef<any>(null);
 
@@ -277,14 +272,14 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       }
 
       const sprite = event.target as Sprite;
-      const node = (sprite as any).node as NodeTypeD3;
+      const node = (sprite as any).node as NodeType;
 
       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;
+          const sprite = nodeMap.current.get(popup.node.id) as Sprite;
           sprite.texture = glyphTexture;
           (sprite as any).selected = false;
         }
@@ -306,7 +301,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       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;
+          const sprite = nodeMap.current.get(popup.node.id) as Sprite;
           sprite.texture = glyphTexture;
           (sprite as any).selected = false;
         }
@@ -319,15 +314,15 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       if (!props.configuration.showPopUpOnHover) return;
 
       const sprite = event.target as Sprite;
-      const node = (sprite as any).node as NodeTypeD3;
+      const node = (sprite as any).node as NodeType;
       if (
         mouseInCanvas.current &&
         viewport?.current &&
         !viewport?.current?.pause &&
         node &&
-        popups.filter(p => p.node._id === node._id).length === 0
+        popups.filter(p => p.node.id === node.id).length === 0
       ) {
-        setQuickPopup({ node: props.graph.nodes[node._id], pos: toGlobal(node) });
+        setQuickPopup({ node: props.graph.nodes[node.id], pos: toGlobal(node) });
       }
     },
     onUnHover() {
@@ -355,7 +350,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
         setResponsiveScale(1);
       }
 
-      if (graph.current.nodes.length < config.LABEL_MAX_NODES) {
+      if (Object.values(props.graph.nodes).length < config.LABEL_MAX_NODES) {
         edgeLabelLayer.alpha = scale > 2 ? Math.min(1, (scale - 2) * 3) : 0;
 
         if (edgeLabelLayer.alpha > 0) {
@@ -485,7 +480,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
     };
   }, []);
 
-  function toGlobal(node: NodeTypeD3): PointData {
+  function toGlobal(node: NodeType): PointData {
     if (viewport?.current) {
       // const rect = ref.current?.getBoundingClientRect();
       const rect = { x: 0, y: 0 };
@@ -495,119 +490,33 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
     } else return { x: 0, y: 0 };
   }
 
-  const updateNode = (node: NodeTypeD3) => {
-    const gfx = nodeMap.current.get(node._id);
+  const updateNode = (node: NodeType) => {
+    const gfx = nodeMap.current.get(node.id);
     if (!gfx) return;
 
     // Update texture when selected
-    const nodeMeta = props.graph.nodes[node._id];
+    const nodeMeta = props.graph.nodes[node.id];
     if (nodeMeta == null) return;
 
     const texture = (gfx as any).selected ? selectedTexture : glyphTexture;
     gfx.texture = texture;
-
-    // Cluster colors
-    if (nodeMeta?.cluster) {
-      gfx.tint = nodeMeta.cluster >= 0 ? nodeColor(nodeMeta.cluster) : 0x000000;
-    } else {
-      gfx.tint = nodeColor(nodeMeta.type);
-    }
-
     gfx.position.set(node.x, node.y);
-
-    // 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 getNodeLabel = (nodeMeta: NodeType) => {
-    let attribute;
-    try {
-      attribute = imperative.current.getNodeAttributes()[nodeMeta.label];
-    } catch (e) {
-      return nodeMeta.label ?? '';
-    }
-
-    if (attribute == 'Default' || attribute == null) {
-      return nodeMeta.label ?? '';
-    }
-
-    const value = nodeMeta.attributes[attribute];
-
-    if (Array.isArray(value)) {
-      return value.join(', ');
-    }
-
-    if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') {
-      return String(value);
-    }
-
-    if (typeof value === 'object' && Object.keys(value).length != 0) {
-      return JSON.stringify(value);
-    }
-
-    return '-';
-  };
-
-  const getEdgeLabel = (edgeMeta: EdgeType) => {
-    let attribute;
-    try {
-      attribute = imperative.current.getEdgeAttributes()[edgeMeta.attributes.type];
-    } catch (e) {
-      return edgeMeta.attributes.type ?? '';
-    }
-
-    if (attribute == 'None') {
-      return '';
-    }
-
-    if (attribute == 'Default' || attribute == null) {
-      return edgeMeta.attributes.type ?? '';
-    }
-
-    const value = edgeMeta.attributes[attribute];
-
-    if (Array.isArray(value)) {
-      return value.join(', ');
-    }
-
-    if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') {
-      return String(value);
-    }
-
-    if (typeof value === 'object' && Object.keys(value).length != 0) {
-      return JSON.stringify(value);
-    }
-
-    return '';
-  };
-
-  const createNode = (node: NodeTypeD3, selected?: boolean) => {
-    const nodeMeta = props.graph.nodes[node._id];
-
+  const createNode = (node: NodeType, selected?: boolean) => {
     // check if node is already drawn, and if so, delete it
-    if (node && node?._id && nodeMap.current.has(node._id)) {
-      nodeMap.current.delete(node._id);
+    if (node && node?.id && nodeMap.current.has(node.id)) {
+      nodeMap.current.delete(node.id);
     }
+
     // Do not draw node if it has no position
     if (node.x === undefined || node.y === undefined) return;
 
     const texture = glyphTexture;
     const sprite = new Sprite(texture);
 
-    sprite.tint = nodeColor(nodeMeta.type);
-    const scale = (Math.max(nodeMeta.radius || 5, 5) / config.NODE_RADIUS) * 2;
+    sprite.tint = node.color;
+    const scale = (Math.max(node.radius || 5, 5) / config.NODE_RADIUS) * 2;
     sprite.scale.set(scale, scale);
     sprite.anchor.set(0.5, 0.5);
     sprite.cullable = true;
@@ -618,14 +527,14 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
     sprite.on('mouseover', e => imperative.current?.onHover(e));
     sprite.on('mouseout', e => imperative.current?.onUnHover(e));
 
-    nodeMap.current.set(node._id, sprite);
+    nodeMap.current.set(node.id, sprite);
     nodeLayer.addChild(sprite);
 
     updateNode(node);
     (sprite as any).node = node;
 
     // Node label
-    const attribute = getNodeLabel(nodeMeta);
+    const attribute = node.label;
     const text = new Text({
       text: attribute,
       style: {
@@ -641,7 +550,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
     text.cullable = true;
     text.anchor.set(0.5, 0.5);
     text.scale.set(0.1, 0.1);
-    nodeLabelMap.current.set(node._id, text);
+    nodeLabelMap.current.set(node.id, text);
     nodeLabelLayer.addChild(text);
 
     updateNodeLabel(node);
@@ -649,16 +558,13 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
     return sprite;
   };
 
-  const createEdgeLabel = (edge: EdgeTypeD3) => {
+  const createEdgeLabel = (edge: EdgeType) => {
     // check if edge is already drawn, and if so, delete it
-    if (edge && edge?._id && edgeLabelMap.current.has(edge._id)) {
-      edgeLabelMap.current.delete(edge._id);
+    if (edge && edge?.id && edgeLabelMap.current.has(edge.id)) {
+      edgeLabelMap.current.delete(edge.id);
     }
 
-    const edgeMeta = metaEdges?.[edge._id];
-    if (edgeMeta == null) return;
-
-    const label = getEdgeLabel(edgeMeta);
+    const label = edge.label;
     const text = new Text({
       text: label,
       style: {
@@ -673,7 +579,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
     text.cullable = true;
     text.anchor.set(0.5, 0.5);
     text.scale.set(0.1, 0.1);
-    edgeLabelMap.current.set(edge._id, text);
+    edgeLabelMap.current.set(edge.id, text);
     edgeLabelLayer.addChild(text);
 
     updateEdgeLabel(edge);
@@ -681,23 +587,21 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
     return text;
   };
 
-  const updateEdge = (edge: EdgeTypeD3, edgeBundle?: Point[]) => {
+  const updateEdge = (edge: EdgeType, edgeBundle?: Point[]) => {
     const multiple =
-      imperative.current.getShowMultipleEdges() && graph.current.edges.length < PERF_EDGE_THRESHOLD
-        ? graph.current.edges.filter(
+      imperative.current.getShowMultipleEdges() && Object.values(props.graph.edges).length < PERF_EDGE_THRESHOLD
+        ? Object.values(props.graph.edges).filter(
             x => (x.source == edge.source && x.target == edge.target) || (x.source == edge.target && x.target == edge.source),
           ).length
         : 0;
 
-    const edgeMeta = metaEdges?.[edge._id];
-    if (edgeMeta == null) return;
-
-    const { style, color, alpha } = getEdgeStyle(edgeMeta);
+    const target = nodeMap.current.get(edge.target as string) as Sprite;
+    const source = nodeMap.current.get(edge.source as string) as Sprite;
 
-    const sx = edgeMeta._source!.x as number;
-    const sy = edgeMeta._source!.y as number;
-    let tx = edgeMeta._target!.x as number;
-    let ty = edgeMeta._target!.y as number;
+    const sx = source.x;
+    const sy = source.y;
+    let tx = target.x;
+    let ty = target.y;
 
     const arrow = imperative.current.getShowArrows();
     let ax, ay;
@@ -716,23 +620,23 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
 
     // Draw the edge
     // - Self-loops
-    if (edge.source === edge.target && edgeMeta._target!.x != null && edgeMeta._target!.y != null) {
+    if (edge.source === edge.target && target.x != null && target.y != null) {
       const selfLoopSize = 30;
       edgeGfx
-        .moveTo(edgeMeta._source!.x || 0, edgeMeta._source!.y || 0)
+        .moveTo(source.x || 0, source.y || 0)
         .bezierCurveTo(
-          edgeMeta._target!.x - selfLoopSize,
-          edgeMeta._target!.y - selfLoopSize,
-          edgeMeta._target!.x + selfLoopSize,
-          edgeMeta._target!.y - selfLoopSize,
-          edgeMeta._target!.x,
-          edgeMeta._target!.y,
+          target.x - selfLoopSize,
+          target.y - selfLoopSize,
+          target.x + selfLoopSize,
+          target.y - selfLoopSize,
+          target.x,
+          target.y,
           0.9,
         )
         .stroke({
-          width: style,
-          color: color,
-          alpha: alpha,
+          width: edge.style,
+          color: edge.color,
+          alpha: edge.alpha,
         });
       return;
     }
@@ -746,9 +650,9 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       });
 
       edgeGfx.stroke({
-        width: style,
-        color: color,
-        alpha: alpha,
+        width: edge.style,
+        color: edge.color,
+        alpha: edge.alpha,
       });
     } else if (imperative.current.getShowMultipleEdges() && multiple > 1) {
       // Perpendicular vector
@@ -772,16 +676,16 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
           .moveTo(sx + ox, sy + oy)
           .lineTo(tx + ox, ty + oy)
           .stroke({
-            width: style,
-            color: color,
-            alpha: alpha,
+            width: edge.style,
+            color: edge.color,
+            alpha: edge.alpha,
           });
       }
     } else {
       edgeGfx.moveTo(sx, sy).lineTo(tx, ty).stroke({
-        width: style,
-        color: color,
-        alpha: alpha,
+        width: edge.style,
+        color: edge.color,
+        alpha: edge.alpha,
       });
     }
 
@@ -804,9 +708,9 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
         .moveTo(tx, ty)
         .lineTo(tx + arrow1_x * arrowSize, ty + arrow1_y * arrowSize)
         .stroke({
-          width: style,
-          color: color,
-          alpha: alpha,
+          width: edge.style,
+          color: edge.color,
+          alpha: edge.alpha,
         });
 
       // -- Arrow head line 2
@@ -817,59 +721,18 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
         .moveTo(tx, ty)
         .lineTo(tx + arrow2_x * arrowSize, ty + arrow2_y * arrowSize)
         .stroke({
-          width: style,
-          color: color,
-          alpha: alpha,
+          width: edge.style,
+          color: edge.color,
+          alpha: edge.alpha,
         });
     }
   };
 
-  const getEdgeStyle = (edgeMeta: EdgeType) => {
-    // let color = edge.color || 0x000000;
-    let color = config.LINE_COLOR_DEFAULT;
-    let style = imperative.current.getEdgeWidth();
-    let alpha = edgeMeta.alpha || 1;
-    if (edgeMeta.mlEdge) {
-      color = config.LINE_COLOR_ML;
-      if (edgeMeta.value > ml.communityDetection.jaccard_threshold) {
-        style = edgeMeta.value * 1.8;
-      } else {
-        style = 0;
-        alpha = 0.2;
-      }
-    } else if (props.highlightedLinks && props.highlightedLinks.includes(edgeMeta)) {
-      if (edgeMeta.mlEdge && ml.communityDetection.jaccard_threshold) {
-        if (edgeMeta.value > ml.communityDetection.jaccard_threshold) {
-          color = dataColors.magenta[50];
-          // 0xaa00ff;
-          style = edgeMeta.value * 1.8;
-        }
-      } else {
-        color = dataColors.red[70];
-        // color = 0xff0000;
-        style = 1.0;
-      }
-    } else if (props.currentShortestPathEdges && props.currentShortestPathEdges.includes(edgeMeta)) {
-      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 === false); //FIXME: needs to match edge._id
-      alpha = isLinkInSearchResults ? 1 : 0.05;
-    }
-
-    return { style, color, alpha };
-  };
-
-  const updateEdgeLabel = (edge: EdgeTypeD3) => {
-    if (graph.current.nodes.length > config.LABEL_MAX_NODES || viewport.current!.scale.x < 2) return;
+  const updateEdgeLabel = (edge: EdgeType) => {
+    if (Object.keys(props.graph.nodes).length > config.LABEL_MAX_NODES || viewport.current!.scale.x < 2) return;
 
-    const text = edgeLabelMap.current.get(edge._id);
-    if (!text) return;
+    const text = edgeLabelMap.current.get(edge.id);
+    if (!text || edge.label == null) return;
 
     const _source = edge.source;
     const _target = edge.target;
@@ -881,10 +744,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
     const source = nodeMap.current.get(edge.source as string) as Sprite;
     const target = nodeMap.current.get(edge.target as string) as Sprite;
 
-    const edgeMeta = metaEdges?.[edge._id];
-    if (edgeMeta == null) return;
-
-    text.text = getEdgeLabel(edgeMeta);
+    text.text = edge.label;
 
     text.x = (source.x + target.x) / 2;
     text.y = (source.y + target.y) / 2;
@@ -915,25 +775,22 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
     (text.style.stroke as StrokeStyle).color = imperative.current.getBackgroundColor();
   };
 
-  const updateNodeLabel = (node: NodeTypeD3) => {
-    if (graph.current.nodes.length > config.LABEL_MAX_NODES || viewport.current!.scale.x < 2) return;
-    const text = nodeLabelMap.current.get(node._id) as Text | undefined;
-    if (text == null) return;
+  const updateNodeLabel = (node: NodeType) => {
+    if (Object.keys(props.graph.nodes).length > config.LABEL_MAX_NODES || viewport.current!.scale.x < 2) return;
+    const text = nodeLabelMap.current.get(node.id) as Text | undefined;
+    if (text == null || node.label == null) return;
 
     if (node.x) text.x = node.x;
     if (node.y) text.y = node.y;
 
-    const nodeMeta = props.graph.nodes[node._id];
-    const originalText = getNodeLabel(nodeMeta);
-
-    text.text = originalText; // This is required to ensure the text size check (next line) works
+    text.text = node.label; // This is required to ensure the text size check (next line) works
 
     if (text.width / text.scale.x <= 90 && text.height / text.scale.y <= 90) {
-      text.text = originalText;
+      text.text = node.label;
     } else {
       // Change character limit at specific scale intervals
       const charLimit = responsiveScale > 0.2 ? 15 : responsiveScale > 0.1 ? 30 : 75;
-      text.text = `${originalText.slice(0, charLimit)}…`;
+      text.text = `${node.label.slice(0, charLimit)}…`;
     }
 
     text.alpha = text.width / text.scale.x <= 90 && text.height / text.scale.y <= 90 ? 1 : 0;
@@ -988,10 +845,10 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
 
   useEffect(() => {
     if (props.graph) {
-      graph.current.nodes.forEach(node => {
-        const gfx = nodeMap.current.get(node._id);
+      Object.values(props.graph.nodes).forEach(node => {
+        const gfx = nodeMap.current.get(node.id);
         if (!gfx) return;
-        const isNodeInSearchResults = searchResults.nodes.some(resultNode => resultNode.id === node._id);
+        const isNodeInSearchResults = searchResults.nodes.some(resultNode => resultNode.id === node.id);
 
         gfx.alpha = isNodeInSearchResults || searchResults.nodes.length === 0 ? 1 : 0.05;
       });
@@ -1004,12 +861,12 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       if (edgeBundling == null && imperative.current?.getEdgeBundlingEnabled()) {
         edgeBundling = ForceEdgeBundling()
           .nodes(
-            graph.current.nodes.reduce((a, b) => {
-              return { ...a, [b._id]: { x: b.x, y: b.y } };
+            Object.values(props.graph.nodes).reduce((a, b) => {
+              return { ...a, [b.id]: { x: b.x, y: b.y } };
             }, {}),
           )
           // @ts-expect-error - edgeBundling is not null
-          .edges(graph.current.edges)();
+          .edges(Object.values(props.graph.edges))();
       } else {
         return;
       }
@@ -1023,17 +880,20 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
 
       const widthHalf = app.renderer.width / 2;
       const heightHalf = app.renderer.height / 2;
-      graph.current.nodes.forEach((node, i) => {
-        const gfx = nodeMap.current.get(node._id);
+      Object.values(props.graph.nodes).forEach((node, i) => {
+        const gfx = nodeMap.current.get(node.id);
         if (!gfx || node.x === undefined || node.y === undefined) {
           stopped += 1;
           return;
         }
 
-        const position = layoutAlgorithm.current.getNodePosition(node._id);
+        const position = layoutAlgorithm.current.getNodePosition(node.id);
 
         if (!position || Math.abs(node.x - position.x - widthHalf) + Math.abs(node.y - position.y - heightHalf) < 5) {
           stopped += 1;
+        } else {
+          node.x = position.x;
+          node.y = position.y;
         }
 
         if (layoutAlgorithm.current.provider === 'Graphology') {
@@ -1050,7 +910,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
         updateNodeLabel(node);
       });
 
-      if (stopped === graph.current.nodes.length) {
+      if (stopped === Object.keys(props.graph.nodes).length) {
         layoutStoppedCount.current = layoutStoppedCount.current + 1;
         if (layoutStoppedCount.current > 500) {
           layoutState.current = 'paused';
@@ -1067,22 +927,22 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       // Draw the edges
       edgeGfx.clear();
 
-      if (props.graph != null && nodeMap.current.size !== 0 && metaEdges != null) {
-        if (graph.current.edges.length > PERF_EDGE_THRESHOLD) {
+      if (props.graph != null && nodeMap.current.size !== 0 && props.graph.edges != null) {
+        if (Object.keys(props.graph.edges).length > PERF_EDGE_THRESHOLD) {
           // If many many edges, only update edges roughy 1/3th the time, dont draw labels, dont do edge bundling.
           if (Math.random() > 0.3) {
-            for (const link of graph.current.edges) {
-              updateEdge(link);
+            for (const edge of Object.values(props.graph.edges)) {
+              updateEdge(edge);
             }
           }
         } else {
-          for (const [i, link] of graph.current.edges.entries()) {
+          for (const [i, edge] of Object.values(props.graph.edges).entries()) {
             if (edgeBundling != null && imperative.current.getEdgeBundlingEnabled()) {
-              updateEdge(link, edgeBundling[i]); // FIXME: edgeBundling omits self-loops, index may not always match exactly!
+              updateEdge(edge, edgeBundling[i]); // FIXME: edgeBundling omits self-loops, index may not always match exactly!
             } else {
-              updateEdge(link);
+              updateEdge(edge);
             }
-            updateEdgeLabel(link);
+            updateEdgeLabel(edge);
           }
         }
       }
@@ -1107,7 +967,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       }
 
       nodeMap.current.forEach((gfx, id) => {
-        if (!graph.current.nodes.find(node => node._id === id)) {
+        if (!props.graph.nodes[id]) {
           nodeLayer.removeChild(gfx);
           gfx.destroy();
           nodeMap.current.delete(id);
@@ -1115,7 +975,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       });
 
       edgeLabelMap.current.forEach((text, id) => {
-        if (!graph.current.edges.find(link => link._id === id)) {
+        if (!props.graph.edges[id]) {
           edgeLabelLayer.removeChild(text);
           text.destroy();
           edgeLabelMap.current.delete(id);
@@ -1124,9 +984,9 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
 
       edgeGfx.clear();
 
-      graph.current.nodes.forEach(node => {
-        if (!forceClear && nodeMap.current.has(node._id)) {
-          const old = nodeMap.current.get(node._id);
+      Object.values(props.graph.nodes).forEach(node => {
+        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;
@@ -1137,12 +997,12 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
         }
       });
 
-      if (graph.current.nodes.length < config.LABEL_MAX_NODES) {
-        for (const link of graph.current.edges) {
-          if (!forceClear && edgeLabelMap.current.has(link._id)) {
-            updateEdgeLabel(link);
+      if (Object.keys(props.graph.nodes).length < config.LABEL_MAX_NODES) {
+        for (const edge of Object.values(props.graph.edges)) {
+          if (!forceClear && edgeLabelMap.current.has(edge.id)) {
+            updateEdgeLabel(edge);
           } else {
-            createEdgeLabel(link);
+            createEdgeLabel(edge);
           }
         }
       }
@@ -1177,16 +1037,6 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
 
     if (!props.graph) throw Error('Graph is undefined');
 
-    //Setup d3 graph structure
-    graph.current = {
-      nodes: Object.values(props.graph.nodes).map(n => ({ _id: n._id, x: n.defaultX, y: n.defaultY })),
-      edges: Object.values(props.graph.edges).map(l => ({
-        _id: l.id,
-        source: l.source,
-        target: l.target,
-      })),
-    };
-
     const size = ref.current?.getBoundingClientRect();
     viewport.current = new Viewport({
       screenWidth: size?.width || 1000,
@@ -1239,18 +1089,18 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
     if (!layoutAlgorithm) throw Error('LayoutAlgorithm is undefined');
 
     const graphologyGraph = new MultiGraph();
-    graph.current.nodes.forEach(node => {
-      if (forceClear) graphologyGraph.addNode(node._id, { size: props.graph.nodes[node._id].radius || 5 });
+    Object.values(props.graph.nodes).forEach(node => {
+      if (forceClear) graphologyGraph.addNode(node.id, { size: node.radius || 5 });
       else
-        graphologyGraph.addNode(node._id, {
-          size: props.graph.nodes[node._id].radius || 5,
+        graphologyGraph.addNode(node.id, {
+          size: node.radius || 5,
           x: node.x || 0,
           y: node.y || 0,
         });
     });
 
-    for (const link of graph.current.edges) {
-      graphologyGraph.addEdge(link.source, link.target);
+    for (const edge of Object.values(props.graph.edges)) {
+      graphologyGraph.addEdge(edge.source, edge.target);
     }
     const boundingBox = { x1: 0, x2: app!.renderer.screen.width, y1: 0, y2: app!.renderer.screen.height };
 
@@ -1265,12 +1115,12 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
 
   return (
     <>
-      {popups.map(popup => (
-        <Popover key={popup.node._id} open={true} interactive={!dragging} boundaryElement={ref as RefObject<HTMLElement>} showArrow={true}>
+      {/* {popups.map(popup => (
+        <Popover key={popup.node.id} open={true} interactive={!dragging} boundaryElement={ref as RefObject<HTMLElement>} showArrow={true}>
           <PopoverTrigger x={popup.pos.x} y={popup.pos.y} />
           <PopoverContent>
-            <NodeDetails name={popup.node._id} colorHeader={nodeColorHex(props.graph.nodes[popup.node._id].type)}>
-              <NLPopUp data={props.graph.nodes[popup.node._id].attributes} />
+            <NodeDetails name={popup.node.id} colorHeader={nodeColorHex(props.graph.nodes[popup.node.id].type)}>
+              <NLPopUp data={props.graph.nodes[popup.node.id].attributes} />
             </NodeDetails>
           </PopoverContent>
         </Popover>
@@ -1284,7 +1134,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
             </NodeDetails>
           </PopoverContent>
         </Popover>
-      )}
+      )} */}
       <div
         className="h-full w-full overflow-hidden"
         ref={ref}
diff --git a/src/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx b/src/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx
index 20f958e82..821bbf77e 100644
--- a/src/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx
@@ -3,10 +3,13 @@
  * Utrecht University within the Software Project course.
  * © Copyright Utrecht University (Department of Information and Computing Sciences)
  */
-import { EdgeQueryResult, GraphQueryResultMetaFromBackend, ML, NodeQueryResult } from 'ts-common';
+import { VisualizationSettingsType } from '@/lib/vis/common';
+import { dataColors, EdgeQueryResult, GraphQueryResultMetaFromBackend, ML, NodeQueryResult, visualizationColors } from 'ts-common';
+import { v4 as uuidv4 } from 'uuid';
 import { GraphQueryResult } from '../../../../data-access/store';
+import { NodelinkVisProps } from '../nodelinkvis';
 import { EdgeType, GraphType, NodeType } from '../types';
-import { processML } from './NLMachineLearning';
+import { nodeColor } from './utils';
 /** ResultNodeLinkParserUseCase implements methods to parse and translate websocket messages from the backend into a GraphType. */
 
 /**
@@ -84,17 +87,112 @@ type OptionsI = {
   defaultRadius?: number;
 };
 
+const getNodeLabel = (node: NodeQueryResult, d3node: NodeType, settings: NodelinkVisProps) => {
+  // let attribute;
+  // try {
+  //   attribute = node.attributes[node.label];
+  // } catch (e) {
+  //   return node.label ?? '';
+  // }
+  // if (attribute == 'Default' || attribute == null) {
+  //   return node.label ?? '';
+  // }
+  // const value = node.attributes[attribute];
+  // if (Array.isArray(value)) {
+  //   return value.join(', ');
+  // }
+  // if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') {
+  //   return String(value);
+  // }
+  // if (typeof value === 'object' && Object.keys(value).length != 0) {
+  //   return JSON.stringify(value);
+  // }
+  // return '-';
+};
+
+const getEdgeLabel = (edge: EdgeQueryResult, d3edge: EdgeType, settings: NodelinkVisProps) => {
+  // let attribute;
+  // try {
+  //   attribute = imperative.current.getEdgeAttributes()[edgeMeta.attributes.type];
+  // } catch (e) {
+  //   return edgeMeta.attributes.type ?? '';
+  // }
+  // if (attribute == 'None') {
+  //   return '';
+  // }
+  // if (attribute == 'Default' || attribute == null) {
+  //   return edgeMeta.attributes.type ?? '';
+  // }
+  // const value = edgeMeta.attributes[attribute];
+  // if (Array.isArray(value)) {
+  //   return value.join(', ');
+  // }
+  // if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') {
+  //   return String(value);
+  // }
+  // if (typeof value === 'object' && Object.keys(value).length != 0) {
+  //   return JSON.stringify(value);
+  // }
+  // return '';
+};
+
+const LINE_COLOR_DEFAULT = dataColors.neutral[40];
+const LINE_COLOR_SELECTED = visualizationColors.GPSelected.colors[1];
+const LINE_COLOR_ML = dataColors.blue[60];
+const LINE_WIDTH_DEFAULT = 0.8;
+
+const getEdgeStyle = (edge: EdgeQueryResult, ml: ML) => {
+  // let color = edge.color || 0x000000;
+  let color = LINE_COLOR_DEFAULT;
+  const thickness = (edge.attributes.jaccard_coefficient as number) || 1;
+  let style = thickness || 1;
+  let alpha = 1;
+  let mlEdge = false;
+
+  // Parse ml edges
+  if (ml != undefined && ml.linkPrediction.result.find(link => link.from === edge.from && link.to === edge.to)) {
+    mlEdge = true;
+  }
+
+  if (mlEdge) {
+    color = LINE_COLOR_ML;
+    if (thickness > ml.communityDetection.jaccard_threshold) {
+      style = thickness * 1.8;
+    } else {
+      style = 0;
+      alpha = 0.2;
+    }
+  }
+
+  // TODO
+  // 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 === false); //FIXME: needs to match edge._id
+  //   alpha = isLinkInSearchResults ? 1 : 0.05;
+  // }
+
+  return { style, color, alpha, thickness };
+};
+
 /**
  * Parse a websocket message containing a query result into a node edge GraphType.
  * @param {any} queryResult An incoming query result from the websocket.
  * @returns {GraphType} A node-link graph containing the nodes and edges for the diagram.
  */
-export function parseQueryResult(queryResult: GraphQueryResultMetaFromBackend, ml: ML, options: OptionsI = {}): GraphType {
+export function parseQueryResult(
+  queryResult: GraphQueryResultMetaFromBackend,
+  ml: ML,
+  settings: NodelinkVisProps & VisualizationSettingsType,
+  options: OptionsI = {},
+): GraphType {
   const ret: GraphType = {
     nodes: {},
     edges: {},
   };
 
+  const nodeMap: Record<string, string> = {};
+
   const typeDict: { [key: string]: number } = {};
   // Counter for the types
   let counter = 1;
@@ -106,7 +204,8 @@ export function parseQueryResult(queryResult: GraphQueryResultMetaFromBackend, m
   const 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 nodeId = uuidv4();
+    nodeMap[queryResult.nodes[i]._id] = nodeId;
     // for datasets without label, label is included in id. eg. "kamerleden/112"
     //const entityType = queryResult.nodes[i].label;
     const node = queryResult.nodes[i];
@@ -125,21 +224,18 @@ export function parseQueryResult(queryResult: GraphQueryResultMetaFromBackend, m
     }
 
     // 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;
 
     const radius = options.defaultRadius || 5;
     const data: NodeType = {
-      _id: queryResult.nodes[i]._id,
-      label: entityType,
-      attributes: queryResult.nodes[i].attributes,
-      type: typeNumber,
-      displayInfo: preferredText,
+      id: nodeId,
+      ids: [queryResult.nodes[i]._id],
+      color: nodeColor(typeNumber),
+      label: entityType, // TODO
       radius: radius,
-      defaultX: (options.defaultX || 0) + Math.random() * radius * 20 - radius * 10,
-      defaultY: (options.defaultY || 0) + Math.random() * radius * 20 - radius * 10,
+      x: options.defaultX || 0,
+      y: options.defaultY || 0,
     };
 
     // let mlExtra = {};
@@ -163,42 +259,23 @@ export function parseQueryResult(queryResult: GraphQueryResultMetaFromBackend, m
 
     // Add mlExtra to the node if necessary
     // data = { ...data, ...mlExtra };
-    ret.nodes[data._id] = data;
+    ret.nodes[nodeId] = data;
   }
 
   // Filter unique edges and transform to LinkTypes
   // List for all links
 
-  // 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
   ret.edges = queryResult.edges
     .map(e => {
       return {
-        id: e.from + ':' + e.to + ':' + e.label,
-        source: e.from,
-        target: e.to,
-        value: (e.attributes.jaccard_coefficient as number) || 1,
-        name: e.label,
-        mlEdge: false,
-        color: 0x000000,
-        attributes: e.attributes,
-      } as EdgeType;
+        id: uuidv4(),
+        ids: [e._id],
+        source: nodeMap[e.from],
+        target: nodeMap[e.to],
+        label: e.label, // TODO
+        ...getEdgeStyle(e, ml),
+      };
     })
     .reduce((a, b) => {
       return { ...a, [b.id]: b };
@@ -222,5 +299,139 @@ export function parseQueryResult(queryResult: GraphQueryResultMetaFromBackend, m
   // }
 
   // return toBeReturned;
-  return processML(ml, ret);
+  // return processML(ml, ret);
+  return ret;
 }
+
+// export function parseLabelAggregationQueryResult(
+//   queryResult: GraphQueryResultMetaFromBackend,
+//   ml: ML,
+//   settings: NodelinkVisProps & VisualizationSettingsType,
+//   options: OptionsI = {},
+// ): GraphType {
+//   const labels = queryResult.nodes.map(node => node.label);
+//   const uniqueLabels = [...new Set(labels)];
+
+//   const ret: GraphType = {
+//     nodes: {},
+//     edges: {},
+//   };
+
+//   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;
+//     // for datasets without label, label is included in id. eg. "kamerleden/112"
+//     //const entityType = queryResult.nodes[i].label;
+//     const node = queryResult.nodes[i];
+//     const entityType: string = node.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
+//     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;
+
+//     const radius = options.defaultRadius || 5;
+//     const data: NodeType = {
+//       _id: queryResult.nodes[i]._id,
+//       label: entityType,
+//       attributes: queryResult.nodes[i].attributes,
+//       type: typeNumber,
+//       displayInfo: preferredText,
+//       radius: radius,
+//       defaultX: 0, // seems not to be used
+//       defaultY: 0, // seems not to be used
+//     };
+
+//     // 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 };
+//     ret.nodes[data._id] = data;
+//   }
+
+//   // Filter unique edges and transform to LinkTypes
+//   // List for all links
+
+//   // 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
+//   ret.edges = queryResult.edges
+//     .map(e => {
+//       return {
+//         id: e.from + ':' + e.to + ':' + e.label,
+//         source: e.from,
+//         target: e.to,
+//         value: (e.attributes.jaccard_coefficient as number) || 1,
+//         name: e.label,
+//         mlEdge: false,
+//         color: 0x000000,
+//         attributes: e.attributes,
+//       } as EdgeType;
+//     })
+//     .reduce((a, b) => {
+//       return { ...a, [b.id]: b };
+//     }, {});
+
+//   // 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, ret);
+// }
diff --git a/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx b/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx
index 7ad7507c3..2aaa54340 100644
--- a/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx
@@ -1,12 +1,11 @@
 import { visualizationColors } from '@/config';
-import { EdgeType, GraphType, NodeType } from '../types';
 
 /**
  * Colour is a function that takes a string of a number and returns a number of a color out of the d3 color scheme.
  * @param num Num is the input string representing a number of a colorgroup.
  * @returns {number} A number corresponding to a color in the d3 color scheme.
  */
-export function nodeColor(num: number) {
+export function nodeColor(num: number): number {
   // num = num % 4;
   // const col = '#000000';
   //let entityColors = Object.values(visualizationColors.GPSeq.colors[9]);
@@ -60,41 +59,3 @@ export function hslStringToHex(hsl: string) {
   };
   return `#${f(0)}${f(8)}${f(4)}`;
 }
-
-/**
- * Used when you select nodes.
- * The highlight is drawn in ticked.
- * @param nodes The nodes you want to related edges to.
- * @returns {EdgeType[]} All the links related to all the nodes
- */
-export const getRelatedLinks = (graph: GraphType, nodes: NodeType[], jaccardThreshold: number): EdgeType[] => {
-  const relatedLinks: EdgeType[] = [];
-  Object.keys(graph.edges).forEach(id => {
-    const link = graph.edges[id];
-    const { source, target } = link;
-    if (isLinkVisible(link, jaccardThreshold)) {
-      nodes.forEach((node: NodeType) => {
-        if (source == node._id || target == node._id || source == node._id || target == node._id) {
-          relatedLinks.push(link);
-        }
-      });
-    }
-  });
-  return relatedLinks;
-};
-
-/**
- * Checks wheter a link is visible.
- * This is used for highlighting nodes.
- * @param link The link you want to check wheter it's visable or not
- * @returns {boolean}
- */
-export function isLinkVisible(link: EdgeType, jaccardThreshold: number): boolean {
-  //About the next line, If you don't do this here but lets say in the constructor it errors. So far no performance issues where noticed.
-  if (link.mlEdge) {
-    if (link.value > jaccardThreshold) {
-      return true;
-    }
-  } else return true;
-  return false;
-}
diff --git a/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx b/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx
index 0e093296f..d1c3459c1 100644
--- a/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx
@@ -1,18 +1,12 @@
-import { canViewFeature } from '@/lib/components/featureFlags';
-import { Input } from '@/lib/components/inputs';
-import { EntityPill } from '@/lib/components/pills/Pill';
-import { SettingsContainer } from '@/lib/vis/components/config';
 import { type PointData } from 'pixi.js';
 import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
 import { ML, NodeQueryResult } from 'ts-common';
 import { useImmer } from 'use-immer';
-import { setShortestPathSource, setShortestPathTarget } from '../../../data-access/store/mlSlice';
 import { Layouts, LayoutTypes } from '../../../graph-layout/types';
 import { VISComponentType, VisualizationPropTypes, VisualizationSettingsPropTypes } from '../../common';
 import { NLPixi } from './components/NLPixi';
 import { parseQueryResult } from './components/query2NL';
-import { nodeColorHex } from './components/utils';
-import { EdgeType, GraphType, NodeType, NodeTypeD3 } from './types';
+import { EdgeType, GraphType, NodeType } from './types';
 
 // For backwards compatibility with older saveStates, we migrate information from settings.nodes to settings.location
 // FIXME: this can be removed once all systems have updated their saveStates.
@@ -37,10 +31,15 @@ export interface NodeLinkVisHandle {
   exportImageInternal: () => void;
 }
 
+export const NLAggregationTypeArray = ['none', 'entity', 'attribute'] as const;
+export type NLAggregationType = (typeof NLAggregationTypeArray)[number];
+
 export type NodelinkVisProps = {
   id: string;
   name: string;
   layout: LayoutTypes;
+  aggregation: NLAggregationType;
+  aggregationAttribute?: string;
   showPopUpOnHover: boolean;
   nodes: {
     shape: {
@@ -66,6 +65,7 @@ export type NodelinkVisProps = {
 const settings: NodelinkVisProps = {
   id: 'NodeLinkVis',
   name: 'NodeLinkVis',
+  aggregation: 'none',
   layout: Layouts.FORCEATLAS2WEBWORKER,
   showPopUpOnHover: false,
   nodes: {
@@ -98,11 +98,11 @@ const NodeLinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin
 
     useEffect(() => {
       if (data) {
-        setGraph(parseQueryResult(data.graph, ml));
+        setGraph(parseQueryResult(data.graph, ml, settings));
       }
     }, [data, ml]);
 
-    const onClickedNode = (event?: { node: NodeTypeD3; pos: PointData }, ml?: ML) => {
+    const onClickedNode = (event?: { node: NodeType; pos: PointData }, ml?: ML) => {
       if (graph) {
         if (!event?.node) {
           if (handleSelect) handleSelect();
@@ -110,35 +110,9 @@ const NodeLinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin
         }
 
         const node = event.node;
-        const nodeMeta = graph.nodes[node._id];
-        if (handleSelect) handleSelect({ nodes: [nodeMeta as NodeQueryResult] });
-
-        if (ml && ml.shortestPath?.enabled) {
-          setGraph(draft => {
-            const _node = draft?.nodes[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));
-            }
-            return draft;
-          });
-        }
+        const nodeMeta: NodeQueryResult[] = data?.graph.nodes.filter(n => node.ids.includes(n._id));
+        if (!nodeMeta) return;
+        if (handleSelect) handleSelect({ nodes: nodeMeta });
       }
     };
 
@@ -170,135 +144,7 @@ const NodeLinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin
   },
 );
 
-const NodelinkSettings = ({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<NodelinkVisProps>) => {
-  useEffect(() => {
-    if (graphMetadata && graphMetadata.nodes && graphMetadata.nodes.labels.length > 0) {
-      updateSettings({ nodeList: graphMetadata.nodes.labels });
-    }
-  }, [graphMetadata]);
-
-  if (!settings.nodeList) return null;
-
-  return (
-    <SettingsContainer>
-      <div className="mb-4 text-xs">
-        <h1 className="font-bold">General</h1>
-        <div className="m-1 flex flex-col space-y-2 mb-2">
-          <h4 className="font-semibold">Nodes Labels:</h4>
-          {settings.nodeList.map((item, index) => (
-            <div className="flex m-1 items-center" key={item}>
-              <div className="w-3/4 mr-6">
-                <EntityPill title={item} />
-              </div>
-              <div className="w-1/2">
-                <div className={`h-5 w-5 border-2 border-sec-300`} style={{ backgroundColor: nodeColorHex(index + 1) }}></div>
-              </div>
-            </div>
-          ))}
-        </div>
-        <Input
-          type="dropdown"
-          label="Layout"
-          size="sm"
-          inline={false}
-          value={settings.layout}
-          options={Object.values(Layouts) as string[]}
-          onChange={val => updateSettings({ layout: val as LayoutTypes })}
-        />
-        <Input
-          type="boolean"
-          label="Show pop-up on hover"
-          value={settings.showPopUpOnHover}
-          onChange={val => updateSettings({ showPopUpOnHover: val })}
-        />
-      </div>
-
-      <div className="mb-4">
-        <h1 className="font-bold">Nodes</h1>
-        <div>
-          <span className="text-xs font-semibold">Shape</span>
-          <Input
-            type="dropdown"
-            label="Shape"
-            value={settings.nodes.shape.type}
-            options={[{ circle: 'Circle' }, { rectangle: 'Square' }]}
-            onChange={val =>
-              updateSettings({
-                nodes: {
-                  ...settings.nodes,
-                  shape: {
-                    ...settings.nodes.shape,
-                    type: val as 'circle' | 'rectangle',
-                  },
-                },
-              })
-            }
-          />
-        </div>
-      </div>
-
-      <div>
-        <h1 className="font-bold">Edges</h1>
-        <div>
-          <span className="text-xs font-semibold">Edge width</span>
-          <Input
-            type="slider"
-            label="Width"
-            size="sm"
-            className="my-1"
-            value={settings.edges.width.width}
-            onChangeConfirmed={val => updateSettings({ edges: { ...settings.edges, width: { ...settings.edges.width, width: val } } })}
-            min={0.1}
-            max={4}
-            step={0.1}
-          />
-        </div>
-      </div>
-      <div>
-        <h1 className="font-bold">Labels</h1>
-        {Object.entries(graphMetadata.edges.types).map(([label, type]) => (
-          <Input
-            type="dropdown"
-            size="sm"
-            key={label}
-            label={label}
-            value={settings.edges.labelAttributes ? settings.edges.labelAttributes[label] || 'Default' : undefined}
-            options={['Default', 'None', ...Object.keys(type.attributes).filter(x => x != 'Type')]}
-            onChange={val =>
-              updateSettings({
-                edges: {
-                  ...settings.edges,
-                  labelAttributes: { ...settings.edges.labelAttributes, [label]: val as string },
-                },
-              })
-            }
-          />
-        ))}
-      </div>
-      <div>
-        <Input type="boolean" label="Show arrows" value={settings.showArrows} onChange={val => updateSettings({ showArrows: val })} />
-      </div>
-      <div>
-        <Input
-          type="boolean"
-          label="Show multiple edges"
-          value={settings.showMultipleEdges}
-          onChange={val => updateSettings({ showMultipleEdges: val })}
-        />
-      </div>
-      {canViewFeature('EDGE_BUNDLING') ? (
-        <div>
-          <Input
-            type="boolean"
-            label="Edge bundling"
-            value={settings.edgeBundlingEnabled}
-            onChange={val => updateSettings({ edgeBundlingEnabled: val })}
-          />
-        </div>
-      ) : null}
-    </SettingsContainer>
-  );
-};
+const NodelinkSettings = ({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<NodelinkVisProps>) => {};
 const nodeLinkVisRef = React.createRef<{ exportImageInternal: () => void }>();
 
 export const NodeLinkComponent: VISComponentType<NodelinkVisProps> = {
diff --git a/src/lib/vis/visualizations/nodelinkvis/types.ts b/src/lib/vis/visualizations/nodelinkvis/types.ts
index 7c980e1cc..d4f8a59ec 100644
--- a/src/lib/vis/visualizations/nodelinkvis/types.ts
+++ b/src/lib/vis/visualizations/nodelinkvis/types.ts
@@ -4,99 +4,27 @@
  * © Copyright Utrecht University (Department of Information and Computing Sciences)
  */
 
-import * as PIXI from 'pixi.js';
-import { NodeQueryResult } from 'ts-common';
-
 /** Types for the nodes and links in the node-link diagram. */
 export type GraphType = {
-  nodes: Record<string, NodeType>; // _id -> node
-  edges: Record<string, EdgeType>; // _id -> link
-  // linkPrediction?: boolean;
-  // shortestPath?: boolean;
-  // communityDetection?: boolean;
-  // numberOfMlClusters?: number;
-};
-
-export type GraphTypeD3 = {
-  nodes: NodeTypeD3[];
-  edges: EdgeTypeD3[];
+  nodes: Record<string, NodeType>;
+  edges: Record<string, EdgeType>;
 };
 
-/** The interface for a node in the node-link diagram */
-export interface NodeType extends NodeQueryResult {
-  _id: string;
-
-  // Number to determine the color of the node
-  label: string;
-  type: number;
-  attributes: Record<string, any>;
-  cluster?: number;
-  clusterAccoringToMLData?: number;
-  shortestPathData?: Record<string, string[]>;
-
-  // Node that is drawn.
-  radius: number;
-  // Text to be displayed on top of the node.
-  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.
-  displayInfo?: string;
-  defaultX?: number;
-  defaultY?: number;
-}
-
-export type NodeTypeD3 = d3.SimulationNodeDatum & { _id: string };
-
-/** The interface for a link in the node-link diagram */
-export type EdgeType = {
-  // 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;
+export type NodeType = d3.SimulationNodeDatum & {
+  id: string; // uuid
+  ids: string[]; // reverse mapping to original _id
   color: number;
+  label?: string;
+  radius?: number;
   alpha?: number;
-  source: string;
-  target: string;
-  _source?: NodeTypeD3;
-  _target?: NodeTypeD3;
-  attributes: Record<string, any>;
 };
 
-export type EdgeTypeD3 = d3.SimulationLinkDatum<NodeTypeD3> & { _id: string };
-
-/**collectionNode holds 1 entry per node kind (so for example a MockNode with name "parties" and all associated attributes,) */
-export type TypeNode = {
-  name: string; //Collection name
-  attributes: string[]; //attributes. This includes all attributes found in the collection
-  type: number | undefined; //number that represents collection of node, for colorscheme
-  visualizations: Visualization[]; //The way to visualize attributes of this Node kind
-};
-
-export type CommunityDetectionNode = {
-  cluster: number; //group as used by colouring scheme
-};
-
-/**Visualization holds the visualization method for an attribute */
-export type Visualization = {
-  attribute: string; //attribute type      (e.g. 'age')
-  vis: string; //visualization type  (e.g. 'radius')
-};
-
-/** possible colors to pick from*/
-export type Colors = {
-  name: string;
-};
-
-/**AssignedColors is a simple holder for color selection  */
-export type AssignedColors = {
-  collection: number | undefined; //number of the collection (type or group)
-  color: string; //color in hex
-  default: string; //default color, for easy switching back
+export type EdgeType = d3.SimulationLinkDatum<NodeType> & {
+  id: string; // uuid
+  ids: string[]; // reverse mapping to original _id
+  label?: string;
+  thickness: number;
+  color: number;
+  alpha?: number;
+  style: number;
 };
-- 
GitLab


From 281ee4582a2f42b50c22d2e3dcf5e00944d78817 Mon Sep 17 00:00:00 2001
From: Leonardo Christino <lchristino@graphpolaris.com>
Date: Mon, 31 Mar 2025 14:26:26 +0200
Subject: [PATCH 02/12] fix: recover settings

---
 .../nodelinkvis/nodelinkvis.tsx               | 134 +++++++++++++++++-
 1 file changed, 133 insertions(+), 1 deletion(-)

diff --git a/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx b/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx
index d1c3459c1..b3c5ee195 100644
--- a/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx
@@ -1,11 +1,15 @@
+import { EntityPill, Input } from '@/lib/components';
+import { canViewFeature } from '@/lib/components/featureFlags';
 import { type PointData } from 'pixi.js';
 import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
 import { ML, NodeQueryResult } from 'ts-common';
 import { useImmer } from 'use-immer';
 import { Layouts, LayoutTypes } from '../../../graph-layout/types';
 import { VISComponentType, VisualizationPropTypes, VisualizationSettingsPropTypes } from '../../common';
+import { SettingsContainer } from '../../components/config';
 import { NLPixi } from './components/NLPixi';
 import { parseQueryResult } from './components/query2NL';
+import { nodeColorHex } from './components/utils';
 import { EdgeType, GraphType, NodeType } from './types';
 
 // For backwards compatibility with older saveStates, we migrate information from settings.nodes to settings.location
@@ -144,7 +148,135 @@ const NodeLinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin
   },
 );
 
-const NodelinkSettings = ({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<NodelinkVisProps>) => {};
+const NodelinkSettings = ({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<NodelinkVisProps>) => {
+  useEffect(() => {
+    if (graphMetadata && graphMetadata.nodes && graphMetadata.nodes.labels.length > 0) {
+      updateSettings({ nodeList: graphMetadata.nodes.labels });
+    }
+  }, [graphMetadata]);
+
+  if (!settings.nodeList) return null;
+
+  return (
+    <SettingsContainer>
+      <div className="mb-4 text-xs">
+        <h1 className="font-bold">General</h1>
+        <div className="m-1 flex flex-col space-y-2 mb-2">
+          <h4 className="font-semibold">Nodes Labels:</h4>
+          {settings.nodeList.map((item, index) => (
+            <div className="flex m-1 items-center" key={item}>
+              <div className="w-3/4 mr-6">
+                <EntityPill title={item} />
+              </div>
+              <div className="w-1/2">
+                <div className={`h-5 w-5 border-2 border-sec-300`} style={{ backgroundColor: nodeColorHex(index + 1) }}></div>
+              </div>
+            </div>
+          ))}
+        </div>
+        <Input
+          type="dropdown"
+          label="Layout"
+          size="sm"
+          inline={false}
+          value={settings.layout}
+          options={Object.values(Layouts) as string[]}
+          onChange={val => updateSettings({ layout: val as LayoutTypes })}
+        />
+        <Input
+          type="boolean"
+          label="Show pop-up on hover"
+          value={settings.showPopUpOnHover}
+          onChange={val => updateSettings({ showPopUpOnHover: val })}
+        />
+      </div>
+
+      <div className="mb-4">
+        <h1 className="font-bold">Nodes</h1>
+        <div>
+          <span className="text-xs font-semibold">Shape</span>
+          <Input
+            type="dropdown"
+            label="Shape"
+            value={settings.nodes.shape.type}
+            options={[{ circle: 'Circle' }, { rectangle: 'Square' }]}
+            onChange={val =>
+              updateSettings({
+                nodes: {
+                  ...settings.nodes,
+                  shape: {
+                    ...settings.nodes.shape,
+                    type: val as 'circle' | 'rectangle',
+                  },
+                },
+              })
+            }
+          />
+        </div>
+      </div>
+
+      <div>
+        <h1 className="font-bold">Edges</h1>
+        <div>
+          <span className="text-xs font-semibold">Edge width</span>
+          <Input
+            type="slider"
+            label="Width"
+            size="sm"
+            className="my-1"
+            value={settings.edges.width.width}
+            onChangeConfirmed={val => updateSettings({ edges: { ...settings.edges, width: { ...settings.edges.width, width: val } } })}
+            min={0.1}
+            max={4}
+            step={0.1}
+          />
+        </div>
+      </div>
+      <div>
+        <h1 className="font-bold">Labels</h1>
+        {Object.entries(graphMetadata.edges.types).map(([label, type]) => (
+          <Input
+            type="dropdown"
+            size="sm"
+            key={label}
+            label={label}
+            value={settings.edges.labelAttributes ? settings.edges.labelAttributes[label] || 'Default' : undefined}
+            options={['Default', 'None', ...Object.keys(type.attributes).filter(x => x != 'Type')]}
+            onChange={val =>
+              updateSettings({
+                edges: {
+                  ...settings.edges,
+                  labelAttributes: { ...settings.edges.labelAttributes, [label]: val as string },
+                },
+              })
+            }
+          />
+        ))}
+      </div>
+      <div>
+        <Input type="boolean" label="Show arrows" value={settings.showArrows} onChange={val => updateSettings({ showArrows: val })} />
+      </div>
+      <div>
+        <Input
+          type="boolean"
+          label="Show multiple edges"
+          value={settings.showMultipleEdges}
+          onChange={val => updateSettings({ showMultipleEdges: val })}
+        />
+      </div>
+      {canViewFeature('EDGE_BUNDLING') ? (
+        <div>
+          <Input
+            type="boolean"
+            label="Edge bundling"
+            value={settings.edgeBundlingEnabled}
+            onChange={val => updateSettings({ edgeBundlingEnabled: val })}
+          />
+        </div>
+      ) : null}
+    </SettingsContainer>
+  );
+};
 const nodeLinkVisRef = React.createRef<{ exportImageInternal: () => void }>();
 
 export const NodeLinkComponent: VISComponentType<NodelinkVisProps> = {
-- 
GitLab


From 7954a8e888789ac2f04a0b1e8bbb65d08d71d691 Mon Sep 17 00:00:00 2001
From: Leonardo Christino <lchristino@graphpolaris.com>
Date: Mon, 31 Mar 2025 14:26:39 +0200
Subject: [PATCH 03/12] optm: optmize multi links in NL

---
 .../nodelinkvis/components/NLPixi.tsx         | 19 +++++++++++++------
 .../vis/visualizations/nodelinkvis/types.ts   |  4 ++--
 2 files changed, 15 insertions(+), 8 deletions(-)

diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
index a78ab9887..510e21286 100644
--- a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
@@ -98,6 +98,18 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
     container.renderable = false;
     return container;
   }, []);
+  const multipleEdgesMap = useMemo<Record<string, number>>(() => {
+    return Object.fromEntries(
+      Object.values(props.graph.edges).map(edge => [
+        edge.id,
+        props.configuration.showMultipleEdges
+          ? Object.values(props.graph.edges).filter(
+              x => (x.source === edge.source && x.target === edge.target) || (x.source === edge.target && x.target === edge.source),
+            ).length
+          : 0,
+      ]),
+    );
+  }, [props.configuration.showMultipleEdges, props.graph.edges]);
 
   const nodeMap = useRef(new Map<string, Sprite>());
   const edgeGfx = new Graphics();
@@ -588,12 +600,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
   };
 
   const updateEdge = (edge: EdgeType, edgeBundle?: Point[]) => {
-    const multiple =
-      imperative.current.getShowMultipleEdges() && Object.values(props.graph.edges).length < PERF_EDGE_THRESHOLD
-        ? Object.values(props.graph.edges).filter(
-            x => (x.source == edge.source && x.target == edge.target) || (x.source == edge.target && x.target == edge.source),
-          ).length
-        : 0;
+    const multiple = multipleEdgesMap[edge.id];
 
     const target = nodeMap.current.get(edge.target as string) as Sprite;
     const source = nodeMap.current.get(edge.source as string) as Sprite;
diff --git a/src/lib/vis/visualizations/nodelinkvis/types.ts b/src/lib/vis/visualizations/nodelinkvis/types.ts
index d4f8a59ec..fccae23b0 100644
--- a/src/lib/vis/visualizations/nodelinkvis/types.ts
+++ b/src/lib/vis/visualizations/nodelinkvis/types.ts
@@ -15,7 +15,7 @@ export type NodeType = d3.SimulationNodeDatum & {
   ids: string[]; // reverse mapping to original _id
   color: number;
   label?: string;
-  radius?: number;
+  radius: number;
   alpha?: number;
 };
 
@@ -24,7 +24,7 @@ export type EdgeType = d3.SimulationLinkDatum<NodeType> & {
   ids: string[]; // reverse mapping to original _id
   label?: string;
   thickness: number;
-  color: number;
+  color: string;
   alpha?: number;
   style: number;
 };
-- 
GitLab


From 2e714594228bce88ce5874967b05fcfabcaa5339 Mon Sep 17 00:00:00 2001
From: Leonardo Christino <lchristino@graphpolaris.com>
Date: Wed, 2 Apr 2025 13:21:56 +0200
Subject: [PATCH 04/12] fix: finalize optimization and slight refactor of logic
 into a separate folder

---
 src/lib/vis/components/VisualizationPanel.tsx |   2 +-
 src/lib/vis/visualizations/index.tsx          |   2 +-
 .../{nodelinkvis.tsx => NodelinkVis.tsx}      |  65 ++++--
 .../components/NLMachineLearning.tsx          | 193 ------------------
 .../nodelinkvis/components/NLPixi.tsx         | 101 ++++-----
 .../components/query2NL/NLMachineLearning.ts  |  62 ++++++
 .../{ => query2NL}/edgeBundling.tsx           |   0
 .../components/query2NL/edgeStyle.ts          |  40 ++++
 .../nodelinkvis/components/query2NL/index.ts  |   0
 .../components/{ => query2NL}/query2NL.tsx    |  74 ++-----
 .../nodelinkvis/components/utils.tsx          |  15 +-
 .../nodelinkvis/nodelinkvis.stories.tsx       |   2 +-
 .../vis/visualizations/nodelinkvis/types.ts   |   6 +-
 13 files changed, 221 insertions(+), 341 deletions(-)
 rename src/lib/vis/visualizations/nodelinkvis/{nodelinkvis.tsx => NodelinkVis.tsx} (81%)
 delete mode 100644 src/lib/vis/visualizations/nodelinkvis/components/NLMachineLearning.tsx
 create mode 100644 src/lib/vis/visualizations/nodelinkvis/components/query2NL/NLMachineLearning.ts
 rename src/lib/vis/visualizations/nodelinkvis/components/{ => query2NL}/edgeBundling.tsx (100%)
 create mode 100644 src/lib/vis/visualizations/nodelinkvis/components/query2NL/edgeStyle.ts
 create mode 100644 src/lib/vis/visualizations/nodelinkvis/components/query2NL/index.ts
 rename src/lib/vis/visualizations/nodelinkvis/components/{ => query2NL}/query2NL.tsx (87%)

diff --git a/src/lib/vis/components/VisualizationPanel.tsx b/src/lib/vis/components/VisualizationPanel.tsx
index d5b0700df..b7a27aa16 100644
--- a/src/lib/vis/components/VisualizationPanel.tsx
+++ b/src/lib/vis/components/VisualizationPanel.tsx
@@ -16,7 +16,7 @@ export const Visualizations: Record<string, PromiseFunc> = {
   ...(canViewFeature('TABLEVIS') && { TableVis: () => import('../visualizations/tablevis/tablevis') }),
   ...(canViewFeature('PAOHVIS') && { PaohVis: () => import('../visualizations/paohvis/paohvis') }),
   ...(canViewFeature('RAWJSONVIS') && { RawJSONVis: () => import('../visualizations/rawjsonvis/rawjsonvis') }),
-  ...(canViewFeature('NODELINKVIS') && { NodeLinkVis: () => import('../visualizations/nodelinkvis/nodelinkvis') }),
+  ...(canViewFeature('NODELINKVIS') && { NodeLinkVis: () => import('../visualizations/nodelinkvis/NodelinkVis') }),
   ...(canViewFeature('MATRIXVIS') && { MatrixVis: () => import('../visualizations/matrixvis/matrixvis') }),
   ...(canViewFeature('SEMANTICSUBSTRATESVIS') && {
     SemanticSubstratesVis: () => import('../visualizations/semanticsubstratesvis/semanticsubstratesvis'),
diff --git a/src/lib/vis/visualizations/index.tsx b/src/lib/vis/visualizations/index.tsx
index e4aea884e..d3e1cc666 100644
--- a/src/lib/vis/visualizations/index.tsx
+++ b/src/lib/vis/visualizations/index.tsx
@@ -1,5 +1,5 @@
 export * from './matrixvis/matrixvis';
-export * from './nodelinkvis/nodelinkvis';
+export * from './nodelinkvis/NodelinkVis';
 export * from './paohvis/paohvis';
 export * from './rawjsonvis';
 export * from './semanticsubstratesvis/semanticsubstratesvis';
diff --git a/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx b/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx
similarity index 81%
rename from src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx
rename to src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx
index b3c5ee195..715a973a0 100644
--- a/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx
@@ -1,15 +1,16 @@
-import { EntityPill, Input } from '@/lib/components';
+import { EntityPill, Input, NodeDetails, Popover, PopoverContent, PopoverTrigger } from '@/lib/components';
 import { canViewFeature } from '@/lib/components/featureFlags';
 import { type PointData } from 'pixi.js';
-import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
+import React, { forwardRef, RefObject, useEffect, useImperativeHandle, useRef, useState } from 'react';
 import { ML, NodeQueryResult } from 'ts-common';
 import { useImmer } from 'use-immer';
 import { Layouts, LayoutTypes } from '../../../graph-layout/types';
 import { VISComponentType, VisualizationPropTypes, VisualizationSettingsPropTypes } from '../../common';
 import { SettingsContainer } from '../../components/config';
-import { NLPixi } from './components/NLPixi';
-import { parseQueryResult } from './components/query2NL';
-import { nodeColorHex } from './components/utils';
+import { NLPixi, SelectedNodeType } from './components/NLPixi';
+import { NLPopUp } from './components/NLPopup';
+import { parseQueryResult } from './components/query2NL/query2NL';
+import { hexColorFromBinary, nodeColorHex } from './components/utils';
 import { EdgeType, GraphType, NodeType } from './types';
 
 // For backwards compatibility with older saveStates, we migrate information from settings.nodes to settings.location
@@ -90,13 +91,15 @@ const settings: NodelinkVisProps = {
   edgeBundlingEnabled: false,
 };
 
-const NodeLinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<NodelinkVisProps>>(
+const NodelinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<NodelinkVisProps>>(
   ({ data, ml, dispatch, settings, handleSelect }, refExternal) => {
     const ref = useRef<HTMLDivElement>(null);
     const nlPixiRef = useRef<any>(null);
     const [graph, setGraph] = useImmer<GraphType | undefined>(undefined);
     const [highlightNodes, setHighlightNodes] = useState<NodeType[]>([]);
     const [highlightedLinks, setHighlightedLinks] = useState<EdgeType[]>([]);
+    const [selectedNodes, setSelectedNodes] = useState<SelectedNodeType[]>([]);
+    const [dragging, setDragging] = useState<boolean>(false);
 
     settings = patchLegacySettings(settings);
 
@@ -114,7 +117,7 @@ const NodeLinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin
         }
 
         const node = event.node;
-        const nodeMeta: NodeQueryResult[] = data?.graph.nodes.filter(n => node.ids.includes(n._id));
+        const nodeMeta: NodeQueryResult[] = data?.graph.nodes.filter(n => node.ids.map(n => n._id).includes(n._id));
         if (!nodeMeta) return;
         if (handleSelect) handleSelect({ nodes: nodeMeta });
       }
@@ -131,19 +134,39 @@ const NodeLinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin
     if (!graph) return null;
 
     return (
-      <NLPixi
-        ref={nlPixiRef}
-        graph={graph}
-        configuration={settings}
-        highlightNodes={highlightNodes}
-        highlightedLinks={highlightedLinks}
-        onClick={event => {
-          onClickedNode(event, ml);
-        }}
-        layoutAlgorithm={settings.layout}
-        showPopupsOnHover={settings.showPopUpOnHover}
-        edgeBundlingEnabled={settings.edgeBundlingEnabled}
-      />
+      <>
+        {selectedNodes.map(selectedNode => (
+          <Popover
+            key={selectedNode.id}
+            open={true}
+            interactive={!dragging}
+            boundaryElement={ref as RefObject<HTMLElement>}
+            showArrow={true}
+          >
+            <PopoverTrigger x={selectedNode.pos.x} y={selectedNode.pos.y} />
+            <PopoverContent>
+              <NodeDetails name={selectedNode.id} colorHeader={hexColorFromBinary(graph.nodes[selectedNode.id].color)}>
+                <NLPopUp data={data.graph.nodes[graph.nodes[selectedNode.id].ids[0].idx].attributes} />
+              </NodeDetails>
+            </PopoverContent>
+          </Popover>
+        ))}
+        <NLPixi
+          ref={nlPixiRef}
+          graph={graph}
+          setDragging={setDragging}
+          onSelectedNodes={setSelectedNodes}
+          configuration={settings}
+          highlightNodes={highlightNodes}
+          highlightedLinks={highlightedLinks}
+          onClick={event => {
+            onClickedNode(event, ml);
+          }}
+          layoutAlgorithm={settings.layout}
+          showPopupsOnHover={settings.showPopUpOnHover}
+          edgeBundlingEnabled={settings.edgeBundlingEnabled}
+        />
+      </>
     );
   },
 );
@@ -280,7 +303,7 @@ const NodelinkSettings = ({ settings, graphMetadata, updateSettings }: Visualiza
 const nodeLinkVisRef = React.createRef<{ exportImageInternal: () => void }>();
 
 export const NodeLinkComponent: VISComponentType<NodelinkVisProps> = {
-  component: React.forwardRef((props: VisualizationPropTypes<NodelinkVisProps>, ref) => <NodeLinkVis {...props} ref={nodeLinkVisRef} />),
+  component: React.forwardRef((props: VisualizationPropTypes<NodelinkVisProps>, ref) => <NodelinkVis {...props} ref={nodeLinkVisRef} />),
   settingsComponent: NodelinkSettings,
   settings: patchLegacySettings(settings),
   exportImage: () => {
diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLMachineLearning.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLMachineLearning.tsx
deleted file mode 100644
index d517168ed..000000000
--- a/src/lib/vis/visualizations/nodelinkvis/components/NLMachineLearning.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-import { useState } from 'react';
-import { ML } from 'ts-common';
-import { EdgeType, GraphType, NodeType } from '../types';
-
-export function processLinkPrediction(ml: ML, graph: GraphType): GraphType {
-  if (ml === undefined || ml.linkPrediction === undefined) return graph;
-
-  if (ml.linkPrediction.enabled) {
-    const allNodeIds = new Set(Object.keys(graph.nodes));
-    ml.linkPrediction.result.forEach(link => {
-      if (allNodeIds.has(link.from) && allNodeIds.has(link.to)) {
-        const toAdd: EdgeType = {
-          id: link.from + ':LP:' + link.to, // TODO: this only supports one link between two nodes
-          name: 'Link Prediction',
-          source: link.from,
-          target: link.to,
-          value: link.attributes.jaccard_coefficient as number,
-          mlEdge: true,
-          color: 0x000000,
-          attributes: {},
-        };
-        graph.edges[toAdd.id] = toAdd;
-      }
-    });
-  }
-  return graph;
-}
-
-export function processCommunityDetection(ml: ML, graph: GraphType): GraphType {
-  if (ml === undefined || ml.communityDetection === undefined) return graph;
-
-  if (ml.communityDetection.enabled) {
-    const allNodeIdMap = new Map<string, number>();
-    ml.communityDetection.result.forEach((idSet, i) => {
-      idSet.forEach(id => {
-        allNodeIdMap.set(id, i);
-      });
-    });
-
-    Object.keys(graph.nodes).forEach(nodeId => {
-      if (allNodeIdMap.has(nodeId)) {
-        graph.nodes[nodeId].cluster = allNodeIdMap.get(nodeId);
-      } else {
-        graph.nodes[nodeId].cluster = -1;
-      }
-    });
-  } else {
-    Object.keys(graph.nodes).forEach(nodeId => {
-      graph.nodes[nodeId].cluster = undefined;
-    });
-  }
-  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;
-  highlightedNodes: NodeType[];
-  jaccardThreshold: number;
-  numberOfMlClusters: number;
-}) => {
-  const [shortestPathEdges, setShortestPathEdges] = useState<EdgeType[]>([]);
-
-  /**
-   * The actual drawing of the shortest path is done in the ticked method
-   * This recalculates what should be shown and adds it to a list currentShortestPathEdges
-   * Small note; the order in which nodes are clicked matters.
-   * Also turns off highlightLinks
-   * */
-  function showShortestPath(): void {
-    const shortestPathNodes: NodeType[] = [];
-    props.highlightedNodes.forEach(node => {
-      if (node.shortestPathData != undefined) {
-        shortestPathNodes.push(node);
-      }
-    });
-    if (shortestPathNodes.length < 2) {
-      setShortestPathEdges([]);
-    }
-    let index = 0;
-    let allPaths: EdgeType[] = [];
-    while (index < shortestPathNodes.length - 1) {
-      const shortestPathData = shortestPathNodes[index].shortestPathData;
-      if (shortestPathData === undefined) {
-        console.warn('Something went wrong with shortest path calculation');
-      } else {
-        const path: string[] = shortestPathData[shortestPathNodes[index + 1]._id];
-        allPaths = allPaths.concat(getShortestPathEdges(path));
-      }
-      index++;
-    }
-    setShortestPathEdges(allPaths);
-  }
-
-  /**
-   * Gets the edges corresponding to the shortestPath.
-   * @param pathString The path as a string.
-   * @returns The path as a LinkType[]
-   * @deprecated This function is not working anymore
-   */
-  function getShortestPathEdges(pathString: string[]): EdgeType[] {
-    try {
-      const newPath: EdgeType[] = [];
-      let index = 0;
-      while (index < pathString.length) {
-        if (pathString[index + 1] == undefined) {
-          index++;
-          continue;
-        }
-        const edgeFound = false;
-        Object.keys(props.graph.edges).forEach(key => {
-          const link = props.graph.edges[key];
-          // if (
-          //   false // FIXME: This is not working anymore
-          //   // (pathString[index] == source.id && pathString[index + 1] == target.id) ||
-          //   // (pathString[index] == source && pathString[index + 1] == target) ||
-          //   // (pathString[index + 1] == source.id && pathString[index] == target.id) ||
-          //   // (pathString[index + 1] == source && pathString[index] == target)
-          // ) {
-          //   newPath.push(link);
-          //   edgeFound = true;
-          // }
-        });
-        if (!edgeFound) {
-          console.warn('skipped path: ' + pathString[index] + ' ' + pathString[index + 1]);
-        }
-        index++;
-      }
-      return newPath;
-    } catch {
-      return [];
-    }
-  }
-
-  //MACHINE LEARNING--------------------------------------------------------------------------------------------------
-  //   /**
-  //    * updates the JacccardThresh value.
-  //    * This is called in the component
-  //    * This makes testing purposes easier and makes sure you dont have to read out the value 2000 times,
-  //    * but only when you change the value.
-  //    */
-  //   function updateJaccardThreshHold(): void {
-  //     const slider = document.getElementById('Slider');
-  //     props.jaccardThreshold = Number(slider?.innerText);
-  //   }
-
-  //   /** initializeUniqueAttributes fills the uniqueAttributeValues with data from graph scheme analytics.
-  //    * @param attributeData NodeAttributeData returned by graph scheme analytics.
-  //    * @param attributeDataType Routing key.
-  //    */
-  //   function initializeUniqueAttributes(attributeData: AttributeData, attributeDataType: string): void {
-  //     if (attributeDataType === 'gsa_node_result') {
-  //       const entity = attributeData as NodeAttributeData;
-  //       entity.attributes.forEach((attribute) => {
-  //         if (attribute.type === AttributeCategory.categorical) {
-  //           const nameAttribute = attribute.name;
-  //           const valuesAttribute = attribute.uniqueCategoricalValues;
-  //           // check if not null
-  //           if (valuesAttribute) {
-  //             this.uniqueAttributeValues[nameAttribute] = valuesAttribute;
-  //           }
-  //         }
-  //       });
-  //     }
-  //   }
-
-  /**
-   * 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 nodes could have been changed.
-   */
-  const resetClusterOfNodes = (type: number): void => {
-    Object.keys(props.graph.nodes).forEach(key => {
-      const node = props.graph.nodes[key];
-      if (node.cluster == type) {
-        node.cluster = props.numberOfMlClusters;
-      }
-      if (node.type == type) {
-        node.cluster = node.type;
-      }
-    });
-  };
-
-  return {
-    shortestPathEdges,
-    showShortestPath,
-    resetClusterOfNodes,
-  };
-};
diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
index 510e21286..ccd1ed007 100644
--- a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
@@ -21,14 +21,15 @@ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState }
 import { AllLayoutAlgorithms } from 'ts-common/src/model/layouts';
 import { useML, useSearchResultData } from '../../../../data-access';
 import { GraphologyForceAtlas2Webworker, LayoutFactory, Layouts, LayoutTypes } from '../../../../graph-layout';
-import { NodelinkVisProps } from '../nodelinkvis';
+import { NodelinkVisProps } from '../NodelinkVis';
 import { EdgeType, GraphType, NodeType } from '../types';
-import { ForceEdgeBundling, type Point } from './edgeBundling';
+import { ForceEdgeBundling, type Point } from './query2NL/edgeBundling';
 import { NLPopUp } from './NLPopup';
 import { nodeColor, nodeColorHex } from './utils';
 
 const PERF_EDGE_THRESHOLD = 2500;
 
+export type SelectedNodeType = { id: string; pos: PointData };
 type Props = {
   onClick: (event?: { node: NodeType; pos: PointData }) => void;
   // onHover: (data: { node: NodeType; pos: PointData }) => void;
@@ -41,6 +42,8 @@ type Props = {
   layoutAlgorithm: LayoutTypes;
   showPopupsOnHover: boolean;
   edgeBundlingEnabled: boolean;
+  setDragging: (dragging: boolean) => void;
+  onSelectedNodes: (selectedNodes: SelectedNodeType[]) => void;
 };
 
 type LayoutState = 'reset' | 'running' | 'paused';
@@ -50,9 +53,6 @@ type LayoutState = 'reset' | 'running' | 'paused';
 //////////////////
 
 export const NLPixi = forwardRef((props: Props, refExternal) => {
-  const [quickPopup, setQuickPopup] = useState<{ node: NodeType; pos: PointData } | undefined>();
-  const [popups, setPopups] = useState<{ node: NodeType; pos: PointData }[]>([]);
-
   const globalConfig = useConfig();
 
   useEffect(() => {
@@ -119,13 +119,13 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
   const layoutState = useRef<LayoutState>('reset');
   const layoutStoppedCount = useRef(0);
   const mouseInCanvas = useRef<boolean>(false);
-  const [dragging, setDragging] = useState<boolean>(false);
   const isSetup = useRef(false);
   const ml = useML();
   const searchResults = useSearchResultData();
-
   const layoutAlgorithm = useRef(new LayoutFactory().createLayout<AllLayoutAlgorithms>(Layouts.DAGRE));
 
+  const [selectedNodes, setSelectedNodes] = useState<{ id: string; pos: PointData; onlyHovered?: boolean }[]>([]);
+
   // const cull = new Cull();
   // let cullDirty = useRef(true);
 
@@ -240,6 +240,10 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
     });
   }, [props.layoutAlgorithm, props.configuration, props.configuration.edgeBundlingEnabled]);
 
+  useEffect(() => {
+    props.onSelectedNodes(selectedNodes);
+  }, [selectedNodes]);
+
   // useEffect(() => {
   //   if (nodeMap.current.size == 0 || props.graph.edges == null) {
   //     metaEdges = null;
@@ -271,7 +275,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       if (props.configuration.showPopUpOnHover) return;
 
       (event as any).mouseDownTimeStamp = event.timeStamp;
-      setDragging(true);
+      props.setDragging(true);
     },
 
     onMouseUpNode(event: FederatedPointerEvent) {
@@ -287,11 +291,11 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       const node = (sprite as any).node as NodeType;
 
       if (event.shiftKey) {
-        setPopups([...popups, { node: node, pos: toGlobal(node) }]);
+        setSelectedNodes([...selectedNodes, { id: node.id, 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;
+        setSelectedNodes([{ id: node.id, pos: toGlobal(node) }]);
+        for (const n of selectedNodes) {
+          const sprite = nodeMap.current.get(n.id) as Sprite;
           sprite.texture = glyphTexture;
           (sprite as any).selected = false;
         }
@@ -299,7 +303,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
 
       sprite.texture = selectedTexture;
       (sprite as any).selected = true;
-      setDragging(false);
+      props.setDragging(false);
 
       props.onClick({ node: node, pos: toGlobal(node) });
 
@@ -312,12 +316,12 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       // 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;
+        for (const n of selectedNodes) {
+          const sprite = nodeMap.current.get(n.id) as Sprite;
           sprite.texture = glyphTexture;
           (sprite as any).selected = false;
         }
-        setPopups([]);
+        setSelectedNodes([]);
         props.onClick();
       }
     },
@@ -332,25 +336,24 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
         viewport?.current &&
         !viewport?.current?.pause &&
         node &&
-        popups.filter(p => p.node.id === node.id).length === 0
+        selectedNodes.filter(p => p.id === node.id).length === 0
       ) {
-        setQuickPopup({ node: props.graph.nodes[node.id], pos: toGlobal(node) });
+        setSelectedNodes([...selectedNodes, { id: node.id, pos: toGlobal(node), onlyHovered: true }]);
       }
     },
     onUnHover() {
       if (!props.configuration.showPopUpOnHover) return;
-
-      setQuickPopup(undefined);
+      setSelectedNodes(selectedNodes.filter(p => p.onlyHovered !== true));
     },
     onMoved(viewport: Viewport) {
       if (props.configuration.showPopUpOnHover) return;
 
-      for (const popup of popups) {
-        if (popup.node.x == null || popup.node.y == null) continue;
-        popup.pos.x = viewport.position.x + popup.node.x * viewport.scale.x;
-        popup.pos.y = viewport.position.y + popup.node.y * viewport.scale.y;
+      for (const n of selectedNodes) {
+        if (n.pos.x == null || n.pos.y == null) continue;
+        n.pos.x = viewport.position.x + (props.graph.nodes[n.id].x ?? 0) * viewport.scale.x;
+        n.pos.y = viewport.position.y + (props.graph.nodes[n.id].y ?? 0) * viewport.scale.y;
       }
-      setPopups([...popups]);
+      setSelectedNodes([...selectedNodes]);
     },
     onZoom() {
       const scale = viewport.current!.scale.x;
@@ -1065,7 +1068,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       imperative.current.onMoved(event.viewport);
     });
     viewport.current.on('drag-end', _ => {
-      setDragging(false);
+      props.setDragging(false);
     });
     viewport.current.on('zoomed', _ => {
       imperative.current.onZoom();
@@ -1121,39 +1124,17 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
   // export image
 
   return (
-    <>
-      {/* {popups.map(popup => (
-        <Popover key={popup.node.id} open={true} interactive={!dragging} boundaryElement={ref as RefObject<HTMLElement>} showArrow={true}>
-          <PopoverTrigger x={popup.pos.x} y={popup.pos.y} />
-          <PopoverContent>
-            <NodeDetails name={popup.node.id} colorHeader={nodeColorHex(props.graph.nodes[popup.node.id].type)}>
-              <NLPopUp data={props.graph.nodes[popup.node.id].attributes} />
-            </NodeDetails>
-          </PopoverContent>
-        </Popover>
-      ))}
-      {quickPopup != null && (
-        <Popover key={quickPopup.node._id} open={true} boundaryElement={ref as RefObject<HTMLElement>} showArrow={true}>
-          <PopoverTrigger x={quickPopup.pos.x} y={quickPopup.pos.y} />
-          <PopoverContent>
-            <NodeDetails name={quickPopup.node._id} colorHeader={nodeColorHex(props.graph.nodes[quickPopup.node._id].type)}>
-              <NLPopUp data={props.graph.nodes[quickPopup.node._id].attributes} />
-            </NodeDetails>
-          </PopoverContent>
-        </Popover>
-      )} */}
-      <div
-        className="h-full w-full overflow-hidden"
-        ref={ref}
-        onMouseEnter={e => {
-          mouseInCanvas.current = true;
-        }}
-        onMouseOut={e => {
-          mouseInCanvas.current = false;
-        }}
-      >
-        <canvas ref={canvas} />
-      </div>
-    </>
+    <div
+      className="h-full w-full overflow-hidden"
+      ref={ref}
+      onMouseEnter={e => {
+        mouseInCanvas.current = true;
+      }}
+      onMouseOut={e => {
+        mouseInCanvas.current = false;
+      }}
+    >
+      <canvas ref={canvas} />
+    </div>
   );
 });
diff --git a/src/lib/vis/visualizations/nodelinkvis/components/query2NL/NLMachineLearning.ts b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/NLMachineLearning.ts
new file mode 100644
index 000000000..c88846df9
--- /dev/null
+++ b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/NLMachineLearning.ts
@@ -0,0 +1,62 @@
+import { ML } from 'ts-common';
+import { EdgeType, GraphType } from '../../types';
+import { nodeColor } from '../utils';
+import { LINE_COLOR_ML } from './edgeStyle';
+
+export function processLinkPrediction(ml: ML, graph: GraphType): GraphType {
+  if (ml === undefined || ml.linkPrediction === undefined || !ml.linkPrediction.enabled) return graph;
+
+  if (ml.linkPrediction.enabled) {
+    const nodeIds = new Set(Object.keys(graph.nodeIdMap));
+    ml.linkPrediction.result.forEach(edge => {
+      if (nodeIds.has(edge.from) && nodeIds.has(edge.to)) {
+        const toAdd: EdgeType = {
+          id: edge.from + ':LP:' + edge.to, // TODO: this only supports one link between two nodes
+          ids: [],
+          source: graph.nodeIdMap[edge.from],
+          target: graph.nodeIdMap[edge.to],
+          label: 'prediction',
+          color: LINE_COLOR_ML,
+          style: 1,
+          thickness: edge.attributes.jaccard_coefficient,
+          alpha: 1,
+        };
+        graph.edges[toAdd.id] = toAdd;
+      }
+    });
+  }
+  return graph;
+}
+
+export function processCommunityDetection(ml: ML, graph: GraphType): GraphType {
+  if (ml === undefined || ml.communityDetection === undefined || !ml.communityDetection.enabled) return graph;
+
+  console.log('processCommunityDetection', ml.communityDetection);
+  if (ml.communityDetection.enabled) {
+    const nodeToColorId = new Map<string, number>();
+    ml.communityDetection.result.forEach((idSet, i) => {
+      idSet.forEach(id => {
+        nodeToColorId.set(id, i);
+      });
+    });
+
+    Object.keys(graph.nodes).forEach(nodeId => {
+      if (nodeToColorId.has(nodeId)) {
+        graph.nodes[nodeId].color = nodeColor(nodeToColorId.get(nodeId) ?? 0);
+      } else {
+        graph.nodes[nodeId].color = nodeColor(-1);
+      }
+    });
+  } else {
+    Object.keys(graph.nodes).forEach(nodeId => {
+      graph.nodes[nodeId].color = nodeColor(-1);
+    });
+  }
+  return graph;
+}
+
+export function processML(ml: ML, graph: GraphType): GraphType {
+  let ret = processLinkPrediction(ml, graph);
+  ret = processCommunityDetection(ml, ret);
+  return ret;
+}
diff --git a/src/lib/vis/visualizations/nodelinkvis/components/edgeBundling.tsx b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/edgeBundling.tsx
similarity index 100%
rename from src/lib/vis/visualizations/nodelinkvis/components/edgeBundling.tsx
rename to src/lib/vis/visualizations/nodelinkvis/components/query2NL/edgeBundling.tsx
diff --git a/src/lib/vis/visualizations/nodelinkvis/components/query2NL/edgeStyle.ts b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/edgeStyle.ts
new file mode 100644
index 000000000..5e0dac48e
--- /dev/null
+++ b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/edgeStyle.ts
@@ -0,0 +1,40 @@
+import { dataColors, EdgeQueryResult, ML, visualizationColors } from 'ts-common';
+
+export const LINE_COLOR_DEFAULT = dataColors.neutral[40];
+export const LINE_COLOR_SELECTED = visualizationColors.GPSelected.colors[1];
+export const LINE_COLOR_ML = dataColors.blue[60];
+export const LINE_WIDTH_DEFAULT = 0.8;
+
+export const getEdgeStyle = (edge: EdgeQueryResult, ml: ML) => {
+  // let color = edge.color || 0x000000;
+  let color = LINE_COLOR_DEFAULT;
+  const thickness = (edge.attributes.jaccard_coefficient as number) || 1;
+  let style = thickness || 1;
+  let alpha = 1;
+  let mlEdge = false;
+
+  // Parse ml edges
+  if (ml != undefined && ml.linkPrediction.result.find(edge => edge.from === edge.from && edge.to === edge.to)) {
+    mlEdge = true;
+  }
+
+  if (mlEdge) {
+    color = LINE_COLOR_ML;
+    if (thickness > ml.communityDetection.jaccard_threshold) {
+      style = thickness * 1.8;
+    } else {
+      style = 0;
+      alpha = 0.2;
+    }
+  }
+
+  // TODO
+  // 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 === false); //FIXME: needs to match edge._id
+  //   alpha = isLinkInSearchResults ? 1 : 0.05;
+  // }
+
+  return { style, color, alpha, thickness };
+};
diff --git a/src/lib/vis/visualizations/nodelinkvis/components/query2NL/index.ts b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/index.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx
similarity index 87%
rename from src/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx
rename to src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx
index 821bbf77e..bb33dd8b9 100644
--- a/src/lib/vis/visualizations/nodelinkvis/components/query2NL.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx
@@ -4,12 +4,14 @@
  * © Copyright Utrecht University (Department of Information and Computing Sciences)
  */
 import { VisualizationSettingsType } from '@/lib/vis/common';
-import { dataColors, EdgeQueryResult, GraphQueryResultMetaFromBackend, ML, NodeQueryResult, visualizationColors } from 'ts-common';
+import { EdgeQueryResult, GraphQueryResultMetaFromBackend, ML, NodeQueryResult } from 'ts-common';
 import { v4 as uuidv4 } from 'uuid';
-import { GraphQueryResult } from '../../../../data-access/store';
-import { NodelinkVisProps } from '../nodelinkvis';
-import { EdgeType, GraphType, NodeType } from '../types';
-import { nodeColor } from './utils';
+import { GraphQueryResult } from '../../../../../data-access/store';
+import { NodelinkVisProps } from '../../NodelinkVis';
+import { EdgeType, GraphType, NodeType } from '../../types';
+import { nodeColor } from '../utils';
+import { processML } from './NLMachineLearning';
+import { getEdgeStyle } from './edgeStyle';
 /** ResultNodeLinkParserUseCase implements methods to parse and translate websocket messages from the backend into a GraphType. */
 
 /**
@@ -136,45 +138,6 @@ const getEdgeLabel = (edge: EdgeQueryResult, d3edge: EdgeType, settings: Nodelin
   // return '';
 };
 
-const LINE_COLOR_DEFAULT = dataColors.neutral[40];
-const LINE_COLOR_SELECTED = visualizationColors.GPSelected.colors[1];
-const LINE_COLOR_ML = dataColors.blue[60];
-const LINE_WIDTH_DEFAULT = 0.8;
-
-const getEdgeStyle = (edge: EdgeQueryResult, ml: ML) => {
-  // let color = edge.color || 0x000000;
-  let color = LINE_COLOR_DEFAULT;
-  const thickness = (edge.attributes.jaccard_coefficient as number) || 1;
-  let style = thickness || 1;
-  let alpha = 1;
-  let mlEdge = false;
-
-  // Parse ml edges
-  if (ml != undefined && ml.linkPrediction.result.find(link => link.from === edge.from && link.to === edge.to)) {
-    mlEdge = true;
-  }
-
-  if (mlEdge) {
-    color = LINE_COLOR_ML;
-    if (thickness > ml.communityDetection.jaccard_threshold) {
-      style = thickness * 1.8;
-    } else {
-      style = 0;
-      alpha = 0.2;
-    }
-  }
-
-  // TODO
-  // 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 === false); //FIXME: needs to match edge._id
-  //   alpha = isLinkInSearchResults ? 1 : 0.05;
-  // }
-
-  return { style, color, alpha, thickness };
-};
-
 /**
  * Parse a websocket message containing a query result into a node edge GraphType.
  * @param {any} queryResult An incoming query result from the websocket.
@@ -189,10 +152,10 @@ export function parseQueryResult(
   const ret: GraphType = {
     nodes: {},
     edges: {},
+    nodeIdMap: {},
+    edgeIdMap: {},
   };
 
-  const nodeMap: Record<string, string> = {};
-
   const typeDict: { [key: string]: number } = {};
   // Counter for the types
   let counter = 1;
@@ -205,7 +168,7 @@ export function parseQueryResult(
   for (let i = 0; i < queryResult.nodes.length; i++) {
     // Assigns a group to every entity type for color coding
     const nodeId = uuidv4();
-    nodeMap[queryResult.nodes[i]._id] = nodeId;
+    ret.nodeIdMap[queryResult.nodes[i]._id] = nodeId;
     // for datasets without label, label is included in id. eg. "kamerleden/112"
     //const entityType = queryResult.nodes[i].label;
     const node = queryResult.nodes[i];
@@ -230,7 +193,7 @@ export function parseQueryResult(
     const radius = options.defaultRadius || 5;
     const data: NodeType = {
       id: nodeId,
-      ids: [queryResult.nodes[i]._id],
+      ids: [{ _id: queryResult.nodes[i]._id, idx: i }],
       color: nodeColor(typeNumber),
       label: entityType, // TODO
       radius: radius,
@@ -267,12 +230,13 @@ export function parseQueryResult(
 
   // Parse normal edges
   ret.edges = queryResult.edges
-    .map(e => {
+    .map((e, i) => {
+      ret.edgeIdMap[e._id] = uuidv4();
       return {
-        id: uuidv4(),
-        ids: [e._id],
-        source: nodeMap[e.from],
-        target: nodeMap[e.to],
+        id: ret.edgeIdMap[e._id],
+        ids: [{ _id: e._id, idx: i }],
+        source: ret.nodeIdMap[e.from],
+        target: ret.nodeIdMap[e.to],
         label: e.label, // TODO
         ...getEdgeStyle(e, ml),
       };
@@ -298,9 +262,7 @@ export function parseQueryResult(
   //   toBeReturned = { ...toBeReturned, ...numberOfClusters };
   // }
 
-  // return toBeReturned;
-  // return processML(ml, ret);
-  return ret;
+  return processML(ml, ret);
 }
 
 // export function parseLabelAggregationQueryResult(
diff --git a/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx b/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx
index 2aaa54340..348f56c1b 100644
--- a/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx
@@ -6,11 +6,7 @@ import { visualizationColors } from '@/config';
  * @returns {number} A number corresponding to a color in the d3 color scheme.
  */
 export function nodeColor(num: number): number {
-  // num = num % 4;
-  // const col = '#000000';
-  //let entityColors = Object.values(visualizationColors.GPSeq.colors[9]);
-  const col = visualizationColors.GPCat.colors[14][(num - 1) % visualizationColors.GPCat.colors[14].length];
-  return binaryColor(col);
+  return binaryColor(nodeColorHex(num));
 }
 
 export function nodeColorHex(num: number) {
@@ -18,7 +14,9 @@ export function nodeColorHex(num: number) {
   // const col = '#000000';
 
   //let entityColors = Object.values(visualizationColors.GPSeq.colors[9]);
-  const col = visualizationColors.GPCat.colors[14][(num - 1) % visualizationColors.GPCat.colors[14].length];
+  const colors = visualizationColors.GPCat.colors[14];
+  const index = (((num - 1) % colors.length) + colors.length) % colors.length; // wrap around numbers
+  const col = colors[index];
   return col;
 }
 
@@ -26,6 +24,11 @@ export function binaryColor(color: string) {
   return Number('0x' + color.replace('#', ''));
 }
 
+export function hexColorFromBinary(num: number) {
+  const hex = num.toString(16);
+  return '#' + hex.padStart(6, '0');
+}
+
 export function uniq<T>(items: T[]): T[] {
   return Array.from(new Set<T>(items));
 }
diff --git a/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.stories.tsx b/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.stories.tsx
index 87bd1d615..fa6ec8667 100644
--- a/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.stories.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/nodelinkvis.stories.tsx
@@ -4,7 +4,7 @@ import { Meta } from '@storybook/react';
 import { Provider } from 'react-redux';
 import { graphQueryResultSlice, schemaSlice, searchResultSlice, visualizationSlice } from '../../../data-access/store';
 import { mockData } from '../../../mock-data';
-import { NodeLinkComponent } from './nodelinkvis';
+import { NodeLinkComponent } from './NodelinkVis';
 
 const Mockstore = configureStore({
   reducer: {
diff --git a/src/lib/vis/visualizations/nodelinkvis/types.ts b/src/lib/vis/visualizations/nodelinkvis/types.ts
index fccae23b0..9a7766fb5 100644
--- a/src/lib/vis/visualizations/nodelinkvis/types.ts
+++ b/src/lib/vis/visualizations/nodelinkvis/types.ts
@@ -8,11 +8,13 @@
 export type GraphType = {
   nodes: Record<string, NodeType>;
   edges: Record<string, EdgeType>;
+  nodeIdMap: Record<string, string>; // maps the original _id of the graph result to generated ids of this struct
+  edgeIdMap: Record<string, string>;
 };
 
 export type NodeType = d3.SimulationNodeDatum & {
   id: string; // uuid
-  ids: string[]; // reverse mapping to original _id
+  ids: { _id: string; idx: number }[]; // reverse mapping to original _id and index in original graph
   color: number;
   label?: string;
   radius: number;
@@ -21,7 +23,7 @@ export type NodeType = d3.SimulationNodeDatum & {
 
 export type EdgeType = d3.SimulationLinkDatum<NodeType> & {
   id: string; // uuid
-  ids: string[]; // reverse mapping to original _id
+  ids: { _id: string; idx: number }[]; // reverse mapping to original _id and index in original graph
   label?: string;
   thickness: number;
   color: string;
-- 
GitLab


From 3b6efd2b603a8bf91e60aa61504b888e5db17a63 Mon Sep 17 00:00:00 2001
From: Dennis Collaris <d.collaris@me.com>
Date: Wed, 2 Apr 2025 14:51:51 +0200
Subject: [PATCH 05/12] perf: prevent expensive Object.values or keys if not
 strictly necessary

---
 .../nodelinkvis/components/NLPixi.tsx              | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)

diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
index ccd1ed007..e290d43a5 100644
--- a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
@@ -178,7 +178,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       sprite.scale.set(scale, scale);
     });
 
-    if (Object.keys(props.graph.nodes).length > config.LABEL_MAX_NODES) return;
+    if (nodeMap.current.size > config.LABEL_MAX_NODES) return;
 
     // Change font size at specific scale intervals
     const fontSize =
@@ -365,7 +365,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
         setResponsiveScale(1);
       }
 
-      if (Object.values(props.graph.nodes).length < config.LABEL_MAX_NODES) {
+      if (nodeMap.current.size < config.LABEL_MAX_NODES) {
         edgeLabelLayer.alpha = scale > 2 ? Math.min(1, (scale - 2) * 3) : 0;
 
         if (edgeLabelLayer.alpha > 0) {
@@ -739,7 +739,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
   };
 
   const updateEdgeLabel = (edge: EdgeType) => {
-    if (Object.keys(props.graph.nodes).length > config.LABEL_MAX_NODES || viewport.current!.scale.x < 2) return;
+    if (edgeLabelMap.current.size > config.LABEL_MAX_NODES || viewport.current!.scale.x < 2) return;
 
     const text = edgeLabelMap.current.get(edge.id);
     if (!text || edge.label == null) return;
@@ -786,7 +786,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
   };
 
   const updateNodeLabel = (node: NodeType) => {
-    if (Object.keys(props.graph.nodes).length > config.LABEL_MAX_NODES || viewport.current!.scale.x < 2) return;
+    if (nodeMap.current.size > config.LABEL_MAX_NODES || viewport.current!.scale.x < 2) return;
     const text = nodeLabelMap.current.get(node.id) as Text | undefined;
     if (text == null || node.label == null) return;
 
@@ -920,7 +920,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
         updateNodeLabel(node);
       });
 
-      if (stopped === Object.keys(props.graph.nodes).length) {
+      if (stopped === nodeMap.current.size) {
         layoutStoppedCount.current = layoutStoppedCount.current + 1;
         if (layoutStoppedCount.current > 500) {
           layoutState.current = 'paused';
@@ -938,7 +938,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       edgeGfx.clear();
 
       if (props.graph != null && nodeMap.current.size !== 0 && props.graph.edges != null) {
-        if (Object.keys(props.graph.edges).length > PERF_EDGE_THRESHOLD) {
+        if (edgeLabelMap.current.size > PERF_EDGE_THRESHOLD) {
           // If many many edges, only update edges roughy 1/3th the time, dont draw labels, dont do edge bundling.
           if (Math.random() > 0.3) {
             for (const edge of Object.values(props.graph.edges)) {
@@ -1007,7 +1007,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
         }
       });
 
-      if (Object.keys(props.graph.nodes).length < config.LABEL_MAX_NODES) {
+      if (edgeLabelMap.current.size < config.LABEL_MAX_NODES) {
         for (const edge of Object.values(props.graph.edges)) {
           if (!forceClear && edgeLabelMap.current.has(edge.id)) {
             updateEdgeLabel(edge);
-- 
GitLab


From 0a17a8391493f439d7f9177bb3493c4ddee02c95 Mon Sep 17 00:00:00 2001
From: Dennis Collaris <d.collaris@me.com>
Date: Wed, 2 Apr 2025 14:53:17 +0200
Subject: [PATCH 06/12] fix: random initialisation for large datasets was way
 too large

---
 src/lib/graph-layout/graphologyLayouts.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/lib/graph-layout/graphologyLayouts.ts b/src/lib/graph-layout/graphologyLayouts.ts
index 8867f29ee..6afa23e73 100644
--- a/src/lib/graph-layout/graphologyLayouts.ts
+++ b/src/lib/graph-layout/graphologyLayouts.ts
@@ -105,7 +105,7 @@ export class GraphologyRandom extends GraphologyLayout {
 
     // To directly assign the positions to the nodes:
     random.assign(graph, {
-      scale: (graph.order * graph.order) / 10,
+      scale: Math.sqrt(graph.order) * 40,
       ...this.defaultLayoutSettings,
       center: 0,
     });
-- 
GitLab


From 6c7eee129bffe36a32f6ea0140a764cf79492434 Mon Sep 17 00:00:00 2001
From: Dennis Collaris <d.collaris@me.com>
Date: Wed, 2 Apr 2025 15:03:42 +0200
Subject: [PATCH 07/12] perf: reduce is very slow, just setting in a for of
 loop is much faster

---
 .../components/query2NL/query2NL.tsx          | 31 ++++++++++---------
 1 file changed, 16 insertions(+), 15 deletions(-)

diff --git a/src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx
index bb33dd8b9..93ff757bd 100644
--- a/src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx
@@ -229,21 +229,22 @@ export function parseQueryResult(
   // List for all links
 
   // Parse normal edges
-  ret.edges = queryResult.edges
-    .map((e, i) => {
-      ret.edgeIdMap[e._id] = uuidv4();
-      return {
-        id: ret.edgeIdMap[e._id],
-        ids: [{ _id: e._id, idx: i }],
-        source: ret.nodeIdMap[e.from],
-        target: ret.nodeIdMap[e.to],
-        label: e.label, // TODO
-        ...getEdgeStyle(e, ml),
-      };
-    })
-    .reduce((a, b) => {
-      return { ...a, [b.id]: b };
-    }, {});
+  const partialEdges = queryResult.edges.map((e, i) => {
+    ret.edgeIdMap[e._id] = uuidv4();
+    return {
+      id: ret.edgeIdMap[e._id],
+      ids: [{ _id: e._id, idx: i }],
+      source: ret.nodeIdMap[e.from],
+      target: ret.nodeIdMap[e.to],
+      label: e.label, // TODO
+      ...getEdgeStyle(e, ml),
+    };
+  });
+
+  ret.edges = {};
+  for (const edge of partialEdges) {
+    ret.edges[edge.id] = edge;
+  }
 
   // Graph to be returned
   // let toBeReturned: GraphType = {
-- 
GitLab


From 6168c057e7ba8b11d3e844cbf5ea51c03bd8afc6 Mon Sep 17 00:00:00 2001
From: Dennis Collaris <d.collaris@me.com>
Date: Wed, 2 Apr 2025 15:19:13 +0200
Subject: [PATCH 08/12] perf: eliminate more Object.values and keys calls in
 favor of for in loops

---
 .../nodelinkvis/components/NLPixi.tsx         | 47 +++++++++++--------
 1 file changed, 28 insertions(+), 19 deletions(-)

diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
index e290d43a5..21442b263 100644
--- a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
@@ -165,7 +165,8 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
   useEffect(() => {
     if (nodeMap.current.size === 0) return;
 
-    Object.values(props.graph.nodes).forEach(node => {
+    for (const id in props.graph.nodes) {
+      const node = props.graph.nodes[id];
       const sprite = nodeMap.current.get(node.id) as Sprite;
       sprite.texture = (sprite as any).selected ? selectedTexture : glyphTexture;
 
@@ -176,7 +177,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       let scale = (Math.max(node.radius || 5, 5) / config.NODE_RADIUS) * 2;
       scale *= responsiveScale;
       sprite.scale.set(scale, scale);
-    });
+    }
 
     if (nodeMap.current.size > config.LABEL_MAX_NODES) return;
 
@@ -196,9 +197,10 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       text.resolution = Math.ceil(1 / responsiveScale);
     });
 
-    Object.values(props.graph.nodes).forEach((node: any) => {
+    for (const id in props.graph.nodes) {
+      const node = props.graph.nodes[id];
       updateNodeLabel(node);
-    });
+    }
   }, [responsiveScale, props.configuration.nodes?.shape?.type]);
 
   const [config, setConfig] = useState({
@@ -855,13 +857,14 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
 
   useEffect(() => {
     if (props.graph) {
-      Object.values(props.graph.nodes).forEach(node => {
+      for (const id in props.graph.nodes) {
+        const node = props.graph.nodes[id];
         const gfx = nodeMap.current.get(node.id);
         if (!gfx) return;
         const isNodeInSearchResults = searchResults.nodes.some(resultNode => resultNode.id === node.id);
 
         gfx.alpha = isNodeInSearchResults || searchResults.nodes.length === 0 ? 1 : 0.05;
-      });
+      }
     }
   }, [searchResults]);
 
@@ -890,7 +893,8 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
 
       const widthHalf = app.renderer.width / 2;
       const heightHalf = app.renderer.height / 2;
-      Object.values(props.graph.nodes).forEach((node, i) => {
+      for (const id in props.graph.nodes) {
+        const node = props.graph.nodes[id];
         const gfx = nodeMap.current.get(node.id);
         if (!gfx || node.x === undefined || node.y === undefined) {
           stopped += 1;
@@ -918,7 +922,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
         gfx.position.copyFrom(node as PointData);
 
         updateNodeLabel(node);
-      });
+      }
 
       if (stopped === nodeMap.current.size) {
         layoutStoppedCount.current = layoutStoppedCount.current + 1;
@@ -940,11 +944,12 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       if (props.graph != null && nodeMap.current.size !== 0 && props.graph.edges != null) {
         if (edgeLabelMap.current.size > PERF_EDGE_THRESHOLD) {
           // If many many edges, only update edges roughy 1/3th the time, dont draw labels, dont do edge bundling.
-          if (Math.random() > 0.3) {
-            for (const edge of Object.values(props.graph.edges)) {
+          // if (Math.random() > 0.3) { // TODO it is actually working pretty nice without this now. Let's test and see if/when we reinstate.
+          for (const id in props.graph.edges) {
+              const edge = props.graph.edges[id];
               updateEdge(edge);
             }
-          }
+          // }
         } else {
           for (const [i, edge] of Object.values(props.graph.edges).entries()) {
             if (edgeBundling != null && imperative.current.getEdgeBundlingEnabled()) {
@@ -994,7 +999,8 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
 
       edgeGfx.clear();
 
-      Object.values(props.graph.nodes).forEach(node => {
+      for (const id in props.graph.nodes) {
+        const node = props.graph.nodes[id];
         if (!forceClear && nodeMap.current.has(node.id)) {
           const old = nodeMap.current.get(node.id);
 
@@ -1005,11 +1011,12 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
         } else {
           createNode(node);
         }
-      });
+      }
 
-      if (edgeLabelMap.current.size < config.LABEL_MAX_NODES) {
-        for (const edge of Object.values(props.graph.edges)) {
-          if (!forceClear && edgeLabelMap.current.has(edge.id)) {
+      if (nodeMap.current.size < config.LABEL_MAX_NODES) {
+        for (const id in props.graph.edges) {
+          const edge = props.graph.edges[id];
+          if (!forceClear && edgeLabelMap.current.has(edge.ids[0]._id)) {
             updateEdgeLabel(edge);
           } else {
             createEdgeLabel(edge);
@@ -1099,7 +1106,8 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
     if (!layoutAlgorithm) throw Error('LayoutAlgorithm is undefined');
 
     const graphologyGraph = new MultiGraph();
-    Object.values(props.graph.nodes).forEach(node => {
+    for (const id in props.graph.nodes) {
+      const node = props.graph.nodes[id];
       if (forceClear) graphologyGraph.addNode(node.id, { size: node.radius || 5 });
       else
         graphologyGraph.addNode(node.id, {
@@ -1107,9 +1115,10 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
           x: node.x || 0,
           y: node.y || 0,
         });
-    });
+    }
 
-    for (const edge of Object.values(props.graph.edges)) {
+    for (const id in props.graph.edges) {
+      const edge = props.graph.edges[id];
       graphologyGraph.addEdge(edge.source, edge.target);
     }
     const boundingBox = { x1: 0, x2: app!.renderer.screen.width, y1: 0, y2: app!.renderer.screen.height };
-- 
GitLab


From 3236dc6071bc0176acfa85529186fbc56b147764 Mon Sep 17 00:00:00 2001
From: Leonardo Christino <lchristino@graphpolaris.com>
Date: Mon, 14 Apr 2025 10:44:34 +0200
Subject: [PATCH 09/12] fix: better popup and fix to only rerun nl parser on
 new graph

---
 .../nodelinkvis/NodelinkVis.tsx               | 37 +++++-----
 .../nodelinkvis/components/NLPixi.tsx         | 20 +++---
 .../nodelinkvis/components/NLPopup.tsx        | 72 ++++++++++---------
 .../nodelinkvis/components/utils.tsx          |  3 +-
 4 files changed, 73 insertions(+), 59 deletions(-)

diff --git a/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx b/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx
index 715a973a0..0990169a8 100644
--- a/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx
@@ -105,9 +105,10 @@ const NodelinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin
 
     useEffect(() => {
       if (data) {
+        console.log('parseQueryResult!!!', data);
         setGraph(parseQueryResult(data.graph, ml, settings));
       }
-    }, [data, ml]);
+    }, [data.graph, ml]);
 
     const onClickedNode = (event?: { node: NodeType; pos: PointData }, ml?: ML) => {
       if (graph) {
@@ -135,22 +136,24 @@ const NodelinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin
 
     return (
       <>
-        {selectedNodes.map(selectedNode => (
-          <Popover
-            key={selectedNode.id}
-            open={true}
-            interactive={!dragging}
-            boundaryElement={ref as RefObject<HTMLElement>}
-            showArrow={true}
-          >
-            <PopoverTrigger x={selectedNode.pos.x} y={selectedNode.pos.y} />
-            <PopoverContent>
-              <NodeDetails name={selectedNode.id} colorHeader={hexColorFromBinary(graph.nodes[selectedNode.id].color)}>
-                <NLPopUp data={data.graph.nodes[graph.nodes[selectedNode.id].ids[0].idx].attributes} />
-              </NodeDetails>
-            </PopoverContent>
-          </Popover>
-        ))}
+        {selectedNodes.map(selectedNode => {
+          return (
+            <Popover
+              key={selectedNode.id}
+              open={true}
+              interactive={!dragging}
+              boundaryElement={ref as RefObject<HTMLElement>}
+              showArrow={true}
+            >
+              <PopoverTrigger x={selectedNode.pos.x} y={selectedNode.pos.y} />
+              <PopoverContent>
+                <NodeDetails name={selectedNode.id} colorHeader={hexColorFromBinary(graph.nodes[selectedNode.id].color)}>
+                  <NLPopUp data={data.graph.nodes[graph.nodes[selectedNode.id].ids[0].idx].attributes} />
+                </NodeDetails>
+              </PopoverContent>
+            </Popover>
+          );
+        })}
         <NLPixi
           ref={nlPixiRef}
           graph={graph}
diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
index 21442b263..390149220 100644
--- a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
@@ -24,8 +24,6 @@ import { GraphologyForceAtlas2Webworker, LayoutFactory, Layouts, LayoutTypes } f
 import { NodelinkVisProps } from '../NodelinkVis';
 import { EdgeType, GraphType, NodeType } from '../types';
 import { ForceEdgeBundling, type Point } from './query2NL/edgeBundling';
-import { NLPopUp } from './NLPopup';
-import { nodeColor, nodeColorHex } from './utils';
 
 const PERF_EDGE_THRESHOLD = 2500;
 
@@ -167,7 +165,9 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
 
     for (const id in props.graph.nodes) {
       const node = props.graph.nodes[id];
-      const sprite = nodeMap.current.get(node.id) as Sprite;
+      const sprite = nodeMap.current.get(node.id);
+      if (!sprite) continue;
+
       sprite.texture = (sprite as any).selected ? selectedTexture : glyphTexture;
 
       // To calculate the scale, we:
@@ -290,7 +290,8 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       }
 
       const sprite = event.target as Sprite;
-      const node = (sprite as any).node as NodeType;
+      const nodeId = (sprite as any).node as number;
+      const node = props.graph.nodes[nodeId];
 
       if (event.shiftKey) {
         setSelectedNodes([...selectedNodes, { id: node.id, pos: toGlobal(node) }]);
@@ -332,7 +333,8 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       if (!props.configuration.showPopUpOnHover) return;
 
       const sprite = event.target as Sprite;
-      const node = (sprite as any).node as NodeType;
+      const nodeId = (sprite as any).node as number;
+      const node = props.graph.nodes[nodeId];
       if (
         mouseInCanvas.current &&
         viewport?.current &&
@@ -548,7 +550,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
     nodeLayer.addChild(sprite);
 
     updateNode(node);
-    (sprite as any).node = node;
+    (sprite as any).node = node.id;
 
     // Node label
     const attribute = node.label;
@@ -946,9 +948,9 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
           // If many many edges, only update edges roughy 1/3th the time, dont draw labels, dont do edge bundling.
           // if (Math.random() > 0.3) { // TODO it is actually working pretty nice without this now. Let's test and see if/when we reinstate.
           for (const id in props.graph.edges) {
-              const edge = props.graph.edges[id];
-              updateEdge(edge);
-            }
+            const edge = props.graph.edges[id];
+            updateEdge(edge);
+          }
           // }
         } else {
           for (const [i, edge] of Object.values(props.graph.edges).entries()) {
diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx
index 891e458d7..731bdcebb 100644
--- a/src/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx
@@ -15,7 +15,7 @@ export const NLPopUp: React.FC<NLPopUpProps> = ({ data }) => {
   const [didCopy, setDidCopy] = useState<string | null>(null);
   return (
     <TooltipProvider delay={100}>
-      <div className={`px-2`}>
+      <div className={`px-2  overflow-auto max-h-96`}>
         {Object.keys(data).length === 0 ? (
           <div className="flex justify-center items-center h-full">
             <span>No attributes</span>
@@ -24,38 +24,46 @@ export const NLPopUp: React.FC<NLPopUpProps> = ({ data }) => {
           Object.entries(data).map(([k, v]) => {
             if (v?.toString().length > ATTRIBUTE_MAX_CHARACTERS) return;
             return (
-              <div className="flex flex-row gap-1 items-center min-h-5" key={k}>
-                <span className={`font-semibold truncate min-w-[40%]`}>{k}</span>
-                <span className="ml-auto text-right truncate grow-1 flex items-center">
-                  {v !== undefined && (typeof v !== 'object' || Array.isArray(v)) && v !== null ? (
-                    <Tooltip open={didCopy === k} placement="top">
-                      <TooltipContent>Copied!</TooltipContent>
-                      <TooltipTrigger>
-                        <span
-                          className="ml-auto text-right truncate"
-                          onDoubleClick={e => {
-                            const value = typeof v === 'number' ? formatNumber(v) : v.toString();
-                            navigator.clipboard.writeText(value);
-                            setDidCopy(k);
-                            window.getSelection()?.empty();
-                            setTimeout(() => setDidCopy(null), 1000);
+              <Tooltip>
+                <TooltipTrigger>
+                  <div className="flex flex-row gap-1 items-center min-h-5" key={k}>
+                    <span className={`font-semibold truncate min-w-[40%]`}>{k}</span>
+                    <span className="ml-auto text-right truncate grow-1 flex items-center">
+                      {v !== undefined && (typeof v !== 'object' || Array.isArray(v)) && v !== null ? (
+                        <Tooltip open={didCopy === k} placement="top">
+                          <TooltipContent>Copied!</TooltipContent>
+                          <TooltipTrigger>
+                            <span
+                              className="ml-auto text-right truncate"
+                              onDoubleClick={e => {
+                                const value = typeof v === 'number' ? formatNumber(v) : v.toString();
+                                navigator.clipboard.writeText(value);
+                                setDidCopy(k);
+                                window.getSelection()?.empty();
+                                setTimeout(() => setDidCopy(null), 1000);
+                              }}
+                            >
+                              {typeof v === 'number' ? formatNumber(v) : v.toString()}
+                            </span>
+                          </TooltipTrigger>
+                        </Tooltip>
+                      ) : (
+                        <div
+                          className={`ml-auto mt-auto h-4 w-12 border-[1px] solid border-gray`}
+                          style={{
+                            background:
+                              'repeating-linear-gradient(-45deg, transparent, transparent 6px, #eaeaea 6px, #eaeaea 8px), linear-gradient(to bottom, transparent, transparent)',
                           }}
-                        >
-                          {typeof v === 'number' ? formatNumber(v) : v.toString()}
-                        </span>
-                      </TooltipTrigger>
-                    </Tooltip>
-                  ) : (
-                    <div
-                      className={`ml-auto mt-auto h-4 w-12 border-[1px] solid border-gray`}
-                      style={{
-                        background:
-                          'repeating-linear-gradient(-45deg, transparent, transparent 6px, #eaeaea 6px, #eaeaea 8px), linear-gradient(to bottom, transparent, transparent)',
-                      }}
-                    ></div>
-                  )}
-                </span>
-              </div>
+                        ></div>
+                      )}
+                    </span>
+                  </div>
+                </TooltipTrigger>
+                <TooltipContent className="w-fit max-w-fit">
+                  <span className="font-semibold">{k}: </span>
+                  <span className="text-sm">{v == null || v == '' ? 'empty' : v?.toString()}</span>
+                </TooltipContent>
+              </Tooltip>
             );
           })
         )}
diff --git a/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx b/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx
index 348f56c1b..8d29d4698 100644
--- a/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/components/utils.tsx
@@ -24,7 +24,8 @@ export function binaryColor(color: string) {
   return Number('0x' + color.replace('#', ''));
 }
 
-export function hexColorFromBinary(num: number) {
+export function hexColorFromBinary(num?: number) {
+  if (num == null) return '#000000';
   const hex = num.toString(16);
   return '#' + hex.padStart(6, '0');
 }
-- 
GitLab


From 11c0bbefc136ce838f96caecd314bd9bc40d628c Mon Sep 17 00:00:00 2001
From: Leonardo Christino <lchristino@graphpolaris.com>
Date: Mon, 14 Apr 2025 11:56:24 +0200
Subject: [PATCH 10/12] fix: nl edge label and width

---
 src/lib/graph-layout/cytoscapeLayouts.ts      |  4 ++
 src/lib/graph-layout/dagreLayout.ts           |  4 ++
 src/lib/graph-layout/graphologyLayouts.ts     | 10 +++-
 src/lib/graph-layout/layout.ts                |  4 ++
 .../nodelinkvis/NodelinkVis.tsx               |  8 ++-
 .../nodelinkvis/components/NLPixi.tsx         | 40 +++++++-------
 .../components/query2NL/edgeStyle.ts          |  6 ++-
 .../components/query2NL/query2NL.tsx          | 53 +++++++++----------
 8 files changed, 74 insertions(+), 55 deletions(-)

diff --git a/src/lib/graph-layout/cytoscapeLayouts.ts b/src/lib/graph-layout/cytoscapeLayouts.ts
index d52062333..251163268 100644
--- a/src/lib/graph-layout/cytoscapeLayouts.ts
+++ b/src/lib/graph-layout/cytoscapeLayouts.ts
@@ -116,6 +116,10 @@ export abstract class CytoscapeLayout extends Layout<CytoscapeProvider> {
     super('Cytoscape', algorithm);
   }
 
+  public override cleanup() {
+    // Cleanup logic if needed
+  }
+
   public override async layout(
     graph: Graph<Attributes, Attributes, Attributes>,
     boundingBox?: { x1: number; x2: number; y1: number; y2: number },
diff --git a/src/lib/graph-layout/dagreLayout.ts b/src/lib/graph-layout/dagreLayout.ts
index d15840d9b..37a03dd9f 100644
--- a/src/lib/graph-layout/dagreLayout.ts
+++ b/src/lib/graph-layout/dagreLayout.ts
@@ -21,6 +21,10 @@ export class DagreLayout extends Layout<DagreProvider> {
     super('Dagre', 'Dagre_Dagre');
   }
 
+  public override cleanup() {
+    // Cleanup logic if needed
+  }
+
   public override async layout(
     graph: Graph<Attributes, Attributes, Attributes>,
     boundingBox?: { x1: number; x2: number; y1: number; y2: number },
diff --git a/src/lib/graph-layout/graphologyLayouts.ts b/src/lib/graph-layout/graphologyLayouts.ts
index 6afa23e73..1d1ed3246 100644
--- a/src/lib/graph-layout/graphologyLayouts.ts
+++ b/src/lib/graph-layout/graphologyLayouts.ts
@@ -52,6 +52,10 @@ export abstract class GraphologyLayout extends Layout<GraphologyProvider> {
     super('Graphology', algorithm);
   }
 
+  public override cleanup() {
+    // Cleanup logic if needed
+  }
+
   /**
    * Retrieves the position of a node in the graph layout.
    * @param nodeId - The ID of the node.
@@ -199,7 +203,9 @@ export class GraphologyForceAtlas2Webworker extends GraphologyLayout {
     super('Graphology_forceAtlas2_webworker');
   }
 
-  public cleanup() {
+  public override cleanup() {
+    console.log('Cleaning up layout webworker');
+    this._layout?.stop();
     this._layout?.kill();
   }
 
@@ -231,7 +237,7 @@ export class GraphologyForceAtlas2Webworker extends GraphologyLayout {
 
     // stop the layout after 60 seconds
     setTimeout(() => {
-      console.log('Stopping layout after set threshold');
+      console.log('Stopping nl webworker layout after set threshold');
       this._layout?.stop();
     }, 60000);
   }
diff --git a/src/lib/graph-layout/layout.ts b/src/lib/graph-layout/layout.ts
index 0f9cdf371..98e64d48c 100644
--- a/src/lib/graph-layout/layout.ts
+++ b/src/lib/graph-layout/layout.ts
@@ -14,6 +14,10 @@ export abstract class Layout<provider extends Providers> {
     // console.info(`Created the following Layout: ${provider} - ${this.algorithm}`);
   }
 
+  public cleanup() {
+    // Cleanup logic if needed
+  }
+
   public setVerbose(verbose: boolean) {
     this.verbose = verbose;
   }
diff --git a/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx b/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx
index 0990169a8..b5c4b9149 100644
--- a/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx
@@ -101,14 +101,12 @@ const NodelinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin
     const [selectedNodes, setSelectedNodes] = useState<SelectedNodeType[]>([]);
     const [dragging, setDragging] = useState<boolean>(false);
 
-    settings = patchLegacySettings(settings);
-
     useEffect(() => {
       if (data) {
-        console.log('parseQueryResult!!!', data);
-        setGraph(parseQueryResult(data.graph, ml, settings));
+        const graph = parseQueryResult(data.graph, ml, settings);
+        setGraph(graph);
       }
-    }, [data.graph, ml]);
+    }, [data.graph, ml, settings.edges, settings.nodes, settings.layout]);
 
     const onClickedNode = (event?: { node: NodeType; pos: PointData }, ml?: ML) => {
       if (graph) {
diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
index 390149220..1f21c4968 100644
--- a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
@@ -1,4 +1,4 @@
-import { dataColors, visualizationColors } from '@/config';
+import { dataColors } from '@/config';
 import { canViewFeature } from '@/lib/components/featureFlags';
 import { useConfig } from '@/lib/data-access/store';
 import { Theme } from '@/lib/data-access/store/configSlice';
@@ -25,6 +25,7 @@ import { NodelinkVisProps } from '../NodelinkVis';
 import { EdgeType, GraphType, NodeType } from '../types';
 import { ForceEdgeBundling, type Point } from './query2NL/edgeBundling';
 
+const layoutFactory = new LayoutFactory();
 const PERF_EDGE_THRESHOLD = 2500;
 
 export type SelectedNodeType = { id: string; pos: PointData };
@@ -78,10 +79,10 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
   }, [canvas]);
 
   useEffect(() => {
-    if (app == null) return;
+    if (app == null || props.graph == null) return;
 
-    setup();
-  }, [app]);
+    setup(true);
+  }, [app, props.graph]);
 
   const nodeLayer = useMemo(() => new Container(), []);
   const edgeLabelLayer = useMemo(() => {
@@ -218,9 +219,6 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
     // NODE_BORDER_COLOR_SELECTED: dataColors.orange[60],
 
     LINE_COLOR_DEFAULT: dataColors.neutral[40],
-    LINE_COLOR_SELECTED: visualizationColors.GPSelected.colors[1],
-    LINE_COLOR_ML: dataColors.blue[60],
-    LINE_WIDTH_DEFAULT: 0.8,
   });
 
   const glyphTexture = useMemo(() => {
@@ -387,10 +385,6 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       }
     },
 
-    getEdgeWidth() {
-      return props.configuration.edges.width.width || config.LINE_WIDTH_DEFAULT;
-    },
-
     getBackgroundColor() {
       // Colors corresponding to .bg-light class
       return globalConfig.currentTheme === Theme.dark ? 0x121621 : 0xffffff;
@@ -847,7 +841,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
       });
 
       const layout = layoutAlgorithm.current as GraphologyForceAtlas2Webworker;
-      if (layout?.cleanup != null) layout.cleanup();
+      layout.cleanup();
     };
   }, []);
 
@@ -1048,8 +1042,15 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
    * 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.
    */
-  const setup = () => {
-    if (app == null || isSetup.current) return;
+  const setup = (restart = false) => {
+    if (app == null) return;
+    if (!restart && isSetup.current) return;
+
+    // Ensure any previous PIXI application is completely cleaned up.
+    app.stage.removeChildren();
+    app.ticker.stop();
+    app.renderer.clear();
+
     nodeLayer.removeChildren();
     edgeLabelLayer.removeChildren();
     app.stage.removeChildren();
@@ -1090,7 +1091,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
     nodeMap.current.clear();
     edgeGfx.clear();
 
-    app.ticker.add(tick);
+    app.ticker.add(tick.bind(this));
 
     // NOTE: this fixes a weird bug that every once in a while results in a white screen in Chrome
     app.ticker.start();
@@ -1102,10 +1103,13 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
   };
 
   const setupLayout = (forceClear: boolean) => {
-    const layoutFactory = new LayoutFactory();
-    layoutAlgorithm.current = layoutFactory.createLayout(config.LAYOUT_ALGORITHM);
+    layoutAlgorithm.current.cleanup();
+
+    if (layoutAlgorithm.current.algorithm !== config.LAYOUT_ALGORITHM) {
+      layoutAlgorithm.current = layoutFactory.createLayout(config.LAYOUT_ALGORITHM);
+    }
 
-    if (!layoutAlgorithm) throw Error('LayoutAlgorithm is undefined');
+    if (!layoutAlgorithm?.current) throw Error('LayoutAlgorithm is undefined');
 
     const graphologyGraph = new MultiGraph();
     for (const id in props.graph.nodes) {
diff --git a/src/lib/vis/visualizations/nodelinkvis/components/query2NL/edgeStyle.ts b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/edgeStyle.ts
index 5e0dac48e..cd772edae 100644
--- a/src/lib/vis/visualizations/nodelinkvis/components/query2NL/edgeStyle.ts
+++ b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/edgeStyle.ts
@@ -1,14 +1,16 @@
+import { VisualizationSettingsType } from '@/lib/vis/common';
 import { dataColors, EdgeQueryResult, ML, visualizationColors } from 'ts-common';
+import { NodelinkVisProps } from '../../NodelinkVis';
 
 export const LINE_COLOR_DEFAULT = dataColors.neutral[40];
 export const LINE_COLOR_SELECTED = visualizationColors.GPSelected.colors[1];
 export const LINE_COLOR_ML = dataColors.blue[60];
 export const LINE_WIDTH_DEFAULT = 0.8;
 
-export const getEdgeStyle = (edge: EdgeQueryResult, ml: ML) => {
+export const getEdgeStyle = (edge: EdgeQueryResult, ml: ML, settings: NodelinkVisProps & VisualizationSettingsType) => {
   // let color = edge.color || 0x000000;
   let color = LINE_COLOR_DEFAULT;
-  const thickness = (edge.attributes.jaccard_coefficient as number) || 1;
+  const thickness = ((edge.attributes.jaccard_coefficient as number) || 1) * settings.edges.width.width;
   let style = thickness || 1;
   let alpha = 1;
   let mlEdge = false;
diff --git a/src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx
index 93ff757bd..d0002b883 100644
--- a/src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/components/query2NL/query2NL.tsx
@@ -8,7 +8,7 @@ import { EdgeQueryResult, GraphQueryResultMetaFromBackend, ML, NodeQueryResult }
 import { v4 as uuidv4 } from 'uuid';
 import { GraphQueryResult } from '../../../../../data-access/store';
 import { NodelinkVisProps } from '../../NodelinkVis';
-import { EdgeType, GraphType, NodeType } from '../../types';
+import { GraphType, NodeType } from '../../types';
 import { nodeColor } from '../utils';
 import { processML } from './NLMachineLearning';
 import { getEdgeStyle } from './edgeStyle';
@@ -112,30 +112,26 @@ const getNodeLabel = (node: NodeQueryResult, d3node: NodeType, settings: Nodelin
   // return '-';
 };
 
-const getEdgeLabel = (edge: EdgeQueryResult, d3edge: EdgeType, settings: NodelinkVisProps) => {
-  // let attribute;
-  // try {
-  //   attribute = imperative.current.getEdgeAttributes()[edgeMeta.attributes.type];
-  // } catch (e) {
-  //   return edgeMeta.attributes.type ?? '';
-  // }
-  // if (attribute == 'None') {
-  //   return '';
-  // }
-  // if (attribute == 'Default' || attribute == null) {
-  //   return edgeMeta.attributes.type ?? '';
-  // }
-  // const value = edgeMeta.attributes[attribute];
-  // if (Array.isArray(value)) {
-  //   return value.join(', ');
-  // }
-  // if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') {
-  //   return String(value);
-  // }
-  // if (typeof value === 'object' && Object.keys(value).length != 0) {
-  //   return JSON.stringify(value);
-  // }
-  // return '';
+const getEdgeLabel = (edge: EdgeQueryResult, settings: NodelinkVisProps): string => {
+  const attribute = settings.edges.labelAttributes[edge.label];
+
+  if (attribute == 'None') {
+    return '';
+  }
+  if (attribute == 'Default' || attribute == null) {
+    return String(edge.attributes.type ?? '');
+  }
+  const value = edge.attributes[attribute];
+  if (Array.isArray(value)) {
+    return value.join(', ');
+  }
+  if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') {
+    return String(value);
+  }
+  if (typeof value === 'object' && Object.keys(value as object).length != 0) {
+    return JSON.stringify(value);
+  }
+  return '';
 };
 
 /**
@@ -167,7 +163,7 @@ export function parseQueryResult(
   const linkPredictionInResult = false;
   for (let i = 0; i < queryResult.nodes.length; i++) {
     // Assigns a group to every entity type for color coding
-    const nodeId = uuidv4();
+    const nodeId = queryResult.nodes[i]._id;
     ret.nodeIdMap[queryResult.nodes[i]._id] = nodeId;
     // for datasets without label, label is included in id. eg. "kamerleden/112"
     //const entityType = queryResult.nodes[i].label;
@@ -231,13 +227,14 @@ export function parseQueryResult(
   // Parse normal edges
   const partialEdges = queryResult.edges.map((e, i) => {
     ret.edgeIdMap[e._id] = uuidv4();
+    const label = getEdgeLabel(e, settings);
     return {
       id: ret.edgeIdMap[e._id],
       ids: [{ _id: e._id, idx: i }],
       source: ret.nodeIdMap[e.from],
       target: ret.nodeIdMap[e.to],
-      label: e.label, // TODO
-      ...getEdgeStyle(e, ml),
+      label: label,
+      ...getEdgeStyle(e, ml, settings),
     };
   });
 
-- 
GitLab


From 3df22900efd6c83435d6a2353ecfa5a56f47c371 Mon Sep 17 00:00:00 2001
From: Leonardo Christino <lchristino@graphpolaris.com>
Date: Mon, 14 Apr 2025 12:15:14 +0200
Subject: [PATCH 11/12] fix: popup only shows inside nl

---
 src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx      | 6 ++++--
 .../vis/visualizations/nodelinkvis/components/NLPixi.tsx    | 3 +--
 .../vis/visualizations/nodelinkvis/components/NLPopup.tsx   | 2 +-
 3 files changed, 6 insertions(+), 5 deletions(-)

diff --git a/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx b/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx
index b5c4b9149..889b60eb9 100644
--- a/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/NodelinkVis.tsx
@@ -133,8 +133,10 @@ const NodelinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin
     if (!graph) return null;
 
     return (
-      <>
+      <div ref={ref} className="relative h-full w-full">
         {selectedNodes.map(selectedNode => {
+          if (selectedNode.pos.x === undefined || selectedNode.pos.y === undefined) return null;
+
           return (
             <Popover
               key={selectedNode.id}
@@ -167,7 +169,7 @@ const NodelinkVis = forwardRef<NodeLinkVisHandle, VisualizationPropTypes<Nodelin
           showPopupsOnHover={settings.showPopUpOnHover}
           edgeBundlingEnabled={settings.edgeBundlingEnabled}
         />
-      </>
+      </div>
     );
   },
 );
diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
index 1f21c4968..2d78d2345 100644
--- a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
@@ -19,7 +19,7 @@ import {
 } from 'pixi.js';
 import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
 import { AllLayoutAlgorithms } from 'ts-common/src/model/layouts';
-import { useML, useSearchResultData } from '../../../../data-access';
+import { useSearchResultData } from '../../../../data-access';
 import { GraphologyForceAtlas2Webworker, LayoutFactory, Layouts, LayoutTypes } from '../../../../graph-layout';
 import { NodelinkVisProps } from '../NodelinkVis';
 import { EdgeType, GraphType, NodeType } from '../types';
@@ -119,7 +119,6 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
   const layoutStoppedCount = useRef(0);
   const mouseInCanvas = useRef<boolean>(false);
   const isSetup = useRef(false);
-  const ml = useML();
   const searchResults = useSearchResultData();
   const layoutAlgorithm = useRef(new LayoutFactory().createLayout<AllLayoutAlgorithms>(Layouts.DAGRE));
 
diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx
index 731bdcebb..33bebaaaf 100644
--- a/src/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx
@@ -24,7 +24,7 @@ export const NLPopUp: React.FC<NLPopUpProps> = ({ data }) => {
           Object.entries(data).map(([k, v]) => {
             if (v?.toString().length > ATTRIBUTE_MAX_CHARACTERS) return;
             return (
-              <Tooltip>
+              <Tooltip key={k}>
                 <TooltipTrigger>
                   <div className="flex flex-row gap-1 items-center min-h-5" key={k}>
                     <span className={`font-semibold truncate min-w-[40%]`}>{k}</span>
-- 
GitLab


From 9d4c4950393235b747d63ae0ffc957d4dea847b0 Mon Sep 17 00:00:00 2001
From: Leonardo Christino <lchristino@graphpolaris.com>
Date: Mon, 14 Apr 2025 12:27:58 +0200
Subject: [PATCH 12/12] fix: reset responsive scale on new data

---
 src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
index 2d78d2345..8512dfd05 100644
--- a/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
+++ b/src/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
@@ -1095,6 +1095,7 @@ export const NLPixi = forwardRef((props: Props, refExternal) => {
     // NOTE: this fixes a weird bug that every once in a while results in a white screen in Chrome
     app.ticker.start();
 
+    setResponsiveScale(1);
     imperative.current?.resize();
 
     isSetup.current = true;
-- 
GitLab