FERS 0.1.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
SceneTree.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 AdjustIcon from '@mui/icons-material/Adjust';
6import ChevronRightIcon from '@mui/icons-material/ChevronRight';
7import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
8import FlightIcon from '@mui/icons-material/Flight';
9import PodcastsIcon from '@mui/icons-material/Podcasts';
10import PublicIcon from '@mui/icons-material/Public';
11import RemoveIcon from '@mui/icons-material/Remove';
12import RssFeedIcon from '@mui/icons-material/RssFeed';
13import SensorsIcon from '@mui/icons-material/Sensors';
14import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
15import TimerIcon from '@mui/icons-material/Timer';
16import WavesIcon from '@mui/icons-material/Waves';
17import { Box, Divider, IconButton, Tooltip, Typography } from '@mui/material';
18import { SimpleTreeView, TreeItem } from '@mui/x-tree-view';
19import React from 'react';
20import { useShallow } from 'zustand/react/shallow';
21import {
22 Platform,
23 PlatformComponent,
24 useScenarioStore,
25} from '@/stores/scenarioStore';
26import ScenarioIO from './ScenarioIO';
27
28const SectionHeader = ({
29 title,
30 onAdd,
31}: {
32 title: string;
33 onAdd: () => void;
34}) => (
35 <Box
36 sx={{
37 display: 'flex',
38 justifyContent: 'space-between',
39 alignItems: 'center',
40 width: '100%',
41 minWidth: 0, // Allow the component to shrink within its flex container
42 }}
43 >
44 <Typography
45 variant="overline"
46 sx={{
47 color: 'text.secondary',
48 whiteSpace: 'nowrap',
49 overflow: 'hidden',
50 textOverflow: 'ellipsis',
51 pr: 1, // Padding between text and add button
52 }}
53 >
54 {title}
55 </Typography>
56 <Tooltip title={`Add ${title.slice(0, -1)}`}>
57 <IconButton
58 size="small"
59 onClick={(e) => {
60 e.stopPropagation();
61 onAdd();
62 }}
63 >
64 <AddIcon fontSize="inherit" />
65 </IconButton>
66 </Tooltip>
67 </Box>
68);
69
70const getPlatformIcon = (platform: Platform) => {
71 const types = platform.components.map((c) => c.type);
72
73 if (types.includes('monostatic')) {
74 return <SensorsIcon sx={{ mr: 1 }} fontSize="small" />;
75 }
76 if (types.includes('transmitter')) {
77 return <PodcastsIcon sx={{ mr: 1 }} fontSize="small" />;
78 }
79 if (types.includes('receiver')) {
80 return <RssFeedIcon sx={{ mr: 1 }} fontSize="small" />;
81 }
82 if (types.includes('target')) {
83 return <AdjustIcon sx={{ mr: 1 }} fontSize="small" />;
84 }
85 return <FlightIcon sx={{ mr: 1 }} fontSize="small" />;
86};
87
88const getComponentIcon = (component: PlatformComponent) => {
89 switch (component.type) {
90 case 'monostatic':
91 return <SensorsIcon sx={{ mr: 1 }} fontSize="small" />;
92 case 'transmitter':
93 return <PodcastsIcon sx={{ mr: 1 }} fontSize="small" />;
94 case 'receiver':
95 return <RssFeedIcon sx={{ mr: 1 }} fontSize="small" />;
96 case 'target':
97 return <AdjustIcon sx={{ mr: 1 }} fontSize="small" />;
98 default:
99 return <FlightIcon sx={{ mr: 1 }} fontSize="small" />;
100 }
101};
102
103export default function SceneTree() {
104 const {
105 globalParameters,
106 waveforms,
107 timings,
108 antennas,
109 platforms,
110 selectedItemId,
111 selectedComponentId,
112 selectItem,
113 addWaveform,
114 addTiming,
115 addAntenna,
116 addPlatform,
117 removeItem,
118 removePlatformComponent,
119 } = useScenarioStore(
120 useShallow((state) => ({
121 globalParameters: state.globalParameters,
122 waveforms: state.waveforms,
123 timings: state.timings,
124 antennas: state.antennas,
125 platforms: state.platforms,
126 selectedItemId: state.selectedItemId,
127 selectedComponentId: state.selectedComponentId,
128 selectItem: state.selectItem,
129 addWaveform: state.addWaveform,
130 addTiming: state.addTiming,
131 addAntenna: state.addAntenna,
132 addPlatform: state.addPlatform,
133 removeItem: state.removeItem,
134 removePlatformComponent: state.removePlatformComponent,
135 }))
136 );
137
138 const handleSelect = (
139 _event: React.SyntheticEvent | null,
140 nodeId: string | null
141 ) => {
142 const rootNodes = [
143 'waveforms-root',
144 'timings-root',
145 'antennas-root',
146 'platforms-root',
147 ];
148 if (nodeId && rootNodes.includes(nodeId)) {
149 return;
150 }
151 selectItem(nodeId);
152 };
153
154 return (
155 <Box
156 sx={{
157 height: '100%',
158 display: 'flex',
159 flexDirection: 'column',
160 }}
161 >
162 {/* 1. Fixed Header */}
163 <Box
164 sx={{
165 display: 'flex',
166 justifyContent: 'space-between',
167 alignItems: 'center',
168 flexShrink: 0,
169 px: 2,
170 pt: 2,
171 pb: 1,
172 }}
173 >
174 <Typography variant="overline" sx={{ color: 'text.secondary' }}>
175 Scenario Explorer
176 </Typography>
177 <Box>
178 <ScenarioIO />
179 </Box>
180 </Box>
181 <Divider sx={{ mx: 2, mb: 1 }} />
182
183 {/* 2. Scrollable Content Area */}
184 <Box
185 sx={{
186 flexGrow: 1, // Takes up all remaining space
187 overflowY: 'auto', // Enables vertical scrolling
188 minHeight: 0, // Crucial for flexbox scrolling
189 px: 2, // Apply horizontal padding inside scroll area
190 }}
191 >
192 <SimpleTreeView
193 selectedItems={selectedComponentId ?? selectedItemId}
194 onSelectedItemsChange={handleSelect}
195 slots={{
196 collapseIcon: ExpandMoreIcon,
197 expandIcon: ChevronRightIcon,
198 }}
199 sx={{
200 // Show remove button on hover
201 '& .MuiTreeItem-content .remove-button': {
202 visibility: 'hidden',
203 },
204 '& .MuiTreeItem-content:hover .remove-button': {
205 visibility: 'visible',
206 },
207 '& .MuiTreeItem-content': { py: 0.5 },
208 }}
209 >
210 <TreeItem
211 itemId={globalParameters.id}
212 label={
213 <Box sx={{ display: 'flex', alignItems: 'center' }}>
214 <PublicIcon sx={{ mr: 1 }} fontSize="small" />
215 <Typography variant="body2">
216 Global Parameters
217 </Typography>
218 </Box>
219 }
220 />
221 <TreeItem
222 itemId="waveforms-root"
223 label={
224 <Box
225 sx={{
226 display: 'flex',
227 alignItems: 'center',
228 width: '100%',
229 }}
230 >
231 <WavesIcon sx={{ mr: 1 }} fontSize="small" />
232 <SectionHeader
233 title="Waveforms"
234 onAdd={addWaveform}
235 />
236 </Box>
237 }
238 >
239 {waveforms.map((waveform) => (
240 <TreeItem
241 key={waveform.id}
242 itemId={waveform.id}
243 label={
244 <Box
245 sx={{
246 display: 'flex',
247 alignItems: 'center',
248 width: '100%',
249 }}
250 >
251 <WavesIcon
252 sx={{ mr: 1 }}
253 fontSize="small"
254 />
255 <Typography
256 variant="body2"
257 sx={{
258 flexGrow: 1,
259 whiteSpace: 'nowrap',
260 overflow: 'hidden',
261 textOverflow: 'ellipsis',
262 minWidth: 0,
263 pr: 1,
264 }}
265 >
266 {waveform.name}
267 </Typography>
268 <IconButton
269 size="small"
270 className="remove-button"
271 onClick={(e) => {
272 e.stopPropagation();
273 removeItem(waveform.id);
274 }}
275 >
276 <RemoveIcon fontSize="inherit" />
277 </IconButton>
278 </Box>
279 }
280 />
281 ))}
282 </TreeItem>
283 <TreeItem
284 itemId="timings-root"
285 label={
286 <Box
287 sx={{
288 display: 'flex',
289 alignItems: 'center',
290 width: '100%',
291 }}
292 >
293 <TimerIcon sx={{ mr: 1 }} fontSize="small" />
294 <SectionHeader
295 title="Timings"
296 onAdd={addTiming}
297 />
298 </Box>
299 }
300 >
301 {timings.map((timing) => (
302 <TreeItem
303 key={timing.id}
304 itemId={timing.id}
305 label={
306 <Box
307 sx={{
308 display: 'flex',
309 alignItems: 'center',
310 width: '100%',
311 }}
312 >
313 <TimerIcon
314 sx={{ mr: 1 }}
315 fontSize="small"
316 />
317 <Typography
318 variant="body2"
319 sx={{
320 flexGrow: 1,
321 whiteSpace: 'nowrap',
322 overflow: 'hidden',
323 textOverflow: 'ellipsis',
324 minWidth: 0,
325 pr: 1,
326 }}
327 >
328 {timing.name}
329 </Typography>
330 <IconButton
331 size="small"
332 className="remove-button"
333 onClick={(e) => {
334 e.stopPropagation();
335 removeItem(timing.id);
336 }}
337 >
338 <RemoveIcon fontSize="inherit" />
339 </IconButton>
340 </Box>
341 }
342 />
343 ))}
344 </TreeItem>
345 <TreeItem
346 itemId="antennas-root"
347 label={
348 <Box
349 sx={{
350 display: 'flex',
351 alignItems: 'center',
352 width: '100%',
353 }}
354 >
355 <SettingsInputAntennaIcon
356 sx={{ mr: 1 }}
357 fontSize="small"
358 />
359 <SectionHeader
360 title="Antennas"
361 onAdd={addAntenna}
362 />
363 </Box>
364 }
365 >
366 {antennas.map((antenna) => (
367 <TreeItem
368 key={antenna.id}
369 itemId={antenna.id}
370 label={
371 <Box
372 sx={{
373 display: 'flex',
374 alignItems: 'center',
375 width: '100%',
376 }}
377 >
378 <SettingsInputAntennaIcon
379 sx={{ mr: 1 }}
380 fontSize="small"
381 />
382 <Typography
383 variant="body2"
384 sx={{
385 flexGrow: 1,
386 whiteSpace: 'nowrap',
387 overflow: 'hidden',
388 textOverflow: 'ellipsis',
389 minWidth: 0,
390 pr: 1,
391 }}
392 >
393 {antenna.name}
394 </Typography>
395 <IconButton
396 size="small"
397 className="remove-button"
398 onClick={(e) => {
399 e.stopPropagation();
400 removeItem(antenna.id);
401 }}
402 >
403 <RemoveIcon fontSize="inherit" />
404 </IconButton>
405 </Box>
406 }
407 />
408 ))}
409 </TreeItem>
410 <TreeItem
411 itemId="platforms-root"
412 label={
413 <Box
414 sx={{
415 display: 'flex',
416 alignItems: 'center',
417 width: '100%',
418 }}
419 >
420 <FlightIcon sx={{ mr: 1 }} fontSize="small" />
421 <SectionHeader
422 title="Platforms"
423 onAdd={addPlatform}
424 />
425 </Box>
426 }
427 >
428 {platforms.map((platform) => {
429 return (
430 <TreeItem
431 key={platform.id}
432 itemId={platform.id}
433 label={
434 <Box
435 sx={{
436 display: 'flex',
437 alignItems: 'center',
438 width: '100%',
439 }}
440 >
441 {getPlatformIcon(platform)}
442 <Typography
443 variant="body2"
444 sx={{
445 flexGrow: 1,
446 whiteSpace: 'nowrap',
447 overflow: 'hidden',
448 textOverflow: 'ellipsis',
449 minWidth: 0,
450 pr: 1,
451 }}
452 >
453 {platform.name}
454 </Typography>
455 <IconButton
456 size="small"
457 className="remove-button"
458 onClick={(e) => {
459 e.stopPropagation();
460 removeItem(platform.id);
461 }}
462 >
463 <RemoveIcon fontSize="inherit" />
464 </IconButton>
465 </Box>
466 }
467 >
468 {platform.components.map((component) => (
469 <TreeItem
470 key={component.id}
471 itemId={component.id}
472 label={
473 <Box
474 sx={{
475 display: 'flex',
476 alignItems: 'center',
477 width: '100%',
478 }}
479 >
480 {getComponentIcon(
481 component
482 )}
483 <Typography
484 variant="body2"
485 sx={{
486 flexGrow: 1,
487 whiteSpace:
488 'nowrap',
489 overflow: 'hidden',
490 textOverflow:
491 'ellipsis',
492 minWidth: 0,
493 pr: 1,
494 }}
495 >
496 {component.name}
497 </Typography>
498 <IconButton
499 size="small"
500 className="remove-button"
501 onClick={(e) => {
502 e.stopPropagation();
503 removePlatformComponent(
504 platform.id,
505 component.id
506 );
507 }}
508 >
509 <RemoveIcon fontSize="inherit" />
510 </IconButton>
511 </Box>
512 }
513 />
514 ))}
515 </TreeItem>
516 );
517 })}
518 </TreeItem>
519 </SimpleTreeView>
520 </Box>
521 </Box>
522 );
523}