From c6b3bc1359bbf25eb270004a75a537d3efa5d5ea Mon Sep 17 00:00:00 2001
From: Dennis Collaris <d.collaris@me.com>
Date: Wed, 12 Jun 2024 16:04:06 +0200
Subject: [PATCH] feat(vis): remake of matrixVis

---
 .../pills/customFlowLines/connection.scss     |   2 +-
 .../matrixvis/components/MatrixPixi.tsx       | 467 ++++++++++--------
 .../components/matrixPixi.module.scss         |  37 ++
 3 files changed, 308 insertions(+), 198 deletions(-)
 create mode 100644 libs/shared/lib/vis/visualizations/matrixvis/components/matrixPixi.module.scss

diff --git a/libs/shared/lib/querybuilder/pills/customFlowLines/connection.scss b/libs/shared/lib/querybuilder/pills/customFlowLines/connection.scss
index 1b9e88a5e..d537570f7 100644
--- a/libs/shared/lib/querybuilder/pills/customFlowLines/connection.scss
+++ b/libs/shared/lib/querybuilder/pills/customFlowLines/connection.scss
@@ -1,3 +1,3 @@
-g {
+g.react-flow__edge g {
   @apply stroke-secondary-300;
 }
diff --git a/libs/shared/lib/vis/visualizations/matrixvis/components/MatrixPixi.tsx b/libs/shared/lib/vis/visualizations/matrixvis/components/MatrixPixi.tsx
index a53fd8beb..62383f984 100644
--- a/libs/shared/lib/vis/visualizations/matrixvis/components/MatrixPixi.tsx
+++ b/libs/shared/lib/vis/visualizations/matrixvis/components/MatrixPixi.tsx
@@ -1,20 +1,8 @@
 import { Edge, GraphQueryResult, Node, useML, useSearchResultData } from '@graphpolaris/shared/lib/data-access';
 import { dataColors, visualizationColors } from 'config';
 import { Viewport } from 'pixi-viewport';
-import {
-  Application,
-  ColorSource,
-  Container,
-  DisplayObject,
-  FederatedPointerEvent,
-  Graphics,
-  IPointData,
-  Point,
-  Sprite,
-  Text,
-  Texture,
-} from 'pixi.js';
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { Application, ColorSource, Container, FederatedPointerEvent, Graphics, IPointData, Point, Text } from 'pixi.js';
+import { useEffect, useRef, useState } from 'react';
 import { LinkType, NodeType } from '../types';
 import { NLPopup } from './MatrixPopup';
 
@@ -23,10 +11,24 @@ import { Actions, Interpolations } from 'pixi-actions';
 import Color from 'color';
 import { createColumn } from './ColumnGraphicsComponent';
 import { ReorderingManager } from './ReorderingManager';
-import { VisualizationSettingsType } from '../../../common';
-import { range, scaleLinear, scaleOrdinal, schemeCategory10 } from 'd3';
+import {
+  select,
+  selectAll,
+  range,
+  scaleLinear,
+  scaleBand,
+  type ScaleBand,
+  axisTop,
+  axisLeft,
+  easeCubicOut,
+  type Selection,
+  type BaseType,
+  type Axis,
+} from 'd3';
 import { MatrixVisProps } from '../matrixvis';
 
+import styles from './matrixPixi.module.scss';
+
 type Props = {
   // onClick: (node: NodeType, pos: IPointData) => void;
   // onHover: (data: { node: NodeType; pos: IPointData }) => void;
@@ -39,19 +41,15 @@ type Props = {
 };
 
 const columnsContainer = new Container();
-const columnAxisContainer = new Container();
-const rowsContainer = new Container();
 
 //////////////////
 // MAIN COMPONENT
 //////////////////
 
 export const MatrixPixi = (props: Props) => {
-  const app = useMemo(() => new Application({ background: 0xffffff, antialias: true, autoDensity: true, eventMode: 'auto' }), []);
-
   let config = {
-    textOffsetX: 100,
-    textOffsetY: 100,
+    textOffsetX: 50,
+    textOffsetY: 50,
     visMapping: [] as any[], // TODO type
 
     cellHeight: 100,
@@ -65,17 +63,21 @@ export const MatrixPixi = (props: Props) => {
   const [popups, setPopups] = useState<{ node: NodeType; pos: IPointData }[]>([]);
   // const [columnOrder, setColumnOrder] = useState<string[]>([]);
 
-  const nodeMap = useRef(new Map<string, Graphics>());
-  const linkMap = useRef(new Map<string, Graphics>());
   const viewport = useRef<Viewport>();
   const ref = useRef<HTMLDivElement>(null);
+  const canvas = useRef<HTMLCanvasElement>(null);
+  const svg = useRef<SVGSVGElement>(null);
   const isSetup = useRef(false);
   const ml = useML();
   const searchResults = useSearchResultData();
 
   // const imperative = useRef<any>(null);
 
+  let app: Application;
+
   function resize() {
+    if (app == null) return;
+
     const width = ref?.current?.clientWidth || 1000;
     const height = ref?.current?.clientHeight || 1000;
     app.renderer.resize(width, height);
@@ -102,13 +104,6 @@ export const MatrixPixi = (props: Props) => {
     return () => resizeObserver.disconnect(); // clean up
   }, []);
 
-  useEffect(() => {
-    if (ref.current && ref.current.children.length === 0) {
-      ref.current.appendChild(app.view as HTMLCanvasElement);
-      resize();
-    }
-  }, [ref]);
-
   useEffect(() => {
     // console.log('graph changed', props.graph, ref.current, ref.current.children.length > 0, imperative.current);
     if (props.graph && ref.current && ref.current.children.length > 0) {
@@ -144,7 +139,7 @@ export const MatrixPixi = (props: Props) => {
   // }, [searchResults]);
 
   function onButtonDown(event: FederatedPointerEvent) {
-    console.log(
+    console.debug(
       event.currentTarget,
       // graph.nodes.find((node) => node.id === event.currentTarget.name)
     );
@@ -156,26 +151,22 @@ export const MatrixPixi = (props: Props) => {
 
     if (props.graph) {
       if (forceClear) {
-        nodeMap.current.clear();
-        linkMap.current.clear();
-        rowsContainer.removeChildren();
         columnsContainer.removeChildren();
       }
     }
   };
 
-  const onHoverColumn = (event: any, currentNode: Node | null) => {
-    app.renderer.plugins.interaction.cursorStyles.pointer = 'crosshair';
+  const onHoverColumn = (event: any, currentNode: Node | null | undefined) => {
+    if (Actions.actions.length > 0) {
+      // Don't hover if reorder animation are running, causes glitches.
+      return;
+    }
+
     if (!currentNode) {
-      rowsContainer.children.forEach((row) => {
-        row.alpha = 1;
-      });
       columnsContainer.children.forEach((col) => {
         col.alpha = 1;
       });
-      columnAxisContainer.children.forEach((colText) => {
-        colText.alpha = 1;
-      });
+      selectAll('.axis .tick').style('opacity', 1);
 
       return;
     }
@@ -185,53 +176,32 @@ export const MatrixPixi = (props: Props) => {
     });
     event.currentTarget.alpha = 1;
 
-    columnAxisContainer.children.forEach((colText) => {
-      colText.alpha = 0.25;
-      if (colText.name?.endsWith(currentNode._id)) colText.alpha = 1;
-    });
-
     if (!props.graph) return;
-    rowsContainer.children.forEach((col) => {
-      col.alpha = 0.25;
-    });
-
-    if (!props.graph) return;
-    rowsContainer.children.forEach((col) => {
-      col.alpha = 0.25;
-    });
 
     const edgesForThisColumn = props.graph.edges.filter((edge) => {
       return edge.from === currentNode._id || edge.to === currentNode._id;
     });
 
-    edgesForThisColumn.forEach((edge) => {
-      let row = rowsContainer.getChildByName(edge.to);
-      if (row) row.alpha = 1;
+    selectAll('.axis.left .tick').style('opacity', (d) => {
+      const highlight = edgesForThisColumn.some((edge) => edge.from == d || edge.to == d);
+      return highlight ? 1 : 0.25;
+    });
 
-      row = rowsContainer.getChildByName(edge.from);
-      if (row) row.alpha = 1;
+    selectAll('.axis.top .tick').style('opacity', (d) => {
+      const highlight = edgesForThisColumn.some((edge) => edge.from == d || edge.to == d);
+      return highlight ? 1 : 0.25;
     });
   };
 
   const reorderColumns = (columnOrder: string[]) => {
-    const columns = columnsContainer.children;
-    const columnsText = columnAxisContainer.children;
-    let columnPositions: Point[] = [];
-    for (let i = 0; i < columns.length; i++) {
-      const col = columns[i];
-      columnPositions.push(new Point(col.position.x, col.position.y));
-    }
-
-    let columnTextPositions: Point[] = [];
-    for (let i = 0; i < columnsText.length; i++) {
-      const col = columnsText[i];
-      columnTextPositions.push(new Point(col.position.x, col.position.y));
+    if (Actions.actions.length > 0) {
+      // Don't reorder if previous animation is still running, causes glitches.
+      return;
     }
 
-    if (columnTextPositions.length !== columnPositions.length)
-      throw new Error(
-        'columnTextPositions and columnPositions have different length ' + columnTextPositions.length + ' / ' + columnPositions.length,
-      );
+    // Animate columns
+    const columns = columnsContainer.children;
+    let columnPositions = columnOrder.map((_, i) => new Point(config.textOffsetX + i * config.cellWidth, columns[i].position.y));
 
     for (let i = 0; i < columns.length; i++) {
       const column = columns[i];
@@ -240,53 +210,67 @@ export const MatrixPixi = (props: Props) => {
       if (index === -1) throw new Error('column not found in columnOrder');
 
       const newPosition = columnPositions[index];
-      // move column to new position
       Actions.moveTo(columns[i], newPosition.x, newPosition.y, 1, Interpolations.pow2out).play();
-
-      const newPositionText = columnTextPositions[index];
-      // move column to new position
-      Actions.moveTo(columnsText[i], newPositionText.x, newPositionText.y, 1, Interpolations.pow2out).play();
     }
-  };
 
-  const reorderRows = (
-    nodesCol: Node[],
-    nodesRow: Node[],
-    edges: Edge[],
-    rowOrder: string[],
-    colorScale: any,
-    cellWidth: number,
-    cellHeight: number,
-  ) => {
-    const rows = rowsContainer.children;
-    let rowPositions: Point[] = [];
-    for (let i = 0; i < rows.length; i++) {
-      const row = rows[i];
-      rowPositions.push(new Point(row.position.x, row.position.y));
-    }
-
-    for (let i = 0; i < rows.length; i++) {
-      const row = rows[i] as DisplayObject;
-      if (!row.name) throw new Error('row has no name');
-      const index = rowOrder.indexOf(row.name);
-      if (index === -1) throw new Error('row not found in rowOrder');
+    // Animate column axis
+    scaleColumns.domain(columnOrder);
+    axisColumns.scale(scaleColumns);
+
+    animatingAxes = true;
+    const selectionTop = select(svg.current).select('.axis.top') as Selection<SVGGElement, unknown, BaseType, unknown>;
+    selectionTop
+      .transition()
+      .duration(1000)
+      .ease(easeCubicOut)
+      .on('end', () => (animatingAxes = false))
+      .call(axisColumns);
+  };
 
-      const newPosition = rowPositions[index];
-      // move column to new position
-      Actions.moveTo(row, newPosition.x, newPosition.y, 1, Interpolations.pow2out).play();
+  const reorderRows = (edges: Edge[], rowOrder: string[], columnOrder: string[]) => {
+    if (Actions.actions.length > 0) {
+      // Don't reorder if previous animation is still running, causes glitches.
+      return;
     }
 
-    const colOrder = columnsContainer.children.map((col) => col.name) as string[];
-    if (!colOrder) throw new Error('colOrder is undefined');
+    // Rearrange rows
     columnsContainer.removeChildren();
-    setupColumns(edges, colOrder, rowOrder);
+    setupColumns(edges, columnOrder, rowOrder);
+    const cols = columnOrder.flatMap((x) => props.graph?.nodes.filter((n) => n._id == x) ?? []);
+    setupColumnInteractivity(cols);
+
+    // Animate row axis
+    scaleColumns.domain(columnOrder);
+    scaleRows.domain(rowOrder);
+
+    animatingAxes = true;
+    const selectionLeft = select(svg.current).select('.axis.left') as Selection<SVGGElement, unknown, BaseType, unknown>;
+    selectionLeft
+      .transition()
+      .duration(1000)
+      .ease(easeCubicOut)
+      .on('end', () => (animatingAxes = false))
+      .call(axisRows);
   };
 
   const setup = () => {
     if (!props.graph) throw Error('Graph is undefined; setup not possible');
+
+    if (app == null) {
+      app = new Application({
+        background: 0xffffff,
+        antialias: true,
+        autoDensity: true,
+        eventMode: 'auto',
+        resolution: window.devicePixelRatio || 1,
+        view: canvas.current as HTMLCanvasElement,
+      });
+    }
+
+    if (svg.current != null) {
+      select(svg.current).select('*').remove();
+    }
     columnsContainer.removeChildren();
-    rowsContainer.removeChildren();
-    columnAxisContainer.removeChildren();
     app.stage.removeChildren();
 
     const size = ref.current?.getBoundingClientRect();
@@ -302,7 +286,6 @@ export const MatrixPixi = (props: Props) => {
     app.stage.addChild(viewport.current);
 
     viewport.current.addChild(columnsContainer);
-    viewport.current.addChild(rowsContainer);
 
     const groupByType = props.graph.nodes.reduce(
       (group: any, node: Node) => {
@@ -348,12 +331,20 @@ export const MatrixPixi = (props: Props) => {
 
     setupVisualizationEncodingMapping(props.settings);
 
+    if (svg.current != null && canvas.current != null) {
+      select(svg.current)
+        .attr('width', canvas.current.clientWidth)
+        .attr('height', canvas.current.clientHeight)
+        .style('position', 'absolute')
+        .style('top', 0)
+        .style('left', 0)
+        .style('pointer-events', 'none');
+    }
+
     setupColumns(props.graph.edges, columnOrder, rowOrder);
-    setupColumnLegend(columnOrder);
+    setupColumnAxis(columnOrder, cols[0].label);
     setupColumnInteractivity(cols);
-
-    setupRowLegend(rows, rowOrder);
-    setupRowInteractivity(cols, rows, rowOrder, scaleOrdinal(schemeCategory10));
+    setupRowAxis(rowOrder, rows[0].label);
 
     console.debug('setup matrixvis with graph:', props.graph);
 
@@ -365,6 +356,9 @@ export const MatrixPixi = (props: Props) => {
     isSetup.current = true;
   };
 
+  let scaleColumns: ScaleBand<string>;
+  let scaleRows: ScaleBand<string>;
+
   const setupVisualizationEncodingMapping = (settings: MatrixVisProps) => {
     if (!props.graph) throw new Error('Graph is undefined; cannot setup matrix');
     const visMapping = []; // TODO type
@@ -380,17 +374,25 @@ export const MatrixPixi = (props: Props) => {
       encoding: settings.marks,
       colorScale: adjacenyScale,
       renderFunction: function (i: number, color: ColorSource, gfxContext: Graphics) {
-        gfxContext.beginFill(color, 1);
+        // Clear locally
+        gfxContext.beginFill(0xffffff, 1);
+        gfxContext.drawRect(0, i * config.cellHeight, config.cellWidth, config.cellHeight);
+        gfxContext.endFill();
 
+        gfxContext.beginFill(color, 1);
         // TODO get
         // gfx.beginFill(colorScale(node.type)));
 
-        gfxContext.lineStyle(2, 0xffffff);
+        const inset = 0.5;
         if (this.encoding === 'rect') {
-          gfxContext.drawRect(0, i * config.cellHeight, config.cellWidth, config.cellHeight);
+          gfxContext.drawRect(inset, i * config.cellHeight + inset, config.cellWidth - 2 * inset, config.cellHeight - 2 * inset);
         }
         if (this.encoding === 'circle') {
-          gfxContext.drawCircle(config.cellWidth / 2, i * config.cellHeight + config.cellHeight / 2, config.cellWidth / 2);
+          gfxContext.drawCircle(
+            config.cellWidth / 2 + inset,
+            i * config.cellHeight + config.cellHeight / 2 + inset,
+            config.cellWidth / 2 - inset,
+          );
         }
         gfxContext.endFill();
 
@@ -454,44 +456,32 @@ export const MatrixPixi = (props: Props) => {
       oneColumn.on('pointerdown', onButtonDown);
       oneColumn.on('pointerover', (event) => {
         const col = cols.find((col) => col._id === oneColumn.name);
-        if (col) onHoverColumn(event, col);
+        onHoverColumn(event, col);
       });
       oneColumn.on('pointerout', (event) => {
-        onHoverColumn(event, null as any);
+        onHoverColumn(event, null);
       });
     }
   };
 
-  const setupColumnLegend = (columnOrder: string[]) => {
-    // columnAxisContainer.removeChildren();
-    columnAxisContainer.position.set(config.textOffsetX, 0);
-    for (let j = 0; j < columnOrder.length; j++) {
-      // from the PixiJS documention: Setting a text object's scale to > 1.0 will result in blurry/pixely display,
-      // because the text is not re-rendered at the higher resolution needed to look sharp -
-      // it's still the same resolution it was when generated. To deal with this, you can render at a higher
-      // initial size and down-scale, instead.
-      const finalTextSize = 10;
-      const bigRandomScaleFactor = 10;
-      const basicText = new Text(columnOrder[j], { fontSize: finalTextSize * bigRandomScaleFactor, fill: 0x000000, align: 'center' });
-      basicText.position.set(j * config.cellWidth + config.cellWidth / 4, config.textOffsetY - 5);
-      basicText.scale.set(finalTextSize / (bigRandomScaleFactor * bigRandomScaleFactor));
-      basicText.rotation = (Math.PI * 3) / 2;
-      basicText.eventMode = 'none';
-      basicText.name = 'Text_' + columnOrder[j];
-      columnAxisContainer.addChild(basicText);
-    }
-    viewport.current?.addChild(columnAxisContainer);
-
-    const columnAxis = new Sprite(Texture.WHITE);
-    columnAxis.width = columnOrder.length * config.cellWidth;
-    columnAxis.height = config.textOffsetY;
-    columnAxis.tint = 0xaaaaaa;
-    columnAxis.position.set(config.textOffsetX, 0);
-    columnAxis.alpha = 0.0;
-    columnAxis.interactive = true;
-    columnAxis.on('pointerdown', (event) => {
-      // const reordering = new ReorderColumnsAction(columnOrder);
-
+  const setupColumnAxis = (columnOrder: string[], label: string) => {
+    if (scaleColumns == null) scaleColumns = scaleBand();
+    scaleColumns.domain(columnOrder);
+
+    if (svg.current == null) return;
+    const selection = select(svg.current);
+    const g = selection.append('g').attr('class', 'axis top').attr('transform', `translate(0, ${config.textOffsetY})`);
+    g.append('text')
+      .attr('class', 'label')
+      .style('text-anchor', 'middle')
+      .style('alignment-baseline', 'hanging')
+      .style('fill', 'black')
+      .style('font-size', '14')
+      .text(label);
+
+    // Click handler for reordering columns
+    const axisTopHandle = ref.current?.querySelector(`.${styles.axisTop}`) as HTMLDivElement;
+    axisTopHandle.addEventListener('click', () => {
       if (!props.graph) throw new Error('Graph is undefined; cannot reorder matrix');
 
       const reorderingManager = new ReorderingManager(props.graph);
@@ -500,14 +490,56 @@ export const MatrixPixi = (props: Props) => {
 
       reorderColumns(columnOrder);
     });
-    // columnAxis.on('pointerdown', reorderColumns);
-    columnAxis.on('pointerover', (event) => {
-      Actions.fadeTo(columnAxis, 0.5, 0.5, Interpolations.pow2out).play();
-    });
-    columnAxis.on('pointerout', (event) => {
-      Actions.fadeTo(columnAxis, 0.0, 0.5, Interpolations.pow2out).play();
-    });
-    viewport.current?.addChild(columnAxis);
+
+    // Create d3 axis object
+    axisColumns = axisTop(scaleColumns)
+      .tickSizeOuter(0)
+      .tickFormat((d: string, i: number) => {
+        const minimumWidth = 25;
+        const range = scaleColumns.range()[1] - scaleColumns.range()[0];
+        let skip = Math.round((minimumWidth * columnOrder.length) / range);
+        skip = Math.max(1, skip);
+
+        return i % skip === 0 ? d : '';
+      });
+
+    // Ensure axis is updated
+    updateColumnAxis();
+  };
+
+  let axisColumns: Axis<string>;
+  let axisRows: Axis<string>;
+
+  let animatingAxes = false;
+  const updateColumnAxis = () => {
+    if (viewport.current == null || canvas.current == null) return;
+
+    const initialWidth = columnOrder.length * config.cellWidth + 2 * config.textOffsetX;
+
+    scaleColumns.range([
+      viewport.current.position.x + viewport.current.scale.x * config.textOffsetX,
+      viewport.current.position.x + viewport.current.scale.x * (initialWidth - config.textOffsetX),
+    ]);
+
+    if (svg.current == null) return;
+
+    if (animatingAxes) return;
+
+    const selectionTop = select(svg.current).select('.axis.top') as Selection<SVGGElement, unknown, BaseType, unknown>;
+    if (!selectionTop.empty()) {
+      selectionTop.call(axisColumns);
+      selectionTop
+        .select('text.label')
+        .attr('x', function () {
+          const halfWidth = (this as SVGTextElement).getBBox().width / 2;
+          return Math.max(
+            config.textOffsetX + halfWidth,
+            Math.min((svg.current?.clientWidth ?? 0) - halfWidth, (scaleColumns.range()[1] + scaleColumns.range()[0]) / 2),
+          );
+        })
+        .attr('y', -config.textOffsetY + 7);
+      selectionTop.select('.domain').style('display', 'none');
+    }
   };
 
   function shuffle(array: any[]) {
@@ -529,48 +561,84 @@ export const MatrixPixi = (props: Props) => {
     return array;
   }
 
-  function setupRowLegend(rows: Node[], rowOrder: string[]) {
-    for (let i = 0; i < rowOrder.length; i++) {
-      const rowID = rowOrder[i];
-      const finalTextSize = 10;
-      const bigRandomScaleFactor = 10;
-      const basicText = new Text(rowID, { fontSize: finalTextSize * bigRandomScaleFactor, fill: 0x000000, align: 'right' });
-      basicText.anchor.x = 1;
-      basicText.anchor.y = 0;
-      basicText.scale.set(finalTextSize / (bigRandomScaleFactor * bigRandomScaleFactor));
-      basicText.position.set(config.textOffsetX - 5, i * config.cellHeight + config.textOffsetY + config.cellHeight / 4);
-      basicText.name = rowID;
-      rowsContainer.addChild(basicText);
+  function setupRowAxis(rowOrder: string[], label: string) {
+    if (scaleRows == null) scaleRows = scaleBand();
+    scaleRows.domain(rowOrder);
+
+    if (svg.current == null) return;
+    const selection = select(svg.current);
+    const g = selection.append('g').attr('class', 'axis left').attr('transform', `translate(${config.textOffsetX}, 0)`);
+
+    g.append('text')
+      .attr('class', 'label')
+      .style('text-anchor', 'middle')
+      .style('alignment-baseline', 'hanging')
+      .style('fill', 'black')
+      .style('font-size', '14')
+      .text(label);
+
+    // Click handler for reordering columns
+    const axisLeftHandle = ref.current?.querySelector(`.${styles.axisLeft}`) as HTMLDivElement;
+    axisLeftHandle.addEventListener('click', () => {
+      if (!props.graph) throw new Error('Graph is undefined; cannot reorder matrix');
 
-      Actions.sequence(Actions.delay(i * 0.005 * Math.random()), Actions.fadeIn(basicText, 0.5, Interpolations.pow2out)).play();
-    }
+      rowOrder = shuffle(rowOrder);
+      reorderRows(props?.graph.edges, rowOrder, columnOrder);
+    });
+
+    // Create d3 axis object
+    axisRows = axisLeft(scaleRows)
+      .tickSizeOuter(0)
+      .tickFormat((d: string, i: number) => {
+        const minimumHeight = 20;
+        const range = scaleRows.range()[1] - scaleRows.range()[0];
+        let skip = Math.round((minimumHeight * rowOrder.length) / range);
+        skip = Math.max(1, skip);
+
+        return i % skip === 0 ? d : '';
+      });
+
+    // Ensure axis is updated
+    updateRowAxis();
   }
 
-  const setupRowInteractivity = (cols: Node[], rows: Node[], rowOrder: string[], colorScale: any) => {
-    const rowAxis = new Sprite(Texture.WHITE);
-    rowAxis.width = config.textOffsetX;
-    rowAxis.height = rows.length * config.cellHeight;
-    rowAxis.tint = 0xaaaaaa;
-    rowAxis.position.set(0, config.textOffsetY);
-    rowAxis.alpha = 0.0;
-    rowAxis.interactive = true;
-    rowAxis.on('pointerdown', (event) => {
-      if (props.graph) {
-        rowOrder = shuffle(rowOrder);
-        reorderRows(cols, rows, props?.graph.edges, rowOrder, colorScale, config.cellWidth, config.cellHeight);
-      }
-    });
-    rowAxis.on('pointerover', (event) => {
-      Actions.fadeTo(rowAxis, 0.5, 0.5, Interpolations.pow2out).play();
-    });
-    rowAxis.on('pointerout', (event) => {
-      Actions.fadeTo(rowAxis, 0.0, 0.5, Interpolations.pow2out).play();
-    });
-    viewport.current?.addChild(rowAxis);
+  const updateRowAxis = () => {
+    if (viewport.current == null || canvas.current == null) return;
+
+    const initialHeight = rowOrder.length * config.cellHeight + 2 * config.textOffsetY;
+
+    scaleRows.range([
+      viewport.current.position.y + viewport.current.scale.y * config.textOffsetY,
+      viewport.current.position.y + viewport.current.scale.y * (initialHeight - config.textOffsetY),
+    ]);
+
+    if (svg.current == null) return;
+
+    if (animatingAxes) return;
+
+    const selectionLeft = select(svg.current).select('.axis.left') as Selection<SVGGElement, unknown, BaseType, unknown>;
+    if (!selectionLeft.empty()) {
+      selectionLeft.call(axisRows);
+      selectionLeft.select('text.label').attr('transform', function () {
+        const halfHeight = (this as SVGTextElement).getBBox().width / 2;
+        const y = Math.max(
+          config.textOffsetY + halfHeight,
+          Math.min((ref.current?.clientHeight ?? 0) - halfHeight, (scaleRows.range()[1] + scaleRows.range()[0]) / 2),
+        );
+
+        return `translate(
+            ${-config.textOffsetX + 7},
+            ${y})rotate(-90)
+          `;
+      });
+      selectionLeft.select('.domain').style('display', 'none');
+    }
   };
 
   const tick = (delta: number) => {
     if (props.graph) {
+      updateColumnAxis();
+      updateRowAxis();
       Actions.tick(delta / 60);
     }
   };
@@ -581,7 +649,12 @@ export const MatrixPixi = (props: Props) => {
         <NLPopup onClose={() => {}} data={popup} key={popup.node.id} />
       ))}
       {quickPopup && <NLPopup onClose={() => {}} data={quickPopup} />}
-      <div className="h-full w-full overflow-hidden" ref={ref}></div>
+      <div ref={ref} className={`h-full w-full overflow-hidden relative ${styles.matrix}`}>
+        <canvas ref={canvas}></canvas>
+        <div className={styles.axisLeft}></div>
+        <div className={styles.axisTop}></div>
+        <svg ref={svg}></svg>
+      </div>
     </>
   );
 };
diff --git a/libs/shared/lib/vis/visualizations/matrixvis/components/matrixPixi.module.scss b/libs/shared/lib/vis/visualizations/matrixvis/components/matrixPixi.module.scss
new file mode 100644
index 000000000..b41a7d661
--- /dev/null
+++ b/libs/shared/lib/vis/visualizations/matrixvis/components/matrixPixi.module.scss
@@ -0,0 +1,37 @@
+.matrix {
+  --size: 50px;
+}
+
+.axisLeft, .axisTop {
+  position: absolute;
+  left: 0;
+  top: 0;
+  backdrop-filter: blur(10px);
+  background: rgba(255,255,255, 0.5);
+}
+
+.axisLeft {
+  top: var(--size);
+  bottom: 0;
+  width: var(--size);
+  box-shadow: 1px 0px 0px 0px rgba(0,0,0,0.2);
+}
+
+.axisTop {
+  right: 0;
+  height: var(--size);
+  box-shadow: var(--size) 1px 0px 0px rgba(0,0,0,0.2);
+}
+
+/* mask top left corner */
+.axisTop + svg {
+  clip-path: polygon(
+    0% 0%, 
+    0% 100%, 
+    100% 100%, 
+    100% 0%, 
+    var(--size) 0%, 
+    var(--size) var(--size), 
+    0% var(--size)
+  );
+}
\ No newline at end of file
-- 
GitLab