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