327 lines
8.3 KiB
Vue
327 lines
8.3 KiB
Vue
<template>
|
||
<!-- scène 3D -->
|
||
<div ref="scene" class="scene z-10">
|
||
<div ref="box" class="box">
|
||
<div class="face front relative" ref="frontFace">
|
||
<img class="cover absolute" :src="`/${compilation.id}/cover.jpg`" alt="">
|
||
</div>
|
||
<div class="face back" ref="backFace" />
|
||
<div class="face right" ref="rightFace" />
|
||
<div class="face left" ref="leftFace" />
|
||
<div class="face top" ref="topFace">
|
||
<img class="logo h-full p-1" src="/logo.svg" alt="">
|
||
<img class="absolute block h-1/2" style="left:5%;" :src="`/${compilation.id}/title.svg`" alt="">
|
||
</div>
|
||
<div class="face bottom" ref="bottomFace" />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import type { Compilation, BoxPosition } from '~~/types/types';
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
compilation: Compilation
|
||
position?: BoxPosition
|
||
size?: number
|
||
}>(),
|
||
{
|
||
position: () => ({ x: 0, y: 0, z: 0 }),
|
||
size: 6,
|
||
}
|
||
)
|
||
|
||
// States
|
||
|
||
const angleX = ref(props.position.x)
|
||
const angleY = ref(props.position.y)
|
||
const angleZ = ref(props.position.z)
|
||
|
||
const frontFace = ref()
|
||
const backFace = ref()
|
||
const rightFace = ref()
|
||
const leftFace = ref()
|
||
const topFace = ref()
|
||
const bottomFace = ref()
|
||
|
||
const box = ref()
|
||
const scene = ref()
|
||
|
||
/*
|
||
ÉTATS POUR LE DRAG + INERTIE
|
||
*/
|
||
let dragging = false
|
||
let lastPointer = { x: 0, y: 0, time: 0 } // position précédente du pointeur
|
||
let velocity = { x: 0, y: 0 } // vitesse calculée pour inertie
|
||
let raf = null // id du requestAnimationFrame
|
||
|
||
/*
|
||
PARAMÈTRES DE RÉGLAGE
|
||
*/
|
||
const sensitivity = 0.3 // combien de degrés par pixel de mouvement
|
||
const friction = 0.95 // inertie : 1 = sans perte, plus bas = ralentit vite
|
||
const minVelocity = 0.02 // seuil sous lequel on arrête l’inertie
|
||
const enableInertia = true // true = inertie activée, false = rotation immédiate sans suite
|
||
|
||
/*
|
||
Applique la transformation CSS à la box
|
||
*/
|
||
function applyTransform() {
|
||
angleX.value = Math.round(angleX.value)
|
||
angleY.value = Math.round(angleY.value)
|
||
angleZ.value = Math.round(angleZ.value)
|
||
|
||
box.value.style.transform = `rotateX(${angleX.value}deg) rotateY(${angleY.value}deg) rotateZ(${angleZ.value}deg)`
|
||
}
|
||
|
||
function applySize() {
|
||
updateCssVar('--height', `${props.size * (100 / 3)}px`, scene.value)
|
||
updateCssVar('--width', `${props.size * 50}px`, scene.value)
|
||
updateCssVar('--depth', `${props.size * 10}px`, scene.value)
|
||
}
|
||
function applyColor() {
|
||
frontFace.value.style.setProperty('background', `${props.compilation.color2}`)
|
||
backFace.value.style.setProperty('background', `linear-gradient(to top, ${props.compilation.color1}, ${props.compilation.color2})`)
|
||
rightFace.value.style.setProperty('background', `linear-gradient(to top, ${props.compilation.color1}, ${props.compilation.color2})`)
|
||
leftFace.value.style.setProperty('background', `linear-gradient(to top, ${props.compilation.color1}, ${props.compilation.color2})`)
|
||
topFace.value.style.setProperty('background', `linear-gradient(to top, ${props.compilation.color2}, ${props.compilation.color2})`)
|
||
bottomFace.value.style.setProperty('background', `${props.compilation.color1}`)
|
||
}
|
||
|
||
/*
|
||
Fonction utilitaire : place la box directement à une rotation donnée
|
||
*/
|
||
function applyRotation() {
|
||
angleX.value = props.position.x
|
||
angleY.value = props.position.y
|
||
angleZ.value = props.position.z
|
||
applyTransform()
|
||
|
||
box.value.style.setProperty('transition', 'transform 800ms ease-in-out')
|
||
setTimeout(() => {
|
||
box.value.style.setProperty('transition', 'transform 120ms linear')
|
||
}, 120)
|
||
}
|
||
|
||
/*
|
||
Boucle d’inertie après un drag
|
||
- reprend la vitesse calculée à la release
|
||
- diminue petit à petit avec friction
|
||
- stoppe quand vitesse < minVelocity
|
||
*/
|
||
function tickInertia() {
|
||
if (!enableInertia) return
|
||
|
||
// appliquer la friction
|
||
velocity.x *= friction
|
||
velocity.y *= friction
|
||
|
||
// appliquer au box
|
||
angleX.value += velocity.y
|
||
angleY.value += velocity.x
|
||
|
||
// clamp angleX pour éviter de retourner la box trop loin
|
||
angleX.value = Math.max(-80, Math.min(80, angleX.value))
|
||
|
||
applyTransform()
|
||
|
||
// continuer tant qu’il reste du mouvement
|
||
if (Math.abs(velocity.x) > minVelocity || Math.abs(velocity.y) > minVelocity) {
|
||
raf = requestAnimationFrame(tickInertia)
|
||
} else {
|
||
raf = null
|
||
}
|
||
}
|
||
|
||
/*
|
||
Mise en place des listeners une fois le composant monté
|
||
*/
|
||
onMounted(() => {
|
||
applySize()
|
||
applyColor()
|
||
applyTransform()
|
||
|
||
// pointerdown = début du drag
|
||
const down = (ev) => {
|
||
ev.preventDefault()
|
||
dragging = true
|
||
scene.value.setPointerCapture(ev.pointerId)
|
||
lastPointer = { x: ev.clientX, y: ev.clientY, time: performance.now() }
|
||
velocity = { x: 0, y: 0 }
|
||
if (raf) { cancelAnimationFrame(raf); raf = null }
|
||
}
|
||
|
||
// pointermove = on bouge la souris ou le doigt
|
||
const move = (ev) => {
|
||
if (!dragging) return
|
||
ev.preventDefault()
|
||
const now = performance.now()
|
||
const dx = ev.clientX - lastPointer.x
|
||
const dy = ev.clientY - lastPointer.y
|
||
|
||
// mise à jour des angles
|
||
angleY.value += dx * sensitivity
|
||
angleX.value -= dy * sensitivity
|
||
angleX.value = Math.max(-80, Math.min(80, angleX.value))
|
||
|
||
// calcul vitesse pour inertie
|
||
const dt = Math.max(1, now - lastPointer.time)
|
||
velocity.x = (dx / dt) * 16 * sensitivity
|
||
velocity.y = (-dy / dt) * 16 * sensitivity
|
||
|
||
lastPointer = { x: ev.clientX, y: ev.clientY, time: now }
|
||
|
||
applyTransform()
|
||
}
|
||
|
||
// pointerup = fin du drag
|
||
const end = (ev) => {
|
||
if (!dragging) return
|
||
dragging = false
|
||
try { scene.value.releasePointerCapture(ev.pointerId) } catch { }
|
||
if (enableInertia && (Math.abs(velocity.x) > minVelocity || Math.abs(velocity.y) > minVelocity)) {
|
||
if (!raf) raf = requestAnimationFrame(tickInertia)
|
||
}
|
||
}
|
||
|
||
// attach des events
|
||
scene.value.addEventListener('pointerdown', down)
|
||
scene.value.addEventListener('pointermove', move)
|
||
scene.value.addEventListener('pointerup', end)
|
||
scene.value.addEventListener('pointercancel', end)
|
||
scene.value.addEventListener('pointerleave', end)
|
||
|
||
// cleanup au démontage
|
||
onBeforeUnmount(() => {
|
||
cancelAnimationFrame(raf)
|
||
})
|
||
})
|
||
|
||
watch(() => props.position, () => {
|
||
applyRotation()
|
||
}, { deep: true })
|
||
|
||
watch(() => props.compilation, () => {
|
||
applyColor()
|
||
}, { deep: true })
|
||
|
||
watch(() => props.size, () => {
|
||
applySize()
|
||
})
|
||
</script>
|
||
|
||
|
||
<style>
|
||
html,
|
||
body {
|
||
height: 100%;
|
||
margin: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-family: system-ui, Segoe UI, Roboto, Helvetica, Arial;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
/* scène avec perspective */
|
||
.scene {
|
||
height: var(--height);
|
||
width: var(--width);
|
||
perspective: 1000px;
|
||
touch-action: none;
|
||
/* essentiel pour empêcher le scroll pendant le drag */
|
||
/* height: 20px; */
|
||
transition: all .5s;
|
||
}
|
||
|
||
/* l'objet 3D (box simple) */
|
||
.box {
|
||
width: var(--width);
|
||
height: var(--height);
|
||
position: relative;
|
||
transform-style: preserve-3d;
|
||
transition: transform 120ms linear;
|
||
transition: height 120ms linear;
|
||
/* légère smoothing quand on lâche */
|
||
margin: auto;
|
||
user-select: none;
|
||
cursor: grab;
|
||
}
|
||
|
||
.box:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
/* faces du box */
|
||
.face {
|
||
position: absolute;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 20px;
|
||
color: white;
|
||
font-weight: 600;
|
||
backface-visibility: hidden;
|
||
/* border: 2px solid rgba(255, 255, 255, 0.06); */
|
||
box-sizing: border-box;
|
||
transform-origin: top right;
|
||
}
|
||
|
||
.front,
|
||
.back {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.face.top,
|
||
.face.bottom {
|
||
width: var(--width);
|
||
height: var(--depth);
|
||
}
|
||
|
||
.face.left,
|
||
.face.right {
|
||
width: var(--depth);
|
||
height: var(--height);
|
||
}
|
||
|
||
.face.front {
|
||
transform: translateX(0px) translateY(0px) translateZ(0px);
|
||
}
|
||
|
||
.face.back {
|
||
transform: rotateY(180deg) translateX(var(--width)) translateY(0px) translateZ(var(--depth));
|
||
}
|
||
|
||
.face.right {
|
||
transform: rotateY(90deg) translateX(0px) translateY(0px) translateZ(var(--width));
|
||
transform-origin: top left;
|
||
}
|
||
|
||
.face.left {
|
||
transform: rotateY(-90deg) translateX(0) translateY(0px) translateZ(var(--depth));
|
||
}
|
||
|
||
.face.top {
|
||
transform: rotateX(90deg) translateX(0px) translateY(calc(var(--depth) * -1)) translateZ(0px);
|
||
}
|
||
|
||
.face.top>* {
|
||
@apply rotate-180;
|
||
}
|
||
|
||
.face.bottom {
|
||
transform: rotateX(-90deg) translateX(0) translateY(0px) translateZ(calc(var(--height)));
|
||
}
|
||
|
||
.cover {
|
||
height: 100%;
|
||
width: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.logo {
|
||
filter: drop-shadow(2px 2px 0 rgb(0 0 0 / 0.8));
|
||
}
|
||
</style> |