cytoscape / cytoscape.js-euler

Euler is a fast, high-quality force-directed (physics simulation) layout for Cytoscape.js
MIT License
31 stars 23 forks source link

Infinite render graph force-directed on parameter "randomize": false #23

Closed DaniilRyb closed 2 months ago

DaniilRyb commented 1 year ago

Hello! I found problem of infinite render page (or graph i don't know what exactly) Bug: infinite render (Chrome Version 116.0.5845.187 (Official Build) (arm64))

Basic dependencies for convenience: "cytoscape": "^3.0.0", "react-cytoscapejs": "^2.0.0", "cytoscape-euler": "^1.2.2",

The main problem is in the CytoscapeСomponent (all code of the Graph component on React) and in the layouts object, namely the property "randomize": false.

One of the main reasons, it seems to me, is the following:

  1. Problem in React. There may be something with the life cycles of rendering components that conflict with the library itself. Although of course they did, but still.
  2. Perhaps some fields in the layout object are ignored, because the "numIter" object parameter simply does not work in this situation.

With randomize: true, all works good just in case, I attach the entire component code

{
  "name": "project",
  "version": "0.1.0",
  "private": true,
  "homepage": "/",
  "dependencies": {
    "@reduxjs/toolkit": "^1.9.3",
    "@types/chart.js": "^2.9.38",
    "@types/chartjs": "^0.0.31",
    "@types/cytoscape": "^3.19.11",
    "@types/react": "^18.0.29",
    "@types/react-cytoscapejs": "^1.2.2",
    "@types/react-dom": "^18.0.11",
    "@types/react-redux": "^7.1.25",
    "@types/styled-components": "^5.1.27",
    "@types/webpack-bundle-analyzer": "^4.6.0",
    "ajv": "^8.11.0",
    "chart.js": "^3.9.1",
    "chartjs-adapter-date-fns": "^3.0.0",
    "chartjs-adapter-moment": "^1.0.1",
    "chartjs-plugin-datalabels": "^2.2.0",
    "chartjs-plugin-piechart-outlabels": "^0.1.4",
    "cytoscape": "^3.0.0",
    "cytoscape-cola": "^2.5.1",
    "cytoscape-euler": "^1.2.2",
    "date-fns": "^2.30.0",
    "dotenv-webpack": "^8.0.1",
    "export-from-json": "^1.7.3",
    "graphology-types": "^0.24.7",
    "i18next": "23.2.3",
    "i18next-browser-languagedetector": "7.1.0",
    "i18next-http-backend": "^2.2.1",
    "moment": "^2.29.4",
    "popper.js": "^1.16.1",
    "react": "^18.2.0",
    "react-bootstrap": "^2.8.0",
    "react-chartjs-2": "^4.3.1",
    "react-cytoscapejs": "^2.0.0",
    "react-dom": "^18.2.0",
    "react-i18next": "13.0.1",
    "react-json-pretty": "^2.2.0",
    "react-json-view": "^1.21.3",
    "react-redux": "^8.1.1",
    "react-router-dom": "6.14.0",
    "react-scripts": "^5.0.1",
    "redux": "^4.2.1",
    "styled-components": "^6.0.7",
    "tippy.js": "^6.2.5",
    "uuid": "^9.0.0",
    "web-vitals": "3.3.2"
  },
  "scripts": {
    "test_debug": "webpack serve --open --config webpack.test_debug.ts",
    "test_release": "webpack --config webpack.test_release.ts",
    "preprod_debug": "webpack serve --open --config webpack.preprod_debug.ts",
    "preprod_release": "webpack --config webpack.preprod_release.ts",
    "prod_debug": "webpack --config webpack.prod_debug.ts",
    "prod_release": "webpack --config webpack.prod_release.ts",
    "development": "webpack serve --open --config webpack.development.ts",
    "production": "webpack --config webpack.production.ts",
    "lint": "eslint src/**/*.{ts,tsx}",
    "lint:fix": "eslint --fix 'src/**/*.{ts,tsx}'",
    "format": "prettier --write 'src/**/*.{ts,tsx,css}'",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest"
    ]
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  },
  "devDependencies": {
    "@types/cytoscape-euler": "^1.2.1",
    "@types/cytoscape-popper": "^2.0.1",
    "@types/lodash": "^4.14.196",
    "@types/node": "^18.15.11",
    "@types/react-d3-graph": "^2.6.4",
    "@types/uuid": "^9.0.1",
    "@types/webpack": "^5.28.1",
    "@typescript-eslint/eslint-plugin": "^5.59.7",
    "@typescript-eslint/parser": "^5.59.7",
    "autoprefixer": "^10.4.14",
    "bootstrap": "^5.2.2",
    "clean-webpack-plugin": "^4.0.0",
    "copy-webpack-plugin": "^11.0.0",
    "css-loader": "^6.8.1",
    "css-minimizer-webpack-plugin": "^5.0.0",
    "eslint": "^8.41.0",
    "eslint-config-prettier": "^8.8.0",
    "eslint-config-standard-with-typescript": "^34.0.1",
    "eslint-import-resolver-typescript": "^3.5.5",
    "eslint-plugin-import": "^2.27.5",
    "eslint-plugin-n": "^15.7.0",
    "eslint-plugin-prettier": "^4.2.1",
    "eslint-plugin-promise": "^6.1.1",
    "eslint-plugin-react": "^7.32.2",
    "html-webpack-plugin": "^5.5.1",
    "node-sass": "^9.0.0",
    "postcss": "^8.4.23",
    "postcss-loader": "^7.3.3",
    "prettier": "^2.8.8",
    "sass-loader": "^13.3.2",
    "style-loader": "^3.3.2",
    "ts-loader": "^9.4.2",
    "ts-node": "^10.9.1",
    "typescript": "^5.0.4",
    "webpack": "^5.81.0",
    "webpack-bundle-analyzer": "^4.8.0",
    "webpack-cli": "^5.0.2",
    "webpack-dev-server": "^4.13.3",
    "webpack-merge": "^5.8.0"
  },
  "overrides": {
    "@svgr/webpack": "^8.1.0"
  },
  "volta": {
    "node": "18.17.1"
  }
}

Source code:


import { useParams } from "react-router-dom";
import React, { FC, useCallback } from "react";
import {
  CollectionReturnValue,
  Core,
  EventObject,
  LayoutOptions,
  NodeSingular,
  Stylesheet,
  use,
} from "cytoscape";
import styled from "styled-components";
import CytoscapeComponent from "react-cytoscapejs";
import euler from "cytoscape-euler";

import { useGenerateGraph } from "../../hooks/use-generate-graph/useGenerateGraph";
import user from "../../assets/userId.svg";
import device from "../../assets/deviceId.svg";
import ip from "../../assets/ip.svg";
import deviceHash from "../../assets/deviceHash.svg";
import app from "../../assets/applicationId.svg";
import { Error } from "../error/Error";
import { Spinner } from "../ui/spinner/Spinner";
use(euler);

const CytoscapeComponentStyled = styled.div`
  width: 800px;
  height: 600px;
  border: 2px solid #ccc;
  border-radius: 5px;
`;
export const layouts = {
  random: {
    name: "random",
    animate: true,
    randomize: false,
  },
  grid: {
    name: "grid",
    animate: true,
    randomize: false,
  },
  circle: {
    name: "circle",
    animate: true,
    randomize: false,
  },
  breadthfirst: {
    name: "breadthfirst",
    animate: true,
    randomize: false,
  },
  klay: {
    name: "klay",
    animate: true,
    randomize: false,
    padding: 4,
    nodeDimensionsIncludeLabels: true,
    klay: {
      spacing: 40,
      mergeEdges: false,
    },
  },
  fcose: {
    name: "fcose",
    animate: true,
    randomize: false,
  },
  cose: {
    name: "cose",
    animate: true,
    randomize: false,
  },

  cola: {
    name: "cola",
    animate: true,
    randomize: false,
  },
  dagre: {
    name: "dagre",
    animate: true,
    randomize: false,
  },
  euler: {
    name: "euler",
  },
};

export const Graph: FC = () => {
  const { user_id: userId } = useParams();
  const nodeType: "user" | "device" | "application" | "ip" | "subnet" = "user";
  const {
    elements,
    status,
    error: { statusCode, statusMessage, statusText },
  } = useGenerateGraph(nodeType, userId as string, 3, ["subnet"]);

  const stylesheet = [
    {
      selector: "node",
      style: {
        // label: "data(title)", // Отключаем текстовую надпись
        "text-max-width": "50px", // Максимальная ширина текста
        "text-margin-x": "-60px", // Внешний отступ по горизонтали
        "text-margin-y": "-25px", // Внешний отступ по горизонтали
        "background-color": "#e5e1e1",
        color: "#000000",
        "text-valign": "center",
        "overlay-padding": "6px",
        "text-outline-color": "#ffffff",
        "text-outline-width": "2px",
        fontSize: 13,
        "border-width": "1px",
      },
    },
    {
      selector: "edge",
      style: {
        width: 1,
        "line-color": "rgba(199,198,198,0.56)",
        "curve-style": "bezier",
      },
    },
    {
      selector: `node[id="${sessionStorage.getItem(
        "team",
      )}:${nodeType}:${userId}"]`,
      style: {
        width: 50,
        height: 50,
        "background-image": `url(${user})`,
        "background-repeat": "no-repeat",
      },
    },
    {
      selector: `node[label="user"]`,
      style: {
        "background-image": `url(${user})`,
        "background-repeat": "no-repeat",
      },
    },
    {
      selector: 'node[label="device"]',
      style: {
        "background-image": `url(${device})`,
        "background-color": "rgb(227,172,172)",
        "background-repeat": "no-repeat",
      },
    },
    {
      selector: 'node[label="application"]',
      style: {
        "background-image": `url(${app})`,
        "background-color": "rgb(161,188,225)",
        "background-repeat": "no-repeat",
      },
    },
    {
      selector: 'node[label="device_hash"]',
      style: {
        "background-image": `url(${deviceHash})`,
        "background-color": "rgb(148,211,158)",
        "background-repeat": "no-repeat",
      },
    },
    {
      selector: 'node[label="ip"]',
      style: {
        "background-image": `url(${ip})`,
        "background-color": "rgb(203,177,215)",
        "background-repeat": "no-repeat",
      },
    },
    {
      selector: ".highlighted",
      style: {
        "background-color": "green",
        "line-color": "green",
        "target-arrow-color": "green",
      },
    },
  ] as Stylesheet[];

  const handleNodeMouseOver = useCallback((event: EventObject) => {
    const node: NodeSingular = event.target as NodeSingular;
    const neighbors: CollectionReturnValue = node.neighborhood();
    const divGraphTooltip = document.getElementById("graphTooltip");
    if (divGraphTooltip) {
      divGraphTooltip.style.opacity = "1";
      divGraphTooltip.style.padding = "0.5rem";
      divGraphTooltip.style.cursor = "pointer";
      //divGraphTooltip.style.transform = "translate(-50%, 75%)"
      divGraphTooltip.style.position = "absolute";
      divGraphTooltip.style.zIndex = "9999";
      divGraphTooltip.innerText = node.data("title");
      divGraphTooltip.style.background = "rgba(0,0,0,0.7)";
      divGraphTooltip.style.borderRadius = "3px";
      divGraphTooltip.style.color = "#fff";
      divGraphTooltip.style.transition = "all .1s ease";

      const graphCanvasPosition = document.getElementById(
        "graphCanvasPosition",
      );
      graphCanvasPosition?.addEventListener("mousemove", (e) => {
        divGraphTooltip.style.left = e.x + "px";
        divGraphTooltip.style.top = e.y + 100 + "px";
      });
    }
    neighbors.edges().style("line-color", "#3dbe20");
    neighbors.edges().style("width", 4);
  }, []);

  const handleNodeMouseOut = useCallback((event: EventObject) => {
    const node: NodeSingular = event.target as NodeSingular;
    const divGraphTooltip: HTMLElement | null =
      document.getElementById("graphTooltip");
    if (divGraphTooltip) divGraphTooltip.style.opacity = "0";
    const neighbors = node.neighborhood();

    neighbors.edges().style("line-color", "rgba(199,198,198,0.56)");
    neighbors.edges().style("width", 1);
  }, []);

  const layouts = {
    name: "euler",
    animate: false,
    // refresh: 10,
    gravity: -3,
    dragCoeff: 0.02,
    padding: 25,
    numIter: 100,
    // minTemp: 1.0,
    randomize: true,
   /* fit: true,*/
    /*componentSpacing: 100,
    coolingFactor: 0.99,
    fit: true,
    timeStep: 2,*/
    /*   initialTemp: 1000,
    minTemp: 1.0,
    nestingFactor: 1.2,
    ungrabifyWhileSimulating: true,
    nodeDimensionsIncludeLabels: false,
    nodeOverlap: 4,
    numIter: 200,
    ,*/
    // springCoeff(edge: any): number {
    //   return 0.0008;
    // },
    // springLength(edge: any): number {
    //   return 80;
    // },
    // mass(node: any): number {
    //   return 8;
    // },
  } as LayoutOptions

  return (
    <>
      {status === "error" && (
        <Error
          statusCode={statusCode}
          statusText={statusText}
          statusMessage={statusMessage}
        />
      )}
      {status === "loading" && (
        <CytoscapeComponentStyled>
          <Spinner />
        </CytoscapeComponentStyled>
      )}
      {status === "success" && (
        <CytoscapeComponentStyled>
          <CytoscapeComponent
            id="graphCanvasPosition"
            elements={elements}
            stylesheet={stylesheet}
            minZoom={0.25}
            maxZoom={5}
            layout={layouts}
            style={{
              width: "100%",
              height: "100%",
              backgroundColor: "#f1f4fa",
              margin: "0",
              padding: "0",
            }}
            cy={(cy: Core) => {
              cy.on("mouseover", "node", handleNodeMouseOver);
              cy.on("mouseout", "node", handleNodeMouseOut);
              return () => {
                cy.off("mouseover", "node", handleNodeMouseOver);
                cy.off("mouseout", "node", handleNodeMouseOut);
              };
            }}
          />
          <div id="graphTooltip" />
        </CytoscapeComponentStyled>
      )}
    </>
  );
};
maxkfranz commented 1 year ago

It looks like you may have a good start to specifying your issue. Two points:

(1) Issues for cytoscape-reactjs should go to that repo, and the folks at Plotly will address it accordingly: https://github.com/plotly/react-cytoscapejs

(2) To expedite your issue, it would be best for you to have a reproducible demo. Codepen allows for demos that require build steps.

DaniilRyb commented 1 year ago

Thanks for the answer, will be trying your advices. Codepen is a good idea, but simulation in Codepen may be different than in Browsers (Firefox, Chrome and etc.) But it's worth a try P. S. I managed to run the project with the "randomize" parameter: false, but when the page is reloaded, the graph still looks different. Then the question arises, is the graph idempotent in principle? (always the same, even the position of the vertices is exactly in the position they were in before)

KonradHoeffner commented 3 months ago

@maxkfranz: I have the same problem (without react): when there are a lot of nodes in the same spot (x:0, y:0) and randomize is false, then the browser freezes during the euler layout. You can see this with the current version of SNIK Graph ( 24.06, commit date 2024-06-18T15:51:11+02:00, release 24.06) at https://www.snik.eu/graph/ when you press CTRL+ALT+R (restores all hidden nodes which are at position 0,0) and then CTRL+ALT+T ("tight layout", or click on the tight layout button), then it often freezes.

I can work around the problem by giving all nodes some other position but this takes way too long (the web app loads in 2 seconds instead of 1 second).

maxkfranz commented 3 months ago

This layout is fairly basic as far as force-directed layouts go.

The purpose of randomise:false is to provide your own initial positions. If you leave everything at (0,0), you haven't specified any positions. The way a FD layout would typically handle inputs of nodes with identical initial positions would be either:

(1) Throw an error (i.e. because of user error), e.g. IdenticalPositionsForbiddenError

(2) Randomise the overlapping positions

You can do (2) yourself by using randomize:true.

In general, I'd recommend using FCOSE (faster, better) instead of this layout unless you have a very strong justification for not using FCOSE.

KonradHoeffner commented 3 months ago

The reason for not using FCOSE is that we created our application in 2016 when it didn't exist, so I will switch to it and see if it fits our use cases the same way :-) @maxkfranz: After testing FCOSE in place of Euler, I noticed that FCOSE is much slower (more than a minute) and causes a Stop/Debug message to appear on Firefox on a Laptop with an Intel i3-1115g4 CPU.

Euler on the other hand only takes a few seconds. The graph has 4085 nodes and 8198 edges.