夜雨聆风学习资料网

ARTICLE · 1355084

港口调度监控中心 (可复制源码) three.js + vue3 + echarts.js

港口调度监控中心 (可复制源码) three.js + vue3 + echarts.js

少年易老学难成,一寸光阴不可轻。

项目地址:git clone https://gitee.com/tb-inslow/port-dispatch-screen.git  直接复制项目安装运行即可

项目信息

node版本: '>=22.18.0 || >=24.11.0'

项目使用到的依赖

"echarts": "^5.5.0",
"three": "^0.166.0",
"vue": "^3.4.0"

运行项目

# 使用 npm install 命令安装依赖
npm install

# 或使用 npm 运行
npm run dev

大屏端部分代码

<template>
<divclass="port-scene">
<divref="container"class="scene-canvas"></div>
<divclass="scene-vignette"></div>
<divclass="scene-tag"><i></i>东区集装箱码头 · 实时作业</div>
<divclass="scene-legend">
<span><iclass="dot orange"></i>重箱</span>
<span><iclass="dot white"></i>空箱</span>
<spanclass="coords num">31°14' N · 121°30' E</span>
</div>
<!-- 预设视角切换 -->
<divclass="camera-preset" @click="flyTo('top')">俯视</div>
<divclass="camera-preset" @click="flyTo('scene')">作业</div>
<divclass="camera-preset" @click="flyTo('ship')">船舶</div>
<!-- 点击信息卡 -->
<div
v-if="tooltip.visible"
class="obj-tooltip"
:style="{ left: tooltip.x + 'px', top: tooltip.y + 'px' }"
    >

<b>{{ tooltip.title }}</b>
<span>{{ tooltip.sub }}</span>
<small>{{ tooltip.extra }}</small>
</div>
</div>
</template>

<scriptsetup>
import { ref, onMounted, onUnmounted } from'vue'
import * as THREE from'three'
import { OrbitControls } from'three/examples/jsm/controls/OrbitControls.js'
import { ribTexture } from'../utils/portModels'
import { loadTruckTemplate, cloneTruck } from'../utils/truckAsset'

const container = ref(null)
let renderer, scene, camera, controls, rafId, observer, clock
const trucks = []
const trolleys = []
const lamps = []
let dust

// 扩展:动画对象 + 互动
const spreaders = []   // { spreader, hangBox, phase, cycle }
let shipGroup = null
const waterRipples = [] // 海面涟漪数组

const raycaster = new THREE.Raycaster()
const mouseNdc = new THREE.Vector2()
const tooltip = ref({ visible: false, x: 0, y: 0, title: '', sub: '', extra: '' })

// 预设视角
const presetCameras = {
top:   { pos: [0, 82, 0.01],  target: [0, 0.5, -4] },
scene: { pos: [39, 32, 39],   target: [0, 0.8, -4] },
ship:  { pos: [18, 8, -52],   target: [0, 1.6, -31] }
}
let flyAnim = null// { startPos, endPos, startTgt, endTgt, elapsed, duration }

const ORANGE = 0xff6a10
const PALETTE = [0xff6a10, 0xe9eae5, 0xd4d7d2, 0xff7c22, 0xe9eae5, 0xc2c5c0, 0xb84e0a, 0xf0f1ed]

onMounted(() => {
const el = container.value
const w = el.clientWidth || 900
const h = el.clientHeight || 600

  scene = new THREE.Scene()
  scene.fog = new THREE.Fog(0x0a0c0e, 95, 190)

  camera = new THREE.PerspectiveCamera(34, w / h, 0.1, 400)
  camera.position.set(39, 32, 39)

  renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
  renderer.setSize(w, h)
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
  renderer.shadowMap.enabled = true
  renderer.shadowMap.type = THREE.PCFSoftShadowMap
  renderer.outputColorSpace = THREE.SRGBColorSpace
  renderer.toneMapping = THREE.ACESFilmicToneMapping
  renderer.toneMappingExposure = 1.05
  el.appendChild(renderer.domElement)

  controls = new OrbitControls(camera, renderer.domElement)
  controls.target.set(0, 0.8, -4)
  controls.enableDamping = true
  controls.dampingFactor = 0.06
  controls.autoRotate = false
  controls.enablePan = false
  controls.minPolarAngle = 0.55
  controls.maxPolarAngle = 1.28
  controls.minDistance = 34
  controls.maxDistance = 84

// ===== 纹理与材质 =====
functionasphaltTexture() {
const s = 256
const cv = document.createElement('canvas')
    cv.width = cv.height = s
const ctx = cv.getContext('2d')
    ctx.fillStyle = '#15181b'
    ctx.fillRect(0, 0, s, s)
for (let i = 0; i < 5200; i++) {
const v = 18 + Math.random() * 26
      ctx.fillStyle = `rgba(${v},${v + 2},${v + 4},${0.3 + Math.random() * 0.4})`
      ctx.fillRect(Math.random() * s, Math.random() * s, 1.6, 1.6)
    }
    ctx.strokeStyle = 'rgba(8, 10, 12, 0.55)'
    ctx.lineWidth = 1
for (let i = 0; i < 10; i++) {
      ctx.beginPath()
let x = Math.random() * s
let y = Math.random() * s
      ctx.moveTo(x, y)
for (let k = 0; k < 4; k++) {
        x += (Math.random() - 0.5) * 40
        y += (Math.random() - 0.5) * 40
        ctx.lineTo(x, y)
      }
      ctx.stroke()
    }
const t = new THREE.CanvasTexture(cv)
    t.colorSpace = THREE.SRGBColorSpace
    t.wrapS = t.wrapT = THREE.RepeatWrapping
    t.repeat.set(12, 7)
return t
  }

const contGeo = new THREE.BoxGeometry(2.5, 1.12, 1.08)
const contMats = newMap()
functioncontainerMat(color) {
let m = contMats.get(color)
if (!m) {
      m = new THREE.MeshStandardMaterial({ map: ribTexture(), color, roughness: 0.62, metalness: 0.22 })
      contMats.set(color, m)
    }
return m
  }

const steel = new THREE.MeshStandardMaterial({ color: 0x24282d, roughness: 0.5, metalness: 0.6 })
const accentMat = new THREE.MeshStandardMaterial({
color: ORANGE,
roughness: 0.45,
metalness: 0.3,
emissive: 0x461a00,
emissiveIntensity: 0.3
  })

// ===== 灯光 =====
  scene.add(new THREE.HemisphereLight(0xdfe8f2, 0x0a0c0e, 0.8))

const key = new THREE.DirectionalLight(0xfff1e0, 2.8)
  key.position.set(30, 48, 28)
  key.castShadow = true
  key.shadow.mapSize.set(2048, 2048)
  key.shadow.camera.left = -52
  key.shadow.camera.right = 52
  key.shadow.camera.top = 52
  key.shadow.camera.bottom = -52
  key.shadow.camera.near = 1
  key.shadow.camera.far = 150
  key.shadow.bias = -0.0004
  scene.add(key)

const rim = new THREE.DirectionalLight(0xff7a2a, 0.45)
  rim.position.set(-34, 16, -26)
  scene.add(rim)

const cool = new THREE.DirectionalLight(0x7fb4ff, 0.5)
  cool.position.set(-14, 22, 34)
  scene.add(cool)

// ===== 地面 / 水域 / 岸线 =====
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(96, 54),
new THREE.MeshStandardMaterial({ map: asphaltTexture(), roughness: 0.92, metalness: 0.08 })
  )
  ground.rotation.x = -Math.PI / 2
  ground.position.z = 2
  ground.receiveShadow = true
  scene.add(ground)

const water = new THREE.Mesh(
new THREE.PlaneGeometry(150, 44),
new THREE.MeshStandardMaterial({ color: 0x1a2128, roughness: 0.62, metalness: 0.4 })
  )
  water.rotation.x = -Math.PI / 2
  water.position.set(0, -0.18, -42)
  scene.add(water)

const quay = new THREE.Mesh(new THREE.BoxGeometry(96, 1.1, 1.4), new THREE.MeshStandardMaterial({ color: 0x2a2f34, roughness: 0.8, metalness: 0.2 }))
  quay.position.set(0, 0.25, -21.9)
  quay.receiveShadow = true
  quay.castShadow = true
  scene.add(quay)

const quayLine = new THREE.Mesh(new THREE.BoxGeometry(96, 0.06, 0.12), new THREE.MeshBasicMaterial({ color: ORANGE }))
  quayLine.position.set(0, 0.62, -21.2)
  scene.add(quayLine)

// ===== 靠泊货轮 =====
  shipGroup = new THREE.Group()
  shipGroup.userData.__info = { type: '船舶', title: '远洋货轮 · MV COSCO STAR', sub: '载重 4.2 万吨', extra: '靠泊 · 125m 长' }
const hullMat = new THREE.MeshStandardMaterial({ color: 0x191e23, roughness: 0.8, metalness: 0.3 })
const hull = new THREE.Mesh(new THREE.BoxGeometry(22, 2.2, 5.4), hullMat)
  hull.position.y = 0.75
  hull.castShadow = true
  hull.userData.__info = { type: '船体', title: '船体', sub: '22 × 2.2 × 5.4 m', extra: '钢质 · 吃水 8.4m' }
  shipGroup.add(hull)

// 舱面集装箱
for (let i = 0; i < 9; i++) {
for (let row = 0; row < 2; row++) {
const tiers = 1 + ((i + row) % 2)
for (let k = 0; k < tiers; k++) {
const box = new THREE.Mesh(new THREE.BoxGeometry(1.9, 0.8, 1.5), containerMat(PALETTE[(i + row + k) % PALETTE.length]))
        box.position.set(-8 + i * 2, 2.3 + k * 0.82, row === 0 ? -1.2 : 1.2)
        box.castShadow = true
        box.userData.__info = { type: '船上集装箱', title: `船上箱 · ${PALETTE[(i + row + k) % PALETTE.length].toString(16).toUpperCase()}`, sub: `${i + 1}-${row + 1}-${k + 1}`, extra: '20\'GP' }
        shipGroup.add(box)
      }
    }
  }

// 上层建筑
const bridge = new THREE.Mesh(new THREE.BoxGeometry(3.2, 3.6, 4.6), new THREE.MeshStandardMaterial({ color: 0xdfe2dd, roughness: 0.6 }))
  bridge.position.set(9, 3.1, 0)
  bridge.castShadow = true
  bridge.userData.__info = { type: '驾驶台', title: '驾驶台', sub: '3.2 × 3.6 × 4.6 m', extra: '导航 · 指挥' }
  shipGroup.add(bridge)
const bridgeGlass = new THREE.Mesh(new THREE.BoxGeometry(3.3, 0.7, 4.7), new THREE.MeshStandardMaterial({ color: 0x0e1a24, roughness: 0.15, metalness: 0.75 }))
  bridgeGlass.position.set(9, 4.3, 0)
  shipGroup.add(bridgeGlass)

  shipGroup.position.set(0, 0, -31)
  scene.add(shipGroup)

// ===== 集装箱堆场 =====
functionaddStack(x, z, color, tiers, stackKey) {
const grp = new THREE.Group()
for (let i = 0; i < tiers; i++) {
const m = new THREE.Mesh(contGeo, containerMat(color))
      m.position.y = 0.57 + i * 1.15
      m.castShadow = true
      m.receiveShadow = true
      m.userData.__info = {
type: '堆场集装箱',
title: `堆场箱 · ${stackKey}-T${i + 1}`,
sub: `#${color.toString(16).toUpperCase()}`,
extra: `${i + 1}/${tiers} 层`
      }
      grp.add(m)
    }
    grp.position.set(x, 0, z)
    scene.add(grp)
  }

const blocks = [
    { x: -28.2, z: -15.2, cols: 5, rows: 4 },
    { x: -2.4, z: -16.2, cols: 6, rows: 3 },
    { x: 15.6, z: -15.2, cols: 4, rows: 3 },
    { x: -27.4, z: 4.6, cols: 5, rows: 3 },
    { x: -7.6, z: 7.4, cols: 4, rows: 2 },
    { x: 8, z: 5.4, cols: 3, rows: 2 },
    { x: 22, z: -4.6, cols: 3, rows: 2 },
    { x: -30, z: 16.4, cols: 4, rows: 2 },
    { x: 2, z: 17.6, cols: 4, rows: 2 }
  ]

  blocks.forEach((b, bi) => {
for (let c = 0; c < b.cols; c++) {
for (let r = 0; r < b.rows; r++) {
const x = b.x + c * 2.78
const z = b.z + r * 1.34
const tiers = 2 + ((c * 3 + r * 2 + bi) % 3)
const color = PALETTE[(c + r * 2 + bi * 3) % PALETTE.length]
const stackKey = `${String(bi + 1).padStart(2, '0')}${String(c + 1).padStart(2, '0')}${String(r + 1).padStart(2, '0')}`
        addStack(x, z, color, tiers, stackKey)
      }
    }
  })

// ===== 海面涟漪(靠泊船艉/舷侧随机散落) =====
  ;[-24, -18, -12, -6, 0, 6, 12, 18].forEach((x, i) => {
for (let k = 0; k < 2; k++) {
const ring = new THREE.Mesh(
new THREE.RingGeometry(0.3, 0.42, 28),
new THREE.MeshBasicMaterial({ color: 0x6ff5ff, transparent: true, opacity: 0.35, side: THREE.DoubleSide, depthWrite: false })
      )
      ring.rotation.x = -Math.PI / 2
      ring.position.set(x + (Math.random() - 0.5) * 6, -0.17, -30 + (Math.random() - 0.5) * 12)
      scene.add(ring)
      waterRipples.push({ mesh: ring, phase: Math.random(), maxR: 1.6 + Math.random() * 0.8 })
    }
  })

// ===== 路面标线 =====
functiondashedLine(z, color, opacity = 0.5, dash = 0.9, gap = 1.2) {
const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity })
const geo = new THREE.PlaneGeometry(dash, 0.12)
for (let x = -33; x < 33; x += dash + gap) {
const d = new THREE.Mesh(geo, mat)
      d.rotation.x = -Math.PI / 2
      d.position.set(x + dash / 2, 0.02, z)
      scene.add(d)
    }
  }

  dashedLine(-7.4, 0xdfe3e6, 0.42)
  dashedLine(12.2, 0xdfe3e6, 0.42)
  dashedLine(0.4, ORANGE, 0.5, 1.1, 1.4)

// 堆场橙色作业区标线
const zoneMat = new THREE.MeshBasicMaterial({ color: ORANGE, transparent: true, opacity: 0.4 })
  ;[-9.4, -17.6, 9.6].forEach((z) => {
const line = new THREE.Mesh(new THREE.PlaneGeometry(50, 0.08), zoneMat)
    line.rotation.x = -Math.PI / 2
    line.position.set(-4, 0.021, z)
    scene.add(line)
  })

// ===== 轨道式龙门吊 =====
functionmakeGantry() {
const g = new THREE.Group()
const zA = -18
const zB = -9.6
const span = zB - zA
const H = 7.4

const legGeo = new THREE.BoxGeometry(0.44, H, 0.44)
    ;[
      [-1.15, zA],
      [1.15, zA],
      [-1.15, zB],
      [1.15, zB]
    ].forEach(([x, z]) => {
const leg = new THREE.Mesh(legGeo, steel)
      leg.position.set(x, H / 2, z)
      leg.castShadow = true
      g.add(leg)
const foot = new THREE.Mesh(new THREE.BoxGeometry(0.78, 0.22, 0.78), accentMat)
      foot.position.set(x, 0.11, z)
      g.add(foot)
    })

const girderGeo = new THREE.BoxGeometry(0.5, 0.72, span + 0.8)
    ;[-1.15, 1.15].forEach((x) => {
const girder = new THREE.Mesh(girderGeo, steel)
      girder.position.set(x, H + 0.36, (zA + zB) / 2)
      girder.castShadow = true
      g.add(girder)

const stripe = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.08, span + 0.8), accentMat)
      stripe.position.set(x, H + 0.74, (zA + zB) / 2)
      g.add(stripe)
    })

    ;[zA, zB].forEach((z) => {
const beam = new THREE.Mesh(new THREE.BoxGeometry(2.9, 0.55, 0.44), steel)
      beam.position.set(0, H + 0.28, z)
      beam.castShadow = true
      g.add(beam)

const brace = new THREE.Mesh(new THREE.BoxGeometry(2.5, 0.18, 0.18), steel)
      brace.position.set(0, H * 0.55, z)
      g.add(brace)
    })

// 顶部机房
const house = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.9, 1.5), steel)
    house.position.set(0, H + 0.9, zA + 1.4)
    house.castShadow = true
    g.add(house)
const houseCap = new THREE.Mesh(new THREE.BoxGeometry(1.15, 0.1, 1.55), accentMat)
    houseCap.position.set(0, H + 1.38, zA + 1.4)
    g.add(houseCap)

// 小车 + 吊具(吊具放入独立 group,便于整体上下抓取)
const trolley = new THREE.Group()
const head = new THREE.Mesh(new THREE.BoxGeometry(1.7, 0.35, 1.0), steel)
    head.castShadow = true
    trolley.add(head)

const cableGeo = new THREE.BoxGeometry(0.04, 1.9, 0.04)
const cableMat = new THREE.MeshBasicMaterial({ color: 0x9aa3ac })
    ;[-0.55, 0.55].forEach((dz) => {
const cable = new THREE.Mesh(cableGeo, cableMat)
      cable.position.set(0, -1.1, dz)
      trolley.add(cable)
    })

const spreaderGroup = new THREE.Group()
    spreaderGroup.position.y = -2.1
const spreader = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.14, 1.1), accentMat)
    spreader.castShadow = true
    spreader.userData.__info = { type: '吊具', title: '龙门吊 · 吊具', sub: '1.5 × 0.14 × 1.1 m', extra: '额定 40t' }
    spreaderGroup.add(spreader)

const hangBox = new THREE.Mesh(new THREE.BoxGeometry(1.3, 0.62, 0.95), containerMat(ORANGE))
    hangBox.position.y = -0.4
    hangBox.castShadow = true
    hangBox.userData.__info = { type: '吊运中集装箱', title: '吊运中 · 重箱', sub: '20\'GP · 橙色', extra: '从堆场 → 集卡' }
    spreaderGroup.add(hangBox)

    trolley.add(spreaderGroup)

    trolley.position.set(0, H - 0.05, (zA + zB) / 2)
    g.add(trolley)

const lamp = new THREE.Mesh(new THREE.SphereGeometry(0.13, 12, 12), new THREE.MeshBasicMaterial({ color: 0xff8c42 }))
    lamp.position.set(1.15, H + 0.82, zB)
    g.add(lamp)

    trolleys.push({ trolley, spreaderGroup, zA, zB, phase: Math.random() * Math.PI * 2 })
    lamps.push(lamp)
return g
  }

  ;[-22, -4, 20].forEach((x) => {
const g = makeGantry()
    g.position.x = x
    scene.add(g)
  })

// ===== 集卡(glTF 资产) =====
const truckDefs = [
    { z: -7.4, dir: 1, speed: 1.7, x: -24 },
    { z: -7.4, dir: 1, speed: 1.3, x: 4 },
    { z: 0.4, dir: -1, speed: 1.5, x: 18 },
    { z: 12.2, dir: 1, speed: 1.2, x: -10 },
    { z: 12.2, dir: 1, speed: 1.5, x: 12 }
  ]

  loadTruckTemplate()
    .then(() => {
      truckDefs.forEach((d, i) => {
const holder = new THREE.Group()
        holder.add(cloneTruck())
        holder.position.set(d.x, 0, d.z)
if (d.dir < 0) holder.rotation.y = Math.PI
        holder.userData.__info = {
type: '集卡',
title: `AGV 集卡 ${String(i + 1).padStart(2, '0')}`,
sub: `速度 ${d.speed.toFixed(1)} m/s · 方向 ${d.dir > 0 ? '→' : '←'}`,
extra: '20\'GP · 运行中'
        }
        scene.add(holder)
        trucks.push({ obj: holder, ...d })
      })
    })
    .catch((err) =>console.error('[truck] glTF 加载失败', err))

// ===== 高杆灯 =====
const poleMat = new THREE.MeshStandardMaterial({ color: 0x33383d, roughness: 0.6, metalness: 0.6 })
  ;[
    [-32, 8],
    [28, 8],
    [-32, -18],
    [30, -8]
  ].forEach(([x, z]) => {
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.11, 9.6, 8), poleMat)
    pole.position.set(x, 4.8, z)
    pole.castShadow = true
    scene.add(pole)

const arm = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.08, 0.08), poleMat)
    arm.position.set(x - 0.7, 9.55, z)
    scene.add(arm)

const bulb = new THREE.Mesh(new THREE.SphereGeometry(0.16, 10, 10), new THREE.MeshBasicMaterial({ color: 0xffb066 }))
    bulb.position.set(x - 1.45, 9.45, z)
    scene.add(bulb)

const glow = new THREE.PointLight(0xff8a3d, 5, 16, 2)
    glow.position.set(x - 1.4, 9.2, z)
    scene.add(glow)
  })

// ===== 空气尘埃 =====
const dustCount = 220
const dustPos = newFloat32Array(dustCount * 3)
for (let i = 0; i < dustCount; i++) {
    dustPos[i * 3] = (Math.random() - 0.5) * 88
    dustPos[i * 3 + 1] = 0.4 + Math.random() * 15
    dustPos[i * 3 + 2] = -22 + Math.random() * 44
  }
const dustGeo = new THREE.BufferGeometry()
  dustGeo.setAttribute('position', new THREE.BufferAttribute(dustPos, 3))
  dust = new THREE.Points(
    dustGeo,
new THREE.PointsMaterial({ color: 0xffd0a8, size: 0.14, transparent: true, opacity: 0.4, depthWrite: false, sizeAttenuation: true })
  )
  scene.add(dust)

// ===== 动画 =====
  clock = new THREE.Clock()

const animate = () => {
    rafId = requestAnimationFrame(animate)
const dt = Math.min(clock.getDelta(), 0.05)
const t = clock.elapsedTime

// 集卡循环
    trucks.forEach((tr) => {
      tr.obj.position.x += tr.dir * tr.speed * dt
if (tr.obj.position.x > 34) tr.obj.position.x = -34
if (tr.obj.position.x < -34) tr.obj.position.x = 34
    })

// 龙门吊小车水平往复 + 吊具上下抓取(两相位错开,模拟抓放箱)
    trolleys.forEach((c, i) => {
const k = (Math.sin(t * 0.32 + i * 1.9) + 1) / 2
      c.trolley.position.z = c.zA + 1.2 + k * (c.zB - c.zA - 2.4)
// 吊具:在 z 行程两端(k≈0 或 k≈1)时下伸,中间抬起
const pickup = Math.sin(t * 0.6 + c.phase)
      c.spreaderGroup.position.y = -2.1 + pickup * 0.55
// 吊具 + 悬挂箱轻微摆动(模拟缆绳晃动)
      c.spreaderGroup.rotation.z = Math.sin(t * 3 + c.phase) * 0.06
    })

    lamps.forEach((l, i) => l.scale.setScalar(1 + Math.sin(t * 3 + i * 1.3) * 0.35))

// 货轮微幅前后+侧摆
if (shipGroup) {
      shipGroup.rotation.z = Math.sin(t * 0.35) * 0.012 + Math.sin(t * 0.22 + 1) * 0.006
      shipGroup.rotation.x = Math.sin(t * 0.28 + 0.5) * 0.018
      shipGroup.position.y = Math.sin(t * 0.18) * 0.04
    }

// 海面涟漪扩散 + 淡出
for (let i = 0; i < waterRipples.length; i++) {
const rp = waterRipples[i]
const p = (t * 0.3 + rp.phase) % 1
const s = 0.2 + p * rp.maxR
      rp.mesh.scale.set(s, s, 1)
      rp.mesh.material.opacity = (1 - p) * 0.45
    }

    dust.rotation.y = t * 0.012
    dust.position.y = Math.sin(t * 0.25) * 0.4

// 视角飞行插值(flyTo)
if (flyAnim) {
      flyAnim.elapsed += dt
const u = Math.min(1, flyAnim.elapsed / flyAnim.duration)
const e = u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2
      camera.position.lerpVectors(flyAnim.startPos, flyAnim.endPos, e)
      controls.target.lerpVectors(flyAnim.startTgt, flyAnim.endTgt, e)
      controls.update()
if (u >= 1) flyAnim = null
else renderer.render(scene, camera)
return
    }

    controls.update()
    renderer.render(scene, camera)
  }
  animate()

  observer = new ResizeObserver(() => {
const nw = el.clientWidth
const nh = el.clientHeight
if (!nw || !nh) return
    camera.aspect = nw / nh
    camera.updateProjectionMatrix()
    renderer.setSize(nw, nh)
  })
  observer.observe(el)

// ===== 点击交互 =====
const onClickCanvas = (ev) => {
const rect = renderer.domElement.getBoundingClientRect()
const nx = ((ev.clientX - rect.left) / rect.width) * 2 - 1
const ny = -((ev.clientY - rect.top) / rect.height) * 2 + 1
    mouseNdc.set(nx, ny)
    raycaster.setFromCamera(mouseNdc, camera)
const hits = raycaster.intersectObjects(scene.children, true)
let info = null
for (const h of hits) {
// 命中 mesh 或其祖先携带 __info 即展示
let o = h.object
while (o && !o.userData.__info) o = o.parent
if (o && o.userData.__info) { info = o.userData.__info; break }
    }
if (info) {
      tooltip.value = {
visible: true,
x: ev.clientX - rect.left + 12,
y: ev.clientY - rect.top + 12,
title: info.title,
sub: info.sub,
extra: info.extra
      }
    } else {
      tooltip.value.visible = false
    }
  }
  renderer.domElement.addEventListener('click', onClickCanvas)
})

// 预设视角切换
functionflyTo(name) {
const p = presetCameras[name]
if (!p || !camera || !controls) return
  flyAnim = {
startPos: camera.position.clone(),
endPos: new THREE.Vector3(...p.pos),
startTgt: controls.target.clone(),
endTgt: new THREE.Vector3(...p.target),
elapsed: 0,
duration: 1.1
  }
  tooltip.value.visible = false
}

onUnmounted(() => {
  cancelAnimationFrame(rafId)
  observer && observer.disconnect()
  controls && controls.dispose()
  renderer && renderer.dispose()
})
</script>

<stylescoped>
.port-scene {
position: absolute;
inset: 0;
}

.scene-canvas {
position: absolute;
inset: 0;
}

.scene-vignette {
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(180deg, rgba(8, 10, 12, 0.55) 0%, transparent 18%, transparent 78%, rgba(8, 10, 12, 0.6) 100%),
radial-gradient(100%90% at 50%45%, transparent 55%, rgba(6, 8, 10, 0.55) 100%);
}

.scene-tag {
position: absolute;
top: 14px;
left: 18px;
display: flex;
align-items: center;
gap: 7px;
font-size: 11px;
letter-spacing: 1.6px;
color: #b9c0c7;
text-shadow: 01px6pxrgba(0, 0, 0, 0.8);
}

.scene-tagi {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--orange);
box-shadow: 008pxvar(--orange);
}

.scene-legend {
position: absolute;
left: 18px;
bottom: 14px;
display: flex;
align-items: center;
gap: 18px;
font-size: 10.5px;
letter-spacing: 1px;
color: #9aa1a8;
}

.scene-legendspan {
display: flex;
align-items: center;
gap: 6px;
}

.dot {
width: 8px;
height: 8px;
display: inline-block;
}

.dot.orange {
background: var(--orange);
}

.dot.white {
background: #dfe2dd;
}

.coords {
color: #6d757d;
letter-spacing: 1.4px;
}

/* 预设视角切换按钮(叠在左下角 legend 右侧) */
.camera-preset {
position: absolute;
right: 18px;
bottom: 14px;
margin-left: 8px;
padding: 5px14px;
font-size: 11px;
letter-spacing: 2px;
color: #b9c0c7;
background: rgba(10, 14, 18, 0.62);
border: 1px solid rgba(30, 159, 255, 0.35);
border-radius: 2px;
cursor: pointer;
user-select: none;
transition: all 0.2s;
box-shadow: inset 0010pxrgba(30, 159, 255, 0.08);
}
.camera-preset:nth-of-type(2) { right: 80px; }
.camera-preset:nth-of-type(3) { right: 142px; }
.camera-preset:hover {
color: #ffae6a;
border-color: rgba(255, 106, 16, 0.7);
box-shadow: inset 0014pxrgba(255, 106, 16, 0.15), 0010pxrgba(255, 106, 16, 0.2);
}

/* 点击弹出的信息卡 */
.obj-tooltip {
position: absolute;
min-width: 200px;
padding: 10px14px;
background: rgba(10, 14, 18, 0.92);
border: 1px solid rgba(30, 159, 255, 0.5);
border-left: 3px solid #ff6a10;
border-radius: 2px;
box-shadow: 04px22pxrgba(0, 0, 0, 0.55), inset 0014pxrgba(30, 159, 255, 0.1);
pointer-events: none;
z-index: 10;
display: flex;
flex-direction: column;
gap: 3px;
font-family: 'Microsoft YaHei', sans-serif;
}
.obj-tooltipb {
font-size: 12.5px;
font-weight: 600;
color: #f1f3f5;
letter-spacing: 0.5px;
}
.obj-tooltipspan {
font-size: 11px;
color: #7fd0ff;
letter-spacing: 0.3px;
}
.obj-tooltipsmall {
font-size: 10px;
color: #6d757d;
letter-spacing: 0.6px;
margin-top: 2px;
}
</style>


数据非真实数据,仅做效果展示。

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料