roi.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  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. // EXTREME DETAIL: Legacy cache migration. If the user had the old raw 'logistics_per_base' stored
  40. // as their active sort, we organically migrate them to the new normalized property.
  41. let storedSortKey = localStorage.getItem('roi-sort-key') as any;
  42. if (storedSortKey === 'logistics_per_base') storedSortKey = 'normalized_logistics_per_base';
  43. let currentSortKey: keyof ProfitWithMetrics | 'outputs' = storedSortKey || 'break_even';
  44. let currentSortAsc: boolean = localStorage.getItem('roi-sort-asc') !== 'false';
  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. let includeShips: boolean = localStorage.getItem('roi-include-ships') === 'true';
  51. let workingCapitalDays: number = parseInt(localStorage.getItem('roi-working-capital') || '3', 10);
  52. let targetPermit: number = parseInt(localStorage.getItem('roi-target-permit') || '2', 10);
  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="include-ships"> Include Ship CapEx
  75. </label>
  76. <label style="margin-right: 15px;">
  77. Days OpEx: <input type="number" id="working-capital" min="0" step="1" style="width: 50px;">
  78. </label>
  79. <label>
  80. Permit Number: <input type="number" id="target-permit" min="1" step="1" style="width: 50px;">
  81. </label>
  82. `;
  83. const table = document.querySelector('table');
  84. if (table) table.parentNode?.insertBefore(controls, table);
  85. (document.getElementById('capex-metric') as HTMLSelectElement).value = capexMetric;
  86. (document.getElementById('opex-metric') as HTMLSelectElement).value = opexMetric;
  87. (document.getElementById('revenue-metric') as HTMLSelectElement).value = revenueMetric;
  88. (document.getElementById('include-ships') as HTMLInputElement).checked = includeShips;
  89. (document.getElementById('working-capital') as HTMLInputElement).value = workingCapitalDays.toString();
  90. (document.getElementById('target-permit') as HTMLInputElement).value = targetPermit.toString();
  91. document.getElementById('capex-metric')!.addEventListener('change', (e) => {
  92. capexMetric = (e.target as HTMLSelectElement).value as MetricType;
  93. render();
  94. });
  95. document.getElementById('opex-metric')!.addEventListener('change', (e) => {
  96. opexMetric = (e.target as HTMLSelectElement).value as MetricType;
  97. render();
  98. });
  99. document.getElementById('revenue-metric')!.addEventListener('change', (e) => {
  100. revenueMetric = (e.target as HTMLSelectElement).value as MetricType;
  101. render();
  102. });
  103. document.getElementById('include-ships')!.addEventListener('change', (e) => {
  104. includeShips = (e.target as HTMLInputElement).checked;
  105. render();
  106. });
  107. document.getElementById('working-capital')!.addEventListener('change', (e) => {
  108. workingCapitalDays = parseInt((e.target as HTMLInputElement).value, 10);
  109. render();
  110. });
  111. document.getElementById('target-permit')!.addEventListener('change', (e) => {
  112. targetPermit = parseInt((e.target as HTMLInputElement).value, 10);
  113. render();
  114. });
  115. metricControlsInitialized = true;
  116. }
  117. if (!headersInitialized) {
  118. const ths = document.querySelectorAll('th');
  119. // EXTREME DETAIL: We swapped `logistics_per_base` for `normalized_logistics_per_base`.
  120. // Clicking the Logistics header now intrinsically triggers a sort utilizing the ship fraction,
  121. // ensuring that 1000m³ perfectly balances against 3000t.
  122. const keys: (keyof ProfitWithMetrics | 'outputs')[] = [
  123. 'outputs', 'expertise', 'profit_per_base', 'break_even',
  124. 'capex_val', 'opex_val', 'normalized_logistics_per_base', 'market_capacity_base'
  125. ];
  126. ths.forEach((th, i) => {
  127. if (keys[i]) {
  128. th.style.cursor = 'pointer';
  129. th.title = '';
  130. if (keys[i] === 'profit_per_base') {
  131. th.textContent = 'Profit/Base';
  132. th.dataset.tooltip = 'Click to sort.\n\nDaily profit scaled to a full 500-area planetary base.\n(Percentiles rank 0.00 as lowest numerical value to 1.00 as highest)';
  133. } else if (keys[i] === 'capex_val') {
  134. th.textContent = 'CapEx/Base';
  135. th.dataset.tooltip = 'Click to sort.\n\nTotal capital expenditure scaled to a full 500-area planetary base.\nIncludes base construction, working capital (days of OpEx), optional HQ Upgrade materials for the target permit, and optional Ship CapEx.';
  136. } else if (keys[i] === 'opex_val') {
  137. th.textContent = 'OpEx/Base';
  138. th.dataset.tooltip = 'Click to sort.\n\nDaily operational expenditure (input materials + worker consumables) scaled to a full 500-area planetary base.';
  139. } else if (keys[i] === 'normalized_logistics_per_base') {
  140. th.textContent = 'Logistics/Base';
  141. 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³).';
  142. } else if (keys[i] === 'market_capacity_base') {
  143. th.textContent = 'Market Cap (Bases)';
  144. 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.';
  145. } else if (keys[i] === 'break_even') {
  146. th.dataset.tooltip = 'Click to sort.\n\nBreak Even: CapEx ÷ daily profit. Note that CapEx dynamically includes working capital and HQ upgrades to accurately reflect operational readiness.';
  147. } else {
  148. th.dataset.tooltip = 'Click to sort.';
  149. }
  150. th.addEventListener('click', () => {
  151. if (currentSortKey === keys[i]) {
  152. currentSortAsc = !currentSortAsc;
  153. } else {
  154. currentSortKey = keys[i];
  155. currentSortAsc = keys[i] === 'break_even' ? true : false;
  156. }
  157. render();
  158. });
  159. }
  160. });
  161. headersInitialized = true;
  162. }
  163. const buildingTickers = new Set(profits.map(p => p.building));
  164. const buildings: {ticker: string, expertise: keyof typeof expertise}[] = Array.from(buildingTickers)
  165. .map((building) => ({ticker: building, expertise: profits.find(p => p.building === building)!.expertise}))
  166. .sort((a, b) => a.ticker.localeCompare(b.ticker));
  167. let selectedBuilding = buildingSelect.value || savedBuilding;
  168. let buildingFound = false;
  169. buildingSelect.innerHTML = '<option value="">(all)</option>';
  170. for (const building of buildings)
  171. if (expertiseSelect.value === '' || expertiseSelect.value === building.expertise) {
  172. const option = document.createElement('option');
  173. option.value = building.ticker;
  174. option.textContent = building.ticker;
  175. if (building.ticker === selectedBuilding) {
  176. buildingFound = true;
  177. option.selected = true;
  178. }
  179. buildingSelect.appendChild(option);
  180. }
  181. if (!buildingFound)
  182. selectedBuilding = '';
  183. savedBuilding = '';
  184. // EXTREME DETAIL: Because the percentile derivations evaluate the arrays *after* the filter executes,
  185. // any row removed by the "low volume" constraint is automatically excluded from the
  186. // percentile population math, ensuring your rankings are entirely context-accurate.
  187. const filteredProfits = profits.filter(p => {
  188. const volumeRatio = p.output_per_day / p.average_traded_7d;
  189. if (!lowVolume.checked && volumeRatio > 0.05) return false;
  190. if (expertiseSelect.value !== '' && p.expertise !== expertiseSelect.value) return false;
  191. if (selectedBuilding !== '' && p.building !== selectedBuilding) return false;
  192. return true;
  193. });
  194. const profitsWithMetrics: ProfitWithMetrics[] = filteredProfits.map(p => {
  195. const bases = p.area / 500;
  196. const opex_val = p.opex[opexMetric] / bases;
  197. const revenue_val = p.revenue[revenueMetric] / bases;
  198. let capex_val = (p.capex[capexMetric] / bases) + (opex_val * workingCapitalDays);
  199. let hq_capex = 0;
  200. if (targetPermit >= 3) {
  201. const hqLevelStr = (targetPermit - 1).toString();
  202. if (p.hq_costs && p.hq_costs[hqLevelStr]) {
  203. hq_capex = p.hq_costs[hqLevelStr][capexMetric];
  204. capex_val += hq_capex;
  205. }
  206. }
  207. if (includeShips) {
  208. capex_val += p.ship_capex_per_base;
  209. }
  210. const profit_per_base = revenue_val - opex_val;
  211. const break_even = profit_per_base > 0 ? capex_val / profit_per_base : Infinity;
  212. return { ...p, capex_val, opex_val, revenue_val, profit_per_base, break_even, hq_capex };
  213. });
  214. // EXTREME DETAIL: We extract 6 sorted arrays of our active numeric values post-mapping.
  215. // Using a robust numeric sorting function protects against `NaN` crashes when dealing with `Infinity`.
  216. const numSort = (a: number, b: number) => (a < b ? -1 : a > b ? 1 : 0);
  217. const arrProfit = profitsWithMetrics.map(p => p.profit_per_base).sort(numSort);
  218. const arrBreak = profitsWithMetrics.map(p => p.break_even).sort(numSort);
  219. const arrCapex = profitsWithMetrics.map(p => p.capex_val).sort(numSort);
  220. const arrOpex = profitsWithMetrics.map(p => p.opex_val).sort(numSort);
  221. const arrLog = profitsWithMetrics.map(p => p.normalized_logistics_per_base).sort(numSort);
  222. const arrCap = profitsWithMetrics.map(p => p.market_capacity_base).sort(numSort);
  223. profitsWithMetrics.sort((a, b) => {
  224. let valA: any = a[currentSortKey as keyof ProfitWithMetrics];
  225. let valB: any = b[currentSortKey as keyof ProfitWithMetrics];
  226. if (currentSortKey === 'outputs') {
  227. valA = a.outputs.map(o => o.ticker).join(', ');
  228. valB = b.outputs.map(o => o.ticker).join(', ');
  229. }
  230. if (valA < valB) return currentSortAsc ? -1 : 1;
  231. if (valA > valB) return currentSortAsc ? 1 : -1;
  232. return 0;
  233. });
  234. for (const p of profitsWithMetrics) {
  235. const tr = document.createElement('tr');
  236. // Map the raw value to its percentile rank within the active filtered column.
  237. const pctProfit = getPercentile(p.profit_per_base, arrProfit);
  238. const pctBreak = getPercentile(p.break_even, arrBreak);
  239. const pctCapex = getPercentile(p.capex_val, arrCapex);
  240. const pctOpex = getPercentile(p.opex_val, arrOpex);
  241. const pctLog = getPercentile(p.normalized_logistics_per_base, arrLog);
  242. const pctCap = getPercentile(p.market_capacity_base, arrCap);
  243. // EXTREME DETAIL: We append the percentiles enclosed in a <small> tag.
  244. // Reducing the opacity slightly creates visual hierarchy, allowing the 3-sig-fig
  245. // metrics to remain the primary focal point of the table while the percentile acts as metadata.
  246. // Note: The Logistics color mapping natively targets `normalized_logistics_per_base` (0.1 to 1.0 ship bounds).
  247. tr.innerHTML = `
  248. <td>${p.outputs.map(o => o.ticker).join(', ')}</td>
  249. <td>${expertise[p.expertise]}</td>
  250. <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})</span></td>
  251. <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})</span></td>
  252. <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})</span></td>
  253. <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})</span></td>
  254. <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})</span></td>
  255. <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})</span></td>
  256. `;
  257. const output = tr.querySelector('td')!;
  258. output.dataset.tooltip = p.recipe;
  259. const profitCell = tr.querySelectorAll('td')[2];
  260. const runs_per_base = p.runs_per_day / (p.area / 500);
  261. profitCell.dataset.tooltip = formatMatPrices(p.outputs, revenueMetric, runs_per_base) + '\n\n' +
  262. formatMatPrices(p.input_costs, opexMetric, runs_per_base) + '\n' +
  263. '+ worker consumables\n\n' +
  264. `(${formatSigFig(p.revenue_val)} - ${formatSigFig(p.opex_val)}) = ${formatSigFig(p.profit_per_base)}`;
  265. const capexCell = tr.querySelectorAll('td')[4];
  266. capexCell.dataset.tooltip = `Base Construction: ${formatSigFig(p.capex[capexMetric] / (p.area / 500))}`;
  267. capexCell.dataset.tooltip += `\nWorking Capital (${workingCapitalDays} days): ${formatSigFig(p.opex_val * workingCapitalDays)}`;
  268. if (p.hq_capex > 0) {
  269. capexCell.dataset.tooltip += `\nHQ Upgrade (Permit ${targetPermit}): ${formatSigFig(p.hq_capex)}`;
  270. }
  271. if (includeShips) {
  272. capexCell.dataset.tooltip += `\nShip CapEx: ${formatSigFig(p.ship_capex_per_base)} (${formatSigFig(p.ship_capex_per_base / 800_000)} ships)`;
  273. }
  274. const marketCell = tr.querySelectorAll('td')[7];
  275. 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`;
  276. tbody.appendChild(tr);
  277. }
  278. document.getElementById('last-updated')!.textContent =
  279. `last updated: ${lastModified.toLocaleString(undefined, {dateStyle: 'full', timeStyle: 'long', hour12: false})}`;
  280. saveState();
  281. }
  282. // EXTREME DETAIL: O(N) simple percentile ranker.
  283. // Locates how many array items are strictly less than the target value.
  284. // Bounding it against `sortedArr.length - 1` gracefully maps the output from 0.00 to 1.00.
  285. function getPercentile(val: number, sortedArr: number[]): string {
  286. if (sortedArr.length < 2) return "1.00";
  287. let less = 0;
  288. for (let i = 0; i < sortedArr.length; i++) {
  289. if (sortedArr[i] < val) less++;
  290. else break; // Break early because array is guaranteed pre-sorted
  291. }
  292. return (less / (sortedArr.length - 1)).toFixed(2);
  293. }
  294. function saveState() {
  295. localStorage.setItem('roi-cx', cxSelect.value);
  296. localStorage.setItem('roi-expertise', expertiseSelect.value);
  297. localStorage.setItem('roi-building', buildingSelect.value);
  298. localStorage.setItem('roi-low-volume', lowVolume.checked.toString());
  299. localStorage.setItem('roi-sort-key', currentSortKey);
  300. localStorage.setItem('roi-sort-asc', currentSortAsc.toString());
  301. localStorage.setItem('roi-capex-metric', capexMetric);
  302. localStorage.setItem('roi-opex-metric', opexMetric);
  303. localStorage.setItem('roi-revenue-metric', revenueMetric);
  304. localStorage.setItem('roi-include-ships', includeShips.toString());
  305. localStorage.setItem('roi-working-capital', workingCapitalDays.toString());
  306. localStorage.setItem('roi-target-permit', targetPermit.toString());
  307. }
  308. function color(n: number, low: number, high: number): string {
  309. const scale = Math.min(Math.max((n - low) / (high - low), 0), 1);
  310. return `color-mix(in xyz, #0aa ${scale * 100}%, #f80)`;
  311. }
  312. function formatMatPrices(matPrices: MatPrice[], metric: MetricType, runs_per_day: number): string {
  313. return matPrices.map(({ticker, amount, vwap_7d, bid, ask}) => {
  314. const val = metric === 'vwap' ? vwap_7d : metric === 'bid' ? (bid ?? vwap_7d) : (ask ?? vwap_7d);
  315. const daily_amount = amount * runs_per_day;
  316. return `${ticker}: ${formatSigFig(daily_amount)} × ${formatSigFig(val)} = ${formatSigFig(daily_amount * val)}`;
  317. }).join('\n');
  318. }
  319. setupPopover();
  320. lowVolume.addEventListener('change', render);
  321. cxSelect.addEventListener('change', render);
  322. expertiseSelect.addEventListener('change', render);
  323. buildingSelect.addEventListener('change', render);
  324. render();
  325. interface Metrics {
  326. vwap: number;
  327. bid: number;
  328. ask: number;
  329. }
  330. interface Profit {
  331. outputs: MatPrice[]
  332. recipe: string
  333. expertise: keyof typeof expertise
  334. building: string
  335. area: number
  336. capex: Metrics
  337. opex: Metrics
  338. revenue: Metrics
  339. input_costs: MatPrice[]
  340. runs_per_day: number
  341. logistics_per_base: number
  342. normalized_logistics_per_base: number // Extracted from backend explicitly for sorting/percentiles
  343. logistics_bottleneck: string
  344. output_per_day: number
  345. average_traded_7d: number
  346. market_capacity_base: number
  347. ship_capex_per_base: number
  348. hq_costs: Record<string, Metrics>
  349. }
  350. interface ProfitWithMetrics extends Profit {
  351. capex_val: number;
  352. opex_val: number;
  353. revenue_val: number;
  354. profit_per_day: number;
  355. profit_per_base: number;
  356. break_even: number;
  357. hq_capex: number;
  358. }
  359. interface MatPrice {
  360. ticker: string
  361. amount: number
  362. vwap_7d: number
  363. bid: number | null
  364. ask: number | null
  365. }
  366. interface Building {
  367. building_type: 'INFRASTRUCTURE' | 'PLANETARY' | 'PRODUCTION';
  368. building_ticker: string;
  369. expertise: keyof typeof expertise;
  370. }