#!/usr/bin/env python # coding: utf-8 # ## Plotly plot of a graph with a circular layout ## # A circular layout places the graph nodes uniformly on a circle. In this Jupyter Notebook we illustrate how to draw the graph edges in order to avoid a cluttered visualization. # As example we consider a circular graph having as nodes the European countries. Among these countries some qualified for the grand final [Eurovision Song Contest](http://www.eurovision.tv/page/timeline). # # Each european country is a jury member and rates some contestants on a scale from 1 to 12 (in 2015 a contestant from Australia led to adding this country to the graph). # # There is a directed edge from a jury member country to a contestant country if the contestant acquired at least one point from the jury country voters. # # The jury member countries are placed uniformly, in alphabetical order, on the unit circle. If there is an edge between two nodes, then we draw a cubic [Bézier curve](http://nbviewer.ipython.org/github/empet/geom_modeling/blob/master/FP-Bezier-Bspline.ipynb) having as the first and the last control point the given nodes. # # To avoid cluttered edges we adopted the following procedure in choosing the interior control points for the Bézier curve: # # - we consider five equally spaced points on the unit circle, corresponding to the angles $0, \:\pi/4$ $\pi/2,\: 3\pi/4, \:\pi$: $$P_1(1,0), \: P_2=(\sqrt{2}/2,\: \sqrt{2}/2),\: P_3(0,1), \: P_4=(-\sqrt{2}/2, \sqrt{2}/2),\: P_5(-1,0)$$ # # - define a list, `Dist`, having as elements the distances between the following pairs of points: # $$(P_1, P_1), \:(P_1, P_2), \: (P_1, P_3),\: (P_1, P_4),\: (P_1, P_5)$$ # # - In order to assign the control poligon to the Bézier curve that will be the edge between two connected # nodes, `V[i], V[j]`, we compute the distance between these nodes, and deduce the interval $k$, of two consecutive values in `Dist`, this distance belongs to. # # - Since there are four such intervals indexed $k=0,1,2,3$, we define the control poligon as follows: $${\bf b}_0=V[i],\:\: {\bf b}_1=V[i]/param,\:\: {\bf b}_2=V[j]/param, \:\:{\bf b}_3=V[j],$$ where `param` is chosen from the list: `params=[1.2, 1.5, 1.8, 2.1]`. # # Namely, if the distance(`V[i], V[j]`), belongs to the $K^{th}$ interval associated to `Dist`, then we choose `param= params[K]`. # # We processed data provided by [Eurovision Song Contest](http://www.eurovision.tv/page/history/by-year/contest?event=2083#Scoreboard), and saved the corresponding graph in a `gml` file. # Now are reading the `gml` file and define an [`igraph.Graph`](http://igraph.org/python/) object. # In[1]: import igraph as ig import numpy as np # In[2]: G = ig.Graph.Read_GML('Data/Eurovision15.gml') # In[3]: G.vs.attributes() # node attributes # Extract node labels to be displayed on hover: # In[4]: labels = [v['label'] for v in G.vs] G.es.attributes()# the edge attributes # Get the edge list as a list of tuples, having as elements the end nodes indices: # In[5]: E = [e.tuple for e in G.es]# list of edges as tuple of node indices # Contestant countries: # In[6]: contestant_list = [G.vs[e[1]] for e in E] contestant = list(set([v['label'] for v in contestant_list])) # list of all graph target labels # Get the node positions, assigned by the circular layout: # In[7]: pos = np.array(G.layout('circular')) L = len(pos) # Define the list of edge weights: # In[8]: weights = list(map(int, G.es["weight"])) # In the sequel we define a few functions that lead to the edge definition as a Bézier curve: # `dist(A,B)` computes the distance between two 2D points, A, B: # In[9]: def p_dist(A, B): return np.linalg.norm(np.asarray(A)-np.asarray(B)) # Define the list `dist` of threshold distances between nodes on the unit circle: # In[10]: dist=[0, p_dist([1,0], 2*[np.sqrt(2)/2]), np.sqrt(2), p_dist([1,0], [-np.sqrt(2)/2, np.sqrt(2)/2]), 2.0] # The list of parameters for interior control points: # In[11]: params = [1.2, 1.5, 1.8, 2.1] # The function `get_idx_interv` returns the index of the interval the distance `d` belongs to: # In[12]: def get_idx_interv(d, D): k = 0 while(d > D[k]): k += 1 return k-1 # Below are defined the function `deCasteljau` and `BezierCv`. The former returns the point corresponding to the parameter `t`, on a Bézier curve of control points given in the list `b`. # # The latter returns an array of shape (nr, 2) containing the coordinates of # `nr` points evaluated on the Bézier curve, at equally spaced parameters in [0,1]. # # For our purpose the default number of points evaluated on a Bézier edge is 5. Then setting the Plotly `shape` of the edge line as `spline`, the five points are interpolated. # In[13]: class InvalidInputError(Exception): pass # In[14]: def deCasteljau(b, t): if not isinstance(b, (list, np.ndarray)) or not isinstance(b[0], (list,np.ndarray)): raise InvalidInputError('b must be a list of 2-lists') N = len(b) if(N < 2): raise InvalidInputError("The control polygon must have at least two points") a = np.copy(b) for r in range(1,N): a[:N-r,:] = (1-t) * a[:N-r,:] + t*a[1:N-r+1,:] return a[0,:] # In[15]: def BezierCv(b, nr=5): t = np.linspace(0, 1, nr) return np.array([deCasteljau(b, t[k]) for k in range(nr)]) # Finally we set data and layout for the Plotly plot of the circular graph: # In[16]: import plotly.plotly as py import plotly.graph_objs as go # Set node colors and line colors (the lines encircling the dots marking the nodes): # In[17]: node_color = ['rgba(0,51,181, 0.85)' if v['label'] in contestant else '#CCCCCC' for v in G.vs] line_color = ['#FFFFFF' if v['label'] in contestant else 'rgb(150,150,150)' for v in G.vs] # The graph edges are colored with colors depending on the distance between end nodes: # In[18]: edge_colors = ['#d4daff','#84a9dd', '#5588c8', '#6d8acf'] # Define the lists of x, respectively y-coordinates of the nodes: # In[19]: Xn = [pos[k][0] for k in range(L)] Yn = [pos[k][1] for k in range(L)] # On each Bézier edge, at the point corresponding to the parameter $t=0.9$, one displays the source and the target node labels, as well as the number of points (votes) assigned by source to target. # In[20]: e = (1, 2) G.vs[e[0]]['label'] # In[21]: lines = []# the list of dicts defining edge Plotly attributes edge_info = []# the list of points on edges where the information is placed for j, e in enumerate(E): A = pos[e[0]] B = pos[e[1]] d = p_dist(A, B) k = get_idx_interv(d, dist) b = [A, A/params[k], B/params[k], B] color = edge_colors[k] pts = BezierCv(b) text = f"{G.vs[e[0]]['label']} to {G.vs[e[1]]['label']} {weights[j]} pts" mark = deCasteljau(b, 0.9) edge_info.append(go.Scatter(x=[mark[0]], y=[mark[1]], mode='markers', marker=dict( size=0.5, color=edge_colors), text=text, hoverinfo='text')) lines.append(go.Scatter(x=pts[:,0], y=pts[:,1], mode='lines', line=dict(color=color, shape='spline', width=weights[j]/5 #the width is proportional to the edge weight ), hoverinfo='none')) # In[22]: trace2 = go.Scatter(x=Xn, y=Yn, mode='markers', name='', marker=dict(size=15, color=node_color, line=dict(color=line_color, width=0.5)), text=labels, hoverinfo='text', ) # In[23]: def make_annotation(anno_text, y_coord): return dict(showarrow=False, text=anno_text, xref='paper', yref='paper', x=0, y=y_coord, xanchor='left', yanchor='bottom', font=dict(size=12) ) # In[24]: anno_text1 = 'Blue nodes mark the countries that are both contestants and jury members' anno_text2 = 'Grey nodes mark the countries that are only jury members' anno_text3 = 'There is an edge from a Jury country to a contestant country '+\ 'if the jury country assigned at least one point to that contestant' title = "A circular graph associated to Eurovision Song Contest, 2015
Data source:"+\ " [1]" layout = go.Layout(title=title, font=dict(size=12), showlegend=False, autosize=False, width=800, height=850, xaxis=dict(visible=False), yaxis=dict(visible=False), margin=dict(l=40, r=40, b=85, t=100), hovermode='closest', annotations=[make_annotation(anno_text1, -0.07), make_annotation(anno_text2, -0.09), make_annotation(anno_text3, -0.11)]) # In[25]: data = lines+edge_info+[trace2] fig = go.FigureWidget(data=data, layout=layout) py.plot(fig, filename='Eurovision-15') # In[28]: from IPython.display import IFrame IFrame('https://plot.ly/~empet/9883', width=800, height=850) # In[ ]: .