three.js+vue智慧社区web3d数字孪生三维地图

案例效果截图如下:

具体案例场景和功能,详见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>

 

posted @ 2024-11-02 19:10  JackGIS  阅读(46)  评论(0编辑  收藏  举报