feat(ui): ObstacleImagesCapability

This commit is contained in:
Sören Beye 2024-09-05 17:46:26 +02:00
parent f9fa9d0b60
commit 30b5fc8a3d
16 changed files with 516 additions and 69 deletions

View File

@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
shape-rendering="crispEdges"
version="1.1"
viewBox="0 -0.5 50 48"
id="svg3"
sodipodi:docname="confused_valetudog.svg"
width="50"
height="48"
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs3" />
<sodipodi:namedview
id="namedview3"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="true"
inkscape:deskcolor="#d1d1d1"
showguides="true"
inkscape:zoom="8.7159622"
inkscape:cx="14.857797"
inkscape:cy="30.117157"
inkscape:window-width="3840"
inkscape:window-height="1537"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg3">
<sodipodi:guide
position="21,21.872356"
orientation="-1,0"
id="guide3"
inkscape:locked="false"
inkscape:label=""
inkscape:color="rgb(0,134,229)" />
</sodipodi:namedview>
<g
id="g1">
<g
id="g3"
transform="translate(-25,-3)">
<path
d="m 31,20 h 32 m -32,1 h 1 m 30,0 h 1 m -32,1 h 1 m 30,0 h 2 m -37,1 h 5 m 31,0 h 2 m -39,1 h 2 m 36,0 h 2 m -40,1 h 1 m 38,0 h 2 m -41,1 h 1 m 39,0 h 2 m -42,1 h 2 m 39,0 h 4 m -44,1 h 2 m 41,0 h 1 m -44,1 h 1 m 42,0 h 1 m -44,1 h 1 m 42,0 h 1 m 1,0 h 3 m -48,1 h 1 m 42,0 h 3 m 1,0 h 1 m -48,1 h 1 m 42,0 h 2 m 1,0 h 2 m -48,1 h 1 m 42,0 h 1 m 1,0 h 2 m -47,1 h 1 m 43,0 h 2 m -46,1 h 1 m 42,0 h 2 m -45,1 h 1 m 42,0 h 1 m -44,1 h 1 m 42,0 h 1 m -44,1 h 1 m 42,0 h 1 m -44,1 h 1 m 42,0 h 1 m -44,1 h 1 m 42,0 h 1 m -44,1 h 1 m 42,0 h 1 m -44,1 h 1 m 42,0 h 1 m -44,1 h 1 m 42,0 h 1 m -44,1 h 1 m 42,0 h 1 m -44,1 h 1 m 42,0 h 1 m -46,1 h 3 m 42,0 h 3 m -48,1 h 1 m 46,0 h 1 m -48,1 h 1 m 5,0 h 36 m 5,0 h 1 m -48,1 h 1 m 5,0 h 1 m 34,0 h 1 m 5,0 h 1 m -48,1 h 7 m 34,0 h 7"
stroke="#ffffff"
id="path1" />
<path
d="m 32,21 h 30 m -30,1 h 1 m 28,0 h 1 m -30,1 h 1 m 12,0 h 1 m 16,0 h 1 m -35,1 h 5 m 30,0 h 1 m -37,1 h 1 m 36,0 h 1 m -38,1 h 18 m 20,0 h 1 m -38,1 h 1 m 37,0 h 1 m 0,1 h 3 m -42,1 h 1 m 40,0 h 1 m -42,1 h 1 m 40,0 h 1 m -42,1 h 1 m 40,0 h 1 m 3,0 h 1 m -46,1 h 1 m 40,0 h 1 m 2,0 h 1 m -45,1 h 1 m 40,0 h 1 m 1,0 h 1 m -44,1 h 1 m 40,0 h 2 m -43,1 h 1 m 40,0 h 1 m -42,1 h 1 m 40,0 h 1 m -42,1 h 1 m 40,0 h 1 m -42,1 h 1 m 40,0 h 1 m -42,1 h 1 m 40,0 h 1 m -42,1 h 1 m 40,0 h 1 m -42,1 h 1 m 40,0 h 1 m -42,1 h 1 m 40,0 h 1 m -42,1 h 1 m 40,0 h 1 m -42,1 h 1 m 40,0 h 1 m -42,1 h 1 m 40,0 h 1 m -42,1 h 1 m 40,0 h 1 m -44,1 h 3 m 1,0 h 38 m 1,0 h 3 m -46,1 h 1 m 3,0 h 1 m 36,0 h 1 m 3,0 h 1 m -46,1 h 5 m 36,0 h 5"
stroke="#000000"
id="path2" />
<path
d="m 33,22 h 28 m -28,1 h 12 m 1,0 h 16 m -29,1 h 30 m -35,1 h 36 m -19,1 h 20 m -36,1 h 37 m -37,1 h 38 m -38,1 h 40 m -40,1 h 40 m -40,1 h 40 m -40,1 h 40 m -40,1 h 40 m -40,1 h 40 m -40,1 h 40 m -40,1 h 40 m -40,1 h 40 m -40,1 h 40 m -40,1 h 40 m -40,1 h 40 m -40,1 h 40 m -40,1 h 40 m -40,1 h 40 m -40,1 h 40 m -40,1 h 40 m -40,1 h 40 m -40,1 h 1 m 38,0 h 1 m -42,1 h 3 m 38,0 h 3"
stroke="#da7446"
id="path3" />
</g>
<g
id="g4"
transform="translate(-10.5,-9)">
<path
stroke="#ffffff"
d="m 27,9 h 8 m -9,1 h 2 m 6,0 h 2 m -10,1 h 1 m 8,0 h 1 m -10,1 h 1 m 2,0 h 4 m 2,0 h 1 m -10,1 h 1 m 2,0 h 1 m 1,0 h 2 m 2,0 h 1 m -10,1 h 6 m 3,0 h 1 m -7,1 h 2 m 3,0 h 2 m -7,1 h 1 m 3,0 h 2 m -6,1 h 1 m 2,0 h 2 m -5,1 h 1 m 2,0 h 1 m -4,1 h 4 m -4,1 h 1 m 2,0 h 1 m -4,1 h 1 m 2,0 h 1 m -4,1 h 4"
id="path1-5" />
<path
stroke="#000000"
d="m 28,10 h 6 m -7,1 h 8 m -8,1 h 2 m 4,0 h 2 m -8,1 h 2 m 4,0 h 2 m -3,1 h 3 m -4,1 h 3 m -4,1 h 3 m -3,1 h 2 m -2,1 h 2 m -2,2 h 2 m -2,1 h 2"
id="path2-0" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -18,6 +18,7 @@ export interface RawMapEntity {
export interface RawMapEntityMetaData {
angle?: number;
label?: string;
id?: string;
}
export interface RawMapLayer {

View File

@ -3,7 +3,8 @@ import { RawMapData } from "./RawMapData";
import {PresetSelectionState, PresetValue, RobotAttribute} from "./RawRobotState";
import {
AutoEmptyDockAutoEmptyInterval,
AutoEmptyDockAutoEmptyIntervalPayload, AutoEmptyDockAutoEmptyIntervalProperties,
AutoEmptyDockAutoEmptyIntervalPayload,
AutoEmptyDockAutoEmptyIntervalProperties,
Capability,
CarpetSensorMode,
CarpetSensorModeControlProperties,
@ -30,6 +31,7 @@ import {
NetworkAdvertisementProperties,
NTPClientConfiguration,
NTPClientStatus,
ObstacleImagesProperties,
Point,
Quirk,
RobotInformation,
@ -66,8 +68,9 @@ import { floorObject } from "./utils";
import {preprocessMap} from "./mapUtils";
import ReconnectingEventSource from "reconnecting-eventsource";
export const valetudoAPIBaseURL = "./api/v2";
export const valetudoAPI = axios.create({
baseURL: "./api/v2",
baseURL: valetudoAPIBaseURL,
});
let currentCommitId = "unknown";
@ -1120,3 +1123,23 @@ export const fetchAutoEmptyDockAutoEmptyIntervalProperties = async (): Promise<A
return data;
});
};
export const fetchObstacleImagesState = async (): Promise<SimpleToggleState> => {
return valetudoAPI
.get<SimpleToggleState>(`/robot/capabilities/${Capability.ObstacleImages}`)
.then(({ data }) => {
return data;
});
};
export const sendObstacleImagesState = async (enable: boolean): Promise<void> => {
await sendToggleMutation(Capability.ObstacleImages, enable);
};
export const fetchObstacleImagesProperties = async (): Promise<ObstacleImagesProperties> => {
return valetudoAPI
.get<ObstacleImagesProperties>(`/robot/capabilities/${Capability.ObstacleImages}/properties`)
.then(({ data }) => {
return data;
});
};

View File

@ -2,11 +2,12 @@
import { useSnackbar } from "notistack";
import React from "react";
import {
QueryClient,
useMutation,
UseMutationOptions,
useQuery,
useQueryClient,
UseQueryResult,
UseQueryResult
} from "@tanstack/react-query";
import {
BasicControlCommand,
@ -118,6 +119,9 @@ import {
fetchAutoEmptyDockAutoEmptyInterval,
sendAutoEmptyDockAutoEmptyInterval,
fetchAutoEmptyDockAutoEmptyIntervalProperties,
fetchObstacleImagesProperties,
fetchObstacleImagesState,
sendObstacleImagesState,
} from "./client";
import {
PresetSelectionState,
@ -213,6 +217,8 @@ enum QueryKey {
CollisionAvoidantNavigation = "collision_avoidant_navigation",
CarpetSensorMode = "carpet_sensor_mode",
CarpetSensorModeProperties = "carpet_sensor_mode_properties",
ObstacleImages = "obstacle_image",
ObstacleImagesProperties = "obstacle_image_properties"
}
const useOnCommandError = (capability: Capability | string): ((error: unknown) => void) => {
@ -1470,3 +1476,43 @@ export const useAutoEmptyDockAutoEmptyIntervalPropertiesQuery = () => {
staleTime: Infinity
});
};
export const useObstacleImagesQuery = () => {
return useQuery( {
queryKey: [QueryKey.ObstacleImages],
queryFn: fetchObstacleImagesState,
staleTime: Infinity
});
};
export const useObstacleImagesMutation = () => {
return useValetudoFetchingMutation({
queryKey: [QueryKey.ObstacleImages],
mutationFn: (enable: boolean) => {
return sendObstacleImagesState(enable).then(fetchObstacleImagesState);
},
onError: useOnCommandError(Capability.ObstacleImages)
});
};
export const useObstacleImagesPropertiesQuery = () => {
return useQuery( {
queryKey: [QueryKey.ObstacleImagesProperties],
queryFn: fetchObstacleImagesProperties,
staleTime: Infinity,
});
};
export const prefetchObstacleImagesProperties = async (queryClient : QueryClient) => {
const queryKey = [QueryKey.ObstacleImagesProperties];
if (!queryClient.getQueryData(queryKey)) {
return queryClient.prefetchQuery({
queryKey: [QueryKey.ObstacleImagesProperties],
queryFn: fetchObstacleImagesProperties,
});
}
};

View File

@ -38,6 +38,7 @@ export enum Capability {
WifiScan = "WifiScanCapability",
ZoneCleaning = "ZoneCleaningCapability",
Quirks = "QuirksCapability",
ObstacleImages = "ObstacleImagesCapability"
}
export type Point = {
@ -574,3 +575,10 @@ export interface AutoEmptyDockAutoEmptyIntervalPayload {
export interface AutoEmptyDockAutoEmptyIntervalProperties {
supportedIntervals: Array<AutoEmptyDockAutoEmptyInterval>
}
export interface ObstacleImagesProperties {
dimensions: {
width: number,
height: number
}
}

View File

@ -0,0 +1,107 @@
import React from "react";
import {Skeleton} from "@mui/material";
import {Capability, useObstacleImagesPropertiesQuery, valetudoAPIBaseURL} from "../api";
import {useCapabilitiesSupported} from "../CapabilitiesProvider";
function getScaledConfusedPlaceholderDog(newWidth: number, newHeight: number) {
const oldWidth = 56;
const oldHeight = 50;
const viewBoxWidth = newWidth / 4;
const viewBoxHeight = newHeight / 4;
const viewBoxX = (viewBoxWidth - oldWidth) / 2;
const viewBoxY = (viewBoxHeight - oldHeight) / 2;
const svgDog = (`
<svg width="${newWidth}" height="${newHeight}" viewBox="-${viewBoxX} -${viewBoxY} ${viewBoxWidth} ${viewBoxHeight}" shape-rendering="crispEdges" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="filter6" x="0" y="-.010638" width="1" height="1.0213" color-interpolation-filters="sRGB">
<feColorMatrix values="0.21 0.72 0.072 0 0 0.21 0.72 0.072 0 0 0.21 0.72 0.072 0 0 0 0 0 1 0 "/>
</filter>
</defs>
<g transform="translate(5,1)" filter="url(#filter6)" opacity=".1">
<g transform="translate(-25,-3)">
<path d="m31 20h32m-32 1h1m30 0h1m-32 1h1m30 0h2m-37 1h5m31 0h2m-39 1h2m36 0h2m-40 1h1m38 0h2m-41 1h1m39 0h2m-42 1h2m39 0h4m-44 1h2m41 0h1m-44 1h1m42 0h1m-44 1h1m42 0h1m1 0h3m-48 1h1m42 0h3m1 0h1m-48 1h1m42 0h2m1 0h2m-48 1h1m42 0h1m1 0h2m-47 1h1m43 0h2m-46 1h1m42 0h2m-45 1h1m42 0h1m-44 1h1m42 0h1m-44 1h1m42 0h1m-44 1h1m42 0h1m-44 1h1m42 0h1m-44 1h1m42 0h1m-44 1h1m42 0h1m-44 1h1m42 0h1m-44 1h1m42 0h1m-44 1h1m42 0h1m-46 1h3m42 0h3m-48 1h1m46 0h1m-48 1h1m5 0h36m5 0h1m-48 1h1m5 0h1m34 0h1m5 0h1m-48 1h7m34 0h7" stroke="#fff"/>
<path d="m32 21h30m-30 1h1m28 0h1m-30 1h1m12 0h1m16 0h1m-35 1h5m30 0h1m-37 1h1m36 0h1m-38 1h18m20 0h1m-38 1h1m37 0h1m0 1h3m-42 1h1m40 0h1m-42 1h1m40 0h1m-42 1h1m40 0h1m3 0h1m-46 1h1m40 0h1m2 0h1m-45 1h1m40 0h1m1 0h1m-44 1h1m40 0h2m-43 1h1m40 0h1m-42 1h1m40 0h1m-42 1h1m40 0h1m-42 1h1m40 0h1m-42 1h1m40 0h1m-42 1h1m40 0h1m-42 1h1m40 0h1m-42 1h1m40 0h1m-42 1h1m40 0h1m-42 1h1m40 0h1m-42 1h1m40 0h1m-42 1h1m40 0h1m-44 1h3m1 0h38m1 0h3m-46 1h1m3 0h1m36 0h1m3 0h1m-46 1h5m36 0h5" stroke="#000"/>
<path d="m33 22h28m-28 1h12m1 0h16m-29 1h30m-35 1h36m-19 1h20m-36 1h37m-37 1h38m-38 1h40m-40 1h40m-40 1h40m-40 1h40m-40 1h40m-40 1h40m-40 1h40m-40 1h40m-40 1h40m-40 1h40m-40 1h40m-40 1h40m-40 1h40m-40 1h40m-40 1h40m-40 1h40m-40 1h40m-40 1h40m-40 1h1m38 0h1m-42 1h3m38 0h3" stroke="#da7446"/>
</g>
<g transform="translate(-10.5,-9)">
<path d="m27 9h8m-9 1h2m6 0h2m-10 1h1m8 0h1m-10 1h1m2 0h4m2 0h1m-10 1h1m2 0h1m1 0h2m2 0h1m-10 1h6m3 0h1m-7 1h2m3 0h2m-7 1h1m3 0h2m-6 1h1m2 0h2m-5 1h1m2 0h1m-4 1h4m-4 1h1m2 0h1m-4 1h1m2 0h1m-4 1h4" stroke="#fff"/>
<path d="m28 10h6m-7 1h8m-8 1h2m4 0h2m-8 1h2m4 0h2m-3 1h3m-4 1h3m-4 1h3m-3 1h2m-2 1h2m-2 2h2m-2 1h2" stroke="#000"/>
</g>
</g>
</svg>
`);
return `data:image/svg+xml,${encodeURIComponent(svgDog)}`;
}
const ActualObstacleImage = (props: { id: string }): React.ReactElement => {
const [imageLoadFailed, setImageLoadFailed] = React.useState(false);
const [imageLoaded, setImageLoaded] = React.useState(false);
const [imageSrc, setImageSrc] = React.useState(`${valetudoAPIBaseURL}/robot/capabilities/${Capability.ObstacleImages}/img/${props.id}`);
const {
data: obstacleImagesCapabilityProperties,
} = useObstacleImagesPropertiesQuery();
// Since the LiveMapPage prefetches the properties, this should never be displayed
if (!obstacleImagesCapabilityProperties) {
return (
<div style={{textAlign: "center"}}>
<Skeleton height={"3rem"} />
</div>
);
}
const style: Record<string, any> = {
maxWidth: "100%",
maxHeight: "85%",
height: "auto",
borderRadius: "4px",
display: "block",
objectFit: "contain",
border: !imageLoaded ? "1px inset black" : undefined, // Imitate the style browsers use for a broken image
};
return (
<img
style={style}
src={imageSrc}
width={obstacleImagesCapabilityProperties.dimensions.width}
height={obstacleImagesCapabilityProperties.dimensions.height}
onLoad={() => {
if (!imageLoadFailed) {
setImageLoaded(true);
}
}}
onError={() => {
if (!imageLoadFailed) {
setImageLoadFailed(true);
setImageSrc(getScaledConfusedPlaceholderDog(
obstacleImagesCapabilityProperties.dimensions.width,
obstacleImagesCapabilityProperties.dimensions.height
));
}
}}
/>
);
};
const ObstacleImage = (props: { id: string}): React.ReactElement => {
const [
obstacleImagesSupported,
] = useCapabilitiesSupported(
Capability.ObstacleImages,
);
if (!obstacleImagesSupported) {
return <></>;
} else {
return <ActualObstacleImage id={props.id}/>;
}
};
export default ObstacleImage;

View File

@ -52,6 +52,10 @@ class EditMap extends Map<EditMapProps, EditMapState> {
this.state = {
selectedSegmentIds: [],
dialogOpen: false,
dialogTitle: "Hello World",
dialogBody: "This should never be visible",
segmentNames: {},
cuttingLine: undefined,

View File

@ -10,6 +10,7 @@ import GoToActions from "./actions/live_map_actions/GoToActions";
import {TapTouchHandlerEvent} from "./utils/touch_handling/events/TapTouchHandlerEvent";
import React from "react";
import {LiveMapModeSwitcher} from "./LiveMapModeSwitcher";
import {Button, Dialog, DialogActions, DialogContent, DialogTitle} from "@mui/material";
export type LiveMapMode = "segments" | "zones" | "goto" | "none";
@ -62,6 +63,11 @@ class LiveMap extends Map<LiveMapProps, LiveMapState> {
this.state = {
mode: this.supportedModes[modeIdxToUse] ?? "none",
selectedSegmentIds: [],
dialogOpen: false,
dialogTitle: "Hello World",
dialogBody: "This should never be visible",
zones: [],
goToTarget: undefined
};
@ -291,6 +297,26 @@ class LiveMap extends Map<LiveMapProps, LiveMapState> {
/>
}
</ActionsContainer>
<Dialog
open={this.state.dialogOpen}
onClose={() =>{
this.setState({dialogOpen: false});
}}
>
<DialogTitle>
{this.state.dialogTitle}
</DialogTitle>
<DialogContent>
{this.state.dialogBody}
</DialogContent>
<DialogActions>
<Button onClick={() => {
this.setState({dialogOpen: false});
}} autoFocus>
Close
</Button>
</DialogActions>
</Dialog>
</MapContainer>
);
}

View File

@ -1,8 +1,9 @@
import {Box, Button, CircularProgress, styled, Typography, useTheme} from "@mui/material";
import {Capability, useMapSegmentationPropertiesQuery, useRobotMapQuery} from "../api";
import {Capability, prefetchObstacleImagesProperties, useMapSegmentationPropertiesQuery, useRobotMapQuery} from "../api";
import LiveMap from "./LiveMap";
import {useCapabilitiesSupported} from "../CapabilitiesProvider";
import React from "react";
import {useQueryClient} from "@tanstack/react-query";
const Container = styled(Box)({
@ -15,6 +16,7 @@ const Container = styled(Box)({
});
const LiveMapPage = (props: Record<string, never> ): React.ReactElement => {
const queryClient = useQueryClient();
const {
data: mapData,
isPending: mapIsPending,
@ -25,15 +27,27 @@ const LiveMapPage = (props: Record<string, never> ): React.ReactElement => {
const [
goToLocationCapabilitySupported,
mapSegmentationCapabilitySupported,
zoneCleaningCapabilitySupported
zoneCleaningCapabilitySupported,
obstacleImagesSupported,
] = useCapabilitiesSupported(
Capability.GoToLocation,
Capability.MapSegmentation,
Capability.ZoneCleaning,
Capability.Locate
Capability.ObstacleImages
);
// If the capability is supported, we prefetch the properties now, so that the image size
// is already available once the user opens a dialog
// => This prevents the content from jumping around
if (obstacleImagesSupported) {
prefetchObstacleImagesProperties(queryClient).catch(err => {
// eslint-disable-next-line no-console
console.error("Prefetching obstacle image properties failed", err);
});
}
const {
data: mapSegmentationProperties,
isPending: mapSegmentationPropertiesPending

View File

@ -27,7 +27,10 @@ export interface MapProps {
}
export interface MapState {
selectedSegmentIds: Array<string>
selectedSegmentIds: Array<string>,
dialogOpen: boolean,
dialogTitle: string,
dialogBody: string | React.ReactElement,
}
export const usePendingMapAction = create<{
@ -86,7 +89,10 @@ abstract class Map<P, S> extends React.Component<P & MapProps, S & MapState > {
this.mapLayerManager = new MapLayerManager();
this.state = {
selectedSegmentIds: [] as Array<string>
selectedSegmentIds: [] as Array<string>,
dialogOpen: false,
dialogTitle: "Hello World",
dialogBody: "This should never be visible",
} as Readonly<S & MapState>;
@ -371,6 +377,16 @@ abstract class Map<P, S> extends React.Component<P & MapProps, S & MapState > {
drawRequested = true;
}
if (result.openDialog) {
this.setState({
...this.state,
dialogOpen: true,
dialogTitle: result.openDialog.title,
dialogBody: result.openDialog.body
});
}
if (result.stopPropagation) {
if (result.deleteMe === true) {
this.structureManager.removeClientStructure(structure);
@ -391,6 +407,43 @@ abstract class Map<P, S> extends React.Component<P & MapProps, S & MapState > {
return true;
}
const mapStructuresHandledTap = this.structureManager.getMapStructures().some(structure => {
const result = structure.tap(tappedPointInScreenSpace, currentTransform);
if (result.requestDraw === true) {
drawRequested = true;
}
if (result.openDialog) {
this.setState({
...this.state,
dialogOpen: true,
dialogTitle: result.openDialog.title,
dialogBody: result.openDialog.body
});
}
if (result.stopPropagation) {
if (result.deleteMe === true) {
this.structureManager.removeMapStructure(structure);
}
this.updateState();
this.draw();
return true;
} else {
return false;
}
});
if (mapStructuresHandledTap) {
return true;
}
//only draw if any structure was changed
let didUpdateStructures = false;
this.structureManager.getClientStructures().forEach(s => {

View File

@ -21,6 +21,10 @@ class RobotCoverageMap extends Map<CleanupCoverageMapProps, CleanupCoverageMapSt
this.state = {
selectedSegmentIds: [],
dialogOpen: false,
dialogTitle: "Hello World",
dialogBody: "This should never be visible",
helpDialogOpen: false
};
}

View File

@ -72,7 +72,8 @@ class StructureManager {
mapStructures.push(new ObstacleMapStructure(
p0.x,
p0.y,
e.metaData.label
e.metaData.label,
e.metaData.id
));
break;
}

View File

@ -1,10 +1,15 @@
import {Canvas2DContextTrackingWrapper} from "../utils/Canvas2DContextTrackingWrapper";
import {PointCoordinates} from "../utils/types";
import React from "react";
export type StructureInterceptionHandlerResult = {
stopPropagation: boolean; //Will always redraw
deleteMe?: boolean;
requestDraw?: boolean; //Optional if things should be redrawn without stopping the event propagation
openDialog?: {
title: string;
body: string | React.ReactElement;
}
}
abstract class Structure {

View File

@ -1,60 +0,0 @@
import MapStructure from "./MapStructure";
import obstacleIconSVG from "../icons/obstacle.svg";
import {Canvas2DContextTrackingWrapper} from "../../utils/Canvas2DContextTrackingWrapper";
import {considerHiDPI} from "../../utils/helpers";
const img = new Image();
img.src = obstacleIconSVG;
class ObstacleMapStructure extends MapStructure {
public static TYPE = "ObstacleMapStructure";
private label: string | undefined;
constructor(x0: number, y0: number, label?: string) {
super(x0, y0);
this.label = label;
}
draw(ctxWrapper: Canvas2DContextTrackingWrapper, transformationMatrixToScreenSpace: DOMMatrixInit, scaleFactor: number): void {
const ctx = ctxWrapper.getContext();
const p0 = new DOMPoint(this.x0, this.y0).matrixTransform(transformationMatrixToScreenSpace);
const scaledSize = {
width: Math.max(considerHiDPI(img.width) / (considerHiDPI(8) / scaleFactor), considerHiDPI(img.width) * 0.3),
height: Math.max(considerHiDPI(img.height) / (considerHiDPI(8) / scaleFactor), considerHiDPI(img.height) * 0.3)
};
ctx.drawImage(
img,
p0.x - scaledSize.width / 2,
p0.y - scaledSize.height / 2,
scaledSize.width,
scaledSize.height
);
if (this.label && scaleFactor >= considerHiDPI(28)) {
ctxWrapper.save();
ctx.textAlign = "center";
ctx.font = `${considerHiDPI(32)}px sans-serif`;
ctx.fillStyle = "rgba(255, 255, 255, 1)";
ctx.strokeStyle = "rgba(18, 18, 18, 1)";
ctx.lineWidth = considerHiDPI(2.5);
ctx.strokeText(this.label, p0.x , p0.y + (scaledSize.height/2) + considerHiDPI(32));
ctx.lineWidth = considerHiDPI(1);
ctx.fillText(this.label, p0.x , p0.y + (scaledSize.height/2) + considerHiDPI(32));
ctxWrapper.restore();
}
}
getType(): string {
return ObstacleMapStructure.TYPE;
}
}
export default ObstacleMapStructure;

View File

@ -0,0 +1,102 @@
import MapStructure from "./MapStructure";
import obstacleIconSVG from "../icons/obstacle.svg";
import {Canvas2DContextTrackingWrapper} from "../../utils/Canvas2DContextTrackingWrapper";
import {calculateBoxAroundPoint, considerHiDPI, isInsideBox} from "../../utils/helpers";
import {PointCoordinates} from "../../utils/types";
import {StructureInterceptionHandlerResult} from "../Structure";
import ObstacleImage from "../../../components/ObstacleImage";
import {Typography} from "@mui/material";
const img = new Image();
img.src = obstacleIconSVG;
const hitboxPadding = 2.5;
class ObstacleMapStructure extends MapStructure {
public static TYPE = "ObstacleMapStructure";
private label: string | undefined;
private id: string | undefined;
private scaledIconSize: { width: number; height: number } = {width: 1, height: 1};
private lastScaleFactor: number = 1;
constructor(x0: number, y0: number, label?: string, id?: string) {
super(x0, y0);
this.label = label;
this.id = id;
}
draw(ctxWrapper: Canvas2DContextTrackingWrapper, transformationMatrixToScreenSpace: DOMMatrixInit, scaleFactor: number): void {
this.lastScaleFactor = scaleFactor;
const ctx = ctxWrapper.getContext();
const p0 = new DOMPoint(this.x0, this.y0).matrixTransform(transformationMatrixToScreenSpace);
this.scaledIconSize = {
width: Math.max(considerHiDPI(img.width) / (considerHiDPI(8) / scaleFactor), considerHiDPI(img.width) * 0.3),
height: Math.max(considerHiDPI(img.height) / (considerHiDPI(8) / scaleFactor), considerHiDPI(img.height) * 0.3)
};
ctx.drawImage(
img,
p0.x - this.scaledIconSize.width / 2,
p0.y - this.scaledIconSize.height / 2,
this.scaledIconSize.width,
this.scaledIconSize.height
);
if (this.label && scaleFactor >= considerHiDPI(28)) {
ctxWrapper.save();
ctx.textAlign = "center";
ctx.font = `${considerHiDPI(32)}px sans-serif`;
ctx.fillStyle = "rgba(255, 255, 255, 1)";
ctx.strokeStyle = "rgba(18, 18, 18, 1)";
ctx.lineWidth = considerHiDPI(2.5);
ctx.strokeText(this.label, p0.x , p0.y + (this.scaledIconSize.height/2) + considerHiDPI(32));
ctx.lineWidth = considerHiDPI(1);
ctx.fillText(this.label, p0.x , p0.y + (this.scaledIconSize.height/2) + considerHiDPI(32));
ctxWrapper.restore();
}
}
tap(tappedPoint : PointCoordinates, transformationMatrixToScreenSpace: DOMMatrixInit) : StructureInterceptionHandlerResult {
const p0 = new DOMPoint(this.x0, this.y0).matrixTransform(transformationMatrixToScreenSpace);
const iconHitbox = calculateBoxAroundPoint(p0, (this.scaledIconSize.width / 2) + hitboxPadding);
if (isInsideBox(tappedPoint, iconHitbox) && this.lastScaleFactor >= considerHiDPI(6)) {
return {
stopPropagation: true,
openDialog: {
title: "Obstacle Information",
body: (
<>
{
this.id &&
<ObstacleImage id={this.id} />
}
<Typography sx={{marginTop: this.id ? "0.5rem" : undefined}}>
{this.label}
</Typography>
</>
)
}
};
}
return {
stopPropagation: false
};
}
getType(): string {
return ObstacleMapStructure.TYPE;
}
}
export default ObstacleMapStructure;

View File

@ -22,6 +22,8 @@ import {
useLocateMutation,
useObstacleAvoidanceControlMutation,
useObstacleAvoidanceControlQuery,
useObstacleImagesMutation,
useObstacleImagesQuery,
usePetObstacleAvoidanceControlMutation,
usePetObstacleAvoidanceControlQuery,
} from "../api";
@ -36,6 +38,7 @@ import {
MiscellaneousServices as MiscIcon,
NotListedLocation as LocateIcon,
Pets as PetObstacleAvoidanceControlIcon,
Photo as ObstacleImagesIcon,
RoundaboutRight as CollisionAvoidantNavigationControlIcon,
Sensors as CarpetModeIcon,
Star as QuirksIcon,
@ -364,6 +367,32 @@ const PetObstacleAvoidanceControlCapabilitySwitchListMenuItem = () => {
);
};
const ObstacleImagesCapabilitySwitchListMenuItem = () => {
const {
data: data,
isFetching: isFetching,
isError: isError,
} = useObstacleImagesQuery();
const {mutate: mutate, isPending: isChanging} = useObstacleImagesMutation();
const loading = isFetching || isChanging;
const disabled = loading || isChanging || isError;
return (
<ToggleSwitchListMenuItem
value={data?.enabled ?? false}
setValue={(value) => {
mutate(value);
}}
disabled={disabled}
loadError={isError}
primaryLabel={"Obstacle Images"}
secondaryLabel={"Take pictures of all encountered obstacles."}
icon={<ObstacleImagesIcon/>}
/>
);
};
const CollisionAvoidantNavigationControlCapabilitySwitchListMenuItem = () => {
const {
data: data,
@ -397,6 +426,7 @@ const RobotOptions = (): React.ReactElement => {
obstacleAvoidanceControlCapabilitySupported,
petObstacleAvoidanceControlCapabilitySupported,
obstacleImagesSupported,
collisionAvoidantNavigationControlCapabilitySupported,
carpetModeControlCapabilitySupported,
carpetSensorModeControlCapabilitySupported,
@ -417,6 +447,7 @@ const RobotOptions = (): React.ReactElement => {
Capability.ObstacleAvoidanceControl,
Capability.PetObstacleAvoidanceControl,
Capability.ObstacleImages,
Capability.CollisionAvoidantNavigation,
Capability.CarpetModeControl,
Capability.CarpetSensorModeControl,
@ -462,6 +493,12 @@ const RobotOptions = (): React.ReactElement => {
);
}
if (obstacleImagesSupported) {
items.push(
<ObstacleImagesCapabilitySwitchListMenuItem key={"obstacleImages"}/>
);
}
if (collisionAvoidantNavigationControlCapabilitySupported) {
items.push(
<CollisionAvoidantNavigationControlCapabilitySwitchListMenuItem key={"collisionAvoidantNavigationControl"}/>
@ -483,6 +520,7 @@ const RobotOptions = (): React.ReactElement => {
}, [
obstacleAvoidanceControlCapabilitySupported,
petObstacleAvoidanceControlCapabilitySupported,
obstacleImagesSupported,
collisionAvoidantNavigationControlCapabilitySupported,
carpetModeControlCapabilitySupported,
carpetSensorModeControlCapabilitySupported