EPFL GraphSociatif

Visualisation du réseau associatif de l'EPFL

GitHub Live Demo

Avez-vous déjà pensé aux liens entres les associations de l’EPFL ? Quelles sont les associations les plus importantes ? Quelles sont les personnes qui sont actives dans plusieurs associations ? Quelles sont les associations qui ont le plus de membres ?

Créons une visualisation interactive qui montre les relations entre les associations et les personnes avec leurs accréditations.


Récupération de la liste des associations

Après quelques recherches sur le site web de l’EPFL, j’ai découvert l’API search-ai.epfl.ch. Elle permet de rechercher des unités et des personnes. L’API n’est pas documentée publiquement, mais nous avons seulement besoin d’utiliser un point de terminaison pour récupérer la liste des sous-unités d’une unité :

https://search-api.epfl.ch/api/unit?hl=en&showall=0&siteSearch=unit.epfl.ch&acro={ACRONYME}

Par exemple, pour récupérer la liste des sous-unités de l’unité ASSOCIATIONS, nous pouvons utiliser l’URL suivante :

curl "https://search-api.epfl.ch/api/unit?hl=en&showall=0&siteSearch=unit.epfl.ch&acro=ASSOCIATIONS"

Nous obtenons la réponse suivante :

{
    "code": 10583,
    "acronym": "ASSOCIATIONS",
    "name": "Associations on the campus",
    "unitPath": "EHE ASSOCIATIONS",
    "path": [
        {
            "acronym": "EHE",
            "name": "New structure of the entities except school"
        },
        {
            "acronym": "ASSOCIATIONS",
            "name": "Associations on the campus"
        }
    ],
    "terminal": null,
    "ghost": null,
    "url": "https://associations.epfl.ch",
    "subunits": [
        {
            "acronym": "AGEPOLY-CE",
            "name": "AGEPoly - Commissions et \u00e9quipes"
        },
        {
            "acronym": "AIDE-PROF",
            "name": "Aide \u00e0 la vie professionnelle"
        },
        {
            "acronym": "ANIMATIONS",
            "name": "Animations"
        },
        {
            "acronym": "AUTRES-ASS",
            "name": "Autres associations"
        },
        {
            "acronym": "DEVELOP",
            "name": "D\u00e9veloppement"
        },
        {
            "acronym": "ETUD-PAYS",
            "name": "Etudiants - Pays"
        },
        {
            "acronym": "ETUD-EPFL",
            "name": "Etudiants EPFL"
        },
        {
            "acronym": "PROJETS-INT",
            "name": "Projets interdisciplinaires"
        },
        {
            "acronym": "4-CORPS",
            "name": "Representation of the 4 school bodies and ACC-EPFL"
        },
        {
            "acronym": "REPRESENT",
            "name": "Repr\u00e9sentation des \u00e9tudiants"
        },
        {
            "acronym": "SCIENC-CULT",
            "name": "Sciences et cultures"
        },
        {
            "acronym": "SPORTS",
            "name": "Sports"
        }
    ]
}

Nous pouvons voir qu’il y a 12 unités “groupe” pour ASSOCIATIONS. En interrogeant maintenant le même point de terminaison avec l’acronyme de l’un des “groupes”, par exemple ANIMATIONS :

curl "https://search-api.epfl.ch/api/unit?hl=en&showall=0&siteSearch=unit.epfl.ch&acro=ANIMATIONS"

Nous obtenons la réponse suivante :

{
    "code": 11438,
    "acronym": "ANIMATIONS",
    "name": "Animations",
    "unitPath": "EHE ASSOCIATIONS ANIMATIONS",
    "path": [
        {
            "acronym": "EHE",
            "name": "New structure of the entities except school"
        },
        {
            "acronym": "ASSOCIATIONS",
            "name": "Associations on the campus"
        },
        {
            "acronym": "ANIMATIONS",
            "name": "Animations"
        }
    ],
    "terminal": null,
    "ghost": null,
    "address": [
        "CH-"
    ],
    "head": {
        "sciper": "220390",
        "name": "Traill",
        "firstname": "Heidy",
        "email": "heidy.traill@epfl.ch",
        "profile": "heidy.traill"
    },
    "subunits": [
        {
            "acronym": "ARTIPHYS",
            "name": "Artiphys"
        },
        {
            "acronym": "BALELEC",
            "name": "Festival Bal\u00e9lec"
        },
        {
            "acronym": "SYSMIC",
            "name": "Festival SYSMIC"
        },
        {
            "acronym": "AS-SATELLITE",
            "name": "Satellite"
        }
    ]
}

Maintenant, nous avons des unités d’associations en tant que sous-unités. Nous pouvons ainsi créer un script qui récupère la liste des sous-unités de l’unité ASSOCIATIONS, puis la liste des sous-unités de chaque sous-unité, et ainsi de suite jusqu’à obtenir la liste de toutes les associations.

import requests
import json

def list_units(write_groups_json=True, write_units_json=True):
    BASE_URL = "https://search-api.epfl.ch/api/unit?hl=en&showall=0&siteSearch=unit.epfl.ch&acro="

    res = requests.get(BASE_URL + 'ASSOCIATIONS')
    groups = json.loads(res.text)['subunits']

    units = []
    for i, group in enumerate(groups):
        res = requests.get(BASE_URL + group['acronym'])

        # Trouver les unités enfants du groupe
        child_units = json.loads(res.text)['subunits']

        # Ajouter l'ID aux groupes
        groups[i] = {
            **group,
            'id': i
        }
        for unit in child_units:
            units.append({
                'group_name': group['acronym'],
                'group_id': i,
                **unit
            })

    # Ajouter l'ID et le type aux unités
    for i, unit in enumerate(units):
        units[i] = {
            **unit,
            'id': i,
            'label': unit['acronym'],
            'type': 'unit'
        }

    return units, groups

Récupération de la liste des personnes dans une unité

Maintenant que nous avons la liste des sous-unités, nous devons récupérer la liste des personnes dans chaque sous-unité. Testons le même point de terminaison qu’auparavant avec l’acronyme SYSMIC :

curl "https://search-api.epfl.ch/api/unit?hl=en&showall=0&siteSearch=unit.epfl.ch&acro=SYSMIC"

Nous obtenons la réponse :

{
    "code": 11346,
    "acronym": "SYSMIC",
    "name": "Festival SYSMIC",
    "unitPath": "EHE ASSOCIATIONS ANIMATIONS SYSMIC",
    "path": [
        {
            "acronym": "EHE",
            "name": "New structure of the entities except school"
        },
        {
            "acronym": "ASSOCIATIONS",
            "name": "Associations on the campus"
        },
        {
            "acronym": "ANIMATIONS",
            "name": "Animations"
        },
        {
            "acronym": "SYSMIC",
            "name": "Festival SYSMIC"
        }
    ],
    "terminal": "1",
    "ghost": null,
    "address": [
        "Festival SYSMIC",
        "P.a. EPFL STI SMT-GE",
        "BM 2107 (B\u00e2timent BM)",
        "Station 17",
        "CH-1015 Lausanne"
    ],
    "head": {
        "sciper": "324926",
        "name": "Cirillo",
        "firstname": "Thomas",
        "email": "thomas.cirillo@epfl.ch",
        "profile": "thomas.cirillo"
    },
    "url": "https://sysmic.epfl.ch",
    "people": [
        {
            "name": "Artru",
            "firstname": "Thomas",
            "email": "thomas.artru@epfl.ch",
            "sciper": "329649",
            "rank": 0,
            "profile": "thomas.artru",
            "position": "Vice-President of Association",
            "phoneList": [
                
            ],
            "officeList": [
                
            ]
        },
        {
            "name": "Charoz\u00e9",
            "firstname": "Rapha\u00ebl Guillaume Alexandre",
            "email": "raphael.charoze@epfl.ch",
            "sciper": "330682",
            "rank": 0,
            "profile": "raphael.charoze",
            "position": "Vice-President of Association",
            "phoneList": [
                
            ],
            "officeList": [
                
            ]
        },
        {
            "name": "Cirillo",
            "firstname": "Thomas",
            "email": "thomas.cirillo@epfl.ch",
            "sciper": "324926",
            "rank": 0,
            "profile": "thomas.cirillo",
            "position": "President of Association",
            "phoneList": [
                
            ],
            "officeList": [
                
            ]
        },
        {
            "name": "D\u00e9vaud",
            "firstname": "S\u00e9bastien Andr\u00e9",
            "email": "sebastien.devaud@epfl.ch",
            "sciper": "315144",
            "rank": 0,
            "profile": "sebastien.devaud",
            "position": "Treasurer",
            "phoneList": [
                
            ],
            "officeList": [
                
            ]
        },
        {
            "name": "Hakim",
            "firstname": "Daoud",
            "email": null,
            "sciper": "330002",
            "rank": 0,
            "profile": "330002",
            "position": "Vice-President of Association",
            "phoneList": [
                
            ],
            "officeList": [
                
            ]
        }
    ]
}

Le champ people contient la liste des personnes de la sous-unité qui est affichée sur la page people.epfl.ch de l’unité.

Malheureusement, pour SYSMIC et d’autres sous-unités, il ne contient que certains membres de la sous-unité. Pour récupérer la liste complète des membres, nous devons utiliser le serveur LDAP interne de l’EPFL.

Le serveur LDAP de l’EPFL est un serveur interne qui contient la liste de toutes les personnes de l’EPFL. Il n’est pas accessible publiquement, mais nous pouvons utiliser le VPN de l’EPFL pour

y accéder. Le serveur LDAP n’est pas documenté, mais il suit le protocole LDAP et nous pouvons utiliser la bibliothèque Python ldap3 pour s’y connecter et effectuer des requêtes.

Voici un script qui récupère la liste des accréditations dans une sous-unité à partir du serveur LDAP, pour toutes les unités :

from ldap3 import Server, Connection, SUBTREE

def list_accreds(units):
    '''
    Liste toutes les accréditations de l'EPFL depuis le serveur LDAP de l'EPFL (ldap.epfl.ch).

    Entrée :
        units (list) : liste des unités
        write_accreds_json (booléen) : écrit les accréditations dans accreds.json (facultatif)

    Sortie :
        accreds.json (fichier) : liste des accréditations (facultatif)

    Retour :
        accreds (list) : liste des accréditations
    '''

    server = Server('ldaps://ldap.epfl.ch:636', connect_timeout=5)
    c = Connection(server)

    if not c.bind():
        print("Erreur : impossible de se connecter à ldap.epfl.ch", c.result)
        return

    accreds = []
    for unit in units:
        c.search(search_base = 'o=ehe,c=ch',
                search_filter = f"(&(ou={unit['acronym']})(objectClass=person))",
                search_scope = SUBTREE,
                attributes = '*')

        results = c.response
        for user in results:
            user = dict(user['attributes'])
            accreds.append({
                'sciper': int(user['uniqueIdentifier'][0]),
                'name': user['displayName'],
                'unit_name': unit['acronym'],
                'unit_id': unit['id']
            })
        
    return accreds

Calcul des tailles d’unités et d’utilisateurs

Maintenant que nous avons la liste des accréditations, nous pouvons calculer la taille de chaque unité et de chaque utilisateur. La taille d’une unité est le nombre d’accréditations dans l’unité. La taille d’un utilisateur est le nombre d’accréditations de l’utilisateur.

def compute_units_size(units, accreds):
    units_size = dict()
    for accred in accreds:
        unit_id = accred['unit_id']
        if unit_id in units_size:
            units_size[unit_id] += 1
        else:
            units_size[unit_id] = 1

    for i, unit in enumerate(units):
        if unit['id'] not in units_size:
            size = 0
        else:
            size = units_size[unit['id']]
        units[i] = {
            **unit,
            'size': size
        }

    return units
def compute_users_size(accreds):
    n_accreds = dict()
    for accred in accreds:
        if (accred['sciper'] in n_accreds):
            n_accreds[accred['sciper']] += 1
        else:
            n_accreds[accred['sciper']] = 1

    users = []
    for accred in accreds:
        if (n_accreds[accred['sciper']] > 1):
            user = {
                'id': accred['sciper'],
                'name': accred['name'],
                'type': 'user',
                'accreds': n_accreds[accred['sciper']]
            }
            if (user not in users):
                users.append(user)

    return users

Calcul des liens entre les unités et les utilisateurs

Maintenant que nous avons la liste des accréditations, nous pouvons calculer les liens entre les unités et les utilisateurs. Un lien entre une unité et un utilisateur signifie que l’utilisateur possède une accréditation dans l’unité.

def compute_links(accreds, units, users):
    links = []
    for i, accred in enumerate(accreds):
        for unit in units:
            if (unit['acronym'] == accred['unit_name']):
                unit_id = unit['id']

        for user in users:
            if (user['id'] == accred['sciper']):
                user_id = user['id']
                links.append({
                    'target': unit_id,
                    'source': user_id
                })

    return links

Visualisation avec D3.js

Maintenant que nous avons la liste des unités, des utilisateurs et des liens, nous pouvons la visualiser avec D3.js. La visualisation est basée sur l’exemple du Graphique à Liaisons Fortes de D3.js.

Tout d’abord, nous devons

écrire les données dans un fichier JSON :

def write_json(units, users, links, groups):

    data = {
        'nodes': units + users,
        'links': links
    }

    with open("data.json", "w", encoding='utf8') as outfile:
        json.dump(data, outfile, ensure_ascii=False)

    with open("groups.json", "w", encoding='utf8') as outfile:
        json.dump(groups, outfile, ensure_ascii=False)

Ensuite, nous pouvons utiliser le modèle HTML suivant pour visualiser les données :

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="description" content="Graphsociatif">
    <meta name="keywords" content="graph,associations,EPFL">
    <meta name="author" content="Antonin Faure">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>Graphsociatif</title>

    <!-- JQuery -->
    <script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>

    <!-- D3.js -->
    <script src="https://d3js.org/d3.v4.min.js"></script>
</head>

<body>
    <svg id="mynetwork"></svg>
</body>

<style>
    html, body {
        min-height: 100%;
        height: 100%;
        min-width: 100%;
        margin: 0;
        padding: 0;
        background-color: black;
    }
    #mynetwork {
        width: 100%;
        min-height: 600px;
        border: 1px solid lightgray;
        height: 100%;
    }
</style>


<!-- Notre script personnalisé -->
<script type="module" src="network.js"></script>

</html>

Maintenant, nous pouvons écrire le script network.js qui chargera les données et les visualisera avec D3.js. Nous devons différencier entre les unités et les utilisateurs, et entre les liens entre les unités et les liens entre les utilisateurs.

Pour les nœuds utilisateur, nous allons définir la couleur en rouge, et le rayon en fonction du nombre d’accréditations de l’utilisateur. Pour les nœuds unité, nous allons définir la couleur en fonction de la couleur du groupe de l’unité, et le rayon en fonction du nombre d’accréditations dans l’unité. On ajoute aussi une légende avec le nom et la couleur de chaque groupe.

// network.js

fetch("groups.json")
  .then(response => {
    return response.json();
  })
  .then(groups => {
    fetch("data.json")
      .then(response => {
        return response.json();
      })
      .then(graph => {

        // Dimensions du canevas SVG
        const largeur = window.innerWidth
        const hauteur = window.innerHeight

        // Sélectionner l'élément SVG et définir ses dimensions
        const svg = d3.select('svg')
          .attr('width', largeur)
          .attr('height', hauteur)

        // Échelle de couleur pour les unités
        var couleur = d3.scaleOrdinal(d3.schemeCategory20);

        // Constantes de rayon du nœud
        const rayon = 20
        const rayon_personnes = 25

        // Créer une simulation de force
        var simulation = d3.forceSimulation()
          .force("link", d3.forceLink().id(function (d) { return d.id; }))
          .force("charge", d3.forceManyBody())
          .force("center", d3.forceCenter(largeur / 2, hauteur / 2))
          .force("collide", d3.forceCollide().radius(d => { return d.type === 'user' ? 50 * rayon_personnes : 100 * rayon }).iterations(3))

        // Ajouter un groupe SVG pour les éléments
        var g = svg.append("g")
          .attr("class", "everything");

        // Créer les nœuds en utilisant les données de graph.nodes
        var node = g.append("g")
          .attr("class", "nodes")
          .selectAll("g")
          .data(graph.nodes)
          .enter().append("g")

        // Créer les liens en utilisant les données de graph.links
        var link = g.append("g")
          .attr("class", "links")
          .selectAll("line")
          .data(graph.links)
          .enter().append("line")
          .attr("stroke-width", function (d) { return Math.sqrt(d.value); })
          .style('stroke', 'white')

        // Créer des cercles pour les nœuds
        var cercles = node.append("circle")
          .attr("r", function (d) {
            return d.type === 'user' ? d.accreds * rayon_personnes : d.size * rayon
          })
          .attr("fill", function (d) {
            if (d.type == 'unit') {
              return couleur(d.group_id);
            } else {
              return 'red'
            }
          })

        // Créer un gestionnaire de traînée et l'ajouter à l'objet nœud
        var drag_handler = d3.drag()
          .on("start", dragstarted)
          .on("drag", dragged)
          .on("end", dragended);

        drag_handler(node);

        // Ajouter des étiquettes aux nœuds
        var etiquettes = node.append("text")
          .attr("text-anchor", "middle")
          .attr("dy", ".35em")
          .text(function (d) {
            return d.type === 'user' ? d.name : d.label
          })
          .style("font-size", function (d) {
            return d.type === 'user' ? d.accreds * rayon_personnes : d.size * rayon
          })
          .style('fill', 'white')

        // Ajouter des info-bulles aux nœuds
        node.append("title")
          .text(function (d) { return d.type === 'user' ? d.name : d.label });

        // Initialiser la simulation avec les nœuds et les liens
        simulation
          .nodes(graph.nodes)
          .on("tick", ticked);

        simulation.force("link")
          .links(graph.links);

        // Fonction pour mettre à jour les positions des liens et des nœuds pendant la simulation
        function ticked() {
          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; });

          node
            .attr("transform",

            function (d) {
              return "translate(" + d.x + "," + d.y + ")";
            })
        }

        // Fonction de gestion des événements pour le démarrage du glisser
        function dragstarted(d) {
          if (!d3.event.active) simulation.alphaTarget(0.3).restart();
          d.fx = d.x;
          d.fy = d.y;
        }

        // Fonction de gestion des événements pour le glisser
        function dragged(d) {
          d.fx = d3.event.x;
          d.fy = d3.event.y;
        }

        // Fonction de gestion des événements pour le glisser
        function dragended(d) {
          if (!d3.event.active) simulation.alphaTarget(0);
          d.fx = null;
          d.fy = null;
        }
      });
  });

La visualisation est maintenant terminée ! Nous pouvons ouvrir le fichier index.html dans un navigateur pour voir la visualisation (nous devons exécuter un serveur local pour charger les données avec la commande fetch).

Pour personnaliser la visualisation, nous pouvons modifier l’échelle de couleurs, le rayon des nœuds, les paramètres de simulation de force, etc dans le fichier network.js.

Graphsociatif

Conclusion

Nous avons appris comment récupérer la liste des associations et la liste des accréditations à partir du serveur LDAP de l’EPFL, ainsi que comment les visualiser avec D3.js. La visualisation est disponible sur https://antoninfaure.github.io/graphsociatif.

Le code est disponible sur GitHub.

Pour les projets futurs, il pourrait être intéressant d’étendre le graph à toutes les unités de l’EPFL et d’ajouter davantage d’informations sur les accréditations (par exemple, le rôle de l’utilisateur dans l’unité).