Skip to content
  1. Examples

Visual grouping

This example shows how to use node grouping to group nodes and show their content. Double click on a group to open/close it.

ts
import Ogma, { NodeGrouping } from '@linkurious/ogma';

const FLAGS = {
  France: 'flags/fr.svg',
  Japan: 'flags/jp.svg',
  USA: 'flags/us.svg',
  Brazil: 'flags/br.svg'
};

type Country = keyof typeof FLAGS;

interface NodeData {
  location: Country;
  open: boolean;
}

const backgroundColor = '#F5F6F6';

const ogma = new Ogma<NodeData>({
  container: 'graph-container',
  options: {
    backgroundColor
  }
});

// animation duration for the layout
const duration = 250;

Ogma.parse
  .jsonFromUrl<NodeData>('countries.json')
  .then(graph => ogma.addGraph(graph))
  .then(() => ogma.layouts.force({ locate: true, duration }));

const selectedColor = '#04ddcb';
ogma.styles.setSelectedNodeAttributes({
  outerStroke: { color: selectedColor }
});
ogma.styles.setHoveredNodeAttributes({
  outerStroke: { color: selectedColor }
});
ogma.styles.setHoveredEdgeAttributes({
  color: selectedColor
});
ogma.styles.setSelectedEdgeAttributes({
  color: selectedColor
});
ogma.styles.setHoveredNodeAttributes({ outline: undefined });
ogma.styles.setSelectedNodeAttributes({ outline: undefined });

ogma.styles.addNodeRule({
  innerStroke: { color: '#555', width: 1 },
  radius: 10,
  image: {
    url: node => FLAGS[node.getData('location')],
    minVisibleSize: 0
  },
  opacity: node => (node.getData('open') ? 0.32 : undefined),
  badges: {
    bottomRight: { stroke: { color: '#999' } }
  }
});

const byCountry = ogma.transformations.addNodeGrouping({
  groupIdFunction: node => node.getData('location'),
  nodeGenerator: (nodes, country) => {
    const isVisible = country !== 'Japan';
    return {
      id: 'special group ' + country,
      data: {
        country: country,
        open: isVisible
      },
      attributes: {
        text: country,
        // fixed radius for the nodes with hidden contents
        radius: 20,
        image: FLAGS[country as Country]
      }
    };
  },
  showContents: node => node.getData('open'),
  onGroupUpdate: (metaNode, subNodes, visible) => {
    const groupId = metaNode.getId();
    if (!layoutPerGroup.has(groupId)) {
      layoutPerGroup.set(groupId, defaultLayout);
    }
    if (!visible) return Promise.resolve();
    if (layoutPerGroup.get(groupId) === 'hierarchical') {
      return ogma.layouts.hierarchical({
        nodes: subNodes,
        duration: 0
      }) as unknown as Promise<void>;
    }
    return ogma.layouts.force({
      nodes: subNodes,
      duration: 0
    }) as unknown as Promise<void>;
  },
  duration: 300,
  enabled: false
});
const layoutPerGroup = new Map();
const defaultLayout = 'force';
const customGroup = ogma.transformations.addNodeGrouping({
  groupIdFunction: node => {
    const id = node.getData('country');
    if (id === 'France' || id === 'Brazil') return 'Level 2';
    return undefined;
  },
  nodeGenerator: (nodes, country) => ({
    id: 'special group ' + country,
    data: {
      country: country,
      open: true
    },
    attributes: {
      color: backgroundColor,
      text: country,
      opacity: 0.32
    }
  }),
  onGroupUpdate: (metaNode, subNodes, visible) => {
    return ogma.layouts.force({
      nodes: subNodes,
      duration: 0
    }) as unknown as Promise<void>;
  },
  showContents: node => node.getData('open'),
  enabled: false,
  duration: 300
});
// UI buttons
const buttonFranceBrazilOnly = document.getElementById(
  'custom-group-btn'
) as HTMLButtonElement;
const buttonSwitchLayout = document.getElementById(
  'switch-layout'
)! as HTMLButtonElement;
const buttonGroupAll = document.getElementById('group-btn')!;

buttonFranceBrazilOnly.disabled = true;

document.getElementById('group-btn')!.addEventListener('click', () => {
  // Toggle the grouping transformation
  byCountry.toggle().then(() => {
    const buttonText = byCountry!.isEnabled()
      ? 'Ungroup by country'
      : 'Group by country';
    buttonGroupAll.textContent = buttonText;
    buttonFranceBrazilOnly.disabled = !byCountry!.isEnabled();
    // return ogma.layouts.force({ duration });
  });
});

document.getElementById('custom-group-btn')!.addEventListener('click', () => {
  // Toggle the grouping
  customGroup.toggle().then(() => {
    const buttonText = customGroup!.isEnabled()
      ? 'Ungroup France & Brazil'
      : 'Group France & Brazil';

    buttonFranceBrazilOnly.textContent = buttonText;
  });
});

buttonSwitchLayout.disabled = true;
buttonSwitchLayout.addEventListener('click', () => {
  const groups = byCountry.getContext().metaNodes;
  // setup the layout for each group
  groups.getId().forEach(id => {
    layoutPerGroup.set(id, 'hierarchical');
  });
  // tell transformations to rerun the group update callback
  ogma.transformations.triggerGroupUpdated(groups);
  // trigger the refresh
  byCountry.refresh();
});
ogma.events
  .on('click', evt => {
    const target = evt.target;
    if (target && target.isNode && target.isVirtual()) {
      console.log('sub nodes', target.getSubNodes()!.getId());
    }
  })
  .on('doubleclick', evt => {
    const target = evt.target;
    if (target && target.isNode && target.isVirtual()) {
      target.setData('open', !target.getData('open'));
    }
  });
ogma.transformations.onGroupsUpdated(() => {
  return ogma.layouts.force({ duration });
});
ogma.events.on('transformationDisabled', () => {
  buttonSwitchLayout.disabled = !ogma.transformations
    .getList()
    .some(e => e.isEnabled());
  return ogma.layouts.force({ duration });
});
ogma.events.on('transformationEnabled', () => {
  buttonSwitchLayout.disabled = false;
});
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 class="panel">
      <div class="section">
        <button id="group-btn">Group by country</button>
      </div>
      <div class="section">
        <button id="custom-group-btn">Group France & Brazil</button>
      </div>
      <div class="section">
        <button id="switch-layout">Run Hierarchical layout</button>
      </div>
    </div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
html,
body {
  font-family: 'Inter', sans-serif;
}

:root {
  --base-color: #4999f7;
  --active-color: var(--base-color);
  --gray: #d9d9d9;
  --lighter-gray: #f4f4f4;
  --light-gray: #e6e6e6;
  --inactive-color: #cee5ff;
  --group-color: #525fe1;
  --group-inactive-color: #c2c8ff;
  --selection-color: #04ddcb;
  --darker-gray: #b6b6b6;
  --dark-gray: #555;
  --dark-color: #3a3535;
  --edge-color: var(--dark-color);
  --border-radius: 5px;
  --button-border-radius: var(--border-radius);
  --edge-inactive-color: var(--light-gray);
  --button-background-color: #ffffff;
  --shadow-color: rgba(0, 0, 0, 0.25);
  --shadow-hover-color: rgba(0, 0, 0, 0.5);
  --button-shadow: 0 0 4px var(--shadow-color);
  --button-shadow-hover: 0 0 4px var(--shadow-hover-color);
  --button-icon-color: #000000;
  --button-icon-hover-color: var(--active-color);
}

#graph-container {
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  position: absolute;
  margin: 0;
  overflow: hidden;
}

.ui {
  position: absolute;
  display: flex;
  flex-direction: column;
  gap: 0.5em;
}

#custom-group-btn {
  top: 40px;
}

.panel {
  position: absolute;
  top: 20px;
  left: 20px;
  background: var(--button-background-color);
  border-radius: var(--button-border-radius);
  box-shadow: var(--button-shadow);
  padding: 10px;
}

.panel h2 {
  text-transform: uppercase;
  font-weight: 400;
  font-size: 14px;
  margin: 0;
}

.panel .section {
  margin-top: 1px;
  padding: 5px 10px;
  text-align: center;
}

.panel .section button {
  background: var(--button-background-color);
  border: none;
  border-radius: var(--button-border-radius);
  border-color: var(--shadow-color);
  padding: 5px 10px;
  cursor: pointer;
  width: 100%;
  color: var(--dark-gray);
  border: 1px solid var(--light-gray);
}

.panel .section button:hover {
  background: var(--lighter-gray);
  border: 1px solid var(--darker-gray);
}

.panel .section button[disabled] {
  color: var(--light-gray);
  border: 1px solid var(--light-gray);
  background-color: var(--lighter-gray);
}
json
{
  "nodes": [
    {
      "id": 0,
      "attributes": {
        "x": -58.08613537669512,
        "y": -42.17457357539305,
        "color": "grey",
        "radius": 5,
        "shape": "circle",
        "text": null
      },
      "data": {
        "location": "France"
      }
    },
    {
      "id": 1,
      "attributes": {
        "x": -13.713836084599476,
        "y": -102.43032443249912,
        "color": "grey",
        "radius": 5,
        "shape": "circle",
        "text": null
      },
      "data": {
        "location": "France"
      }
    },
    {
      "id": 2,
      "attributes": {
        "x": 78.18022166331382,
        "y": -78.83085224468655,
        "color": "grey",
        "radius": 5,
        "shape": "circle",
        "text": null
      },
      "data": {
        "location": "France"
      }
    },
    {
      "id": 3,
      "attributes": {
        "x": -80.04579597181308,
        "y": -92.80349816780331,
        "color": "grey",
        "radius": 5,
        "shape": "circle",
        "text": null
      },
      "data": {
        "location": "France"
      }
    },
    {
      "id": 4,
      "attributes": {
        "x": 26.866002889626042,
        "y": -142.31666618081036,
        "color": "grey",
        "radius": 5,
        "shape": "circle",
        "text": null
      },
      "data": {
        "location": "France"
      }
    },
    {
      "id": 10,
      "attributes": {
        "x": 86.6948842918209,
        "y": 7.607028981568861,
        "color": "grey",
        "radius": 5,
        "shape": "circle",
        "text": null
      },
      "data": {
        "location": "USA"
      }
    },
    {
      "id": 11,
      "attributes": {
        "x": 55.14918974638689,
        "y": 77.21612384739898,
        "color": "grey",
        "radius": 5,
        "shape": "circle",
        "text": null
      },
      "data": {
        "location": "USA"
      }
    },
    {
      "id": 12,
      "attributes": {
        "x": 15.939748427451477,
        "y": 121.42557416014343,
        "color": "grey",
        "radius": 5,
        "shape": "circle",
        "text": null
      },
      "data": {
        "location": "USA"
      }
    },
    {
      "id": 13,
      "attributes": {
        "x": 100.35233210155475,
        "y": 104.92327128191509,
        "color": "grey",
        "radius": 5,
        "shape": "circle",
        "text": null
      },
      "data": {
        "location": "USA"
      }
    },
    {
      "id": 14,
      "attributes": {
        "x": 119.38828745374323,
        "y": 43.29808488050811,
        "color": "grey",
        "radius": 5,
        "shape": "circle",
        "text": null
      },
      "data": {
        "location": "USA"
      }
    },
    {
      "id": 20,
      "attributes": {
        "x": 16.877716464648596,
        "y": 29.54224307483783,
        "color": "grey",
        "radius": 5,
        "shape": "circle",
        "text": null
      },
      "data": {
        "location": "Japan"
      }
    },
    {
      "id": 21,
      "attributes": {
        "x": 43.739538316030774,
        "y": -25.700465065025504,
        "color": "grey",
        "radius": 5,
        "shape": "circle",
        "text": null
      },
      "data": {
        "location": "Japan"
      }
    },
    {
      "id": 22,
      "attributes": {
        "x": -9.497046215814374,
        "y": 4.685025820713196,
        "color": "grey",
        "radius": 5,
        "shape": "circle",
        "text": null
      },
      "data": {
        "location": "Japan"
      }
    },
    {
      "id": 23,
      "attributes": {
        "x": -12.597386365123791,
        "y": -39.355838537079244,
        "color": "grey",
        "radius": 5,
        "shape": "circle",
        "text": null
      },
      "data": {
        "location": "Japan"
      }
    },
    {
      "id": 24,
      "attributes": {
        "x": 123.01811984577611,
        "y": -28.078297607827192,
        "color": "grey",
        "radius": 5,
        "shape": "circle",
        "text": null
      },
      "data": {
        "location": "Japan"
      }
    },
    {
      "id": 30,
      "attributes": {
        "x": -122.81169804344762,
        "y": 40.91164279174578,
        "color": "grey",
        "radius": 5,
        "shape": "circle",
        "text": null
      },
      "data": {
        "location": "Brazil"
      }
    },
    {
      "id": 31,
      "attributes": {
        "x": -71.9453110279818,
        "y": 29.53969162232996,
        "color": "grey",
        "radius": 5,
        "shape": "circle",
        "text": null
      },
      "data": {
        "location": "Brazil"
      }
    },
    {
      "id": 32,
      "attributes": {
        "x": -106.86025795760797,
        "y": -30.401856621318228,
        "color": "grey",
        "radius": 5,
        "shape": "circle",
        "text": null
      },
      "data": {
        "location": "Brazil"
      }
    },
    {
      "id": 33,
      "attributes": {
        "x": -18.280941662091053,
        "y": 78.21279781316291,
        "color": "grey",
        "radius": 5,
        "shape": "circle",
        "text": null
      },
     

...