Skip to content
  1. Examples
  2. Layers

Parallel views with annotations

Try panning and zooming the separate views in this example. Note als that the node positions are synchronized and dotted pseudo-edges connecting the nodes are spanning from one view to the other. This can be used to show meta-graphs or different views of the same graph.

ts
import Ogma, { NodeId, RawGraph, RawNode } from '@linkurious/ogma';

type Mode = 'aligned' | 'free';
let mode: Mode = 'aligned';

const view1 = new Ogma({ container: 'graph-container-view1' });
const view2 = new Ogma({ container: 'graph-container-view2' });

const initGraph = (ogma: Ogma, graph: RawGraph) =>
  ogma
    .setGraph(graph)
    .then(() => ogma.layouts.forceLink())
    .then(() => ogma.view.locateGraph({ easing: 'linear', duration: 300 }));

// synchronize the views
const synchViews = (views: Ogma[]) =>
  views.forEach(view => {
    const synchViews = views.filter(synchView => synchView !== view);
    const updateNode = (node: RawNode) =>
      synchViews.forEach(synchView =>
        synchView.getNode(node.id!)!.setAttributes(node.attributes!)
      );
    view.events.on('nodesDragProgress', evt =>
      evt.nodes.forEach(node =>
        updateNode(node.toJSON({ attributes: ['x', 'y'] }))
      )
    );
  });

// extra links bridging the views
const overlay = document.getElementById('overlay') as HTMLCanvasElement;
overlay.width = window.innerWidth;
overlay.height = window.innerHeight;
const context = overlay.getContext('2d')!;

const drawBridges = (
  view1: Ogma,
  view2: Ogma,
  bridges: Map<NodeId, NodeId[]>
) => {
  context.clearRect(0, 0, overlay.width, overlay.height);
  context.lineWidth = 2;
  context.setLineDash([8, 4]);
  context.strokeStyle = '#888888';
  bridges.forEach((sources, target) => {
    const { x, y } = view2.getNode(target)!.getPositionOnScreen();
    sources.forEach(source => {
      const { x: sx, y: sy } = view1.getNode(source)!.getPositionOnScreen();
      context.beginPath();
      context.moveTo(sx, sy);
      context.lineTo(x, y + window.innerHeight / 2);
      context.stroke();
    });
  });
};

// generate a graph, some bridges and synchronize the views
const bridges = new Map<NodeId, NodeId[]>();
const bridgeIds = [1, 2, 3, 4, 5, 6, 7];
bridgeIds.forEach(id => bridges.set(id, [id]));

view1.generate
  .barabasiAlbert({ nodes: 20, m0: 5, m: 1 })
  .then(graph =>
    Promise.all([initGraph(view1, graph), initGraph(view2, graph)])
  )
  .then(() => {
    const views = [view1, view2];
    synchViews(views);
    views.forEach(view => {
      view.events.on(['nodesDragProgress', 'move', 'zoom'], () =>
        drawBridges(view1, view2, bridges)
      );
    });
    drawBridges(view1, view2, bridges);
  });

// Utility function to synchronize the views if relevant
const syncViews = (view: Ogma, refView: Ogma) => {
  if (mode === 'free') return;
  view.view.setCenter(refView.view.getCenter());
  view.view.setZoom(refView.view.getZoom());
};

// Synchronize the views if the user selects the aligned mode
view1.events.on('dragProgress', () => syncViews(view2, view1));
view2.events.on('dragProgress', () => syncViews(view1, view2));
view1.events.on('viewChanged', () => syncViews(view2, view1));
view2.events.on('viewChanged', () => syncViews(view1, view2));

// Menu at the top right
const form = document.querySelector('#ui form') as HTMLFormElement;
const getMode = (id: string) => {
  const select = form.elements.namedItem(id) as RadioNodeList | null;
  if (!select) {
    throw new Error(`Form control with id "${id}" not found.`);
  }
  const currentMode = Array.prototype.filter.call(select, (input: HTMLInputElement) => {
    return input.checked;
  })[0].value; // IE inconsistency
  return currentMode as Mode;
};

const updateUI = () => {
  const alignedViews = getMode('aligned-views');
  mode = alignedViews;
  syncViews(view2, view1);
};
form.addEventListener('change', updateUI);
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />

    <link type="text/css" rel="stylesheet" href="styles.css" />
    <link
      href="https://cdn.jsdelivr.net/npm/lucide-static@0.483.0/font/lucide.css"
      rel="stylesheet"
    />
    <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"
    />
  </head>
  <body>
    <div class="container">
      <div id="graph-container-view1" class="graph-container"></div>
      <div id="graph-container-view2" class="graph-container"></div>
      <canvas id="overlay" width="100vw" height="100vh" />
    </div>
    <div class="toolbar" id="ui">
      <form>
        <div class="section mode">
          <h3>Aligned Views</h3>
          <div class="switch switch--horizontal">
            <input
              type="radio"
              name="aligned-views"
              value="aligned"
              checked="checked"
            />
            <label for="aligned">Aligned</label>
            <input type="radio" name="aligned-views" value="free" />
            <label for="free">Free</label>
            <span class="toggle-outside">
              <span class="toggle-inside"></span>
            </span>
          </div>
        </div>
      </form>
      <div class="section mode" id="details"></div>
    </div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
:root {
  --font: 'IBM Plex Sans', sans-serif;
}

html,body {
  margin: 0;
  height: 100%;
  font-family: var(--font);
}

*,
*:before,
*:after {
  box-sizing: border-box;
}

.container {
  height: 100%;
  min-height: 100%;
  display: flex;
  flex-direction: column;
  background: #ddd;
}

.graph-container {
  flex: 1;
  display: flex;
  overflow: hidden;
  border: 1px dotted #aaa;
}

#overlay {
  position: absolute;
  pointer-events: none;
  z-index: 100;
}

.toolbar {
  display: block;
  position: absolute;
  top: 20px;
  right: 20px;
  padding: 10px;
  box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65);
  border-radius: 4px;
  background: #ffffff;
  color: #222222;
  font-weight: 300;
}

.toolbar .section {
  position: relative;
  display: block;
}

.toolbar .section h3 {
  display: block;
  font-weight: 300;
  border-bottom: 1px solid #ddd;
  color: #606060;
  font-size: 1rem;
}

.toolbar .section p {
  padding-left: 18px;
  padding-right: 18px;
}

.toolbar .section p label {
  width: 4rem;
  display: inline-block;
}

.toolbar .mode {
  text-align: center;
}

.toolbar .disabled {
  display: none;
}

/* -- CSS flip switch */
.switch {
  width: 100%;
  position: relative;
}

.switch input {
  position: absolute;
  top: 0;
  z-index: 2;
  opacity: 0;
  cursor: pointer;
}
.switch input:checked {
  z-index: 1;
}
.switch input:checked + label {
  opacity: 1;
  cursor: default;
}
.switch input:not(:checked) + label:hover {
  opacity: 0.5;
}
.switch label {
  color: #222222;
  opacity: 0.33;
  transition: opacity 0.25s ease;
  cursor: pointer;
}
.switch .toggle-outside {
  height: 100%;
  border-radius: 2rem;
  padding: 0.25rem;
  overflow: hidden;
  transition: 0.25s ease all;
}
.switch .toggle-inside {
  border-radius: 2.5rem;
  background: #4a4a4a;
  position: absolute;
  transition: 0.25s ease all;
}
.switch--horizontal {
  width: 15rem;
  height: 2rem;
  margin: 0 auto;
  font-size: 0;
  margin-bottom: 1rem;
}
.switch--horizontal input {
  height: 2rem;
  width: 5rem;
  left: 5rem;
  margin: 0;
}
.switch--horizontal label {
  font-size: 1rem;
  line-height: 2rem;
  display: inline-block;
  width: 5rem;
  height: 100%;
  margin: 0;
  text-align: center;
}
.switch--horizontal label:last-of-type {
  margin-left: 5rem;
}
.switch--horizontal .toggle-outside {
  background: #dddddd;
  position: absolute;
  width: 5rem;
  left: 5rem;
}
.switch--horizontal .toggle-inside {
  height: 1.5rem;
  width: 1.5rem;
}
.switch--horizontal input:checked ~ .toggle-outside .toggle-inside {
  left: 0.25rem;
}
.switch--horizontal input ~ input:checked ~ .toggle-outside .toggle-inside {
  left: 3.25rem;
}
.switch--horizontal input:disabled ~ .toggle-outside .toggle-inside {
  background: #9a9a9a;
}
.switch--horizontal input:disabled ~ label {
  color: #9a9a9a;
}

.hidden {
  display: none;
}

.control-bar {
  font-family: Helvetica, Arial, sans-serif;
  box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65);
  border-radius: 4px;
}

.btn {
  padding: 6px 8px;
  background-color: white;
  cursor: pointer;
  font-size: 18px;
  border: none;
  border-radius: 5px;
  outline: none;
}

.btn:hover {
  color: #333;
  background-color: #e6e6e6;
}

.menu {
  border: 1px solid #ddd;
  width: 80%;
  font-size: 14px;
  margin-top: 10px;
}