683 lines
24 KiB
TypeScript
683 lines
24 KiB
TypeScript
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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} />
|
||
</>
|
||
);
|
||
}
|