Appearance
Transport network analysis
This example shows how to use Ogma to analyse the topology of a transport network. It can find shortest path between two stations, relayout the graph by placing one node at the center, and display the stations on a map. Click on any metro station to put it into the center and see how many hops will it take to reach other stations.
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')} ${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">×</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">×</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">×</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]);
};