Files
shoot-miniprograms/src/kde-heatmap.js
2025-10-09 18:14:07 +08:00

278 lines
8.0 KiB
JavaScript
Raw Permalink 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 = [];
for (let x = range[0]; x <= range[1]; x += gridSize) {
for (let y = range[0]; y <= range[1]; y += gridSize) {
let sum = 0;
for (const point of data) {
sum += kernel([x - point[0], y - point[1]]);
}
densityData.push([x, y, sum / data.length]);
}
}
// 归一化
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.7})`;
}
}
/**
* 基于小程序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 = {}) {
const {
bandwidth = 0.8,
gridSize = 100,
range = [-4, 4],
showPoints = true,
pointColor = "rgba(255, 255, 255, 0.9)",
} = options;
// 微信小程序使用 Canvas 2D
return new Promise((resolve, reject) => {
try {
wx.createSelectorQuery()
.select(`#${canvasId}`)
.fields({ node: true, size: true })
.exec((res) => {
try {
const { node: canvas, width: w, height: h } = res[0] || {};
if (!canvas) return resolve();
// 设置画布尺寸
const cw = width || w || 300;
const ch = height || h || 300;
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, cw, ch);
if (!points || points.length === 0) return resolve();
// 计算核密度估计
const kernel = kernelEpanechnikov(bandwidth);
const kde = kernelDensityEstimator(kernel, range, gridSize);
const densityData = kde(points);
// 计算网格大小
const cellWidth = cw / gridSize;
const cellHeight = ch / gridSize;
const xRange = range[1] - range[0];
const yRange = range[1] - range[0];
// 绘制热力图网格
densityData.forEach(([x, y, density]) => {
const normalizedX = (x - range[0]) / xRange;
const normalizedY = (y - range[0]) / yRange;
const canvasX = normalizedX * cw;
const canvasY = normalizedY * ch;
const color = getHeatColor(density);
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(
canvasX,
canvasY,
Math.min(cellWidth, cellHeight) * 0.6,
0,
2 * Math.PI
);
ctx.fill();
});
// 绘制原始数据点
if (showPoints) {
ctx.fillStyle = pointColor;
points.forEach(([x, y]) => {
const normalizedX = (x - range[0]) / xRange;
const normalizedY = (y - range[0]) / yRange;
const canvasX = normalizedX * cw;
const canvasY = normalizedY * ch;
ctx.beginPath();
ctx.arc(canvasX, canvasY, 2.5, 0, 2 * Math.PI);
ctx.fill();
});
}
resolve();
} catch (err) {
reject(err);
}
});
} catch (error) {
reject(error);
}
});
}
/**
* 生成热力图图片类似原有的generateHeatmapImage函数
* 但使用核密度估计算法
*/
export function generateKDEHeatmapImage(
canvasId,
width,
height,
points,
options = {}
) {
// Canvas 2D 导出(传入 canvas 对象)
return new Promise((resolve, reject) => {
drawKDEHeatmap(canvasId, width, height, points, options)
.then(() => {
try {
wx.createSelectorQuery()
.select(`#${canvasId}`)
.fields({ node: true, size: true })
.exec((res) => {
const { node: canvas, width: w, height: h } = res[0] || {};
if (!canvas) return reject(new Error("canvas 为空"));
const cw = width || w || 300;
const ch = height || h || 300;
uni.canvasToTempFilePath({
canvas,
width: cw,
height: ch,
destWidth: cw * 3,
destHeight: ch * 3,
success: (r) => resolve(r.tempFilePath),
fail: reject,
});
});
} catch (e) {
reject(e);
}
})
.catch(reject);
});
}
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;
};