"""
Module for graph drawing on a canvas in jupyter notebook.
.. currentmodule:: pyrigi.graph_drawer
Classes:
.. autosummary::
GraphDrawer
"""
from ipywidgets import Output, ColorPicker, HBox, VBox, IntSlider, Checkbox
from ipycanvas import MultiCanvas, hold_canvas
from IPython.display import display
from pyrigi.graph import Graph
from pyrigi.framework import Framework
from ipyevents import Event
import networkx as nx
import numpy as np
[docs]
class GraphDrawer(object):
"""
Class for graph drawing.
An instance of this class creates a canvas and
takes mouse inputs in order to construct a graph. The vertices of the graph
will be labeled using non-negative integers. Supported inputs are listed below.
- Press mouse button on an empty place on canvas:
Add a vertex at the pointer position.
- Press mouse button on an existing vertex (or empty space) and release the mouse button on another vertex (or empty space):
Add/remove an edge between the two vertices.
- Drag a vertex with ``Ctrl`` is being pressed:
Reposition the vertex.
- Double click on an existing vertex:
Remove the corresponding vertex.
- Double click on an existing edge:
Remove the corresponding edge.
Parameters
----------
graph:
(optional) A graph without loops which is to be drawn on canvas
when the object is created. The non-integer labels are relabeled
layout_type:
Layout type to visualise the ``graph``.
For supported layout types see :meth:`.Graph.layout`.
The default is ``spring``.
If ``graph`` is ``None`` or empty this parameter will not have any effect.
place:
The part of the canvas that will be used for drawing ``graph``.
Options are ``all`` (default, use all canvas), ``E`` (use the east part),
``W`` (use the west part), ``N`` (use the north part), ``S`` (use the south part),
and also ``NE``, ``NW``, ``SE`` and ``SW``.
If ``graph`` is ``None`` or empty this parameter will not have any effect.
Examples
--------
>>> from pyrigi import GraphDrawer
>>> Drawer = GraphDrawer()
HBox(children=(MultiCanvas(height=600, width=600), VBox(children=(ColorPicker(value='blue', description='V-Color'), ColorPicker(value='black', description='E-Color'), IntSlider(value=10, description='V-Size:', max=20, min=8), IntSlider(value=2, description='E-Size:', max=10, min=1), Checkbox(value=True, description='Show V-Labels', indent=False)))))
Output()
press and hold ctrl key to move vertices around with mouse.
>>> Drawer.graph()
Graph with vertices [] and edges []
TODO
----
- Add width/height parameters to canvas. Currently width=600 and height=600 are fixed.
- Add a background grid option.
""" # noqa: E501
def __init__(
self, graph: Graph = None, layout_type: str = "spring", place: str = "all"
) -> None:
"""
Constructor of the class.
"""
self._radius = 10
self._ewidth = 2
self._v_color = "blue" # default color for vertices
self._e_color = "black" # default color for edges
self._selected_vertex = None # this determines what vertex to update on canvas
self._next_vertex_label = 0 # label for next vertex
self._show_vlabels = True
self._mouse_down = False
self._vertexmove_on = False
self._graph = Graph() # the graph on canvas
self._out = Output() # can later be used to represent some properties
# setting multicanvas properties
self._mcanvas = MultiCanvas(4, width=600, height=600)
self._mcanvas[0].stroke_rect(0, 0, self._mcanvas.width, self._mcanvas.height)
self._mcanvas[2].font = "12px serif"
self._mcanvas[2].text_align = "center"
self._mcanvas[2].text_baseline = "middle"
self._mcanvas[3].font = "12px serif"
self._mcanvas[3].text_align = "center"
self._mcanvas[3].text_baseline = "middle"
self._mcanvas.on_mouse_down(self._handle_mouse_down)
self._mcanvas.on_mouse_up(self._handle_mouse_up)
self._mcanvas.on_mouse_move(self._handle_mouse_move)
self._mcanvas.on_mouse_out(self._handle_mouse_out)
# IpyEvents Part
self._events = Event()
self._events.source = self._mcanvas
self._events.watched_events = ["keydown", "keyup", "dblclick"]
self._events.on_dom_event(self._handle_event)
# color picker for the new vertices
self._vcolor_picker = ColorPicker(
concise=False, description="V-Color", value=self._v_color, disabled=False
)
self._vcolor_picker.observe(self._on_vcolor_change)
# color picker for the new edges
self._ecolor_picker = ColorPicker(
concise=False, description="E-Color", value=self._e_color, disabled=False
)
self._ecolor_picker.observe(self._on_ecolor_change)
# setting radius for vertices
self._vradius_slider = IntSlider(
value=self._radius,
min=8,
max=20,
step=1,
description="V-Size:",
disabled=False,
continuous_update=True,
orientation="horizontal",
readout=True,
readout_format="d",
)
self._vradius_slider.observe(self._on_vradius_change)
# setting line width for the edges
self._ewidth_slider = IntSlider(
value=self._ewidth,
min=1,
max=10,
step=1,
description="E-Size:",
disabled=False,
continuous_update=True,
orientation="horizontal",
readout=True,
readout_format="d",
)
self._ewidth_slider.observe(self._on_ewidth_change)
# setting checkbox for showing vertex labels
self._vlabel_checkbox = Checkbox(
value=True, description="Show V-Labels", disabled=False, indent=False
)
self._vlabel_checkbox.observe(self._on_show_vlabel_change)
# combining the menu and canvas
right_box = VBox(
[
self._vcolor_picker,
self._ecolor_picker,
self._vradius_slider,
self._ewidth_slider,
self._vlabel_checkbox,
]
)
box = HBox([self._mcanvas, right_box])
if isinstance(graph, Graph) and graph.number_of_nodes() > 0:
self._set_graph(graph, layout_type, place)
with hold_canvas():
self._mcanvas[1].clear()
self._redraw_graph()
# displaying the combined menu and canvas, and the output
display(box)
display(self._out)
with self._out:
print("press and hold ctrl key to move vertices around with mouse.")
def _handle_event(self, event):
"""
This function handles keyboard events and double click event using ``ipyevents``.
"""
if event["event"] == "keydown":
self._vertexmove_on = event["ctrlKey"]
elif event["event"] == "keyup":
self._vertexmove_on = event["ctrlKey"]
elif event["event"] == "dblclick":
x, y = event["relativeX"], event["relativeY"]
self._handle_dblclick(x, y)
def _assign_pos(self, x, y, place):
"""
This function converts layout positions which are between -1 and 1
to canvas positions according to the chosen place by scaling.
"""
width = self._mcanvas.width
height = self._mcanvas.height
r = self._radius
# -3 is used below so that the vertices do not touch the edges of the multicanvas
if place == "all":
return [
int(width / 2 + x * (width / 2 - r - 3)),
int(height / 2 + y * (height / 2 - r - 3)),
]
if place == "N":
return [
int(width / 2 + x * (width / 2 - r - 3)),
int(height / 4 + y * (height / 4 - r - 3)),
]
if place == "S":
return [
int(width / 2 + x * (width / 2 - r - 3)),
int(height * 3 / 4 + y * (height / 4 - r - 3)),
]
if place == "W":
return [
int(width / 4 + x * (width / 4 - r - 3)),
int(height / 2 + y * (height / 2 - r - 3)),
]
if place == "E":
return [
int(width * 3 / 4 + x * (width / 4 - r - 3)),
int(height / 2 + y * (height / 2 - r - 3)),
]
if place == "NE":
return [
int(width * 3 / 4 + x * (width / 4 - r - 3)),
int(height / 4 + y * (height / 4 - r - 3)),
]
if place == "NW":
return [
int(width / 4 + x * (width / 4 - r - 3)),
int(height / 4 + y * (height / 4 - r - 3)),
]
if place == "SE":
return [
int(width * 3 / 4 + x * (width / 4 - r - 3)),
int(height * 3 / 4 + y * (height / 4 - r - 3)),
]
if place == "SW":
return [
int(width / 4 + x * (width / 4 - r - 3)),
int(height * 3 / 4 + y * (height / 4 - r - 3)),
]
def _set_graph(self, graph: Graph, layout_type, place):
vertex_map = {}
for vertex in graph:
if not isinstance(vertex, int) or vertex < 0:
for k in range(graph.number_of_nodes()):
if not graph.has_node(k) and k not in vertex_map.values():
vertex_map[vertex] = k
break
graph = nx.relabel_nodes(graph, vertex_map, copy=True)
placement = graph.layout(layout_type)
# random layout assigns coordinates between 0 and 1.
# adjust the coordinates to between -1 and 1 as other layouts
if layout_type == "random":
for vertex in placement:
placement[vertex] = [2 * x - 1 for x in placement[vertex]]
# add vertices to the graph of the graphdrawer by scaling the coordinates
# from [-1,1] to [self._mcanvas.width, self._mcanvas.height]
for vertex in graph.nodes:
px, py = placement[vertex]
self._graph.add_node(
vertex, color=self._v_color, pos=self._assign_pos(px, py, place)
)
for edge in graph.edges:
self._graph.add_edge(edge[0], edge[1], color=self._e_color)
self._next_vertex_label = max(self._graph.nodes) + 1
if len(vertex_map) != 0:
with self._out:
print("relabeled vertices:", vertex_map)
def _on_vcolor_change(self, change) -> None:
"""
Handler of the color picker for the new vertices.
"""
if change["type"] == "change" and change["name"] == "value":
self._v_color = change["new"]
def _on_ecolor_change(self, change) -> None:
"""
Handler of the color picker for the new edges.
"""
if change["type"] == "change" and change["name"] == "value":
self._e_color = change["new"]
def _on_vradius_change(self, change) -> None:
"""
Handler of the vertex size slider.
"""
if change["type"] == "change" and change["name"] == "value":
self._radius = change["new"]
with hold_canvas():
self._mcanvas[2].clear()
self._redraw_graph()
def _on_ewidth_change(self, change) -> None:
"""
Handler of the edge width slider.
"""
if change["type"] == "change" and change["name"] == "value":
self._ewidth = change["new"]
with hold_canvas():
self._mcanvas[2].clear()
self._redraw_graph()
def _on_show_vlabel_change(self, change) -> None:
"""
Handler of the vertex labels checkbox.
"""
if change["type"] == "change" and change["name"] == "value":
self._show_vlabels = change["new"]
with hold_canvas():
self._mcanvas[2].clear()
self._redraw_graph()
def _handle_mouse_down(self, x, y):
"""
Handler for :meth:`ipycanvas.MultiCanvas.on_mouse_down`.
It determines what to do when mouse button is pressed.
"""
self._selected_vertex = self._collided_vertex(
x, y
) # select the vertex containing the mouse pointer position
if self._selected_vertex is None and self._collided_edge(x, y) is None:
# add a new vertex if no vertex is selected and
# no edge contains the mouse pointer position
self._graph.add_node(
self._next_vertex_label, color=self._v_color, pos=[int(x), int(y)]
)
self._selected_vertex = self._next_vertex_label
self._next_vertex_label += 1
with hold_canvas():
# redraw graph and send the edges incident with selected vertex to layer 1
# and the selected vertex to layer 3 for possible continuous update.
self._mcanvas[2].clear()
self._redraw_graph(self._selected_vertex)
self._mouse_down = True
def _handle_mouse_up(self, x, y):
"""
Handler for :meth:`ipycanvas.MultiCanvas.on_mouse_up`.
It determines what to do when mouse button is released.
"""
vertex = self._collided_vertex(x, y)
s_vertex = self._selected_vertex
if s_vertex is None:
# This is to ignore the case when mousebutton is pressed
# outside multicanvas and released on multicanvas
return
if vertex is None:
# if there is no existing vertex containing the mouse pointer position,
# add a new vertex and an edge between the new vertex and the selected vertex
vertex = self._next_vertex_label
self._graph.add_node(vertex, color=self._v_color, pos=[int(x), int(y)])
self._graph.add_edge(vertex, s_vertex, color=self._e_color)
self._next_vertex_label += 1
elif vertex is not None and vertex is not s_vertex:
# if there is a vertex containing mouse pointer position other than
# the selected vertex, add / remove edge between these two vertices.
if self._graph.has_edge(vertex, s_vertex):
self._graph.remove_edge(vertex, s_vertex)
else:
self._graph.add_edge(vertex, s_vertex, color=self._e_color)
with hold_canvas():
self._mcanvas[1].clear()
self._mcanvas[2].clear()
self._mcanvas[3].clear()
self._redraw_graph()
self._mouse_down = False
def _handle_dblclick(self, x, y):
"""
This function is the handler for double click event (using ipyevents).
Double clicking on a vertex or edge will remove the vertex or the edge, resp.
"""
edge = self._collided_edge(x, y)
vertex = self._collided_vertex(x, y)
if vertex is not None and vertex == self._selected_vertex:
self._graph.remove_node(self._selected_vertex)
elif edge is not None:
self._graph.remove_edge(edge[0], edge[1])
with hold_canvas():
self._mcanvas[2].clear()
self._redraw_graph()
self._selected_vertex = None
def _handle_mouse_move(self, x, y):
"""
Handler for :meth:`ipycanvas.MultiCanvas.on_mouse_move`.
It determines what to do when mouse pointer is moving on multicanvas.
"""
vertex = self._selected_vertex
if vertex is None or not self._mouse_down:
# do nothing if no vertex is selected or mouse button is not down
return
if not self._vertexmove_on:
# add a line segment between selected vertex and the mouse pointer position
# and update layer 1 of multicanvas
with hold_canvas():
self._mcanvas[1].clear()
self._mcanvas[1].stroke_style = self._e_color
self._mcanvas[1].line_width = self._ewidth
self._mcanvas[1].stroke_line(
self._graph.nodes[vertex]["pos"][0],
self._graph.nodes[vertex]["pos"][1],
x,
y,
)
self._redraw_vertex(vertex)
else:
# move vertex to mouse pointer position
# and update layer 1 and 3 of multicanvas
self._graph.nodes[vertex]["pos"] = [int(x), int(y)]
with hold_canvas():
self._mcanvas[1].clear()
self._mcanvas[3].clear()
self._redraw_vertex(vertex)
def _handle_mouse_out(self, x, y):
"""
Handler for :meth:`ipycanvas.MultiCanvas.on_mouse_out`.
It determines what to do when the mouse leaves multicanvas.
"""
self._selected_vertex = None
self._vertexmove_on = False
with hold_canvas():
self._mcanvas[1].clear()
self._mcanvas[2].clear()
self._mcanvas[3].clear()
self._redraw_graph()
def _collided_vertex(self, x, y) -> int | None:
"""
Return the vertex containing the point (x,y) on canvas.
"""
for vertex in self._graph.nodes:
if (self._graph.nodes[vertex]["pos"][0] - x) ** 2 + (
self._graph.nodes[vertex]["pos"][1] - y
) ** 2 < self._radius**2:
return vertex
return None
def _collided_edge(self, x, y):
"""
Return the edge containing the point (x,y) on canvas.
"""
for edge in self._graph.edges:
if (
self._point_distance_to_segment(
self._graph.nodes[edge[0]]["pos"],
self._graph.nodes[edge[1]]["pos"],
[x, y],
)
< self._ewidth / 2 + 1
):
return edge
return None
def _point_distance_to_segment(self, a, b, p):
"""
Return the distance between point 'p' and line segment given by 'a' and 'b'.
"""
a = np.asarray(a)
b = np.asarray(b)
p = np.asarray(p)
ap = p - a
ab = b - a
t = np.dot(ap, ab) / np.dot(ab, ab)
t = max(0, min(1, t))
closest_point = a + t * ab
return np.linalg.norm(p - closest_point)
def _redraw_vertex(self, vertex):
"""
Update the position of a specific vertex and its incident edges
It is used when repositioning a vertex and
adding/removing a new edge so that only the parts related to
the vertex are updated on canvas. The incident edges with vertex
are drawn on layer 1 and the vertex itself is drawn on layer 3
of the multicanvas.
This is to make sure that edges do not show up above other vertices.
"""
self._mcanvas[1].line_width = self._ewidth
for v in self._graph[vertex]:
self._mcanvas[1].stroke_style = self._graph[vertex][v]["color"]
self._mcanvas[1].stroke_line(
self._graph.nodes[vertex]["pos"][0],
self._graph.nodes[vertex]["pos"][1],
self._graph.nodes[v]["pos"][0],
self._graph.nodes[v]["pos"][1],
)
self._mcanvas[3].fill_style = self._graph.nodes[vertex]["color"]
x, y = self._graph.nodes[vertex]["pos"]
self._mcanvas[3].fill_circle(x, y, self._radius)
if self._show_vlabels:
self._mcanvas[3].fill_style = "white"
self._mcanvas[3].fill_text(str(vertex), x, y)
def _redraw_graph(self, hvertex=None) -> None:
"""
Redraw the whole graph.
If hvertex is not None,
then the edges incident with hvertex are drawn on layer 1,
hvertex is drawn on layer 3 and all other vertices and edges are drawn
on layer 2. This is to prepare multicanvas for adding/removing edges
incident with hvertex and repositioning hvertex while keeping other vertices
and edges fixed on multicanvas in layer 2.
"""
self._mcanvas[1].line_width = self._ewidth
self._mcanvas[2].line_width = self._ewidth
for u, v in self._graph.edges:
# n below is the index of the layer to be used.
# if the edge is incident with hvertex,
# draw this edge on layer 1 of multicanvas.
# otherwise draw it on layer 2.
if hvertex in [u, v]:
n = 1
else:
n = 2
self._mcanvas[n].stroke_style = self._graph[u][v]["color"]
self._mcanvas[n].stroke_line(
self._graph.nodes[u]["pos"][0],
self._graph.nodes[u]["pos"][1],
self._graph.nodes[v]["pos"][0],
self._graph.nodes[v]["pos"][1],
)
for vertex in self._graph.nodes:
# n below is the index of the layer to be used.
# draw hvertex on layer 3 and other vertices on layer 2
# so that moving vertex (hvertex) will show up above others.
if hvertex == vertex:
n = 3
else:
n = 2
self._mcanvas[n].fill_style = self._graph.nodes[vertex]["color"]
x, y = self._graph.nodes[vertex]["pos"]
self._mcanvas[n].fill_circle(x, y, self._radius)
if self._show_vlabels:
self._mcanvas[n].fill_style = "white"
self._mcanvas[n].fill_text(str(vertex), x, y)
[docs]
def graph(self) -> Graph:
"""
Return a copy of the current graph on the multicanvas.
"""
H = Graph()
H.add_nodes_from(self._graph.nodes)
H.add_edges_from(self._graph.edges)
return H
[docs]
def framework(self) -> Framework:
"""
Return a copy of the current 2D framework on the multicanvas.
"""
H = self.graph()
posdict = {
v: [
self._graph.nodes[v]["pos"][0] - int(self._mcanvas.width / 2),
int(self._mcanvas.height / 2) - self._graph.nodes[v]["pos"][1],
]
for v in H.nodes
}
return Framework(graph=H, realization=posdict)