族谱:父代/子代可断言区分 + 「只看父代与子代」可取消开关;侧栏卡文案对齐口径
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
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:
@@ -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` 为真时再加上
|
||||
* 它整棵子树。其余节点在渲染层降透明度。
|
||||
|
||||
Reference in New Issue
Block a user