1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
4import AddIcon from '@mui/icons-material/Add';
5import DeleteIcon from '@mui/icons-material/Delete';
6import EditIcon from '@mui/icons-material/Edit';
20} from '@mui/material';
21import { useState } from 'react';
28} from '@/stores/scenarioStore';
29import { generateSimId } from '@/stores/scenarioStore/idUtils';
30import { BufferedTextField, NumberField, Section } from './InspectorControls';
31import { PlatformComponentInspector } from './PlatformComponentInspector';
33interface PlatformInspectorProps {
35 selectedComponentId: string | null;
38type InterpolationType = 'static' | 'linear' | 'cubic';
40interface WaypointEditDialogProps {
43 finalWaypoint: PositionWaypoint | RotationWaypoint | null
45 waypoint: PositionWaypoint | RotationWaypoint | null;
46 waypointType: 'position' | 'rotation';
47 angleUnitLabel: 'deg' | 'rad';
50const createDefaultPositionWaypoint = (): PositionWaypoint => ({
51 id: generateSimId('Platform'),
58const createDefaultRotationWaypoint = (): RotationWaypoint => ({
59 id: generateSimId('Platform'),
65export function ensureCubicPositionWaypoints(
66 waypoints: PositionWaypoint[]
67): PositionWaypoint[] {
68 if (waypoints.length >= 2) {
72 const first = waypoints[0] ?? createDefaultPositionWaypoint();
77 id: generateSimId('Platform'),
83export function ensureCubicRotationWaypoints(
84 waypoints: RotationWaypoint[]
85): RotationWaypoint[] {
86 if (waypoints.length >= 2) {
90 const first = waypoints[0] ?? createDefaultRotationWaypoint();
95 id: generateSimId('Platform'),
101function WaypointEditDialog({
107}: WaypointEditDialogProps) {
108 const [editedWaypoint, setEditedWaypoint] = useState(waypoint);
110 const handleFieldChange = (field: string, value: number | null) => {
111 setEditedWaypoint((prev) => {
112 if (!prev) return null;
113 return { ...prev, [field]: value };
117 const handleClose = () => {
118 if (!editedWaypoint) {
123 // Create a mutable copy and cast to a record for safe dynamic access.
124 const sanitizedWaypoint: Record<string, unknown> = {
128 // Sanitize the local state on close: convert any nulls back to 0.
129 for (const key in sanitizedWaypoint) {
131 Object.hasOwn(sanitizedWaypoint, key) &&
132 sanitizedWaypoint[key] === null
134 sanitizedWaypoint[key] = 0;
137 // Cast back to the original type before calling the parent callback.
138 onClose(sanitizedWaypoint as PositionWaypoint | RotationWaypoint);
141 if (!editedWaypoint) return null;
144 <Dialog open={open} onClose={handleClose}>
145 <DialogTitle>Edit Waypoint</DialogTitle>
150 flexDirection: 'column',
155 {waypointType === 'position' && 'x' in editedWaypoint && (
159 value={editedWaypoint.x}
160 emptyBehavior="revert"
161 onChange={(v) => handleFieldChange('x', v)}
165 value={editedWaypoint.y}
166 emptyBehavior="revert"
167 onChange={(v) => handleFieldChange('y', v)}
171 value={editedWaypoint.altitude}
172 emptyBehavior="revert"
174 handleFieldChange('altitude', v)
179 {waypointType === 'rotation' &&
180 'azimuth' in editedWaypoint && (
183 label={`Azimuth (${angleUnitLabel})`}
184 value={editedWaypoint.azimuth}
185 emptyBehavior="revert"
187 handleFieldChange('azimuth', v)
191 label={`Elevation (${angleUnitLabel})`}
192 value={editedWaypoint.elevation}
193 emptyBehavior="revert"
195 handleFieldChange('elevation', v)
202 value={editedWaypoint.time}
203 emptyBehavior="revert"
204 onChange={(v) => handleFieldChange('time', v)}
209 <Button onClick={handleClose}>Close</Button>
215export function PlatformInspector({
218}: PlatformInspectorProps) {
222 removePositionWaypoint,
224 removeRotationWaypoint,
225 addPlatformComponent,
226 removePlatformComponent,
227 } = useScenarioStore.getState();
228 const angleUnitLabel = useScenarioStore(
229 (state) => state.globalParameters.rotationAngleUnit
232 const handleChange = (path: string, value: unknown) =>
233 updateItem(item.id, path, value);
235 const allowMultiplePosWaypoints =
236 item.motionPath.interpolation !== 'static';
238 const [editingWaypointInfo, setEditingWaypointInfo] = useState<{
239 type: 'position' | 'rotation';
243 const [newComponentType, setNewComponentType] =
244 useState<PlatformComponent['type']>('monostatic');
246 const handleDialogClose = (
247 finalWaypoint: PositionWaypoint | RotationWaypoint | null
249 if (finalWaypoint && editingWaypointInfo) {
250 const { type, index } = editingWaypointInfo;
251 const pathPrefix = type === 'position' ? 'motionPath' : 'rotation';
252 handleChange(`${pathPrefix}.waypoints.${index}`, finalWaypoint);
254 setEditingWaypointInfo(null);
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]
265 const handleRotationTypeChange = (newType: 'fixed' | 'path') => {
266 if (newType === 'fixed' && item.rotation.type !== 'fixed') {
267 handleChange('rotation', {
274 } else if (newType === 'path' && item.rotation.type !== 'path') {
275 handleChange('rotation', {
277 interpolation: 'static',
280 id: generateSimId('Platform'),
290 const handleMotionInterpolationChange = (
291 interpolation: InterpolationType
293 handleChange('motionPath', {
297 interpolation === 'cubic'
298 ? ensureCubicPositionWaypoints(item.motionPath.waypoints)
299 : item.motionPath.waypoints,
303 const handleRotationInterpolationChange = (
304 interpolation: InterpolationType
306 if (item.rotation.type !== 'path') {
310 handleChange('rotation', {
314 interpolation === 'cubic'
315 ? ensureCubicRotationWaypoints(item.rotation.waypoints)
316 : item.rotation.waypoints,
321 <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
329 onChange={(v) => handleChange('name', v)}
332 <Section title="Motion Path">
333 <FormControl fullWidth size="small">
334 <InputLabel>Interpolation</InputLabel>
336 label="Interpolation"
337 value={item.motionPath.interpolation}
339 handleMotionInterpolationChange(
340 e.target.value as InterpolationType
344 <MenuItem value="static">Static</MenuItem>
345 <MenuItem value="linear">Linear</MenuItem>
346 <MenuItem value="cubic">Cubic</MenuItem>
349 {item.motionPath.waypoints
352 allowMultiplePosWaypoints
353 ? item.motionPath.waypoints.length
361 alignItems: 'center',
362 justifyContent: 'space-between',
365 borderColor: 'divider',
374 whiteSpace: 'nowrap',
376 textOverflow: 'ellipsis',
379 {`T: ${wp.time}s, X: ${wp.x}, Y: ${wp.y}, Alt: ${wp.altitude}`}
381 <Box sx={{ display: 'flex', flexShrink: 0 }}>
385 setEditingWaypointInfo({
391 <EditIcon fontSize="small" />
393 {allowMultiplePosWaypoints && (
397 removePositionWaypoint(
403 item.motionPath.waypoints.length <=
407 <DeleteIcon fontSize="small" />
413 {allowMultiplePosWaypoints && (
415 onClick={() => addPositionWaypoint(item.id)}
423 <Section title="Rotation">
424 <FormControl fullWidth size="small">
425 <InputLabel>Rotation Type</InputLabel>
427 label="Rotation Type"
428 value={item.rotation.type}
430 handleRotationTypeChange(
431 e.target.value as 'fixed' | 'path'
435 <MenuItem value="fixed">Fixed Rate</MenuItem>
436 <MenuItem value="path">Waypoint Path</MenuItem>
439 {item.rotation.type === 'fixed' && (
442 label={`Start Azimuth (${angleUnitLabel})`}
443 value={item.rotation.startAzimuth}
444 emptyBehavior="revert"
446 handleChange('rotation.startAzimuth', v)
450 label={`Start Elevation (${angleUnitLabel})`}
451 value={item.rotation.startElevation}
452 emptyBehavior="revert"
454 handleChange('rotation.startElevation', v)
458 label={`Azimuth Rate (${angleUnitLabel}/s)`}
459 value={item.rotation.azimuthRate}
460 emptyBehavior="revert"
462 handleChange('rotation.azimuthRate', v)
466 label={`Elevation Rate (${angleUnitLabel}/s)`}
467 value={item.rotation.elevationRate}
468 emptyBehavior="revert"
470 handleChange('rotation.elevationRate', v)
475 {item.rotation.type === 'path' &&
477 const rotation = item.rotation;
478 const allowMultipleWaypoints =
479 rotation.interpolation !== 'static';
482 <FormControl fullWidth size="small">
483 <InputLabel>Interpolation</InputLabel>
485 label="Interpolation"
486 value={rotation.interpolation}
488 handleRotationInterpolationChange(
490 .value as InterpolationType
494 <MenuItem value="static">
497 <MenuItem value="linear">
500 <MenuItem value="cubic">Cubic</MenuItem>
506 allowMultipleWaypoints
507 ? rotation.waypoints.length
515 alignItems: 'center',
516 justifyContent: 'space-between',
519 borderColor: 'divider',
528 whiteSpace: 'nowrap',
530 textOverflow: 'ellipsis',
533 {`T: ${wp.time}s, Az: ${wp.azimuth} ${angleUnitLabel}, El: ${wp.elevation} ${angleUnitLabel}`}
544 setEditingWaypointInfo({
550 <EditIcon fontSize="small" />
552 {allowMultipleWaypoints && (
556 removeRotationWaypoint(
566 <DeleteIcon fontSize="small" />
572 {allowMultipleWaypoints && (
575 addRotationWaypoint(item.id)
587 <Section title="Components">
588 {item.components.map((comp, index) => (
593 borderColor: 'divider',
597 backgroundColor: 'background.default',
603 justifyContent: 'space-between',
604 alignItems: 'center',
608 <Typography variant="subtitle2" color="primary">
609 {comp.type.toUpperCase()}
610 {selectedComponentId === comp.id &&
617 removePlatformComponent(item.id, comp.id)
620 <DeleteIcon fontSize="small" />
623 <PlatformComponentInspector
635 alignItems: 'center',
639 <FormControl size="small" sx={{ flexGrow: 1 }}>
640 <InputLabel>New Component</InputLabel>
642 label="New Component"
643 value={newComponentType}
646 e.target.value as PlatformComponent['type']
650 <MenuItem value="monostatic">
653 <MenuItem value="transmitter">Transmitter</MenuItem>
654 <MenuItem value="receiver">Receiver</MenuItem>
655 <MenuItem value="target">Target</MenuItem>
661 addPlatformComponent(item.id, newComponentType)
663 startIcon={<AddIcon />}
673 ? `${editingWaypointInfo.type}-${editingWaypointInfo.index}`
676 open={!!editingWaypointInfo}
677 onClose={handleDialogClose}
678 waypoint={currentEditingWaypoint}
679 waypointType={editingWaypointInfo?.type ?? 'position'}
680 angleUnitLabel={angleUnitLabel}