族谱卡宽统一加宽(清单卡=画布卡,224/148 同一下发);容器改透明、阴影移到卡片;常量按真机重测 265/247/12
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 / AI game creator shell Rust lane 1/2 (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 20:03:12 +08:00
parent 8458113c94
commit 5665eaa26a
4 changed files with 149 additions and 88 deletions
@@ -3,6 +3,7 @@ import './gameDistribution.css';
import { GameCard } from '@genarrative/shared/components';
import { ArrowLeft, Minimize, Minus, Plus, RefreshCcw } from 'lucide-react';
import {
type CSSProperties,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
@@ -41,6 +42,12 @@ import {
estimateLineageCardHeight,
generationAxisLabel,
LINEAGE_CARD_BASE_HEIGHT,
LINEAGE_CARD_WIDTH,
LINEAGE_COMPACT_CARD_BASE_HEIGHT,
LINEAGE_COMPACT_CARD_WIDTH,
LINEAGE_COMPACT_NODE_WIDTH,
LINEAGE_NODE_PADDING_X,
LINEAGE_NODE_PADDING_Y,
type LineageLayoutNode,
type LineagePlayCountBreakdown,
type LineageRelationRole,
@@ -71,14 +78,6 @@ type GameLineagePageProps = {
) => void;
};
/**
* 窄屏紧凑卡片的基础高(176 宽画布里的广场卡)。
* 真机实测(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 = 222;
/** 拖动阈值(像素):超过它才算「拖动」,否则一律当作点击,交给浏览器原生 click。 */
const DRAG_START_THRESHOLD_PX = 4;
@@ -483,8 +482,9 @@ export function GameLineagePage({
filteredNodes,
compactTree
? {
// 紧凑态:卡片 176 宽 → 16:9 封面 99 高 + 角标行 + 标题 + 作者 / 操作。
nodeWidth: 176,
// 紧凑态:卡宽与清单卡同一个值(`LINEAGE_COMPACT_CARD_WIDTH`),
// 节点盒 = 卡宽 + 2×内边距 —— 不在这里另写 176 这类魔法数。
nodeWidth: LINEAGE_COMPACT_NODE_WIDTH,
nodeHeight: LINEAGE_COMPACT_CARD_BASE_HEIGHT,
columnGap: 22,
rowGap: 88,
@@ -964,6 +964,19 @@ export function GameLineagePage({
return (
<div
className={`game-page game-lineage-page${embedded ? ' game-lineage-page--embedded' : ''}`}
/*
卡宽的**唯一来源**是布局常量(`lineageTreeLayout.ts`):这里把卡宽与节点内边距
下发给 CSS,`.game-lineage-node` 与 `.lineage-relation__grid` 都只读这几个变量,
因此「画布节点卡」与「父代/子代清单卡」永远同宽(用户口径:用的是同一个卡片)。
*/
style={
{
'--lineage-card-width': `${LINEAGE_CARD_WIDTH}px`,
'--lineage-compact-card-width': `${LINEAGE_COMPACT_CARD_WIDTH}px`,
'--lineage-node-pad-y': `${LINEAGE_NODE_PADDING_Y}px`,
'--lineage-node-pad-x': `${LINEAGE_NODE_PADDING_X}px`,
} as CSSProperties
}
>
{embedded ? null : (
<button type="button" className="game-back-button" onClick={onBack}>
@@ -1146,21 +1146,25 @@
.game-lineage-node--dimmed {
opacity: 0.34;
}
/* 高亮分三档,差异要肉眼可见:路径节点(强调边框 + 淡底)、仅子树节点(淡底 + 中等边框)、
目标节点(外发光 + 强调边框)。全都比「只把别人变淡」更容易看出结构。 */
.game-lineage-node--path {
/* 高亮分三档,差异要肉眼可见:路径节点(强调边 + 淡强调环)、仅子树节点(强调虚线边)、
目标节点(2px 强调边 + 更粗的环)。容器已透明,因此这些表达都落在**卡片**上;
环叠在卡片自身的轻阴影之前,所以高亮时不会把阴影吃掉。 */
.game-lineage-node--path .game-card--lineage {
border-color: var(--platform-accent-strong);
background: rgba(199, 101, 61, 0.08);
box-shadow:
0 0 0 2px rgba(199, 101, 61, 0.28),
var(--lineage-card-shadow);
}
.game-lineage-node--subtree {
.game-lineage-node--subtree .game-card--lineage {
border-color: var(--platform-accent-strong);
border-style: dashed;
background: rgba(199, 101, 61, 0.04);
}
.game-lineage-node--pinned {
.game-lineage-node--pinned .game-card--lineage {
border-style: solid;
border-width: 2px;
box-shadow: 0 0 0 3px rgba(199, 101, 61, 0.22);
box-shadow:
0 0 0 3px rgba(199, 101, 61, 0.22),
var(--lineage-card-shadow);
}
.game-lineage-breadcrumb {
position: absolute;
@@ -1224,10 +1228,17 @@
color: var(--platform-accent-strong);
}
.game-lineage-node--stub {
border-style: dashed;
cursor: default;
}
/* 占位节点(父作品不可用)的虚线边同样落在**卡片**上(容器已透明)。 */
.game-lineage-node--stub .game-card--lineage {
border-style: dashed;
}
/* 焦点环也贴在**卡片**上(容器盒子比卡片大 12.8px,环画在容器上会离卡一圈空白)。 */
.game-lineage-node:focus-visible {
outline: none;
}
.game-lineage-node:focus-visible .game-card--lineage {
outline: 2px solid var(--platform-accent-strong);
outline-offset: 2px;
}
@@ -1358,12 +1369,10 @@
padding: 0.05rem 0.35rem;
}
/* 卡片主体 = 选中;标题/封面 = 打开详情。封面做主视觉(大图 + 圆角 + 轻阴影)。 */
/* 卡片主体 = 选中;标题/封面 = 打开详情。封面做主视觉(大图 + 圆角 + 轻阴影)。
容器不裁切(`visible`):卡片自身的阴影/高亮环不能被容器 `overflow: hidden` 切掉。 */
.game-lineage-node {
padding: 0;
overflow: hidden;
border-color: var(--lineage-branch, var(--platform-surface-border));
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
overflow: visible;
}
.game-lineage-node__open {
display: flex;
@@ -2275,16 +2284,27 @@
.game-lineage-node {
display: flex;
flex-direction: column;
gap: 0.35rem;
gap: 0;
min-width: 0;
border: 1px solid var(--platform-surface-border);
border-radius: 0.75rem;
padding: 0.7rem 0.8rem;
background: var(--platform-surface, #fff);
/*
* 容器**完全透明**:页面上只应看到卡片本身。
* 用户口径(截图里卡片后面那块浅色圆角板):「有个背景,应该只需要这张卡片,可以稍微做点阴影」
* —— 背景 / 边框 / 圆角 / 阴影全部搬到**卡片自身**(`.game-card--lineage`),
* 高亮也改成作用在卡片上(见下面的 `--path/--subtree/--pinned/--anchor` 那组)。
* 内边距只用于「卡宽 → 节点盒宽」的换算,与 `LINEAGE_NODE_PADDING_X/Y` 同源(页面下发变量)。
*/
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
padding: var(--lineage-node-pad-y, 11.2px) var(--lineage-node-pad-x, 12.8px);
}
.game-lineage-node--anchor {
/* 「当前作品」:强调描边 + 一圈淡强调环(环作用在**卡片**上,容器已透明)。 */
.game-lineage-node--anchor .game-card--lineage {
border-color: var(--platform-accent-strong);
box-shadow: 0 0 0 2px rgba(199, 101, 61, 0.18);
box-shadow:
0 0 0 2px rgba(199, 101, 61, 0.18),
var(--lineage-card-shadow);
}
.game-lineage-node__head {
display: flex;
@@ -2957,6 +2977,21 @@
min-height: 0;
border-radius: 0;
}
/*
* **阴影搬到这里**(用户口径:容器只应是一张卡,「可以稍微做点阴影」):
* 容器 `.game-lineage-node` 已经透明无阴影,卡片自己是唯一的视觉主体 —— 用两层轻阴影
* (贴地 1px + 弥散 12px),比原来容器上那块 0 6px 18px 更轻,且高亮环会叠在它之前。
*/
.game-card--lineage {
box-shadow: var(--lineage-card-shadow);
}
/*
* 族谱卡的**唯一阴影来源**:容器已透明,只需要卡片自己「稍微做点阴影」。
* 两层:1px 贴地 + 12px 弥散;高亮环(路径 / 锁定 / 当前作品)会叠在它之前,因此两者不互斥。
*/
.game-lineage-page {
--lineage-card-shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(0, 0, 0, 0.08);
}
.game-card--lineage .game-cover__image,
.game-card--lineage .game-cover img {
display: block;
@@ -3024,54 +3059,32 @@
padding: 0.05rem 0.3rem;
}
/*
* 图下方「父代 / 子代」清单:与画布卡**同一套**卡片,改成**响应式多列网格**
* (2026-10-07 用户口径:「一行一个太奢侈了,你整小一点,响应式布局」)。
* 图下方「父代 / 子代」清单:与画布节点卡**同一张卡、同一个卡宽**(2026-10-07 用户口径:
* 「父代子代应该都和血缘图里的用的是一个卡片,一起调宽一点」)。
*
* 为什么原来很奢侈:子代那段用的是 `.lineage-relation__list`(flex column + 54rem 上限),
* 一张卡就占满一行,16:9 封面被拉到 ~850×480,一屏只看得到一张半。现在两段共用同一个网格。
*
* 尺寸口径:
* - 容器限宽 `54rem`(与大纲列 / 族谱内容列同一档),**不再 full-bleed**;
* - 卡宽 = `minmax(10rem, 1fr)` ⇒ 实测 160–200px 区间(1440 视口 5 列、1024 视口 5 列、
* 390 窄屏 2 列);与画布卡同数量级、比它小一档(画布卡桌面 212 / 窄屏 148)。
* - 卡宽是**有上限的**(由列数决定),所以封面不会再被拉成整页宽。
* 卡宽的唯一来源是布局常量(`lineageTreeLayout.ts` 的 `LINEAGE_CARD_WIDTH` /
* `LINEAGE_COMPACT_CARD_WIDTH`),由 `GameLineagePage` 下发成 `--lineage-card-width`;
* 这里**只读它**,不另写一套数字 ⇒ 两处不可能再漂移。
* 窄屏(≤820px)用紧凑卡宽(与窄屏横版画布同一档),因此 390 宽下仍能排两列。
*/
.lineage-relations {
max-width: 54rem;
}
.lineage-relation__grid {
display: grid;
/* 最小轨道 9rem(144px):390 窄屏容器实测 316px,需要 2×144+12 = 300 ≤ 316 才能排成
**两列**(用户期望量级 ≥2 列;10rem 时 2×160+12 = 332 > 316 只能落成一列)。 */
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
grid-template-columns: repeat(
auto-fill,
var(--lineage-card-width, 14rem)
);
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
/* 清单卡 ~160px,比画布卡(212/148)窄一档:标题压成**单行省略**,信息区与角标留白收紧。
尺寸差异只由 wrapper 表达,共享卡与画布卡的口径一个字都不动。 */
.lineage-relation__item .game-card--lineage .game-card__title-row strong {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.lineage-relation__item .game-card--lineage .game-card__body {
padding: 0.5rem 0.7rem 0.7rem;
}
.lineage-relation__item .game-card--lineage .game-card__badges {
padding: 0.45rem 0.7rem 0;
}
.lineage-relation__item .game-card--lineage .game-card__badges > * {
padding: 0.05rem 0.3rem;
}
.lineage-relation__item .game-card--lineage .game-card__play-count,
.lineage-relation__item .game-card--lineage .game-card__meta {
font-size: 0.64rem;
}
.lineage-relation__item .game-card--lineage .game-card__meta {
margin-top: 0.2rem;
@media (max-width: 820px) {
.lineage-relations {
--lineage-card-width: var(--lineage-compact-card-width, 9.25rem);
}
}
.lineage-relation__item {
display: flex;
@@ -209,11 +209,11 @@ describe('buildLineageTreeLayout', () => {
const edge = layout.edges[0]!;
const root = layout.nodes.find((item) => item.gameId === 'root')!;
const child = layout.nodes.find((item) => item.gameId === 'child')!;
expect(edge.from).toEqual({
x: root.x + root.width / 2,
y: root.y + root.height,
});
expect(edge.to).toEqual({ x: child.x + child.width / 2, y: child.y });
// 端点在布局里按 2 位小数取整(SVG path 精度),所以逐字段允许 2 位小数的舍入误差。
expect(edge.from.x).toBeCloseTo(root.x + root.width / 2, 2);
expect(edge.from.y).toBeCloseTo(root.y + root.height, 2);
expect(edge.to.x).toBeCloseTo(child.x + child.width / 2, 2);
expect(edge.to.y).toBeCloseTo(child.y, 2);
// 单链:父与子同一条竖线 → 纯竖直段。
expect(edge.path.startsWith('M ')).toBe(true);
expect(edge.path).toContain(' V ');
@@ -241,14 +241,10 @@ describe('buildLineageTreeLayout', () => {
for (const item of branched.edges) {
const parent = byId.get(item.parentGameId)!;
const child = byId.get(item.childGameId)!;
expect(item.from).toEqual({
x: parent.x + parent.width / 2,
y: parent.y + parent.height,
});
expect(item.to).toEqual({
x: child.x + child.width / 2,
y: child.y,
});
expect(item.from.x).toBeCloseTo(parent.x + parent.width / 2, 2);
expect(item.from.y).toBeCloseTo(parent.y + parent.height, 2);
expect(item.to.x).toBeCloseTo(child.x + child.width / 2, 2);
expect(item.to.y).toBeCloseTo(child.y, 2);
// 路径首点 = 父锚点;路径末点写在最后一段 V/H 里(曲线控制点不是端点)。
expect(item.path.startsWith(`M ${item.from.x},${item.from.y}`)).toBe(
true,
@@ -34,10 +34,41 @@ import type { GameDistributionLineageNode } from '../../services/gameDistributio
*/
export const LINEAGE_COVER_ASPECT = 16 / 9;
export const LINEAGE_NODE_WIDTH = 240;
/**
* **卡片宽度的唯一来源**(2026-10-07 用户口径:「父代子代应该都和血缘图里的用的是一个卡片,
* 一起调宽一点」)——画布节点卡与「父代 / 子代」清单卡读**同一个值**:
* - JS 侧:本文件的布局常量(节点容器宽 = 卡宽 + 2×`LINEAGE_NODE_PADDING_X`);
* - CSS 侧:族谱页把这两个值写进自定义属性 `--lineage-card-width` / `--lineage-compact-card-width`
* (见 `GameLineagePage`),`.game-lineage-node` 与 `.lineage-relation__grid` 都只读它们,
* **不在 CSS 里另写一套卡宽**。
*
* 取值依据:旧的画布卡 212.4px 宽时标题每行只放得下 10 个全角字符,
* 清单卡更是 152–174px、常见标题(如「共创演示·A2|星海工坊·轨道集市」16 字)被单行截断;
* 加宽到 **224**(+11.6px)后每行约 11 字、2 行可完整容纳 16–22 字标题。
*/
export const LINEAGE_CARD_WIDTH = 224;
/** 窄屏图谱(横版紧凑画布)的卡宽:与清单卡在 ≤820px 断点用的是同一个值。 */
export const LINEAGE_COMPACT_CARD_WIDTH = 148;
/**
* 节点容器左右内边距(px)。
*
* 为什么写成 px 且与 CSS 共享:容器宽 = 卡宽 + 2×该值,而布局要按**容器宽**排 x / 算连线锚点,
* 渲染层要按**卡宽**撑卡片 —— 两边必须是同一次计算结果。旧的 0.8rem 在不同根字号下会漂移,
* 因此这里定 px,并由页面把 `--lineage-node-pad-x` 一起下发(CSS 只消费、不自己写数)。
*/
export const LINEAGE_NODE_PADDING_X = 12.8;
/** 节点容器上下内边距(px):与 `LINEAGE_NODE_PADDING_X` 一样由页面下发给 CSS。 */
export const LINEAGE_NODE_PADDING_Y = 11.2;
export const LINEAGE_NODE_WIDTH =
LINEAGE_CARD_WIDTH + LINEAGE_NODE_PADDING_X * 2;
/** 紧凑画布的容器宽(窄屏横版用)。 */
export const LINEAGE_COMPACT_NODE_WIDTH =
LINEAGE_COMPACT_CARD_WIDTH + LINEAGE_NODE_PADDING_X * 2;
/**
* 卡片高度的**缺省回退值**(调用方没传 `cardHeights` 时用)。
* 族谱页/血缘图真实渲染的是 240 宽的广场卡变体,走 `LINEAGE_CARD_BASE_HEIGHT`(含角标行与作者行)。
* 族谱页/血缘图真实渲染的是 `LINEAGE_CARD_WIDTH` 宽的广场卡变体,
* 走 `LINEAGE_CARD_BASE_HEIGHT`(含角标行与作者行)。
*/
export const LINEAGE_NODE_HEIGHT = 192;
/** 同一层相邻槽位之间额外留的横向间隙(保证不重叠)。 */
@@ -148,24 +179,32 @@ export type LineageLayoutOptions = {
*
* 常量由**真机实测校准**(2026-10-07 重测:角标槽从「压在封面上」改回**封面下方**的信息区第一行、
* 标题独占一行之后,卡片又高了 —— 老常量 214/20 已明显偏小,实测层间距被吃掉一半)。
* 实测(真机 Chromium、演示树 8 节点、桌面 1440×900、240 宽节点):
* 节点盒高 = 6:9 封面 118.3 + 角标行 26.9 + 信息区(1 行标题)88.6 + 节点内边距/边框 24.4 = **260.3**;
* 实测(真机 Chromium、演示树 8 节点、桌面 1440×900、卡宽 224px / 节点盒 249.6px):
* 节点盒高 = 16:9 封面 126 + 角标行 26.9 + 信息区(1 行标题)≈ 90 + 节点内边距 22.4 = **264.8**(真机实测);
* 标题每多一行 = **+24**(标题行盒 24px);
* 实测值与公式逐项相等(260.3 / 284.3),因此取 `base = 260` / `行高 = 24`。
* 真机实测(2026-10-07,1440×1000、卡宽 224 / 节点盒 249.6、画布缩放 0.8216 换算回内容坐标):
* 1 行标题节点盒 **264.8**、2 行 **288.8**(差 24 = 行高)⇒ 取 `base = 265` / `行高 = 24`(**估算 ≥ 实测**,只多 0.2px)。
*/
export const LINEAGE_CARD_BASE_HEIGHT = 260;
export const LINEAGE_CARD_BASE_HEIGHT = 265;
/**
* 窄屏紧凑卡片(`LINEAGE_COMPACT_CARD_WIDTH` 宽、横版画布)的**节点盒**基础高。
* 真机实测(2026-10-07,390×844 / 图谱视图、画布缩放 0.35 换算回内容坐标):
* 卡 148×223.7(1 行标题)⇒ 节点盒 173.6×**246.1**,取 **247**(估算 ≥ 实测);
* 标题每多一行 +24(同 `LINEAGE_CARD_TITLE_LINE_HEIGHT`)。
*/
export const LINEAGE_COMPACT_CARD_BASE_HEIGHT = 247;
/** 标题每多一行增加的高度(= 标题行盒高度,真机实测 24px)。 */
export const LINEAGE_CARD_TITLE_LINE_HEIGHT = 24;
/** 标题最多 2 行(CSS line-clamp 同值)。 */
export const LINEAGE_CARD_TITLE_MAX_LINES = 2;
/**
* 卡面一行大约能放下的「全角字符」数(估算用)。
* 广场卡在 212 宽画布卡里标题是 1rem 粗体、可用宽度 **180.4px** ⇒ 一行 ≈ **11.3** 个全角字符。
* 广场卡在 224 宽画布卡里标题是 1rem 粗体、可用宽度 **192px** ⇒ 一行 ≈ **12** 个全角字符。
* 真机实测:「共创演示·A0|星海工坊」(视觉宽度 10.65)**1 行**、
* 「共创演示·A1|星海工坊·深海回声」(15.65)**2 行** —— 取 11 与两条实测都对得上
*(取 10 会把前者高估成 2 行,白高 24px)。
*/
export const LINEAGE_CARD_TITLE_CHARS_PER_LINE = 11;
export const LINEAGE_CARD_TITLE_CHARS_PER_LINE = 12;
function titleVisualWidth(title: string) {
let width = 0;