Appearance
All styles
This example provides a tweak panel to edit all NodeAttributes and EdgeAttributes availiable in Ogma. So you can see what they do and how they affect the style of your graph.
js
import Ogma from '@linkurious/ogma';
import { Pane } from 'tweakpane';
import {
defaultNodeAttributes,
defaultEdgeAttributes,
customNodeAttributes,
customEdgeAttributes,
createControllers,
onChange,
read
} from './gui';
// Ogma instance
const ogma = new Ogma({ container: 'graph-container' });
// Initialize the interface and the listener
const init = (
type,
nodeAttributes,
edgeAttributes,
onTypeChange,
updateNodeAttributes,
updateEdgeAttributes,
onImport,
onExport,
onReset
) => {
const setupGUI = () => {
// Create a Tweakpane interface with some folders
const gui = new Pane({
container: document.getElementById('gui-container')
});
// import / export buttons
gui.addButton({ title: 'Import' }).on('click', onImport);
gui
.addButton({ title: 'Export' })
.on('click', () => onExport(nodeAttributes, edgeAttributes));
console.log({ gui }, gui.addButton);
// Type
gui
.addBinding({ value: type }, 'value', {
label: 'type',
options: { all: 'all', hovered: 'hovered', selected: 'selected' }
})
.on('change', onTypeChange);
const nodeMenu = gui.addFolder({ title: 'Node Attributes' });
const edgeMenu = gui.addFolder({ title: 'Edge Attributes' });
// Create the custom attributes controllers
const nodeControllers = createControllers(nodeAttributes, nodeMenu);
const edgeControllers = createControllers(edgeAttributes, edgeMenu);
// Update ogma attributes on controller change
onChange([nodeControllers], evt =>
updateNodeAttributes(type, nodeAttributes, evt)
);
onChange([edgeControllers], evt =>
updateEdgeAttributes(type, edgeAttributes, evt)
);
// reset button
gui.addButton({ title: 'Reset' }).on('click', onReset);
return gui;
};
updateNodeAttributes(type, nodeAttributes);
updateEdgeAttributes(type, edgeAttributes);
return setupGUI();
};
const clone = obj => JSON.parse(JSON.stringify(obj));
// Generate a simple graph following the Barabási–Albert model
ogma.generate
.barabasiAlbert({
nodes: 20,
scale: 100
})
.then(rawGraph => ogma.setGraph(rawGraph))
.then(() => {
let gui,
customStyle = {
nodes: {
all: clone(customNodeAttributes),
hovered: clone(customNodeAttributes),
selected: clone(customNodeAttributes)
},
edges: {
all: clone(customEdgeAttributes),
hovered: clone(customEdgeAttributes),
selected: clone(customEdgeAttributes)
}
};
// Update node / edge attributes callback handling the update according to the "type"
const updateNodeAttributes = (type, nodeAttributes, evt /* optional */) => {
// Update behaviour depends on "type"
switch (type) {
case 'all':
// Apply the attributes to all the selection (or all if no selection)
const selected = ogma.getSelectedNodes();
// If nothing is selected, apply custom node attributes to all
const nodes = selected.size > 0 ? selected : ogma.getNodes();
nodes.setAttributes(read(nodeAttributes));
break;
case 'hovered':
ogma.styles.setHoveredNodeAttributes(read(nodeAttributes));
break;
case 'selected':
ogma.styles.setSelectedNodeAttributes(read(nodeAttributes));
break;
default:
console.error(`Unknown type ${type}`);
}
};
const updateEdgeAttributes = (type, edgeAttributes, evt /* optional */) => {
// Update behaviour depends on "type"
switch (type) {
case 'all':
// Apply the attributes to all the selection (or all if no selection)
const selected = ogma.getSelectedEdges();
// If nothing is selected, apply custom edge attributes to all
const edges = selected.size > 0 ? selected : ogma.getEdges();
edges.setAttributes(read(edgeAttributes));
break;
case 'hovered':
ogma.styles.setSelectedEdgeAttributes(read(edgeAttributes));
break;
case 'selected':
ogma.styles.setSelectedEdgeAttributes(read(edgeAttributes));
break;
default:
console.error(`Unknown type ${type}`);
}
};
// type change
const typeChange = newType => {
gui.dispose();
gui = init(
newType,
customStyle.nodes[newType],
customStyle.edges[newType],
typeChange,
updateNodeAttributes,
updateEdgeAttributes,
() => fileInput.click(),
export_,
reset
);
};
// import json
const import_ = evt => {
const file = evt.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = evt => {
try {
customStyle = JSON.parse(evt.target.result);
gui.dispose();
gui = init(
'all',
customStyle.nodes.all,
customStyle.edges.all,
typeChange,
updateNodeAttributes,
updateEdgeAttributes,
() => fileInput.click(),
export_,
reset
);
// Update the other types (selected + hovered)
updateNodeAttributes('selected', customStyle.nodes.selected);
updateEdgeAttributes('selected', customStyle.edges.selected);
updateNodeAttributes('hovered', customStyle.nodes.hovered);
updateEdgeAttributes('hovered', customStyle.edges.hovered);
} catch (err) {
console.error('Cannot import the file. Reason :', err);
}
};
reader.readAsText(file);
evt.target.value = ''; // so we can load the same file twice in a row, reseting the current styles
};
const fileInput = document.getElementById('file-input');
fileInput.oninput = import_;
// export json
const export_ = () => {
// https://stackoverflow.com/a/30800715
const dataStr =
'data:text/json;charset=utf-8,' +
encodeURIComponent(JSON.stringify(customStyle));
const dlAnchorElem = document.getElementById('download');
dlAnchorElem.setAttribute('href', dataStr);
dlAnchorElem.setAttribute(
'download',
`styles ${new Date().toLocaleString()}.json`.replace(/\s/g, '_')
);
dlAnchorElem.click();
};
// reset to default style
const reset = () => {
gui.dispose();
gui = init(
'all',
clone(defaultNodeAttributes),
clone(defaultEdgeAttributes),
typeChange,
updateNodeAttributes,
updateEdgeAttributes,
() => fileInput.click(),
export_,
reset
);
};
// init gui
gui = init(
'all',
customStyle.nodes.all,
customStyle.edges.all,
typeChange,
updateNodeAttributes,
updateEdgeAttributes,
() => fileInput.click(),
export_,
reset
);
// Update the other types (selected + hovered)
updateNodeAttributes('selected', customStyle.nodes.selected);
updateEdgeAttributes('selected', customStyle.edges.selected);
updateNodeAttributes('hovered', customStyle.nodes.hovered);
updateEdgeAttributes('hovered', customStyle.edges.hovered);
})
.then(() => ogma.layouts.force({ gravity: 0.005, locate: true }));
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>
<div id="gui-container"></div>
<input
id="file-input"
type="file"
name="import-json"
style="display: none"
accept=".json"
/>
<a id="download" style="display: none"></a>
<script src="index.js"></script>
</body>
</html>
css
html {
margin: 0;
overflow: hidden;
}
#graph-container {
top: 0;
bottom: 0;
left: 0;
right: 0;
position: absolute;
margin: 0;
overflow: hidden;
}
#gui-container {
overflow: auto;
width: 400px;
height: 100vh;
position: absolute;
top: 0px;
right: 0;
}
.menu {
border: 1px solid #ddd;
width: 80%;
font-size: 14px;
}
js
import { Pane } from 'tweakpane';
// Quick abstraction to process some objects before passing them to Tweakpane
export const shallow = value => ({ value, isShallow: true });
// Optional value
export const optional = (value, disabled = true) => ({
...shallow(value),
disabled
});
// Value within a [min, max] range (with optional step incrementation)
export const range = (value, min, max, step) => ({
...shallow(value),
min,
max,
step,
isRange: true
});
// Menu filled with some items
export const menu = (value, items) => ({
...shallow(value),
items,
isMenu: true
});
// Some values specific to nodes and edges attributes
export const scalingMethod = value => menu(value, ['scaled', 'fixed']);
export const fontStyle = value => menu(value, ['normal', 'bold', 'italic']);
export const nodeShape = value =>
menu(value, [
'circle',
'cross',
'diamond',
'pentagon',
'square',
'star',
'equilateral'
]);
export const textAlign = value => menu(value, ['left', 'center']);
export const position = value =>
menu(value, ['right', 'left', 'top', 'bottom', 'center']);
export const edgeType = value => menu(value, ['line', 'triangle']);
export const edgeExtremity = value =>
menu(value, [
'none',
'arrow',
'circle-hole-arrow',
'triangle-hole-arrow',
'short-arrow',
'sharp-arrow',
'circle',
'square'
]);
export const edgeStyle = value => menu(value, ['plain', 'dotted', 'dashed']);
// Default node badge
export const defaultBadge = {
color: '#fff',
image: optional({
scale: 1,
url: 'null'
}),
minVisibleSize: 12,
positionScale: 1,
scale: range(0.45, 0, 20),
stroke: {
color: '#000',
scalingMethod: scalingMethod('fixed'),
width: 2
},
text: {
color: '#000',
content: 'null',
font: 'Arial',
paddingLeft: 0,
paddingTop: 0,
scale: 0.5,
style: fontStyle('normal')
}
};
// All possible node attributes
export const defaultNodeAttributes = {
badges: optional({
bottomLeft: optional(defaultBadge),
bottomRight: optional(defaultBadge),
topLeft: optional(defaultBadge),
topRight: optional(defaultBadge)
}),
color: '#888',
detectable: true,
draggable: true,
halo: optional({
color: optional('#999'),
hideNonAdjacentEdges: false,
scalingMethod: scalingMethod('fixed'),
strokeColor: optional('#999'),
strokeWidth: 1,
width: 50
}),
// hidden: false,
icon: optional({
color: '#000',
content: 'null',
font: 'Arial',
minVisibleSize: 12,
scale: 0.7,
style: fontStyle('normal')
}),
image: optional({
fit: true,
minVisibleSize: 12,
scale: 1,
tile: false,
url: 'null'
}),
innerStroke: {
color: '#fff',
minVisibleSize: 12,
scalingMethod: scalingMethod('fixed'),
width: 2
},
layer: 0,
layoutable: true,
opacity: 1,
outerStroke: {
color: optional('#999'),
minVisibleSize: 0,
scalingMethod: scalingMethod('fixed'),
width: 5
},
outline: {
color: 'rgba(0, 0, 0, 0.36)',
enabled: false,
minVisibleSize: 12
},
pulse: {
duration: 1000,
enabled: false,
endColor: 'rgba(0, 0, 0, 0)',
endRatio: 2,
interval: 800,
startColor: 'rgba(0, 0, 0, 0.6)',
startRatio: 1,
width: 50
},
radius: 5,
scalingMethod: scalingMethod('scaled'),
shape: nodeShape('circle'),
text: optional({
align: textAlign('center'),
backgroundColor: optional('#999'),
color: '#000',
content: 'null',
font: 'Arial',
margin: 10,
maxLineLength: 0,
minVisibleSize: 24,
padding: 2,
position: position('bottom'),
scale: 0.1,
scaling: false,
secondary: {
align: textAlign('center'),
backgroundColor: optional('#999'),
color: '#000',
content: 'null',
font: 'Arial',
margin: 2,
minVisibleSize: 24,
padding: 2,
scale: 0.08,
size: 10,
style: fontStyle('normal')
},
size: 12,
style: fontStyle('normal'),
tip: true
})
// x: 0, // no need to specify coords here
// y: 0
};
export const defaultEdgeAttributes = {
adjustAnchors: true,
color: '#888',
detectable: true,
halo: optional({
color: optional('#999'),
scalingMethod: scalingMethod('fixed'),
width: 10
}),
// hidden: false,
layer: 0,
minVisibleSize: 0,
opacity: 1,
outline: {
color: 'rgba(0, 0, 0, 0.36)',
enabled: false,
minVisibleSize: 0
},
pulse: {
duration: 1000,
enabled: false,
endColor: 'rgba(0, 0, 0, 0)',
endRatio: 2,
interval: 800,
startColor: 'rgba(0, 0, 0, 0.6)',
startRatio: 1,
width: 50
},
scalingMethod: scalingMethod('scaled'),
shape: {
body: edgeType('line'),
head: edgeExtremity('null'),
style: edgeStyle('plain'),
tail: edgeExtremity('null')
},
stroke: {
color: '#888',
minVisibleSize: 0,
width: 0
},
text: {
adjustAngle: true,
align: textAlign('center'),
backgroundColor: optional('#999'),
color: '#000',
content: 'null',
font: 'Arial',
margin: 2,
maxLineLength: 0,
minVisibleSize: 4,
padding: 2,
scale: 1,
scaling: false,
secondary: {
align: textAlign('center'),
backgroundColor: optional('#999'),
color: '#000',
content: 'null',
font: 'Arial',
margin: 2,
minVisibleSize: 4,
padding: 2,
scale: 0.8,
size: 12,
style: fontStyle('normal')
},
size: 12,
style: fontStyle('normal')
},
width: 1
};
export const customNodeAttributes = {
...defaultNodeAttributes,
color: '#b0496c',
pulse: {
duration: 1000,
enabled: false,
endColor: 'rgba(0, 0, 0, 0)',
endRatio: 2,
interval: 800,
startColor: 'rgba(0, 0, 0, 0.6)',
startRatio: 1,
width: 5
},
badges: optional(
{
bottomLeft: optional(
{
color: '#fff',
image: optional(
{
scale: 1,
url: 'flags/fr.svg'
},
false
),
minVisibleSize: 12,
positionScale: 1,
scale: range(0.45, 0, 20),
stroke: {
color: '#000',
scalingMethod: scalingMethod('fixed'),
width: 2
},
text: {
color: '#000',
content: '1',
font: 'Arial',
paddingLeft: 0,
paddingTop: 0,
scale: 0.5,
style: fontStyle('normal')
}
},
false
),
bottomRight: optional(defaultBadge),
topLeft: optional(defaultBadge),
topRight: optional(defaultBadge)
},
false
),
image: optional(
{
fit: true,
minVisibleSize: 12,
scale: 1,
tile: false,
url: 'flags/es.svg'
},
false
)
};
export const customEdgeAttributes = {
...defaultEdgeAttributes,
color: '#333',
shape: {
body: edgeType('line'),
head: edgeExtremity('arrow'),
style: edgeStyle('plain'),
tail: edgeExtremity('arrow')
}
};
// Process the values, create the controllers
export const createControllers = (obj, gui) => {
if (!gui) gui = new Pane();
return Object.entries(obj)
.filter(([key]) => !obj.isShallow || key === 'value')
.map(([key, content]) => {
// Handle obj props, returns a controller
if (typeof content === 'object') {
if (content['isShallow']) {
if (content['disabled'] !== undefined) {
const objGui = gui.addFolder({
title: `${key} [optional]`,
expanded: false
});
const disabled = objGui.addBinding(content, 'disabled');
const valueGui = objGui.addFolder({
title: 'value',
expanded: false
});
disabled.on('change', active => (valueGui.hidden = active));
valueGui.hidden = content.disabled;
const { value } = content;
return [
disabled,
createControllers(
typeof value === 'object' ? value : content,
valueGui
)
];
}
if (content['isRange'])
return gui.addBinding(content, 'value', {
label: key,
min: content.min,
max: content.max,
step: content.step
});
if (content['isMenu'])
return gui.addBinding(content, 'value', {
label: key,
options: content.items.reduce((acc, cur) => {
acc[cur] = cur;
return acc;
}, {})
});
}
const objGui = gui.addFolder({ title: key, expanded: false });
return createControllers(content, objGui);
}
// Otherwise it's not an object, returns a controller
// (tweakplate is smart enough to detect color by itself)
return gui.addBinding(obj, key);
});
};
// Listen for controller changes
export const onChange = (controllers, callback) => {
controllers.forEach(c => {
if (Array.isArray(c)) onChange(c, callback);
else c.on('change', callback);
});
};
// Read an object to pass it to ogma
export const read = obj => {
if (typeof obj !== 'object') return obj;
const result = {};
Object.entries(obj).forEach(([key, val]) => {
result[key] = val.isShallow
? val.disabled
? null
: read(val.value)
: read(val);
});
return result;
};