瀏覽代碼

Condense_Market_Data_To_Single_Metric

Replaced the dual-line production/trade volume column with a unified `market_capacity_area` metric.

        PURPOSE:
        To provide a more actionable, immediately understandable metric regarding market saturation risk. Instead of forcing the user to visually cross-reference daily output against the 7-day average traded volume, this new metric explicitly calculates the "Market Capacity in Equivalent Areas." By dividing the total market volume by the daily production yield of a single area, the user can instantly see exactly how many areas they can safely build before flooding the market.

        IMPLEMENTATION DETAILS:
        - In `roi.py`: Added the `market_capacity_area` derivation step. Assigned it to a new float property on the `Profit` dataclass to respect the Single Source of Truth architectural constraint established previously.
        - In `ts/roi.ts`: Updated the interactive header configuration array, swapping `output_per_day` out for `market_capacity_area`. This ensures that clicking the final table header dynamically sorts the array by the new metric without requiring dedicated logic.
        - In `ts/roi.ts`: Overhauled the rendering logic for the 8th table column to display the new float, utilizing a newly tailored color mapping scale (ranging from 20 [Red] to 500 [Cyan]).
        - Left the base parameters (`output_per_day` and `average_traded_7d`) intact within the JSON contract so the existing `lowVolume` UI filter checkbox continues to function correctly without requiring math adjustments.
        - Appended a dedicated tooltip to the new UI column so the user can hover to see the specific input variables driving the calculation.
Thomas Knott 2 周之前
父節點
當前提交
5da5823d40
共有 8 個文件被更改,包括 1182 次插入407 次删除
  1. 14 10
      roi.py
  2. 19 23
      ts/roi.ts
  3. 57 10
      www/roi.js
  4. 0 0
      www/roi.js.map
  5. 273 91
      www/roi_ai1.json
  6. 273 91
      www/roi_ci1.json
  7. 273 91
      www/roi_ic1.json
  8. 273 91
      www/roi_nc1.json

+ 14 - 10
roi.py

@@ -78,10 +78,6 @@ 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
@@ -94,12 +90,20 @@ def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_ar
 			key=lambda output: output['material_amount'] / output_prices[output['material_ticker']].average_traded_7d)
 	output_per_day = lowest_liquidity['material_amount'] * runs_per_day
 
+	# EXTREME DETAIL: We calculate the Market Capacity in Areas here.
+	# By dividing the 7-day average traded volume by the daily production yield of a single area,
+	# we derive how many 'areas' worth of production the market can absorb.
+	average_traded_7d = output_prices[lowest_liquidity['material_ticker']].average_traded_7d
+	output_per_area = output_per_day / area
+	market_capacity_area = average_traded_7d / output_per_area
+
 	logistics_per_area = max(
 		sum(materials[input['material_ticker']]['weight'] * input['material_amount'] for input in recipe['inputs']),
 		sum(materials[input['material_ticker']]['volume'] * input['material_amount'] for input in recipe['inputs']),
 		sum(materials[output['material_ticker']]['weight'] * output['material_amount'] for output in recipe['outputs']),
 		sum(materials[output['material_ticker']]['volume'] * output['material_amount'] for output in recipe['outputs']),
 	) * runs_per_day / area
+	
 	return Profit(outputs, recipe['recipe_name'],
 			expertise=building['expertise'],
 			building=building['building_ticker'],
@@ -112,9 +116,10 @@ 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=average_traded_7d,
 			profit_per_area=profit_per_area,
-			break_even=break_even)
+			break_even=break_even,
+			market_capacity_area=market_capacity_area)
 
 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]
@@ -205,12 +210,11 @@ 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
+	profit_per_area: float
+	break_even: float
+	market_capacity_area: float # Added pre-calculated property
 
 	def __lt__(self, other: Profit) -> bool:
-		# 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)

+ 19 - 23
ts/roi.ts

@@ -35,8 +35,6 @@ 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;
@@ -50,13 +48,14 @@ 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');
+		// EXTREME DETAIL: We swapped 'output_per_day' for 'market_capacity_area'.
+		// Because this array index directly maps to the DOM headers, clicking the 8th 
+		// table header now natively routes to the new metric for dynamic sorting.
 		const keys: (keyof Profit | 'outputs')[] = [
 			'outputs', 'expertise', 'profit_per_area', 'break_even', 
-			'capex', 'cost_per_day', 'logistics_per_area', 'output_per_day'
+			'capex', 'cost_per_day', 'logistics_per_area', 'market_capacity_area'
 		];
 		
 		ths.forEach((th, i) => {
@@ -65,15 +64,11 @@ async function render() {
 				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();
 				});
 			}
@@ -102,8 +97,9 @@ async function render() {
 	if (!buildingFound)
 		selectedBuilding = '';
 
-	// 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.
+	// EXTREME DETAIL: Even though we removed 'output_per_day' and 'average_traded_7d' from
+	// the visual table, they are still present in the JSON backend structure.
+	// This means our 'lowVolume' filter still works perfectly without requiring any math changes!
 	const filteredProfits = profits.filter(p => {
 		const volumeRatio = p.output_per_day / p.average_traded_7d;
 		if (!lowVolume.checked && volumeRatio > 0.05) return false;
@@ -112,8 +108,6 @@ async function render() {
 		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];
@@ -129,11 +123,11 @@ async function render() {
 	});
 
 	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.
+		// EXTREME DETAIL: We swapped the dual-line output code for a single <td> rendering the new
+		// market_capacity_area metric. The color mapping scale ranges from 20 (Red) to 500 (Cyan).
+		// For reference: a capacity of 20 means building 1 area captures exactly 5% of the market volume.
 		tr.innerHTML = `
 			<td>${p.outputs.map(o => o.ticker).join(', ')}</td>
 			<td>${expertise[p.expertise]}</td>
@@ -142,10 +136,7 @@ async function render() {
 			<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>
-			<td>
-				${formatDecimal(p.output_per_day)}<br>
-				<span style="color: ${color(volumeRatio, 0.05, 0.002)}">${formatWhole(p.average_traded_7d)}</span>
-			</td>
+			<td style="color: ${color(p.market_capacity_area, 20, 500)}">${formatWhole(p.market_capacity_area)}</td>
 		`;
 
 		const output = tr.querySelector('td')!;
@@ -161,6 +152,11 @@ async function render() {
 			`- ${formatWhole(p.worker_consumable_cost_per_day)} = ${formatDecimal(p.profit_per_day)}\n` +
 			`${formatDecimal(p.profit_per_day)} / ${formatWhole(p.area)} area = ${formatDecimal(p.profit_per_area)}`;
 
+		// EXTREME DETAIL: Because we are condensing two variables into a single number, providing a tooltip
+		// explaining exactly how the math breaks down is critical for the end-user experience.
+		const marketCell = tr.querySelectorAll('td')[7];
+		marketCell.dataset.tooltip = `Market Capacity: ${formatWhole(p.average_traded_7d)} traded/day ÷ ${formatDecimal(p.output_per_day / p.area)} produced/day/area = ${formatWhole(p.market_capacity_area)} equivalent areas`;
+
 		tbody.appendChild(tr);
 	}
 	document.getElementById('last-updated')!.textContent =
@@ -168,7 +164,6 @@ async function render() {
 }
 
 function color(n: number, low: number, high: number): string {
-	// scale n from low..high to 0..1 clamped
 	const scale = Math.min(Math.max((n - low) / (high - low), 0), 1);
 	return `color-mix(in xyz, #0aa ${scale * 100}%, #f80)`;
 }
@@ -200,8 +195,9 @@ 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
+	profit_per_area: number
+	break_even: number
+	market_capacity_area: number // Added pre-calculated property tracking
 }
 
 interface MatPrice {

+ 57 - 10
www/roi.js

@@ -50,6 +50,9 @@ for (const key of Object.keys(expertise)) {
 var buildingSelect = document.querySelector("select#building");
 var formatDecimal = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2, maximumSignificantDigits: 6, roundingPriority: "lessPrecision" }).format;
 var formatWhole = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format;
+var currentSortKey = "break_even";
+var currentSortAsc = true;
+var headersInitialized = false;
 async function render() {
   const tbody = document.querySelector("tbody");
   tbody.innerHTML = "";
@@ -57,6 +60,35 @@ async function render() {
   if (!roiCache[cx])
     roiCache[cx] = getROI(cx);
   const { lastModified, profits } = await roiCache[cx];
+  if (!headersInitialized) {
+    const ths = document.querySelectorAll("th");
+    const keys = [
+      "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]) {
+            currentSortAsc = !currentSortAsc;
+          } else {
+            currentSortKey = keys[i];
+            currentSortAsc = keys[i] === "break_even" ? true : false;
+          }
+          render();
+        });
+      }
+    });
+    headersInitialized = true;
+  }
   const buildingTickers = new Set(profits.map((p) => p.building));
   const buildings = Array.from(buildingTickers).map((building) => ({ ticker: building, expertise: profits.find((p) => p.building === building).expertise })).sort((a, b) => a.ticker.localeCompare(b.ticker));
   let selectedBuilding = buildingSelect.value;
@@ -75,22 +107,37 @@ async function render() {
     }
   if (!buildingFound)
     selectedBuilding = "";
-  for (const p of profits) {
+  const filteredProfits = profits.filter((p) => {
     const volumeRatio = p.output_per_day / p.average_traded_7d;
     if (!lowVolume.checked && volumeRatio > 0.05)
-      continue;
+      return false;
     if (expertiseSelect.value !== "" && p.expertise !== expertiseSelect.value)
-      continue;
+      return false;
     if (selectedBuilding !== "" && p.building !== selectedBuilding)
-      continue;
+      return false;
+    return true;
+  });
+  filteredProfits.sort((a, b) => {
+    let valA = a[currentSortKey];
+    let valB = b[currentSortKey];
+    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");
-    const profitPerArea = p.profit_per_day / p.area;
-    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>
-			<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, 300000, 40000)}">${formatWhole(p.capex)}</td>
 			<td style="color: ${color(p.cost_per_day, 40000, 1000)}">${formatWhole(p.cost_per_day)}</td>
 			<td style="color: ${color(p.logistics_per_area, 2, 0.2)}">${formatDecimal(p.logistics_per_area)}</td>
@@ -110,7 +157,7 @@ async function render() {
 ` + "worker consumables: " + formatWhole(p.worker_consumable_cost_per_day) + `
 
 ` + `(${formatWhole(revenue)} - ${formatWhole(inputCost)}) × ${formatDecimal(p.runs_per_day)} runs ` + `- ${formatWhole(p.worker_consumable_cost_per_day)} = ${formatDecimal(p.profit_per_day)}
-` + `${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);
   }
   document.getElementById("last-updated").textContent = `last updated: ${lastModified.toLocaleString(undefined, { dateStyle: "full", timeStyle: "long", hour12: false })}`;
@@ -130,4 +177,4 @@ expertiseSelect.addEventListener("change", render);
 buildingSelect.addEventListener("change", render);
 render();
 
-//# debugId=55BEB0C86795158164756E2164756E21
+//# debugId=944BFC6BA5BAAFDF64756E2164756E21

文件差異過大導致無法顯示
+ 0 - 0
www/roi.js.map


文件差異過大導致無法顯示
+ 273 - 91
www/roi_ai1.json


文件差異過大導致無法顯示
+ 273 - 91
www/roi_ci1.json


文件差異過大導致無法顯示
+ 273 - 91
www/roi_ic1.json


文件差異過大導致無法顯示
+ 273 - 91
www/roi_nc1.json


部分文件因文件數量過多而無法顯示