Appearance
Draw your graph
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';
const root = createRoot(document.getElementById('root'));
root.render(<App />);
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta charset="utf-8" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://cdn.jsdelivr.net/npm/lucide-static@0.483.0/font/lucide.css"
rel="stylesheet"
/>
<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"
/>
<script src="https://cdn.jsdelivr.net/npm/drag-drop-touch-polyfill-es5-compiled@1.0.0/DragDropTouch.min.js"></script>
<title>Draw your graph</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>
css
body {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
font-family: 'IBM Plex Sans', Arial, Helvetica, sans-serif;
}
#root {
width: 100%;
height: 100%;
}
.ogma-container {
width: 100%;
height: 100%;
}
json
{
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@linkurious/ogma-react": "latest",
"react": "19.1.0",
"react-dom": "19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.4",
"@vitejs/plugin-react": "^4.4.1",
"vite": "latest"
}
}
tsx
import React from 'react';
import { Ogma, useEvent } from '@linkurious/ogma-react';
import OgmaLib, { RawGraph } from '@linkurious/ogma';
import './style.css';
import { Controls } from './Controls';
import { createNode } from './utils';
import { Styles } from './Styles';
function App() {
const ogmaRef = React.useRef<OgmaLib<unknown, unknown>>(null);
const [isMove, setIsMove] = React.useState(true);
const [graph, setGraph] = React.useState<RawGraph<unknown, unknown>>();
const controlsRef = React.useRef<{
getIconCode: (className: string) => string;
getToolbarHeight: () => number;
}>(null);
React.useEffect(() => {
// Initialize the graph when the component mounts
const graph: RawGraph<unknown, unknown> = {
nodes: [
createNode('money', controlsRef.current!.getIconCode('banknote'), 0, 0),
createNode(
'commodity',
controlsRef.current!.getIconCode('puzzle'),
45,
4
)
],
edges: [
{
id: 'money-assets',
source: 'money1',
target: 'commodity2',
attributes: { shape: { head: 'arrow' } }
}
]
};
setGraph(graph);
}, []);
const onDragEnd = useEvent('dragEnd', ev => {
ev.domEvent.preventDefault();
});
const onDrop = useEvent('drop', ev => {
// Convert the drop point to graph coords
const pos = ogmaRef.current!.view.screenToGraphCoordinates({
x: ev.domEvent.clientX,
y: ev.domEvent.clientY - controlsRef.current!.getToolbarHeight()
});
// now get the icons type and its URL
const id = ev.domEvent.dataTransfer!.getData(
'type'
) as keyof typeof createNode;
ev.domEvent.dataTransfer!.dropEffect = 'copy';
ev.domEvent.preventDefault();
// create a node on the graph to the exact x and y of the drop
ogmaRef.current!.addNode(
createNode(id, controlsRef.current!.getIconCode(id), pos.x, pos.y)
);
ogmaRef.current!.view.locateGraph({ duration: 500 });
});
// Enable the connect tool when dragging a node and when the user is not in move mode
const onDragStart = useEvent(
'dragStart',
() => {
if (isMove) return;
ogmaRef.current!.tools.connectNodes.enable({
strokeColor: 'red',
createNodes: false,
// avoid self edges
condition: (source, target) => source.getId() !== target!.getId(),
createEdge: edge => {
edge.attributes = { shape: { head: 'arrow' } };
return edge;
}
});
},
[isMove]
);
const deleteItems = () => {
const selectedNodes = ogmaRef.current!.getSelectedNodes();
const selectedEdges = ogmaRef.current!.getSelectedEdges();
if (selectedNodes) {
ogmaRef.current!.removeNodes(selectedNodes);
}
if (selectedEdges) {
ogmaRef.current!.removeEdges(selectedEdges);
}
};
// Delete selected nodes and edges on backspace or delete key
const onKeyDown = useEvent('keydown', ev => {
if (ev.key === 'del' || ev.key === 'backspace') {
deleteItems();
}
});
return (
<>
{graph && (
<Ogma
ref={ogmaRef}
onReady={ref => {
ogmaRef.current = ref;
}}
graph={graph}
onDragEnd={onDragEnd}
onDrop={onDrop}
onDragStart={onDragStart}
onKeydown={onKeyDown}
>
<Styles />
</Ogma>
)}
<Controls isMove={isMove} setIsMove={setIsMove} ref={controlsRef} />
</>
);
}
export default App;
css
.toolbar {
display: flex;
align-items: center;
padding: 10px 20px;
position: fixed;
top: 0;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65);
border-radius: 4px;
background: #ffffff;
color: #222222;
height: 50px;
width: calc(100% - 40px);
}
tsx
import React from 'react';
import { Icons } from './Icons';
import { ModeToggle } from './ModeToggle';
import './Controls.css';
export const Controls = (props: {
isMove: boolean;
setIsMove: (isGeo: boolean) => void;
ref: React.Ref<{
getIconCode: (className: string) => string;
}>;
}) => {
const { isMove, setIsMove } = props;
const iconsRef = React.createRef<{
getIconCode: (className: string) => string;
}>();
// Expose methods to the parent component
React.useImperativeHandle(props.ref, () => {
return {
getIconCode: (className: string) => {
if (iconsRef.current) {
return iconsRef.current.getIconCode(className);
}
return '';
},
getToolbarHeight: () => {
const toolbar = document.querySelector('.toolbar');
if (toolbar) {
return toolbar.clientHeight;
}
return 0;
}
};
});
return (
<div className="toolbar">
<Icons ref={iconsRef} />
<ModeToggle isMove={isMove} setIsMove={setIsMove} />
</div>
);
};
css
.toolbar-icons i {
font-size: 2.5em;
}
img {
width: 50px;
height: 50px;
}
.toolbar-icons {
flex-basis: 70%;
}
.inline-images {
display: flex;
justify-content: space-around;
}
#banknote:hover {
color: #56b956;
}
#puzzle:hover {
color: #bd8362;
}
#files:hover {
color: #cacaca;
}
#cctv:hover {
color: #f7102f;
}
#building:hover {
color: #c7c7c7;
}
.inline-images > * {
cursor: pointer;
}
tsx
import React from 'react';
import './Icons.css';
export const Icons = (props: {
ref: React.Ref<{
getIconCode: (className: string) => string;
}>;
}) => {
// Expose methods to the parent component
React.useImperativeHandle(props.ref, () => {
return {
getIconCode(className: string) {
const iconContainer = document.querySelector(
'.icon-' + className
) as HTMLElement;
const code = getComputedStyle(iconContainer, ':before').content;
return code[1];
}
};
});
function handleDragStart(ev: React.DragEvent<HTMLDivElement>) {
ev.dataTransfer.setData('type', ev.currentTarget.id);
}
return (
<div className="toolbar-icons">
<div className="inline-images">
<i
className="icon-puzzle"
draggable="true"
id="puzzle"
onDragStart={handleDragStart}
/>
<i
className="icon-files"
draggable="true"
id="files"
onDragStart={handleDragStart}
/>
<i
className="icon-cctv"
draggable="true"
id="cctv"
onDragStart={handleDragStart}
/>
<i
className="icon-banknote"
draggable="true"
id="banknote"
onDragStart={handleDragStart}
/>
<i
className="icon-building"
draggable="true"
id="building"
onDragStart={handleDragStart}
/>
</div>
</div>
);
};
css
/* From Uiverse.io by arghyaBiswasDev */
/* The switch - the box around the slider */
.switch .switch--horizontal {
width: 100%;
}
.switch {
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
gap: 10px;
padding: 0 10px;
font-size: 17px;
position: relative;
width: 100%;
height: 2em;
}
/* Hide default HTML checkbox */
.switch input {
opacity: 0;
width: 0;
height: 0;
display: none;
}
/* The slider */
.slider {
display: flex;
position: relative;
width: 60px;
height: 30px;
background-color: #ccc;
border-radius: 20px;
cursor: pointer;
transition: background-color 0.2s;
flex-shrink: 0;
}
.slider:before {
content: '';
position: absolute;
width: 28px;
height: 28px;
border-radius: 50%;
background-color: white;
top: 1px;
left: 1px;
transition: transform 0.2s;
}
input:focus + .slider {
box-shadow: 0 0 1px #007bff;
}
input:checked + .slider:before {
transform: translateX(30px);
}
.label-text {
color: #808080;
text-align: center;
}
.selected {
color: #222222;
}
tsx
import React from 'react';
import './ModeToggle.css';
export const ModeToggle = (props: {
isMove: boolean;
setIsMove: (isGeo: boolean) => void;
}) => {
const { isMove, setIsMove } = props;
return (
<div className="section mode">
<div className="switch switch--horizontal">
<label className="switch">
<span className={'label-text' + (isMove ? ' selected' : '')}>
Move nodes
</span>
<input
name="_"
checked={!isMove}
type="checkbox"
onChange={() => setIsMove(!isMove)}
/>
<span className="slider"></span>
<span className={'label-text' + (!isMove ? ' selected' : '')}>
Connect links
</span>
</label>
</div>
</div>
);
};
tsx
import { EdgeStyle, NodeStyle } from '@linkurious/ogma-react';
import React from 'react';
export const Styles = () => {
return (
<>
{/* Selected style for nodes */}
<NodeStyle
selector={node => node.isSelected()}
attributes={{
color: null,
outline: false,
halo: null,
outerStroke: {
color: 'red'
}
}}
/>
{/* Default style for nodes */}
<NodeStyle
attributes={{
radius: 5,
color: 'white',
text: {
content: node => node.getData('type'),
font: 'IBM Plex Sans'
},
innerStroke: {
color: '#ddd',
width: 1
}
}}
/>
{/* Default style for edges */}
<EdgeStyle
attributes={{
color: '#ddd',
width: 0.4
}}
/>
</>
);
};
ts
const nodeColors = {
money: '#56B956',
commodity: '#BD8362',
documents: '#CACACA',
fraudster: '#F7102F',
office: '#C7C7C7'
};
let counter = 0;
export const createNode = (
id: keyof typeof nodeColors,
icon: string,
x: number,
y: number
) => {
counter++;
return {
id: id + counter,
attributes: {
icon: {
font: 'lucide',
color: nodeColors[id],
content: icon,
scale: 0.5
},
x: x,
y: y
},
data: { type: id }
};
};
ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()]
});