{
"cells": [
{
"cell_type": "markdown",
"metadata": {
"collapsed": true
},
"source": [
"## Plotly plot of a graph with a circular layout ##"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"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."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"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).\n",
"\n",
"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).\n",
"\n",
"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.\n",
"\n",
"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. \n",
"\n",
"To avoid cluttered edges we adopted the following procedure in choosing the interior control points for the Bézier curve:\n",
"\n",
"- 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)$$\n",
"\n",
"- define a list, `Dist`, having as elements the distances between the following pairs of points:\n",
"$$(P_1, P_1), \\:(P_1, P_2), \\: (P_1, P_3),\\: (P_1, P_4),\\: (P_1, P_5)$$\n",
"\n",
"- In order to assign the control poligon to the Bézier curve that will be the edge between two connected\n",
"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.\n",
"\n",
"- 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]`.\n",
"\n",
"Namely, if the distance(`V[i], V[j]`), belongs to the $K^{th}$ interval associated to `Dist`, then we choose `param= params[K]`.\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"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. "
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Now are reading the `gml` file and define an [`igraph.Graph`](http://igraph.org/python/) object."
]
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"outputs": [],
"source": [
"import igraph as ig\n",
"import numpy as np"
]
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {},
"outputs": [],
"source": [
"G = ig.Graph.Read_GML('Data/Eurovision15.gml')"
]
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"['id', 'label']"
]
},
"execution_count": 3,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"G.vs.attributes() # node attributes"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Extract node labels to be displayed on hover:"
]
},
{
"cell_type": "code",
"execution_count": 4,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"['weight']"
]
},
"execution_count": 4,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"labels = [v['label'] for v in G.vs] \n",
"G.es.attributes()# the edge attributes"
]
},
{
"cell_type": "markdown",
"metadata": {
"collapsed": true
},
"source": [
"Get the edge list as a list of tuples, having as elements the end nodes indices:"
]
},
{
"cell_type": "code",
"execution_count": 5,
"metadata": {},
"outputs": [],
"source": [
"E = [e.tuple for e in G.es]# list of edges as tuple of node indices"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Contestant countries:"
]
},
{
"cell_type": "code",
"execution_count": 6,
"metadata": {},
"outputs": [],
"source": [
"contestant_list = [G.vs[e[1]] for e in E] \n",
"contestant = list(set([v['label'] for v in contestant_list])) # list of all graph target labels"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Get the node positions, assigned by the circular layout:"
]
},
{
"cell_type": "code",
"execution_count": 7,
"metadata": {},
"outputs": [],
"source": [
"pos = np.array(G.layout('circular')) \n",
"L = len(pos)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Define the list of edge weights:"
]
},
{
"cell_type": "code",
"execution_count": 8,
"metadata": {},
"outputs": [],
"source": [
"weights = list(map(int, G.es[\"weight\"]))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"In the sequel we define a few functions that lead to the edge definition as a Bézier curve:"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"`dist(A,B)` computes the distance between two 2D points, A, B:"
]
},
{
"cell_type": "code",
"execution_count": 9,
"metadata": {},
"outputs": [],
"source": [
"def p_dist(A, B):\n",
" return np.linalg.norm(np.asarray(A)-np.asarray(B))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Define the list `dist` of threshold distances between nodes on the unit circle:"
]
},
{
"cell_type": "code",
"execution_count": 10,
"metadata": {},
"outputs": [],
"source": [
"dist=[0, p_dist([1,0], 2*[np.sqrt(2)/2]), np.sqrt(2),\n",
" p_dist([1,0], [-np.sqrt(2)/2, np.sqrt(2)/2]), 2.0]"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The list of parameters for interior control points:"
]
},
{
"cell_type": "code",
"execution_count": 11,
"metadata": {},
"outputs": [],
"source": [
"params = [1.2, 1.5, 1.8, 2.1]"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The function `get_idx_interv` returns the index of the interval the distance `d` belongs to:"
]
},
{
"cell_type": "code",
"execution_count": 12,
"metadata": {},
"outputs": [],
"source": [
"def get_idx_interv(d, D):\n",
" k = 0\n",
" while(d > D[k]): \n",
" k += 1\n",
" return k-1"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"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`. \n",
"\n",
"The latter returns an array of shape (nr, 2) containing the coordinates of \n",
"`nr` points evaluated on the Bézier curve, at equally spaced parameters in [0,1].\n",
"\n",
"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."
]
},
{
"cell_type": "code",
"execution_count": 13,
"metadata": {},
"outputs": [],
"source": [
"class InvalidInputError(Exception):\n",
" pass"
]
},
{
"cell_type": "code",
"execution_count": 14,
"metadata": {},
"outputs": [],
"source": [
"def deCasteljau(b, t): \n",
" if not isinstance(b, (list, np.ndarray)) or not isinstance(b[0], (list,np.ndarray)):\n",
" raise InvalidInputError('b must be a list of 2-lists')\n",
" N = len(b) \n",
" if(N < 2):\n",
" raise InvalidInputError(\"The control polygon must have at least two points\")\n",
" a = np.copy(b)\n",
" for r in range(1,N): \n",
" a[:N-r,:] = (1-t) * a[:N-r,:] + t*a[1:N-r+1,:] \n",
" return a[0,:]"
]
},
{
"cell_type": "code",
"execution_count": 15,
"metadata": {},
"outputs": [],
"source": [
"def BezierCv(b, nr=5):\n",
" t = np.linspace(0, 1, nr)\n",
" return np.array([deCasteljau(b, t[k]) for k in range(nr)]) "
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Finally we set data and layout for the Plotly plot of the circular graph:"
]
},
{
"cell_type": "code",
"execution_count": 16,
"metadata": {},
"outputs": [],
"source": [
"import plotly.plotly as py\n",
"import plotly.graph_objs as go"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Set node colors and line colors (the lines encircling the dots marking the nodes):"
]
},
{
"cell_type": "code",
"execution_count": 17,
"metadata": {},
"outputs": [],
"source": [
"node_color = ['rgba(0,51,181, 0.85)' if v['label'] in contestant else '#CCCCCC' for v in G.vs] \n",
"line_color = ['#FFFFFF' if v['label'] in contestant else 'rgb(150,150,150)' for v in G.vs]"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The graph edges are colored with colors depending on the distance between end nodes:"
]
},
{
"cell_type": "code",
"execution_count": 18,
"metadata": {},
"outputs": [],
"source": [
"edge_colors = ['#d4daff','#84a9dd', '#5588c8', '#6d8acf']"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Define the lists of x, respectively y-coordinates of the nodes:"
]
},
{
"cell_type": "code",
"execution_count": 19,
"metadata": {},
"outputs": [],
"source": [
"Xn = [pos[k][0] for k in range(L)]\n",
"Yn = [pos[k][1] for k in range(L)]"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"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."
]
},
{
"cell_type": "code",
"execution_count": 20,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"'Armenia'"
]
},
"execution_count": 20,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"e = (1, 2)\n",
"G.vs[e[0]]['label']"
]
},
{
"cell_type": "code",
"execution_count": 21,
"metadata": {},
"outputs": [],
"source": [
"lines = []# the list of dicts defining edge Plotly attributes\n",
"edge_info = []# the list of points on edges where the information is placed\n",
"\n",
"for j, e in enumerate(E):\n",
" A = pos[e[0]]\n",
" B = pos[e[1]]\n",
" d = p_dist(A, B)\n",
" k = get_idx_interv(d, dist)\n",
" b = [A, A/params[k], B/params[k], B]\n",
" color = edge_colors[k]\n",
" pts = BezierCv(b)\n",
" text = f\"{G.vs[e[0]]['label']} to {G.vs[e[1]]['label']} {weights[j]} pts\"\n",
" mark = deCasteljau(b, 0.9)\n",
" \n",
" edge_info.append(go.Scatter(x=[mark[0]], \n",
" y=[mark[1]], \n",
" mode='markers', \n",
" marker=dict( size=0.5, color=edge_colors),\n",
" text=text, \n",
" hoverinfo='text'))\n",
" \n",
" lines.append(go.Scatter(x=pts[:,0],\n",
" y=pts[:,1],\n",
" mode='lines',\n",
" line=dict(color=color, \n",
" shape='spline',\n",
" width=weights[j]/5 #the width is proportional to the edge weight\n",
" ), \n",
" hoverinfo='none'))"
]
},
{
"cell_type": "code",
"execution_count": 22,
"metadata": {},
"outputs": [],
"source": [
"trace2 = go.Scatter(x=Xn,\n",
" y=Yn,\n",
" mode='markers',\n",
" name='',\n",
" marker=dict(size=15, \n",
" color=node_color, \n",
" line=dict(color=line_color, width=0.5)),\n",
" text=labels,\n",
" hoverinfo='text',\n",
" )"
]
},
{
"cell_type": "code",
"execution_count": 23,
"metadata": {},
"outputs": [],
"source": [
"def make_annotation(anno_text, y_coord):\n",
" return dict(showarrow=False, \n",
" text=anno_text, \n",
" xref='paper', \n",
" yref='paper', \n",
" x=0, \n",
" y=y_coord, \n",
" xanchor='left', \n",
" yanchor='bottom', \n",
" font=dict(size=12) \n",
" )"
]
},
{
"cell_type": "code",
"execution_count": 24,
"metadata": {},
"outputs": [],
"source": [
"anno_text1 = 'Blue nodes mark the countries that are both contestants and jury members'\n",
"anno_text2 = 'Grey nodes mark the countries that are only jury members'\n",
"anno_text3 = 'There is an edge from a Jury country to a contestant country '+\\\n",
" 'if the jury country assigned at least one point to that contestant'\n",
"\n",
"title = \"A circular graph associated to Eurovision Song Contest, 2015
Data source:\"+\\\n",
"\" [1]\"\n",
"\n",
"layout = go.Layout(title=title,\n",
" font=dict(size=12),\n",
" showlegend=False,\n",
" autosize=False,\n",
" width=800,\n",
" height=850,\n",
" xaxis=dict(visible=False),\n",
" yaxis=dict(visible=False), \n",
" margin=dict(l=40,\n",
" r=40,\n",
" b=85,\n",
" t=100),\n",
" hovermode='closest',\n",
" annotations=[make_annotation(anno_text1, -0.07), \n",
" make_annotation(anno_text2, -0.09),\n",
" make_annotation(anno_text3, -0.11)])"
]
},
{
"cell_type": "code",
"execution_count": 25,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"'https://plot.ly/~empet/9883'"
]
},
"execution_count": 25,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"data = lines+edge_info+[trace2]\n",
"fig = go.FigureWidget(data=data, layout=layout)\n",
"py.plot(fig, filename='Eurovision-15') "
]
},
{
"cell_type": "code",
"execution_count": 28,
"metadata": {},
"outputs": [
{
"data": {
"text/html": [
"\n",
" \n",
" "
],
"text/plain": [
""
]
},
"execution_count": 28,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"from IPython.display import IFrame\n",
"IFrame('https://plot.ly/~empet/9883', width=800, height=850)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.7.1"
}
},
"nbformat": 4,
"nbformat_minor": 1
}