From a61f3cbbaf13831baf5dc0a796bf1fa220d83eae Mon Sep 17 00:00:00 2001
From: Milho001 <l.milhomemfrancochristino@uu.nl>
Date: Tue, 18 Jun 2024 16:05:42 +0000
Subject: [PATCH] feat(nl): nodelink performance to pixi rendering and
 layouting

does optimizations for large graphs while keeping the normal process for smaller graphs
also reinstates nl shapes in settings
---
 apps/web/public/assets/sprite.png             | Bin 0 -> 687 bytes
 apps/web/public/assets/sprite_selected.png    | Bin 0 -> 646 bytes
 .../public/assets/sprite_selected_square.png  | Bin 0 -> 100 bytes
 apps/web/public/assets/sprite_square.png      | Bin 0 -> 344 bytes
 .../shared/lib/components/dropdowns/index.tsx |   2 +-
 libs/shared/lib/components/inputs/index.tsx   |  26 ++-
 .../lib/graph-layout/graphology-layouts.ts    |  27 ++-
 .../nodelinkvis/components/NLPixi.tsx         | 184 +++++++++---------
 .../nodelinkvis/nodelinkvis.tsx               |   2 +-
 9 files changed, 133 insertions(+), 108 deletions(-)
 create mode 100644 apps/web/public/assets/sprite.png
 create mode 100644 apps/web/public/assets/sprite_selected.png
 create mode 100644 apps/web/public/assets/sprite_selected_square.png
 create mode 100644 apps/web/public/assets/sprite_square.png

diff --git a/apps/web/public/assets/sprite.png b/apps/web/public/assets/sprite.png
new file mode 100644
index 0000000000000000000000000000000000000000..a9d77b0520b6b4945bbe38e04553e4e349f1794e
GIT binary patch
literal 687
zcmeAS@N?(olHy`uVBq!ia0vp^ZXnFT3?e@WZ39vj0X`wFK-w=N%P%|&L?WS}=&YdV
zY>-GKL>$P@_6yJUjm+^5&+zoj{r~?zP&PO=f6?-dKY#xE@#E+2{l^lss(%0e{ps_U
zH*ep+eD&tWt$WvQ+&y>k+M%OoCQe_}HDOL=V_#uqM@B)znOKo*pxweHL4LsuZ!i5=
zvogU$ivRwNLsQx!f_2!h<z3j$z`%If)5S3);_#fQoYR^N1YDdsG*)PoDJWQX-2315
zIn64ONy+;k<FC@trB*487kDMU>fYW~bBf7a<@d*!<Bx=Oj(<HN$Jci@edAo_o2RCJ
zVtrA1TTOl6q0HLF%8#WJ+&7)@{cif|@fN+$I;T%t_5SuK|GmxNN!`V?5??0eNy{#)
z#lDMowktkr?VHf1Qd@lV=I^A_0v8syuHVwHxasj5k@%CHtU3xws%PiVd>pvdi&Z-$
zOYv3hj@31lq8q$E2L1D?4U@EG&2g{^jhp=a&_oF{q2ecIo32mVeIV_L-LyR8$@@5N
z^i23?u(sD|F;iK<i9P?SyXN+3-Eb96yx;!YXk+>Ev#<UIOi@WtXG*;|(OCD*va5Fs
zH(X9&_;&ZFws72g5gw0^=l+MCJ~~6lK;htqJC4EZZY9lHZycH3Om1*EIr&cBxGEs(
za<k%I6LpFG37e<O2rN6AD`Pp?cg4X8QQsK<Hw*D-e(ax6`8>fxPx!c2N`gmQ>5t5#
zei;l6jS_7i({qK?3^L>OGo#dUwPXu-efaX{Qo2XHG3(LJwIYi&FI{f1e5}WNznO{C
zhfhs4!<u7e!?y=t8~-kmb8qRJChKW)s)}XTi$9v;URMLVrpGY<4blotVK_ET^}&|e
S-<5%}&*16m=d#Wzp$P!Bu{<>Z

literal 0
HcmV?d00001

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

literal 0
HcmV?d00001

diff --git a/apps/web/public/assets/sprite_selected_square.png b/apps/web/public/assets/sprite_selected_square.png
new file mode 100644
index 0000000000000000000000000000000000000000..eb7a57e4f70d60c5096d07f0cd53803454285099
GIT binary patch
literal 100
zcmeAS@N?(olHy`uVBq!ia0vp^wjj*N3?x<7d|m~l*aCb)T>t<7?-!Bv;jqp+popZW
wi(`n!`Q#t`jP@F71`Q0qHIf4iR|xN9C|rJH`@fU+{6Iwvp00i_>zopr01F8n;Q#;t

literal 0
HcmV?d00001

diff --git a/apps/web/public/assets/sprite_square.png b/apps/web/public/assets/sprite_square.png
new file mode 100644
index 0000000000000000000000000000000000000000..38a4cd208703a2e481ca4337430f631026c6bed0
GIT binary patch
literal 344
zcmeAS@N?(olHy`uVBq!ia0vp^wjj*G1SBUieA@@47>k44ofy`glX(f`xTHpSruq6Z
zXaU(A42<oW3@jieKr98s3=GT*7#Wy>G$TlC0TW!-b^$Yj4N_<*$-N#(9rAQ>49Q@9
zdut=_AqNqbz@8QSL3fzewM%up6qwjMm;aIX<BYc{;qUdn6z#S5W1j!{z}fpJ-~M}h
z(D&;;nHl%iKYw_7{+@f+dcBubZJ)jT#q&M2$!1S{dF`LSmENqFpY_8s|Nac#%HYH2
zD}Nu$IWb$R?%ZwjH0OI)J`~^EXDIz?S=;>2-+FIOFgN`(XM6scwmmB!;x}T(tG!?T
mEA$7R4}R~t`|IkB+CLZ^8CYlcMn~QU`O4GP&t;ucLK6V{;DuBG

literal 0
HcmV?d00001

diff --git a/libs/shared/lib/components/dropdowns/index.tsx b/libs/shared/lib/components/dropdowns/index.tsx
index 9fea3c4c9..be780115d 100644
--- a/libs/shared/lib/components/dropdowns/index.tsx
+++ b/libs/shared/lib/components/dropdowns/index.tsx
@@ -109,7 +109,7 @@ export function DropdownItem({ value, disabled, className, onClick, submenu, sel
       onMouseEnter={() => setIsSubmenuOpen(true)}
       onMouseLeave={() => setIsSubmenuOpen(false)}
     >
-      {value}
+      { value }
       {submenu && isSubmenuOpen && <DropdownSubmenuContainer ref={submenuRef}>{submenu}</DropdownSubmenuContainer>}
     </li>
   );
diff --git a/libs/shared/lib/components/inputs/index.tsx b/libs/shared/lib/components/inputs/index.tsx
index c3a3e9838..df18203eb 100644
--- a/libs/shared/lib/components/inputs/index.tsx
+++ b/libs/shared/lib/components/inputs/index.tsx
@@ -96,7 +96,7 @@ type DropdownProps = {
   disabled?: boolean;
   className?: string;
   value?: number | string;
-  options: number[] | string[];
+  options: number[] | string[] | {[key: string]: string}[];
   onChange?: (value: number | string) => void;
 };
 
@@ -371,6 +371,23 @@ export const BooleanInput = ({ label, value, onChange, tooltip }: BooleanProps)
   );
 };
 
+function parsedValue(item: number | string | {[key: string]: string}, key: boolean = false) {
+  switch (typeof item) {
+    case 'number': return item.toString();
+    case 'object': return key ? Object.keys(item)[0] : Object.values(item)[0];
+  }
+
+  return item;
+}
+
+function currentValue(value: string | number | undefined, options?: {[key: string]: string}[]) {
+  if (options != null && typeof options[0] == 'object') {
+    return parsedValue(options.find(x => x[value as string]) as {[key: string]: string});
+  }
+
+  return value;
+}
+
 export const DropdownInput = ({
   label,
   value,
@@ -411,7 +428,7 @@ export const DropdownInput = ({
         >
           <DropdownTrigger
             variant={buttonVariant}
-            title={overrideRender || value}
+            title={overrideRender || currentValue(value, options as {[key: string]: string}[]) }
             size={size}
             className="cursor-pointer"
             disabled={disabled}
@@ -427,12 +444,11 @@ export const DropdownInput = ({
                 options.map((item: any, index: number) => (
                   <DropdownItem
                     key={index}
-                    value={item.toString()}
+                    value={parsedValue(item)}
                     selected={value === item}
                     onClick={() => {
-                      const parsedValue = typeof item === 'number' ? item.toString() : item;
                       if (onChange) {
-                        onChange(parsedValue);
+                        onChange(parsedValue(item, true));
                       }
                       setIsDropdownOpen(false);
                     }}
diff --git a/libs/shared/lib/graph-layout/graphology-layouts.ts b/libs/shared/lib/graph-layout/graphology-layouts.ts
index dd82b7093..67419cbb7 100644
--- a/libs/shared/lib/graph-layout/graphology-layouts.ts
+++ b/libs/shared/lib/graph-layout/graphology-layouts.ts
@@ -198,18 +198,27 @@ export class GraphologyForceAtlas2Webworker extends GraphologyLayout {
 
     const sensibleSettings = forceAtlas2.inferSettings(graph);
 
-    const layout = new FA2Layout(graph, {
-      settings: {
-        ...this.defaultLayoutSettings,
-        ...sensibleSettings,
-        adjustSizes: graph.order < 300 ? true : false,
-      },
-    });
+    let settings = {
+      ...this.defaultLayoutSettings,
+      ...sensibleSettings,
+      adjustSizes: graph.order < 300 ? true : false
+    };
+
+    if (graph.order > 5000) {
+      settings = {
+        ...settings,
+        barnesHutOptimize: true,
+        barnesHutTheta: 0.75,
+        slowDown: 0.75
+      };
+    }
+
+    const layout = new FA2Layout(graph, { settings });
     layout.start();
 
-    // stop the layout after 5 seconds
+    // stop the layout after 10 seconds
     setTimeout(() => {
       layout.stop();
-    }, 20000);
+    }, 10000);
   }
 }
diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
index 0b14261d0..2f41de1a8 100644
--- a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
+++ b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx
@@ -1,7 +1,7 @@
 import { GraphType, LinkType, NodeType } from '../types';
 import { dataColors, visualizationColors } from 'config';
 import { ReactEventHandler, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
-import { Application, Circle, Color, Container, FederatedPointerEvent, Graphics, IPointData } from 'pixi.js';
+import { Application, AssetsBundle, Color, Container, FederatedPointerEvent, Graphics, IPointData, Sprite, Assets, Texture, Resource } from 'pixi.js';
 import { useAppDispatch, useML, useSearchResultData } from '../../../../data-access';
 import { NLPopup } from './NLPopup';
 import { hslStringToHex, nodeColor } from './utils';
@@ -30,6 +30,16 @@ type LayoutState = 'reset' | 'running' | 'paused';
 // MAIN COMPONENT
 //////////////////
 
+if (!Assets.cache.has('texture')) {
+  Assets.addBundle('glyphs', {
+    texture: 'assets/sprite.png',
+    texture_square: 'assets/sprite_square.png',
+    texture_selected: 'assets/sprite_selected.png',
+    texture_selected_square: 'assets/sprite_selected_square.png',
+  });
+  await Assets.loadBundle('glyphs');
+}
+
 export const NLPixi = (props: Props) => {
   const [quickPopup, setQuickPopup] = useState<{ node: NodeType; pos: IPointData } | undefined>();
   const [popups, setPopups] = useState<{ node: NodeType; pos: IPointData }[]>([]);
@@ -46,10 +56,9 @@ export const NLPixi = (props: Props) => {
     [],
   );
   const nodeLayer = useMemo(() => new Container(), []);
-  const linkLayer = useMemo(() => new Container(), []);
 
-  const nodeMap = useRef(new Map<string, Graphics>());
-  const linkMap = useRef(new Map<string, Graphics>());
+  const nodeMap = useRef(new Map<string, Sprite>());
+  const linkGfx = new Graphics();
   const viewport = useRef<Viewport>();
   const layoutState = useRef<LayoutState>('reset');
   const layoutStoppedCount = useRef(0);
@@ -57,7 +66,7 @@ export const NLPixi = (props: Props) => {
   const mouseInCanvas = useRef<boolean>(false);
   const isSetup = useRef(false);
   const ml = useML();
-  const dragging = useRef<{ node: NodeType; gfx: Graphics } | null>(null);
+  const dragging = useRef<{ node: NodeType; gfx: Sprite } | null>(null);
   const onlyClicked = useRef(false);
   const searchResults = useSearchResultData();
 
@@ -66,6 +75,12 @@ export const NLPixi = (props: Props) => {
   // const cull = new Cull();
   // let cullDirty = useRef(true);
 
+  const textureId = (selected: boolean = false) => {
+    const selectionSuffix = selected ? '_selected' : '';
+    const shapeSuffix = (props.configuration.shapes.shape == 'rectangle') ? '_square' : '';
+    return `texture${selectionSuffix}${shapeSuffix}`;
+  };
+
   const [config, setConfig] = useState({
     width: 1000,
     height: 1000,
@@ -73,10 +88,10 @@ export const NLPixi = (props: Props) => {
     LAYOUT_ALGORITHM: Layouts.FORCEATLAS2WEBWORKER,
 
     NODE_RADIUS: 5,
-    NODE_BORDER_LINE_WIDTH: 1.0,
-    NODE_BORDER_LINE_WIDTH_SELECTED: 5.0, // if selected and normal width are different the thicker line will be still in the gfx
-    NODE_BORDER_COLOR_DEFAULT: dataColors.neutral[70],
-    NODE_BORDER_COLOR_SELECTED: dataColors.orange[60],
+    // NODE_BORDER_LINE_WIDTH: 1.0,
+    // NODE_BORDER_LINE_WIDTH_SELECTED: 5.0, // if selected and normal width are different the thicker line will be still in the gfx
+    // NODE_BORDER_COLOR_DEFAULT: dataColors.neutral[70],
+    // NODE_BORDER_COLOR_SELECTED: dataColors.orange[60],
 
     LINE_COLOR_DEFAULT: dataColors.neutral[40],
     LINE_COLOR_SELECTED: visualizationColors.GPSelected.colors[1],
@@ -93,10 +108,19 @@ export const NLPixi = (props: Props) => {
     });
   }, [props.layoutAlgorithm, props.configuration]);
 
+  useEffect(() => {
+    if (nodeMap.current.size === 0) return;
+
+    const texture = Assets.get(textureId());
+    for (const sprite of nodeMap.current.values()) {
+      sprite.texture = texture;  
+    }
+  }, [props.configuration.shapes?.shape]);
+
   const imperative = useRef<any>(null);
 
   useImperativeHandle(imperative, () => ({
-    onDragStart(node: NodeType, gfx: Graphics) {
+    onDragStart(node: NodeType, gfx: Sprite) {
       dragging.current = { node, gfx };
       onlyClicked.current = true;
 
@@ -208,7 +232,7 @@ export const NLPixi = (props: Props) => {
     } else return { x: 0, y: 0 };
   }
 
-  function onDragStart(event: FederatedPointerEvent, node: NodeType, gfx: Graphics) {
+  function onDragStart(event: FederatedPointerEvent, node: NodeType, gfx: Sprite) {
     event.stopPropagation();
     if (imperative.current) imperative.current.onDragStart(node, gfx);
   }
@@ -227,22 +251,16 @@ export const NLPixi = (props: Props) => {
     const gfx = nodeMap.current.get(node._id);
     if (!gfx) return;
 
-    const lineColor = node.isShortestPathSource
-      ? dataColors.orange['95']
-      : node.isShortestPathTarget
-        ? dataColors.blue['95']
-        : node.selected
-          ? config.NODE_BORDER_COLOR_SELECTED
-          : config.NODE_BORDER_COLOR_DEFAULT;
-    const lineWidth = node.selected ? config.NODE_BORDER_LINE_WIDTH_SELECTED : config.NODE_BORDER_LINE_WIDTH;
-    gfx.lineStyle(lineWidth, new Color(hslStringToHex(lineColor)));
+    // Update texture when selected
+    const texture = Assets.get(textureId(node.selected));
+    gfx.texture = texture;
 
+    // Cluster colors
     if (node?.cluster) {
-      gfx.beginFill(node.cluster >= 0 ? nodeColor(node.cluster) : 0x000000);
-    } else gfx.beginFill(nodeColor(node.type));
-
-    gfx.drawCircle(0, 0, Math.max(node.radius || 5, 5));
-    gfx.endFill();
+      gfx.tint = node.cluster >= 0 ? nodeColor(node.cluster) : 0x000000;
+    } else {
+      gfx.tint = nodeColor(node.type);
+    }
 
     gfx.position.set(node.x, node.y);
 
@@ -278,12 +296,21 @@ export const NLPixi = (props: Props) => {
 
   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)) {
+    if (node && node?._id && nodeMap.current.has(node._id)) {
       nodeMap.current.delete(node._id);
     }
     // Do not draw node if it has no position
     if (node.x === undefined || node.y === undefined) return;
-    const gfx = new Graphics();
+
+    let gfx: Sprite;
+    const texture = Assets.get(textureId());
+    gfx = new Sprite(texture);
+    
+    gfx.tint = nodeColor(node.type);
+    const scale = (Math.max(node.radius || 5, 5) / 70) * 2;
+    gfx.scale.set(scale, scale);
+    gfx.anchor.set(0.5, 0.5);
+
     nodeMap.current.set(node._id, gfx);
     nodeLayer.addChild(gfx);
     node.selected = selected;
@@ -303,8 +330,8 @@ export const NLPixi = (props: Props) => {
   //   });
   // };
 
-  const updateLink = (link: LinkType, gfx: Graphics) => {
-    if (!props.graph) return;
+  const updateLink = (link: LinkType) => {
+    if (!props.graph || nodeMap.current.size === 0) return;
 
     const _source = link.source;
     const _target = link.target;
@@ -335,7 +362,7 @@ export const NLPixi = (props: Props) => {
       return;
     }
 
-    if (gfx) {
+    if (linkGfx) {
       // let color = link.color || 0x000000;
       let color = config.LINE_COLOR_DEFAULT;
       let style = config.LINE_WIDTH_DEFAULT;
@@ -366,36 +393,27 @@ export const NLPixi = (props: Props) => {
         style = 3.0;
       }
 
-      gfx.clear();
-      gfx.beginFill();
-      gfx
+      // Conditional alpha for search results
+      if (searchResults.nodes.length > 0 || searchResults.edges.length > 0) {
+        // FIXME: searchResults.edges should be a hashmap to improve performance.
+        const isLinkInSearchResults = searchResults.edges.some((resultEdge) => resultEdge.id === link.id);
+        alpha = isLinkInSearchResults ? 1 : 0.05;
+      }
+
+      linkGfx
         .lineStyle(style, hslStringToHex(color), alpha)
         .moveTo(source.x || 0, source.y || 0)
         .lineTo(target.x || 0, target.y || 0);
-      gfx.endFill();
     } else {
       throw Error('Link not found');
     }
   };
 
-  const createLink = (link: LinkType) => {
-    if (linkMap.current.has(link.id)) {
-      linkMap.current.delete(link.id);
-    }
-    const gfx = new Graphics();
-    gfx.name = 'link_' + link.id;
-    linkMap.current.set(link.id, gfx);
-    updateLink(link, gfx);
-    linkLayer.addChild(gfx);
-    return gfx;
-  };
-
   useEffect(() => {
     return () => {
       nodeMap.current.clear();
-      linkMap.current.clear();
+      linkGfx.clear();
       nodeLayer.removeChildren();
-      linkLayer.removeChildren();
     };
   }, []);
 
@@ -412,15 +430,9 @@ export const NLPixi = (props: Props) => {
         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;
       });
-
-      props.graph.links.forEach((link: LinkType) => {
-        const gfx = linkMap.current.get(link.id);
-        if (!gfx) return;
-        const isLinkInSearchResults = searchResults.edges.some((resultEdge) => resultEdge.id === link.id);
-        gfx.alpha = isLinkInSearchResults || searchResults.edges.length === 0 ? 1 : 0.05;
-      });
     }
   }, [searchResults]);
 
@@ -432,6 +444,9 @@ export const NLPixi = (props: Props) => {
       if (!layoutAlgorithm.current) return;
 
       let stopped = 0;
+
+      const widthHalf = app.renderer.width / 2;
+      const heightHalf = app.renderer.height / 2;
       props.graph.nodes.forEach((node: NodeType, i) => {
         if (!layoutAlgorithm.current) return;
         const gfx = nodeMap.current.get(node._id);
@@ -441,7 +456,7 @@ export const NLPixi = (props: Props) => {
 
         if (
           !position ||
-          Math.abs(node.x - position.x - app.renderer.width / 2) + Math.abs(node.y - position.y - app.renderer.height / 2) < 1
+          Math.abs(node.x - position.x - widthHalf) + Math.abs(node.y - position.y - heightHalf) < 1
         ) {
           stopped += 1;
           return;
@@ -449,8 +464,8 @@ export const NLPixi = (props: Props) => {
         try {
           if (layoutAlgorithm.current.provider === 'Graphology') {
             // this is a dirty hack to fix the graphology layout being out of bounds
-            node.x = position.x + app.renderer.width / 2;
-            node.y = position.y + app.renderer.height / 2;
+            node.x = position.x + widthHalf;
+            node.y = position.y + heightHalf;
           } else {
             node.x = position.x;
             node.y = position.y;
@@ -460,14 +475,7 @@ export const NLPixi = (props: Props) => {
           layoutState.current = 'paused';
         }
 
-        if (layoutState.current === 'running') {
-          gfx.position.copyFrom({
-            x: node.fx || gfx.position.x + ((node.x || 0) - gfx.position.x) * 0.1 * delta,
-            y: node.fy || gfx.position.y + ((node.y || 0) - gfx.position.y) * 0.1 * delta,
-          });
-        } else {
-          gfx.position.copyFrom(node as IPointData);
-        }
+        gfx.position.copyFrom(node as IPointData);
       });
 
       if (stopped === props.graph.nodes.length) {
@@ -483,12 +491,13 @@ export const NLPixi = (props: Props) => {
         layoutState.current = 'running';
       }
 
-      // Update forces of the links
+      // Draw the links
+      linkGfx.clear();
+      linkGfx.beginFill();
       props.graph.links.forEach((link: any) => {
-        if (linkMap.current && !!linkMap.current.has(link.id)) {
-          updateLink(link, linkMap.current.get(link.id) as Graphics);
-        }
+        updateLink(link);
       });
+      linkGfx.endFill();
     }
   };
 
@@ -499,9 +508,8 @@ export const NLPixi = (props: Props) => {
     if (props.graph) {
       if (forceClear) {
         nodeMap.current.clear();
-        linkMap.current.clear();
+        linkGfx.clear();
         nodeLayer.removeChildren();
-        linkLayer.removeChildren();
       }
 
       nodeMap.current.forEach((gfx, id) => {
@@ -512,30 +520,23 @@ export const NLPixi = (props: Props) => {
         }
       });
 
-      linkMap.current.forEach((gfx, id) => {
-        if (!props.graph?.links?.find((link) => link.id === id)) {
-          linkLayer.removeChild(gfx);
-          gfx.destroy();
-          linkMap.current.delete(id);
-        }
-      });
+      linkGfx.clear();
 
       props.graph.nodes.forEach((node: NodeType) => {
         if (!forceClear && nodeMap.current.has(node._id)) {
           const old = nodeMap.current.get(node._id);
-          node.x = old?.x || node.x;
-          node.y = old?.y || node.y;
-          updateNode(node);
+          
+          try {
+            node.x = old?.x || node.x;
+            node.y = old?.y || node.y;
+            updateNode(node);
+          } catch (e) {
+            // node.x and .y become read-only when some layout algorithms are finished
+          }
         } else {
           createNode(node);
         }
       });
-      props.graph.links.forEach((link: LinkType) => {
-        if (!forceClear && linkMap.current.has(link.id)) {
-          const gfx = linkMap.current.get(link.id);
-          if (gfx) updateLink(link, gfx);
-        } else createLink(link);
-      });
 
       // // update text colour (written after nodes so that text appears on top of nodes)
       //   nodes.forEach((node: NodeType) => {
@@ -563,9 +564,8 @@ export const NLPixi = (props: Props) => {
    * 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 = () => {
+  const setup = async () => {
     nodeLayer.removeChildren();
-    linkLayer.removeChildren();
     app.stage.removeChildren();
 
     if (!props.graph) throw Error('Graph is undefined');
@@ -583,7 +583,7 @@ export const NLPixi = (props: Props) => {
     // activate plugins
     viewport.current.drag().pinch().wheel({ smooth: 2 }).animate({}).decelerate({ friction: 0.75 });
 
-    viewport.current.addChild(linkLayer);
+    viewport.current.addChild(linkGfx);
     viewport.current.addChild(nodeLayer);
     viewport.current.on('drag-start', (event) => {
       imperative.current.onPan();
@@ -595,7 +595,7 @@ export const NLPixi = (props: Props) => {
     app.stage.on('mouseup', onDragEnd);
 
     nodeMap.current.clear();
-    linkMap.current.clear();
+    linkGfx.clear();
     update(true);
     isSetup.current = true;
   };
diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx
index f5c06cf4f..70c7867ba 100644
--- a/libs/shared/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx
+++ b/libs/shared/lib/vis/visualizations/nodelinkvis/nodelinkvis.tsx
@@ -177,7 +177,7 @@ const NodelinkSettings = ({ settings, graphMetadata, updateSettings }: Visualiza
               type="dropdown"
               label="Shape"
               value={settings.shapes.shape}
-              options={['Circle', 'Square']}
+              options={[{circle: 'Circle'}, {rectangle: 'Square'}]}
               onChange={(val) => updateSettings({ shapes: { ...settings.shapes, shape: val as any } })}
             />
           ) : (
-- 
GitLab