roi.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. import {setupPopover} from './popover';
  2. const roiCache: Record<string, Promise<{lastModified: Date, profits: Profit[]}>> = {};
  3. async function getROI(cx: string) {
  4. const response = await fetch(`/roi_${cx.toLowerCase()}.json`);
  5. const lastModified = new Date(response.headers.get('last-modified')!);
  6. const profits = await response.json();
  7. return {lastModified, profits};
  8. }
  9. // EXTREME DETAIL: Hoisted the MetricType so it can be utilized by the state initialization block below.
  10. type MetricType = 'vwap' | 'bid' | 'ask';
  11. const lowVolume = document.querySelector('input#low-volume') as HTMLInputElement;
  12. const cxSelect = document.querySelector('select#cx') as HTMLSelectElement;
  13. const expertise = {
  14. AGRICULTURE: 'agri',
  15. CHEMISTRY: 'chem',
  16. CONSTRUCTION: 'const',
  17. ELECTRONICS: 'elec',
  18. FOOD_INDUSTRIES: 'food ind',
  19. FUEL_REFINING: 'fuel',
  20. MANUFACTURING: 'mfg',
  21. METALLURGY: 'metal',
  22. RESOURCE_EXTRACTION: 'res ext',
  23. } as const;
  24. const expertiseSelect = document.querySelector('select#expertise') as HTMLSelectElement;
  25. for (const key of Object.keys(expertise)) {
  26. const option = document.createElement('option');
  27. option.value = key;
  28. option.textContent = key.replace('_', ' ').toLowerCase();
  29. expertiseSelect.appendChild(option);
  30. }
  31. const buildingSelect = document.querySelector('select#building') as HTMLSelectElement;
  32. const formatDecimal = new Intl.NumberFormat(undefined,
  33. {maximumFractionDigits: 2, maximumSignificantDigits: 6, roundingPriority: 'lessPrecision'}).format;
  34. const formatWhole = new Intl.NumberFormat(undefined, {maximumFractionDigits: 0}).format;
  35. // EXTREME DETAIL: --- STATE INITIALIZATION ---
  36. // Upon script execution (page load), we immediately query the browser's localStorage.
  37. // If valid keys exist, we override the default DOM selections with the persisted state.
  38. if (localStorage.getItem('roi-cx')) cxSelect.value = localStorage.getItem('roi-cx')!;
  39. if (localStorage.getItem('roi-expertise')) expertiseSelect.value = localStorage.getItem('roi-expertise')!;
  40. if (localStorage.getItem('roi-low-volume')) lowVolume.checked = localStorage.getItem('roi-low-volume') === 'true';
  41. // The building options haven't been dynamically generated yet, so we store the target in a temporary variable.
  42. let savedBuilding = localStorage.getItem('roi-building') || '';
  43. let currentSortKey: keyof ProfitWithMetrics | 'outputs' = (localStorage.getItem('roi-sort-key') as any) || 'break_even';
  44. let currentSortAsc: boolean = localStorage.getItem('roi-sort-asc') !== 'false'; // Defaults to true if null
  45. let headersInitialized = false;
  46. let metricControlsInitialized = false;
  47. let capexMetric: MetricType = (localStorage.getItem('roi-capex-metric') as MetricType) || 'vwap';
  48. let opexMetric: MetricType = (localStorage.getItem('roi-opex-metric') as MetricType) || 'vwap';
  49. let revenueMetric: MetricType = (localStorage.getItem('roi-revenue-metric') as MetricType) || 'vwap';
  50. // ------------------------------------------
  51. async function render() {
  52. const tbody = document.querySelector('tbody')!;
  53. tbody.innerHTML = '';
  54. const cx = cxSelect.value;
  55. if (!roiCache[cx])
  56. roiCache[cx] = getROI(cx);
  57. const {lastModified, profits} = await roiCache[cx];
  58. if (!metricControlsInitialized) {
  59. const controls = document.createElement('div');
  60. controls.style.marginBottom = '15px';
  61. controls.innerHTML = `
  62. <label style="margin-right: 15px;">CapEx Price:
  63. <select id="capex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
  64. </label>
  65. <label style="margin-right: 15px;">OpEx Price:
  66. <select id="opex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
  67. </label>
  68. <label>Revenue (Outputs) Price:
  69. <select id="revenue-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
  70. </label>
  71. `;
  72. const table = document.querySelector('table');
  73. if (table) table.parentNode?.insertBefore(controls, table);
  74. // EXTREME DETAIL: Apply the stored state defaults to the newly generated dropdown nodes.
  75. (document.getElementById('capex-metric') as HTMLSelectElement).value = capexMetric;
  76. (document.getElementById('opex-metric') as HTMLSelectElement).value = opexMetric;
  77. (document.getElementById('revenue-metric') as HTMLSelectElement).value = revenueMetric;
  78. document.getElementById('capex-metric')!.addEventListener('change', (e) => {
  79. capexMetric = (e.target as HTMLSelectElement).value as MetricType;
  80. render();
  81. });
  82. document.getElementById('opex-metric')!.addEventListener('change', (e) => {
  83. opexMetric = (e.target as HTMLSelectElement).value as MetricType;
  84. render();
  85. });
  86. document.getElementById('revenue-metric')!.addEventListener('change', (e) => {
  87. revenueMetric = (e.target as HTMLSelectElement).value as MetricType;
  88. render();
  89. });
  90. metricControlsInitialized = true;
  91. }
  92. if (!headersInitialized) {
  93. const ths = document.querySelectorAll('th');
  94. const keys: (keyof ProfitWithMetrics | 'outputs')[] = [
  95. 'outputs', 'expertise', 'profit_per_area', 'break_even',
  96. 'capex_val', 'opex_val', 'logistics_per_area', 'market_capacity_area'
  97. ];
  98. ths.forEach((th, i) => {
  99. if (keys[i]) {
  100. th.style.cursor = 'pointer';
  101. th.title = 'Click to sort';
  102. th.addEventListener('click', () => {
  103. if (currentSortKey === keys[i]) {
  104. currentSortAsc = !currentSortAsc;
  105. } else {
  106. currentSortKey = keys[i];
  107. currentSortAsc = keys[i] === 'break_even' ? true : false;
  108. }
  109. render();
  110. });
  111. }
  112. });
  113. headersInitialized = true;
  114. }
  115. const buildingTickers = new Set(profits.map(p => p.building));
  116. const buildings: {ticker: string, expertise: keyof typeof expertise}[] = Array.from(buildingTickers)
  117. .map((building) => ({ticker: building, expertise: profits.find(p => p.building === building)!.expertise}))
  118. .sort((a, b) => a.ticker.localeCompare(b.ticker));
  119. // EXTREME DETAIL: Inject the stored 'savedBuilding' target if this is the first execution.
  120. let selectedBuilding = buildingSelect.value || savedBuilding;
  121. let buildingFound = false;
  122. buildingSelect.innerHTML = '<option value="">(all)</option>';
  123. for (const building of buildings)
  124. if (expertiseSelect.value === '' || expertiseSelect.value === building.expertise) {
  125. const option = document.createElement('option');
  126. option.value = building.ticker;
  127. option.textContent = building.ticker;
  128. if (building.ticker === selectedBuilding) {
  129. buildingFound = true;
  130. option.selected = true;
  131. }
  132. buildingSelect.appendChild(option);
  133. }
  134. if (!buildingFound)
  135. selectedBuilding = '';
  136. // Clear the injection buffer so future renders correctly rely purely on DOM/User manipulation.
  137. savedBuilding = '';
  138. const filteredProfits = profits.filter(p => {
  139. const volumeRatio = p.output_per_day / p.average_traded_7d;
  140. if (!lowVolume.checked && volumeRatio > 0.05) return false;
  141. if (expertiseSelect.value !== '' && p.expertise !== expertiseSelect.value) return false;
  142. if (selectedBuilding !== '' && p.building !== selectedBuilding) return false;
  143. return true;
  144. });
  145. const profitsWithMetrics: ProfitWithMetrics[] = filteredProfits.map(p => {
  146. const capex_val = p.capex[capexMetric];
  147. const opex_val = p.opex[opexMetric];
  148. const revenue_val = p.revenue[revenueMetric];
  149. const profit_per_day = revenue_val - opex_val;
  150. const profit_per_area = profit_per_day / p.area;
  151. const break_even = profit_per_day > 0 ? (capex_val + 3 * opex_val) / profit_per_day : Infinity;
  152. return { ...p, capex_val, opex_val, revenue_val, profit_per_day, profit_per_area, break_even };
  153. });
  154. profitsWithMetrics.sort((a, b) => {
  155. let valA: any = a[currentSortKey as keyof ProfitWithMetrics];
  156. let valB: any = b[currentSortKey as keyof ProfitWithMetrics];
  157. if (currentSortKey === 'outputs') {
  158. valA = a.outputs.map(o => o.ticker).join(', ');
  159. valB = b.outputs.map(o => o.ticker).join(', ');
  160. }
  161. if (valA < valB) return currentSortAsc ? -1 : 1;
  162. if (valA > valB) return currentSortAsc ? 1 : -1;
  163. return 0;
  164. });
  165. for (const p of profitsWithMetrics) {
  166. const volumeRatio = p.output_per_day / p.average_traded_7d;
  167. const tr = document.createElement('tr');
  168. tr.innerHTML = `
  169. <td>${p.outputs.map(o => o.ticker).join(', ')}</td>
  170. <td>${expertise[p.expertise]}</td>
  171. <td style="color: ${color(p.profit_per_area, 0, 300)}">${formatDecimal(p.profit_per_area)}</td>
  172. <td><span style="color: ${color(p.break_even, 30, 3)}">${formatDecimal(p.break_even)}</span>d</td>
  173. <td style="color: ${color(p.capex_val, 300_000, 40_000)}">${formatWhole(p.capex_val)}</td>
  174. <td style="color: ${color(p.opex_val, 40_000, 1_000)}">${formatWhole(p.opex_val)}</td>
  175. <td style="color: ${color(p.logistics_per_area, 2, 0.2)}">${formatDecimal(p.logistics_per_area)}</td>
  176. <td style="color: ${color(p.market_capacity_area, 20, 500)}">${formatWhole(p.market_capacity_area)}</td>
  177. `;
  178. const output = tr.querySelector('td')!;
  179. output.dataset.tooltip = p.recipe;
  180. const profitCell = tr.querySelectorAll('td')[2];
  181. profitCell.dataset.tooltip = formatMatPrices(p.outputs, revenueMetric, p.runs_per_day) + '\n\n' +
  182. formatMatPrices(p.input_costs, opexMetric, p.runs_per_day) + '\n' +
  183. `(${formatWhole(p.revenue_val)} - ${formatWhole(p.opex_val)}) = ${formatDecimal(p.profit_per_day)}\n` +
  184. `${formatDecimal(p.profit_per_day)} / ${formatWhole(p.area)} area = ${formatDecimal(p.profit_per_area)}`;
  185. const marketCell = tr.querySelectorAll('td')[7];
  186. 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`;
  187. tbody.appendChild(tr);
  188. }
  189. document.getElementById('last-updated')!.textContent =
  190. `last updated: ${lastModified.toLocaleString(undefined, {dateStyle: 'full', timeStyle: 'long', hour12: false})}`;
  191. // EXTREME DETAIL: By calling saveState() at the very conclusion of the render loop,
  192. // we take a final snapshot of the fully sanitized UI state (ensuring we don't accidentally
  193. // save invalid building + expertise configurations to the hard drive).
  194. saveState();
  195. }
  196. function saveState() {
  197. localStorage.setItem('roi-cx', cxSelect.value);
  198. localStorage.setItem('roi-expertise', expertiseSelect.value);
  199. localStorage.setItem('roi-building', buildingSelect.value);
  200. localStorage.setItem('roi-low-volume', lowVolume.checked.toString());
  201. localStorage.setItem('roi-sort-key', currentSortKey);
  202. localStorage.setItem('roi-sort-asc', currentSortAsc.toString());
  203. localStorage.setItem('roi-capex-metric', capexMetric);
  204. localStorage.setItem('roi-opex-metric', opexMetric);
  205. localStorage.setItem('roi-revenue-metric', revenueMetric);
  206. }
  207. function color(n: number, low: number, high: number): string {
  208. const scale = Math.min(Math.max((n - low) / (high - low), 0), 1);
  209. return `color-mix(in xyz, #0aa ${scale * 100}%, #f80)`;
  210. }
  211. function formatMatPrices(matPrices: MatPrice[], metric: MetricType, runs_per_day: number): string {
  212. return matPrices.map(({ticker, amount, vwap_7d, bid, ask}) => {
  213. const val = metric === 'vwap' ? vwap_7d : metric === 'bid' ? (bid ?? vwap_7d) : (ask ?? vwap_7d);
  214. const daily_amount = amount * runs_per_day;
  215. return `${ticker}: ${formatDecimal(daily_amount)} × ${formatDecimal(val)} = ${formatWhole(daily_amount * val)}`;
  216. }).join('\n');
  217. }
  218. setupPopover();
  219. lowVolume.addEventListener('change', render);
  220. cxSelect.addEventListener('change', render);
  221. expertiseSelect.addEventListener('change', render);
  222. buildingSelect.addEventListener('change', render);
  223. render();
  224. interface Metrics {
  225. vwap: number;
  226. bid: number;
  227. ask: number;
  228. }
  229. interface Profit {
  230. outputs: MatPrice[]
  231. recipe: string
  232. expertise: keyof typeof expertise
  233. building: string
  234. area: number
  235. capex: Metrics
  236. opex: Metrics
  237. revenue: Metrics
  238. input_costs: MatPrice[]
  239. runs_per_day: number
  240. logistics_per_area: number
  241. output_per_day: number
  242. average_traded_7d: number
  243. market_capacity_area: number
  244. }
  245. interface ProfitWithMetrics extends Profit {
  246. capex_val: number;
  247. opex_val: number;
  248. revenue_val: number;
  249. profit_per_day: number;
  250. profit_per_area: number;
  251. break_even: number;
  252. }
  253. interface MatPrice {
  254. ticker: string
  255. amount: number
  256. vwap_7d: number
  257. bid: number | null
  258. ask: number | null
  259. }
  260. interface Building {
  261. building_type: 'INFRASTRUCTURE' | 'PLANETARY' | 'PRODUCTION';
  262. building_ticker: string;
  263. expertise: keyof typeof expertise;
  264. }