| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601 |
- 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};
- }
- type MetricType = 'vwap' | 'bid' | 'ask';
- 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 formatSigFig = new Intl.NumberFormat(undefined, {
- notation: 'compact',
- maximumSignificantDigits: 3,
- }).format;
- if (localStorage.getItem('roi-cx')) cxSelect.value = localStorage.getItem('roi-cx')!;
- if (localStorage.getItem('roi-expertise')) expertiseSelect.value = localStorage.getItem('roi-expertise')!;
- if (localStorage.getItem('roi-low-volume')) lowVolume.checked = localStorage.getItem('roi-low-volume') === 'true';
- let savedBuilding = localStorage.getItem('roi-building') || '';
- let storedSortKey = localStorage.getItem('roi-sort-key') as any;
- if (storedSortKey === 'logistics_per_base') storedSortKey = 'normalized_logistics_per_base';
- let currentSortKey: keyof ProfitWithMetrics | 'outputs' = storedSortKey || 'break_even';
- let currentSortAsc: boolean = localStorage.getItem('roi-sort-asc') !== 'false';
- let headersInitialized = false;
- let metricControlsInitialized = false;
- let capexMetric: MetricType = (localStorage.getItem('roi-capex-metric') as MetricType) || 'vwap';
- let opexMetric: MetricType = (localStorage.getItem('roi-opex-metric') as MetricType) || 'vwap';
- let revenueMetric: MetricType = (localStorage.getItem('roi-revenue-metric') as MetricType) || 'vwap';
- let roundTripOption: string = localStorage.getItem('roi-round-trip') || 'omit';
- let showNegativeProfit: boolean = localStorage.getItem('roi-show-negative') !== 'false';
- let workingCapitalOption: string = localStorage.getItem('roi-working-capital-opt') || 'dynamic';
- let targetPermitOption: string = localStorage.getItem('roi-target-permit') || '2';
- let percentileMode: 'relative' | 'absolute' = (localStorage.getItem('roi-pct-mode') as 'relative' | 'absolute') || 'relative';
- 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 (!metricControlsInitialized) {
- const controls = document.createElement('div');
- controls.style.marginBottom = '15px';
- controls.innerHTML = `
- <label style="margin-right: 15px;">CapEx Price:
- <select id="capex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
- </label>
- <label style="margin-right: 15px;">OpEx Price:
- <select id="opex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
- </label>
- <label style="margin-right: 15px;">Revenue Price:
- <select id="revenue-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
- </label>
- <label style="margin-right: 15px;">
- <input type="checkbox" id="show-negative"> Show Negative Profit
- </label>
- <label style="margin-right: 15px;">
- Round Trip (hrs):
- <select id="round-trip">
- <option value="omit">Omit From Calculation</option>
- ${Array.from({length: 25}, (_, i) => `<option value="${i + 1}">${i + 1}</option>`).join('')}
- </select>
- </label>
- <label style="margin-right: 15px;">
- Days OpEx:
- <select id="working-capital">
- <option value="omit">Omit From Calculation</option>
- <option value="dynamic">Max for Shipment (dynamic)</option>
- <option value="1">1</option>
- <option value="2">2</option>
- <option value="3">3</option>
- <option value="4">4</option>
- <option value="5">5</option>
- <option value="6">6</option>
- <option value="7">7</option>
- <option value="14">14</option>
- <option value="30">30</option>
- </select>
- </label>
- <label>
- Permit Number:
- <select id="target-permit">
- <option value="omit">Omit From Calculation</option>
- ${Array.from({length: 49}, (_, i) => `<option value="${i + 2}">${i + 2}</option>`).join('')}
- </select>
- </label>
- <label style="margin-left: 15px;">
- Percentiles:
- <select id="percentile-mode">
- <option value="relative">Normalized (Weighted)</option>
- <option value="absolute">Absolute</option>
- </select>
- </label>
- `;
- const table = document.querySelector('table');
- if (table) table.parentNode?.insertBefore(controls, table);
- (document.getElementById('capex-metric') as HTMLSelectElement).value = capexMetric;
- (document.getElementById('opex-metric') as HTMLSelectElement).value = opexMetric;
- (document.getElementById('revenue-metric') as HTMLSelectElement).value = revenueMetric;
- (document.getElementById('show-negative') as HTMLInputElement).checked = showNegativeProfit;
- (document.getElementById('round-trip') as HTMLSelectElement).value = roundTripOption;
- (document.getElementById('working-capital') as HTMLSelectElement).value = workingCapitalOption;
- (document.getElementById('target-permit') as HTMLSelectElement).value = targetPermitOption;
- (document.getElementById('percentile-mode') as HTMLSelectElement).value = percentileMode;
- document.getElementById('capex-metric')!.addEventListener('change', (e) => {
- capexMetric = (e.target as HTMLSelectElement).value as MetricType;
- render();
- });
- document.getElementById('opex-metric')!.addEventListener('change', (e) => {
- opexMetric = (e.target as HTMLSelectElement).value as MetricType;
- render();
- });
- document.getElementById('revenue-metric')!.addEventListener('change', (e) => {
- revenueMetric = (e.target as HTMLSelectElement).value as MetricType;
- render();
- });
- document.getElementById('show-negative')!.addEventListener('change', (e) => {
- showNegativeProfit = (e.target as HTMLInputElement).checked;
- render();
- });
- document.getElementById('round-trip')!.addEventListener('change', (e) => {
- roundTripOption = (e.target as HTMLSelectElement).value;
- render();
- });
- document.getElementById('working-capital')!.addEventListener('change', (e) => {
- workingCapitalOption = (e.target as HTMLSelectElement).value;
- render();
- });
- document.getElementById('target-permit')!.addEventListener('change', (e) => {
- targetPermitOption = (e.target as HTMLSelectElement).value;
- render();
- });
- document.getElementById('percentile-mode')!.addEventListener('change', (e) => {
- percentileMode = (e.target as HTMLSelectElement).value as 'relative' | 'absolute';
- render();
- });
- metricControlsInitialized = true;
- }
- if (!headersInitialized) {
- const ths = document.querySelectorAll('th');
- const keys: (keyof ProfitWithMetrics | 'outputs')[] = [
- 'outputs', 'expertise', 'profit_per_base', 'break_even',
- 'capex_val', 'opex_val', 'normalized_logistics_per_base', 'market_capacity_base'
- ];
-
- const pctExplainer = '\n(Percentiles: 100.0% is the most desirable outcome. Toggle between Absolute and Normalized (weighted by market cash flow) using the controls.)';
- ths.forEach((th, i) => {
- if (keys[i]) {
- th.style.cursor = 'pointer';
- th.title = '';
-
- if (keys[i] === 'profit_per_base') {
- th.textContent = 'Profit/Base';
- th.dataset.tooltip = 'Click to sort.\n\nDaily profit scaled to a full 500-area planetary base.' + pctExplainer;
- } else if (keys[i] === 'capex_val') {
- th.textContent = 'CapEx/Base';
- 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;
- } else if (keys[i] === 'opex_val') {
- th.textContent = 'OpEx/Base';
- th.dataset.tooltip = 'Click to sort.\n\nDaily operational expenditure (input materials + worker consumables) scaled to a full 500-area planetary base.' + pctExplainer;
- } else if (keys[i] === 'normalized_logistics_per_base') {
- th.textContent = 'Logistics/Base';
- 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;
- } else if (keys[i] === 'market_capacity_base') {
- th.textContent = 'Market Cap (Bases)';
- 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;
- } else if (keys[i] === 'break_even') {
- 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;
- } else {
- th.dataset.tooltip = '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 || savedBuilding;
- 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 = '';
-
- savedBuilding = '';
- 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;
- });
- let profitsWithMetrics: ProfitWithMetrics[] = filteredProfits.map(p => {
- const bases = p.area / 500;
- const opex_val = p.opex[opexMetric] / bases;
- const revenue_val = p.revenue[revenueMetric] / bases;
-
- let capex_val = p.capex[capexMetric] / bases;
- let activeWorkingCapitalDays = 0;
- if (workingCapitalOption !== 'omit') {
- if (workingCapitalOption === 'dynamic') {
- activeWorkingCapitalDays = p.normalized_logistics_per_base > 0
- ? 1 / p.normalized_logistics_per_base
- : 0;
- } else {
- activeWorkingCapitalDays = parseInt(workingCapitalOption, 10);
- }
- capex_val += (opex_val * activeWorkingCapitalDays);
- }
-
- let hq_capex = 0;
- if (targetPermitOption !== 'omit') {
- const targetPermit = parseInt(targetPermitOption, 10);
- if (targetPermit >= 3) {
- const hqLevelStr = (targetPermit - 1).toString();
- if (p.hq_costs && p.hq_costs[hqLevelStr]) {
- hq_capex = p.hq_costs[hqLevelStr][capexMetric];
- capex_val += hq_capex;
- }
- }
- }
- let shipsNeeded = 0;
- let activeShipCapex = 0;
- if (roundTripOption !== 'omit') {
- const roundTripHours = parseInt(roundTripOption, 10);
- shipsNeeded = p.normalized_logistics_per_base * (roundTripHours / 24);
- activeShipCapex = shipsNeeded * 800_000;
- capex_val += activeShipCapex;
- }
- const profit_per_base = revenue_val - opex_val;
- const break_even = profit_per_base > 0 ? capex_val / profit_per_base : Infinity;
-
- // EXTREME DETAIL: We determine the specific market weight of this recipe line.
- // Multiplying the revenue (value per day per base) by the market capacity (max bases allowed)
- // yields the total cash flow value of all available trades in the global FIO market for this bottleneck.
- const market_cash_flow = revenue_val * p.market_capacity_base;
-
- return {
- ...p,
- capex_val,
- opex_val,
- revenue_val,
- profit_per_base,
- break_even,
- hq_capex,
- activeWorkingCapitalDays,
- activeShipCapex,
- shipsNeeded,
- market_cash_flow
- };
- });
- if (!showNegativeProfit) {
- profitsWithMetrics = profitsWithMetrics.filter(p => p.profit_per_base > 0);
- }
- // EXTREME DETAIL: Overhauled the extraction arrays to store both the numerical value AND the weight parameter.
- const numSortObj = (a: {val: number, weight: number}, b: {val: number, weight: number}) => (a.val < b.val ? -1 : a.val > b.val ? 1 : 0);
- const arrProfit = profitsWithMetrics.map(p => ({val: p.profit_per_base, weight: p.market_cash_flow})).sort(numSortObj);
- const arrBreak = profitsWithMetrics.map(p => ({val: p.break_even, weight: p.market_cash_flow})).sort(numSortObj);
- const arrCapex = profitsWithMetrics.map(p => ({val: p.capex_val, weight: p.market_cash_flow})).sort(numSortObj);
- const arrOpex = profitsWithMetrics.map(p => ({val: p.opex_val, weight: p.market_cash_flow})).sort(numSortObj);
- const arrLog = profitsWithMetrics.map(p => ({val: p.normalized_logistics_per_base, weight: p.market_cash_flow})).sort(numSortObj);
- const arrCap = profitsWithMetrics.map(p => ({val: p.market_capacity_base, weight: p.market_cash_flow})).sort(numSortObj);
- profitsWithMetrics.sort((a, b) => {
- let valA: any = a[currentSortKey as keyof ProfitWithMetrics];
- let valB: any = b[currentSortKey as keyof ProfitWithMetrics];
-
- 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 profitsWithMetrics) {
- const tr = document.createElement('tr');
-
- // Map the raw value to the selected percentile rank.
- const pctProfit = getPercentiles(p.profit_per_base, arrProfit, false)[percentileMode === 'absolute' ? 'abs' : 'rel'];
- const pctBreak = getPercentiles(p.break_even, arrBreak, true)[percentileMode === 'absolute' ? 'abs' : 'rel'];
- const pctCapex = getPercentiles(p.capex_val, arrCapex, true)[percentileMode === 'absolute' ? 'abs' : 'rel'];
- const pctOpex = getPercentiles(p.opex_val, arrOpex, true)[percentileMode === 'absolute' ? 'abs' : 'rel'];
- const pctLog = getPercentiles(p.normalized_logistics_per_base, arrLog, true)[percentileMode === 'absolute' ? 'abs' : 'rel'];
- const pctCap = getPercentiles(p.market_capacity_base, arrCap, false)[percentileMode === 'absolute' ? 'abs' : 'rel'];
-
- // Interplate the format string requested directly into the <small> span wrapper.
- // EXTREME DETAIL: Replaced all space characters right before the <span> wrappers with <br> tags
- // to guarantee consistent vertical stacking of the main value and the percentile regardless of CSS flex/wrapping behaviors.
- tr.innerHTML = `
- <td>${p.outputs.map(o => o.ticker).join(', ')}</td>
- <td>${expertise[p.expertise]}</td>
- <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>
- <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>
- <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>
- <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>
- <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>
- <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>
- `;
- const output = tr.querySelector('td')!;
- output.dataset.tooltip = p.recipe;
- const profitCell = tr.querySelectorAll('td')[2];
- const runs_per_base = p.runs_per_day / (p.area / 500);
-
- profitCell.dataset.tooltip = formatMatPrices(p.outputs, revenueMetric, runs_per_base) + '\n\n' +
- formatMatPrices(p.input_costs, opexMetric, runs_per_base) + '\n' +
- '+ worker consumables\n\n' +
- `(${formatSigFig(p.revenue_val)} - ${formatSigFig(p.opex_val)}) = ${formatSigFig(p.profit_per_base)}`;
- const capexCell = tr.querySelectorAll('td')[4];
- capexCell.dataset.tooltip = `Base Construction: ${formatSigFig(p.capex[capexMetric] / (p.area / 500))}`;
-
- if (workingCapitalOption !== 'omit') {
- capexCell.dataset.tooltip += `\nWorking Capital (${formatSigFig(p.activeWorkingCapitalDays)} days): ${formatSigFig(p.opex_val * p.activeWorkingCapitalDays)}`;
- }
-
- if (targetPermitOption !== 'omit' && p.hq_capex > 0) {
- capexCell.dataset.tooltip += `\nHQ Upgrade (Permit ${targetPermitOption}): ${formatSigFig(p.hq_capex)}`;
- }
-
- if (roundTripOption !== 'omit') {
- capexCell.dataset.tooltip += `\nShip CapEx: ${formatSigFig(p.activeShipCapex)} (${formatSigFig(p.shipsNeeded)} ships)`;
- }
- const marketCell = tr.querySelectorAll('td')[7];
- 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`;
- tbody.appendChild(tr);
- }
- document.getElementById('last-updated')!.textContent =
- `last updated: ${lastModified.toLocaleString(undefined, {dateStyle: 'full', timeStyle: 'long', hour12: false})}`;
- saveState();
- }
- // EXTREME DETAIL: Overhauled function to calculate both Absolute and Relative Volume-Weighted Percentiles.
- // To ensure the mathematical max bounds cleanly hit 100.0%, the denominator is extracted dynamically
- // relative to the highest data point present in the array.
- function getPercentiles(val: number, sortedArr: {val: number, weight: number}[], invert: boolean = false): {abs: string, rel: string} {
- if (sortedArr.length < 2) return {abs: "100.0%", rel: "100.0%"};
-
- let lessCount = 0;
- let lessWeight = 0;
-
- for (let i = 0; i < sortedArr.length; i++) {
- if (sortedArr[i].val < val) {
- lessCount++;
- lessWeight += sortedArr[i].weight;
- } else {
- break;
- }
- }
-
- const maxVal = sortedArr[sortedArr.length - 1].val;
- let maxLessCount = 0;
- let maxLessWeight = 0;
-
- for (let i = 0; i < sortedArr.length; i++) {
- if (sortedArr[i].val < maxVal) {
- maxLessCount++;
- maxLessWeight += sortedArr[i].weight;
- } else {
- break;
- }
- }
- let absDecimal = maxLessCount > 0 ? lessCount / maxLessCount : 1.0;
- let relDecimal = maxLessWeight > 0 ? lessWeight / maxLessWeight : 1.0;
- if (invert) {
- absDecimal = 1.0 - absDecimal;
- relDecimal = 1.0 - relDecimal;
- }
-
- return {
- abs: (absDecimal * 100).toFixed(1) + "%",
- rel: (relDecimal * 100).toFixed(1) + "%"
- };
- }
- function saveState() {
- localStorage.setItem('roi-cx', cxSelect.value);
- localStorage.setItem('roi-expertise', expertiseSelect.value);
- localStorage.setItem('roi-building', buildingSelect.value);
- localStorage.setItem('roi-low-volume', lowVolume.checked.toString());
- localStorage.setItem('roi-sort-key', currentSortKey);
- localStorage.setItem('roi-sort-asc', currentSortAsc.toString());
- localStorage.setItem('roi-capex-metric', capexMetric);
- localStorage.setItem('roi-opex-metric', opexMetric);
- localStorage.setItem('roi-revenue-metric', revenueMetric);
- localStorage.setItem('roi-show-negative', showNegativeProfit.toString());
- localStorage.setItem('roi-round-trip', roundTripOption);
- localStorage.setItem('roi-working-capital-opt', workingCapitalOption);
- localStorage.setItem('roi-target-permit', targetPermitOption);
- localStorage.setItem('roi-pct-mode', percentileMode);
- }
- 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[], metric: MetricType, runs_per_day: number): string {
- return matPrices.map(({ticker, amount, vwap_7d, bid, ask}) => {
- const val = metric === 'vwap' ? vwap_7d : metric === 'bid' ? (bid ?? vwap_7d) : (ask ?? vwap_7d);
- const daily_amount = amount * runs_per_day;
- return `${ticker}: ${formatSigFig(daily_amount)} × ${formatSigFig(val)} = ${formatSigFig(daily_amount * val)}`;
- }).join('\n');
- }
- // EXTREME DETAIL: Injects a comprehensive, casually written documentation section at the bottom of the page.
- // This demystifies the PrUn API data pipeline and exposes the raw formulas used for the table's metrics.
- function initDocumentation() {
- // Prevent duplicate documentation blocks if initialized multiple times (e.g. HMR or weird state)
- if (document.getElementById('methodology-doc')) return;
- const docDetails = document.createElement('details');
- docDetails.id = 'methodology-doc';
- docDetails.style.marginTop = '40px';
- docDetails.style.marginBottom = '40px';
- docDetails.style.padding = '15px';
- docDetails.style.border = '1px solid #444';
- docDetails.style.borderRadius = '8px';
- docDetails.style.backgroundColor = 'rgba(128, 128, 128, 0.1)';
-
- docDetails.innerHTML = `
- <summary style="cursor: pointer; font-size: 1.25em; font-weight: bold;">📖 Documentation: How These Numbers Are Calculated</summary>
- <div style="margin-top: 20px; line-height: 1.6; font-size: 0.95em;">
- <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>
-
- <h3 style="margin-top: 1.5em;">1. The "Base" Normalization</h3>
- <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>
- <h3 style="margin-top: 1.5em;">2. Revenue & OpEx (Operational Expenditure)</h3>
- <p><strong>Revenue:</strong> Daily output amount × Runs per day × Selected Price (VWAP, Bid, or Ask).</p>
- <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>
- <p><strong>Profit/Base:</strong> Total Daily Revenue - Total Daily OpEx.</p>
- <h3 style="margin-top: 1.5em;">3. CapEx (Capital Expenditure) & Break Even</h3>
- <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>
- <ul style="margin-top: 0.5em; margin-bottom: 0.5em; padding-left: 20px;">
- <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>
- <li style="margin-bottom: 0.5em;"><strong>HQ Upgrades:</strong> Adds the material costs of upgrading your Headquarters to the selected Permit level.</li>
- <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>
- </ul>
- <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>
- <h3 style="margin-top: 1.5em;">4. Logistics Bottleneck</h3>
- <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>
- <h3 style="margin-top: 1.5em;">5. Market Capacity (Saturation)</h3>
- <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>
- <h3 style="margin-top: 1.5em;">6. Percentiles (Normalized vs Absolute)</h3>
- <p>Percentiles rank every recipe from 0.0% (worst) to 100.0% (best).<br>
- <strong>Absolute</strong> percentiles treat every recipe equally (e.g., 1 vote per recipe).<br>
- <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>
- </div>
- `;
- const table = document.querySelector('table');
- if (table && table.parentNode) {
- table.parentNode.appendChild(docDetails);
- } else {
- document.body.appendChild(docDetails);
- }
- }
- setupPopover();
- lowVolume.addEventListener('change', render);
- cxSelect.addEventListener('change', render);
- expertiseSelect.addEventListener('change', render);
- buildingSelect.addEventListener('change', render);
- render();
- initDocumentation();
- interface Metrics {
- vwap: number;
- bid: number;
- ask: number;
- }
- interface Profit {
- outputs: MatPrice[]
- recipe: string
- expertise: keyof typeof expertise
- building: string
- area: number
- capex: Metrics
- opex: Metrics
- revenue: Metrics
- input_costs: MatPrice[]
- runs_per_day: number
- logistics_per_base: number
- normalized_logistics_per_base: number
- logistics_bottleneck: string
- output_per_day: number
- average_traded_7d: number
- market_capacity_base: number
- hq_costs: Record<string, Metrics>
- }
- interface ProfitWithMetrics extends Profit {
- capex_val: number;
- opex_val: number;
- revenue_val: number;
- profit_per_day: number;
- profit_per_base: number;
- break_even: number;
- hq_capex: number;
- activeWorkingCapitalDays: number;
- activeShipCapex: number;
- shipsNeeded: number;
- market_cash_flow: number; // Exported weight tracker
- }
- interface MatPrice {
- ticker: string
- amount: number
- vwap_7d: number
- bid: number | null
- ask: number | null
- }
- interface Building {
- building_type: 'INFRASTRUCTURE' | 'PLANETARY' | 'PRODUCTION';
- building_ticker: string;
- expertise: keyof typeof expertise;
- }
|