1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
4import { StateCreator } from 'zustand';
5import { cloneTemplateIntoScenarioData } from '../../assetTemplates';
6import { useSimulationProgressStore } from '../../simulationProgressStore';
7import { defaultGlobalParameters } from '../defaults';
8import { buildHydratedScenarioState, parseScenarioData } from '../hydration';
10 createUniqueScenarioName,
11 getComponentIdentityIds,
15 serializeGlobalParameters,
18} from '../serializers';
20 enqueueFullSyncDetached,
21 enqueueGranularSyncDetached,
31import { setPropertyByPath } from '../utils';
32import { buildScenarioJson } from './backendSlice';
34function convertRotationValue(
36 fromUnit: GlobalParameters['rotationAngleUnit'],
37 toUnit: GlobalParameters['rotationAngleUnit']
39 if (fromUnit === toUnit) {
42 return fromUnit === 'deg'
43 ? (value * Math.PI) / 180
44 : (value * 180) / Math.PI;
47export const createScenarioSlice: StateCreator<
49 [['zustand/immer', never]],
53 setScenarioFilePath: (path) => set({ scenarioFilePath: path }),
54 setOutputDirectory: (dir) => set({ outputDirectory: dir }),
56 selectItem: (itemId) =>
59 state.selectedItemId = null;
60 state.selectedComponentId = null;
64 if (itemId === 'global-parameters') {
65 state.selectedItemId = itemId;
66 state.selectedComponentId = null;
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;
85 for (const platform of state.platforms) {
86 const component = platform.components.find(
87 (c) => c.id === itemId
90 state.selectedItemId = platform.id;
91 state.selectedComponentId = component.id;
94 const monostatic = platform.components.find(
96 c.type === 'monostatic' &&
97 (c.txId === itemId || c.rxId === itemId)
100 state.selectedItemId = platform.id;
101 state.selectedComponentId = monostatic.id;
106 state.selectedItemId = itemId;
107 state.selectedComponentId = null;
109 updateItem: (itemId, propertyPath, value) => {
110 let targetItemType = '';
111 let targetItemId = '';
112 let jsonPayload: string | null = null;
113 let requiresFullSync = false;
116 if (itemId === 'global-parameters') {
117 setPropertyByPath(state.globalParameters, propertyPath, value);
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);
123 propertyPath === 'end' &&
124 typeof value === 'number'
126 state.currentTime = Math.min(state.currentTime, value);
129 state.isDirty = true;
132 ['start', 'end', 'rate', 'oversample_ratio'].includes(
136 requiresFullSync = true;
140 targetItemType = 'GlobalParameters';
141 targetItemId = itemId;
142 jsonPayload = JSON.stringify(
143 serializeGlobalParameters(state.globalParameters)
148 const collections = [
155 for (const key of collections) {
156 const item = state[key].find(
157 (i: { id: string }) => i.id === itemId
161 let nextValue = value;
162 if (typeof value === 'string') {
163 if (propertyPath === 'name') {
164 nextValue = createUniqueScenarioName(state, value, [
167 } else if (item.type === 'Platform') {
168 const componentNameMatch = propertyPath.match(
169 /^components\.(\d+)\.name$/
171 if (componentNameMatch) {
173 item.components[Number(componentNameMatch[1])];
175 nextValue = createUniqueScenarioName(
178 getComponentIdentityIds(component)
185 setPropertyByPath(item, propertyPath, nextValue);
186 state.isDirty = true;
187 if (item.type === 'Antenna') {
188 delete state.antennaPreviewErrors[item.id];
191 if (item.type === 'Waveform') {
192 requiresFullSync = true;
197 item.type !== 'Platform' &&
198 item.type !== 'Antenna' &&
199 item.type !== 'Timing'
204 const componentMatch = propertyPath.match(
205 /^components\.(\d+)(?:\.|$)/
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;
216 targetItemType = item.type;
217 targetItemId = item.id;
218 if (item.type === 'Platform') {
219 jsonPayload = JSON.stringify(
220 serializePlatform(item as Platform)
222 } else if (item.type === 'Antenna') {
223 jsonPayload = JSON.stringify(
224 serializeAntenna(item as Antenna)
226 } else if (item.type === 'Timing') {
227 jsonPayload = JSON.stringify(
228 serializeTiming(item as Timing)
235 if (requiresFullSync) {
236 enqueueFullSyncDetached(() => buildScenarioJson(get()));
237 } else if (jsonPayload) {
238 enqueueGranularSyncDetached(
245 setRotationAngleUnit: (unit, convertExisting) => {
246 const previousUnit = get().globalParameters.rotationAngleUnit;
247 if (previousUnit === unit) {
252 state.globalParameters.rotationAngleUnit = unit;
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,
262 platform.rotation.startElevation = convertRotationValue(
263 platform.rotation.startElevation,
267 platform.rotation.azimuthRate = convertRotationValue(
268 platform.rotation.azimuthRate,
272 platform.rotation.elevationRate = convertRotationValue(
273 platform.rotation.elevationRate,
278 for (const waypoint of platform.rotation.waypoints) {
279 waypoint.azimuth = convertRotationValue(
284 waypoint.elevation = convertRotationValue(
294 for (const platform of state.platforms) {
295 delete platform.rotationPathPoints;
297 state.isDirty = true;
300 enqueueFullSyncDetached(() => buildScenarioJson(get()));
301 for (const platform of get().platforms) {
302 void get().fetchPlatformPath(platform.id);
305 removeItem: (itemId) => {
309 const collections = [
315 for (const key of collections) {
316 const index = state[key].findIndex(
317 (item: { id: string }) => item.id === itemId
320 if (key === 'antennas') {
321 delete state.antennaPreviewErrors[itemId];
323 state[key].splice(index, 1);
324 if (state.selectedItemId === itemId) {
325 state.selectedItemId = null;
327 if (key === 'platforms') {
328 state.selectedComponentId = null;
330 state.isDirty = true;
337 // libfers has no granular remove API — full sync is required.
338 enqueueFullSyncDetached(() => buildScenarioJson(get()));
341 resetScenario: () => {
343 globalParameters: defaultGlobalParameters,
348 selectedItemId: null,
349 selectedComponentId: null,
351 antennaPreviewErrors: {},
352 currentTime: defaultGlobalParameters.start,
354 useSimulationProgressStore.getState().clearSimulationProgress();
355 useSimulationProgressStore.setState({ isSimulating: false });
356 enqueueFullSyncDetached(() => buildScenarioJson(get()));
358 loadScenario: (backendData: unknown) => {
359 const scenarioData = parseScenarioData(backendData);
364 set(buildHydratedScenarioState(get(), scenarioData, { isDirty: true }));
366 insertAssetTemplate: (template) => {
367 const { scenarioData, result } = cloneTemplateIntoScenarioData(
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;
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()));