1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
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';
12 registerGranularSyncFailureHandler,
13 registerSyncWarningsHandler,
15import { ScenarioStore } from './types';
17export * from './types';
18export * from './utils';
20export const useScenarioStore = create<ScenarioStore>()(
21 immer((set, get, store) => ({
23 globalParameters: defaultGlobalParameters,
29 selectedComponentId: null,
33 targetPlaybackDuration: null,
34 isBackendSyncing: false,
36 scenarioFilePath: null,
37 outputDirectory: null,
38 antennaPreviewErrors: {},
39 notificationSnackbar: {
44 notificationQueue: [],
45 viewControlAction: { type: null, timestamp: 0 },
52 showLinkMonostatic: true,
53 showLinkIlluminator: true,
54 showLinkScattered: true,
58 showPlatformLabels: true,
59 showMotionPaths: true,
63 ...createAssetSlice(set, get, store),
64 ...createBackendSlice(set, get, store),
65 ...createPlatformSlice(set, get, store),
66 ...createScenarioSlice(set, get, store),
69 togglePlayPause: () =>
70 set((state) => ({ isPlaying: !state.isPlaying })),
71 setCurrentTime: (time) => {
72 const { start, end } = get().globalParameters;
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 });
79 setTargetPlaybackDuration: (duration) =>
81 targetPlaybackDuration:
82 duration !== null && duration > 0 ? duration : null,
86 viewControlAction: { type: 'frame', timestamp: Date.now() },
88 focusOnItem: (itemId) =>
93 timestamp: Date.now(),
96 toggleFollowItem: (itemId) => {
97 const currentAction = get().viewControlAction;
99 currentAction.type === 'follow' &&
100 currentAction.targetId === itemId
103 viewControlAction: { type: null, timestamp: Date.now() },
110 timestamp: Date.now(),
115 clearViewControlAction: () =>
117 if (state.viewControlAction.type !== 'follow') {
121 timestamp: state.viewControlAction.timestamp,
127 toggleLayer: (layer) =>
129 state.visibility[layer] = !state.visibility[layer];
132 // Notification Actions
133 showSuccess: (message) =>
135 const notification = {
138 severity: 'success' as const,
141 (state.notificationSnackbar.open &&
142 state.notificationSnackbar.message === message &&
143 state.notificationSnackbar.severity ===
144 notification.severity) ||
145 state.notificationQueue.some(
147 queued.message === message &&
148 queued.severity === notification.severity
153 if (!state.notificationSnackbar.open) {
154 state.notificationSnackbar = notification;
156 state.notificationQueue.push({
162 showError: (message) =>
164 const notification = {
167 severity: 'error' as const,
170 (state.notificationSnackbar.open &&
171 state.notificationSnackbar.message === message &&
172 state.notificationSnackbar.severity ===
173 notification.severity) ||
174 state.notificationQueue.some(
176 queued.message === message &&
177 queued.severity === notification.severity
182 if (!state.notificationSnackbar.open) {
183 state.notificationSnackbar = notification;
185 state.notificationQueue.push({
191 showWarning: (message) =>
193 const notification = {
196 severity: 'warning' as const,
199 (state.notificationSnackbar.open &&
200 state.notificationSnackbar.message === message &&
201 state.notificationSnackbar.severity ===
202 notification.severity) ||
203 state.notificationQueue.some(
205 queued.message === message &&
206 queued.severity === notification.severity
211 if (!state.notificationSnackbar.open) {
212 state.notificationSnackbar = notification;
214 state.notificationQueue.push({
220 hideNotification: () =>
222 state.notificationSnackbar.open = false;
224 advanceNotification: () =>
226 const next = state.notificationQueue.shift();
228 state.notificationSnackbar = {
233 state.notificationSnackbar = {
234 ...state.notificationSnackbar,
240 setAntennaPreviewError: (antennaId, message) =>
242 state.antennaPreviewErrors[antennaId] = message;
244 clearAntennaPreviewError: (antennaId) =>
246 delete state.antennaPreviewErrors[antennaId];
251function formatSyncError(error: unknown): string {
252 return error instanceof Error ? error.message : String(error);
255registerGranularSyncFailureHandler(async ({ itemType, itemId, error }) => {
256 const syncMessage = `Sync error for ${itemType} ${itemId}: ${formatSyncError(error)}. Reverting to backend state.`;
257 useScenarioStore.getState().showError(syncMessage);
260 await useScenarioStore.getState().fetchFromBackend();
261 } catch (reloadError) {
265 `${syncMessage} Failed to reload backend state: ${formatSyncError(reloadError)}`
270registerSyncWarningsHandler((warnings) => {
271 const { showWarning } = useScenarioStore.getState();
272 warnings.forEach((warning) => showWarning(warning));