Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
110 changes: 110 additions & 0 deletions src/layout/concentric.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { ConcentricLayoutInputs } from 'layout/concentric2d';
import { buildNodeEdges } from './layoutUtils';
import * as THREE 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 THREE.Vector3(x, y, z);
}

export function concentric({
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;

nodeIds.forEach((id, i) => {
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 as any;
}

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

return layout[id];
}
};
}
2 changes: 1 addition & 1 deletion src/layout/concentric2d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface ConcentricLayoutInputs extends LayoutFactoryProps {
concentricSpacing?: number;
}

export function concentricLayout({
export function concentric2d({
graph,
radius = 40,
drags,
Expand Down
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 { concentric } from 'layout/concentric';
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 === 'concentric') {
return concentric(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'
| 'concentric'
| 'treeTd2d'
| 'treeTd3d'
| 'treeLr2d'
Expand Down
24 changes: 24 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,20 @@ export default {
component: GraphCanvas
};

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 +37,12 @@ export const TreeLeftRight = () => (
export const TreeTopDown = () => (
<GraphCanvas layoutType="treeTd3d" nodes={simpleNodes} edges={simpleEdges} />
);

export const Concentric = () => (
<GraphCanvas layoutType="concentric" 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