说在前面
📸 每次旅游完,大家手机相册里是不是都会多几百上千张照片?风景照、美食照、打卡照......拍的时候一顿猛拍,回来之后全都躺在相册吃灰,翻都懒得翻。
想做成视频却又无从下手?那么可以试试这个工具,导入图片一键生成炫酷的 3D 照片球播放动画

在线体验
体验地址 :jyeontu.xyz/htmlDemo/3D...


主要功能:
- 🌍 照片均匀分布在 3D 球面上,自动缓慢旋转展示
- 🖱️ 支持鼠标拖拽旋转,松手后有惯性效果
- 📱 支持手机端触摸拖拽
- 🔍 点击照片可放大查看
- 🎬 自动播放模式:全屏逐张展示,照片从球面飞到中央放大,像 3D 电子相册
- 📤 支持上传自己的照片(最多 36 张)
- 💾 一键导出为独立的 HTML 文件,发给谁都能直接打开
- 🖥️ 全屏展示模式
想要制作自己的图片动画可以直接打开体验地址,上传自己的照片,然后自动播放并录屏即可~
对代码实现有兴趣或者想要源码的同学可以继续往下看~
代码实现
1、页面基础结构
先搭一个简单的 HTML 骨架,主要就三个部分:3D 球体容器、底部操作面板、图片放大弹窗:
html
<body>
<!-- 3D球体容器 -->
<div class="tagcloud-wrapper">
<div class="tagcloud"></div>
</div>
<!-- 上传与导出控制面板 -->
<div class="control-panel">
<input type="file" id="imageInput" accept="image/*" multiple />
<button type="button" id="uploadBtn">上传图片</button>
<button type="button" id="exportBtn">导出 HTML</button>
<button type="button" id="autoPlayBtn">自动播放</button>
<button type="button" id="fullscreenBtn">全屏展示</button>
<p class="hint">支持多选,上传后将覆盖并只展示所选图片,最多 36 张</p>
</div>
<!-- 图片放大查看模态框 -->
<div class="image-modal" id="imageModal">
<div class="image-modal-close" id="closeModal">×</div>
<div class="image-modal-content">
<img id="modalImage" src="" alt="放大图片" />
</div>
</div>
</body>
结构很简单,核心就是 .tagcloud-wrapper 这个容器,里面的 .tagcloud 会作为 3D 旋转的载体,所有照片都会动态生成在这里面。
2、CSS 3D 场景搭建
这一步是整个效果的关键基础------我们需要用 CSS 创建一个 3D 空间:
css
.tagcloud-wrapper {
position: relative;
width: 80vh;
height: 80vh;
max-width: 100vw;
perspective: 1000px; /* 透视距离,值越小3D效果越强 */
cursor: grab;
}
.tagcloud {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
transform-style: preserve-3d; /* 关键!让子元素保留3D变换 */
transform-origin: center center;
will-change: transform;
transform: translate(-50%, -50%);
}
这里有两个最重要的 CSS 属性:
- perspective: 1000px:给父容器设置透视距离,有了透视距离,才会有近大远小的 3D 效果
- transform-style: preserve-3d :让子元素保持在 3D 空间中。如果不加这个,子元素的
translate3d会被"拍平"成 2D,就没有立体感了
每张照片的样式也需要开启 3D:
css
.tagcloud__item {
position: absolute;
top: 50%;
left: 50%;
will-change: transform;
transform-style: preserve-3d;
transform-origin: 0 0;
transition: opacity 0.2s ease, z-index 0s;
}
.tagcloud__item img {
display: inline-block;
transform: translate(-50%, -50%);
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
border: 2px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
}
球体背面的照片我们把透明度降低,模拟"远处模糊"的效果:
css
.tagcloud__item.tagcloud__item--back {
opacity: 0.4;
pointer-events: none;
}
另外还有自动播放相关的样式,后面会详细讲到,这里先贴出来:
css
/* 自动播放:照片居中摆正动画 */
.tagcloud__item.autoplay-highlight {
opacity: 1 !important;
z-index: 200;
transition: transform 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0s !important;
}
/* 居中后放大到 4 倍 */
.tagcloud__item.autoplay-zoomed img {
transform: translate(-50%, -50%) scale(4);
box-shadow: 0 10px 40px rgba(52, 152, 219, 0.6);
border-color: var(--tag-hover-bg-color);
}
/* 自动播放时,非高亮的照片进一步暗淡,突出当前展示的照片 */
.tagcloud.autoplay-dimmed .tagcloud__item:not(.autoplay-highlight) {
opacity: 0.15 !important;
transition: opacity 0.4s ease;
}
这几个 class 会在自动播放的不同阶段被动态添加/移除,配合 CSS transition 实现丝滑的飞入、放大、飞回动画。
3、Fibonacci 球面分布算法
这是整个项目最核心的部分------怎么把照片均匀地分布在一个球面上?
大家可能第一反应是用经纬度均匀分割,但经纬度分割有个很大的问题:两极会很密集,赤道会很稀疏,分布非常不均匀。
这里我们用一个非常经典的算法------Fibonacci 球面分布(黄金角分布)。它的核心思想是利用黄金角(约 137.5°)来决定每个点的经度偏移,再用均匀的高度分割来确定纬度,最终得到近似均匀的球面分布:
javascript
const MAX_TAGS = 36;
let radius = Math.min(wrapper.offsetWidth, wrapper.offsetHeight) / 2.4;
function createTags() {
tagCloud.innerHTML = "";
tagElements.length = 0;
const uniqueList = [...new Set(currentImageList)];
const totalTags = Math.min(MAX_TAGS, uniqueList.length);
/* 黄金角弧度,使经度方向均匀分散 */
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < totalTags; i++) {
const imageUrl = uniqueList[i];
const tagEl = document.createElement("div");
tagEl.className = "tagcloud__item";
const imgEl = document.createElement("img");
imgEl.src = imageUrl;
imgEl.alt = `图片 ${i + 1}`;
imgEl.loading = "lazy";
tagEl.appendChild(imgEl);
/* Fibonacci 球面分布:高度用 (i+0.5)/n 避免两极过密 */
const y = 1 - (2 * (i + 0.5)) / totalTags;
const radiusAtY = Math.sqrt(Math.max(1 - y * y, 0.01));
const theta = goldenAngle * i;
const x = Math.cos(theta) * radiusAtY;
const z = Math.sin(theta) * radiusAtY;
// 将单位球坐标映射到实际像素
const finalX = x * radius;
const finalY = y * radius;
const finalZ = z * radius;
// 计算朝向角度,让照片"面向"球心
const yaw = Math.atan2(x, z);
const pitch = Math.atan2(y, Math.sqrt(x * x + z * z));
const yaw_deg = (yaw * 180) / Math.PI;
const pitch_deg = (pitch * 180) / Math.PI;
tagEl.style.transform =
`translate3d(${finalX}px, ${finalY}px, ${finalZ}px) rotateY(${yaw_deg}deg) rotateX(${-pitch_deg}deg)`;
// 保存单位坐标,后续用于背面判断
tagEl.dataset.x = String(x);
tagEl.dataset.y = String(y);
tagEl.dataset.z = String(z);
tagElements.push(tagEl);
tagCloud.appendChild(tagEl);
}
}
来解释一下关键代码:
(1)黄金角
javascript
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
这个值约等于 2.3999...弧度(137.5°),是自然界中最"不理性"的角度(向日葵种子就是按黄金角排列的)。每个点的经度偏移一个黄金角,可以保证点不会在经度方向上"排成一列"。
(2)均匀高度分割
javascript
const y = 1 - (2 * (i + 0.5)) / totalTags;
y 的范围从接近 1(北极)到接近 -1(南极),+0.5 的作用是让第一个和最后一个点不要正好落在两极上,这样分布更均匀。
(3)球面坐标转直角坐标
javascript
const radiusAtY = Math.sqrt(Math.max(1 - y * y, 0.01));
const x = Math.cos(theta) * radiusAtY;
const z = Math.sin(theta) * radiusAtY;
知道了高度 y 和角度 theta,就可以算出 x 和 z,三个坐标组合就是球面上一个点的位置。
(4)照片朝向计算
javascript
const yaw = Math.atan2(x, z);
const pitch = Math.atan2(y, Math.sqrt(x * x + z * z));
给每张照片加上 rotateY 和 rotateX,让它们的"正面"朝向球心外侧,这样无论球怎么转,照片看起来都是正面对着你的。
4、3D 旋转与惯性动画
球体搭好了,接下来要让它动起来。我们用 requestAnimationFrame 实现每一帧的旋转更新:
javascript
let angleX = 0, angleY = 0; // 当前旋转角度
let velX = 0, velY = 0.05; // 旋转速度
const friction = 0.98; // 摩擦系数
let isAutoPlaying = false; // 自动播放状态
function animate() {
if (isAutoPlaying) {
// 自动播放模式:平滑插值旋转到目标照片正面
if (autoPlayPhase === "rotating") {
const lerpFactor = 0.05;
const dx = autoPlayTargetX - angleX;
const dy = autoPlayTargetY - angleY;
angleX += dx * lerpFactor;
angleY += dy * lerpFactor;
// 接近目标时切换到展示阶段
if (Math.abs(dx) < 0.3 && Math.abs(dy) < 0.3) {
angleX = autoPlayTargetX;
angleY = autoPlayTargetY;
autoPlayPhase = "showing";
// ...开始展示动画(下一节详细讲)
}
}
// 'showing' 阶段保持静止
} else if (!isDragging) {
// 手动模式:应用速度和摩擦力
angleY += velY;
angleX += velX;
velY *= friction;
velX *= friction;
// 初次进入页面时,保持缓慢自动旋转
if (!userHasInteracted && Math.abs(velX) < 0.0001 && Math.abs(velY) < 0.0001) {
velY = 0.05 * (Math.random() > 0.5 ? 1 : -1);
}
}
// 应用旋转变换到球体容器
tagCloud.style.transform =
`translate(-50%, -50%) rotateY(${angleY}deg) rotateX(${-angleX}deg)`;
updateBackfaceVisibility();
requestAnimationFrame(animate);
}
这里的思路是:
- 摩擦系数 0.98 :每帧速度都乘以 0.98,所以松手后球会慢慢减速,最终停下来,模拟物理中的惯性效果
- 自动旋转:页面刚打开时,球会缓慢自动旋转;用户拖拽过一次后就不再自动恢复旋转了
- 自动播放分支 :当
isAutoPlaying为 true 时,animate 函数不再执行惯性逻辑,而是用lerp(线性插值)平滑地将球体旋转到目标角度,让当前照片转到正面 - 整个球体的旋转只需要改
.tagcloud容器的rotateX和rotateY,里面所有照片会跟着一起转,这就是 CSStransform-style: preserve-3d的魅力
5、鼠标/触摸拖拽交互
用户可以用鼠标拖拽球体旋转,这部分的实现需要注意几个细节:
javascript
let isDragging = false;
let dragStartX, dragStartY;
let lastMouseX, lastMouseY;
const MOVE_THRESHOLD_PX = 2; // 移动超过2px才开始旋转
function screenDeltaToAngleDelta(dx, dy) {
const sensitivity = 0.08;
const ay = (angleY * Math.PI) / 180;
const cosY = Math.cos(ay);
// 背面时上下方向反转,让拖拽方向始终符合直觉
const signY = cosY >= 0 ? 1 : -1;
return {
dAngleX: dy * sensitivity * signY,
dAngleY: dx * sensitivity,
};
}
wrapper.addEventListener("mousedown", (e) => {
if (e.target.tagName === "IMG") return; // 点击照片不触发拖拽
// 自动播放中点击,直接停止播放
if (isAutoPlaying) {
stopAutoPlay();
return;
}
isDragging = true;
userHasInteracted = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
lastMouseX = e.clientX;
lastMouseY = e.clientY;
velX = 0;
velY = 0;
wrapper.style.cursor = "grabbing";
});
window.addEventListener("mousemove", (e) => {
if (!isDragging) return;
const dx = e.clientX - lastMouseX;
const dy = e.clientY - lastMouseY;
const totalMove = Math.sqrt(
(e.clientX - dragStartX) ** 2 + (e.clientY - dragStartY) ** 2
);
if (totalMove >= MOVE_THRESHOLD_PX) {
const { dAngleX, dAngleY } = screenDeltaToAngleDelta(dx, dy);
angleX += dAngleX;
angleY += dAngleY;
// 混合当前速度和拖拽速度,让惯性更平滑
const velocityBlend = 0.4;
velX = velX * (1 - velocityBlend) + dAngleX * velocityBlend;
velY = velY * (1 - velocityBlend) + dAngleY * velocityBlend;
}
lastMouseX = e.clientX;
lastMouseY = e.clientY;
});
这里有几个巧妙的处理:
- 自动播放中断:如果当前正在自动播放,用户点击球体会立即停止播放,回到手动模式
- 移动阈值:移动超过 2px 才开始旋转,避免手指按下时的微小抖动导致球体抖动
- 背面方向反转 :当球转到背面时,上下拖拽的方向会自动反转。想象你在转一个地球仪,当你看到地球仪的背面时,手往下拨,地球仪顶部其实是往上走的。
screenDeltaToAngleDelta函数里的signY就是处理这个问题的 - 速度混合 :用
velocityBlend = 0.4做一个平滑混合,让松手后的惯性速度不会突变,体验更自然
触摸端的处理逻辑和鼠标端基本一致,只是把 clientX/clientY 换成 touches[0].clientX/clientY,这里就不重复贴代码了。
6、背面照片的透明度处理
球体旋转时,背面的照片应该变暗变透明,正面的照片要清晰可见。我们通过矩阵运算来判断每张照片当前是在正面还是背面:
javascript
function updateBackfaceVisibility() {
const ax = (-angleX * Math.PI) / 180;
const ay = (angleY * Math.PI) / 180;
const cosX = Math.cos(ax), sinX = Math.sin(ax);
const cosY = Math.cos(ay), sinY = Math.sin(ay);
tagElements.forEach((el) => {
// 自动播放高亮中的照片始终保持正面可见,跳过背面判断
if (el.classList.contains("autoplay-highlight")) return;
const x = parseFloat(el.dataset.x);
const y = parseFloat(el.dataset.y);
const z = parseFloat(el.dataset.z);
// 先绕X轴旋转,再绕Y轴旋转,取最终的Z分量
const zAfterX = y * sinX + z * cosX;
const viewZ = -x * sinY + zAfterX * cosY;
// viewZ < 0 说明在观察者背后,添加背面样式
el.classList.toggle("tagcloud__item--back", viewZ < 0);
});
}
原理很简单:在 CSS 3D 坐标系中,Z 轴正方向指向观察者(也就是你的眼睛)。我们用每张照片的初始坐标,经过当前旋转角度变换后,看它的 Z 分量是正还是负------正的就是面向你的,负的就是背对你的,给背面照片加个 opacity: 0.4 的 class 就行了。
注意这里有一个细节:自动播放高亮中的照片要跳过背面判断,因为它已经飞到屏幕中央了,不管球怎么转,它都应该始终可见。
7、图片上传与预览
支持用户上传自己的照片来替换默认的图片,这里用的是 FileReader API:
javascript
const imageInput = document.getElementById("imageInput");
const uploadBtn = document.getElementById("uploadBtn");
uploadBtn.addEventListener("click", () => imageInput.click());
imageInput.addEventListener("change", (e) => {
const files = Array.from(e.target.files || []);
const imageFiles = files.filter((f) => f.type.startsWith("image/"));
if (imageFiles.length === 0) {
e.target.value = "";
return;
}
const newUrls = [];
let loaded = 0;
imageFiles.forEach((file) => {
const reader = new FileReader();
reader.onload = (ev) => {
newUrls.push(ev.target.result); // base64 格式的图片数据
loaded++;
if (loaded === imageFiles.length) {
currentImageList = newUrls; // 替换当前图片列表
createTags(); // 重新创建球体
e.target.value = "";
}
};
reader.readAsDataURL(file); // 读取为 base64
});
});
这里的流程是:
- 点击「上传图片」按钮 → 触发隐藏的
<input type="file">→ 用户选择图片 - 用
FileReader.readAsDataURL()把图片文件读成 base64 字符串 - 等所有图片都读取完成后,替换掉
currentImageList,然后重新调用createTags()生成新的球体
8、导出为独立 HTML 文件
这个功能很实用------把当前的照片球导出成一个独立的 HTML 文件,不依赖任何服务器,双击就能打开:
javascript
exportBtn.addEventListener("click", () => {
// 克隆整个文档
const clone = document.documentElement.cloneNode(true);
// 移除上传、导出按钮和提示文字,保留自动播放和全屏按钮
const panel = clone.querySelector(".control-panel");
if (panel) {
const fileInput = panel.querySelector("#imageInput");
const uploadButton = panel.querySelector("#uploadBtn");
const exportButton = panel.querySelector("#exportBtn");
const hint = panel.querySelector(".hint");
if (fileInput) fileInput.remove();
if (uploadButton) uploadButton.remove();
if (exportButton) exportButton.remove();
if (hint) hint.remove();
}
// 替换 JS 中的图片数据为当前展示的图片
const scripts = clone.querySelectorAll("script");
const mainScript = scripts[scripts.length - 1];
let scriptContent = mainScript.textContent;
const newTagsStr = "const tagsData = " + JSON.stringify(currentImageList) + ";";
scriptContent = scriptContent.replace(/const tagsData = \[[\s\S]*?\];/, newTagsStr);
// 移除上传/导出相关的 JS 代码,保留全屏和自动播放
scriptContent = scriptContent.replace(
/\n\s*\/\/ 上传图片:点击按钮触发文件选择[\s\S]*?(?=\n\s*\/\/ 全屏展示)/,
""
);
mainScript.textContent = scriptContent;
// 生成并下载文件
const exportedHtml = "<!DOCTYPE html>\n" + clone.outerHTML;
const blob = new Blob([exportedHtml], { type: "text/html;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "3D照片球-导出.html";
a.click();
URL.revokeObjectURL(a.href);
});
- 用
cloneNode(true)深拷贝整个页面 DOM - 把上传、导出等编辑功能的按钮和 JS 代码都移除掉,保留自动播放和全屏功能,这样收到的人也能体验自动播放效果
- 用正则替换 JS 中的
tagsData数组为当前实际展示的图片数据(包括用户上传的 base64 图片) - 最后用
Blob + URL.createObjectURL生成下载链接
导出后的 HTML 文件是完全独立的,直接双击用浏览器打开就能看到你的 3D 照片球了,还能自动播放,发给朋友也完全没问题!
9、点击照片放大查看
球体上的照片太小看不清?点击一下就能放大查看:
javascript
function openImageModal(imageSrc) {
modalImage.src = imageSrc;
imageModal.classList.add("show");
document.body.style.overflow = "hidden"; // 阻止背景滚动
}
function closeImageModal() {
imageModal.classList.remove("show");
document.body.style.overflow = "";
}
// 关闭方式:点击关闭按钮 / 点击背景 / 按 ESC 键
closeModal.addEventListener("click", closeImageModal);
imageModal.addEventListener("click", (e) => {
if (e.target === imageModal) closeImageModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && imageModal.classList.contains("show")) {
closeImageModal();
}
});
这里有一个细节需要处理------点击照片和拖拽球体的事件冲突。因为照片在球体上,拖拽球体的时候如果经过照片就会误触。所以我们在图片的 mousedown 时记录起始坐标,click 时判断有没有发生过拖拽,只有"原地点击"才触发放大:
javascript
imgEl.addEventListener("mousedown", (e) => {
e.stopPropagation();
clickStartX = e.clientX;
clickStartY = e.clientY;
hasDragged = false;
});
imgEl.addEventListener("mousemove", (e) => {
if (clickStartX !== undefined) {
const dx = Math.abs(e.clientX - clickStartX);
const dy = Math.abs(e.clientY - clickStartY);
if (dx > 5 || dy > 5) hasDragged = true; // 移动超过5px视为拖拽
}
});
imgEl.addEventListener("click", (e) => {
e.stopPropagation();
if (!hasDragged) openImageModal(imageUrl); // 没有拖拽才触发放大
hasDragged = false;
});
放大弹窗的样式也加了一个缩放动画,让体验更丝滑:
css
@keyframes zoomIn {
from { transform: scale(0.8); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.image-modal-content img {
max-width: 100%;
max-height: 90vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
animation: zoomIn 0.3s ease;
}
10、自动播放模式
这是整个项目最酷的功能------自动播放。点击按钮后,球体会自动进入全屏,然后按顺序将每张照片旋转到正前方,飞到屏幕中央放大展示,看完再飞回球面,接着旋转到下一张,循环往复,就像一个 3D 电子相册。
整个自动播放分为 6 个阶段:
① 球面停顿 0.5s → ② 飞到中央 0.6s → ③ 放大 4 倍 0.5s → ④ 展示 1s → ⑤ 缩小 0.5s → ⑥ 飞回球面 0.7s → 下一张
(1)计算目标旋转角度
首先要解决一个问题:球体旋转多少度,才能让第 N 张照片刚好转到正面?
javascript
function normalizeAngle(a) {
a = a % 360;
if (a > 180) a -= 360;
if (a < -180) a += 360;
return a;
}
function autoPlayRotateTo(index) {
if (!isAutoPlaying || tagElements.length === 0) return;
const el = tagElements[index];
const px = parseFloat(el.dataset.x);
const py = parseFloat(el.dataset.y);
const pz = parseFloat(el.dataset.z);
// 计算将该点旋转到正面 (0,0,1) 所需的目标角度
const r = Math.sqrt(py * py + pz * pz);
let targetX = -Math.atan2(py, pz) * (180 / Math.PI);
let targetY = Math.atan2(-px, r) * (180 / Math.PI);
// 规范化角度,找最短旋转路径
angleX = normalizeAngle(angleX);
angleY = normalizeAngle(angleY);
targetX = normalizeAngle(targetX);
targetY = normalizeAngle(targetY);
// 处理角度跨越 ±180° 的情况,确保走最短路径
let diffX = targetX - angleX;
let diffY = targetY - angleY;
while (diffX > 180) diffX -= 360;
while (diffX < -180) diffX += 360;
while (diffY > 180) diffY -= 360;
while (diffY < -180) diffY += 360;
autoPlayTargetX = angleX + diffX;
autoPlayTargetY = angleY + diffY;
autoPlayPhase = "rotating";
}
核心思路是:已知照片在球面上的单位坐标 (px, py, pz),我们需要反算出让这个点旋转到 (0, 0, 1)(正对观察者)所需的 angleX 和 angleY。用 atan2 做反三角运算就能得到目标角度。
normalizeAngle 确保角度始终在 -180° 到 180° 之间,这样 diffX/diffY 就是最短旋转路径,球不会"绕远路"。
(2)6 阶段展示动画
当球体旋转到位(rotating 阶段结束),就进入多阶段展示:
javascript
// 进入 showing 阶段
autoPlayPhase = "showing";
const highlightEl = tagElements[autoPlayIndex];
highlightEl._origTransform = highlightEl.style.transform; // 保存原始位置
highlightEl.classList.remove("tagcloud__item--back"); // 确保不带背面样式
// ① 球面正面停顿 0.5s,让用户看清照片已面朝自己
setTimeout(() => {
// ② 飞到中央摆正(0.6s CSS transition)
tagCloud.classList.add("autoplay-dimmed"); // 其他照片暗淡
highlightEl.classList.add("autoplay-highlight"); // 添加过渡动画
highlightEl.style.transform =
`rotateX(${angleX}deg) rotateY(${-angleY}deg) translate3d(0, 0, ${radius * 0.5}px)`;
setTimeout(() => {
// ③ 居中完成后 → 放大 4 倍(0.5s CSS transition)
highlightEl.classList.add("autoplay-zoomed");
setTimeout(() => {
// ④ 放大展示 1s... 然后
// ⑤ 缩小(0.5s)
highlightEl.classList.remove("autoplay-zoomed");
setTimeout(() => {
// ⑥ 飞回球面原位(0.6s)
highlightEl.style.transform = highlightEl._origTransform;
tagCloud.classList.remove("autoplay-dimmed");
setTimeout(() => {
// 清理 class,切换到下一张
highlightEl.classList.remove("autoplay-highlight");
autoPlayIndex = (autoPlayIndex + 1) % tagElements.length;
autoPlayRotateTo(autoPlayIndex); // 递归播放下一张
}, 700);
}, 500);
}, 1000);
}, 650);
}, 500);
这段代码看起来嵌套很深,但逻辑其实很清晰------每一层 setTimeout 对应一个动画阶段,等上一个阶段的 CSS transition 完成后再执行下一个。
飞到中央的 transform 值很巧妙:
javascript
highlightEl.style.transform =
`rotateX(${angleX}deg) rotateY(${-angleY}deg) translate3d(0, 0, ${radius * 0.5}px)`;
先用 rotateX/rotateY 抵消球体当前的旋转(让照片"摆正"),再用 translate3d(0, 0, radius*0.5) 把照片推到球心前方,这样在视觉上看起来就是照片从球面"飞"到了屏幕中央。
(3)启动与停止
javascript
function startAutoPlay() {
if (tagElements.length === 0) return;
isAutoPlaying = true;
autoPlayIndex = 0;
autoPlayBtn.textContent = "停止播放";
// 自动进入全屏
document.body.requestFullscreen?.() || document.body.webkitRequestFullscreen?.();
document.body.classList.add("fullscreen-mode");
// 清除手动惯性
velX = 0;
velY = 0;
isDragging = false;
// 开始旋转到第一张
autoPlayRotateTo(autoPlayIndex);
}
function stopAutoPlay() {
isAutoPlaying = false;
autoPlayPhase = "idle";
autoPlayBtn.textContent = "自动播放";
if (autoPlayShowTimer) {
clearTimeout(autoPlayShowTimer);
autoPlayShowTimer = null;
}
// 清除所有高亮、放大和暗淡效果,恢复原始 transform
tagElements.forEach((el) => {
el.classList.remove("autoplay-highlight", "autoplay-zoomed");
if (el._origTransform !== undefined) {
el.style.transform = el._origTransform;
delete el._origTransform;
}
});
tagCloud.classList.remove("autoplay-dimmed");
}
启动时自动进入全屏,按钮文字切换为"停止播放";停止时要把所有正在进行的动画效果清理干净,把飞出去的照片恢复到球面原位。
用户可以通过三种方式停止自动播放:
- 点击「停止播放」按钮
- 在球体上点击/触摸
- 按 ESC 退出全屏(会触发
fullscreenchange事件自动停止)
11、全屏展示与响应式
加上全屏展示功能,隐藏所有按钮和文字,让照片球占满整个屏幕:
javascript
fullscreenBtn.addEventListener("click", () => {
if (!document.fullscreenElement) {
document.body.requestFullscreen?.() ||
document.body.webkitRequestFullscreen?.() ||
document.body.msRequestFullscreen?.();
document.body.classList.add("fullscreen-mode");
} else {
(document.exitFullscreen?.() ||
document.webkitExitFullscreen?.() ||
document.msExitFullscreen?.())?.();
document.body.classList.remove("fullscreen-mode");
}
});
// 监听全屏状态变化,退出全屏时自动停止播放
document.addEventListener("fullscreenchange", () => {
if (!document.fullscreenElement) {
document.body.classList.remove("fullscreen-mode");
if (isAutoPlaying) stopAutoPlay();
}
});
全屏模式下通过 CSS 隐藏控制面板,并让球体占满窗口:
css
body.fullscreen-mode .control-panel { display: none !important; }
body.fullscreen-mode .info-panel { display: none !important; }
body.fullscreen-mode .tagcloud-wrapper {
width: 100vmin;
height: 100vmin;
max-width: 100vw;
max-height: 100vh;
}
这里监听了 fullscreenchange 事件,当用户按 ESC 退出全屏时,如果正在自动播放会自动停止,避免出现样式错乱。
同时还做了窗口 resize 的自适应,窗口大小变化时重新计算球体半径并重建:
javascript
window.addEventListener("resize", () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
radius = Math.min(wrapper.offsetWidth, wrapper.offsetHeight) / 2.4;
createTags();
}, 200);
});
用了 200ms 的防抖,避免拖动窗口时频繁重建。
源码地址
gitee

github

🌟 觉得有帮助的可以点个 star~
🖊 有什么问题或错误可以指出,欢迎 pr~
📬 有什么想要实现的功能或想法可以联系我~
公众号
关注公众号 前端也能这么有趣 ,获取更多有趣内容。
发送 加群 还可以加入群聊,一起来学习(摸鱼)吧~
说在后面
🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。