族谱:父代/子代可断言区分 + 「只看父代与子代」可取消开关;侧栏卡文案对齐口径
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-10-07 11:08:22 +08:00
parent 7301d675ae
commit dd9e3b0019
24 changed files with 649 additions and 8112 deletions
@@ -871,12 +871,12 @@ function GameDetailContent({
*/}
<span className="game-fork-lineage">
{lineage.parentTitle
? `${coCreationGenerationTagLabel(lineage.generation)} · 改编自《${lineage.parentTitle}》`
? `${coCreationGenerationTagLabel(lineage.generation)} · 改编自父代《${lineage.parentTitle}》`
: `${coCreationGenerationTagLabel(lineage.generation)} · 原作品已不可用`}
</span>
{lineage.parentAuthorName ? (
<span className="game-fork-lineage__author">
由 {lineage.parentAuthorName} 制作
父代作者 {lineage.parentAuthorName}
</span>
) : null}
</>
@@ -426,8 +426,8 @@ describe('GameDetailPage', () => {
);
expect(await screen.findByText('允许非商用共创')).toBeTruthy();
expect(screen.getByText('2 代三创 · 改编自《原初之光》')).toBeTruthy();
expect(screen.getByText('由 原作者 制作')).toBeTruthy();
expect(screen.getByText('2 代三创 · 改编自父代《原初之光》')).toBeTruthy();
expect(screen.getByText('父代作者 原作者')).toBeTruthy();
expect(screen.getByText('累计衍生作品 3')).toBeTruthy();
// 已公开 + 授权开放 → 「一键 Fork 共创」入口出现(M1 时它还不存在,这里钉住新契约)。
expect(screen.getByRole('button', { name: '一键 Fork 共创' })).toBeTruthy();
@@ -2385,6 +2385,65 @@ function buildChainDataset() {
};
}
/**
* 父代 / 子代数据集:root → mid → current(锚点)→ child1/child2(直接子代),
* child1 → grandchild(孙代,不算「直接子代」);root → uncle 是旁支。
*/
const relationTree = {
rootGameId: 'game-root',
root: createLineageNode({
gameId: 'game-root',
title: '根原创',
generation: 0,
parentGameId: null,
}),
nodes: [
createLineageNode({
gameId: 'game-root',
title: '根原创',
generation: 0,
parentGameId: null,
}),
createLineageNode({
gameId: 'game-mid',
title: '中间代',
generation: 1,
parentGameId: 'game-root',
}),
createLineageNode({
gameId: 'game-uncle',
title: '旁支叔伯',
generation: 1,
parentGameId: 'game-root',
}),
createLineageNode({
gameId: 'game-current',
title: '当前作品',
generation: 2,
parentGameId: 'game-mid',
}),
createLineageNode({
gameId: 'game-child-1',
title: '子代甲',
generation: 3,
parentGameId: 'game-current',
}),
createLineageNode({
gameId: 'game-child-2',
title: '子代乙',
generation: 3,
parentGameId: 'game-current',
}),
createLineageNode({
gameId: 'game-grandchild',
title: '孙代作品',
generation: 4,
parentGameId: 'game-child-1',
}),
],
truncated: false,
};
/** 节点卡片上的主行动作按钮(打开详情)与它的卡片容器:多个 describe 共用。 */
const openButton = (title: string) =>
screen.getByRole('button', { name: `打开《${title}》详情` });
@@ -2452,6 +2511,126 @@ describe('GameLineagePage 可视化与交互', () => {
).toEqual(['一代作者', '当前作者', '根作者']);
});
/** 画布上所有节点卡片的标题(排序后比较,避免依赖布局顺序)。 */
const canvasTitles = (canvas: HTMLElement) =>
[...canvas.querySelectorAll('.game-lineage-node')]
.map(
(card) =>
card.querySelector('.game-lineage-node__title')?.textContent ?? '',
)
.sort();
it('区分父代 / 子代 / 自己 / 旁支:data-relation + 可读角标', async () => {
getGameLineageMock.mockResolvedValueOnce(relationTree);
render(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-current"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const currentCard = await cardOf('当前作品');
// 锚点 = 自己:不给「父代/子代」角标(它就是「当前作品」)。
expect(currentCard.getAttribute('data-relation')).toBe('self');
expect(
currentCard.querySelector('.game-lineage-node__relation'),
).toBeNull();
// 祖先链 = 父代。
for (const title of ['根原创', '中间代']) {
const card = await cardOf(title);
expect(card.getAttribute('data-relation')).toBe('ancestor');
expect(
within(card).getByText('父代', {
selector: '.game-lineage-node__relation',
}),
).toBeTruthy();
}
// 后代(含孙代)= 子代。
for (const title of ['子代甲', '子代乙', '孙代作品']) {
const card = await cardOf(title);
expect(card.getAttribute('data-relation')).toBe('descendant');
expect(
within(card).getByText('子代', {
selector: '.game-lineage-node__relation',
}),
).toBeTruthy();
}
// 旁支不冒充直系。
const uncleCard = await cardOf('旁支叔伯');
expect(uncleCard.getAttribute('data-relation')).toBe('side');
expect(uncleCard.querySelector('.game-lineage-node__relation')).toBeNull();
});
it('「只看父代与子代」:只留祖先链 + 自己 + 直接子代,再点一次恢复整棵树', async () => {
getGameLineageMock.mockResolvedValueOnce(relationTree);
render(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-current"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const canvas = (await screen.findByLabelText('创作族谱树')) as HTMLElement;
// 默认关:整棵树(含旁支与孙代)。
expect(canvasTitles(canvas)).toEqual(
[
'当前作品',
'中间代',
'子代乙',
'子代甲',
'旁支叔伯',
'根原创',
'孙代作品',
].sort(),
);
const toggle = screen.getByRole('button', { name: '只看父代与子代' });
expect(toggle.getAttribute('aria-pressed')).toBe('false');
fireEvent.click(toggle);
// 开:父代(祖先链)+ 自己 + **直接**子代——不多不少。
expect(canvasTitles(canvas)).toEqual(
['当前作品', '根原创', '子代乙', '子代甲', '中间代'].sort(),
);
expect(canvasTitles(canvas)).not.toContain('孙代作品');
expect(canvasTitles(canvas)).not.toContain('旁支叔伯');
// 状态有明确的可视/可读反馈:按钮文案 + aria-pressed + 说明行。
expect(
screen
.getByRole('button', { name: '只看父代与子代(已开启)' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
screen.getByText(
'只显示当前作品的父代(祖先链)与直接子代,再点一次恢复整棵树',
),
).toBeTruthy();
// 取消:回到整棵树。
fireEvent.click(
screen.getByRole('button', { name: '只看父代与子代(已开启)' }),
);
expect(canvasTitles(canvas)).toEqual(
[
'当前作品',
'中间代',
'子代乙',
'子代甲',
'旁支叔伯',
'根原创',
'孙代作品',
].sort(),
);
expect(
screen
.getByRole('button', { name: '只看父代与子代' })
.getAttribute('aria-pressed'),
).toBe('false');
});
it('节点是真实 DOM 卡片:主体点击=选中、标题/打开作品=进详情、当前作品有标记且父在上子在下', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
const onOpenDetail = vi.fn();
@@ -3212,6 +3391,42 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
}
});
it('移动端:「只看父代与子代」开关可用,大纲行也标出父代 / 子代', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(relationTree);
render(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-current"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const outline = await screen.findByLabelText('创作族谱大纲');
// 默认关:整棵树(7 个节点)。
expect(outline.querySelectorAll('.lineage-outline__row').length).toBe(7);
fireEvent.click(screen.getByRole('button', { name: '只看父代与子代' }));
const rows = [...outline.querySelectorAll('.lineage-outline__row')];
// 开:父代链(根原创 / 中间代)+ 自己 + 直接子代(子代甲 / 子代乙)——不多不少。
expect(rows.length).toBe(5);
expect(rows.map((row) => row.getAttribute('data-relation')).sort()).toEqual(
['ancestor', 'ancestor', 'descendant', 'descendant', 'self'],
);
// 大纲行里同样是**可读角标**(不是只靠描边颜色)。
expect(
[...outline.querySelectorAll('.game-lineage-node__relation')]
.map((element) => element.textContent)
.sort(),
).toEqual(['子代', '子代', '父代', '父代']);
// 取消:回到整棵树。
fireEvent.click(
screen.getByRole('button', { name: '只看父代与子代(已开启)' }),
);
expect(outline.querySelectorAll('.lineage-outline__row').length).toBe(7);
});
it('窄屏仍能点进详情页与查看完整族谱入口(大纲模式不丢入口)', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(tree);
@@ -3294,7 +3509,7 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
expect(await screen.findByText('2 代三创')).toBeTruthy();
expect(screen.getByText('允许非商用共创')).toBeTruthy();
expect(screen.getByText('累计衍生作品 3')).toBeTruthy();
expect(screen.getByText('2 代三创 · 改编自《原初之光》')).toBeTruthy();
expect(screen.getByText('2 代三创 · 改编自父代《原初之光》')).toBeTruthy();
// 主操作按文档叫「一键 Fork 共创」。
expect(screen.getByRole('button', { name: '一键 Fork 共创' })).toBeTruthy();
});
@@ -32,8 +32,11 @@ import { LineageOutlineList } from './LineageOutlineList';
import {
buildLineageTreeLayout,
collectLineageHighlight,
collectLineageImmediateScope,
collectLineageRelationRoles,
generationAxisLabel,
type LineageLayoutNode,
type LineageRelationRole,
} from './lineageTreeLayout';
type GameLineagePageProps = {
@@ -77,6 +80,7 @@ function LineageTreeNodeCard({
isOnPath,
isPinned,
compact,
relation,
onOpenDetail,
onActivate,
onDeactivate,
@@ -91,6 +95,8 @@ function LineageTreeNodeCard({
isPinned: boolean;
/** 窄屏紧凑模式:少一行信息、卡片更小。 */
compact: boolean;
/** 相对「当前作品」的角色:父代 / 子代 / 自己 / 旁支(打 `data-relation` + 角标)。 */
relation: LineageRelationRole;
onOpenDetail: (gameId: string) => void;
onActivate: (gameId: string) => void;
onDeactivate: () => void;
@@ -133,6 +139,7 @@ function LineageTreeNodeCard({
}}
data-generation={generation}
data-branch={layoutNode.branchIndex}
data-relation={relation}
// 卡片主体点击 = **选中**(持续高亮祖先链 + 子树);打开详情在标题/「打开作品」按钮上。
tabIndex={isStub ? 0 : undefined}
aria-label={accessibleName}
@@ -180,6 +187,7 @@ function LineageTreeNodeCard({
isStub={isStub}
isTrunk={layoutNode.isTrunk}
compact={compact}
relation={relation}
/>
</button>
<span className="game-lineage-node__actions">
@@ -298,6 +306,23 @@ export function GameLineagePage({
}, [loadLineage]);
const nodes = useMemo(() => lineage?.nodes ?? [], [lineage]);
// 高亮锚点(= 「当前作品」,也是「父代 / 子代」的基准)必须早于筛选链路声明。
const anchorId = highlightGameId?.trim() || normalizedRootId;
/**
* 每个节点相对当前作品的角色:`self` / `ancestor`(父代)/ `descendant`(子代)/ `side`(旁支)。
* 用**未筛选**的整棵树算,所以世代筛选、只看父子、排序都不会改口径。
*/
const relationRoles = useMemo(
() => collectLineageRelationRoles(nodes, anchorId),
[anchorId, nodes],
);
/** 「只看父代与子代」的保留集合:祖先链 + 自己 + 直接子代。 */
const relatedScope = useMemo(
() => collectLineageImmediateScope(nodes, anchorId),
[anchorId, nodes],
);
/** 开关:默认关(显示以根为作用域的整棵树),打开后只看父代与直接子代,再点一次取消。 */
const [onlyRelated, setOnlyRelated] = useState(false);
/**
* 顶部筛选器:按世代筛选(保留该代及其祖先,树不断链)与按热度排序
* (同代内 `playCount` 倒序;关闭时保持服务端顺序 = 创建时间序)。
@@ -307,8 +332,16 @@ export function GameLineagePage({
generationFilter === null
? [...nodes]
: nodes.filter((node) => node.generation <= generationFilter);
if (!sortByHeat) return kept;
return kept
// 「只看父代与子代」:作用域里没有锚点(例如锚点不在可见集合里)时不筛选,
// 免得渲染出一张空图。
const scoped = onlyRelated
? kept.filter((node) => relatedScope.has(node.gameId))
: kept;
const useScoped =
onlyRelated && scoped.some((node) => node.gameId === anchorId);
const result = useScoped ? scoped : kept;
if (!sortByHeat) return result;
return result
.map((node, index) => ({ node, index }))
.sort(
(left, right) =>
@@ -317,7 +350,14 @@ export function GameLineagePage({
left.index - right.index,
)
.map((item) => item.node);
}, [generationFilter, nodes, sortByHeat]);
}, [
anchorId,
generationFilter,
nodes,
onlyRelated,
relatedScope,
sortByHeat,
]);
const generationsPresent = useMemo(
() =>
[...new Set(nodes.map((node) => node.generation))].sort((a, b) => a - b),
@@ -379,7 +419,6 @@ export function GameLineagePage({
generation: item.generation,
}));
}, [layout.nodes, pathIds, pinnedGameId]);
const anchorId = highlightGameId?.trim() || normalizedRootId;
const effectiveViewMode =
viewMode ?? (isMobileViewport ? 'outline' : 'canvas');
const trunkGameIds = useMemo(
@@ -789,6 +828,23 @@ export function GameLineagePage({
>
{effectiveViewMode === 'canvas' ? '大纲视图' : '图谱视图'}
</button>
{/*
「只看父代与子代」:默认关(整棵树)。打开后只留当前作品的祖先链(父代)
与**直接**子代;再点一次取消。作用是「聚焦一支」——树大了以后不必在旁支里找。
*/}
<button
type="button"
className="game-lineage-filter__switch game-lineage-filter__switch--relation"
aria-pressed={onlyRelated}
onClick={() => setOnlyRelated((current) => !current)}
>
{onlyRelated ? '只看父代与子代(已开启)' : '只看父代与子代'}
</button>
{onlyRelated ? (
<span className="game-lineage-filter__note" role="status">
只显示当前作品的父代(祖先链)与直接子代,再点一次恢复整棵树
</span>
) : null}
{/*
改动类型筛选:`changeSummary` 字段已上线,但它是**自由文本**(服务端没有分类枚举),
因此仍然**禁用 + 说明**,不编造筛选值域;等后端给出可枚举的改动类型再接。
@@ -885,6 +941,7 @@ export function GameLineagePage({
currentGameId={anchorId}
trunkGameIds={trunkGameIds}
selectedGameId={pinnedGameId}
relationByGameId={relationRoles}
onSelect={(gameId) =>
setPinnedGameId((current) =>
current === gameId ? '' : gameId,
@@ -950,6 +1007,9 @@ export function GameLineagePage({
isOnPath={Boolean(pathIds?.has(layoutNode.gameId))}
isPinned={pinnedGameId === layoutNode.gameId}
compact={compactTree}
relation={
relationRoles.get(layoutNode.gameId) ?? 'side'
}
onTogglePin={setPinnedGameId}
onOpenDetail={onOpenDetail}
onActivate={setActiveGameId}
@@ -1,5 +1,6 @@
import type { GameDistributionGame } from '../../services/gameDistributionClient';
import { GameCover } from './GameCover';
import type { LineageRelationRole } from './lineageTreeLayout';
import { generationAxisLabel } from './lineageTreeLayout';
/**
@@ -21,6 +22,7 @@ export function LineageNodeContent({
isStub = false,
isTrunk = false,
compact = false,
relation = 'side',
}: {
title: string | null;
authorName: string | null;
@@ -32,6 +34,12 @@ export function LineageNodeContent({
isTrunk?: boolean;
/** 画布卡片的紧凑态(窄屏图谱):省掉作者行;大纲/血缘链行始终保留作者名。 */
compact?: boolean;
/**
* 相对「当前作品」的角色:父代(祖先链)/ 子代(后代)/ 自己 / 旁支。
* 只给**父代 / 子代**渲染角标(自己是「当前作品」角标、旁支不标注),
* 是可读文本而不是只靠颜色,因此可断言。
*/
relation?: LineageRelationRole;
}) {
const displayTitle = isStub ? '原作品已不可用' : (title ?? '未命名作品');
const authorLabel = isStub
@@ -65,6 +73,15 @@ export function LineageNodeContent({
</span>
<span className="game-lineage-node__text">
<span className="game-lineage-node__title">{displayTitle}</span>
{relation === 'ancestor' || relation === 'descendant' ? (
// 「父代 / 子代」是可读文本角标(不是只靠描边颜色),所以能被断言、也能被读屏。
<span
className={`game-lineage-node__relation game-lineage-node__relation--${relation}`}
data-relation={relation}
>
{relation === 'ancestor' ? '父代' : '子代'}
</span>
) : null}
{/*
作者名在两种形态都渲染(画布卡片曾因重构一度漏掉,导致节点少了作者信息、
也踩到了 e2e 的作者名断言):只有窄屏图谱的紧凑卡片才省掉它。
@@ -1,4 +1,5 @@
import { LineageNodeContent } from './LineageNodeContent';
import type { LineageRelationRole } from './lineageTreeLayout';
/**
* 移动端**大纲式缩进树**(默认形态):全宽行 + 左缩进 + 肘形导线。
@@ -15,6 +16,7 @@ export function LineageOutlineList({
currentGameId,
trunkGameIds,
selectedGameId,
relationByGameId,
onSelect,
onOpenDetail,
onOpenFork,
@@ -31,6 +33,8 @@ export function LineageOutlineList({
currentGameId: string;
trunkGameIds: ReadonlySet<string>;
selectedGameId: string;
/** 每个节点相对「当前作品」的角色(父代 / 子代 / 自己 / 旁支):与画布同一份口径。 */
relationByGameId: ReadonlyMap<string, LineageRelationRole>;
onSelect: (gameId: string) => void;
onOpenDetail: (gameId: string) => void;
onOpenFork: (gameId: string) => void;
@@ -40,6 +44,7 @@ export function LineageOutlineList({
{nodes.map((node) => {
const isCurrent = node.gameId === currentGameId;
const isSelected = node.gameId === selectedGameId;
const relation = relationByGameId.get(node.gameId) ?? 'side';
return (
<li
key={node.gameId}
@@ -47,6 +52,7 @@ export function LineageOutlineList({
isSelected ? ' lineage-outline__row--selected' : ''
}`}
data-generation={node.generation}
data-relation={relation}
// 层级用左缩进表达:每代 1.1rem,最多缩进 6 代避免窄屏被挤没。
style={{ paddingLeft: `${Math.min(node.generation, 6) * 1.1}rem` }}
>
@@ -82,6 +88,7 @@ export function LineageOutlineList({
isAnchor={isCurrent}
isStub={node.isStub}
isTrunk={trunkGameIds.has(node.gameId)}
relation={relation}
/>
</button>
{node.isStub ? null : (
@@ -2434,6 +2434,45 @@
font-weight: 600;
cursor: pointer;
}
/* 开关按下态:明确的可视反馈(不是只靠 aria-pressed 属性)。 */
.game-lineage-filter__switch[aria-pressed='true'] {
border-color: var(--platform-accent-strong);
background: rgba(199, 101, 61, 0.12);
color: var(--platform-accent-strong);
}
/*
* 「父代 / 子代」角标:**可读文本**(不是只靠颜色深浅),所以可断言、读屏也能读到;
* 自己是「当前作品」角标、旁支不标注。颜色只是辅助表达。
*/
.game-lineage-node__relation {
align-self: flex-start;
padding: 0 0.35rem;
border: 1px solid currentColor;
border-radius: 999px;
font-size: 0.62rem;
line-height: 1.6;
white-space: nowrap;
}
.game-lineage-node__relation--ancestor {
color: var(--platform-accent-strong);
background: rgba(199, 101, 61, 0.1);
}
.game-lineage-node__relation--descendant {
color: var(--platform-text-base);
background: var(--platform-surface-muted, rgba(0, 0, 0, 0.05));
}
/* 画布卡片按角色加描边(辅助:主表达仍是上面的角标与 data-relation)。 */
.game-lineage-node[data-relation='ancestor'] {
border-color: var(--platform-accent-strong);
}
.lineage-outline__row[data-relation='ancestor'] .lineage-outline__main {
border-left: 3px solid var(--platform-accent-strong);
}
.lineage-outline__row[data-relation='descendant'] .lineage-outline__main {
border-left: 3px solid var(--platform-surface-border);
}
.game-lineage-filter__switch[aria-pressed='true'] {
border-color: var(--platform-accent-strong);
color: var(--platform-accent-strong);
@@ -7,6 +7,8 @@ import {
buildLineageTreeLayout,
collectLineageAncestorChain,
collectLineageHighlight,
collectLineageImmediateScope,
collectLineageRelationRoles,
collectLineageSubtreeDepth,
generationAxisLabel,
LINEAGE_COLUMN_GAP,
@@ -317,9 +319,9 @@ describe('横向朝向(移动端图谱视图)', () => {
expect(hById.get('a')!.y).not.toBe(hById.get('b')!.y);
expect(hById.get('a')!.x).toBe(hById.get('b')!.x);
// 同代(同一列)节点在纵向上至少隔一个列间距,结构上不重叠。
expect(Math.abs(hById.get('a')!.y - hById.get('b')!.y)).toBeGreaterThanOrEqual(
hById.get('a')!.height + 1,
);
expect(
Math.abs(hById.get('a')!.y - hById.get('b')!.y),
).toBeGreaterThanOrEqual(hById.get('a')!.height + 1);
expect(vertical.nodes[0]!.y).toBeLessThan(vertical.nodes[1]!.y);
});
@@ -435,3 +437,103 @@ describe('collectLineageHighlight', () => {
expect([...highlighted].sort()).toEqual(['child', 'game-removed']);
});
});
/**
* 血缘关系数据集:root → a → a1 → a1x,root → b(旁支),a → a2(第二个子代)。
* 用来钉住「父代 / 子代 / 自己 / 旁支」四类角色与「只看父代与子代」的集合口径。
*/
const relationTree = [
node('root', 0, null),
node('a', 1, 'root'),
node('b', 1, 'root'),
node('a1', 2, 'a'),
node('a2', 2, 'a'),
node('a1x', 3, 'a1'),
];
describe('collectLineageRelationRoles', () => {
it('区分自己 / 父代(祖先链)/ 子代(整棵后代子树)/ 旁支', () => {
const roles = collectLineageRelationRoles(relationTree, 'a');
expect(roles.get('a')).toBe('self');
expect(roles.get('root')).toBe('ancestor');
expect(roles.get('a1')).toBe('descendant');
expect(roles.get('a2')).toBe('descendant');
expect(roles.get('a1x')).toBe('descendant');
// 兄弟分支是旁支:不冒充父代/子代。
expect(roles.get('b')).toBe('side');
});
it('根节点:自己没有父代,所有后代都是子代', () => {
const roles = collectLineageRelationRoles(relationTree, 'root');
expect([...roles.entries()].filter(([, r]) => r === 'ancestor')).toEqual(
[],
);
expect(
[...roles.entries()]
.filter(([, r]) => r === 'descendant')
.map(([id]) => id)
.sort(),
).toEqual(['a', 'a1', 'a1x', 'a2', 'b']);
});
it('叶节点:只有父代链,没有子代', () => {
const roles = collectLineageRelationRoles(relationTree, 'a1x');
expect(roles.get('a1x')).toBe('self');
expect(
[...roles.entries()]
.filter(([, r]) => r === 'ancestor')
.map(([id]) => id)
.sort(),
).toEqual(['a', 'a1', 'root']);
expect(
[...roles.values()].filter((role) => role === 'descendant'),
).toHaveLength(0);
});
it('单节点树:自己是 self,没有父代/子代', () => {
const roles = collectLineageRelationRoles([node('solo', 0, null)], 'solo');
expect([...roles.entries()]).toEqual([['solo', 'self']]);
});
it('锚点不在可见集合里:全部按旁支返回(不抛错、不臆造关系)', () => {
const roles = collectLineageRelationRoles(relationTree, 'game-missing');
expect([...roles.values()].every((role) => role === 'side')).toBe(true);
});
it('自环数据不会死循环', () => {
const cyclic = [node('x', 1, 'y'), node('y', 0, 'x')];
const roles = collectLineageRelationRoles(cyclic, 'x');
expect(roles.get('x')).toBe('self');
expect(roles.get('y')).toBe('ancestor');
});
});
describe('collectLineageImmediateScope(只看父代与子代)', () => {
it('保留祖先链 + 自己 + **直接**子代(不含孙代)', () => {
const scope = collectLineageImmediateScope(relationTree, 'a');
// a1x 是孙代(a 的子代的子代),不在「只看父子」的范围里。
expect([...scope].sort()).toEqual(['a', 'a1', 'a2', 'root']);
expect(scope.has('a1x')).toBe(false);
expect(scope.has('b')).toBe(false);
});
it('根节点:自己 + 直接子代', () => {
expect(
[...collectLineageImmediateScope(relationTree, 'root')].sort(),
).toEqual(['a', 'b', 'root']);
});
it('离树的锚点返回空集合(调用方据此不筛选)', () => {
expect(
collectLineageImmediateScope(relationTree, 'game-missing').size,
).toBe(0);
});
it('父代不在可见集合里时链停在子节点上(父仍由布局层补占位)', () => {
const scope = collectLineageImmediateScope(
[node('child', 1, 'game-removed'), node('grandchild', 2, 'child')],
'child',
);
expect([...scope].sort()).toEqual(['child', 'grandchild']);
});
});
@@ -308,8 +308,14 @@ export function buildLineageTreeLayout(
const horizontal = orientation === 'horizontal';
// 竖版:父底中点 → 子顶中点;横版:父右边中点 → 子左边中点。
const from = horizontal
? { x: roundTo(parent.x + parent.width), y: roundTo(parent.y + parent.height / 2) }
: { x: roundTo(parent.x + parent.width / 2), y: roundTo(parent.y + parent.height) };
? {
x: roundTo(parent.x + parent.width),
y: roundTo(parent.y + parent.height / 2),
}
: {
x: roundTo(parent.x + parent.width / 2),
y: roundTo(parent.y + parent.height),
};
const to = horizontal
? { x: roundTo(node.x), y: roundTo(node.y + node.height / 2) }
: { x: roundTo(node.x + node.width / 2), y: roundTo(node.y) };
@@ -321,7 +327,10 @@ export function buildLineageTreeLayout(
);
const mid = roundTo((horizontal ? from.x : from.y) + delta * 0.5);
const path = (() => {
if (cornerRadius <= 0 || (horizontal ? from.y === to.y : from.x === to.x)) {
if (
cornerRadius <= 0 ||
(horizontal ? from.y === to.y : from.x === to.x)
) {
return horizontal
? `M ${from.x},${from.y} H ${to.x}`
: `M ${from.x},${from.y} V ${to.y}`;
@@ -448,6 +457,89 @@ export function collectLineageAncestorChain<
return chain.reverse();
}
/** 节点相对「当前作品」的角色(文档口径的**父代 / 子代**)。 */
export type LineageRelationRole = 'self' | 'ancestor' | 'descendant' | 'side';
/**
* 每个节点相对 `gameId` 的角色:
*
* - `self`:当前作品本体;
* - `ancestor`:沿 `parentGameId` 上溯到的**父代**(祖先链,不含自己);
* - `descendant`:沿 `parentGameId` **反向**走到的**子代**(整棵后代子树);
* - `side`:与当前作品没有直系血缘的旁支。
*
* 纯函数、稳定(同输入同输出);渲染层据此打 `data-relation` 并显示「父代 / 子代」角标,
* 不用「颜色深浅」这类无法断言的表达。
*/
export function collectLineageRelationRoles<
T extends { gameId: string; parentGameId?: string | null },
>(nodes: readonly T[], gameId: string): Map<string, LineageRelationRole> {
const roles = new Map<string, LineageRelationRole>();
for (const node of nodes) roles.set(node.gameId, 'side');
const byId = new Map(nodes.map((node) => [node.gameId, node]));
if (!byId.has(gameId)) return roles;
roles.set(gameId, 'self');
// 父代:沿 parentGameId 上溯(防环)。
const guard = new Set<string>([gameId]);
let cursor = byId.get(gameId);
while (cursor?.parentGameId) {
const parentId = cursor.parentGameId;
if (guard.has(parentId) || !byId.has(parentId)) break;
guard.add(parentId);
roles.set(parentId, 'ancestor');
cursor = byId.get(parentId);
}
// 子代:按 parentGameId 反向 BFS(整棵后代子树)。
const childrenByParent = new Map<string, string[]>();
for (const node of nodes) {
const parentId = node.parentGameId ?? null;
if (!parentId) continue;
const bucket = childrenByParent.get(parentId);
if (bucket) bucket.push(node.gameId);
else childrenByParent.set(parentId, [node.gameId]);
}
const stack = [...(childrenByParent.get(gameId) ?? [])];
while (stack.length > 0) {
const id = stack.pop() as string;
// 只给还是「旁支」的节点定子代:脏数据里出现环时(某人既是祖先又是后代),
// 先判定的**父代**优先,保证结果稳定、不来回翻。
if (roles.get(id) !== 'side') continue;
roles.set(id, 'descendant');
stack.push(...(childrenByParent.get(id) ?? []));
}
return roles;
}
/**
* 「只看父代与子代」要保留的节点集合:当前作品的**祖先链**(父代)+ **直接子代** + 自己。
*
* 注意是**直接**子代(下一层),不是整棵后代子树——开关的语义是「只看这个作品的上一步与下一步」,
* 想看整棵树关掉开关即可。父代不在可见集合里时链停在那里(与「父作品不可用」口径一致,
* 布局层仍会给那个缺失的父补一个占位节点)。
*
* 找不到 `gameId` 时返回空集合:调用方据此「不筛选」而不是渲染空图。
*/
export function collectLineageImmediateScope<
T extends { gameId: string; parentGameId?: string | null },
>(nodes: readonly T[], gameId: string): Set<string> {
const scope = new Set<string>();
const byId = new Map(nodes.map((node) => [node.gameId, node]));
if (!byId.has(gameId)) return scope;
scope.add(gameId);
let cursor = byId.get(gameId);
while (cursor?.parentGameId) {
const parentId = cursor.parentGameId;
if (scope.has(parentId) || !byId.has(parentId)) break;
scope.add(parentId);
cursor = byId.get(parentId);
}
for (const node of nodes) {
if ((node.parentGameId ?? null) === gameId) scope.add(node.gameId);
}
return scope;
}
/**
* hover / 聚焦一个节点时要高亮的集合:它自己 + 它的祖先路径;`includeSubtree` 为真时再加上
* 它整棵子树。其余节点在渲染层降透明度。