roi.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  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. async function render() {
  53. const tbody = document.querySelector('tbody')!;
  54. tbody.innerHTML = '';
  55. const cx = cxSelect.value;
  56. if (!roiCache[cx])
  57. roiCache[cx] = getROI(cx);
  58. const {lastModified, profits} = await roiCache[cx];
  59. if (!metricControlsInitialized) {
  60. const controls = document.createElement('div');
  61. controls.style.marginBottom = '15px';
  62. controls.innerHTML = `
  63. <label style="margin-right: 15px;">CapEx Price:
  64. <select id="capex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
  65. </label>
  66. <label style="margin-right: 15px;">OpEx Price:
  67. <select id="opex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
  68. </label>
  69. <label style="margin-right: 15px;">Revenue Price:
  70. <select id="revenue-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
  71. </label>
  72. <label style="margin-right: 15px;">
  73. <input type="checkbox" id="show-negative"> Show Negative Profit
  74. </label>
  75. <label style="margin-right: 15px;">
  76. Round Trip (hrs):
  77. <select id="round-trip">
  78. <option value="omit">Omit From Calculation</option>
  79. ${Array.from({length: 25}, (_, i) => `<option value="${i + 1}">${i + 1}</option>`).join('')}
  80. </select>
  81. </label>
  82. <label style="margin-right: 15px;">
  83. Days OpEx:
  84. <select id="working-capital">
  85. <option value="omit">Omit From Calculation</option>
  86. <option value="dynamic">Max for Shipment (dynamic)</option>
  87. <option value="1">1</option>
  88. <option value="2">2</option>
  89. <option value="3">3</option>
  90. <option value="4">4</option>
  91. <option value="5">5</option>
  92. <option value="6">6</option>
  93. <option value="7">7</option>
  94. <option value="14">14</option>
  95. <option value="30">30</option>
  96. </select>
  97. </label>
  98. <label>
  99. Permit Number:
  100. <select id="target-permit">
  101. <option value="omit">Omit From Calculation</option>
  102. ${Array.from({length: 49}, (_, i) => `<option value="${i + 2}">${i + 2}</option>`).join('')}
  103. </select>
  104. </label>
  105. `;
  106. const table = document.querySelector('table');
  107. if (table) table.parentNode?.insertBefore(controls, table);
  108. (document.getElementById('capex-metric') as HTMLSelectElement).value = capexMetric;
  109. (document.getElementById('opex-metric') as HTMLSelectElement).value = opexMetric;
  110. (document.getElementById('revenue-metric') as HTMLSelectElement).value = revenueMetric;
  111. (document.getElementById('show-negative') as HTMLInputElement).checked = showNegativeProfit;
  112. (document.getElementById('round-trip') as HTMLSelectElement).value = roundTripOption;
  113. (document.getElementById('working-capital') as HTMLSelectElement).value = workingCapitalOption;
  114. (document.getElementById('target-permit') as HTMLSelectElement).value = targetPermitOption;
  115. document.getElementById('capex-metric')!.addEventListener('change', (e) => {
  116. capexMetric = (e.target as HTMLSelectElement).value as MetricType;
  117. render();
  118. });
  119. document.getElementById('opex-metric')!.addEventListener('change', (e) => {
  120. opexMetric = (e.target as HTMLSelectElement).value as MetricType;
  121. render();
  122. });
  123. document.getElementById('revenue-metric')!.addEventListener('change', (e) => {
  124. revenueMetric = (e.target as HTMLSelectElement).value as MetricType;
  125. render();
  126. });
  127. document.getElementById('show-negative')!.addEventListener('change', (e) => {
  128. showNegativeProfit = (e.target as HTMLInputElement).checked;
  129. render();
  130. });
  131. document.getElementById('round-trip')!.addEventListener('change', (e) => {
  132. roundTripOption = (e.target as HTMLSelectElement).value;
  133. render();
  134. });
  135. document.getElementById('working-capital')!.addEventListener('change', (e) => {
  136. workingCapitalOption = (e.target as HTMLSelectElement).value;
  137. render();
  138. });
  139. document.getElementById('target-permit')!.addEventListener('change', (e) => {
  140. targetPermitOption = (e.target as HTMLSelectElement).value;
  141. render();
  142. });
  143. metricControlsInitialized = true;
  144. }
  145. if (!headersInitialized) {
  146. const ths = document.querySelectorAll('th');
  147. const keys: (keyof ProfitWithMetrics | 'outputs')[] = [
  148. 'outputs', 'expertise', 'profit_per_base', 'break_even',
  149. 'capex_val', 'opex_val', 'normalized_logistics_per_base', 'market_capacity_base'
  150. ];
  151. const pctExplainer = '\n(Percentiles: Relative Volume / Absolute. 100.0% is the most desirable outcome. Relative rank is weighted by total market cash flow.)';
  152. ths.forEach((th, i) => {
  153. if (keys[i]) {
  154. th.style.cursor = 'pointer';
  155. th.title = '';
  156. if (keys[i] === 'profit_per_base') {
  157. th.textContent = 'Profit/Base';
  158. th.dataset.tooltip = 'Click to sort.\n\nDaily profit scaled to a full 500-area planetary base.' + pctExplainer;
  159. } else if (keys[i] === 'capex_val') {
  160. th.textContent = 'CapEx/Base';
  161. 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;
  162. } else if (keys[i] === 'opex_val') {
  163. th.textContent = 'OpEx/Base';
  164. th.dataset.tooltip = 'Click to sort.\n\nDaily operational expenditure (input materials + worker consumables) scaled to a full 500-area planetary base.' + pctExplainer;
  165. } else if (keys[i] === 'normalized_logistics_per_base') {
  166. th.textContent = 'Logistics/Base';
  167. 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;
  168. } else if (keys[i] === 'market_capacity_base') {
  169. th.textContent = 'Market Cap (Bases)';
  170. 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;
  171. } else if (keys[i] === 'break_even') {
  172. 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;
  173. } else {
  174. th.dataset.tooltip = 'Click to sort.';
  175. }
  176. th.addEventListener('click', () => {
  177. if (currentSortKey === keys[i]) {
  178. currentSortAsc = !currentSortAsc;
  179. } else {
  180. currentSortKey = keys[i];
  181. currentSortAsc = keys[i] === 'break_even' ? true : false;
  182. }
  183. render();
  184. });
  185. }
  186. });
  187. headersInitialized = true;
  188. }
  189. const buildingTickers = new Set(profits.map(p => p.building));
  190. const buildings: {ticker: string, expertise: keyof typeof expertise}[] = Array.from(buildingTickers)
  191. .map((building) => ({ticker: building, expertise: profits.find(p => p.building === building)!.expertise}))
  192. .sort((a, b) => a.ticker.localeCompare(b.ticker));
  193. let selectedBuilding = buildingSelect.value || savedBuilding;
  194. let buildingFound = false;
  195. buildingSelect.innerHTML = '<option value="">(all)</option>';
  196. for (const building of buildings)
  197. if (expertiseSelect.value === '' || expertiseSelect.value === building.expertise) {
  198. const option = document.createElement('option');
  199. option.value = building.ticker;
  200. option.textContent = building.ticker;
  201. if (building.ticker === selectedBuilding) {
  202. buildingFound = true;
  203. option.selected = true;
  204. }
  205. buildingSelect.appendChild(option);
  206. }
  207. if (!buildingFound)
  208. selectedBuilding = '';
  209. savedBuilding = '';
  210. const filteredProfits = profits.filter(p => {
  211. savedBuilding = '';
  212. const filteredProfits = profits.filter(p => {
  213. const volumeRatio = p.output_per_day / p.average_traded_7d;
  214. if (!lowVolume.checked && volumeRatio > 0.05) return false;
  215. if (expertiseSelect.value !== '' && p.expertise !== expertiseSelect.value) return false;
  216. if (selectedBuilding !== '' && p.building !== selectedBuilding) return false;
  217. return true;
  218. });
  219. let profitsWithMetrics: ProfitWithMetrics[] = filteredProfits.map(p => {
  220. const bases = p.area / 500;
  221. const opex_val = p.opex[opexMetric] / bases;
  222. const revenue_val = p.revenue[revenueMetric] / bases;
  223. let capex_val = p.capex[capexMetric] / bases;
  224. let activeWorkingCapitalDays = 0;
  225. if (workingCapitalOption !== 'omit') {
  226. if (workingCapitalOption === 'dynamic') {
  227. activeWorkingCapitalDays = p.normalized_logistics_per_base > 0
  228. ? 1 / p.normalized_logistics_per_base
  229. : 0;
  230. } else {
  231. activeWorkingCapitalDays = parseInt(workingCapitalOption, 10);
  232. }
  233. capex_val += (opex_val * activeWorkingCapitalDays);
  234. }
  235. let hq_capex = 0;
  236. if (targetPermitOption !== 'omit') {
  237. const targetPermit = parseInt(targetPermitOption, 10);
  238. if (targetPermit >= 3) {
  239. const hqLevelStr = (targetPermit - 1).toString();
  240. if (p.hq_costs && p.hq_costs[hqLevelStr]) {
  241. hq_capex = p.hq_costs[hqLevelStr][capexMetric];
  242. capex_val += hq_capex;
  243. }
  244. }
  245. }
  246. let shipsNeeded = 0;
  247. let activeShipCapex = 0;
  248. if (roundTripOption !== 'omit') {
  249. const roundTripHours = parseInt(roundTripOption, 10);
  250. shipsNeeded = p.normalized_logistics_per_base * (roundTripHours / 24);
  251. activeShipCapex = shipsNeeded * 800_000;
  252. capex_val += activeShipCapex;
  253. }
  254. const profit_per_base = revenue_val - opex_val;
  255. const break_even = profit_per_base > 0 ? capex_val / profit_per_base : Infinity;
  256. // EXTREME DETAIL: We determine the specific market weight of this recipe line.
  257. // Multiplying the revenue (value per day per base) by the market capacity (max bases allowed)
  258. // yields the total cash flow value of all available trades in the global FIO market for this bottleneck.
  259. const market_cash_flow = revenue_val * p.market_capacity_base;
  260. return {
  261. ...p,
  262. capex_val,
  263. opex_val,
  264. revenue_val,
  265. profit_per_base,
  266. break_even,
  267. hq_capex,
  268. activeWorkingCapitalDays,
  269. activeShipCapex,
  270. shipsNeeded,
  271. market_cash_flow
  272. };
  273. });
  274. if (!showNegativeProfit) {
  275. profitsWithMetrics = profitsWithMetrics.filter(p => p.profit_per_base > 0);
  276. }
  277. // EXTREME DETAIL: Overhauled the extraction arrays to store both the numerical value AND the weight parameter.
  278. const numSortObj = (a: {val: number, weight: number}, b: {val: number, weight: number}) => (a.val < b.val ? -1 : a.val > b.val ? 1 : 0);
  279. const arrProfit = profitsWithMetrics.map(p => ({val: p.profit_per_base, weight: p.market_cash_flow})).sort(numSortObj);
  280. const arrBreak = profitsWithMetrics.map(p => ({val: p.break_even, weight: p.market_cash_flow})).sort(numSortObj);
  281. const arrCapex = profitsWithMetrics.map(p => ({val: p.capex_val, weight: p.market_cash_flow})).sort(numSortObj);
  282. const arrOpex = profitsWithMetrics.map(p => ({val: p.opex_val, weight: p.market_cash_flow})).sort(numSortObj);
  283. const arrLog = profitsWithMetrics.map(p => ({val: p.normalized_logistics_per_base, weight: p.market_cash_flow})).sort(numSortObj);
  284. const arrCap = profitsWithMetrics.map(p => ({val: p.market_capacity_base, weight: p.market_cash_flow})).sort(numSortObj);
  285. profitsWithMetrics.sort((a, b) => {
  286. let valA: any = a[currentSortKey as keyof ProfitWithMetrics];
  287. let valB: any = b[currentSortKey as keyof ProfitWithMetrics];
  288. if (currentSortKey === 'outputs') {
  289. valA = a.outputs.map(o => o.ticker).join(', ');
  290. valB = b.outputs.map(o => o.ticker).join(', ');
  291. }
  292. if (valA < valB) return currentSortAsc ? -1 : 1;
  293. if (valA > valB) return currentSortAsc ? 1 : -1;
  294. return 0;
  295. });
  296. for (const p of profitsWithMetrics) {
  297. const tr = document.createElement('tr');
  298. // Map the raw value to both absolute and relative percentile ranks.
  299. const pctProfit = getPercentiles(p.profit_per_base, arrProfit, false);
  300. const pctBreak = getPercentiles(p.break_even, arrBreak, true);
  301. const pctCapex = getPercentiles(p.capex_val, arrCapex, true);
  302. const pctOpex = getPercentiles(p.opex_val, arrOpex, true);
  303. const pctLog = getPercentiles(p.normalized_logistics_per_base, arrLog, true);
  304. const pctCap = getPercentiles(p.market_capacity_base, arrCap, false);
  305. // Interplate the Rel/Abs format string requested directly into the <small> span wrapper.
  306. tr.innerHTML = `
  307. <td>${p.outputs.map(o => o.ticker).join(', ')}</td>
  308. <td>${expertise[p.expertise]}</td>
  309. <td style="color: ${color(p.profit_per_base, 0, 150000)}">${formatSigFig(p.profit_per_base)} <span style="font-size: 0.85em; opacity: 0.6;">(${pctProfit.rel} / ${pctProfit.abs})</span></td>
  310. <td><span style="color: ${color(p.break_even, 30, 3)}">${formatSigFig(p.break_even)}</span>d <span style="font-size: 0.85em; opacity: 0.6;">(${pctBreak.rel} / ${pctBreak.abs})</span></td>
  311. <td style="color: ${color(p.capex_val, 3_000_000, 400_000)}">${formatSigFig(p.capex_val)} <span style="font-size: 0.85em; opacity: 0.6;">(${pctCapex.rel} / ${pctCapex.abs})</span></td>
  312. <td style="color: ${color(p.opex_val, 400_000, 10_000)}">${formatSigFig(p.opex_val)} <span style="font-size: 0.85em; opacity: 0.6;">(${pctOpex.rel} / ${pctOpex.abs})</span></td>
  313. <td style="color: ${color(p.normalized_logistics_per_base, 1.0, 0.1)}">${formatSigFig(p.logistics_per_base)} ${p.logistics_bottleneck} <span style="font-size: 0.85em; opacity: 0.6;">(${pctLog.rel} / ${pctLog.abs})</span></td>
  314. <td style="color: ${color(p.market_capacity_base, 0.04, 1)}">${formatSigFig(p.market_capacity_base)} <span style="font-size: 0.85em; opacity: 0.6;">(${pctCap.rel} / ${pctCap.abs})</span></td>
  315. `;
  316. const output = tr.querySelector('td')!;
  317. output.dataset.tooltip = p.recipe;
  318. const profitCell = tr.querySelectorAll('td')[2];
  319. const runs_per_base = p.runs_per_day / (p.area / 500);
  320. profitCell.dataset.tooltip = formatMatPrices(p.outputs, revenueMetric, runs_per_base) + '\n\n' +
  321. formatMatPrices(p.input_costs, opexMetric, runs_per_base) + '\n' +
  322. '+ worker consumables\n\n' +
  323. `(${formatSigFig(p.revenue_val)} - ${formatSigFig(p.opex_val)}) = ${formatSigFig(p.profit_per_base)}`;
  324. const capexCell = tr.querySelectorAll('td')[4];
  325. capexCell.dataset.tooltip = `Base Construction: ${formatSigFig(p.capex[capexMetric] / (p.area / 500))}`;
  326. if (workingCapitalOption !== 'omit') {
  327. capexCell.dataset.tooltip += `\nWorking Capital (${formatSigFig(p.activeWorkingCapitalDays)} days): ${formatSigFig(p.opex_val * p.activeWorkingCapitalDays)}`;
  328. }
  329. if (targetPermitOption !== 'omit' && p.hq_capex > 0) {
  330. capexCell.dataset.tooltip += `\nHQ Upgrade (Permit ${targetPermitOption}): ${formatSigFig(p.hq_capex)}`;
  331. }
  332. if (roundTripOption !== 'omit') {
  333. capexCell.dataset.tooltip += `\nShip CapEx: ${formatSigFig(p.activeShipCapex)} (${formatSigFig(p.shipsNeeded)} ships)`;
  334. }
  335. const marketCell = tr.querySelectorAll('td')[7];
  336. 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`;
  337. const runs_per_base = p.runs_per_day / (p.area / 500);
  338. profitCell.dataset.tooltip = formatMatPrices(p.outputs, revenueMetric, runs_per_base) + '\n\n' +
  339. formatMatPrices(p.input_costs, opexMetric, runs_per_base) + '\n' +
  340. '+ worker consumables\n\n' +
  341. `(${formatSigFig(p.revenue_val)} - ${formatSigFig(p.opex_val)}) = ${formatSigFig(p.profit_per_base)}`;
  342. const capexCell = tr.querySelectorAll('td')[4];
  343. capexCell.dataset.tooltip = `Base Construction: ${formatSigFig(p.capex[capexMetric] / (p.area / 500))}`;
  344. if (workingCapitalOption !== 'omit') {
  345. capexCell.dataset.tooltip += `\nWorking Capital (${formatSigFig(p.activeWorkingCapitalDays)} days): ${formatSigFig(p.opex_val * p.activeWorkingCapitalDays)}`;
  346. }
  347. if (targetPermitOption !== 'omit' && p.hq_capex > 0) {
  348. capexCell.dataset.tooltip += `\nHQ Upgrade (Permit ${targetPermitOption}): ${formatSigFig(p.hq_capex)}`;
  349. }
  350. if (roundTripOption !== 'omit') {
  351. capexCell.dataset.tooltip += `\nShip CapEx: ${formatSigFig(p.activeShipCapex)} (${formatSigFig(p.shipsNeeded)} ships)`;
  352. }
  353. const marketCell = tr.querySelectorAll('td')[7];
  354. 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`;
  355. tbody.appendChild(tr);
  356. }
  357. document.getElementById('last-updated')!.textContent =
  358. `last updated: ${lastModified.toLocaleString(undefined, {dateStyle: 'full', timeStyle: 'long', hour12: false})}`;
  359. saveState();
  360. }
  361. // EXTREME DETAIL: Overhauled function to calculate both Absolute and Relative Volume-Weighted Percentiles.
  362. // To ensure the mathematical max bounds cleanly hit 100.0%, the denominator is extracted dynamically
  363. // relative to the highest data point present in the array.
  364. function getPercentiles(val: number, sortedArr: {val: number, weight: number}[], invert: boolean = false): {abs: string, rel: string} {
  365. if (sortedArr.length < 2) return {abs: "100.0%", rel: "100.0%"};
  366. let lessCount = 0;
  367. let lessWeight = 0;
  368. for (let i = 0; i < sortedArr.length; i++) {
  369. if (sortedArr[i].val < val) {
  370. lessCount++;
  371. lessWeight += sortedArr[i].weight;
  372. } else {
  373. break;
  374. }
  375. }
  376. const maxVal = sortedArr[sortedArr.length - 1].val;
  377. let maxLessCount = 0;
  378. let maxLessWeight = 0;
  379. for (let i = 0; i < sortedArr.length; i++) {
  380. if (sortedArr[i].val < maxVal) {
  381. maxLessCount++;
  382. maxLessWeight += sortedArr[i].weight;
  383. } else {
  384. break;
  385. }
  386. }
  387. let absDecimal = maxLessCount > 0 ? lessCount / maxLessCount : 1.0;
  388. let relDecimal = maxLessWeight > 0 ? lessWeight / maxLessWeight : 1.0;
  389. if (invert) {
  390. absDecimal = 1.0 - absDecimal;
  391. relDecimal = 1.0 - relDecimal;
  392. }
  393. return {
  394. abs: (absDecimal * 100).toFixed(1) + "%",
  395. rel: (relDecimal * 100).toFixed(1) + "%"
  396. };
  397. }
  398. function saveState() {
  399. localStorage.setItem('roi-cx', cxSelect.value);
  400. localStorage.setItem('roi-expertise', expertiseSelect.value);
  401. localStorage.setItem('roi-building', buildingSelect.value);
  402. localStorage.setItem('roi-low-volume', lowVolume.checked.toString());
  403. localStorage.setItem('roi-sort-key', currentSortKey);
  404. localStorage.setItem('roi-sort-asc', currentSortAsc.toString());
  405. localStorage.setItem('roi-capex-metric', capexMetric);
  406. localStorage.setItem('roi-opex-metric', opexMetric);
  407. localStorage.setItem('roi-revenue-metric', revenueMetric);
  408. localStorage.setItem('roi-show-negative', showNegativeProfit.toString());
  409. localStorage.setItem('roi-round-trip', roundTripOption);
  410. localStorage.setItem('roi-working-capital-opt', workingCapitalOption);
  411. localStorage.setItem('roi-target-permit', targetPermitOption);
  412. }
  413. function color(n: number, low: number, high: number): string {
  414. const scale = Math.min(Math.max((n - low) / (high - low), 0), 1);
  415. return `color-mix(in xyz, #0aa ${scale * 100}%, #f80)`;
  416. }
  417. function formatMatPrices(matPrices: MatPrice[], metric: MetricType, runs_per_day: number): string {
  418. return matPrices.map(({ticker, amount, vwap_7d, bid, ask}) => {
  419. const val = metric === 'vwap' ? vwap_7d : metric === 'bid' ? (bid ?? vwap_7d) : (ask ?? vwap_7d);
  420. const daily_amount = amount * runs_per_day;
  421. return `${ticker}: ${formatSigFig(daily_amount)} × ${formatSigFig(val)} = ${formatSigFig(daily_amount * val)}`;
  422. }).join('\n');
  423. function formatMatPrices(matPrices: MatPrice[], metric: MetricType, runs_per_day: number): string {
  424. return matPrices.map(({ticker, amount, vwap_7d, bid, ask}) => {
  425. const val = metric === 'vwap' ? vwap_7d : metric === 'bid' ? (bid ?? vwap_7d) : (ask ?? vwap_7d);
  426. const daily_amount = amount * runs_per_day;
  427. return `${ticker}: ${formatSigFig(daily_amount)} × ${formatSigFig(val)} = ${formatSigFig(daily_amount * val)}`;
  428. }).join('\n');
  429. }
  430. setupPopover();
  431. lowVolume.addEventListener('change', render);
  432. cxSelect.addEventListener('change', render);
  433. expertiseSelect.addEventListener('change', render);
  434. buildingSelect.addEventListener('change', render);
  435. render();
  436. interface Metrics {
  437. vwap: number;
  438. bid: number;
  439. ask: number;
  440. }
  441. interface Metrics {
  442. vwap: number;
  443. bid: number;
  444. ask: number;
  445. }
  446. interface Profit {
  447. outputs: MatPrice[]
  448. recipe: string
  449. expertise: keyof typeof expertise
  450. building: string
  451. area: number
  452. capex: Metrics
  453. opex: Metrics
  454. revenue: Metrics
  455. input_costs: MatPrice[]
  456. runs_per_day: number
  457. logistics_per_base: number
  458. normalized_logistics_per_base: number
  459. logistics_bottleneck: string
  460. logistics_per_base: number
  461. normalized_logistics_per_base: number
  462. logistics_bottleneck: string
  463. output_per_day: number
  464. average_traded_7d: number
  465. market_capacity_base: number
  466. hq_costs: Record<string, Metrics>
  467. }
  468. interface ProfitWithMetrics extends Profit {
  469. capex_val: number;
  470. opex_val: number;
  471. revenue_val: number;
  472. profit_per_day: number;
  473. profit_per_base: number;
  474. break_even: number;
  475. hq_capex: number;
  476. activeWorkingCapitalDays: number;
  477. activeShipCapex: number;
  478. shipsNeeded: number;
  479. market_cash_flow: number; // Exported weight tracker
  480. }
  481. interface MatPrice {
  482. ticker: string
  483. amount: number
  484. vwap_7d: number
  485. bid: number | null
  486. ask: number | null
  487. bid: number | null
  488. ask: number | null
  489. }
  490. interface Building {
  491. building_type: 'INFRASTRUCTURE' | 'PLANETARY' | 'PRODUCTION';
  492. building_ticker: string;
  493. expertise: keyof typeof expertise;
  494. }