three.js+vue智慧社群web3d數字孿生三維地圖

JackGIS發表於2024-11-02

案例效果截圖如下:

具體案例場景和功能,詳見b站影片:

https://www.bilibili.com/video/BV1Bb421E7WL/?vd_source=7d4ec9c9275b9c7d16afe9b4625f636c

案例場景邏輯程式碼:

<template>
  <div id="whole">
    <!-- threejs容器 -->
    <div id="three" ref="container"></div>

    <!-- 搜尋框 -->
    <div id="search" v-if="props.itemType === '房屋資料'">
      <a-input v-model:value="searchValue" placeholder="樓棟搜尋" id="searchFrame" style="width: 100%; height: 4vh" @input="searchChange" />
      <div id="searchContent" v-show="searchData.length > 0">
        <div v-for="(val, index) in searchData" :key="index" id="searchItem" @click="viewAngleZoomIn(val)">{{ val }}</div>
      </div>
    </div>

    <!-- 建築標記元素 -->
    <div id="buildMarker" ref="buildMarker" style="display: none">
      <div id="content">1幢</div>
    </div>

    <!-- 樓棟點選彈出框 -->
    <div id="popup" ref="popup" style="display: none">
      <div id="head">
        <div id="title">{{ popupTitle }}</div>
        <div id="close" @click="popupClose"></div>
      </div>
      <div id="content">
        <div class="common" @click="popupClick('1單元')">1單元</div>
        <div class="common" @click="popupClick('2單元')">2單元</div>
        <div class="common" @click="popupClick('3單元')">3單元</div>
        <div class="common" @click="popupClick('4單元')">4單元</div>
      </div>
    </div>
  </div>

  <!-- 樓棟單元資訊彈框  -->
  <infoPopFrame
    :building="popupTitle"
    :buildingUnit="buildingUnit"
    :visible="infoPopFrameVisible"
    :baseInfo="buildingBaseInfo"
    :floorData="floorData"
    @closePopFrame="infoPopFrameVisible = false"
  ></infoPopFrame>
</template>
<script lang="ts" setup>
import * as THREE from 'three';
import TWEEN from '@tweenjs/tween.js';
import { onMounted, ref, onUnmounted } from 'vue';
import { CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
import { initBaseConfig } from './components/threeBaseConfig';
import { loadingModel } from './components/modelImport.js';
import { disposeObject } from './components/disposeObject.js';
import infoPopFrame from './components/infoPopFrame.vue';

// 父元件傳值
const props = defineProps({
  // 專案型別:總覽/房屋資料
  itemType: {
    type: String,
  },
});

// threejs基礎配置
let scene, camera, renderer, controls, css2DRenderer;
// threejs畫布容器
const container = ref();
// 彈框標題
const popupTitle = ref('');
// 彈框元素
const popup = ref();
// 2D彈框
let cSS2DPopup;
// 建築標記元素
const buildMarker = ref();
// 搜尋框輸入值
const searchValue = ref('');
// 小區建築模型
let model = null;
// 搜尋框檢索到的資料
const searchData = ref([]);
// 建築名稱資料,用於匹配搜尋框的值searchValue
const buildNameData = [];
// 建築單元
const buildingUnit = ref('');
// 建築單元基本資訊
const buildingBaseInfo = ref([
  { name: '產權人', value: '於曉敏' },
  { name: '商鋪', value: 2 },
  { name: '自住房間', value: 3 },
  { name: '租住房間', value: 12 },
  { name: '常駐人口', value: 4 },
  { name: '流動人口', value: 8 },
]);
// 建築資訊彈框顯示
const infoPopFrameVisible = ref(false);
// 建築樓層資料
const floorData = ref([
  {
    name: '一樓',
    houseNumArr: [
      { num: '1-101', type: '商鋪' },
      { num: '1-102', type: '商鋪' },
      { num: '1-103', type: '商鋪' },
      { num: '1-104', type: '商鋪' },
    ],
  },
  {
    name: '二樓',
    houseNumArr: [
      { num: '2-101', type: '租住' },
      { num: '2-102', type: '租住' },
      { num: '2-103', type: '租住' },
      { num: '2-104', type: '租住' },
    ],
  },
  {
    name: '三樓',
    houseNumArr: [
      { num: '3-101', type: '租住' },
      { num: '3-102', type: '租住' },
      { num: '3-103', type: '租住' },
      { num: '3-104', type: '租住' },
    ],
  },
  {
    name: '四樓',
    houseNumArr: [
      { num: '4-101', type: '自住' },
      { num: '4-102', type: '自住' },
      { num: '4-103', type: '自住' },
      { num: '4-104', type: '自住' },
    ],
  },
  {
    name: '五樓',
    houseNumArr: [
      { num: '5-101', type: '自住' },
      { num: '5-102', type: '自住' },
      { num: '5-103', type: '自住' },
      { num: '5-104', type: '自住' },
    ],
  },
]);
// 元件解除安裝時清除場景scene中的所有內容,釋放資源
onUnmounted(() => {
  disposeObject(scene);
});

// 元件掛載完成,進行初始化
onMounted(async () => {
  // 初始化基礎配置:場景、相機、渲染器等
  const baseConfig = initBaseConfig();
  scene = baseConfig.scene;
  camera = baseConfig.camera;
  renderer = baseConfig.renderer;
  controls = baseConfig.controls;
  css2DRenderer = baseConfig.css2DRenderer;

  // 渲染器dom掛在threejs容器中
  container.value.appendChild(renderer.domElement);
  container.value.appendChild(css2DRenderer.domElement);

  // 載入3D模型
  model = await loadingModel();
  scene.add(model);

  // 初始化css2D彈框
  initPopup();
  // 新增滑鼠移動事件
  addMouseMoveEvent();
  // 新增滑鼠點選事件
  addMouseClickEvent();
  // 新增建築的標記
  addBuildMarker();

  if (props.itemType === '房屋資料') {
    // 獲取建築名稱資料,用以搜尋框檢索
    model.getObjectByName('建築').traverse((item) => {
      if (item.isMesh && item.name && item.name.includes('幢')) {
        if (buildNameData.includes(item.name)) return;
        buildNameData.push(item.name);
      }
    });
  }

  // 開始迴圈渲染
  render();
});

// 迴圈渲染
const render = () => {
  requestAnimationFrame(render);
  TWEEN.update();
  controls.update();
  css2DRenderer.render(scene, camera);
  renderer.render(scene, camera);
};

// 射線檢測
const rayTest = (e) => {
  const px = e.offsetX;
  const py = e.offsetY;
  // 螢幕座標轉為標準裝置座標
  const x = (px / window.innerWidth) * 2 - 1;
  const y = -(py / (window.innerHeight - 36 - 56)) * 2 + 1;
  // 建立射線
  const raycaster = new THREE.Raycaster();
  // 設定射線引數
  raycaster.setFromCamera(new THREE.Vector2(x, y), camera);
  // 射線交叉計算拾取模型
  let intersects = raycaster.intersectObjects(model.getObjectByName('建築').children);
  return intersects;
};

// 滑鼠移動事件,釋放射線進行檢測建築模型,改變檢測到的建築模型的顏色
const moveEvent = (e) => {
  const intersects = rayTest(e);
  // 所有建築模型發射光emissive重置黑色
  model.getObjectByName('建築').traverse((item) => {
    if (item.isMesh) {
      item.material.emissive = new THREE.Color('#000');
    }
  });
  // 檢測結果存在時
  if (intersects[0]) {
    // 改變滑鼠樣式為手指
    document.body.style.cursor = 'pointer';
    // 當前檢測建築模型
    const currentBuildModel = intersects[0].object;

    // 定義材質顏色
    currentBuildModel.material.emissive = new THREE.Color('#00BFFF');
  } else {
    // 恢復預設滑鼠樣式
    document.body.style.cursor = 'default';
  }
};

// 滑鼠點選事件,釋放射線進行檢測建築模型
const clickEvent = (e) => {
  const intersects = rayTest(e);
  // 檢測結果存在時
  if (intersects[0]) {
    // 過濾掉其他建築
    if (intersects[0].object.name.includes('其他')) return;

    if (intersects[0].object.name.includes('配電')) {
      popupTitle.value = '配電';
    } else {
      popupTitle.value = intersects[0].object.name;
    }

    model.getObjectByName('建築').traverse((item) => {
      if (item.isMesh) {
        item.material.color = item.color;
      }
    });
    intersects[0].object.material.color = new THREE.Color('#00C5CD');

    cSS2DPopup.visible = true;
    controls.update();
    cSS2DPopup.position.copy(controls.target);
  }
};

// 初始css2D彈框,將彈框元素轉換成threejs中的css2D物件
function initPopup() {
  popup.value.style.display = 'block';
  cSS2DPopup = new CSS2DObject(popup.value);
  cSS2DPopup.renderOrder = 99;
  cSS2DPopup.visible = false;
  cSS2DPopup.position.set(0, 0, 0);
  scene.add(cSS2DPopup);
}

// 新增滑鼠移動事件
function addMouseMoveEvent() {
  // 節流函式
  const throttleChange = throttle(moveEvent, 10);
  // 監聽滑鼠移動事件
  container.value.addEventListener('mousemove', (e) => {
    throttleChange(e);
  });
}

// 節流函式,滑鼠移動事件觸發太過頻繁需要節制觸發次數
function throttle(func, limit) {
  let inThrottle;
  return function () {
    const args = arguments;
    const context = this;
    if (!inThrottle) {
      func.apply(context, args);
      inThrottle = true;
      setTimeout(() => (inThrottle = false), limit);
    }
  };
}

// 新增滑鼠點選事件
function addMouseClickEvent() {
  // 監聽滑鼠點選事件
  container.value.addEventListener('click', (e) => {
    clickEvent(e);
  });
}

// 視角拉近
function viewAngleZoomIn(val) {
  cSS2DPopup.visible = false;
  // 當前目標建築模型
  const target = model.getObjectByName(val);
  // 重置所有建築模型顏色
  model.getObjectByName('建築').traverse((item) => {
    if (item.isMesh) {
      item.material.color = item.color;
    }
  });
  // 設定建築模型顏色
  target.material.color = new THREE.Color('#00C5CD');

  // 目標位置
  const targetPos = target.getWorldPosition(new THREE.Vector3());
  // 移動位置
  const movePos = targetPos.clone();
  movePos.y += 80;
  movePos.z += 55;
  // 開始位置
  const startPos = camera.position.clone();
  // 初始的控制元件目標
  const initialTarget = controls.target.clone();

  new TWEEN.Tween({ t: 0 })
    .to({ t: 1 }, 1500)
    .easing(TWEEN.Easing.Sinusoidal.InOut)
    .onUpdate(function (e) {
      const t = e.t;
      camera.position.lerpVectors(startPos, movePos, t);
      controls.target.lerpVectors(initialTarget, targetPos, t);
      camera.updateProjectionMatrix();
      controls.update();
    })
    .onComplete(function () {
      cSS2DPopup.visible = true;
      popupTitle.value = val;
      controls.update();
      cSS2DPopup.position.copy(controls.target);
    })
    .start();
}

// 新增建築標記
function addBuildMarker() {
  model.getObjectByName('建築').traverse((item) => {
    if (item.name.includes('其他')) return;
    if (item.isMesh) {
      let closeDom;

      if (item.name.includes('配電')) {
        closeDom = buildMarker.value.cloneNode(true);
        closeDom.style.width = '2vw';
        closeDom.children[0].innerHTML = '配電';
      } else {
        closeDom = buildMarker.value.cloneNode(true);
        closeDom.style.width = `${item.name.length * 0.7}vw`;
        closeDom.children[0].innerHTML = item.name;
      }

      const cSS2DObject = new CSS2DObject(closeDom);
      const pos = item.getWorldPosition(new THREE.Vector3());
      cSS2DObject.position.copy(pos);
      cSS2DObject.position.y += 5;
      cSS2DObject.name = item.name + '標記';
      scene.add(cSS2DObject);
    }
  });
}

// 搜尋框內容變化事件,模糊匹配建築名稱資料
function searchChange(e) {
  if (!e) {
    searchData.value = [];
    return;
  }
  // 匹配結果
  const rel = buildNameData.filter((item) => item.includes(e));
  // 對匹配結果進行排序
  rel.sort((a, b) => {
    // 提取數值
    const getNumber = (str) => parseInt(str.match(/\d+/)[0]);
    // 檢測名稱中是否帶有別墅
    const isVilla = (str) => str.includes('別墅');

    if (isVilla(a) && isVilla(b)) {
      return getNumber(a) - getNumber(b);
    } else if (isVilla(a)) {
      return 1;
    } else if (isVilla(b)) {
      return -1;
    } else {
      return getNumber(a) - getNumber(b);
    }
  });

  searchData.value = rel;
}

// 彈框關閉事件
function popupClose() {
  cSS2DPopup.visible = false;
  model.getObjectByName('建築').traverse((item) => {
    if (item.isMesh) {
      item.material.color = item.color;
    }
  });
}

// 彈框點選事件
function popupClick(e) {
  buildingUnit.value = e;
  infoPopFrameVisible.value = true;
}
</script>

<style lang="less" scoped>
body {
  font-size: 0.7vw;
}
::v-deep .arco-card-body {
  padding: 0px !important;
  width: 100%;
  height: 100%;
}

::v-deep .arco-input-wrapper {
  border-radius: 10px;
  background: #000;
  border: 1px solid #009acd;
  color: #fafafa;
}

::v-deep .arco-input-wrapper .arco-input.arco-input-size-medium {
  font-size: 0.7vw !important;
}

/* 當視口寬度小於 1400 畫素時,設定最小字型大小 */
@media (max-width: 1400px) {
  #buildMarker {
    font-size: 12px !important;
    width: 55px !important;
    height: 20px !important;
  }
}
#whole {
  width: 100%;
  height: calc(100% - 36px - 56px);

  #three {
    width: 100%;
    height: 100%;
  }

  #search {
    z-index: 999;
    position: absolute;
    width: 22vw;
    right: 1%;
    top: 3%;

    #searchContent {
      border-radius: 4px;
      margin-top: 4px;
      width: 100%;
      max-height: 300px;
      border: 1px solid #0e2346;
      background: rgba(0, 0, 0, 0.7);

      #searchItem {
        text-indent: 1em;
        line-height: 2.5vh;
        font-size: 0.7vw;
        width: 100%;
        height: 2.5vh;
        color: #eee;
        border-bottom: 1px solid #12485a;
      }

      #searchItem:hover {
        cursor: pointer;
        background: #0e2346;
      }
    }

    #searchContent {
      overflow-y: auto;
    }
    #searchContent::-webkit-scrollbar {
      width: 4px;
    }
    #searchContent::-webkit-scrollbar-thumb {
      border-radius: 10px;
      background: rgba(30, 150, 200, 0.7);
    }
    #searchContent::-webkit-scrollbar-track {
      border-radius: 0;
      background: rgba(0, 0, 0, 0.1);
    }
  }

  #buildMarker {
    z-index: 997;
    position: absolute;
    top: 0;
    left: 0;
    font-size: 0.6vw;
    height: 2.5vh;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;

    #content {
      width: 100%;
      height: 100%;
      background: #0e2346;
      border: 1px solid #6298a9;
      display: flex;
      align-items: center;
      justify-content: center;
      color: #fafafa;

      #mapTag_value {
        color: #ffd700;
      }
    }
  }

  #popup {
    z-index: 999;
    position: absolute;
    top: 0;
    left: 0;
    width: 20vw;
    height: 15vh;
    background: rgba(15, 41, 77, 0.85);
    border-radius: 0.3vw;
    border: 1px solid rgba(10, 109, 155, 0.95);

    #head {
      width: 95%;
      margin-left: 2.5%;
      height: 30%;
      border-bottom: 1px solid #009acd;
      display: flex;
      align-items: center;
      justify-content: space-between;

      #title {
        font-size: 0.85vw;
        color: #bbffff;
        margin-left: 2.5%;
      }

      #close {
        pointer-events: all;
        width: 1vw;
        height: 1vw;
        background: url('../../assets/close.png') no-repeat;
        background-size: 100% 100%;
      }

      #close:hover {
        cursor: pointer;
      }
    }

    #content {
      width: 95%;
      margin-left: 2.5%;
      height: 70%;
      display: flex;
      justify-content: space-evenly;
      align-items: center;

      .common {
        font-size: 0.7vw;
        display: flex;
        justify-content: center;
        align-items: center;
        pointer-events: all;
        width: 18%;
        height: 3vh;
        border: 1px solid #1f81a1;
        color: #fafafa;
      }

      .common:hover {
        cursor: pointer;
        color: #bbffff;
        border: 1px solid #03c0ff;
      }
    }
  }
}
</style>

相關文章