mirror of
https://github.com/calli-eve/eve-pi.git
synced 2026-02-12 10:48:48 +01:00
419 lines
15 KiB
TypeScript
419 lines
15 KiB
TypeScript
import { AccessToken, PlanetWithInfo, Pin } from "@/types";
|
|
import { Box, Stack, Typography, useTheme, Paper, IconButton, Divider } from "@mui/material";
|
|
import { CharacterRow } from "../Characters/CharacterRow";
|
|
import { PlanetaryInteractionRow } from "../PlanetaryInteraction/PlanetaryInteractionRow";
|
|
import { SessionContext } from "@/app/context/Context";
|
|
import { useContext, useState, useEffect } from "react";
|
|
import { PlanRow } from "./PlanRow";
|
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
|
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
|
import { planetCalculations } from "@/planets";
|
|
import { EvePraisalResult } from "@/eve-praisal";
|
|
import { STORAGE_IDS, PI_SCHEMATICS, PI_PRODUCT_VOLUMES, STORAGE_CAPACITIES } from "@/const";
|
|
import { DateTime } from "luxon";
|
|
import { PlanetCalculations, AlertState, StorageContent, StorageInfo } from "@/types/planet";
|
|
|
|
interface AccountTotals {
|
|
monthlyEstimate: number;
|
|
storageValue: number;
|
|
planetCount: number;
|
|
characterCount: number;
|
|
runningExtractors: number;
|
|
totalExtractors: number;
|
|
}
|
|
|
|
const calculateAlertState = (planetDetails: PlanetCalculations): AlertState => {
|
|
const hasLowStorage = planetDetails.storageInfo.some(storage => storage.fillRate > 60);
|
|
const hasLowImports = planetDetails.importDepletionTimes.some(depletion => depletion.hoursUntilDepletion < 24);
|
|
|
|
return {
|
|
expired: planetDetails.expired,
|
|
hasLowStorage,
|
|
hasLowImports,
|
|
hasLargeExtractorDifference: planetDetails.hasLargeExtractorDifference
|
|
};
|
|
};
|
|
|
|
const calculatePlanetDetails = (planet: PlanetWithInfo, piPrices: EvePraisalResult | undefined, balanceThreshold: number): PlanetCalculations => {
|
|
const { expired, extractors, localProduction: rawProduction, localImports, localExports: rawExports } = planetCalculations(planet);
|
|
|
|
// Convert localProduction to include factoryCount
|
|
const localProduction = new Map(Array.from(rawProduction).map(([key, value]) => [
|
|
key,
|
|
{
|
|
...value,
|
|
factoryCount: value.count || 1
|
|
}
|
|
]));
|
|
|
|
// Calculate extractor averages and check for large differences
|
|
const extractorAverages = extractors
|
|
.filter(e => e.extractor_details?.product_type_id && e.extractor_details?.qty_per_cycle)
|
|
.map(e => {
|
|
const cycleTime = e.extractor_details?.cycle_time || 3600;
|
|
const qtyPerCycle = e.extractor_details?.qty_per_cycle || 0;
|
|
return {
|
|
typeId: e.extractor_details!.product_type_id!,
|
|
averagePerHour: (qtyPerCycle * 3600) / cycleTime
|
|
};
|
|
});
|
|
|
|
const hasLargeExtractorDifference = extractorAverages.length === 2 &&
|
|
Math.abs(extractorAverages[0].averagePerHour - extractorAverages[1].averagePerHour) > balanceThreshold;
|
|
|
|
// Calculate storage info
|
|
const storageFacilities = planet.info.pins.filter((pin: Pin) =>
|
|
STORAGE_IDS().some(storage => storage.type_id === pin.type_id)
|
|
);
|
|
|
|
const storageInfo = storageFacilities.map((storage: Pin) => {
|
|
if (!storage || !storage.contents) return null;
|
|
|
|
const storageType = STORAGE_IDS().find(s => s.type_id === storage.type_id)?.name || 'Unknown';
|
|
const storageCapacity = STORAGE_CAPACITIES[storage.type_id] || 0;
|
|
|
|
const totalVolume = (storage.contents || [])
|
|
.reduce((sum: number, item: StorageContent) => {
|
|
const volume = PI_PRODUCT_VOLUMES[item.type_id] || 0;
|
|
return sum + (item.amount * volume);
|
|
}, 0);
|
|
|
|
const totalValue = (storage.contents || [])
|
|
.reduce((sum: number, item: StorageContent) => {
|
|
const price = piPrices?.appraisal.items.find((a) => a.typeID === item.type_id)?.prices.sell.min ?? 0;
|
|
return sum + (item.amount * price);
|
|
}, 0);
|
|
|
|
const fillRate = storageCapacity > 0 ? (totalVolume / storageCapacity) * 100 : 0;
|
|
|
|
return {
|
|
type: storageType,
|
|
type_id: storage.type_id,
|
|
capacity: storageCapacity,
|
|
used: totalVolume,
|
|
fillRate: fillRate,
|
|
value: totalValue
|
|
};
|
|
}).filter(Boolean) as StorageInfo[];
|
|
|
|
// Calculate import depletion times
|
|
const importDepletionTimes = localImports.map(i => {
|
|
// Find all storage facilities containing this import
|
|
const storagesWithImport = storageFacilities.filter((storage: Pin) =>
|
|
storage.contents?.some((content: StorageContent) => content.type_id === i.type_id)
|
|
);
|
|
|
|
// Get the total amount in all storage facilities
|
|
const totalAmount = storagesWithImport.reduce((sum: number, storage: Pin) => {
|
|
const content = storage.contents?.find((content: StorageContent) => content.type_id === i.type_id);
|
|
return sum + (content?.amount ?? 0);
|
|
}, 0);
|
|
|
|
// Calculate consumption rate per hour
|
|
const schematic = PI_SCHEMATICS.find(s => s.schematic_id === i.schematic_id);
|
|
const cycleTime = schematic?.cycle_time ?? 3600;
|
|
const consumptionPerHour = i.quantity * i.factoryCount * (3600 / cycleTime);
|
|
|
|
// Calculate time until depletion in hours, starting from last_update
|
|
const lastUpdate = DateTime.fromISO(planet.last_update);
|
|
const now = DateTime.now();
|
|
const hoursSinceUpdate = now.diff(lastUpdate, 'hours').hours;
|
|
const remainingAmount = Math.max(0, totalAmount - (consumptionPerHour * hoursSinceUpdate));
|
|
const hoursUntilDepletion = consumptionPerHour > 0 ? remainingAmount / consumptionPerHour : 0;
|
|
|
|
// Calculate monthly cost
|
|
const price = piPrices?.appraisal.items.find((a) => a.typeID === i.type_id)?.prices.sell.min ?? 0;
|
|
const monthlyCost = (consumptionPerHour * 24 * 30 * price) / 1000000; // Cost in millions
|
|
|
|
return {
|
|
typeId: i.type_id,
|
|
hoursUntilDepletion,
|
|
monthlyCost
|
|
};
|
|
});
|
|
|
|
// Convert localExports to match the LocalExport interface
|
|
const localExports = rawExports.map(e => {
|
|
const schematic = PI_SCHEMATICS.flatMap(s => s.outputs)
|
|
.find(s => s.type_id === e.typeId)?.schematic_id ?? 0;
|
|
const factoryCount = planet.info.pins
|
|
.filter(p => p.schematic_id === schematic)
|
|
.length;
|
|
|
|
return {
|
|
type_id: e.typeId,
|
|
schematic_id: schematic,
|
|
quantity: e.amount / factoryCount, // Convert total amount back to per-factory quantity
|
|
factoryCount
|
|
};
|
|
});
|
|
|
|
return {
|
|
expired,
|
|
extractors,
|
|
localProduction,
|
|
localImports,
|
|
localExports,
|
|
storageInfo,
|
|
extractorAverages,
|
|
hasLargeExtractorDifference,
|
|
importDepletionTimes,
|
|
visibility: 'visible' as const
|
|
};
|
|
};
|
|
|
|
const calculateAccountTotals = (characters: AccessToken[], piPrices: EvePraisalResult | undefined): AccountTotals => {
|
|
let totalMonthlyEstimate = 0;
|
|
let totalStorageValue = 0;
|
|
let totalPlanetCount = 0;
|
|
let totalCharacterCount = characters.length;
|
|
let runningExtractors = 0;
|
|
let totalExtractors = 0;
|
|
|
|
characters.forEach((character) => {
|
|
totalPlanetCount += character.planets.length;
|
|
character.planets.forEach((planet) => {
|
|
const { localExports, extractors } = planetCalculations(planet);
|
|
const planetConfig = character.planetConfig.find(p => p.planetId === planet.planet_id);
|
|
|
|
// Count running and total extractors
|
|
if (!planetConfig?.excludeFromTotals) {
|
|
extractors.forEach(extractor => {
|
|
totalExtractors++;
|
|
if (extractor.expiry_time && new Date(extractor.expiry_time) > new Date()) {
|
|
runningExtractors++;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Calculate monthly estimate
|
|
if (!planetConfig?.excludeFromTotals) {
|
|
localExports.forEach((exportItem) => {
|
|
const valueInMillions = (((piPrices?.appraisal.items.find(
|
|
(a) => a.typeID === exportItem.typeId,
|
|
)?.prices.sell.min ?? 0) *
|
|
exportItem.amount) /
|
|
1000000) *
|
|
24 *
|
|
30;
|
|
totalMonthlyEstimate += valueInMillions;
|
|
});
|
|
}
|
|
|
|
if (!planetConfig?.excludeFromTotals) {
|
|
planet.info.pins
|
|
.filter(pin => STORAGE_IDS().some(storage => storage.type_id === pin.type_id))
|
|
.forEach(storage => {
|
|
storage.contents?.forEach(content => {
|
|
const valueInMillions = (piPrices?.appraisal.items.find(
|
|
(a) => a.typeID === content.type_id,
|
|
)?.prices.sell.min ?? 0) * content.amount / 1000000;
|
|
totalStorageValue += valueInMillions;
|
|
});
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
return {
|
|
monthlyEstimate: totalMonthlyEstimate,
|
|
storageValue: totalStorageValue,
|
|
planetCount: totalPlanetCount,
|
|
characterCount: totalCharacterCount,
|
|
runningExtractors,
|
|
totalExtractors
|
|
};
|
|
};
|
|
|
|
export const AccountCard = ({ characters, isCollapsed: propIsCollapsed }: { characters: AccessToken[], isCollapsed?: boolean }) => {
|
|
const theme = useTheme();
|
|
const [localIsCollapsed, setLocalIsCollapsed] = useState(false);
|
|
const { planMode, piPrices, alertMode, balanceThreshold } = useContext(SessionContext);
|
|
const { monthlyEstimate, storageValue, planetCount, characterCount, runningExtractors, totalExtractors } = calculateAccountTotals(characters, piPrices);
|
|
|
|
// Calculate planet details and alert states for each planet
|
|
const planetDetails = characters.reduce((acc, character) => {
|
|
character.planets.forEach(planet => {
|
|
const details = calculatePlanetDetails(planet, piPrices, balanceThreshold);
|
|
acc[`${character.character.characterId}-${planet.planet_id}`] = {
|
|
...details,
|
|
alertState: calculateAlertState(details)
|
|
};
|
|
});
|
|
return acc;
|
|
}, {} as Record<string, PlanetCalculations & { alertState: AlertState }>);
|
|
|
|
// Update local collapse state when prop changes
|
|
useEffect(() => {
|
|
setLocalIsCollapsed(propIsCollapsed ?? false);
|
|
}, [propIsCollapsed]);
|
|
|
|
const getAlertVisibility = (alertState: AlertState) => {
|
|
if (!alertMode) return 'visible';
|
|
if (alertState.expired) return 'visible';
|
|
if (alertState.hasLowStorage) return 'visible';
|
|
if (alertState.hasLowImports) return 'visible';
|
|
if (alertState.hasLargeExtractorDifference) return 'visible';
|
|
return 'hidden';
|
|
};
|
|
|
|
// Check if any planet in the account has alerts
|
|
const hasAnyAlerts = Object.values(planetDetails).some(details => {
|
|
const alertState = calculateAlertState(details);
|
|
return alertState.expired ||
|
|
alertState.hasLowStorage ||
|
|
alertState.hasLowImports ||
|
|
alertState.hasLargeExtractorDifference;
|
|
});
|
|
|
|
// If in alert mode and no alerts, hide the entire card
|
|
if (alertMode && !hasAnyAlerts) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Paper
|
|
elevation={2}
|
|
sx={{
|
|
padding: theme.custom.compactMode ? theme.spacing(1) : theme.spacing(2),
|
|
margin: theme.spacing(1),
|
|
display: 'flex',
|
|
alignItems: 'flex-start',
|
|
gap: 1,
|
|
backgroundColor: theme.palette.background.paper,
|
|
transition: 'all 0.2s ease-in-out',
|
|
cursor: 'grab',
|
|
'&:hover': {
|
|
boxShadow: theme.shadows[4],
|
|
},
|
|
'&:active': {
|
|
boxShadow: theme.shadows[8],
|
|
cursor: 'grabbing',
|
|
},
|
|
}}
|
|
>
|
|
<Box sx={{ flex: 1 }}>
|
|
<Box
|
|
sx={{
|
|
backgroundColor: theme.palette.background.default,
|
|
borderRadius: 1,
|
|
padding: theme.spacing(1),
|
|
marginBottom: theme.spacing(2),
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
cursor: 'pointer',
|
|
'&:hover': {
|
|
backgroundColor: theme.palette.action.hover,
|
|
},
|
|
}}
|
|
onClick={() => setLocalIsCollapsed(!localIsCollapsed)}
|
|
>
|
|
<Box>
|
|
<Typography
|
|
sx={{
|
|
fontSize: "0.9rem",
|
|
fontWeight: 500,
|
|
color: theme.palette.text.primary,
|
|
}}
|
|
>
|
|
{characters.length > 0 && characters[0].account !== "-"
|
|
? `Account: ${characters[0].account}`
|
|
: "No account name"}
|
|
</Typography>
|
|
<Box sx={{
|
|
display: 'flex',
|
|
gap: 2,
|
|
flexWrap: 'wrap',
|
|
mt: 1,
|
|
alignItems: 'center'
|
|
}}>
|
|
<Typography
|
|
sx={{
|
|
fontSize: "0.8rem",
|
|
color: theme.palette.text.secondary,
|
|
}}
|
|
>
|
|
Monthly: {monthlyEstimate >= 1000
|
|
? `${(monthlyEstimate / 1000).toFixed(2)} B`
|
|
: `${monthlyEstimate.toFixed(2)} M`} ISK
|
|
</Typography>
|
|
<Divider orientation="vertical" flexItem sx={{ height: 16, borderColor: theme.palette.divider }} />
|
|
<Typography
|
|
sx={{
|
|
fontSize: "0.8rem",
|
|
color: theme.palette.text.secondary,
|
|
}}
|
|
>
|
|
Storage: {storageValue >= 1000
|
|
? `${(storageValue / 1000).toFixed(2)} B`
|
|
: `${storageValue.toFixed(2)} M`} ISK
|
|
</Typography>
|
|
<Divider orientation="vertical" flexItem sx={{ height: 16, borderColor: theme.palette.divider }} />
|
|
<Typography
|
|
sx={{
|
|
fontSize: "0.8rem",
|
|
color: theme.palette.text.secondary,
|
|
}}
|
|
>
|
|
Planets: {planetCount}
|
|
</Typography>
|
|
<Divider orientation="vertical" flexItem sx={{ height: 16, borderColor: theme.palette.divider }} />
|
|
<Typography
|
|
sx={{
|
|
fontSize: "0.8rem",
|
|
color: theme.palette.text.secondary,
|
|
}}
|
|
>
|
|
Characters: {characterCount}
|
|
</Typography>
|
|
<Divider orientation="vertical" flexItem sx={{ height: 16, borderColor: theme.palette.divider }} />
|
|
<Typography
|
|
sx={{
|
|
fontSize: "0.8rem",
|
|
color: runningExtractors < totalExtractors ? theme.palette.error.main : theme.palette.text.secondary,
|
|
}}
|
|
>
|
|
Extractors: {runningExtractors}/{totalExtractors}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
<IconButton
|
|
size="small"
|
|
sx={{
|
|
transform: localIsCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
|
|
transition: 'transform 0.2s ease-in-out'
|
|
}}
|
|
>
|
|
{localIsCollapsed ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
|
|
</IconButton>
|
|
</Box>
|
|
{!localIsCollapsed && characters.map((c) => (
|
|
<Stack
|
|
key={c.character.characterId}
|
|
direction="row"
|
|
alignItems="flex-start"
|
|
>
|
|
<CharacterRow character={c} />
|
|
{planMode ? (
|
|
<PlanRow character={c} />
|
|
) : (
|
|
<PlanetaryInteractionRow
|
|
character={c}
|
|
planetDetails={c.planets.reduce((acc, planet) => {
|
|
const details = planetDetails[`${c.character.characterId}-${planet.planet_id}`];
|
|
acc[planet.planet_id] = {
|
|
...details,
|
|
visibility: getAlertVisibility(details.alertState)
|
|
};
|
|
return acc;
|
|
}, {} as Record<number, PlanetCalculations & { visibility: string }>)}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
))}
|
|
</Box>
|
|
</Paper>
|
|
);
|
|
};
|