Files
deerflow-code/web-code/components/angentchat/SixStepFlowFullscreen.tsx
T
2026-05-28 18:29:37 +08:00

654 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Bot,
Compass,
Crosshair,
Download,
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_LABEL,
buildEdgePath,
buildLayout,
type LayoutEdge,
type LayoutNode,
} from './flowLayout';
interface SixStepFlowFullscreenProps {
message?: SixStepMessage;
onClose: () => void;
}
const SCALE_MIN = 0.2;
const SCALE_MAX = 3;
const buildExpertRootDetail = (expert: ExpertCard): string => {
const segments = [expert.title, expert.detail].filter((item): item is string => Boolean(item && item.trim()));
return segments.join(' · ') || '已确认专家';
};
const VARIANT_TOKEN: Record<LayoutNode['variant'], {
dark: string; light: string;
icon: React.ComponentType<{ className?: string }>;
iconCls: { dark: string; light: string };
}> = {
agent: { dark: 'from-slate-700/80 to-slate-900/80 border-slate-500/60', light: 'from-white to-slate-50 border-slate-300', icon: Bot, iconCls: { dark: 'bg-slate-500/30 text-slate-200', light: 'bg-slate-100 text-slate-600' } },
purpose: { dark: 'from-violet-700/40 to-violet-900/40 border-violet-400/50', light: 'from-orange-50 to-white border-orange-300', icon: Target, iconCls: { dark: 'bg-violet-500/30 text-violet-200', light: 'bg-orange-100 text-orange-600' } },
actor: { dark: 'from-cyan-700/40 to-cyan-900/40 border-cyan-400/50', light: 'from-red-50 to-white border-red-300', icon: UserCheck, iconCls: { dark: 'bg-cyan-500/30 text-cyan-200', light: 'bg-red-100 text-red-600' } },
behavior: { dark: 'from-emerald-700/40 to-emerald-900/40 border-emerald-400/50', light: 'from-emerald-50 to-white border-emerald-300', icon: TrendingUp,iconCls: { dark: 'bg-emerald-500/30 text-emerald-200', light: 'bg-emerald-100 text-emerald-700' } },
driver: { dark: 'from-emerald-800/40 to-teal-900/40 border-teal-400/50', light: 'from-teal-50 to-white border-teal-300', icon: Zap, iconCls: { dark: 'bg-teal-500/30 text-teal-200', light: 'bg-teal-100 text-teal-700' } },
narrative: { dark: 'from-amber-700/40 to-amber-900/40 border-amber-400/50', light: 'from-amber-50 to-white border-amber-300', icon: ScrollText,iconCls: { dark: 'bg-amber-500/30 text-amber-200', light: 'bg-amber-100 text-amber-600' } },
};
const MINIMAP_VARIANT_FILL: Record<LayoutNode['variant'], { dark: string; light: string }> = {
agent: { dark: '#475569', light: '#cbd5e1' },
purpose: { dark: '#7c3aed', light: '#fb923c' },
actor: { dark: '#06b6d4', light: '#ef4444' },
behavior: { dark: '#10b981', light: '#10b981' },
driver: { dark: '#0d9488', light: '#0d9488' },
narrative: { dark: '#f59e0b', light: '#f59e0b' },
};
const DetailNode: React.FC<{
node: LayoutNode;
isDark: boolean;
collapsible: boolean;
collapsed: boolean;
onToggleCollapse?: (nodeId: string) => void;
entering: boolean;
exiting: boolean;
}> = ({ node, isDark, collapsible, collapsed, onToggleCollapse, entering, exiting }) => {
const token = VARIANT_TOKEN[node.variant];
const Icon = token.icon;
const grad = isDark ? token.dark : token.light;
const canToggle = collapsible && Boolean(onToggleCollapse);
const handleClick = (e: React.MouseEvent<SVGGElement>) => {
if (!canToggle) return;
e.stopPropagation();
onToggleCollapse?.(node.id);
};
const handleMouseDown = (e: React.MouseEvent<SVGGElement>) => {
if (canToggle) e.stopPropagation();
};
return (
<g
onMouseDown={handleMouseDown}
onClick={handleClick}
style={{
cursor: canToggle ? 'pointer' : undefined,
opacity: exiting ? 0 : 1,
transform: entering || exiting ? 'translateY(8px)' : 'translateY(0)',
transformOrigin: `${node.x + node.width / 2}px ${node.y + node.height / 2}px`,
transition: `opacity ${COLLAPSE_TRANSITION_MS}ms ease, transform ${COLLAPSE_TRANSITION_MS}ms ease`,
}}
>
<foreignObject x={node.x} y={node.y} width={node.width} height={node.height}>
<div className={`relative w-full h-full rounded-2xl border bg-gradient-to-br ${grad} backdrop-blur-sm p-2.5 shadow-sm overflow-hidden`}>
{collapsible && (
<div
className={`absolute right-2 top-2 w-4 h-4 rounded-full flex items-center justify-center text-[10px] font-bold ${
isDark ? 'bg-slate-800/75 text-slate-200' : 'bg-white text-slate-600 border border-slate-200'
}`}
title={collapsed ? '展开子节点' : '收起子节点'}
>
{collapsed ? '+' : '-'}
</div>
)}
<div className="flex items-center gap-1.5 mb-1">
<div className={`w-5 h-5 rounded-md flex items-center justify-center ${token.iconCls[isDark ? 'dark' : 'light']}`}>
<Icon className="w-3 h-3" />
</div>
<span className={`text-[10px] uppercase tracking-wider font-semibold ${isDark ? 'text-white/70' : 'text-slate-500'}`}>
{VARIANT_LABEL[node.variant]}
</span>
</div>
<div className={`text-xs font-semibold leading-tight line-clamp-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>
{node.title}
</div>
<div className={`text-[11px] leading-snug line-clamp-2 mt-0.5 ${isDark ? 'text-slate-300' : 'text-slate-600'}`}>
{node.detail || '—'}
</div>
</div>
</foreignObject>
</g>
);
};
const ToolbarButton: React.FC<{ title: string; onClick: () => void; isDark: boolean; children: React.ReactNode }> = ({ title, onClick, isDark, children }) => (
<button
type="button"
title={title}
onClick={onClick}
className={`sixstep-toolbar-btn ${
isDark ? 'text-slate-300 hover:bg-slate-700/70 hover:text-white' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}`}
>
{children}
</button>
);
interface MinimapProps {
layout: { nodes: LayoutNode[]; width: number; height: number };
transform: { tx: number; ty: number; scale: number };
viewportSize: { width: number; height: number };
onJump: (worldX: number, worldY: number) => void;
isDark: boolean;
}
const MINIMAP_W = 220;
const MINIMAP_H = 150;
const COLLAPSE_TRANSITION_MS = 240;
const Minimap: React.FC<MinimapProps> = ({ layout, transform, viewportSize, onJump, isDark }) => {
const ratio = layout.width > 0 && layout.height > 0
? Math.min((MINIMAP_W - 10) / layout.width, (MINIMAP_H - 10) / layout.height)
: 0.1;
const renderW = layout.width * ratio;
const renderH = layout.height * ratio;
const offsetX = (MINIMAP_W - renderW) / 2;
const offsetY = (MINIMAP_H - renderH) / 2;
// Visible area (in world coords): from world (-tx/scale, -ty/scale) with size viewportW/scale × viewportH/scale
const viewW = viewportSize.width / transform.scale;
const viewH = viewportSize.height / transform.scale;
const viewX = -transform.tx / transform.scale;
const viewY = -transform.ty / transform.scale;
const handleClick = (e: React.MouseEvent<SVGSVGElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const mx = e.clientX - rect.left - offsetX;
const my = e.clientY - rect.top - offsetY;
const wx = mx / ratio;
const wy = my / ratio;
onJump(wx, wy);
};
return (
<div className={`rounded-xl overflow-hidden border shadow-lg backdrop-blur-md ${
isDark ? 'bg-slate-900/80 border-slate-700/70' : 'bg-white/90 border-gray-200'
}`}>
<div className={`px-3 py-1.5 text-[10px] uppercase tracking-wider border-b font-medium ${
isDark ? 'text-slate-400 border-slate-700/50' : 'text-gray-500 border-gray-100'
}`}>
缩略图
</div>
<svg
width={MINIMAP_W}
height={MINIMAP_H}
onClick={handleClick}
style={{ cursor: 'pointer' }}
>
<rect width={MINIMAP_W} height={MINIMAP_H} fill={isDark ? '#0f172a' : '#f8fafc'} />
<g transform={`translate(${offsetX}, ${offsetY}) scale(${ratio})`}>
{layout.nodes.map((n) => (
<rect
key={n.id}
x={n.x}
y={n.y}
width={n.width}
height={n.height}
rx={6}
fill={MINIMAP_VARIANT_FILL[n.variant][isDark ? 'dark' : 'light']}
opacity={0.75}
/>
))}
</g>
<rect
className="sixstep-minimap-rect"
x={offsetX + viewX * ratio}
y={offsetY + viewY * ratio}
width={Math.max(6, viewW * ratio)}
height={Math.max(6, viewH * ratio)}
rx={3}
/>
</svg>
</div>
);
};
const SixStepFlowFullscreen: React.FC<SixStepFlowFullscreenProps> = ({ message, onClose }) => {
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, {
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 [viewportSize, setViewportSize] = useState({ width: 800, height: 600 });
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set());
const [displayedNodeIds, setDisplayedNodeIds] = useState<Set<string>>(new Set());
const [enteringNodeIds, setEnteringNodeIds] = useState<Set<string>>(new Set());
const [exitingNodeIds, setExitingNodeIds] = useState<Set<string>>(new Set());
const [displayedEdgeIds, setDisplayedEdgeIds] = useState<Set<string>>(new Set());
const [exitingEdgeIds, setExitingEdgeIds] = useState<Set<string>>(new Set());
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const panStart = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
const transitionTimerRef = useRef<number | null>(null);
const nodeMap = useMemo(() => new Map(layout.nodes.map((node) => [node.id, node])), [layout.nodes]);
const targetVisibleNodeSet = useMemo(() => {
const visible = new Set<string>();
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 targetVisibleEdgeSet = useMemo(() => {
const visible = new Set<string>();
layout.edges.forEach((edge) => {
if (targetVisibleNodeSet.has(edge.fromId) && targetVisibleNodeSet.has(edge.toId)) {
visible.add(edge.id);
}
});
return visible;
}, [layout.edges, targetVisibleNodeSet]);
const renderedNodeSet = displayedNodeIds.size > 0 ? displayedNodeIds : targetVisibleNodeSet;
const renderedEdgeSet = displayedEdgeIds.size > 0 ? displayedEdgeIds : targetVisibleEdgeSet;
const renderedNodes = useMemo(
() => layout.nodes.filter((node) => renderedNodeSet.has(node.id)),
[layout.nodes, renderedNodeSet],
);
const renderedEdges = useMemo(
() => layout.edges.filter((edge) => renderedEdgeSet.has(edge.id)),
[layout.edges, renderedEdgeSet],
);
const toggleNodeCollapse = (nodeId: string) => {
const node = nodeMap.get(nodeId);
if (!node || node.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 update = () => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setViewportSize({ width: rect.width, height: rect.height });
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
// 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) return;
const rect = container.getBoundingClientRect();
const scale = Math.min((rect.width - 80) / Math.max(layout.width, 1), (rect.height - 80) / Math.max(layout.height, 1), 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 });
const fitAppliedRef = useRef(false);
useEffect(() => {
if (!fitAppliedRef.current && viewportSize.width > 0 && layout.nodes.length > 1) {
fitToView();
fitAppliedRef.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewportSize.width, layout.nodes.length]);
const handleMinimapJump = (wx: number, wy: number) => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
setTransform((prev) => ({
...prev,
tx: rect.width / 2 - wx * prev.scale,
ty: rect.height / 2 - wy * prev.scale,
}));
};
const handleDownload = () => {
const svg = svgRef.current;
if (!svg) return;
const cloned = svg.cloneNode(true) as SVGSVGElement;
cloned.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
cloned.setAttribute('width', String(layout.width));
cloned.setAttribute('height', String(layout.height));
cloned.setAttribute('viewBox', `0 0 ${layout.width} ${layout.height}`);
const inner = cloned.querySelector('g');
if (inner) inner.setAttribute('transform', '');
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(cloned);
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `6步法推演-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.svg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
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<string>();
prev.forEach((id) => {
if (validIds.has(id)) next.add(id);
else changed = true;
});
return changed ? next : prev;
});
}, [layout.nodes]);
useEffect(() => {
if (transitionTimerRef.current) {
window.clearTimeout(transitionTimerRef.current);
transitionTimerRef.current = null;
}
const prevNodes = displayedNodeIds;
const nextNodes = targetVisibleNodeSet;
const prevEdges = displayedEdgeIds;
const nextEdges = targetVisibleEdgeSet;
const enteringNodes = new Set([...nextNodes].filter((id) => !prevNodes.has(id)));
const leavingNodes = new Set([...prevNodes].filter((id) => !nextNodes.has(id)));
const leavingEdges = new Set([...prevEdges].filter((id) => !nextEdges.has(id)));
const mergedNodes = new Set([...prevNodes, ...nextNodes]);
const mergedEdges = new Set([...prevEdges, ...nextEdges]);
setEnteringNodeIds(enteringNodes);
setExitingNodeIds(leavingNodes);
setExitingEdgeIds(leavingEdges);
setDisplayedNodeIds(mergedNodes);
setDisplayedEdgeIds(mergedEdges);
transitionTimerRef.current = window.setTimeout(() => {
setDisplayedNodeIds(new Set(nextNodes));
setDisplayedEdgeIds(new Set(nextEdges));
setEnteringNodeIds(new Set());
setExitingNodeIds(new Set());
setExitingEdgeIds(new Set());
transitionTimerRef.current = null;
}, COLLAPSE_TRANSITION_MS);
}, [targetVisibleNodeSet, targetVisibleEdgeSet]);
useEffect(() => {
return () => {
if (transitionTimerRef.current) window.clearTimeout(transitionTimerRef.current);
};
}, []);
return (
<div className={`fixed inset-0 z-50 flex flex-col ${isDark ? 'bg-slate-950/95' : 'bg-white/95'} backdrop-blur-sm`}>
<div className={`flex items-center justify-between gap-3 px-5 py-3 border-b ${
isDark ? 'border-slate-700/60 bg-slate-900/70' : 'border-red-100 bg-white'
}`}>
<div className="flex items-center gap-3 min-w-0">
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${
isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
} ${generating ? 'sixstep-pulse' : ''}`}>
<Compass className="w-5 h-5" />
</div>
<div className="min-w-0">
<div className={`text-base font-semibold ${
generating ? (isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light') : (isDark ? 'text-white' : 'text-gray-900')
}`}>
{statusTitle}
</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
全屏视图 · 完整内容展示
</div>
</div>
</div>
<div className="flex items-center gap-1">
<ToolbarButton title="放大" onClick={zoomIn} isDark={isDark}><Plus className="w-4 h-4" /></ToolbarButton>
<ToolbarButton title="缩小" onClick={zoomOut} isDark={isDark}><Minus className="w-4 h-4" /></ToolbarButton>
<ToolbarButton title="恢复 1:1" onClick={reset} isDark={isDark}><RotateCcw className="w-4 h-4" /></ToolbarButton>
<ToolbarButton title="定位到画布中央" onClick={centerView} isDark={isDark}><Crosshair className="w-4 h-4" /></ToolbarButton>
<ToolbarButton title="下载流程图(SVG)" onClick={handleDownload} isDark={isDark}><Download className="w-4 h-4" /></ToolbarButton>
<span className={`mx-2 h-5 w-px ${isDark ? 'bg-slate-700' : 'bg-gray-200'}`} />
<span className={`text-[11px] tabular-nums ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{Math.round(transform.scale * 100)}%
</span>
<ToolbarButton title="关闭全屏 (Esc)" onClick={onClose} isDark={isDark}>
<X className="w-4 h-4" />
</ToolbarButton>
</div>
</div>
<div className={`flex items-center gap-3 px-5 py-2 border-b text-[11px] flex-wrap ${
isDark ? 'border-slate-700/60 text-slate-400 bg-slate-900/40' : 'border-red-100 text-gray-500 bg-white'
}`}>
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
const active = activePair === pair;
return (
<span key={pair} className="inline-flex items-center gap-1.5">
<span className={`inline-block w-6 h-0.5 rounded-full ${active ? 'sixstep-ant-line' : ''}`} style={{ background: color, stroke: color }} />
<span className={active ? (isDark ? 'text-cyan-200' : 'text-red-600') : ''}>{EDGE_LABEL[pair]}</span>
</span>
);
})}
</div>
<div
ref={containerRef}
className={`flex-1 overflow-hidden relative ${isPanning ? 'sixstep-canvas-grabbing' : 'sixstep-canvas-grab'}`}
onMouseDown={handlePanStart}
>
{renderedNodes.length <= 1 ? (
<div className={`h-full flex flex-col items-center justify-center text-center ${
isDark ? 'text-slate-500' : 'text-gray-400'
}`}>
<Megaphone className="w-12 h-12 opacity-40 mb-3" />
<div className={`text-base font-medium ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>等待推演开始</div>
<div className="text-sm mt-1">推演产出将以流程图形式呈现</div>
</div>
) : (
<svg ref={svgRef} width="100%" height="100%" className="block select-none">
<defs>
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
return (
<marker
key={pair}
id={`sixstep-fs-arrow-${pair}`}
viewBox="0 0 8 8"
refX="7"
refY="4"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 8 4 L 0 8 z" fill={color} />
</marker>
);
})}
</defs>
<g transform={`translate(${transform.tx}, ${transform.ty}) scale(${transform.scale})`}>
{renderedEdges.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;
const exiting = exitingEdgeIds.has(edge.id);
return (
<path
key={edge.id}
d={buildEdgePath(from, to)}
fill="none"
stroke={color}
strokeWidth={isActive ? 2.4 : 1.8}
strokeOpacity={exiting ? 0 : isActive ? 1 : 0.75}
className={isActive ? 'sixstep-ant-line' : ''}
markerEnd={`url(#sixstep-fs-arrow-${edge.pair})`}
style={{
opacity: exiting ? 0 : 1,
transition: `opacity ${COLLAPSE_TRANSITION_MS}ms ease`,
}}
/>
);
})}
{renderedNodes.map((node) => (
<DetailNode
key={node.id}
node={node}
isDark={isDark}
collapsible={node.children.length > 0}
collapsed={collapsedNodeIds.has(node.id)}
onToggleCollapse={toggleNodeCollapse}
entering={enteringNodeIds.has(node.id)}
exiting={exitingNodeIds.has(node.id)}
/>
))}
</g>
</svg>
)}
{targetVisibleNodeSet.size > 1 && (
<div className="absolute bottom-4 right-4">
<Minimap
layout={{
nodes: layout.nodes.filter((node) => targetVisibleNodeSet.has(node.id)),
width: layout.width,
height: layout.height,
}}
transform={transform}
viewportSize={viewportSize}
onJump={handleMinimapJump}
isDark={isDark}
/>
</div>
)}
<div className={`absolute bottom-4 left-4 px-2 py-1 rounded-md text-[10px] backdrop-blur-md ${
isDark ? 'bg-slate-800/70 text-slate-400 border border-slate-700/50' : 'bg-white/90 text-gray-500 border border-gray-200'
}`}>
点击父节点可收起/展开子节点
</div>
</div>
</div>
);
};
export default SixStepFlowFullscreen;