Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts
T
kdletters 647cf82cec 实现AGC直连运行时与陶泥儿美术闭环
新增直连 Codex 项目聊天、会话恢复与可读错误反馈

接入陶泥儿规范图、背景、固定四切片并校验实际渲染

同步游戏代码资源分类、项目版本 revision 与外部编辑器契约
2026-08-17 09:57:31 +08:00

978 lines
30 KiB
TypeScript

import {
GAME_CREATION_RESOURCE_LAYOUT_MAX_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_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;
const sectionOrder: ProjectResourceCanvasSection[] = [
'code',
'document',
'version',
'art',
'audio',
];
export type ResourceCanvasItem = {
id: string;
category: ProjectResourceCanvasSection;
subtype: string;
label: string;
mediaType: string;
dependencyDepth: number;
};
/**
* 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,
);
function positionsOverlap(
leftX: number,
leftY: number,
right: ProjectResourceCanvasPosition,
) {
return (
leftX < right.x + RESOURCE_CANVAS_SLOT_WIDTH &&
leftX + RESOURCE_CANVAS_SLOT_WIDTH > right.x &&
leftY < right.y + RESOURCE_CANVAS_SLOT_HEIGHT &&
leftY + RESOURCE_CANVAS_SLOT_HEIGHT > right.y
);
}
class ResourceCanvasOccupancyIndex {
private readonly positionsByCell = new Map<
string,
ProjectResourceCanvasPosition[]
>();
constructor(positions: ProjectResourceCanvasPosition[]) {
positions.forEach((position) => this.add(position));
}
private cellKeys(x: number, y: number) {
const firstColumn = Math.floor(x / RESOURCE_CANVAS_SLOT_WIDTH);
const lastColumn = Math.floor(
(x + RESOURCE_CANVAS_SLOT_WIDTH - 1) / RESOURCE_CANVAS_SLOT_WIDTH,
);
const firstRow = Math.floor(y / RESOURCE_CANVAS_SLOT_HEIGHT);
const lastRow = Math.floor(
(y + RESOURCE_CANVAS_SLOT_HEIGHT - 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) {
for (const key of this.cellKeys(position.x, position.y)) {
const positions = this.positionsByCell.get(key);
if (positions) {
positions.push(position);
} else {
this.positionsByCell.set(key, [position]);
}
}
}
overlaps(x: number, y: number) {
const visited = new Set<ProjectResourceCanvasPosition>();
for (const key of this.cellKeys(x, y)) {
for (const position of this.positionsByCell.get(key) ?? []) {
if (!visited.has(position)) {
visited.add(position);
if (positionsOverlap(x, y, position)) {
return true;
}
}
}
}
return false;
}
}
function defaultTypePosition(
occupancy: ResourceCanvasOccupancyIndex,
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)) {
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;
};
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 dependencyAutomaticPositions(
section: ProjectResourceCanvasSection,
sectionResources: readonly ResourceCanvasItem[],
automaticResources: readonly ResourceCanvasItem[],
preservedPositions: readonly ProjectResourceCanvasPosition[],
topology: ResourceCanvasLayoutTopology | undefined,
) {
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[] = [];
let nextClusterY = 0;
for (const component of relatedComponents) {
const resourceIds = component.resourceIds.filter((resourceId) =>
automaticIds.has(resourceId),
);
if (resourceIds.length === 0) {
continue;
}
const bucketCounts = new Map<number, number>();
for (const resourceId of resourceIds) {
const depth = resourceById.get(resourceId)?.dependencyDepth ?? 0;
bucketCounts.set(depth, (bucketCounts.get(depth) ?? 0) + 1);
}
const cluster = {
resourceIds,
minimumDepth: component.minimumDepth,
minimumResourceId: component.minimumResourceId,
related: true,
baseY: nextClusterY,
rowCount: Math.max(1, ...bucketCounts.values()),
};
clusters.push(cluster);
nextClusterY +=
cluster.rowCount * RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT +
RESOURCE_CANVAS_CLUSTER_GAP;
}
if (isolatedResourceIds.length > 0) {
const isolatedBucketCounts = new Map<number, number>();
for (const resourceId of isolatedResourceIds) {
const depth = resourceById.get(resourceId)?.dependencyDepth ?? 0;
isolatedBucketCounts.set(
depth,
(isolatedBucketCounts.get(depth) ?? 0) + 1,
);
}
clusters.push({
resourceIds: isolatedResourceIds,
minimumDepth: Number.MAX_SAFE_INTEGER,
minimumResourceId: isolatedResourceIds[0] ?? '',
related: false,
baseY: nextClusterY,
rowCount: Math.max(1, ...isolatedBucketCounts.values()),
});
}
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 occupancy = new ResourceCanvasOccupancyIndex([...preservedPositions]);
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 =
Math.min(normalizedDepth, RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN) *
RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH;
const bucketBaseY =
cluster.baseY +
(cluster.related
? ((cluster.rowCount - resourceIds.length) *
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
2
: 0);
for (let index = 0; index < resourceIds.length; index += 1) {
const resourceId = resourceIds[index]!;
let y = bucketBaseY + index * RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT;
while (occupancy.overlaps(x, y)) {
y += RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT;
}
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,
): ReconciledResourceCanvasLayout {
const resourceById = new Map(
resources.map((resource) => [resource.id, resource]),
);
const positionsBySection = new Map(
sectionOrder.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(
sectionOrder.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 sectionOrder) {
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,
);
sectionPositions.push(...automaticPositions);
continue;
}
const occupancy = new ResourceCanvasOccupancyIndex(sectionPositions);
for (const resource of newResources) {
const placement = defaultTypePosition(occupancy, 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 = sectionOrder.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(0, Math.round(finiteX)),
);
const normalizedY = Math.min(
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
Math.max(0, 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: ProjectResourceCanvasPosition[],
) {
return {
width: Math.max(
RESOURCE_CANVAS_SECTION_MIN_WIDTH,
...positions.map(
(position) =>
position.x + RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
),
),
height: Math.max(
RESOURCE_CANVAS_SECTION_MIN_HEIGHT,
...positions.map(
(position) =>
position.y + RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP,
),
),
};
}