312 lines
8.3 KiB
TypeScript
312 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|