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。
This commit is contained in:
Yaojia Wang
2026-07-06 17:38:58 +02:00
parent 7babb4854b
commit 3d2a8b8cee
9 changed files with 896 additions and 1 deletions

View File

@@ -0,0 +1,258 @@
import { describe, expect, it } from "vitest";
import type { CharacterCardView } from "@/lib/api/types";
import {
buildGraphEdges,
buildGraphNodes,
buildRelationshipGraph,
countDanglingRelations,
DEFAULT_RELATION_LABEL,
layoutGraph,
roleStyle,
} from "./graphLayout";
// 最小角色卡工厂CharacterCardView 多字段必填,测试只关心 name/role/relations。
function ch(
name: string,
role: string,
relations: { name: string; kind?: string; note?: string | null }[] = [],
): CharacterCardView {
return {
name,
role,
traits: [],
appearance: "",
motive: "",
backstory: "",
arc: "",
speech_tics: [],
relations: relations.map((r) => ({
name: r.name,
kind: r.kind ?? "",
note: r.note ?? null,
})),
};
}
describe("roleStyle", () => {
it("主角关键词 → protagonist + 朱砂色", () => {
expect(roleStyle("主角")).toEqual({
group: "protagonist",
colorVar: "--color-cinnabar",
});
expect(roleStyle("男主角").group).toBe("protagonist");
});
it("反派/对手/敌 → antagonist + 冲突色", () => {
expect(roleStyle("反派").group).toBe("antagonist");
expect(roleStyle("对手").group).toBe("antagonist");
expect(roleStyle("宿敌").group).toBe("antagonist");
expect(roleStyle("反派").colorVar).toBe("--color-conflict");
});
it("导师/师父 → mentor + info 色", () => {
expect(roleStyle("导师").group).toBe("mentor");
expect(roleStyle("师父").group).toBe("mentor");
expect(roleStyle("导师").colorVar).toBe("--color-info");
});
it("CP/恋人 → ally大小写不敏感", () => {
expect(roleStyle("CP").group).toBe("ally");
expect(roleStyle("cp").group).toBe("ally");
expect(roleStyle("恋人").group).toBe("ally");
});
it("配角/工具人 → supporting", () => {
expect(roleStyle("配角").group).toBe("supporting");
expect(roleStyle("工具人").group).toBe("supporting");
});
it("未知/空定位 → other默认", () => {
expect(roleStyle("神秘存在").group).toBe("other");
expect(roleStyle("").group).toBe("other");
expect(roleStyle(" ").group).toBe("other");
});
});
describe("buildGraphNodes", () => {
it("每个唯一角色一个节点id=namerole 决定分组/配色", () => {
const nodes = buildGraphNodes([ch("林风", "主角"), ch("赵石", "对手")]);
expect(nodes).toHaveLength(2);
expect(nodes[0]).toMatchObject({
id: "林风",
position: { x: 0, y: 0 },
data: {
label: "林风",
role: "主角",
group: "protagonist",
colorVar: "--color-cinnabar",
},
});
expect(nodes[1]?.data.group).toBe("antagonist");
});
it("跳过空名与重名角色(先到先得)", () => {
const nodes = buildGraphNodes([
ch("林风", "主角"),
ch(" ", "配角"),
ch("林风", "对手"),
]);
expect(nodes).toHaveLength(1);
expect(nodes[0]?.data.role).toBe("主角");
});
it("名字两端空白被裁剪用作 id", () => {
const nodes = buildGraphNodes([ch(" 林风 ", "主角")]);
expect(nodes[0]?.id).toBe("林风");
});
});
describe("buildGraphEdges", () => {
const known = new Set(["林风", "赵石", "青禾"]);
it("kind → 边标签", () => {
const edges = buildGraphEdges(
[ch("林风", "主角", [{ name: "赵石", kind: "宿敌" }])],
known,
);
expect(edges).toHaveLength(1);
expect(edges[0]).toMatchObject({
source: "林风",
target: "赵石",
label: "宿敌",
});
});
it("kind 为空 → 回退到命名常量默认标签", () => {
const edges = buildGraphEdges(
[ch("林风", "主角", [{ name: "青禾", kind: "" }])],
known,
);
expect(edges[0]?.label).toBe(DEFAULT_RELATION_LABEL);
});
it("悬空边(目标未入库)被跳过", () => {
const edges = buildGraphEdges(
[ch("林风", "主角", [{ name: "不存在的人", kind: "师父" }])],
known,
);
expect(edges).toHaveLength(0);
});
it("自指关系被跳过", () => {
const edges = buildGraphEdges(
[ch("林风", "主角", [{ name: "林风", kind: "内心" }])],
known,
);
expect(edges).toHaveLength(0);
});
it("互指关系无向去重为单边", () => {
const edges = buildGraphEdges(
[
ch("林风", "主角", [{ name: "赵石", kind: "宿敌" }]),
ch("赵石", "对手", [{ name: "林风", kind: "宿敌" }]),
],
known,
);
expect(edges).toHaveLength(1);
});
it("边 id 唯一", () => {
const edges = buildGraphEdges(
[
ch("林风", "主角", [
{ name: "赵石", kind: "宿敌" },
{ name: "青禾", kind: "青梅竹马" },
]),
],
known,
);
const ids = edges.map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe("countDanglingRelations", () => {
it("统计引用未入库角色的关系数", () => {
const known = new Set(["林风"]);
const count = countDanglingRelations(
[
ch("林风", "主角", [
{ name: "赵石", kind: "宿敌" }, // 悬空
{ name: "青禾", kind: "青梅竹马" }, // 悬空
]),
],
known,
);
expect(count).toBe(2);
});
it("全部已入库时为 0", () => {
const known = new Set(["林风", "赵石"]);
const count = countDanglingRelations(
[ch("林风", "主角", [{ name: "赵石", kind: "宿敌" }])],
known,
);
expect(count).toBe(0);
});
});
describe("layoutGraph", () => {
const nodes = buildGraphNodes([ch("林风", "主角"), ch("赵石", "对手")]);
const edges = buildGraphEdges(
[ch("林风", "主角", [{ name: "赵石", kind: "宿敌" }])],
new Set(["林风", "赵石"]),
);
it("同输入产出相同坐标(确定性)", () => {
const a = layoutGraph(nodes, edges);
const b = layoutGraph(nodes, edges);
expect(a).toEqual(b);
});
it("dagre 赋予非零坐标并保持节点数据", () => {
const laid = layoutGraph(nodes, edges);
expect(laid).toHaveLength(2);
for (const n of laid) {
expect(Number.isFinite(n.position.x)).toBe(true);
expect(Number.isFinite(n.position.y)).toBe(true);
expect(n.data.label).toBeTruthy();
}
// 两节点经布局后不再重叠于原点。
expect(laid[0]?.position).not.toEqual(laid[1]?.position);
});
it("不可变:不修改传入节点,返回新对象", () => {
const before = structuredClone(nodes);
const laid = layoutGraph(nodes, edges);
expect(nodes).toEqual(before); // 入参未被修改
expect(laid[0]).not.toBe(nodes[0]); // 新对象
});
});
describe("buildRelationshipGraph", () => {
it("端到端:布局后节点 + 有效边 + 悬空计数", () => {
const graph = buildRelationshipGraph([
ch("林风", "主角", [
{ name: "赵石", kind: "宿敌" },
{ name: "幽灵", kind: "谜团" }, // 悬空
]),
ch("赵石", "对手", [{ name: "林风", kind: "宿敌" }]),
]);
expect(graph.nodes).toHaveLength(2);
expect(graph.edges).toHaveLength(1);
expect(graph.danglingCount).toBe(1);
// 已布局(非全零)。
const positions = graph.nodes.map((n) => `${n.position.x},${n.position.y}`);
expect(new Set(positions).size).toBe(2);
});
it("空输入 → 空图", () => {
const graph = buildRelationshipGraph([]);
expect(graph.nodes).toEqual([]);
expect(graph.edges).toEqual([]);
expect(graph.danglingCount).toBe(0);
});
});