UNPKG

aura-glass

Version:

A comprehensive glassmorphism design system for React applications with 142+ production-ready components

507 lines (504 loc) 19.1 kB
'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