地图添加过渡动画

master
LiuJiaNan 2026-01-15 18:01:40 +08:00
parent efc735f01d
commit 6d2e201730
23 changed files with 1880 additions and 510 deletions

View File

@ -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 (
<div className="bottom_utils branch_office">
{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 (
<motion.div
key={index}
ref={el => (portUtilsOptionRefs.current[index] = el)}
className="option"
animate={portUtilsHasInteracted ? portUtilsParentControls[index] : false}
onClick={() => !portUtilsIsAnimating && !isAllowClick && optionsClick(index)}
style={{
cursor: isAllowClick ? "default" : "pointer",
}}
>
<img src={isCurrentActive ? item.checkImg : item.img} alt="" className="img" />
<div className="label" style={{ backgroundImage: `url(${isCurrentActive ? titleOnImg : titleImg})` }}>
{item.label}
</div>
{isCurrentActive && (
<motion.div
className="child_items"
animate={portUtilsChildControls}
initial={{ opacity: 0 }}
>
{item.list?.map((item1, index1) => {
return (
<motion.div
key={index1}
className="child_item"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: index1 * portUtilsAnimationConfig.staggerDelay }}
onClick={(e) => {
e.stopPropagation();
optionsItemsClick(index, index1, item, item1);
}}
>
<img src={item1.check ? item1.checkImg : item1.img} alt="" className="child_img" />
<div className="child_label">{item1.label}</div>
</motion.div>
);
})}
</motion.div>
)}
</motion.div>
);
});
}
};
const renderBranchOfficeUtils = () => {
if (bottomUtilsDisplayedMode === "branchOffice") {
return (
list.map((item, index) => {
const isCurrentActive = bottomUtilsCurrentIndex === index;
return (
<div
key={index}
@ -92,92 +169,44 @@ function BottomUtils(props) {
onClick={() => optionsClick(index)}
>
<div className="label">{item.label}</div>
<CSSTransition
timeout={500}
classNames={{
enter: "animate__animated animate__faster",
enterActive: "animate__animated animate__faster animate__fadeInUp",
exit: "animate__animated animate__faster",
exitActive: "animate__animated animate__faster animate__fadeOutDown",
}}
unmountOnExit
in={(isCurrentActive && item.list?.length > 0)}
>
<div className="items">
{item.list?.map((item1, index1) => (
<div
key={index1}
className={`item ${item1.check ? "active" : ""}`}
onClick={(e) => {
e.stopPropagation();
optionsItemsClick(index, index1, item, item1);
}}
>
<div className="child_label">{item1.label}</div>
</div>
))}
</div>
</CSSTransition>
<AnimatePresence>
{(isCurrentActive && item.list?.length > 0) && (
<motion.div className="items" initial="hidden" animate="visible" exit="hidden" variants={branchOfficeUtilsContainerVariants}>
{item.list?.map((item1, index1) => {
return (
<motion.div
key={index1}
className={`item ${item1.check ? "active" : ""}`}
onClick={(e) => {
e.stopPropagation();
optionsItemsClick(index, index1, item, item1);
}}
variants={branchOfficeUtilsChildItemVariants}
>
<div className="child_label">{item1.label}</div>
</motion.div>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
);
};
const renderPortUtils = () => {
return (
<div className="bottom_utils port">
{list.map((item, index) => {
const isCurrentActive = bottomUtilsCurrentIndex === index;
const hasActiveChildren = bottomUtilsCurrentIndex !== -1;
if (hasActiveChildren && !isCurrentActive) {
return null;
}
return (
<div key={index} className="option" onClick={() => optionsClick(index)}>
<img src={isCurrentActive ? item.checkImg : item.img} alt="" className="img" />
<div
className="label"
style={{ backgroundImage: `url(${isCurrentActive ? titleOnImg : titleImg})` }}
>
{item.label}
</div>
{(isCurrentActive && item.list?.length > 0) && (
<div className="items">
{item.list?.map((item1, index1) => (
<div
key={index1}
className="item"
onClick={(e) => {
e.stopPropagation();
optionsItemsClick(index, index1, item, item1);
}}
>
<img src={item1.check ? item1.checkImg : item1.img} alt="" className="child_img" />
<div className="child_label">{item1.label}</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
);
};
const renderUtils = () => {
if (pureMap || !currentPort)
return (<div></div>);
return (!currentBranchOffice ? renderPortUtils() : renderBranchOfficeUtils());
})
);
}
};
return (
<div className="map_content_bottom_utils_container">
{renderUtils()}
<motion.div className={bottomUtilsDisplayedMode === "port" ? "bottom_utils port" : "bottom_utils branch_office"} animate={bottomUtilsControls}>
{bottomUtilsIsVisible && (
<>
{renderPortUtils()}
{renderBranchOfficeUtils()}
</>
)}
</motion.div>
</div>
);
}

View File

@ -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;

View File

@ -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, // 元素可见性状态,用于控制内容的显示/隐藏
};
}

View File

@ -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 属性
};
}

View File

@ -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
};
}

View File

@ -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 (
<div className="map_content_center_options_container">
<CSSTransition
timeout={1000}
classNames={{
enter: "animate__animated",
enterActive: "animate__animated animate__bounceInDown",
exit: "animate__animated",
exitActive: "animate__animated animate__bounceOutUp",
}}
unmountOnExit
in={(currentPort === "00003" && !currentBranchOffice && !pureMap)}
>
<div>
<div className="center_options">
<div
className="guang"
style={{ backgroundImage: `url(${guangImg})` }}
/>
{list.map((item, index) => (
<motion.div animate={containerControls} className="center_utils">
{isContainerVisible && (
<>
<div className="center_options">
<div
key={index}
className={`option option${index}`}
style={{
backgroundImage: `url(${activeIndex === index
? [tabLeftOnImg, tabMidOnImg, tabRightOnImg][index]
: [tabLeftImg, tabMidImg, tabRightImg][index]})`,
}}
onClick={() => onChangeArea(index)}
>
{item.label}
</div>
))}
</div>
<div className="statistics">
<CSSTransition
timeout={1000}
classNames={{
enter: "animate__animated",
enterActive: "animate__animated animate__bounceIn",
exit: "animate__animated",
exitActive: "animate__animated animate__bounceOut",
}}
unmountOnExit
in={(activeIndex === 0 || activeIndex === 1)}
>
<div className="statistic">
<div className="title" style={{ backgroundImage: `url(${statisticsTitleImg})` }}>西港区</div>
<div className="info">
<div className="value">人数33</div>
<div className="value">车辆33</div>
className="guang"
style={{ backgroundImage: `url(${guangImg})` }}
/>
{list.map((item, index) => (
<div
key={index}
className={`option option${index}`}
style={{
backgroundImage: `url(${activeIndex === index
? [tabLeftOnImg, tabMidOnImg, tabRightOnImg][index]
: [tabLeftImg, tabMidImg, tabRightImg][index]})`,
}}
onClick={() => onChangeArea(index)}
>
{item.label}
</div>
</div>
</CSSTransition>
<CSSTransition
timeout={1000}
classNames={{
enter: "animate__animated",
enterActive: "animate__animated animate__bounceIn",
exit: "animate__animated",
exitActive: "animate__animated animate__bounceOut",
}}
unmountOnExit
in={(activeIndex === 2 || activeIndex === 1)}
>
<div className="statistic">
<div className="title" style={{ backgroundImage: `url(${statisticsTitleImg})` }}>东港区</div>
<div className="info">
<div className="value">人数33</div>
<div className="value">车辆33</div>
</div>
</div>
</CSSTransition>
</div>
</div>
</CSSTransition>
))}
</div>
<div className="statistics">
<motion.div animate={westControls}>
{isWestVisible && (
<div className="statistic">
<div className="title" style={{ backgroundImage: `url(${statisticsTitleImg})` }}>西港区</div>
<div className="info">
<div className="value">人数33</div>
<div className="value">车辆33</div>
</div>
</div>
)}
</motion.div>
<motion.div animate={eastControls}>
{isEastVisible && (
<div className="statistic">
<div className="title" style={{ backgroundImage: `url(${statisticsTitleImg})` }}>东港区</div>
<div className="info">
<div className="value">人数33</div>
<div className="value">车辆33</div>
</div>
</div>
)}
</motion.div>
</div>
</>
)}
</motion.div>
</div>
);
}

View File

@ -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 {
}
}
}
}

View File

@ -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,
};
}

View File

@ -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 <PortIndex />;
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 <BranchOfficeIndexLeft />;
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 <BranchOfficeIndexRight />;
};
const renderContent = () => {
if (pureMap)
return null;
return (
<>
{!collapseLeft && (
<div
className={`map_content_container__content ${(!currentPort || (currentPort === "00003" && !currentBranchOffice)) ? "port" : "branch_office"}`}
{isLeftVisible && (
<motion.div
animate={leftControls}
className={`map_content_container__content ${(!leftDisplayedContent.currentPort || (leftDisplayedContent.currentPort === "00003" && !leftDisplayedContent.currentBranchOffice)) ? "port" : "branch_office"}`}
style={{ left: 35 }}
>
{!currentPort && <IndexInfo />}
{(currentPort === "00003" && !currentBranchOffice) && renderPortContent()}
{currentBranchOffice && renderBranchOfficeContentLeft()}
</div>
{!leftDisplayedContent.currentPort && <IndexInfo />}
{(leftDisplayedContent.currentPort === "00003" && !leftDisplayedContent.currentBranchOffice) && renderPortContent()}
{leftDisplayedContent.currentBranchOffice && renderBranchOfficeContentLeft()}
</motion.div>
)}
{!collapseRight && (
<div
className={`map_content_container__content ${(!currentPort || (currentPort === "00003" && !currentBranchOffice)) ? "port" : "branch_office"}`}
{isRightVisible && (
<motion.div
animate={rightControls}
className={`map_content_container__content ${(!leftDisplayedContent.currentPort || (leftDisplayedContent.currentPort === "00003" && !leftDisplayedContent.currentBranchOffice)) ? "port" : "branch_office"}`}
style={{ right: 35 }}
>
{currentBranchOffice && renderBranchOfficeContentRight()}
</div>
{rightDisplayedContent.currentBranchOffice && renderBranchOfficeContentRight()}
</motion.div>
)}
</>
);
@ -118,9 +148,7 @@ function Content() {
<div
className={`collapse_menu port left ${collapseLeft ? "active" : ""}`}
style={{ backgroundImage: `url(${collapseMenuBg1})` }}
onClick={() => {
setCollapseLeft(!collapseLeft);
}}
onClick={() => handleLeftCollapse(setCollapseLeft)}
>
<img src={collapseMenuImg1} alt="" />
</div>
@ -136,9 +164,7 @@ function Content() {
<div
className={`collapse_menu branch_office left ${collapseLeft ? "active" : ""}`}
style={{ backgroundImage: `url(${collapseMenuBg2})` }}
onClick={() => {
setCollapseLeft(!collapseLeft);
}}
onClick={() => handleLeftCollapse(setCollapseLeft)}
>
<img src={collapseMenuImg2} alt="" />
</div>
@ -147,9 +173,7 @@ function Content() {
<div
className={`collapse_menu branch_office right ${collapseRight ? "active" : ""}`}
style={{ backgroundImage: `url(${collapseMenuBg2})` }}
onClick={() => {
setCollapseRight(!collapseRight);
}}
onClick={() => handleRightCollapse(setCollapseRight)}
>
<img src={collapseMenuImg2} alt="" />
</div>

View File

@ -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;
}
}

View File

@ -0,0 +1,243 @@
import { useAnimation } from "motion/react";
import { useEffect, useRef, useState } from "react";
/**
* 内容切换动画 Hook
*
* 功能说明
* 1. 管理内容面板的显示/隐藏状态
* 2. 处理内容切换时的动画效果离开 + 进入
* 3. 处理折叠/展开时的动画效果
* 4. 处理纯地图模式下的动画效果
*
* 优先级pureMap > 折叠状态 > 内容变化
*
* @param {object} currentContent - 当前要显示的内容状态包含 currentPortcurrentBranchOfficebottomUtilsCurrentIndex
* @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, // 折叠/展开处理函数
};
}

View File

@ -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 (
<div className="map_content_header_container">
<CSSTransition
timeout={animationTime}
classNames={{
enter: "animate__animated",
enterActive: "animate__animated animate__fadeInDown",
exit: "animate__animated",
exitActive: "animate__animated animate__fadeOutUp",
}}
unmountOnExit
in={animationShow}
<motion.header
animate={controls}
className={`${displayedTitle === "秦港股份安全监管平台" ? "port" : "branch_office"}`}
style={{ backgroundImage: `url(${displayedTitle === "秦港股份安全监管平台" ? topImg1 : topImg2})` }}
>
<header
className={`${displayTitle === "秦港股份安全监管平台" ? "port" : "branch_office"}`}
style={{ backgroundImage: `url(${displayTitle === "秦港股份安全监管平台" ? topImg1 : topImg2})` }}
>
{(currentPort && displayTitle === "秦港股份安全监管平台") && (
<div style={{ backgroundImage: `url(${backImg1})` }} className="back" onClick={onBack} />
)}
{displayTitle !== "秦港股份安全监管平台" && (
<div className="back" onClick={onBack}>
<img src={backImg2} alt="" />
<div>返回</div>
</div>
)}
<div className="title">{displayTitle}</div>
<div style={{ backgroundImage: `url(${guangImg})` }} className="guang" />
</header>
</CSSTransition>
{(currentPort && displayedTitle === "秦港股份安全监管平台") && (
<div style={{ backgroundImage: `url(${backImg1})` }} className="back" onClick={onBack} />
)}
{displayedTitle !== "秦港股份安全监管平台" && (
<div className="back" onClick={onBack}>
<img src={backImg2} alt="" />
<div>返回</div>
</div>
)}
<div className="title">{displayedTitle}</div>
<div style={{ backgroundImage: `url(${guangImg})` }} className="guang" />
</motion.header>
</div>
);
}

View File

@ -9,6 +9,7 @@
text-align: center;
font-weight: bold;
position: absolute;
opacity: 0;
&.port {
padding-top: 10px;

View File

@ -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, // 延迟显示的标题,用于在动画过渡期间保持视觉连续性
};
}

View File

@ -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",
},
];

View File

@ -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 (
<div className="right_utils port">
{
list.map((item, index) => (
if (rightUtilsDisplayedMode === "port") {
return (
<>
{list.map((item, index) => (
<div
key={index}
className="option"
@ -124,56 +132,54 @@ function RightUtils(props) {
<div style={{ backgroundImage: `url(${tooltipImg1})` }} className="tooltip">{item.label}</div>
<img src={item.check ? item.checkImgPort : item.imgPort} alt="" />
</div>
))
}
</div>
);
))}
</>
);
}
return null;
};
const renderBranchOfficeUtils = () => {
return (
<div className="right_utils branch_office">
<CSSTransition
timeout={1000}
classNames={{
enter: "animate__animated animate__faster",
enterActive: "animate__animated animate__faster animate__bounceInUp",
exit: "animate__animated animate__faster",
exitActive: "animate__animated animate__faster animate__bounceOutDown",
}}
unmountOnExit
in={(isShowChildLevel && bottomUtilsCurrentIndex !== -1)}
>
<div className="options">
{
list.map((item, index) => (
<div
key={index}
className={`option ${item.check ? "active" : ""}`}
onClick={() => clickRightTools(index, item.type)}
>
<div style={{ backgroundImage: `url(${tooltipImg2})` }} className="tooltip">{item.label}</div>
<img src={item.imgBranchOffice} alt="" />
</div>
))
}
</div>
</CSSTransition>
{(bottomUtilsCurrentIndex !== -1) && (
if (rightUtilsDisplayedMode === "branchOffice") {
return (
<>
<motion.div className="options" animate={childMenuControls}>
{list.map((item, index) => (
<div
key={index}
className={`option ${item.check ? "active" : ""}`}
onClick={() => clickRightTools(index, item.type)}
>
<div style={{ backgroundImage: `url(${tooltipImg2})` }} className="tooltip">{item.label}</div>
<img src={item.imgBranchOffice} alt="" />
</div>
))}
</motion.div>
<div
className={`bg ${isShowChildLevel ? "active" : ""}`}
onClick={() => setIsShowChildLevel(!isShowChildLevel)}
>
<img src={buttonBg} alt="" />
</div>
)}
</div>
);
</>
);
}
return null;
};
return (
<div className="map_content_right_utils_container">
{!currentBranchOffice ? renderPortUtils() : renderBranchOfficeUtils()}
<motion.div
className={rightUtilsDisplayedMode === "port" ? "right_utils port" : "right_utils branch_office"}
animate={rightUtilsControls}
>
{rightUtilsIsVisible && (
<>
{renderPortUtils()}
{renderBranchOfficeUtils()}
</>
)}
</motion.div>
</div>
);
}

View File

@ -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;

View File

@ -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",
},
];

View File

@ -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 秒
// 关键帧时间点分布:
// ① 00% 时0s在 y=80 处
// ② 0.550% 时0.35s)在 y=-15 处(过冲峰值)
// ③ 0.7575% 时0.525s)在 y=5 处(回落)
// ④ 1100% 时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 秒(比进入动画短,显得更轻快)
// 关键帧时间点分布:
// ① 00% 时0s在正常位置 y=0
// ② 0.220% 时0.1s)在 y=-20 处(快速弹起)
// ③ 0.550% 时0.25s)在 y=10 处(回落)
// ④ 1100% 时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,
};
}

View File

@ -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, // 元素可见性状态,用于控制内容的显示/隐藏
};
}

View File

@ -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],

View File

@ -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";

View File

@ -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);