Adds extended tooltip information to observables in the orbit ui (#70547)

A continuation of #68389 which addresses an issue that still bothers me to this day:

The orbit menu displays a player's name as a combo of name id transform. It can get lengthy to a point where the names clip the entire screen (as buttons do not multiline).

This PR shortens excessively long player names on the orbit menu and adds a tooltip that will show extended info like full name, health and job titles.

Mostly drawn from concerns brought up in the original.
This commit is contained in:
Jeremiah
2022-10-16 11:05:39 -07:00
committed by GitHub
parent a672a6d294
commit 9ecf77ca28
2 changed files with 166 additions and 57 deletions
+29 -1
View File
@@ -57,7 +57,7 @@ GLOBAL_DATUM_INIT(orbit_menu, /datum/orbit_menu, new)
var/poi_ref = REF(mob_poi)
serialized["ref"] = poi_ref
serialized["name"] = name
serialized["full_name"] = name
if(isobserver(mob_poi))
var/number_of_orbiters = length(mob_poi.get_all_orbiters())
@@ -81,6 +81,15 @@ GLOBAL_DATUM_INIT(orbit_menu, /datum/orbit_menu, new)
var/datum/mind/mind = mob_poi.mind
var/was_antagonist = FALSE
serialized["job"] = mind?.assigned_role?.title
serialized["name"] = mob_poi.real_name
serialized["health"] = null
// Cast the mob so we can get health
var/mob/living/player
if(isliving(mob_poi)) // Kind of silly here since we've already checked for dead mobs
player = mob_poi
serialized["health"] = FLOOR((player.health / player.maxHealth * 100), 1)
for(var/datum/antagonist/antag_datum as anything in mind.antag_datums)
if (antag_datum.show_to_ghosts)
was_antagonist = TRUE
@@ -97,8 +106,27 @@ GLOBAL_DATUM_INIT(orbit_menu, /datum/orbit_menu, new)
misc += list(list(
"ref" = REF(atom_poi),
"name" = name,
"extra" = null, // Just in case you want to add anything
))
// Display the supermatter crystal integrity
if(istype(atom_poi, /obj/machinery/power/supermatter_crystal))
var/obj/machinery/power/supermatter_crystal/crystal = atom_poi
misc[length(misc)]["extra"] = "Integrity: [crystal.get_integrity_percent()]%"
continue
// Display the nuke timer
if(istype(atom_poi, /obj/machinery/nuclearbomb))
var/obj/machinery/nuclearbomb/bomb = atom_poi
if(bomb.timing)
misc[length(misc)]["extra"] = "Timer: [bomb.countdown?.displayed_text]s"
continue
// Display the holder if its a nuke disk
if(istype(atom_poi, /obj/item/disk/nuclear))
var/obj/item/disk/nuclear/disk = atom_poi
var/mob/holder = disk.pulledby || get(disk, /mob)
misc[length(misc)]["extra"] = "Location: [holder?.real_name || "Unsecured"]"
continue
return list(
"alive" = alive,
"antagonists" = antagonists,
+137 -56
View File
@@ -1,32 +1,31 @@
import { useBackend, useLocalState } from '../backend';
import { filter, sortBy } from 'common/collections';
import { capitalizeFirst, multiline } from 'common/string';
import { Button, Collapsible, Icon, Input, Section, Stack } from '../components';
import { Box, Button, Collapsible, Icon, Input, LabeledList, NoticeBox, Section, Stack } from '../components';
import { Window } from '../layouts';
import { flow } from 'common/fp';
type AntagGroup = [string, Observable[]];
type AntagGroup = [string, Antags];
type Antags = Array<Observable & { antag: string }>;
type Data = {
alive: Observable[];
antagonists: Observable[];
dead: Observable[];
ghosts: Observable[];
misc: Observable[];
npcs: Observable[];
alive: Array<Observable>;
antagonists: Antags;
dead: Array<Observable>;
ghosts: Array<Observable>;
misc: Array<Observable>;
npcs: Array<Observable>;
};
type Observable = {
ref: string;
antag?: string;
name: string;
extra?: string;
full_name: string;
health?: number;
job?: string;
name?: string;
orbiters?: number;
};
type SectionProps = {
color?: string;
section: Observable[];
title: string;
ref: string;
};
const ANTAG2COLOR = {
@@ -103,17 +102,17 @@ const ObservableSearch = (props, context) => {
'searchQuery',
''
);
/** Gets a list of Observable[], then filters the most relevant to orbit */
/** Gets a list of Observables, 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())
isJobOrNameMatch(observable, searchQuery)
),
// Sorts descending by orbiters
sortBy<Observable>((poi) => -(poi.orbiters || 0)),
// Makes a single Observable[] list for an easy search
sortBy<Observable>((observable) => -(observable.orbiters || 0)),
// Makes a single Observables list for an easy search
])([alive, antagonists, dead, ghosts, misc, npcs].flat())[0];
if (mostRelevant !== undefined) {
act('orbit', {
@@ -180,7 +179,7 @@ const ObservableContent = (props, context) => {
misc = [],
npcs = [],
} = data;
let collatedAntagonists: AntagGroup[] = [];
let collatedAntagonists: Array<AntagGroup> = [];
if (antagonists.length) {
collatedAntagonists = collateAntagonists(antagonists);
}
@@ -210,21 +209,24 @@ const ObservableContent = (props, context) => {
* 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 ObservableSection = (
props: {
color?: string;
section: Array<Observable>;
title: string;
},
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())
const [searchQuery] = useLocalState<string>(context, 'searchQuery', '');
const filteredSection: Array<Observable> = flow([
filter<Observable>((observable) =>
isJobOrNameMatch(observable, searchQuery)
),
sortBy<Observable>((poi) => poi.name.toLowerCase()),
sortBy<Observable>((observable) => observable.name?.toLowerCase()),
])(section);
if (!filteredSection.length) {
return null;
@@ -245,32 +247,29 @@ const ObservableSection = (props: SectionProps, context) => {
);
};
/** Renders an observable button */
/** Renders an observable button that has tooltip info for living Observables*/
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);
const { color, item } = props;
const { extra, full_name, health, name, orbiters, ref } = item;
const [autoObserve] = useLocalState<boolean>(context, 'autoObserve', false);
const threat = getThreat(orbiters ?? 0);
const displayName = getDisplayName(name, full_name);
return (
<Button
color={threat || color}
onClick={() => act('orbit', { auto_observe: autoObserve, ref: ref })}>
{capitalizeFirst(name).slice(0, 44) /** prevents it from overflowing */}
onClick={() => act('orbit', { auto_observe: autoObserve, ref: ref })}
tooltip={(!!health || !!extra) && <ObservableTooltip item={item} />}
tooltipPosition="bottom-start">
{capitalizeFirst(displayName)}
{!!orbiters && (
<>
{' '}
({orbiters?.toString()}{' '}
({orbiters}{' '}
<Icon mr={0} name={threat === THREAT.Large ? 'skull' : 'ghost'} />)
</>
)}
@@ -278,30 +277,100 @@ const ObservableItem = (
);
};
/** Displays some info on the mob as a tooltip. */
const ObservableTooltip = (props: { item: Observable }) => {
const {
item: { extra, full_name, job, health },
} = props;
const extraInfo = extra?.split(':');
return (
<>
<NoticeBox textAlign="center" nowrap>
Last Known Data
</NoticeBox>
<LabeledList>
{!!extraInfo && (
<LabeledList.Item label={extraInfo[0]}>
{extraInfo[1]}
</LabeledList.Item>
)}
{!!full_name && (
<LabeledList.Item label="Name">{full_name}</LabeledList.Item>
)}
{!!job && <LabeledList.Item label="Job">{job}</LabeledList.Item>}
{!!health && (
<LabeledList.Item label="Health">
{getHealthLabel(health!)}
</LabeledList.Item>
)}
</LabeledList>
</>
);
};
/**
* 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) {
const collateAntagonists = (antagonists: Antags) => {
const collatedAntagonists = {}; // Hate that I cant use a map here
antagonists.map((player) => {
const { antag } = player;
const resolvedName: string = ANTAG2GROUP[antag] || antag;
if (!collatedAntagonists[resolvedName]) {
collatedAntagonists[resolvedName] = [];
}
collatedAntagonists[resolvedName].push(antagonist);
}
const sortedAntagonists = sortBy<AntagGroup>((antagonist) => antagonist[0])(
collatedAntagonists[resolvedName].push(player);
});
const sortedAntagonists = sortBy<AntagGroup>(([key]) => key)(
Object.entries(collatedAntagonists)
);
return sortedAntagonists;
};
/** Returns a disguised name in case the person is wearing someone else's ID */
const getDisplayName = (name: string | undefined, full_name: string) => {
if (!name) {
return full_name;
}
if (
!full_name?.includes('[') ||
full_name.match(/\(as /) ||
full_name.match(/^Unknown/)
) {
return name;
}
// return only the name before the first ' [' or ' ('
return `"${full_name.split(/ \[| \(/)[0]}"`;
};
/** Returns some labels for a player's health */
const getHealthLabel = (health: number) => {
if (health >= 100) {
return <Box color="blue">Great</Box>;
}
if (health >= 75) {
return <Box color="green">Good</Box>;
}
if (health >= 50) {
return <Box color="yellow">Fair</Box>;
}
if (health >= 25) {
return <Box color="orange">Poor</Box>;
}
if (health > 0) {
return <Box color="orange">Bad</Box>;
}
if (health <= 0) {
return <Box color="red">Critical</Box>;
}
};
/** Takes the amount of orbiters and returns some style options */
const getThreat = (orbiters: number): THREAT => {
const getThreat = (orbiters: number) => {
if (!orbiters || orbiters <= 2) {
return THREAT.None;
} else if (orbiters === 3) {
@@ -312,3 +381,15 @@ const getThreat = (orbiters: number): THREAT => {
return THREAT.Large;
}
};
/** Checks if a full name or job title matches the search. */
const isJobOrNameMatch = (observable: Observable, searchQuery: string) => {
const { full_name, name, job } = observable;
const displayName = full_name ?? name;
return (
displayName?.toLowerCase().includes(searchQuery?.toLowerCase()) ||
job?.toLowerCase().includes(searchQuery?.toLowerCase()) ||
false
);
};