From 04c3f76eef4a8b8d6cd7f43cf10aa0d0ee081312 Mon Sep 17 00:00:00 2001
From: Pierre-Antoine Rouby <pierre-antoine.rouby@inrae.fr>
Date: Mon, 20 Mar 2023 16:52:44 +0100
Subject: [PATCH] network: Add graphwidget view.

---
 src/model/network/Graph.py      |   8 +-
 src/model/network/Node.py       |   7 +-
 src/model/network/Point.py      |   2 +-
 src/view/NetworkWindow.py       |  25 ++-
 src/view/network/GraphWidget.py | 284 ++++++++++++++++++++++++++++++++
 src/view/ui/Network.ui          |  53 +++---
 6 files changed, 343 insertions(+), 36 deletions(-)
 create mode 100644 src/view/network/GraphWidget.py

diff --git a/src/model/network/Graph.py b/src/model/network/Graph.py
index c0efeb1d..78ea877c 100644
--- a/src/model/network/Graph.py
+++ b/src/model/network/Graph.py
@@ -50,8 +50,12 @@ class Graph(object):
     def node(self, node_name:str):
         return list(filter(lambda n: n.name == node_name, self._nodes))[0]
 
-    def add_node(self):
-        node = Node(self._nodes_ids, f"Node {self._nodes_ids}")
+    def add_node(self, x:float = 0.0, y:float = 0.0):
+        node = Node(
+            self._nodes_ids,
+            f"Node {self._nodes_ids}",
+            x = x, y = y
+        )
         self._nodes.append(node)
         self._nodes_ids += 1
         return node
diff --git a/src/model/network/Node.py b/src/model/network/Node.py
index e0632cee..46ed203f 100644
--- a/src/model/network/Node.py
+++ b/src/model/network/Node.py
@@ -3,12 +3,13 @@
 from model.network.Point import Point
 
 class Node(object):
-    def __init__(self, id:str, name:str):
+    def __init__(self, id:str, name:str,
+                 x:float = 0.0, y:float = 0.0):
         super(Node, self).__init__()
 
         self.id = id
         self.name = name
-        self.pos = Point(0, 0)
+        self.pos = Point(x, y)
 
     def __repr__(self):
         return f"Node {{id: {self.id}, name: {self.name}}}"
@@ -20,6 +21,8 @@ class Node(object):
             ret = self.name
         elif name == "id":
             ret = self.id
+        elif name == "pos":
+            ret = f"({self.pos.x},{self.pos.y})"
 
         return ret
 
diff --git a/src/model/network/Point.py b/src/model/network/Point.py
index bb92c356..8ea0ecd9 100644
--- a/src/model/network/Point.py
+++ b/src/model/network/Point.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 
-def Point(object):
+class Point(object):
     def __init__(self, x:float, y:float):
         super(Point, self).__init__()
 
diff --git a/src/view/NetworkWindow.py b/src/view/NetworkWindow.py
index 95053c9c..99cf5fdb 100644
--- a/src/view/NetworkWindow.py
+++ b/src/view/NetworkWindow.py
@@ -1,16 +1,17 @@
 # -*- coding: utf-8 -*-
 
-from view.ASubWindow import ASubWindow
 from model.network.Node import Node
 from model.network.Edge import Edge
 from model.network.Graph import Graph
+from view.ASubWindow import ASubWindow
+from view.network.GraphWidget import GraphWidget
 
 from PyQt5.QtCore import (
     Qt, QRect, QVariant, QAbstractTableModel, pyqtSlot, pyqtSignal,
 )
 
 from PyQt5.QtWidgets import (
-    QTableView, QItemDelegate, QComboBox, QLineEdit,
+    QTableView, QItemDelegate, QComboBox, QLineEdit, QHBoxLayout,
 )
 
 class LineEditDelegate(QItemDelegate):
@@ -121,11 +122,20 @@ class NetworkWindow(ASubWindow):
         self.ui.setWindowTitle(title)
 
         self.graph = Graph()
+        n1 = self.graph.add_node()
+        n2 = self.graph.add_node(50.0,50.0)
+        e1 = self.graph.add_edge(n1,n2)
+
+        # Graph Widget
+
+        self.graph_widget = GraphWidget(self.graph)
+        self.graph_layout = self.find(QHBoxLayout, "horizontalLayout_graph")
+        self.graph_layout.addWidget(self.graph_widget)
 
         # Nodes table
 
         self.nodes_model = TableModel(
-            headers = ["name", "id"],
+            headers = ["name", "id", "pos"],
             rows = self.graph.nodes(),
             graph = self.graph,
         )
@@ -140,11 +150,11 @@ class NetworkWindow(ASubWindow):
         self.reachs_model = TableModel(
             headers = ["name", "node1", "node2"],
             rows = self.graph.edges(),
-            graph=self.graph,
+            graph = self.graph,
         )
         self.delegate_combobox = ComboBoxDelegate(
-            graph=self.graph,
-            parent=self,
+            graph = self.graph,
+            parent = self,
         )
         table = self.find(QTableView, "tableView_reachs")
         table.setModel(self.reachs_model)
@@ -152,6 +162,7 @@ class NetworkWindow(ASubWindow):
         table.setItemDelegateForColumn(2, self.delegate_combobox)
         #table.resizeColumnsToContents()
 
-        # Connection the two table
+        # Connection
+
         self.nodes_model.dataChanged.connect(self.reachs_model.update)
         self.reachs_model.dataChanged.connect(self.nodes_model.update)
diff --git a/src/view/network/GraphWidget.py b/src/view/network/GraphWidget.py
new file mode 100644
index 00000000..a224d3ee
--- /dev/null
+++ b/src/view/network/GraphWidget.py
@@ -0,0 +1,284 @@
+# -*- coding: utf-8 -*-
+
+import math
+
+from model.network.Node import Node
+from model.network.Edge import Edge
+from model.network.Graph import Graph
+
+from PyQt5.QtCore import (
+    qAbs, QLineF, QPointF, qrand, QRectF, QSizeF, qsrand,
+    Qt, QTime
+)
+from PyQt5.QtGui import (
+    QBrush, QColor, QLinearGradient, QPainter,
+    QPainterPath, QPen, QPolygonF, QRadialGradient,
+    QFont,
+)
+from PyQt5.QtWidgets import (
+    QApplication, QGraphicsItem, QGraphicsScene,
+    QGraphicsView, QStyle
+)
+
+class LandMark(QGraphicsItem):
+     def paint(self, painter, option, widget):
+         painter.setPen(QPen(Qt.black, 0))
+
+         painter.drawLine(-500, -500, 500, 500)
+         painter.drawLine(-500, 500, 500, -500)
+
+         painter.drawLine(-500, -500, 500, -500)
+         painter.drawLine(500, -500, 500, 500)
+         painter.drawLine(500, 500, -500, 500)
+         painter.drawLine(-500, 500, -500, -500)
+
+class NodeItem(QGraphicsItem):
+    Type = QGraphicsItem.UserType + 1
+
+    def __init__(self, node, graphWidget):
+        super(NodeItem, self).__init__()
+
+        self.node = node
+        self.setPos(QPointF(self.node.pos.x, self.node.pos.y))
+
+        self.graph = graphWidget
+        self.newPos = QPointF()
+
+        self.setFlag(QGraphicsItem.ItemIsMovable)
+        self.setFlag(QGraphicsItem.ItemSendsGeometryChanges)
+        self.setCacheMode(QGraphicsItem.DeviceCoordinateCache)
+        self.setZValue(1)
+
+    def type(self):
+        return NodeItem.Type
+
+    def advance(self):
+        if self.newPos == self.pos():
+            return False
+
+        self.setPos(self.newPos)
+        return True
+
+    def boundingRect(self):
+        adjust = 2.0
+        return QRectF(-10 - adjust, -10 - adjust, 23 + adjust, 23 + adjust)
+
+    def shape(self):
+        path = QPainterPath()
+        path.addEllipse(-10, -10, 20, 20)
+        return path
+
+    def paint(self, painter, option, widget):
+        painter.setPen(Qt.NoPen)
+        # painter.setBrush(Qt.darkGray)
+        # painter.drawEllipse(-7, -7, 20, 20)
+
+        gradient = QRadialGradient(-3, -3, 10)
+        if option.state & QStyle.State_Sunken:
+            gradient.setCenter(3, 3)
+            gradient.setFocalPoint(3, 3)
+            gradient.setColorAt(1, QColor(Qt.yellow).lighter(120))
+            gradient.setColorAt(0, QColor(Qt.darkYellow).lighter(120))
+        else:
+            gradient.setColorAt(0, Qt.yellow)
+            gradient.setColorAt(1, Qt.darkYellow)
+
+        painter.setBrush(QBrush(gradient))
+        painter.setPen(QPen(Qt.black, 0))
+        painter.drawEllipse(-10, -10, 20, 20)
+
+        painter.setFont(QFont("Arial", 20))
+        painter.drawText(QRectF(-10, -10, 20, 20), Qt.AlignCenter, self.node.name)
+
+    def itemChange(self, change, value):
+        if change == QGraphicsItem.ItemPositionHasChanged:
+            self.graph.itemMoved()
+
+        return super(NodeItem, self).itemChange(change, value)
+
+    def mousePressEvent(self, event):
+        self.update()
+        super(NodeItem, self).mousePressEvent(event)
+
+    def mouseReleaseEvent(self, event):
+        self.update()
+        super(NodeItem, self).mouseReleaseEvent(event)
+
+class EdgeItem(QGraphicsItem):
+    Type = QGraphicsItem.UserType + 2
+
+    def __init__(self, edge):
+        super(EdgeItem, self).__init__()
+
+        self.arrowSize = 10.0
+        self.sourcePoint = QPointF()
+        self.destPoint = QPointF()
+
+        self.setAcceptedMouseButtons(Qt.NoButton)
+        self.source = edge.node1
+        self.dest = edge.node2
+        self.edge = edge
+        self.adjust()
+
+    def type(self):
+        return Edge.Type
+
+    def sourceNode(self):
+        return self.source
+
+    def setSourceNode(self, node):
+        self.source = node
+        self.adjust()
+
+    def destNode(self):
+        return self.dest
+
+    def setDestNode(self, node):
+        self.dest = node
+        self.adjust()
+
+    def adjust(self):
+        if not self.source or not self.dest:
+            return
+
+        line = QLineF(
+            self.mapFromItem(self.source, 0, 0),
+            self.mapFromItem(self.dest, 0, 0)
+        )
+        length = line.length()
+
+        self.prepareGeometryChange()
+
+        if length > 20.0:
+            edgeOffset = QPointF((line.dx() * 10) / length,
+                    (line.dy() * 10) / length)
+
+            self.sourcePoint = line.p1() + edgeOffset
+            self.destPoint = line.p2() - edgeOffset
+        else:
+            self.sourcePoint = line.p1()
+            self.destPoint = line.p1()
+
+    def boundingRect(self):
+        if not self.source or not self.dest:
+            return QRectF()
+
+        penWidth = 1.0
+        extra = (penWidth + self.arrowSize) / 2.0
+
+        return QRectF(self.sourcePoint,
+                QSizeF(self.destPoint.x() - self.sourcePoint.x(),
+                        self.destPoint.y() - self.sourcePoint.y())).normalized().adjusted(-extra, -extra, extra, extra)
+
+    def paint(self, painter, option, widget):
+        if not self.source or not self.dest:
+            return
+
+        # Draw the line itself.
+        line = QLineF(self.sourcePoint, self.destPoint)
+
+        if line.length() == 0.0:
+            return
+
+        painter.setPen(QPen(Qt.black, 1, Qt.SolidLine, Qt.RoundCap,
+                Qt.RoundJoin))
+        painter.drawLine(line)
+
+        # Draw the arrows if there's enough room.
+        angle = math.acos(line.dx() / line.length())
+        if line.dy() >= 0:
+            angle = Edge.TwoPi - angle
+
+        sourceArrowP1 = self.sourcePoint + QPointF(math.sin(angle + Edge.Pi / 3) * self.arrowSize,
+                                                   math.cos(angle + Edge.Pi / 3) * self.arrowSize)
+        sourceArrowP2 = self.sourcePoint + QPointF(math.sin(angle + Edge.Pi - Edge.Pi / 3) * self.arrowSize,
+                                                   math.cos(angle + Edge.Pi - Edge.Pi / 3) * self.arrowSize);
+        destArrowP1 = self.destPoint + QPointF(math.sin(angle - Edge.Pi / 3) * self.arrowSize,
+                                               math.cos(angle - Edge.Pi / 3) * self.arrowSize)
+        destArrowP2 = self.destPoint + QPointF(math.sin(angle - Edge.Pi + Edge.Pi / 3) * self.arrowSize,
+                                               math.cos(angle - Edge.Pi + Edge.Pi / 3) * self.arrowSize)
+
+        painter.setBrush(Qt.black)
+        painter.drawPolygon(QPolygonF([line.p1(), sourceArrowP1, sourceArrowP2]))
+        painter.drawPolygon(QPolygonF([line.p2(), destArrowP1, destArrowP2]))
+
+
+class GraphWidget(QGraphicsView):
+    def __init__(self, graph):
+        super(GraphWidget, self).__init__()
+
+        self.timerId = 0
+
+        scene = QGraphicsScene(self)
+        scene.setItemIndexMethod(QGraphicsScene.NoIndex)
+        scene.setSceneRect(-500, -500, 500, 500)
+        self.setScene(scene)
+        self.setCacheMode(QGraphicsView.CacheBackground)
+        self.setViewportUpdateMode(QGraphicsView.BoundingRectViewportUpdate)
+        self.setRenderHint(QPainter.Antialiasing)
+        self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
+        self.setResizeAnchor(QGraphicsView.AnchorViewCenter)
+
+        scene.addItem(LandMark())
+
+        self.graph = graph
+        for node in self.graph.nodes():
+            scene.addItem(NodeItem(node, self))
+
+        # for edge in self.graph.edges():
+        #     scene.addItem(EdgeItem(edge))
+
+        self.scale(0.8, 0.8)
+        self.setMinimumSize(400, 400)
+
+    def itemMoved(self):
+        if not self.timerId:
+            self.timerId = self.startTimer(1000 / 25)
+
+    def keyPressEvent(self, event):
+        key = event.key()
+
+        if key == Qt.Key_Up:
+            self.centerNode.moveBy(0, -20)
+        elif key == Qt.Key_Down:
+            self.centerNode.moveBy(0, 20)
+        elif key == Qt.Key_Left:
+            self.centerNode.moveBy(-20, 0)
+        elif key == Qt.Key_Right:
+            self.centerNode.moveBy(20, 0)
+        elif key == Qt.Key_Plus:
+            self.scaleView(1.2)
+        elif key == Qt.Key_Minus:
+            self.scaleView(1 / 1.2)
+        elif key == Qt.Key_Space or key == Qt.Key_Enter:
+            for item in self.scene().items():
+                if isinstance(item, Node):
+                    item.setPos(-150 + qrand() % 300, -150 + qrand() % 300)
+        else:
+            super(GraphWidget, self).keyPressEvent(event)
+
+    def timerEvent(self, event):
+        nodes = [item for item in self.scene().items() if isinstance(item, Node)]
+
+        for node in nodes:
+            node.calculateForces()
+
+        itemsMoved = False
+        for node in nodes:
+            if node.advance():
+                itemsMoved = True
+
+        if not itemsMoved:
+            self.killTimer(self.timerId)
+            self.timerId = 0
+
+    def wheelEvent(self, event):
+        self.scaleView(math.pow(2.0, -event.angleDelta().y() / 240.0))
+
+    def scaleView(self, scaleFactor):
+        factor = self.transform().scale(scaleFactor, scaleFactor).mapRect(QRectF(0, 0, 1, 1)).width()
+
+        if factor < 0.07 or factor > 100:
+            return
+
+        self.scale(scaleFactor, scaleFactor)
diff --git a/src/view/ui/Network.ui b/src/view/ui/Network.ui
index b78a009e..54513b99 100644
--- a/src/view/ui/Network.ui
+++ b/src/view/ui/Network.ui
@@ -62,28 +62,7 @@
       </layout>
      </item>
      <item>
-      <layout class="QHBoxLayout" name="horizontalLayout_2">
-       <item>
-        <widget class="QGraphicsView" name="graphicsView_3">
-         <property name="minimumSize">
-          <size>
-           <width>0</width>
-           <height>400</height>
-          </size>
-         </property>
-        </widget>
-       </item>
-       <item>
-        <widget class="QSlider" name="verticalSlider_2">
-         <property name="value">
-          <number>50</number>
-         </property>
-         <property name="orientation">
-          <enum>Qt::Vertical</enum>
-         </property>
-        </widget>
-       </item>
-      </layout>
+      <layout class="QHBoxLayout" name="horizontalLayout_graph"/>
      </item>
     </layout>
    </item>
@@ -140,7 +119,20 @@
       </layout>
      </item>
      <item>
-      <widget class="QTableView" name="tableView_reachs"/>
+      <widget class="QTableView" name="tableView_reachs">
+       <property name="minimumSize">
+        <size>
+         <width>650</width>
+         <height>150</height>
+        </size>
+       </property>
+       <property name="maximumSize">
+        <size>
+         <width>16777215</width>
+         <height>200</height>
+        </size>
+       </property>
+      </widget>
      </item>
     </layout>
    </item>
@@ -197,7 +189,20 @@
       </layout>
      </item>
      <item>
-      <widget class="QTableView" name="tableView_nodes"/>
+      <widget class="QTableView" name="tableView_nodes">
+       <property name="minimumSize">
+        <size>
+         <width>0</width>
+         <height>150</height>
+        </size>
+       </property>
+       <property name="maximumSize">
+        <size>
+         <width>16777215</width>
+         <height>200</height>
+        </size>
+       </property>
+      </widget>
      </item>
     </layout>
    </item>
-- 
GitLab