my-three-menu/src/ThreeMenu.vue

<template>
  <div class="my-three-menu" :style="styles">
    <div class="my-three-menu__container" ref="container">
      <div
        v-for="(item, idx) in this.elements"
        :key="idx"
        class="my-three-menu__item"
        style="display: none"
        :data-index="idx"
      >
        <slot name="item" :item="item"></slot>
      </div>
    </div>
    <slot></slot>
    <div class="my-three-menu__menu">
      <div class="table my-three-menu__button" ref="table" v-if="showTab" @click="transLayout('table')">
        <MyIcon :name="'icon-all-fill'" svg></MyIcon>
      </div>
      <div class="sphere my-three-menu__button" ref="sphere" v-if="showTab"  @click="transLayout('sphere')">
        <MyIcon :name="'icon-network'" svg></MyIcon>
      </div>
      <div class="helix my-three-menu__button" ref="helix" v-if="showTab"  @click="transLayout('helix')">
        <MyIcon :name="'icon-odbc'" svg></MyIcon>
      </div>
      <div class="grid my-three-menu__button" ref="grid" v-if="showTab"  @click="transLayout('grid')">
        <MyIcon :name="'icon-copy-rect-fill'" svg></MyIcon>
      </div>
      <div
      v-if="showTab && currLayout === 'grid'"
        :class="['prev', 'my-three-menu__button', { disabled: gridIndex === 0 }]"
        ref="prev"
        @click="jumpPrev()"
      >
        <MyIcon :name="'icon-mono-left-fill'" svg></MyIcon>
      </div>
      <div
      v-if="showTab && currLayout === 'grid'"
        :class="['next', 'my-three-menu__button', { disabled: gridIndex === gridLayerCount - 1 }]"
        ref="next"
        @click="jumpNext()"
      >
        <MyIcon :name="'icon-mono-right-fill'" svg></MyIcon>
      </div>
    </div>
  </div>
</template>
<script>
  /**
   * 3D菜单
   * @author 李国师 chester
   * @module $ui/components/my-three-menu
   */
  import {addResizeListener, removeResizeListener} from 'element-ui/lib/utils/resize-event'
import * as THREE from 'three'
import MyIcon from '../../my-icon/src/Icon'
import '$ui/icons/mono-left-fill.js'
import '$ui/icons/mono-right-fill.js'
import '$ui/icons/odbc.js'
import '$ui/icons/all-fill.js'
import '$ui/icons/network.js'
import '$ui/icons/copy-rect-fill.js'
import { TWEEN } from 'three/examples/jsm/libs/tween.module.min'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import {
  CSS3DObject,
  CSS3DRenderer
} from 'three/examples/jsm/renderers/CSS3DRenderer'
import { cloneDeep } from '$ui/utils/util'
const defaultOption = {
        camera: {
        //   deep: 2000
        },
        table: {
          // x: 0,
          // y: 0,
        //   column: 10
        },
        sphere: {
        //   radius: 600
        },
        helix: {
        //   y: 50, // 圆柱的y坐标偏移
        //   radius: 800, // 圆柱的半径
        //   dy: 10 // 圆柱的层高
        },
        grid: {
        //   row: 3,
        //   column: 3,
        //   deep: 0,
        //   layerSpace: 200,
        //   x: -100,
        //   y: 400
        }
      }
  /**
   * 插槽
   * @member slots
   * @property {string} default 默认插槽,定义其它内容
   * @property {string} item 卡片插槽,定义其它内容
   */
export default {
  name: 'MyThreeMenu',
  components: {
    MyIcon
  },
      /**
     * 属性参数
     * @member props
     * @property {Array} [list] 卡片列表
     * @property {string} [width] 画布宽
     * @property {string} [height] 画布高
     * @property {number} [itemWidth] 卡片宽
     * @property {number} [itemHeight] 卡片高
     * @property {number} [itemSpace] 卡片边距
     * @property {string} [defaultLayout] 默认布局
     * @property {boolean} [showTab] 显示菜单
     * @property {boolean} [play] 是否自动播放
     * @property {number} [playDuration] 播放等待时长(ms)
     * @property {number} [transformDuration] 变换时长(ms)
     * @property {object} [options] 布局配置
     * @property {object} [options.camera] 摄像机配置
     * @property {number} [options.camera.deep] 默认摄像相机深度
     * @property {number} [options.camera.minDistance] 最近摄像相机深度
     * @property {number} [options.camera.maxDistance] 最远摄像相机深度
     * @property {number} [options.camera.minZoom] 最小缩放
     * @property {number} [options.camera.maxZoom] 最大缩放
     * @property {number} [options.camera.minPolarAngle] 垂直最小旋转角度
     * @property {number} [options.camera.maxPolarAngle] 垂直最大旋转角度
     * @property {number} [options.camera.minAzimuthAngle] 水平最小旋转角度
     * @property {number} [options.camera.maxAzimuthAngle] 水平最大旋转角度
     * @property {boolean} [options.camera.enableZoom] 是否可缩放
     * @property {boolean} [options.camera.enableRotate] 是否可旋转
     * @property {boolean} [options.camera.enablePan] 是否可鼠标右键拖动
     * @property {object} [options.table] 表格布局配置
     * @property {number} [options.table.x] x偏移
     * @property {number} [options.table.y] y偏移
     * @property {number} [options.table.column] 列数
     * @property {object} [options.sphere] 圆球布局配置
     * @property {number} [options.sphere.radius] 圆球半径
     * @property {number} [options.sphere.x] 圆球x偏移
     * @property {number} [options.sphere.y] 圆球y偏移
     * @property {object} [options.helix] 圆柱布局配置
     * @property {number} [options.helix.x] 圆柱x偏移
     * @property {number} [options.helix.y] 圆柱y偏移
     * @property {number} [options.helix.radius] 圆柱半径
     * @property {number} [options.helix.dy] 圆柱卡片高度差
     * @property {object} [options.grid] 网格布局配置
     * @property {number} [options.grid.x] 网格x偏移
     * @property {number} [options.grid.y] 网格y偏移
     * @property {number} [options.grid.row] 网格行数
     * @property {number} [options.grid.column] 网格列数
     * @property {number} [options.grid.layerSpace] 网格层距
     **/
  props: {
    list: {
      type: Array,
      default() {
        return []
      }
    },
    width: {
        type: String,
        default: '100%'
    },  
    height: {
        type: String,
        default: '400px'
    },
    itemWidth: {
        type: Number,
        default: 200
    },
      itemHeight: {
          type: Number,
          default: 150
      },
      itemSpace: {
          type: Number,
          default: 5
      },
      defaultLayout: {
          type: String,
          default: 'table',
          validator: (val) => {
              return ['table', 'helix', 'sphere', 'grid'].includes(val)
          }
      },
      showTab: {
          type: Boolean,
          default: true
      },
      options: {
          type: Object,
          default() {
              return defaultOption
          }
      },
      play: Boolean,
      playDuration: {
          type: Number,
          default: 6000
      },
      transformDuration: {
          type: Number,
          default: 1000
      }
  },
  computed: {
    styles() {
        return {
        width: this.width,
        height: this.height
        }
    }
  },
  watch: {
      play(val) {
          if(val) {
              this.playCamera()
          } else if(!val && this.playTimeout) {
              clearTimeout(this.playTimeout)
          }
      },
      options: {
          deep: true,
          handler(val) {
               this.layoutOption = Object.assign({}, defaultOption, this.options)
          }
      }
  },
  data() {
    return {
    // 组件内部变量
      screenWidth: 400,
      screenHeight: 100,
      objects: [],
      targets: { table: [], sphere: [], helix: [], grid: [] },
      camera: null,
      scene: null,
      renderer: null,
      controls: null,
      elements: [],
      column: 1,
      gridIndex: 0,
      gridLayerCount: 0,
      playTimeout: null,
      loopTimeout: null,
      playIndex: 0,
      currLayout: '',
      tweenGroup: [],
      layoutOption: {},
      angle: 0
    }
  },
  methods: {
    init() {
      this.layoutOption = Object.assign({}, defaultOption, this.options)
      this.camera = new THREE.PerspectiveCamera(
        40,
        this.screenWidth / this.screenHeight,
        1,
        10000
      )
      this.camera.position.z =
        this.layoutOption.camera.deep !== undefined
          ? this.layoutOption.camera.deep
          : (this.itemHeight + this.itemSpace) * this.column + 500

      this.scene = new THREE.Scene()
        const vector = new THREE.Vector3()
      // table
      this.initTable(vector)
      // sphere
      this.initSphere(vector)
      // helix
      this.initHelix(vector)
      // grid
       const gridColumn = Math.ceil(Math.pow(this.objects.length, 1 / 3))
      this.initGrid(vector, gridColumn)

      this.renderer = new CSS3DRenderer()
      this.renderer.setSize(this.screenWidth, this.screenHeight)
      this.$refs.container.appendChild(this.renderer.domElement)
     const defaultMaxDistance = 1500 * Math.floor(this.objects.length / (gridColumn * gridColumn)) +
        (this.itemHeight + this.itemSpace) * this.column * 1.5 +
        500
      this.setControl(defaultMaxDistance)
      this.transform(this.defaultLayout, this.transformDuration)
      window.addEventListener('resize', this.onWindowResize, false)
      this.$el.addEventListener('click', this.handleLoop, false)
      if(this.play) {
          this.playCamera()
      }
        addResizeListener(this.$el, this.setScreenSize)
       this.setScreenSize()
    },
    setControl(defaultMaxDistance) {
        this.controls = new OrbitControls(
        this.camera,
        this.renderer.domElement
      )
     
      const _cameraOption = cloneDeep(this.layoutOption.camera || {})
      if(_cameraOption.minPolarAngle !== undefined) {
        _cameraOption.minPolarAngle = _cameraOption.minPolarAngle * Math.PI / 180
      }
      if(_cameraOption.maxPolarAngle !== undefined) {
        _cameraOption.maxPolarAngle = _cameraOption.maxPolarAngle * Math.PI / 180
      }
      if(_cameraOption.minAzimuthAngle !== undefined) {
        _cameraOption.minAzimuthAngle = _cameraOption.minAzimuthAngle * Math.PI / 180
      }
      if(_cameraOption.maxAzimuthAngle !== undefined) {
        _cameraOption.maxAzimuthAngle = _cameraOption.maxAzimuthAngle * Math.PI / 180
      }
      delete _cameraOption.deep
      const cameraOption = Object.assign({}, {
        minDistance: 100,
        maxDistance: defaultMaxDistance,
        minZoom: 0,
        maxZoom: Infinity,
        minPolarAngle: 0,
        maxPolarAngle: Math.PI,
        minAzimuthAngle: -Infinity,
        maxAzimuthAngle: Infinity,
        enableZoom: true,
        enableRotate: true,
        enablePan: true
      }, _cameraOption)
      for(const name in cameraOption) {
        this.controls[name] = cameraOption[name]
      }
      this.controls.addEventListener('change', this.listRender)
    },
    initTable(vector) {
         const tableCol =
        this.layoutOption.table.column !== undefined
          ? this.layoutOption.table.column
          : this.column
      const tableRow = Math.ceil(this.elements.length / tableCol)
      const tableWidth = (this.itemWidth + this.itemSpace) * tableCol
      const tableHeight = (this.itemHeight + this.itemSpace) * tableRow
      const tableX =
        this.layoutOption.table.y !== undefined ? this.layoutOption.table.x : 0
      const tableY =
        this.layoutOption.table.y !== undefined ? this.layoutOption.table.y : 0
      for (let i = 0; i < this.elements.length; i++) {
        const element = this.$el.querySelector(
          `.my-three-menu__item[data-index="${i}"]`
        )
        const objectCSS = new CSS3DObject(element)
        objectCSS.position.x = Math.random() * 4000 - 2000
        objectCSS.position.y = Math.random() * 4000 - 2000
        objectCSS.position.z = Math.random() * 4000 - 2000
        this.scene.add(objectCSS)
        this.objects.push(objectCSS)
        const object = new THREE.Object3D()
        const dx = Math.floor(i % tableCol)
        const dy = Math.floor(i / tableCol)
        object.position.x =
          dx * (this.itemWidth + this.itemSpace) -
          (tableWidth - this.itemWidth) / 2 +
          tableX
        object.position.y =
          -dy * (this.itemHeight + this.itemSpace) + tableHeight / 2 + tableY
        this.targets.table.push(object)
      }
    },
    initSphere(vector) {
         
      const sphereRadius =
        this.layoutOption.sphere.radius ||
        Math.sqrt(this.itemWidth * this.itemHeight * this.objects.length * 3 / (4 * Math.PI))
      for (let i = 0, l = this.objects.length; i < l; i++) {
        const phi = Math.acos(-1 + (2 * i) / l)
        const theta = Math.sqrt(l * Math.PI) * phi
        const object = new THREE.Object3D()

        object.position.setFromSphericalCoords(sphereRadius, phi, theta)
        vector.copy(object.position).multiplyScalar(2)

        object.lookAt(vector)
        const x = object.position.x + (this.layoutOption.sphere.x || 0)
        const y = object.position.y + (this.layoutOption.sphere.y || 0)
        const z = object.position.z
        object.position.set(x, y, z)
        this.targets.sphere.push(object)
      }
    },
    initHelix(vector) {
         const helixRadius =
        this.layoutOption.helix.radius || (this.itemWidth + this.itemSpace) * 4
      const length = 2 * Math.PI * helixRadius
      const count = length / (this.itemWidth + this.itemSpace)
      const offsetY =
        (Math.ceil(this.objects.length / count) *
          (this.itemHeight + this.itemSpace)) /
        2
      for (let i = 0, l = this.objects.length; i < l; i++) {
        let theta = i * 0.175 + Math.PI
        theta = (i * Math.PI * 2) / count + Math.PI
        const dy =
          this.layoutOption.helix.dy === undefined
            ? -((i * (this.itemHeight + this.itemSpace)) / count)
            : -i * this.layoutOption.helix.dy
        const y = dy + offsetY

        const object = new THREE.Object3D()

        object.position.setFromCylindricalCoords(helixRadius, theta, y)

        vector.x = object.position.x * 2
        vector.y = object.position.y
        vector.z = object.position.z * 2

        object.lookAt(vector)

        const tX = object.position.x + (this.layoutOption.helix.x || 0)
        const tY = object.position.y + (this.layoutOption.helix.y || 0)
        object.position.set(tX, tY, object.position.z)
        this.targets.helix.push(object)
      }

    },
    initGrid(vector, gridColumn) {
      const gridRow = this.layoutOption.grid.row || gridColumn
      const gridCol = this.layoutOption.grid.column || gridColumn
      this.gridLayerCount = Math.ceil(this.objects.length / (gridCol * gridRow))
      const gridOffsetXStatic =
        ((this.itemWidth + this.itemSpace) * (gridCol - 1)) / 2
      const gridOffsetYStatic =
        ((this.itemHeight + this.itemSpace) * (gridRow - 1)) / 2
      const gridOffsetX = -gridOffsetXStatic + (this.layoutOption.grid.x || 0)
      const gridOffsetY = gridOffsetYStatic + (this.layoutOption.grid.y || 0)
      const gridOffsetDeep =
        this.layoutOption.grid.deep !== undefined ? this.layoutOption.grid.deep : 800
      const layerSpace = this.layoutOption.grid.layerSpace || 800
      for (let i = 0; i < this.objects.length; i++) {
        const object = new THREE.Object3D()
        object.position.x = (i % gridCol) * (this.itemWidth + this.itemSpace) + gridOffsetX
        object.position.y = -(Math.floor(i / gridCol) % gridRow) * (this.itemHeight + this.itemSpace) + gridOffsetY
        object.position.z =
          -(Math.floor(i / (gridCol * gridRow))) * layerSpace +
          gridOffsetDeep
        this.targets.grid.push(object)
      }
    },
    handleLoop() {
        if(this.playTimeout) {
            clearTimeout(this.playTimeout)
        }
        if(this.loopTimeout) {
            clearTimeout(this.loopTimeout)
        }
        if(this.play) {
            this.loopTimeout = setTimeout(() => {
                this.playCamera()
            }, 10000)
        }
    },
    playCamera() {
       this.playTimeout = setInterval(() => {
           this.playIndex++
           this.playIndex = this.playIndex % 4
           const target = ['table', 'sphere', 'helix', 'grid'][this.playIndex]
           this.transform(target, this.transformDuration)
       }, this.playDuration) 
    },
    transLayout(name) {
        this.transform(name, this.transformDuration)
    },
    jumpPrev() {
         if (this.gridIndex !== 0) {
            this.gridIndex--
          }
          this.jumpLayer()
    },
    jumpNext() {
        if (this.gridIndex < this.gridLayerCount - 1) {
                    this.gridIndex++
          }
          this.jumpLayer()
    },
    jumpLayer() {
       const gridColumn = Math.ceil(Math.pow(this.objects.length, 1 / 3))
      const gridRow = this.layoutOption.grid.row || gridColumn
      const gridCol = this.layoutOption.grid.column || gridColumn
      this.gridLayerCount = Math.ceil(this.objects.length / (gridCol * gridRow))
      const gridOffsetXStatic =
        ((this.itemWidth + this.itemSpace) * (gridCol - 1)) / 2
      const gridOffsetYStatic =
        ((this.itemHeight + this.itemSpace) * (gridRow - 1)) / 2
      const gridOffsetX = -gridOffsetXStatic + (this.layoutOption.grid.x || 0)
      const gridOffsetY = gridOffsetYStatic + (this.layoutOption.grid.y || 0)
      const gridOffsetDeep =
        this.layoutOption.grid.deep !== undefined ? this.layoutOption.grid.deep : 800
      const layerSpace = this.layoutOption.grid.layerSpace || 800
      this.targets.grid = []
      for (let i = 0; i < this.objects.length; i++) {
        const object = new THREE.Object3D()
        const layerIndex = Math.floor(i / (gridCol * gridRow))
       object.position.x = (i % gridCol) * (this.itemWidth + this.itemSpace) + gridOffsetX
        if (layerIndex < this.gridIndex) {
          object.position.y = -10000
        } else {
          object.position.y =
            -(Math.floor(i / gridCol) % gridRow) *
              (this.itemHeight + this.itemSpace) +
            gridOffsetY
        }
        object.position.z =
          -(Math.floor(i / (gridCol * gridRow)) - this.gridIndex) *
            layerSpace +
          gridOffsetDeep
        this.targets.grid.push(object)
      }
      this.transform('grid', this.transformDuration)
    },
    transform(targetName, duration) {
        this.currLayout = targetName
        const targets = this.targets[targetName]
        this.removeAllTween()
      for (let i = 0; i < this.objects.length; i++) {
        const object = this.objects[i]
        const target = targets[i]

        const t1 = new TWEEN.Tween(object.position)
          .to(
            {
              x: target.position.x,
              y: target.position.y,
              z: target.position.z
            },
            Math.random() * duration + duration
          )
          .easing(TWEEN.Easing.Exponential.InOut)
          .start()

        const t2 = new TWEEN.Tween(object.rotation)
          .to(
            {
              x: target.rotation.x,
              y: target.rotation.y,
              z: target.rotation.z
            },
            Math.random() * duration + duration
          )
          .easing(TWEEN.Easing.Exponential.InOut)
          .start()
          this.tweenGroup.push(t1, t2)
      }

      const t3 = new TWEEN.Tween(this)
        .to({}, duration * 2)
        .onUpdate(this.listRender)
        .start()
        this.tweenGroup.push(t3)
    },
    removeAllTween() {
        if(this.tweenGroup.length !== 0) {
            for(let i = 0; i < this.tweenGroup.length; i++) {
              this.tweenGroup[i].stop()
            }
        }
        this.tweenGroup = []
    },
    onWindowResize() {
      this.camera.aspect = this.screenWidth / this.screenHeight
      this.camera.updateProjectionMatrix()
      this.renderer.setSize(this.screenWidth, this.screenHeight)
      this.listRender()
    },
    animate() {
        TWEEN.update()
        this.controls.update()
         this.renderer.render(this.scene, this.camera)
        requestAnimationFrame(this.animate)
    },
    listRender() {
        if(this.renderer) {
            this.renderer.render(this.scene, this.camera)
        }
    },
    setData() {
      const column = Math.ceil(Math.sqrt(this.list.length))
      this.column = column
      const elems = []
      for (let i = 0; i < this.list.length; i++) {
        const item = {
          ...this.list[i]
        }
        elems.push(item)
      }
      this.elements = elems
    },
    setScreenSize() {
        const rect = this.$el.getBoundingClientRect()
        this.screenWidth = rect.width
        this.screenHeight = rect.height
        if(this.renderer) {
            this.renderer.setSize(this.screenWidth, this.screenHeight)
        }
    },
    reset() {
        this.objects = []
        this.targets = { table: [], sphere: [], helix: [], grid: [] }
        this.camera = null
        this.scene = null
        this.renderer = null
        this.elements = []
        if(this.playTimeout) {
            clearTimeout(this.playTimeout)
        }
        if(this.loopTimeout) {
            clearTimeout(this.loopTimeout)
        }
        this.removeAllTween()
         this.$el.removeEventListener('click', this.handleLoop, false)
        removeResizeListener(this.$el, this.setScreenSize)
    }
  },
  created() {
    this.setData()
  },
  mounted() {
    this.init()
    this.animate()
  },
  beforeDestroy() {
      this.reset()
  }
}
</script>