Appearance
Transport Network
This example shows how to use geo clustering to make a map with a large number of nodes more readable. It uses the geoClustering transformation to ungroup nodes on zoom and group them when zooming out.
ts
import Ogma from '@linkurious/ogma';
Ogma.libraries['leaflet'] = window.L;
const ogma = new Ogma<{ name: string; line: number }>({
container: 'graph-container'
});
fetch('./france-train-stations.json')
.then(response => response.json())
.then(graph => ogma.setGraph(graph))
.then(() =>
ogma.geo.enable({
tiles: { url: '//{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' }
})
);
const colors = [
'#FFFFFF',
'#5E5348',
'#56787D',
'#A47E61',
'#d99f4f',
'#64909f',
'#bbaca5',
'#66a4be'
];
ogma.styles.addNodeRule({
text: {
content: node => node.getData('name'),
size: node => (node.getData('name').startsWith('line') ? 10 : 12),
font: 'IBM Plex Sans',
minVisibleSize: 20
},
color: node => colors[node.getData('line')! % 8],
innerStroke: { width: 0 },
outerStroke: { color: '#222', width: 0.5 }
});
ogma.styles.addEdgeRule({
color: edge => colors[edge.getData('line')! % 8]
});
ogma.styles.setHoveredNodeAttributes({
outerStroke: { color: '#222', width: 1 },
text: {
backgroundColor: '#222',
color: '#fff'
}
});
const clustering = ogma.transformations.addGeoClustering({
groupIdFunction: node => node.getData('line').toString(),
nodeGenerator: (nodes, groupId) => ({
id: groupId,
data: {
name:
nodes.size > 1
? `line ${nodes.get(0).getData('line')}`
: nodes.get(0).getData('name'),
line: nodes.get(0).getData('line')
}
})
});
document
.querySelector('#cluster')!
.addEventListener('change', () => clustering.toggle());
html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.3/leaflet.css"
/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.3/leaflet.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"
/>
<link type="text/css" rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="graph-container"></div>
<div class="control-bar" id="controls">
<label>
<span>Geo mode</span>
<input type="checkbox" id="mode" checked />
</label>
</div>
<form class="toolbar" id="ui">
<div class="section mode">
<label class="toggle-switch">
<input
id="cluster"
type="checkbox"
name="mode-switch"
value="cluster"
checked
class="checkbox"
/>
<div class="toggle-switch-background">
<div class="toggle-switch-handle"></div>
</div>
<span>Clustering</span>
</label>
</div>
</form>
<script type="module" src="index.ts"></script>
</body>
</html>
css
body {
font-family: 'IBM Plex sans', sans-serif;
margin: 0;
padding: 0;
background: #f5f5f5;
color: #222222;
}
#graph-container {
top: 0;
bottom: 0;
left: 0;
right: 0;
position: absolute;
margin: 0;
overflow: hidden;
}
.toolbar {
display: block;
position: absolute;
top: 20px;
right: 20px;
padding: 10px;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65);
border-radius: 5px;
font-weight: 300;
z-index: 9999;
background: white;
}
.toolbar .section {
position: relative;
display: block;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 80px;
height: 40px;
cursor: pointer;
}
.toggle-switch input[type='checkbox'] {
display: none;
}
.toggle-switch-background {
width: 100%;
height: 100%;
background-color: #ddd;
border-radius: 20px;
box-shadow: inset 0 0 0 2px #ccc;
transition: background-color 0.3s ease-in-out;
}
.toggle-switch-handle {
position: absolute;
top: 5px;
left: 5px;
width: 30px;
height: 30px;
background-color: #fff;
border-radius: 50%;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
transition: transform 0.3s ease-in-out;
}
.toggle-switch::before {
content: '';
position: absolute;
top: -25px;
right: -35px;
font-size: 12px;
font-weight: bold;
color: #aaa;
text-shadow: 1px 1px #fff;
transition: color 0.3s ease-in-out;
}
.toggle-switch input[type='checkbox']:checked + .toggle-switch-handle {
transform: translateX(45px);
box-shadow:
0 2px 5px rgba(0, 0, 0, 0.2),
0 0 0 3px #05c46b;
}
.toggle-switch input[type='checkbox']:checked + .toggle-switch-background {
background-color: #05c46b;
box-shadow: inset 0 0 0 2px #04b360;
}
.toggle-switch input[type='checkbox']:checked + .toggle-switch:before {
content: 'On';
color: #05c46b;
right: -15px;
}
.toggle-switch
input[type='checkbox']:checked
+ .toggle-switch-background
.toggle-switch-handle {
transform: translateX(40px);
}
.toggle-switch span {
display: block;
margin-top: 0.5em;
text-align: center;
}
json
{
"nodes": [
{
"id": 1,
"data": {
"name": "Bandol",
"latitude": 43.14049104738302,
"longitude": 5.749815526229215,
"line": 0,
"km": 50
}
},
{
"id": 2,
"data": {
"name": "Pouilly-sur-Loire",
"latitude": 47.28252848176941,
"longitude": 2.965051211539134,
"line": 1,
"km": 214
}
},
{
"id": 3,
"data": {
"name": "Arbois",
"latitude": 46.91270132076251,
"longitude": 5.763090017438219,
"line": 2,
"km": 401
}
},
{
"id": 4,
"data": {
"name": "Cases-de-Pène",
"latitude": 42.77898338901533,
"longitude": 2.7931166200241657,
"line": 3,
"km": 463
}
},
{
"id": 5,
"data": {
"name": "Nantes",
"latitude": 47.22623886712025,
"longitude": -1.5187659795664639,
"line": 4,
"km": 428
}
},
{
"id": 6,
"data": {
"name": "Culmont-Chalindrey",
"latitude": 47.808466511639466,
"longitude": 5.4459063706995465,
"line": 5,
"km": 390
}
},
{
"id": 7,
"data": {
"name": "La Penne-sur-Huveaune",
"latitude": 43.28478914045096,
"longitude": 5.5155538408980345,
"line": 0,
"km": 12
}
},
{
"id": 8,
"data": {
"name": "Gannat",
"latitude": 46.097617862497984,
"longitude": 3.2048652200449,
"line": 6,
"km": 378
}
},
{
"id": 9,
"data": {
"name": "Haluchère-Batignolles",
"latitude": 47.24914745463753,
"longitude": -1.5225453757032819,
"line": 4,
"km": 433
}
},
{
"id": 10,
"data": {
"name": "La Chartre-sur-le-Loir",
"latitude": 47.740608359192024,
"longitude": 0.581929079377478,
"line": 7,
"km": 201
}
},
{
"id": 11,
"data": {
"name": "Feuquières-Broquiers",
"latitude": 49.6545131273755,
"longitude": 1.83106831857752,
"line": 8,
"km": 119
}
},
{
"id": 12,
"data": {
"name": "Amiens",
"latitude": 49.8899171570771,
"longitude": 2.3092764940777903,
"line": 9,
"km": 130
}
},
{
"id": 13,
"data": {
"name": "Labège-Village",
"latitude": 43.530185715400734,
"longitude": 1.5332979503119948,
"line": 10,
"km": 267
}
},
{
"id": 14,
"data": {
"name": "Lardy",
"latitude": 48.51996498537976,
"longitude": 2.2535711520859407,
"line": 11,
"km": 42
}
},
{
"id": 15,
"data": {
"name": "Le Soler",
"latitude": 42.678079974423696,
"longitude": 2.7876024386942424,
"line": 12,
"km": 474
}
},
{
"id": 16,
"data": {
"name": "L'Estaque",
"latitude": 43.36379521414173,
"longitude": 5.321365695857461,
"line": 13,
"km": 870
}
},
{
"id": 17,
"data": {
"name": "Pont-à-Mousson",
"latitude": 48.9002071898534,
"longitude": 6.050608194237219,
"line": 14,
"km": 362
}
},
{
"id": 18,
"data": {
"name": "Les Échets",
"latitude": 45.87449953551212,
"longitude": 4.910745269670009,
"line": 15,
"km": 20
}
},
{
"id": 19,
"data": {
"name": "Villeneuve-le-Roi",
"latitude": 48.73821835135393,
"longitude": 2.4269289897862905,
"line": 11,
"km": 12
}
},
{
"id": 20,
"data": {
"name": "Uckange",
"latitude": 49.303441043459934,
"longitude": 6.156190363131995,
"line": 16,
"km": 181
}
},
{
"id": 21,
"data": {
"name": "Brunémont",
"latitude": 50.27814083996918,
"longitude": 3.146083415609878,
"line": 17,
"km": 210
}
},
{
"id": 22,
"data": {
"name": "Arches",
"latitude": 48.11988792019504,
"longitude": 6.528289800019224,
"line": 18,
"km": 11
}
},
{
"id": 23,
"data": {
"name": "Calais-Fréthun",
"latitude": 50.90157078712958,
"longitude": 1.8115554027850775,
"line": 19,
"km": 112
}
},
{
"id": 24,
"data": {
"name": "Beillant",
"latitude": 45.701017141940206,
"longitude": -0.5268853168032458,
"line": 7,
"km": 501
}
},
{
"id": 25,
"data": {
"name": "St-Denis-de-Pile",
"latitude": 44.97850876287167,
"longitude": -0.19795164441633,
"line": 11,
"km": 539
}
},
{
"id": 26,
"data": {
"name": "La Courneuve-
...