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-10-03 16:40:28 +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
|
|
|
|
|
2025-10-03 16:40:28 +08:00
|
|
|
|
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]]);
|
2025-09-30 11:29:09 +08:00
|
|
|
|
}
|
2025-09-29 11:05:42 +08:00
|
|
|
|
densityData.push([x, y, sum / data.length]);
|
|
|
|
|
|
}
|
2025-10-03 16:40:28 +08:00
|
|
|
|
}
|
2025-09-29 11:05:42 +08:00
|
|
|
|
|
|
|
|
|
|
// 归一化
|
2025-10-03 16:40:28 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-03 16:40:28 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 生成随机射箭数据点
|
|
|
|
|
|
* @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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-29 11:05:42 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 颜色映射函数 - 将密度值映射到颜色
|
|
|
|
|
|
* @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 16:40:28 +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-10-03 16:40:28 +08:00
|
|
|
|
return `rgba(${red}, ${green}, ${blue}, ${alpha * 0.7})`;
|
2025-09-29 11:05:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-03 16:40:28 +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-10-03 16:40:28 +08:00
|
|
|
|
const {
|
|
|
|
|
|
bandwidth = 0.8,
|
|
|
|
|
|
gridSize = 100,
|
|
|
|
|
|
range = [-4, 4],
|
|
|
|
|
|
showPoints = true,
|
|
|
|
|
|
pointColor = "rgba(255, 255, 255, 0.9)",
|
|
|
|
|
|
} = options;
|
|
|
|
|
|
|
|
|
|
|
|
// #ifdef MP-WEIXIN
|
|
|
|
|
|
// 微信小程序使用 Canvas 2D
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
2025-09-29 11:05:42 +08:00
|
|
|
|
try {
|
2025-10-03 16:40:28 +08:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
2025-09-30 16:47:00 +08:00
|
|
|
|
}
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-10-03 16:40:28 +08:00
|
|
|
|
resolve();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
reject(err);
|
|
|
|
|
|
}
|
2025-09-30 16:47:00 +08:00
|
|
|
|
});
|
2025-10-03 16:40:28 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
reject(error);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
// #endif
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-10-03 16:40:28 +08:00
|
|
|
|
// #ifndef MP-WEIXIN
|
|
|
|
|
|
// 其他平台沿用旧版绘制上下文
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const ctx = uni.createCanvasContext(canvasId);
|
|
|
|
|
|
ctx.clearRect(0, 0, width, height);
|
2025-10-03 11:16:01 +08:00
|
|
|
|
|
2025-10-03 16:40:28 +08:00
|
|
|
|
if (!points || points.length === 0) {
|
|
|
|
|
|
ctx.draw(false, () => resolve());
|
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-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-10-03 16:40:28 +08:00
|
|
|
|
densityData.forEach(([x, y, density]) => {
|
|
|
|
|
|
const normalizedX = (x - range[0]) / xRange;
|
|
|
|
|
|
const normalizedY = (y - range[0]) / yRange;
|
|
|
|
|
|
const canvasX = normalizedX * width;
|
|
|
|
|
|
const canvasY = normalizedY * height;
|
|
|
|
|
|
|
|
|
|
|
|
const color = getHeatColor(density);
|
|
|
|
|
|
ctx.setFillStyle(color);
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
ctx.arc(
|
|
|
|
|
|
canvasX,
|
|
|
|
|
|
canvasY,
|
|
|
|
|
|
Math.min(cellWidth, cellHeight) * 0.6,
|
|
|
|
|
|
0,
|
|
|
|
|
|
2 * Math.PI
|
|
|
|
|
|
);
|
|
|
|
|
|
ctx.fill();
|
|
|
|
|
|
});
|
2025-09-29 11:05:42 +08:00
|
|
|
|
|
|
|
|
|
|
if (showPoints) {
|
|
|
|
|
|
ctx.setFillStyle(pointColor);
|
2025-10-03 16:40:28 +08:00
|
|
|
|
points.forEach(([x, y]) => {
|
2025-09-29 11:05:42 +08:00
|
|
|
|
const normalizedX = (x - range[0]) / xRange;
|
|
|
|
|
|
const normalizedY = (y - range[0]) / yRange;
|
|
|
|
|
|
const canvasX = normalizedX * width;
|
|
|
|
|
|
const canvasY = normalizedY * height;
|
2025-10-03 16:40:28 +08:00
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
ctx.arc(canvasX, canvasY, 2.5, 0, 2 * Math.PI);
|
|
|
|
|
|
ctx.fill();
|
2025-09-29 11:05:42 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-03 16:40:28 +08:00
|
|
|
|
ctx.draw(false, () => resolve());
|
2025-09-29 11:05:42 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
reject(error);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-10-03 16:40:28 +08:00
|
|
|
|
// #endif
|
2025-09-29 11:05:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 生成热力图图片(类似原有的generateHeatmapImage函数)
|
|
|
|
|
|
* 但使用核密度估计算法
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function generateKDEHeatmapImage(
|
|
|
|
|
|
canvasId,
|
|
|
|
|
|
width,
|
|
|
|
|
|
height,
|
|
|
|
|
|
points,
|
|
|
|
|
|
options = {}
|
|
|
|
|
|
) {
|
2025-10-03 16:40:28 +08:00
|
|
|
|
// #ifdef MP-WEIXIN
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
// #endif
|
|
|
|
|
|
|
|
|
|
|
|
// #ifndef MP-WEIXIN
|
|
|
|
|
|
// 旧版导出(使用 canvasId)
|
2025-09-29 11:05:42 +08:00
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
drawKDEHeatmap(canvasId, width, height, points, options)
|
|
|
|
|
|
.then(() => {
|
|
|
|
|
|
uni.canvasToTempFilePath({
|
2025-10-03 16:40:28 +08:00
|
|
|
|
canvasId,
|
|
|
|
|
|
width,
|
|
|
|
|
|
height,
|
|
|
|
|
|
destWidth: width * 3,
|
|
|
|
|
|
destHeight: height * 3,
|
|
|
|
|
|
success: (res) => resolve(res.tempFilePath),
|
|
|
|
|
|
fail: reject,
|
2025-09-29 11:05:42 +08:00
|
|
|
|
});
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(reject);
|
|
|
|
|
|
});
|
2025-10-03 16:40:28 +08:00
|
|
|
|
// #endif
|
2025-09-29 11:05:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-03 16:40:28 +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;
|
|
|
|
|
|
};
|