<template>
  <div id="rack" ref="el">
    <!-- Greetings from Stick Season! -->
  </div>
</template>

<script setup>
// Imports
import { computed, ref, onMounted, onUnmounted, watch } from "vue"
  
// Refs
const card = ref(null)
const el = ref(null)
const interactive = ref(true)
const selected = ref(false)
const tracks = ref([
  {
    "title": "Northern Attitude",
    "slug": "northern-attitude",
    "position": 1
  },
  {
    "title": "Stick Season",
    "slug": "stick-season",
    "position": 2
  },
  {
    "title": "All My Love",
    "slug": "all-my-love",
    "position": 3
  },
  {
    "title": "She Calls Me Back",
    "slug": "she-calls-me-back",
    "position": 4
  },
  {
    "title": "Come Over",
    "slug": "come-over",
    "position": 5
  },
  {
    "title": "New Perspective",
    "slug": "new-perspective",
    "position": 6
  },
  {
    "title": "Everywhere, Everything",
    "slug": "everywhere-everything",
    "position": 7
  },
  {
    "title": "Orange Juice",
    "slug": "orange-juice",
    "position": 8
  },
  {
    "title": "Strawberry Wine",
    "slug": "strawberry-wine",
    "position": 9
  },
  {
    "title": "Growing Sideways",
    "slug": "growing-sideways",
    "position": 10
  },
  {
    "title": "Halloween",
    "slug": "halloween",
    "position": 11
  },
  {
    "title": "Homesick",
    "slug": "homesick",
    "position": 12
  },
  {
    "title": "Still",
    "slug": "still",
    "position": 13
  },
  {
    "title": "The View Between Villages",
    "slug": "the-view-between-villages",
    "position": 14
  },
  {
    "title": "Your Needs, My Needs",
    "slug": "your-needs-my-needs",
    "position": 15
  },
  {
    "title": "Dial Drunk",
    "slug": "dial-drunk",
    "position": 16
  },
  {
    "title": "Paul Revere",
    "slug": "paul-revere",
    "position": 17
  },
  {
    "title": "No Complaints",
    "slug": "no-complaints",
    "position": 18
  },
  {
    "title": "Call Your Mom",
    "slug": "call-your-mom",
    "position": 19
  },
  {
    "title": "You’re Gonna Go Far",
    "slug": "youre-gonna-go-far",
    "position": 20
  }
])

// Vars
let scene, camera, renderer, raycaster, pointer, frame, hammer
let holderModel, postcardTextures, backTexture, signTexture
let box, rack, spinner, pedestal, topper
let postcards = []

// Watchers
// ----------

// Interactive
watch(interactive, (newVal, oldVal) => {
  // Log
  console.log('interactive', newVal, oldVal)  

  // Adjust pan
  hammer.get('pan').set({
    enable: newVal
  })
  
  // Adjust tap
  hammer.get('tap').set({
    enable: newVal
  })

})

// Card
watch(card, (newVal, oldVal) => {
  // Log
  console.log('card', newVal, oldVal)

  // If card reset
  if (!newVal) {
    // Return card
    returnPostcard(postcards[oldVal.position - 1])

  }

})
  
// Selected
watch(selected, (newVal, oldVal) => {
  // Log
  console.log('selected', newVal, oldVal)
  
  // If selected
  if (newVal) { 
    // Enable tap
    hammer.get('tap').set({
      enable: true
    })
    
  }
  
})

// Initialize rack
// ----------
async function initializeRack() {
  // Scene
  addScene()

  // Camera
  addCamera()

  // Renderer
  addRenderer()

  // Raycaster
  addRaycaster()

  // Hammer
  addHammer()

  // Load models
  await loadModels()

  // Load textures
  await loadTextures()

  // Rack
  addRack()

  // Box
  addBox()

  // Spinner
  addSpinner()

  // Pedestal
  addPedestal()

  // Start rendering
  startRendering()

  // Start resizing
  startResizing()

}

// Add scene
// ----------
function addScene() {
  // Scene
  scene = new THREE.Scene()

}

// Add camera
// ----------
function addCamera() {
  // Camera
  camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)

  // Position camera
  camera.position.y = 22
  camera.position.z = 50

}

// Return camera
// ----------
function returnCamera() {
  // Animate camera position
  gsap.to(camera.position, {
    duration: 2,
    y: 22,
    z: 50
  })

}

// Point camera
// ----------
function pointCamera(postcard) {
  // Promise
  return new Promise((resolve, revoke) => {
    // Initialize position
    let position = new THREE.Vector3()

    // Get postcard world position
    postcard.getWorldPosition(position)

    // Animate camera position
    gsap.to(camera.position, {
      duration: 2,
      y: position.y + 3,
      z: 30,
      onComplete() {
        // Resolve
        resolve()

      }
    })

  })

}

// Add renderer
// ----------
function addRenderer() {
  // Renderer
  renderer = new THREE.WebGLRenderer({
    alpha: true,
    antialias: true
  })
  
  // Resize renderer
  renderer.setSize(window.innerWidth, window.innerHeight)

  // Pixel ratio
  renderer.setPixelRatio(window.devicePixelRatio)

  // Append renderer
  el.value.appendChild(renderer.domElement)

}

// Add raycaster
// ----------
function addRaycaster() {
  // Raycaster
  raycaster = new THREE.Raycaster()

  // Pointer
  pointer = new THREE.Vector2()

  // Start moving
  startMoving()

}

// Add hammer
// ----------
function addHammer() {
  // Initialize hammer
  hammer = new Hammer(renderer.domElement, {
    enable: interactive.value
  })

  // Set pan horizontal
  hammer.get('pan').set({
    direction: Hammer.DIRECTION_HORIZONTAL
  })

  // Tap
  hammer.on('tap', e => {
    // Log
    // console.log('tap', e)

    // Update pointer
    pointer.x = (e.center.x / window.innerWidth) * 2 - 1
    pointer.y = - (e.center.y / window.innerHeight) * 2 + 1

    // Set raycaster
    raycaster.setFromCamera(pointer, camera)

    // Check for intersects
    const intersects = raycaster.intersectObjects(postcards)

    // If intersects
    if (intersects.length) {
      // Get postcard
      let postcard = intersects[0].object.parent
      
      // If selected
      if (selected.value) {
        // Reset card
        card.value = null
        
      } else {
        // Select postcard
        selectPostcard(postcard)
        
      }
      

    }

  })

  // Pan start
  hammer.on('panstart', e => {
    // Log
    // console.log('panstart', e)

    // Grabbing cursor
    el.value.style.cursor = "grabbing"

  })

  // Pan
  hammer.on('pan', e => {
    // Log
    // console.log('pan', e)

    // Rotate spinner
    spinner.rotation.y += e.velocityX / 4

  })

  // Pan end
  hammer.on('panend', e => {
    // Log
    // console.log('panend', e)

    // Default cursor
    el.value.style.cursor = "pointer"

  })

}

// Load model
// ----------
function loadModel(url) {
  // Promise
  return new Promise((resolve, revoke) => {
    // Load model
    new THREE.GLTFLoader().load(url, resolve, undefined, revoke)

  })
}

// Load models
// ----------
async function loadModels() {
  // Load model
  let model = await loadModel('https://assets.codepen.io/141041/holder.glb')

  // Get holder
  holderModel = model.scene.children[0]

  // Adjust color
  holderModel.material = new THREE.MeshBasicMaterial({
    color: 0x998564
  })

}

// Load texture
// ----------
function loadTexture(url) {
  // Promise
  return new Promise((resolve, revoke) => {
    // Load texture
    new THREE.TextureLoader().load(url, resolve, undefined, revoke)

  })

}

// Load textures
// ----------
async function loadTextures() {
  // Load postcard textures
  postcardTextures = await Promise.all(tracks.value.map(track => loadTexture(`https://assets.codepen.io/141041/${track.slug}.jpg`)))

  // Loop through postcard textures
  postcardTextures.forEach(texture => {
    // Set wrap
    texture.wrapS = THREE.RepeatWrapping

    // Flip texture
    texture.repeat.x = -1

  })

  // Load postcard back texture
  backTexture = await loadTexture('https://assets.codepen.io/141041/back.jpg')

  // Load sign texture
  signTexture = await loadTexture('https://assets.codepen.io/141041/sign.jpg')

  // Adjust colorspace
  Array.from([...postcardTextures, backTexture, signTexture]).forEach(texture => texture.colorSpace = THREE.SRGBColorSpace)

}

// Add rack
// ----------
function addRack() {
  // Initialize rack
  rack = new THREE.Object3D()

  // Add rack to scene
  scene.add(rack)

}

// Add box
// ----------
function addBox() {
  // Geometry
  let geometry = new THREE.BoxGeometry(24, 64, 24)

  // Material
  let material = new THREE.MeshBasicMaterial({
    color: 0xFFFFFF,
    depthWrite: false,    
    opacity: 0.0,
    transparent: true
  })

  // Box
  box = new THREE.Mesh(geometry, material)

  // Position box
  box.position.y = 20

  // Add box to rack
  rack.add(box)

}

// Add spinner
// ----------
function addSpinner() {
  // Initialize spinner
  spinner = new THREE.Object3D()

  // Position spinner
  spinner.position.y = 2

  // Add spinner to rack
  rack.add(spinner)

  // Loop 5 times for each row
  for (let i = 0; i < 5; i++) {
    // Add row
    addRow(i)

  }

}

// Rotate spinner
// ----------
function rotateSpinner(column) {
  // Promise
  return new Promise((resolve, revoke) => {
    // Spinner rotation to
    let to = [0, THREE.MathUtils.degToRad(-90), THREE.MathUtils.degToRad(-180), THREE.MathUtils.degToRad(-270)][column]

    // Spinner rotation from
    let from = spinner.rotation.y % (Math.PI * 2)

    // Log
    // console.log('from: ', from , 'to: ', to)

    // Calculate shortest rotation
    if (Math.abs(to - from) > Math.PI) {
      if (to > 0) {
        to = to - 2 * Math.PI
      } else {
        to = to + 2 * Math.PI
      }
    }

    // Calculate delta
    let delta = to - from

    // Log
    // console.log('from: ', from , 'to: ', to, 'delta: ', delta)

    // Rotate spinner
    gsap.to(spinner.rotation, {
      duration: 2,
      // y: to,
      y: `+=${delta}`,
      onComplete() {
        // Resolve
        resolve()
        
      }
    })

  })

}

// Add row
// ----------
function addRow(row) {
  // Loop 4 times for each column
  for (let column = 0; column < 4; column++) {
    // Clone holder
    let holder = holderModel.clone()

    // Rotate holder
    holder.rotation.y = THREE.MathUtils.degToRad((column * 90) + 90)

    // Position holder x or y
    switch(column) {
      case 0:
        holder.position.z = 8
        break
      case 1:
        holder.position.x = 8
        break
      case 2:
        holder.position.z = -8
        break
      case 3:
        holder.position.x = -8
        break
    }

    // Position holder y
    holder.position.y = (8 * row) + 3

    // Name
    holder.name = "holder"

    // Row
    holder.row = row

    // Column
    holder.column = column

    // Add holder to spinner
    spinner.add(holder)

    // Get postcard index
    let postcard = row * 4 + column

    // Add postcard
    addPostcard(postcard, holder)

  }

}

// Initialize postcard
// ----------
function addPostcard(index, holder) {
  // Postcard
  let postcard = new THREE.Object3D()

  // Geometry
  let geometry = new THREE.PlaneGeometry(12, 8)

  // Rotate geometry
  geometry.rotateY(THREE.MathUtils.degToRad(90))

  // Back material
  let backMaterial = new THREE.MeshBasicMaterial({
    color: 0xFFFFFF,
    map: backTexture,
    side: THREE.FrontSide,
  })

  // Postcard back
  let postcardBack = new THREE.Mesh(geometry, backMaterial)

  // Name
  postcardBack.name = "back"

  // Add to postcard
  postcard.add(postcardBack)

  // Front material
  let frontMaterial = new THREE.MeshBasicMaterial({
    color: 0xFFFFFF,
    map: postcardTextures[index],
    side: THREE.BackSide,
  })

  // Postcard front
  let postcardFront = new THREE.Mesh(geometry, frontMaterial)

  // Name
  postcardFront.name = "front"

  // Add to postcard
  postcard.add(postcardFront)

  // Name
  postcard.name = "postcard"

  // Track
  postcard.track = tracks.value[index]

  // Selected
  postcard.selected = false

  // Scale postcard
  postcard.scale.set(0.95, 0.95, 0.95)

  // Rotate postcard
  postcard.rotation.z = THREE.MathUtils.degToRad(-20)

  // Position postcard
  postcard.position.x = 0.75
  postcard.position.y = 0.75

  // Add postcard to holder
  holder.add(postcard)

  // Add postcard to array
  postcards.push(postcard)

  // Animation
  // ----------

  // Postcard animation timeline
  let tl = gsap.timeline({
    duration: 0.25,
    paused: true
  })

  // Rotate postcard back
  tl.to(postcard.rotation, {
    z: THREE.MathUtils.degToRad(30),
  })

  // Position postcard back
  tl.to(postcard.position, {
    x: "0"
  }, "<")

  // Pull out of holder
  tl.to(postcard.position, {
    y: "+=6",
    x: "-=5"
  })

  // Hold straight
  tl.to(postcard.rotation, {
    z: THREE.MathUtils.degToRad(0)
  })

  // Lower
  tl.to(postcard.position, {
    y: "-=3"
  }, "<")

  // Fade away everything else
  tl.to(box.material, {
    opacity: 1.0
  }, "<")

  // Add postcard pull animation
  postcard.pull = tl
}

// Select postcard
// ----------
async function selectPostcard(postcard) {
  // Log
  console.log('select postcard: ', postcard)

  // Not interactive
  interactive.value = false

  // Selected
  postcard.selected = true

  // Point camera and rotate spinner
  await Promise.all([
    pointCamera(postcard),
    rotateSpinner(postcard.parent.column)
  ])

  // Pull postcard
  postcard.pull.play().then(() => {
    // Log
    console.log('postcard selected')

    // Update card
    card.value = postcard.track
    
    // Selected
    selected.value = true

  })

}

// Return postcard
// ----------
function returnPostcard(postcard) {
  // Log
  console.log('return postcard: ', postcard)

  // Not selected
  postcard.selected = false

  // Return camera
  returnCamera()

  // Put back postcard
  postcard.pull.reverse().then(() => {
    // Log
    console.log('postcard returned')

    // Interactive
    interactive.value = true
    
    // Not selected
    selected.value = false

  })

}

// Add pedestal
// ----------
function addPedestal() {
  // Initialize pedestal
  pedestal = new THREE.Object3D()

  // Add pedestal to rack
  rack.add(pedestal)

  // Base
  addBase()

  // Pole
  addPole()

  // Topper
  addTopper()

}

// Add base
// ----------
function addBase() {
  // Geometry
  let geometry = new THREE.CylinderGeometry(12, 12, 0.25, 32)

  // Material
  let material = new THREE.MeshBasicMaterial({
    color: 0xF1F1F1
  })

  // Base
  let base = new THREE.Mesh(geometry, material)

  // Name
  base.name = "base"

  // Add base to pedestal
  pedestal.add(base)

}

// Add pole
// ----------
function addPole() {
  // Geometry
  let geometry = new THREE.CylinderGeometry(1, 1, 44, 16)

  // Material
  let material = new THREE.MeshBasicMaterial({
    color: 0x998564
  })

  // Pole
  let pole = new THREE.Mesh(geometry, material)

  // Name
  pole.name = "pole"

  // Position pole
  pole.position.y = 22

  // Add pole to pedestal
  pedestal.add(pole)

}

// Add topper
// ----------
function addTopper() {
  // Initialize topper
  topper = new THREE.Object3D()

  // Position topper
  topper.position.y = 44

  // Add topper to pedestal
  pedestal.add(topper)

  // Slot
  addSlot()

  // Sign
  addSign()

}

// Add slot
// ----------
function addSlot() {
  // Geometry
  let geometry = new THREE.BoxGeometry(5, 2, 1)

  // Material
  let material = new THREE.MeshBasicMaterial({
    color: 0xF1F1F1
  })

  // Slot
  let slot = new THREE.Mesh(geometry, material)

  // Name
  slot.name = "slot"

  // Position slot
  slot.position.y = 1

  // Add slot to topper
  topper.add(slot)

}

// Add sign
// ----------
function addSign() {
  // Geometry
  let geometry = new THREE.PlaneGeometry(17, 7)

  // Material
  let material = new THREE.MeshBasicMaterial({
    map: signTexture,
    side: THREE.DoubleSide,
    // transparent: true
  })

  // Sign
  let sign = new THREE.Mesh(geometry, material)

  // Name
  sign.name = "sign"

  // Position sign
  sign.position.y = 4

  // Add sign to topper
  topper.add(sign)

}

// Render
// ----------
function render() {
  // Request animation frame
  frame = requestAnimationFrame(render)

  // Render scene
  renderer.render(scene, camera)

}

// Start rendering
// ----------
function startRendering() {
  // Log
  console.log('start rendering')

  // Render
  render()

}

// Stop rendering
// ----------
function stopRendering() {
  // Log
  console.log('stop rendering')

  // Stop render
  cancelAnimationFrame(frame)

}

// Resize
// ----------
function resize() {
  // Log
  console.log('resize')

  // Update camera aspect
  camera.aspect = window.innerWidth / window.innerHeight

  // Update camera projection
  camera.updateProjectionMatrix()

  // Resize renderer
  renderer.setSize(window.innerWidth, window.innerHeight)

}

// Start resizing
// ----------
function startResizing() {
  // Log
  console.log('start resizing')

  // On window resize
  window.addEventListener('resize', resize)

}

// Stop resizing
// ----------
function stopResizing() {
  // Log
  console.log('stop resizing')

  // Remove window resize event listener
  window.removeEventListener('resize', resize)

}

// Move
// ----------
function move(e) {
  // Log
  // console.log('move', e)

  // Update pointer
  pointer.x = (e.clientX / window.innerWidth) * 2 - 1
  pointer.y = - (e.clientY / window.innerHeight) * 2 + 1

}

// Start moving
// ----------
function startMoving() {
  // Log
  console.log('start moving')

  // On pointer move
  window.addEventListener('pointermove', move)

}

// Stop moving
// ----------
function stopMoving() {
  // Log
  console.log('stop moving')

  // Remove window pointer move listener
  window.removeEventListener('pointermove', move)

}

// Play intro
// ----------
function playIntro() {
  // Intro timeline
  let tl = gsap.timeline({
    ease: "none",
    delay: 0,
    onComplete() {
      // Introed
      introed.value = true

      // Select postcard
      selectPostcard(postcards[card.value.position - 1])

    }
  })

  // Fade out box
  tl.to(box.material, {
    duration: 10,
    opacity: 0
  })

  // Position camera
  tl.to(camera.position, {
    duration: 10,
    y: 22,
    z: 50
  }, "<")

  // Rotate spinner (to particular location?)
  tl.to(spinner.rotation, {
    duration: 10,
    y: Math.PI * 2
  }, "<")

}

// On mounted
// ----------
onMounted(async () => {
  // Initialize rack
  initializeRack()

})

// On Unmounted
// ----------
onUnmounted(() => {
  // Stop rendering
  stopRendering()

  // Stop resizing
  stopResizing()

  // Stop moving
  stopMoving()

})
</script>

<style>
  canvas{
    cursor: pointer;
  }
</style>
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/hammer.js/2.0.8/hammer.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/three.js/0.154.0/three.min.js
  3. https://unpkg.com/three@0.135.0/examples/js/loaders/GLTFLoader.js
  4. https://unpkg.co/gsap@3/dist/gsap.min.js