Skip to content
  1. Examples

Time layout

This example shows you the "Lombardi-style" time layout of movies and actors database. It's a way to present the graph as an event timeline.

js
import Ogma from '@linkurious/ogma';

const ogma = new Ogma({
  container: 'graph-container',
  options: {
    edgesAlwaysCurvy: true
  }
});

// actors node style
ogma.styles.addNodeRule(node => node.getData('type') === 'actor', {
  text: {
    content: node => node.getData('name'),
    // hide if node is too small
    minVisibleSize: 8,
    margin: 0
  }
});

// movies node style
ogma.styles.addNodeRule(node => node.getData('type') === 'movie', {
  text: {
    content: node => node.getData('title'),
    style: 'italic',
    minVisibleSize: 0
  },
  radius: 10,
  color: node => node.getData('color')
});

// all edges are directed
ogma.styles.addEdgeRule(() => true, {
  shape: {
    head: 'arrow'
  }
});

Promise.all([fetch('files/actors.json'), fetch('files/records.json')])
  .then(responses => Promise.all(responses.map(r => r.text())))
  .then(([actors, movies]) => {
    let offsetId = 0;
    const actorsMap = {};
    const edges = [];
    const data = JSON.parse(movies);
    //   .sort((a, b) => a.year - b.year)
    //   .filter(movie => movie.year < 1970);

    window.data = data;

    const movieNodes = new Array(100)
      .fill(0)
      .map((_, i, arr) => data[Math.floor((i / arr.length) * data.length)])
      .map((movie, i, arr) => {
        const moviesCount = arr.length;
        movie.actors.forEach(actor => {
          if (!actorsMap[actor]) {
            actorsMap[actor] = {
              id: ++offsetId + moviesCount,
              data: {
                name: actor,
                type: 'actor'
              }
            };
          }
          edges.push({
            source: actorsMap[actor].id,
            target: i
          });
        });
        return {
          id: i,
          data: {
            type: 'movie',
            title: movie.title,
            year: movie.year,
            color: movie.color
          }
        };
      });

    return Promise.all([
      ogma.addNodes(movieNodes),
      ogma.addNodes(Object.values(actorsMap)),
      ogma.addEdges(edges)
    ]);
  })
  .then(() => {
    const movies = ogma
      .getNodes()
      .filter(node => node.getData('type') === 'movie');
    return timeLayout(movies)
      .then(() => {
        movies.setAttribute('layoutable', false);
        return ogma.layouts.force({ duration: 0, locate: true });
      })
      .then(() => {
        movies.setAttribute('layoutable', true);
      });
  });

/**
 * @param {Ogma.Nodelist} movies
 * @returns {Promise<void>}
 */
function timeLayout(movies) {
  /** @type {Record<number, Ogma.NodeId[]>} */
  const years = movies.reduce((years, movie) => {
    const year = movie.getData('year');
    years[year] = years[year] || [];
    years[year].push(movie.getId());
    return years;
  }, {});

  const colDist = 200;
  const rowDist = 300;
  /** @type {number[]} */
  const xs = [];

  return Promise.all(
    Object.entries(years).reduce((promises, [year, nodeIds], i, arr) => {
      const nodes = ogma.getNodes(nodeIds);
      const x = (i - arr.length / 2) * colDist;
      xs.push(x);
      promises.push(nodes.setAttribute('x', x));
      promises.push(
        nodes.setAttribute(
          'y',
          nodeIds.map((_, j) => (j - nodeIds.length / 2) * rowDist)
        )
      );
      return promises;
    }, [])
  ).then(() => {
    // add overlays
    const { maxY } = ogma.view.getBounds();

    // vertical year lines
    const yearlines = ogma.layers.addCanvasLayer(ctx => {
      const size = ogma.view.getSize();
      const lb = ogma.view.screenToGraphCoordinates({ x: 0, y: 0 });
      const rt = ogma.view.screenToGraphCoordinates({
        x: size.width,
        y: size.height
      });
      ctx.setLineDash([3, 5]);
      ctx.strokeStyle = '#444';
      ctx.beginPath();
      xs.forEach(x => {
        ctx.moveTo(x, lb.y);
        ctx.lineTo(x, rt.y);
      });
      ctx.stroke();
    });
    yearlines.moveToBottom();

    // year tooltips
    const overlays = Object.keys(years).map((year, i) => {
      const div = document.createElement('span');
      div.classList.add('timeline');
      const p = document.createElement('p');
      p.innerText = year;
      div.appendChild(p);
      return ogma.layers.addOverlay({
        element: div,
        position: { x: xs[i], y: maxY - 50 },
        scaled: false,
        size: {
          width: '',
          height: ''
        }
      });
    });

    ogma.events.on(['viewChanged', 'pan'], () => {
      requestAnimationFrame(() => {
        const { minY } = ogma.view.getBounds();
        overlays.forEach((o, i) => {
          o.show();
          o.setPosition({ x: xs[i], y: minY });
        });
      });
    });
  });
}
html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <!---->
    <script src="https://cdn.jsdelivr.net/npm/d3-array@3"></script>
    <script src="https://cdn.jsdelivr.net/npm/d3-color@3"></script>
    <script src="https://cdn.jsdelivr.net/npm/d3-format@3"></script>
    <script src="https://cdn.jsdelivr.net/npm/d3-interpolate@3"></script>
    <script src="https://cdn.jsdelivr.net/npm/d3-time@3"></script>
    <script src="https://cdn.jsdelivr.net/npm/d3-time-format@4"></script>
    <script src="https://cdn.jsdelivr.net/npm/d3-scale@4"></script>
    <link type="text/css" rel="stylesheet" href="styles.css" />
  </head>

  <body>
    <div id="graph-container"></div>
    <script src="index.js"></script>
  </body>
</html>
css
#graph-container {
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  position: absolute;
  margin: 0;
  overflow: hidden;
}

.timeline {
  transition: all 0.5s ease;
}

.timeline p {
  border-radius: 4px;
  transform: translateX(-50%);
  padding: 3px 8px;
  background: white;
  font-size: 12px;
  box-shadow: rgba(0, 0, 0, 0.35) 0px 3px 10px;
}
.context-menu {
  background-color: blue;
}