Skip to content

Commit cb7e7b3

Browse files
authored
Merge pull request #367 from reaviz/feature/GOOD-878-concentric-3d
Concentric 3D
2 parents ac4aa29 + 7270109 commit cb7e7b3

File tree

5 files changed

+165
-3
lines changed

5 files changed

+165
-3
lines changed

src/layout/concentric2d.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,15 @@ export interface ConcentricLayoutInputs extends LayoutFactoryProps {
1212
concentricSpacing?: number;
1313
}
1414

15-
export function concentricLayout({
15+
/**
16+
* Concentric layout algorithm for 2D graphs.
17+
* @param graph
18+
* @param radius
19+
* @param drags
20+
* @param getNodePosition
21+
* @param concentricSpacing
22+
*/
23+
export function concentric2d({
1624
graph,
1725
radius = 40,
1826
drags,

src/layout/concentric3d.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { ConcentricLayoutInputs } from 'layout/concentric2d';
2+
import { buildNodeEdges } from './layoutUtils';
3+
import { Vector3 } from 'three';
4+
5+
/**
6+
* Generates a point on a Fibonacci sphere.
7+
* @param i
8+
* @param n
9+
* @param r
10+
*/
11+
function fibonacciSpherePoint(i: number, n: number, r: number) {
12+
const phi = Math.acos(1 - (2 * (i + 0.5)) / n);
13+
const theta = Math.PI * (1 + Math.sqrt(5)) * (i + 0.5);
14+
const x = r * Math.sin(phi) * Math.cos(theta);
15+
const y = r * Math.sin(phi) * Math.sin(theta);
16+
const z = r * Math.cos(phi);
17+
18+
return new Vector3(x, y, z);
19+
}
20+
21+
/**
22+
* Concentric layout algorithm for 3D graphs.
23+
* @param graph
24+
* @param radius
25+
* @param drags
26+
* @param getNodePosition
27+
* @param concentricSpacing
28+
*/
29+
export function concentric3d({
30+
graph,
31+
radius = 40,
32+
drags,
33+
getNodePosition,
34+
concentricSpacing = 100
35+
}: ConcentricLayoutInputs) {
36+
const { nodes, edges } = buildNodeEdges(graph);
37+
38+
const layout: Record<string, { x: number; y: number; z: number }> = {};
39+
40+
const getNodesInLevel = (level: number) => {
41+
const circumference = 2 * Math.PI * (radius + level * concentricSpacing);
42+
const minNodeSpacing = 40;
43+
return Math.floor(circumference / minNodeSpacing);
44+
};
45+
46+
const fixedLevelMap = new Map<number, string[]>();
47+
const dynamicNodes: { id: string; metric: number }[] = [];
48+
49+
// Split nodes: fixed-level and dynamic
50+
for (const node of nodes) {
51+
const data = graph.getNodeAttribute(node.id, 'data');
52+
const level = data?.level;
53+
54+
if (typeof level === 'number' && level >= 0) {
55+
if (!fixedLevelMap.has(level)) {
56+
fixedLevelMap.set(level, []);
57+
}
58+
fixedLevelMap.get(level)!.push(node.id);
59+
} else {
60+
dynamicNodes.push({ id: node.id, metric: graph.degree(node.id) });
61+
}
62+
}
63+
64+
// Sort dynamic nodes by degree
65+
dynamicNodes.sort((a, b) => b.metric - a.metric);
66+
67+
// Fill layout for fixed-level nodes (3D spherical placement)
68+
for (const [level, nodeIds] of fixedLevelMap.entries()) {
69+
const count = nodeIds.length;
70+
const r = radius + level * concentricSpacing;
71+
72+
for (const [i, id] of nodeIds.entries()) {
73+
const pos = fibonacciSpherePoint(i, count, r);
74+
layout[id] = { x: pos.x, y: pos.y, z: pos.z };
75+
}
76+
}
77+
78+
// Determine which levels are partially used and which are available
79+
const occupiedLevels = new Set(fixedLevelMap.keys());
80+
let dynamicLevel = 0;
81+
82+
let i = 0;
83+
while (i < dynamicNodes.length) {
84+
// Skip occupied levels
85+
while (occupiedLevels.has(dynamicLevel)) {
86+
dynamicLevel++;
87+
}
88+
89+
const nodesInLevel = getNodesInLevel(dynamicLevel);
90+
const r = radius + dynamicLevel * concentricSpacing;
91+
92+
for (let j = 0; j < nodesInLevel && i < dynamicNodes.length; j++) {
93+
const pos = fibonacciSpherePoint(j, nodesInLevel, r);
94+
layout[dynamicNodes[i].id] = { x: pos.x, y: pos.y, z: pos.z };
95+
i++;
96+
}
97+
98+
dynamicLevel++;
99+
}
100+
101+
return {
102+
step() {
103+
return true;
104+
},
105+
getNodePosition(id: string) {
106+
if (getNodePosition) {
107+
const pos = getNodePosition(id, { graph, drags, nodes, edges });
108+
if (pos) {
109+
return pos;
110+
}
111+
}
112+
113+
if (drags?.[id]?.position) {
114+
return drags[id].position as any;
115+
}
116+
117+
return layout[id];
118+
}
119+
};
120+
}

src/layout/layoutProvider.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { concentricLayout } from 'layout/concentric2d';
1+
import { concentric3d } from 'layout/concentric3d';
2+
import { concentric2d } from 'layout/concentric2d';
23
import { LayoutFactoryProps, LayoutStrategy } from './types';
34
import { forceDirected, ForceDirectedLayoutInputs } from './forceDirected';
45
import { circular2d, CircularLayoutInputs } from './circular2d';
@@ -119,7 +120,9 @@ export function layoutProvider({
119120
radius: radius || 300
120121
} as CircularLayoutInputs);
121122
} else if (type === 'concentric2d') {
122-
return concentricLayout(rest as CircularLayoutInputs);
123+
return concentric2d(rest as ConcentricLayoutInputs);
124+
} else if (type === 'concentric3d') {
125+
return concentric3d(rest as ConcentricLayoutInputs);
123126
} else if (type === 'hierarchicalTd') {
124127
return hierarchical({ ...rest, mode: 'td' } as HierarchicalLayoutInputs);
125128
} else if (type === 'hierarchicalLr') {

src/layout/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type LayoutTypes =
1111
| 'forceDirected3d'
1212
| 'circular2d'
1313
| 'concentric2d'
14+
| 'concentric3d'
1415
| 'treeTd2d'
1516
| 'treeTd3d'
1617
| 'treeLr2d'

stories/demos/ThreeLayouts.story.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { range } from 'd3-array';
12
import React from 'react';
23
import { GraphCanvas } from '../../src';
34
import { complexEdges, complexNodes, simpleEdges, simpleNodes } from '../assets/demo';
@@ -7,6 +8,26 @@ export default {
78
component: GraphCanvas
89
};
910

11+
/**
12+
* Calculate concentric level based on current index, total nodes, and growth ratio.
13+
* @param current
14+
* @param total
15+
* @param ratio
16+
*/
17+
function getConcentricLevel(current, total, ratio) {
18+
let level = 1;
19+
let levelSize = 20;
20+
let covered = 0;
21+
22+
while (covered + levelSize < current && covered < total) {
23+
covered += levelSize;
24+
levelSize = Math.floor(levelSize * ratio); // grow geometrically
25+
level++;
26+
}
27+
28+
return level;
29+
}
30+
1031
export const ForceDirected = () => (
1132
<GraphCanvas layoutType="forceDirected3d" nodes={complexNodes} edges={complexEdges} />
1233
);
@@ -22,3 +43,12 @@ export const TreeLeftRight = () => (
2243
export const TreeTopDown = () => (
2344
<GraphCanvas layoutType="treeTd3d" nodes={simpleNodes} edges={simpleEdges} />
2445
);
46+
47+
export const Concentric = () => (
48+
<GraphCanvas layoutType="concentric3d" nodes={range(300).map(i => ({
49+
id: `${i}`,
50+
label: `Node ${i}`,
51+
fill: `hsl(${getConcentricLevel(i, 300, 7) * 100}, 100%, 50%)`,
52+
data: { level: getConcentricLevel(i, 300, 7)}
53+
}))} edges={complexEdges} />
54+
);

0 commit comments

Comments
 (0)