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