综合实战:基于 Turfjs 的智慧园区空间管理系统

一、项目背景与核心价值

智慧园区空间管理需要解决区域可视化管控、路径规划、资源定位三大核心问题,本项目基于 Vue3 + Cesium + Turf.js 构建一站式空间管理系统,实现从二维地理计算到三维场景渲染的全链路闭环。相比传统方案,本系统具备以下优势:

  • 轻量级前端架构,无后端依赖时可通过本地兜底数据完成演示;
  • 基于 Turf.js 实现高精度地理计算(缓冲区、最短路径、最近邻分析);
  • 结合 Cesium 完成三维场景可视化,支持交互性的空间操作(坐标拾取、相机聚焦);
  • 模块化设计,可快速对接后端接口实现数据持久化。

二、技术栈选型

技术 / 库 核心用途
Vue3 + Setup 语法 组件化开发,响应式状态管理
Cesium 三维地球场景渲染,坐标拾取与相机控制
Turf.js 地理空间计算(缓冲区、距离、最短路径)
Pinia 全局状态管理(园区区域、资源、路网数据)
Element Plus 快速构建交互面板(表单、表格、按钮)

三、核心功能设计与实现

3.1 项目结构

复制代码
src/
├── components/
│   └── ParkSpaceManagement.vue  # 核心业务组件
├── stores/
│   └── park.js                  # Pinia状态管理(数据与路径算法)
├── router/
│   └── index.js                 # 路由配置
└── api/
    └── client.js                # 后端接口封装(可选)

3.2 核心组件:ParkSpaceManagement.vue

该组件是系统的核心交互载体,分为「控制面板」和「Cesium 三维场景」两大部分,通过 Tab 切分「区域管理」「路径规划」「资源定位」三大业务模块。

3.2.1 模板结构(核心片段)
html 复制代码
<template>
  <div class="park-space-management">
    <div class="header">
      <h2>综合实战:基于Turfjs的智慧园区空间管理系统</h2>
    </div>
    <div class="container">
      <!-- 控制面板:区域管理/路径规划/资源定位 -->
      <div class="control-panel">
        <el-tabs v-model="tab" type="border-card">
          <!-- 区域管理 Tab -->
          <el-tab-pane label="区域管理" name="area">
            <!-- 新增区域表单 -->
            <div class="form-block">
              <div class="subtitle">新增区域</div>
              <el-input v-model="areaName" placeholder="区域名称" />
              <el-input
                v-model="areaCoordsStr"
                type="textarea"
                :rows="4"
                placeholder="面坐标数组:[[lon,lat],...],闭合环"
              />
              <div class="inline">
                <span class="label">颜色</span>
                <el-color-picker v-model="areaColor" />
              </div>
              <div class="inline">
                <el-button type="primary" @click="addArea">添加区域</el-button>
                <el-button @click="resetAreaInput">清空</el-button>
              </div>
            </div>
            <!-- 区域列表与缓冲区控制 -->
            <div class="list-block">
              <el-table :data="areas" size="small" style="width: 100%">
                <el-table-column prop="id" label="ID" width="90" />
                <el-table-column prop="name" label="名称" />
                <el-table-column label="颜色" width="110">
                  <template #default="{ row }">
                    <el-tag :style="{ background: row.properties?.color || '#2196f3', color: '#fff' }">色块</el-tag>
                  </template>
                </el-table-column>
                <el-table-column label="操作" width="180">
                  <template #default="{ row }">
                    <el-button size="small" type="primary" @click="focusArea(row)">定位</el-button>
                    <el-button size="small" type="danger" @click="removeArea(row)">删除</el-button>
                  </template>
                </el-table-column>
              </el-table>
              <div class="subtitle">缓冲区半径 (km)</div>
              <el-slider v-model="bufferKm" :min="0" :max="10" :step="0.5" @change="updateBuffer" />
            </div>
          </el-tab-pane>

          <!-- 路径规划 Tab -->
          <el-tab-pane label="路径规划" name="route">
            <div class="form-block">
              <div class="subtitle">起终点</div>
              <div class="inline">
                <span class="label">起点经纬度</span>
                <el-input-number v-model="start[0]" :min="-180" :max="180" :step="0.000001" />
                <el-input-number v-model="start[1]" :min="-90" :max="90" :step="0.000001" />
              </div>
              <div class="inline">
                <span class="label">终点经纬度</span>
                <el-input-number v-model="end[0]" :min="-180" :max="180" :step="0.000001" />
                <el-input-number v-model="end[1]" :min="-90" :max="90" :step="0.000001" />
              </div>
              <div class="inline">
                <el-button type="primary" @click="planRoute">规划路径</el-button>
                <el-button @click="clearRoute">清空</el-button>
                <el-tag v-if="routeLenKm" type="success">总长 {{ routeLenKm.toFixed(3) }} km</el-tag>
              </div>
            </div>
          </el-tab-pane>

          <!-- 资源定位 Tab -->
          <el-tab-pane label="资源定位" name="resource">
            <div class="form-block">
              <div class="subtitle">目标位置</div>
              <div class="inline">
                <span class="label">经度</span>
                <el-input-number v-model="target[0]" :min="-180" :max="180" :step="0.000001" />
                <span class="label">纬度</span>
                <el-input-number v-model="target[1]" :min="-90" :max="90" :step="0.000001" />
              </div>
              <div class="inline">
                <el-button type="primary" @click="locateNearest">定位最近资源</el-button>
                <el-button @click="clearNearest">清空</el-button>
              </div>
              <!-- 资源列表 -->
              <el-table :data="resources" size="small">
                <el-table-column prop="id" label="ID" width="90" />
                <el-table-column prop="name" label="名称" />
                <el-table-column prop="coord" label="坐标">
                  <template #default="{ row }">
                    [{{ row.coord[0] }}, {{ row.coord[1] }}]
                  </template>
                </el-table-column>
                <el-table-column label="操作" width="260">
                  <template #default="{ row }">
                    <el-button size="small" type="primary" @click="flyTo(row.coord)">定位</el-button>
                    <el-popover placement="bottom" :width="360" trigger="click">
                      <template #reference>
                        <el-button size="small">更新坐标</el-button>
                      </template>
                      <div class="inline">
                        <el-input-number v-model="editCoord.lon" :min="-180" :max="180" :step="0.000001" />
                        <el-input-number v-model="editCoord.lat" :min="-90" :max="90" :step="0.000001" />
                        <el-button size="small" type="primary" @click="commitResource(row)">保存</el-button>
                      </div>
                    </el-popover>
                  </template>
                </el-table-column>
              </el-table>
              <div v-if="nearestRes" class="result">
                最近资源:{{ nearestRes.name }},距离 {{ nearestDistKm.toFixed(3) }} km
              </div>
            </div>
          </el-tab-pane>
        </el-tabs>
      </div>

      <!-- Cesium 三维场景容器 -->
      <div id="cesiumPark" class="cesium-container"></div>
    </div>
  </div>
</template>

3.2.2 脚本逻辑(核心片段)

javascript 复制代码
<script setup>
import { onMounted, ref, onBeforeUnmount, computed } from "vue";
import * as Cesium from "cesium";
import "cesium/Build/Cesium/Widgets/widgets.css";
import * as turf from "@turf/turf";
import { useParkStore } from "../stores/park.js";

// 状态初始化
const store = useParkStore();
const tab = ref("area");
const areaName = ref("");
const areaCoordsStr = ref("");
const areaColor = ref("#2196f3");
const bufferKm = ref(1);
const start = ref([116.395, 39.905]);
const end = ref([116.405, 39.915]);
const target = ref([116.4, 39.91]);
const editCoord = ref({ lon: 116.4, lat: 39.91 });

// 响应式数据
const areas = computed(() => store.areas);
const resources = computed(() => store.resources);
const routeLenKm = ref(0);
const nearestRes = ref(null);
const nearestDistKm = ref(0);

// Cesium 核心对象
const viewer = ref(null);
let areaEntities = [];
let bufferEntities = [];
let routeEntity = null;
let resourceEntities = [];

// 初始化 Cesium 场景
function initCesium() {
  viewer.value = new Cesium.Viewer("cesiumPark", {
    terrainProvider: new Cesium.EllipsoidTerrainProvider(),
    animation: false,
    timeline: false,
    geocoder: false,
    homeButton: false,
    sceneModePicker: false,
    baseLayerPicker: false,
    navigationHelpButton: false,
    infoBox: false,
    selectionIndicator: false,
  });
  // 隐藏版权信息,开启地形深度测试
  viewer.value._cesiumWidget._creditContainer.style.display = "none";
  viewer.value.scene.globe.depthTestAgainstTerrain = true;
  // 设置初始视角
  viewer.value.scene.camera.setView({
    destination: Cesium.Cartesian3.fromDegrees(116.405, 39.91, 40000),
  });
  // 点击场景拾取坐标(快速设置起终点/目标位置)
  viewer.value.screenSpaceEventHandler.setInputAction((movement) => {
    const pos = viewer.value.scene.pickPosition(movement.position);
    if (!pos) return;
    const c = Cesium.Cartographic.fromCartesian(pos);
    const lon = Cesium.Math.toDegrees(c.longitude);
    const lat = Cesium.Math.toDegrees(c.latitude);
    // 简化交互:点击直接赋值目标位置(可扩展为起终点切换)
    target.value = [Number(lon.toFixed(6)), Number(lat.toFixed(6))];
  }, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}

// 渲染园区区域(多边形)
function renderAreas() {
  clearEntities(areaEntities);
  for (const a of areas.value) {
    const coords = a.geometry?.coordinates?.[0] || [];
    const hierarchy = coords.map((c) => Cesium.Cartesian3.fromDegrees(c[0], c[1], 0));
    const e = viewer.value.entities.add({
      polygon: {
        hierarchy: new Cesium.PolygonHierarchy(hierarchy),
        material: Cesium.Color.fromCssColorString(a.properties?.color || "#2196f3").withAlpha(0.35),
        outline: true,
        outlineColor: Cesium.Color.WHITE,
      },
    });
    areaEntities.push(e);
  }
}

// 生成并渲染缓冲区
function updateBuffer() {
  clearEntities(bufferEntities);
  const selected = areas.value[0];
  if (!selected) return;
  // Turf.js 计算缓冲区
  const poly = turf.polygon(selected.geometry.coordinates);
  const buf = turf.buffer(poly, bufferKm.value, { units: "kilometers" });
  // Cesium 渲染缓冲区
  const coords = buf.geometry.coordinates[0];
  const hierarchy = coords.map((c) => Cesium.Cartesian3.fromDegrees(c[0], c[1], 0));
  const e = viewer.value.entities.add({
    polygon: {
      hierarchy: new Cesium.PolygonHierarchy(hierarchy),
      material: Cesium.Color.fromCssColorString("#26a69a").withAlpha(0.25),
      outline: true,
      outlineColor: Cesium.Color.fromCssColorString("#26a69a"),
    },
  });
  bufferEntities.push(e);
}

// 路径规划核心逻辑
function planRoute() {
  const path = store.planRoute(start.value, end.value);
  if (!path.length) {
    clearRoute();
    return;
  }
  // 渲染路径
  const positions = path.map((c) => Cesium.Cartesian3.fromDegrees(c[0], c[1], 0));
  if (routeEntity) viewer.value.entities.remove(routeEntity);
  routeEntity = viewer.value.entities.add({
    polyline: {
      positions,
      width: 4,
      material: Cesium.Color.CYAN,
    },
  });
  // Turf.js 计算路径长度
  let len = 0;
  for (let i = 0; i + 1 < path.length; i++) {
    len += turf.distance(turf.point(path[i]), turf.point(path[i + 1]), { units: "kilometers" });
  }
  routeLenKm.value = len;
}

// 最近邻资源定位
function locateNearest() {
  let best = null;
  let bestD = Infinity;
  for (const r of resources.value) {
    // Turf.js 计算两点距离
    const d = turf.distance(turf.point(r.coord), turf.point(target.value), { units: "kilometers" });
    if (d < bestD) {
      bestD = d;
      best = r;
    }
  }
  nearestRes.value = best;
  nearestDistKm.value = bestD === Infinity ? 0 : bestD;
  if (best) flyTo(best.coord);
}

// 工具函数:清空实体
function clearEntities(list) {
  if (!viewer.value) return;
  for (const e of list) viewer.value.entities.remove(e);
  list.length = 0;
}

// 生命周期
onMounted(async () => {
  initCesium();
  await store.init();
  renderAreas();
  renderResources();
  updateBuffer();
});

onBeforeUnmount(() => {
  if (viewer.value) viewer.value.destroy();
});
</script>

3.3 状态管理:park.js(Pinia)

该文件封装了数据管理核心算法(最短路径 - Dijkstra),是前端地理计算的核心。

javascript 复制代码
import { defineStore } from "pinia";
import { distance as turfDistance, point as turfPoint } from "@turf/turf";

// 坐标格式化(避免精度问题)
function keyOfCoord(c) {
  return `${Number(c[0].toFixed(6))},${Number(c[1].toFixed(6))}`;
}

// 查找最近的路网节点
function nearestNode(nodes, coord) {
  let best = null;
  let bestD = Infinity;
  for (const k of Object.keys(nodes)) {
    const c = nodes[k];
    const d = turfDistance(turfPoint(c), turfPoint(coord), { units: "kilometers" });
    if (d < bestD) {
      bestD = d;
      best = k;
    }
  }
  return best;
}

// 构建路网图(节点+边)
function buildWalkGraph(walkways) {
  const nodes = {};
  const edges = {};
  const features = walkways?.features || [];
  for (const f of features) {
    const coords = f?.geometry?.coordinates || [];
    for (let i = 0; i < coords.length; i++) {
      const a = coords[i];
      const ak = keyOfCoord(a);
      nodes[ak] = a;
      if (i + 1 < coords.length) {
        const b = coords[i + 1];
        const bk = keyOfCoord(b);
        nodes[bk] = b;
        // 计算边的权重(距离)
        const w = turfDistance(turfPoint(a), turfPoint(b), { units: "kilometers" });
        edges[ak] = edges[ak] || {};
        edges[bk] = edges[bk] || {};
        edges[ak][bk] = Math.min(edges[ak][bk] ?? Infinity, w);
        edges[bk][ak] = Math.min(edges[bk][ak] ?? Infinity, w);
      }
    }
  }
  return { nodes, edges };
}

// Dijkstra 最短路径算法
function dijkstra(nodes, edges, startK, endK) {
  const dist = {};
  const prev = {};
  const visited = new Set();
  // 初始化距离
  for (const k of Object.keys(nodes)) dist[k] = Infinity;
  dist[startK] = 0;

  while (visited.size < Object.keys(nodes).length) {
    // 找未访问的最短距离节点
    let u = null;
    let best = Infinity;
    for (const k of Object.keys(nodes)) {
      if (!visited.has(k) && dist[k] < best) {
        best = dist[k];
        u = k;
      }
    }
    if (!u) break;
    if (u === endK) break;
    visited.add(u);

    // 松弛操作
    const nbrs = edges[u] || {};
    for (const v of Object.keys(nbrs)) {
      const alt = dist[u] + nbrs[v];
      if (alt < dist[v]) {
        dist[v] = alt;
        prev[v] = u;
      }
    }
  }

  // 回溯路径
  const pathKeys = [];
  let cur = endK;
  if (dist[endK] === Infinity) return [];
  while (cur) {
    pathKeys.unshift(cur);
    cur = prev[cur];
  }
  return pathKeys.map((k) => nodes[k]);
}

// 定义 Pinia Store
export const useParkStore = defineStore("park", {
  state: () => ({
    areas: [],        // 园区区域
    resources: [],    // 园区资源(摄像头、无人车等)
    walkways: null,   // 路网数据
    graph: { nodes: {}, edges: {} }, // 路网图
    loading: false,
    error: null,
  }),
  actions: {
    // 初始化数据(优先接口,兜底本地)
    async init() {
      this.loading = true;
      try {
        const [areas, walkways, resources] = await Promise.all([
          api.getAreas().catch(() => null),
          api.getWalkways().catch(() => null),
          api.getResources().catch(() => null),
        ]);
        if (areas) this.areas = areas;
        if (walkways) {
          this.walkways = walkways;
          this.graph = buildWalkGraph(walkways);
        }
        if (resources) this.resources = resources;
        // 兜底数据
        if (!areas && !resources && !walkways) {
          this.applyDefaults();
        }
      } finally {
        this.loading = false;
      }
    },
    // 兜底默认数据(无后端时演示)
    applyDefaults() {
      this.areas = [
        {
          id: "A-001",
          name: "研发区",
          geometry: {
            type: "Polygon",
            coordinates: [[[116.39, 39.90], [116.41, 39.90], [116.41, 39.92], [116.39, 39.92], [116.39, 39.90]]],
          },
          properties: { color: "#1e88e5" },
        },
      ];
      this.resources = [
        { id: "R-001", name: "无人车-01", coord: [116.395, 39.905] },
        { id: "R-002", name: "无人车-02", coord: [116.405, 39.915] },
        { id: "R-003", name: "安防-摄像头A", coord: [116.400, 39.910] },
      ];
      // 路网数据
      this.walkways = {
        type: "FeatureCollection",
        features: [/* 省略路网坐标 */],
      };
      this.graph = buildWalkGraph(this.walkways);
    },
    // 路径规划入口
    planRoute(start, end) {
      const g = this.graph;
      const sK = nearestNode(g.nodes, start);
      const eK = nearestNode(g.nodes, end);
      if (!sK || !eK) return [];
      const path = dijkstra(g.nodes, g.edges, sK, eK);
      return [start, ...path, end]; // 补全起终点
    },
    // 其他动作:addArea/deleteArea/updateResource 等
    async addArea(area) { /* 省略 */ },
    async deleteArea(id) { /* 省略 */ },
    async updateResource(id, patch) { /* 省略 */ },
  },
});

3.4 路由配置

router/index.js 中添加系统路由,集成到整体项目:

javascript 复制代码
import { createRouter, createWebHistory } from "vue-router";
const ParkSpaceManagement = () => import("../components/ParkSpaceManagement.vue");

export const router = createRouter({
  history: createWebHistory(),
  routes: [
    // 其他路由...
    {
      path: "/park",
      name: "ParkSpaceManagement",
      component: ParkSpaceManagement,
      meta: { title: "综合实战:基于Turfjs的智慧园区空间管理系统" },
    },
  ],
});

router.afterEach((to) => {
  if (to?.meta?.title) document.title = to.meta.title;
});

四、核心功能演示

4.1 区域管理

  1. 输入区域名称、闭合多边形坐标(格式:[[lon1,lat1],[lon2,lat2],...]),选择颜色后点击「添加区域」;
  2. 区域列表中可点击「定位」聚焦到该区域中心,或「删除」区域;
  3. 拖动滑块调整缓冲区半径,实时渲染区域的缓冲范围(用于安全 / 管控范围分析)。

4.2 路径规划

  1. 输入起点、终点经纬度(或点击三维场景快速拾取);
  2. 点击「规划路径」,系统基于路网数据计算最短路径并渲染,同时显示路径总长;
  3. 点击「清空」可清除当前路径。

4.3 资源定位

  1. 输入目标经纬度(或点击场景拾取),点击「定位最近资源」;
  2. 系统计算并显示最近的资源(如无人车、摄像头)及距离,同时相机聚焦到该资源;
  3. 资源列表中可「定位」资源、「更新坐标」并保存。

五、扩展与优化方向

  1. 后端对接 :完善 api/client.js 接口封装,实现区域、资源、路网数据的持久化;
  2. 性能优化
    • 对大规模路网数据做分块加载,避免前端计算压力;
    • 缓存 Turf.js 计算结果(如距离、缓冲区),减少重复计算;
  3. 功能扩展
    • 支持区域相交 / 包含关系校验;
    • 新增资源分类(如安防、巡检、运维),支持筛选;
    • 路径规划支持避障(如禁行区域);
  4. 交互优化
    • 增加坐标拾取的可视化提示;
    • 支持批量导入 / 导出地理数据(GeoJSON 格式);
  5. 样式优化:完善响应式布局,适配不同屏幕尺寸。

六、项目运行

  1. 克隆代码仓库:git clone https://gitee.com/YAY-404/turfjs-vue3-demo.git
  2. 安装依赖:npm install
  3. 配置 Cesium Token:在 .env 文件中添加 VITE_CESIUM_ION_TOKEN=你的Cesium Token
  4. 启动项目:npm run dev
  5. 访问系统:打开浏览器访问 http://localhost:5173/#/park
相关推荐
生活在一步步变好i2 小时前
模块化与包管理核心知识点详解
前端
午安~婉2 小时前
整理Git
前端·git
千寻girling2 小时前
Vue.js 前端开发实战 ( 电子版 ) —— 黑马
前端·javascript·vue.js·b树·决策树·随机森林·最小二乘法
程序员爱钓鱼2 小时前
Node.js 编程实战:博客系统 —— 数据库设计
前端·后端·node.js
m0_741412242 小时前
Webpack:F:\nochinese_path\React_code\webpack
前端·react.js·webpack
毕设源码-邱学长2 小时前
【开题答辩全过程】以 基于Web技术的知识付费平台为例,包含答辩的问题和答案
前端
困惑阿三2 小时前
利用 Flexbox 实现无需媒体查询(Media Queries)的自动响应式网格。
开发语言·前端·javascript
朝阳392 小时前
前端项目的 【README.md】详解
前端
浩冉学编程2 小时前
html中在某个父元素动态生成列表子元素,添加点击事件,利用事件委托
前端·javascript·html