Skip to content
  1. Examples

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')}&nbsp;${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) +
        '&nbsp;' +
        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 = '&times;';
    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]);
};