@geoportallux/lux-3dviewer-plugin-statesync 1.0.0-dev

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ # v1.0.0
2
+
3
+ Document features and fixes
package/LICENSE.md ADDED
@@ -0,0 +1,14 @@
1
+ Copyright (C) 2026 author <email>
2
+
3
+ This program is free software: you can redistribute it and/or modify
4
+ it under the terms of the GNU General Public License as published by
5
+ the Free Software Foundation, either version 3 of the License, or
6
+ (at your option) any later version.
7
+
8
+ This program is distributed in the hope that it will be useful,
9
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ GNU General Public License for more details.
12
+
13
+ You should have received a copy of the GNU General Public License
14
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # lux-3dviewer-plugin-statesync
2
+
3
+ > Part of the [VC Map Project](https://github.com/virtualcitySYSTEMS/map-ui)
4
+
5
+ This plugin persists the current VC Map application state (active map, viewpoint, layer activation and styles, oblique collection, clipping polygons, plugin states) to the browser's `localStorage` and restores it on the next visit. It writes the same state used for sharable `?state=` URLs, throttled and only when something actually changes. A `?state=` URL keeps priority over the stored state, and state is preserved across a module reload (e.g. on login/logout), keeping layers that are temporarily unavailable.
6
+
7
+ ## Development
8
+
9
+ To further develop the plugin run: `npm start`
10
+
11
+ ## Config parameters
12
+
13
+ This plugin has no config parameters.
14
+
15
+ ## Deploy plugin within map-ui
16
+
17
+ - Add plugin dependency in desired version to `plugins/package.json`:
18
+
19
+ ```
20
+ "dependencies": {
21
+ ...
22
+ "@geoportallux/lux-3dviewer-plugin-statesync": "...",
23
+ ...
24
+ ```
25
+
26
+ - Add plugin to map-ui module configuration. List it **first** in the `plugins` array, ahead of any plugin that restores its own state from the `state` argument of `initialize(app, state)`, since this plugin must seed the cached state before those plugins are initialized:
27
+
28
+ ```
29
+ {
30
+ "name": "@geoportallux/lux-3dviewer-plugin-statesync",
31
+ "entry": "plugins/@geoportallux/lux-3dviewer-plugin-statesync/index.js",
32
+ },
33
+ ```
34
+
35
+ > Note: restore only takes effect for modules whose `_id` is stable across sessions. In the Geoportail viewer this is provided by the themesync plugin (stable `_id: 'catalogConfig'`), which triggers the restore once its module loads.
36
+
37
+ ## Build the npm package
38
+
39
+ Use the following commands to increase the version and push a new tag, which builds a new version as npm package:
40
+
41
+ ```shell
42
+ npm version 1.0.0 --no-git-tag-version
43
+ git add .
44
+ git commit -m "1.0.0"
45
+ git tag v1.0.0
46
+ git push origin main v1.0.0 # replace "origin" with your remote repo name
47
+ ```
package/dist/index.js ADDED
@@ -0,0 +1,204 @@
1
+ import { getFromLocalStorage as O, removeFromLocalStorage as _, setToLocalStorage as N } from "../../../assets/ui.js";
2
+ const l = "@geoportallux/lux-3dviewer-plugin-statesync", R = "1.0.0-dev", w = "^6.3", h = "state", K = 1e3;
3
+ function j(e) {
4
+ return "_cachedAppState" in e;
5
+ }
6
+ function z(e) {
7
+ return e._cachedAppState;
8
+ }
9
+ function V(e, t) {
10
+ e._cachedAppState = t;
11
+ }
12
+ function D(e) {
13
+ if (typeof e != "object" || e === null)
14
+ return !1;
15
+ const t = e;
16
+ return Array.isArray(t.moduleIds) && t.moduleIds.every((n) => typeof n == "string") && Array.isArray(t.layers) && t.layers.every(
17
+ (n) => typeof n == "object" && n !== null && typeof n.name == "string" && typeof n.active == "boolean"
18
+ ) && Array.isArray(t.plugins) && Array.isArray(t.clippingPolygons) && (t.activeMap === void 0 || typeof t.activeMap == "string") && (t.activeObliqueCollection === void 0 || typeof t.activeObliqueCollection == "string") && (t.activeViewpoint === void 0 || typeof t.activeViewpoint == "object" && t.activeViewpoint !== null);
19
+ }
20
+ const J = 7, L = 2, S = 3;
21
+ function c(e, t) {
22
+ const n = 10 ** t;
23
+ return Math.round(e * n) / n;
24
+ }
25
+ function E(e) {
26
+ return e.map(
27
+ (t, n) => c(t, n < 2 ? J : L)
28
+ );
29
+ }
30
+ function b(e) {
31
+ const t = { ...e };
32
+ return delete t.name, Array.isArray(e.cameraPosition) && (t.cameraPosition = E(e.cameraPosition)), Array.isArray(e.groundPosition) && (t.groundPosition = E(e.groundPosition)), typeof e.distance == "number" && (t.distance = c(e.distance, L)), typeof e.heading == "number" && (t.heading = c(e.heading, S)), typeof e.pitch == "number" && (t.pitch = c(e.pitch, S)), typeof e.roll == "number" && (t.roll = c(e.roll, S)), t;
33
+ }
34
+ function x(e) {
35
+ return e.activeViewpoint ? {
36
+ ...e,
37
+ activeViewpoint: b(e.activeViewpoint)
38
+ } : e;
39
+ }
40
+ function C() {
41
+ const e = O(l, h);
42
+ if (e) {
43
+ try {
44
+ const t = JSON.parse(e);
45
+ if (D(t))
46
+ return t;
47
+ } catch {
48
+ }
49
+ _(l, h);
50
+ }
51
+ }
52
+ function F(e) {
53
+ var n;
54
+ if (new URL(window.location.href).searchParams.has("state"))
55
+ return;
56
+ const t = C();
57
+ if (t) {
58
+ if (!j(e)) {
59
+ console.warn(
60
+ `${l}: VcsUiApp no longer exposes _cachedAppState, state restore is disabled.`
61
+ );
62
+ return;
63
+ }
64
+ (n = z(e)) != null && n.moduleIds.length || V(e, t);
65
+ }
66
+ }
67
+ function P(e, t, n) {
68
+ const o = new Set(e.map((i) => i.name)), a = t.filter(
69
+ (i) => !o.has(i.name) && !n(i.name)
70
+ );
71
+ return a.length ? [...e, ...a] : e;
72
+ }
73
+ function H(e) {
74
+ return e._id;
75
+ }
76
+ async function k(e) {
77
+ var n;
78
+ const t = e.maps.activeMap;
79
+ if (!t)
80
+ return "";
81
+ try {
82
+ const o = await t.getViewpoint();
83
+ if ((n = o == null ? void 0 : o.isValid) != null && n.call(o))
84
+ return JSON.stringify(b(o.toJSON()));
85
+ } catch {
86
+ }
87
+ return "";
88
+ }
89
+ function q(e) {
90
+ let t, n, o = !1, a, i = C();
91
+ async function I() {
92
+ a = void 0;
93
+ const r = await k(e), d = r !== n;
94
+ if (n = r, !(!o && !d)) {
95
+ o = !1;
96
+ try {
97
+ const s = await e.getState(!0);
98
+ i && (s.layers = P(
99
+ s.layers,
100
+ i.layers,
101
+ (p) => !!e.layers.getByKey(p)
102
+ ), s.clippingPolygons = P(
103
+ s.clippingPolygons,
104
+ i.clippingPolygons,
105
+ (p) => !!e.clippingPolygons.getByKey(p)
106
+ ));
107
+ const v = x(s), m = JSON.stringify(v);
108
+ m !== t && (N(l, h, m), t = m), i = v;
109
+ } catch {
110
+ }
111
+ }
112
+ }
113
+ function M(r) {
114
+ if (!i)
115
+ return;
116
+ const d = H(r);
117
+ d !== e.dynamicModuleId && V(e, {
118
+ moduleIds: [d],
119
+ layers: i.layers.map((s) => ({ ...s })),
120
+ clippingPolygons: i.clippingPolygons.map((s) => ({
121
+ ...s
122
+ })),
123
+ plugins: []
124
+ });
125
+ }
126
+ function A() {
127
+ a || (a = setTimeout(() => {
128
+ I().catch(() => {
129
+ });
130
+ }, K));
131
+ }
132
+ function f() {
133
+ o = !0, A();
134
+ }
135
+ let u;
136
+ function y(r) {
137
+ u == null || u(), u = r == null ? void 0 : r.postRender.addEventListener(A);
138
+ }
139
+ y(e.maps.activeMap);
140
+ const g = e.clippingPolygons.stateChanged, T = [
141
+ e.layers.stateChanged.addEventListener(f),
142
+ g == null ? void 0 : g.addEventListener(f),
143
+ e.maps.mapActivated.addEventListener((r) => {
144
+ y(r), f();
145
+ }),
146
+ e.moduleRemoved.addEventListener(M)
147
+ ].filter((r) => !!r);
148
+ return () => {
149
+ a && (clearTimeout(a), a = void 0), y(null), T.forEach((r) => {
150
+ r();
151
+ });
152
+ };
153
+ }
154
+ function G() {
155
+ let e;
156
+ return {
157
+ get name() {
158
+ return l;
159
+ },
160
+ get version() {
161
+ return R;
162
+ },
163
+ get mapVersion() {
164
+ return w;
165
+ },
166
+ initialize(t) {
167
+ return F(t), Promise.resolve();
168
+ },
169
+ onVcsAppMounted(t) {
170
+ e = q(t);
171
+ },
172
+ /**
173
+ * should return all default values of the configuration
174
+ */
175
+ getDefaultOptions() {
176
+ return {};
177
+ },
178
+ /**
179
+ * should return the plugin's serialization excluding all default values
180
+ */
181
+ toJSON() {
182
+ return {};
183
+ },
184
+ /**
185
+ * should return the plugins state
186
+ * @returns {PluginState}
187
+ */
188
+ getState() {
189
+ return {};
190
+ },
191
+ /**
192
+ * components for configuring the plugin and/ or custom items defined by the plugin
193
+ */
194
+ getConfigEditors() {
195
+ return [];
196
+ },
197
+ destroy() {
198
+ e == null || e(), e = void 0;
199
+ }
200
+ };
201
+ }
202
+ export {
203
+ G as default
204
+ };
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@geoportallux/lux-3dviewer-plugin-statesync",
3
+ "version": "1.0.0-dev",
4
+ "description": "",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "scripts": {
8
+ "prepublishOnly": "vcmplugin build",
9
+ "build": "vcmplugin build",
10
+ "bundle": "vcmplugin bundle",
11
+ "start": "vcmplugin serve",
12
+ "preview": "vcmplugin preview",
13
+ "buildStagingApp": "vcmplugin buildStagingApp",
14
+ "lint:js": "eslint . --ext .vue,.js,.cjs,.mjs,.ts,.cts,.mts",
15
+ "lint:prettier": "prettier --check .",
16
+ "lint": "npm run lint:js && npm run lint:prettier",
17
+ "format": "prettier --write --list-different . && npm run lint:js -- --fix",
18
+ "test": "vitest",
19
+ "coverage": "vitest run --coverage",
20
+ "type-check": "vue-tsc --noEmit",
21
+ "ensure-types": "vcmplugin ensure-types"
22
+ },
23
+ "author": "author <email>",
24
+ "license": "GPL-3.0",
25
+ "overrides": {
26
+ "esbuild": "^0.25.0",
27
+ "@zip.js/zip.js": "2.7.68"
28
+ },
29
+ "keywords": [
30
+ "vcmap",
31
+ "plugin"
32
+ ],
33
+ "files": [
34
+ "src/",
35
+ "dist/",
36
+ "plugin-assets/",
37
+ "LICENSE.md",
38
+ "README.md",
39
+ "CHANGELOG.md"
40
+ ],
41
+ "exports": {
42
+ ".": "dist/index.js",
43
+ "./dist": "./dist/index.js"
44
+ },
45
+ "prettier": "@vcsuite/eslint-config/prettier.js",
46
+ "peerDependencies": {
47
+ "@vcmap/core": "^6.3.8",
48
+ "@vcmap/ui": "^6.3.8"
49
+ },
50
+ "devDependencies": {
51
+ "@vcmap/plugin-cli": "^4.2.0",
52
+ "@vcsuite/eslint-config": "^4.1.0",
53
+ "@vitest/coverage-v8": "^4.1.8",
54
+ "jest-canvas-mock": "^2.5.2",
55
+ "jsdom": "^29.1.1",
56
+ "resize-observer-polyfill": "^1.5.1",
57
+ "typescript": "^5.9.3",
58
+ "vitest": "^4.1.8",
59
+ "vue-tsc": "^3.3.4"
60
+ },
61
+ "mapVersion": "^6.3"
62
+ }
package/src/index.ts ADDED
@@ -0,0 +1,61 @@
1
+ import type { VcsPlugin, VcsUiApp, PluginConfigEditor } from '@vcmap/ui';
2
+ import { name, version, mapVersion } from '../package.json';
3
+ import { restoreStateFromLocalStorage, startStateSync } from './stateSync.js';
4
+
5
+ type PluginConfig = Record<never, never>;
6
+ type PluginState = Record<never, never>;
7
+
8
+ type StateSyncPlugin = VcsPlugin<PluginConfig, PluginState>;
9
+
10
+ export default function plugin(): StateSyncPlugin {
11
+ let stopStateSync: (() => void) | undefined;
12
+
13
+ return {
14
+ get name(): string {
15
+ return name;
16
+ },
17
+ get version(): string {
18
+ return version;
19
+ },
20
+ get mapVersion(): string {
21
+ return mapVersion;
22
+ },
23
+ initialize(vcsUiApp: VcsUiApp): Promise<void> {
24
+ // must run synchronously, before any module the state applies to is loaded
25
+ restoreStateFromLocalStorage(vcsUiApp);
26
+ return Promise.resolve();
27
+ },
28
+ onVcsAppMounted(vcsUiApp: VcsUiApp): void {
29
+ stopStateSync = startStateSync(vcsUiApp);
30
+ },
31
+ /**
32
+ * should return all default values of the configuration
33
+ */
34
+ getDefaultOptions(): PluginConfig {
35
+ return {};
36
+ },
37
+ /**
38
+ * should return the plugin's serialization excluding all default values
39
+ */
40
+ toJSON(): PluginConfig {
41
+ return {};
42
+ },
43
+ /**
44
+ * should return the plugins state
45
+ * @returns {PluginState}
46
+ */
47
+ getState(): PluginState {
48
+ return {};
49
+ },
50
+ /**
51
+ * components for configuring the plugin and/ or custom items defined by the plugin
52
+ */
53
+ getConfigEditors(): PluginConfigEditor<object>[] {
54
+ return [];
55
+ },
56
+ destroy(): void {
57
+ stopStateSync?.();
58
+ stopStateSync = undefined;
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,349 @@
1
+ import type {
2
+ VcsEvent,
3
+ VcsMap,
4
+ VcsModule,
5
+ ViewpointOptions,
6
+ } from '@vcmap/core';
7
+ import type { AppState, VcsUiApp } from '@vcmap/ui';
8
+ import {
9
+ getFromLocalStorage,
10
+ removeFromLocalStorage,
11
+ setToLocalStorage,
12
+ } from '@vcmap/ui';
13
+ import { name } from '../package.json';
14
+
15
+ /** localStorage key, prefixed by the plugin name: `${name}_${STATE_KEY}` */
16
+ export const STATE_KEY = 'state';
17
+
18
+ const PERSIST_THROTTLE_MS = 1000;
19
+
20
+ // the cached app state is a soft-private member of VcsUiApp, applied module by
21
+ // module while modules are loading — the same mechanism as the state URL parameter
22
+ /* eslint-disable no-underscore-dangle, @typescript-eslint/naming-convention */
23
+ type CachedStateContainer = { _cachedAppState?: AppState };
24
+
25
+ function hasCachedAppState(app: VcsUiApp): boolean {
26
+ return '_cachedAppState' in (app as unknown as CachedStateContainer);
27
+ }
28
+
29
+ export function getCachedAppState(app: VcsUiApp): AppState | undefined {
30
+ return (app as unknown as CachedStateContainer)._cachedAppState;
31
+ }
32
+
33
+ export function setCachedAppState(app: VcsUiApp, state: AppState): void {
34
+ (app as unknown as CachedStateContainer)._cachedAppState = state;
35
+ }
36
+ /* eslint-enable no-underscore-dangle, @typescript-eslint/naming-convention */
37
+
38
+ function isAppState(value: unknown): value is AppState {
39
+ if (typeof value !== 'object' || value === null) {
40
+ return false;
41
+ }
42
+ const state = value as Partial<AppState>;
43
+ return (
44
+ Array.isArray(state.moduleIds) &&
45
+ state.moduleIds.every((id) => typeof id === 'string') &&
46
+ Array.isArray(state.layers) &&
47
+ state.layers.every(
48
+ (layer) =>
49
+ typeof layer === 'object' &&
50
+ layer !== null &&
51
+ typeof layer.name === 'string' &&
52
+ typeof layer.active === 'boolean',
53
+ ) &&
54
+ Array.isArray(state.plugins) &&
55
+ Array.isArray(state.clippingPolygons) &&
56
+ (state.activeMap === undefined || typeof state.activeMap === 'string') &&
57
+ (state.activeObliqueCollection === undefined ||
58
+ typeof state.activeObliqueCollection === 'string') &&
59
+ (state.activeViewpoint === undefined ||
60
+ (typeof state.activeViewpoint === 'object' &&
61
+ state.activeViewpoint !== null))
62
+ );
63
+ }
64
+
65
+ // the active viewpoint is read from the live camera on every getState call.
66
+ // It is volatile in two ways: it gets a fresh uuid `name` each call, and its
67
+ // floating point values jitter in their last digits frame to frame. Dropping
68
+ // the name and rounding the numbers keeps an idle camera serializing
69
+ // identically, so a re-render alone does not trigger a write — coordinates to
70
+ // ~1cm, angles to ~0.001°.
71
+ const COORDINATE_DECIMALS = 7;
72
+ const HEIGHT_DECIMALS = 2;
73
+ const ANGLE_DECIMALS = 3;
74
+
75
+ function roundTo(value: number, decimals: number): number {
76
+ const factor = 10 ** decimals;
77
+ return Math.round(value * factor) / factor;
78
+ }
79
+
80
+ function roundCoordinate(coordinate: number[]): number[] {
81
+ return coordinate.map((value, index) =>
82
+ roundTo(value, index < 2 ? COORDINATE_DECIMALS : HEIGHT_DECIMALS),
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Returns a copy of the viewpoint without its volatile uuid name and with its
88
+ * numbers rounded, so an unchanged view yields a stable serialization.
89
+ */
90
+ export function normalizeViewpoint(
91
+ viewpoint: ViewpointOptions,
92
+ ): ViewpointOptions {
93
+ const normalized = { ...viewpoint };
94
+ // a camera-derived viewpoint gets a fresh uuid name on every read
95
+ delete normalized.name;
96
+ if (Array.isArray(viewpoint.cameraPosition)) {
97
+ normalized.cameraPosition = roundCoordinate(viewpoint.cameraPosition);
98
+ }
99
+ if (Array.isArray(viewpoint.groundPosition)) {
100
+ normalized.groundPosition = roundCoordinate(viewpoint.groundPosition);
101
+ }
102
+ if (typeof viewpoint.distance === 'number') {
103
+ normalized.distance = roundTo(viewpoint.distance, HEIGHT_DECIMALS);
104
+ }
105
+ if (typeof viewpoint.heading === 'number') {
106
+ normalized.heading = roundTo(viewpoint.heading, ANGLE_DECIMALS);
107
+ }
108
+ if (typeof viewpoint.pitch === 'number') {
109
+ normalized.pitch = roundTo(viewpoint.pitch, ANGLE_DECIMALS);
110
+ }
111
+ if (typeof viewpoint.roll === 'number') {
112
+ normalized.roll = roundTo(viewpoint.roll, ANGLE_DECIMALS);
113
+ }
114
+ return normalized;
115
+ }
116
+
117
+ /**
118
+ * Returns a copy of the state with the active viewpoint normalized, so an
119
+ * unchanged view yields a stable serialization for change detection.
120
+ */
121
+ export function normalizeState(state: AppState): AppState {
122
+ if (!state.activeViewpoint) {
123
+ return state;
124
+ }
125
+ return {
126
+ ...state,
127
+ activeViewpoint: normalizeViewpoint(state.activeViewpoint),
128
+ };
129
+ }
130
+
131
+ export function readStoredState(): AppState | undefined {
132
+ const json = getFromLocalStorage(name, STATE_KEY);
133
+ if (!json) {
134
+ return undefined;
135
+ }
136
+ try {
137
+ const parsed: unknown = JSON.parse(json);
138
+ if (isAppState(parsed)) {
139
+ return parsed;
140
+ }
141
+ } catch {
142
+ // invalid JSON, cleaned up below
143
+ }
144
+ removeFromLocalStorage(name, STATE_KEY);
145
+ return undefined;
146
+ }
147
+
148
+ /**
149
+ * Restores the app state persisted in a previous session by injecting it into
150
+ * the app's cached state, which the app applies module by module — the same
151
+ * mechanism used for the `state` URL parameter. The URL keeps highest
152
+ * priority: nothing is restored when a `state` URL parameter is present.
153
+ * Must be called synchronously in the plugin's `initialize`, before any module
154
+ * the state applies to has finished loading.
155
+ */
156
+ export function restoreStateFromLocalStorage(app: VcsUiApp): void {
157
+ if (new URL(window.location.href).searchParams.has('state')) {
158
+ return;
159
+ }
160
+ const stored = readStoredState();
161
+ if (!stored) {
162
+ return;
163
+ }
164
+ if (!hasCachedAppState(app)) {
165
+ // eslint-disable-next-line no-console
166
+ console.warn(
167
+ `${name}: VcsUiApp no longer exposes _cachedAppState, state restore is disabled.`,
168
+ );
169
+ return;
170
+ }
171
+ if (getCachedAppState(app)?.moduleIds.length) {
172
+ // a state is already cached (e.g. from the URL), never clobber it
173
+ return;
174
+ }
175
+ setCachedAppState(app, stored);
176
+ }
177
+
178
+ /**
179
+ * Returns `current`, with entries from `previous` appended for names that are
180
+ * not in `current` and no longer exist in the app. Used to keep the persisted
181
+ * state of layers/clipping polygons that are temporarily absent (e.g. a layer
182
+ * only available while logged in), so logging out does not drop their state.
183
+ */
184
+ function preserveAbsentEntries<T extends { name: string }>(
185
+ current: T[],
186
+ previous: T[],
187
+ exists: (name: string) => boolean,
188
+ ): T[] {
189
+ const currentNames = new Set(current.map((entry) => entry.name));
190
+ const kept = previous.filter(
191
+ (entry) => !currentNames.has(entry.name) && !exists(entry.name),
192
+ );
193
+ return kept.length ? [...current, ...kept] : current;
194
+ }
195
+
196
+ function getModuleId(module: VcsModule): string {
197
+ return module._id;
198
+ }
199
+
200
+ async function readViewpointKey(app: VcsUiApp): Promise<string> {
201
+ const map = app.maps.activeMap;
202
+ if (!map) {
203
+ return '';
204
+ }
205
+ try {
206
+ const viewpoint = await map.getViewpoint();
207
+ if (viewpoint?.isValid?.()) {
208
+ return JSON.stringify(normalizeViewpoint(viewpoint.toJSON()));
209
+ }
210
+ } catch {
211
+ // ignore: no valid viewpoint yet
212
+ }
213
+ return '';
214
+ }
215
+
216
+ /**
217
+ * Continuously persists the app state to the localStorage, throttled to one
218
+ * write per second. Returns a dispose function removing all listeners.
219
+ *
220
+ * The map fires postRender every frame, but a full `getState()` is expensive
221
+ * (it scans every layer and module) and is only meaningful when something
222
+ * changed. So a render only triggers a cheap viewpoint read; the full
223
+ * `getState()` runs only when the view actually moved or a discrete event
224
+ * (layer/clipping polygon/map change) requested it.
225
+ *
226
+ * State is also preserved across a module reload (e.g. the themesync plugin
227
+ * removing and re-adding its module on login/logout): when a module is removed,
228
+ * the last known layer/clipping-polygon states are re-seeded into the app's
229
+ * cached state, so the re-added module re-applies them. Entries for layers that
230
+ * are temporarily absent (only available while logged in) are kept in the
231
+ * persisted state instead of being dropped.
232
+ */
233
+ export function startStateSync(app: VcsUiApp): () => void {
234
+ let lastWritten: string | undefined;
235
+ let lastViewpointKey: string | undefined;
236
+ let forced = false;
237
+ let timer: ReturnType<typeof setTimeout> | undefined;
238
+ // last persisted state, used both as the merge baseline and to re-seed the
239
+ // cached state on a module reload. Initialised from the stored state so a
240
+ // reload that happens before the first persist is still covered.
241
+ let lastState: AppState | undefined = readStoredState();
242
+
243
+ async function tick(): Promise<void> {
244
+ timer = undefined;
245
+ const viewpointKey = await readViewpointKey(app);
246
+ const viewpointChanged = viewpointKey !== lastViewpointKey;
247
+ lastViewpointKey = viewpointKey;
248
+ if (!forced && !viewpointChanged) {
249
+ return;
250
+ }
251
+ forced = false;
252
+ try {
253
+ const state = await app.getState(true);
254
+ if (lastState) {
255
+ state.layers = preserveAbsentEntries(
256
+ state.layers,
257
+ lastState.layers,
258
+ (layerName) => !!app.layers.getByKey(layerName),
259
+ );
260
+ state.clippingPolygons = preserveAbsentEntries(
261
+ state.clippingPolygons,
262
+ lastState.clippingPolygons,
263
+ (polygonName) => !!app.clippingPolygons.getByKey(polygonName),
264
+ );
265
+ }
266
+ const normalized = normalizeState(state);
267
+ const json = JSON.stringify(normalized);
268
+ if (json !== lastWritten) {
269
+ setToLocalStorage(name, STATE_KEY, json);
270
+ lastWritten = json;
271
+ }
272
+ lastState = normalized;
273
+ } catch {
274
+ // getState throws as long as no map is active yet
275
+ }
276
+ }
277
+
278
+ // Re-seed the cached state before a removed module is re-added, so the app
279
+ // re-applies the layer and clipping-polygon states through its own startup
280
+ // mechanism. Layers/polygons that no longer exist are skipped by the app.
281
+ // Viewpoint and active map are intentionally omitted: a module reload does
282
+ // not move the camera, and re-applying them could cause a jump.
283
+ function seedReloadState(removedModule: VcsModule): void {
284
+ if (!lastState) {
285
+ return;
286
+ }
287
+ const moduleId = getModuleId(removedModule);
288
+ if (moduleId === app.dynamicModuleId) {
289
+ return;
290
+ }
291
+ setCachedAppState(app, {
292
+ moduleIds: [moduleId],
293
+ layers: lastState.layers.map((layer) => ({ ...layer })),
294
+ clippingPolygons: lastState.clippingPolygons.map((polygon) => ({
295
+ ...polygon,
296
+ })),
297
+ plugins: [],
298
+ });
299
+ }
300
+
301
+ function schedule(): void {
302
+ if (!timer) {
303
+ timer = setTimeout(() => {
304
+ tick().catch(() => {});
305
+ }, PERSIST_THROTTLE_MS);
306
+ }
307
+ }
308
+
309
+ function scheduleForced(): void {
310
+ forced = true;
311
+ schedule();
312
+ }
313
+
314
+ let postRenderListener: (() => void) | undefined;
315
+ function bindPostRender(map: VcsMap | null): void {
316
+ postRenderListener?.();
317
+ postRenderListener = map?.postRender.addEventListener(schedule);
318
+ }
319
+ bindPostRender(app.maps.activeMap);
320
+
321
+ // typed as a plain OverrideCollection on VcsApp, but the underlying
322
+ // ClippingPolygonObjectCollection raises stateChanged
323
+ const clippingPolygonsStateChanged = (
324
+ app.clippingPolygons as unknown as {
325
+ stateChanged?: VcsEvent<unknown>;
326
+ }
327
+ ).stateChanged;
328
+
329
+ const listeners = [
330
+ app.layers.stateChanged.addEventListener(scheduleForced),
331
+ clippingPolygonsStateChanged?.addEventListener(scheduleForced),
332
+ app.maps.mapActivated.addEventListener((map) => {
333
+ bindPostRender(map);
334
+ scheduleForced();
335
+ }),
336
+ app.moduleRemoved.addEventListener(seedReloadState),
337
+ ].filter((listener) => !!listener);
338
+
339
+ return () => {
340
+ if (timer) {
341
+ clearTimeout(timer);
342
+ timer = undefined;
343
+ }
344
+ bindPostRender(null);
345
+ listeners.forEach((unlisten) => {
346
+ unlisten();
347
+ });
348
+ };
349
+ }