568 lines
28 KiB
HTML
568 lines
28 KiB
HTML
import React, { useState, useRef, useEffect, useCallback, memo, useMemo } from 'react';
|
||
import {
|
||
Settings,
|
||
Plus,
|
||
Trash2,
|
||
Cpu,
|
||
Box,
|
||
BookOpen,
|
||
Move,
|
||
Target,
|
||
Ruler,
|
||
Zap,
|
||
Palette,
|
||
Spline,
|
||
ChevronRight,
|
||
Maximize2,
|
||
Wind,
|
||
RotateCw,
|
||
FileText,
|
||
DollarSign,
|
||
Activity,
|
||
GitBranch,
|
||
Layers,
|
||
Share2,
|
||
Battery,
|
||
HardDrive
|
||
} from 'lucide-react';
|
||
|
||
/**
|
||
* 工业蓝图风格样式系统
|
||
*/
|
||
const customStyles = `
|
||
.blueprint-grid {
|
||
background-image:
|
||
linear-gradient(#ccc 1px, transparent 1px),
|
||
linear-gradient(90deg, #ccc 1px, transparent 1px);
|
||
background-size: 40px 40px;
|
||
}
|
||
.drag-active {
|
||
cursor: grabbing !important;
|
||
}
|
||
.selection-box {
|
||
position: absolute;
|
||
border: 1px dashed #000;
|
||
background-color: rgba(0, 0, 0, 0.05);
|
||
pointer-events: none;
|
||
z-index: 200;
|
||
}
|
||
@keyframes line-flow {
|
||
from { stroke-dashoffset: 40; }
|
||
to { stroke-dashoffset: 0; }
|
||
}
|
||
@keyframes dash-flow-circle {
|
||
from { stroke-dashoffset: 40; }
|
||
to { stroke-dashoffset: 0; }
|
||
}
|
||
.leader-line {
|
||
animation: line-flow linear infinite;
|
||
pointer-events: none;
|
||
stroke-linecap: round;
|
||
transition: opacity 0.6s ease-out;
|
||
}
|
||
.rotating-origin {
|
||
animation: dash-flow-circle linear infinite;
|
||
pointer-events: none;
|
||
}
|
||
.custom-scrollbar::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||
background: #000;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.node-pulse {
|
||
animation: node-pulse-kf 2s infinite;
|
||
}
|
||
@keyframes node-pulse-kf {
|
||
0% { box-shadow: 0 0 0 0px rgba(234, 179, 8, 0.4); }
|
||
100% { box-shadow: 0 0 0 10px rgba(234, 179, 8, 0); }
|
||
}
|
||
|
||
.junction-node {
|
||
transition: opacity 0.6s ease-out, transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||
}
|
||
`;
|
||
|
||
// 工业随机颜色池
|
||
const INDUSTRIAL_PALETTE = [
|
||
'#262626', '#ef4444', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#06b6d4', '#64748b'
|
||
];
|
||
|
||
// 零件实体组件
|
||
const PartItem = memo(({ part, isSelected, showExploded, onMouseDown, isDraggingAny }) => {
|
||
const x = showExploded ? part.target.x : part.origin.x;
|
||
const y = showExploded ? part.target.y : part.origin.y;
|
||
|
||
const getIcon = () => {
|
||
const n = part.name.toLowerCase();
|
||
if (n.includes('处理器') || n.includes('cpu')) return <Cpu size={36} strokeWidth={1} />;
|
||
if (n.includes('电池') || n.includes('energy')) return <Battery size={36} strokeWidth={1} />;
|
||
if (n.includes('存储') || n.includes('ssd')) return <HardDrive size={36} strokeWidth={1} />;
|
||
if (n.includes('冷却') || n.includes('fan')) return <Wind size={36} strokeWidth={1} />;
|
||
return <Box size={36} strokeWidth={1} />;
|
||
};
|
||
|
||
return (
|
||
<div
|
||
onMouseDown={(e) => onMouseDown(e, part.id, 'target')}
|
||
className={`absolute w-60 h-32 -translate-x-1/2 -translate-y-1/2 cursor-move pointer-events-auto z-40
|
||
${isSelected ? 'z-[45]' : ''}
|
||
${!isDraggingAny ? 'transition-all duration-700 cubic-bezier(0.25, 1, 0.5, 1)' : ''}`}
|
||
style={{ left: `${x}%`, top: `${y}%` }}
|
||
>
|
||
<div className={`w-full h-full border-2 border-neutral-900 bg-white flex flex-col p-0 transition-all overflow-visible relative
|
||
${isSelected ? 'border-black shadow-[12px_12px_0px_#000] ring-4 ring-yellow-400/30 -translate-y-1' : 'shadow-lg opacity-100'}`}>
|
||
|
||
<div className="absolute -top-3 -right-2 px-3 py-1 bg-yellow-400 border-2 border-black shadow-[3px_3px_0px_#000] rotate-3 z-50 flex items-center gap-1">
|
||
<span className="text-[8px] font-black opacity-40">COST:</span>
|
||
<span className="text-[10px] font-black text-black antialiased tracking-tighter">
|
||
${part.price || '0.00'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="h-1.5 w-full shrink-0" style={{backgroundColor: part.color}}></div>
|
||
|
||
<div className="flex-1 flex items-stretch overflow-hidden bg-white text-left">
|
||
<div className="w-20 bg-neutral-50 border-r border-neutral-100 flex items-center justify-center shrink-0 text-neutral-800">
|
||
{getIcon()}
|
||
</div>
|
||
<div className="flex-1 p-3 flex flex-col justify-center overflow-hidden">
|
||
<div className="text-[11px] font-black uppercase leading-tight text-neutral-900 tracking-tighter w-full truncate mb-1">
|
||
{part.name}
|
||
</div>
|
||
<div className="text-[9px] font-bold text-neutral-400 line-clamp-3 leading-[1.3] uppercase italic">
|
||
{part.description || 'SPECIFICATION_NOT_DEFINED'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
const INITIAL_GROUPS = [
|
||
{
|
||
id: 'g1',
|
||
title: '系统架构拓扑 - 多样性物料',
|
||
image: 'https://images.unsplash.com/photo-1581091226825-a6a2a5aee158?auto=format&fit=crop&q=80&w=1600',
|
||
parts: [
|
||
{ id: 'p1-1', name: '独立感应器', description: '环境监测模块\nIP67工业防护等级\n实时光敏反馈回路', price: '45.00', color: '#10b981', lineColor: '#059669', origin: { x: 20, y: 70 }, target: { x: 15, y: 35 }, waypoint: { x: 20, y: 55 }, pointRadius: 30, lineWidth: 2, lineType: 'straight', dashLength: 4, circleDash: 2, lineSpeed: 2.0, circleSpeed: 4.0 },
|
||
{ id: 'p1-2', name: '处理器 A (主)', description: '核心逻辑运算单元\n共享总线节点\n分布式指令架构', price: '299.00', color: '#ef4444', lineColor: '#b91c1c', origin: { x: 50, y: 50 }, target: { x: 35, y: 15 }, waypoint: { x: 45, y: 35 }, pointRadius: 40, lineWidth: 2.5, lineType: 'polyline', dashLength: 2, circleDash: 1.5, lineSpeed: 2.0, circleSpeed: 4.0 },
|
||
{ id: 'p1-3', name: '处理器 B (从)', description: '冗余热备模块\n自动继承父级配色\n共享起点布线逻辑', price: '299.00', color: '#ef4444', lineColor: '#b91c1c', origin: { x: 50, y: 50 }, target: { x: 65, y: 15 }, waypoint: { x: 55, y: 35 }, pointRadius: 40, lineWidth: 2.5, lineType: 'polyline', dashLength: 2, circleDash: 1.5, lineSpeed: 2.0, circleSpeed: 4.0 },
|
||
{ id: 'p1-4', name: '电池模组', description: '5000mAh 动力包\n共享能源转接点\n内置热管理网格', price: '120.00', color: '#3b82f6', lineColor: '#1d4ed8', origin: { x: 80, y: 60 }, target: { x: 75, y: 85 }, waypoint: { x: 85, y: 75 }, pointRadius: 35, lineWidth: 2, lineType: 'polyline', dashLength: 2, circleDash: 1.5, lineSpeed: 2.0, circleSpeed: 5.0 },
|
||
]
|
||
}
|
||
];
|
||
|
||
const App = () => {
|
||
const [isEditMode, setIsEditMode] = useState(true);
|
||
const [isExploded, setIsExploded] = useState(true);
|
||
const [topoVisible, setTopoVisible] = useState(true); // 核心:控制线条与节点的显示
|
||
const [groups, setGroups] = useState(INITIAL_GROUPS);
|
||
const [activeGroupIndex, setActiveGroupIndex] = useState(0);
|
||
const [selectedPartIds, setSelectedPartIds] = useState([INITIAL_GROUPS[0].parts[1].id]);
|
||
const [dragState, setDragState] = useState({ active: false, type: 'target', affectedIds: [] });
|
||
const [canvasSize, setCanvasSize] = useState({ width: 1, height: 1 });
|
||
|
||
const canvasRef = useRef(null);
|
||
const dragStartRef = useRef({ x: 0, y: 0 });
|
||
const initialPositionsRef = useRef({});
|
||
|
||
const currentGroup = groups[activeGroupIndex];
|
||
const lastSelectedPart = currentGroup.parts.find(p => p.id === selectedPartIds[selectedPartIds.length - 1]);
|
||
|
||
// 监听炸开状态,实现逻辑上的延迟显示
|
||
useEffect(() => {
|
||
if (isEditMode) {
|
||
setTopoVisible(true);
|
||
return;
|
||
}
|
||
|
||
if (isExploded) {
|
||
// 炸开:组件移动耗时 700ms,我们延迟 800ms 后显示线条
|
||
const timer = setTimeout(() => setTopoVisible(true), 800);
|
||
return () => clearTimeout(timer);
|
||
} else {
|
||
// 收回:线条立即消失
|
||
setTopoVisible(false);
|
||
}
|
||
}, [isExploded, isEditMode]);
|
||
|
||
const uniqueOrigins = useMemo(() => {
|
||
const seen = new Set();
|
||
return currentGroup.parts.filter(p => {
|
||
const key = `${Math.round(p.origin.x)}-${Math.round(p.origin.y)}`;
|
||
if (seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
}, [currentGroup.parts]);
|
||
|
||
const uniqueWaypoints = useMemo(() => {
|
||
const seen = new Set();
|
||
return currentGroup.parts.filter(p => {
|
||
if (p.lineType !== 'polyline') return false;
|
||
const key = `${Math.round(p.waypoint.x)}-${Math.round(p.waypoint.y)}`;
|
||
if (seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
}, [currentGroup.parts]);
|
||
|
||
useEffect(() => {
|
||
const updateSize = () => {
|
||
if (canvasRef.current) {
|
||
setCanvasSize({ width: canvasRef.current.offsetWidth, height: canvasRef.current.offsetHeight });
|
||
}
|
||
};
|
||
updateSize();
|
||
window.addEventListener('resize', updateSize);
|
||
return () => window.removeEventListener('resize', updateSize);
|
||
}, []);
|
||
|
||
const updatePart = (partId, field, value) => {
|
||
setGroups(prev => {
|
||
const n = [...prev];
|
||
n[activeGroupIndex] = {
|
||
...n[activeGroupIndex],
|
||
parts: n[activeGroupIndex].parts.map(p => p.id === partId ? { ...p, [field]: value } : p)
|
||
};
|
||
return n;
|
||
});
|
||
};
|
||
|
||
const bulkUpdate = (field, value) => {
|
||
setGroups(prev => {
|
||
const n = [...prev];
|
||
n[activeGroupIndex] = {
|
||
...n[activeGroupIndex],
|
||
parts: n[activeGroupIndex].parts.map(p => selectedPartIds.includes(p.id) ? { ...p, [field]: value } : p)
|
||
};
|
||
return n;
|
||
});
|
||
};
|
||
|
||
const handleBranch = () => {
|
||
if (!lastSelectedPart) return;
|
||
const newId = 'p-branch-' + Date.now();
|
||
const newPart = {
|
||
...lastSelectedPart,
|
||
id: newId,
|
||
name: `${lastSelectedPart.name} 分支`,
|
||
target: { x: lastSelectedPart.target.x + 10, y: lastSelectedPart.target.y + 10 },
|
||
color: lastSelectedPart.color,
|
||
description: `基于 ${lastSelectedPart.name} 的拓扑分支\n自动同步父级配色与节点\n规格支持独立定义`
|
||
};
|
||
setGroups(prev => {
|
||
const n = [...prev];
|
||
n[activeGroupIndex].parts = [...n[activeGroupIndex].parts, newPart];
|
||
return n;
|
||
});
|
||
setSelectedPartIds([newId]);
|
||
};
|
||
|
||
const handleMouseDown = (e, partId, dragType) => {
|
||
if (!isEditMode) return;
|
||
e.stopPropagation();
|
||
|
||
const targetPart = currentGroup.parts.find(p => p.id === partId);
|
||
let affectedIds = [partId];
|
||
|
||
if (dragType === 'origin' || dragType === 'waypoint') {
|
||
affectedIds = currentGroup.parts.filter(p =>
|
||
Math.round(p[dragType].x) === Math.round(targetPart[dragType].x) &&
|
||
Math.round(p[dragType].y) === Math.round(targetPart[dragType].y)
|
||
).map(p => p.id);
|
||
}
|
||
|
||
setSelectedPartIds(affectedIds);
|
||
setDragState({ active: true, type: dragType, affectedIds });
|
||
|
||
const rect = canvasRef.current.getBoundingClientRect();
|
||
dragStartRef.current = {
|
||
x: ((e.clientX - rect.left) / rect.width) * 100,
|
||
y: ((e.clientY - rect.top) / rect.height) * 100
|
||
};
|
||
|
||
const initPos = {};
|
||
currentGroup.parts.forEach(p => {
|
||
if (affectedIds.includes(p.id)) {
|
||
initPos[p.id] = { ...p[dragType] };
|
||
}
|
||
});
|
||
initialPositionsRef.current = initPos;
|
||
};
|
||
|
||
const handleMouseMove = useCallback((e) => {
|
||
if (!isEditMode || !dragState.active || !canvasRef.current) return;
|
||
|
||
const rect = canvasRef.current.getBoundingClientRect();
|
||
const currentX = ((e.clientX - rect.left) / rect.width) * 100;
|
||
const currentY = ((e.clientY - rect.top) / rect.height) * 100;
|
||
const dx = currentX - dragStartRef.current.x;
|
||
const dy = currentY - dragStartRef.current.y;
|
||
|
||
const coordToUpdate = dragState.type;
|
||
|
||
setGroups(prev => {
|
||
const n = [...prev];
|
||
const g = n[activeGroupIndex];
|
||
n[activeGroupIndex] = {
|
||
...g,
|
||
parts: g.parts.map(p => {
|
||
if (dragState.affectedIds.includes(p.id)) {
|
||
const initPos = initialPositionsRef.current[p.id];
|
||
return {
|
||
...p,
|
||
[coordToUpdate]: {
|
||
x: Math.round((initPos.x + dx) * 10) / 10,
|
||
y: Math.round((initPos.y + dy) * 10) / 10
|
||
}
|
||
};
|
||
}
|
||
return p;
|
||
})
|
||
};
|
||
return n;
|
||
});
|
||
}, [dragState, isEditMode, activeGroupIndex]);
|
||
|
||
const handleMouseUp = useCallback(() => {
|
||
setDragState({ active: false, type: 'target', affectedIds: [] });
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (dragState.active) {
|
||
window.addEventListener('mousemove', handleMouseMove);
|
||
window.addEventListener('mouseup', handleMouseUp);
|
||
}
|
||
return () => {
|
||
window.removeEventListener('mousemove', handleMouseMove);
|
||
window.removeEventListener('mouseup', handleMouseUp);
|
||
};
|
||
}, [dragState.active, handleMouseMove, handleMouseUp]);
|
||
|
||
const getLeaderPath = (part) => {
|
||
if (part.lineType === 'polyline') {
|
||
return `M ${part.origin.x} ${part.origin.y} L ${part.waypoint.x} ${part.waypoint.y} L ${part.target.x} ${part.target.y}`;
|
||
}
|
||
return `M ${part.origin.x} ${part.origin.y} L ${part.target.x} ${part.target.y}`;
|
||
};
|
||
|
||
return (
|
||
<div className={`h-screen w-screen bg-[#f2f2f0] text-neutral-900 font-mono flex flex-col overflow-hidden relative selection:bg-black selection:text-white ${dragState.active ? 'drag-active' : ''}`}>
|
||
<style>{customStyles}</style>
|
||
<div className="fixed inset-0 pointer-events-none z-0 opacity-40 blueprint-grid"></div>
|
||
|
||
<header className="flex-none h-14 border-b-2 border-neutral-900 bg-white z-[110] flex items-center justify-between px-6 shadow-sm">
|
||
<div className="flex items-center gap-3">
|
||
<div className="bg-black text-white p-1 shadow-md"><Layers className="w-5 h-5" /></div>
|
||
<div>
|
||
<h1 className="text-lg font-black uppercase tracking-tighter leading-none text-neutral-900">Blueprint Topology</h1>
|
||
<p className="text-[10px] text-neutral-400 font-bold tracking-widest uppercase">Visual_Sync v15.0</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center bg-neutral-100 p-1 border-2 border-neutral-900 rounded-sm">
|
||
<button onClick={() => setIsEditMode(true)} className={`px-4 py-1 text-[10px] font-black transition-all ${isEditMode ? 'bg-black text-white shadow-inner' : 'text-neutral-500 hover:text-black'}`}>编辑模式</button>
|
||
<button onClick={() => { setIsEditMode(false); setIsExploded(false); setSelectedPartIds([]); }} className={`px-4 py-1 text-[10px] font-black transition-all ${!isEditMode ? 'bg-black text-white shadow-inner' : 'text-neutral-500 hover:text-black'}`}>预览展示</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="flex-1 flex overflow-hidden relative">
|
||
{isEditMode && (
|
||
<aside className="w-80 bg-white border-r-2 border-neutral-900 z-[100] flex flex-col p-6 space-y-6 overflow-y-auto shadow-xl custom-scrollbar">
|
||
<div className="space-y-4 text-left">
|
||
<h2 className="text-sm font-black flex items-center justify-between italic text-neutral-800 border-b-2 border-neutral-100 pb-2">
|
||
<span className="flex items-center gap-2"><Settings size={16} /> 属性管理</span>
|
||
{selectedPartIds.length > 0 && <span className="text-[9px] bg-yellow-400 px-2 py-0.5 rounded-full font-bold shadow-sm uppercase">Linked: {selectedPartIds.length}</span>}
|
||
</h2>
|
||
|
||
{selectedPartIds.length > 0 ? (
|
||
<div className="space-y-5 animate-in fade-in slide-in-from-left-2 duration-300">
|
||
<button onClick={handleBranch} className="w-full py-3 bg-yellow-400 border-2 border-black text-[10px] font-black uppercase shadow-[4px_4px_0px_#000] active:translate-x-0.5 active:translate-y-0.5 transition-all flex items-center justify-center gap-2 hover:bg-yellow-300">
|
||
<GitBranch size={14} /> 基于当前节点分支
|
||
</button>
|
||
|
||
<div className="space-y-4 p-4 border-2 border-neutral-900 bg-neutral-50 rounded-sm shadow-sm text-left">
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-bold text-neutral-400 uppercase tracking-tighter">组件名称</label>
|
||
<input type="text" disabled={selectedPartIds.length > 1} value={lastSelectedPart?.name || ''} onChange={e => updatePart(lastSelectedPart.id, 'name', e.target.value)} className="w-full border-2 border-neutral-900 px-2 py-2 text-xs font-bold outline-none focus:bg-white transition-colors" />
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-bold text-neutral-400 uppercase tracking-tighter flex items-center gap-1"><FileText size={10}/> 规格描述</label>
|
||
<textarea disabled={selectedPartIds.length > 1} value={lastSelectedPart?.description || ''} onChange={e => updatePart(lastSelectedPart.id, 'description', e.target.value)} rows="4" className="w-full border-2 border-neutral-900 px-2 py-2 text-[10px] font-bold outline-none focus:bg-white resize-none leading-relaxed" />
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-bold text-neutral-400 uppercase tracking-tighter flex items-center gap-1"><Palette size={10}/> 顶部色块颜色</label>
|
||
<div className="flex items-center gap-2">
|
||
<input type="color" value={lastSelectedPart?.color || '#000000'} onChange={e => bulkUpdate('color', e.target.value)} className="w-full h-10 border-2 border-neutral-900 bg-white cursor-pointer p-1" />
|
||
<div className="w-10 h-10 border-2 border-neutral-900" style={{backgroundColor: lastSelectedPart?.color}}></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-neutral-900 p-4 rounded shadow-2xl space-y-4 text-white">
|
||
<label className="text-[9px] font-black text-yellow-400 uppercase tracking-widest flex items-center gap-2"><Zap size={12}/> Global_Sync</label>
|
||
<div className="space-y-2">
|
||
<label className="text-[10px] font-bold uppercase flex justify-between">
|
||
<span className="text-neutral-400 text-left">连线周期 (S)</span>
|
||
<span className="bg-white/10 px-1 rounded font-mono text-yellow-400">{lastSelectedPart?.lineSpeed || 2.0}s</span>
|
||
</label>
|
||
<input type="range" min="0.05" max="5.0" step="0.05" value={lastSelectedPart?.lineSpeed || 2.0} onChange={e => bulkUpdate('lineSpeed', parseFloat(e.target.value))} className="w-full accent-yellow-400 cursor-pointer h-1" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-3 pt-4 border-t-2 border-neutral-100">
|
||
<button onClick={() => setIsExploded(!isExploded)} className={`w-full py-4 border-2 border-neutral-900 text-[10px] font-black uppercase shadow-[6px_6px_0px_#000] active:translate-x-1 active:translate-y-1 transition-all ${isExploded ? 'bg-yellow-400 text-black' : 'bg-white'}`}>
|
||
{isExploded ? '视图: 炸开模式' : '视图: 组装模式'}
|
||
</button>
|
||
<button onClick={() => { setGroups(prev => { const n = [...prev]; n[activeGroupIndex].parts = currentGroup.parts.filter(p => !selectedPartIds.includes(p.id)); return n; }); setSelectedPartIds([]); }} className="w-full py-2.5 bg-red-50 text-red-600 border-2 border-red-200 text-[10px] font-black hover:bg-red-600 hover:text-white transition-all flex items-center justify-center gap-2 uppercase tracking-tighter"><Trash2 size={12} /> Delete_Nodes</button>
|
||
</div>
|
||
</div>
|
||
) : <div className="text-center py-12 text-neutral-300 text-[10px] font-bold uppercase italic border-2 border-dashed border-neutral-200 p-8 leading-relaxed">选择节点或物料卡<br/>进行拓扑逻辑管理</div>}
|
||
|
||
<button onClick={() => {
|
||
const randomColor = INDUSTRIAL_PALETTE[Math.floor(Math.random() * INDUSTRIAL_PALETTE.length)];
|
||
const newPart = { id: 'p' + Date.now(), name: '新独立物料', description: '待定义规格参数\n第二行规格\n第三行规格', price: '0.00', color: randomColor, lineColor: randomColor, origin: { x: Math.random()*20+40, y: Math.random()*20+40 }, target: { x: 50, y: 30 }, waypoint: { x: 50, y: 40 }, pointRadius: 40, lineWidth: 2.0, lineType: 'straight', dashLength: 2, circleDash: 1.5, lineSpeed: 2.0, circleSpeed: 4.0 };
|
||
setGroups(prev => { const n = [...prev]; n[activeGroupIndex].parts = [...n[activeGroupIndex].parts, newPart]; return n; });
|
||
setSelectedPartIds([newPart.id]);
|
||
}} className="w-full py-4 bg-white border-2 border-neutral-900 text-[10px] font-black shadow-[8px_8px_0px_#000] active:translate-x-1 active:translate-y-1 flex items-center justify-center gap-2 transition-all hover:bg-neutral-50"><Plus size={18} /> 添加独立物料</button>
|
||
</div>
|
||
</aside>
|
||
)}
|
||
|
||
<main className="flex-1 relative flex items-center justify-center p-20 overflow-visible" onMouseDown={() => isEditMode && setSelectedPartIds([])}>
|
||
<div ref={canvasRef} className={`relative w-full max-w-5xl aspect-video bg-white border-4 border-neutral-900 shadow-[48px_48px_0px_#ddd] transition-all duration-700`}>
|
||
|
||
<div className="absolute inset-0 z-10 pointer-events-none overflow-hidden">
|
||
<img src={currentGroup.image} alt={currentGroup.title} className="w-full h-full object-cover filter grayscale contrast-125" />
|
||
<div className="absolute inset-0 blueprint-grid opacity-20"></div>
|
||
</div>
|
||
|
||
{/* 线条渲染层 - 使用逻辑状态 topoVisible 控制显示 */}
|
||
<svg className="absolute inset-0 w-full h-full z-20 pointer-events-none overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none" shapeRendering="geometricPrecision">
|
||
{currentGroup.parts.map(part => {
|
||
const isSelected = selectedPartIds.includes(part.id);
|
||
const lw = (part.lineWidth || 2.5) * 0.35;
|
||
const actualLineColor = isSelected ? "#eab308" : (part.lineColor || part.color);
|
||
const dashArray = part.dashLength > 0 ? `${part.dashLength} ${lw * 4}` : "none";
|
||
|
||
return (
|
||
<path
|
||
key={`line-${part.id}`}
|
||
d={getLeaderPath(part)}
|
||
stroke={actualLineColor}
|
||
strokeWidth={isSelected ? lw * 1.8 : lw}
|
||
fill="none"
|
||
strokeDasharray={dashArray}
|
||
style={{
|
||
// 当 topoVisible 为 true 时才显示(炸开模式有 800ms 延迟,回收模式 0s 立即消失)
|
||
opacity: topoVisible ? (isSelected ? 1 : 0.7) : 0,
|
||
animationDuration: `${part.lineSpeed || 2.0}s`
|
||
}}
|
||
className="leader-line"
|
||
/>
|
||
);
|
||
})}
|
||
</svg>
|
||
|
||
{/* 节点圆环渲染层 - 同步 topoVisible */}
|
||
<svg className="absolute inset-0 w-full h-full z-30 pointer-events-none overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none" shapeRendering="geometricPrecision">
|
||
{uniqueOrigins.map(part => {
|
||
const isSelected = selectedPartIds.includes(part.id);
|
||
const rx = (part.pointRadius / (canvasSize.width || 1)) * 100;
|
||
const ry = (part.pointRadius / (canvasSize.height || 1)) * 100;
|
||
|
||
return (
|
||
<g key={`origin-unique-${part.id}`} style={{
|
||
opacity: topoVisible ? 1 : 0,
|
||
transition: 'opacity 0.6s ease-out'
|
||
}}>
|
||
<ellipse cx={part.origin.x} cy={part.origin.y} rx={rx} ry={ry} fill="none" stroke={part.color} strokeWidth="0.05" opacity="0.15" />
|
||
<ellipse
|
||
cx={part.origin.x} cy={part.origin.y} rx={rx} ry={ry} fill="none"
|
||
stroke={isSelected ? "#fbbf24" : part.color}
|
||
strokeWidth={isSelected ? 0.8 : 0.3}
|
||
strokeDasharray={`${part.circleDash || 1.5}, 2.5`}
|
||
style={{ animationDuration: `${part.circleSpeed || 4}s` }}
|
||
className="rotating-origin"
|
||
/>
|
||
</g>
|
||
);
|
||
})}
|
||
</svg>
|
||
|
||
{/* 物料卡片渲染层 */}
|
||
<div className="absolute inset-0 z-40 pointer-events-none">
|
||
{currentGroup.parts.map(part => (
|
||
<PartItem
|
||
key={part.id} part={part} isSelected={selectedPartIds.includes(part.id)}
|
||
showExploded={isExploded} onMouseDown={handleMouseDown} isDraggingAny={dragState.active}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* 编辑句柄 - 起点 */}
|
||
{isEditMode && uniqueOrigins.map(part => (
|
||
<div
|
||
key={`h-origin-${part.id}`}
|
||
onMouseDown={(e) => handleMouseDown(e, part.id, 'origin')}
|
||
className={`absolute -translate-x-1/2 -translate-y-1/2 cursor-move z-[55] rounded-full transition-all
|
||
${selectedPartIds.includes(part.id) ? 'bg-yellow-400/20 node-pulse' : 'hover:bg-black/10'}`}
|
||
style={{ left: `${part.origin.x}%`, top: `${part.origin.y}%`, width: `${part.pointRadius * 1.5}px`, height: `${part.pointRadius * 1.5}px` }}
|
||
/>
|
||
))}
|
||
|
||
{/* 编辑句柄 - 中继点 - 同步 topoVisible */}
|
||
{uniqueWaypoints.map(part => {
|
||
const isSelected = selectedPartIds.includes(part.id);
|
||
return (
|
||
<div
|
||
key={`h-waypoint-${part.id}`}
|
||
onMouseDown={(e) => handleMouseDown(e, part.id, 'waypoint')}
|
||
className={`absolute w-4 h-4 -translate-x-1/2 -translate-y-1/2 bg-white border-2 rounded-full cursor-move z-[70] shadow-md junction-node
|
||
${isSelected ? 'border-yellow-500 scale-125 bg-yellow-50 shadow-lg' : 'border-neutral-900'}`}
|
||
style={{
|
||
left: `${part.waypoint.x}%`,
|
||
top: `${part.waypoint.y}%`,
|
||
opacity: topoVisible ? 1 : 0,
|
||
pointerEvents: topoVisible ? 'auto' : 'none'
|
||
}}
|
||
>
|
||
{(isSelected && isEditMode) && <div className="absolute -top-7 bg-black text-white text-[8px] px-1.5 py-0.5 font-black rounded uppercase whitespace-nowrap left-1/2 -translate-x-1/2">Junction</div>}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="absolute top-12 left-12">
|
||
<div className="bg-black text-white px-6 py-2.5 text-xs font-black uppercase tracking-[0.3em] italic flex items-center gap-4 shadow-[12px_12px_0px_#ccc]">
|
||
<Target size={18} className="text-yellow-400 animate-pulse" /> {currentGroup.title}
|
||
</div>
|
||
</div>
|
||
|
||
{!isEditMode && (
|
||
<div className="absolute bottom-12 flex flex-col items-center gap-4 z-[120]">
|
||
<button onClick={() => setIsExploded(!isExploded)} className="bg-black text-white px-16 py-6 font-black uppercase text-xs tracking-[0.4em] shadow-[18px_18px_0px_#ccc] hover:shadow-none hover:translate-x-2.5 hover:translate-y-2.5 transition-all flex items-center gap-5 group border-2 border-neutral-900 text-left">
|
||
{isExploded ? 'RESET_ASSEMBLY' : 'START_AXIS_ANIMATION'} <ChevronRight size={20} className="group-hover:translate-x-2 transition-transform" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</main>
|
||
</div>
|
||
|
||
<footer className="flex-none h-10 border-t-2 border-neutral-900 bg-white flex items-center justify-between px-6 z-[110] shadow-sm text-neutral-500">
|
||
<div className="flex items-center gap-6 text-left">
|
||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-wider text-neutral-400"><Ruler size={12} /> Alignment: Corrected_Axis</div>
|
||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-wider text-neutral-400 opacity-60"><GitBranch size={12}/> Anim_Sync: Logic_State_v15.0</div>
|
||
</div>
|
||
<div className="text-[10px] font-mono opacity-40 uppercase tracking-tighter font-bold italic text-right">System_Engine_Stable</div>
|
||
</footer>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default App; |