Skip to content
  1. Examples

Transport network analysis

tsx
import React from 'react';
import ReactDOM from 'react-dom';
import ReactDOMServer from 'react-dom/server';
import { createRoot } from 'react-dom/client';
import App from './App';

const root = createRoot(document.getElementById('root')!);
root.render(<App />);
html
<!doctype html>
<html lang="en">
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta charset="utf-8" />
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/leaflet@1.7.1/dist/leaflet.css"
    />
    <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"
    />
    <title>Transport Network React</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="./index.tsx"></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;
}

.ogma-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.65rem;
  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);
}
json
{
  "name": "@linkurious/transport-network-example",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "dependencies": {
    "@linkurious/ogma": "latest",
    "react": "19.0.0",
    "react-dom": "19.0.0",
    "@linkurious/ogma-react": "5.1.9"
  },
  "devDependencies": {
    "@eslint/js": "^9.26.0",
    "@types/react": "^19.1.4",
    "@types/react-dom": "^19.1.4",
    "@vitejs/plugin-react": "^4.4.1",
    "eslint": "^9.26.0",
    "eslint-plugin-react-hooks": "^5.2.0",
    "eslint-plugin-react-refresh": "^0.4.20",
    "globals": "^16.1.0",
    "typescript": "~5.8.3",
    "typescript-eslint": "^8.32.1",
    "vite": "^6.3.5"
  }
}
tsx
import React, { useState, useEffect, useCallback, createRef } from 'react';
import { renderToString } from 'react-dom/server';
import {
  Geo,
  NodeStyle,
  Ogma,
  StyleClass,
  useEvent
} from '@linkurious/ogma-react';

import OgmaLib, { RawGraph, Node as OgmaNode, Point } from '@linkurious/ogma';

import * as L from 'leaflet';
import 'leaflet/dist/leaflet.css';

import { MetroNode, MetroEdge, Station } from './types';
import { LoadingOverlay } from './LoadingOverlay';

import { TooltipContent } from './TooltipContent';
import { Controls } from './Controls';
import { CirclesLayer } from './CirclesLayer';

import './style.css';

// to enable geo mode integration
OgmaLib.libraries['leaflet'] = L;

function App() {
  const ogmaRef = createRef<OgmaLib>();
  const [graph, setGraph] = useState<
    RawGraph<MetroNode, MetroEdge> | undefined
  >();

  // States for the controls
  const [isGeo, setIsGeo] = useState(false);
  // This station has a state to rerender the graph every time
  const [selectedCenterStation, setSelectedCenterStation] = useState<
    Station | undefined
  >();

  // References
  const initialPositions = createRef<Point[]>();
  const centerStationRef = createRef<Station | undefined>();
  // These stations are not states to prevent rerendering the graph
  // when not needed
  const fromStationRef = createRef<Station | undefined>();
  const toStationRef = createRef<Station | undefined>();

  // Initialise the graph
  useEffect(() => {
    OgmaLib.parse
      .jsonFromUrl('files/paris-metro.json')
      .then((data: RawGraph) => {
        setGraph(data);
      });
  }, []);

  // Apply the radial layout when a node is selected through the controls
  useEffect(() => {
    if (!ogmaRef.current) return;
    ogmaRef.current.clearSelection();
    if (
      selectedCenterStation &&
      selectedCenterStation !== centerStationRef.current
    ) {
      const node = ogmaRef.current.getNode(
        selectedCenterStation.id
      ) as OgmaNode;
      centerStationRef.current = selectedCenterStation;
      ogmaRef.current.layouts.radial({
        centralNode: node,
        radiusDelta: 200,
        locate: true
      });
      node.setSelected(true);
    }
  }, [selectedCenterStation]);

  const setSelectedFromStation = useCallback((station: Station | undefined) => {
    if (!ogmaRef.current) return;
    // Prevents the function from executing if the station is the same as the previous one
    // for better performance
    if (station && station === fromStationRef.current) return;

    fromStationRef.current = station;
    ogmaRef.current
      .getNodesByClassName('shortestPath')
      .removeClass('shortestPath');
    ogmaRef.current
      .getEdgesByClassName('shortestPath')
      .removeClass('shortestPath');
    if (!station) return;
    if (!(toStationRef.current && station !== toStationRef.current)) return;

    // Executes only when both of the stations are defined
    showShortestPath(station, toStationRef.current);
  }, []);

  const setSelectedToStation = useCallback((station: Station | undefined) => {
    if (!ogmaRef.current) return;
    // Prevents the function from executing if the station is the same as the previous one
    // for better performance
    if (station && station === toStationRef.current) return;

    toStationRef.current = station;
    ogmaRef.current
      .getNodesByClassName('shortestPath')
      .removeClass('shortestPath');
    ogmaRef.current
      .getEdgesByClassName('shortestPath')
      .removeClass('shortestPath');
    if (!station) return;
    if (!(fromStationRef.current && station !== fromStationRef.current)) return;

    // Executes only when both of the stations are defined
    showShortestPath(fromStationRef.current, station);
  }, []);

  function showShortestPath(sourceStation: Station, destStation: Station) {
    if (!ogmaRef.current) return;

    const sourceNode = ogmaRef.current.getNode(sourceStation.id) as OgmaNode;
    const destNode = ogmaRef.current.getNode(destStation.id) as OgmaNode;

    const sp = ogmaRef.current.algorithms.shortestPath({
      source: sourceNode,
      target: destNode
    });
    // Highlight all the nodes in the shortest path
    sp.then(subGraph => {
      const nodes = subGraph.nodes;
      nodes.addClass('shortestPath');

      // 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('shortestPath');
    });
  }

  const onReady = useCallback((ref: OgmaLib<unknown, unknown>) => {
    ogmaRef.current = ref;
    // Center the graph
    ref.view.locateGraph({ duration: 200 });
    ref.styles.setHoveredNodeAttributes(null);

    // Tooltips on hover
    ogmaRef.current.tools.tooltip.onNodeHover(
      node => renderToString(<TooltipContent target={node} />),
      { className: 'ogma-tooltip' }
    );
  }, []);

  // Center the node when clicked
  // and apply the radial layout
  const onClick = useEvent('click', evt => {
    if (!ogmaRef.current) return;
    if (evt.target && evt.target.isNode) {
      const node = evt.target as OgmaNode;
      const station = {
        id: node.getId() as string,
        name: node.getData('local_name') as string,
        lines: node.getData('lines') as string,
        colors: node.getAttribute('color') as string[] | string
      };
      centerStationRef.current = station;
      setSelectedCenterStation(station);
      ogmaRef.current.layouts.radial({
        centralNode: node,
        radiusDelta: 200,
        locate: true
      });
    }
  });

  // Save the initial positions of the nodes
  const onLayoutEnd = useEvent('layoutEnd', evt => {
    if (initialPositions.current) return;
    initialPositions.current = evt.positions.before;
  });

  const resetGraph = useCallback(() => {
    if (!(ogmaRef.current && initialPositions.current)) return;
    ogmaRef.current.getNodes().setAttributes(initialPositions.current);
    ogmaRef.current.view.locateGraph({ duration: 200 });
  }, []);

  if (!graph) return <LoadingOverlay />;
  return (
    <Ogma
      ref={ogmaRef}
      onReady={onReady}
      onClick={isGeo ? () => {} : onClick}
      onLayoutEnd={onLayoutEnd}
      graph={graph}
      options={{
        backgroundColor: 'transparent',
        interactions: {
          zoom: { onDoubleClick: true }
        }
      }}
    >
      {/* Geo mode */}
      <Geo
        enabled={isGeo}
        latitudePath="latitude"
        longitudePath="longitude"
        tiles={{
          url:
            'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}' +
            (devicePixelRatio > 1 ? '@2x' : '') +
            '.png'
        }}
        duration={500}
        sizeRatio={0.25}
      />

      <StyleClass
        name="shortestPath"
        nodeAttributes={{
          outerStroke: {
            color: 'red',
            width: 5
          }
        }}
        edgeAttributes={{
          stroke: {
            width: 5,
            color: 'red'
          },
          color: 'red'
        }}
      />

      {/* Show labels */}
      <NodeStyle
        attributes={{
          text: {
            minVisibleSize: 0,
            font: 'IBM Plex Sans'
          },
          radius: n => Number(n.getAttribute('radius')) / 2
        }}
      />

      {!isGeo && (selectedCenterStation || centerStationRef.current) && (
        // @ts-expect-error centerStationRef is never undefined if this line is executed
        <CirclesLayer
          key={selectedCenterStation.id || centerStationRef.current.id}
          centerStation={
            selectedCenterStation.id || centerStationRef.current.id
          }
        />
      )}

      {/* Control panel */}
      <Controls
        isGeo={isGeo}
        setIsGeo={setIsGeo}
        setSelectedFromStation={setSelectedFromStation}
        setSelectedToStation={setSelectedToStation}
        selectedCenterStation={selectedCenterStation}
        setSelectedCenterStation={setSelectedCenterStation}
        resetGraph={resetGraph}
      />
    </Ogma>
  );
}

export default App;
css
.autocomplete {
  position: relative;
  display: flex;
  justify-content: space-between;
  width: 100%;
}

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

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

.clearable-input {
  font-size: 14px;
  border-radius: 4px;
  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;
}

.optionlist.hidden {
  display: none;
}

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

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

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

.optionlist-item.selected:hover {
  background-color: #aec6f8;
}
tsx
import React, { useRef, useState, useEffect, useMemo } from 'react';
import { Station } from './types';
import { Optionlist } from './Optionlist';

import './Autocomplete.css';

export const Autocomplete = (props: {
  sortedStations: Station[];
  labelText: string;
  selectedStationName?: string;
  setSelectedStation: (station: Station | undefined) => void;
  resetGraph?: () => void;
}) => {
  const {
    sortedStations,
    labelText,
    selectedStationName = '',
    setSelectedStation,
    resetGraph = () => {}
  } = props;
  const [query, setQuery] = useState<string>(selectedStationName);
  const [showOptionlist, setShowOptionlist] = useState<boolean>(false);

  // HTML references
  const input = useRef<HTMLInputElement>(null);
  const clear = useRef<HTMLSpanElement>(null);
  const ref = useRef<{
    onKeyPress: (
      event: React.KeyboardEvent<HTMLInputElement>,
      setQuery: (query: string) => void,
      setSelectedStation: (station: Station) => void
    ) => void;
    resetPositions: () => void;
  }>(null);

  // Filter the stations based on the query
  const filteredStations = sortedStations.filter(station =>
    station.name.toLowerCase().includes(query.toLowerCase())
  );

  const optionList = useMemo(
    () => (
      <Optionlist
        filteredStations={filteredStations}
        labelText={labelText}
        handleOptionClick={handleOptionClick}
        ref={ref}
      />
    ),
    [filteredStations]
  );

  // Hide the list when there are no stations from the query
  // or when the input is not focused
  useEffect(() => {
    if (filteredStations.length === 0) {
      setShowOptionlist(false);
    } else if (input.current === document.activeElement) {
      setShowOptionlist(true);
    }
  }, [filteredStations]);

  // Set the query to the modified selected station name
  useEffect(() => {
    if (document.activeElement !== input.current) setQuery(selectedStationName);
  }, [selectedStationName]);

  function handleInputFocus() {
    if (filteredStations.length !== 0) {
      setShowOptionlist(true);
    }
  }

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    const value = event.target.value;
    setQuery(value);
    ref.current?.resetPositions();
  }

  function handleBlur() {
    // To allow the click event to be registered, wait a bit before removing the selection
    // since the blur event is triggered before the click event
    setTimeout(() => {
      setShowOptionlist(false);
      const element = filteredStations.find(
        station =>
          station.name.toLowerCase() === input.current?.value.toLowerCase()
      );
      if (!element) {
        setSelectedStation(undefined);
      } else {
        setSelectedStation(element);
      }
    }, 250);
  }

  function handleOptionClick(station: Station) {
    setSelectedStation(station);
    setQuery(station.name);
  }

  function handleClearClick() {
    setQuery('');
    setSelectedStation(undefined);
    resetGraph();
    input.current?.focus();
  }

  return (
    <div className="autocomplete">
      <label htmlFor={labelText + '-node-select'} className="input-label">
        {labelText}
      </label>
      <div className="clearable-input-container">
        <input
          ref={input}
          type="text"
          className="clearable-input"
          name={labelText + '-node-select'}
          id={labelText + '-node-select'}
          value={query}
          placeholder="Select"
          onChange={handleChange}
          onFocus={handleInputFocus}
          onBlur={handleBlur}
          onKeyDown={evt =>
            ref.current?.onKeyPress(evt, setQuery, setSelectedStation)
          }
          autoComplete="off"
        />
        {/* Clear button */}
        <span
          ref={clear}
          className={`clear ${query.length === 0 ? 'hidden' : ''}`}
          onClick={handleClearClick}
        >
          &times;
        </span>
      </div>

      {/* The list of stations is hidden by default and appears when the input is focused */}
      {showOptionlist && optionList}
    </div>
  );
};
tsx
import React, { useEffect, useState, useCallback } from 'react';
import { NodeId, Point } from '@linkurious/ogma';
import { useOgma, CanvasLayer } from '@linkurious/ogma-react';

interface CirclesLayerProps {
  centerStation: NodeId;
}

export function CirclesLayer({ centerStation }: CirclesLayerProps) {
  const ogma = useOgma();
  const [center, setCenter] = useState<Point>(
    ogma.getNode(centerStation)!.getPosition()
  );

  useEffect(() => {
    if (centerStation) {
      const pos = ogma.getNode(centerStation)!.getPosition();
      setCenter(pos);
    }
  }, [centerStation]);

  const render = useCallback(
    ctx => {
      const nodes = ogma.getNodes();
      const positions = nodes.getPosition();
      const ids = nodes.getId();

      const layers: NodeId[][] = [];
      for (let i = 0; i < nodes.size; i++) {
        const pos = positions[i];
        const dist = Math.round(Math.hypot(center.x - pos.x, center.y - pos.y));
        layers[dist] = layers[dist] || [];
        layers[dist].push(ids[i]);
      }
      const distances = Object.keys(layers).map(key => parseInt(key));
      const zoom: number = ogma.view.getZoom();
      const pixelRatio = devicePixelRatio;

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

      // concentric circles
      ctx.beginPath();
      if (distances && distances.length > 1) {
        for (let i = 0; i < distances.length; i++) {
          const distance = distances[i];
          const 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 (distances && distances.length > 1) {
        for (let i = 0; i < distances.length; i++) {
          const distance = distances[i];
          const radius = distance; // * pixelRatio * zoom;
          ctx.arc(
            center.x + radius,
            center.y,
            (12 * pixelRatio) / zoom,
            0,
            2 * Math.PI,
            false
          );
        }
      }
      ctx.fill();

      // label texts
      ctx.fillStyle = '#777';
      ctx.font = 12 / zoom + 'px sans-serif';
      if (distances && distances.length > 1) {
        for (let i = 0; i < distances.length; i++) {
          const distance = distances[i];
          const radius = distance; // * pixelRatio * zoom;
          ctx.fillText(distance / 200 + '', center.x + radius, center.y);
        }
      }
    },
    [center]
  );

  return <CanvasLayer visible={true} index={0} render={render} />;
}
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;
}
tsx
import React, { useMemo } from 'react';
import { useOgma } from '@linkurious/ogma-react';
import './Controls.css';
import { ModeToggle } from './ModeToggle';
import { Autocomplete } from './Autocomplete';
import { Station } from '../types';

export const Controls = (props: {
  isGeo: boolean;
  setIsGeo: (isGeo: boolean) => void;
  setSelectedFromStation: (station: Station | undefined) => void;
  setSelectedToStation: (station: Station | undefined) => void;
  selectedCenterStation: Station | undefined;
  setSelectedCenterStation: (station: Station | undefined) => void;
  resetGraph: () => void;
}) => {
  const {
    isGeo,
    setIsGeo,
    setSelectedFromStation,
    setSelectedToStation,
    selectedCenterStation,
    setSelectedCenterStation,
    resetGraph
  } = props;

  const ogma = useOgma();

  // Stations are memoized to avoid recomputing them on every render
  const stations: Station[] = useMemo(() => {
    return ogma
      .getNodes()
      .map(station => ({
        id: station.getId() as string,
        name: station.getData('local_name') as string,
        lines: station.getData('lines') as string,
        colors: station.getAttribute('color') as string[] | string
      }))
      .sort((a, b) => a.name.localeCompare(b.name));
  }, []);

  const fromInput = useMemo(
    () => (
      <Autocomplete
        sortedStations={stations}
        labelText="From"
        setSelectedStation={setSelectedFromStation}
      />
    ),
    []
  );

  const toInput = useMemo(
    () => (
      <Autocomplete
        sortedStations={stations}
        labelText="To"
        setSelectedStation={setSelectedToStation}
      />
    ),
    []
  );

  const centerInput = useMemo(
    () => (
      <Autocomplete
        sortedStations={stations}
        labelText="Center"
        selectedStationName={selectedCenterStation?.name}
        setSelectedStation={setSelectedCenterStation}
        resetGraph={resetGraph}
      />
    ),
    [selectedCenterStation?.name]
  );

  return (
    <form className="toolbar" onWheel={e => e.stopPropagation()}>
      {/* The mode toggle is a switch to change between the graph and geo mode */}
      <ModeToggle isGeo={isGeo} setIsGeo={setIsGeo} />

      <div className="section shortest-path">
        <h3>Shortest path</h3>
        <div className="input-group">
          {/* The from and to inputs are used to select the start and end
                 stations of the shortest path */}
          {fromInput}
          {toInput}
        </div>
      </div>

      {
        /* Make this appear only when geo mode is disabled */
        !isGeo && (
          <div className="section layout">
            <h3>Distance</h3>
            <div className="input-group" style={{ paddingBottom: '0.5rem' }}>
              {
                /* The center node can be defined by clicking which is why this
                 input has another prop */
                centerInput
              }
            </div>
          </div>
        )
      }
    </form>
  );
};
css
.loading-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(255, 255, 255, 0.8);
  backdrop-filter: blur(5px);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 9999;
}

.loading-indicator {
  width: 50px;
  height: 50px;
  border: 5px solid rgba(0, 0, 0, 0.1);
  border-top: 5px solid var(--overlay-text-color);
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}
tsx
import React from "react";
import "./LoadingOverlay.css";

export const LoadingOverlay: React.FC = () => {
  return (
    <div className="loading-overlay">
      <div className="loading-indicator"></div>
    </div>
  );
};
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;
}
tsx
import React, { useState } from 'react';
import './ModeToggle.css';

export const ModeToggle = (props: {
  isGeo: boolean;
  setIsGeo: (isGeo: boolean) => void;
  cooldown?: number;
}) => {
  const { cooldown = 1000, isGeo, setIsGeo } = props;
  const [isDisabled, setIsDisabled] = useState(false);

  function handleChange() {
    if (isDisabled) return;
    setIsGeo(!isGeo);
    // Prevent changing multiple times within a short time window (most notably the animation time)
    setIsDisabled(true);
    setTimeout(() => {
      setIsDisabled(false);
    }, cooldown);
  }

  return (
    <div className="section mode">
      <div className="switch switch--horizontal">
        <label className="switch">
          <span className={'label-text' + (!isGeo ? ' selected' : '')}>
            Graph
          </span>
          <input
            name="_"
            disabled={isDisabled}
            checked={isGeo}
            type="checkbox"
            onChange={handleChange}
          />
          <span className="slider"></span>
          <span className={'label-text' + (isGeo ? ' selected' : '')}>Geo</span>
        </label>
      </div>
    </div>
  );
};
tsx
import React, {
  useImperativeHandle,
  useRef,
  type KeyboardEvent,
  type Ref
} from 'react';
import { formatColors } from './utils';
import { Station } from './types';

export const Optionlist = (props: {
  filteredStations: Station[];
  labelText: string;
  handleOptionClick: (station: Station) => void;
  ref: Ref<{
    onKeyPress: (
      event: KeyboardEvent<HTMLInputElement>,
      setQuery: (query: string) => void,
      setSelectedStation: (station: Station) => void
    ) => void;
    resetPositions: () => void;
  }>;
}) => {
  const { filteredStations, labelText, handleOptionClick, ref } = props;
  const positionsRef = useRef([0, 0]);

  const ul = useRef<HTMLUListElement>(null);

  useImperativeHandle(ref, () => {
    // Function passed to the parent component
    // to handle the keypress events
    return {
      onKeyPress(
        event: KeyboardEvent<HTMLInputElement>,
        setQuery: (query: string) => void,
        setSelectedStation: (station: Station) => void
      ) {
        if (filteredStations.length <= 1) {
          return;
        }
        const input = event.currentTarget as HTMLInputElement;
        if (event.key === 'Enter') {
          // Select the current item
          setSelectedStation(filteredStations[positionsRef.current[1]]);
          setQuery(filteredStations[positionsRef.current[1]].name);
          input.blur();
        } else if (event.key === 'ArrowDown') {
          // Go down in the list
          event.preventDefault();
          const li = ul.current?.querySelector('.selected') as HTMLLIElement;
          let nextLi: Element;
          if (li.nextElementSibling) {
            nextLi = li.nextElementSibling;
            li.nextElementSibling.classList.add('selected');
            li.classList.remove('selected');
            positionsRef.current = [positionsRef.current[1], positionsRef.current[1] + 1];
          } else {
            nextLi = ul.current?.firstChild as Element;
            ul.current?.firstChild?.classList.add('selected');
            li.classList.remove('selected');
            positionsRef.current = [positionsRef.current[1], 0];
          }

          if (isItemInvisible(nextLi)) {
            nextLi.scrollIntoView({
              block: 'end',
              inline: 'nearest',
              behavior: 'smooth'
            });
          }
        } else if (event.key === 'ArrowUp') {
          // Go up in the list
          event.preventDefault();
          const li = ul.current?.querySelector('.selected') as HTMLLIElement;
          let nextLi: Element;
          if (li.previousElementSibling) {
            nextLi = li.previousElementSibling;
            li.previousElementSibling.classList.add('selected');
            li.classList.remove('selected');
            positionsRef.current = [positionsRef.current[1], positionsRef.current[1] - 1];
          } else {
            nextLi = ul.current?.lastChild as Element;
            ul.current?.lastChild?.classList.add('selected');
            li.classList.remove('selected');
            positionsRef.current = [positionsRef.current[1], filteredStations.length - 1];
          }

          if (isItemInvisible(nextLi)) {
            nextLi.scrollIntoView({
              block: 'start',
              inline: 'nearest',
              behavior: 'smooth'
            });
          }
        } else if (event.key === 'Escape') {
          // Unfocus the input
          input.blur();
        }
      },
      resetPositions() {
        if (filteredStations.length > 0) {
          const li = ul.current?.querySelector('.selected') as HTMLLIElement;
          if (li) {
              li.classList.remove('selected');
          }
          ul.current?.firstChild?.classList.add('selected');
          positionsRef.current = [0, 0];
      }
      }
    };
  }, [filteredStations, positionsRef]);

  // 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.current.getBoundingClientRect();
    const ulTop = rect2.top;
    const ulBottom = rect2.bottom - 1;

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

  return (
    <ul id={labelText} className="optionlist" ref={ul}>
      {filteredStations.map((station, i) => {
        return (
          <li
            key={i}
            className={`optionlist-item ${positionsRef.current[1] === i ? 'selected' : ''}`}
            onClick={() => handleOptionClick(station)}
          >
            <div className="arrow"></div>
            <div className="">
              <span className="">
                {formatColors(station.colors, station.lines)}&nbsp;
                {station.name}
              </span>
            </div>
          </li>
        );
      })}
    </ul>
  );
};
tsx
import React from 'react';
import { Node as OgmaNode } from '@linkurious/ogma';
import { formatColors } from './utils';

export const TooltipContent = (props: { target: OgmaNode | undefined }) => {
  const target = props.target;
  if (!target) return null;
  const color = formatColors(
    target.getAttribute('color'),
    target.getData('lines')
  );
  return (
    <>
      <div className="arrow"></div>
      <div className="ogma-tooltip-header">
        <span className="title">
          {target.getData('local_name')}&nbsp;{color}
        </span>
      </div>
    </>
  );
};
ts
import { NodeId, Point } from "@linkurious/ogma";

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

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 DataLayout {
    nodeIds?: NodeId[];
    initialPositions?: Point[];
    layers?: NodeId[][];
    center?: { x: number; y: number };
    positions?: Point[];
    distances?: number[];
    centralNode?: NodeId;
    radiusDelta: number;
}

export interface Station {
    id: string;
    name: string;
    lines: string;
    colors: string[] | string;
}
tsx
import React, { ReactNode, Fragment } from 'react';
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
 * @param  {Color | Color[]} color Color
 * @param  {String} name  Line name
 * @return {String}
 */
const FormattedColor = (props: {
  color: Color | Color[];
  name: string;
}): ReactNode => {
  const { color, name } = props;
  const match = name.match(LINE_RE);
  const code = match ? match[2] : '';
  return (
    <span
      className="line-color"
      style={{ backgroundColor: color as string }}
      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: Color | Color[], lines: string) {
  const linesArray = lines.split('-');
  return Array.isArray(colors) ? (
    colors.map((color, i) => (
      <Fragment key={i}>
        <FormattedColor color={color} name={linesArray[i]} />
      </Fragment>
    ))
  ) : (
    <FormattedColor color={colors} name={linesArray[0]} />
  );
};
ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()]
});