1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
4import MapIcon from '@mui/icons-material/Map';
5import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
27} from '@mui/material';
28import { invoke } from '@tauri-apps/api/core';
29import { listen } from '@tauri-apps/api/event';
30import { dirname, join } from '@tauri-apps/api/path';
31import { open, save } from '@tauri-apps/plugin-dialog';
32import React, { useEffect, useRef, useState } from 'react';
33import { useScenarioStore } from '@/stores/scenarioStore';
35 type SimulationOutputMetadata,
36 type SimulationProgressState,
37 useSimulationProgressStore,
38} from '@/stores/simulationProgressStore';
40 addSimulationProgressEvent,
41 getSimulationProgressPercent,
42 normalizeCompletedProgressSnapshot,
43} from './simulationProgress';
45export const SimulationView = React.memo(function SimulationView() {
46 const [metadataExportPath, setMetadataExportPath] = useState<string | null>(
49 const isSimulating = useSimulationProgressStore(
50 (state) => state.isSimulating
52 const isGeneratingKml = useSimulationProgressStore(
53 (state) => state.isGeneratingKml
55 const setIsGeneratingKml = useSimulationProgressStore(
56 (state) => state.setIsGeneratingKml
58 const simulationProgress = useSimulationProgressStore(
59 (state) => state.simulationProgress
61 const simulationRunStatus = useSimulationProgressStore(
62 (state) => state.simulationRunStatus
64 const simulationRunError = useSimulationProgressStore(
65 (state) => state.simulationRunError
67 const simulationOutputMetadata = useSimulationProgressStore(
68 (state) => state.simulationOutputMetadata
70 const startSimulationRun = useSimulationProgressStore(
71 (state) => state.startSimulationRun
73 const setSimulationProgressSnapshot = useSimulationProgressStore(
74 (state) => state.setSimulationProgressSnapshot
76 const setSimulationOutputMetadata = useSimulationProgressStore(
77 (state) => state.setSimulationOutputMetadata
79 const completeSimulationRun = useSimulationProgressStore(
80 (state) => state.completeSimulationRun
82 const failSimulationRun = useSimulationProgressStore(
83 (state) => state.failSimulationRun
85 const showError = useScenarioStore((state) => state.showError);
86 const showSuccess = useScenarioStore((state) => state.showSuccess);
87 const scenarioFilePath = useScenarioStore(
88 (state) => state.scenarioFilePath
90 const outputDirectory = useScenarioStore((state) => state.outputDirectory);
91 const setOutputDirectory = useScenarioStore(
92 (state) => state.setOutputDirectory
95 // Use a Ref to store incoming data to avoid triggering re-renders on every event
96 const progressRef = useRef<Record<string, SimulationProgressState>>({});
99 let animationFrameId: number | undefined;
101 const flushProgress = () => {
102 setSimulationProgressSnapshot({ ...progressRef.current });
105 // The update loop synchronizes the Ref data to the State at screen refresh rate
106 const updateLoop = () => {
107 if (useSimulationProgressStore.getState().isSimulating) {
109 animationFrameId = requestAnimationFrame(updateLoop);
113 const unlistenSimComplete = listen<void>('simulation-complete', () => {
114 console.log('Simulation completed successfully.');
115 progressRef.current = normalizeCompletedProgressSnapshot(
119 completeSimulationRun();
120 if (animationFrameId !== undefined) {
121 cancelAnimationFrame(animationFrameId);
125 const unlistenSimError = listen<string>('simulation-error', (event) => {
126 const errorMessage = `Simulation failed: ${event.payload}`;
127 console.error(errorMessage);
128 showError(errorMessage);
130 failSimulationRun(errorMessage);
131 if (animationFrameId !== undefined) {
132 cancelAnimationFrame(animationFrameId);
136 const unlistenSimProgress = listen<SimulationProgressState>(
137 'simulation-progress',
139 progressRef.current = addSimulationProgressEvent(
146 const unlistenOutputMetadata = listen<string>(
147 'simulation-output-metadata',
150 setSimulationOutputMetadata(
151 JSON.parse(event.payload) as SimulationOutputMetadata
155 err instanceof Error ? err.message : String(err);
157 `Failed to decode simulation metadata: ${errorMessage}`
163 const unlistenKmlComplete = listen<string>(
164 'kml-generation-complete',
166 console.log('KML generated successfully at:', event.payload);
167 setIsGeneratingKml(false);
171 const unlistenKmlError = listen<string>(
172 'kml-generation-error',
174 const errorMessage = `KML generation failed: ${event.payload}`;
175 console.error(errorMessage);
176 showError(errorMessage);
177 setIsGeneratingKml(false);
181 // Start the UI update loop if we are simulating
187 if (animationFrameId !== undefined) {
188 cancelAnimationFrame(animationFrameId);
194 unlistenOutputMetadata,
197 ]).then((unlisteners) => {
198 unlisteners.forEach((unlisten) => unlisten());
203 setSimulationProgressSnapshot,
204 setSimulationOutputMetadata,
205 completeSimulationRun,
211 const getEffectiveOutputDir = async () => {
212 if (outputDirectory) return outputDirectory;
213 if (scenarioFilePath) {
215 return await dirname(scenarioFilePath);
217 console.warn('Failed to get dirname of scenario file', e);
223 const handleSelectOutputDir = async () => {
225 const selected = await open({
228 defaultPath: await getEffectiveOutputDir(),
230 if (typeof selected === 'string') {
231 setOutputDirectory(selected);
234 console.error('Failed to open directory dialog:', err);
238 const handleRunSimulation = async () => {
239 progressRef.current = {};
240 setMetadataExportPath(null);
241 startSimulationRun();
243 // Ensure the C++ backend has the latest scenario from the UI
244 const effectiveDir = await getEffectiveOutputDir();
245 await invoke('set_output_directory', { dir: effectiveDir });
247 await useScenarioStore.getState().syncBackend();
248 await invoke('run_simulation');
251 err instanceof Error ? err.message : String(err);
252 console.error('Failed to invoke simulation:', errorMessage);
253 showError(`Failed to start simulation: ${errorMessage}`);
254 failSimulationRun(`Failed to start simulation: ${errorMessage}`);
258 const handleGenerateKml = async () => {
260 const effectiveDir = await getEffectiveOutputDir();
262 // 1. Get the simulation name from the store
264 useScenarioStore.getState().globalParameters.simulation_name ||
267 // 2. Sanitize the name and append extension
268 const suggestedFileName = `${simName.replace(/[^a-z0-9]/gi, '_')}.kml`;
270 // 3. Join the directory and filename to create the pre-fill path
271 const defaultPath = await join(effectiveDir, suggestedFileName);
273 await invoke('set_output_directory', { dir: effectiveDir });
275 const outputPath = await save({
276 title: 'Save KML File',
277 defaultPath: defaultPath,
278 filters: [{ name: 'KML File', extensions: ['kml'] }],
282 setIsGeneratingKml(true);
283 // Ensure the C++ backend has the latest scenario from the UI
284 await useScenarioStore.getState().syncBackend();
285 await invoke('generate_kml', { outputPath });
289 err instanceof Error ? err.message : String(err);
290 console.error('Failed to invoke KML generation:', errorMessage);
291 showError(`Failed to start KML generation: ${errorMessage}`);
292 setIsGeneratingKml(false); // Stop on invocation failure
296 const hasProgress = Object.keys(simulationProgress).length > 0;
297 const progressPanelVisible =
298 isSimulating || hasProgress || simulationRunStatus === 'failed';
299 const mainProgress = simulationProgress['main'];
300 const progressHeading = mainProgress
301 ? mainProgress.message
302 : simulationRunStatus === 'completed'
303 ? 'Simulation complete'
304 : simulationRunStatus === 'failed'
305 ? 'Simulation failed'
306 : 'Preparing simulation...';
307 const otherProgresses = Object.entries(simulationProgress)
308 .filter(([key]) => key !== 'main')
309 .sort((a, b) => a[0].localeCompare(b[0]));
310 const mainProgressPercent = mainProgress
311 ? getSimulationProgressPercent(mainProgress)
313 const renderProgressDetails = (
314 details: SimulationProgressState['details']
316 if (!details || details.length === 0) {
321 <Box component="ul" sx={{ m: 0, mt: 1, pl: 2 }}>
322 {details.map((detail) => {
323 const detailPercent = getSimulationProgressPercent(detail);
325 detailPercent !== null
326 ? ` (${Math.round(detailPercent)}%)`
328 ? ` (Chunk ${detail.current})`
335 color="text.secondary"
346 const exportMetadataJson = async () => {
348 const outputPath = await invoke<string>(
349 'export_output_metadata_json'
351 setMetadataExportPath(outputPath);
352 showSuccess(`Metadata JSON saved to ${outputPath}`);
355 err instanceof Error ? err.message : String(err);
356 showError(`Failed to export metadata JSON: ${errorMessage}`);
359 const formatSampleRange = (start: number, end: number) =>
360 `[${start}, ${end})`;
361 const formatPulseLength = (
366 if (minSamples === 0 && maxSamples === 0) {
369 return uniform ? String(minSamples) : `${minSamples} - ${maxSamples}`;
374 sx={{ p: 4, height: '100%', overflowY: 'auto', contain: 'content' }}
376 <Typography variant="h4" gutterBottom>
379 <Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
380 Execute the configured scenario or generate a geographical
381 visualization. Ensure your scenario is fully configured before
384 <Card elevation={0} sx={{ mb: 4 }}>
386 <Typography variant="h6" gutterBottom>
391 color="text.secondary"
394 Simulation results (.h5 files) and default KML exports
397 <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
399 label="Output Directory"
406 ? 'Default (Scenario Directory)'
407 : 'Default (Current Directory)')
417 onClick={handleSelectOutputDir}
418 sx={{ whiteSpace: 'nowrap' }}
422 {outputDirectory && (
426 onClick={() => setOutputDirectory(null)}
435 <Grid container spacing={4} sx={{ width: '100%' }}>
436 {/* ... existing Grid items for Run Simulation and Generate KML ... */}
437 <Grid size={{ xs: 12, md: 6 }}>
438 <Card elevation={0} sx={{ height: '100%' }}>
440 <Typography variant="h5" component="div">
443 <Typography sx={{ mt: 1.5 }} color="text.secondary">
444 Executes the entire simulation based on the
445 current scenario settings. This is a
446 computationally intensive process that will
447 generate output files.
450 <CardActions sx={{ p: 2 }}>
461 <PlayCircleOutlineIcon />
464 disabled={isSimulating || isGeneratingKml}
465 onClick={handleRunSimulation}
467 {isSimulating ? 'Running...' : 'Run Simulation'}
472 <Grid size={{ xs: 12, md: 6 }}>
473 <Card elevation={0} sx={{ height: '100%' }}>
475 <Typography variant="h5" component="div">
478 <Typography sx={{ mt: 1.5 }} color="text.secondary">
479 Creates a KML file from the scenario's
480 platform motion paths and antenna pointings.
481 This allows for quick visualization in
482 applications like Google Earth without running
483 the full signal-level simulation.
486 <CardActions sx={{ p: 2 }}>
500 disabled={isSimulating || isGeneratingKml}
501 onClick={handleGenerateKml}
512 {progressPanelVisible && (
517 backgroundColor: 'action.hover',
521 {/* Main Simulation Progress */}
524 sx={{ mb: 1, textAlign: 'center' }}
528 {simulationRunStatus === 'failed' && simulationRunError && (
532 sx={{ mb: 2, textAlign: 'center' }}
537 {mainProgressPercent !== null && (
541 alignItems: 'center',
546 <Box sx={{ width: '100%', mr: 1 }}>
548 variant="determinate"
549 value={mainProgressPercent}
552 <Box sx={{ minWidth: 40 }}>
555 color="text.secondary"
556 >{`${Math.round(mainProgressPercent)}%`}</Typography>
560 {renderProgressDetails(mainProgress?.details)}
562 {/* Finalizer Threads List */}
563 {otherProgresses.length > 0 && (
568 borderColor: 'divider',
574 color="text.secondary"
579 {otherProgresses.map(([key, prog]) => {
580 const progressPercent =
581 getSimulationProgressPercent(prog);
582 const showChunkLabel =
583 progressPercent === null &&
588 <Box sx={{ width: '100%' }}>
592 alignItems: 'center',
596 primary={prog.message}
615 color="text.secondary"
631 variant="determinate"
639 {renderProgressDetails(
652 {simulationOutputMetadata && (
653 <Card elevation={0} sx={{ mt: 4 }}>
658 justifyContent: 'space-between',
659 alignItems: 'center',
665 <Typography variant="h6">
670 color="text.secondary"
672 {simulationOutputMetadata.files.length} HDF5
674 {simulationOutputMetadata.files.length === 1
677 at {simulationOutputMetadata.sampling_rate}{' '}
683 onClick={exportMetadataJson}
688 {metadataExportPath && (
691 color="text.secondary"
692 sx={{ mb: 2, overflowWrap: 'anywhere' }}
694 Metadata JSON saved to {metadataExportPath}
698 {simulationOutputMetadata.files.length === 0 ? (
699 <Typography color="text.secondary">
700 No HDF5 output files were generated for this
711 <TableCell>Receiver</TableCell>
712 <TableCell>Mode</TableCell>
713 <TableCell align="right">
716 <TableCell>Sample Range</TableCell>
717 <TableCell>Pulse/Segment</TableCell>
718 <TableCell>File</TableCell>
722 {simulationOutputMetadata.files.map(
724 <TableRow key={file.path}>
731 <TableCell align="right">
737 file.sample_end_exclusive
741 {file.mode === 'pulsed'
742 ? `${file.pulse_count} pulses, ${formatPulseLength(file.min_pulse_length_samples, file.max_pulse_length_samples, file.uniform_pulse_length)} samples`
743 : `${file.cw_segments.length} CW segments`}