From 862fbd773c18d7dd83fc01b6e319b2aacf8a2d3a Mon Sep 17 00:00:00 2001 From: SkyratBot <59378654+SkyratBot@users.noreply.github.com> Date: Mon, 4 Jul 2022 02:10:31 +0200 Subject: [PATCH] [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> --- code/modules/mob/dead/observer/orbit.dm | 5 - tgui/packages/tgui/interfaces/Orbit.js | 242 ------------------ tgui/packages/tgui/interfaces/Orbit.tsx | 317 ++++++++++++++++++++++++ tgui/packages/tgui/interfaces/Wires.tsx | 2 +- 4 files changed, 318 insertions(+), 248 deletions(-) delete mode 100644 tgui/packages/tgui/interfaces/Orbit.js create mode 100644 tgui/packages/tgui/interfaces/Orbit.tsx diff --git a/code/modules/mob/dead/observer/orbit.dm b/code/modules/mob/dead/observer/orbit.dm index eaa2012b0ac..fdcfc15b8e0 100644 --- a/code/modules/mob/dead/observer/orbit.dm +++ b/code/modules/mob/dead/observer/orbit.dm @@ -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() diff --git a/tgui/packages/tgui/interfaces/Orbit.js b/tgui/packages/tgui/interfaces/Orbit.js deleted file mode 100644 index 6882097ead2..00000000000 --- a/tgui/packages/tgui/interfaces/Orbit.js +++ /dev/null @@ -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 && ( -
- {things.map((thing) => ( -
- ) - ); -}; - -const OrbitedButton = (props, context) => { - const { act } = useBackend(context); - const { color, thing, autoObserve } = props; - - return ( - - ); -}; - -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 ( - - -
- - - - - - setSearchText(value)} - onEnter={(_, value) => orbitMostRelevant(value)} - /> - - - - - -
- {antagonists.length > 0 && ( -
- {sortedAntagonists.map(([name, antags]) => ( -
- {antags - .filter(searchFor(searchText)) - .sort(compareNumberedText) - .map((antag) => ( - - ))} -
- ))} -
- )} - -
- {alive - .filter(searchFor(searchText)) - .sort(compareNumberedText) - .map((thing) => ( - - ))} -
- -
- {ghosts - .filter(searchFor(searchText)) - .sort(compareNumberedText) - .map((thing) => ( - - ))} -
- - - - - - -
-
- ); -}; diff --git a/tgui/packages/tgui/interfaces/Orbit.tsx b/tgui/packages/tgui/interfaces/Orbit.tsx new file mode 100644 index 00000000000..91f25be3f27 --- /dev/null +++ b/tgui/packages/tgui/interfaces/Orbit.tsx @@ -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 ( + + + + + + + +
+ +
+
+
+
+
+ ); +}; + +/** Controls filtering out the list of observables via search */ +const ObservableSearch = (props, context) => { + const { act, data } = useBackend(context); + const { + alive = [], + antagonists = [], + dead = [], + ghosts = [], + misc = [], + npcs = [], + } = data; + const [autoObserve, setAutoObserve] = useLocalState( + context, + 'autoObserve', + false + ); + const [searchQuery, setSearchQuery] = useLocalState( + 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.name?.toLowerCase().includes(searchQuery?.toLowerCase()) + ), + // Sorts descending by orbiters + sortBy((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 ( +
+ + + + + + orbitMostRelevant(value)} + onInput={(e) => setSearchQuery(e.target.value)} + placeholder="Search..." + value={searchQuery} + /> + + + +
+ ); +}; + +/** + * 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(context); + const { + alive = [], + antagonists = [], + dead = [], + ghosts = [], + misc = [], + npcs = [], + } = data; + let collatedAntagonists: AntagGroup[] = []; + if (antagonists.length) { + collatedAntagonists = collateAntagonists(antagonists); + } + + return ( + + {collatedAntagonists?.map(([name, antag]) => { + return ( + + ); + })} + + + + + + + ); +}; + +/** + * 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( + context, + 'searchQuery', + '' + ); + const filteredSection: Observable[] = flow([ + filter((poi) => + poi.name?.toLowerCase().includes(searchQuery?.toLowerCase()) + ), + sortBy((poi) => poi.name.toLowerCase()), + ])(section); + if (!filteredSection.length) { + return null; + } + + return ( + + + {filteredSection.map((poi, index) => { + return ; + })} + + + ); +}; + +/** Renders an observable button */ +const ObservableItem = ( + props: { color: string; item: Observable }, + context +) => { + const { act } = useBackend(context); + const { + color, + item: { name, orbiters, ref }, + } = props; + const [autoObserve, setAutoObserve] = useLocalState( + context, + 'autoObserve', + false + ); + const threat = getThreat(orbiters || 0); + + return ( + + ); +}; + +/** + * 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((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()); diff --git a/tgui/packages/tgui/interfaces/Wires.tsx b/tgui/packages/tgui/interfaces/Wires.tsx index f9e621a4c78..3bfd64b552f 100644 --- a/tgui/packages/tgui/interfaces/Wires.tsx +++ b/tgui/packages/tgui/interfaces/Wires.tsx @@ -19,7 +19,7 @@ type Wire = { export const Wires = (props, context) => { const { data } = useBackend(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 (