族谱移动端图谱视图:横版画布 + 进入时按宽度适配并定位当前作品
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
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
This commit is contained in:
@@ -3096,6 +3096,146 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
|
||||
expect(await screen.findByLabelText('创作族谱大纲')).toBeTruthy();
|
||||
});
|
||||
|
||||
/** 读画布视口状态:内容层的 `transform: translate(...) scale(...)`。 */
|
||||
function readCanvasViewport(canvas: HTMLElement) {
|
||||
const content = canvas.querySelector(
|
||||
'.game-lineage-canvas__content',
|
||||
) as HTMLElement;
|
||||
const match =
|
||||
/translate\(([-\d.]+)px, ([-\d.]+)px\) scale\(([\d.]+)\)/u.exec(
|
||||
content.style.transform,
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(`无法解析画布 transform: ${content.style.transform}`);
|
||||
}
|
||||
return {
|
||||
offsetX: Number.parseFloat(match[1]!),
|
||||
offsetY: Number.parseFloat(match[2]!),
|
||||
scale: Number.parseFloat(match[3]!),
|
||||
};
|
||||
}
|
||||
|
||||
/** 视口内可见的节点卡片:把内容层的 transform 应用到卡片矩形,再与画布矩形求交。 */
|
||||
function visibleCanvasCards(
|
||||
canvas: HTMLElement,
|
||||
view: { offsetX: number; offsetY: number; scale: number },
|
||||
) {
|
||||
return [...canvas.querySelectorAll('.game-lineage-node')].filter((node) => {
|
||||
const card = node as HTMLElement;
|
||||
const left =
|
||||
view.offsetX + Number.parseFloat(card.style.left) * view.scale;
|
||||
const top = view.offsetY + Number.parseFloat(card.style.top) * view.scale;
|
||||
const width = Number.parseFloat(card.style.width) * view.scale;
|
||||
const height = Number.parseFloat(card.style.minHeight) * view.scale;
|
||||
return (
|
||||
left + width > 0 &&
|
||||
top + height > 0 &&
|
||||
left < canvas.clientWidth &&
|
||||
top < canvas.clientHeight
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it('图谱视图初次进入即适配并定位当前作品(不是 zoom 100% 的空屏)', async () => {
|
||||
setViewportWidth(390);
|
||||
getGameLineageMock.mockResolvedValueOnce(tree);
|
||||
// jsdom 不做布局:给画布一个「真机尺寸」,适配与定位才有可断言的数值。
|
||||
const widthSpy = vi
|
||||
.spyOn(Element.prototype, 'clientWidth', 'get')
|
||||
.mockReturnValue(390);
|
||||
const heightSpy = vi
|
||||
.spyOn(Element.prototype, 'clientHeight', 'get')
|
||||
.mockReturnValue(667);
|
||||
try {
|
||||
render(
|
||||
<GameLineagePage
|
||||
rootGameId="game-root"
|
||||
onBack={vi.fn()}
|
||||
onOpenDetail={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByLabelText('创作族谱大纲');
|
||||
fireEvent.click(screen.getByRole('button', { name: '图谱视图' }));
|
||||
|
||||
const canvas = (await screen.findByLabelText(
|
||||
'创作族谱树',
|
||||
)) as HTMLElement;
|
||||
const view = readCanvasViewport(canvas);
|
||||
// 窄屏图谱视图是**横版**:代际沿 x 推进,且不再渲染左侧代际轴(角标承担代际信息)。
|
||||
expect(canvas.querySelector('.game-lineage-canvas__axis')).toBeNull();
|
||||
expect(
|
||||
new Set(
|
||||
[...canvas.querySelectorAll('.game-lineage-node')].map((card) =>
|
||||
Number.parseFloat((card as HTMLElement).style.left),
|
||||
),
|
||||
).size,
|
||||
).toBeGreaterThan(1);
|
||||
expect(Math.round(view.scale * 100)).not.toBe(100);
|
||||
expect(canvas.querySelector('.game-lineage-zoom')?.textContent).toBe(
|
||||
`${Math.round(view.scale * 100)}%`,
|
||||
);
|
||||
|
||||
const visible = visibleCanvasCards(canvas, view);
|
||||
// 「一个节点都看不到」是最初的回归:这里至少要看到 3 个,且当前作品在视口内。
|
||||
expect(visible.length).toBeGreaterThanOrEqual(3);
|
||||
expect(visible).toContain(
|
||||
canvas.querySelector('.game-lineage-node--anchor'),
|
||||
);
|
||||
expect(
|
||||
visible.some((card) => card.getAttribute('data-generation') === '0'),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
widthSpy.mockRestore();
|
||||
heightSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('图谱视图缩放触底时把当前作品滚进视口(保上下文,不只剩空屏)', async () => {
|
||||
setViewportWidth(320);
|
||||
getGameLineageMock.mockResolvedValueOnce(tree);
|
||||
const widthSpy = vi
|
||||
.spyOn(Element.prototype, 'clientWidth', 'get')
|
||||
.mockReturnValue(200);
|
||||
const heightSpy = vi
|
||||
.spyOn(Element.prototype, 'clientHeight', 'get')
|
||||
.mockReturnValue(420);
|
||||
try {
|
||||
render(
|
||||
<GameLineagePage
|
||||
rootGameId="game-root"
|
||||
// 锚点是**最右代**:树比视口宽、缩放已触底,必须靠位移把它拉进来。
|
||||
highlightGameId="deep"
|
||||
onBack={vi.fn()}
|
||||
onOpenDetail={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByLabelText('创作族谱大纲');
|
||||
fireEvent.click(screen.getByRole('button', { name: '图谱视图' }));
|
||||
|
||||
const canvas = (await screen.findByLabelText(
|
||||
'创作族谱树',
|
||||
)) as HTMLElement;
|
||||
const view = readCanvasViewport(canvas);
|
||||
expect(view.scale).toBeGreaterThan(0);
|
||||
expect(Math.round(view.scale * 100)).not.toBe(100);
|
||||
|
||||
const visible = visibleCanvasCards(canvas, view);
|
||||
expect(visible).toContain(
|
||||
canvas.querySelector('.game-lineage-node--anchor'),
|
||||
);
|
||||
// 当前作品进来了,但不是「只剩它」:父代仍在视口里,用户看得到上下文。
|
||||
expect(visible.length).toBeGreaterThanOrEqual(2);
|
||||
expect(
|
||||
visible.some((card) => card.getAttribute('data-generation') === '1'),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
widthSpy.mockRestore();
|
||||
heightSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('窄屏仍能点进详情页与查看完整族谱入口(大纲模式不丢入口)', async () => {
|
||||
setViewportWidth(375);
|
||||
getGameLineageMock.mockResolvedValueOnce(tree);
|
||||
|
||||
@@ -325,6 +325,8 @@ export function GameLineagePage({
|
||||
);
|
||||
// 窄屏用紧凑布局(更小的卡片与间距 + 更大的相对字号),避免「桌面画布缩小 + 字太小」。
|
||||
const compactTree = isMobileViewport;
|
||||
/** 窄屏图谱视图用横版画布:代际信息交给节点角标,因此不渲染左侧代际轴层。 */
|
||||
const horizontalCanvas = compactTree;
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
buildLineageTreeLayout(
|
||||
@@ -337,6 +339,9 @@ export function GameLineagePage({
|
||||
columnGap: 22,
|
||||
rowGap: 88,
|
||||
axisWidth: 72,
|
||||
// 窄屏图谱视图用横版(根在左、代际向右、兄弟沿纵向排开):窄屏上比顶→下更省横向空间,
|
||||
// 纵向溢出交给滑动。代际信息由节点自带角标承担,所以横版不渲染代际轴(见下面的轴层)。
|
||||
orientation: 'horizontal',
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
@@ -441,6 +446,68 @@ export function GameLineagePage({
|
||||
fitToView();
|
||||
}, [fitToView, layout.nodes.length, layout.width, normalizedRootId]);
|
||||
|
||||
/**
|
||||
* 窄屏「图谱视图」的初次定位:按**宽度**适配(整棵树横向看完),再把当前作品拉进视口。
|
||||
*
|
||||
* 为什么单独一条:窄屏默认是大纲形态,画布**根本没挂载**(`viewportRef.current` 为 null),
|
||||
* 上面那次自动适配其实什么都没做;用户切到图谱视图时画布是新挂载的,会停在
|
||||
* `scale 1 / offset 0`——实测一个节点都看不到。桌面仍然是「宽高同时满足 + 居中」,
|
||||
* 不受这里影响。
|
||||
*/
|
||||
const focusMobileCanvasOnAnchor = useCallback(() => {
|
||||
const viewportElement = viewportRef.current;
|
||||
if (!viewportElement || layout.nodes.length === 0) return;
|
||||
const width = viewportElement.clientWidth;
|
||||
const height = viewportElement.clientHeight;
|
||||
const availableWidth = Math.max(1, width - FIT_PADDING * 2);
|
||||
const fitWidth = availableWidth / Math.max(layout.width, 1);
|
||||
const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, fitWidth));
|
||||
setFitHint(
|
||||
scale > fitWidth + 0.001
|
||||
? '树较宽,已按最小缩放显示:拖拽平移查看。'
|
||||
: '',
|
||||
);
|
||||
let offsetX = Math.max(FIT_PADDING, (width - layout.width * scale) / 2);
|
||||
let offsetY = FIT_PADDING;
|
||||
const anchorNode = layout.nodes.find((item) => item.gameId === anchorId);
|
||||
if (anchorNode && width > 0 && height > 0) {
|
||||
const left = offsetX + anchorNode.x * scale;
|
||||
const right = left + anchorNode.width * scale;
|
||||
const top = offsetY + anchorNode.y * scale;
|
||||
const bottom = top + anchorNode.height * scale;
|
||||
// 只做**最小位移**把当前作品拉进视口(贴到最近的边),不做粗暴对中——
|
||||
// 这样父代 / 兄弟仍在视口里,用户一眼能看到上下文。
|
||||
if (left < FIT_PADDING) offsetX += FIT_PADDING - left;
|
||||
else if (right > width - FIT_PADDING) {
|
||||
offsetX -= right - (width - FIT_PADDING);
|
||||
}
|
||||
if (top < FIT_PADDING) offsetY += FIT_PADDING - top;
|
||||
else if (bottom > height - FIT_PADDING) {
|
||||
offsetY -= bottom - (height - FIT_PADDING);
|
||||
}
|
||||
}
|
||||
setViewport({ scale, offsetX, offsetY });
|
||||
}, [anchorId, layout.nodes, layout.width]);
|
||||
|
||||
// 每次真正「进入图谱视图」(或换了根作品 / 锚点)定位一次;筛选、缩放、平移不抢视野。
|
||||
const mobileFocusedForRef = useRef('');
|
||||
useEffect(() => {
|
||||
if (effectiveViewMode !== 'canvas' || !isMobileViewport) return;
|
||||
if (layout.nodes.length === 0) return;
|
||||
const key = `${normalizedRootId}:${layout.nodes.length}:${layout.width}:${anchorId}`;
|
||||
if (mobileFocusedForRef.current === key) return;
|
||||
mobileFocusedForRef.current = key;
|
||||
focusMobileCanvasOnAnchor();
|
||||
}, [
|
||||
anchorId,
|
||||
effectiveViewMode,
|
||||
focusMobileCanvasOnAnchor,
|
||||
isMobileViewport,
|
||||
layout.nodes.length,
|
||||
layout.width,
|
||||
normalizedRootId,
|
||||
]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (forkTimerRef.current !== null) {
|
||||
@@ -753,40 +820,40 @@ export function GameLineagePage({
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{pinnedChain.length > 0 ? (
|
||||
<div
|
||||
className="game-lineage-breadcrumb"
|
||||
aria-label="已选中的路径"
|
||||
>
|
||||
{pinnedChain.map((crumb, index) => (
|
||||
<span
|
||||
key={crumb.gameId}
|
||||
className="game-lineage-breadcrumb__item"
|
||||
>
|
||||
{index > 0 ? <span aria-hidden="true">→</span> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="game-lineage-breadcrumb__crumb"
|
||||
ref={
|
||||
crumb.gameId === pinnedGameId
|
||||
? currentCrumbRef
|
||||
: undefined
|
||||
}
|
||||
onClick={() => setPinnedGameId(crumb.gameId)}
|
||||
>
|
||||
{`${generationAxisLabel(crumb.generation)}《${crumb.label}》`}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="game-lineage-breadcrumb__clear"
|
||||
onClick={() => setPinnedGameId('')}
|
||||
{pinnedChain.length > 0 ? (
|
||||
<div
|
||||
className="game-lineage-breadcrumb"
|
||||
aria-label="已选中的路径"
|
||||
>
|
||||
取消锁定
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{pinnedChain.map((crumb, index) => (
|
||||
<span
|
||||
key={crumb.gameId}
|
||||
className="game-lineage-breadcrumb__item"
|
||||
>
|
||||
{index > 0 ? <span aria-hidden="true">→</span> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="game-lineage-breadcrumb__crumb"
|
||||
ref={
|
||||
crumb.gameId === pinnedGameId
|
||||
? currentCrumbRef
|
||||
: undefined
|
||||
}
|
||||
onClick={() => setPinnedGameId(crumb.gameId)}
|
||||
>
|
||||
{`${generationAxisLabel(crumb.generation)}《${crumb.label}》`}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="game-lineage-breadcrumb__clear"
|
||||
onClick={() => setPinnedGameId('')}
|
||||
>
|
||||
取消锁定
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{effectiveViewMode === 'outline' ? (
|
||||
<LineageOutlineList
|
||||
nodes={layout.nodes}
|
||||
@@ -869,29 +936,35 @@ export function GameLineagePage({
|
||||
{/*
|
||||
代际轴是**固定层**:在 transform 之外,位置按视口换算,字号不随缩放变小,
|
||||
因此放大或平移后轴标签始终在画布左侧可读,而不会被内容层带出去裁掉。
|
||||
横版(窄屏图谱视图)不渲染它:代际沿横向推进,节点自带的角标已承担代际信息。
|
||||
*/}
|
||||
<div className="game-lineage-canvas__axis" aria-hidden="true">
|
||||
{layout.axis.map((entry, index) => (
|
||||
<span
|
||||
key={entry.generation}
|
||||
className="game-lineage-axis"
|
||||
style={{
|
||||
top: viewport.offsetY + entry.y * viewport.scale,
|
||||
// 行高跟着缩放走:轴上的行与画布里的行始终对齐。
|
||||
height:
|
||||
(layout.nodes[0]?.height ?? 0) * viewport.scale,
|
||||
borderTopWidth: index === 0 ? 0 : 1,
|
||||
}}
|
||||
>
|
||||
<span className="game-lineage-axis__label">
|
||||
{entry.label}
|
||||
{horizontalCanvas ? null : (
|
||||
<div
|
||||
className="game-lineage-canvas__axis"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{layout.axis.map((entry, index) => (
|
||||
<span
|
||||
key={entry.generation}
|
||||
className="game-lineage-axis"
|
||||
style={{
|
||||
top: viewport.offsetY + entry.y * viewport.scale,
|
||||
// 行高跟着缩放走:轴上的行与画布里的行始终对齐。
|
||||
height:
|
||||
(layout.nodes[0]?.height ?? 0) * viewport.scale,
|
||||
borderTopWidth: index === 0 ? 0 : 1,
|
||||
}}
|
||||
>
|
||||
<span className="game-lineage-axis__label">
|
||||
{entry.label}
|
||||
</span>
|
||||
<span className="game-lineage-axis__count">
|
||||
{entry.count}
|
||||
</span>
|
||||
</span>
|
||||
<span className="game-lineage-axis__count">
|
||||
{entry.count}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
画布内悬浮控件:放在**右下角**——左侧是代际轴、顶部是路径面包屑,
|
||||
右下角是地图/画布类应用的习惯位置,也最不容易遮住根节点与主干。
|
||||
@@ -960,15 +1033,15 @@ export function GameLineagePage({
|
||||
「核心改动说明」是后端待补字段,缺失时整行不渲染。
|
||||
*/}
|
||||
{selectedLayoutNode ? (
|
||||
<LineageInfoPanel
|
||||
node={selectedLayoutNode}
|
||||
game={selectedGame}
|
||||
isSheet={false}
|
||||
onClose={() => setPinnedGameId('')}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onOpenFork={launchForkFromNode}
|
||||
/>
|
||||
) : null}
|
||||
<LineageInfoPanel
|
||||
node={selectedLayoutNode}
|
||||
game={selectedGame}
|
||||
isSheet={false}
|
||||
onClose={() => setPinnedGameId('')}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onOpenFork={launchForkFromNode}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{/* 大纲模式下的 bottom sheet(桌面形态的面板在画布内,见上一分支)。 */}
|
||||
|
||||
Reference in New Issue
Block a user