Tutorial. Grafo social Twitter con Riot y D3

30/07/2016

Sin que sirva de precedente, crearemos un generador de grafos interactivo de la red social twitter con la librería gráfica D3 y un framework tan peculiar y desconocido como Riot.

Tendremos un formulario para añadir usuarios de twitter dibujando un bonito grafo de nodos y links. Los nodos representaran a los usuarios y los links representaran la conexiones que tienen entre ellos (follow - follower). Podremos ir agregando uno a uno y ver como nuestro grafo va creciendo de tamaño de forma interactiva.

Este tutorial nos permitirá comprender las relaciones que se producen en el mundo sin movernos de casa

1º objetivo: Obtener la informacion que nos hace falta mediante la API de twitter. Usaremos node.js (express & cors) y twitter . Lo importante ahora mismo es mostrar la estructura de carpetas y ficheros que usaremos para llegar la idea a buen puerto:

    Riotjs/ 
    /* Carpeta raiz */ 
    --- app/ 
    /* Carpeta app */ 
    -------- app.js 
    -------- routes.js 
    -------- twitter.js
    -------- tags/ 
    /* Carpeta tags de Riot.js */ 
    -------------- app.tag 
    -------------- more_options.tag 
    -------------- index.js 
    --- public/ 
    /* Carpeta public */ 
    -------- index.html 
    --- package.json 
    --- webpack.config.js 
    --- server.js

package.json: Aquí tendremos todo lo necesario para que funcione nuestro invento. Usaremos express, cors y twitter para crear los servicios. El resto nos permitirá trabajar con Riot.js y D3: he pensado en riotjs-loader & webpack para contruir nuestro bundle:

{
  "name": "riot-webpack",
  "version": "1.0.0",
  "description": "",
  "main": "bundle.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "webpack-dev-server --inline --hot",
    "bundle": "webpack"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "d3": "3.5.17",
    "riot": "~2.6.2",
    "twitter": "^1.4.0"
  },
  "devDependencies": {
    "babel-core": "^6.13.2",
    "babel-loader": "^6.2.4",
    "riotjs-loader": "^3.0.0",
    "superagent": "^2.3.0",
    "webpack": "^1.13.1",
    "webpack-dev-server": "^1.14.1",
    "cors": "^2.8.1",
    "express": "^4.14.0"
  }
}

He decidido usar superagent para conectar correctamente con los servicios por que se adapta bastante bien a Riot. Ok, lanzamos este comando para instalarlo todo:

npm install
Node.js

server.js: Este fichero se encargara de arrancar nuestros servicios usando express y cors en el puerto 4500. En las carpetas app tendremos el cliente twitter con las keys (os presto las mías) y las rutas:

var express = require("express"),
app = express(), cors = require("cors"), client = require('./app/twitter');

var router = express.Router();
app.use(cors());
app.use('/', router);
/_ Routes _/
require('./app/routes')(router, client);
app.listen(4500);
exports = module.exports = app;

app/twitter.js: Las claves que comente anteriormente para poder acceder a la api de twitter y que almacenaremos en la variable client:

var Twitter = require('twitter');

var client = new Twitter({
consumer_key: '20WaLvRtAfgNaidtVj75bEyQq'
, consumer_secret: '3hwKEd3Q39OiAPtGKomjOU1u5SL45i6yDic5BfttLPxmhMdrYL'
, access_token_key: '1072055204-FqweRykJv3h8KAtmG8IE3PSOqnGroEXEasp8eEG'
, access_token_secret: 'o8VC0TDlclmRAMPUWMFygh2DSaFASh8C0GHYNu6PzkX6O'
});

module.exports = client;

app/routes.js: Aquí estarán las rutas, en nuestro caso solo tendremos dos. Una para obtener la información de perfil de cada usuario que queramos agregar en nuestro grafo y otra para comprobar si están relacionados con los otros usuarios anteriores:

// app/router.js module.exports = function (router, client) {/* Aqui iran los dos servicios */}

1º servicio: Necesitamos informacion del usuario que incluyamos en nuestro grafo. Tendremos que usar users/show de la API twitter para extraer su identificador, su nick/nombre y la url de su avatar/imagen:

router.route('/user/:name').get(function (req, res) {
    /* Usamos de parametro name (example: localhost:4500/user/ivanheral) */
        var params = {
            screen_name: req.params.name
        };
        client.get('users/show', params, function (error, user) {
            if (!error) {
               console.log({
                    "id": user.id_str,
                    "screen_name": user.screen_name
                    , "name": user.name
                    , "img": user.profile_image_url
                });
            /* Devolvemos un json con el id, nick, nombre y imagen del usuario de twitter */
            res.json({
                    "id": user.id_str,
                    "screen_name": user.screen_name
                    , "name": user.name
                    , "img": user.profile_image_url
                });
            }
        });
    });

2º servicio: Necesitamos comprobar si dos usuarios de twitter se siguen mutuamente. Bastara con usar users/show de la API twitter para comprobar si podemos conectar ambos usuarios con un link:

router.route('/links/:user_1/:user_2/:x/:y').get(function (req, res) {
        /* Usamos los nombres user_1 y user_2 en params */
        var params = {
            source_screen_name : req.params.user_1
            , target_screen_name: req.params.user_2
        };
        client.get('friendships/show', params, function (error, tweets, response) {
            if (!error) {
                /* Condicion que comprueba si se siguen mutuamente */
                if (tweets.relationship.target.following && tweets.relationship.target.followed_by) {
                    /* Exito! Devuelvo true y la posicion que tienen en nuestro acumulado particular */
                    res.json({"relation": true, "pos_x":req.params.x, "pos_y":req.params.y});
                } else res.json({"relation": false});
            }
        });
    });

Ok, con esto bastaria para tener montado nuestro particular servidor. Ya solo tendriamos que lanzarlo con un node server y probar en nuestro navegador escribiendo esta url: localhost:4500/user/perezreverte para ver si funciona correctamente.

Webpack

webpack.config.js: Este es el fichero webpack con la configuración pertinente. El comando npm run bundle construira nuestro bundle.js mientras que npm run start nos permitirá lanzarlo en caliente mientras modificamos el proyecto:

var webpack = require('webpack');
/* compilamos el fichero app/app.js en public/bundle.js */
module.exports = {
    context: __dirname + '/app'
    , entry: './app.js'
    , output: {
        path: __dirname + '/public'
        , filename: 'bundle.js'
    }
    /* importante incluir riot, d3 y superagent en nuestro bundle */
    , plugins: [
    new webpack.ProvidePlugin({
            riot: 'riot', d3: 'd3'
            , request: 'superagent'
        })
  ]
    , module: {
        preLoaders: [
            {
                test: /\.tag$/, exclude: /node_modules/
                , loader: 'riotjs-loader'
      }
    ]
    }
    /* servidor */
    , devServer: {
        contentBase: './public'
    }
};

public/index.html: la estructura es muy sencilla con estilos bootstrap. Lo único a destacar es la etiqueta app que sera indispensable para montar los componentes de Riot:

<!DOCTYPE html> <html lang=""> <head> <meta charset="UTF-8"> <meta name="viewport"
    content="width=device-width, initial-scale=1.0"> <meta name="description" content=""> <meta
    name="author" content=""> <title>Social Graph Twitter</title> <link rel="shortcut icon"
    href=""> <link rel="stylesheet"
    href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"> <link rel="stylesheet"
    href="style.css"> </head> <body> <app></app> <script
    src="bundle.js"></script> </body> </html>

app/app.js: La columna vertebral del proyecto donde incluimos riot, superagent y los tags. Finalmente montamos el 1º componente social-twitter en la etiqueta app de public/index.html

var riot = require('riot')
var request = require('superagent')
require('./tags')
riot.mount('app', 'social-twitter')

tags/index.js:Capitan Obvius al rescate!, fichero que no necesita mucha explicación al respecto, simplemente incluyo todos los tags que se encuentran en la carpeta del mismo nombre para que app.js los incluya con un simple require('./tags'): js require('./more_options.tag') require('./app.tag')

tags/app.tag: Esto es lo que vendría a ser nuestro primer componente de Riot mencionado anteriormente como social-twitter. Por una parte tenemos los estilos propios (css) junto al html (un formulario con una función add) y finalmente el javascript (ES6) que ira al final del todo. En el siguiente paso iré incluyendo lo necesario para que funcione:

<social-twitter>
<style>
.options {background: #ddd;}
form {padding: 5px 5px;}
</style>
<div class='container options'>
<form class='form-inline'>
  <div class='form-group'>
    <input name='user' class='form-control'>
  <button type='submit' class='btn btn-primary' onclick={add}>ADD USER TWITTER</button>
  </div>
  <options nodes_list={nodes} links_list={links}></options>
</form>
</div>

/_ codigo javascript que explicare por pasos a continuación _/

</social-twitter>
D3

Ok, comenzamos con javascript. Definimos el ancho y alto que tendrá nuestro componente svg para visualizar nuestro grafo (1800/820px en nuestro caso). Ahora mismo esta calibrado para monitores FullHD pero podéis modificar public/style.css para adecuarlo a vuestro monitor:

let self = this let width = 1800 , height = 820

Incluimos las dimensiones a nuestra etiqueta app para que tenga constancia de sus dimensiones:

self.svg = d3
  .select('app')
  .append('div')
  .attr('class', 'container graph')
  .append('svg')
  .attr('width', width)
  .attr('height', height)

Aplicamos force layout a nuestro d3 indicándole algunas propiedades como pueden ser la gravedad hacia el centro o la distancia entre nodos:

self.force = d3.layout.force().gravity(0.1).distance(100).charge(-100).size([width, height])

¿Como interpreta force layout & d3 la información? pues... os pongo un ejemplo. Alex de la Iglesia y Nacho vigalondo se siguen mutuamente en twitter. Por lo tanto, la variable links contendra un link con source y target apuntando a 0 y 1 que son las posiciones de la lista de nodos que tendremos en la variable nodes:

{
  "nodes": [
    {
      "id": "5793642",
      "name": "Nacho Vigalondo",
      "img": "http://pbs.twimg.com/profile_images/782341301551325184/pTkWiFy2_normal.jpg",
      "screen_name": "vigalondo",
      "index": 0
    },
    {
      "id": "43310939",
      "name": "De la Iglesia",
      "img": "http://pbs.twimg.com/profile_images/752006649255198720/-3MXvi1k_normal.jpg",
      "screen_name": "alexdelaIglesia",
      "index": 1
    }
  ],
  "links": [
    {
      "source": 0,
      "target": 1
    }
  ]
}

Teniendo en cuenta lo anterior, pasamos a crear una lista de nodos y links para acumular los usuarios y relaciones que obtengamos tras escribirlos en el formulario:

self.nodes = [] self.links = []

Incluimos dos funciones que aplicaremos a los nodos para poder fijarlos en el area como si fueran chinchetas. Con doble click volveran a su posicion original:

this.dblclick = function (d) {
  d3.select(this).classed('fixed', (d.fixed = false))
}

this.dragstart = function (d) {
  d3.select(this).classed('fixed', (d.fixed = true))
}

Aplicamos force a los nodos y links. Editamos los nodos para que se muestren con la imagen y nombre del usuario correspondiente. A los links se les aplicara los estilos de la clase link y los nodos dispondran las funciones anteriormente mencionadas:

self.force.nodes(self.nodes).links(self.links)
self.link = self.svg.selectAll('.link').data(self.links).enter().append('line').attr('class', 'link')
self.node = self.svg
  .selectAll('.node')
  .data(self.nodes)
  .enter()
  .append('g')
  .attr('class', 'node')
  .call(self.force.drag)
  .on('dragstart', this.dragstart)
  .on('dblclick', this.dblclick)

self.node
  .append('image')
  .attr('xlink:href', function (d) {
    return d.img
  })
  .attr('x', -12)
  .attr('y', -12)
  .attr('width', 24)
  .attr('height', 24)
  .append('text')
  .attr('x', 0)
  .attr('dy', '26px')
  .attr('text-anchor', 'middle')
  .text(function (d) {
    return d.name
  })

Mediante tick conseguiremos que nuestros nodos cobren vida y se muevan libremente:

self.force.on('tick', function () {
  self.link
    .attr('x1', function (d) {
      return d.source.x
    })
    .attr('y1', function (d) {
      return d.source.y
    })
    .attr('x2', function (d) {
      return d.target.x
    })
    .attr('y2', function (d) {
      return d.target.y
    })
  self.node.attr('transform', function (d) {
    return 'translate(' + d.x + ',' + d.y + ')'
  })
})

Esta función se encargara de refrescar los nodos & links cada vez que añadamos un nuevo usuario. Les aplicara todas las propiedades necesarias para que funcionen del mismo modo que el resto:

self.start = function (e) {
  self.link = self.link.data(self.force.links())
  self.node = self.node.data(self.force.nodes())
  self.link.enter().insert('line', '.node').attr('class', 'link')
  var aux = self.node.enter().insert('g').attr('class', 'node')
  aux
    .append('image')
    .attr('xlink:href', function (d) {
      return d.img
    })
    .attr('x', -12)
    .attr('y', -12)
    .attr('width', 24)
    .attr('height', 24)
  aux
    .append('text')
    .attr('x', 0)
    .attr('dy', '26px')
    .attr('text-anchor', 'middle')
    .text(function (d) {
      return d.name
    })
  self.link.exit().remove()
  self.node.exit().remove()
  self.node.call(self.force.drag).on('dragstart', this.dragstart).on('dblclick', this.dblclick)
  self.force.nodes(self.nodes).links(self.links).start()
}

Finalmente terminamos con la logica del componente social-twitter, concretamente la funcion add que se ejecuta al pulsar el boton ADD USER TWITTER:

self.add = function (e) {
  /* Obtenemos el nick del usuario de twitter */
  self.add_aux(self.user.value)
}

self.add_aux = function (name) {
  /* usamos superagent y tiramos del servicio */
  request.get('http://localhost:4500/user/' + name, function (err, res) {
    /* Lo guardamos todo en la variable new_node */
    var new_node = {
      id: res.body.id,
      name: res.body.name,
      img: res.body.img,
      screen_name: res.body.screen_name,
    }

    /* Agregamos el nodo en nodes */
    self.nodes.push(new_node)

    /* En cuanto agregamos el 1º nodo... refrescamos! */
    if (self.nodes.length == 1) {
      self.start()
    }
    /* Vaya... tenemos que actualizar nuestras variables nodes y links */
    self.update()

    /* Comprobamos si el nuevo nodo tiene relaciones con los que
    estaban anteriormente agregados */
    for (i = 0; i < self.nodes.length - 1; i++) {
      let size = self.nodes.length - 1
      request.get(
        'http://localhost:4500/links/' +
          self.nodes[i].screen_name +
          '/' +
          self.nodes[self.nodes.length - 1].screen_name +
          '/' +
          i +
          '/' +
          size,
        function (err, res) {
          /* Si hay relacion, agregamos link */
          if (res.body != null && res.body.relation) {
            self.links.push({
              source: parseInt(res.body.pos_x),
              target: parseInt(res.body.pos_y),
            })
            /* Relacion encontrada! toca refrescar */
            self.start()
          }
        },
      )
    }
  })
  /* clear */
  self.user.value = ''
}

Y aqui termina el tutorial por mi parte, solo teneis que lanzar desde la carpeta Riotjs un node server y abrir Public/index.html para cacharrear un poco (se me olvido que teneis que lanzar un npm run bundle para que funcione correctamente). Quedaría explicar el 2º componente de nombre more_options, componente hijo de social-twitter y responsable del guardado, carga y reseteo de nuestras creaciones. Es mejor descargarse el codigo que dejo al final del post como siempre digo.

Ejemplo de la versión final con opción para guardar, cargar y resetear grafos

Advertencias: Es importante recalcar que las peticiones a la API Twitter son limitadas. Recomiendo salvar vuestras creaciones cada 15-20 usuarios para no perder el progreso. Otra recomendacion es que os hagais usuario de twitter para solicitar unas keys y access tokens en https://apps.twitter.com/. Ahora mismo os presto mis permisos pero probablemente acabe petando si mucha gente le da por usarlo.