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(); 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, ): GraphEdge[] { const edges: GraphEdge[] = []; const seenPairs = new Set(); 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, ): 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 }; }