族谱树终稿:卡片主体点击=选中路径、封面主视觉、elbow 圆角连线与分支配色
Project CI / AI game creator shell Rust lane 1/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 2/2 (pull_request) Has been cancelled

body click selects ancestors+subtree with breadcrumb; title/打开作品 opens detail; cover-first cards; branch-colored elbow edges; tight-width centering; compact mobile cards; 261 tests green.
This commit is contained in:
2026-10-06 15:16:50 +08:00
parent e2056db146
commit 6dc314562e
4 changed files with 278 additions and 157 deletions
@@ -1130,7 +1130,7 @@ describe('GameLineagePage', () => {
// 父作品不在可见集合里:只标注「原作品已不可用」,不回显父作品内容。
expect(screen.getByText('原作品已不可用')).toBeTruthy();
// 任一公开节点都能进入对应详情页。
fireEvent.click(screen.getByRole('button', { name: /作品《母版》/u }));
fireEvent.click(screen.getByRole('button', { name: '打开《母版》详情' }));
expect(onOpenDetail).toHaveBeenCalledWith('game-root');
});
@@ -1219,7 +1219,7 @@ describe('GameLineagePage', () => {
);
expect(
await screen.findByRole('button', { name: /作品《母版》/u }),
await screen.findByRole('button', { name: '打开《母版》详情' }),
).toBeTruthy();
expect(
screen.getByText('这部作品还没有衍生作品,欢迎从它开始共创。'),
@@ -1977,6 +1977,14 @@ 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;
const treeWithSibling = {
rootGameId: 'game-root',
root: createLineageNode({
@@ -2014,7 +2022,7 @@ describe('GameLineagePage 可视化与交互', () => {
truncated: false,
};
it('节点是真实 DOM 卡片:点击进详情、Enter 等价、当前作品有标记且父在上子在下', async () => {
it('节点是真实 DOM 卡片:主体点击=选中、标题/打开作品=进详情、当前作品有标记且父在上子在下', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
const onOpenDetail = vi.fn();
render(
@@ -2026,10 +2034,8 @@ describe('GameLineagePage 可视化与交互', () => {
/>,
);
const leafCard = await screen.findByRole('button', {
name: /作品《叶节点》/u,
});
const rootCard = screen.getByRole('button', { name: /作品《母版》/u });
const leafCard = await cardOf('叶节点');
const rootCard = await cardOf('母版');
// 当前作品标记
expect(leafCard.getAttribute('aria-current')).toBe('true');
expect(rootCard.getAttribute('aria-current')).toBeNull();
@@ -2041,15 +2047,26 @@ describe('GameLineagePage 可视化与交互', () => {
// 正交 elbow 连线
const paths = document.querySelectorAll('path.game-lineage-edge');
expect(paths.length).toBeGreaterThan(0);
// 正交 elbow:竖直段 + 水平段 + 圆角(不再是三次贝塞尔弧)。
const firstEdgePath = paths[0]!.getAttribute('d') ?? '';
expect(firstEdgePath).toContain(' V ');
expect(firstEdgePath).not.toContain(' C ');
// 键盘 Enter 与点击等价
fireEvent.keyDown(leafCard, { key: 'Enter' });
// 主行动作是**真实 button**:键盘(Tab + Enter)与点击都能打开详情。
const leafOpen = openButton('叶节点');
expect(leafOpen.tagName).toBe('BUTTON');
leafOpen.focus();
expect(document.activeElement).toBe(leafOpen);
fireEvent.click(leafOpen);
expect(onOpenDetail).toHaveBeenCalledWith('game-leaf');
fireEvent.click(rootCard);
expect(onOpenDetail).toHaveBeenCalledWith('game-root');
fireEvent.click(within(leafCard).getByRole('button', { name: '打开作品' }));
expect(onOpenDetail).toHaveBeenCalledWith('game-leaf');
// 卡片主体点击 = 选中(不是打开详情),再点一次取消。
fireEvent.click(leafCard);
expect(onOpenDetail).toHaveBeenCalledTimes(2);
expect(leafCard.className).toContain('game-lineage-node--pinned');
fireEvent.click(leafCard);
expect(leafCard.className).not.toContain('game-lineage-node--pinned');
});
it('hover 一个节点高亮祖先路径与子树,其余节点降透明;移开后恢复', async () => {
@@ -2062,12 +2079,10 @@ describe('GameLineagePage 可视化与交互', () => {
/>,
);
const midCard = await screen.findByRole('button', {
name: /作品《中间代》/u,
});
const rootCard = screen.getByRole('button', { name: /作品《母版》/u });
const leafCard = screen.getByRole('button', { name: /作品《叶节点》/u });
const otherCard = screen.getByRole('button', { name: /作品《旁支》/u });
const midCard = await cardOf('中间代');
const rootCard = await cardOf('母版');
const leafCard = await cardOf('叶节点');
const otherCard = await cardOf('旁支');
fireEvent.mouseEnter(midCard);
// 祖先(母版)+ 自身 + 子树(叶节点)都不降透明,旁支降透明。
@@ -2100,7 +2115,7 @@ describe('GameLineagePage 可视化与交互', () => {
/>,
);
await screen.findByRole('button', { name: /作品《母版》/u });
await screen.findByRole('button', { name: '打开《母版》详情' });
// 初次适配在 effect 里落地:先等它生效(jsdom 无布局尺寸 → min(scaleX, scaleY) 远小于 1,
// 因此命中可读下限 60%)。
await waitFor(() =>
@@ -2127,7 +2142,7 @@ describe('GameLineagePage 可视化与交互', () => {
/>,
);
await screen.findByRole('button', { name: /作品《母版》/u });
await screen.findByRole('button', { name: '打开《母版》详情' });
const axisLayer = document.querySelector('.game-lineage-canvas__axis');
expect(axisLayer).not.toBeNull();
expect(axisLayer?.querySelectorAll('.game-lineage-axis').length).toBe(3);
@@ -2176,17 +2191,18 @@ describe('GameLineagePage 可视化与交互', () => {
);
await screen.findByText('有封面');
// 只有带 coverObjectKey 的那个节点渲染 GameCover(复用既有封面组件与换签逻辑)。
// 封面是卡片主视觉:**每个节点都有封面位**;没有 coverObjectKey 的节点走 GameCover
// 自带的「底色 + 图标」降级,所以只有带 key 的那个节点出现真实图片。
expect(
document.querySelectorAll('.game-lineage-node .game-cover'),
).toHaveLength(1);
).toHaveLength(2);
expect(document.querySelectorAll('img.game-cover__image')).toHaveLength(1);
// 没有封面的节点不渲染空封面块。
const withoutCover = screen.getByRole('button', { name: /《没封面》/u });
expect(withoutCover.querySelector('.game-cover')).toBeNull();
const withoutCover = await cardOf('没封面');
expect(withoutCover.querySelector('.game-cover')).not.toBeNull();
expect(withoutCover.querySelector('img.game-cover__image')).toBeNull();
});
it('锁定路径后祖先链持续强化并给出面包屑;取消锁定后复原', async () => {
it('选中路径后祖先链持续强化并给出面包屑;取消选中后复原', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
render(
<GameLineagePage
@@ -2196,37 +2212,39 @@ describe('GameLineagePage 可视化与交互', () => {
/>,
);
const leafCard = await screen.findByRole('button', {
name: /作品《叶节点》/u,
});
const leafCard = await cardOf('叶节点');
fireEvent.click(
within(leafCard).getByRole('button', { name: '锁定 叶节点 的路径' }),
within(leafCard).getByRole('button', { name: '选中 叶节点 的路径' }),
);
// 面包屑:母版 → 中间代 → 叶节点,祖先移出视口也能读。
const breadcrumb = screen.getByLabelText('已锁定的路径');
const breadcrumb = screen.getByLabelText('已选中的路径');
expect(breadcrumb.textContent).toContain('母版');
expect(breadcrumb.textContent).toContain('中间代');
expect(breadcrumb.textContent).toContain('叶节点');
// 祖先链与目标持续强化(不依赖 hover),旁支仍是被压暗的一方。
expect(
screen.getByRole('button', { name: /作品《母版》/u }).className,
).toContain('game-lineage-node--path');
expect(
screen.getByRole('button', { name: /作品《中间代》/u }).className,
).toContain('game-lineage-node--path');
expect((await cardOf('母版')).className).toContain(
'game-lineage-node--path',
);
expect((await cardOf('中间代')).className).toContain(
'game-lineage-node--path',
);
expect(leafCard.className).toContain('game-lineage-node--pinned');
expect((await cardOf('旁支')).className).toContain(
'game-lineage-node--dimmed',
);
// 祖先链的连线也进入强化态(这正是「共创路径」的可视表达)。
expect(
screen.getByRole('button', { name: /作品《旁支》/u }).className,
).toContain('game-lineage-node--dimmed');
document.querySelectorAll('path.game-lineage-edge--active').length,
).toBeGreaterThan(0);
fireEvent.click(
within(leafCard).getByRole('button', { name: '取消锁定 叶节点 的路径' }),
within(leafCard).getByRole('button', { name: '取消选中 叶节点 的路径' }),
);
expect(screen.queryByLabelText('已选中的路径')).toBeNull();
expect((await cardOf('旁支')).className).not.toContain(
'game-lineage-node--dimmed',
);
expect(screen.queryByLabelText('已锁定的路径')).toBeNull();
expect(
screen.getByRole('button', { name: /作品《旁支》/u }).className,
).not.toContain('game-lineage-node--dimmed');
});
it('窄屏用紧凑卡片:少一行信息、卡片标记为 compact', async () => {
@@ -2240,9 +2258,7 @@ describe('GameLineagePage 可视化与交互', () => {
/>,
);
const leafCard = await screen.findByRole('button', {
name: /作品《叶节点》/u,
});
const leafCard = await cardOf('叶节点');
expect(leafCard.className).toContain('game-lineage-node--compact');
// 紧凑模式不渲染作者行,但标题/Fork/锁定仍在(可访问性不降级)。
expect(within(leafCard).queryByText('作者 作者 game-leaf')).toBeNull();
@@ -2250,7 +2266,7 @@ describe('GameLineagePage 可视化与交互', () => {
within(leafCard).getByRole('button', { name: 'Fork 叶节点' }),
).toBeTruthy();
expect(
within(leafCard).getByRole('button', { name: '锁定 叶节点 的路径' }),
within(leafCard).getByRole('button', { name: '选中 叶节点 的路径' }),
).toBeTruthy();
});
@@ -2264,7 +2280,7 @@ describe('GameLineagePage 可视化与交互', () => {
/>,
);
await screen.findByRole('button', { name: /作品《母版》/u });
await screen.findByRole('button', { name: '打开《母版》详情' });
const zoomLabel = () =>
document.querySelector('.game-lineage-zoom')?.textContent ?? '';
// 初次适配是 effect 里跑的:先等它落定(jsdom 无布局尺寸 → 命中可读下限 60%)。
@@ -2289,9 +2305,7 @@ describe('GameLineagePage 可视化与交互', () => {
/>,
);
const leafCard = await screen.findByRole('button', {
name: /作品《叶节点》/u,
});
const leafCard = await cardOf('叶节点');
const forkButton = within(leafCard).getByRole('button', {
name: 'Fork 叶节点',
});
@@ -103,14 +103,10 @@ function LineageTreeNodeCard({
const accessibleName = isStub
? `原作品已不可用 · ${generationAxisLabel(generation)}`
: `${generationAxisLabel(generation)}作品《${displayTitle}》· ${authorLabel}`;
// 父作品不可用的占位节点:可聚焦(读屏能听到它),但**不可点进详情**(那会 404),
// 也没有 Fork 动作(取件接口同样会拒绝)。
const isInteractive = !isStub;
/**
* 封面:族谱节点只带 `coverObjectKey`(服务端补齐中),没有完整游戏行。
* `GameCover` 只读 title / coverColor / icon / coverObjectKey 四个字段,这里用与
* `game_payload` 相同的兜底色与图标补上,其余字段不参与渲染——不做逐节点详情请求。
*/
// 父作品不可用的占位节点只能聚焦(不可点进详情,也没有 Fork 动作)。
// 封面:族谱节点只带 `coverObjectKey`,没有完整游戏行;`GameCover` 只读
// title / coverColor / icon / coverObjectKey 四个字段,这里用与 `game_payload` 相同的
// 兜底色与图标补上,其余字段不参与渲染——不做逐节点详情请求。
const coverGame = {
title: displayTitle,
coverColor: '#F3E4D0',
@@ -139,86 +135,112 @@ function LineageTreeNodeCard({
top: layoutNode.y,
width: layoutNode.width,
minHeight: layoutNode.height,
// 默认态就按「顶级分支」着色:不必等 hover 也能看出哪几颗属于同一个共创分支。
['--lineage-branch' as string]: `var(--lineage-branch-${layoutNode.branchIndex % 6})`,
}}
data-generation={generation}
role={isInteractive ? 'button' : undefined}
tabIndex={0}
data-branch={layoutNode.branchIndex}
// 卡片主体点击 = **选中**(持续高亮祖先链 + 子树);打开详情在标题/「打开作品」按钮上。
tabIndex={isStub ? 0 : undefined}
aria-label={accessibleName}
aria-current={isAnchor ? 'true' : undefined}
aria-disabled={isStub ? 'true' : undefined}
onClick={
isInteractive ? () => onOpenDetail(gameId) : () => onActivate(gameId)
}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
if (isInteractive) onOpenDetail(gameId);
else onActivate(gameId);
onClick={() => {
if (isStub) onActivate(gameId);
else onTogglePin(isPinned ? '' : gameId);
}}
onMouseEnter={() => onActivate(gameId)}
onMouseLeave={onDeactivate}
onFocus={() => onActivate(gameId)}
onBlur={onDeactivate}
>
<span className="game-lineage-node__main">
{layoutNode.coverObjectKey ? (
<GameCover
game={coverGame}
compact
objectKey={layoutNode.coverObjectKey}
/>
) : null}
<span className="game-lineage-node__text">
<span className="game-lineage-node__head">
<span className="game-lineage-node__generation">
{generationAxisLabel(generation)}
</span>
{isAnchor ? (
<span className="game-lineage-node__anchor">当前作品</span>
) : null}
{isStub ? (
// 占位节点没有可打开的作品,也不展示被删作品的标题与作者名:只保留可聚焦的降级说明。
<span className="game-lineage-node__stub-body">
<span className="game-lineage-node__generation">
{generationAxisLabel(generation)}
</span>
<span className="game-lineage-node__title">{displayTitle}</span>
{/* 树节点只留「代际 + 标题 + 作者 + Fork」:游玩数与「与原版对比」在详情页已有。 */}
{compact ? null : (
<span className="game-lineage-node__meta">
<span>{authorLabel}</span>
<span className="game-lineage-node__meta">
<span>{authorLabel}</span>
</span>
</span>
) : (
<>
<button
type="button"
className="game-lineage-node__open"
aria-label={`打开《${displayTitle}》详情`}
onClick={(event) => {
// 主行动作:打开详情。stopPropagation 保证它不会被「选中」吞掉。
event.stopPropagation();
onOpenDetail(gameId);
}}
>
<span className="game-lineage-node__cover">
<GameCover
game={coverGame}
objectKey={layoutNode.coverObjectKey}
/>
<span className="game-lineage-node__badge">
{generationAxisLabel(generation)}
</span>
{isAnchor ? (
<span className="game-lineage-node__anchor">当前作品</span>
) : null}
</span>
<span className="game-lineage-node__strip">
<span className="game-lineage-node__title">{displayTitle}</span>
{compact ? null : (
<span className="game-lineage-node__meta">
<span>{authorLabel}</span>
</span>
)}
</span>
)}
</span>
</span>
{isInteractive ? (
<span className="game-lineage-node__actions">
<button
type="button"
className="game-lineage-node__fork"
aria-label={`Fork ${displayTitle}`}
onClick={(event) => {
// 次要动作不抢主点击。
event.stopPropagation();
onOpenFork(gameId);
}}
>
Fork
</button>
<button
type="button"
className="game-lineage-node__pin"
aria-pressed={isPinned}
aria-label={
isPinned
? `取消锁定 ${displayTitle} 的路径`
: `锁定 ${displayTitle} 的路径`
}
onClick={(event) => {
// 点击主区域是「进详情」,锁定路径是它的独立次要动作,互不干扰。
event.stopPropagation();
onTogglePin(isPinned ? '' : gameId);
}}
>
{isPinned ? '已锁定' : '锁定路径'}
</button>
</span>
) : null}
<span className="game-lineage-node__actions">
<button
type="button"
className="game-lineage-node__open-action"
onClick={(event) => {
event.stopPropagation();
onOpenDetail(gameId);
}}
>
打开作品
</button>
<button
type="button"
className="game-lineage-node__fork"
aria-label={`Fork ${displayTitle}`}
onClick={(event) => {
// 次要动作不抢主点击。
event.stopPropagation();
onOpenFork(gameId);
}}
>
Fork
</button>
<button
type="button"
className="game-lineage-node__pin"
aria-pressed={isPinned}
aria-label={
isPinned
? `取消选中 ${displayTitle} 的路径`
: `选中 ${displayTitle} 的路径`
}
onClick={(event) => {
// 键盘可达的「选中路径」:点击版的同一件事。
event.stopPropagation();
onTogglePin(isPinned ? '' : gameId);
}}
>
{isPinned ? '已选中' : '选中路径'}
</button>
</span>
</>
)}
</div>
);
}
@@ -670,7 +692,7 @@ export function GameLineagePage({
{pinnedChain.length > 0 ? (
<div
className="game-lineage-breadcrumb"
aria-label="已锁定的路径"
aria-label="已选中的路径"
>
{pinnedChain.map((crumb, index) => (
<span
@@ -714,6 +736,10 @@ export function GameLineagePage({
<path
key={edge.id}
d={edge.path}
// 默认态按顶级分支着色;高亮态由 class 覆盖成强调色。
style={{
['--lineage-branch' as string]: `var(--lineage-branch-${edge.branchIndex % 6})`,
}}
className={
highlighted &&
highlighted.has(edge.parentGameId) &&
@@ -763,6 +763,13 @@
height: clamp(22rem, 62vh, 46rem);
max-width: 100%;
overflow: hidden;
/* 默认态分支色板:同一顶级分支的节点与连线共用一种色系。 */
--lineage-branch-0: #8a6cb4;
--lineage-branch-1: #4e8f8a;
--lineage-branch-2: #b4763a;
--lineage-branch-3: #5d7fb0;
--lineage-branch-4: #a8556f;
--lineage-branch-5: #6b8f4e;
border: 1px solid var(--platform-surface-border);
border-radius: 0.85rem;
background: radial-gradient(
@@ -795,23 +802,24 @@
}
.game-lineage-edge {
fill: none;
stroke: var(--platform-text-soft);
/* 默认态就按顶级分支着色(同一分支同色系),不必等 hover 也能读出「共创路径」。 */
stroke: var(--lineage-branch, var(--platform-text-base));
opacity: 0.9;
/* 缩到 35% 时线不能细到看不见:non-scaling-stroke 让线宽与屏幕像素绑定,不随缩放变化。 */
vector-effect: non-scaling-stroke;
stroke-width: 2.5;
opacity: 0.85;
stroke-width: 3;
transition:
stroke 160ms ease,
opacity 160ms ease;
}
.game-lineage-edge--active {
stroke: var(--platform-accent-strong);
stroke-width: 4;
stroke-width: 5.5;
opacity: 1;
filter: drop-shadow(0 0 3px rgba(199, 101, 61, 0.35));
filter: drop-shadow(0 0 4px rgba(199, 101, 61, 0.45));
}
.game-lineage-edge--dimmed {
opacity: 0.1;
opacity: 0.08;
}
/* 代际轴:画布左侧的固定层(在内容层的 transform 之外),放大/平移后仍然可读、不被裁。 */
.game-lineage-canvas__axis {
@@ -912,6 +920,16 @@
color: var(--platform-text-strong);
background: rgba(199, 101, 61, 0.12);
}
/* 窄屏紧凑卡:封面比例更扁、标题字号相对更大,字段更少(作者行不渲染)。 */
.game-lineage-node--compact .game-lineage-node__cover {
aspect-ratio: 176 / 96;
}
.game-lineage-node--compact .game-lineage-node__title {
font-size: 0.88rem;
}
.game-lineage-node--compact .game-lineage-node__badge {
font-size: 0.72rem;
}
.game-lineage-node__pin {
border: 1px solid var(--platform-surface-border);
border-radius: 999px;
@@ -941,23 +959,63 @@
font-weight: 700;
overflow-wrap: anywhere;
}
.game-lineage-node__main {
display: flex;
align-items: flex-start;
gap: 0.5rem;
min-width: 0;
/* 卡片主体 = 选中;标题/封面 = 打开详情。封面做主视觉(大图 + 圆角 + 轻阴影)。 */
.game-lineage-node {
padding: 0;
overflow: hidden;
border-color: var(--lineage-branch, var(--platform-surface-border));
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
}
.game-lineage-node__text {
.game-lineage-node__open {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
gap: 0;
width: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.game-lineage-node .game-cover {
flex: 0 0 auto;
width: 3.2rem;
height: 3.2rem;
border-radius: 0.5rem;
.game-lineage-node__cover {
position: relative;
display: block;
width: 100%;
aspect-ratio: 224 / 120;
overflow: hidden;
background: var(--platform-surface-border);
}
.game-lineage-node__cover .game-cover {
width: 100%;
height: 100%;
border-radius: 0;
}
.game-lineage-node__badge {
position: absolute;
top: 0.35rem;
left: 0.35rem;
border-radius: 999px;
padding: 0.1rem 0.5rem;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 0.68rem;
font-weight: 700;
}
.game-lineage-node__strip {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
padding: 0.4rem 0.55rem 0.45rem;
}
.game-lineage-node__stub-body {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.7rem 0.8rem;
min-width: 0;
}
.game-lineage-node__actions {
display: flex;
@@ -20,10 +20,10 @@ import type { GameDistributionLineageNode } from '../../services/gameDistributio
* 与「竖直段/水平段存在」而不是解析路径。
*/
export const LINEAGE_NODE_WIDTH = 208;
export const LINEAGE_NODE_HEIGHT = 96;
export const LINEAGE_NODE_WIDTH = 224;
export const LINEAGE_NODE_HEIGHT = 184;
/** 同一层相邻槽位之间额外留的横向间隙(保证不重叠)。 */
export const LINEAGE_COLUMN_GAP = 28;
export const LINEAGE_COLUMN_GAP = 32;
/** 相邻代际之间的纵向间隙。 */
export const LINEAGE_ROW_GAP = 104;
/** 左侧「代际轴」占的宽度(第 0 代 / 第 1 代 …)。 */
@@ -47,16 +47,25 @@ export type LineageLayoutNode = {
height: number;
/** true = 父作品不可用的降级占位节点(可聚焦、不可点进详情)。 */
isStub: boolean;
/**
* 顶级分支序号(森林里第几棵子树,按 DFS 顺序)。
*
* 同一分支的节点与连线共用一种色系,因此**默认全览**下也能看出「哪几颗是一个共创分支」,
* 不必等 hover。占位父节点属于它子节点所在的分支。
*/
branchIndex: number;
};
export type LineageLayoutEdge = {
id: string;
parentGameId: string;
childGameId: string;
/** SVG path(三次贝塞尔)。 */
/** SVG path(正交 elbow,转角带圆角)。 */
path: string;
from: { x: number; y: number };
to: { x: number; y: number };
/** 该边属于哪棵顶级子树,用于默认态按分支着色。 */
branchIndex: number;
};
export type LineageGenerationAxisEntry = {
@@ -172,10 +181,16 @@ export function buildLineageTreeLayout(
(entry) => !entry.parentGameId || !byId.has(entry.parentGameId),
);
// 第 3 步:后序分配槽位。
// 第 3 步:后序分配槽位;同时记录每棵顶级子树的编号(用于默认态分支着色)。
const slotByGameId = new Map<string, number>();
const branchByGameId = new Map<string, number>();
let nextSlot = 0;
const visit = (entry: (typeof entries)[number]): number => {
let nextBranch = 0;
const visit = (
entry: (typeof entries)[number],
branchIndex: number,
): number => {
branchByGameId.set(entry.gameId, branchIndex);
const children = childrenByParent.get(entry.gameId) ?? [];
if (children.length === 0) {
const slot = nextSlot;
@@ -186,7 +201,7 @@ export function buildLineageTreeLayout(
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const child of children) {
const childSlot = visit(child);
const childSlot = visit(child, branchIndex);
min = Math.min(min, childSlot);
max = Math.max(max, childSlot);
}
@@ -195,7 +210,9 @@ export function buildLineageTreeLayout(
return slot;
};
for (const root of forestRoots) {
if (!slotByGameId.has(root.gameId)) visit(root);
if (slotByGameId.has(root.gameId)) continue;
visit(root, nextBranch);
nextBranch += 1;
}
const generations = [
@@ -205,6 +222,11 @@ export function buildLineageTreeLayout(
generations.map((generation, index) => [generation, index] as const),
);
const pitch = nodeWidth + columnGap;
// 只在真正用到的槽位区间上铺像素:把左侧空槽位裁掉,内容因此更紧凑,
// 「整棵树居中」时右侧也不会留下大片空白。
const usedSlots = [...slotByGameId.values()];
const minSlot = usedSlots.length > 0 ? Math.min(...usedSlots) : 0;
const maxSlot = usedSlots.length > 0 ? Math.max(...usedSlots) : 0;
const layoutNodes: LineageLayoutNode[] = entries.map((entry) => {
const slot = slotByGameId.get(entry.gameId) ?? 0;
@@ -216,11 +238,12 @@ export function buildLineageTreeLayout(
authorName: entry.authorName,
coverObjectKey: entry.coverObjectKey,
generation: entry.generation,
x: roundTo(axisWidth + slot * pitch),
x: roundTo(axisWidth + (slot - minSlot) * pitch),
y: roundTo(row * (nodeHeight + rowGap)),
width: nodeWidth,
height: nodeHeight,
isStub: entry.node === null,
branchIndex: branchByGameId.get(entry.gameId) ?? 0,
};
});
@@ -265,6 +288,7 @@ export function buildLineageTreeLayout(
from,
to,
path,
branchIndex: parent.branchIndex,
});
}
@@ -275,13 +299,12 @@ export function buildLineageTreeLayout(
count: layoutNodes.filter((node) => node.generation === generation).length,
}));
const maxSlot =
slotByGameId.size > 0 ? Math.max(...slotByGameId.values()) : 0;
const maxSlotCount = slotByGameId.size > 0 ? maxSlot - minSlot + 1 : 0;
return {
nodes: layoutNodes,
edges,
axis,
width: roundTo(axisWidth + (maxSlot + 1) * pitch),
width: roundTo(axisWidth + maxSlotCount * pitch),
height: roundTo(generations.length * (nodeHeight + rowGap)),
};
}