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,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 };
}