SpringBoot+Leaflet构建景区地图:从数据清洗到可视化分析全实践 前一阵接了一个小项目需求看起来不复杂把全国省会城市的风景区信息整理后放到网页地图上再顺便做点分布层面的分析。我选了 SpringBoot Leaflet 这套组合前后大概花了两周落地。这里把完整的设计思路、数据清洗、后端接口、前端渲染以及分布分析的套路都整理出来给后面想做类似地图项目的朋友一个参考。这套东西做完之后的实际效果是页面首屏渲染大概2秒几百个景区坐标点在地图上缩放、聚合都不卡后端接口在普通配置下响应能做到几十毫秒内更重要的是通过这套可视化能直接看到景区密度东高西低、高等级景区向个别省会城市集中的明显趋势。对想拿地图项目练手、或者需要做旅游数据可视化的人来说这篇文章应该能帮你省掉不少弯路。1. 整体方案选型与思路拆解1.1 为什么选 SpringBoot Leaflet而不是其他组合先说后端。SpringBoot 几乎是国内绝大多数项目团队最熟悉的基础框架起步快、社区资料多、内嵌 Tomcat 部署也省事。对于这种数据接口不算复杂、业务逻辑主要是查询和聚合分析的项目用 SpringBoot 配合 MyBatis 或者 Spring Data JPA分层写起来非常顺手。有人可能会想这种量级用 Flask 或者 Node.js 不更轻吗确实也可以但考虑到后续可能要接权限、定时任务、导出报表等功能Java 生态的组件更齐全长期维护成本更低。前端选 Leaflet 的原因更直接它非常轻整个核心库压缩后只有 40KB 左右基础地图、Marker、Popup、图层控制这些功能开箱即用。相比之下 OpenLayers 功能更强但学习曲线陡一点配置概念也多Mapbox GL 视觉效果更好但用它的在线服务通常需要申请 token在需要完全内网部署的场景下就多了一道麻烦。Leaflet 只需要一个瓦片地址就能跑起来各种扩展插件也够用做景区分布这种“点数据可视化”的场景性价比是最高的。这个项目还涉及分布分析所以前端除了地图还需要配合统计图表。我的做法是地图渲染用 Leaflet统计图形用 ECharts两边通过同一个数据源联动。这样既保持代码简单又能做出“地图上看位置、图表上看趋势”的效果分析能力并不弱于一套重型的 GIS 展示系统。1.2 功能边界与数据流设计接到需求后我没有急着写代码先把功能边界划清楚了。表面上这是个地图展示页面但用户真正想要的其实是三件事在地图上看到全部景区的位置并且能按城市、按等级筛选能够了解不同省会城市的景区数量排名能够看到景区类型、等级在空间上的分布规律。换句话说这不只是“把所有点画上去”而是“用地图承载分析”。按照这个定位我确定了下面这条数据流数据源多份表格、网页抓取、历史台账 - 清洗入库 - SpringBoot 后端聚合查询 - 前端通过接口拉取 GeoJSON - Leaflet 渲染地图、ECharts 渲染统计图。在接口设计上我刻意区分了两类接口一类是原始点位接口返回带经纬度的 FeatureCollection供地图打点另一类是统计接口返回城市、等级、类型的聚合结果只给图表用。分开之后的好处是地图接口的请求量可能很大但统计数据可以按需刷新互不干扰也方便将来给接口加缓存。1.3 前端地图渲染策略提前定好全国省会的景区数据积累到几百个点之后如果全部用 Leaflet 默认的 Marker 直接画上去缩放拖拽时会明显掉帧。所以我在项目一开始就决定引入leaflet.markercluster插件做点聚合缩小地图时相距较近的点合并成一个带数字的聚合团放大地图时聚合团自动散开变成单个点。这个策略是地图性能的基础后面所有的 Marker 操作都要基于这个思路。另外我也规定了一个小原则弹窗内容不能提前全部生成只有用户点击某个 Marker 的时候再通过绑定好的bindPopup去渲染。用 Leaflet 的bindPopup绑定数据时一般是传一个字符串或者一个函数等到真正触发弹窗才执行这样大量点也不会在初始化时抢占渲染资源。前端看起来简单的点其实很多性能问题都是在这里出现的。2. 数据准备与质量校验2.1 风景区数据结构设计数据是地图应用的地基。我整理的景区信息表结构大致如下字段类型说明idbigint主键namevarchar景区名称province_codevarchar所在省份编码city_namevarchar所在城市名称scenic_levelvarchar等级比如 5A、4A、3Ascenic_typevarchar类型分类自然山水、历史文化、主题园区longitudedecimal经度WGS84 或 GCJ-02 统一latitudedecimal纬度update_timedatetime更新时间实际做表的时候我还加了一个source字段用来记录数据是从哪个渠道来的。这个字段看起来不起眼但后面做数据校验和数据追溯时特别管用。某个景区信息如果明显不对直接看source就知道是哪一份原始资料出了问题不用到处翻文件。索引方面我建了一个组合索引(province_code, scenic_level)因为这正好对应了地图页面的主要筛选条件。如果查询里经常用到scenic_type把scenic_type也放进组合索引里也可行。核心思路是预先想好前端会用什么筛选条件让索引跟上查询而不是等接口慢了再反过头来改表。2.2 坐标采集与坐标系避坑景区经纬度并不是想象中那样直接拿来就用。国内很多平台输出的坐标并不是“裸坐标”而是经过偏移处理的加密坐标常见的是 GCJ-02 坐标系Leaflet 底图如果直接使用卫星影像或者某些商业底图这样的坐标系可能和标准 WGS84 存在几十米到几百米的偏移。如果数据混用多个来源又不做统一就会出现“景区跑到马路上”“点在湖心”的怪象。我的做法是在入库环节统一转成 WGS84。写了个简单的坐标转换工具类内部用的是公开的转换公式逻辑不复杂核心代码可以这么看public class CoordTransform { private static final double PI Math.PI; private static final double AXIS 6378245.0; private static final double OFFSET 0.006693421622965943D; public static double[] gcj02ToWgs84(double lng, double lat) { if (outOfChina(lng, lat)) { return new double[]{lng, lat}; } double[] d delta(lng, lat); return new double[]{lng - d[0], lat - d[1]}; } private static double[] delta(double lng, double lat) { // 项目里实际实现了偏移量计算 // 这里省略的是一组转换数学公式根据经纬度计算偏移量 return new double[]{0.0, 0.0}; } }更关键的是所有采集到的景区坐标入库前我都会随机抽一批人工校验打开地图逐一核对这些点的名称和位置是否对得上。这个过程很枯燥但绝不能跳过因为分布分析的前提就是坐标位置真实可信。注意如果你的底图用的是一个带 GCJ-02 偏移的在线瓦片源那你就不应该把数据转成 WGS84而应该反过来把数据转成 GCJ-02。坐标系的选择要和底图匹配不能只看单边。2.3 数据清洗与扩充原始数据永远比我预想的要脏。常见的问题有这么几类同一个景区在不同表里名称写法不一致有的带“风景名胜区”有的只写简称城市字段写的是“某市某县”而我们需要的是“某市”要做行政区划匹配部分景区缺少经纬度只能根据文字描述重新定位还有一些点明显定位到了错误的省界外比如应该在黄河南岸的却标到了北岸。针对名称不一致我先把所有景区名称和地标词做了一次去重和标准化。流程是读取所有记录做简单正则清洗比如去掉头尾空格、把全角括号转半角、统一“森林公园”“风景名胜区”等后缀再按照名称所在城市做匹配相同名称但不同城市的保留为两条相同城市相同名称的合并成一条。这样处理后原始数据量大概缩了六分之一但质量明显提升。行政区划匹配这块我在数据库里准备了一张省市区映射表每次清洗时用city_name LIKE CONCAT(city, %)这类方式去匹配城市编码。因为只到省会城市一级城市关系比较简单不需要引入完整的行政区划库。如果将来要细化到区县再考虑用专门的数据包。3. 后端接口开发与实现3.1 项目结构与依赖配置后端项目结构保持经典的三层架构controller // 接口层 service // 业务逻辑与聚合计算 repository // 数据访问层 entity // 实体类 dto // 接口返回对象SpringBoot 的依赖非常简单只需要引入 Web、Validation、数据库访问相关组件。下面是pom.xml里的核心依赖片段dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency !-- 按实际需要选择 JPA 或 MyBatis -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency /dependencies配置文件里我习惯把数据库连接信息和地图瓦片地址分开放方便不同环境切换server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/scenic_map?useUnicodetruecharacterEncodingutf8useSSLfalse username: root password: root这里故意用useUnicodetruecharacterEncodingutf8来避免后面接口查询结果中文乱码很多人数据库连接没配这个参数前端展示中文时莫名出现问号排查半天才发现是 JDBC 字符集的问题。3.2 输出 GeoJSON 格式的地图点位接口Leaflet 原生支持 GeoJSON所以后端最好直接返回标准 GeoJSON 结构不要自己发明一套“经纬度数组”给前端再加工。Leaflet 拿到 GeoJSON 后可以直接L.geoJSON().addTo(map)省掉大量手动创建 Marker 的代码。接口设计如下RestController RequestMapping(/api/scenic) public class ScenicSpotController { private final ScenicSpotService scenicSpotService; public ScenicSpotController(ScenicSpotService scenicSpotService) { this.scenicSpotService scenicSpotService; } /** * 返回符合 GeoJSON 规范的景区点位集合 */ GetMapping(/points) public MapString, Object getPoints( RequestParam(required false) String provinceCode, RequestParam(required false) String scenicLevel) { return scenicSpotService.buildGeoJson(provinceCode, scenicLevel); } }对应的 Service 里核心部分是把景点列表转换成 GeoJSON FeatureCollectionpublic MapString, Object buildGeoJson(String provinceCode, String scenicLevel) { ListScenicSpot spots scenicSpotRepository.findByCondition(provinceCode, scenicLevel); ListMapString, Object features new ArrayList(); for (ScenicSpot spot : spots) { MapString, Object geometry new LinkedHashMap(); geometry.put(type, Point); geometry.put(coordinates, Arrays.asList(spot.getLongitude(), spot.getLatitude())); MapString, Object properties new LinkedHashMap(); properties.put(name, spot.getName()); properties.put(level, spot.getScenicLevel()); properties.put(type, spot.getScenicType()); properties.put(cityName, spot.getCityName()); MapString, Object feature new LinkedHashMap(); feature.put(type, Feature); feature.put(properties, properties); feature.put(geometry, geometry); features.add(feature); } MapString, Object result new LinkedHashMap(); result.put(type, FeatureCollection); result.put(features, features); return result; }这段代码的重点是properties里只放前端打点和弹窗需要的最小字段避免把整条数据库记录都传出去。数据量小的时候区别不明显等点位上了几百上千每个多余字段都会延长浏览器解析时间。输出顺序也建议用LinkedHashMap保证 JSON 字段顺序稳定调试和缓存友好。3.3 分布统计接口的设计除了点位分布分析还需要三个统计维度各省会城市景区数量排名各等级景区数量构成各类型景区数量构成。这三个接口可以分开写也可以合成一个聚合接口。我在项目中是分成两个一个是“按城市汇总”一个是“按等级和类型汇总”。分开的好处是页面图表可以独立刷新不需要一次把所有统计全部算完。统计接口的实现用 SQL 聚合最方便。比如按城市汇总可以这样查SELECT city_name AS cityName, COUNT(*) AS spotCount FROM scenic_spot GROUP BY city_name ORDER BY spotCount DESC;在 Spring Data JPA 里可以用原生 SQL 配合 DTO 映射也可以直接返回ListMapString, Object。实际项目中为了返回结构清晰我定义了一个简单的 DTOpublic class CityStatDto { private String cityName; private Long spotCount; // getter、setter 省略 }统计接口的 SQL 虽然有GROUP BY但数据量并不大所以没有刻意做缓存。不过后续如果加了用户量我会给统计接口加一层Cacheable因为统计结果直到数据更新前都不会变化属于典型的读多写少场景。4. Leaflet 端可视化落地4.1 地图初始化与底图设置前端页面最简单的方式是直接写一个独立的 HTML引入 Leaflet 的 CSS 和 JS再加上 ECharts。地图容器必须显式设置高度否则你会看到一片空白。div idmap styleheight: 720px;/div初始化地图时我建议把视图中心放在全国范围缩放等级设置成能看到整个东部沿海和中部地区。代码const map L.map(map, { center: [34.5, 106.5], zoom: 5, zoomControl: true }); const tileLayer L.tileLayer(https://{s}.tile.example.com/{z}/{x}/{y}.png, { maxZoom: 18, attribution: map data }).addTo(map);实际部署时瓦片地址要换成你自己可用的图层源。如果项目跑在内网还要提前准备好内网瓦片或者离线瓦片包这一点比 Leaflet 的 API 本身更容易被忽略。4.2 用 MarkerCluster 渲染点位绑定弹窗拿到后端接口返回的数据后我不会直接用L.geoJSON静态加载因为点位多之后缺乏控制。我选择创建一个markerClusterGroup然后把 GeoJSON 转成单个 Marker 后加入这个分组这样缩放时自动聚合点开时显示单个景区。const clusterGroup L.markerClusterGroup(); fetch(/api/scenic/points) .then(res res.json()) .then(data { L.geoJSON(data, { pointToLayer: function (feature, latlng) { return L.marker(latlng); }, onEachFeature: function (feature, layer) { const p feature.properties; layer.bindPopup( b p.name /bbr/ 等级 p.level br/ 类型 p.type br/ 所在城市 p.cityName ); } }).eachLayer(function (layer) { clusterGroup.addLayer(layer); }); clusterGroup.addTo(map); });这段代码有个细节L.geoJSON会把整个 FeatureCollection 当成一个图层组但我们要把它里面的每个点加进clusterGroup所以要用eachLayer遍历后再添加。如果直接把L.geoJSON对象整体addTo(map)就不会出现聚合效果几百个点会直接铺满地图性能瞬间变差。4.3 多条件筛选与图表联动页面上我放了三个筛选控件省份下拉框、等级下拉框、类型下拉框。每次筛选变化时把条件拼到请求参数里重新获取点位和统计数据然后刷新地图上的clusterGroup同时刷新旁边的 ECharts 柱状图和饼图。避免重复渲染的方法是先把旧的聚合层从地图上移除再创建新的。实现如下function loadPoints(params) { fetch(/api/scenic/points? new URLSearchParams(params)) .then(res res.json()) .then(data { clusterGroup.clearLayers(); // 重新构建图层并加入 rebuildCluster(data); }); }因为clusterGroup.clearLayers()只是清理当前分组里的点并不会销毁分组本身所以地图引用关系不需要重建效率很高。图表部分同理每次拿到聚合接口的数据后先setOption替换数据配合 ECharts 的动画页面交互感受非常顺畅。5. 分布分析的几个观察维度数据全部跑通之后我开始认真看分布规律。以下表格中的数据经过脱敏处理用“某省”代替真实省份名但分析方法和结论逻辑是实际项目的真实反映。5.1 数量分布明显看出疏密差异把全国省会城市的 A 级景区数量按区间统计后我能看到一个非常清晰的空间分布区域范围样本城市景区数量合计占比东部沿海相关省会某省、某省等约120处约42%中部地区省会某省、某省等约90处约31%西部地区省会某省、某省等约55处约19%北方边远地区省会某省、某省等约23处约8%这说明景区数量并不平均而是明显向东部和中部倾斜。这种差异和经济发展水平、人口密度、自然地理条件都有关系。东部不少城市的人文景观和现代园区数量更多西部虽然自然风光资源丰富但同时也要考虑适建性、可达性和开发程度所以入库数量相对少这直接反映在分布图上就是“东南密、西北疏”。5.2 等级结构高等级景区更向知名省会集中从 5A 级景区分布来看我看到的现象是个别经济发展水平较高的省会城市5A 级景区数量明显占据省级乃至全国优势而数量排名靠后的省会城市几乎找不到 5A 级项目的影子。反而是在地图缩小后能看到几个城市周边出现了明显的“高等级聚团”。我还做了一次“省会城市首位度”测算简单公式是该省最高等级景区数占全省高等级景区总数的比例。结果发现有些省份的高等级景区高度集中在省会周边有些则分散在地级市。这种分析如果只看报表数据是不容易注意到的但在 Leaflet 地图上一目了然。5.3 类型结构与旅游资源禀赋有关按景区类型分类后又从另一个维度解释了分布以自然山水为主的景区多集中在山地丘陵地带的省会城市周边以历史文化遗址、古建筑和博物馆体系为主的景区多集中在历史底蕴深厚的省级城市以主题乐园、休闲度假区等现代业态为主的景区主要集中在经济较发达、消费人口多的省会城市。这说明“风景区分布”并不只是一个孤立的旅游问题它其实反映了一个城市所处的资源禀赋、历史地位和经济发展阶段。分析到这一步我觉得这个项目的价值就已经不仅是地图打点了而是一个可以帮助区域旅游发展规划做参考的可视化分析工具。6. 常见问题与排查技巧做这个项目我踩过不少坑整理成一张速查表后端和前端的常见问题基本都能覆盖现象可能原因排查思路接口返回 404Controller 请求路径写错或者类没加RestController看启动时映射日志访问/actuator/mappings确认路径接口返回 500MyBatis 映射字段对不上、SQL 报错看控制台异常栈先落到具体 SQL 定位前端地图空白容器高度为 0或者瓦片地址不可访问F12 看 Network确认瓦片 URL 是否返回 200Marker 不刷出来接口没返回坐标或坐标数组顺序写反看接口 JSON 中 coordinates 是否存在弹窗中文乱码JDBC 连接没设characterEncodingutf8检查数据库连接 URL确认字符集聚合插件报错markerClusterGroup引用顺序错误确认先引入 Leaflet再引入 markercluster 插件图表数据不更新用了同一个 ECharts 实例但没清空旧数据每次刷新数据前先clear()或setOption(option, true)除了表格里的内容我再补充三个实操经验第一调式地图接口时可以在浏览器里先直接打开接口地址看返回的 GeoJSON 是否符合 Leaflet 的默认要求。如果FeatureCollection结构不对前端往往没有任何报错数据就是不画出来非常难排查。第二Leaflet 的 Marker 坐标顺序是[lat, lng]而常规 GeoJSON 是[lng, lat]。很多人在后端按习惯把经纬度写成了[经度, 纬度]但在 Leaflet 的pointToLayer回调中feature.geometry.coordinates里接收的顺序依然是 GeoJSON 的标准顺序传给L.marker时要交换一次。记住这个顺序差异能少掉一半头发。第三在线瓦片图片经常有跨域限制。如果一个地图项目要发布给其他人访问一定要在部署阶段提前确认瓦片授权和跨域配置否则换一个网络环境访问就白屏。7. 部署与后续扩展7.1 部署要点项目开发完以后部署不复杂我习惯的流程是这样的mvn clean package -DskipTests java -jar target/scenic-map-0.0.1-SNAPSHOT.jar生产环境我会用环境变量覆盖数据库连接和端口而不是写死在application.yml里。比如export SPRING_DATASOURCE_URLjdbc:mysql://prod-host:3306/scenic_map?useUnicodetruecharacterEncodingutf8 java -jar app.jar再用 Nginx 或者 Caddy 把后端接口和前端静态页面放在同一个域名下这样前端请求就不存在跨域问题了。需要注意一点如果后端接口和前端不在同一个域名记得在 SpringBoot 里开启 CORS 配置或者通过反向代理转发。7.2 后续推进方向这个项目做完后我脑子里已经列了好几个扩展方向加入热力图插件用leaflet.heat把景区密度渲染成热力灶比散点更能体现集聚区接入实时客流或门票预约数据让点位大小和颜色按热度变化把景区数据做成定时任务每周自动抓取新增 A 级景区名单并更新数据库在分析层引入人口、GDP、交通路网数据计算“景区可达性指数”这样分布分析能上升到区域规划视角。这些方向不用推翻现有架构都是在 SpringBoot 后端和 Leaflet 前端上往纵深扩展。基础打好了后面每一个功能的落地都会很顺。分享一点个人体会地图项目的难点从来不在框架本身而在数据质量和对地理知识的理解。坐标、坐标系、性能、字符集这几个不起眼的细节能决定整个项目的成败。很多人卡在原地很久往往只是因为一个坐标顺序写反了。把这类细节整理成自己的检查清单以后再跑同类项目效率会提高很多。