aura-glass
Version:
A comprehensive glassmorphism design system for React applications with 142+ production-ready components
507 lines (504 loc) • 19.1 kB
JavaScript
'use client';
import { jsx, jsxs } from 'react/jsx-runtime';
import { forwardRef, useState, useRef, useEffect, useCallback } from 'react';
import '../../primitives/GlassCore.js';
import '../../primitives/glass/GlassAdvanced.js';
import { OptimizedGlassCore } from '../../primitives/OptimizedGlassCore.js';
import '../../primitives/glass/OptimizedGlassAdvanced.js';
import '../../primitives/MotionNative.js';
import { MotionFramer } from '../../primitives/motion/MotionFramer.js';
import { cn } from '../../lib/utilsComprehensive.js';
import { useA11yId } from '../../utils/a11y.js';
const Glass360Viewer = /*#__PURE__*/forwardRef(({
source,
sources = [],
hotspots = [],
controls = {
zoom: true,
pan: true,
autoRotate: true,
gyroscope: true,
fullscreen: true,
vr: false,
playback: true,
volume: true
},
initialView = {
yaw: 0,
pitch: 0,
fov: 75
},
autoRotateSpeed = 2,
autoRotateEnabled = false,
maxZoom = 3,
minZoom = 0.5,
loadingComponent,
errorComponent,
onViewChange,
onHotspotClick,
onLoad,
onError,
overlay,
debug = false,
quality = "high",
performanceMode = "quality",
respectMotionPreference = true,
className,
...props
}, ref) => {
const shouldAnimate = true; // TODO: Replace with actual motion preference hook
const playSound = soundKey => {}; // TODO: Replace with actual sound design hook
const viewerId = useA11yId("glass-360-viewer");
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const [currentView, setCurrentView] = useState({
yaw: initialView.yaw ?? 0,
pitch: initialView.pitch ?? 0,
fov: initialView.fov ?? 75
});
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({
x: 0,
y: 0,
yaw: 0,
pitch: 0
});
const [isAutoRotating, setIsAutoRotating] = useState(autoRotateEnabled);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showControls, setShowControls] = useState(true);
const [mediaElement, setMediaElement] = useState(null);
const [gyroscopeEnabled, setGyroscopeEnabled] = useState(false);
const containerRef = useRef(null);
const canvasRef = useRef(null);
const animationFrameRef = useRef();
const lastGyroscopeRef = useRef({
alpha: 0,
beta: 0,
gamma: 0
});
// Initialize viewer
useEffect(() => {
const initializeViewer = async () => {
try {
setIsLoading(true);
setHasError(false);
if (source.type === "image") {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
setMediaElement(img);
setIsLoading(false);
onLoad?.();
playSound("media_load");
};
img.onerror = () => {
const error = new Error("Failed to load 360° image");
setHasError(true);
setIsLoading(false);
onError?.(error);
};
img.src = source.url;
} else if (source.type === "video") {
const video = document.createElement("video");
video.crossOrigin = "anonymous";
video.preload = "metadata";
video.onloadedmetadata = () => {
setMediaElement(video);
setIsLoading(false);
onLoad?.();
playSound("media_load");
};
video.onerror = () => {
const error = new Error("Failed to load 360° video");
setHasError(true);
setIsLoading(false);
onError?.(error);
};
video.src = source.url;
}
} catch (error) {
setHasError(true);
setIsLoading(false);
onError?.(error);
}
};
initializeViewer();
}, [source, onLoad, onError, playSound]);
// Render 360° view on canvas
useEffect(() => {
if (!mediaElement || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const render = () => {
if (!mediaElement || !ctx) return;
const {
yaw,
pitch,
fov
} = currentView;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Calculate projection based on source projection type
const projectionMatrix = calculateProjectionMatrix(source.projection || "equirectangular", yaw, pitch, fov, canvas.width, canvas.height);
// Render the media with projection
if (mediaElement instanceof HTMLImageElement) {
renderImageProjection(ctx, mediaElement, projectionMatrix, canvas.width, canvas.height);
} else if (mediaElement instanceof HTMLVideoElement) {
renderVideoProjection(ctx, mediaElement, projectionMatrix, canvas.width, canvas.height);
}
// Render hotspots
renderHotspots(ctx, canvas.width, canvas.height);
};
const animate = () => {
render();
if (isAutoRotating) {
setCurrentView(prev => ({
...prev,
yaw: ((prev.yaw ?? 0) + autoRotateSpeed / 60) % 360
}));
}
animationFrameRef.current = requestAnimationFrame(animate);
};
animationFrameRef.current = requestAnimationFrame(animate);
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [mediaElement, currentView, isAutoRotating, autoRotateSpeed, hotspots, source.projection]);
// Calculate projection matrix (simplified implementation)
const calculateProjectionMatrix = useCallback((projection, yaw, pitch, fov, width, height) => {
// This is a simplified projection calculation
// In a real implementation, you would use a proper 3D math library
const yawRad = yaw * Math.PI / 180;
const pitchRad = pitch * Math.PI / 180;
const fovRad = fov * Math.PI / 180;
return {
yawRad,
pitchRad,
fovRad,
aspect: width / height
};
}, []);
// Render image with projection
const renderImageProjection = useCallback((ctx, img, matrix, width, height) => {
// Simplified equirectangular projection rendering
// In a real implementation, this would be much more complex
const {
yawRad,
pitchRad
} = matrix;
const sourceX = (yawRad + Math.PI) / (2 * Math.PI) * img.width;
const sourceY = (pitchRad + Math.PI / 2) / Math.PI * img.height;
const sourceWidth = img.width / 4; // Simplified view window
const sourceHeight = img.height / 4;
ctx.drawImage(img, sourceX - sourceWidth / 2, sourceY - sourceHeight / 2, sourceWidth, sourceHeight, 0, 0, width, height);
}, []);
// Render video with projection
const renderVideoProjection = useCallback((ctx, video, matrix, width, height) => {
// Similar to image projection but for video
const {
yawRad,
pitchRad
} = matrix;
const sourceX = (yawRad + Math.PI) / (2 * Math.PI) * video.videoWidth;
const sourceY = (pitchRad + Math.PI / 2) / Math.PI * video.videoHeight;
const sourceWidth = video.videoWidth / 4;
const sourceHeight = video.videoHeight / 4;
ctx.drawImage(video, sourceX - sourceWidth / 2, sourceY - sourceHeight / 2, sourceWidth, sourceHeight, 0, 0, width, height);
}, []);
// Render hotspots
const renderHotspots = useCallback((ctx, width, height) => {
hotspots.forEach((hotspot, index) => {
// Convert hotspot 3D position to 2D screen coordinates
const {
x,
y
} = project3DTo2D(hotspot.x, hotspot.y, currentView, width, height);
// Only render if hotspot is in view
if (x >= 0 && x <= width && y >= 0 && y <= height) {
ctx.save();
ctx.fillStyle = hotspot.color || "var(--glass-color-primary)";
ctx.strokeStyle = "var(--glass-white)";
ctx.lineWidth = 2;
// Draw hotspot circle
ctx.beginPath();
ctx.arc(x, y, 8, 0, 2 * Math.PI);
ctx.fill();
ctx.stroke();
// Pulse animation
{
const pulseScale = 1 + Math.sin(Date.now() * 0.01 + index) * 0.2;
ctx.beginPath();
ctx.arc(x, y, 8 * pulseScale, 0, 2 * Math.PI);
ctx.strokeStyle = hotspot.color || "var(--glass-color-primary)";
ctx.globalAlpha = 0.5;
ctx.stroke();
ctx.globalAlpha = 1;
}
ctx.restore();
}
});
}, [hotspots, currentView, shouldAnimate]);
// Convert 3D spherical coordinates to 2D screen coordinates
const project3DTo2D = useCallback((longitude, latitude, view, width, height) => {
const {
yaw,
pitch,
fov
} = view;
const adjustedLon = longitude - yaw;
const adjustedLat = latitude - pitch;
// Simplified projection
const x = width / 2 + adjustedLon / 90 * (width / 4);
const y = height / 2 + adjustedLat / 90 * (height / 4);
return {
x,
y
};
}, []);
// Handle mouse interactions
const handleMouseDown = useCallback(event => {
if (!controls.pan) return;
setIsDragging(true);
setIsAutoRotating(false);
setDragStart({
x: event.clientX,
y: event.clientY,
yaw: currentView.yaw ?? 0,
pitch: currentView.pitch ?? 0
});
}, [controls.pan, currentView, playSound]);
const handleMouseMove = useCallback(event => {
if (!isDragging || !controls.pan) return;
const deltaX = event.clientX - dragStart.x;
const deltaY = event.clientY - dragStart.y;
const sensitivity = 0.5;
const newYaw = dragStart.yaw - deltaX * sensitivity;
const newPitch = Math.max(-90, Math.min(90, dragStart.pitch + deltaY * sensitivity));
const newView = {
...currentView,
yaw: newYaw,
pitch: newPitch
};
setCurrentView(newView);
onViewChange?.({
yaw: newView.yaw ?? 0,
pitch: newView.pitch ?? 0,
fov: newView.fov ?? 75
});
}, [isDragging, controls.pan, dragStart, currentView, onViewChange]);
const handleMouseUp = useCallback(() => {
if (isDragging) {
setIsDragging(false);
}
}, [isDragging, playSound]);
// Handle zoom
const handleWheel = useCallback(event => {
if (!controls.zoom) return;
event.preventDefault();
const delta = event.deltaY * 0.1;
const newFov = Math.max(30, Math.min(120, currentView.fov + delta));
const newView = {
...currentView,
fov: newFov
};
setCurrentView(newView);
onViewChange?.({
yaw: newView.yaw ?? 0,
pitch: newView.pitch ?? 0,
fov: newView.fov ?? 75
});
}, [controls.zoom, currentView, onViewChange, playSound]);
// Handle hotspot clicks
const handleCanvasClick = useCallback(event => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// Check if click is on a hotspot
for (const hotspot of hotspots) {
const hotspotPos = project3DTo2D(hotspot.x, hotspot.y, currentView, canvas.width, canvas.height);
const distance = Math.sqrt(Math.pow(x - hotspotPos.x, 2) + Math.pow(y - hotspotPos.y, 2));
if (distance <= 16) {
// Hotspot click radius
hotspot.onClick?.();
onHotspotClick?.(hotspot);
return;
}
}
}, [hotspots, currentView, project3DTo2D, onHotspotClick, playSound]);
// Gyroscope support
useEffect(() => {
if (!gyroscopeEnabled || !controls.gyroscope) return;
const handleDeviceOrientation = event => {
if (event.alpha !== null && event.beta !== null && event.gamma !== null) {
const alpha = event.alpha;
const beta = event.beta;
const gamma = event.gamma;
// Convert device orientation to view angles
const yaw = alpha - lastGyroscopeRef.current.alpha;
const pitch = beta - lastGyroscopeRef.current.beta;
setCurrentView(prev => ({
...prev,
yaw: (prev.yaw ?? 0) + yaw * 0.1,
pitch: Math.max(-90, Math.min(90, (prev.pitch ?? 0) + pitch * 0.1))
}));
lastGyroscopeRef.current = {
alpha,
beta,
gamma
};
}
};
window.addEventListener("deviceorientation", handleDeviceOrientation);
return () => window.removeEventListener("deviceorientation", handleDeviceOrientation);
}, [gyroscopeEnabled, controls.gyroscope]);
// Toggle fullscreen
const toggleFullscreen = useCallback(() => {
if (!controls.fullscreen) return;
if (!isFullscreen) {
containerRef.current?.requestFullscreen();
setIsFullscreen(true);
} else {
document.exitFullscreen();
setIsFullscreen(false);
}
}, [controls.fullscreen, isFullscreen, playSound]);
// Control panel
const renderControls = () => {
if (!showControls) return null;
return jsxs(OptimizedGlassCore, {
"data-glass-component": true,
elevation: "level3",
intensity: "strong",
depth: 2,
tint: "neutral",
border: "subtle",
className: 'absolute bottom-4 left-1/2 transform -translate-x-1/2 glass-flex glass-items-center glass-gap-2 glass-px-4 glass-py-2 glass-radius-lg glass-glass-backdrop-blur-md glass-border glass-border-glass-border/20 glass-contrast-guard',
children: [controls.autoRotate && jsx("button", {
onClick: () => setIsAutoRotating(!isAutoRotating),
className: cn("glass-p-2 glass-radius-md transition-all", isAutoRotating ? "bg-primary/20 text-primary" : "hover:bg-background/20"),
title: "Auto Rotate",
children: "\uD83D\uDD04"
}), controls.gyroscope && jsx("button", {
onClick: () => setGyroscopeEnabled(!gyroscopeEnabled),
className: cn("glass-p-2 glass-radius-md transition-all", gyroscopeEnabled ? "bg-primary/20 text-primary" : "hover:bg-background/20"),
title: "Gyroscope",
children: "\uD83D\uDCF1"
}), controls.fullscreen && jsx("button", {
onClick: toggleFullscreen,
className: 'glass-p-2 glass-radius-md hover:glass-surface-overlay transition-all glass-focus glass-touch-target glass-contrast-guard glass-focus glass-touch-target glass-contrast-guard',
title: "Fullscreen",
children: isFullscreen ? "🗗" : "🗖"
}), source.type === "video" && controls.playback && mediaElement instanceof HTMLVideoElement && jsx("button", {
onClick: () => {
if (mediaElement.paused) {
mediaElement.play();
} else {
mediaElement.pause();
}
},
className: 'glass-p-2 glass-radius-md hover:glass-surface-overlay transition-all',
title: mediaElement.paused ? "Play" : "Pause",
children: mediaElement.paused ? "▶" : "⏸"
}), jsxs("div", {
className: "glass-text-xs glass-text-secondary",
children: [Math.round(currentView.yaw ?? 0), "\u00B0 /", " ", Math.round(currentView.pitch ?? 0), "\u00B0 /", " ", Math.round(currentView.fov ?? 75), "\u00B0"]
})]
});
};
return jsx(OptimizedGlassCore, {
ref: ref,
id: viewerId,
elevation: "level1",
intensity: "subtle",
depth: 1,
tint: "neutral",
border: "subtle",
className: cn("glass-360-viewer relative glass-radius-lg glass-backdrop-blur-md border border-border/20 overflow-hidden", className),
...props,
children: jsx(MotionFramer, {
preset: respectMotionPreference ? "fadeIn" : "none",
className: 'relative glass-w-full glass-h-full',
children: jsxs("div", {
ref: containerRef,
className: 'relative glass-w-full glass-h-full cursor-move',
onMouseDown: handleMouseDown,
onMouseMove: handleMouseMove,
onMouseUp: handleMouseUp,
onMouseLeave: handleMouseUp,
onWheel: handleWheel,
children: [isLoading && jsx("div", {
className: 'absolute inset-0 glass-flex glass-items-center glass-justify-center',
children: loadingComponent || jsxs("div", {
className: "glass-flex glass-flex-col glass-items-center glass-gap-4",
children: [jsx("div", {
className: 'w-12 h-12 glass-border-4 glass-border-primary glass-border-t-transparent glass-radius-full animate-spin'
}), jsx("div", {
className: "glass-text-sm glass-text-secondary",
children: "Loading 360\u00B0 media..."
})]
})
}), hasError && jsx("div", {
className: 'absolute inset-0 glass-flex glass-items-center glass-justify-center',
children: errorComponent || jsxs("div", {
className: 'glass-flex glass-flex-col glass-items-center glass-gap-4 text-center',
children: [jsx("div", {
className: "glass-text-4xl",
children: "\u274C"
}), jsx("div", {
className: "glass-text-sm glass-text-secondary",
children: "Failed to load 360\u00B0 media"
})]
})
}), !isLoading && !hasError && jsx("canvas", {
ref: canvasRef,
className: "glass-w-full glass-h-full",
onClick: handleCanvasClick,
style: {
cursor: isDragging ? "grabbing" : "grab"
}
}), overlay && jsx("div", {
className: 'absolute inset-0 pointer-events-none',
children: overlay
}), !isLoading && !hasError && renderControls(), debug && !isLoading && !hasError && jsx(OptimizedGlassCore, {
elevation: "level2",
intensity: "medium",
depth: 1,
tint: "neutral",
border: "subtle",
className: 'absolute top-4 left-4 glass-p-3 glass-radius-lg glass-glass-backdrop-blur-md glass-border glass-border-glass-border/20 glass-contrast-guard',
children: jsxs("div", {
className: 'glass-text-xs font-mono glass-gap-1',
children: [jsxs("div", {
children: ["Yaw: ", (currentView.yaw ?? 0).toFixed(1), "\u00B0"]
}), jsxs("div", {
children: ["Pitch: ", (currentView.pitch ?? 0).toFixed(1), "\u00B0"]
}), jsxs("div", {
children: ["FOV: ", (currentView.fov ?? 75).toFixed(1), "\u00B0"]
}), jsxs("div", {
children: ["Auto: ", isAutoRotating ? "On" : "Off"]
}), jsxs("div", {
children: ["Type: ", source.type]
}), jsxs("div", {
children: ["Projection: ", source.projection || "equirectangular"]
}), jsxs("div", {
children: ["Hotspots: ", hotspots.length]
})]
})
}), jsx("button", {
onClick: () => setShowControls(!showControls),
className: 'absolute top-4 right-4 glass-p-2 glass-radius-full glass-surface-overlay hover:glass-surface-overlay transition-all',
title: "Toggle Controls",
children: showControls ? "🎛" : "⚙"
})]
})
})
});
});
Glass360Viewer.displayName = "Glass360Viewer";
export { Glass360Viewer, Glass360Viewer as default };
//# sourceMappingURL=Glass360Viewer.js.map