diff --git a/src/components/map/edit/AreaSettingsDialog.tsx b/src/components/map/edit/AreaSettingsDialog.tsx index 67a0b42..bf11352 100644 --- a/src/components/map/edit/AreaSettingsDialog.tsx +++ b/src/components/map/edit/AreaSettingsDialog.tsx @@ -1,25 +1,45 @@ 'use client'; +import {TooltipTextField} from '@/components/ui/TooltipTextField'; import {displaySortKey, useMap, useMapboxDraw, useMapContext, useMapSelection} from '@/contexts/MapContext'; -import {AreaProps} from '@/stores/schemas'; +import {AreaProps, areaSchema} from '@/stores/schemas'; import MapboxDraw from '@mapbox/mapbox-gl-draw'; +import {ExpandMore as ExpandMoreIcon, InfoOutlined as InfoOutlinedIcon} from '@mui/icons-material'; import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, Button, + Chip, DialogActions, DialogContent, DialogTitle, FormControl, FormControlLabel, + IconButton, InputLabel, MenuItem, Select, Switch, TextField, + Tooltip, + tooltipClasses, + Typography, } from '@mui/material'; import {useEffect, useState} from 'react'; import {AsyncDialogProps} from 'react-dialog-async'; +import {z} from 'zod/v4'; import MapDialog from '../MapDialog'; +const RAD_TO_DEG = 180 / Math.PI; +const DEG_TO_RAD = Math.PI / 180; + +// Format a stored radian angle as a degrees string for the input (2 decimals, no trailing noise). +function radToDegString(rad: number): string { + return String(Math.round(rad * RAD_TO_DEG * 100) / 100); +} + export function AreaSettingsDialog({isOpen, handleClose}: AsyncDialogProps) { const map = useMap(); const draw = useMapboxDraw(); @@ -28,6 +48,13 @@ export function AreaSettingsDialog({isOpen, handleClose}: AsyncDialogProps) { const [name, setName] = useState(''); const [type, setType] = useState('draft'); const [active, setActive] = useState(true); + // Per-area mowing overrides — kept as strings so an empty field means "use the global default". + const [outlineCount, setOutlineCount] = useState(''); + const [outlineOverlapCount, setOutlineOverlapCount] = useState(''); + const [outlineOffset, setOutlineOffset] = useState(''); + const [angleDeg, setAngleDeg] = useState(''); + // Overrides are collapsed by default, expanded automatically when the area already has any set. + const [overridesExpanded, setOverridesExpanded] = useState(false); // Initialize form values when dialog opens or selected area changes useEffect(() => { @@ -37,20 +64,67 @@ export function AreaSettingsDialog({isOpen, handleClose}: AsyncDialogProps) { setName(properties.name ?? ''); setType(properties.type ?? 'draft'); setActive(properties.active ?? true); + setOutlineCount(properties.outline_count != null ? String(properties.outline_count) : ''); + setOutlineOverlapCount(properties.outline_overlap_count != null ? String(properties.outline_overlap_count) : ''); + setOutlineOffset(properties.outline_offset != null ? String(properties.outline_offset) : ''); + setAngleDeg(properties.angle != null ? radToDegString(properties.angle) : ''); + setOverridesExpanded( + properties.outline_count != null || + properties.outline_overlap_count != null || + properties.outline_offset != null || + properties.angle != null, + ); }, [draw, selectedIds]); + const overrideCount = [outlineCount, outlineOverlapCount, outlineOffset, angleDeg].filter( + (v) => v.trim() !== '', + ).length; + + const propsShape = areaSchema.shape.properties.shape; + const angleDegSchema = z.number().min(-180).max(180); + const validateField = ( + schema: {safeParse: (v: unknown) => {success: boolean; error?: {issues: {message: string}[]}}}, + raw: string, + ): string => { + if (raw.trim() === '') return ''; + const result = schema.safeParse(Number(raw)); + return result.success ? '' : (result.error?.issues[0].message ?? 'Invalid value'); + }; + const outlineCountError = validateField(propsShape.outline_count, outlineCount); + const outlineOverlapCountError = validateField(propsShape.outline_overlap_count, outlineOverlapCount); + const outlineOffsetError = validateField(propsShape.outline_offset, outlineOffset); + const angleDegError = validateField(angleDegSchema, angleDeg); + const hasErrors = !!(outlineCountError || outlineOverlapCountError || outlineOffsetError || angleDegError); + const handleSave = () => { if (!map || !draw || selectedIds.length === 0) return; const feature = draw.get(selectedIds[0])!; const index = features.features.findIndex((f) => f.id === feature.id); - feature.properties = { + const properties: Record = { ...feature.properties, name, type, active, sort_key: displaySortKey(index, type, features.features), }; + + // Per-area mowing overrides. An empty input (or a non-mowing area) removes the key entirely so + // ROS falls back to the global config default. map.json stores angle in radians. + const applyOverride = (key: string, raw: string, parse: (s: string) => number) => { + const value = type === 'mow' ? parse(raw.trim()) : NaN; + if (Number.isFinite(value)) { + properties[key] = value; + } else { + delete properties[key]; + } + }; + applyOverride('outline_count', outlineCount, (s) => parseInt(s, 10)); + applyOverride('outline_overlap_count', outlineOverlapCount, (s) => parseInt(s, 10)); + applyOverride('outline_offset', outlineOffset, (s) => parseFloat(s)); + applyOverride('angle', angleDeg, (s) => parseFloat(s) * DEG_TO_RAD); + + feature.properties = properties; draw.add(feature); map.fire(MapboxDraw.constants.events.UPDATE, {features: [feature]}); @@ -97,10 +171,145 @@ export function AreaSettingsDialog({isOpen, handleClose}: AsyncDialogProps) { label="Active" sx={{mt: 2}} /> + + {type === 'mow' && ( + setOverridesExpanded(expanded)} + disableGutters + sx={{ + mt: 2, + '&:before': {display: 'none'}, + borderRadius: '8px !important', + overflow: 'hidden', + border: '1px solid', + borderColor: 'divider', + boxShadow: 'none', + }} + > + } + sx={{bgcolor: 'background.paper', '&:hover': {bgcolor: 'action.hover'}, minHeight: 48}} + > + + + Mowing settings overrides + + {overrideCount > 0 && ( + + )} + + + e.stopPropagation()}> + + + + + + + + + + setOutlineCount(e.target.value)} + fullWidth + margin="normal" + placeholder="Global default" + inputProps={{min: 0, step: 1}} + slotProps={{inputLabel: {shrink: true}}} + sx={{ + '& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button': {display: 'none'}, + '& input[type=number]': {MozAppearance: 'textfield'}, + }} + error={!!outlineCountError} + helperText={outlineCountError} + tooltip="How many outlines should the mower drive. It's not recommended to set this below 4." + /> + setOutlineOverlapCount(e.target.value)} + fullWidth + margin="normal" + placeholder="Global default" + inputProps={{min: 0, step: 1}} + slotProps={{inputLabel: {shrink: true}}} + sx={{ + '& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button': {display: 'none'}, + '& input[type=number]': {MozAppearance: 'textfield'}, + }} + error={!!outlineOverlapCountError} + helperText={outlineOverlapCountError} + tooltip="Number of outlines to overlap." + /> + setOutlineOffset(e.target.value)} + fullWidth + margin="normal" + placeholder="Global default" + inputProps={{step: 0.01}} + slotProps={{inputLabel: {shrink: true}}} + sx={{ + '& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button': {display: 'none'}, + '& input[type=number]': {MozAppearance: 'textfield'}, + }} + error={!!outlineOffsetError} + helperText={outlineOffsetError} + tooltip="Offset applied to the outline. Positive values move it inwards (i.e. safety margin)." + /> + setAngleDeg(e.target.value)} + fullWidth + margin="normal" + placeholder="Auto-detect" + inputProps={{min: -180, max: 180, step: 'any'}} + slotProps={{inputLabel: {shrink: true}}} + sx={{ + '& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button': {display: 'none'}, + '& input[type=number]': {MozAppearance: 'textfield'}, + }} + error={!!angleDegError} + helperText={angleDegError} + tooltip="Fixed mowing direction (0° = east). Empty = auto-detect from the first 2 m of the outline." + /> + + + + )} - diff --git a/src/components/ui/TooltipTextField.tsx b/src/components/ui/TooltipTextField.tsx new file mode 100644 index 0000000..1c49c4e --- /dev/null +++ b/src/components/ui/TooltipTextField.tsx @@ -0,0 +1,59 @@ +import {InfoOutlined as InfoOutlinedIcon} from '@mui/icons-material'; +import {IconButton, InputAdornment, TextField, Tooltip, tooltipClasses, type TextFieldProps} from '@mui/material'; + +type TooltipTextFieldProps = TextFieldProps & { + tooltip: string; +}; + +export function TooltipTextField({tooltip, slotProps, ...props}: TooltipTextFieldProps) { + const infoAdornment = ( + + + + + + + + ); + + const blockArrowKeys = + props.type === 'number' + ? (e: React.KeyboardEvent) => { + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') e.preventDefault(); + } + : undefined; + + return ( + + ); +} diff --git a/src/stores/schemas.ts b/src/stores/schemas.ts index aa43abf..e7efa46 100644 --- a/src/stores/schemas.ts +++ b/src/stores/schemas.ts @@ -61,12 +61,18 @@ export type Datum = z.infer; const pointSchema = z.object({x: z.number(), y: z.number()}); const polygonSchema = z.array(pointSchema); -const areaSchema = z.object({ +export const areaSchema = z.object({ id: z.string(), properties: z.looseObject({ name: z.string().optional(), type: z.enum(['mow', 'nav', 'obstacle', 'draft']).default('draft'), active: z.boolean().default(true), + // Per-area overrides for mowing areas. When a field is omitted, ROS falls back to the + // corresponding global config default (see open_mower_ros MowingBehavior::overrideOrGlobal). + angle: z.number().optional(), // radians; replaces the auto-detected mow orientation + outline_count: z.int().gte(0).optional(), + outline_overlap_count: z.int().gte(0).optional(), + outline_offset: z.number().optional(), // meters }), outline: polygonSchema, });