族谱画布打磨:角标搬回封面下方并去掉 ▶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
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user