-
- {isWestVisible && (
+
+ {(activeIndex === 0 || activeIndex === 1) && (
+
西港区
@@ -90,10 +108,12 @@ function CenterUtils(props) {
车辆:33辆
+
)}
-
-
- {isEastVisible && (
+
+
+ {(activeIndex === 2 || activeIndex === 1) && (
+
东港区
@@ -101,12 +121,13 @@ function CenterUtils(props) {
车辆:33辆
+
)}
-
+
- >
+
)}
-
+
);
}
diff --git a/src/pages/Container/Map/components/CenterUtils/useCenterUtilsAnimation.js b/src/pages/Container/Map/components/CenterUtils/useCenterUtilsAnimation.js
deleted file mode 100644
index 237fa7e..0000000
--- a/src/pages/Container/Map/components/CenterUtils/useCenterUtilsAnimation.js
+++ /dev/null
@@ -1,205 +0,0 @@
-import { useAnimation } from "motion/react";
-import { useEffect, useRef, useState } from "react";
-
-/**
- * CenterUtils 弹跳动画 Hook
- *
- * 功能说明:
- * 1. 监听组件的显示/隐藏状态变化,执行炫酷的弹跳动画效果
- * 2. 支持两种动画类型:上下弹跳(up-down)和缩放弹跳(scale)
- * 3. 使用位置、缩放、透明度三重动画效果,营造生动的视觉体验
- * 4. 延迟隐藏机制,确保离开动画完整播放后再移除组件
- *
- * 动画参数配置:
- * - up-down 模式进入动画:
- * - 位置关键帧:y: [-120, 40, -10, 0](从上方120px -> 冲到下方40px -> 轻微上弹 -> 稳定)
- * - 缩放关键帧:scale: [0.9, 1.05, 0.98, 1](90% -> 105% -> 98% -> 100%)
- * - 透明度关键帧:opacity: [0, 1, 1, 1](透明 -> 不透明)
- * - 时长:0.7s,缓动:[0.34, 1.56, 0.64, 1](强弹性曲线,产生过冲和回弹)
- * - 关键帧时间点:times: [0, 0.5, 0.75, 1]
- *
- * - up-down 模式离开动画:
- * - 位置关键帧:y: [0, -50, 20, -120](正常 -> 向上弹跳50px -> 回落到20px -> 向上离开到-120px)
- * - 缩放关键帧:scale: [1, 1.02, 0.95, 0.85](100% -> 102% -> 95% -> 85%)
- * - 透明度关键帧:opacity: [1, 1, 0.8, 0](保持可见 -> 淡出)
- * - 时长:0.5s,缓动:[0.68, -0.6, 0.32, 1.6](超弹性曲线,产生夸张反向弹跳)
- * - 关键帧时间点:times: [0, 0.2, 0.5, 1]
- *
- * - scale 模式进入动画:
- * - 缩放关键帧:scale: [0, 1.15, 0.92, 1](0 -> 115% -> 92% -> 100%)
- * - 时长:0.7s,缓动:[0.34, 1.56, 0.64, 1]
- * - 关键帧时间点:times: [0, 0.4, 0.7, 1]
- *
- * - scale 模式离开动画:
- * - 缩放关键帧:scale: [1, 1.1, 0.85, 0](100% -> 110% -> 85% -> 0)
- * - 时长:0.5s,缓动:[0.68, -0.6, 0.32, 1.6]
- * - 关键帧时间点:times: [0, 0.3, 0.6, 1]
- *
- * @param {boolean} isVisible - 控制组件的显示/隐藏
- * - true: 组件应该显示,执行进入动画
- * - false: 组件应该隐藏,执行离开动画
- * @param {string} type - 动画类型
- * - 'up-down':上下弹跳动画(默认值,从上方进入,向上方离开)
- * - 'scale':缩放弹跳动画(从中心放大进入,缩小离开)
- * @returns {object} 返回包含以下属性的对象:
- * - controls: motion 动画控制器,绑定到 motion.div 的 animate 属性
- * - isVisible: 延迟的可见性状态,用于条件渲染(等待离开动画完成)
- */
-export function useCenterUtilsAnimation(isVisible, type = "up-down") {
- // ==================== 状态管理 ====================
- //
- // 动画时长说明:
- // - 进入动画:0.7s,较慢,展示完整的弹跳效果
- // - 离开动画:0.5s,较快,让用户感觉到响应迅速
- //
- // 缓动函数说明:
- // - 进入动画:[0.34, 1.56, 0.64, 1],强弹性曲线,产生明显的过冲和回弹
- // - 离开动画:[0.68, -0.6, 0.32, 1.6],超弹性曲线,产生夸张的反向弹跳
- //
- // 三重动画效果:
- // - 位置动画(y)/ 缩放动画(scale):产生弹跳的物理运动效果
- // - 透明度动画(opacity):平滑的进入和离开
- // ====================
-
- // motion 动画控制器,用于控制 motion.div 的动画
- const controls = useAnimation();
-
- // 标记是否是首次渲染
- // 作用:首次渲染时需要特殊处理,确保初始状态正确设置
- const isFirstRender = useRef(true);
-
- // 组件的实际显示状态
- // 作用:延迟隐藏,确保离开动画执行完成后才将组件从 DOM 中移除
- const [displayState, setDisplayState] = useState(isVisible);
-
- // ==================== 主题:监听可见性变化并执行弹跳动画 ====================
- //
- // useEffect 的职责:
- // 1. 监听 isVisible 和 type 的变化
- // 2. 根据显示/隐藏状态执行相应的弹跳动画
- //
- // 场景说明:
- // - 显示状态:根据 type 执行 up-down 或 scale 弹跳进入动画
- // - 隐藏状态:根据 type 执行 up-down 或 scale 弹跳离开动画
- // ====================
-
- useEffect(() => {
- // ==================== 显示状态:执行进入动画 ====================
- if (isVisible) {
- // 步骤 1:确保组件处于显示状态
- if (!displayState) {
- setDisplayState(true);
- }
-
- // 步骤 2:根据动画类型执行进入动画
- // ----------------------------------------------------------------
-
- // 类型 1:上下弹跳动画(炫酷 bounceInDown 效果)
- if (type === "up-down") {
- if (isFirstRender.current) {
- // === 首次渲染:炫酷弹跳进入 ===
- controls.start({
- y: [-120, 40, -10, 0], // 四个关键帧:上方120px -> 超出下方40px(大弹跳)-> 轻微上弹 -> 稳定
- opacity: [0, 1, 1, 1], // 快速淡入
- scale: [0.9, 1.05, 0.98, 1], // 缩放:缩小 -> 轻微放大 -> 轻微缩小 -> 正常
- transition: {
- duration: 0.7, // 总动画时长:0.7 秒
- times: [0, 0.5, 0.75, 1], // 时间点分布:快速下落,多次回弹
- ease: [0.34, 1.56, 0.64, 1], // 强弹性贝塞尔曲线,产生明显的回弹效果
- },
- }).then(() => {
- isFirstRender.current = false;
- });
- }
- else {
- // === 非首次渲染:炫酷弹跳进入 ===
- controls.start({
- y: [-120, 40, -10, 0],
- opacity: [0, 1, 1, 1],
- scale: [0.9, 1.05, 0.98, 1],
- transition: {
- duration: 0.7,
- times: [0, 0.5, 0.75, 1],
- ease: [0.34, 1.56, 0.64, 1],
- },
- });
- }
- }
- // ----------------------------------------------------------------
-
- // 类型 2:缩放弹跳动画(炫酷 bounceIn 效果)
- else if (type === "scale") {
- if (isFirstRender.current) {
- // === 首次渲染:炫酷缩放弹跳 ===
- controls.start({
- scale: [0, 1.15, 0.92, 1], // 四个关键帧:0 -> 超出115% -> 缩小到92% -> 正常
- opacity: [0, 1, 1, 1],
- transition: {
- duration: 0.7,
- times: [0, 0.4, 0.7, 1], // 时间点分布
- ease: [0.34, 1.56, 0.64, 1], // 强弹性曲线
- },
- }).then(() => {
- isFirstRender.current = false;
- });
- }
- else {
- // === 非首次渲染:炫酷缩放弹跳 ===
- controls.start({
- scale: [0, 1.15, 0.92, 1],
- opacity: [0, 1, 1, 1],
- transition: {
- duration: 0.7,
- times: [0, 0.4, 0.7, 1],
- ease: [0.34, 1.56, 0.64, 1],
- },
- });
- }
- }
- }
-
- // ==================== 隐藏状态:执行离开动画 ====================
- else {
- // 步骤 1:根据动画类型执行离开动画
- // ----------------------------------------------------------------
-
- // 类型 1:上下弹跳动画(炫酷 bounceOutUp 效果)
- if (type === "up-down") {
- controls.start({
- y: [0, -50, 20, -120], // 四个关键帧:正常 -> 超出上方50px -> 回落 -> 向上离开
- opacity: [1, 1, 0.8, 0], // 保持可见再淡出
- scale: [1, 1.02, 0.95, 0.85], // 缩放:轻微放大 -> 轻微缩小
- transition: {
- duration: 0.5, // 总动画时长:0.5 秒
- times: [0, 0.2, 0.5, 1], // 时间点分布:快速弹起,缓慢落下
- ease: [0.68, -0.6, 0.32, 1.6], // 超弹性曲线,产生夸张的回弹效果
- },
- }).then(() => {
- setDisplayState(false);
- });
- }
- // ----------------------------------------------------------------
-
- // 类型 2:缩放弹跳动画(炫酷 bounceOut 效果)
- else if (type === "scale") {
- controls.start({
- scale: [1, 1.1, 0.85, 0], // 四个关键帧:正常 -> 放大到110% -> 缩小到85% -> 消失
- opacity: [1, 1, 0.6, 0],
- transition: {
- duration: 0.5,
- times: [0, 0.3, 0.6, 1],
- ease: [0.68, -0.6, 0.32, 1.6], // 超弹性曲线
- },
- }).then(() => {
- setDisplayState(false);
- });
- }
- }
- }, [isVisible, type, controls, displayState]);
-
- // ==================== 返回值 ====================
- return {
- controls, // motion 动画控制器,绑定到 motion.div 的 animate 属性
- isVisible: displayState, // 延迟的可见性状态,用于条件渲染(等待离开动画完成)
- };
-}
diff --git a/src/pages/Container/Map/components/Content/index.js b/src/pages/Container/Map/components/Content/index.js
index 82cce10..8fc07c1 100644
--- a/src/pages/Container/Map/components/Content/index.js
+++ b/src/pages/Container/Map/components/Content/index.js
@@ -1,5 +1,5 @@
-import { motion } from "motion/react";
-import { useContext } from "react";
+import { AnimatePresence, motion } from "motion/react";
+import { useContext, useState } from "react";
import collapseMenuImg1 from "~/assets/images/map_bi/content/collapse_menu1.png";
import collapseMenuImg2 from "~/assets/images/map_bi/content/collapse_menu2.png";
import collapseMenuBg1 from "~/assets/images/map_bi/content/collapse_menu_bg1.png";
@@ -26,219 +26,146 @@ import PortWeiXian from "~/pages/Container/Map/components/Content/port/WeiXian";
import PortXiaoFang from "~/pages/Container/Map/components/Content/port/XiaoFang";
import PortZhongDian from "~/pages/Container/Map/components/Content/port/ZhongDian";
import { Context } from "~/pages/Container/Map/js/context";
-import { useContentAnimation } from "./useContentAnimation";
import "./index.less";
+const indexAnimation = {
+ initial: { scale: 0.8, opacity: 0 },
+ animate: { scale: 1, opacity: 1, transition: { duration: 0.5, ease: "easeOut" } },
+ exit: { scale: 0.8, opacity: 0, transition: { duration: 0.3, ease: "easeIn" } },
+};
+
+const panelAnimation = side => ({
+ initial: { x: side === "left" ? -300 : 300, opacity: 0 },
+ animate: { x: 0, opacity: 1, transition: { duration: 0.5, ease: "easeOut" } },
+ exit: { x: side === "left" ? -300 : 300, opacity: 0, transition: { duration: 0.3, ease: "easeIn" } },
+});
+
function Content(props) {
const { currentPort, currentBranchOffice, pureMap, bottomUtilsCurrentIndex } = useContext(Context);
+ const [isLeftCollapsed, setIsLeftCollapsed] = useState(false);
+ const [isRightCollapsed, setIsRightCollapsed] = useState(false);
- const {
- controls: indexInfoControls,
- isVisible: isIndexInfoVisible,
- } = useContentAnimation({
- currentContent: { currentPort, currentBranchOffice },
- type: "index",
- });
-
- const {
- controls: leftControls,
- displayedContent: leftDisplayedContent,
- isVisible: isLeftVisible,
- isCollapsed: isLeftCollapsed,
- handleCollapse: handleLeftCollapse,
- menuControls: leftMenuControls,
- } = useContentAnimation({
- currentContent: { currentPort, currentBranchOffice, bottomUtilsCurrentIndex },
- side: "left",
- isPureMap: pureMap,
- type: "panel",
- });
-
- const {
- controls: rightControls,
- displayedContent: rightDisplayedContent,
- isVisible: isRightVisible,
- isCollapsed: isRightCollapsed,
- handleCollapse: handleRightCollapse,
- menuControls: rightMenuControls,
- } = useContentAnimation({
- currentContent: { currentBranchOffice, bottomUtilsCurrentIndex },
- side: "right",
- isPureMap: pureMap,
- type: "panel",
- });
+ const bottomUtilsCurrentType = currentBranchOffice
+ ? branchOfficeUtilsList[bottomUtilsCurrentIndex]?.type || ""
+ : portUtilsList[bottomUtilsCurrentIndex]?.type || "";
+ const contentKey = `${currentPort}_${currentBranchOffice}_${bottomUtilsCurrentIndex}`;
+ const isPortContent = currentPort === "00003" && !currentBranchOffice;
const renderPortContent = () => {
- const bottomUtilsCurrentType = (leftDisplayedContent.bottomUtilsCurrentIndex !== -1 && portUtilsList[leftDisplayedContent.bottomUtilsCurrentIndex])
- ? portUtilsList[leftDisplayedContent.bottomUtilsCurrentIndex].type
- : "";
- if (bottomUtilsCurrentType === "" || bottomUtilsCurrentType === "camera")
- return
;
- if (bottomUtilsCurrentType === "door")
- return
;
- if (bottomUtilsCurrentType === "fire")
- return
;
- if (bottomUtilsCurrentType === "danger")
- return
;
- if (bottomUtilsCurrentType === "weather")
- return
;
- if (bottomUtilsCurrentType === "people")
- return
;
- if (bottomUtilsCurrentType === "project")
- return
;
- if (bottomUtilsCurrentType === "closedArea")
- return
;
+ const contentMap = {
+ "": PortIndex,
+ "camera": PortIndex,
+ "door": PortMenJin,
+ "fire": PortXiaoFang,
+ "danger": PortWeiXian,
+ "weather": PortQiXiang,
+ "people": PortRenYuan,
+ "project": PortZhongDian,
+ "closedArea": PortFengBi,
+ };
+ const Component = contentMap[bottomUtilsCurrentType];
+ return Component ?
: null;
};
const renderBranchOfficeContentLeft = () => {
- const bottomUtilsCurrentType = (leftDisplayedContent.bottomUtilsCurrentIndex !== -1 && branchOfficeUtilsList[leftDisplayedContent.bottomUtilsCurrentIndex])
- ? branchOfficeUtilsList[leftDisplayedContent.bottomUtilsCurrentIndex].type
- : "";
- if (bottomUtilsCurrentType === "")
- return
;
- if (bottomUtilsCurrentType === "danger")
- return
;
- if (bottomUtilsCurrentType === "door")
- return
;
- if (bottomUtilsCurrentType === "fire")
- return
;
- if (bottomUtilsCurrentType === "people")
- return
;
- if (bottomUtilsCurrentType === "weather")
- return
;
- if (bottomUtilsCurrentType === "project")
- return
;
- if (bottomUtilsCurrentType === "closedArea")
- return
;
- if (bottomUtilsCurrentType === "camera")
- return
;
+ const contentMap = {
+ "": BranchOfficeIndexLeft,
+ "danger": BranchWeiXian,
+ "door": BranchMenJin,
+ "fire": BranchXiaoFang,
+ "people": BranchRenYuan,
+ "weather": BranchQiXiang,
+ "project": BranchZhongDian,
+ "closedArea": BranchFengBi,
+ "camera": BranchCamera,
+ };
+ const Component = contentMap[bottomUtilsCurrentType];
+ return Component ?
: null;
};
- const renderBranchOfficeContentRight = () => {
- const bottomUtilsCurrentType = (rightDisplayedContent.bottomUtilsCurrentIndex !== -1 && branchOfficeUtilsList[rightDisplayedContent.bottomUtilsCurrentIndex])
- ? branchOfficeUtilsList[rightDisplayedContent.bottomUtilsCurrentIndex].type
- : "";
- if (bottomUtilsCurrentType === "")
- return
;
- };
+ const renderCollapseButton = ({ side, isCollapsed, setIsCollapsed }) => {
+ const isPort = isPortContent;
+ const collapsedX = side === "left" ? -465 : 465;
+ const containerRotation = !isPort && side === "right" ? 180 : 0;
+ const imageRotation = isPort
+ ? (isCollapsed ? 180 : 0)
+ : (isCollapsed ? 0 : 180);
- const renderContent = () => {
return (
- <>
- {isIndexInfoVisible && (
-
-
-
- )}
- {isLeftVisible && (
-
- {(leftDisplayedContent.currentPort === "00003" && !leftDisplayedContent.currentBranchOffice) && renderPortContent()}
- {leftDisplayedContent.currentBranchOffice && renderBranchOfficeContentLeft()}
-
- )}
- {isRightVisible && (
-
- {rightDisplayedContent.currentBranchOffice && renderBranchOfficeContentRight()}
-
- )}
- >
+
setIsCollapsed(value => !value)}
+ >
+
+
);
};
- const renderCollapseMenu = () => {
- if (pureMap)
- return null;
-
- if (currentPort === "00003" && !currentBranchOffice) {
- return (
-
-
-
- );
- }
-
- if (currentBranchOffice) {
- const bottomUtilsCurrentType = bottomUtilsCurrentIndex !== -1 ? branchOfficeUtilsList[bottomUtilsCurrentIndex].type : "";
-
- return (
- <>
- {bottomUtilsCurrentType !== "camera" && (
-
-
-
- )}
- {bottomUtilsCurrentIndex === -1 && (
-
-
-
- )}
- >
- );
- }
- };
-
return (
- {renderContent()}
- {renderCollapseMenu()}
+
+ {!currentPort && !currentBranchOffice && (
+
+
+
+ )}
+
+
+
+ {!pureMap && !isLeftCollapsed && (isPortContent || currentBranchOffice) && (
+
+ {isPortContent ? renderPortContent() : renderBranchOfficeContentLeft()}
+
+ )}
+
+
+
+ {!pureMap && !isRightCollapsed && currentBranchOffice && (
+
+ {bottomUtilsCurrentType === "" && }
+
+ )}
+
+
+ {!pureMap && isPortContent && renderCollapseButton({
+ side: "left",
+ isCollapsed: isLeftCollapsed,
+ setIsCollapsed: setIsLeftCollapsed,
+ })}
+
+ {!pureMap && currentBranchOffice && (
+ <>
+ {bottomUtilsCurrentType !== "camera" && renderCollapseButton({
+ side: "left",
+ isCollapsed: isLeftCollapsed,
+ setIsCollapsed: setIsLeftCollapsed,
+ })}
+ {bottomUtilsCurrentIndex === -1 && renderCollapseButton({
+ side: "right",
+ isCollapsed: isRightCollapsed,
+ setIsCollapsed: setIsRightCollapsed,
+ })}
+ >
+ )}
);
}
diff --git a/src/pages/Container/Map/components/Content/useContentAnimation.js b/src/pages/Container/Map/components/Content/useContentAnimation.js
deleted file mode 100644
index 9e0ac3a..0000000
--- a/src/pages/Container/Map/components/Content/useContentAnimation.js
+++ /dev/null
@@ -1,458 +0,0 @@
-import { useAnimation } from "motion/react";
-import { useEffect, useRef, useState } from "react";
-
-/**
- * 内容动画 Hook(统一处理 IndexInfo 和面板动画)
- *
- * 功能说明:
- * 1. 通过 type 参数区分动画类型:"index"(IndexInfo 缩放动画)或 "panel"(面板滑动动画)
- * 2. IndexInfo 类型:管理首页内容的显示/隐藏,使用缩放动画
- * 3. Panel 类型:管理内容面板的显示/隐藏,使用滑动动画,支持折叠/展开
- *
- * @param {object} params - 参数对象
- * @param {object} params.currentContent - 当前要显示的内容状态(包含 currentPort、currentBranchOffice、bottomUtilsCurrentIndex 等)
- * @param {string} params.side - 'left' 或 'right',控制动画方向(仅 panel 类型)
- * @param {boolean} params.isPureMap - 是否为纯地图模式(仅 panel 类型)
- * @param {string} params.type - 动画类型:"index" 或 "panel"(默认 "panel")
- * @returns {object} 返回包含以下属性的对象:
- * - controls: motion 动画控制器
- * - displayedContent: 延迟显示的内容状态(仅 panel 类型)
- * - isVisible: 元素可见性状态
- * - isCollapsed: 折叠状态(仅 panel 类型)
- * - handleCollapse: 处理折叠/展开的函数(仅 panel 类型)
- */
-export function useContentAnimation({
- currentContent,
- side = "left",
- isPureMap = false,
- type = "panel",
-}) {
- // ==================== 状态管理 ====================
- //
- // 动画时长说明:
- // - 进入动画:0.5s,较慢,营造舒适的进入体验
- // - 离开动画:0.3s,较快,让用户感觉到响应迅速
- //
- // 缓动函数说明:
- // - easeOut:进入时使用,先快后慢,更自然
- // - easeIn:离开时使用,先慢后快,快速消失
- // ====================
-
- // motion 动画控制器,用于控制内容面板的动画
- const controls = useAnimation();
-
- // 菜单按钮的动画控制器,用于控制折叠按钮的动画
- const menuControls = useAnimation();
-
- // 元素可见性状态
- // false: 元素被隐藏(折叠或纯地图模式)
- // true: 元素可见(非折叠、非纯地图模式)
- const [isVisible, setIsVisible] = useState(true);
-
- // 标记当前是否正在执行动画(防止动画冲突)
- const isAnimating = useRef(false);
-
- // 标记是否是首次渲染(首次渲染不执行离开动画,直接进入)
- const isFirstRender = useRef(true);
-
- // 折叠状态(仅 panel 类型使用)
- // true: 面板被折叠,点击按钮可展开
- // false: 面板展开,点击按钮可折叠
- const [isCollapsed, setIsCollapsed] = useState(false);
-
- // 延迟显示的内容状态(仅 panel 类型使用)
- // 作用:在内容切换时,先显示旧内容,动画完成后再更新为新内容
- // 这样可以实现旧内容离开 -> 新内容进入的平滑过渡
- const [displayedContent, setDisplayedContent] = useState(type === "index" ? {} : currentContent);
-
- // 标记是否正在手动处理折叠/展开(防止 useEffect 干预)
- // 当手动点击折叠/展开按钮时,设置此标记为 true,阻止 useEffect 中的自动动画逻辑
- const isManualCollapseAnimating = useRef(false);
-
- // ==================== IndexInfo 类型的缩放动画逻辑 ====================
-
- useEffect(() => {
- // 仅处理 index 类型
- if (type !== "index") {
- return;
- }
-
- // 判断是否应该显示 IndexInfo(没有选择任何港口或分公司时)
- const shouldShowIndexInfo = !currentContent.currentPort && !currentContent.currentBranchOffice;
-
- // ==================== 场景 1:应该显示 IndexInfo ====================
- if (shouldShowIndexInfo) {
- // ----- 情况 A:首次渲染 -----
- if (isFirstRender.current) {
- // 1. 设置元素到初始缩小状态
- controls.set({
- scale: 0.8, // 缩小到 80%
- opacity: 0, // 透明
- });
-
- // 2. 显示元素
- setIsVisible(true);
-
- // 3. 执行放大进入动画(从小到大缩放)
- // 使用 requestAnimationFrame 确保在下一帧执行动画,避免在当前帧被覆盖
- requestAnimationFrame(() => {
- controls.start({
- scale: 1, // 放大到正常大小
- opacity: 1, // 淡入
- transition: { duration: 0.5, ease: "easeOut" },
- });
- });
-
- // 4. 标记首次渲染完成
- isFirstRender.current = false;
- }
-
- // ----- 情况 B:从其他内容切换回来:放大进入(带延迟,等待其他内容离开动画完成) -----
- else if (!isVisible && !isAnimating.current) {
- // 标记开始执行动画(防止动画冲突)
- isAnimating.current = true;
-
- // 1. 显示元素
- setIsVisible(true);
-
- // 2. 执行放大进入动画
- // 使用双重 requestAnimationFrame:
- // - 第一层:确保状态更新已生效
- // - 第二层:确保 set() 操作完成后再执行 start()
- requestAnimationFrame(() => {
- // 设置元素到初始缩小状态
- controls.set({
- scale: 0.8,
- opacity: 0,
- });
-
- // 然后触发放大进入动画(带 0.3s 延迟,等待其他内容离开)
- // 延迟时间匹配其他内容的离开动画时长(0.3s),确保视觉上的连贯性
- requestAnimationFrame(() => {
- controls.start({
- scale: 1,
- opacity: 1,
- transition: { duration: 0.5, ease: "easeOut", delay: 0.3 },
- }).then(() => {
- // 动画完成标记
- isAnimating.current = false;
- });
- });
- });
- }
- }
-
- // ==================== 场景 2:不应该显示 IndexInfo(选择了港口或分公司)====================
- else if (isVisible && !isAnimating.current) {
- // 标记开始执行动画(防止动画冲突)
- isAnimating.current = true;
-
- // 执行缩小离开动画
- controls.start({
- scale: 0.8, // 缩小到 80%
- opacity: 0, // 淡出
- transition: { duration: 0.3, ease: "easeIn" },
- }).then(() => {
- // 动画完成后:
- // 1. 隐藏元素
- setIsVisible(false);
-
- // 2. 标记动画完成
- isAnimating.current = false;
- });
- }
- }, [currentContent.currentPort, currentContent.currentBranchOffice, controls, isVisible, type]);
-
- // ==================== Panel 类型的滑动动画逻辑 ====================
-
- // 辅助函数:计算动画的初始位置
- // @returns {number} 初始 X 轴位移值
- // - 左侧面板:返回 -300(从左侧外部进入)
- // - 右侧面板:返回 300(从右侧外部进入)
- const getInitialX = () => side === "left" ? -300 : 300;
-
- // ==================== 折叠/展开处理 ====================
-
- /**
- * 处理折叠按钮点击事件
- *
- * 折叠/展开动画流程:
- * 1. 展开:菜单按钮先从边缘滑到内容旁边 -> 内容面板从侧边滑入
- * 2. 折叠:内容面板先滑出到侧边 -> 菜单按钮从内容旁边滑到边缘
- *
- * 菜单按钮位置说明:
- * - 展开状态:x: 0,在内容区旁边(left: 465px 或 right: 465px)
- * - 折叠状态:左侧 x: -465(屏幕左边缘),右侧 x: 465(屏幕右边缘)
- */
- const handleCollapse = () => {
- if (isCollapsed) {
- // ==================== 展开逻辑 ====================
- // 标记开始手动处理折叠/展开动画(阻止 useEffect 干预)
- isManualCollapseAnimating.current = true;
-
- // 1. 先同步显示内容(确保显示最新的内容)
- setDisplayedContent(currentContent);
-
- // 2. 更新折叠状态
- setIsCollapsed(false);
-
- // 3. 显示元素
- setIsVisible(true);
-
- // 4. 同步执行菜单按钮动画(从边缘移到内容旁边)
- // 左侧按钮:left: 465px,展开时 x: 0(在内容区旁),折叠时 x: -465(向左到边缘)
- // 右侧按钮:right: 465px,展开时 x: 0(在内容区旁),折叠时 x: 465(向右到边缘)
- menuControls.start({
- x: 0, // 移到展开状态位置(内容区旁边)
- transition: { duration: 0.5, ease: "easeOut" },
- });
-
- // 5. 使用双重 requestAnimationFrame 确保 DOM 已挂载且 motion 控制器已准备好
- // 双重 RAF 的作用:
- // - 第一层:确保 React 完成状态更新和 DOM 渲染
- // - 第二层:确保 controls.set() 的设置被 Framer Motion 识别并应用
- requestAnimationFrame(() => {
- // 先设置到初始隐藏位置
- controls.set({
- x: getInitialX(),
- opacity: 0,
- });
-
- requestAnimationFrame(() => {
- // 然后执行进入动画
- controls.start({
- x: 0, // 移动到正常位置
- opacity: 1, // 淡入
- transition: { duration: 0.5, ease: "easeOut" },
- }).then(() => {
- // 动画完成后清除手动处理标记,允许 useEffect 再次响应状态变化
- isManualCollapseAnimating.current = false;
- });
- });
- });
- }
- else {
- // ==================== 折叠逻辑 ====================
- // 标记开始手动处理折叠/展开动画(阻止 useEffect 干预)
- isManualCollapseAnimating.current = true;
-
- // 同步执行菜单按钮动画(从内容旁边移到边缘)
- // 左侧按钮:从 left: 465px 移到 left: 0(x: -465 向左到屏幕边缘)
- // 右侧按钮:从 right: 465px 移到 right: 0(x: 465 向右到屏幕边缘)
- menuControls.start({
- x: side === "left" ? -465 : 465, // 左侧向左移动,右侧向右移动
- transition: { duration: 0.3, ease: "easeIn" },
- });
-
- // 执行离开动画(滑到侧边并淡出)
- // 注意:菜单按钮和内容面板的动画是并行执行的,同时开始
- controls.start({
- x: getInitialX(), // 移动到侧边外部
- opacity: 0, // 淡出
- transition: { duration: 0.3, ease: "easeIn" },
- }).then(() => {
- // 动画完成后:
- // 1. 隐藏元素(从 DOM 中移除或设置 display: none)
- setIsVisible(false);
-
- // 2. 更新折叠状态为 true
- setIsCollapsed(true);
-
- // 3. 清除手动处理标记,允许 useEffect 再次响应状态变化
- isManualCollapseAnimating.current = false;
- });
- }
- };
-
- // ==================== 主题:监听状态变化并执行动画 ====================
- //
- // useEffect 的职责:
- // 1. 监听 currentContent、isPureMap、isCollapsed 等状态变化
- // 2. 根据不同场景自动执行相应的动画
- // 3. 不处理手动折叠/展开动画(由 handleCollapse 处理)
- //
- // 场景优先级(从高到低):
- // 1. 纯地图模式:最高优先级,立即隐藏所有面板
- // 2. 折叠状态:静默更新数据,不执行动画
- // 3. 正常模式:根据内容变化、可见性等执行相应动画
- // ====================
-
- useEffect(() => {
- // 仅处理 panel 类型
- if (type !== "panel") {
- return;
- }
-
- // 如果正在手动处理折叠/展开动画,不执行 useEffect 逻辑
- // 这一步很关键,避免 handleCollapse 和 useEffect 之间的动画冲突
- if (isManualCollapseAnimating.current) {
- return;
- }
-
- // ==================== 场景 1:纯地图模式(最高优先级)====================
- if (isPureMap) {
- // 如果元素当前可见,执行离开动画并隐藏
- if (isVisible) {
- controls.start({
- x: getInitialX(), // 滑到侧边
- opacity: 0, // 淡出
- transition: { duration: 0.3, ease: "easeIn" },
- }).then(() => {
- // 动画完成后隐藏元素
- setIsVisible(false);
- });
- }
- // 纯地图模式下,不执行任何后续逻辑
- return;
- }
-
- // ==================== 场景 2:折叠状态 ====================
- if (isCollapsed) {
- // 检查内容是否发生变化
- const contentChanged = Object.keys(currentContent).some(
- key => displayedContent[key] !== currentContent[key],
- );
-
- // 静默更新内容(不执行动画,保持数据同步)
- // 目的:虽然面板是折叠的,但数据要始终保持最新
- // 这样展开时显示的就是最新内容,不需要额外加载
- if (contentChanged) {
- setDisplayedContent(currentContent);
- }
-
- // 确保元素保持隐藏(防止异常情况导致的意外显示)
- if (isVisible) {
- setIsVisible(false);
- }
-
- // 折叠状态下,不执行任何动画逻辑
- return;
- }
-
- // ==================== 场景 3:正常模式(非折叠、非纯地图)====================
- // 正常模式下,面板应该可见并根据内容变化执行动画
-
- // 检查内容是否真的变化了
- const contentChanged = Object.keys(currentContent).some(
- key => displayedContent[key] !== currentContent[key],
- );
-
- // ----- 情况 A:首次渲染 -----
- if (isFirstRender.current) {
- // 1. 设置元素到初始隐藏位置(侧边外部)
- controls.set({
- x: getInitialX(),
- opacity: 0,
- });
-
- // 2. 设置菜单按钮到初始位置(展开状态,在内容区旁边)
- // 菜单按钮不执行进入动画,直接显示在最终位置
- menuControls.set({
- x: 0,
- });
-
- // 3. 立即更新显示的内容
- setDisplayedContent(currentContent);
-
- // 4. 执行滑入动画(从侧边滑入到正常位置)
- controls.start({
- x: 0, // 移动到正常位置
- opacity: 1, // 淡入
- transition: { duration: 0.5, ease: "easeOut" },
- });
-
- // 5. 菜单按钮保持在展开状态位置,不需要移动
- // 这个 start() 调用确保 menuControls 与动画系统同步
- menuControls.start({
- x: 0,
- transition: { duration: 0.5, ease: "easeOut" },
- });
-
- // 6. 标记首次渲染完成
- isFirstRender.current = false;
- }
-
- // ----- 情况 B:内容变化(用户切换工具)-----
- // 动画流程:旧内容离开(0.3s)-> 更新数据 -> 新内容进入(0.5s)
- else if (contentChanged && !isAnimating.current) {
- // 标记开始执行动画(防止动画冲突)
- isAnimating.current = true;
-
- // 1. 先执行离开动画(旧内容离开)
- controls.start({
- x: getInitialX(), // 滑到侧边
- opacity: 0, // 淡出
- transition: { duration: 0.3, ease: "easeIn" },
- }).then(() => {
- // 离开动画完成后:
-
- // 2. 更新显示的内容为新内容
- setDisplayedContent(currentContent);
-
- // 3. 标记动画完成
- isAnimating.current = false;
-
- // 4. 执行进入动画(新内容进入)
- controls.start({
- x: 0, // 从侧边滑入到正常位置
- opacity: 1, // 淡入
- transition: { duration: 0.5, ease: "easeOut" },
- });
- });
- }
-
- // ----- 情况 C:从隐藏状态切换回来(退出纯地图模式)-----
- // 注意:展开操作由 handleCollapse 处理,不在这里处理
- // 这里处理的是退出纯地图模式后的自动显示
- else if (!isVisible && !isCollapsed) {
- // 1. 显示元素
- setIsVisible(true);
-
- // 2. 更新显示的内容
- setDisplayedContent(currentContent);
-
- // 3. 执行进入动画
- // 使用双重 requestAnimationFrame 确保动画正确执行
- requestAnimationFrame(() => {
- // 设置元素到初始隐藏位置
- controls.set({
- x: getInitialX(),
- opacity: 0,
- });
-
- // 然后触发进入动画
- requestAnimationFrame(() => {
- controls.start({
- x: 0, // 滑入到正常位置
- opacity: 1, // 淡入
- transition: { duration: 0.5, ease: "easeOut" },
- });
- });
- });
- }
- }, [currentContent, isCollapsed, isPureMap, controls, displayedContent, side, isVisible, type]);
-
- // ==================== 返回值 ====================
- //
- // 根据类型返回不同的属性:
- // - index 类型:只返回 controls 和 isVisible(简单动画)
- // - panel 类型:返回完整的控制属性(包括折叠功能)
- // ====================
-
- // 返回值根据类型不同而不同
- if (type === "index") {
- return {
- controls, // motion 动画控制器,用于 IndexInfo 的缩放动画
- isVisible, // 元素可见性状态,控制 IndexInfo 的显示/隐藏
- };
- }
-
- return {
- controls, // motion 动画控制器,用于内容面板的滑动动画
- displayedContent, // 延迟显示的内容(用于动画过渡)
- isVisible, // 元素可见性状态,控制内容面板的显示/隐藏
- isCollapsed, // 折叠状态,标识当前面板是否被折叠
- handleCollapse, // 折叠/展开处理函数,响应按钮点击事件
- menuControls, // 菜单按钮的动画控制器,用于控制折叠按钮的位置动画
- };
-}
diff --git a/src/pages/Container/Map/components/Header/index.js b/src/pages/Container/Map/components/Header/index.js
index 6b4548c..f1eef2a 100644
--- a/src/pages/Container/Map/components/Header/index.js
+++ b/src/pages/Container/Map/components/Header/index.js
@@ -1,4 +1,4 @@
-import { motion } from "motion/react";
+import { AnimatePresence, motion } from "motion/react";
import { useContext } from "react";
import backImg1 from "~/assets/images/map_bi/back1.png";
import backImg2 from "~/assets/images/map_bi/back2.png";
@@ -16,13 +16,19 @@ import {
resetAllBottomUtilsCheckMittKey,
resetBottomCurrentIndexMittKey,
} from "~/pages/Container/Map/js/mittKey";
-import { useHeaderAnimation } from "./useHeaderAnimation";
import "./index.less";
+// 标题切换时先让旧标题离开,再让新标题进入,保留原来的上下滑动和淡入淡出效果。
+const headerAnimation = {
+ initial: { y: -100, opacity: 0 },
+ animate: { y: 0, opacity: 1, transition: { duration: 0.5, ease: "easeOut" } },
+ exit: { y: -100, opacity: 0, transition: { duration: 0.3, ease: "easeIn" } },
+};
+
function Header(props) {
const { currentPort, currentBranchOffice, mapMethods, area } = useContext(Context);
- const { controls, displayedTitle } = useHeaderAnimation(props.headerTitle);
+ const isPortTitle = props.headerTitle === "秦港股份安全监管平台";
const onBack = () => {
sessionStorage.removeItem("mapCurrentBranchOfficeId");
@@ -73,23 +79,25 @@ function Header(props) {
return (
-
- {(displayedTitle === "秦港股份安全监管平台") && (
-
- )}
- {displayedTitle !== "秦港股份安全监管平台" && (
-
-

-
返回
-
- )}
- {displayedTitle}
-
-
+
+
+ {isPortTitle
+ ?
+ : (
+
+

+
返回
+
+ )}
+ {props.headerTitle}
+
+
+
);
}
diff --git a/src/pages/Container/Map/components/Header/useHeaderAnimation.js b/src/pages/Container/Map/components/Header/useHeaderAnimation.js
deleted file mode 100644
index 40c9fe2..0000000
--- a/src/pages/Container/Map/components/Header/useHeaderAnimation.js
+++ /dev/null
@@ -1,129 +0,0 @@
-import { useAnimation } from "motion/react";
-import { useEffect, useRef, useState } from "react";
-
-/**
- * Header 组件动画 Hook
- *
- * 功能说明:
- * 1. 监听标题内容变化,执行上下方向的动画效果
- * 2. 首次加载时从上方滑入,后续切换时旧标题向上离开 + 新标题向下进入
- * 3. 使用延迟显示模式,确保动画过渡完整可见
- *
- * 动画参数配置:
- * - 进入动画:y: -100 → 0(从上方向下滑入),opacity: 0 → 1(淡入)
- * 时长:0.5s,缓动:easeOut(先快后慢,营造舒适的进入体验)
- * - 离开动画:y: 0 → -100(从下方向上滑出),opacity: 1 → 0(淡出)
- * 时长:0.3s,缓动:easeIn(先慢后快,让用户感觉到响应迅速)
- *
- * 标题切换动画流程:
- * 1. 旧标题向上离开(0.3s):向上滑出并淡出
- * 2. 更新 displayedTitle 为新标题
- * 3. 新标题向下进入(0.5s):从上方滑入并淡入
- *
- * @param {string} currentTitle - 当前的标题内容
- * @returns {object} 返回包含以下属性的对象:
- * - controls: motion 动画控制器,绑定到 motion.header 的 animate 属性
- * - displayedTitle: 延迟显示的标题,用于在动画过渡期间保持视觉连续性
- */
-export function useHeaderAnimation(currentTitle) {
- // ==================== 状态管理 ====================
- //
- // 动画时长说明:
- // - 进入动画:0.5s,较慢,营造舒适的进入体验
- // - 离开动画:0.3s,较快,让用户感觉到响应迅速
- //
- // 缓动函数说明:
- // - easeOut:进入时使用,先快后慢,更自然
- // - easeIn:离开时使用,先慢后快,快速消失
- // ====================
-
- // motion 动画控制器,用于控制 motion.header 的动画
- const controls = useAnimation();
-
- // 标记是否是首次渲染
- // 作用:首次渲染时需要特殊处理,确保初始状态正确设置
- const isFirstRender = useRef(true);
-
- // 延迟显示的标题状态
- // 作用:在标题切换时,先显示旧标题,动画完成后再更新为新标题
- // 这样确保离开动画显示的是旧标题,进入动画显示的是新标题
- const [displayedTitle, setDisplayedTitle] = useState(currentTitle);
-
- // ==================== 主题:监听标题变化并执行动画 ====================
- //
- // useEffect 的职责:
- // 1. 监听 currentTitle 的变化
- // 2. 根据首次渲染或标题变化执行相应的动画
- //
- // 场景说明:
- // - 首次渲染:初始化并执行进入动画
- // - 标题变化:旧标题离开 -> 新标题进入
- // ====================
-
- useEffect(() => {
- // 检查标题是否发生变化
- const titleChanged = displayedTitle !== currentTitle;
-
- // ==================== 场景 1:首次渲染 ====================
- if (isFirstRender.current) {
- // 1. 设置元素到初始隐藏位置(上方外部)
- // controls.set() 是立即设置,不会触发动画过渡
- controls.set({
- y: -100, // Y 轴位置:从上方 100px 处开始
- opacity: 0, // 透明度:完全透明
- });
-
- // 2. 立即更新显示的标题
- // 因为首次渲染不需要保留旧标题,直接设置为传入的标题
- setDisplayedTitle(currentTitle);
-
- // 3. 执行滑入动画(从上方滑入到正常位置)
- controls.start({
- y: 0, // 移动到正常位置
- opacity: 1, // 淡入
- transition: {
- duration: 0.5, // 动画时长:0.5 秒
- ease: "easeOut", // 缓动函数:快速开始,缓慢结束,营造舒适的进入体验
- },
- });
-
- // 4. 标记首次渲染完成
- isFirstRender.current = false;
- }
-
- // ==================== 场景 2:标题变化 ====================
- else if (titleChanged) {
- // 1. 先执行离开动画(旧标题向上离开)
- controls.start({
- y: -100, // 向上移动到外部
- opacity: 0, // 淡出
- transition: {
- duration: 0.3, // 动画时长:0.3 秒(比进入动画短,显得更轻快)
- ease: "easeIn", // 缓动函数:缓慢开始,快速结束,让用户感觉到响应迅速
- },
- }).then(() => {
- // 离开动画完成后:
-
- // 2. 更新显示的标题为新标题
- // 此时旧标题已经完全离开视图,更新标题用户看不见
- setDisplayedTitle(currentTitle);
-
- // 3. 执行进入动画(新标题从上方进入)
- controls.start({
- y: 0, // 从上方移动到正常位置
- opacity: 1, // 淡入
- transition: {
- duration: 0.5, // 动画时长:0.5 秒
- ease: "easeOut", // 缓动函数:快速开始,缓慢结束
- },
- });
- });
- }
- }, [currentTitle, controls, displayedTitle]);
-
- // ==================== 返回值 ====================
- return {
- controls, // motion 动画控制器,绑定到 motion.header 的 animate 属性
- displayedTitle, // 延迟显示的标题,用于在动画过渡期间保持视觉连续性
- };
-}
diff --git a/src/pages/Container/Map/components/RightUtils/index.js b/src/pages/Container/Map/components/RightUtils/index.js
index 9318946..4841be5 100644
--- a/src/pages/Container/Map/components/RightUtils/index.js
+++ b/src/pages/Container/Map/components/RightUtils/index.js
@@ -1,7 +1,7 @@
import { message } from "antd";
import { produce } from "immer";
-import { motion } from "motion/react";
-import { useContext, useEffect, useState } from "react";
+import { AnimatePresence, motion } from "motion/react";
+import { useContext, useEffect, useRef, useState } from "react";
import tooltipImg2 from "~/assets/images/map_bi/right_utils/branch_office/bg11.png";
import buttonBg from "~/assets/images/map_bi/right_utils/branch_office/button.png";
import tooltipImg1 from "~/assets/images/map_bi/right_utils/port/tooltip.png";
@@ -14,33 +14,40 @@ import {
deletePeoplePositionPointMittKey,
resetAllBottomUtilsCheckMittKey,
} from "~/pages/Container/Map/js/mittKey";
-import { useChildMenuAnimation } from "./useChildMenuAnimation";
-import { useRightUtilsAnimation } from "./useRightUtilsAnimation";
import { utilsList } from "./utilsList";
import "./index.less";
+const rightUtilsAnimation = {
+ initial: { x: 100, opacity: 0 },
+ animate: { x: 0, opacity: 1, transition: { duration: 0.5, ease: "easeOut" } },
+ exit: { x: 100, opacity: 0, transition: { duration: 0.3, ease: "easeIn" } },
+};
+
+const childMenuAnimation = {
+ visible: {
+ y: [80, -15, 5, 0],
+ opacity: [0, 1, 1, 1],
+ scale: [0.9, 1.05, 0.98, 1],
+ transition: { duration: 0.7, times: [0, 0.5, 0.75, 1], ease: [0.34, 1.56, 0.64, 1] },
+ },
+ hidden: {
+ y: [0, -20, 10, 100],
+ opacity: [1, 1, 0.8, 0],
+ scale: [1, 1.02, 0.95, 0.85],
+ transition: { duration: 0.5, times: [0, 0.2, 0.5, 1], ease: [0.68, -0.6, 0.32, 1.6] },
+ },
+};
+
function RightUtils(props) {
const { currentPort, mapMethods, pureMap, currentBranchOffice, bottomUtilsCurrentIndex } = useContext(Context);
const [list, setList] = useState(utilsList);
const [isShowChildLevel, setIsShowChildLevel] = useState(true);
-
- const {
- controls: rightUtilsControls,
- displayedMode: rightUtilsDisplayedMode,
- isVisible: rightUtilsIsVisible,
- } = useRightUtilsAnimation(
- currentPort && !currentBranchOffice,
- currentBranchOffice && bottomUtilsCurrentIndex !== -1,
- );
-
- const {
- controls: childMenuControls,
- isAnimating: isChildMenuAnimating,
- tryStartAnimation: tryStartChildMenuAnimation,
- } = useChildMenuAnimation(
- currentBranchOffice && bottomUtilsCurrentIndex !== -1 && isShowChildLevel,
- );
+ const childMenuAnimationLockRef = useRef(true);
+ const [isChildMenuAnimating, setIsChildMenuAnimating] = useState(true);
+ const rightUtilsMode = currentPort && !currentBranchOffice
+ ? "port"
+ : currentBranchOffice && bottomUtilsCurrentIndex !== -1 ? "branchOffice" : null;
useEffect(() => {
if (bottomUtilsCurrentIndex === -1)
@@ -146,7 +153,7 @@ function RightUtils(props) {
};
const renderPortUtils = () => {
- if (rightUtilsDisplayedMode === "port") {
+ if (rightUtilsMode === "port") {
return (
<>
{list.map((item, index) => (
@@ -166,10 +173,23 @@ function RightUtils(props) {
};
const renderBranchOfficeUtils = () => {
- if (rightUtilsDisplayedMode === "branchOffice") {
+ if (rightUtilsMode === "branchOffice") {
return (
<>
-
+ {
+ childMenuAnimationLockRef.current = true;
+ setIsChildMenuAnimating(true);
+ }}
+ onAnimationComplete={() => {
+ childMenuAnimationLockRef.current = false;
+ setIsChildMenuAnimating(false);
+ }}
+ >
{list.map((item, index) => (
{
- if (!tryStartChildMenuAnimation())
+ if (childMenuAnimationLockRef.current)
return;
+ // 点击时同步占用锁,避免等待下一次渲染期间发生连续点击。
+ childMenuAnimationLockRef.current = true;
+ setIsChildMenuAnimating(true);
setIsShowChildLevel(visible => !visible);
}}
>
@@ -200,17 +223,18 @@ function RightUtils(props) {
return (
-
- {rightUtilsIsVisible && (
- <>
+
+ {rightUtilsMode && (
+
{renderPortUtils()}
{renderBranchOfficeUtils()}
- >
+
)}
-
+
);
}
diff --git a/src/pages/Container/Map/components/RightUtils/useChildMenuAnimation.js b/src/pages/Container/Map/components/RightUtils/useChildMenuAnimation.js
deleted file mode 100644
index b56848c..0000000
--- a/src/pages/Container/Map/components/RightUtils/useChildMenuAnimation.js
+++ /dev/null
@@ -1,230 +0,0 @@
-import { useAnimation } from "motion/react";
-import { useEffect, useRef, useState } from "react";
-
-/**
- * RightUtils 子菜单动画 Hook
- *
- * 功能说明:
- * 1. 监听子菜单的展开/收起状态变化,执行炫酷的弹跳动画效果
- * 2. 首次加载时从下向上弹跳进入,收起时向上弹跳后向下离开
- * 3. 使用位置、缩放、透明度三重动画效果,营造生动的视觉体验
- *
- * 动画参数配置:
- * - 展开动画(进入):
- * - 位置关键帧:y: [80, -15, 5, 0](从下方80px -> 冲到上方15px -> 回落到5px -> 稳定到0)
- * - 缩放关键帧:scale: [0.9, 1.05, 0.98, 1](90% -> 105% -> 98% -> 100%)
- * - 透明度关键帧:opacity: [0, 1, 1, 1](透明 -> 不透明)
- * - 时长:0.7s,缓动:[0.34, 1.56, 0.64, 1](强弹性曲线,产生过冲和回弹)
- *
- * - 收起动画(离开):
- * - 位置关键帧:y: [0, -20, 10, 100](正常位置 -> 向上弹跳20px -> 回落到10px -> 向下离开到100px)
- * - 缩放关键帧:scale: [1, 1.02, 0.95, 0.85](100% -> 102% -> 95% -> 85%)
- * - 透明度关键帧:opacity: [1, 1, 0.8, 0](不透明 -> 淡出)
- * - 时长:0.5s,缓动:[0.68, -0.6, 0.32, 1.6](超弹性曲线,产生夸张的反向弹跳)
- *
- * 关键帧时间点分布:
- * - 展开动画:times: [0, 0.5, 0.75, 1](0% -> 50% -> 75% -> 100%)
- * - 收起动画:times: [0, 0.2, 0.5, 1](0% -> 20% -> 50% -> 100%)
- *
- * @param {boolean} isVisible - 子菜单是否可见
- * - true: 子菜单应该展开,执行展开动画
- * - false: 子菜单应该收起,执行收起动画
- * @returns {object} 返回包含以下属性的对象:
- * - controls: motion 动画控制器,绑定到 motion.div 的 animate 属性
- */
-export function useChildMenuAnimation(isVisible) {
- // ==================== 状态管理 ====================
- //
- // 动画时长说明:
- // - 展开动画:0.7s,较慢,展示完整的弹跳效果
- // - 收起动画:0.5s,较快,让用户感觉到响应迅速
- //
- // 缓动函数说明:
- // - 展开动画:[0.34, 1.56, 0.64, 1],强弹性曲线,产生明显的过冲和回弹
- // - 收起动画:[0.68, -0.6, 0.32, 1.6],超弹性曲线,产生夸张的反向弹跳
- //
- // 三重动画效果:
- // - 位置动画(y):产生弹跳的物理运动效果
- // - 缩放动画(scale):增强弹跳的视觉冲击力
- // - 透明度动画(opacity):平滑的进入和离开
- // ====================
-
- // motion 动画控制器,用于控制 motion.div 的动画
- const controls = useAnimation();
-
- // 标记子菜单是否已经执行过展开动画
- // 作用:防止重复执行展开动画,只在首次显示时执行
- const isExpanded = useRef(false);
-
- // 标记是否是首次渲染
- // 作用:首次渲染时需要特殊处理,确保初始状态正确设置
- const isFirstRender = useRef(true);
-
- // 使用 ref 同步锁定点击,避免 React 状态尚未更新时连续点击穿透;state 负责更新按钮禁用样式。
- // 初始即需要展开时直接锁定,避免首次 effect 执行前的点击空窗。
- const animationLockRef = useRef(isVisible);
- const [isAnimating, setIsAnimating] = useState(isVisible);
-
- const lockAnimation = () => {
- if (animationLockRef.current)
- return false;
- animationLockRef.current = true;
- setIsAnimating(true);
- return true;
- };
-
- const unlockAnimation = () => {
- animationLockRef.current = false;
- setIsAnimating(false);
- };
-
- // ==================== 主题:监听可见性变化并执行弹跳动画 ====================
- //
- // useEffect 的职责:
- // 1. 监听 isVisible 的变化
- // 2. 根据展开/收起状态执行相应的弹跳动画
- //
- // 场景说明:
- // - 首次渲染:初始化并执行展开动画
- // - 展开状态:执行从下向上的弹跳进入动画
- // - 收起状态:执行向上弹跳后向下离开的动画
- // ====================
-
- useEffect(() => {
- // ==================== 场景 1:首次渲染 ====================
- if (isFirstRender.current) {
- if (isVisible) {
- lockAnimation();
- // 执行展开动画:从下向上炫酷弹跳进入
- controls.start({
- // Y 轴位置关键帧(四个关键帧实现弹跳效果):
- // ① 80:从下方 80px 处开始
- // ② -15:快速冲到上方 15px(超出目标位置,产生过冲效果)
- // ③ 5:轻微回落到下方 5px
- // ④ 0:最终稳定到正常位置
- y: [80, -15, 5, 0],
-
- // 透明度关键帧:
- // ① 0:初始完全透明
- // ② 1:快速变为不透明
- // ③ 1:保持不透明
- // ④ 1:保持不透明
- opacity: [0, 1, 1, 1],
-
- // 缩放关键帧(配合位置变化,增强弹跳感):
- // ① 0.9:从 90% 大小开始(轻微缩小)
- // ② 1.05:放大到 105%(轻微放大,配合位置过冲)
- // ③ 0.98:缩小到 98%(轻微缩小,配合位置回落)
- // ④ 1:回到正常大小 100%
- scale: [0.9, 1.05, 0.98, 1],
-
- transition: {
- duration: 0.7, // 总动画时长:0.7 秒
-
- // 关键帧时间点分布:
- // ① 0:0% 时(0s)在 y=80 处
- // ② 0.5:50% 时(0.35s)在 y=-15 处(过冲峰值)
- // ③ 0.75:75% 时(0.525s)在 y=5 处(回落)
- // ④ 1:100% 时(0.7s)在 y=0 处(稳定)
- times: [0, 0.5, 0.75, 1],
-
- // 强弹性贝塞尔曲线 [0.34, 1.56, 0.64, 1]:
- // - 第一个值 0.34:控制起始加速度(较小,平稳开始)
- // - 第二个值 1.56:控制起始减速度(大于 1,产生过冲)
- // - 第三个值 0.64:控制结束加速度(小于 1,平滑过渡)
- // - 第四个值 1:控制结束减速度(正常结束)
- // 这个曲线会产生明显的过冲和回弹效果
- ease: [0.34, 1.56, 0.64, 1],
- },
- }).then(() => {
- // 动画完成后标记状态
- isExpanded.current = true; // 标记已展开
- isFirstRender.current = false; // 标记首次渲染完成
- unlockAnimation();
- });
- }
- else {
- // 如果首次渲染时不需要显示,直接标记首次渲染完成
- isFirstRender.current = false;
- }
- return;
- }
-
- // ==================== 场景 2:展开动画(非首次渲染)====================
- if (isVisible && !isExpanded.current) {
- // 正常点击时锁已由按钮同步占用;外部状态触发动画时在这里补充锁定。
- lockAnimation();
- // 从下向上炫酷弹跳进入
- controls.start({
- y: [80, -15, 5, 0], // 位置:下方80px -> 上方15px(过冲)-> 下方5px(回落)-> 正常
- opacity: [0, 1, 1, 1], // 透明度:透明 -> 不透明 -> 不透明 -> 不透明
- scale: [0.9, 1.05, 0.98, 1], // 缩放:90% -> 105% -> 98% -> 100%
- transition: {
- duration: 0.7, // 总动画时长:0.7 秒
- times: [0, 0.5, 0.75, 1], // 时间点分布
- ease: [0.34, 1.56, 0.64, 1], // 强弹性贝塞尔曲线
- },
- }).then(() => {
- isExpanded.current = true; // 标记已展开
- unlockAnimation();
- });
- }
-
- // ==================== 场景 3:收起动画 ====================
- else if (!isVisible && isExpanded.current) {
- lockAnimation();
- // 从上向下炫酷弹跳离开
- controls.start({
- // Y 轴位置关键帧:
- // ① 0:从正常位置开始
- // ② -20:向上弹跳到上方 20px(反向过冲)
- // ③ 10:回落到下方 10px
- // ④ 100:向下离开到 100px 处
- y: [0, -20, 10, 100],
-
- // 透明度关键帧:
- // ① 1:初始不透明
- // ② 1:弹跳过程中保持不透明
- // ③ 0.8:开始淡出
- // ④ 0:完全透明
- opacity: [1, 1, 0.8, 0],
-
- // 缩放关键帧:
- // ① 1:从正常大小开始
- // ② 1.02:轻微放大到 102%(配合向上弹跳)
- // ③ 0.95:缩小到 95%(配合回落)
- // ④ 0.85:继续缩小到 85%(配合向下离开)
- scale: [1, 1.02, 0.95, 0.85],
-
- transition: {
- duration: 0.5, // 总动画时长:0.5 秒(比进入动画短,显得更轻快)
- // 关键帧时间点分布:
- // ① 0:0% 时(0s)在正常位置 y=0
- // ② 0.2:20% 时(0.1s)在 y=-20 处(快速弹起)
- // ③ 0.5:50% 时(0.25s)在 y=10 处(回落)
- // ④ 1:100% 时(0.5s)在 y=100 处(完全离开)
- times: [0, 0.2, 0.5, 1],
-
- // 超弹性曲线 [0.68, -0.6, 0.32, 1.6]:
- // - 第一个值 0.68:较大的起始加速度
- // - 第二个值 -0.6:负值表示反向过冲(产生夸张的向上弹跳)
- // - 第三个值 0.32:较小的结束加速度
- // - 第四个值 1.6:大于 1 的结束减速度(产生回弹效果)
- // 这个曲线会产生夸张的反向弹跳效果
- ease: [0.68, -0.6, 0.32, 1.6],
- },
- }).then(() => {
- isExpanded.current = false; // 标记已收起
- unlockAnimation();
- });
- }
- }, [isVisible, controls]);
-
- // ==================== 返回值 ====================
- return {
- controls, // motion 动画控制器,绑定到 motion.div 的 animate 属性
- isAnimating,
- // 点击处理器调用后会立即占用锁,确保同一帧内的连续点击也会被拦截。
- tryStartAnimation: lockAnimation,
- };
-}
diff --git a/src/pages/Container/Map/components/RightUtils/useRightUtilsAnimation.js b/src/pages/Container/Map/components/RightUtils/useRightUtilsAnimation.js
deleted file mode 100644
index 378e1e6..0000000
--- a/src/pages/Container/Map/components/RightUtils/useRightUtilsAnimation.js
+++ /dev/null
@@ -1,232 +0,0 @@
-import { useAnimation } from "motion/react";
-import { useEffect, useRef, useState } from "react";
-
-/**
- * RightUtils 组件动画 Hook
- *
- * 功能说明:
- * 1. 监听显示/隐藏状态变化,执行从右侧进入/离开的滑动动画
- * 2. 支持模式切换(港口工具 ↔ 分公司工具),实现平滑过渡
- * 3. 支持首次加载动画,初始化时自动进入
- * 4. 使用延迟显示模式,确保动画过渡完整可见
- *
- * 动画参数配置:
- * - 进入动画:x: 100 → 0(从右向左滑入),opacity: 0 → 1(淡入)
- * 时长:0.5s,缓动:easeOut(先快后慢,营造舒适的进入体验)
- * - 离开动画:x: 0 → 100(从左向右滑出),opacity: 1 → 0(淡出)
- * 时长:0.3s,缓动:easeIn(先慢后快,让用户感觉到响应迅速)
- *
- * 模式切换动画流程:
- * 1. 旧模式离开(0.3s):向右滑出并淡出
- * 2. 更新 displayedMode 为新模式
- * 3. 新模式进入(0.5s):从右侧滑入并淡入
- *
- * @param {boolean} shouldShowPort - 是否应该显示港口工具
- * @param {boolean} shouldShowBranchOffice - 是否应该显示分公司工具
- * @returns {object} 返回包含以下属性的对象:
- * - controls: motion 动画控制器,绑定到 motion.div 的 animate 属性
- * - displayedMode: 延迟显示的模式('port' | 'branchOffice' | null),用于决定渲染内容
- * - isVisible: 元素可见性状态,用于控制内容的显示/隐藏
- */
-export function useRightUtilsAnimation(shouldShowPort, shouldShowBranchOffice) {
- // ==================== 状态管理 ====================
- //
- // 动画时长说明:
- // - 进入动画:0.5s,较慢,营造舒适的进入体验
- // - 离开动画:0.3s,较快,让用户感觉到响应迅速
- //
- // 缓动函数说明:
- // - easeOut:进入时使用,先快后慢,更自然
- // - easeIn:离开时使用,先慢后快,快速消失
- // ====================
-
- // motion 动画控制器,用于控制 motion.div 的动画
- const controls = useAnimation();
-
- // 标记是否是首次渲染
- // 作用:首次渲染时需要特殊处理,确保初始状态正确设置
- const isFirstRender = useRef(true);
-
- // 标记是否正在执行动画
- // 作用:防止在动画执行过程中触发新的动画,避免动画冲突
- const isAnimating = useRef(false);
-
- // 延迟显示的模式状态
- // 作用:在模式切换时,先显示旧模式,动画完成后再更新为新模式
- // 这样确保用户看到完整的离开动画和进入动画,实现平滑过渡
- const [displayedMode, setDisplayedMode] = useState(
- shouldShowPort ? "port" : shouldShowBranchOffice ? "branchOffice" : null,
- );
-
- // 元素可见性状态
- // 注意:motion.div 始终挂载,isVisible 只控制内容的显示/隐藏
- // 这样可以保证动画控制器始终有效,避免重复创建/销毁
- const [isVisible, setIsVisible] = useState(false);
-
- // ==================== 主题:监听状态变化并执行动画 ====================
- //
- // useEffect 的职责:
- // 1. 监听 shouldShowPort 和 shouldShowBranchOffice 的变化
- // 2. 根据不同场景自动执行相应的动画
- //
- // 场景说明:
- // - 首次渲染:初始化并执行进入动画
- // - 隐藏状态:执行离开动画并隐藏元素
- // - 模式切换:旧模式离开 -> 新模式进入
- // - 显示状态:从隐藏切换到可见
- // ====================
-
- useEffect(() => {
- // ==================== 计算当前应该显示的模式 ====================
- // 优先级:港口工具 > 分公司工具 > 隐藏
- const currentMode = shouldShowPort ? "port" : shouldShowBranchOffice ? "branchOffice" : null;
-
- // ==================== 场景 1:首次渲染 ====================
- if (isFirstRender.current) {
- if (currentMode) {
- // ----- 步骤 1:立即更新显示的模式 -----
- // 设置 displayedMode 为当前模式,让组件知道应该渲染哪种内容
- setDisplayedMode(currentMode);
-
- // ----- 步骤 2:设置初始动画状态 -----
- // controls.set() 会将初始状态保存到 controls 对象中
- // 当 motion.div 挂载时,会读取这个初始状态
- controls.set({
- x: 100, // X 轴位置:从右侧 100px 处开始
- opacity: 0, // 透明度:完全透明
- });
-
- // ----- 步骤 3:显示元素,触发 motion.div 挂载 -----
- // 设置 isVisible 为 true,触发组件重渲染
- // motion.div 会挂载到 DOM,并读取 controls 的初始状态
- setIsVisible(true);
-
- // ----- 步骤 4:执行进入动画 -----
- // 从初始位置(x: 100, opacity: 0)动画到正常位置(x: 0, opacity: 1)
- controls.start({
- x: 0, // 移动到正常位置(X 轴 0)
- opacity: 1, // 变为完全不透明
- transition: {
- duration: 0.5, // 动画时长:0.5 秒
- ease: "easeOut", // 缓动函数:快速开始,缓慢结束,营造舒适的进入体验
- },
- }).then(() => {
- // ----- 步骤 5:动画完成,标记首次渲染结束 -----
- isFirstRender.current = false;
- });
- }
- else {
- // 如果首次渲染时不需要显示(currentMode 为 null)
- // 直接标记首次渲染完成,不需要执行动画
- isFirstRender.current = false;
- }
- return;
- }
-
- // ==================== 场景 2:隐藏状态(当前没有工具应该显示)====================
- if (!currentMode) {
- if (isVisible && !isAnimating.current) {
- // 标记开始执行动画(防止动画冲突)
- isAnimating.current = true;
-
- // 执行离开动画
- controls.start({
- x: 100, // 向右移动到 100px 处
- opacity: 0, // 同时淡出
- transition: {
- duration: 0.3, // 动画时长:0.3 秒(比进入动画短,显得更轻快)
- ease: "easeIn", // 缓动函数:缓慢开始,快速结束,让用户感觉到响应迅速
- },
- }).then(() => {
- // 离开动画完成后:
- // 1. 隐藏内容(motion.div 仍然挂载,避免重新创建)
- setIsVisible(false);
-
- // 2. 清空显示的模式
- setDisplayedMode(null);
-
- // 3. 解除动画锁定,允许后续动画
- isAnimating.current = false;
- });
- }
- return;
- }
-
- // ==================== 场景 3:模式切换或显示 ====================
-
- // 检查模式是否发生了变化(港口 ↔ 分公司)
- const modeChanged = displayedMode !== currentMode;
-
- // ----- 情况 A:模式切换(港口 ↔ 分公司)-----
- if (modeChanged && !isAnimating.current && isVisible) {
- // 标记开始执行动画(防止动画冲突)
- isAnimating.current = true;
-
- // 1. 执行离开动画(旧模式离开)
- controls.start({
- x: 100, // 向右移动到 100px 处
- opacity: 0, // 同时淡出
- transition: {
- duration: 0.3, // 动画时长:0.3 秒
- ease: "easeIn", // 缓动函数
- },
- }).then(() => {
- // 离开动画完成后:
-
- // 2. 更新显示的模式为新模式
- setDisplayedMode(currentMode);
-
- // 3. 设置元素到初始位置(右侧外部)
- controls.set({
- x: 100, // 确保元素在右侧 100px 处
- opacity: 0, // 确保元素是透明的
- });
-
- // 4. 执行进入动画(新模式进入)
- controls.start({
- x: 0, // 移动到正常位置
- opacity: 1, // 淡入
- transition: {
- duration: 0.5, // 动画时长:0.5 秒
- ease: "easeOut", // 缓动函数
- },
- }).then(() => {
- // 动画完成后解除锁定
- isAnimating.current = false;
- });
- });
- }
-
- // ----- 情况 B:元素不可见,但应该显示(例如:从隐藏状态切换到显示)-----
- else if (!isVisible && currentMode) {
- // 1. 显示元素
- setIsVisible(true);
-
- // 2. 更新显示的模式
- setDisplayedMode(currentMode);
-
- // 3. 设置元素到初始位置
- controls.set({
- x: 100, // 右侧 100px 处
- opacity: 0, // 透明
- });
-
- // 4. 执行进入动画
- controls.start({
- x: 0, // 移动到正常位置
- opacity: 1, // 淡入
- transition: {
- duration: 0.5, // 动画时长:0.5 秒
- ease: "easeOut", // 缓动函数
- },
- });
- }
- }, [shouldShowPort, shouldShowBranchOffice, controls, displayedMode, isVisible]);
-
- // ==================== 返回值 ====================
- return {
- controls, // motion 动画控制器,绑定到 motion.div 的 animate 属性
- displayedMode, // 延迟显示的模式,用于决定渲染 port 还是 branchOffice 内容
- isVisible, // 元素可见性状态,用于控制内容的显示/隐藏
- };
-}