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.

ts
import Ogma, { RawGraph, Edge, Node, Color } from '@linkurious/ogma';
import { formatColors } from './utils';
import { layoutData, resetLayout, collectRadii, applyLayout } from './layout';
import { toggleGeoMode } from './geo';
import {
  nameToId,
  shortestPathClass,
  font,
  form,
  shortestPathStyle
} from './constants';
import { MetroEdgeData, MetroNodeData, Station } from './types';
import { Autocomplete } from './autocomplete';

import './style.css';
import './controls.css';
import './toggle.css';
import { state } from './state';

const ogma = new Ogma<MetroNodeData, MetroEdgeData>({
  container: 'graph-container',
  options: {
    backgroundColor: null,
    interactions: { zoom: { onDoubleClick: true } }
  }
});

// always show labels
ogma.styles.addNodeRule({
  text: {
    minVisibleSize: 0,
    font
  },
  radius: n => Number(n.getAttribute('radius')) / 2
});

// Add tooltip when hovering the node
ogma.tools.tooltip.onNodeHover(
  (node: Node) => {
    const color = formatColors(
      node.getAttribute('color') as 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);

/**
 * Populates UI elements based on received graph data
 * @param {RawGraph<MetroNode, MetroEdge>} graph
 */
const populateUI = function (graph: RawGraph<MetroNodeData, MetroEdgeData>) {
  const stations = graph.nodes.map(node => {
    const { local_name: name, lines } = node.data!;
    return {
      id: node.id!,
      name,
      lines,
      colors: node.attributes!.color
    } as Station;
  });
  const sortedStations = stations.sort((a, b) => a.name.localeCompare(b.name));

  const ac = new Autocomplete(sortedStations);

  ac.createAutocomplete('from', onShortestPath);
  ac.createAutocomplete('to', onShortestPath);

  ac.createAutocomplete(
    'center',
    () => applyLayout(ogma),
    () => {
      resetLayout(ogma);
      ogma
        .getNode(nameToId[form['center-node-select'].value])
        ?.setSelected(true);
    }
  );
};

/**
 * Shortest path UI callback
 */
const onShortestPath = function () {
  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(shortestPathStyle);

/**
 * Calculate and render shortest path
 * @param  {String} source
 * @param  {String} destination
 */
const showShortestPath = function (source: string, destination: string) {
  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(subGraph => {
      const nodes = subGraph!.nodes;
      nodes.addClass(shortestPathClass);

      // Get all the edges that connect the nodes of the shortest
      // path together, and highlight them
      nodes
        .getAdjacentEdges()
        .filter(
          (edge: Edge) =>
            nodes.includes(edge.getSource()) && nodes.includes(edge.getTarget())
        )
        .addClass(shortestPathClass);
    });
  }
};

// Load data and init application
const graph: RawGraph<MetroNodeData, MetroEdgeData> =
  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['center-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 currentMode = form['mode-switch'].checked;
  state.geo = currentMode;

  // Disable the toggle while switching modes
  form['mode-switch'].disabled = true;

  const graphLabel = document.getElementById('label-graph') as Element;
  const geoLabel = document.getElementById('label-geo') as Element;

  graphLabel.classList.toggle('selected', !currentMode);
  geoLabel.classList.toggle('selected', currentMode);

  if (currentMode) resetLayout(ogma);

  toggleGeoMode(ogma).then(() => {
    //ogma.view.locateGraph({ duration: 200 });
    const layoutPanel = document.querySelector('.toolbar .layout') as Element;

    if (state.geo) layoutPanel.classList.add('disabled');
    else {
      layoutPanel.classList.remove('disabled');
      onShortestPath();
      applyLayout(ogma);
    }

    // Re-enable after the mode switch is done
    form['mode-switch'].disabled = false;
  });
};

// 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();
});
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 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">
          <label class="switch">
            <span class="label-text selected" id="label-graph">Graph</span>
            <input name="mode-switch" type="checkbox" />
            <span class="slider"></span>
            <span class="label-text" id="label-geo">Geo</span>
          </label>
        </div>
      </div>
      <div class="section shortest-path">
        <h3>Shortest path</h3>
        <div class="input-group" id="shortest-path">
          <div class="autocomplete">
            <label for="from-node-select" class="input-label">From</label>
            <div class="clearable-input-container">
              <input
                id="from-node-select"
                name="from-node-select"
                class="clearable-input"
                type="text"
                placeholder="Select"
              />
              <span class="clear hidden">&times;</span>
            </div>
          </div>
          <div class="autocomplete">
            <label for="to-node-select" class="input-label">To</label>
            <div class="clearable-input-container">
              <input
                id="to-node-select"
                name="to-node-select"
                class="clearable-input"
                type="text"
                placeholder="Select"
              />
              <span class="clear hidden">&times;</span>
            </div>
          </div>
        </div>
      </div>
      <div class="section layout">
        <h3>Distance</h3>
        <div class="input-group">
          <div class="autocomplete">
            <label for="center-node-select">Center</label>
            <div class="clearable-input-container">
              <input
                id="center-node-select"
                name="center-node-select"
                type="text"
                class="clearable-input"
                placeholder="Select"
              />
              <span class="clear hidden">&times;</span>
            </div>
          </div>
        </div>
      </div>
    </form>
    <script src="index.ts"></script>
  </body>
</html>
css
html {
  height: 100%;
}

body,
#root {
  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 .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.55rem;
  line-height: 1rem;
  font-weight: bold;
  vertical-align: text-top;
}

.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;
}

#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);
}

.toolbar .disabled {
  display: none;
}
css
.autocomplete {
  position: relative;
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
}

.input-label {
  display: flex;
  align-self: center;
}

.clearable-input-container {
  position: relative;
}

.clearable-input {
  font-size: 14px;
  border-radius: 5px;
  padding: 7px 10px;
  border: 1px solid #dddddd;
  font-family: 'IBM Plex Sans', Helvetica, Arial, sans-serif;
}

.clearable-input::placeholder {
  color: #999999;
}

.clearable-input::-ms-clear {
  display: none;
}

.clear {
  position: absolute;
  right: 10px;
  top: 5px;
  cursor: pointer;
  color: #999999;
}

.clear:hover {
  color: #666666;
  -webkit-text-stroke: 1px #8b8b8b;
}

.hidden {
  display: none;
}

.optionlist {
  position: absolute;
  z-index: 1;
  background-color: white;
  border: 1px solid #ccc;
  width: 100%;
  height: auto;
  max-height: 200px;
  overflow-y: auto;
  list-style: none;
  padding: 0;
  margin: 0;
  translate: 0 32px;
  display: block;
  border-radius: 4px;
  top: 0;
}

.optionlist.hidden {
  display: none;
}

.optionlist-item {
  border-bottom: 1px solid #999;
  padding: 10px;
  color: #000;
  font-weight: normal;
  font-size: 14px;
  cursor: pointer;
}

.optionlist-item:hover {
  background-color: #f0f0f0;
}

.optionlist-item.selected {
  background-color: #d0e0ff;
}

.optionlist-item.selected:hover {
  background-color: #aec6f8;
}
ts
import { Station } from './types';
import { createOptionList } from './optionlist';

import './autocomplete.css';
export class Autocomplete {
  private sortedStations: Station[];

  constructor(sortedStations: Station[]) {
    this.sortedStations = sortedStations;
  }

  public createAutocomplete(
    labelText: string,
    action: () => void,
    resetGraph?: () => void
  ) {
    const input = document.getElementById(
      labelText + '-node-select'
    ) as HTMLInputElement;
    const clearButton = input.nextElementSibling as HTMLSpanElement;

    let position = 0;

    const optionList = createOptionList({
      filteredStations: [],
      labelText,
      handleOptionClick: handleOptionClick
    });

    // Update the options
    Array.prototype.forEach.call(['input', 'focus'], eventName =>
      input.addEventListener(eventName, () => {
        if (input.value.length > 0) clearButton.style.display = 'block';
        else clearButton.style.display = 'none';

        const filtered = this.sortedStations.filter(st =>
          st.name.toLowerCase().includes(input.value.toLowerCase())
        );

        position = 0; // Reset position on input change

        // If the amount of filtered stations is greater than 0, show and update the option list
        if (filtered.length > 0) {
          optionList.update(filtered, position);
          optionList.show();
        } else {
          optionList.hide();
        }
      })
    );

    input.addEventListener('keydown', event => {
      // Handle arrow keys and enter key
      const currentStations = optionList.currentStations;
      if (event.key === 'ArrowDown') {
        event.preventDefault();
        position = (position + 1) % currentStations.length;
        optionList.updateSelectedOption(position, false);
      } else if (event.key === 'ArrowUp') {
        event.preventDefault();
        position =
          (position - 1 + currentStations.length) %
          currentStations.length;
        optionList.updateSelectedOption(position, true);
      } else if (event.key === 'Enter') {
        // If an option is selected, click it
        if (position >= 0 && position < currentStations.length) {
          handleOptionClick(optionList.getIthStation(position));
        }
      } else if (event.key === 'Escape') {
        // Hide the option list on escape and reset position
        input.blur();
        position = 0;
      }
    });

    // On unfocus
    input.addEventListener('blur', () => {
      // Delay to allow click on option list
      setTimeout(() => {
        optionList.hide();
        const match = this.sortedStations.find(
          st => st.name.toLowerCase() === input.value.toLowerCase()
        );
        // Automatically select the station if it matches (case insensitive)
        if (match) {
          input.value = match.name;
        }
      }, 200);
    });

    // Clear button functionality
    clearButton.addEventListener('click', () => {
      input.value = '';
      clearButton.style.display = 'none';
      action();
      resetGraph?.();
      input.focus();
    });

    // Click handler for the options
    function handleOptionClick(station: Station) {
      input.value = station.name;
      clearButton.style.display = 'block';
      optionList.hide();
      position = 0;
      action();
      input.blur();
    }

    // Add the option list after it's created
    input.parentNode?.parentNode?.appendChild(optionList.element);
  }
}
ts
import { NodeId } from '@linkurious/ogma';

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: Record<string, NodeId> = {}; // map station names to node ids
export const shortestPathClass = 'shortestPath';
export const font = 'IBM Plex Sans';

export const form = document.querySelector('#ui') as HTMLFormElement;
export const shortestPathStyle = {
  name: shortestPathClass,
  nodeAttributes: {
    outerStroke: {
      color: 'red',
      width: 5
    }
  },
  edgeAttributes: {
    stroke: {
      width: 5,
      color: 'red'
    },
    color: 'red'
  }
};
css
.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;
  width: 280px;
}

.toolbar .section {
  position: relative;
  display: block;
}

.toolbar .section h3 {
  display: block;
  font-weight: 300;
  border-bottom: 1px solid #ddd;
  color: #606060;
  font-size: 1rem;
  margin-bottom: 12px;
  padding-bottom: 4px;
}

.input-group {
  display: flex;
  flex-direction: column;
  gap: 0.8em;
  align-items: center;
  padding: 0 0.3em;
}
ts
import Ogma from '@linkurious/ogma';
import { modeTransitionDuration } from './constants';
import { state } from './state';

// canvas for radius outlines
const tilesUrl =
  'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{retina}.png';

/**
 * Toggle geo mode on/off
 * @return {Promise}
 */
export const toggleGeoMode = function (ogma: Ogma) {
  if (state.geo) {
    const url = tilesUrl.replace('{retina}', devicePixelRatio > 1 ? '@2x' : '');
    return ogma.geo.enable({
      latitudePath: 'latitude',
      longitudePath: 'longitude',
      tiles: {
        url: url
      },
      duration: modeTransitionDuration,
      sizeRatio: 0.25
    });
  } else {
    return ogma.geo.disable({ duration: modeTransitionDuration });
  }
};
ts
import {
  outlinesColor,
  fontSize,
  textColor,
  nameToId,
  form
} from './constants';
import Ogma, { CanvasLayer, NodeId } from '@linkurious/ogma';
import { DataLayout } from './types';
import { state } from './state';

export const layoutData: DataLayout = {
  radiusDelta: 200
};

/**
 * Read the current central node and apply the layout
 * @param {Ogma} ogma
 */
export const applyLayout = (ogma: Ogma) => {
  runLayout(ogma, form['center-node-select'].value);
};

/**
 * Reset from radial layout to initial positions
 */
export const resetLayout = function (ogma: Ogma) {
  ogma.clearSelection();
  layoutData.centralNode = undefined;

  if (layoutData.nodeIds && layoutData.initialPositions) {
    ogma.getNodes().setAttributes(layoutData.initialPositions);
  }
  renderLayoutOutlines(ogma);
};

let canvasLayer: CanvasLayer | null;
/**
 * Renders circles outlining the layout layers (orders of graph-theoretical
 * distance)
 * @param {Ogma} ogma
 */
export const renderLayoutOutlines = (ogma: Ogma) => {
  // draw outlines only in graph mode
  if (layoutData.centralNode && !state.geo) {
    if (canvasLayer) return canvasLayer.refresh();
    canvasLayer = ogma.layers.addCanvasLayer(ctx => {
      const zoom = ogma.view.getZoom();
      const center = layoutData.center;
      const pixelRatio = devicePixelRatio;
      let i, len, distance, radius;

      ctx.lineWidth = 8;
      ctx.strokeStyle = outlinesColor;
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';

      // concentric circles
      ctx.beginPath();
      if (layoutData.distances && layoutData.distances.length > 1 && center) {
        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();

      if (layoutData.distances && layoutData.distances.length > 1 && center) {
        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';
      if (layoutData.distances && layoutData.distances.length > 1 && center) {
        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: Ogma) {
  if (!layoutData.centralNode) return;
  const nodes = ogma.getNodes();
  const positions = nodes.getPosition();
  const ids = nodes.getId();
  const center = ogma.getNode(layoutData.centralNode)!.getPosition();
  const layers: NodeId[][] = [];
  for (let i = 0; i < nodes.size; 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: Ogma, newCenter: string) => {
  if (state.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);
};
ts
import { Color } from '@linkurious/ogma';
import { Station } from './types';
import { formatColors } from './utils';

export function createOptionList(options: {
  filteredStations: Station[];
  labelText: string;
  handleOptionClick: (station: Station) => void;
}) {
  let currentStations: Station[] = options.filteredStations;

  const ul = document.createElement('ul');
  ul.id = options.labelText;
  ul.className = 'optionlist';
  ul.style.display = 'none';

  function update(filteredStations: Station[], position: number) {
    currentStations = filteredStations;
    ul.innerHTML = '';

    // Generate the options
    filteredStations.forEach((station, i) => {
      const li = document.createElement('li');
      li.className = 'optionlist-item';
      if (i === position) li.classList.add('selected');
      li.addEventListener('click', () => options.handleOptionClick(station));

      const arrow = document.createElement('div');
      arrow.className = 'arrow';

      const label = document.createElement('span');
      label.innerHTML = `${formatColors(station.colors as Color, station.lines)} ${station.name}`;

      li.appendChild(arrow);
      li.appendChild(label);
      ul.appendChild(li);
    });
  }

  // Update the option that is selected
  function updateSelectedOption(position: number, isUp: boolean) {
    let old: Element;
    if (isUp) {
      if (position + 1 === currentStations.length) {
        old = ul.firstElementChild as Element;
      } else {
        old = ul.children[position + 1];
      }
    } else {
      if (position - 1 < 0) {
        old = ul.lastElementChild as Element;
      } else {
        old = ul.children[position - 1];
      }
    }
    const current = ul.children[position];
    old?.classList.remove('selected');
    current?.classList.add('selected');

    // Scroll the selected item into view if it is invisible
    if (isItemInvisible(current)) {
      current?.scrollIntoView({
        block: isUp ? 'start' : 'end',
        inline: 'nearest',
        behavior: 'smooth'
      });
    }
  }

  function show() {
    ul.style.display = 'block';
  }

  function hide() {
    ul.style.display = 'none';
  }

  // Get a station by its index
  function getIthStation(i: number): Station {
    return currentStations[i];
  }

  // Checks if an item is invisible
  function isItemInvisible(el: Element) {
    const rect = el.getBoundingClientRect();
    const elemTop = rect.top;
    const elemBottom = rect.bottom - 1;

    const rect2 = ul.getBoundingClientRect();
    const ulTop = rect2.top;
    const ulBottom = rect2.bottom - 1;

    const isInvisible = elemTop <= ulTop || elemBottom >= ulBottom;
    return isInvisible;
  }

  return {
    element: ul,
    update,
    show,
    hide,
    updateSelectedOption,
    getIthStation,
    get currentStations() {
      return currentStations;
    }
  };
}
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,
     

...
ts
export const state = {
  // True if the graph is in geo mode, false if in graph mode
  geo: false
};
css
/* From Uiverse.io by arghyaBiswasDev */
/* The switch - the box around the slider */
.switch .switch--horizontal {
  width: 100%;
}

.switch {
  display: flex;
  flex-direction: row;
  justify-content: space-around;
  align-items: center;
  gap: 10px;
  padding: 0 10px;
  font-size: 17px;
  position: relative;
  width: 100%;
  height: 2em;
}

/* Hide default HTML checkbox */
.switch input {
  opacity: 0;
  width: 0;
  height: 0;
  display: none;
}

/* The slider */
.slider {
  display: flex;
  position: relative;
  width: 60px;
  height: 30px;
  background-color: #ccc;
  border-radius: 20px;
  cursor: pointer;
  transition: background-color 0.2s;
  flex-shrink: 0;
}

.slider:before {
  content: '';
  position: absolute;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background-color: white;
  top: 1px;
  left: 1px;
  transition: transform 0.2s;
}

input:focus + .slider {
  box-shadow: 0 0 1px #007bff;
}

input:checked + .slider:before {
  transform: translateX(30px);
}

.label-text {
  color: #808080;
  margin-left: 10px;
}

.selected {
  color: #222222;
}
ts
import { NodeId, Point } from '@linkurious/ogma';

export interface MetroNodeData {
  latin_name: string;
  Eccentricity: number;
  'Betweenness Centrality': number;
  'Closeness Centrality': number;
  local_name: string;
  location: string;
  'Eigenvector Centrality': number;
  lines: string;
  latitude: number;
  longitude: number;
}

interface MetroNodeAttributes {
  radius: number;
  color: string | string[];
  text: string;
}

export interface MetroNode {
  id: string;
  degree: number;
  inDegree: number;
  outDegree: number;
  active: boolean;
  halo: boolean;
  hidden: boolean;
  latitude: number;
  longitude: number;
  pinned: boolean;
  size: number;
  text: string;
  x: number;
  y: number;
  isNode: boolean;
  data: MetroNodeData;
  attributes: MetroNodeAttributes;
}

export interface MetroEdgeData {
  kind: string;
}

interface MetroEdgeAttributes {
  width: number;
  color: string;
}

export interface MetroEdge {
  id: string;
  source: string;
  target: string;
  active: boolean;
  color: string;
  halo: boolean;
  hidden: boolean;
  isNode: boolean;
  curvature: number;
  data: MetroEdgeData;
  attributes: MetroEdgeAttributes;
}

export interface Station {
  id: string;
  name: string;
  lines: string;
  colors: string[] | string;
}

export interface DataLayout {
  nodeIds?: NodeId[];
  initialPositions?: Point[];
  layers?: NodeId[][];
  center?: { x: number; y: number };
  positions?: Point[];
  distances?: number[];
  centralNode?: NodeId;
  radiusDelta: number;
}
ts
import { Color } from '@linkurious/ogma';

const LINE_RE = /(Line|RER)([\d\w]+)/i; // RegExp to match line numbers

/**
 * Format color for the autocomplete and tooltip
 */
const formatColor = function (color: string, name: string) {
  const match = name.match(LINE_RE);
  const code = match ? match[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
 */
export const formatColors = function (colors: Color | Color[], lines: string) {
  const linesArray = lines.split('-');
  return Array.isArray(colors)
    ? colors.map((color, i) => formatColor(color!, linesArray[i])).join('')
    : formatColor(colors!, linesArray[0]);
};