921d86ed44
- 新增 RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS = 6 与槽位宽高比常量,作为层内网格列数上限 - 新增 normalizedDependencyDepth:落位、层带与层内网格共用同一深度归一,避免同一深度被当成两层 - 新增 dependencyLayerColumnCount:列数 = clamp(floor(sqrt(卡数 × 槽位宽高比)), 1, 6),是卡片数量的纯函数 - 新增 dependencyLayerGrid:层内网格几何,行高统一取该层最高卡,使层高与层内顺序无关(中位数扫描重排后几何不抖) - 用 dependencyColumnGeometryByDepth 取代 dependencyColumnXByDepth:x 仍只由 dependencyDepth 决定层带基址(depth 仍是唯一横向层级权威),层带宽度 = 列数 × 列间距;全部层为单列时与旧值逐值一致 - 层内按行优先落位,层内排序(稳定 ID 初序 + 两轮左至右/右至左中位数扫描)与深度层级语义不变;网格之外仍按既有向下找空位回避手动坐标与饱和层带 - 移除 dependencyBucketHeight,簇高、簇行数与层内 rank 改用层内网格行数 - 同步 resourceCanvasLayoutModel 测试:新增层内网格平面(12 张同深度 → 4 列 3 行)、重复计算与输入顺序稳定、层带随深度单调且不与换行层重叠、列数封顶 6、手动坐标在自动网格重派生前后逐项不变;同层 3 张卡的环连续性用例改为行优先网格口径 - 影响面:既有项目里已落盘的自动坐标(manuallyPlaced=false)会在下次协调时按新口径一次性重排成平面;manuallyPlaced=true 的历史手动坐标不删除、不迁移、不被自动排序覆盖 - 不新增持久化字段、sidecar schema 不升版,ProjectResourceCanvasSection/Position 契约与 RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE 均未改动
1482 lines
46 KiB
TypeScript
1482 lines
46 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,
|
||
PROJECT_RESOURCE_CANVAS_SECTIONS,
|
||
type ProjectResourceCanvasCategory,
|
||
type ProjectResourceCanvasLayout,
|
||
type ProjectResourceCanvasLayoutMode,
|
||
type ProjectResourceCanvasPosition,
|
||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||
import { normalizeResourceCanvasPosition } from './resourceCanvasSectionMapping';
|
||
|
||
export const RESOURCE_CANVAS_CARD_WIDTH = 180;
|
||
export const RESOURCE_CANVAS_CARD_HEIGHT = 128;
|
||
export const RESOURCE_CANVAS_IMAGE_CARD_MAX_WIDTH = 220;
|
||
export const RESOURCE_CANVAS_IMAGE_CARD_MAX_HEIGHT = 180;
|
||
export const RESOURCE_CANVAS_IMAGE_CARD_MIN_SHORT_EDGE = 96;
|
||
export const RESOURCE_CANVAS_IMAGE_CARD_MIN_ASPECT_RATIO = 1 / 2;
|
||
export const RESOURCE_CANVAS_IMAGE_CARD_MAX_ASPECT_RATIO = 2;
|
||
export const RESOURCE_CANVAS_COLUMN_GAP = 16;
|
||
export const RESOURCE_CANVAS_ROW_GAP = 16;
|
||
export const RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP = 48;
|
||
export const RESOURCE_CANVAS_DEPENDENCY_ROW_GAP = 40;
|
||
export const RESOURCE_CANVAS_DRAG_THRESHOLD = 5;
|
||
export const RESOURCE_CANVAS_TYPE_COLUMNS = 3;
|
||
/**
|
||
* dependency 层内网格的列数上限。卡片是 180×128 的横向卡,6 列加层间距约 1368px,
|
||
* 与栏目页在 1x 下的一屏宽度同量级;再宽就会退化成需要横向滚动的超长横带。
|
||
*/
|
||
export const RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS = 6;
|
||
export const RESOURCE_CANVAS_SECTION_MIN_WIDTH = 620;
|
||
export const RESOURCE_CANVAS_SECTION_MIN_HEIGHT = 108;
|
||
export const RESOURCE_CANVAS_CLUSTER_GAP = 48;
|
||
/**
|
||
* 总览是导航面,不是图片查看器。初次适配不要把卡片位图放大到明显插值
|
||
* 失真的范围;用户主动缩放仍可使用 MAX_SCALE。
|
||
*/
|
||
export const RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE = 1.5;
|
||
/**
|
||
* Initial fit inset only. The resource canvas background itself is infinite;
|
||
* this value must not be used to clamp a later pan or zoom.
|
||
*/
|
||
export const RESOURCE_CANVAS_FIT_PADDING = 16;
|
||
|
||
/**
|
||
* 栏目顺序 = 6 类资源资产分类 + 末尾独立的「项目版本」栏目。项目版本不是资源资产,
|
||
* 6 类资产分类轴对它不适用,因此固定单独成栏。
|
||
*/
|
||
export const RESOURCE_CANVAS_SECTION_ORDER: readonly ProjectResourceCanvasCategory[] =
|
||
PROJECT_RESOURCE_CANVAS_SECTIONS;
|
||
|
||
export type ResourceCanvasCardSize = {
|
||
width: number;
|
||
height: number;
|
||
};
|
||
|
||
export type ResourceCanvasImageDimensions = {
|
||
pixelWidth: number;
|
||
pixelHeight: number;
|
||
};
|
||
|
||
export type ResourceCanvasNavigationBounds = {
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
};
|
||
|
||
function clampResourceCanvasNumber(
|
||
value: number,
|
||
minimum: number,
|
||
maximum: number,
|
||
) {
|
||
return Math.min(maximum, Math.max(minimum, value));
|
||
}
|
||
|
||
/**
|
||
* Normalizes an infinite resource-canvas viewport.
|
||
*
|
||
* The canvas has no navigational edge: x/y are intentionally left untouched
|
||
* so users can pan the background and resources arbitrarily far in any
|
||
* direction. Only invalid coordinates and the shared image-canvas scale
|
||
* limits are normalized here.
|
||
*/
|
||
export function normalizeInfiniteResourceCanvasViewport(
|
||
viewport: CanvasViewport,
|
||
): CanvasViewport {
|
||
return {
|
||
x: Number.isFinite(viewport.x) ? viewport.x : 0,
|
||
y: Number.isFinite(viewport.y) ? viewport.y : 0,
|
||
scale: clampResourceCanvasNumber(
|
||
Number.isFinite(viewport.scale) ? viewport.scale : 1,
|
||
MIN_SCALE,
|
||
MAX_SCALE,
|
||
),
|
||
};
|
||
}
|
||
|
||
export type ResourceCanvasCardSizeByResourceId = ReadonlyMap<
|
||
string,
|
||
ResourceCanvasCardSize
|
||
>;
|
||
|
||
export type ResourceCanvasItem = {
|
||
id: string;
|
||
category: ProjectResourceCanvasCategory;
|
||
subtype: string;
|
||
label: string;
|
||
mediaType: string;
|
||
dependencyDepth: number;
|
||
cardSize?: ResourceCanvasCardSize;
|
||
};
|
||
|
||
export const DEFAULT_RESOURCE_CANVAS_CARD_SIZE: ResourceCanvasCardSize = {
|
||
width: RESOURCE_CANVAS_CARD_WIDTH,
|
||
height: RESOURCE_CANVAS_CARD_HEIGHT,
|
||
};
|
||
|
||
function validResourceCanvasCardSize(
|
||
size: ResourceCanvasCardSize | undefined,
|
||
): size is ResourceCanvasCardSize {
|
||
return Boolean(
|
||
size &&
|
||
Number.isFinite(size.width) &&
|
||
Number.isFinite(size.height) &&
|
||
size.width > 0 &&
|
||
size.height > 0,
|
||
);
|
||
}
|
||
|
||
export function resourceCanvasImageCardSize(
|
||
dimensions: ResourceCanvasImageDimensions,
|
||
): ResourceCanvasCardSize {
|
||
if (
|
||
!Number.isSafeInteger(dimensions.pixelWidth) ||
|
||
!Number.isSafeInteger(dimensions.pixelHeight) ||
|
||
dimensions.pixelWidth <= 0 ||
|
||
dimensions.pixelHeight <= 0
|
||
) {
|
||
return DEFAULT_RESOURCE_CANVAS_CARD_SIZE;
|
||
}
|
||
const aspectRatio = Math.min(
|
||
RESOURCE_CANVAS_IMAGE_CARD_MAX_ASPECT_RATIO,
|
||
Math.max(
|
||
RESOURCE_CANVAS_IMAGE_CARD_MIN_ASPECT_RATIO,
|
||
dimensions.pixelWidth / dimensions.pixelHeight,
|
||
),
|
||
);
|
||
const maximumBoxAspectRatio =
|
||
RESOURCE_CANVAS_IMAGE_CARD_MAX_WIDTH /
|
||
RESOURCE_CANVAS_IMAGE_CARD_MAX_HEIGHT;
|
||
const width =
|
||
aspectRatio >= maximumBoxAspectRatio
|
||
? RESOURCE_CANVAS_IMAGE_CARD_MAX_WIDTH
|
||
: Math.max(
|
||
RESOURCE_CANVAS_IMAGE_CARD_MIN_SHORT_EDGE,
|
||
RESOURCE_CANVAS_IMAGE_CARD_MAX_HEIGHT * aspectRatio,
|
||
);
|
||
const height =
|
||
aspectRatio >= maximumBoxAspectRatio
|
||
? Math.max(
|
||
RESOURCE_CANVAS_IMAGE_CARD_MIN_SHORT_EDGE,
|
||
RESOURCE_CANVAS_IMAGE_CARD_MAX_WIDTH / aspectRatio,
|
||
)
|
||
: RESOURCE_CANVAS_IMAGE_CARD_MAX_HEIGHT;
|
||
return {
|
||
width: Math.min(RESOURCE_CANVAS_IMAGE_CARD_MAX_WIDTH, Math.round(width)),
|
||
height: Math.min(RESOURCE_CANVAS_IMAGE_CARD_MAX_HEIGHT, Math.round(height)),
|
||
};
|
||
}
|
||
|
||
export function createResourceCanvasCardSizeByResourceId(
|
||
resources: readonly Pick<ResourceCanvasItem, 'id' | 'mediaType'>[],
|
||
imageDimensionsByResourceId: ReadonlyMap<
|
||
string,
|
||
ResourceCanvasImageDimensions
|
||
>,
|
||
): Map<string, ResourceCanvasCardSize> {
|
||
return new Map(
|
||
resources.map((resource) => {
|
||
const dimensions = imageDimensionsByResourceId.get(resource.id);
|
||
return [
|
||
resource.id,
|
||
dimensions && resource.mediaType.toLowerCase().startsWith('image/')
|
||
? resourceCanvasImageCardSize(dimensions)
|
||
: DEFAULT_RESOURCE_CANVAS_CARD_SIZE,
|
||
];
|
||
}),
|
||
);
|
||
}
|
||
|
||
export function resourceCanvasCardSize(
|
||
resourceId: string,
|
||
cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId,
|
||
): ResourceCanvasCardSize {
|
||
const size = cardSizeByResourceId?.get(resourceId);
|
||
return validResourceCanvasCardSize(size)
|
||
? size
|
||
: DEFAULT_RESOURCE_CANVAS_CARD_SIZE;
|
||
}
|
||
|
||
function resourceCanvasItemCardSizes(
|
||
resources: readonly ResourceCanvasItem[],
|
||
explicitSizes?: ResourceCanvasCardSizeByResourceId,
|
||
): ResourceCanvasCardSizeByResourceId {
|
||
const sizes = new Map<string, ResourceCanvasCardSize>();
|
||
for (const resource of resources) {
|
||
const size = explicitSizes?.get(resource.id) ?? resource.cardSize;
|
||
if (validResourceCanvasCardSize(size)) {
|
||
sizes.set(resource.id, size);
|
||
}
|
||
}
|
||
return sizes;
|
||
}
|
||
|
||
/**
|
||
* This is deliberately a display-only projection of the Rust resource graph.
|
||
* It never becomes a second dependency source: `dependencyDepth` remains on
|
||
* ResourceCanvasItem and is still the sole authority for the horizontal layer.
|
||
*/
|
||
export type ResourceCanvasLayoutTopology = {
|
||
referenceEdges: readonly {
|
||
sourceResourceId: string;
|
||
targetResourceId: string;
|
||
}[];
|
||
taskFlows: readonly {
|
||
sourceResourceIds: readonly string[];
|
||
targetResourceIds: readonly string[];
|
||
}[];
|
||
};
|
||
|
||
export type ReconciledResourceCanvasLayout = {
|
||
layout: ProjectResourceCanvasLayout;
|
||
changed: boolean;
|
||
};
|
||
|
||
export function createEmptyResourceCanvasLayout(
|
||
projectId: string,
|
||
mode: ProjectResourceCanvasLayoutMode,
|
||
): ProjectResourceCanvasLayout {
|
||
return {
|
||
schemaVersion: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
|
||
projectId,
|
||
mode,
|
||
revision: 0,
|
||
positions: [],
|
||
updatedAt: 0,
|
||
};
|
||
}
|
||
|
||
function positionsEqual(
|
||
left: ProjectResourceCanvasPosition[],
|
||
right: ProjectResourceCanvasPosition[],
|
||
) {
|
||
return (
|
||
left.length === right.length &&
|
||
left.every((position, index) => {
|
||
const other = right[index];
|
||
return (
|
||
other !== undefined &&
|
||
position.resourceId === other.resourceId &&
|
||
position.section === other.section &&
|
||
position.x === other.x &&
|
||
position.y === other.y &&
|
||
position.manuallyPlaced === other.manuallyPlaced
|
||
);
|
||
})
|
||
);
|
||
}
|
||
|
||
const RESOURCE_CANVAS_SLOT_WIDTH =
|
||
RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP;
|
||
const RESOURCE_CANVAS_SLOT_HEIGHT =
|
||
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP;
|
||
const RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH =
|
||
RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP;
|
||
const RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT =
|
||
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP;
|
||
const RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN = Math.floor(
|
||
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE /
|
||
RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH,
|
||
);
|
||
const RESOURCE_CANVAS_MAX_DEPENDENCY_X =
|
||
RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN * RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH;
|
||
/**
|
||
* 层内网格按槽位宽高比确定列数:让整层的块面接近方形,避免铺成横带或竖带。
|
||
*/
|
||
const RESOURCE_CANVAS_DEPENDENCY_LAYER_SLOT_ASPECT =
|
||
RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH /
|
||
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT;
|
||
|
||
type PositionedResourceCanvasRect = {
|
||
position: ProjectResourceCanvasPosition;
|
||
size: ResourceCanvasCardSize;
|
||
};
|
||
|
||
function positionsOverlap(
|
||
leftX: number,
|
||
leftY: number,
|
||
leftSize: ResourceCanvasCardSize,
|
||
right: PositionedResourceCanvasRect,
|
||
columnGap: number,
|
||
rowGap: number,
|
||
) {
|
||
return (
|
||
leftX < right.position.x + right.size.width + columnGap &&
|
||
leftX + leftSize.width + columnGap > right.position.x &&
|
||
leftY < right.position.y + right.size.height + rowGap &&
|
||
leftY + leftSize.height + rowGap > right.position.y
|
||
);
|
||
}
|
||
|
||
class ResourceCanvasOccupancyIndex {
|
||
private readonly rectsByCell = new Map<
|
||
string,
|
||
PositionedResourceCanvasRect[]
|
||
>();
|
||
|
||
constructor(
|
||
positions: readonly ProjectResourceCanvasPosition[],
|
||
private readonly cardSizeByResourceId: ResourceCanvasCardSizeByResourceId,
|
||
private readonly columnGap = RESOURCE_CANVAS_COLUMN_GAP,
|
||
private readonly rowGap = RESOURCE_CANVAS_ROW_GAP,
|
||
) {
|
||
positions.forEach((position) => this.add(position));
|
||
}
|
||
|
||
private cellKeys(x: number, y: number, size: ResourceCanvasCardSize) {
|
||
const paddedWidth = size.width + this.columnGap;
|
||
const paddedHeight = size.height + this.rowGap;
|
||
const firstColumn = Math.floor(x / RESOURCE_CANVAS_SLOT_WIDTH);
|
||
const lastColumn = Math.floor(
|
||
(x + paddedWidth - 1) / RESOURCE_CANVAS_SLOT_WIDTH,
|
||
);
|
||
const firstRow = Math.floor(y / RESOURCE_CANVAS_SLOT_HEIGHT);
|
||
const lastRow = Math.floor(
|
||
(y + paddedHeight - 1) / RESOURCE_CANVAS_SLOT_HEIGHT,
|
||
);
|
||
const keys: string[] = [];
|
||
for (let column = firstColumn; column <= lastColumn; column += 1) {
|
||
for (let row = firstRow; row <= lastRow; row += 1) {
|
||
keys.push(`${column}:${row}`);
|
||
}
|
||
}
|
||
return keys;
|
||
}
|
||
|
||
add(position: ProjectResourceCanvasPosition) {
|
||
const rect = {
|
||
position,
|
||
size: resourceCanvasCardSize(
|
||
position.resourceId,
|
||
this.cardSizeByResourceId,
|
||
),
|
||
};
|
||
for (const key of this.cellKeys(position.x, position.y, rect.size)) {
|
||
const rects = this.rectsByCell.get(key);
|
||
if (rects) {
|
||
rects.push(rect);
|
||
} else {
|
||
this.rectsByCell.set(key, [rect]);
|
||
}
|
||
}
|
||
}
|
||
|
||
overlaps(x: number, y: number, resourceId: string) {
|
||
const size = resourceCanvasCardSize(resourceId, this.cardSizeByResourceId);
|
||
const visited = new Set<ProjectResourceCanvasPosition>();
|
||
for (const key of this.cellKeys(x, y, size)) {
|
||
for (const rect of this.rectsByCell.get(key) ?? []) {
|
||
if (!visited.has(rect.position)) {
|
||
visited.add(rect.position);
|
||
if (positionsOverlap(x, y, size, rect, this.columnGap, this.rowGap)) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function defaultTypePosition(
|
||
occupancy: ResourceCanvasOccupancyIndex,
|
||
resourceId: string,
|
||
nextSlot: number,
|
||
) {
|
||
let slot = nextSlot;
|
||
for (;;) {
|
||
const column = slot % RESOURCE_CANVAS_TYPE_COLUMNS;
|
||
const row = Math.floor(slot / RESOURCE_CANVAS_TYPE_COLUMNS);
|
||
const x = column * RESOURCE_CANVAS_SLOT_WIDTH;
|
||
const y = row * RESOURCE_CANVAS_SLOT_HEIGHT;
|
||
if (!occupancy.overlaps(x, y, resourceId)) {
|
||
return { point: { x, y }, nextSlot: slot + 1 };
|
||
}
|
||
slot += 1;
|
||
}
|
||
}
|
||
|
||
function compareStableIdentifier(left: string, right: string) {
|
||
return left < right ? -1 : left > right ? 1 : 0;
|
||
}
|
||
|
||
function compareResources(
|
||
mode: ProjectResourceCanvasLayoutMode,
|
||
left: ResourceCanvasItem,
|
||
right: ResourceCanvasItem,
|
||
) {
|
||
if (mode === 'dependency') {
|
||
return (
|
||
left.dependencyDepth - right.dependencyDepth ||
|
||
left.label.localeCompare(right.label, 'zh-CN') ||
|
||
compareStableIdentifier(left.id, right.id)
|
||
);
|
||
}
|
||
return (
|
||
compareStableIdentifier(left.subtype, right.subtype) ||
|
||
compareStableIdentifier(left.mediaType, right.mediaType) ||
|
||
left.label.localeCompare(right.label, 'zh-CN') ||
|
||
compareStableIdentifier(left.id, right.id)
|
||
);
|
||
}
|
||
|
||
type DependencyFlow = {
|
||
sourceResourceIds: string[];
|
||
targetResourceIds: string[];
|
||
};
|
||
|
||
type DependencyComponent = {
|
||
resourceIds: string[];
|
||
minimumDepth: number;
|
||
minimumResourceId: string;
|
||
related: boolean;
|
||
};
|
||
|
||
type DependencyCycle = {
|
||
resourceIds: string[];
|
||
minimumResourceId: string;
|
||
};
|
||
|
||
type DependencyCluster = {
|
||
resourceIds: string[];
|
||
minimumDepth: number;
|
||
minimumResourceId: string;
|
||
related: boolean;
|
||
baseY: number;
|
||
rowCount: number;
|
||
height: number;
|
||
};
|
||
|
||
const DEPENDENCY_ORDERING_SCAN_ROUNDS = 2;
|
||
|
||
function uniqueSortedResourceIds(values: Iterable<string>) {
|
||
return Array.from(new Set(values)).sort(compareStableIdentifier);
|
||
}
|
||
|
||
function median(values: readonly number[]) {
|
||
if (values.length === 0) {
|
||
return null;
|
||
}
|
||
const sorted = [...values].sort((left, right) => left - right);
|
||
const middle = Math.floor(sorted.length / 2);
|
||
return sorted.length % 2 === 1
|
||
? sorted[middle]!
|
||
: (sorted[middle - 1]! + sorted[middle]!) / 2;
|
||
}
|
||
|
||
function addUndirectedNeighbor(
|
||
neighborsByNodeId: Map<string, Set<string>>,
|
||
left: string,
|
||
right: string,
|
||
) {
|
||
let leftNeighbors = neighborsByNodeId.get(left);
|
||
if (!leftNeighbors) {
|
||
leftNeighbors = new Set();
|
||
neighborsByNodeId.set(left, leftNeighbors);
|
||
}
|
||
leftNeighbors.add(right);
|
||
let rightNeighbors = neighborsByNodeId.get(right);
|
||
if (!rightNeighbors) {
|
||
rightNeighbors = new Set();
|
||
neighborsByNodeId.set(right, rightNeighbors);
|
||
}
|
||
rightNeighbors.add(left);
|
||
}
|
||
|
||
/**
|
||
* Iterative Kosaraju traversal. The Rust depth is still authoritative; SCCs
|
||
* here are only a visual contiguity hint for same-depth automatic cards.
|
||
*/
|
||
function dependencyCycles(
|
||
resourceIds: readonly string[],
|
||
downstreamIdsByResourceId: ReadonlyMap<string, ReadonlySet<string>>,
|
||
upstreamIdsByResourceId: ReadonlyMap<string, ReadonlySet<string>>,
|
||
) {
|
||
const orderedResourceIds = uniqueSortedResourceIds(resourceIds);
|
||
const visited = new Set<string>();
|
||
const finishOrder: string[] = [];
|
||
for (const startResourceId of orderedResourceIds) {
|
||
if (visited.has(startResourceId)) {
|
||
continue;
|
||
}
|
||
const pending: Array<{ resourceId: string; expanded: boolean }> = [
|
||
{ resourceId: startResourceId, expanded: false },
|
||
];
|
||
while (pending.length > 0) {
|
||
const current = pending.pop()!;
|
||
if (current.expanded) {
|
||
finishOrder.push(current.resourceId);
|
||
continue;
|
||
}
|
||
if (visited.has(current.resourceId)) {
|
||
continue;
|
||
}
|
||
visited.add(current.resourceId);
|
||
pending.push({ resourceId: current.resourceId, expanded: true });
|
||
const neighbors = downstreamIdsByResourceId.get(current.resourceId) ?? [];
|
||
for (const neighbor of neighbors) {
|
||
if (!visited.has(neighbor)) {
|
||
pending.push({ resourceId: neighbor, expanded: false });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const componentByResourceId = new Map<string, true>();
|
||
const cycles: DependencyCycle[] = [];
|
||
for (let index = finishOrder.length - 1; index >= 0; index -= 1) {
|
||
const startResourceId = finishOrder[index]!;
|
||
if (componentByResourceId.has(startResourceId)) {
|
||
continue;
|
||
}
|
||
const componentResourceIds: string[] = [];
|
||
const pending = [startResourceId];
|
||
while (pending.length > 0) {
|
||
const resourceId = pending.pop()!;
|
||
if (componentByResourceId.has(resourceId)) {
|
||
continue;
|
||
}
|
||
componentByResourceId.set(resourceId, true);
|
||
componentResourceIds.push(resourceId);
|
||
for (const neighbor of upstreamIdsByResourceId.get(resourceId) ?? []) {
|
||
if (!componentByResourceId.has(neighbor)) {
|
||
pending.push(neighbor);
|
||
}
|
||
}
|
||
}
|
||
const sortedResourceIds = uniqueSortedResourceIds(componentResourceIds);
|
||
const selfReferential =
|
||
sortedResourceIds.length === 1 &&
|
||
Boolean(
|
||
downstreamIdsByResourceId
|
||
.get(sortedResourceIds[0]!)
|
||
?.has(sortedResourceIds[0]!),
|
||
);
|
||
if (sortedResourceIds.length > 1 || selfReferential) {
|
||
cycles.push({
|
||
resourceIds: sortedResourceIds,
|
||
minimumResourceId: sortedResourceIds[0] ?? '',
|
||
});
|
||
}
|
||
}
|
||
return cycles;
|
||
}
|
||
|
||
function dependencyClusters(
|
||
sectionResources: readonly ResourceCanvasItem[],
|
||
topology: ResourceCanvasLayoutTopology | undefined,
|
||
) {
|
||
const resourceById = new Map(
|
||
sectionResources.map((resource) => [resource.id, resource]),
|
||
);
|
||
const neighborsByNodeId = new Map<string, Set<string>>(
|
||
sectionResources.map((resource) => [resource.id, new Set<string>()]),
|
||
);
|
||
const upstreamReferenceIdsByResourceId = new Map<string, Set<string>>(
|
||
sectionResources.map((resource) => [resource.id, new Set<string>()]),
|
||
);
|
||
const downstreamReferenceIdsByResourceId = new Map<string, Set<string>>(
|
||
sectionResources.map((resource) => [resource.id, new Set<string>()]),
|
||
);
|
||
const incomingFlowsByResourceId = new Map<string, DependencyFlow[]>(
|
||
sectionResources.map((resource) => [resource.id, []]),
|
||
);
|
||
const outgoingFlowsByResourceId = new Map<string, DependencyFlow[]>(
|
||
sectionResources.map((resource) => [resource.id, []]),
|
||
);
|
||
const relatedResourceIds = new Set<string>();
|
||
const flows: DependencyFlow[] = [];
|
||
|
||
for (const edge of topology?.referenceEdges ?? []) {
|
||
const source = resourceById.get(edge.sourceResourceId);
|
||
const target = resourceById.get(edge.targetResourceId);
|
||
if (source && target) {
|
||
addUndirectedNeighbor(neighborsByNodeId, source.id, target.id);
|
||
upstreamReferenceIdsByResourceId.get(target.id)?.add(source.id);
|
||
downstreamReferenceIdsByResourceId.get(source.id)?.add(target.id);
|
||
relatedResourceIds.add(source.id);
|
||
relatedResourceIds.add(target.id);
|
||
}
|
||
}
|
||
|
||
const cycles = dependencyCycles(
|
||
sectionResources.map((resource) => resource.id),
|
||
downstreamReferenceIdsByResourceId,
|
||
upstreamReferenceIdsByResourceId,
|
||
);
|
||
|
||
for (const flow of topology?.taskFlows ?? []) {
|
||
const sourceResourceIds = uniqueSortedResourceIds(
|
||
flow.sourceResourceIds.filter((resourceId) =>
|
||
resourceById.has(resourceId),
|
||
),
|
||
);
|
||
const targetResourceIds = uniqueSortedResourceIds(
|
||
flow.targetResourceIds.filter((resourceId) =>
|
||
resourceById.has(resourceId),
|
||
),
|
||
);
|
||
if (sourceResourceIds.length === 0 || targetResourceIds.length === 0) {
|
||
continue;
|
||
}
|
||
const normalizedFlow: DependencyFlow = {
|
||
sourceResourceIds,
|
||
targetResourceIds,
|
||
};
|
||
flows.push(normalizedFlow);
|
||
const members = uniqueSortedResourceIds([
|
||
...sourceResourceIds,
|
||
...targetResourceIds,
|
||
]);
|
||
// Connect the hyperedge as a star, not a cartesian product of members.
|
||
const flowNodeId = `\u0000flow:${flows.length - 1}`;
|
||
neighborsByNodeId.set(flowNodeId, new Set<string>());
|
||
for (const resourceId of members) {
|
||
addUndirectedNeighbor(neighborsByNodeId, flowNodeId, resourceId);
|
||
relatedResourceIds.add(resourceId);
|
||
}
|
||
for (const resourceId of sourceResourceIds) {
|
||
outgoingFlowsByResourceId.get(resourceId)?.push(normalizedFlow);
|
||
}
|
||
for (const resourceId of targetResourceIds) {
|
||
incomingFlowsByResourceId.get(resourceId)?.push(normalizedFlow);
|
||
}
|
||
}
|
||
|
||
const layoutComponentByResourceId = new Map<string, number>();
|
||
const components: DependencyComponent[] = [];
|
||
for (const resource of [...sectionResources].sort((left, right) =>
|
||
compareStableIdentifier(left.id, right.id),
|
||
)) {
|
||
if (layoutComponentByResourceId.has(resource.id)) {
|
||
continue;
|
||
}
|
||
const pending = [resource.id];
|
||
const visitedNodes = new Set<string>();
|
||
const resourceIds: string[] = [];
|
||
while (pending.length > 0) {
|
||
const nodeId = pending.pop()!;
|
||
if (visitedNodes.has(nodeId)) {
|
||
continue;
|
||
}
|
||
visitedNodes.add(nodeId);
|
||
const nodeResource = resourceById.get(nodeId);
|
||
if (nodeResource) {
|
||
resourceIds.push(nodeId);
|
||
}
|
||
for (const neighbor of neighborsByNodeId.get(nodeId) ?? []) {
|
||
if (!visitedNodes.has(neighbor)) {
|
||
pending.push(neighbor);
|
||
}
|
||
}
|
||
}
|
||
const sortedResourceIds = uniqueSortedResourceIds(resourceIds);
|
||
const componentIndex = components.length;
|
||
sortedResourceIds.forEach((resourceId) =>
|
||
layoutComponentByResourceId.set(resourceId, componentIndex),
|
||
);
|
||
components.push({
|
||
resourceIds: sortedResourceIds,
|
||
minimumDepth: Math.min(
|
||
...sortedResourceIds.map(
|
||
(resourceId) => resourceById.get(resourceId)?.dependencyDepth ?? 0,
|
||
),
|
||
),
|
||
minimumResourceId: sortedResourceIds[0] ?? '',
|
||
related: sortedResourceIds.some((resourceId) =>
|
||
relatedResourceIds.has(resourceId),
|
||
),
|
||
});
|
||
}
|
||
|
||
return {
|
||
componentByResourceId: layoutComponentByResourceId,
|
||
cycles,
|
||
components,
|
||
incomingFlowsByResourceId,
|
||
outgoingFlowsByResourceId,
|
||
upstreamReferenceIdsByResourceId,
|
||
downstreamReferenceIdsByResourceId,
|
||
flows,
|
||
};
|
||
}
|
||
|
||
function dependencyTopologyForSection(
|
||
sectionResources: readonly ResourceCanvasItem[],
|
||
topology: ResourceCanvasLayoutTopology | undefined,
|
||
) {
|
||
return dependencyClusters(sectionResources, topology);
|
||
}
|
||
|
||
function sortDependencyBucket(
|
||
resourceIds: string[],
|
||
resourceById: ReadonlyMap<string, ResourceCanvasItem>,
|
||
rankByResourceId: ReadonlyMap<string, number>,
|
||
scanDirection: 'forward' | 'reverse',
|
||
topology: ReturnType<typeof dependencyTopologyForSection>,
|
||
flowSignalByFlow: ReadonlyMap<DependencyFlow, number | null>,
|
||
) {
|
||
type OrderingUnit = {
|
||
resourceIds: string[];
|
||
minimumResourceId: string;
|
||
previousRank: number;
|
||
signal: number | null;
|
||
};
|
||
const resourceIdSet = new Set(resourceIds);
|
||
const claimedResourceIds = new Set<string>();
|
||
const units: OrderingUnit[] = [];
|
||
for (const cycle of topology.cycles) {
|
||
const cycleResourceIds = cycle.resourceIds.filter((resourceId) =>
|
||
resourceIdSet.has(resourceId),
|
||
);
|
||
if (cycleResourceIds.length !== cycle.resourceIds.length) {
|
||
continue;
|
||
}
|
||
cycleResourceIds.forEach((resourceId) =>
|
||
claimedResourceIds.add(resourceId),
|
||
);
|
||
units.push({
|
||
resourceIds: cycleResourceIds,
|
||
minimumResourceId: cycle.minimumResourceId,
|
||
previousRank: Math.min(
|
||
...cycleResourceIds.map(
|
||
(resourceId) => rankByResourceId.get(resourceId) ?? 0,
|
||
),
|
||
),
|
||
signal: null,
|
||
});
|
||
}
|
||
for (const resourceId of resourceIds) {
|
||
if (claimedResourceIds.has(resourceId)) {
|
||
continue;
|
||
}
|
||
units.push({
|
||
resourceIds: [resourceId],
|
||
minimumResourceId: resourceId,
|
||
previousRank: rankByResourceId.get(resourceId) ?? 0,
|
||
signal: null,
|
||
});
|
||
}
|
||
for (const unit of units) {
|
||
const signals: number[] = [];
|
||
for (const resourceId of unit.resourceIds) {
|
||
const resource = resourceById.get(resourceId);
|
||
if (!resource) {
|
||
continue;
|
||
}
|
||
const directNeighborIds =
|
||
scanDirection === 'forward'
|
||
? topology.upstreamReferenceIdsByResourceId.get(resourceId)
|
||
: topology.downstreamReferenceIdsByResourceId.get(resourceId);
|
||
for (const neighborId of directNeighborIds ?? []) {
|
||
const neighbor = resourceById.get(neighborId);
|
||
const rank = rankByResourceId.get(neighborId);
|
||
const isLayerNeighbor =
|
||
neighbor &&
|
||
rank !== undefined &&
|
||
(scanDirection === 'forward'
|
||
? neighbor.dependencyDepth < resource.dependencyDepth
|
||
: neighbor.dependencyDepth > resource.dependencyDepth);
|
||
if (isLayerNeighbor && rank !== undefined) {
|
||
signals.push(rank);
|
||
}
|
||
}
|
||
const flows =
|
||
scanDirection === 'forward'
|
||
? topology.incomingFlowsByResourceId.get(resourceId)
|
||
: topology.outgoingFlowsByResourceId.get(resourceId);
|
||
for (const flow of flows ?? []) {
|
||
const value = flowSignalByFlow.get(flow);
|
||
if (value !== null && value !== undefined) {
|
||
signals.push(value);
|
||
}
|
||
}
|
||
}
|
||
unit.signal = median(signals);
|
||
}
|
||
units.sort((left, right) => {
|
||
const leftSignal = left.signal;
|
||
const rightSignal = right.signal;
|
||
if (leftSignal !== null && leftSignal !== undefined) {
|
||
if (rightSignal === null || rightSignal === undefined) {
|
||
return -1;
|
||
}
|
||
if (leftSignal !== rightSignal) {
|
||
return leftSignal - rightSignal;
|
||
}
|
||
} else if (rightSignal !== null && rightSignal !== undefined) {
|
||
return 1;
|
||
}
|
||
return (
|
||
left.previousRank - right.previousRank ||
|
||
compareStableIdentifier(left.minimumResourceId, right.minimumResourceId)
|
||
);
|
||
});
|
||
resourceIds.splice(
|
||
0,
|
||
resourceIds.length,
|
||
...units.flatMap((unit) =>
|
||
[...unit.resourceIds].sort(
|
||
(left, right) =>
|
||
(rankByResourceId.get(left) ?? 0) -
|
||
(rankByResourceId.get(right) ?? 0) ||
|
||
compareStableIdentifier(left, right),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* dependency 深度归一。落位、层带与层内网格共用同一口径,避免同一深度被当成两层。
|
||
*/
|
||
function normalizedDependencyDepth(depth: number) {
|
||
return Number.isFinite(depth) ? Math.max(0, Math.round(depth)) : 0;
|
||
}
|
||
|
||
/**
|
||
* 层内网格列数:卡片数量 → 列数,让整层块面接近方形(槽位宽高比 ≈ 1.36),并按上限封顶。
|
||
* 它是卡片数量的纯函数,所以同一份输入重复计算稳定,也不受资源顺序影响;
|
||
* 1 张卡只占 1 列,2 张卡仍是 1 列(`floor(sqrt(2 × 1.36)) = 1`),
|
||
* 只有真正摞成长条的层(≥3 张)才会横向展开成平面。
|
||
*/
|
||
function dependencyLayerColumnCount(cardCount: number) {
|
||
return Math.min(
|
||
RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS,
|
||
Math.max(
|
||
1,
|
||
Math.floor(
|
||
Math.sqrt(cardCount * RESOURCE_CANVAS_DEPENDENCY_LAYER_SLOT_ASPECT),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
type DependencyLayerGrid = {
|
||
columns: number;
|
||
rows: number;
|
||
rowHeight: number;
|
||
height: number;
|
||
};
|
||
|
||
/**
|
||
* 层内网格几何。行高统一取该层最高卡,使层高只由行列数量决定、与层内顺序无关:
|
||
* 中位数扫描会重排层内次序,几何不能跟着变,否则簇高、簇间留白与居中偏移都会抖。
|
||
*/
|
||
function dependencyLayerGrid(
|
||
resourceIds: readonly string[],
|
||
cardSizeByResourceId: ResourceCanvasCardSizeByResourceId,
|
||
): DependencyLayerGrid {
|
||
const columns = dependencyLayerColumnCount(resourceIds.length);
|
||
let rowHeight = 0;
|
||
for (const resourceId of resourceIds) {
|
||
rowHeight = Math.max(
|
||
rowHeight,
|
||
resourceCanvasCardSize(resourceId, cardSizeByResourceId).height,
|
||
);
|
||
}
|
||
const rows = Math.max(1, Math.ceil(resourceIds.length / columns));
|
||
return {
|
||
columns,
|
||
rows,
|
||
rowHeight,
|
||
height: rows * rowHeight + (rows - 1) * RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
|
||
};
|
||
}
|
||
|
||
type DependencyColumnGeometry = {
|
||
x: number;
|
||
pitch: number;
|
||
};
|
||
|
||
/**
|
||
* 每个深度的层带基址与层内列间距。`dependencyDepth` 仍是唯一横向层级权威:
|
||
* 层带基址只由深度决定,随深度单调递增;层内第几列由网格列数在层带内展开。
|
||
* 层带宽度 = 列数 × 列间距,层与层之间恒定留一个列间距。当每一层都是单列时,
|
||
* 层带宽度公式退化成旧值(`maxWidth + 列间距`),坐标与改动前逐值一致。
|
||
*/
|
||
function dependencyColumnGeometryByDepth(
|
||
resources: readonly ResourceCanvasItem[],
|
||
cardSizeByResourceId: ResourceCanvasCardSizeByResourceId,
|
||
columnCountByDepth: ReadonlyMap<number, number>,
|
||
) {
|
||
const widthByDepth = new Map<number, number>();
|
||
for (const resource of resources) {
|
||
const depth = normalizedDependencyDepth(resource.dependencyDepth);
|
||
widthByDepth.set(
|
||
depth,
|
||
Math.max(
|
||
widthByDepth.get(depth) ?? 0,
|
||
resourceCanvasCardSize(resource.id, cardSizeByResourceId).width,
|
||
),
|
||
);
|
||
}
|
||
const geometryByDepth = new Map<number, DependencyColumnGeometry>();
|
||
let accumulatedBandWidthDelta = 0;
|
||
for (const depth of Array.from(widthByDepth.keys()).sort(
|
||
(left, right) => left - right,
|
||
)) {
|
||
const pitch =
|
||
(widthByDepth.get(depth) ?? RESOURCE_CANVAS_CARD_WIDTH) +
|
||
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP;
|
||
const columns = Math.max(1, columnCountByDepth.get(depth) ?? 1);
|
||
geometryByDepth.set(depth, {
|
||
x: Math.min(
|
||
RESOURCE_CANVAS_MAX_DEPENDENCY_X,
|
||
Math.max(
|
||
0,
|
||
Math.min(depth, RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN) *
|
||
RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH +
|
||
accumulatedBandWidthDelta,
|
||
),
|
||
),
|
||
pitch,
|
||
});
|
||
accumulatedBandWidthDelta +=
|
||
columns * pitch - RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH;
|
||
}
|
||
return geometryByDepth;
|
||
}
|
||
|
||
function dependencyAutomaticPositions(
|
||
section: ProjectResourceCanvasCategory,
|
||
sectionResources: readonly ResourceCanvasItem[],
|
||
automaticResources: readonly ResourceCanvasItem[],
|
||
preservedPositions: readonly ProjectResourceCanvasPosition[],
|
||
topology: ResourceCanvasLayoutTopology | undefined,
|
||
cardSizeByResourceId: ResourceCanvasCardSizeByResourceId,
|
||
) {
|
||
const resourceById = new Map(
|
||
sectionResources.map((resource) => [resource.id, resource]),
|
||
);
|
||
const automaticIds = new Set(
|
||
automaticResources.map((resource) => resource.id),
|
||
);
|
||
const topologyForSection = dependencyTopologyForSection(
|
||
sectionResources,
|
||
topology,
|
||
);
|
||
const relatedComponents = topologyForSection.components
|
||
.filter((component) => component.related)
|
||
.sort(
|
||
(left, right) =>
|
||
left.minimumDepth - right.minimumDepth ||
|
||
compareStableIdentifier(
|
||
left.minimumResourceId,
|
||
right.minimumResourceId,
|
||
),
|
||
);
|
||
const isolatedResourceIds = topologyForSection.components
|
||
.filter((component) => !component.related)
|
||
.flatMap((component) => component.resourceIds)
|
||
.filter((resourceId) => automaticIds.has(resourceId))
|
||
.sort(compareStableIdentifier);
|
||
|
||
const clusters: DependencyCluster[] = [];
|
||
for (const component of relatedComponents) {
|
||
const resourceIds = component.resourceIds.filter((resourceId) =>
|
||
automaticIds.has(resourceId),
|
||
);
|
||
if (resourceIds.length === 0) {
|
||
continue;
|
||
}
|
||
const resourceIdsByDepth = new Map<number, string[]>();
|
||
for (const resourceId of resourceIds) {
|
||
const depth = resourceById.get(resourceId)?.dependencyDepth ?? 0;
|
||
const bucket = resourceIdsByDepth.get(depth) ?? [];
|
||
bucket.push(resourceId);
|
||
resourceIdsByDepth.set(depth, bucket);
|
||
}
|
||
const grids = Array.from(resourceIdsByDepth.values(), (ids) =>
|
||
dependencyLayerGrid(ids, cardSizeByResourceId),
|
||
);
|
||
clusters.push({
|
||
resourceIds,
|
||
minimumDepth: component.minimumDepth,
|
||
minimumResourceId: component.minimumResourceId,
|
||
related: true,
|
||
baseY: 0,
|
||
rowCount: Math.max(1, ...grids.map((grid) => grid.rows)),
|
||
height: Math.max(1, ...grids.map((grid) => grid.height)),
|
||
});
|
||
}
|
||
if (isolatedResourceIds.length > 0) {
|
||
const resourceIdsByDepth = new Map<number, string[]>();
|
||
for (const resourceId of isolatedResourceIds) {
|
||
const depth = resourceById.get(resourceId)?.dependencyDepth ?? 0;
|
||
const bucket = resourceIdsByDepth.get(depth) ?? [];
|
||
bucket.push(resourceId);
|
||
resourceIdsByDepth.set(depth, bucket);
|
||
}
|
||
const grids = Array.from(resourceIdsByDepth.values(), (ids) =>
|
||
dependencyLayerGrid(ids, cardSizeByResourceId),
|
||
);
|
||
clusters.push({
|
||
resourceIds: isolatedResourceIds,
|
||
minimumDepth: Number.MAX_SAFE_INTEGER,
|
||
minimumResourceId: isolatedResourceIds[0] ?? '',
|
||
related: false,
|
||
baseY: 0,
|
||
rowCount: Math.max(1, ...grids.map((grid) => grid.rows)),
|
||
height: Math.max(1, ...grids.map((grid) => grid.height)),
|
||
});
|
||
}
|
||
let nextClusterY = 0;
|
||
for (const cluster of clusters) {
|
||
cluster.baseY = nextClusterY;
|
||
nextClusterY += cluster.height + RESOURCE_CANVAS_CLUSTER_GAP;
|
||
}
|
||
|
||
const rankByResourceId = new Map<string, number>();
|
||
for (const position of preservedPositions) {
|
||
rankByResourceId.set(
|
||
position.resourceId,
|
||
position.y / RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT,
|
||
);
|
||
}
|
||
const orderedIdsByClusterAndDepth = new Map<
|
||
DependencyCluster,
|
||
Map<number, string[]>
|
||
>();
|
||
const gridByBucket = new Map<string[], DependencyLayerGrid>();
|
||
for (const cluster of clusters) {
|
||
const idsByDepth = new Map<number, string[]>();
|
||
for (const resourceId of cluster.resourceIds) {
|
||
const resource = resourceById.get(resourceId);
|
||
if (!resource) {
|
||
continue;
|
||
}
|
||
const ids = idsByDepth.get(resource.dependencyDepth) ?? [];
|
||
ids.push(resourceId);
|
||
idsByDepth.set(resource.dependencyDepth, ids);
|
||
}
|
||
for (const [depth, resourceIds] of idsByDepth) {
|
||
resourceIds.sort(compareStableIdentifier);
|
||
const grid = dependencyLayerGrid(resourceIds, cardSizeByResourceId);
|
||
gridByBucket.set(resourceIds, grid);
|
||
// 层内 rank 用"网格行号",同一行的相邻卡共享纵向位置,跨层中位数对齐仍然成立。
|
||
const bucketBaseY =
|
||
cluster.baseY +
|
||
(cluster.related
|
||
? ((cluster.rowCount - grid.rows) *
|
||
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
|
||
2
|
||
: 0);
|
||
resourceIds.forEach((resourceId, index) =>
|
||
rankByResourceId.set(
|
||
resourceId,
|
||
(bucketBaseY +
|
||
Math.floor(index / grid.columns) *
|
||
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
|
||
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT,
|
||
),
|
||
);
|
||
idsByDepth.set(depth, resourceIds);
|
||
}
|
||
orderedIdsByClusterAndDepth.set(cluster, idsByDepth);
|
||
}
|
||
|
||
for (let round = 0; round < DEPENDENCY_ORDERING_SCAN_ROUNDS; round += 1) {
|
||
for (const scanDirection of ['forward', 'reverse'] as const) {
|
||
const flowSignalByFlow = new Map<DependencyFlow, number | null>();
|
||
for (const flow of topologyForSection.flows) {
|
||
const signalResourceIds =
|
||
scanDirection === 'forward'
|
||
? flow.sourceResourceIds
|
||
: flow.targetResourceIds;
|
||
flowSignalByFlow.set(
|
||
flow,
|
||
median(
|
||
signalResourceIds.flatMap((resourceId) => {
|
||
const rank = rankByResourceId.get(resourceId);
|
||
return rank === undefined ? [] : [rank];
|
||
}),
|
||
),
|
||
);
|
||
}
|
||
for (const cluster of clusters) {
|
||
if (!cluster.related) {
|
||
continue;
|
||
}
|
||
const idsByDepth = orderedIdsByClusterAndDepth.get(cluster)!;
|
||
const depths = Array.from(idsByDepth.keys()).sort((left, right) =>
|
||
scanDirection === 'forward' ? left - right : right - left,
|
||
);
|
||
for (const depth of depths) {
|
||
const resourceIds = idsByDepth.get(depth)!;
|
||
sortDependencyBucket(
|
||
resourceIds,
|
||
resourceById,
|
||
rankByResourceId,
|
||
scanDirection,
|
||
topologyForSection,
|
||
flowSignalByFlow,
|
||
);
|
||
const grid = gridByBucket.get(resourceIds)!;
|
||
const bucketBaseY =
|
||
cluster.baseY +
|
||
(cluster.related
|
||
? ((cluster.rowCount - grid.rows) *
|
||
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
|
||
2
|
||
: 0);
|
||
resourceIds.forEach((resourceId, index) =>
|
||
rankByResourceId.set(
|
||
resourceId,
|
||
(bucketBaseY +
|
||
Math.floor(index / grid.columns) *
|
||
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
|
||
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 层带宽度要按该深度上最宽的一层预留,否则宽层会侵入下一个深度的层带。
|
||
const layerColumnCountByDepth = new Map<number, number>();
|
||
for (const cluster of clusters) {
|
||
const idsByDepth = orderedIdsByClusterAndDepth.get(cluster)!;
|
||
for (const [depth, resourceIds] of idsByDepth) {
|
||
const normalizedDepth = normalizedDependencyDepth(depth);
|
||
layerColumnCountByDepth.set(
|
||
normalizedDepth,
|
||
Math.max(
|
||
layerColumnCountByDepth.get(normalizedDepth) ?? 1,
|
||
gridByBucket.get(resourceIds)!.columns,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
const columnGeometryByDepth = dependencyColumnGeometryByDepth(
|
||
sectionResources,
|
||
cardSizeByResourceId,
|
||
layerColumnCountByDepth,
|
||
);
|
||
const occupancy = new ResourceCanvasOccupancyIndex(
|
||
preservedPositions,
|
||
cardSizeByResourceId,
|
||
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
|
||
RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
|
||
);
|
||
const positions: ProjectResourceCanvasPosition[] = [];
|
||
for (const cluster of clusters) {
|
||
const idsByDepth = orderedIdsByClusterAndDepth.get(cluster)!;
|
||
for (const [depth, resourceIds] of Array.from(idsByDepth).sort(
|
||
([left], [right]) => left - right,
|
||
)) {
|
||
const normalizedDepth = normalizedDependencyDepth(depth);
|
||
const grid = gridByBucket.get(resourceIds)!;
|
||
const columnGeometry = columnGeometryByDepth.get(normalizedDepth) ?? {
|
||
x: Math.min(
|
||
RESOURCE_CANVAS_MAX_DEPENDENCY_X,
|
||
Math.min(normalizedDepth, RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN) *
|
||
RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH,
|
||
),
|
||
pitch: RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH,
|
||
};
|
||
const baseY = Math.round(
|
||
cluster.baseY +
|
||
(cluster.related ? (cluster.height - grid.height) / 2 : 0),
|
||
);
|
||
resourceIds.forEach((resourceId, index) => {
|
||
const size = resourceCanvasCardSize(resourceId, cardSizeByResourceId);
|
||
const x = Math.min(
|
||
RESOURCE_CANVAS_MAX_DEPENDENCY_X,
|
||
columnGeometry.x + (index % grid.columns) * columnGeometry.pitch,
|
||
);
|
||
let y =
|
||
baseY +
|
||
Math.floor(index / grid.columns) *
|
||
(grid.rowHeight + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP);
|
||
// 网格之外仍可能与手动坐标或饱和层带相撞,沿用既有向下找空位的回避。
|
||
while (occupancy.overlaps(x, y, resourceId)) {
|
||
y += size.height + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP;
|
||
}
|
||
const position: ProjectResourceCanvasPosition = {
|
||
resourceId,
|
||
section,
|
||
x,
|
||
y,
|
||
manuallyPlaced: false,
|
||
};
|
||
positions.push(position);
|
||
occupancy.add(position);
|
||
});
|
||
}
|
||
}
|
||
|
||
return positions;
|
||
}
|
||
|
||
export function reconcileResourceCanvasLayout(
|
||
source: ProjectResourceCanvasLayout,
|
||
resources: ResourceCanvasItem[],
|
||
topology?: ResourceCanvasLayoutTopology,
|
||
explicitCardSizeByResourceId?: ResourceCanvasCardSizeByResourceId,
|
||
): ReconciledResourceCanvasLayout {
|
||
const cardSizeByResourceId = resourceCanvasItemCardSizes(
|
||
resources,
|
||
explicitCardSizeByResourceId,
|
||
);
|
||
const categoryByResourceId = new Map(
|
||
resources.map((resource) => [resource.id, resource.category]),
|
||
);
|
||
const positionsBySection = new Map(
|
||
RESOURCE_CANVAS_SECTION_ORDER.map((section) => [
|
||
section,
|
||
[] as ProjectResourceCanvasPosition[],
|
||
]),
|
||
);
|
||
// 读时兼容映射:旧栏目值按资源当前分区归并,只改写 section,坐标与手动标记原样保留。
|
||
const preserved = source.positions.filter((position) => {
|
||
const normalized = normalizeResourceCanvasPosition(
|
||
position,
|
||
categoryByResourceId,
|
||
);
|
||
if (!normalized) {
|
||
return false;
|
||
}
|
||
positionsBySection
|
||
.get(normalized.position.section)
|
||
?.push(normalized.position);
|
||
return true;
|
||
});
|
||
const preservedIds = new Set(
|
||
preserved.map((position) => position.resourceId),
|
||
);
|
||
const newResourcesBySection = new Map(
|
||
RESOURCE_CANVAS_SECTION_ORDER.map((section) => [
|
||
section,
|
||
[] as ResourceCanvasItem[],
|
||
]),
|
||
);
|
||
for (const resource of resources) {
|
||
if (!preservedIds.has(resource.id)) {
|
||
newResourcesBySection.get(resource.category)?.push(resource);
|
||
}
|
||
}
|
||
|
||
for (const section of RESOURCE_CANVAS_SECTION_ORDER) {
|
||
const sectionPositions = positionsBySection.get(section) ?? [];
|
||
const sectionResources = resources.filter(
|
||
(resource) => resource.category === section,
|
||
);
|
||
let nextTypeSlot = 0;
|
||
const newResources = (newResourcesBySection.get(section) ?? []).sort(
|
||
(left, right) => compareResources(source.mode, left, right),
|
||
);
|
||
if (source.mode === 'dependency') {
|
||
const automaticPositions = dependencyAutomaticPositions(
|
||
section,
|
||
sectionResources,
|
||
newResources,
|
||
sectionPositions,
|
||
topology,
|
||
cardSizeByResourceId,
|
||
);
|
||
sectionPositions.push(...automaticPositions);
|
||
continue;
|
||
}
|
||
const occupancy = new ResourceCanvasOccupancyIndex(
|
||
sectionPositions,
|
||
cardSizeByResourceId,
|
||
);
|
||
for (const resource of newResources) {
|
||
const placement = defaultTypePosition(
|
||
occupancy,
|
||
resource.id,
|
||
nextTypeSlot,
|
||
);
|
||
const point = placement.point;
|
||
nextTypeSlot = placement.nextSlot;
|
||
const position: ProjectResourceCanvasPosition = {
|
||
resourceId: resource.id,
|
||
section,
|
||
x: point.x,
|
||
y: point.y,
|
||
manuallyPlaced: false,
|
||
};
|
||
sectionPositions.push(position);
|
||
occupancy.add(position);
|
||
}
|
||
}
|
||
|
||
const ordered = RESOURCE_CANVAS_SECTION_ORDER.flatMap((section) =>
|
||
(positionsBySection.get(section) ?? []).sort(
|
||
(left, right) =>
|
||
left.y - right.y ||
|
||
left.x - right.x ||
|
||
left.resourceId.localeCompare(right.resourceId),
|
||
),
|
||
);
|
||
return {
|
||
layout: {
|
||
...source,
|
||
positions: ordered,
|
||
},
|
||
changed: !positionsEqual(source.positions, ordered),
|
||
};
|
||
}
|
||
|
||
export function moveResourceCanvasPosition(
|
||
layout: ProjectResourceCanvasLayout,
|
||
resourceId: string,
|
||
section: ProjectResourceCanvasCategory,
|
||
x: number,
|
||
y: number,
|
||
): ProjectResourceCanvasLayout {
|
||
const finiteX = Number.isFinite(x) ? x : 0;
|
||
const finiteY = Number.isFinite(y) ? y : 0;
|
||
const normalizedX = Math.min(
|
||
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
|
||
Math.max(GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, Math.round(finiteX)),
|
||
);
|
||
const normalizedY = Math.min(
|
||
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
|
||
Math.max(GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, Math.round(finiteY)),
|
||
);
|
||
return {
|
||
...layout,
|
||
positions: layout.positions.map((position) =>
|
||
position.resourceId === resourceId && position.section === section
|
||
? {
|
||
...position,
|
||
x: normalizedX,
|
||
y: normalizedY,
|
||
manuallyPlaced: true,
|
||
}
|
||
: position,
|
||
),
|
||
};
|
||
}
|
||
|
||
export function resourceCanvasSectionExtent(
|
||
positions: readonly ProjectResourceCanvasPosition[],
|
||
cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId,
|
||
) {
|
||
const minX = Math.min(
|
||
0,
|
||
...positions.map((position) =>
|
||
position.x < 0 ? position.x - RESOURCE_CANVAS_COLUMN_GAP : 0,
|
||
),
|
||
);
|
||
const minY = Math.min(
|
||
0,
|
||
...positions.map((position) =>
|
||
position.y < 0 ? position.y - RESOURCE_CANVAS_ROW_GAP : 0,
|
||
),
|
||
);
|
||
const maxX = Math.max(
|
||
RESOURCE_CANVAS_SECTION_MIN_WIDTH,
|
||
...positions.map(
|
||
(position) =>
|
||
position.x +
|
||
resourceCanvasCardSize(position.resourceId, cardSizeByResourceId)
|
||
.width +
|
||
RESOURCE_CANVAS_COLUMN_GAP,
|
||
),
|
||
);
|
||
const maxY = Math.max(
|
||
RESOURCE_CANVAS_SECTION_MIN_HEIGHT,
|
||
...positions.map(
|
||
(position) =>
|
||
position.y +
|
||
resourceCanvasCardSize(position.resourceId, cardSizeByResourceId)
|
||
.height +
|
||
RESOURCE_CANVAS_ROW_GAP,
|
||
),
|
||
);
|
||
return {
|
||
x: Math.floor(minX),
|
||
y: Math.floor(minY),
|
||
width: Math.ceil(maxX - minX),
|
||
height: Math.ceil(maxY - minY),
|
||
};
|
||
}
|
||
|
||
export function resourceCanvasContentBounds(
|
||
positions: readonly ProjectResourceCanvasPosition[],
|
||
cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId,
|
||
) {
|
||
if (positions.length === 0) {
|
||
return {
|
||
x: 0,
|
||
y: 0,
|
||
width: RESOURCE_CANVAS_SECTION_MIN_WIDTH,
|
||
height: RESOURCE_CANVAS_SECTION_MIN_HEIGHT,
|
||
};
|
||
}
|
||
const minX = Math.min(...positions.map((position) => position.x));
|
||
const minY = Math.min(...positions.map((position) => position.y));
|
||
const maxX = Math.max(
|
||
...positions.map(
|
||
(position) =>
|
||
position.x +
|
||
resourceCanvasCardSize(position.resourceId, cardSizeByResourceId).width,
|
||
),
|
||
);
|
||
const maxY = Math.max(
|
||
...positions.map(
|
||
(position) =>
|
||
position.y +
|
||
resourceCanvasCardSize(position.resourceId, cardSizeByResourceId)
|
||
.height,
|
||
),
|
||
);
|
||
return {
|
||
x: Math.floor(minX),
|
||
y: Math.floor(minY),
|
||
width: Math.max(1, Math.ceil(maxX - minX)),
|
||
height: Math.max(1, Math.ceil(maxY - minY)),
|
||
};
|
||
}
|
||
|
||
export function fitResourceCanvasViewportToContent({
|
||
bounds,
|
||
canvasSize,
|
||
padding = RESOURCE_CANVAS_FIT_PADDING,
|
||
maxScale = RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
|
||
}: {
|
||
bounds: { x: number; y: number; width: number; height: number };
|
||
canvasSize: { width: number; height: number };
|
||
padding?: number;
|
||
maxScale?: number;
|
||
}): CanvasViewport {
|
||
const normalizedPadding = Math.max(0, padding);
|
||
const boundsWidth = Math.max(1, bounds.width);
|
||
const boundsHeight = Math.max(1, bounds.height);
|
||
const availableWidth = Math.max(1, canvasSize.width - normalizedPadding * 2);
|
||
const availableHeight = Math.max(
|
||
1,
|
||
canvasSize.height - normalizedPadding * 2,
|
||
);
|
||
const normalizedMaxScale = Number.isFinite(maxScale)
|
||
? clampResourceCanvasNumber(maxScale, MIN_SCALE, MAX_SCALE)
|
||
: MAX_SCALE;
|
||
const scale = clampResourceCanvasNumber(
|
||
Math.min(availableWidth / boundsWidth, availableHeight / boundsHeight),
|
||
MIN_SCALE,
|
||
normalizedMaxScale,
|
||
);
|
||
return {
|
||
x:
|
||
normalizedPadding +
|
||
availableWidth / 2 -
|
||
(bounds.x + boundsWidth / 2) * scale,
|
||
y:
|
||
normalizedPadding +
|
||
availableHeight / 2 -
|
||
(bounds.y + boundsHeight / 2) * scale,
|
||
scale,
|
||
};
|
||
}
|