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['hierarchical'].checked;
state.geo = currentMode;
// Disable the toggle while switching modes
form['hierarchical'].disabled = true;
form['network'].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')?.lastElementChild 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['network'].disabled = false;
form['hierarchical'].disabled = false;
});
};
// Initialize the toggle buttons
document.getElementById("label-geo")?.addEventListener('click', () => {
if (form['hierarchical'].checked || form["hierarchical"].disabled) return;
form['hierarchical'].checked = true;
form["network"].checked = false;
updateUI();
});
document.getElementById("label-graph")?.addEventListener('click', () => {
if (form['network'].checked || form['network'].disabled) return;
form['network'].checked = true;
form["hierarchical"].checked = false;
updateUI();
});
// Initialize the tooltips
const distance = document.getElementById('distance') as HTMLElement;
const shortestPath = document.getElementById('shortest-path') as HTMLElement;
const distancePopup = document.getElementById('distance-popup') as HTMLElement;
const shortestPathPopup = document.getElementById('shortest-path-popup') as HTMLElement;
distance!.addEventListener('mouseover', () => {
distancePopup.classList.toggle("hidden", false);
});
shortestPath!.addEventListener('mouseover', () => {
shortestPathPopup.classList.toggle("hidden", false);
});
shortestPath!.addEventListener('mouseout', () => {
shortestPathPopup.classList.toggle("hidden", true);
});
distance!.addEventListener('mouseout', () => {
distancePopup.classList.toggle("hidden", true);
});
// 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"
/>
<script src="https://cdn.jsdelivr.net/npm/@linkurious/ogma-ui-kit@latest/dist/ogma-ui-kit.min.js"></script>
</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="title-container">
<i class="icon-square-m"></i>
<span class="title">Paris Metro Stations</span>
</div>
<div class="section">
<span class="title">VIEW MODE</span>
<div class="layout-switch">
<input class="switch" type="radio" checked="" name="network">
<label id="label-graph" class="label" for="network">Graph</label>
<input class="switch" type="radio" name="hierarchical">
<label id="label-geo" class="label" for="hierarchical">Map</label>
</div>
</div>
<sl-divider></sl-divider>
<div class="section">
<span class="title">
<span>FIND SHORTEST PATH</span>
<i class="icon-info" id="shortest-path"></i>
<div class="ogma-popup ogma-popup--bottom hidden" id="shortest-path-popup">
<div class="ogma-popup--body">
Finds the shortest route between two metro stations, including transfers
</div>
</div>
</span>
<div class="input-group">
<div class="autocomplete">
<span class="input-label">Start Station</span>
<div class="clearable-input-container">
<input
class="clearable-input"
id="from-node-select"
placeholder="Select"
autocomplete="off"
type="text"
value=""
name="from-node-select"
>
<span class="clear hidden">×</span>
</div>
</div>
<div class="autocomplete">
<span class="input-label">Destination</span>
<div class="clearable-input-container">
<input
class="clearable-input"
id="to-node-select"
placeholder="Select"
autocomplete="off"
type="text"
value=""
name="to-node-select"
>
<span class="clear hidden">×</span>
</div>
</div>
</div>
</div>
<sl-divider></sl-divider>
<div class="section">
<span class="title">
<span>SHOW DISTANCES</span>
<i class="icon-info" id="distance"></i>
<div class="ogma-popup ogma-popup--bottom hidden" id="distance-popup">
<div class="ogma-popup--body">
Shows the number of stops it takes to reach each station from the center station.
</div>
</div>
</span>
<div class="input-group">
<div class="autocomplete">
<span class="input-label">Center Station</span>
<div class="clearable-input-container">
<input
class="clearable-input"
id="center-node-select"
placeholder="Select"
autocomplete="off"
type="text"
value=""
name="center-node-select"
>
<span class="clear hidden">×</span>
</div>
</div>
</div>
</div>
</form>
<script src="index.ts"></script>
</body>
</html>
css
html {
height: 100%;
}
body {
overflow: hidden;
margin: 0;
padding: 0;
width: 100%;
height: 100%;
font-family: IBM Plex Sans;
font-size: 14px;
}
*,
*: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.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);
}
css
.autocomplete {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
justify-content: flex-start;
width: 100%;
}
.input-label {
color: black;
display: flex;
}
.clearable-input-container {
position: relative;
width: 100%;
}
.clearable-input {
width: 100%;
font-size: 14px;
border-radius: 4px;
padding: 10px 12px;
border: 1px solid #dddddd;
color: #333;
}
.clearable-input:focus {
border-color: var(--primary);
}
.clearable-input:hover {
border-color: var(--primary);
background-color: var(--primary-transparent);
}
.clearable-input:hover + .clearable-input::placeholder {
color: var(--primary);
}
.clearable-input::placeholder {
color: #666;
}
.clearable-input::-ms-clear {
display: none;
}
.clear {
position: absolute;
right: 12px;
top: 9px;
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 63px;
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 {
color: white;
background-color: #4995ff;
}
.optionlist-item.selected {
color: white;
background-color: #006cff;
}
.optionlist-item.selected:hover {
background-color: #104389;
}
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: flex;
flex-direction: column;
gap: 32px;
position: absolute;
top: 20px;
right: 20px;
padding: 16px;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.15);
border-radius: 4px;
background: #ffffff;
z-index: 9999;
width: 228px;
}
.title-container {
font-size: 24px;
display: flex;
align-items: center;
gap: 8px;
}
.title {
font-size: 14px;
display: flex;
align-items: center;
margin: 0;
}
.section {
display: flex;
flex-direction: column;
gap: 12px;
}
.section.disabled {
display: none;
}
.section > .title {
font-size: 14px;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 500;
margin: 0;
}
.title > i {
font-size: 20px;
color: #6D6D6D;
}
sl-divider {
margin: -16px -16px;
}
.input-group {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
padding: 0 0.3em;
}
.hidden {
visibility: hidden;
}
.ogma-popup.ogma-popup--bottom {
transform: translate(186.5px, 50px);
position: absolute;
z-index: 100;
}
.ogma-tooltip--content:after,
.ogma-popup--body:after {
content: "";
width: 0;
height: 0;
border-style: solid;
border-width: 6px 7px 6px 0;
border-color: transparent var(--overlay-background-color) transparent
transparent;
position: absolute;
left: 50%;
top: auto;
bottom: 3px;
right: auto;
transform: translate(-50%, 100%) rotate(270deg);
}
.ogma-popup--bottom .ogma-popup--body {
border-radius: 4px;
transform: translate(-91%, 0%);
padding: 8px;
width: 214px;
background-color: #303030;
color: white;
font-weight: normal;
}
.ogma-popup--bottom .ogma-popup--body:after {
top: 3px;
bottom: auto;
transform: translate(82px, -63%) rotate(45deg);
color: #303030;
}
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';
ul.querySelector(".selected")?.scrollIntoView({
block: 'start',
inline: 'nearest'
});
}
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
.layout-switch {
display: flex;
flex-direction: row;
align-items: center;
background-color: #fff;
padding: 2px;
border: 1px solid #888;
border-radius: 4px;
width: calc(100% - 4px);
height: 28px;
margin: 0;
}
.label {
color: black;
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
padding: 3px 14px;
border-radius: 2px;
transition: all 0.3s;
cursor: pointer;
}
.switch {
display: none;
}
.switch:checked + .label {
color: white;
background-color: #006cff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
cursor: default;
}
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]);
};