族谱画布打磨:角标搬回封面下方并去掉 ▶N/世代重复项、可读下限 0.8 且横向铺满、视图形态改分段控件、缩放图标改 ±(卡片常量按实测重标 260/222/24)
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:
2026-10-07 17:51:49 +08:00
parent 30ea1ee246
commit e349bc696a
2 changed files with 106 additions and 51 deletions
@@ -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 = (
<>
<span className="game-lineage-node__generation">
{generationAxisLabel(generation)}
</span>
{showGenerationBadge ? (
<span className="game-lineage-node__generation">
{generationAxisLabel(generation)}
</span>
) : null}
{layoutNode.isTrunk ? (
<span className="game-lineage-node__trunk" title="该代热度最高的版本">
主干
@@ -172,15 +189,6 @@ function LineageTreeNodeCard({
{relation === 'ancestor' ? '父代' : '子代'}
</span>
) : null}
{playCount ? (
<span
className="game-lineage-node__play-count"
title={`游玩次数(含衍生作品):自身 ${playCount.own} + 衍生 ${playCount.inherited}`}
aria-label={`游玩次数(含衍生作品)${playCount.total}`}
>
▶ {playCount.total}
</span>
) : 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({
/>
按热度排序
</label>
{/* 形态切换:移动端默认大纲,需要看全局时可以切到图谱(画布)。 */}
<button
type="button"
className="game-lineage-filter__switch"
aria-pressed={effectiveViewMode === 'canvas'}
onClick={() =>
setViewMode(
effectiveViewMode === 'canvas' ? 'outline' : 'canvas',
)
}
{/*
形态切换:移动端默认大纲、桌面默认图谱(画布)。
用**两个按钮的分段控件**而不是一个来回翻转的开关:原来那个单按钮的文案是
「目标形态」(图谱态显示「大纲视图」)而 `aria-pressed` 却指「当前是画布」,
于是屏幕上明明画着画布、按钮却是「大纲视图 + 已按下」(截图反馈里的状态不一致)。
现在两个选项常驻,`aria-pressed` 与文案一一对应,当前形态一眼可读。
*/}
<span
className="game-lineage-filter__group"
role="group"
aria-label="视图形态"
>
{effectiveViewMode === 'canvas' ? '大纲视图' : '图谱视图'}
</button>
<button
type="button"
className="game-lineage-filter__switch"
aria-pressed={effectiveViewMode === 'canvas'}
onClick={() => setViewMode('canvas')}
>
图谱视图
</button>
<button
type="button"
className="game-lineage-filter__switch"
aria-pressed={effectiveViewMode === 'outline'}
onClick={() => setViewMode('outline')}
>
大纲视图
</button>
</span>
{/*
「只看父代与子代」:默认关(整棵树)。打开后只留当前作品的祖先链(父代)
与**直接**子代;再点一次取消。作用是「聚焦一支」——树大了以后不必在旁支里找。
@@ -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)}
>
<ZoomOut aria-hidden="true" />
{/*
用「−/+」而不是放大镜图标:放大镜在控件栏里会被读成「搜索」
(截图反馈里就有人问「搜索为什么放在图谱控件栏」),
而这里其实**没有**搜索按钮 —— 读数和 ± 才是地图/画布控件的通用语。
*/}
<Minus aria-hidden="true" />
</button>
<span className="game-lineage-zoom" role="status">
{Math.round(viewport.scale * 100)}%
@@ -1310,7 +1356,7 @@ export function GameLineagePage({
disabled={viewport.scale >= MAX_SCALE - 0.001}
onClick={() => zoomBy(1.2)}
>
<ZoomIn aria-hidden="true" />
<Plus aria-hidden="true" />
</button>
<span
className="game-lineage-controls__divider"
@@ -35,7 +35,10 @@ import type { GameDistributionLineageNode } from '../../services/gameDistributio
export const LINEAGE_COVER_ASPECT = 16 / 9;
export const LINEAGE_NODE_WIDTH = 240;
/** 卡片高度 = 16:9 封面 + 信息条(标题 + 作者)。 */
/**
* 卡片高度的**缺省回退值**(调用方没传 `cardHeights` 时用)。
* 族谱页/血缘图真实渲染的是 240 宽的广场卡变体,走 `LINEAGE_CARD_BASE_HEIGHT`(含角标行与作者行)。
*/
export const LINEAGE_NODE_HEIGHT = 192;
/** 同一层相邻槽位之间额外留的横向间隙(保证不重叠)。 */
export const LINEAGE_COLUMN_GAP = 32;
@@ -142,21 +145,27 @@ export type LineageLayoutOptions = {
* 一旦依赖 `getBoundingClientRect` 就会引入抖动与「先渲染再重排」的死循环。
* 估算公式 = 基础高 + (标题行数 - 1) × 行高,标题行数按**字符宽度**估(CJK 记 1、其余记 0.55),
* 上限 2 行(与 CSS 的 `-webkit-line-clamp: 2` 一致)。
* 常量由**真机实测校准**(2026-10-07,卡片改为**广场作品卡组件**之后重测):
* 桌面卡宽 212.4(内容坐标)、标题两行时**实测卡高 231.5**;按公式 `base + (行数-1)×行高`,
* 取 `base = 214` / `行高 = 20`(两行 → 234 ≥ 231.5),保证「估算 ≥ 实测」,层间只会更宽不会重叠。
*
* 常量由**真机实测校准**(2026-10-07 重测:角标槽从「压在封面上」改回**封面下方**的信息区第一行、
* 标题独占一行之后,卡片又高了 —— 老常量 214/20 已明显偏小,实测层间距被吃掉一半)。
* 实测(真机 Chromium、演示树 8 节点、桌面 1440×900、240 宽节点):
* 节点盒高 = 6:9 封面 118.3 + 角标行 26.9 + 信息区(1 行标题)88.6 + 节点内边距/边框 24.4 = **260.3**;
* 标题每多一行 = **+24**(标题行盒 24px);
* 实测值与公式逐项相等(260.3 / 284.3),因此取 `base = 260` / `行高 = 24`。
*/
export const LINEAGE_CARD_BASE_HEIGHT = 214;
/** 标题每多一行增加的高度(与 CSS 的标题行高一致)。 */
export const LINEAGE_CARD_TITLE_LINE_HEIGHT = 20;
export const LINEAGE_CARD_BASE_HEIGHT = 260;
/** 标题每多一行增加的高度(= 标题行盒高度,真机实测 24px)。 */
export const LINEAGE_CARD_TITLE_LINE_HEIGHT = 24;
/** 标题最多 2 行(CSS line-clamp 同值)。 */
export const LINEAGE_CARD_TITLE_MAX_LINES = 2;
/**
* 卡面一行大约能放下的「全角字符」数(估算用)。
* 广场卡在 212 宽画布卡里标题是 1rem 粗体、可用宽度约 180px ⇒ 一行约 11~12 个全角字符;
* 真机实测「共创演示·A0|星海工坊」(按视觉宽度约 11 个全角单位)**实际渲染成两行**,所以这里取 10。
* 广场卡在 212 宽画布卡里标题是 1rem 粗体、可用宽度 **180.4px** ⇒ 一行 ≈ **11.3** 个全角字符。
* 真机实测:「共创演示·A0|星海工坊」(视觉宽度 10.65)**1 行**、
* 「共创演示·A1|星海工坊·深海回声」(15.65)**2 行** —— 取 11 与两条实测都对得上
*(取 10 会把前者高估成 2 行,白高 24px)。
*/
export const LINEAGE_CARD_TITLE_CHARS_PER_LINE = 10;
export const LINEAGE_CARD_TITLE_CHARS_PER_LINE = 11;
function titleVisualWidth(title: string) {
let width = 0;