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