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
110 changes: 110 additions & 0 deletions src/layout/concentric2d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { LayoutFactoryProps } from './types';
import { buildNodeEdges } from './layoutUtils';

export interface ConcentricLayoutInputs extends LayoutFactoryProps {
/**
* Base radius of the innermost circle.
*/
radius: number;
/**
* Distance between circles.
*/
concentricSpacing?: number;
}

export function concentricLayout({
graph,
radius = 40,
drags,
getNodePosition,
concentricSpacing = 100
}: ConcentricLayoutInputs) {
const { nodes, edges } = buildNodeEdges(graph);

const layout: Record<string, { x: number; y: 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
for (const [level, nodeIds] of fixedLevelMap.entries()) {
const count = nodeIds.length;
const r = radius + level * concentricSpacing;

for (let i = 0; i < count; i++) {
const angle = (2 * Math.PI * i) / count;
layout[nodeIds[i]] = {
x: r * Math.cos(angle),
y: r * Math.sin(angle)
};
}
}

// 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 angle = (2 * Math.PI * j) / nodesInLevel;
layout[dynamicNodes[i].id] = {
x: r * Math.cos(angle),
y: r * Math.sin(angle)
};
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];
}
};
}
1 change: 1 addition & 0 deletions src/layout/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './types';
export * from './layoutProvider';
export * from './circular2d';
export * from './concentric2d';
export * from './forceDirected';
export * from './layoutUtils';
export * from './forceUtils';
Expand Down
5 changes: 5 additions & 0 deletions src/layout/layoutProvider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { concentricLayout } from 'layout/concentric2d';
import { LayoutFactoryProps, LayoutStrategy } from './types';
import { forceDirected, ForceDirectedLayoutInputs } from './forceDirected';
import { circular2d, CircularLayoutInputs } from './circular2d';
import { ConcentricLayoutInputs } from './concentric2d';
import { hierarchical, HierarchicalLayoutInputs } from './hierarchical';
import { NoOverlapLayoutInputs, nooverlap } from './nooverlap';
import { ForceAtlas2LayoutInputs, forceAtlas2 } from './forceatlas2';
Expand All @@ -9,6 +11,7 @@ import { custom } from './custom';
export type LayoutOverrides = Partial<
| Omit<ForceDirectedLayoutInputs, 'dimensions' | 'mode'>
| CircularLayoutInputs
| ConcentricLayoutInputs
| HierarchicalLayoutInputs
>;

Expand Down Expand Up @@ -115,6 +118,8 @@ export function layoutProvider({
...rest,
radius: radius || 300
} as CircularLayoutInputs);
} else if (type === 'concentric2d') {
return concentricLayout(rest as CircularLayoutInputs);
} 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 @@ -10,6 +10,7 @@ export type LayoutTypes =
| 'forceDirected2d'
| 'forceDirected3d'
| 'circular2d'
| 'concentric2d'
| 'treeTd2d'
| 'treeTd3d'
| 'treeLr2d'
Expand Down
93 changes: 93 additions & 0 deletions src/symbols/nodes/Badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React, { FC, useMemo } from 'react';
import { a, useSpring } from '@react-spring/three';
import { Billboard, Text } from '@react-three/drei';
import { animationConfig } from '../../utils';
import { NodeRendererProps } from '../../types';
import { Color, DoubleSide } from 'three';

export interface BadgeProps extends NodeRendererProps {
/**
* The text to display in the badge.
*/
label: string;

/**
* Background color of the badge.
*/
backgroundColor?: string;

/**
* Text color of the badge.
*/
textColor?: string;

/**
* Size multiplier for the badge relative to the node size.
*/
badgeSize?: number;

/**
* Position offset from the node center.
*/
position?: [number, number, number];
}

export const Badge: FC<BadgeProps> = ({
label,
id,
size,
opacity = 1,
animated,
backgroundColor = '#ffffff',
textColor = '#000000',
badgeSize = 2,
position = [size * 0.65, size * 0.65, 0.1]
}) => {
const normalizedBgColor = useMemo(
() => new Color(backgroundColor),
[backgroundColor]
);
const normalizedTextColor = useMemo(() => new Color(textColor), [textColor]);

const { scale, badgeOpacity } = useSpring({
from: {
scale: [0.00001, 0.00001, 0.00001],
badgeOpacity: 0
},
to: {
scale: [size * badgeSize, size * badgeSize, size * badgeSize],
badgeOpacity: opacity
},
config: {
...animationConfig,
duration: animated ? undefined : 0
}
});

return (
<Billboard position={position}>
<a.group scale={scale as any}>
<a.mesh>
<planeGeometry attach="geometry" args={[0.5, 0.5]} />
<a.meshBasicMaterial
attach="material"
color={normalizedBgColor}
transparent={true}
opacity={badgeOpacity}
side={DoubleSide}
depthTest={false}
/>
</a.mesh>
<Text
position={[0, 0, 1]}
fontSize={0.3}
color={normalizedTextColor}
anchorX="center"
anchorY="middle"
>
{label}
</Text>
</a.group>
</Billboard>
);
};
1 change: 1 addition & 0 deletions src/symbols/nodes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './Sphere';
export * from './SphereWithIcon';
export * from './Svg';
export * from './SphereWithSvg';
export * from './Badge';
81 changes: 81 additions & 0 deletions stories/demos/Badge.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React from 'react';
import { GraphCanvas } from '../../src';
import { Badge, Sphere } from '../../src/symbols';
import { simpleNodes, simpleEdges } from '../assets/demo';

export default {
title: 'Demos/Badge',
component: Badge
};

export const Default = () => (
<GraphCanvas
nodes={simpleNodes}
edges={simpleEdges}
cameraMode="rotate"
renderNode={({ node, ...rest }) => (
<group>
<Sphere
{...rest}
node={node}
/>
<Badge
{...rest}
node={node}
label="3"
backgroundColor="#000000"
textColor="#ffffff"
/>
</group>
)}
/>
);

export const CustomColors = () => (
<GraphCanvas
nodes={simpleNodes}
edges={simpleEdges}
cameraMode="rotate"
renderNode={({ node, ...rest }) => (
<group>
<Sphere
{...rest}
node={node}
/>
<Badge
{...rest}
node={node}
label="5"
backgroundColor="#ff6b6b"
textColor="#ffffff"
badgeSize={0.3}
/>
</group>
)}
/>
);

export const DifferentSizes = () => (
<GraphCanvas
nodes={simpleNodes}
edges={simpleEdges}
cameraMode="rotate"
renderNode={({ node, ...rest }) => (
<group>
<Sphere
{...rest}
node={node}
/>
<Badge
{...rest}
node={node}
label="99+"
backgroundColor="#4ecdc4"
textColor="#ffffff"
badgeSize={0.35}
position={[rest.size * 0.7, rest.size * 0.7, 0.1]}
/>
</group>
)}
/>
);
5 changes: 2 additions & 3 deletions stories/demos/Layouts.story.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { CustomLayoutInputs, GraphCanvas, NodePositionArgs, recommendLayout } from '../../src';
import { GraphCanvas, NodePositionArgs, recommendLayout, LayoutFactoryProps } from '../../src';
import { complexEdges, complexNodes, simpleEdges, simpleNodes } from '../assets/demo';

export default {
Expand Down Expand Up @@ -37,14 +37,13 @@ export const Custom = () => (
layoutOverrides={{
getNodePosition: (id: string, { nodes }: NodePositionArgs) => {
const idx = nodes.findIndex(n => n.id === id);
const node = nodes[idx];
return {
x: 25 * idx,
y: idx % 2 === 0 ? 0 : 50,
z: 1
};
}
} as CustomLayoutInputs}
} as LayoutFactoryProps}
nodes={simpleNodes}
edges={simpleEdges}
/>
Expand Down
8 changes: 8 additions & 0 deletions stories/demos/TwoLayouts.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, treeEdges } from '../assets/demo';
Expand All @@ -23,6 +24,13 @@ export const Circular = () => (
<GraphCanvas layoutType="circular2d" nodes={complexNodes} edges={complexEdges} />
);

export const Concentric = () => (
<GraphCanvas layoutType="concentric2d" nodes={range(117).map(i => ({
id: `${i}`,
label: `Node ${i}`
}))} edges={complexEdges} />
);

export const NoOverlap = () => (
<GraphCanvas layoutType="nooverlap" nodes={simpleNodes} edges={simpleEdges} />
);
Expand Down