Files
3D-pano/app.js
袁涛 427d99d87a 更新 序列帧资源更换为video2.mp4
- 更换视频源从video.mp4到video2.mp4
- 重新提取400帧序列图(原336帧)
- 更新应用配置中的总帧数
2026-03-29 01:37:55 +08:00

516 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 汽车360°全景展示应用
* 支持触摸滑动、自动旋转、惯性效果
*/
class Car360Viewer {
constructor() {
// 配置
this.config = {
totalFrames: 400, // 总帧数
framesPerSwipe: 1, // 每像素滑动切换的帧数
inertiaFriction: 0.95, // 惯性摩擦系数
minVelocity: 0.1, // 最小速度阈值
autoRotateSpeed: 80, // 自动旋转速度ms/帧)
autoRotateDelay: 3000, // 停止操作后自动旋转延迟ms
imagePath: './images/', // 图片路径
imagePrefix: 'car_', // 图片前缀
imageExt: '.jpg' // 图片扩展名
};
// 状态
this.state = {
currentFrame: 0,
isDragging: false,
startX: 0,
lastX: 0,
velocity: 0,
images: [],
loaded: false,
animationId: null,
autoRotateTimer: null,
autoRotateInterval: null
};
// DOM元素
this.elements = {
canvas: document.getElementById('carCanvas'),
loadingScreen: document.getElementById('loadingScreen'),
loadingProgress: document.getElementById('loadingProgress'),
loadingText: document.getElementById('loadingText')
};
this.ctx = this.elements.canvas.getContext('2d');
this.init();
}
init() {
this.setupCanvas();
this.bindEvents();
this.loadImages();
}
setupCanvas() {
const rect = this.elements.canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
this.elements.canvas.width = rect.width * dpr;
this.elements.canvas.height = rect.height * dpr;
this.ctx.scale(dpr, dpr);
// 监听窗口大小变化
window.addEventListener('resize', () => {
const newRect = this.elements.canvas.getBoundingClientRect();
this.elements.canvas.width = newRect.width * dpr;
this.elements.canvas.height = newRect.height * dpr;
this.ctx.scale(dpr, dpr);
this.renderFrame(this.state.currentFrame);
});
}
bindEvents() {
const viewer = this.elements.canvas;
// 触摸事件
viewer.addEventListener('touchstart', this.handleStart.bind(this), { passive: false });
viewer.addEventListener('touchmove', this.handleMove.bind(this), { passive: false });
viewer.addEventListener('touchend', this.handleEnd.bind(this), { passive: false });
}
handleStart(e) {
e.preventDefault();
this.stopInertia();
this.stopAutoRotate();
const touch = e.touches[0];
this.state.isDragging = true;
this.state.startX = touch.clientX;
this.state.lastX = touch.clientX;
this.state.velocity = 0;
}
handleMove(e) {
if (!this.state.isDragging) return;
e.preventDefault();
const touch = e.touches[0];
const deltaX = touch.clientX - this.state.lastX;
// 计算速度
this.state.velocity = deltaX;
// 根据滑动距离更新帧
const frameDelta = Math.round(deltaX / 5);
if (frameDelta !== 0) {
this.state.lastX = touch.clientX;
this.updateFrame(frameDelta);
}
}
handleEnd(e) {
if (!this.state.isDragging) return;
this.state.isDragging = false;
// 启动惯性动画
if (Math.abs(this.state.velocity) > this.config.minVelocity) {
this.startInertia();
} else {
// 没有惯性,延迟启动自动旋转
this.scheduleAutoRotate();
}
}
startInertia() {
const animate = () => {
this.state.velocity *= this.config.inertiaFriction;
if (Math.abs(this.state.velocity) > this.config.minVelocity) {
const frameDelta = Math.round(this.state.velocity / 5);
if (frameDelta !== 0) {
this.updateFrame(frameDelta);
}
this.state.animationId = requestAnimationFrame(animate);
} else {
// 惯性结束,启动自动旋转
this.scheduleAutoRotate();
}
};
this.state.animationId = requestAnimationFrame(animate);
}
stopInertia() {
if (this.state.animationId) {
cancelAnimationFrame(this.state.animationId);
this.state.animationId = null;
}
this.state.velocity = 0;
}
updateFrame(delta) {
let newFrame = this.state.currentFrame + delta;
// 循环处理
while (newFrame < 0) newFrame += this.config.totalFrames;
newFrame = newFrame % this.config.totalFrames;
this.state.currentFrame = newFrame;
this.renderFrame(newFrame);
}
async loadImages() {
// 首先尝试加载文件系统图片
try {
const images = [];
let loadedCount = 0;
this.updateLoadingProgress(0, '正在加载图片资源...');
for (let i = 0; i < this.config.totalFrames; i++) {
const img = new Image();
const frameNum = String(i + 1).padStart(3, '0');
img.src = `${this.config.imagePath}${this.config.imagePrefix}${frameNum}${this.config.imageExt}`;
await new Promise((resolve, reject) => {
img.onload = () => {
loadedCount++;
const progress = Math.round((loadedCount / this.config.totalFrames) * 100);
this.updateLoadingProgress(progress, `加载中 ${loadedCount}/${this.config.totalFrames}`);
resolve(img);
};
img.onerror = () => reject(new Error(`Failed to load frame ${i}`));
});
images.push(img);
}
this.updateLoadingProgress(100, '加载完成');
this.state.images = images;
this.state.loaded = true;
setTimeout(() => {
this.hideLoading();
this.renderFrame(0);
}, 500);
console.log('Loaded', images.length, 'frames from files');
return;
} catch (error) {
console.log('File load failed:', error.message);
}
// 尝试从 IndexedDB 加载
this.updateLoadingProgress(0, '正在从缓存加载...');
const indexedDBFrames = await this.loadFromIndexedDB();
if (indexedDBFrames && indexedDBFrames.length > 0) {
this.state.images = indexedDBFrames;
this.state.loaded = true;
this.config.totalFrames = indexedDBFrames.length;
this.updateLoadingProgress(100, '加载完成');
setTimeout(() => {
this.hideLoading();
this.renderFrame(0);
}, 500);
return;
}
// 演示模式
console.log('Using demo mode');
this.updateLoadingProgress(100, '进入演示模式');
setTimeout(() => {
this.hideLoading();
this.startDemoMode();
}, 500);
}
updateLoadingProgress(percent, text) {
if (this.elements.loadingProgress) {
this.elements.loadingProgress.style.width = `${percent}%`;
}
if (this.elements.loadingText) {
this.elements.loadingText.textContent = text;
}
}
hideLoading() {
if (this.elements.loadingScreen) {
this.elements.loadingScreen.classList.add('hidden');
}
// 加载完成后启动自动旋转
this.startAutoRotate();
}
scheduleAutoRotate() {
// 清除之前的定时器
if (this.state.autoRotateTimer) {
clearTimeout(this.state.autoRotateTimer);
}
// 延迟启动自动旋转
this.state.autoRotateTimer = setTimeout(() => {
this.startAutoRotate();
}, this.config.autoRotateDelay);
}
startAutoRotate() {
// 清除之前的自动旋转
this.stopAutoRotate();
// 启动新的自动旋转
this.state.autoRotateInterval = setInterval(() => {
this.updateFrame(1);
}, this.config.autoRotateSpeed);
}
stopAutoRotate() {
if (this.state.autoRotateTimer) {
clearTimeout(this.state.autoRotateTimer);
this.state.autoRotateTimer = null;
}
if (this.state.autoRotateInterval) {
clearInterval(this.state.autoRotateInterval);
this.state.autoRotateInterval = null;
}
}
loadFromIndexedDB() {
return new Promise((resolve) => {
const request = indexedDB.open('Car360DB', 1);
request.onerror = () => {
console.log('IndexedDB error');
resolve(null);
};
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains('frames')) {
db.createObjectStore('frames', { keyPath: 'id' });
}
};
request.onsuccess = async () => {
const db = request.result;
try {
if (!db.objectStoreNames.contains('frames')) {
db.close();
resolve(null);
return;
}
const tx = db.transaction('frames', 'readonly');
const store = tx.objectStore('frames');
const getRequest = store.get('frames');
getRequest.onsuccess = async () => {
if (getRequest.result && getRequest.result.data && getRequest.result.data.length > 0) {
console.log('Loading', getRequest.result.data.length, 'frames from IndexedDB');
const images = getRequest.result.data.map(dataUrl => {
const img = new Image();
img.src = dataUrl;
return img;
});
await Promise.all(images.map(img =>
new Promise(res => {
img.onload = () => res();
img.onerror = () => res();
})
));
db.close();
resolve(images);
} else {
console.log('No frames data in IndexedDB');
db.close();
resolve(null);
}
};
getRequest.onerror = () => {
console.log('Failed to get frames from IndexedDB');
db.close();
resolve(null);
};
} catch (e) {
console.log('IndexedDB exception:', e);
db.close();
resolve(null);
}
};
});
}
startDemoMode() {
// 演示模式:生成占位图形
this.state.loaded = true;
this.renderFrame(0);
}
renderFrame(frameIndex) {
const canvas = this.elements.canvas;
const ctx = this.ctx;
const rect = canvas.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
// 清空画布
ctx.clearRect(0, 0, width, height);
const img = this.state.images[frameIndex];
if (img && img.complete && img.naturalWidth > 0) {
// 绘制真实图片
const scale = Math.max(width / img.naturalWidth, height / img.naturalHeight);
const x = (width - img.naturalWidth * scale) / 2;
const y = (height - img.naturalHeight * scale) / 2;
ctx.drawImage(img, x, y, img.naturalWidth * scale, img.naturalHeight * scale);
} else {
// 演示模式:绘制占位图形
this.drawDemoFrame(ctx, width, height, frameIndex);
}
}
drawDemoFrame(ctx, width, height, frameIndex) {
const angle = (frameIndex / this.config.totalFrames) * Math.PI * 2;
// 背景渐变
const gradient = ctx.createLinearGradient(0, 0, width, height);
gradient.addColorStop(0, '#1a1a2e');
gradient.addColorStop(1, '#16213e');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
// 网格效果
ctx.strokeStyle = 'rgba(0, 212, 255, 0.1)';
ctx.lineWidth = 1;
const gridSize = 30;
for (let x = 0; x < width; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 0; y < height; y += gridSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
// 绘制示意汽车简化的3D效果
const centerX = width / 2;
const centerY = height / 2;
// 车身主体
ctx.save();
ctx.translate(centerX, centerY);
// 阴影
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.beginPath();
ctx.ellipse(0, height * 0.25, width * 0.35, height * 0.08, 0, 0, Math.PI * 2);
ctx.fill();
// 车身渐变
const bodyGradient = ctx.createLinearGradient(0, -height * 0.3, 0, height * 0.2);
bodyGradient.addColorStop(0, '#4a4a5a');
bodyGradient.addColorStop(0.5, '#2a2a3a');
bodyGradient.addColorStop(1, '#1a1a2a');
// 车身形状(根据角度变化)
const perspective = Math.cos(angle);
const bodyWidth = width * 0.35 * Math.abs(perspective) + width * 0.15;
const bodyHeight = height * 0.18;
ctx.fillStyle = bodyGradient;
ctx.beginPath();
ctx.roundRect(-bodyWidth, -bodyHeight, bodyWidth * 2, bodyHeight * 2, 10);
ctx.fill();
// 车顶
const roofGradient = ctx.createLinearGradient(0, -height * 0.35, 0, -height * 0.15);
roofGradient.addColorStop(0, '#5a5a6a');
roofGradient.addColorStop(1, '#3a3a4a');
ctx.fillStyle = roofGradient;
ctx.beginPath();
ctx.roundRect(-bodyWidth * 0.7, -height * 0.35, bodyWidth * 1.4, height * 0.2, [15, 15, 5, 5]);
ctx.fill();
// 车窗
ctx.fillStyle = 'rgba(0, 212, 255, 0.3)';
ctx.beginPath();
const windowWidth = bodyWidth * 0.5;
ctx.roundRect(-windowWidth, -height * 0.32, windowWidth * 2, height * 0.12, 5);
ctx.fill();
// 车灯光效
const lightIntensity = 0.5 + Math.sin(angle * 2) * 0.3;
// 左车灯
const leftLightGradient = ctx.createRadialGradient(
-bodyWidth * 0.8, 0, 0,
-bodyWidth * 0.8, 0, 30
);
leftLightGradient.addColorStop(0, `rgba(255, 220, 100, ${lightIntensity})`);
leftLightGradient.addColorStop(1, 'rgba(255, 220, 100, 0)');
ctx.fillStyle = leftLightGradient;
ctx.beginPath();
ctx.arc(-bodyWidth * 0.8, 0, 30, 0, Math.PI * 2);
ctx.fill();
// 右车灯
const rightLightGradient = ctx.createRadialGradient(
bodyWidth * 0.8, 0, 0,
bodyWidth * 0.8, 0, 30
);
rightLightGradient.addColorStop(0, `rgba(255, 100, 100, ${lightIntensity})`);
rightLightGradient.addColorStop(1, 'rgba(255, 100, 100, 0)');
ctx.fillStyle = rightLightGradient;
ctx.beginPath();
ctx.arc(bodyWidth * 0.8, 0, 30, 0, Math.PI * 2);
ctx.fill();
// 车轮
ctx.fillStyle = '#1a1a2a';
ctx.strokeStyle = '#3a3a4a';
ctx.lineWidth = 3;
// 左轮
ctx.beginPath();
ctx.ellipse(-bodyWidth * 0.6, bodyHeight, 25, 20, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// 右轮
ctx.beginPath();
ctx.ellipse(bodyWidth * 0.6, bodyHeight, 25, 20, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.restore();
// 帧号显示
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.font = '14px monospace';
ctx.textAlign = 'center';
ctx.fillText(`${frameIndex + 1}/${this.config.totalFrames}`, centerX, height - 20);
// 演示模式提示
ctx.fillStyle = 'rgba(0, 212, 255, 0.6)';
ctx.font = '12px sans-serif';
ctx.fillText('演示模式 - 请添加实际序列帧图片到 images 文件夹', centerX, 30);
}
}
// 初始化应用
document.addEventListener('DOMContentLoaded', () => {
window.carViewer = new Car360Viewer();
});