diff --git a/src/components/game-distribution/lineageTreeLayout.test.ts b/src/components/game-distribution/lineageTreeLayout.test.ts new file mode 100644 index 000000000..b75a831d7 --- /dev/null +++ b/src/components/game-distribution/lineageTreeLayout.test.ts @@ -0,0 +1,273 @@ +/* @vitest-environment jsdom */ + +import { describe, expect, it } from 'vitest'; + +import type { GameDistributionLineageNode } from '../../services/gameDistributionClient'; +import { + buildLineageTreeLayout, + collectLineageHighlight, + generationAxisLabel, + LINEAGE_COLUMN_GAP, + LINEAGE_NODE_WIDTH, +} from './lineageTreeLayout'; + +function node( + gameId: string, + generation: number, + parentGameId: string | null, + title = `作品 ${gameId}`, +): GameDistributionLineageNode { + return { + gameId, + title, + authorName: `作者 ${gameId}`, + generation, + parentGameId, + playCount: generation * 10, + status: 'published', + }; +} + +/** 同层任意两节点必须至少隔着列间距:这是「绝不重叠」的可断言形式。 */ +function assertNoOverlap( + nodes: readonly { x: number; y: number; generation: number }[], +) { + for (let left = 0; left < nodes.length; left += 1) { + for (let right = left + 1; right < nodes.length; right += 1) { + const a = nodes[left]!; + const b = nodes[right]!; + if (a.generation !== b.generation) continue; + expect( + Math.abs(a.x - b.x), + `${a.x} 与 ${b.x} 在第 ${a.generation} 代重叠`, + ).toBeGreaterThanOrEqual(LINEAGE_NODE_WIDTH); + expect(a.y).toBe(b.y); + } + } +} + +describe('buildLineageTreeLayout', () => { + it('单节点:一个布局节点、零连线、一条代际轴', () => { + const layout = buildLineageTreeLayout([node('game-root', 0, null)]); + expect(layout.nodes).toHaveLength(1); + expect(layout.edges).toHaveLength(0); + expect(layout.axis).toEqual([ + { generation: 0, label: '母版', y: 0, count: 1 }, + ]); + expect(layout.nodes[0]!.isStub).toBe(false); + expect(layout.width).toBeGreaterThan(LINEAGE_NODE_WIDTH); + }); + + it('空树:零节点零连线,尺寸仍是可渲染的正数', () => { + const layout = buildLineageTreeLayout([]); + expect(layout.nodes).toHaveLength(0); + expect(layout.edges).toHaveLength(0); + expect(layout.axis).toHaveLength(0); + expect(layout.width).toBeGreaterThan(0); + expect(layout.height).toBe(0); + }); + + it('深链(10 代):逐代下行、x 对齐、连线数 = 代数 - 1', () => { + const chain = Array.from({ length: 10 }, (_, generation) => + node( + `g${generation}`, + generation, + generation === 0 ? null : `g${generation - 1}`, + ), + ); + const layout = buildLineageTreeLayout(chain); + expect(layout.nodes).toHaveLength(10); + expect(layout.edges).toHaveLength(9); + expect(layout.axis).toHaveLength(10); + // 单链:每一代的 x 相同,y 严格递增。 + const xs = new Set(layout.nodes.map((item) => item.x)); + expect(xs.size).toBe(1); + const ys = layout.nodes.map((item) => item.y); + for (let index = 1; index < ys.length; index += 1) { + expect(ys[index]!).toBeGreaterThan(ys[index - 1]!); + } + assertNoOverlap(layout.nodes); + }); + + it('宽扇出(1 父 24 子):同层不重叠、父居中在子节点中间', () => { + const children = Array.from({ length: 24 }, (_, index) => + node(`child${index}`, 1, 'root'), + ); + const layout = buildLineageTreeLayout([node('root', 0, null), ...children]); + expect(layout.nodes).toHaveLength(25); + expect(layout.edges).toHaveLength(24); + const root = layout.nodes.find((item) => item.gameId === 'root')!; + const childXs = layout.nodes + .filter((item) => item.generation === 1) + .map((item) => item.x); + const expectedCenter = + (Math.min(...childXs) + Math.max(...childXs)) / 2 + + LINEAGE_NODE_WIDTH / 2; + expect(Math.abs(root.x + root.width / 2 - expectedCenter)).toBeLessThan( + 0.51, + ); + assertNoOverlap(layout.nodes); + }); + + it('对称分支:左右两支镜像分布,父节点居中', () => { + const layout = buildLineageTreeLayout([ + node('root', 0, null), + node('left', 1, 'root'), + node('right', 1, 'root'), + node('left-a', 2, 'left'), + node('left-b', 2, 'left'), + node('right-a', 2, 'right'), + node('right-b', 2, 'right'), + ]); + const root = layout.nodes.find((item) => item.gameId === 'root')!; + const leftBranch = layout.nodes.filter((item) => + item.gameId.startsWith('left'), + ); + const rightBranch = layout.nodes.filter((item) => + item.gameId.startsWith('right'), + ); + // 对称:两支相对父中心镜像(同一代际各取一个点比较)。 + const leftL1 = leftBranch.find((item) => item.generation === 1)!; + const rightL1 = rightBranch.find((item) => item.generation === 1)!; + const center = root.x + root.width / 2; + // 对称 = 两个子节点中心相对父中心的偏移互为相反数(和为零)。 + const mirrorDelta = + leftL1.x + + LINEAGE_NODE_WIDTH / 2 - + center + + (rightL1.x + LINEAGE_NODE_WIDTH / 2 - center); + expect(Math.abs(mirrorDelta)).toBeLessThan(0.51); + assertNoOverlap(layout.nodes); + }); + + it('混合树的每一层都不重叠', () => { + const layout = buildLineageTreeLayout([ + node('root', 0, null), + node('a', 1, 'root'), + node('b', 1, 'root'), + node('c', 1, 'root'), + node('a1', 2, 'a'), + node('a2', 2, 'a'), + node('b1', 2, 'b'), + node('c1', 2, 'c'), + node('c2', 2, 'c'), + node('c3', 2, 'c'), + node('a1x', 3, 'a1'), + ]); + assertNoOverlap(layout.nodes); + // 同层至少隔一个列间距(比「不重叠」更强)。 + const byGeneration = new Map(); + for (const item of layout.nodes) { + byGeneration.set(item.generation, [ + ...(byGeneration.get(item.generation) ?? []), + item.x, + ]); + } + for (const xs of byGeneration.values()) { + const sorted = [...xs].sort((left, right) => left - right); + for (let index = 1; index < sorted.length; index += 1) { + expect(sorted[index]! - sorted[index - 1]!).toBeGreaterThanOrEqual( + LINEAGE_NODE_WIDTH + LINEAGE_COLUMN_GAP - 0.01, + ); + } + } + }); + + it('连线端点落在父底边中点与子顶边中点,路径是贝塞尔', () => { + const layout = buildLineageTreeLayout([ + node('root', 0, null), + node('child', 1, 'root'), + ]); + const edge = layout.edges[0]!; + const root = layout.nodes.find((item) => item.gameId === 'root')!; + const child = layout.nodes.find((item) => item.gameId === 'child')!; + expect(edge.from).toEqual({ + x: root.x + root.width / 2, + y: root.y + root.height, + }); + expect(edge.to).toEqual({ x: child.x + child.width / 2, y: child.y }); + expect(edge.path.startsWith('M ')).toBe(true); + expect(edge.path).toContain(' C '); + expect(edge.id).toBe('root->child'); + }); + + it('确定性:同一输入两次调用结果逐字段相同', () => { + const input = [ + node('root', 0, null), + node('a', 1, 'root'), + node('b', 1, 'root'), + node('a1', 2, 'a'), + ]; + expect(buildLineageTreeLayout(input)).toEqual( + buildLineageTreeLayout(input), + ); + }); + + it('父作品不可用:补一个不带标题与作者名的占位节点,且连线仍然连通', () => { + const layout = buildLineageTreeLayout([ + node('child', 1, 'game-removed', '当前作品'), + ]); + const stub = layout.nodes.find((item) => item.gameId === 'game-removed')!; + expect(stub.isStub).toBe(true); + expect(stub.title).toBeNull(); + expect(stub.authorName).toBeNull(); + expect(stub.generation).toBe(0); + // 占位节点不携带被删作品的任何身份信息。 + expect(JSON.stringify(stub)).not.toContain('当前作品'); + expect(layout.edges).toHaveLength(1); + expect(layout.edges[0]!.parentGameId).toBe('game-removed'); + expect(layout.axis.map((entry) => entry.label)).toEqual([ + generationAxisLabel(0), + generationAxisLabel(1), + ]); + }); + + it('同一不可用父只补一个占位节点(多个子共享)', () => { + const layout = buildLineageTreeLayout([ + node('child-a', 1, 'game-removed'), + node('child-b', 1, 'game-removed'), + ]); + expect( + layout.nodes.filter((item) => item.gameId === 'game-removed'), + ).toHaveLength(1); + expect(layout.edges).toHaveLength(2); + }); +}); + +describe('collectLineageHighlight', () => { + const tree = buildLineageTreeLayout([ + node('root', 0, null), + node('a', 1, 'root'), + node('b', 1, 'root'), + node('a1', 2, 'a'), + node('a1x', 3, 'a1'), + ]); + + it('高亮节点自身 + 祖先路径(不含兄弟)', () => { + const highlighted = collectLineageHighlight(tree.nodes, 'a1', { + includeSubtree: false, + }); + expect([...highlighted].sort()).toEqual(['a', 'a1', 'root']); + expect(highlighted.has('b')).toBe(false); + expect(highlighted.has('a1x')).toBe(false); + }); + + it('包含子树时把后代一并高亮', () => { + const highlighted = collectLineageHighlight(tree.nodes, 'a'); + expect([...highlighted].sort()).toEqual(['a', 'a1', 'a1x', 'root']); + }); + + it('根节点 + 子树 = 整棵树', () => { + expect(collectLineageHighlight(tree.nodes, 'root').size).toBe(5); + }); + + it('激活不存在的节点时返回空集合(不抛错)', () => { + expect(collectLineageHighlight(tree.nodes, 'game-missing').size).toBe(0); + }); + + it('占位父节点也会被高亮(它是路径上的一环)', () => { + const withStub = buildLineageTreeLayout([node('child', 1, 'game-removed')]); + const highlighted = collectLineageHighlight(withStub.nodes, 'child'); + expect([...highlighted].sort()).toEqual(['child', 'game-removed']); + }); +}); diff --git a/src/components/game-distribution/lineageTreeLayout.ts b/src/components/game-distribution/lineageTreeLayout.ts new file mode 100644 index 000000000..11a34e049 --- /dev/null +++ b/src/components/game-distribution/lineageTreeLayout.ts @@ -0,0 +1,313 @@ +import type { GameDistributionLineageNode } from '../../services/gameDistributionClient'; + +/** + * 创作族谱树的可视化布局(tidy tree 的轻量实现)。 + * + * 纯函数:输入节点列表,输出坐标 / 尺寸 / 连线端点,不依赖 DOM 也不依赖 React, + * 因此可以在单测里把「不重叠」「确定性」「深链 / 宽扇出 / 单节点」这些性质钉死。 + * + * 算法(两趟,O(n log n) 里 log 只来自排序): + * 1. **补降级父节点**:节点的 `parentGameId` 不在可见集合里(父作品已删 / 未公开)时, + * 为它补一个**占位节点**——只有 ID 与代际,没有标题与作者名(既有降级口径), + * 这样树在视觉上仍然连通,而不是让子节点孤零零飘着。 + * 2. **按代际分行**:generation 决定行(父在上、子在下),行内 y 相同。 + * 3. **槽位分配求 x**:对森林做后序遍历——叶子按访问顺序拿下一个整数槽位,内部节点取 + * 子节点槽位的**中点**。因为 DFS 让每棵子树的槽位区间连续且互不相交,同层节点的中心 + * 间距必然 ≥ 1 个槽位;再用「槽位间距 = 节点宽 + 列间距」把槽位映射成像素, + * 于是**同层任意两节点至少隔着 `LINEAGE_COLUMN_GAP`,结构上不可能重叠**。 + * 4. **连线**:父节点底边中点 → 子节点顶边中点,三次贝塞尔(`path` 字符串里同时给出 + * 端点坐标,测试可以直接断言端点而不是解析路径)。 + */ + +export const LINEAGE_NODE_WIDTH = 208; +export const LINEAGE_NODE_HEIGHT = 96; +/** 同一层相邻槽位之间额外留的横向间隙(保证不重叠)。 */ +export const LINEAGE_COLUMN_GAP = 28; +/** 相邻代际之间的纵向间隙。 */ +export const LINEAGE_ROW_GAP = 104; +/** 左侧「代际轴」占的宽度(第 0 代 / 第 1 代 …)。 */ +export const LINEAGE_AXIS_WIDTH = 92; + +export type LineageLayoutNode = { + /** 真实作品是 gameId;占位父节点是那个不可用的父作品 ID。 */ + gameId: string; + /** 占位节点没有原始节点(服务端不会把不可用父作品当成节点发出来)。 */ + node: GameDistributionLineageNode | null; + /** 占位节点为 null:不展示被删作品的标题。 */ + title: string | null; + /** 占位节点为 null:不展示被删作品的作者名。 */ + authorName: string | null; + generation: number; + x: number; + y: number; + width: number; + height: number; + /** true = 父作品不可用的降级占位节点(可聚焦、不可点进详情)。 */ + isStub: boolean; +}; + +export type LineageLayoutEdge = { + id: string; + parentGameId: string; + childGameId: string; + /** SVG path(三次贝塞尔)。 */ + path: string; + from: { x: number; y: number }; + to: { x: number; y: number }; +}; + +export type LineageGenerationAxisEntry = { + generation: number; + label: string; + /** 该代际所在行的顶部 y。 */ + y: number; + /** 该行实际渲染的节点数(含占位节点)。 */ + count: number; +}; + +export type LineageTreeLayout = { + nodes: LineageLayoutNode[]; + edges: LineageLayoutEdge[]; + axis: LineageGenerationAxisEntry[]; + /** 内容总尺寸(含左侧代际轴),供画布与「适应窗口」使用。 */ + width: number; + height: number; +}; + +export type LineageLayoutOptions = { + nodeWidth?: number; + nodeHeight?: number; + columnGap?: number; + rowGap?: number; + axisWidth?: number; +}; + +export function generationAxisLabel(generation: number) { + return generation === 0 ? '母版' : `第 ${generation} 代`; +} + +function roundTo(value: number, digits = 2) { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +export function buildLineageTreeLayout( + nodes: readonly GameDistributionLineageNode[], + options: LineageLayoutOptions = {}, +): LineageTreeLayout { + const nodeWidth = options.nodeWidth ?? LINEAGE_NODE_WIDTH; + const nodeHeight = options.nodeHeight ?? LINEAGE_NODE_HEIGHT; + const columnGap = options.columnGap ?? LINEAGE_COLUMN_GAP; + const rowGap = options.rowGap ?? LINEAGE_ROW_GAP; + const axisWidth = options.axisWidth ?? LINEAGE_AXIS_WIDTH; + + const visibleIds = new Set(nodes.map((node) => node.gameId)); + const entries: { + gameId: string; + node: GameDistributionLineageNode | null; + title: string | null; + authorName: string | null; + generation: number; + parentGameId: string | null; + order: number; + }[] = []; + let order = 0; + for (const node of nodes) { + entries.push({ + gameId: node.gameId, + node, + title: node.title, + authorName: node.authorName ?? null, + generation: node.generation, + parentGameId: node.parentGameId ?? null, + order: order++, + }); + } + // 占位父节点:同一父只补一次;代际取所有子节点里最小代际 - 1。 + const stubGenerations = new Map(); + for (const node of nodes) { + const parentGameId = node.parentGameId ?? null; + if (!parentGameId || visibleIds.has(parentGameId)) continue; + // 占位父节点的代际:至少 0;父代际未知时按「比子节点早一代」推断。 + const candidate = node.generation > 0 ? node.generation - 1 : 0; + const current = stubGenerations.get(parentGameId); + stubGenerations.set( + parentGameId, + current === undefined ? candidate : Math.min(current, candidate), + ); + } + for (const [gameId, generation] of stubGenerations) { + entries.push({ + gameId, + node: null, + title: null, + authorName: null, + generation, + parentGameId: null, + order: order++, + }); + } + + // 稳定顺序:代际升序 → 输入顺序(含占位节点)。同输入必然同输出。 + entries.sort( + (left, right) => + left.generation - right.generation || left.order - right.order, + ); + + const byId = new Map(entries.map((entry) => [entry.gameId, entry])); + const childrenByParent = new Map(); + for (const entry of entries) { + if (!entry.parentGameId || !byId.has(entry.parentGameId)) continue; + const bucket = childrenByParent.get(entry.parentGameId); + if (bucket) bucket.push(entry); + else childrenByParent.set(entry.parentGameId, [entry]); + } + const forestRoots = entries.filter( + (entry) => !entry.parentGameId || !byId.has(entry.parentGameId), + ); + + // 第 3 步:后序分配槽位。 + const slotByGameId = new Map(); + let nextSlot = 0; + const visit = (entry: (typeof entries)[number]): number => { + const children = childrenByParent.get(entry.gameId) ?? []; + if (children.length === 0) { + const slot = nextSlot; + nextSlot += 1; + slotByGameId.set(entry.gameId, slot); + return slot; + } + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + for (const child of children) { + const childSlot = visit(child); + min = Math.min(min, childSlot); + max = Math.max(max, childSlot); + } + const slot = (min + max) / 2; + slotByGameId.set(entry.gameId, slot); + return slot; + }; + for (const root of forestRoots) { + if (!slotByGameId.has(root.gameId)) visit(root); + } + + const generations = [ + ...new Set(entries.map((entry) => entry.generation)), + ].sort((left, right) => left - right); + const rowByGeneration = new Map( + generations.map((generation, index) => [generation, index] as const), + ); + const pitch = nodeWidth + columnGap; + + const layoutNodes: LineageLayoutNode[] = entries.map((entry) => { + const slot = slotByGameId.get(entry.gameId) ?? 0; + const row = rowByGeneration.get(entry.generation) ?? 0; + return { + gameId: entry.gameId, + node: entry.node, + title: entry.title, + authorName: entry.authorName, + generation: entry.generation, + x: roundTo(axisWidth + slot * pitch), + y: roundTo(row * (nodeHeight + rowGap)), + width: nodeWidth, + height: nodeHeight, + isStub: entry.node === null, + }; + }); + + const layoutById = new Map(layoutNodes.map((node) => [node.gameId, node])); + const edges: LineageLayoutEdge[] = []; + for (const node of layoutNodes) { + const parentGameId = node.isStub ? null : (node.node?.parentGameId ?? null); + if (!parentGameId) continue; + const parent = layoutById.get(parentGameId); + if (!parent) continue; + const from = { + x: roundTo(parent.x + parent.width / 2), + y: roundTo(parent.y + parent.height), + }; + const to = { + x: roundTo(node.x + node.width / 2), + y: roundTo(node.y), + }; + const delta = to.y - from.y; + edges.push({ + id: `${parentGameId}->${node.gameId}`, + parentGameId, + childGameId: node.gameId, + from, + to, + path: + `M ${from.x},${from.y} ` + + `C ${from.x},${roundTo(from.y + delta * 0.5)} ` + + `${to.x},${roundTo(to.y - delta * 0.5)} ` + + `${to.x},${to.y}`, + }); + } + + const axis: LineageGenerationAxisEntry[] = generations.map((generation) => ({ + generation, + label: generationAxisLabel(generation), + y: roundTo((rowByGeneration.get(generation) ?? 0) * (nodeHeight + rowGap)), + count: layoutNodes.filter((node) => node.generation === generation).length, + })); + + const maxSlot = + slotByGameId.size > 0 ? Math.max(...slotByGameId.values()) : 0; + return { + nodes: layoutNodes, + edges, + axis, + width: roundTo(axisWidth + (maxSlot + 1) * pitch), + height: roundTo(generations.length * (nodeHeight + rowGap)), + }; +} + +/** + * hover / 聚焦一个节点时要高亮的集合:它自己 + 它的祖先路径;`includeSubtree` 为真时再加上 + * 它整棵子树。其余节点在渲染层降透明度。 + */ +export function collectLineageHighlight( + layoutNodes: readonly LineageLayoutNode[], + activeGameId: string, + options: { includeSubtree?: boolean } = {}, +): Set { + const includeSubtree = options.includeSubtree ?? true; + const byId = new Map(layoutNodes.map((node) => [node.gameId, node])); + const highlighted = new Set(); + if (!byId.has(activeGameId)) return highlighted; + + // 祖先路径:沿 parentGameId 上溯(占位节点没有 parent,天然成为终点)。 + let cursor: LineageLayoutNode | undefined = byId.get(activeGameId); + while (cursor) { + highlighted.add(cursor.gameId); + const parentGameId = cursor.isStub + ? null + : (cursor.node?.parentGameId ?? null); + cursor = parentGameId ? byId.get(parentGameId) : undefined; + } + + if (includeSubtree) { + const childrenByParent = new Map(); + for (const node of layoutNodes) { + const parentGameId = node.isStub + ? null + : (node.node?.parentGameId ?? null); + if (!parentGameId || !byId.has(parentGameId)) continue; + const bucket = childrenByParent.get(parentGameId); + if (bucket) bucket.push(node.gameId); + else childrenByParent.set(parentGameId, [node.gameId]); + } + const stack = [...(childrenByParent.get(activeGameId) ?? [])]; + while (stack.length > 0) { + const gameId = stack.pop() as string; + if (highlighted.has(gameId)) continue; + highlighted.add(gameId); + stack.push(...(childrenByParent.get(gameId) ?? [])); + } + } + + return highlighted; +}