140 lines
6.6 KiB
TypeScript
140 lines
6.6 KiB
TypeScript
|
|
import React from 'react';
|
|
import { GenerationStep } from '../types';
|
|
import { Database, Palette, FileText, Layers, Clapperboard, Check } from 'lucide-react';
|
|
|
|
interface PipelineVisualizerProps {
|
|
currentStep: GenerationStep;
|
|
onNavigate: (step: GenerationStep) => void;
|
|
isProcessing: boolean;
|
|
}
|
|
|
|
const PipelineVisualizer: React.FC<PipelineVisualizerProps> = ({ currentStep, onNavigate, isProcessing }) => {
|
|
|
|
const nodes = [
|
|
{ id: 'source', label: '來源選擇', icon: Database, steps: [GenerationStep.PROJECT_SELECTION], color: '#ec4899' },
|
|
{ id: 'style', label: '風格定義', icon: Palette, steps: [GenerationStep.STYLE_SELECTION], color: '#8b5cf6' },
|
|
{ id: 'blueprint', label: '結構藍圖', icon: FileText, steps: [GenerationStep.INPUT_PHASE, GenerationStep.STRUCTURE_PLANNING], color: '#6366f1' },
|
|
{ id: 'assets', label: '資產合成', icon: Layers, steps: [GenerationStep.ASSET_MANAGEMENT], color: '#06b6d4' },
|
|
{ id: 'prod', label: '分鏡製作', icon: Clapperboard, steps: [GenerationStep.PRODUCTION_DASHBOARD], color: '#10b981' },
|
|
];
|
|
|
|
const getNodeStatus = (nodeSteps: GenerationStep[]) => {
|
|
const stepOrder = Object.values(GenerationStep);
|
|
const firstNodeStepIndex = stepOrder.indexOf(nodeSteps[0]);
|
|
const currentStepIndex = stepOrder.indexOf(currentStep);
|
|
|
|
if (nodeSteps.includes(currentStep)) return 'active';
|
|
if (currentStepIndex > firstNodeStepIndex) return 'completed';
|
|
return 'pending';
|
|
};
|
|
|
|
const getTargetStep = (nodeSteps: GenerationStep[]) => {
|
|
return nodeSteps[0];
|
|
}
|
|
|
|
const nodeWidth = 40;
|
|
const gap = 120;
|
|
const startX = 50;
|
|
const y = 40; // Centered vertically in the available space of Header (excluding text)
|
|
|
|
return (
|
|
<div className="w-full h-full flex items-center justify-center select-none">
|
|
<div className="relative w-full max-w-3xl h-full">
|
|
<svg className="w-full h-full overflow-visible">
|
|
<defs>
|
|
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
|
<feGaussianBlur stdDeviation="4" result="coloredBlur"/>
|
|
<feMerge>
|
|
<feMergeNode in="coloredBlur"/>
|
|
<feMergeNode in="SourceGraphic"/>
|
|
</feMerge>
|
|
</filter>
|
|
</defs>
|
|
|
|
{/* Edges */}
|
|
{nodes.map((node, i) => {
|
|
if (i === nodes.length - 1) return null;
|
|
const status = getNodeStatus(node.steps);
|
|
const nextStatus = getNodeStatus(nodes[i+1].steps);
|
|
const isActiveFlow = status === 'active' && isProcessing;
|
|
const isCompletedFlow = nextStatus === 'active' || nextStatus === 'completed';
|
|
|
|
return (
|
|
<g key={`edge-${i}`}>
|
|
<line
|
|
x1={startX + (i * gap) + nodeWidth/2}
|
|
y1={y}
|
|
x2={startX + ((i + 1) * gap) - nodeWidth/2}
|
|
y2={y}
|
|
stroke={isCompletedFlow ? "rgba(255,255,255,0.2)" : "rgba(255,255,255,0.05)"}
|
|
strokeWidth="2"
|
|
/>
|
|
{(isActiveFlow || isCompletedFlow) && (
|
|
<line
|
|
x1={startX + (i * gap) + nodeWidth/2}
|
|
y1={y}
|
|
x2={startX + ((i + 1) * gap) - nodeWidth/2}
|
|
y2={y}
|
|
stroke={node.color}
|
|
strokeWidth="2"
|
|
strokeDasharray="4 4"
|
|
className={isActiveFlow ? "animate-flow" : ""}
|
|
opacity={isActiveFlow ? 1 : 0.5}
|
|
/>
|
|
)}
|
|
</g>
|
|
);
|
|
})}
|
|
|
|
{/* Nodes */}
|
|
{nodes.map((node, i) => {
|
|
const status = getNodeStatus(node.steps);
|
|
const cx = startX + (i * gap);
|
|
const isClickable = status === 'completed' || status === 'active';
|
|
|
|
return (
|
|
<g
|
|
key={node.id}
|
|
onClick={() => isClickable && onNavigate(getTargetStep(node.steps))}
|
|
className={`transition-all duration-300 ${isClickable ? 'cursor-pointer hover:opacity-80' : 'opacity-40 cursor-not-allowed'}`}
|
|
>
|
|
{status === 'active' && (
|
|
<circle cx={cx} cy={y} r={nodeWidth / 1.5} fill={node.color} opacity="0.2" filter="url(#glow)" className="animate-pulse" />
|
|
)}
|
|
<circle
|
|
cx={cx}
|
|
cy={y}
|
|
r={nodeWidth / 2}
|
|
fill={status === 'active' ? node.color : (status === 'completed' ? '#1f2937' : '#000')}
|
|
stroke={status === 'active' ? '#fff' : (status === 'completed' ? node.color : '#374151')}
|
|
strokeWidth={status === 'active' ? 2 : 1}
|
|
/>
|
|
<foreignObject x={cx - 10} y={y - 10} width="20" height="20">
|
|
<div className="flex items-center justify-center w-full h-full text-white">
|
|
{status === 'completed' ? <Check className="w-4 h-4 text-emerald-400" /> : <node.icon className="w-4 h-4" />}
|
|
</div>
|
|
</foreignObject>
|
|
<text
|
|
x={cx}
|
|
y={y + 35}
|
|
textAnchor="middle"
|
|
fill={status === 'active' ? '#fff' : '#6b7280'}
|
|
fontSize="10"
|
|
fontWeight="bold"
|
|
letterSpacing="1"
|
|
className={status === 'active' ? 'text-glow' : ''}
|
|
>
|
|
{node.label}
|
|
</text>
|
|
</g>
|
|
)
|
|
})}
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PipelineVisualizer;
|