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..a35f8294a 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,9 @@ 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.time.format.TextStyle; +import java.util.*; import java.util.Map.Entry; -import java.util.PriorityQueue; import javax.imageio.ImageIO; @@ -24,6 +19,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 +31,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", "Generates the plot with a dark background.", 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()); @@ -73,10 +79,10 @@ public void execute(SlashCommandInteractionEvent event) { correctMonth = false; } } - plotData.add(new Pair<>(position.getMonth() + " " + position.getYear(), new Plotter.Bar(entriesForThisMonth))); + plotData.add(new Pair<>(position.getMonth().getDisplayName(TextStyle.SHORT_STANDALONE, Locale.ROOT) + " '" + String.valueOf(position.getYear()).substring(2), 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..79e064ac9 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,102 +8,280 @@ * Creates diagrams. */ public class Plotter { - - private String title; + private static final int WIDTH = 3000; + private static final int HEIGHT = 1500; + + private static final int GRID_LINES = 5; + private static final int BAR_LABEL_MARGIN = 34; + private static final int BAR_LABEL_HEIGHT = 42; + private static final int BAR_LABEL_BOTTOM_MARGIN = 65; + private static final int TITLE_FONT_SIZE = 57; + private static final int SUBTITLE_FONT_SIZE = 27; + private static final int AXIS_FONT_SIZE = 24; + private static final int HEADING_MARGIN_LEFT = (int) (WIDTH * 0.02); // 2% + private static final int AXIS_LABEL_MARGIN_LEFT = (int) (WIDTH * 0.045); // 4.5% + private static final int GRAPH_MARGIN_LEFT = (int) (WIDTH * 0.05); // 5% + private static final int GRAPH_MARGIN_RIGHT = (int) (WIDTH * 0.02); // 2% + private static final int AXIS_LABEL_MARGIN_BOTTOM = (int) (HEIGHT * 0.043); // 4.3% + private static final int HEADING_MARGIN_TOP = (int) (HEIGHT * 0.06); // 6% + private static final int SUBHEADING_MARGIN_TOP = (int) (HEIGHT * 0.09); // 9% + private static final int GRAPH_MARGIN_TOP = (int) (HEIGHT * 0.15); // 15% + private static final int GRAPH_MARGIN_BOTTOM = (int) (HEIGHT * 0.09); // 9% + private static final int LABEL_SIZE = (int) (HEIGHT * 0.016); // 1.6% + private static final int VALUE_SIZE = (int) (HEIGHT * 0.016); // 1.6% + private static final int GRAPH_WIDTH = WIDTH - GRAPH_MARGIN_LEFT - GRAPH_MARGIN_RIGHT; + private static final int GRAPH_HEIGHT = HEIGHT - GRAPH_MARGIN_TOP - GRAPH_MARGIN_BOTTOM; + private static final int ARC_SIZE = 18; + private static final double BAR_WIDTH_RATIO = 0.42; + private static final int BAR_WIDTH_MAX = 110; + + private final Color borderColor; + private final Color backgroundColor; + private final Color gridLineColor; + private final Color gridLineColorStrong; + private final Color textColor; + private final Color textColorMuted; + private final Color textColorDim; + private final Color barLabelColor; + + private final String titleText; + private final String subtitleText; 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 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 titleText the title of the plot + * @param subtitleText the subtitle of plot */ - public Plotter(List> entries, String title) { + public Plotter(List> entries, String titleText, String subtitleText) { this.entries = entries; - this.title = title; + this.titleText = titleText; + this.subtitleText = subtitleText; + borderColor = Color.BLACK; + backgroundColor = Color.decode("#EAEDF5"); + gridLineColor = Color.decode("#B2B2B2"); + gridLineColorStrong = Color.decode("#606061"); + textColor = Color.decode("#2D2D2D"); + textColorMuted = Color.decode("#42474D"); + textColorDim = Color.decode("#59616D"); + barLabelColor = Color.decode("#D9D9D9"); } - + + /** + * 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 titleText the title of the plot + * @param subtitleText the subtitle of plot + * @param darkMode {@code true} if the plot should be generated in dark mode, otherwise {@code false} + */ + public Plotter(List> entries, String titleText, String subtitleText, boolean darkMode) { + this.entries = entries; + this.titleText = titleText; + this.subtitleText = subtitleText; + borderColor = Color.BLACK; + if (darkMode) { + backgroundColor = Color.decode("#111318"); + gridLineColor = Color.decode("#252A32"); + gridLineColorStrong = Color.decode("#303640"); + textColor = Color.decode("#F5F7FA"); + barLabelColor = Color.decode("#333333"); + textColorMuted = Color.decode("#C9C9C9"); + textColorDim = Color.decode("#E3E3E3"); + } else { + backgroundColor = Color.decode("#EAEDF5"); + gridLineColor = Color.decode("#B2B2B2"); + gridLineColorStrong = Color.decode("#606061"); + textColor = Color.decode("#2D2D2D"); + barLabelColor = Color.decode("#D9D9D9"); + textColorMuted = Color.decode("#42474D"); + textColorDim = Color.decode("#59616D"); + } + } + /** * 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(); + + fillBackground(graphics2D); + + drawHeadings(graphics2D); + drawGraph(graphics2D); + 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; + private void drawHeadings(Graphics2D graphics2D) { + Font titleFont = ImageGenerationUtils.getResourceFont("assets/fonts/Uni-Sans-Heavy.ttf", TITLE_FONT_SIZE).orElseThrow(); + Font subtitleFont = ImageGenerationUtils.getResourceFont("assets/fonts/Uni-Sans-Heavy.ttf", SUBTITLE_FONT_SIZE).orElseThrow(); + + graphics2D.setColor(textColor); + graphics2D.setFont(titleFont); + graphics2D.drawString(titleText, HEADING_MARGIN_LEFT, HEADING_MARGIN_TOP); + + graphics2D.setColor(textColorMuted); + graphics2D.setFont(subtitleFont); + graphics2D.drawString(subtitleText, HEADING_MARGIN_LEFT, SUBHEADING_MARGIN_TOP); + + } + + private void fillBackground(Graphics2D graphics2D) { + graphics2D.setColor(backgroundColor); + graphics2D.fillRect(-1, -1, WIDTH, HEIGHT); + } + + private void drawGraph(Graphics2D graphics2D) { + double maxValue = entries.stream().map(Pair::second).mapToDouble(Bar::sum).max().orElse(0); + + if (maxValue == 0) { + return; } - 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); - } + double axisMax = niceMaximum(maxValue); + + drawGridLines(graphics2D, axisMax); + drawBars(graphics2D, axisMax); + } + + private void drawGridLines(Graphics2D graphics2D, double axisMax) { + Font axisFont = ImageGenerationUtils.getResourceFont("assets/fonts/Uni-Sans-Heavy.ttf", AXIS_FONT_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 = GRAPH_MARGIN_TOP + GRAPH_HEIGHT - (int) (GRAPH_HEIGHT * fraction); + + graphics2D.setColor(i == 0 ? gridLineColorStrong : gridLineColor); + graphics2D.drawLine(GRAPH_MARGIN_LEFT, gridY + 1, GRAPH_MARGIN_LEFT + GRAPH_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(textColorDim); + graphics2D.drawString(label, AXIS_LABEL_MARGIN_LEFT - (graphics2D.getFontMetrics().stringWidth(label)), gridY + (graphics2D.getFontMetrics().getAscent() + graphics2D.getFontMetrics().getDescent()) / 2); } - - 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, double axisMax) { + int count = entries.size(); + int slotWidth = GRAPH_WIDTH / count; + Font labelFont = ImageGenerationUtils.getResourceFont("assets/fonts/Uni-Sans-Heavy.ttf", LABEL_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; + + int centerX = GRAPH_MARGIN_LEFT + (slotWidth * i) + (slotWidth / 2); + int barWidth = Math.min(BAR_WIDTH_MAX, (int) (slotWidth * BAR_WIDTH_RATIO)); + int barX = centerX - barWidth / 2; + + drawBarLabel(graphics2D, axisMax, centerX, bar); + drawSegmentedBars(graphics2D, bar, barX, barWidth, axisMax); + + graphics2D.setFont(labelFont); + graphics2D.setColor(textColorMuted); + + String label = entry.first(); + drawStringCentered(graphics2D, label, centerX, GRAPH_MARGIN_TOP + GRAPH_HEIGHT + AXIS_LABEL_MARGIN_BOTTOM); + } + } + + private void drawBarLabel(Graphics2D graphics2D, double axisMax, int centerX, Bar bar) { + int barBottom = GRAPH_MARGIN_TOP + GRAPH_HEIGHT; + double barTotal = bar.sum(); + int totalBarHeight = (int) (GRAPH_HEIGHT * (barTotal / axisMax)); + int barLabelY = barBottom - totalBarHeight - BAR_LABEL_BOTTOM_MARGIN; + + Font valueFont = ImageGenerationUtils.getResourceFont("assets/fonts/Uni-Sans-Heavy.ttf", VALUE_SIZE).orElseThrow(); + graphics2D.setFont(valueFont); + + String totalText = formatValue(barTotal); + int textWidth = graphics2D.getFontMetrics().stringWidth(totalText); + int barLabelWidth = textWidth + BAR_LABEL_MARGIN; + + graphics2D.setColor(barLabelColor); + graphics2D.fillRoundRect(centerX - barLabelWidth / 2, barLabelY, barLabelWidth, BAR_LABEL_HEIGHT, ARC_SIZE, ARC_SIZE); + graphics2D.setColor(textColor); + drawStringCentered(graphics2D,totalText,centerX, barLabelY + 29); + } + + private void drawSegmentedBars(Graphics2D graphics2D, Bar bar,int barX, int barWidth, double axisMax) { + int barBottom = GRAPH_MARGIN_TOP + GRAPH_HEIGHT; + int barSum = 0; + int incrementalBarHeight = 0; + + for (Pair element : bar.elements()) { + double value = element.second(); + barSum += value; + int expectedBarHeight = (int) (GRAPH_HEIGHT * (barSum / axisMax)); + int segmentHeight = expectedBarHeight - incrementalBarHeight; + incrementalBarHeight += segmentHeight; + int currentY = barBottom - incrementalBarHeight; + + if (segmentHeight > 0) { + graphics2D.setColor(element.first()); + graphics2D.fillRect(barX, currentY, barWidth, segmentHeight); + + graphics2D.setColor(borderColor); + 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; } } - - private void centeredText(Graphics2D g2d, String text, int x, int y) { - g2d.drawString(text, x-g2d.getFontMetrics().stringWidth(text)/2, y); + + private void drawStringCentered(Graphics2D graphics2D,String text,int x, int y){ + int textWidth = graphics2D.getFontMetrics().stringWidth(text); + graphics2D.drawString(text,x- textWidth / 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 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.5) { + nice = 2.5; + } else if (normalized <= 5) { + nice = 5; + } else { + nice = 10; + } + + return nice * magnitude; } - + /** * A single bar which should be plotted. - * * @param elements any number of the entries to plot */ - public record Bar(List> elements) { + public record Bar(List> elements) { public Bar(double singleElement) { this(List.of(new Pair<>(Color.GRAY, singleElement))); } - + private double sum() { return elements.stream().mapToDouble(Pair::second).sum(); } 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..3a692a9ce --- /dev/null +++ b/src/test/java/net/discordjug/javabot/util/PlotterTest.java @@ -0,0 +1,205 @@ +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.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URL; +import java.util.*; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +public class PlotterTest { + @Test + public void ImageDifferenceTest() throws IOException{ + List> testData = testData0(); + BufferedImage generatedImage = new Plotter(testData, "General helper statistics","subtitle").plot(); + BufferedImage expectedImage = readImage("PlotterTestImages/PlotterTest-Light.png"); + + assertTrue(assertImagesAreEqual(generatedImage,expectedImage)); + } + + @Test + public void ImageDifferenceTestDarkMode() throws IOException{ + List> testData = testData0(); + BufferedImage generatedImage = new Plotter(testData, "General helper statistics","subtitle",true).plot(); + BufferedImage expectedImage = readImage("PlotterTestImages/PlotterTest-Dark.png"); + + assertTrue(assertImagesAreEqual(generatedImage,expectedImage)); + } + + @Test + public void ImageDifferenceTestLessData() throws IOException{ + List> testData = testData1(); + BufferedImage generatedImage = new Plotter(testData, "General helper statistics","subtitle").plot(); + BufferedImage expectedImage = readImage("PlotterTestImages/PlotterTest-LessData.png"); + + assertTrue(assertImagesAreEqual(generatedImage,expectedImage)); + } + + @Test + public void ImageDifferenceTestNoData() throws IOException{ + List> testData = testData2(); + BufferedImage generatedImage = new Plotter(testData, "General helper statistics","subtitle").plot(); + BufferedImage expectedImage = readImage("PlotterTestImages/PlotterTest-NoData.png"); + + assertTrue(assertImagesAreEqual(generatedImage,expectedImage)); + } + + private BufferedImage readImage(String fileName) throws IOException{ + URL resource = getClass().getClassLoader().getResource(fileName); + BufferedImage image = ImageIO.read(resource); + return image; + } + + public static boolean assertImagesAreEqual(BufferedImage actualImage, BufferedImage expectedImage) throws IOException { + assertNotNull(actualImage, "Generated Image is Null."); + assertNotNull(expectedImage, "Expected Image is Null."); + + String actualImageBase64 = convertToBase64(actualImage); + + assertEquals(expectedImage.getWidth(), actualImage.getWidth(), "Image width does not match.\nActual image:"+actualImageBase64); + assertEquals(expectedImage.getHeight(), actualImage.getHeight(), "Image height does not match.\nActual image:"+actualImageBase64); + + for (int y = 0; y < actualImage.getHeight(); y++) { + for (int x = 0; x < actualImage.getWidth(); x++) { + assertEquals(actualImage.getRGB(x, y),expectedImage.getRGB(x, y),() -> "Image does not match.\nActual image:"+actualImageBase64); + } + } + return true; + } + + private static String convertToBase64(BufferedImage image) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(image, "png", out); + return Base64.getEncoder().encodeToString(out.toByteArray()); + } + + private static List> testData0() { + Color yellow = new Color(249,199,79); + Color red = new Color(249,80,110); + Color grey = new Color(108,114,128); + Color blue = new Color(91,140,255); + + List> entries = Arrays.asList( + new Pair<>("SEP '26", new Plotter.Bar(Arrays.asList( + new Pair<>(yellow, 145.6), + new Pair<>(red, 133.8), + new Pair<>(grey, 1698.6), + new Pair<>(blue, 1288.5) + ))), + new Pair<>("OCT '26", new Plotter.Bar(Arrays.asList( + new Pair<>(yellow, 1630.9), + new Pair<>(red, 695.3), + new Pair<>(grey, 532.5) + ))), + new Pair<>("NOV '26", new Plotter.Bar(Arrays.asList( + new Pair<>(yellow, 1383.6), + new Pair<>(red, 1796.8), + new Pair<>(grey, 315.9), + new Pair<>(blue, 819.6) + ))), + new Pair<>("DEC '2026", new Plotter.Bar(Arrays.asList( + new Pair<>(yellow, 726.5), + new Pair<>(red, 360.4), + new Pair<>(grey, 1090.1) + ))), + new Pair<>("JAN '27", new Plotter.Bar(Arrays.asList( + new Pair<>(yellow, 488.6), + new Pair<>(red, 689.0), + new Pair<>(grey, 327.6), + new Pair<>(blue, 529.8) + ))), + new Pair<>("FEB '27", new Plotter.Bar(Arrays.asList( + new Pair<>(yellow, 1049.3), + new Pair<>(red, 1065.0), + new Pair<>(grey, 1366.8) + ))), + new Pair<>("MAR '27", new Plotter.Bar(Arrays.asList( + new Pair<>(yellow, 603.9), + new Pair<>(red, 398.4), + new Pair<>(grey, 69.3), + new Pair<>(blue, 1396.6) + ))), + new Pair<>("APR '27", new Plotter.Bar(Arrays.asList( + new Pair<>(yellow, 1298.4), + new Pair<>(red, 891.0), + new Pair<>(grey, 560.4) + ))), + new Pair<>("MAY '27", new Plotter.Bar(Arrays.asList( + new Pair<>(yellow, 1092.8), + new Pair<>(red, 380.3), + new Pair<>(grey, 712.2) + ))), + new Pair<>("JUN '27", new Plotter.Bar(Arrays.asList( + new Pair<>(yellow, 680.4), + new Pair<>(red, 810.6), + new Pair<>(grey, 850.3), + new Pair<>(blue, 877.2) + ))), + new Pair<>("JUL '27", new Plotter.Bar(Arrays.asList( + new Pair<>(yellow, 1010.6), + new Pair<>(red, 115.4), + new Pair<>(grey, 142.0), + new Pair<>(blue, 1430.0) + ))), + new Pair<>("AUG '27", new Plotter.Bar(Arrays.asList( + new Pair<>(yellow, 541.0), + new Pair<>(red, 392.9), + new Pair<>(grey, 363.7), + new Pair<>(blue, 1564.9) + ))), + new Pair<>("SEP '27", new Plotter.Bar(Arrays.asList( + new Pair<>(yellow, 1009.2), + new Pair<>(red, 64.2), + new Pair<>(grey, 1375.8), + new Pair<>(blue, 1154.3) + ))) + ); + return entries; + } + + private static List> testData1() { + Color yellow = new Color(249,199,79); + Color red = new Color(249,80,110); + Color grey = new Color(108,114,128); + Color blue = new Color(91,140,255); + + List> entries = Arrays.asList( + new Pair<>("SEP '26", new Plotter.Bar(Arrays.asList( + new Pair<>(yellow, 145.6), + new Pair<>(red, 133.8), + new Pair<>(grey, 1698.6), + new Pair<>(blue, 1288.5) + ))), + new Pair<>("OCT '26", new Plotter.Bar(Arrays.asList( + new Pair<>(yellow, 1630.9), + new Pair<>(red, 695.3), + new Pair<>(grey, 532.5) + ))), + new Pair<>("NOV '26", new Plotter.Bar(Arrays.asList( + new Pair<>(yellow, 1383.6), + new Pair<>(red, 1796.8), + new Pair<>(grey, 315.9), + new Pair<>(blue, 819.6) + ))), + new Pair<>("DEC '2026", new Plotter.Bar(Arrays.asList( + new Pair<>(yellow, 726.5), + new Pair<>(red, 360.4), + new Pair<>(grey, 1090.1) + ))) + ); + return entries; + } + + private static List> testData2() { + return new ArrayList>(); + } +} diff --git a/src/test/resources/PlotterTestImages/PlotterTest-Dark.png b/src/test/resources/PlotterTestImages/PlotterTest-Dark.png new file mode 100644 index 000000000..1b2cb8f0b Binary files /dev/null and b/src/test/resources/PlotterTestImages/PlotterTest-Dark.png differ diff --git a/src/test/resources/PlotterTestImages/PlotterTest-LessData.png b/src/test/resources/PlotterTestImages/PlotterTest-LessData.png new file mode 100644 index 000000000..dada007ad Binary files /dev/null and b/src/test/resources/PlotterTestImages/PlotterTest-LessData.png differ diff --git a/src/test/resources/PlotterTestImages/PlotterTest-Light.png b/src/test/resources/PlotterTestImages/PlotterTest-Light.png new file mode 100644 index 000000000..e661fac92 Binary files /dev/null and b/src/test/resources/PlotterTestImages/PlotterTest-Light.png differ diff --git a/src/test/resources/PlotterTestImages/PlotterTest-NoData.png b/src/test/resources/PlotterTestImages/PlotterTest-NoData.png new file mode 100644 index 000000000..a4a5c3822 Binary files /dev/null and b/src/test/resources/PlotterTestImages/PlotterTest-NoData.png differ