aboutsummaryrefslogtreecommitdiff
path: root/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'src/main')
-rw-r--r--src/main/java/io/github/moulberry/notenoughupdates/NEUOverlay.java2
-rw-r--r--src/main/java/io/github/moulberry/notenoughupdates/NotEnoughUpdates.java6
-rw-r--r--src/main/java/io/github/moulberry/notenoughupdates/auction/APIManager.java6
-rw-r--r--src/main/java/io/github/moulberry/notenoughupdates/miscgui/GuiPriceGraph.java611
-rw-r--r--src/main/java/io/github/moulberry/notenoughupdates/options/NEUConfig.java2
-rw-r--r--src/main/java/io/github/moulberry/notenoughupdates/options/seperateSections/AHGraph.java68
-rw-r--r--src/main/kotlin/io/github/moulberry/notenoughupdates/commands/dev/DevTestCommand.kt5
-rw-r--r--src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/GraphDataProvider.kt27
-rw-r--r--src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/GuiPriceGraph.kt596
-rw-r--r--src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/LocalGraphDataProvider.kt192
-rw-r--r--src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/PriceObject.kt24
-rw-r--r--src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/ServerGraphDataProvider.kt42
-rw-r--r--src/main/kotlin/io/github/moulberry/notenoughupdates/util/KotlinNumberUtils.kt28
13 files changed, 985 insertions, 624 deletions
diff --git a/src/main/java/io/github/moulberry/notenoughupdates/NEUOverlay.java b/src/main/java/io/github/moulberry/notenoughupdates/NEUOverlay.java
index ce7b21b2..000158a4 100644
--- a/src/main/java/io/github/moulberry/notenoughupdates/NEUOverlay.java
+++ b/src/main/java/io/github/moulberry/notenoughupdates/NEUOverlay.java
@@ -37,8 +37,8 @@ import io.github.moulberry.notenoughupdates.mbgui.MBGuiGroupAligned;
import io.github.moulberry.notenoughupdates.mbgui.MBGuiGroupFloating;
import io.github.moulberry.notenoughupdates.miscfeatures.EnchantingSolvers;
import io.github.moulberry.notenoughupdates.miscfeatures.SunTzu;
-import io.github.moulberry.notenoughupdates.miscgui.GuiPriceGraph;
import io.github.moulberry.notenoughupdates.miscgui.NeuSearchCalculator;
+import io.github.moulberry.notenoughupdates.miscgui.pricegraph.GuiPriceGraph;
import io.github.moulberry.notenoughupdates.options.NEUConfigEditor;
import io.github.moulberry.notenoughupdates.util.Constants;
import io.github.moulberry.notenoughupdates.util.GuiTextures;
diff --git a/src/main/java/io/github/moulberry/notenoughupdates/NotEnoughUpdates.java b/src/main/java/io/github/moulberry/notenoughupdates/NotEnoughUpdates.java
index 14f75fb0..794df0f4 100644
--- a/src/main/java/io/github/moulberry/notenoughupdates/NotEnoughUpdates.java
+++ b/src/main/java/io/github/moulberry/notenoughupdates/NotEnoughUpdates.java
@@ -142,7 +142,8 @@ public class NotEnoughUpdates {
//Stolen from Biscut and used for detecting whether in skyblock
private static final Set<String> SKYBLOCK_IN_ALL_LANGUAGES =
Sets.newHashSet("SKYBLOCK", "\u7A7A\u5C9B\u751F\u5B58", "\u7A7A\u5CF6\u751F\u5B58",
- "SKIBLOCK"); // april fools language
+ "SKIBLOCK"
+ ); // april fools language
public static NotEnoughUpdates INSTANCE = null;
public static HashMap<String, String> petRarityToColourMap = new HashMap<String, String>() {{
put("UNKNOWN", EnumChatFormatting.RED.toString());
@@ -255,6 +256,9 @@ public class NotEnoughUpdates {
if (config.apiData.moulberryCodesApi.isEmpty()) {
config.apiData.moulberryCodesApi = "moulberry.codes";
}
+ if (config.ahGraph.serverUrl.trim().isEmpty()) {
+ config.ahGraph.serverUrl = "pricehistory.notenoughupdates.org";
+ }
saveConfig();
}
diff --git a/src/main/java/io/github/moulberry/notenoughupdates/auction/APIManager.java b/src/main/java/io/github/moulberry/notenoughupdates/auction/APIManager.java
index 329ab796..1faac488 100644
--- a/src/main/java/io/github/moulberry/notenoughupdates/auction/APIManager.java
+++ b/src/main/java/io/github/moulberry/notenoughupdates/auction/APIManager.java
@@ -25,7 +25,7 @@ import com.google.gson.JsonObject;
import io.github.moulberry.notenoughupdates.ItemPriceInformation;
import io.github.moulberry.notenoughupdates.NEUManager;
import io.github.moulberry.notenoughupdates.NotEnoughUpdates;
-import io.github.moulberry.notenoughupdates.miscgui.GuiPriceGraph;
+import io.github.moulberry.notenoughupdates.miscgui.pricegraph.LocalGraphDataProvider;
import io.github.moulberry.notenoughupdates.recipes.Ingredient;
import io.github.moulberry.notenoughupdates.recipes.ItemShopRecipe;
import io.github.moulberry.notenoughupdates.recipes.NeuRecipe;
@@ -306,7 +306,7 @@ public class APIManager {
ItemPriceInformation.updateAuctionableItemsList();
didFirstUpdate = true;
}
- GuiPriceGraph.addToCache(lowestBins, false);
+ LocalGraphDataProvider.INSTANCE.savePrices(lowestBins, false);
});
}
@@ -778,7 +778,7 @@ public class APIManager {
bazaarJson.add(transformHypixelBazaarToNEUItemId(entry.getKey()), productInfo);
}
}
- GuiPriceGraph.addToCache(bazaarJson, true);
+ LocalGraphDataProvider.INSTANCE.savePrices(bazaarJson, true);
});
}
diff --git a/src/main/java/io/github/moulberry/notenoughupdates/miscgui/GuiPriceGraph.java b/src/main/java/io/github/moulberry/notenoughupdates/miscgui/GuiPriceGraph.java
deleted file mode 100644
index c03a72a6..00000000
--- a/src/main/java/io/github/moulberry/notenoughupdates/miscgui/GuiPriceGraph.java
+++ /dev/null
@@ -1,611 +0,0 @@
-/*
- * Copyright (C) 2022 NotEnoughUpdates contributors
- *
- * This file is part of NotEnoughUpdates.
- *
- * NotEnoughUpdates is free software: you can redistribute it
- * and/or modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation, either
- * version 3 of the License, or (at your option) any later version.
- *
- * NotEnoughUpdates is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with NotEnoughUpdates. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package io.github.moulberry.notenoughupdates.miscgui;
-
-import com.google.common.reflect.TypeToken;
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import io.github.moulberry.notenoughupdates.NotEnoughUpdates;
-import io.github.moulberry.notenoughupdates.util.SpecialColour;
-import io.github.moulberry.notenoughupdates.util.Utils;
-import net.minecraft.client.Minecraft;
-import net.minecraft.client.gui.Gui;
-import net.minecraft.client.gui.GuiScreen;
-import net.minecraft.client.renderer.GlStateManager;
-import net.minecraft.item.ItemStack;
-import net.minecraft.util.EnumChatFormatting;
-import net.minecraft.util.ResourceLocation;
-import org.lwjgl.opengl.GL11;
-
-import java.io.BufferedReader;
-import java.io.BufferedWriter;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.io.OutputStreamWriter;
-import java.lang.reflect.Type;
-import java.nio.charset.StandardCharsets;
-import java.text.DecimalFormat;
-import java.text.NumberFormat;
-import java.text.SimpleDateFormat;
-import java.time.Instant;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.TreeMap;
-import java.util.stream.Collectors;
-import java.util.zip.GZIPInputStream;
-import java.util.zip.GZIPOutputStream;
-
-public class GuiPriceGraph extends GuiScreen {
-
- private static final Gson GSON = new GsonBuilder().create();
- private static final SimpleDateFormat format = new SimpleDateFormat("dd-MM-yyyy");
- private final ResourceLocation TEXTURE;
- private static final int X_SIZE = 364;
- private static final int Y_SIZE = 215;
- private ItemData itemData;
- private double highestValue;
- private long firstTime;
- private long lastTime;
- private double lowestValue;
- private String itemName;
- private final String itemId;
- private int guiLeft;
- private int guiTop;
- private ItemStack itemStack = null;
- private boolean loaded = false;
- /**
- * 0 = hour
- * 1 = day
- * 2 = week
- * 3 = all
- * 4 = custom
- **/
- private int mode = NotEnoughUpdates.INSTANCE.config.ahGraph.defaultMode;
- private long customStart = 0;
- private long customEnd = 0;
- private boolean customSelecting = false;
-
- public GuiPriceGraph(String itemId) {
- switch (NotEnoughUpdates.INSTANCE.config.ahGraph.graphStyle) {
- case 1:
- TEXTURE = new ResourceLocation("notenoughupdates:price_graph_gui/price_information_gui_dark.png");
- break;
- case 2:
- TEXTURE = new ResourceLocation("notenoughupdates:price_graph_gui/price_information_gui_phqdark.png");
- break;
- case 3:
- TEXTURE = new ResourceLocation("notenoughupdates:price_graph_gui/price_information_gui_fsr.png");
- break;
- default:
- TEXTURE = new ResourceLocation("notenoughupdates:price_graph_gui/price_information_gui.png");
- break;
- }
- this.itemId = itemId;
- if (NotEnoughUpdates.INSTANCE.manager.getItemInformation().containsKey(itemId)) {
- JsonObject itemInfo = NotEnoughUpdates.INSTANCE.manager.getItemInformation().get(itemId);
- itemName = itemInfo.get("displayname").getAsString();
- itemStack = NotEnoughUpdates.INSTANCE.manager.jsonToStack(itemInfo);
- }
- loadData();
- }
-
- @Override
- public void drawScreen(int mouseX, int mouseY, float partialTicks) {
- guiLeft = (width - X_SIZE) / 2;
- guiTop = (height - Y_SIZE) / 2;
-
- Minecraft.getMinecraft().getTextureManager().bindTexture(TEXTURE);
- GlStateManager.color(1, 1, 1, 1);
- Utils.drawTexturedRect(guiLeft, guiTop, X_SIZE, Y_SIZE,
- 0, X_SIZE / 512f, 0, Y_SIZE / 512f, GL11.GL_NEAREST
- );
- Utils.drawTexturedRect(guiLeft + 245, guiTop + 17, 16, 16,
- 0, 16 / 512f, (mode == 0 ? 215 : 231) / 512f, (mode == 0 ? 231 : 247) / 512f, GL11.GL_NEAREST
- );
- Utils.drawTexturedRect(guiLeft + 263, guiTop + 17, 16, 16,
- 16 / 512f, 32 / 512f, (mode == 1 ? 215 : 231) / 512f, (mode == 1 ? 231 : 247) / 512f, GL11.GL_NEAREST
- );
- Utils.drawTexturedRect(guiLeft + 281, guiTop + 17, 16, 16,
- 32 / 512f, 48 / 512f, (mode == 2 ? 215 : 231) / 512f, (mode == 2 ? 231 : 247) / 512f, GL11.GL_NEAREST
- );
- Utils.drawTexturedRect(guiLeft + 299, guiTop + 17, 16, 16,
- 48 / 512f, 64 / 512f, (mode == 3 ? 215 : 231) / 512f, (mode == 3 ? 231 : 247) / 512f, GL11.GL_NEAREST
- );
-
- if (itemName != null && itemStack != null) {
- Utils.drawItemStack(itemStack, guiLeft + 16, guiTop + 11);
- Utils.drawStringScaledMax(itemName, guiLeft + 35, guiTop + 13, false,
- 0xffffff, 1.77f, 208
- );
- }
-
- if (!loaded)
- Utils.drawStringCentered("Loading...", guiLeft + 166, guiTop + 116, false, 0xffffff00);
- else if (
- itemData == null || itemData.get() == null || itemData.get().size() <= 1)
- Utils.drawStringCentered("No data found.", guiLeft + 166, guiTop + 116, false, 0xffff0000);
- else {
- int graphColor = SpecialColour.specialToChromaRGB(NotEnoughUpdates.INSTANCE.config.ahGraph.graphColor);
- int graphColor2 = SpecialColour.specialToChromaRGB(NotEnoughUpdates.INSTANCE.config.ahGraph.graphColor2);
- Integer lowestDist = null;
- Long lowestDistTime = null;
- HashMap<Integer, Integer> secondLineData = new HashMap<>();
- for (int i = (itemData.isBz() ? 1 : 0); i >= 0; i--) {
- Utils.drawGradientRect(0, guiLeft + 17, guiTop + 35, guiLeft + 315, guiTop + 198,
- changeAlpha(i == 0 ? graphColor : graphColor2, 120), changeAlpha(i == 0 ? graphColor : graphColor2, 10)
- );
- Integer prevX = null;
- Integer prevY = null;
- for (Long time : itemData.get().keySet()) {
- double price = itemData.isBz()
- ? i == 0 ? itemData.bz.get(time).b : itemData.bz.get(time).s
- : itemData.ah.get(time);
- int xPos = (int) map(time, firstTime, lastTime, guiLeft + 17, guiLeft + 315);
- int yPos = (int) map(price, highestValue + 10d, lowestValue - 10d, guiTop + 35, guiTop + 198);
- if (prevX != null) {
- Minecraft.getMinecraft().getTextureManager().bindTexture(TEXTURE);
- GlStateManager.color(1, 1, 1, 1);
- Utils.drawTexturedQuad(
- prevX,
- prevY,
- xPos,
- yPos,
- xPos,
- guiTop + 35,
- prevX,
- guiTop + 35,
- 18 / 512f,
- 19 / 512f,
- 36 / 512f,
- 37 / 512f,
- GL11.GL_NEAREST
- );
- if (i == 0) {
- Utils.drawLine(prevX, prevY + 0.5f, xPos, yPos + 0.5f, 2, graphColor);
- if (itemData.isBz())
- Utils.drawLine(
- prevX,
- secondLineData.get(prevX) + 0.5f,
- xPos,
- secondLineData.get(xPos) + 0.5f,
- 2,
- graphColor2
- );
- }
- }
- if (i == 1)
- secondLineData.put(xPos, yPos);
- if (mouseX >= guiLeft + 17 && mouseX <= guiLeft + 315 && mouseY >= guiTop + 35 && mouseY <= guiTop + 198) {
- int dist = Math.abs(mouseX - xPos);
- if (lowestDist == null || dist < lowestDist) {
- lowestDist = dist;
- lowestDistTime = time;
- }
- }
- prevX = xPos;
- prevY = yPos;
- }
- }
- boolean showDays = lastTime - firstTime > 86400;
- int prevNum = showDays ? Date.from(Instant.ofEpochSecond(firstTime)).getDate() : Date.from(Instant.ofEpochSecond(
- firstTime)).getHours();
- long prevXPos = -100;
- for (long time = firstTime; time <= lastTime; time += showDays ? 3600 : 60) {
- int num = showDays
- ? Date.from(Instant.ofEpochSecond(time)).getDate()
- : Date.from(Instant.ofEpochSecond(time)).getHours();
- if (num != prevNum) {
- int xPos = (int) map(time, firstTime, lastTime, guiLeft + 17, guiLeft + 315);
- if (Math.abs(prevXPos - xPos) > 30) {
- Utils.drawStringCentered(String.valueOf(num), xPos, guiTop + 206, false, 0x8b8b8b);
- prevXPos = xPos;
- }
- prevNum = num;
- }
- }
- for (int i = 0; i <= 6; i++) {
- long price = (long) map(i, 0, 6, highestValue, lowestValue);
- String formattedPrice = formatPrice(price);
- Utils.drawStringF(formattedPrice, guiLeft + 320,
- (float) map(i, 0, 6, guiTop + 35, guiTop + 198)
- - Minecraft.getMinecraft().fontRendererObj.FONT_HEIGHT / 2f, false, 0x8b8b8b
- );
- }
- if (customSelecting) {
- Utils.drawDottedLine(customStart, guiTop + 36, customStart, guiTop + 197, 2, 10, 0xFFc6c6c6);
- Utils.drawDottedLine(customEnd, guiTop + 36, customEnd, guiTop + 197, 2, 10, 0xFFc6c6c6);
- Utils.drawDottedLine(customStart, guiTop + 36, customEnd, guiTop + 36, 2, 10, 0xFFc6c6c6);
- Utils.drawDottedLine(customStart, guiTop + 197, customEnd, guiTop + 197, 2, 10, 0xFFc6c6c6);
- }
- if (lowestDist != null && !customSelecting) {
- double price = itemData.isBz() ? itemData.bz.get(lowestDistTime).b : itemData.ah.get(lowestDistTime);
- Double price2 = itemData.isBz() ? itemData.bz.get(lowestDistTime).s : null;
- int xPos = (int) map(lowestDistTime, firstTime, lastTime, guiLeft + 17, guiLeft + 315);
- int yPos = (int) map(price, highestValue + 10d, lowestValue - 10d, guiTop + 35, guiTop + 198);
- int yPos2 = price2 != null
- ? (int) map(price2, highestValue + 10d, lowestValue - 10d, guiTop + 35, guiTop + 198)
- : 0;
-
- Utils.drawLine(xPos, guiTop + 35, xPos, guiTop + 198, 2, 0x4D8b8b8b);
- Minecraft.getMinecraft().getTextureManager().bindTexture(TEXTURE);
- GlStateManager.color(1, 1, 1, 1);
- Utils.drawTexturedRect(xPos - 2.5f, yPos - 2.5f, 5, 5,
- 0, 5 / 512f, 247 / 512f, 252 / 512f, GL11.GL_NEAREST
- );
- if (price2 != null) {
- Utils.drawTexturedRect(xPos - 2.5f, yPos2 - 2.5f, 5, 5,
- 0, 5 / 512f, 247 / 512f, 252 / 512f, GL11.GL_NEAREST
- );
- }
-
- Date date = Date.from(Instant.ofEpochSecond(lowestDistTime));
- SimpleDateFormat displayFormat = new SimpleDateFormat("'§b'd MMMMM yyyy '§eat§b' HH:mm");
- NumberFormat nf = NumberFormat.getInstance();
- ArrayList<String> text = new ArrayList<>();
- text.add(displayFormat.format(date));
- if (itemData.isBz()) {
- text.add(EnumChatFormatting.YELLOW + "" + EnumChatFormatting.BOLD + "Bazaar Insta-Buy: " +
- EnumChatFormatting.GOLD + EnumChatFormatting.BOLD + nf.format(price));
- text.add(EnumChatFormatting.YELLOW + "" + EnumChatFormatting.BOLD + "Bazaar Insta-Sell: " +
- EnumChatFormatting.GOLD + EnumChatFormatting.BOLD + nf.format(price2));
- } else {
- text.add(EnumChatFormatting.YELLOW + "" + EnumChatFormatting.BOLD + "Lowest BIN: " +
- EnumChatFormatting.GOLD + EnumChatFormatting.BOLD + nf.format(price));
- }
- drawHoveringText(text, xPos, yPos);
- }
- }
-
- if (mouseY >= guiTop + 17 && mouseY <= guiTop + 35 && mouseX >= guiLeft + 244 && mouseX <= guiLeft + 316) {
- int index = (mouseX - guiLeft - 245) / 18;
- switch (index) {
- case 0:
- Gui.drawRect(guiLeft + 245, guiTop + 17, guiLeft + 261, guiTop + 33, 0x80ffffff);
- drawHoveringText(Collections.singletonList("Show 1 Hour"), mouseX, mouseY);
- break;
- case 1:
- Gui.drawRect(guiLeft + 263, guiTop + 17, guiLeft + 279, guiTop + 33, 0x80ffffff);
- drawHoveringText(Collections.singletonList("Show 1 Day"), mouseX, mouseY);
- break;
- case 2:
- Gui.drawRect(guiLeft + 281, guiTop + 17, guiLeft + 297, guiTop + 33, 0x80ffffff);
- drawHoveringText(Collections.singletonList("Show 1 Week"), mouseX, mouseY);
- break;
- case 3:
- Gui.drawRect(guiLeft + 299, guiTop + 17, guiLeft + 315, guiTop + 33, 0x80ffffff);
- drawHoveringText(Collections.singletonList("Show All"), mouseX, mouseY);
- break;
- }
- }
- }
-
- @Override
- protected void mouseClicked(int mouseX, int mouseY, int mouseButton) throws IOException {
- super.mouseClicked(mouseX, mouseY, mouseButton);
- if (mouseY >= guiTop + 17 && mouseY <= guiTop + 35 && mouseX >= guiLeft + 244 && mouseX <= guiLeft + 316) {
- mode = (mouseX - guiLeft - 245) / 18;
- loadData();
- } else if (mouseY >= guiTop + 35 && mouseY <= guiTop + 198 && mouseX >= guiLeft + 17 && mouseX <= guiLeft + 315) {
- customSelecting = true;
- customStart = mouseX;
- customEnd = mouseX;
- }
- }
-
- @Override
- protected void mouseReleased(int mouseX, int mouseY, int state) {
- super.mouseReleased(mouseX, mouseY, state);
- if (customSelecting) {
- customSelecting = false;
- customStart = (int) map(customStart, guiLeft + 17, guiLeft + 315, firstTime, lastTime);
- customEnd = (int) map(mouseX, guiLeft + 17, guiLeft + 315, firstTime, lastTime);
- if (customStart > customEnd) {
- long temp = customStart;
- customStart = customEnd;
- customEnd = temp;
- }
- if (customEnd - customStart != 0) {
- mode = 4;
- loadData();
- }
- }
- }
-
- @Override
- protected void mouseClickMove(int mouseX, int mouseY, int clickedMouseButton, long timeSinceLastClick) {
- super.mouseClickMove(mouseX, mouseY, clickedMouseButton, timeSinceLastClick);
- if (customSelecting) {
- customEnd = mouseX < guiLeft + 18 ? guiLeft + 18 : Math.min(mouseX, guiLeft + 314);
- }
- }
-
- private void loadData() {
- itemData = null;
- loaded = false;
- new Thread(() -> {
- File dir = new File("config/notenoughupdates/prices");
- if (!dir.exists()) {
- loaded = true;
- return;
- }
- File[] files = dir.listFiles();
- ItemData itemData = new ItemData();
- if (files == null) return;
- for (File file : files) {
- if (!file.getName().endsWith(".gz")) continue;
- HashMap<String, ItemData> data2 = load(file);
- if (data2 == null || !data2.containsKey(itemId)) continue;
- if (data2.get(itemId).isBz()) {
- if (itemData.bz == null) itemData.bz = data2.get(itemId).bz;
- else itemData.bz.putAll(data2.get(itemId).bz);
- } else if (itemData.ah == null) itemData.ah = data2.get(itemId).ah;
- else itemData.ah.putAll(data2.get(itemId).ah);
- }
- if (itemData.get() != null && !itemData.get().isEmpty()) {
- if (mode < 3)
- itemData = new ItemData(
- new TreeMap<>(itemData.get().entrySet().stream()
- .filter(e -> e.getKey() > System.currentTimeMillis() / 1000 -
- (mode == 0 ? 3600 : mode == 1 ? 86400 : 604800))
- .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))),
- itemData.isBz()
- );
- else if (mode == 4)
- itemData = new ItemData(
- new TreeMap<>(itemData.get().entrySet().stream()
- .filter(e -> e.getKey() >= customStart && e.getKey() <= customEnd)
- .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))),
- itemData.isBz()
- );
- if (itemData.get() == null || itemData.get().isEmpty()) {
- loaded = true;
- return;
- }
- this.itemData = trimData(itemData);
- firstTime = this.itemData.get().firstKey();
- lastTime = this.itemData.get().lastKey();
- highestValue = this.itemData.isBz() ? this.itemData.bz.get(firstTime).b : this.itemData.ah.get(firstTime);
- lowestValue = this.itemData.isBz() ? this.itemData.bz.get(firstTime).b : this.itemData.ah.get(firstTime);
- for (long key : this.itemData.get().keySet()) {
- double value1 = this.itemData.isBz() ? this.itemData.bz.get(key).b : this.itemData.ah.get(key);
- Double value2 = this.itemData.isBz() ? this.itemData.bz.get(key).s : null;
- if (value1 > highestValue) highestValue = value1;
- if (value2 != null && value2 > highestValue) highestValue = value2;
- if (value1 < lowestValue) lowestValue = value1;
- if (value2 != null && value2 < lowestValue) lowestValue = value2;
- }
- }
- loaded = true;
- }).start();
- }
-
- public static void addToCache(JsonObject items, boolean bazaar) {
- if (!NotEnoughUpdates.INSTANCE.config.ahGraph.graphEnabled) return;
- try {
- File dir = new File("config/notenoughupdates/prices");
- if (!dir.exists() && !dir.mkdir()) return;
- File[] files = dir.listFiles();
- if (files != null)
- for (File file : files) {
- if (!file.getName().endsWith(".gz")) continue;
- if (file.lastModified() <
- System.currentTimeMillis() - NotEnoughUpdates.INSTANCE.config.ahGraph.dataRetention * 86400000L)
- //noinspection ResultOfMethodCallIgnored
- file.delete();
- }
- Date date = new Date();
- Long epochSecond = date.toInstant().getEpochSecond();
- File file = new File(dir, "prices_" + format.format(date) + ".gz");
- HashMap<String, ItemData> prices = new HashMap<>();
- if (file.exists()) {
- HashMap<String, ItemData> tempPrices = load(file);
- if (tempPrices != null) prices = tempPrices;
- }
- for (Map.Entry<String, JsonElement> item : items.entrySet()) {
- addOrUpdateItemPriceInfo(item, prices, epochSecond, bazaar);
- }
- //noinspection ResultOfMethodCallIgnored
- file.createNewFile();
- try (
- BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(
- new GZIPOutputStream(new FileOutputStream(file)),
- StandardCharsets.UTF_8
- ))
- ) {
- writer.write(GSON.toJson(prices));
- }
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
-
- private static void addOrUpdateItemPriceInfo(
- Map.Entry<String, JsonElement> item,
- HashMap<String, ItemData> prices,
- long timestamp,
- boolean bazaar
- ) {
- String itemName = item.getKey();
- ItemData existingItemData = null;
- if (prices.containsKey(itemName)) {
- existingItemData = prices.get(itemName);
- }
-
- // Handle transitions from ah to bz (the other direction typically doesn't happen)
- if (existingItemData != null) {
- if (existingItemData.isBz() && !bazaar) {
- return;
- }
-
- if (!existingItemData.isBz() && bazaar) {
- prices.remove(itemName);
- existingItemData = null;
- }
- }
-
- if (bazaar) {
- if (!item.getValue().getAsJsonObject().has("curr_buy") ||
- !item.getValue().getAsJsonObject().has("curr_sell")
- ) {
- return;
- }
-
- BzData bzData = new BzData(
- item.getValue().getAsJsonObject().get("curr_buy").getAsFloat(),
- item.getValue().getAsJsonObject().get("curr_sell").getAsFloat()
- );
-
- if (existingItemData != null) {
- existingItemData.bz.put(timestamp, bzData);
- } else {
- TreeMap<Long, Object> mapData = new TreeMap<>();
- mapData.put(timestamp, bzData);
- prices.put(item.getKey(), new ItemData(mapData, true));
- }
- } else {
- if (existingItemData != null) {
- prices.get(item.getKey()).ah.put(timestamp, item.getValue().getAsBigDecimal().longValue());
- } else {
- TreeMap<Long, Object> mapData = new TreeMap<>();
- mapData.put(timestamp, item.getValue().getAsLong());
- prices.put(item.getKey(), new ItemData(mapData, false));
- }
- }
- }
-
- private ItemData trimData(ItemData itemData) {
- long first = itemData.get().firstKey();
- long last = itemData.get().lastKey();
- ItemData trimmed = new ItemData();
- if (itemData.isBz())
- trimmed.bz = new TreeMap<>();
- else
- trimmed.ah = new TreeMap<>();
- int zones = NotEnoughUpdates.INSTANCE.config.ahGraph.graphZones;
- Long[] dataArray =
- !itemData.isBz() ? itemData.ah.keySet().toArray(new Long[0]) : itemData.bz.keySet().toArray(new Long[0]);
- int prev = 0;
- for (int i = 0; i < zones; i++) {
- long lowest = (long) map(i, 0, zones, first, last);
- long highest = (long) map(i + 1, 0, zones, first, last);
- int amount = 0;
- double sumBuy = 0;
- double sumSell = 0;
- for (int l = prev; l < dataArray.length; l++) {
- if (dataArray[l] >= lowest && dataArray[l] <= highest) {
- amount++;
- sumBuy += itemData.isBz() ? itemData.bz.get(dataArray[l]).b : itemData.ah.get(dataArray[l]);
- if (itemData.isBz()) sumSell += itemData.bz.get(dataArray[l]).s;
- prev = l + 1;
- } else if (dataArray[l] > highest)
- break;
- }
- if (amount > 0) {
- if (itemData.isBz())
- trimmed.bz.put(lowest, new BzData((float) (sumBuy / amount), (float) (sumSell / amount)));
- else
- trimmed.ah.put(lowest, (long) (sumBuy / amount));
- }
- }
- return trimmed;
- }
-
- private static HashMap<String, ItemData> load(File file) {
- Type type = new TypeToken<HashMap<String, ItemData>>() {
- }.getType();
- if (file.exists()) {
- try (
- BufferedReader reader = new BufferedReader(new InputStreamReader(
- new GZIPInputStream(new FileInputStream(file)),
- StandardCharsets.UTF_8
- ))
- ) {
- return GSON.fromJson(reader, type);
- } catch (Exception e) {
- System.out.println("Deleting " + file.getName() + " because it is probably corrupted.");
- //noinspection ResultOfMethodCallIgnored
- file.delete();
- }
- }
- return null;
- }
-
- private static double map(double x, double in_min, double in_max, double out_min, double out_max) {
- return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;
- }
-
- private static String formatPrice(long price) {
- DecimalFormat df = new DecimalFormat("#.00");
- if (price >= 1000000000) {
- return df.format(price / 1000000000f) + "B";
- } else if (price >= 1000000) {
- return df.format(price / 1000000f) + "M";
- } else if (price >= 1000) {
- return df.format(price / 1000f) + "K";
- }
- return String.valueOf(price);
- }
-
- private int changeAlpha(int origColor, int alpha) {
- origColor = origColor & 0x00ffffff; //drop the previous alpha value
- return (alpha << 24) | origColor; //add the one the user inputted
- }
-}
-
-class ItemData {
- public TreeMap<Long, Long> ah = null;
- public TreeMap<Long, BzData> bz = null;
-
- public ItemData() {
- }
-
- public ItemData(TreeMap<Long, ?> map, boolean bz) {
- if (bz)
- this.bz = (TreeMap<Long, BzData>) map;
- else
- this.ah = (TreeMap<Long, Long>) map;
- }
-
- public TreeMap<Long, ?> get() {
- return !isBz() ? ah : bz;
- }
-
- public boolean isBz() {
- return bz != null && !bz.isEmpty();
- }
-}
-
-class BzData {
- double b;
- double s;
-
- public BzData(double b, double s) {
- this.b = b;
- this.s = s;
- }
-}
diff --git a/src/main/java/io/github/moulberry/notenoughupdates/options/NEUConfig.java b/src/main/java/io/github/moulberry/notenoughupdates/options/NEUConfig.java
index 53906cd8..49053316 100644
--- a/src/main/java/io/github/moulberry/notenoughupdates/options/NEUConfig.java
+++ b/src/main/java/io/github/moulberry/notenoughupdates/options/NEUConfig.java
@@ -369,7 +369,7 @@ public class NEUConfig extends Config {
@Expose
@Category(
- name = "AH/BZ Graph",
+ name = "Price Graph",
desc = "Graph of auction and bazaar prices"
)
public AHGraph ahGraph = new AHGraph();
diff --git a/src/main/java/io/github/moulberry/notenoughupdates/options/seperateSections/AHGraph.java b/src/main/java/io/github/moulberry/notenoughupdates/options/seperateSections/AHGraph.java
index 58c58e3c..ff5bdf62 100644
--- a/src/main/java/io/github/moulberry/notenoughupdates/options/seperateSections/AHGraph.java
+++ b/src/main/java/io/github/moulberry/notenoughupdates/options/seperateSections/AHGraph.java
@@ -25,6 +25,7 @@ import io.github.moulberry.notenoughupdates.core.config.annotations.ConfigEditor
import io.github.moulberry.notenoughupdates.core.config.annotations.ConfigEditorDropdown;
import io.github.moulberry.notenoughupdates.core.config.annotations.ConfigEditorKeybind;
import io.github.moulberry.notenoughupdates.core.config.annotations.ConfigEditorSlider;
+import io.github.moulberry.notenoughupdates.core.config.annotations.ConfigEditorText;
import io.github.moulberry.notenoughupdates.core.config.annotations.ConfigOption;
import org.lwjgl.input.Keyboard;
@@ -32,7 +33,7 @@ public class AHGraph {
@Expose
@ConfigOption(
name = "Enable AH/BZ Price Graph",
- desc = "Enable or disable the graph. Disabling this will also make it so that no price data is stored.",
+ desc = "Enable or disable the graph.",
searchTags = {"auction", "bazaar"}
)
@ConfigEditorBoolean
@@ -58,6 +59,16 @@ public class AHGraph {
@Expose
@ConfigOption(
+ name = "Default Time",
+ desc = "Change the default time period for the graph."
+ )
+ @ConfigEditorDropdown(
+ values = {"1 Hour", "1 Day", "1 Week", "All Time"}
+ )
+ public int defaultMode = 1;
+
+ @Expose
+ @ConfigOption(
name = "Graph Colour",
desc = "Set a custom colour for the graph.",
searchTags = "color"
@@ -76,13 +87,60 @@ public class AHGraph {
@Expose
@ConfigOption(
- name = "Default Time",
- desc = "Change the default time period for the graph."
+ name = "Moving Average",
+ desc = "Whether the graph should have a moving average line or not."
+ )
+ @ConfigEditorBoolean
+ public boolean movingAverages = false;
+ // Disabled by default because it looks weird to people who don't know what it is
+
+ @Expose
+ @ConfigOption(
+ name = "Moving Average Size (%)",
+ desc = "The percent of the time displayed that should be averaged."
+ )
+ @ConfigEditorSlider(
+ minValue = 0.05f,
+ maxValue = 0.5f,
+ minStep = 0.05f
+ )
+ public double movingAveragePercent = 0.2;
+
+ @Expose
+ @ConfigOption(
+ name = "Moving Average Colour",
+ desc = "Set a custom colour for the graph's moving average line.",
+ searchTags = "color"
+ )
+ @ConfigEditorColour
+ public String movingAverageColor = "0:255:0:255:171";
+
+ @Expose
+ @ConfigOption(
+ name = "Secondary Moving Average Colour",
+ desc = "Set a custom colour for the second graph's secondary moving average line line.",
+ searchTags = "color"
+ )
+ @ConfigEditorColour
+ public String movingAverageColor2 = "0:255:255:109:0";
+
+ @Expose
+ @ConfigOption(
+ name = "Data Source",
+ desc = "Where NEU should get the data for the graph.\nPrices are only stored locally if this is set to 'Local'."
)
@ConfigEditorDropdown(
- values = {"1 Hour", "1 Day", "1 Week", "All Time"}
+ values = {"Server", "Local"}
)
- public int defaultMode = 1;
+ public int dataSource = 0;
+
+ @Expose
+ @ConfigOption(
+ name = "Price History API",
+ desc = "§4Do §lNOT §r§4change this, unless you know exactly what you are doing\n§fDefault: §apricehistory.notenoughupdates.org"
+ )
+ @ConfigEditorText
+ public String serverUrl = "pricehistory.notenoughupdates.org";
@Expose
@ConfigOption(
diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/commands/dev/DevTestCommand.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/commands/dev/DevTestCommand.kt
index a5ff48d2..93c4ab90 100644
--- a/src/main/kotlin/io/github/moulberry/notenoughupdates/commands/dev/DevTestCommand.kt
+++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/commands/dev/DevTestCommand.kt
@@ -29,8 +29,8 @@ import io.github.moulberry.notenoughupdates.events.RegisterBrigadierCommandEvent
import io.github.moulberry.notenoughupdates.miscfeatures.FishingHelper
import io.github.moulberry.notenoughupdates.miscfeatures.customblockzones.CustomBiomes
import io.github.moulberry.notenoughupdates.miscfeatures.customblockzones.LocationChangeEvent
-import io.github.moulberry.notenoughupdates.miscgui.GuiPriceGraph
import io.github.moulberry.notenoughupdates.miscgui.minionhelper.MinionHelperManager
+import io.github.moulberry.notenoughupdates.miscgui.pricegraph.GuiPriceGraph
import io.github.moulberry.notenoughupdates.util.PronounDB
import io.github.moulberry.notenoughupdates.util.SBInfo
import io.github.moulberry.notenoughupdates.util.TabListUtils
@@ -131,7 +131,8 @@ class DevTestCommand {
thenLiteral("pricetest") {
thenArgument("item", StringArgumentType.string()) { item ->
thenExecute {
- NotEnoughUpdates.INSTANCE.openGui = GuiPriceGraph(this[item])
+ NotEnoughUpdates.INSTANCE.openGui =
+ GuiPriceGraph(this[item])
}
}.withHelp("Display the price graph for an item by id")
thenExecute {
diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/GraphDataProvider.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/GraphDataProvider.kt
new file mode 100644
index 00000000..575c34b9
--- /dev/null
+++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/GraphDataProvider.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2023 NotEnoughUpdates contributors
+ *
+ * This file is part of NotEnoughUpdates.
+ *
+ * NotEnoughUpdates is free software: you can redistribute it
+ * and/or modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation, either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * NotEnoughUpdates is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with NotEnoughUpdates. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.moulberry.notenoughupdates.miscgui.pricegraph
+
+import java.time.Instant
+import java.util.concurrent.CompletableFuture
+
+interface GraphDataProvider {
+ fun loadData(itemId: String,): CompletableFuture<Map<Instant, PriceObject>?>
+}
diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/GuiPriceGraph.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/GuiPriceGraph.kt
new file mode 100644
index 00000000..0fe6d843
--- /dev/null
+++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/GuiPriceGraph.kt
@@ -0,0 +1,596 @@
+/*
+ * Copyright (C) 2023 NotEnoughUpdates contributors
+ *
+ * This file is part of NotEnoughUpdates.
+ *
+ * NotEnoughUpdates is free software: you can redistribute it
+ * and/or modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation, either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * NotEnoughUpdates is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with NotEnoughUpdates. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.moulberry.notenoughupdates.miscgui.pricegraph
+
+import io.github.moulberry.notenoughupdates.NotEnoughUpdates
+import io.github.moulberry.notenoughupdates.util.SpecialColour
+import io.github.moulberry.notenoughupdates.util.Utils
+import io.github.moulberry.notenoughupdates.util.roundToDecimals
+import net.minecraft.client.Minecraft
+import net.minecraft.client.gui.GuiScreen
+import net.minecraft.client.renderer.GlStateManager
+import net.minecraft.item.ItemStack
+import net.minecraft.util.EnumChatFormatting
+import net.minecraft.util.ResourceLocation
+import org.lwjgl.opengl.GL11
+import java.text.DecimalFormat
+import java.text.NumberFormat
+import java.text.SimpleDateFormat
+import java.time.Duration
+import java.time.Instant
+import java.util.*
+import java.util.concurrent.CompletableFuture
+import kotlin.math.abs
+
+private const val X_SIZE = 364
+private const val Y_SIZE = 215
+private val dateFormat = SimpleDateFormat("'§b'd MMMMM yyyy '§eat§b' HH:mm")
+private val numberFormat = NumberFormat.getInstance()
+private val config = NotEnoughUpdates.INSTANCE.config
+
+class GuiPriceGraph(itemId: String) : GuiScreen() {
+ private val TEXTURE: ResourceLocation = when (config.ahGraph.graphStyle) {
+ 1 -> ResourceLocation("notenoughupdates:price_graph_gui/price_information_gui_dark.png")
+ 2 -> ResourceLocation("notenoughupdates:price_graph_gui/price_information_gui_phqdark.png")
+ 3 -> ResourceLocation("notenoughupdates:price_graph_gui/price_information_gui_fsr.png")
+ else -> ResourceLocation("notenoughupdates:price_graph_gui/price_information_gui.png")
+ }
+ private val dataProvider: GraphDataProvider = when (config.ahGraph.dataSource) {
+ 0 -> ServerGraphDataProvider
+ else -> LocalGraphDataProvider
+ }
+ private val rawData: CompletableFuture<Map<Instant, PriceObject>?> = dataProvider.loadData(itemId)
+ private var data: Map<Instant, PriceObject> = mapOf()
+ private var processedData = false
+ private var hasSellData = false
+ private var firstTime: Instant = Instant.now()
+ private var lastTime: Instant = Instant.now()
+ private var lowestPrice: Double = 0.0
+ private var highestPrice: Double = 1.0
+ private var guiLeft = 0
+ private var guiTop = 0
+
+ /**
+ * 0 = hour
+ * 1 = day
+ * 2 = week
+ * 3 = all
+ * 4 = custom
+ */
+ private var mode = config.ahGraph.defaultMode
+
+ private var itemName: String? = null
+ private var itemStack: ItemStack? = null
+
+ private var customSelecting = false
+ private var customSelectionStart = 0
+ private var customSelectionEnd = 0
+
+ private val buyPoints = mutableMapOf<Double, Pair<Double, Double>>()
+ private val sellPoints = mutableMapOf<Double, Pair<Double, Double>>()
+
+ private val buyMovingAverage = mutableMapOf<Double, Double>()
+ private val buyMovingAveragePoints = mutableMapOf<Double, Pair<Double, Double>>()
+ private val sellMovingAverage = mutableMapOf<Double, Double>()
+ private val sellMovingAveragePoints = mutableMapOf<Double, Pair<Double, Double>>()
+
+ init {
+ if (NotEnoughUpdates.INSTANCE.manager.itemInformation.containsKey(itemId)) {
+ val itemInfo = NotEnoughUpdates.INSTANCE.manager.itemInformation[itemId]
+ itemName = itemInfo!!["displayname"].asString
+ itemStack = NotEnoughUpdates.INSTANCE.manager.jsonToStack(itemInfo)
+ }
+ }
+
+ override fun drawScreen(mouseX: Int, mouseY: Int, partialTicks: Float) {
+ drawDefaultBackground()
+
+ guiLeft = (width - X_SIZE) / 2
+ guiTop = (height - Y_SIZE) / 2
+
+ if (customSelecting) customSelectionEnd = // Update custom selecting box
+ if (mouseX < guiLeft + 17) guiLeft + 17 else mouseX.coerceAtMost(guiLeft + 315)
+
+ Minecraft.getMinecraft().textureManager.bindTexture(TEXTURE)
+ GlStateManager.color(1f, 1f, 1f, 1f)
+ Utils.drawTexturedRect( // Draw main background
+ guiLeft.toFloat(), guiTop.toFloat(), X_SIZE.toFloat(), Y_SIZE.toFloat(),
+ 0f, X_SIZE / 512f, 0f, Y_SIZE / 512f, GL11.GL_NEAREST
+ )
+ for (i in 0..3) Utils.drawTexturedRect( // Draw buttons
+ (guiLeft + 245 + 18 * i).toFloat(), (guiTop + 17).toFloat(), 16f, 16f,
+ (0f + 16f * i) / 512f, (16 + 16f * i) / 512f,
+ (if (mode == i) 215 else 231) / 512f,
+ (if (mode == i) 231 else 247) / 512f, GL11.GL_NEAREST
+ )
+
+ if (itemName != null && itemStack != null) { // Draw item name and icon
+ Utils.drawItemStack(itemStack, guiLeft + 16, guiTop + 11)
+ Utils.drawStringScaledMax(
+ itemName, (guiLeft + 35).toFloat(), (guiTop + 13).toFloat(), false,
+ 0xffffff, 1.77f, 208
+ )
+ }
+
+ if (!rawData.isDone) {
+ Utils.drawStringCentered( // Loading text
+ "Loading...",
+ (guiLeft + 166).toFloat(), (guiTop + 116).toFloat(),
+ false, -0x100
+ )
+ } else if (rawData.get() == null || rawData.get()!!.size < 2 || data.isEmpty() && processedData) {
+ Utils.drawStringCentered( // Error text
+ "No data found.",
+ (guiLeft + 166).toFloat(),
+ (guiTop + 116).toFloat(),
+ false,
+ -0x10000
+ )
+ } else if (data.isEmpty()) {
+ processData() // Process the data if needed, done here so no race conditions of any kind can occur
+ } else {
+ val buyColor = SpecialColour.specialToChromaRGB(config.ahGraph.graphColor)
+ val sellColor = SpecialColour.specialToChromaRGB(config.ahGraph.graphColor2)
+ val buyMovingAverageColor = SpecialColour.specialToChromaRGB(config.ahGraph.movingAverageColor)
+ val sellMovingAverageColor = SpecialColour.specialToChromaRGB(config.ahGraph.movingAverageColor2)
+ var prevX: Double? = null
+ var prevY: Double? = null
+
+ if (hasSellData) { // Draw sell gradient
+ drawGradient(sellColor)
+ for (point in data) {
+ if (point.value.sellPrice == null) continue
+ val x = getX(point.key)
+ val y = getY(point.value.sellPrice!!)
+
+ if (prevX != null && prevY != null) drawCoveringQuad(x, y, prevX, prevY)
+
+ prevX = x
+ prevY = y
+ }
+ }
+
+ prevX = null
+ prevY = null
+ var closestPoint = data.entries.first()
+ var closestDistance = Double.MAX_VALUE
+
+ drawGradient(buyColor) // Draw buy gradient
+ for (point in data) {
+ val x = getX(point.key)
+ val y = getY(point.value.buyPrice)
+
+ if (prevX != null && prevY != null) drawCoveringQuad(x, y, prevX, prevY)
+
+ val distance = abs(mouseX - x) // Find the closest point to show tooltip
+ if (closestDistance > distance) {
+ closestPoint = point
+ closestDistance = distance
+ }
+
+ prevX = x
+ prevY = y
+ }
+ prevX = null
+
+ // Draw lines last to make sure nothing cuts into it
+ for (point in data) {
+ val x = getX(point.key)
+ if (prevX != null) {
+ if (config.ahGraph.movingAverages) drawLine(
+ x, prevX,
+ buyMovingAveragePoints[x], sellMovingAveragePoints[x],
+ buyMovingAverageColor, sellMovingAverageColor
+ )
+ drawLine(x, prevX, buyPoints[x], sellPoints[x], buyColor, sellColor)
+ }
+ prevX = x
+ }
+
+ // Draw axis
+ for (i in 0..6) { // Y-axis with price
+ val price = map(i.toDouble(), 0.0, 6.0, highestPrice, lowestPrice).toLong()
+ Utils.drawStringF(
+ formatPrice(price), guiLeft + 320f,
+ map(i.toDouble(), 0.0, 6.0, guiTop + 35.0, guiTop + 198.0).toFloat()
+ - Minecraft.getMinecraft().fontRendererObj.FONT_HEIGHT / 2f, false, 0x8b8b8b
+ )
+ }
+ // X-axis with hour or date
+ val showDays = lastTime.epochSecond - firstTime.epochSecond > 86400
+ val amountOfTime = (lastTime.epochSecond - firstTime.epochSecond) / (if (showDays) 86400.0 else 3600.0)
+ val pixelsPerTime = 298.0 / amountOfTime
+ var time = firstTime.plusSeconds(
+ if (showDays) (24 - Date.from(firstTime).hours) * 3600L
+ else (60 - Date.from(firstTime).minutes) * 60L
+ )
+ var xPos = getX(time)
+ var lastX = -100.0
+ while (xPos < guiLeft + 315) {
+ if (abs(xPos - lastX) > 30) {
+ Utils.drawStringCentered(
+ Date.from(time).let { if (showDays) it.date else it.hours }.toString(),
+ xPos.toFloat(), (guiTop + 206).toFloat(),
+ false, 0x8b8b8b
+ )
+ lastX = xPos
+ }
+ time = time.plusSeconds(if (showDays) 86400L else 3600L)
+ xPos += pixelsPerTime
+ }
+
+ if (
+ mouseX >= guiLeft + 17 && mouseX <= guiLeft + 315 &&
+ mouseY >= guiTop + 35 && mouseY <= guiTop + 198 && !customSelecting
+ ) {
+ // Draw tooltip with price info
+ val text = ArrayList<String>()
+ val x = getX(closestPoint.key)
+ val y = getY(closestPoint.value.buyPrice)
+ text.add(dateFormat.format(Date.from(closestPoint.key)))
+ if (closestPoint.value.sellPrice == null) {
+ text.add(
+ "${EnumChatFormatting.YELLOW}${EnumChatFormatting.BOLD}Lowest BIN: ${EnumChatFormatting.GOLD}" +
+ "${EnumChatFormatting.BOLD}${numberFormat.format(closestPoint.value.buyPrice)}"
+ )
+ if (config.ahGraph.movingAverages && buyMovingAverage[x] != null) text.add(
+ "${EnumChatFormatting.YELLOW}${EnumChatFormatting.BOLD}Lowest BIN Moving Average: ${EnumChatFormatting.GOLD}" +
+ "${EnumChatFormatting.BOLD}${numberFormat.format(buyMovingAverage[x])}"
+ )
+ } else {
+ text.add(
+ "${EnumChatFormatting.YELLOW}${EnumChatFormatting.BOLD}Bazaar Insta-Buy: ${EnumChatFormatting.GOLD}" +
+ "${EnumChatFormatting.BOLD}${numberFormat.format(closestPoint.value.buyPrice)}"
+ )
+ text.add(
+ "${EnumChatFormatting.YELLOW}${EnumChatFormatting.BOLD}Bazaar Insta-Sell: ${EnumChatFormatting.GOLD}" +
+ "${EnumChatFormatting.BOLD}${numberFormat.format(closestPoint.value.sellPrice)}"
+ )
+ if (config.ahGraph.movingAverages) {
+ if (buyMovingAverage[x] != null) text.add(
+ "${EnumChatFormatting.YELLOW}${EnumChatFormatting.BOLD}Bazaar Insta-Buy Moving Average: ${EnumChatFormatting.GOLD}${EnumChatFormatting.BOLD}" +
+ numberFormat.format(buyMovingAverage[x])
+ )
+ if (sellMovingAverage[x] != null) text.add(
+ "${EnumChatFormatting.YELLOW}${EnumChatFormatting.BOLD}Bazaar Insta-Sell Moving Average: ${EnumChatFormatting.GOLD}${EnumChatFormatting.BOLD}" +
+ numberFormat.format(sellMovingAverage[x])
+ )
+ }
+ }
+ Utils.drawLine(
+ x.toFloat(), (guiTop + 35).toFloat(),
+ x.toFloat(), (guiTop + 198).toFloat(),
+ 2, 0x4D8b8b8b
+ )
+ Minecraft.getMinecraft().textureManager.bindTexture(TEXTURE)
+ GlStateManager.color(1f, 1f, 1f, 1f)
+ Utils.drawTexturedRect(
+ x.toFloat() - 2.5f, y.toFloat() - 2.5f, 5f, 5f,
+ 0f, 5 / 512f, 247 / 512f, 252 / 512f, GL11.GL_NEAREST
+ )
+ if (closestPoint.value.sellPrice != null) Utils.drawTexturedRect(
+ x.toFloat() - 2.5f, getY(closestPoint.value.sellPrice!!).toFloat() - 2.5f, 5f, 5f,
+ 0f, 5 / 512f, 247 / 512f, 252 / 512f, GL11.GL_NEAREST
+ )
+ if (config.ahGraph.movingAverages) {
+ val buyAverageY = buyMovingAveragePoints[x]?.second
+ if (buyAverageY != null) Utils.drawTexturedRect(
+ x.toFloat() - 2.5f, buyAverageY.toFloat() - 2.5f, 5f, 5f,
+ 0f, 5 / 512f, 247 / 512f, 252 / 512f, GL11.GL_NEAREST
+ )
+ val sellAverageY = sellMovingAveragePoints[x]?.second
+ if (sellAverageY != null) Utils.drawTexturedRect(
+ x.toFloat() - 2.5f, sellAverageY.toFloat() - 2.5f, 5f, 5f,
+ 0f, 5 / 512f, 247 / 512f, 252 / 512f, GL11.GL_NEAREST
+ )
+ }
+
+ drawHoveringText(text, x.toInt(), y.toInt())
+ }
+
+ if (customSelecting) { // Draw selecting box
+ Utils.drawDottedLine(
+ customSelectionStart.toFloat(), guiTop + 36f,
+ customSelectionStart.toFloat(), guiTop + 197f,
+ 2, 10, -0x39393a
+ )
+ Utils.drawDottedLine(
+ customSelectionEnd.toFloat(), guiTop + 36f,
+ customSelectionEnd.toFloat(), guiTop + 197f,
+ 2, 10, -0x39393a
+ )
+ Utils.drawDottedLine(
+ customSelectionStart.toFloat(), guiTop + 36f,
+ customSelectionEnd.toFloat(), guiTop + 36f,
+ 2, 10, -0x39393a
+ )
+ Utils.drawDottedLine(
+ customSelectionStart.toFloat(), guiTop + 197f,
+ customSelectionEnd.toFloat(), guiTop + 197f,
+ 2, 10, -0x39393a
+ )
+ }
+ }
+
+ // Draw item tooltips
+ if (mouseY >= guiTop + 17 && mouseY <= guiTop + 35 && mouseX >= guiLeft + 244 && mouseX <= guiLeft + 316) {
+ val index = (mouseX - guiLeft - 245) / 18
+ drawRect(
+ guiLeft + 245 + 18 * index,
+ guiTop + 17, guiLeft + 261 + 18 * index,
+ guiTop + 33, -0x7f000001
+ )
+ drawHoveringText(
+ listOf(
+ when (index) {
+ 0 -> "Show 1 Hour"
+ 1 -> "Show 1 Day"
+ 2 -> "Show 1 Week"
+ else -> if (dataProvider is LocalGraphDataProvider) "Show All" else "Show 1 Month"
+ }
+ ), mouseX, mouseY
+ )
+ }
+ }
+
+ private fun getX(time: Instant) = map(
+ time.epochSecond.toDouble(),
+ firstTime.epochSecond.toDouble(),
+ lastTime.epochSecond.toDouble(),
+ guiLeft + 17.0,
+ guiLeft + 315.0
+ )
+
+ private fun getY(price: Double) = map(
+ price,
+ highestPrice + 1,
+ lowestPrice - 1,
+ guiTop + 45.0,
+ guiTop + 188.0
+ )
+
+ override fun mouseClicked(mouseX: Int, mouseY: Int, mouseButton: Int) {
+ super.mouseClicked(mouseX, mouseY, mouseButton)
+ if (mouseY >= guiTop + 17 && mouseY <= guiTop + 35 && mouseX >= guiLeft + 244 && mouseX <= guiLeft + 316) {
+ selectMode((mouseX - guiLeft - 245) / 18)
+ Utils.playPressSound()
+ } else if (mouseY >= guiTop + 35 && mouseY <= guiTop + 198 && mouseX >= guiLeft + 17 && mouseX <= guiLeft + 315) {
+ customSelecting = true
+ customSelectionStart = mouseX
+ customSelectionEnd = mouseX
+ }
+ }
+
+ override fun mouseReleased(mouseX: Int, mouseY: Int, state: Int) {
+ super.mouseReleased(mouseX, mouseY, state)
+ if (customSelecting) {
+ customSelecting = false
+ customSelectionEnd =
+ if (mouseX < guiLeft + 17) guiLeft + 17 else mouseX.coerceAtMost(guiLeft + 315)
+ if (customSelectionStart > customSelectionEnd) {
+ val temp = customSelectionStart
+ customSelectionStart = customSelectionEnd
+ customSelectionEnd = temp
+ }
+ if (customSelectionStart - customSelectionEnd == 0) return
+ selectMode(4)
+ }
+ }
+
+ private fun processData() {
+ processedData = true
+ // Filter based on time
+ val now = Instant.now()
+ val startTime = when (mode) {
+ 0 -> now.minus(Duration.ofHours(1))
+ 1 -> now.minus(Duration.ofDays(1))
+ 2 -> now.minus(Duration.ofDays(7))
+ // The server only deletes old data every hour, so you could get 30 days and 1 hour, this is just for consistency
+ 3 -> if (dataProvider is ServerGraphDataProvider) now.minus(Duration.ofDays(30)) else null
+ 4 -> Instant.ofEpochSecond(
+ map(
+ customSelectionStart.toDouble(),
+ guiLeft + 17.0,
+ guiLeft + 315.0,
+ firstTime.epochSecond.toDouble(),
+ lastTime.epochSecond.toDouble()
+ ).toLong()
+ )
+
+ else -> error("$mode is not a valid mode!")
+ }
+ val endTime = when (mode) {
+ 4 -> Instant.ofEpochSecond(
+ map(
+ customSelectionEnd.toDouble(),
+ guiLeft + 17.0,
+ guiLeft + 315.0,
+ firstTime.epochSecond.toDouble(),
+ lastTime.epochSecond.toDouble()
+ ).toLong()
+ )
+
+ else -> null
+ }
+ val cutData = rawData.get()!!.filter {
+ (startTime == null || it.key >= startTime) && (endTime == null || it.key <= endTime)
+ }
+ if (cutData.isEmpty()) return
+
+ // Smooth data
+ val zones = config.ahGraph.graphZones
+ val first = cutData.minOf { it.key }
+ val last = cutData.maxOf { it.key }
+ val trimmedData = mutableMapOf<Instant, PriceObject>()
+ for (i in 0..zones) {
+ val zoneStart = Instant.ofEpochSecond(
+ map(
+ i.toDouble(), 0.0, zones.toDouble(),
+ first.epochSecond.toDouble(), last.epochSecond.toDouble()
+ ).toLong()
+ )
+ val zoneEnd = Instant.ofEpochSecond(
+ map(
+ i + 1.0, 0.0, zones.toDouble(),
+ first.epochSecond.toDouble(), last.epochSecond.toDouble()
+ ).toLong()
+ )
+ val dataInZone = cutData.filter { it.key >= zoneStart && it.key < zoneEnd }
+ if (dataInZone.isEmpty()) {
+ continue
+ } else {
+ val averageTime = Instant.ofEpochSecond(dataInZone.keys.sumOf { it.epochSecond } / dataInZone.size)
+ val averageBuyPrice = (dataInZone.values.sumOf { it.buyPrice } / dataInZone.size).roundToDecimals(1)
+ val sellPoints = dataInZone.values.filter { it.sellPrice != null }
+ val averageSellPrice = if (sellPoints.isEmpty()) null
+ else (sellPoints.sumOf { it.sellPrice ?: 0.0 } / sellPoints.size).roundToDecimals(1)
+ trimmedData[averageTime] = PriceObject(averageBuyPrice, averageSellPrice)
+ }
+ }
+ data = trimmedData
+ if (data.isEmpty()) return
+
+ // Populate variables required for graphs
+ firstTime = data.minOf { it.key }
+ lastTime = data.maxOf { it.key }
+ lowestPrice = data.minOf {
+ if (it.value.sellPrice != null) it.value.buyPrice.coerceAtMost(it.value.sellPrice!!)
+ else it.value.buyPrice
+ }
+ highestPrice = data.maxOf {
+ if (it.value.sellPrice != null) it.value.buyPrice.coerceAtLeast(it.value.sellPrice!!)
+ else it.value.buyPrice
+ }
+
+ // Populate line variables
+ buyPoints.clear()
+ sellPoints.clear()
+ buyMovingAveragePoints.clear()
+ sellMovingAveragePoints.clear()
+ val movingAveragePeriod = (lastTime.epochSecond - firstTime.epochSecond) * config.ahGraph.movingAveragePercent
+ var prevBuyY: Double? = null
+ var prevSellY: Double? = null
+ var prevBuyAverageY: Double? = null
+ var prevSellAverageY: Double? = null
+ for (point in data) {
+ val x = getX(point.key)
+ val buyY = getY(point.value.buyPrice)
+ if (prevBuyY != null) buyPoints[x] = prevBuyY to buyY
+ prevBuyY = buyY
+ if (point.value.sellPrice != null) {
+ val sellY = getY(point.value.sellPrice!!)
+ if (prevSellY != null) sellPoints[x] = prevSellY to sellY
+ prevSellY = sellY
+ }
+ // Moving average stuff
+ if (!config.ahGraph.movingAverages) continue
+ val dataInPeriod = rawData.get()?.filterKeys {
+ it >= point.key.minusSeconds(movingAveragePeriod.toLong()) && it <= point.key
+ }
+ if (dataInPeriod.isNullOrEmpty()) continue
+ val buyAverage = dataInPeriod.values.sumOf { it.buyPrice } / dataInPeriod.size
+ buyMovingAverage[x] = buyAverage.roundToDecimals(1)
+ val buyAverageY = getY((buyAverage).coerceAtLeast(lowestPrice).coerceAtMost(highestPrice))
+ if (prevBuyAverageY != null) buyMovingAveragePoints[x] = prevBuyAverageY to buyAverageY
+ prevBuyAverageY = buyAverageY
+ val sellData = dataInPeriod.filterValues { it.sellPrice != null }
+ if (sellData.isEmpty()) continue
+ val sellAverage = sellData.values.sumOf { it.sellPrice!! } / sellData.size
+ sellMovingAverage[x] = sellAverage.roundToDecimals(1)
+ val sellAverageY = getY((sellAverage).coerceAtLeast(lowestPrice).coerceAtMost(highestPrice))
+ if (prevSellAverageY != null) sellMovingAveragePoints[x] = prevSellAverageY to sellAverageY
+ prevSellAverageY = sellAverageY
+ }
+ hasSellData = sellPoints.isNotEmpty()
+ }
+
+ private fun selectMode(mode: Int) {
+ this.mode = mode
+ data = mapOf()
+ processedData = false
+ }
+
+ private fun drawGradient(color: Int) {
+ Utils.drawGradientRect(
+ 0,
+ guiLeft + 17,
+ guiTop + 35,
+ guiLeft + 315,
+ guiTop + 198,
+ changeAlpha(color, 120),
+ changeAlpha(color, 10)
+ )
+ }
+
+ private fun drawCoveringQuad(x: Double, y: Double, prevX: Double, prevY: Double) {
+ Minecraft.getMinecraft().textureManager.bindTexture(TEXTURE)
+ GlStateManager.color(1f, 1f, 1f, 1f)
+ Utils.drawTexturedQuad(
+ prevX.toFloat(), prevY.toFloat(),
+ x.toFloat(), y.toFloat(),
+ x.toFloat(), guiTop + 35f,
+ prevX.toFloat(), guiTop + 35f,
+ 18 / 512f, 19 / 512f,
+ 36 / 512f, 37 / 512f,
+ GL11.GL_NEAREST
+ )
+ }
+
+ private fun drawLine(
+ x: Double,
+ prevX: Double,
+ buyLine: Pair<Double, Double>?,
+ sellLine: Pair<Double, Double>?,
+ buyColor: Int,
+ sellColor: Int
+ ) {
+ if (buyLine != null) Utils.drawLine(
+ prevX.toFloat(), buyLine.first.toFloat() + 0.5f,
+ x.toFloat(), buyLine.second.toFloat() + 0.5f,
+ 2, buyColor
+ )
+ if (sellLine != null) Utils.drawLine(
+ prevX.toFloat(), sellLine.first.toFloat() + 0.5f,
+ x.toFloat(), sellLine.second.toFloat() + 0.5f,
+ 2, sellColor
+ )
+ }
+
+ private fun formatPrice(price: Long): String {
+ val df = DecimalFormat("#.00")
+ if (price >= 1000000000) {
+ return df.format((price / 1000000000f).toDouble()) + "B"
+ } else if (price >= 1000000) {
+ return df.format((price / 1000000f).toDouble()) + "M"
+ } else if (price >= 1000) {
+ return df.format((price / 1000f).toDouble()) + "K"
+ }
+ return price.toString()
+ }
+
+ private fun changeAlpha(origColor: Int, alpha: Int): Int {
+ val color = origColor and 0x00ffffff //drop the previous alpha value
+ return alpha shl 24 or color //add the one the user inputted
+ }
+
+ private fun map(x: Double, inMin: Double, inMax: Double, outMin: Double, outMax: Double): Double {
+ return (x - inMin) * (outMax - outMin) / (inMax - inMin) + outMin
+ }
+}
diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/LocalGraphDataProvider.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/LocalGraphDataProvider.kt
new file mode 100644
index 00000000..be93a8d6
--- /dev/null
+++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/LocalGraphDataProvider.kt
@@ -0,0 +1,192 @@
+/*
+ * Copyright (C) 2023 NotEnoughUpdates contributors
+ *
+ * This file is part of NotEnoughUpdates.
+ *
+ * NotEnoughUpdates is free software: you can redistribute it
+ * and/or modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation, either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * NotEnoughUpdates is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with NotEnoughUpdates. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.moulberry.notenoughupdates.miscgui.pricegraph
+
+import com.google.common.reflect.TypeToken
+import com.google.gson.GsonBuilder
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import io.github.moulberry.notenoughupdates.NotEnoughUpdates
+import java.io.*
+import java.nio.charset.StandardCharsets
+import java.text.SimpleDateFormat
+import java.time.Instant
+import java.util.*
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.zip.GZIPInputStream
+import java.util.zip.GZIPOutputStream
+
+object LocalGraphDataProvider : GraphDataProvider {
+ private val priceDir = File("config/notenoughupdates/prices")
+ private val GSON = GsonBuilder().create()
+ private val format = SimpleDateFormat("dd-MM-yyyy")
+ private val config = NotEnoughUpdates.INSTANCE.config
+ private val fileLocked = AtomicBoolean(false)
+
+ override fun loadData(itemId: String): CompletableFuture<Map<Instant, PriceObject>?> {
+ return CompletableFuture.supplyAsync {
+ if (!priceDir.exists() || !priceDir.isDirectory) return@supplyAsync null
+ if (fileLocked.get()) while (fileLocked.get()) { // Wait for file to become unlocked
+ Thread.sleep(100)
+ }
+ val response = mutableMapOf<Instant, PriceObject>()
+ val futures = mutableListOf<CompletableFuture<Map<Instant, PriceObject>?>>()
+ for (file in priceDir.listFiles { file -> file.extension == "gz" }!!) {
+ futures.add(CompletableFuture.supplyAsync {
+ val data = load(file)?.get(itemId) ?: return@supplyAsync null
+ (if (data.isBz()) data.bz?.map {
+ Instant.ofEpochSecond(it.key) to PriceObject(it.value.b, it.value.s)
+ } else data.ah?.map {
+ Instant.ofEpochSecond(it.key) to PriceObject(it.value.toDouble(), null)
+ })?.toMap()
+ })
+ }
+
+ for (future in futures) {
+ val result = future.get()
+ if (result != null) response.putAll(result)
+ }
+
+ return@supplyAsync response
+ }
+ }
+
+ private fun load(file: File): MutableMap<String, ItemData>? {
+ val type = object : TypeToken<MutableMap<String, ItemData>?>() {}.type
+ if (file.exists()) {
+ try {
+ BufferedReader(
+ InputStreamReader(
+ GZIPInputStream(FileInputStream(file)),
+ StandardCharsets.UTF_8
+ )
+ ).use { reader ->
+ return GSON.fromJson(
+ reader,
+ type
+ )
+ }
+ } catch (e: Exception) {
+ println("Deleting " + file.name + " because it is probably corrupted.")
+ file.delete()
+ }
+ }
+ return null
+ }
+
+ fun savePrices(items: JsonObject, bazaar: Boolean) {
+ try {
+ if (!priceDir.exists() && !priceDir.mkdir()) return
+ val files = priceDir.listFiles()
+ val dataRetentionTime =
+ System.currentTimeMillis() - config.ahGraph.dataRetention * 86400000L
+ files?.filter { it.extension == "gz" && it.lastModified() < dataRetentionTime }?.forEach { it.delete() }
+
+ if (!config.ahGraph.graphEnabled || config.ahGraph.dataSource != 1) return
+ if (fileLocked.get()) while (fileLocked.get()) { // Wait for file to become unlocked
+ Thread.sleep(100)
+ }
+ fileLocked.set(true)
+
+ val date = Date()
+ val epochSecond = date.toInstant().epochSecond
+ val file = File(priceDir, "prices_" + format.format(date) + ".gz")
+ var prices = load(file) ?: mutableMapOf()
+ if (file.exists()) {
+ val tempPrices = load(file)
+ if (tempPrices != null) prices = tempPrices
+ }
+ for (item in items.entrySet()) {
+ addOrUpdateItemPriceInfo(item, prices, epochSecond, bazaar)
+ }
+ file.createNewFile()
+ BufferedWriter(
+ OutputStreamWriter(
+ GZIPOutputStream(FileOutputStream(file)),
+ StandardCharsets.UTF_8
+ )
+ ).use { writer -> writer.write(GSON.toJson(prices)) }
+ fileLocked.set(false)
+ } catch (e: java.lang.Exception) {
+ e.printStackTrace()
+ fileLocked.set(false)
+ }
+ }
+
+ private fun addOrUpdateItemPriceInfo(
+ item: Map.Entry<String, JsonElement>,
+ prices: MutableMap<String, ItemData>,
+ timestamp: Long,
+ bazaar: Boolean
+ ) {
+ val itemName = item.key
+ var existingItemData: ItemData? = null
+ if (prices.containsKey(itemName)) {
+ existingItemData = prices[itemName]
+ }
+
+ // Handle transitions from ah to bz (the other direction typically doesn't happen)
+ if (existingItemData != null) {
+ if (existingItemData.isBz() && !bazaar) {
+ return
+ }
+ if (!existingItemData.isBz() && bazaar) {
+ prices.remove(itemName)
+ existingItemData = null
+ }
+ }
+ if (bazaar) {
+ if (!item.value.asJsonObject.has("curr_buy") ||
+ !item.value.asJsonObject.has("curr_sell")
+ ) {
+ return
+ }
+ val bzData = BzData(
+ item.value.asJsonObject["curr_buy"].asDouble,
+ item.value.asJsonObject["curr_sell"].asDouble
+ )
+ if (existingItemData != null) {
+ existingItemData.bz?.set(timestamp, bzData)
+ } else {
+ val mapData = mutableMapOf(timestamp to bzData)
+ prices[item.key] = ItemData(bz = mapData)
+ }
+ } else {
+ if (existingItemData != null) {
+ prices[item.key]!!.ah?.set(timestamp, item.value.asBigDecimal.toLong())
+ } else {
+ val mapData = mutableMapOf(timestamp to item.value.asLong)
+ prices[item.key] = ItemData(ah = mapData)
+ }
+ }
+ }
+}
+
+
+private data class ItemData(val ah: MutableMap<Long, Long>? = null, val bz: MutableMap<Long, BzData>? = null) {
+
+ fun get(): MutableMap<Long, *>? = if (!isBz()) ah else bz
+
+ fun isBz() = !bz.isNullOrEmpty()
+}
+
+private class BzData(val b: Double, val s: Double)
+
diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/PriceObject.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/PriceObject.kt
new file mode 100644
index 00000000..41f1d02f
--- /dev/null
+++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/PriceObject.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2023 NotEnoughUpdates contributors
+ *
+ * This file is part of NotEnoughUpdates.
+ *
+ * NotEnoughUpdates is free software: you can redistribute it
+ * and/or modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation, either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * NotEnoughUpdates is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with NotEnoughUpdates. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.moulberry.notenoughupdates.miscgui.pricegraph
+
+import com.google.gson.annotations.SerializedName
+
+data class PriceObject(@SerializedName("b") val buyPrice: Double, @SerializedName("s") val sellPrice: Double? = null)
diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/ServerGraphDataProvider.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/ServerGraphDataProvider.kt
new file mode 100644
index 00000000..65c76bca
--- /dev/null
+++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/ServerGraphDataProvider.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2023 NotEnoughUpdates contributors
+ *
+ * This file is part of NotEnoughUpdates.
+ *
+ * NotEnoughUpdates is free software: you can redistribute it
+ * and/or modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation, either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * NotEnoughUpdates is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with NotEnoughUpdates. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.moulberry.notenoughupdates.miscgui.pricegraph
+
+import com.google.gson.GsonBuilder
+import io.github.moulberry.notenoughupdates.NotEnoughUpdates
+import java.time.Instant
+import java.util.concurrent.CompletableFuture
+
+object ServerGraphDataProvider : GraphDataProvider {
+ private val gson = GsonBuilder().create()
+
+ override fun loadData(itemId: String): CompletableFuture<Map<Instant, PriceObject>?> {
+ return CompletableFuture.supplyAsync {
+ val request = NotEnoughUpdates.INSTANCE.manager.apiUtils.request()
+ .url("https://${NotEnoughUpdates.INSTANCE.config.ahGraph.serverUrl}")
+ .queryArgument("item", itemId).requestJson().get()?.asJsonObject ?: return@supplyAsync null
+
+ val response = mutableMapOf<Instant, PriceObject>()
+ for (element in request.entrySet()) response[Instant.parse(element.key)] =
+ gson.fromJson(element.value, PriceObject::class.java)
+ return@supplyAsync response
+ }
+ }
+}
diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/util/KotlinNumberUtils.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/util/KotlinNumberUtils.kt
new file mode 100644
index 00000000..f20da4e4
--- /dev/null
+++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/util/KotlinNumberUtils.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2023 NotEnoughUpdates contributors
+ *
+ * This file is part of NotEnoughUpdates.
+ *
+ * NotEnoughUpdates is free software: you can redistribute it
+ * and/or modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation, either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * NotEnoughUpdates is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with NotEnoughUpdates. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.moulberry.notenoughupdates.util
+
+import kotlin.math.pow
+import kotlin.math.round
+
+fun Double.roundToDecimals(decimals: Int): Double {
+ val multiplier = 10.0.pow(decimals)
+ return round(this * multiplier) / multiplier
+}