Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts
T
suzmii 921d86ed44 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 均未改动
2026-09-11 10:16:01 +08:00

1482 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
type CanvasViewport,
MAX_SCALE,
MIN_SCALE,
} from '@genarrative/image-canvas-core';
import {
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
PROJECT_RESOURCE_CANVAS_SECTIONS,
type ProjectResourceCanvasCategory,
type ProjectResourceCanvasLayout,
type ProjectResourceCanvasLayoutMode,
type ProjectResourceCanvasPosition,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { normalizeResourceCanvasPosition } from './resourceCanvasSectionMapping';
export const RESOURCE_CANVAS_CARD_WIDTH = 180;
export const RESOURCE_CANVAS_CARD_HEIGHT = 128;
export const RESOURCE_CANVAS_IMAGE_CARD_MAX_WIDTH = 220;
export const RESOURCE_CANVAS_IMAGE_CARD_MAX_HEIGHT = 180;
export const RESOURCE_CANVAS_IMAGE_CARD_MIN_SHORT_EDGE = 96;
export const RESOURCE_CANVAS_IMAGE_CARD_MIN_ASPECT_RATIO = 1 / 2;
export const RESOURCE_CANVAS_IMAGE_CARD_MAX_ASPECT_RATIO = 2;
export const RESOURCE_CANVAS_COLUMN_GAP = 16;
export const RESOURCE_CANVAS_ROW_GAP = 16;
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;
/**
* 总览是导航面,不是图片查看器。初次适配不要把卡片位图放大到明显插值
* 失真的范围;用户主动缩放仍可使用 MAX_SCALE。
*/
export const RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE = 1.5;
/**
* Initial fit inset only. The resource canvas background itself is infinite;
* this value must not be used to clamp a later pan or zoom.
*/
export const RESOURCE_CANVAS_FIT_PADDING = 16;
/**
* 栏目顺序 = 6 类资源资产分类 + 末尾独立的「项目版本」栏目。项目版本不是资源资产,
* 6 类资产分类轴对它不适用,因此固定单独成栏。
*/
export const RESOURCE_CANVAS_SECTION_ORDER: readonly ProjectResourceCanvasCategory[] =
PROJECT_RESOURCE_CANVAS_SECTIONS;
export type ResourceCanvasCardSize = {
width: number;
height: number;
};
export type ResourceCanvasImageDimensions = {
pixelWidth: number;
pixelHeight: number;
};
export type ResourceCanvasNavigationBounds = {
x: number;
y: number;
width: number;
height: number;
};
function clampResourceCanvasNumber(
value: number,
minimum: number,
maximum: number,
) {
return Math.min(maximum, Math.max(minimum, value));
}
/**
* Normalizes an infinite resource-canvas viewport.
*
* The canvas has no navigational edge: x/y are intentionally left untouched
* so users can pan the background and resources arbitrarily far in any
* direction. Only invalid coordinates and the shared image-canvas scale
* limits are normalized here.
*/
export function normalizeInfiniteResourceCanvasViewport(
viewport: CanvasViewport,
): CanvasViewport {
return {
x: Number.isFinite(viewport.x) ? viewport.x : 0,
y: Number.isFinite(viewport.y) ? viewport.y : 0,
scale: clampResourceCanvasNumber(
Number.isFinite(viewport.scale) ? viewport.scale : 1,
MIN_SCALE,
MAX_SCALE,
),
};
}
export type ResourceCanvasCardSizeByResourceId = ReadonlyMap<
string,
ResourceCanvasCardSize
>;
export type ResourceCanvasItem = {
id: string;
category: ProjectResourceCanvasCategory;
subtype: string;
label: string;
mediaType: string;
dependencyDepth: number;
cardSize?: ResourceCanvasCardSize;
};
export const DEFAULT_RESOURCE_CANVAS_CARD_SIZE: ResourceCanvasCardSize = {
width: RESOURCE_CANVAS_CARD_WIDTH,
height: RESOURCE_CANVAS_CARD_HEIGHT,
};
function validResourceCanvasCardSize(
size: ResourceCanvasCardSize | undefined,
): size is ResourceCanvasCardSize {
return Boolean(
size &&
Number.isFinite(size.width) &&
Number.isFinite(size.height) &&
size.width > 0 &&
size.height > 0,
);
}
export function resourceCanvasImageCardSize(
dimensions: ResourceCanvasImageDimensions,
): ResourceCanvasCardSize {
if (
!Number.isSafeInteger(dimensions.pixelWidth) ||
!Number.isSafeInteger(dimensions.pixelHeight) ||
dimensions.pixelWidth <= 0 ||
dimensions.pixelHeight <= 0
) {
return DEFAULT_RESOURCE_CANVAS_CARD_SIZE;
}
const aspectRatio = Math.min(
RESOURCE_CANVAS_IMAGE_CARD_MAX_ASPECT_RATIO,
Math.max(
RESOURCE_CANVAS_IMAGE_CARD_MIN_ASPECT_RATIO,
dimensions.pixelWidth / dimensions.pixelHeight,
),
);
const maximumBoxAspectRatio =
RESOURCE_CANVAS_IMAGE_CARD_MAX_WIDTH /
RESOURCE_CANVAS_IMAGE_CARD_MAX_HEIGHT;
const width =
aspectRatio >= maximumBoxAspectRatio
? RESOURCE_CANVAS_IMAGE_CARD_MAX_WIDTH
: Math.max(
RESOURCE_CANVAS_IMAGE_CARD_MIN_SHORT_EDGE,
RESOURCE_CANVAS_IMAGE_CARD_MAX_HEIGHT * aspectRatio,
);
const height =
aspectRatio >= maximumBoxAspectRatio
? Math.max(
RESOURCE_CANVAS_IMAGE_CARD_MIN_SHORT_EDGE,
RESOURCE_CANVAS_IMAGE_CARD_MAX_WIDTH / aspectRatio,
)
: RESOURCE_CANVAS_IMAGE_CARD_MAX_HEIGHT;
return {
width: Math.min(RESOURCE_CANVAS_IMAGE_CARD_MAX_WIDTH, Math.round(width)),
height: Math.min(RESOURCE_CANVAS_IMAGE_CARD_MAX_HEIGHT, Math.round(height)),
};
}
export function createResourceCanvasCardSizeByResourceId(
resources: readonly Pick<ResourceCanvasItem, 'id' | 'mediaType'>[],
imageDimensionsByResourceId: ReadonlyMap<
string,
ResourceCanvasImageDimensions
>,
): Map<string, ResourceCanvasCardSize> {
return new Map(
resources.map((resource) => {
const dimensions = imageDimensionsByResourceId.get(resource.id);
return [
resource.id,
dimensions && resource.mediaType.toLowerCase().startsWith('image/')
? resourceCanvasImageCardSize(dimensions)
: DEFAULT_RESOURCE_CANVAS_CARD_SIZE,
];
}),
);
}
export function resourceCanvasCardSize(
resourceId: string,
cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId,
): ResourceCanvasCardSize {
const size = cardSizeByResourceId?.get(resourceId);
return validResourceCanvasCardSize(size)
? size
: DEFAULT_RESOURCE_CANVAS_CARD_SIZE;
}
function resourceCanvasItemCardSizes(
resources: readonly ResourceCanvasItem[],
explicitSizes?: ResourceCanvasCardSizeByResourceId,
): ResourceCanvasCardSizeByResourceId {
const sizes = new Map<string, ResourceCanvasCardSize>();
for (const resource of resources) {
const size = explicitSizes?.get(resource.id) ?? resource.cardSize;
if (validResourceCanvasCardSize(size)) {
sizes.set(resource.id, size);
}
}
return sizes;
}
/**
* This is deliberately a display-only projection of the Rust resource graph.
* It never becomes a second dependency source: `dependencyDepth` remains on
* ResourceCanvasItem and is still the sole authority for the horizontal layer.
*/
export type ResourceCanvasLayoutTopology = {
referenceEdges: readonly {
sourceResourceId: string;
targetResourceId: string;
}[];
taskFlows: readonly {
sourceResourceIds: readonly string[];
targetResourceIds: readonly string[];
}[];
};
export type ReconciledResourceCanvasLayout = {
layout: ProjectResourceCanvasLayout;
changed: boolean;
};
export function createEmptyResourceCanvasLayout(
projectId: string,
mode: ProjectResourceCanvasLayoutMode,
): ProjectResourceCanvasLayout {
return {
schemaVersion: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
projectId,
mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
function positionsEqual(
left: ProjectResourceCanvasPosition[],
right: ProjectResourceCanvasPosition[],
) {
return (
left.length === right.length &&
left.every((position, index) => {
const other = right[index];
return (
other !== undefined &&
position.resourceId === other.resourceId &&
position.section === other.section &&
position.x === other.x &&
position.y === other.y &&
position.manuallyPlaced === other.manuallyPlaced
);
})
);
}
const RESOURCE_CANVAS_SLOT_WIDTH =
RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP;
const RESOURCE_CANVAS_SLOT_HEIGHT =
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP;
const RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH =
RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP;
const RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT =
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP;
const RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN = Math.floor(
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE /
RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH,
);
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;
size: ResourceCanvasCardSize;
};
function positionsOverlap(
leftX: number,
leftY: number,
leftSize: ResourceCanvasCardSize,
right: PositionedResourceCanvasRect,
columnGap: number,
rowGap: number,
) {
return (
leftX < right.position.x + right.size.width + columnGap &&
leftX + leftSize.width + columnGap > right.position.x &&
leftY < right.position.y + right.size.height + rowGap &&
leftY + leftSize.height + rowGap > right.position.y
);
}
class ResourceCanvasOccupancyIndex {
private readonly rectsByCell = new Map<
string,
PositionedResourceCanvasRect[]
>();
constructor(
positions: readonly ProjectResourceCanvasPosition[],
private readonly cardSizeByResourceId: ResourceCanvasCardSizeByResourceId,
private readonly columnGap = RESOURCE_CANVAS_COLUMN_GAP,
private readonly rowGap = RESOURCE_CANVAS_ROW_GAP,
) {
positions.forEach((position) => this.add(position));
}
private cellKeys(x: number, y: number, size: ResourceCanvasCardSize) {
const paddedWidth = size.width + this.columnGap;
const paddedHeight = size.height + this.rowGap;
const firstColumn = Math.floor(x / RESOURCE_CANVAS_SLOT_WIDTH);
const lastColumn = Math.floor(
(x + paddedWidth - 1) / RESOURCE_CANVAS_SLOT_WIDTH,
);
const firstRow = Math.floor(y / RESOURCE_CANVAS_SLOT_HEIGHT);
const lastRow = Math.floor(
(y + paddedHeight - 1) / RESOURCE_CANVAS_SLOT_HEIGHT,
);
const keys: string[] = [];
for (let column = firstColumn; column <= lastColumn; column += 1) {
for (let row = firstRow; row <= lastRow; row += 1) {
keys.push(`${column}:${row}`);
}
}
return keys;
}
add(position: ProjectResourceCanvasPosition) {
const rect = {
position,
size: resourceCanvasCardSize(
position.resourceId,
this.cardSizeByResourceId,
),
};
for (const key of this.cellKeys(position.x, position.y, rect.size)) {
const rects = this.rectsByCell.get(key);
if (rects) {
rects.push(rect);
} else {
this.rectsByCell.set(key, [rect]);
}
}
}
overlaps(x: number, y: number, resourceId: string) {
const size = resourceCanvasCardSize(resourceId, this.cardSizeByResourceId);
const visited = new Set<ProjectResourceCanvasPosition>();
for (const key of this.cellKeys(x, y, size)) {
for (const rect of this.rectsByCell.get(key) ?? []) {
if (!visited.has(rect.position)) {
visited.add(rect.position);
if (positionsOverlap(x, y, size, rect, this.columnGap, this.rowGap)) {
return true;
}
}
}
}
return false;
}
}
function defaultTypePosition(
occupancy: ResourceCanvasOccupancyIndex,
resourceId: string,
nextSlot: number,
) {
let slot = nextSlot;
for (;;) {
const column = slot % RESOURCE_CANVAS_TYPE_COLUMNS;
const row = Math.floor(slot / RESOURCE_CANVAS_TYPE_COLUMNS);
const x = column * RESOURCE_CANVAS_SLOT_WIDTH;
const y = row * RESOURCE_CANVAS_SLOT_HEIGHT;
if (!occupancy.overlaps(x, y, resourceId)) {
return { point: { x, y }, nextSlot: slot + 1 };
}
slot += 1;
}
}
function compareStableIdentifier(left: string, right: string) {
return left < right ? -1 : left > right ? 1 : 0;
}
function compareResources(
mode: ProjectResourceCanvasLayoutMode,
left: ResourceCanvasItem,
right: ResourceCanvasItem,
) {
if (mode === 'dependency') {
return (
left.dependencyDepth - right.dependencyDepth ||
left.label.localeCompare(right.label, 'zh-CN') ||
compareStableIdentifier(left.id, right.id)
);
}
return (
compareStableIdentifier(left.subtype, right.subtype) ||
compareStableIdentifier(left.mediaType, right.mediaType) ||
left.label.localeCompare(right.label, 'zh-CN') ||
compareStableIdentifier(left.id, right.id)
);
}
type DependencyFlow = {
sourceResourceIds: string[];
targetResourceIds: string[];
};
type DependencyComponent = {
resourceIds: string[];
minimumDepth: number;
minimumResourceId: string;
related: boolean;
};
type DependencyCycle = {
resourceIds: string[];
minimumResourceId: string;
};
type DependencyCluster = {
resourceIds: string[];
minimumDepth: number;
minimumResourceId: string;
related: boolean;
baseY: number;
rowCount: number;
height: number;
};
const DEPENDENCY_ORDERING_SCAN_ROUNDS = 2;
function uniqueSortedResourceIds(values: Iterable<string>) {
return Array.from(new Set(values)).sort(compareStableIdentifier);
}
function median(values: readonly number[]) {
if (values.length === 0) {
return null;
}
const sorted = [...values].sort((left, right) => left - right);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 === 1
? sorted[middle]!
: (sorted[middle - 1]! + sorted[middle]!) / 2;
}
function addUndirectedNeighbor(
neighborsByNodeId: Map<string, Set<string>>,
left: string,
right: string,
) {
let leftNeighbors = neighborsByNodeId.get(left);
if (!leftNeighbors) {
leftNeighbors = new Set();
neighborsByNodeId.set(left, leftNeighbors);
}
leftNeighbors.add(right);
let rightNeighbors = neighborsByNodeId.get(right);
if (!rightNeighbors) {
rightNeighbors = new Set();
neighborsByNodeId.set(right, rightNeighbors);
}
rightNeighbors.add(left);
}
/**
* Iterative Kosaraju traversal. The Rust depth is still authoritative; SCCs
* here are only a visual contiguity hint for same-depth automatic cards.
*/
function dependencyCycles(
resourceIds: readonly string[],
downstreamIdsByResourceId: ReadonlyMap<string, ReadonlySet<string>>,
upstreamIdsByResourceId: ReadonlyMap<string, ReadonlySet<string>>,
) {
const orderedResourceIds = uniqueSortedResourceIds(resourceIds);
const visited = new Set<string>();
const finishOrder: string[] = [];
for (const startResourceId of orderedResourceIds) {
if (visited.has(startResourceId)) {
continue;
}
const pending: Array<{ resourceId: string; expanded: boolean }> = [
{ resourceId: startResourceId, expanded: false },
];
while (pending.length > 0) {
const current = pending.pop()!;
if (current.expanded) {
finishOrder.push(current.resourceId);
continue;
}
if (visited.has(current.resourceId)) {
continue;
}
visited.add(current.resourceId);
pending.push({ resourceId: current.resourceId, expanded: true });
const neighbors = downstreamIdsByResourceId.get(current.resourceId) ?? [];
for (const neighbor of neighbors) {
if (!visited.has(neighbor)) {
pending.push({ resourceId: neighbor, expanded: false });
}
}
}
}
const componentByResourceId = new Map<string, true>();
const cycles: DependencyCycle[] = [];
for (let index = finishOrder.length - 1; index >= 0; index -= 1) {
const startResourceId = finishOrder[index]!;
if (componentByResourceId.has(startResourceId)) {
continue;
}
const componentResourceIds: string[] = [];
const pending = [startResourceId];
while (pending.length > 0) {
const resourceId = pending.pop()!;
if (componentByResourceId.has(resourceId)) {
continue;
}
componentByResourceId.set(resourceId, true);
componentResourceIds.push(resourceId);
for (const neighbor of upstreamIdsByResourceId.get(resourceId) ?? []) {
if (!componentByResourceId.has(neighbor)) {
pending.push(neighbor);
}
}
}
const sortedResourceIds = uniqueSortedResourceIds(componentResourceIds);
const selfReferential =
sortedResourceIds.length === 1 &&
Boolean(
downstreamIdsByResourceId
.get(sortedResourceIds[0]!)
?.has(sortedResourceIds[0]!),
);
if (sortedResourceIds.length > 1 || selfReferential) {
cycles.push({
resourceIds: sortedResourceIds,
minimumResourceId: sortedResourceIds[0] ?? '',
});
}
}
return cycles;
}
function dependencyClusters(
sectionResources: readonly ResourceCanvasItem[],
topology: ResourceCanvasLayoutTopology | undefined,
) {
const resourceById = new Map(
sectionResources.map((resource) => [resource.id, resource]),
);
const neighborsByNodeId = new Map<string, Set<string>>(
sectionResources.map((resource) => [resource.id, new Set<string>()]),
);
const upstreamReferenceIdsByResourceId = new Map<string, Set<string>>(
sectionResources.map((resource) => [resource.id, new Set<string>()]),
);
const downstreamReferenceIdsByResourceId = new Map<string, Set<string>>(
sectionResources.map((resource) => [resource.id, new Set<string>()]),
);
const incomingFlowsByResourceId = new Map<string, DependencyFlow[]>(
sectionResources.map((resource) => [resource.id, []]),
);
const outgoingFlowsByResourceId = new Map<string, DependencyFlow[]>(
sectionResources.map((resource) => [resource.id, []]),
);
const relatedResourceIds = new Set<string>();
const flows: DependencyFlow[] = [];
for (const edge of topology?.referenceEdges ?? []) {
const source = resourceById.get(edge.sourceResourceId);
const target = resourceById.get(edge.targetResourceId);
if (source && target) {
addUndirectedNeighbor(neighborsByNodeId, source.id, target.id);
upstreamReferenceIdsByResourceId.get(target.id)?.add(source.id);
downstreamReferenceIdsByResourceId.get(source.id)?.add(target.id);
relatedResourceIds.add(source.id);
relatedResourceIds.add(target.id);
}
}
const cycles = dependencyCycles(
sectionResources.map((resource) => resource.id),
downstreamReferenceIdsByResourceId,
upstreamReferenceIdsByResourceId,
);
for (const flow of topology?.taskFlows ?? []) {
const sourceResourceIds = uniqueSortedResourceIds(
flow.sourceResourceIds.filter((resourceId) =>
resourceById.has(resourceId),
),
);
const targetResourceIds = uniqueSortedResourceIds(
flow.targetResourceIds.filter((resourceId) =>
resourceById.has(resourceId),
),
);
if (sourceResourceIds.length === 0 || targetResourceIds.length === 0) {
continue;
}
const normalizedFlow: DependencyFlow = {
sourceResourceIds,
targetResourceIds,
};
flows.push(normalizedFlow);
const members = uniqueSortedResourceIds([
...sourceResourceIds,
...targetResourceIds,
]);
// Connect the hyperedge as a star, not a cartesian product of members.
const flowNodeId = `\u0000flow:${flows.length - 1}`;
neighborsByNodeId.set(flowNodeId, new Set<string>());
for (const resourceId of members) {
addUndirectedNeighbor(neighborsByNodeId, flowNodeId, resourceId);
relatedResourceIds.add(resourceId);
}
for (const resourceId of sourceResourceIds) {
outgoingFlowsByResourceId.get(resourceId)?.push(normalizedFlow);
}
for (const resourceId of targetResourceIds) {
incomingFlowsByResourceId.get(resourceId)?.push(normalizedFlow);
}
}
const layoutComponentByResourceId = new Map<string, number>();
const components: DependencyComponent[] = [];
for (const resource of [...sectionResources].sort((left, right) =>
compareStableIdentifier(left.id, right.id),
)) {
if (layoutComponentByResourceId.has(resource.id)) {
continue;
}
const pending = [resource.id];
const visitedNodes = new Set<string>();
const resourceIds: string[] = [];
while (pending.length > 0) {
const nodeId = pending.pop()!;
if (visitedNodes.has(nodeId)) {
continue;
}
visitedNodes.add(nodeId);
const nodeResource = resourceById.get(nodeId);
if (nodeResource) {
resourceIds.push(nodeId);
}
for (const neighbor of neighborsByNodeId.get(nodeId) ?? []) {
if (!visitedNodes.has(neighbor)) {
pending.push(neighbor);
}
}
}
const sortedResourceIds = uniqueSortedResourceIds(resourceIds);
const componentIndex = components.length;
sortedResourceIds.forEach((resourceId) =>
layoutComponentByResourceId.set(resourceId, componentIndex),
);
components.push({
resourceIds: sortedResourceIds,
minimumDepth: Math.min(
...sortedResourceIds.map(
(resourceId) => resourceById.get(resourceId)?.dependencyDepth ?? 0,
),
),
minimumResourceId: sortedResourceIds[0] ?? '',
related: sortedResourceIds.some((resourceId) =>
relatedResourceIds.has(resourceId),
),
});
}
return {
componentByResourceId: layoutComponentByResourceId,
cycles,
components,
incomingFlowsByResourceId,
outgoingFlowsByResourceId,
upstreamReferenceIdsByResourceId,
downstreamReferenceIdsByResourceId,
flows,
};
}
function dependencyTopologyForSection(
sectionResources: readonly ResourceCanvasItem[],
topology: ResourceCanvasLayoutTopology | undefined,
) {
return dependencyClusters(sectionResources, topology);
}
function sortDependencyBucket(
resourceIds: string[],
resourceById: ReadonlyMap<string, ResourceCanvasItem>,
rankByResourceId: ReadonlyMap<string, number>,
scanDirection: 'forward' | 'reverse',
topology: ReturnType<typeof dependencyTopologyForSection>,
flowSignalByFlow: ReadonlyMap<DependencyFlow, number | null>,
) {
type OrderingUnit = {
resourceIds: string[];
minimumResourceId: string;
previousRank: number;
signal: number | null;
};
const resourceIdSet = new Set(resourceIds);
const claimedResourceIds = new Set<string>();
const units: OrderingUnit[] = [];
for (const cycle of topology.cycles) {
const cycleResourceIds = cycle.resourceIds.filter((resourceId) =>
resourceIdSet.has(resourceId),
);
if (cycleResourceIds.length !== cycle.resourceIds.length) {
continue;
}
cycleResourceIds.forEach((resourceId) =>
claimedResourceIds.add(resourceId),
);
units.push({
resourceIds: cycleResourceIds,
minimumResourceId: cycle.minimumResourceId,
previousRank: Math.min(
...cycleResourceIds.map(
(resourceId) => rankByResourceId.get(resourceId) ?? 0,
),
),
signal: null,
});
}
for (const resourceId of resourceIds) {
if (claimedResourceIds.has(resourceId)) {
continue;
}
units.push({
resourceIds: [resourceId],
minimumResourceId: resourceId,
previousRank: rankByResourceId.get(resourceId) ?? 0,
signal: null,
});
}
for (const unit of units) {
const signals: number[] = [];
for (const resourceId of unit.resourceIds) {
const resource = resourceById.get(resourceId);
if (!resource) {
continue;
}
const directNeighborIds =
scanDirection === 'forward'
? topology.upstreamReferenceIdsByResourceId.get(resourceId)
: topology.downstreamReferenceIdsByResourceId.get(resourceId);
for (const neighborId of directNeighborIds ?? []) {
const neighbor = resourceById.get(neighborId);
const rank = rankByResourceId.get(neighborId);
const isLayerNeighbor =
neighbor &&
rank !== undefined &&
(scanDirection === 'forward'
? neighbor.dependencyDepth < resource.dependencyDepth
: neighbor.dependencyDepth > resource.dependencyDepth);
if (isLayerNeighbor && rank !== undefined) {
signals.push(rank);
}
}
const flows =
scanDirection === 'forward'
? topology.incomingFlowsByResourceId.get(resourceId)
: topology.outgoingFlowsByResourceId.get(resourceId);
for (const flow of flows ?? []) {
const value = flowSignalByFlow.get(flow);
if (value !== null && value !== undefined) {
signals.push(value);
}
}
}
unit.signal = median(signals);
}
units.sort((left, right) => {
const leftSignal = left.signal;
const rightSignal = right.signal;
if (leftSignal !== null && leftSignal !== undefined) {
if (rightSignal === null || rightSignal === undefined) {
return -1;
}
if (leftSignal !== rightSignal) {
return leftSignal - rightSignal;
}
} else if (rightSignal !== null && rightSignal !== undefined) {
return 1;
}
return (
left.previousRank - right.previousRank ||
compareStableIdentifier(left.minimumResourceId, right.minimumResourceId)
);
});
resourceIds.splice(
0,
resourceIds.length,
...units.flatMap((unit) =>
[...unit.resourceIds].sort(
(left, right) =>
(rankByResourceId.get(left) ?? 0) -
(rankByResourceId.get(right) ?? 0) ||
compareStableIdentifier(left, right),
),
),
);
}
/**
* 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),
),
),
);
}
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 = normalizedDependencyDepth(resource.dependencyDepth);
widthByDepth.set(
depth,
Math.max(
widthByDepth.get(depth) ?? 0,
resourceCanvasCardSize(resource.id, cardSizeByResourceId).width,
),
);
}
const geometryByDepth = new Map<number, DependencyColumnGeometry>();
let accumulatedBandWidthDelta = 0;
for (const depth of Array.from(widthByDepth.keys()).sort(
(left, right) => left - right,
)) {
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 +
accumulatedBandWidthDelta,
),
),
pitch,
});
accumulatedBandWidthDelta +=
columns * pitch - RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH;
}
return geometryByDepth;
}
function dependencyAutomaticPositions(
section: ProjectResourceCanvasCategory,
sectionResources: readonly ResourceCanvasItem[],
automaticResources: readonly ResourceCanvasItem[],
preservedPositions: readonly ProjectResourceCanvasPosition[],
topology: ResourceCanvasLayoutTopology | undefined,
cardSizeByResourceId: ResourceCanvasCardSizeByResourceId,
) {
const resourceById = new Map(
sectionResources.map((resource) => [resource.id, resource]),
);
const automaticIds = new Set(
automaticResources.map((resource) => resource.id),
);
const topologyForSection = dependencyTopologyForSection(
sectionResources,
topology,
);
const relatedComponents = topologyForSection.components
.filter((component) => component.related)
.sort(
(left, right) =>
left.minimumDepth - right.minimumDepth ||
compareStableIdentifier(
left.minimumResourceId,
right.minimumResourceId,
),
);
const isolatedResourceIds = topologyForSection.components
.filter((component) => !component.related)
.flatMap((component) => component.resourceIds)
.filter((resourceId) => automaticIds.has(resourceId))
.sort(compareStableIdentifier);
const clusters: DependencyCluster[] = [];
for (const component of relatedComponents) {
const resourceIds = component.resourceIds.filter((resourceId) =>
automaticIds.has(resourceId),
);
if (resourceIds.length === 0) {
continue;
}
const resourceIdsByDepth = new Map<number, string[]>();
for (const resourceId of resourceIds) {
const depth = resourceById.get(resourceId)?.dependencyDepth ?? 0;
const bucket = resourceIdsByDepth.get(depth) ?? [];
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, ...grids.map((grid) => grid.rows)),
height: Math.max(1, ...grids.map((grid) => grid.height)),
});
}
if (isolatedResourceIds.length > 0) {
const resourceIdsByDepth = new Map<number, string[]>();
for (const resourceId of isolatedResourceIds) {
const depth = resourceById.get(resourceId)?.dependencyDepth ?? 0;
const bucket = resourceIdsByDepth.get(depth) ?? [];
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, ...grids.map((grid) => grid.rows)),
height: Math.max(1, ...grids.map((grid) => grid.height)),
});
}
let nextClusterY = 0;
for (const cluster of clusters) {
cluster.baseY = nextClusterY;
nextClusterY += cluster.height + RESOURCE_CANVAS_CLUSTER_GAP;
}
const rankByResourceId = new Map<string, number>();
for (const position of preservedPositions) {
rankByResourceId.set(
position.resourceId,
position.y / RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT,
);
}
const orderedIdsByClusterAndDepth = new Map<
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) {
const resource = resourceById.get(resourceId);
if (!resource) {
continue;
}
const ids = idsByDepth.get(resource.dependencyDepth) ?? [];
ids.push(resourceId);
idsByDepth.set(resource.dependencyDepth, ids);
}
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 - grid.rows) *
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
2
: 0);
resourceIds.forEach((resourceId, index) =>
rankByResourceId.set(
resourceId,
(bucketBaseY +
Math.floor(index / grid.columns) *
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT,
),
);
idsByDepth.set(depth, resourceIds);
}
orderedIdsByClusterAndDepth.set(cluster, idsByDepth);
}
for (let round = 0; round < DEPENDENCY_ORDERING_SCAN_ROUNDS; round += 1) {
for (const scanDirection of ['forward', 'reverse'] as const) {
const flowSignalByFlow = new Map<DependencyFlow, number | null>();
for (const flow of topologyForSection.flows) {
const signalResourceIds =
scanDirection === 'forward'
? flow.sourceResourceIds
: flow.targetResourceIds;
flowSignalByFlow.set(
flow,
median(
signalResourceIds.flatMap((resourceId) => {
const rank = rankByResourceId.get(resourceId);
return rank === undefined ? [] : [rank];
}),
),
);
}
for (const cluster of clusters) {
if (!cluster.related) {
continue;
}
const idsByDepth = orderedIdsByClusterAndDepth.get(cluster)!;
const depths = Array.from(idsByDepth.keys()).sort((left, right) =>
scanDirection === 'forward' ? left - right : right - left,
);
for (const depth of depths) {
const resourceIds = idsByDepth.get(depth)!;
sortDependencyBucket(
resourceIds,
resourceById,
rankByResourceId,
scanDirection,
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,
(bucketBaseY +
Math.floor(index / grid.columns) *
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT,
),
);
}
}
}
}
// 层带宽度要按该深度上最宽的一层预留,否则宽层会侵入下一个深度的层带。
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,
cardSizeByResourceId,
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
);
const positions: ProjectResourceCanvasPosition[] = [];
for (const cluster of clusters) {
const idsByDepth = orderedIdsByClusterAndDepth.get(cluster)!;
for (const [depth, resourceIds] of Array.from(idsByDepth).sort(
([left], [right]) => left - right,
)) {
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,
),
pitch: RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH,
};
const baseY = Math.round(
cluster.baseY +
(cluster.related ? (cluster.height - grid.height) / 2 : 0),
);
resourceIds.forEach((resourceId, index) => {
const size = resourceCanvasCardSize(resourceId, cardSizeByResourceId);
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;
}
const position: ProjectResourceCanvasPosition = {
resourceId,
section,
x,
y,
manuallyPlaced: false,
};
positions.push(position);
occupancy.add(position);
});
}
}
return positions;
}
export function reconcileResourceCanvasLayout(
source: ProjectResourceCanvasLayout,
resources: ResourceCanvasItem[],
topology?: ResourceCanvasLayoutTopology,
explicitCardSizeByResourceId?: ResourceCanvasCardSizeByResourceId,
): ReconciledResourceCanvasLayout {
const cardSizeByResourceId = resourceCanvasItemCardSizes(
resources,
explicitCardSizeByResourceId,
);
const categoryByResourceId = new Map(
resources.map((resource) => [resource.id, resource.category]),
);
const positionsBySection = new Map(
RESOURCE_CANVAS_SECTION_ORDER.map((section) => [
section,
[] as ProjectResourceCanvasPosition[],
]),
);
// 读时兼容映射:旧栏目值按资源当前分区归并,只改写 section,坐标与手动标记原样保留。
const preserved = source.positions.filter((position) => {
const normalized = normalizeResourceCanvasPosition(
position,
categoryByResourceId,
);
if (!normalized) {
return false;
}
positionsBySection
.get(normalized.position.section)
?.push(normalized.position);
return true;
});
const preservedIds = new Set(
preserved.map((position) => position.resourceId),
);
const newResourcesBySection = new Map(
RESOURCE_CANVAS_SECTION_ORDER.map((section) => [
section,
[] as ResourceCanvasItem[],
]),
);
for (const resource of resources) {
if (!preservedIds.has(resource.id)) {
newResourcesBySection.get(resource.category)?.push(resource);
}
}
for (const section of RESOURCE_CANVAS_SECTION_ORDER) {
const sectionPositions = positionsBySection.get(section) ?? [];
const sectionResources = resources.filter(
(resource) => resource.category === section,
);
let nextTypeSlot = 0;
const newResources = (newResourcesBySection.get(section) ?? []).sort(
(left, right) => compareResources(source.mode, left, right),
);
if (source.mode === 'dependency') {
const automaticPositions = dependencyAutomaticPositions(
section,
sectionResources,
newResources,
sectionPositions,
topology,
cardSizeByResourceId,
);
sectionPositions.push(...automaticPositions);
continue;
}
const occupancy = new ResourceCanvasOccupancyIndex(
sectionPositions,
cardSizeByResourceId,
);
for (const resource of newResources) {
const placement = defaultTypePosition(
occupancy,
resource.id,
nextTypeSlot,
);
const point = placement.point;
nextTypeSlot = placement.nextSlot;
const position: ProjectResourceCanvasPosition = {
resourceId: resource.id,
section,
x: point.x,
y: point.y,
manuallyPlaced: false,
};
sectionPositions.push(position);
occupancy.add(position);
}
}
const ordered = RESOURCE_CANVAS_SECTION_ORDER.flatMap((section) =>
(positionsBySection.get(section) ?? []).sort(
(left, right) =>
left.y - right.y ||
left.x - right.x ||
left.resourceId.localeCompare(right.resourceId),
),
);
return {
layout: {
...source,
positions: ordered,
},
changed: !positionsEqual(source.positions, ordered),
};
}
export function moveResourceCanvasPosition(
layout: ProjectResourceCanvasLayout,
resourceId: string,
section: ProjectResourceCanvasCategory,
x: number,
y: number,
): ProjectResourceCanvasLayout {
const finiteX = Number.isFinite(x) ? x : 0;
const finiteY = Number.isFinite(y) ? y : 0;
const normalizedX = Math.min(
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
Math.max(GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, Math.round(finiteX)),
);
const normalizedY = Math.min(
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
Math.max(GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, Math.round(finiteY)),
);
return {
...layout,
positions: layout.positions.map((position) =>
position.resourceId === resourceId && position.section === section
? {
...position,
x: normalizedX,
y: normalizedY,
manuallyPlaced: true,
}
: position,
),
};
}
export function resourceCanvasSectionExtent(
positions: readonly ProjectResourceCanvasPosition[],
cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId,
) {
const minX = Math.min(
0,
...positions.map((position) =>
position.x < 0 ? position.x - RESOURCE_CANVAS_COLUMN_GAP : 0,
),
);
const minY = Math.min(
0,
...positions.map((position) =>
position.y < 0 ? position.y - RESOURCE_CANVAS_ROW_GAP : 0,
),
);
const maxX = Math.max(
RESOURCE_CANVAS_SECTION_MIN_WIDTH,
...positions.map(
(position) =>
position.x +
resourceCanvasCardSize(position.resourceId, cardSizeByResourceId)
.width +
RESOURCE_CANVAS_COLUMN_GAP,
),
);
const maxY = Math.max(
RESOURCE_CANVAS_SECTION_MIN_HEIGHT,
...positions.map(
(position) =>
position.y +
resourceCanvasCardSize(position.resourceId, cardSizeByResourceId)
.height +
RESOURCE_CANVAS_ROW_GAP,
),
);
return {
x: Math.floor(minX),
y: Math.floor(minY),
width: Math.ceil(maxX - minX),
height: Math.ceil(maxY - minY),
};
}
export function resourceCanvasContentBounds(
positions: readonly ProjectResourceCanvasPosition[],
cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId,
) {
if (positions.length === 0) {
return {
x: 0,
y: 0,
width: RESOURCE_CANVAS_SECTION_MIN_WIDTH,
height: RESOURCE_CANVAS_SECTION_MIN_HEIGHT,
};
}
const minX = Math.min(...positions.map((position) => position.x));
const minY = Math.min(...positions.map((position) => position.y));
const maxX = Math.max(
...positions.map(
(position) =>
position.x +
resourceCanvasCardSize(position.resourceId, cardSizeByResourceId).width,
),
);
const maxY = Math.max(
...positions.map(
(position) =>
position.y +
resourceCanvasCardSize(position.resourceId, cardSizeByResourceId)
.height,
),
);
return {
x: Math.floor(minX),
y: Math.floor(minY),
width: Math.max(1, Math.ceil(maxX - minX)),
height: Math.max(1, Math.ceil(maxY - minY)),
};
}
export function fitResourceCanvasViewportToContent({
bounds,
canvasSize,
padding = RESOURCE_CANVAS_FIT_PADDING,
maxScale = RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
}: {
bounds: { x: number; y: number; width: number; height: number };
canvasSize: { width: number; height: number };
padding?: number;
maxScale?: number;
}): CanvasViewport {
const normalizedPadding = Math.max(0, padding);
const boundsWidth = Math.max(1, bounds.width);
const boundsHeight = Math.max(1, bounds.height);
const availableWidth = Math.max(1, canvasSize.width - normalizedPadding * 2);
const availableHeight = Math.max(
1,
canvasSize.height - normalizedPadding * 2,
);
const normalizedMaxScale = Number.isFinite(maxScale)
? clampResourceCanvasNumber(maxScale, MIN_SCALE, MAX_SCALE)
: MAX_SCALE;
const scale = clampResourceCanvasNumber(
Math.min(availableWidth / boundsWidth, availableHeight / boundsHeight),
MIN_SCALE,
normalizedMaxScale,
);
return {
x:
normalizedPadding +
availableWidth / 2 -
(bounds.x + boundsWidth / 2) * scale,
y:
normalizedPadding +
availableHeight / 2 -
(bounds.y + boundsHeight / 2) * scale,
scale,
};
}