共创模块B: 族谱页顶部筛选器 + 主干线(每代热度最高) + 右侧信息面板
This commit is contained in:
@@ -2347,15 +2347,15 @@ describe('GameDetailPage 共创主题入口', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameLineagePage 可视化与交互', () => {
|
||||
/** 节点卡片上的主行动作按钮(打开详情)与它的卡片容器。 */
|
||||
const openButton = (title: string) =>
|
||||
screen.getByRole('button', { name: `打开《${title}》详情` });
|
||||
const cardOf = async (title: string) =>
|
||||
(
|
||||
await screen.findByRole('button', { name: `打开《${title}》详情` })
|
||||
).closest('.game-lineage-node') as HTMLElement;
|
||||
/** 节点卡片上的主行动作按钮(打开详情)与它的卡片容器:多个 describe 共用。 */
|
||||
const openButton = (title: string) =>
|
||||
screen.getByRole('button', { name: `打开《${title}》详情` });
|
||||
const cardOf = async (title: string) =>
|
||||
(
|
||||
await screen.findByRole('button', { name: `打开《${title}》详情` })
|
||||
).closest('.game-lineage-node') as HTMLElement;
|
||||
|
||||
describe('GameLineagePage 可视化与交互', () => {
|
||||
const treeWithSibling = {
|
||||
rootGameId: 'game-root',
|
||||
root: createLineageNode({
|
||||
@@ -2739,6 +2739,155 @@ describe('GameLineagePage 可视化与交互', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)', () => {
|
||||
const tree = {
|
||||
rootGameId: 'game-root',
|
||||
root: createLineageNode({
|
||||
gameId: 'game-root',
|
||||
title: '根原创',
|
||||
generation: 0,
|
||||
parentGameId: null,
|
||||
playCount: 5,
|
||||
}),
|
||||
nodes: [
|
||||
createLineageNode({
|
||||
gameId: 'game-root',
|
||||
title: '根原创',
|
||||
generation: 0,
|
||||
parentGameId: null,
|
||||
playCount: 5,
|
||||
}),
|
||||
createLineageNode({
|
||||
gameId: 'hot',
|
||||
title: '热门二创',
|
||||
generation: 1,
|
||||
parentGameId: 'game-root',
|
||||
playCount: 999,
|
||||
}),
|
||||
createLineageNode({
|
||||
gameId: 'cold',
|
||||
title: '冷门二创',
|
||||
generation: 1,
|
||||
parentGameId: 'game-root',
|
||||
playCount: 1,
|
||||
}),
|
||||
createLineageNode({
|
||||
gameId: 'deep',
|
||||
title: '深代作品',
|
||||
generation: 2,
|
||||
parentGameId: 'hot',
|
||||
playCount: 3,
|
||||
}),
|
||||
],
|
||||
truncated: false,
|
||||
};
|
||||
|
||||
it('顶部筛选器:世代筛选只留该代及祖先,改动类型禁用并说明原因', async () => {
|
||||
getGameLineageMock.mockResolvedValueOnce(tree);
|
||||
render(
|
||||
<GameLineagePage
|
||||
rootGameId="game-root"
|
||||
onBack={vi.fn()}
|
||||
onOpenDetail={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText('深代作品');
|
||||
const changeType = screen.getByLabelText('按改动类型筛选');
|
||||
expect((changeType as HTMLSelectElement).disabled).toBe(true);
|
||||
expect(
|
||||
screen.getByText('核心改动说明字段尚未上线'),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('按世代筛选'), {
|
||||
target: { value: '1' },
|
||||
});
|
||||
// 保留第 0、1 代(树不断链),第 2 代被筛掉。
|
||||
expect(screen.queryByText('深代作品')).toBeNull();
|
||||
expect(await cardOf('热门二创')).toBeTruthy();
|
||||
expect(await cardOf('根原创')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('按热度排序:同代内 playCount 高的排在前面', async () => {
|
||||
getGameLineageMock.mockResolvedValueOnce(tree);
|
||||
render(
|
||||
<GameLineagePage
|
||||
rootGameId="game-root"
|
||||
onBack={vi.fn()}
|
||||
onOpenDetail={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText('热门二创');
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '按热度排序' }));
|
||||
|
||||
const coldCard = await cardOf('冷门二创');
|
||||
const hotCard = await cardOf('热门二创');
|
||||
expect(parseFloat(hotCard.style.left)).toBeLessThan(
|
||||
parseFloat(coldCard.style.left),
|
||||
);
|
||||
});
|
||||
|
||||
it('主干线:每代热度最高的节点带「主干」角标,主干边单独标记', async () => {
|
||||
getGameLineageMock.mockResolvedValueOnce(tree);
|
||||
render(
|
||||
<GameLineagePage
|
||||
rootGameId="game-root"
|
||||
onBack={vi.fn()}
|
||||
onOpenDetail={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText('热门二创');
|
||||
// 第 1 代主干 = 热门二创(999 > 1);冷门二创不是主干。
|
||||
expect(
|
||||
(await cardOf('热门二创')).querySelector('.game-lineage-node__trunk'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
(await cardOf('冷门二创')).querySelector('.game-lineage-node__trunk'),
|
||||
).toBeNull();
|
||||
// 主干线:root→hot 与 hot→deep 两条。
|
||||
expect(
|
||||
document.querySelectorAll('path.game-lineage-edge--trunk').length,
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('选中节点后右侧信息面板给出简介/授权与「前往体验 / Fork 此版本」', async () => {
|
||||
getGameLineageMock.mockResolvedValueOnce(tree);
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
createGame({
|
||||
summary: '这是一个二创作品的简介',
|
||||
forkAuthorization: 'nonCommercial',
|
||||
}),
|
||||
);
|
||||
const onOpenDetail = vi.fn();
|
||||
render(
|
||||
<GameLineagePage
|
||||
rootGameId="game-root"
|
||||
onBack={vi.fn()}
|
||||
onOpenDetail={onOpenDetail}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hotCard = await cardOf('热门二创');
|
||||
// 面板只在选中后出现。
|
||||
expect(screen.queryByLabelText('选中作品信息')).toBeNull();
|
||||
fireEvent.click(hotCard);
|
||||
|
||||
const panel = await screen.findByLabelText('选中作品信息');
|
||||
expect(await within(panel).findByText('这是一个二创作品的简介')).toBeTruthy();
|
||||
expect(within(panel).getByText(/允许非商用共创/u)).toBeTruthy();
|
||||
// 「核心改动说明」字段还没上:不渲染这一行(不假装有数据)。
|
||||
expect(within(panel).queryByText(/核心改动说明/u)).toBeNull();
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '前往体验' }));
|
||||
expect(onOpenDetail).toHaveBeenCalledWith('hot');
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Fork 此版本' }));
|
||||
expect(openForkProtocolMock).toHaveBeenCalledWith('hot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameDetailPage 共创模块(文档对齐)', () => {
|
||||
it('世代/授权标签按文档口径渲染,并显示累计衍生作品数', async () => {
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
|
||||
@@ -11,9 +11,14 @@ import {
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS,
|
||||
resolveGameForkAuthorization,
|
||||
} from '../../../packages/shared/src/contracts/gameDistribution';
|
||||
import {
|
||||
type GameDistributionGame,
|
||||
type GameDistributionLineageResponse,
|
||||
getGame,
|
||||
getGameLineage,
|
||||
} from '../../services/gameDistributionClient';
|
||||
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
|
||||
@@ -189,6 +194,11 @@ function LineageTreeNodeCard({
|
||||
<span className="game-lineage-node__badge">
|
||||
{generationAxisLabel(generation)}
|
||||
</span>
|
||||
{layoutNode.isTrunk ? (
|
||||
<span className="game-lineage-node__trunk" title="该代热度最高的版本">
|
||||
主干
|
||||
</span>
|
||||
) : null}
|
||||
{isAnchor ? (
|
||||
<span className="game-lineage-node__anchor">当前作品</span>
|
||||
) : null}
|
||||
@@ -253,6 +263,11 @@ export function GameLineagePage({
|
||||
const [error, setError] = useState('');
|
||||
const [activeGameId, setActiveGameId] = useState('');
|
||||
const [pinnedGameId, setPinnedGameId] = useState('');
|
||||
const [generationFilter, setGenerationFilter] = useState<number | null>(null);
|
||||
const [sortByHeat, setSortByHeat] = useState(false);
|
||||
const [selectedGame, setSelectedGame] = useState<GameDistributionGame | null>(
|
||||
null,
|
||||
);
|
||||
const isMobileViewport = useIsMobileViewport();
|
||||
const [includeSubtree, setIncludeSubtree] = useState(true);
|
||||
const [viewport, setViewport] = useState<ViewportState>({
|
||||
@@ -307,12 +322,36 @@ export function GameLineagePage({
|
||||
}, [loadLineage]);
|
||||
|
||||
const nodes = useMemo(() => lineage?.nodes ?? [], [lineage]);
|
||||
/**
|
||||
* 顶部筛选器:按世代筛选(保留该代及其祖先,树不断链)与按热度排序
|
||||
* (同代内 `playCount` 倒序;关闭时保持服务端顺序 = 创建时间序)。
|
||||
*/
|
||||
const filteredNodes = useMemo(() => {
|
||||
const kept =
|
||||
generationFilter === null
|
||||
? [...nodes]
|
||||
: nodes.filter((node) => node.generation <= generationFilter);
|
||||
if (!sortByHeat) return kept;
|
||||
return kept
|
||||
.map((node, index) => ({ node, index }))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.node.generation - right.node.generation ||
|
||||
right.node.playCount - left.node.playCount ||
|
||||
left.index - right.index,
|
||||
)
|
||||
.map((item) => item.node);
|
||||
}, [generationFilter, nodes, sortByHeat]);
|
||||
const generationsPresent = useMemo(
|
||||
() => [...new Set(nodes.map((node) => node.generation))].sort((a, b) => a - b),
|
||||
[nodes],
|
||||
);
|
||||
// 窄屏用紧凑布局(更小的卡片与间距 + 更大的相对字号),避免「桌面画布缩小 + 字太小」。
|
||||
const compactTree = isMobileViewport;
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
buildLineageTreeLayout(
|
||||
nodes,
|
||||
filteredNodes,
|
||||
compactTree
|
||||
? {
|
||||
// 紧凑态:卡片 176 宽 → 16:9 封面 99 高 + 信息条;度量仍满足「同层 ≥ columnGap」。
|
||||
@@ -324,7 +363,7 @@ export function GameLineagePage({
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
[compactTree, nodes],
|
||||
[compactTree, filteredNodes],
|
||||
);
|
||||
// 高亮目标:锁定(点击「锁定路径」)优先于 hover/聚焦;锁定后即使祖先移出视口,路径仍由面包屑兜底。
|
||||
const highlightTargetId = pinnedGameId || activeGameId;
|
||||
@@ -430,6 +469,41 @@ export function GameLineagePage({
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 右侧信息面板的数据:只为**选中的那一个节点**请求一次公开详情(简介 / 授权类型)。
|
||||
* 「核心改动说明」等后端字段,缺失就不渲染这一行。
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!pinnedGameId) {
|
||||
setSelectedGame(null);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const detail = await getGame(pinnedGameId);
|
||||
if (alive) setSelectedGame(detail ?? null);
|
||||
} catch {
|
||||
if (alive) setSelectedGame(null);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [pinnedGameId]);
|
||||
const selectedLayoutNode = pinnedGameId
|
||||
? (layout.nodes.find((item) => item.gameId === pinnedGameId) ?? null)
|
||||
: null;
|
||||
const selectedForkAuthorization = selectedGame
|
||||
? resolveGameForkAuthorization(selectedGame.forkAuthorization)
|
||||
: null;
|
||||
const selectedCoreChangeNote =
|
||||
selectedGame && 'coreChangeNote' in selectedGame
|
||||
? typeof selectedGame.coreChangeNote === 'string'
|
||||
? selectedGame.coreChangeNote
|
||||
: ''
|
||||
: '';
|
||||
|
||||
const zoomBy = useCallback((factor: number) => {
|
||||
setViewport((current) => {
|
||||
const scale = Math.min(
|
||||
@@ -593,6 +667,47 @@ export function GameLineagePage({
|
||||
</PlatformEmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div className="game-lineage-filters">
|
||||
<label className="game-lineage-filter">
|
||||
<span>世代</span>
|
||||
<select
|
||||
aria-label="按世代筛选"
|
||||
value={generationFilter === null ? 'all' : String(generationFilter)}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setGenerationFilter(value === 'all' ? null : Number(value));
|
||||
}}
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
{generationsPresent.map((generation) => (
|
||||
<option key={generation} value={generation}>
|
||||
{generationAxisLabel(generation)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="game-lineage-filter game-lineage-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sortByHeat}
|
||||
onChange={(event) => setSortByHeat(event.target.checked)}
|
||||
/>
|
||||
按热度排序
|
||||
</label>
|
||||
{/*
|
||||
改动类型筛选依赖「本次核心改动说明」字段(后端还没上)。
|
||||
按「字段可能存在」处理:**禁用 + 说明**,不假装能筛。
|
||||
*/}
|
||||
<label className="game-lineage-filter game-lineage-filter--disabled">
|
||||
<span>改动类型</span>
|
||||
<select aria-label="按改动类型筛选" disabled>
|
||||
<option>暂不可用</option>
|
||||
</select>
|
||||
<span className="game-lineage-filter__note">
|
||||
核心改动说明字段尚未上线
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="game-lineage-viewport-controls">
|
||||
<label className="game-lineage-toggle">
|
||||
<input
|
||||
@@ -697,13 +812,14 @@ export function GameLineagePage({
|
||||
['--lineage-branch' as string]: `var(--lineage-branch-${edge.branchIndex % 6})`,
|
||||
}}
|
||||
className={
|
||||
highlighted &&
|
||||
(edge.isTrunk ? 'game-lineage-edge--trunk ' : '') +
|
||||
(highlighted &&
|
||||
highlighted.has(edge.parentGameId) &&
|
||||
highlighted.has(edge.childGameId)
|
||||
? 'game-lineage-edge game-lineage-edge--active'
|
||||
: highlighted
|
||||
? 'game-lineage-edge game-lineage-edge--dimmed'
|
||||
: 'game-lineage-edge'
|
||||
: 'game-lineage-edge')
|
||||
}
|
||||
/>
|
||||
))}
|
||||
@@ -814,6 +930,74 @@ export function GameLineagePage({
|
||||
<span className="game-lineage-controls__label">刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
{/*
|
||||
右侧信息面板:只在**选中节点**时出现,浮动叠加(不推挤画布)。
|
||||
简介 / 授权类型只为选中的这一个节点请求一次公开详情;
|
||||
「核心改动说明」是后端待补字段,缺失时整行不渲染。
|
||||
*/}
|
||||
{selectedLayoutNode ? (
|
||||
<aside
|
||||
className="game-lineage-panel"
|
||||
aria-label="选中作品信息"
|
||||
>
|
||||
<h3 className="game-lineage-panel__title">
|
||||
{selectedLayoutNode.isStub
|
||||
? '原作品已不可用'
|
||||
: (selectedLayoutNode.title ?? '未命名作品')}
|
||||
</h3>
|
||||
<p className="game-lineage-panel__meta">
|
||||
{generationAxisLabel(selectedLayoutNode.generation)} ·{' '}
|
||||
{selectedLayoutNode.isStub
|
||||
? '标题与作者名不对外展示'
|
||||
: (selectedLayoutNode.authorName ?? '').trim() ||
|
||||
'未署名作者'}
|
||||
</p>
|
||||
{selectedLayoutNode.isStub ? (
|
||||
<p className="game-lineage-panel__note">
|
||||
来源作品已删除或未公开,只保留代际位置。
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="game-lineage-panel__summary">
|
||||
{selectedGame?.summary?.trim() || '暂无简介'}
|
||||
</p>
|
||||
<p className="game-lineage-panel__meta">
|
||||
授权:
|
||||
{selectedForkAuthorization
|
||||
? GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[
|
||||
selectedForkAuthorization
|
||||
]
|
||||
: '读取中…'}
|
||||
</p>
|
||||
{selectedCoreChangeNote ? (
|
||||
<p className="game-lineage-panel__note">
|
||||
核心改动说明:{selectedCoreChangeNote}
|
||||
</p>
|
||||
) : null}
|
||||
<span className="game-lineage-panel__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="game-lineage-panel__button"
|
||||
onClick={() =>
|
||||
onOpenDetail(selectedLayoutNode.gameId)
|
||||
}
|
||||
>
|
||||
前往体验
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="game-lineage-panel__button game-lineage-panel__button--primary"
|
||||
onClick={() =>
|
||||
launchForkFromNode(selectedLayoutNode.gameId)
|
||||
}
|
||||
>
|
||||
Fork 此版本
|
||||
</button>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
{derivedCount === 0 ? (
|
||||
<PlatformEmptyState surface="subpanel" size="panel">
|
||||
|
||||
@@ -1018,6 +1018,109 @@
|
||||
transition: transform 180ms ease-out;
|
||||
will-change: transform;
|
||||
}
|
||||
/* 顶部筛选器(文档「顶部操作栏」):世代 / 热度排序 / 改动类型(待字段,禁用)。 */
|
||||
.game-lineage-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.game-lineage-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.game-lineage-filter select {
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.15rem 0.35rem;
|
||||
background: var(--platform-surface, #fff);
|
||||
color: var(--platform-text-base);
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.game-lineage-filter--disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.game-lineage-filter__note {
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
/* 主干线:文档要求「每代热度最高的版本用粗实线连接」。 */
|
||||
.game-lineage-edge--trunk {
|
||||
stroke-width: 6;
|
||||
}
|
||||
.game-lineage-node__trunk {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
right: 0.35rem;
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
background: var(--platform-accent-strong);
|
||||
color: #fff;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
/* 右侧信息面板:浮动叠加在画布右上(控件在右下,避免互相遮挡)。 */
|
||||
.game-lineage-panel {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 0.6rem;
|
||||
right: 0.6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
width: min(18rem, calc(100% - 1.2rem));
|
||||
max-height: calc(100% - 4.5rem);
|
||||
overflow-y: auto;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 0.8rem;
|
||||
background: var(--platform-surface, #fff);
|
||||
box-shadow: 0 10px 26px rgba(76, 41, 27, 0.18);
|
||||
}
|
||||
.game-lineage-panel__title {
|
||||
margin: 0;
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 0.9rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.game-lineage-panel__meta,
|
||||
.game-lineage-panel__note,
|
||||
.game-lineage-panel__summary {
|
||||
margin: 0;
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.game-lineage-panel__summary {
|
||||
color: var(--platform-text-base);
|
||||
}
|
||||
.game-lineage-panel__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.game-lineage-panel__button {
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.7rem;
|
||||
background: transparent;
|
||||
color: var(--platform-text-base);
|
||||
font: inherit;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.game-lineage-panel__button--primary {
|
||||
border-color: var(--platform-accent-strong);
|
||||
background: rgba(199, 101, 61, 0.14);
|
||||
color: var(--platform-accent-strong);
|
||||
}
|
||||
.game-lineage-canvas__edges {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -2198,3 +2301,18 @@
|
||||
font-size: 0.76rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* 个人创作数据概览(我的作品页):与详情页共创模块同一视觉语言。 */
|
||||
.co-creation-overview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 0.7rem;
|
||||
background: var(--platform-surface-muted, rgba(104, 54, 36, 0.04));
|
||||
}
|
||||
.co-creation-overview .co-creation-stat {
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -260,6 +260,39 @@ describe('buildLineageTreeLayout', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('主干线(每代热度最高的版本)', () => {
|
||||
it('按 playCount 选出每代主干节点,主干边标记 isTrunk', () => {
|
||||
const layout = buildLineageTreeLayout([
|
||||
node('root', 0, null),
|
||||
node('hot', 1, 'root'),
|
||||
node('cold', 1, 'root'),
|
||||
node('hot-child', 2, 'hot'),
|
||||
]);
|
||||
const byId = new Map(layout.nodes.map((item) => [item.gameId, item]));
|
||||
// node() 里 playCount = generation * 10,同代并列 → 取先出现的那个。
|
||||
expect(byId.get('root')!.isTrunk).toBe(true);
|
||||
expect(byId.get('hot')!.isTrunk).toBe(true);
|
||||
expect(byId.get('cold')!.isTrunk).toBe(false);
|
||||
const trunkEdges = layout.edges.filter((edge) => edge.isTrunk);
|
||||
expect(trunkEdges.map((edge) => edge.id)).toEqual(['root->hot', 'hot->hot-child']);
|
||||
});
|
||||
|
||||
it('热度更高的节点成为主干(显式 playCount 覆盖)', () => {
|
||||
const cold = {
|
||||
...node('cold', 1, 'root'),
|
||||
playCount: 1000,
|
||||
};
|
||||
const layout = buildLineageTreeLayout([
|
||||
node('root', 0, null),
|
||||
node('hot', 1, 'root'),
|
||||
cold,
|
||||
]);
|
||||
const byId = new Map(layout.nodes.map((item) => [item.gameId, item]));
|
||||
expect(byId.get('cold')!.isTrunk).toBe(true);
|
||||
expect(byId.get('hot')!.isTrunk).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectLineageAncestorChain', () => {
|
||||
const nodes = [
|
||||
node('root', 0, null, '根原创'),
|
||||
|
||||
@@ -69,6 +69,11 @@ export type LineageLayoutNode = {
|
||||
* 不必等 hover。占位父节点属于它子节点所在的分支。
|
||||
*/
|
||||
branchIndex: number;
|
||||
/**
|
||||
* 主干线节点:**该代 `playCount` 最高**的那个(并列取先出现的;占位节点不参与)。
|
||||
* 文档要求「每代热度最高的版本标为主干线,用粗实线连接」。
|
||||
*/
|
||||
isTrunk: boolean;
|
||||
};
|
||||
|
||||
export type LineageLayoutEdge = {
|
||||
@@ -81,6 +86,8 @@ export type LineageLayoutEdge = {
|
||||
to: { x: number; y: number };
|
||||
/** 该边属于哪棵顶级子树,用于默认态按分支着色。 */
|
||||
branchIndex: number;
|
||||
/** 主干线:父与子都是各自代际的热度最高节点(渲染成粗实线)。 */
|
||||
isTrunk: boolean;
|
||||
};
|
||||
|
||||
export type LineageGenerationAxisEntry = {
|
||||
@@ -259,9 +266,23 @@ export function buildLineageTreeLayout(
|
||||
height: nodeHeight,
|
||||
isStub: entry.node === null,
|
||||
branchIndex: branchByGameId.get(entry.gameId) ?? 0,
|
||||
isTrunk: false,
|
||||
};
|
||||
});
|
||||
|
||||
// 主干线:每代取 `playCount` 最高的真实节点(占位节点不参与、并列取先出现的)。
|
||||
const trunkByGeneration = new Map<number, LineageLayoutNode>();
|
||||
for (const node of layoutNodes) {
|
||||
if (node.isStub) continue;
|
||||
const current = trunkByGeneration.get(node.generation);
|
||||
if (!current || node.node!.playCount > current.node!.playCount) {
|
||||
trunkByGeneration.set(node.generation, node);
|
||||
}
|
||||
}
|
||||
for (const node of layoutNodes) {
|
||||
node.isTrunk = trunkByGeneration.get(node.generation) === node;
|
||||
}
|
||||
|
||||
const layoutById = new Map(layoutNodes.map((node) => [node.gameId, node]));
|
||||
const edges: LineageLayoutEdge[] = [];
|
||||
for (const node of layoutNodes) {
|
||||
@@ -304,6 +325,7 @@ export function buildLineageTreeLayout(
|
||||
to,
|
||||
path,
|
||||
branchIndex: parent.branchIndex,
|
||||
isTrunk: parent.isTrunk && node.isTrunk,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user