Skip to content
  1. Examples

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