Appearance
Timeline animation new
This example uses the timeline to show the construction of the Paris metro.
ts
import Ogma from '@linkurious/ogma';
import paris from './paris-metro.json';
import Timeline from './timeline';
const ogma = new Ogma({
container: 'graph-container'
});
// always show labels
ogma.styles.addNodeRule({
text: {
minVisibleSize: 0
}
});
// Load data and init application
const graph = await Ogma.parse.json(JSON.stringify(paris));
await ogma.setGraph(graph);
await ogma.geo.enable({
latitudePath: 'latitude',
longitudePath: 'longitude',
tileUrlTemplate:
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{retina}.png'.replace(
'{retina}',
window.devicePixelRatio > 1 ? '@2x' : ''
),
duration: 0,
sizeRatio: 0.1
});
await ogma.geo.setView(48.85679244995117, 2.347100615501404, 12);
const timeline = new Timeline(document.getElementById('timeline')!, ogma);
timeline.filter.enable(0);
setTimeout(() => {
timeline.play();
}, 1000);
html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<link
href="https://cdn.jsdelivr.net/npm/vis-timeline@latest/styles/vis-timeline-graph2d.min.css"
rel="stylesheet"
type="text/css"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@linkurious/ogma-timeline-plugin@latest/dist/style.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=Roboto:wght@300&display=swap"
rel="stylesheet"
/>
<link type="text/css" rel="stylesheet" href="styles.css" />
<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
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.11.2/css/solid.min.css"
rel="stylesheet"
/>
</head>
<body>
<div id="graph-container"></div>
<div id="timeline">
<span class="controls">
<div id="playpause" class="btn pause">
<span class="bar bar-1"></span>
<span class="bar bar-2"></span>
</div>
<span class="btn restart">
<svg
width="24"
height="24"
stroke-width="3"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_1735_6488)">
<path
d="M6.67742 20.5673C2.53141 18.0212 0.758026 12.7584 2.71678 8.1439C4.87472 3.0601 10.7453 0.68822 15.8291 2.84617C20.9129 5.00412 23.2848 10.8747 21.1269 15.9585C20.2837 17.945 18.8736 19.5174 17.1651 20.5673"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M17 16V20.4C17 20.7314 17.2686 21 17.6 21H22"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<defs></defs>
</svg>
</span>
</span>
</div>
<script type="module" src="index.ts"></script>
</body>
</html>
css
body {
display: grid;
grid-template-rows: auto 33%;
height: 100vh;
margin: 0;
padding: 0;
}
#timeline {
display: grid;
grid-template-columns: 1em auto;
grid-template-rows: 1em 2em auto;
width: 100%;
height: 100%;
}
#timeline > span.controls {
grid-area: 2 / 2 / 3 / 3;
z-index: 1000;
display: flex;
gap: 10px;
}
#timeline > div {
grid-area: 1 / 1 / 4 / 3;
}
.container> div {
height: 100%;
}
.btn {
position: relative;
width: 20px;
height: 20px;
border: #666 3px solid;
border-radius: 3px;
cursor: pointer;
transition: border 0.1s ease-in-out;
background-color: #eee;
}
.btn > .bar {
display: inline-block;
position: absolute;
top: 0px;
left: 0;
width: 3px;
height: 15px;
border-radius: 3px;
background-color: #666;
transform-origin: center;
transition:
transform 0.4s ease-in-out,
background 0.1s ease-in-out;
}
.btn:hover {
border: #333 3px solid;
}
.btn:hover > .bar {
background-color: #333;
}
.btn.pause > .bar-1 {
transform: translateX(4.5px) translateY(2px) rotate(0deg);
}
.btn.pause > .bar-2 {
transform: translateX(11px) translateY(2px) rotate(0deg);
}
.btn.play > .bar-1 {
transform: translateX(10px) translateY(-1px) rotate(-55deg);
}
.btn.play > .bar-2 {
transform: translateX(10px) translateY(6px) rotate(-125deg);
}
.restart > svg {
transform: translate(-2px, -2px) scale(0.8);
}
.restart.active > svg {
animation-name: spin;
animation-duration: 800ms;
animation-timing-function: ease-out;
}
@keyframes spin {
from {
transform: translate(-2px, -2px) scale(0.8) rotate(0deg);
}
to {
transform: translate(-2px, -2px) scale(0.8) rotate(360deg);
}
}
.btn > svg {
color: #666;
}
.btn:hover > svg {
color: #333;
}
.ogma-timeline-wrapper > div {
height: 100%;
}
json
{
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@linkurious/ogma": "^5.2.3",
"vis-data": "7.1.16",
"vis-timeline": "7.7.2",
"leaflet": "1.9.4",
"@linkurious/ogma-timeline-plugin": "0.2.15"
}
}
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,
"date": -2191795761000
},
"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,
"date": -1947024561000
},
"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,
"date": -2191795761000
},
"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,
"date": -2187994161000
},
"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,
"date": -1782086400000
},
"attributes": {
"radius": 42.42640687119285,
"color": [
"#89C7D6",
"#C5A3CA"
...
ts
import Ogma, { Transformation } from '@linkurious/ogma';
import { Controller as TimelinePlugin } from '@linkurious/ogma-timeline-plugin';
const oneYear =
new Date('January 1, 1979 00:00:00').getTime() -
new Date('January 1, 1978 00:00:00').getTime();
export default class Timeline {
private start: number;
private end: number;
private timeline: any;
private offsetTimestamp: number;
private minTime: number;
private maxTime: number;
public filter: Transformation<any, any>;
private isPlaying: boolean;
private pauseTimestamp: number;
private animationStart: number;
private timeout?: NodeJS.Timeout;
private playPause: HTMLElement;
private restartButton: HTMLElement;
constructor(container: HTMLElement, ogma: Ogma<any, any>) {
this.start = +new Date(1900, 0);
this.end = +Date.now();
this.isPlaying = false;
this.pauseTimestamp = 0;
this.animationStart = 0;
this.offsetTimestamp = 0;
this.timeline = new TimelinePlugin(ogma, container, {
nodeStartPath: 'date',
nodeFilter: {
enabled: true,
strategy: 'before',
tolerance: 'loose'
},
switchOnZoom: false,
timeBars: [{ date: this.start + oneYear / 2, fixed: true }],
timeline: {
nodeItemGenerator: node => ({
content: node.getData('local_name')
})
}
});
this.minTime = this.start;
this.maxTime = this.start + oneYear;
this.timeline.setWindow(this.start, this.start + oneYear);
this.filter = ogma.transformations.addNodeFilter({
criteria: node => {
return this.timeline.filteredNodes.has(node.getId());
},
enabled: false
});
const playPause = document.querySelector('#playpause')!;
const restart = document.querySelector('.restart')!;
this.playPause = playPause as HTMLElement;
this.restartButton = restart as HTMLElement;
playPause.addEventListener('click', evt => {
if (!this.isPlaying) {
this.play();
} else {
this.pause();
}
});
restart.addEventListener('click', () => {
this.restart();
restart.classList.add('active');
setTimeout(() => {
restart.classList.remove('active');
}, 850);
});
}
play() {
this.isPlaying = true;
this.playPause.classList.remove('play');
this.playPause.classList.add('pause');
if (this.pauseTimestamp) {
this.offsetTimestamp += Date.now() - this.pauseTimestamp;
this.pauseTimestamp = 0;
return window.requestAnimationFrame(t => this.animationStep(t));
} else {
return window.requestAnimationFrame(t => this.animationStep(t));
}
}
pause() {
this.isPlaying = false;
this.playPause.classList.remove('pause');
this.playPause.classList.add('play');
this.pauseTimestamp = Date.now();
}
restart() {
this.pause();
setTimeout(() => {
this.animationStart = 0;
this.offsetTimestamp = 0;
this.pauseTimestamp = 0;
this.play();
}, 100);
}
animationStep(timestamp: number): any {
if (!this.isPlaying) return;
if (this.timeout) {
// just loop, wait for timeout to finish
return window.requestAnimationFrame(t => this.animationStep(t));
}
if (!this.animationStart) {
this.animationStart = timestamp;
}
const elapsed = timestamp - this.animationStart - this.offsetTimestamp;
this.minTime = this.start + (elapsed / 5000) * oneYear;
this.maxTime = this.minTime + oneYear;
// here we need to rely on the timeline's animation function, which requires a minimal time to run
// so we sync it with the requestAnimation frame with a setTimeout.
this.timeout = setTimeout(() => {
this.timeout = undefined;
}, 100);
this.timeline.setWindow(this.minTime, this.maxTime, {
animation: {
duration: 100,
easingFunction: 'linear'
}
});
this.filter.whenApplied().then(() => this.filter.refresh());
if (this.minTime > this.end) return;
window.requestAnimationFrame(t => this.animationStep(t));
}
}