EPFL GraphSociative
Table Of Content
Визуализация ассоциативной сети EPFL
Репозиторий GitHub — https://github.com/antoninfaure/graphsociatif
EPFL GraphSociative
Визуализация ассоциативной сети EPFL
antoninfaure.ch
Вы когда-нибудь задумывались о сложных связях внутри ассоциаций EPFL? Как ассоциации взаимодействуют друг с другом? Сколько аккредитаций имеют отдельные люди?
Давайте создадим интерактивную визуализацию, чтобы показать связи между ассоциациями и отдельными людьми с их аккредитациями!
- Получение списка ассоциаций
- Получение списка людей в отделе
- Вычисление размеров отдела и пользователя
- Вычисление связей между отделами и пользователями
- Визуализация с помощью D3.js
- Заключение
Получение списка ассоциаций
После некоторого исследования на веб-сайте 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.