roi.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  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. const lowVolume = document.querySelector('input#low-volume') as HTMLInputElement;
  10. const cxSelect = document.querySelector('select#cx') as HTMLSelectElement;
  11. const expertise = {
  12. AGRICULTURE: 'agri',
  13. CHEMISTRY: 'chem',
  14. CONSTRUCTION: 'const',
  15. ELECTRONICS: 'elec',
  16. FOOD_INDUSTRIES: 'food ind',
  17. FUEL_REFINING: 'fuel',
  18. MANUFACTURING: 'mfg',
  19. METALLURGY: 'metal',
  20. RESOURCE_EXTRACTION: 'res ext',
  21. } as const;
  22. const expertiseSelect = document.querySelector('select#expertise') as HTMLSelectElement;
  23. for (const key of Object.keys(expertise)) {
  24. const option = document.createElement('option');
  25. option.value = key;
  26. option.textContent = key.replace('_', ' ').toLowerCase();
  27. expertiseSelect.appendChild(option);
  28. }
  29. const buildingSelect = document.querySelector('select#building') as HTMLSelectElement;
  30. const formatDecimal = new Intl.NumberFormat(undefined,
  31. {maximumFractionDigits: 2, maximumSignificantDigits: 6, roundingPriority: 'lessPrecision'}).format;
  32. const formatWhole = new Intl.NumberFormat(undefined, {maximumFractionDigits: 0}).format;
  33. // EXTREME DETAIL: These state variables track the current interactive table sort configuration.
  34. // By defaulting to 'break_even' and 'true' (ascending), the initial page load mirrors the old default behavior.
  35. let currentSortKey: keyof Profit | 'outputs' = 'break_even';
  36. let currentSortAsc: boolean = true;
  37. let headersInitialized = false;
  38. async function render() {
  39. const tbody = document.querySelector('tbody')!;
  40. tbody.innerHTML = '';
  41. const cx = cxSelect.value;
  42. if (!roiCache[cx])
  43. roiCache[cx] = getROI(cx);
  44. const {lastModified, profits} = await roiCache[cx];
  45. // EXTREME DETAIL: On the very first render, we grab all table headers and wire them up with click listeners.
  46. // The `keys` array maps 1:1 with the columns defined in the HTML structure.
  47. if (!headersInitialized) {
  48. const ths = document.querySelectorAll('th');
  49. const keys: (keyof Profit | 'outputs')[] = [
  50. 'outputs', 'expertise', 'profit_per_area', 'break_even',
  51. 'capex', 'cost_per_day', 'logistics_per_area', 'output_per_day'
  52. ];
  53. ths.forEach((th, i) => {
  54. if (keys[i]) {
  55. th.style.cursor = 'pointer';
  56. th.title = 'Click to sort';
  57. th.addEventListener('click', () => {
  58. if (currentSortKey === keys[i]) {
  59. // Flip the sort direction if clicking the same column twice
  60. currentSortAsc = !currentSortAsc;
  61. } else {
  62. // Switch to a new column
  63. currentSortKey = keys[i];
  64. // Break Even defaults to Lowest-to-Highest. All other numbers default to Highest-to-Lowest.
  65. currentSortAsc = keys[i] === 'break_even' ? true : false;
  66. }
  67. // Re-trigger the render loop with the new sorting state
  68. render();
  69. });
  70. }
  71. });
  72. headersInitialized = true;
  73. }
  74. const buildingTickers = new Set(profits.map(p => p.building));
  75. const buildings: {ticker: string, expertise: keyof typeof expertise}[] = Array.from(buildingTickers)
  76. .map((building) => ({ticker: building, expertise: profits.find(p => p.building === building)!.expertise}))
  77. .sort((a, b) => a.ticker.localeCompare(b.ticker));
  78. let selectedBuilding = buildingSelect.value;
  79. let buildingFound = false;
  80. buildingSelect.innerHTML = '<option value="">(all)</option>';
  81. for (const building of buildings)
  82. if (expertiseSelect.value === '' || expertiseSelect.value === building.expertise) {
  83. const option = document.createElement('option');
  84. option.value = building.ticker;
  85. option.textContent = building.ticker;
  86. if (building.ticker === selectedBuilding) {
  87. buildingFound = true;
  88. option.selected = true;
  89. }
  90. buildingSelect.appendChild(option);
  91. }
  92. if (!buildingFound)
  93. selectedBuilding = '';
  94. // EXTREME DETAIL: We extract the filtering logic into an explicit filter pass prior to rendering.
  95. // This separates data culling from the actual DOM string-building loop.
  96. const filteredProfits = profits.filter(p => {
  97. const volumeRatio = p.output_per_day / p.average_traded_7d;
  98. if (!lowVolume.checked && volumeRatio > 0.05) return false;
  99. if (expertiseSelect.value !== '' && p.expertise !== expertiseSelect.value) return false;
  100. if (selectedBuilding !== '' && p.building !== selectedBuilding) return false;
  101. return true;
  102. });
  103. // EXTREME DETAIL: We execute the interactive sorting logic based on the user's header click state.
  104. // 'outputs' requires special handling because it is an Array of MatPrice objects, not a primitive string/number.
  105. filteredProfits.sort((a, b) => {
  106. let valA: any = a[currentSortKey as keyof Profit];
  107. let valB: any = b[currentSortKey as keyof Profit];
  108. if (currentSortKey === 'outputs') {
  109. valA = a.outputs.map(o => o.ticker).join(', ');
  110. valB = b.outputs.map(o => o.ticker).join(', ');
  111. }
  112. if (valA < valB) return currentSortAsc ? -1 : 1;
  113. if (valA > valB) return currentSortAsc ? 1 : -1;
  114. return 0;
  115. });
  116. for (const p of filteredProfits) {
  117. const volumeRatio = p.output_per_day / p.average_traded_7d;
  118. const tr = document.createElement('tr');
  119. // EXTREME DETAIL: We no longer recalculate profitPerArea and breakEven here.
  120. // We simply read the properties established by the Python backend via the JSON contract.
  121. tr.innerHTML = `
  122. <td>${p.outputs.map(o => o.ticker).join(', ')}</td>
  123. <td>${expertise[p.expertise]}</td>
  124. <td style="color: ${color(p.profit_per_area, 0, 300)}">${formatDecimal(p.profit_per_area)}</td>
  125. <td><span style="color: ${color(p.break_even, 30, 3)}">${formatDecimal(p.break_even)}</span>d</td>
  126. <td style="color: ${color(p.capex, 300_000, 40_000)}">${formatWhole(p.capex)}</td>
  127. <td style="color: ${color(p.cost_per_day, 40_000, 1_000)}">${formatWhole(p.cost_per_day)}</td>
  128. <td style="color: ${color(p.logistics_per_area, 2, 0.2)}">${formatDecimal(p.logistics_per_area)}</td>
  129. <td>
  130. ${formatDecimal(p.output_per_day)}<br>
  131. <span style="color: ${color(volumeRatio, 0.05, 0.002)}">${formatWhole(p.average_traded_7d)}</span>
  132. </td>
  133. `;
  134. const output = tr.querySelector('td')!;
  135. output.dataset.tooltip = p.recipe;
  136. const profitCell = tr.querySelectorAll('td')[2];
  137. const revenue = p.outputs.reduce((sum, o) => sum + o.amount * o.vwap_7d, 0);
  138. const inputCost = p.input_costs.reduce((sum, o) => sum + o.amount * o.vwap_7d, 0);
  139. profitCell.dataset.tooltip = formatMatPrices(p.outputs) + '\n\n' +
  140. formatMatPrices(p.input_costs) + '\n' +
  141. 'worker consumables: ' + formatWhole(p.worker_consumable_cost_per_day) + '\n\n' +
  142. `(${formatWhole(revenue)} - ${formatWhole(inputCost)}) × ${formatDecimal(p.runs_per_day)} runs ` +
  143. `- ${formatWhole(p.worker_consumable_cost_per_day)} = ${formatDecimal(p.profit_per_day)}\n` +
  144. `${formatDecimal(p.profit_per_day)} / ${formatWhole(p.area)} area = ${formatDecimal(p.profit_per_area)}`;
  145. tbody.appendChild(tr);
  146. }
  147. document.getElementById('last-updated')!.textContent =
  148. `last updated: ${lastModified.toLocaleString(undefined, {dateStyle: 'full', timeStyle: 'long', hour12: false})}`;
  149. }
  150. function color(n: number, low: number, high: number): string {
  151. // scale n from low..high to 0..1 clamped
  152. const scale = Math.min(Math.max((n - low) / (high - low), 0), 1);
  153. return `color-mix(in xyz, #0aa ${scale * 100}%, #f80)`;
  154. }
  155. function formatMatPrices(matPrices: MatPrice[]): string {
  156. return matPrices.map(({ticker, amount, vwap_7d}) =>
  157. `${ticker}: ${amount} × ${formatDecimal(vwap_7d)} = ${formatWhole(amount * vwap_7d)}`).join('\n');
  158. }
  159. setupPopover();
  160. lowVolume.addEventListener('change', render);
  161. cxSelect.addEventListener('change', render);
  162. expertiseSelect.addEventListener('change', render);
  163. buildingSelect.addEventListener('change', render);
  164. render();
  165. interface Profit {
  166. outputs: MatPrice[]
  167. recipe: string
  168. expertise: keyof typeof expertise
  169. building: string
  170. profit_per_day: number
  171. area: number
  172. capex: number
  173. cost_per_day: number
  174. input_costs: MatPrice[]
  175. worker_consumable_cost_per_day: number
  176. runs_per_day: number
  177. logistics_per_area: number
  178. output_per_day: number
  179. average_traded_7d: number
  180. profit_per_area: number // Added pre-calculated property tracking
  181. break_even: number // Added pre-calculated property tracking
  182. }
  183. interface MatPrice {
  184. ticker: string
  185. amount: number
  186. vwap_7d: number
  187. }
  188. interface Building {
  189. building_type: 'INFRASTRUCTURE' | 'PLANETARY' | 'PRODUCTION';
  190. building_ticker: string;
  191. expertise: keyof typeof expertise;
  192. }