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:
+159
-72
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user