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)} > -