Files
mini-programs/src/components/Radar/indexV2.tsx
2026-02-07 11:56:43 +08:00

569 lines
23 KiB
TypeScript
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.
import Taro from "@tarojs/taro";
import { View, Canvas } from "@tarojs/components";
import { forwardRef, useImperativeHandle } from "react";
import shareLogoSvg from "@/static/ntrp/ntrp_share_logo.png";
import docCopyPng from "@/static/ntrp/ntrp_doc_copy.png";
import { OSS_BASE_URL } from "@/config/api";
interface RadarChartV2Props {
data: [string, number][];
title?: string;
ntrpLevel?: string;
levelDescription?: string;
avatarUrl?: string;
qrCodeUrl?: string;
bottomText?: string;
}
export interface RadarChartV2Ref {
generateImage: () => Promise<string>;
generateFullImage: (options: {
title?: string;
ntrpLevel?: string;
levelDescription?: string;
avatarUrl?: string;
qrCodeUrl?: string;
bottomText?: string;
width?: number;
height?: number;
}) => Promise<string>;
}
const RadarChartV2 = forwardRef<RadarChartV2Ref, RadarChartV2Props>((props, ref) => {
const { data } = props;
const maxValue = 100;
const levels = 5;
// 在 exportCanvasV2 中绘制雷达图的函数
function drawRadarChart(ctx: CanvasRenderingContext2D, radarX: number, radarY: number, radarSize: number) {
// 雷达图中心点位置radarSize 已经是2倍图尺寸
const center = {
x: radarX + radarSize / 2,
y: radarY + radarSize / 2
};
// 计算实际半径radarSize 是直径,半径是直径的一半)
const radius = radarSize / 2;
// 启用抗锯齿
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// 解析数据
const { texts, vals } = data.reduce(
(res, item) => {
const [text, val] = item;
return {
texts: [...res.texts, text],
vals: [...res.vals, val],
};
},
{ texts: [], vals: [] }
);
// === 绘制圆形网格 ===
for (let i = levels; i >= 1; i--) {
const r = (radius / levels) * i;
ctx.beginPath();
ctx.arc(center.x, center.y, r, 0, Math.PI * 2);
if (i % 2 === 1) {
ctx.fillStyle = "#fff";
ctx.fill();
} else {
ctx.fillStyle = "#CAFCF0";
ctx.fill();
}
// 根据层级设置不同的线条颜色
if (i === 1) {
ctx.strokeStyle = "#E5E5E5";
} else if (i <= 3) {
ctx.strokeStyle = "#E0E0E0";
} else {
ctx.strokeStyle = "#D5D5D5";
}
ctx.lineWidth = 1 * (radarSize / 200); // 根据2倍图调整线宽
ctx.stroke();
}
// === 坐标轴 & 标签 ===
texts.forEach((label, i) => {
const angle = ((Math.PI * 2) / texts.length) * i - Math.PI / 2;
const x = center.x + radius * Math.cos(angle);
const y = center.y + radius * Math.sin(angle);
// 坐标轴
ctx.beginPath();
ctx.moveTo(center.x, center.y);
ctx.lineTo(x, y);
ctx.strokeStyle = "#E0E0E0";
ctx.lineWidth = 1 * (radarSize / 200); // 根据2倍图调整线宽
ctx.stroke();
// 标签:沿轴线外侧延伸,文字中心对齐轴线端点(与 index.tsx 一致)
const labelOffset = 28 * (radarSize / 200); // 文字距离圆圈的偏移量2倍图
const textX = center.x + (radius + labelOffset) * Math.cos(angle);
const textY = center.y + (radius + labelOffset) * Math.sin(angle);
ctx.font = `${12 * (radarSize / 200)}px sans-serif`; // 根据2倍图调整字体大小
ctx.fillStyle = "#333";
ctx.textBaseline = "middle";
ctx.textAlign = "center";
ctx.fillText(label, textX, textY);
});
// === 数据区域 ===
const baseRadius = (radius / levels) * 1; // 第二个环为起点
const usableRadius = radius - baseRadius;
ctx.beginPath();
vals.forEach((val, i) => {
const angle = ((Math.PI * 2) / texts.length) * i - Math.PI / 2;
const r = baseRadius + (val / maxValue) * usableRadius;
const x = center.x + r * Math.cos(angle);
const y = center.y + r * Math.sin(angle);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.closePath();
ctx.fillStyle = "rgba(0,200,180,0.3)";
ctx.fill();
ctx.strokeStyle = "#00c8b4";
ctx.lineWidth = 3 * (radarSize / 200); // 根据2倍图调整线宽
ctx.stroke();
}
// 加载图片工具函数
function loadImage(canvas: any, src: string): Promise<any> {
return new Promise((resolve, reject) => {
const img = canvas.createImage();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = src;
});
}
// 获取图片信息(宽高)
function getImageInfo(src: string): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
(Taro as any).getImageInfo({
src,
success: (res: any) => resolve({ width: res.width, height: res.height }),
fail: reject,
});
});
}
// 绘制圆角矩形
function roundRect(ctx: any, x: number, y: number, width: number, height: number, radius: number) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
// 格式化 NTRP 显示
function formatNtrpDisplay(level: string): string {
if (!level) return "";
// 检查是否包含 + 号
const hasPlus = level.includes("+");
const num = parseFloat(level);
if (isNaN(num)) return level;
const formatted = num % 1 === 0 ? num.toFixed(0) : num.toFixed(1);
return hasPlus ? `${formatted}+` : formatted;
}
useImperativeHandle(ref, () => ({
// 生成原始雷达图(已废弃,现在直接在 exportCanvasV2 中绘制)
generateImage: () =>
Promise.resolve(""),
// 生成完整图片(包含标题、雷达图、底部文字和二维码)
generateFullImage: async (options: {
title?: string;
ntrpLevel?: string;
levelDescription?: string;
avatarUrl?: string;
qrCodeUrl?: string;
bottomText?: string;
width?: number;
height?: number;
}) => {
return new Promise(async (resolve, reject) => {
try {
// 使用2倍图提高图片质量
const scale = 2; // 2倍图
const baseWidth = 350; // 基础宽度
const baseHeight = 600; // 基础高度
const width = baseWidth * scale; // 实际宽度 700
const height = baseHeight * scale; // 实际高度 1200
// 创建导出画布
const query = Taro.createSelectorQuery();
query
.select("#exportCanvasV2")
.fields({ node: true, size: true })
.exec(async (res) => {
if (!res || !res[0]) {
reject(new Error("Canvas not found"));
return;
}
const canvas = res[0].node;
const ctx = canvas.getContext("2d");
// 使用2倍图尺寸
canvas.width = width;
canvas.height = height;
// 启用抗锯齿
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// 绘制背景 - 使用 share_bg.png 背景图,撑满整个画布(从 OSS 动态加载)
try {
const shareBgUrl = `${OSS_BASE_URL}/images/share_bg.png`;
const bgImg = await loadImage(canvas, shareBgUrl);
ctx.drawImage(bgImg, 0, 0, width, height);
} catch (error) {
console.error("Failed to load background image:", error);
// 如果加载失败,使用白色背景作为兜底
ctx.fillStyle = "#FFFFFF";
ctx.fillRect(0, 0, width, height);
}
// 根据设计稿调整内边距和布局2倍图
const topPadding = 24 * scale; // 设计稿顶部内边距
const sidePadding = 28 * scale; // 设计稿左右内边距Frame 1912055062 的 x: 28
let currentY = topPadding;
// 绘制用户头像和装饰图片 - 根据设计稿尺寸2倍图
if (options.avatarUrl) {
try {
const avatarSize = 43.46 * scale; // 设计稿头像尺寸
const avatarImg = await loadImage(canvas, options.avatarUrl);
const avatarInfo = await getImageInfo(options.avatarUrl);
// 头像区域总宽度(头像 + 装饰图片重叠部分)
const avatarWrapWidth = 84.7 * scale; // 设计稿 Frame 1912055063 宽度
const avatarX = sidePadding + (294 * scale - avatarWrapWidth) / 2; // 294 是 Frame 1912055062 宽度,居中
const avatarY = currentY;
// 绘制头像圆形背景
ctx.save();
ctx.beginPath();
ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2);
ctx.fillStyle = "#FFFFFF";
ctx.fill();
ctx.strokeStyle = "#EFEFEF";
ctx.lineWidth = 1 * scale;
ctx.stroke();
// 计算头像绘制尺寸,保持宽高比
const innerSize = avatarSize - 1.94 * scale; // 内部可用尺寸
const avatarAspectRatio = avatarInfo.width / avatarInfo.height;
let drawWidth = innerSize;
let drawHeight = innerSize;
let drawX = avatarX + 0.97 * scale;
let drawY = avatarY + 0.97 * scale;
// 根据宽高比调整尺寸,保持比例不变形
if (avatarAspectRatio > 1) {
// 图片更宽,以高度为准
drawWidth = innerSize * avatarAspectRatio;
drawX = avatarX + avatarSize / 2 - drawWidth / 2;
} else {
// 图片更高或正方形,以宽度为准
drawHeight = innerSize / avatarAspectRatio;
drawY = avatarY + avatarSize / 2 - drawHeight / 2;
}
// 绘制头像(圆形裁剪)
ctx.beginPath();
ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2 - 0.97 * scale, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(avatarImg, drawX, drawY, drawWidth, drawHeight);
ctx.restore();
// 绘制装饰图片DocCopy- 在头像右侧
const addonSize = 42 * scale; // 装饰图片和头像一样大
const addonX = avatarX + avatarSize - 5 * scale; // 重叠部分
const addonY = avatarY;
const addonRotation = 8 * (Math.PI / 180); // 旋转 8 度
try {
const docCopyImg = await loadImage(canvas, docCopyPng);
ctx.save();
// 移动到旋转中心
const centerX = addonX + addonSize / 2;
const centerY = addonY + addonSize / 2;
ctx.translate(centerX, centerY);
ctx.rotate(addonRotation);
// 绘制装饰图片背景(圆角矩形,带渐变)
const borderRadius = 9.66 * scale; // 设计稿圆角
ctx.fillStyle = "#FFFFFF";
ctx.beginPath();
roundRect(ctx, -addonSize / 2, -addonSize / 2, addonSize, addonSize, borderRadius);
ctx.fill();
// 添加渐变背景色
ctx.globalAlpha = 0.2;
ctx.fillStyle = "rgba(89, 255, 214, 1)";
ctx.fill();
ctx.globalAlpha = 1.0;
ctx.strokeStyle = "#FFFFFF";
ctx.lineWidth = 1.93 * scale; // 设计稿 strokeWeight
ctx.stroke();
// 绘制装饰图片
const docSize = 26.18 * scale; // 设计稿内部图片尺寸
const docRotation = -7 * (Math.PI / 180); // 内部旋转 -7 度
ctx.rotate(docRotation);
ctx.drawImage(docCopyImg, -docSize / 2, -docSize / 2, docSize, docSize);
ctx.restore();
} catch (error) {
console.error("Failed to load docCopy image:", error);
}
currentY += (48 + 20) * scale; // 头像区域高度 + gap
} catch (error) {
console.error("Failed to load avatar image:", error);
}
}
// 绘制文字区域 - 根据设计稿样式居中对齐2倍图
const textCenterX = sidePadding + (294 * scale) / 2; // Frame 1912055062 的中心
const textGap = 6 * scale; // 设计稿 gap: 6px
// 绘制标题 - 14px, font-weight: 300, line-height: 1.22倍图
if (options.title) {
ctx.fillStyle = "#000000";
ctx.font = `300 ${14 * scale}px Noto Sans SC, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.fillText(options.title, textCenterX, currentY);
currentY += 14 * 1.2 * scale + textGap; // line-height: 1.2 + gap
}
// 绘制 NTRP 等级 - 36px, font-weight: 900, "NTRP" 黑色,等级数字 #00E5AD2倍图
if (options.ntrpLevel) {
ctx.font = `900 ${36 * scale}px Noto Sans SC, sans-serif`;
ctx.textBaseline = "top";
const ntrpText = "NTRP";
const levelText = formatNtrpDisplay(options.ntrpLevel);
const ntrpWidth = ctx.measureText(ntrpText).width;
const levelWidth = ctx.measureText(levelText).width;
const totalWidth = ntrpWidth + levelWidth; // 设计稿中紧挨着
const startX = textCenterX - totalWidth / 2;
// 绘制 "NTRP"(黑色)
ctx.fillStyle = "#000000";
ctx.textAlign = "left";
ctx.fillText(ntrpText, startX, currentY);
// 绘制等级数字(#00E5AD
ctx.fillStyle = "#00E5AD";
ctx.fillText(levelText, startX + ntrpWidth, currentY);
currentY += 36 * 1.222 * scale + textGap; // line-height: 1.222 + gap
}
// 绘制描述文本 - 16px, font-weight: 600, line-height: 1.42倍图
if (options.levelDescription) {
ctx.fillStyle = "#000000";
ctx.font = `600 ${16 * scale}px PingFang SC, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.fillText(options.levelDescription, textCenterX, currentY);
// 计算到雷达图的间距:雷达图 y: 252 - 当前 Y
const radarY = 252 * scale; // 设计稿雷达图位置
currentY = radarY; // 直接设置到雷达图位置
}
// 直接绘制雷达图 - 根据设计稿调整尺寸和位置2倍图
const radarSize = 200 * scale; // 固定雷达图尺寸为 200*200
const radarX = 75 * scale; // 设计稿雷达图 x 位置
const radarY = currentY;
// 直接在 exportCanvasV2 中绘制雷达图
drawRadarChart(ctx, radarX, radarY, radarSize);
currentY += radarSize; // 雷达图高度
// 绘制底部区域 - 根据设计稿调整2倍图
const qrSize = 64 * scale; // 设计稿二维码尺寸64*64
const qrX = 276 * scale; // 设计稿二维码 x 位置
const qrY = 523 * scale; // 设计稿二维码 y 位置
const bottomTextContent = options.bottomText || "长按识别二维码,快来加入,有你就有场!";
// 绘制底部文字 - 设计稿fontSize: 12, fontWeight: 400, line-height: 1.52倍图
ctx.fillStyle = "rgba(0, 0, 0, 0.45)";
ctx.font = `400 ${12 * scale}px Noto Sans SC, sans-serif`;
ctx.textAlign = "left";
ctx.textBaseline = "top";
const textX = 20 * scale; // 设计稿文字 x 位置
const maxTextWidth = qrX - textX - 10 * scale; // 到二维码的间距
// 计算文字行数
const bottomWords = bottomTextContent.split("");
let bottomLine = "";
let textLines: string[] = [];
const lineHeight = 12 * 1.5 * scale; // fontSize * line-height
for (let i = 0; i < bottomWords.length; i++) {
const testBottomLine = bottomLine + bottomWords[i];
const metrics = ctx.measureText(testBottomLine);
if (metrics.width > maxTextWidth && i > 0) {
textLines.push(bottomLine);
bottomLine = bottomWords[i];
} else {
bottomLine = testBottomLine;
}
}
if (bottomLine) {
textLines.push(bottomLine);
}
// 计算文字总高度
const textTotalHeight = textLines.length * lineHeight;
// 计算二维码底部位置
const qrBottomY = qrY + qrSize;
// 让文字底部和二维码底部对齐
const textY = qrBottomY - textTotalHeight;
// 绘制顶部标题和图标 - 设计稿 Frame 20360814262倍图
// 图标应该在文字上方gap: 4px
const iconGap = 4 * scale; // 图标和文字之间的间距
const topTitleX = textX;
// 绘制上面的标题 logo
try {
const iconSize = 28 * scale;
const iconImg = await loadImage(canvas, shareLogoSvg);
// 图标位置:文字顶部上方 iconSize + gap
const iconY = textY - iconSize - iconGap;
ctx.drawImage(iconImg, topTitleX, iconY, 235 * scale, iconSize);
} catch (error) {
console.error("Failed to load icon:", error);
}
// 绘制底部文字
textLines.forEach((lineText, index) => {
ctx.fillText(lineText, textX, textY + index * lineHeight);
});
// 绘制二维码 - 设计稿位置(带白色背景、边框、阴影和圆角)
if (options.qrCodeUrl) {
try {
const qrImg = await loadImage(canvas, options.qrCodeUrl);
// 二维码样式参数2倍图
const borderWidth = 2 * scale; // 边框宽度 2px
const borderRadius = 10 * scale; // 圆角 10px
const shadowOffsetX = 0; // 阴影 X 偏移
const shadowOffsetY = 1.04727 * scale; // 阴影 Y 偏移 1.04727px
const shadowBlur = 9.42545 * scale; // 阴影模糊 9.42545px
const shadowColor = "rgba(0, 0, 0, 0.12)"; // 阴影颜色
// 二维码实际绘制区域(留出边框空间)
const qrInnerSize = qrSize - borderWidth * 2;
const qrInnerX = qrX + borderWidth;
const qrInnerY = qrY + borderWidth;
// 保存上下文状态
ctx.save();
// 设置阴影
ctx.shadowOffsetX = shadowOffsetX;
ctx.shadowOffsetY = shadowOffsetY;
ctx.shadowBlur = shadowBlur;
ctx.shadowColor = shadowColor;
// 绘制白色背景(圆角矩形)
ctx.fillStyle = "#FFFFFF";
roundRect(ctx, qrX, qrY, qrSize, qrSize, borderRadius);
ctx.fill();
// 绘制白色边框
ctx.strokeStyle = "#FFFFFF";
ctx.lineWidth = borderWidth;
roundRect(ctx, qrX, qrY, qrSize, qrSize, borderRadius);
ctx.stroke();
// 清除阴影(二维码图片不需要阴影)
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
ctx.shadowBlur = 0;
ctx.shadowColor = "transparent";
// 绘制二维码图片(在圆角矩形内)
ctx.save();
// 创建圆角裁剪区域
roundRect(ctx, qrInnerX, qrInnerY, qrInnerSize, qrInnerSize, borderRadius - borderWidth);
ctx.clip();
// 绘制二维码图片
ctx.drawImage(qrImg, qrInnerX, qrInnerY, qrInnerSize, qrInnerSize);
ctx.restore();
// 恢复上下文状态
ctx.restore();
} catch (error) {
console.error("Failed to load QR code:", error);
}
}
// 导出图片
Taro.canvasToTempFilePath({
canvas,
fileType: 'png',
quality: 1,
success: (res) => {
resolve(res.tempFilePath);
},
fail: (err) => {
reject(err);
},
});
});
} catch (error) {
reject(error);
}
});
},
}));
return (
<View>
{/* 隐藏的导出画布 - 2倍图尺寸 */}
<Canvas
type="2d"
id="exportCanvasV2"
style={{ position: "fixed", top: "-9999px", left: "-9999px", width: "700px", height: "1200px" }}
/>
</View>
);
});
RadarChartV2.displayName = "RadarChartV2";
export default RadarChartV2;