Skip to content
  1. Examples

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));
  }
}