diff --git a/src/pages/Container/Map/components/BottomUtils/index.js b/src/pages/Container/Map/components/BottomUtils/index.js index dc81854..b647c49 100644 --- a/src/pages/Container/Map/components/BottomUtils/index.js +++ b/src/pages/Container/Map/components/BottomUtils/index.js @@ -1,5 +1,5 @@ +import { AnimatePresence, motion } from "motion/react"; import { useContext, useEffect, useState } from "react"; -import { CSSTransition } from "react-transition-group"; import bg7 from "~/assets/images/map_bi/bottom_utils/bg7.png"; import bg8 from "~/assets/images/map_bi/bottom_utils/bg8.png"; import titleImg from "~/assets/images/map_bi/bottom_utils/title.png"; @@ -9,11 +9,13 @@ import { Context } from "~/pages/Container/Map/js/context"; import mitt from "~/pages/Container/Map/js/mitt"; import { deletePeoplePositionPointMittKey, - initBottomUtilsMittKey, resetAllBottomUtilsCheckMittKey, resetBottomCurrentIndexMittKey, } from "~/pages/Container/Map/js/mittKey"; import { portUtilsList } from "./portUtilsList"; +import { useBottomUtilsAnimation } from "./useBottomUtilsAnimation"; +import { useBranchOfficeUtilsAnimation } from "./useBranchOfficeUtilsAnimation"; +import { usePortUtilsAnimation } from "./usePortUtilsAnimation"; import "./index.less"; function BottomUtils(props) { @@ -21,9 +23,33 @@ function BottomUtils(props) { const [list, setList] = useState([]); - const initList = () => { - setList(!currentBranchOffice ? portUtilsList : branchOfficeUtilsList); - }; + const { + controls: bottomUtilsControls, + displayedMode: bottomUtilsDisplayedMode, + isVisible: bottomUtilsIsVisible, + } = useBottomUtilsAnimation( + !pureMap && currentPort && !currentBranchOffice, + !pureMap && currentPort && currentBranchOffice, + ); + + const { + containerVariants: branchOfficeUtilsContainerVariants, + itemVariants: branchOfficeUtilsChildItemVariants, + } = useBranchOfficeUtilsAnimation(); + + const { + parentControls: portUtilsParentControls, + childControls: portUtilsChildControls, + optionRefs: portUtilsOptionRefs, + isAnimating: portUtilsIsAnimating, + hasInteracted: portUtilsHasInteracted, + shouldHideInactive: portUtilsShouldHideInactive, + animationConfig: portUtilsAnimationConfig, + } = usePortUtilsAnimation( + portUtilsList.length, + bottomUtilsCurrentIndex, + bottomUtilsDisplayedMode === "port", + ); const resetAllCheck = () => { setList((prevList) => { @@ -35,14 +61,16 @@ function BottomUtils(props) { }; useEffect(() => { - mitt.on(initBottomUtilsMittKey, () => { - initList(); - }); - - return () => { - mitt.off(initBottomUtilsMittKey); - }; - }, [currentBranchOffice]); + if (bottomUtilsDisplayedMode === "port") { + setList(portUtilsList); + } + else if (bottomUtilsDisplayedMode === "branchOffice") { + setList(branchOfficeUtilsList); + } + else { + setList([]); + } + }, [bottomUtilsDisplayedMode]); useEffect(() => { mitt.on(resetBottomCurrentIndexMittKey, () => { @@ -74,16 +102,65 @@ function BottomUtils(props) { }); }; - const renderBranchOfficeUtils = () => { - return ( -
- {list.map((item, index) => { - const isCurrentActive = bottomUtilsCurrentIndex === index; - // const hasActiveChildren = bottomUtilsCurrentIndex !== -1; - // if (hasActiveChildren && !isCurrentActive) { - // return null; - // } + const renderPortUtils = () => { + if (bottomUtilsDisplayedMode === "port") { + return list.map((item, index) => { + const isCurrentActive = bottomUtilsCurrentIndex === index; + const hasActiveChildren = bottomUtilsCurrentIndex !== -1; + const isAllowClick = hasActiveChildren && !isCurrentActive && portUtilsShouldHideInactive; + return ( + (portUtilsOptionRefs.current[index] = el)} + className="option" + animate={portUtilsHasInteracted ? portUtilsParentControls[index] : false} + onClick={() => !portUtilsIsAnimating && !isAllowClick && optionsClick(index)} + style={{ + cursor: isAllowClick ? "default" : "pointer", + }} + > + +
+ {item.label} +
+ {isCurrentActive && ( + + {item.list?.map((item1, index1) => { + return ( + { + e.stopPropagation(); + optionsItemsClick(index, index1, item, item1); + }} + > + +
{item1.label}
+
+ ); + })} +
+ )} +
+ ); + }); + } + }; + + const renderBranchOfficeUtils = () => { + if (bottomUtilsDisplayedMode === "branchOffice") { + return ( + list.map((item, index) => { + const isCurrentActive = bottomUtilsCurrentIndex === index; return (
optionsClick(index)} >
{item.label}
- 0)} - > -
- {item.list?.map((item1, index1) => ( -
{ - e.stopPropagation(); - optionsItemsClick(index, index1, item, item1); - }} - > -
{item1.label}
-
- ))} -
-
+ + {(isCurrentActive && item.list?.length > 0) && ( + + {item.list?.map((item1, index1) => { + return ( + { + e.stopPropagation(); + optionsItemsClick(index, index1, item, item1); + }} + variants={branchOfficeUtilsChildItemVariants} + > +
{item1.label}
+
+ ); + })} +
+ )} +
); - })} -
- ); - }; - - const renderPortUtils = () => { - return ( -
- {list.map((item, index) => { - const isCurrentActive = bottomUtilsCurrentIndex === index; - const hasActiveChildren = bottomUtilsCurrentIndex !== -1; - if (hasActiveChildren && !isCurrentActive) { - return null; - } - - return ( -
optionsClick(index)}> - -
- {item.label} -
- {(isCurrentActive && item.list?.length > 0) && ( -
- {item.list?.map((item1, index1) => ( -
{ - e.stopPropagation(); - optionsItemsClick(index, index1, item, item1); - }} - > - -
{item1.label}
-
- ))} -
- )} -
- ); - })} -
- ); - }; - - const renderUtils = () => { - if (pureMap || !currentPort) - return (
); - - return (!currentBranchOffice ? renderPortUtils() : renderBranchOfficeUtils()); + }) + ); + } }; return (
- {renderUtils()} + + {bottomUtilsIsVisible && ( + <> + {renderPortUtils()} + {renderBranchOfficeUtils()} + + )} +
); } diff --git a/src/pages/Container/Map/components/BottomUtils/index.less b/src/pages/Container/Map/components/BottomUtils/index.less index 436bcbf..a1dc8d9 100644 --- a/src/pages/Container/Map/components/BottomUtils/index.less +++ b/src/pages/Container/Map/components/BottomUtils/index.less @@ -33,7 +33,7 @@ font-size: 14px; } - .items { + .child_items { transform-origin: left; display: flex; position: absolute; @@ -52,7 +52,7 @@ border-right: none; padding: 6px 50px; - .item { + .child_item { padding: 0 20px; text-align: center; diff --git a/src/pages/Container/Map/components/BottomUtils/useBottomUtilsAnimation.js b/src/pages/Container/Map/components/BottomUtils/useBottomUtilsAnimation.js new file mode 100644 index 0000000..35fec07 --- /dev/null +++ b/src/pages/Container/Map/components/BottomUtils/useBottomUtilsAnimation.js @@ -0,0 +1,201 @@ +import { useAnimation } from "motion/react"; +import { useEffect, useRef, useState } from "react"; + +/** + * BottomUtils 组件动画 Hook + * + * 功能说明: + * 1. 监听显示/隐藏状态变化 + * 2. 执行从下向上进入、从上向下离开的动画效果 + * 3. 支持模式切换(港口工具 ↔ 分公司工具) + * 4. 支持首次加载动画 + * + * 动画效果: + * - 进入动画:从下方 100px 处滑入,同时淡入(y: 100 → 0, opacity: 0 → 1) + * - 离开动画:向下方 100px 处滑出,同时淡出(y: 0 → 100, opacity: 1 → 0) + * + * 模式切换流程: + * 1. 旧模式执行离开动画(0.3秒) + * 2. 离开动画完成后,更新 displayedMode 为新模式 + * 3. 设置元素到初始位置(下方 100px) + * 4. 执行新模式的进入动画(0.5秒) + * + * @param {boolean} shouldShowPort - 是否应该显示港口工具 + * @param {boolean} shouldShowBranchOffice - 是否应该显示分公司工具 + * @returns {object} 返回包含以下属性的对象: + * - controls: motion 动画控制器 + * - displayedMode: 延迟显示的模式('port' | 'branchOffice' | null) + * - isVisible: 元素可见性状态 + */ +export function useBottomUtilsAnimation(shouldShowPort, shouldShowBranchOffice) { + // ==================== 状态和引用管理 ==================== + + // 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(() => { + // ==================== 计算当前应该显示的模式 ==================== + // 根据 shouldShowPort 和 shouldShowBranchOffice 判断当前应该显示哪个模式 + const currentMode = shouldShowPort ? "port" : shouldShowBranchOffice ? "branchOffice" : null; + + // ==================== 首次渲染 ==================== + if (isFirstRender.current) { + if (currentMode) { + // ===== 步骤 1:立即更新显示的模式 ===== + // 设置 displayedMode 为当前模式,让组件知道应该渲染哪种内容 + setDisplayedMode(currentMode); + + // ===== 步骤 2:设置初始动画状态 ===== + // 此时 motion.div 还没有挂载(因为 isVisible 还是 false) + // controls.set() 会将初始状态保存到 controls 对象中 + // 当 motion.div 挂载时,会读取这个初始状态 + controls.set({ + y: 100, // Y 轴位置:从下方 100px 处开始 + opacity: 0, // 透明度:完全透明 + }); + + // ===== 步骤 3:显示元素,触发 motion.div 挂载 ===== + // 设置 isVisible 为 true,触发组件重渲染 + // motion.div 会挂载到 DOM,并读取 controls 的初始状态 { y: 100, opacity: 0 } + setIsVisible(true); + + // ===== 步骤 4:执行进入动画 ===== + // 从初始位置(y: 100, opacity: 0)动画到正常位置(y: 0, opacity: 1) + controls.start({ + y: 0, // 移动到正常位置(Y 轴 0) + opacity: 1, // 变为完全不透明 + transition: { + duration: 0.5, // 动画时长:0.5 秒 + ease: "easeOut", // 缓动函数:快速开始,缓慢结束 + }, + }).then(() => { + // ===== 步骤 5:动画完成,标记首次渲染结束 ===== + isFirstRender.current = false; + }); + } + else { + // 如果首次渲染时不需要显示(currentMode 为 null) + // 直接标记首次渲染完成,不需要执行动画 + isFirstRender.current = false; + } + return; + } + + // ==================== 隐藏状态(当前没有工具应该显示)==================== + if (!currentMode) { + if (isVisible && !isAnimating.current) { + // ===== 执行离开动画 ===== + isAnimating.current = true; + controls.start({ + y: 100, // 向下移动到 100px 处 + opacity: 0, // 同时淡出 + transition: { + duration: 0.3, // 动画时长:0.3 秒(比进入动画短,显得更轻快) + ease: "easeIn", // 缓动函数:缓慢开始,快速结束 + }, + }).then(() => { + // ===== 离开动画完成后,隐藏元素 ===== + setIsVisible(false); // 隐藏内容(motion.div 仍然挂载) + setDisplayedMode(null); // 清空显示的模式 + isAnimating.current = false; // 解除动画锁定 + }); + } + return; + } + + // ==================== 模式切换或显示 ==================== + + // 检查模式是否发生了变化(港口 ↔ 分公司) + const modeChanged = displayedMode !== currentMode; + + // ===== 场景 1:模式切换(港口 ↔ 分公司)===== + if (modeChanged && !isAnimating.current && isVisible) { + isAnimating.current = true; + + // -------- 步骤 1:执行离开动画(旧模式离开)-------- + controls.start({ + y: 100, // 向下移动到 100px 处 + opacity: 0, // 同时淡出 + transition: { + duration: 0.3, // 动画时长:0.3 秒 + ease: "easeIn", // 缓动函数 + }, + }).then(() => { + // -------- 步骤 2:离开动画完成后,更新显示的模式 -------- + setDisplayedMode(currentMode); + + // -------- 步骤 3:设置元素到初始位置(下方外部)-------- + controls.set({ + y: 100, // 确保元素在下方 100px 处 + opacity: 0, // 确保元素是透明的 + }); + + // -------- 步骤 4:执行进入动画(新模式进入)-------- + controls.start({ + y: 0, // 移动到正常位置 + opacity: 1, // 淡入 + transition: { + duration: 0.5, // 动画时长:0.5 秒 + ease: "easeOut", // 缓动函数 + }, + }).then(() => { + // -------- 步骤 5:动画完成 -------- + isAnimating.current = false; // 解除动画锁定 + }); + }); + } + + // ===== 场景 2:元素不可见,但应该显示(例如:从隐藏状态切换到显示)===== + else if (!isVisible && currentMode) { + // -------- 步骤 1:显示元素 -------- + setIsVisible(true); + + // -------- 步骤 2:更新显示的模式 -------- + setDisplayedMode(currentMode); + + // -------- 步骤 3:设置元素到初始位置 -------- + controls.set({ + y: 100, // 下方 100px 处 + opacity: 0, // 透明 + }); + + // -------- 步骤 4:执行进入动画 -------- + controls.start({ + y: 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, // 元素可见性状态,用于控制内容的显示/隐藏 + }; +} diff --git a/src/pages/Container/Map/components/BottomUtils/useBranchOfficeUtilsAnimation.js b/src/pages/Container/Map/components/BottomUtils/useBranchOfficeUtilsAnimation.js new file mode 100644 index 0000000..48f94b5 --- /dev/null +++ b/src/pages/Container/Map/components/BottomUtils/useBranchOfficeUtilsAnimation.js @@ -0,0 +1,94 @@ +/** + * BottomUtils 子项动画 Hook + * + * 功能说明: + * 1. 为子项列表提供从下向上进入、从上向下离开的动画效果 + * 2. 子项依次出现的波浪式动画(stagger 效果) + * 3. 离开时反向依次消失 + * + * 动画效果: + * - 进入动画:每个子项从下方 50px 处滑入,同时淡入(y: 50 → 0, opacity: 0 → 1) + * - 离开动画:每个子项向下方 50px 处滑出,同时淡出(y: 0 → 50, opacity: 1 → 0) + * + * Stagger 效果: + * - 进入时(visible):子项从第一个到最后一个依次出现,每个间隔 0.05 秒 + * - 离开时(hidden):子项从最后一个到第一个依次消失,每个间隔 0.03 秒(更快) + * + * 使用方法: + * 1. 在父组件中使用 AnimatePresence 包裹子项容器 + * 2. 在子项容器上设置 initial="hidden"、animate="visible"、exit="hidden" + * 3. 在子项容器上使用 containerVariants + * 4. 在每个子项上使用 itemVariants + * + * @returns {object} 返回包含以下属性的对象: + * - containerVariants: 容器的动画变体(用于 motion.div) + * - itemVariants: 子项的动画变体(用于每个子项) + */ +export function useBranchOfficeUtilsAnimation() { + // ==================== 容器动画变体 ==================== + // 作用:控制整个子项容器的动画和子项的 stagger 效果 + const containerVariants = { + // 隐藏状态 + // - 用途 1:作为初始状态(initial="hidden") + // - 用途 2:作为离开状态(exit="hidden") + // - 当同时定义 hidden.transition 和 visible.transition 时 + // Framer Motion 会根据当前是进入还是离开自动选择对应的 transition + hidden: { + opacity: 0, // 容器整体透明 + y: 200, // 容器从下方 200px 处开始/离开到下方 200px + // 离开时的 transition 配置(当 exit="hidden" 时使用) + // 进入时会使用 visible.transition,所以这里只配置离开相关的参数 + transition: { + duration: 0.2, // 容器离开动画时长:0.2 秒(比进入快) + ease: "easeIn", // 容器离开缓动函数 + // 子项依次离开的反向波浪效果 + staggerChildren: 0.03, // 离开时间隔更短(0.03 秒),显得更轻快 + staggerDirection: -1, // -1 = 反向(从最后一个到第一个) + }, + }, + + // 可见状态(进入动画) + // - 当组件挂载后立即使用(animate="visible") + visible: { + opacity: 1, // 容器整体不透明 + y: 0, // 容器移动到正常位置 + transition: { + duration: 0.3, // 容器动画时长:0.3 秒 + ease: "easeOut", // 容器缓动函数 + // 子项依次出现的波浪效果(进入时使用) + staggerChildren: 0.05, // 每个子项间隔 0.05 秒依次出现 + staggerDirection: 1, // 1 = 正向(从第一个到最后一个) + }, + }, + }; + + // ==================== 子项动画变体 ==================== + // 作用:控制单个子项的进入和离开动画 + const itemVariants = { + // 隐藏状态 + // - 作为初始状态(initial="hidden") + // - 作为离开的目标状态(exit="hidden" 时子项会动画到此状态) + hidden: { + y: 50, // Y 轴位置:从下方 50px 处开始/离开到下方 50px + opacity: 0, // 透明度:完全透明 + }, + + // 可见状态 + // - 作为进入的目标状态(animate="visible" 时子项会动画到此状态) + visible: { + y: 0, // Y 轴位置:移动到正常位置 + opacity: 1, // 透明度:变为完全不透明 + transition: { + duration: 0.3, // 动画时长:0.3 秒 + ease: "easeOut", // 缓动函数:快速开始,缓慢结束 + }, + }, + }; + + // ==================== 返回值 ==================== + // 返回容器和子项的动画变体 + return { + containerVariants, // 容器的动画变体,用于 motion.div 的 variants 属性 + itemVariants, // 子项的动画变体,用于每个子项 motion.div 的 variants 属性 + }; +} diff --git a/src/pages/Container/Map/components/BottomUtils/usePortUtilsAnimation.js b/src/pages/Container/Map/components/BottomUtils/usePortUtilsAnimation.js new file mode 100644 index 0000000..c20ac4b --- /dev/null +++ b/src/pages/Container/Map/components/BottomUtils/usePortUtilsAnimation.js @@ -0,0 +1,266 @@ +import { useAnimation } from "motion/react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +/** + * PortUtils 父子项动画 Hook + * + * 功能说明: + * 1. 实现父级和子级的复杂交互动画 + * 2. 支持点击父级展开/收起子级 + * 3. 初始化时不执行动画,只执行 useBottomUtilsAnimation 的容器动画 + * 4. 首次点击后执行完整的展开/收起动画序列 + * + * 动画流程: + * + * 【初始显示】 + * - 使用 useBottomUtilsAnimation 的主容器动画 + * - 不执行父级的依次显示动画 + * + * 【点击父级展开子级】 + * 1. 所有父级依次下落隐藏(y: 0 → 30, opacity: 1 → 0, x: 0) + * 2. 选中的父级显示回来(y: 30 → 0, opacity: 0 → 1, x: 0) + * 3. 选中的父级移动到最左边(x: 0 → -offsetLeft) + * 4. 子级展开(opacity: 0 → 1) + * + * 【点击父级收起子级】 + * 1. 子级收起(opacity: 1 → 0) + * 2. 所有父级在各自当前位置下落(y: 0 → 30, opacity: 1 → 0,保持 x 位置) + * 3. 所有父级回到原位(x: 当前位置 → 0,仍在下方) + * 4. 所有父级依次从下向上显示(y: 30 → 0, opacity: 0 → 1, x: 0) + * + * @param {number} parentCount - 父级数量 + * @param {number} currentIndex - 当前选中的父级索引(-1 表示未选中) + * @param {boolean} isPortMode - 是否是港口模式 + * @returns {object} 返回包含以下属性的对象: + * - parentControls: 父级动画控制器数组 + * - childControls: 子级动画控制器 + * - optionRefs: 父级元素引用数组 + * - isAnimating: 是否正在执行动画 + * - hasInteracted: 是否已经交互过 + * - shouldHideInactive: 是否应该隐藏非激活的父级 + * - animationConfig: 动画配置对象(包含 duration 和 staggerDelay) + */ +export function usePortUtilsAnimation(parentCount, currentIndex, isPortMode) { + // ==================== 动画配置 ==================== + const animationConfig = { + duration: 0.1, // 单个动画的持续时间(秒) + staggerDelay: 0.05, // 每个父级的延迟时间(秒),用于依次动画 + }; + + // ==================== 状态和引用管理 ==================== + + // 标记是否正在执行动画 + // 作用:防止在动画执行过程中触发新的动画,避免动画冲突 + const [isAnimating, setIsAnimating] = useState(false); + + // 标记是否已经交互过 + // 作用:初始化时不执行动画,首次点击后设置为 true,之后才执行完整动画 + const [hasInteracted, setHasInteracted] = useState(false); + + // 标记是否应该隐藏非激活的父级 + // 作用:当子级展开时,隐藏未选中的父级,避免干扰 + // 在动画执行期间设置为 false,让所有父级参与动画 + // 动画完成后设置为 true,隐藏未选中的父级 + const [shouldHideInactive, setShouldHideInactive] = useState(false); + + // 父级元素的 ref 数组 + // 作用:获取父级 DOM 元素,用于计算位置偏移量 + const optionRefs = useRef([]); + + // 为每个父级创建独立的动画控制器 + // 作用:每个父级可以独立控制动画 + const parentControls = Array.from({ length: parentCount }, () => useAnimation()); + + // 子级的动画控制器 + // 作用:控制子级的展开/收起动画 + const childControls = useAnimation(); + + // ==================== 初始化 ==================== + useEffect(() => { + // 设置子级为初始状态(隐藏) + // 作用:确保子级一开始不可见 + childControls.set({ opacity: 0 }); + }, []); + + // ==================== 展开动画序列 ==================== + const performExpandAnimation = useCallback(async () => { + if (isAnimating) + return; + setIsAnimating(true); + + const { duration, staggerDelay } = animationConfig; + const selectedIndex = currentIndex; + + // ===== 准备阶段 ===== + // 先设置为 false,让其他父级参与下落动画 + setShouldHideInactive(false); + + // ===== 步骤 1:所有父级依次下落隐藏 ===== + // 目的:让所有父级依次消失,为选中的父级腾出空间 + const hidePromises = parentControls.map((control, index) => { + return control.start({ + y: 30, // 向下移动 30px + opacity: 0, // 透明度变为 0 + x: 0, // 重置 x 位置到原始位置 + transition: { duration, delay: index * staggerDelay }, // 依次延迟,产生波浪效果 + }); + }); + + await Promise.all(hidePromises); // 等待所有父级下落完成 + + // ===== 步骤 2:选中的父级显示回来 ===== + // 目的:让选中的父级从下向上淡入显示 + await parentControls[selectedIndex].start({ + y: 0, // 回到原始 Y 位置 + opacity: 1, // 透明度变为 1 + x: 0, // 保持 x 为 0 + transition: { duration }, // 不延迟,立即执行 + }); + + // ===== 步骤 3:选中的父级移动到最左边 ===== + // 目的:让选中的父级移动到容器最左侧,为子级展开腾出空间 + const selectedElement = optionRefs.current[selectedIndex]; + const targetX = -selectedElement.offsetLeft; // 计算目标 x 位置(负的偏移量) + + await parentControls[selectedIndex].start({ + x: targetX, // 移动到最左边 + transition: { duration, ease: "easeInOut" }, // 使用平滑的缓动函数 + }); + + // ===== 步骤 4:子级展开 ===== + // 目的:让子级淡入显示 + await childControls.start({ + opacity: 1, // 透明度从 0 到 1 + transition: { + duration, // 动画时长 + delay: staggerDelay, // 延迟执行,等待步骤 3 完成 + }, + }); + + // ===== 动画完成 ===== + // 所有动画完成后,隐藏其他父级,避免干扰 + setShouldHideInactive(true); + + setIsAnimating(false); + }, [isAnimating, currentIndex, parentControls, childControls, animationConfig]); + + // ==================== 首次交互监听 ==================== + // 监听 hasInteracted 变化,在第一次切换后执行动画 + useEffect(() => { + if (hasInteracted && !isAnimating && currentIndex !== -1) { + // 第一次切换到 motion.div 后,延迟一帧执行动画 + // 作用:确保 DOM 已更新,motion.div 已挂载 + requestAnimationFrame(() => { + performExpandAnimation(); + }); + } + }, [hasInteracted]); + + // ==================== 收起动画序列 ==================== + const performCollapseAnimation = useCallback(async () => { + if (isAnimating) + return; + setIsAnimating(true); + + const { duration, staggerDelay } = animationConfig; + + // ===== 准备阶段 ===== + // 先设置为 false,让所有父级参与收起动画 + setShouldHideInactive(false); + + // ===== 步骤 1:子级收起 ===== + // 目的:先隐藏子级,避免视觉干扰 + await childControls.start({ + opacity: 0, // 透明度从 1 到 0 + transition: { duration }, // 立即执行 + }); + + // ===== 步骤 2:所有父级在各自当前位置下落 ===== + // 目的:让所有父级从当前位置下落,保持各自的 x 位置 + const hidePromises = parentControls.map((control) => { + return control.start({ + y: 30, // 向下移动 30px + opacity: 0, // 透明度变为 0 + // 不设置 x,保持各自的当前位置(选中的父级在左边,其他在原位) + transition: { duration }, + }); + }); + + await Promise.all(hidePromises); // 等待所有父级下落完成 + + // ===== 步骤 3:所有父级回到原位 ===== + // 目的:将所有父级的 x 位置重置为 0(仍在下方) + await Promise.all( + parentControls.map((control) => { + return control.start({ + x: 0, // 重置 x 位置到原始位置 + transition: { duration }, // 平滑过渡 + }); + }), + ); + + // ===== 步骤 4:所有父级依次从下向上显示 ===== + // 目的:让所有父级依次从下向上淡入,恢复到初始状态 + const showPromises = parentControls.map((control, index) => { + return control.start({ + y: 0, // 回到原始 Y 位置 + opacity: 1, // 透明度变为 1 + transition: { duration, delay: index * staggerDelay }, // 依次延迟,产生波浪效果 + }); + }); + + await Promise.all(showPromises); // 等待所有父级显示完成 + + // ===== 动画完成 ===== + // 收起完成,恢复隐藏状态 + setShouldHideInactive(true); + + setIsAnimating(false); + }, [isAnimating, parentControls, childControls, animationConfig]); + + // ==================== 监听 currentIndex 变化,触发相应的动画 ==================== + useEffect(() => { + // ===== 模式切换时重置交互状态 ===== + // 作用:当 isPortMode 变化时,重新设置成未交互过 + // 这样下次点击时不会触发动画,直到再次点击 + if (!isPortMode) { + setHasInteracted(false); + } + if (!isPortMode || isAnimating) + return; + + // ===== 判断当前是否有激活的父级 ===== + const hasActiveParent = currentIndex !== -1; + + // ===== 场景 1:有激活的父级,且是首次交互 ===== + if (hasActiveParent && !hasInteracted) { + // 第一次点击:只设置 hasInteracted,不执行动画 + // 动画会在 hasInteracted 变化后的 useEffect 中执行 + // 作用:延迟动画执行,确保 DOM 已更新 + setHasInteracted(true); + } + // ===== 场景 2:有激活的父级,且已经交互过 ===== + else if (hasActiveParent && hasInteracted) { + // 已经交互过,直接执行展开动画 + performExpandAnimation(); + } + // ===== 场景 3:没有激活的父级,且已经交互过 ===== + else if (!hasActiveParent && hasInteracted) { + // 只有在已经交互过的情况下,才执行收起动画 + // 作用:避免初始化时执行收起动画 + performCollapseAnimation(); + } + }, [currentIndex, isPortMode]); + + // ==================== 返回值 ==================== + // 返回动画控制器和相关状态,供组件使用 + return { + parentControls, // 父级动画控制器数组 + childControls, // 子级动画控制器 + optionRefs, // 父级元素引用数组 + isAnimating, // 是否正在执行动画 + hasInteracted, // 是否已经交互过 + shouldHideInactive, // 是否应该隐藏非激活的父级 + animationConfig, // 动画配置对象(包含 duration 和 staggerDelay) + }; +} diff --git a/src/pages/Container/Map/components/CenterUtils/index.js b/src/pages/Container/Map/components/CenterUtils/index.js index 00ef68f..013a2ae 100644 --- a/src/pages/Container/Map/components/CenterUtils/index.js +++ b/src/pages/Container/Map/components/CenterUtils/index.js @@ -1,5 +1,5 @@ +import { motion } from "motion/react"; import { useContext, useState } from "react"; -import { CSSTransition } from "react-transition-group"; import statisticsTitleImg from "~/assets/images/map_bi/center_utils/statistics_title.png"; import guangImg from "~/assets/images/map_bi/center_utils/tabguang.png"; import tabLeftImg from "~/assets/images/map_bi/center_utils/tableft.png"; @@ -15,6 +15,7 @@ import { resetAllBottomUtilsCheckMittKey, resetBottomCurrentIndexMittKey, } from "~/pages/Container/Map/js/mittKey"; +import { useCenterUtilsAnimation } from "./useCenterUtilsAnimation"; import "./index.less"; function CenterUtils(props) { @@ -27,6 +28,21 @@ function CenterUtils(props) { ]; const [activeIndex, setActiveIndex] = useState(1); + const { controls: containerControls, isVisible: isContainerVisible } = useCenterUtilsAnimation( + (currentPort === "00003" && !currentBranchOffice && !pureMap), + "up-down", + ); + + const { controls: westControls, isVisible: isWestVisible } = useCenterUtilsAnimation( + ((currentPort === "00003" && !currentBranchOffice && !pureMap) && (activeIndex === 0 || activeIndex === 1)), + "scale", + ); + + const { controls: eastControls, isVisible: isEastVisible } = useCenterUtilsAnimation( + ((currentPort === "00003" && !currentBranchOffice && !pureMap) && (activeIndex === 2 || activeIndex === 1)), + "scale", + ); + const onChangeArea = (index) => { if (index === activeIndex) return; @@ -41,80 +57,56 @@ function CenterUtils(props) { return (
- -
-
-
- {list.map((item, index) => ( + + {isContainerVisible && ( + <> +
onChangeArea(index)} - > - {item.label} -
- ))} -
-
- -
-
西港区
-
-
人数:33人
-
车辆:33辆
+ className="guang" + style={{ backgroundImage: `url(${guangImg})` }} + /> + {list.map((item, index) => ( +
onChangeArea(index)} + > + {item.label}
-
- - -
-
东港区
-
-
人数:33人
-
车辆:33辆
-
-
-
-
-
- + ))} +
+
+ + {isWestVisible && ( +
+
西港区
+
+
人数:33人
+
车辆:33辆
+
+
+ )} +
+ + {isEastVisible && ( +
+
东港区
+
+
人数:33人
+
车辆:33辆
+
+
+ )} +
+
+ + )} +
); } diff --git a/src/pages/Container/Map/components/CenterUtils/index.less b/src/pages/Container/Map/components/CenterUtils/index.less index 35da860..e28b8c0 100644 --- a/src/pages/Container/Map/components/CenterUtils/index.less +++ b/src/pages/Container/Map/components/CenterUtils/index.less @@ -1,75 +1,78 @@ .map_content_center_options_container { - .center_options { - width: 408px; - position: absolute; - top: 135px; - left: 50%; - display: flex; - transform: translateX(-50%); + .center_utils { - .guang { - background-size: 100% 100%; - background-repeat: no-repeat; - width: 348px; - height: 32px; + .center_options { + width: 408px; position: absolute; - top: -23px; + top: 135px; left: 50%; + display: flex; transform: translateX(-50%); - } - .option { - background-size: 100% 100%; - background-repeat: no-repeat; - cursor: pointer; - font-size: 14px; - color: #fff; - font-weight: bold; - text-align: center; - line-height: 40px; - - &.option0 { - width: 139px; - height: 42px; + .guang { + background-size: 100% 100%; + background-repeat: no-repeat; + width: 348px; + height: 32px; + position: absolute; + top: -23px; + left: 50%; + transform: translateX(-50%); } - &.option1 { - width: 130px; - height: 40px; - } - - &.option2 { - width: 139px; - height: 42px; - } - } - } - - .statistics { - width: 408px; - position: absolute; - top: 205px; - left: calc(50% + 240px); - display: flex; - gap: 5px; - color: #fff; - - .statistic { - border-radius: 2px; - - .title { - padding: 3px 0; + .option { + background-size: 100% 100%; + background-repeat: no-repeat; + cursor: pointer; + font-size: 14px; + color: #fff; + font-weight: bold; text-align: center; - border-radius: 2px; + line-height: 40px; + + &.option0 { + width: 139px; + height: 42px; + } + + &.option1 { + width: 130px; + height: 40px; + } + + &.option2 { + width: 139px; + height: 42px; + } } + } - .info { - padding: 7px 14px; - border: 1px solid rgb(44, 105, 172); + .statistics { + width: 408px; + position: absolute; + top: 205px; + left: calc(50% + 240px); + display: flex; + gap: 5px; + color: #fff; + + .statistic { border-radius: 2px; - background-color: rgba(0, 41, 82, 0.722); - .value { + .title { + padding: 3px 0; + text-align: center; + border-radius: 2px; + } + + .info { + padding: 7px 14px; + border: 1px solid rgb(44, 105, 172); + border-radius: 2px; + background-color: rgba(0, 41, 82, 0.722); + + .value { + } } } } diff --git a/src/pages/Container/Map/components/CenterUtils/useCenterUtilsAnimation.js b/src/pages/Container/Map/components/CenterUtils/useCenterUtilsAnimation.js new file mode 100644 index 0000000..a571f77 --- /dev/null +++ b/src/pages/Container/Map/components/CenterUtils/useCenterUtilsAnimation.js @@ -0,0 +1,186 @@ +import { useAnimation } from "motion/react"; +import { useEffect, useRef, useState } from "react"; + +/** + * CenterUtils 弹跳动画 Hook + * + * 功能说明: + * 1. 监听组件的显示/隐藏状态变化 + * 2. 执行炫酷的弹跳动画效果(类似 animate.css 的 bounceIn/bounceOut) + * 3. 支持多种动画类型(上下弹跳、缩放弹跳) + * 4. 使用弹跳、缩放、透明度三重动画效果,让动画更加生动 + * 5. 延迟隐藏机制,确保离开动画完整播放 + * + * 动画效果详解(上下弹跳模式 up-down): + * - 进入动画: + * - 位置:从上方 120px 处冲到下方 40px(超出目标),轻微上弹到 -10px,最后稳定到 0 + * - 缩放:从 0.9 倍缩小到 1.05 倍(轻微放大),轻微缩小到 0.98,回到正常 1.0 + * - 透明度:从透明到不透明 + * - 时长:0.7 秒 + * - 缓动:使用强弹性贝塞尔曲线 [0.34, 1.56, 0.64, 1],产生明显的过冲和回弹效果 + * + * - 离开动画: + * - 位置:从正常位置向上弹跳 50px,回落到 20px,然后向上离开到 -120px + * - 缩放:轻微放大到 1.02,然后缩小到 0.85 + * - 透明度:保持可见再淡出 + * - 时长:0.5 秒 + * - 缓动:使用超弹性曲线 [0.68, -0.6, 0.32, 1.6],产生夸张的反向弹跳 + * + * 动画效果详解(缩放弹跳模式 scale): + * - 进入动画: + * - 缩放:从 0 放大到 1.15 倍(超出目标),缩小到 0.92,回到正常 1.0 + * - 时长:0.7 秒 + * - 缓动:使用强弹性贝塞尔曲线 + * + * - 离开动画: + * - 缩放:从 1.0 放大到 1.1,缩小到 0.85,最后消失到 0 + * - 时长:0.5 秒 + * - 缓动:使用超弹性曲线 + * + * @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") { + // ==================== 状态和引用管理 ==================== + + // motion 动画控制器,用于控制 motion.div 的动画 + const controls = useAnimation(); + + // 标记是否是首次渲染 + // 作用:首次渲染时需要特殊处理,确保初始状态正确设置 + // 首次渲染不执行离开动画,直接执行进入动画 + const isFirstRender = useRef(true); + + // 组件的实际显示状态 + // 作用:延迟隐藏,确保离开动画执行完成后才将组件从 DOM 中移除 + const [displayState, setDisplayState] = useState(isVisible); + + 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]); + + // 返回动画控制器和延迟的可见性状态 + // - controls:用于绑定到 motion.div 的 animate 属性 + // - isVisible:延迟的可见性状态,用于条件渲染(等待离开动画完成) + return { + controls, + isVisible: displayState, + }; +} diff --git a/src/pages/Container/Map/components/Content/index.js b/src/pages/Container/Map/components/Content/index.js index ff5106a..050bf36 100644 --- a/src/pages/Container/Map/components/Content/index.js +++ b/src/pages/Container/Map/components/Content/index.js @@ -1,3 +1,4 @@ +import { 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"; @@ -25,16 +26,42 @@ 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"; function Content() { const { currentPort, currentBranchOffice, pureMap, bottomUtilsCurrentIndex } = useContext(Context); - const [collapseLeft, setCollapseLeft] = useState(false); const [collapseRight, setCollapseRight] = useState(false); + const { + controls: leftControls, + displayedContent: leftDisplayedContent, + isVisible: isLeftVisible, + handleCollapse: handleLeftCollapse, + } = useContentAnimation( + { currentPort, currentBranchOffice, bottomUtilsCurrentIndex }, + collapseLeft, + "left", + pureMap, + ); + + const { + controls: rightControls, + displayedContent: rightDisplayedContent, + isVisible: isRightVisible, + handleCollapse: handleRightCollapse, + } = useContentAnimation( + { currentBranchOffice, bottomUtilsCurrentIndex }, + collapseRight, + "right", + pureMap, + ); + const renderPortContent = () => { - const bottomUtilsCurrentType = bottomUtilsCurrentIndex !== -1 ? portUtilsList[bottomUtilsCurrentIndex].type : ""; + const bottomUtilsCurrentType = (leftDisplayedContent.bottomUtilsCurrentIndex !== -1 && portUtilsList[leftDisplayedContent.bottomUtilsCurrentIndex]) + ? portUtilsList[leftDisplayedContent.bottomUtilsCurrentIndex].type + : ""; if (bottomUtilsCurrentType === "" || bottomUtilsCurrentType === "camera") return ; if (bottomUtilsCurrentType === "door") @@ -54,7 +81,9 @@ function Content() { }; const renderBranchOfficeContentLeft = () => { - const bottomUtilsCurrentType = bottomUtilsCurrentIndex !== -1 ? branchOfficeUtilsList[bottomUtilsCurrentIndex].type : ""; + const bottomUtilsCurrentType = (leftDisplayedContent.bottomUtilsCurrentIndex !== -1 && branchOfficeUtilsList[leftDisplayedContent.bottomUtilsCurrentIndex]) + ? branchOfficeUtilsList[leftDisplayedContent.bottomUtilsCurrentIndex].type + : ""; if (bottomUtilsCurrentType === "") return ; if (bottomUtilsCurrentType === "danger") @@ -76,34 +105,35 @@ function Content() { }; const renderBranchOfficeContentRight = () => { - const bottomUtilsCurrentType = bottomUtilsCurrentIndex !== -1 ? branchOfficeUtilsList[bottomUtilsCurrentIndex].type : ""; + const bottomUtilsCurrentType = (rightDisplayedContent.bottomUtilsCurrentIndex !== -1 && branchOfficeUtilsList[rightDisplayedContent.bottomUtilsCurrentIndex]) + ? branchOfficeUtilsList[rightDisplayedContent.bottomUtilsCurrentIndex].type + : ""; if (bottomUtilsCurrentType === "") return ; }; const renderContent = () => { - if (pureMap) - return null; - return ( <> - {!collapseLeft && ( -
- {!currentPort && } - {(currentPort === "00003" && !currentBranchOffice) && renderPortContent()} - {currentBranchOffice && renderBranchOfficeContentLeft()} -
+ {!leftDisplayedContent.currentPort && } + {(leftDisplayedContent.currentPort === "00003" && !leftDisplayedContent.currentBranchOffice) && renderPortContent()} + {leftDisplayedContent.currentBranchOffice && renderBranchOfficeContentLeft()} + )} - {!collapseRight && ( -
- {currentBranchOffice && renderBranchOfficeContentRight()} -
+ {rightDisplayedContent.currentBranchOffice && renderBranchOfficeContentRight()} + )} ); @@ -118,9 +148,7 @@ function Content() {
{ - setCollapseLeft(!collapseLeft); - }} + onClick={() => handleLeftCollapse(setCollapseLeft)} >
@@ -136,9 +164,7 @@ function Content() {
{ - setCollapseLeft(!collapseLeft); - }} + onClick={() => handleLeftCollapse(setCollapseLeft)} >
@@ -147,9 +173,7 @@ function Content() {
{ - setCollapseRight(!collapseRight); - }} + onClick={() => handleRightCollapse(setCollapseRight)} >
diff --git a/src/pages/Container/Map/components/Content/index.less b/src/pages/Container/Map/components/Content/index.less index a2c7199..3d7630e 100644 --- a/src/pages/Container/Map/components/Content/index.less +++ b/src/pages/Container/Map/components/Content/index.less @@ -33,11 +33,13 @@ &.port { top: 75px; max-height: calc(100vh - 75px); + opacity: 0; } &.branch_office { top: 70px; max-height: calc(100vh - 70px); + opacity: 0; } } diff --git a/src/pages/Container/Map/components/Content/useContentAnimation.js b/src/pages/Container/Map/components/Content/useContentAnimation.js new file mode 100644 index 0000000..6604131 --- /dev/null +++ b/src/pages/Container/Map/components/Content/useContentAnimation.js @@ -0,0 +1,243 @@ +import { useAnimation } from "motion/react"; +import { useEffect, useRef, useState } from "react"; + +/** + * 内容切换动画 Hook + * + * 功能说明: + * 1. 管理内容面板的显示/隐藏状态 + * 2. 处理内容切换时的动画效果(离开 + 进入) + * 3. 处理折叠/展开时的动画效果 + * 4. 处理纯地图模式下的动画效果 + * + * 优先级:pureMap > 折叠状态 > 内容变化 + * + * @param {object} currentContent - 当前要显示的内容状态(包含 currentPort、currentBranchOffice、bottomUtilsCurrentIndex 等) + * @param {boolean} isCollapsed - 当前是否处于折叠状态 + * @param {string} side - 'left' 或 'right',控制动画方向 + * @param {boolean} isPureMap - 是否为纯地图模式 + * @returns {object} 返回包含以下属性的对象: + * - controls: motion 动画控制器 + * - displayedContent: 延迟显示的内容状态(用于动画过渡) + * - isVisible: 元素可见性状态 + * - handleCollapse: 处理折叠/展开的函数 + */ +export function useContentAnimation(currentContent, isCollapsed, side = "left", isPureMap = false) { + // ==================== 状态管理 ==================== + + // motion 动画控制器,用于控制元素的动画 + const controls = useAnimation(); + + // 标记是否是首次渲染(首次渲染不执行离开动画,直接进入) + const isLeftFirstRender = useRef(true); + + // 标记当前是否正在执行动画(防止动画冲突) + const isAnimating = useRef(false); + + // 延迟显示的内容状态 + // 作用:在内容切换时,先显示旧内容,动画完成后再更新为新内容 + const [displayedContent, setDisplayedContent] = useState(currentContent); + + // 元素可见性状态 + // false: 元素被隐藏(折叠或纯地图模式) + // true: 元素可见(非折叠、非纯地图模式) + const [isVisible, setIsVisible] = useState(true); + + // ==================== 辅助函数 ==================== + + /** + * 计算动画的初始位置 + * @returns {number} 初始 X 轴位移值(负数表示在左侧,正数表示在右侧) + */ + const getInitialX = () => side === "left" ? -300 : 300; + + // ==================== 折叠/展开处理 ==================== + + /** + * 处理折叠按钮点击事件 + * @param {Function} setCollapseState - setState 函数,用于更新折叠状态 + */ + const handleCollapse = (setCollapseState) => { + if (isCollapsed) { + // ==================== 展开逻辑 ==================== + // 1. 先同步显示内容(确保显示最新的内容) + setDisplayedContent(currentContent); + + // 2. 显示元素 + setIsVisible(true); + + // 3. 更新折叠状态 + setCollapseState(false); + + // 4. 执行进入动画 + // 使用 requestAnimationFrame 确保 DOM 更新后再设置初始状态 + requestAnimationFrame(() => { + // 设置元素到初始隐藏位置(侧边外部) + controls.set({ + x: getInitialX(), + opacity: 0, + }); + + // 然后触发进入动画(从侧边滑入) + requestAnimationFrame(() => { + controls.start({ + x: 0, // 移动到正常位置 + opacity: 1, // 淡入 + transition: { duration: 0.5, ease: "easeOut" }, + }); + }); + }); + } + else { + // ==================== 折叠逻辑 ==================== + // 执行离开动画(滑到侧边并淡出) + controls.start({ + x: getInitialX(), // 移动到侧边外部 + opacity: 0, // 淡出 + transition: { duration: 0.3, ease: "easeIn" }, + }).then(() => { + // 动画完成后: + // 1. 隐藏元素 + setIsVisible(false); + + // 2. 更新折叠状态为 true + setCollapseState(true); + }); + } + }; + + // ==================== 主题:监听状态变化并执行动画 ==================== + + useEffect(() => { + // ==================== 场景 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 (isLeftFirstRender.current) { + // 1. 设置元素到初始隐藏位置(侧边外部) + controls.set({ + x: getInitialX(), + opacity: 0, + }); + + // 2. 立即更新显示的内容 + setDisplayedContent(currentContent); + + // 3. 执行滑入动画(从侧边滑入到正常位置) + controls.start({ + x: 0, // 移动到正常位置 + opacity: 1, // 淡入 + transition: { duration: 0.5, ease: "easeOut" }, + }); + + // 4. 标记首次渲染完成 + isLeftFirstRender.current = false; + } + + // ----- 情况 B:内容变化(用户切换工具)----- + 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:从隐藏状态切换回来(退出纯地图模式或展开)----- + else if (!isVisible) { + // 1. 显示元素 + setIsVisible(true); + + // 2. 更新显示的内容 + setDisplayedContent(currentContent); + + // 3. 执行进入动画 + 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]); + + // ==================== 返回值 ==================== + + return { + controls, // motion 动画控制器 + displayedContent, // 延迟显示的内容(用于动画过渡) + isVisible, // 元素可见性状态 + handleCollapse, // 折叠/展开处理函数 + }; +} diff --git a/src/pages/Container/Map/components/Header/index.js b/src/pages/Container/Map/components/Header/index.js index 28b7e99..c25ae38 100644 --- a/src/pages/Container/Map/components/Header/index.js +++ b/src/pages/Container/Map/components/Header/index.js @@ -1,5 +1,5 @@ -import { useContext, useEffect, useRef, useState } from "react"; -import { CSSTransition } from "react-transition-group"; +import { 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"; import guangImg from "~/assets/images/map_bi/guang.png"; @@ -16,27 +16,13 @@ import { resetAllBottomUtilsCheckMittKey, resetBottomCurrentIndexMittKey, } from "~/pages/Container/Map/js/mittKey"; +import { useHeaderAnimation } from "./useHeaderAnimation"; import "./index.less"; -const animationTime = 1000; function Header(props) { const { currentPort, currentBranchOffice, mapMethods, area } = useContext(Context); - const [animationShow, setAnimationShow] = useState(false); - const [displayTitle, setDisplayTitle] = useState(props.headerTitle); - const timer = useRef(null); - - useEffect(() => { - setAnimationShow(false); - timer.current = setTimeout(() => { - setDisplayTitle(props.headerTitle); - setAnimationShow(true); - }, animationTime); - - return () => { - timer.current && clearTimeout(timer.current); - }; - }, [props.headerTitle]); + const { controls, displayedTitle } = useHeaderAnimation(props.headerTitle); const onBack = () => { sessionStorage.removeItem("mapCurrentBranchOfficeId"); @@ -56,6 +42,8 @@ function Header(props) { mapMethods.current.removeBranchOfficePoint(); mapMethods.current.removeMarkPoint(); mapMethods.current.returnPreviousCenterPoint(); + mapMethods.current.removeFourColorDiagram(); + mapMethods.current.removeWall(); // setTimeout(() => { mapMethods.current.addBranchOfficePoint(area); // }, 2000); @@ -77,34 +65,23 @@ function Header(props) { return (
- -
- {(currentPort && displayTitle === "秦港股份安全监管平台") && ( -
- )} - {displayTitle !== "秦港股份安全监管平台" && ( -
- -
返回
-
- )} -
{displayTitle}
-
-
-
+ {(currentPort && displayedTitle === "秦港股份安全监管平台") && ( +
+ )} + {displayedTitle !== "秦港股份安全监管平台" && ( +
+ +
返回
+
+ )} +
{displayedTitle}
+
+
); } diff --git a/src/pages/Container/Map/components/Header/index.less b/src/pages/Container/Map/components/Header/index.less index 7eeac58..82a13b6 100644 --- a/src/pages/Container/Map/components/Header/index.less +++ b/src/pages/Container/Map/components/Header/index.less @@ -9,6 +9,7 @@ text-align: center; font-weight: bold; position: absolute; + opacity: 0; &.port { padding-top: 10px; diff --git a/src/pages/Container/Map/components/Header/useHeaderAnimation.js b/src/pages/Container/Map/components/Header/useHeaderAnimation.js new file mode 100644 index 0000000..7277606 --- /dev/null +++ b/src/pages/Container/Map/components/Header/useHeaderAnimation.js @@ -0,0 +1,108 @@ +import { useAnimation } from "motion/react"; +import { useEffect, useRef, useState } from "react"; + +/** + * Header 组件动画 Hook + * + * 功能说明: + * 1. 监听标题内容变化 + * 2. 内容变化时执行上下方向的动画效果(向上离开 + 向下进入) + * 3. 支持首次加载动画 + * + * 动画效果: + * - 进入动画:从上方 100px 处滑入,同时淡入(y: -100 → 0, opacity: 0 → 1) + * - 离开动画:向上方 100px 处滑出,同时淡出(y: 0 → -100, opacity: 1 → 0) + * + * @param {string} currentTitle - 当前的标题内容 + * @returns {object} 返回包含以下属性的对象: + * - controls: motion 动画控制器 + * - displayedTitle: 延迟显示的标题(用于动画过渡) + */ +export function useHeaderAnimation(currentTitle) { + // ==================== 状态和引用管理 ==================== + + // motion 动画控制器,用于控制 motion.header 的动画 + const controls = useAnimation(); + + // 标记是否是首次渲染 + // 作用:首次渲染时需要特殊处理,确保初始状态正确设置 + const isFirstRender = useRef(true); + + // 延迟显示的标题状态 + // 作用:在标题切换时,先显示旧标题,动画完成后再更新为新标题 + // 这样确保离开动画显示的是旧标题,进入动画显示的是新标题 + const [displayedTitle, setDisplayedTitle] = useState(currentTitle); + + useEffect(() => { + // ==================== 检查标题是否发生变化 ==================== + const titleChanged = displayedTitle !== currentTitle; + + // ==================== 首次渲染 ==================== + if (isFirstRender.current) { + // ===== 步骤 1:设置元素到初始隐藏位置(上方外部)===== + // - y: -100:元素位于视图上方 100px 处(隐藏) + // - opacity: 0:完全透明 + // - controls.set() 是立即设置,不会触发动画过渡 + controls.set({ + y: -100, // Y 轴位置:从上方 100px 处开始 + opacity: 0, // 透明度:完全透明 + }); + + // ===== 步骤 2:立即更新显示的标题 ===== + // 因为首次渲染不需要保留旧标题,直接设置为传入的标题 + setDisplayedTitle(currentTitle); + + // ===== 步骤 3:执行滑入动画(从上方滑入到正常位置)===== + // - y: 0:从上方移动到正常位置 + // - opacity: 1:从透明变为不透明 + // - duration: 0.5s:动画时长 0.5 秒 + // - ease: "easeOut":快速开始,缓慢结束的缓动函数 + controls.start({ + y: 0, // 移动到正常位置 + opacity: 1, // 淡入 + transition: { duration: 0.5, ease: "easeOut" }, + }); + + // ===== 步骤 4:标记首次渲染完成 ===== + isFirstRender.current = false; + } + + // ==================== 标题变化 ==================== + else if (titleChanged) { + // ===== 步骤 1:先执行离开动画(向上离开)===== + // 作用:显示旧标题向上滑出 + // - y: -100:向上移动到视图外部 + // - opacity: 0:同时淡出 + // - duration: 0.3s:较短的离开动画时长,显得更轻快 + // - ease: "easeIn":缓慢开始,快速结束的缓动函数 + controls.start({ + y: -100, // 向上移动到外部 + opacity: 0, // 淡出 + transition: { duration: 0.3, ease: "easeIn" }, + }).then(() => { + // ===== 步骤 2:离开动画完成后,更新显示的标题为新标题 ===== + // 此时旧标题已经完全离开视图,更新标题用户看不见 + setDisplayedTitle(currentTitle); + + // ===== 步骤 3:执行进入动画(从上方进入)===== + // 作用:显示新标题从上方滑入 + // - y: 0:从上方移动到正常位置 + // - opacity: 1:从透明变为不透明 + // - duration: 0.5s:进入动画时长 + // - ease: "easeOut":快速开始,缓慢结束的缓动函数 + controls.start({ + y: 0, // 从上方移动到正常位置 + opacity: 1, // 淡入 + transition: { duration: 0.5, ease: "easeOut" }, + }); + }); + } + }, [currentTitle, controls, displayedTitle]); + + // ==================== 返回值 ==================== + // 返回动画控制器和延迟显示的标题 + return { + controls, // motion 动画控制器,绑定到 motion.header 的 animate 属性 + displayedTitle, // 延迟显示的标题,用于在动画过渡期间保持视觉连续性 + }; +} diff --git a/src/pages/Container/Map/components/RightUtils/branchOfficeUtilsList.js b/src/pages/Container/Map/components/RightUtils/branchOfficeUtilsList.js deleted file mode 100644 index 78497eb..0000000 --- a/src/pages/Container/Map/components/RightUtils/branchOfficeUtilsList.js +++ /dev/null @@ -1,62 +0,0 @@ -import backImg from "~/assets/images/map_bi/right_utils/branch_office/ico1.png"; -import fullImg from "~/assets/images/map_bi/right_utils/branch_office/ico2.png"; -import img2Img from "~/assets/images/map_bi/right_utils/branch_office/ico3.png"; -import img4Img from "~/assets/images/map_bi/right_utils/branch_office/ico4.png"; -import delImg from "~/assets/images/map_bi/right_utils/branch_office/ico5.png"; -import mapImg from "~/assets/images/map_bi/right_utils/branch_office/ico6.png"; -import qixiangImg from "~/assets/images/map_bi/right_utils/branch_office/ico7.png"; -import sisetuImg from "~/assets/images/map_bi/right_utils/branch_office/ico8.png"; -import bianjieImg from "~/assets/images/map_bi/right_utils/branch_office/ico9.png"; - -export const branchOfficeUtilsList = [ - { - img: backImg, - label: "返回主系统", - type: "back", - }, - { - img: fullImg, - check: false, - label: "全屏", - type: "full", - }, - { - img: img2Img, - label: "返回中心点", - type: "return", - }, - { - img: img4Img, - check: false, - label: "切换视角", - type: "scene", - }, - { - img: delImg, - label: "删除标记点", - type: "del", - }, - { - img: mapImg, - check: false, - label: "纯净地图", - type: "pureMap", - }, - { - img: qixiangImg, - label: "气象监测", - type: "weather", - }, - { - img: sisetuImg, - check: false, - label: "四色图", - type: "fourColor", - }, - { - img: bianjieImg, - check: false, - label: "边界", - type: "wall", - }, -]; diff --git a/src/pages/Container/Map/components/RightUtils/index.js b/src/pages/Container/Map/components/RightUtils/index.js index b73fce4..2457eb3 100644 --- a/src/pages/Container/Map/components/RightUtils/index.js +++ b/src/pages/Container/Map/components/RightUtils/index.js @@ -1,6 +1,6 @@ import { message } from "antd"; +import { motion } from "motion/react"; import { useContext, useEffect, useState } from "react"; -import { CSSTransition } from "react-transition-group"; 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"; @@ -11,9 +11,10 @@ import { clickBackMittKey, clickMarkPointMittKey, deletePeoplePositionPointMittKey, - initRightUtilsMittKey, resetAllBottomUtilsCheckMittKey, } from "~/pages/Container/Map/js/mittKey"; +import { useChildMenuAnimation } from "./useChildMenuAnimation"; +import { useRightUtilsAnimation } from "./useRightUtilsAnimation"; import { utilsList } from "./utilsList"; import "./index.less"; @@ -21,18 +22,25 @@ function RightUtils(props) { const { currentPort, mapMethods, pureMap, currentBranchOffice, bottomUtilsCurrentIndex } = useContext(Context); const [list, setList] = useState(utilsList); - const [isShowChildLevel, setIsShowChildLevel] = useState(false); + const [isShowChildLevel, setIsShowChildLevel] = useState(true); + + const { + controls: rightUtilsControls, + displayedMode: rightUtilsDisplayedMode, + isVisible: rightUtilsIsVisible, + } = useRightUtilsAnimation( + !currentBranchOffice, + currentBranchOffice && bottomUtilsCurrentIndex !== -1, + ); + + const { controls: childMenuControls } = useChildMenuAnimation( + currentBranchOffice && bottomUtilsCurrentIndex !== -1 && isShowChildLevel, + ); useEffect(() => { - mitt.on(initRightUtilsMittKey, () => { - mapMethods.current?.removeFourColorDiagram(); - mapMethods.current?.removeWall(); - }); - - return () => { - mitt.off(initRightUtilsMittKey); - }; - }, [currentBranchOffice]); + if (bottomUtilsCurrentIndex === -1) + setIsShowChildLevel(true); + }, [bottomUtilsCurrentIndex]); useEffect(() => { mitt.on(clickBackMittKey, () => { @@ -112,10 +120,10 @@ function RightUtils(props) { }; const renderPortUtils = () => { - return ( -
- { - list.map((item, index) => ( + if (rightUtilsDisplayedMode === "port") { + return ( + <> + {list.map((item, index) => (
{item.label}
- )) - } -
- ); + ))} + + ); + } + return null; }; const renderBranchOfficeUtils = () => { - return ( -
- -
- { - list.map((item, index) => ( -
clickRightTools(index, item.type)} - > -
{item.label}
- -
- )) - } -
-
- {(bottomUtilsCurrentIndex !== -1) && ( + if (rightUtilsDisplayedMode === "branchOffice") { + return ( + <> + + {list.map((item, index) => ( +
clickRightTools(index, item.type)} + > +
{item.label}
+ +
+ ))} +
setIsShowChildLevel(!isShowChildLevel)} >
- )} -
- ); + + ); + } + return null; }; return (
- {!currentBranchOffice ? renderPortUtils() : renderBranchOfficeUtils()} + + {rightUtilsIsVisible && ( + <> + {renderPortUtils()} + {renderBranchOfficeUtils()} + + )} +
); } diff --git a/src/pages/Container/Map/components/RightUtils/index.less b/src/pages/Container/Map/components/RightUtils/index.less index 4ef1180..703ce4b 100644 --- a/src/pages/Container/Map/components/RightUtils/index.less +++ b/src/pages/Container/Map/components/RightUtils/index.less @@ -77,6 +77,7 @@ text-align: center; border: 1px solid rgba(0, 126, 255, 0.58); border-radius: 50px 50px 0 0; + transform-origin: bottom center; .option { padding: 15px 0; diff --git a/src/pages/Container/Map/components/RightUtils/portUtilsList.js b/src/pages/Container/Map/components/RightUtils/portUtilsList.js deleted file mode 100644 index 462f06f..0000000 --- a/src/pages/Container/Map/components/RightUtils/portUtilsList.js +++ /dev/null @@ -1,72 +0,0 @@ -import backImg from "~/assets/images/map_bi/right_utils/port/back.png"; -import bianjieImg from "~/assets/images/map_bi/right_utils/port/bianjie.png"; -import bianjieOnImg from "~/assets/images/map_bi/right_utils/port/bianjie_on.png"; -import delImg from "~/assets/images/map_bi/right_utils/port/del.png"; -import fullImg from "~/assets/images/map_bi/right_utils/port/full.png"; -import fullOnImg from "~/assets/images/map_bi/right_utils/port/full_on.png"; -import img2Img from "~/assets/images/map_bi/right_utils/port/img2.png"; -import img4Img from "~/assets/images/map_bi/right_utils/port/img4.png"; -import img4OnImg from "~/assets/images/map_bi/right_utils/port/img4_on.png"; -import mapImg from "~/assets/images/map_bi/right_utils/port/map.png"; -import mapOnImg from "~/assets/images/map_bi/right_utils/port/map_on.png"; -import qixiangImg from "~/assets/images/map_bi/right_utils/port/qixiang.png"; -import sisetuImg from "~/assets/images/map_bi/right_utils/port/sisetu.png"; -import sisetuOnImg from "~/assets/images/map_bi/right_utils/port/sisetu_on.png"; - -export const portUtilsList = [ - { - img: backImg, - label: "返回主系统", - type: "back", - }, - { - img: fullImg, - checkImg: fullOnImg, - check: false, - label: "全屏", - type: "full", - }, - { - img: img2Img, - label: "返回中心点", - type: "return", - }, - { - img: img4Img, - checkImg: img4OnImg, - check: false, - label: "切换视角", - type: "scene", - }, - { - img: delImg, - label: "删除标记点", - type: "del", - }, - { - img: mapImg, - checkImg: mapOnImg, - check: false, - label: "纯净地图", - type: "pureMap", - }, - { - img: qixiangImg, - label: "气象监测", - type: "weather", - }, - { - img: sisetuImg, - checkImg: sisetuOnImg, - check: false, - label: "四色图", - type: "fourColor", - }, - { - img: bianjieImg, - checkImg: bianjieOnImg, - check: false, - label: "边界", - type: "wall", - }, -]; diff --git a/src/pages/Container/Map/components/RightUtils/useChildMenuAnimation.js b/src/pages/Container/Map/components/RightUtils/useChildMenuAnimation.js new file mode 100644 index 0000000..81bb30d --- /dev/null +++ b/src/pages/Container/Map/components/RightUtils/useChildMenuAnimation.js @@ -0,0 +1,174 @@ +import { useAnimation } from "motion/react"; +import { useEffect, useRef } from "react"; + +/** + * RightUtils 子菜单动画 Hook + * + * 功能说明: + * 1. 监听子菜单的展开/收起状态变化 + * 2. 执行炫酷的弹跳动画效果(从下向上进入,从上向下离开) + * 3. 支持首次加载动画,初始化时就会显示并执行进入动画 + * 4. 使用弹跳、缩放、透明度三重动画效果,让动画更加生动 + * + * 动画效果详解: + * - 展开动画(进入): + * - 位置:从下方 80px 处冲到上方 15px(超出目标),轻微回落到 5px,最后稳定到 0 + * - 缩放:从 0.9 倍缩小到 1.05 倍(轻微放大),轻微缩小到 0.98,回到正常 1.0 + * - 透明度:从透明到不透明 + * - 时长:0.7 秒 + * - 缓动:使用强弹性贝塞尔曲线 [0.34, 1.56, 0.64, 1],产生明显的过冲和回弹效果 + * + * - 收起动画(离开): + * - 位置:从正常位置向上弹跳 20px,回落到 10px,然后向下离开到 100px + * - 缩放:轻微放大到 1.02,然后缩小到 0.85 + * - 透明度:保持可见再淡出 + * - 时长:0.5 秒 + * - 缓动:使用超弹性曲线 [0.68, -0.6, 0.32, 1.6],产生夸张的反向弹跳 + * + * @param {boolean} isVisible - 子菜单是否可见 + * - true: 子菜单应该展开,执行展开动画 + * - false: 子菜单应该收起,执行收起动画 + * @returns {object} 返回包含以下属性的对象: + * - controls: motion 动画控制器,绑定到 motion.div 的 animate 属性 + */ +export function useChildMenuAnimation(isVisible) { + // ==================== 状态和引用管理 ==================== + + // motion 动画控制器,用于控制 motion.div 的动画 + const controls = useAnimation(); + + // 标记子菜单是否已经执行过展开动画 + // 作用:防止重复执行展开动画,只在首次显示时执行 + const isExpanded = useRef(false); + + // 标记是否是首次渲染 + // 作用:首次渲染时需要特殊处理,确保初始状态正确设置 + const isFirstRender = useRef(true); + + useEffect(() => { + // ==================== 首次渲染 ==================== + if (isFirstRender.current) { + if (isVisible) { + // ===== 执行展开动画:从下向上炫酷弹跳进入 ===== + 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; // 标记首次渲染完成 + }); + } + else { + // 如果首次渲染时不需要显示,直接标记首次渲染完成 + isFirstRender.current = false; + } + return; + } + + // ==================== 展开动画(非首次渲染)==================== + if (isVisible && !isExpanded.current) { + // ===== 从下向上炫酷弹跳进入 ===== + 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; // 标记已展开 + }); + } + + // ==================== 收起动画 ==================== + else if (!isVisible && isExpanded.current) { + // ===== 从上向下炫酷弹跳离开 ===== + 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; // 标记已收起 + }); + } + }, [isVisible, controls]); + + // 返回动画控制器 + return { + controls, + }; +} diff --git a/src/pages/Container/Map/components/RightUtils/useRightUtilsAnimation.js b/src/pages/Container/Map/components/RightUtils/useRightUtilsAnimation.js new file mode 100644 index 0000000..30e1424 --- /dev/null +++ b/src/pages/Container/Map/components/RightUtils/useRightUtilsAnimation.js @@ -0,0 +1,202 @@ +import { useAnimation } from "motion/react"; +import { useEffect, useRef, useState } from "react"; + +/** + * RightUtils 组件动画 Hook + * + * 功能说明: + * 1. 监听显示/隐藏状态变化 + * 2. 执行从右侧进入、从右侧离开的动画效果 + * 3. 支持模式切换(港口工具 ↔ 分公司工具) + * 4. 支持首次加载动画 + * 5. 初始化时就会显示并执行进入动画 + * + * 动画效果: + * - 进入动画:从右侧 100px 处滑入,同时淡入(x: 100 → 0, opacity: 0 → 1) + * - 离开动画:向右侧 100px 处滑出,同时淡出(x: 0 → 100, opacity: 1 → 0) + * + * 模式切换流程: + * 1. 旧模式执行离开动画(0.3秒) + * 2. 离开动画完成后,更新 displayedMode 为新模式 + * 3. 设置元素到初始位置(右侧 100px) + * 4. 执行新模式的进入动画(0.5秒) + * + * @param {boolean} shouldShowPort - 是否应该显示港口工具 + * @param {boolean} shouldShowBranchOffice - 是否应该显示分公司工具 + * @returns {object} 返回包含以下属性的对象: + * - controls: motion 动画控制器 + * - displayedMode: 延迟显示的模式('port' | 'branchOffice' | null) + * - isVisible: 元素可见性状态 + */ +export function useRightUtilsAnimation(shouldShowPort, shouldShowBranchOffice) { + // ==================== 状态和引用管理 ==================== + + // 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(() => { + // ==================== 计算当前应该显示的模式 ==================== + // 根据 shouldShowPort 和 shouldShowBranchOffice 判断当前应该显示哪个模式 + const currentMode = shouldShowPort ? "port" : shouldShowBranchOffice ? "branchOffice" : null; + + // ==================== 首次渲染 ==================== + if (isFirstRender.current) { + if (currentMode) { + // ===== 步骤 1:立即更新显示的模式 ===== + // 设置 displayedMode 为当前模式,让组件知道应该渲染哪种内容 + setDisplayedMode(currentMode); + + // ===== 步骤 2:设置初始动画状态 ===== + // 此时 motion.div 还没有挂载(因为 isVisible 还是 false) + // controls.set() 会将初始状态保存到 controls 对象中 + // 当 motion.div 挂载时,会读取这个初始状态 + controls.set({ + x: 100, // X 轴位置:从右侧 100px 处开始 + opacity: 0, // 透明度:完全透明 + }); + + // ===== 步骤 3:显示元素,触发 motion.div 挂载 ===== + // 设置 isVisible 为 true,触发组件重渲染 + // motion.div 会挂载到 DOM,并读取 controls 的初始状态 { x: 100, opacity: 0 } + 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; + } + + // ==================== 隐藏状态(当前没有工具应该显示)==================== + 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(() => { + // ===== 离开动画完成后,隐藏元素 ===== + setIsVisible(false); // 隐藏内容(motion.div 仍然挂载) + setDisplayedMode(null); // 清空显示的模式 + isAnimating.current = false; // 解除动画锁定 + }); + } + return; + } + + // ==================== 模式切换或显示 ==================== + + // 检查模式是否发生了变化(港口 ↔ 分公司) + const modeChanged = displayedMode !== currentMode; + + // ===== 场景 1:模式切换(港口 ↔ 分公司)===== + 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(() => { + // -------- 步骤 5:动画完成 -------- + isAnimating.current = false; // 解除动画锁定 + }); + }); + } + + // ===== 场景 2:元素不可见,但应该显示(例如:从隐藏状态切换到显示)===== + 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, // 元素可见性状态,用于控制内容的显示/隐藏 + }; +} diff --git a/src/pages/Container/Map/index.js b/src/pages/Container/Map/index.js index b3fb4dd..093b682 100644 --- a/src/pages/Container/Map/index.js +++ b/src/pages/Container/Map/index.js @@ -1,7 +1,7 @@ import { useFullscreen, useMount } from "ahooks"; import { message } from "antd"; import autoFit from "autofit.js"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import useGetUrlQuery from "zy-react-library/hooks/useGetUrlQuery"; import BottomUtils from "./components/BottomUtils"; import CenterUtils from "./components/CenterUtils"; @@ -15,8 +15,6 @@ import { changeCoverMaskVisibleMittKey, clickBranchOfficePointMittKey, clickPortPointMittKey, - initBottomUtilsMittKey, - initRightUtilsMittKey, } from "./js/mittKey"; import "./index.less"; @@ -51,8 +49,10 @@ function Map() { initMap.externalEntryPort(query); } else { - mapMethodsInstance.flyTo(); - mapMethodsInstance.addPortPoint(); + setTimeout(() => { + mapMethodsInstance.flyTo(); + mapMethodsInstance.addPortPoint(); + }, 500); } mitt.on(clickPortPointMittKey, (data) => { @@ -84,11 +84,6 @@ function Map() { }; }); - useEffect(() => { - mitt.emit(initBottomUtilsMittKey); - mitt.emit(initRightUtilsMittKey); - }, [currentBranchOffice]); - const providerValues = useMemo( () => ({ viewer, mapMethods, currentPort, currentBranchOffice, area, bottomUtilsCurrentIndex, pureMap }), [viewer, mapMethods, currentPort, currentBranchOffice, area, bottomUtilsCurrentIndex, pureMap], diff --git a/src/pages/Container/Map/js/mittKey.js b/src/pages/Container/Map/js/mittKey.js index bcb85d7..67a72fc 100644 --- a/src/pages/Container/Map/js/mittKey.js +++ b/src/pages/Container/Map/js/mittKey.js @@ -10,8 +10,6 @@ export const clickBackMittKey = "clickBack"; export const resetBottomCurrentIndexMittKey = "resetBottomCurrentIndex"; // 重置所有底部工具栏选中状态 export const resetAllBottomUtilsCheckMittKey = "resetAllBottomUtilsCheck"; -// 初始化底部工具栏 -export const initBottomUtilsMittKey = "initBottomUtils"; // 改变覆盖蒙版显隐 export const changeCoverMaskVisibleMittKey = "changeCoverMaskVisible"; // 改变人员轨迹选择窗口显隐 @@ -20,5 +18,3 @@ export const changePeopleTrajectorySelectVisibleMittKey = "changePeopleTrajector export const providePeoplePositionListMittKey = "providePeoplePositionList"; // 删除人员轨迹点位 export const deletePeoplePositionPointMittKey = "deletePeoplePositionPoint"; -// 初始化右侧工具栏 -export const initRightUtilsMittKey = "initRightUtils"; diff --git a/src/pages/Container/Map/js/pointClickEvent.js b/src/pages/Container/Map/js/pointClickEvent.js index a37aea5..da1ac37 100644 --- a/src/pages/Container/Map/js/pointClickEvent.js +++ b/src/pages/Container/Map/js/pointClickEvent.js @@ -73,6 +73,8 @@ export default class PointClickEvent { mitt.emit(clickPortPointMittKey, data); } else { + this.#mapMethods.removeFourColorDiagram(); + this.#mapMethods.removeWall(); this.#mapMethods.flyTo({ longitude: data.position.x, latitude: data.position.y, height: 2000 }); this.#mapMethods.addBranchOfficePoint("", { corpName: data.name, @@ -115,6 +117,8 @@ export default class PointClickEvent { this.#mapMethods.removeBranchOfficePoint(); this.#mapMethods.removeMarkPoint(); this.#mapMethods.addBranchOfficePoint("", data); + this.#mapMethods.removeFourColorDiagram(); + this.#mapMethods.removeWall(); this.#mapMethods.flyTo({ longitude: data.longitude, latitude: data.latitude, height: 2000 }); mitt.emit(deletePeoplePositionPointMittKey); mitt.emit(clickBranchOfficePointMittKey, data);