liusheng
10 天以前 b46b8b8fee4d10e52dd882febc07974b02e2aed9
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
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 行。
 * <p>
 * 用 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<ServiceSubtaskDetailRatioExport> rows;
 
    private final String footer;
 
    public MydOptionRatioPieChartBuilder(List<ServiceSubtaskDetailRatioExport> 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<String, List<ServiceSubtaskDetailRatioExport>> 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<String, LinkedHashMap<String, Double>> 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<String, List<ServiceSubtaskDetailRatioExport>> groupByTask() {
        Map<String, List<ServiceSubtaskDetailRatioExport>> 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<String, LinkedHashMap<String, Double>> groupByQuestion(List<ServiceSubtaskDetailRatioExport> taskRows) {
        Map<String, LinkedHashMap<String, Double>> 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<String, Double> 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<String, Double> 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<String, Double> 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;
        }
    }
}