hoist calculations and alerts to accountCard level

This commit is contained in:
calli
2025-05-02 21:41:48 +03:00
parent cbef0fd39b
commit 73b54f6bf5
5 changed files with 459 additions and 297 deletions

View File

@@ -1,4 +1,4 @@
import { AccessToken } from "@/types"; import { AccessToken, PlanetWithInfo, Pin } from "@/types";
import { Box, Stack, Typography, useTheme, Paper, IconButton, Divider } from "@mui/material"; import { Box, Stack, Typography, useTheme, Paper, IconButton, Divider } from "@mui/material";
import { CharacterRow } from "../Characters/CharacterRow"; import { CharacterRow } from "../Characters/CharacterRow";
import { PlanetaryInteractionRow } from "../PlanetaryInteraction/PlanetaryInteractionRow"; import { PlanetaryInteractionRow } from "../PlanetaryInteraction/PlanetaryInteractionRow";
@@ -9,7 +9,9 @@ import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import { planetCalculations } from "@/planets"; import { planetCalculations } from "@/planets";
import { EvePraisalResult } from "@/eve-praisal"; import { EvePraisalResult } from "@/eve-praisal";
import { STORAGE_IDS } from "@/const"; 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 { interface AccountTotals {
monthlyEstimate: number; monthlyEstimate: number;
@@ -20,6 +22,146 @@ interface AccountTotals {
totalExtractors: 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 => { const calculateAccountTotals = (characters: AccessToken[], piPrices: EvePraisalResult | undefined): AccountTotals => {
let totalMonthlyEstimate = 0; let totalMonthlyEstimate = 0;
let totalStorageValue = 0; let totalStorageValue = 0;
@@ -86,14 +228,49 @@ const calculateAccountTotals = (characters: AccessToken[], piPrices: EvePraisalR
export const AccountCard = ({ characters, isCollapsed: propIsCollapsed }: { characters: AccessToken[], isCollapsed?: boolean }) => { export const AccountCard = ({ characters, isCollapsed: propIsCollapsed }: { characters: AccessToken[], isCollapsed?: boolean }) => {
const theme = useTheme(); const theme = useTheme();
const [localIsCollapsed, setLocalIsCollapsed] = useState(false); const [localIsCollapsed, setLocalIsCollapsed] = useState(false);
const { planMode, piPrices } = useContext(SessionContext); const { planMode, piPrices, alertMode, balanceThreshold } = useContext(SessionContext);
const { monthlyEstimate, storageValue, planetCount, characterCount, runningExtractors, totalExtractors } = calculateAccountTotals(characters, piPrices); 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 // Update local collapse state when prop changes
useEffect(() => { useEffect(() => {
setLocalIsCollapsed(propIsCollapsed ?? false); setLocalIsCollapsed(propIsCollapsed ?? false);
}, [propIsCollapsed]); }, [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 ( return (
<Paper <Paper
elevation={2} elevation={2}
@@ -221,7 +398,17 @@ export const AccountCard = ({ characters, isCollapsed: propIsCollapsed }: { char
{planMode ? ( {planMode ? (
<PlanRow character={c} /> <PlanRow character={c} />
) : ( ) : (
<PlanetaryInteractionRow 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> </Stack>
))} ))}

View File

@@ -4,18 +4,21 @@ import {
AccessToken, AccessToken,
PlanetWithInfo, PlanetWithInfo,
} from "@/types"; } from "@/types";
import { PlanetCalculations } from "@/types/planet";
import React, { useContext } from "react"; import React, { useContext } from "react";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { EXTRACTOR_TYPE_IDS } from "@/const";
import Countdown from "react-countdown"; import Countdown from "react-countdown";
import { getProgramOutputPrediction } from "./ExtractionSimulation"; import { ColorContext } from "@/app/context/Context";
import {
alertModeVisibility,
extractorsHaveExpired,
timeColor,
} from "./alerts";
import { ColorContext, SessionContext } from "@/app/context/Context";
import { ExtractionSimulationTooltip } from "./ExtractionSimulationTooltip"; import { ExtractionSimulationTooltip } from "./ExtractionSimulationTooltip";
import { timeColor } from "./alerts";
interface ExtractorConfig {
typeId: number;
baseValue: number;
cycleTime: number;
installTime: string;
expiryTime: string;
}
const StackItem = styled(Stack)(({ theme }) => ({ const StackItem = styled(Stack)(({ theme }) => ({
...theme.typography.body2, ...theme.typography.body2,
@@ -29,82 +32,31 @@ const StackItem = styled(Stack)(({ theme }) => ({
export const PlanetCard = ({ export const PlanetCard = ({
character, character,
planet, planet,
planetDetails,
}: { }: {
character: AccessToken; character: AccessToken;
planet: PlanetWithInfo; planet: PlanetWithInfo;
planetDetails: PlanetCalculations;
}) => { }) => {
const { alertMode } = useContext(SessionContext);
const planetInfo = planet.info;
const planetInfoUniverse = planet.infoUniverse;
const theme = useTheme(); const theme = useTheme();
const extractorsExpiryTime =
(planetInfo &&
planetInfo.pins
.filter((p) => EXTRACTOR_TYPE_IDS.some((e) => e === p.type_id))
.map((p) => p.expiry_time)) ??
[];
const { colors } = useContext(ColorContext); const { colors } = useContext(ColorContext);
const expired = extractorsHaveExpired(extractorsExpiryTime);
const CYCLE_TIME = 30 * 60; // 30 minutes in seconds const extractorConfigs: ExtractorConfig[] = planetDetails.extractors
.filter(e => e.extractor_details?.product_type_id && e.extractor_details?.qty_per_cycle)
const extractors = planetInfo?.pins .map(e => ({
.filter((p) => EXTRACTOR_TYPE_IDS.some((e) => e === p.type_id)) typeId: e.extractor_details!.product_type_id!,
.map((p) => ({ baseValue: e.extractor_details!.qty_per_cycle!,
typeId: p.type_id, cycleTime: e.extractor_details?.cycle_time || 3600,
baseValue: p.extractor_details?.qty_per_cycle || 0, installTime: e.install_time ?? "",
cycleTime: p.extractor_details?.cycle_time || 3600, expiryTime: e.expiry_time ?? ""
installTime: p.install_time || "", }));
expiryTime: p.expiry_time || "",
installedSchematicId: p.extractor_details?.product_type_id || undefined
})) || [];
// 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)
};
});
// 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 average per hour for each extractor
const extractorAverages = extractorOutputs.map(extractor => {
const totalOutput = extractor.prediction.reduce((sum, val) => sum + val, 0);
const programDuration = extractor.cycles * CYCLE_TIME;
const averagePerHour = (totalOutput / programDuration) * 3600;
return {
typeId: extractor.typeId,
averagePerHour
};
});
return ( return (
<Tooltip <Tooltip
title={ title={
extractors.length > 0 ? ( planetDetails.extractors.length > 0 ? (
<ExtractionSimulationTooltip <ExtractionSimulationTooltip
extractors={extractors} extractors={extractorConfigs}
/> />
) : null ) : null
} }
@@ -121,14 +73,13 @@ export const PlanetCard = ({
} }
}} }}
> >
<StackItem <StackItem
alignItems="flex-start" alignItems="flex-start"
height="100%" height="100%"
position="relative" position="relative"
minHeight={theme.custom.cardMinHeight} minHeight={theme.custom.cardMinHeight}
visibility={alertModeVisibility(alertMode, expired)} style={{ visibility: planetDetails.visibility }}
> >
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<Image <Image
unoptimized unoptimized
@@ -153,55 +104,53 @@ export const PlanetCard = ({
borderRadius: 8, borderRadius: 8,
}} /> }} />
</div> </div>
{planetDetails.expired && (
{expired && ( <Image
<Image width={32}
width={32} height={32}
height={32} src={`/stopped.png`}
src={`/stopped.png`} alt=""
alt="" style={{ position: "absolute", top: theme.custom.stoppedPosition }}
style={{ position: "absolute", top: theme.custom.stoppedPosition }} />
/> )}
)} <div style={{ position: "absolute", top: 5, left: 10 }}>
<div style={{ position: "absolute", top: 5, left: 10 }}> <Typography fontSize={theme.custom.smallText}>
<Typography fontSize={theme.custom.smallText}> {planet.infoUniverse?.name}
{planetInfoUniverse?.name} </Typography>
</Typography> {planetDetails.extractors.map((e, idx) => {
{extractorsExpiryTime.map((e, idx) => { const average = planetDetails.extractorAverages[idx];
const extractor = extractors[idx]; return (
const average = extractorAverages[idx]; <div key={`${e}-${idx}-${character.character.characterId}`}>
return ( <Typography
<div key={`${e}-${idx}-${character.character.characterId}`}> color={timeColor(e.expiry_time, colors)}
<Typography fontSize={theme.custom.smallText}
color={timeColor(e, colors)} >
fontSize={theme.custom.smallText} {!planetDetails.expired && e.expiry_time && <Countdown
> overtime={true}
{!expired && e && <Countdown date={DateTime.fromISO(e.expiry_time).toMillis()}
overtime={true} />
date={DateTime.fromISO(e).toMillis()} }
/> </Typography>
} {!planetDetails.expired && e && average && (
</Typography> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{!expired && extractor && average && ( <Image
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> unoptimized
<Image src={`https://images.evetech.net/types/${e.extractor_details?.product_type_id}/icon?size=32`}
unoptimized alt=""
src={`https://images.evetech.net/types/${extractor.installedSchematicId}/icon?size=32`} width={16}
alt="" height={16}
width={16} style={{ borderRadius: 4 }}
height={16} />
style={{ borderRadius: 4 }} <Typography fontSize={theme.custom.smallText}>
/> {average.averagePerHour.toFixed(1)}/h
<Typography fontSize={theme.custom.smallText}> </Typography>
{average.averagePerHour.toFixed(1)}/h </div>
</Typography> )}
</div> </div>
)} );
</div> })}
); </div>
})} </StackItem>
</div>
</StackItem>
</Tooltip> </Tooltip>
); );
}; };

View File

@@ -2,6 +2,7 @@ import { ColorContext, SessionContext } from "@/app/context/Context";
import { PI_TYPES_MAP, STORAGE_IDS, STORAGE_CAPACITIES, PI_PRODUCT_VOLUMES, EVE_IMAGE_URL, PI_SCHEMATICS, LAUNCHPAD_IDS } from "@/const"; import { PI_TYPES_MAP, STORAGE_IDS, STORAGE_CAPACITIES, PI_PRODUCT_VOLUMES, EVE_IMAGE_URL, PI_SCHEMATICS, LAUNCHPAD_IDS } from "@/const";
import { planetCalculations } from "@/planets"; import { planetCalculations } from "@/planets";
import { AccessToken, PlanetWithInfo } from "@/types"; import { AccessToken, PlanetWithInfo } from "@/types";
import { PlanetCalculations, StorageInfo } from "@/types/planet";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import MoreVertIcon from '@mui/icons-material/MoreVert'; import MoreVertIcon from '@mui/icons-material/MoreVert';
import { Button, Tooltip, Typography, useTheme, Menu, MenuItem, IconButton, Checkbox, FormControlLabel } from "@mui/material"; import { Button, Tooltip, Typography, useTheme, Menu, MenuItem, IconButton, Checkbox, FormControlLabel } from "@mui/material";
@@ -18,10 +19,9 @@ import React, { forwardRef, useContext, useState } from "react";
import Countdown from "react-countdown"; 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 "./alerts"; import { timeColor } from "./alerts";
import { ExtractionSimulationDisplay } from './ExtractionSimulationDisplay'; import { ExtractionSimulationDisplay } from './ExtractionSimulationDisplay';
import { ExtractionSimulationTooltip } from './ExtractionSimulationTooltip'; import { ExtractionSimulationTooltip } from './ExtractionSimulationTooltip';
import { ProductionNode } from './ExtractionSimulation';
import { Collapse, Box, Stack } from "@mui/material"; import { Collapse, Box, Stack } from "@mui/material";
const Transition = forwardRef(function Transition( const Transition = forwardRef(function Transition(
@@ -33,15 +33,28 @@ const Transition = forwardRef(function Transition(
return <Slide direction="up" ref={ref} {...props} />; return <Slide direction="up" ref={ref} {...props} />;
}); });
interface SchematicInput {
type_id: number;
quantity: number;
}
interface SchematicOutput {
type_id: number;
quantity: number;
}
export const PlanetTableRow = ({ export const PlanetTableRow = ({
planet, planet,
character, character,
planetDetails,
}: { }: {
planet: PlanetWithInfo; planet: PlanetWithInfo;
character: AccessToken; character: AccessToken;
planetDetails: PlanetCalculations;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const { showProductIcons, extractionTimeMode } = useContext(SessionContext); const { showProductIcons, extractionTimeMode, alertMode } = useContext(SessionContext);
const { colors } = useContext(ColorContext);
const [planetRenderOpen, setPlanetRenderOpen] = useState(false); const [planetRenderOpen, setPlanetRenderOpen] = useState(false);
const [planetConfigOpen, setPlanetConfigOpen] = useState(false); const [planetConfigOpen, setPlanetConfigOpen] = useState(false);
@@ -72,80 +85,13 @@ export const PlanetTableRow = ({
setPlanetConfigOpen(false); setPlanetConfigOpen(false);
}; };
const { piPrices, alertMode, updatePlanetConfig, readPlanetConfig, balanceThreshold } = useContext(SessionContext); const { piPrices, updatePlanetConfig, readPlanetConfig } = useContext(SessionContext);
const planetInfo = planet.info; const planetInfo = planet.info;
const planetInfoUniverse = planet.infoUniverse; const planetInfoUniverse = planet.infoUniverse;
const { expired, extractors, localProduction, localImports, localExports } =
planetCalculations(planet);
const planetConfig = readPlanetConfig({ const planetConfig = readPlanetConfig({
characterId: character.character.characterId, characterId: character.character.characterId,
planetId: planet.planet_id, planetId: planet.planet_id,
}); });
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,
factoryCount: schematic.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;
const storageFacilities = planetInfo.pins.filter(pin =>
STORAGE_IDS().some(storage => storage.type_id === pin.type_id)
);
const getStorageInfo = (pin: any) => {
if (!pin || !pin.contents) return null;
const storageType = PI_TYPES_MAP[pin.type_id].name;
const storageCapacity = STORAGE_CAPACITIES[pin.type_id] || 0;
const totalVolume = (pin.contents || [])
.reduce((sum: number, item: any) => {
const volume = PI_PRODUCT_VOLUMES[item.type_id] || 0;
return sum + (item.amount * volume);
}, 0);
const totalValue = (pin.contents || [])
.reduce((sum: number, item: any) => {
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,
capacity: storageCapacity,
used: totalVolume,
fillRate: fillRate,
value: totalValue
};
};
const handleExcludeChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleExcludeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
updatePlanetConfig({ updatePlanetConfig({
@@ -154,6 +100,19 @@ export const PlanetTableRow = ({
}); });
}; };
// Check if there are any alerts
const hasAlerts = alertMode && (
planetDetails.expired ||
planetDetails.storageInfo.some(storage => storage.fillRate > 60) ||
planetDetails.importDepletionTimes.some(depletion => depletion.hoursUntilDepletion < 24) ||
planetDetails.hasLargeExtractorDifference
);
// If in alert mode and no alerts, hide the row
if (alertMode && !hasAlerts) {
return null;
}
const renderProductDisplay = (typeId: number, amount?: number) => { const renderProductDisplay = (typeId: number, amount?: number) => {
if (!typeId || !PI_TYPES_MAP[typeId]) { if (!typeId || !PI_TYPES_MAP[typeId]) {
return ( return (
@@ -205,7 +164,7 @@ export const PlanetTableRow = ({
return ( return (
<> <>
<TableRow <TableRow
style={{ visibility: alertModeVisibility(alertMode, expired) }} style={{ visibility: planetDetails.visibility }}
sx={{ sx={{
"&:last-child td, &:last-child th": { border: 0 }, "&:last-child td, &:last-child th": { border: 0 },
cursor: 'pointer', cursor: 'pointer',
@@ -213,7 +172,7 @@ export const PlanetTableRow = ({
backgroundColor: 'action.hover' backgroundColor: 'action.hover'
} }
}} }}
onClick={(e) => { onClick={(e: React.MouseEvent<HTMLTableRowElement>) => {
if (!(e.target as HTMLElement).closest('.clickable-cell')) return; if (!(e.target as HTMLElement).closest('.clickable-cell')) return;
setSimulationOpen(!simulationOpen); setSimulationOpen(!simulationOpen);
}} }}
@@ -236,9 +195,9 @@ export const PlanetTableRow = ({
<Tooltip <Tooltip
placement="right" placement="right"
title={ title={
extractors.length > 0 ? ( planetDetails.extractors.length > 0 ? (
<ExtractionSimulationTooltip <ExtractionSimulationTooltip
extractors={extractors extractors={planetDetails.extractors
.filter(e => e.extractor_details?.product_type_id && e.extractor_details?.qty_per_cycle) .filter(e => e.extractor_details?.product_type_id && e.extractor_details?.qty_per_cycle)
.map(e => ({ .map(e => ({
typeId: e.extractor_details!.product_type_id!, typeId: e.extractor_details!.product_type_id!,
@@ -266,11 +225,11 @@ export const PlanetTableRow = ({
<Stack spacing={0}> <Stack spacing={0}>
<Typography <Typography
fontSize={theme.custom.smallText} fontSize={theme.custom.smallText}
color={hasLargeExtractorDifference ? 'error' : 'inherit'} color={planetDetails.hasLargeExtractorDifference ? 'error' : 'inherit'}
> >
{planetInfoUniverse?.name} {planetInfoUniverse?.name}
</Typography> </Typography>
{hasLargeExtractorDifference && ( {planetDetails.hasLargeExtractorDifference && (
<Typography <Typography
fontSize={theme.custom.smallText} fontSize={theme.custom.smallText}
color="error" color="error"
@@ -287,8 +246,8 @@ export const PlanetTableRow = ({
<TableCell className="clickable-cell">{planet.upgrade_level}</TableCell> <TableCell className="clickable-cell">{planet.upgrade_level}</TableCell>
<TableCell className="clickable-cell"> <TableCell className="clickable-cell">
<div style={{ display: "flex", flexDirection: "column" }}> <div style={{ display: "flex", flexDirection: "column" }}>
{extractors.length === 0 &&<Typography fontSize={theme.custom.smallText}>No extractors</Typography>} {planetDetails.extractors.length === 0 &&<Typography fontSize={theme.custom.smallText}>No extractors</Typography>}
{extractors.map((e, idx) => { {planetDetails.extractors.map((e, idx) => {
return ( return (
<div <div
key={`${e}-${idx}-${character.character.characterId}`} key={`${e}-${idx}-${character.character.characterId}`}
@@ -320,7 +279,7 @@ export const PlanetTableRow = ({
</TableCell> </TableCell>
<TableCell className="clickable-cell"> <TableCell className="clickable-cell">
<div style={{ display: "flex", flexDirection: "column" }}> <div style={{ display: "flex", flexDirection: "column" }}>
{Array.from(localProduction).map((schematic, idx) => { {Array.from(planetDetails.localProduction).map((schematic, idx) => {
return ( return (
<div <div
key={`prod-${character.character.characterId}-${planet.planet_id}-${idx}`} key={`prod-${character.character.characterId}-${planet.planet_id}-${idx}`}
@@ -334,34 +293,8 @@ export const PlanetTableRow = ({
</TableCell> </TableCell>
<TableCell className="clickable-cell"> <TableCell className="clickable-cell">
<div style={{ display: "flex", flexDirection: "column" }}> <div style={{ display: "flex", flexDirection: "column" }}>
{localImports.map((i) => { {planetDetails.localImports.map((i) => {
// Find all storage facilities (including launchpads) containing this import const depletionTime = planetDetails.importDepletionTimes.find(d => d.typeId === i.type_id);
const storagesWithImport = storageFacilities.filter(storage =>
storage.contents?.some(content => content.type_id === i.type_id)
);
// Get the total amount in all storage facilities
const totalAmount = storagesWithImport.reduce((sum, storage) => {
const content = storage.contents?.find(content => 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 ( return (
<div <div
key={`import-${character.character.characterId}-${planet.planet_id}-${i.type_id}`} key={`import-${character.character.characterId}-${planet.planet_id}-${i.type_id}`}
@@ -369,22 +302,19 @@ export const PlanetTableRow = ({
> >
<Tooltip title={ <Tooltip title={
<> <>
<div>Total in storage: {totalAmount.toFixed(1)} units</div> <div>Will be depleted in {depletionTime?.hoursUntilDepletion.toFixed(1)} hours</div>
<div>Consumption rate: {consumptionPerHour.toFixed(1)} units/hour</div> <div>Monthly cost: {depletionTime?.monthlyCost.toFixed(2)}M ISK</div>
<div>Last update: {lastUpdate.toFormat('yyyy-MM-dd HH:mm:ss')}</div>
<div>Will be depleted in {hoursUntilDepletion.toFixed(1)} hours</div>
<div>Monthly cost: {monthlyCost.toFixed(2)}M ISK</div>
</> </>
}> }>
<div style={{ display: "flex", alignItems: "center" }}> <div style={{ display: "flex", alignItems: "center" }}>
{renderProductDisplay(i.type_id, i.quantity * i.factoryCount)} {renderProductDisplay(i.type_id, i.quantity * i.factoryCount)}
{totalAmount > 0 && ( {depletionTime && (
<Typography <Typography
fontSize={theme.custom.smallText} fontSize={theme.custom.smallText}
color={hoursUntilDepletion < 24 ? 'error' : hoursUntilDepletion < 48 ? 'warning' : 'success'} color={depletionTime.hoursUntilDepletion < 24 ? 'error' : depletionTime.hoursUntilDepletion < 48 ? 'warning' : 'success'}
sx={{ ml: 1 }} sx={{ ml: 1 }}
> >
({hoursUntilDepletion.toFixed(1)}h) ({depletionTime.hoursUntilDepletion.toFixed(1)}h)
</Typography> </Typography>
)} )}
</div> </div>
@@ -396,21 +326,21 @@ export const PlanetTableRow = ({
</TableCell> </TableCell>
<TableCell className="clickable-cell"> <TableCell className="clickable-cell">
<div style={{ display: "flex", flexDirection: "column" }}> <div style={{ display: "flex", flexDirection: "column" }}>
{localExports.map((exports) => ( {planetDetails.localExports.map((exports) => (
<div <div
key={`export-${character.character.characterId}-${planet.planet_id}-${exports.typeId}`} key={`export-${character.character.characterId}-${planet.planet_id}-${exports.type_id}`}
style={{ display: "flex", alignItems: "center" }} style={{ display: "flex", alignItems: "center" }}
> >
{renderProductDisplay(exports.typeId, exports.amount)} {renderProductDisplay(exports.type_id, exports.quantity * exports.factoryCount)}
</div> </div>
))} ))}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div style={{ display: "flex", flexDirection: "column" }}> <div style={{ display: "flex", flexDirection: "column" }}>
{localExports.map((exports) => ( {planetDetails.localExports.map((exports) => (
<FormControlLabel <FormControlLabel
key={`export-excluded-${character.character.characterId}-${planet.planet_id}-${exports.typeId}`} key={`export-excluded-${character.character.characterId}-${planet.planet_id}-${exports.type_id}`}
control={ control={
<Checkbox <Checkbox
checked={planetConfig.excludeFromTotals} checked={planetConfig.excludeFromTotals}
@@ -425,12 +355,12 @@ export const PlanetTableRow = ({
</TableCell> </TableCell>
<TableCell className="clickable-cell"> <TableCell className="clickable-cell">
<div style={{ display: "flex", flexDirection: "column" }}> <div style={{ display: "flex", flexDirection: "column" }}>
{localExports.map((exports) => ( {planetDetails.localExports.map((exports) => (
<Typography <Typography
key={`export-uph-${character.character.characterId}-${planet.planet_id}-${exports.typeId}`} key={`export-uph-${character.character.characterId}-${planet.planet_id}-${exports.type_id}`}
fontSize={theme.custom.smallText} fontSize={theme.custom.smallText}
> >
{exports.amount} {exports.quantity * exports.factoryCount}
</Typography> </Typography>
))} ))}
</div> </div>
@@ -444,11 +374,11 @@ export const PlanetTableRow = ({
textAlign: "end", textAlign: "end",
}} }}
> >
{localExports.map((e) => { {planetDetails.localExports.map((e) => {
const valueInMillions = const valueInMillions =
(((piPrices?.appraisal.items.find((a) => a.typeID === e.typeId) (((piPrices?.appraisal.items.find((a) => a.typeID === e.type_id)
?.prices.sell.min ?? 0) * ?.prices.sell.min ?? 0) *
e.amount) / e.quantity * e.factoryCount) /
1000000) * 1000000) *
24 * 24 *
30; 30;
@@ -459,7 +389,7 @@ export const PlanetTableRow = ({
return ( return (
<Typography <Typography
key={`export-praisal-${character.character.characterId}-${planet.planet_id}-${e.typeId}`} key={`export-praisal-${character.character.characterId}-${planet.planet_id}-${e.type_id}`}
fontSize={theme.custom.smallText} fontSize={theme.custom.smallText}
> >
{displayValue} {displayValue}
@@ -470,38 +400,28 @@ export const PlanetTableRow = ({
</TableCell> </TableCell>
<TableCell className="clickable-cell"> <TableCell className="clickable-cell">
<div style={{ display: "flex", flexDirection: "column" }}> <div style={{ display: "flex", flexDirection: "column" }}>
{storageFacilities.length === 0 &&<Typography fontSize={theme.custom.smallText}>No storage</Typography>} {planetDetails.storageInfo.length === 0 &&<Typography fontSize={theme.custom.smallText}>No storage</Typography>}
{storageFacilities {planetDetails.storageInfo.map((storage: StorageInfo) => {
.sort((a, b) => { const isLaunchpad = LAUNCHPAD_IDS.includes(storage.type_id);
const isALaunchpad = LAUNCHPAD_IDS.includes(a.type_id); const fillRate = storage.fillRate;
const isBLaunchpad = LAUNCHPAD_IDS.includes(b.type_id); const color = fillRate > 90 ? '#ff0000' : fillRate > 80 ? '#ffa500' : fillRate > 60 ? '#ffd700' : 'inherit';
return isALaunchpad === isBLaunchpad ? 0 : isALaunchpad ? -1 : 1;
})
.map((storage) => {
const storageInfo = getStorageInfo(storage);
if (!storageInfo) return null;
const isLaunchpad = LAUNCHPAD_IDS.includes(storage.type_id); return (
<div key={`storage-${character.character.characterId}-${planet.planet_id}-${storage.type}`} style={{ display: "flex", alignItems: "center" }}>
const fillRate = storageInfo.fillRate; <Typography fontSize={theme.custom.smallText} style={{ marginRight: "5px" }}>
const color = fillRate > 90 ? '#ff0000' : fillRate > 80 ? '#ffa500' : fillRate > 60 ? '#ffd700' : 'inherit'; {isLaunchpad ? 'L' : 'S'}
</Typography>
return ( <Typography fontSize={theme.custom.smallText} style={{ color }}>
<div key={`storage-${character.character.characterId}-${planet.planet_id}-${storage.pin_id}`} style={{ display: "flex", alignItems: "center" }}> {fillRate.toFixed(1)}%
<Typography fontSize={theme.custom.smallText} style={{ marginRight: "5px" }}> </Typography>
{isLaunchpad ? 'L' : 'S'} {storage.value > 0 && (
<Typography fontSize={theme.custom.smallText} style={{ marginLeft: "5px" }}>
({Math.round(storage.value / 1000000)}M)
</Typography> </Typography>
<Typography fontSize={theme.custom.smallText} style={{ color }}> )}
{fillRate.toFixed(1)}% </div>
</Typography> );
{storageInfo.value > 0 && ( })}
<Typography fontSize={theme.custom.smallText} style={{ marginLeft: "5px" }}>
({Math.round(storageInfo.value / 1000000)}M)
</Typography>
)}
</div>
);
})}
</div> </div>
</TableCell> </TableCell>
<TableCell className="menu-cell"> <TableCell className="menu-cell">
@@ -540,7 +460,7 @@ export const PlanetTableRow = ({
<Collapse in={simulationOpen} timeout="auto" unmountOnExit> <Collapse in={simulationOpen} timeout="auto" unmountOnExit>
<Box sx={{ my: 2 }}> <Box sx={{ my: 2 }}>
<ExtractionSimulationDisplay <ExtractionSimulationDisplay
extractors={extractors extractors={planetDetails.extractors
.filter(e => e.extractor_details?.product_type_id && e.extractor_details?.qty_per_cycle) .filter(e => e.extractor_details?.product_type_id && e.extractor_details?.qty_per_cycle)
.map(e => ({ .map(e => ({
typeId: e.extractor_details!.product_type_id!, typeId: e.extractor_details!.product_type_id!,
@@ -549,7 +469,21 @@ export const PlanetTableRow = ({
installTime: e.install_time ?? "", installTime: e.install_time ?? "",
expiryTime: e.expiry_time ?? "" expiryTime: e.expiry_time ?? ""
}))} }))}
productionNodes={productionNodes} productionNodes={Array.from(planetDetails.localProduction).map(([schematicId, schematic]) => ({
schematicId: schematicId,
typeId: schematic.outputs[0].type_id,
name: schematic.name,
inputs: schematic.inputs.map((input: SchematicInput) => ({
typeId: input.type_id,
quantity: input.quantity
})),
outputs: schematic.outputs.map((output: SchematicOutput) => ({
typeId: output.type_id,
quantity: output.quantity
})),
cycleTime: schematic.cycle_time,
factoryCount: schematic.factoryCount || 1
}))}
/> />
</Box> </Box>
</Collapse> </Collapse>

View File

@@ -1,5 +1,5 @@
import { AccessToken } from "@/types"; import { AccessToken } from "@/types";
import { Icon, IconButton, Stack, Tooltip, Typography, styled, useTheme } from "@mui/material"; import { IconButton, Stack, Tooltip, Typography, styled, useTheme } from "@mui/material";
import { PlanetCard } from "./PlanetCard"; import { PlanetCard } from "./PlanetCard";
import { NoPlanetCard } from "./NoPlanetCard"; import { NoPlanetCard } from "./NoPlanetCard";
import Table from "@mui/material/Table"; import Table from "@mui/material/Table";
@@ -11,6 +11,7 @@ import TableRow from "@mui/material/TableRow";
import Paper from "@mui/material/Paper"; import Paper from "@mui/material/Paper";
import { PlanetTableRow } from "./PlanetTableRow"; import { PlanetTableRow } from "./PlanetTableRow";
import { Settings } from "@mui/icons-material"; import { Settings } from "@mui/icons-material";
import { PlanetCalculations } from "@/types/planet";
const StackItem = styled(Stack)(({ theme }) => ({ const StackItem = styled(Stack)(({ theme }) => ({
...theme.typography.body2, ...theme.typography.body2,
@@ -22,8 +23,10 @@ const StackItem = styled(Stack)(({ theme }) => ({
const PlanetaryIteractionTable = ({ const PlanetaryIteractionTable = ({
character, character,
planetDetails,
}: { }: {
character: AccessToken; character: AccessToken;
planetDetails: Record<number, PlanetCalculations>;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
@@ -117,6 +120,7 @@ const PlanetaryIteractionTable = ({
key={`${character.character.characterId}-${planet.planet_id}`} key={`${character.character.characterId}-${planet.planet_id}`}
planet={planet} planet={planet}
character={character} character={character}
planetDetails={planetDetails[planet.planet_id]}
/> />
))} ))}
</TableBody> </TableBody>
@@ -128,8 +132,10 @@ const PlanetaryIteractionTable = ({
const PlanetaryInteractionIconsRow = ({ const PlanetaryInteractionIconsRow = ({
character, character,
planetDetails,
}: { }: {
character: AccessToken; character: AccessToken;
planetDetails: Record<number, PlanetCalculations>;
}) => { }) => {
return ( return (
<StackItem> <StackItem>
@@ -139,6 +145,7 @@ const PlanetaryInteractionIconsRow = ({
key={`${character.character.characterId}-${planet.planet_id}`} key={`${character.character.characterId}-${planet.planet_id}`}
planet={planet} planet={planet}
character={character} character={character}
planetDetails={planetDetails[planet.planet_id]}
/> />
))} ))}
{Array.from(Array(6 - character.planets.length).keys()).map((i, id) => ( {Array.from(Array(6 - character.planets.length).keys()).map((i, id) => (
@@ -153,14 +160,16 @@ const PlanetaryInteractionIconsRow = ({
export const PlanetaryInteractionRow = ({ export const PlanetaryInteractionRow = ({
character, character,
planetDetails,
}: { }: {
character: AccessToken; character: AccessToken;
planetDetails: Record<number, PlanetCalculations>;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
return theme.custom.compactMode ? ( return theme.custom.compactMode ? (
<div style={{ marginTop: "1.2rem" }}><PlanetaryInteractionIconsRow character={character} /></div> <div style={{ marginTop: "1.2rem" }}><PlanetaryInteractionIconsRow character={character} planetDetails={planetDetails} /></div>
) : ( ) : (
<div style={{ marginTop: "1.4rem" }}><PlanetaryIteractionTable character={character} /></div> <div style={{ marginTop: "1.4rem" }}><PlanetaryIteractionTable character={character} planetDetails={planetDetails} /></div>
); );
}; };

83
src/types/planet.ts Normal file
View File

@@ -0,0 +1,83 @@
import { Pin, PlanetWithInfo } from '../types';
export interface StorageContent {
type_id: number;
amount: number;
}
export interface StorageInfo {
type: string;
type_id: number;
capacity: number;
used: number;
fillRate: number;
value: number;
}
export interface PlanetCalculations {
expired: boolean;
extractors: Pin[];
localProduction: Map<number, LocalProductionInfo>;
localImports: LocalImport[];
localExports: LocalExport[];
storageInfo: StorageInfo[];
extractorAverages: ExtractorAverage[];
hasLargeExtractorDifference: boolean;
importDepletionTimes: ImportDepletionTime[];
visibility: 'visible' | 'hidden';
}
export interface AlertState {
expired: boolean;
hasLowStorage: boolean;
hasLowImports: boolean;
hasLargeExtractorDifference: boolean;
}
export interface ExtractorAverage {
typeId: number;
averagePerHour: number;
}
export interface ImportDepletionTime {
typeId: number;
hoursUntilDepletion: number;
monthlyCost: number;
}
export interface LocalProductionInfo {
name: string;
cycle_time: number;
schematic_id: number;
inputs: SchematicInput[];
outputs: SchematicOutput[];
factoryCount: number;
}
export interface LocalImport {
type_id: number;
schematic_id: number;
quantity: number;
factoryCount: number;
}
export interface LocalExport {
type_id: number;
schematic_id: number;
quantity: number;
factoryCount: number;
}
export interface SchematicInput {
schematic_id: number;
type_id: number;
quantity: number;
is_input: number;
}
export interface SchematicOutput {
schematic_id: number;
type_id: number;
quantity: number;
is_input: number;
}