Skip to content
  1. Examples

Tooltip

This example shows how to build a dynamic tooltip using Ogma.layers.addOverlay and an external tooltip component. You can see how you can handle the tooltip positioning inside of the viewport.

ts
import Ogma from '@linkurious/ogma';
import { Tooltip } from './tooltip';
import './styles.css';

const ogma = new Ogma({
  graph: {
    nodes: [{ id: 0 }, { id: 1 }, { id: 3 }],
    edges: [
      { source: 0, target: 1, id: 0 },
      { source: 1, target: 3, id: 1 }
    ]
  },
  container: 'graph-container'
});

await ogma.layouts.force({ locate: true });
const tooltip = new Tooltip(ogma, {
  placement: 'left',
  // here you can use your HTML templates
  content: ogma => {
    const { target } = ogma.getPointerInformation();
    if (target)
      return `${
        target.isNode ? 'node' : 'edge'
      }: <span class="info">${target.getId()}</span>`;
    return '';
  }
});
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>
    <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; */
  pointer-events: none;
}

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

.info {
  color: white;
}
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() {
    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}`;
}