add production chain simulation and extraction amounts

This commit is contained in:
calli
2025-01-30 23:54:42 +02:00
parent da7c0d53b0
commit dfeb307562
6 changed files with 1017 additions and 159 deletions

49
package-lock.json generated
View File

@@ -18,12 +18,14 @@
"@types/react": "18.2.12", "@types/react": "18.2.12",
"@types/react-dom": "18.2.5", "@types/react-dom": "18.2.5",
"autoprefixer": "10.4.14", "autoprefixer": "10.4.14",
"chart.js": "^4.4.7",
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"eslint": "8.42.0", "eslint": "8.42.0",
"luxon": "^3.3.0", "luxon": "^3.3.0",
"next": "^14.2.23", "next": "^14.2.23",
"next-plausible": "^3.12.0", "next-plausible": "^3.12.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-chartjs-2": "^5.3.0",
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-countdown": "^2.3.5", "react-countdown": "^2.3.5",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -676,6 +678,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@mui/base": { "node_modules/@mui/base": {
"version": "5.0.0-beta.4", "version": "5.0.0-beta.4",
"resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.4.tgz", "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.4.tgz",
@@ -3526,6 +3534,18 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/chart.js": {
"version": "4.4.7",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.7.tgz",
"integrity": "sha512-pwkcKfdzTMAU/+jNosKhNL2bHtJc/sSmYgVbuGTEDhzkrhmyihmP7vUc/5ZK9WopidMDHNe3Wm7jOd/WhuHWuw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -7347,6 +7367,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-chartjs-2": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz",
"integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-color": { "node_modules/react-color": {
"version": "2.19.3", "version": "2.19.3",
"resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz", "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz",
@@ -9754,6 +9784,11 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
},
"@mui/base": { "@mui/base": {
"version": "5.0.0-beta.4", "version": "5.0.0-beta.4",
"resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.4.tgz", "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.4.tgz",
@@ -11567,6 +11602,14 @@
"supports-color": "^7.1.0" "supports-color": "^7.1.0"
} }
}, },
"chart.js": {
"version": "4.4.7",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.7.tgz",
"integrity": "sha512-pwkcKfdzTMAU/+jNosKhNL2bHtJc/sSmYgVbuGTEDhzkrhmyihmP7vUc/5ZK9WopidMDHNe3Wm7jOd/WhuHWuw==",
"requires": {
"@kurkle/color": "^0.3.0"
}
},
"chokidar": { "chokidar": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -14122,6 +14165,12 @@
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }
}, },
"react-chartjs-2": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz",
"integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==",
"requires": {}
},
"react-color": { "react-color": {
"version": "2.19.3", "version": "2.19.3",
"resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz", "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz",

View File

@@ -20,12 +20,14 @@
"@types/react": "18.2.12", "@types/react": "18.2.12",
"@types/react-dom": "18.2.5", "@types/react-dom": "18.2.5",
"autoprefixer": "10.4.14", "autoprefixer": "10.4.14",
"chart.js": "^4.4.7",
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"eslint": "8.42.0", "eslint": "8.42.0",
"luxon": "^3.3.0", "luxon": "^3.3.0",
"next": "^14.2.23", "next": "^14.2.23",
"next-plausible": "^3.12.0", "next-plausible": "^3.12.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-chartjs-2": "^5.3.0",
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-countdown": "^2.3.5", "react-countdown": "^2.3.5",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",

View File

@@ -0,0 +1,123 @@
export interface ExtractionSimulationConfig {
baseValue: number;
cycleTime: number;
length: number;
}
const SEC = 10000000;
export const getProgramOutputPrediction = (
baseValue: number,
cycleDuration: number, // in seconds
length: number
): number[] => {
const vals: number[] = [];
const startTime = 0;
const cycleTime = cycleDuration * SEC;
for (let i = 0; i < length; i++) {
const currentTime = (i + 1) * cycleTime;
vals.push(getProgramOutput(baseValue, startTime, currentTime, cycleTime));
}
return vals;
};
export const getProgramOutput = (
baseValue: number,
startTime: number,
currentTime: number,
cycleTime: number
): number => {
const decayFactor = 0.012;
const noiseFactor = 0.8;
const timeDiff = currentTime - startTime;
const cycleNum = Math.max((timeDiff + SEC) / cycleTime - 1, 0);
const barWidth = cycleTime / SEC / 900.0;
const t = (cycleNum + 0.5) * barWidth;
const decayValue = baseValue / (1 + t * decayFactor);
const f1 = 1.0 / 12.0;
const f2 = 1.0 / 5.0;
const f3 = 1.0 / 2.0;
const phaseShift = Math.pow(baseValue, 0.7);
const sinA = Math.cos(phaseShift + t * f1);
const sinB = Math.cos(phaseShift / 2.0 + t * f2);
const sinC = Math.cos(t * f3);
let sinStuff = (sinA + sinB + sinC) / 3.0;
sinStuff = Math.max(0.0, sinStuff);
const barHeight = decayValue * (1 + noiseFactor * sinStuff);
const output = barWidth * barHeight;
// Round down, with integers also rounded down (123.0 -> 122)
return output - output % 1 - 1;
};
export interface ProductionNode {
typeId: number;
name: string;
schematicId: number;
inputs: Array<{
typeId: number;
quantity: number;
}>;
outputs: Array<{
typeId: number;
quantity: number;
}>;
cycleTime: number;
}
export interface ProductionChainBalance {
nodes: ProductionNode[];
totalInputs: Map<number, number>;
totalOutputs: Map<number, number>;
deficit: Map<number, number>;
surplus: Map<number, number>;
}
export const calculateProductionChainBalance = (
nodes: ProductionNode[]
): ProductionChainBalance => {
const totalInputs = new Map<number, number>();
const totalOutputs = new Map<number, number>();
const deficit = new Map<number, number>();
const surplus = new Map<number, number>();
// Calculate total inputs and outputs
for (const node of nodes) {
// Process inputs
for (const input of node.inputs) {
const current = totalInputs.get(input.typeId) || 0;
totalInputs.set(input.typeId, current + input.quantity);
}
// Process outputs
for (const output of node.outputs) {
const current = totalOutputs.get(output.typeId) || 0;
totalOutputs.set(output.typeId, current + output.quantity);
}
}
// Calculate deficits and surpluses
Array.from(totalInputs.entries()).forEach(([typeId, inputQty]) => {
const outputQty = totalOutputs.get(typeId) || 0;
if (inputQty > outputQty) {
deficit.set(typeId, inputQty - outputQty);
}
});
Array.from(totalOutputs.entries()).forEach(([typeId, outputQty]) => {
const inputQty = totalInputs.get(typeId) || 0;
if (outputQty > inputQty) {
surplus.set(typeId, outputQty - inputQty);
}
});
return {
nodes,
totalInputs,
totalOutputs,
deficit,
surplus
};
};

View File

@@ -0,0 +1,215 @@
import React from 'react';
import { Box, Paper, Typography, Stack } from '@mui/material';
import { Line } from 'react-chartjs-2';
import { useTheme } from '@mui/material';
import { getProgramOutputPrediction, ProductionNode } from './ExtractionSimulation';
import { PI_TYPES_MAP } from '@/const';
import { ProductionChainVisualization } from './ProductionChainVisualization';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend
} from 'chart.js';
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend
);
interface ExtractorConfig {
typeId: number;
baseValue: number;
cycleTime: number;
installTime: string;
expiryTime: string;
}
interface ExtractionSimulationDisplayProps {
extractors: ExtractorConfig[];
productionNodes: ProductionNode[];
}
export const ExtractionSimulationDisplay: React.FC<ExtractionSimulationDisplayProps> = ({
extractors,
productionNodes
}) => {
const CYCLE_TIME = 30 * 60; // 30 minutes in seconds
// Calculate program duration and cycles for each extractor
const extractorPrograms = extractors.map(extractor => {
const installDate = new Date(extractor.installTime);
const expiryDate = new Date(extractor.expiryTime);
const programDuration = (expiryDate.getTime() - installDate.getTime()) / 1000; // Convert to seconds
return {
...extractor,
programDuration,
cycles: Math.floor(programDuration / CYCLE_TIME)
};
});
const maxCycles = Math.max(...extractorPrograms.map(e => e.cycles));
// Get output predictions for each extractor
const extractorOutputs = extractorPrograms.map(extractor => ({
typeId: extractor.typeId,
cycleTime: CYCLE_TIME,
cycles: extractor.cycles,
prediction: getProgramOutputPrediction(
extractor.baseValue,
CYCLE_TIME,
extractor.cycles
)
}));
// Calculate total output per program for each extractor
const programTotals = extractorPrograms.map(extractor => {
const prediction = getProgramOutputPrediction(
extractor.baseValue,
CYCLE_TIME,
extractor.cycles
);
const totalOutput = prediction.reduce((sum, val) => sum + val, 0);
return {
typeId: extractor.typeId,
cycleTime: CYCLE_TIME,
cycles: extractor.cycles,
total: totalOutput,
installTime: extractor.installTime,
expiryTime: extractor.expiryTime
};
});
// Create datasets for the chart
const datasets = extractorOutputs.map((output, index) => {
const hue = (360 / extractors.length) * index;
return {
label: `${PI_TYPES_MAP[output.typeId]?.name ?? `Resource ${output.typeId}`}`,
data: output.prediction,
borderColor: `hsl(${hue}, 70%, 50%)`,
backgroundColor: `hsl(${hue}, 70%, 80%)`,
tension: 0.4
};
});
const chartData = {
labels: Array.from({ length: maxCycles }, (_, i) => {
// Show every 4th cycle number to avoid overcrowding
return (i % 4 === 0) ? `Cycle ${i + 1}` : '';
}),
datasets
};
const chartOptions = {
responsive: true,
plugins: {
legend: {
position: 'top' as const,
},
title: {
display: true,
text: 'Extraction Output Prediction (30 Minute Program)'
},
tooltip: {
callbacks: {
title: (context: any) => `Cycle ${context[0].dataIndex + 1}`,
label: (context: any) => `Output: ${context.raw.toFixed(1)} units`
}
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Units per Cycle'
}
},
x: {
ticks: {
autoSkip: true,
maxTicksLimit: 24
}
}
}
};
// Prepare data for ProductionChainVisualization
const extractorTotals = new Map<number, number>();
programTotals.forEach(({ typeId, total }) => {
extractorTotals.set(typeId, total);
});
// Get unique extracted type IDs
const extractedTypeIds = Array.from(new Set(extractors.map(e => e.typeId)));
// Get installed schematic IDs from production nodes
const installedSchematicIds = Array.from(new Set(productionNodes.map(node => node.schematicId)));
// Create factories array with correct counts
const factories = installedSchematicIds.map(schematicId => ({
schematic_id: schematicId,
count: productionNodes.filter(node => node.schematicId === schematicId).length
}));
return (
<Box>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Extraction Simulation
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 2 }}>
{programTotals.map(({ typeId, total, cycleTime, cycles, installTime, expiryTime }) => (
<Stack key={typeId} spacing={1}>
<Typography variant="subtitle2">
{PI_TYPES_MAP[typeId]?.name}:
</Typography>
<Typography variant="body2" component="div" sx={{ pl: 2 }}>
Total Output: {total.toFixed(1)} units per program
</Typography>
<Typography variant="body2" component="div" sx={{ pl: 2 }}>
Cycle Time: {(cycleTime / 60).toFixed(1)} minutes
</Typography>
<Typography variant="body2" component="div" sx={{ pl: 2 }}>
Program Cycles: {cycles}
</Typography>
<Typography variant="body2" component="div" sx={{ pl: 2 }}>
Average per Cycle: {(total / cycles).toFixed(1)} units
</Typography>
<Typography variant="body2" component="div" sx={{ pl: 2 }}>
Install Time: {new Date(installTime).toLocaleString()}
</Typography>
<Typography variant="body2" component="div" sx={{ pl: 2 }}>
Expiry Time: {new Date(expiryTime).toLocaleString()}
</Typography>
</Stack>
))}
</Box>
<div style={{ height: '300px' }}>
<Line data={chartData} options={chartOptions} />
</div>
</Paper>
<ProductionChainVisualization
extractedTypeIds={extractedTypeIds}
extractors={extractors.map(e => ({
typeId: e.typeId,
baseValue: e.baseValue,
cycleTime: CYCLE_TIME
}))}
factories={factories}
extractorTotals={extractorTotals}
productionNodes={productionNodes}
/>
</Box>
);
};

View File

@@ -3,10 +3,10 @@ import { PI_TYPES_MAP } from "@/const";
import { planetCalculations } from "@/planets"; import { planetCalculations } from "@/planets";
import { AccessToken, PlanetWithInfo } from "@/types"; import { AccessToken, PlanetWithInfo } from "@/types";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import { Button, Tooltip, Typography, useTheme } from "@mui/material"; import MoreVertIcon from '@mui/icons-material/MoreVert';
import { Button, Tooltip, Typography, useTheme, Menu, MenuItem, IconButton } from "@mui/material";
import AppBar from "@mui/material/AppBar"; import AppBar from "@mui/material/AppBar";
import Dialog from "@mui/material/Dialog"; import Dialog from "@mui/material/Dialog";
import IconButton from "@mui/material/IconButton";
import Slide from "@mui/material/Slide"; import Slide from "@mui/material/Slide";
import TableCell from "@mui/material/TableCell"; import TableCell from "@mui/material/TableCell";
import TableRow from "@mui/material/TableRow"; import TableRow from "@mui/material/TableRow";
@@ -19,6 +19,11 @@ import Countdown from "react-countdown";
import { PlanetConfigDialog } from "../PlanetConfig/PlanetConfigDialog"; import { PlanetConfigDialog } from "../PlanetConfig/PlanetConfigDialog";
import PinsCanvas3D from "./PinsCanvas3D"; import PinsCanvas3D from "./PinsCanvas3D";
import { alertModeVisibility, timeColor } from "./timeColors"; import { alertModeVisibility, timeColor } from "./timeColors";
import { ExtractionSimulationDisplay } from './ExtractionSimulationDisplay';
import { ProductionNode } from './ExtractionSimulation';
import { Collapse, Box, Stack } from "@mui/material";
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
const Transition = forwardRef(function Transition( const Transition = forwardRef(function Transition(
props: TransitionProps & { props: TransitionProps & {
@@ -40,6 +45,16 @@ export const PlanetTableRow = ({
const [planetRenderOpen, setPlanetRenderOpen] = useState(false); const [planetRenderOpen, setPlanetRenderOpen] = useState(false);
const [planetConfigOpen, setPlanetConfigOpen] = useState(false); const [planetConfigOpen, setPlanetConfigOpen] = useState(false);
const [simulationOpen, setSimulationOpen] = useState(false);
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setMenuAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setMenuAnchorEl(null);
};
const handle3DrenderOpen = () => { const handle3DrenderOpen = () => {
setPlanetRenderOpen(true); setPlanetRenderOpen(true);
@@ -66,7 +81,32 @@ export const PlanetTableRow = ({
(p) => p.planetId === planet.planet_id, (p) => p.planetId === planet.planet_id,
); );
const { colors } = useContext(ColorContext); const { colors } = useContext(ColorContext);
// Convert local production to ProductionNode array for simulation
const productionNodes: ProductionNode[] = Array.from(localProduction).map(([schematicId, schematic]) => ({
schematicId: schematicId,
typeId: schematic.outputs[0].type_id,
name: schematic.name,
inputs: schematic.inputs.map(input => ({
typeId: input.type_id,
quantity: input.quantity
})),
outputs: schematic.outputs.map(output => ({
typeId: output.type_id,
quantity: output.quantity
})),
cycleTime: schematic.cycle_time
}));
// Convert Map to Array for schematic IDs
const installedSchematicIds = Array.from(localProduction.values()).map(p => p.schematic_id);
// Get extractor head types safely
const extractedTypeIds = extractors
.map(e => e.extractor_details?.product_type_id)
.filter((id): id is number => id !== undefined);
return ( return (
<>
<TableRow <TableRow
style={{ visibility: alertModeVisibility(alertMode, expired) }} style={{ visibility: alertModeVisibility(alertMode, expired) }}
sx={{ "&:last-child td, &:last-child th": { border: 0 } }} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
@@ -220,20 +260,64 @@ export const PlanetTableRow = ({
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Tooltip title="Open planet configuration"> <IconButton
<Button variant="contained" onClick={handlePlanetConfigOpen}> aria-label="more"
Config aria-controls="planet-menu"
</Button> aria-haspopup="true"
</Tooltip> onClick={handleMenuOpen}
>
<MoreVertIcon />
</IconButton>
<Menu
id="planet-menu"
anchorEl={menuAnchorEl}
keepMounted
open={Boolean(menuAnchorEl)}
onClose={handleMenuClose}
>
<MenuItem onClick={() => {
handlePlanetConfigOpen();
handleMenuClose();
}}>
Configure Planet
</MenuItem>
{extractors.length > 0 && (
<MenuItem onClick={() => {
setSimulationOpen(!simulationOpen);
handleMenuClose();
}}>
{simulationOpen ? 'Hide Extraction Simulation' : 'Show Extraction Simulation'}
</MenuItem>
)}
<MenuItem onClick={() => {
handle3DrenderOpen();
handleMenuClose();
}}>
Show 3D View
</MenuItem>
</Menu>
</TableCell> </TableCell>
</TableRow>
<TableCell> <TableRow>
<Tooltip title="Open 3D render of this planet"> <TableCell colSpan={6} style={{ paddingBottom: 0, paddingTop: 0 }}>
<Button variant="contained" onClick={handle3DrenderOpen}> <Collapse in={simulationOpen} timeout="auto" unmountOnExit>
3D <Box sx={{ my: 2 }}>
</Button> <ExtractionSimulationDisplay
</Tooltip> extractors={extractors
.filter(e => e.extractor_details?.product_type_id && e.extractor_details?.qty_per_cycle)
.map(e => ({
typeId: e.extractor_details!.product_type_id!,
baseValue: e.extractor_details!.qty_per_cycle!,
cycleTime: e.extractor_details!.cycle_time || 3600,
installTime: e.install_time ?? "",
expiryTime: e.expiry_time ?? ""
}))}
productionNodes={productionNodes}
/>
</Box>
</Collapse>
</TableCell> </TableCell>
</TableRow>
<Dialog <Dialog
fullScreen fullScreen
open={planetRenderOpen} open={planetRenderOpen}
@@ -286,6 +370,6 @@ export const PlanetTableRow = ({
</AppBar> </AppBar>
<PlanetConfigDialog planet={planet} character={character} /> <PlanetConfigDialog planet={planet} character={character} />
</Dialog> </Dialog>
</TableRow> </>
); );
}; };

View File

@@ -0,0 +1,385 @@
import React from 'react';
import { Box, Paper, Typography, Grid, Stack, Divider } from '@mui/material';
import { EVE_IMAGE_URL } from '@/const';
import { PI_TYPES_MAP } from '@/const';
interface Factory {
schematic_id: number;
count: number;
}
interface ProductionNode {
typeId: number;
name: string;
schematicId: number;
inputs: Array<{
typeId: number;
quantity: number;
}>;
outputs: Array<{
typeId: number;
quantity: number;
}>;
cycleTime: number;
}
interface ProductionChainVisualizationProps {
extractedTypeIds: number[];
extractors: Array<{
typeId: number;
baseValue: number;
cycleTime: number;
}>;
factories: Factory[];
extractorTotals: Map<number, number>;
productionNodes: ProductionNode[];
}
export const ProductionChainVisualization: React.FC<ProductionChainVisualizationProps> = ({
extractedTypeIds,
factories,
extractorTotals,
productionNodes
}) => {
// Get all type IDs involved in the production chain
const allTypeIds = new Set<number>();
const requiredInputs = new Set<number>();
// Add extracted resources
extractedTypeIds.forEach(id => allTypeIds.add(id));
// Add all resources involved in the production chain
productionNodes.forEach(node => {
node.inputs.forEach(input => {
allTypeIds.add(input.typeId);
requiredInputs.add(input.typeId);
});
node.outputs.forEach(output => allTypeIds.add(output.typeId));
});
// Calculate production and consumption rates for the program
const productionTotals = new Map<number, number>();
const consumptionTotals = new Map<number, number>();
const importedTypes = new Set<number>();
const importAmounts = new Map<number, number>();
const nodesByOutput = new Map<number, ProductionNode>();
const cyclesByNode = new Map<number, number>(); // Track cycles per schematic
// Add extractor production to totals
extractorTotals.forEach((total, typeId) => {
productionTotals.set(typeId, total);
});
// Map each output type to its producing node
productionNodes.forEach(node => {
node.outputs.forEach(output => {
nodesByOutput.set(output.typeId, node);
});
});
// Calculate production levels first
const productionLevels = new Map<number, number>();
extractedTypeIds.forEach(id => productionLevels.set(id, 0));
const determineProductionLevel = (typeId: number, visited = new Set<number>()): number => {
if (productionLevels.has(typeId)) {
return productionLevels.get(typeId)!;
}
if (visited.has(typeId)) {
return 0;
}
visited.add(typeId);
const producingNode = nodesByOutput.get(typeId);
if (!producingNode) {
// If this is a required input but not produced locally,
// find the maximum level of nodes that consume it
if (requiredInputs.has(typeId)) {
const consumingNodes = productionNodes.filter(node =>
node.inputs.some(input => input.typeId === typeId)
);
if (consumingNodes.length > 0) {
// Get the level of the first consuming node's outputs
const consumerLevel = Math.max(...consumingNodes[0].outputs.map(output =>
determineProductionLevel(output.typeId, new Set(visited))
)) - 1; // Place one level below the consumer
productionLevels.set(typeId, consumerLevel);
return consumerLevel;
}
}
return 0;
}
const inputLevels = producingNode.inputs.map(input =>
determineProductionLevel(input.typeId, visited)
);
const level = Math.max(...inputLevels) + 1;
productionLevels.set(typeId, level);
return level;
};
// Calculate levels for all types
Array.from(allTypeIds).forEach(typeId => {
if (!productionLevels.has(typeId)) {
determineProductionLevel(typeId);
}
});
// Sort nodes by production level to process in order
const sortedNodes = [...productionNodes].sort((a, b) => {
const aLevel = Math.max(...a.outputs.map(o => productionLevels.get(o.typeId) ?? 0));
const bLevel = Math.max(...b.outputs.map(o => productionLevels.get(o.typeId) ?? 0));
return aLevel - bLevel;
});
// Process nodes in order of production level
sortedNodes.forEach(node => {
const factoryCount = factories.find(f => f.schematic_id === node.schematicId)?.count ?? 0;
if (factoryCount === 0) return;
// Calculate maximum possible cycles based on available inputs
let maxPossibleCycles = Infinity;
let needsImports = false;
const inputCycles = new Map<number, number>();
// First calculate how many cycles we could run for each input
node.inputs.forEach(input => {
const availableInput = productionTotals.get(input.typeId) ?? 0;
const requiredPerCycle = input.quantity * factoryCount;
const cyclesPossible = Math.floor(availableInput / requiredPerCycle);
inputCycles.set(input.typeId, cyclesPossible);
if (cyclesPossible === 0) {
needsImports = true;
}
maxPossibleCycles = Math.min(maxPossibleCycles, cyclesPossible);
});
// Find the maximum cycles we could run with the most abundant input
const maxInputCycles = Math.max(...Array.from(inputCycles.values()));
// If we need imports, calculate them based on the maximum possible cycles from our most abundant input
if (needsImports) {
const targetCycles = maxInputCycles > 0 ? maxInputCycles : 1; // If no inputs, assume 1 cycle
node.inputs.forEach(input => {
const availableInput = productionTotals.get(input.typeId) ?? 0;
const requiredInput = input.quantity * factoryCount * targetCycles;
const currentImport = importAmounts.get(input.typeId) ?? 0;
if (requiredInput > availableInput) {
importedTypes.add(input.typeId);
importAmounts.set(input.typeId, Math.max(currentImport, requiredInput - availableInput));
}
});
maxPossibleCycles = targetCycles;
}
if (!isFinite(maxPossibleCycles)) maxPossibleCycles = 0;
cyclesByNode.set(node.schematicId, maxPossibleCycles);
// Calculate consumption
node.inputs.forEach(input => {
const currentTotal = consumptionTotals.get(input.typeId) ?? 0;
const factoryConsumption = input.quantity * maxPossibleCycles * factoryCount;
consumptionTotals.set(input.typeId, currentTotal + factoryConsumption);
});
// Calculate production
node.outputs.forEach(output => {
const currentTotal = productionTotals.get(output.typeId) ?? 0;
const factoryProduction = output.quantity * maxPossibleCycles * factoryCount;
productionTotals.set(output.typeId, currentTotal + factoryProduction);
});
});
// Final pass: Update import amounts for any remaining deficits
requiredInputs.forEach(typeId => {
const production = productionTotals.get(typeId) ?? 0;
const consumption = consumptionTotals.get(typeId) ?? 0;
if (consumption > production) {
importedTypes.add(typeId);
importAmounts.set(typeId, consumption - production);
}
});
// Group types by production level
const levelGroups = new Map<number, number[]>();
Array.from(allTypeIds).forEach(typeId => {
const level = productionLevels.get(typeId) ?? 0;
const group = levelGroups.get(level) ?? [];
group.push(typeId);
levelGroups.set(level, group);
});
// Get factory count for a type
const getFactoryCount = (typeId: number): number => {
const node = nodesByOutput.get(typeId);
if (!node) return 0;
return factories.find(f => f.schematic_id === node.schematicId)?.count ?? 0;
};
// Get input requirements for a type
const getInputRequirements = (typeId: number): Array<{ typeId: number; quantity: number }> => {
const node = nodesByOutput.get(typeId);
if (!node) return [];
return node.inputs;
};
// Get schematic cycle time for a type
const getSchematicCycleTime = (typeId: number): number | undefined => {
const node = nodesByOutput.get(typeId);
return node?.cycleTime;
};
return (
<Paper sx={{ p: 2, my: 2 }}>
<Typography variant="h6" gutterBottom>
Production Chain
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{Array.from(levelGroups.entries())
.sort(([a], [b]) => a - b)
.map(([level, typeIds]) => (
<Box key={level}>
<Typography variant="subtitle1" gutterBottom sx={{ borderBottom: '2px solid', borderColor: 'divider', pb: 1 }}>
{level === 0 ? 'Raw Materials (P0)' :
level === 1 ? 'Basic Materials (P1)' :
level === 2 ? 'Refined Materials (P2)' :
level === 3 ? 'Advanced Materials (P3)' : 'High-Tech Products (P4)'}
</Typography>
<Grid container spacing={2}>
{typeIds.map(typeId => {
const type = PI_TYPES_MAP[typeId];
const factoryCount = getFactoryCount(typeId);
const isImported = importedTypes.has(typeId);
const importAmount = importAmounts.get(typeId) ?? 0;
const production = productionTotals.get(typeId) ?? 0;
const consumption = consumptionTotals.get(typeId) ?? 0;
const inputs = getInputRequirements(typeId);
const cycleTime = getSchematicCycleTime(typeId);
return (
<Grid item key={typeId} xs={12} sm={6} md={4}>
<Paper
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
gap: 1,
border: isImported ? '2px solid orange' :
production > 0 ? '2px solid green' :
consumption > 0 ? '2px solid red' : 'none',
height: '100%'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<img
src={`${EVE_IMAGE_URL}/types/${typeId}/icon`}
alt={type?.name ?? `Type ${typeId}`}
width={48}
height={48}
/>
<Box>
<Typography variant="subtitle2">
{type?.name ?? `Type ${typeId}`}
</Typography>
{cycleTime && (
<Typography variant="caption" color="text.secondary">
{cycleTime === 1800 ? 'Basic (30m)' : 'Advanced (1h)'}
</Typography>
)}
</Box>
</Box>
{inputs.length > 0 && (
<>
<Divider />
<Typography variant="caption" color="text.secondary">
Inputs per cycle:
</Typography>
<Stack spacing={0.5}>
{inputs.map(input => (
<Typography key={input.typeId} variant="caption" sx={{ pl: 2 }}>
{PI_TYPES_MAP[input.typeId]?.name}: {input.quantity} units
{factoryCount > 0 && ` (${(input.quantity * factoryCount).toFixed(0)} total)`}
</Typography>
))}
</Stack>
</>
)}
<Divider />
<Stack spacing={0.5}>
{factoryCount > 0 && (
<>
<Typography variant="caption" color="text.secondary">
Factories: {factoryCount}
</Typography>
{cycleTime && (
<Typography variant="caption" color="text.secondary">
Cycles per hour: {(3600 / cycleTime).toFixed(1)}
</Typography>
)}
</>
)}
{production > 0 && (
<>
<Typography variant="caption" color="success.main">
Production: {production.toFixed(1)} units total
</Typography>
{factoryCount > 0 && (
<Typography variant="caption" color="success.main">
({(production / factoryCount).toFixed(1)} units/factory)
</Typography>
)}
</>
)}
{consumption > 0 && (
<>
<Typography variant="caption" color="error.main">
Consumption: {consumption.toFixed(1)} units total
</Typography>
{factoryCount > 0 && (
<Typography variant="caption" color="error.main">
({(consumption / factoryCount).toFixed(1)} units/factory)
</Typography>
)}
</>
)}
{isImported && (
<>
<Typography variant="caption" color="warning.main" sx={{ fontWeight: 'bold' }}>
Required Import: {importAmount.toFixed(1)} units
</Typography>
<Typography variant="caption" color="warning.main">
(Local production: {production.toFixed(1)} units)
</Typography>
</>
)}
<Typography
variant="caption"
color={production - consumption > 0 ? "success.main" :
production - consumption < 0 ? "error.main" : "text.secondary"}
sx={{ fontWeight: 'bold' }}
>
Net: {(production - consumption).toFixed(1)} units total
{factoryCount > 0 && (
<>
<br />
({((production - consumption) / factoryCount).toFixed(1)} units/factory)
</>
)}
</Typography>
</Stack>
</Paper>
</Grid>
);
})}
</Grid>
</Box>
))}
</Box>
</Paper>
);
};