Files
deerflow-code/frontend-web/src/components/workspace/visualizations/mindmap-renderer.tsx
T

683 lines
24 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 { useCallback, useEffect, useMemo, useState } from "react";
import {
Background,
BackgroundVariant,
Handle,
Panel,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type Edge,
type Node,
type NodeProps,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
Download,
FileJson,
ImageDown,
LocateFixed,
Maximize2,
Minus,
Plus,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { renderSvgElementToPngBlob, triggerBlobDownload } from "@/core/utils/mermaid";
import { cn } from "@/lib/utils";
import { VisualizationCard } from "./visualization-card";
import type { MindmapNodeKind, MindmapSpec } from "./visualization-spec";
const NODE_WIDTH = 184;
const NODE_HEIGHT = 68;
const LAYER_GAP = 244;
const ROW_GAP = 88;
const PREVIEW_DEPTH = 2;
const KIND_META: Record<MindmapNodeKind, { label: string; className: string }> = {
topic: { label: "主题", className: "border-primary/50 bg-primary/10" },
dimension: { label: "维度", className: "border-sky-500/45 bg-sky-500/10" },
finding: { label: "发现", className: "border-violet-500/45 bg-violet-500/10" },
evidence: { label: "依据", className: "border-emerald-500/45 bg-emerald-500/10" },
risk: { label: "风险", className: "border-amber-500/50 bg-amber-500/10" },
action: { label: "建议", className: "border-rose-500/45 bg-rose-500/10" },
};
type MindmapNode = MindmapSpec["nodes"][number];
type LayoutNode = MindmapNode & {
depth: number;
x: number;
y: number;
hiddenCount: number;
hasChildren: boolean;
collapsed: boolean;
};
type MindmapLayout = {
nodes: LayoutNode[];
edges: Array<{ id: string; from: string; to: string }>;
width: number;
height: number;
};
function childrenByParent(spec: MindmapSpec) {
const children = new Map<string, MindmapNode[]>();
for (const node of spec.nodes) {
if (node.parentId === null) continue;
const list = children.get(node.parentId) ?? [];
list.push(node);
children.set(node.parentId, list);
}
return children;
}
function getRoot(spec: MindmapSpec): MindmapNode {
return spec.nodes.find((node) => node.parentId === null)!;
}
function descendantsCount(id: string, children: Map<string, MindmapNode[]>): number {
let count = 0;
for (const child of children.get(id) ?? []) {
count += 1 + descendantsCount(child.id, children);
}
return count;
}
/**
* A rooted analysis tree uses a left-to-right layout in messages. It reads more
* naturally than a radial graph inside a narrow chat column and stays stable
* when the same persisted message is replayed.
*/
function layoutMindmap(
spec: MindmapSpec,
maxDepth?: number,
collapsedIds: ReadonlySet<string> = new Set(),
): MindmapLayout {
const children = childrenByParent(spec);
const root = getRoot(spec);
const visible = new Map<string, { node: MindmapNode; depth: number }>();
const collect = (node: MindmapNode, depth: number) => {
visible.set(node.id, { node, depth });
if ((maxDepth !== undefined && depth >= maxDepth) || collapsedIds.has(node.id)) return;
for (const child of children.get(node.id) ?? []) collect(child, depth + 1);
};
collect(root, 0);
const unitHeight = (id: string): number => {
const childIds = (children.get(id) ?? []).filter((child) => visible.has(child.id));
if (childIds.length === 0) return 1;
return childIds.reduce((sum, child) => sum + unitHeight(child.id), 0);
};
const positions = new Map<string, { x: number; y: number }>();
const assign = (id: string, depth: number, top: number): number => {
const ownUnits = unitHeight(id);
positions.set(id, {
x: depth * LAYER_GAP,
y: top + ((ownUnits - 1) * ROW_GAP) / 2,
});
let nextTop = top;
for (const child of children.get(id) ?? []) {
if (!visible.has(child.id)) continue;
nextTop = assign(child.id, depth + 1, nextTop) + ROW_GAP;
}
return top + (ownUnits - 1) * ROW_GAP;
};
assign(root.id, 0, 0);
const nodes = [...visible.values()].map(({ node, depth }) => ({
...node,
depth,
x: positions.get(node.id)?.x ?? 0,
y: positions.get(node.id)?.y ?? 0,
hiddenCount:
(maxDepth !== undefined && depth === maxDepth) || collapsedIds.has(node.id)
? descendantsCount(node.id, children)
: 0,
hasChildren: (children.get(node.id) ?? []).length > 0,
collapsed: collapsedIds.has(node.id),
}));
const nodeIds = new Set(nodes.map((node) => node.id));
const edges = nodes
.filter((node) => node.parentId && nodeIds.has(node.parentId))
.map((node) => ({ id: `${node.parentId}->${node.id}`, from: node.parentId!, to: node.id }));
const maxX = Math.max(...nodes.map((node) => node.x), 0);
const maxY = Math.max(...nodes.map((node) => node.y), 0);
return {
nodes,
edges,
width: maxX + NODE_WIDTH + 64,
height: maxY + NODE_HEIGHT + 64,
};
}
type FlowNodeData = {
node: LayoutNode;
selected: boolean;
onToggle?: (id: string) => void;
};
function MindmapFlowNode({ data }: NodeProps) {
const { node, selected, onToggle } = data as FlowNodeData;
const meta = KIND_META[node.kind];
return (
<div
className={cn(
"relative w-[184px] rounded-xl border bg-background px-3 py-2 shadow-sm transition-shadow",
"hover:shadow-md focus-within:ring-2 focus-within:ring-primary/50",
meta.className,
selected && "ring-2 ring-primary ring-offset-2 ring-offset-background",
)}
title={node.label}
>
{node.parentId ? <Handle type="target" position={Position.Left} className="!size-1.5 !border-0 !bg-border" /> : null}
<div className="mb-1 flex items-center gap-1.5 text-[11px] leading-none text-muted-foreground">
<span className="font-medium">{meta.label}</span>
{node.hiddenCount > 0 ? (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
+{node.hiddenCount}
</span>
) : null}
</div>
<div className="line-clamp-2 text-sm font-medium leading-5 text-foreground">{node.label}</div>
{node.hasChildren ? (
<>
<Handle type="source" position={Position.Right} className="!size-6 !border-0 !bg-transparent" />
<button
type="button"
className="absolute -right-3 top-1/2 z-20 flex size-6 -translate-y-1/2 items-center justify-center rounded-full border border-slate-400 bg-background text-slate-600 shadow-sm transition-colors hover:border-primary hover:bg-primary hover:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={`${node.collapsed ? "展开" : "收起"}${node.label}`}
aria-expanded={!node.collapsed}
title={node.collapsed ? "展开子节点" : "收起子节点"}
onClick={(event) => {
event.stopPropagation();
onToggle?.(node.id);
}}
>
{node.collapsed ? <Plus aria-hidden="true" className="size-3.5" /> : <Minus aria-hidden="true" className="size-3.5" />}
</button>
</>
) : null}
</div>
);
}
const NODE_TYPES = { mindmap: MindmapFlowNode };
function MindmapCanvasControls({
compact,
onClose,
}: {
compact: boolean;
onClose?: () => void;
}) {
const rf = useReactFlow();
const buttonClassName = compact ? "size-7" : "size-8";
const padding = compact ? 0.16 : 0.12;
return (
<Panel position="top-right" className="flex items-center gap-1 rounded-lg border bg-background/95 p-1 shadow-sm backdrop-blur">
<Button
type="button"
variant="ghost"
size="icon"
className={buttonClassName}
title="放大"
aria-label="放大思维导图"
onClick={() => void rf.zoomIn({ duration: 150 })}
>
<Plus aria-hidden="true" className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={buttonClassName}
title="缩小"
aria-label="缩小思维导图"
onClick={() => void rf.zoomOut({ duration: 150 })}
>
<Minus aria-hidden="true" className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={buttonClassName}
title="重置位置与缩放"
aria-label="重置思维导图位置与缩放"
onClick={() => void rf.fitView({ duration: 220, padding })}
>
<LocateFixed aria-hidden="true" className="size-4" />
</Button>
{onClose ? (
<>
<span aria-hidden="true" className="mx-0.5 h-4 w-px bg-border" />
<Button
type="button"
variant="ghost"
size="icon"
className={buttonClassName}
title="关闭"
aria-label="关闭思维导图"
onClick={onClose}
>
<X aria-hidden="true" className="size-4" />
</Button>
</>
) : null}
</Panel>
);
}
function overviewNodeColor(kind: MindmapNodeKind): string {
return svgColors(kind).fill;
}
function MindmapOverview({ layout }: { layout: MindmapLayout }) {
const nodesById = new Map(layout.nodes.map((node) => [node.id, node]));
return (
<Panel position="bottom-right" className="overflow-hidden rounded-lg border bg-background/95 p-1 shadow-md backdrop-blur">
<div className="mb-1 px-1.5 pt-0.5 text-[10px] font-medium text-muted-foreground">全图预览</div>
<svg
className="block h-28 w-44 rounded bg-muted/30"
viewBox={`0 0 ${layout.width} ${layout.height}`}
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="思维导图全图预览"
>
{layout.edges.map((edge) => {
const from = nodesById.get(edge.from);
const to = nodesById.get(edge.to);
if (!from || !to) return null;
return (
<path
key={edge.id}
d={`M ${from.x + NODE_WIDTH} ${from.y + NODE_HEIGHT / 2} L ${to.x} ${to.y + NODE_HEIGHT / 2}`}
fill="none"
stroke="#64748b"
strokeWidth="4"
strokeOpacity="0.8"
/>
);
})}
{layout.nodes.map((node) => (
<rect
key={node.id}
x={node.x}
y={node.y}
width={NODE_WIDTH}
height={NODE_HEIGHT}
rx="10"
fill={overviewNodeColor(node.kind)}
stroke="#94a3b8"
strokeWidth="2"
/>
))}
</svg>
</Panel>
);
}
function MindmapCanvas({
spec,
preview,
selectedId,
onSelect,
onClose,
}: {
spec: MindmapSpec;
preview: boolean;
selectedId?: string | null;
onSelect?: (id: string) => void;
onClose?: () => void;
}) {
const rf = useReactFlow();
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(() => new Set());
const toggleNode = useCallback((id: string) => {
setCollapsedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const layout = useMemo(
() => layoutMindmap(spec, preview ? PREVIEW_DEPTH : undefined, collapsedIds),
[collapsedIds, preview, spec],
);
const nodes = useMemo<Node[]>(
() =>
layout.nodes.map((node) => ({
id: node.id,
type: "mindmap",
position: { x: node.x, y: node.y },
width: NODE_WIDTH,
height: NODE_HEIGHT,
data: { node, selected: node.id === selectedId, onToggle: toggleNode } satisfies FlowNodeData,
})),
[layout.nodes, selectedId, toggleNode],
);
const edges = useMemo<Edge[]>(
() =>
layout.edges.map((edge) => ({
id: edge.id,
source: edge.from,
target: edge.to,
type: "smoothstep",
style: { stroke: "#64748b", strokeWidth: 2, strokeOpacity: 0.8 },
})),
[layout.edges],
);
const handleNodeClick = useCallback((id: string) => {
const node = layout.nodes.find((item) => item.id === id);
if (node?.hasChildren) {
toggleNode(id);
return;
}
onSelect?.(id);
}, [layout.nodes, onSelect, toggleNode]);
useEffect(() => {
const timer = window.setTimeout(() => {
void rf.fitView({ padding: preview ? 0.16 : 0.12, duration: 180 });
}, 0);
return () => window.clearTimeout(timer);
}, [collapsedIds, preview, rf, spec]);
return (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
fitView
fitViewOptions={{ padding: preview ? 0.16 : 0.12 }}
minZoom={0.12}
maxZoom={2}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={Boolean(onSelect)}
panOnDrag
panOnScroll={false}
zoomOnScroll
zoomOnDoubleClick
zoomOnPinch
preventScrolling
onNodeClick={(_, node) => handleNodeClick(node.id)}
aria-label={`${spec.title}思维导图`}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={18} size={1} color="var(--border)" />
<MindmapCanvasControls compact={preview} onClose={onClose} />
{!preview ? <MindmapOverview layout={layout} /> : null}
</ReactFlow>
);
}
function svgEscape(value: string): string {
return value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[char]!);
}
function splitSvgLabel(label: string): string[] {
const chunks = label.match(/.{1,16}/gu) ?? [label];
return chunks.slice(0, 2);
}
function buildMindmapSvg(spec: MindmapSpec): string {
const layout = layoutMindmap(spec);
const offset = 32;
const lines = layout.edges
.map((edge) => {
const from = layout.nodes.find((node) => node.id === edge.from)!;
const to = layout.nodes.find((node) => node.id === edge.to)!;
const x1 = from.x + NODE_WIDTH + offset;
const y1 = from.y + NODE_HEIGHT / 2 + offset;
const x2 = to.x + offset;
const y2 = to.y + NODE_HEIGHT / 2 + offset;
const control = Math.max(42, (x2 - x1) / 2);
return `<path d="M ${x1} ${y1} C ${x1 + control} ${y1}, ${x2 - control} ${y2}, ${x2} ${y2}" fill="none" stroke="#94a3b8" stroke-width="1.5"/>`;
})
.join("");
const nodes = layout.nodes
.map((node) => {
const meta = KIND_META[node.kind];
const { fill, stroke } = svgColors(node.kind);
const lines = splitSvgLabel(node.label)
.map((line, index) => `<tspan x="${node.x + offset + 14}" dy="${index === 0 ? 0 : 18}">${svgEscape(line)}</tspan>`)
.join("");
return [
`<g>`,
`<rect x="${node.x + offset}" y="${node.y + offset}" width="${NODE_WIDTH}" height="${NODE_HEIGHT}" rx="12" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>`,
`<text x="${node.x + offset + 14}" y="${node.y + offset + 18}" font-family="Microsoft YaHei, Arial, sans-serif" font-size="10" fill="#64748b">${meta.label}</text>`,
`<text x="${node.x + offset + 14}" y="${node.y + offset + 42}" font-family="Microsoft YaHei, Arial, sans-serif" font-size="13" font-weight="600" fill="#0f172a">${lines}</text>`,
`</g>`,
].join("");
})
.join("");
return `<svg xmlns="http://www.w3.org/2000/svg" width="${layout.width + offset}" height="${layout.height + offset}" viewBox="0 0 ${layout.width + offset} ${layout.height + offset}" role="img" aria-label="${svgEscape(spec.title)}"><rect width="100%" height="100%" fill="#ffffff"/><text x="32" y="26" font-family="Microsoft YaHei, Arial, sans-serif" font-size="16" font-weight="700" fill="#0f172a">${svgEscape(spec.title)}</text>${lines}${nodes}</svg>`;
}
function svgColors(kind: MindmapNodeKind): { fill: string; stroke: string } {
switch (kind) {
case "topic": return { fill: "#eff6ff", stroke: "#60a5fa" };
case "dimension": return { fill: "#f0f9ff", stroke: "#38bdf8" };
case "evidence": return { fill: "#ecfdf5", stroke: "#34d399" };
case "risk": return { fill: "#fffbeb", stroke: "#fbbf24" };
case "action": return { fill: "#fff1f2", stroke: "#fb7185" };
default: return { fill: "#f5f3ff", stroke: "#a78bfa" };
}
}
function safeDownloadName(title: string, extension: string): string {
const clean = title.replace(/[\\/:*?"<>|]/g, "-").trim().slice(0, 80) || "分析思维导图";
return `${clean}.${extension}`;
}
function MindmapDownloads({ spec }: { spec: MindmapSpec }) {
const [downloading, setDownloading] = useState<"png" | "svg" | "json" | null>(null);
const [open, setOpen] = useState(false);
const download = useCallback(async (format: "png" | "svg" | "json") => {
setDownloading(format);
try {
if (format === "json") {
triggerBlobDownload(
new Blob([JSON.stringify(spec, null, 2)], { type: "application/json;charset=utf-8" }),
safeDownloadName(spec.title, "mindmap.json"),
);
setOpen(false);
return;
}
const svgMarkup = buildMindmapSvg(spec);
if (format === "svg") {
triggerBlobDownload(
new Blob([svgMarkup], { type: "image/svg+xml;charset=utf-8" }),
safeDownloadName(spec.title, "svg"),
);
setOpen(false);
return;
}
const host = document.createElement("div");
host.style.cssText = "position:fixed;left:-10000px;top:-10000px;pointer-events:none;opacity:0";
host.innerHTML = svgMarkup;
document.body.appendChild(host);
try {
const svg = host.querySelector("svg");
if (!(svg instanceof SVGSVGElement)) throw new Error("无法生成思维导图图片");
const blob = await renderSvgElementToPngBlob(svg);
triggerBlobDownload(blob, safeDownloadName(spec.title, "png"));
setOpen(false);
} finally {
host.remove();
}
} catch (error) {
console.error("Failed to download mindmap", error);
toast.error(error instanceof Error ? error.message : "下载思维导图失败");
} finally {
setDownloading(null);
}
}, [spec]);
return (
<>
<Button
type="button"
variant="outline"
size="icon"
className="size-9"
title="下载思维导图"
aria-label="下载思维导图"
onClick={() => setOpen(true)}
>
<Download aria-hidden="true" className="size-4" />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm p-5">
<DialogHeader className="text-left">
<DialogTitle>下载思维导图</DialogTitle>
<DialogDescription>选择需要导出的文件格式。</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<Button
type="button"
variant="outline"
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
onClick={() => void download("png")}
disabled={downloading !== null}
>
<ImageDown aria-hidden="true" className="size-4 shrink-0" />
<span>
<span className="block text-sm font-medium">PNG 图片</span>
<span className="block text-xs font-normal text-muted-foreground">
{downloading === "png" ? "正在生成…" : "便于在文档或图片中使用"}
</span>
</span>
</Button>
<Button
type="button"
variant="outline"
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
onClick={() => void download("svg")}
disabled={downloading !== null}
>
<Download aria-hidden="true" className="size-4 shrink-0" />
<span>
<span className="block text-sm font-medium">SVG 矢量图</span>
<span className="block text-xs font-normal text-muted-foreground">适合无损缩放和二次编辑</span>
</span>
</Button>
<Button
type="button"
variant="outline"
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
onClick={() => void download("json")}
disabled={downloading !== null}
>
<FileJson aria-hidden="true" className="size-4 shrink-0" />
<span>
<span className="block text-sm font-medium">JSON 数据</span>
<span className="block text-xs font-normal text-muted-foreground">保留完整节点结构</span>
</span>
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}
function MindmapDialog({
open,
onOpenChange,
spec,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
spec: MindmapSpec;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="flex h-[calc(100dvh-2rem)] !w-[calc(100vw-2rem)] !max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden rounded-xl p-0 sm:!max-w-[calc(100vw-2rem)]"
>
<DialogTitle className="sr-only">{spec.title}</DialogTitle>
<DialogDescription className="sr-only">
思维导图画布。支持鼠标滚轮缩放、拖动画布,并可使用右上角按钮调整视图。
</DialogDescription>
<div className="relative min-h-0 flex-1 bg-muted/20">
<div className="absolute inset-0">
<ReactFlowProvider>
<MindmapCanvas spec={spec} preview={false} onClose={() => onOpenChange(false)} />
</ReactFlowProvider>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export default function MindmapRenderer({ spec }: { spec: MindmapSpec }) {
const [open, setOpen] = useState(false);
const root = getRoot(spec);
const ariaSummary = spec.nodes.map((node) => `${KIND_META[node.kind].label}:${node.label}`).join(";");
return (
<>
<VisualizationCard className="my-4" >
<section aria-label={`${spec.title}思维导图`}>
<div className="flex flex-wrap items-start justify-between gap-3 border-b px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-sm font-semibold">{spec.title}</h2>
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary">思维导图</span>
</div>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
{spec.summary || `围绕“${root.label}”整理的 ${spec.nodes.length} 个分析节点。`}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<MindmapDownloads spec={spec} />
<Button type="button" size="sm" className="h-8 gap-1.5" onClick={() => setOpen(true)}>
<Maximize2 aria-hidden="true" className="size-3.5" />
展开查看
</Button>
</div>
</div>
<p className="sr-only">{ariaSummary}</p>
<div className="h-[20rem] bg-muted/15">
<ReactFlowProvider>
<MindmapCanvas
spec={spec}
preview
onSelect={() => {
setOpen(true);
}}
/>
</ReactFlowProvider>
</div>
<div className="flex items-center justify-between gap-3 border-t px-4 py-2 text-xs text-muted-foreground">
<span>预览显示前 {PREVIEW_DEPTH + 1} 层;完整导图可缩放查看。</span>
<span>{spec.nodes.length} 个节点</span>
</div>
</section>
</VisualizationCard>
<MindmapDialog open={open} onOpenChange={setOpen} spec={spec} />
</>
);
}