feat: merge XCBluePrint 3D visualization into project
This commit is contained in:
311
src/components/blueprint/SystemStructure.tsx
Normal file
311
src/components/blueprint/SystemStructure.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import { useRef, useMemo } from 'react';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { Text, Billboard, QuadraticBezierLine } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import { useBlueprintStore } from '../../store/blueprintStore';
|
||||
|
||||
const NODE_COLOR = '#aaaaaa';
|
||||
const ROOT_COLOR = '#ffffff';
|
||||
|
||||
function splitCamelCase(str: string): string[] {
|
||||
if (str === str.toUpperCase()) {
|
||||
return [str];
|
||||
}
|
||||
const result: string[] = [];
|
||||
let current = '';
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str[i];
|
||||
if (char >= 'A' && char <= 'Z' && current.length > 0) {
|
||||
result.push(current);
|
||||
current = char;
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current) result.push(current);
|
||||
return result;
|
||||
}
|
||||
|
||||
function SystemNode({
|
||||
id,
|
||||
name,
|
||||
position,
|
||||
isRoot = false,
|
||||
size = 0.8
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
position: [number, number, number];
|
||||
isRoot?: boolean;
|
||||
size?: number;
|
||||
}) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
const { selectedNode, setSelectedNode } = useBlueprintStore();
|
||||
const isSelected = selectedNode === id;
|
||||
|
||||
const color = isRoot ? ROOT_COLOR : NODE_COLOR;
|
||||
|
||||
useFrame(() => {
|
||||
if (meshRef.current) {
|
||||
meshRef.current.rotation.y += 0.005;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedNode(id);
|
||||
}}
|
||||
onPointerOver={() => document.body.style.cursor = 'pointer'}
|
||||
onPointerOut={() => document.body.style.cursor = 'auto'}
|
||||
>
|
||||
<sphereGeometry args={[size, 32, 32]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={color}
|
||||
emissiveIntensity={isSelected ? 0.8 : 0.3}
|
||||
metalness={0.5}
|
||||
roughness={0.3}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
<Billboard follow={true}>
|
||||
{(() => {
|
||||
const parts = splitCamelCase(name);
|
||||
const lineHeight = 0.45;
|
||||
const startY = size + 0.5 + (parts.length - 1) * lineHeight / 2;
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => (
|
||||
<Text
|
||||
key={i}
|
||||
position={[0, startY - i * lineHeight, 0]}
|
||||
fontSize={0.35}
|
||||
color="#ffffff"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
>
|
||||
{part}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Billboard>
|
||||
|
||||
{isSelected && (
|
||||
<mesh>
|
||||
<sphereGeometry args={[size + 0.1, 32, 32]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.3} />
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function fibonacciSpherePoint(i: number, n: number, radius: number): [number, number, number] {
|
||||
const phi = Math.acos(1 - 2 * (i + 0.5) / n);
|
||||
const theta = Math.PI * (1 + Math.sqrt(5)) * i;
|
||||
return [
|
||||
radius * Math.sin(phi) * Math.cos(theta),
|
||||
radius * Math.cos(phi),
|
||||
radius * Math.sin(phi) * Math.sin(theta),
|
||||
];
|
||||
}
|
||||
|
||||
function ConnectionLine({
|
||||
start,
|
||||
end,
|
||||
color = '#4a5568',
|
||||
lineWidth = 1.5
|
||||
}: {
|
||||
start: [number, number, number];
|
||||
end: [number, number, number];
|
||||
color?: string;
|
||||
lineWidth?: number;
|
||||
}) {
|
||||
const dx = end[0] - start[0];
|
||||
const dy = end[1] - start[1];
|
||||
const dz = end[2] - start[2];
|
||||
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
|
||||
const midX = (start[0] + end[0]) / 2;
|
||||
const midY = (start[1] + end[1]) / 2;
|
||||
const midZ = (start[2] + end[2]) / 2;
|
||||
|
||||
const controlOffset = dist * 0.25;
|
||||
const yDirection = dy > 0 ? 1 : -1;
|
||||
|
||||
return (
|
||||
<QuadraticBezierLine
|
||||
start={start}
|
||||
end={end}
|
||||
mid={[
|
||||
midX - dz * controlOffset / dist,
|
||||
midY + yDirection * controlOffset,
|
||||
midZ + dx * controlOffset / dist,
|
||||
]}
|
||||
color={color}
|
||||
lineWidth={lineWidth}
|
||||
transparent
|
||||
opacity={0.6}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SystemStructure() {
|
||||
const { blueprint, selectedNode } = useBlueprintStore();
|
||||
const { subsystems, modules } = blueprint;
|
||||
|
||||
const rootPosition: [number, number, number] = [0, 0, 0];
|
||||
|
||||
const subsystemPositions = useMemo(() => {
|
||||
const radius = 8;
|
||||
const count = subsystems.length;
|
||||
return subsystems.map((sub, i) => {
|
||||
const position = fibonacciSpherePoint(i, count, radius);
|
||||
position[1] *= 0.6;
|
||||
return {
|
||||
id: sub.id,
|
||||
position: position as [number, number, number],
|
||||
dependCount: sub.depends_on.length,
|
||||
};
|
||||
});
|
||||
}, [subsystems]);
|
||||
|
||||
const getSubsystemSize = (dependCount: number) => {
|
||||
const baseSize = 0.6;
|
||||
const maxAdd = 0.4;
|
||||
return baseSize + Math.min(dependCount * 0.15, maxAdd);
|
||||
};
|
||||
|
||||
const modulePositions = useMemo(() => {
|
||||
const positions: { id: string; name: string; position: [number, number, number]; parentId: string }[] = [];
|
||||
const moduleRadius = 2.5;
|
||||
|
||||
subsystems.forEach((sub) => {
|
||||
const subModules = modules.filter(m => m.parent_subsystem === sub.id);
|
||||
const subPos = subsystemPositions.find(p => p.id === sub.id);
|
||||
if (!subPos) return;
|
||||
|
||||
const count = subModules.length || 1;
|
||||
|
||||
subModules.forEach((mod, i) => {
|
||||
const offset = fibonacciSpherePoint(i, count, moduleRadius);
|
||||
const parentY = subPos.position[1];
|
||||
const yOffset = parentY > 0 ? 1.2 : -1.2;
|
||||
positions.push({
|
||||
id: mod.id,
|
||||
name: mod.name,
|
||||
position: [
|
||||
subPos.position[0] + offset[0],
|
||||
parentY + yOffset + offset[1] * 0.5,
|
||||
subPos.position[2] + offset[2],
|
||||
] as [number, number, number],
|
||||
parentId: sub.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return positions;
|
||||
}, [subsystems, modules, subsystemPositions]);
|
||||
|
||||
const dependencies = useMemo(() => {
|
||||
const lines: { from: [number, number, number]; to: [number, number, number]; fromId: string; toId: string }[] = [];
|
||||
|
||||
subsystems.forEach(sub => {
|
||||
const fromPos = subsystemPositions.find(p => p.id === sub.id);
|
||||
if (!fromPos) return;
|
||||
|
||||
sub.depends_on.forEach(depId => {
|
||||
const toPos = subsystemPositions.find(p => p.id === depId);
|
||||
if (toPos) {
|
||||
lines.push({
|
||||
from: fromPos.position,
|
||||
to: toPos.position,
|
||||
fromId: sub.id,
|
||||
toId: depId,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return lines;
|
||||
}, [subsystems, subsystemPositions]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<SystemNode
|
||||
id="root"
|
||||
name={blueprint.meta.name}
|
||||
position={rootPosition}
|
||||
isRoot
|
||||
size={1.2}
|
||||
/>
|
||||
|
||||
{subsystemPositions.map((sub) => (
|
||||
<SystemNode
|
||||
key={sub.id}
|
||||
id={sub.id}
|
||||
name={sub.id}
|
||||
position={sub.position}
|
||||
size={getSubsystemSize(sub.dependCount)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{modulePositions.map((mod) => (
|
||||
<SystemNode
|
||||
key={mod.id}
|
||||
id={mod.id}
|
||||
name={mod.name}
|
||||
position={mod.position}
|
||||
size={0.4}
|
||||
/>
|
||||
))}
|
||||
|
||||
{dependencies.map((dep, i) => {
|
||||
const isHighlighted = selectedNode && (dep.fromId === selectedNode || dep.toId === selectedNode);
|
||||
return (
|
||||
<ConnectionLine
|
||||
key={i}
|
||||
start={dep.from}
|
||||
end={dep.to}
|
||||
color={isHighlighted ? '#ffffff' : '#666666'}
|
||||
lineWidth={isHighlighted ? 3 : 1.5}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{subsystemPositions.map((sub) => {
|
||||
const isHighlighted = selectedNode === sub.id || selectedNode === 'root';
|
||||
return (
|
||||
<ConnectionLine
|
||||
key={`root-${sub.id}`}
|
||||
start={rootPosition}
|
||||
end={sub.position}
|
||||
color={isHighlighted ? '#ffffff' : '#666666'}
|
||||
lineWidth={isHighlighted ? 3 : 1.5}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{modulePositions.map((mod) => {
|
||||
const parent = subsystemPositions.find(p => p.id === mod.parentId);
|
||||
if (!parent) return null;
|
||||
const isHighlighted = selectedNode === mod.id || selectedNode === mod.parentId;
|
||||
return (
|
||||
<ConnectionLine
|
||||
key={`mod-${mod.id}`}
|
||||
start={parent.position}
|
||||
end={mod.position}
|
||||
color={isHighlighted ? '#ffffff' : '#666666'}
|
||||
lineWidth={isHighlighted ? 3 : 1.5}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user