1c2f82e246
## 变更内容 ### 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>
1391 lines
42 KiB
TypeScript
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,
|
|
};
|
|
}
|