import type { ResourceBookCardPresentation, ResourceBookCategory, ResourceBookState, } from './resourceBookModel'; import { resourceBookCategoryCardPresentation, resourceBookShowsExitingCards, } from './resourceBookModel'; import { RESOURCE_CANVAS_CARD_HEIGHT, RESOURCE_CANVAS_CARD_WIDTH, type ResourceCanvasCardSize, } from './resourceCanvasLayoutModel'; import type { ProjectResource } from './resourceProjectionModel'; import { projectResourceTypeLabel } from './resourceProjectionModel'; export type ResourceBookOverviewRect = { left: number; top: number; width: number; height: number; }; export type ResourceBookStack = [type: string, items: ProjectResource[]]; export type ResourceBookCardLayout = { x: number; y: number; width: number; height: number; dragX: number; dragY: number; rotation: number; }; // 总览每个资源类型最多铺这么多张卡片;其余数量由标题栏的“N 项”表达。 // 叠放角度同样只取这几个值,避免素材变多后旋转角随下标无限增大。 const overviewStackRotations = [0.5, -1.5, 2.5] as const; export const RESOURCE_BOOK_OVERVIEW_STACK_LIMIT = overviewStackRotations.length; export function groupResourceBookStacks( resources: readonly ProjectResource[], ): ResourceBookStack[] { const groups = new Map(); for (const resource of resources) { const items = groups.get(projectResourceTypeLabel(resource)) ?? []; items.push(resource); groups.set(projectResourceTypeLabel(resource), items); } return Array.from( groups, ([type, items]) => [type, items] as ResourceBookStack, ); } export function groupResourceBookResourcesByCategory( resources: readonly ProjectResource[], ) { const result = new Map(); for (const resource of resources) { const items = result.get(resource.category) ?? []; items.push(resource); result.set(resource.category, items); } return result; } /** * 总览摞只铺有限张卡。挑哪几张决定了进入子画布时的观感:若按数组顺序取, * 用户看到的卡和入场视口里最近的卡不是同一批,FLIP 找不到对应终点,动画会 * 退化成淡入淡出、返回时还会跳成另一张图。这里改为取离当前视图中心最近的卡。 */ export function selectResourceBookOverviewCards({ resources, visibleResourceIds, positions, cardSizes, viewCenter, limit = RESOURCE_BOOK_OVERVIEW_STACK_LIMIT, }: { resources: readonly ProjectResource[]; visibleResourceIds: ReadonlySet; positions: ReadonlyMap; cardSizes: ReadonlyMap; viewCenter: { x: number; y: number } | undefined; limit?: number; }): ProjectResource[] { const visible = resources.filter((resource) => visibleResourceIds.has(resource.id), ); if (!viewCenter || visible.length <= limit) { return visible.slice(0, limit); } const distance = (resource: ProjectResource) => { const position = positions.get(resource.id); if (!position) return Number.POSITIVE_INFINITY; const size = cardSizes.get(resource.id) ?? { width: RESOURCE_CANVAS_CARD_WIDTH, height: RESOURCE_CANVAS_CARD_HEIGHT, }; const centerX = position.x + size.width / 2; const centerY = position.y + size.height / 2; return (centerX - viewCenter.x) ** 2 + (centerY - viewCenter.y) ** 2; }; return visible .map((resource, index) => ({ resource, index, distance: distance(resource), })) .sort( (left, right) => left.distance - right.distance || left.index - right.index, ) .slice(0, limit) .map((entry) => entry.resource); } export function resourceBookOverviewCardLayout({ rect, stackIndex, stackColumn, }: { rect: ResourceBookOverviewRect | undefined; stackIndex: number; stackColumn: number; }): ResourceBookCardLayout { const rotationIndex = Math.min( Math.max(stackIndex, 0), overviewStackRotations.length - 1, ); return { x: (rect?.left ?? 0) + 18 + (stackColumn % 3) * 102, y: (rect?.top ?? 0) + 58 + Math.min(stackIndex, 2) * 5, width: 92, height: 64, dragX: 0, dragY: 0, rotation: overviewStackRotations[rotationIndex] ?? 0, }; } export function resourceBookChildCardLayout({ position, size, }: { position: { x: number; y: number } | undefined; size: ResourceCanvasCardSize; }): ResourceBookCardLayout { const x = position?.x ?? 0; const y = position?.y ?? 0; // The child scene applies the viewport transform on its card-world wrapper. // Keep card geometry logical here so persistence, drag callbacks, and the // public ResourceCard style contract are not polluted by screen space. return { x, y, width: size.width, height: size.height, dragX: x, dragY: y, rotation: 0, }; } const RESOURCE_BOOK_CARD_FALLBACK_SIZE: ResourceCanvasCardSize = { width: 180, height: 128, }; export function resourceBookSceneCardKey(resourceId: string) { return `card:${resourceId}`; } export function resourceBookSceneTitleKey(category: ResourceBookCategory) { return `title:${category}`; } export type ResourceBookSceneCardPlan = { key: string; category: ResourceBookCategory; resource: ProjectResource; presentation: ResourceBookCardPresentation; stackIndex: number; stackColumn: number; /** 同一类型摞里的卡片总数,用于总览态 z-index。 */ stackCount: number; layout: ResourceBookCardLayout; }; export type ResourceBookSceneCategoryPlan = { category: ResourceBookCategory; resourceCount: number; titlebarActive: boolean; titlebarOpacity: number; cards: ResourceBookSceneCardPlan[]; }; /** * 画本场景渲染计划。渲染、转场签名和淡出集合共用这一份计划,避免"渲染用一套、 * 测量用另一套"的错位: * - 总览/返回态全部用总览摞布局; * - 进入栏目时当前栏目换成子画布布局,其他栏目留在 DOM 里按总览布局淡出; * - 返回总览时源栏目未回到摞上的卡片按子画布布局淡出; * - 搜索/排序导致的卸载由 `exitingCards` 保留一帧,用同一套淡出撤下。 */ export function buildResourceBookScenePlan({ state, visibleCategoryOrder, resourcesByCategory, overviewRects, positions, cardSizes, visibleResourceIds, categoryViewCenters, cardsReady, exitingCategory = null, exitingCards = [], }: { state: ResourceBookState; visibleCategoryOrder: readonly ResourceBookCategory[]; resourcesByCategory: ReadonlyMap; overviewRects: ReadonlyMap; positions: ReadonlyMap; cardSizes: ReadonlyMap; visibleResourceIds: ReadonlySet; categoryViewCenters: ReadonlyMap< ResourceBookCategory, { x: number; y: number } >; cardsReady: boolean; exitingCategory?: ResourceBookCategory | null; exitingCards?: readonly ResourceBookSceneCardPlan[]; }): ResourceBookSceneCategoryPlan[] { return visibleCategoryOrder.map((category) => { const presentation = resourceBookCategoryCardPresentation(state, category); const rect = overviewRects.get(category); const cards: ResourceBookSceneCardPlan[] = []; const categoryResources = resourcesByCategory.get(category) ?? []; const stacks = groupResourceBookStacks(categoryResources); const childLayout = presentation === 'child'; if (cardsReady && presentation) { stacks.forEach(([, items], stackColumn) => { // 总览只铺有限张,数量由栏目标题栏的“N 项”表达;子画布渲染全部资源。 const stackItems = childLayout ? items : selectResourceBookOverviewCards({ resources: items, visibleResourceIds, positions, cardSizes, viewCenter: categoryViewCenters.get(category), }); stackItems.forEach((resource, stackIndex) => { cards.push({ key: resourceBookSceneCardKey(resource.id), category, resource, presentation: childLayout ? 'child' : 'overview', stackIndex, stackColumn, stackCount: stackItems.length, layout: childLayout ? resourceBookChildCardLayout({ position: positions.get(resource.id), size: cardSizes.get(resource.id) ?? RESOURCE_BOOK_CARD_FALLBACK_SIZE, }) : resourceBookOverviewCardLayout({ rect, stackIndex, stackColumn, }), }); }); }); } if ( resourceBookShowsExitingCards(state) && exitingCategory === category && cardsReady ) { const onStack = new Set( cards .filter((card) => card.presentation === 'overview') .map((card) => card.key), ); categoryResources.forEach((resource) => { const key = resourceBookSceneCardKey(resource.id); if (onStack.has(key)) return; if (!visibleResourceIds.has(resource.id)) return; cards.push({ key, category, resource, presentation: 'exiting', stackIndex: 0, stackColumn: 0, stackCount: 1, layout: resourceBookChildCardLayout({ position: positions.get(resource.id), size: cardSizes.get(resource.id) ?? RESOURCE_BOOK_CARD_FALLBACK_SIZE, }), }); }); } exitingCards.forEach((card) => { if (card.category !== category) return; if (cards.some((entry) => entry.key === card.key)) return; cards.push({ ...card, presentation: 'exiting' }); }); return { category, resourceCount: categoryResources.length, titlebarActive: presentation === 'child', titlebarOpacity: childLayout || state.view === 'main' ? 1 : 0, cards, }; }); } export function resourceBookSceneCardLayoutSignature( card: ResourceBookSceneCardPlan, ) { return `${card.presentation}:${card.layout.x},${card.layout.y}:${card.layout.width},${card.layout.height}:${card.layout.rotation}`; }