族谱卡片:层高按该层最高卡自适应(纯函数估算)+角标移到封面下方并上色+作者行与等权重按钮重做+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
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:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user