制作數(shù)字農(nóng)場(chǎng)3D可視化大屏
|
admin
2025年4月11日 15:57
本文熱度 167
|
1.介紹
數(shù)字農(nóng)業(yè)可視化是一種將農(nóng)業(yè)生產(chǎn)過(guò)程中的各類數(shù)據(jù),通過(guò)先進(jìn)的信息技術(shù)手段進(jìn)行采集、整合、分析,并以直觀的可視化形式呈現(xiàn)出來(lái)的技術(shù)應(yīng)用模式。它利用大數(shù)據(jù)、物聯(lián)網(wǎng)、人工智能、GIS等技術(shù),為農(nóng)業(yè)生產(chǎn)經(jīng)營(yíng)管理提供了全新的、高效的決策支持工具,使農(nóng)業(yè)從業(yè)者能夠更加清晰、準(zhǔn)確地了解農(nóng)業(yè)生產(chǎn)的各個(gè)環(huán)節(jié),從而實(shí)現(xiàn)精準(zhǔn)決策、精細(xì)管理和高效運(yùn)營(yíng)。
最近對(duì)數(shù)字農(nóng)業(yè)有點(diǎn)感興趣,于是就有了接下來(lái)的探索和嘗試,本文的內(nèi)容比較有綜合性,基本上用到了之前在技術(shù)社區(qū)分享的大部分經(jīng)驗(yàn),不僅包括高德開(kāi)發(fā)平臺(tái)的技術(shù),也集成了具體業(yè)務(wù)分析、GIS數(shù)據(jù)生成、3D模型制作等內(nèi)容。附演示頁(yè)面地址,源代碼地址見(jiàn)文末。
2. 需求分析
本次做可視化大屏的開(kāi)發(fā),我希望最終的開(kāi)發(fā)成果是可以在后續(xù)的產(chǎn)品或者項(xiàng)目中復(fù)用、至少能發(fā)揮一定的參考價(jià)值,因此需要做一些業(yè)務(wù)需求分析。由于我在這方面的業(yè)務(wù)涉獵比較淺顯,于是先看了幾個(gè)智慧農(nóng)業(yè)解決方案方便的PPT,然后詢問(wèn)AI助手,整理為下面幾個(gè)專題的內(nèi)容:
2.1 基礎(chǔ)配套
- 地形:以三維地形圖的形式呈現(xiàn),通過(guò)不同顏色和高度標(biāo)識(shí)展示區(qū)域內(nèi)的山地、沼澤、平原等地形分布??梢允褂玫雀呔€、陰影等效果增強(qiáng)立體感,讓用戶直觀了解地形的起伏。由于增加地形起伏會(huì)直接增加其他貼合地形圖層的實(shí)現(xiàn)復(fù)雜度,為降低閱讀難度本次示例選了塊地形相對(duì)平整的沖擊平原,因此規(guī)避地形問(wèn)題。
- 影像:展示高分辨率的衛(wèi)星影像圖,全面覆蓋智慧農(nóng)業(yè)所涉及的區(qū)域范圍,讓用戶能夠以宏觀視角清晰了解整個(gè)區(qū)域的全貌,包括地形、河流、村居、植被等基礎(chǔ)配套元素的分布及相互關(guān)系。
- 水域:在地圖上清晰標(biāo)注河流的走向、河道寬度以及與其他水體的連接關(guān)系
- 水質(zhì):如酸堿度、溶解氧、污染物含量等指標(biāo),并以不同顏色或圖表形式在大屏上直觀展示,以保障農(nóng)業(yè)用水安全。
- 村居建筑:展示村莊的分布位置和范圍,以建筑模型或圖標(biāo)形式呈現(xiàn)村居的布局。

2.2 農(nóng)業(yè)生產(chǎn)
- 農(nóng)田:以高精度地圖展示農(nóng)田地塊的邊界和面積,對(duì)不同的農(nóng)田進(jìn)行編號(hào)和分類管理,例如按照種植作物類型、當(dāng)前使用狀態(tài)等進(jìn)行劃分
- 魚(yú)塘:標(biāo)注魚(yú)塘的位置和范圍,顯示魚(yú)塘的面積和水深等基本信息。展示魚(yú)塘的養(yǎng)殖情況,包括養(yǎng)殖的魚(yú)類品種、生長(zhǎng)階段、投喂記錄等,方便養(yǎng)殖戶進(jìn)行科學(xué)管理和養(yǎng)殖計(jì)劃制定。
- 作物識(shí)別:利用圖像識(shí)別技術(shù),通過(guò)攝像頭或衛(wèi)星影像對(duì)農(nóng)田中的作物進(jìn)行實(shí)時(shí)識(shí)別和分類。在大屏上以不同顏色或圖標(biāo)標(biāo)注出不同作物的種植區(qū)域,方便用戶快速了解農(nóng)田的作物布局
- 災(zāi)害預(yù)測(cè):通過(guò)監(jiān)測(cè)田間的病蟲(chóng)害發(fā)生情況、氣象條件、作物生長(zhǎng)狀況等因素,運(yùn)用病蟲(chóng)害預(yù)測(cè)模型,預(yù)測(cè)病蟲(chóng)害的發(fā)生趨勢(shì)和流行范圍。

2.3 安全監(jiān)管
- 無(wú)人機(jī)巡查:在地圖上展示無(wú)人機(jī)的巡查路線和實(shí)時(shí)位置,用戶可以直觀地看到無(wú)人機(jī)的飛行軌跡。
- 入侵告警:在地圖上劃定重點(diǎn)安全區(qū)域,如農(nóng)田保護(hù)區(qū)、魚(yú)塘養(yǎng)殖區(qū)、倉(cāng)庫(kù)等,當(dāng)有人員或車輛未經(jīng)授權(quán)進(jìn)入這些區(qū)域時(shí),系統(tǒng)自動(dòng)觸發(fā)入侵告警。
- 重點(diǎn)位置POI:在地圖上標(biāo)注所有攝像頭的位置,形成 POI(Point of Interest)圖層。用戶可以點(diǎn)擊每個(gè)攝像頭圖標(biāo),查看該攝像頭的實(shí)時(shí)監(jiān)控畫(huà)面和相關(guān)信息,如攝像頭編號(hào)、安裝位置、監(jiān)控范圍等。

2.4 經(jīng)濟(jì)效益
- 區(qū)塊產(chǎn)量預(yù)測(cè):對(duì)比不同年份或不同種植季節(jié)的產(chǎn)量預(yù)測(cè)數(shù)據(jù),分析產(chǎn)量變化趨勢(shì)和影響因素,為農(nóng)業(yè)生產(chǎn)規(guī)劃和資源配置提供決策依據(jù)
- 投入產(chǎn)出比分析:詳細(xì)展示農(nóng)業(yè)生產(chǎn)過(guò)程中的各項(xiàng)投入成本,包括土地租賃費(fèi)用、農(nóng)資采購(gòu)成本、人工成本、水電費(fèi)、運(yùn)輸費(fèi)用等,并以圖表形式呈現(xiàn)各項(xiàng)成本在總成本中的占比情況,幫助用戶清晰了解成本結(jié)構(gòu)。

3. 技術(shù)分析
經(jīng)過(guò)上面的業(yè)務(wù)需求分析,我們就可以開(kāi)始將它們轉(zhuǎn)為技術(shù)上的需求模塊進(jìn)行逐個(gè)實(shí)現(xiàn),其中部分圖層可視化效果,使用高德平臺(tái)提供的可視化類Loca可以滿足了,其他部分圖層則需要自行開(kāi)發(fā),這里我將自己平時(shí)積累的可視化圖層整理為的gl-layers圖層庫(kù),核心代碼是基于three JS和高德自定義圖層類CustomLayer、GLCustomLayer進(jìn)行開(kāi)發(fā)。

3.1 技術(shù)棧說(shuō)明
工具名稱 | 版本 | 用途 |
---|
高德地圖 JSAPI | 2.0 | 為GIS平臺(tái)提供基礎(chǔ)底圖和服務(wù) | three.js | 0.157 | 主流webGL引擎之一,負(fù)責(zé)實(shí)現(xiàn)展示層面的功能 | QGIS | 3.32.3 | GIS數(shù)據(jù)處理工具,用于處理本文的矢量化數(shù)據(jù) | cesiumlab | 3.1.11 | 三維數(shù)據(jù)處理工具集,用于將模型轉(zhuǎn)換為互聯(lián)網(wǎng)可用的3DTiles | blender | 3.6 | 模型處理工具,用于對(duì)BIM模型進(jìn)行最簡(jiǎn)單的預(yù)處理 | CityEngine | 2023.0 | arcGIS團(tuán)隊(duì)開(kāi)發(fā)的程序化 3D 城市生成器 ,支持通過(guò)腳本將GIS轉(zhuǎn)換為3D模型 | vue | 3.2.25 | 實(shí)現(xiàn)可視化大屏UI的語(yǔ)言框架,特點(diǎn)是數(shù)據(jù)雙向綁定 | vite | 2.9.15 | 便捷的前端工程構(gòu)建工具 | AI Earth |
| 達(dá)摩學(xué)院提供的AIE-SEM影像識(shí)別、分割、提取服務(wù),可以幫忙我們從遙感影像圖片中提取GIS數(shù)據(jù) |
3.2 圖層說(shuō)明
專題 | 內(nèi)容 | GIS數(shù)據(jù)類型 | 表現(xiàn)形式 | 代碼層 |
---|
基礎(chǔ)配套 | 衛(wèi)星影像底圖 | 圖片 | 瓦片地圖 | AMap.TileLayer | 基礎(chǔ)配套 | 村居建筑 | polygon | 三維建筑模型 | GlLayer.TilesLayer | 基礎(chǔ)配套 | 綠化區(qū)域 | point | 實(shí)例模型 | GlLayer.TilesLayer | 基礎(chǔ)配套 | 水域 | polygon | 水面多邊形 | GlLayer.WaterLayer | 農(nóng)業(yè)生產(chǎn) | 農(nóng)田地塊 | polygon | 帶紋理多邊形,可區(qū)分當(dāng)前使用狀態(tài) | GlLayer.PolygonLayer | 農(nóng)業(yè)生產(chǎn) | 魚(yú)塘地塊 | polygon | 帶紋理多邊形,可區(qū)分當(dāng)前水體狀態(tài) | GlLayer.PolygonLayer | 農(nóng)業(yè)生產(chǎn) | 農(nóng)作物識(shí)別結(jié)果 | point | 作物類型點(diǎn)圖標(biāo) | AMap.MassMarker | 農(nóng)業(yè)生產(chǎn) | 農(nóng)田災(zāi)害風(fēng)險(xiǎn)AI預(yù)測(cè)圖 | point | 熱力圖 | Loca.HeatMapLayer | 安全監(jiān)管 | 區(qū)域邊界 | polyline | 三維發(fā)光墻面體,如果有監(jiān)控目標(biāo)進(jìn)入?yún)^(qū)域內(nèi)則會(huì)出現(xiàn)告警 | GlLayer.BorderLayer | 安全監(jiān)管 | 無(wú)人機(jī)導(dǎo)航 | polyline | 無(wú)人機(jī)模型在空中飛行移動(dòng) | GlLayer.DrivingLayer | 安全監(jiān)管 | 巡查路線 | polyline | 無(wú)人機(jī)移動(dòng)軌跡 | GlLayer.FlowlineLayer | 安全監(jiān)管 | 示范區(qū)服務(wù)點(diǎn) | point | 帶名稱點(diǎn)標(biāo)記,點(diǎn)擊可切換到專屬視角 | Loca.LabelsLayer | 經(jīng)濟(jì)效益 | 產(chǎn)量AI預(yù)測(cè)圖層 | point | 網(wǎng)格蜂窩柱狀圖,產(chǎn)量越大柱狀越紅且越高 | Loca.HexagonLayer |
4. 實(shí)現(xiàn)步驟
4.1 主體框架開(kāi)發(fā)
- 使用vite創(chuàng)建工程,安裝前文技術(shù)棧提及的各種依賴包
在入口模塊編寫主體邏輯,引入主要模塊、聲明變量
<script setup>
import { getMap, initMap } from '@/utils/mainMap2.js'
import GLlayer from '#/gl-layers/src/index'
import * as THREE from 'three'
import * as dat from 'dat.gui'
let loca
const container = ref(null)
const layerManger = new LayerManager()
let normalMarker
onMounted(async () => {
await init()
await initLayers()
animateFn()
})
</script>
<template>
<div ref="container" class="container"></div>
<div class="tool">
<div class="btn" @click="gotoCenter()">回到中心</div>
<div class="btn" @click="toggleCross()">越界告警</div>
<div class="btn" @click="toggleDronView()">無(wú)人機(jī)巡航</div>
</div>
</template>
-
初始化基礎(chǔ)地圖,并添加衛(wèi)星影像圖
async function init() {
const map = await initMap({
viewMode: '3D',
dom: container.value,
showBuildingBlock: false,
center: SETTING.center,
zoom: 15.5,
pitch: 42.0,
rotation: 4.9,
mapStyle: 'amap://styles/light',
skyColor: '#c8edff'
})
const satelliteLayer = new AMap.TileLayer.Satellite();
map.add([satelliteLayer]);
map.on('zoomend', (e) => {
console.log(map.getZoom())
})
map.on('click', (e) => {
const { lng, lat } = e.lnglat
console.log([lng, lat])
})
loca = new Loca.Container({
map,
});
normalMarker = new AMap.Marker({
offset: [70, -15],
zooms: [1, 22]
});
}
4.2 村居/綠化圖層
村居是指農(nóng)業(yè)示范區(qū)內(nèi)的建筑面生成模型,綠化圖層則是綠樹(shù)等植物的覆蓋區(qū)域,原本應(yīng)該是兩個(gè)圖層,因?yàn)樵诒緢?chǎng)景中僅僅作為地圖三維底座,均無(wú)交互性,我就直接把它們合并為一個(gè)3Dtiles以提升性能了。
4.2.1 制作村居數(shù)據(jù)
- 村居數(shù)據(jù)的建筑面獲取方法有兩種,我們可以通過(guò)一些GIS數(shù)據(jù)工具下載指定區(qū)域內(nèi)建筑面數(shù)據(jù),也可以通過(guò)AI Earth進(jìn)行衛(wèi)星影像圖建筑物提取,最終生成geoJSON文件,導(dǎo)入QGIS進(jìn)行數(shù)據(jù)清洗和加工。
- 如果建筑面沒(méi)有高度數(shù)據(jù),我們根據(jù)目標(biāo)場(chǎng)景的實(shí)際情況,可以在QGIS中生成一定范圍內(nèi)的隨機(jī)值

4.2.2 制作綠化區(qū)域數(shù)據(jù)
- 使用QGIS新建多邊形面圖層,在目標(biāo)場(chǎng)景區(qū)域內(nèi)將綠化區(qū)域圈選出來(lái)。在過(guò)程中可能會(huì)涉及到帶孔多邊形的制作,我們可以利用矢量多邊形的布爾運(yùn)算獲得。
- 在QGIS工具箱找到“矢量創(chuàng)建-多邊形內(nèi)部的隨機(jī)點(diǎn)”即可生成隨機(jī)點(diǎn)功能,即可在綠化區(qū)域生成均勻分布的隨機(jī)點(diǎn),后續(xù)每個(gè)點(diǎn)我們都可以種上一棵樹(shù)。
4.2.3 轉(zhuǎn)換為3D瓦片
- 新建cityEngine工程,并將制作好的村居和綠化數(shù)據(jù)另存為SHP格式,置入到工程中
- 將目標(biāo)場(chǎng)景的矩形范圍也導(dǎo)出一張TIF格式的圖片,置入到工程中,作為本工程場(chǎng)景的底圖
- 將村居數(shù)據(jù)Polygons拖入場(chǎng)景編輯面板中,選中元素對(duì)象并配置規(guī)則文件,我們就可以快速生成建筑模型,并通過(guò)配置將建筑高度與建筑面高度數(shù)據(jù)關(guān)聯(lián)上,選擇合適的房屋造型和風(fēng)格。

-
同理將綠化區(qū)域數(shù)據(jù)Points拖拽入場(chǎng)景編輯面板,并配置植物生成規(guī)則文件,我們就可以快速得到效果非常不錯(cuò)的植物綠化區(qū)域

-
選中兩個(gè)圖層的模型并導(dǎo)出為FBX,注意配置面板中的設(shè)置,中心一項(xiàng)關(guān)系到所有模型在地圖上的位置是否正確,需要格外關(guān)注 -
開(kāi)啟cesiumlab,進(jìn)入通用模型切片,直接轉(zhuǎn)換為3Dtiles,可以在ceisumlab的預(yù)覽頁(yè)面中看到建筑和植物都落在地球的地面上,可能原點(diǎn)的地理位置是錯(cuò)誤的。這個(gè)不用擔(dān)心,我們?cè)趯⑵浣尤敫叩碌貓D時(shí)做再做調(diào)整。更細(xì)節(jié)的步驟可以看我之前寫的低成本創(chuàng)建數(shù)字孿生場(chǎng)景

4.2.4 在高德地圖呈現(xiàn)
- 部署3dtiles靜態(tài)服務(wù),在高德地圖中需要重新定義3dtiles的原點(diǎn)坐標(biāo),因此需要?jiǎng)?chuàng)建一個(gè)tileset.json入口文件副本,并將其初始轉(zhuǎn)置矩陣歸零

-
編寫代碼,這里使用之前開(kāi)發(fā)的TilesLayer圖層做加載,關(guān)于如何在高德地圖中實(shí)現(xiàn)3dtiles,想了解具體實(shí)現(xiàn)可以看看這里 。
async function initBuildingLayer() {
const map = getMap()
const layer = new TilesLayer({
id: 'buildingLayer',
title: '村居建筑圖層',
alone: SETTING.alone,
map,
center: [113.531905, 22.737473],
zooms: [4, 30],
interact: false,
tilesURL: 'http://localhost:9003/model/twQ1mVSwQ/tileset.0.json',
needShadow: true
})
layerManger.add(layer)
}
-
為保證視覺(jué)效果,加載完成后還對(duì)模型打光調(diào)亮、添加陰影,關(guān)于如何在地圖的平面上添加陰影,需要開(kāi)個(gè)單獨(dú)的小節(jié)在后文詳敘。
layer.on('complete', ({ scene, renderer }) => {
const aLight = new THREE.AmbientLight(0xffffff, 0.5)
scene.add(aLight)
var dLight = new THREE.DirectionalLight(0xffffff, intetity);
dLight.position.set(lightPositionX, lightPositionY, lightPositionZ);
dLight.castShadow = true;
dLight.shadow.mapSize.width = mapSize;
dLight.shadow.mapSize.height = mapSize;
dLight.shadow.camera.near = cameraNear;
dLight.shadow.camera.far = caremaFar;
dLight.shadow.camera.left = cameraLeft;
dLight.shadow.camera.right = cameraRight;
dLight.shadow.camera.top = cameraTop;
dLight.shadow.camera.bottom = cameraBottom;
dLight.shadow.bias = -0.0001;
scene.add(dLight);
directionalLight = dLight
const geometry1 = new THREE.PlaneGeometry(5000, 5000);
const material1 = new THREE.ShadowMaterial({ opacity: 1.0 })
const plane = new THREE.Mesh(geometry1, material1);
plane.position.z = 0;
plane.receiveShadow = true;
scene.add(plane);
}) ?
-
最終的效果如下

4.3 水域圖層
- 我們同樣可以使用QGIS自行繪制、或者使用GIS工具獲取水域范圍數(shù)據(jù)

-
水面的實(shí)現(xiàn)方式是在指定的多邊形平面上添加水紋材質(zhì),這里使用到了ShaderMaterial編寫自定義著色器材質(zhì),我們封裝為WaterLayer圖層,詳細(xì)步驟可以看這里
async function initWaterLayer() {
const map = getMap()
const data = await fetchMockData('water.geojson')
const layer = new GLlayers.WaterLayer({
id: 'waterLayer',
map,
data,
alone: SETTING.alone,
zooms: [16, 22],
animate: true,
waterColor: '#CFEACD',
altitude: -5
})
layerManger.add(layer)
}
-
最終效果如下,動(dòng)靜結(jié)合這樣一來(lái)村居看起來(lái)更靈動(dòng)了

4.4 農(nóng)田地塊
- 農(nóng)田和魚(yú)塘地塊具有共同的特性,實(shí)現(xiàn)方法類似可以合起來(lái)講,在QGIS上我們就可以通過(guò)屬性表對(duì)polygone按屬性做分類

-
獲取數(shù)據(jù),實(shí)例化Polylone,其實(shí)這種常規(guī)的Polygon,高德地圖Loca也有提供,之所以用自己開(kāi)發(fā)的polygon是想給Polygon添加圖片紋理,比如正在使用的地塊使用水稻田紋理 ,而養(yǎng)護(hù)中的地塊則使用土地紋理,簡(jiǎn)單一點(diǎn)就是用顏色做區(qū)分。
async function initFarmLayer() {
const map = getMap()
const data = await fetchMockData('farm.geojson')
console.log(data)
data.features.forEach(item => {
const { used } = item.properties
item.properties.color = used == 1 ? "#33a02c" : (used == 0 ? "#b2df8a" : "#ceb89e")
})
const layer = new GlLayer.PolygonLayer({
id: 'farmLayer',
alone: SETTING.alone,
map,
data,
lineWidth: 0,
opacity: 0.4,
interact: true,
zIndex: 100,
altitude: 2
})
layerManger.add(layer)
}
-
單個(gè)PolygonLayer生成Mesh的核心代碼如下,將空間坐標(biāo)數(shù)組轉(zhuǎn)為Mesh的頂點(diǎn)三角面,并賦予材質(zhì),更詳細(xì)的的實(shí)現(xiàn)步驟可以看看之前分享的在高德地圖上實(shí)現(xiàn)Polylone圖層。
* 繪制多邊形
* @private
* @param {Array} path 路徑
* @param {Object} properties 屬性
*/
drawPolygon ({ path, properties }) {
const { altitude, opacity } = this._conf
const flatArr = path.map(v => {
return [v[0], v[1], altitude]
}).flat()
const triangles = Earcut.triangulate(flatArr, null, 3)
const geometry = new THREE.BufferGeometry()
let faceList = []
for (let i = 0; i < triangles.length; i++) {
const [x, y, z] = path[triangles[i]]
faceList = [...faceList, x, y, altitude]
}
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(faceList), 3))
geometry.computeVertexNormals()
const material = new THREE.MeshBasicMaterial({
color: properties.color || '#0674F1',
transparent: true,
opacity: properties.opacity || opacity
})
const polygon = new THREE.Mesh(geometry, material)
const _scene = this.group || this.scene
_scene.add(polygon)
}
最終效果如下

4.5 作物識(shí)別圖層
- 作物識(shí)別圖層的作用是展示AI遙感識(shí)別技術(shù)對(duì)農(nóng)田作物的識(shí)別結(jié)果,以及展示AI技術(shù)對(duì)魚(yú)塘產(chǎn)量做出的預(yù)測(cè)數(shù)據(jù),用AMap.MassMarker就可以滿足了
- 需要注意的是點(diǎn)標(biāo)記的坐標(biāo)位置是如何生成的,總不可能手動(dòng)創(chuàng)建效率太低了,我們可以使用QGIS自帶的矢量數(shù)據(jù)處理功能自動(dòng)創(chuàng)建質(zhì)心,直接為每個(gè)polygon生成中心坐標(biāo)點(diǎn)。右鍵圖層打開(kāi)屬性表添加識(shí)別結(jié)果,導(dǎo)出geojson格式備用。


-
在高德地圖中添加圖層實(shí)現(xiàn),為保證與其他圖層的接口統(tǒng)一,我對(duì)MassMark和MassMakers進(jìn)行了封裝,統(tǒng)一基礎(chǔ)屬性、初始化配置參數(shù)和顯示隱藏方法。
import BaseUtils from './BaseUtils';
class CropLayer extends BaseUtils {
data = [];
markers = [];
id = null
layer = null
iconMap = {
'香蕉': { icon: 'xiangjiao.png', style: 0},
'火龍果': { icon: 'huolongguo.png', style: 1},
}
constructor(config) {
super(config);
this.getData(config.data);
this.map = config.map;
this.zooms = config.zooms ?? [10, 22];
this._zIndex = config.zIndex
this.id = config.id
this.init();
}
* 處理具體的圖層顯示邏輯
* @param val
*/
_handleVisible(val) {
const {layer} = this;
const fn = val ? 'show' : 'hide';
if(layer){
layer[fn]()
}
}
getData(geoJSON) {
const arr = []
const {iconMap} = this
geoJSON.features.forEach(item=>{
const {geometry, properties} = item
const {crop} = properties
const match = iconMap[crop]
const [lng, lat] = geometry.coordinates
if(match){
arr.push({
lnglat: [lng, lat, 50],
crop,
style: match.style
})
}
})
this.data = arr
}
async init() {
const {data, map, iconMap, zooms, _zIndex} = this;
const style = Object.keys(this.iconMap).map(key=>{
const {icon, style} = iconMap[key]
return {
url: `./static/icons/${icon}`,
size: new AMap.Size(30,30),
name: key
}
})
const layer = new AMap.MassMarks(data, {
opacity: 1,
zIndex: _zIndex,
cursor: 'pointer',
style,
zooms
});
layer.setMap(map)
layer.on('mouseover', (e) => {
this.dispatchEvent('mouseover', e)
});
this.layer = layer
this.visible = true;
}
}
export default CropLayer;
這樣一來(lái)就可以輕松調(diào)用了,直接將農(nóng)田和魚(yú)塘數(shù)據(jù)合并使用一個(gè)圖層展示
async function initCropLayer() {
const map = getMap()
const data1 = await fetchMockData('crop.geojson')
const data2 = await fetchMockData('poolCenter.geojson')
data1.features = data1.features.concat(data2.features)
const layer = new CropLayer({
id: 'cropLayer',
data: data1,
zooms: [16, 22],
zIndex: 200,
map
})
layer.on('mouseover', (e) => {
const { crop, style } = e.data
normalMarker.setPosition(e.data.lnglat);
normalMarker.setOffset(new AMap.Pixel(90, -10))
let content = ''
if (style <= 4) {
content = `<div class="amap-info-window">
<p>作物: ${crop}</p>
<p>識(shí)別匹配度: ${parseInt(Math.random() * 20) + 80}%</p>
<p>產(chǎn)量預(yù)計(jì): ${parseInt(Math.random() * 30) + 20}噸</p>
</div>`
} else {
content = `<div class="amap-info-window">
<p>作物: ${crop}</p>
<p>產(chǎn)量預(yù)計(jì): ${parseInt(Math.random() * 20) + 10}噸</p>
</div>`
}
normalMarker.setContent(content)
normalMarker.setMap(map)
})
layer.on('mouseout', (e) => {
map.remove(normalMarker);
})
layerManger.add(layer)
}
-
最終效果如下

4.6 區(qū)域邊界
- 區(qū)域邊界的數(shù)據(jù)繪制很簡(jiǎn)單,就是一個(gè)常規(guī)的封閉線圖形polyline。

-
我使用之前開(kāi)發(fā)的GlLayer.BorderLayer進(jìn)行實(shí)例化渲染,方便定制各種動(dòng)畫(huà)。
async function initBorderLayer() {
const map = getMap()
const data = await fetchMockData('border.geojson')
const layer = new GlLayer.BorderLayer({
id: 'borderLayer',
alone: SETTING.alone,
map,
wallColor: '#3dfcfc',
wallHeight: 100,
data,
speed: 0.3,
animate: true,
zooms: [11, 22],
altitude: 0
})
layerManger.add(layer)
}
-
區(qū)域入侵監(jiān)控這部分操作正常來(lái)說(shuō)是由物聯(lián)網(wǎng)設(shè)備檢測(cè)到,推送消息給服務(wù)端,再由服務(wù)端推送給前端一條消息。為方便演示我直接在前端模擬了,定時(shí)檢測(cè)指定目標(biāo)位置,如果在polygon內(nèi)部,則區(qū)域邊界圖層出現(xiàn)告警狀態(tài),整體變?yōu)榧t色;目標(biāo)離開(kāi),則解除告警狀態(tài)。為此新增了setColor方法用于切換顏色狀態(tài)。
* 設(shè)置區(qū)域邊界顏色
* @param {String} newColor 顏色值,比如'#ffffff'
*/
setColor(newColor){
const newTexture = this.generateTexture (128, newColor)
newTexture.wrapS = THREE.RepeatWrapping
newTexture.wrapT = THREE.RepeatWrapping
this._color = newColor
this._texture_offset = 0
this.mainMesh.material.color = newColor
this.animateMesh.material.map = newTexture
this._texture = newTexture
}
generateTexture (size = 64, color = '#ff0000') {
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const ctx = canvas.getContext('2d')
const linearGradient = ctx.createLinearGradient(0, 0, 0, size)
linearGradient.addColorStop(0.2, hexToRgba(color, 0.0))
linearGradient.addColorStop(0.8, hexToRgba(color, 0.5))
linearGradient.addColorStop(1.0, hexToRgba(color, 1.0))
ctx.fillStyle = linearGradient
ctx.fillRect(0, 0, size, size)
const texture = new THREE.Texture(canvas)
texture.needsUpdate = true
return texture
}
-
模擬邊界入侵檢測(cè),我們可以使用AMap.GeometryUtils提供的幾何計(jì)算方法,判斷點(diǎn)是否在多邊形內(nèi),是的話則改變邊界狀態(tài)為告警,否則移除告警。
let isInvadeMode = false
let invadeClock = null
let invadeMarker
* 切換入侵檢測(cè)模式
*/
async function toggleInvade() {
const map = getMap()
const borderLayer = layerManger.findLayerById('borderLayer')
isInvadeMode = !isInvadeMode
let ring = []
let invadePath
let invadeStep = 0
if (isInvadeMode) {
const borderPath = await fetchMockData('border.geojson')
ring = borderPath.features[0].geometry.coordinates[0]
initInvade()
invadeClock = setInterval(() => {
const pos = invadePath[invadeStep]
invadeStep = (invadeStep + 1) % invadePath.length
invadeMarker.setPosition(pos)
const color = isInRing(pos, ring) ? '#ff0000' : '#3dfcfc'
if(borderLayer._color !== color){
borderLayer.setColor(color)
}
}, 1000)
} else {
clearInvade()
borderLayer.setColor('#3dfcfc')
}
async function initInvade(){
const {features} = await fetchMockData('invade-path.geojson')
invadePath = features[0].geometry.coordinates[0]
invadeMarker = new AMap.Marker({
content: `<img style="width:30px;" src="./static/icons/ico-invade.png">`,
anchor: 'bottom-center',
offset: new AMap.Pixel(-15, -20)
})
map.add(invadeMarker)
}
function clearInvade(){
clearInterval(invadeClock)
invadeClock = null
map.remove(invadeMarker)
invadeMarker = null
}
function isInRing (pos, ring){
const res = AMap.GeometryUtil.isPointInRing(pos, ring)
console.log('is in ring ', res)
return res
}
}
-
最終效果如下

4.7 無(wú)人機(jī)巡查功能
最近“低空經(jīng)濟(jì)”這個(gè)概念很火,說(shuō)的是是以各種有人駕駛和無(wú)人駕駛航空器的各類低空飛行活動(dòng)為牽引,輻射帶動(dòng)相關(guān)領(lǐng)域融合發(fā)展的綜合性經(jīng)濟(jì)形態(tài),既然如此怎么能少得了無(wú)人機(jī)的出場(chǎng)。在本文中我們實(shí)現(xiàn)的是單架無(wú)人機(jī)模型沿著指定的閉合軌跡飛行移動(dòng),并且可以用無(wú)人機(jī)的第三人稱視角俯瞰地圖。
- 關(guān)于自動(dòng)巡航的功能在之前做無(wú)人車巡航的時(shí)候已經(jīng)實(shí)現(xiàn)過(guò)了,這里再講解一下核心代碼,其實(shí)就是在Tween更新函數(shù)中,按照既定的路徑軌跡不斷調(diào)整NPC的位置和朝向,如果需要第三人稱視角,則同步更新相機(jī)的朝向即可,更詳細(xì)的步驟可以看在高德地圖實(shí)現(xiàn)自動(dòng)巡航
onReady () {
if (this._conf.NPC) {
this.initNPC()
}
this.initController()
}
* 初始化主體NPC的狀態(tài)
* @private
*/
initNPC () {
const { _PATH_COORDS, scene } = this
const { NPC } = this._conf
NPC.up.set(0, 0, 1)
if (_PATH_COORDS.length > 1) {
NPC.position.copy(_PATH_COORDS[0])
NPC.lookAt(_PATH_COORDS[1])
}
scene.add(NPC)
}
* 創(chuàng)建移動(dòng)控制器
* @private
*/
initController () {
const target = { t: 0 }
const duration = this.getMoveDuration()
const { _PATH_COORDS, _PATH_LNG_LAT, map } = this
this._rayController = new TWEEN.Tween(target)
.to({ t: 1 }, duration)
.easing(TWEEN.Easing.Linear.None)
.onUpdate(() => {
const { NPC, cameraFollow } = this._conf
const nextIndex = this.getNextStepIndex()
const point = new THREE.Vector3().copy(_PATH_COORDS[this.npc_step])
const nextPoint = new THREE.Vector3().copy(_PATH_COORDS[nextIndex])
const position = new THREE.Vector3().copy(point).lerp(nextPoint, target.t)
if (NPC) {
NPC.position.copy(position)
}
if (cameraFollow) {
const pointLngLat = new THREE.Vector3().copy(_PATH_LNG_LAT[this.npc_step])
const nextPointLngLat = new THREE.Vector3().copy(_PATH_LNG_LAT[nextIndex])
const positionLngLat = new THREE.Vector3().copy(pointLngLat).lerp(nextPointLngLat, target.t)
this.updateMapCenter(positionLngLat)
}
if (cameraFollow) {
const angle = this.getAngle(position, _PATH_COORDS[(this.npc_step + 3) % _PATH_COORDS.length])
this.updateMapRotation(angle)
}
})
.onStart(() => {
const { NPC } = this._conf
const nextPoint = _PATH_COORDS[(this.npc_step + 3) % _PATH_COORDS.length]
if (NPC) {
NPC.lookAt(nextPoint)
NPC.up.set(0, 0, 1)
}
})
.onComplete(() => {
this.npc_step = this.getNextStepIndex()
const duration = this.getMoveDuration()
target.t = 0
this._rayController
.stop()
.to({ t: 1 }, duration)
.start()
})
.start()
}
-
實(shí)例化GlLayer.DrivinLayer圖層,我們將無(wú)人機(jī)巡航和飛行軌跡拆分為兩個(gè)圖層實(shí)現(xiàn)
async function initDroneLayer() {
const map = getMap()
const data = await fetchMockData('dronWander2.geojson')
const NPC = await getDroneModel()
const layer = new DrivingLayer({
id: 'dronLayer',
map,
zooms: [4, 30],
path: data,
altitude: 50,
speed: 50.0,
NPC,
interact: true
})
layer.on('complete', ({ scene }) => {
const aLight = new THREE.AmbientLight(0xffffff, 3.5)
scene.add(aLight)
layer.resume()
})
layerManger.add(layer)
const dronPathLayer = new FlowlineLayer({
id: 'dronPathLayer',
map,
zooms: [16, 22],
data,
speed: 0.5,
lineWidth: 10,
altitude: 50
})
layerManger.add(dronPathLayer)
}
-
本實(shí)例最大的難度在于如何讓無(wú)人機(jī)在飛行的時(shí)候4個(gè)螺旋槳旋轉(zhuǎn)擺動(dòng),這里最后選擇了在逐幀函數(shù)更新gltf自帶動(dòng)畫(huà)的方法;關(guān)于gltf動(dòng)畫(huà)如何制作,在后面有單獨(dú)章節(jié)。
function getDroneModel() {
return new Promise((resolve) => {
const loader = new GLTFLoader()
loader.load('./static/model/drone/drone1.glb', (gltf) => {
const model = gltf.scene.children[0]
const size = 10.0
model.scale.set(size, size, size)
mixer = new THREE.AnimationMixer(gltf.scene);
const action = mixer.clipAction(gltf.animations[0])
action.setEffectiveTimeScale(guiCtrl.mixerPlaySpeed);
action.play();
resolve(model)
})
})
}
function animateFn() {
requestAnimationFrame(animateFn);
if (mixer) {
mixer.update(0.01);
}
}
-
最終實(shí)現(xiàn)效果如下,第三人稱游戲的代入感出來(lái)了有沒(méi)有。

4.8 災(zāi)害預(yù)測(cè)圖層
- 該圖層本質(zhì)上是個(gè)3D熱力圖,源數(shù)據(jù)是帶有權(quán)重屬性的坐標(biāo)點(diǎn)集合,我們可以在QGIS上編輯它們甚至可以查看二維效果
 -
導(dǎo)出數(shù)據(jù),使用高德自帶的可視化圖層Loca.Heatmap實(shí)現(xiàn)
* 災(zāi)害風(fēng)險(xiǎn)檢測(cè)圖層
*/
async function initRiskLayer() {
const map = getMap()
const data = await fetchMockData('fertility.geojson')
const geo = new Loca.GeoJSONSource({ data })
const heatmap = new Loca.HeatMapLayer({
zIndex: 10,
opacity: 1,
visible: false,
zooms: [2, 22],
});
heatmap.setSource(geo, {
id: 'riskLayer',
radius: 150,
unit: 'meter',
height: 300,
gradient: {
1: '#FF4C2F',
0.8: '#FAA53F',
0.6: '#FFF100',
0.5: '#7DF675',
0.4: '#5CE182',
0.2: '#29CF6F',
},
value: function (index, feature) {
return feature.properties.weight ?? 0;
},
min: 0,
max: 100,
visible: true
});
loca.add(heatmap);
map.on('click', function (e) {
const feat = heatmap.queryFeature(e.pixel.toArray());
});
heatmap.id = 'riskLayer'
layerManger.add(heatmap)
}
-
在切換圖層為顯示狀態(tài)時(shí),可以加上動(dòng)畫(huà)以達(dá)到更好的視覺(jué)效果
function animateLayer(layer){
switch(layer.id){
case 'riskLayer':
layer.addAnimate({
key: 'height',
value: [0, 1],
duration: 2000,
easing: 'BackOut',
});
layer.addAnimate({
key: 'radius',
value: [0, 1],
duration: 2000,
easing: 'BackOut',
transform: 1000,
random: true,
delay: 5000,
});
break;
}
-
最終效果如下,產(chǎn)量AI預(yù)測(cè)圖層的實(shí)現(xiàn)方法類似就不贅述

4.9 使用圖層管理器操作圖層
本示例涉及到圖層數(shù)量已經(jīng)有十幾個(gè),為方便進(jìn)行圖層的統(tǒng)一操作(比如在專題A哪些圖層需要顯示,其他圖層隱藏;或者調(diào)用圖層的某個(gè)功能),我們需要圖層管理器layerManager,且給圖層賦予唯一的id值便于在管理器中獲取。
如下面代碼所示,提供最基礎(chǔ)的添加、查找、清除功能
* 圖層管理器
* @extends null
* @author Zhanglinhai <gyrate.sky@qq.com>
*/
class Manager {
* @description 創(chuàng)建一個(gè)實(shí)例
* @param {Object} conf
* @param {Array} conf.data 圖層數(shù)組 [layer,...] 默認(rèn)為[]
*/
constructor (config = {}) {
this._list = config.data || []
}
* @description 添加1個(gè)圖層到管理器
* @param {String} id 圖層id
* @param {String} title 圖層名稱
* @param {*} layer 圖層實(shí)例
*/
add (layer) {
if (layer === undefined) {
console.error('缺少圖層實(shí)例')
return
}
if (layer.id === undefined) {
console.error('缺少圖層id')
return
}
const { id } = layer
const match = this.findLayerById(id)
if (match) {
console.error(`圖層的id ${id} 不是唯一標(biāo)識(shí),請(qǐng)更換`)
return
}
this._list.push(layer)
}
* @description 通過(guò)id查找圖層信息
* @param {String} id 圖層id
* @returns {*} 返回匹配的第一個(gè)圖層
*/
findLayerById (id) {
const match = this._list.find(item => item.id === id)
return match
}
* @description 清空當(dāng)前的圖層管理器
*/
clear () {
this._list.forEach((layer) => {
if (layer.destroy) {
layer.destroy()
}
console.log(`銷毀layer ${layer.id}`)
})
this._list = []
}
}
這樣一來(lái)就方便我們快捷操作圖層,將整個(gè)地圖作為可視化大屏的主體,放置到帶有導(dǎo)航和圖表的低代碼大屏框架中,就完成了初步的搭建工作。

5. 其他問(wèn)題解決方案
5.1 如何在場(chǎng)景中產(chǎn)生投影
如何在高德地圖的底圖上添加模型的投影,我被困擾了一段時(shí)間,后來(lái)請(qǐng)教了高德的技術(shù)大佬WT才得到啟發(fā)解開(kāi)了這個(gè)問(wèn)題,感謝wt大佬的支持。three.js提供了一種陰影材(ShadowMaterial)此材質(zhì)可以接收陰影,但在其他方面完全透明。
要想在場(chǎng)景中獲得投影,需要下面幾個(gè)步驟都齊全
- 渲染器打開(kāi)投影
renderer.autoClear = false;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setSize(window.innerWidth, window.innerHeight);
創(chuàng)建合適的平行光源,有各種參數(shù)需要設(shè)置
var dLight = new THREE.DirectionalLight(0xffffff, 3);
dLight.position.set(lightPositionX, lightPositionY, lightPositionZ);
dLight.castShadow = true;
dLight.shadow.mapSize.width = mapSize;
dLight.shadow.mapSize.height = mapSize;
dLight.shadow.camera.near = cameraNear;
dLight.shadow.camera.far = caremaFar;
dLight.shadow.camera.left = cameraLeft;
dLight.shadow.camera.right = cameraRight;
dLight.shadow.camera.top = cameraTop;
dLight.shadow.camera.bottom = cameraBottom;
scene.add(dLight);
各種關(guān)聯(lián)物體也必須將屬性castShadow 、receiveShadow設(shè)置為true
var geo = new THREE.BoxGeometry(1000, 1000, 1000);
for (let i = 0; i < data.length; i++) {
const d = data[i];
var mesh = new THREE.Mesh(geo, mat);
mesh.position.set(d[0], d[1], 500);
mesh.castShadow = true;
mesh.receiveShadow = true;
}
-
給底部平面賦予shadowMaterial材質(zhì)
var planeGeo = new THREE.PlaneGeometry(50000, 50000);
var shadowMat = new THREE.ShadowMaterial({
opacity: planeMaterialOpacity,
});
plane = new THREE.Mesh(planeGeo, shadowMat);
plane.receiveShadow = true;
scene.add(plane);
-
最終效果如下,演示代碼鏈接放到這里了

5.2 給模型制作常規(guī)動(dòng)畫(huà)
- 下載一個(gè)無(wú)人機(jī)模型FBX格式,推薦在sketchfab上找,素材齊全。打開(kāi)blender,導(dǎo)入FBX模型,把所有部件歸屬到一個(gè)根節(jié)點(diǎn),后續(xù)控制根節(jié)點(diǎn)其他部件也跟著移動(dòng)
 -
在動(dòng)畫(huà)時(shí)間軸給每個(gè)部件加上動(dòng)畫(huà)關(guān)鍵幀,調(diào)試好動(dòng)畫(huà)
 -
補(bǔ)間動(dòng)畫(huà)默認(rèn)是緩入緩出的,可以同個(gè)左上角切換面板到曲線編輯器修改補(bǔ)間動(dòng)畫(huà)線
 -
最關(guān)鍵的一步。導(dǎo)出gltf時(shí)動(dòng)畫(huà)一項(xiàng)必須勾選,且動(dòng)畫(huà)模式設(shè)置為“合并的活動(dòng)動(dòng)作”,這樣的話,導(dǎo)出的gltf就能把所有部件動(dòng)作合并為一個(gè)動(dòng)作了。

-
最終預(yù)覽效果,螺旋槳的旋轉(zhuǎn)動(dòng)畫(huà)不需要做太快,因?yàn)樵趙eb端實(shí)際播放時(shí),速度倍率是可以通過(guò)action.setEffectiveTimeScal()調(diào)節(jié)的,要多快有多塊。

5.3 圖層的深度關(guān)系
如何處理高德自有圖層和自定義圖層的深度關(guān)系,這里必須了解高德提供的CustomLayer和GLCustomLayer的區(qū)別。
前者是在地圖實(shí)例畫(huà)布Canvas1之外另外覆蓋了一個(gè)Canvas標(biāo)簽,因此所有內(nèi)容都會(huì)置于Canvas1內(nèi)容之上,無(wú)論空間上是否合理;而后者則是與地圖實(shí)例共享畫(huà)布的,在GLCustomLayer上創(chuàng)建的內(nèi)容能夠與地圖上的元素、高德可視化類創(chuàng)建的元素共享深度關(guān)系,因此使用GLCustomLayer會(huì)讓多圖層的場(chǎng)景視覺(jué)上更加和諧,但代價(jià)就是Map需要逐幀重繪,性能損耗更高。所以如何取舍還是要看具體的業(yè)務(wù)場(chǎng)景進(jìn)行選擇。

總結(jié)
至此,使用高德地圖制作數(shù)字農(nóng)業(yè)可視化大屏的分享就告一段落了。事實(shí)上這并不是一個(gè)最終成本,因?yàn)槲疫€有很多想法沒(méi)有落實(shí), 比如精細(xì)化農(nóng)業(yè)大棚的搭建,無(wú)人機(jī)實(shí)時(shí)視頻投影、火災(zāi)預(yù)測(cè)等等功能展示;還有一些技術(shù)問(wèn)題沒(méi)有解決,比如cesiumlab使用FBX生成的3dtiles沒(méi)有支持LOD,即不同地圖縮放層級(jí)下的精細(xì)度,這在性能和視覺(jué)效果上肯定是存在優(yōu)化空間的,據(jù)我所見(jiàn)在cityEngine階段LOD信息還是存在的,至于具體在哪個(gè)過(guò)程中丟失了,還需要排查一下。
但戰(zhàn)線拉太長(zhǎng)的話項(xiàng)目可能就會(huì)永遠(yuǎn)沒(méi)有階段成果,時(shí)間關(guān)系就先發(fā)布這么多了了。說(shuō)不定分享出來(lái)之后,可以起到拋磚引玉的作用,最好能撈到更多志同道合的伙伴來(lái)一起共建虛擬農(nóng)場(chǎng)元宇宙。
本示例使用到的高德JSAPI
3D自定義圖層AMap.GLCustomLayer
自定義圖層AMap.CustomLayer
AMap.Map地圖對(duì)象類
海量點(diǎn)類AMap.MassMarkers
LOCA 數(shù)據(jù)可視化 API 2.0
空間數(shù)據(jù)計(jì)算的函數(shù)庫(kù) GeometryUtil
相關(guān)鏈接
數(shù)字孿生×低空經(jīng)濟(jì) | 天空地一體化 城市數(shù)字孿生電子沙盤指揮系統(tǒng)
在cityEngine編寫模型生成規(guī)則
THREEJS 陰影材質(zhì)的使用文檔
源代碼Github地址
演示頁(yè)面地址 作者:Gyrate 鏈接:https://juejin.cn/post/7432127587919298600 來(lái)源:稀土掘金 著作權(quán)歸作者所有。商業(yè)轉(zhuǎn)載請(qǐng)聯(lián)系作者獲得授權(quán),非商業(yè)轉(zhuǎn)載請(qǐng)注明出處。
該文章在 2025/4/12 18:27:20 編輯過(guò)
|
|