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
Original file line number Diff line number Diff line change
Expand Up @@ -57,36 +57,39 @@ function ArchWorkspaceInner({ model, source }: ArchWorkspaceInnerProps) {
heatmapMode,
bookmarkedNodes,
hiddenNodes,
collapsedLayers,
showAllNodes,
showAllLayers,
isolatedSubtree,
setIsolatedSubtree,
openContextMenu,
closeContextMenu,
} = useArchitectureStore();

const { nodes: initialNodes, edges: initialEdges } = useMemo(
() => getLayoutedElements(model.nodes, model.edges, {
const layoutOptions = useMemo(() => ({
layers: model.detectedLayers,
heatmapMode,
bookmarks: bookmarkedNodes,
hiddenNodes,
isolatedSubtree,
}),
[model, heatmapMode, bookmarkedNodes, hiddenNodes, isolatedSubtree]
collapsedLayers,
}), [model.detectedLayers, heatmapMode, bookmarkedNodes, hiddenNodes, isolatedSubtree, collapsedLayers]);

const { nodes: initialNodes, edges: initialEdges } = useMemo(
() => getLayoutedElements(model.nodes, model.edges, layoutOptions),
[model.nodes, model.edges, layoutOptions]
);

const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);

useEffect(() => {
const { nodes: newNodes, edges: newEdges } = getLayoutedElements(model.nodes, model.edges, {
heatmapMode,
bookmarks: bookmarkedNodes,
hiddenNodes,
isolatedSubtree,
});
const { nodes: newNodes, edges: newEdges } = getLayoutedElements(model.nodes, model.edges, layoutOptions);
setNodes(newNodes);
setEdges(newEdges);
}, [heatmapMode, bookmarkedNodes, hiddenNodes, isolatedSubtree, model, setNodes, setEdges]);
const frame = requestAnimationFrame(() => reactFlowInstance.fitView({ padding: 0.12, duration: 300 }));
return () => cancelAnimationFrame(frame);
}, [layoutOptions, model.nodes, model.edges, reactFlowInstance, setNodes, setEdges]);

useEffect(() => {
setNodes((nds) =>
Expand Down Expand Up @@ -192,13 +195,15 @@ function ArchWorkspaceInner({ model, source }: ArchWorkspaceInnerProps) {
setIsolatedSubtree(null);
showAllNodes();
const { nodes: newNodes, edges: newEdges } = getLayoutedElements(model.nodes, model.edges, {
heatmapMode,
bookmarks: bookmarkedNodes,
...layoutOptions,
hiddenNodes: new Set(),
collapsedLayers: new Set(),
});
showAllLayers();
setNodes(newNodes);
setEdges(newEdges);
setTimeout(() => reactFlowInstance.fitView({ padding: 0.2, duration: 300 }), 50);
}, [model, setNodes, setEdges, reactFlowInstance, heatmapMode, bookmarkedNodes, setIsolatedSubtree, showAllNodes]);
}, [model, setNodes, setEdges, reactFlowInstance, layoutOptions, setIsolatedSubtree, showAllNodes, showAllLayers]);

const handleExportPng = useCallback(() => {
const el = document.querySelector('.react-flow') as HTMLElement;
Expand Down Expand Up @@ -301,7 +306,7 @@ function ArchWorkspaceInner({ model, source }: ArchWorkspaceInnerProps) {
onPaneClick={handlePaneClick}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
fitViewOptions={{ padding: 0.12 }}
minZoom={0.1}
maxZoom={3}
proOptions={{ hideAttribution: true }}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { ReactFlowProvider, type NodeProps } from '@xyflow/react';
import type { ArchFlowNode } from './ArchitectureNode';
import { ArchitectureNode } from './ArchitectureNode';

describe('ArchitectureNode', () => {
it('keeps the name, type, layer, and relationship state visible', () => {
const props = {
id: 'module:orders',
type: 'architectureNode',
data: {
label: 'Orders',
nodeType: 'service',
layer: 'business-logic',
relationshipState: 'connected',
description: 'Coordinates order processing.',
filesCount: 3,
complexity: 'medium',
},
} as NodeProps<ArchFlowNode>;

render(
<ReactFlowProvider>
<ArchitectureNode {...props} />
</ReactFlowProvider>,
);

expect(screen.getByText('Orders')).toBeInTheDocument();
expect(screen.getByText('Service · Business Logic')).toBeInTheDocument();
expect(screen.getByText('Connected')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
ListTodo, Zap,
} from 'lucide-react';
import { cn } from '@/shared/utils/cn';
import type { ArchNodeType } from '@/shared/types/architecture';
import type { ArchNodeType, RelationshipState } from '@/shared/types/architecture';

const nodeConfig: Record<ArchNodeType, { icon: typeof Monitor; color: string; bg: string }> = {
frontend: { icon: Monitor, color: 'text-blue-400', bg: 'bg-blue-500/10 border-blue-500/30' },
Expand All @@ -31,6 +31,8 @@ const nodeConfig: Record<ArchNodeType, { icon: typeof Monitor; color: string; bg
export interface ArchNodeData {
label: string;
nodeType: ArchNodeType;
layer: string;
relationshipState: RelationshipState;
description: string;
filesCount: number;
complexity: 'low' | 'medium' | 'high';
Expand All @@ -43,6 +45,17 @@ export interface ArchNodeData {

export type ArchFlowNode = Node<ArchNodeData, 'architectureNode'>;

const relationshipStateConfig: Record<RelationshipState, { label: string; className: string }> = {
connected: { label: 'Connected', className: 'bg-success/10 text-success' },
'no-observed-relationships': { label: 'No observed', className: 'bg-warning/10 text-warning' },
unresolved: { label: 'Unresolved', className: 'bg-destructive/10 text-destructive' },
'not-extracted': { label: 'Not extracted', className: 'bg-muted text-muted-foreground' },
};

function formatLabel(value: string): string {
return value.replace(/[-_]/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
}

export const ArchitectureNode = memo(({ data }: NodeProps<ArchFlowNode>) => {
const config = nodeConfig[data.nodeType] || nodeConfig.utilities;
const Icon = config.icon;
Expand All @@ -59,7 +72,7 @@ export const ArchitectureNode = memo(({ data }: NodeProps<ArchFlowNode>) => {
return (
<div
className={cn(
'relative rounded-lg border px-4 py-3 min-w-[160px] max-w-[200px] shadow-sm transition-all duration-200',
'relative h-[104px] w-[220px] rounded-lg border px-4 py-3 shadow-sm transition-all duration-200',
config.bg,
data.isSelected && 'ring-2 ring-primary shadow-lg scale-105',
data.isHighlighted && !data.isSelected && 'ring-1 ring-primary/50 shadow-md',
Expand All @@ -68,7 +81,7 @@ export const ArchitectureNode = memo(({ data }: NodeProps<ArchFlowNode>) => {
>
<Handle
type="target"
position={Position.Top}
position={Position.Left}
className="!w-2 !h-2 !bg-muted-foreground/50 !border-0"
/>
<div className="flex items-start gap-2.5">
Expand All @@ -77,13 +90,16 @@ export const ArchitectureNode = memo(({ data }: NodeProps<ArchFlowNode>) => {
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1">
<p className="text-xs font-medium text-foreground truncate">{data.label}</p>
<p className="text-sm font-semibold text-foreground truncate">{data.label}</p>
{data.isBookmarked && (
<span className="text-amber-400 text-[10px]">*</span>
)}
</div>
<p className="text-2xs text-muted-foreground mt-0.5 truncate">
{formatLabel(data.nodeType)} · {formatLabel(data.layer)}
</p>
<p className="text-2xs text-muted-foreground mt-0.5 line-clamp-1">{data.description}</p>
<div className="flex items-center gap-2 mt-1.5">
<div className="flex items-center gap-1.5 mt-1.5 flex-wrap">
{data.filesCount > 0 && (
<span className="text-2xs text-muted-foreground">{data.filesCount} files</span>
)}
Expand All @@ -97,12 +113,15 @@ export const ArchitectureNode = memo(({ data }: NodeProps<ArchFlowNode>) => {
>
{data.complexity}
</span>
<span className={cn('text-2xs px-1 py-0.5 rounded', relationshipStateConfig[data.relationshipState].className)}>
{relationshipStateConfig[data.relationshipState].label}
</span>
</div>
</div>
</div>
<Handle
type="source"
position={Position.Bottom}
position={Position.Right}
className="!w-2 !h-2 !bg-muted-foreground/50 !border-0"
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@ import { cn } from '@/shared/utils/cn';
import { useArchitectureStore } from '../store';

export function ModuleExplorer() {
const { model, expandedModules, toggleModule, selectedNodeId, setSelectedNodeId, setHighlightedNodeIds } = useArchitectureStore();
const {
model,
expandedModules,
toggleModule,
collapsedLayers,
toggleLayer,
selectedNodeId,
setSelectedNodeId,
setHighlightedNodeIds,
} = useArchitectureStore();

if (!model) return null;

Expand Down Expand Up @@ -32,10 +41,18 @@ export function ModuleExplorer() {
return (
<div key={layer.id}>
<button
onClick={() => toggleModule(layer.id)}
onClick={() => {
toggleModule(layer.id);
toggleLayer(layer.id);
}}
onMouseEnter={() => handleModuleHover(layer.nodes)}
onMouseLeave={handleModuleLeave}
className="w-full flex items-center gap-1.5 px-2 py-1.5 rounded-md text-left hover:bg-accent/50 transition-colors"
aria-expanded={isExpanded}
aria-pressed={!collapsedLayers.has(layer.id)}
className={cn(
'w-full flex items-center gap-1.5 px-2 py-1.5 rounded-md text-left hover:bg-accent/50 transition-colors',
collapsedLayers.has(layer.id) && 'opacity-70'
)}
>
<ChevronRight
className={cn(
Expand Down
84 changes: 84 additions & 0 deletions apps/frontend/src/features/architecture/layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import { getLayoutedElements } from './layout';
import type { ArchEdge, ArchLayer, ArchNode } from '@/shared/types/architecture';

function node(id: string, layer: string, relationshipState: ArchNode['relationshipState'] = 'connected'): ArchNode {
return {
id,
name: id,
type: 'service',
description: `${id} description`,
responsibilities: [],
files: [`${id}.ts`],
dependencies: [],
dependents: [],
estimatedComplexity: 'low',
estimatedLines: 20,
tags: [layer],
layer,
relationshipState,
};
}

function edge(source: string, target: string): ArchEdge {
return {
id: `${source}->${target}`,
source,
target,
type: 'dependency',
predicate: 'depends_on',
truthClass: 'resolved',
evidence: [],
};
}

const layers: ArchLayer[] = [
{ id: 'presentation', name: 'Presentation', order: 0, nodes: ['web'] },
{ id: 'business-logic', name: 'Business Logic', order: 1, nodes: ['api', 'worker'] },
{ id: 'infrastructure', name: 'Infrastructure', order: 2, nodes: ['db'] },
];

describe('getLayoutedElements', () => {
it('places disconnected nodes into readable left-to-right layer columns', () => {
const result = getLayoutedElements(
[node('web', 'presentation'), node('api', 'business-logic'), node('worker', 'business-logic'), node('db', 'infrastructure')],
[edge('web', 'api')],
{ layers },
);

const byId = new Map(result.nodes.map((item) => [item.id, item]));
expect(byId.get('web')!.position.x).toBeLessThan(byId.get('api')!.position.x);
expect(byId.get('api')!.position.x).toBeLessThan(byId.get('db')!.position.x);
expect(byId.get('api')!.position.x).toBe(byId.get('worker')!.position.x);
expect(byId.get('api')!.position.y).not.toBe(byId.get('worker')!.position.y);
});

it('keeps node geometry non-overlapping and preserves only real edges', () => {
const result = getLayoutedElements(
[node('web', 'presentation'), node('api', 'business-logic'), node('db', 'infrastructure')],
[edge('web', 'api')],
{ layers },
);

const positions = result.nodes.map((item) => item.position);
for (let left = 0; left < positions.length; left += 1) {
for (let right = left + 1; right < positions.length; right += 1) {
const sameColumn = positions[left].x === positions[right].x;
const sameRow = positions[left].y === positions[right].y;
expect(sameColumn && sameRow).toBe(false);
}
}
expect(result.edges.map((item) => item.id)).toEqual(['web->api']);
});

it('removes collapsed layers and their incident edges', () => {
const result = getLayoutedElements(
[node('web', 'presentation'), node('api', 'business-logic'), node('db', 'infrastructure')],
[edge('web', 'api'), edge('api', 'db')],
{ layers, collapsedLayers: new Set(['business-logic']) },
);

expect(result.nodes.map((item) => item.id)).toEqual(['web', 'db']);
expect(result.edges).toEqual([]);
});
});
Loading
Loading