feat: unit reveal overlay with 40-frame autoplay animation
@@ -9,6 +9,7 @@ import {
|
|||||||
useMotionValueEvent,
|
useMotionValueEvent,
|
||||||
useReducedMotion,
|
useReducedMotion,
|
||||||
} from 'framer-motion'
|
} from 'framer-motion'
|
||||||
|
import UnitRevealOverlay from './UnitRevealOverlay'
|
||||||
|
|
||||||
const TOTAL_FRAMES = 298
|
const TOTAL_FRAMES = 298
|
||||||
const FRAME_PATH = (n: number) =>
|
const FRAME_PATH = (n: number) =>
|
||||||
@@ -39,6 +40,7 @@ export default function CloudRevealSection() {
|
|||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
const [loadProgress, setLoadProgress] = useState(0)
|
const [loadProgress, setLoadProgress] = useState(0)
|
||||||
const hasSnapped = useRef(false)
|
const hasSnapped = useRef(false)
|
||||||
|
const [activeUnit, setActiveUnit] = useState<{ id: string; label: string; sub: string } | null>(null)
|
||||||
|
|
||||||
const { scrollYProgress } = useScroll({
|
const { scrollYProgress } = useScroll({
|
||||||
target: sectionRef,
|
target: sectionRef,
|
||||||
@@ -228,7 +230,10 @@ export default function CloudRevealSection() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* HOTSPOTS — son frame'de görünür */}
|
{/* HOTSPOTS — son frame'de görünür */}
|
||||||
<UnitHotspots scrollYProgress={scrollYProgress} />
|
<UnitHotspots
|
||||||
|
scrollYProgress={scrollYProgress}
|
||||||
|
onSelect={(unit) => setActiveUnit(unit)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
<div className="absolute bottom-8 left-10 right-10 z-10 h-[2px] bg-white/10">
|
<div className="absolute bottom-8 left-10 right-10 z-10 h-[2px] bg-white/10">
|
||||||
@@ -237,6 +242,14 @@ export default function CloudRevealSection() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Unit reveal overlay */}
|
||||||
|
<UnitRevealOverlay
|
||||||
|
unitId={activeUnit?.id ?? null}
|
||||||
|
unitLabel={activeUnit?.label ?? ''}
|
||||||
|
unitSub={activeUnit?.sub ?? ''}
|
||||||
|
onClose={() => setActiveUnit(null)}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,8 +271,10 @@ const HOTSPOTS: {
|
|||||||
|
|
||||||
function UnitHotspots({
|
function UnitHotspots({
|
||||||
scrollYProgress,
|
scrollYProgress,
|
||||||
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
scrollYProgress: ReturnType<typeof useScroll>['scrollYProgress']
|
scrollYProgress: ReturnType<typeof useScroll>['scrollYProgress']
|
||||||
|
onSelect: (unit: { id: string; label: string; sub: string }) => void
|
||||||
}) {
|
}) {
|
||||||
const containerOpacity = useTransform(scrollYProgress, [0.92, 0.98], [0, 1])
|
const containerOpacity = useTransform(scrollYProgress, [0.92, 0.98], [0, 1])
|
||||||
|
|
||||||
@@ -310,10 +325,7 @@ function UnitHotspots({
|
|||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ delay: i * 0.2 + 0.45, duration: 0.3, ease: 'easeOut' }}
|
transition={{ delay: i * 0.2 + 0.45, duration: 0.3, ease: 'easeOut' }}
|
||||||
className="pointer-events-auto bg-black/60 backdrop-blur-md border border-white/20 rounded-lg px-3 py-2 text-left hover:bg-black/80 hover:border-white/40 transition-all duration-200 active:scale-95 cursor-pointer"
|
className="pointer-events-auto bg-black/60 backdrop-blur-md border border-white/20 rounded-lg px-3 py-2 text-left hover:bg-black/80 hover:border-white/40 transition-all duration-200 active:scale-95 cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => onSelect({ id: spot.id, label: spot.label, sub: spot.sub })}
|
||||||
// İleride: daire içi frame sekansını başlat
|
|
||||||
console.log('unit clicked:', spot.id)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<p className="text-white text-xs font-medium tracking-wide whitespace-nowrap">{spot.label}</p>
|
<p className="text-white text-xs font-medium tracking-wide whitespace-nowrap">{spot.label}</p>
|
||||||
<p className="text-white/50 text-[10px] mt-0.5 whitespace-nowrap">{spot.sub}</p>
|
<p className="text-white/50 text-[10px] mt-0.5 whitespace-nowrap">{spot.sub}</p>
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
const UNIT_FRAMES = 40
|
||||||
|
|
||||||
|
interface UnitRevealOverlayProps {
|
||||||
|
unitId: string | null
|
||||||
|
unitLabel: string
|
||||||
|
unitSub: string
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UnitRevealOverlay({
|
||||||
|
unitId,
|
||||||
|
unitLabel,
|
||||||
|
unitSub,
|
||||||
|
onClose,
|
||||||
|
}: UnitRevealOverlayProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const framesRef = useRef<HTMLImageElement[]>([])
|
||||||
|
const rafRef = useRef<number | null>(null)
|
||||||
|
const [loadProgress, setLoadProgress] = useState(0)
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const [finished, setFinished] = useState(false)
|
||||||
|
|
||||||
|
// Frame'leri preload et
|
||||||
|
useEffect(() => {
|
||||||
|
if (!unitId) return
|
||||||
|
|
||||||
|
setLoaded(false)
|
||||||
|
setFinished(false)
|
||||||
|
setLoadProgress(0)
|
||||||
|
framesRef.current = []
|
||||||
|
|
||||||
|
const images: HTMLImageElement[] = new Array(UNIT_FRAMES)
|
||||||
|
let loadedCount = 0
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
for (let i = 0; i < UNIT_FRAMES; i++) {
|
||||||
|
const img = new Image()
|
||||||
|
img.src = `/unit-frames/${unitId}/ezgif-frame-${String(i + 1).padStart(3, '0')}.jpg`
|
||||||
|
img.onload = () => {
|
||||||
|
if (cancelled) return
|
||||||
|
loadedCount++
|
||||||
|
setLoadProgress(Math.round((loadedCount / UNIT_FRAMES) * 100))
|
||||||
|
if (loadedCount === UNIT_FRAMES) {
|
||||||
|
framesRef.current = images
|
||||||
|
setLoaded(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
images[i] = img
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [unitId])
|
||||||
|
|
||||||
|
// Canvas boyutu
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = window.innerWidth
|
||||||
|
canvas.height = window.innerHeight
|
||||||
|
}
|
||||||
|
resize()
|
||||||
|
window.addEventListener('resize', resize)
|
||||||
|
return () => window.removeEventListener('resize', resize)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Yüklenince otomatik oynat
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loaded) return
|
||||||
|
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
const ctx = canvas?.getContext('2d')
|
||||||
|
if (!canvas || !ctx) return
|
||||||
|
|
||||||
|
let frame = 0
|
||||||
|
const fps = 24
|
||||||
|
const interval = 1000 / fps
|
||||||
|
let last = 0
|
||||||
|
|
||||||
|
const draw = (imgs: HTMLImageElement[], index: number) => {
|
||||||
|
const img = imgs[index]
|
||||||
|
if (img?.complete && img.naturalWidth > 0) {
|
||||||
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const animate = (ts: number) => {
|
||||||
|
if (ts - last >= interval) {
|
||||||
|
draw(framesRef.current, frame)
|
||||||
|
last = ts
|
||||||
|
if (frame < UNIT_FRAMES - 1) {
|
||||||
|
frame++
|
||||||
|
} else {
|
||||||
|
setFinished(true)
|
||||||
|
return // son frame'de dur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(animate)
|
||||||
|
}
|
||||||
|
|
||||||
|
rafRef.current = requestAnimationFrame(animate)
|
||||||
|
return () => {
|
||||||
|
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
||||||
|
}
|
||||||
|
}, [loaded])
|
||||||
|
|
||||||
|
// ESC ile kapat
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{unitId && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="fixed inset-0 z-50 bg-black"
|
||||||
|
>
|
||||||
|
{/* Canvas */}
|
||||||
|
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{!loaded && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black z-10">
|
||||||
|
<div className="w-48 h-px bg-white/10 mb-3">
|
||||||
|
<div
|
||||||
|
className="h-full bg-white/60 transition-all duration-150"
|
||||||
|
style={{ width: `${loadProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-white/30 text-xs tracking-widest uppercase">{loadProgress}%</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Kapat */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-6 right-6 z-20 flex items-center gap-2 bg-black/40 backdrop-blur-sm border border-white/20 text-white/70 hover:text-white hover:bg-black/60 transition-all rounded-full px-4 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
Geri
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Bitiş bilgisi — son frame'de */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{finished && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className="absolute bottom-16 left-10 z-20"
|
||||||
|
>
|
||||||
|
<p className="text-white/50 text-[10px] tracking-[0.4em] uppercase mb-2">Vesta Muğla</p>
|
||||||
|
<h2 className="text-white text-3xl md:text-4xl font-serif font-light">{unitLabel}</h2>
|
||||||
|
<p className="text-white/50 mt-1 text-sm">{unitSub}</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="mt-5 flex items-center gap-2 text-white/60 hover:text-white text-xs tracking-widest uppercase transition-colors"
|
||||||
|
>
|
||||||
|
<span>← Projeye Dön</span>
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 55 KiB |