Files
shoot-miniprograms/src/kde-heatmap.js

498 lines
17 KiB
JavaScript
Raw Normal View History

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; // 从核函数中提取带宽
// 预计算核函数值缓存(减少重复计算)
const kernelCache = new Map();
const maxDistance = Math.ceil(bandwidth * 2 / gridSize); // 最大影响范围
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) {
kernelCache.set(`${dx},${dy}`, kernel([dx * gridSize, dy * gridSize]));
}
}
}
// 使用稀疏网格计算(只计算有数据点影响的区域)
const affectedGridPoints = new Set();
// 第一步:找出所有受影响的网格点
data.forEach(point => {
const centerX = Math.round((point[0] - range[0]) / gridSize);
const centerY = Math.round((point[1] - range[0]) / gridSize);
// 只考虑带宽范围内的网格点
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
// 第二步:只计算受影响的网格点
affectedGridPoints.forEach(gridKey => {
const [gridX, gridY] = gridKey.split(',').map(Number);
const x = range[0] + gridX * gridSize;
const y = range[0] + gridY * gridSize;
let sum = 0;
let validPoints = 0;
// 只考虑附近的点(空间分割优化)
data.forEach(point => {
const dx = (x - point[0]) / gridSize;
const dy = (y - point[1]) / gridSize;
const cacheKey = `${Math.round(dx)},${Math.round(dy)}`;
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
});
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} centerCount 中心点数量
* @param {Number} pointsPerCenter 每个中心点的箭数
* @returns {Array} 箭矢坐标数组
*/
export function generateArcheryPoints(centerCount = 2, pointsPerCenter = 100) {
const points = [];
const range = 8; // 坐标范围 -4 到 4
const spread = 3; // 分散度
for (let i = 0; i < centerCount; i++) {
const centerX = Math.random() * range - range / 2;
const centerY = Math.random() * range - range / 2;
for (let j = 0; j < pointsPerCenter; j++) {
points.push([
centerX + (Math.random() - 0.5) * spread,
centerY + (Math.random() - 0.5) * spread,
]);
}
}
return points;
}
/**
* 颜色映射函数 - 将密度值映射到颜色
* @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);
return `rgba(${Math.round(50 * intensity)}, ${green}, ${blue}, ${alpha * 0.7})`;
} 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 {
2025-09-30 16:47:00 +08:00
// iOS兼容性限制canvas尺寸避免内存问题
const maxCanvasSize = 1500; // iOS设备最大建议尺寸
if (width > maxCanvasSize || height > maxCanvasSize) {
const scale = Math.min(maxCanvasSize / width, maxCanvasSize / height);
width = Math.floor(width * scale);
height = Math.floor(height * scale);
console.log(`iOS兼容性限制canvas尺寸为 ${width}x${height}`);
}
2025-09-29 11:05:42 +08:00
const {
bandwidth = 0.8,
gridSize = 100,
range = [-4, 4],
showPoints = true,
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-09-30 16:47:00 +08:00
// iOS兼容性设置全局合成操作让颜色叠加更自然
try {
ctx.globalCompositeOperation = 'screen';
} 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;
const processChunk = () => {
// iOS兼容性使用Date.now()作为performance.now的回退
const startTime = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
const endIndex = Math.min(index + chunkSize, data.length);
// 批量处理多个点,减少函数调用开销
ctx.save(); // 保存当前状态
for (let i = index; i < endIndex; i++) {
const point = data[i];
// 处理单个点的绘制逻辑
processPoint(point);
// 每处理50个点检查一次时间避免超时
const currentTime = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
if (i % 50 === 0 && currentTime - startTime > 8) {
// 如果处理时间超过8ms保存状态并中断
index = i + 1;
ctx.restore();
// iOS兼容性更安全的requestAnimationFrame检测
if (typeof requestAnimationFrame === 'function') {
try {
requestAnimationFrame(processChunk);
} catch (e) {
// 如果requestAnimationFrame失败使用setTimeout
setTimeout(processChunk, 2);
}
} else {
setTimeout(processChunk, 2); // 小延迟后继续
}
return;
}
}
ctx.restore(); // 恢复状态
index = endIndex;
frameCount++;
if (index < data.length) {
// 动态调整延迟如果处理时间超过16ms一帧使用更大延迟
const currentTime = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
const processingTime = currentTime - startTime;
const delay = processingTime > 16 ? 8 : 1; // 根据处理时间动态调整
// iOS兼容性更安全的requestAnimationFrame检测
if (typeof requestAnimationFrame === 'function') {
try {
requestAnimationFrame(processChunk);
} catch (e) {
// 如果requestAnimationFrame失败使用setTimeout
setTimeout(processChunk, delay);
}
} else {
setTimeout(processChunk, delay);
}
} else {
resolve();
}
};
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);
// iOS兼容性确保数值有效
if (isNaN(canvasX) || isNaN(canvasY) || !isFinite(canvasX) || !isFinite(canvasY)) {
return;
}
ctx.setFillStyle(color);
ctx.beginPath();
const radius = Math.max(1, Math.min(width / gridSize, height / gridSize) * 0.6); // 确保半径至少为1
ctx.arc(canvasX, canvasY, radius, 0, 2 * Math.PI);
ctx.fill();
};
2025-09-30 11:29:09 +08:00
// 生成缓存key基于参数和数据点的哈希
const cacheKey = `${bandwidth}-${gridSize}-${range.join(',')}-${points.length}-${JSON.stringify(points.slice(0, 10))}`;
// 检查缓存
if (heatmapCache.has(cacheKey)) {
console.log('使用缓存的热力图数据');
const cachedDensityData = heatmapCache.get(cacheKey);
2025-09-30 16:47:00 +08:00
// 使用分片处理绘制缓存数据
await processInChunks(cachedDensityData, 200); // 每批处理200个点减少单次处理量
2025-09-30 11:29:09 +08:00
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-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-09-30 16:47:00 +08:00
// iOS兼容性确保坐标有效
if (!isNaN(canvasX) && !isNaN(canvasY) && isFinite(canvasX) && isFinite(canvasY)) {
ctx.arc(canvasX, canvasY, 2.5, 0, 2 * Math.PI);
validPoints++;
}
2025-09-30 11:29:09 +08:00
});
2025-09-30 16:47:00 +08:00
// 只有在有有效点的情况下才执行填充
if (validPoints > 0) {
ctx.fill(); // 一次性填充所有圆点
}
2025-09-30 11:29:09 +08:00
}
ctx.draw(false, () => {
console.log("KDE热力图绘制完成缓存");
resolve();
2025-09-30 16:47:00 +08:00
}, (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-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-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-09-30 11:29:09 +08:00
2025-09-30 16:47:00 +08:00
// iOS兼容性确保坐标有效
if (!isNaN(canvasX) && !isNaN(canvasY) && isFinite(canvasX) && isFinite(canvasY)) {
ctx.arc(canvasX, canvasY, 2.5, 0, 2 * Math.PI);
validPoints++;
}
2025-09-29 11:05:42 +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-09-29 11:05:42 +08:00
ctx.draw(false, () => {
console.log("KDE热力图绘制完成");
resolve();
2025-09-30 16:47:00 +08:00
}, (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();
console.log('热力图缓存已清除');
}
2025-09-29 11:05:42 +08:00
export const generateHeatMapData = (width, height, amount = 100) => {
const data = [];
const centerX = 0.5; // 中心点X坐标
const centerY = 0.5; // 中心点Y坐标
for (let i = 0; i < amount; i++) {
let x, y;
// 30%的数据集中在中心区域(高斯分布)
if (Math.random() < 0.3) {
// 使用正态分布生成中心区域的数据
const angle = Math.random() * 2 * Math.PI;
const radius = Math.sqrt(-2 * Math.log(Math.random())) * 0.15; // 标准差0.15
x = centerX + radius * Math.cos(angle);
y = centerY + radius * Math.sin(angle);
} else {
x = Math.random() * 0.8 + 0.1; // 0.1-0.9范围
y = Math.random() * 0.8 + 0.1;
}
// 确保坐标在0-1范围内
x = Math.max(0.05, Math.min(0.95, x));
y = Math.max(0.05, Math.min(0.95, y));
data.push({
x: parseFloat(x.toFixed(3)),
y: parseFloat(y.toFixed(3)),
ring: Math.floor(Math.random() * 5) + 6, // 6-10环
});
}
return data;
};