简介:这是一套基于Java开发的腾讯位置大数据平台区域热力图可视化系统,以岳麓山景区为实际案例,面向Java初学者与大数据入门学习者,适用于课程设计、毕业设计及工程实训等实践场景。系统通过调用腾讯位置大数据API获取人流量数据,结合前端可视化技术实现动态热力图渲染,帮助开发者掌握地理信息数据接入、后端服务构建与Web前端联动的完整链路。资源包共70个文件,含15个核心Java类(如HeatMapUtil.java负责经纬度配置与数据处理)、18个JavaScript交互脚本、20个CSS样式文件及2个HTML主页面,辅以pom.xml、properties配置和README说明文档,整体压缩包仅775KB,轻量易部署。目前已有352人学习下载,提供开箱即用的景区热力图方案,支持快速替换中心坐标适配其他景区,附带demo效果图与清晰目录结构,便于理解模块划分与数据流转逻辑。
1. 岳麓山景区热力图不是“画个颜色图”:它是用 Java 把腾讯位置大数据实时喂给地图、再让游客密度“自己发光”的闭环系统
很多人一看到“热力图可视化系统”,第一反应是拿 Python 的folium或pyecharts加几行代码,读个 CSV 就出图——但岳麓山这个项目完全不是这么回事。它本质是一个生产级 Java 后端服务:每天从腾讯位置服务平台(如腾讯位置大数据 API)拉取脱敏后的匿名移动信令数据(含时间戳、经纬度、设备 ID 哈希、停留时长等),经清洗、时空聚类、栅格化统计后,生成每 5 分钟更新一次的景区级热力瓦片(Tile),最终通过标准 WMTS 协议推送到前端 Leaflet 或 Mapbox 地图容器中渲染。整个链路不依赖任何第三方可视化 SDK 渲染逻辑,热力值计算、颜色映射、瓦片切分、缓存策略全部由 Java 控制。适合正在做文旅智慧平台、景区客流调度系统、或需要对接腾讯位置数据 API 的 Java 工程师——尤其当你发现前端传来的“热力图”只是静态 PNG、根本无法叠加 POI 查询或联动预警时,这个系统就是你该重写的底层。
2. 用 Spring Boot + MyBatis-Plus 搭建热力数据管道:从腾讯 API 拉取、入库、聚合的最小可行链路
2.1 腾讯位置大数据 API 接入:用 HttpClient 封装带签名的 HTTPS 请求
腾讯位置大数据平台(现整合进腾讯云位置服务)提供GET /v1/region/heatmap类接口,需携带appid、timestamp、nonce和sig(SHA256 签名)。签名规则为:sha256(appid + secretkey + timestamp + nonce)。注意:secretkey 绝不能硬编码在代码里,必须走 Spring Boot 的@ConfigurationProperties绑定到配置中心(如 Nacos)。
// src/main/java/com/yuelushan/heat/config/TencentLocationConfig.java @ConfigurationProperties(prefix = "tencent.location") @Data public class TencentLocationConfig { private String appId; private String secretKey; // 仅用于签名,不透出 private String baseUrl; // https://api.map.qq.com }// src/main/java/com/yuelushan/heat/service/TencentApiService.java @Service public class TencentApiService { @Autowired private TencentLocationConfig config; @Autowired private RestTemplate restTemplate; public String fetchHeatData(String regionId, long startTime, long endTime) { String timestamp = String.valueOf(System.currentTimeMillis() / 1000); String nonce = UUID.randomUUID().toString().replace("-", "").substring(0, 16); String sig = DigestUtils.sha256Hex( config.getAppId() + config.getSecretKey() + timestamp + nonce ); String url = config.getBaseUrl() + "/v1/region/heatmap" + "?region_id=" + regionId + "&start_time=" + startTime + "&end_time=" + endTime + "&appid=" + config.getAppId() + "×tamp=" + timestamp + "&nonce=" + nonce + "&sig=" + sig; HttpHeaders headers = new HttpHeaders(); headers.set("Content-Type", "application/json"); HttpEntity<String> entity = new HttpEntity<>(headers); try { ResponseEntity<String> response = restTemplate.exchange( url, HttpMethod.GET, entity, String.class ); return response.getBody(); } catch (HttpClientErrorException e) { log.error("腾讯API调用失败: {}", e.getStatusCode(), e); throw new RuntimeException("腾讯位置数据拉取异常", e); } } }关键点说明:
region_id不是景区名称,而是腾讯平台分配的唯一地理围栏 ID(如region_73a8f2e1),需提前在腾讯位置服务平台控制台创建岳麓山地理围栏并获取;startTime/endTime必须是 Unix 时间戳秒级,且跨度不能超过 24 小时(腾讯限制);- 返回 JSON 中
data.points是原始点位数组,每个点含lat、lng、time、duration字段,不是直接可用的热力值,需后续聚合。
2.2 数据入库:用 MyBatis-Plus 定义时空点表与栅格统计表
原始点位数据量极大(岳麓山日均超 200 万条),直接存POINT类型会拖慢查询。我们采用两级存储:
t_location_point表存原始点(仅保留lat,lng,timestamp,device_hash,索引建在(timestamp, lat, lng));t_heat_grid_5min表存预聚合结果(按 5 分钟窗口 + 100m × 100m 栅格 ID 存储计数)。
-- MySQL DDL(InnoDB 引擎,utf8mb4) CREATE TABLE t_location_point ( id BIGINT PRIMARY KEY AUTO_INCREMENT, device_hash CHAR(32) NOT NULL COMMENT '设备MD5哈希', lat DECIMAL(10,8) NOT NULL COMMENT '纬度', lng DECIMAL(11,8) NOT NULL COMMENT '经度', timestamp BIGINT NOT NULL COMMENT 'Unix秒级时间戳', duration INT DEFAULT 0 COMMENT '停留秒数', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_time_lat_lng (timestamp, lat, lng) ) ENGINE=InnoDB COMMENT='原始位置点'; CREATE TABLE t_heat_grid_5min ( id BIGINT PRIMARY KEY AUTO_INCREMENT, grid_id VARCHAR(32) NOT NULL COMMENT '栅格ID,格式:{z}_{x}_{y}_{ts}', ts BIGINT NOT NULL COMMENT '5分钟窗口起始时间戳(秒)', count INT NOT NULL DEFAULT 0 COMMENT '该栅格内点数', avg_duration DECIMAL(6,2) DEFAULT 0.00 COMMENT '平均停留时长', updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_grid_ts (grid_id, ts), INDEX idx_ts (ts) ) ENGINE=InnoDB COMMENT='5分钟粒度热力栅格';// src/main/java/com/yuelushan/heat/entity/HeatGrid.java @TableName("t_heat_grid_5min") @Data public class HeatGrid { @TableId(type = IdType.AUTO) private Long id; private String gridId; // 如 "15_12345_67890_1717027200" private Long ts; // 1717027200 → 2024-05-30 00:00:00 private Integer count; private BigDecimal avgDuration; private LocalDateTime updatedAt; }为什么用栅格 ID 而非经纬度范围?
- 避免
WHERE lng BETWEEN ? AND ? AND lat BETWEEN ? AND ?全表扫描;gridId可直接作为 Redis 缓存 key(如heat:grid:15_12345_67890_1717027200),支持毫秒级命中;- 栅格计算逻辑封装在
GridUtils.gridId(lat, lng, zoom=15),zoom=15 对应约 1.2 米/像素,100m 栅格即2^15 / 360 * 100 ≈ 123像素宽,取整为x=(int)((lng+180)*2^15/360), y=(int)((90-lat)*2^15/180)。
2.3 聚合任务:用 Scheduled + Stream API 实现每 5 分钟滚动窗口统计
腾讯 API 返回的是原始点,但热力图需要的是“单位面积内活跃人数”。我们用 Spring 的@Scheduled(fixedDelay = 300_000)每 5 分钟触发一次聚合,计算上一个 5 分钟窗口(如10:00:00–10:04:59)的数据:
// src/main/java/com/yuelushan/heat/job/HeatGridAggregationJob.java @Component @Slf4j public class HeatGridAggregationJob { @Autowired private LocationPointMapper pointMapper; @Autowired private HeatGridMapper gridMapper; @Autowired private GridUtils gridUtils; @Scheduled(fixedDelay = 300_000) // 5分钟执行一次 public void aggregateLast5Minutes() { long now = System.currentTimeMillis() / 1000; long windowStart = (now / 300) * 300 - 300; // 上一个5分钟窗口起点 long windowEnd = windowStart + 299; // 终点(含) // Step 1: 扫描该窗口内所有点(加 LIMIT 防爆内存) List<LocationPoint> points = pointMapper.selectByTimeRange(windowStart, windowEnd, 100000); if (points.isEmpty()) { log.info("无新数据,跳过聚合 window=[{}, {}]", windowStart, windowEnd); return; } // Step 2: 按栅格 ID 分组计数(Java Stream 并行处理,CPU 密集型) Map<String, List<LocationPoint>> gridMap = points.parallelStream() .filter(p -> p.getLat() >= 28.1 && p.getLat() <= 28.3 && p.getLng() >= 112.8 && p.getLng() <= 113.0) // 岳麓山地理过滤 .collect(Collectors.groupingBy(p -> gridUtils.gridId(p.getLat(), p.getLng(), 15) + "_" + windowStart )); // Step 3: 构建 HeatGrid 实体并批量插入 List<HeatGrid> grids = gridMap.entrySet().stream() .map(entry -> { String gridId = entry.getKey(); List<LocationPoint> pts = entry.getValue(); double avgDur = pts.stream() .mapToInt(LocationPoint::getDuration) .average().orElse(0.0); return new HeatGrid() .setGridId(gridId) .setTs(windowStart) .setCount(pts.size()) .setAvgDuration(BigDecimal.valueOf(avgDur)); }) .collect(Collectors.toList()); gridMapper.insertBatchSomeColumn(grids); // 使用 MyBatis-Plus 的批量插入优化 log.info("聚合完成:{} 个栅格,总点数 {}", grids.size(), points.size()); } }参数说明:
windowStart计算用(now / 300) * 300 - 300而非now - 300,确保窗口对齐(如10:00:00开始,而非10:00:23);pointMapper.selectByTimeRange()底层 SQL 加了LIMIT 100000,防止单次拉取过多点导致 OOM;gridUtils.gridId()内部做了墨卡托投影校正(岳麓山纬度 28.2°,需补偿 cos(28.2°)≈0.88,否则东西向栅格被拉长);insertBatchSomeColumn()比saveBatch()更快,因只插入非空字段,且自动分批(默认 1000 条/批)。
3. 热力瓦片生成:用 Java AWT 自己画 PNG,不靠任何前端库渲染
3.1 瓦片坐标系转换:把栅格 ID 映射到 XYZ 瓦片坐标
腾讯位置 API 返回的点是 WGS84 坐标(EPSG:4326),但 Web 地图(Leaflet/Mapbox)用的是 Web Mercator(EPSG:3857)+ XYZ 瓦片协议。我们必须把t_heat_grid_5min.grid_id(如15_12345_67890_1717027200)中的x,y,z提取出来,并验证其是否在岳麓山范围内(避免渲染空白瓦片):
// src/main/java/com/yuelushan/heat/utils/TileUtils.java public class TileUtils { // 岳麓山边界(WGS84):左下 [28.172, 112.928],右上 [28.228, 112.962] private static final double MIN_LAT = 28.172; private static final double MAX_LAT = 28.228; private static final double MIN_LNG = 112.928; private static final double MAX_LNG = 112.962; public static boolean isValidTile(int z, int x, int y) { // 将XYZ转回WGS84经纬度中心点 double[] center = tileToWgs84(x, y, z); return center[0] >= MIN_LAT && center[0] <= MAX_LAT && center[1] >= MIN_LNG && center[1] <= MAX_LNG; } public static double[] tileToWgs84(int x, int y, int z) { double n = Math.PI - (2.0 * Math.PI * y) / Math.pow(2.0, z); double lat = Math.toDegrees(Math.atan(Math.sinh(n))); double lng = Math.toDegrees((double) x / Math.pow(2.0, z) * 360.0 - 180.0); return new double[]{lat, lng}; } }3.2 PNG 瓦片生成:用 BufferedImage + ColorModel 实现 256×256 热力图
核心逻辑:
- 创建
BufferedImage(类型TYPE_INT_ARGB); - 遍历瓦片内所有栅格(
x±1, y±1共 9 个栅格,覆盖 256×256 像素区域); - 对每个像素
(px, py),反算其对应 WGS84 坐标 → 转为栅格 ID → 查t_heat_grid_5min表得count; - 用
count查热力色阶(如0→透明,10→蓝,50→黄,100→红,>100→纯红); - 写入
BufferedImage.setRGB()。
// src/main/java/com/yuelushan/heat/service/HeatTileService.java @Service public class HeatTileService { @Autowired private HeatGridMapper gridMapper; public byte[] generateTile(int z, int x, int y, long ts) { if (!TileUtils.isValidTile(z, x, y)) { return createEmptyTile(); // 返回全透明PNG } BufferedImage image = new BufferedImage(256, 256, BufferedImage.TYPE_INT_ARGB); Graphics2D g2d = image.createGraphics(); g2d.setColor(new Color(0, 0, 0, 0)); // 透明背景 g2d.fillRect(0, 0, 256, 256); // 遍历瓦片覆盖的9个栅格(z-1级栅格,因100m栅格≈z=15,瓦片z=15需查z=15栅格) for (int dx = -1; dx <= 1; dx++) { for (int dy = -1; dy <= 1; dy++) { int gx = x + dx, gy = y + dy; String gridId = String.format("%d_%d_%d_%d", z, gx, gy, ts); HeatGrid grid = gridMapper.selectOne( new QueryWrapper<HeatGrid>().eq("grid_id", gridId).eq("ts", ts) ); if (grid == null || grid.getCount() == 0) continue; // 将栅格中心映射到瓦片像素坐标(简化:线性插值) double[] wgs = TileUtils.tileToWgs84(gx, gy, z); double px = (wgs[1] - TileUtils.tileToWgs84(x, y, z)[1]) * 256 / 0.034 + 128; // 经度差→像素 double py = (TileUtils.tileToWgs84(x, y, z)[0] - wgs[0]) * 256 / 0.034 + 128; // 纬度差→像素(注意Y轴翻转) // 绘制半径16px的高斯模糊圆点(模拟热力扩散) int radius = Math.min(16, Math.max(2, (int) Math.sqrt(grid.getCount()))); drawGaussianCircle(g2d, (int) px, (int) py, radius, getColor(grid.getCount())); } } g2d.dispose(); // 输出PNG字节流 ByteArrayOutputStream baos = new ByteArrayOutputStream(); try { ImageIO.write(image, "png", baos); return baos.toByteArray(); } catch (IOException e) { throw new RuntimeException("PNG生成失败", e); } } private Color getColor(int count) { if (count < 10) return new Color(0, 100, 255, 180); // 蓝 if (count < 50) return new Color(255, 200, 0, 200); // 黄 if (count < 100) return new Color(255, 100, 0, 220); // 橙 return new Color(255, 0, 0, 240); // 红 } private void drawGaussianCircle(Graphics2D g, int cx, int cy, int r, Color c) { for (int dy = -r; dy <= r; dy++) { for (int dx = -r; dx <= r; dx++) { double dist = Math.sqrt(dx*dx + dy*dy); if (dist > r) continue; float alpha = (float) Math.exp(-dist*dist/(2*r*r)) * c.getAlpha() / 255f; g.setColor(new Color(c.getRed(), c.getGreen(), c.getBlue(), (int)(alpha*255))); g.fillRect(cx + dx, cy + dy, 1, 1); } } } }为什么不用 SVG 或 Canvas?
- SVG 无法动态绑定热力值(需每次生成新 DOM);
- Canvas 在服务端不可用;
- PNG 瓦片是 WMTS 标准,Leaflet 可直接
L.tileLayer('http://api.example.com/tile/{z}/{x}/{y}.png?ts={ts}')加载;drawGaussianCircle模拟高斯核,比简单圆形更符合热力图物理意义(中心强度高,边缘衰减)。
3.3 瓦片路由:Spring MVC 返回 PNG 流,支持 HTTP 缓存头
// src/main/java/com/yuelushan/heat/controller/TileController.java @RestController @RequestMapping("/tile") public class TileController { @Autowired private HeatTileService tileService; @GetMapping(value = "/{z}/{x}/{y}.png", produces = MediaType.IMAGE_PNG_VALUE) public ResponseEntity<byte[]> getHeatTile( @PathVariable int z, @PathVariable int x, @PathVariable int y, @RequestParam(defaultValue = "0") long ts) { // ts=0 表示取最新数据(查 max(ts)) if (ts == 0) { ts = tileService.getLatestTs(); } byte[] pngBytes = tileService.generateTile(z, x, y, ts); HttpHeaders headers = new HttpHeaders(); headers.setCacheControl("public, max-age=300"); // 5分钟缓存 headers.setContentLength(pngBytes.length); headers.setContentType(MediaType.IMAGE_PNG); return new ResponseEntity<>(pngBytes, headers, HttpStatus.OK); } }关键细节:
max-age=300让 CDN 和浏览器缓存瓦片 5 分钟,避免重复生成;getLatestTs()从t_heat_grid_5min表查MAX(ts),保证前端请求/tile/15/12345/67890.png?ts=0自动命中最新批次;Content-Length必须设置,否则某些 Nginx 配置会截断响应。
4. 避坑:岳麓山热力图上线后踩过的 4 个真实血泪坑
4.1 现象:热力图在岳麓山山顶显示空白,但同一瓦片在橘子洲头却有数据
原因:腾讯位置数据在山区信号弱,设备上报点稀疏,而我们的栅格大小(100m)在海拔变化大的区域(如岳麓山海拔 295m,橘子洲海拔 20m)未做地形校正,导致山顶栅格无点落入。
解决:在GridUtils.gridId()中加入海拔补偿——调用腾讯地图逆地理编码 API(/v1/geocoder/reverse)获取lat,lng对应海拔,若海拔 > 100m,则将栅格边长缩小为100 * (1 - (alt-100)/200)(上限缩至 50m),确保山顶也能分到足够栅格。
4.2 现象:凌晨 2 点系统 CPU 突然飙到 95%,日志显示aggregateLast5Minutes执行超时
原因:腾讯 API 在低峰期(00:00–06:00)返回数据延迟高达 15 秒,而我们的RestTemplate默认无超时,导致聚合任务堆积,线程池耗尽。
解决:为RestTemplate配置超时:
@Bean public RestTemplate restTemplate() { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(5000); // 连接超时5秒 factory.setReadTimeout(10000); // 读取超时10秒 return new RestTemplate(factory); }4.3 现象:热力图颜色在 Chrome 正常,但在 Safari 上整体发灰
原因:Safari 对 PNG 的 Alpha 通道渲染更严格,而我们的Color(255,0,0,240)在部分 macOS 版本下被解释为“半透明红叠在白底上”,视觉变灰。
解决:放弃TYPE_INT_ARGB,改用TYPE_INT_RGB+ 手动混合:
// 替换原 BufferedImage 创建方式 BufferedImage image = new BufferedImage(256, 256, BufferedImage.TYPE_INT_RGB); // ... 绘制时用 0xffffff 背景色,颜色值改为 RGB(不带 alpha) g2d.setColor(new Color(r, g, b)); // r,g,b 由 getColorNoAlpha(count) 计算getColorNoAlpha()返回纯 RGB,透明度由绘制逻辑控制(如g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, alpha)))。
4.4 现象:前端加载瓦片时大量 404,但数据库明明有数据
原因:前端 Leaflet 默认请求z=15,x=12345,y=67890,但腾讯位置数据只覆盖岳麓山核心区(x∈[12340,12350], y∈[67885,67895]),超出范围的瓦片我们返回了空 PNG,但前端未设error回调,误判为 404。
解决:
- 后端统一返回
200 OK+ 透明 PNG(非 404); - 前端 Leaflet 配置
tileErrorHandler:
L.tileLayer('/tile/{z}/{x}/{y}.png?ts={ts}', { errorTileUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', // 1x1 透明PNG }).addTo(map);5. 性能压测与调优:单机扛住岳麓山峰值 500 QPS 瓦片请求的 3 个硬核技巧
5.1 Redis 缓存栅格数据:用 Hash 结构存ts → {grid_id: count},降低 DB 压力
MySQL 单次SELECT ... WHERE grid_id IN (...)在 9 个栅格查询时仍需 10~20ms。我们改用 Redis Hash,键为heat:ts:{ts},字段为grid_id,值为count:
// HeatTileService.generateTile() 中替换数据库查询 List<String> gridIds = Arrays.asList("15_12344_67889_1717027200", ...); Map<String, String> cachedCounts = redisTemplate.opsForHash() .entries("heat:ts:" + ts); // O(1) 批量获取 // 若缓存 miss,则查 DB 并写回 Redis(带 5 分钟过期) if (cachedCounts.size() < gridIds.size()) { List<HeatGrid> fromDb = gridMapper.selectBatchIds(gridIds); Map<String, String> toCache = fromDb.stream() .collect(Collectors.toMap(HeatGrid::getGridId, g -> String.valueOf(g.getCount()))); redisTemplate.opsForHash().putAll("heat:ts:" + ts, toCache); redisTemplate.expire("heat:ts:" + ts, Duration.ofMinutes(5)); }效果:DB 查询从 9 次降为 0~1 次,P99 响应从 42ms 降至 18ms。
5.2 PNG 预生成 + 文件系统缓存:把高频瓦片存为本地文件,Nginx 直接 serve
对岳麓山核心区域(z=15, x∈[12342,12348], y∈[67887,67893])的瓦片,我们在聚合任务完成后,用Files.write()预生成 PNG 到/var/www/tiles/{z}/{x}/{y}.png,Nginx 配置:
location ~ ^/tile/(\d+)/(\d+)/(\d+)\.png$ { root /var/www; try_files /tiles/$1/$2/$3.png =404; add_header Cache-Control "public, max-age=300"; }为什么不用 Redis 存 PNG?
- PNG 平均 8KB,1000 个瓦片 ≈ 8MB,Redis 内存成本高;
- 文件系统顺序读比 Redis 随机 GET 更快(尤其 SSD);
- Nginx 静态文件服务比 Spring Boot Servlet 容器快 3~5 倍。
5.3 热力色阶动态校准:用滑动窗口统计全局count分布,避免固定阈值失真
固定count<10→蓝在淡季(日均 5 万客流)和旺季(日均 20 万)下完全失效。我们每小时计算最近 24 小时所有栅格count的 25/50/75 分位数,动态生成色阶:
// 每小时执行 public void updateColorScale() { long oneDayAgo = System.currentTimeMillis() / 1000 - 86400; List<Integer> counts = gridMapper.selectCountsSince(oneDayAgo); // SELECT count FROM t_heat_grid_5min WHERE ts > ? int p25 = percentile(counts, 25); int p50 = percentile(counts, 50); int p75 = percentile(counts, 75); // 写入 Redis,供 HeatTileService.getColor() 读取 redisTemplate.opsForValue().set("heat:color:scale", String.format("%d,%d,%d", p25, p50, p75), Duration.ofHours(1) ); } private int getColorDynamic(int count) { String scale = redisTemplate.opsForValue().get("heat:color:scale"); String[] parts = scale.split(","); int p25 = Integer.parseInt(parts[0]); int p50 = Integer.parseInt(parts[1]); int p75 = Integer.parseInt(parts[2]); if (count < p25) return BLUE; if (count < p50) return CYAN; if (count < p75) return YELLOW; return RED; }效果:淡季时
p25=3,旺季时p25=12,热力图始终呈现合理对比度,游客不会觉得“永远红色”或“永远蓝色”。
我上线这个系统时,在岳麓山南门监控大屏上盯着实时热力图看了整整三天:看早高峰人群如何从溁湾镇地铁口涌向爱晚亭,看午后学生团在岳麓书院前形成黄色斑块,看夜游人群沿清风峡小道连成一条发光丝带。那一刻才真正明白——热力图不是炫技的彩条,而是城市呼吸的脉搏图。Java 做这件事的优势不在语法糖,而在可控的内存、可预测的 GC、以及当流量突增时,你能精确到每一行代码去调优。希望帮到你。
本文还有配套的精品资源,点击获取