做这个基于SpringBoot和Leaflet的全国省会城市风景区分布分析小系统,算是一次典型的“后端接口 + 地图可视化 + 数据统计”三位一体实践。后端用SpringBoot提供景区数据和统计接口,前端用Leaflet把景区直接撒在地图上,再配ECharts做省份排行、等级占比分析。整个过程涉及数据采集、坐标纠偏、接口设计、地图渲染、聚合优化几个环节,踩了不少坑,整理出来给准备做类似地理可视化项目的朋友参考。
先交代一下背景:我接手的是一个偏教学和展示性质的项目,要求在一个页面上把全国主要省会城市周边的重点风景区分布展示出来,同时能按省份、景区等级、景区类别做筛选和分析。数据量不算大,也就几百上千条,但涉及的环节很全——从数据清洗到后端聚合统计,再到前端联动交互,每一环都有值得记录的东西。这个项目做完之后,我觉得它很适合作为GIS可视化入门的完整范例,也适合作为SpringBoot接口设计的一次综合练习。
1. 项目定位与整体思路拆解
1.1 为什么选SpringBoot + Leaflet这个组合
先说后端。SpringBoot在这个场景里几乎是顺理成章的选择——它内置Tomcat,打包成jar就能跑,开发时热加载也很方便,对于“提供数据接口 + 统计汇总”这种需求再合适不过。我用的版本是2.7.x搭配JDK8,主要是考虑到部署环境兼容性和依赖稳定性。当然你完全可以用SpringBoot 3.x加JDK17,但如果你和我一样需要在老服务器上跑,2.7会更省心。
ORM层我选了MyBatis-Plus 3.5.x,原因很简单:单表查询和简单的分组统计用它的Wrapper就够了,复杂SQL自己写在XML里也方便。相比JPA,MyBatis-Plus对SQL的控制更直观,尤其在写“按省份分组统计景区数量”这类查询时,我脑子里想的是什么SQL,XML里就能直接写出什么SQL,没有额外的抽象负担。
前端选Leaflet而不是OpenLayers或Mapbox GL,核心逻辑是——项目要的只是轻量级点标注、聚合、弹窗和基础底图切换,Leaflet是最轻、最直接的工具。它的API简单到几乎看一眼文档就能上手,加上丰富的插件生态(聚合用Leaflet.markercluster、热力图用Leaflet.heat),完全覆盖需求。而且Leaflet默认使用WGS84坐标系,配合开放底图非常顺手,不需要处理复杂的投影转换。
1.2 功能边界与数据分析维度
这个项目的核心不是“做一个地图出来”,而是“从分布数据里看出东西”。所以我把分析维度定成了四个:
- 按省份统计景区总量和5A级景区数量,用来观察旅游资源在省级层面的集中程度;
- 按城市(省会城市)看单个城市的景区密度,比较城市间差异;
- 按景区等级看分布结构(5A、4A、3A),判断不同等级景区在全国的铺开情况;
- 按景区类别看类型结构(自然风光、历史遗迹、主题乐园、城市公园等),了解“旅游资源大省”到底是靠什么撑起来的。
功能上分三层:数据层负责存储和提供原始景区数据;接口层提供列表查询、筛选、统计三类API;展示层完成地图点位渲染、聚合、弹窗、图表联动和筛选交互。
1.3 技术选型的几个关键取舍
有些细节一开始没想清楚,做到中途才回头调整,这里把重要的取舍先摆出来:
- 底图选择:我用了OpenStreetMap标准瓦片,配合WGS84坐标数据,零转换直接对位。如果你的数据来源是高德或者腾讯地图那种已经偏移过的坐标(GCJ02),就需要做坐标纠偏,否则点位会差出几百米,后面我会专门讲这个坑。
- 数据量级判断:各省会城市加周边的主要景区,撑死也就几百上千条,这个量级根本不需要上空间数据库或后端空间计算,MySQL普通表加索引就够了。Leaflet对一千个点直接渲染也没大问题,但加上聚合插件以后体验会更好,缩放时不会出现满屏挤成一团的情况。
- 前后端是否分离:我一开始打算把前端独立出来用Vue开发,后来考虑到项目规模不大、还要经常演示,干脆把HTML页面扔进SpringBoot的static目录下,一个jar包全搞定。这样少了跨域配置、联调成本,演示的时候也不用开两个服务。
2. 数据准备:景区名录与坐标处理
2.1 景区数据从哪来
这可能是整个项目最“接地气”的一步。数据来源没有捷径,我是整理了一份全国主要省会城市(含直辖市、自治区首府)的代表性重点景区名录,覆盖5A和4A为主、少量3A补充。大概范围是每个省会城市整理6到15个景区,加上周边车程一小时内的著名景点。
数据字段一开始就定好:景区名称、所属省份、所属城市、景区等级(5A/4A/3A)、景区类别(自然景观/历史文化/主题公园/城市休闲/红色旅游等)、经度、纬度、一句话简介。整理的时候特别提醒自己注意几个问题:
- 不要混入非景区的地标。曾经有人把某城市的政府大楼旁公园也算进去了,严格来说这些是城市公共绿地,不是风景区,统计出来就是噪声。
- 同一景区不要重复录入。尤其跨市联合景区(比如某山水风景区同时挂两个市的牌子),要明确归属到主入口所在城市。
- 等级信息一定以官网或官方公示为准。5A级景区名录是动态调整的,如果你引用的数据源是两年前的,统计出来会跟实际情况有偏差。
2.2 坐标系的坑:WGS84还是GCJ02
这一节是全文最需要划重点的地方。国内做地图可视化,坐标系是第一个绕不开的坑。
简单梳理一下:国际通用坐标是WGS84,Google Earth、OpenStreetMap、大多数GPS设备都用这个标准;国内出于地理信息安全的考虑,在高德、腾讯等地图上开放出来的坐标是GCJ02(俗称“火星坐标”),也就是说你在高德开放平台拿到的坐标其实是经过偏移的,和真实WGS84坐标差了大概几百米到上千米不等;而百度地图又在GCJ02基础上做了二次偏移,变成了BD09。
这个项目里我选用的是公开整理的数据,坐标基准为WGS84,所以直接配合Leaflet默认的坐标系和OSM底图,完全不需要转换。如果你的数据是通过网页抓包或者调用国内地图API拿到的,大概率是GCJ02甚至BD09,这时候就必须转换。转换方式可以引入一个轻量处理库,在数据入库前或接口返回前做一次统一处理,把坐标统一到WGS84再落到数据库。特别注意,如果你拿GCJ02坐标直接配OSM底图,点位会明显偏移到马路上甚至跑到隔壁街区,演示起来非常尴尬。
2.3 建表结构与入库脚本
数据库表结构我设计得非常简单,满足当前需求但留了扩展余地:
CREATE TABLE scenic_spot ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(128) NOT NULL COMMENT '景区名称', province VARCHAR(64) NOT NULL COMMENT '省份', city VARCHAR(64) NOT NULL COMMENT '城市', level VARCHAR(16) NOT NULL COMMENT '等级:5A/4A/3A', category VARCHAR(32) NOT NULL COMMENT '类别', lng DECIMAL(10, 6) NOT NULL COMMENT '经度', lat DECIMAL(10, 6) NOT NULL COMMENT '纬度', intro VARCHAR(512) COMMENT '简介', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_province_city (province, city), INDEX idx_level (level), INDEX idx_category (category) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='重点景区信息表';几点说明:
- 经纬度用DECIMAL(10,6)而不是FLOAT,避免浮点精度带来的偏移误差。6位小数对应的精度大约0.1米,对景区点位绰绰有余。
- 联合索引idx_province_city是为后面的“按省份统计”设计的,虽然数据量小的时候索引意义不大,但这是好习惯。
- 类别字段我用的是字符串,虽然不规范了一点,但胜在直观。如果要做得更工程化,可以拆一张category字典表,这个项目规模不需要。
数据方面,我写了一个SQL脚本批量插入,每行一个景区。你也可以先用CSV整理好,再通过临时表导入,效率更高。入库后随手跑几个验证SQL:按省份统计数量、查某个城市是否有重复景区、抽查几个知名景区的经纬度是否和实际位置吻合。
3. 后端接口设计与实现
3.1 工程搭建与分层结构
SpringBoot工程结构我保持了最经典的分层方式:
com.example.scenic ├── ScenicApplication.java ├── common │ └── Result.java ├── config │ └── CorsConfig.java ├── controller │ ├── ScenicSpotController.java │ └── StatisticsController.java ├── entity │ └── ScenicSpot.java ├── mapper │ └── ScenicSpotMapper.java ├── service │ ├── ScenicSpotService.java │ └── impl/ScenicSpotServiceImpl.java └── vo ├── ScenicSpotVO.java ├── ProvinceStatVO.java └── LevelStatVO.javaController很薄,只负责参数接收和结果包装;Service里做业务逻辑和组装统计结果;Mapper只做单表查询。这个项目体量不需要过度设计,但分层还是要有,不然以后加需求会很痛苦。
前端页面统一放在src/main/resources/static目录下,打包进jar,这样整个应用就是一个可独立部署的SpringBoot jar。如果你的前端要单独部署,那就需要走CORS,我配置里也放了一份,双保险。
3.2 统一返回与跨域配置
接口返回格式统一成一个Result对象,前端处理起来非常顺畅:
@Data public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.code = 200; r.msg = "ok"; r.data = data; return r; } public static <T> Result<T> error(String msg) { Result<T> r = new Result<>(); r.code = 500; r.msg = msg; return r; } }设计接口时我坚持一个原则:接口语义要清楚,不要一个接口干所有事。所以拆了下面这几个:
| 接口 | 方法 | 作用 |
|---|---|---|
| /api/spots/list | GET | 按省份、城市、等级、类别组合条件查询景区列表 |
| /api/spots/geo | GET | 返回GeoJSON格式点位数据,直接供地图渲染 |
| /api/spots/statistics/province | GET | 返回各省份景区数量与5A数量统计 |
| /api/spots/statistics/level | GET | 返回各等级景区数量 |
| /api/spots/statistics/category | GET | 返回各类别景区数量 |
跨域配置是很常见的坑,尤其当前端页面不是从SpringBoot里加载,而是单独起了一个开发服务器时,请求会直接报跨域错误。我直接在配置里放了一个CorsFilter,允许常见来源:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }注意一点:addAllowedOrigin("*")在旧版本Spring里配了allowCredentials(true)会失效,所以我用addAllowedOriginPattern("*"),兼容性更好。
3.3 统计SQL的编写思路
统计接口是整个项目的另外一个难点,SQL写得好不好直接影响前端图表的呈现。省份统计的核心SQL大概是这样的:
<select id="selectProvinceStat" resultType="com.example.scenic.vo.ProvinceStatVO"> SELECT province AS province, COUNT(*) AS spotCount, SUM(CASE WHEN level = '5A' THEN 1 ELSE 0 END) AS fiveACount FROM scenic_spot GROUP BY province ORDER BY spotCount DESC </select>这段SQL的关键是SUM(CASE WHEN ...),相当于在分组汇总的同时完成了条件计数,比查出来在Java里再循环判断要高效整洁得多。同样的思路可以用在等级统计和类别统计上。
MyBatis-Plus的QueryWrapper也可以写类似的统计,但碰到CASE WHEN这类复杂一点的逻辑,XML里写SQL更清晰。我的习惯是:单表简单查询用Wrapper,聚合统计走XML。两条路线都不排斥,按场景选。
3.4 通讯协议:GeoJSON与JSON
地图渲染接口我直接返回了GeoJSON,而不是普通的JSON数组,这是后期优化时特意调整的。
GeoJSON是地图数据的事实标准格式,Leaflet原生支持L.geoJSON()方法解析,前端代码会简洁很多。一个典型的Feature是这样的:
{ "type": "FeatureCollection", "features": [ { "type": "Feature", "geometry": { "type": "Point", "coordinates": [113.650340, 34.745300] }, "properties": { "name": "示例风景区", "province": "河南", "city": "郑州", "level": "5A", "category": "历史文化", "intro": "景区简介" } } ] }后端组装这个结构也不复杂,一个循环往Map和List里塞数据就行。这里特别提醒:GeoJSON的坐标顺序是[经度, 纬度],和Leaflet默认的(纬度, 经度)参数顺序正好相反,一开始写反了点位会跑到完全错误的地方,排查的时候一定要先检查是不是这个问题。
Service层组装GeoJSON的核心代码片段:
public Map<String, Object> buildGeoJson(String city, String level, String category) { List<ScenicSpot> spots = scenicSpotMapper.selectByCondition(city, level, category); Map<String, Object> featureCollection = new HashMap<>(); featureCollection.put("type", "FeatureCollection"); List<Map<String, Object>> features = new ArrayList<>(); for (ScenicSpot spot : spots) { Map<String, Object> feature = new HashMap<>(); feature.put("type", "Feature"); Map<String, Object> geometry = new HashMap<>(); geometry.put("type", "Point"); geometry.put("coordinates", Arrays.asList(spot.getLng(), spot.getLat())); feature.put("geometry", geometry); Map<String, Object> props = new HashMap<>(); props.put("name", spot.getName()); props.put("province", spot.getProvince()); props.put("city", spot.getCity()); props.put("level", spot.getLevel()); props.put("category", spot.getCategory()); props.put("intro", spot.getIntro()); feature.put("properties", props); features.add(feature); } featureCollection.put("features", features); return featureCollection; }数据量不大时直接在内存循环完全没有性能问题。如果以后景点数据膨胀到几万条,还可以在SQL层面用ST_AsGeoJSON配合空间索引来做,那就是另一个故事了。
4. 前端Leaflet地图渲染
4.1 页面骨架与地图初始化
前端页面用原生HTML + JavaScript,没有引入前端框架,保持最简单直接的形态。主要分成两块区域:左侧是地图容器,右侧是统计图表区域,底部或顶部放筛选条件。
引入Leaflet和它的一些插件,我用的是CDN方式,简单直接:
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" /> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script> <script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script> <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>地图初始化代码很经典:
const MAP_CENTER = [35.86, 104.19]; const MAP_ZOOM = 4; const map = L.map('map', { center: MAP_CENTER, zoom: MAP_ZOOM, zoomControl: true, attributionControl: false }); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18, subdomains: ['a', 'b', 'c'] }).addTo(map);全国的视图中心一般定在[35.86, 104.19],缩放级别4可以看全貌。如果你想默认聚焦到某个省份,就把中心和缩放级别调整成对应的值,这个后面可以做成下拉切换。
4.2 点位渲染与弹窗交互
拿到后端返回的GeoJSON后,直接用L.geoJSON渲染:
let markersGroup = L.markerClusterGroup({ maxClusterRadius: 40, iconCreateFunction: function(cluster) { var count = cluster.getChildCount(); return L.divIcon({ html: '<div class="cluster-marker">' + count + '</div>', className: '', iconSize: [40, 40] }); } }); function loadSpots(filters) { let url = '/api/spots/geo'; // 根据filters拼接查询参数... let response = await fetch(url); let geojson = await response.json(); markersGroup.clearLayers(); L.geoJSON(geojson, { pointToLayer: function(feature, latlng) { let level = feature.properties.level; return L.circleMarker(latlng, { radius: level === '5A' ? 9 : 6, color: getLevelColor(level), weight: 2, fillOpacity: 0.7 }); }, onEachFeature: function(feature, layer) { layer.bindPopup(buildPopupHtml(feature.properties)); } }).eachLayer(function(layer) { markersGroup.addLayer(layer); }); markersGroup.addTo(map); map.fitBounds(markersGroup.getBounds().pad(0.05)); }这里有两个经验点:
第一,pointToLayer里按景区等级区分了圆点半径和颜色,5A用大圆点高亮色,4A用中等圆点,其他小圆点。这样从地图上一眼就能看出哪些城市5A级景区更密集。
第二,buildPopupHtml拼了一个带简介的弹窗,包含景区名、等级、类别和简短介绍。弹窗是Leaflet里最直接的交互方式,信息要精准,但不要塞进大量图文,否则弹层又长又慢,体验很差。
4.3 聚合与热力图
上千个标记如果直接撒在地图上,缩放级别低时会堆成一坨,根本没法看。聚合插件能自动把一定半径内的点聚合成一个数字气泡,缩放级别高时自动展开,效果非常好。
聚合的配置我调过几次,几个关键参数:
maxClusterRadius:聚合半径,默认80,我调到40,让聚合更精细一点;iconCreateFunction:自定义聚合气泡样式,我用了彩色圆形数字气泡,数字表示这个区域内景区数量;showCoverageOnHover:默认是true,鼠标悬停会显示聚合覆盖的圆形区域,视觉上有点花哨,我把它关掉了,更清爽。
热力图是另外一个视角。聚合看的是“数量分布”,热力图看的是“密度分布”。两者结合起来,就能看出全国景区在全国层面上的疏密格局:东部和中西部核心城市明显更密集,西部少数旅游大市以少量精品5A景区为主。
let heat = L.heatLayer(heatPoints, { radius: 25, blur: 18, maxZoom: 10, max: 1.0, gradient: {0.2: '#3388ff', 0.5: '#ffcc00', 0.8: '#ff6600'} });热力图数据直接从GeoJSON的coordinates里提取,不需要额外接口。注意热力层和聚合层要能切换,我加了一个底图工具栏,用Leaflet的LayerControl把普通点位、聚合层、热力层分成三个图层,用户按需切换。
4.4 ECharts联动统计视图
地图放进来了,分析也不能少。我用ECharts画了三张图:省份景区数量TOP10柱状图、景区等级分布饼图、景区类别分布玫瑰图。数据直接调统计接口,一次加载,不需要额外处理。
省份TOP10的数据结构是这样的:
fetch('/api/spots/statistics/province') .then(res => res.json()) .then(result => { let data = result.data.slice(0, 10); let names = data.map(d => d.province); let counts = data.map(d => d.spotCount); let fiveACounts = data.map(d => d.fiveACount); provinceChart.setOption({ tooltip: { trigger: 'axis' }, legend: { data: ['景区总数', '5A景区数'] }, xAxis: { type: 'category', data: names }, yAxis: { type: 'value' }, series: [ { name: '景区总数', type: 'bar', data: counts, barWidth: 20 }, { name: '5A景区数', type: 'bar', data: fiveACounts, barWidth: 20 } ] }); });我特意把“景区总数”和“5A景区数”做成双柱对比,这样能同时看出一个省份“总量大不大”和“精品多不多”。有的省份总量中等但5A数量靠前,说明走的是精品路线;有的省份总量大但5A占比低,说明旅游资源分散但盛名在外的少。
图表和地图还做了联动:点击柱状图中的某个省份,地图自动高亮对应省份的景点,同时筛选下拉框同步更新。联动逻辑不算复杂,就是往click事件里塞一段setUrl或重新请求的代码,但确实让整个页面从“各自独立展示”升级成了“可以交互分析的工具”,这算是我整个项目里最满意的一个设计。
5. 部署运行与常见问题排查
5.1 本地启动步骤与效果验收
整个项目启动非常轻量,步骤如下:
- 准备好MySQL,执行建表SQL并导入景区数据;
- 修改application.yml里的数据源连接配置;
- 运行ScenicApplication主类,或者打包后执行
java -jar scenic-1.0.jar; - 浏览器访问http://localhost:8080,加载index.html;
- 验收:地图能显示全国各省会城市的景区点位;缩放地图时点位聚合和展开正常;点击点位能弹出景区信息;右侧图表正确显示省份排行、等级占比;切换筛选条件后地图和图表同步刷新。
我自己的验收小结是:页面首屏加载大概几秒(主要是瓦片资源和ECharts资源),接口响应基本都是几十毫秒,交互流畅度是合格的。数据量不大,也没有引入多余依赖,整体运行非常稳。
5.2 高频问题排查速查表
这个项目折腾下来,真正容易让人卡住的就那几个点:
| 问题 | 现象 | 原因与对策 |
|---|---|---|
| 点位偏移到地图其他位置 | 景区圆点落在了错误街道或空白区域 | 坐标系不一致。确认数据是WGS84,如果数据是GCJ02或BD09,需先转换再入库 |
| 点位显示不出来 | 控制台报GeoJSON解析错误 | 检查坐标顺序。GeoJSON要求[经度, 纬度],L.geoJSON里默认也是这个顺序,不要手动再翻转一次 |
| 跨域403/No 'Access-Control-Allow-Origin' | 前端页面访问接口被浏览器拦截 | 前端和后端不同源时,在SpringBoot里配置CorsFilter,或者开发时用代理转发 |
| 聚合气泡不显示 | 聚合层为空 | 确认markersGroup.addTo(map)是否执行了,以及eachLayer遍历的layer是否正确加入 |
| 热力图发白 | 热力图区域显示不明显 | 降低radius和blur值,检查heatPoints数组是不是在页面加载时为空(因为接口可能还没返回) |
| 统计数据显示为null | 前端图表空值报错 | SQL中用IFNULL(count, 0)处理,注意SUM(CASE WHEN ...)在无匹配时会返回NULL |
5.3 后续可选扩展方向
这个项目做完之后,我没急着加需求,但心里已经列了好几个值得往下走的方向,供自己后期参考:
- 把景区数据扩大到全国几千个4A级景区,数据量上来之后再做后端分页 + 空间索引 + 多边形聚合,接近一个轻量级的“景区查询系统”;
- 增加路线规划和周边服务查询,比如以某个景区为中心,查询附近多少公里内的其他景区,这个用MySQL的haversine公式就能实现,数据量小也扛得住;
- 前端把静态页面升级成Vue或React单页应用,把地图组件化,筛选条件、图层控制、图表联动都拆成独立组件;
- 增加“人口迁徙量”或“游客接待量”做关联分析,那就需要另一套数据和建模了,属进阶玩法。
我个人在实际操作中的体会是,这个项目虽然技术上没有特别高深的东西,但它的价值在于把后端接口设计、数据处理、地图可视化和统计展示串成了完整的链路。做一遍下来,你就知道一个真实的地理可视化项目从零到一是怎么搭起来的。尤其是坐标系、GeoJSON顺序、聚合性能这些细节,不亲手踩一遍很难真正长记性。
最后再分享一个小小的经验:如果以后你还有类似的地理可视化需求,第一件事永远是确认坐标系统和数据格式,先把这个对齐,后面的开发会顺畅得多。数据、地图、分析这三个环节里,地图渲染往往是最不费神的,真正花时间的,永远是把数据准备得干净可靠。