Skip to content
  1. Examples

Annotations

This example shows how you can integrate @linkurious/ogma-annotations-react plugin with Ogma to create a graph visualization with annotations. You can draw arrows that can be snapped to nodes, and add text annotations to the graph. You can link text boxes to nodes and see how the arrows will follow the nodes and text boxes when they move.

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(<App />);
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@linkurious/ogma-annotations@1.1.16/dist/style.css"
    />
    <link type="text/css" rel="stylesheet" href="styles.css" />
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap"
      rel="stylesheet"
    />
    <!-- Importmap is only needed here to solve the issue with private npm registry -->
    <script type="importmap">
      {
        "imports": {
          "@linkurious/ogma-react": "https://cdn.jsdelivr.net/npm/@linkurious/ogma-react@latest/index.mjs",
          "@linkurious/ogma-annotations-react": "https://cdn.jsdelivr.net/npm/@linkurious/ogma-annotations-react@1.1.26/dist/index.mjs"
        }
      }
    </script>
  </head>
  <body>
    <div id="app"></div>
    <script src="./index.tsx"></script>
  </body>
</html>
css
html,
body {
  font-family: 'IBM Plex Sans', Arial, Helvetica, sans-serif;
  margin: 0;
  padding: 0;
}

#graph-container {
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  position: absolute;
  margin: 0;
  overflow: hidden;
}
json
{
  "dependencies": {
    "@linkurious/ogma-annotations": "1.1.26",
    "@linkurious/ogma-annotations-react": "1.1.26",
    "react": "19.1.0",
    "react-dom": "19.1.0"
  }
}
json
{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "id": 2,
      "properties": {
        "type": "arrow",
        "style": {
          "strokeType": "plain",
          "strokeColor": "#2D00A6",
          "strokeWidth": 4,
          "head": "halo-dot",
          "tail": "none"
        },
        "link": {
          "start": {
            "id": 0,
            "side": "start",
            "type": "text",
            "magnet": {
              "x": 1,
              "y": 0.5
            }
          },
          "end": {
            "id": "0",
            "side": "end",
            "type": "node",
            "magnet": {
              "x": -45.2809297350213,
              "y": -159.10598861297984
            }
          }
        }
      },
      "geometry": {
        "type": "LineString",
        "coordinates": [
          [-663.8988904175328, -356.74430552470324],
          [-45.21970709863166, -159.31319405632294]
        ]
      }
    },
    {
      "id": "_ehl9o_Yw7ubR17UConfe",
      "type": "Feature",
      "properties": {
        "type": "arrow",
        "style": {
          "strokeType": "plain",
          "strokeColor": "#e8346d",
          "strokeWidth": 5,
          "head": "halo-dot",
          "tail": "none"
        },
        "link": {
          "end": {
            "id": "1",
            "side": "end",
            "type": "node",
            "magnet": {
              "x": -503.6878974740729,
              "y": 348.9457782965114
            }
          },
          "start": {
            "id": "yHia09RukDxkVI5Jb50ze",
            "side": "start",
            "type": "text",
            "magnet": {
              "x": 0,
              "y": 0.5
            }
          }
        }
      },
      "geometry": {
        "type": "LineString",
        "coordinates": [
          [447.89908613443674, 561.6440218094451],
          [-503.57114352516686, 351.9211278358034]
        ]
      }
    },
    {
      "id": "rHX86vJ2-0tp6MahHTlGF",
      "type": "Feature",
      "properties": {
        "type": "arrow",
        "style": {
          "strokeType": "plain",
          "strokeColor": "#e8346d",
          "strokeWidth": 5,
          "head": "halo-dot",
          "tail": "none"
        },
        "link": {
          "end": {
            "id": "1317",
            "side": "end",
            "type": "node",
            "magnet": {
              "x": 783.9361210917288,
              "y": -68.3140821722226
            }
          },
          "start": {
            "id": "yHia09RukDxkVI5Jb50ze",
            "side": "start",
            "type": "text",
            "magnet": {
              "x": 0.5,
              "y": 0
            }
          }
        }
      },
      "geometry": {
        "type": "LineString",
        "coordinates": [
          [656.5223749658235, 523.2047849237448],
          [600.8912284444023, -22.274441521509942]
        ]
      }
    },
    {
      "type": "Feature",
      "id": 0,
      "properties": {
        "type": "text",
        "content": "This is a cluster",
        "style": {
          "font": "IBM Plex Sans",
          "fontSize": 52,
          "color": "#2D00A6",
          "background": "#EDE6FF",
          "strokeWidth": 0,
          "borderRadius": 8,
          "padding": 12,
          "strokeType": "plain"
        }
      },
      "geometry": {
        "type": "Polygon",
        "coordinates": [
          [
            [-1055.6712819373658, -400.20513968406317],
            [-663.8988904175328, -400.20513968406317],
            [-663.8988904175328, -313.28347136534336],
            [-1055.6712819373658, -313.28347136534336],
            [-1055.6712819373658, -400.20513968406317]
          ]
        ],
        "bbox": [
          -1055.6712819373658, -400.20513968406317, -663.8988904175328,
          -313.28347136534336
        ]
      }
    },
    {
      "id": "yHia09RukDxkVI5Jb50ze",
      "type": "Feature",
      "properties": {
        "type": "text",
        "content": "This is another one",
        "style": {
          "font": "IBM Plex Sans",
          "fontSize": 32,
          "color": "#e8346d",
          "background": "#FAEDF7",
          "strokeWidth": 0,
          "borderRadius": 8,
          "padding": 12,
          "strokeType": "plain",
          "cornerRadius": 8
        }
      },
      "geometry": {
        "type": "Polygon",
        "coordinates": [
          [
            [447.89908613443674, 523.2047849237448],
            [865.1456637972101, 523.2047849237448],
            [865.1456637972101, 600.0832586951456],
            [447.89908613443674, 600.0832586951456],
            [447.89908613443674, 523.2047849237448]
          ]
        ],
        "bbox": [
          447.89908613443674, 523.2047849237448, 865.1456637972101,
          600.0832586951456
        ]
      }
    }
  ]
}
css
#app {
  display: flex;
  width: 100vw;
  height: 100vh;
}

.ogma-container {
  width: 100%;
  height: 100%;
}
tsx
import React from 'react';
import OgmaLib, { type RawGraph, parse } from '@linkurious/ogma';
import { Ogma } from '@linkurious/ogma-react';
import { getAnnotationsBounds } from '@linkurious/ogma-annotations';
import { AnnotationsContextProvider } from '@linkurious/ogma-annotations-react';
import { Styles } from './Styles';
import { Controls } from './Controls';

import './App.css';

export function App() {
  const [loading, setLoading] = React.useState(true);
  const [graph, setGraph] = React.useState<RawGraph>(null);
  const [annotations, setAnnotations] = React.useState([]);
  const ogmaRef = React.useRef<OgmaLib>(null);

  React.useEffect(() => {
    setLoading(true);
    Promise.all([
      parse.jsonFromUrl('files/paris.json').then(graph => setGraph(graph)),
      fetch('./annotations.json')
        .then(response => response.json())
        .then(annotations => setAnnotations(annotations))
    ]).then(() => {
      setLoading(false);
    });
  }, []);

  const onReady = React.useCallback(
    (ogma: OgmaLib) => {
      const bounds = ogma.view
        .getGraphBoundingBox()
        .extend(getAnnotationsBounds(annotations))
        .pad(30);
      ogma.view.moveToBounds(bounds, { duration: 0 });
    },
    [ogmaRef, graph, annotations]
  );

  if (loading) return <div className="loading">Loading...</div>;
  return (
    <Ogma graph={graph} onReady={onReady} ref={ogmaRef}>
      <Styles />
      <AnnotationsContextProvider annotations={annotations}>
        <Controls />
      </AnnotationsContextProvider>
    </Ogma>
  );
}
css
#controls {
  position: absolute;
  top: 2em;
  right: 2em;
  display: flex;
}

#controls button {
  width: 3em;
  height: 2.5em;
  margin: 0;
  border: 1px solid rgba(0, 0, 0, 0.2);
  background-color: #fff;
  color: #444;
  cursor: pointer;
  background-size: 50%;
  background-repeat: no-repeat;
  background-position: center center;
  margin-left: -1px;
}

#controls button:hover,
#controls button:active,
#controls button.active {
  color: #222;
  background-color: #f0f0f0;
  border-color: rgba(0, 0, 0, 0.4);
}

#controls button:disabled {
  cursor: not-allowed;
  color: #999;
  background-color: #f9f9f9;
  border-color: rgba(0, 0, 0, 0.2);
  opacity: 0.5;
}

#controls button:first-child {
  border-top-left-radius: 5px;
  border-bottom-left-radius: 5px;
  border-right-width: 0;
}

#controls button:last-child {
  border-top-right-radius: 5px;
  border-bottom-right-radius: 5px;
}

#draw-arrow {
  background-image: url('./img/icon-arrow.svg');
}

#draw-text {
  background-image: url('./img/icon-textbox.svg');
}

#download {
  background-image: url('./img/icon-download.svg');
}

#snapshot {
  background-image: url('./img/icon-photo.svg');
}
tsx
import React from 'react';
import {
  type AnnotationCollection,
  createArrow,
  createText,
  getAnnotationsBounds,
  isArrow,
  isText
} from '@linkurious/ogma-annotations';
import { useOgma } from '@linkurious/ogma-react';
import { useAnnotationsContext } from '@linkurious/ogma-annotations-react';
import './Controls.css';

export function Controls() {
  const { editor, annotations } = useAnnotationsContext();
  const [drawingArrow, setDrawingArrow] = React.useState(false);
  const [drawingText, setDrawingText] = React.useState(false);
  const ogma = useOgma();

  const onDrawArrow = React.useCallback(() => {
    if (drawingArrow || drawingText) return;

    setDrawingArrow(true);

    ogma.events.once('mousedown', evt => {
      const { x, y } = ogma.view.screenToGraphCoordinates(evt);
      const arrow = createArrow(x, y, x, y, {
        strokeWidth: 8,
        strokeColor: '#811313',
        strokeType: 'plain',
        head: 'arrow'
      });
      editor.startArrow(x, y, arrow);
      editor.once('dragend', () => setDrawingArrow(false));
    });
  }, [ogma, editor]);

  const onDrawText = React.useCallback(() => {
    if (drawingArrow || drawingText) return;

    ogma.events.once('mousedown', evt => {
      const { x, y } = ogma.view.screenToGraphCoordinates(evt);
      const text = createText(x, y, 0, 0, '', {
        fontSize: 34,
        font: 'IBM Plex Sans',
        padding: 22,
        color: '#1499ff',
        strokeWidth: 1,
        strokeColor: 'rgba(0,0,0,0.35)'
      });
      editor.startText(x, y, text);
      editor.once('dragend', () => setDrawingText(false));
    });
  }, [ogma, editor, drawingArrow]);

  const onSnapshot = React.useCallback(() => {
    if (!ogma || !editor) return;

    const exportView = async () => {
      const view = ogma.view.get();
      const bounds = ogma.view
        .getGraphBoundingBox()
        .extend(getAnnotationsBounds(editor.getAnnotations()))
        .pad(30);

      await ogma.view.moveToBounds(bounds, { duration: 0 });
      const zoom = ogma.view.getZoom();
      const width = bounds.width * zoom;
      const height = bounds.height * zoom;

      const desiredWidth = 566;
      const desiredHeight = 420;

      const ratio = Math.min(desiredWidth / width, desiredHeight / height);
      await ogma.view.setZoom(zoom * ratio, { ignoreZoomLimits: true });
      await ogma.export.svg({
        background: '#fff',
        width: desiredWidth,
        height: desiredHeight,
        clip: true
      });
      await ogma.view.set(view);
    };
    exportView();
  }, [ogma, editor, annotations]);

  const onDownload = React.useCallback(() => {
    if (!ogma || !editor) return;
    const annotations = editor.getAnnotations();
    const data = JSON.stringify(annotations, null, 2);

    const blob = new Blob([data], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'annotations.json';
    a.click();
    URL.revokeObjectURL(url);
  }, [editor, ogma, annotations]);

  // stop drawing when pressing escape
  React.useEffect(() => {
    if (editor && ogma) {
      ogma.events.on('keyup', evt => {
        if (evt.key === 'esc') {
          editor.cancelDrawing();
          setDrawingArrow(false);
          setDrawingText(false);
        }
      });
    }
  }, [editor, ogma]);

  return (
    <div id="controls">
      <button
        id="draw-arrow"
        disabled={drawingText}
        title="Arrow"
        className={drawingArrow ? 'active' : ''}
        onClick={onDrawArrow}
      ></button>
      <button
        id="draw-text"
        disabled={drawingArrow}
        title="Text"
        className={drawingText ? 'active' : ''}
        onClick={onDrawText}
      ></button>
      <button id="snapshot" title="Snapshot" onClick={onSnapshot}></button>
      <button
        id="download"
        title="Download annotations"
        onClick={onDownload}
      ></button>
    </div>
  );
}
tsx
import React from 'react';
import { NodeStyle } from '@linkurious/ogma-react';

export function Styles() {
  const nodeAttributes = React.useMemo(
    () => ({
      text: {
        font: 'IBM Plex Sans'
      }
    }),
    []
  );
  return <NodeStyle attributes={nodeAttributes} />;
}