|
@@ -56,8 +56,6 @@ let capexMetric: MetricType = (localStorage.getItem('roi-capex-metric') as Metri
|
|
|
let opexMetric: MetricType = (localStorage.getItem('roi-opex-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 revenueMetric: MetricType = (localStorage.getItem('roi-revenue-metric') as MetricType) || 'vwap';
|
|
|
|
|
|
|
|
-// EXTREME DETAIL: Because '0' is no longer an option in the UI, we update the fallback
|
|
|
|
|
-// state to 'omit' to prevent the script from trying to load a non-existent dropdown item.
|
|
|
|
|
let roundTripOption: string = localStorage.getItem('roi-round-trip') || 'omit';
|
|
let roundTripOption: string = localStorage.getItem('roi-round-trip') || 'omit';
|
|
|
let showNegativeProfit: boolean = localStorage.getItem('roi-show-negative') !== 'false';
|
|
let showNegativeProfit: boolean = localStorage.getItem('roi-show-negative') !== 'false';
|
|
|
let workingCapitalOption: string = localStorage.getItem('roi-working-capital-opt') || 'dynamic';
|
|
let workingCapitalOption: string = localStorage.getItem('roi-working-capital-opt') || 'dynamic';
|
|
@@ -75,9 +73,6 @@ 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: We generate the massive dropdown arrays on the fly using ES6 interpolation.
|
|
|
|
|
- // Array.from({length: x}) creates an empty array of size x, and the mapping function evaluates
|
|
|
|
|
- // the index (i) to inject the correct incremental HTML options directly into the template string.
|
|
|
|
|
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>
|
|
@@ -171,6 +166,8 @@ async function render() {
|
|
|
'capex_val', 'opex_val', 'normalized_logistics_per_base', 'market_capacity_base'
|
|
'capex_val', 'opex_val', 'normalized_logistics_per_base', 'market_capacity_base'
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
|
|
+ const pctExplainer = '\n(Percentiles: Relative Volume / Absolute. 100.0% is the most desirable outcome. Relative rank is weighted by total market cash flow.)';
|
|
|
|
|
+
|
|
|
ths.forEach((th, i) => {
|
|
ths.forEach((th, i) => {
|
|
|
if (keys[i]) {
|
|
if (keys[i]) {
|
|
|
th.style.cursor = 'pointer';
|
|
th.style.cursor = 'pointer';
|
|
@@ -178,21 +175,21 @@ async function render() {
|
|
|
|
|
|
|
|
if (keys[i] === 'profit_per_base') {
|
|
if (keys[i] === 'profit_per_base') {
|
|
|
th.textContent = 'Profit/Base';
|
|
th.textContent = 'Profit/Base';
|
|
|
- th.dataset.tooltip = 'Click to sort.\n\nDaily profit scaled to a full 500-area planetary base.\n(Percentiles rank 100.0% as the most desirable outcome, i.e., highest profit or lowest cost.)';
|
|
|
|
|
|
|
+ th.dataset.tooltip = 'Click to sort.\n\nDaily profit scaled to a full 500-area planetary base.' + pctExplainer;
|
|
|
} 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 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).';
|
|
|
|
|
|
|
+ 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') {
|
|
} 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.' + pctExplainer;
|
|
|
} else if (keys[i] === 'normalized_logistics_per_base') {
|
|
} else if (keys[i] === 'normalized_logistics_per_base') {
|
|
|
th.textContent = 'Logistics/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³).';
|
|
|
|
|
|
|
+ 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') {
|
|
} 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.' + pctExplainer;
|
|
|
} else if (keys[i] === 'break_even') {
|
|
} 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.';
|
|
|
|
|
|
|
+ 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 {
|
|
} else {
|
|
|
th.dataset.tooltip = 'Click to sort.';
|
|
th.dataset.tooltip = 'Click to sort.';
|
|
|
}
|
|
}
|
|
@@ -286,6 +283,11 @@ async function render() {
|
|
|
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 / profit_per_base : Infinity;
|
|
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 {
|
|
return {
|
|
|
...p,
|
|
...p,
|
|
|
capex_val,
|
|
capex_val,
|
|
@@ -296,7 +298,8 @@ async function render() {
|
|
|
hq_capex,
|
|
hq_capex,
|
|
|
activeWorkingCapitalDays,
|
|
activeWorkingCapitalDays,
|
|
|
activeShipCapex,
|
|
activeShipCapex,
|
|
|
- shipsNeeded
|
|
|
|
|
|
|
+ shipsNeeded,
|
|
|
|
|
+ market_cash_flow
|
|
|
};
|
|
};
|
|
|
});
|
|
});
|
|
|
|
|
|
|
@@ -304,13 +307,14 @@ async function render() {
|
|
|
profitsWithMetrics = profitsWithMetrics.filter(p => p.profit_per_base > 0);
|
|
profitsWithMetrics = profitsWithMetrics.filter(p => p.profit_per_base > 0);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const numSort = (a: number, b: number) => (a < b ? -1 : a > b ? 1 : 0);
|
|
|
|
|
- const arrProfit = profitsWithMetrics.map(p => p.profit_per_base).sort(numSort);
|
|
|
|
|
- const arrBreak = profitsWithMetrics.map(p => p.break_even).sort(numSort);
|
|
|
|
|
- const arrCapex = profitsWithMetrics.map(p => p.capex_val).sort(numSort);
|
|
|
|
|
- const arrOpex = profitsWithMetrics.map(p => p.opex_val).sort(numSort);
|
|
|
|
|
- const arrLog = profitsWithMetrics.map(p => p.normalized_logistics_per_base).sort(numSort);
|
|
|
|
|
- const arrCap = profitsWithMetrics.map(p => p.market_capacity_base).sort(numSort);
|
|
|
|
|
|
|
+ // 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) => {
|
|
profitsWithMetrics.sort((a, b) => {
|
|
|
let valA: any = a[currentSortKey as keyof ProfitWithMetrics];
|
|
let valA: any = a[currentSortKey as keyof ProfitWithMetrics];
|
|
@@ -329,22 +333,24 @@ async function render() {
|
|
|
for (const p of profitsWithMetrics) {
|
|
for (const p of profitsWithMetrics) {
|
|
|
const tr = document.createElement('tr');
|
|
const tr = document.createElement('tr');
|
|
|
|
|
|
|
|
- const pctProfit = getPercentile(p.profit_per_base, arrProfit, false);
|
|
|
|
|
- const pctBreak = getPercentile(p.break_even, arrBreak, true);
|
|
|
|
|
- const pctCapex = getPercentile(p.capex_val, arrCapex, true);
|
|
|
|
|
- const pctOpex = getPercentile(p.opex_val, arrOpex, true);
|
|
|
|
|
- const pctLog = getPercentile(p.normalized_logistics_per_base, arrLog, true);
|
|
|
|
|
- const pctCap = getPercentile(p.market_capacity_base, arrCap, false);
|
|
|
|
|
|
|
+ // Map the raw value to both absolute and relative percentile ranks.
|
|
|
|
|
+ const pctProfit = getPercentiles(p.profit_per_base, arrProfit, false);
|
|
|
|
|
+ const pctBreak = getPercentiles(p.break_even, arrBreak, true);
|
|
|
|
|
+ const pctCapex = getPercentiles(p.capex_val, arrCapex, true);
|
|
|
|
|
+ const pctOpex = getPercentiles(p.opex_val, arrOpex, true);
|
|
|
|
|
+ const pctLog = getPercentiles(p.normalized_logistics_per_base, arrLog, true);
|
|
|
|
|
+ const pctCap = getPercentiles(p.market_capacity_base, arrCap, false);
|
|
|
|
|
|
|
|
|
|
+ // Interplate the Rel/Abs format string requested directly into the <small> span wrapper.
|
|
|
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)}">${formatSigFig(p.profit_per_base)} <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 <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)} <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)} <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} <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)} <span style="font-size: 0.85em; opacity: 0.6;">(${pctCap})</span></td>
|
|
|
|
|
|
|
+ <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.rel} / ${pctProfit.abs})</span></td>
|
|
|
|
|
+ <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.rel} / ${pctBreak.abs})</span></td>
|
|
|
|
|
+ <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.rel} / ${pctCapex.abs})</span></td>
|
|
|
|
|
+ <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.rel} / ${pctOpex.abs})</span></td>
|
|
|
|
|
+ <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.rel} / ${pctLog.abs})</span></td>
|
|
|
|
|
+ <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.rel} / ${pctCap.abs})</span></td>
|
|
|
`;
|
|
`;
|
|
|
|
|
|
|
|
const output = tr.querySelector('td')!;
|
|
const output = tr.querySelector('td')!;
|
|
@@ -384,20 +390,49 @@ async function render() {
|
|
|
saveState();
|
|
saveState();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function getPercentile(val: number, sortedArr: number[], invert: boolean = false): string {
|
|
|
|
|
- if (sortedArr.length < 2) return "100.0%";
|
|
|
|
|
- let less = 0;
|
|
|
|
|
|
|
+// 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++) {
|
|
for (let i = 0; i < sortedArr.length; i++) {
|
|
|
- if (sortedArr[i] < val) less++;
|
|
|
|
|
- else break;
|
|
|
|
|
|
|
+ if (sortedArr[i].val < val) {
|
|
|
|
|
+ lessCount++;
|
|
|
|
|
+ lessWeight += sortedArr[i].weight;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- let decimal = less / (sortedArr.length - 1);
|
|
|
|
|
|
|
+ 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) {
|
|
if (invert) {
|
|
|
- decimal = 1.0 - decimal;
|
|
|
|
|
|
|
+ absDecimal = 1.0 - absDecimal;
|
|
|
|
|
+ relDecimal = 1.0 - relDecimal;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- return (decimal * 100).toFixed(1) + "%";
|
|
|
|
|
|
|
+ return {
|
|
|
|
|
+ abs: (absDecimal * 100).toFixed(1) + "%",
|
|
|
|
|
+ rel: (relDecimal * 100).toFixed(1) + "%"
|
|
|
|
|
+ };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function saveState() {
|
|
function saveState() {
|
|
@@ -473,6 +508,7 @@ interface ProfitWithMetrics extends Profit {
|
|
|
activeWorkingCapitalDays: number;
|
|
activeWorkingCapitalDays: number;
|
|
|
activeShipCapex: number;
|
|
activeShipCapex: number;
|
|
|
shipsNeeded: number;
|
|
shipsNeeded: number;
|
|
|
|
|
+ market_cash_flow: number; // Exported weight tracker
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
interface MatPrice {
|
|
interface MatPrice {
|