roi.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. // ts/popover.ts
  2. function setupPopover() {
  3. const main = document.querySelector("main");
  4. const popover = document.querySelector("#popover");
  5. main.addEventListener("mouseover", (event) => {
  6. const target = event.target;
  7. if (target.dataset.tooltip) {
  8. popover.textContent = target.dataset.tooltip;
  9. const rect = target.getBoundingClientRect();
  10. popover.style.left = `${rect.left}px`;
  11. popover.style.top = `${rect.bottom}px`;
  12. popover.showPopover();
  13. }
  14. });
  15. main.addEventListener("mouseout", (event) => {
  16. const target = event.target;
  17. if (target.dataset.tooltip)
  18. popover.hidePopover();
  19. });
  20. }
  21. // ts/roi.ts
  22. var roiCache = {};
  23. async function getROI(cx) {
  24. const response = await fetch(`/roi_${cx.toLowerCase()}.json`);
  25. const lastModified = new Date(response.headers.get("last-modified"));
  26. const profits = await response.json();
  27. return { lastModified, profits };
  28. }
  29. var lowVolume = document.querySelector("input#low-volume");
  30. var cxSelect = document.querySelector("select#cx");
  31. var expertise = {
  32. AGRICULTURE: "agri",
  33. CHEMISTRY: "chem",
  34. CONSTRUCTION: "const",
  35. ELECTRONICS: "elec",
  36. FOOD_INDUSTRIES: "food ind",
  37. FUEL_REFINING: "fuel",
  38. MANUFACTURING: "mfg",
  39. METALLURGY: "metal",
  40. RESOURCE_EXTRACTION: "res ext"
  41. };
  42. var expertiseSelect = document.querySelector("select#expertise");
  43. for (const key of Object.keys(expertise)) {
  44. const option = document.createElement("option");
  45. option.value = key;
  46. option.textContent = key.replace("_", " ").toLowerCase();
  47. expertiseSelect.appendChild(option);
  48. }
  49. var buildingSelect = document.querySelector("select#building");
  50. var formatDecimal = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2, maximumSignificantDigits: 6, roundingPriority: "lessPrecision" }).format;
  51. var formatWhole = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format;
  52. if (localStorage.getItem("roi-cx"))
  53. cxSelect.value = localStorage.getItem("roi-cx");
  54. if (localStorage.getItem("roi-expertise"))
  55. expertiseSelect.value = localStorage.getItem("roi-expertise");
  56. if (localStorage.getItem("roi-low-volume"))
  57. lowVolume.checked = localStorage.getItem("roi-low-volume") === "true";
  58. var savedBuilding = localStorage.getItem("roi-building") || "";
  59. var currentSortKey = localStorage.getItem("roi-sort-key") || "break_even";
  60. var currentSortAsc = localStorage.getItem("roi-sort-asc") !== "false";
  61. var headersInitialized = false;
  62. var metricControlsInitialized = false;
  63. var capexMetric = localStorage.getItem("roi-capex-metric") || "vwap";
  64. var opexMetric = localStorage.getItem("roi-opex-metric") || "vwap";
  65. var revenueMetric = localStorage.getItem("roi-revenue-metric") || "vwap";
  66. async function render() {
  67. const tbody = document.querySelector("tbody");
  68. tbody.innerHTML = "";
  69. const cx = cxSelect.value;
  70. if (!roiCache[cx])
  71. roiCache[cx] = getROI(cx);
  72. const { lastModified, profits } = await roiCache[cx];
  73. if (!metricControlsInitialized) {
  74. const controls = document.createElement("div");
  75. controls.style.marginBottom = "15px";
  76. controls.innerHTML = `
  77. <label style="margin-right: 15px;">CapEx Price:
  78. <select id="capex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
  79. </label>
  80. <label style="margin-right: 15px;">OpEx Price:
  81. <select id="opex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
  82. </label>
  83. <label>Revenue (Outputs) Price:
  84. <select id="revenue-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
  85. </label>
  86. `;
  87. const table = document.querySelector("table");
  88. if (table)
  89. table.parentNode?.insertBefore(controls, table);
  90. document.getElementById("capex-metric").value = capexMetric;
  91. document.getElementById("opex-metric").value = opexMetric;
  92. document.getElementById("revenue-metric").value = revenueMetric;
  93. document.getElementById("capex-metric").addEventListener("change", (e) => {
  94. capexMetric = e.target.value;
  95. render();
  96. });
  97. document.getElementById("opex-metric").addEventListener("change", (e) => {
  98. opexMetric = e.target.value;
  99. render();
  100. });
  101. document.getElementById("revenue-metric").addEventListener("change", (e) => {
  102. revenueMetric = e.target.value;
  103. render();
  104. });
  105. metricControlsInitialized = true;
  106. }
  107. if (!headersInitialized) {
  108. const ths = document.querySelectorAll("th");
  109. const keys = [
  110. "outputs",
  111. "expertise",
  112. "profit_per_area",
  113. "break_even",
  114. "capex_val",
  115. "opex_val",
  116. "logistics_per_area",
  117. "market_capacity_area"
  118. ];
  119. ths.forEach((th, i) => {
  120. if (keys[i]) {
  121. th.style.cursor = "pointer";
  122. th.title = "";
  123. if (keys[i] === "market_capacity_area") {
  124. th.textContent = "Market Cap (Areas)";
  125. th.dataset.tooltip = `Click to sort.
  126. Market Capacity: 7-day average traded volume ÷ daily output per area. Indicates how many areas you can build before saturating the market.`;
  127. } else if (keys[i] === "break_even") {
  128. th.dataset.tooltip = `Click to sort.
  129. Break Even: (CapEx + 3 days of OpEx) ÷ daily profit. Includes 3 days of operating costs as working capital.`;
  130. } else {
  131. th.dataset.tooltip = "Click to sort.";
  132. }
  133. th.addEventListener("click", () => {
  134. if (currentSortKey === keys[i]) {
  135. currentSortAsc = !currentSortAsc;
  136. } else {
  137. currentSortKey = keys[i];
  138. currentSortAsc = keys[i] === "break_even" ? true : false;
  139. }
  140. render();
  141. });
  142. }
  143. });
  144. headersInitialized = true;
  145. }
  146. const buildingTickers = new Set(profits.map((p) => p.building));
  147. const buildings = Array.from(buildingTickers).map((building) => ({ ticker: building, expertise: profits.find((p) => p.building === building).expertise })).sort((a, b) => a.ticker.localeCompare(b.ticker));
  148. let selectedBuilding = buildingSelect.value || savedBuilding;
  149. let buildingFound = false;
  150. buildingSelect.innerHTML = '<option value="">(all)</option>';
  151. for (const building of buildings)
  152. if (expertiseSelect.value === "" || expertiseSelect.value === building.expertise) {
  153. const option = document.createElement("option");
  154. option.value = building.ticker;
  155. option.textContent = building.ticker;
  156. if (building.ticker === selectedBuilding) {
  157. buildingFound = true;
  158. option.selected = true;
  159. }
  160. buildingSelect.appendChild(option);
  161. }
  162. if (!buildingFound)
  163. selectedBuilding = "";
  164. savedBuilding = "";
  165. const filteredProfits = profits.filter((p) => {
  166. const volumeRatio = p.output_per_day / p.average_traded_7d;
  167. if (!lowVolume.checked && volumeRatio > 0.05)
  168. return false;
  169. if (expertiseSelect.value !== "" && p.expertise !== expertiseSelect.value)
  170. return false;
  171. if (selectedBuilding !== "" && p.building !== selectedBuilding)
  172. return false;
  173. return true;
  174. });
  175. const profitsWithMetrics = filteredProfits.map((p) => {
  176. const capex_val = p.capex[capexMetric];
  177. const opex_val = p.opex[opexMetric];
  178. const revenue_val = p.revenue[revenueMetric];
  179. const profit_per_day = revenue_val - opex_val;
  180. const profit_per_area = profit_per_day / p.area;
  181. const break_even = profit_per_day > 0 ? (capex_val + 3 * opex_val) / profit_per_day : Infinity;
  182. return { ...p, capex_val, opex_val, revenue_val, profit_per_day, profit_per_area, break_even };
  183. });
  184. profitsWithMetrics.sort((a, b) => {
  185. let valA = a[currentSortKey];
  186. let valB = b[currentSortKey];
  187. if (currentSortKey === "outputs") {
  188. valA = a.outputs.map((o) => o.ticker).join(", ");
  189. valB = b.outputs.map((o) => o.ticker).join(", ");
  190. }
  191. if (valA < valB)
  192. return currentSortAsc ? -1 : 1;
  193. if (valA > valB)
  194. return currentSortAsc ? 1 : -1;
  195. return 0;
  196. });
  197. for (const p of profitsWithMetrics) {
  198. const tr = document.createElement("tr");
  199. tr.innerHTML = `
  200. <td>${p.outputs.map((o) => o.ticker).join(", ")}</td>
  201. <td>${expertise[p.expertise]}</td>
  202. <td style="color: ${color(p.profit_per_area, 0, 300)}">${formatDecimal(p.profit_per_area)}</td>
  203. <td><span style="color: ${color(p.break_even, 30, 3)}">${formatDecimal(p.break_even)}</span>d</td>
  204. <td style="color: ${color(p.capex_val, 300000, 40000)}">${formatWhole(p.capex_val)}</td>
  205. <td style="color: ${color(p.opex_val, 40000, 1000)}">${formatWhole(p.opex_val)}</td>
  206. <td style="color: ${color(p.logistics_per_area, 2, 0.2)}">${formatDecimal(p.logistics_per_area)}</td>
  207. <td style="color: ${color(p.market_capacity_area, 20, 500)}">${formatWhole(p.market_capacity_area)}</td>
  208. `;
  209. const output = tr.querySelector("td");
  210. output.dataset.tooltip = p.recipe;
  211. const profitCell = tr.querySelectorAll("td")[2];
  212. profitCell.dataset.tooltip = formatMatPrices(p.outputs, revenueMetric, p.runs_per_day) + `
  213. ` + formatMatPrices(p.input_costs, opexMetric, p.runs_per_day) + `
  214. ` + `(${formatWhole(p.revenue_val)} - ${formatWhole(p.opex_val)}) = ${formatDecimal(p.profit_per_day)}
  215. ` + `${formatDecimal(p.profit_per_day)} / ${formatWhole(p.area)} area = ${formatDecimal(p.profit_per_area)}`;
  216. const marketCell = tr.querySelectorAll("td")[7];
  217. marketCell.dataset.tooltip = `Market Capacity: ${formatWhole(p.average_traded_7d)} traded/day ÷ ${formatDecimal(p.output_per_day / p.area)} produced/day/area = ${formatWhole(p.market_capacity_area)} equivalent areas`;
  218. tbody.appendChild(tr);
  219. }
  220. document.getElementById("last-updated").textContent = `last updated: ${lastModified.toLocaleString(undefined, { dateStyle: "full", timeStyle: "long", hour12: false })}`;
  221. saveState();
  222. }
  223. function saveState() {
  224. localStorage.setItem("roi-cx", cxSelect.value);
  225. localStorage.setItem("roi-expertise", expertiseSelect.value);
  226. localStorage.setItem("roi-building", buildingSelect.value);
  227. localStorage.setItem("roi-low-volume", lowVolume.checked.toString());
  228. localStorage.setItem("roi-sort-key", currentSortKey);
  229. localStorage.setItem("roi-sort-asc", currentSortAsc.toString());
  230. localStorage.setItem("roi-capex-metric", capexMetric);
  231. localStorage.setItem("roi-opex-metric", opexMetric);
  232. localStorage.setItem("roi-revenue-metric", revenueMetric);
  233. }
  234. function color(n, low, high) {
  235. const scale = Math.min(Math.max((n - low) / (high - low), 0), 1);
  236. return `color-mix(in xyz, #0aa ${scale * 100}%, #f80)`;
  237. }
  238. function formatMatPrices(matPrices, metric, runs_per_day) {
  239. return matPrices.map(({ ticker, amount, vwap_7d, bid, ask }) => {
  240. const val = metric === "vwap" ? vwap_7d : metric === "bid" ? bid ?? vwap_7d : ask ?? vwap_7d;
  241. const daily_amount = amount * runs_per_day;
  242. return `${ticker}: ${formatDecimal(daily_amount)} × ${formatDecimal(val)} = ${formatWhole(daily_amount * val)}`;
  243. }).join(`
  244. `);
  245. }
  246. setupPopover();
  247. lowVolume.addEventListener("change", render);
  248. cxSelect.addEventListener("change", render);
  249. expertiseSelect.addEventListener("change", render);
  250. buildingSelect.addEventListener("change", render);
  251. render();
  252. //# debugId=2FB8029285CE8DDA64756E2164756E21