FERS 1.0.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
index.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 { create } from 'zustand';
5import { immer } from 'zustand/middleware/immer';
6import { defaultGlobalParameters } from './defaults';
7import { createAssetSlice } from './slices/assetSlice';
8import { createBackendSlice } from './slices/backendSlice';
9import { createPlatformSlice } from './slices/platformSlice';
10import { createScenarioSlice } from './slices/scenarioSlice';
11import {
12 registerGranularSyncFailureHandler,
13 registerSyncWarningsHandler,
14} from './syncQueue';
15import { ScenarioStore } from './types';
16
17export * from './types';
18export * from './utils';
19
20export const useScenarioStore = create<ScenarioStore>()(
21 immer((set, get, store) => ({
22 // Initial State
23 globalParameters: defaultGlobalParameters,
24 waveforms: [],
25 timings: [],
26 antennas: [],
27 platforms: [],
28 selectedItemId: null,
29 selectedComponentId: null,
30 isDirty: false,
31 isPlaying: false,
32 currentTime: 0,
33 targetPlaybackDuration: null,
34 isBackendSyncing: false,
35 backendVersion: 0,
36 scenarioFilePath: null,
37 outputDirectory: null,
38 antennaPreviewErrors: {},
39 notificationSnackbar: {
40 open: false,
41 message: '',
42 severity: 'error',
43 },
44 notificationQueue: [],
45 viewControlAction: { type: null, timestamp: 0 },
46 visibility: {
47 showAxes: true,
48 showPatterns: true,
49 showBoresights: true,
50 showLinks: true,
51 showLinkLabels: true,
52 showLinkMonostatic: true,
53 showLinkIlluminator: true,
54 showLinkScattered: true,
55 showLinkDirect: true,
56 showVelocities: true,
57 showPlatforms: true,
58 showPlatformLabels: true,
59 showMotionPaths: true,
60 },
61
62 // Slices
63 ...createAssetSlice(set, get, store),
64 ...createBackendSlice(set, get, store),
65 ...createPlatformSlice(set, get, store),
66 ...createScenarioSlice(set, get, store),
67
68 // Playback Actions
69 togglePlayPause: () =>
70 set((state) => ({ isPlaying: !state.isPlaying })),
71 setCurrentTime: (time) => {
72 const { start, end } = get().globalParameters;
73 const newTime =
74 typeof time === 'function' ? time(get().currentTime) : time;
75 // Clamp time to simulation bounds
76 const clampedTime = Math.max(start, Math.min(end, newTime));
77 set({ currentTime: clampedTime });
78 },
79 setTargetPlaybackDuration: (duration) =>
80 set({
81 targetPlaybackDuration:
82 duration !== null && duration > 0 ? duration : null,
83 }),
84 frameScene: () =>
85 set({
86 viewControlAction: { type: 'frame', timestamp: Date.now() },
87 }),
88 focusOnItem: (itemId) =>
89 set({
90 viewControlAction: {
91 type: 'focus',
92 targetId: itemId,
93 timestamp: Date.now(),
94 },
95 }),
96 toggleFollowItem: (itemId) => {
97 const currentAction = get().viewControlAction;
98 if (
99 currentAction.type === 'follow' &&
100 currentAction.targetId === itemId
101 ) {
102 set({
103 viewControlAction: { type: null, timestamp: Date.now() },
104 });
105 } else {
106 set({
107 viewControlAction: {
108 type: 'follow',
109 targetId: itemId,
110 timestamp: Date.now(),
111 },
112 });
113 }
114 },
115 clearViewControlAction: () =>
116 set((state) => {
117 if (state.viewControlAction.type !== 'follow') {
118 return {
119 viewControlAction: {
120 type: null,
121 timestamp: state.viewControlAction.timestamp,
122 },
123 };
124 }
125 return {};
126 }),
127 toggleLayer: (layer) =>
128 set((state) => {
129 state.visibility[layer] = !state.visibility[layer];
130 }),
131
132 // Notification Actions
133 showSuccess: (message) =>
134 set((state) => {
135 const notification = {
136 open: true,
137 message,
138 severity: 'success' as const,
139 };
140 if (
141 (state.notificationSnackbar.open &&
142 state.notificationSnackbar.message === message &&
143 state.notificationSnackbar.severity ===
144 notification.severity) ||
145 state.notificationQueue.some(
146 (queued) =>
147 queued.message === message &&
148 queued.severity === notification.severity
149 )
150 ) {
151 return;
152 }
153 if (!state.notificationSnackbar.open) {
154 state.notificationSnackbar = notification;
155 } else {
156 state.notificationQueue.push({
157 ...notification,
158 open: false,
159 });
160 }
161 }),
162 showError: (message) =>
163 set((state) => {
164 const notification = {
165 open: true,
166 message,
167 severity: 'error' as const,
168 };
169 if (
170 (state.notificationSnackbar.open &&
171 state.notificationSnackbar.message === message &&
172 state.notificationSnackbar.severity ===
173 notification.severity) ||
174 state.notificationQueue.some(
175 (queued) =>
176 queued.message === message &&
177 queued.severity === notification.severity
178 )
179 ) {
180 return;
181 }
182 if (!state.notificationSnackbar.open) {
183 state.notificationSnackbar = notification;
184 } else {
185 state.notificationQueue.push({
186 ...notification,
187 open: false,
188 });
189 }
190 }),
191 showWarning: (message) =>
192 set((state) => {
193 const notification = {
194 open: true,
195 message,
196 severity: 'warning' as const,
197 };
198 if (
199 (state.notificationSnackbar.open &&
200 state.notificationSnackbar.message === message &&
201 state.notificationSnackbar.severity ===
202 notification.severity) ||
203 state.notificationQueue.some(
204 (queued) =>
205 queued.message === message &&
206 queued.severity === notification.severity
207 )
208 ) {
209 return;
210 }
211 if (!state.notificationSnackbar.open) {
212 state.notificationSnackbar = notification;
213 } else {
214 state.notificationQueue.push({
215 ...notification,
216 open: false,
217 });
218 }
219 }),
220 hideNotification: () =>
221 set((state) => {
222 state.notificationSnackbar.open = false;
223 }),
224 advanceNotification: () =>
225 set((state) => {
226 const next = state.notificationQueue.shift();
227 if (next) {
228 state.notificationSnackbar = {
229 ...next,
230 open: true,
231 };
232 } else {
233 state.notificationSnackbar = {
234 ...state.notificationSnackbar,
235 open: false,
236 message: '',
237 };
238 }
239 }),
240 setAntennaPreviewError: (antennaId, message) =>
241 set((state) => {
242 state.antennaPreviewErrors[antennaId] = message;
243 }),
244 clearAntennaPreviewError: (antennaId) =>
245 set((state) => {
246 delete state.antennaPreviewErrors[antennaId];
247 }),
248 }))
249);
250
251function formatSyncError(error: unknown): string {
252 return error instanceof Error ? error.message : String(error);
253}
254
255registerGranularSyncFailureHandler(async ({ itemType, itemId, error }) => {
256 const syncMessage = `Sync error for ${itemType} ${itemId}: ${formatSyncError(error)}. Reverting to backend state.`;
257 useScenarioStore.getState().showError(syncMessage);
258
259 try {
260 await useScenarioStore.getState().fetchFromBackend();
261 } catch (reloadError) {
262 useScenarioStore
263 .getState()
264 .showError(
265 `${syncMessage} Failed to reload backend state: ${formatSyncError(reloadError)}`
266 );
267 }
268});
269
270registerSyncWarningsHandler((warnings) => {
271 const { showWarning } = useScenarioStore.getState();
272 warnings.forEach((warning) => showWarning(warning));
273});