|
|
@@ -44,8 +44,6 @@ if (localStorage.getItem('roi-low-volume')) lowVolume.checked = localStorage.get
|
|
|
|
|
|
let savedBuilding = localStorage.getItem('roi-building') || '';
|
|
|
|
|
|
-// 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';
|
|
|
@@ -58,6 +56,10 @@ let capexMetric: MetricType = (localStorage.getItem('roi-capex-metric') as Metri
|
|
|
let opexMetric: MetricType = (localStorage.getItem('roi-opex-metric') as MetricType) || 'vwap';
|
|
|
let revenueMetric: MetricType = (localStorage.getItem('roi-revenue-metric') as MetricType) || 'vwap';
|
|
|
let includeShips: boolean = localStorage.getItem('roi-include-ships') === 'true';
|
|
|
+
|
|
|
+// EXTREME DETAIL: Added state tracking for the negative profit toggle.
|
|
|
+// It defaults to 'true' (showing negatives) to preserve previous user experience until toggled.
|
|
|
+let showNegativeProfit: boolean = localStorage.getItem('roi-show-negative') !== 'false';
|
|
|
let workingCapitalDays: number = parseInt(localStorage.getItem('roi-working-capital') || '3', 10);
|
|
|
let targetPermit: number = parseInt(localStorage.getItem('roi-target-permit') || '2', 10);
|
|
|
|
|
|
@@ -86,6 +88,9 @@ async function render() {
|
|
|
<label style="margin-right: 15px;">
|
|
|
<input type="checkbox" id="include-ships"> Include Ship CapEx
|
|
|
</label>
|
|
|
+ <label style="margin-right: 15px;">
|
|
|
+ <input type="checkbox" id="show-negative"> Show Negative Profit
|
|
|
+ </label>
|
|
|
<label style="margin-right: 15px;">
|
|
|
Days OpEx: <input type="number" id="working-capital" min="0" step="1" style="width: 50px;">
|
|
|
</label>
|
|
|
@@ -100,6 +105,7 @@ async function render() {
|
|
|
(document.getElementById('opex-metric') as HTMLSelectElement).value = opexMetric;
|
|
|
(document.getElementById('revenue-metric') as HTMLSelectElement).value = revenueMetric;
|
|
|
(document.getElementById('include-ships') as HTMLInputElement).checked = includeShips;
|
|
|
+ (document.getElementById('show-negative') as HTMLInputElement).checked = showNegativeProfit;
|
|
|
(document.getElementById('working-capital') as HTMLInputElement).value = workingCapitalDays.toString();
|
|
|
(document.getElementById('target-permit') as HTMLInputElement).value = targetPermit.toString();
|
|
|
|
|
|
@@ -119,6 +125,10 @@ async function render() {
|
|
|
includeShips = (e.target as HTMLInputElement).checked;
|
|
|
render();
|
|
|
});
|
|
|
+ document.getElementById('show-negative')!.addEventListener('change', (e) => {
|
|
|
+ showNegativeProfit = (e.target as HTMLInputElement).checked;
|
|
|
+ render();
|
|
|
+ });
|
|
|
document.getElementById('working-capital')!.addEventListener('change', (e) => {
|
|
|
workingCapitalDays = parseInt((e.target as HTMLInputElement).value, 10);
|
|
|
render();
|
|
|
@@ -132,9 +142,6 @@ 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', 'normalized_logistics_per_base', 'market_capacity_base'
|
|
|
@@ -204,9 +211,6 @@ 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;
|
|
|
@@ -215,7 +219,9 @@ async function render() {
|
|
|
return true;
|
|
|
});
|
|
|
|
|
|
- const profitsWithMetrics: ProfitWithMetrics[] = filteredProfits.map(p => {
|
|
|
+ // EXTREME DETAIL: Notice that this is initialized as 'let' instead of 'const'.
|
|
|
+ // This allows us to re-assign the array post-mapping if the negative profit toggle is active.
|
|
|
+ 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;
|
|
|
@@ -241,8 +247,14 @@ 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`.
|
|
|
+ // EXTREME DETAIL: We filter the array again AFTER mapping the dynamic pricing logic.
|
|
|
+ // This ensures we are testing the user's specific VWAP/Bid/Ask permutation, and doing
|
|
|
+ // this before the percentile arrays are generated guarantees money-losing bases are entirely
|
|
|
+ // excluded from the active percentile population.
|
|
|
+ if (!showNegativeProfit) {
|
|
|
+ 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);
|
|
|
@@ -268,7 +280,6 @@ 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);
|
|
|
@@ -276,10 +287,6 @@ async function render() {
|
|
|
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>
|
|
|
@@ -324,15 +331,12 @@ 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
|
|
|
+ else break;
|
|
|
}
|
|
|
return (less / (sortedArr.length - 1)).toFixed(2);
|
|
|
}
|
|
|
@@ -348,6 +352,7 @@ function saveState() {
|
|
|
localStorage.setItem('roi-opex-metric', opexMetric);
|
|
|
localStorage.setItem('roi-revenue-metric', revenueMetric);
|
|
|
localStorage.setItem('roi-include-ships', includeShips.toString());
|
|
|
+ localStorage.setItem('roi-show-negative', showNegativeProfit.toString());
|
|
|
localStorage.setItem('roi-working-capital', workingCapitalDays.toString());
|
|
|
localStorage.setItem('roi-target-permit', targetPermit.toString());
|
|
|
}
|
|
|
@@ -390,7 +395,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
|
|
|
+ normalized_logistics_per_base: number
|
|
|
logistics_bottleneck: string
|
|
|
output_per_day: number
|
|
|
average_traded_7d: number
|