背景
要求在地图上任意选择一个点为圆心,基于这个点,半径为 X 公里渲染热力图。有一份 Excel 数据,该表格每一行数据代表基于这个点的角度,每一列代表该角度上距离圆心的距离。交叉值为某个角度上距离圆心 N 公里的值。 基于已经渲染的热力图框选某个区域,改变其值。
最终使用的开源技术
- 地图:高德
- 热力图:Aatv-HeatmapLayer
- excel 数据处理:sheetjs
- 数据存储:indexDB
- 多线程:webWorker
需求分析与技术难点
前期需求和技术难点
首选我拿到这个需求的时候,做了简单的需求和技术分析:
- 数据中没有直接的经纬度坐标,只有距离和角度,怎么渲染?
- 文件处理的时候页面卡顿问题怎么解决?
- 采用渲染真实地图+经纬度渲染数据点?还是渲染地图背景图片,基于角度和距离计算 xy 坐标,在 canvas 根据值的大小渲染对应颜色?
- 怎么解决框选问题?
- 怎么知道哪些渲染点被框选了?
- 数据怎么存储?
- 怎么将角度转换为经纬度?
调研过程
一开始呢,我以为这个需求解决了上述问题,就没问题了。看起来还是一个比较简单的需求,但是事实上没那么简单。基于上述问题,我快速的给出了我的答案。
我花了一点时间去做技术调研,首先就看了高德地图的热力图渲染。有一下结论:
- 地图接入简单。
- 支持热力图渲染。
- 支持矢量工具框选。
- 支持查询点是否在框选区域。
- 在渲染热力图的时候,数据量很大时,渲染很慢,这个和内部的实现原理有关系,高德的实现交互交过更好。渲染效果和 heatMapjs 差不多。
也看了 Cesium-Heatmap 和 googleMap-Heatmap,我的结论如下:
- googleMap 需求开通 google Cloud,大陆地区很麻烦,需求香港信用卡
- Cesium 自带的地理数据需要用户翻墙访问,可以考虑接入高德地图数据,但文档全英文对后续的功能接入可能产生一些麻烦。
- Heatmapjs 是基本的热力图技术,他的渲染速度比较快,但放大后有明显卡顿,如果想和高德地图结合使用,需要使用 Cesium 为桥梁。
- 如果对渲染及时性要求很高,可以考虑自己在通过高德地图的覆盖物/图层管理 API,融合一下 heatMapjs
- AntV HeatmapLayer 是一个很好的开源项目,可以实现热力图的渲染。几乎零成本接入高德,渲染超级快
实现过程
最终决定使用高德的技术,在实现过程中:
-
使用 sheetjs 对 Excel 进行内容解析,解析过程放在 webWorker 中操作,避免产生卡顿,和 postMessage 进度。
-
通过角度、初始经纬度坐标、距离,使用球型三角函数,计算 Excel 中所有点的坐标。
-
将所有点计算完成后,放入 indexDB 中。
- 为了减少等待时间,这里可以使用多 webWorker 线程处理,将 excel 的所有数据切片,然后启动多个 webworker 处理这些数据。请看代码切片:
使用球型三角函数处理数据
如果使用平面三角函数处理数据会导致,地理数据误差特别大
import Decimal from "decimal.js";
/**
* 根据角度、距离、中心点计算坐标
* @param {number} centerLon 中心点经度
* @param {number} centerLat 中心点纬度
* @param {number} angle 角度
* @param {number} distance 距离中心店距离,单位为米
* @returns
*/
export function dataToCoordinates(data) {
const { centerLng, centerLat, angle, distance, count, ...rest } = data;
const angleRad = new Decimal(angle).times(Math.PI).div(180); // 将角度转换为弧度
const latRad = new Decimal(centerLat).times(Math.PI).div(180); // 中心经度弧度
const lngRad = new Decimal(centerLng).times(Math.PI).div(180); // 中心纬度弧度
const earthRadius = new Decimal(6378137); // 地球半径(单位:米)
const distanceRatio = new Decimal(distance).div(earthRadius); // 距离比
// 目标点纬度(弧度)
const targetLatRad = Decimal.asin(
Decimal.sin(latRad)
.times(Decimal.cos(distanceRatio))
.plus(
Decimal.cos(latRad)
.times(Decimal.sin(distanceRatio))
.times(Decimal.cos(angleRad))
)
);
// 目标点经度(弧度)
const targetLngRad = lngRad.plus(
Decimal.atan2(
Decimal.sin(angleRad)
.times(Decimal.sin(distanceRatio))
.times(Decimal.cos(latRad)),
Decimal.cos(distanceRatio).minus(
Decimal.sin(latRad).times(Decimal.sin(targetLatRad))
)
)
);
// 转换回角度制
let targetLat = targetLatRad.times(180).div(Math.PI);
let targetLng = targetLngRad.times(180).div(Math.PI);
// 边界处理
targetLat = normalizeLatitude(targetLat);
targetLng = normalizeLnggitude(targetLng);
return {
lng: targetLng.toNumber(),
lat: targetLat.toNumber(),
count,
...rest,
};
}
/** 纬度边界函数 */
function normalizeLatitude(lat) {
if (lat.greaterThan(90)) {
return new Decimal(180).minus(lat); // 超过北极,则反射到南半球
} else if (lat.lessThan(-90)) {
return new Decimal(-180).minus(lat); // 超过南极,则反射到北半球
}
return lat;
}
/** 经度边界处理 */
function normalizeLnggitude(lng) {
return lng.plus(180).mod(360).plus(360).mod(360).minus(180);
}
文件多线程解析 多 webWorker
下面的代码是将一个上百万的数据,分成 threads 份,threads 根据用户电脑的性能自己输入,同时调用 threads 个 webWorker,启动对数据的解析。
const run = () => {
const fileLength = fileDataRef.current?.length || 0;
const chunkSize = Math.ceil(fileLength / threads);
for (let i = 0; i < threads; i++) {
const nowData = fileDataRef.current?.slice(
i * chunkSize,
(i + 1) * chunkSize
);
if (!nowData?.length) {
setstopWorker((v) => {
v[i] = "stop";
return [...v];
});
return;
}
let worker: Worker = new Worker(
new URL("@/worker/dataOperations.worker.js", import.meta.url),
{ type: "module" }
);
try {
worker.onmessage = (e) => {
const { records, idx } = e.data;
updateData(db, "excelTable", records);
updateDatas(db, "heatMapTable", records);
setparsingPercent((v) => {
v[i] = idx + 1;
return [...v];
});
if (idx + 1 >= nowData?.length) {
worker.terminate();
setstopWorker((v) => {
v[i] = "stop";
return [...v];
});
}
};
worker.postMessage({
origin: originRef.current,
fileData: nowData,
});
} catch (error) {
setparsingLoading(false);
message.error("webWorker错误");
worker.terminate();
}
}
};
indexDB 封装
相关数据的存储都放入本地。
// 定义存储数据的接口
type DataRecord<T> = T & { id: number };
// 创建数据库并打开连接
export function openDatabase(
dbName: string,
/** 类似表名字 */
storeNames?: string[],
version: number = 1
): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, version);
// 初次open的时候执行
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
storeNames &&
storeNames.forEach((storeName) => {
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName, {
keyPath: "id",
autoIncrement: true,
});
}
});
};
// 连接成功
request.onsuccess = (event) => {
resolve((event.target as IDBOpenDBRequest).result);
};
// 连接失败
request.onerror = (event) => {
reject(`Database error: ${(event.target as IDBOpenDBRequest).error}`);
};
});
}
// 通过实物获取storage 的操作对象
export const getStorage = (
db: IDBDatabase,
storeNameArray: string[],
mode: "readonly" | "readwrite" = "readwrite"
): Map<string, IDBObjectStore> => {
// 创建一个读写事务,指定需要操作的对象存储
const transaction = db.transaction(storeNameArray, mode);
const storeMap = new Map<string, IDBObjectStore>();
// 获取对象存储(Object Store)的引用,便于执行增删改查操作
storeNameArray.forEach((storeName) => {
storeMap.set(storeName, transaction.objectStore(storeName));
});
return storeMap;
};
/** 单个数据入库 */
export function updateData<T extends Record<string, any> = Record<string, any>>(
/** 数据库对象 */
db: IDBDatabase,
/** 表名 */
storeName: string,
/** 数据 */
data: T,
/** 添加或者更新 */
operation: "add" | "put" = "add"
): Promise<DataRecord<T>> {
return new Promise((resolve, reject) => {
const storeMap = getStorage(db, [storeName]);
const store = storeMap.get(storeName);
// 在对象存储中添加一条数据
const request = store?.[operation](data) as IDBRequest<IDBValidKey>;
request.onsuccess = () => {
const resData = Object.assign({}, data, { id: request.result as any });
resolve(resData);
};
request.onerror = () => reject(`Add error: ${request.error}`);
});
}
/** 批量数据入库 */
export function updateDatas<
T extends Record<string, any> = Record<string, any>
>(
/** 数据库对象 */
db: IDBDatabase,
/** 表名 */
storeName: string,
/** 数据 */
data: T[],
/** 添加或者更新 */
operation: "add" | "put" = "add"
): Promise<DataRecord<T>[]> {
return new Promise((resolve, reject) => {
const storeMap = getStorage(db, [storeName]);
const store = storeMap.get(storeName);
const transaction = store?.transaction!;
// 监听事务完成
const resDatas: DataRecord<T>[] = [];
data.forEach((item) => {
const request = store?.[operation](item) as IDBRequest<IDBValidKey>;
request.onerror = (event) => {
console.error("添加失败:", event);
transaction?.abort(); // 遇到错误时中止事务
reject(`Add error: ${request.error}`);
throw new Error("Stop execution");
};
request.onsuccess = () => {
resDatas.push(Object.assign({}, item, { id: request.result as any }));
};
});
transaction.oncomplete = () => {
resolve(resDatas);
};
});
}
// 查询数据
export function getData<T extends Record<string, any> = Record<string, any>>(
db: IDBDatabase,
storeName: string,
id: number
): Promise<DataRecord<T> | undefined> {
return new Promise((resolve, reject) => {
const storeMap = getStorage(db, [storeName], "readonly");
const store = storeMap.get(storeName) as IDBObjectStore;
const request = store.get(id);
request.onsuccess = () =>
resolve(request.result as DataRecord<T> | undefined);
request.onerror = () => reject(`Get error: ${request.error}`);
});
}
/**
* 条件查询,并支持分页参数
* @param {IDBDatabase} db - IndexedDB 数据库实例
* @param {string} storeName - 对象存储名称
* @param {(record: T) => boolean} condition - 条件函数
* @param {number} pageNum - 起始索引,从第几个数据开始查询
* @param {number} pageSize - 需要筛选的数据总数 不传递全部查询
* @returns {Promise<T[]>} - 符合条件的数据数组
*/
export function queryByCondition<T extends Record<string, any> = any>(
db: IDBDatabase,
storeName: string,
condition: (record: T) => boolean = () => true,
pageNum: number = 1,
pageSize: number = 10
): Promise<T[]> {
if (!(pageNum > 0)) pageNum = 1;
if (!(pageSize > 0)) pageSize = 10;
return new Promise((resolve, reject) => {
const storeMap = getStorage(db, [storeName], "readonly");
const store = storeMap.get(storeName)!;
const results: T[] = [];
const startIndex = (pageNum - 1) * pageSize;
let skipped = false; // 标志是否已经跳过了 startIndex 条数据
// 使用游标遍历数据
const request = store.openCursor();
request.onsuccess = (event) => {
const cursor = request.result;
if (cursor) {
if (!skipped && startIndex > 0) {
// 跳过 N 条数据
cursor.advance(startIndex); //会触发request.onsuccess 回调 所以return
skipped = true;
return;
}
// 是否符合查询条件
if (condition(cursor.value)) {
results.push(cursor.value);
}
if (results.length < pageSize) {
cursor.continue(); // 游标继续下一个
} else {
resolve(results); // 达到 pageSize 返回结果
return;
}
} else {
// 游标遍历完成
resolve(results);
}
};
request.onerror = (event) => {
reject(event);
};
});
}
/** 全部查询 */
export function getTableAll<T extends Record<string, any>>(
db: IDBDatabase,
storeName: string
): Promise<T[]> {
return new Promise((resolve, reject) => {
const storeMap = getStorage(db, [storeName], "readonly");
const store = storeMap.get(storeName)!;
// 使用游标遍历数据
const request = store.getAll();
request.onsuccess = (event) => {
resolve((event.target as any).result); // 游标遍历完成
};
request.onerror = (event) => {
reject(event);
};
});
}
// 清理数据
export function clearData(db: IDBDatabase, storeName: string): Promise<string> {
return new Promise((resolve, reject) => {
const storeMap = getStorage(db, [storeName]);
const store = storeMap.get(storeName) as IDBObjectStore;
const request = store.clear();
request.onsuccess = () => resolve("Data cleared");
request.onerror = () => reject(`Clear error: ${request.error}`);
});
}
使用原始数据渲染
通过以上对数据的解析处理后,使用高德的 AMap.HeatMap 的 setDataSet 方法和 addDataPoint 方法,渲染每一度上的数据得到了下面的效果。 这里主要采用了两个办法:
-
所有数据使用同一个 HeatMap 渲染。
- 缺点:超级慢
- 优点:数据协调
-
每 1° 数据使用一个 HeatMap 进行渲染。
- 缺点:数据不平滑,两个 heatMap 之间会存在明显的图层痕迹。
- 优点:快
原始数据效果图
使用高德地图加多个 HeatMap 叠加实现
使用高德地图 + 一个 HeatMap 实现
使用 Google+heatMapjs 实现
antv/HeatmapLayer
这个热力图渲染超级快,是项目写完后最后才发现的(文章总结写完后才发现的),底层本接入高德地图
优化实现
为什么原始数据渲染效果很差
在上面的效果中,可以说效果特别差。为什么呢? 因为数据是集中在圆心的,导致圆心的数据在热力图上显现的效果值就很大。这就违背了整个需求,也就是说 可能圆心都没有什么值,但是看起来特别红。
怎么解决?
这个问题的原因呢,总结出来就一个问题,在 canvas 画布中,坐标点不平均,也就是说,任意多选择的两个点距离不相等。所以我们接下来就需要做一件事:重新构建数据,根据权重和原始数据值平均化分布点。
实现思路
- 将整个地球网格化: 比如以 0.001 经纬度为一个网格单位,将地球画成 N 个格子
- 那么所有的坐标点都会坐落于属于自己的那个网格。
- 根据原始数据的最小经度和最小维度开始向最大经度和维度循环每一个网格。
- 在当前的格子代表为中心,拿出四周的八个格子和自己格子的数据进行权重比计算。
- 将着九个格子的数据一一循环,将小于设定距离的点进行权重累加和值累加,最后:代表点的值=值累加/权重累加
实现代码切片
代码中 gridSpacing 代表网格的间隔大小,也会影响代表点的权重取值范围,gridSpacing 也就是说值越小,渲染的点就越多,渲染压力就更大,渲染越细腻。
import Decimal from "decimal.js";
export function dataToCoordinates(data) {
const { centerLng, centerLat, angle, distance, count, ...rest } = data;
const angleRad = new Decimal(angle).times(Math.PI).div(180); // 将角度转换为弧度
const latRad = new Decimal(centerLat).times(Math.PI).div(180); // 中心经度弧度
const lngRad = new Decimal(centerLng).times(Math.PI).div(180); // 中心纬度弧度
const earthRadius = new Decimal(6378137); // 地球半径(单位:米)
const distanceRatio = new Decimal(distance).div(earthRadius); // 距离比
// 目标点纬度(弧度)
const targetLatRad = Decimal.asin(
Decimal.sin(latRad)
.times(Decimal.cos(distanceRatio))
.plus(
Decimal.cos(latRad)
.times(Decimal.sin(distanceRatio))
.times(Decimal.cos(angleRad))
)
);
// 目标点经度(弧度)
const targetLngRad = lngRad.plus(
Decimal.atan2(
Decimal.sin(angleRad)
.times(Decimal.sin(distanceRatio))
.times(Decimal.cos(latRad)),
Decimal.cos(distanceRatio).minus(
Decimal.sin(latRad).times(Decimal.sin(targetLatRad))
)
)
);
// 转换回角度制
let targetLat = targetLatRad.times(180).div(Math.PI);
let targetLng = targetLngRad.times(180).div(Math.PI);
// 边界处理
targetLat = normalizeLatitude(targetLat);
targetLng = normalizeLnggitude(targetLng);
return {
lng: targetLng.toNumber(),
lat: targetLat.toNumber(),
count,
...rest,
};
}
/** 纬度边界函数 */
function normalizeLatitude(lat) {
if (lat.greaterThan(90)) {
return new Decimal(180).minus(lat); // 超过北极,则反射到南半球
} else if (lat.lessThan(-90)) {
return new Decimal(-180).minus(lat); // 超过南极,则反射到北半球
}
return lat;
}
/** 经度边界处理 */
function normalizeLnggitude(lng) {
return lng.plus(180).mod(360).plus(360).mod(360).minus(180);
}
/**
* Haversine公式计算两点之间的距离(单位:米)
*/
function haversine(lat1, lng1, lat2, lng2) {
const R = new Decimal(6371000); // 地球半径(米)
const toRad = (deg) => new Decimal(deg).times(Math.PI).div(180);
const dLat = toRad(lat2).minus(toRad(lat1));
const dlng = toRad(lng2).minus(toRad(lng1));
const phi1 = toRad(lat1);
const phi2 = toRad(lat2);
const a = Decimal.sin(dLat.div(2))
.pow(2)
.plus(
Decimal.cos(phi1)
.times(Decimal.cos(phi2))
.times(Decimal.sin(dlng.div(2)).pow(2))
);
return R.times(2).times(Decimal.atan2(a.sqrt(), a.sqrt(-1).plus(1)));
}
/**
* 构建网格索引,预处理数据点
*/
function buildGridIndex(data, gridSpacing) {
const gridIndex = new Map();
// 创建网格的坐标
const toGridKey = (lat, lng) =>
`${Math.floor(lat / gridSpacing)}:${Math.floor(lng / gridSpacing)}`;
data.forEach((point) => {
const key = toGridKey(point.lat, point.lng);
if (!gridIndex.has(key)) {
gridIndex.set(key, []);
}
gridIndex.get(key).push(point);
});
return { gridIndex, toGridKey };
}
/**
* 计算新的网格点数据
* @param {any[]} data
* @param {Number} gridSpacing 新的点的经纬度间隔,0.01 度大约对应 1.11 公里
* @returns newData
*/
export function calculateNewPoints(data, gridSpacing = 0.01) {
let minLat = Decimal.min(999);
let maxLat = Decimal.max(0);
let minlng = Decimal.min(999);
let maxlng = Decimal.max(0);
// 提取经纬度范围
data.forEach((d) => {
const latDecimal = new Decimal(d.lat);
const lngDecimal = new Decimal(d.lng);
if (latDecimal.lt(minLat)) {
minLat = latDecimal;
}
if (latDecimal.gt(maxLat)) {
maxLat = latDecimal;
}
if (lngDecimal.lt(minlng)) {
minlng = lngDecimal;
}
if (lngDecimal.gt(maxlng)) {
maxlng = lngDecimal;
}
});
// 构建索引
const { gridIndex, toGridKey } = buildGridIndex(data, gridSpacing);
const newData = [];
let f = 0;
const whileNumbers = Decimal.ceil(
maxLat.minus(minLat).div(gridSpacing)
).times(Decimal.ceil(maxlng.minus(minlng).div(gridSpacing)));
console.log(
"第一次for",
Math.ceil(maxLat.minus(minLat).div(gridSpacing).toNumber()),
"第二次for",
Math.ceil(maxlng.minus(minlng).div(gridSpacing).toNumber()),
"总循环",
whileNumbers.toNumber()
);
// 遍历新网格
for (let lat = minLat; lat.lte(maxLat); lat = lat.plus(gridSpacing)) {
// 遍历维度网格
for (let lng = minlng; lng.lte(maxlng); lng = lng.plus(gridSpacing)) {
// 遍历经度网格
let weights = new Decimal(0);
let weightedSum = new Decimal(0);
// 确定影响范围内的网格
const latGrid = Math.floor(lat / gridSpacing); //当前点的维度网格值
const lngGrid = Math.floor(lng / gridSpacing); // 当前点的经度网格值
for (let i = -1; i <= 1; i++) {
for (let j = -1; j <= 1; j++) {
const key = `${latGrid + i}:${lngGrid + j}`;
const nearbyPoints = gridIndex.get(key) || [];
// 计算当前网格点的加权值
nearbyPoints.forEach((point) => {
const dist = haversine(lat, lng, point.lat, point.lng);
const maxDist = new Decimal(gridSpacing).times(111000); // 转换为米
if (dist.lte(maxDist)) {
const weight = new Decimal(1).div(dist.plus(1e-10)); // 避免除以零
weights = weights.plus(weight);
weightedSum = weightedSum.plus(
weight.times(new Decimal(point.count || 0))
);
} else if (weights.eq(0)) {
// 周边的9个大格子 有点 但是有距离 这里就先给weights给个初始值,避免所有点都距离他很远导致weights等于0 最后不渲染
weights = new Decimal(1e-10);
}
});
f = f + 1;
}
}
// 如果有有效点,计算新值
if (weights.gt(0)) {
const value = weightedSum.div(weights);
const nowItem = {
lat: lat.toNumber(),
lng: lng.toNumber(),
count: value.eq(0) ? 0.00000001 : value.toNumber(), // count 等于0的时候 高德会把数据渲染成默认值1
};
newData.push(nowItem);
postMessage({
down: false,
progress: Decimal(f).div(whileNumbers.times(9)).toNumber(),
data: newData,
item: nowItem,
});
}
}
}
return newData;
}
效果
最终优化渲染速度 AntV/HeatmapLayer
其实这篇文章,之前写完了还是不太满足于高德地图的热力图渲染速度,之后想着自己使用 heatmapjs 叠加高德地图图层根据比例实现,写了一点发现没那么简单,后面发现了 antV 的 HeatmapLayer,基于高德地图的地图底层实现,而且速度很快,接入层本很低,不影响调用高德地图的 API,特别是这个渲染速度,简直眼前一亮。