ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java实现腾讯位置数据热力图实时渲染系统

Java实现腾讯位置数据热力图实时渲染系统 简介这是一套基于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和sigSHA256 签名。签名规则为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() timestamp timestamp nonce nonce sig sig; HttpHeaders headers new HttpHeaders(); headers.set(Content-Type, application/json); HttpEntityString entity new HttpEntity(headers); try { ResponseEntityString 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 DDLInnoDB 引擎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) ) ENGINEInnoDB 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) ) ENGINEInnoDB COMMENT5分钟粒度热力栅格;// 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, zoom15)zoom15 对应约 1.2 米/像素100m 栅格即2^15 / 360 * 100 ≈ 123像素宽取整为x(int)((lng180)*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 防爆内存 ListLocationPoint points pointMapper.selectByTimeRange(windowStart, windowEnd, 100000); if (points.isEmpty()) { log.info(无新数据跳过聚合 window[{}, {}], windowStart, windowEnd); return; } // Step 2: 按栅格 ID 分组计数Java Stream 并行处理CPU 密集型 MapString, ListLocationPoint 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 实体并批量插入 ListHeatGrid grids gridMap.entrySet().stream() .map(entry - { String gridId entry.getKey(); ListLocationPoint 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:23pointMapper.selectByTimeRange()底层 SQL 加了LIMIT 100000防止单次拉取过多点导致 OOMgridUtils.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 MercatorEPSG: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栅格≈z15瓦片z15需查z15栅格 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 QueryWrapperHeatGrid().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 或 CanvasSVG 无法动态绑定热力值需每次生成新 DOMCanvas 在服务端不可用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 ResponseEntitybyte[] getHeatTile( PathVariable int z, PathVariable int x, PathVariable int y, RequestParam(defaultValue 0) long ts) { // ts0 表示取最新数据查 max(ts) if (ts 0) { ts tileService.getLatestTs(); } byte[] pngBytes tileService.generateTile(z, x, y, ts); HttpHeaders headers new HttpHeaders(); headers.setCacheControl(public, max-age300); // 5分钟缓存 headers.setContentLength(pngBytes.length); headers.setContentType(MediaType.IMAGE_PNG); return new ResponseEntity(pngBytes, headers, HttpStatus.OK); } }关键细节max-age300让 CDN 和浏览器缓存瓦片 5 分钟避免重复生成getLatestTs()从t_heat_grid_5min表查MAX(ts)保证前端请求/tile/15/12345/67890.png?ts0自动命中最新批次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 默认请求z15,x12345,y67890但腾讯位置数据只覆盖岳麓山核心区x∈[12340,12350], y∈[67885,67895]超出范围的瓦片我们返回了空 PNG但前端未设error回调误判为 404。解决后端统一返回200 OK 透明 PNG非 404前端 Leaflet 配置tileErrorHandlerL.tileLayer(/tile/{z}/{x}/{y}.png?ts{ts}, { errorTileUrl: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5hHgAHggJ/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() 中替换数据库查询 ListString gridIds Arrays.asList(15_12344_67889_1717027200, ...); MapString, String cachedCounts redisTemplate.opsForHash() .entries(heat:ts: ts); // O(1) 批量获取 // 若缓存 miss则查 DB 并写回 Redis带 5 分钟过期 if (cachedCounts.size() gridIds.size()) { ListHeatGrid fromDb gridMapper.selectBatchIds(gridIds); MapString, 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对岳麓山核心区域z15, x∈[12342,12348], y∈[67887,67893]的瓦片我们在聚合任务完成后用Files.write()预生成 PNG 到/var/www/tiles/{z}/{x}/{y}.pngNginx 配置location ~ ^/tile/(\d)/(\d)/(\d)\.png$ { root /var/www; try_files /tiles/$1/$2/$3.png 404; add_header Cache-Control public, max-age300; }为什么不用 Redis 存 PNGPNG 平均 8KB1000 个瓦片 ≈ 8MBRedis 内存成本高文件系统顺序读比 Redis 随机 GET 更快尤其 SSDNginx 静态文件服务比 Spring Boot Servlet 容器快 3~5 倍。5.3 热力色阶动态校准用滑动窗口统计全局count分布避免固定阈值失真固定count10→蓝在淡季日均 5 万客流和旺季日均 20 万下完全失效。我们每小时计算最近 24 小时所有栅格count的 25/50/75 分位数动态生成色阶// 每小时执行 public void updateColorScale() { long oneDayAgo System.currentTimeMillis() / 1000 - 86400; ListInteger 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; }效果淡季时p253旺季时p2512热力图始终呈现合理对比度游客不会觉得“永远红色”或“永远蓝色”。我上线这个系统时在岳麓山南门监控大屏上盯着实时热力图看了整整三天看早高峰人群如何从溁湾镇地铁口涌向爱晚亭看午后学生团在岳麓书院前形成黄色斑块看夜游人群沿清风峡小道连成一条发光丝带。那一刻才真正明白——热力图不是炫技的彩条而是城市呼吸的脉搏图。Java 做这件事的优势不在语法糖而在可控的内存、可预测的 GC、以及当流量突增时你能精确到每一行代码去调优。希望帮到你。本文还有配套的精品资源点击获取
返回列表