|
|
@@ -44,8 +44,13 @@ if (localStorage.getItem('roi-low-volume')) lowVolume.checked = localStorage.get
|
|
|
|
|
|
let savedBuilding = localStorage.getItem('roi-building') || '';
|
|
|
|
|
|
-let currentSortKey: keyof ProfitWithMetrics | 'outputs' = (localStorage.getItem('roi-sort-key') as any) || 'break_even';
|
|
|
+// EXTREME DETAIL: Legacy cache migration. If the user had the old raw 'logistics_per_base' stored
|
|
|
+// as their active sort, we organically migrate them to the new normalized property.
|
|
|
+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;
|
|
|
|
|
|
@@ -68,8 +73,6 @@ async function render() {
|
|
|
if (!metricControlsInitialized) {
|
|
|
const controls = document.createElement('div');
|
|
|
controls.style.marginBottom = '15px';
|
|
|
- // EXTREME DETAIL: Re-arranged the `<label>` blocks so the descriptive text precedes the input boxes,
|
|
|
- // and updated "Target Permit" to "Permit Number" per the user's specification.
|
|
|
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>
|
|
|
@@ -129,9 +132,12 @@ async function render() {
|
|
|
|
|
|
if (!headersInitialized) {
|
|
|
const ths = document.querySelectorAll('th');
|
|
|
+ // EXTREME DETAIL: We swapped `logistics_per_base` for `normalized_logistics_per_base`.
|
|
|
+ // Clicking the Logistics header now intrinsically triggers a sort utilizing the ship fraction,
|
|
|
+ // ensuring that 1000m³ perfectly balances against 3000t.
|
|
|
const keys: (keyof ProfitWithMetrics | 'outputs')[] = [
|
|
|
'outputs', 'expertise', 'profit_per_base', 'break_even',
|
|
|
- 'capex_val', 'opex_val', 'logistics_per_base', 'market_capacity_base'
|
|
|
+ 'capex_val', 'opex_val', 'normalized_logistics_per_base', 'market_capacity_base'
|
|
|
];
|
|
|
|
|
|
ths.forEach((th, i) => {
|
|
|
@@ -141,16 +147,16 @@ async function render() {
|
|
|
|
|
|
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.';
|
|
|
+ th.dataset.tooltip = 'Click to sort.\n\nDaily profit scaled to a full 500-area planetary base.\n(Percentiles rank 0.00 as lowest numerical value to 1.00 as highest)';
|
|
|
} 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, working capital (days of OpEx), optional HQ Upgrade materials for the target permit, and optional Ship CapEx.';
|
|
|
} 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.';
|
|
|
- } else if (keys[i] === 'logistics_per_base') {
|
|
|
+ } 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.';
|
|
|
+ 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³).';
|
|
|
} 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.';
|
|
|
@@ -198,6 +204,9 @@ async function render() {
|
|
|
|
|
|
savedBuilding = '';
|
|
|
|
|
|
+ // EXTREME DETAIL: Because the percentile derivations evaluate the arrays *after* the filter executes,
|
|
|
+ // any row removed by the "low volume" constraint is automatically excluded from the
|
|
|
+ // percentile population math, ensuring your rankings are entirely context-accurate.
|
|
|
const filteredProfits = profits.filter(p => {
|
|
|
const volumeRatio = p.output_per_day / p.average_traded_7d;
|
|
|
if (!lowVolume.checked && volumeRatio > 0.05) return false;
|
|
|
@@ -232,6 +241,16 @@ async function render() {
|
|
|
return { ...p, capex_val, opex_val, revenue_val, profit_per_base, break_even, hq_capex };
|
|
|
});
|
|
|
|
|
|
+ // EXTREME DETAIL: We extract 6 sorted arrays of our active numeric values post-mapping.
|
|
|
+ // Using a robust numeric sorting function protects against `NaN` crashes when dealing with `Infinity`.
|
|
|
+ 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);
|
|
|
+
|
|
|
profitsWithMetrics.sort((a, b) => {
|
|
|
let valA: any = a[currentSortKey as keyof ProfitWithMetrics];
|
|
|
let valB: any = b[currentSortKey as keyof ProfitWithMetrics];
|
|
|
@@ -249,15 +268,27 @@ async function render() {
|
|
|
for (const p of profitsWithMetrics) {
|
|
|
const tr = document.createElement('tr');
|
|
|
|
|
|
+ // Map the raw value to its percentile rank within the active filtered column.
|
|
|
+ const pctProfit = getPercentile(p.profit_per_base, arrProfit);
|
|
|
+ const pctBreak = getPercentile(p.break_even, arrBreak);
|
|
|
+ const pctCapex = getPercentile(p.capex_val, arrCapex);
|
|
|
+ const pctOpex = getPercentile(p.opex_val, arrOpex);
|
|
|
+ const pctLog = getPercentile(p.normalized_logistics_per_base, arrLog);
|
|
|
+ const pctCap = getPercentile(p.market_capacity_base, arrCap);
|
|
|
+
|
|
|
+ // EXTREME DETAIL: We append the percentiles enclosed in a <small> tag.
|
|
|
+ // Reducing the opacity slightly creates visual hierarchy, allowing the 3-sig-fig
|
|
|
+ // metrics to remain the primary focal point of the table while the percentile acts as metadata.
|
|
|
+ // Note: The Logistics color mapping natively targets `normalized_logistics_per_base` (0.1 to 1.0 ship bounds).
|
|
|
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)}</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>
|
|
|
+ <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>
|
|
|
`;
|
|
|
|
|
|
const output = tr.querySelector('td')!;
|
|
|
@@ -293,6 +324,19 @@ async function render() {
|
|
|
saveState();
|
|
|
}
|
|
|
|
|
|
+// EXTREME DETAIL: O(N) simple percentile ranker.
|
|
|
+// Locates how many array items are strictly less than the target value.
|
|
|
+// Bounding it against `sortedArr.length - 1` gracefully maps the output from 0.00 to 1.00.
|
|
|
+function getPercentile(val: number, sortedArr: number[]): string {
|
|
|
+ if (sortedArr.length < 2) return "1.00";
|
|
|
+ let less = 0;
|
|
|
+ for (let i = 0; i < sortedArr.length; i++) {
|
|
|
+ if (sortedArr[i] < val) less++;
|
|
|
+ else break; // Break early because array is guaranteed pre-sorted
|
|
|
+ }
|
|
|
+ return (less / (sortedArr.length - 1)).toFixed(2);
|
|
|
+}
|
|
|
+
|
|
|
function saveState() {
|
|
|
localStorage.setItem('roi-cx', cxSelect.value);
|
|
|
localStorage.setItem('roi-expertise', expertiseSelect.value);
|
|
|
@@ -346,6 +390,7 @@ interface Profit {
|
|
|
input_costs: MatPrice[]
|
|
|
runs_per_day: number
|
|
|
logistics_per_base: number
|
|
|
+ normalized_logistics_per_base: number // Extracted from backend explicitly for sorting/percentiles
|
|
|
logistics_bottleneck: string
|
|
|
output_per_day: number
|
|
|
average_traded_7d: number
|