Appearance
Mini-map
This example demonstrates how you can create a mini-map control to see which part of the graph is currently in view. Move and zoom around to see how it works. You can also change the graph to see the changes reflected on the preview.
ts
import Ogma from '@linkurious/ogma';
import { MinimapControl } from './mini-map';
import chroma from 'chroma-js';
const ogma = new Ogma({
container: 'graph-container'
});
ogma.styles.addRule({
nodeAttributes: {
outerStroke: {
width: 0.5,
color: '#222'
}
},
edgeAttributes: {
opacity: 0.5
}
});
const NODES = 15;
const colors = chroma.scale(['#0094ff', '#da1039']).mode('lch').colors(NODES);
const graph = await ogma.generate.random({ nodes: NODES, edges: NODES * 3 });
graph.nodes.forEach((node, i) => {
node.attributes!.color = colors[i]; //getRandomColor();
node.attributes!.radius = 5 + 10 * Math.random();
});
await ogma.setGraph(graph, { ignoreInvalid: true });
await ogma.layouts.force({ locate: false, autoStop: true });
new MinimapControl(ogma, {});
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
#graph-container {
top: 0;
bottom: 0;
left: 0;
right: 0;
position: absolute;
margin: 0;
overflow: hidden;
}
.ogma-mini-map {
display: inline-block;
min-width: 100px;
min-height: 100px;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
position: absolute;
bottom: 10px;
right: 10px;
background-color: #fff;
}
ts
import Ogma from '@linkurious/ogma';
type RGB =
| `rgb(${number}, ${number}, ${number})`
| `rgb(${number},${number},${number})`;
type RGBA =
| `rgba(${number}, ${number}, ${number}, ${number})`
| `rgba(${number},${number},${number},${number})`;
type HEX = `#${string}`;
type Color = RGB | RGBA | HEX;
interface MinimapControlProps {
/** Container class name, to style it in CSS */
className?: string;
/** Control width */
width?: number;
/** Control height */
height?: number;
/** Viewport rectangle stroke width */
strokeWidth?: number;
/** Viewport rectangle stroke color */
strokeColor?: Color;
/** Viewport rectangle fill color */
fillColor?: Color;
/** Export image margin in pixels */
margin?: number;
}
/** @type {Options} */
const defaultOptions: Required<MinimapControlProps> = {
className: 'ogma-mini-map',
width: 100,
height: 100,
strokeColor: '#ff0000',
strokeWidth: 2,
fillColor: '#ffffff',
margin: 0
};
const clamp = (x: number, min: number, max: number) =>
Math.min(max, Math.max(min, x));
export class MinimapControl<ND = unknown, ED = unknown> {
private ogma: Ogma<ND, ED>;
private options: Required<MinimapControlProps>;
private dppx: number = devicePixelRatio;
private updateSnapshotTimer!: number;
private updateTimer!: number;
private container!: HTMLDivElement;
private snapshot!: HTMLDivElement;
private frame!: HTMLCanvasElement;
private ctx!: CanvasRenderingContext2D;
constructor(ogma: Ogma<ND, ED>, options: MinimapControlProps) {
this.ogma = ogma;
/** @type {Options} */
this.options = {
...defaultOptions,
...options
} as Required<MinimapControlProps>;
/** @type {number} */
this.dppx = devicePixelRatio;
this.createContainer();
this.setSize();
this.updateSnapshot();
this.update();
this.addEvents();
}
private addEvents() {
this.ogma.events
// update the snapshot when the graph is changed
.on(
[
'addNodes',
'addEdges',
'nodesDragEnd',
'removeNodes',
'removeEdges',
'nodesSelected',
'edgesSelected',
'nodesUnselected',
'edgesUnselected',
'layoutEnd'
],
this.updateSnapshot
)
// update the minimap when the graph view is changed
.on('move', this.update);
}
updateSnapshot = () => {
this.updateSnapshotTimer = requestAnimationFrame(() => {
this.ogma.export
.svg({
width: this.options.width,
height: this.options.height,
margin: 0,
texts: false,
download: false
})
.then(svgString => (this.snapshot.innerHTML = svgString));
});
};
private createContainer() {
this.container = document.createElement('div');
this.container.className = this.options.className;
this.snapshot = document.createElement('div');
this.container.appendChild(this.snapshot);
this.frame = document.createElement('canvas');
this.ctx = this.frame.getContext('2d') as CanvasRenderingContext2D;
this.container.appendChild(this.frame);
this.ogma.getContainer()!.appendChild(this.container);
}
private setSize() {
const { width, height } = this.options;
const { container, frame, snapshot } = this;
container.style.width = width + 'px';
container.style.height = height + 'px';
snapshot.style.width = width + 'px';
snapshot.style.height = height + 'px';
frame.width = width * devicePixelRatio;
frame.height = height * devicePixelRatio;
frame.style.display = 'block';
frame.style.width = width + 'px';
frame.style.height = height + 'px';
frame.style.marginTop = -height + 'px';
}
public update = () => {
this.updateTimer = requestAnimationFrame(this.updateInternal);
};
private updateInternal = () => {
const { width, height, strokeColor, strokeWidth } = this.options;
const { ctx, ogma, dppx } = this;
// clear frame
ctx.clearRect(0, 0, this.frame.width, this.frame.height);
// we are now in the preview square coordinate space
ctx.save();
ctx.scale(dppx, dppx);
const size = ogma.view.getSize();
const zoom = ogma.view.getZoom();
const viewCenter = ogma.view.getCenter();
const graphBbox = ogma.view.getGraphBoundingBox();
const scale =
graphBbox.width > graphBbox.height
? width / graphBbox.width
: height / graphBbox.height;
const vec = {
x: (viewCenter.x - (graphBbox.minX + graphBbox.maxX) / 2) * scale,
y: (viewCenter.y - (graphBbox.minY + graphBbox.maxY) / 2) * scale
};
const hStroke = strokeWidth / 2;
let w = (size.width * scale) / zoom;
let h = (size.height * scale) / zoom;
let x = clamp(width / 2 + vec.x - w / 2, -w, width - hStroke);
let y = clamp(height / 2 + vec.y - h / 2, -h, height - hStroke);
if (x + w > width) w = width - x - hStroke;
if (y + h > height) h = height - y - hStroke;
if (x < hStroke) {
w += x - hStroke;
x = hStroke;
}
if (y < hStroke) {
h += y - hStroke;
y = hStroke;
}
// box style
ctx.strokeStyle = strokeColor;
ctx.lineWidth = strokeWidth;
// draw rect
ctx.beginPath();
ctx.rect(x, y, w, h);
ctx.closePath();
ctx.stroke();
ctx.restore();
};
destroy() {
cancelAnimationFrame(this.updateSnapshotTimer);
cancelAnimationFrame(this.updateTimer);
if (this.container.parentElement)
this.container.parentElement.removeChild(this.container);
// @ts-ignore
this.container = null;
this.ogma.events.off(this.updateSnapshot).off(this.update);
}
}