设定库人物 tab 新增 React Flow 关系图谱,仅用现有角色读端点数据
(CharacterRelationView={name,kind,note}):边按 name-join、kind 作中文
标签、节点按 role 上色,无 closeness/阵营(数据不存在,不硬造)。
- lib/characters/graphLayout.ts:纯逻辑(role→分组配色关键词映射 / 节点
去空重名 / kind→标签+DEFAULT_RELATION_LABEL / 悬空边跳过并计数 / 自指
跳过 / 互指无向去重 / @dagrejs/dagre 一次性确定性初始坐标、中心→左上、
不可变更新)+22 单测。
- components/characters/RelationshipGraph.tsx:'use client',经 CodexPage
dynamic(ssr:false) 挂载;显式容器高度 + import RF CSS + prefers-reduced-
motion 关 fitView 动画 + 纸感 CSS 变量配色 + 悬空计数提示。
- 依赖:@xyflow/react@^12.11.2 + @dagrejs/dagre@^3.0.0。
无后端契约变更/迁移/gen:api。门禁:lint/typecheck 干净、vitest 565、
coverage lib 95.4%(graphLayout 97.6%)、build OK。
150 lines
4.7 KiB
TypeScript
150 lines
4.7 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import {
|
||
Background,
|
||
BackgroundVariant,
|
||
Controls,
|
||
ReactFlow,
|
||
type Edge,
|
||
type Node,
|
||
} from "@xyflow/react";
|
||
import { Users } from "lucide-react";
|
||
|
||
// React Flow 样式必须显式引入(组件经 dynamic ssr:false 加载,样式随该 chunk 打包)。
|
||
import "@xyflow/react/dist/style.css";
|
||
|
||
import { EmptyState } from "@/components/ui/EmptyState";
|
||
import {
|
||
buildRelationshipGraph,
|
||
NODE_WIDTH,
|
||
type GraphNode,
|
||
} from "@/lib/characters/graphLayout";
|
||
import type { CharacterCardView } from "@/lib/api/types";
|
||
|
||
interface RelationshipGraphProps {
|
||
characters: CharacterCardView[];
|
||
}
|
||
|
||
// 纸感节点样式:面板底 + 细线框 + 左侧按分组配色的强调条(色值走主题 CSS 变量)。
|
||
function paperNodeStyle(colorVar: string): React.CSSProperties {
|
||
return {
|
||
width: NODE_WIDTH,
|
||
padding: "8px 10px",
|
||
borderRadius: 6,
|
||
background: "var(--color-panel)",
|
||
color: "var(--color-ink)",
|
||
border: "1px solid var(--color-line)",
|
||
borderLeft: `4px solid var(${colorVar})`,
|
||
boxShadow: "0 1px 3px var(--shadow-paper)",
|
||
fontSize: 12,
|
||
textAlign: "left",
|
||
};
|
||
}
|
||
|
||
// 节点内容:角色名(宋体)+ 定位副标题。
|
||
function NodeLabel({ name, role }: { name: string; role: string }) {
|
||
return (
|
||
<div className="min-w-0">
|
||
<p className="truncate font-serif text-sm text-ink">{name}</p>
|
||
{role ? <p className="truncate text-2xs text-ink-soft">{role}</p> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function toReactFlowNodes(nodes: GraphNode[]): Node[] {
|
||
return nodes.map((node) => ({
|
||
id: node.id,
|
||
position: node.position,
|
||
data: { label: <NodeLabel name={node.data.label} role={node.data.role} /> },
|
||
style: paperNodeStyle(node.data.colorVar),
|
||
// 只读可视化:可拖动重排,不可新建连线。
|
||
connectable: false,
|
||
}));
|
||
}
|
||
|
||
function toReactFlowEdges(
|
||
edges: { id: string; source: string; target: string; label: string }[],
|
||
): Edge[] {
|
||
return edges.map((edge) => ({
|
||
id: edge.id,
|
||
source: edge.source,
|
||
target: edge.target,
|
||
label: edge.label,
|
||
labelStyle: { fill: "var(--color-ink-soft)", fontSize: 11 },
|
||
labelBgStyle: { fill: "var(--color-bg)", fillOpacity: 0.9 },
|
||
labelBgPadding: [4, 2] as [number, number],
|
||
labelBgBorderRadius: 4,
|
||
style: { stroke: "var(--color-line)", strokeWidth: 1.5 },
|
||
}));
|
||
}
|
||
|
||
// 尊重 prefers-reduced-motion:减动偏好下关闭 fitView 平滑动画与边流动动画。
|
||
function usePrefersReducedMotion(): boolean {
|
||
const [reduced, setReduced] = useState(false);
|
||
useEffect(() => {
|
||
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||
const update = () => setReduced(media.matches);
|
||
update();
|
||
media.addEventListener("change", update);
|
||
return () => media.removeEventListener("change", update);
|
||
}, []);
|
||
return reduced;
|
||
}
|
||
|
||
// 角色关系图谱(灵感⑥ 缩减版 / D4):只用现有 relations 数据,节点按 role 上色、
|
||
// 边按 name-join、kind 作中文标签。悬空边(引用未入库角色)已在 graphLayout 略过并计数。
|
||
export function RelationshipGraph({ characters }: RelationshipGraphProps) {
|
||
const reducedMotion = usePrefersReducedMotion();
|
||
|
||
const graph = useMemo(
|
||
() => buildRelationshipGraph(characters),
|
||
[characters],
|
||
);
|
||
const nodes = useMemo(() => toReactFlowNodes(graph.nodes), [graph.nodes]);
|
||
const edges = useMemo(() => toReactFlowEdges(graph.edges), [graph.edges]);
|
||
|
||
if (graph.nodes.length === 0) {
|
||
return (
|
||
<EmptyState
|
||
icon={Users}
|
||
title="暂无可视化的人物"
|
||
description="先入库角色并登记彼此关系,这里会绘制出关系图谱。"
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
{/* React Flow 必须有显式尺寸的容器,否则画布高度为 0。 */}
|
||
<div className="h-[28rem] w-full overflow-hidden rounded border border-line bg-bg">
|
||
<ReactFlow
|
||
nodes={nodes}
|
||
edges={edges}
|
||
fitView
|
||
fitViewOptions={{ padding: 0.2, duration: reducedMotion ? 0 : 400 }}
|
||
nodesConnectable={false}
|
||
edgesFocusable={false}
|
||
proOptions={{ hideAttribution: false }}
|
||
minZoom={0.2}
|
||
maxZoom={1.75}
|
||
defaultEdgeOptions={{ animated: false }}
|
||
>
|
||
<Background
|
||
variant={BackgroundVariant.Dots}
|
||
gap={20}
|
||
size={1}
|
||
color="var(--color-line)"
|
||
/>
|
||
<Controls showInteractive={false} />
|
||
</ReactFlow>
|
||
</div>
|
||
{graph.danglingCount > 0 ? (
|
||
<p className="text-2xs text-ink-soft">
|
||
有 {graph.danglingCount} 条关系引用了未入库的角色,已略过。
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|