Appearance
Transport network analysis
This example shows how to use Ogma to analyse the topology of a transport network. It can find shortest path between two stations, relayout the graph by placing one node at the center, and display the stations on a map. Click on any metro station to put it into the center and see how many hops will it take to reach other stations.
js
import Ogma from '@linkurious/ogma';
import { formatColors } from './utils';
import {
layoutData,
toggleGeoMode,
resetLayout,
collectRadii,
runLayout
} from './layout';
import { nameToId, state, shortestPathClass, font } from './constants';
const ogma = new Ogma({
container: 'graph-container',
options: {
backgroundColor: null,
interactions: { zoom: { onDoubleClick: true } }
}
});
// always show labels
ogma.styles.addNodeRule({
text: {
minVisibleSize: 0,
font
}
});
// Constants
const form = document.querySelector('#ui');
// Add tooltip when hovering the node
ogma.tools.tooltip.onNodeHover(
node => {
const color = formatColors(
node.getAttribute('color'),
node.getData('lines')
);
return `<div class="arrow"></div>
<div class="ogma-tooltip-header">
<span class="title">${node.getData('local_name')} ${color}</span>
</div>`;
},
{
className: 'ogma-tooltip' // tooltip container class to bind to css
}
);
// Disable the highlight on hover
ogma.styles.setHoveredNodeAttributes(null);
/**
* Creates autocomplete element with eternal library (awesomplete)
*
* @param {String} selector DOM selector for input
* @param {Array} data Stations data
* @param {Number} maxLength Max items length in list
* @param {Function} onSelect Select item callback
*/
const createAutocomplete = function (selector, data, maxLength, onSelect) {
const input = document.querySelector(selector);
const select = new Awesomplete(input, {
list: data,
minChars: 0,
sort: function (a, b) {
if (a.value < b.value) return -1;
if (a.value > b.value) return 1;
return 0;
},
maxItems: maxLength,
autoFirst: true,
item: function (text, input) {
// render item with highlighted text
let html, highlighted;
if (input.trim() === '') html = text.label;
// no changes
else {
// make sure we only replace in contents, not markup
highlighted = text.value.replace(
RegExp(Awesomplete.$.regExpEscape(input.trim()), 'gi'),
'<mark>$&</mark>'
);
html = text.label.replace(text.value, highlighted);
}
// create DOM element, see Awesomplete documentation
return Awesomplete.$.create('li', {
innerHTML: html,
'aria-selected': 'false'
});
}
});
input.addEventListener('focus', function () {
select.evaluate();
select.open();
});
input.addEventListener('awesomplete-selectcomplete', function (evt) {
this.blur();
onSelect(evt);
});
}.bind(this);
/**
* Read the current central node and apply the layout
* @param {Ogma} ogma
*/
const applyLayout = ogma => {
runLayout(ogma, form['central-node-select'].value);
};
/**
* Populates UI elements based on received graph data
* @param {Object} graph
*/
const populateUI = function (graph) {
const stations = graph.nodes.map(node => {
const name = node.data.local_name;
return {
label:
'<span>' +
formatColors(node.attributes.color, node.data.lines) +
' ' +
name +
'</span>',
lines: node.data.lines,
color: node.attributes.color,
value: name
};
});
const maxLength = graph.nodes.length;
createAutocomplete('#from-node-select', stations, maxLength, onShortestPath);
createAutocomplete('#to-node-select', stations, maxLength, onShortestPath);
createAutocomplete('#central-node-select', stations, maxLength, () =>
applyLayout(ogma)
);
};
/**
* Shortest path UI callback
* @param {Event} evt
*/
const onShortestPath = function (evt) {
const source = form['from-node-select'].value;
const destination = form['to-node-select'].value;
ogma.getNodesByClassName(shortestPathClass).removeClass(shortestPathClass);
ogma.getEdgesByClassName(shortestPathClass).removeClass(shortestPathClass);
if (source && destination) showShortestPath(source, destination);
};
// Define the class for shortest path representation
// and the attributes associated to it
ogma.styles.createClass({
name: shortestPathClass,
nodeAttributes: {
outerStroke: {
color: 'red',
width: 5
}
},
edgeAttributes: {
strokeWidth: 5,
color: 'red'
}
});
/**
* Calculate and render shortest path
* @param {String} source
* @param {String} destination
*/
const showShortestPath = function (source, destination) {
if (source !== destination) {
// calculate and highlight shortest path
const sourceNode = ogma.getNode(nameToId[source]);
const destNode = ogma.getNode(nameToId[destination]);
const sp = ogma.algorithms.shortestPath({
source: sourceNode,
target: destNode
});
// Highlight all the nodes in the shortest path
sp.then(({ nodes }) => {
nodes.addClass(shortestPathClass);
// Get all the edges that connect the nodes of the shortest
// path together, and highlight them
nodes
.getAdjacentEdges()
.filter(
edge =>
nodes.includes(edge.getSource()) && nodes.includes(edge.getTarget())
)
.addClass(shortestPathClass);
});
}
};
// Load data and init application
const graph = await Ogma.parse.jsonFromUrl('./parisMetro.json');
populateUI(graph);
graph.nodes.forEach(node => {
nameToId[node.data.local_name] = node.id;
});
await ogma.view.locateRawGraph(graph);
await ogma.setGraph(graph);
// clicking on nodes will run the layout, if possible
ogma.events
.on('click', evt => {
if (evt.target && evt.target.isNode) {
form['central-node-select'].value = evt.target.getData('local_name');
applyLayout(ogma);
}
})
// update layout outlines after it's done
.on('layoutEnd', evt => {
// store initial positions
if (!layoutData.initialPositions) {
layoutData.initialPositions = evt.positions.before;
layoutData.nodeIds = evt.ids;
}
collectRadii(ogma);
});
/**
* Reads mode toggle status from the form
*/
const updateUI = function () {
const select = form['mode-switch'];
const currentMode = Array.prototype.filter.call(select, function (input) {
return input.checked;
})[0].value; // IE inconsistency
if (currentMode !== state.mode) {
if (currentMode === 'geo') resetLayout(ogma);
toggleGeoMode(ogma).then(() => {
//ogma.view.locateGraph({ duration: 200 });
state.mode = currentMode;
const layoutPanel = document.querySelector('.toolbar .layout');
if (state.mode === 'geo') layoutPanel.classList.add('disabled');
else {
layoutPanel.classList.remove('disabled');
onShortestPath();
applyLayout(ogma);
}
});
}
};
// listen for changes on the form
form.addEventListener('change', updateUI);
// disable mouse wheel scroll propagation from UI to Ogma
form.addEventListener('mousewheel', function (evt) {
evt.stopPropagation();
});
// Add clear buttons to the search elements
(() => {
const toggleClear = function (input, button) {
button.classList[input.value ? 'remove' : 'add']('hidden');
};
function onInputChange(evt) {
let button = this.parentNode.querySelector('.clear');
if (!button) button = createButton(this);
toggleClear(this, button);
}
const createButton = function (input) {
const button = document.createElement('span');
const parentNode = input.parentNode;
button.classList.add('clear');
button.innerHTML = '×';
if (input.nextSibling) parentNode.insertBefore(button, input.nextSibling);
else parentNode.appendChild(button);
button.addEventListener('click', function (e) {
input.value = '';
toggleClear(input, this);
onShortestPath();
applyLayout(ogma);
});
return button;
}.bind(this);
Array.prototype.forEach.call(
document.querySelectorAll('.clearable-input'),
function (input) {
input.addEventListener('input', onInputChange);
input.addEventListener('focus', onInputChange);
input.addEventListener('blur', onInputChange);
}
);
})();
html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/leaflet@1.7.1/dist/leaflet.css"
/>
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.7.1/dist/leaflet-src.min.js"></script>
<link type="text/css" rel="stylesheet" href="style.css" />
<link
rel="stylesheet"
type="text/css"
href="https://cdn.jsdelivr.net/npm/awesomplete@1.1.2/awesomplete.css"
/>
<script src="https://cdn.jsdelivr.net/npm/awesomplete@1.1.2/awesomplete.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="graph-container"></div>
<div class="attribution">
Paris Metro | Graph data ©
<a
target="_blank"
rel="noopener"
href="https://github.com/totetmatt/gexf/tree/master/metro/Paris"
>Matthieu Totet</a
>
CC-BY-SA
</div>
<form class="toolbar" id="ui">
<div class="section mode">
<div class="switch switch--horizontal">
<input
id="graph"
type="radio"
name="mode-switch"
value="graph"
checked="checked"
/>
<label for="graph">Graph</label>
<input id="geo" type="radio" name="mode-switch" value="geo" />
<label for="geo">Geo</label>
<span class="toggle-outside">
<span class="toggle-inside"></span>
</span>
</div>
</div>
<div class="section shortest-path">
<h3>Shortest path</h3>
<p>
<label for="from-node-select">From</label>
<input
name="from-node-select"
id="from-node-select"
class="clearable-input"
placeholder="Select"
/>
</p>
<p>
<label for="to-node-select">To</label>
<input
name="to-node-select"
id="to-node-select"
class="clearable-input"
placeholder="Select"
/>
</p>
</div>
<div class="section layout">
<h3>Distance</h3>
<p>
<label for="central-node-select">Center</label>
<input
name="central-node-select"
id="central-node-select"
class="clearable-input"
placeholder="Select"
/>
</p>
</div>
</form>
<form id="options" class="control-bar">
<div class="content">
<label
><input
type="range"
id="push-ratio"
value="1"
min="0.1"
max="2"
step="0.1"
/>
Push factor</label
>
<label
><input type="checkbox" id="overlap" /> allow nodes overlap</label
>
<label><input type="checkbox" id="randomize" /> randomize nodes</label>
<label><input type="checkbox" id="animate" /> animate</label>
</div>
<div class="controls">
<button id="reset">Reset</button>
</div>
</form>
<script src="index.js"></script>
</body>
</html>
css
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
font-family: 'IBM Plex Sans', Arial, Helvetica, sans-serif;
}
*,
*:before,
*:after {
box-sizing: border-box;
}
#graph-container {
top: 0;
bottom: 0;
left: 0;
right: 0;
position: absolute;
margin: 0;
overflow: hidden;
}
.toolbar {
display: block;
position: absolute;
top: 20px;
right: 20px;
padding: 10px;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65);
border-radius: 4px;
background: #ffffff;
color: #222222;
font-weight: 300;
z-index: 9999;
}
.toolbar .section {
position: relative;
display: block;
}
.toolbar .section h3 {
display: block;
font-weight: 300;
border-bottom: 1px solid #ddd;
color: #606060;
font-size: 1rem;
}
.toolbar .section .clearable-input {
border-radius: 4px;
padding: 5px;
border: 1px solid #dddddd;
}
.toolbar .section .line-color,
.ogma-tooltip .line-color {
display: inline-block;
width: 1rem;
height: 1rem;
margin-right: 0.25rem;
border-radius: 50%;
color: #ffffff;
text-align: center;
font-size: 0.65rem;
line-height: 1rem;
font-weight: bold;
vertical-align: text-top;
}
.toolbar .section .awesomplete > ul {
max-height: 500px;
overflow-y: auto;
width: 350px;
right: 0;
left: auto;
transform-origin: 50% 0;
}
.toolbar .section p {
padding-left: 18px;
padding-right: 18px;
}
.toolbar .section p label {
width: 4rem;
display: inline-block;
}
.toolbar .mode {
text-align: center;
}
.toolbar .disabled {
display: none;
}
.ogma-tooltip-header .title {
font-weight: bold;
text-transform: uppercase;
}
/* --- Tooltip */
.ogma-tooltip {
max-width: 240px;
max-height: 280px;
background-color: #fff;
border: 1px solid #999;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
border-radius: 6px;
cursor: auto;
font-size: 12px;
pointer-events: none;
z-index: 9999;
}
.ogma-tooltip-header {
font-variant: small-caps;
font-size: 120%;
color: #000;
border-bottom: 1px solid #999;
padding: 10px;
}
.ogma-tooltip-body {
padding: 10px;
overflow-x: hidden;
overflow-y: auto;
max-width: inherit;
max-height: 180px;
}
.ogma-tooltip-body th {
color: #999;
text-align: left;
}
.ogma-tooltip-footer {
padding: 10px;
border-top: 1px solid #999;
}
.ogma-tooltip > .arrow {
border-width: 10px;
position: absolute;
display: block;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
}
.ogma-tooltip.top {
margin-top: -12px;
}
.ogma-tooltip.top > .arrow {
left: 50%;
bottom: -10px;
margin-left: -10px;
border-top-color: #999;
border-bottom-width: 0;
}
.ogma-tooltip.bottom {
margin-top: 12px;
}
.ogma-tooltip.bottom > .arrow {
left: 50%;
top: -10px;
margin-left: -10px;
border-bottom-color: #999;
border-top-width: 0;
}
.ogma-tooltip.left {
margin-left: -12px;
}
.ogma-tooltip.left > .arrow {
top: 50%;
right: -10px;
margin-top: -10px;
border-left-color: #999;
border-right-width: 0;
}
.ogma-tooltip.right {
margin-left: 12px;
}
.ogma-tooltip.right > .arrow {
top: 50%;
left: -10px;
margin-top: -10px;
border-right-color: #999;
border-left-width: 0;
}
/* -- CSS flip switch */
.switch {
width: 100%;
position: relative;
}
.switch input {
position: absolute;
top: 0;
z-index: 2;
opacity: 0;
cursor: pointer;
}
.switch input:checked {
z-index: 1;
}
.switch input:checked + label {
opacity: 1;
cursor: default;
}
.switch input:not(:checked) + label:hover {
opacity: 0.5;
}
.switch label {
color: #222222;
opacity: 0.33;
transition: opacity 0.25s ease;
cursor: pointer;
}
.switch .toggle-outside {
height: 100%;
border-radius: 2rem;
padding: 0.25rem;
overflow: hidden;
transition: 0.25s ease all;
}
.switch .toggle-inside {
border-radius: 2.5rem;
background: #4a4a4a;
position: absolute;
transition: 0.25s ease all;
}
.switch--horizontal {
width: 15rem;
height: 2rem;
margin: 0 auto;
font-size: 0;
margin-bottom: 1rem;
}
.switch--horizontal input {
height: 2rem;
width: 5rem;
left: 5rem;
margin: 0;
}
.switch--horizontal label {
font-size: 1rem;
line-height: 2rem;
display: inline-block;
width: 5rem;
height: 100%;
margin: 0;
text-align: center;
}
.switch--horizontal label:last-of-type {
margin-left: 5rem;
}
.switch--horizontal .toggle-outside {
background: #dddddd;
position: absolute;
width: 5rem;
left: 5rem;
}
.switch--horizontal .toggle-inside {
height: 1.5rem;
width: 1.5rem;
}
.switch--horizontal input:checked ~ .toggle-outside .toggle-inside {
left: 0.25rem;
}
.switch--horizontal input ~ input:checked ~ .toggle-outside .toggle-inside {
left: 3.25rem;
}
/* ---- Clearable inputs */
.clearable-input {
position: relative;
display: inline-block;
padding-right: 1.4em;
}
.clearable-input + .clear {
position: absolute;
top: 4px;
right: 4px;
font-size: 1rem;
padding: 0;
line-height: 0.8rem;
border-radius: 50%;
background: #dddddd;
color: #808080;
cursor: pointer;
width: 1rem;
height: 1rem;
text-align: center;
}
.clearable-input + .clear:hover {
background: #aaaaaa;
color: #ffffff;
}
.clearable-input::-ms-clear {
display: none;
}
.hidden {
display: none;
}
#options {
position: absolute;
top: 350px;
right: 20px;
padding: 10px;
background: white;
z-index: 400;
display: none;
}
#options label {
display: block;
}
#options .controls {
text-align: center;
margin-top: 10px;
}
#options .content {
line-height: 1.5em;
}
.control-bar {
font-family: Helvetica, Arial, sans-serif;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65);
border-radius: 4px;
}
.attribution {
position: absolute;
right: 0px;
bottom: 0px;
padding: 0px;
z-index: 1000;
font-size: 11px;
padding: 1px 5px;
background: rgba(255, 255, 255, 0.7);
}
js
export const modeTransitionDuration = 500; // geo-graph transition duration
export const outlinesColor = '#bbb'; // color of radius outlines
export const textColor = '#777'; // color of the radius labels
export const fontSize = 12; // font size for the radius marks
export const nameToId = {}; // map station names to node ids
export const shortestPathClass = 'shortestPath';
export const font = 'IBM Plex Sans';
export const state = {
/** @enum {"graph"|"geo"} */
mode: 'graph'
};
js
import {
modeTransitionDuration,
state,
outlinesColor,
fontSize,
textColor,
nameToId
} from './constants';
import Ogma, { CanvasLayer } from '@linkurious/ogma';
export const layoutData = {
centralNode: null,
radiusDelta: 200
};
// canvas for radius outlines
const tilesUrl =
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{retina}.png';
/**
* Reset from radial layout to initial positions
*/
export const resetLayout = function (ogma) {
ogma.clearSelection();
layoutData.centralNode = null;
if (layoutData.nodeIds) {
ogma.getNodes().setAttributes(layoutData.initialPositions);
}
renderLayoutOutlines(ogma);
};
/**
* Toggle geo mode on/off
* @return {Promise}
*/
export const toggleGeoMode = function (ogma) {
if (state.mode === 'graph') {
const url = tilesUrl.replace(
'{retina}',
Ogma.utils.getPixelRatio() > 1 ? '@2x' : ''
);
return ogma.geo.enable({
latitudePath: 'latitude',
longitudePath: 'longitude',
tileUrlTemplate: url,
duration: modeTransitionDuration,
sizeRatio: 0.1
});
} else {
return ogma.geo.disable({ duration: modeTransitionDuration });
}
};
/** @type {CanvasLayer} */
let canvasLayer;
/**
* Renders circles outlining the layout layers (orders of graph-theoretical
* distance)
* @param {Ogma} ogma
*/
export const renderLayoutOutlines = ogma => {
// draw outlines only in graph mode
if (layoutData.centralNode && state.mode !== 'geo') {
if (canvasLayer) return canvasLayer.refresh();
canvasLayer = ogma.layers.addCanvasLayer(ctx => {
const zoom = ogma.view.getZoom();
const center = layoutData.center;
const pixelRatio = Ogma.utils.getPixelRatio();
let i, len, distance, radius;
ctx.lineWidth = 8;
ctx.strokeStyle = outlinesColor;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// concentric circles
ctx.beginPath();
for (i = 0, len = layoutData.distances.length; i < len; i++) {
distance = layoutData.distances[i];
radius = distance; // * pixelRatio * zoom;
ctx.moveTo(center.x + radius, center.y);
ctx.arc(center.x, center.y, radius, 0, 2 * Math.PI, false);
ctx.moveTo(center.x + radius, center.y);
}
ctx.closePath();
ctx.stroke();
// label backgrounds
ctx.fillStyle = '#ffffff';
ctx.beginPath();
for (i = 0, len = layoutData.distances.length; i < len; i++) {
distance = layoutData.distances[i];
radius = distance; // * pixelRatio * zoom;
ctx.arc(
center.x + radius,
center.y,
(fontSize * pixelRatio) / zoom,
0,
2 * Math.PI,
false
);
}
ctx.fill();
// label texts
ctx.fillStyle = textColor;
ctx.font = fontSize / zoom + 'px sans-serif';
for (i = 0, len = layoutData.distances.length; i < len; i++) {
distance = layoutData.distances[i];
radius = distance; // * pixelRatio * zoom;
ctx.fillText(distance / 200, center.x + radius, center.y);
}
});
canvasLayer.moveToBottom();
} else {
if (canvasLayer) canvasLayer.destroy();
canvasLayer = null;
}
};
// Calculates distances of elements from center and stores them to be rendered
// in `renderLayoutOutlines`
export const collectRadii = function (ogma) {
const nodes = ogma.getNodes();
const positions = nodes.getPosition();
const ids = nodes.getId();
const center = ogma.getNode(layoutData.centralNode).getPosition();
const layers = {};
for (let i = 0, len = nodes.size; i < len; i++) {
const pos = positions[i];
const dist = Math.round(
Ogma.geometry.distance(center.x, center.y, pos.x, pos.y)
);
layers[dist] = layers[dist] || [];
layers[dist].push(ids[i]);
}
layoutData.layers = layers;
layoutData.center = center;
layoutData.positions = positions;
layoutData.distances = Object.keys(layers).map(key => parseInt(key));
renderLayoutOutlines(ogma);
};
/**
* Run graph-theoretical distance-based radial layout
* @param {Ogma} ogma
*/
export const runLayout = (ogma, newCenter) => {
if (state.mode === 'geo') return;
if (
newCenter &&
nameToId[newCenter] &&
layoutData.centralNode !== nameToId[newCenter]
) {
layoutData.centralNode = nameToId[newCenter];
ogma.layouts.radial({
centralNode: layoutData.centralNode,
radiusDelta: layoutData.radiusDelta,
locate: true
});
} else resetLayout(ogma);
};
json
{
"nodes": [
{
"degree": 5,
"id": "Gare de LyonPARIS-12EME",
"inDegree": 3,
"outDegree": 2,
"active": false,
"halo": false,
"hidden": false,
"latitude": 48.844757,
"longitude": 2.3740723,
"pinned": false,
"size": 42.42640687119285,
"text": "Gare de Lyon",
"x": 525.1153564453125,
"y": -281,
"isNode": true,
"data": {
"latin_name": "Gare de Lyon",
"Eccentricity": 23,
"Betweenness Centrality": 66284.82695915208,
"Closeness Centrality": 8.378731343283581,
"local_name": "Gare de Lyon",
"location": "PARIS-12EME",
"Eigenvector Centrality": 1,
"lines": "Line14-Line1-RERA-RERD",
"latitude": 48.844757,
"longitude": 2.3740723
},
"attributes": {
"radius": 42.42640687119285,
"color": [
"#67328E",
"#F2C931"
],
"text": "Gare de Lyon"
}
},
{
"degree": 3,
"id": "Gare du NordPARIS-10EME",
"inDegree": 2,
"outDegree": 1,
"active": false,
"halo": false,
"hidden": false,
"latitude": 48.880035,
"longitude": 2.3545492,
"pinned": false,
"size": 42.42640687119285,
"text": "Gare du Nord",
"x": 496.4261779785156,
"y": -1209,
"isNode": true,
"data": {
"latin_name": "Gare du Nord",
"Eccentricity": 25,
"Betweenness Centrality": 36901.69440836936,
"Closeness Centrality": 8.89179104477612,
"local_name": "Gare du Nord",
"location": "PARIS-10EME",
"Eigenvector Centrality": 0.5366748142943254,
"lines": "Line4-Line5-RERB-RERD",
"latitude": 48.880035,
"longitude": 2.3545492
},
"attributes": {
"radius": 42.42640687119285,
"color": [
"#BB4D98",
"#DE8B53"
],
"text": "Gare du Nord"
}
},
{
"degree": 7,
"id": "NationPARIS-12EME",
"inDegree": 3,
"outDegree": 4,
"active": false,
"halo": false,
"hidden": false,
"latitude": 48.848465,
"longitude": 2.3959057,
"pinned": false,
"size": 60,
"text": "Nation",
"x": 989.2464599609375,
"y": -235,
"isNode": true,
"data": {
"latin_name": "Nation",
"Eccentricity": 24,
"Betweenness Centrality": 31660.579437229462,
"Closeness Centrality": 9.078358208955224,
"local_name": "Nation",
"location": "PARIS-12EME",
"Eigenvector Centrality": 0.7770134775054275,
"lines": "Line1-Line2-Line6-Line9-RERA",
"latitude": 48.848465,
"longitude": 2.3959057
},
"attributes": {
"radius": 60,
"color": [
"#F2C931",
"#216EB4",
"#75c695",
"#CDC83F"
],
"text": "Nation"
}
},
{
"degree": 6,
"id": "Charles de Gaulle - EtoilePARIS-08EME",
"inDegree": 1,
"outDegree": 5,
"active": false,
"halo": false,
"hidden": false,
"latitude": 48.87441,
"longitude": 2.2957628,
"pinned": false,
"size": 51.96152422706631,
"text": "Charles de Gaulle - Etoile",
"x": -973.6716918945312,
"y": -1286,
"isNode": true,
"data": {
"latin_name": "Charles de Gaulle - Etoile",
"Eccentricity": 26,
"Betweenness Centrality": 30386.45905483406,
"Closeness Centrality": 9.42910447761194,
"local_name": "Charles de Gaulle - Etoile",
"location": "PARIS-08EME",
"Eigenvector Centrality": 0.35464528135158707,
"lines": "Line1-Line2-Line6-RERA",
"latitude": 48.87441,
"longitude": 2.2957628
},
"attributes": {
"radius": 51.96152422706631,
"color": [
"#F2C931",
"#216EB4",
"#75c695"
],
"text": "Charles de Gaulle - Etoile"
}
},
{
"degree": 4,
"id": "InvalidesPARIS-07EME",
"inDegree": 2,
"outDegree": 2,
"active": false,
"halo": false,
"hidden": false,
"latitude": 48.862553,
"longitude": 2.313989,
"pinned": false,
"size": 42.42640687119285,
"text": "Invalides",
"x": -689.1814575195312,
"y": -520,
"isNode": true,
"data": {
"latin_name": "Invalides",
"Eccentricity": 25,
"Betweenness Centrality": 22916.702586302596,
"Closeness Centrality": 9.598880597014926,
"local_name": "Invalides",
"location": "PARIS-07EME",
"Eigenvector Centrality": 0.44784291910876195,
"lines": "Line13-Line8-RERC",
"latitude": 48.862553,
"longitude": 2.313989
},
"attributes": {
"radius": 42.42640687119285,
"color": [
"#89C7D6",
"#C5A3CA"
],
"text": "Invalides"
}
},
{
"degree": 8,
"id": "ChâteletPARIS-01ER",
"inDegree": 0,
"outDegree": 8,
...
js
const LINE_RE = /(Line|RER)([\d\w]+)/i; // RegExp to match line numbers
/**
* Format color for the autocomplete and tooltip
* @param {String} color Color
* @param {String} name Line name
* @return {String}
*/
const formatColor = function (color, name) {
const code = name.match(LINE_RE)[2];
return (
'<span class="line-color" style="background: ' +
color +
'" title="' +
name +
'">' +
code +
'</span>'
);
};
/**
* Format line information for the station: multiple lines and colors for them
* @param {Array<String>|String} colors
* @param {String} lines
* @return {String}
*/
export const formatColors = function (colors, lines) {
const linesArray = lines.split('-');
return Array.isArray(colors)
? colors.map((color, i) => formatColor(color, linesArray[i])).join('')
: formatColor(colors, linesArray[0]);
};