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