feat(资源画布): dependency 默认铺排改为层内网格平面,不再退化成一列

- 新增 RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS = 6 与槽位宽高比常量,作为层内网格列数上限
- 新增 normalizedDependencyDepth:落位、层带与层内网格共用同一深度归一,避免同一深度被当成两层
- 新增 dependencyLayerColumnCount:列数 = clamp(floor(sqrt(卡数 × 槽位宽高比)), 1, 6),是卡片数量的纯函数
- 新增 dependencyLayerGrid:层内网格几何,行高统一取该层最高卡,使层高与层内顺序无关(中位数扫描重排后几何不抖)
- 用 dependencyColumnGeometryByDepth 取代 dependencyColumnXByDepth:x 仍只由 dependencyDepth 决定层带基址(depth 仍是唯一横向层级权威),层带宽度 = 列数 × 列间距;全部层为单列时与旧值逐值一致
- 层内按行优先落位,层内排序(稳定 ID 初序 + 两轮左至右/右至左中位数扫描)与深度层级语义不变;网格之外仍按既有向下找空位回避手动坐标与饱和层带
- 移除 dependencyBucketHeight,簇高、簇行数与层内 rank 改用层内网格行数
- 同步 resourceCanvasLayoutModel 测试:新增层内网格平面(12 张同深度 → 4 列 3 行)、重复计算与输入顺序稳定、层带随深度单调且不与换行层重叠、列数封顶 6、手动坐标在自动网格重派生前后逐项不变;同层 3 张卡的环连续性用例改为行优先网格口径
- 影响面:既有项目里已落盘的自动坐标(manuallyPlaced=false)会在下次协调时按新口径一次性重排成平面;manuallyPlaced=true 的历史手动坐标不删除、不迁移、不被自动排序覆盖
- 不新增持久化字段、sidecar schema 不升版,ProjectResourceCanvasSection/Position 契约与 RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE 均未改动
This commit is contained in:
2026-09-11 10:16:01 +08:00
parent dcb750c318
commit 921d86ed44
2 changed files with 362 additions and 80 deletions
@@ -29,6 +29,11 @@ export const RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP = 48;
export const RESOURCE_CANVAS_DEPENDENCY_ROW_GAP = 40;
export const RESOURCE_CANVAS_DRAG_THRESHOLD = 5;
export const RESOURCE_CANVAS_TYPE_COLUMNS = 3;
/**
* dependency 层内网格的列数上限。卡片是 180×128 的横向卡,6 列加层间距约 1368px,
* 与栏目页在 1x 下的一屏宽度同量级;再宽就会退化成需要横向滚动的超长横带。
*/
export const RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS = 6;
export const RESOURCE_CANVAS_SECTION_MIN_WIDTH = 620;
export const RESOURCE_CANVAS_SECTION_MIN_HEIGHT = 108;
export const RESOURCE_CANVAS_CLUSTER_GAP = 48;
@@ -283,6 +288,12 @@ const RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN = Math.floor(
);
const RESOURCE_CANVAS_MAX_DEPENDENCY_X =
RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN * RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH;
/**
* 层内网格按槽位宽高比确定列数:让整层的块面接近方形,避免铺成横带或竖带。
*/
const RESOURCE_CANVAS_DEPENDENCY_LAYER_SLOT_ASPECT =
RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH /
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT;
type PositionedResourceCanvasRect = {
position: ProjectResourceCanvasPosition;
@@ -823,28 +834,82 @@ function sortDependencyBucket(
);
}
function dependencyBucketHeight(
resourceIds: readonly string[],
cardSizeByResourceId: ResourceCanvasCardSizeByResourceId,
) {
return resourceIds.reduce(
(height, resourceId, index) =>
height +
resourceCanvasCardSize(resourceId, cardSizeByResourceId).height +
(index === 0 ? 0 : RESOURCE_CANVAS_DEPENDENCY_ROW_GAP),
0,
/**
* dependency 深度归一。落位、层带与层内网格共用同一口径,避免同一深度被当成两层。
*/
function normalizedDependencyDepth(depth: number) {
return Number.isFinite(depth) ? Math.max(0, Math.round(depth)) : 0;
}
/**
* 层内网格列数:卡片数量 → 列数,让整层块面接近方形(槽位宽高比 ≈ 1.36),并按上限封顶。
* 它是卡片数量的纯函数,所以同一份输入重复计算稳定,也不受资源顺序影响;
* 1 张卡只占 1 列,2 张卡仍是 1 列(`floor(sqrt(2 × 1.36)) = 1`),
* 只有真正摞成长条的层(≥3 张)才会横向展开成平面。
*/
function dependencyLayerColumnCount(cardCount: number) {
return Math.min(
RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS,
Math.max(
1,
Math.floor(
Math.sqrt(cardCount * RESOURCE_CANVAS_DEPENDENCY_LAYER_SLOT_ASPECT),
),
),
);
}
function dependencyColumnXByDepth(
type DependencyLayerGrid = {
columns: number;
rows: number;
rowHeight: number;
height: number;
};
/**
* 层内网格几何。行高统一取该层最高卡,使层高只由行列数量决定、与层内顺序无关:
* 中位数扫描会重排层内次序,几何不能跟着变,否则簇高、簇间留白与居中偏移都会抖。
*/
function dependencyLayerGrid(
resourceIds: readonly string[],
cardSizeByResourceId: ResourceCanvasCardSizeByResourceId,
): DependencyLayerGrid {
const columns = dependencyLayerColumnCount(resourceIds.length);
let rowHeight = 0;
for (const resourceId of resourceIds) {
rowHeight = Math.max(
rowHeight,
resourceCanvasCardSize(resourceId, cardSizeByResourceId).height,
);
}
const rows = Math.max(1, Math.ceil(resourceIds.length / columns));
return {
columns,
rows,
rowHeight,
height: rows * rowHeight + (rows - 1) * RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
};
}
type DependencyColumnGeometry = {
x: number;
pitch: number;
};
/**
* 每个深度的层带基址与层内列间距。`dependencyDepth` 仍是唯一横向层级权威:
* 层带基址只由深度决定,随深度单调递增;层内第几列由网格列数在层带内展开。
* 层带宽度 = 列数 × 列间距,层与层之间恒定留一个列间距。当每一层都是单列时,
* 层带宽度公式退化成旧值(`maxWidth + 列间距`),坐标与改动前逐值一致。
*/
function dependencyColumnGeometryByDepth(
resources: readonly ResourceCanvasItem[],
cardSizeByResourceId: ResourceCanvasCardSizeByResourceId,
columnCountByDepth: ReadonlyMap<number, number>,
) {
const widthByDepth = new Map<number, number>();
for (const resource of resources) {
const depth = Number.isFinite(resource.dependencyDepth)
? Math.max(0, Math.round(resource.dependencyDepth))
: 0;
const depth = normalizedDependencyDepth(resource.dependencyDepth);
widthByDepth.set(
depth,
Math.max(
@@ -853,28 +918,31 @@ function dependencyColumnXByDepth(
),
);
}
const xByDepth = new Map<number, number>();
let accumulatedWidthDelta = 0;
const geometryByDepth = new Map<number, DependencyColumnGeometry>();
let accumulatedBandWidthDelta = 0;
for (const depth of Array.from(widthByDepth.keys()).sort(
(left, right) => left - right,
)) {
xByDepth.set(
depth,
Math.min(
const pitch =
(widthByDepth.get(depth) ?? RESOURCE_CANVAS_CARD_WIDTH) +
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP;
const columns = Math.max(1, columnCountByDepth.get(depth) ?? 1);
geometryByDepth.set(depth, {
x: Math.min(
RESOURCE_CANVAS_MAX_DEPENDENCY_X,
Math.max(
0,
Math.min(depth, RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN) *
RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH +
accumulatedWidthDelta,
accumulatedBandWidthDelta,
),
),
);
accumulatedWidthDelta +=
(widthByDepth.get(depth) ?? RESOURCE_CANVAS_CARD_WIDTH) -
RESOURCE_CANVAS_CARD_WIDTH;
pitch,
});
accumulatedBandWidthDelta +=
columns * pitch - RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH;
}
return xByDepth;
return geometryByDepth;
}
function dependencyAutomaticPositions(
@@ -926,22 +994,17 @@ function dependencyAutomaticPositions(
bucket.push(resourceId);
resourceIdsByDepth.set(depth, bucket);
}
const grids = Array.from(resourceIdsByDepth.values(), (ids) =>
dependencyLayerGrid(ids, cardSizeByResourceId),
);
clusters.push({
resourceIds,
minimumDepth: component.minimumDepth,
minimumResourceId: component.minimumResourceId,
related: true,
baseY: 0,
rowCount: Math.max(
1,
...Array.from(resourceIdsByDepth.values(), (ids) => ids.length),
),
height: Math.max(
1,
...Array.from(resourceIdsByDepth.values(), (ids) =>
dependencyBucketHeight(ids, cardSizeByResourceId),
),
),
rowCount: Math.max(1, ...grids.map((grid) => grid.rows)),
height: Math.max(1, ...grids.map((grid) => grid.height)),
});
}
if (isolatedResourceIds.length > 0) {
@@ -952,22 +1015,17 @@ function dependencyAutomaticPositions(
bucket.push(resourceId);
resourceIdsByDepth.set(depth, bucket);
}
const grids = Array.from(resourceIdsByDepth.values(), (ids) =>
dependencyLayerGrid(ids, cardSizeByResourceId),
);
clusters.push({
resourceIds: isolatedResourceIds,
minimumDepth: Number.MAX_SAFE_INTEGER,
minimumResourceId: isolatedResourceIds[0] ?? '',
related: false,
baseY: 0,
rowCount: Math.max(
1,
...Array.from(resourceIdsByDepth.values(), (ids) => ids.length),
),
height: Math.max(
1,
...Array.from(resourceIdsByDepth.values(), (ids) =>
dependencyBucketHeight(ids, cardSizeByResourceId),
),
),
rowCount: Math.max(1, ...grids.map((grid) => grid.rows)),
height: Math.max(1, ...grids.map((grid) => grid.height)),
});
}
let nextClusterY = 0;
@@ -987,6 +1045,7 @@ function dependencyAutomaticPositions(
DependencyCluster,
Map<number, string[]>
>();
const gridByBucket = new Map<string[], DependencyLayerGrid>();
for (const cluster of clusters) {
const idsByDepth = new Map<number, string[]>();
for (const resourceId of cluster.resourceIds) {
@@ -1000,17 +1059,22 @@ function dependencyAutomaticPositions(
}
for (const [depth, resourceIds] of idsByDepth) {
resourceIds.sort(compareStableIdentifier);
const grid = dependencyLayerGrid(resourceIds, cardSizeByResourceId);
gridByBucket.set(resourceIds, grid);
// 层内 rank 用"网格行号",同一行的相邻卡共享纵向位置,跨层中位数对齐仍然成立。
const bucketBaseY =
cluster.baseY +
(cluster.related
? ((cluster.rowCount - resourceIds.length) *
? ((cluster.rowCount - grid.rows) *
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
2
: 0);
resourceIds.forEach((resourceId, index) =>
rankByResourceId.set(
resourceId,
(bucketBaseY + index * RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
(bucketBaseY +
Math.floor(index / grid.columns) *
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT,
),
);
@@ -1055,16 +1119,20 @@ function dependencyAutomaticPositions(
topologyForSection,
flowSignalByFlow,
);
const grid = gridByBucket.get(resourceIds)!;
const bucketBaseY =
cluster.baseY +
(cluster.related
? ((cluster.rowCount - grid.rows) *
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
2
: 0);
resourceIds.forEach((resourceId, index) =>
rankByResourceId.set(
resourceId,
(cluster.baseY +
(cluster.related
? ((cluster.rowCount - resourceIds.length) *
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
2
: 0) +
index * RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
(bucketBaseY +
Math.floor(index / grid.columns) *
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT,
),
);
@@ -1073,9 +1141,25 @@ function dependencyAutomaticPositions(
}
}
const dependencyColumnPositions = dependencyColumnXByDepth(
// 层带宽度要按该深度上最宽的一层预留,否则宽层会侵入下一个深度的层带。
const layerColumnCountByDepth = new Map<number, number>();
for (const cluster of clusters) {
const idsByDepth = orderedIdsByClusterAndDepth.get(cluster)!;
for (const [depth, resourceIds] of idsByDepth) {
const normalizedDepth = normalizedDependencyDepth(depth);
layerColumnCountByDepth.set(
normalizedDepth,
Math.max(
layerColumnCountByDepth.get(normalizedDepth) ?? 1,
gridByBucket.get(resourceIds)!.columns,
),
);
}
}
const columnGeometryByDepth = dependencyColumnGeometryByDepth(
sectionResources,
cardSizeByResourceId,
layerColumnCountByDepth,
);
const occupancy = new ResourceCanvasOccupancyIndex(
preservedPositions,
@@ -1089,27 +1173,31 @@ function dependencyAutomaticPositions(
for (const [depth, resourceIds] of Array.from(idsByDepth).sort(
([left], [right]) => left - right,
)) {
const normalizedDepth = Number.isFinite(depth)
? Math.max(0, Math.round(depth))
: 0;
const x =
dependencyColumnPositions.get(normalizedDepth) ??
Math.min(
const normalizedDepth = normalizedDependencyDepth(depth);
const grid = gridByBucket.get(resourceIds)!;
const columnGeometry = columnGeometryByDepth.get(normalizedDepth) ?? {
x: Math.min(
RESOURCE_CANVAS_MAX_DEPENDENCY_X,
Math.min(normalizedDepth, RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN) *
RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH,
);
const bucketHeight = dependencyBucketHeight(
resourceIds,
cardSizeByResourceId,
);
let nextY = Math.round(
),
pitch: RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH,
};
const baseY = Math.round(
cluster.baseY +
(cluster.related ? (cluster.height - bucketHeight) / 2 : 0),
(cluster.related ? (cluster.height - grid.height) / 2 : 0),
);
for (const resourceId of resourceIds) {
resourceIds.forEach((resourceId, index) => {
const size = resourceCanvasCardSize(resourceId, cardSizeByResourceId);
let y = nextY;
const x = Math.min(
RESOURCE_CANVAS_MAX_DEPENDENCY_X,
columnGeometry.x + (index % grid.columns) * columnGeometry.pitch,
);
let y =
baseY +
Math.floor(index / grid.columns) *
(grid.rowHeight + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP);
// 网格之外仍可能与手动坐标或饱和层带相撞,沿用既有向下找空位的回避。
while (occupancy.overlaps(x, y, resourceId)) {
y += size.height + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP;
}
@@ -1122,8 +1210,7 @@ function dependencyAutomaticPositions(
};
positions.push(position);
occupancy.add(position);
nextY = y + size.height + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP;
}
});
}
}
@@ -17,6 +17,7 @@ import {
RESOURCE_CANVAS_CARD_WIDTH,
RESOURCE_CANVAS_COLUMN_GAP,
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS,
RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
RESOURCE_CANVAS_SECTION_ORDER,
@@ -573,14 +574,20 @@ describe('resource canvas layout model', () => {
]),
).layout;
const cycleRows = ['art:cycle-a', 'art:cycle-b']
.map((resourceId) => positionById(layout, resourceId).y)
.sort((left, right) => left - right);
const siblingRow = positionById(layout, 'art:sibling').y;
expect(cycleRows[1] - cycleRows[0]).toBe(
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
);
expect(siblingRow).toBeGreaterThan(cycleRows[1]);
const cycleA = positionById(layout, 'art:cycle-a');
const cycleB = positionById(layout, 'art:cycle-b');
const sibling = positionById(layout, 'art:sibling');
const dependencySlotWidth =
RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP;
const dependencySlotHeight =
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP;
// 同层 3 张卡按层内网格展开成 2 列,环成员仍占相邻两格(同排相邻列),
// 另一张共享该层的相关卡落在它们之后的下一行。
expect(cycleB.y).toBe(cycleA.y);
expect(cycleB.x - cycleA.x).toBe(dependencySlotWidth);
expect(sibling.x).toBe(cycleA.x);
expect(sibling.y).toBe(cycleA.y + dependencySlotHeight);
});
it('is deterministic, does not modify type placement, and avoids historical manual positions', () => {
@@ -968,3 +975,191 @@ describe('resource canvas variable card geometry', () => {
);
});
});
describe('resource canvas dependency layer grid', () => {
const dependencySlotWidth =
RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP;
const dependencySlotHeight =
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP;
function gridResources(count: number, dependencyDepth = 0) {
return Array.from({ length: count }, (_, index) =>
resource(
`art:grid-${index.toString().padStart(2, '0')}`,
'scene',
dependencyDepth,
),
);
}
function expectNoOverlappingCards(
layout: ReturnType<typeof reconcileResourceCanvasLayout>['layout'],
) {
const rects = layout.positions.map((position) => ({
position,
size: DEFAULT_RESOURCE_CANVAS_CARD_SIZE,
}));
rects.forEach((left, index) => {
rects.slice(index + 1).forEach((right) => {
expect(
left.position.x <
right.position.x +
right.size.width +
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP &&
left.position.x +
left.size.width +
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP >
right.position.x &&
left.position.y <
right.position.y +
right.size.height +
RESOURCE_CANVAS_DEPENDENCY_ROW_GAP &&
left.position.y +
left.size.height +
RESOURCE_CANVAS_DEPENDENCY_ROW_GAP >
right.position.y,
).toBe(false);
});
});
}
it('lays a single-depth section out as a grid plane instead of one column', () => {
const resources = gridResources(12);
const layout = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-layer-grid', 'dependency'),
resources,
).layout;
// 12 张同深度卡:4 列 × 3 行,横向展开成平面,而不是 12 张摞成一列。
expect(
Array.from(new Set(layout.positions.map((position) => position.x))),
).toEqual([
0,
dependencySlotWidth,
2 * dependencySlotWidth,
3 * dependencySlotWidth,
]);
expect(
Array.from(new Set(layout.positions.map((position) => position.y))),
).toEqual([0, dependencySlotHeight, 2 * dependencySlotHeight]);
expect(layout.positions).toHaveLength(12);
expectNoOverlappingCards(layout);
});
it('is stable for repeated runs and independent of the resource input order', () => {
const resources = gridResources(9);
const layout = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout(
'project-layer-grid-stable',
'dependency',
),
resources,
).layout;
const repeated = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout(
'project-layer-grid-stable',
'dependency',
),
resources,
).layout;
const reversed = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout(
'project-layer-grid-stable',
'dependency',
),
[...resources].reverse(),
).layout;
expect(repeated.positions).toEqual(layout.positions);
expect(reversed.positions).toEqual(layout.positions);
});
it('keeps depth bands monotone and clear of the wrapped layer', () => {
const shallow = gridResources(9);
const deep = resource('art:deep', 'scene', 1);
const layout = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-layer-band', 'dependency'),
[...shallow, deep],
).layout;
const shallowPositions = shallow.map((item) =>
positionById(layout, item.id),
);
const deepPosition = positionById(layout, deep.id);
// 9 张同深度铺成 3 列,层带按 3 列预留;深度 1 的层带整体让开,仍随深度向右推进。
expect(
Array.from(new Set(shallowPositions.map((position) => position.x))).sort(
(left, right) => left - right,
),
).toEqual([0, dependencySlotWidth, 2 * dependencySlotWidth]);
expect(deepPosition.x).toBe(3 * dependencySlotWidth);
expect(
Math.max(...shallowPositions.map((position) => position.x)) +
RESOURCE_CANVAS_CARD_WIDTH +
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
).toBeLessThanOrEqual(deepPosition.x);
});
it('caps the wrapped columns so a large layer stays readable', () => {
const resources = gridResources(100);
const layout = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-layer-cap', 'dependency'),
resources,
).layout;
const xValues = Array.from(
new Set(layout.positions.map((position) => position.x)),
);
// 100 张同深度卡铺成封顶的 6 列,而不是一条 100 张的超高竖带或超长横带。
expect(xValues).toHaveLength(RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS);
expect(xValues).toHaveLength(6);
expect(Math.max(...xValues)).toBe(
(RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS - 1) * dependencySlotWidth,
);
expectNoOverlappingCards(layout);
});
it('keeps manual coordinates identical while the automatic grid re-derives', () => {
const resources = gridResources(9);
const source = createEmptyResourceCanvasLayout(
'project-layer-grid-manual',
'dependency',
);
source.positions = [
{
resourceId: 'art:grid-00',
section: 'scene',
x: 900,
y: 640,
manuallyPlaced: true,
},
];
const layout = reconcileResourceCanvasLayout(source, resources).layout;
// 重派生路径只保留手动坐标后重算自动坐标,手动坐标必须逐项不变。
const redriven = reconcileResourceCanvasLayout(
{
...layout,
positions: layout.positions.filter(
(position) => position.manuallyPlaced,
),
},
resources,
).layout;
expect(positionById(layout, 'art:grid-00')).toMatchObject({
x: 900,
y: 640,
manuallyPlaced: true,
});
expect(positionById(redriven, 'art:grid-00')).toMatchObject({
x: 900,
y: 640,
manuallyPlaced: true,
});
expectNoOverlappingCards(layout);
expectNoOverlappingCards(redriven);
expect(
redriven.positions.filter((position) => !position.manuallyPlaced),
).toEqual(layout.positions.filter((position) => !position.manuallyPlaced));
});
});