From 14d90718ad0cebb727b95661cf8c8cf9f8585dfa Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 7 Oct 2026 13:24:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=97=8F=E8=B0=B1=E5=8D=A1=E7=89=87=EF=BC=9A?= =?UTF-8?q?=E5=B1=82=E9=AB=98=E6=8C=89=E8=AF=A5=E5=B1=82=E6=9C=80=E9=AB=98?= =?UTF-8?q?=E5=8D=A1=E8=87=AA=E9=80=82=E5=BA=94(=E7=BA=AF=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E4=BC=B0=E7=AE=97)+=E8=A7=92=E6=A0=87=E7=A7=BB?= =?UTF-8?q?=E5=88=B0=E5=B0=81=E9=9D=A2=E4=B8=8B=E6=96=B9=E5=B9=B6=E4=B8=8A?= =?UTF-8?q?=E8=89=B2+=E4=BD=9C=E8=80=85=E8=A1=8C=E4=B8=8E=E7=AD=89?= =?UTF-8?q?=E6=9D=83=E9=87=8D=E6=8C=89=E9=92=AE=E9=87=8D=E5=81=9A+Fork=20?= =?UTF-8?q?=E6=94=B9=E5=90=8D=E5=BC=80=E5=A7=8B=E5=85=B1=E5=88=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GameDistributionPages.test.tsx | 132 +++++++++++++++- .../game-distribution/GameLineagePage.tsx | 74 ++++++--- .../game-distribution/LineageNodeContent.tsx | 9 +- .../game-distribution/LineageOutlineList.tsx | 8 +- .../game-distribution/gameDistribution.css | 109 +++++++++---- .../lineageTreeLayout.test.ts | 146 +++++++++++++++++- .../game-distribution/lineageTreeLayout.ts | 127 +++++++++++++-- 7 files changed, 523 insertions(+), 82 deletions(-) diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 5b02084f5..c717b8026 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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(); }); }); diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx index 2cdf25ead..015daf1b5 100644 --- a/src/components/game-distribution/GameLineagePage.tsx +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -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({ /> + {/* + 两个**等权重**的动作按钮:次要(描边)= 打开作品,主要(填充)= 开始共创。 + 同一尺寸 / 同一圆角 / 同一内边距,窄卡里允许换行但绝不裁切(见 CSS)。 + */} @@ -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, }} > diff --git a/src/components/game-distribution/LineageNodeContent.tsx b/src/components/game-distribution/LineageNodeContent.tsx index 2fb4d8561..b8aeece23 100644 --- a/src/components/game-distribution/LineageNodeContent.tsx +++ b/src/components/game-distribution/LineageNodeContent.tsx @@ -116,11 +116,12 @@ export function LineageNodeContent({ > - {/* 画布卡片:角标组作为封面上的浮层(整组一起排,不会各自乱定位)。 */} - {layout === 'stacked' ? badges : null} - {/* 大纲行:角标组独立成列(在封面右侧、操作左侧),所以不会挤进封面里叠字。 */} - {layout === 'inline' ? badges : null} + {/* + 角标组在封面**下方**(卡片信息区第一行):不再压封面、不再用深色药丸浮在图上。 + 画布卡片与大纲行都用同一组(角标语义 / 配色 / 顺序完全一致),组内 flex + gap + 可换行。 + */} + {badges} {displayTitle} {/* diff --git a/src/components/game-distribution/LineageOutlineList.tsx b/src/components/game-distribution/LineageOutlineList.tsx index ba0d21254..d857ee763 100644 --- a/src/components/game-distribution/LineageOutlineList.tsx +++ b/src/components/game-distribution/LineageOutlineList.tsx @@ -108,15 +108,15 @@ export function LineageOutlineList({ aria-label={`打开《${node.title}》详情`} onClick={() => onOpenDetail(node.gameId)} > - 打开 + 打开作品 )} diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css index 4554d09b8..b8921e30d 100644 --- a/src/components/game-distribution/gameDistribution.css +++ b/src/components/game-distribution/gameDistribution.css @@ -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'] { diff --git a/src/components/game-distribution/lineageTreeLayout.test.ts b/src/components/game-distribution/lineageTreeLayout.test.ts index de0126fc0..966d7af25 100644 --- a/src/components/game-distribution/lineageTreeLayout.test.ts +++ b/src/components/game-distribution/lineageTreeLayout.test.ts @@ -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, '根原创'), diff --git a/src/components/game-distribution/lineageTreeLayout.ts b/src/components/game-distribution/lineageTreeLayout.ts index cd1c9b2ce..90b2a3380 100644 --- a/src/components/game-distribution/lineageTreeLayout.ts +++ b/src/components/game-distribution/lineageTreeLayout.ts @@ -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; }; +/** + * 画布卡片的高度估算(**纯函数、确定性**,布局据此算「每层多高」)。 + * + * 为什么必须估算而不是等渲染后测量:布局是纯函数(要能单测、要能离线复算), + * 一旦依赖 `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(); + 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(); + { + 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), }; }