roi.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  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. type MetricType = 'vwap' | 'bid' | 'ask';
  10. const lowVolume = document.querySelector('input#low-volume') as HTMLInputElement;
  11. const cxSelect = document.querySelector('select#cx') as HTMLSelectElement;
  12. const expertise = {
  13. AGRICULTURE: 'agri',
  14. CHEMISTRY: 'chem',
  15. CONSTRUCTION: 'const',
  16. ELECTRONICS: 'elec',
  17. FOOD_INDUSTRIES: 'food ind',
  18. FUEL_REFINING: 'fuel',
  19. MANUFACTURING: 'mfg',
  20. METALLURGY: 'metal',
  21. RESOURCE_EXTRACTION: 'res ext',
  22. } as const;
  23. const expertiseSelect = document.querySelector('select#expertise') as HTMLSelectElement;
  24. for (const key of Object.keys(expertise)) {
  25. const option = document.createElement('option');
  26. option.value = key;
  27. option.textContent = key.replace('_', ' ').toLowerCase();
  28. expertiseSelect.appendChild(option);
  29. }
  30. const buildingSelect = document.querySelector('select#building') as HTMLSelectElement;
  31. const formatDecimal = new Intl.NumberFormat(undefined,
  32. {maximumFractionDigits: 2, maximumSignificantDigits: 6, roundingPriority: 'lessPrecision'}).format;
  33. const formatWhole = new Intl.NumberFormat(undefined, {maximumFractionDigits: 0}).format;
  34. if (localStorage.getItem('roi-cx')) cxSelect.value = localStorage.getItem('roi-cx')!;
  35. if (localStorage.getItem('roi-expertise')) expertiseSelect.value = localStorage.getItem('roi-expertise')!;
  36. if (localStorage.getItem('roi-low-volume')) lowVolume.checked = localStorage.getItem('roi-low-volume') === 'true';
  37. let savedBuilding = localStorage.getItem('roi-building') || '';
  38. let currentSortKey: keyof ProfitWithMetrics | 'outputs' = (localStorage.getItem('roi-sort-key') as any) || 'break_even';
  39. let currentSortAsc: boolean = localStorage.getItem('roi-sort-asc') !== 'false';
  40. let headersInitialized = false;
  41. let metricControlsInitialized = false;
  42. let capexMetric: MetricType = (localStorage.getItem('roi-capex-metric') as MetricType) || 'vwap';
  43. let opexMetric: MetricType = (localStorage.getItem('roi-opex-metric') as MetricType) || 'vwap';
  44. let revenueMetric: MetricType = (localStorage.getItem('roi-revenue-metric') as MetricType) || 'vwap';
  45. let includeShips: boolean = localStorage.getItem('roi-include-ships') === 'true';
  46. async function render() {
  47. const tbody = document.querySelector('tbody')!;
  48. tbody.innerHTML = '';
  49. const cx = cxSelect.value;
  50. if (!roiCache[cx])
  51. roiCache[cx] = getROI(cx);
  52. const {lastModified, profits} = await roiCache[cx];
  53. if (!metricControlsInitialized) {
  54. const controls = document.createElement('div');
  55. controls.style.marginBottom = '15px';
  56. // EXTREME DETAIL: Added a new checkbox for 'Include Ship CapEx' directly alongside the dropdowns.
  57. controls.innerHTML = `
  58. <label style="margin-right: 15px;">CapEx Price:
  59. <select id="capex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
  60. </label>
  61. <label style="margin-right: 15px;">OpEx Price:
  62. <select id="opex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
  63. </label>
  64. <label style="margin-right: 15px;">Revenue Price:
  65. <select id="revenue-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
  66. </label>
  67. <label>
  68. <input type="checkbox" id="include-ships"> Include Ship CapEx
  69. </label>
  70. `;
  71. const table = document.querySelector('table');
  72. if (table) table.parentNode?.insertBefore(controls, table);
  73. (document.getElementById('capex-metric') as HTMLSelectElement).value = capexMetric;
  74. (document.getElementById('opex-metric') as HTMLSelectElement).value = opexMetric;
  75. (document.getElementById('revenue-metric') as HTMLSelectElement).value = revenueMetric;
  76. (document.getElementById('include-ships') as HTMLInputElement).checked = includeShips;
  77. document.getElementById('capex-metric')!.addEventListener('change', (e) => {
  78. capexMetric = (e.target as HTMLSelectElement).value as MetricType;
  79. render();
  80. });
  81. document.getElementById('opex-metric')!.addEventListener('change', (e) => {
  82. opexMetric = (e.target as HTMLSelectElement).value as MetricType;
  83. render();
  84. });
  85. document.getElementById('revenue-metric')!.addEventListener('change', (e) => {
  86. revenueMetric = (e.target as HTMLSelectElement).value as MetricType;
  87. render();
  88. });
  89. document.getElementById('include-ships')!.addEventListener('change', (e) => {
  90. includeShips = (e.target as HTMLInputElement).checked;
  91. render();
  92. });
  93. metricControlsInitialized = true;
  94. }
  95. if (!headersInitialized) {
  96. const ths = document.querySelectorAll('th');
  97. const keys: (keyof ProfitWithMetrics | 'outputs')[] = [
  98. 'outputs', 'expertise', 'profit_per_base', 'break_even',
  99. 'capex_val', 'opex_val', 'logistics_per_base', 'market_capacity_base'
  100. ];
  101. ths.forEach((th, i) => {
  102. if (keys[i]) {
  103. th.style.cursor = 'pointer';
  104. th.title = '';
  105. if (keys[i] === 'profit_per_base') {
  106. th.textContent = 'Profit/Base';
  107. th.dataset.tooltip = 'Click to sort.\n\nDaily profit scaled to a full 500-area planetary base.';
  108. } else if (keys[i] === 'capex_val') {
  109. th.textContent = 'CapEx/Base';
  110. th.dataset.tooltip = 'Click to sort.\n\nTotal capital expenditure (construction + habitation costs) scaled to a full 500-area planetary base.\nIf "Include Ship CapEx" is toggled, adds the cost of ships (800k each) required for daily logistics.';
  111. } else if (keys[i] === 'opex_val') {
  112. th.textContent = 'OpEx/Base';
  113. th.dataset.tooltip = 'Click to sort.\n\nDaily operational expenditure (input materials + worker consumables) scaled to a full 500-area planetary base.';
  114. } else if (keys[i] === 'logistics_per_base') {
  115. th.textContent = 'Logistics/Base';
  116. th.dataset.tooltip = 'Click to sort.\n\nDaily logistics volume/weight scaled to a full 500-area planetary base.';
  117. } else if (keys[i] === 'market_capacity_base') {
  118. th.textContent = 'Market Cap (Bases)';
  119. th.dataset.tooltip = 'Click to sort.\n\nMarket Capacity: 7-day average traded volume ÷ daily output per base. Indicates how many full 500-area bases you can build before saturating the market.';
  120. } else if (keys[i] === 'break_even') {
  121. th.dataset.tooltip = 'Click to sort.\n\nBreak Even: (CapEx + 3 days of OpEx) ÷ daily profit. Includes 3 days of operating costs as working capital.';
  122. } else {
  123. th.dataset.tooltip = 'Click to sort.';
  124. }
  125. th.addEventListener('click', () => {
  126. if (currentSortKey === keys[i]) {
  127. currentSortAsc = !currentSortAsc;
  128. } else {
  129. currentSortKey = keys[i];
  130. currentSortAsc = keys[i] === 'break_even' ? true : false;
  131. }
  132. render();
  133. });
  134. }
  135. });
  136. headersInitialized = true;
  137. }
  138. const buildingTickers = new Set(profits.map(p => p.building));
  139. const buildings: {ticker: string, expertise: keyof typeof expertise}[] = Array.from(buildingTickers)
  140. .map((building) => ({ticker: building, expertise: profits.find(p => p.building === building)!.expertise}))
  141. .sort((a, b) => a.ticker.localeCompare(b.ticker));
  142. let selectedBuilding = buildingSelect.value || savedBuilding;
  143. let buildingFound = false;
  144. buildingSelect.innerHTML = '<option value="">(all)</option>';
  145. for (const building of buildings)
  146. if (expertiseSelect.value === '' || expertiseSelect.value === building.expertise) {
  147. const option = document.createElement('option');
  148. option.value = building.ticker;
  149. option.textContent = building.ticker;
  150. if (building.ticker === selectedBuilding) {
  151. buildingFound = true;
  152. option.selected = true;
  153. }
  154. buildingSelect.appendChild(option);
  155. }
  156. if (!buildingFound)
  157. selectedBuilding = '';
  158. savedBuilding = '';
  159. const filteredProfits = profits.filter(p => {
  160. const volumeRatio = p.output_per_day / p.average_traded_7d;
  161. if (!lowVolume.checked && volumeRatio > 0.05) return false;
  162. if (expertiseSelect.value !== '' && p.expertise !== expertiseSelect.value) return false;
  163. if (selectedBuilding !== '' && p.building !== selectedBuilding) return false;
  164. return true;
  165. });
  166. const profitsWithMetrics: ProfitWithMetrics[] = filteredProfits.map(p => {
  167. const bases = p.area / 500;
  168. let capex_val = p.capex[capexMetric] / bases;
  169. const opex_val = p.opex[opexMetric] / bases;
  170. const revenue_val = p.revenue[revenueMetric] / bases;
  171. // EXTREME DETAIL: If the user toggles the UI checkbox, we silently inject the precalculated
  172. // ship capital expenditures into the active CapEx pipeline. This causes the break-even math
  173. // to dynamically cascade and update instantly.
  174. if (includeShips) {
  175. capex_val += p.ship_capex_per_base;
  176. }
  177. const profit_per_base = revenue_val - opex_val;
  178. const break_even = profit_per_base > 0 ? (capex_val + 3 * opex_val) / profit_per_base : Infinity;
  179. return { ...p, capex_val, opex_val, revenue_val, profit_per_base, break_even };
  180. });
  181. profitsWithMetrics.sort((a, b) => {
  182. let valA: any = a[currentSortKey as keyof ProfitWithMetrics];
  183. let valB: any = b[currentSortKey as keyof ProfitWithMetrics];
  184. if (currentSortKey === 'outputs') {
  185. valA = a.outputs.map(o => o.ticker).join(', ');
  186. valB = b.outputs.map(o => o.ticker).join(', ');
  187. }
  188. if (valA < valB) return currentSortAsc ? -1 : 1;
  189. if (valA > valB) return currentSortAsc ? 1 : -1;
  190. return 0;
  191. });
  192. for (const p of profitsWithMetrics) {
  193. const volumeRatio = p.output_per_day / p.average_traded_7d;
  194. const tr = document.createElement('tr');
  195. tr.innerHTML = `
  196. <td>${p.outputs.map(o => o.ticker).join(', ')}</td>
  197. <td>${expertise[p.expertise]}</td>
  198. <td style="color: ${color(p.profit_per_base, 0, 150000)}">${formatDecimal(p.profit_per_base)}</td>
  199. <td><span style="color: ${color(p.break_even, 30, 3)}">${formatDecimal(p.break_even)}</span>d</td>
  200. <td style="color: ${color(p.capex_val, 3_000_000, 400_000)}">${formatWhole(p.capex_val)}</td>
  201. <td style="color: ${color(p.opex_val, 400_000, 10_000)}">${formatWhole(p.opex_val)}</td>
  202. <td style="color: ${color(p.logistics_per_base, 1000, 100)}">${formatDecimal(p.logistics_per_base)}</td>
  203. <td style="color: ${color(p.market_capacity_base, 0.04, 1)}">${formatDecimal(p.market_capacity_base)}</td>
  204. `;
  205. const output = tr.querySelector('td')!;
  206. output.dataset.tooltip = p.recipe;
  207. const profitCell = tr.querySelectorAll('td')[2];
  208. const runs_per_base = p.runs_per_day / (p.area / 500);
  209. profitCell.dataset.tooltip = formatMatPrices(p.outputs, revenueMetric, runs_per_base) + '\n\n' +
  210. formatMatPrices(p.input_costs, opexMetric, runs_per_base) + '\n' +
  211. '+ worker consumables\n\n' +
  212. `(${formatWhole(p.revenue_val)} - ${formatWhole(p.opex_val)}) = ${formatDecimal(p.profit_per_base)}`;
  213. // EXTREME DETAIL: To provide absolute transparency, if the ship toggle is active,
  214. // we inject a dedicated tooltip into the CapEx cell specifically. This proves to the user
  215. // exactly how many fractional ships were assumed and how much they cost.
  216. const capexCell = tr.querySelectorAll('td')[4];
  217. capexCell.dataset.tooltip = `Base Construction: ${formatWhole(p.capex[capexMetric] / (p.area / 500))}`;
  218. if (includeShips) {
  219. capexCell.dataset.tooltip += `\nShip CapEx: ${formatWhole(p.ship_capex_per_base)} (${formatDecimal(p.ship_capex_per_base / 800_000)} ships)`;
  220. }
  221. const marketCell = tr.querySelectorAll('td')[7];
  222. marketCell.dataset.tooltip = `Market Capacity: ${formatWhole(p.average_traded_7d)} traded/day ÷ ${formatDecimal(p.output_per_day / (p.area / 500))} produced/day/base = ${formatDecimal(p.market_capacity_base)} equivalent bases`;
  223. tbody.appendChild(tr);
  224. }
  225. document.getElementById('last-updated')!.textContent =
  226. `last updated: ${lastModified.toLocaleString(undefined, {dateStyle: 'full', timeStyle: 'long', hour12: false})}`;
  227. saveState();
  228. }
  229. function saveState() {
  230. localStorage.setItem('roi-cx', cxSelect.value);
  231. localStorage.setItem('roi-expertise', expertiseSelect.value);
  232. localStorage.setItem('roi-building', buildingSelect.value);
  233. localStorage.setItem('roi-low-volume', lowVolume.checked.toString());
  234. localStorage.setItem('roi-sort-key', currentSortKey);
  235. localStorage.setItem('roi-sort-asc', currentSortAsc.toString());
  236. localStorage.setItem('roi-capex-metric', capexMetric);
  237. localStorage.setItem('roi-opex-metric', opexMetric);
  238. localStorage.setItem('roi-revenue-metric', revenueMetric);
  239. localStorage.setItem('roi-include-ships', includeShips.toString());
  240. }
  241. function color(n: number, low: number, high: number): string {
  242. const scale = Math.min(Math.max((n - low) / (high - low), 0), 1);
  243. return `color-mix(in xyz, #0aa ${scale * 100}%, #f80)`;
  244. }
  245. function formatMatPrices(matPrices: MatPrice[], metric: MetricType, runs_per_day: number): string {
  246. return matPrices.map(({ticker, amount, vwap_7d, bid, ask}) => {
  247. const val = metric === 'vwap' ? vwap_7d : metric === 'bid' ? (bid ?? vwap_7d) : (ask ?? vwap_7d);
  248. const daily_amount = amount * runs_per_day;
  249. return `${ticker}: ${formatDecimal(daily_amount)} × ${formatDecimal(val)} = ${formatWhole(daily_amount * val)}`;
  250. }).join('\n');
  251. }
  252. setupPopover();
  253. lowVolume.addEventListener('change', render);
  254. cxSelect.addEventListener('change', render);
  255. expertiseSelect.addEventListener('change', render);
  256. buildingSelect.addEventListener('change', render);
  257. render();
  258. interface Metrics {
  259. vwap: number;
  260. bid: number;
  261. ask: number;
  262. }
  263. interface Profit {
  264. outputs: MatPrice[]
  265. recipe: string
  266. expertise: keyof typeof expertise
  267. building: string
  268. area: number
  269. capex: Metrics
  270. opex: Metrics
  271. revenue: Metrics
  272. input_costs: MatPrice[]
  273. runs_per_day: number
  274. logistics_per_base: number
  275. output_per_day: number
  276. average_traded_7d: number
  277. market_capacity_base: number
  278. ship_capex_per_base: number
  279. }
  280. interface ProfitWithMetrics extends Profit {
  281. capex_val: number;
  282. opex_val: number;
  283. revenue_val: number;
  284. profit_per_day: number;
  285. profit_per_base: number;
  286. break_even: number;
  287. }
  288. interface MatPrice {
  289. ticker: string
  290. amount: number
  291. vwap_7d: number
  292. bid: number | null
  293. ask: number | null
  294. }
  295. interface Building {
  296. building_type: 'INFRASTRUCTURE' | 'PLANETARY' | 'PRODUCTION';
  297. building_ticker: string;
  298. expertise: keyof typeof expertise;
  299. }