Skip to content
  1. Examples

Force layout GPU new

Use this playground to explore the settings of the new GPU mode of the force layout. You can also drag and drop your own graphs in JSON, .mtx or .gexf formats.

ts
import Ogma, { ForceLayoutOptions, RawGraph } from '@linkurious/ogma';
import { GUI } from 'dat.gui';
import {
  filterParallelEdges,
  startTimer,
  stopTimer,
  uploadOnDrop,
  loadZip,
  updateLabel
} from './utils';

const ogma = new Ogma({
  container: 'container-gpu',
  options: {
    backgroundColor: '#fefefe',
    // Disable text hover, and text overlap detection for this example
    texts: { preventOverlap: false },
    detect: { nodeTexts: false, edgeTexts: false },
    interactions: { drag: { enabled: false } }
  }
});

ogma.styles.addRule({
  nodeAttributes: {
    color: '#042B53',
    innerStroke: {
      width: 2
    },
    radius: 15
  },
  edgeAttributes: {
    color: '#ccc'
  }
});

ogma.events.on('addGraph', () => {
  document.querySelector<HTMLSpanElement>('#graph-info')!.innerHTML = `${
    ogma.getNodes().size
  } nodes, ${ogma.getEdges().size} edges`;
});

interface Options extends ForceLayoutOptions {
  gpu: true;
  dataset: string;
  run: () => Promise<void>;
  restoreSettings: () => void;
  randomize: () => void;
}

const defaultOptions: ForceLayoutOptions = {
  steps: 200,
  charge: 10,
  edgeStrength: 0.5,
  gravity: 0.01,
  elasticity: 0.9,
  duration: 0
};
const optionsForDataset: Record<string, ForceLayoutOptions> = {
  'files/eurosys.json': { ...defaultOptions, gravity: 0.005 },
  'files/arctic-cleaned-up.json': {
    ...defaultOptions,
    edgeStrength: 1,
    edgeLength: 0,
    gravity: 0.002
  },
  'files/yeast.json': { ...defaultOptions, gravity: 0.005 },
  'files/paris-software.json': {
    ...defaultOptions,
    charge: 20,
    gravity: 0.002
  },
  'files/relativity.json': { ...defaultOptions, gravity: 0.005 },
  'files/oregon.json': defaultOptions,
  'files/silk-road.json': defaultOptions,
  'files/small-clusters.mtx.gz': defaultOptions,
  'files/circuit_4.mtx.gz': defaultOptions,
  'files/ruwikt20160210_2017-01-08_noun.json.gz': defaultOptions,
  'files/hcircuit.mtx.gz': {
    ...defaultOptions,
    charge: 15,
    edgeStrength: 5,
    gravity: 0.005
  },
  'files/notre-dame.mtx.gz': defaultOptions
};

const spinner = document.querySelector<HTMLDivElement>('#spinner')!;
const setSpinner = (on: boolean) => {
  spinner.style.display = on ? 'block' : 'none';
};

// Options passed to the force layout algorithm
const options: Options = {
  ...defaultOptions,
  gpu: true,
  locate: true,
  dataset: 'files/eurosys.json',
  run: () => {
    updateLabel('layout...');
    setSpinner(true);
    startTimer();
    const datasetOptions = optionsForDataset[options.dataset];
    datasetOptions.steps = options.steps;
    datasetOptions.charge = options.charge;
    datasetOptions.edgeStrength = options.edgeStrength;
    datasetOptions.gravity = options.gravity;
    datasetOptions.elasticity = options.elasticity;
    datasetOptions.duration = options.duration;
    return ogma.layouts
      .force({
        gpu: true,
        locate: true,
        steps: options.steps,
        charge: options.charge,
        edgeStrength: options.edgeStrength,
        gravity: options.gravity,
        elasticity: options.elasticity,
        duration: options.duration
      })
      .then(() => {
        stopTimer();
        setSpinner(false);
      });
  },
  restoreSettings: () => {
    Object.assign(options, defaultOptions);
    console.log(gui.updateDisplay());
  },
  // @ts-expect-error debug tools are not typed
  randomize: () => ogma.debug.randomizeGraph()
};

const onChange = () => {
  setSpinner(true);
  loadGraph(options.dataset)
    .then(() => {
      Object.assign(options, optionsForDataset[options.dataset]);
      gui.updateDisplay();
    })
    .then(() => options.run())
    .catch(err => {
      updateLabel(err.message);
    });
};

const gui = new GUI({ name: 'GPU layout', width: 500 });
gui.width = 300;
gui
  .add(options, 'dataset', {
    '1285 nodes, 6462 edges': 'files/eurosys.json',
    '1715 nodes, 6228 edges': 'files/arctic-cleaned-up.json',
    '2361 nodes, 7182 edges': 'files/yeast.json',
    '3689 nodes, 3762 edges': 'files/paris-software.json',
    '5242 nodes, 14496 edges': 'files/relativity.json',
    '10981 nodes, 30855 edges': 'files/oregon.json',
    '26112 nodes, 90514 edges': 'files/silk-road.json',
    '32460 nodes, 147788 edges': 'files/small-clusters.mtx.gz',
    '80209 nodes, 307604 edges': 'files/circuit_4.mtx.gz',
    '91049 nodes, 124533 edges': 'files/ruwikt20160210_2017-01-08_noun.json.gz',
    '105767 nodes, 309362 edges': 'files/hcircuit.mtx.gz',
    '325729 nodes, 779327 edges': 'files/notre-dame.mtx.gz'
  })
  .name('Dataset')
  .onChange(onChange);
gui.add(options, 'steps', 0, 1000).step(1).name('Steps').onChange(options.run);
gui
  .add(options, 'charge', 0, 100)
  .step(0.05)
  .name('Charge')
  .onChange(options.run);
gui
  .add(options, 'edgeStrength')
  .min(0)
  .max(30)
  .step(0.5)
  .name('Edge strength')
  .onChange(options.run);
gui.add(options, 'gravity', 0, 0.1).step(0.001).onChange(options.run);
gui.add(options, 'elasticity', 0, 1).step(0.01).onChange(options.run);
gui.add(options, 'duration', 0, 3000).step(100).onChange(options.run);
gui.add(options, 'locate').onChange(options.run);
gui.add(options, 'restoreSettings').name('Restore defaults');
gui.add(options, 'randomize').name('Randomize positions');
gui.add(options, 'run').name('&#9658; Run layout');

function parseFile(
  extension: 'mtx' | 'gexf' | 'json' | string,
  content: string
) {
  if (extension === 'mtx') return Ogma.parse.mtx(content);
  else if (extension === 'gexf') return Ogma.parse.gexf(content);
  else if (extension === 'json') return Ogma.parse.json(content);
  else return Promise.reject('Unsupported file format');
}

function addNewGraph(graph: RawGraph<unknown, unknown>) {
  graph.edges = filterParallelEdges(graph.edges);
  graph.nodes.forEach(node => (node.attributes = {}));
  // this is an important trick to reduce the memory pressure
  // which can lead to fatal crashes on older hardware
  const batchSize = graph.nodes.length < 1e5 ? 5e3 : 1e4;
  return ogma.setGraph(graph, { batchSize });
}

function loadGraph(dataset: string) {
  updateLabel('Loading...');
  ogma.getContainer()?.classList.add('loading');
  setSpinner(true);

  let parse: (fileName: string) => Promise<RawGraph<unknown, unknown>> =
    dataset.endsWith('.mtx')
      ? Ogma.parse.mtxFromUrl
      : dataset.endsWith('.gexf')
        ? Ogma.parse.gexfFromUrl
        : Ogma.parse.jsonFromUrl;

  if (dataset.endsWith('.gz')) {
    parse = (fileName: string) =>
      loadZip(fileName)
        .then(str => {
          const originalFileName = fileName.replace('.gz', '');
          const extension = originalFileName.split('.').pop()!;
          return parseFile(extension, str);
        })
        .catch(err => Promise.reject(err));
  }

  return parse(dataset)
    .then(addNewGraph)
    .finally(() => {
      ogma.getContainer()?.classList.remove('loading');
      setSpinner(false);
    });
}

onChange();

// Drag-and-drop files
const backdrop = document.getElementById('backdrop')!;
ogma.events.on('drop', ({ domEvent }) => {
  backdrop.style.display = 'none';

  updateLabel('Loading...');
  setSpinner(true);
  uploadOnDrop(domEvent)
    .catch(() => {
      updateLabel('Failed to parse file');
    })
    .then(files => {
      if (!files || files.length === 0)
        return Promise.reject('Failed to parse file');
      // we can only handle one file at a time
      const { extension, content } = files[0];
      if (!content) Promise.reject('Failed to parse file');
      return parseFile(extension, content!);
    })
    .then(graph => {
      if (graph) return addNewGraph(graph);
    })
    .then(() => setSpinner(false))
    .then(() => options.run())
    .catch(err => {
      setSpinner(false);
      updateLabel(err);
    });
});

ogma.getContainer()?.addEventListener('dragover', evt => {
  evt.stopPropagation();
  backdrop.style.display = 'block';
});
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <script src="https://cdn.jsdelivr.net/npm/alea"></script>
    <script src="https://cdn.jsdelivr.net/npm/fflate@0.8.0/umd/index.min.js"></script>
    <link type="text/css" rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <div id="container-gpu" class="graph-container loading">
      <span class="timer"></span>
      <span class="info" id="graph-info"></span>
    </div>
    <div class="backdrop" id="backdrop"></div>
    <div class="spinner" id="spinner"></div>
    <div id="dataset"></div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
.graph-container {
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  width: 100%;
  position: absolute;
  margin: 0;
  overflow: hidden;
  display: flex;
  justify-content: center;
}
.graph-container > span {
  position: absolute;
  font-size: 2em;
  background-color: rgba(255, 255, 255, 0.8);
  font-family: monospace;
  border-radius: 5px;
  padding: 0.1em;
}
.info {
  position: absolute;
  color: #141229;
  font-size: 12px;
  font-family: monospace;
  padding: 5px;
  bottom: 10px;
}

#dataset {
  bottom: 30px;
  left: 50%;
  position: absolute;
}

#dataset > select {
  margin-left: -50%;
  font-size: 1.25em;
}

.loading,
.spinner {
  pointer-events: none;
  background-position: 50% 50%;
  background-repeat: no-repeat;
  background-image: url('data:image/gif;base64,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');
}

.spinner {
  z-index: 1000;
  top: 50%;
  left: 50%;
  position: absolute;
  width: 40px;
  height: 40px;
  background-color: #fff;
  border-radius: 10px;
  margin-left: -20px;
  margin-top: -20px;
  display: none;
}

.backdrop {
  z-index: 1001;
  top: 0;
  left: 0;
  position: absolute;
  width: 100%;
  height: 100%;
  background-color: rgba(255, 255, 255, 0.8);
  display: none;
  pointer-events: none;
}
ts
import { RawEdge } from '@linkurious/ogma';

const label = document.querySelector<HTMLSpanElement>('#container-gpu > span')!;
let duration = 0;
let running = true;

export const updateLabel = (message: string) => {
  label.innerText = message;
};

export const updateTimeLabel = (time: number) =>
  updateLabel(humanizeDuration(time));

let timer = 0;
let t0 = 0;
export const startTimer = () => {
  running = true;

  t0 = performance.now();
  const update = () => {
    timer = requestAnimationFrame(update);
    const t = performance.now() - t0;
    if (running) updateTimeLabel(t);
  };
  update();
};

export const stopTimer = () => {
  if (running) {
    running = false;
    duration = performance.now() - t0;
  }
};

export function filterParallelEdges(edges: RawEdge[]) {
  const edgeset = new Set();
  return edges.filter(edge => {
    const st = `${edge.source}-${edge.target}`;
    const ts = `${edge.target}-${edge.source}`;
    if (edgeset.has(st) || edgeset.has(ts)) return false;
    edgeset.add(st);
    edgeset.add(ts);
    return true;
  });
}

/**
 * Convert a duration in milliseconds to a string easily readable by humans.
 * @param ms The duration in milliseconds.
 */
export const humanizeDuration = (ms: number): string => {
  // Define the various time units in milliseconds.
  const msInASecond = 1000;
  const msInAMinute = 60 * msInASecond;
  const msInAnHour = 60 * msInAMinute;
  const msInADay = 24 * msInAnHour;

  let remainingMs = ms;

  // Calculate the number of days, hours, minutes, seconds, and milliseconds.
  const days = Math.floor(remainingMs / msInADay);
  remainingMs %= msInADay;

  const hours = Math.floor(remainingMs / msInAnHour);
  remainingMs %= msInAnHour;

  const minutes = Math.floor(remainingMs / msInAMinute);
  remainingMs %= msInAMinute;

  const seconds = Math.floor(remainingMs / msInASecond);
  remainingMs %= msInASecond;

  // Construct the humanized duration string.
  const parts: string[] = [];

  if (days) parts.push(`${days}d`);
  if (hours) parts.push(`${hours}h`);
  if (minutes) parts.push(`${minutes}min`);
  if (seconds || (!days && !hours && !minutes && !remainingMs))
    parts.push(`${seconds}s`);
  if (remainingMs) parts.push(`${remainingMs.toFixed()} ms`);

  return parts.join(' ');
};

export function uploadOnDrop(domEvent: DragEvent) {
  let files: File[] = [];
  if (domEvent.dataTransfer) {
    if (domEvent.dataTransfer.items) {
      files = [...domEvent.dataTransfer.items]
        .map(item => (item.kind === 'file' ? item.getAsFile() : null))
        .filter(Boolean) as File[];
    } else {
      files = [...domEvent.dataTransfer.files];
    }
  }

  interface UploadedFile {
    name: string;
    extension: 'gexf' | 'mtx' | 'json' | string;
    content: string | null;
  }

  return Promise.all(
    files.map(file => {
      const match = file.name.match(/\.(gexf|mtx|json)(.gz)?$/);
      if (!match) return Promise.resolve(null);

      const reader = new FileReader();
      const isArchive = file.name.endsWith('.zip') || file.name.endsWith('.gz');
      const extension = match[1];
      const promise = new Promise<UploadedFile>(resolve => {
        reader.onload = function (event) {
          if (isArchive)
            return uncompress(event.target!.result as ArrayBuffer).then(str => {
              resolve({
                name: file.name,
                extension,
                content: str
              });
            });
          resolve({
            name: file.name,
            extension,
            content: event.target!.result as string
          });
        };
      });
      if (isArchive) reader.readAsArrayBuffer(file);
      else reader.readAsText(file, 'utf-8');

      return promise;
    })
  ).then(files => files.filter(Boolean) as UploadedFile[]);
}

function uncompress(buffer: ArrayBuffer): Promise<string> {
  return new Promise((resolve, reject) => {
    // @ts-expect-error missing types for fflate
    fflate.gunzip(
      new Uint8Array(buffer),
      (err: unknown, unzipped: Uint8Array) => {
        if (err) return reject(err);
        // @ts-expect-error missing types for fflate
        const str = fflate.strFromU8(unzipped) as string;
        resolve(str);
      }
    );
  });
}

export function loadZip(zip: string): Promise<string> {
  return fetch(zip)
    .then(res => res.arrayBuffer())
    .then(buffer => uncompress(buffer));
}