Appearance
React + Minimap
This example shows how to integrate Ogma with React using the ogma-react library. It also shows how the minimap plugin can be integrated with your React component.
tsx
import React from 'react';
import ReactDOM from 'react-dom';
import ReactDOMServer from 'react-dom/server';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles.css';
const root = createRoot(document.getElementById('app'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<link type="text/css" rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="app"></div>
<!-- React -->
<script src="./index.tsx"></script>
</body>
</html>
css
html,
body {
height: 100%;
padding: 0;
margin: 0;
}
#app {
height: 100%;
}
.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;
}
json
{
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1",
"@linkurious/ogma-react": "5.0.3"
}
}
tsx
import React, { useState, useEffect } from 'react';
import Ogma, { RawGraph } from '@linkurious/ogma';
// avoid name clash
import { Ogma as Vis } from '@linkurious/ogma-react';
import { LayoutService } from './Layout';
import { Styles } from './Styles';
import { parse } from './parse';
import { Minimap } from './Minimap';
export default function App() {
// graph state
const [graph, setGraph] = useState<RawGraph>();
const [loading, setLoading] = useState(true);
useEffect(() => {
// load custom json and parse it
Ogma.parse
.jsonFromUrl('files/tokyo-subway.json', parse)
// store it in the component state
.then(json => setGraph(json))
.then(() => setLoading(false));
}, []);
if (loading) return <div>Loading...</div>;
return (
<Vis graph={graph}>
<Minimap />
<LayoutService />
<Styles />
</Vis>
);
}
tsx
import { useEffect } from 'react';
import { useOgma } from '@linkurious/ogma-react';
export function LayoutService() {
const ogma = useOgma(); // hook to get the ogma instance
useEffect(() => {
const onNodesAdded = evt => {
// apply layout here
ogma.layouts.force({ gravity: 0, locate: true });
};
ogma.events.on('addNodes', onNodesAdded);
if (ogma.getNodes().size > 0) onNodesAdded({ nodes: ogma.getNodes() });
// cleanup
return () => {
ogma.events.off(onNodesAdded);
};
}, []);
return null;
}
tsx
import { useEffect } from 'react';
import { MinimapControl } from './MinimapControl';
import { useOgma } from '@linkurious/ogma-react';
//import { useAppContext } from '../context';
export function Minimap() {
const ogma = useOgma(); // hook to get the ogma instance
useEffect(() => {
let minimap: MinimapControl;
if (ogma) {
minimap = new MinimapControl(ogma, {
width: 150,
height: 150,
strokeWidth: 0,
fillColor: `rgba(0,0,0,0.2)`
});
}
return () => {
minimap!.destroy();
};
}, [ogma]);
return null;
}
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);
}
}
ts
import { RawEdge, RawGraph } from '@linkurious/ogma';
export const parse = (input: any): RawGraph => {
const edges: RawEdge[] = [];
// keys are stations
const edgesVisited = new Set<string>();
const nodes = Object.keys(input).map(name => {
const node = input[name];
node.connections.forEach(connection => {
const source = name;
const target = connection.target_id;
// read line codes
const sourceLine = source.substring(0, 1);
const targetLine = target.substring(0, 1);
// make edges bi-directional
if (
!edgesVisited.has(source + ':' + target) &&
!edgesVisited.has(target + ':' + source)
) {
edgesVisited.add(source + ':' + target);
edgesVisited.add(target + ':' + source);
// identify the line by first symbol
const code = sourceLine === targetLine ? sourceLine : null;
edges.push({
source: name,
target: connection.target_id,
data: { code: code }
});
}
});
return { id: name, data: node };
});
return { nodes: nodes, edges: edges };
};
tsx
import React from 'react';
import O, { NodeStyleRule, EdgeStyleRule } from '@linkurious/ogma-react';
const colors = {
G: '#F9A328',
M: '#EE2628',
H: '#C7BEB3',
T: '#00AFEF',
C: '#1BB267',
Y: '#D1A662',
Z: '#8C7DBA',
N: '#02B69B',
F: '#A95E33',
A: '#EF463C',
I: '#0072BC',
S: '#ABBA41',
E: '#CE1C64'
};
export function Styles () {
return (<>
<NodeStyleRule attributes={{
radius: 12,
color: '#ffffff',
innerStroke: {
width: 7,
color: '#505050',
scalingMethod: 'scaled',
minVisibleSize: 0
},
draggable: false,
text: {
minVisibleSize: 0,
size: 8
}
}} />
<EdgeStyleRule attributes={{
color: edge => colors[edge.getData('code')],
width: 12
}} />
</>);
}