"""Graph Visualizer for the VH diagram."""
__authors__ = [
"Maxime Rambosson <Maxime.Rambosson@etu.unige.ch>",
]
from PyQt5.QtWidgets import (QWidget, QFrame, QHBoxLayout, QVBoxLayout,
QLabel, QGridLayout)
from PyQt5.QtCore import Qt, QPoint, QSize
from PyQt5.QtGui import QPainter, QPixmap
from .MathTextLabel import MathTextLabel
from dataclasses import dataclass
from enum import Enum, auto
[docs]class INFOSTYPE(Enum):
"""Enumeration of different type of infos."""
CASE = auto()
POINT = auto()
STATE = auto()
[docs]@dataclass
class Infos:
"""Mother-class with common informations uselful for all widget."""
column_id: int
infos_type: INFOSTYPE
connected: bool
def __init__(self, column_id, infos_type, connected):
"""Initialize an Infos instance."""
self.column_id = column_id
self.infos_type = infos_type
self.connected = connected
[docs]@dataclass
class CaseInfos(Infos):
"""Informations to create a case widget."""
centered_text: str
bot_right_text: str
bot_left_text: str
top_right_text: str
top_left_text: str
border_width: int
def __init__(
self,
column_id,
*,
centered_txt: str = "",
bot_right_txt: str = "",
bot_left_txt: str = "",
top_right_txt: str = "",
tot_left_txt: str = "",
border_width: int = 0,
):
"""Initialize a CaseInfos instance."""
super(CaseInfos, self).__init__(column_id, INFOSTYPE.CASE, True)
self.centered_text = centered_txt
self.bot_right_text = bot_right_txt
self.bot_left_text = bot_left_txt
self.top_right_text = top_right_txt
self.top_left_text = tot_left_txt
self.border_width = border_width
[docs]@dataclass
class PointInfos(Infos):
"""Informations to create a widget with a point drew."""
text: str
def __init__(self, column_id, text: str = ""):
"""Initialize a PointInfos instance."""
super(PointInfos, self).__init__(column_id, INFOSTYPE.POINT, True)
self.text = text
[docs]class GraphVisualizerItem(QWidget):
"""Define the mother-class for widget in GraphVisualizer.
Attributes
----------
connected : bool
Indicate if this widget need to be connected with the previous one in
the same column.
"""
def __init__(self):
"""Initialize a GraphVisualizerItem instrance."""
super(GraphVisualizerItem, self).__init__()
self.connected = False
[docs] def get_attach_point_top(self):
"""Get coordinate to connect this widget with the previous one.
Returns
-------
int
Coordinate to link the widget with the previous one.
"""
return self.mapToGlobal(self.rect().center()) # Default behavior
[docs] def get_attach_point_bot(self):
"""Get coordinate to connect this widget with the next one.
Returns
-------
int
Coordinate to link the widget with the next one.
"""
return self.mapToGlobal(self.rect().center()) # Default behavior
[docs]class GraphVisualizerCase(GraphVisualizerItem):
"""Case widget in GraphVisualizer.
Provides the ability to display 5 texts: 2 on the top 2 on the bottom,
and 1 in center. Can have a border.
"""
def __init__(self):
"""Initialize a GraphVisualizerCase instance."""
super(GraphVisualizerCase, self).__init__()
self._v_layout = QVBoxLayout()
self.setLayout(self._v_layout)
top_label_layout = QHBoxLayout()
self._v_layout.addLayout(top_label_layout)
self._top_left_label = MathTextLabel()
top_label_layout.addWidget(self._top_left_label)
self._top_right_label = MathTextLabel()
top_label_layout.addWidget(self._top_right_label)
self._center_widget = QFrame()
self._center_text_label = QLabel()
self._center_text_label.setAlignment(Qt.AlignCenter)
layout = QHBoxLayout()
layout.addWidget(self._center_text_label)
self._center_widget.setLayout(layout)
self._v_layout.addWidget(self._center_widget)
bot_label_layout = QHBoxLayout()
self._v_layout.addLayout(bot_label_layout)
self._bot_left_label = MathTextLabel()
bot_label_layout.addWidget(self._bot_left_label)
self._bot_right_label = MathTextLabel()
bot_label_layout.addWidget(self._bot_right_label)
[docs] def set_central_text(self, text):
"""Set the text at the center."""
self._center_text_label.setText(text)
[docs] def set_bottom_left_text(self, text):
"""Set the text at the bottom left."""
self._bot_left_label.setText(text)
[docs] def set_bottom_rigth_text(self, text):
"""Set the text at the bottom right."""
self._bot_right_label.setText(text)
[docs] def set_top_left_text(self, text):
"""Set the text at the top left."""
self._top_left_label.setText(text)
[docs] def set_top_right_text(self, text):
"""Set the text at the top right."""
self._top_right_label.setText(text)
[docs] def set_central_border(self, width):
"""Set the text at the central border."""
self._center_widget.setFrameShape(QFrame.Box)
self._center_widget.setLineWidth(width)
[docs] def get_attach_point_top(self):
"""Get coordinate to connect this widget with the previous one."""
return self._center_widget.mapToGlobal(
self._center_widget.rect().topLeft()
) + QPoint(self._center_widget.width() / 2, 0)
[docs] def get_attach_point_bot(self):
"""Get coordinate to connect this widget with the next one."""
return self._center_widget.mapToGlobal(
self._center_widget.rect().bottomLeft()
) + QPoint(self._center_widget.width() / 2, 0)
[docs]def prepare_case(infos: CaseInfos):
"""Help to create GraphVisualizerCase from CaseInfos.
Parameters
----------
infos : CaseInfos
Infos to create the widget.
Returns
-------
GraphVisualizerCase
Created widget with given infos.
"""
case = GraphVisualizerCase()
case.connected = infos.connected
case.set_central_text(infos.centered_text)
case.set_bottom_left_text(infos.bot_left_text)
case.set_bottom_rigth_text(infos.bot_right_text)
case.set_top_left_text(infos.top_left_text)
case.set_top_right_text(infos.top_right_text)
if infos.border_width != 0:
case.set_central_border(infos.border_width)
return case
[docs]class GraphVisualizerPointDraw(QWidget):
"""Define an empty widget with a point drew."""
def __init__(self):
"""Initialize a GraphVisualizerPointDraw instance."""
super(GraphVisualizerPointDraw, self).__init__()
self.setMinimumSize(QSize(13, 13))
self.setMaximumSize(QSize(13, 13))
[docs] def paintEvent(self, event): # override paintEvent of QWidget
"""Paint an event."""
painter = QPainter(self)
painter.drawEllipse(self.rect().center(), 6, 6)
painter.setBrush(Qt.black)
painter.drawEllipse(self.rect().center(), 2, 2)
[docs]class GraphVisualizerPoint(GraphVisualizerItem):
"""Widget containing GraphVisualizerPointDraw.
Provides the ability to display 2 texts, one at each side.
"""
def __init__(self):
"""Initialize a GraphVisualizerPoint instance."""
super(GraphVisualizerPoint, self).__init__()
self._layout = QGridLayout()
self.setLayout(self._layout)
self._point_draw = GraphVisualizerPointDraw()
self._layout.addWidget(self._point_draw, 0, 0)
self._label = MathTextLabel()
self._layout.addWidget(self._label, 0, 1)
[docs] def set_text(self, text):
"""Se the text of the label."""
self._label.setText(text)
[docs] def get_attach_point_top(self):
"""Get coordinate to connect this widget with the previous one."""
return self._point_draw.mapToGlobal(self._point_draw.rect().center())
[docs] def get_attach_point_bot(self):
"""Get coordinate to connect this widget with the next one."""
return self._point_draw.mapToGlobal(self._point_draw.rect().center())
[docs]def prepare_point(infos: PointInfos):
"""Help to create GraphVisualizerPoint from PointInfos.
Parameters
----------
infos : PointInfos
Infos to create the widget.
Returns
-------
GraphVisualizerPoint
Created widget with given infos.
"""
point = GraphVisualizerPoint()
point.connected = infos.connected
point.set_text(infos.text)
return point
[docs]@dataclass
class StateInfos(Infos):
"""Information to create a widget with a line of diagram inside."""
S1_filename: str
S2_filename: str
distance: int
top_texts: list
bot_texts: list
def __init__(
self,
column_id,
*,
S1_filename=None,
S2_filename=None,
event_filename=None,
distance=1,
):
"""Initialize a StateInfos instance."""
super(StateInfos, self).__init__(column_id, INFOSTYPE.STATE, False)
self.distance = distance
self.S1_filename = S1_filename
self.S2_filename = S2_filename
self.event_filename = event_filename
self.top_texts = []
self.bot_texts = []
[docs]class GraphVisualizerState(GraphVisualizerItem):
"""Widget containing drawings.
Provides the ability to display 4 texts on top & bottom.
"""
_max_height = 128
_offset = 10
def __init__(self):
"""Initialize a GraphVisualizerState instance."""
super(GraphVisualizerState, self).__init__()
self.done = False
layout = QGridLayout()
layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(layout)
self._bg_container = QLabel()
self._bg_container.resize(self._max_height, self._max_height)
layout.addWidget(self._bg_container, 1, 0, 1, 4)
self._bg = QPixmap(self._max_height, self._max_height)
self._bg_container.setPixmap(self._bg)
self._S1_pixmap = QPixmap()
self._S2_pixmap = QPixmap()
self._event_pixmap = QPixmap()
self._distance = 1
self._top_labels = []
self._bot_labels = []
for i in range(4):
self._top_labels.append(MathTextLabel(self))
layout.addWidget(
self._top_labels[i], 0, i, alignment=Qt.AlignCenter)
self._bot_labels.append(MathTextLabel(self))
layout.addWidget(
self._bot_labels[i], 2, i, alignment=Qt.AlignCenter)
[docs] def set_first_star(self, filename):
"""Load the picture 'filename' and resize it if needed.
Parameters
----------
filename : str
Name (+ path) to the picture.
Returns
-------
bool
Indicate if loading & resize success.
"""
if not self._S1_pixmap.load(filename):
return False
if self._S1_pixmap.height() > self._max_height:
self._S1_pixmap = self._S1_pixmap.scaledToHeight(self._max_height)
return True
[docs] def set_second_star(self, filename):
"""Load the picture 'filename' and resize it if needed.
Parameters
----------
filename : str
Name (+ path) to the picture.
Returns
-------
bool
Indicate if loading & resize success.
"""
if not self._S2_pixmap.load(filename):
return False
if self._S2_pixmap.height() > self._max_height:
self._S2_pixmap = self._S2_pixmap.scaledToHeight(self._max_height)
return True
[docs] def set_event(self, filename):
"""Load the picture 'filename' and resize it if needed.
Parameters
----------
filename : str
Name (+ path) to the picture.
Returns
-------
bool
Indicate if loading & resize success.
"""
if not self._event_pixmap.load(filename):
return False
if self._event_pixmap.height() > self._max_height:
self._event_pixmap = self._event_pixmap.scaledToHeight(
self._max_height)
return True
[docs] def set_distance(self, dist):
"""Set the distance."""
if dist > 1 or dist < 0:
print("`dist` needs to be normalised.")
self._distance = dist
[docs] def set_top_text(self, text, index):
"""Set the text of the top label."""
if index >= len(self._top_labels):
print(f"Can't set text to top label {index}")
return
self._top_labels[index].setText(text)
[docs] def set_bot_text(self, text, index):
"""Se the text of the bottom label."""
if index >= len(self._bot_labels):
print(f"Can't set text to bot label {index}")
return
self._bot_labels[index].setText(text)
[docs] def get_attach_point_top(self):
"""Get coordinate to connect this widget with the previous one."""
return self._bg_container.mapToGlobal(
self._bg_container.rect().topLeft()
) + QPoint(self._bg_container.width() / 2, 0)
[docs] def get_attach_point_bot(self):
"""Get coordinate to connect this widget with the next one."""
return self._bg_container.mapToGlobal(
self._bg_container.rect().bottomLeft()
) + QPoint(self._bg_container.width() / 2, 0)
[docs] def resizeEvent(self, event):
"""Resize the event."""
self._bg_container.resize(event.size().width(),
self._bg_container.height())
# Offset is used here to keep the possibility to scale down
self._bg = QPixmap(self._bg_container.width() - self._offset,
self._max_height)
self._bg_container.setPixmap(self._bg)
[docs] def paintEvent(self, event): # override paintEvent of QWidget
"""Paint the event."""
self._bg.fill()
painter = QPainter(self._bg)
x_S1 = ((self._bg.width() / 2 - self._S1_pixmap.width())
* (1 - self._distance))
y_S1 = self._bg.height() / 2 - self._S1_pixmap.height() / 2
painter.drawPixmap(x_S1, y_S1, self._S1_pixmap)
x_S2 = (
self._bg.width() / 2
+ (self._bg.width() / 2 - self._S2_pixmap.width()) * self._distance
)
y_S2 = self._bg.height() / 2 - self._S2_pixmap.height() / 2
painter.drawPixmap(x_S2, y_S2, self._S2_pixmap)
if not self._event_pixmap.isNull():
x_event = self._bg.width() / 2 - self._event_pixmap.width() / 2
painter.drawPixmap(x_event, 0, self._event_pixmap)
self._bg_container.setPixmap(self._bg)
[docs]def prepare_state(infos: StateInfos):
"""Help to create GraphVisualizerState from StateInfos.
Parameters
----------
infos : StateInfos
Infos to create the widget.
Returns
-------
GraphVisualizerState
Created widget with given infos.
"""
state = GraphVisualizerState()
state.connected = infos.connected
if infos.S1_filename:
if not state.set_first_star(infos.S1_filename):
print(f"Can't load {infos.S1_filename} as S1")
if infos.S2_filename:
if not state.set_second_star(infos.S2_filename):
print(f"Can't load {infos.S2_filename} as S2")
if infos.event_filename:
if not state.set_event(infos.event_filename):
print(f"Can't load {infos.event_filename} as event")
state.set_distance(infos.distance)
for i in range(len(infos.top_texts)):
state.set_top_text(infos.top_texts[i], i)
for i in range(len(infos.bot_texts)):
state.set_bot_text(infos.bot_texts[i], i)
return state
[docs]class columnTYPE(Enum):
"""Enumeration of different column type."""
TIMELINE = auto()
CONNECTED = auto()
[docs]@dataclass
class ConnectedItem:
"""Represent a visual link between 2 widgets."""
from_item: GraphVisualizerItem
to_item: GraphVisualizerItem
def __init__(self, from_item: GraphVisualizerItem,
to_item: GraphVisualizerItem):
"""Initialize a ConnectedItem instance."""
self.from_item = from_item
self.to_item = to_item
[docs]class GraphVisualizercolumn:
"""Mother-class of visual column in GraphVisualizer.
Manage one visual column in the QGridLayout
(can take several logical columns).
"""
def __init__(self, grid, column_id, column_span):
"""Initialize a GraphVisualizercolumn instance.
Parameters
----------
grid : QGridLayout
Grid where column take place.
column_id : int
Unique id of this column.
column_span : int
Number of logical column used.
"""
self._column_id = column_id
self._row_index = 0
self._column_span = column_span
self._grid = grid
self._items = []
self._create_title_label()
self._connected_items = []
self._last_item = None
[docs] def set_title(self, title):
"""Set the title."""
self._title_label.setText(title)
[docs] def add_item(self, item):
"""Add one item in the column, connect it to previous if needed.
Parameters
----------
item : GraphVisualizerItem
Widget to add in column.
"""
self._add_widget(item)
self._items.append(item)
if (
item.connected and self._last_item is not None
): # It's not the first item added ::
self._connected_items.append(ConnectedItem(self._last_item, item))
self._last_item = item
[docs] def clear(self):
"""Delete all widget in the column, keep the title."""
self._last_item = None
self._connected_items = []
for item in self._items:
item.deleteLater()
self._items = []
self._row_index = 1
[docs] def reset(self):
"""Delete everythings (widget + title), reset logical column used."""
self._last_item = None
self._connected_items = []
self._title_label.deleteLater()
for item in self._items:
item.deleteLater()
for i in range(self._column_span):
self._grid.setColumnStretch(self._column_id + i, 0)
self._items = []
self._row_index = 0
[docs] def skip(self):
"""Skip one row."""
self._row_index += 1
[docs] def get_id(self):
"""Return the column id."""
return self._column_id
def _create_title_label(self):
self._title_label = QLabel()
self._title_label.setAlignment(Qt.AlignCenter)
self._add_widget(self._title_label)
def _add_widget(self, widget):
"""Add widget to the logical column used.
Parameters
----------
widget : QWidget
Widget to add.
"""
self._grid.addWidget(
widget, self._row_index, self._column_id, 1, self._column_span
)
self._row_index += 1
[docs]class GraphVisualizerConnectedcolumn(GraphVisualizercolumn):
"""Simple visual column with arrow between connected widget."""
def __init__(self, grid, column_id, column_span=1):
"""Initialize a GraphVisualizerConnectedcolumn instance."""
super(GraphVisualizerConnectedcolumn, self).__init__(
grid, column_id, column_span
)
for i in range(column_span):
self._grid.setColumnStretch(self._column_id + i, 1)
[docs] def draw(self, surface):
"""Draw the surface."""
painter = QPainter(surface)
for connection in self._connected_items:
start = surface.mapFromGlobal(
connection.from_item.get_attach_point_bot())
end = surface.mapFromGlobal(
connection.to_item.get_attach_point_top())
painter.drawLine(start, end)
left = end + QPoint(-4, -8)
rigth = end + QPoint(4, -8)
painter.setBrush(Qt.black)
painter.drawPolygon(end, left, rigth)
[docs]class GraphVisualizerTimeline(GraphVisualizercolumn):
"""Draw a visual column, compressed by another other column."""
def __init__(self, grid, column_id, column_span=1):
"""Initialize a GraphVisualizerTimeline instance."""
super(GraphVisualizerTimeline, self).__init__(
grid, column_id, column_span)
for i in range(column_span):
self._grid.setColumnStretch(self._column_id + i, 0)
[docs] def draw(self, surface):
"""Draw the surface."""
painter = QPainter(surface)
for connection in self._connected_items:
start = surface.mapFromGlobal(
connection.from_item.get_attach_point_bot())
end = surface.mapFromGlobal(
connection.to_item.get_attach_point_top())
painter.drawLine(start, end)
[docs]class GraphVisualizer(QWidget):
"""Widget used to display the different columns and add widget in them."""
def __init__(self):
"""Initialize a GraphVisualizer instance."""
super(GraphVisualizer, self).__init__()
self._layout = QGridLayout()
self.setLayout(self._layout)
self._next_column = 0
self._columns = []
[docs] def add_column(self, column_type, column_span=1):
"""Create a column according to the column_type.
It takes account for the column_span of the logical column.
Parameters
----------
column_type : columnTYPE
Type of the column to add.
column_span : int
Nb of logical column used.
Returns
-------
int
ID of created column.
"""
if column_type == columnTYPE.TIMELINE:
self._columns.append(
GraphVisualizerTimeline(
self._layout, self._next_column, column_span)
)
elif column_type == columnTYPE.CONNECTED:
self._columns.append(
GraphVisualizerConnectedcolumn(
self._layout, self._next_column, column_span)
)
self._next_column += column_span
return len(self._columns) - 1
[docs] def get_column(self, column_id):
"""Return visual column with the given id.
Parameters
----------
column_id : int
ID of searched column.
Returns
-------
GraphVisualizercolumn
column with the given ID.
"""
if column_id < len(self._columns):
return self._columns[column_id]
return None
[docs] def add_line(self, infos):
"""Add all lines based on all elements in `infos`.
For each info in infos, create the corresponding widget and add it to
the column with corresponding id (given in each info), only 1 widget
per column by call, if one column haven't any widget associated,
column skip this row.
Parameters
----------
infos : Array of Infos
Array with derivated struct of Infos to create the different
widget for this line.
"""
for column in self._columns:
info = next((x for x in infos if (
lambda info: info.column_id == column.get_id())(x)), None)
if info is not None:
item = None
if info.infos_type == INFOSTYPE.CASE:
item = prepare_case(info)
elif info.infos_type == INFOSTYPE.POINT:
item = prepare_point(info)
elif info.infos_type == INFOSTYPE.STATE:
item = prepare_state(info)
if item is not None:
column.add_item(item)
else:
column.skip()
[docs] def clear(self):
"""Clear each column."""
for col in self._columns:
col.clear()
[docs] def reset(self):
"""Clear all delete each column."""
for col in self._columns:
col.reset()
self._next_column = 0
self._columns = []
[docs] def paintEvent(self, event): # override paintEvent of QWidget
"""Paint the event."""
for col in self._columns:
col.draw(self)
[docs] def saveAsPicture(self, filename):
"""Render this widget in a picture and save it a filename.
Parameters
----------
filename : str
Destination file of the picture.
"""
pixmap = QPixmap(self.size())
self.render(pixmap)
if not pixmap.save(filename):
print("Can't save as " + filename)