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 国家大地坐标系)是中国新一代的国家大地坐标系,项目实现该坐标系的支持具有以下重要意义:
- 符合国家规范:确保地理数据符合中国测绘地理信息行业的标准要求
- 数据精度保证:提供更准确的地理位置表示,特别是在中国区域
- 互操作性增强:便于与其他使用 CGCS2000 坐标系的系统进行数据交换
- 官方数据兼容:更好地兼容和利用国家官方发布的地理空间数据
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 等)以在地图上显示。
操作步骤:
- 点击"选择文件"按钮
- 选择包含完整 SHP 文件集的文件夹或直接选择所需文件
- 文件将显示在"已上传文件"列表中
- 点击"在地图上加载"按钮将文件加载到地图上
技术实现:
- 使用 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 数据库。
操作步骤:
- 加载 SHP 文件到地图上
- 点击"保存数据"按钮
- 系统将数据发送到后端并保存到数据库
技术实现:
- 收集当前所有加载图层的 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 数据库加载已保存的地理数据。
操作步骤:
- 点击"加载数据"按钮
- 系统将从数据库获取数据并显示在地图上
技术实现:
- 使用 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
URL:POST http://localhost:3000/upLoad
功能:接收前端发送的地理数据并保存到 PostgreSQL/PostGIS 数据库
请求体:
[
{
"name": "图层名称",
"geojson": {
/* GeoJSON数据 */
}
}
]
响应:
- 成功:HTTP 201 Created,返回保存的记录数据
- 失败:HTTP 4xx/5xx,返回错误信息
7.2 读取数据 API
URL:GET 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)- 其他属性字段:如
tbrmc、dkbm、dkmc等,用于存储地理要素的属性信息
数据模型完整定义:
// 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 性能优化建议
- 对于大型 SHP 文件,考虑在后端进行预处理,减少前端解析的压力
- 对于复杂的地理数据,可考虑使用矢量切片技术提高渲染性能
- 优化数据库查询,使用空间索引提高数据检索效率
12. 开发指南
12.1 添加新功能
添加新的 API 端点:
- 在
back/apis/routers/目录下创建新的路由文件 - 在
back/services/目录下实现对应的业务逻辑 - 在
back/apis/index.js中注册新的路由
添加新的前端组件:
- 在
former/src/components/目录下创建新的组件文件 - 在需要使用的地方导入并注册组件
- 根据需要添加相应的样式和交互逻辑
12.2 修改数据模型
- 修改
back/models/gisModle.js中的模型定义 - 重启后端服务,Sequelize 会自动应用模型更改(通过
alter: true配置) - 如有必要,可以手动运行数据库迁移脚本
13. 部署指南
前端部署
-
构建前端应用:
cd former pnpm run build -
构建后的文件将生成在
former/dist目录中,可以部署到任何静态文件服务器
后端部署
-
确保目标服务器上安装了 Node.js 和 PostgreSQL
-
配置环境变量,设置数据库连接参数
-
启动后端服务:
cd back node app.js -
对于生产环境,建议使用 PM2 等进程管理器来管理 Node.js 应用
14. 总结
SHP 文件地图查看器是一个功能完整的地理空间数据可视化应用,它使用现代 Web 技术栈实现了 SHP 文件的上传、解析、可视化、保存和加载等功能。该应用采用前后端分离的架构设计,具有良好的可扩展性和可维护性。通过本教学文档,您应该能够理解项目的整体架构、核心功能和使用方法,并能够进行基本的安装、配置和开发工作。