<template>
<div
class="scene"
:class="orientation"
ref="scene"
>
<button
class="nav-button left"
@click="prev"
>
<svg
width="50%"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.818 4.182a.45.45 0 0 1 0 .636L6.136 7.5l2.682 2.682a.45.45 0 1 1-.636.636l-3-3a.45.45 0 0 1 0-.636l3-3a.45.45 0 0 1 .636 0"
fill="currentColor"
/>
</svg>
</button>
<div
class="carousel"
ref="carousel"
>
<slot>
<img src="@/assets/alps.png" />
</slot>
</div>
<button
class="nav-button right"
@click="next"
>
<svg
width="50%"
transform="matrix(-1,0,0,1,0,0)"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
version="1.1"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.818 4.182a.45.45 0 0 1 0 .636L6.136 7.5l2.682 2.682a.45.45 0 1 1-.636.636l-3-3a.45.45 0 0 1 0-.636l3-3a.45.45 0 0 1 .636 0"
fill="currentColor"
></path>
</svg>
</button>
</div>
</template>
<script>
export default {
name: 'Carousel3D',
inject: ['theme'],
props: {
orientation: {
type: String,
default: 'horizontal',
options: [{
key: 'Horizontal',
value: 'horizontal'
}, {
key: 'Vertical',
value: 'vertical'
}]
},
buttonBackground: {
type: String,
default: '',
control: 'color'
},
buttonBackgroundHover: {
type: String,
default: '',
control: 'color'
},
buttonColor: {
type: String,
default: '',
control: 'color'
},
buttonColorHover: {
type: String,
default: '',
control: 'color'
}
},
data() {
return {
selectedIndex: 0,
cellWidth: 0,
cellHeight: 0,
radius: 0,
theta: 0,
isDragging: false,
startPos: 0,
currentAngle: 0,
dragAngle: 0
};
},
computed: {
background() {
if (this.buttonBackground) return this.buttonBackground;
if (this.theme === 'dark') return 'hsla(0, 0%, 0%, 0.4)';
return 'hsla(0, 0%, 100%, 0.4)';
},
backgroundHover() {
if (this.buttonBackgroundHover) return this.buttonBackgroundHover;
if (this.theme === 'dark') return 'hsla(0, 0%, 0%, 0.8)';
return 'hsla(0, 0%, 100%, 0.8)';
},
color() {
if (this.buttonColor) return this.buttonColor;
if (this.theme === 'dark') return 'hsla(0, 0%, 100%, 0.6)';
return 'hsla(0, 0%, 0%, 0.6)';
},
colorHover() {
if (this.buttonColorHover) return this.buttonColorHover;
if (this.theme === 'dark') return 'hsla(0, 0%, 100%, 0.8)';
return 'hsla(0, 0%, 0%, 0.8)';
},
isHorizontal() {
return this.orientation === 'horizontal';
},
rotateFn() {
return this.isHorizontal ? 'rotateY' : 'rotateX';
},
cells() {
if (!this.$refs.carousel) return [];
const children = Array.from(this.$refs.carousel.children);
const len = children.length;
if (len === 1) {
const a = children[0].cloneNode(true);
const b = children[0].cloneNode(true);
this.$refs.carousel.appendChild(a);
this.$refs.carousel.appendChild(b);
return [children[0], a, b];
}
if (len === 2) {
const a = children[0].cloneNode(true);
const b = children[1].cloneNode(true);
this.$refs.carousel.appendChild(a);
this.$refs.carousel.appendChild(b);
return [children[0], children[1], a, b];
}
return children;
},
cellCount() {
return this.cells.length;
}
},
mounted() {
this.cellWidth = this.$refs.scene.offsetWidth;
this.cellHeight = this.$refs.scene.offsetHeight;
this.updateCarousel(true);
this.slotObserver = new MutationObserver(() => {
this.updateCarousel(true);
});
this.slotObserver.observe(this.$refs.carousel, {
childList: true,
// detect added/removed elements
subtree: false // only direct children
});
this.$refs.scene.addEventListener('pointerdown', this.onPointerDown);
window.addEventListener('pointerup', this.onPointerUp);
window.addEventListener('pointermove', this.onPointerMove);
window.addEventListener('resize', this.updateCarouselInitial);
window.addEventListener('orientationchange', this.updateCarouselInitial);
if (screen.orientation) screen.orientation.addEventListener('change', this.updateCarouselInitial);
},
beforeUnmount() {
this.$refs.scene.removeEventListener('pointerdown', this.onPointerDown);
window.removeEventListener('pointerup', this.onPointerUp);
window.removeEventListener('pointermove', this.onPointerMove);
window.removeEventListener('resize', this.updateCarouselInitial);
window.removeEventListener('orientationchange', this.updateCarouselInitial);
if (screen.orientation) screen.orientation.removeEventListener('change', this.updateCarouselInitial);
},
methods: {
updateCarouselInitial() {
this.updateCarousel(true);
},
updateCarousel(initial = false) {
if (this.cellCount === 0) return;
this.cellWidth = this.$refs.scene.offsetWidth;
this.cellHeight = this.$refs.scene.offsetHeight;
this.theta = 360 / this.cellCount;
const cellSize = this.isHorizontal ? this.cellWidth : this.cellHeight;
this.radius = Math.round(cellSize / 2 / Math.tan(Math.PI / this.cellCount));
this.cells.forEach((cell, i) => {
const cellAngle = this.theta * i;
cell.style.width = this.cellWidth + 'px';
cell.style.height = this.cellHeight + 'px';
cell.style.transform = `${this.rotateFn}(${cellAngle}deg) translateZ(${this.radius}px)`;
cell.style.opacity = 1;
if (!initial) {
cell.style.transition = 'transform 1s, opacity 1s';
} else {
cell.style.transition = 'transform 0s, opacity 0s';
}
});
this.rotateCarousel(initial);
},
rotateCarousel(initial = false) {
const angle = this.theta * this.selectedIndex * -1;
this.currentAngle = angle;
if (!initial) {
this.$refs.carousel.style.transition = 'transform 1s';
} else {
this.$refs.carousel.style.transition = 'transform 0s';
}
this.$refs.carousel.style.transform = `translateZ(${-this.radius}px) ${this.rotateFn}(${angle}deg)`;
},
rotateInstant(angle) {
this.$refs.carousel.style.transition = 'none';
this.$refs.carousel.style.transform = `translateZ(${-this.radius}px) ${this.rotateFn}(${angle}deg)`;
},
prev() {
this.selectedIndex--;
this.rotateCarousel();
},
next() {
this.selectedIndex++;
this.rotateCarousel();
},
// pointer events
onPointerDown(e) {
this.isDragging = true;
this.startPos = this.isHorizontal ? e.clientX : e.clientY;
this.dragAngle = this.currentAngle;
this.$refs.carousel.style.transition = 'none';
},
onPointerMove(e) {
if (!this.isDragging) return;
const currentPos = this.isHorizontal ? e.clientX : e.clientY;
let delta = currentPos - this.startPos;
if (!this.isHorizontal) delta = -delta;
const rotationPerPixel = this.theta / (this.isHorizontal ? this.cellWidth : this.cellHeight);
const angle = this.dragAngle + delta * rotationPerPixel;
this.rotateInstant(angle);
},
onPointerUp() {
if (!this.isDragging) return;
this.isDragging = false;
const match = this.$refs.carousel.style.transform.match(/rotate[XY]\((-?\d+(\.\d+)?)deg\)/);
let angle = match ? parseFloat(match[1]) : this.currentAngle;
const index = Math.round(angle / -this.theta);
this.selectedIndex = index;
this.rotateCarousel();
}
}
};
</script>
<style scoped>
.scene {
width: 100%;
aspect-ratio: 16 / 9;
perspective: 1000px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.carousel {
width: 100%;
height: 100%;
position: absolute;
/* change from absolute */
transform-style: preserve-3d;
}
.carousel>* {
position: absolute;
width: 100%;
height: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
}
.carousel img {
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
}
.nav-button {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: v-bind(background);
color: v-bind(color);
border: none;
font-size: 2rem;
cursor: pointer;
z-index: 2;
border-radius: 50%;
display: flex;
align-items: 0;
justify-content: center;
width: 50px;
height: 50px;
transition: background linear .2s, color linear .2s;
}
.nav-button:hover {
background: v-bind(backgroundHover);
color: v-bind(colorHover);
}
.nav-button.left {
left: 20px;
}
.nav-button.right {
right: 20px;
}
</style>