Skip to content
  1. Examples

iPhone parts origin

tsx
import React from 'react';
import ReactDOM from 'react-dom';
import ReactDOMServer from 'react-dom/server';
import { createRoot } from 'react-dom/client';
import App from './App';
import './style.css';

const root = createRoot(document.getElementById('root')!);
root.render(<App />);
html
<html>
  <head>
    <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"
    />
    <script src="https://cdn.jsdelivr.net/npm/@linkurious/ogma-ui-kit@0.0.9/dist/ogma-ui-kit.min.js"></script>
    <link
      rel="stylesheet"
      type="text/css"
      href="https://cdn.jsdelivr.net/npm/lucide-static@0.516.0/font/lucide.css"
      id="lucide-css"
    />
    <title>Ogma Iphone Parts</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="./index.tsx"></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;
}

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

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

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

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

.panel {
  position: absolute;
  top: 20px;
  left: 12px;
  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);
}

label {
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}

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

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

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

label:has(.switch input:disabled) {
  cursor: wait;
}

.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%;
}
json
{
  "name": "@linkurious/iphone-parts-react",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "dependencies": {
    "@linkurious/ogma-react": "^5.1.6",
    "@linkurious/ogma-ui-kit": "^0.0.9",
    "@shoelace-style/shoelace": "^2.20.1",
    "react": "19.1.0",
    "react-dom": "19.1.0"
  },
  "devDependencies": {
    "@types/react": "^19.1.4",
    "@types/react-dom": "^19.1.4",
    "@vitejs/plugin-react": "^4.4.1",
    "vite": "latest"
  }
}
tsx
import React from 'react';
import { StateContext } from './StateContext';

export const ActionsControl = () => {
  const {
    states,
    togglePanel,
    toggleGrouping,
    toggleIcons,
    toggleManufacturers
  } = React.useContext(StateContext);

  const undo = () => {
    const state = states.pop();
    if (!state) return;

    Object.keys(state).forEach(action => {
      if (action === 'grouping') toggleGrouping!(state[action]!);
      if (action === 'icons') toggleIcons!(state[action]!);
      if (action === 'explore') toggleManufacturers!(state[action]!);
    });
  };

  return (
    <div className="ogma-control ogma-actions-control">
      <button
        className="icon-sliders-vertical button-filter"
        onClick={togglePanel}
      />
      <button className="icon-undo-2 button-undo" onClick={undo} />
    </div>
  );
};
tsx
import React from 'react';
import { Ogma, useEvent } from '@linkurious/ogma-react';
import OgmaLib, { InputTarget, NodeId, RawGraph } from '@linkurious/ogma';

// Utility imports
import { INodeData, IEdgeData } from './types';
import { FONT } from './constants';
import { throttle, typeToIcon } from './utils';
import { StateContext } from './StateContext';
import './style.css';

// Component imports
import { Styles } from './Styles';
import { Panel } from './Panel';
import { ActionsControl } from './ActionsControl';
import { ZoomControl } from './ZoomControl';

function App() {
  const [graph, setGraph] = React.useState<RawGraph>();
  const ogmaRef = React.useRef<OgmaLib<unknown, unknown>>(null);
  const highlightedNodesRef = React.useRef(new Set<NodeId>());
  const hoveredNodeRef = React.useRef<InputTarget<INodeData, IEdgeData>>(null);
  const frameRef = React.useRef<number>(0);

  React.useEffect(() => {
    OgmaLib.parse
      .jsonFromUrl<INodeData, IEdgeData>('iphone_parts.json')
      .then(data => {
        setGraph(data);
      });
  }, []);

  const onReady = React.useCallback(ref => {
    ogmaRef.current = ref;
    ref.getNode(0)?.setAttributes({
      color: 'white',
      radius: 10,
      outerStroke: {
        color: '#333',
        width: 2
      },
      icon: {
        font: 'lucide',
        color: '#333',
        content: typeToIcon['star'],
        minVisibleSize: 2,
        scale: 0.75
      }
    });
    ogmaRef.current?.layouts.force({ locate: true });
  }, []);

  const onNodesSelected = useEvent('nodesSelected', ({ nodes }) => {
    nodes.addClass('selected');
    nodes.getId().forEach(id => {
      if (!highlightedNodesRef.current?.has(id))
        highlightedNodesRef.current?.add(id);
    });
    onHighlightChange();
  });

  const onNodesUnselected = useEvent('nodesUnselected', ({ nodes }) => {
    nodes.removeClass('selected');
    nodes.getId().forEach(id => {
      if (highlightedNodesRef.current?.has(id))
        highlightedNodesRef.current?.delete(id);
    });
    onHighlightChange();
  });

  const onEdgesSelected = useEvent('edgesSelected', ({ edges }) => {
    const sources = edges.getSource();
    const targets = edges.getTarget();
    sources.addClass('selected');
    targets.addClass('selected');
    sources.getId().forEach(id => {
      if (!highlightedNodesRef.current?.has(id))
        highlightedNodesRef.current?.add(id);
    });
    targets.getId().forEach(id => {
      if (!highlightedNodesRef.current?.has(id))
        highlightedNodesRef.current?.add(id);
    });
    onHighlightChange();
  });

  const onEdgesUnselected = useEvent('edgesUnselected', ({ edges }) => {
    const sources = edges.getSource();
    const targets = edges.getTarget();
    sources.removeClass('selected');
    targets.removeClass('selected');
    sources.getId().forEach(id => {
      if (highlightedNodesRef.current?.has(id))
        highlightedNodesRef.current?.delete(id);
    });
    targets.getId().forEach(id => {
      if (highlightedNodesRef.current?.has(id))
        highlightedNodesRef.current?.delete(id);
    });
    onHighlightChange();
  });

  const onMouseout = useEvent('mouseout', () => {
    cancelAnimationFrame(frameRef.current);
    frameRef.current = requestAnimationFrame(() => {
      const id = hoveredNodeRef.current?.getId();
      if (
        id !== undefined &&
        highlightedNodesRef.current.has(id) &&
        hoveredNodeRef.current!.hasClass('selected') === false
      ) {
        highlightedNodesRef.current.delete(id);
        onHighlightChange();
      }
      hoveredNodeRef.current = null;
    });
  });

  const onMouseover = useEvent('mouseover', ({ target }) => {
    requestAnimationFrame(() => {
      if (!target || !target.isNode) return;
      hoveredNodeRef.current = target as InputTarget<INodeData, IEdgeData>;

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

  const onHighlightChange = throttle(() => {
    if (highlightedNodesRef.current?.size === 0) {
      ogmaRef.current!.getNodes().removeClass('dimmed', { duration: 100 });
      ogmaRef.current!.getEdges().removeClass('dimmed', { duration: 100 });
      ogmaRef.current!.getNodes().removeClass('highlighted');
      ogmaRef.current!.getEdges().removeClass('highlighted');
    } else {
      const hiNodes = ogmaRef
        .current!.getNodes()
        .filter(node => highlightedNodesRef.current?.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(() => {
        ogmaRef
          .current!.getNodes()
          .subtract(adjacentElements.nodes)
          .addClass('dimmed');
        ogmaRef
          .current!.getEdges()
          .subtract(adjacentElements.edges)
          .addClass('dimmed');
        hiNodes.forEach(highlightedNode => {
          highlightedNode.addClass('highlighted');
          highlightedNode.removeClass('dimmed');
        });
      });
    }
  }, 100);

  if (!graph) {
    return <div>Loading...</div>;
  }

  return (
    <Ogma
      graph={graph}
      theme={{
        nodeAttributes: { text: { font: FONT } },
        edgeAttributes: { text: { font: FONT } }
      }}
      ref={ogmaRef}
      onReady={onReady}
      onNodesSelected={onNodesSelected}
      onNodesUnselected={onNodesUnselected}
      onEdgesSelected={onEdgesSelected}
      onEdgesUnselected={onEdgesUnselected}
      onMouseover={onMouseover}
      onMouseout={onMouseout}
    >
      <StateContext
        value={{
          states: []
        }}
      >
        <Styles />
        <Panel />
        <ActionsControl />
        <ZoomControl />
      </StateContext>
    </Ogma>
  );
}

export default App;
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;
tsx
import React, { ChangeEvent } from 'react';

export const Filter = (props: {
  title: string;
  offLabel: string;
  onLabel: string;
  checked: boolean;
  action: (evt: ChangeEvent<HTMLInputElement>) => void;
}) => {
  const { title, offLabel, onLabel, checked, action } = props;

  return (
    <section>
      <h3>{title}</h3>
      <div className="section-body toggle-section">
        <label>
          <span className="toggle-label toggle-label-off">{offLabel}</span>
          <div className="switch">
            <input
              id={title}
              type="checkbox"
              defaultChecked={checked}
              onChange={action}
            />
            <span className="slider round" />
          </div>
          <span className="toggle-label toggle-label-on">{onLabel}</span>
        </label>
      </div>
    </section>
  );
};
tsx
import React, { ChangeEvent } from 'react';
import { Filter } from './Filter';
import { partTypeToGroup } from './utils';
import { useOgma } from '@linkurious/ogma-react';
import { ANIMATION_DURATION } from './constants';
import { StateContext } from './StateContext';

export const Filters = () => {
  const context = React.useContext(StateContext);
  const ogma = useOgma();

  const toggleAllButtons = () => {
    const buttons = document.querySelectorAll('input[type="checkbox"]');
    buttons.forEach(button => {
      const input = button as HTMLInputElement;
      input.disabled = !input.disabled;
    });
  };

  const runLayout = () => {
    ogma.layouts.force({ locate: true }).then(toggleAllButtons);
  };

  const groupPerType = 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
  });

  const manufacturers = 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
  });

  const toggleGrouping = (arg: ChangeEvent<HTMLInputElement> | boolean) => {
    toggleAllButtons();
    if (typeof arg === 'boolean') {
      if (arg) groupPerType.enable(ANIMATION_DURATION).then(runLayout);
      else groupPerType.disable(ANIMATION_DURATION).then(runLayout);
      const input = document.querySelector('#GROUPING') as HTMLInputElement;
      input.checked = arg;
    } else {
      const evt = arg as ChangeEvent<HTMLInputElement>;
      groupPerType.toggle(ANIMATION_DURATION).then(runLayout);
      context.states.push({
        grouping: !evt.currentTarget.checked
      });
    }
  };

  const toggleManufacturers = (
    arg: ChangeEvent<HTMLInputElement> | boolean
  ) => {
    toggleAllButtons();
    if (typeof arg === 'boolean') {
      if (arg) manufacturers.enable(ANIMATION_DURATION).then(runLayout);
      else manufacturers.disable(ANIMATION_DURATION).then(runLayout);
      const input = document.querySelector('#EXPLORE') as HTMLInputElement;
      input.checked = arg;
    } else {
      const evt = arg as ChangeEvent<HTMLInputElement>;
      manufacturers.toggle(ANIMATION_DURATION).then(runLayout);
      context.states.push({
        explore: !evt.currentTarget.checked
      });
    }
  };

  const toggleIcons = (arg: ChangeEvent<HTMLInputElement> | boolean) => {
    if (typeof arg === 'boolean') {
      context.setShouldShowIcons!();
      const input = document.querySelector('#ICONS') as HTMLInputElement;
      input.checked = arg;
    } else {
      const evt = arg as ChangeEvent<HTMLInputElement>;
      context.setShouldShowIcons!();
      context.states.push({
        icons: !evt.currentTarget.checked
      });
    }
  };

  context.toggleGrouping = toggleGrouping;
  context.toggleIcons = toggleIcons;
  context.toggleManufacturers = toggleManufacturers;

  const filters = [
    {
      title: 'Grouping',
      offLabel: 'OFF',
      onLabel: 'ON',
      checked: false,
      action: toggleGrouping
    },
    {
      title: 'Icons',
      offLabel: 'OFF',
      onLabel: 'ON',
      checked: true,
      action: toggleIcons
    },
    {
      title: 'Explore',
      offLabel: 'Manufacturers',
      onLabel: 'Countries',
      checked: false,
      action: toggleManufacturers
    }
  ];

  return (
    <>
      <h2>Filters</h2>
      {filters.map((filter, index) => (
        <Filter
          key={index}
          title={filter.title}
          offLabel={filter.offLabel}
          onLabel={filter.onLabel}
          checked={filter.checked}
          action={filter.action}
        />
      ))}
    </>
  );
};
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"
      }


...
tsx
import React from 'react';
import { Filters } from './Filters';
import { StateContext } from './StateContext';

export const Panel = () => {
  const context = React.useContext(StateContext);
  const panelRef = React.useRef<HTMLDivElement>(null);

  const togglePanel = () => {
    panelRef.current?.classList.toggle('show');
  };
  context.togglePanel = togglePanel;

  return (
    <div ref={panelRef} className="panel show">
      <span className="close" onClick={togglePanel}>
        &times;
      </span>
      <Filters />
    </div>
  );
};
tsx
import React from 'react';
import { State } from './types';

export const StateContext = React.createContext<{
  states: State[];
  togglePanel?: () => void;
  setShouldShowIcons?: (state?: boolean) => void;
  toggleGrouping?: (arg: React.ChangeEvent<HTMLInputElement> | boolean) => void;
  toggleIcons?: (arg: React.ChangeEvent<HTMLInputElement> | boolean) => void;
  toggleManufacturers?: (
    arg: React.ChangeEvent<HTMLInputElement> | boolean
  ) => void;
}>({
  states: [],
  setShouldShowIcons: function (state?: boolean): void {
    throw new Error('Function not implemented.');
  },
  toggleGrouping: function (
    arg: React.ChangeEvent<HTMLInputElement> | boolean
  ): void {
    throw new Error('Function not implemented.');
  },
  toggleIcons: function (
    arg: React.ChangeEvent<HTMLInputElement> | boolean
  ): void {
    throw new Error('Function not implemented.');
  },
  toggleManufacturers: function (
    arg: React.ChangeEvent<HTMLInputElement> | boolean
  ): void {
    throw new Error('Function not implemented.');
  }
});
tsx
import React from 'react';
import { StyleClass, NodeStyle, EdgeStyle } from '@linkurious/ogma-react';

import {
  GROUP_COLOR,
  BASE_COLOR,
  GREY,
  COUNTRY_COLOR,
  GROUP_RADIUS,
  GROUP_INACTIVE_COLOR,
  COUNTRY_INACTIVE_COLOR,
  EDGE_INACTIVE_COLOR,
  INACTIVE_COLOR,
  DARK_COLOR,
  SELECTION_COLOR,
  BACKGROUND_COLOR
} from './constants';
import { capitalize, typeToIcon } from './utils';
import { StateContext } from './StateContext';

export const Styles = () => {
  const context = React.useContext(StateContext);
  const [shouldShowIcons, setShouldShowIcons] = React.useState(true);
  const toggleIcons = (state?: boolean) => {
    // If called with a state, set it to that state
    if (state) setShouldShowIcons(state);
    // Otherwise, toggle the current state
    else setShouldShowIcons(!shouldShowIcons);
  };

  context.setShouldShowIcons = toggleIcons;

  return (
    <>
      {/* Classes */}
      <StyleClass
        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
        }}
      />
      <StyleClass name="highlighted" />
      <StyleClass
        name="selected"
        nodeAttributes={{
          color: node =>
            // if there's an icon, keep the color white
            node.getAttribute('icon')
              ? node.getAttribute('color')
              : SELECTION_COLOR
        }}
      />
      <StyleClass
        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('_', ' ')
          }
        }}
      />

      <NodeStyle
        attributes={{
          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
          }
        }}
      />
      {/* Labels for parts and manufacturers */}
      <NodeStyle
        selector={node => node.getData('type') === 'manufacturer'}
        attributes={{
          text: {
            tip: false,
            minVisibleSize: 10,
            content: node => node.getData('name')
          }
        }}
      />
      <NodeStyle
        selector={node => node.getData('type') === 'part'}
        attributes={{
          radius: 8,
          text: {
            tip: false,
            minVisibleSize: 3,
            content: node => node.getData('part_type')
          }
        }}
      />
      {/* Icon Rule */}
      <NodeStyle
        selector={() => shouldShowIcons}
        attributes={{
          color: BACKGROUND_COLOR,
          icon: {
            font: 'lucide',
            color: node =>
              // icon color corresponds to the node color or the selection color
              node.hasClass('selected')
                ? SELECTION_COLOR
                : (node.getAttribute('color') as string),
            content: node => typeToIcon[node.getData('type')],
            minVisibleSize: 2
          }
        }}
      />
      {/* Style for countries */}
      <NodeStyle
        selector={node => node.getData('type') === 'country'}
        attributes={{
          color: COUNTRY_COLOR,
          radius: 2 * GROUP_RADIUS,
          text: {
            position: 'center',
            scaling: true,
            scale: 0.5,
            color: 'white',
            minVisibleSize: 3,
            content: node => node.getData('iso').toUpperCase()
          }
        }}
      />
      {/* Style for manufacturers */}
      <NodeStyle
        selector={node => node.getData('type') === 'part-group'}
        attributes={{
          color: () => (shouldShowIcons ? BACKGROUND_COLOR : GROUP_COLOR),
          icon: {
            color: GROUP_COLOR
          },
          text: {
            style: 'bold',
            content: node => capitalize(node.getData('name')!),
            minVisibleSize: 3
          },
          radius: GROUP_RADIUS
        }}
      />
      <NodeStyle.Hovered
        attributes={{
          text: {
            backgroundColor: 'rgba(0, 0, 0, 0)'
          },
          outerStroke: {
            color: DARK_COLOR,
            width: 1
          },
          radius: node => +node.getAttribute('radius') * 1.05
        }}
      />
      <NodeStyle.Selected
        attributes={{
          text: {
            backgroundColor: null
          },
          outerStroke: {
            color: DARK_COLOR,
            width: 1
          }
        }}
      />
      <EdgeStyle
        attributes={{
          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
          }
        }}
      />
      <EdgeStyle.Hovered
        attributes={{
          color: DARK_COLOR,
          width: edge => +edge.getAttribute('width') * 1.1,
          text: {
            size: 12
          }
        }}
      />
      <EdgeStyle.Selected
        attributes={{
          color: DARK_COLOR,
          text: {
            content: edge => (edge.getData('type') || '').replace('_', ' ')
          }
        }}
      />
    </>
  );
};
ts
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;
}

export interface State {
  grouping?: boolean;
  icons?: boolean;
  explore?: boolean;
}
ts
// dummy icon element to retrieve the HEX code, it should be hidden
const placeholder = document.createElement('span');
document.body.appendChild(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('icon-smartphone'),
  gear: getIconCode('icon-settings'),
  building: getIconCode('icon-building')
};

export const typeToIcon = {
  manufacturer: ICONS.building,
  device: ICONS.star,
  part: ICONS.gear,
  'part-group': ICONS.gear,
  country: ''
};

export function capitalize(str: string) {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

export 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 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'
  ])
};

export const partTypeToGroup = Object.entries(groups).reduce(
  (acc, [group, parts]) => {
    parts.forEach(part => acc.set(part, group));
    return acc;
  },
  new Map<string, string>()
);
ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()]
});
tsx
import React from 'react';
import { useOgma } from '@linkurious/ogma-react';

export const ZoomControl = () => {
  const ogma = useOgma();

  const zoomIn = () => {
    ogma.view.zoomIn({
      easing: 'cubicIn',
      duration: 250
    });
  };

  const zoomOut = () => {
    ogma.view.zoomOut({
      easing: 'cubicIn',
      duration: 250
    });
  };

  const zoomReset = () => {
    ogma.view.locateGraph({
      easing: 'cubicIn',
      duration: 250
    });
  };

  return (
    <div className="ogma-control ogma-zoom-control">
      <button className="icon-plus zoom-in" onClick={zoomIn} title="Zoom in" />
      <button
        className="icon-minus zoom-out"
        onClick={zoomOut}
        title="Zoom out"
      />
      <button
        className="icon-expand zoom-reset"
        onClick={zoomReset}
        title="Reset zoom"
      />
    </div>
  );
};