######################################################################################################################
# Copyright (C) 2017-2021 Spine project consortium
# This file is part of Spine Toolbox.
# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option)
# any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General
# Public License for more details. You should have received a copy of the GNU Lesser General Public License along with
# this program. If not, see <http://www.gnu.org/licenses/>.
######################################################################################################################
"""
Classes for drawing graphics items on graph view's QGraphicsScene.
:authors: M. Marin (KTH), P. Savolainen (VTT)
:date: 4.4.2018
"""
from PySide2.QtCore import Qt, Signal, Slot, QLineF, QPointF
from PySide2.QtSvg import QGraphicsSvgItem
from PySide2.QtWidgets import (
QAction,
QGraphicsItem,
QGraphicsTextItem,
QGraphicsRectItem,
QGraphicsEllipseItem,
QGraphicsPathItem,
QStyle,
QApplication,
QMenu,
)
from PySide2.QtGui import QPen, QBrush, QPainterPath, QPalette, QGuiApplication
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt5agg import FigureCanvas # pylint: disable=no-name-in-module
from spinetoolbox.widgets.custom_qwidgets import TitleWidgetAction
[docs]class EntityItem(QGraphicsRectItem):
"""Base class for ObjectItem and RelationshipItem."""
def __init__(self, spine_db_editor, x, y, extent, db_map_entity_id):
"""
Args:
spine_db_editor (SpineDBEditor): 'owner'
x (float): x-coordinate of central point
y (float): y-coordinate of central point
extent (int): Preferred extent
db_map_entity_id (tuple): db_map, entity id
"""
super().__init__()
self._spine_db_editor = spine_db_editor
self.db_mngr = spine_db_editor.db_mngr
self.db_map_entity_id = db_map_entity_id
self.arc_items = list()
self._extent = extent
self.setRect(-0.5 * self._extent, -0.5 * self._extent, self._extent, self._extent)
self.setPen(Qt.NoPen)
self._svg_item = QGraphicsSvgItem(self)
self._svg_item.setCacheMode(QGraphicsItem.CacheMode.NoCache) # Needed for the exported pdf to be vector
self.refresh_icon()
self.setPos(x, y)
self._moved_on_scene = False
self._bg = None
self._init_bg()
self._bg_brush = Qt.NoBrush
self.setZValue(0)
self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=True)
self.setFlag(QGraphicsItem.ItemIsMovable, enabled=True)
self.setFlag(QGraphicsItem.ItemIgnoresTransformations, enabled=True)
self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges, enabled=True)
self.setAcceptHoverEvents(True)
self.setCursor(Qt.ArrowCursor)
self.setToolTip(self._make_tool_tip())
@property
[docs] def entity_type(self):
raise NotImplementedError()
@property
[docs] def entity_name(self):
return self.db_mngr.get_item(self.db_map, self.entity_type, self.entity_id)["name"]
@property
[docs] def entity_class_type(self):
return {"relationship": "relationship_class", "object": "object_class"}[self.entity_type]
@property
[docs] def entity_class_id(self):
return self.db_mngr.get_item(self.db_map, self.entity_type, self.entity_id)["class_id"]
@property
[docs] def entity_class_name(self):
return self.db_mngr.get_item(self.db_map, self.entity_class_type, self.entity_class_id)["name"]
@property
[docs] def db_map(self):
return self.db_map_entity_id[0]
@property
[docs] def entity_id(self):
return self.db_map_entity_id[1]
@property
[docs] def first_db_map(self):
return self.db_map
@property
[docs] def display_data(self):
return self.entity_name
@property
[docs] def display_database(self):
return self.db_map.codename
@property
[docs] def db_maps(self):
return (self.db_map,)
[docs] def db_map_data(self, _db_map):
# NOTE: Needed by EditObjectsDialog and EditRelationshipsDialog
return self.db_mngr.get_item(self.db_map, self.entity_type, self.entity_id)
[docs] def db_map_id(self, _db_map):
# NOTE: Needed by EditObjectsDialog and EditRelationshipsDialog
return self.entity_id
[docs] def boundingRect(self):
return super().boundingRect() | self.childrenBoundingRect()
[docs] def moveBy(self, dx, dy):
super().moveBy(dx, dy)
self.update_arcs_line()
[docs] def _init_bg(self):
self._bg = QGraphicsRectItem(self.boundingRect(), self)
self._bg.setPen(Qt.NoPen)
self._bg.setFlag(QGraphicsItem.ItemStacksBehindParent, enabled=True)
[docs] def refresh_icon(self):
"""Refreshes the icon."""
renderer = self.db_mngr.entity_class_renderer(self.db_map, self.entity_class_type, self.entity_class_id)
self._set_renderer(renderer)
[docs] def _set_renderer(self, renderer):
self._svg_item.setSharedRenderer(renderer)
size = renderer.defaultSize()
scale = self._extent / max(size.width(), size.height())
self._svg_item.setScale(scale)
self._svg_item.setPos(self.rect().center() - 0.5 * scale * QPointF(size.width(), size.height()))
[docs] def shape(self):
"""Returns a shape containing the entire bounding rect, to work better with icon transparency."""
path = QPainterPath()
path.setFillRule(Qt.WindingFill)
path.addRect(self._bg.boundingRect())
return path
[docs] def paint(self, painter, option, widget=None):
"""Shows or hides the selection halo."""
if option.state & (QStyle.State_Selected):
self._paint_as_selected()
option.state &= ~QStyle.State_Selected
else:
self._paint_as_deselected()
super().paint(painter, option, widget)
[docs] def _paint_as_selected(self):
self._bg.setBrush(QGuiApplication.palette().highlight())
[docs] def _paint_as_deselected(self):
self._bg.setBrush(self._bg_brush)
[docs] def add_arc_item(self, arc_item):
"""Adds an item to the list of arcs.
Args:
arc_item (ArcItem)
"""
self.arc_items.append(arc_item)
arc_item.update_line()
[docs] def apply_zoom(self, factor):
"""Applies zoom.
Args:
factor (float): The zoom factor.
"""
if factor > 1:
factor = 1
self.setScale(factor)
[docs] def apply_rotation(self, angle, center):
"""Applies rotation.
Args:
angle (float): The angle in degrees.
center (QPointF): Rotates around this point.
"""
line = QLineF(center, self.pos())
line.setAngle(line.angle() + angle)
self.setPos(line.p2())
self.update_arcs_line()
[docs] def block_move_by(self, dx, dy):
self.moveBy(dx, dy)
[docs] def mouseMoveEvent(self, event):
"""Moves the item and all connected arcs.
Args:
event (QGraphicsSceneMouseEvent)
"""
if event.buttons() & Qt.LeftButton == 0:
super().mouseMoveEvent(event)
return
move_by = event.scenePos() - event.lastScenePos()
# Move selected items together
for item in self.scene().selectedItems():
if isinstance(item, (EntityItem)):
item.block_move_by(move_by.x(), move_by.y())
[docs] def update_arcs_line(self):
"""Moves arc items."""
for item in self.arc_items:
item.update_line()
[docs] def itemChange(self, change, value):
"""
Keeps track of item's movements on the scene.
Args:
change (GraphicsItemChange): a flag signalling the type of the change
value: a value related to the change
Returns:
the same value given as input
"""
if change == QGraphicsItem.ItemScenePositionHasChanged:
self._moved_on_scene = True
return value
[docs] def set_all_visible(self, on):
"""Sets visibility status for this item and all arc items.
Args:
on (bool)
"""
for item in self.arc_items:
item.setVisible(on)
self.setVisible(on)
[docs]class RelationshipItem(EntityItem):
"""Represents a relationship in the Entity graph."""
def __init__(self, spine_db_editor, x, y, extent, db_map_entity_id):
"""Initializes the item.
Args:
spine_db_editor (GraphViewForm): 'owner'
x (float): x-coordinate of central point
y (float): y-coordinate of central point
extent (int): preferred extent
db_map_entity_id (tuple): db_map, relationship id
"""
super().__init__(spine_db_editor, x, y, extent, db_map_entity_id=db_map_entity_id)
@property
[docs] def entity_type(self):
return "relationship"
@property
[docs] def object_class_id_list(self):
return self.db_mngr.get_item(self.db_map, "relationship_class", self.entity_class_id)["object_class_id_list"]
@property
[docs] def object_name_list(self):
return self.db_mngr.get_item(self.db_map, "relationship", self.entity_id)["object_name_list"]
@property
[docs] def object_id_list(self):
return self.db_mngr.get_item(self.db_map, "relationship", self.entity_id)["object_id_list"]
@property
[docs] def entity_class_name(self):
return self.db_mngr.get_item(self.db_map, "relationship", self.entity_id)["class_name"]
@property
[docs] def db_representation(self):
return dict(
class_id=self.entity_class_id,
id=self.entity_id,
object_id_list=self.object_id_list,
object_name_list=self.object_name_list,
)
f"""{self.object_name_list.replace(",", self.db_mngr._GROUP_SEP)}<br>"""
f"""@{self.db_map.codename}</p></html>"""
)
[docs] def _init_bg(self):
extent = self._extent
self._bg = QGraphicsEllipseItem(-0.5 * extent, -0.5 * extent, extent, extent, self)
self._bg.setPen(Qt.NoPen)
bg_color = QGuiApplication.palette().color(QPalette.Normal, QPalette.Window)
bg_color.setAlphaF(0.8)
self._bg_brush = QBrush(bg_color)
[docs] def follow_object_by(self, dx, dy):
factor = 1.0 / len(set(arc.obj_item for arc in self.arc_items))
self.moveBy(factor * dx, factor * dy)
[docs]class ObjectItem(EntityItem):
"""Represents an object in the Entity graph."""
def __init__(self, spine_db_editor, x, y, extent, db_map_entity_id):
"""Initializes the item.
Args:
spine_db_editor (GraphViewForm): 'owner'
x (float): x-coordinate of central point
y (float): y-coordinate of central point
extent (int): preferred extent
db_map_entity_id (tuple): db_map, object id
"""
super().__init__(spine_db_editor, x, y, extent, db_map_entity_id=db_map_entity_id)
self._relationship_classes = {}
self.label_item = ObjectLabelItem(self)
self.setZValue(0.5)
self.update_name(self.entity_name)
@property
[docs] def entity_type(self):
return "object"
@property
[docs] def db_representation(self):
return dict(class_id=self.entity_class_id, id=self.entity_id, name=self.entity_name)
[docs] def shape(self):
path = super().shape()
path.addPolygon(self.label_item.mapToItem(self, self.label_item.boundingRect()))
return path
[docs] def update_name(self, name):
"""Refreshes the name."""
self.label_item.setPlainText(name)
[docs] def block_move_by(self, dx, dy):
super().block_move_by(dx, dy)
rel_items_follow = self._spine_db_editor.qsettings.value(
"appSettings/relationshipItemsFollow", defaultValue="true"
)
if rel_items_follow == "false":
return
rel_items = {arc_item.rel_item for arc_item in self.arc_items}
for rel_item in rel_items:
rel_item.follow_object_by(dx, dy)
[docs] def mouseDoubleClickEvent(self, e):
add_relationships_menu = QMenu(self._spine_db_editor)
title = TitleWidgetAction("Add relationships", self._spine_db_editor)
add_relationships_menu.addAction(title)
add_relationships_menu.triggered.connect(self._start_relationship)
self._refresh_relationship_classes()
self._populate_add_relationships_menu(add_relationships_menu)
add_relationships_menu.popup(e.screenPos())
[docs] def _refresh_relationship_classes(self):
self._relationship_classes.clear()
db_map_object_ids = {self.db_map: {self.entity_id}}
relationship_ids_per_class = {}
for rel in self.db_mngr.find_cascading_relationships(db_map_object_ids).get(self.db_map, []):
relationship_ids_per_class.setdefault(rel["class_id"], set()).add((self.db_map, rel["id"]))
db_map_object_class_ids = {self.db_map: {self.entity_class_id}}
for rel_cls in self.db_mngr.find_cascading_relationship_classes(db_map_object_class_ids).get(self.db_map, []):
rel_cls = rel_cls.copy()
rel_cls["object_class_id_list"] = [int(id_) for id_ in rel_cls["object_class_id_list"].split(",")]
rel_cls["relationship_ids"] = relationship_ids_per_class.get(rel_cls["id"], set())
self._relationship_classes[rel_cls["name"]] = rel_cls
[docs] def _populate_expand_collapse_menu(self, menu):
"""
Populates the 'Expand' or 'Collapse' menu.
Args:
menu (QMenu)
"""
if not self._relationship_classes:
menu.setEnabled(False)
return
menu.setEnabled(True)
menu.addAction("All")
menu.addSeparator()
for name, rel_cls in self._relationship_classes.items():
icon = self.db_mngr.entity_class_icon(self.db_map, "relationship_class", rel_cls["id"])
menu.addAction(icon, name).setEnabled(bool(rel_cls["relationship_ids"]))
[docs] def _get_relationship_ids_to_expand_or_collapse(self, action):
rel_cls = self._relationship_classes.get(action.text())
if rel_cls is not None:
return rel_cls["relationship_ids"]
return {id_ for rel_cls in self._relationship_classes.values() for id_ in rel_cls["relationship_ids"]}
@Slot(QAction)
[docs] def _expand(self, action):
relationship_ids = self._get_relationship_ids_to_expand_or_collapse(action)
self._spine_db_editor.added_relationship_ids.update(relationship_ids)
self._spine_db_editor.build_graph(persistent=True)
@Slot(QAction)
[docs] def _collapse(self, action):
relationship_ids = self._get_relationship_ids_to_expand_or_collapse(action)
self._spine_db_editor.added_relationship_ids.difference_update(relationship_ids)
self._spine_db_editor.build_graph(persistent=True)
@Slot(QAction)
[docs] def _start_relationship(self, action):
self._spine_db_editor.start_relationship(self._relationship_classes[action.text()], self)
[docs]class ArcItem(QGraphicsPathItem):
"""Connects a RelationshipItem to an ObjectItem."""
def __init__(self, rel_item, obj_item, width):
"""Initializes item.
Args:
rel_item (spinetoolbox.widgets.graph_view_graphics_items.RelationshipItem): relationship item
obj_item (spinetoolbox.widgets.graph_view_graphics_items.ObjectItem): object item
width (float): Preferred line width
"""
super().__init__()
self.rel_item = rel_item
self.obj_item = obj_item
self._width = float(width)
self._pen = self._make_pen()
self.setPen(self._pen)
self.setZValue(-2)
rel_item.add_arc_item(self)
obj_item.add_arc_item(self)
self.setCursor(Qt.ArrowCursor)
self.update_line()
[docs] def _make_pen(self):
pen = QPen()
pen.setWidth(self._width)
color = QGuiApplication.palette().color(QPalette.Normal, QPalette.WindowText)
color.setAlphaF(0.8)
pen.setColor(color)
pen.setStyle(Qt.SolidLine)
pen.setCapStyle(Qt.RoundCap)
return pen
[docs] def moveBy(self, dx, dy):
"""Does nothing. This item is not moved the regular way, but follows the EntityItems it connects."""
[docs] def update_line(self):
overlapping_arcs = [arc for arc in self.rel_item.arc_items if arc.obj_item == self.obj_item]
count = len(overlapping_arcs)
path = QPainterPath(self.rel_item.pos())
if count == 1:
path.lineTo(self.obj_item.pos())
else:
rank = overlapping_arcs.index(self)
line = QLineF(self.rel_item.pos(), self.obj_item.pos())
line.setP1(line.center())
line = line.normalVector()
line.setLength(self._width * count)
line.setP1(2 * line.p1() - line.p2())
t = rank / (count - 1)
ctrl_point = line.pointAt(t)
path.quadTo(ctrl_point, self.obj_item.pos())
self.setPath(path)
[docs] def mousePressEvent(self, event):
"""Accepts the event so it's not propagated."""
event.accept()
[docs] def other_item(self, item):
return {self.rel_item: self.obj_item, self.obj_item: self.rel_item}.get(item)
[docs] def apply_zoom(self, factor):
"""Applies zoom.
Args:
factor (float): The zoom factor.
"""
if factor < 1:
factor = 1
scaled_width = self._width / factor
self._pen.setWidthF(scaled_width)
self.setPen(self._pen)
[docs]class CrossHairsItem(RelationshipItem):
"""Creates new relationships directly in the graph."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=False)
self.setZValue(2)
self._current_icon = None
@property
[docs] def entity_class_name(self):
return None
@property
[docs] def entity_name(self):
return None
[docs] def refresh_icon(self):
renderer = self.db_mngr.get_icon_mngr(self.db_map).icon_renderer("\uf05b", 0)
self._set_renderer(renderer)
[docs] def set_plus_icon(self):
self.set_icon("\uf067", Qt.blue)
[docs] def set_check_icon(self):
self.set_icon("\uf00c", Qt.green)
[docs] def set_normal_icon(self):
self.set_icon("\uf05b")
[docs] def set_ban_icon(self):
self.set_icon("\uf05e", Qt.red)
[docs] def set_icon(self, unicode, color=0):
"""Refreshes the icon."""
if (unicode, color) == self._current_icon:
return
renderer = self.db_mngr.get_icon_mngr(self.db_map).icon_renderer(unicode, color)
self._set_renderer(renderer)
self._current_icon = (unicode, color)
[docs] def mouseMoveEvent(self, event):
move_by = event.scenePos() - self.scenePos()
self.block_move_by(move_by.x(), move_by.y())
[docs] def block_move_by(self, dx, dy):
self.moveBy(dx, dy)
rel_items = {arc_item.rel_item for arc_item in self.arc_items}
for rel_item in rel_items:
rel_item.follow_object_by(dx, dy)
[docs]class CrossHairsRelationshipItem(RelationshipItem):
"""Represents the relationship that's being created using the CrossHairsItem."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=False)
[docs] def refresh_icon(self):
"""Refreshes the icon."""
obj_items = [arc_item.obj_item for arc_item in self.arc_items]
object_class_name_list = [
obj_item.entity_class_name for obj_item in obj_items if not isinstance(obj_item, CrossHairsItem)
]
object_class_name_list = ",".join(object_class_name_list)
renderer = self.db_mngr.get_icon_mngr(self.db_map).relationship_renderer(object_class_name_list)
self._set_renderer(renderer)
[docs]class CrossHairsArcItem(ArcItem):
"""Connects a CrossHairsRelationshipItem with the CrossHairsItem,
and with all the ObjectItem's in the relationship so far.
"""
[docs] def _make_pen(self):
pen = super()._make_pen()
pen.setStyle(Qt.DotLine)
color = pen.color()
color.setAlphaF(0.5)
pen.setColor(color)
return pen
[docs]class ObjectLabelItem(QGraphicsTextItem):
"""Provides a label for ObjectItem's."""
[docs] entity_name_edited = Signal(str)
def __init__(self, entity_item):
"""Initializes item.
Args:
entity_item (spinetoolbox.widgets.graph_view_graphics_items.EntityItem): The parent item.
"""
super().__init__(entity_item)
self.entity_item = entity_item
self._font = QApplication.font()
self._font.setPointSize(11)
self.setFont(self._font)
self.bg = QGraphicsRectItem(self)
self.bg_color = QGuiApplication.palette().color(QPalette.Normal, QPalette.ToolTipBase)
self.bg_color.setAlphaF(0.8)
self.bg.setBrush(QBrush(self.bg_color))
self.bg.setPen(Qt.NoPen)
self.bg.setFlag(QGraphicsItem.ItemStacksBehindParent)
self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=False)
self.setAcceptHoverEvents(False)
[docs] def setPlainText(self, text):
"""Set texts and resets position.
Args:
text (str)
"""
super().setPlainText(text)
self.reset_position()
[docs] def reset_position(self):
"""Adapts item geometry so text is always centered."""
rectf = self.boundingRect()
x = -rectf.width() / 2
y = rectf.height() + 4
self.setPos(x, y)
self.bg.setRect(self.boundingRect())