#!/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[ ]:
.