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';
12 serializeComponentInner,
13 serializeGlobalParameters,
17} from '../serializers';
18import { enqueueFullSync, enqueueGranularSync } from '../syncQueue';
28import { setPropertyByPath } from '../utils';
29import { buildScenarioJson } from './backendSlice';
31function convertRotationValue(
33 fromUnit: GlobalParameters['rotationAngleUnit'],
34 toUnit: GlobalParameters['rotationAngleUnit']
36 if (fromUnit === toUnit) {
39 return fromUnit === 'deg'
40 ? (value * Math.PI) / 180
41 : (value * 180) / Math.PI;
44export const createScenarioSlice: StateCreator<
46 [['zustand/immer', never]],
50 setScenarioFilePath: (path) => set({ scenarioFilePath: path }),
51 setOutputDirectory: (dir) => set({ outputDirectory: dir }),
53 selectItem: (itemId) =>
56 state.selectedItemId = null;
57 state.selectedComponentId = null;
61 if (itemId === 'global-parameters') {
62 state.selectedItemId = itemId;
63 state.selectedComponentId = null;
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;
82 for (const platform of state.platforms) {
83 const component = platform.components.find(
84 (c) => c.id === itemId
87 state.selectedItemId = platform.id;
88 state.selectedComponentId = component.id;
91 const monostatic = platform.components.find(
93 c.type === 'monostatic' &&
94 (c.txId === itemId || c.rxId === itemId)
97 state.selectedItemId = platform.id;
98 state.selectedComponentId = monostatic.id;
103 state.selectedItemId = itemId;
104 state.selectedComponentId = null;
106 updateItem: (itemId, propertyPath, value) => {
107 let targetItemType = '';
108 let targetItemId = '';
109 let jsonPayload: string | null = null;
112 if (itemId === 'global-parameters') {
113 setPropertyByPath(state.globalParameters, propertyPath, value);
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);
119 propertyPath === 'end' &&
120 typeof value === 'number'
122 state.currentTime = Math.min(state.currentTime, value);
125 state.isDirty = true;
127 targetItemType = 'GlobalParameters';
128 targetItemId = itemId;
129 jsonPayload = JSON.stringify(
130 serializeGlobalParameters(state.globalParameters)
135 const collections = [
142 for (const key of collections) {
143 const item = state[key].find(
144 (i: { id: string }) => i.id === itemId
148 setPropertyByPath(item, propertyPath, value);
149 state.isDirty = true;
150 if (item.type === 'Antenna') {
151 delete state.antennaPreviewErrors[item.id];
155 item.type !== 'Platform' &&
156 item.type !== 'Antenna' &&
157 item.type !== 'Waveform' &&
158 item.type !== 'Timing'
163 const componentMatch = propertyPath.match(
164 /^components\.(\d+)(?:\.|$)/
166 if (item.type === 'Platform' && componentMatch) {
167 const compIndex = parseInt(componentMatch[1], 10);
168 const component = (item as Platform).components[compIndex];
170 jsonPayload = JSON.stringify(
171 cleanObject(serializeComponentInner(component))
173 // Map frontend component type to backend expected type string
175 component.type === 'monostatic'
177 : component.type.charAt(0).toUpperCase() +
178 component.type.slice(1);
179 targetItemId = component.id;
184 targetItemType = item.type;
185 targetItemId = item.id;
186 if (item.type === 'Platform') {
187 jsonPayload = JSON.stringify(
188 serializePlatform(item as Platform)
190 } else if (item.type === 'Antenna') {
191 jsonPayload = JSON.stringify(
192 serializeAntenna(item as Antenna)
194 } else if (item.type === 'Waveform') {
195 jsonPayload = JSON.stringify(
196 serializeWaveform(item as Waveform)
198 } else if (item.type === 'Timing') {
199 jsonPayload = JSON.stringify(
200 serializeTiming(item as Timing)
208 void enqueueGranularSync(targetItemType, targetItemId, jsonPayload);
211 setRotationAngleUnit: (unit, convertExisting) => {
212 const previousUnit = get().globalParameters.rotationAngleUnit;
213 if (previousUnit === unit) {
218 state.globalParameters.rotationAngleUnit = unit;
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,
228 platform.rotation.startElevation = convertRotationValue(
229 platform.rotation.startElevation,
233 platform.rotation.azimuthRate = convertRotationValue(
234 platform.rotation.azimuthRate,
238 platform.rotation.elevationRate = convertRotationValue(
239 platform.rotation.elevationRate,
244 for (const waypoint of platform.rotation.waypoints) {
245 waypoint.azimuth = convertRotationValue(
250 waypoint.elevation = convertRotationValue(
260 for (const platform of state.platforms) {
261 delete platform.rotationPathPoints;
263 state.isDirty = true;
266 void enqueueFullSync(() => buildScenarioJson(get()));
267 for (const platform of get().platforms) {
268 void get().fetchPlatformPath(platform.id);
271 removeItem: (itemId) => {
275 const collections = [
281 for (const key of collections) {
282 const index = state[key].findIndex(
283 (item: { id: string }) => item.id === itemId
286 if (key === 'antennas') {
287 delete state.antennaPreviewErrors[itemId];
289 state[key].splice(index, 1);
290 if (state.selectedItemId === itemId) {
291 state.selectedItemId = null;
293 if (key === 'platforms') {
294 state.selectedComponentId = null;
296 state.isDirty = true;
303 // libfers has no granular remove API — full sync is required.
304 void enqueueFullSync(() => buildScenarioJson(get()));
307 resetScenario: () => {
309 globalParameters: defaultGlobalParameters,
314 selectedItemId: null,
315 selectedComponentId: null,
317 antennaPreviewErrors: {},
318 currentTime: defaultGlobalParameters.start,
320 useSimulationProgressStore.getState().clearSimulationProgress();
321 useSimulationProgressStore.setState({ isSimulating: false });
322 void enqueueFullSync(() => buildScenarioJson(get()));
324 loadScenario: (backendData: unknown) => {
325 const scenarioData = parseScenarioData(backendData);
330 set(buildHydratedScenarioState(get(), scenarioData, { isDirty: true }));
332 insertAssetTemplate: (template) => {
333 const { scenarioData, result } = cloneTemplateIntoScenarioData(
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;
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()));