tgui: Power monitor refactor, tgui-core bump (#84486)

## About The Pull Request
Refactors the power monitor UI to prevent layout shift and losing
headers as you scroll.

Also bumps tgui-core version to latest, since there's been some changes
there (Style's requests)


![I4KAjHYS4i](https://github.com/tgstation/tgstation/assets/42397676/7c7a5cdd-39a3-4492-83d3-0311c6a24720)
## Why It's Good For The Game
Neater UI for power monitor
## Changelog
🆑
fix: Fixed some UI bugs in the power monitor screen.
/🆑
This commit is contained in:
Jeremiah
2024-06-30 22:21:51 -04:00
committed by GitHub
parent 5674c1b96f
commit a6aba1706a
5 changed files with 325 additions and 237 deletions
@@ -4,7 +4,7 @@ import { PowerMonitorContent } from './PowerMonitor';
export const NtosPowerMonitor = (props) => {
return (
<NtosWindow width={550} height={700}>
<NtosWindow.Content scrollable>
<NtosWindow.Content>
<PowerMonitorContent />
</NtosWindow.Content>
</NtosWindow>
@@ -1,230 +0,0 @@
import { map, sortBy } from 'common/collections';
import { flow } from 'common/fp';
import { toFixed } from 'common/math';
import { useState } from 'react';
import { useBackend } from '../backend';
import {
Box,
Button,
Chart,
ColorBox,
Dimmer,
Flex,
Icon,
LabeledList,
ProgressBar,
Section,
Stack,
Table,
} from '../components';
import { Window } from '../layouts';
const PEAK_DRAW = 500000;
export const powerRank = (str) => {
const unit = String(str.split(' ')[1]).toLowerCase();
return ['w', 'kw', 'mw', 'gw'].indexOf(unit);
};
export const PowerMonitor = () => {
return (
<Window width={550} height={700}>
<Window.Content scrollable>
<PowerMonitorContent />
</Window.Content>
</Window>
);
};
export const PowerMonitorContent = (props) => {
const { data } = useBackend();
const { history = { supply: [], demand: [] } } = data;
const [sortByField, setSortByField] = useState(null);
const supply = history.supply[history.supply.length - 1] || 0;
const demand = history.demand[history.demand.length - 1] || 0;
const supplyData = history.supply.map((value, i) => [i, value]);
const demandData = history.demand.map((value, i) => [i, value]);
const maxValue = Math.max(PEAK_DRAW, ...history.supply, ...history.demand);
// Process area data
const areas = flow([
(areas) =>
map(areas, (area, i) => ({
...area,
// Generate a unique id
id: area.name + i,
})),
sortByField === 'name' && ((areas) => sortBy(areas, (area) => area.name)),
sortByField === 'charge' &&
((areas) => sortBy(areas, (area) => -area.charge)),
sortByField === 'draw' &&
((areas) =>
sortBy(
areas,
(area) => -powerRank(area.load),
(area) => -parseFloat(area.load),
)),
])(data.areas);
return (
<>
{areas.length === 0 && (
<Dimmer>
<Stack>
<Stack.Item>
<Icon name="plug-circle-exclamation" size={2} />
</Stack.Item>
<Stack.Item>
<h1>No APCs found!</h1>
</Stack.Item>
</Stack>
</Dimmer>
)}
<Flex mx={-0.5} mb={1}>
<Flex.Item mx={0.5} width="200px">
<Section>
<LabeledList>
<LabeledList.Item label="Supply">
<ProgressBar
value={supply}
minValue={0}
maxValue={maxValue}
color="teal"
>
{toFixed(supply / 1000) + ' kW'}
</ProgressBar>
</LabeledList.Item>
<LabeledList.Item label="Draw">
<ProgressBar
value={demand}
minValue={0}
maxValue={maxValue}
color="pink"
>
{toFixed(demand / 1000) + ' kW'}
</ProgressBar>
</LabeledList.Item>
</LabeledList>
</Section>
</Flex.Item>
<Flex.Item mx={0.5} grow={1}>
<Section position="relative" height="100%" fill="true">
<Chart.Line
fillPositionedParent
data={supplyData}
rangeX={[0, supplyData.length - 1]}
rangeY={[0, maxValue]}
strokeColor="rgba(0, 181, 173, 1)"
fillColor="rgba(0, 181, 173, 0.25)"
/>
<Chart.Line
fillPositionedParent
data={demandData}
rangeX={[0, demandData.length - 1]}
rangeY={[0, maxValue]}
strokeColor="rgba(224, 57, 151, 1)"
fillColor="rgba(224, 57, 151, 0.25)"
/>
</Section>
</Flex.Item>
</Flex>
<Section>
<Box mb={1}>
<Box inline mr={2} color="label">
Sort by:
</Box>
<Button.Checkbox
checked={sortByField === 'name'}
content="Name"
onClick={() => setSortByField(sortByField !== 'name' && 'name')}
/>
<Button.Checkbox
checked={sortByField === 'charge'}
content="Charge"
onClick={() => setSortByField(sortByField !== 'charge' && 'charge')}
/>
<Button.Checkbox
checked={sortByField === 'draw'}
content="Draw"
onClick={() => setSortByField(sortByField !== 'draw' && 'draw')}
/>
</Box>
<Table>
<Table.Row header>
<Table.Cell>Area</Table.Cell>
<Table.Cell collapsing>Charge</Table.Cell>
<Table.Cell textAlign="right">Draw</Table.Cell>
<Table.Cell collapsing title="Equipment">
Eqp
</Table.Cell>
<Table.Cell collapsing title="Lighting">
Lgt
</Table.Cell>
<Table.Cell collapsing title="Environment">
Env
</Table.Cell>
</Table.Row>
{areas.map((area, i) => (
<tr key={area.id} className="Table__row candystripe">
<td>{area.name}</td>
<td className="Table__cell text-right text-nowrap">
<AreaCharge charging={area.charging} charge={area.charge} />
</td>
<td className="Table__cell text-right text-nowrap">
{area.load}
</td>
<td className="Table__cell text-center text-nowrap">
<AreaStatusColorBox status={area.eqp} />
</td>
<td className="Table__cell text-center text-nowrap">
<AreaStatusColorBox status={area.lgt} />
</td>
<td className="Table__cell text-center text-nowrap">
<AreaStatusColorBox status={area.env} />
</td>
</tr>
))}
</Table>
</Section>
</>
);
};
export const AreaCharge = (props) => {
const { charging, charge } = props;
return (
<>
<Icon
width="18px"
textAlign="center"
name={
(charging === 0 &&
(charge > 50 ? 'battery-half' : 'battery-quarter')) ||
(charging === 1 && 'bolt') ||
(charging === 2 && 'battery-full')
}
color={
(charging === 0 && (charge > 50 ? 'yellow' : 'red')) ||
(charging === 1 && 'yellow') ||
(charging === 2 && 'green')
}
/>
<Box inline width="36px" textAlign="right">
{toFixed(charge) + '%'}
</Box>
</>
);
};
const AreaStatusColorBox = (props) => {
const { status } = props;
const power = Boolean(status & 2);
const mode = Boolean(status & 1);
const tooltipText = (power ? 'On' : 'Off') + ` [${mode ? 'auto' : 'manual'}]`;
return (
<ColorBox
color={power ? 'good' : 'bad'}
content={mode ? undefined : 'M'}
title={tooltipText}
/>
);
};
@@ -0,0 +1,318 @@
import { useState } from 'react';
import {
Box,
Button,
Chart,
ColorBox,
Flex,
Icon,
LabeledList,
ProgressBar,
Section,
Stack,
Table,
} from 'tgui-core/components';
import { toFixed } from 'tgui-core/math';
import { useBackend } from '../backend';
import { Tooltip } from '../components';
import { Window } from '../layouts';
import { LoadingScreen } from './common/LoadingToolbox';
type Data = {
areas: Area[];
history: PowerHistory;
};
type Area = {
charge: number;
charging: number;
env: number;
eqp: number;
lgt: number;
load: string;
name: string;
};
type PowerHistory = {
demand: number[];
supply: number[];
};
export function powerRank(str: string): number {
const unit = String(str.split(' ')[1]).toLowerCase();
return ['w', 'kw', 'mw', 'gw'].indexOf(unit);
}
// Oh no not another sorting algorithm
function powerSort(a: Area, b: Area): number {
const sortedByRank = powerRank(a.load) - powerRank(b.load);
const sortedByLoad = parseFloat(a.load) - parseFloat(b.load);
if (sortedByRank !== 0) {
return sortedByRank;
}
return sortedByLoad;
}
function nameSort(a: Area, b: Area): number {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
return 0;
}
export function PowerMonitor() {
return (
<Window width={550} height={700}>
<Window.Content>
<PowerMonitorContent />
</Window.Content>
</Window>
);
}
const PEAK_DRAW = 500000;
export function PowerMonitorContent(props) {
const { data } = useBackend<Data>();
const { history } = data;
if (!history) {
return <>Loading...</>;
}
const supply = history.supply[history.supply.length - 1] || 0;
const demand = history.demand[history.demand.length - 1] || 0;
const supplyData = history.supply.map((value, i) => [i, value]);
const demandData = history.demand.map((value, i) => [i, value]);
const maxValue = Math.max(PEAK_DRAW, ...history.supply, ...history.demand);
if (supplyData.length === 0 || demandData.length === 0) {
return <LoadingScreen />;
}
return (
<Stack fill vertical>
<Stack.Item>
<Flex mx={-0.5}>
<Flex.Item mx={0.5} width="200px">
<Section>
<LabeledList>
<LabeledList.Item label="Supply">
<ProgressBar
value={supply}
minValue={0}
maxValue={maxValue}
color="teal"
>
{toFixed(supply / 1000) + ' kW'}
</ProgressBar>
</LabeledList.Item>
<LabeledList.Item label="Draw">
<ProgressBar
value={demand}
minValue={0}
maxValue={maxValue}
color="pink"
>
{toFixed(demand / 1000) + ' kW'}
</ProgressBar>
</LabeledList.Item>
</LabeledList>
</Section>
</Flex.Item>
<Flex.Item mx={0.5} grow>
<Section position="relative" fill>
<Chart.Line
fillPositionedParent
data={supplyData}
rangeX={[0, supplyData.length - 1]}
rangeY={[0, maxValue]}
strokeColor="rgba(0, 181, 173, 1)"
fillColor="rgba(0, 181, 173, 0.25)"
/>
<Chart.Line
fillPositionedParent
data={demandData}
rangeX={[0, demandData.length - 1]}
rangeY={[0, maxValue]}
strokeColor="rgba(224, 57, 151, 1)"
fillColor="rgba(224, 57, 151, 0.25)"
/>
</Section>
</Flex.Item>
</Flex>
</Stack.Item>
<Stack.Item grow>
<StationAreas />
</Stack.Item>
</Stack>
);
}
function StationAreas(props) {
const { data } = useBackend<Data>();
const [sortByField, setSortByField] = useState('');
const areas = data.areas
.map((area, i) => ({
...area,
// Generate a unique id
id: area.name + i,
}))
.sort((a, b) => {
if (sortByField === 'name') {
return nameSort(a, b);
}
if (sortByField === 'charge') {
return a.charge - b.charge;
}
if (sortByField === 'draw') {
return powerSort(b, a);
}
return 0;
});
return (
<Stack fill vertical>
<Section height={3}>
<Box>
<Box inline mr={2} color="label">
Sort by:
</Box>
<Button.Checkbox
checked={sortByField === 'name'}
onClick={() => setSortByField(sortByField !== 'name' ? 'name' : '')}
>
Name
</Button.Checkbox>
<Button.Checkbox
checked={sortByField === 'charge'}
onClick={() =>
setSortByField(sortByField !== 'charge' ? 'charge' : '')
}
>
Charge
</Button.Checkbox>
<Button.Checkbox
checked={sortByField === 'draw'}
onClick={() => setSortByField(sortByField !== 'draw' ? 'draw' : '')}
>
Draw
</Button.Checkbox>
</Box>
</Section>
<Stack.Item grow mt={-1}>
<Section fill scrollable>
<Table>
<Table.Row header>
<Table.Cell>Area</Table.Cell>
<Table.Cell collapsing>Charge</Table.Cell>
<Table.Cell textAlign="right" width={7}>
Draw
</Table.Cell>
<Tooltip content="Equipment power">
<Table.Cell collapsing>Eqp</Table.Cell>
</Tooltip>
<Tooltip content="Lighting power">
<Table.Cell collapsing>Lgt</Table.Cell>
</Tooltip>
<Tooltip content="Environment power">
<Table.Cell collapsing>Env</Table.Cell>
</Tooltip>
</Table.Row>
{areas.map((area) => (
<tr key={area.id} className="Table__row candystripe">
<td>{area.name}</td>
<td className="Table__cell text-right text-nowrap">
<AreaCharge charging={area.charging} charge={area.charge} />
</td>
<td className="Table__cell text-right text-nowrap">
{area.load}
</td>
<td className="Table__cell text-center text-nowrap">
<AreaStatusColorBox status={area.eqp} />
</td>
<td className="Table__cell text-center text-nowrap">
<AreaStatusColorBox status={area.lgt} />
</td>
<td className="Table__cell text-center text-nowrap">
<AreaStatusColorBox status={area.env} />
</td>
</tr>
))}
</Table>
</Section>
</Stack.Item>
</Stack>
);
}
type AreaChargeProps = {
charge: number;
charging: number;
};
const NOT_CHARGING = 0;
const CHARGING = 1;
const CHARGED = 2;
export function AreaCharge(props: AreaChargeProps) {
const { charging, charge } = props;
let name: string;
if (charging === NOT_CHARGING) {
name = charge > 50 ? 'battery-half' : 'battery-quarter';
} else if (charging === CHARGING) {
name = 'bolt';
} else {
name = 'battery-full';
}
return (
<>
<Icon
width="18px"
textAlign="center"
name={name}
color={
(charging === NOT_CHARGING && (charge > 50 ? 'yellow' : 'red')) ||
(charging === CHARGING && 'yellow') ||
(charging === CHARGED && 'green')
}
/>
<Box inline width="36px" textAlign="right">
{toFixed(charge) + '%'}
</Box>
</>
);
}
type AreaStatusColorBoxProps = {
status: number;
};
function AreaStatusColorBox(props: AreaStatusColorBoxProps) {
const { status } = props;
const power = Boolean(status & 2);
const mode = Boolean(status & 1);
const tooltipText = (power ? 'On' : 'Off') + ` [${mode ? 'auto' : 'manual'}]`;
return (
<Tooltip content={tooltipText}>
<ColorBox
color={power ? 'good' : 'bad'}
content={mode ? undefined : 'M'}
/>
</Tooltip>
);
}
+1 -1
View File
@@ -16,7 +16,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-popper": "^2.3.0",
"tgui-core": "^1.0.18",
"tgui-core": "^1.1.2",
"tgui-dev-server": "workspace:*",
"tgui-polyfill": "workspace:*"
}
+5 -5
View File
@@ -8281,13 +8281,13 @@ __metadata:
languageName: unknown
linkType: soft
"tgui-core@npm:^1.0.18":
version: 1.0.18
resolution: "tgui-core@npm:1.0.18"
"tgui-core@npm:^1.1.2":
version: 1.1.2
resolution: "tgui-core@npm:1.1.2"
peerDependencies:
react: ^18.2.0
react-dom: ^18.2.0
checksum: 10c0/f5fa9078649ba0aa052116cd3ecbbb3a38113e9c32e6875c233d074f38db6abdb4dbfc3fb846d3cd125d4572eaf999988a082c1650b94ae429ace90e56deaca9
checksum: 10c0/05edc2215daf63bdb126f57aa74e17875784dbe1fadf19f1218dabb17dfffcd63b3c6a1678eb8a5b10b8d325755c1e124f1298350be20af913e9cd0d5a749ec9
languageName: node
linkType: hard
@@ -8399,7 +8399,7 @@ __metadata:
react: "npm:^18.2.0"
react-dom: "npm:^18.2.0"
react-popper: "npm:^2.3.0"
tgui-core: "npm:^1.0.18"
tgui-core: "npm:^1.1.2"
tgui-dev-server: "workspace:*"
tgui-polyfill: "workspace:*"
languageName: unknown