export type NotebookPhase = | "approach" | "open" | "boot" | "cursor-in" | "browse" | "hold" | "close"; export type NotebookFrame = { phase: NotebookPhase; lidAngle: number; deviceOpacity: number; screenOpacity: number; activeTabIndex: number; cursorVisible: boolean; cursorX: number; cursorY: number; cursorClicking: boolean; }; const OPEN_ANGLE = 112; const BROWSE_START = 0.44; const BROWSE_END = 0.78; const HOLD_END = 0.87; const CLOSE_LID_START = 0.9; const TAB_CLICK_Y = 0.3; const PARK = { x: 0.5, y: 0.95 }; function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } function normalizeProgress(progress: number): number { return clamp(Number.isFinite(progress) ? progress : 0, 0, 1); } function interpolate(from: number, to: number, progress: number): number { return from + (to - from) * clamp(progress, 0, 1); } function getPhase(progress: number): NotebookPhase { if (progress < 0.1) return "approach"; if (progress < 0.25) return "open"; if (progress < 0.34) return "boot"; if (progress < BROWSE_START) return "cursor-in"; if (progress < BROWSE_END) return "browse"; if (progress < HOLD_END) return "hold"; return "close"; } function getActiveTabIndex( progress: number, tabCount: number, phase: NotebookPhase, ): number { if ( tabCount < 1 || phase === "approach" || phase === "open" || phase === "boot" || phase === "cursor-in" || phase === "close" ) { return -1; } if (phase === "hold") { return tabCount - 1; } const browseProgress = (progress - BROWSE_START) / (BROWSE_END - BROWSE_START); return clamp(Math.floor(browseProgress * tabCount), 0, tabCount - 1); } function tabCenterX(index: number, tabCount: number): number { if (tabCount < 1) return PARK.x; return (index + 0.5) / tabCount; } function getCursorPoint( progress: number, phase: NotebookPhase, activeTabIndex: number, tabCount: number, ): { x: number; y: number } { if (phase === "cursor-in") { const cursorProgress = (progress - 0.34) / 0.1; return { x: interpolate(PARK.x, tabCenterX(0, tabCount), cursorProgress), y: interpolate(PARK.y, TAB_CLICK_Y, cursorProgress), }; } if (activeTabIndex < 0) { return { ...PARK }; } const target = { x: tabCenterX(activeTabIndex, tabCount), y: TAB_CLICK_Y }; if (phase !== "browse" || activeTabIndex === 0) return target; const browseProgress = (progress - BROWSE_START) / (BROWSE_END - BROWSE_START); const slotProgress = browseProgress * tabCount - activeTabIndex; const previous = { x: tabCenterX(activeTabIndex - 1, tabCount), y: TAB_CLICK_Y, }; const moveProgress = clamp(slotProgress / 0.36, 0, 1); return { x: interpolate(previous.x, target.x, moveProgress), y: target.y, }; } function isCursorClicking( progress: number, phase: NotebookPhase, tabCount: number, ): boolean { if ( tabCount < 1 || phase === "approach" || phase === "open" || phase === "boot" || phase === "close" ) { return false; } if (phase === "cursor-in") { return progress >= 0.43; } if (phase === "hold") { return false; } const browseProgress = (progress - BROWSE_START) / (BROWSE_END - BROWSE_START); const slotProgress = (browseProgress * tabCount) % 1; return slotProgress >= 0.36 && slotProgress < 0.5; } export function getNotebookFrame( progress: number, tabCount: number, ): NotebookFrame { const normalizedProgress = normalizeProgress(progress); const normalizedTabCount = Math.max(0, Math.floor(tabCount)); const phase = getPhase(normalizedProgress); const activeTabIndex = getActiveTabIndex( normalizedProgress, normalizedTabCount, phase, ); const isApproach = normalizedProgress < 0.1; const isOpening = phase === "open"; const isBooting = phase === "boot"; const isClosing = phase === "close"; const lidAngle = isOpening ? interpolate(0, OPEN_ANGLE, (normalizedProgress - 0.1) / 0.15) : isClosing ? normalizedProgress < CLOSE_LID_START ? OPEN_ANGLE : interpolate( OPEN_ANGLE, 0, (normalizedProgress - CLOSE_LID_START) / (1 - CLOSE_LID_START), ) : normalizedProgress >= 0.25 ? OPEN_ANGLE : 0; const screenOpacity = isBooting ? interpolate(0, 1, (normalizedProgress - 0.25) / 0.09) : normalizedProgress >= 0.34 && !isClosing ? 1 : isClosing ? interpolate( 1, 0, (normalizedProgress - HOLD_END) / (CLOSE_LID_START - HOLD_END), ) : 0; const deviceOpacity = isApproach ? interpolate(0, 1, normalizedProgress / 0.1) : isClosing ? interpolate(1, 0.92, (normalizedProgress - HOLD_END) / (1 - HOLD_END)) : 1; const cursorVisible = (phase === "cursor-in" || phase === "browse" || phase === "hold") && normalizedTabCount > 0; const { x: cursorX, y: cursorY } = getCursorPoint( normalizedProgress, phase, activeTabIndex, normalizedTabCount, ); return { phase, lidAngle, deviceOpacity, screenOpacity, activeTabIndex, cursorVisible, cursorX, cursorY, cursorClicking: cursorVisible && isCursorClicking(normalizedProgress, phase, normalizedTabCount), }; }