Skip to content
  1. Examples

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