Appearance
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}>
×
</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>
);
};