FERS 1.0.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
scenarioSlice.ts
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 { StateCreator } from 'zustand';
5import { cloneTemplateIntoScenarioData } from '../../assetTemplates';
6import { useSimulationProgressStore } from '../../simulationProgressStore';
7import { defaultGlobalParameters } from '../defaults';
8import { buildHydratedScenarioState, parseScenarioData } from '../hydration';
9import {
10 cleanObject,
11 serializeAntenna,
12 serializeComponentInner,
13 serializeGlobalParameters,
14 serializePlatform,
15 serializeTiming,
16 serializeWaveform,
17} from '../serializers';
18import { enqueueFullSync, enqueueGranularSync } from '../syncQueue';
19import {
20 Antenna,
21 GlobalParameters,
22 Platform,
23 ScenarioActions,
24 ScenarioStore,
25 Timing,
26 Waveform,
27} from '../types';
28import { setPropertyByPath } from '../utils';
29import { buildScenarioJson } from './backendSlice';
30
31function convertRotationValue(
32 value: number,
33 fromUnit: GlobalParameters['rotationAngleUnit'],
34 toUnit: GlobalParameters['rotationAngleUnit']
35): number {
36 if (fromUnit === toUnit) {
37 return value;
38 }
39 return fromUnit === 'deg'
40 ? (value * Math.PI) / 180
41 : (value * 180) / Math.PI;
42}
43
44export const createScenarioSlice: StateCreator<
45 ScenarioStore,
46 [['zustand/immer', never]],
47 [],
48 ScenarioActions
49> = (set, get) => ({
50 setScenarioFilePath: (path) => set({ scenarioFilePath: path }),
51 setOutputDirectory: (dir) => set({ outputDirectory: dir }),
52
53 selectItem: (itemId) =>
54 set((state) => {
55 if (!itemId) {
56 state.selectedItemId = null;
57 state.selectedComponentId = null;
58 return;
59 }
60
61 if (itemId === 'global-parameters') {
62 state.selectedItemId = itemId;
63 state.selectedComponentId = null;
64 return;
65 }
66
67 const collections = [
68 'waveforms',
69 'timings',
70 'antennas',
71 'platforms',
72 ] as const;
73
74 for (const key of collections) {
75 if (state[key].some((i: { id: string }) => i.id === itemId)) {
76 state.selectedItemId = itemId;
77 state.selectedComponentId = null;
78 return;
79 }
80 }
81
82 for (const platform of state.platforms) {
83 const component = platform.components.find(
84 (c) => c.id === itemId
85 );
86 if (component) {
87 state.selectedItemId = platform.id;
88 state.selectedComponentId = component.id;
89 return;
90 }
91 const monostatic = platform.components.find(
92 (c) =>
93 c.type === 'monostatic' &&
94 (c.txId === itemId || c.rxId === itemId)
95 );
96 if (monostatic) {
97 state.selectedItemId = platform.id;
98 state.selectedComponentId = monostatic.id;
99 return;
100 }
101 }
102
103 state.selectedItemId = itemId;
104 state.selectedComponentId = null;
105 }),
106 updateItem: (itemId, propertyPath, value) => {
107 let targetItemType = '';
108 let targetItemId = '';
109 let jsonPayload: string | null = null;
110
111 set((state) => {
112 if (itemId === 'global-parameters') {
113 setPropertyByPath(state.globalParameters, propertyPath, value);
114
115 // Ensure currentTime remains within valid bounds if start/end parameters are updated
116 if (propertyPath === 'start' && typeof value === 'number') {
117 state.currentTime = Math.max(state.currentTime, value);
118 } else if (
119 propertyPath === 'end' &&
120 typeof value === 'number'
121 ) {
122 state.currentTime = Math.min(state.currentTime, value);
123 }
124
125 state.isDirty = true;
126
127 targetItemType = 'GlobalParameters';
128 targetItemId = itemId;
129 jsonPayload = JSON.stringify(
130 serializeGlobalParameters(state.globalParameters)
131 );
132 return;
133 }
134
135 const collections = [
136 'waveforms',
137 'timings',
138 'antennas',
139 'platforms',
140 ] as const;
141
142 for (const key of collections) {
143 const item = state[key].find(
144 (i: { id: string }) => i.id === itemId
145 );
146 if (!item) continue;
147
148 setPropertyByPath(item, propertyPath, value);
149 state.isDirty = true;
150 if (item.type === 'Antenna') {
151 delete state.antennaPreviewErrors[item.id];
152 }
153
154 if (
155 item.type !== 'Platform' &&
156 item.type !== 'Antenna' &&
157 item.type !== 'Waveform' &&
158 item.type !== 'Timing'
159 ) {
160 return;
161 }
162
163 const componentMatch = propertyPath.match(
164 /^components\.(\d+)(?:\.|$)/
165 );
166 if (item.type === 'Platform' && componentMatch) {
167 const compIndex = parseInt(componentMatch[1], 10);
168 const component = (item as Platform).components[compIndex];
169 if (component) {
170 jsonPayload = JSON.stringify(
171 cleanObject(serializeComponentInner(component))
172 );
173 // Map frontend component type to backend expected type string
174 targetItemType =
175 component.type === 'monostatic'
176 ? 'Monostatic'
177 : component.type.charAt(0).toUpperCase() +
178 component.type.slice(1);
179 targetItemId = component.id;
180 }
181 return;
182 }
183
184 targetItemType = item.type;
185 targetItemId = item.id;
186 if (item.type === 'Platform') {
187 jsonPayload = JSON.stringify(
188 serializePlatform(item as Platform)
189 );
190 } else if (item.type === 'Antenna') {
191 jsonPayload = JSON.stringify(
192 serializeAntenna(item as Antenna)
193 );
194 } else if (item.type === 'Waveform') {
195 jsonPayload = JSON.stringify(
196 serializeWaveform(item as Waveform)
197 );
198 } else if (item.type === 'Timing') {
199 jsonPayload = JSON.stringify(
200 serializeTiming(item as Timing)
201 );
202 }
203 return;
204 }
205 });
206
207 if (jsonPayload) {
208 void enqueueGranularSync(targetItemType, targetItemId, jsonPayload);
209 }
210 },
211 setRotationAngleUnit: (unit, convertExisting) => {
212 const previousUnit = get().globalParameters.rotationAngleUnit;
213 if (previousUnit === unit) {
214 return;
215 }
216
217 set((state) => {
218 state.globalParameters.rotationAngleUnit = unit;
219
220 if (convertExisting) {
221 for (const platform of state.platforms) {
222 if (platform.rotation.type === 'fixed') {
223 platform.rotation.startAzimuth = convertRotationValue(
224 platform.rotation.startAzimuth,
225 previousUnit,
226 unit
227 );
228 platform.rotation.startElevation = convertRotationValue(
229 platform.rotation.startElevation,
230 previousUnit,
231 unit
232 );
233 platform.rotation.azimuthRate = convertRotationValue(
234 platform.rotation.azimuthRate,
235 previousUnit,
236 unit
237 );
238 platform.rotation.elevationRate = convertRotationValue(
239 platform.rotation.elevationRate,
240 previousUnit,
241 unit
242 );
243 } else {
244 for (const waypoint of platform.rotation.waypoints) {
245 waypoint.azimuth = convertRotationValue(
246 waypoint.azimuth,
247 previousUnit,
248 unit
249 );
250 waypoint.elevation = convertRotationValue(
251 waypoint.elevation,
252 previousUnit,
253 unit
254 );
255 }
256 }
257 }
258 }
259
260 for (const platform of state.platforms) {
261 delete platform.rotationPathPoints;
262 }
263 state.isDirty = true;
264 });
265
266 void enqueueFullSync(() => buildScenarioJson(get()));
267 for (const platform of get().platforms) {
268 void get().fetchPlatformPath(platform.id);
269 }
270 },
271 removeItem: (itemId) => {
272 let removed = false;
273 set((state) => {
274 if (!itemId) return;
275 const collections = [
276 'waveforms',
277 'timings',
278 'antennas',
279 'platforms',
280 ] as const;
281 for (const key of collections) {
282 const index = state[key].findIndex(
283 (item: { id: string }) => item.id === itemId
284 );
285 if (index > -1) {
286 if (key === 'antennas') {
287 delete state.antennaPreviewErrors[itemId];
288 }
289 state[key].splice(index, 1);
290 if (state.selectedItemId === itemId) {
291 state.selectedItemId = null;
292 }
293 if (key === 'platforms') {
294 state.selectedComponentId = null;
295 }
296 state.isDirty = true;
297 removed = true;
298 return;
299 }
300 }
301 });
302 if (removed) {
303 // libfers has no granular remove API — full sync is required.
304 void enqueueFullSync(() => buildScenarioJson(get()));
305 }
306 },
307 resetScenario: () => {
308 set({
309 globalParameters: defaultGlobalParameters,
310 waveforms: [],
311 timings: [],
312 antennas: [],
313 platforms: [],
314 selectedItemId: null,
315 selectedComponentId: null,
316 isDirty: false,
317 antennaPreviewErrors: {},
318 currentTime: defaultGlobalParameters.start,
319 });
320 useSimulationProgressStore.getState().clearSimulationProgress();
321 useSimulationProgressStore.setState({ isSimulating: false });
322 void enqueueFullSync(() => buildScenarioJson(get()));
323 },
324 loadScenario: (backendData: unknown) => {
325 const scenarioData = parseScenarioData(backendData);
326 if (!scenarioData) {
327 return;
328 }
329
330 set(buildHydratedScenarioState(get(), scenarioData, { isDirty: true }));
331 },
332 insertAssetTemplate: (template) => {
333 const { scenarioData, result } = cloneTemplateIntoScenarioData(
334 get(),
335 template
336 );
337
338 set((state) => {
339 state.waveforms = scenarioData.waveforms;
340 state.timings = scenarioData.timings;
341 state.antennas = scenarioData.antennas;
342 state.platforms = scenarioData.platforms;
343 state.selectedItemId = result.insertedItemId;
344 state.selectedComponentId = null;
345 state.isDirty = true;
346 });
347
348 // v1 loads library templates by cloning them into the active scenario.
349 // Replacing selected items or prompting for placement each time remain
350 // future options; nested component-only templates are intentionally out
351 // of scope until whole-platform reuse proves insufficient.
352 result.warnings.forEach((warning) => get().showWarning(warning));
353 void enqueueFullSync(() => buildScenarioJson(get()));
354
355 return result;
356 },
357});