@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 +3 -0
- package/LICENSE.md +14 -0
- package/README.md +47 -0
- package/dist/index.js +204 -0
- package/package.json +62 -0
- package/src/index.ts +61 -0
- package/src/stateSync.ts +349 -0
package/CHANGELOG.md
ADDED
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
|
+
}
|
package/src/stateSync.ts
ADDED
|
@@ -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
|
+
}
|