旅游拍的照片不要扔,一键生成炫酷的 3D 照片球动画

说在前面

📸 每次旅游完,大家手机相册里是不是都会多几百上千张照片?风景照、美食照、打卡照......拍的时候一顿猛拍,回来之后全都躺在相册吃灰,翻都懒得翻。

想做成视频却又无从下手?那么可以试试这个工具,导入图片一键生成炫酷的 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">&times;</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,就可以算出 xz,三个坐标组合就是球面上一个点的位置。

(4)照片朝向计算

javascript 复制代码
const yaw = Math.atan2(x, z);
const pitch = Math.atan2(y, Math.sqrt(x * x + z * z));

给每张照片加上 rotateYrotateX,让它们的"正面"朝向球心外侧,这样无论球怎么转,照片看起来都是正面对着你的。

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 容器的 rotateXrotateY,里面所有照片会跟着一起转,这就是 CSS transform-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)(正对观察者)所需的 angleXangleY。用 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

地址gitee.com/zheng_yongt...

github

地址github.com/yongtaozhen...


🌟 觉得有帮助的可以点个 star~

🖊 有什么问题或错误可以指出,欢迎 pr~

📬 有什么想要实现的功能或想法可以联系我~


公众号

关注公众号 前端也能这么有趣 ,获取更多有趣内容。

发送 加群 还可以加入群聊,一起来学习(摸鱼)吧~

说在后面

🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。

相关推荐
豆豆2 小时前
PageAdmin CMS模板开发详解:HTML转CMS系统的10个核心步骤
前端·html·cms·网站建设·网站制作·自助建站·网站管理系统
lemon_yyds2 小时前
vue 2 升级vue3 : element ui 校验红色高亮失去效果
前端·element
真夜2 小时前
又遇到生产与开发环境结果不一致问题。。。
前端·javascript·http
lemon_yyds2 小时前
vue2升级vue3:图片点击预览出现样式错乱
前端
掘金安东尼2 小时前
低代码工具很多,为什么 RollCode 更像一套「页面生产平台」
前端·javascript·面试
HelloReader2 小时前
Flutter StatefulWidget让界面动起来(六)
前端
umigreen2 小时前
uniapp实现小程序地图导航
前端
Linsk2 小时前
Web 新 API cookieStore 值得用吗?
前端
baozj2 小时前
前端大文件上传的另一种提速思路
前端·javascript