diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md
index ea645121d..d397794bb 100644
--- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md
+++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md
@@ -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 名,不做前缀/范围匹配)。**创作 / 项目 / 发布 / 我的 / 平台首页仍保持门限**——那些才是「移动端不支持体验创作工具」的部分;弹窗文案未改(与豁免后的事实不矛盾:族谱与主题页本身就是作品展示)。
diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx
index f82904b8d..59202243e 100644
--- a/src/components/game-distribution/GameDistributionPages.test.tsx
+++ b/src/components/game-distribution/GameDistributionPages.test.tsx
@@ -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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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);
diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx
index d32035fe2..ca3535af0 100644
--- a/src/components/game-distribution/GameLineagePage.tsx
+++ b/src/components/game-distribution/GameLineagePage.tsx
@@ -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}
- {pinnedChain.length > 0 ? (
-
- {pinnedChain.map((crumb, index) => (
-
- {index > 0 ? → : null}
-
-
- ))}
-
+ ) : null}
{effectiveViewMode === 'outline' ? (
- {layout.axis.map((entry, index) => (
-
-
- {entry.label}
+ {horizontalCanvas ? null : (
+
+ {layout.axis.map((entry, index) => (
+
+
+ {entry.label}
+
+
+ {entry.count}
+
-
- {entry.count}
-
-
- ))}
-
+ ))}
+
+ )}
{/*
画布内悬浮控件:放在**右下角**——左侧是代际轴、顶部是路径面包屑,
右下角是地图/画布类应用的习惯位置,也最不容易遮住根节点与主干。
@@ -960,15 +1033,15 @@ export function GameLineagePage({
「核心改动说明」是后端待补字段,缺失时整行不渲染。
*/}
{selectedLayoutNode ? (
- setPinnedGameId('')}
- onOpenDetail={onOpenDetail}
- onOpenFork={launchForkFromNode}
- />
- ) : null}
+ setPinnedGameId('')}
+ onOpenDetail={onOpenDetail}
+ onOpenFork={launchForkFromNode}
+ />
+ ) : null}
)}
{/* 大纲模式下的 bottom sheet(桌面形态的面板在画布内,见上一分支)。 */}