|
|
@@ -35,6 +35,12 @@ const formatDecimal = new Intl.NumberFormat(undefined,
|
|
|
{maximumFractionDigits: 2, maximumSignificantDigits: 6, roundingPriority: 'lessPrecision'}).format;
|
|
|
const formatWhole = new Intl.NumberFormat(undefined, {maximumFractionDigits: 0}).format;
|
|
|
|
|
|
+// EXTREME DETAIL: These state variables track the current interactive table sort configuration.
|
|
|
+// By defaulting to 'break_even' and 'true' (ascending), the initial page load mirrors the old default behavior.
|
|
|
+let currentSortKey: keyof Profit | 'outputs' = 'break_even';
|
|
|
+let currentSortAsc: boolean = true;
|
|
|
+let headersInitialized = false;
|
|
|
+
|
|
|
async function render() {
|
|
|
const tbody = document.querySelector('tbody')!;
|
|
|
tbody.innerHTML = '';
|
|
|
@@ -44,6 +50,37 @@ async function render() {
|
|
|
roiCache[cx] = getROI(cx);
|
|
|
const {lastModified, profits} = await roiCache[cx];
|
|
|
|
|
|
+ // EXTREME DETAIL: On the very first render, we grab all table headers and wire them up with click listeners.
|
|
|
+ // The `keys` array maps 1:1 with the columns defined in the HTML structure.
|
|
|
+ if (!headersInitialized) {
|
|
|
+ const ths = document.querySelectorAll('th');
|
|
|
+ const keys: (keyof Profit | 'outputs')[] = [
|
|
|
+ 'outputs', 'expertise', 'profit_per_area', 'break_even',
|
|
|
+ 'capex', 'cost_per_day', 'logistics_per_area', 'output_per_day'
|
|
|
+ ];
|
|
|
+
|
|
|
+ ths.forEach((th, i) => {
|
|
|
+ if (keys[i]) {
|
|
|
+ th.style.cursor = 'pointer';
|
|
|
+ th.title = 'Click to sort';
|
|
|
+ th.addEventListener('click', () => {
|
|
|
+ if (currentSortKey === keys[i]) {
|
|
|
+ // Flip the sort direction if clicking the same column twice
|
|
|
+ currentSortAsc = !currentSortAsc;
|
|
|
+ } else {
|
|
|
+ // Switch to a new column
|
|
|
+ currentSortKey = keys[i];
|
|
|
+ // Break Even defaults to Lowest-to-Highest. All other numbers default to Highest-to-Lowest.
|
|
|
+ currentSortAsc = keys[i] === 'break_even' ? true : false;
|
|
|
+ }
|
|
|
+ // Re-trigger the render loop with the new sorting state
|
|
|
+ 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}))
|
|
|
@@ -65,28 +102,43 @@ async function render() {
|
|
|
if (!buildingFound)
|
|
|
selectedBuilding = '';
|
|
|
|
|
|
- for (const p of profits) {
|
|
|
+ // EXTREME DETAIL: We extract the filtering logic into an explicit filter pass prior to rendering.
|
|
|
+ // This separates data culling from the actual DOM string-building loop.
|
|
|
+ const filteredProfits = profits.filter(p => {
|
|
|
const volumeRatio = p.output_per_day / p.average_traded_7d;
|
|
|
- if (!lowVolume.checked && volumeRatio > 0.05)
|
|
|
- continue;
|
|
|
- if (expertiseSelect.value !== '' && p.expertise !== expertiseSelect.value)
|
|
|
- continue;
|
|
|
- if (selectedBuilding !== '' && p.building !== selectedBuilding)
|
|
|
- continue;
|
|
|
- const tr = document.createElement('tr');
|
|
|
- const profitPerArea = p.profit_per_day / p.area;
|
|
|
+ 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;
|
|
|
+ });
|
|
|
+
|
|
|
+ // EXTREME DETAIL: We execute the interactive sorting logic based on the user's header click state.
|
|
|
+ // 'outputs' requires special handling because it is an Array of MatPrice objects, not a primitive string/number.
|
|
|
+ filteredProfits.sort((a, b) => {
|
|
|
+ let valA: any = a[currentSortKey as keyof Profit];
|
|
|
+ let valB: any = b[currentSortKey as keyof Profit];
|
|
|
|
|
|
- // EXTREME DETAIL: The break-even calculation dictates how long it takes to recoup the initial investment.
|
|
|
- // Previously, this only factored in raw Capital Expenditure (capex). It has been altered to include
|
|
|
- // 3 days of Operational Expenditure (cost_per_day). This reflects the reality that running a production
|
|
|
- // line requires an initial working capital buffer before the first batch of goods can be sold for profit.
|
|
|
- const breakEven = p.profit_per_day > 0 ? (p.capex + 3 * p.cost_per_day) / p.profit_per_day : Infinity;
|
|
|
+ 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 filteredProfits) {
|
|
|
+ const volumeRatio = p.output_per_day / p.average_traded_7d;
|
|
|
+ const tr = document.createElement('tr');
|
|
|
|
|
|
+ // EXTREME DETAIL: We no longer recalculate profitPerArea and breakEven here.
|
|
|
+ // We simply read the properties established by the Python backend via the JSON contract.
|
|
|
tr.innerHTML = `
|
|
|
<td>${p.outputs.map(o => o.ticker).join(', ')}</td>
|
|
|
<td>${expertise[p.expertise]}</td>
|
|
|
- <td style="color: ${color(profitPerArea, 0, 300)}">${formatDecimal(profitPerArea)}</td>
|
|
|
- <td><span style="color: ${color(breakEven, 30, 3)}">${formatDecimal(breakEven)}</span>d</td>
|
|
|
+ <td style="color: ${color(p.profit_per_area, 0, 300)}">${formatDecimal(p.profit_per_area)}</td>
|
|
|
+ <td><span style="color: ${color(p.break_even, 30, 3)}">${formatDecimal(p.break_even)}</span>d</td>
|
|
|
<td style="color: ${color(p.capex, 300_000, 40_000)}">${formatWhole(p.capex)}</td>
|
|
|
<td style="color: ${color(p.cost_per_day, 40_000, 1_000)}">${formatWhole(p.cost_per_day)}</td>
|
|
|
<td style="color: ${color(p.logistics_per_area, 2, 0.2)}">${formatDecimal(p.logistics_per_area)}</td>
|
|
|
@@ -107,7 +159,7 @@ async function render() {
|
|
|
'worker consumables: ' + formatWhole(p.worker_consumable_cost_per_day) + '\n\n' +
|
|
|
`(${formatWhole(revenue)} - ${formatWhole(inputCost)}) × ${formatDecimal(p.runs_per_day)} runs ` +
|
|
|
`- ${formatWhole(p.worker_consumable_cost_per_day)} = ${formatDecimal(p.profit_per_day)}\n` +
|
|
|
- `${formatDecimal(p.profit_per_day)} / ${formatWhole(p.area)} area = ${formatDecimal(profitPerArea)}`;
|
|
|
+ `${formatDecimal(p.profit_per_day)} / ${formatWhole(p.area)} area = ${formatDecimal(p.profit_per_area)}`;
|
|
|
|
|
|
tbody.appendChild(tr);
|
|
|
}
|
|
|
@@ -148,6 +200,8 @@ interface Profit {
|
|
|
logistics_per_area: number
|
|
|
output_per_day: number
|
|
|
average_traded_7d: number
|
|
|
+ profit_per_area: number // Added pre-calculated property tracking
|
|
|
+ break_even: number // Added pre-calculated property tracking
|
|
|
}
|
|
|
|
|
|
interface MatPrice {
|