族谱卡片:层高按该层最高卡自适应(纯函数估算)+角标移到封面下方并上色+作者行与等权重按钮重做+Fork 改名开始共创
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 13:24:40 +08:00
parent f59a7d21df
commit 14d90718ad
7 changed files with 523 additions and 82 deletions
@@ -2155,7 +2155,9 @@ describe('原版与二创对比入口', () => {
// 「与原版对比」仍然留在详情页,不在树里重复渲染。
expect(screen.queryByRole('button', { name: '与原版对比' })).toBeNull();
expect(screen.queryByText(/次游玩/u)).toBeNull();
expect(screen.getByRole('button', { name: 'Fork 叶节点' })).toBeTruthy();
expect(
screen.getByRole('button', { name: '开始共创 叶节点' }),
).toBeTruthy();
});
});
@@ -3016,7 +3018,9 @@ describe('GameLineagePage 可视化与交互', () => {
.mockReturnValue(1600);
const wideHeight = vi
.spyOn(Element.prototype, 'clientHeight', 'get')
.mockReturnValue(1200);
// 卡片改成信息区版式后整棵树更高(4 层 × ~400 + 间距 ≈ 1900),
// 要装下并在可读下限之上,视口就得足够高。
.mockReturnValue(1500);
try {
getGameLineageMock.mockResolvedValueOnce(demoShapeTree);
const first = render(
@@ -3080,6 +3084,124 @@ describe('GameLineagePage 可视化与交互', () => {
}
});
it('画布卡片:角标移到封面下方、按钮「打开作品 / 开始共创」等权重主次分明、作者行独立一行', async () => {
getGameLineageMock.mockResolvedValueOnce(relationTree);
render(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-current"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const card = await cardOf('当前作品');
const content = card.querySelector('.game-lineage-node__content')!;
const cover = content.querySelector('.game-lineage-node__cover')!;
// ① 角标**不再压在封面里**:封面内没有任何角标;角标组是内容区的第二块(封面之后、文字之前)。
expect(cover.querySelector('.game-lineage-node__badges')).toBeNull();
expect(cover.querySelector('.game-lineage-node__trunk')).toBeNull();
const blockOrder = [...content.children].map((el) => el.className);
expect(blockOrder[0]).toContain('game-lineage-node__cover');
expect(blockOrder[1]).toContain('game-lineage-node__badges');
expect(blockOrder[2]).toContain('game-lineage-node__text');
// ② 两个等权重按钮:文案与主次类名固定(「开始共创」是本次改名)。
const openButton = within(card).getByRole('button', { name: '打开作品' });
const coCreate = within(card).getByRole('button', {
name: '开始共创 当前作品',
});
expect(openButton.className).toContain('game-lineage-node__open-action');
expect(coCreate.className).toContain('game-lineage-node__fork');
expect(coCreate.textContent).toBe('开始共创');
// ③ 作者行独立一行(`.game-lineage-node__meta > __author`),且不与标题同一节点。
const meta = card.querySelector('.game-lineage-node__meta')!;
expect(meta.querySelector('.game-lineage-node__author')).toBeTruthy();
expect(meta.contains(card.querySelector('.game-lineage-node__title'))).toBe(
false,
);
// CSS 契约(jsdom 不算样式):等权重、触控目标、主次配色、角标配色一律取自平台变量。
const css = await import('node:fs').then((fs) =>
fs.readFileSync(
'src/components/game-distribution/gameDistribution.css',
'utf8',
),
);
expect(css).toMatch(
/\.game-lineage-node__actions > button \{[\s\S]{0,500}?flex: 1 1 0;/u,
);
expect(css).toMatch(
/\.game-lineage-node__actions > button \{[\s\S]{0,500}?min-height: 2rem;/u,
);
expect(css).toMatch(
/\.game-lineage-node__fork \{[\s\S]{0,300}?--platform-button-primary-solid/u,
);
expect(css).toMatch(
/\.game-lineage-node__open-action \{[\s\S]{0,300}?--platform-button-secondary-fill/u,
);
// 角标配色表:主干=暖色、当前作品=主色实心、父代=cool、子代=success、代际/游玩数=中性。
expect(css).toMatch(
/\.game-lineage-node__trunk \{[\s\S]{0,300}?--platform-warm-bg/u,
);
expect(css).toMatch(
/\.game-lineage-node__anchor \{[\s\S]{0,300}?background: var\(--platform-accent\)/u,
);
expect(css).toMatch(
/\.game-lineage-node__relation--ancestor \{[\s\S]{0,300}?--platform-cool-bg/u,
);
expect(css).toMatch(
/\.game-lineage-node__relation--descendant \{[\s\S]{0,300}?--platform-success-bg/u,
);
expect(css).toMatch(
/\.game-lineage-node__badge \{[\s\S]{0,300}?--platform-neutral-bg/u,
);
// 角标不再用「深色药丸浮在图上」的写法(旧的 rgba(0,0,0,.55/.62) 已删除)。
expect(css).not.toMatch(
/game-lineage-node__badge \{[\s\S]{0,200}?rgba\(0, 0, 0/u,
);
expect(css).not.toMatch(
/game-lineage-node__play-count \{[\s\S]{0,200}?rgba\(0, 0, 0/u,
);
});
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('创作族谱大纲');
const row = outline.querySelector('.lineage-outline__row')!;
const actions = row.querySelector('.lineage-outline__actions')!;
expect(actions.querySelectorAll('button')).toHaveLength(2);
expect(
within(actions as HTMLElement).getByRole('button', { name: /打开/u })
.textContent,
).toBe('打开作品');
const coCreate = within(actions as HTMLElement).getByRole('button', {
name: /开始共创/u,
});
expect(coCreate.textContent).toBe('开始共创');
expect(coCreate.className).toContain('lineage-outline__action--co-create');
// 主要/次要的视觉区分同样落在大纲行(不只画布卡片)。
const outlineCss = await import('node:fs').then((fs) =>
fs.readFileSync(
'src/components/game-distribution/gameDistribution.css',
'utf8',
),
);
expect(outlineCss).toMatch(
/\.lineage-outline__action--co-create \{[\s\S]{0,200}?--platform-button-primary-solid/u,
);
// 全树的 Fork 旧文案在两种形态里都已不存在(信息面板的「Fork 此版本」不在树节点上,另见报告)。
expect(outline.textContent).not.toContain('Fork');
});
it('节点是真实 DOM 卡片:主体点击=选中、标题/打开作品=进详情、当前作品有标记且父在上子在下', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
const onOpenDetail = vi.fn();
@@ -3325,7 +3447,7 @@ describe('GameLineagePage 可视化与交互', () => {
expect(zoomLabel()).toBe('35%');
});
it('节点的 Fork 次要动作不触发导航;未失焦时给出降级提示与复制入口', async () => {
it('节点的「开始共创」次要动作不触发导航;未失焦时给出降级提示与复制入口', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
const onOpenDetail = vi.fn();
render(
@@ -3338,7 +3460,7 @@ describe('GameLineagePage 可视化与交互', () => {
const leafCard = await cardOf('叶节点');
const forkButton = within(leafCard).getByRole('button', {
name: 'Fork 叶节点',
name: '开始共创 叶节点',
});
vi.useFakeTimers();
fireEvent.click(forkButton);
@@ -4021,7 +4143,7 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
within(outline).getByRole('button', { name: '打开《根原创》详情' }),
).toBeTruthy();
expect(
within(outline).getByRole('button', { name: 'Fork 根原创' }),
within(outline).getByRole('button', { name: '开始共创 根原创' }),
).toBeTruthy();
});
});
@@ -35,7 +35,9 @@ import {
collectLineageImmediateScope,
collectLineagePlayCountTotals,
collectLineageRelationRoles,
estimateLineageCardHeight,
generationAxisLabel,
LINEAGE_CARD_BASE_HEIGHT,
type LineageLayoutNode,
type LineagePlayCountBreakdown,
type LineageRelationRole,
@@ -66,6 +68,12 @@ type GameLineagePageProps = {
) => void;
};
/**
* 窄屏紧凑卡片的基础高(176 宽:16:9 封面 + 角标行 + 标题 + 作者 / 操作)。
* 由真机实测校准(2026-10-07):1 行标题 350px、2 行 373px → 取 352(+24/行)留余量。
*/
const LINEAGE_COMPACT_CARD_BASE_HEIGHT = 352;
const MIN_SCALE = 0.35;
const MAX_SCALE = 1.8;
const FIT_PADDING = 24;
@@ -205,6 +213,10 @@ function LineageTreeNodeCard({
/>
</button>
<span className="game-lineage-node__actions">
{/*
两个**等权重**的动作按钮:次要(描边)= 打开作品,主要(填充)= 开始共创。
同一尺寸 / 同一圆角 / 同一内边距,窄卡里允许换行但绝不裁切(见 CSS)。
*/}
<button
type="button"
className="game-lineage-node__open-action"
@@ -218,14 +230,14 @@ function LineageTreeNodeCard({
<button
type="button"
className="game-lineage-node__fork"
aria-label={`Fork ${displayTitle}`}
aria-label={`开始共创 ${displayTitle}`}
onClick={(event) => {
// 次要动作不抢主点击。
event.stopPropagation();
onOpenFork(gameId);
}}
>
Fork
开始共创
</button>
</span>
</>
@@ -405,26 +417,38 @@ export function GameLineagePage({
const compactTree = isMobileViewport;
/** 窄屏图谱视图用横版画布:代际信息交给节点角标,因此不渲染左侧代际轴层。 */
const horizontalCanvas = compactTree;
const layout = useMemo(
() =>
buildLineageTreeLayout(
filteredNodes,
compactTree
? {
// 紧凑态:卡片 176 宽 → 16:9 封面 99 高 + 信息条;度量仍满足「同层 ≥ columnGap」。
nodeWidth: 176,
nodeHeight: 148,
columnGap: 22,
rowGap: 88,
axisWidth: 72,
// 窄屏图谱视图用横版(根在左、代际向右、兄弟沿纵向排开):窄屏上比顶→下更省横向空间,
// 纵向溢出交给滑动。代际信息由节点自带角标承担,所以横版不渲染代际轴(见下面的轴层)。
orientation: 'horizontal',
}
: undefined,
),
[compactTree, filteredNodes],
);
const layout = useMemo(() => {
// 卡片高度估算(纯函数、确定性):层高 = 该层最高卡片 + rowGap,据此排下一层 ——
// 同层里出现一张「2 行标题」的高卡也不会被下一层压住。渲染后不再测量 DOM。
const cardHeights = new Map(
filteredNodes.map((node) => [
node.gameId,
estimateLineageCardHeight({
title: node.title,
baseHeight: compactTree
? LINEAGE_COMPACT_CARD_BASE_HEIGHT
: LINEAGE_CARD_BASE_HEIGHT,
}),
]),
);
return buildLineageTreeLayout(
filteredNodes,
compactTree
? {
// 紧凑态:卡片 176 宽 → 16:9 封面 99 高 + 角标行 + 标题 + 作者 / 操作。
nodeWidth: 176,
nodeHeight: LINEAGE_COMPACT_CARD_BASE_HEIGHT,
columnGap: 22,
rowGap: 88,
axisWidth: 72,
// 窄屏图谱视图用横版(根在左、代际向右、兄弟沿纵向排开):窄屏上比顶→下更省横向空间,
// 纵向溢出交给滑动。代际信息由节点自带角标承担,所以横版不渲染代际轴(见下面的轴层)。
orientation: 'horizontal',
cardHeights,
}
: { cardHeights },
);
}, [compactTree, filteredNodes]);
// 高亮目标:锁定(点击「锁定路径」)优先于 hover/聚焦;锁定后即使祖先移出视口,路径仍由面包屑兜底。
const highlightTargetId = pinnedGameId || activeGameId;
const highlighted = useMemo(
@@ -1070,9 +1094,9 @@ export function GameLineagePage({
className="game-lineage-axis"
style={{
top: viewport.offsetY + entry.y * viewport.scale,
// 行高跟着缩放走:轴上的行与画布里的行始终对齐。
height:
(layout.nodes[0]?.height ?? 0) * viewport.scale,
// 行高跟着缩放走,且取**该层层高**(层高按最高卡算):
// 轴上的行与画布里的行始终对齐。
height: entry.height * viewport.scale,
borderTopWidth: index === 0 ? 0 : 1,
}}
>
@@ -116,11 +116,12 @@ export function LineageNodeContent({
>
<span className="game-lineage-node__cover">
<GameCover game={coverGame} objectKey={coverObjectKey} />
{/* 画布卡片:角标组作为封面上的浮层(整组一起排,不会各自乱定位)。 */}
{layout === 'stacked' ? badges : null}
</span>
{/* 大纲行:角标组独立成列(在封面右侧、操作左侧),所以不会挤进封面里叠字。 */}
{layout === 'inline' ? badges : null}
{/*
角标组在封面**下方**(卡片信息区第一行):不再压封面、不再用深色药丸浮在图上。
画布卡片与大纲行都用同一组(角标语义 / 配色 / 顺序完全一致),组内 flex + gap + 可换行。
*/}
{badges}
<span className="game-lineage-node__text">
<span className="game-lineage-node__title">{displayTitle}</span>
{/*
@@ -108,15 +108,15 @@ export function LineageOutlineList({
aria-label={`打开《${node.title}》详情`}
onClick={() => onOpenDetail(node.gameId)}
>
打开
打开作品
</button>
<button
type="button"
className="lineage-outline__action"
aria-label={`Fork ${node.title}`}
className="lineage-outline__action lineage-outline__action--co-create"
aria-label={`开始共创 ${node.title}`}
onClick={() => onOpenFork(node.gameId)}
>
Fork
开始共创
</button>
</span>
)}
@@ -1033,10 +1033,11 @@
.game-lineage-edge--trunk {
stroke-width: 6;
}
/* 「主干」角标:只给颜色,定位/尺寸由角标组(.game-lineage-node__badges)统一管。 */
/* 「主干」角标 = 暖色(强调)。定位/尺寸由角标组统一管。 */
.game-lineage-node__trunk {
background: var(--platform-accent-strong);
color: #fff;
border: 1px solid var(--platform-warm-border);
background: var(--platform-warm-bg);
color: var(--platform-warm-text);
font-weight: 700;
}
/* 右侧信息面板:浮动叠加在画布右上(控件在右下,避免互相遮挡)。 */
@@ -1297,6 +1298,12 @@
gap: 0.25rem;
min-width: 0;
}
/*
* 角标配色表(每类语义一色,**全部取自平台既有 `--platform-*` 变量,不新增色值**):
* 代际 = 中性 | 主干 = 暖色(强调) | 当前作品 = 主色实心
* 父代 = cool | 子代 = success | ▶游玩数 = 中性
* 全部带 1px 边框(浅色卡片上也有边界);组内静态定位 + flex + gap(永不重叠)。
*/
/* 组内角标一律静态定位(颜色 / 圆角仍由各自规则给)。 */
.game-lineage-node__badges .game-lineage-node__badge,
.game-lineage-node__badges .game-lineage-node__trunk,
@@ -1315,14 +1322,10 @@
line-height: 1.6;
white-space: nowrap;
}
/* 画布卡片:整组角标作为封面左上角的浮层(一组一起排,不再各自定位)。 */
/* 画布卡片:角标组在**封面下方**的信息区第一行(不再压封面、不再用深色药丸浮在图上)。 */
.game-lineage-node__content--stacked .game-lineage-node__badges {
position: absolute;
top: 0.3rem;
left: 0.3rem;
right: 0.3rem;
justify-content: flex-start;
z-index: 1;
padding: 0.35rem 0.5rem 0;
}
/* 大纲行:角标组在封面右侧、操作左侧,靠右对齐,标题占满剩余空间。 */
.game-lineage-node__content--inline .game-lineage-node__badges {
@@ -1348,19 +1351,20 @@
}
/*
* 画布卡片:标题**固定 2 行高度**(1 行也占满 2 行)。
* 目的:同一层的卡片等高(连线端点对齐),1 行标题与 2 行标题不再把卡片高度拉得参差。
* 大纲行不受影响(那里的行高由 `min-height: 3.5rem` 统一)。
* 画布卡片:标题按真实行数占高(1 行 = 1 行)。
* 「同层等高」现在由**布局**负责:`buildLineageTreeLayout` 给同层每个节点同一个 `height`
* (= 该层最高卡片的估算高度),卡片按 `min-height` 撑到它 —— 所以不再需要用
* `min-height` 把每一行标题都硬撑成 2 行(那会让所有卡片白高一行)。
*/
.game-lineage-node__content--stacked .game-lineage-node__title {
/* 2 行标题的自然高度 ≈ 3.2em(实测),1 行标题也占满它 → 同层卡片等高。 */
min-height: 3.2em;
}
/* 作者行:独立一行、更小更淡(与标题拉开层级)。 */
.game-lineage-node__meta {
display: flex;
min-width: 0;
gap: 0.3rem;
overflow: hidden;
color: var(--platform-text-muted);
font-size: 0.7rem;
font-weight: 500;
}
.game-lineage-node__author {
overflow: hidden;
@@ -1408,8 +1412,9 @@
* 注意:主干线 / 热度排序的判据是**自身** playCount,与这个显示口径不同。
*/
.game-lineage-node__play-count {
background: rgba(0, 0, 0, 0.62);
color: #fff;
border: 1px solid var(--platform-neutral-border);
background: var(--platform-neutral-bg);
color: var(--platform-text-base);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
@@ -1433,10 +1438,11 @@
height: 100%;
border-radius: 0;
}
/* 代际角标:只给颜色,定位/尺寸由角标组统一管。 */
/* 代际角标 = 中性。定位/尺寸由角标组统一管。 */
.game-lineage-node__badge {
background: rgba(0, 0, 0, 0.55);
color: #fff;
border: 1px solid var(--platform-neutral-border);
background: var(--platform-neutral-bg);
color: var(--platform-neutral-text);
font-weight: 700;
}
.game-lineage-node__strip {
@@ -1453,22 +1459,41 @@
padding: 0.7rem 0.8rem;
min-width: 0;
}
/* 卡片底部操作行:两个**等权重**按钮并排(等宽、同高、同圆角),窄卡允许换行但不裁切。 */
.game-lineage-node__actions {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
gap: 0.35rem;
padding: 0.45rem 0.5rem 0.55rem;
}
.game-lineage-node__fork {
border: 1px solid var(--platform-surface-border);
.game-lineage-node__actions > button {
flex: 1 1 0;
min-width: 6.4rem;
min-height: 2rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
padding: 0.14rem 0.6rem;
background: transparent;
color: var(--platform-text-base);
padding: 0 0.6rem;
font: inherit;
font-size: 0.72rem;
font-size: 0.74rem;
font-weight: 600;
cursor: pointer;
}
/* 次要动作(描边)。 */
.game-lineage-node__actions > .game-lineage-node__open-action {
border: 1px solid var(--platform-surface-border);
background: var(--platform-button-secondary-fill);
color: var(--platform-button-secondary-text);
}
/* 主要动作 = 开始共创(填充/着色)。 */
.game-lineage-node__fork {
border: 1px solid var(--platform-button-primary-border);
background: var(--platform-button-primary-solid);
color: var(--platform-button-primary-text);
font: inherit;
cursor: pointer;
}
.game-lineage-node__fork:hover {
color: var(--platform-text-strong);
}
@@ -2306,9 +2331,12 @@
background: var(--platform-surface-border);
color: var(--platform-text-soft);
}
/* 「当前作品」= 主色实心(最强的一枚)。 */
.game-lineage-node__anchor {
background: rgba(199, 101, 61, 0.16);
color: var(--platform-accent-strong);
border: 1px solid var(--platform-accent);
background: var(--platform-accent);
color: var(--platform-button-primary-text);
font-weight: 700;
}
.game-lineage-node__title {
border: none;
@@ -2490,6 +2518,13 @@
font-weight: 600;
cursor: pointer;
}
/* 大纲行的两个动作与画布卡片同一套主次:打开作品 = 描边(次要),开始共创 = 填充(主要)。 */
.lineage-outline__action--co-create {
border-color: var(--platform-button-primary-border);
background: var(--platform-button-primary-solid);
color: var(--platform-button-primary-text);
}
.lineage-outline__actions {
display: flex;
flex: 0 0 auto;
@@ -2541,13 +2576,19 @@
line-height: 1.6;
white-space: nowrap;
}
/* 父代 = cool 色系。 */
.game-lineage-node__relation--ancestor {
color: var(--platform-accent-strong);
background: rgba(199, 101, 61, 0.1);
border: 1px solid var(--platform-cool-border);
background: var(--platform-cool-bg);
color: var(--platform-cool-text);
font-weight: 700;
}
/* 子代 = success 色系。 */
.game-lineage-node__relation--descendant {
color: var(--platform-text-base);
background: var(--platform-surface-muted, rgba(0, 0, 0, 0.05));
border: 1px solid var(--platform-success-border);
background: var(--platform-success-bg);
color: var(--platform-success-text);
font-weight: 700;
}
/* 画布卡片按角色加描边(辅助:主表达仍是上面的角标与 data-relation)。 */
.game-lineage-node[data-relation='ancestor'] {
@@ -11,11 +11,15 @@ import {
collectLineagePlayCountTotals,
collectLineageRelationRoles,
collectLineageSubtreeDepth,
estimateLineageCardHeight,
generationAxisLabel,
LINEAGE_CARD_BASE_HEIGHT,
LINEAGE_CARD_TITLE_LINE_HEIGHT,
LINEAGE_COLUMN_GAP,
LINEAGE_COVER_ASPECT,
LINEAGE_NODE_HEIGHT,
LINEAGE_NODE_WIDTH,
LINEAGE_ROW_GAP,
} from './lineageTreeLayout';
function node(
@@ -68,7 +72,14 @@ describe('buildLineageTreeLayout', () => {
expect(layout.nodes).toHaveLength(1);
expect(layout.edges).toHaveLength(0);
expect(layout.axis).toEqual([
{ generation: 0, label: '母版', y: 0, count: 1 },
{
generation: 0,
label: '母版',
y: 0,
// 层高默认取节点高(没传 cardHeights 时退回 nodeHeight)。
height: LINEAGE_NODE_HEIGHT,
count: 1,
},
]);
expect(layout.nodes[0]!.isStub).toBe(false);
expect(layout.width).toBeGreaterThan(LINEAGE_NODE_WIDTH);
@@ -371,6 +382,139 @@ describe('collectLineageSubtreeDepth', () => {
});
});
describe('层高自适应:第 N 层层高 = 该层最高卡片(不重叠)', () => {
/**
* 树形状:root → mid(同层还有 sibling)→ deep1/deep2。
* 用 `cardHeights` 显式给 mid 一个「更高」的卡片(模拟 2 行标题),验证层高按它撑开。
*/
const nodes = [
node('root', 0, null),
node('mid', 1, 'root'),
node('sibling', 1, 'root'),
node('deep1', 2, 'mid'),
node('deep2', 2, 'mid'),
];
it('层高取该层最大卡片高度,下一层 y = 上一层 y + 层高 + rowGap', () => {
const heights = new Map([
['root', 300],
['mid', 400],
['sibling', 300],
['deep1', 300],
['deep2', 320],
]);
const layout = buildLineageTreeLayout(nodes, { cardHeights: heights });
const byId = new Map(layout.nodes.map((item) => [item.gameId, item]));
// 第 1 层被 400 的卡片撑开:两个兄弟都取 400(层内等高)。
expect(byId.get('mid')!.height).toBe(400);
expect(byId.get('sibling')!.height).toBe(400);
// 第 2 层取 320。
expect(byId.get('deep1')!.height).toBe(320);
expect(byId.get('deep2')!.height).toBe(320);
// y:0 → 300 + rowGap → 300 + gap + 400 + gap。
expect(byId.get('root')!.y).toBe(0);
expect(byId.get('mid')!.y).toBe(300 + LINEAGE_ROW_GAP);
expect(byId.get('deep1')!.y).toBe(300 + LINEAGE_ROW_GAP * 2 + 400);
// 代际轴跟着层高走(不再拿「第一个节点的高度」凑)。
expect(layout.axis.map((entry) => entry.height)).toEqual([300, 400, 320]);
expect(layout.axis.map((entry) => entry.y)).toEqual([
0,
300 + LINEAGE_ROW_GAP,
300 + LINEAGE_ROW_GAP * 2 + 400,
]);
});
it('任意两层之间不重叠:层间空隙 ≥ rowGap,且层高 ≥ 该层每张卡的高度', () => {
const heights = new Map([
['root', 260],
['mid', 420], // 同层有一张「2 行标题」的高卡
['sibling', 300],
['deep1', 340],
['deep2', 300],
]);
const layout = buildLineageTreeLayout(nodes, { cardHeights: heights });
const layers = [...new Set(layout.nodes.map((item) => item.generation))]
.sort((a, b) => a - b)
.map((generation) =>
layout.nodes.filter((item) => item.generation === generation),
);
for (let index = 0; index < layers.length; index += 1) {
const layer = layers[index]!;
// 层内等高
expect(new Set(layer.map((item) => item.height)).size).toBe(1);
// 层高 ≥ 该层每张卡自己的高度
for (const item of layer) {
expect(item.height).toBeGreaterThanOrEqual(heights.get(item.gameId)!);
}
const next = layers[index + 1];
if (!next) continue;
const layerBottom = layer[0]!.y + layer[0]!.height;
// 层间不重叠:下一层顶部至少隔 rowGap
expect(next[0]!.y - layerBottom).toBeGreaterThanOrEqual(LINEAGE_ROW_GAP);
}
// 内容总高覆盖最后一层
const lastLayer = layers.at(-1)!;
expect(layout.height).toBeGreaterThanOrEqual(
lastLayer[0]!.y + lastLayer[0]!.height,
);
});
it('没传 cardHeights(或值非法)时退回 nodeHeight,几何与旧口径一致', () => {
const layout = buildLineageTreeLayout(nodes);
const byId = new Map(layout.nodes.map((item) => [item.gameId, item]));
expect(byId.get('mid')!.height).toBe(LINEAGE_NODE_HEIGHT);
expect(byId.get('deep1')!.y).toBe(
(LINEAGE_NODE_HEIGHT + LINEAGE_ROW_GAP) * 2,
);
// 横版:槽位高取全局最大(否则同一槽位的兄弟会互相压住)。
const horizontal = buildLineageTreeLayout(nodes, {
orientation: 'horizontal',
cardHeights: new Map([['deep2', 380]]),
});
expect(new Set(horizontal.nodes.map((item) => item.height)).size).toBe(1);
});
});
describe('estimateLineageCardHeight(纯函数估算)', () => {
it('一行标题 = 基础高;两行标题多一个行高;超过两行仍按两行封顶', () => {
const short = estimateLineageCardHeight({ title: '共创演示·A0' });
expect(short).toBe(LINEAGE_CARD_BASE_HEIGHT);
const twoLines = estimateLineageCardHeight({
title: '共创演示·A1|星海工坊·深海回声|超长标题超长标题',
});
expect(twoLines).toBe(
LINEAGE_CARD_BASE_HEIGHT + LINEAGE_CARD_TITLE_LINE_HEIGHT,
);
const veryLong = estimateLineageCardHeight({
title:
'共创演示·A11|深海回声·潮汐秘境|这是一条远超过两行的超长标题用于封顶断言',
});
expect(veryLong).toBe(
LINEAGE_CARD_BASE_HEIGHT + LINEAGE_CARD_TITLE_LINE_HEIGHT,
);
});
it('占位节点与空标题都有稳定的行数(不抛错、不返回 0)', () => {
expect(estimateLineageCardHeight({ title: null, isStub: true })).toBe(
LINEAGE_CARD_BASE_HEIGHT,
);
expect(estimateLineageCardHeight({ title: ' ' })).toBe(
LINEAGE_CARD_BASE_HEIGHT,
);
expect(estimateLineageCardHeight({ title: null })).toBeGreaterThan(0);
});
it('西文标题按 0.55 折算宽度(不会因为「字符数少」把行数算少)', () => {
// 40 个西文字符 ≈ 22 个全角单位 → 2 行。
const ascii = estimateLineageCardHeight({
title: 'CoCreation Demo Workspace Alpha Beta Gam',
});
expect(ascii).toBe(
LINEAGE_CARD_BASE_HEIGHT + LINEAGE_CARD_TITLE_LINE_HEIGHT,
);
});
});
describe('collectLineageAncestorChain', () => {
const nodes = [
node('root', 0, null, '根原创'),
@@ -94,8 +94,10 @@ export type LineageLayoutEdge = {
export type LineageGenerationAxisEntry = {
generation: number;
label: string;
/** 该代际所在行的顶部 y。 */
/** 该代际所在行的顶部 y(竖版 = 累计层高)。 */
y: number;
/** 该层的高度(= 该层卡片的最大高度);渲染代际轴时按它画行高,不再拿「第一个节点的高度」凑。 */
height: number;
/** 该行实际渲染的节点数(含占位节点)。 */
count: number;
};
@@ -122,8 +124,72 @@ export type LineageLayoutOptions = {
* 布局算法本身不变(只是把「行/列」这两个轴的像素映射与连线端点对调)。
*/
orientation?: 'vertical' | 'horizontal';
/**
* 每个节点卡片的高度(`estimateLineageCardHeight` 的结果)。
*
* 竖版:**第 N 层的层高 = 该层卡片的最大高度**,下一层的 y 按累计层高 + `rowGap` 排 ——
* 这样「同层有一张 2 行标题的高卡」也不会被下一层压住(层内所有卡片同高:都取该层最大值)。
* 横版:兄弟沿 y 排开,槽位高取全局最大值。
* 缺省(不传 / 某节点没值)退回 `nodeHeight`。
*/
cardHeights?: ReadonlyMap<string, number>;
};
/**
* 画布卡片的高度估算(**纯函数、确定性**,布局据此算「每层多高」)。
*
* 为什么必须估算而不是等渲染后测量:布局是纯函数(要能单测、要能离线复算),
* 一旦依赖 `getBoundingClientRect` 就会引入抖动与「先渲染再重排」的死循环。
* 估算公式 = 基础高 + (标题行数 - 1) × 行高,标题行数按**字符宽度**估(CJK 记 1、其余记 0.55),
* 上限 2 行(与 CSS 的 `-webkit-line-clamp: 2` 一致)。
* 常量由**真机实测校准**(2026-10-07,演示树):1 行标题的卡 376px、2 行 399px ——
* 这里取 380 / 24 留 4~5px 余量,保证「估算 ≥ 实测」,层间只会更宽不会重叠。
*/
export const LINEAGE_CARD_BASE_HEIGHT = 380;
/** 标题每多一行增加的高度(与 CSS 的标题行高一致)。 */
export const LINEAGE_CARD_TITLE_LINE_HEIGHT = 24;
/** 标题最多 2 行(CSS line-clamp 同值)。 */
export const LINEAGE_CARD_TITLE_MAX_LINES = 2;
/** 240 宽卡片一行大约能放下的「全角字符」数(估算用,偏小更安全)。 */
export const LINEAGE_CARD_TITLE_CHARS_PER_LINE = 15;
function titleVisualWidth(title: string) {
let width = 0;
for (const char of title) {
// CJK / 全角标点占一个「全角单位」,西文与半角按 0.55 折算(经验值,宁可高估行数)。
width +=
/[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe6f\uff00-\uff60]/u.test(
char,
)
? 1
: 0.55;
}
return width;
}
/**
* 给定标题,估算画布卡片高度(像素)。**同一层里取最大值**就是那一层的高度
* (`buildLineageTreeLayout` 的 `cardHeights` 输入就是这么用的)。
*/
export function estimateLineageCardHeight(input: {
title?: string | null;
isStub?: boolean;
baseHeight?: number;
}): number {
const base = input.baseHeight ?? LINEAGE_CARD_BASE_HEIGHT;
const title = input.isStub
? '原作品已不可用'
: (input.title ?? '').trim() || '未命名作品';
const lines = Math.min(
LINEAGE_CARD_TITLE_MAX_LINES,
Math.max(
1,
Math.ceil(titleVisualWidth(title) / LINEAGE_CARD_TITLE_CHARS_PER_LINE),
),
);
return base + (lines - 1) * LINEAGE_CARD_TITLE_LINE_HEIGHT;
}
export function generationAxisLabel(generation: number) {
return generation === 0 ? '母版' : `第 ${generation} 代`;
}
@@ -252,8 +318,43 @@ export function buildLineageTreeLayout(
generations.map((generation, index) => [generation, index] as const),
);
const pitch = nodeWidth + columnGap;
const slotPitch = nodeHeight + rowGap;
const orientation = options.orientation ?? 'vertical';
/**
* 每个节点卡片的高度(调用方用 `estimateLineageCardHeight` 估算),缺省退回 `nodeHeight`。
* 布局**不测量 DOM**:所有几何都由这张表推出,纯函数可离线复算、可单测。
*/
const cardHeightOf = (gameId: string) => {
const provided = options.cardHeights?.get(gameId);
return provided && Number.isFinite(provided) && provided > 0
? provided
: nodeHeight;
};
// 竖版:**层高 = 该层卡片的最大高度**(层内所有卡片同高 → 同层等高,且下一层不会被压住)。
const layerHeightByGeneration = new Map<number, number>();
for (const entry of entries) {
const height = cardHeightOf(entry.gameId);
const current = layerHeightByGeneration.get(entry.generation);
layerHeightByGeneration.set(
entry.generation,
current === undefined ? height : Math.max(current, height),
);
}
/** 竖版:每层顶部 y(上一层的层高 + rowGap 累计)。 */
const rowTopByGeneration = new Map<number, number>();
{
let cursor = 0;
for (const generation of generations) {
rowTopByGeneration.set(generation, roundTo(cursor));
cursor +=
(layerHeightByGeneration.get(generation) ?? nodeHeight) + rowGap;
}
}
// 横版:兄弟沿 y 排开,槽位高取全局最大卡片高(否则同一槽位上的兄弟会互相压住)。
const slotHeight = Math.max(
nodeHeight,
...[...new Set(entries.map((entry) => entry.gameId))].map(cardHeightOf),
);
const slotPitch = slotHeight + rowGap;
// 只在真正用到的槽位区间上铺像素:把左侧空槽位裁掉,内容因此更紧凑,
// 「整棵树居中」时右侧也不会留下大片空白。
const usedSlots = [...slotByGameId.values()];
@@ -265,6 +366,8 @@ export function buildLineageTreeLayout(
const row = rowByGeneration.get(entry.generation) ?? 0;
// 竖版:代际→y、槽位→x;横版:代际→x、槽位→y(同一套槽位算法,只换轴)。
const horizontal = orientation === 'horizontal';
const layerHeight =
layerHeightByGeneration.get(entry.generation) ?? nodeHeight;
return {
gameId: entry.gameId,
node: entry.node,
@@ -277,9 +380,10 @@ export function buildLineageTreeLayout(
: roundTo(axisWidth + (slot - minSlot) * pitch),
y: horizontal
? roundTo((slot - minSlot) * slotPitch)
: roundTo(row * slotPitch),
: (rowTopByGeneration.get(entry.generation) ?? 0),
width: nodeWidth,
height: nodeHeight,
// 层内同高:竖版取该层最大高度、横版取全局最大高度(卡片按 min-height 撑到同高)。
height: horizontal ? slotHeight : layerHeight,
isStub: entry.node === null,
branchIndex: branchByGameId.get(entry.gameId) ?? 0,
isTrunk: false,
@@ -376,23 +480,28 @@ export function buildLineageTreeLayout(
const axis: LineageGenerationAxisEntry[] = generations.map((generation) => ({
generation,
label: generationAxisLabel(generation),
y: roundTo((rowByGeneration.get(generation) ?? 0) * (nodeHeight + rowGap)),
y: rowTopByGeneration.get(generation) ?? 0,
height: layerHeightByGeneration.get(generation) ?? nodeHeight,
count: layoutNodes.filter((node) => node.generation === generation).length,
}));
const maxSlotCount = slotByGameId.size > 0 ? maxSlot - minSlot + 1 : 0;
const horizontal = orientation === 'horizontal';
// 竖版纵向总高 = 各层层高累加 + 每层的行间距(层高按最高卡算,见上面的 layerHeightByGeneration)。
const verticalHeight = generations.reduce(
(sum, generation) =>
sum + (layerHeightByGeneration.get(generation) ?? nodeHeight) + rowGap,
0,
);
return {
nodes: layoutNodes,
edges,
axis,
// 竖版:横向由槽位数决定、纵向由代际数决定;横版反过来(代际沿横向推进)。
// 竖版:横向由槽位数决定、纵向由代际层高决定;横版反过来(代际沿横向推进)。
width: roundTo(
axisWidth + (horizontal ? generations.length : maxSlotCount) * pitch,
),
height: roundTo(
(horizontal ? maxSlotCount : generations.length) * slotPitch,
),
height: roundTo(horizontal ? maxSlotCount * slotPitch : verticalHeight),
};
}