Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts
T
suzmii 1c2f82e246
Project CI / Repository checks (push) Successful in 3m11s
Project CI / Frontend tests (push) Successful in 4m59s
Project CI / Backend tests (push) Successful in 9m53s
Project CI / Native shell tests (push) Successful in 19m18s
优化 Game Agent 资源画布展示与默认缩放 (#208)
## 变更内容

### 1. 普通资源画布隐藏游戏代码

- 资源画布的大纲、分页和卡片展示不再显示“游戏代码”栏目。
- 保留内部完整的五栏目布局契约,避免已有代码资源坐标被重写。
- 补充回归测试,确认游戏代码不会出现在普通资源画布中,且代码端点不可见时不会渲染对应依赖边。

### 2. 增加资源类型角标

- 资源卡右上角增加类型角标,用于快速识别资源类型。
- 类型从既有资源投影字段推导,覆盖图片、SVG、视频、音频、文档、任务产物、Agent 回执、项目版本等类型,并为未知类型兜底。
- 该能力仅用于前端展示,不新增后端字段或修改资源投影契约。

### 3. 限制默认自动适配缩放

- 画布首次自动适配和显式复位时的默认最大缩放统一限制为 `1.5`,避免内容较少或只有一个资源时卡片被放得过大。
- 该限制对设计文档、美术、音频、游戏代码、项目版本全部资源栏目生效。
- 用户主动缩放仍沿用现有上限,不受默认适配上限影响。
- 用户调整后的画布缩放会按排序模式和栏目保留;切换到其他栏目再返回后,仍恢复用户调整后的 viewport,而不是重新自动放大。

## 兼容性

- 不修改后端 API、SpacetimeDB schema 或 `/api/external/v1`。
- 资源类型角标是展示层推导逻辑,不改变资源数据结构。
- 内部布局仍保留游戏代码栏目契约,已有布局数据不需要迁移。

## 验证

- `resourceCanvasLayoutModel.test.ts`
- `project-development.suite.ts` 相关 App Surface 用例
- `npm run typecheck`
- `npm run check:encoding`
- Prettier 相关文件检查
- `git diff --check`

历史分支检查不替代最新 head CI;PR 更新后将以最新 CI 结果为准。

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/208
Co-authored-by: 董羽秦 <suzmii@qq.com>
Co-committed-by: 董羽秦 <suzmii@qq.com>
2026-08-31 14:36:58 +08:00

1391 lines
42 KiB
TypeScript

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,
type ProjectResourceCanvasLayout,
type ProjectResourceCanvasLayoutMode,
type ProjectResourceCanvasPosition,
type ProjectResourceCanvasSection,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
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;
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;
export const RESOURCE_CANVAS_SECTION_ORDER: readonly ProjectResourceCanvasSection[] =
['document', 'art', 'audio', 'code', 'version'];
/**
* Ordinary resource-canvas navigation intentionally omits game code. The
* complete five-section order above remains the internal layout/sidecar
* contract so existing code-resource coordinates are not rewritten.
*/
export const RESOURCE_CANVAS_VISIBLE_SECTION_ORDER: readonly ProjectResourceCanvasSection[] =
['document', 'art', 'audio', 'version'];
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: ProjectResourceCanvasSection;
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;
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),
),
),
);
}
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,
);
}
function dependencyColumnXByDepth(
resources: readonly ResourceCanvasItem[],
cardSizeByResourceId: ResourceCanvasCardSizeByResourceId,
) {
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;
widthByDepth.set(
depth,
Math.max(
widthByDepth.get(depth) ?? 0,
resourceCanvasCardSize(resource.id, cardSizeByResourceId).width,
),
);
}
const xByDepth = new Map<number, number>();
let accumulatedWidthDelta = 0;
for (const depth of Array.from(widthByDepth.keys()).sort(
(left, right) => left - right,
)) {
xByDepth.set(
depth,
Math.min(
RESOURCE_CANVAS_MAX_DEPENDENCY_X,
Math.max(
0,
Math.min(depth, RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN) *
RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH +
accumulatedWidthDelta,
),
),
);
accumulatedWidthDelta +=
(widthByDepth.get(depth) ?? RESOURCE_CANVAS_CARD_WIDTH) -
RESOURCE_CANVAS_CARD_WIDTH;
}
return xByDepth;
}
function dependencyAutomaticPositions(
section: ProjectResourceCanvasSection,
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);
}
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),
),
),
});
}
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);
}
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),
),
),
});
}
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[]>
>();
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 bucketBaseY =
cluster.baseY +
(cluster.related
? ((cluster.rowCount - resourceIds.length) *
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
2
: 0);
resourceIds.forEach((resourceId, index) =>
rankByResourceId.set(
resourceId,
(bucketBaseY + index * 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,
);
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) /
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT,
),
);
}
}
}
}
const dependencyColumnPositions = dependencyColumnXByDepth(
sectionResources,
cardSizeByResourceId,
);
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 = Number.isFinite(depth)
? Math.max(0, Math.round(depth))
: 0;
const x =
dependencyColumnPositions.get(normalizedDepth) ??
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(
cluster.baseY +
(cluster.related ? (cluster.height - bucketHeight) / 2 : 0),
);
for (const resourceId of resourceIds) {
const size = resourceCanvasCardSize(resourceId, cardSizeByResourceId);
let y = nextY;
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);
nextY = y + size.height + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP;
}
}
}
return positions;
}
export function reconcileResourceCanvasLayout(
source: ProjectResourceCanvasLayout,
resources: ResourceCanvasItem[],
topology?: ResourceCanvasLayoutTopology,
explicitCardSizeByResourceId?: ResourceCanvasCardSizeByResourceId,
): ReconciledResourceCanvasLayout {
const cardSizeByResourceId = resourceCanvasItemCardSizes(
resources,
explicitCardSizeByResourceId,
);
const resourceById = new Map(
resources.map((resource) => [resource.id, resource]),
);
const positionsBySection = new Map(
RESOURCE_CANVAS_SECTION_ORDER.map((section) => [
section,
[] as ProjectResourceCanvasPosition[],
]),
);
const preserved = source.positions.filter((position) => {
const resource = resourceById.get(position.resourceId);
const keep = resource?.category === position.section;
if (keep) {
positionsBySection.get(position.section)?.push(position);
}
return keep;
});
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: ProjectResourceCanvasSection,
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,
};
}