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 { ScenarioStore } from './types';
7import { defaultGlobalParameters } from './defaults';
8import { createAssetSlice } from './slices/assetSlice';
9import { createBackendSlice } from './slices/backendSlice';
10import { createPlatformSlice } from './slices/platformSlice';
11import { createScenarioSlice } from './slices/scenarioSlice';
12export * from './types';
13export * from './utils';
15export const useScenarioStore = create<ScenarioStore>()(
16 immer((set, get, store) => ({
18 globalParameters: defaultGlobalParameters,
27 targetPlaybackDuration: null,
29 isBackendSyncing: false,
35 viewControlAction: { type: null, timestamp: 0 },
42 showLinkMonostatic: true,
43 showLinkIlluminator: true,
44 showLinkScattered: true,
48 showPlatformLabels: true,
49 showMotionPaths: true,
53 ...createAssetSlice(set, get, store),
54 ...createBackendSlice(set, get, store),
55 ...createPlatformSlice(set, get, store),
56 ...createScenarioSlice(set, get, store),
59 togglePlayPause: () =>
60 set((state) => ({ isPlaying: !state.isPlaying })),
61 setCurrentTime: (time) => {
62 const { start, end } = get().globalParameters;
64 typeof time === 'function' ? time(get().currentTime) : time;
65 // Clamp time to simulation bounds
66 const clampedTime = Math.max(start, Math.min(end, newTime));
67 set({ currentTime: clampedTime });
69 setTargetPlaybackDuration: (duration) =>
71 targetPlaybackDuration:
72 duration !== null && duration > 0 ? duration : null,
74 setIsSimulating: (isSimulating) => set({ isSimulating }),
78 viewControlAction: { type: 'frame', timestamp: Date.now() },
80 focusOnItem: (itemId) =>
85 timestamp: Date.now(),
88 toggleFollowItem: (itemId) => {
89 const currentAction = get().viewControlAction;
91 currentAction.type === 'follow' &&
92 currentAction.targetId === itemId
95 viewControlAction: { type: null, timestamp: Date.now() },
102 timestamp: Date.now(),
107 clearViewControlAction: () =>
109 if (state.viewControlAction.type !== 'follow') {
113 timestamp: state.viewControlAction.timestamp,
119 toggleLayer: (layer) =>
121 state.visibility[layer] = !state.visibility[layer];
125 showError: (message) => set({ errorSnackbar: { open: true, message } }),
128 errorSnackbar: { ...state.errorSnackbar, open: false },
133let debounceTimer: ReturnType<typeof setTimeout>;
135useScenarioStore.subscribe((state, prevState) => {
136 // Check for structural changes in the scenario data using reference equality.
137 // Immer ensures that if data changes, the array/object reference changes.
138 const hasStructuralChanges =
139 state.platforms !== prevState.platforms ||
140 state.antennas !== prevState.antennas ||
141 state.waveforms !== prevState.waveforms ||
142 state.timings !== prevState.timings ||
143 state.globalParameters !== prevState.globalParameters;
145 // We only trigger sync if data changed AND we aren't currently simulating/playing
146 // (though simulation usually locks UI, checking prevents edge cases).
147 if (hasStructuralChanges && !state.isSimulating) {
148 if (debounceTimer) clearTimeout(debounceTimer);
150 debounceTimer = setTimeout(() => {
151 // Trigger the sync action defined in backendSlice
152 void state.syncBackend();