[MIRROR] Completely rewrites the orbit screen [MDB IGNORE] (#14715)

* Completely rewrites the orbit screen (#68054)

I set about refactoring Orbit.js when I just decided to scrap it and maybe add in some different functionality.

This PR:
- rewrites orbit from the ground up in typescript
- color coded list + scrollable + wider
- adds some styling and effects to gauge "threat": icons change, buttons change color
- fixes button text overflow
- fixes issue where scrolling hides the search header
- fixes similar antags being grouped separately (nukie and nukie leader, etc)

* Completely rewrites the orbit screen

Co-authored-by: Jeremiah <42397676+jlsnow301@users.noreply.github.com>
This commit is contained in:
SkyratBot
2022-07-04 02:10:31 +02:00
committed by GitHub
parent 6bd66b30c1
commit 862fbd773c
4 changed files with 318 additions and 248 deletions
-5
View File
@@ -39,11 +39,6 @@ GLOBAL_DATUM_INIT(orbit_menu, /datum/orbit_menu, new)
update_static_data(usr, ui)
return TRUE
/datum/orbit_menu/ui_assets()
return list(
get_asset_datum(/datum/asset/simple/orbit),
)
/datum/orbit_menu/ui_static_data(mob/user)
var/list/new_mob_pois = SSpoints_of_interest.get_mob_pois(CALLBACK(src, .proc/validate_mob_poi), append_dead_role = FALSE)
var/list/new_other_pois = SSpoints_of_interest.get_other_pois()
-242
View File
@@ -1,242 +0,0 @@
import { createSearch } from 'common/string';
import { multiline } from 'common/string';
import { resolveAsset } from '../assets';
import { useBackend, useLocalState } from '../backend';
import { Box, Button, Divider, Flex, Icon, Input, Section } from '../components';
import { Window } from '../layouts';
const PATTERN_NUMBER = / \(([0-9]+)\)$/;
const searchFor = (searchText) =>
createSearch(searchText, (thing) => thing.name);
const compareString = (a, b) => (a < b ? -1 : a > b);
const compareNumberedText = (a, b) => {
const aName = a.name;
const bName = b.name;
// Check if aName and bName are the same except for a number at the end
// e.g. Medibot (2) and Medibot (3)
const aNumberMatch = aName.match(PATTERN_NUMBER);
const bNumberMatch = bName.match(PATTERN_NUMBER);
if (
aNumberMatch &&
bNumberMatch &&
aName.replace(PATTERN_NUMBER, '') === bName.replace(PATTERN_NUMBER, '')
) {
const aNumber = parseInt(aNumberMatch[1], 10);
const bNumber = parseInt(bNumberMatch[1], 10);
return aNumber - bNumber;
}
return compareString(aName, bName);
};
const BasicSection = (props, context) => {
const { act } = useBackend(context);
const { searchText, source, title, autoObserve } = props;
const things = source.filter(searchFor(searchText));
things.sort(compareNumberedText);
return (
source.length > 0 && (
<Section title={`${title} - (${source.length})`}>
{things.map((thing) => (
<Button
key={thing.name}
content={thing.name}
onClick={() =>
act('orbit', {
ref: thing.ref,
auto_observe: autoObserve,
})
}
/>
))}
</Section>
)
);
};
const OrbitedButton = (props, context) => {
const { act } = useBackend(context);
const { color, thing, autoObserve } = props;
return (
<Button
color={color}
onClick={() =>
act('orbit', {
ref: thing.ref,
auto_observe: autoObserve,
})
}>
{thing.name}
{thing.orbiters && (
<Box inline ml={1}>
{'('}
{thing.orbiters}{' '}
<Box as="img" src={resolveAsset('ghost.png')} opacity={0.7} />
{')'}
</Box>
)}
</Button>
);
};
export const Orbit = (props, context) => {
const { act, data } = useBackend(context);
const { alive, antagonists, dead, ghosts, misc, npcs } = data;
const [searchText, setSearchText] = useLocalState(context, 'searchText', '');
const [autoObserve, setAutoObserve] = useLocalState(
context,
'autoObserve',
false
);
const collatedAntagonists = {};
for (const antagonist of antagonists) {
if (collatedAntagonists[antagonist.antag] === undefined) {
collatedAntagonists[antagonist.antag] = [];
}
collatedAntagonists[antagonist.antag].push(antagonist);
}
const sortedAntagonists = Object.entries(collatedAntagonists);
sortedAntagonists.sort((a, b) => {
return compareString(a[0], b[0]);
});
const orbitMostRelevant = (searchText) => {
for (const source of [
sortedAntagonists.map(([_, antags]) => antags),
alive,
ghosts,
dead,
npcs,
misc,
]) {
const member = source
.filter(searchFor(searchText))
.sort(compareNumberedText)[0];
if (member !== undefined) {
act('orbit', {
ref: member.ref,
auto_observe: autoObserve,
});
break;
}
}
};
return (
<Window title="Orbit" width={350} height={700}>
<Window.Content scrollable>
<Section>
<Flex>
<Flex.Item>
<Icon name="search" mr={1} />
</Flex.Item>
<Flex.Item grow={1}>
<Input
placeholder="Search..."
autoFocus
fluid
value={searchText}
onInput={(_, value) => setSearchText(value)}
onEnter={(_, value) => orbitMostRelevant(value)}
/>
</Flex.Item>
<Flex.Item>
<Divider vertical />
</Flex.Item>
<Flex.Item>
<Button
inline
color="transparent"
tooltip={multiline`Toggle Auto-Observe. When active, you'll
see the UI / full inventory of whoever you're orbiting. Neat!`}
tooltipPosition="bottom-start"
selected={autoObserve}
icon={autoObserve ? 'toggle-on' : 'toggle-off'}
onClick={() => setAutoObserve(!autoObserve)}
/>
</Flex.Item>
<Flex.Item>
<Button
inline
color="transparent"
tooltip="Refresh"
tooltipPosition="bottom-start"
icon="sync-alt"
onClick={() => act('refresh')}
/>
</Flex.Item>
</Flex>
</Section>
{antagonists.length > 0 && (
<Section title="Ghost-Visible Antagonists">
{sortedAntagonists.map(([name, antags]) => (
<Section key={name} title={name} level={2}>
{antags
.filter(searchFor(searchText))
.sort(compareNumberedText)
.map((antag) => (
<OrbitedButton
key={antag.name}
color="bad"
thing={antag}
autoObserve={autoObserve}
/>
))}
</Section>
))}
</Section>
)}
<Section title={`Alive - (${alive.length})`}>
{alive
.filter(searchFor(searchText))
.sort(compareNumberedText)
.map((thing) => (
<OrbitedButton
key={thing.name}
color="good"
thing={thing}
autoObserve={autoObserve}
/>
))}
</Section>
<Section title={`Ghosts - (${ghosts.length})`}>
{ghosts
.filter(searchFor(searchText))
.sort(compareNumberedText)
.map((thing) => (
<OrbitedButton
key={thing.name}
color="grey"
thing={thing}
autoObserve={autoObserve}
/>
))}
</Section>
<BasicSection
title="Dead"
source={dead}
searchText={searchText}
autoObserve={autoObserve}
/>
<BasicSection title="NPCs" source={npcs} searchText={searchText} />
<BasicSection title="Misc" source={misc} searchText={searchText} />
</Window.Content>
</Window>
);
};
+317
View File
@@ -0,0 +1,317 @@
import { useBackend, useLocalState } from '../backend';
import { filter, sortBy } from 'common/collections';
import { multiline } from 'common/string';
import { Button, Collapsible, Icon, Input, Section, Stack } from '../components';
import { Window } from '../layouts';
import { flow } from 'common/fp';
import { logger } from '../logging';
type AntagGroup = [string, Observable[]];
type Data = {
alive: Observable[];
antagonists: Observable[];
dead: Observable[];
ghosts: Observable[];
misc: Observable[];
npcs: Observable[];
};
type Observable = {
ref: string;
antag?: string;
name: string;
orbiters?: number;
};
type SectionProps = {
color?: string;
section: Observable[];
title: string;
};
const ANTAG2COLOR = {
'Abductors': 'pink',
'Ash Walkers': 'olive',
'Biohazards': 'brown',
} as const;
const ANTAG2GROUP = {
'Abductor Agent': 'Abductors',
'Abductor Scientist': 'Abductors',
'Ash Walker': 'Ash Walkers',
'Blob': 'Biohazards',
'Sentient Disease': 'Biohazards',
'Clown Operative': 'Clown Operatives',
'Clown Operative Leader': 'Clown Operatives',
'Nuclear Operative': 'Nuclear Operatives',
'Nuclear Operative Leader': 'Nuclear Operatives',
'Space Wizard': 'Wizard Federation',
'Wizard Apprentice': 'Wizard Federation',
'Wizard Minion': 'Wizard Federation',
} as const;
enum THREAT {
None,
Small = 'teal',
Medium = 'blue',
Large = 'violet',
}
export const Orbit = (props, context) => {
return (
<Window title="Orbit" width={400} height={550}>
<Window.Content>
<Stack fill vertical>
<Stack.Item mt={0}>
<ObservableSearch />
</Stack.Item>
<Stack.Item mt={0.2} grow>
<Section fill scrollable>
<ObservableContent />
</Section>
</Stack.Item>
</Stack>
</Window.Content>
</Window>
);
};
/** Controls filtering out the list of observables via search */
const ObservableSearch = (props, context) => {
const { act, data } = useBackend<Data>(context);
const {
alive = [],
antagonists = [],
dead = [],
ghosts = [],
misc = [],
npcs = [],
} = data;
const [autoObserve, setAutoObserve] = useLocalState<boolean>(
context,
'autoObserve',
false
);
const [searchQuery, setSearchQuery] = useLocalState<string>(
context,
'searchQuery',
''
);
/** Gets a list of Observable[], then filters the most relevant to orbit */
const orbitMostRelevant = (searchQuery: string): void => {
/** Returns the most orbited observable that matches the search. */
const mostRelevant: Observable = flow([
// Filters out anything that doesn't match search
filter<Observable>((observable) =>
observable.name?.toLowerCase().includes(searchQuery?.toLowerCase())
),
// Sorts descending by orbiters
sortBy<Observable>((poi) => -(poi.orbiters || 0)),
// Makes a single Observable[] list for an easy search
])([alive, antagonists, dead, ghosts, misc, npcs].flat())[0];
logger.log(mostRelevant);
if (mostRelevant !== undefined) {
act('orbit', {
ref: mostRelevant.ref,
auto_observe: autoObserve,
});
}
};
return (
<Section>
<Stack>
<Stack.Item>
<Icon name="search" />
</Stack.Item>
<Stack.Item grow>
<Input
autoFocus
fluid
onEnter={(e, value) => orbitMostRelevant(value)}
onInput={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
value={searchQuery}
/>
</Stack.Item>
<Stack.Divider />
<Stack.Item>
<Button
color={autoObserve ? 'good' : 'transparent'}
icon={autoObserve ? 'toggle-on' : 'toggle-off'}
onClick={() => setAutoObserve(!autoObserve)}
tooltip={multiline`Toggle Auto-Observe. When active, you'll
see the UI / full inventory of whoever you're orbiting. Neat!`}
tooltipPosition="bottom-start"
/>
</Stack.Item>
<Stack.Item>
<Button
inline
color="transparent"
tooltip="Refresh"
tooltipPosition="bottom-start"
icon="sync-alt"
onClick={() => act('refresh')}
/>
</Stack.Item>
</Stack>
</Section>
);
};
/**
* The primary content display for points of interest.
* Renders a scrollable section replete with subsections for each
* observable group.
*/
const ObservableContent = (props, context) => {
const { data } = useBackend<Data>(context);
const {
alive = [],
antagonists = [],
dead = [],
ghosts = [],
misc = [],
npcs = [],
} = data;
let collatedAntagonists: AntagGroup[] = [];
if (antagonists.length) {
collatedAntagonists = collateAntagonists(antagonists);
}
return (
<Stack vertical>
{collatedAntagonists?.map(([name, antag]) => {
return (
<ObservableSection
color={ANTAG2COLOR[name] || 'bad'}
key={name}
section={antag}
title={name}
/>
);
})}
<ObservableSection color="good" section={alive} title="Alive" />
<ObservableSection section={dead} title="Dead" />
<ObservableSection section={ghosts} title="Ghosts" />
<ObservableSection section={misc} title="Misc" />
<ObservableSection section={npcs} title="NPCs" />
</Stack>
);
};
/**
* Displays a collapsible with a map of observable items.
* Filters the results if there is a provided search query.
*/
const ObservableSection = (props: SectionProps, context) => {
const { color = 'grey', section = [], title } = props;
if (!section.length) {
return null;
}
const [searchQuery, setSearchQuery] = useLocalState<string>(
context,
'searchQuery',
''
);
const filteredSection: Observable[] = flow([
filter<Observable>((poi) =>
poi.name?.toLowerCase().includes(searchQuery?.toLowerCase())
),
sortBy<Observable>((poi) => poi.name.toLowerCase()),
])(section);
if (!filteredSection.length) {
return null;
}
return (
<Stack.Item>
<Collapsible
bold
color={color}
open={color !== 'grey'}
title={title + ` - (${filteredSection.length})`}>
{filteredSection.map((poi, index) => {
return <ObservableItem color={color} item={poi} key={index} />;
})}
</Collapsible>
</Stack.Item>
);
};
/** Renders an observable button */
const ObservableItem = (
props: { color: string; item: Observable },
context
) => {
const { act } = useBackend<Data>(context);
const {
color,
item: { name, orbiters, ref },
} = props;
const [autoObserve, setAutoObserve] = useLocalState<boolean>(
context,
'autoObserve',
false
);
const threat = getThreat(orbiters || 0);
return (
<Button
color={threat || color}
onClick={() => act('orbit', { auto_observe: autoObserve, ref: ref })}>
{nameToUpper(name).slice(0, 44) /** prevents it from overflowing */}
{!!orbiters && (
<>
{' '}
({orbiters?.toString()}{' '}
<Icon mr={0} name={threat === THREAT.Large ? 'skull' : 'ghost'} />)
</>
)}
</Button>
);
};
/**
* Collates antagonist groups into their own separate sections.
* Some antags are grouped together lest they be listed separately,
* ie: Nuclear Operatives. See: ANTAG_GROUPS.
*/
const collateAntagonists = (antagonists: Observable[]): AntagGroup[] => {
const collatedAntagonists = {};
for (const antagonist of antagonists) {
const { antag } = antagonist;
const resolvedName = ANTAG2GROUP[antag!] || antag;
if (collatedAntagonists[resolvedName] === undefined) {
collatedAntagonists[resolvedName] = [];
}
collatedAntagonists[resolvedName].push(antagonist);
}
const sortedAntagonists = sortBy<AntagGroup>((antagonist) => antagonist[0])(
Object.entries(collatedAntagonists)
);
return sortedAntagonists;
};
/** Takes the amount of orbiters and returns some style options */
const getThreat = (orbiters: number): THREAT => {
if (!orbiters || orbiters <= 2) {
return THREAT.None;
} else if (orbiters === 3) {
return THREAT.Small;
} else if (orbiters <= 6) {
return THREAT.Medium;
} else {
return THREAT.Large;
}
};
/**
* Returns a string with the first letter in uppercase.
* Unlike capitalize(), has no effect on the other letters
*/
const nameToUpper = (name: string): string =>
name.replace(/^\w/, (c) => c.toUpperCase());
+1 -1
View File
@@ -19,7 +19,7 @@ type Wire = {
export const Wires = (props, context) => {
const { data } = useBackend<Data>(context);
const { proper_name, status = [], wires = [] } = data;
const dynamicHeight = 150 + wires.length * 28 + (proper_name ? 30 : 0);
const dynamicHeight = 150 + wires.length * 30 + (proper_name ? 30 : 0);
return (
<Window width={350} height={dynamicHeight}>