Files
writer-work-flow/apps/web/components/characters/RelationshipGraph.tsx
Yaojia Wang 3d2a8b8cee feat(web): 角色关系图谱(灵感⑥缩减版/D4)
设定库人物 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。
2026-07-06 17:38:58 +02:00

150 lines
4.7 KiB
TypeScript
Raw Permalink 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.

"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>
);
}