2025-09-29 11:05:42 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 基于小程序Canvas API的核密度估计热力图
|
|
|
|
|
|
* 实现类似test.html中的效果,但适配uni-app小程序环境
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Epanechnikov核函数
|
|
|
|
|
|
* @param {Number} bandwidth 带宽参数
|
|
|
|
|
|
* @returns {Function} 核函数
|
|
|
|
|
|
*/
|
|
|
|
|
|
function kernelEpanechnikov(bandwidth) {
|
|
|
|
|
|
return function (v) {
|
|
|
|
|
|
const r = Math.sqrt(v[0] * v[0] + v[1] * v[1]);
|
|
|
|
|
|
return r <= bandwidth
|
|
|
|
|
|
? (3 / (Math.PI * bandwidth * bandwidth)) *
|
|
|
|
|
|
(1 - (r * r) / (bandwidth * bandwidth))
|
|
|
|
|
|
: 0;
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2025-09-30 11:29:09 +08:00
|
|
|
|
* 核密度估计器 - 优化版本
|
2025-09-29 11:05:42 +08:00
|
|
|
|
* @param {Function} kernel 核函数
|
|
|
|
|
|
* @param {Array} range 范围[xmin, xmax]
|
|
|
|
|
|
* @param {Number} samples 采样点数
|
|
|
|
|
|
* @returns {Function} 密度估计函数
|
|
|
|
|
|
*/
|
|
|
|
|
|
function kernelDensityEstimator(kernel, range, samples) {
|
|
|
|
|
|
return function (data) {
|
|
|
|
|
|
const gridSize = (range[1] - range[0]) / samples;
|
|
|
|
|
|
const densityData = [];
|
2025-09-30 11:29:09 +08:00
|
|
|
|
const bandwidth = 0.8; // 从核函数中提取带宽
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 11:29:09 +08:00
|
|
|
|
// 预计算核函数值缓存(减少重复计算)
|
|
|
|
|
|
const kernelCache = new Map();
|
2025-10-03 11:16:01 +08:00
|
|
|
|
const maxDistance = Math.ceil((bandwidth * 2) / gridSize); // 最大影响范围
|
|
|
|
|
|
|
2025-09-30 11:29:09 +08:00
|
|
|
|
for (let dx = -maxDistance; dx <= maxDistance; dx++) {
|
|
|
|
|
|
for (let dy = -maxDistance; dy <= maxDistance; dy++) {
|
|
|
|
|
|
const distance = Math.sqrt(dx * dx + dy * dy) * gridSize;
|
|
|
|
|
|
if (distance <= bandwidth * 2) {
|
2025-10-03 11:16:01 +08:00
|
|
|
|
kernelCache.set(
|
|
|
|
|
|
`${dx},${dy}`,
|
|
|
|
|
|
kernel([dx * gridSize, dy * gridSize])
|
|
|
|
|
|
);
|
2025-09-30 11:29:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 使用稀疏网格计算(只计算有数据点影响的区域)
|
|
|
|
|
|
const affectedGridPoints = new Set();
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 11:29:09 +08:00
|
|
|
|
// 第一步:找出所有受影响的网格点
|
2025-10-03 11:16:01 +08:00
|
|
|
|
data.forEach((point) => {
|
2025-09-30 11:29:09 +08:00
|
|
|
|
const centerX = Math.round((point[0] - range[0]) / gridSize);
|
|
|
|
|
|
const centerY = Math.round((point[1] - range[0]) / gridSize);
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 11:29:09 +08:00
|
|
|
|
// 只考虑带宽范围内的网格点
|
|
|
|
|
|
for (let dx = -maxDistance; dx <= maxDistance; dx++) {
|
|
|
|
|
|
for (let dy = -maxDistance; dy <= maxDistance; dy++) {
|
|
|
|
|
|
const gridX = centerX + dx;
|
|
|
|
|
|
const gridY = centerY + dy;
|
|
|
|
|
|
if (gridX >= 0 && gridX < samples && gridY >= 0 && gridY < samples) {
|
|
|
|
|
|
affectedGridPoints.add(`${gridX},${gridY}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-09-29 11:05:42 +08:00
|
|
|
|
|
2025-09-30 11:29:09 +08:00
|
|
|
|
// 第二步:只计算受影响的网格点
|
2025-10-03 11:16:01 +08:00
|
|
|
|
affectedGridPoints.forEach((gridKey) => {
|
|
|
|
|
|
const [gridX, gridY] = gridKey.split(",").map(Number);
|
2025-09-30 11:29:09 +08:00
|
|
|
|
const x = range[0] + gridX * gridSize;
|
|
|
|
|
|
const y = range[0] + gridY * gridSize;
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 11:29:09 +08:00
|
|
|
|
let sum = 0;
|
|
|
|
|
|
let validPoints = 0;
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 11:29:09 +08:00
|
|
|
|
// 只考虑附近的点(空间分割优化)
|
2025-10-03 11:16:01 +08:00
|
|
|
|
data.forEach((point) => {
|
2025-09-30 11:29:09 +08:00
|
|
|
|
const dx = (x - point[0]) / gridSize;
|
|
|
|
|
|
const dy = (y - point[1]) / gridSize;
|
|
|
|
|
|
const cacheKey = `${Math.round(dx)},${Math.round(dy)}`;
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 11:29:09 +08:00
|
|
|
|
if (kernelCache.has(cacheKey)) {
|
|
|
|
|
|
sum += kernelCache.get(cacheKey);
|
|
|
|
|
|
validPoints++;
|
2025-09-29 11:05:42 +08:00
|
|
|
|
}
|
2025-09-30 11:29:09 +08:00
|
|
|
|
});
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 11:29:09 +08:00
|
|
|
|
if (validPoints > 0) {
|
2025-09-29 11:05:42 +08:00
|
|
|
|
densityData.push([x, y, sum / data.length]);
|
|
|
|
|
|
}
|
2025-09-30 11:29:09 +08:00
|
|
|
|
});
|
2025-09-29 11:05:42 +08:00
|
|
|
|
|
|
|
|
|
|
// 归一化
|
2025-09-30 11:29:09 +08:00
|
|
|
|
if (densityData.length > 0) {
|
|
|
|
|
|
const maxDensity = Math.max(...densityData.map((d) => d[2]));
|
|
|
|
|
|
densityData.forEach((d) => {
|
|
|
|
|
|
if (maxDensity > 0) d[2] /= maxDensity;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-09-29 11:05:42 +08:00
|
|
|
|
|
|
|
|
|
|
return densityData;
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 颜色映射函数 - 将密度值映射到颜色
|
|
|
|
|
|
* @param {Number} density 密度值 0-1
|
|
|
|
|
|
* @returns {String} RGBA颜色字符串
|
|
|
|
|
|
*/
|
|
|
|
|
|
function getHeatColor(density) {
|
|
|
|
|
|
// 绿色系热力图:从浅绿到深绿
|
|
|
|
|
|
if (density < 0.1) return "rgba(0, 255, 0, 0)";
|
|
|
|
|
|
|
|
|
|
|
|
const alpha = Math.min(density * 1.2, 1); // 增强透明度
|
|
|
|
|
|
const intensity = density;
|
|
|
|
|
|
|
|
|
|
|
|
if (intensity < 0.5) {
|
|
|
|
|
|
// 低密度:浅绿色
|
|
|
|
|
|
const green = Math.round(200 + 55 * intensity);
|
|
|
|
|
|
const blue = Math.round(50 + 100 * intensity);
|
2025-10-03 11:16:01 +08:00
|
|
|
|
return `rgba(${Math.round(50 * intensity)}, ${green}, ${blue}, ${
|
|
|
|
|
|
alpha * 0.7
|
|
|
|
|
|
})`;
|
2025-09-29 11:05:42 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
// 高密度:深绿色
|
|
|
|
|
|
const red = Math.round(50 * (intensity - 0.5) * 2);
|
|
|
|
|
|
const green = Math.round(180 + 75 * (1 - intensity));
|
|
|
|
|
|
const blue = Math.round(30 * (1 - intensity));
|
2025-09-30 16:47:00 +08:00
|
|
|
|
return `rgba(${red}, ${green}, ${blue}, ${alpha * 0.8})`;
|
2025-09-29 11:05:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-30 11:29:09 +08:00
|
|
|
|
// 添加缓存机制
|
|
|
|
|
|
const heatmapCache = new Map();
|
|
|
|
|
|
|
2025-09-29 11:05:42 +08:00
|
|
|
|
/**
|
2025-09-30 11:29:09 +08:00
|
|
|
|
* 基于小程序Canvas API绘制核密度估计热力图 - 带缓存优化
|
2025-09-29 11:05:42 +08:00
|
|
|
|
* @param {String} canvasId 画布ID
|
|
|
|
|
|
* @param {Number} width 画布宽度
|
|
|
|
|
|
* @param {Number} height 画布高度
|
|
|
|
|
|
* @param {Array} points 箭矢坐标数组 [[x, y], ...]
|
|
|
|
|
|
* @param {Object} options 可选参数
|
|
|
|
|
|
* @returns {Promise} 绘制完成的Promise
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function drawKDEHeatmap(canvasId, width, height, points, options = {}) {
|
2025-09-30 16:47:00 +08:00
|
|
|
|
return new Promise(async (resolve, reject) => {
|
2025-09-29 11:05:42 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const {
|
|
|
|
|
|
bandwidth = 0.8,
|
|
|
|
|
|
gridSize = 100,
|
|
|
|
|
|
range = [-4, 4],
|
2025-10-03 11:16:01 +08:00
|
|
|
|
showPoints = false,
|
2025-09-29 11:05:42 +08:00
|
|
|
|
pointColor = "rgba(255, 255, 255, 0.9)",
|
|
|
|
|
|
} = options;
|
|
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
// 创建绘图上下文 - iOS兼容性检查
|
|
|
|
|
|
let ctx;
|
|
|
|
|
|
try {
|
|
|
|
|
|
ctx = uni.createCanvasContext(canvasId);
|
|
|
|
|
|
if (!ctx) {
|
|
|
|
|
|
throw new Error("无法创建canvas上下文");
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("创建canvas上下文失败:", error);
|
|
|
|
|
|
reject(new Error("iOS兼容性:Canvas上下文创建失败"));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-09-29 11:05:42 +08:00
|
|
|
|
|
|
|
|
|
|
// 清空画布
|
|
|
|
|
|
ctx.clearRect(0, 0, width, height);
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
// iOS兼容性:设置全局合成操作,让颜色叠加更自然
|
|
|
|
|
|
try {
|
2025-10-03 11:16:01 +08:00
|
|
|
|
ctx.globalCompositeOperation = "screen";
|
2025-09-30 16:47:00 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.warn("设置全局合成操作失败,使用默认设置:", error);
|
|
|
|
|
|
}
|
2025-09-29 11:05:42 +08:00
|
|
|
|
|
|
|
|
|
|
// 如果没有数据,直接绘制
|
|
|
|
|
|
if (!points || points.length === 0) {
|
|
|
|
|
|
ctx.draw(false, () => resolve());
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
// 使用分片处理,避免长时间阻塞主线程
|
|
|
|
|
|
const processInChunks = (data, chunkSize = 1000) => {
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
|
let index = 0;
|
|
|
|
|
|
let frameCount = 0;
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
const processChunk = () => {
|
|
|
|
|
|
// iOS兼容性:使用Date.now()作为performance.now的回退
|
2025-10-03 11:16:01 +08:00
|
|
|
|
const startTime =
|
|
|
|
|
|
typeof performance !== "undefined" && performance.now
|
|
|
|
|
|
? performance.now()
|
|
|
|
|
|
: Date.now();
|
2025-09-30 16:47:00 +08:00
|
|
|
|
const endIndex = Math.min(index + chunkSize, data.length);
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
// 批量处理多个点,减少函数调用开销
|
|
|
|
|
|
ctx.save(); // 保存当前状态
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
for (let i = index; i < endIndex; i++) {
|
|
|
|
|
|
const point = data[i];
|
|
|
|
|
|
// 处理单个点的绘制逻辑
|
|
|
|
|
|
processPoint(point);
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
// 每处理50个点检查一次时间,避免超时
|
2025-10-03 11:16:01 +08:00
|
|
|
|
const currentTime =
|
|
|
|
|
|
typeof performance !== "undefined" && performance.now
|
|
|
|
|
|
? performance.now()
|
|
|
|
|
|
: Date.now();
|
2025-09-30 16:47:00 +08:00
|
|
|
|
if (i % 50 === 0 && currentTime - startTime > 8) {
|
|
|
|
|
|
// 如果处理时间超过8ms,保存状态并中断
|
|
|
|
|
|
index = i + 1;
|
|
|
|
|
|
ctx.restore();
|
|
|
|
|
|
// iOS兼容性:更安全的requestAnimationFrame检测
|
2025-10-03 11:16:01 +08:00
|
|
|
|
if (typeof requestAnimationFrame === "function") {
|
2025-09-30 16:47:00 +08:00
|
|
|
|
try {
|
|
|
|
|
|
requestAnimationFrame(processChunk);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// 如果requestAnimationFrame失败,使用setTimeout
|
|
|
|
|
|
setTimeout(processChunk, 2);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setTimeout(processChunk, 2); // 小延迟后继续
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
ctx.restore(); // 恢复状态
|
|
|
|
|
|
index = endIndex;
|
|
|
|
|
|
frameCount++;
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
if (index < data.length) {
|
|
|
|
|
|
// 动态调整延迟:如果处理时间超过16ms(一帧),使用更大延迟
|
2025-10-03 11:16:01 +08:00
|
|
|
|
const currentTime =
|
|
|
|
|
|
typeof performance !== "undefined" && performance.now
|
|
|
|
|
|
? performance.now()
|
|
|
|
|
|
: Date.now();
|
2025-09-30 16:47:00 +08:00
|
|
|
|
const processingTime = currentTime - startTime;
|
|
|
|
|
|
const delay = processingTime > 16 ? 8 : 1; // 根据处理时间动态调整
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
// iOS兼容性:更安全的requestAnimationFrame检测
|
2025-10-03 11:16:01 +08:00
|
|
|
|
if (typeof requestAnimationFrame === "function") {
|
2025-09-30 16:47:00 +08:00
|
|
|
|
try {
|
|
|
|
|
|
requestAnimationFrame(processChunk);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// 如果requestAnimationFrame失败,使用setTimeout
|
|
|
|
|
|
setTimeout(processChunk, delay);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setTimeout(processChunk, delay);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
resolve();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
processChunk();
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理单个点的函数 - iOS兼容性优化
|
|
|
|
|
|
const processPoint = (point) => {
|
|
|
|
|
|
const [x, y, density] = point;
|
|
|
|
|
|
const normalizedX = (x - range[0]) / (range[1] - range[0]);
|
|
|
|
|
|
const normalizedY = (y - range[0]) / (range[1] - range[0]);
|
|
|
|
|
|
const canvasX = normalizedX * width;
|
|
|
|
|
|
const canvasY = normalizedY * height;
|
|
|
|
|
|
const color = getHeatColor(density);
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
// iOS兼容性:确保数值有效
|
2025-10-03 11:16:01 +08:00
|
|
|
|
if (
|
|
|
|
|
|
isNaN(canvasX) ||
|
|
|
|
|
|
isNaN(canvasY) ||
|
|
|
|
|
|
!isFinite(canvasX) ||
|
|
|
|
|
|
!isFinite(canvasY)
|
|
|
|
|
|
) {
|
2025-09-30 16:47:00 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
ctx.setFillStyle(color);
|
|
|
|
|
|
ctx.beginPath();
|
2025-10-03 11:16:01 +08:00
|
|
|
|
const radius = Math.max(
|
|
|
|
|
|
1,
|
|
|
|
|
|
Math.min(width / gridSize, height / gridSize) * 0.6
|
|
|
|
|
|
); // 确保半径至少为1
|
2025-09-30 16:47:00 +08:00
|
|
|
|
ctx.arc(canvasX, canvasY, radius, 0, 2 * Math.PI);
|
|
|
|
|
|
ctx.fill();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-30 11:29:09 +08:00
|
|
|
|
// 生成缓存key(基于参数和数据点的哈希)
|
2025-10-03 11:16:01 +08:00
|
|
|
|
const cacheKey = `${bandwidth}-${gridSize}-${range.join(",")}-${
|
|
|
|
|
|
points.length
|
|
|
|
|
|
}-${JSON.stringify(points.slice(0, 10))}`;
|
|
|
|
|
|
|
2025-09-30 11:29:09 +08:00
|
|
|
|
// 检查缓存
|
|
|
|
|
|
if (heatmapCache.has(cacheKey)) {
|
2025-10-03 11:16:01 +08:00
|
|
|
|
console.log("使用缓存的热力图数据");
|
2025-09-30 11:29:09 +08:00
|
|
|
|
const cachedDensityData = heatmapCache.get(cacheKey);
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
// 使用分片处理绘制缓存数据
|
2025-10-03 11:16:01 +08:00
|
|
|
|
await processInChunks(cachedDensityData, 200); // 每批处理200个点,减少单次处理量
|
|
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
// 绘制原始数据点 - iOS兼容性优化
|
2025-09-30 11:29:09 +08:00
|
|
|
|
if (showPoints) {
|
|
|
|
|
|
ctx.setFillStyle(pointColor);
|
2025-09-30 16:47:00 +08:00
|
|
|
|
ctx.beginPath(); // 开始批量路径
|
|
|
|
|
|
const xRange = range[1] - range[0];
|
|
|
|
|
|
const yRange = range[1] - range[0];
|
|
|
|
|
|
let validPoints = 0;
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 11:29:09 +08:00
|
|
|
|
points.forEach((point) => {
|
|
|
|
|
|
const [x, y] = point;
|
|
|
|
|
|
const normalizedX = (x - range[0]) / xRange;
|
|
|
|
|
|
const normalizedY = (y - range[0]) / yRange;
|
|
|
|
|
|
const canvasX = normalizedX * width;
|
|
|
|
|
|
const canvasY = normalizedY * height;
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
// iOS兼容性:确保坐标有效
|
2025-10-03 11:16:01 +08:00
|
|
|
|
if (
|
|
|
|
|
|
!isNaN(canvasX) &&
|
|
|
|
|
|
!isNaN(canvasY) &&
|
|
|
|
|
|
isFinite(canvasX) &&
|
|
|
|
|
|
isFinite(canvasY)
|
|
|
|
|
|
) {
|
2025-09-30 16:47:00 +08:00
|
|
|
|
ctx.arc(canvasX, canvasY, 2.5, 0, 2 * Math.PI);
|
|
|
|
|
|
validPoints++;
|
|
|
|
|
|
}
|
2025-09-30 11:29:09 +08:00
|
|
|
|
});
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
// 只有在有有效点的情况下才执行填充
|
|
|
|
|
|
if (validPoints > 0) {
|
|
|
|
|
|
ctx.fill(); // 一次性填充所有圆点
|
|
|
|
|
|
}
|
2025-09-30 11:29:09 +08:00
|
|
|
|
}
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
|
|
|
|
|
ctx.draw(
|
|
|
|
|
|
false,
|
|
|
|
|
|
() => {
|
|
|
|
|
|
console.log("KDE热力图绘制完成(缓存)");
|
|
|
|
|
|
resolve();
|
|
|
|
|
|
},
|
|
|
|
|
|
(error) => {
|
|
|
|
|
|
console.error("KDE热力图绘制失败(缓存):", error);
|
|
|
|
|
|
reject(new Error("Canvas绘制失败(缓存): " + error));
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
2025-09-30 11:29:09 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-29 11:05:42 +08:00
|
|
|
|
// 计算核密度估计
|
|
|
|
|
|
const kernel = kernelEpanechnikov(bandwidth);
|
|
|
|
|
|
const kde = kernelDensityEstimator(kernel, range, gridSize);
|
|
|
|
|
|
const densityData = kde(points);
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 11:29:09 +08:00
|
|
|
|
// 缓存结果(限制缓存大小)
|
|
|
|
|
|
if (heatmapCache.size > 10) {
|
|
|
|
|
|
const firstKey = heatmapCache.keys().next().value;
|
|
|
|
|
|
heatmapCache.delete(firstKey);
|
|
|
|
|
|
}
|
|
|
|
|
|
heatmapCache.set(cacheKey, densityData);
|
2025-09-29 11:05:42 +08:00
|
|
|
|
|
|
|
|
|
|
// 计算网格大小
|
|
|
|
|
|
const cellWidth = width / gridSize;
|
|
|
|
|
|
const cellHeight = height / gridSize;
|
|
|
|
|
|
const xRange = range[1] - range[0];
|
|
|
|
|
|
const yRange = range[1] - range[0];
|
|
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
// 使用分片处理绘制热力图网格
|
|
|
|
|
|
await processInChunks(densityData, 200); // 每批处理200个点,减少单次处理量
|
2025-09-29 11:05:42 +08:00
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
// 绘制原始数据点 - iOS兼容性优化
|
2025-09-29 11:05:42 +08:00
|
|
|
|
if (showPoints) {
|
|
|
|
|
|
ctx.setFillStyle(pointColor);
|
2025-09-30 11:29:09 +08:00
|
|
|
|
ctx.beginPath(); // 开始批量路径
|
2025-09-30 16:47:00 +08:00
|
|
|
|
let validPoints = 0;
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-29 11:05:42 +08:00
|
|
|
|
points.forEach((point) => {
|
|
|
|
|
|
const [x, y] = point;
|
|
|
|
|
|
const normalizedX = (x - range[0]) / xRange;
|
|
|
|
|
|
const normalizedY = (y - range[0]) / yRange;
|
|
|
|
|
|
const canvasX = normalizedX * width;
|
|
|
|
|
|
const canvasY = normalizedY * height;
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
// iOS兼容性:确保坐标有效
|
2025-10-03 11:16:01 +08:00
|
|
|
|
if (
|
|
|
|
|
|
!isNaN(canvasX) &&
|
|
|
|
|
|
!isNaN(canvasY) &&
|
|
|
|
|
|
isFinite(canvasX) &&
|
|
|
|
|
|
isFinite(canvasY)
|
|
|
|
|
|
) {
|
2025-09-30 16:47:00 +08:00
|
|
|
|
ctx.arc(canvasX, canvasY, 2.5, 0, 2 * Math.PI);
|
|
|
|
|
|
validPoints++;
|
|
|
|
|
|
}
|
2025-09-29 11:05:42 +08:00
|
|
|
|
});
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
// 只有在有有效点的情况下才执行填充
|
|
|
|
|
|
if (validPoints > 0) {
|
|
|
|
|
|
ctx.fill(); // 一次性填充所有圆点
|
|
|
|
|
|
}
|
2025-09-29 11:05:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-30 16:47:00 +08:00
|
|
|
|
// 执行绘制 - iOS兼容性优化
|
2025-10-03 11:16:01 +08:00
|
|
|
|
ctx.draw(
|
|
|
|
|
|
false,
|
|
|
|
|
|
() => {
|
|
|
|
|
|
console.log("KDE热力图绘制完成");
|
|
|
|
|
|
resolve();
|
|
|
|
|
|
},
|
|
|
|
|
|
(error) => {
|
|
|
|
|
|
console.error("KDE热力图绘制失败:", error);
|
|
|
|
|
|
reject(new Error("Canvas绘制失败: " + error));
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
2025-09-29 11:05:42 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("KDE热力图绘制失败:", error);
|
|
|
|
|
|
reject(error);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 生成热力图图片(类似原有的generateHeatmapImage函数)
|
|
|
|
|
|
* 但使用核密度估计算法
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function generateKDEHeatmapImage(
|
|
|
|
|
|
canvasId,
|
|
|
|
|
|
width,
|
|
|
|
|
|
height,
|
|
|
|
|
|
points,
|
|
|
|
|
|
options = {}
|
|
|
|
|
|
) {
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
drawKDEHeatmap(canvasId, width, height, points, options)
|
|
|
|
|
|
.then(() => {
|
2025-09-30 16:47:00 +08:00
|
|
|
|
// 生成图片 - iOS兼容性优化
|
2025-09-29 11:05:42 +08:00
|
|
|
|
uni.canvasToTempFilePath({
|
|
|
|
|
|
canvasId: canvasId,
|
|
|
|
|
|
width: width,
|
|
|
|
|
|
height: height,
|
2025-09-30 16:47:00 +08:00
|
|
|
|
destWidth: width * 2, // iOS兼容性:降低分辨率避免内存问题
|
|
|
|
|
|
destHeight: height * 2,
|
|
|
|
|
|
fileType: "png", // iOS兼容性:明确指定png格式
|
|
|
|
|
|
quality: 1, // iOS兼容性:最高质量
|
2025-09-29 11:05:42 +08:00
|
|
|
|
success: (res) => {
|
|
|
|
|
|
console.log("KDE热力图图片生成成功:", res.tempFilePath);
|
|
|
|
|
|
resolve(res.tempFilePath);
|
|
|
|
|
|
},
|
|
|
|
|
|
fail: (error) => {
|
|
|
|
|
|
console.error("KDE热力图图片生成失败:", error);
|
|
|
|
|
|
reject(error);
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(reject);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-30 11:29:09 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 清除热力图缓存
|
|
|
|
|
|
* 在数据或参数需要强制更新时调用
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function clearHeatmapCache() {
|
|
|
|
|
|
heatmapCache.clear();
|
2025-10-03 11:16:01 +08:00
|
|
|
|
console.log("热力图缓存已清除");
|
2025-09-30 11:29:09 +08:00
|
|
|
|
}
|