族谱移动端图谱视图:横版画布 + 进入时按宽度适配并定位当前作品
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

This commit is contained in:
2026-10-07 01:51:21 +08:00
parent 9cf30517db
commit ec8f27cb28
3 changed files with 277 additions and 64 deletions
@@ -459,7 +459,7 @@ A 路线里那个必须提前知道的互斥点已经按既有做法绕开:`cr
- **「累计创作世代数」在前端计算**:=本作品子树深度(子树最大代际 − 本作品代际),用详情页**已经请求的同一份 `/lineage` 数据**算,不新增请求、也不加服务端字段。
- **移动端形态**(跟随仓库既有窄断点,`useIsMobileViewport`):
- **默认大纲式缩进树**(`LineageOutlineList`):全宽行、层级用左缩进 + 肘形导线表达、主干链导线加粗用主题色;**无横向滚动**、无需缩放平移;行高 ≥44px,点击行 = 选中(无 hover)。
- **图谱视图为可选开关**:切到画布后支持拖拽平移 / 缩放;布局层支持 `orientation: 'horizontal'`(根在左、代际向右,兄弟分支沿纵向排开)——窄屏上比顶→下更省横向空间。
- **图谱视图为可选开关**(形态 B = **横版画布**,`orientation: 'horizontal'`:根在左、代际向右、兄弟分支沿纵向排开,窄屏上比顶→下更省横向空间):切到画布后支持拖拽平移 / 缩放;横版**不渲染左侧代际轴**,代际信息由节点自带角标承担。**初次进入按宽度适配**(整棵树横向看完;触及最小缩放时给出「可拖拽平移」提示),并把**当前作品**用最小位移拉进视口——贴最近的边,父代 / 兄弟仍留在视口里,不会出现「停在 100% 缩放、一个节点都看不到」的空屏。桌面仍为纵向画布 + 宽高同时适配并居中。
- **信息面板用 bottom sheet**:从底部升起(含简介 / 授权 / 核心改动说明 / 前往体验 / Fork 此版本),遮罩点击或关闭按钮收起;桌面保持画布右上浮层。
- 面包屑横向可滚动且**当前项常驻可见**;尊重 `prefers-reduced-motion`;底部留 `env(safe-area-inset-bottom)`。
- **移动端门限豁免(2026-10-06)**:平台原有的「移动端仅支持作品展示」欢迎弹窗只看 `!isDesktopLayout`、忽略路由,会把公开只读的族谱树 / 共创主题页首屏盖住。现在按**显式白名单**豁免这三个 stage:`game-lineage` / `game-themes` / `game-theme`(清单在 `PlatformActiveMobileWelcomeDialog.tsx` 的 `MOBILE_WELCOME_EXEMPT_STAGES`,键名即 stage 名,不做前缀/范围匹配)。**创作 / 项目 / 发布 / 我的 / 平台首页仍保持门限**——那些才是「移动端不支持体验创作工具」的部分;弹窗文案未改(与豁免后的事实不矛盾:族谱与主题页本身就是作品展示)。
@@ -3096,6 +3096,146 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
expect(await screen.findByLabelText('创作族谱大纲')).toBeTruthy();
});
/** 读画布视口状态:内容层的 `transform: translate(...) scale(...)`。 */
function readCanvasViewport(canvas: HTMLElement) {
const content = canvas.querySelector(
'.game-lineage-canvas__content',
) as HTMLElement;
const match =
/translate\(([-\d.]+)px, ([-\d.]+)px\) scale\(([\d.]+)\)/u.exec(
content.style.transform,
);
if (!match) {
throw new Error(`无法解析画布 transform: ${content.style.transform}`);
}
return {
offsetX: Number.parseFloat(match[1]!),
offsetY: Number.parseFloat(match[2]!),
scale: Number.parseFloat(match[3]!),
};
}
/** 视口内可见的节点卡片:把内容层的 transform 应用到卡片矩形,再与画布矩形求交。 */
function visibleCanvasCards(
canvas: HTMLElement,
view: { offsetX: number; offsetY: number; scale: number },
) {
return [...canvas.querySelectorAll('.game-lineage-node')].filter((node) => {
const card = node as HTMLElement;
const left =
view.offsetX + Number.parseFloat(card.style.left) * view.scale;
const top = view.offsetY + Number.parseFloat(card.style.top) * view.scale;
const width = Number.parseFloat(card.style.width) * view.scale;
const height = Number.parseFloat(card.style.minHeight) * view.scale;
return (
left + width > 0 &&
top + height > 0 &&
left < canvas.clientWidth &&
top < canvas.clientHeight
);
});
}
it('图谱视图初次进入即适配并定位当前作品(不是 zoom 100% 的空屏)', async () => {
setViewportWidth(390);
getGameLineageMock.mockResolvedValueOnce(tree);
// jsdom 不做布局:给画布一个「真机尺寸」,适配与定位才有可断言的数值。
const widthSpy = vi
.spyOn(Element.prototype, 'clientWidth', 'get')
.mockReturnValue(390);
const heightSpy = vi
.spyOn(Element.prototype, 'clientHeight', 'get')
.mockReturnValue(667);
try {
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
await screen.findByLabelText('创作族谱大纲');
fireEvent.click(screen.getByRole('button', { name: '图谱视图' }));
const canvas = (await screen.findByLabelText(
'创作族谱树',
)) as HTMLElement;
const view = readCanvasViewport(canvas);
// 窄屏图谱视图是**横版**:代际沿 x 推进,且不再渲染左侧代际轴(角标承担代际信息)。
expect(canvas.querySelector('.game-lineage-canvas__axis')).toBeNull();
expect(
new Set(
[...canvas.querySelectorAll('.game-lineage-node')].map((card) =>
Number.parseFloat((card as HTMLElement).style.left),
),
).size,
).toBeGreaterThan(1);
expect(Math.round(view.scale * 100)).not.toBe(100);
expect(canvas.querySelector('.game-lineage-zoom')?.textContent).toBe(
`${Math.round(view.scale * 100)}%`,
);
const visible = visibleCanvasCards(canvas, view);
// 「一个节点都看不到」是最初的回归:这里至少要看到 3 个,且当前作品在视口内。
expect(visible.length).toBeGreaterThanOrEqual(3);
expect(visible).toContain(
canvas.querySelector('.game-lineage-node--anchor'),
);
expect(
visible.some((card) => card.getAttribute('data-generation') === '0'),
).toBe(true);
} finally {
widthSpy.mockRestore();
heightSpy.mockRestore();
}
});
it('图谱视图缩放触底时把当前作品滚进视口(保上下文,不只剩空屏)', async () => {
setViewportWidth(320);
getGameLineageMock.mockResolvedValueOnce(tree);
const widthSpy = vi
.spyOn(Element.prototype, 'clientWidth', 'get')
.mockReturnValue(200);
const heightSpy = vi
.spyOn(Element.prototype, 'clientHeight', 'get')
.mockReturnValue(420);
try {
render(
<GameLineagePage
rootGameId="game-root"
// 锚点是**最右代**:树比视口宽、缩放已触底,必须靠位移把它拉进来。
highlightGameId="deep"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
await screen.findByLabelText('创作族谱大纲');
fireEvent.click(screen.getByRole('button', { name: '图谱视图' }));
const canvas = (await screen.findByLabelText(
'创作族谱树',
)) as HTMLElement;
const view = readCanvasViewport(canvas);
expect(view.scale).toBeGreaterThan(0);
expect(Math.round(view.scale * 100)).not.toBe(100);
const visible = visibleCanvasCards(canvas, view);
expect(visible).toContain(
canvas.querySelector('.game-lineage-node--anchor'),
);
// 当前作品进来了,但不是「只剩它」:父代仍在视口里,用户看得到上下文。
expect(visible.length).toBeGreaterThanOrEqual(2);
expect(
visible.some((card) => card.getAttribute('data-generation') === '1'),
).toBe(true);
} finally {
widthSpy.mockRestore();
heightSpy.mockRestore();
}
});
it('窄屏仍能点进详情页与查看完整族谱入口(大纲模式不丢入口)', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(tree);
@@ -325,6 +325,8 @@ export function GameLineagePage({
);
// 窄屏用紧凑布局(更小的卡片与间距 + 更大的相对字号),避免「桌面画布缩小 + 字太小」。
const compactTree = isMobileViewport;
/** 窄屏图谱视图用横版画布:代际信息交给节点角标,因此不渲染左侧代际轴层。 */
const horizontalCanvas = compactTree;
const layout = useMemo(
() =>
buildLineageTreeLayout(
@@ -337,6 +339,9 @@ export function GameLineagePage({
columnGap: 22,
rowGap: 88,
axisWidth: 72,
// 窄屏图谱视图用横版(根在左、代际向右、兄弟沿纵向排开):窄屏上比顶→下更省横向空间,
// 纵向溢出交给滑动。代际信息由节点自带角标承担,所以横版不渲染代际轴(见下面的轴层)。
orientation: 'horizontal',
}
: undefined,
),
@@ -441,6 +446,68 @@ export function GameLineagePage({
fitToView();
}, [fitToView, layout.nodes.length, layout.width, normalizedRootId]);
/**
* 窄屏「图谱视图」的初次定位:按**宽度**适配(整棵树横向看完),再把当前作品拉进视口。
*
* 为什么单独一条:窄屏默认是大纲形态,画布**根本没挂载**(`viewportRef.current` 为 null),
* 上面那次自动适配其实什么都没做;用户切到图谱视图时画布是新挂载的,会停在
* `scale 1 / offset 0`——实测一个节点都看不到。桌面仍然是「宽高同时满足 + 居中」,
* 不受这里影响。
*/
const focusMobileCanvasOnAnchor = useCallback(() => {
const viewportElement = viewportRef.current;
if (!viewportElement || layout.nodes.length === 0) return;
const width = viewportElement.clientWidth;
const height = viewportElement.clientHeight;
const availableWidth = Math.max(1, width - FIT_PADDING * 2);
const fitWidth = availableWidth / Math.max(layout.width, 1);
const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, fitWidth));
setFitHint(
scale > fitWidth + 0.001
? '树较宽,已按最小缩放显示:拖拽平移查看。'
: '',
);
let offsetX = Math.max(FIT_PADDING, (width - layout.width * scale) / 2);
let offsetY = FIT_PADDING;
const anchorNode = layout.nodes.find((item) => item.gameId === anchorId);
if (anchorNode && width > 0 && height > 0) {
const left = offsetX + anchorNode.x * scale;
const right = left + anchorNode.width * scale;
const top = offsetY + anchorNode.y * scale;
const bottom = top + anchorNode.height * scale;
// 只做**最小位移**把当前作品拉进视口(贴到最近的边),不做粗暴对中——
// 这样父代 / 兄弟仍在视口里,用户一眼能看到上下文。
if (left < FIT_PADDING) offsetX += FIT_PADDING - left;
else if (right > width - FIT_PADDING) {
offsetX -= right - (width - FIT_PADDING);
}
if (top < FIT_PADDING) offsetY += FIT_PADDING - top;
else if (bottom > height - FIT_PADDING) {
offsetY -= bottom - (height - FIT_PADDING);
}
}
setViewport({ scale, offsetX, offsetY });
}, [anchorId, layout.nodes, layout.width]);
// 每次真正「进入图谱视图」(或换了根作品 / 锚点)定位一次;筛选、缩放、平移不抢视野。
const mobileFocusedForRef = useRef('');
useEffect(() => {
if (effectiveViewMode !== 'canvas' || !isMobileViewport) return;
if (layout.nodes.length === 0) return;
const key = `${normalizedRootId}:${layout.nodes.length}:${layout.width}:${anchorId}`;
if (mobileFocusedForRef.current === key) return;
mobileFocusedForRef.current = key;
focusMobileCanvasOnAnchor();
}, [
anchorId,
effectiveViewMode,
focusMobileCanvasOnAnchor,
isMobileViewport,
layout.nodes.length,
layout.width,
normalizedRootId,
]);
useEffect(
() => () => {
if (forkTimerRef.current !== null) {
@@ -753,40 +820,40 @@ export function GameLineagePage({
/>
) : null}
</div>
{pinnedChain.length > 0 ? (
<div
className="game-lineage-breadcrumb"
aria-label="已选中的路径"
>
{pinnedChain.map((crumb, index) => (
<span
key={crumb.gameId}
className="game-lineage-breadcrumb__item"
>
{index > 0 ? <span aria-hidden="true">→</span> : null}
<button
type="button"
className="game-lineage-breadcrumb__crumb"
ref={
crumb.gameId === pinnedGameId
? currentCrumbRef
: undefined
}
onClick={() => setPinnedGameId(crumb.gameId)}
>
{`${generationAxisLabel(crumb.generation)}《${crumb.label}》`}
</button>
</span>
))}
<button
type="button"
className="game-lineage-breadcrumb__clear"
onClick={() => setPinnedGameId('')}
{pinnedChain.length > 0 ? (
<div
className="game-lineage-breadcrumb"
aria-label="已选中的路径"
>
取消锁定
</button>
</div>
) : null}
{pinnedChain.map((crumb, index) => (
<span
key={crumb.gameId}
className="game-lineage-breadcrumb__item"
>
{index > 0 ? <span aria-hidden="true">→</span> : null}
<button
type="button"
className="game-lineage-breadcrumb__crumb"
ref={
crumb.gameId === pinnedGameId
? currentCrumbRef
: undefined
}
onClick={() => setPinnedGameId(crumb.gameId)}
>
{`${generationAxisLabel(crumb.generation)}《${crumb.label}》`}
</button>
</span>
))}
<button
type="button"
className="game-lineage-breadcrumb__clear"
onClick={() => setPinnedGameId('')}
>
取消锁定
</button>
</div>
) : null}
{effectiveViewMode === 'outline' ? (
<LineageOutlineList
nodes={layout.nodes}
@@ -869,29 +936,35 @@ export function GameLineagePage({
{/*
代际轴是**固定层**:在 transform 之外,位置按视口换算,字号不随缩放变小,
因此放大或平移后轴标签始终在画布左侧可读,而不会被内容层带出去裁掉。
横版(窄屏图谱视图)不渲染它:代际沿横向推进,节点自带的角标已承担代际信息。
*/}
<div className="game-lineage-canvas__axis" aria-hidden="true">
{layout.axis.map((entry, index) => (
<span
key={entry.generation}
className="game-lineage-axis"
style={{
top: viewport.offsetY + entry.y * viewport.scale,
// 行高跟着缩放走:轴上的行与画布里的行始终对齐。
height:
(layout.nodes[0]?.height ?? 0) * viewport.scale,
borderTopWidth: index === 0 ? 0 : 1,
}}
>
<span className="game-lineage-axis__label">
{entry.label}
{horizontalCanvas ? null : (
<div
className="game-lineage-canvas__axis"
aria-hidden="true"
>
{layout.axis.map((entry, index) => (
<span
key={entry.generation}
className="game-lineage-axis"
style={{
top: viewport.offsetY + entry.y * viewport.scale,
// 行高跟着缩放走:轴上的行与画布里的行始终对齐。
height:
(layout.nodes[0]?.height ?? 0) * viewport.scale,
borderTopWidth: index === 0 ? 0 : 1,
}}
>
<span className="game-lineage-axis__label">
{entry.label}
</span>
<span className="game-lineage-axis__count">
{entry.count}
</span>
</span>
<span className="game-lineage-axis__count">
{entry.count}
</span>
</span>
))}
</div>
))}
</div>
)}
{/*
画布内悬浮控件:放在**右下角**——左侧是代际轴、顶部是路径面包屑,
右下角是地图/画布类应用的习惯位置,也最不容易遮住根节点与主干。
@@ -960,15 +1033,15 @@ export function GameLineagePage({
「核心改动说明」是后端待补字段,缺失时整行不渲染。
*/}
{selectedLayoutNode ? (
<LineageInfoPanel
node={selectedLayoutNode}
game={selectedGame}
isSheet={false}
onClose={() => setPinnedGameId('')}
onOpenDetail={onOpenDetail}
onOpenFork={launchForkFromNode}
/>
) : null}
<LineageInfoPanel
node={selectedLayoutNode}
game={selectedGame}
isSheet={false}
onClose={() => setPinnedGameId('')}
onOpenDetail={onOpenDetail}
onOpenFork={launchForkFromNode}
/>
) : null}
</div>
)}
{/* 大纲模式下的 bottom sheet(桌面形态的面板在画布内,见上一分支)。 */}