1
This commit is contained in:
384
src/components/Radar/indexV2.tsx
Normal file
384
src/components/Radar/indexV2.tsx
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
import Taro from "@tarojs/taro";
|
||||||
|
import { View, Canvas } from "@tarojs/components";
|
||||||
|
import { useEffect, forwardRef, useImperativeHandle } from "react";
|
||||||
|
import shareLogoSvg from "@/static/ntrp/ntrp_share_logo.svg";
|
||||||
|
|
||||||
|
interface RadarChartV2Props {
|
||||||
|
data: [string, number][];
|
||||||
|
title?: string;
|
||||||
|
ntrpLevel?: string;
|
||||||
|
qrCodeUrl?: string;
|
||||||
|
bottomText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadarChartV2Ref {
|
||||||
|
generateImage: () => Promise<string>;
|
||||||
|
generateFullImage: (options: {
|
||||||
|
title?: string;
|
||||||
|
ntrpLevel?: string;
|
||||||
|
qrCodeUrl?: string;
|
||||||
|
bottomText?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}) => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RadarChartV2 = forwardRef<RadarChartV2Ref, RadarChartV2Props>((props, ref) => {
|
||||||
|
const { data, title, ntrpLevel, qrCodeUrl, bottomText } = props;
|
||||||
|
|
||||||
|
const maxValue = 100;
|
||||||
|
const levels = 5;
|
||||||
|
const radius = 100;
|
||||||
|
const center = { x: 160, y: 160 };
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data.length > 0) {
|
||||||
|
const { texts, vals } = data.reduce(
|
||||||
|
(res, item) => {
|
||||||
|
const [text, val] = item;
|
||||||
|
return {
|
||||||
|
texts: [...res.texts, text],
|
||||||
|
vals: [...res.vals, val],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ texts: [], vals: [] }
|
||||||
|
);
|
||||||
|
renderCanvas(texts, vals);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
function renderCanvas(labels, values) {
|
||||||
|
const query = Taro.createSelectorQuery();
|
||||||
|
query
|
||||||
|
.select("#radarCanvasV2")
|
||||||
|
.fields({ node: true, size: true })
|
||||||
|
.exec((res) => {
|
||||||
|
const canvas = res[0].node as HTMLCanvasElement;
|
||||||
|
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
|
||||||
|
const dpr = Taro.getWindowInfo().pixelRatio;
|
||||||
|
canvas.width = res[0].width * dpr;
|
||||||
|
canvas.height = res[0].height * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
// 启用抗锯齿,消除锯齿
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
// 设置线条端点样式为圆形,减少锯齿
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
|
||||||
|
// === 绘制圆形网格 ===
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
// 设置线条宽度为1px,确保清晰
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 坐标轴 & 标签 ===
|
||||||
|
labels.forEach((label, i) => {
|
||||||
|
const angle = ((Math.PI * 2) / labels.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;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 标签
|
||||||
|
const offset = 10;
|
||||||
|
const textX = center.x + (radius + offset) * Math.cos(angle);
|
||||||
|
const textY = center.y + (radius + offset) * Math.sin(angle);
|
||||||
|
|
||||||
|
ctx.font = "12px sans-serif";
|
||||||
|
ctx.fillStyle = "#333";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
|
||||||
|
if (
|
||||||
|
Math.abs(angle) < 0.01 ||
|
||||||
|
Math.abs(Math.abs(angle) - Math.PI) < 0.01
|
||||||
|
) {
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
} else if (angle > -Math.PI / 2 && angle < Math.PI / 2) {
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
} else {
|
||||||
|
ctx.textAlign = "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillText(label, textX, textY);
|
||||||
|
});
|
||||||
|
|
||||||
|
// === 数据区域 ===
|
||||||
|
const baseRadius = (radius / levels) * 1; // 第二个环为起点
|
||||||
|
const usableRadius = radius - baseRadius;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
values.forEach((val, i) => {
|
||||||
|
const angle = ((Math.PI * 2) / labels.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;
|
||||||
|
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.getImageInfo({
|
||||||
|
src,
|
||||||
|
success: ({ width, height }) => resolve({ width, height }),
|
||||||
|
fail: reject,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化 NTRP 显示
|
||||||
|
function formatNtrpDisplay(level: string): string {
|
||||||
|
if (!level) return "";
|
||||||
|
const num = parseFloat(level);
|
||||||
|
return num % 1 === 0 ? num.toFixed(0) : num.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
// 生成原始雷达图
|
||||||
|
generateImage: () =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const query = Taro.createSelectorQuery();
|
||||||
|
query
|
||||||
|
.select("#radarCanvasV2")
|
||||||
|
.fields({ node: true, size: true })
|
||||||
|
.exec((res) => {
|
||||||
|
const canvas = res[0].node;
|
||||||
|
Taro.canvasToTempFilePath({
|
||||||
|
canvas,
|
||||||
|
success: (res) => resolve(res.tempFilePath),
|
||||||
|
fail: (err) => reject(err),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 生成完整图片(包含标题、雷达图、底部文字和二维码)
|
||||||
|
generateFullImage: async (options: {
|
||||||
|
title?: string;
|
||||||
|
ntrpLevel?: string;
|
||||||
|
qrCodeUrl?: string;
|
||||||
|
bottomText?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}) => {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
// 默认尺寸,根据设计稿调整
|
||||||
|
const width = options.width || 750; // 设计稿宽度
|
||||||
|
const height = options.height || 1334; // 设计稿高度
|
||||||
|
|
||||||
|
// 先获取雷达图
|
||||||
|
const radarImageUrl = await new Promise<string>((resolveRadar, rejectRadar) => {
|
||||||
|
const query = Taro.createSelectorQuery();
|
||||||
|
query
|
||||||
|
.select("#radarCanvasV2")
|
||||||
|
.fields({ node: true, size: true })
|
||||||
|
.exec((res) => {
|
||||||
|
const canvas = res[0].node;
|
||||||
|
Taro.canvasToTempFilePath({
|
||||||
|
canvas,
|
||||||
|
success: (res) => resolveRadar(res.tempFilePath),
|
||||||
|
fail: (err) => rejectRadar(err),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建导出画布
|
||||||
|
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");
|
||||||
|
const dpr = Taro.getWindowInfo().pixelRatio;
|
||||||
|
canvas.width = width * dpr;
|
||||||
|
canvas.height = height * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
// 启用抗锯齿
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
|
||||||
|
// 绘制背景
|
||||||
|
ctx.fillStyle = "#E9FDF8";
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// 根据设计稿调整内边距和布局
|
||||||
|
const topPadding = 60; // 顶部内边距
|
||||||
|
const sidePadding = 40; // 左右内边距
|
||||||
|
const bottomPadding = 30; // 底部内边距
|
||||||
|
|
||||||
|
let currentY = topPadding;
|
||||||
|
|
||||||
|
// 绘制标题 - 根据设计稿调整字体大小和间距
|
||||||
|
if (options.title) {
|
||||||
|
ctx.fillStyle = "#000000";
|
||||||
|
ctx.font = "400 28px sans-serif"; // 设计稿字体大小
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.textBaseline = "top";
|
||||||
|
ctx.fillText(options.title, sidePadding, currentY);
|
||||||
|
currentY += 44; // 标题到 NTRP 等级的间距
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制 NTRP 等级 - 根据设计稿调整字体大小和颜色
|
||||||
|
if (options.ntrpLevel) {
|
||||||
|
ctx.fillStyle = "#00E5AD";
|
||||||
|
ctx.font = "bold 48px sans-serif"; // 设计稿字体大小
|
||||||
|
ctx.fillText(`NTRP ${formatNtrpDisplay(options.ntrpLevel)}`, sidePadding, currentY);
|
||||||
|
currentY += 72; // NTRP 等级到雷达图的间距
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制雷达图 - 根据设计稿调整尺寸和位置
|
||||||
|
try {
|
||||||
|
const radarImg = await loadImage(canvas, radarImageUrl);
|
||||||
|
// 计算雷达图尺寸,根据设计稿调整
|
||||||
|
const radarMaxSize = width - sidePadding * 2;
|
||||||
|
const radarActualSize = Math.min(radarMaxSize, 670); // 设计稿雷达图尺寸
|
||||||
|
const radarX = (width - radarActualSize) / 2; // 居中
|
||||||
|
const radarY = currentY;
|
||||||
|
ctx.drawImage(radarImg, radarX, radarY, radarActualSize, radarActualSize);
|
||||||
|
currentY += radarActualSize + 60; // 雷达图到底部的间距
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load radar image:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制底部区域 - 根据设计稿调整
|
||||||
|
const qrSize = 100; // 设计稿二维码尺寸
|
||||||
|
|
||||||
|
// 计算底部区域布局 - 从底部向上计算
|
||||||
|
const bottomTextHeight = 28; // 底部文字高度
|
||||||
|
const bottomSpacing = 30; // 底部间距
|
||||||
|
|
||||||
|
// 绘制二维码 - 右下角
|
||||||
|
const qrX = width - sidePadding - qrSize;
|
||||||
|
const qrY = height - bottomPadding - qrSize - bottomTextHeight - 20; // 在文字上方
|
||||||
|
|
||||||
|
if (options.qrCodeUrl) {
|
||||||
|
try {
|
||||||
|
const qrImg = await loadImage(canvas, options.qrCodeUrl);
|
||||||
|
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load QR code:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制底部 SVG logo 图片 - 根据设计稿,SVG 包含 logo 和文字
|
||||||
|
try {
|
||||||
|
const logoSvgPath = shareLogoSvg;
|
||||||
|
const logoSvgImg = await loadImage(canvas, logoSvgPath);
|
||||||
|
const logoSvgWidth = 235; // SVG 原始宽度
|
||||||
|
const logoSvgHeight = 28; // SVG 原始高度
|
||||||
|
const logoSvgX = sidePadding; // 左边对齐
|
||||||
|
const logoSvgY = height - bottomPadding - logoSvgHeight; // 底部对齐
|
||||||
|
|
||||||
|
ctx.drawImage(
|
||||||
|
logoSvgImg,
|
||||||
|
logoSvgX,
|
||||||
|
logoSvgY,
|
||||||
|
logoSvgWidth,
|
||||||
|
logoSvgHeight
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load logo SVG:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出图片
|
||||||
|
Taro.canvasToTempFilePath({
|
||||||
|
canvas,
|
||||||
|
fileType: 'png',
|
||||||
|
quality: 1,
|
||||||
|
success: (res) => {
|
||||||
|
resolve(res.tempFilePath);
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
reject(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Canvas
|
||||||
|
type="2d"
|
||||||
|
id="radarCanvasV2"
|
||||||
|
style={{ width: "320px", height: "320px", background: "transparent" }}
|
||||||
|
/>
|
||||||
|
{/* 隐藏的导出画布 */}
|
||||||
|
<Canvas
|
||||||
|
type="2d"
|
||||||
|
id="exportCanvasV2"
|
||||||
|
style={{ position: "fixed", top: "-9999px", left: "-9999px", width: "750px", height: "1334px" }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
RadarChartV2.displayName = "RadarChartV2";
|
||||||
|
|
||||||
|
export default RadarChartV2;
|
||||||
|
|
||||||
Reference in New Issue
Block a user