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:
258
apps/web/lib/characters/graphLayout.test.ts
Normal file
258
apps/web/lib/characters/graphLayout.test.ts
Normal 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=name,role 决定分组/配色", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
245
apps/web/lib/characters/graphLayout.ts
Normal file
245
apps/web/lib/characters/graphLayout.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import dagre from "@dagrejs/dagre";
|
||||
|
||||
import type { CharacterCardView } from "@/lib/api/types";
|
||||
|
||||
// 关系图布局纯逻辑(灵感⑥ 缩减版):把「角色 + relations」转成节点/边并跑一次性
|
||||
// dagre 初始坐标。仅用现有读端点数据(CharacterRelationView={name,kind,note})——
|
||||
// 无 closeness/阵营/target-id,节点按 role 上色,边按 name-join、kind 作中文标签。
|
||||
// 无 DOM 依赖,可在 node 环境单测;React Flow 组件只做渲染,不含此处逻辑。
|
||||
|
||||
// 节点尺寸(dagre 布局与 React Flow 渲染共用;命名常量非魔法值)。
|
||||
export const NODE_WIDTH = 176;
|
||||
export const NODE_HEIGHT = 56;
|
||||
// dagre 间距。
|
||||
export const DAGRE_RANK_SEP = 88;
|
||||
export const DAGRE_NODE_SEP = 48;
|
||||
// kind 缺失时的关系标签回退(命名常量非魔法值)。
|
||||
export const DEFAULT_RELATION_LABEL = "关系";
|
||||
|
||||
// 角色定位分组(role 是自由中文文本,无固定枚举 → 关键词映射到有限分组)。
|
||||
export type RoleGroup =
|
||||
| "protagonist"
|
||||
| "antagonist"
|
||||
| "mentor"
|
||||
| "ally"
|
||||
| "supporting"
|
||||
| "other";
|
||||
|
||||
export interface RoleStyle {
|
||||
group: RoleGroup;
|
||||
// 纸感主题 CSS 变量名(随亮/暗主题自适应,不写死色值)。
|
||||
colorVar: string;
|
||||
}
|
||||
|
||||
// role→分组关键词规则;按序匹配,首个命中生效,否则回退 other。
|
||||
const ROLE_RULES: ReadonlyArray<{
|
||||
keywords: readonly string[];
|
||||
style: RoleStyle;
|
||||
}> = [
|
||||
{
|
||||
keywords: ["主角", "主人公", "男主", "女主"],
|
||||
style: { group: "protagonist", colorVar: "--color-cinnabar" },
|
||||
},
|
||||
{
|
||||
keywords: ["反派", "对手", "宿敌", "敌", "boss"],
|
||||
style: { group: "antagonist", colorVar: "--color-conflict" },
|
||||
},
|
||||
{
|
||||
keywords: ["导师", "师父", "师傅", "师尊", "老师"],
|
||||
style: { group: "mentor", colorVar: "--color-info" },
|
||||
},
|
||||
{
|
||||
keywords: ["cp", "恋", "爱人", "伴侣", "挚友", "知己"],
|
||||
style: { group: "ally", colorVar: "--color-pass" },
|
||||
},
|
||||
{
|
||||
keywords: ["配角", "工具人", "路人", "龙套"],
|
||||
style: { group: "supporting", colorVar: "--color-ink-soft" },
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_ROLE_STYLE: RoleStyle = {
|
||||
group: "other",
|
||||
colorVar: "--color-ink-soft",
|
||||
};
|
||||
|
||||
// 把角色定位映射到分组/配色(大小写不敏感,子串匹配)。
|
||||
export function roleStyle(role: string): RoleStyle {
|
||||
const normalized = role.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return DEFAULT_ROLE_STYLE;
|
||||
}
|
||||
for (const rule of ROLE_RULES) {
|
||||
if (rule.keywords.some((keyword) => normalized.includes(keyword))) {
|
||||
return rule.style;
|
||||
}
|
||||
}
|
||||
return DEFAULT_ROLE_STYLE;
|
||||
}
|
||||
|
||||
export interface RelationNodeData {
|
||||
label: string;
|
||||
role: string;
|
||||
group: RoleGroup;
|
||||
colorVar: string;
|
||||
}
|
||||
|
||||
export interface GraphNode {
|
||||
id: string; // = 角色名(name-join 主键)
|
||||
position: { x: number; y: number };
|
||||
data: RelationNodeData;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface RelationshipGraphData {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
// 引用未入库角色而被略过的关系条数(供 UI 提示)。
|
||||
danglingCount: number;
|
||||
}
|
||||
|
||||
// 每个唯一角色一个节点;空名/重名(先到先得)跳过。位置初始 {0,0},由 layoutGraph 赋值。
|
||||
export function buildGraphNodes(
|
||||
characters: readonly CharacterCardView[],
|
||||
): GraphNode[] {
|
||||
const seen = new Set<string>();
|
||||
const nodes: GraphNode[] = [];
|
||||
for (const character of characters) {
|
||||
const name = character.name.trim();
|
||||
if (!name || seen.has(name)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(name);
|
||||
const style = roleStyle(character.role);
|
||||
nodes.push({
|
||||
id: name,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: name,
|
||||
role: character.role,
|
||||
group: style.group,
|
||||
colorVar: style.colorVar,
|
||||
},
|
||||
});
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// 无向对键:与方向无关,用于 A↔B 去重。
|
||||
function pairKey(a: string, b: string): string {
|
||||
return [a, b].sort().join("");
|
||||
}
|
||||
|
||||
// 按 name-join 构边:kind→标签;悬空边(目标不在 knownNames)与自指跳过;互指无向去重。
|
||||
export function buildGraphEdges(
|
||||
characters: readonly CharacterCardView[],
|
||||
knownNames: ReadonlySet<string>,
|
||||
): GraphEdge[] {
|
||||
const edges: GraphEdge[] = [];
|
||||
const seenPairs = new Set<string>();
|
||||
let index = 0;
|
||||
for (const character of characters) {
|
||||
const source = character.name.trim();
|
||||
if (!source || !knownNames.has(source)) {
|
||||
continue;
|
||||
}
|
||||
for (const relation of character.relations ?? []) {
|
||||
const target = relation.name.trim();
|
||||
if (!target || !knownNames.has(target) || target === source) {
|
||||
continue;
|
||||
}
|
||||
const key = pairKey(source, target);
|
||||
if (seenPairs.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seenPairs.add(key);
|
||||
const label = relation.kind.trim() || DEFAULT_RELATION_LABEL;
|
||||
edges.push({
|
||||
id: `e-${index}-${source}-${target}`,
|
||||
source,
|
||||
target,
|
||||
label,
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
// 统计引用未入库角色(悬空)的关系条数——供 UI「N 条关系已略过」提示。
|
||||
export function countDanglingRelations(
|
||||
characters: readonly CharacterCardView[],
|
||||
knownNames: ReadonlySet<string>,
|
||||
): number {
|
||||
let count = 0;
|
||||
for (const character of characters) {
|
||||
const source = character.name.trim();
|
||||
if (!source || !knownNames.has(source)) {
|
||||
continue;
|
||||
}
|
||||
for (const relation of character.relations ?? []) {
|
||||
const target = relation.name.trim();
|
||||
if (target && target !== source && !knownNames.has(target)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
interface LaidOutNode {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
// 跑一次性 dagre 布局(确定性:同输入同坐标)。dagre 给中心坐标 → 转 React Flow 左上角。
|
||||
// 不可变:返回新节点数组,不修改入参。
|
||||
export function layoutGraph(
|
||||
nodes: readonly GraphNode[],
|
||||
edges: readonly GraphEdge[],
|
||||
): GraphNode[] {
|
||||
const graph = new dagre.graphlib.Graph();
|
||||
graph.setGraph({
|
||||
rankdir: "LR",
|
||||
nodesep: DAGRE_NODE_SEP,
|
||||
ranksep: DAGRE_RANK_SEP,
|
||||
});
|
||||
graph.setDefaultEdgeLabel(() => ({}));
|
||||
for (const node of nodes) {
|
||||
graph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||
}
|
||||
for (const edge of edges) {
|
||||
graph.setEdge(edge.source, edge.target);
|
||||
}
|
||||
dagre.layout(graph);
|
||||
return nodes.map((node) => {
|
||||
const laid = graph.node(node.id) as LaidOutNode | undefined;
|
||||
const centerX = laid?.x ?? 0;
|
||||
const centerY = laid?.y ?? 0;
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: centerX - NODE_WIDTH / 2,
|
||||
y: centerY - NODE_HEIGHT / 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 端到端:角色列表 → 布局后的节点 + 有效边 + 悬空计数(组件里 useMemo 缓存)。
|
||||
export function buildRelationshipGraph(
|
||||
characters: readonly CharacterCardView[],
|
||||
): RelationshipGraphData {
|
||||
const nodes = buildGraphNodes(characters);
|
||||
const knownNames = new Set(nodes.map((node) => node.id));
|
||||
const edges = buildGraphEdges(characters, knownNames);
|
||||
const danglingCount = countDanglingRelations(characters, knownNames);
|
||||
const laidOut = layoutGraph(nodes, edges);
|
||||
return { nodes: laidOut, edges, danglingCount };
|
||||
}
|
||||
Reference in New Issue
Block a user