Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/layout/concentric2d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,15 @@ export interface ConcentricLayoutInputs extends LayoutFactoryProps {
concentricSpacing?: number;
}

export function concentricLayout({
/**
* Concentric layout algorithm for 2D graphs.
* @param graph
* @param radius
* @param drags
* @param getNodePosition
* @param concentricSpacing
*/
export function concentric2d({
graph,
radius = 40,
drags,
Expand Down
120 changes: 120 additions & 0 deletions src/layout/concentric3d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { ConcentricLayoutInputs } from 'layout/concentric2d';
import { buildNodeEdges } from './layoutUtils';
import { Vector3 } from 'three';

/**
* Generates a point on a Fibonacci sphere.
* @param i
* @param n
* @param r
*/
function fibonacciSpherePoint(i: number, n: number, r: number) {
const phi = Math.acos(1 - (2 * (i + 0.5)) / n);
const theta = Math.PI * (1 + Math.sqrt(5)) * (i + 0.5);
const x = r * Math.sin(phi) * Math.cos(theta);
const y = r * Math.sin(phi) * Math.sin(theta);
const z = r * Math.cos(phi);

return new Vector3(x, y, z);
}

/**
* Concentric layout algorithm for 3D graphs.
* @param graph
* @param radius
* @param drags
* @param getNodePosition
* @param concentricSpacing
*/
export function concentric3d({
graph,
radius = 40,
drags,
getNodePosition,
concentricSpacing = 100
}: ConcentricLayoutInputs) {
const { nodes, edges } = buildNodeEdges(graph);

const layout: Record<string, { x: number; y: number; z: number }> = {};

const getNodesInLevel = (level: number) => {
const circumference = 2 * Math.PI * (radius + level * concentricSpacing);
const minNodeSpacing = 40;
return Math.floor(circumference / minNodeSpacing);
};

const fixedLevelMap = new Map<number, string[]>();
const dynamicNodes: { id: string; metric: number }[] = [];

// Split nodes: fixed-level and dynamic
for (const node of nodes) {
const data = graph.getNodeAttribute(node.id, 'data');
const level = data?.level;

if (typeof level === 'number' && level >= 0) {
if (!fixedLevelMap.has(level)) {
fixedLevelMap.set(level, []);
}
fixedLevelMap.get(level)!.push(node.id);
} else {
dynamicNodes.push({ id: node.id, metric: graph.degree(node.id) });
}
}

// Sort dynamic nodes by degree
dynamicNodes.sort((a, b) => b.metric - a.metric);

// Fill layout for fixed-level nodes (3D spherical placement)
for (const [level, nodeIds] of fixedLevelMap.entries()) {
const count = nodeIds.length;
const r = radius + level * concentricSpacing;

for (const [i, id] of nodeIds.entries()) {
const pos = fibonacciSpherePoint(i, count, r);
layout[id] = { x: pos.x, y: pos.y, z: pos.z };
}
}

// Determine which levels are partially used and which are available
const occupiedLevels = new Set(fixedLevelMap.keys());
let dynamicLevel = 0;

let i = 0;
while (i < dynamicNodes.length) {
// Skip occupied levels
while (occupiedLevels.has(dynamicLevel)) {
dynamicLevel++;
}

const nodesInLevel = getNodesInLevel(dynamicLevel);
const r = radius + dynamicLevel * concentricSpacing;

for (let j = 0; j < nodesInLevel && i < dynamicNodes.length; j++) {
const pos = fibonacciSpherePoint(j, nodesInLevel, r);
layout[dynamicNodes[i].id] = { x: pos.x, y: pos.y, z: pos.z };
i++;
}

dynamicLevel++;
}

return {
step() {
return true;
},
getNodePosition(id: string) {
if (getNodePosition) {
const pos = getNodePosition(id, { graph, drags, nodes, edges });
if (pos) {
return pos;
}
}

if (drags?.[id]?.position) {
return drags[id].position as any;
}

return layout[id];
}
};
}
7 changes: 5 additions & 2 deletions src/layout/layoutProvider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { concentricLayout } from 'layout/concentric2d';
import { concentric3d } from 'layout/concentric3d';
import { concentric2d } from 'layout/concentric2d';
import { LayoutFactoryProps, LayoutStrategy } from './types';
import { forceDirected, ForceDirectedLayoutInputs } from './forceDirected';
import { circular2d, CircularLayoutInputs } from './circular2d';
Expand Down Expand Up @@ -119,7 +120,9 @@ export function layoutProvider({
radius: radius || 300
} as CircularLayoutInputs);
} else if (type === 'concentric2d') {
return concentricLayout(rest as CircularLayoutInputs);
return concentric2d(rest as ConcentricLayoutInputs);
} else if (type === 'concentric3d') {
return concentric3d(rest as ConcentricLayoutInputs);
} else if (type === 'hierarchicalTd') {
return hierarchical({ ...rest, mode: 'td' } as HierarchicalLayoutInputs);
} else if (type === 'hierarchicalLr') {
Expand Down
1 change: 1 addition & 0 deletions src/layout/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type LayoutTypes =
| 'forceDirected3d'
| 'circular2d'
| 'concentric2d'
| 'concentric3d'
| 'treeTd2d'
| 'treeTd3d'
| 'treeLr2d'
Expand Down
30 changes: 30 additions & 0 deletions stories/demos/ThreeLayouts.story.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { range } from 'd3-array';
import React from 'react';
import { GraphCanvas } from '../../src';
import { complexEdges, complexNodes, simpleEdges, simpleNodes } from '../assets/demo';
Expand All @@ -7,6 +8,26 @@ export default {
component: GraphCanvas
};

/**
* Calculate concentric level based on current index, total nodes, and growth ratio.
* @param current
* @param total
* @param ratio
*/
function getConcentricLevel(current, total, ratio) {
let level = 1;
let levelSize = 20;
let covered = 0;

while (covered + levelSize < current && covered < total) {
covered += levelSize;
levelSize = Math.floor(levelSize * ratio); // grow geometrically
level++;
}

return level;
}

export const ForceDirected = () => (
<GraphCanvas layoutType="forceDirected3d" nodes={complexNodes} edges={complexEdges} />
);
Expand All @@ -22,3 +43,12 @@ export const TreeLeftRight = () => (
export const TreeTopDown = () => (
<GraphCanvas layoutType="treeTd3d" nodes={simpleNodes} edges={simpleEdges} />
);

export const Concentric = () => (
<GraphCanvas layoutType="concentric3d" nodes={range(300).map(i => ({
id: `${i}`,
label: `Node ${i}`,
fill: `hsl(${getConcentricLevel(i, 300, 7) * 100}, 100%, 50%)`,
data: { level: getConcentricLevel(i, 300, 7)}
}))} edges={complexEdges} />
);
Loading