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