Skip to content
  1. Examples

Async load nodes metadata new

This example shows how to use layers to make the loading of asynchronus data more user-friendly. It displays a loader on node double click while the data is being loaded and then displays a tooltip with the loaded data.

ts
import Ogma, { Node, Overlay } from '@linkurious/ogma';
import { Tooltip } from './tooltip';

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

const colors = [
  '#4E79A7',
  '#F28E2B',
  '#E15759',
  '#76B7B2',
  '#59A14F',
  '#EDC949',
  '#AF7AA1',
  '#FF9DA7',
  '#9C755F',
  '#BAB0AC'
];

ogma.styles.addNodeRule({
  radius: n => 5 + +n.getId() * 0.25,
  color: n => colors[+n.getId() % (colors.length - 1)]
});

ogma.styles.createClass({
  name: 'locked',
  nodeAttributes: {
    layoutable: false,
    draggable: false,
    opacity: 0.25
  }
});

ogma.generate
  .random({ nodes: 20, edges: 40 })
  .then(ogma.setGraph)
  .then(() => ogma.layouts.force({ locate: true }));

let isLoading = false;
let preloader: Overlay;
let tooltip: Tooltip;

ogma.events
  .on('doubleclick', ({ target }) => {
    if (target && target.isNode && !isLoading) {
      // show in-place preloader
      isLoading = true;
      if (tooltip) tooltip.destroy();
      const position = target.getPosition();
      const radius = +target.getAttribute('radius');
      position.x -= radius;
      position.y -= radius;
      const otherNodes = target.toList().inverse();
      otherNodes.addClass('locked');
      preloader = ogma.layers.addOverlay({
        element: generatePreloader(),
        position,
        size: { width: radius * 2, height: radius * 2 }
      });
      ogma.view.moveTo({ ...position, zoom: 3 }, { duration: 250 });

      if (target.getData() !== undefined)
        return onDataLoaded(target, target.getData());
      setTimeout(() => {
        onDataLoaded(target, generateRandomData());
      }, 2000);
    }
  })
  .on('click', evt => {
    if (tooltip && tooltip.layer.element) {
      const domTarget = evt.domEvent.target;
      // clicked on tooltip
      if (domTarget && tooltip.layer.element.contains(domTarget as HTMLElement))
        return;
      tooltip.destroy();
    }
  });

function generateRandomData(): Record<string, string> {
  return Array(10)
    .fill(0)
    .reduce((acc, _, i) => {
      acc[`field ${i}`] = `value ${i}`;
      return acc;
    }, {});
}

function onDataLoaded(target: Node, data: Record<string, string>) {
  isLoading = false;

  const otherNodes = target.toList().inverse();
  otherNodes.removeClass('locked');
  preloader.destroy();

  // generate random data, imitate retrieval

  // store it for later;
  target.setData(data);
  const content =
    Object.keys(data).reduce((acc, curr, i) => {
      const key = curr;
      const value = data[key];
      acc += `<tr><td>${key}</td><td>${value}</td></tr>`;
      return acc;
    }, `<table><tr><th>key</th><th>value</th></tr>`) + '</table>';
  if (tooltip) tooltip.destroy();
  tooltip = new Tooltip(ogma, {
    position: target.getPosition(),
    placement: 'left',
    content
  });
}

function generatePreloader() {
  return `
  <svg version="1.1" id="L9" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 100 100" xml:space="preserve">
    <path fill="#fff" d="M73,50c0-12.7-10.3-23-23-23S27,37.3,27,50 M30.9,50c0-10.5,8.5-19.1,19.1-19.1S69.1,39.5,69.1,50">
      <animateTransform
         attributeName="transform"
         attributeType="XML"
         type="rotate"
         dur="1s"
         from="0 50 50"
         to="360 50 50"
         repeatCount="indefinite" />
  </path>
</svg>
  `;
}
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <link type="text/css" rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <div id="graph-container"></div>
    <div id="info">double-click on the node to see the info</div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
html,
body {
  margin: 0;
  padding: 0;
  font-family: Georgia, 'Times New Roman', Times, serif;
}

:root {
  --overlay-background-color: #282c34;
  --overlay-text-color: #61dafb;
}

#graph-container {
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  position: absolute;
  margin: 0;
  overflow: hidden;
}

.ogma-tooltip {
  z-index: 401;
  box-sizing: border-box;
}

.ogma-tooltip--content {
  transform: translate(-50%, 0);
  background-color: var(--overlay-background-color);
  color: var(--overlay-text-color);
  border-radius: 5px;
  padding: 5px;
  box-sizing: border-box;
  box-shadow: 0 8px 30px rgb(0 0 0 / 12%);
  width: auto;
  height: auto;
  position: relative;
}

.ogma-tooltip {
  transition: linear;
  transition-property: transform;
  transition-duration: 50ms;
}

.ogma-tooltip--content:after {
  content: '';
  width: 0;
  height: 0;
  border-style: solid;
  border-width: 6px 7px 6px 0;
  border-color: transparent var(--overlay-background-color) transparent
    transparent;
  position: absolute;
  left: 50%;
  top: auto;
  bottom: 3px;
  right: auto;
  transform: translate(-50%, 100%) rotate(270deg);
}

.ogma-tooltip--top .ogma-tooltip--content {
  bottom: 6px;
  transform: translate(-50%, -100%);
}

.ogma-tooltip--bottom .ogma-tooltip--content {
  transform: translate(-50%, 0%);
  top: 3px;
}

.ogma-tooltip--bottom .ogma-tooltip--content:after {
  top: 3px;
  bottom: auto;
  transform: translate(-50%, -100%) rotate(90deg);
}

.ogma-tooltip--right .ogma-tooltip--content {
  transform: translate(0, -50%);
  left: 6px;
}

.ogma-tooltip--right .ogma-tooltip--content:after {
  left: 0%;
  top: 50%;
  transform: translate(-100%, -50%) rotate(0deg);
}

.ogma-tooltip--left .ogma-tooltip--content {
  transform: translate(-100%, -50%);
  right: 6px;
}

.ogma-tooltip--left .ogma-tooltip--content:after {
  right: 0%;
  left: auto;
  top: 50%;
  transform: translate(100%, -50%) rotate(180deg);
}
ts
import Ogma, { Size, Overlay } from '@linkurious/ogma';

type Point = { x: number; y: number };
type PositionGetter = (ogma: Ogma) => Point | null;

export type Content = string | ((ogma: Ogma, position: Point | null) => string);
export type Placement = 'top' | 'bottom' | 'left' | 'right' | 'center';

interface Options {
  position: Point | PositionGetter;
  content?: Content;
  size?: Size;
  visible?: boolean;
  placement?: Placement;
  tooltipClass?: string;
}

const defaultOptions: Required<Options> = {
  tooltipClass: 'ogma-tooltip',
  placement: 'right',
  size: { width: 'auto', height: 'auto' } as any as Size,
  visible: true,
  content: '',
  position: ogma =>
    ogma.view.screenToGraphCoordinates(ogma.getPointerInformation())
};

export class Tooltip {
  private ogma: Ogma;
  private options: Required<Options>;
  private timer = 0;
  public size: Size = { width: 0, height: 0 };
  public layer!: Overlay;

  constructor(ogma: Ogma, options: Partial<Options>) {
    this.ogma = ogma;
    this.options = { ...defaultOptions, ...options };
    this.createLayer();
  }

  private getPosition() {
    if (typeof this.options.position === 'function')
      return this.options.position(this.ogma);
    return this.options.position;
  }

  private getContent(position: Point | null) {
    const content = this.options.content;
    if (typeof content === 'string') return content;
    else if (typeof content === 'function') return content(this.ogma, position);
    return '';
  }

  private createLayer() {
    const { tooltipClass, placement, size, visible } = this.options;
    const className = getContainerClass(tooltipClass, placement);
    const wrapperHtml = `<div class="${className}"><div class="${tooltipClass}--content" /></div>`;
    const newCoords = this.getPosition();
    this.layer = this.ogma.layers.addOverlay({
      position: newCoords || { x: -9999, y: -9999 },
      element: wrapperHtml,
      scaled: false,
      size
    });
    this.frame();
  }

  private frame = () => {
    this.update();
    this.timer = requestAnimationFrame(this.frame);
  };

  private updateSize() {
    this.size = {
      width: this.layer.element.offsetWidth,
      height: this.layer.element.offsetHeight
    };
  }

  private update() {
    const coords = this.getPosition();
    const newContent = this.getContent(coords);
    this.layer.element.firstElementChild!.innerHTML = newContent;

    if (!newContent) {
      this.layer.hide();
      return;
    } else this.layer.show();

    this.updateSize();

    this.layer.element.className = getContainerClass(
      this.options.tooltipClass,
      getAdjustedPlacement(
        coords as Point,
        this.options.placement,
        this.size,
        this.ogma
      )
    );
    this.layer.setPosition(coords!);
  }

  setPosition(position: Point | PositionGetter) {
    this.options.position = position;
  }

  setContent(content: Content) {
    this.options.content = content;
  }

  getSize() {
    return { ...this.size };
  }

  destroy() {
    cancelAnimationFrame(this.timer);
    this.layer.destroy();
  }
}

function getAdjustedPlacement(
  coords: Point,
  placement: Placement,
  dimensions: Size,
  ogma: Ogma
): Placement {
  const { width: screenWidth, height: screenHeight } = ogma.view.getSize();
  const { x, y } = ogma.view.graphToScreenCoordinates(coords);
  let res = placement;
  const { width, height } = dimensions;

  if (placement === 'left' && x - width < 0) res = 'right';
  else if (placement === 'right' && x + width > screenWidth) res = 'left';
  else if (placement === 'bottom' && y + height > screenHeight) res = 'top';
  else if (placement === 'top' && y - height < 0) res = 'bottom';

  if (res === 'right' || res === 'left') {
    if (y + height / 2 > screenHeight) res = 'top';
    else if (y - height / 2 < 0) res = 'bottom';
  } else {
    if (x + width / 2 > screenWidth) res = 'left';
    else if (x - width / 2 < 0) res = 'right';
  }

  return res;
}

function getContainerClass(popupClass: string, placement: Placement) {
  return `${popupClass} ${popupClass}--${placement}`;
}