diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx
index 9ae16cd13..1a6b675b8 100644
--- a/src/components/game-distribution/GameLineagePage.tsx
+++ b/src/components/game-distribution/GameLineagePage.tsx
@@ -1,7 +1,7 @@
import './gameDistribution.css';
import { GameCard } from '@genarrative/shared/components';
-import { ArrowLeft, Expand, RefreshCcw, ZoomIn, ZoomOut } from 'lucide-react';
+import { ArrowLeft, Expand, Minus, Plus, RefreshCcw } from 'lucide-react';
import {
type PointerEvent as ReactPointerEvent,
useCallback,
@@ -73,10 +73,11 @@ type GameLineagePageProps = {
/**
* 窄屏紧凑卡片的基础高(176 宽画布里的广场卡)。
- * 真机实测(390×844 / 360×800、图谱视图、标题两行):卡宽 148、卡高 **196** ⇒
- * 取 `182 / +20`(两行估算 202 ≥ 196,留 6px 余量)。
+ * 真机实测(2026-10-07 重测,390×844 / 图谱视图 / 176 宽节点 / 角标行已回到封面下方):
+ * 16:9 封面 82.3 + 角标行 26.9 + 信息区(1 行标题)88.6 + 节点内边距/边框 24.4 ≈ **222**;
+ * 标题每多一行 +24(与 `LINEAGE_CARD_TITLE_LINE_HEIGHT` 同值)。
*/
-const LINEAGE_COMPACT_CARD_BASE_HEIGHT = 182;
+const LINEAGE_COMPACT_CARD_BASE_HEIGHT = 222;
/** 拖动阈值(像素):超过它才算「拖动」,否则一律当作点击,交给浏览器原生 click。 */
const DRAG_START_THRESHOLD_PX = 4;
@@ -87,8 +88,13 @@ const FIT_PADDING = 24;
/**
* 可读下限:把整棵树硬塞进窗口会把字缩到看不清(尤其窄屏),
* 所以初次进入若全览缩放低于它,就按可读缩放 + 可平移呈现,由用户点「总览」主动全览。
+ *
+ * 取值依据(2026-10-07 真机实测,截图反馈「60% 可读缩放下卡片信息过密/过小」):
+ * 卡面是 240 宽的广场卡 —— 标题 `1rem`、游玩数 `0.66rem`、作者 `0.7rem`,
+ * 60% 时分别是 9.6 / 6.3 / 6.7px,明显读不清;0.8 时是 12.8 / 8.4 / 9px,
+ * 配合角标下移与「标题独占一行」的版式,首屏可读。
*/
-const READABLE_MIN_SCALE = 0.6;
+const READABLE_MIN_SCALE = 0.8;
type ViewportState = { scale: number; offsetX: number; offsetY: number };
@@ -105,6 +111,7 @@ function LineageTreeNodeCard({
isOnPath,
isPinned,
compact,
+ showGenerationBadge,
relation,
playCount,
onOpenDetail,
@@ -119,9 +126,11 @@ function LineageTreeNodeCard({
isPinned: boolean;
/** 窄屏紧凑模式:少一行信息、卡片更小。 */
compact: boolean;
+ /** 是否渲染世代角标(仅在**没有**左侧代际轴的横版画布上为 true,避免与轴重复)。 */
+ showGenerationBadge: boolean;
/** 相对「当前作品」的角色:父代 / 子代 / 自己 / 旁支(打 `data-relation` + 角标)。 */
relation: LineageRelationRole;
- /** 游玩次数**子树合计**(显示口径):紧凑角标 `▶ N`,不额外占一行。 */
+ /** 游玩次数**子树合计**(显示口径):卡面本体的「N 次游玩」用它,角标里不再重复。 */
playCount: LineagePlayCountBreakdown | null;
onOpenDetail: (gameId: string) => void;
onActivate: (gameId: string) => void;
@@ -148,14 +157,22 @@ function LineageTreeNodeCard({
author: { id: 'lineage', name: authorLabel || '未署名作者' },
} as unknown as GameDistributionGame;
/**
- * 卡面角标槽(不承载动作按钮):世代 / 主干 / 当前作品 / 父代-子代 / ▶子树合计。
- * 卡片本体 = 广场用的同一个 `GameCard`(整卡可点 → 进详情)。
+ * 卡面角标槽(不承载动作按钮):世代 / 主干 / 当前作品 / 父代-子代。
+ *
+ * 两处**去重**(截图反馈里「角标压在封面上导致遮挡与信息重复」):
+ * - 游玩次数不再进角标:卡面本体(广场卡)的标题行已经写着同一个合计「N 次游玩」,
+ * 角标里再来一个 `▶ N` 是同一数字的第二处(口径见 `collectLineagePlayCountTotals`)。
+ * 大纲行没有广场卡本体,那边的 `▶ N` 保留(`LineageNodeContent` 里的那份)。
+ * - 世代角标只在**没有代际轴**时渲染(窄屏横版画布):竖版画布左侧的代际轴已经逐行写着
+ * 「母版 / 第 1 代 / …」,每张卡再带一枚「第 N 代」是同一信息的第二处。
*/
const badges = (
<>
-
- {generationAxisLabel(generation)}
-
+ {showGenerationBadge ? (
+
+ {generationAxisLabel(generation)}
+
+ ) : null}
{layoutNode.isTrunk ? (
主干
@@ -172,15 +189,6 @@ function LineageTreeNodeCard({
{relation === 'ancestor' ? '父代' : '子代'}
) : null}
- {playCount ? (
-
- ▶ {playCount.total}
-
- ) : null}
>
);
@@ -211,6 +219,16 @@ function LineageTreeNodeCard({
data-generation={generation}
data-branch={layoutNode.branchIndex}
data-relation={relation}
+ /*
+ 游玩次数的**含衍生**说明:卡面本体的「N 次游玩」是广场卡给的一行文本,没法挂 title,
+ 所以把口径挂在节点容器上(悬停卡片空白处仍能看到),角标里那枚重复的 `▶ N` 因此可以去掉。
+ 拿不到树(`playCount` 为 null)时不给说明——不暗示含衍生。
+ */
+ title={
+ playCount
+ ? `游玩次数(含衍生作品):自身 ${playCount.own} + 衍生 ${playCount.inherited}`
+ : undefined
+ }
// 占位节点只能聚焦(不进详情);当前作品在无障碍层也标出来。
tabIndex={isStub ? 0 : undefined}
aria-disabled={isStub ? 'true' : undefined}
@@ -589,14 +607,19 @@ export function GameLineagePage({
fitWidth,
availableHeight / Math.max(layout.height, 1),
);
- const target = mode === 'width' ? fitWidth : fitBoth;
- // 低于可读下限时不强行全览:用可读缩放 + 平移,并把「还有代际在视口外」写进提示
+ const fitTarget = mode === 'width' ? fitWidth : fitBoth;
+ // 低于可读下限时不强行全览:改用可读缩放 + 平移,并把「还有代际在视口外」写进提示
//(不静默把最深的代际丢在视口外)。
- const floored = mode === 'both' && target < READABLE_MIN_SCALE;
- const scale = Math.min(
- MAX_SCALE,
- Math.max(MIN_SCALE, floored ? READABLE_MIN_SCALE : target),
- );
+ const floored = mode === 'both' && fitTarget < READABLE_MIN_SCALE;
+ /*
+ * 可读缩放下**横向铺满**:宽度适配比下限更大时就用宽度适配,左右不留大片空白
+ *(截图反馈「留白过多」);树比视口还宽时退回下限,多出来的横向由拖拽承担。
+ * 纵向不铺满:树比视口高就上下平移,这是「可读优先、全览交给按钮」的既定口径。
+ */
+ const target = floored
+ ? Math.max(READABLE_MIN_SCALE, Math.min(fitWidth, MAX_SCALE))
+ : fitTarget;
+ const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, target));
setFitHint(
floored
? '树较大:已按可读缩放显示(其余代际在视口外),拖拽平移查看,或点「总览」看全树。'
@@ -1041,19 +1064,35 @@ export function GameLineagePage({
/>
按热度排序
- {/* 形态切换:移动端默认大纲,需要看全局时可以切到图谱(画布)。 */}
-
+
+
+
{/*
「只看父代与子代」:默认关(整棵树)。打开后只留当前作品的祖先链(父代)
与**直接**子代;再点一次取消。作用是「聚焦一支」——树大了以后不必在旁支里找。
@@ -1228,6 +1267,8 @@ export function GameLineagePage({
isOnPath={Boolean(pathIds?.has(layoutNode.gameId))}
isPinned={pinnedGameId === layoutNode.gameId}
compact={compactTree}
+ // 竖版画布有左侧代际轴,卡上不再重复「第 N 代」;横版(窄屏图谱)没有轴,保留。
+ showGenerationBadge={horizontalCanvas}
relation={
relationRoles.get(layoutNode.gameId) ?? 'side'
}
@@ -1297,7 +1338,12 @@ export function GameLineagePage({
disabled={viewport.scale <= MIN_SCALE + 0.001}
onClick={() => zoomBy(1 / 1.2)}
>
-
+ {/*
+ 用「−/+」而不是放大镜图标:放大镜在控件栏里会被读成「搜索」
+ (截图反馈里就有人问「搜索为什么放在图谱控件栏」),
+ 而这里其实**没有**搜索按钮 —— 读数和 ± 才是地图/画布控件的通用语。
+ */}
+
{Math.round(viewport.scale * 100)}%
@@ -1310,7 +1356,7 @@ export function GameLineagePage({
disabled={viewport.scale >= MAX_SCALE - 0.001}
onClick={() => zoomBy(1.2)}
>
-
+