| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213 |
- import {setupPopover} from './popover';
- const roiCache: Record<string, Promise<{lastModified: Date, profits: Profit[]}>> = {};
- async function getROI(cx: string) {
- const response = await fetch(`/roi_${cx.toLowerCase()}.json`);
- const lastModified = new Date(response.headers.get('last-modified')!);
- const profits = await response.json();
- return {lastModified, profits};
- }
- const lowVolume = document.querySelector('input#low-volume') as HTMLInputElement;
- const cxSelect = document.querySelector('select#cx') as HTMLSelectElement;
- const expertise = {
- AGRICULTURE: 'agri',
- CHEMISTRY: 'chem',
- CONSTRUCTION: 'const',
- ELECTRONICS: 'elec',
- FOOD_INDUSTRIES: 'food ind',
- FUEL_REFINING: 'fuel',
- MANUFACTURING: 'mfg',
- METALLURGY: 'metal',
- RESOURCE_EXTRACTION: 'res ext',
- } as const;
- const expertiseSelect = document.querySelector('select#expertise') as HTMLSelectElement;
- for (const key of Object.keys(expertise)) {
- const option = document.createElement('option');
- option.value = key;
- option.textContent = key.replace('_', ' ').toLowerCase();
- expertiseSelect.appendChild(option);
- }
- const buildingSelect = document.querySelector('select#building') as HTMLSelectElement;
- const formatDecimal = new Intl.NumberFormat(undefined,
- {maximumFractionDigits: 2, maximumSignificantDigits: 6, roundingPriority: 'lessPrecision'}).format;
- const formatWhole = new Intl.NumberFormat(undefined, {maximumFractionDigits: 0}).format;
- let currentSortKey: keyof Profit | 'outputs' = 'break_even';
- let currentSortAsc: boolean = true;
- let headersInitialized = false;
- async function render() {
- const tbody = document.querySelector('tbody')!;
- tbody.innerHTML = '';
- const cx = cxSelect.value;
- if (!roiCache[cx])
- roiCache[cx] = getROI(cx);
- const {lastModified, profits} = await roiCache[cx];
- if (!headersInitialized) {
- const ths = document.querySelectorAll('th');
- // EXTREME DETAIL: We swapped 'output_per_day' for 'market_capacity_area'.
- // Because this array index directly maps to the DOM headers, clicking the 8th
- // table header now natively routes to the new metric for dynamic sorting.
- const keys: (keyof Profit | 'outputs')[] = [
- 'outputs', 'expertise', 'profit_per_area', 'break_even',
- 'capex', 'cost_per_day', 'logistics_per_area', 'market_capacity_area'
- ];
-
- ths.forEach((th, i) => {
- if (keys[i]) {
- th.style.cursor = 'pointer';
- th.title = 'Click to sort';
- th.addEventListener('click', () => {
- if (currentSortKey === keys[i]) {
- currentSortAsc = !currentSortAsc;
- } else {
- currentSortKey = keys[i];
- currentSortAsc = keys[i] === 'break_even' ? true : false;
- }
- render();
- });
- }
- });
- headersInitialized = true;
- }
- const buildingTickers = new Set(profits.map(p => p.building));
- const buildings: {ticker: string, expertise: keyof typeof expertise}[] = Array.from(buildingTickers)
- .map((building) => ({ticker: building, expertise: profits.find(p => p.building === building)!.expertise}))
- .sort((a, b) => a.ticker.localeCompare(b.ticker));
- let selectedBuilding = buildingSelect.value;
- let buildingFound = false;
- buildingSelect.innerHTML = '<option value="">(all)</option>';
- for (const building of buildings)
- if (expertiseSelect.value === '' || expertiseSelect.value === building.expertise) {
- const option = document.createElement('option');
- option.value = building.ticker;
- option.textContent = building.ticker;
- if (building.ticker === selectedBuilding) {
- buildingFound = true;
- option.selected = true;
- }
- buildingSelect.appendChild(option);
- }
- if (!buildingFound)
- selectedBuilding = '';
- // EXTREME DETAIL: Even though we removed 'output_per_day' and 'average_traded_7d' from
- // the visual table, they are still present in the JSON backend structure.
- // This means our 'lowVolume' filter still works perfectly without requiring any math changes!
- const filteredProfits = profits.filter(p => {
- const volumeRatio = p.output_per_day / p.average_traded_7d;
- if (!lowVolume.checked && volumeRatio > 0.05) return false;
- if (expertiseSelect.value !== '' && p.expertise !== expertiseSelect.value) return false;
- if (selectedBuilding !== '' && p.building !== selectedBuilding) return false;
- return true;
- });
- filteredProfits.sort((a, b) => {
- let valA: any = a[currentSortKey as keyof Profit];
- let valB: any = b[currentSortKey as keyof Profit];
-
- if (currentSortKey === 'outputs') {
- valA = a.outputs.map(o => o.ticker).join(', ');
- valB = b.outputs.map(o => o.ticker).join(', ');
- }
-
- if (valA < valB) return currentSortAsc ? -1 : 1;
- if (valA > valB) return currentSortAsc ? 1 : -1;
- return 0;
- });
- for (const p of filteredProfits) {
- const tr = document.createElement('tr');
-
- // EXTREME DETAIL: We swapped the dual-line output code for a single <td> rendering the new
- // market_capacity_area metric. The color mapping scale ranges from 20 (Red) to 500 (Cyan).
- // For reference: a capacity of 20 means building 1 area captures exactly 5% of the market volume.
- tr.innerHTML = `
- <td>${p.outputs.map(o => o.ticker).join(', ')}</td>
- <td>${expertise[p.expertise]}</td>
- <td style="color: ${color(p.profit_per_area, 0, 300)}">${formatDecimal(p.profit_per_area)}</td>
- <td><span style="color: ${color(p.break_even, 30, 3)}">${formatDecimal(p.break_even)}</span>d</td>
- <td style="color: ${color(p.capex, 300_000, 40_000)}">${formatWhole(p.capex)}</td>
- <td style="color: ${color(p.cost_per_day, 40_000, 1_000)}">${formatWhole(p.cost_per_day)}</td>
- <td style="color: ${color(p.logistics_per_area, 2, 0.2)}">${formatDecimal(p.logistics_per_area)}</td>
- <td style="color: ${color(p.market_capacity_area, 20, 500)}">${formatWhole(p.market_capacity_area)}</td>
- `;
- const output = tr.querySelector('td')!;
- output.dataset.tooltip = p.recipe;
- const profitCell = tr.querySelectorAll('td')[2];
- const revenue = p.outputs.reduce((sum, o) => sum + o.amount * o.vwap_7d, 0);
- const inputCost = p.input_costs.reduce((sum, o) => sum + o.amount * o.vwap_7d, 0);
- profitCell.dataset.tooltip = formatMatPrices(p.outputs) + '\n\n' +
- formatMatPrices(p.input_costs) + '\n' +
- 'worker consumables: ' + formatWhole(p.worker_consumable_cost_per_day) + '\n\n' +
- `(${formatWhole(revenue)} - ${formatWhole(inputCost)}) × ${formatDecimal(p.runs_per_day)} runs ` +
- `- ${formatWhole(p.worker_consumable_cost_per_day)} = ${formatDecimal(p.profit_per_day)}\n` +
- `${formatDecimal(p.profit_per_day)} / ${formatWhole(p.area)} area = ${formatDecimal(p.profit_per_area)}`;
- // EXTREME DETAIL: Because we are condensing two variables into a single number, providing a tooltip
- // explaining exactly how the math breaks down is critical for the end-user experience.
- const marketCell = tr.querySelectorAll('td')[7];
- marketCell.dataset.tooltip = `Market Capacity: ${formatWhole(p.average_traded_7d)} traded/day ÷ ${formatDecimal(p.output_per_day / p.area)} produced/day/area = ${formatWhole(p.market_capacity_area)} equivalent areas`;
- tbody.appendChild(tr);
- }
- document.getElementById('last-updated')!.textContent =
- `last updated: ${lastModified.toLocaleString(undefined, {dateStyle: 'full', timeStyle: 'long', hour12: false})}`;
- }
- function color(n: number, low: number, high: number): string {
- const scale = Math.min(Math.max((n - low) / (high - low), 0), 1);
- return `color-mix(in xyz, #0aa ${scale * 100}%, #f80)`;
- }
- function formatMatPrices(matPrices: MatPrice[]): string {
- return matPrices.map(({ticker, amount, vwap_7d}) =>
- `${ticker}: ${amount} × ${formatDecimal(vwap_7d)} = ${formatWhole(amount * vwap_7d)}`).join('\n');
- }
- setupPopover();
- lowVolume.addEventListener('change', render);
- cxSelect.addEventListener('change', render);
- expertiseSelect.addEventListener('change', render);
- buildingSelect.addEventListener('change', render);
- render();
- interface Profit {
- outputs: MatPrice[]
- recipe: string
- expertise: keyof typeof expertise
- building: string
- profit_per_day: number
- area: number
- capex: number
- cost_per_day: number
- input_costs: MatPrice[]
- worker_consumable_cost_per_day: number
- runs_per_day: number
- logistics_per_area: number
- output_per_day: number
- average_traded_7d: number
- profit_per_area: number
- break_even: number
- market_capacity_area: number // Added pre-calculated property tracking
- }
- interface MatPrice {
- ticker: string
- amount: number
- vwap_7d: number
- }
- interface Building {
- building_type: 'INFRASTRUCTURE' | 'PLANETARY' | 'PRODUCTION';
- building_ticker: string;
- expertise: keyof typeof expertise;
- }
|