Files
shoot-miniprograms/src/kde-heatmap.js
2025-09-30 16:47:00 +08:00

498 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 基于小程序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;
};
}
/**
* 核密度估计器 - 优化版本
* @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 = [];
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}`);
}
}
}
});
// 第二步:只计算受影响的网格点
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++;
}
});
if (validPoints > 0) {
densityData.push([x, y, sum / data.length]);
}
});
// 归一化
if (densityData.length > 0) {
const maxDensity = Math.max(...densityData.map((d) => d[2]));
densityData.forEach((d) => {
if (maxDensity > 0) d[2] /= maxDensity;
});
}
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));
return `rgba(${red}, ${green}, ${blue}, ${alpha * 0.8})`;
}
}
// 添加缓存机制
const heatmapCache = new Map();
/**
* 基于小程序Canvas API绘制核密度估计热力图 - 带缓存优化
* @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 = {}) {
return new Promise(async (resolve, reject) => {
try {
// 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}`);
}
const {
bandwidth = 0.8,
gridSize = 100,
range = [-4, 4],
showPoints = true,
pointColor = "rgba(255, 255, 255, 0.9)",
} = options;
// 创建绘图上下文 - 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;
}
// 清空画布
ctx.clearRect(0, 0, width, height);
// iOS兼容性设置全局合成操作让颜色叠加更自然
try {
ctx.globalCompositeOperation = 'screen';
} catch (error) {
console.warn("设置全局合成操作失败,使用默认设置:", error);
}
// 如果没有数据,直接绘制
if (!points || points.length === 0) {
ctx.draw(false, () => resolve());
return;
}
// 使用分片处理,避免长时间阻塞主线程
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();
};
// 生成缓存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);
// 使用分片处理绘制缓存数据
await processInChunks(cachedDensityData, 200); // 每批处理200个点减少单次处理量
// 绘制原始数据点 - iOS兼容性优化
if (showPoints) {
ctx.setFillStyle(pointColor);
ctx.beginPath(); // 开始批量路径
const xRange = range[1] - range[0];
const yRange = range[1] - range[0];
let validPoints = 0;
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;
// iOS兼容性确保坐标有效
if (!isNaN(canvasX) && !isNaN(canvasY) && isFinite(canvasX) && isFinite(canvasY)) {
ctx.arc(canvasX, canvasY, 2.5, 0, 2 * Math.PI);
validPoints++;
}
});
// 只有在有有效点的情况下才执行填充
if (validPoints > 0) {
ctx.fill(); // 一次性填充所有圆点
}
}
ctx.draw(false, () => {
console.log("KDE热力图绘制完成缓存");
resolve();
}, (error) => {
console.error("KDE热力图绘制失败缓存:", error);
reject(new Error("Canvas绘制失败缓存: " + error));
});
return;
}
// 计算核密度估计
const kernel = kernelEpanechnikov(bandwidth);
const kde = kernelDensityEstimator(kernel, range, gridSize);
const densityData = kde(points);
// 缓存结果(限制缓存大小)
if (heatmapCache.size > 10) {
const firstKey = heatmapCache.keys().next().value;
heatmapCache.delete(firstKey);
}
heatmapCache.set(cacheKey, densityData);
// 计算网格大小
const cellWidth = width / gridSize;
const cellHeight = height / gridSize;
const xRange = range[1] - range[0];
const yRange = range[1] - range[0];
// 使用分片处理绘制热力图网格
await processInChunks(densityData, 200); // 每批处理200个点减少单次处理量
// 绘制原始数据点 - iOS兼容性优化
if (showPoints) {
ctx.setFillStyle(pointColor);
ctx.beginPath(); // 开始批量路径
let validPoints = 0;
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;
// iOS兼容性确保坐标有效
if (!isNaN(canvasX) && !isNaN(canvasY) && isFinite(canvasX) && isFinite(canvasY)) {
ctx.arc(canvasX, canvasY, 2.5, 0, 2 * Math.PI);
validPoints++;
}
});
// 只有在有有效点的情况下才执行填充
if (validPoints > 0) {
ctx.fill(); // 一次性填充所有圆点
}
}
// 执行绘制 - iOS兼容性优化
ctx.draw(false, () => {
console.log("KDE热力图绘制完成");
resolve();
}, (error) => {
console.error("KDE热力图绘制失败:", error);
reject(new Error("Canvas绘制失败: " + error));
});
} 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(() => {
// 生成图片 - iOS兼容性优化
uni.canvasToTempFilePath({
canvasId: canvasId,
width: width,
height: height,
destWidth: width * 2, // iOS兼容性降低分辨率避免内存问题
destHeight: height * 2,
fileType: "png", // iOS兼容性明确指定png格式
quality: 1, // iOS兼容性最高质量
success: (res) => {
console.log("KDE热力图图片生成成功:", res.tempFilePath);
resolve(res.tempFilePath);
},
fail: (error) => {
console.error("KDE热力图图片生成失败:", error);
reject(error);
},
});
})
.catch(reject);
});
}
/**
* 清除热力图缓存
* 在数据或参数需要强制更新时调用
*/
export function clearHeatmapCache() {
heatmapCache.clear();
console.log('热力图缓存已清除');
}
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;
};