Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts
T
k88936 6981648796
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m48s
Project CI / Native shell tests (pull_request) Failing after 45s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Failing after 1m52s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m57s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m1s
合并 origin/master:Supervisor 永久退役,项目对话收敛为 DirectProject 与 Design Agent
- 解决 refactor/split-direct-project 与 origin/master 在 App.tsx、立项策划聊天视图、Direct composer/引用输入区、styles.css、Rust direct user item 与 appSurface 用例上的冲突,按「Supervisor 永久退役」口径保留 DirectProject 独立聊天容器与 Design Agent 两条产品路径
- 采纳 master 的策划 Agent V1/V2 退役:删除 GDD 审批卡、策划输入卡、planningLane、planningSessionV2、planningSessionContract、规划展示适配与 Rust planning_*_v2 命令、模块、契约及对应用例,不保留兼容别名或双跑路径
- 把 master「折叠思考显示单行预览」的目的落到当前结构:新增共享表现 chat/components/AgentReasoning/AgentReasoning.tsx(折叠态单行纯文本预览 + 箭头、展开态安全 Markdown),DirectProject 回合与策划回合共用,删掉两处写死的 pre 折叠实现
- 把 master「策划入口可选模型 / 推理档」的目的接到当前策划输入盒:复用 ConversationModelSelect 与 ComposerReasoningEffortSelect,配置写回仍走客户端配置通道
- App.tsx 删除只服务退役 Supervisor / 策划 V2 的 state、ref、effect、回调与死参数,并删除两条读路径都退役后的 workspaceProjectKind;openWorkspace 的工程类型入参保留为未使用契约
- Rust 侧保留本分支 canonical→wire 投影、无审计 Direct 回合与 direct user item 严格校验,并入 master 的 prepare_new_web_project_at 前置复核
- 更新 ADR 与 shared-memory 决策记录:策划当前只有 Design Agent、两条路径的共享表现清单,以及本次合并的口径、代价与验证证据
- 验证:AGC 与仓库 typecheck、check:encoding、check:doc-index、git diff --check、改动文件 eslint 0 error;AGC vitest 168 个文件中除 5 个 jsdom localStorage 环境失败文件与本分支既有 resourceTagStatsRefresh 失败外全绿,appSurface 198 passed / 13 skipped;Rust 定向用例 direct_codex_user_item、skill_pack、sessions 全过(整套分片在本容器受 /sbin -> usr/bin 触发沙箱预检失败,与本合并无关)
2026-09-21 21:09:34 +08:00

1527 lines
48 KiB
TypeScript
Raw Blame History

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