SHP 文件地图查看器教学文档 基于vue-js-nodejs-postgis

142 阅读21分钟

SHP 文件地图查看器教学文档

1. 项目概述

SHP 文件地图查看器是一个基于 Web 的地理空间数据可视化应用,允许用户上传、查看、管理和保存 SHP 格式的地理数据文件。该应用提供了直观的用户界面,使用户能够轻松地与地理空间数据进行交互和分析,并支持 CGCS2000 国家大地坐标系(SRID 4490)。

主要功能特性:

  • 支持上传完整的 SHP 文件集(.shp, .shx, .dbf 等)
  • 可视化显示地理空间数据,并支持图层控制
  • 实现数据保存到后端 PostgreSQL/PostGIS 数据库功能,支持 CGCS2000 坐标系(SRID 4490)
  • 支持从数据库加载已保存的数据
  • 提供地图交互功能,如缩放、平移、图层切换等
  • 支持点击图层名称跳转到相应地图位置
  • 提供要素详情弹窗显示,包含属性信息的格式化展示

2. 系统架构与技术栈

系统架构

该项目采用前后端分离的架构设计:

前端:负责用户界面和交互,地理数据的解析和可视化 后端:负责数据接收、处理、存储和提供 API 接口

技术栈

前端技术:
  • Vue 3:前端框架,负责构建用户界面和状态管理
  • Leaflet.js:开源 JavaScript 地图库,用于地理数据可视化
  • shpjs:用于解析 SHP 文件的 JavaScript 库
  • Axios:用于与后端 API 通信的 HTTP 客户端
  • Vite:现代化前端构建工具
后端技术:
  • Node.js/Express.js:后端服务器框架
  • PostgreSQL/PostGIS:空间数据库,用于存储地理数据
  • Sequelize:ORM 框架,用于数据库操作,支持 PostGIS 空间扩展
  • 坐标系处理:实现 CGCS2000 国家大地坐标系(SRID 4490)支持

3. 项目结构

整体目录结构

leaflet-shp/
├── SHP文件地图查看器教学文档.md  # 项目教学文档
├── back/                  # 后端代码
│   ├── apis/              # API路由定义
│   │   ├── index.js       # API主入口
│   │   └── routers/       # 具体路由实现
│   │       └── upLoad.js  # 文件上传与读取路由
│   ├── app.js             # 后端应用入口
│   ├── models/            # 数据库模型
│   │   ├── gisModle.js    # GIS数据模型
│   │   ├── postgress.js   # 数据库连接配置
│   │   └── sync.js        # 模型同步脚本
│   ├── services/          # 业务逻辑服务
│   │   ├── index.js       # 服务主入口
│   │   └── upLoad.js      # 文件上传处理服务
│   ├── package.json       # 后端依赖配置
│   └── pnpm-lock.yaml     # 依赖版本锁定文件
└── former/                # 前端代码
    ├── src/               # 前端源代码
    │   ├── App.vue        # 主组件
    │   ├── assets/        # 静态资源
    │   ├── components/    # 组件目录
    │   └── main.js        # 前端入口
    ├── package.json       # 前端依赖配置
    ├── pnpm-lock.yaml     # 依赖版本锁定文件
    └── vite.config.js     # Vite配置

核心文件说明

后端文件
  • back/app.js:后端应用的主入口文件,负责启动服务和加载关键模块
  • back/apis/index.js:API 路由的主入口,配置 Express 中间件和路由
  • back/apis/routers/upLoad.js:处理文件上传和数据读取的 API 路由
  • back/services/upLoad.js:实现文件上传和数据读取的核心业务逻辑
  • back/models/gisModle.js:定义 GIS 数据模型,用于数据库交互
  • back/models/postgress.js:配置 PostgreSQL 数据库连接
前端文件
  • former/src/App.vue:前端应用的主组件,包含所有 UI 和交互逻辑
  • former/src/main.js:前端应用的入口文件
  • former/package.json:前端项目配置和依赖管理

4. 环境配置与安装

系统要求

  • Node.js (v20.19.0 或 >=22.12.0)
  • PostgreSQL (安装 PostGIS 扩展)

安装步骤

1. 克隆项目并安装依赖
# 克隆项目
cd leaflet-shp

# 安装后端依赖
cd back
pnpm install

# 安装前端依赖
cd ../former
pnpm install
2. 数据库配置

需要配置 PostgreSQL 数据库并启用 PostGIS 扩展:

注意:当前配置使用默认值,可在back/models/postgress.js文件中修改:

// back/models/postgress.js
const { Sequelize } = require("sequelize");
const sequelize = new Sequelize({
  dialect: "postgres",
  host: process.env.DB_HOST || "localhost",
  port: process.env.DB_PORT || "端口",
  username: process.env.DB_USER || "用户名",
  password: process.env.DB_PASSWORD || "密码",
  database: process.env.DB_NAME || "库名",
  pool: {
    max: 5,
    min: 0,
    acquire: 30000,
    idle: 10000,
  },
  dialectOptions: {
    encoding: "utf8",
    postgres: {
      extensions: ["postgis"], // 启用PostGIS扩展,支持空间数据类型
    },
  },
  logging: console.log,
});

module.exports = sequelize;

确保创建了名为gis的数据库,并在该数据库中启用 PostGIS 扩展:

-- 创建数据库
CREATE DATABASE gis;

-- 连接到数据库
\c gis;

-- 启用PostGIS扩展
CREATE EXTENSION postgis;

5. CGCS2000 坐标系实现详解

本项目完整实现了 CGCS2000 坐标系(SRID 4490)的支持,确保地理数据的坐标系统正确处理和存储。以下是 CGCS2000 坐标系在项目中的具体实现细节。

5.1 数据库级别的实现

在 PostgreSQL/PostGIS 数据库中,通过 Sequelize ORM 框架配置,使用 SRID 4490(CGCS2000 坐标系的标准标识符)来定义几何字段:

// back/models/gisModle.js
const { DataTypes } = require("sequelize");
const sequelize = require("./postgress");

const gisModel = sequelize.define(
  "gis",
  {
    // ...其他字段...
    wkb_geometry: {
      // 定义为MULTIPOLYGON类型,使用CGCS2000坐标系(SRID 4490)
      type: DataTypes.GEOMETRY("MULTIPOLYGON", 4490),
      allowNull: false,
    },
    // ...其他字段...
  },
  {
    tableName: "shp",
    timestamps: false,
  }
);

数据库连接配置中启用了 PostGIS 扩展,确保空间数据处理功能可用:

// back/models/postgress.js
const sequelize = new Sequelize({
  // ...基本连接配置...
  dialectOptions: {
    postgres: {
      extensions: ["postgis"], // 启用PostGIS扩展,支持空间数据类型
    },
  },
});

5.2 后端服务中的坐标转换与处理

在后端服务中,实现了几何类型转换和 CGCS2000 坐标系的添加功能:

// back/services/upLoad.js
// 处理几何类型和坐标系统
let geometryData = geometry;

// 如果是Polygon类型,转换为MULTIPOLYGON类型以匹配数据库定义
if (geometry.type === "Polygon") {
  console.log(`转换Polygon为MULTIPOLYGON`);
  geometryData = {
    type: "MultiPolygon",
    coordinates: [geometry.coordinates], // 将Polygon的coordinates包装在额外的数组中
  };
}

// 添加SRID信息(4490是CGCS2000坐标系)
const geoJSONWithSRID = {
  type: geometryData.type,
  coordinates: geometryData.coordinates,
  crs: {
    type: "name",
    properties: {
      name: "urn:ogc:def:crs:EPSG::4490",
    },
  },
};

5.3 数据保存时的坐标系处理

在保存数据时,确保每个地理要素都正确关联到 CGCS2000 坐标系:

// back/services/upLoad.js
// 创建一个新的记录
const newRecord = await gisModel.create({
  wkb_geometry: geoJSONWithSRID, // 包含CGCS2000坐标系信息的几何数据
  // ...其他属性字段...
});

5.4 数据读取时的坐标系保持

从数据库读取数据时,PostGIS 会自动保留几何数据的坐标系信息,确保数据在前端正确显示和处理:

// back/services/upLoad.js
// 创建GeoJSON要素,自动保留坐标系信息
const feature = {
  type: "Feature",
  geometry: record.wkb_geometry, // 从数据库读取的几何数据包含坐标系信息
  properties: {
    // ...属性字段...
  },
};

5.5 CGCS2000 坐标系的重要性

CGCS2000(2000 国家大地坐标系)是中国新一代的国家大地坐标系,项目实现该坐标系的支持具有以下重要意义:

  1. 符合国家规范:确保地理数据符合中国测绘地理信息行业的标准要求
  2. 数据精度保证:提供更准确的地理位置表示,特别是在中国区域
  3. 互操作性增强:便于与其他使用 CGCS2000 坐标系的系统进行数据交换
  4. 官方数据兼容:更好地兼容和利用国家官方发布的地理空间数据

6. 项目启动

完成环境配置和安装后,可以按照以下步骤启动项目:

6.1 启动后端服务

# 在back目录下
cd back
node app.js

后端服务将在端口 3000 启动,控制台会显示:服务器运行在端口http://localhost:3000

6.2 启动前端开发服务器

# 在former目录下
cd former
pnpm run dev

前端开发服务器启动后,可通过浏览器访问(通常是 http://localhost:5173/)

7. 前端实现详解

功能说明:允许用户上传完整的 SHP 文件集(.shp, .shx, .dbf 等)以在地图上显示。

操作步骤

  1. 点击"选择文件"按钮
  2. 选择包含完整 SHP 文件集的文件夹或直接选择所需文件
  3. 文件将显示在"已上传文件"列表中
  4. 点击"在地图上加载"按钮将文件加载到地图上

技术实现

  • 使用 HTML5 的文件 API 处理文件选择
  • 通过 shpjs 库解析 SHP 文件为 GeoJSON 格式
  • 检查必需文件(.shp 和.dbf)是否已上传
  • 文件加载后自动清空上传列表

关键代码

// former/src/components/app.vue
// 处理文件上传
const handleFileUpload = (event) => {
  const files = event.target.files;
  if (files.length === 0) return;

  // 清空之前的错误信息
  errorMessage.value = "";

  // 添加新文件到上传列表
  Array.from(files).forEach((file) => {
    // 检查文件类型
    const validExtensions = [".shp", ".shx", ".dbf", ".prj", ".cpg"];
    const fileExtension = file.name
      .toLowerCase()
      .substring(file.name.lastIndexOf("."));

    if (validExtensions.includes(fileExtension)) {
      // 检查文件是否已存在
      const isDuplicate = uploadedFiles.value.some(
        (existingFile) => existingFile.name === file.name
      );

      if (!isDuplicate) {
        uploadedFiles.value.push(file);
      }
    }
  });

  // 检查是否有必需的 SHP 文件
  checkRequiredFiles();

  // 清空文件输入,以便可以重新选择相同的文件
  event.target.value = "";
};

// former/src/components/ShpViewer.vue
// 检查是否有必需的 SHP 文件
const checkRequiredFiles = () => {
  const hasShpFile = hasFileWithExtension(uploadedFiles.value, "shp");
  const hasDbfFile = hasFileWithExtension(uploadedFiles.value, "dbf");
  const hasShxFile = hasFileWithExtension(uploadedFiles.value, "shx");

  // 至少需要.shp 文件,.dbf 和.shx 文件
  hasRequiredFiles.value = hasShpFile && hasDbfFile;
};

7. 前端实现详解

前端使用Vue 3和Leaflet.js构建,实现了SHP文件的上传、解析、可视化和交互功能。下面将详细介绍前端的核心实现。

7.1 文件上传处理

文件上传功能允许用户选择并上传完整的SHP文件集,系统会自动检查必需文件并进行分组处理。

// former/src/components/ShpViewer.vue
// 处理文件上传
const handleFileUpload = (event) => {
  const files = event.target.files;
  if (files.length === 0) return;

  // 清空之前的错误信息
  errorMessage.value = "";

  // 添加新文件到上传列表
  Array.from(files).forEach((file) => {
    // 检查文件类型
    const validExtensions = [".shp", ".shx", ".dbf", ".prj", ".cpg"];
    const fileExtension = file.name
      .toLowerCase()
      .substring(file.name.lastIndexOf("."));

    if (validExtensions.includes(fileExtension)) {
      // 检查文件是否已存在
      const isDuplicate = uploadedFiles.value.some(
        (existingFile) => existingFile.name === file.name
      );

      if (!isDuplicate) {
        uploadedFiles.value.push(file);
      }
    }
  });

  // 检查是否有必需的SHP文件
  checkRequiredFiles();

  // 清空文件输入,以便可以重新选择相同的文件
  event.target.value = "";
};

// former/src/components/ShpViewer.vue
// 检查文件类型的通用函数
const hasFileWithExtension = (files, extension) => {
  return files.some((file) =>
    file.name.toLowerCase().endsWith(`.${extension}`)
  );
};

// former/src/components/ShpViewer.vue
// 检查是否有必需的SHP文件
const checkRequiredFiles = () => {
  const hasShpFile = hasFileWithExtension(uploadedFiles.value, "shp");
  const hasDbfFile = hasFileWithExtension(uploadedFiles.value, "dbf");
  const hasShxFile = hasFileWithExtension(uploadedFiles.value, "shx");

  // 至少需要.shp文件和.dbf文件
  hasRequiredFiles.value = hasShpFile && hasDbfFile;
};

### 7.2 SHP文件解析与地图加载功能

系统使用shpjs库解析SHP文件,并将解析后的数据转换为GeoJSON格式,然后在地图上显示。该功能支持处理多个相关文件(.shp, .dbf等),并合并属性数据。

```javascript
// former/src/components/ShpViewer.vue
// 加载SHP文件
const loadShpFiles = async () => {
  try {
    // 清空之前的错误信息
    errorMessage.value = "";

    // 按文件名分组文件
    const filesByBaseName = {};
    uploadedFiles.value.forEach((file) => {
      const baseName = file.name.substring(0, file.name.lastIndexOf("."));
      if (!filesByBaseName[baseName]) {
        filesByBaseName[baseName] = [];
      }
      filesByBaseName[baseName].push(file);
    });

    // 处理每个SHP文件集
    for (const baseName in filesByBaseName) {
      const files = filesByBaseName[baseName];

      // 查找.shp文件
      const shpFile = files.find((file) =>
        file.name.toLowerCase().endsWith(".shp")
      );
      if (!shpFile) continue;

      // 创建文件对象映射
      const fileMap = {};
      files.forEach((file) => {
        const extension = file.name
          .substring(file.name.lastIndexOf("."))
          .toLowerCase();
        fileMap[extension] = file;
      });

      // 使用shpjs解析SHP文件
      try {
        // 读取文件为ArrayBuffer
        const readFileAsArrayBuffer = (file) => {
          return new Promise((resolve, reject) => {
            const reader = new FileReader();
            reader.onload = () => resolve(reader.result);
            reader.onerror = reject;
            reader.readAsArrayBuffer(file);
          });
        };

        // 将SHP文件转换为ArrayBuffer
        const shpBuffer = await readFileAsArrayBuffer(shpFile);

        // 检查是否有对应的DBF文件
        const dbfFile = files.find((file) =>
          file.name.toLowerCase().endsWith(".dbf")
        );
        let dbfBuffer = null;
        if (dbfFile) {
          dbfBuffer = await readFileAsArrayBuffer(dbfFile);
        }

        // 使用shpjs的parseShp方法解析SHP文件
        geojson = shp.parseShp(shpBuffer);

        // 如果有DBF文件,解析并合并属性
        if (dbfBuffer) {
          const records = shp.parseDbf(dbfBuffer);
          if (geojson && Array.isArray(geojson)) {
            geojson.forEach((feature, index) => {
              if (records[index]) {
                feature.properties = records[index];
              }
            });
          }
        }

        // 确保geojson格式正确
        if (!geojson || !Array.isArray(geojson)) {
          geojson = [];
        }

        // 将数组转换为FeatureCollection
        geojson = { type: "FeatureCollection", features: geojson };

        // 为每个要素添加空properties对象(如果不存在)
        geojson.features.forEach((feature) => {
          if (!feature.properties) {
            feature.properties = {};
          }
        });
      } catch (error) {
        console.error("解析SHP文件时出错:", error);
        errorMessage.value = `解析SHP文件时出错: ${error.message}`;
        return;
      }

      // 创建样式
      const style = {
        color: getRandomColor(),
        weight: 2,
        opacity: 0.8,
        fillOpacity: 0.4,
      };

      // 创建GeoJSON图层
      const geoJsonLayer = createGeoJsonLayer(geojson, style);
      geoJsonLayer.addTo(map.value);

      // 将图层添加到控制列表
      shapeLayers.value.push({
        name: baseName,
        layer: geoJsonLayer,
        visible: true,
        rawGeojson: geojson, // 保存原始的geojson数据
      });

      // 调整地图视野以显示所有要素
      adjustMapView([geoJsonLayer]);
    }

    // 加载完成后清空上传的文件列表
    uploadedFiles.value = [];
    hasRequiredFiles.value = false;
  } catch (error) {
    console.error("加载SHP文件时出错:", error);
    errorMessage.value = `加载文件时出错: ${error.message}`;
  }
};

// 生成随机颜色的辅助函数
const getRandomColor = () => {
  const letters = "0123456789ABCDEF";
  let color = "#";
  for (let i = 0; i < 6; i++) {
    color += letters[Math.floor(Math.random() * 16)];
  }
  return color;
};

// 调整地图视野的通用函数
const adjustMapView = (layers) => {
  try {
    if (layers.length > 0) {
      // 如果是单个图层
      if (layers.length === 1) {
        const singleLayer = layers[0];
        if (singleLayer && typeof singleLayer.getBounds === "function") {
          map.value.fitBounds(singleLayer.getBounds(), { padding: [50, 50] });
        } else {
          console.warn("图层无法获取边界,跳过视野调整");
        }
      } else {
        // 如果是多个图层,创建图层组
        const allLayers = layers.map((layer) => layer);
        const group = L.layerGroup(allLayers);

        // 确保group有getBounds方法
        if (group && typeof group.getBounds === "function") {
          map.value.fitBounds(group.getBounds(), { padding: [50, 50] });
        } else {
          console.warn("图层组无法获取边界,跳过视野调整");
        }
      }
    }
  } catch (error) {
    console.error("调整地图视野时出错:", error);
  }
};

7.3 图层控制功能

图层控制功能允许用户控制已加载图层的显示和隐藏,并支持点击图层名称跳转到相应地图位置。

// former/src/components/ShpViewer.vue
// 切换图层可见性
const toggleLayerVisibility = (index) => {
  const layerInfo = shapeLayers.value[index];
  layerInfo.visible = !layerInfo.visible;

  if (layerInfo.visible) {
    map.value.addLayer(layerInfo.layer);
  } else {
    map.value.removeLayer(layerInfo.layer);
  }
};

// former/src/components/ShpViewer.vue
// 跳转到指定图层的地图位置
const zoomToLayer = (index) => {
  const layerInfo = shapeLayers.value[index];
  if (layerInfo && layerInfo.layer) {
    // 使用现有的adjustMapView函数来调整地图视野
    adjustMapView([layerInfo.layer]);
  }
};

// former/src/components/ShpViewer.vue
// 地图缩放控制
const zoomIn = () => {
  map.value.zoomIn();
};

const zoomOut = () => {
  map.value.zoomOut();
};

// former/src/components/ShpViewer.vue
// 清空地图
const clearMap = () => {
  try {
    // 先关闭所有弹窗
    if (map.value) {
      map.value.closePopup();
    }

    // 然后移除所有图层
    shapeLayers.value.forEach((layerInfo) => {
      if (map.value && map.value.hasLayer(layerInfo.layer)) {
        map.value.removeLayer(layerInfo.layer);
      }
    });

    shapeLayers.value = [];
    uploadedFiles.value = [];
    hasRequiredFiles.value = false;
    errorMessage.value = "";
  } catch (error) {
    console.error("清空地图时出错:", error);
  }
};

// 创建GeoJSON图层的通用函数
const createGeoJsonLayer = (geojson, style) => {
  // 创建图层时设置样式和交互选项
  const geoJsonLayer = L.geoJSON(geojson, {
    style: style,
    // 为每个地理要素添加点击事件处理
    onEachFeature: (feature, layer) => {
      // 添加点击事件,显示要素信息
      layer.on("click", (e) => {
        // 创建要素属性信息的HTML内容
        let content = "<div class='popup-content'>";
        content += "<div class='popup-header'><h4>要素详情</h4></div>";
        content += "<div class='popup-body'>";

        // 添加几何类型信息
        if (feature.geometry && feature.geometry.type) {
          content += `<div class='geometry-type'>几何类型: ${feature.geometry.type}</div>`;
        }

        // 添加属性信息
        if (feature.properties && Object.keys(feature.properties).length > 0) {
          for (const [key, value] of Object.entries(feature.properties)) {
            content += `<div class='property-item'>`;
            content += `<span class='property-name'>${key}</span>`;
            content += `<span class='property-value'>${value}</span>`;
            content += `</div>`;
          }
        } else {
          content += "<div class='no-properties'>无可用属性</div>";
        }

        content += "</div></div>";

        // 创建并绑定自定义弹窗
        layer.bindPopup(content, {
          className: "custom-popup",
        });

        // 打开弹窗
        layer.openPopup(e.latlng);
      });
    },
  });

  return geoJsonLayer;
};

7.4 数据保存功能

功能说明:允许用户将当前加载的地理数据保存到后端 PostgreSQL/PostGIS 数据库。

操作步骤

  1. 加载 SHP 文件到地图上
  2. 点击"保存数据"按钮
  3. 系统将数据发送到后端并保存到数据库

技术实现

  • 收集当前所有加载图层的 GeoJSON 数据
  • 过滤无效数据,确保数据格式正确
  • 使用 Axios 发送 POST 请求到后端 API
  • 支持错误处理和用户提示

关键代码

// former/src/components/ShpViewer.vue
// 保存数据到后端
const saveData = async () => {
  try {
    // 检查是否有可保存的数据
    if (shapeLayers.value.length === 0) {
      errorMessage.value = "没有可保存的数据,请先加载SHP文件";
      return;
    }

    // 准备要保存的数据
    const saveData = shapeLayers.value
      .map((layerInfo) => {
        try {
          return {
            name: layerInfo.name,
            geojson: layerInfo.rawGeojson,
          };
        } catch (error) {
          console.error(`处理图层 ${layerInfo.name} 数据时出错:`, error);
          return null; // 标记此图层数据无效
        }
      })
      .filter((item) => item !== null); // 过滤掉无效数据

    // 检查过滤后的数据是否为空
    if (saveData.length === 0) {
      errorMessage.value = "所有图层数据都无法获取,请重新加载文件";
      return;
    }

    // 使用axios发送数据到后端
    const response = await axios.post("http://localhost:3000/upLoad", saveData);
    console.log("保存成功:", response.data);
    // 可以添加成功提示
  } catch (error) {
    console.error("保存数据时出错:", error);
    if (error.response) {
      // 服务器返回了错误状态码
      errorMessage.value = `保存数据时出错: ${error.response.status} ${error.response.statusText}`;
    } else if (error.request) {
      // 请求已发出但没有收到响应
      errorMessage.value = `保存数据时出错: 服务器无响应,请检查后端服务是否正常运行`;
    } else {
      // 其他错误
      errorMessage.value = `保存数据时出错: ${error.message || "未知错误"}`;
    }
  }
};

7.5 数据加载功能

功能说明:允许用户从后端 PostgreSQL/PostGIS 数据库加载已保存的地理数据。

操作步骤

  1. 点击"加载数据"按钮
  2. 系统将从数据库获取数据并显示在地图上

技术实现

  • 使用 Axios 发送 GET 请求到后端 API 获取数据
  • 清空当前地图并加载新数据
  • 根据数据创建地图图层并调整视野
  • 提供错误处理和用户提示

关键代码

// former/src/components/ShpViewer.vue
// 从后端加载数据
const loadData = async () => {
  try {
    // 先清空当前地图
    clearMap();

    // 使用axios从后端获取数据
    const response = await axios.get("http://localhost:3000/upLoad/read");
    const data = response.data;

    // 如果没有数据,显示提示信息
    if (!data || data.length === 0) {
      errorMessage.value = "没有找到可加载的数据";
      return;
    }

    // 处理加载的数据
    const addedLayers = [];
    data.forEach((item) => {
      try {
        // 创建样式
        const style = {
          color: item.color,
          weight: 2,
          opacity: 1,
          fillOpacity: 0.6,
        };

        // 创建GeoJSON图层
        const geoJsonLayer = createGeoJsonLayer(item.geojson, style);
        geoJsonLayer.addTo(map.value);

        // 将图层添加到控制列表
        shapeLayers.value.push({
          name: item.name,
          layer: geoJsonLayer,
          visible: true,
        });

        // 记录添加的图层,用于调整地图视野
        addedLayers.push(geoJsonLayer);
      } catch (error) {
        console.error(`加载图层 ${item.name} 时出错:`, error);
      }
    });

    // 如果有图层,调整地图视野
    if (addedLayers.length > 0) {
      adjustMapView(addedLayers);
    }
  } catch (error) {
    console.error("加载数据时出错:", error);
    if (error.response) {
      // 服务器返回了错误状态码
      errorMessage.value = `加载数据时出错: ${error.response.status} ${error.response.statusText}`;
    } else if (error.request) {
      // 请求已发出但没有收到响应
      errorMessage.value = `加载数据时出错: 服务器无响应,请检查后端服务是否正常运行`;
    } else {
      // 其他错误
      errorMessage.value = `加载数据时出错: ${error.message || "未知错误"}`;
    }
  }
};

// 清空地图
const clearMap = () => {
  try {
    // 先关闭所有弹窗
    if (map.value) {
      map.value.closePopup();
    }

    // 然后移除所有图层
    shapeLayers.value.forEach((layerInfo) => {
      if (map.value && map.value.hasLayer(layerInfo.layer)) {
        map.value.removeLayer(layerInfo.layer);
      }
    });

    shapeLayers.value = [];
    uploadedFiles.value = [];
    hasRequiredFiles.value = false;
    errorMessage.value = "";
  } catch (error) {
    console.error("清空地图时出错:", error);
  }
};

7. 后端 API 说明

7.1 上传数据 API

URLPOST http://localhost:3000/upLoad

功能:接收前端发送的地理数据并保存到 PostgreSQL/PostGIS 数据库

请求体

[
  {
    "name": "图层名称",
    "geojson": {
      /* GeoJSON数据 */
    }
  }
]

响应

  • 成功:HTTP 201 Created,返回保存的记录数据
  • 失败:HTTP 4xx/5xx,返回错误信息

7.2 读取数据 API

URLGET http://localhost:3000/upLoad/read

功能:从 PostgreSQL/PostGIS 数据库读取地理数据并返回给前端

响应

  • 成功:HTTP 200 OK,返回格式化的图层数据
[
  {
    "name": "图层名称",
    "geojson": {
      /* GeoJSON数据 */
    },
    "color": "#随机颜色"
  }
]
  • 失败:HTTP 4xx/5xx,返回错误信息

7.3 API 路由实现

// back/apis/routers/upLoad.js
const router = express.Router();
const { upLoadData, readData } = require("../../services/upLoad");

// 上传数据路由
router.post("/", upLoadData);
// 读取数据路由
router.get("/read", readData);

module.exports = router;

8. 数据模型说明

后端使用 Sequelize 定义了 GIS 数据模型,用于与 PostgreSQL/PostGIS 数据库交互。

主要字段:

  • ogc_fid:主键,自动递增
  • wkb_geometry:几何字段,存储 MULTIPOLYGON 类型的地理数据,使用 CGCS2000 坐标系(SRID 4490)
  • 其他属性字段:如tbrmcdkbmdkmc等,用于存储地理要素的属性信息

数据模型完整定义

// back/models/gisModle.js
const sequelize = require("./postgress");
const gisModel = sequelize.define(
  "gis",
  {
    ogc_fid: {
      type: DataTypes.INTEGER,
      primaryKey: true,
      autoIncrement: true,
    },
    wkb_geometry: {
      type: DataTypes.GEOMETRY("MULTIPOLYGON", 4490),
      allowNull: false,
    },
    tbrmc: {
      type: DataTypes.STRING(254),
      allowNull: true,
    },
    tbrjh: {
      type: DataTypes.STRING(254),
      allowNull: true,
    },
    zzzmc: {
      type: DataTypes.STRING(254),
      allowNull: true,
    },
    dkbm: {
      type: DataTypes.STRING(254),
      allowNull: true,
    },
    dkmc: {
      type: DataTypes.STRING(254),
      allowNull: true,
    },
    dkmj: {
      type: DataTypes.DECIMAL(19, 11),
      allowNull: true,
    },
    dkcbrxm: {
      type: DataTypes.STRING(254),
      allowNull: true,
    },
    dkcbrzj: {
      type: DataTypes.STRING(254),
      allowNull: true,
    },
    sftg: {
      type: DataTypes.STRING(254),
      allowNull: true,
    },
    tgzzmc: {
      type: DataTypes.STRING(254),
      allowNull: true,
    },
    tgzzjh: {
      type: DataTypes.STRING(254),
      allowNull: true,
    },
    cun: {
      type: DataTypes.STRING(20),
      allowNull: true,
    },
  },
  {
    tableName: "shp",
    timestamps: false,
  }
);

module.exports = gisModel;

9. 后端服务实现

9.1 数据保存服务

功能说明:实现将地理数据保存到 PostgreSQL/PostGIS 数据库的核心逻辑。

技术实现

  • 支持处理多图层数据
  • 处理不同格式的几何数据
  • 转换 Polygon 为 MultiPolygon 以匹配数据库定义
  • 添加 CGCS2000 坐标系(SRID 4490)支持
  • 提供完整的错误处理和日志记录

核心代码

// back/services/upLoad.js
const upLoadData = async (req, res) => {
  try {
    console.log("接收到保存数据请求");

    // 获取前端发送的数据(包含name和geojson的对象数组)
    const layersData = req.body;

    if (!layersData || !Array.isArray(layersData)) {
      console.error("无效的数据格式:", typeof layersData);
      return res.status(400).json({ error: "无效的数据格式,期望接收数组" });
    }

    const createdData = [];

    // 遍历每个图层数据
    for (const layerData of layersData) {
      const { name, geojson } = layerData;

      if (!geojson || !geojson.features || !Array.isArray(geojson.features)) {
        console.warn("跳过无效的GeoJSON数据");
        continue;
      }

      // 遍历每个GeoJSON要素
      for (let i = 0; i < geojson.features.length; i++) {
        const feature = geojson.features[i];

        // 提取属性
        const properties = feature.properties || {};

        // 处理几何数据
        let geometry;

        // 检查数据结构
        if (feature.geometry) {
          // 标准GeoJSON格式
          geometry = feature.geometry;
        } else if (feature.type === "Polygon" && feature.coordinates) {
          // 非标准格式:feature直接包含type和coordinates
          geometry = {
            type: "Polygon",
            coordinates: feature.coordinates,
          };
        } else {
          console.warn(`跳过要素${i}:无效的数据结构`);
          continue;
        }

        // 检查几何数据是否有效
        if (!geometry.type || !geometry.coordinates) {
          console.warn(`跳过要素${i}:无效的几何数据`);
          continue;
        }

        try {
          // 处理几何类型和坐标系统
          let geometryData = geometry;

          // 如果是Polygon类型,转换为MULTIPOLYGON类型以匹配数据库定义
          if (geometry.type === "Polygon") {
            geometryData = {
              type: "MultiPolygon",
              coordinates: [geometry.coordinates], // 将Polygon的coordinates包装在额外的数组中
            };
          }

          // 添加SRID信息(4490是CGCS2000坐标系,符合用户要求)
          const geoJSONWithSRID = {
            type: geometryData.type,
            coordinates: geometryData.coordinates,
            crs: {
              type: "name",
              properties: {
                name: "urn:ogc:def:crs:EPSG::4490",
              },
            },
          };

          // 创建一个新的记录
          const newRecord = await gisModel.create({
            wkb_geometry: geoJSONWithSRID,
            // 从properties中提取其他字段
            tbrmc: properties.tbrmc || name,
            tbrjh: properties.tbrjh || "",
            zzzmc: properties.zzzmc || "",
            dkbm: properties.dkbm || "",
            dkmc: properties.dkmc || name,
            dkmj: properties.dkmj || 0,
            dkcbrxm: properties.dkcbrxm || "",
            dkcbrzj: properties.dkcbrzj || "",
            sftg: properties.sftg || "",
            tgzzmc: properties.tgzzmc || name, // 使用图层名称作为默认值
            tgzzjh: properties.tgzzjh || "",
            cun: properties.cun || "",
          });

          console.log("成功创建记录:", newRecord.id);
          createdData.push(newRecord);
        } catch (recordError) {
          console.error("创建记录失败:", recordError);
          // 继续处理其他记录,而不是完全失败
        }
      }
    }

    console.log("保存数据完成,共创建", createdData.length, "条记录");
    res.status(201).json(createdData);
  } catch (error) {
    console.error("上传数据失败:", error);
    console.error("错误堆栈:", error.stack);
    res.status(500).json({
      error: "上传数据失败: " + error.message,
      stack: error.stack,
    });
  }
};

9.2 数据读取服务

功能说明:实现从 PostgreSQL/PostGIS 数据库读取地理数据并返回给前端的核心逻辑。

技术实现

  • 从数据库查询所有地理数据记录
  • 按图层名称(tgzzmc 字段)对数据进行分组
  • 转换数据格式为前端所需的 GeoJSON 格式
  • 为每个图层生成随机颜色
  • 提供完整的错误处理和日志记录

核心代码

// back/services/upLoad.js
const readData = async (req, res) => {
  try {
    console.log("接收到读取数据请求");
    const data = await gisModel.findAll();
    console.log("从数据库获取到", data.length, "条记录");

    const layersMap = new Map();

    data.forEach((record) => {
      try {
        // 获取图层名称(使用tgzzmc作为图层名称)
        const layerName = record.tgzzmc;

        // 如果图层不存在,则创建一个新图层
        if (!layersMap.has(layerName)) {
          layersMap.set(layerName, {
            name: layerName,
            features: [],
          });
        }

        // 提取图层信息
        const layerInfo = layersMap.get(layerName);

        // 创建GeoJSON要素
        const feature = {
          type: "Feature",
          geometry: record.wkb_geometry,
          properties: {
            tbrmc: record.tbrmc,
            tbrjh: record.tbrjh,
            zzzmc: record.zzzmc,
            dkbm: record.dkbm,
            dkmc: record.dkmc,
            dkmj: record.dkmj,
            dkcbrxm: record.dkcbrxm,
            dkcbrzj: record.dkcbrzj,
            sftg: record.sftg,
            tgzzmc: record.tgzzmc,
            tgzzjh: record.tgzzjh,
            cun: record.cun,
          },
        };

        // 将要素添加到图层中
        layerInfo.features.push(feature);
      } catch (recordError) {
        console.error("处理记录失败:", recordError);
        // 继续处理其他记录
      }
    });

    // 转换为前端期望的格式(包含name和geojson的对象数组)
    const result = Array.from(layersMap.values()).map((layerInfo) => ({
      name: layerInfo.name,
      geojson: {
        type: "FeatureCollection",
        features: layerInfo.features,
      },
      // 为图层添加随机颜色,以便在前端显示时使用
      color: getRandomColor(),
    }));

    console.log("数据处理完成,共生成", result.length, "个图层");
    res.status(200).json(result);
  } catch (error) {
    console.error("读取数据失败:", error);
    console.error("错误堆栈:", error.stack);
    res.status(500).json({
      error: "读取数据失败: " + error.message,
      stack: error.stack,
    });
  }
};

// 生成随机颜色的辅助函数
function getRandomColor() {
  const letters = "0123456789ABCDEF";
  let color = "#";
  for (let i = 0; i < 6; i++) {
    color += letters[Math.floor(Math.random() * 16)];
  }
  return color;
}

module.exports = {
  upLoadData,
  readData,
};

10. 地图交互与 UI 组件

10.1 地图初始化与配置

功能说明:负责初始化 Leaflet 地图实例,配置底图和地图控件。

技术实现

  • 使用 Leaflet.js 创建地图实例
  • 配置天地图作为底图
  • 设置初始中心点和缩放级别
  • 移除默认控件,使用自定义控件

核心代码

// former/src/components/ShpViewer.vue
// 初始化地图
const initMap = () => {
  // 创建地图实例
  map.value = L.map("map", {
    center: [33.589771122033845, 113.9829337799561],
    zoom: 18,
  });

  // 添加底图 天地图地图
  L.tileLayer(
    "http://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=4c5198bd810e465d6a3f09dc395b836e",
    {}
  ).addTo(map.value);

  // 添加点击事件监听(用于调试)
  map.value.on("click", (e) => {
    console.log(e.latlng);
  });

  // 禁用默认的缩放控件,使用自定义控件
  map.value.zoomControl.remove();
};

// 组件挂载时初始化地图
onMounted(() => {
  initMap();
});

// 组件卸载时清理地图
onUnmounted(() => {
  try {
    if (map.value) {
      // 先关闭所有弹窗
      map.value.closePopup();

      // 移除所有图层
      shapeLayers.value.forEach((layerInfo) => {
        if (map.value.hasLayer(layerInfo.layer)) {
          map.value.removeLayer(layerInfo.layer);
        }
      });

      // 然后移除地图实例
      map.value.remove();
      map.value = null;
    }
  } catch (error) {
    console.error("组件卸载时清理地图出错:", error);
  }
});

10.2 地图交互功能

功能说明:提供地图缩放、清空等基本交互功能。

技术实现

  • 使用 Leaflet 的内置方法实现缩放功能
  • 实现地图清空功能,移除所有图层

核心代码

// former/src/components/ShpViewer.vue
// 地图缩放控制
const zoomIn = () => {
  map.value.zoomIn();
};

const zoomOut = () => {
  map.value.zoomOut();
};

// 清空地图
const clearMap = () => {
  try {
    // 先关闭所有弹窗
    if (map.value) {
      map.value.closePopup();
    }

    // 然后移除所有图层
    shapeLayers.value.forEach((layerInfo) => {
      if (map.value && map.value.hasLayer(layerInfo.layer)) {
        map.value.removeLayer(layerInfo.layer);
      }
    });

    shapeLayers.value = [];
    uploadedFiles.value = [];
    hasRequiredFiles.value = false;
    errorMessage.value = "";
  } catch (error) {
    console.error("清空地图时出错:", error);
  }
};

10.3 要素交互与弹窗显示

功能说明:为地图要素添加交互事件和属性信息弹窗。

技术实现

  • 为每个 GeoJSON 要素添加鼠标悬停和点击事件
  • 实现自定义弹窗,显示要素的属性信息
  • 支持属性名中英文映射和格式化显示

核心代码

// former/src/components/ShpViewer.vue
// 创建GeoJSON图层的通用函数
const createGeoJsonLayer = (geojson, style, propertyNameMap = null) => {
  return L.geoJSON(geojson, {
    style: style,
    onEachFeature: (feature, layer) => {
      // 添加鼠标悬停效果
      layer.on("mouseover", () => {
        if (!map.value || !map.value.hasLayer(layer)) return;
        layer.setStyle({
          weight: 3,
          color: "#ff7e00",
          fillOpacity: 0.6,
        });
        if (!L.Browser.ie && !L.Browser.opera && !L.Browser.edge) {
          layer.bringToFront();
        }
      });

      layer.on("mouseout", () => {
        if (!map.value || !map.value.hasLayer(layer)) return;
        layer.setStyle(style);
      });

      // 添加弹窗
      if (feature.properties) {
        const popupContent = generatePopupContent(feature, propertyNameMap);
        layer.bindPopup(popupContent, {
          className: "custom-popup",
          maxWidth: 400,
          minWidth: 280,
          closeButton: true,
          autoClose: true,
          closeOnClick: true,
          keepInView: true,
          autoPan: true,
          animate: false,
        });
      }

      // 添加点击事件处理
      layer.on("click", () => {
        if (!map.value || !map.value.hasLayer(layer)) return;

        try {
          // 高亮显示当前选中的要素
          highlightSelectedFeature(layer);

          // 主动触发弹窗显示
          if (feature.properties) {
            layer.openPopup();
          }
        } catch (error) {
          console.error("处理点击事件时出错:", error);
        }
      });
    },
  });
};

// former/src/components/ShpViewer.vue
// 生成弹窗内容的通用函数
const generatePopupContent = (feature, propertyNameMap = null) => {
  let popupContent = `<div class="popup-content">`;

  // 添加属性表格
  let hasProperties = false;

  // 默认属性名中英文映射表
  const defaultPropertyNameMap = {
    // 土地相关属性
    tbrmc: "名字",
    tbrzjh: "身份证号",
    tbrzj: "图斑总面积",
    zzzmc: "地类名称",
    dkbm: "地块编码",
    dkmc: "地块名称",
    dkmj: "地块面积",
    dkcbrxm: "地块承包责任人姓名",
    dkcbrzj: "地块承包责任证号",
    layer: "图层",
    cun: "村",
    // 可根据实际数据继续扩展
  };

  // 使用传入的映射表或默认映射表
  const nameMap = propertyNameMap || defaultPropertyNameMap;

  // 需要排除的属性列表
  const excludedProperties = [
    "fid",
    "sftg",
    "tgzzmc",
    "tgzzjhj",
    "tgzzzjh",
    "path",
    "tbrjh",
    "tgzzjh",
  ];

  // 创建属性表格
  popupContent += `<div class="property-table">`;
  // 添加CSS样式以支持长文本换行
  popupContent += `<style>
      .custom-popup .property-table { display: table; width: 100%; }
      .custom-popup .property-row { display: table-row; padding: 4px 0; border-bottom: 1px solid #eee; }
      .custom-popup .property-label { display: table-cell; font-weight: bold; padding: 4px 8px 4px 0; min-width: 100px; vertical-align: top; }
      .custom-popup .property-data { display: table-cell; padding: 4px 0 4px 8px; word-wrap: break-word; word-break: break-all; vertical-align: top; }
    </style>`;

  for (const key in feature.properties) {
    if (feature.properties.hasOwnProperty(key)) {
      // 跳过需要排除的属性
      if (excludedProperties.includes(key.toLowerCase())) {
        continue;
      }

      hasProperties = true;
      let value = feature.properties[key];
      // 处理空值
      if (value === null || value === undefined || value === "") {
        value = "<em>空</em>";
      } else if (typeof value === "string") {
        // 将逗号分隔的多个号码转为每行一个号码
        if (value.includes(",")) {
          const items = value.split(",");
          // 检查是否是由逗号分隔的多个编号
          if (
            items.length > 1 &&
            items.every((item) => item.trim().length > 5)
          ) {
            value = items.map((item) => item.trim()).join("<br>");
          }
        }
        // 处理过长文本
        else if (value.length > 200) {
          value = value.substring(0, 200) + "...";
        }
      }
      // 获取中文属性名,如果不存在则使用原属性名
      const chineseKey = nameMap[key.toLowerCase()] || key;
      popupContent += `<div class="property-row">
                        <span class="property-label">${chineseKey}</span>
                        <span class="property-data">${value}</span>
                      </div>`;
    }
  }

  if (!hasProperties) {
    popupContent += `<div class="no-properties">无属性信息</div>`;
  }

  popupContent += `</div>`;
  popupContent += `</div>`;

  return popupContent;
};

// former/src/components/ShpViewer.vue
// 高亮选中要素的通用函数
const highlightSelectedFeature = (selectedLayer) => {
  shapeLayers.value.forEach((layerItem) => {
    if (
      layerItem.layer !== selectedLayer &&
      map.value.hasLayer(layerItem.layer)
    ) {
      layerItem.layer.setStyle(layerItem.layer.options.style);
    }
  });
  selectedLayer.setStyle({
    weight: 3,
    color: "#ff7e00",
    fillOpacity: 0.6,
  });
};

11. 常见问题与解决方案

11.1 文件上传相关问题

问题:上传文件后"在地图上加载"按钮不可用 解决方法:确保上传了必需的文件(至少需要.shp 和.dbf 文件)

问题:解析 SHP 文件时出错 解决方法:检查文件格式是否正确,确保上传的是有效的 SHP 文件集

11.2 地图显示相关问题

问题:图层不显示在地图上 解决方法:确保图层的"visible"属性为 true,可通过图层控制的复选框控制

问题:点击图层名称不跳转 解决方法:确保图层数据正确加载,且图层对象包含有效的边界信息

11.3 数据保存与加载问题

问题:保存数据时出现网络错误 解决方法:确保后端服务正常运行,检查网络连接和跨域设置

问题:加载数据后图层显示异常 解决方法:检查数据库中的数据格式是否正确,确保几何数据符合 GeoJSON 标准

11.4 性能优化建议

  1. 对于大型 SHP 文件,考虑在后端进行预处理,减少前端解析的压力
  2. 对于复杂的地理数据,可考虑使用矢量切片技术提高渲染性能
  3. 优化数据库查询,使用空间索引提高数据检索效率

12. 开发指南

12.1 添加新功能

添加新的 API 端点

  1. back/apis/routers/目录下创建新的路由文件
  2. back/services/目录下实现对应的业务逻辑
  3. back/apis/index.js中注册新的路由

添加新的前端组件

  1. former/src/components/目录下创建新的组件文件
  2. 在需要使用的地方导入并注册组件
  3. 根据需要添加相应的样式和交互逻辑

12.2 修改数据模型

  1. 修改back/models/gisModle.js中的模型定义
  2. 重启后端服务,Sequelize 会自动应用模型更改(通过alter: true配置)
  3. 如有必要,可以手动运行数据库迁移脚本

13. 部署指南

前端部署

  1. 构建前端应用:

    cd former
    pnpm run build
    
  2. 构建后的文件将生成在former/dist目录中,可以部署到任何静态文件服务器

后端部署

  1. 确保目标服务器上安装了 Node.js 和 PostgreSQL

  2. 配置环境变量,设置数据库连接参数

  3. 启动后端服务:

    cd back
    node app.js
    
  4. 对于生产环境,建议使用 PM2 等进程管理器来管理 Node.js 应用

14. 总结

SHP 文件地图查看器是一个功能完整的地理空间数据可视化应用,它使用现代 Web 技术栈实现了 SHP 文件的上传、解析、可视化、保存和加载等功能。该应用采用前后端分离的架构设计,具有良好的可扩展性和可维护性。通过本教学文档,您应该能够理解项目的整体架构、核心功能和使用方法,并能够进行基本的安装、配置和开发工作。