From 5546f0355619956c8f3f908883480760094bebf7 Mon Sep 17 00:00:00 2001 From: Reko Peltola <4742341+rpeltola@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:41:08 +0300 Subject: [PATCH 1/4] Add per-area mowing overrides to area settings Add optional per-area overrides for outline_count, outline_overlap_count, outline_offset and angle, shown in a collapsible "Advanced" section in the area settings dialog (auto-expanded when the area already has overrides set, with a badge showing how many are set). Empty fields fall back to the global config defaults by omitting the key from map.json, matching ROS's overrideOrGlobal sentinels. Angle is entered in degrees and stored as radians to match ROS (mower_map MapArea.msg). --- .../map/edit/AreaSettingsDialog.tsx | 143 +++++++++++++++++- src/stores/schemas.ts | 6 + 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/src/components/map/edit/AreaSettingsDialog.tsx b/src/components/map/edit/AreaSettingsDialog.tsx index 67a0b42..ba2b816 100644 --- a/src/components/map/edit/AreaSettingsDialog.tsx +++ b/src/components/map/edit/AreaSettingsDialog.tsx @@ -3,8 +3,14 @@ import {displaySortKey, useMap, useMapboxDraw, useMapContext, useMapSelection} from '@/contexts/MapContext'; import {AreaProps} from '@/stores/schemas'; import MapboxDraw from '@mapbox/mapbox-gl-draw'; +import {ExpandMore as ExpandMoreIcon} from '@mui/icons-material'; import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, Button, + Chip, DialogActions, DialogContent, DialogTitle, @@ -15,11 +21,20 @@ import { Select, Switch, TextField, + Typography, } from '@mui/material'; import {useEffect, useState} from 'react'; import {AsyncDialogProps} from 'react-dialog-async'; 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 +43,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(''); + // Advanced overrides are collapsed by default, expanded automatically when the area already has any set. + const [advancedExpanded, setAdvancedExpanded] = useState(false); // Initialize form values when dialog opens or selected area changes useEffect(() => { @@ -37,20 +59,51 @@ 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) : ''); + setAdvancedExpanded( + 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 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,6 +150,94 @@ export function AreaSettingsDialog({isOpen, handleClose}: AsyncDialogProps) { label="Active" sx={{mt: 2}} /> + + {type === 'mow' && ( + setAdvancedExpanded(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}} + > + + + Advanced + + {overrideCount > 0 && ( + + )} + + + + + Per-area overrides for the global mowing settings. Leave empty to use the global default. + + setOutlineCount(e.target.value)} + fullWidth + margin="normal" + variant="outlined" + placeholder="Global default" + inputProps={{min: 0, step: 1}} + slotProps={{inputLabel: {shrink: true}}} + helperText="How many outlines should the mower drive. It's not recommended to set this below 4." + /> + setOutlineOverlapCount(e.target.value)} + fullWidth + margin="normal" + variant="outlined" + placeholder="Global default" + inputProps={{min: 0, step: 1}} + slotProps={{inputLabel: {shrink: true}}} + helperText="Number of outlines to overlap." + /> + setOutlineOffset(e.target.value)} + fullWidth + margin="normal" + variant="outlined" + placeholder="Global default" + inputProps={{step: 'any'}} + slotProps={{inputLabel: {shrink: true}}} + helperText="Offset applied to the outline. Positive values move it inwards (i.e. safety margin)." + /> + setAngleDeg(e.target.value)} + fullWidth + margin="normal" + variant="outlined" + placeholder="Auto-detect" + inputProps={{min: -180, max: 180, step: 'any'}} + slotProps={{inputLabel: {shrink: true}}} + helperText="Fixed mowing direction (0° = east). Empty = auto-detect from the first 2 m of the outline." + /> + + + )} diff --git a/src/stores/schemas.ts b/src/stores/schemas.ts index aa43abf..a34333a 100644 --- a/src/stores/schemas.ts +++ b/src/stores/schemas.ts @@ -67,6 +67,12 @@ const areaSchema = z.object({ 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, }); From e299530cf34794e73ec20d5601270f08ba8affc0 Mon Sep 17 00:00:00 2001 From: Robert Vollmer Date: Thu, 2 Jul 2026 22:05:57 +0000 Subject: [PATCH 2/4] Some visual tweaks for area overrides --- .../map/edit/AreaSettingsDialog.tsx | 167 +++++++++++------- src/components/ui/TooltipTextField.tsx | 59 +++++++ 2 files changed, 163 insertions(+), 63 deletions(-) create mode 100644 src/components/ui/TooltipTextField.tsx diff --git a/src/components/map/edit/AreaSettingsDialog.tsx b/src/components/map/edit/AreaSettingsDialog.tsx index ba2b816..83362bb 100644 --- a/src/components/map/edit/AreaSettingsDialog.tsx +++ b/src/components/map/edit/AreaSettingsDialog.tsx @@ -1,9 +1,10 @@ 'use client'; +import {TooltipTextField} from '@/components/ui/TooltipTextField'; import {displaySortKey, useMap, useMapboxDraw, useMapContext, useMapSelection} from '@/contexts/MapContext'; import {AreaProps} from '@/stores/schemas'; import MapboxDraw from '@mapbox/mapbox-gl-draw'; -import {ExpandMore as ExpandMoreIcon} from '@mui/icons-material'; +import {ExpandMore as ExpandMoreIcon, InfoOutlined as InfoOutlinedIcon} from '@mui/icons-material'; import { Accordion, AccordionDetails, @@ -16,11 +17,14 @@ import { DialogTitle, FormControl, FormControlLabel, + IconButton, InputLabel, MenuItem, Select, Switch, TextField, + Tooltip, + tooltipClasses, Typography, } from '@mui/material'; import {useEffect, useState} from 'react'; @@ -48,8 +52,8 @@ export function AreaSettingsDialog({isOpen, handleClose}: AsyncDialogProps) { const [outlineOverlapCount, setOutlineOverlapCount] = useState(''); const [outlineOffset, setOutlineOffset] = useState(''); const [angleDeg, setAngleDeg] = useState(''); - // Advanced overrides are collapsed by default, expanded automatically when the area already has any set. - const [advancedExpanded, setAdvancedExpanded] = useState(false); + // 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(() => { @@ -63,7 +67,7 @@ export function AreaSettingsDialog({isOpen, handleClose}: AsyncDialogProps) { 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) : ''); - setAdvancedExpanded( + setOverridesExpanded( properties.outline_count != null || properties.outline_overlap_count != null || properties.outline_offset != null || @@ -153,8 +157,8 @@ export function AreaSettingsDialog({isOpen, handleClose}: AsyncDialogProps) { {type === 'mow' && ( setAdvancedExpanded(expanded)} + expanded={overridesExpanded} + onChange={(_, expanded) => setOverridesExpanded(expanded)} disableGutters sx={{ mt: 2, @@ -170,71 +174,108 @@ export function AreaSettingsDialog({isOpen, handleClose}: AsyncDialogProps) { expandIcon={} sx={{bgcolor: 'background.paper', '&:hover': {bgcolor: 'action.hover'}, minHeight: 48}} > - + - Advanced + Overrides {overrideCount > 0 && ( )} + + + e.stopPropagation()}> + + + - - Per-area overrides for the global mowing settings. Leave empty to use the global default. - - setOutlineCount(e.target.value)} - fullWidth - margin="normal" - variant="outlined" - placeholder="Global default" - inputProps={{min: 0, step: 1}} - slotProps={{inputLabel: {shrink: true}}} - helperText="How many outlines should the mower drive. It's not recommended to set this below 4." - /> - setOutlineOverlapCount(e.target.value)} - fullWidth - margin="normal" - variant="outlined" - placeholder="Global default" - inputProps={{min: 0, step: 1}} - slotProps={{inputLabel: {shrink: true}}} - helperText="Number of outlines to overlap." - /> - setOutlineOffset(e.target.value)} - fullWidth - margin="normal" - variant="outlined" - placeholder="Global default" - inputProps={{step: 'any'}} - slotProps={{inputLabel: {shrink: true}}} - helperText="Offset applied to the outline. Positive values move it inwards (i.e. safety margin)." - /> - setAngleDeg(e.target.value)} - fullWidth - margin="normal" - variant="outlined" - placeholder="Auto-detect" - inputProps={{min: -180, max: 180, step: 'any'}} - slotProps={{inputLabel: {shrink: true}}} - helperText="Fixed mowing direction (0° = east). Empty = auto-detect from the first 2 m of the outline." - /> + + 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'}, + }} + 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'}, + }} + 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'}, + }} + 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'}, + }} + 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..3e431a8 --- /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 ( + )?.input, + }, + }} + /> + ); +} From cd6700b68ff0dbdd068b9c0762b239aec6ca41df Mon Sep 17 00:00:00 2001 From: Robert Vollmer Date: Thu, 2 Jul 2026 22:21:22 +0000 Subject: [PATCH 3/4] Validation and some further tweaks --- .../map/edit/AreaSettingsDialog.tsx | 39 ++++++++++++++++--- src/stores/schemas.ts | 2 +- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/components/map/edit/AreaSettingsDialog.tsx b/src/components/map/edit/AreaSettingsDialog.tsx index 83362bb..bf11352 100644 --- a/src/components/map/edit/AreaSettingsDialog.tsx +++ b/src/components/map/edit/AreaSettingsDialog.tsx @@ -2,7 +2,7 @@ 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 { @@ -29,6 +29,7 @@ import { } 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; @@ -79,6 +80,22 @@ export function AreaSettingsDialog({isOpen, handleClose}: AsyncDialogProps) { (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; @@ -176,7 +193,7 @@ export function AreaSettingsDialog({isOpen, handleClose}: AsyncDialogProps) { > - Overrides + Mowing settings overrides {overrideCount > 0 && ( @@ -203,9 +220,11 @@ export function AreaSettingsDialog({isOpen, handleClose}: AsyncDialogProps) { }} arrow > - e.stopPropagation()}> - - + e.stopPropagation()}> + + + + @@ -225,6 +244,8 @@ export function AreaSettingsDialog({isOpen, handleClose}: AsyncDialogProps) { '& 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." /> @@ -282,7 +309,7 @@ export function AreaSettingsDialog({isOpen, handleClose}: AsyncDialogProps) { - diff --git a/src/stores/schemas.ts b/src/stores/schemas.ts index a34333a..e7efa46 100644 --- a/src/stores/schemas.ts +++ b/src/stores/schemas.ts @@ -61,7 +61,7 @@ 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(), From a36a533741832a82658615748178066b1dcf491a Mon Sep 17 00:00:00 2001 From: Robert Vollmer Date: Thu, 2 Jul 2026 22:42:50 +0000 Subject: [PATCH 4/4] Fix build error --- src/components/ui/TooltipTextField.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui/TooltipTextField.tsx b/src/components/ui/TooltipTextField.tsx index 3e431a8..1c49c4e 100644 --- a/src/components/ui/TooltipTextField.tsx +++ b/src/components/ui/TooltipTextField.tsx @@ -51,7 +51,7 @@ export function TooltipTextField({tooltip, slotProps, ...props}: TooltipTextFiel ...slotProps, input: { endAdornment: infoAdornment, - ...(slotProps as Record)?.input, + ...(slotProps?.input as object | undefined), }, }} />