Appearance
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}
>
×
</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)}
{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')} {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()]
});