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