NanoMap QoL Changes (#25487)

* Introduce constants for map size and pixes per turf

* Scale markers with zoom

* Remove unused state

* Center map by default

* Fix zooming offset

* Add view reset button

* Fix exaggerated dragging when zoomed

* Remove zoom from local state

* Rewrite centering code

* Allow opening air alarms from map view

* Rename Marker to MarkerIcon

* Factor out generic map marker

* Implement name highlighting

* Fix oversight

* Save settings across UI opens

* Do not sanitize air alarm names

They are already automatically sanitized by Inferno

* Build and update tgui

* force ci

* Make labelStyle optional

* Make labelStyle optional again

* [ci skip]

* Build and update /tg/ui

* Reformat /tg/ui

* Autodec variables

[ci skip]

---------

Co-authored-by: Arthri <41360489+a@users.noreply.github.com>
Co-authored-by: /tg/ui Builder <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Burzah <116982774+Burzah@users.noreply.github.com>
This commit is contained in:
Arthri
2024-07-31 08:48:23 +00:00
committed by GitHub
co-authored by Arthri /tg/ui Builder <41898282+github-actions[bot]@users.noreply.github.com> Burzah
parent 2f91dcb5df
commit 69e7b890a5
14 changed files with 490 additions and 271 deletions
@@ -654,7 +654,7 @@
/obj/machinery/alarm/ui_data(mob/user)
var/list/data = list()
data["name"] = sanitize(name)
data["name"] = name
data["air"] = ui_air_status()
data["alarmActivated"] = alarmActivated || danger_level == ATMOS_ALARM_DANGER
data["thresholds"] = generate_thresholds_menu()
@@ -694,7 +694,7 @@
for(var/obj/machinery/atmospherics/unary/vent_pump/P as anything in alarm_area.vents)
var/list/vent_info = list()
vent_info["id_tag"] = P.UID()
vent_info["name"] = sanitize(P.name)
vent_info["name"] = P.name
vent_info["power"] = P.on
vent_info["direction"] = P.releasing
vent_info["checks"] = P.pressure_checks
@@ -707,7 +707,7 @@
for(var/obj/machinery/atmospherics/unary/vent_scrubber/S as anything in alarm_area.scrubbers)
var/list/scrubber_info = list()
scrubber_info["id_tag"] = S.UID()
scrubber_info["name"] = sanitize(S.name)
scrubber_info["name"] = S.name
scrubber_info["power"] = S.on
scrubber_info["scrubbing"] = S.scrubbing
scrubber_info["widenet"] = S.widenet
@@ -722,11 +722,11 @@
/obj/machinery/alarm/proc/get_console_data(mob/user)
var/list/data = list()
data["name"] = sanitize(name)
data["name"] = name
data["ref"] = "\ref[src]"
data["danger"] = max(danger_level, alarm_area.atmosalm)
var/area/A = get_area(src)
data["area"] = sanitize(A.name)
data["area"] = A.name
var/turf/T = get_turf(src)
data["x"] = T.x
data["y"] = T.y
+54
View File
@@ -1,9 +1,26 @@
#define MIN_ZOOM 1
#define MAX_ZOOM 8
#define MIN_TAB_INDEX 0
#define MAX_TAB_INDEX 1
/datum/ui_module/crew_monitor
name = "Crew monitor"
var/is_advanced = FALSE
var/viewing_current_z_level
/// If true, we'll see everyone, regardless of their suit sensors.
var/ignore_sensors = FALSE
/// The ID of the currently opened UI tab
var/tab_index = 0
/// The zoom level of the UI map view
var/zoom = 1
/// The X offset of the UI map
var/offset_x = 0
/// The Y offset of the UI map
var/offset_y = 0
/// A list of displayed names. Displayed names were intentionally chosen over ckeys,
/// refs, or uids, because exposing any of the aforementioned to the client could allow
/// an exploit to detect changelings on sensors.
var/highlighted_names = list()
/datum/ui_module/crew_monitor/ui_act(action, params)
if(..())
@@ -31,6 +48,33 @@
if(!is_advanced)
return
viewing_current_z_level = text2num(params["new_level"])
if("set_tab_index")
var/new_tab_index = text2num(params["tab_index"])
if(isnull(new_tab_index) || new_tab_index < MIN_TAB_INDEX || new_tab_index > MAX_TAB_INDEX)
return
tab_index = new_tab_index
if("set_zoom")
var/new_zoom = text2num(params["zoom"])
if(isnull(new_zoom) || new_zoom < MIN_ZOOM || new_zoom > MAX_ZOOM)
return
zoom = new_zoom
if("set_offset")
var/new_offset_x = text2num(params["offset_x"])
var/new_offset_y = text2num(params["offset_y"])
if(isnull(new_offset_x) || isnull(new_offset_y))
return
offset_x = new_offset_x
offset_y = new_offset_y
if("add_highlighted_name")
// Intentionally not sanitized as the name is not used for rendering
var/name = params["name"]
highlighted_names += list(name)
if("remove_highlighted_name")
// Intentionally not sanitized as the name is not used for rendering
var/name = params["name"]
highlighted_names -= list(name)
if("clear_highlighted_names")
highlighted_names = list()
/datum/ui_module/crew_monitor/ui_state(mob/user)
return GLOB.default_state
@@ -56,11 +100,16 @@
viewing_current_z_level = level_name_to_num(MAIN_STATION) // by default, set it to the station
data["viewing_current_z_level"] = viewing_current_z_level
data["tabIndex"] = tab_index
data["zoom"] = zoom
data["offsetX"] = offset_x
data["offsetY"] = offset_y
data["isAI"] = isAI(user)
data["isObserver"] = isobserver(user)
data["ignoreSensors"] = ignore_sensors
data["crewmembers"] = GLOB.crew_repository.health_data(viewing_current_z_level, ignore_sensors)
data["highlightedNames"] = highlighted_names
data["critThreshold"] = HEALTH_THRESHOLD_CRIT
return data
@@ -82,3 +131,8 @@
/datum/ui_module/crew_monitor/ghost/ui_state(mob/user)
return GLOB.observer_state
#undef MIN_ZOOM
#undef MAX_ZOOM
#undef MIN_TAB_INDEX
#undef MAX_TAB_INDEX
@@ -33,6 +33,7 @@ type LabeledListItemProps = {
/** @deprecated */
content?: any;
children?: InfernoNode;
labelStyle?: Record<string | symbol, any>;
};
const LabeledListItem = (props: LabeledListItemProps) => {
@@ -47,10 +48,16 @@ const LabeledListItem = (props: LabeledListItemProps) => {
content,
children,
preserveWhitespace,
labelStyle,
} = props;
let listItem = (
<tr className={classes(['LabeledList__row', className])}>
<Box as="td" color={labelColor} className={classes(['LabeledList__cell', 'LabeledList__label'])}>
<Box
as="td"
color={labelColor}
className={classes(['LabeledList__cell', 'LabeledList__label'])}
style={labelStyle}
>
{label ? label + ':' : null}
</Box>
<Box
+92 -28
View File
@@ -1,10 +1,14 @@
import { Component } from 'inferno';
import { Box, Icon, Tooltip } from '.';
import { Box, Button, Flex, Icon, Tooltip } from '.';
import { useBackend } from '../backend';
import { LabeledList } from './LabeledList';
import { Slider } from './Slider';
import { resolveAsset } from '../assets';
const MAP_SIZE = 510;
/** At zoom = 1 */
const PIXELS_PER_TURF = 2;
const pauseEvent = (e) => {
if (e.stopPropagation) {
e.stopPropagation();
@@ -26,13 +30,12 @@ export class NanoMap extends Component {
const Ycenter = window.innerHeight / 2 - 256;
this.state = {
offsetX: 128,
offsetY: 48,
transform: 'none',
offsetX: props.offsetX ?? 0,
offsetY: props.offsetY ?? 0,
dragging: false,
originX: null,
originY: null,
zoom: 1,
zoom: props.zoom ?? 1,
};
// Dragging
@@ -54,13 +57,14 @@ export class NanoMap extends Component {
const newOffsetX = e.screenX - state.originX;
const newOffsetY = e.screenY - state.originY;
if (prevState.dragging) {
state.offsetX += newOffsetX;
state.offsetY += newOffsetY;
state.offsetX += newOffsetX / state.zoom;
state.offsetY += newOffsetY / state.zoom;
state.originX = e.screenX;
state.originY = e.screenY;
} else {
state.dragging = true;
}
props.onOffsetChange?.(e, state);
return state;
});
pauseEvent(e);
@@ -80,16 +84,31 @@ export class NanoMap extends Component {
this.handleZoom = (_e, value) => {
this.setState((state) => {
const newZoom = Math.min(Math.max(value, 1), 8);
let zoomDiff = (newZoom - state.zoom) * 1.5;
state.zoom = newZoom;
state.offsetX = state.offsetX - 262 * zoomDiff;
state.offsetY = state.offsetY - 256 * zoomDiff;
if (props.onZoom) {
props.onZoom(state.zoom);
}
return state;
});
};
this.handleReset = (e) => {
this.setState((state) => {
state.offsetX = 0;
state.offsetY = 0;
state.zoom = 1;
this.handleZoom(e, 1);
props.onOffsetChange?.(e, state);
});
};
}
getChildContext() {
return {
map: {
zoom: this.state.zoom,
},
};
}
render() {
@@ -98,14 +117,17 @@ export class NanoMap extends Component {
const { children } = this.props;
const mapUrl = config.map + '_nanomap_z1.png';
const mapSize = 510 * zoom + 'px';
const mapSize = MAP_SIZE * zoom + 'px';
const newStyle = {
width: mapSize,
height: mapSize,
'margin-top': offsetY + 'px',
'margin-left': offsetX + 'px',
'margin-top': offsetY * zoom + 'px',
'margin-left': offsetX * zoom + 'px',
'overflow': 'hidden',
'position': 'relative',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
'background-size': 'cover',
'background-repeat': 'no-repeat',
'text-align': 'center',
@@ -128,21 +150,35 @@ export class NanoMap extends Component {
<img src={resolveAsset(mapUrl)} style={mapStyle} />
<Box>{children}</Box>
</Box>
<NanoMapZoomer zoom={zoom} onZoom={this.handleZoom} />
<NanoMapZoomer zoom={zoom} onZoom={this.handleZoom} onReset={this.handleReset} />
</Box>
);
}
}
const NanoMapMarker = (props, context) => {
const { x, y, zoom = 1, icon, tooltip, color } = props;
const rx = x * 2 * zoom - zoom - 3;
const ry = y * 2 * zoom - zoom - 3;
const {
map: { zoom },
} = context;
const { x, y, icon, tooltip, color, children, ...rest } = props;
const pixelsPerTurfAtZoom = PIXELS_PER_TURF * zoom;
// For some reason the X and Y are offset by 1
const rx = (x - 1) * pixelsPerTurfAtZoom;
const ry = (y - 1) * pixelsPerTurfAtZoom;
return (
<div>
<Tooltip content={tooltip}>
<Box position="absolute" className="NanoMap__marker" lineHeight="0" bottom={ry + 'px'} left={rx + 'px'}>
<Icon name={icon} color={color} fontSize="6px" />
<Box
position="absolute"
className="NanoMap__marker"
lineHeight="0"
bottom={ry + 'px'}
left={rx + 'px'}
width={pixelsPerTurfAtZoom + 'px'}
height={pixelsPerTurfAtZoom + 'px'}
{...rest}
>
{children}
</Box>
</Tooltip>
</div>
@@ -151,19 +187,47 @@ const NanoMapMarker = (props, context) => {
NanoMap.Marker = NanoMapMarker;
const NanoMapMarkerIcon = (props, context) => {
const {
map: { zoom },
} = context;
const { icon, color, ...rest } = props;
const markerSize = PIXELS_PER_TURF * zoom + 4 / Math.ceil(zoom / 4);
return (
<NanoMapMarker {...rest}>
<Icon
name={icon}
color={color}
fontSize={`${markerSize}px`}
style={{
position: 'relative',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
/>
</NanoMapMarker>
);
};
NanoMap.MarkerIcon = NanoMapMarkerIcon;
const NanoMapZoomer = (props, context) => {
return (
<Box className="NanoMap__zoomer">
<LabeledList>
<LabeledList.Item label="Zoom">
<Slider
minValue={1}
maxValue={8}
stepPixelSize={10}
format={(v) => v + 'x'}
value={props.zoom}
onDrag={(e, v) => props.onZoom(e, v)}
/>
<LabeledList.Item label="Zoom" labelStyle={{ 'vertical-align': 'middle' }}>
<Flex direction="row">
<Slider
minValue={1}
maxValue={8}
stepPixelSize={10}
format={(v) => v + 'x'}
value={props.zoom}
onDrag={(e, v) => props.onZoom(e, v)}
/>
<Button ml="0.5em" float="right" icon="sync" tooltip="Reset View" onClick={(e) => props.onReset?.(e)} />
</Flex>
</LabeledList.Item>
</LabeledList>
</Box>
@@ -102,24 +102,23 @@ const AtmosControlDataView = (_properties, context) => {
};
const AtmosControlMapView = (_properties, context) => {
const { data } = useBackend(context);
const [zoom, setZoom] = useLocalState(context, 'zoom', 1);
const { act, data } = useBackend(context);
const { alarms } = data;
return (
<Box height="526px" mb="0.5rem" overflow="hidden">
<NanoMap onZoom={(v) => setZoom(v)}>
<NanoMap>
{alarms
.filter((a) => a.z === 2)
.map((aa) => (
// The AA means air alarm, and nothing else
<NanoMap.Marker
<NanoMap.MarkerIcon
key={aa.ref}
x={aa.x}
y={aa.y}
zoom={zoom}
icon="circle"
tooltip={aa.name}
color={getStatusColour(aa.danger)}
onClick={() => act('open_alarm', { aref: aa.ref })}
/>
))}
</NanoMap>
+136 -75
View File
@@ -5,6 +5,7 @@ import { Box, Button, Dropdown, Input, NanoMap, Section, Stack, Table, Tabs } fr
import { TableCell } from '../components/Table';
import { COLORS } from '../constants';
import { Window } from '../layouts';
import { ButtonCheckbox } from '../components/Button';
const getStatText = (cm, critThreshold) => {
if (cm.dead) {
@@ -38,7 +39,11 @@ const getStatColor = (cm, critThreshold) => {
export const CrewMonitor = (props, context) => {
const { act, data } = useBackend(context);
const [tabIndex, setTabIndex] = useLocalState(context, 'tabIndex', 0);
const [tabIndex, setTabIndexInternal] = useLocalState(context, 'tabIndex', data.tabIndex);
const setTabIndex = (index) => {
setTabIndexInternal(index);
act('set_tab_index', { tab_index: index });
};
const decideTab = (index) => {
switch (index) {
case 0:
@@ -74,7 +79,7 @@ export const CrewMonitor = (props, context) => {
const CrewMonitorDataView = (_properties, context) => {
const { act, data } = useBackend(context);
const crew = sortBy((cm) => cm.name)(data.crewmembers || []);
const { possible_levels, viewing_current_z_level, is_advanced } = data;
const { possible_levels, viewing_current_z_level, is_advanced, highlightedNames } = data;
const [search, setSearch] = useLocalState(context, 'search', '');
const searcher = createSearch(search, (cm) => {
return cm.name + '|' + cm.assignment + '|' + cm.area;
@@ -103,96 +108,152 @@ const CrewMonitorDataView = (_properties, context) => {
</Stack>
<Table m="0.5rem">
<Table.Row header>
<Table.Cell>
<Button tooltip="Clear highlights" icon="square-xmark" onClick={() => act('clear_highlighted_names')} />
</Table.Cell>
<Table.Cell>Name</Table.Cell>
<Table.Cell>Status</Table.Cell>
<Table.Cell>Location</Table.Cell>
</Table.Row>
{crew.filter(searcher).map((cm) => (
<Table.Row key={cm.name} bold={!!cm.is_command}>
<TableCell>
{cm.name} ({cm.assignment})
</TableCell>
<TableCell>
<Box inline color={getStatColor(cm, data.critThreshold)}>
{getStatText(cm, data.critThreshold)}
</Box>
{cm.sensor_type >= 2 || data.ignoreSensors ? (
<Box inline ml={1}>
{'('}
<Box inline color={COLORS.damageType.oxy}>
{cm.oxy}
</Box>
{'|'}
<Box inline color={COLORS.damageType.toxin}>
{cm.tox}
</Box>
{'|'}
<Box inline color={COLORS.damageType.burn}>
{cm.fire}
</Box>
{'|'}
<Box inline color={COLORS.damageType.brute}>
{cm.brute}
</Box>
{')'}
{crew.filter(searcher).map((cm) => {
const highlighted = highlightedNames.includes(cm.name);
return (
<Table.Row key={cm.name} bold={!!cm.is_command}>
<TableCell>
<ButtonCheckbox
checked={highlighted}
tooltip="Mark on map"
onClick={() =>
act(highlighted ? 'remove_highlighted_name' : 'add_highlighted_name', { name: cm.name })
}
/>
</TableCell>
<TableCell>
{cm.name} ({cm.assignment})
</TableCell>
<TableCell>
<Box inline color={getStatColor(cm, data.critThreshold)}>
{getStatText(cm, data.critThreshold)}
</Box>
) : null}
</TableCell>
<TableCell>
{cm.sensor_type === 3 || data.ignoreSensors ? (
data.isAI || data.isObserver ? (
<Button
fluid
icon="location-arrow"
content={cm.area + ' (' + cm.x + ', ' + cm.y + ')'}
onClick={() =>
act('track', {
track: cm.ref,
})
}
/>
{cm.sensor_type >= 2 || data.ignoreSensors ? (
<Box inline ml={1}>
{'('}
<Box inline color={COLORS.damageType.oxy}>
{cm.oxy}
</Box>
{'|'}
<Box inline color={COLORS.damageType.toxin}>
{cm.tox}
</Box>
{'|'}
<Box inline color={COLORS.damageType.burn}>
{cm.fire}
</Box>
{'|'}
<Box inline color={COLORS.damageType.brute}>
{cm.brute}
</Box>
{')'}
</Box>
) : null}
</TableCell>
<TableCell>
{cm.sensor_type === 3 || data.ignoreSensors ? (
data.isAI || data.isObserver ? (
<Button
fluid
icon="location-arrow"
content={cm.area + ' (' + cm.x + ', ' + cm.y + ')'}
onClick={() =>
act('track', {
track: cm.ref,
})
}
/>
) : (
cm.area + ' (' + cm.x + ', ' + cm.y + ')'
)
) : (
cm.area + ' (' + cm.x + ', ' + cm.y + ')'
)
) : (
<Box inline color="grey">
Not Available
</Box>
)}
</TableCell>
</Table.Row>
))}
<Box inline color="grey">
Not Available
</Box>
)}
</TableCell>
</Table.Row>
);
})}
</Table>
</Section>
);
};
const HighlightedMarker = (props, context) => {
const { color, ...rest } = props;
return (
<NanoMap.Marker {...rest}>
<span class={`highlighted-marker color-border-${color}`} />
</NanoMap.Marker>
);
};
const CrewMonitorMapView = (_properties, context) => {
const { act, data } = useBackend(context);
const [zoom, setZoom] = useLocalState(context, 'zoom', 1);
const { highlightedNames } = data;
return (
<Box height="526px" mb="0.5rem" overflow="hidden">
<NanoMap onZoom={(v) => setZoom(v)}>
<NanoMap
zoom={data.zoom}
offsetX={data.offsetX}
offsetY={data.offsetY}
onZoom={(zoom) => act('set_zoom', { zoom })}
onOffsetChange={(e, state) =>
act('set_offset', {
offset_x: state.offsetX,
offset_y: state.offsetY,
})
}
>
{data.crewmembers
.filter((x) => x.sensor_type === 3 || data.ignoreSensors)
.map((cm) => (
<NanoMap.Marker
key={cm.ref}
x={cm.x}
y={cm.y}
zoom={zoom}
icon="circle"
tooltip={cm.name + ' (' + cm.assignment + ')'}
color={getStatColor(cm, data.critThreshold)}
onClick={() =>
data.isObserver
? act('track', {
track: cm.ref,
})
: null
}
/>
))}
.map((cm) => {
const color = getStatColor(cm, data.critThreshold);
const highlighted = highlightedNames.includes(cm.name);
const onClick = () =>
data.isObserver
? act('track', {
track: cm.ref,
})
: null;
const onDblClick = () =>
act(highlighted ? 'remove_highlighted_name' : 'add_highlighted_name', { name: cm.name });
const tooltip = cm.name + ' (' + cm.assignment + ')';
if (highlighted) {
return (
<HighlightedMarker
key={cm.ref}
x={cm.x}
y={cm.y}
tooltip={tooltip}
color={color}
onClick={onClick}
onDblClick={onDblClick}
/>
);
} else {
return (
<NanoMap.MarkerIcon
key={cm.ref}
x={cm.x}
y={cm.y}
icon="circle"
tooltip={tooltip}
color={color}
onClick={onClick}
onDblClick={onDblClick}
/>
);
}
})}
</NanoMap>
</Box>
);
@@ -19,3 +19,9 @@ $bg-map: colors.$bg-map !default;
background-color: $color-value !important;
}
}
@each $color-name, $color-value in $fg-map {
.color-border-#{$color-name} {
border-color: $color-value !important;
}
}
@@ -3,6 +3,7 @@ $background-color: rgba(0, 0, 0, 0.33) !default;
.NanoMap__container {
overflow: hidden;
width: 100%;
height: 100%;
z-index: 1;
}
@@ -19,5 +20,5 @@ $background-color: rgba(0, 0, 0, 0.33) !default;
top: 30px;
left: 0;
padding: 0.5rem;
width: 20%;
width: 24%;
}
@@ -0,0 +1,26 @@
.highlighted-marker {
box-sizing: content-box;
display: inline-block;
position: relative;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-style: solid;
border-width: 50%;
border-radius: 50%;
animation: 1s infinite mark-shrink;
}
@keyframes mark-shrink {
from {
width: 200%;
height: 200%;
}
to {
width: 0%;
height: 0%;
}
}
+1
View File
@@ -50,6 +50,7 @@
@include meta.load-css('./interfaces/BrigCells.scss');
@include meta.load-css('./interfaces/CameraConsole.scss');
@include meta.load-css('./interfaces/Contractor.scss');
@include meta.load-css('./interfaces/CrewMonitor.scss');
@include meta.load-css('./interfaces/ExosuitFabricator.scss');
@include meta.load-css('./interfaces/GeneModder.scss');
@include meta.load-css('./interfaces/KitchenMachine.scss');
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long