Source code for pyrigi.graph_drawer

"""
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)