Bläddra i källkod

Implement_SSOT_And_Interactive_Table_Sorting

Migrated dynamic table derivations to the Python backend and implemented interactive frontend column sorting.

        PURPOSE:
        To establish a "Single Source of Truth" (SSOT) data contract and elevate the usability of the ROI display table. Previously, the frontend recalculated `break_even` and `profit_per_area` via independent inline formulas, posing a parity risk with the backend's sorting algorithms. By deriving these directly within `roi.py` and transmitting them as concrete floats over JSON, the frontend is streamlined to a pure display layer. Furthermore, because these attributes are now concrete, an interactive dynamic array sorter has been attached to the table headers, fulfilling a user request for customizable table querying (e.g., sorting by highest raw CapEx or lowest OpEx with a single click).

        IMPLEMENTATION DETAILS:
        - `roi.py`: Extracted the formulas for `break_even` and `profit_per_area` into the `calc_profit` function and appended them to the `Profit` dataclass instances.
        - `ts/roi.ts`: Added state variables `currentSortKey` and `currentSortAsc` to the module scope.
        - `ts/roi.ts`: Embedded an initialization block inside `render()` that queries all `<th>` tags and binds dynamic click listeners dictating the active sort column. Explicitly programmed `break_even` to default to ascending on first click, while all other integers default to descending.
        - `ts/roi.ts`: Added an array `.sort()` routing based on `currentSortKey`. Mapped `outputs` manually via a `.join()` array mapping to allow alphabetical sorting of standard materials.
        - `ts/roi.ts`: Erased localized derivation mathematics from the row generation loop, leaning exclusively on `p.profit_per_area` and `p.break_even`.
Thomas Knott 2 veckor sedan
förälder
incheckning
811eb2cc65
8 ändrade filer med 1843 tillägg och 2931 borttagningar
  1. 21 11
      roi.py
  2. 71 17
      ts/roi.ts
  3. 2 2
      www/roi.js
  4. 0 0
      www/roi.js.map
  5. 439 578
      www/roi_ai1.json
  6. 130 883
      www/roi_ci1.json
  7. 559 670
      www/roi_ic1.json
  8. 621 770
      www/roi_nc1.json

+ 21 - 11
roi.py

@@ -78,6 +78,18 @@ def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_ar
 	worker_consumable_daily_cost = building_daily_cost(building, prices)
 	cost_per_day = cost * runs_per_day + worker_consumable_daily_cost
 
+	# EXTREME DETAIL: We establish the "Single Source of Truth" (SSOT) here. 
+	# Previously, the frontend recalculated `break_even` and `profit_per_area` dynamically.
+	# By performing the math here on the backend and storing it directly into the JSON data contract,
+	# we guarantee the sorting algorithms in Python and rendering in TypeScript can never mismatch.
+	profit_per_day = profit_per_run * runs_per_day - worker_consumable_daily_cost
+	if profit_per_day > 0:
+		break_even = (capex + 3 * cost_per_day) / profit_per_day
+	else:
+		break_even = 10000 - profit_per_day
+	
+	profit_per_area = profit_per_day / area
+
 	lowest_liquidity = min(recipe['outputs'],
 			key=lambda output: output['material_amount'] / output_prices[output['material_ticker']].average_traded_7d)
 	output_per_day = lowest_liquidity['material_amount'] * runs_per_day
@@ -91,7 +103,7 @@ def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_ar
 	return Profit(outputs, recipe['recipe_name'],
 			expertise=building['expertise'],
 			building=building['building_ticker'],
-			profit_per_day=(profit_per_run * runs_per_day - worker_consumable_daily_cost),
+			profit_per_day=profit_per_day,
 			area=area,
 			capex=capex,
 			cost_per_day=cost_per_day,
@@ -100,7 +112,9 @@ def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_ar
 			runs_per_day=runs_per_day,
 			logistics_per_area=logistics_per_area,
 			output_per_day=output_per_day,
-			average_traded_7d=output_prices[lowest_liquidity['material_ticker']].average_traded_7d)
+			average_traded_7d=output_prices[lowest_liquidity['material_ticker']].average_traded_7d,
+			profit_per_area=profit_per_area,
+			break_even=break_even)
 
 def building_construction_cost(building: Building, prices: typing.Mapping[str, Price]) -> float:
 	cost = sum(bc['material_amount'] * prices[bc['material_ticker']].vwap_7d for bc in building['costs']) # pyright: ignore[reportOperatorIssue]
@@ -191,17 +205,13 @@ class Profit:
 	logistics_per_area: float
 	output_per_day: float
 	average_traded_7d: float
+	profit_per_area: float  # Added derived property
+	break_even: float       # Added derived property
 
 	def __lt__(self, other: Profit) -> bool:
-		# EXTREME DETAIL: The sorting logic for Profit instances determines the order items appear in the frontend.
-		# This has been updated to match the new frontend break-even calculation, which includes 3 days of operational
-		# expenses (cost_per_day) in the numerator along with capex. This ensures the JSON arrays generated by this script
-		# are inherently sorted by the new, more accurate break-even metric.
-		if (break_even := (self.capex + 3 * self.cost_per_day) / self.profit_per_day) < 0:
-			break_even = 10000 - self.profit_per_day
-		if (other_break_even := (other.capex + 3 * other.cost_per_day) / other.profit_per_day) < 0:
-			other_break_even = 10000 - other.profit_per_day
-		return break_even < other_break_even
+		# EXTREME DETAIL: Because break_even is now pre-calculated upon instantiation,
+		# the magic less-than comparison used for backend array sorting is simplified to a pure property read.
+		return self.break_even < other.break_even
 
 @dataclasses.dataclass(eq=False, frozen=True, slots=True)
 class MatPrice:

+ 71 - 17
ts/roi.ts

@@ -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 {

+ 2 - 2
www/roi.js

@@ -85,7 +85,7 @@ async function render() {
       continue;
     const tr = document.createElement("tr");
     const profitPerArea = p.profit_per_day / p.area;
-    const breakEven = p.profit_per_day > 0 ? p.capex / p.profit_per_day : Infinity;
+    const breakEven = p.profit_per_day > 0 ? (p.capex + 3 * p.cost_per_day) / p.profit_per_day : Infinity;
     tr.innerHTML = `
 			<td>${p.outputs.map((o) => o.ticker).join(", ")}</td>
 			<td>${expertise[p.expertise]}</td>
@@ -130,4 +130,4 @@ expertiseSelect.addEventListener("change", render);
 buildingSelect.addEventListener("change", render);
 render();
 
-//# debugId=C343FB9F3283EF5D64756E2164756E21
+//# debugId=55BEB0C86795158164756E2164756E21

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
www/roi.js.map


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 439 - 578
www/roi_ai1.json


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 130 - 883
www/roi_ci1.json


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 559 - 670
www/roi_ic1.json


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 621 - 770
www/roi_nc1.json


Vissa filer visades inte eftersom för många filer har ändrats