roi.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601
  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 formatSigFig = new Intl.NumberFormat(undefined, {
  32. notation: 'compact',
  33. maximumSignificantDigits: 3,
  34. }).format;
  35. if (localStorage.getItem('roi-cx')) cxSelect.value = localStorage.getItem('roi-cx')!;
  36. if (localStorage.getItem('roi-expertise')) expertiseSelect.value = localStorage.getItem('roi-expertise')!;
  37. if (localStorage.getItem('roi-low-volume')) lowVolume.checked = localStorage.getItem('roi-low-volume') === 'true';
  38. let savedBuilding = localStorage.getItem('roi-building') || '';
  39. let storedSortKey = localStorage.getItem('roi-sort-key') as any;
  40. if (storedSortKey === 'logistics_per_base') storedSortKey = 'normalized_logistics_per_base';
  41. let currentSortKey: keyof ProfitWithMetrics | 'outputs' = storedSortKey || 'break_even';
  42. let currentSortAsc: boolean = localStorage.getItem('roi-sort-asc') !== 'false';
  43. let headersInitialized = false;
  44. let metricControlsInitialized = false;
  45. let capexMetric: MetricType = (localStorage.getItem('roi-capex-metric') as MetricType) || 'vwap';
  46. let opexMetric: MetricType = (localStorage.getItem('roi-opex-metric') as MetricType) || 'vwap';
  47. let revenueMetric: MetricType = (localStorage.getItem('roi-revenue-metric') as MetricType) || 'vwap';
  48. let roundTripOption: string = localStorage.getItem('roi-round-trip') || 'omit';
  49. let showNegativeProfit: boolean = localStorage.getItem('roi-show-negative') !== 'false';
  50. let workingCapitalOption: string = localStorage.getItem('roi-working-capital-opt') || 'dynamic';
  51. let targetPermitOption: string = localStorage.getItem('roi-target-permit') || '2';
  52. let percentileMode: 'relative' | 'absolute' = (localStorage.getItem('roi-pct-mode') as 'relative' | 'absolute') || 'relative';
  53. async function render() {
  54. const tbody = document.querySelector('tbody')!;
  55. tbody.innerHTML = '';
  56. const cx = cxSelect.value;
  57. if (!roiCache[cx])
  58. roiCache[cx] = getROI(cx);
  59. const {lastModified, profits} = await roiCache[cx];
  60. if (!metricControlsInitialized) {
  61. const controls = document.createElement('div');
  62. controls.style.marginBottom = '15px';
  63. controls.innerHTML = `
  64. <label style="margin-right: 15px;">CapEx Price: 
  65. <select id="capex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
  66. </label>
  67. <label style="margin-right: 15px;">OpEx Price: 
  68. <select id="opex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
  69. </label>
  70. <label style="margin-right: 15px;">Revenue Price: 
  71. <select id="revenue-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
  72. </label>
  73. <label style="margin-right: 15px;">
  74. <input type="checkbox" id="show-negative"> Show Negative Profit
  75. </label>
  76. <label style="margin-right: 15px;">
  77. Round Trip (hrs): 
  78. <select id="round-trip">
  79. <option value="omit">Omit From Calculation</option>
  80. ${Array.from({length: 25}, (_, i) => `<option value="${i + 1}">${i + 1}</option>`).join('')}
  81. </select>
  82. </label>
  83. <label style="margin-right: 15px;">
  84. Days OpEx: 
  85. <select id="working-capital">
  86. <option value="omit">Omit From Calculation</option>
  87. <option value="dynamic">Max for Shipment (dynamic)</option>
  88. <option value="1">1</option>
  89. <option value="2">2</option>
  90. <option value="3">3</option>
  91. <option value="4">4</option>
  92. <option value="5">5</option>
  93. <option value="6">6</option>
  94. <option value="7">7</option>
  95. <option value="14">14</option>
  96. <option value="30">30</option>
  97. </select>
  98. </label>
  99. <label>
  100. Permit Number: 
  101. <select id="target-permit">
  102. <option value="omit">Omit From Calculation</option>
  103. ${Array.from({length: 49}, (_, i) => `<option value="${i + 2}">${i + 2}</option>`).join('')}
  104. </select>
  105. </label>
  106. <label style="margin-left: 15px;">
  107. Percentiles: 
  108. <select id="percentile-mode">
  109. <option value="relative">Normalized (Weighted)</option>
  110. <option value="absolute">Absolute</option>
  111. </select>
  112. </label>
  113. `;
  114. const table = document.querySelector('table');
  115. if (table) table.parentNode?.insertBefore(controls, table);
  116. (document.getElementById('capex-metric') as HTMLSelectElement).value = capexMetric;
  117. (document.getElementById('opex-metric') as HTMLSelectElement).value = opexMetric;
  118. (document.getElementById('revenue-metric') as HTMLSelectElement).value = revenueMetric;
  119. (document.getElementById('show-negative') as HTMLInputElement).checked = showNegativeProfit;
  120. (document.getElementById('round-trip') as HTMLSelectElement).value = roundTripOption;
  121. (document.getElementById('working-capital') as HTMLSelectElement).value = workingCapitalOption;
  122. (document.getElementById('target-permit') as HTMLSelectElement).value = targetPermitOption;
  123. (document.getElementById('percentile-mode') as HTMLSelectElement).value = percentileMode;
  124. document.getElementById('capex-metric')!.addEventListener('change', (e) => {
  125. capexMetric = (e.target as HTMLSelectElement).value as MetricType;
  126. render();
  127. });
  128. document.getElementById('opex-metric')!.addEventListener('change', (e) => {
  129. opexMetric = (e.target as HTMLSelectElement).value as MetricType;
  130. render();
  131. });
  132. document.getElementById('revenue-metric')!.addEventListener('change', (e) => {
  133. revenueMetric = (e.target as HTMLSelectElement).value as MetricType;
  134. render();
  135. });
  136. document.getElementById('show-negative')!.addEventListener('change', (e) => {
  137. showNegativeProfit = (e.target as HTMLInputElement).checked;
  138. render();
  139. });
  140. document.getElementById('round-trip')!.addEventListener('change', (e) => {
  141. roundTripOption = (e.target as HTMLSelectElement).value;
  142. render();
  143. });
  144. document.getElementById('working-capital')!.addEventListener('change', (e) => {
  145. workingCapitalOption = (e.target as HTMLSelectElement).value;
  146. render();
  147. });
  148. document.getElementById('target-permit')!.addEventListener('change', (e) => {
  149. targetPermitOption = (e.target as HTMLSelectElement).value;
  150. render();
  151. });
  152. document.getElementById('percentile-mode')!.addEventListener('change', (e) => {
  153. percentileMode = (e.target as HTMLSelectElement).value as 'relative' | 'absolute';
  154. render();
  155. });
  156. metricControlsInitialized = true;
  157. }
  158. if (!headersInitialized) {
  159. const ths = document.querySelectorAll('th');
  160. const keys: (keyof ProfitWithMetrics | 'outputs')[] = [
  161. 'outputs', 'expertise', 'profit_per_base', 'break_even', 
  162. 'capex_val', 'opex_val', 'normalized_logistics_per_base', 'market_capacity_base'
  163. ];
  164. const pctExplainer = '\n(Percentiles: 100.0% is the most desirable outcome. Toggle between Absolute and Normalized (weighted by market cash flow) using the controls.)';
  165. ths.forEach((th, i) => {
  166. if (keys[i]) {
  167. th.style.cursor = 'pointer';
  168. th.title = ''; 
  169. if (keys[i] === 'profit_per_base') {
  170. th.textContent = 'Profit/Base';
  171. th.dataset.tooltip = 'Click to sort.\n\nDaily profit scaled to a full 500-area planetary base.' + pctExplainer;
  172. } else if (keys[i] === 'capex_val') {
  173. th.textContent = 'CapEx/Base';
  174. th.dataset.tooltip = 'Click to sort.\n\nTotal capital expenditure scaled to a full 500-area planetary base.\nIncludes base construction, and optional Working Capital, HQ Upgrades, and Ship CapEx (use "Omit From Calculation" to exclude).' + pctExplainer;
  175. } else if (keys[i] === 'opex_val') {
  176. th.textContent = 'OpEx/Base';
  177. th.dataset.tooltip = 'Click to sort.\n\nDaily operational expenditure (input materials + worker consumables) scaled to a full 500-area planetary base.' + pctExplainer;
  178. } else if (keys[i] === 'normalized_logistics_per_base') {
  179. th.textContent = 'Logistics/Base';
  180. th.dataset.tooltip = 'Click to sort.\n\nDaily logistics bottleneck scaled to a full 500-area planetary base. The suffix indicates whether Weight (t) or Volume (m³) of Inputs (I) or Outputs (O) is the limiting bottleneck.\nSorts and percentiles are strictly normalized based on ship capacity limits (3000t or 1000m³).' + pctExplainer;
  181. } else if (keys[i] === 'market_capacity_base') {
  182. th.textContent = 'Market Cap (Bases)';
  183. 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.' + pctExplainer;
  184. } else if (keys[i] === 'break_even') {
  185. th.dataset.tooltip = 'Click to sort.\n\nBreak Even: CapEx ÷ daily profit. Note that CapEx dynamically includes optional logistics and HQ upgrades to accurately reflect operational readiness.' + pctExplainer;
  186. } else {
  187. th.dataset.tooltip = 'Click to sort.';
  188. }
  189. th.addEventListener('click', () => {
  190. if (currentSortKey === keys[i]) {
  191. currentSortAsc = !currentSortAsc;
  192. } else {
  193. currentSortKey = keys[i];
  194. currentSortAsc = keys[i] === 'break_even' ? true : false;
  195. }
  196. render();
  197. });
  198. }
  199. });
  200. headersInitialized = true;
  201. }
  202. const buildingTickers = new Set(profits.map(p => p.building));
  203. const buildings: {ticker: string, expertise: keyof typeof expertise}[] = Array.from(buildingTickers)
  204. .map((building) => ({ticker: building, expertise: profits.find(p => p.building === building)!.expertise}))
  205. .sort((a, b) => a.ticker.localeCompare(b.ticker));
  206. let selectedBuilding = buildingSelect.value || savedBuilding;
  207. let buildingFound = false;
  208. buildingSelect.innerHTML = '<option value="">(all)</option>';
  209. for (const building of buildings)
  210. if (expertiseSelect.value === '' || expertiseSelect.value === building.expertise) {
  211. const option = document.createElement('option');
  212. option.value = building.ticker;
  213. option.textContent = building.ticker;
  214. if (building.ticker === selectedBuilding) {
  215. buildingFound = true;
  216. option.selected = true;
  217. }
  218. buildingSelect.appendChild(option);
  219. }
  220. if (!buildingFound)
  221. selectedBuilding = '';
  222. savedBuilding = ''; 
  223. const filteredProfits = profits.filter(p => {
  224. const volumeRatio = p.output_per_day / p.average_traded_7d;
  225. if (!lowVolume.checked && volumeRatio > 0.05) return false;
  226. if (expertiseSelect.value !== '' && p.expertise !== expertiseSelect.value) return false;
  227. if (selectedBuilding !== '' && p.building !== selectedBuilding) return false;
  228. return true;
  229. });
  230. let profitsWithMetrics: ProfitWithMetrics[] = filteredProfits.map(p => {
  231. const bases = p.area / 500;
  232. const opex_val = p.opex[opexMetric] / bases;
  233. const revenue_val = p.revenue[revenueMetric] / bases;
  234. let capex_val = p.capex[capexMetric] / bases;
  235. let activeWorkingCapitalDays = 0;
  236. if (workingCapitalOption !== 'omit') {
  237. if (workingCapitalOption === 'dynamic') {
  238. activeWorkingCapitalDays = p.normalized_logistics_per_base > 0 
  239. ? 1 / p.normalized_logistics_per_base 
  240. : 0;
  241. } else {
  242. activeWorkingCapitalDays = parseInt(workingCapitalOption, 10);
  243. }
  244. capex_val += (opex_val * activeWorkingCapitalDays);
  245. }
  246. let hq_capex = 0;
  247. if (targetPermitOption !== 'omit') {
  248. const targetPermit = parseInt(targetPermitOption, 10);
  249. if (targetPermit >= 3) {
  250. const hqLevelStr = (targetPermit - 1).toString();
  251. if (p.hq_costs && p.hq_costs[hqLevelStr]) {
  252. hq_capex = p.hq_costs[hqLevelStr][capexMetric];
  253. capex_val += hq_capex;
  254. }
  255. }
  256. }
  257. let shipsNeeded = 0;
  258. let activeShipCapex = 0;
  259. if (roundTripOption !== 'omit') {
  260. const roundTripHours = parseInt(roundTripOption, 10);
  261. shipsNeeded = p.normalized_logistics_per_base * (roundTripHours / 24);
  262. activeShipCapex = shipsNeeded * 800_000;
  263. capex_val += activeShipCapex;
  264. }
  265. const profit_per_base = revenue_val - opex_val;
  266. const break_even = profit_per_base > 0 ? capex_val / profit_per_base : Infinity;
  267. // EXTREME DETAIL: We determine the specific market weight of this recipe line.
  268. // Multiplying the revenue (value per day per base) by the market capacity (max bases allowed)
  269. // yields the total cash flow value of all available trades in the global FIO market for this bottleneck.
  270. const market_cash_flow = revenue_val * p.market_capacity_base;
  271. return { 
  272. ...p, 
  273. capex_val, 
  274. opex_val, 
  275. revenue_val, 
  276. profit_per_base, 
  277. break_even, 
  278. hq_capex,
  279. activeWorkingCapitalDays,
  280. activeShipCapex,
  281. shipsNeeded,
  282. market_cash_flow
  283. };
  284. });
  285. if (!showNegativeProfit) {
  286. profitsWithMetrics = profitsWithMetrics.filter(p => p.profit_per_base > 0);
  287. }
  288. // EXTREME DETAIL: Overhauled the extraction arrays to store both the numerical value AND the weight parameter.
  289. const numSortObj = (a: {val: number, weight: number}, b: {val: number, weight: number}) => (a.val < b.val ? -1 : a.val > b.val ? 1 : 0);
  290. const arrProfit = profitsWithMetrics.map(p => ({val: p.profit_per_base, weight: p.market_cash_flow})).sort(numSortObj);
  291. const arrBreak = profitsWithMetrics.map(p => ({val: p.break_even, weight: p.market_cash_flow})).sort(numSortObj);
  292. const arrCapex = profitsWithMetrics.map(p => ({val: p.capex_val, weight: p.market_cash_flow})).sort(numSortObj);
  293. const arrOpex = profitsWithMetrics.map(p => ({val: p.opex_val, weight: p.market_cash_flow})).sort(numSortObj);
  294. const arrLog = profitsWithMetrics.map(p => ({val: p.normalized_logistics_per_base, weight: p.market_cash_flow})).sort(numSortObj);
  295. const arrCap = profitsWithMetrics.map(p => ({val: p.market_capacity_base, weight: p.market_cash_flow})).sort(numSortObj);
  296. profitsWithMetrics.sort((a, b) => {
  297. let valA: any = a[currentSortKey as keyof ProfitWithMetrics];
  298. let valB: any = b[currentSortKey as keyof ProfitWithMetrics];
  299. if (currentSortKey === 'outputs') {
  300. valA = a.outputs.map(o => o.ticker).join(', ');
  301. valB = b.outputs.map(o => o.ticker).join(', ');
  302. }
  303. if (valA < valB) return currentSortAsc ? -1 : 1;
  304. if (valA > valB) return currentSortAsc ? 1 : -1;
  305. return 0;
  306. });
  307. for (const p of profitsWithMetrics) {
  308. const tr = document.createElement('tr');
  309. // Map the raw value to the selected percentile rank.
  310. const pctProfit = getPercentiles(p.profit_per_base, arrProfit, false)[percentileMode === 'absolute' ? 'abs' : 'rel'];
  311. const pctBreak = getPercentiles(p.break_even, arrBreak, true)[percentileMode === 'absolute' ? 'abs' : 'rel'];
  312. const pctCapex = getPercentiles(p.capex_val, arrCapex, true)[percentileMode === 'absolute' ? 'abs' : 'rel'];
  313. const pctOpex = getPercentiles(p.opex_val, arrOpex, true)[percentileMode === 'absolute' ? 'abs' : 'rel'];
  314. const pctLog = getPercentiles(p.normalized_logistics_per_base, arrLog, true)[percentileMode === 'absolute' ? 'abs' : 'rel'];
  315. const pctCap = getPercentiles(p.market_capacity_base, arrCap, false)[percentileMode === 'absolute' ? 'abs' : 'rel'];
  316. // Interplate the format string requested directly into the <small> span wrapper.
  317. // EXTREME DETAIL: Replaced all space characters right before the <span> wrappers with <br> tags
  318. // to guarantee consistent vertical stacking of the main value and the percentile regardless of CSS flex/wrapping behaviors.
  319. tr.innerHTML = `
  320. <td>${p.outputs.map(o => o.ticker).join(', ')}</td>
  321. <td>${expertise[p.expertise]}</td>
  322. <td style="color: ${color(p.profit_per_base, 0, 150000)}">${formatSigFig(p.profit_per_base)}<br><span style="font-size: 0.85em; opacity: 0.6;">${pctProfit}</span></td>
  323. <td><span style="color: ${color(p.break_even, 30, 3)}">${formatSigFig(p.break_even)}</span>d<br><span style="font-size: 0.85em; opacity: 0.6;">${pctBreak}</span></td>
  324. <td style="color: ${color(p.capex_val, 3_000_000, 400_000)}">${formatSigFig(p.capex_val)}<br><span style="font-size: 0.85em; opacity: 0.6;">${pctCapex}</span></td>
  325. <td style="color: ${color(p.opex_val, 400_000, 10_000)}">${formatSigFig(p.opex_val)}<br><span style="font-size: 0.85em; opacity: 0.6;">${pctOpex}</span></td>
  326. <td style="color: ${color(p.normalized_logistics_per_base, 1.0, 0.1)}">${formatSigFig(p.logistics_per_base)} ${p.logistics_bottleneck}<br><span style="font-size: 0.85em; opacity: 0.6;">${pctLog}</span></td>
  327. <td style="color: ${color(p.market_capacity_base, 0.04, 1)}">${formatSigFig(p.market_capacity_base)}<br><span style="font-size: 0.85em; opacity: 0.6;">${pctCap}</span></td>
  328. `;
  329. const output = tr.querySelector('td')!;
  330. output.dataset.tooltip = p.recipe;
  331. const profitCell = tr.querySelectorAll('td')[2];
  332. const runs_per_base = p.runs_per_day / (p.area / 500);
  333. profitCell.dataset.tooltip = formatMatPrices(p.outputs, revenueMetric, runs_per_base) + '\n\n' +
  334. formatMatPrices(p.input_costs, opexMetric, runs_per_base) + '\n' +
  335. '+ worker consumables\n\n' +
  336. `(${formatSigFig(p.revenue_val)} - ${formatSigFig(p.opex_val)}) = ${formatSigFig(p.profit_per_base)}`;
  337. const capexCell = tr.querySelectorAll('td')[4];
  338. capexCell.dataset.tooltip = `Base Construction: ${formatSigFig(p.capex[capexMetric] / (p.area / 500))}`;
  339. if (workingCapitalOption !== 'omit') {
  340. capexCell.dataset.tooltip += `\nWorking Capital (${formatSigFig(p.activeWorkingCapitalDays)} days): ${formatSigFig(p.opex_val * p.activeWorkingCapitalDays)}`;
  341. }
  342. if (targetPermitOption !== 'omit' && p.hq_capex > 0) {
  343. capexCell.dataset.tooltip += `\nHQ Upgrade (Permit ${targetPermitOption}): ${formatSigFig(p.hq_capex)}`;
  344. }
  345. if (roundTripOption !== 'omit') {
  346. capexCell.dataset.tooltip += `\nShip CapEx: ${formatSigFig(p.activeShipCapex)} (${formatSigFig(p.shipsNeeded)} ships)`;
  347. }
  348. const marketCell = tr.querySelectorAll('td')[7];
  349. marketCell.dataset.tooltip = `Market Capacity: ${formatSigFig(p.average_traded_7d)} traded/day ÷ ${formatSigFig(p.output_per_day / (p.area / 500))} produced/day/base = ${formatSigFig(p.market_capacity_base)} equivalent bases`;
  350. tbody.appendChild(tr);
  351. }
  352. document.getElementById('last-updated')!.textContent =
  353. `last updated: ${lastModified.toLocaleString(undefined, {dateStyle: 'full', timeStyle: 'long', hour12: false})}`;
  354. saveState();
  355. }
  356. // EXTREME DETAIL: Overhauled function to calculate both Absolute and Relative Volume-Weighted Percentiles.
  357. // To ensure the mathematical max bounds cleanly hit 100.0%, the denominator is extracted dynamically 
  358. // relative to the highest data point present in the array.
  359. function getPercentiles(val: number, sortedArr: {val: number, weight: number}[], invert: boolean = false): {abs: string, rel: string} {
  360. if (sortedArr.length < 2) return {abs: "100.0%", rel: "100.0%"};
  361. let lessCount = 0;
  362. let lessWeight = 0;
  363. for (let i = 0; i < sortedArr.length; i++) {
  364. if (sortedArr[i].val < val) {
  365. lessCount++;
  366. lessWeight += sortedArr[i].weight;
  367. } else {
  368. break; 
  369. }
  370. }
  371. const maxVal = sortedArr[sortedArr.length - 1].val;
  372. let maxLessCount = 0;
  373. let maxLessWeight = 0;
  374. for (let i = 0; i < sortedArr.length; i++) {
  375. if (sortedArr[i].val < maxVal) {
  376. maxLessCount++;
  377. maxLessWeight += sortedArr[i].weight;
  378. } else {
  379. break;
  380. }
  381. }
  382. let absDecimal = maxLessCount > 0 ? lessCount / maxLessCount : 1.0;
  383. let relDecimal = maxLessWeight > 0 ? lessWeight / maxLessWeight : 1.0;
  384. if (invert) {
  385. absDecimal = 1.0 - absDecimal;
  386. relDecimal = 1.0 - relDecimal;
  387. }
  388. return {
  389. abs: (absDecimal * 100).toFixed(1) + "%",
  390. rel: (relDecimal * 100).toFixed(1) + "%"
  391. };
  392. }
  393. function saveState() {
  394. localStorage.setItem('roi-cx', cxSelect.value);
  395. localStorage.setItem('roi-expertise', expertiseSelect.value);
  396. localStorage.setItem('roi-building', buildingSelect.value);
  397. localStorage.setItem('roi-low-volume', lowVolume.checked.toString());
  398. localStorage.setItem('roi-sort-key', currentSortKey);
  399. localStorage.setItem('roi-sort-asc', currentSortAsc.toString());
  400. localStorage.setItem('roi-capex-metric', capexMetric);
  401. localStorage.setItem('roi-opex-metric', opexMetric);
  402. localStorage.setItem('roi-revenue-metric', revenueMetric);
  403. localStorage.setItem('roi-show-negative', showNegativeProfit.toString());
  404. localStorage.setItem('roi-round-trip', roundTripOption);
  405. localStorage.setItem('roi-working-capital-opt', workingCapitalOption);
  406. localStorage.setItem('roi-target-permit', targetPermitOption);
  407. localStorage.setItem('roi-pct-mode', percentileMode);
  408. }
  409. function color(n: number, low: number, high: number): string {
  410. const scale = Math.min(Math.max((n - low) / (high - low), 0), 1);
  411. return `color-mix(in xyz, #0aa ${scale * 100}%, #f80)`;
  412. }
  413. function formatMatPrices(matPrices: MatPrice[], metric: MetricType, runs_per_day: number): string {
  414. return matPrices.map(({ticker, amount, vwap_7d, bid, ask}) => {
  415. const val = metric === 'vwap' ? vwap_7d : metric === 'bid' ? (bid ?? vwap_7d) : (ask ?? vwap_7d);
  416. const daily_amount = amount * runs_per_day;
  417. return `${ticker}: ${formatSigFig(daily_amount)} × ${formatSigFig(val)} = ${formatSigFig(daily_amount * val)}`;
  418. }).join('\n');
  419. }
  420. // EXTREME DETAIL: Injects a comprehensive, casually written documentation section at the bottom of the page.
  421. // This demystifies the PrUn API data pipeline and exposes the raw formulas used for the table's metrics.
  422. function initDocumentation() {
  423. // Prevent duplicate documentation blocks if initialized multiple times (e.g. HMR or weird state)
  424. if (document.getElementById('methodology-doc')) return;
  425. const docDetails = document.createElement('details');
  426. docDetails.id = 'methodology-doc';
  427. docDetails.style.marginTop = '40px';
  428. docDetails.style.marginBottom = '40px';
  429. docDetails.style.padding = '15px';
  430. docDetails.style.border = '1px solid #444';
  431. docDetails.style.borderRadius = '8px';
  432. docDetails.style.backgroundColor = 'rgba(128, 128, 128, 0.1)';
  433. docDetails.innerHTML = `
  434. <summary style="cursor: pointer; font-size: 1.25em; font-weight: bold;">📖 Documentation: How These Numbers Are Calculated</summary>
  435. <div style="margin-top: 20px; line-height: 1.6; font-size: 0.95em;">
  436. <p>This tool pulls live data from the PrUn APIs and calculates the true profitability of every recipe in the game. Here is the step-by-step math from the raw data to your screen:</p>
  437. <h3 style="margin-top: 1.5em;">1. The "Base" Normalization</h3>
  438. <p>To make fair comparisons between a tiny Farm and a massive Shipyard, all metrics are scaled to a standard <strong>500-area planetary base</strong>. First, we calculate the true area of a building by adding its base area footprint to the area of the habitation modules required for its specific workers. Then, we divide 500 by this true area to figure out exactly how many of these fully-staffed buildings fit on one planet.</p>
  439. <h3 style="margin-top: 1.5em;">2. Revenue & OpEx (Operational Expenditure)</h3>
  440. <p><strong>Revenue:</strong> Daily output amount × Runs per day × Selected Price (VWAP, Bid, or Ask).</p>
  441. <p><strong>OpEx:</strong> (Daily input amount × Selected Price) + <strong>Worker Consumables</strong>. The consumables cost is calculated by looking up the exact daily drinking water, rations, luxury goods, etc., needed to keep your specific workforce alive and operating at 100%.</p>
  442. <p><strong>Profit/Base:</strong> Total Daily Revenue - Total Daily OpEx.</p>
  443. <h3 style="margin-top: 1.5em;">3. CapEx (Capital Expenditure) & Break Even</h3>
  444. <p><strong>CapEx</strong> starts with the exact material costs to construct the buildings (plus MCG/BSE/etc. for the planetary surface). Using the dropdown toggles at the top, it dynamically expands to include:</p>
  445. <ul style="margin-top: 0.5em; margin-bottom: 0.5em; padding-left: 20px;">
  446. <li style="margin-bottom: 0.5em;"><strong>Working Capital:</strong> Adds your daily OpEx multiplied by the number of days you need to buffer in a warehouse.</li>
  447. <li style="margin-bottom: 0.5em;"><strong>HQ Upgrades:</strong> Adds the material costs of upgrading your Headquarters to the selected Permit level.</li>
  448. <li style="margin-bottom: 0.5em;"><strong>Ship CapEx:</strong> Adds a flat 800,000 CIS per ship required to move your goods (calculated dynamically based on your Logistics Bottleneck and the Round Trip time).</li>
  449. </ul>
  450. <p><strong>Break Even:</strong> Total CapEx ÷ Daily Profit. This tells you exactly how many days it takes for the entire setup to pay for itself.</p>
  451. <h3 style="margin-top: 1.5em;">4. Logistics Bottleneck</h3>
  452. <p>Every recipe requires shipping inputs in and outputs out. We calculate the total Weight (t) and Volume (m³) for both. We then divide these by a standard ship's capacity (3,000t or 1,000m³). The highest resulting number is your <strong>Logistics Bottleneck</strong>. For example, if a recipe fills 1.5 ships per day with output volume, your bottleneck is "1.5 m³ (O)".</p>
  453. <h3 style="margin-top: 1.5em;">5. Market Capacity (Saturation)</h3>
  454. <p>Even if a recipe is insanely profitable, it doesn't matter if nobody is buying it. We divide the <strong>7-Day Average Traded Volume</strong> on the selected exchange by the <strong>Daily Output of a Full 500-Area Base</strong>. If the Market Capacity is "0.5", it means building just half a base will completely saturate the market and crash the price.</p>
  455. <h3 style="margin-top: 1.5em;">6. Percentiles (Normalized vs Absolute)</h3>
  456. <p>Percentiles rank every recipe from 0.0% (worst) to 100.0% (best).<br>
  457. <strong>Absolute</strong> percentiles treat every recipe equally (e.g., 1 vote per recipe).<br>
  458. <strong>Normalized</strong> percentiles weight each recipe by its <em>Market Cash Flow</em> (Revenue × Market Capacity). This prevents thinly traded, obscure recipes from artificially skewing the rankings, giving you a much more realistic view of the overall economy.</p>
  459. </div>
  460. `;
  461. const table = document.querySelector('table');
  462. if (table && table.parentNode) {
  463. table.parentNode.appendChild(docDetails);
  464. } else {
  465. document.body.appendChild(docDetails);
  466. }
  467. }
  468. setupPopover();
  469. lowVolume.addEventListener('change', render);
  470. cxSelect.addEventListener('change', render);
  471. expertiseSelect.addEventListener('change', render);
  472. buildingSelect.addEventListener('change', render);
  473. render();
  474. initDocumentation();
  475. interface Metrics {
  476. vwap: number;
  477. bid: number;
  478. ask: number;
  479. }
  480. interface Profit {
  481. outputs: MatPrice[]
  482. recipe: string
  483. expertise: keyof typeof expertise
  484. building: string
  485. area: number
  486. capex: Metrics
  487. opex: Metrics
  488. revenue: Metrics
  489. input_costs: MatPrice[]
  490. runs_per_day: number
  491. logistics_per_base: number
  492. normalized_logistics_per_base: number
  493. logistics_bottleneck: string
  494. output_per_day: number
  495. average_traded_7d: number
  496. market_capacity_base: number
  497. hq_costs: Record<string, Metrics>
  498. }
  499. interface ProfitWithMetrics extends Profit {
  500. capex_val: number;
  501. opex_val: number;
  502. revenue_val: number;
  503. profit_per_day: number;
  504. profit_per_base: number;
  505. break_even: number;
  506. hq_capex: number;
  507. activeWorkingCapitalDays: number;
  508. activeShipCapex: number;
  509. shipsNeeded: number;
  510. market_cash_flow: number; // Exported weight tracker
  511. }
  512. interface MatPrice {
  513. ticker: string
  514. amount: number
  515. vwap_7d: number
  516. bid: number | null
  517. ask: number | null
  518. }
  519. interface Building {
  520. building_type: 'INFRASTRUCTURE' | 'PLANETARY' | 'PRODUCTION';
  521. building_ticker: string;
  522. expertise: keyof typeof expertise;
  523. }