package com.ruoyi.satisfaction.support;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.satisfaction.domain.ServiceSubtaskDetailRatioExport;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFClientAnchor;
import org.apache.poi.xssf.usermodel.XSSFDrawing;
import org.apache.poi.xssf.usermodel.XSSFPicture;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import javax.imageio.ImageIO;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.Arc2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 「问题详情占比饼图」Sheet:每道题一个饼图(带百分比标签和图例),
* 排版参照纸质报告:每题上方是题目,左边饼图、右边图例,一页 2 列 × 3 行。
*
* 用 Java2D 画成 PNG 再贴进 Excel,保证「百分比直接标在扇区上」的效果与报告一致
* (POI 4.1.2 的原生图表没有数据标签 API)。
*
* @author smartor
*/
public class MydOptionRatioPieChartBuilder implements ExcelUtil.ChartSheetBuilder {
/** Sheet 名称 */
public static final String SHEET_NAME = "问题详情占比饼图";
/** 单张图片宽高(像素) */
private static final int IMG_W = 560;
private static final int IMG_H = 400;
/** 每张图占用的列数 / 行数(用于单元格锚点定位) */
private static final int COLS_PER_IMG = 9;
private static final int ROWS_PER_IMG = 20;
/** 每行放几张图 */
private static final int PER_ROW = 2;
/** 扇形配色 */
private static final Color[] PALETTE = {
new Color(0x5B, 0x8F, 0xF9),
new Color(0x61, 0xDD, 0xAA),
new Color(0xF6, 0xBD, 0x16),
new Color(0x72, 0x62, 0xFD),
new Color(0x78, 0xD3, 0xF8),
new Color(0x96, 0x61, 0xBC),
new Color(0xFF, 0x98, 0x4D),
new Color(0xFF, 0x6D, 0x6D)
};
private final List rows;
private final String footer;
public MydOptionRatioPieChartBuilder(List rows, String footer) {
this.rows = rows == null ? new ArrayList<>() : rows;
this.footer = footer;
}
@Override
public void build(XSSFWorkbook workbook) {
if (rows.isEmpty()) {
return;
}
XSSFSheet sheet = workbook.createSheet(SHEET_NAME);
sheet.setColumnWidth(0, 12 * 256);
for (int i = 1; i < PER_ROW * COLS_PER_IMG; i++) {
sheet.setColumnWidth(i, 10 * 256);
}
XSSFDrawing drawing = sheet.createDrawingPatriarch();
int slot = 0;
for (Map.Entry> taskEntry : groupByTask().entrySet()) {
// 每个任务一行标题
int titleRow = (slot / PER_ROW) * ROWS_PER_IMG;
org.apache.poi.ss.usermodel.Row tr = sheet.getRow(titleRow) == null
? sheet.createRow(titleRow) : sheet.getRow(titleRow);
tr.createCell(0).setCellValue("任务名称:" + taskEntry.getKey());
for (Map.Entry> q : groupByQuestion(taskEntry.getValue()).entrySet()) {
int col = (slot % PER_ROW) * COLS_PER_IMG;
int row = (slot / PER_ROW) * ROWS_PER_IMG + 1;
try {
byte[] png = renderPie(q.getKey(), q.getValue());
int picIndex = workbook.addPicture(png, Workbook.PICTURE_TYPE_PNG);
XSSFClientAnchor anchor = new XSSFClientAnchor(0, 0, 0, 0,
col, row, col + COLS_PER_IMG - 1, row + ROWS_PER_IMG - 2);
XSSFPicture pic = drawing.createPicture(anchor, picIndex);
// 尺寸完全由锚点决定(不要 resize(),否则会按 POI 默认列宽重算)
} catch (Exception e) {
org.apache.poi.ss.usermodel.Row errRow = sheet.getRow(row) == null
? sheet.createRow(row) : sheet.getRow(row);
errRow.createCell(col).setCellValue(q.getKey() + ":饼图生成失败");
}
slot++;
}
slot = ((slot + PER_ROW - 1) / PER_ROW) * PER_ROW;
}
// 页脚:医院--科室
int footerRow = ((slot + PER_ROW - 1) / PER_ROW) * ROWS_PER_IMG + 1;
org.apache.poi.ss.usermodel.Row fr = sheet.createRow(footerRow);
fr.createCell(0).setCellValue(resolveFooter());
}
/**
* 按任务名称分组,保持原有顺序
*/
private Map> groupByTask() {
Map> grouped = new LinkedHashMap<>();
for (ServiceSubtaskDetailRatioExport row : rows) {
String taskName = row.getTaskname() == null ? "" : row.getTaskname();
grouped.computeIfAbsent(taskName, k -> new ArrayList<>()).add(row);
}
return grouped;
}
/**
* 题目 -> (选项 -> 人数),保持出现顺序
*/
private Map> groupByQuestion(List taskRows) {
Map> result = new LinkedHashMap<>();
for (ServiceSubtaskDetailRatioExport row : taskRows) {
String question = row.getQuestiontext() == null ? "" : row.getQuestiontext();
String option = row.getOptionresult() == null ? "" : row.getOptionresult();
result.computeIfAbsent(question, k -> new LinkedHashMap<>())
.put(option, parseCount(row.getCount()));
}
return result;
}
/**
* 页脚:优先用传入值,否则取第一行数据里的科室名称
*/
private String resolveFooter() {
if (footer != null && !footer.trim().isEmpty()) {
return footer;
}
for (ServiceSubtaskDetailRatioExport row : rows) {
if (row.getDeptname() != null && !row.getDeptname().trim().isEmpty()) {
return row.getDeptname();
}
if (row.getLeavehospitaldistrictname() != null && !row.getLeavehospitaldistrictname().trim().isEmpty()) {
return row.getLeavehospitaldistrictname();
}
}
return "";
}
/**
* 画一张饼图:标题 + 饼(扇区内标百分比) + 右侧图例(选项 + 百分比)
*/
private byte[] renderPie(String question, LinkedHashMap options) throws Exception {
BufferedImage img = new BufferedImage(IMG_W, IMG_H, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g.setColor(Color.WHITE);
g.fillRect(0, 0, IMG_W, IMG_H);
// 标题(最多两行)
g.setFont(new Font("Microsoft YaHei", Font.BOLD, 18));
g.setColor(new Color(0x33, 0x33, 0x33));
drawWrapped(g, question, 16, 28, IMG_W - 32);
double total = 0D;
for (Double v : options.values()) {
total += v == null ? 0D : v;
}
int cx = 165;
int cy = 235;
int r = 120;
if (total <= 0) {
g.setFont(new Font("Microsoft YaHei", Font.PLAIN, 16));
g.setColor(new Color(0x99, 0x99, 0x99));
g.drawString("暂无作答数据", cx - 45, cy);
} else {
double startAngle = -90D;
int i = 0;
for (Map.Entry entry : options.entrySet()) {
double count = entry.getValue() == null ? 0D : entry.getValue();
double angle = count / total * 360D;
g.setColor(PALETTE[i % PALETTE.length]);
g.fill(new Arc2D.Double(cx - r, cy - r, r * 2D, r * 2D, startAngle, angle, Arc2D.PIE));
g.setColor(Color.WHITE);
g.setStroke(new BasicStroke(2f));
g.draw(new Arc2D.Double(cx - r, cy - r, r * 2D, r * 2D, startAngle, angle, Arc2D.PIE));
// 扇区中间标百分比
String pct = formatPercent(count, total);
double mid = Math.toRadians(startAngle + angle / 2D);
int tx = (int) (cx + Math.cos(mid) * r * 0.58);
int ty = (int) (cy + Math.sin(mid) * r * 0.58);
g.setFont(new Font("Microsoft YaHei", Font.BOLD, 15));
g.setColor(Color.WHITE);
drawCentered(g, pct, tx, ty);
startAngle += angle;
i++;
}
}
// 右侧图例
g.setFont(new Font("Microsoft YaHei", Font.PLAIN, 14));
FontMetrics fm = g.getFontMetrics();
int lx = cx + r + 42;
int ly = cy - r + 30;
int i = 0;
for (Map.Entry entry : options.entrySet()) {
g.setColor(PALETTE[i % PALETTE.length]);
g.fillRect(lx, ly - 10, 12, 12);
g.setColor(new Color(0x33, 0x33, 0x33));
double count = entry.getValue() == null ? 0D : entry.getValue();
String text = entry.getKey() + " " + formatPercent(count, total);
g.drawString(text, lx + 18, ly);
ly += Math.max(22, fm.getHeight() + 4);
i++;
}
g.dispose();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ImageIO.write(img, "png", bos);
return bos.toByteArray();
}
/**
* 百分比:总量为 0 时显示 0.00%
*/
private String formatPercent(double count, double total) {
if (total <= 0) {
return "0.00%";
}
return String.format("%.2f%%", count * 100D / total);
}
/**
* 单行居中绘制文本
*/
private void drawCentered(Graphics2D g, String text, int cx, int cy) {
FontMetrics fm = g.getFontMetrics();
g.drawString(text, cx - fm.stringWidth(text) / 2, cy + fm.getAscent() / 2 - 2);
}
/**
* 按宽度自动换行绘制标题
*/
private void drawWrapped(Graphics2D g, String text, int x, int y, int maxWidth) {
if (text == null) {
return;
}
FontMetrics fm = g.getFontMetrics();
StringBuilder line = new StringBuilder();
int lineY = y;
for (int i = 0; i < text.length(); i++) {
char ch = text.charAt(i);
if (fm.stringWidth(line.toString() + ch) > maxWidth) {
g.drawString(line.toString(), x, lineY);
lineY += fm.getHeight();
line.setLength(0);
}
line.append(ch);
}
if (line.length() > 0) {
g.drawString(line.toString(), x, lineY);
}
}
/**
* 数量字符串 -> 数字
*/
private double parseCount(String count) {
if (count == null || count.trim().isEmpty()) {
return 0D;
}
try {
return Double.parseDouble(count.trim());
} catch (NumberFormatException e) {
return 0D;
}
}
}