Skip to content
Snippets Groups Projects
wot.py 15.76 KiB
# -*- coding: utf-8 -*-

import math
from PyQt5.QtGui import QPainter, QBrush, QPen, QPolygonF, QColor, QRadialGradient, \
    QPainterPath, QMouseEvent, QWheelEvent, QTransform, QCursor
from PyQt5.QtCore import Qt, QRectF, QLineF, QPoint, QPointF, QSizeF, \
                        qFuzzyCompare, pyqtSignal, QT_TRANSLATE_NOOP, \
                        QCoreApplication, QLocale
from PyQt5.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsEllipseItem, \
    QGraphicsSimpleTextItem, QGraphicsLineItem, QMenu, QAction, QGraphicsSceneHoverEvent, \
    QGraphicsSceneContextMenuEvent

NODE_STATUS_HIGHLIGHTED = 1
NODE_STATUS_SELECTED = 2
NODE_STATUS_OUT = 4
ARC_STATUS_STRONG = 1
ARC_STATUS_WEAK = 2


class WotView(QGraphicsView):
    def __init__(self, parent=None):
        """
        Create View to display scene

        :param parent:  [Optional, default=None] Parent widget
        """
        super(WotView, self).__init__(parent)

        self.setScene(Scene(self))

        self.setCacheMode(QGraphicsView.CacheBackground)
        self.setViewportUpdateMode(QGraphicsView.BoundingRectViewportUpdate)
        self.setRenderHint(QPainter.Antialiasing)
        self.setRenderHint(QPainter.SmoothPixmapTransform)

    def wheelEvent(self, event: QWheelEvent):
        """
        Zoom in/out on the mouse cursor
        """
        # zoom only when CTRL key pressed
        if (event.modifiers() & Qt.ControlModifier) == Qt.ControlModifier:
            steps = event.angleDelta().y() / 15 / 8

            if steps == 0:
                event.ignore()
                return

            # scale factor 1.25
            sc = pow(1.25, steps)
            self.scale(sc, sc)
            self.centerOn(self.mapToScene(event.pos()))
            event.accept()
        #  act normally on scrollbar
        else:
            # transmit event to parent class wheelevent
            super(QGraphicsView, self).wheelEvent(event)


class Scene(QGraphicsScene):
    # This defines signals taking string arguments
    node_clicked = pyqtSignal(dict, name='nodeClicked')
    node_signed = pyqtSignal(dict, name='nodeSigned')
    node_transaction = pyqtSignal(dict, name='nodeTransaction')
    node_contact = pyqtSignal(dict, name='nodeContact')
    node_member = pyqtSignal(dict, name='nodeMember')

    def __init__(self, parent=None):
        """
        Create scene of the graph

        :param parent: [Optional, default=None] Parent view
        """
        super(Scene, self).__init__(parent)

        self.lastDragPos = QPoint()
        self.setItemIndexMethod(QGraphicsScene.NoIndex)

        # list of nodes in scene
        self.nodes = dict()
        #  axis of the scene for debug purpose
        # self.addLine(-100, 0, 100, 0)
        # self.addLine(0, -100, 0, 100)

    def add_node(self, metadata, pos):
        """
        Add a node item in the graph

        :param dict metadata: Node metadata
        :param tuple pos: Position (x,y) of the node

        :return: Node
        """
        node = Node(metadata, pos)
        self.addItem(node)
        self.nodes[node.id] = node
        return node

    def add_arc(self, source_node, destination_node, metadata):
        """
        Add an arc between two nodes

        :param Node source_node: Source node of the arc
        :param Node destination_node: Destination node of the arc
        :param dict arc: Arc data

        :return: Arc
        """
        arc = Arc(source_node, destination_node, metadata)
        self.addItem(arc)
        return arc

    def update_wot(self, graph):
        """
        draw community graph

        :param dict graph: graph to draw
        """
        #  clear scene
        self.clear()

        # capture selected node (to draw it in the center)
        for _id, node in graph.items():
            if node['status'] & NODE_STATUS_SELECTED:
                selected_id = _id
                selected_node = node

        root_node = self.add_node(selected_node, (0, 0))

        # add certified by selected node
        y = 0
        x = 200
        # capture nodes for sorting by text
        nodes = list()
        for arc in selected_node['arcs']:
            nodes.append({'node': graph[arc['id']], 'arc': arc})
        # sort by text
        nodes = sorted(nodes, key=lambda _node: _node['node']['text'].lower())
        # add nodes and arcs
        for _node in nodes:
            node = self.add_node(_node['node'], (x, y))
            self.add_arc(root_node, node, _node['arc'])
            node.setToolTip(self.tr("Certification expires at {0}").format(_node['arc']['tooltip']))
            y += 50

        # add certifiers of selected node
        y = 0
        x = -200
        # sort by text
        nodes = ((k, v) for (k, v) in sorted(graph.items(), key=lambda kv: kv[1]['text'].lower()) if
                 selected_id in (arc['id'] for arc in v['arcs']))
        # add nodes and arcs
        for _id, certifier_node in nodes:
            node = self.add_node(certifier_node, (x, y))
            for arc in certifier_node['arcs']:
                if arc['id'] == selected_id:
                    self.add_arc(node, root_node, arc)
                    node.setToolTip(self.tr("Certification expires at {0}").format(arc['tooltip']))
            y += 50

        self.update()

    def update_path(self, path):
        x = 0
        y = 0
        for json_node in path:
            if json_node['status'] & NODE_STATUS_SELECTED:
                previous_node = json_node
                y -= 100
                continue
            node = self.add_node(json_node, (x, y))
            skip_reverse_arc = False
            for arc in json_node['arcs']:
                if arc['id'] == previous_node['id']:
                    #print("arc from %s to %s" % (node.id, previous_node['id']))
                    self.add_arc(node, self.nodes[previous_node['id']], arc)
                    skip_reverse_arc = True
                    break
            if not skip_reverse_arc:
                for arc in previous_node['arcs']:
                    if arc['id'] == json_node['id']:
                        #print("arc from %s to %s" % (previous_node['id'], node.id))
                        self.add_arc(self.nodes[previous_node['id']], node, arc)

            previous_node = json_node
            y -= 100


class Node(QGraphicsEllipseItem):
    def __init__(self, metadata, x_y):
        """
        Create node in the graph scene

        :param dict metadata: Node metadata
        :param x_y: Position of the node
        """
        # unpack tuple
        x, y = x_y

        super(Node, self).__init__()

        self.metadata = metadata
        self.id = metadata['id']
        self.status_wallet = self.metadata['status'] & NODE_STATUS_HIGHLIGHTED
        self.status_member = not self.metadata['status'] & NODE_STATUS_OUT
        self.text = self.metadata['text']
        self.setToolTip(self.metadata['tooltip'])
        self.arcs = []
        self.menu = None
        self.action_sign = None
        self.action_transaction = None
        self.action_contact = None
        self.action_show_member = None

        # color around ellipse
        outline_color = QColor('grey')
        outline_style = Qt.SolidLine
        outline_width = 1
        if self.status_wallet:
            outline_color = QColor('black')
            outline_width = 2
        if not self.status_member:
            outline_color = QColor('red')
            outline_style = Qt.SolidLine
        self.setPen(QPen(outline_color, outline_width, outline_style))

        # text inside ellipse
        self.text_item = QGraphicsSimpleTextItem(self)
        self.text_item.setText(self.text)
        text_color = QColor('grey')
        if self.status_wallet == NODE_STATUS_HIGHLIGHTED:
            text_color = QColor('black')
        self.text_item.setBrush(QBrush(text_color))
        # center ellipse around text
        self.setRect(
            0,
            0,
            self.text_item.boundingRect().width() * 2,
            self.text_item.boundingRect().height() * 2
        )

        #  set anchor to the center
        self.setTransform(
            QTransform().translate(-self.boundingRect().width() / 2.0, -self.boundingRect().height() / 2.0))
        self.setPos(x, y)
        # center text in ellipse
        self.text_item.setPos(self.boundingRect().width() / 4.0, self.boundingRect().height() / 4.0)

        # create gradient inside the ellipse
        gradient = QRadialGradient(QPointF(0, self.boundingRect().height() / 4), self.boundingRect().width())
        gradient.setColorAt(0, QColor('white'))
        gradient.setColorAt(1, QColor('darkgrey'))
        self.setBrush(QBrush(gradient))

        # cursor change on hover
        self.setAcceptHoverEvents(True)
        self.setZValue(1)

    def mousePressEvent(self, event: QMouseEvent):
        """
        Click on mouse button

        :param event: mouse event
        """
        if event.button() == Qt.LeftButton:
            # trigger scene signal
            self.scene().node_clicked.emit(self.metadata)

    def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent):
        """
        Mouse enter on node zone

        :param event: scene hover event
        """
        self.setCursor(Qt.ArrowCursor)

    def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent):
        """
        Right click on node to show node menu
        Except on wallet node

        :param event: scene context menu event
        """
        #  no menu on the wallet node
        if self.status_wallet:
            return None
        # create node context menus
        self.menu = QMenu()
        # action show member
        QT_TRANSLATE_NOOP('WoT.Node', 'Informations')
        self.action_show_member = QAction(QCoreApplication.translate('WoT.Node', 'Informations'), self.scene())
        self.menu.addAction(self.action_show_member)
        self.action_show_member.triggered.connect(self.member_action)
        # action add identity as contact
        QT_TRANSLATE_NOOP('WoT.Node', 'Add as contact')
        self.action_contact = QAction(QCoreApplication.translate('WoT.Node', 'Add as contact'), self.scene())
        self.menu.addAction(self.action_contact)
        self.action_contact.triggered.connect(self.contact_action)
        # action transaction toward identity
        QT_TRANSLATE_NOOP('WoT.Node', 'Send money')
        self.action_transaction = QAction(QCoreApplication.translate('WoT.Node', 'Send money'), self.scene())
        self.menu.addAction(self.action_transaction)
        self.action_transaction.triggered.connect(self.transaction_action)
        # action sign identity
        QT_TRANSLATE_NOOP('WoT.Node', 'Certify identity')
        self.action_sign = QAction(QCoreApplication.translate('WoT.Node', 'Certify identity'), self.scene())
        self.menu.addAction(self.action_sign)
        self.action_sign.triggered.connect(self.sign_action)
        # run menu
        self.menu.exec(event.screenPos())

    def add_arc(self, arc):
        """
        Add arc to the arc list

        :param arc: Arc
        """
        self.arcs.append(arc)

    def member_action(self):
        """
        Transaction action to identity node
        """
        # trigger scene signal
        self.scene().node_member.emit(self.metadata)

    def contact_action(self):
        """
        Transaction action to identity node
        """
        # trigger scene signal
        self.scene().node_contact.emit(self.metadata)

    def sign_action(self):
        """
        Sign identity node
        """
        # trigger scene signal
        self.scene().node_signed.emit(self.metadata)

    def transaction_action(self):
        """
        Transaction action to identity node
        """
        # trigger scene signal
        self.scene().node_transaction.emit(self.metadata)


class Arc(QGraphicsLineItem):
    def __init__(self, source_node, destination_node, metadata):
        """
        Create an arc between two nodes

        :param Node source_node: Source node of the arc
        :param Node destination_node: Destination node of the arc
        :param dict metadata: Arc metadata
        """
        super(Arc, self).__init__()

        self.metadata = metadata
        self.source = source_node
        self.destination = destination_node
        self.source.add_arc(self)

        self.status = self.metadata['status']

        self.source_point = None
        self.destination_point = None
        self.arrow_size = 5.0

        self.setAcceptedMouseButtons(Qt.NoButton)

        #  cursor change on hover
        self.setAcceptHoverEvents(True)
        self.adjust()
        self.setZValue(0)

    def adjust(self):
        """
        Draw the arc line
        """
        if not self.source or not self.destination:
            return
        line = QLineF(
            self.mapFromItem(
                self.source,
                self.source.boundingRect().width() - (self.source.boundingRect().width() / 2.0),
                self.source.boundingRect().height() / 2.0
            ),
            self.mapFromItem(
                self.destination,
                self.destination.boundingRect().width() / 2.0,
                self.destination.boundingRect().height() / 2.0
            )
        )
        self.prepareGeometryChange()
        self.source_point = line.p1()
        self.destination_point = line.p2()

        # mouse over on line only
        self.setLine(line)

    # virtual function require subclassing
    def boundingRect(self):
        """
        Return the bounding rectangle size

        :return: QRectF
        """
        if not self.source or not self.destination:
            return QRectF()
        pen_width = 1.0
        extra = (pen_width + self.arrow_size) / 2.0

        return QRectF(
            self.source_point, QSizeF(
                self.destination_point.x() - self.source_point.x(),
                self.destination_point.y() - self.source_point.y()
            )
        ).normalized().adjusted(
            -extra,
            -extra,
            extra,
            extra
        )

    def paint(self, painter, option, widget):
        """
        Customize line adding an arrow head

        :param QPainter painter: Painter instance of the item
        :param option:  Painter option of the item
        :param widget:  Widget instance
        """
        if not self.source or not self.destination:
            return
        line = QLineF(self.source_point, self.destination_point)
        if qFuzzyCompare(line.length(), 0):
            return

        # Draw the line itself
        color = QColor()
        style = Qt.SolidLine
        if self.status == ARC_STATUS_STRONG:
            color.setNamedColor('blue')
        if self.status == ARC_STATUS_WEAK:
            color.setNamedColor('salmon')
            style = Qt.DashLine

        painter.setPen(QPen(color, 1, style, Qt.RoundCap, Qt.RoundJoin))
        painter.drawLine(line)
        painter.setPen(QPen(color, 1, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))

        # Draw the arrows
        angle = math.acos(line.dx() / line.length())
        if line.dy() >= 0:
            angle = (2.0 * math.pi) - angle

        #  arrow in the middle of the arc
        hpx = line.p1().x() + (line.dx() / 2.0)
        hpy = line.p1().y() + (line.dy() / 2.0)
        head_point = QPointF(hpx, hpy)

        painter.setPen(QPen(color, 1, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
        destination_arrow_p1 = head_point + QPointF(
            math.sin(angle - math.pi / 3) * self.arrow_size,
            math.cos(angle - math.pi / 3) * self.arrow_size)
        destination_arrow_p2 = head_point + QPointF(
            math.sin(angle - math.pi + math.pi / 3) * self.arrow_size,
            math.cos(angle - math.pi + math.pi / 3) * self.arrow_size)

        painter.setBrush(color)
        painter.drawPolygon(QPolygonF([head_point, destination_arrow_p1, destination_arrow_p2]))

        if self.metadata["validation_text"]:
            painter.drawText(head_point, self.metadata["validation_text"])