Skip to content
  1. Examples

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