CoderCastrov logo
CoderCastrov
EPFL GraphSociative
просмотров
10 мин чтение
#Визуализация данных

Визуализация ассоциативной сети EPFL

Репозиторий GitHubhttps://github.com/antoninfaure/graphsociatif

EPFL GraphSociative

Визуализация ассоциативной сети EPFL

antoninfaure.ch


Вы когда-нибудь задумывались о сложных связях внутри ассоциаций EPFL? Как ассоциации взаимодействуют друг с другом? Сколько аккредитаций имеют отдельные люди?

Давайте создадим интерактивную визуализацию, чтобы показать связи между ассоциациями и отдельными людьми с их аккредитациями!


Получение списка ассоциаций

После некоторого исследования на веб-сайте EPFL я нашел API search-ai.epfl.ch. Он позволяет искать подразделения и людей. API не имеет публичной документации, но нам просто нужно использовать одну конечную точку, чтобы получить список подразделений подразделения:

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

Например, чтобы получить список подразделений подразделения ASSOCIATIONS, мы можем использовать следующий URL:

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

Мы получаем следующий ответ:

{
    "code": 10583,
    "acronym": "ASSOCIATIONS",
    "name": "Ассоциации на кампусе",
    "unitPath": "EHE ASSOCIATIONS",
    "path": [
        {
            "acronym": "EHE",
            "name": "Новая структура сущностей, кроме школы"
        },
        {
            "acronym": "ASSOCIATIONS",
            "name": "Ассоциации на кампусе"
        }
    ],
    "terminal": null,
    "ghost": null,
    "url": "https://associations.epfl.ch",
    "subunits": [
        {
            "acronym": "AGEPOLY-CE",
            "name": "AGEPoly - Комиссии и группы"
        },
        {
            "acronym": "AIDE-PROF",
            "name": "Помощь в профессиональной жизни"
        },
        {
            "acronym": "ANIMATIONS",
            "name": "Анимации"
        },
        {
            "acronym": "AUTRES-ASS",
            "name": "Другие ассоциации"
        },
        {
            "acronym": "DEVELOP",
            "name": "Развитие"
        },
        {
            "acronym": "ETUD-PAYS",
            "name": "Студенты - Страны"
        },
        {
            "acronym": "ETUD-EPFL",
            "name": "Студенты EPFL"
        },
        {
            "acronym": "PROJETS-INT",
            "name": "Междисциплинарные проекты"
        },
        {
            "acronym": "4-CORPS",
            "name": "Представление 4 школьных органов и ACC-EPFL"
        },
        {
            "acronym": "REPRESENT",
            "name": "Представительство студентов"
        },
        {
            "acronym": "SCIENC-CULT",
            "name": "Наука и культура"
        },
        {
            "acronym": "SPORTS",
            "name": "Спорт"
        }
    ]
}

Мы видим, что для ASSOCIATIONS есть 12 подразделений "групп". Теперь запрашиваем ту же конечную точку с аббревиатурой одной из "групп", например ANIMATIONS:

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

Мы получаем следующий ответ:

{
    "code": 11438,
    "acronym": "ANIMATIONS",
    "name": "Анимации",
    "unitPath": "EHE ASSOCIATIONS ANIMATIONS",
    "path": [
        {
            "acronym": "EHE",
            "name": "Новая структура сущностей, кроме школы"
        },
        {
            "acronym": "ASSOCIATIONS",
            "name": "Ассоциации на кампусе"
        },
        {
            "acronym": "ANIMATIONS",
            "name": "Анимации"
        }
    ],
    "terminal": null,
    "ghost": null,
    "address": [
        "CH-"
    ],
    "head": {
        "sciper": "220390",
        "name": "Трейл",
        "firstname": "Хайди",
        "email": "heidy.traill@epfl.ch",
        "profile": "heidy.traill"
    },
    "subunits": [
        {
            "acronym": "ARTIPHYS",
            "name": "Artiphys"
        },
        {
            "acronym": "BALELEC",
            "name": "Фестиваль Balélec"
        },
        {
            "acronym": "SYSMIC",
            "name": "Фестиваль SYSMIC"
        },
        {
            "acronym": "AS-SATELLITE",
            "name": "Спутник"
        }
    ]
}

Теперь у нас есть подразделения ассоциаций в качестве подразделений. Мы можем создать скрипт, который получает список подразделений подразделения 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'])

        # Находим дочерние подразделения группы
        child_units = json.loads(res.text)['subunits']

        # Добавляем id к группам
        groups[i] = {
            **group,
            'id': i
        }
        for unit in child_units:
            units.append({
                'group_name': group['acronym'],
                'group_id': i,
                **unit
            })

    # Добавляем id и тип к подразделениям
    for i, unit in enumerate(units):
        units[i] = {
            **unit,
            'id': i,
            'label': unit['acronym'],
            'type': 'unit'
        }

    return units, groups

Получение списка людей в подразделении

Теперь, когда у нас есть список подразделений, нам нужно получить список людей в каждом подразделении. Давайте протестируем тот же конечный пункт, что и раньше, с аббревиатурой SYSMIC:

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

Мы получаем ответ:

{
    "code": 11346,
    "acronym": "SYSMIC",
    "name": "Festival SYSMIC",
    "unitPath": "EHE ASSOCIATIONS ANIMATIONS SYSMIC",
    "path": [
        {
            "acronym": "EHE",
            "name": "Новая структура сущностей, кроме школы"
        },
        {
            "acronym": "ASSOCIATIONS",
            "name": "Ассоциации на кампусе"
        },
        {
            "acronym": "ANIMATIONS",
            "name": "Анимации"
        },
        {
            "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": "Заместитель председателя ассоциации",
            "phoneList": [
                
            ],
            "officeList": [
                
            ]
        },
        {
            "name": "Charoz\u00e9",
            "firstname": "Rapha\u00ebl Guillaume Alexandre",
            "email": "raphael.charoze@epfl.ch",
            "sciper": "330682",
            "rank": 0,
            "profile": "raphael.charoze",
            "position": "Заместитель председателя ассоциации",
            "phoneList": [
                
            ],
            "officeList": [
                
            ]
        },
        {
            "name": "Cirillo",
            "firstname": "Thomas",
            "email": "thomas.cirillo@epfl.ch",
            "sciper": "324926",
            "rank": 0,
            "profile": "thomas.cirillo",
            "position": "Председатель ассоциации",
            "phoneList": [
                
            ],
            "officeList": [
                
            ]
        },
        {
            "name": "D\u00e9vaud",
            "firstname": "S\u00e9bastien Andr\u00e9",
            "email": "sebastien.devaud@epfl.ch",
            "sciper": "315144",
            "rank": 0,
            "profile": "sebastien.devaud",
            "position": "Казначей",
            "phoneList": [
                
            ],
            "officeList": [
                
            ]
        },
        {
            "name": "Hakim",
            "firstname": "Daoud",
            "email": null,
            "sciper": "330002",
            "rank": 0,
            "profile": "330002",
            "position": "Заместитель председателя ассоциации",
            "phoneList": [
                
            ],
            "officeList": [
                
            ]
        }
    ]
}

Поле people содержит список людей в подразделении, который отображается на общедоступной странице people.epfl.ch подразделения.

К сожалению, для SYSMIC и других подразделений оно содержит только определенных членов подразделения. Чтобы получить полный список участников, нам нужно использовать внутренний LDAP-сервер EPFL.

LDAP-сервер EPFL - это внутренний сервер, который содержит список всех людей EPFL. Он не является общедоступным, но мы можем использовать EPFL VPN, чтобы получить к нему доступ. Сервер LDAP не документирован, но он следует протоколу LDAP, и мы можем использовать библиотеку Python ldap3 для подключения и запросов к нему.

Вот скрипт, который получает список аккредитаций в подразделении из LDAP-сервера для всех подразделений:

from ldap3 import Server, Connection, SUBTREE

def list_accreds(units):
    '''    Список всех аккредитаций EPFL из LDAP-сервера EPFL (ldap.epfl.ch).    Входные данные:        units (список): список подразделений        write_accreds_json (bool): запись аккредитаций в accreds.json (необязательно)    Выходные данные:        accreds.json (файл): список аккредитаций (необязательно)    Возврат:        accreds (список): список аккредитаций    '''

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

    if not c.bind():
        print("Ошибка: не удалось подключиться к 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

Вычисление размеров юнитов и пользователей

Теперь, когда у нас есть список аккредитаций, мы можем вычислить размер каждого юнита и каждого пользователя. Размер юнита - это количество аккредитаций в юните. Размер пользователя - это количество аккредитаций у пользователя.

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

Вычисление связей между юнитами и пользователями

Теперь, когда у нас есть список аккредитаций, мы можем вычислить связи между юнитами и пользователями. Связь между юнитом и пользователем означает, что у пользователя есть аккредитация в данном юните.

def вычислить_связи(аккреды, юниты, пользователи):
    связи = []
    for i, аккред in enumerate(аккреды):
        for юнит in юниты:
            if (юнит['сокращение'] == аккред['название_юнита']):
                идентификатор_юнита = юнит['идентификатор']

        for пользователь in пользователи:
            if (пользователь['идентификатор'] == аккред['сципер']):
                идентификатор_пользователя = пользователь['идентификатор']
                связи.append({
                    'цель': идентификатор_юнита,
                    'источник': идентификатор_пользователя
                })

    return связи

Визуализация с помощью D3.js

Теперь, когда у нас есть список юнитов, пользователей и связей, мы можем визуализировать его с помощью D3.js. Визуализация основана на примере Графа силы направленного графа D3.js.

Сначала нам нужно записать данные в файл 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)

Затем мы можем использовать следующий HTML-шаблон для визуализации данных:

<!-- 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>


<!-- Our custom script -->
<script type="module" src="network.js"></script>

</html>

Теперь мы можем написать скрипт network.js, который загрузит данные и визуализирует их с помощью D3.js. Мы должны различать юниты и пользователей, а также различать связи между юнитами и связи между пользователями.

Для узлов пользователей мы установим цвет на красный, а радиус - в количество аккредитаций пользователя. Для узлов юнитов мы установим цвет в цвет группы юнита, а радиус - в количество аккредитаций в юните. Мы также создадим легенду, чтобы показать каждую группу с ее названием и цветом.

// network.js

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

        // Размеры SVG-холста
        const width = window.innerWidth
        const height = window.innerHeight

        // Выбираем элемент SVG и устанавливаем его размеры
        const svg = d3.select('svg')
          .attr('width', width)
          .attr('height', height)

        // Цветовая шкала для юнитов
        var color = d3.scaleOrdinal(d3.schemeCategory20);

        // Константы радиуса узлов
        const radius = 20
        const radius_people = 25

        // Создаем симуляцию силы
        var simulation = d3.forceSimulation()
          .force("link", d3.forceLink().id(function (d) { return d.id; }))
          .force("charge", d3.forceManyBody())
          .force("center", d3.forceCenter(width / 2, height / 2))
          .force("collide", d3.forceCollide().radius(d => { return d.type === 'user' ? 50 * radius_people : 100 * radius }).iterations(3))

        // Добавляем группу SVG для элементов
        var g = svg.append("g")
          .attr("class", "everything");

        // Создаем узлы с использованием данных из graph.nodes
        var node = g.append("g")
          .attr("class", "nodes")
          .selectAll("g")
          .data(graph.nodes)
          .enter().append("g")

        // Создаем связи с использованием данных из 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')

        // Создаем круги для узлов
        var circles = node.append("circle")
          .attr("r", function (d) {
            return d.type === 'user' ? d.accreds * radius_people : d.size * radius
          })
          .attr("fill", function (d) {
            if (d.type == 'unit') {
              return color(d.group_id);
            } else {
              return 'red'
            }
          })

        // Создаем обработчик перетаскивания и добавляем его к объекту узла
        var drag_handler = d3.drag()
          .on("start", dragstarted)
          .on("drag", dragged)
          .on("end", dragended);

        drag_handler(node);

        // Добавляем метки к узлам
        var labels = 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 * radius_people : d.size * radius
          })
          .style('fill', 'white')

        // Добавляем всплывающие подсказки к узлам
        node.append("title")
          .text(function (d) { return d.type === 'user' ? d.name : d.label });

        // Инициализируем симуляцию с узлами и связями
        simulation
          .nodes(graph.nodes)
          .on("tick", ticked);

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

        // Функция для обновления позиций связей и узлов во время симуляции
        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 + ")";
            })

        }

        // Функции для взаимодействия с перетаскиванием
        function dragstarted(d) {
          if (!d3.event.active) simulation.alphaTarget(0.3).restart();
          d.fx = d.x;
          d.fy = d.y;
        }

        function dragged(d) {
          d.fx = d3.event.x;
          d.fy = d3.event.y;
        }

        function dragended(d) {
          if (!d3.event.active) simulation.alphaTarget(0);
          d.fx = null;
          d.fy = null;
        }


        // Добавляем возможности масштабирования
        var zoom_handler = d3.zoom()
          .on("zoom", zoom_actions);

        zoom_handler(svg);

        function zoom_actions() {
          g.attr("transform", d3.event.transform)
        }

        // Добавляем легенду для юнитов (точка + название)
        svg.selectAll("mydots")
          .data(groups)
          .enter()
          .append("circle")
          .attr("cx", 100)
          .attr("cy", function (d, i) { return 100 + i * 25 }) // 100 - это место, где появляется первая точка. 25 - расстояние между точками
          .attr("r", 7)
          .style("fill", function (d) { return color(d.id) })

        svg.selectAll("mylabels")
          .data(groups)
          .enter()
          .append("text")
          .attr("x", 120)
          .attr("y", function (d, i) { return 100 + i * 25 }) // 100 - это место, где появляется первая точка. 25 - расстояние между точками
          .style("fill", function (d) { return color(d.id) })
          .text(function (d) { return d.name })
          .attr("text-anchor", "left")
          .style("alignment-baseline", "middle")

      })
  })

Визуализация теперь завершена! Теперь мы можем открыть файл index.html в браузере и увидеть визуализацию (нам нужно запустить локальный сервер для загрузки данных с помощью fetch).

Для настройки визуализации мы можем изменить цветовую шкалу, радиус узлов, параметры симуляции силы и т. д. в файле network.js.

Визуализация

Заключение

Мы рассмотрели, как получить список ассоциаций и список аккредитаций с сервера LDAP EPFL и как визуализировать их с помощью D3.js. Визуализация доступна здесь: Демо

Для будущих проектов было бы интересно расширить граф до всех подразделений EPFL и добавить больше информации о аккредитациях (например, роль пользователя в подразделении).


Оригинальная публикация на https://antoninfaure.ch.