import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Bot, Compass, Crosshair, Maximize2, Megaphone, Minus, Plus, RotateCcw, ScrollText, Target, TrendingUp, UserCheck, X, Zap, } from 'lucide-react'; import { useTheme } from '../../contexts/ThemeContext'; import type { ExpertCard, SixStepMessage } from './types'; import { CURRENT_PAIR_BY_STAGE, EDGE_COLOR, EDGE_LABEL, STATUS_TITLE, VARIANT_ABBREV, VARIANT_LABEL, buildEdgePath, buildLayout, type LayoutEdge, type LayoutNode, } from './flowLayout'; interface SixStepFlowSandboxProps { message?: SixStepMessage; width: number; onResizeStart: (e: React.MouseEvent) => void; onClose: () => void; onOpenFullscreen: () => void; } const COMPACT_OPTIONS = { nodeWidth: 120, nodeHeight: 60, levelGap: 80, nodeGap: 24, paddingX: 32, paddingY: 28, }; const VARIANT_GRADIENT: Record = { agent: { dark: 'from-slate-700/80 to-slate-900/80 border-slate-500/60', light: 'from-white to-slate-50 border-slate-300' }, purpose: { dark: 'from-violet-700/40 to-violet-900/40 border-violet-400/50', light: 'from-orange-50 to-white border-orange-300' }, actor: { dark: 'from-cyan-700/40 to-cyan-900/40 border-cyan-400/50', light: 'from-red-50 to-white border-red-300' }, behavior: { dark: 'from-emerald-700/40 to-emerald-900/40 border-emerald-400/50', light: 'from-emerald-50 to-white border-emerald-300' }, driver: { dark: 'from-emerald-800/40 to-teal-900/40 border-teal-400/50', light: 'from-teal-50 to-white border-teal-300' }, narrative: { dark: 'from-amber-700/40 to-amber-900/40 border-amber-400/50', light: 'from-amber-50 to-white border-amber-300' }, }; const VARIANT_ICON: Record> = { agent: Bot, purpose: Target, actor: UserCheck, behavior: TrendingUp, driver: Zap, narrative: ScrollText, }; // Colored icon wrapper per variant (dark / light) const VARIANT_ICON_CLS: Record = { agent: { dark: 'bg-slate-600/60 text-slate-200', light: 'bg-slate-100 text-slate-600' }, purpose: { dark: 'bg-violet-500/25 text-violet-200', light: 'bg-orange-100 text-orange-600' }, actor: { dark: 'bg-cyan-500/25 text-cyan-200', light: 'bg-red-100 text-red-600' }, behavior: { dark: 'bg-emerald-500/25 text-emerald-200', light: 'bg-emerald-100 text-emerald-700' }, driver: { dark: 'bg-teal-500/25 text-teal-200', light: 'bg-teal-100 text-teal-700' }, narrative: { dark: 'bg-amber-500/25 text-amber-200', light: 'bg-amber-100 text-amber-600' }, }; const SCALE_MIN = 0.3; const SCALE_MAX = 2.5; const buildExpertRootDetail = (expert: ExpertCard): string => { const segments = [expert.title, expert.detail].filter((item): item is string => Boolean(item && item.trim())); return segments.join(' · ') || '已确认专家'; }; const CompactNode: React.FC<{ node: LayoutNode; isDark: boolean; appearing: boolean; collapsible: boolean; collapsed: boolean; onToggleCollapse?: (nodeId: string) => void; }> = ({ node, isDark, appearing, collapsible, collapsed, onToggleCollapse }) => { const Icon = VARIANT_ICON[node.variant]; const grad = VARIANT_GRADIENT[node.variant][isDark ? 'dark' : 'light']; const canToggle = collapsible && Boolean(onToggleCollapse); const handleClick = (e: React.MouseEvent) => { if (!canToggle) return; e.stopPropagation(); onToggleCollapse(node.id); }; const handleMouseDown = (e: React.MouseEvent) => { if (canToggle) e.stopPropagation(); }; return ( {appearing && ( )}
{collapsible && (
{collapsed ? '+' : '-'}
)}
{VARIANT_ABBREV[node.variant]}
); }; const ToolbarButton: React.FC<{ title: string; onClick: () => void; isDark: boolean; children: React.ReactNode }> = ({ title, onClick, isDark, children }) => ( ); const SixStepFlowSandbox: React.FC = ({ message, width, onResizeStart, onClose, onOpenFullscreen }) => { const { theme } = useTheme(); const isDark = theme === 'dark'; const purposes = message?.stages?.purpose?.cards ?? []; const actors = message?.stages?.actor?.cards ?? []; const behaviors = message?.stages?.behavior?.cards ?? []; const narratives = message?.stages?.narrative?.cards ?? []; const selectedExpert = message?.selectedExpert; const layout = useMemo( () => buildLayout(purposes, actors, behaviors, narratives, { ...COMPACT_OPTIONS, rootTitle: selectedExpert?.name, rootDetail: selectedExpert ? buildExpertRootDetail(selectedExpert) : undefined, }), [purposes, actors, behaviors, narratives, selectedExpert], ); const currentSub = message?.currentSubStep; const activePair = currentSub ? CURRENT_PAIR_BY_STAGE[currentSub] : null; const statusTitle = currentSub ? STATUS_TITLE[currentSub] : '推演结果可视化'; const generating = Boolean(currentSub); const [transform, setTransform] = useState({ tx: 0, ty: 0, scale: 1 }); const [isPanning, setIsPanning] = useState(false); const [collapsedNodeIds, setCollapsedNodeIds] = useState>(new Set()); const containerRef = useRef(null); const panStart = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null); // Track newly appearing nodes for animation const seenIdsRef = useRef>(new Set()); const [appearingIds, setAppearingIds] = useState>(new Set()); const nodeMap = useMemo(() => { return new Map(layout.nodes.map((node) => [node.id, node])); }, [layout.nodes]); const visibleNodeSet = useMemo(() => { const visible = new Set(); layout.nodes.forEach((node) => { let parentId = node.parent; let hidden = false; while (parentId) { if (collapsedNodeIds.has(parentId)) { hidden = true; break; } parentId = nodeMap.get(parentId)?.parent; } if (!hidden) visible.add(node.id); }); return visible; }, [collapsedNodeIds, layout.nodes, nodeMap]); const visibleNodes = useMemo( () => layout.nodes.filter((node) => visibleNodeSet.has(node.id)), [layout.nodes, visibleNodeSet], ); const visibleEdges = useMemo( () => layout.edges.filter((edge) => visibleNodeSet.has(edge.fromId) && visibleNodeSet.has(edge.toId)), [layout.edges, visibleNodeSet], ); const toggleNodeCollapse = (nodeId: string) => { const targetNode = nodeMap.get(nodeId); if (!targetNode || targetNode.children.length === 0) return; setCollapsedNodeIds((prev) => { const next = new Set(prev); if (next.has(nodeId)) next.delete(nodeId); else next.add(nodeId); return next; }); }; useEffect(() => { const validIds = new Set( layout.nodes.filter((node) => node.children.length > 0).map((node) => node.id), ); setCollapsedNodeIds((prev) => { let changed = false; const next = new Set(); prev.forEach((id) => { if (validIds.has(id)) next.add(id); else changed = true; }); return changed ? next : prev; }); }, [layout.nodes]); useEffect(() => { const newIds: string[] = []; visibleNodes.forEach((n) => { if (!seenIdsRef.current.has(n.id)) { seenIdsRef.current.add(n.id); newIds.push(n.id); } }); if (newIds.length > 0) { setAppearingIds((prev) => { const next = new Set(prev); newIds.forEach((id) => next.add(id)); return next; }); const timer = window.setTimeout(() => { setAppearingIds((prev) => { const next = new Set(prev); newIds.forEach((id) => next.delete(id)); return next; }); }, 700); return () => window.clearTimeout(timer); } }, [visibleNodes]); // Native wheel listener with passive:false so preventDefault actually blocks browser zoom useEffect(() => { const container = containerRef.current; if (!container) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); const rect = container.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const delta = -e.deltaY * 0.0015; setTransform((prev) => { const nextScale = Math.max(SCALE_MIN, Math.min(SCALE_MAX, prev.scale * (1 + delta))); const ratio = nextScale / prev.scale; return { tx: mx - (mx - prev.tx) * ratio, ty: my - (my - prev.ty) * ratio, scale: nextScale }; }); }; container.addEventListener('wheel', onWheel, { passive: false }); return () => container.removeEventListener('wheel', onWheel); }, []); const handlePanStart = (e: React.MouseEvent) => { if (e.button !== 0) return; panStart.current = { x: e.clientX, y: e.clientY, tx: transform.tx, ty: transform.ty }; setIsPanning(true); }; useEffect(() => { if (!isPanning) return; const move = (e: MouseEvent) => { const start = panStart.current; if (!start) return; setTransform((prev) => ({ ...prev, tx: start.tx + (e.clientX - start.x), ty: start.ty + (e.clientY - start.y) })); }; const up = () => { panStart.current = null; setIsPanning(false); }; window.addEventListener('mousemove', move); window.addEventListener('mouseup', up); return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); }; }, [isPanning]); const fitToView = () => { const container = containerRef.current; if (!container || layout.width <= 0 || layout.height <= 0) { setTransform({ tx: 0, ty: 0, scale: 1 }); return; } const rect = container.getBoundingClientRect(); const scale = Math.min((rect.width - 40) / layout.width, (rect.height - 40) / layout.height, 1); const tx = (rect.width - layout.width * scale) / 2; const ty = (rect.height - layout.height * scale) / 2; setTransform({ tx, ty, scale }); }; const centerView = () => { const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); setTransform((prev) => ({ ...prev, tx: (rect.width - layout.width * prev.scale) / 2, ty: (rect.height - layout.height * prev.scale) / 2, })); }; const zoomIn = () => setTransform((prev) => ({ ...prev, scale: Math.min(SCALE_MAX, prev.scale * 1.2) })); const zoomOut = () => setTransform((prev) => ({ ...prev, scale: Math.max(SCALE_MIN, prev.scale / 1.2) })); const reset = () => setTransform({ tx: 0, ty: 0, scale: 1 }); // Auto-fit on first render with content const fitAppliedRef = useRef(false); useEffect(() => { if (!fitAppliedRef.current && layout.nodes.length > 1) { fitToView(); fitAppliedRef.current = true; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [layout.nodes.length]); return (
{statusTitle}
简化视图 · 节点缩写显示
{Math.round(transform.scale * 100)}%
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => { const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light']; const active = activePair === pair; return ( {EDGE_LABEL[pair]} ); })}
{visibleNodes.length <= 1 ? (
等待推演开始
推演产出将以流程图形式呈现
) : ( {(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => { const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light']; return ( ); })} {visibleEdges.map((edge) => { const from = nodeMap.get(edge.fromId); const to = nodeMap.get(edge.toId); if (!from || !to) return null; const color = EDGE_COLOR[edge.pair][isDark ? 'dark' : 'light']; const isActive = activePair === edge.pair; return ( ); })} {visibleNodes.map((node) => ( 0} collapsed={collapsedNodeIds.has(node.id)} onToggleCollapse={toggleNodeCollapse} /> ))} )}
{VARIANT_LABEL.purpose}=md · {VARIANT_LABEL.actor}=xwt · {VARIANT_LABEL.behavior}=yqxw · {VARIANT_LABEL.driver}=qdys · {VARIANT_LABEL.narrative}=xs
点击父节点可收起/展开子节点
); }; export default SixStepFlowSandbox;