Skip to content
  1. Examples

iPhone parts origin

This example shows how to use neighbor generation transformation and node collapsing to enhance the visualization of a graph. It allows to show the manufacturing countries and to collapse the factories nodes.

ts
import Ogma, { InputTarget, NodeId } from '@linkurious/ogma';
import { INodeData, IEdgeData } from './types';
import { ActionsControl } from './actions-control';
import { addStyles } from './styles';
import { ZoomControl } from './zoom-control';
import { createGroup, createCountries } from './groups';
import { DARK_COLOR, BACKGROUND_COLOR } from './constants';
import { ICONS } from './icons';
import { runLayout } from './layout';
// Create instance of Ogma
const ogma = new Ogma<INodeData, IEdgeData>({
  container: 'graph-container',
  options: {
    backgroundColor: BACKGROUND_COLOR,
    detect: { nodeTexts: false }
  }
});

new ZoomControl(ogma);
createCountries(ogma);
createGroup(ogma);
addStyles(ogma);

new ActionsControl(
  ogma,
  document.getElementById('actions-control') as HTMLElement
);

ogma.events.on('nodesSelected', ({ nodes }) => {
  nodes.addClass('selected');
  nodes.getId().forEach(id => {
    if (!highlightedNodes.has(id)) highlightedNodes.add(id);
  });
  onHighlightChange();
});

ogma.events.on('nodesUnselected', ({ nodes }) => {
  nodes.removeClass('selected');
  nodes.getId().forEach(id => {
    if (highlightedNodes.has(id)) highlightedNodes.delete(id);
  });
  onHighlightChange();
});

ogma.events.on('edgesSelected', ({ edges }) => {
  const sources = edges.getSource();
  const targets = edges.getTarget();
  sources.addClass('selected');
  targets.addClass('selected');
  sources.getId().forEach(id => {
    if (!highlightedNodes.has(id)) highlightedNodes.add(id);
  });
  targets.getId().forEach(id => {
    if (!highlightedNodes.has(id)) highlightedNodes.add(id);
  });
  onHighlightChange();
});

ogma.events.on('edgesUnselected', ({ edges }) => {
  const sources = edges.getSource();
  const targets = edges.getTarget();
  sources.removeClass('selected');
  targets.removeClass('selected');
  sources.getId().forEach(id => {
    if (highlightedNodes.has(id)) highlightedNodes.delete(id);
  });
  targets.getId().forEach(id => {
    if (highlightedNodes.has(id)) highlightedNodes.delete(id);
  });
  onHighlightChange();
});

function throttle(func: () => void, limit: number): () => void {
  let lastFunc: number;
  let lastRan: number;

  return function () {
    if (!lastRan) {
      func();
      lastRan = Date.now();
    } else {
      clearTimeout(lastFunc);
      lastFunc = window.setTimeout(
        function () {
          if (Date.now() - lastRan >= limit) {
            func();
            lastRan = Date.now();
          }
        },
        limit - (Date.now() - lastRan)
      );
    }
  };
}

const highlightedNodes = new Set<NodeId>();
const onHighlightChange = throttle(() => {
  if (highlightedNodes.size === 0) {
    ogma.getNodes().removeClass('dimmed', { duration: 100 });
    ogma.getEdges().removeClass('dimmed', { duration: 100 });
    ogma.getNodes().removeClass('highlighted');
    ogma.getEdges().removeClass('highlighted');
  } else {
    const hiNodes = ogma
      .getNodes()
      .filter(node => highlightedNodes.has(node.getId()));
    const adjacentElements = hiNodes.getAdjacentElements();
    Promise.all([
      adjacentElements.nodes.addClass('highlighted'),
      adjacentElements.edges.addClass('highlighted'),
      adjacentElements.nodes.removeClass('dimmed'),
      adjacentElements.edges.removeClass('dimmed')
    ]).then(() => {
      ogma.getNodes().subtract(adjacentElements.nodes).addClass('dimmed');
      ogma.getEdges().subtract(adjacentElements.edges).addClass('dimmed');
      hiNodes.forEach(highlightedNode => {
        highlightedNode.addClass('highlighted');
        highlightedNode.removeClass('dimmed');
      });
    });
  }
}, 100);

let hoveredNode: InputTarget<INodeData, IEdgeData> | null = null;
let frame: number = 0;
ogma.events.on('mouseout', () => {
  cancelAnimationFrame(frame);
  frame = requestAnimationFrame(() => {
    const id = hoveredNode?.getId();
    if (
      id !== undefined &&
      highlightedNodes.has(id) &&
      hoveredNode?.hasClass('selected') === false
    ) {
      highlightedNodes.delete(id);
      onHighlightChange();
    }
    hoveredNode = null;
  });
});

ogma.events.on('mouseover', ({ target }) => {
  requestAnimationFrame(() => {
    if (!target || !target.isNode) return;
    hoveredNode = target;

    const id = target.getId();
    if (highlightedNodes.has(id)) return;
    highlightedNodes.add(id);
    onHighlightChange();
  });
});

// Load the graph and apply a layout
const graph = await Ogma.parse.jsonFromUrl<INodeData, IEdgeData>(
  'iphone_parts.json'
);
await ogma.setGraph(graph);
await ogma.getNode(0)?.setAttributes({
  color: 'white',
  radius: 10,
  outerStroke: {
    color: DARK_COLOR,
    width: 2
  },
  icon: {
    font: 'Font Awesome 6 Free',
    color: DARK_COLOR,
    content: ICONS.star,
    minVisibleSize: 2,
    scale: 0.75
  }
});
await runLayout(ogma);
html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <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"
    />
    <link type="text/css" rel="stylesheet" href="style.css" />
    <link
      type="text/css"
      rel="stylesheet"
      href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.11.2/css/solid.min.css"
    />
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.4.2/css/solid.css"
    />
    <link
      href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.4.2/css/fontawesome.min.css"
      rel="stylesheet"
    />
  </head>

  <body>
    <div id="graph-container"></div>
    <div class="icons-vertical-container" id="actions"></div>
    <div class="panel show" id="filters-panel">
      <span class="close">&times;</span>
      <h2>Filters</h2>
      <section id="grouping">
        <h3>Grouping</h3>
        <div class="section-body toggle-section">
          <span class="toggle-label toggle-label-off">OFF</span>
          <label class="switch">
            <input type="checkbox" id="grouping-toggle" />
            <span class="slider round"></span>
          </label>
          <span class="toggle-label toggle-label-on">ON</span>
        </div>
      </section>
      <section id="icons">
        <h3>Icons</h3>
        <div class="section-body toggle-section">
          <span class="toggle-label toggle-label-off">OFF</span>
          <label class="switch">
            <input type="checkbox" checked id="icons-toggle" />
            <span class="slider round"></span>
          </label>
          <span class="toggle-label toggle-label-on">ON</span>
        </div>
      </section>
      <section id="explore">
        <h3>Explore</h3>
        <div class="section-body toggle-section">
          <span class="toggle-label toggle-label-off">Manufacturers</span>
          <label class="switch">
            <input type="checkbox" id="filter-toggle" />
            <span class="slider round"></span>
          </label>
          <span class="toggle-label toggle-label-on">Countries</span>
        </div>
      </section>
    </div>
    <span class="fa-solid fa-shield-halved" id="icon-placeholder"></span>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
: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;
  --country-color: #044b87;
  --country-inactive-color: #bccddb;
  --dark-color: #3a3535;
  --edge-color: var(--dark-color);
  --border-radius: 3px;
  --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);
}

html,
body {
  font-family: 'IBM Plex Sans', sans-serif;
}

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

#neighbor-generation-toggle-button {
  left: 10px;
  top: 10px;
}

#node-collapsing-toggle-button {
  left: 10px;
  top: 40px;
}

#layout-button {
  right: 10px;
  top: 10px;
}

.ogma-zoom-control {
  position: absolute;
  right: 20px;
  bottom: 20px;
  display: block;
  width: 25px;
  height: 79px;
  z-index: 1000;
}

.ogma-control button {
  border: none;
  background: var(--button-background-color);
  box-shadow: var(--button-shadow);
  border-radius: var(--button-border-radius);
  outline: none;
  cursor: pointer;
  background-repeat: no-repeat;
  background-position: center center;
  color: transparent;
}

.ogma-zoom-control button {
  width: 25px;
  height: 25px;
  margin-bottom: 2px;
}

.ogma-control button:hover,
.ogma-control button:focus {
  box-shadow: var(--button-shadow-hover);
}

.ogma-control button:active {
  outline: 1px solid var(--active-color);
}

.ogma-zoom-control .zoom-out {
  background-image: url('img/iphone-parts/icon-minus.svg');
}

.ogma-zoom-control .zoom-in {
  background-image: url('img/iphone-parts/icon-plus.svg');
}

.ogma-zoom-control .zoom-reset {
  background-image: url('img/iphone-parts/icon-fit.svg');
}

.ogma-actions-control {
  position: absolute;
  left: 20px;
  top: 20px;
  display: block;
  width: 35px;
  height: 111px;
  z-index: 1000;
}

.ogma-actions-control button {
  width: 35px;
  height: 35px;
  margin-bottom: 5px;
}

.ogma-actions-control .button-filter {
  background-image: url('img/iphone-parts/icon-settings.svg');
}

.ogma-actions-control .button-info {
  background-image: url('img/iphone-parts/icon-info.svg');
}

.ogma-actions-control .button-undo {
  background-image: url('img/iphone-parts/icon-undo.svg');
}

.panel {
  position: absolute;
  top: 20px;
  left: 20px;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.menu-button {
  background: var(--button-background-color);
  border-radius: var(--button-border-radius);
  box-shadow: var(--button-shadow);
  outline: none;
  padding: 10px;
  border-width: 0px;
  color: var(--button-icon-color);
  cursor: pointer;
}

.menu-button:hover .icon,
.menu-button:active .icon {
  color: var(--button-icon-hover-color);
}

.menu-button .icon {
  width: 18px;
  height: 18px;
  color: var(--dark-color);
}

.menu-button:hover {
  box-shadow: var(--button-shadow-hover);
}

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

.panel .close {
  position: absolute;
  right: 10px;
  top: 5px;
  cursor: pointer;
  font-weight: 100;
}

.panel.show {
  display: block;
}

.panel .close:hover {
  color: var(--active-color);
}

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

.panel section {
  margin-top: 15px;
  min-width: 100px;
}

.panel section h3 {
  font-size: 10px;
  font-weight: 400;
  text-transform: uppercase;
  border-radius: var(--border-radius) var(--border-radius) 0 0;
  margin-bottom: 1px;
}

.panel section h3,
.panel section .section-body {
  background: var(--lighter-gray);
  padding: 5px 10px;
}

.panel section .section-body {
  background: var(--lighter-gray);
  border-radius: 0 0 var(--border-radius) var(--border-radius);
}

.toggle-section {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 5px 10px;
  cursor: pointer;
}

.toggle-section .toggle-label {
  font-size: 12px;
  font-weight: 100;
  text-align: center;
}

.toggle-section .toggle-label-off {
  padding-right: 10px;
}

.toggle-section .toggle-label-on {
  padding-left: 10px;
}

.toggle-section:has(.switch input:disabled) {
  cursor: wait;
}
/* .switch>input:disabled ~ .toggle-label{
} */

.switch {
  position: relative;
  display: inline-block;
  width: 30px;
  height: 17px;
}

.switch input {
  opacity: 0;
  width: 0;
  height: 0;
}

.slider {
  position: absolute;
  cursor: pointer;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: var(--gray);
  transition: 0.4s;
}

.slider:before {
  position: absolute;
  content: '';
  height: 15px;
  width: 15px;
  left: 1px;
  bottom: 1px;
  background-color: var(--dark-color);
  transition: 0.4s;
}

input:checked + .slider {
  background-color: var(--active-color);
}

input:focus + .slider {
  box-shadow: 0 0 0 1px var(--active-color);
  outline: none;
}

input:checked + .slider:before {
  transform: translateX(13px);
  background-color: #ffffff;
}

.slider.round {
  border-radius: 34px;
}

.slider.round:before {
  border-radius: 50%;
}
ts
import Ogma from '@linkurious/ogma';
import { SettingsPanel } from './settings';
import { stateStack } from './states';

const groupingButton = document.querySelector(
  '#grouping input'
) as HTMLButtonElement;
const exploreButton = document.querySelector(
  '#explore input'
) as HTMLButtonElement;
const iconsButton = document.querySelector('#icons input') as HTMLButtonElement;

export class ActionsControl {
  private filtersPanel!: SettingsPanel;

  constructor(
    private ogma: Ogma,
    private container: HTMLElement
  ) {
    this.createUI();
  }

  private createUI() {
    this.container = document.createElement('div');
    this.container.classList.add('ogma-control', 'ogma-actions-control');

    // create buttons: filters, info, undo-redo
    const filter = document.createElement('button');
    filter.classList.add('button-filter');
    filter.textContent = 'Filter';
    this.initFiltersPanel();
    filter.addEventListener('click', this.onFilterClick);

    const undo = document.createElement('button');
    undo.classList.add('button-undo');
    undo.textContent = 'Undo';
    undo.addEventListener('click', this.onUndoClick);

    this.container.appendChild(filter);
    this.container.appendChild(undo);

    this.ogma
      .getContainer()
      ?.insertAdjacentElement('afterbegin', this.container);
  }

  private initFiltersPanel() {
    this.filtersPanel = new SettingsPanel(
      this.ogma,
      document.getElementById('filters-panel') as HTMLDivElement
    );
    this.filtersPanel.initialize();
  }

  private onFilterClick = () => {
    this.filtersPanel.toggle();
  };

  private onUndoClick = () => {
    const change = stateStack.pop();
    if (!change) return;

    Object.keys(change).forEach(action => {
      if (action === 'grouping') this.filtersPanel.toggleGrouping();
      if (action === 'icons') this.filtersPanel.toggleIcons();
      if (action === 'explore') this.filtersPanel.toggleExplore();
    });
  };

  destroy() {
    this.ogma.getContainer()?.querySelector('.ogma-actions-control')?.remove();
  }
}
ts
export const BASE_COLOR = '#4999F7';
export const INACTIVE_COLOR = '#CEE5FF';
export const GROUP_COLOR = '#525FE1';
export const GROUP_INACTIVE_COLOR = '#C2C8FF';
export const SELECTION_COLOR = '#04DDCB';
export const COUNTRY_COLOR = '#044B87';
export const COUNTRY_INACTIVE_COLOR = '#BCCDDB';
export const DARK_COLOR = '#3A3535';
export const EDGE_COLOR = DARK_COLOR;
export const EDGE_INACTIVE_COLOR = '#E6E6E6';
export const GREY = '#808080';
export const GROUP_RADIUS = 10;
export const BACKGROUND_COLOR = '#F5F6F6';
export const FONT = 'IBM Plex Sans';

export const ANIMATION_DURATION = 300;
ts
import Ogma from '@linkurious/ogma';
import { IEdgeData, INodeData } from './types';

const groups = {
  'audio-video': new Set(['Audio Chipset', 'Codec', 'Camera', 'Display']),
  core: new Set([
    'Baseband processor',
    'Chipset',
    'Controller chip',
    'DRAM',
    'Flash memory',
    'Processor'
  ]),
  sensors: new Set([
    'Accelerometer',
    'eCompass',
    'Gyroscope',
    'Mixed-signal chips'
  ]),
  touch: new Set([
    'Fingerprint sensor authentication',
    'Touch ID sensor',
    'Touchscreen controller'
  ]),
  wireless: new Set([
    'Amplification modules',
    'Radio frequency modules',
    'Transmitter'
  ]),
  misc: new Set([
    'Battery',
    'Inductor coils',
    'Main chassi',
    'Plastic parts',
    'Screen glass',
    'Semiconductors'
  ])
};
const partTypeToGroup = Object.entries(groups).reduce((acc, [group, parts]) => {
  parts.forEach(part => acc.set(part, group));
  return acc;
}, new Map<string, string>());

export function createGroup(ogma: Ogma<INodeData, IEdgeData>) {
  return ogma.transformations.addNodeGrouping({
    selector: node => node.getData('type') === 'part',
    groupIdFunction: node => partTypeToGroup.get(node.getData('part_type')!),
    nodeGenerator: (nodes, id) => {
      return {
        id,
        data: {
          countries: nodes.getData('country').join('-'),
          type: 'part-group',
          name: id
        }
      };
    },
    enabled: false
  });
}

export function createCountries(ogma: Ogma<INodeData, IEdgeData>) {
  return ogma.transformations.addNeighborGeneration({
    selector: node => node.getData('type') === 'manufacturer',
    neighborIdFunction: node => node.getData('country') || null,
    nodeGenerator: (id, nodes) => ({
      data: {
        type: 'country',
        iso: id,
        nb_parts_produced: nodes.size
      }
    }),
    edgeGenerator: (source, target) => {
      return {
        source: source.getId(),
        target: target.getId(),
        data: { type: 'produced_in' }
      };
    },
    enabled: false
  });
}
ts
// see https://doc.linkurious.com/ogma/latest/examples/node-icon.html for more info
const placeholder = document.getElementById('icon-placeholder')!;
placeholder.style.visibility = 'hidden';

const getIconCode = (className: string) => {
  placeholder.className = className;
  const code = getComputedStyle(placeholder, ':before').content;
  return code[1];
};
export const ICONS = {
  star: getIconCode('fa-solid fa-mobile'),
  gear: getIconCode('fa-solid fa-gear'),
  building: getIconCode('fa-solid fa-building')
};

export const typeToIcon = {
  manufacturer: ICONS.building,
  device: ICONS.star,
  part: ICONS.gear,
  'part-group': ICONS.gear,
  country: ''
};
json
{
  "nodes": [
    {
      "id": 0,
      "data": {
        "type": "device",
        "name": "iPhone"
      }
    },
    {
      "data": {
        "type": "part",
        "part_type": "Accelerometer"
      },
      "id": "p0"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Audio Chipset"
      },
      "id": "p1"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Codec"
      },
      "id": "p2"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Baseband processor"
      },
      "id": "p3"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Battery"
      },
      "id": "p4"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Controller chip"
      },
      "id": "p5"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Camera"
      },
      "id": "p6"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Display"
      },
      "id": "p7"
    },
    {
      "data": {
        "type": "part",
        "part_type": "DRAM"
      },
      "id": "p8"
    },
    {
      "data": {
        "type": "part",
        "part_type": "eCompass"
      },
      "id": "p9"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Fingerprint sensor authentication"
      },
      "id": "p10"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Flash memory"
      },
      "id": "p11"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Gyroscope"
      },
      "id": "p12"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Inductor coils"
      },
      "id": "p13"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Main chassi"
      },
      "id": "p14"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Mixed-signal chips"
      },
      "id": "p15"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Plastic parts"
      },
      "id": "p16"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Radio frequency modules"
      },
      "id": "p17"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Screen glass"
      },
      "id": "p18"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Semiconductors"
      },
      "id": "p19"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Touch ID sensor"
      },
      "id": "p20"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Touchscreen controller"
      },
      "id": "p21"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Transmitter"
      },
      "id": "p22"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Amplification modules"
      },
      "id": "p23"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Chipset"
      },
      "id": "p24"
    },
    {
      "data": {
        "type": "part",
        "part_type": "Processor"
      },
      "id": "p25"
    },
    {
      "id": "m0",
      "data": {
        "type": "manufacturer",
        "name": "Bosch",
        "country": "de"
      }
    },
    {
      "id": "m1",
      "data": {
        "type": "manufacturer",
        "name": "Invensense",
        "country": "us"
      }
    },
    {
      "id": "m2",
      "data": {
        "type": "manufacturer",
        "name": "Cirrus Logic",
        "country": "us"
      }
    },
    {
      "id": "m3",
      "data": {
        "type": "manufacturer",
        "name": "Qualcomm",
        "country": "us"
      }
    },
    {
      "id": "m4",
      "data": {
        "type": "manufacturer",
        "name": "Samsung",
        "country": "kr"
      }
    },
    {
      "id": "m5",
      "data": {
        "type": "manufacturer",
        "name": "Huizhou Desay Battery",
        "country": "cn"
      }
    },
    {
      "id": "m6",
      "data": {
        "type": "manufacturer",
        "name": "Sony",
        "country": "jp"
      }
    },
    {
      "id": "m7",
      "data": {
        "type": "manufacturer",
        "name": "OmniVision",
        "country": "us"
      }
    },
    {
      "id": "m8",
      "data": {
        "type": "manufacturer",
        "name": "TMSC",
        "country": "tw"
      }
    },
    {
      "id": "m9",
      "data": {
        "type": "manufacturer",
        "name": "GlobalFoundries",
        "country": "us"
      }
    },
    {
      "id": "m10",
      "data": {
        "type": "manufacturer",
        "name": "PMC Sierra",
        "country": "us"
      }
    },
    {
      "id": "m11",
      "data": {
        "type": "manufacturer",
        "name": "Broadcom Corp",
        "country": "us"
      }
    },
    {
      "id": "m12",
      "data": {
        "type": "manufacturer",
        "name": "Japan Display",
        "country": "jp"
      }
    },
    {
      "id": "m13",
      "data": {
        "type": "manufacturer",
        "name": "Sharp",
        "country": "jp"
      }


...
ts
import Ogma from '@linkurious/ogma';

export const runLayout = async (ogma: Ogma) => {
  return ogma.layouts.force({ locate: true });
};
ts
import Ogma from '@linkurious/ogma';

export class Panel {
  protected ogma: Ogma;
  protected container: HTMLElement;
  constructor(ogma: Ogma, container: HTMLElement) {
    this.ogma = ogma;
    this.container = container;
    this.initialize();
    // we need this frame to ensure the container is in the DOM
    // and the listeners are bound
    requestAnimationFrame(() => {
      this.createUI();
      this.container
        .querySelector('.close')
        ?.addEventListener('click', this.onCloseClick);
    });
  }

  protected initialize() {}

  protected createUI() {}

  private onCloseClick = () => {
    this.toggle();
  };

  toggle() {
    this.container.classList.toggle('show');
  }
}
ts
import { ANIMATION_DURATION } from './constants';
import { createCountries, createGroup } from './groups';
import { Panel } from './panel';
import { stateStack } from './states';
import { toggleIcons } from './styles';

export class SettingsPanel extends Panel {
  private groupPerType!: ReturnType<typeof createGroup>;
  private manufacturers!: ReturnType<typeof createCountries>;

  initialize(): void {
    this.manufacturers = createCountries(this.ogma);
    this.groupPerType = createGroup(this.ogma);
  }

  createUI() {
    this.getInput('grouping').addEventListener('change', this.toggleGrouping);
    this.getInput('explore').addEventListener('change', this.toggleExplore);
    this.getInput('icons').addEventListener('change', this.toggleIcons);
  }

  public toggleGrouping = (evt?: Event) => {
    const input = this.getInput('grouping');
    if (!evt) input.checked = !input.checked;
    this.groupPerType.toggle(ANIMATION_DURATION).then(this.runLayout);
    stateStack.push({ grouping: input.checked });
  };

  public toggleIcons = (evt?: Event) => {
    const input = this.getInput('icons');
    if (!evt) input.checked = !input.checked;
    toggleIcons();
    stateStack.push({ icons: input.checked });
  };

  public toggleExplore = (evt?: Event) => {
    const input = this.getInput('explore');
    if (!evt) input.checked = !input.checked;
    this.manufacturers.toggle(ANIMATION_DURATION).then(this.runLayout);
    stateStack.push({ explore: input.checked });
  };

  private getInput(group: string) {
    return this.container.querySelector<HTMLInputElement>(`#${group} input`)!;
  }

  private runLayout = () => {
    this.disableAllButtons();
    return this.ogma.layouts.force({ locate: true }).then(() => {
      this.enableAllButtons();
    });
  };

  private disableAllButtons() {
    const controls = this.container.querySelectorAll(
      '.toggle-section input'
    ) as NodeListOf<HTMLInputElement>;
    controls.forEach(control => (control.disabled = false));
  }

  private enableAllButtons() {
    const controls = this.container.querySelectorAll(
      '.toggle-section input'
    ) as NodeListOf<HTMLInputElement>;
    controls.forEach(control => (control.disabled = false));
  }
}
ts
export type StateDiff = {
  grouping?: boolean;
  icons?: boolean;
  explore?: boolean;
};

export const stateStack: StateDiff[] = [];
ts
import Ogma, { Color, StyleRule } from '@linkurious/ogma';
import {
  GROUP_COLOR,
  BASE_COLOR,
  GREY,
  FONT,
  COUNTRY_COLOR,
  GROUP_RADIUS,
  GROUP_INACTIVE_COLOR,
  COUNTRY_INACTIVE_COLOR,
  EDGE_INACTIVE_COLOR,
  INACTIVE_COLOR,
  DARK_COLOR,
  SELECTION_COLOR,
  BACKGROUND_COLOR
} from './constants';
import { IEdgeData, INodeData } from './types';
import { typeToIcon } from './icons';

let shouldShowIcons = true;
let iconsRule: StyleRule;
/**
 * Adds styles for the central node, countries, and manufacturers to the given Ogma instance.
 * @param {Ogma} ogma - The Ogma instance to add styles to.
 */
export function addStyles(ogma: Ogma<INodeData, IEdgeData>) {
  const defaultFont = { font: FONT };
  // change the default font in all captions
  ogma.styles.setTheme({
    nodeAttributes: { text: defaultFont },
    edgeAttributes: { text: defaultFont }
  });
  // all nodes and edges
  ogma.styles.addRule({
    edgeAttributes: {
      color: GREY,
      width: edge => {
        const type = edge.getData('type');
        if (type === 'has_part') return 3;
        if (type === 'produced_in') return 4;
        if (type === 'produced_by') return 1;
      },
      text: {
        content: edge => (edge.getData('type') || '').replace('_', ' '),
        size: 0
      }
    },
    nodeAttributes: {
      innerStroke: { width: 0 },
      color: node => {
        const type = node.getData('type');
        if (type === 'manufacturer') return BASE_COLOR;
        if (type === 'part') return GROUP_COLOR;
      },
      text: {
        margin: 0
      }
    }
  });

  ogma.styles.addNodeRule(node => node.getData('type') === 'manufacturer', {
    text: {
      tip: false,
      minVisibleSize: 10,
      content: node => node.getData('name')
    }
  });

  // labels for parts
  ogma.styles.addNodeRule(node => node.getData('type') === 'part', {
    radius: 8,
    text: {
      tip: false,
      minVisibleSize: 3,
      content: node => node.getData('part_type')
    }
  });
  ogma.styles.setHoveredNodeAttributes({
    text: {
      backgroundColor: 'rgba(0, 0, 0, 0)'
    },
    outerStroke: {
      color: DARK_COLOR,
      width: 1
    },
    radius: node => +node.getAttribute('radius') * 1.05
  });
  ogma.styles.setSelectedNodeAttributes({
    text: {
      backgroundColor: null
    },
    outerStroke: {
      color: DARK_COLOR,
      width: 1
    }
  });
  ogma.styles.setHoveredEdgeAttributes({
    color: DARK_COLOR,
    width: edge => +edge.getAttribute('width') * 1.1,
    text: {
      size: 12
    }
  });
  ogma.styles.setSelectedEdgeAttributes({
    color: DARK_COLOR,
    text: {
      content: edge => (edge.getData('type') || '').replace('_', ' ')
    }
  });

  iconsRule = ogma.styles.addRule({
    nodeSelector: node => {
      return shouldShowIcons;
    },
    nodeAttributes: {
      color: BACKGROUND_COLOR,
      icon: {
        font: 'Font Awesome 6 Free',
        color: node =>
          // icon color corresponds to the node color or the selection color
          node.hasClass('selected')
            ? SELECTION_COLOR
            : (node.getAttribute('color') as Color),
        content: node => typeToIcon[node.getData('type')],
        minVisibleSize: 2
      }
    }
  });
  // Add styles for countries
  ogma.styles.addNodeRule(node => node.getData('type') === 'country', {
    color: COUNTRY_COLOR,
    radius: 2 * GROUP_RADIUS,
    text: {
      position: 'center',
      scaling: true,
      scale: 0.5,
      // font: 'Roboto',
      // size: 1.5 * GROUP_RADIUS,
      color: 'white',
      minVisibleSize: 3,
      content: node => node.getData('iso').toUpperCase()
    }
  });

  // Add styles for manufacturers
  ogma.styles.addNodeRule(node => node.getData('type') === 'part-group', {
    color: () => (shouldShowIcons ? BACKGROUND_COLOR : GROUP_COLOR),
    icon: {
      color: GROUP_COLOR
    },
    text: {
      style: 'bold',
      content: node => capitalize(node.getData('name')!),
      minVisibleSize: 3
    },
    radius: GROUP_RADIUS
  });

  ogma.styles.createClass({
    name: 'dimmed',
    nodeAttributes: {
      color: node => {
        const type = node.getData('type');
        if (shouldShowIcons) return undefined;
        if (type === 'part-group') return GROUP_INACTIVE_COLOR;
        if (type === 'country') return COUNTRY_INACTIVE_COLOR;
        return INACTIVE_COLOR;
      }
    },
    edgeAttributes: {
      color: EDGE_INACTIVE_COLOR
    }
  });
  ogma.styles.createClass({
    name: 'highlighted'
  });
  ogma.styles.createClass({
    name: 'selected',
    nodeAttributes: {
      color: node =>
        // if there's an icon, keep the color white
        node.getAttribute('icon') ? node.getAttribute('color') : SELECTION_COLOR
    }
  });
  ogma.styles.createClass({
    name: 'hovered',
    nodeAttributes: {
      text: {
        backgroundColor: 'rgba(0, 0, 0, 0)'
      },
      outerStroke: {
        color: DARK_COLOR,
        width: 1
      },
      radius: node => +node.getAttribute('radius') * 1.05
    },
    edgeAttributes: {
      color: DARK_COLOR,
      width: edge => +edge.getAttribute('width') * 1.1,
      text: {
        content: edge => (edge.getData('type') || '').replace('_', ' ')
      }
    }
  });
}

export function toggleIcons() {
  shouldShowIcons = !shouldShowIcons;
  if (!iconsRule) return;
  iconsRule.refresh();
}

function capitalize(str: string) {
  return str.charAt(0).toUpperCase() + str.slice(1);
}
ts
import Ogma from '@linkurious/ogma';

type NodeType = 'part' | 'part-group' | 'manufacturer' | 'device' | 'country';
type EdgeType = 'has_part' | 'produced_by' | 'produced_in';

export interface INodeData {
  type: NodeType;
  part_type?: string;
  name?: string;
  country?: string;
}

export interface IEdgeData {
  type: EdgeType;
}
ts
import Ogma, { Layer, Easing } from '@linkurious/ogma';

interface ZoomControlOptions {
  easing?: Easing;
  duration?: number;
  zoomInTitle?: string;
  zoomOutTitle?: string;
  resetTitle?: string;
}

export class ZoomControl {
  private ogma: Ogma;
  private layer!: Layer;
  private options: Required<ZoomControlOptions>;

  constructor(
    ogma: Ogma,
    {
      easing = 'cubicIn',
      duration = 250,
      zoomInTitle = 'Zoom in',
      zoomOutTitle = 'Zoom out',
      resetTitle = 'Reset zoom'
    }: ZoomControlOptions = {}
  ) {
    this.ogma = ogma;
    this.options = { duration, easing, zoomInTitle, zoomOutTitle, resetTitle };
    this.createUI();
  }

  private createUI() {
    const zoomIn = document.createElement('button');
    zoomIn.classList.add('zoom-in');
    zoomIn.textContent = '+';
    zoomIn.title = this.options.zoomInTitle;
    zoomIn.addEventListener('click', this.onZoomInClick);

    const zoomOut = document.createElement('button');
    zoomOut.classList.add('zoom-out');
    zoomOut.textContent = '-';
    zoomOut.title = 'Zoom out';
    zoomOut.addEventListener('click', this.onZoomOutClick);

    const zoomReset = document.createElement('button');
    zoomReset.classList.add('zoom-reset');
    zoomReset.textContent = 'Reset';
    zoomReset.title = this.options.resetTitle;
    zoomReset.addEventListener('click', this.onZoomResetClick);

    const container = document.createElement('div');
    container.classList.add('ogma-control', 'ogma-zoom-control');
    container.appendChild(zoomIn);
    container.appendChild(zoomOut);
    container.appendChild(zoomReset);

    this.ogma.getContainer()?.insertAdjacentElement('afterbegin', container);
  }

  private getAnimationOptions() {
    return {
      easing: this.options.easing,
      duration: this.options.duration
    };
  }

  private onZoomInClick = () => {
    this.ogma.view.zoomIn(this.getAnimationOptions());
  };

  private onZoomOutClick = () => {
    this.ogma.view.zoomOut(this.getAnimationOptions());
  };

  private onZoomResetClick = () => {
    this.ogma.view.locateGraph(this.getAnimationOptions());
  };

  destroy() {
    this.ogma.getContainer()?.querySelector('.zoom-control')?.remove();
  }
}