Files
writer-work-flow/apps/web/lib/characters/graphLayout.ts
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

246 lines
7.1 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

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