DecisionChapterDiagramRenderer.java

package com.taxonomy.architecture.decision;

import com.taxonomy.architecture.decision.DecisionRationaleReport.ChildDecision;
import com.taxonomy.architecture.decision.DecisionRationaleReport.DecisionChapter;
import com.taxonomy.architecture.decision.DecisionRationaleReport.Disposition;
import org.springframework.stereotype.Component;

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.Stroke;
import java.awt.geom.Line2D;
import java.awt.geom.RoundRectangle2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

/**
 * Deterministically renders one-level parent/children decision diagrams.
 *
 * <p>Large sibling sets are split into panels of at most six children. Every panel repeats
 * the parent node, so it remains understandable when a page break separates the images.</p>
 */
@Component
public class DecisionChapterDiagramRenderer {

    static final int WIDTH = 1200;
    static final int HEIGHT = 720;
    static final int CHILDREN_PER_PANEL = 6;

    private static final Color NAVY = new Color(11, 31, 51);
    private static final Color TEAL = new Color(0, 122, 120);
    private static final Color LIGHT_TEAL = new Color(225, 244, 243);
    private static final Color PALE_BLUE = new Color(233, 240, 247);
    private static final Color GRAY_900 = new Color(38, 48, 58);
    private static final Color GRAY_600 = new Color(100, 112, 124);
    private static final Color GRAY_300 = new Color(205, 213, 220);
    private static final Color GRAY_100 = new Color(244, 246, 248);
    private static final Color WHITE = Color.WHITE;

    public record DiagramPanel(
            int panelNumber,
            int panelCount,
            byte[] png,
            String svg,
            String altText) {
    }

    public List<DiagramPanel> render(DecisionChapter chapter, String languageTag) {
        DecisionReportLabels labels = new DecisionReportLabels(languageTag);
        List<ChildDecision> children = chapter.children();
        int panelCount = Math.max(1,
                (children.size() + CHILDREN_PER_PANEL - 1) / CHILDREN_PER_PANEL);
        List<DiagramPanel> panels = new ArrayList<>();
        for (int panel = 0; panel < panelCount; panel++) {
            int from = panel * CHILDREN_PER_PANEL;
            int to = Math.min(children.size(), from + CHILDREN_PER_PANEL);
            List<ChildDecision> panelChildren = children.subList(from, to);
            String altText = altText(chapter, panelChildren, labels, panel + 1, panelCount);
            panels.add(new DiagramPanel(
                    panel + 1,
                    panelCount,
                    renderPng(chapter, panelChildren, labels, panel + 1, panelCount),
                    renderSvg(chapter, panelChildren, labels, panel + 1, panelCount),
                    altText));
        }
        return List.copyOf(panels);
    }

    private byte[] renderPng(
            DecisionChapter chapter,
            List<ChildDecision> children,
            DecisionReportLabels labels,
            int panelNumber,
            int panelCount) {
        BufferedImage image = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_ARGB);
        Graphics2D graphics = image.createGraphics();
        try {
            graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
                    RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
            graphics.setColor(WHITE);
            graphics.fillRect(0, 0, WIDTH, HEIGHT);

            graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 24));
            graphics.setColor(NAVY);
            String heading = labels.parentNode() + " " + chapter.parentCode()
                    + (panelCount > 1 ? " · " + panelNumber + "/" + panelCount : "");
            graphics.drawString(heading, 50, 42);

            Card parentCard = new Card(370, 70, 460, 126);
            drawCard(graphics, parentCard, NAVY, NAVY, false);
            graphics.setColor(WHITE);
            graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 25));
            graphics.drawString(chapter.parentCode(), parentCard.x + 24, parentCard.y + 36);
            graphics.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 20));
            drawWrapped(graphics, chapter.parentTitle(), parentCard.x + 24,
                    parentCard.y + 66, parentCard.width - 48, 22, 2);
            graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 21));
            String score = chapter.parentScore() == null
                    ? labels.notEvaluated() : chapter.parentScore() + " %";
            drawRight(graphics, score, parentCard.x + parentCard.width - 24,
                    parentCard.y + 38);

            List<Card> cards = childCards(children.size());
            graphics.setColor(GRAY_300);
            graphics.setStroke(new BasicStroke(3.0f));
            double parentX = parentCard.x + parentCard.width / 2.0;
            double parentY = parentCard.y + parentCard.height;
            double junctionY = 235;
            graphics.draw(new Line2D.Double(parentX, parentY, parentX, junctionY));
            if (!cards.isEmpty()) {
                double left = cards.stream().mapToDouble(card -> card.x + card.width / 2.0).min().orElse(parentX);
                double right = cards.stream().mapToDouble(card -> card.x + card.width / 2.0).max().orElse(parentX);
                graphics.draw(new Line2D.Double(left, junctionY, right, junctionY));
                for (Card card : cards) {
                    double center = card.x + card.width / 2.0;
                    graphics.draw(new Line2D.Double(center, junctionY, center, card.y));
                }
            }

            for (int index = 0; index < children.size(); index++) {
                drawChild(graphics, cards.get(index), children.get(index), labels);
            }

            graphics.setColor(GRAY_600);
            graphics.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 15));
            String legend = labels.german()
                    ? "Absoluter Relevanzwert · lokaler Anteil am Vaterwert · Status des Pfades"
                    : "Absolute relevance score · local share of parent score · path status";
            graphics.drawString(legend, 50, HEIGHT - 30);
        } finally {
            graphics.dispose();
        }

        try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
            ImageIO.write(image, "png", output);
            return output.toByteArray();
        } catch (Exception exception) {
            throw new IllegalStateException("Could not render decision diagram PNG", exception);
        }
    }

    private void drawChild(
            Graphics2D graphics,
            Card card,
            ChildDecision child,
            DecisionReportLabels labels) {
        CardStyle style = style(child);
        drawCard(graphics, card, style.fill(), style.border(), style.dashed());

        graphics.setColor(style.text());
        graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 22));
        graphics.drawString(child.code(), card.x + 18, card.y + 31);

        String absolute = child.absoluteScore() == null
                ? "—" : child.absoluteScore() + " %";
        graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 22));
        drawRight(graphics, absolute, card.x + card.width - 18, card.y + 31);

        graphics.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 18));
        drawWrapped(graphics, child.title(), card.x + 18, card.y + 60,
                card.width - 36, 20, 3);

        graphics.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 16));
        graphics.setColor(style.secondaryText());
        String local = child.localSharePercent() == null
                ? labels.localShare() + ": —"
                : labels.localShare() + ": " + formatPercent(child.localSharePercent());
        graphics.drawString(local, card.x + 18, card.y + card.height - 42);

        String status = dispositionLabel(child, labels);
        graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 15));
        graphics.setColor(style.text());
        graphics.drawString(status, card.x + 18, card.y + card.height - 17);
        if (child.leadingSibling()) {
            String leading = labels.leadingLabel();
            int width = graphics.getFontMetrics().stringWidth(leading) + 18;
            graphics.setColor(TEAL);
            graphics.fillRoundRect(card.x + card.width - width - 14,
                    card.y + card.height - 34, width, 24, 12, 12);
            graphics.setColor(WHITE);
            graphics.drawString(leading, card.x + card.width - width - 5,
                    card.y + card.height - 17);
        }
    }

    private void drawCard(
            Graphics2D graphics,
            Card card,
            Color fill,
            Color border,
            boolean dashed) {
        RoundRectangle2D shape = new RoundRectangle2D.Double(
                card.x, card.y, card.width, card.height, 24, 24);
        graphics.setColor(fill);
        graphics.fill(shape);
        Stroke previous = graphics.getStroke();
        graphics.setStroke(dashed
                ? new BasicStroke(2.5f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_ROUND,
                    10.0f, new float[]{10.0f, 8.0f}, 0.0f)
                : new BasicStroke(2.5f));
        graphics.setColor(border);
        graphics.draw(shape);
        graphics.setStroke(previous);
    }

    private List<Card> childCards(int count) {
        if (count <= 0) {
            return List.of();
        }
        int columns = count == 1 ? 1 : count == 2 ? 2 : 3;
        int rows = (count + columns - 1) / columns;
        int cardWidth = columns == 1 ? 520 : columns == 2 ? 470 : 340;
        int cardHeight = rows == 1 ? 210 : 190;
        int gapX = 34;
        int totalWidth = columns * cardWidth + (columns - 1) * gapX;
        int startX = (WIDTH - totalWidth) / 2;
        int startY = rows == 1 ? 315 : 285;
        int gapY = 42;
        List<Card> result = new ArrayList<>();
        for (int index = 0; index < count; index++) {
            int row = index / columns;
            int column = index % columns;
            result.add(new Card(
                    startX + column * (cardWidth + gapX),
                    startY + row * (cardHeight + gapY),
                    cardWidth,
                    cardHeight));
        }
        return result;
    }

    private CardStyle style(ChildDecision child) {
        if (child.disposition() == Disposition.NOT_EVALUATED) {
            return new CardStyle(WHITE, GRAY_600, GRAY_900, GRAY_600, true);
        }
        if (child.disposition() == Disposition.REJECTED) {
            return new CardStyle(GRAY_100, GRAY_300, GRAY_900, GRAY_600, false);
        }
        if (child.leadingSibling()) {
            return new CardStyle(LIGHT_TEAL, TEAL, NAVY, GRAY_900, false);
        }
        return new CardStyle(PALE_BLUE, NAVY, NAVY, GRAY_900, false);
    }

    private String renderSvg(
            DecisionChapter chapter,
            List<ChildDecision> children,
            DecisionReportLabels labels,
            int panelNumber,
            int panelCount) {
        String idPrefix = "decision-chapter-" + chapter.number()
                + "-panel-" + panelNumber;
        String titleId = idPrefix + "-title";
        String descriptionId = idPrefix + "-desc";
        StringBuilder svg = new StringBuilder(8_192);
        svg.append("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ")
                .append(WIDTH).append(' ').append(HEIGHT)
                .append("\" role=\"img\" aria-labelledby=\"")
                .append(titleId).append(' ').append(descriptionId)
                .append("\">");
        svg.append("<title id=\"").append(titleId).append("\">")
                .append(xml(labels.parentNode() + " " + chapter.parentCode()))
                .append("</title>");
        svg.append("<desc id=\"").append(descriptionId).append("\">")
                .append(xml(altText(
                        chapter, children, labels, panelNumber, panelCount)))
                .append("</desc>");
        svg.append("<rect width=\"1200\" height=\"720\" fill=\"#ffffff\"/>");
        String heading = labels.parentNode() + " " + chapter.parentCode()
                + (panelCount > 1 ? " · " + panelNumber + "/" + panelCount : "");
        svg.append(text(50, 42, heading, 24, true, "#0b1f33", "start"));

        Card parent = new Card(370, 70, 460, 126);
        svg.append(rect(parent, "#0b1f33", "#0b1f33", false));
        svg.append(text(parent.x + 24, parent.y + 36, chapter.parentCode(), 25,
                true, "#ffffff", "start"));
        svg.append(wrappedSvgText(parent.x + 24, parent.y + 66,
                chapter.parentTitle(), 20, parent.width - 48, 2, "#ffffff"));
        String parentScore = chapter.parentScore() == null
                ? labels.notEvaluated() : chapter.parentScore() + " %";
        svg.append(text(parent.x + parent.width - 24, parent.y + 38,
                parentScore, 21, true, "#ffffff", "end"));

        List<Card> cards = childCards(children.size());
        double parentX = parent.x + parent.width / 2.0;
        double parentY = parent.y + parent.height;
        double junctionY = 235;
        svg.append(line(parentX, parentY, parentX, junctionY));
        if (!cards.isEmpty()) {
            double left = cards.stream().mapToDouble(card -> card.x + card.width / 2.0).min().orElse(parentX);
            double right = cards.stream().mapToDouble(card -> card.x + card.width / 2.0).max().orElse(parentX);
            svg.append(line(left, junctionY, right, junctionY));
            for (Card card : cards) {
                double center = card.x + card.width / 2.0;
                svg.append(line(center, junctionY, center, card.y));
            }
        }

        for (int index = 0; index < children.size(); index++) {
            ChildDecision child = children.get(index);
            Card card = cards.get(index);
            CardStyle style = style(child);
            svg.append(rect(card, hex(style.fill()), hex(style.border()), style.dashed()));
            svg.append(text(card.x + 18, card.y + 31, child.code(), 22, true,
                    hex(style.text()), "start"));
            String absolute = child.absoluteScore() == null ? "—" : child.absoluteScore() + " %";
            svg.append(text(card.x + card.width - 18, card.y + 31, absolute, 22,
                    true, hex(style.text()), "end"));
            svg.append(wrappedSvgText(card.x + 18, card.y + 60, child.title(), 18,
                    card.width - 36, 3, hex(style.text())));
            String local = child.localSharePercent() == null
                    ? labels.localShare() + ": —"
                    : labels.localShare() + ": " + formatPercent(child.localSharePercent());
            svg.append(text(card.x + 18, card.y + card.height - 42, local, 16,
                    false, hex(style.secondaryText()), "start"));
            svg.append(text(card.x + 18, card.y + card.height - 17,
                    dispositionLabel(child, labels), 15, true,
                    hex(style.text()), "start"));
            if (child.leadingSibling()) {
                int badgeWidth = 112;
                Card badge = new Card(card.x + card.width - badgeWidth - 14,
                        card.y + card.height - 36, badgeWidth, 26);
                svg.append(rect(badge, "#007a78", "#007a78", false));
                svg.append(text(badge.x + badge.width / 2,
                        badge.y + 19, labels.leadingLabel(), 14, true,
                        "#ffffff", "middle"));
            }
        }
        String legend = labels.german()
                ? "Absoluter Relevanzwert · lokaler Anteil am Vaterwert · Status des Pfades"
                : "Absolute relevance score · local share of parent score · path status";
        svg.append(text(50, HEIGHT - 30, legend, 15, false, "#64707c", "start"));
        svg.append("</svg>");
        return svg.toString();
    }

    private String dispositionLabel(ChildDecision child, DecisionReportLabels labels) {
        return switch (child.disposition()) {
            case CONTINUED -> labels.continuedLabel();
            case LEAF_CANDIDATE -> labels.leafLabel();
            case REJECTED -> labels.rejectedLabel();
            case NOT_EVALUATED -> labels.notEvaluatedLabel();
        };
    }

    private String altText(
            DecisionChapter chapter,
            List<ChildDecision> children,
            DecisionReportLabels labels,
            int panelNumber,
            int panelCount) {
        StringBuilder text = new StringBuilder();
        text.append(labels.parentNode()).append(' ').append(chapter.parentCode())
                .append(' ').append(chapter.parentTitle()).append(", ")
                .append(chapter.parentScore() == null ? labels.notEvaluated()
                        : chapter.parentScore() + "%");
        if (panelCount > 1) {
            text.append(", panel ").append(panelNumber).append(" of ").append(panelCount);
        }
        for (ChildDecision child : children) {
            text.append("; ").append(child.code()).append(' ').append(child.title())
                    .append(' ').append(child.absoluteScore() == null ? labels.notEvaluated()
                            : child.absoluteScore() + "%")
                    .append(' ').append(dispositionLabel(child, labels));
        }
        return text.toString();
    }

    private void drawWrapped(
            Graphics2D graphics,
            String value,
            int x,
            int y,
            int maxWidth,
            int lineHeight,
            int maxLines) {
        List<String> lines = wrap(value, graphics.getFontMetrics(), maxWidth, maxLines);
        for (int index = 0; index < lines.size(); index++) {
            graphics.drawString(lines.get(index), x, y + index * lineHeight);
        }
    }

    private void drawRight(Graphics2D graphics, String value, int rightX, int baselineY) {
        graphics.drawString(value, rightX - graphics.getFontMetrics().stringWidth(value), baselineY);
    }

    private List<String> wrap(String value, FontMetrics metrics, int maxWidth, int maxLines) {
        String safe = value == null || value.isBlank() ? "—" : value.strip();
        String[] words = safe.split("\\s+");
        List<String> lines = new ArrayList<>();
        StringBuilder current = new StringBuilder();
        for (String word : words) {
            String candidate = current.isEmpty() ? word : current + " " + word;
            if (metrics.stringWidth(candidate) <= maxWidth || current.isEmpty()) {
                current.setLength(0);
                current.append(candidate);
            } else {
                lines.add(current.toString());
                current.setLength(0);
                current.append(word);
                if (lines.size() == maxLines - 1) {
                    break;
                }
            }
        }
        if (!current.isEmpty() && lines.size() < maxLines) {
            lines.add(current.toString());
        }
        if (lines.isEmpty()) {
            lines.add("—");
        }
        if (String.join(" ", lines).length() < safe.length()) {
            int last = lines.size() - 1;
            String shortened = lines.get(last);
            while (!shortened.isEmpty() && metrics.stringWidth(shortened + "…") > maxWidth) {
                shortened = shortened.substring(0, shortened.length() - 1);
            }
            lines.set(last, shortened.stripTrailing() + "…");
        }
        return lines;
    }

    private String wrappedSvgText(
            int x,
            int y,
            String value,
            int fontSize,
            int maxWidth,
            int maxLines,
            String color) {
        FontMetrics metrics = metrics(new Font(Font.SANS_SERIF, Font.PLAIN, fontSize));
        List<String> lines = wrap(value, metrics, maxWidth, maxLines);
        StringBuilder result = new StringBuilder();
        result.append("<text x=\"").append(x).append("\" y=\"").append(y)
                .append("\" font-family=\"Arial, sans-serif\" font-size=\"")
                .append(fontSize).append("\" fill=\"").append(color).append("\">");
        for (int index = 0; index < lines.size(); index++) {
            result.append("<tspan x=\"").append(x).append("\" dy=\"")
                    .append(index == 0 ? 0 : fontSize + 2).append("\">")
                    .append(xml(lines.get(index))).append("</tspan>");
        }
        result.append("</text>");
        return result.toString();
    }

    private FontMetrics metrics(Font font) {
        BufferedImage image = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB);
        Graphics2D graphics = image.createGraphics();
        try {
            graphics.setFont(font);
            return graphics.getFontMetrics();
        } finally {
            graphics.dispose();
        }
    }

    private String rect(Card card, String fill, String stroke, boolean dashed) {
        return "<rect x=\"" + card.x + "\" y=\"" + card.y
                + "\" width=\"" + card.width + "\" height=\"" + card.height
                + "\" rx=\"18\" fill=\"" + fill + "\" stroke=\"" + stroke
                + "\" stroke-width=\"2.5\""
                + (dashed ? " stroke-dasharray=\"10 8\"" : "") + "/>";
    }

    private String line(double x1, double y1, double x2, double y2) {
        return "<line x1=\"" + x1 + "\" y1=\"" + y1 + "\" x2=\"" + x2
                + "\" y2=\"" + y2 + "\" stroke=\"#cdd5dc\" stroke-width=\"3\"/>";
    }

    private String text(
            int x,
            int y,
            String value,
            int fontSize,
            boolean bold,
            String fill,
            String anchor) {
        return "<text x=\"" + x + "\" y=\"" + y
                + "\" font-family=\"Arial, sans-serif\" font-size=\"" + fontSize
                + "\" font-weight=\"" + (bold ? "700" : "400")
                + "\" fill=\"" + fill + "\" text-anchor=\"" + anchor + "\">"
                + xml(value) + "</text>";
    }

    private String formatPercent(Double value) {
        if (value == null) {
            return "—";
        }
        return Math.abs(value - Math.rint(value)) < 0.05
                ? String.format(java.util.Locale.ROOT, "%.0f%%", value)
                : String.format(java.util.Locale.ROOT, "%.1f%%", value);
    }

    private String hex(Color color) {
        return String.format(java.util.Locale.ROOT, "#%02x%02x%02x",
                color.getRed(), color.getGreen(), color.getBlue());
    }

    private String xml(String value) {
        if (value == null) {
            return "";
        }
        return value.replace("&", "&amp;")
                .replace("<", "&lt;")
                .replace(">", "&gt;")
                .replace("\"", "&quot;")
                .replace("'", "&apos;");
    }

    private record Card(int x, int y, int width, int height) {
    }

    private record CardStyle(
            Color fill,
            Color border,
            Color text,
            Color secondaryText,
            boolean dashed) {
    }
}