FERS 1.0.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
PlatformInspector.tsx
Go to the documentation of this file.
1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
3
4import AddIcon from '@mui/icons-material/Add';
5import DeleteIcon from '@mui/icons-material/Delete';
6import EditIcon from '@mui/icons-material/Edit';
7import {
8 Box,
9 Button,
10 Dialog,
11 DialogActions,
12 DialogContent,
13 DialogTitle,
14 FormControl,
15 IconButton,
16 InputLabel,
17 MenuItem,
18 Select,
19 TextField,
20 Typography,
21} from '@mui/material';
22import { useState } from 'react';
23import {
24 Platform,
25 PlatformComponent,
26 PositionWaypoint,
27 RotationWaypoint,
28 useScenarioStore,
29} from '@/stores/scenarioStore';
30import { generateSimId } from '@/stores/scenarioStore/idUtils';
31import { NumberField, Section } from './InspectorControls';
32import { PlatformComponentInspector } from './PlatformComponentInspector';
33
34interface PlatformInspectorProps {
35 item: Platform;
36 selectedComponentId: string | null;
37}
38
39interface WaypointEditDialogProps {
40 open: boolean;
41 onClose: (
42 finalWaypoint: PositionWaypoint | RotationWaypoint | null
43 ) => void;
44 waypoint: PositionWaypoint | RotationWaypoint | null;
45 waypointType: 'position' | 'rotation';
46 angleUnitLabel: 'deg' | 'rad';
47}
48
49function WaypointEditDialog({
50 open,
51 onClose,
52 waypoint,
53 waypointType,
54 angleUnitLabel,
55}: WaypointEditDialogProps) {
56 const [editedWaypoint, setEditedWaypoint] = useState(waypoint);
57
58 const handleFieldChange = (field: string, value: number | null) => {
59 setEditedWaypoint((prev) => {
60 if (!prev) return null;
61 return { ...prev, [field]: value };
62 });
63 };
64
65 const handleClose = () => {
66 if (!editedWaypoint) {
67 onClose(null);
68 return;
69 }
70
71 // Create a mutable copy and cast to a record for safe dynamic access.
72 const sanitizedWaypoint: Record<string, unknown> = {
73 ...editedWaypoint,
74 };
75
76 // Sanitize the local state on close: convert any nulls back to 0.
77 for (const key in sanitizedWaypoint) {
78 if (
79 Object.hasOwn(sanitizedWaypoint, key) &&
80 sanitizedWaypoint[key] === null
81 ) {
82 sanitizedWaypoint[key] = 0;
83 }
84 }
85 // Cast back to the original type before calling the parent callback.
86 onClose(sanitizedWaypoint as PositionWaypoint | RotationWaypoint);
87 };
88
89 if (!editedWaypoint) return null;
90
91 return (
92 <Dialog open={open} onClose={handleClose}>
93 <DialogTitle>Edit Waypoint</DialogTitle>
94 <DialogContent>
95 <Box
96 sx={{
97 display: 'flex',
98 flexDirection: 'column',
99 gap: 2,
100 pt: 1,
101 }}
102 >
103 {waypointType === 'position' && 'x' in editedWaypoint && (
104 <>
105 <NumberField
106 label="X"
107 value={editedWaypoint.x}
108 onChange={(v) => handleFieldChange('x', v)}
109 />
110 <NumberField
111 label="Y"
112 value={editedWaypoint.y}
113 onChange={(v) => handleFieldChange('y', v)}
114 />
115 <NumberField
116 label="Altitude"
117 value={editedWaypoint.altitude}
118 onChange={(v) =>
119 handleFieldChange('altitude', v)
120 }
121 />
122 </>
123 )}
124 {waypointType === 'rotation' &&
125 'azimuth' in editedWaypoint && (
126 <>
127 <NumberField
128 label={`Azimuth (${angleUnitLabel})`}
129 value={editedWaypoint.azimuth}
130 onChange={(v) =>
131 handleFieldChange('azimuth', v)
132 }
133 />
134 <NumberField
135 label={`Elevation (${angleUnitLabel})`}
136 value={editedWaypoint.elevation}
137 onChange={(v) =>
138 handleFieldChange('elevation', v)
139 }
140 />
141 </>
142 )}
143 <NumberField
144 label="Time (s)"
145 value={editedWaypoint.time}
146 onChange={(v) => handleFieldChange('time', v)}
147 />
148 </Box>
149 </DialogContent>
150 <DialogActions>
151 <Button onClick={handleClose}>Close</Button>
152 </DialogActions>
153 </Dialog>
154 );
155}
156
157export function PlatformInspector({
158 item,
159 selectedComponentId,
160}: PlatformInspectorProps) {
161 const {
162 updateItem,
163 addPositionWaypoint,
164 removePositionWaypoint,
165 addRotationWaypoint,
166 removeRotationWaypoint,
167 addPlatformComponent,
168 removePlatformComponent,
169 } = useScenarioStore.getState();
170 const angleUnitLabel = useScenarioStore(
171 (state) => state.globalParameters.rotationAngleUnit
172 );
173
174 const handleChange = (path: string, value: unknown) =>
175 updateItem(item.id, path, value);
176
177 const allowMultiplePosWaypoints =
178 item.motionPath.interpolation !== 'static';
179
180 const [editingWaypointInfo, setEditingWaypointInfo] = useState<{
181 type: 'position' | 'rotation';
182 index: number;
183 } | null>(null);
184
185 const [newComponentType, setNewComponentType] =
186 useState<PlatformComponent['type']>('monostatic');
187
188 const handleDialogClose = (
189 finalWaypoint: PositionWaypoint | RotationWaypoint | null
190 ) => {
191 if (finalWaypoint && editingWaypointInfo) {
192 const { type, index } = editingWaypointInfo;
193 const pathPrefix = type === 'position' ? 'motionPath' : 'rotation';
194 handleChange(`${pathPrefix}.waypoints.${index}`, finalWaypoint);
195 }
196 setEditingWaypointInfo(null);
197 };
198
199 const currentEditingWaypoint = editingWaypointInfo
200 ? editingWaypointInfo.type === 'position'
201 ? item.motionPath.waypoints[editingWaypointInfo.index]
202 : item.rotation.type === 'path'
203 ? item.rotation.waypoints[editingWaypointInfo.index]
204 : null
205 : null;
206
207 const handleRotationTypeChange = (newType: 'fixed' | 'path') => {
208 if (newType === 'fixed' && item.rotation.type !== 'fixed') {
209 handleChange('rotation', {
210 type: 'fixed',
211 startAzimuth: 0,
212 startElevation: 0,
213 azimuthRate: 0,
214 elevationRate: 0,
215 });
216 } else if (newType === 'path' && item.rotation.type !== 'path') {
217 handleChange('rotation', {
218 type: 'path',
219 interpolation: 'static',
220 waypoints: [
221 {
222 id: generateSimId('Platform'),
223 azimuth: 0,
224 elevation: 0,
225 time: 0,
226 },
227 ],
228 });
229 }
230 };
231
232 return (
233 <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
234 <TextField
235 label="Name"
236 variant="outlined"
237 size="small"
238 fullWidth
239 value={item.name}
240 onChange={(e) => handleChange('name', e.target.value)}
241 />
242
243 <Section title="Motion Path">
244 <FormControl fullWidth size="small">
245 <InputLabel>Interpolation</InputLabel>
246 <Select
247 label="Interpolation"
248 value={item.motionPath.interpolation}
249 onChange={(e) =>
250 handleChange(
251 'motionPath.interpolation',
252 e.target.value
253 )
254 }
255 >
256 <MenuItem value="static">Static</MenuItem>
257 <MenuItem value="linear">Linear</MenuItem>
258 <MenuItem value="cubic">Cubic</MenuItem>
259 </Select>
260 </FormControl>
261 {item.motionPath.waypoints
262 .slice(
263 0,
264 allowMultiplePosWaypoints
265 ? item.motionPath.waypoints.length
266 : 1
267 )
268 .map((wp, i) => (
269 <Box
270 key={wp.id}
271 sx={{
272 display: 'flex',
273 alignItems: 'center',
274 justifyContent: 'space-between',
275 p: 1,
276 border: 1,
277 borderColor: 'divider',
278 borderRadius: 1,
279 }}
280 >
281 <Typography
282 variant="body2"
283 sx={{
284 flexGrow: 1,
285 mr: 1,
286 whiteSpace: 'nowrap',
287 overflow: 'hidden',
288 textOverflow: 'ellipsis',
289 }}
290 >
291 {`T: ${wp.time}s, X: ${wp.x}, Y: ${wp.y}, Alt: ${wp.altitude}`}
292 </Typography>
293 <Box sx={{ display: 'flex', flexShrink: 0 }}>
294 <IconButton
295 size="small"
296 onClick={() =>
297 setEditingWaypointInfo({
298 type: 'position',
299 index: i,
300 })
301 }
302 >
303 <EditIcon fontSize="small" />
304 </IconButton>
305 {allowMultiplePosWaypoints && (
306 <IconButton
307 size="small"
308 onClick={() =>
309 removePositionWaypoint(
310 item.id,
311 wp.id
312 )
313 }
314 disabled={
315 item.motionPath.waypoints.length <=
316 1
317 }
318 >
319 <DeleteIcon fontSize="small" />
320 </IconButton>
321 )}
322 </Box>
323 </Box>
324 ))}
325 {allowMultiplePosWaypoints && (
326 <Button
327 onClick={() => addPositionWaypoint(item.id)}
328 size="small"
329 >
330 Add Waypoint
331 </Button>
332 )}
333 </Section>
334
335 <Section title="Rotation">
336 <FormControl fullWidth size="small">
337 <InputLabel>Rotation Type</InputLabel>
338 <Select
339 label="Rotation Type"
340 value={item.rotation.type}
341 onChange={(e) =>
342 handleRotationTypeChange(
343 e.target.value as 'fixed' | 'path'
344 )
345 }
346 >
347 <MenuItem value="fixed">Fixed Rate</MenuItem>
348 <MenuItem value="path">Waypoint Path</MenuItem>
349 </Select>
350 </FormControl>
351 {item.rotation.type === 'fixed' && (
352 <>
353 <NumberField
354 label={`Start Azimuth (${angleUnitLabel})`}
355 value={item.rotation.startAzimuth}
356 onChange={(v) =>
357 handleChange('rotation.startAzimuth', v)
358 }
359 />
360 <NumberField
361 label={`Start Elevation (${angleUnitLabel})`}
362 value={item.rotation.startElevation}
363 onChange={(v) =>
364 handleChange('rotation.startElevation', v)
365 }
366 />
367 <NumberField
368 label={`Azimuth Rate (${angleUnitLabel}/s)`}
369 value={item.rotation.azimuthRate}
370 onChange={(v) =>
371 handleChange('rotation.azimuthRate', v)
372 }
373 />
374 <NumberField
375 label={`Elevation Rate (${angleUnitLabel}/s)`}
376 value={item.rotation.elevationRate}
377 onChange={(v) =>
378 handleChange('rotation.elevationRate', v)
379 }
380 />
381 </>
382 )}
383 {item.rotation.type === 'path' &&
384 (() => {
385 const rotation = item.rotation;
386 const allowMultipleWaypoints =
387 rotation.interpolation !== 'static';
388 return (
389 <>
390 <FormControl fullWidth size="small">
391 <InputLabel>Interpolation</InputLabel>
392 <Select
393 label="Interpolation"
394 value={rotation.interpolation}
395 onChange={(e) =>
396 handleChange(
397 'rotation.interpolation',
398 e.target.value
399 )
400 }
401 >
402 <MenuItem value="static">
403 Static
404 </MenuItem>
405 <MenuItem value="linear">
406 Linear
407 </MenuItem>
408 <MenuItem value="cubic">Cubic</MenuItem>
409 </Select>
410 </FormControl>
411 {rotation.waypoints
412 .slice(
413 0,
414 allowMultipleWaypoints
415 ? rotation.waypoints.length
416 : 1
417 )
418 .map((wp, i) => (
419 <Box
420 key={wp.id}
421 sx={{
422 display: 'flex',
423 alignItems: 'center',
424 justifyContent: 'space-between',
425 p: 1,
426 border: 1,
427 borderColor: 'divider',
428 borderRadius: 1,
429 }}
430 >
431 <Typography
432 variant="body2"
433 sx={{
434 flexGrow: 1,
435 mr: 1,
436 whiteSpace: 'nowrap',
437 overflow: 'hidden',
438 textOverflow: 'ellipsis',
439 }}
440 >
441 {`T: ${wp.time}s, Az: ${wp.azimuth} ${angleUnitLabel}, El: ${wp.elevation} ${angleUnitLabel}`}
442 </Typography>
443 <Box
444 sx={{
445 display: 'flex',
446 flexShrink: 0,
447 }}
448 >
449 <IconButton
450 size="small"
451 onClick={() =>
452 setEditingWaypointInfo({
453 type: 'rotation',
454 index: i,
455 })
456 }
457 >
458 <EditIcon fontSize="small" />
459 </IconButton>
460 {allowMultipleWaypoints && (
461 <IconButton
462 size="small"
463 onClick={() =>
464 removeRotationWaypoint(
465 item.id,
466 wp.id
467 )
468 }
469 disabled={
470 rotation.waypoints
471 .length <= 1
472 }
473 >
474 <DeleteIcon fontSize="small" />
475 </IconButton>
476 )}
477 </Box>
478 </Box>
479 ))}
480 {allowMultipleWaypoints && (
481 <Button
482 onClick={() =>
483 addRotationWaypoint(item.id)
484 }
485 size="small"
486 >
487 Add Waypoint
488 </Button>
489 )}
490 </>
491 );
492 })()}
493 </Section>
494
495 <Section title="Components">
496 {item.components.map((comp, index) => (
497 <Box
498 key={comp.id}
499 sx={{
500 border: 1,
501 borderColor: 'divider',
502 borderRadius: 1,
503 p: 2,
504 mb: 2,
505 backgroundColor: 'background.default',
506 }}
507 >
508 <Box
509 sx={{
510 display: 'flex',
511 justifyContent: 'space-between',
512 alignItems: 'center',
513 mb: 2,
514 }}
515 >
516 <Typography variant="subtitle2" color="primary">
517 {comp.type.toUpperCase()}
518 {selectedComponentId === comp.id &&
519 ' (Selected)'}
520 </Typography>
521 <IconButton
522 size="small"
523 color="error"
524 onClick={() =>
525 removePlatformComponent(item.id, comp.id)
526 }
527 >
528 <DeleteIcon fontSize="small" />
529 </IconButton>
530 </Box>
531 <PlatformComponentInspector
532 component={comp}
533 platformId={item.id}
534 index={index}
535 />
536 </Box>
537 ))}
538
539 <Box
540 sx={{
541 display: 'flex',
542 gap: 1,
543 alignItems: 'center',
544 mt: 1,
545 }}
546 >
547 <FormControl size="small" sx={{ flexGrow: 1 }}>
548 <InputLabel>New Component</InputLabel>
549 <Select
550 label="New Component"
551 value={newComponentType}
552 onChange={(e) =>
553 setNewComponentType(
554 e.target.value as PlatformComponent['type']
555 )
556 }
557 >
558 <MenuItem value="monostatic">
559 Monostatic Radar
560 </MenuItem>
561 <MenuItem value="transmitter">Transmitter</MenuItem>
562 <MenuItem value="receiver">Receiver</MenuItem>
563 <MenuItem value="target">Target</MenuItem>
564 </Select>
565 </FormControl>
566 <Button
567 variant="outlined"
568 onClick={() =>
569 addPlatformComponent(item.id, newComponentType)
570 }
571 startIcon={<AddIcon />}
572 >
573 Add
574 </Button>
575 </Box>
576 </Section>
577
578 <WaypointEditDialog
579 key={
580 editingWaypointInfo
581 ? `${editingWaypointInfo.type}-${editingWaypointInfo.index}`
582 : 'none'
583 }
584 open={!!editingWaypointInfo}
585 onClose={handleDialogClose}
586 waypoint={currentEditingWaypoint}
587 waypointType={editingWaypointInfo?.type ?? 'position'}
588 angleUnitLabel={angleUnitLabel}
589 />
590 </Box>
591 );
592}