修改热力图渲染方式

This commit is contained in:
kron
2025-09-29 11:05:42 +08:00
parent 9c6824b82f
commit 301b7a67a0
3 changed files with 299 additions and 58 deletions

View File

@@ -64,6 +64,12 @@ button {
box-sizing: border-box; box-sizing: border-box;
} }
view::-webkit-scrollbar {
width: 0;
height: 0;
color: transparent;
}
button::after { button::after {
border: none; border: none;
} }

270
src/kde-heatmap.js Normal file
View File

@@ -0,0 +1,270 @@
/**
* 基于小程序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 = {}) {
return new Promise((resolve, reject) => {
try {
const {
bandwidth = 0.8,
gridSize = 100,
range = [-4, 4],
showPoints = true,
pointColor = "rgba(255, 255, 255, 0.9)",
} = options;
// 创建绘图上下文
const ctx = uni.createCanvasContext(canvasId);
// 清空画布
ctx.clearRect(0, 0, width, height);
// 如果没有数据,直接绘制
if (!points || points.length === 0) {
ctx.draw(false, () => resolve());
return;
}
// 计算核密度估计
const kernel = kernelEpanechnikov(bandwidth);
const kde = kernelDensityEstimator(kernel, range, gridSize);
const densityData = kde(points);
// 计算网格大小
const cellWidth = width / gridSize;
const cellHeight = height / gridSize;
const xRange = range[1] - range[0];
const yRange = range[1] - range[0];
// 绘制热力图网格
densityData.forEach((point) => {
const [x, y, density] = point;
// 将逻辑坐标转换为画布坐标
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();
});
// 绘制原始数据点
if (showPoints) {
ctx.setFillStyle(pointColor);
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;
// 绘制小圆点
ctx.beginPath();
ctx.arc(canvasX, canvasY, 2.5, 0, 2 * Math.PI);
ctx.fill();
});
}
// 执行绘制
ctx.draw(false, () => {
console.log("KDE热力图绘制完成");
resolve();
});
} 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(() => {
// 生成图片
uni.canvasToTempFilePath({
canvasId: canvasId,
width: width,
height: height,
destWidth: width * 3, // 提高输出分辨率,让图像更细腻
destHeight: height * 3,
success: (res) => {
console.log("KDE热力图图片生成成功:", res.tempFilePath);
resolve(res.tempFilePath);
},
fail: (error) => {
console.error("KDE热力图图片生成失败:", error);
reject(error);
},
});
})
.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;
};

View File

@@ -17,7 +17,8 @@ import {
} from "@/apis"; } from "@/apis";
import { getElementRect } from "@/util"; import { getElementRect } from "@/util";
import { generateHeatmapImage } from "@/heatmap";
import { generateKDEHeatmapImage } from "@/kde-heatmap";
import useStore from "@/store"; import useStore from "@/store";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
@@ -61,75 +62,39 @@ const startScoring = () => {
} }
}; };
// 生成热力图测试数据
const generateHeatMapData = (width, height, amount = 100) => {
const data = [];
const centerX = 0.5; // 中心点X坐标
const centerY = 0.5; // 中心点Y坐标
// 生成500条记录
for (let i = 0; i < amount; i++) {
let x, y, count;
// 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);
count = Math.floor(Math.random() * 20);
} else {
x = Math.random() * 0.8 + 0.1; // 0.1-0.9范围
y = Math.random() * 0.8 + 0.1;
count = Math.floor(Math.random() * 20);
}
// 确保坐标在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环
count: count,
});
}
return data;
};
const loadData = async () => { const loadData = async () => {
const result = await getPointBookListAPI(1); const result = await getPointBookListAPI(1);
list.value = result.slice(0, 3); list.value = result.slice(0, 3);
const result2 = await getPointBookStatisticsAPI(); const result2 = await getPointBookStatisticsAPI();
data.value = result2;
const rect = await getElementRect(".heat-map"); const rect = await getElementRect(".heat-map");
// const testWeekArrows = generateHeatMapData(rect.width, rect.height);
// result2.weekArrows = testWeekArrows;
// console.log(result2.weekArrows)
data.value = result2;
let hot = 0; let hot = 0;
if (result2.checkInCount > -3 && result2.checkInCount < 3) hot = 1; if (result2.checkInCount > -3 && result2.checkInCount < 3) hot = 1;
else if (result2.checkInCount >= 3) hot = 2; else if (result2.checkInCount >= 3) hot = 2;
else if (result2.checkInCount >= 5) hot = 3; else if (result2.checkInCount >= 5) hot = 3;
else if (result2.checkInCount === 7) hot = 4; else if (result2.checkInCount === 7) hot = 4;
uni.$emit("update-hot", hot); uni.$emit("update-hot", hot);
setTimeout(async () => {
try { try {
const imagePath = await generateHeatmapImage( const imagePath = await generateKDEHeatmapImage(
"heatMapCanvas", "heatMapCanvas",
rect.width, rect.width,
rect.height, rect.height,
result2.weekArrows.filter((item) => item.x && item.y) result2.weekArrows
.filter((item) => item.x && item.y)
.map((item) => [item.x, item.y]),
{
range: [0, 1], // 适配0-1坐标范围
gridSize: 150, // 更高的网格密度,减少锯齿
bandwidth: 0.15, // 稍小的带宽,让热力图更细腻
showPoints: true, // 显示白色原始数据点
}
); );
heatMapImageSrc.value = imagePath; // 存储生成的图片地址 heatMapImageSrc.value = imagePath; // 存储生成的图片地址
console.log("热力图图片地址:", imagePath); console.log("热力图图片地址:", imagePath);
} catch (error) { } catch (error) {
console.error("生成热力图图片失败:", error); console.error("生成热力图图片失败:", error);
} }
}, 500);
}; };
watch( watch(