|
@@ -33,9 +33,13 @@ for (const key of Object.keys(expertise)) {
|
|
|
}
|
|
}
|
|
|
const buildingSelect = document.querySelector('select#building') as HTMLSelectElement;
|
|
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;
|
|
|
|
|
|
|
+// EXTREME DETAIL: We replace the distinct `formatWhole` and `formatDecimal` formatters with a unified
|
|
|
|
|
+// `formatSigFig` formatter. Utilizing the `compact` notation automatically caps the output at
|
|
|
|
|
+// 3 significant figures and organically appends "K", "M", etc. to large numbers.
|
|
|
|
|
+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-cx')) cxSelect.value = localStorage.getItem('roi-cx')!;
|
|
|
if (localStorage.getItem('roi-expertise')) expertiseSelect.value = localStorage.getItem('roi-expertise')!;
|
|
if (localStorage.getItem('roi-expertise')) expertiseSelect.value = localStorage.getItem('roi-expertise')!;
|
|
@@ -53,6 +57,9 @@ let opexMetric: MetricType = (localStorage.getItem('roi-opex-metric') as MetricT
|
|
|
let revenueMetric: MetricType = (localStorage.getItem('roi-revenue-metric') as MetricType) || 'vwap';
|
|
let revenueMetric: MetricType = (localStorage.getItem('roi-revenue-metric') as MetricType) || 'vwap';
|
|
|
let includeShips: boolean = localStorage.getItem('roi-include-ships') === 'true';
|
|
let includeShips: boolean = localStorage.getItem('roi-include-ships') === 'true';
|
|
|
|
|
|
|
|
|
|
+// EXTREME DETAIL: Track the state of the user's working capital input. Default to 3 days.
|
|
|
|
|
+let workingCapitalDays: number = parseInt(localStorage.getItem('roi-working-capital') || '3', 10);
|
|
|
|
|
+
|
|
|
async function render() {
|
|
async function render() {
|
|
|
const tbody = document.querySelector('tbody')!;
|
|
const tbody = document.querySelector('tbody')!;
|
|
|
tbody.innerHTML = '';
|
|
tbody.innerHTML = '';
|
|
@@ -65,7 +72,8 @@ async function render() {
|
|
|
if (!metricControlsInitialized) {
|
|
if (!metricControlsInitialized) {
|
|
|
const controls = document.createElement('div');
|
|
const controls = document.createElement('div');
|
|
|
controls.style.marginBottom = '15px';
|
|
controls.style.marginBottom = '15px';
|
|
|
- // EXTREME DETAIL: Added a new checkbox for 'Include Ship CapEx' directly alongside the dropdowns.
|
|
|
|
|
|
|
+ // EXTREME DETAIL: Added the numerical input `<input type="number">` allowing users to configure
|
|
|
|
|
+ // exactly how many days of OpEx should be buffered into their CapEx.
|
|
|
controls.innerHTML = `
|
|
controls.innerHTML = `
|
|
|
<label style="margin-right: 15px;">CapEx Price:
|
|
<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>
|
|
<select id="capex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
|
|
@@ -76,9 +84,12 @@ async function render() {
|
|
|
<label style="margin-right: 15px;">Revenue Price:
|
|
<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>
|
|
<select id="revenue-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
|
|
|
</label>
|
|
</label>
|
|
|
- <label>
|
|
|
|
|
|
|
+ <label style="margin-right: 15px;">
|
|
|
<input type="checkbox" id="include-ships"> Include Ship CapEx
|
|
<input type="checkbox" id="include-ships"> Include Ship CapEx
|
|
|
</label>
|
|
</label>
|
|
|
|
|
+ <label>
|
|
|
|
|
+ <input type="number" id="working-capital" min="0" step="1" style="width: 50px;"> Days OpEx
|
|
|
|
|
+ </label>
|
|
|
`;
|
|
`;
|
|
|
const table = document.querySelector('table');
|
|
const table = document.querySelector('table');
|
|
|
if (table) table.parentNode?.insertBefore(controls, table);
|
|
if (table) table.parentNode?.insertBefore(controls, table);
|
|
@@ -87,6 +98,7 @@ async function render() {
|
|
|
(document.getElementById('opex-metric') as HTMLSelectElement).value = opexMetric;
|
|
(document.getElementById('opex-metric') as HTMLSelectElement).value = opexMetric;
|
|
|
(document.getElementById('revenue-metric') as HTMLSelectElement).value = revenueMetric;
|
|
(document.getElementById('revenue-metric') as HTMLSelectElement).value = revenueMetric;
|
|
|
(document.getElementById('include-ships') as HTMLInputElement).checked = includeShips;
|
|
(document.getElementById('include-ships') as HTMLInputElement).checked = includeShips;
|
|
|
|
|
+ (document.getElementById('working-capital') as HTMLInputElement).value = workingCapitalDays.toString();
|
|
|
|
|
|
|
|
document.getElementById('capex-metric')!.addEventListener('change', (e) => {
|
|
document.getElementById('capex-metric')!.addEventListener('change', (e) => {
|
|
|
capexMetric = (e.target as HTMLSelectElement).value as MetricType;
|
|
capexMetric = (e.target as HTMLSelectElement).value as MetricType;
|
|
@@ -104,6 +116,10 @@ async function render() {
|
|
|
includeShips = (e.target as HTMLInputElement).checked;
|
|
includeShips = (e.target as HTMLInputElement).checked;
|
|
|
render();
|
|
render();
|
|
|
});
|
|
});
|
|
|
|
|
+ document.getElementById('working-capital')!.addEventListener('change', (e) => {
|
|
|
|
|
+ workingCapitalDays = parseInt((e.target as HTMLInputElement).value, 10);
|
|
|
|
|
+ render();
|
|
|
|
|
+ });
|
|
|
metricControlsInitialized = true;
|
|
metricControlsInitialized = true;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -124,18 +140,18 @@ async function render() {
|
|
|
th.dataset.tooltip = 'Click to sort.\n\nDaily profit scaled to a full 500-area planetary base.';
|
|
th.dataset.tooltip = 'Click to sort.\n\nDaily profit scaled to a full 500-area planetary base.';
|
|
|
} else if (keys[i] === 'capex_val') {
|
|
} else if (keys[i] === 'capex_val') {
|
|
|
th.textContent = 'CapEx/Base';
|
|
th.textContent = 'CapEx/Base';
|
|
|
- th.dataset.tooltip = 'Click to sort.\n\nTotal capital expenditure (construction + habitation costs) scaled to a full 500-area planetary base.\nIf "Include Ship CapEx" is toggled, adds the cost of ships (800k each) required for daily logistics.';
|
|
|
|
|
|
|
+ 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), and optional Ship CapEx.';
|
|
|
} else if (keys[i] === 'opex_val') {
|
|
} else if (keys[i] === 'opex_val') {
|
|
|
th.textContent = 'OpEx/Base';
|
|
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.';
|
|
th.dataset.tooltip = 'Click to sort.\n\nDaily operational expenditure (input materials + worker consumables) scaled to a full 500-area planetary base.';
|
|
|
} else if (keys[i] === 'logistics_per_base') {
|
|
} else if (keys[i] === 'logistics_per_base') {
|
|
|
th.textContent = 'Logistics/Base';
|
|
th.textContent = 'Logistics/Base';
|
|
|
- th.dataset.tooltip = 'Click to sort.\n\nDaily logistics volume/weight scaled to a full 500-area planetary 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.';
|
|
|
} else if (keys[i] === 'market_capacity_base') {
|
|
} else if (keys[i] === 'market_capacity_base') {
|
|
|
th.textContent = 'Market Cap (Bases)';
|
|
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.';
|
|
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.';
|
|
|
} else if (keys[i] === 'break_even') {
|
|
} else if (keys[i] === 'break_even') {
|
|
|
- th.dataset.tooltip = 'Click to sort.\n\nBreak Even: (CapEx + 3 days of OpEx) ÷ daily profit. Includes 3 days of operating costs as working capital.';
|
|
|
|
|
|
|
+ th.dataset.tooltip = 'Click to sort.\n\nBreak Even: CapEx ÷ daily profit. Note that CapEx dynamically includes working capital (days of OpEx) to accurately reflect operational readiness.';
|
|
|
} else {
|
|
} else {
|
|
|
th.dataset.tooltip = 'Click to sort.';
|
|
th.dataset.tooltip = 'Click to sort.';
|
|
|
}
|
|
}
|
|
@@ -188,19 +204,20 @@ async function render() {
|
|
|
|
|
|
|
|
const profitsWithMetrics: ProfitWithMetrics[] = filteredProfits.map(p => {
|
|
const profitsWithMetrics: ProfitWithMetrics[] = filteredProfits.map(p => {
|
|
|
const bases = p.area / 500;
|
|
const bases = p.area / 500;
|
|
|
- let capex_val = p.capex[capexMetric] / bases;
|
|
|
|
|
const opex_val = p.opex[opexMetric] / bases;
|
|
const opex_val = p.opex[opexMetric] / bases;
|
|
|
const revenue_val = p.revenue[revenueMetric] / bases;
|
|
const revenue_val = p.revenue[revenueMetric] / bases;
|
|
|
|
|
|
|
|
- // EXTREME DETAIL: If the user toggles the UI checkbox, we silently inject the precalculated
|
|
|
|
|
- // ship capital expenditures into the active CapEx pipeline. This causes the break-even math
|
|
|
|
|
- // to dynamically cascade and update instantly.
|
|
|
|
|
|
|
+ // EXTREME DETAIL: CapEx now completely absorbs the working capital (days of OpEx).
|
|
|
|
|
+ // Because we moved the addition directly into `capex_val`, it will be explicitly rendered
|
|
|
|
|
+ // inside the CapEx column on the dashboard, making the break-even math visually apparent.
|
|
|
|
|
+ let capex_val = (p.capex[capexMetric] / bases) + (opex_val * workingCapitalDays);
|
|
|
|
|
+
|
|
|
if (includeShips) {
|
|
if (includeShips) {
|
|
|
capex_val += p.ship_capex_per_base;
|
|
capex_val += p.ship_capex_per_base;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const profit_per_base = revenue_val - opex_val;
|
|
const profit_per_base = revenue_val - opex_val;
|
|
|
- const break_even = profit_per_base > 0 ? (capex_val + 3 * opex_val) / profit_per_base : Infinity;
|
|
|
|
|
|
|
+ const break_even = profit_per_base > 0 ? capex_val / profit_per_base : Infinity;
|
|
|
|
|
|
|
|
return { ...p, capex_val, opex_val, revenue_val, profit_per_base, break_even };
|
|
return { ...p, capex_val, opex_val, revenue_val, profit_per_base, break_even };
|
|
|
});
|
|
});
|
|
@@ -223,15 +240,17 @@ async function render() {
|
|
|
const volumeRatio = p.output_per_day / p.average_traded_7d;
|
|
const volumeRatio = p.output_per_day / p.average_traded_7d;
|
|
|
const tr = document.createElement('tr');
|
|
const tr = document.createElement('tr');
|
|
|
|
|
|
|
|
|
|
+ // EXTREME DETAIL: We replaced all formatDecimal/formatWhole calls with formatSigFig.
|
|
|
|
|
+ // Additionally, we append the newly generated bottleneck string onto the Logistics cell.
|
|
|
tr.innerHTML = `
|
|
tr.innerHTML = `
|
|
|
<td>${p.outputs.map(o => o.ticker).join(', ')}</td>
|
|
<td>${p.outputs.map(o => o.ticker).join(', ')}</td>
|
|
|
<td>${expertise[p.expertise]}</td>
|
|
<td>${expertise[p.expertise]}</td>
|
|
|
- <td style="color: ${color(p.profit_per_base, 0, 150000)}">${formatDecimal(p.profit_per_base)}</td>
|
|
|
|
|
- <td><span style="color: ${color(p.break_even, 30, 3)}">${formatDecimal(p.break_even)}</span>d</td>
|
|
|
|
|
- <td style="color: ${color(p.capex_val, 3_000_000, 400_000)}">${formatWhole(p.capex_val)}</td>
|
|
|
|
|
- <td style="color: ${color(p.opex_val, 400_000, 10_000)}">${formatWhole(p.opex_val)}</td>
|
|
|
|
|
- <td style="color: ${color(p.logistics_per_base, 1000, 100)}">${formatDecimal(p.logistics_per_base)}</td>
|
|
|
|
|
- <td style="color: ${color(p.market_capacity_base, 0.04, 1)}">${formatDecimal(p.market_capacity_base)}</td>
|
|
|
|
|
|
|
+ <td style="color: ${color(p.profit_per_base, 0, 150000)}">${formatSigFig(p.profit_per_base)}</td>
|
|
|
|
|
+ <td><span style="color: ${color(p.break_even, 30, 3)}">${formatSigFig(p.break_even)}</span>d</td>
|
|
|
|
|
+ <td style="color: ${color(p.capex_val, 3_000_000, 400_000)}">${formatSigFig(p.capex_val)}</td>
|
|
|
|
|
+ <td style="color: ${color(p.opex_val, 400_000, 10_000)}">${formatSigFig(p.opex_val)}</td>
|
|
|
|
|
+ <td style="color: ${color(p.logistics_per_base, 1000, 100)}">${formatSigFig(p.logistics_per_base)} ${p.logistics_bottleneck}</td>
|
|
|
|
|
+ <td style="color: ${color(p.market_capacity_base, 0.04, 1)}">${formatSigFig(p.market_capacity_base)}</td>
|
|
|
`;
|
|
`;
|
|
|
|
|
|
|
|
const output = tr.querySelector('td')!;
|
|
const output = tr.querySelector('td')!;
|
|
@@ -243,19 +262,19 @@ async function render() {
|
|
|
profitCell.dataset.tooltip = formatMatPrices(p.outputs, revenueMetric, runs_per_base) + '\n\n' +
|
|
profitCell.dataset.tooltip = formatMatPrices(p.outputs, revenueMetric, runs_per_base) + '\n\n' +
|
|
|
formatMatPrices(p.input_costs, opexMetric, runs_per_base) + '\n' +
|
|
formatMatPrices(p.input_costs, opexMetric, runs_per_base) + '\n' +
|
|
|
'+ worker consumables\n\n' +
|
|
'+ worker consumables\n\n' +
|
|
|
- `(${formatWhole(p.revenue_val)} - ${formatWhole(p.opex_val)}) = ${formatDecimal(p.profit_per_base)}`;
|
|
|
|
|
|
|
+ `(${formatSigFig(p.revenue_val)} - ${formatSigFig(p.opex_val)}) = ${formatSigFig(p.profit_per_base)}`;
|
|
|
|
|
|
|
|
- // EXTREME DETAIL: To provide absolute transparency, if the ship toggle is active,
|
|
|
|
|
- // we inject a dedicated tooltip into the CapEx cell specifically. This proves to the user
|
|
|
|
|
- // exactly how many fractional ships were assumed and how much they cost.
|
|
|
|
|
|
|
+ // EXTREME DETAIL: Because working capital is now part of CapEx, we break down
|
|
|
|
|
+ // the constituent components in the tooltip so the math is transparent.
|
|
|
const capexCell = tr.querySelectorAll('td')[4];
|
|
const capexCell = tr.querySelectorAll('td')[4];
|
|
|
- capexCell.dataset.tooltip = `Base Construction: ${formatWhole(p.capex[capexMetric] / (p.area / 500))}`;
|
|
|
|
|
|
|
+ capexCell.dataset.tooltip = `Base Construction: ${formatSigFig(p.capex[capexMetric] / (p.area / 500))}`;
|
|
|
|
|
+ capexCell.dataset.tooltip += `\nWorking Capital (${workingCapitalDays} days): ${formatSigFig(p.opex_val * workingCapitalDays)}`;
|
|
|
if (includeShips) {
|
|
if (includeShips) {
|
|
|
- capexCell.dataset.tooltip += `\nShip CapEx: ${formatWhole(p.ship_capex_per_base)} (${formatDecimal(p.ship_capex_per_base / 800_000)} ships)`;
|
|
|
|
|
|
|
+ capexCell.dataset.tooltip += `\nShip CapEx: ${formatSigFig(p.ship_capex_per_base)} (${formatSigFig(p.ship_capex_per_base / 800_000)} ships)`;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const marketCell = tr.querySelectorAll('td')[7];
|
|
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 / 500))} produced/day/base = ${formatDecimal(p.market_capacity_base)} equivalent bases`;
|
|
|
|
|
|
|
+ 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);
|
|
tbody.appendChild(tr);
|
|
|
}
|
|
}
|
|
@@ -276,6 +295,7 @@ function saveState() {
|
|
|
localStorage.setItem('roi-opex-metric', opexMetric);
|
|
localStorage.setItem('roi-opex-metric', opexMetric);
|
|
|
localStorage.setItem('roi-revenue-metric', revenueMetric);
|
|
localStorage.setItem('roi-revenue-metric', revenueMetric);
|
|
|
localStorage.setItem('roi-include-ships', includeShips.toString());
|
|
localStorage.setItem('roi-include-ships', includeShips.toString());
|
|
|
|
|
+ localStorage.setItem('roi-working-capital', workingCapitalDays.toString());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function color(n: number, low: number, high: number): string {
|
|
function color(n: number, low: number, high: number): string {
|
|
@@ -287,7 +307,7 @@ function formatMatPrices(matPrices: MatPrice[], metric: MetricType, runs_per_day
|
|
|
return matPrices.map(({ticker, amount, vwap_7d, bid, ask}) => {
|
|
return matPrices.map(({ticker, amount, vwap_7d, bid, ask}) => {
|
|
|
const val = metric === 'vwap' ? vwap_7d : metric === 'bid' ? (bid ?? vwap_7d) : (ask ?? vwap_7d);
|
|
const val = metric === 'vwap' ? vwap_7d : metric === 'bid' ? (bid ?? vwap_7d) : (ask ?? vwap_7d);
|
|
|
const daily_amount = amount * runs_per_day;
|
|
const daily_amount = amount * runs_per_day;
|
|
|
- return `${ticker}: ${formatDecimal(daily_amount)} × ${formatDecimal(val)} = ${formatWhole(daily_amount * val)}`;
|
|
|
|
|
|
|
+ return `${ticker}: ${formatSigFig(daily_amount)} × ${formatSigFig(val)} = ${formatSigFig(daily_amount * val)}`;
|
|
|
}).join('\n');
|
|
}).join('\n');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -316,6 +336,7 @@ interface Profit {
|
|
|
input_costs: MatPrice[]
|
|
input_costs: MatPrice[]
|
|
|
runs_per_day: number
|
|
runs_per_day: number
|
|
|
logistics_per_base: number
|
|
logistics_per_base: number
|
|
|
|
|
+ logistics_bottleneck: string // Extracted from backend
|
|
|
output_per_day: number
|
|
output_per_day: number
|
|
|
average_traded_7d: number
|
|
average_traded_7d: number
|
|
|
market_capacity_base: number
|
|
market_capacity_base: number
|