Skip to content

Commit 1943940

Browse files
committed
feat(web): add hierarchical/tree layout to graph (task-7)
- Create hierarchical layout algorithm in packages/algorithms/src/layout/ - Implement Reingold-Tilford inspired tree layout with level-based positioning - Add useGraphLayout hook for applying different graph layouts - Create LayoutSelector component for layout selection UI - Add nodePositions prop to OptimizedForceGraphVisualization - Integrate layout selector into graph page with enableSimulation toggle - Support hierarchical, circular, bipartite, and timeline layouts Technical details: - Parent-child relationship detection for AUTHORSHIP, AFFILIATION, REFERENCE edges - Root node selection (automatic or manual) - Level-based horizontal positioning with vertical sibling spacing - Fixed node positions (fx, fy) for static layouts - Force simulation disabled when using static layouts Follows constitution principles: - No any types, proper TypeScript strict mode - Atomic commit for single task - Export from algorithms package via index.ts - Named exports and constants to prevent unstable context
1 parent b4ebc5f commit 1943940

File tree

6 files changed

+825
-12
lines changed

6 files changed

+825
-12
lines changed
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* Layout Selector Component
3+
*
4+
* Provides UI for selecting different graph layout algorithms:
5+
* - Force-directed (default)
6+
* - Hierarchical/tree layout
7+
* - Circular layout
8+
* - Bipartite layout (two-column)
9+
* - Timeline layout
10+
*
11+
* @module components/graph/LayoutSelector
12+
*/
13+
14+
import type { GraphEdge, GraphNode } from '@bibgraph/types';
15+
import { SegmentedControl, Stack } from '@mantine/core';
16+
import { IconChartDots, IconGitBranch, IconHierarchy, IconRoute,IconTimeline } from '@tabler/icons-react';
17+
18+
import { ICON_SIZE } from '@/config/style-constants';
19+
import type { GraphLayoutType } from '@/hooks/useGraphLayout';
20+
21+
/**
22+
* Layout options with metadata
23+
*/
24+
export interface LayoutOption {
25+
description: string;
26+
icon: React.ReactNode;
27+
label: string;
28+
value: GraphLayoutType;
29+
}
30+
31+
/**
32+
* Layout options configuration
33+
*/
34+
export const LAYOUT_OPTIONS: LayoutOption[] = [
35+
{
36+
description: 'Force-directed layout (physics simulation)',
37+
icon: <IconChartDots size={ICON_SIZE.SM} />,
38+
label: 'Force',
39+
value: 'force',
40+
},
41+
{
42+
description: 'Hierarchical tree layout',
43+
icon: <IconHierarchy size={ICON_SIZE.SM} />,
44+
label: 'Tree',
45+
value: 'hierarchical',
46+
},
47+
{
48+
description: 'Circular arrangement',
49+
icon: <IconRoute size={ICON_SIZE.SM} />,
50+
label: 'Circle',
51+
value: 'circular',
52+
},
53+
{
54+
description: 'Two-column bipartite layout',
55+
icon: <IconGitBranch size={ICON_SIZE.SM} />,
56+
label: 'Split',
57+
value: 'bipartite',
58+
},
59+
{
60+
description: 'Timeline-based layout',
61+
icon: <IconTimeline size={ICON_SIZE.SM} />,
62+
label: 'Timeline',
63+
value: 'timeline',
64+
},
65+
];
66+
67+
/**
68+
* Empty arrays (defined outside component to prevent re-renders)
69+
*/
70+
const EMPTY_NODES: GraphNode[] = [];
71+
const EMPTY_EDGES: GraphEdge[] = [];
72+
73+
/**
74+
* Layout selector component props
75+
*/
76+
export interface LayoutSelectorProps {
77+
/** Callback when layout changes */
78+
onChange: (layout: GraphLayoutType) => void;
79+
/** Edges for layout-specific options */
80+
edges?: GraphEdge[];
81+
/** Nodes for layout-specific options */
82+
nodes?: GraphNode[];
83+
/** Current layout type */
84+
value: GraphLayoutType;
85+
}
86+
87+
/**
88+
* Layout selector UI component
89+
*
90+
* @param props
91+
* @param props.edges
92+
* @param props.nodes
93+
* @param props.onChange
94+
* @param props.value
95+
*/
96+
export const LayoutSelector: React.FC<LayoutSelectorProps> = (props) => {
97+
const { edges = EMPTY_EDGES, nodes = EMPTY_NODES, onChange, value } = props;
98+
// Only show layouts that make sense for the current graph
99+
const availableLayouts = LAYOUT_OPTIONS.filter((option) => {
100+
// Force is always available
101+
if (option.value === 'force') {
102+
return true;
103+
}
104+
105+
// Hierarchical requires parent-child relationships
106+
if (option.value === 'hierarchical') {
107+
return edges.some((e) => e.type === 'AUTHORSHIP' || e.type === 'AFFILIATION' || e.type === 'REFERENCE');
108+
}
109+
110+
// Bipartite requires a way to split nodes
111+
if (option.value === 'bipartite') {
112+
return nodes.some((n) => n.entityType === 'authors' || n.entityType === 'institutions');
113+
}
114+
115+
// Timeline requires nodes with publication dates
116+
if (option.value === 'timeline') {
117+
return nodes.some((n) => 'publication_year' in n);
118+
}
119+
120+
// Circular always works
121+
return true;
122+
});
123+
124+
const data = availableLayouts.map((option) => ({
125+
label: option.label,
126+
value: option.value,
127+
}));
128+
129+
if (data.length === 0) {
130+
return null;
131+
}
132+
133+
return (
134+
<Stack gap="xs">
135+
<SegmentedControl
136+
data={data}
137+
size="xs"
138+
value={value}
139+
onChange={(newValue) => onChange(newValue as GraphLayoutType)}
140+
/>
141+
</Stack>
142+
);
143+
};

apps/web/src/components/graph/OptimizedForceGraphVisualization.tsx

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ export interface OptimizedForceGraphVisualizationProps {
117117
onBackgroundClick?: () => void;
118118
/** Enable/disable force simulation */
119119
enableSimulation?: boolean;
120+
/** Fixed node positions for static layouts (nodeId -> {x, y}) */
121+
nodePositions?: Map<string, { x: number; y: number }>;
120122
/** Seed for deterministic initial positions (defaults to 42 for reproducibility) */
121123
seed?: number;
122124
/** Callback when graph methods become available (for external control like zoomToFit) */
@@ -160,6 +162,7 @@ export const OptimizedForceGraphVisualization = ({
160162
onNodeHover,
161163
onBackgroundClick,
162164
enableSimulation = true,
165+
nodePositions,
163166
seed,
164167
onGraphReady,
165168
enableOptimizations = true,
@@ -302,15 +305,21 @@ export const OptimizedForceGraphVisualization = ({
302305
});
303306

304307
// Transform nodes for force graph
305-
const transformedNodes: ForceGraphNode[] = deduplicatedNodes.map(node => ({
306-
id: node.id,
307-
entityType: node.entityType,
308-
label: node.label || node.id,
309-
entityId: node.id,
310-
x: node.x ?? (random() - 0.5) * 200,
311-
y: node.y ?? (random() - 0.5) * 200,
312-
originalNode: node,
313-
}));
308+
const transformedNodes: ForceGraphNode[] = deduplicatedNodes.map(node => {
309+
const fixedPosition = nodePositions?.get(node.id);
310+
return {
311+
id: node.id,
312+
entityType: node.entityType,
313+
label: node.label || node.id,
314+
entityId: node.id,
315+
x: node.x ?? (random() - 0.5) * 200,
316+
y: node.y ?? (random() - 0.5) * 200,
317+
// Use fixed positions if provided (for static layouts)
318+
fx: fixedPosition?.x,
319+
fy: fixedPosition?.y,
320+
originalNode: node,
321+
};
322+
});
314323

315324
// Transform edges, only include edges where both endpoints are visible
316325
const visibleNodeIds = new Set(transformedNodes.map(n => n.id));
@@ -329,7 +338,7 @@ export const OptimizedForceGraphVisualization = ({
329338
}));
330339

331340
return { nodes: transformedNodes, links: transformedEdges };
332-
}, [renderNodes, edges, seed]);
341+
}, [renderNodes, edges, seed, nodePositions]);
333342

334343
// Node highlighting logic
335344
const isNodeHighlighted = useCallback((nodeId: string): boolean => {

0 commit comments

Comments
 (0)