diff --git a/src/main/java/net/discordjug/javabot/systems/help/commands/HelpAccountSubcommand.java b/src/main/java/net/discordjug/javabot/systems/help/commands/HelpAccountSubcommand.java index 18042daec..6083f4e2f 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/commands/HelpAccountSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/help/commands/HelpAccountSubcommand.java @@ -125,7 +125,7 @@ private FileUpload generatePlot(User user) { plotData.add(new Pair<>(position.getMonth() + " " + position.getYear(), new Plotter.Bar(value))); } - BufferedImage plt = new Plotter(plotData, "gained help XP per month").plot(); + BufferedImage plt = new Plotter(plotData, "General Helper XP Gains","XP earned each month from general helper activity").plot(); try(ByteArrayOutputStream os = new ByteArrayOutputStream()){ ImageIO.write(plt, "png", os); return FileUpload.fromData(os.toByteArray(), "image.png"); diff --git a/src/main/java/net/discordjug/javabot/systems/help/commands/HelpStatisticsSubcommand.java b/src/main/java/net/discordjug/javabot/systems/help/commands/HelpStatisticsSubcommand.java index 50886eb09..1eb1a0dee 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/commands/HelpStatisticsSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/help/commands/HelpStatisticsSubcommand.java @@ -5,14 +5,8 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.time.LocalDate; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.Map.Entry; -import java.util.PriorityQueue; import javax.imageio.ImageIO; @@ -24,6 +18,8 @@ import net.discordjug.javabot.util.Plotter; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; import net.dv8tion.jda.api.utils.FileUpload; import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; @@ -34,21 +30,30 @@ public class HelpStatisticsSubcommand extends SlashCommand.Subcommand { private static final List> COLORS = List.of( - new Pair<>("Red", Color.RED), new Pair<>("Blue", Color.BLUE), new Pair<>("Yellow", Color.YELLOW), - new Pair<>("Green", Color.GREEN), new Pair<>("Cyan", Color.CYAN), new Pair<>("Magenta", Color.MAGENTA), - new Pair<>("Orange", Color.ORANGE), new Pair<>("Pink", Color.PINK), new Pair<>("Light gray", Color.LIGHT_GRAY) + new Pair<>("Red", Color.decode("#D62728")), new Pair<>("Blue", Color.decode("#1F77B4")), new Pair<>("Yellow", Color.decode("#BCBD22")), + new Pair<>("Green", Color.decode("#2CA02C")), new Pair<>("Cyan", Color.decode("#17BECF")), new Pair<>("Magenta", Color.decode("#9467BD")), + new Pair<>("Orange", Color.decode("#FF7F0E")), new Pair<>("Pink", Color.decode("#E377C2")), new Pair<>("Light gray", Color.decode("#7F7F7F")) ); private final HelpTransactionRepository transactionRepository; - + + /** + * Creates the help statistics subcommand. + * + * @param transactionRepository repository for help transactions + */ public HelpStatisticsSubcommand(HelpTransactionRepository transactionRepository) { this.transactionRepository = transactionRepository; - setCommandData(new SubcommandData("stats", "Shows an general plot about help activity in this server")); + setCommandData(new SubcommandData("stats", "Shows an general plot about help activity in this server") + .addOption(OptionType.BOOLEAN, "darkmode", "generate a plot in dark mode.", false) + ); + } @Override public void execute(SlashCommandInteractionEvent event) { - + boolean darkMode = event.getOption("darkmode", false, OptionMapping::getAsBoolean); + event.deferReply().queue(); List> transactionWeights = transactionRepository.getTotalTransactionWeightByMonthAndUsers(LocalDate.now().withDayOfMonth(1).minusYears(1).atStartOfDay()); @@ -75,8 +80,8 @@ public void execute(SlashCommandInteractionEvent event) { } plotData.add(new Pair<>(position.getMonth() + " " + position.getYear(), new Plotter.Bar(entriesForThisMonth))); } - - BufferedImage plot = new Plotter(plotData, "General helper statistics").plot(); + + BufferedImage plot = new Plotter(plotData, "Help Statistics","Monthly assistance provided to community members",darkMode).plot(); try(ByteArrayOutputStream os = new ByteArrayOutputStream()){ ImageIO.write(plot, "png", os); FileUpload upload = FileUpload.fromData(os.toByteArray(), "image.png"); diff --git a/src/main/java/net/discordjug/javabot/util/Plotter.java b/src/main/java/net/discordjug/javabot/util/Plotter.java index 59ed2279e..03b2321b1 100644 --- a/src/main/java/net/discordjug/javabot/util/Plotter.java +++ b/src/main/java/net/discordjug/javabot/util/Plotter.java @@ -1,7 +1,6 @@ package net.discordjug.javabot.util; -import java.awt.Color; -import java.awt.Graphics2D; +import java.awt.*; import java.awt.image.BufferedImage; import java.util.List; @@ -9,92 +8,252 @@ * Creates diagrams. */ public class Plotter { - + private static final int WIDTH = 3000; + private static final int HEIGHT = 1500; + + private static final int GRID_LINES = 6; + private static final int PILL_MARGIN = 34; + private static final int PILL_HEIGHT = 42; + private static final int PILL_BOTTOM_MARGIN = 65; + private static final int LEFT_HEADING_MARGIN = (int) (WIDTH * 0.02); // 2% + private static final int LEFT_GRAPH_MARGIN = (int) (WIDTH * 0.05); // 5% + private static final int RIGHT_GRAPH_MARGIN = (int) (WIDTH * 0.02); // 2% + private static final int TOP_HEADING_MARGIN = (int) (HEIGHT * 0.06); // 6% + private static final int TOP_SUBHEADING_MARGIN = (int) (HEIGHT * 0.09); // 9% + private static final int TOP_GRAPH_MARGIN = (int) (HEIGHT * 0.15); // 15% + private static final int BOTTOM_GRAPH_MARGIN = (int) (HEIGHT * 0.09); // 9% + private static final int TITLE_SIZE = (int) (HEIGHT * 0.038); // 3.87% + private static final int SUBTITLE_SIZE = (int) (HEIGHT * 0.018); // 1.87% + private static final int AXIS_SIZE = (int) (HEIGHT * 0.016); // 1.6% + private static final int LABEL_SIZE = (int) (HEIGHT * 0.016); // 1.67% + private static final int VALUE_SIZE = (int) (HEIGHT * 0.016); // 1.6% + private static final int GRAPH_WIDTH = WIDTH - LEFT_GRAPH_MARGIN - RIGHT_GRAPH_MARGIN; + private static final int GRAPH_HEIGHT = HEIGHT - TOP_GRAPH_MARGIN - BOTTOM_GRAPH_MARGIN; + + private Color BOARDER_COLOR = Color.BLACK; + private Color BACKGROUND = Color.decode("#EAEDF5"); + private Color GRID = Color.decode("#B2B2B2"); + private Color GRID_STRONG = Color.decode("#606061"); + private Color TEXT = Color.decode("#2D2D2D"); + private Color TEXT_MUTED = Color.decode("#42474D"); + private Color TEXT_DIM = Color.decode("#59616D"); + private Color PILL = Color.decode("#D9D9D9"); + private String title; + private String subtitle; private final List> entries; - private int width=3000; - private int height=1500; - + /** * Creates the plotter. * @param entries a list of all data points to plot, each represented as a {@link Pair} consisting of the name and value of the data point * @param title the title of the plot + * @param subtitle the subtitle of plot */ - public Plotter(List> entries, String title) { + public Plotter(List> entries, String title, String subtitle) { this.entries = entries; this.title = title; + this.subtitle = subtitle; } - + + /** + * Creates the plotter. + * @param entries a list of all data points to plot, each represented as a {@link Pair} consisting of the name and value of the data point + * @param title the title of the plot + * @param subtitle the subtitle of plot + * @param darkMode the dark mode for plot + */ + public Plotter(List> entries, String title, String subtitle,boolean darkMode) { + this.entries = entries; + this.title = title; + this.subtitle = subtitle; + if (darkMode){ + BACKGROUND = Color.decode("#111318"); + GRID = Color.decode("#252A32"); + GRID_STRONG = Color.decode("#303640"); + TEXT = Color.decode("#F5F7FA"); + PILL = Color.decode("#171C23"); + } + } + /** * Create a diagram from the data supplied to the constructor. * @return the diagram as a {@link BufferedImage} */ public BufferedImage plot() { - BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); - Graphics2D g2d = img.createGraphics(); - - g2d.setFont(ImageGenerationUtils.getResourceFont("assets/fonts/Uni-Sans-Heavy.ttf", 30).orElseThrow()); - - g2d.setBackground(Color.WHITE); - g2d.fillRect(0, 0, width, height); - g2d.setColor(Color.BLACK); - - centeredText(g2d, title, width/2, 50); - - plotEntries(g2d, 100, 100, width-200, height-200); - + BufferedImage img = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics2D = img.createGraphics(); + + setBackground(graphics2D); + + drawHeading(graphics2D,LEFT_HEADING_MARGIN,TOP_HEADING_MARGIN,TOP_SUBHEADING_MARGIN); + drawGraph(graphics2D,LEFT_GRAPH_MARGIN,TOP_GRAPH_MARGIN,GRAPH_WIDTH,GRAPH_HEIGHT); + return img; } - private void plotEntries(Graphics2D g2d, int x, int y, int width, int height) { - double maxValue = entries.stream().map(Pair::second).mapToDouble(Bar::sum).max().orElse(0); - int stepSize = 2*(int)Math.pow(10,(int)Math.log10(maxValue)-1); - if (stepSize==0) { - stepSize=1; - } - maxValue += stepSize; - - int numEntries = entries.size(); - - int currentX = x; - - g2d.drawLine(x, y, x, y+height); - - if(maxValue>0) { - for (int current = 0; current < maxValue; current += stepSize) { - g2d.drawString(String.valueOf(current), 95-g2d.getFontMetrics().stringWidth(String.valueOf(current)), this.height-(y+(height*current)/(int)maxValue)+g2d.getFontMetrics().getHeight()/3); - } + private void drawHeading(Graphics2D graphics2D,int startX, int titleY, int subtitleY){ + Font titleFont = ImageGenerationUtils.getResourceFont("assets/fonts/Uni-Sans-Heavy.ttf", TITLE_SIZE).orElseThrow(); + Font subtitleFont = ImageGenerationUtils.getResourceFont("assets/fonts/Uni-Sans-Heavy.ttf", SUBTITLE_SIZE).orElseThrow(); + + graphics2D.setColor(TEXT); + graphics2D.setFont(titleFont); + graphics2D.drawString(title,startX,titleY); + + graphics2D.setColor(TEXT_MUTED); + graphics2D.setFont(subtitleFont); + graphics2D.drawString(subtitle,startX,subtitleY); + + } + + private void setBackground(Graphics2D graphics2D){ + graphics2D.setColor(BACKGROUND); + graphics2D.fillRect(-1, -1, WIDTH, HEIGHT); + } + + private void drawGraph(Graphics2D graphics2D, int startX, int startY, int width,int height){ + double maxValue = entries.stream() + .map(Pair::second) + .mapToDouble(Bar::sum) + .max().orElse(0); + + if(maxValue == 0) return; + double axisMax = niceMaximum(maxValue); + + drawLines(graphics2D,startX,startY,width,height,axisMax); + drawBars(graphics2D,startX,startY,width,height,axisMax); + } + + private void drawLines(Graphics2D graphics2D,int startX ,int startY,int width ,int height, double axisMax){ + Font axisFont = ImageGenerationUtils.getResourceFont("assets/fonts/Uni-Sans-Heavy.ttf", AXIS_SIZE).orElseThrow(); + graphics2D.setStroke(new BasicStroke(2, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); // Use a 2px stroke because a line is between pixels. + graphics2D.setFont(axisFont); + + for (int i = 0; i <= GRID_LINES; i++) { + double fraction = (double) i / GRID_LINES; + int gridY = startY + height - (int) (height * fraction); + + graphics2D.setColor(i == 0 ? GRID_STRONG : GRID); + graphics2D.drawLine(startX, gridY+1, startX + width, gridY+1); // Move 1px down so the 2px stroke aligns with the grid position. + + double value = axisMax * fraction; + + String label = formatValue(value); + graphics2D.setColor(TEXT_DIM); + graphics2D.drawString(label, startX - 25 - graphics2D.getFontMetrics().stringWidth(label), gridY + 8); } - - boolean shift=false; - for (Pair entry : entries) { - int shiftNum = shift ? g2d.getFontMetrics().getHeight() : 0; - centeredText(g2d, entry.first(), currentX+(width/(2*numEntries)), this.height-y/2+shiftNum); + } + + private void drawBars(Graphics2D graphics2D,int startX ,int startY,int width ,int height, double axisMax){ + int count = entries.size(); + int slotWidth = width / count; + int barWidth = Math.min(110, (int) (slotWidth * 0.42)); + + Font labelFont = ImageGenerationUtils.getResourceFont("assets/fonts/Uni-Sans-Heavy.ttf", LABEL_SIZE).orElseThrow(); + Font valueFont = ImageGenerationUtils.getResourceFont("assets/fonts/Uni-Sans-Heavy.ttf", VALUE_SIZE).orElseThrow(); + + for (int i = 0; i < count; i++) { + Pair entry = entries.get(i); Bar bar = entry.second(); - int totalBarHeight = 0; - - double barSum = 0; - - for (Pair barSquares : bar.elements()) { - int entryHeight = (int)(height*barSquares.second()/maxValue); - barSum += barSquares.second(); - g2d.setColor(barSquares.first()); - int start = this.height-y-entryHeight - totalBarHeight; - g2d.fillRect(currentX, start, width/numEntries, entryHeight); - g2d.setColor(Color.BLACK); - g2d.drawRect(currentX, start, width/numEntries, entryHeight); - totalBarHeight += entryHeight; + double total = bar.sum(); + int centerX = startX + (slotWidth * i )+ (slotWidth / 2); + int totalHeight = (int) (height * (total / axisMax)); + int barX = centerX - barWidth / 2; + int barBottom = startY + height; + + String totalText = formatValue(total); + graphics2D.setFont(valueFont); + int textWidth = graphics2D.getFontMetrics().stringWidth(totalText); + int pillWidth = textWidth + PILL_MARGIN; + int pillY = barBottom - totalHeight - PILL_BOTTOM_MARGIN; + + graphics2D.setColor(PILL); + graphics2D.fillRoundRect(centerX - pillWidth / 2, pillY, pillWidth, PILL_HEIGHT, 18, 18); + graphics2D.setColor(TEXT); + graphics2D.drawString(totalText, centerX - textWidth / 2, pillY + 29); + + int currentY = barBottom; + for (Pair element : bar.elements()) { + double value = element.second(); + int segmentHeight = (int) (height * (value / axisMax)); + if (segmentHeight <= 0) continue; + + currentY -=segmentHeight; + + graphics2D.setColor(element.first()); + graphics2D.fillRect(barX, currentY, barWidth, segmentHeight); + + graphics2D.setColor(BOARDER_COLOR); + graphics2D.drawRect(barX-1, currentY-1, barWidth+1, segmentHeight+1); } - centeredText(g2d, String.valueOf(Math.round(barSum*100)/100.0), currentX+(width/(2*numEntries)), this.height-y-totalBarHeight-10); - shift=!shift; - currentX += width/numEntries; + + graphics2D.setFont(labelFont); + graphics2D.setColor(TEXT_MUTED); + + String label = formatMonth(entry.first()); + int labelWidth = graphics2D.getFontMetrics().stringWidth(label); + graphics2D.drawString(label, centerX - labelWidth / 2, startY + height + 65); } } - - private void centeredText(Graphics2D g2d, String text, int x, int y) { - g2d.drawString(text, x-g2d.getFontMetrics().stringWidth(text)/2, y); + + private String formatValue(double value) { + if (value >= 1_000_000) { + return String.format("%.1fM", value / 1_000_000); + } + + if (value >= 1_000) { + return String.format("%.1fK", value / 1_000); + } + + if (value % 1 == 0) { + return String.format("%.0f", value); + } + + return String.format("%.2f", value); + } + + private String formatMonth(String month) { + String[] parts = month.split(" "); + + String monthName = parts[0]; + String year = parts[1]; + + String shortMonth = switch (monthName) { + case "JANUARY" -> "Jan"; + case "FEBRUARY" -> "Feb"; + case "MARCH" -> "Mar"; + case "APRIL" -> "Apr"; + case "MAY" -> "May"; + case "JUNE" -> "Jun"; + case "JULY" -> "Jul"; + case "AUGUST" -> "Aug"; + case "SEPTEMBER" -> "Sep"; + case "OCTOBER" -> "Oct"; + case "NOVEMBER" -> "Nov"; + case "DECEMBER" -> "Dec"; + default -> throw new IllegalArgumentException("Invalid month: " + monthName); + }; + + return shortMonth + " " + year.substring(2); } - + + private double niceMaximum(double value) { + double magnitude = Math.pow(10, Math.floor(Math.log10(value))); + double normalized = value / magnitude; + double nice; + + if (normalized <= 1) { + nice = 1; + } else if (normalized <= 2) { + nice = 2; + } else if (normalized <= 5) { + nice = 5; + } else { + nice = 10; + } + + return nice * magnitude; + } + /** * A single bar which should be plotted. * diff --git a/src/test/java/net/discordjug/javabot/util/PlotterTest.java b/src/test/java/net/discordjug/javabot/util/PlotterTest.java new file mode 100644 index 000000000..c7d6e4784 --- /dev/null +++ b/src/test/java/net/discordjug/javabot/util/PlotterTest.java @@ -0,0 +1,90 @@ +package net.discordjug.javabot.util; + +import org.junit.jupiter.api.Test; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.time.YearMonth; +import java.time.format.TextStyle; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertTrue; + + +public class PlotterTest { + private static final Color[] PALETTE = { + new Color(0xF9C74F), new Color(0xF9506E), new Color(0x6C7280), + new Color(0x5B8CFF), new Color(0x43D9AD), new Color(0xC084FC), + new Color(0x22D3EE), new Color(0xFB923C) + }; + + @Test + public void ImageDifferenceTest(){ + List> testData = testData(); + BufferedImage img1 = new Plotter(testData, "General helper statistics","subtitle").plot(); + BufferedImage img2 = readImage("src/test/resources/PlotterTest.png"); + + assertTrue(compareImage(img1,img2)); + } + + public static BufferedImage readImage(String path){ + try { + File inputFile = new File(path); + BufferedImage image = ImageIO.read(inputFile); + if (image == null) { + throw new IOException("Image is null."); + } + return image; + } catch (IOException e) { + System.err.println("Error reading the image file: " + e.getMessage()); + return null; + } + } + + public static boolean compareImage(BufferedImage img1, BufferedImage img2) { + if (img1 == null || img2 == null) { + return false; + } + + if (img1.getWidth() != img2.getWidth() || + img1.getHeight() != img2.getHeight()) { + return false; + } + + for (int y = 0; y < img1.getHeight(); y++) { + for (int x = 0; x < img1.getWidth(); x++) { + if (img1.getRGB(x, y) != img2.getRGB(x, y)) { + return false; + } + } + } + return true; + } + + private static List> testData() { + Random random = new Random(42); + List> entries = new ArrayList<>(); + YearMonth month = YearMonth.of(2026,9); + + for (int i = 0; i < 13; i++) { + int segments = random.nextInt(3,5); + List> parts = new ArrayList<>(); + + for (int s = 0; s < segments; s++) { + double value = random.nextDouble(50,1800); + parts.add(new Pair<>(PALETTE[s % PALETTE.length], value)); + } + + String monthLabel = month.getMonth().getDisplayName(TextStyle.FULL, Locale.ENGLISH).toUpperCase(Locale.ROOT) + " " + month.getYear(); + entries.add(new Pair<>(monthLabel, new Plotter.Bar(parts))); + month = month.plusMonths(1); + } + return entries; + } +} diff --git a/src/test/resources/PlotterTest.png b/src/test/resources/PlotterTest.png new file mode 100644 index 000000000..66eae255d Binary files /dev/null and b/src/test/resources/PlotterTest.png differ